cyber-sdd 0.7.0 → 0.7.2

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