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.
- package/.claude-plugin/plugin.json +1 -2
- package/.codex-plugin/plugin.json +1 -2
- package/CHANGELOG.md +320 -0
- package/agents/sdd-scanner.md +1 -1
- package/package.json +11 -12
- package/plugin.json +55 -0
- package/skills/align-spec/SKILL.md +1 -1
- package/skills/align-spec/scripts/align-spec.mjs +6755 -12
- package/skills/blast-estimate/README.md +3 -3
- package/skills/blast-estimate/scripts/blast-estimate.mjs +601 -12
- package/skills/check-field-mandates/scripts/check-field-mandates.mjs +253 -12
- package/skills/check-partition-quality/scripts/check-partition-quality.mjs +235 -12
- package/skills/check-plan-safety/SKILL.md +2 -3
- package/skills/check-plan-safety/scripts/check-plan-safety.mjs +109 -12
- package/skills/check-plugin-manifests/scripts/check-plugin-manifests.mjs +250 -12
- package/skills/check-project-specs/SKILL.md +1 -1
- package/skills/check-project-specs/scripts/check-project-specs.mjs +593 -12
- package/skills/check-retired-terms/SKILL.md +2 -3
- package/skills/check-retired-terms/scripts/check-retired-terms.mjs +186 -12
- package/skills/check-scenario-overlap/SKILL.md +1 -2
- package/skills/check-scenario-overlap/scripts/check-scenario-overlap.mjs +177 -12
- package/skills/check-spec-references/SKILL.md +2 -2
- package/skills/check-spec-references/scripts/check-spec-references.mjs +287 -12
- package/skills/check-spec-structure/SKILL.md +1 -2
- package/skills/check-spec-structure/scripts/check-spec-structure.mjs +243 -12
- package/skills/collision-ladder/README.md +2 -2
- package/skills/collision-ladder/scripts/collision-ladder.mjs +7047 -12
- package/skills/combat-log-governance/SKILL.md +1 -1
- package/skills/concept-index/SKILL.md +1 -1
- package/skills/concept-index/scripts/concept-index.mjs +183 -12
- package/skills/discover-plans/README.md +3 -3
- package/skills/discover-plans/SKILL.md +1 -1
- package/skills/discover-plans/scripts/discover-plans.mjs +137 -12
- package/skills/discover-specs/README.md +3 -3
- package/skills/discover-specs/SKILL.md +1 -1
- package/skills/discover-specs/scripts/discover-specs.mjs +321 -12
- package/skills/doctrine-loop/SKILL.md +1 -1
- package/skills/gate-validation-governance/SKILL.md +1 -1
- package/skills/init/README.md +2 -2
- package/skills/init/scripts/wire-statusline.mjs +168 -12
- package/skills/manage-ignore/README.md +3 -3
- package/skills/manage-ignore/SKILL.md +1 -2
- package/skills/manage-ignore/scripts/manage-ignore.mjs +285 -12
- package/skills/manage-scenario-bridge/README.md +3 -3
- package/skills/manage-scenario-bridge/SKILL.md +1 -1
- package/skills/manage-scenario-bridge/scripts/manage-scenario-bridge.mjs +239 -12
- package/skills/manage-spec-anchors/README.md +3 -3
- package/skills/manage-spec-anchors/SKILL.md +1 -1
- package/skills/manage-spec-anchors/scripts/manage-spec-anchors.mjs +310 -12
- package/skills/mission-graph/README.md +2 -2
- package/skills/mission-graph/SKILL.md +1 -1
- package/skills/mission-graph/scripts/mission-graph.mjs +1040 -12
- package/skills/pause-mission/SKILL.md +1 -1
- package/skills/place-node/SKILL.md +2 -2
- package/skills/place-node/scripts/place-node.mjs +117 -12
- package/skills/plan-retirement/README.md +2 -2
- package/skills/plan-retirement/scripts/retire-plans.mjs +123 -12
- package/skills/resolve-governances/README.md +3 -3
- package/skills/resolve-governances/SKILL.md +1 -1
- package/skills/resolve-governances/scripts/resolve-governances.mjs +352 -12
- package/skills/resolve-tracking/SKILL.md +1 -1
- package/skills/resolve-tracking/scripts/resolve-tracking.mjs +152 -12
- package/skills/scaffold-project-spec/SKILL.md +1 -1
- package/skills/spec-gate/README.md +12 -9
- package/skills/spec-gate/SKILL.md +2 -2
- package/skills/spec-gate/scripts/check-spec-state.mjs +422 -12
- package/skills/spec-gate/scripts/check-suite.mjs +6500 -12
- package/skills/spec-gate/scripts/classify-edit-class.mjs +6579 -12
- package/skills/spec-producer-governance/SKILL.md +2 -2
- package/skills/start-mission/SKILL.md +1 -1
- package/skills/suite-format-governance/README.md +1 -1
- package/skills/suite-format-governance/SKILL.md +1 -1
- package/skills/touch-set-correction/README.md +5 -5
- package/skills/touch-set-correction/SKILL.md +1 -1
- package/skills/touch-set-correction/scripts/touch-set-correction.mjs +6663 -12
- package/skills/verify-scenarios/README.md +6 -6
- package/skills/verify-scenarios/scripts/verify-scenarios.mjs +6539 -12
- package/.plugin/pins.json +0 -3
- package/.plugin/plugin.json +0 -19
- package/dist/align-spec/scripts/align-spec.js +0 -102
- package/dist/check-project-specs/scripts/check-project-specs.js +0 -312
- package/dist/check-scenario-overlap/scripts/check-scenario-overlap.js +0 -175
- package/dist/check-spec-references/scripts/check-spec-references.js +0 -285
- package/dist/check-spec-structure/scripts/check-spec-structure.js +0 -241
- package/dist/concept-index/scripts/concept-index.js +0 -181
- package/dist/discover-specs/scripts/discover-specs.js +0 -318
- package/dist/spec-gate/scripts/check-spec-state.js +0 -420
- package/dist/spec-gate/scripts/check-suite.js +0 -317
- package/dist/spec-gate/scripts/classify-edit-class.js +0 -263
- package/dist/spec-gate/vendor/gherkin-cli.js +0 -6369
- package/skills/align-spec/scripts/align-spec.bundle.mjs +0 -6753
- package/skills/align-spec/scripts/align-spec.mts +0 -188
- package/skills/blast-estimate/scripts/blast-estimate.bundle.mjs +0 -593
- package/skills/blast-estimate/scripts/blast-estimate.mts +0 -586
- package/skills/check-field-mandates/scripts/check-field-mandates.bundle.mjs +0 -251
- package/skills/check-field-mandates/scripts/check-field-mandates.mts +0 -337
- package/skills/check-partition-quality/scripts/check-partition-quality.bundle.mjs +0 -233
- package/skills/check-partition-quality/scripts/check-partition-quality.mts +0 -338
- package/skills/check-plan-safety/scripts/check-plan-safety.bundle.mjs +0 -107
- package/skills/check-plan-safety/scripts/check-plan-safety.mts +0 -148
- package/skills/check-plugin-manifests/scripts/check-plugin-manifests.bundle.mjs +0 -248
- package/skills/check-plugin-manifests/scripts/check-plugin-manifests.mts +0 -303
- package/skills/check-project-specs/scripts/check-project-specs.bundle.mjs +0 -584
- package/skills/check-project-specs/scripts/check-project-specs.mts +0 -370
- package/skills/check-retired-terms/scripts/check-retired-terms.bundle.mjs +0 -184
- package/skills/check-retired-terms/scripts/check-retired-terms.mts +0 -293
- package/skills/check-scenario-overlap/scripts/check-scenario-overlap.bundle.mjs +0 -175
- package/skills/check-scenario-overlap/scripts/check-scenario-overlap.mts +0 -250
- package/skills/check-spec-references/scripts/check-spec-references.bundle.mjs +0 -285
- package/skills/check-spec-references/scripts/check-spec-references.mts +0 -422
- package/skills/check-spec-structure/scripts/check-spec-structure.bundle.mjs +0 -241
- package/skills/check-spec-structure/scripts/check-spec-structure.mts +0 -347
- package/skills/collision-ladder/scripts/collision-ladder.bundle.mjs +0 -7041
- package/skills/collision-ladder/scripts/collision-ladder.mts +0 -662
- package/skills/concept-index/scripts/concept-index.bundle.mjs +0 -181
- package/skills/concept-index/scripts/concept-index.mts +0 -246
- package/skills/discover-plans/scripts/discover-plans.bundle.mjs +0 -135
- package/skills/discover-plans/scripts/discover-plans.mts +0 -215
- package/skills/discover-specs/scripts/discover-specs.bundle.mjs +0 -319
- package/skills/discover-specs/scripts/discover-specs.mts +0 -420
- package/skills/init/scripts/wire-statusline.bundle.mjs +0 -166
- package/skills/init/scripts/wire-statusline.mts +0 -279
- package/skills/manage-ignore/scripts/manage-ignore.bundle.mjs +0 -244
- package/skills/manage-ignore/scripts/manage-ignore.mts +0 -297
- package/skills/manage-scenario-bridge/scripts/manage-scenario-bridge.bundle.mjs +0 -237
- package/skills/manage-scenario-bridge/scripts/manage-scenario-bridge.mts +0 -159
- package/skills/manage-spec-anchors/scripts/manage-spec-anchors.bundle.mjs +0 -308
- package/skills/manage-spec-anchors/scripts/manage-spec-anchors.mts +0 -331
- package/skills/mission-graph/scripts/mission-graph.bundle.mjs +0 -1038
- package/skills/mission-graph/scripts/mission-graph.mts +0 -1333
- package/skills/place-node/scripts/place-node.bundle.mjs +0 -115
- package/skills/place-node/scripts/place-node.mts +0 -158
- package/skills/plan-retirement/scripts/retire-plans.bundle.mjs +0 -121
- package/skills/plan-retirement/scripts/retire-plans.mts +0 -205
- package/skills/resolve-governances/scripts/resolve-governances.bundle.mjs +0 -350
- package/skills/resolve-governances/scripts/resolve-governances.mts +0 -518
- package/skills/resolve-tracking/scripts/resolve-tracking.bundle.mjs +0 -150
- package/skills/resolve-tracking/scripts/resolve-tracking.mts +0 -214
- package/skills/spec-gate/scripts/check-spec-state.bundle.mjs +0 -420
- package/skills/spec-gate/scripts/check-spec-state.mts +0 -637
- package/skills/spec-gate/scripts/check-suite.bundle.mjs +0 -6498
- package/skills/spec-gate/scripts/check-suite.mts +0 -538
- package/skills/spec-gate/scripts/classify-edit-class.bundle.mjs +0 -6577
- package/skills/spec-gate/scripts/classify-edit-class.mts +0 -414
- package/skills/spec-gate/vendor/gherkin-cli.d.mts +0 -2
- package/skills/spec-gate/vendor/gherkin-cli.mjs +0 -6545
- package/skills/touch-set-correction/scripts/touch-set-correction.bundle.mjs +0 -6655
- package/skills/touch-set-correction/scripts/touch-set-correction.mts +0 -429
- package/skills/verify-scenarios/scripts/verify-scenarios.bundle.mjs +0 -6537
- 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
|
-
[/</g, '<'],
|
|
150
|
-
[/>/g, '>'],
|
|
151
|
-
[/"/g, '"'],
|
|
152
|
-
[/'/g, "'"],
|
|
153
|
-
[/&/g, '&'], // last, so a literal "&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
|
-
}
|