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,470 +0,0 @@
1
- #!/usr/bin/env node
2
- // verify-scenarios — the Gherkin-scenario -> test-report bridge verifier. For a frozen `.feature`
3
- // and a spec-node path, reports per-scenario PASS / FAIL / UNBOUND by reading the reports of one or
4
- // more configured test-result SOURCES, so an impl-judge reasons by hand only over the UNBOUND set
5
- // instead of every scenario.
6
- //
7
- // Binding convention: KEY = a scenario's `@id:<slug>` tag if present, else its verbatim name. A test
8
- // declares its node via a `describe('spec:<node>', …)` wrapper (or equivalent) — the bridge reads
9
- // that segment out of the test-report's `" > "`-joined test name. A test's leaf title is either the
10
- // exact scenario name or `@id:<slug>`; a Scenario Outline is ONE key (its outline name), not one per
11
- // Examples row.
12
- //
13
- // Operations:
14
- // --feature <path> frozen .feature to verify (required)
15
- // --node <path> the spec node to bind against, e.g. cyberlegion/identity (required)
16
- // --config <toml> .agents/sdd/scenario-bridge.toml (default, resolved under --root)
17
- // --root <dir> bridge/report root: --config's default, --report, and every source's
18
- // reportPath resolve here (default cwd)
19
- // --feature-root <dir> where --feature resolves; defaults to --root when omitted (monorepo:
20
- // specs at repo root, bridge/report under the project's own root)
21
- // --report <xml> bypass --config: a single ad-hoc junit report (resolved under --root)
22
- // --run execute each source's `command` before reading its report
23
- // --format toon|json machine output (default: a readable text map)
24
- //
25
- // Config `.agents/sdd/scenario-bridge.toml` is an array-of-tables of sources:
26
- // [[source]]
27
- // adapter = "junit"
28
- // command = "pnpm build && vitest run src --reporter=junit --outputFile=.agents/.scenario-report.xml"
29
- // reportPath = ".agents/.scenario-report.xml"
30
- //
31
- // Adapter interface: (source, root, run) => Array<{node, key, outcome: 'pass'|'fail'|'skip'}>. Only
32
- // `junit` is implemented; `tap`/`aced` slot into the same `switch(adapter)` seam later.
33
- //
34
- // Fold: union every source's results, keep only those whose `node` equals --node, group by KEY. Per
35
- // feature scenario KEY: UNBOUND if no result; PASS if ≥1 result and none fail; FAIL if any fails.
36
- // EXTRA = bound result keys matching no feature scenario key (diagnostic, not a failure).
37
- //
38
- // Near-miss binding: a key that matches no result EXACTLY gets a second pass on a punctuation- and
39
- // whitespace-folded COMPARISON key (a pasted curly apostrophe, an em dash, a non-breaking space),
40
- // so a real binding is not split across the UNBOUND list and the EXTRA list with nothing saying the
41
- // two are the same scenario. Only unclaimed results are eligible, and only an unambiguous 1:1 fold
42
- // binds — an ambiguous fold stays UNBOUND rather than manufacturing a false bind. Nothing rewrites
43
- // a title: the fold is the only place the folded form exists, and every reported field carries the
44
- // verbatim key, with the matched test title reported alongside it as a probable title mismatch.
45
- //
46
- // Pure functions are exported for node:test; running the file directly drives the CLI.
47
-
48
- import { execSync } from 'node:child_process'
49
- import { existsSync, readFileSync, realpathSync } from 'node:fs'
50
- import { isAbsolute, join } from 'node:path'
51
- import { pathToFileURL } from 'node:url'
52
- import { parse } from '../../spec-gate/vendor/gherkin-cli.mjs'
53
-
54
- // Resolves a path argument against `--root`: relative paths join beneath root (which defaults to the
55
- // current directory); an absolute path is used verbatim, never double-prefixed under root.
56
- export function underRoot(root: string, p: string): string {
57
- return isAbsolute(p) ? p : join(root, p)
58
- }
59
-
60
- // ── Config (a minimal TOML: `[[source]]` array-of-tables) ──
61
-
62
- export interface SourceConfig {
63
- adapter: string
64
- command?: string
65
- reportPath: string
66
- }
67
-
68
- // Parses `[[source]]` blocks; each block's `key = "value"` lines become fields on one source. Blocks
69
- // are delimited by the next `[[source]]` or `[...]` header (or EOF). Malformed/incomplete blocks
70
- // (no reportPath) are dropped rather than throwing — mirrors spec-anchors' lenient-read posture.
71
- export function parseSourcesToml(text: string): SourceConfig[] {
72
- const out: SourceConfig[] = []
73
- const blocks = text.split(/(?=^\s*\[\[source\]\]\s*$)/m).filter((b) => /^\s*\[\[source\]\]\s*$/m.test(b))
74
- for (const block of blocks) {
75
- const body = block.replace(/^\s*\[\[source\]\]\s*$/m, '')
76
- const fields: Record<string, string> = {}
77
- for (const m of body.matchAll(/^\s*([A-Za-z_][\w-]*)\s*=\s*(?:"([^"]*)"|'([^']*)')\s*$/gm)) {
78
- fields[m[1]] = m[2] ?? m[3] ?? ''
79
- }
80
- if (!fields.adapter || !fields.reportPath) continue
81
- out.push({ adapter: fields.adapter, command: fields.command, reportPath: fields.reportPath })
82
- }
83
- return out
84
- }
85
-
86
- export function readSourcesConfig(configPath: string): SourceConfig[] {
87
- if (!existsSync(configPath)) return []
88
- try {
89
- return parseSourcesToml(readFileSync(configPath, 'utf8'))
90
- } catch {
91
- return []
92
- }
93
- }
94
-
95
- // ── Scenario keys from the frozen .feature (via gherkin-cli) ──
96
-
97
- export interface GherkinScenario {
98
- name: string
99
- keyword: string
100
- tags: string[]
101
- }
102
-
103
- // Narrower than gherkin-cli's real `ParseResult` — this function reads only `files[].scenarios`,
104
- // so its signature stays structural rather than demanding fields (summary, per-file counts) it
105
- // never touches; a real `ParseResult` value satisfies this shape and passes straight through.
106
- interface GherkinParseOutput {
107
- files: { scenarios: GherkinScenario[] }[]
108
- }
109
-
110
- export interface ScenarioKey {
111
- name: string
112
- key: string
113
- }
114
-
115
- const ID_TAG_RE = /^@id:(.+)$/
116
-
117
- // A scenario's KEY is its `@id:<slug>` tag if one is present, else its verbatim name. A Scenario
118
- // Outline is one key (its outline name) — gherkin-cli already emits one entry per outline, not per
119
- // Examples row, so no special-casing is needed here.
120
- export function scenarioKeysFromParse(parsed: GherkinParseOutput): ScenarioKey[] {
121
- const out: ScenarioKey[] = []
122
- for (const file of parsed.files) {
123
- for (const s of file.scenarios) {
124
- const idTag = s.tags.map((t) => ID_TAG_RE.exec(t)).find((m): m is RegExpExecArray => m !== null)
125
- out.push({ name: s.name, key: idTag ? idTag[1] : s.name })
126
- }
127
- }
128
- return out
129
- }
130
-
131
- // `featureRoot` resolves `featurePath` (defaults to `root` at the call site when the CLI omits
132
- // `--feature-root` — see main()).
133
- export function getScenarioKeys(root: string, featurePath: string, featureRoot: string = root): ScenarioKey[] {
134
- const abs = underRoot(featureRoot, featurePath)
135
- return scenarioKeysFromParse(parse([abs]))
136
- }
137
-
138
- // ── JUnit parsing (hand-rolled, no xml dep) ──
139
-
140
- export type Outcome = 'pass' | 'fail' | 'skip'
141
-
142
- export interface JUnitTestcase {
143
- classname: string
144
- name: string
145
- outcome: Outcome
146
- }
147
-
148
- const XML_ENTITIES: [RegExp, string][] = [
149
- [/&lt;/g, '<'],
150
- [/&gt;/g, '>'],
151
- [/&quot;/g, '"'],
152
- [/&apos;/g, "'"],
153
- [/&amp;/g, '&'], // last, so a literal "&amp;lt;" does not get double-unescaped
154
- ]
155
-
156
- export function unescapeXml(s: string): string {
157
- let out = s
158
- for (const [re, rep] of XML_ENTITIES) out = out.replace(re, rep)
159
- return out
160
- }
161
-
162
- function attr(attrs: string, name: string): string {
163
- const m = new RegExp(`\\b${name}\\s*=\\s*"([^"]*)"`).exec(attrs)
164
- return m ? unescapeXml(m[1]) : ''
165
- }
166
-
167
- // Finds every `<testcase …>…</testcase>` (or self-closed `<testcase … />`), extracting `classname`
168
- // and `name` BY ATTRIBUTE NAME (not position), unescaping both. Outcome: a child `<failure` -> fail;
169
- // a child `<skipped` -> skip; else pass.
170
- //
171
- // The opening-tag regex walks attribute-by-attribute (quoted strings or a non-quote/non-`>` run) so
172
- // an attribute VALUE containing a literal `>` — e.g. a scenario name built from `" > "`-joined
173
- // segments — never truncates the match, which a naive `[^>]*` class would do.
174
- const OPEN_TAG_RE = /<testcase\b((?:"[^"]*"|'[^']*'|[^"'>/])*)(\/)?>/g
175
-
176
- export function parseJUnitTestcases(xml: string): JUnitTestcase[] {
177
- const out: JUnitTestcase[] = []
178
- for (const m of xml.matchAll(OPEN_TAG_RE)) {
179
- const attrs = m[1]
180
- const selfClosed = m[2] === '/'
181
- let body = ''
182
- if (!selfClosed) {
183
- const closeIdx = xml.indexOf('</testcase>', m.index + m[0].length)
184
- if (closeIdx !== -1) body = xml.slice(m.index + m[0].length, closeIdx)
185
- }
186
- const outcome: Outcome = /<failure\b/.test(body) ? 'fail' : /<skipped\b/.test(body) ? 'skip' : 'pass'
187
- out.push({ classname: attr(attrs, 'classname'), name: attr(attrs, 'name'), outcome })
188
- }
189
- return out
190
- }
191
-
192
- // ── junit adapter: testcase -> {node, key, outcome} ──
193
-
194
- export interface BoundResult {
195
- node: string
196
- key: string
197
- outcome: Outcome
198
- }
199
-
200
- const NODE_SEGMENT_RE = /^spec:(.+)$/
201
-
202
- // Splits an (already-unescaped) testcase `name` on `" > "`. NODE = the capture of whichever segment
203
- // matches `/^spec:(.+)$/` (found at any depth); testcases with no such segment are not bound to a
204
- // spec node and are dropped. LEAF = the last segment; KEY = its `@id:<slug>` capture if it matches,
205
- // else the leaf verbatim.
206
- export function junitTestcaseToResult(tc: JUnitTestcase): BoundResult | undefined {
207
- const segments = tc.name.split(' > ')
208
- let node: string | undefined
209
- for (const seg of segments) {
210
- const m = NODE_SEGMENT_RE.exec(seg)
211
- if (m) {
212
- node = m[1]
213
- break
214
- }
215
- }
216
- if (node === undefined) return undefined
217
- const leaf = segments[segments.length - 1]
218
- const idMatch = ID_TAG_RE.exec(leaf)
219
- const key = idMatch ? idMatch[1] : leaf
220
- return { node, key, outcome: tc.outcome }
221
- }
222
-
223
- export function junitResultsFromXml(xml: string): BoundResult[] {
224
- const out: BoundResult[] = []
225
- for (const tc of parseJUnitTestcases(xml)) {
226
- const r = junitTestcaseToResult(tc)
227
- if (r) out.push(r)
228
- }
229
- return out
230
- }
231
-
232
- function runJunitSource(source: SourceConfig, root: string, run: boolean): BoundResult[] {
233
- if (run && source.command) {
234
- execSync(source.command, { cwd: root, stdio: ['ignore', 'pipe', 'inherit'] })
235
- }
236
- const reportPath = underRoot(root, source.reportPath)
237
- if (!existsSync(reportPath)) return []
238
- return junitResultsFromXml(readFileSync(reportPath, 'utf8'))
239
- }
240
-
241
- // Adapter dispatch seam — `tap` / `aced` / others implement the same signature and slot in here.
242
- export function runSource(source: SourceConfig, root: string, run: boolean): BoundResult[] {
243
- switch (source.adapter) {
244
- case 'junit':
245
- return runJunitSource(source, root, run)
246
- default:
247
- return []
248
- }
249
- }
250
-
251
- // ── Fold: union all sources, group by key, compare against the feature's scenario set ──
252
-
253
- export type ScenarioState = 'pass' | 'fail' | 'unbound'
254
-
255
- export interface ScenarioReport {
256
- name: string
257
- key: string
258
- state: ScenarioState
259
- resultCount: number
260
- /** The result key this scenario bound to when it bound on the folded comparison key, not exactly. */
261
- matchedKey?: string
262
- }
263
-
264
- export interface TitleMismatch {
265
- /** The frozen scenario's verbatim key. */
266
- key: string
267
- /** The test's verbatim leaf title it bound to — differing only by punctuation or whitespace. */
268
- matchedKey: string
269
- }
270
-
271
- export interface FoldReport {
272
- node: string
273
- total: number
274
- bound: number
275
- pass: number
276
- fail: number
277
- unbound: number
278
- scenarios: ScenarioReport[]
279
- extras: string[]
280
- /** Bindings made on the folded comparison key — a probable one-character typo, reported so it gets fixed. */
281
- mismatches: TitleMismatch[]
282
- }
283
-
284
- // Punctuation and whitespace variants that carry no meaning of their own: the smart-quote and dash
285
- // substitutions an editor, a copy-paste, or a Markdown renderer makes to a title on its way into a
286
- // test file. Case is deliberately NOT folded — a differing case is not a punctuation variant, and
287
- // folding it would bind two titles the author wrote differently on purpose.
288
- const PUNCTUATION_FOLDS: [RegExp, string][] = [
289
- [/[‘’‚‛′´]/g, "'"], // curly/prime apostrophes -> straight
290
- [/[“”„‟″]/g, '"'], // curly double quotes -> straight
291
- [/[‐‑‒–—―−]/g, '-'], // hyphens/dashes/minus -> hyphen
292
- [/…/g, '...'], // ellipsis -> three dots
293
- ]
294
- // A no-break/thin/figure space needs no entry of its own — the trailing `\s+` collapse below
295
- // already covers every Unicode space separator.
296
-
297
- // The MATCH-ONLY form of a key: Unicode-composed, punctuation-folded, whitespace-collapsed. Never
298
- // written back anywhere — reports carry the verbatim key.
299
- export function comparisonKey(key: string): string {
300
- let out = key.normalize('NFC')
301
- for (const [re, rep] of PUNCTUATION_FOLDS) out = out.replace(re, rep)
302
- return out.replace(/\s+/g, ' ').trim()
303
- }
304
-
305
- // Indexes values by their comparison key, dropping every comparison key claimed by more than one
306
- // value — an ambiguous fold must not bind, so only the 1:1 entries survive.
307
- function unambiguousByComparisonKey(keys: string[]): Map<string, string> {
308
- const index = new Map<string, string>()
309
- const ambiguous = new Set<string>()
310
- for (const k of keys) {
311
- const c = comparisonKey(k)
312
- if (index.has(c)) ambiguous.add(c)
313
- else index.set(c, k)
314
- }
315
- for (const c of ambiguous) index.delete(c)
316
- return index
317
- }
318
-
319
- export function foldResults(scenarioKeys: ScenarioKey[], results: BoundResult[], node: string): FoldReport {
320
- const nodeResults = results.filter((r) => r.node === node)
321
- const byKey = new Map<string, BoundResult[]>()
322
- for (const r of nodeResults) {
323
- const list = byKey.get(r.key)
324
- if (list) list.push(r)
325
- else byKey.set(r.key, [r])
326
- }
327
-
328
- const featureKeys = new Set(scenarioKeys.map((s) => s.key))
329
- // Only a result no scenario claims exactly is eligible for a near-miss bind, and only a scenario
330
- // that found no exact result looks for one — an exact binding always wins.
331
- const unclaimed = [...byKey.keys()].filter((k) => !featureKeys.has(k))
332
- const unmatchedScenarioKeys = scenarioKeys.map((s) => s.key).filter((k) => !byKey.has(k))
333
- const unclaimedByComparison = unambiguousByComparisonKey(unclaimed)
334
- const unmatchedByComparison = unambiguousByComparisonKey(unmatchedScenarioKeys)
335
-
336
- const nearMisses = new Map<string, string>() // scenario key -> result key
337
- for (const [comparison, scenarioKey] of unmatchedByComparison) {
338
- const resultKey = unclaimedByComparison.get(comparison)
339
- if (resultKey !== undefined) nearMisses.set(scenarioKey, resultKey)
340
- }
341
-
342
- const scenarios: ScenarioReport[] = scenarioKeys.map(({ name, key }) => {
343
- const matchedKey = byKey.has(key) ? undefined : nearMisses.get(key)
344
- const group = byKey.get(matchedKey ?? key) ?? []
345
- const state: ScenarioState =
346
- group.length === 0 ? 'unbound' : group.some((r) => r.outcome === 'fail') ? 'fail' : 'pass'
347
- return { name, key, state, resultCount: group.length, ...(matchedKey === undefined ? {} : { matchedKey }) }
348
- })
349
-
350
- const nearMissKeys = new Set(nearMisses.values())
351
- const extras = unclaimed.filter((k) => !nearMissKeys.has(k)).sort()
352
- const mismatches: TitleMismatch[] = scenarios
353
- .filter((s): s is ScenarioReport & { matchedKey: string } => s.matchedKey !== undefined)
354
- .map((s) => ({ key: s.key, matchedKey: s.matchedKey }))
355
-
356
- const pass = scenarios.filter((s) => s.state === 'pass').length
357
- const fail = scenarios.filter((s) => s.state === 'fail').length
358
- const unbound = scenarios.filter((s) => s.state === 'unbound').length
359
-
360
- return { node, total: scenarios.length, bound: pass + fail, pass, fail, unbound, scenarios, extras, mismatches }
361
- }
362
-
363
- // ── Output ──
364
-
365
- function toonField(v: string): string {
366
- if (v === '' || /[",]/.test(v) || v !== v.trim()) return `"${v.replace(/"/g, '""')}"`
367
- return v
368
- }
369
-
370
- export function formatToon(report: FoldReport): string {
371
- const header = `scenarios[${report.scenarios.length}]{name,key,state,resultCount}:`
372
- const rows = report.scenarios.map(
373
- (s) => ` ${[toonField(s.name), toonField(s.key), s.state, String(s.resultCount)].join(',')}`,
374
- )
375
- const extras = [`extras[${report.extras.length}]{key}:`, ...report.extras.map((e) => ` ${toonField(e)}`)]
376
- const mismatches = [
377
- `mismatches[${report.mismatches.length}]{key,matchedKey}:`,
378
- ...report.mismatches.map((m) => ` ${[toonField(m.key), toonField(m.matchedKey)].join(',')}`),
379
- ]
380
- const summary = `summary{node,total,bound,pass,fail,unbound}:\n ${[
381
- toonField(report.node),
382
- report.total,
383
- report.bound,
384
- report.pass,
385
- report.fail,
386
- report.unbound,
387
- ].join(',')}`
388
- return [header, ...rows, ...extras, ...mismatches, summary].join('\n')
389
- }
390
-
391
- export function formatText(report: FoldReport): string {
392
- const lines: string[] = []
393
- for (const s of report.scenarios) lines.push(`${s.state.toUpperCase().padEnd(8)} ${s.name}`)
394
- lines.push('')
395
- lines.push(
396
- `${report.bound}/${report.total} BOUND, ${report.pass} pass, ${report.fail} fail, ${report.unbound} unbound`,
397
- )
398
- if (report.extras.length > 0) {
399
- lines.push('')
400
- lines.push(`EXTRA (bound results matching no scenario): ${report.extras.join(', ')}`)
401
- }
402
- if (report.mismatches.length > 0) {
403
- lines.push('')
404
- lines.push('PROBABLE TITLE MISMATCH (bound on punctuation/whitespace only — fix the test title):')
405
- for (const m of report.mismatches) lines.push(` scenario "${m.key}" <- test "${m.matchedKey}"`)
406
- }
407
- return lines.join('\n')
408
- }
409
-
410
- // ── CLI ──
411
-
412
- function flag(argv: string[], name: string): string | undefined {
413
- const i = argv.indexOf(name)
414
- return i === -1 ? undefined : argv[i + 1]
415
- }
416
-
417
- // Resolves the source set for a run: `--report` bypasses the configured sources entirely with a
418
- // single ad-hoc junit source; otherwise the sources come from the (resolved) config file.
419
- export function resolveSources(argv: string[], root: string): SourceConfig[] {
420
- const reportFlag = flag(argv, '--report')
421
- if (reportFlag) {
422
- return [{ adapter: 'junit', reportPath: reportFlag }]
423
- }
424
- const configPath = underRoot(root, flag(argv, '--config') ?? '.agents/sdd/scenario-bridge.toml')
425
- return readSourcesConfig(configPath)
426
- }
427
-
428
- // Renders a fold report in the requested format, defaulting to text.
429
- export function renderReport(report: FoldReport, format: string): string {
430
- if (format === 'json') return JSON.stringify(report, null, 2)
431
- if (format === 'toon') return formatToon(report)
432
- return formatText(report)
433
- }
434
-
435
- // Non-zero exactly when any scenario is UNBOUND or FAIL; zero only when every scenario is bound
436
- // and passing.
437
- export function exitCode(report: FoldReport): number {
438
- return report.unbound > 0 || report.fail > 0 ? 1 : 0
439
- }
440
-
441
- export function main(argv: string[]): number {
442
- const root = flag(argv, '--root') ?? '.'
443
- const featureRoot = flag(argv, '--feature-root') ?? root
444
- const feature = flag(argv, '--feature')
445
- const node = flag(argv, '--node')
446
- const format = flag(argv, '--format') ?? 'text'
447
- const run = argv.includes('--run')
448
- const w = (s: string) => process.stdout.write(`${s}\n`)
449
-
450
- if (!feature || !node) {
451
- w(
452
- 'usage: verify-scenarios --feature <path> --node <path> [--config <toml>] [--root <dir>] [--feature-root <dir>] [--report <xml>] [--run] [--format toon|json]',
453
- )
454
- return 1
455
- }
456
-
457
- const sources = resolveSources(argv, root)
458
-
459
- const results = sources.flatMap((s) => runSource(s, root, run))
460
- const scenarioKeys = getScenarioKeys(root, feature, featureRoot)
461
- const report = foldResults(scenarioKeys, results, node)
462
-
463
- w(renderReport(report, format))
464
-
465
- return exitCode(report)
466
- }
467
-
468
- if (process.argv[1] && import.meta.url === pathToFileURL(realpathSync(process.argv[1])).href) {
469
- process.exit(main(process.argv.slice(2)))
470
- }