spec-controller 0.1.0-alpha.4 → 0.1.0-alpha.41

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (118) hide show
  1. package/README.md +34 -6
  2. package/dist/cli-allocate/cli.d.ts +2 -0
  3. package/dist/cli-allocate/cli.d.ts.map +1 -0
  4. package/dist/cli-allocate/cli.js +57 -0
  5. package/dist/cli-allocate/cli.js.map +1 -0
  6. package/dist/cli-allocate/readHistory.d.ts +29 -0
  7. package/dist/cli-allocate/readHistory.d.ts.map +1 -0
  8. package/dist/cli-allocate/readHistory.js +136 -0
  9. package/dist/cli-allocate/readHistory.js.map +1 -0
  10. package/dist/cli-allocate/readParts.d.ts +3 -0
  11. package/dist/cli-allocate/readParts.d.ts.map +1 -0
  12. package/dist/cli-allocate/readParts.js +28 -0
  13. package/dist/cli-allocate/readParts.js.map +1 -0
  14. package/dist/cli-allocate/readTree.d.ts +16 -0
  15. package/dist/cli-allocate/readTree.d.ts.map +1 -0
  16. package/dist/cli-allocate/readTree.js +106 -0
  17. package/dist/cli-allocate/readTree.js.map +1 -0
  18. package/dist/cli-allocate/refStore.d.ts +3 -0
  19. package/dist/cli-allocate/refStore.d.ts.map +1 -0
  20. package/dist/cli-allocate/refStore.js +47 -0
  21. package/dist/cli-allocate/refStore.js.map +1 -0
  22. package/dist/cli-allocate/request.d.ts +11 -0
  23. package/dist/cli-allocate/request.d.ts.map +1 -0
  24. package/dist/cli-allocate/request.js +29 -0
  25. package/dist/cli-allocate/request.js.map +1 -0
  26. package/dist/cli-allocate/stderr.d.ts +14 -0
  27. package/dist/cli-allocate/stderr.d.ts.map +1 -0
  28. package/dist/cli-allocate/stderr.js +33 -0
  29. package/dist/cli-allocate/stderr.js.map +1 -0
  30. package/dist/cli-allocate/unreadable.d.ts +24 -0
  31. package/dist/cli-allocate/unreadable.d.ts.map +1 -0
  32. package/dist/cli-allocate/unreadable.js +43 -0
  33. package/dist/cli-allocate/unreadable.js.map +1 -0
  34. package/dist/cli-args.d.ts +73 -18
  35. package/dist/cli-args.d.ts.map +1 -1
  36. package/dist/cli-args.js +244 -22
  37. package/dist/cli-args.js.map +1 -1
  38. package/dist/cli-balance/cli.d.ts +31 -13
  39. package/dist/cli-balance/cli.d.ts.map +1 -1
  40. package/dist/cli-balance/cli.js +206 -251
  41. package/dist/cli-balance/cli.js.map +1 -1
  42. package/dist/cli-balance/emit/format.d.ts +18 -1
  43. package/dist/cli-balance/emit/format.d.ts.map +1 -1
  44. package/dist/cli-balance/emit/format.js +27 -2
  45. package/dist/cli-balance/emit/format.js.map +1 -1
  46. package/dist/cli-balance/emit/writer.d.ts +11 -25
  47. package/dist/cli-balance/emit/writer.d.ts.map +1 -1
  48. package/dist/cli-balance/emit/writer.js +58 -33
  49. package/dist/cli-balance/emit/writer.js.map +1 -1
  50. package/dist/cli-balance/exitStatus.d.ts +3 -0
  51. package/dist/cli-balance/exitStatus.d.ts.map +1 -0
  52. package/dist/cli-balance/exitStatus.js +12 -0
  53. package/dist/cli-balance/exitStatus.js.map +1 -0
  54. package/dist/cli-balance/reRender.d.ts +22 -0
  55. package/dist/cli-balance/reRender.d.ts.map +1 -0
  56. package/dist/cli-balance/reRender.js +36 -0
  57. package/dist/cli-balance/reRender.js.map +1 -0
  58. package/dist/cli-balance/unreadableInputs.d.ts +35 -0
  59. package/dist/cli-balance/unreadableInputs.d.ts.map +1 -0
  60. package/dist/cli-balance/unreadableInputs.js +101 -0
  61. package/dist/cli-balance/unreadableInputs.js.map +1 -0
  62. package/dist/cli-registry.d.ts +28 -0
  63. package/dist/cli-registry.d.ts.map +1 -1
  64. package/dist/cli-registry.js +40 -35
  65. package/dist/cli-registry.js.map +1 -1
  66. package/dist/cli.d.ts +4 -5
  67. package/dist/cli.d.ts.map +1 -1
  68. package/dist/cli.js +38 -22
  69. package/dist/cli.js.map +1 -1
  70. package/dist/host.d.ts +33 -3
  71. package/dist/host.d.ts.map +1 -1
  72. package/dist/host.js +153 -17
  73. package/dist/host.js.map +1 -1
  74. package/dist/ingest/gherkinValidation.d.ts +3 -7
  75. package/dist/ingest/gherkinValidation.d.ts.map +1 -1
  76. package/dist/ingest/gherkinValidation.js +3 -7
  77. package/dist/ingest/gherkinValidation.js.map +1 -1
  78. package/dist/ingest/ingestQualityChecks.d.ts +25 -54
  79. package/dist/ingest/ingestQualityChecks.d.ts.map +1 -1
  80. package/dist/ingest/ingestQualityChecks.js +112 -145
  81. package/dist/ingest/ingestQualityChecks.js.map +1 -1
  82. package/dist/ingest/ingestScenarios.d.ts +11 -17
  83. package/dist/ingest/ingestScenarios.d.ts.map +1 -1
  84. package/dist/ingest/ingestScenarios.js +52 -62
  85. package/dist/ingest/ingestScenarios.js.map +1 -1
  86. package/dist/ingest/inputShapes.d.ts +47 -0
  87. package/dist/ingest/inputShapes.d.ts.map +1 -0
  88. package/dist/ingest/inputShapes.js +143 -0
  89. package/dist/ingest/inputShapes.js.map +1 -0
  90. package/dist/outputLocation.d.ts +48 -0
  91. package/dist/outputLocation.d.ts.map +1 -0
  92. package/dist/outputLocation.js +140 -0
  93. package/dist/outputLocation.js.map +1 -0
  94. package/dist/run-management/keptRun.d.ts +29 -25
  95. package/dist/run-management/keptRun.d.ts.map +1 -1
  96. package/dist/run-management/keptRun.js +25 -21
  97. package/dist/run-management/keptRun.js.map +1 -1
  98. package/dist/storedRun.d.ts +27 -0
  99. package/dist/storedRun.d.ts.map +1 -0
  100. package/dist/storedRun.js +49 -0
  101. package/dist/storedRun.js.map +1 -0
  102. package/package.json +2 -2
  103. package/dist/corpus/cli.d.ts +0 -34
  104. package/dist/corpus/cli.d.ts.map +0 -1
  105. package/dist/corpus/cli.js +0 -128
  106. package/dist/corpus/cli.js.map +0 -1
  107. package/dist/mutation-ratchet/index.d.ts +0 -51
  108. package/dist/mutation-ratchet/index.d.ts.map +0 -1
  109. package/dist/mutation-ratchet/index.js +0 -51
  110. package/dist/mutation-ratchet/index.js.map +0 -1
  111. package/dist/mutation-ratchet/record.d.ts +0 -178
  112. package/dist/mutation-ratchet/record.d.ts.map +0 -1
  113. package/dist/mutation-ratchet/record.js +0 -314
  114. package/dist/mutation-ratchet/record.js.map +0 -1
  115. package/dist/mutation-ratchet/report.d.ts +0 -109
  116. package/dist/mutation-ratchet/report.d.ts.map +0 -1
  117. package/dist/mutation-ratchet/report.js +0 -156
  118. package/dist/mutation-ratchet/report.js.map +0 -1
@@ -13,21 +13,25 @@
13
13
  */
14
14
  import { existsSync, readFileSync } from "node:fs";
15
15
  import { dirname } from "node:path";
16
- import { runReconcile, readEvidenceObligations, readFeatureNames, featureCorpusParseErrors, featureCorpusDuplicateScnIds, } from "../ingest/ingestQualityChecks.js";
16
+ import { runReconcile, readEvidenceObligations, readFeatureNames, featureCorpusParseErrors, } from "../ingest/ingestQualityChecks.js";
17
17
  import { planOutput } from "./emit/format.js";
18
18
  import { writeOutputs } from "./emit/writer.js";
19
- import { renderReportFromJson, isBalanced, hasPendingItems, hasBrokenEvidence, hasNothingToReconcile, } from "@3f-consulting/spec-controller-core";
19
+ import { namesStdout } from "../outputLocation.js";
20
+ import { checkReRenderFlags } from "./reRender.js";
21
+ import { checkInputPaths, checkInputShapes, checkSavedReconciliation, jsonInputsOf, reRenderOrRefusal, } from "./unreadableInputs.js";
22
+ import { statusFor } from "./exitStatus.js";
23
+ import { verdictCode, } from "@3f-consulting/spec-controller-core";
20
24
  import { resolveSourceSha, resolveTargetRoot, resolveToolIdentity } from "../run-management/resolveRunInputs.js";
21
25
  import { cliRegistry, renderCommandHelp } from "../cli-registry.js";
22
- // THE ARGV READING AND ITS REGISTRY BOUNDING, SHARED WITH EVERY OTHER COMMAND (3F-3300). Both
26
+ // THE ARGV READING AND ITS REGISTRY BOUNDING, SHARED WITH EVERY OTHER COMMAND. Both
23
27
  // lived here while `balance` was the only command; a second command needs them, and a second
24
28
  // copy of "what did the operator type" is the one thing this tree least wants two of.
25
- import { parseArgs, checkUnknownFlags } from "../cli-args.js";
29
+ import { asksForHelp, parseArgs, checkArgvShape, checkUnknownFlags, checkMissingValues, checkMisplacedHostModifiers, } from "../cli-args.js";
26
30
  /**
27
31
  * Collect the `--format <spec>` values from argv in order (the flag is REPEATABLE, unlike
28
32
  * the single-valued flags `parseArgs` records) — the raw specs `planOutput` parses. Empty
29
- * when no `--format` was given, which `planOutput` reads as the md→stdout default
30
- * (@SCN-FMT-001). Later sub-issues (FMT-004) route several specs in one invocation.
33
+ * when no `--format` was given, which `planOutput` reads as the md→stdout default.
34
+ * Several specs route in one invocation.
31
35
  */
32
36
  function collectFormatSpecs(argv) {
33
37
  const specs = [];
@@ -43,7 +47,7 @@ function collectFormatSpecs(argv) {
43
47
  return specs;
44
48
  }
45
49
  /**
46
- * The legacy-`--json` guard at the CLI edge (@SCN-FMT-006). The earlier
50
+ * The legacy-`--json` guard at the CLI edge. The earlier
47
51
  * `--json <path>` flag is removed; supplying it must fail fast with a migration hint to
48
52
  * `--format json:<path>`, so the removed flag never silently no-ops and drops output. PURE
49
53
  * and `@unit`-testable: the removed flag is NOT a `--format` spec (planOutput never sees
@@ -60,8 +64,8 @@ export function checkLegacyJson(args) {
60
64
  return null;
61
65
  }
62
66
  /**
63
- * Source the provenance "Target repo:" name for a run (@SCN-RPT-001 Update —
64
- * Fork A). The operator's `--target` is recorded VERBATIM — no resolution — defaulting to
67
+ * Source the provenance "Target repo:" name for a run. The
68
+ * operator's `--target` is recorded VERBATIM — no resolution — defaulting to
65
69
  * `"unknown-target"` when omitted (parallel to `sourceSha`'s `"unknown"`; the facts
66
70
  * artefact is self-describing, so the line always renders). PURE and `@unit`-testable at
67
71
  * the CLI edge, like `checkLegacyJson`. It never inspects `--features`: the prior
@@ -72,23 +76,6 @@ export function checkLegacyJson(args) {
72
76
  export function sourceTarget(args) {
73
77
  return args["target"] ?? "unknown-target";
74
78
  }
75
- /**
76
- * Guard the supplied input-file/dir flags at the CLI edge (@SCN-CLI-002). A
77
- * mistyped `--vitest` / `--cucumber` / `--features` / `--ci` / `--package-json`
78
- * pointing at a non-existent path used to reconcile silently against ZERO evidence —
79
- * a terrifying false all-red report meaning "you forgot the reports", not "your code
80
- * is broken" (the dogfooding fault). These flags are Examples of ONE boundary
81
- * rule: a supplied-but-absent input path is a hard error, and a flag added to the set
82
- * inherits it rather than restating it. Scope is existence only
83
- * (readability / file-vs-dir type-correctness out of scope; `exists` covers the dir
84
- * flag and the file flags alike).
85
- *
86
- * PURE and `@unit`-testable like `checkLegacyJson` / `sourceTarget`: reads the parsed
87
- * args + an injected `exists` fn (no fs), returns the FIRST supplied-but-absent
88
- * `(flag, path)` as a typed error naming both — routed to stderr + a non-zero exit by
89
- * `runBalance` (the shared typed-error surface) — else null. An OMITTED flag is not an
90
- * error (legitimately optional: simply no evidence of that kind).
91
- */
92
79
  /**
93
80
  * The parsed args, as the reconciler's input paths — the one place a `--flag` name becomes a
94
81
  * `ReconcileOptions` key.
@@ -104,16 +91,32 @@ function reconcileInputsOf(args) {
104
91
  cucumber: args["cucumber"],
105
92
  ci: args["ci"],
106
93
  packageJson: args["package-json"],
107
- measurements: args["measurements"],
108
94
  ciSteps: args["ci-steps"],
109
95
  };
110
96
  }
97
+ /**
98
+ * Guard the supplied input-file/dir flags at the CLI edge. A
99
+ * mistyped `--vitest` / `--cucumber` / `--features` / `--ci` / `--package-json`
100
+ * pointing at a non-existent path used to reconcile silently against ZERO evidence —
101
+ * a terrifying false all-red report meaning "you forgot the reports", not "your code
102
+ * is broken" (the dogfooding fault). These flags are Examples of ONE boundary
103
+ * rule: a supplied-but-absent input path is a hard error, and a flag added to the set
104
+ * inherits it rather than restating it. Scope is existence only
105
+ * (readability / file-vs-dir type-correctness out of scope; `exists` covers the dir
106
+ * flag and the file flags alike).
107
+ *
108
+ * PURE and `@unit`-testable like `checkLegacyJson` / `sourceTarget`: reads the parsed
109
+ * args + an injected `exists` fn (no fs), returns the FIRST supplied-but-absent
110
+ * `(flag, path)` as a typed error naming both — routed to stderr + a non-zero exit by
111
+ * `refuseUnreadableInputs` (the shared typed-error surface) — else null. An OMITTED flag is not an
112
+ * error (legitimately optional: simply no evidence of that kind).
113
+ */
111
114
  export function checkInputsExist(args, exists) {
112
115
  // The input flags in a fixed order — the FIRST supplied-but-absent one is the
113
116
  // reported error (deterministic; the operator fixes and re-runs). Keys are the
114
117
  // parseArgs form (the `--` stripped); the message names the flag WITH its dashes.
115
118
  const flags = [
116
- "vitest", "cucumber", "features", "ci", "package-json", "measurements", "ci-steps",
119
+ "vitest", "cucumber", "features", "ci", "package-json", "ci-steps",
117
120
  ];
118
121
  for (const flag of flags) {
119
122
  const path = args[flag];
@@ -124,53 +127,60 @@ export function checkInputsExist(args, exists) {
124
127
  return null;
125
128
  }
126
129
  /**
127
- * The JSON-BEARING input flags — the ones whose content is `JSON.parse`d, and so the only
128
- * ones whose content can throw. `--features` is Gherkin (parsed tolerantly) and `--ci` is
129
- * read as raw TEXT and scanned for `run:` steps — neither is JSON, neither can crash the
130
- * process, and on garbage both reconcile normally (verified, 3F-1742 Discovery).
130
+ * The JSON-BEARING input flags — the ones whose content is `JSON.parse`d. `--features` and `--ci`
131
+ * are read as Gherkin and YAML files on disk, and whether they can be read is `checkInputPaths`'s.
131
132
  */
132
133
  const JSON_INPUT_FLAGS = [
133
- "vitest", "cucumber", "package-json", "measurements", "ci-steps",
134
+ "vitest", "cucumber", "package-json", "ci-steps",
134
135
  ];
135
136
  /**
136
- * Guard the READABILITY of the supplied JSON inputs at the CLI edge (@SCN-CLI-014).
137
+ * Guard the READABILITY of the supplied JSON inputs at the CLI edge.
137
138
  * `checkInputsExist` already guards their PRESENCE — but a path that is present yet
138
139
  * UNPARSEABLE fell straight through it: the reader threw mid-parse, nothing caught it, and
139
140
  * the process hit Node's default exit 1 — the code reserved for a genuine OUT-OF-BALANCE
140
141
  * verdict. A crashed invocation was therefore indistinguishable, by exit code, from an
141
- * honest disagreement (3F-1742). An input the tool cannot read is a USAGE fault: exit 2,
142
- * sibling to a missing path (@SCN-CLI-002) and an unrecognised flag (@SCN-CLI-009).
142
+ * honest disagreement. An input the tool cannot read is a USAGE fault: exit 2,
143
+ * sibling to a missing path and an unrecognised flag.
143
144
  *
144
145
  * PURE and `@unit`-testable like its sibling guards: reads the parsed args + an injected
145
146
  * `read` fn (no fs), returns the FIRST supplied-but-unparseable `(flag, path, failure)` as
146
- * a typed error naming all three — routed to stderr + exit 2 by `runBalance` — else null.
147
- * An omitted flag is not an error, and an unreadable file (a race after the existence
148
- * check) reports as the same input fault rather than escaping as a crash.
147
+ * a typed error naming all three — routed to stderr + exit 2 by `refuseUnreadableInputs` — else null.
148
+ * An omitted flag is not an error, and a file that cannot be read at all (a race after the
149
+ * existence check, a permission refused) is refused as one that could not be read, never as JSON
150
+ * that did not parse.
149
151
  */
150
152
  export function checkInputsParse(args, read) {
151
153
  for (const flag of JSON_INPUT_FLAGS) {
152
154
  const path = args[flag];
153
155
  if (path === undefined)
154
156
  continue;
157
+ let text;
155
158
  try {
156
- JSON.parse(read(path));
159
+ text = read(path);
157
160
  }
158
161
  catch (err) {
159
- // Both a parse failure and an unreadable file are the SAME input fault. Anything
160
- // that escapes this catch lands on Node's default exit 1 — the collision itself.
161
- const failure = err instanceof Error ? err.message : String(err);
162
- return `The --${flag} report is not valid JSON: ${path} (${failure})`;
162
+ return `The --${flag} file could not be read: ${path} (${failureOf(err)})`;
163
+ }
164
+ try {
165
+ JSON.parse(text);
166
+ }
167
+ catch (err) {
168
+ // Anything that escapes this catch lands on Node's default exit 1 — the collision itself.
169
+ return `The --${flag} report is not valid JSON: ${path} (${failureOf(err)})`;
163
170
  }
164
171
  }
165
172
  return null;
166
173
  }
174
+ function failureOf(err) {
175
+ return err instanceof Error ? err.message : String(err);
176
+ }
167
177
  /**
168
- * Guard the readability of `--render-from-json` at the CLI edge (@SCN-CLI-015). The
178
+ * Guard the readability of `--render-from-json` at the CLI edge. The
169
179
  * render-from-saved-JSON branch RETURNS before the input guards ever run, so it honoured
170
180
  * NEITHER: a missing path crashed to `ENOENT` and a malformed file to `SyntaxError`, both
171
- * landing on Node's default exit 1 — the out-of-balance code (3F-1764).
181
+ * landing on Node's default exit 1 — the out-of-balance code.
172
182
  *
173
- * The missing-path case was a live violation of @SCN-CLI-002's own shipped principle: a
183
+ * The missing-path case was a live violation of the missing-input rule's own shipped principle: a
174
184
  * supplied input path that does not exist is a hard error at exit 2, enforced for the five
175
185
  * evidence flags and not for this one. The CLI shipped a guard that half-honoured its Rule.
176
186
  *
@@ -197,96 +207,175 @@ export function checkRenderFromJson(args, read) {
197
207
  return null;
198
208
  }
199
209
  export function runBalance(argv) {
200
- // Answer `--help`/`-h` from the command/flag registry (@SCN-CLI-006) BEFORE any
210
+ // Answer `--help`/`-h` from the command/flag registry BEFORE any
201
211
  // parse or reconcile: render the balance command's per-command scope (a line per
202
212
  // registered flag — name + arg + description), leave process.exitCode at its default 0,
203
213
  // and return. This intercepts --help so it never falls through parseArgs as a stray flag
204
214
  // while balance runs anyway. The registry is the single source, so a flag that exists is
205
215
  // a flag that appears in help — no hand-kept list to drift.
206
- if (argv.includes("--help") || argv.includes("-h")) {
216
+ if (asksForHelp(argv)) {
207
217
  process.stdout.write(renderCommandHelp(cliRegistry, "balance") + "\n");
208
- return;
218
+ return false;
209
219
  }
210
220
  // Capture the run-start instant up front — the run provenance's UTC timestamp,
211
221
  // threaded through ctx.runStart to the renderers.
212
222
  const runStart = new Date();
213
223
  const args = parseArgs(argv);
214
- // Legacy-`--json` guard (@SCN-FMT-006): the earlier `--json <path>` flag is
224
+ // Legacy-`--json` guard: the earlier `--json <path>` flag is
215
225
  // removed. Reject it fast — before any reconcile or write — with a migration hint to
216
226
  // `--format json:<path>`, so the removed flag never silently no-ops and drops output. The
217
227
  // guard's typed error is routed to stderr + a non-zero exit, exactly as planOutput's error
218
228
  // is below (the shared typed-error surface).
219
229
  const legacyJsonError = checkLegacyJson(args);
220
- if (legacyJsonError) {
221
- process.stderr.write(legacyJsonError + "\n");
222
- // Usage/config error → exit 2 (the enumerated usage code, @SCN-CLI-002), distinct
223
- // from the out-of-balance verdict code 1 (@SCN-CLI-004): an out-of-balance run must
224
- // never masquerade as a usage error, nor the reverse.
225
- process.exit(2);
226
- }
227
- // Registry-bounded parse (@SCN-CLI-009): `balance` accepts ONLY the flags its registry
228
- // scope LISTS. An unregistered flag — a typo `--strcit`, a stray `--bogus` — is a
229
- // USAGE error: name it on stderr, set process.exitCode = 2 (the enumerated usage code,
230
- // 3F-1414's 0/1/2 taxonomy — never 1, never a silent swallow), and RETURN before any
231
- // reconcile. Runs AFTER the legacy-`--json` guard so `--json` keeps its specific migration
232
- // hint; the global `--store`/`--run-id` modifiers were consumed by the dispatcher before
233
- // balance, so they never reach here. This is the structural teeth behind "accepted =
230
+ if (legacyJsonError)
231
+ exitUsage(legacyJsonError);
232
+ // A host modifier after the command is out of place, not unknown, so it is
233
+ // refused before the drift advice.
234
+ const misplacedModifierError = checkMisplacedHostModifiers(args, cliRegistry, "balance");
235
+ if (misplacedModifierError)
236
+ exitUsage(misplacedModifierError);
237
+ // Every token is a flag or the value of the flag before it, refused before the unknown-flag
238
+ // check below.
239
+ const shapeError = checkArgvShape(argv, cliRegistry, "balance");
240
+ if (shapeError)
241
+ exitUsage(shapeError);
242
+ // Registry-bounded flag NAMES: `balance` accepts ONLY the flags its registry
243
+ // scope LISTS. An unregistered flag — a typo `--strcit`, a stray `--bogus` — is a USAGE error:
244
+ // name it on stderr, set process.exitCode = 2 (the enumerated usage code, the 0/1/2
245
+ // taxonomy — never 1, never 0), and RETURN before any reconcile. It bounds names only; that every
246
+ // other token is read or refused is the argv-shape check above.
247
+ // Runs AFTER the legacy-`--json` guard so `--json` keeps its specific migration hint, after the
248
+ // misplaced-modifier refusal, so a `--store` / `--run-id` written after the command is named as
249
+ // out of place rather than given pin advice, and after the argv-shape check, so `--format=json` is
250
+ // refused as a form and never given pin advice. This is the structural teeth behind "accepted =
234
251
  // registry = help" — a flag can't affect behaviour without a registry entry.
235
252
  const unknownFlagError = checkUnknownFlags(args, cliRegistry, "balance", resolveToolIdentity().toolVersion);
236
253
  if (unknownFlagError) {
237
254
  process.stderr.write(unknownFlagError + "\n");
238
255
  process.exitCode = 2;
239
- return;
256
+ return false;
240
257
  }
241
- // Render-from-saved-JSON mode (@SCN-RMD-007): regenerate the human markdown from a
242
- // banked spec-reconciliation.json ALONE — no reconcile, no live inputs, no RunContext. Reads
243
- // the file, JSON.parses it back into the whole-document report model, renders the markdown via
244
- // renderReport, and writes it to stdout (the same trailing-newline convention as the writer's
245
- // stdout path). The load-bearing proof that the JSON IS the model — returns before any reconcile.
246
- // Saved-report READABILITY guard (@SCN-CLI-015). This branch RETURNS before the input
247
- // guards below ever run, so it honoured NEITHER: a missing path crashed to ENOENT and a
248
- // malformed file to SyntaxError, both landing on Node's default exit 1 — the
249
- // out-of-balance code. The missing-path case was a live violation of @SCN-CLI-002's own
250
- // shipped principle, which the CLI enforced for the five evidence flags and not for this
251
- // one. The guard therefore runs INSIDE this branch, ahead of the read (3F-1764).
258
+ // A flag that takes a value, given none, refused before anything reads
259
+ // the value `parseArgs` made up for it.
260
+ const missingValueError = checkMissingValues(argv, cliRegistry, "balance");
261
+ if (missingValueError)
262
+ exitUsage(missingValueError);
263
+ if (renderSavedReport(args))
264
+ return false;
265
+ refuseUnreadableInputs(args);
266
+ const inputs = reconcileInputsOf(args);
267
+ // Reconcile the obligation-vs-observation evidence into the Ledger. The runtime evidence-observation
268
+ // views (Test volume, the evidence grid) are FOLDS the renderers derive off this Ledger's posted
269
+ // credits (the census is retired), so they cannot drift from the
270
+ // reconciler's verdict: they ARE the same rows. `withRuntimeObservations: true` (below) tells the
271
+ // renderers the axis is in play.
272
+ const balance = runReconcile(inputs);
273
+ // Read the scenario-corpus census alongside the balance —
274
+ // the raw-vs-parsed @SCN-occurrence delta over the same discovered feature corpus.
275
+ // A sibling to the balance, threaded to the renderers via writeOutputs like ctx.
276
+ const evidenceObligations = readEvidenceObligations(args["features"]);
277
+ // The static-check kind-counts are a FOLD OVER THE LEDGER — the
278
+ // renderers count the balance's own posted static-check credits (`staticObservationTally`), so
279
+ // they cannot drift from the reconciler's verdict: they ARE the same rows. The parallel static
280
+ // census is RETIRED; `withStaticChecks: true` (below) tells the renderers the axis is in play.
281
+ // Read the target's feature-code→slug map alongside the balance
282
+ // — the by-feature cut's full-name labels. The 5th sibling, threaded to the MARKDOWN
283
+ // renderer via writeOutputs only (markdown-only; the by-feature cut isn't on JSON).
284
+ const featureNames = readFeatureNames(args["features"]);
285
+ const ctx = runContextOf(args, inputs, runStart);
286
+ // Plan the outputs from the --format specs (no flag → md→stdout) and write them via
287
+ // the writer — the pure plan / impure writer split. The plan
288
+ // parse+validate is pure; the writer is the only IO for the emitted report. This is
289
+ // the SOLE output path: the tool emits via --format and holds no storage opinion — the
290
+ // legacy runs/<target>/<ISO>/ auto-archive was removed, the
291
+ // caller now routes stored runs (spec-controller's run-management layer).
292
+ const planResult = planOutput(collectFormatSpecs(argv), namesStdout);
293
+ if (!planResult.ok)
294
+ exitUsage(planResult.error);
295
+ writeOutputs(planResult.plan, { balance, ctx, evidenceObligations, withRuntimeObservations: true, withStaticChecks: true, featureNames });
296
+ // Set after the report is written in full, never `process.exit`, so the buffered report is
297
+ // flushed intact rather than truncated mid-write.
298
+ const verdict = verdictCode({ ...balance, strict: ctx.strict === true });
299
+ const code = statusFor(verdict, args["exit-zero"] !== undefined);
300
+ // Assigned only when NON-ZERO, so a balanced run still LEAVES process.exitCode at its default
301
+ // rather than writing a 0 over it — the distinction the balanced-exit scenarios rest on.
302
+ if (code !== 0)
303
+ process.exitCode = code;
304
+ return true;
305
+ }
306
+ /**
307
+ * A usage or input fault: the message on stderr, then exit 2 — the enumerated usage code
308
+ *, distinct from the out-of-balance verdict code 1. An
309
+ * out-of-balance run must never masquerade as a usage error, nor the reverse.
310
+ */
311
+ function exitUsage(message) {
312
+ process.stderr.write(message + "\n");
313
+ process.exit(2);
314
+ }
315
+ /**
316
+ * Render-from-saved-JSON mode: the markdown a banked spec-reconciliation.json renders to, on
317
+ * stdout, and the exit its saved verdict names — no reconcile, no live inputs. True when it
318
+ * answered the run, so `runBalance` returns before any reconcile.
319
+ */
320
+ function renderSavedReport(args) {
321
+ // Here rather than with the live-input guards, because this mode returns before they run.
322
+ const ignoredFlagError = checkReRenderFlags(args);
323
+ if (ignoredFlagError)
324
+ exitUsage(ignoredFlagError);
252
325
  const unreadableSavedReportError = checkRenderFromJson(args, (path) => readFileSync(path, "utf8"));
253
- if (unreadableSavedReportError) {
254
- process.stderr.write(unreadableSavedReportError + "\n");
255
- process.exit(2);
256
- }
326
+ if (unreadableSavedReportError)
327
+ exitUsage(unreadableSavedReportError);
257
328
  const rehydratePath = args["render-from-json"];
258
- if (rehydratePath !== undefined) {
259
- const savedReport = readFileSync(rehydratePath, "utf8");
260
- process.stdout.write(renderReportFromJson(savedReport) + "\n");
261
- return;
262
- }
263
- // Input-existence guard (@SCN-CLI-002): a supplied input-file/dir flag pointing at a
329
+ if (rehydratePath === undefined)
330
+ return false;
331
+ const savedJson = readFileSync(rehydratePath, "utf8");
332
+ const notSavedError = checkSavedReconciliation(rehydratePath, savedJson);
333
+ if (notSavedError)
334
+ exitUsage(notSavedError);
335
+ const saved = reRenderOrRefusal(rehydratePath, savedJson, args["strict"] !== undefined);
336
+ if (typeof saved === "string")
337
+ exitUsage(saved);
338
+ process.stdout.write(saved.markdown + "\n");
339
+ const code = statusFor(saved.code, args["exit-zero"] !== undefined);
340
+ if (code !== 0)
341
+ process.exitCode = code;
342
+ return true;
343
+ }
344
+ /**
345
+ * The live-input guards, in the order they run: each halts at exit 2 BEFORE any reconcile,
346
+ * because an input the tool cannot read is a usage error, never a verdict.
347
+ */
348
+ function refuseUnreadableInputs(args) {
349
+ // Input-existence guard: a supplied input-file/dir flag pointing at a
264
350
  // non-existent path is a HARD ERROR — halt BEFORE reconciling, with a typed message
265
351
  // naming the flag + path, never the silent zero-evidence all-red report a mistyped
266
352
  // path used to produce (the dogfooding fault). Routed to stderr + a non-zero exit,
267
- // exactly as the legacy-`--json` guard above (the shared typed-error surface). An
353
+ // exactly as the legacy-`--json` guard (the shared typed-error surface). An
268
354
  // omitted flag is legitimately optional and passes through untouched.
269
355
  const missingInputError = checkInputsExist(args, existsSync);
270
- if (missingInputError) {
271
- process.stderr.write(missingInputError + "\n");
272
- // Usage/input error → exit 2 (@SCN-CLI-002), distinct from the out-of-balance
273
- // verdict code 1 (@SCN-CLI-004).
274
- process.exit(2);
275
- }
276
- // Input-READABILITY guard (@SCN-CLI-014): existence is not enough. A report that is
356
+ if (missingInputError)
357
+ exitUsage(missingInputError);
358
+ // Input-READABILITY guard: existence is not enough. A report that is
277
359
  // PRESENT but UNPARSEABLE (`--vitest /dev/null`, truncated JSON, non-JSON) used to throw
278
360
  // mid-parse inside reconcileWithCensus, uncaught — and Node's default exit code is 1, the
279
- // code reserved for a genuine OUT-OF-BALANCE verdict (@SCN-CLI-004). So a tool that
361
+ // code reserved for a genuine OUT-OF-BALANCE verdict. So a tool that
280
362
  // CRASHED before reconciling was indistinguishable, by exit code, from an honest
281
- // disagreement (3F-1742). Runs immediately after the existence guard and BEFORE any
363
+ // disagreement. Runs immediately after the existence guard and BEFORE any
282
364
  // reconcile, routing to the same stderr + exit 2 lane: an input the tool cannot read is a
283
365
  // usage fault, sibling to a missing path and an unrecognised flag — never a verdict.
284
366
  const unparseableInputError = checkInputsParse(args, (path) => readFileSync(path, "utf8"));
285
- if (unparseableInputError) {
286
- process.stderr.write(unparseableInputError + "\n");
287
- process.exit(2);
288
- }
289
- // The FEATURE-CORPUS integrity checkpoint (@SCN-CLI-016, 3F-1767). The feature corpus is an
367
+ if (unparseableInputError)
368
+ exitUsage(unparseableInputError);
369
+ // A corpus or workflow path that is not the kind its flag reads, or may not be read, cannot be read at all.
370
+ const unreadablePathError = checkInputPaths(args);
371
+ if (unreadablePathError)
372
+ exitUsage(unreadablePathError);
373
+ // Parsing is not reading: a report that parses but is not in the shape its reader expects would
374
+ // be read as nothing, or read until it threw, so it is refused here with where its shape is wrong.
375
+ const misshapenInputError = checkInputShapes(jsonInputsOf(args, existsSync), (path) => readFileSync(path, "utf8"));
376
+ if (misshapenInputError)
377
+ exitUsage(misshapenInputError);
378
+ // The FEATURE-CORPUS integrity checkpoint. The feature corpus is an
290
379
  // input too — the ledger's DEBIT side — and it was read by a hand-rolled TAG-LINE SCAN that
291
380
  // never consulted a parser. So a malformed tag line simply stopped being a tag line and its
292
381
  // scenario CEASED TO EXIST: one stray character deleted a failing row and turned an
@@ -297,10 +386,10 @@ export function runBalance(argv) {
297
386
  // Validated with the REAL Gherkin parser (cucumber's own), so a file the tool would reconcile is
298
387
  // at least a file cucumber would RUN. A file it rejects is an input we CANNOT READ: exit 2,
299
388
  // halting BEFORE any reconcile, naming the file and the parse failure — the same Rule as
300
- // @SCN-CLI-014 and @SCN-CLI-015 ("an input the tool cannot read is a usage error, never a
389
+ // the unparseable-report and unreadable-saved-report guards ("an input the tool cannot read is a usage error, never a
301
390
  // verdict"), now on the obligation side. Never a verdict, and never a green.
302
391
  //
303
- // The corpus is EXTRACTED from the same real AST (@SCN-LDG-020), so what the tool reconciles is
392
+ // The corpus is EXTRACTED from the same real AST, so what the tool reconciles is
304
393
  // what cucumber runs — Feature/Rule tag inheritance included. This guard decides whether the file
305
394
  // can be read at all; parseScenarios then reads it exactly as cucumber would.
306
395
  const featureParseErrors = featureCorpusParseErrors(args["features"]);
@@ -310,50 +399,16 @@ export function runBalance(argv) {
310
399
  }
311
400
  process.exit(2);
312
401
  }
313
- // AMBIGUOUS-CORPUS guard (@SCN-LDG-020's guardrail, 3F-1775). An @SCN identifies exactly ONE
314
- // scenario — that is the whole basis of attributing evidence to it. Tag inheritance makes a
315
- // DUPLICATE reachable: an @SCN hoisted to a Feature:/Rule: is inherited by every scenario
316
- // beneath it (cucumber does this, so we do). One passing test would then mark EVERY scenario
317
- // sharing that id `balanced` — including scenarios nothing proves — a BALANCED-WHEN-BROKEN run
318
- // at exit 0. A new silent false-green, introduced by the very fix that closed the last one.
319
- //
320
- // So the corpus is REFUSED, not reconciled: the tool cannot know which scenario a citing test
321
- // proves, and any verdict over it would be a guess. Same Rule as @SCN-CLI-014/015/016 — an input
322
- // the tool cannot read is a usage error, never a verdict.
323
- const duplicateScnIds = featureCorpusDuplicateScnIds(args["features"]);
324
- if (duplicateScnIds.length > 0) {
325
- process.stderr.write(`The --features corpus names the same @SCN on more than one scenario: ` +
326
- `${duplicateScnIds.join(", ")}.\n` +
327
- `An @SCN identifies exactly one scenario — evidence citing a duplicated id cannot be ` +
328
- `attributed, so no verdict over this corpus would be trustworthy. (An @SCN on a Feature: ` +
329
- `or Rule: line is inherited by every scenario beneath it — name it on the scenario.)\n`);
330
- process.exit(2);
331
- }
332
- const inputs = reconcileInputsOf(args);
333
- // Reconcile the obligation-vs-observation evidence into the Ledger. The runtime evidence-observation
334
- // views (Test volume, the evidence grid) are FOLDS the renderers derive off this Ledger's posted
335
- // credits (@SCN-RPT-010 / 3F-2103 — the census is retired), so they cannot drift from the
336
- // reconciler's verdict: they ARE the same rows. `withRuntimeObservations: true` (below) tells the
337
- // renderers the axis is in play.
338
- const balance = runReconcile(inputs);
339
- // Read the scenario-corpus census alongside the balance (@SCN-RPT-008) —
340
- // the raw-vs-parsed @SCN-occurrence delta over the same discovered feature corpus.
341
- // A sibling to the balance, threaded to the renderers via writeOutputs like ctx.
342
- const evidenceObligations = readEvidenceObligations(args["features"]);
343
- // The static-check kind-counts are a FOLD OVER THE LEDGER (@SCN-USG-001 / 3F-2118) — the
344
- // renderers count the balance's own posted static-check credits (`staticObservationTally`), so
345
- // they cannot drift from the reconciler's verdict: they ARE the same rows. The parallel static
346
- // census is RETIRED; `withStaticChecks: true` (below) tells the renderers the axis is in play.
347
- // Read the target's feature-code→slug map alongside the balance (@SCN-RPT-006)
348
- // — the by-feature cut's full-name labels. The 5th sibling, threaded to the MARKDOWN
349
- // renderer via writeOutputs only (markdown-only; the by-feature cut isn't on JSON).
350
- const featureNames = readFeatureNames(args["features"]);
351
- // Build the run provenance ONCE (project design §1) — a sibling RunContext, the
352
- // Ledger staying pure — reusing the existing runStart. The target is the
353
- // operator's --target VERBATIM (unknown-target on omit; Fork A — no
354
- // resolution, no basename guessing). Source SHA via the read-only target read, with
355
- // --source-sha as the override (resolveSourceSha).
356
- const ctx = {
402
+ }
403
+ /**
404
+ * Build the run provenance ONCE (project design §1) — a sibling RunContext, the
405
+ * Ledger staying pure — reusing the existing runStart. The target is the
406
+ * operator's --target VERBATIM (unknown-target on omit; Fork A — no
407
+ * resolution, no basename guessing). Source SHA via the read-only target read, with
408
+ * --source-sha as the override (resolveSourceSha).
409
+ */
410
+ function runContextOf(args, inputs, runStart) {
411
+ return {
357
412
  target: sourceTarget(args),
358
413
  inputs,
359
414
  runStart,
@@ -362,118 +417,18 @@ export function runBalance(argv) {
362
417
  targetDir: args["features"] !== undefined ? dirname(args["features"]) : undefined,
363
418
  }),
364
419
  // The TARGET repo root the recorded input paths are made portable against (fixed to
365
- // the target root, not the run cwd, by @SCN-RPT-016) — the SAME
420
+ // the target root, not the run cwd) — the SAME
366
421
  // root readEvidenceObservations passes to countRuntimeEvidenceObservationKinds, derived from
367
422
  // --features, so a cross-repo reconcile records inputs relative (`features`) rather
368
423
  // than leaking the target's absolute path.
369
424
  root: resolveTargetRoot({ features: inputs.features }),
370
- // The TOOL's own identity (@SCN-RPT-026, 3F-1916) — which spec-controller produced
425
+ // The TOOL's own identity — which spec-controller produced
371
426
  // this run, distinct from the target's sourceSha. Read from the tool's OWN dir, never
372
427
  // cwd (which is the target). One call, spread into the ctx; the same identity feeds
373
428
  // the run.yaml manifest.
374
429
  ...resolveToolIdentity(),
430
+ strict: args["strict"] !== undefined,
375
431
  };
376
- // Plan the outputs from the --format specs (no flag → md→stdout) and write them via
377
- // the writer — the pure plan / impure writer split (@SCN-FMT-001). The plan
378
- // parse+validate is pure; the writer is the only IO for the emitted report. This is
379
- // the SOLE output path: the tool emits via --format and holds no storage opinion — the
380
- // legacy runs/<target>/<ISO>/ auto-archive was removed with @SCN-RUN-001, the
381
- // caller now routes stored runs (spec-controller's run-management layer).
382
- const planResult = planOutput(collectFormatSpecs(argv));
383
- if (!planResult.ok) {
384
- process.stderr.write(planResult.error + "\n");
385
- // Usage/config error (bad --format spec) → exit 2 (@SCN-CLI-002), distinct from the
386
- // out-of-balance verdict code 1 (@SCN-CLI-004).
387
- process.exit(2);
388
- }
389
- writeOutputs(planResult.plan, { balance, ctx, evidenceObligations, withRuntimeObservations: true, withStaticChecks: true, featureNames });
390
- // The CI gate (@SCN-CLI-004): `balance` is a GATE, not just a reporter. AFTER the report
391
- // is written in full, map the whole-run verdict to the process exit code. Out-of-balance
392
- // (ANY reconciling item anywhere: an out-of-balance scenario OR a suspense-row item such
393
- // as a no-evidence-obligation static-check watermelon) sets process.exitCode = 1 and RETURNS — set,
394
- // never `process.exit(1)`, so the buffered report is flushed intact rather than truncated
395
- // mid-write. It exits EXACTLY 1 so it reds a CI build and can't masquerade as the usage
396
- // error (2, above). An ENUMERATED status, never a count (8-bit wrap → false pass). A
397
- // balanced run leaves process.exitCode at its default 0 and returns.
398
- //
399
- // The --strict escalation (@SCN-CLI-011): Pending Items are run-neutral by DEFAULT
400
- // (@SCN-PND-008 — they never red a build), but `--strict` is the OPT-IN that fails an
401
- // OTHERWISE-BALANCED run carrying them, exiting the DISTINCT code 3 (taxonomy 0 balanced /
402
- // 1 out-of-balance / 2 usage / 3 pending-under-strict / 4 unsound / 5 nothing to reconcile —
403
- // a Pending Item is not out-of-balance,
404
- // so it must NEVER share code 1). Out-of-balance DOMINATES: the escalation is checked ONLY in
405
- // the `isBalanced` branch, so an out-of-balance run exits 1 regardless of --strict. Like the
406
- // CLI-004 gate this SETS process.exitCode (never process.exit) AFTER writeOutputs, so the
407
- // buffered report is flushed intact. `--strict` is a registry-LISTED boolean, recorded by
408
- // parseArgs as the string "true" — presence is what matters, so `!== undefined` reads it.
409
- //
410
- // The UNSOUND pre-emption (@SCN-CLI-012): a run carrying BROKEN evidence — a proving suite
411
- // that failed to COLLECT, so the behaviour was never exercised — exits the DISTINCT code 4
412
- // (taxonomy 0 balanced / 1 out-of-balance / 2 usage / 3 pending-under-strict / 4 unsound /
413
- // 5 nothing to reconcile).
414
- // Exit 1 says "the ledger disagrees"; exit 4 says "the ledger could not be TRUSTED to
415
- // disagree" — you cannot trust a reconciliation whose evidence never materialised, so
416
- // unsound DOMINATES.
417
- //
418
- // THE ORDER IS LOAD-BEARING, NOT COSMETIC. A broken run is ALWAYS out-of-balance: its
419
- // `broken` item sits on the SUSPENSE row, so `isBalanced` is already false. Check it AFTER
420
- // `isBalanced` and the branch is UNREACHABLE — every broken run would exit 1 and the
421
- // collection failure would stay masked as an honest disagreement (the AWTY dogfood fault,
422
- // 3F-1642). So `hasBrokenEvidence` is checked FIRST, PRE-EMPTING the balance verdict it
423
- // would otherwise be swallowed by. @SCN-CLI-004 is a BYSTANDER, not an Update: its fixture
424
- // is a SOUND out-of-balance run (no broken item), so it falls through this branch and still
425
- // exits exactly 1 — the pre-emption fires only when evidence genuinely failed to collect.
426
- //
427
- // THE NOTHING-TO-RECONCILE PRE-EMPTION (@SCN-CLI-019): a run whose ledger carries ZERO Scenario
428
- // Reconciliation rows — a target that declared nothing AND produced nothing — exits the DISTINCT
429
- // code 5. It is its own state: not balanced (no accounts to agree), not out-of-balance (nothing
430
- // disagrees), not a usage fault (the invocation was valid and `new-client` is a supported
431
- // adoption state), not unsound (no proof failed; none was owed).
432
- //
433
- // IT IS NOT THE UNSOUND PRE-EMPTION ONE CODE FURTHER ON, THOUGH IT LOOKS LIKE IT — measured,
434
- // because the ticket predicted it was. An empty ledger is MUTUALLY EXCLUSIVE with all three
435
- // predicates above: with no rows there is no row to carry a broken item, a reconciling item or a
436
- // Pending Item, so it fires none of them. Moved below `!isBalanced`, this branch STILL returns 5
437
- // (the prescribed plant stayed green). Its position among the predicates is free, and it is
438
- // first only so this ladder and `runVerdictSection` read in the SAME order — the shape
439
- // @SCN-CLI-013's invariant leans on.
440
- //
441
- // WHAT IS LOAD-BEARING is its position against the DEFAULT below. "Balanced" is not a branch
442
- // here, it is the `return 0` a run reaches by failing every test — so there is no "after the
443
- // balanced case" to append a fifth code to. Appended after that return the branch is dead code
444
- // and an empty run exits 0 (measured: the plant that DID red). That is why the fault survived a
445
- // taxonomy that had already grown twice: a fifth code cannot be ADDED to this chain, it has to
446
- // DISPLACE the default, and nothing about the other three says so.
447
- //
448
- // Keyed on the EMPTY LEDGER, never on the empty corpus: a corpus-less run carrying uncited
449
- // credits HAS a row — its suspense row — and is an ordinary out-of-balance report at exit 1
450
- // (@SCN-CLI-020). @SCN-CLI-004/005/010/012 are BYSTANDERS: every one of their fixtures carries
451
- // rows, so none reaches this branch.
452
- const code = verdictExitCode(balance.ledger, args["strict"] !== undefined);
453
- // Assigned only when NON-ZERO, so a balanced run still LEAVES process.exitCode at its default
454
- // rather than writing a 0 over it — the distinction @SCN-CLI-005 and @SCN-CLI-010 both rest on.
455
- if (code !== 0)
456
- process.exitCode = code;
457
- }
458
- /**
459
- * THE DOMINANCE LADDER — the whole-run verdict as the enumerated status a consumer's CI branches
460
- * on, and the ONE place the order argued for above is written down. Extracted from `runBalance`
461
- * (3F-3344) so the ladder is a named thing with its own reading rather than a tail the reader
462
- * arrives at, and so the pre-emption argument sits ON it.
463
- *
464
- * ENUMERATED, NEVER A COUNT — a count would 8-bit `& 0xFF` wrap into a false pass. Returns 0 for a
465
- * balanced run; the caller leaves the process's default alone rather than writing that 0 back.
466
- */
467
- function verdictExitCode(ledger, strict) {
468
- if (hasNothingToReconcile(ledger))
469
- return 5;
470
- if (hasBrokenEvidence(ledger))
471
- return 4;
472
- if (!isBalanced(ledger))
473
- return 1;
474
- if (strict && hasPendingItems(ledger))
475
- return 3;
476
- return 0;
477
432
  }
478
433
  // Only run the balance command when this module is executed directly
479
434
  // (`tsx cli-balance/cli.ts`), NOT when imported by the top-level `spec-controller`