textmachine/backend/cmd/tmctl/invocation.go

256 lines
18 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

package main
import (
"flag"
"fmt"
"io"
"math"
"strconv"
"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
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 <translate|report|status|export|redrive|manifest|backup|migrate> --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 be PAID for. 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. 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)")
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/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)
}
}
// 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 <glossary.yaml> 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 <decisions.json> 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,
sel: pipeline.RedriveSelector{
Chapter: *chapter, ChunkIdx: *chunk, Reason: *reason, DryRun: *dryRun,
},
}, nil
}