@rsaglobaltech/specgate 0.8.1 → 0.10.0

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 (144) hide show
  1. package/CHANGELOG.md +196 -0
  2. package/README.md +17 -25
  3. package/bin/specgate.ts +23 -5
  4. package/dist/bin/specgate.js +19 -4
  5. package/dist/bin/specgate.js.map +1 -1
  6. package/dist/packages/core/src/application/GenerateAgentPromptUseCase.js +56 -0
  7. package/dist/packages/core/src/application/GenerateAgentPromptUseCase.js.map +1 -1
  8. package/dist/packages/core/src/application/ValidateProjectUseCase.js +54 -0
  9. package/dist/packages/core/src/application/ValidateProjectUseCase.js.map +1 -1
  10. package/dist/packages/core/src/domain/AdversarialProbe.js +75 -0
  11. package/dist/packages/core/src/domain/AdversarialProbe.js.map +1 -0
  12. package/dist/packages/core/src/domain/AgentPrompt.js +52 -3
  13. package/dist/packages/core/src/domain/AgentPrompt.js.map +1 -1
  14. package/dist/packages/core/src/domain/DeclaredArtifacts.js +25 -0
  15. package/dist/packages/core/src/domain/DeclaredArtifacts.js.map +1 -1
  16. package/dist/packages/core/src/domain/DoneVerification.js +89 -0
  17. package/dist/packages/core/src/domain/DoneVerification.js.map +1 -0
  18. package/dist/packages/core/src/domain/GateCommandIntegrity.js +139 -0
  19. package/dist/packages/core/src/domain/GateCommandIntegrity.js.map +1 -0
  20. package/dist/packages/core/src/domain/GherkinQuality.js +19 -3
  21. package/dist/packages/core/src/domain/GherkinQuality.js.map +1 -1
  22. package/dist/packages/core/src/domain/HarnessConfig.js +6 -0
  23. package/dist/packages/core/src/domain/HarnessConfig.js.map +1 -1
  24. package/dist/packages/core/src/domain/MultiStack.js +96 -0
  25. package/dist/packages/core/src/domain/MultiStack.js.map +1 -0
  26. package/dist/packages/core/src/domain/NewRequirement.js +46 -0
  27. package/dist/packages/core/src/domain/NewRequirement.js.map +1 -0
  28. package/dist/packages/core/src/domain/Precedents.js +96 -0
  29. package/dist/packages/core/src/domain/Precedents.js.map +1 -0
  30. package/dist/packages/core/src/domain/RequirementPlan.js +1 -0
  31. package/dist/packages/core/src/domain/RequirementPlan.js.map +1 -1
  32. package/dist/packages/core/src/domain/RequirementRemoval.js +161 -0
  33. package/dist/packages/core/src/domain/RequirementRemoval.js.map +1 -0
  34. package/dist/packages/core/src/domain/ScenarioCoverage.js +178 -0
  35. package/dist/packages/core/src/domain/ScenarioCoverage.js.map +1 -0
  36. package/dist/packages/core/src/domain/SpecSections.js +206 -0
  37. package/dist/packages/core/src/domain/SpecSections.js.map +1 -0
  38. package/dist/packages/core/src/domain/TestCommand.js +82 -0
  39. package/dist/packages/core/src/domain/TestCommand.js.map +1 -0
  40. package/dist/packages/core/src/domain/TraceabilityFormat.js +45 -2
  41. package/dist/packages/core/src/domain/TraceabilityFormat.js.map +1 -1
  42. package/dist/packages/core/src/infrastructure/HarnessConfigFile.js +31 -0
  43. package/dist/packages/core/src/infrastructure/HarnessConfigFile.js.map +1 -1
  44. package/dist/packages/core/test/unit/adversarial-probe.test.js +81 -0
  45. package/dist/packages/core/test/unit/adversarial-probe.test.js.map +1 -0
  46. package/dist/packages/core/test/unit/done-verification.test.js +84 -0
  47. package/dist/packages/core/test/unit/done-verification.test.js.map +1 -0
  48. package/dist/packages/core/test/unit/gate-command-integrity.test.js +114 -0
  49. package/dist/packages/core/test/unit/gate-command-integrity.test.js.map +1 -0
  50. package/dist/packages/core/test/unit/gherkin-quality.test.js +28 -0
  51. package/dist/packages/core/test/unit/gherkin-quality.test.js.map +1 -1
  52. package/dist/packages/core/test/unit/multi-stack.test.js +86 -0
  53. package/dist/packages/core/test/unit/multi-stack.test.js.map +1 -0
  54. package/dist/packages/core/test/unit/new-requirement.test.js +67 -0
  55. package/dist/packages/core/test/unit/new-requirement.test.js.map +1 -0
  56. package/dist/packages/core/test/unit/precedents.test.js +99 -0
  57. package/dist/packages/core/test/unit/precedents.test.js.map +1 -0
  58. package/dist/packages/core/test/unit/requirement-removal.test.js +124 -0
  59. package/dist/packages/core/test/unit/requirement-removal.test.js.map +1 -0
  60. package/dist/packages/core/test/unit/scenario-coverage.test.js +146 -0
  61. package/dist/packages/core/test/unit/scenario-coverage.test.js.map +1 -0
  62. package/dist/packages/core/test/unit/spec-sections.test.js +146 -0
  63. package/dist/packages/core/test/unit/spec-sections.test.js.map +1 -0
  64. package/dist/packages/core/test/unit/test-command.test.js +81 -0
  65. package/dist/packages/core/test/unit/test-command.test.js.map +1 -0
  66. package/dist/packages/core/test/unit/validate-project.test.js +21 -0
  67. package/dist/packages/core/test/unit/validate-project.test.js.map +1 -1
  68. package/dist/scripts/agents/commands.js +1 -1
  69. package/dist/scripts/agents/commands.js.map +1 -1
  70. package/dist/scripts/agents/init.js +59 -15
  71. package/dist/scripts/agents/init.js.map +1 -1
  72. package/dist/scripts/change/instructions.js +2 -2
  73. package/dist/scripts/change/instructions.js.map +1 -1
  74. package/dist/scripts/check.js +10 -0
  75. package/dist/scripts/check.js.map +1 -0
  76. package/dist/scripts/cli/commands/change/ChangeCommand.js +2 -2
  77. package/dist/scripts/cli/commands/change/ChangeCommand.js.map +1 -1
  78. package/dist/scripts/cli/commands/config/CiInitCommand.js +1 -1
  79. package/dist/scripts/cli/commands/config/CiInitCommand.js.map +1 -1
  80. package/dist/scripts/cli/commands/harness/RunCommand.js +214 -9
  81. package/dist/scripts/cli/commands/harness/RunCommand.js.map +1 -1
  82. package/dist/scripts/cli/commands/pack/InferPackCommand.js +331 -31
  83. package/dist/scripts/cli/commands/pack/InferPackCommand.js.map +1 -1
  84. package/dist/scripts/cli/commands/project/AdoptCommand.js +86 -9
  85. package/dist/scripts/cli/commands/project/AdoptCommand.js.map +1 -1
  86. package/dist/scripts/cli/commands/project/InitProjectCommand.js +286 -45
  87. package/dist/scripts/cli/commands/project/InitProjectCommand.js.map +1 -1
  88. package/dist/scripts/cli/commands/project/UpdateCommand.js +118 -8
  89. package/dist/scripts/cli/commands/project/UpdateCommand.js.map +1 -1
  90. package/dist/scripts/cli/commands/quality/CheckCommand.js +184 -0
  91. package/dist/scripts/cli/commands/quality/CheckCommand.js.map +1 -0
  92. package/dist/scripts/cli/commands/quality/DoctorCommand.js +20 -2
  93. package/dist/scripts/cli/commands/quality/DoctorCommand.js.map +1 -1
  94. package/dist/scripts/cli/commands/quality/FixCommand.js +1 -1
  95. package/dist/scripts/cli/commands/quality/FixCommand.js.map +1 -1
  96. package/dist/scripts/cli/commands/quality/ReportCommand.js +1 -1
  97. package/dist/scripts/cli/commands/quality/ReportCommand.js.map +1 -1
  98. package/dist/scripts/cli/commands/quality/ValidateSpecsCommand.js +406 -24
  99. package/dist/scripts/cli/commands/quality/ValidateSpecsCommand.js.map +1 -1
  100. package/dist/scripts/cli/commands/spec/DoneCommand.js +108 -0
  101. package/dist/scripts/cli/commands/spec/DoneCommand.js.map +1 -1
  102. package/dist/scripts/cli/commands/spec/NewCommand.js +178 -0
  103. package/dist/scripts/cli/commands/spec/NewCommand.js.map +1 -0
  104. package/dist/scripts/cli/commands/spec/PlanCommand.js +8 -2
  105. package/dist/scripts/cli/commands/spec/PlanCommand.js.map +1 -1
  106. package/dist/scripts/cli/commands/spec/ReqCommand.js +124 -4
  107. package/dist/scripts/cli/commands/spec/ReqCommand.js.map +1 -1
  108. package/dist/scripts/cli/commands/spec/StatusCommand.js +84 -32
  109. package/dist/scripts/cli/commands/spec/StatusCommand.js.map +1 -1
  110. package/dist/scripts/docs/build-site.js +145 -35
  111. package/dist/scripts/docs/build-site.js.map +1 -1
  112. package/dist/scripts/docs/components.js +208 -0
  113. package/dist/scripts/docs/components.js.map +1 -0
  114. package/dist/scripts/docs/highlight.js +180 -0
  115. package/dist/scripts/docs/highlight.js.map +1 -0
  116. package/dist/scripts/docs/icons.js +63 -0
  117. package/dist/scripts/docs/icons.js.map +1 -0
  118. package/dist/scripts/docs/nav.js +9 -3
  119. package/dist/scripts/docs/nav.js.map +1 -1
  120. package/dist/scripts/gen_mcp_tools.js +22 -2
  121. package/dist/scripts/gen_mcp_tools.js.map +1 -1
  122. package/dist/scripts/harness/init.js +101 -40
  123. package/dist/scripts/harness/init.js.map +1 -1
  124. package/dist/scripts/harness/worktree-lock.js +161 -0
  125. package/dist/scripts/harness/worktree-lock.js.map +1 -0
  126. package/dist/scripts/infer_pack.js +4 -1
  127. package/dist/scripts/infer_pack.js.map +1 -1
  128. package/dist/scripts/lib/surface.js +88 -38
  129. package/dist/scripts/lib/surface.js.map +1 -1
  130. package/dist/scripts/new.js +10 -0
  131. package/dist/scripts/new.js.map +1 -0
  132. package/dist/scripts/plugin/gate-hook.js +2 -2
  133. package/dist/scripts/plugin/gate-hook.js.map +1 -1
  134. package/package.json +5 -2
  135. package/templates/adopt/AI_RULES.md.tpl +1 -1
  136. package/templates/adopt/spec.md.tpl +4 -4
  137. package/templates/base/spec.md.tpl +12 -0
  138. package/templates/ci/azure.yml.tpl +2 -2
  139. package/templates/ci/github.yml.tpl +2 -2
  140. package/templates/ci/gitlab.yml.tpl +1 -1
  141. package/templates/ci/jenkins.tpl +1 -1
  142. package/templates/contracts/test-strategy.md.tpl +2 -2
  143. package/templates/harness/harness.config.yaml.tpl +3 -3
  144. package/templates/harness/prompt-prefix.md.tpl +2 -5
@@ -44,16 +44,26 @@ const DiskProjectRepository_1 = require("../../../../packages/core/src/infrastru
44
44
  const diagnostics_1 = require("../../../lib/diagnostics");
45
45
  const against_lock_1 = require("../../../specops/against_lock");
46
46
  const requirement_graph_1 = require("../../../lib/requirement-graph");
47
+ const MultiStack_1 = require("../../../../packages/core/src/domain/MultiStack");
48
+ const ScenarioCoverage_1 = require("../../../../packages/core/src/domain/ScenarioCoverage");
49
+ const TraceabilityFormat_1 = require("../../../../packages/core/src/domain/TraceabilityFormat");
50
+ const crypto = __importStar(require("node:crypto"));
47
51
  const agent_1 = require("../../../lib/agent");
48
52
  const placeholders_1 = require("../../../lib/placeholders");
49
53
  const command_1 = require("../../../lib/command");
50
54
  const ValidateProjectUseCase_1 = require("../../../../packages/core/src/application/ValidateProjectUseCase");
51
55
  const DiskTraceabilityRepository_1 = require("../../../../packages/core/src/infrastructure/DiskTraceabilityRepository");
52
- const TraceabilityFormat_1 = require("../../../../packages/core/src/domain/TraceabilityFormat");
56
+ const TraceabilityFormat_2 = require("../../../../packages/core/src/domain/TraceabilityFormat");
53
57
  const project_root_1 = require("../../../lib/project-root");
54
58
  const GherkinQuality_1 = require("../../../../packages/core/src/domain/GherkinQuality");
59
+ /** Scenario findings that make a suite report a pass it did not run. */
60
+ const SUITE_LIES = new Set([
61
+ GherkinQuality_1.QUALITY_CODES.NO_STEPS,
62
+ GherkinQuality_1.QUALITY_CODES.KEYWORD_CASE,
63
+ GherkinQuality_1.QUALITY_CODES.OUTLINE_WITHOUT_EXAMPLES,
64
+ ]);
55
65
  const GherkinTags_1 = require("../../../../packages/core/src/domain/GherkinTags");
56
- const TraceabilityFormat_2 = require("../../../../packages/core/src/domain/TraceabilityFormat");
66
+ const TraceabilityFormat_3 = require("../../../../packages/core/src/domain/TraceabilityFormat");
57
67
  const RequirementSyntax_1 = require("../../../../packages/core/src/domain/RequirementSyntax");
58
68
  const DeclaredArtifacts_1 = require("../../../../packages/core/src/domain/DeclaredArtifacts");
59
69
  const SpecParser_1 = require("../../../../packages/core/src/domain/SpecParser");
@@ -69,7 +79,8 @@ class ValidateSpecsCommand extends command_1.BaseCommand {
69
79
  }
70
80
  usage() {
71
81
  process.stdout.write("🔎 Usage:\n" +
72
- " validate_specs.js <project_dir> [--strict-tdd] [--strict-scenarios] " +
82
+ " specgate validate <project_dir> [--strict] [--strict-tdd] [--strict-scenarios] " +
83
+ " [--strict-coverage] " +
73
84
  "[--strict-requirements] [--strict-links] [--against-lock]\n\n" +
74
85
  "Checks:\n" +
75
86
  "- minimum SDD structure\n" +
@@ -93,6 +104,14 @@ class ValidateSpecsCommand extends command_1.BaseCommand {
93
104
  "--strict-requirements additionally enforces, over docs/specs/capabilities/**/spec.md:\n" +
94
105
  "- Every requirement states an obligation (SHALL / MUST / SHOULD / MAY / DEBE / DEBERÁ)\n" +
95
106
  "- A requirement that opens with IF resolves with THEN in the same sentence\n\n" +
107
+ "--strict is the gate: every check below, in one flag. Prefer it — the\n" +
108
+ "individual flags exist so an existing CI invocation keeps meaning what it meant.\n\n" +
109
+ "--strict-coverage additionally enforces:\n" +
110
+ "- Every scenario in a declared feature file is named by the test artifact that\n" +
111
+ " claims to prove it\n" +
112
+ "- Every declared test artifact names its requirement or one of its scenarios —\n" +
113
+ " a path that exists is not evidence that it proves anything\n" +
114
+ " Both are name matches, so this is opt-in: it does not run your suite\n\n" +
96
115
  "--strict-links additionally enforces:\n" +
97
116
  "- Every Feature file / Technical artifact / Test artifact the matrix declares as a\n" +
98
117
  " path still exists on disk\n");
@@ -138,7 +157,64 @@ class ValidateSpecsCommand extends command_1.BaseCommand {
138
157
  this.logFix(fixLines);
139
158
  process.exit(exitCode);
140
159
  }
141
- validateMonorepo(targetDir, strictTdd, strictScenarios, strictRequirements, strictLinks) {
160
+ /**
161
+ * A shared spec that is only shared by convention is not shared.
162
+ *
163
+ * `init --multi-stack` symlinks spec.md, features/ and the ADR index into
164
+ * every stack, so there is one file with several names. Windows refuses to
165
+ * create symlinks without Developer Mode, and scaffolding copies instead —
166
+ * at which point "shared" is a claim rather than a fact, and the three stacks
167
+ * can quietly start describing different products.
168
+ *
169
+ * So the guarantee moves into the gate: a copy that no longer matches the
170
+ * root is an error. Symlinked trees skip this, because they cannot drift.
171
+ */
172
+ sharedSpecDrift(rootDir, projectRel) {
173
+ const digest = (target) => {
174
+ const h = crypto.createHash("sha256");
175
+ const stat = fs.statSync(target);
176
+ if (stat.isFile()) {
177
+ h.update(fs.readFileSync(target));
178
+ return h.digest("hex");
179
+ }
180
+ const entries = [];
181
+ const walkInto = (dir) => {
182
+ for (const name of fs.readdirSync(dir).sort()) {
183
+ const full = path.join(dir, name);
184
+ if (fs.statSync(full).isDirectory())
185
+ walkInto(full);
186
+ else
187
+ entries.push(path.relative(target, full).split(path.sep).join("/"));
188
+ }
189
+ };
190
+ walkInto(target);
191
+ for (const rel of entries) {
192
+ h.update(rel);
193
+ h.update(fs.readFileSync(path.join(target, rel)));
194
+ }
195
+ return h.digest("hex");
196
+ };
197
+ const drifted = [];
198
+ for (const shared of MultiStack_1.SHARED_PATHS) {
199
+ const atRoot = path.join(rootDir, shared);
200
+ const inProject = path.join(projectRel, shared);
201
+ if (!fs.existsSync(atRoot) || !fs.existsSync(inProject))
202
+ continue;
203
+ // A link cannot drift from what it points at.
204
+ const link = fs.lstatSync(inProject, { throwIfNoEntry: false });
205
+ if (link && link.isSymbolicLink())
206
+ continue;
207
+ try {
208
+ if (digest(atRoot) !== digest(inProject))
209
+ drifted.push(shared);
210
+ }
211
+ catch {
212
+ // Unreadable is a different problem, and the normal checks report it.
213
+ }
214
+ }
215
+ return drifted;
216
+ }
217
+ validateMonorepo(targetDir, strictTdd, strictScenarios, strictRequirements, strictLinks, strictCoverage) {
142
218
  const cfgPath = path.join(targetDir, "specops.config.yaml");
143
219
  if (!fs.existsSync(cfgPath))
144
220
  return null;
@@ -181,9 +257,24 @@ class ValidateSpecsCommand extends command_1.BaseCommand {
181
257
  args.push("--strict-requirements");
182
258
  if (strictLinks)
183
259
  args.push("--strict-links");
260
+ if (strictCoverage)
261
+ args.push("--strict-coverage");
184
262
  const r = (0, node_child_process_1.spawnSync)(process.execPath, args, { encoding: "utf8" });
185
263
  process.stdout.write(r.stdout || "");
186
264
  process.stderr.write(r.stderr || "");
265
+ const drifted = this.sharedSpecDrift(targetDir, subDir);
266
+ if (drifted.length > 0) {
267
+ this.logError(`Shared spec has drifted in ${rel}: ${drifted.join(", ")} no longer matches the root`);
268
+ this.logFix([
269
+ "These paths are meant to be one artifact for the whole tree. They were copied",
270
+ "rather than symlinked (this platform would not create links), so an edit in one",
271
+ "stack does not reach the others.",
272
+ `Fix: copy the root's version over ${rel}'s, or move the edit to the root and`,
273
+ "copy outward — then commit both.",
274
+ ]);
275
+ results.push({ project: rel, ok: false, detail: "shared spec drift" });
276
+ continue;
277
+ }
187
278
  results.push({ project: rel, ok: r.status === 0 });
188
279
  }
189
280
  const failures = results.filter((r) => !r.ok);
@@ -211,11 +302,15 @@ class ValidateSpecsCommand extends command_1.BaseCommand {
211
302
  * this tool uses everywhere else. Under the flag, warnings fail too — asking
212
303
  * for strict and getting lenient is the H14 mistake in a different costume.
213
304
  */
214
- checkScenarioQuality(targetDir, featureFiles) {
305
+ checkScenarioQuality(targetDir, featureFiles, delivering = "") {
306
+ const owed = this.featureFilesOwed(targetDir, delivering);
215
307
  const findings = [];
216
308
  for (const file of featureFiles.slice().sort()) {
217
309
  const rel = path.relative(targetDir, file).split(path.sep).join("/");
218
- findings.push(...(0, GherkinQuality_1.analyseGherkinSource)(fs.readFileSync(file, "utf8"), rel));
310
+ const found = (0, GherkinQuality_1.analyseGherkinSource)(fs.readFileSync(file, "utf8"), rel);
311
+ // A scenario Cucumber reports as passing without running anything lies
312
+ // whatever the row's status (H14). The rest is owed on delivery.
313
+ findings.push(...(owed(rel) ? found : found.filter((d) => SUITE_LIES.has(d.code))));
219
314
  }
220
315
  if (findings.length === 0)
221
316
  return;
@@ -234,6 +329,41 @@ class ValidateSpecsCommand extends command_1.BaseCommand {
234
329
  ]);
235
330
  process.exit(1);
236
331
  }
332
+ /**
333
+ * Which feature files the scenario rules apply to: the same line as
334
+ * `--strict-links` — a Draft row owes nothing yet, a delivered one owes
335
+ * everything. `specgate new` writes a scenario of `<placeholder>` steps for a
336
+ * person to fill in; failing the build the moment it exists would teach
337
+ * people to skip the gate, and passing it once delivered would be a green
338
+ * that means nothing. A file no row claims is always checked.
339
+ */
340
+ featureFilesOwed(targetDir, delivering) {
341
+ const tracePath = path.join(targetDir, "docs/specs/traceability.md");
342
+ if (!fs.existsSync(tracePath))
343
+ return () => true;
344
+ let rows = [];
345
+ try {
346
+ rows = (0, TraceabilityFormat_3.parseTraceabilityRows)(fs.readFileSync(tracePath, "utf8")).rows || [];
347
+ }
348
+ catch {
349
+ return () => true;
350
+ }
351
+ const claimed = new Set();
352
+ const owed = new Set();
353
+ for (const row of rows) {
354
+ const delivered = TraceabilityFormat_1.DELIVERED_STATUS.has(String(row.status || "").trim()) ||
355
+ (delivering !== "" && row.requirement === delivering);
356
+ for (const declared of (0, DeclaredArtifacts_1.declaredPaths)(row.featureFile)) {
357
+ const rel = (0, DeclaredArtifacts_1.artifactFile)(declared);
358
+ if (!rel)
359
+ continue;
360
+ claimed.add(rel);
361
+ if (delivered)
362
+ owed.add(rel);
363
+ }
364
+ }
365
+ return (rel) => owed.has(rel) || !claimed.has(rel);
366
+ }
237
367
  /**
238
368
  * Does the scenario the matrix names actually exist in the file? (F4)
239
369
  *
@@ -254,7 +384,7 @@ class ValidateSpecsCommand extends command_1.BaseCommand {
254
384
  checkScenarioTags(targetDir, traceContent) {
255
385
  let rows = [];
256
386
  try {
257
- rows = (0, TraceabilityFormat_2.parseTraceabilityRows)(traceContent).rows || [];
387
+ rows = (0, TraceabilityFormat_3.parseTraceabilityRows)(traceContent).rows || [];
258
388
  }
259
389
  catch {
260
390
  return;
@@ -309,10 +439,10 @@ class ValidateSpecsCommand extends command_1.BaseCommand {
309
439
  * Opt-in, same promise as `--strict-scenarios`: a project with aspirational
310
440
  * rows does not fail its next `validate` over this.
311
441
  */
312
- checkDeclaredArtifactsExist(targetDir, traceContent) {
442
+ checkDeclaredArtifactsExist(targetDir, traceContent, delivering = "") {
313
443
  let rows = [];
314
444
  try {
315
- rows = (0, TraceabilityFormat_2.parseTraceabilityRows)(traceContent).rows || [];
445
+ rows = (0, TraceabilityFormat_3.parseTraceabilityRows)(traceContent).rows || [];
316
446
  }
317
447
  catch {
318
448
  return;
@@ -325,11 +455,20 @@ class ValidateSpecsCommand extends command_1.BaseCommand {
325
455
  ["test artifact", "testArtifact"],
326
456
  ];
327
457
  for (const row of rows) {
458
+ // A row that has not been delivered names where the work is going to
459
+ // land; only a delivered one claims the file is there. Measured
460
+ // 2026-10-06: with `--strict` as the CI gate, every planned row failed
461
+ // the build — and in the harness, one pending requirement's paths failed
462
+ // its sibling's worktree.
463
+ const owesFiles = TraceabilityFormat_1.DELIVERED_STATUS.has(String(row.status || "").trim()) ||
464
+ (delivering !== "" && row.requirement === delivering);
465
+ if (!owesFiles)
466
+ continue;
328
467
  for (const [label, key] of columns) {
329
468
  for (const declared of (0, DeclaredArtifacts_1.declaredPaths)(row[key])) {
330
- // A cell may anchor a line range (`src/auth/login.ts#L15-L89`); the
331
- // anchor is not part of the filesystem path.
332
- const rel = declared.split("#")[0];
469
+ // A cell may point into a file — an anchor (`login.ts#L15-L89`) or a
470
+ // test selector (`test_x.py::test_y`). Neither is part of the filename.
471
+ const rel = (0, DeclaredArtifacts_1.artifactFile)(declared);
333
472
  if (!rel || seen.has(`${row.requirement || row.feature}::${rel}`))
334
473
  continue;
335
474
  if (fs.existsSync(path.join(targetDir, rel)))
@@ -358,6 +497,162 @@ class ValidateSpecsCommand extends command_1.BaseCommand {
358
497
  ]);
359
498
  process.exit(1);
360
499
  }
500
+ /**
501
+ * `--strict-coverage`: every declared scenario is named by something that
502
+ * claims to prove it (#168).
503
+ *
504
+ * A row names one `Scenario ID` and one feature file; the file may hold five
505
+ * scenarios, and nothing related the other four to anything. Measured: a
506
+ * feature declaring three scenarios with tests covering two passed
507
+ * `--strict-tdd --strict-scenarios --strict-links` all at once, with the row
508
+ * on `Implemented`.
509
+ *
510
+ * It is behind its own flag on purpose. The check is a name match — this tool
511
+ * does not run the suite and cannot know which assertion belongs to which
512
+ * scenario — and a project that names its tests some other way would fail
513
+ * through no fault of its own. Opting in is the honest interface for a
514
+ * heuristic.
515
+ */
516
+ checkScenarioCoverage(targetDir, traceContent) {
517
+ let rows = [];
518
+ try {
519
+ rows = (0, TraceabilityFormat_3.parseTraceabilityRows)(traceContent).rows || [];
520
+ }
521
+ catch {
522
+ return;
523
+ }
524
+ const readAll = (rel) => {
525
+ const full = path.join(targetDir, (0, DeclaredArtifacts_1.artifactFile)(rel));
526
+ if (!fs.existsSync(full))
527
+ return [];
528
+ const stat = fs.statSync(full);
529
+ if (stat.isFile())
530
+ return [fs.readFileSync(full, "utf8")];
531
+ const out = [];
532
+ const walkTests = (dir) => {
533
+ for (const name of fs.readdirSync(dir)) {
534
+ const child = path.join(dir, name);
535
+ if (fs.statSync(child).isDirectory())
536
+ walkTests(child);
537
+ else
538
+ out.push(fs.readFileSync(child, "utf8"));
539
+ }
540
+ };
541
+ walkTests(full);
542
+ return out;
543
+ };
544
+ const findings = [];
545
+ const advisories = [];
546
+ const seen = new Set();
547
+ // Read every row once first, so the check can calibrate before it judges.
548
+ const readRow = (row) => {
549
+ const declaredTests = (0, DeclaredArtifacts_1.declaredPaths)(row.testArtifact).filter((t) => t && t !== "-" && t.toUpperCase() !== "TBD");
550
+ const featureRel = (0, DeclaredArtifacts_1.declaredPaths)(row.featureFile)[0];
551
+ const featurePath = featureRel ? path.join(targetDir, (0, DeclaredArtifacts_1.artifactFile)(featureRel)) : null;
552
+ return {
553
+ requirement: row.requirement || "",
554
+ featureRel,
555
+ feature: featurePath && fs.existsSync(featurePath) ? fs.readFileSync(featurePath, "utf8") : "",
556
+ tests: declaredTests.flatMap(readAll),
557
+ declaredTests,
558
+ };
559
+ };
560
+ const read = rows.map(readRow).filter((r) => r.declaredTests.length > 0);
561
+ // Does this project name requirements in its tests at all? A brownfield
562
+ // adoption points rows at tests written years before this tool existed, and
563
+ // the only way to satisfy a name match there is to edit them — which is the
564
+ // one thing `adopt` promises it will never make you do.
565
+ const conventionInUse = read.length > 0 && (0, ScenarioCoverage_1.usesNamingConvention)(read);
566
+ if (read.length > 0 && !conventionInUse) {
567
+ this.logWarn("--strict-coverage could not run: no test artifact in this project names its " +
568
+ "requirement or any of its scenarios.");
569
+ this.logFix([
570
+ "This check matches names, and nothing here uses that convention — which is normal",
571
+ "for a repository whose tests predate its specifications. It is not a finding:",
572
+ "failing every row for a convention nobody adopted would be the gate lying.",
573
+ "",
574
+ "To turn it on, name the requirement in the test that proves it — a comment or a",
575
+ "test name mentioning REQ-NNN is enough — and this check starts holding the rest",
576
+ "of the matrix to the same standard.",
577
+ ]);
578
+ return;
579
+ }
580
+ for (const row of rows) {
581
+ // A `TBD` or `-` test artifact is --strict-tdd's finding, not this one:
582
+ // reporting it twice under two flags helps nobody.
583
+ const declaredTests = (0, DeclaredArtifacts_1.declaredPaths)(row.testArtifact).filter((t) => t && t !== "-" && t.toUpperCase() !== "TBD");
584
+ if (declaredTests.length === 0)
585
+ continue;
586
+ const sources = declaredTests.flatMap(readAll);
587
+ const featureRel = (0, DeclaredArtifacts_1.declaredPaths)(row.featureFile)[0];
588
+ const featurePath = featureRel ? path.join(targetDir, (0, DeclaredArtifacts_1.artifactFile)(featureRel)) : null;
589
+ const feature = featurePath && fs.existsSync(featurePath) ? fs.readFileSync(featurePath, "utf8") : "";
590
+ // A row with no scenario has nothing for the per-scenario check to match
591
+ // on — and those are the rows that lie most easily, because `adopt` seeds
592
+ // them with a use case and no scenario. Measured on a real adoption: a
593
+ // "Vet" requirement declared `PetValidatorTests.java` as its proof and
594
+ // every gate stayed green. The requirement id is the only evidence left.
595
+ const requirementId = row.requirement || "";
596
+ // Advisory, not a failure. At row level this cannot tell "the team has
597
+ // not adopted the convention yet" from "this link is a lie", and failing
598
+ // the first to catch the second is what made adding one comment turn
599
+ // three unrelated rows red.
600
+ if ((0, ScenarioCoverage_1.linkIsUnevidenced)(requirementId, feature, sources)) {
601
+ const key = `${requirementId}::unevidenced`;
602
+ if (!seen.has(key)) {
603
+ seen.add(key);
604
+ advisories.push((0, diagnostics_1.warning)("link_without_evidence", `${requirementId}'s test artifact names neither ${requirementId} nor any of ` +
605
+ `its scenarios.`, {
606
+ target: requirementId,
607
+ file: "docs/specs/traceability.md",
608
+ fix: `Name ${requirementId} or one of its scenarios in ` +
609
+ `${declaredTests.join(" or ")}, or point the row at the test that ` +
610
+ `really proves it. A path that exists is not evidence that it proves ` +
611
+ `anything.`,
612
+ }));
613
+ }
614
+ continue;
615
+ }
616
+ if (!featurePath || !fs.existsSync(featurePath))
617
+ continue; // --strict-links reports this.
618
+ for (const scenario of (0, ScenarioCoverage_1.skippedScenarios)(feature, sources)) {
619
+ const key = `${featureRel}::${scenario.title}`;
620
+ if (seen.has(key))
621
+ continue;
622
+ seen.add(key);
623
+ findings.push((0, diagnostics_1.error)("scenario_not_covered", `Nothing proves "${scenario.title}" (${featureRel}).`, {
624
+ target: row.requirement || row.feature,
625
+ file: featureRel,
626
+ fix: `Add a test naming ${scenario.needles[0]} to ` +
627
+ `${declaredTests.join(" or ")}, or delete the scenario if it no ` +
628
+ `longer describes behaviour this project promises.`,
629
+ }));
630
+ }
631
+ }
632
+ // Formatted like the errors, so an advisory carries its code and can be
633
+ // grepped for in CI output the same way.
634
+ for (const a of advisories) {
635
+ process.stderr.write(` ${(0, diagnostics_1.formatDiagnostic)(a)}\n`);
636
+ }
637
+ if (findings.length === 0)
638
+ return;
639
+ if (this.io.json) {
640
+ this.io.fail({ validation: null }, [...findings, ...advisories]);
641
+ return;
642
+ }
643
+ this.logError(`Declared scenarios nothing proves: ${findings.length}`);
644
+ for (const f of findings)
645
+ process.stderr.write(` ${(0, diagnostics_1.formatDiagnostic)(f)}\n`);
646
+ // Each finding already carries its own fix; this is the shared why. It used
647
+ // to say "a scenario no test names" for both codes, which was the wrong
648
+ // advice for half of them.
649
+ this.logFix([
650
+ "A promise with nothing behind it is the thing this gate exists to catch —",
651
+ "and it is what an agent leaves behind when it cannot satisfy a scenario.",
652
+ "Other rows in this project do name what proves them, so these stand out.",
653
+ ]);
654
+ process.exit(1);
655
+ }
361
656
  /**
362
657
  * `--strict-requirements`: EARS-checkable requirement prose, at rest (F6).
363
658
  *
@@ -378,6 +673,29 @@ class ValidateSpecsCommand extends command_1.BaseCommand {
378
673
  * scaffolded projects never grow one — it is the change-lifecycle structure,
379
674
  * not something `specgate init` writes. Nothing to check is not a violation.
380
675
  */
676
+ /**
677
+ * The root `spec.md` plus every capability spec, concatenated.
678
+ *
679
+ * Concatenation is enough because the only question asked of it is "does this
680
+ * requirement have text somewhere". Nothing downstream cares which file the
681
+ * text came from.
682
+ */
683
+ collectSpecProse(targetDir, specPath) {
684
+ const parts = [];
685
+ if (fs.existsSync(specPath))
686
+ parts.push(fs.readFileSync(specPath, "utf8"));
687
+ const capabilitiesDir = path.join(targetDir, ChangeWorkspace_2.CAPABILITIES_DIR);
688
+ if (fs.existsSync(capabilitiesDir)) {
689
+ for (const entry of fs.readdirSync(capabilitiesDir, { withFileTypes: true }).sort()) {
690
+ if (!entry.isDirectory())
691
+ continue;
692
+ const capSpec = path.join(capabilitiesDir, entry.name, "spec.md");
693
+ if (fs.existsSync(capSpec))
694
+ parts.push(fs.readFileSync(capSpec, "utf8"));
695
+ }
696
+ }
697
+ return parts.length > 0 ? parts.join("\n\n") : null;
698
+ }
381
699
  checkRequirementSyntax(targetDir) {
382
700
  const capabilitiesDir = path.join(targetDir, ChangeWorkspace_2.CAPABILITIES_DIR);
383
701
  if (!fs.existsSync(capabilitiesDir))
@@ -423,12 +741,39 @@ class ValidateSpecsCommand extends command_1.BaseCommand {
423
741
  const rawArgs = this.args[0] === "validate" ? this.args.slice(1) : this.args;
424
742
  const argv = rawArgs;
425
743
  this.io = (0, agent_1.agentIo)((0, agent_1.wantsJson)(argv));
426
- const strictTdd = argv.includes("--strict-tdd");
427
- const strictScenarios = argv.includes("--strict-scenarios");
428
- const strictRequirements = argv.includes("--strict-requirements");
429
- const strictLinks = argv.includes("--strict-links");
744
+ // `--strict` is the gate, singular.
745
+ //
746
+ // A cold evaluator counted ten places calling `--strict-tdd` "the gate" —
747
+ // the quickstart, `adopt`'s epilogue, the generated AI_RULES.md, `harness
748
+ // init`, the harness prompt — while `ci init` alone emitted
749
+ // `--strict-tdd --strict-links`. So a user who followed any of the ten got
750
+ // a check that passes on an Implemented requirement whose test file does
751
+ // not exist, and the one place that was right made the other nine look
752
+ // like a mistake rather than a policy.
753
+ //
754
+ // Fixing the wording in ten places would have left the same trap for the
755
+ // eleventh. One name that means all of them is the fix; the individual
756
+ // flags keep working, so every existing CI invocation still means what it
757
+ // meant.
758
+ const strict = argv.includes("--strict");
759
+ const strictTdd = strict || argv.includes("--strict-tdd");
760
+ const strictScenarios = strict || argv.includes("--strict-scenarios");
761
+ const strictRequirements = strict || argv.includes("--strict-requirements");
762
+ const strictLinks = strict || argv.includes("--strict-links");
763
+ const strictCoverage = strict || argv.includes("--strict-coverage");
430
764
  const againstLock = argv.includes("--against-lock");
431
- const positional = argv.filter((a) => !a.startsWith("-"));
765
+ // `--delivering REQ-NNN` holds one requirement to what a delivered row
766
+ // owes while its Status still says Draft. `done --check` and the harness
767
+ // gate run before the status flips, so without it they would skip the very
768
+ // requirement they are about to mark Implemented.
769
+ const deliveringAt = argv.indexOf("--delivering");
770
+ const delivering = deliveringAt >= 0 ? argv[deliveringAt + 1] || "" : "";
771
+ const positional = argv.filter((a, i) => !a.startsWith("-") && !(deliveringAt >= 0 && i === deliveringAt + 1));
772
+ // Asking what a command does is not a usage error, and it exits 0.
773
+ if (argv.includes("--help") || argv.includes("-h")) {
774
+ this.usage();
775
+ process.exit(0);
776
+ }
432
777
  const targetDir = positional[0];
433
778
  if (!targetDir) {
434
779
  this.usage();
@@ -440,7 +785,7 @@ class ValidateSpecsCommand extends command_1.BaseCommand {
440
785
  " specgate init",
441
786
  ]);
442
787
  }
443
- const monorepo = this.validateMonorepo(targetDir, strictTdd, strictScenarios, strictRequirements, strictLinks);
788
+ const monorepo = this.validateMonorepo(targetDir, strictTdd, strictScenarios, strictRequirements, strictLinks, strictCoverage);
444
789
  if (monorepo !== null) {
445
790
  process.exit(monorepo.failures === 0 ? 0 : 1);
446
791
  }
@@ -494,7 +839,7 @@ class ValidateSpecsCommand extends command_1.BaseCommand {
494
839
  "spec.md": "Author it from the shipped template (templates/base/spec.md.tpl) — one `## REQ-NNN — <title>` section per requirement.",
495
840
  "AI_RULES.md": "Author it from templates/backend/AI_RULES.md.tpl (or frontend) — it is the agent's project rulebook.",
496
841
  "README.md": "Add a README.md describing how to build and test the project.",
497
- "docs/specs/traceability.md": `Create it with the rich matrix header:\n ${TraceabilityFormat_1.RICH_HEADER}`,
842
+ "docs/specs/traceability.md": `Create it with the rich matrix header:\n ${TraceabilityFormat_2.RICH_HEADER}`,
498
843
  "docs/specs/adr/README.md": "Create docs/specs/adr/README.md as the index of your Architecture Decision Records.",
499
844
  };
500
845
  for (const f of REQUIRED_FILES) {
@@ -515,7 +860,7 @@ class ValidateSpecsCommand extends command_1.BaseCommand {
515
860
  }
516
861
  const featureCount = featureFiles.length;
517
862
  if (strictScenarios) {
518
- this.checkScenarioQuality(targetDir, featureFiles);
863
+ this.checkScenarioQuality(targetDir, featureFiles, delivering);
519
864
  }
520
865
  if (strictRequirements) {
521
866
  this.checkRequirementSyntax(targetDir);
@@ -549,7 +894,14 @@ class ValidateSpecsCommand extends command_1.BaseCommand {
549
894
  const specPath = path.join(targetDir, "spec.md");
550
895
  const { report, mode, requirements: reqsInMatrix, } = new ValidateProjectUseCase_1.ValidateProjectUseCase(new DiskTraceabilityRepository_1.DiskTraceabilityRepository()).checkMatrix(traceContent, {
551
896
  strictTdd,
552
- specContent: fs.existsSync(specPath) ? fs.readFileSync(specPath, "utf8") : null,
897
+ // Every place a requirement's prose is allowed to live. Three shapes are
898
+ // in the wild and all three are legitimate: `## REQ-NNN` sections (what
899
+ // `adopt` writes), the `| ID | Requirement |` table in §8 (what the
900
+ // original `init` template wrote, and what this repository still uses),
901
+ // and a capability spec under docs/specs/capabilities/ (ADR-0022).
902
+ // Reading only the root spec.md flagged 14 requirements in our own repo
903
+ // that are documented perfectly well one directory down.
904
+ specContent: this.collectSpecProse(targetDir, specPath),
553
905
  });
554
906
  const strictTddViolations = report.findings
555
907
  .filter((f) => f.code === "strict_tdd_violation")
@@ -557,6 +909,15 @@ class ValidateSpecsCommand extends command_1.BaseCommand {
557
909
  for (const finding of report.findings) {
558
910
  if (finding.code === "strict_tdd_violation")
559
911
  continue; // reported together, below
912
+ // Severity decides. This loop used to fail on anything the report
913
+ // carried, which turned the first warning added to `checkMatrix` into a
914
+ // red build — the exact green-to-red flip ADR-0026 exists to avoid.
915
+ if (finding.severity === "warning") {
916
+ this.logWarn(finding.message);
917
+ if (finding.fixLines?.length)
918
+ this.logFix([...finding.fixLines]);
919
+ continue;
920
+ }
560
921
  this.fail(finding.code, finding.message, 1, [...(finding.fixLines || [])]);
561
922
  return;
562
923
  }
@@ -586,7 +947,10 @@ class ValidateSpecsCommand extends command_1.BaseCommand {
586
947
  }
587
948
  this.checkScenarioTags(targetDir, traceContent);
588
949
  if (strictLinks) {
589
- this.checkDeclaredArtifactsExist(targetDir, traceContent);
950
+ this.checkDeclaredArtifactsExist(targetDir, traceContent, delivering);
951
+ }
952
+ if (strictCoverage) {
953
+ this.checkScenarioCoverage(targetDir, traceContent);
590
954
  }
591
955
  for (const ff of featureFiles.sort()) {
592
956
  const rel = path.relative(targetDir, ff).split(path.sep).join("/");
@@ -746,8 +1110,26 @@ class ValidateSpecsCommand extends command_1.BaseCommand {
746
1110
  }
747
1111
  this.logInfo("- Base SDD structure: complete");
748
1112
  this.logInfo(`- Traceability mode: ${traceMode}`);
749
- if (strictTdd)
750
- this.logInfo("- Strict TDD gate: passed");
1113
+ if (strictTdd) {
1114
+ // The count travels with the pass. `passed` over rows that were never
1115
+ // checked is how the tool and its own documentation ended up disagreeing.
1116
+ let exempt = 0;
1117
+ try {
1118
+ exempt = (0, TraceabilityFormat_1.countDraftExempt)((0, TraceabilityFormat_3.parseTraceabilityRows)(traceContent).rows || []);
1119
+ }
1120
+ catch {
1121
+ exempt = 0;
1122
+ }
1123
+ this.logInfo(exempt > 0
1124
+ ? `- Strict TDD gate: passed (${exempt} row(s) exempt — still Draft, so not checked)`
1125
+ : "- Strict TDD gate: passed");
1126
+ if (exempt > 0) {
1127
+ this.logFix([
1128
+ "A Draft row owes nothing yet, so --strict-tdd skips it. It starts being",
1129
+ "checked the moment its Status leaves Draft — `specgate done <REQ>` does that.",
1130
+ ]);
1131
+ }
1132
+ }
751
1133
  if (skeletonOnly) {
752
1134
  this.logWarn("Adoption never retro-filled — the only scenario is the adoption baseline.");
753
1135
  this.logFix([