cyber-sdd 0.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude-plugin/plugin.json +17 -0
- package/.codex-plugin/plugin.json +17 -0
- package/.plugin/plugin.json +17 -0
- package/README.md +159 -0
- package/agents/sdd-automaton.md +97 -0
- package/agents/sdd-impl-judge.md +214 -0
- package/agents/sdd-scanner.md +120 -0
- package/agents/sdd-spec-judge.md +224 -0
- package/agents/sdd-warden.md +101 -0
- package/package.json +24 -0
- package/skills/align-spec/README.md +20 -0
- package/skills/align-spec/SKILL.md +111 -0
- package/skills/align-spec/scripts/align-spec.mts +187 -0
- package/skills/architect-impl-governance/README.md +46 -0
- package/skills/architect-impl-governance/SKILL.md +45 -0
- package/skills/architect-spec-governance/README.md +48 -0
- package/skills/architect-spec-governance/SKILL.md +59 -0
- package/skills/blast-estimate/README.md +47 -0
- package/skills/blast-estimate/SKILL.md +133 -0
- package/skills/blast-estimate/scripts/blast-estimate.mts +583 -0
- package/skills/builder-impl-governance/README.md +47 -0
- package/skills/builder-impl-governance/SKILL.md +47 -0
- package/skills/builder-spec-governance/README.md +49 -0
- package/skills/builder-spec-governance/SKILL.md +36 -0
- package/skills/check-partition-quality/README.md +22 -0
- package/skills/check-partition-quality/SKILL.md +51 -0
- package/skills/check-partition-quality/scripts/check-partition-quality.mts +336 -0
- package/skills/check-plan-safety/README.md +17 -0
- package/skills/check-plan-safety/SKILL.md +60 -0
- package/skills/check-plan-safety/scripts/check-plan-safety.mts +145 -0
- package/skills/check-project-specs/README.md +19 -0
- package/skills/check-project-specs/SKILL.md +69 -0
- package/skills/check-project-specs/scripts/check-project-specs.mts +217 -0
- package/skills/check-scenario-overlap/README.md +19 -0
- package/skills/check-scenario-overlap/SKILL.md +74 -0
- package/skills/check-scenario-overlap/scripts/check-scenario-overlap.mts +249 -0
- package/skills/check-spec-structure/README.md +17 -0
- package/skills/check-spec-structure/SKILL.md +66 -0
- package/skills/check-spec-structure/scripts/check-spec-structure.mts +346 -0
- package/skills/collision-ladder/README.md +18 -0
- package/skills/collision-ladder/SKILL.md +83 -0
- package/skills/collision-ladder/scripts/collision-ladder.mts +657 -0
- package/skills/combat-log-governance/README.md +13 -0
- package/skills/combat-log-governance/SKILL.md +257 -0
- package/skills/concept-index/README.md +13 -0
- package/skills/concept-index/SKILL.md +38 -0
- package/skills/concept-index/scripts/concept-index.mts +245 -0
- package/skills/discover-plans/README.md +16 -0
- package/skills/discover-plans/SKILL.md +74 -0
- package/skills/discover-plans/scripts/discover-plans.mts +212 -0
- package/skills/discover-specs/README.md +15 -0
- package/skills/discover-specs/SKILL.md +76 -0
- package/skills/discover-specs/scripts/discover-specs.mts +396 -0
- package/skills/doctrine-loop/README.md +15 -0
- package/skills/doctrine-loop/SKILL.md +97 -0
- package/skills/formation-loop/README.md +17 -0
- package/skills/formation-loop/SKILL.md +140 -0
- package/skills/gate-validation-governance/README.md +12 -0
- package/skills/gate-validation-governance/SKILL.md +87 -0
- package/skills/impl-producer-governance/README.md +48 -0
- package/skills/impl-producer-governance/SKILL.md +85 -0
- package/skills/init/README.md +27 -0
- package/skills/init/SKILL.md +68 -0
- package/skills/init/scripts/wire-statusline.mts +276 -0
- package/skills/lifecycle-governance/README.md +11 -0
- package/skills/lifecycle-governance/SKILL.md +168 -0
- package/skills/manage/README.md +9 -0
- package/skills/manage/SKILL.md +62 -0
- package/skills/manage-ignore/README.md +19 -0
- package/skills/manage-ignore/SKILL.md +52 -0
- package/skills/manage-ignore/scripts/manage-ignore.mts +294 -0
- package/skills/manage-scenario-bridge/README.md +20 -0
- package/skills/manage-scenario-bridge/SKILL.md +60 -0
- package/skills/manage-scenario-bridge/scripts/manage-scenario-bridge.mts +156 -0
- package/skills/manage-spec-anchors/README.md +18 -0
- package/skills/manage-spec-anchors/SKILL.md +56 -0
- package/skills/manage-spec-anchors/scripts/manage-spec-anchors.mts +328 -0
- package/skills/mission-graph/README.md +15 -0
- package/skills/mission-graph/SKILL.md +67 -0
- package/skills/mission-graph/scripts/mission-graph.mts +844 -0
- package/skills/oracle-spec-governance/README.md +45 -0
- package/skills/oracle-spec-governance/SKILL.md +45 -0
- package/skills/ownership-governance/README.md +65 -0
- package/skills/ownership-governance/SKILL.md +104 -0
- package/skills/pause-mission/README.md +18 -0
- package/skills/pause-mission/SKILL.md +112 -0
- package/skills/place-node/README.md +12 -0
- package/skills/place-node/SKILL.md +47 -0
- package/skills/place-node/scripts/place-node.mts +157 -0
- package/skills/plan-retirement/README.md +32 -0
- package/skills/plan-retirement/SKILL.md +90 -0
- package/skills/plan-retirement/scripts/retire-plans.mts +196 -0
- package/skills/plugin-contract-governance/README.md +12 -0
- package/skills/plugin-contract-governance/SKILL.md +112 -0
- package/skills/remediation-governance/README.md +46 -0
- package/skills/remediation-governance/SKILL.md +78 -0
- package/skills/resolve-governances/README.md +18 -0
- package/skills/resolve-governances/SKILL.md +50 -0
- package/skills/resolve-governances/scripts/resolve-governances.mts +515 -0
- package/skills/resolve-tracking/SKILL.md +64 -0
- package/skills/resolve-tracking/scripts/resolve-tracking.mts +213 -0
- package/skills/resume-mission/README.md +12 -0
- package/skills/resume-mission/SKILL.md +53 -0
- package/skills/scaffold-project-spec/README.md +7 -0
- package/skills/scaffold-project-spec/SKILL.md +192 -0
- package/skills/sdd/README.md +7 -0
- package/skills/sdd/SKILL.md +92 -0
- package/skills/solution-producer-governance/README.md +9 -0
- package/skills/solution-producer-governance/SKILL.md +44 -0
- package/skills/spec-format-governance/README.md +73 -0
- package/skills/spec-format-governance/SKILL.md +114 -0
- package/skills/spec-gate/README.md +26 -0
- package/skills/spec-gate/SKILL.md +201 -0
- package/skills/spec-gate/scripts/check-spec-state.mts +601 -0
- package/skills/spec-gate/scripts/check-suite.mts +501 -0
- package/skills/spec-gate/scripts/classify-edit-class.mts +411 -0
- package/skills/spec-producer-governance/README.md +7 -0
- package/skills/spec-producer-governance/SKILL.md +86 -0
- package/skills/spec-structure-governance/README.md +40 -0
- package/skills/spec-structure-governance/SKILL.md +169 -0
- package/skills/ssa-lowering/README.md +26 -0
- package/skills/ssa-lowering/SKILL.md +181 -0
- package/skills/start-mission/README.md +7 -0
- package/skills/start-mission/SKILL.md +115 -0
- package/skills/suite-format-governance/README.md +75 -0
- package/skills/suite-format-governance/SKILL.md +299 -0
- package/skills/suite-format-governance/references/rubric.md +313 -0
- package/skills/touch-set-correction/README.md +16 -0
- package/skills/touch-set-correction/SKILL.md +67 -0
- package/skills/touch-set-correction/scripts/touch-set-correction.mts +418 -0
- package/skills/verify-scenarios/README.md +17 -0
- package/skills/verify-scenarios/SKILL.md +109 -0
- package/skills/verify-scenarios/scripts/verify-scenarios.mts +386 -0
|
@@ -0,0 +1,418 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// touch-set-correction — a post-hoc, read-only reconciliation of a Mission's DECLARED touch-set
|
|
3
|
+
// (the pre-work guess used to keep the mission-graph's WAW frontier apart) against its ACTUAL
|
|
4
|
+
// touch-set (recovered from `git diff base..head`). It composes three tools — `git diff` (changed
|
|
5
|
+
// files), `resolve-governances` (each file's artifact-type, best-effort), and `gherkin-cli diff`
|
|
6
|
+
// (a touched .feature's changed scenarios) — into one three-way split (confirmed / missed /
|
|
7
|
+
// over-declared) plus the corrected touch-set (= the actual touched set). See
|
|
8
|
+
// .agents/specs/sdd/touch-set-correction/README.md for the full contract.
|
|
9
|
+
//
|
|
10
|
+
// Architecture — pure derivation kept apart from IO, on purpose (mission-graph.mts's convention):
|
|
11
|
+
// - isFeature / fileToNode / reconcile / assembleCorrection are PURE: they take and return plain
|
|
12
|
+
// data — no fs/network access. Tests exercise these directly over CONSTRUCTED fixtures — never
|
|
13
|
+
// a live git diff or the live mission-graph store.
|
|
14
|
+
// - readChangedFiles / resolveArtifactType / changedScenarios / collectChangedFiles /
|
|
15
|
+
// discoverLayouts are the thin IO SEAM: they shell out to `git`, `resolve-governances.mts`, and
|
|
16
|
+
// the pinned `gherkin-cli@0.0.2` `diffFeatures` (the same differ classify-edit-class.mts uses —
|
|
17
|
+
// this tool never reimplements a differ). NOT unit-tested (binary/fs boundary) — the tested
|
|
18
|
+
// logic is everything downstream of the file list.
|
|
19
|
+
// - main() is a thin CLI: argv -> collectChangedFiles + assembleCorrection, rendering TOON by
|
|
20
|
+
// default or `--format json`.
|
|
21
|
+
//
|
|
22
|
+
// This tool is READ-ONLY with respect to the mission graph: it never writes to it. The graph's
|
|
23
|
+
// single writer appends the corrected touch-set at Mission retirement (deferred, F3). It also does
|
|
24
|
+
// NOT classify a collision hard/soft, run the finer-than-node ladder, descend to region/hunk tier,
|
|
25
|
+
// or do SSA lowering (all deferred, issue #189 remainder).
|
|
26
|
+
//
|
|
27
|
+
// Pure functions are exported for node:test; running the file directly drives the CLI.
|
|
28
|
+
|
|
29
|
+
import { execFileSync } from 'node:child_process'
|
|
30
|
+
import { readFileSync } from 'node:fs'
|
|
31
|
+
import { dirname, join, relative, resolve, sep } from 'node:path'
|
|
32
|
+
import { fileURLToPath } from 'node:url'
|
|
33
|
+
import { type DiffReader, diffFeatures } from 'gherkin-cli'
|
|
34
|
+
|
|
35
|
+
// ── Types ──
|
|
36
|
+
|
|
37
|
+
export interface ProjectLayout {
|
|
38
|
+
project: string
|
|
39
|
+
roots: string[]
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** A changed file with its resolved annotations — the IO seam fills these in; tests construct them. */
|
|
43
|
+
export interface ChangedFile {
|
|
44
|
+
path: string
|
|
45
|
+
artifactType: string
|
|
46
|
+
changedScenarios: string[]
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export interface FileEntry {
|
|
50
|
+
path: string
|
|
51
|
+
node: string | null
|
|
52
|
+
artifactType: string
|
|
53
|
+
changedScenarios: string[]
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** One changed file under a node, with the artifact-type resolved for it (best-effort — `unknown`
|
|
57
|
+
* when it does not resolve). The per-file annotation the frozen "carries the artifact-type"
|
|
58
|
+
* contract requires observable in the output. */
|
|
59
|
+
export interface NodeFile {
|
|
60
|
+
path: string
|
|
61
|
+
artifactType: string
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export interface NodeDetail {
|
|
65
|
+
node: string
|
|
66
|
+
files: NodeFile[]
|
|
67
|
+
changedScenarios: string[]
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export interface Reconciliation {
|
|
71
|
+
confirmed: string[]
|
|
72
|
+
missed: string[]
|
|
73
|
+
overDeclared: string[]
|
|
74
|
+
corrected: string[]
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
export interface Correction extends Reconciliation {
|
|
78
|
+
nodes: NodeDetail[]
|
|
79
|
+
unmapped: string[]
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
// ── Pure derivations ──
|
|
83
|
+
|
|
84
|
+
/** The scenario-rung gate is STRUCTURAL — the `.feature` extension — never the resolved
|
|
85
|
+
* artifact-type (a `.feature` with an unresolved artifact-type still gets scenario detail; a
|
|
86
|
+
* non-`.feature` with a resolved artifact-type never does). */
|
|
87
|
+
export function isFeature(path: string): boolean {
|
|
88
|
+
return path.endsWith('.feature')
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
function stripTrailingSlash(root: string): string {
|
|
92
|
+
return root.endsWith('/') ? root.slice(0, -1) : root
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* fileToNode — capability-first work-area recovery. For each project's root, if `path` sits under
|
|
97
|
+
* `root/`, the CAPABILITY is the first path segment after the matched root; the node is
|
|
98
|
+
* `project/capability`. Matches the LONGEST matching root prefix across every project + root (so a
|
|
99
|
+
* deeper root like `plugins/sdd/skills` wins over a shallower `plugins/sdd`, when both are
|
|
100
|
+
* declared). Returns null when no root matches — the file is unmapped.
|
|
101
|
+
*/
|
|
102
|
+
export function fileToNode(path: string, projects: ProjectLayout[]): string | null {
|
|
103
|
+
let best: { project: string; capability: string; rootLen: number } | null = null
|
|
104
|
+
for (const p of projects) {
|
|
105
|
+
for (const rawRoot of p.roots) {
|
|
106
|
+
const root = stripTrailingSlash(rawRoot)
|
|
107
|
+
const prefix = root === '' ? '' : `${root}/`
|
|
108
|
+
if (prefix === '' || !path.startsWith(prefix)) continue
|
|
109
|
+
const rest = path.slice(prefix.length)
|
|
110
|
+
const capability = rest.split('/')[0]
|
|
111
|
+
if (!capability) continue
|
|
112
|
+
if (best === null || prefix.length > best.rootLen) {
|
|
113
|
+
best = { project: p.project, capability, rootLen: prefix.length }
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
return best ? `${best.project}/${best.capability}` : null
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
function sortedUnique(values: readonly string[]): string[] {
|
|
121
|
+
return [...new Set(values)].sort()
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* reconcile — the declared-vs-actual three-way split: confirmed = declared ∩ actual, missed =
|
|
126
|
+
* actual − declared, overDeclared = declared − actual. The corrected touch-set is simply the
|
|
127
|
+
* actual set — the real change is the ground truth. Every list de-duplicated and sorted, so a
|
|
128
|
+
* fixed input always reconciles to the same answer in the same order.
|
|
129
|
+
*/
|
|
130
|
+
export function reconcile(declared: string[], actual: string[]): Reconciliation {
|
|
131
|
+
const declaredSet = new Set(declared)
|
|
132
|
+
const actualSet = new Set(actual)
|
|
133
|
+
return {
|
|
134
|
+
confirmed: sortedUnique(declared.filter((d) => actualSet.has(d))),
|
|
135
|
+
missed: sortedUnique(actual.filter((a) => !declaredSet.has(a))),
|
|
136
|
+
overDeclared: sortedUnique(declared.filter((d) => !actualSet.has(d))),
|
|
137
|
+
corrected: sortedUnique(actual),
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/**
|
|
142
|
+
* assembleCorrection — the whole pure assembly. Builds a FileEntry per changed file (node via
|
|
143
|
+
* fileToNode; changedScenarios gated by isFeature — belt-and-suspenders: a non-.feature never
|
|
144
|
+
* records scenario detail even if fed some), collects the unmapped paths and the actual (non-null,
|
|
145
|
+
* de-duplicated) node set, groups per-node file + scenario detail, and reconciles declared against
|
|
146
|
+
* actual. Deterministic + stably ordered for fixed inputs — no fs/network access of its own.
|
|
147
|
+
*/
|
|
148
|
+
export function assembleCorrection(declared: string[], files: ChangedFile[], projects: ProjectLayout[]): Correction {
|
|
149
|
+
const entries: FileEntry[] = files.map((f) => ({
|
|
150
|
+
path: f.path,
|
|
151
|
+
node: fileToNode(f.path, projects),
|
|
152
|
+
artifactType: f.artifactType,
|
|
153
|
+
changedScenarios: isFeature(f.path) ? f.changedScenarios : [],
|
|
154
|
+
}))
|
|
155
|
+
|
|
156
|
+
const unmapped = sortedUnique(entries.filter((e) => e.node === null).map((e) => e.path))
|
|
157
|
+
const actual = sortedUnique(
|
|
158
|
+
entries.filter((e): e is FileEntry & { node: string } => e.node !== null).map((e) => e.node),
|
|
159
|
+
)
|
|
160
|
+
|
|
161
|
+
const nodes: NodeDetail[] = actual.map((node) => {
|
|
162
|
+
const nodeEntries = entries.filter((e) => e.node === node)
|
|
163
|
+
// One NodeFile per distinct path (first artifact-type wins on the rare duplicate path), sorted
|
|
164
|
+
// by path — so the per-file artifact-type rides through into the returned record, deterministically.
|
|
165
|
+
const byPath = new Map<string, string>()
|
|
166
|
+
for (const e of nodeEntries) if (!byPath.has(e.path)) byPath.set(e.path, e.artifactType)
|
|
167
|
+
const files: NodeFile[] = [...byPath.entries()]
|
|
168
|
+
.map(([path, artifactType]) => ({ path, artifactType }))
|
|
169
|
+
.sort((a, b) => (a.path < b.path ? -1 : a.path > b.path ? 1 : 0))
|
|
170
|
+
return {
|
|
171
|
+
node,
|
|
172
|
+
files,
|
|
173
|
+
changedScenarios: sortedUnique(nodeEntries.flatMap((e) => e.changedScenarios)),
|
|
174
|
+
}
|
|
175
|
+
})
|
|
176
|
+
|
|
177
|
+
return { ...reconcile(declared, actual), nodes, unmapped }
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
// ── IO seam (thin — network/binary/fs boundary; not unit-tested, mirrors classify-edit-class.mts) ──
|
|
181
|
+
|
|
182
|
+
/** Reads the changed-file list of `base..head` via `git diff --name-status`. A rename records its
|
|
183
|
+
* NEW path only. On any git failure (absent binary, bad refs, not a repo) returns []. */
|
|
184
|
+
export function readChangedFiles(base: string, head: string, cwd: string): { path: string; status: string }[] {
|
|
185
|
+
let out: string
|
|
186
|
+
try {
|
|
187
|
+
out = execFileSync('git', ['diff', '--name-status', `${base}..${head}`], {
|
|
188
|
+
encoding: 'utf8',
|
|
189
|
+
cwd,
|
|
190
|
+
stdio: ['ignore', 'pipe', 'ignore'],
|
|
191
|
+
})
|
|
192
|
+
} catch {
|
|
193
|
+
return []
|
|
194
|
+
}
|
|
195
|
+
const results: { path: string; status: string }[] = []
|
|
196
|
+
for (const line of out.split('\n')) {
|
|
197
|
+
if (line.trim() === '') continue
|
|
198
|
+
const rename = /^R\d+\t([^\t]+)\t([^\t]+)$/.exec(line)
|
|
199
|
+
if (rename) {
|
|
200
|
+
results.push({ path: rename[2], status: 'R' })
|
|
201
|
+
continue
|
|
202
|
+
}
|
|
203
|
+
const m = /^(\S+)\t(.+)$/.exec(line)
|
|
204
|
+
if (m) results.push({ path: m[2], status: m[1] })
|
|
205
|
+
}
|
|
206
|
+
return results
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
const HERE = dirname(fileURLToPath(import.meta.url))
|
|
210
|
+
const RESOLVE_GOVERNANCES_PATH = join(HERE, '..', '..', 'resolve-governances', 'scripts', 'resolve-governances.mts')
|
|
211
|
+
|
|
212
|
+
/** Resolves one file's artifact-type via `resolve-governances --path` (best-effort — its own
|
|
213
|
+
* no-match "classify by convention" case). On ANY failure (absent tool, bad JSON, non-zero exit)
|
|
214
|
+
* returns 'unknown' — never throws. */
|
|
215
|
+
export function resolveArtifactType(path: string, root: string, cwd: string): string {
|
|
216
|
+
try {
|
|
217
|
+
const out = execFileSync('node', [RESOLVE_GOVERNANCES_PATH, '--root', root, '--path', path, '--format', 'json'], {
|
|
218
|
+
encoding: 'utf8',
|
|
219
|
+
cwd,
|
|
220
|
+
stdio: ['ignore', 'pipe', 'ignore'],
|
|
221
|
+
})
|
|
222
|
+
const parsed = JSON.parse(out) as { artifactType?: string | null }
|
|
223
|
+
return parsed.artifactType ?? 'unknown'
|
|
224
|
+
} catch {
|
|
225
|
+
return 'unknown'
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
// `diffFeatures`'s default reader resolves paths against `process.cwd()` and derives git's own cwd
|
|
230
|
+
// from that resolved location (`git ls-files --full-name` to recover the repo-relative path) —
|
|
231
|
+
// this reader is that same algorithm, re-pointed at `cwd` (same seam classify-edit-class.mts
|
|
232
|
+
// uses), so it stays robust to a caller whose relative-path bookkeeping doesn't line up with its
|
|
233
|
+
// own `cwd`. Any failure (bad ref, unreadable file) surfaces as `undefined` text, which the differ
|
|
234
|
+
// reads as "absent" — the outer `changedScenarios` catch-all is this call site's real fail-open
|
|
235
|
+
// boundary, so a thrown `GitError` here is caught there rather than escalated.
|
|
236
|
+
const cwdReader =
|
|
237
|
+
(cwd: string): DiffReader =>
|
|
238
|
+
(file, base) => {
|
|
239
|
+
const abs = resolve(cwd, file)
|
|
240
|
+
const dir = dirname(abs)
|
|
241
|
+
let head: string | undefined
|
|
242
|
+
try {
|
|
243
|
+
head = readFileSync(abs, 'utf8')
|
|
244
|
+
} catch {
|
|
245
|
+
head = undefined
|
|
246
|
+
}
|
|
247
|
+
const gitIo = {
|
|
248
|
+
cwd: dir,
|
|
249
|
+
encoding: 'utf8' as const,
|
|
250
|
+
stdio: ['ignore', 'pipe', 'ignore'] as ['ignore', 'pipe', 'ignore'],
|
|
251
|
+
}
|
|
252
|
+
let rel: string
|
|
253
|
+
try {
|
|
254
|
+
rel = execFileSync('git', ['ls-files', '--full-name', '--', abs], gitIo).trim()
|
|
255
|
+
} catch {
|
|
256
|
+
return { head, base: undefined }
|
|
257
|
+
}
|
|
258
|
+
if (rel === '') {
|
|
259
|
+
try {
|
|
260
|
+
const top = execFileSync('git', ['rev-parse', '--show-toplevel'], gitIo).trim()
|
|
261
|
+
rel = relative(top, abs).split(sep).join('/')
|
|
262
|
+
} catch {
|
|
263
|
+
return { head, base: undefined }
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
let baseText: string | undefined
|
|
267
|
+
try {
|
|
268
|
+
baseText = execFileSync('git', ['show', `${base}:${rel}`], gitIo)
|
|
269
|
+
} catch {
|
|
270
|
+
baseText = undefined
|
|
271
|
+
}
|
|
272
|
+
return { head, base: baseText }
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
/** The changed scenario names of a touched `.feature`, via the pinned `gherkin-cli@0.0.2`
|
|
276
|
+
* `diffFeatures` (same tool classify-edit-class.mts uses — never a reimplemented differ). Gated
|
|
277
|
+
* by isFeature — a non-.feature never calls out. On any failure returns []. Reads any `.feature`
|
|
278
|
+
* regardless of freeze — the freeze gate is a separate concern (spec-gate), not this tool's
|
|
279
|
+
* business. */
|
|
280
|
+
export function changedScenarios(base: string, path: string, cwd: string): string[] {
|
|
281
|
+
if (!isFeature(path)) return []
|
|
282
|
+
try {
|
|
283
|
+
const { files } = diffFeatures([path], { base, reader: cwdReader(cwd) })
|
|
284
|
+
const fileResult = files.find((f) => f.file === path) ?? files[0]
|
|
285
|
+
return (fileResult?.scenarios ?? []).filter((s) => s.change !== 'unchanged').map((s) => s.name)
|
|
286
|
+
} catch {
|
|
287
|
+
return []
|
|
288
|
+
}
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
/** Composes the three IO calls per changed file into the ChangedFile list `assembleCorrection`
|
|
292
|
+
* consumes. */
|
|
293
|
+
export function collectChangedFiles(base: string, head: string, root: string, cwd: string): ChangedFile[] {
|
|
294
|
+
const changed = readChangedFiles(base, head, cwd)
|
|
295
|
+
return changed.map(({ path }) => ({
|
|
296
|
+
path,
|
|
297
|
+
artifactType: resolveArtifactType(path, root, cwd),
|
|
298
|
+
changedScenarios: changedScenarios(base, path, cwd),
|
|
299
|
+
}))
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
const DISCOVER_SPECS_PATH = join(HERE, '..', '..', 'discover-specs', 'scripts', 'discover-specs.mts')
|
|
303
|
+
|
|
304
|
+
interface DiscoveredSpec {
|
|
305
|
+
path: string
|
|
306
|
+
name: string
|
|
307
|
+
projectPath: string
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
/** Best-effort project-layout discovery via `discover-specs`: each project's spec-path plus an
|
|
311
|
+
* impl-root convention (`plugins/<p>` → also `plugins/<p>/skills`; `packages/<p>` → also
|
|
312
|
+
* `packages/<p>/src`; else the project-path itself). `--layout` (CLI) overrides this entirely. On
|
|
313
|
+
* any failure returns []. */
|
|
314
|
+
export function discoverLayouts(root: string, cwd: string): ProjectLayout[] {
|
|
315
|
+
try {
|
|
316
|
+
const out = execFileSync('node', [DISCOVER_SPECS_PATH, '--root', root, '--format', 'json'], {
|
|
317
|
+
encoding: 'utf8',
|
|
318
|
+
cwd,
|
|
319
|
+
stdio: ['ignore', 'pipe', 'ignore'],
|
|
320
|
+
})
|
|
321
|
+
const specs = JSON.parse(out) as DiscoveredSpec[]
|
|
322
|
+
return specs.map((s) => {
|
|
323
|
+
const roots = [s.path]
|
|
324
|
+
const projectPath = s.projectPath ?? ''
|
|
325
|
+
const pluginsMatch = /^plugins\/([^/]+)$/.exec(projectPath)
|
|
326
|
+
const packagesMatch = /^packages\/([^/]+)$/.exec(projectPath)
|
|
327
|
+
if (pluginsMatch) roots.push(`plugins/${pluginsMatch[1]}/skills`)
|
|
328
|
+
else if (packagesMatch) roots.push(`packages/${packagesMatch[1]}/src`)
|
|
329
|
+
else if (projectPath) roots.push(projectPath)
|
|
330
|
+
return { project: s.name, roots }
|
|
331
|
+
})
|
|
332
|
+
} catch {
|
|
333
|
+
return []
|
|
334
|
+
}
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
// ── Render (TOON — the token-efficient tabular form the repo's other sdd engines emit) ──
|
|
338
|
+
|
|
339
|
+
function toonQuote(v: string): string {
|
|
340
|
+
if (v === '' || /[",;]/.test(v) || v !== v.trim()) return `"${v.replace(/"/g, '""')}"`
|
|
341
|
+
return v
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
export function renderCorrectionToon(correction: Correction): string {
|
|
345
|
+
const lines: string[] = []
|
|
346
|
+
lines.push(`corrected[${correction.corrected.length}]: ${correction.corrected.map(toonQuote).join(';')}`)
|
|
347
|
+
lines.push(`confirmed[${correction.confirmed.length}]: ${correction.confirmed.map(toonQuote).join(';')}`)
|
|
348
|
+
lines.push(`missed[${correction.missed.length}]: ${correction.missed.map(toonQuote).join(';')}`)
|
|
349
|
+
lines.push(`overDeclared[${correction.overDeclared.length}]: ${correction.overDeclared.map(toonQuote).join(';')}`)
|
|
350
|
+
lines.push(`nodes[${correction.nodes.length}]{node,files,changedScenarios}:`)
|
|
351
|
+
for (const n of correction.nodes) {
|
|
352
|
+
const files = n.files.map((f) => `${f.path}(${f.artifactType})`).join(';')
|
|
353
|
+
lines.push(` ${toonQuote(n.node)},"${files}","${n.changedScenarios.join(';')}"`)
|
|
354
|
+
}
|
|
355
|
+
lines.push(`unmapped[${correction.unmapped.length}]: ${correction.unmapped.map(toonQuote).join(';')}`)
|
|
356
|
+
return lines.join('\n')
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
// ── CLI ──
|
|
360
|
+
|
|
361
|
+
function flag(argv: string[], name: string): string | undefined {
|
|
362
|
+
const i = argv.indexOf(name)
|
|
363
|
+
return i === -1 ? undefined : argv[i + 1]
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
function allFlags(argv: string[], name: string): string[] {
|
|
367
|
+
const out: string[] = []
|
|
368
|
+
for (let i = 0; i < argv.length; i++) {
|
|
369
|
+
if (argv[i] === name && argv[i + 1] !== undefined) out.push(argv[i + 1])
|
|
370
|
+
}
|
|
371
|
+
return out
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
function splitCsv(v: string | undefined): string[] {
|
|
375
|
+
if (v === undefined || v === '') return []
|
|
376
|
+
return v
|
|
377
|
+
.split(',')
|
|
378
|
+
.map((s) => s.trim())
|
|
379
|
+
.filter((s) => s.length > 0)
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
/** Parses one `--layout '<project>:<root1>,<root2>'` flag value into a ProjectLayout. */
|
|
383
|
+
export function parseLayoutFlag(value: string): ProjectLayout | null {
|
|
384
|
+
const idx = value.indexOf(':')
|
|
385
|
+
if (idx === -1) return null
|
|
386
|
+
const project = value.slice(0, idx).trim()
|
|
387
|
+
const roots = splitCsv(value.slice(idx + 1))
|
|
388
|
+
if (project === '' || roots.length === 0) return null
|
|
389
|
+
return { project, roots }
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
export function main(argv: string[]): number {
|
|
393
|
+
const base = flag(argv, '--base')
|
|
394
|
+
if (base === undefined) {
|
|
395
|
+
process.stderr.write('touch-set-correction: --base <ref> is required\n')
|
|
396
|
+
return 1
|
|
397
|
+
}
|
|
398
|
+
const head = flag(argv, '--head') ?? 'HEAD'
|
|
399
|
+
const root = flag(argv, '--root') ?? '.'
|
|
400
|
+
const declared = splitCsv(flag(argv, '--declared'))
|
|
401
|
+
const format = flag(argv, '--format') === 'json' ? 'json' : 'toon'
|
|
402
|
+
|
|
403
|
+
const layoutFlags = allFlags(argv, '--layout')
|
|
404
|
+
const layouts =
|
|
405
|
+
layoutFlags.length > 0
|
|
406
|
+
? layoutFlags.map(parseLayoutFlag).filter((l): l is ProjectLayout => l !== null)
|
|
407
|
+
: discoverLayouts(root, root)
|
|
408
|
+
|
|
409
|
+
const files = collectChangedFiles(base, head, root, root)
|
|
410
|
+
const correction = assembleCorrection(declared, files, layouts)
|
|
411
|
+
|
|
412
|
+
process.stdout.write(
|
|
413
|
+
`${format === 'json' ? JSON.stringify(correction, null, 2) : renderCorrectionToon(correction)}\n`,
|
|
414
|
+
)
|
|
415
|
+
return 0
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
if (import.meta.main) process.exit(main(process.argv.slice(2)))
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
# verify-scenarios
|
|
2
|
+
|
|
3
|
+
The concrete engine for the SDD **scenario-bridge** — a language/runner-agnostic bridge from a
|
|
4
|
+
frozen `.feature` scenario to the test that proves it, so an impl-judge runs the project's own test
|
|
5
|
+
suite and reads a report instead of re-verifying every scenario by hand. A non-user-invocable
|
|
6
|
+
skill, invoked at the impl gate for deterministic artifact-types, carrying a self-contained `.mts`
|
|
7
|
+
script that unions one or more junit (today) result sources against the scenario key set.
|
|
8
|
+
|
|
9
|
+
- **Skill contract:** [`SKILL.md`](./SKILL.md)
|
|
10
|
+
- **Script:** [`scripts/verify-scenarios.mts`](./scripts/verify-scenarios.mts)
|
|
11
|
+
- **Tests:** [`scripts/verify-scenarios.test.mts`](./scripts/verify-scenarios.test.mts) (`node:test`)
|
|
12
|
+
|
|
13
|
+
```bash
|
|
14
|
+
node scripts/verify-scenarios.mts --feature .agents/spec/identity/identity.feature --node cyberlegion/identity
|
|
15
|
+
node scripts/verify-scenarios.mts --feature x.feature --node proj/x --run --config .agents/sdd/scenario-bridge.toml
|
|
16
|
+
node scripts/verify-scenarios.mts --feature x.feature --node proj/x --report .agents/.scenario-report.xml --format json
|
|
17
|
+
```
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: verify-scenarios
|
|
3
|
+
description: "Partial Skill: invoke by name only — the Gherkin-scenario-to-test-report bridge verifier — invoked by the impl-judge at the impl gate, not triggered by users directly."
|
|
4
|
+
user-invocable: false
|
|
5
|
+
metadata:
|
|
6
|
+
internal: true
|
|
7
|
+
---
|
|
8
|
+
|
|
9
|
+
# Verify Scenarios
|
|
10
|
+
|
|
11
|
+
The concrete engine for the SDD **scenario-bridge** — a language/runner-agnostic bridge from a
|
|
12
|
+
frozen `.feature` scenario to the test that proves it, so the impl-judge runs the project's own
|
|
13
|
+
test suite and reads a report instead of re-verifying every scenario by hand. Deterministic
|
|
14
|
+
test-running is the SDD **default** verification path (not a plugin specialty — an unmatched
|
|
15
|
+
artifact-type already falls through to SDD defaults).
|
|
16
|
+
|
|
17
|
+
## Binding convention
|
|
18
|
+
|
|
19
|
+
- **Key = the scenario's `@id:<slug>` tag if present, else its verbatim name.** No synthetic ID
|
|
20
|
+
registry — node-path + name is globally unique in the repo.
|
|
21
|
+
- **A test declares its node** with a `describe('spec:<node>', …)` wrapper (or the equivalent in a
|
|
22
|
+
non-JS runner) around its cases. The node segment is found at **any depth** in the test-report's
|
|
23
|
+
`" > "`-joined name, so nesting the wrapper deeper never breaks the bind.
|
|
24
|
+
- **A test's leaf title is the exact scenario name**, pasted verbatim from the frozen `.feature` —
|
|
25
|
+
or `@id:<slug>` when the scenario carries that tag.
|
|
26
|
+
- **A Scenario Outline is ONE key** (its outline name). Give an `it.each`/table-driven leaf a
|
|
27
|
+
**static** title equal to the outline name (no per-row interpolation) — every row folds into the
|
|
28
|
+
same key; a failing row fails the whole key.
|
|
29
|
+
- **Many-to-one is fine.** Two tests can bind the same key; the fold is PASS only if none of them
|
|
30
|
+
fail. A test that maps to an already-covered key and isn't the canonical rename shows up as an
|
|
31
|
+
EXTRA (diagnostic, not a failure) — leave it.
|
|
32
|
+
|
|
33
|
+
## Config schema
|
|
34
|
+
|
|
35
|
+
`.agents/sdd/scenario-bridge.toml`, resolved beneath `--root` (**not** a single repo-root path — see
|
|
36
|
+
"Monorepo rooting" below) — an array-of-tables of result **sources** (a `.feature` is not assumed to
|
|
37
|
+
map to one runner: vitest + playwright + pytest can all contribute to one node):
|
|
38
|
+
|
|
39
|
+
```toml
|
|
40
|
+
[[source]]
|
|
41
|
+
adapter = "junit"
|
|
42
|
+
command = "pnpm build && vitest run src --reporter=junit --outputFile=.agents/.scenario-report.xml"
|
|
43
|
+
reportPath = ".agents/.scenario-report.xml"
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
- `adapter` — `junit` today; `tap`/`aced` are a `switch` case away, same interface.
|
|
47
|
+
- `command` — optional; run only with `--run` (omit to just read an already-produced report).
|
|
48
|
+
- `reportPath` — resolved relative to `--root`.
|
|
49
|
+
|
|
50
|
+
Add `.agents/.scenario-report.xml` (or wherever `reportPath` points) to the project's `.gitignore`.
|
|
51
|
+
|
|
52
|
+
## JUnit adapter mechanics
|
|
53
|
+
|
|
54
|
+
Hand-rolled regex parse, no xml dependency — verified against vitest 4.1.7's JUnit reporter:
|
|
55
|
+
|
|
56
|
+
- One `<testsuite>` per test file; `classname` = file path; `name` = describe-chain joined by
|
|
57
|
+
`" > "` + leaf title.
|
|
58
|
+
- `classname`/`name` are extracted **by attribute name**, not position, and both **unescaped**
|
|
59
|
+
(`& " ' < >`) — an unescaped apostrophe silently drops a scenario like
|
|
60
|
+
`whoami prints this session's own identity`.
|
|
61
|
+
- Outcome: a child `<failure` -> fail; a child `<skipped` -> skip; otherwise pass.
|
|
62
|
+
- The `name` is split on `" > "`; the **node** is the capture of whichever segment matches
|
|
63
|
+
`/^spec:(.+)$/` (any depth); a testcase with no such segment is dropped — it is not bound to any
|
|
64
|
+
node. The **leaf** is the last segment; the **key** is its `@id:<slug>` capture if it matches,
|
|
65
|
+
else the leaf verbatim.
|
|
66
|
+
|
|
67
|
+
## Run
|
|
68
|
+
|
|
69
|
+
```bash
|
|
70
|
+
node "<skill>/scripts/verify-scenarios.mts" \
|
|
71
|
+
--feature <path/to/x.feature> --node <project>/<node> \
|
|
72
|
+
[--config .agents/sdd/scenario-bridge.toml] [--root .] [--feature-root <dir>] \
|
|
73
|
+
[--report <xml>] [--run] [--format toon|json]
|
|
74
|
+
```
|
|
75
|
+
|
|
76
|
+
- `--report <xml>` bypasses `--config` entirely — a single ad-hoc junit source, no command.
|
|
77
|
+
- `--run` executes each source's `command` first; without it, existing reports are read as-is.
|
|
78
|
+
- Default output is a readable per-scenario table + a `N/M BOUND, P pass, F fail, U unbound`
|
|
79
|
+
summary line + any EXTRA keys. `--format json` emits
|
|
80
|
+
`{node,total,bound,pass,fail,unbound,scenarios[],extras[]}`. `--format toon` emits the repo's
|
|
81
|
+
TOON tabular form.
|
|
82
|
+
- Exit code is non-zero when any scenario is UNBOUND or FAIL; zero only at full BOUND+PASS.
|
|
83
|
+
|
|
84
|
+
## Monorepo rooting — `--feature-root` vs. `--root`
|
|
85
|
+
|
|
86
|
+
`--root` is the **bridge/report root** — where `--config` defaults to, and where every source's
|
|
87
|
+
`reportPath` and an ad-hoc `--report` resolve. `--feature-root` is where `--feature` resolves; it
|
|
88
|
+
**defaults to `--root`** when omitted, so a colocated project (spec, config, and report all under one
|
|
89
|
+
root) needs only `--root` — unchanged from before this option existed. Pass `--feature-root`
|
|
90
|
+
separately when the frozen `.feature` lives at a **different** root than the project's config +
|
|
91
|
+
report — the common monorepo shape, where specs sit at a repo-root spec corpus
|
|
92
|
+
(`.agents/specs/<project>/`) but the project's own `.agents/sdd/scenario-bridge.toml` and test report
|
|
93
|
+
sit under its `project-path` (e.g. `packages/<project>/`):
|
|
94
|
+
|
|
95
|
+
```bash
|
|
96
|
+
node "<skill>/scripts/verify-scenarios.mts" \
|
|
97
|
+
--feature .agents/specs/cyberlegion-plugin/identity/identity.feature \
|
|
98
|
+
--node cyberlegion-plugin/identity \
|
|
99
|
+
--feature-root . \
|
|
100
|
+
--root packages/cyberlegion
|
|
101
|
+
```
|
|
102
|
+
|
|
103
|
+
## Boundaries
|
|
104
|
+
|
|
105
|
+
Read-only over the `.feature` and test reports; writes nothing. Consumes `gherkin-cli` (via `npx
|
|
106
|
+
gherkin-cli@0.0.2 parse <feature> --format json`) for the scenario set — never re-implements a
|
|
107
|
+
Gherkin parser. Does not reorganize `.feature` files by runner and does not wire itself into the
|
|
108
|
+
impl-judge (a separate CR grows the default `sdd-impl-judge` to call this for deterministic
|
|
109
|
+
artifact-types, run through SDD's own spec gate).
|