cyber-sdd 0.7.0 → 0.7.1

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 (149) hide show
  1. package/.claude-plugin/plugin.json +1 -2
  2. package/.codex-plugin/plugin.json +1 -2
  3. package/agents/sdd-scanner.md +1 -1
  4. package/package.json +11 -13
  5. package/plugin.json +55 -0
  6. package/skills/align-spec/SKILL.md +1 -1
  7. package/skills/align-spec/scripts/align-spec.mjs +6755 -12
  8. package/skills/blast-estimate/README.md +3 -3
  9. package/skills/blast-estimate/scripts/blast-estimate.mjs +601 -12
  10. package/skills/check-field-mandates/scripts/check-field-mandates.mjs +253 -12
  11. package/skills/check-partition-quality/scripts/check-partition-quality.mjs +235 -12
  12. package/skills/check-plan-safety/SKILL.md +2 -3
  13. package/skills/check-plan-safety/scripts/check-plan-safety.mjs +109 -12
  14. package/skills/check-plugin-manifests/scripts/check-plugin-manifests.mjs +250 -12
  15. package/skills/check-project-specs/SKILL.md +1 -1
  16. package/skills/check-project-specs/scripts/check-project-specs.mjs +593 -12
  17. package/skills/check-retired-terms/SKILL.md +2 -3
  18. package/skills/check-retired-terms/scripts/check-retired-terms.mjs +186 -12
  19. package/skills/check-scenario-overlap/SKILL.md +1 -2
  20. package/skills/check-scenario-overlap/scripts/check-scenario-overlap.mjs +177 -12
  21. package/skills/check-spec-references/SKILL.md +2 -2
  22. package/skills/check-spec-references/scripts/check-spec-references.mjs +287 -12
  23. package/skills/check-spec-structure/SKILL.md +1 -2
  24. package/skills/check-spec-structure/scripts/check-spec-structure.mjs +243 -12
  25. package/skills/collision-ladder/README.md +2 -2
  26. package/skills/collision-ladder/scripts/collision-ladder.mjs +7047 -12
  27. package/skills/combat-log-governance/SKILL.md +1 -1
  28. package/skills/concept-index/SKILL.md +1 -1
  29. package/skills/concept-index/scripts/concept-index.mjs +183 -12
  30. package/skills/discover-plans/README.md +3 -3
  31. package/skills/discover-plans/SKILL.md +1 -1
  32. package/skills/discover-plans/scripts/discover-plans.mjs +137 -12
  33. package/skills/discover-specs/README.md +3 -3
  34. package/skills/discover-specs/SKILL.md +1 -1
  35. package/skills/discover-specs/scripts/discover-specs.mjs +321 -12
  36. package/skills/doctrine-loop/SKILL.md +1 -1
  37. package/skills/gate-validation-governance/SKILL.md +1 -1
  38. package/skills/init/README.md +2 -2
  39. package/skills/init/scripts/wire-statusline.mjs +168 -12
  40. package/skills/manage-ignore/README.md +3 -3
  41. package/skills/manage-ignore/SKILL.md +1 -2
  42. package/skills/manage-ignore/scripts/manage-ignore.mjs +285 -12
  43. package/skills/manage-scenario-bridge/README.md +3 -3
  44. package/skills/manage-scenario-bridge/SKILL.md +1 -1
  45. package/skills/manage-scenario-bridge/scripts/manage-scenario-bridge.mjs +239 -12
  46. package/skills/manage-spec-anchors/README.md +3 -3
  47. package/skills/manage-spec-anchors/SKILL.md +1 -1
  48. package/skills/manage-spec-anchors/scripts/manage-spec-anchors.mjs +310 -12
  49. package/skills/mission-graph/README.md +2 -2
  50. package/skills/mission-graph/SKILL.md +1 -1
  51. package/skills/mission-graph/scripts/mission-graph.mjs +1040 -12
  52. package/skills/pause-mission/SKILL.md +1 -1
  53. package/skills/place-node/SKILL.md +2 -2
  54. package/skills/place-node/scripts/place-node.mjs +117 -12
  55. package/skills/plan-retirement/README.md +2 -2
  56. package/skills/plan-retirement/scripts/retire-plans.mjs +123 -12
  57. package/skills/resolve-governances/README.md +3 -3
  58. package/skills/resolve-governances/SKILL.md +1 -1
  59. package/skills/resolve-governances/scripts/resolve-governances.mjs +352 -12
  60. package/skills/resolve-tracking/SKILL.md +1 -1
  61. package/skills/resolve-tracking/scripts/resolve-tracking.mjs +152 -12
  62. package/skills/scaffold-project-spec/SKILL.md +1 -1
  63. package/skills/spec-gate/README.md +12 -9
  64. package/skills/spec-gate/SKILL.md +2 -2
  65. package/skills/spec-gate/scripts/check-spec-state.mjs +422 -12
  66. package/skills/spec-gate/scripts/check-suite.mjs +6500 -12
  67. package/skills/spec-gate/scripts/classify-edit-class.mjs +6579 -12
  68. package/skills/spec-producer-governance/SKILL.md +2 -2
  69. package/skills/start-mission/SKILL.md +1 -1
  70. package/skills/suite-format-governance/README.md +1 -1
  71. package/skills/suite-format-governance/SKILL.md +1 -1
  72. package/skills/touch-set-correction/README.md +5 -5
  73. package/skills/touch-set-correction/SKILL.md +1 -1
  74. package/skills/touch-set-correction/scripts/touch-set-correction.mjs +6663 -12
  75. package/skills/verify-scenarios/README.md +6 -6
  76. package/skills/verify-scenarios/scripts/verify-scenarios.mjs +6539 -12
  77. package/.plugin/pins.json +0 -3
  78. package/.plugin/plugin.json +0 -19
  79. package/dist/align-spec/scripts/align-spec.js +0 -102
  80. package/dist/check-project-specs/scripts/check-project-specs.js +0 -312
  81. package/dist/check-scenario-overlap/scripts/check-scenario-overlap.js +0 -175
  82. package/dist/check-spec-references/scripts/check-spec-references.js +0 -285
  83. package/dist/check-spec-structure/scripts/check-spec-structure.js +0 -241
  84. package/dist/concept-index/scripts/concept-index.js +0 -181
  85. package/dist/discover-specs/scripts/discover-specs.js +0 -318
  86. package/dist/spec-gate/scripts/check-spec-state.js +0 -420
  87. package/dist/spec-gate/scripts/check-suite.js +0 -317
  88. package/dist/spec-gate/scripts/classify-edit-class.js +0 -263
  89. package/dist/spec-gate/vendor/gherkin-cli.js +0 -6369
  90. package/skills/align-spec/scripts/align-spec.bundle.mjs +0 -6753
  91. package/skills/align-spec/scripts/align-spec.mts +0 -188
  92. package/skills/blast-estimate/scripts/blast-estimate.bundle.mjs +0 -593
  93. package/skills/blast-estimate/scripts/blast-estimate.mts +0 -586
  94. package/skills/check-field-mandates/scripts/check-field-mandates.bundle.mjs +0 -251
  95. package/skills/check-field-mandates/scripts/check-field-mandates.mts +0 -337
  96. package/skills/check-partition-quality/scripts/check-partition-quality.bundle.mjs +0 -233
  97. package/skills/check-partition-quality/scripts/check-partition-quality.mts +0 -338
  98. package/skills/check-plan-safety/scripts/check-plan-safety.bundle.mjs +0 -107
  99. package/skills/check-plan-safety/scripts/check-plan-safety.mts +0 -148
  100. package/skills/check-plugin-manifests/scripts/check-plugin-manifests.bundle.mjs +0 -248
  101. package/skills/check-plugin-manifests/scripts/check-plugin-manifests.mts +0 -303
  102. package/skills/check-project-specs/scripts/check-project-specs.bundle.mjs +0 -584
  103. package/skills/check-project-specs/scripts/check-project-specs.mts +0 -370
  104. package/skills/check-retired-terms/scripts/check-retired-terms.bundle.mjs +0 -184
  105. package/skills/check-retired-terms/scripts/check-retired-terms.mts +0 -293
  106. package/skills/check-scenario-overlap/scripts/check-scenario-overlap.bundle.mjs +0 -175
  107. package/skills/check-scenario-overlap/scripts/check-scenario-overlap.mts +0 -250
  108. package/skills/check-spec-references/scripts/check-spec-references.bundle.mjs +0 -285
  109. package/skills/check-spec-references/scripts/check-spec-references.mts +0 -422
  110. package/skills/check-spec-structure/scripts/check-spec-structure.bundle.mjs +0 -241
  111. package/skills/check-spec-structure/scripts/check-spec-structure.mts +0 -347
  112. package/skills/collision-ladder/scripts/collision-ladder.bundle.mjs +0 -7041
  113. package/skills/collision-ladder/scripts/collision-ladder.mts +0 -662
  114. package/skills/concept-index/scripts/concept-index.bundle.mjs +0 -181
  115. package/skills/concept-index/scripts/concept-index.mts +0 -246
  116. package/skills/discover-plans/scripts/discover-plans.bundle.mjs +0 -135
  117. package/skills/discover-plans/scripts/discover-plans.mts +0 -215
  118. package/skills/discover-specs/scripts/discover-specs.bundle.mjs +0 -319
  119. package/skills/discover-specs/scripts/discover-specs.mts +0 -420
  120. package/skills/init/scripts/wire-statusline.bundle.mjs +0 -166
  121. package/skills/init/scripts/wire-statusline.mts +0 -279
  122. package/skills/manage-ignore/scripts/manage-ignore.bundle.mjs +0 -244
  123. package/skills/manage-ignore/scripts/manage-ignore.mts +0 -297
  124. package/skills/manage-scenario-bridge/scripts/manage-scenario-bridge.bundle.mjs +0 -237
  125. package/skills/manage-scenario-bridge/scripts/manage-scenario-bridge.mts +0 -159
  126. package/skills/manage-spec-anchors/scripts/manage-spec-anchors.bundle.mjs +0 -308
  127. package/skills/manage-spec-anchors/scripts/manage-spec-anchors.mts +0 -331
  128. package/skills/mission-graph/scripts/mission-graph.bundle.mjs +0 -1038
  129. package/skills/mission-graph/scripts/mission-graph.mts +0 -1333
  130. package/skills/place-node/scripts/place-node.bundle.mjs +0 -115
  131. package/skills/place-node/scripts/place-node.mts +0 -158
  132. package/skills/plan-retirement/scripts/retire-plans.bundle.mjs +0 -121
  133. package/skills/plan-retirement/scripts/retire-plans.mts +0 -205
  134. package/skills/resolve-governances/scripts/resolve-governances.bundle.mjs +0 -350
  135. package/skills/resolve-governances/scripts/resolve-governances.mts +0 -518
  136. package/skills/resolve-tracking/scripts/resolve-tracking.bundle.mjs +0 -150
  137. package/skills/resolve-tracking/scripts/resolve-tracking.mts +0 -214
  138. package/skills/spec-gate/scripts/check-spec-state.bundle.mjs +0 -420
  139. package/skills/spec-gate/scripts/check-spec-state.mts +0 -637
  140. package/skills/spec-gate/scripts/check-suite.bundle.mjs +0 -6498
  141. package/skills/spec-gate/scripts/check-suite.mts +0 -538
  142. package/skills/spec-gate/scripts/classify-edit-class.bundle.mjs +0 -6577
  143. package/skills/spec-gate/scripts/classify-edit-class.mts +0 -414
  144. package/skills/spec-gate/vendor/gherkin-cli.d.mts +0 -2
  145. package/skills/spec-gate/vendor/gherkin-cli.mjs +0 -6545
  146. package/skills/touch-set-correction/scripts/touch-set-correction.bundle.mjs +0 -6655
  147. package/skills/touch-set-correction/scripts/touch-set-correction.mts +0 -429
  148. package/skills/verify-scenarios/scripts/verify-scenarios.bundle.mjs +0 -6537
  149. 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
- }