cyber-sdd 0.0.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 (133) hide show
  1. package/.claude-plugin/plugin.json +17 -0
  2. package/.codex-plugin/plugin.json +17 -0
  3. package/.plugin/plugin.json +17 -0
  4. package/README.md +159 -0
  5. package/agents/sdd-automaton.md +97 -0
  6. package/agents/sdd-impl-judge.md +214 -0
  7. package/agents/sdd-scanner.md +120 -0
  8. package/agents/sdd-spec-judge.md +224 -0
  9. package/agents/sdd-warden.md +101 -0
  10. package/package.json +24 -0
  11. package/skills/align-spec/README.md +20 -0
  12. package/skills/align-spec/SKILL.md +111 -0
  13. package/skills/align-spec/scripts/align-spec.mts +187 -0
  14. package/skills/architect-impl-governance/README.md +46 -0
  15. package/skills/architect-impl-governance/SKILL.md +45 -0
  16. package/skills/architect-spec-governance/README.md +48 -0
  17. package/skills/architect-spec-governance/SKILL.md +59 -0
  18. package/skills/blast-estimate/README.md +47 -0
  19. package/skills/blast-estimate/SKILL.md +133 -0
  20. package/skills/blast-estimate/scripts/blast-estimate.mts +583 -0
  21. package/skills/builder-impl-governance/README.md +47 -0
  22. package/skills/builder-impl-governance/SKILL.md +47 -0
  23. package/skills/builder-spec-governance/README.md +49 -0
  24. package/skills/builder-spec-governance/SKILL.md +36 -0
  25. package/skills/check-partition-quality/README.md +22 -0
  26. package/skills/check-partition-quality/SKILL.md +51 -0
  27. package/skills/check-partition-quality/scripts/check-partition-quality.mts +336 -0
  28. package/skills/check-plan-safety/README.md +17 -0
  29. package/skills/check-plan-safety/SKILL.md +60 -0
  30. package/skills/check-plan-safety/scripts/check-plan-safety.mts +145 -0
  31. package/skills/check-project-specs/README.md +19 -0
  32. package/skills/check-project-specs/SKILL.md +69 -0
  33. package/skills/check-project-specs/scripts/check-project-specs.mts +217 -0
  34. package/skills/check-scenario-overlap/README.md +19 -0
  35. package/skills/check-scenario-overlap/SKILL.md +74 -0
  36. package/skills/check-scenario-overlap/scripts/check-scenario-overlap.mts +249 -0
  37. package/skills/check-spec-structure/README.md +17 -0
  38. package/skills/check-spec-structure/SKILL.md +66 -0
  39. package/skills/check-spec-structure/scripts/check-spec-structure.mts +346 -0
  40. package/skills/collision-ladder/README.md +18 -0
  41. package/skills/collision-ladder/SKILL.md +83 -0
  42. package/skills/collision-ladder/scripts/collision-ladder.mts +657 -0
  43. package/skills/combat-log-governance/README.md +13 -0
  44. package/skills/combat-log-governance/SKILL.md +257 -0
  45. package/skills/concept-index/README.md +13 -0
  46. package/skills/concept-index/SKILL.md +38 -0
  47. package/skills/concept-index/scripts/concept-index.mts +245 -0
  48. package/skills/discover-plans/README.md +16 -0
  49. package/skills/discover-plans/SKILL.md +74 -0
  50. package/skills/discover-plans/scripts/discover-plans.mts +212 -0
  51. package/skills/discover-specs/README.md +15 -0
  52. package/skills/discover-specs/SKILL.md +76 -0
  53. package/skills/discover-specs/scripts/discover-specs.mts +396 -0
  54. package/skills/doctrine-loop/README.md +15 -0
  55. package/skills/doctrine-loop/SKILL.md +97 -0
  56. package/skills/formation-loop/README.md +17 -0
  57. package/skills/formation-loop/SKILL.md +140 -0
  58. package/skills/gate-validation-governance/README.md +12 -0
  59. package/skills/gate-validation-governance/SKILL.md +87 -0
  60. package/skills/impl-producer-governance/README.md +48 -0
  61. package/skills/impl-producer-governance/SKILL.md +85 -0
  62. package/skills/init/README.md +27 -0
  63. package/skills/init/SKILL.md +68 -0
  64. package/skills/init/scripts/wire-statusline.mts +276 -0
  65. package/skills/lifecycle-governance/README.md +11 -0
  66. package/skills/lifecycle-governance/SKILL.md +168 -0
  67. package/skills/manage/README.md +9 -0
  68. package/skills/manage/SKILL.md +62 -0
  69. package/skills/manage-ignore/README.md +19 -0
  70. package/skills/manage-ignore/SKILL.md +52 -0
  71. package/skills/manage-ignore/scripts/manage-ignore.mts +294 -0
  72. package/skills/manage-scenario-bridge/README.md +20 -0
  73. package/skills/manage-scenario-bridge/SKILL.md +60 -0
  74. package/skills/manage-scenario-bridge/scripts/manage-scenario-bridge.mts +156 -0
  75. package/skills/manage-spec-anchors/README.md +18 -0
  76. package/skills/manage-spec-anchors/SKILL.md +56 -0
  77. package/skills/manage-spec-anchors/scripts/manage-spec-anchors.mts +328 -0
  78. package/skills/mission-graph/README.md +15 -0
  79. package/skills/mission-graph/SKILL.md +67 -0
  80. package/skills/mission-graph/scripts/mission-graph.mts +844 -0
  81. package/skills/oracle-spec-governance/README.md +45 -0
  82. package/skills/oracle-spec-governance/SKILL.md +45 -0
  83. package/skills/ownership-governance/README.md +65 -0
  84. package/skills/ownership-governance/SKILL.md +104 -0
  85. package/skills/pause-mission/README.md +18 -0
  86. package/skills/pause-mission/SKILL.md +112 -0
  87. package/skills/place-node/README.md +12 -0
  88. package/skills/place-node/SKILL.md +47 -0
  89. package/skills/place-node/scripts/place-node.mts +157 -0
  90. package/skills/plan-retirement/README.md +32 -0
  91. package/skills/plan-retirement/SKILL.md +90 -0
  92. package/skills/plan-retirement/scripts/retire-plans.mts +196 -0
  93. package/skills/plugin-contract-governance/README.md +12 -0
  94. package/skills/plugin-contract-governance/SKILL.md +112 -0
  95. package/skills/remediation-governance/README.md +46 -0
  96. package/skills/remediation-governance/SKILL.md +78 -0
  97. package/skills/resolve-governances/README.md +18 -0
  98. package/skills/resolve-governances/SKILL.md +50 -0
  99. package/skills/resolve-governances/scripts/resolve-governances.mts +515 -0
  100. package/skills/resolve-tracking/SKILL.md +64 -0
  101. package/skills/resolve-tracking/scripts/resolve-tracking.mts +213 -0
  102. package/skills/resume-mission/README.md +12 -0
  103. package/skills/resume-mission/SKILL.md +53 -0
  104. package/skills/scaffold-project-spec/README.md +7 -0
  105. package/skills/scaffold-project-spec/SKILL.md +192 -0
  106. package/skills/sdd/README.md +7 -0
  107. package/skills/sdd/SKILL.md +92 -0
  108. package/skills/solution-producer-governance/README.md +9 -0
  109. package/skills/solution-producer-governance/SKILL.md +44 -0
  110. package/skills/spec-format-governance/README.md +73 -0
  111. package/skills/spec-format-governance/SKILL.md +114 -0
  112. package/skills/spec-gate/README.md +26 -0
  113. package/skills/spec-gate/SKILL.md +201 -0
  114. package/skills/spec-gate/scripts/check-spec-state.mts +601 -0
  115. package/skills/spec-gate/scripts/check-suite.mts +501 -0
  116. package/skills/spec-gate/scripts/classify-edit-class.mts +411 -0
  117. package/skills/spec-producer-governance/README.md +7 -0
  118. package/skills/spec-producer-governance/SKILL.md +86 -0
  119. package/skills/spec-structure-governance/README.md +40 -0
  120. package/skills/spec-structure-governance/SKILL.md +169 -0
  121. package/skills/ssa-lowering/README.md +26 -0
  122. package/skills/ssa-lowering/SKILL.md +181 -0
  123. package/skills/start-mission/README.md +7 -0
  124. package/skills/start-mission/SKILL.md +115 -0
  125. package/skills/suite-format-governance/README.md +75 -0
  126. package/skills/suite-format-governance/SKILL.md +299 -0
  127. package/skills/suite-format-governance/references/rubric.md +313 -0
  128. package/skills/touch-set-correction/README.md +16 -0
  129. package/skills/touch-set-correction/SKILL.md +67 -0
  130. package/skills/touch-set-correction/scripts/touch-set-correction.mts +418 -0
  131. package/skills/verify-scenarios/README.md +17 -0
  132. package/skills/verify-scenarios/SKILL.md +109 -0
  133. package/skills/verify-scenarios/scripts/verify-scenarios.mts +386 -0
@@ -0,0 +1,501 @@
1
+ #!/usr/bin/env node
2
+ // Static .feature analysis for SDD specs — Gherkin validity, boolean form, and
3
+ // scenario ordering/sectioning checks. Pure functions are exported for node:test;
4
+ // running the file directly drives the CLI.
5
+
6
+ import { type Dirent, readdirSync, readFileSync } from 'node:fs'
7
+ import { basename, dirname, join } from 'node:path'
8
+ import { validateFeatures } from 'gherkin-cli'
9
+
10
+ // ─── types ────────────────────────────────────────────────────────────────────
11
+
12
+ export interface ParsedSuite {
13
+ hasFeatureLine: boolean
14
+ scenarios: ParsedScenario[]
15
+ sectionCommentCount: number
16
+ }
17
+
18
+ export interface ParsedScenario {
19
+ name: string
20
+ steps: string[]
21
+ tags: string[]
22
+ // A `Scenario Outline` drives its steps from an `Examples:` table; a plain
23
+ // `Scenario` has neither. `placeholders` are the `<name>` tokens used in steps.
24
+ isOutline: boolean
25
+ placeholders: string[]
26
+ examples: { header: string[]; rows: string[][] } | null
27
+ // Step DocStrings (`"""..."""` blocks), in encounter order. A @rubric scenario's
28
+ // rubric YAML lives in one of these — the permissive scan otherwise skips them.
29
+ docStrings: string[]
30
+ }
31
+
32
+ // ─── hedge words that signal probabilistic / rubric assertions ────────────────
33
+
34
+ // Probabilistic adverbs make any step non-boolean — flag them on every step.
35
+ const ADVERB_PATTERNS = ['sometimes', 'usually', 'often', 'occasionally'].map((w) => new RegExp(`\\b${w}\\b`, 'i'))
36
+
37
+ // Rubric nouns (a graded scale leaking into the contract) are only a violation
38
+ // when they form a positive Then/And/But assertion. A negated/absence assertion
39
+ // ("no rubric appears") or one expressing a boolean verdict ("reports failing
40
+ // when the score is below the threshold") is fine — and a meta-spec mentioning
41
+ // the rule in a Given/When setup is not the contract embedding a rubric.
42
+ const RUBRIC_PATTERNS = [...['score', 'threshold', 'rubric'].map((w) => new RegExp(`\\b${w}\\b`, 'i')), /\b1[-–]5\b/]
43
+ const ASSERTION_RE = /^(Then|And|But)\b/i
44
+ const RUBRIC_EXEMPT_RE = /\b(no|not|never|without|nor|passing|failing|pass|fail|boolean|true|false|verdict)\b/i
45
+
46
+ // ─── parse ────────────────────────────────────────────────────────────────────
47
+
48
+ export function parseSuite(text: string): ParsedSuite {
49
+ const lines = text.split('\n')
50
+ let hasFeatureLine = false
51
+ let sectionCommentCount = 0
52
+ const scenarios: ParsedScenario[] = []
53
+ let current: ParsedScenario | null = null
54
+ // Tags on the line(s) above a Scenario apply to the scenario that follows.
55
+ let pendingTags: string[] = []
56
+ // A step DocString (`"""` ... `"""`) attaches to the step immediately above it —
57
+ // content is captured verbatim (not keyword-parsed) until the closing `"""`.
58
+ let inDocString = false
59
+ let docStringLines: string[] = []
60
+
61
+ for (const raw of lines) {
62
+ const line = raw.trimStart()
63
+
64
+ if (inDocString) {
65
+ if (line.startsWith('"""')) {
66
+ inDocString = false
67
+ if (current) current.docStrings.push(docStringLines.join('\n'))
68
+ docStringLines = []
69
+ } else {
70
+ docStringLines.push(raw)
71
+ }
72
+ continue
73
+ }
74
+
75
+ if (current && line.startsWith('"""')) {
76
+ inDocString = true
77
+ continue
78
+ }
79
+
80
+ if (/^Feature:/i.test(line)) {
81
+ hasFeatureLine = true
82
+ continue
83
+ }
84
+
85
+ // Section comment: a comment line that contains ── or -- (box-drawing or dashes)
86
+ if (/^#/.test(line) && (/──/.test(line) || /--/.test(line))) {
87
+ sectionCommentCount++
88
+ continue
89
+ }
90
+
91
+ // Tag line: one or more @tags preceding a Scenario.
92
+ if (/^@/.test(line)) {
93
+ pendingTags.push(...line.split(/\s+/).filter((t) => t.startsWith('@')))
94
+ continue
95
+ }
96
+
97
+ if (/^Scenario:/i.test(line) || /^Scenario Outline:/i.test(line)) {
98
+ if (current) scenarios.push(current)
99
+ const isOutline = /^Scenario Outline:/i.test(line)
100
+ const name = line.replace(/^Scenario(?: Outline)?:/i, '').trim()
101
+ current = { name, steps: [], tags: pendingTags, isOutline, placeholders: [], examples: null, docStrings: [] }
102
+ pendingTags = []
103
+ continue
104
+ }
105
+
106
+ // Examples: opens an outline's data table; the first table row is its header.
107
+ if (current && /^Examples:/i.test(line)) {
108
+ current.examples = { header: [], rows: [] }
109
+ continue
110
+ }
111
+
112
+ // A table row (`| a | b |`) fills the open Examples table — header first.
113
+ if (current?.examples && line.startsWith('|')) {
114
+ const cells = line
115
+ .split('|')
116
+ .slice(1, -1)
117
+ .map((c) => c.trim())
118
+ if (current.examples.header.length === 0) current.examples.header = cells
119
+ else current.examples.rows.push(cells)
120
+ continue
121
+ }
122
+
123
+ if (current && /^(Given|When|Then|And|But)\b/i.test(line)) {
124
+ current.steps.push(line)
125
+ // Collect `<placeholder>` tokens so an outline's table can be checked to cover them.
126
+ for (const m of line.matchAll(/<([^>]+)>/g)) current.placeholders.push(m[1])
127
+ }
128
+ }
129
+
130
+ if (current) scenarios.push(current)
131
+
132
+ return { hasFeatureLine, scenarios, sectionCommentCount }
133
+ }
134
+
135
+ // ─── Gherkin validity — the pinned parser, not the permissive scan below ───────
136
+
137
+ export interface ParseError {
138
+ line: number
139
+ message: string
140
+ }
141
+
142
+ // Maps the pinned parser's per-file report to its errors (empty array when it parses) so callers
143
+ // can look a path up directly.
144
+ export function runGherkinValidate(paths: string[]): Map<string, ParseError[]> {
145
+ const { files } = validateFeatures(paths)
146
+ const out = new Map<string, ParseError[]>()
147
+ for (const f of files) {
148
+ out.set(
149
+ f.file,
150
+ f.errors.map((e) => ({ line: e.line, message: e.message })),
151
+ )
152
+ }
153
+ return out
154
+ }
155
+
156
+ // ─── dead rubric — a rubric whose attainable maximum can't reach its threshold ─
157
+
158
+ export interface DeadRubric {
159
+ dimensionsTotal: number
160
+ threshold: number
161
+ }
162
+
163
+ // Hand-rolled line scan (no YAML dependency) over a rubric DocString of the shape:
164
+ // dimensions:
165
+ // - name: correctness
166
+ // max: 3
167
+ // threshold: 4
168
+ // Sums every `max:` value (at any indent, so it doesn't depend on YAML nesting rules)
169
+ // and reads the (last) `threshold:` value. Returns the violation only when the sum is
170
+ // STRICTLY less than the threshold — sum === threshold is a legal all-or-nothing bar
171
+ // and must never be reported. Missing/non-numeric threshold or no `max:` lines found
172
+ // return null: malformed rubric form is not this check's job to police.
173
+ export function findDeadRubric(docString: string): DeadRubric | null {
174
+ let dimensionsTotal = 0
175
+ let sawMax = false
176
+ let threshold: number | null = null
177
+
178
+ for (const raw of docString.split('\n')) {
179
+ const line = raw.trim()
180
+ const maxMatch = /^max:\s*(-?\d+(?:\.\d+)?)\s*$/.exec(line)
181
+ if (maxMatch) {
182
+ sawMax = true
183
+ dimensionsTotal += Number(maxMatch[1])
184
+ continue
185
+ }
186
+ const thresholdMatch = /^threshold:\s*(-?\d+(?:\.\d+)?)\s*$/.exec(line)
187
+ if (thresholdMatch) {
188
+ threshold = Number(thresholdMatch[1])
189
+ }
190
+ }
191
+
192
+ if (!sawMax || threshold === null) return null
193
+ if (dimensionsTotal >= threshold) return null
194
+ return { dimensionsTotal, threshold }
195
+ }
196
+
197
+ // ─── checks ──────────────────────────────────────────────────────────────────
198
+
199
+ // `parseErrors` carries the pinned parser's verdict for this file (empty when it parses, or
200
+ // omitted by 3-arg callers that predate this guard). A parse failure REPLACES every other
201
+ // finding below rather than joining them: every other check reads the file through the
202
+ // permissive `parseSuite` scan, so on an unparseable file those findings come from a partial
203
+ // view and are not evidence — reporting them as "the form" would still be the fail-open this
204
+ // guard exists to close, just with company.
205
+ export function checkSuite(slug: string, file: string, text: string, parseErrors: ParseError[] = []): string[] {
206
+ const tag = (msg: string) => `${slug}/${file}: ${msg}`
207
+
208
+ if (parseErrors.length > 0) {
209
+ return parseErrors.map((e) => tag(`cannot parse as Gherkin at line ${e.line} — ${e.message}`))
210
+ }
211
+
212
+ const v: string[] = []
213
+ const ref = parseSuite(text)
214
+
215
+ // Gherkin validity: must have Feature: line
216
+ if (!ref.hasFeatureLine) {
217
+ v.push(tag('missing Feature: line'))
218
+ }
219
+
220
+ for (const scenario of ref.scenarios) {
221
+ const steps = scenario.steps
222
+ const label = scenario.name ? `Scenario "${scenario.name}"` : 'unnamed scenario'
223
+ // A @rubric-tagged scenario is the sanctioned rubric form: its rubric/score/
224
+ // threshold lingo is the contract, not a leaked grade. Skip the rubric-noun
225
+ // ban for it (adverb hedges still apply — a rubric is not an excuse to hedge).
226
+ const isRubric = scenario.tags.includes('@rubric')
227
+
228
+ // Dead rubric: sum(dimension max) < threshold means no subject can ever reach
229
+ // the cut — the rubric grades nothing. Only checked for @rubric scenarios;
230
+ // sum === threshold is a legal strict (all-or-nothing) bar, not a violation.
231
+ if (isRubric) {
232
+ for (const docString of scenario.docStrings) {
233
+ const dead = findDeadRubric(docString)
234
+ if (dead) {
235
+ v.push(
236
+ tag(
237
+ `${label}: rubric cannot be passed — dimensions total ${dead.dimensionsTotal}, threshold ${dead.threshold}`,
238
+ ),
239
+ )
240
+ }
241
+ }
242
+ }
243
+
244
+ // Must have at least one step
245
+ if (steps.length === 0) {
246
+ v.push(tag(`${label}: has no steps`))
247
+ continue
248
+ }
249
+
250
+ // Must have a When or Given
251
+ const hasGivenOrWhen = steps.some((s) => /^(Given|When)\b/i.test(s))
252
+ if (!hasGivenOrWhen) {
253
+ v.push(tag(`${label}: missing Given or When step`))
254
+ }
255
+
256
+ // Must have a Then (or And/But after a Then)
257
+ const hasThen = steps.some((s) => /^Then\b/i.test(s))
258
+ if (!hasThen) {
259
+ v.push(tag(`${label}: missing Then step — scenario has no assertion`))
260
+ }
261
+
262
+ // Boolean form
263
+ for (const step of steps) {
264
+ // Probabilistic adverbs are never boolean — flag on any step.
265
+ const adverb = ADVERB_PATTERNS.find((p) => p.test(step))
266
+ if (adverb) {
267
+ v.push(tag(`${label}: step contains non-boolean hedge — "${step.trim()}" (matched ${adverb.source})`))
268
+ continue
269
+ }
270
+ // Rubric nouns only count as a leaked grade in a positive Then/And/But
271
+ // assertion of an untagged scenario; a @rubric scenario admits them.
272
+ if (isRubric || !ASSERTION_RE.test(step) || RUBRIC_EXEMPT_RE.test(step)) continue
273
+ const rubric = RUBRIC_PATTERNS.find((p) => p.test(step))
274
+ if (rubric) {
275
+ v.push(
276
+ tag(`${label}: step embeds a rubric/score in its assertion — "${step.trim()}" (matched ${rubric.source})`),
277
+ )
278
+ }
279
+ }
280
+
281
+ // Scenario Outline: the Examples table must be present, non-empty, and cover
282
+ // every <placeholder> used in the steps. A bare outline with no table (or a
283
+ // table missing a placeholder's column) would silently drive nothing.
284
+ if (scenario.isOutline) {
285
+ const ex = scenario.examples
286
+ if (!ex || ex.header.length === 0 || ex.rows.length === 0) {
287
+ v.push(tag(`${label}: Scenario Outline has no non-empty Examples table`))
288
+ } else {
289
+ const missing = [...new Set(scenario.placeholders)].filter((p) => !ex.header.includes(p))
290
+ if (missing.length) {
291
+ v.push(tag(`${label}: Examples table missing column(s) for placeholder(s): ${missing.join(', ')}`))
292
+ }
293
+ }
294
+ }
295
+ }
296
+
297
+ // Ordering / sectioning: many scenarios must have at least one section comment
298
+ const SECTION_THRESHOLD = 6
299
+ if (ref.scenarios.length > SECTION_THRESHOLD && ref.sectionCommentCount === 0) {
300
+ v.push(
301
+ tag(
302
+ `${ref.scenarios.length} scenarios but no section comments (add # ── Stage comments to group lifecycle stages)`,
303
+ ),
304
+ )
305
+ }
306
+
307
+ return v
308
+ }
309
+
310
+ // ─── CLI entry ────────────────────────────────────────────────────────────────
311
+
312
+ // Discovery walks the tree recursively so nested spec folders (sdd/sdd-skill)
313
+ // are analyzed too — a .feature is a real contract wherever it lives. Returns
314
+ // each dir (root-relative slug) paired with its .feature files.
315
+ export function discoverSuiteDirs(root: string): { slug: string; files: string[] }[] {
316
+ const out: { slug: string; files: string[] }[] = []
317
+ const walk = (dir: string, rel: string) => {
318
+ let entries: Dirent[]
319
+ try {
320
+ entries = readdirSync(dir, { withFileTypes: true })
321
+ } catch {
322
+ return
323
+ }
324
+ const files = entries.filter((e) => e.isFile() && e.name.endsWith('.feature')).map((e) => e.name)
325
+ if (files.length) out.push({ slug: rel, files })
326
+ for (const e of entries) {
327
+ if (!e.isDirectory() || e.name === 'node_modules' || e.name.startsWith('.')) continue
328
+ walk(join(dir, e.name), rel ? join(rel, e.name) : e.name)
329
+ }
330
+ }
331
+ walk(root, '')
332
+ return out
333
+ }
334
+
335
+ // The gate scopes to a CR's *touched* .feature files, not the whole tree. In
336
+ // --files mode the caller passes an explicit path list and only those files are
337
+ // checked (tree discovery is skipped). An unreadable path fails closed — the
338
+ // gate must never silently pass a file it could not read.
339
+ //
340
+ // `validate` is injected so the unit tests exercise this wiring with a fake parser (fast,
341
+ // offline) while `main` wires the real pinned in-process parser. The parser is the SOLE source of
342
+ // Gherkin validity — if it cannot be run at all, every readable path fails closed rather than
343
+ // falling back to the permissive scan; if it runs but omits a path from its report, that path
344
+ // fails closed too rather than defaulting to "parses fine".
345
+ export function checkFilePaths(paths: string[], validate: typeof runGherkinValidate = runGherkinValidate): string[] {
346
+ const violations: string[] = []
347
+ const readable: { path: string; text: string }[] = []
348
+ for (const p of paths) {
349
+ try {
350
+ readable.push({ path: p, text: readFileSync(p, 'utf8') })
351
+ } catch {
352
+ violations.push(`${p}: cannot read file`)
353
+ }
354
+ }
355
+ if (readable.length === 0) return violations
356
+
357
+ let parseErrorsByPath: Map<string, ParseError[]>
358
+ try {
359
+ parseErrorsByPath = validate(readable.map((r) => r.path))
360
+ } catch (err) {
361
+ for (const { path } of readable) {
362
+ violations.push(`${path}: cannot verify Gherkin validity — ${(err as Error).message}`)
363
+ }
364
+ return violations
365
+ }
366
+
367
+ for (const { path: p, text } of readable) {
368
+ const errs = parseErrorsByPath.get(p)
369
+ if (errs === undefined) {
370
+ // Defaulting a missing report to "parses fine" is the exact fail-open bug being fixed —
371
+ // the parser said nothing about this file, so it cannot be classified as valid.
372
+ violations.push(`${p}: the Gherkin parser returned no result for this file`)
373
+ continue
374
+ }
375
+ violations.push(...checkSuite(dirname(p), basename(p), text, errs))
376
+ if (errs.length === 0) {
377
+ const specPath = join(dirname(p), 'README.md')
378
+ let specText: string | undefined
379
+ try {
380
+ specText = readFileSync(specPath, 'utf8')
381
+ } catch {
382
+ specText = undefined
383
+ }
384
+ if (specText !== undefined) {
385
+ violations.push(...checkScenarioMap(dirname(p), basename(p), text, specText))
386
+ }
387
+ }
388
+ }
389
+ return violations
390
+ }
391
+
392
+ // ─── scenario-map binding ─────────────────────────────────────────────────────
393
+ // The sibling spec's `## Scenario map` binds each scenario to a (path class, edge) pair
394
+ // (`| Edge | Path (Given) | Scenario |`). Form only: this checks the BINDING is complete and
395
+ // non-duplicated. Whether the edges cover the control-flow graph (CFG) is judged, not linted — that
396
+ // needs the drawn CFG's semantics, and a green check clears no coverage question.
397
+ //
398
+ // A spec carrying no `## Scenario map` section is SKIPPED, not failed: the map is the rebuilt node
399
+ // format, and a node still on the older shape is not in violation of a section it does not claim.
400
+ export interface MapRow {
401
+ edge: string
402
+ path: string
403
+ scenario: string
404
+ }
405
+
406
+ export function parseScenarioMap(specText: string): MapRow[] | undefined {
407
+ const start = specText.indexOf('## Scenario map')
408
+ if (start === -1) return undefined
409
+ const body = specText.slice(start)
410
+ const rows: MapRow[] = []
411
+ for (const line of body.split('\n')) {
412
+ const t = line.trim()
413
+ if (!t.startsWith('|')) continue
414
+ const cells = t
415
+ .split('|')
416
+ .slice(1, -1)
417
+ .map((c) => c.trim())
418
+ if (cells.length !== 3) continue
419
+ const scenario = cells[2] ?? ''
420
+ // Skip the header row and its separator; a data row names its scenario in backticks.
421
+ const m = scenario.match(/^`(.+)`$/)
422
+ if (m === null) continue
423
+ rows.push({ edge: cells[0] ?? '', path: cells[1] ?? '', scenario: m[1] ?? '' })
424
+ }
425
+ return rows
426
+ }
427
+
428
+ export function checkScenarioMap(slug: string, file: string, featureText: string, specText: string): string[] {
429
+ const rows = parseScenarioMap(specText)
430
+ if (rows === undefined) return []
431
+ const tag = (msg: string) => `${slug}/${file}: ${msg}`
432
+ const v: string[] = []
433
+
434
+ const titles = [...featureText.matchAll(/^\s*Scenario(?: Outline)?:\s*(.+?)\s*$/gm)].map((m) => m[1] ?? '')
435
+ const mapped = new Set(rows.map((r) => r.scenario))
436
+
437
+ for (const t of titles) {
438
+ if (!mapped.has(t)) v.push(tag(`scenario is not on the scenario map — "${t}"`))
439
+ }
440
+ const titleSet = new Set(titles)
441
+ for (const r of rows) {
442
+ if (!titleSet.has(r.scenario)) v.push(tag(`scenario map row names no such scenario — "${r.scenario}"`))
443
+ }
444
+ const seen = new Map<string, string>()
445
+ for (const r of rows) {
446
+ const key = `${r.edge}\u0000${r.path}`
447
+ const prior = seen.get(key)
448
+ if (prior !== undefined) {
449
+ v.push(
450
+ tag(
451
+ `duplicate map pair — edge "${r.edge}" and path "${r.path}" cover both "${prior}" and "${r.scenario}"; a repeated edge needs a DIFFERENT path class`,
452
+ ),
453
+ )
454
+ } else seen.set(key, r.scenario)
455
+ }
456
+ return v
457
+ }
458
+
459
+ // Collect the path list following --files, stopping at the next flag.
460
+ export function parseFilesArg(argv: string[]): string[] {
461
+ const idx = argv.indexOf('--files')
462
+ if (idx === -1) return []
463
+ const paths: string[] = []
464
+ for (let i = idx + 1; i < argv.length; i++) {
465
+ if (argv[i].startsWith('--')) break
466
+ paths.push(argv[i])
467
+ }
468
+ return paths
469
+ }
470
+
471
+ export function main(argv: string[]): number {
472
+ let violations: string[] = []
473
+
474
+ if (argv.includes('--files')) {
475
+ const paths = parseFilesArg(argv)
476
+ if (paths.length === 0) {
477
+ console.error('✗ --files requires at least one .feature path')
478
+ return 1
479
+ }
480
+ violations = checkFilePaths(paths)
481
+ } else {
482
+ const root = argv.includes('--root') ? argv[argv.indexOf('--root') + 1] : '.agents/specs'
483
+ // The tree-wide sweep must fail closed on a parse failure exactly like --files — route the
484
+ // full discovered path list through the same validated path rather than the bare parseSuite
485
+ // scan, so an unparseable suite anywhere in the corpus fails the sweep closed.
486
+ const paths: string[] = []
487
+ for (const { slug, files } of discoverSuiteDirs(root)) {
488
+ for (const file of files) paths.push(join(root, slug, file))
489
+ }
490
+ violations = checkFilePaths(paths)
491
+ }
492
+
493
+ if (violations.length) {
494
+ for (const line of violations) console.error(`✗ ${line}`)
495
+ return 1
496
+ }
497
+ process.stdout.write('suite checks OK\n')
498
+ return 0
499
+ }
500
+
501
+ if (import.meta.main) process.exit(main(process.argv.slice(2)))