package main import ( "flag" "fmt" "io" "math" "strconv" "strings" "textmachine/backend/internal/bookfile" "textmachine/backend/internal/pipeline" ) // invocation.go: CLI argument parsing, extracted into a pure function (package №4 — // tmctl had 0 tests for the contract invariants: bad-flag→exit 1, not 2; // the redrive selector; validation order). The flag/error-text contract is FROZEN // (D12/D15.3) — this function merely relocates it to a testable place. // invocation is one parsed tmctl command line. type invocation struct { cmd string cfgPath string seedPath string // seed-lint: the glossary seed YAML to validate (no --config) // decisionsPath is the bank-apply decision document (the JSON the platform hands the engine). decisionsPath string // dryRun is bank-apply's projection mode. redrive keeps its own copy inside sel (a selector field), // and the two are the same flag with the same meaning: report, touch nothing. dryRun bool resnapshot bool acceptRebill pipeline.RebillConsent // --accept-rebill[=usd]: Р6 consent to a projected re-payment asJSON bool asPlaintext bool asPairs bool // export: include the source column (--pairs) for the DC1/DC2 FP-measure // verifyBank is the bank-verification FLAG (pack-20 / D39.42 п.5 · D39.144): stop at the bank-mining // boundary and show the table when the delta holds never-presented terms, instead of carrying an // unsigned bank into the edit wave. CLI-only and BINARY — «флаг бинарный» (owner) — and deliberately // not a book.yaml key: whether a human is available to review right now is a property of the // invocation, not of the book. verifyBank bool // keysFile is the DEPLOYMENT's provider-key file (backlog row 211), empty when the flag was absent. // `translate` only: the $0 read commands must not merely tolerate keys, they must not demand them at // all (D20.4), and a flag they accept is a flag a caller will eventually be required to pass. keysFile string // ceilingUSD is the BOOK ceiling for THIS RUN ONLY (row 145 / D39.110), 0 when the flag was absent. // It is an invocation property for the same reason verifyBank is: the amount a caller is willing to // spend on one run belongs to that run, not to the book's data — writing it into book.yaml would make // a caller's number a permanent record in the engine's config and mix the zones (D39.81/D39.85). ceilingUSD float64 // maxUnits is the VOLUME ceiling for THIS RUN (D39.165 §1б), 0 when the flag was absent. It is an // invocation property for exactly the reason ceilingUSD is — how much of the book a caller is buying // right now belongs to the call, not to the book's data. maxUnits int // buildFormats / buildOut / buildPartial are `build`'s own (backlog row 236): which reader's copies // to write, where the one file goes when the caller names a place, and whether a book with holes is // written marked rather than refused. Invocation properties like the flags above — a caller's // choice for one build, not a fact about the book. buildFormats []string buildOut string buildPartial bool sel pipeline.RedriveSelector } // rebillConsentValue parses `--accept-rebill[=usd]` (D20.2-Q2): the OPTIONAL-VALUE form of Р6, where a // bare flag consents to the whole projected re-payment and `--accept-rebill=1.50` consents only while // the projection stays at or below $1.50. It is a flag.Value with IsBoolFlag, which is the only way the // stdlib grants a flag an optional value: bare, the package calls Set("true"). type rebillConsentValue struct{ c pipeline.RebillConsent } func (v *rebillConsentValue) String() string { switch { case v == nil || !v.c.Given: return "false" case v.c.Capped: return strconv.FormatFloat(v.c.CapUSD, 'g', -1, 64) default: return "true" } } func (v *rebillConsentValue) Set(s string) error { switch s { case "true": v.c = pipeline.RebillConsent{Given: true} return nil case "false": v.c = pipeline.RebillConsent{} return nil } usd, err := strconv.ParseFloat(s, 64) if err != nil || math.IsNaN(usd) || math.IsInf(usd, 0) || usd < 0 { return fmt.Errorf("--accept-rebill takes a non-negative USD ceiling (--accept-rebill=1.50) or no value at all; got %q", s) } v.c = pipeline.RebillConsent{Given: true, Capped: true, CapUSD: usd} return nil } // IsBoolFlag lets `--accept-rebill` stand alone. The cost of that stdlib affordance is that // `--accept-rebill 1.50` does NOT bind the amount — the flag reads as bare and 1.50 becomes a stray // argument — which would silently turn a capped consent into an unlimited one. parseInvocation refuses // that spelling explicitly rather than let it read as consent to everything. func (v *rebillConsentValue) IsBoolFlag() bool { return true } // parseInvocation parses os.Args[1:] into an invocation. flagOut receives the // stdlib's flag diagnostics ("flag provided but not defined" + usage) — main // passes os.Stderr, tests a buffer; the bytes and their destination are part of // the frozen contract. // // Deliberately preserved quirks (changing them = changing the contract): // - the command NAME is not validated here — `tmctl bogus --config x` reaches // the dispatch switch (after dotenv/ctx setup) and errors there, while // `tmctl bogus` without --config errors "--config book.yaml is required"; // - ContinueOnError, NOT ExitOnError: the stdlib's ExitOnError would call // os.Exit(2) on a bad flag and would COLLAPSE onto exit code 2 // (completed-with-flags); returning an error routes the bad flag into // main()'s default branch → exit 1, leaving 2 exclusive to flagged chunks. func parseInvocation(args []string, flagOut io.Writer) (invocation, error) { if len(args) < 1 { // The command list is the dispatch switch's, in full (row 176): `backup` and `migrate` are // deploy-step commands and `seed-lint` is the $0 seed validator, and a usage line that omits them // tells the operator they do not exist. seed-lint and bank-apply are on their own clauses because // their arguments differ — seed-lint takes --seed and no --config, bank-apply needs --decisions — // and folding them into the first clause would be the same class of lie. return invocation{}, fmt.Errorf("usage: tmctl --config book.yaml | tmctl bank-apply --config book.yaml --decisions decisions.json | tmctl seed-lint --seed glossary.yaml") } cmd, rest := args[0], args[1:] fs := flag.NewFlagSet(cmd, flag.ContinueOnError) fs.SetOutput(flagOut) cfgPath := fs.String("config", "", "path to book.yaml") resnapshot := fs.Bool("resnapshot", false, "re-pin existing jobs to the current config snapshot (re-translates already-paid chunks — explicit consent)") acceptRebill := &rebillConsentValue{} fs.Var(acceptRebill, "accept-rebill", "translate/redrive: consent to the projected RE-PAYMENT of already-billed work (D20.2-Q2). Bare accepts the whole projected amount; --accept-rebill=1.50 accepts it only up to $1.50") asJSON := fs.Bool("json", false, "status: emit the projection as JSON (stable disposition/flag_reason enums) for CI/IDE; manifest: emit the manifest document instead of a summary") asPlaintext := fs.Bool("plaintext", false, "export: emit the concatenated human text instead of the default stable JSON") asPairs := fs.Bool("pairs", false, "export: include the source text per chunk (src↔target column for the DC1/DC2 FP-measure, WS5)") chapter := fs.Int("chapter", -1, "redrive: restrict to this chapter (default: any)") chunk := fs.Int("chunk", -1, "redrive: restrict to this chunk index within the chapter (default: any)") reason := fs.String("reason", "", "redrive: restrict to this flag_reason (default: any)") dryRun := fs.Bool("dry-run", false, "redrive: report what would be re-attacked without touching anything; bank-apply: print the projection of the decisions and write NOTHING") verifyBank := fs.Bool("verify-bank", false, "translate/redrive: STOP at the bank-mining boundary and print the bank table when the delta holds terms no earlier stop has shown (D39.144: the flag trips on novelty; a resumed run goes on and undecided terms ride to the editor marked). Default: never stop") ceilingUSD := fs.Float64("ceiling-usd", 0, "translate/redrive: the book USD ceiling in force for THIS RUN ONLY — it OVERRIDES book.yaml `ceilings.book_usd` and is never written back. It caps the book's CUMULATIVE committed+reserved spend, not this run's increment, and must be > 0") maxUnits := fs.Int("max-units", 0, "translate: the VOLUME ceiling for THIS RUN — at most N OUTPUT UNITS (the manifest's units_total granularity) may take a slot: new book this run starts paying for, and delivered book it re-makes under a moved snapshot. Units this run resolves for free (resumed or re-pinned) ride along and do not count, and neither do retries or escalation hops inside a unit; a unit an EARLIER run started and never shipped is finished outside the grant, so the same unit is never charged a slot twice — at most N of those per run, in book order, and the rest wait like any undelivered unit. ORTHOGONAL to --ceiling-usd, which caps the book's CUMULATIVE money: this caps THIS run's work. Stopping on it is a COMPLETION (exit 0), not a pause") seed := fs.String("seed", "", "seed-lint: path to the glossary seed YAML to validate ($0, no --config)") keysFile := fs.String("keys-file", "", "translate: path to the DEPLOYMENT's provider-key file (KEY=VALUE lines). Loaded FIRST, so it wins over the .env beside book.yaml; a named file that cannot be read is a refusal, never a silent skip") decisions := fs.String("decisions", "", "bank-apply: path to the JSON decision document to apply to the book's memory bank ($0)") buildFormat := fs.String("format", "", "build: the reader's copies to write, comma-separated ("+strings.Join(bookfile.Formats, ",")+"); default: all of them") buildOut := fs.String("out", "", "build: write the ONE requested format to this path instead of beside the project database (needs --format with a single format)") buildPartial := fs.Bool("partial", false, "build: write the book even when it is not whole — the notice is on its first page and every hole (a unit not translated, withheld, or missing a piece; text the current cut cannot place) is marked in the text. Without it a book with any hole is REFUSED (exit 16) and nothing is written") if err := fs.Parse(rest); err != nil { return invocation{}, err } // Which flags were actually PRESENT on the command line. // // fs.Lookup does not answer this — it returns the DECLARED flag whether or not anybody passed it — // and fs.Visit is the only thing that does. The difference is the whole of the two guards below: they // keyed on the VALUE being non-empty, so `--decisions=` with an empty value was invisible to them and // `tmctl translate --decisions=` went down the PAID path exactly as if the flag were absent. The live // trigger is not a typist: it is `--decisions=$DOC` in a unit file or a script with the variable // unset. The precedent for the technique is `ceiling-usd` below, which has needed it since D39.110. given := map[string]bool{} fs.Visit(func(f *flag.Flag) { given[f.Name] = true }) // Keys belong to the ONE command that spends money. Refused rather than ignored for a read command: // silently accepting it teaches a caller to pass it everywhere, and the next reader of that wiring // concludes the $0 projections need keys — the exact belief D20.4 exists to prevent. Placed before the // seed-lint branch so no command escapes the rule, and scoped to the flag being PRESENT so the frozen // "--config first" validation order is untouched for every invocation that does not pass it. if given["keys-file"] { if cmd != "translate" { // The reason has to describe EVERY command the branch turns away, or the operator who gets it // is taught something false about the system. It names them all, and the test that guards it // enumerates dispatchCommands rather than a hand-written list — the hand list is what left // `bank-apply`, the very verb this pack added, undescribed by its own refusal. return invocation{}, fmt.Errorf("--keys-file is accepted by `translate` only, not by %q: `translate` is the one command that spends. The $0 read commands (report/status/export/build/manifest/seed-lint/bank-apply) must not demand provider keys at all (D20.4); `redrive` does re-attack and re-bill, but takes its keys from its own conventional .env; `backup` and `migrate` are deploy steps that call no provider", cmd) } // A PRESENT flag with an empty value is refused rather than ignored, for the reason the flag // exists: an unset variable in a deployment's unit file would otherwise fall back to the // conventional .env, and the deployment would believe it had supplied its own keys. if *keysFile == "" { return invocation{}, fmt.Errorf("--keys-file was given with no path: a deployment that names its key file and passes nothing has an unset variable, and falling back to the conventional .env would hide it") } } // The same rule for the decision document, and here the silent version costs MONEY: the flags of this // CLI live in one FlagSet, so `tmctl translate --decisions d.json` parsed cleanly and ran a paid // translation while its caller believed it was applying a user's bank corrections. A flag a command // does not act on is refused, never ignored. if given["decisions"] { if cmd != "bank-apply" { return invocation{}, fmt.Errorf("--decisions is accepted by `bank-apply` only, not by %q: %q does not apply decisions, and running it with this flag would do something other than what the flag says", cmd, cmd) } if *decisions == "" { return invocation{}, fmt.Errorf("--decisions was given with no path: an unset variable must not read as «apply nothing», and for bank-apply the document IS the command") } } // And the same rule for --dry-run, whose silent version is the most expensive of the three: // `tmctl translate --dry-run` parsed cleanly and ran a PAID translation while its caller believed // it was asking for a $0 projection — the exact inversion of what the flag promises everywhere else. if given["dry-run"] && cmd != "bank-apply" && cmd != "redrive" { return invocation{}, fmt.Errorf("--dry-run is accepted by `bank-apply` and `redrive` only, not by %q: %q has no projection mode, and running it with this flag would spend real money on a call its caller believes is free", cmd, cmd) } // The volume ceiling follows the same rule as every other flag above: a command that does not act on it // REFUSES it rather than ignoring it. Silently accepting it here would be the expensive spelling — // `redrive --max-units 10` re-attacks flagged chunks and then translates the WHOLE book through // TranslateBook, so a caller who believed the flag bounded the work would be billed for everything // left. Scoped to `translate`, which is the one verb whose volume a purchase actually buys. if given["max-units"] { if cmd != "translate" { // The reason has to be TRUE of the command it addresses, which is the discipline the // --keys-file refusal above already carries a test for. `redrive` is the case that makes it // matter: it DOES drive a book wave — it calls TranslateBook after its reset — so telling it // "you have no book wave to bound" would teach the reader a false fact about the engine, and // the next session wiring this flag would believe it. if cmd == "redrive" { return invocation{}, fmt.Errorf("--max-units is accepted by `translate` only, not by `redrive`: redrive DOES drive a book wave (it re-attacks the flagged chunks and then translates through TranslateBook), but the destructive reset it performs first is bounded by its own selector and by nothing else — a units cap here would bound the second half of the command while reading as a cap on the whole. Bound the re-attack with --chapter/--chunk/--reason, and the translation that follows with a separate `translate --max-units`") } return invocation{}, fmt.Errorf("--max-units is accepted by `translate` only, not by %q: it caps the OUTPUT UNITS a run pays for, and %q neither runs nor pays for them — accepting the flag would tell a caller their work was capped when it was not", cmd, cmd) } // Refused rather than defaulted when present-but-not-a-volume, the same rule --ceiling-usd follows // (D39.110: "off" is not a state). Zero is the ABSENT value, so admitting `--max-units 0` would turn // an unset variable in a deployment's unit file into an unbounded run that the caller believes is // bounded — and a negative is not a quantity of book at all. if *maxUnits <= 0 { return invocation{}, fmt.Errorf("--max-units must be a positive number of output units (it is the volume ceiling for THIS run); got %d — omit the flag to run the whole book", *maxUnits) } } // `build`'s flags follow the same rule: a command that does not act on them refuses them. The silent // version of --partial is the expensive one in the reader's currency rather than in dollars — a // caller who passed it to `export` would believe they had asked for an honest, marked copy and get // the audit projection, which marks nothing. for _, name := range []string{"format", "out", "partial"} { if given[name] && cmd != "build" { return invocation{}, fmt.Errorf("--%s is accepted by `build` only, not by %q: it shapes the reader's copy of the book that `build` writes, and %q writes no such copy — accepting it would tell a caller a file was written the way they asked when nothing was", name, cmd, cmd) } } // And the converse: `build` prints its report as JSON and writes files, so export's and status's // output flags mean nothing to it — refused, not ignored (`build --plaintext` would otherwise read as // «the text file, please» and quietly do something else). if cmd == "build" { for _, name := range []string{"plaintext", "pairs", "json"} { if given[name] { return invocation{}, fmt.Errorf("--%s is not a `build` flag: build writes the reader's copies (--format epub,txt) and prints its report as JSON regardless; accepting it would tell a caller it changed the output when it did not", name) } } } var buildFormats []string if given["format"] { seen := map[string]bool{} for _, f := range strings.Split(*buildFormat, ",") { f = strings.TrimSpace(f) if f == "" || seen[f] { continue // a format named twice is one file, not two writes of it } if !bookfile.KnownFormat(f) { return invocation{}, fmt.Errorf("--format %q is not a format `build` writes (want one or more of %s, comma-separated)", f, strings.Join(bookfile.Formats, ",")) } seen[f] = true buildFormats = append(buildFormats, f) } if len(buildFormats) == 0 { return invocation{}, fmt.Errorf("--format was given with no format: an unset variable must not read as «all formats» — omit the flag for that") } } if given["out"] { if *buildOut == "" { return invocation{}, fmt.Errorf("--out was given with no path: an unset variable must not read as «the default place»") } if len(buildFormats) != 1 { return invocation{}, fmt.Errorf("--out names ONE file, so it needs --format with exactly one format (got %d)", len(buildFormats)) } } // seed-lint validates a standalone seed YAML — it takes --seed, not --config (no book/store/keys). if cmd == "seed-lint" { if *seed == "" { return invocation{}, fmt.Errorf("--seed is required for seed-lint") } return invocation{cmd: cmd, seedPath: *seed}, nil } if *cfgPath == "" { return invocation{}, fmt.Errorf("--config book.yaml is required") } // The run ceiling is refused rather than defaulted when it is present but not a spendable amount, so // the flag can only ever ADD a bound, never remove one (D39.110: «выключено» is not a state). // // Р7's own validator (config.LoadBook) is untouched and is NOT the same rule: it demands at least ONE // of book_usd/day_usd, so a book may legitimately declare a daily ceiling only — and then the book // ceiling this flag overrides is zero, i.e. absent, and passing the flag introduces one for this run. // fs.Visit reports only flags actually PRESENT on the command line, which is what separates // "--ceiling-usd 0" (a refusal) from an absent flag (the book's own ceiling stands). Placed AFTER the // --config check so the frozen validation order is untouched: a missing --config is still the first // thing a caller is told about. if given["ceiling-usd"] && (math.IsNaN(*ceilingUSD) || math.IsInf(*ceilingUSD, 0) || *ceilingUSD <= 0) { return invocation{}, fmt.Errorf("--ceiling-usd must be a finite amount greater than zero (it is the ceiling for THIS run and overrides book.yaml ceilings.book_usd); got %v — omit the flag to run under the book's own ceiling", *ceilingUSD) } // The optional-value trap (see IsBoolFlag): `--accept-rebill 1.50` leaves 1.50 as a stray argument // and the consent unlimited. Refuse loud rather than bill the difference. Scoped to exactly that // spelling, so the historically-tolerated stray argument stays tolerated everywhere else. if acceptRebill.c.Given && !acceptRebill.c.Capped && fs.NArg() > 0 { return invocation{}, fmt.Errorf("--accept-rebill takes its ceiling with an «=» (--accept-rebill=%s), not a space: as written %q is a stray argument and the flag consents to the FULL projected re-payment", fs.Arg(0), fs.Arg(0)) } // bank-apply is meaningless without the document it applies, and defaulting that path to some // convention would let an argument-less invocation mutate the book's decisions. if cmd == "bank-apply" && *decisions == "" { return invocation{}, fmt.Errorf("--decisions is required for bank-apply") } return invocation{ cmd: cmd, cfgPath: *cfgPath, resnapshot: *resnapshot, acceptRebill: acceptRebill.c, asJSON: *asJSON, asPlaintext: *asPlaintext, asPairs: *asPairs, verifyBank: *verifyBank, ceilingUSD: *ceilingUSD, maxUnits: *maxUnits, keysFile: *keysFile, decisionsPath: *decisions, dryRun: *dryRun, buildFormats: buildFormats, buildOut: *buildOut, buildPartial: *buildPartial, sel: pipeline.RedriveSelector{ Chapter: *chapter, ChunkIdx: *chunk, Reason: *reason, DryRun: *dryRun, }, }, nil }