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,249 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// check-scenario-overlap — project-spec/scenario-overlap's concrete engine. Audits ACROSS the nodes
|
|
3
|
+
// of one project spec and surfaces where the same behavior lives in more than one node's `.feature`:
|
|
4
|
+
// the intra-project spec-level SSA partner of the code-side collision ladder, and the cross-node
|
|
5
|
+
// axis check-spec-structure (intra-node node-shape) leaves uncovered (see this skill's README.md).
|
|
6
|
+
//
|
|
7
|
+
// Two deterministic candidate kinds, each with a severity:
|
|
8
|
+
// - exact-duplicate (blocking) — two DISTINCT nodes whose suites each carry a scenario with an
|
|
9
|
+
// identical normalized step fingerprint. `--check` fails on it.
|
|
10
|
+
// - title-overlap (advisory) — two distinct nodes sharing a normalized scenario title but with
|
|
11
|
+
// DIFFERING step fingerprints. A weaker hint; never fails `--check`.
|
|
12
|
+
// Confirming real behavioral overlap and assigning the owning node is Warden judgment (the @rubric
|
|
13
|
+
// scenario) — no engine code here.
|
|
14
|
+
//
|
|
15
|
+
// The fingerprint is computed from step BODIES only (title/tags/comments/prose never reach it), so
|
|
16
|
+
// the signal is behavior-shaped, not cosmetic. For a `Scenario Outline` the steps are a template —
|
|
17
|
+
// every canonical `@trigger` outline shares byte-identical steps by construction — so the outline's
|
|
18
|
+
// `Examples` table (header + rows, normalized) is folded into the fingerprint too: two outlines are
|
|
19
|
+
// an exact-duplicate only when their steps AND their rows match. Title and tags stay excluded from
|
|
20
|
+
// both — a plain `Scenario` and an outline's Examples rows are the only places distinguishing
|
|
21
|
+
// content can live. Detection is cross-node only: a within-node duplicate and a once-corpus-wide
|
|
22
|
+
// scenario both raise nothing. Pure derivation, writes nothing, no deps (the repo's node->=23.6 /
|
|
23
|
+
// no-deps convention). Pure functions are exported for node:test; running the file directly drives
|
|
24
|
+
// the CLI.
|
|
25
|
+
|
|
26
|
+
import { readdirSync, readFileSync } from 'node:fs'
|
|
27
|
+
import { join } from 'node:path'
|
|
28
|
+
|
|
29
|
+
const SKIP_DIRS = new Set(['node_modules', '.git', 'dist', '.turbo', '.next', 'coverage'])
|
|
30
|
+
|
|
31
|
+
export type CandidateKind = 'exact-duplicate' | 'title-overlap'
|
|
32
|
+
export type Severity = 'blocking' | 'advisory'
|
|
33
|
+
|
|
34
|
+
export interface Scenario {
|
|
35
|
+
title: string
|
|
36
|
+
/** Normalized ordered step bodies, plus an outline's Examples rows — the behavior fingerprint. */
|
|
37
|
+
fingerprint: string
|
|
38
|
+
/** Normalized title — the weaker signal. */
|
|
39
|
+
titleKey: string
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export interface NodeSuite {
|
|
43
|
+
/** Display form of the owning node — its folder path. */
|
|
44
|
+
node: string
|
|
45
|
+
scenarios: Scenario[]
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export interface Candidate {
|
|
49
|
+
kind: CandidateKind
|
|
50
|
+
severity: Severity
|
|
51
|
+
nodes: [string, string]
|
|
52
|
+
scenario: string
|
|
53
|
+
detail: string
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
// ── Normalization ──
|
|
57
|
+
export function normalize(s: string): string {
|
|
58
|
+
return s.trim().replace(/\s+/g, ' ').toLowerCase()
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
// ── Parse a .feature into its scenarios (steps + an outline's Examples rows reach the fingerprint) ──
|
|
62
|
+
const STEP = /^\s*(Given|When|Then|And|But)\s+(.*\S)\s*$/
|
|
63
|
+
const SCENARIO = /^\s*Scenario(?: Outline)?:\s*(.*\S)\s*$/
|
|
64
|
+
const TABLE_ROW = /^\s*\|(.*)\|\s*$/
|
|
65
|
+
|
|
66
|
+
// Normalize one `| cell | cell |` row cell-by-cell so a row survives reordered
|
|
67
|
+
// whitespace inside a cell but not a reordered or differing cell.
|
|
68
|
+
function normalizeRow(line: string): string {
|
|
69
|
+
const inner = TABLE_ROW.exec(line)?.[1] ?? line
|
|
70
|
+
return inner
|
|
71
|
+
.split('|')
|
|
72
|
+
.map((cell) => normalize(cell))
|
|
73
|
+
.join('|')
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
export function parseFeature(featureText: string): Scenario[] {
|
|
77
|
+
const scenarios: Scenario[] = []
|
|
78
|
+
let title: string | null = null
|
|
79
|
+
let steps: string[] = []
|
|
80
|
+
let exampleRows: string[] = []
|
|
81
|
+
const flush = () => {
|
|
82
|
+
if (title !== null) {
|
|
83
|
+
const fingerprint =
|
|
84
|
+
exampleRows.length === 0
|
|
85
|
+
? steps.map(normalize).join('\n')
|
|
86
|
+
: `${steps.map(normalize).join('\n')}\n EXAMPLES \n${exampleRows.join('\n')}`
|
|
87
|
+
scenarios.push({ title, titleKey: normalize(title), fingerprint })
|
|
88
|
+
}
|
|
89
|
+
title = null
|
|
90
|
+
steps = []
|
|
91
|
+
exampleRows = []
|
|
92
|
+
}
|
|
93
|
+
for (const line of featureText.split('\n')) {
|
|
94
|
+
const sc = SCENARIO.exec(line)
|
|
95
|
+
if (sc) {
|
|
96
|
+
flush()
|
|
97
|
+
title = sc[1]
|
|
98
|
+
continue
|
|
99
|
+
}
|
|
100
|
+
if (title === null) continue
|
|
101
|
+
const st = STEP.exec(line)
|
|
102
|
+
if (st) {
|
|
103
|
+
steps.push(st[2])
|
|
104
|
+
continue
|
|
105
|
+
}
|
|
106
|
+
if (TABLE_ROW.test(line)) exampleRows.push(normalizeRow(line))
|
|
107
|
+
}
|
|
108
|
+
flush()
|
|
109
|
+
return scenarios
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
function displayPath(relPath: string): string {
|
|
113
|
+
const p = relPath.replace(/\\/g, '/')
|
|
114
|
+
return p.endsWith('/README.md') ? p.slice(0, -'README.md'.length) : p
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
// ── Scan the project-spec — one suite per node folder that carries a .feature ──
|
|
118
|
+
export function scanSuites(specDir: string): NodeSuite[] {
|
|
119
|
+
const suites: NodeSuite[] = []
|
|
120
|
+
walk(specDir, specDir, suites)
|
|
121
|
+
return suites.sort((a, b) => a.node.localeCompare(b.node))
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
function walk(dir: string, specDir: string, out: NodeSuite[]): void {
|
|
125
|
+
const entries = readdirSync(dir, { withFileTypes: true })
|
|
126
|
+
for (const entry of entries) {
|
|
127
|
+
if (entry.name.startsWith('.') || SKIP_DIRS.has(entry.name)) continue
|
|
128
|
+
const full = join(dir, entry.name)
|
|
129
|
+
if (entry.isDirectory()) {
|
|
130
|
+
walk(full, specDir, out)
|
|
131
|
+
continue
|
|
132
|
+
}
|
|
133
|
+
if (!entry.name.endsWith('.feature')) continue
|
|
134
|
+
const scenarios = parseFeature(readFileSync(full, 'utf8'))
|
|
135
|
+
if (scenarios.length === 0) continue
|
|
136
|
+
const relDir = dir.slice(specDir.length + 1).replace(/\\/g, '/')
|
|
137
|
+
out.push({ node: displayPath(`${relDir}/`), scenarios })
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
// ── Detect — cross-node only (distinct nodes), fingerprint then title ──
|
|
142
|
+
export function detect(suites: NodeSuite[]): Candidate[] {
|
|
143
|
+
// fingerprint -> (node -> first scenario title in that node)
|
|
144
|
+
const byFingerprint = new Map<string, Map<string, string>>()
|
|
145
|
+
// titleKey -> (node -> { fingerprints, display title })
|
|
146
|
+
const byTitle = new Map<string, Map<string, { fps: Set<string>; title: string }>>()
|
|
147
|
+
for (const suite of suites) {
|
|
148
|
+
for (const sc of suite.scenarios) {
|
|
149
|
+
if (sc.fingerprint !== '') {
|
|
150
|
+
let m = byFingerprint.get(sc.fingerprint)
|
|
151
|
+
if (!m) byFingerprint.set(sc.fingerprint, (m = new Map()))
|
|
152
|
+
if (!m.has(suite.node)) m.set(suite.node, sc.title)
|
|
153
|
+
}
|
|
154
|
+
let t = byTitle.get(sc.titleKey)
|
|
155
|
+
if (!t) byTitle.set(sc.titleKey, (t = new Map()))
|
|
156
|
+
let e = t.get(suite.node)
|
|
157
|
+
if (!e) t.set(suite.node, (e = { fps: new Set(), title: sc.title }))
|
|
158
|
+
e.fps.add(sc.fingerprint)
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
const candidates: Candidate[] = []
|
|
162
|
+
// exact-duplicate: same fingerprint across >=2 distinct nodes
|
|
163
|
+
for (const [, nodeMap] of byFingerprint) {
|
|
164
|
+
if (nodeMap.size < 2) continue
|
|
165
|
+
const nodes = [...nodeMap.keys()].sort()
|
|
166
|
+
for (let i = 0; i < nodes.length; i++) {
|
|
167
|
+
for (let j = i + 1; j < nodes.length; j++) {
|
|
168
|
+
candidates.push({
|
|
169
|
+
kind: 'exact-duplicate',
|
|
170
|
+
severity: 'blocking',
|
|
171
|
+
nodes: [nodes[i], nodes[j]],
|
|
172
|
+
scenario: nodeMap.get(nodes[i]) ?? '',
|
|
173
|
+
detail:
|
|
174
|
+
'identical step fingerprint — the same behavior is specified in both nodes; one behavior = one owning node',
|
|
175
|
+
})
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
// title-overlap: same normalized title across >=2 distinct nodes with DIFFERING fingerprints
|
|
180
|
+
for (const [, nodeMap] of byTitle) {
|
|
181
|
+
if (nodeMap.size < 2) continue
|
|
182
|
+
const nodes = [...nodeMap.keys()].sort()
|
|
183
|
+
for (let i = 0; i < nodes.length; i++) {
|
|
184
|
+
for (let j = i + 1; j < nodes.length; j++) {
|
|
185
|
+
const a = nodeMap.get(nodes[i])
|
|
186
|
+
const b = nodeMap.get(nodes[j])
|
|
187
|
+
if (!a || !b) continue
|
|
188
|
+
const shareFp = [...a.fps].some((fp) => b.fps.has(fp))
|
|
189
|
+
if (shareFp) continue // this title pair is already an exact-duplicate
|
|
190
|
+
candidates.push({
|
|
191
|
+
kind: 'title-overlap',
|
|
192
|
+
severity: 'advisory',
|
|
193
|
+
nodes: [nodes[i], nodes[j]],
|
|
194
|
+
scenario: a.title,
|
|
195
|
+
detail: 'same scenario title, differing steps — a weaker overlap hint the Warden judges',
|
|
196
|
+
})
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
return candidates
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
export function hasBlocking(candidates: Candidate[]): boolean {
|
|
204
|
+
return candidates.some((c) => c.severity === 'blocking')
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
// ── Render ──
|
|
208
|
+
export function renderCandidates(candidates: Candidate[], specDir: string): string {
|
|
209
|
+
const blocking = candidates.filter((c) => c.severity === 'blocking')
|
|
210
|
+
const advisory = candidates.filter((c) => c.severity === 'advisory')
|
|
211
|
+
const lines: string[] = [`check-scenario-overlap: spec-dir=${specDir}`]
|
|
212
|
+
lines.push(`blocking[${blocking.length}]:`)
|
|
213
|
+
for (const c of blocking) lines.push(` ${c.nodes[0]} <-> ${c.nodes[1]} — ${c.kind}: "${c.scenario}" — ${c.detail}`)
|
|
214
|
+
lines.push(`advisory[${advisory.length}]:`)
|
|
215
|
+
for (const c of advisory) lines.push(` ${c.nodes[0]} <-> ${c.nodes[1]} — ${c.kind}: "${c.scenario}" — ${c.detail}`)
|
|
216
|
+
lines.push('note: advisory — candidates feed the Warden formation pass; the engine writes nothing')
|
|
217
|
+
return lines.join('\n')
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
// ── CLI ──
|
|
221
|
+
export function main(argv: string[]): number {
|
|
222
|
+
let specDir = '.'
|
|
223
|
+
let mode: 'audit' | 'check' = 'audit'
|
|
224
|
+
let format: 'toon' | 'json' = 'toon'
|
|
225
|
+
for (let i = 0; i < argv.length; i++) {
|
|
226
|
+
const a = argv[i]
|
|
227
|
+
if (a === '--spec-dir') specDir = argv[++i] ?? '.'
|
|
228
|
+
else if (a === '--check') mode = 'check'
|
|
229
|
+
else if (a === '--format') format = (argv[++i] as 'toon' | 'json') ?? 'toon'
|
|
230
|
+
}
|
|
231
|
+
const candidates = detect(scanSuites(specDir))
|
|
232
|
+
if (mode === 'check') {
|
|
233
|
+
if (hasBlocking(candidates)) {
|
|
234
|
+
process.stderr.write(
|
|
235
|
+
`check-scenario-overlap: ${candidates.filter((c) => c.severity === 'blocking').length} exact-duplicate candidate(s)\n`,
|
|
236
|
+
)
|
|
237
|
+
return 1
|
|
238
|
+
}
|
|
239
|
+
process.stdout.write('check-scenario-overlap: no exact-duplicate candidates\n')
|
|
240
|
+
return 0
|
|
241
|
+
}
|
|
242
|
+
if (format === 'json') process.stdout.write(`${JSON.stringify(candidates)}\n`)
|
|
243
|
+
else process.stdout.write(`${renderCandidates(candidates, specDir)}\n`)
|
|
244
|
+
return 0
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
if (import.meta.url === `file://${process.argv[1]}`) {
|
|
248
|
+
process.exit(main(process.argv.slice(2)))
|
|
249
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
# check-spec-structure
|
|
2
|
+
|
|
3
|
+
Internal SDD skill — the concrete engine for **spec-structure checking**. Audits one project spec's
|
|
4
|
+
internal node-shape and emits a finding set for the formation Warden: the intra-spec successor to the
|
|
5
|
+
retired cross-spec `dedupe-specs`/`split-spec` tools, now that one project is one spec.
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
node scripts/check-spec-structure.mts --spec-dir <spec> # audit (TOON finding set)
|
|
9
|
+
node scripts/check-spec-structure.mts --spec-dir <spec> --check # CI guard (fails on blocking)
|
|
10
|
+
```
|
|
11
|
+
|
|
12
|
+
Three deterministic checks — **untagged-node** (blocking: a spec-typed node with no `concept:`),
|
|
13
|
+
**oversized-node** (advisory: `.feature` over the granularity threshold), and **incomplete-node**
|
|
14
|
+
(advisory: a behavioral leaf spec missing one of the four required `spec.md` sections — `## What`,
|
|
15
|
+
`## Use Cases`, `## Control Flow`, `## Scenario map`) — plus an intra-spec contradiction arm judged
|
|
16
|
+
by the Warden. Read-only, frontmatter + scenario-count + section-heading only; writes nothing. See
|
|
17
|
+
[`SKILL.md`](./SKILL.md) for the full contract. Not user-invocable.
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: check-spec-structure
|
|
3
|
+
description: "Partial Skill: invoke by name only — project-spec/check-spec-structure's engine that audits a project spec's internal node-shape — feeds the formation Warden, not triggered by users directly."
|
|
4
|
+
user-invocable: false
|
|
5
|
+
metadata:
|
|
6
|
+
internal: true
|
|
7
|
+
---
|
|
8
|
+
|
|
9
|
+
# Check Spec Structure
|
|
10
|
+
|
|
11
|
+
The concrete engine for **spec-structure checking**.
|
|
12
|
+
It audits the **internal node-shape** of one project spec and returns a finding set for the
|
|
13
|
+
formation **Warden** — the **intra-spec** successor to the retired cross-spec `dedupe-specs`/`split-spec`
|
|
14
|
+
tools, now that one project is **one spec**. It carries a self-contained `.mts` script (the repo's
|
|
15
|
+
node-≥23.6 / no-deps convention). It is the **node-shape** sibling of `align-spec` (prose↔suite
|
|
16
|
+
alignment) and `concept-index` (the by-concept view).
|
|
17
|
+
|
|
18
|
+
## The three deterministic checks (and two judgment arms)
|
|
19
|
+
|
|
20
|
+
- **untagged-node** (blocking) — a spec-typed node README carrying no `concept:` tag, so it never
|
|
21
|
+
appears in the by-concept index (`concept-index`). `--check` fails on it.
|
|
22
|
+
- **oversized-node** (advisory) — a node whose sibling `.feature` scenario count exceeds the
|
|
23
|
+
granularity threshold. Carries a deterministic **shape profile** — plain scenario count, tagged
|
|
24
|
+
(`@rubric`-preceded) scenario count, and section-cluster count (comment headers of either
|
|
25
|
+
`# ── … ──` or `# ---- … ----` style) as a soft breadth hint — and prescribes **no route**.
|
|
26
|
+
Surfaced in the audit but **never** fails `--check`.
|
|
27
|
+
- **incomplete-node** (advisory) — a **behavioral leaf spec** (a README with `spec-type: behavioral`
|
|
28
|
+
and a colocated `.feature`) whose `spec.md` is missing one of the four required sections —
|
|
29
|
+
`## What`, `## Use Cases`, `## Control Flow`, `## Scenario map` (`sdd:spec-format-governance`). A
|
|
30
|
+
spec that stops at `## Use Cases` never draws its CFG or scenario map. Spec-type-aware: a reference
|
|
31
|
+
artifact (`## Subject`, no scenario map) and an index node (no colocated `.feature`) are **not**
|
|
32
|
+
held to the shape. **Advisory** while a corpus is brought up to the four-section shape; flip it to
|
|
33
|
+
**blocking** with a follow-up once the corpus is clean.
|
|
34
|
+
- **breadth-vs-depth routing** and **intra-spec contradiction** are **Warden judgments** (the spec's
|
|
35
|
+
`@rubric` scenarios) — the engine ships no code for them. The Warden reads the shape profile and
|
|
36
|
+
routes: breadth (many clusters/plain scenarios) → propose a node split; depth with a deterministic
|
|
37
|
+
suite → down-level via the verify-scenarios bridge; depth with agent-behavior scenarios → redesign.
|
|
38
|
+
**placement-drift** is deliberately *not* a deterministic check: a concept legitimately scatters
|
|
39
|
+
across folders (that scatter is what `concept-index` re-unifies), so a concept-vs-folder scan would
|
|
40
|
+
false-positive on correctly-placed nodes.
|
|
41
|
+
|
|
42
|
+
## Run the scan
|
|
43
|
+
|
|
44
|
+
```bash
|
|
45
|
+
node "<skill>/scripts/check-spec-structure.mts" [--spec-dir <spec>] [--check] [--max-scenarios <n>] [--format toon|json]
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
- Default `--spec-dir` is the current directory; default `--format` is **TOON** (the token-efficient
|
|
49
|
+
form the Warden scans); default `--max-scenarios` is **40**.
|
|
50
|
+
- **Audit mode** (default) emits the finding set — a `blocking[]` group then an `advisory[]` group,
|
|
51
|
+
each finding naming the node. `--format json` emits the same findings as a flat JSON array.
|
|
52
|
+
- **`--check`** (CI guard) exits **non-zero** iff a **blocking** finding exists and **writes
|
|
53
|
+
nothing**; advisory-only findings still exit zero. Wire it after `concept-index --check` in
|
|
54
|
+
`verify:specs-new` so the project-spec stays structurally clean.
|
|
55
|
+
|
|
56
|
+
When `node` is absent, an agent performs the same derivation by hand: for each `<cap>/<unit>/README.md`
|
|
57
|
+
with a `spec-type`, flag it untagged if it carries no `concept:`; for each with a sibling `.feature`,
|
|
58
|
+
count `Scenario:` lines and flag oversized over the threshold.
|
|
59
|
+
|
|
60
|
+
## Boundaries
|
|
61
|
+
|
|
62
|
+
Frontmatter + scenario-count only — it never reads a node body, owns no lifecycle state, and writes
|
|
63
|
+
nothing. It **never acts** on a finding (a split, a reconcile) — that is the Warden's
|
|
64
|
+
(`sdd:formation-loop`) under its own self-clear-vs-escalate verdict. It does **not** render the
|
|
65
|
+
by-concept view (`concept-index`), advise a new node's home (`place-node`), or check gate legality
|
|
66
|
+
(`spec-gate`'s `check-spec-state`, fail-closed at the gate).
|
|
@@ -0,0 +1,346 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// check-spec-structure — project-spec/check-spec-structure's concrete engine. Audits the internal
|
|
3
|
+
// node-shape of one project spec and emits a finding set for the formation Warden: the intra-spec
|
|
4
|
+
// successor to the retired cross-spec dedupe/split tools, now that one project is one spec
|
|
5
|
+
// (see this skill's README.md).
|
|
6
|
+
//
|
|
7
|
+
// Two deterministic checks, each with a severity:
|
|
8
|
+
// - untagged-node (blocking) — a spec-typed node README with no `concept:` tag, so it never
|
|
9
|
+
// appears in the by-concept index (../concept-index/). `--check` fails on it.
|
|
10
|
+
// - oversized-node (advisory) — a node whose sibling `.feature` scenario count exceeds the
|
|
11
|
+
// granularity threshold; carries a deterministic shape profile (plain/tagged counts + section-
|
|
12
|
+
// cluster count) but prescribes no route. Never fails `--check`.
|
|
13
|
+
// - missing-glossary (advisory) — the project spec has no root `glossary.md`, so its ubiquitous
|
|
14
|
+
// language has no home and a term can be used without ever being defined. A root FILE, not a
|
|
15
|
+
// folder: every mandated folder is an exception to screaming architecture. Never fails `--check`.
|
|
16
|
+
// - incomplete-node (advisory) — a behavioral leaf spec (a README with `spec-type: behavioral` and
|
|
17
|
+
// a colocated `.feature`) missing one of the four required `spec.md` sections (`## What`,
|
|
18
|
+
// `## Use Cases`, `## Control Flow`, `## Scenario map` — `sdd:spec-format-governance`). A spec
|
|
19
|
+
// that stops at `## Use Cases` never draws its CFG or scenario map. Advisory while a corpus is
|
|
20
|
+
// brought up to the four-section shape; a follow-up flips it to blocking once clean.
|
|
21
|
+
// Breadth-vs-depth routing and intra-spec contradiction are Warden judgment (the @rubric scenarios)
|
|
22
|
+
// — no engine code here.
|
|
23
|
+
//
|
|
24
|
+
// Pure derivation from frontmatter + scenario counts + the README's level-2 section headings (the
|
|
25
|
+
// four-section-shape signal): no node *prose* reaches a finding, and the engine writes nothing. No
|
|
26
|
+
// dependencies (the repo's node-≥23.6 / no-deps convention). Pure functions are exported for
|
|
27
|
+
// node:test; running the file directly drives the CLI.
|
|
28
|
+
|
|
29
|
+
import { existsSync, readdirSync, readFileSync } from 'node:fs'
|
|
30
|
+
import { join } from 'node:path'
|
|
31
|
+
|
|
32
|
+
const SKIP_DIRS = new Set(['node_modules', '.git', 'dist', '.turbo', '.next', 'coverage'])
|
|
33
|
+
export const DEFAULT_MAX_SCENARIOS = 40
|
|
34
|
+
|
|
35
|
+
// The four `spec.md` sections a behavioral leaf spec must carry, in order
|
|
36
|
+
// (`sdd:spec-format-governance`). `## References` is optional and not required here.
|
|
37
|
+
export const REQUIRED_BEHAVIORAL_SECTIONS = ['What', 'Use Cases', 'Control Flow', 'Scenario map']
|
|
38
|
+
|
|
39
|
+
export type FindingKind = 'untagged-node' | 'oversized-node' | 'missing-glossary' | 'incomplete-node'
|
|
40
|
+
export type Severity = 'blocking' | 'advisory'
|
|
41
|
+
|
|
42
|
+
export interface NodeRecord {
|
|
43
|
+
/** Path relative to the spec directory (POSIX). */
|
|
44
|
+
relPath: string
|
|
45
|
+
/** The top-level folder — the capability. */
|
|
46
|
+
capability: string
|
|
47
|
+
/** Display form — a README.md node shows its folder. */
|
|
48
|
+
display: string
|
|
49
|
+
concepts: string[]
|
|
50
|
+
specType?: string
|
|
51
|
+
hasFeature: boolean
|
|
52
|
+
/** Level-2 `## ` headings in the README body, in document order. */
|
|
53
|
+
sectionHeadings: string[]
|
|
54
|
+
scenarioCount: number
|
|
55
|
+
plainCount: number
|
|
56
|
+
taggedCount: number
|
|
57
|
+
clusterCount: number
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export interface Finding {
|
|
61
|
+
kind: FindingKind
|
|
62
|
+
severity: Severity
|
|
63
|
+
node: string
|
|
64
|
+
detail: string
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
// ── Frontmatter parse (concept + spec-type only — the classification signal) ──
|
|
68
|
+
export interface NodeFrontmatter {
|
|
69
|
+
concepts: string[]
|
|
70
|
+
specType?: string
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export function parseNodeFrontmatter(text: string): NodeFrontmatter {
|
|
74
|
+
const m = /^---\r?\n([\s\S]*?)\r?\n---\s*(?:\r?\n|$)/.exec(text)
|
|
75
|
+
if (!m) return { concepts: [] }
|
|
76
|
+
const fm: NodeFrontmatter = { concepts: [] }
|
|
77
|
+
const lines = m[1].split('\n').map((l) => l.replace(/\r$/, ''))
|
|
78
|
+
for (let i = 0; i < lines.length; i++) {
|
|
79
|
+
const line = lines[i]
|
|
80
|
+
if (line.trim() === '' || line.trim().startsWith('#')) continue
|
|
81
|
+
if (line.length - line.trimStart().length !== 0) continue // top-level keys only
|
|
82
|
+
const [key, ...rest] = line.trim().split(':')
|
|
83
|
+
const value = rest.join(':').trim()
|
|
84
|
+
if (key === 'spec-type') fm.specType = unquote(value)
|
|
85
|
+
else if (key === 'concept') {
|
|
86
|
+
if (value === '' || value === '|' || value === '>') {
|
|
87
|
+
for (let j = i + 1; j < lines.length; j++) {
|
|
88
|
+
const item = lines[j]
|
|
89
|
+
if (item.trim() === '') continue
|
|
90
|
+
if (item.length - item.trimStart().length === 0) break
|
|
91
|
+
const dash = /^\s*-\s+(.*)$/.exec(item)
|
|
92
|
+
if (dash) fm.concepts.push(unquote(dash[1].trim()))
|
|
93
|
+
}
|
|
94
|
+
} else {
|
|
95
|
+
fm.concepts.push(...parseScalarOrFlow(value))
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
return fm
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
export function parseScalarOrFlow(value: string): string[] {
|
|
103
|
+
const v = value.trim()
|
|
104
|
+
if (v.startsWith('[') && v.endsWith(']')) {
|
|
105
|
+
return v
|
|
106
|
+
.slice(1, -1)
|
|
107
|
+
.split(',')
|
|
108
|
+
.map((s) => unquote(s.trim()))
|
|
109
|
+
.filter((s) => s.length > 0)
|
|
110
|
+
}
|
|
111
|
+
const single = unquote(v)
|
|
112
|
+
return single.length > 0 ? [single] : []
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
function unquote(v: string): string {
|
|
116
|
+
return v.replace(/^["']|["']$/g, '')
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
// ── Section headings — the four-section shape signal (level-2 `## ` headings, fence-aware) ──
|
|
120
|
+
export function parseSectionHeadings(text: string): string[] {
|
|
121
|
+
const headings: string[] = []
|
|
122
|
+
let inFence = false
|
|
123
|
+
for (const raw of text.split('\n')) {
|
|
124
|
+
const line = raw.replace(/\r$/, '')
|
|
125
|
+
if (/^\s*(```|~~~)/.test(line)) {
|
|
126
|
+
inFence = !inFence
|
|
127
|
+
continue
|
|
128
|
+
}
|
|
129
|
+
if (inFence) continue
|
|
130
|
+
const m = /^##\s+(.+?)\s*$/.exec(line)
|
|
131
|
+
if (m) headings.push(m[1].replace(/`/g, '').trim())
|
|
132
|
+
}
|
|
133
|
+
return headings
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
// ── Scenario count — the granularity signal (frontmatter-free, count `Scenario:` lines) ──
|
|
137
|
+
export function countScenarios(featureText: string): number {
|
|
138
|
+
let n = 0
|
|
139
|
+
for (const line of featureText.split('\n')) {
|
|
140
|
+
if (/^\s*Scenario:/.test(line)) n++
|
|
141
|
+
}
|
|
142
|
+
return n
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
// ── Shape profile — the deterministic breadth-vs-depth signal for the Warden ──
|
|
146
|
+
export interface ShapeProfile {
|
|
147
|
+
scenarioCount: number
|
|
148
|
+
plainCount: number
|
|
149
|
+
taggedCount: number
|
|
150
|
+
clusterCount: number
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
const SECTION_HEADER = /^\s*#\s*(?:──|-{3,})/
|
|
154
|
+
|
|
155
|
+
export function profileFeature(featureText: string): ShapeProfile {
|
|
156
|
+
const lines = featureText.split('\n')
|
|
157
|
+
let scenarioCount = 0
|
|
158
|
+
let taggedCount = 0
|
|
159
|
+
let clusterCount = 0
|
|
160
|
+
let pendingRubric = false
|
|
161
|
+
for (const line of lines) {
|
|
162
|
+
if (/^\s*Scenario:/.test(line)) {
|
|
163
|
+
scenarioCount++
|
|
164
|
+
if (pendingRubric) taggedCount++
|
|
165
|
+
pendingRubric = false
|
|
166
|
+
continue
|
|
167
|
+
}
|
|
168
|
+
const trimmed = line.trim()
|
|
169
|
+
if (trimmed === '') continue
|
|
170
|
+
if (trimmed.startsWith('@')) {
|
|
171
|
+
if (/(^|\s)@rubric(\s|$)/.test(trimmed)) pendingRubric = true
|
|
172
|
+
continue
|
|
173
|
+
}
|
|
174
|
+
if (SECTION_HEADER.test(line)) {
|
|
175
|
+
clusterCount++
|
|
176
|
+
pendingRubric = false
|
|
177
|
+
continue
|
|
178
|
+
}
|
|
179
|
+
pendingRubric = false
|
|
180
|
+
}
|
|
181
|
+
return { scenarioCount, plainCount: scenarioCount - taggedCount, taggedCount, clusterCount }
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
function displayPath(relPath: string): string {
|
|
185
|
+
const p = relPath.replace(/\\/g, '/')
|
|
186
|
+
return p.endsWith('/README.md') ? p.slice(0, -'README.md'.length) : p
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
// ── Scan the project-spec — every node carrying a spec-type or concept tag ──
|
|
190
|
+
export function scanProjectSpec(specDir: string): NodeRecord[] {
|
|
191
|
+
const records: NodeRecord[] = []
|
|
192
|
+
walk(specDir, specDir, records)
|
|
193
|
+
return records.sort((a, b) => a.display.localeCompare(b.display))
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
function walk(dir: string, specDir: string, out: NodeRecord[]): void {
|
|
197
|
+
const entries = readdirSync(dir, { withFileTypes: true })
|
|
198
|
+
for (const entry of entries) {
|
|
199
|
+
if (entry.name.startsWith('.') || SKIP_DIRS.has(entry.name)) continue
|
|
200
|
+
const full = join(dir, entry.name)
|
|
201
|
+
if (entry.isDirectory()) {
|
|
202
|
+
walk(full, specDir, out)
|
|
203
|
+
} else if (entry.name === 'README.md') {
|
|
204
|
+
const readmeText = readFileSync(full, 'utf8')
|
|
205
|
+
const fm = parseNodeFrontmatter(readmeText)
|
|
206
|
+
if (fm.specType === undefined && fm.concepts.length === 0) continue
|
|
207
|
+
const sectionHeadings = parseSectionHeadings(readmeText)
|
|
208
|
+
const features = entries.filter((e) => e.isFile() && e.name.endsWith('.feature'))
|
|
209
|
+
const hasFeature = features.length > 0
|
|
210
|
+
const profile = hasFeature
|
|
211
|
+
? profileFeature(readFileSync(join(dir, features[0].name), 'utf8'))
|
|
212
|
+
: { scenarioCount: 0, plainCount: 0, taggedCount: 0, clusterCount: 0 }
|
|
213
|
+
const relPath = full.slice(specDir.length + 1).replace(/\\/g, '/')
|
|
214
|
+
out.push({
|
|
215
|
+
relPath,
|
|
216
|
+
capability: relPath.split('/')[0],
|
|
217
|
+
display: displayPath(relPath),
|
|
218
|
+
concepts: fm.concepts,
|
|
219
|
+
specType: fm.specType,
|
|
220
|
+
sectionHeadings,
|
|
221
|
+
hasFeature,
|
|
222
|
+
scenarioCount: profile.scenarioCount,
|
|
223
|
+
plainCount: profile.plainCount,
|
|
224
|
+
taggedCount: profile.taggedCount,
|
|
225
|
+
clusterCount: profile.clusterCount,
|
|
226
|
+
})
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
// ── Checks ──
|
|
232
|
+
// A spec-typed node with no concept is orphaned from the by-concept index → blocking.
|
|
233
|
+
export function checkUntagged(records: NodeRecord[]): Finding[] {
|
|
234
|
+
return records
|
|
235
|
+
.filter((r) => r.specType !== undefined && r.concepts.length === 0)
|
|
236
|
+
.map((r) => ({
|
|
237
|
+
kind: 'untagged-node' as const,
|
|
238
|
+
severity: 'blocking' as const,
|
|
239
|
+
node: r.display,
|
|
240
|
+
detail: `spec-type: ${r.specType} but no concept tag — orphaned from the by-concept index`,
|
|
241
|
+
}))
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
// A node whose suite exceeds the granularity threshold → advisory split candidate.
|
|
245
|
+
export function checkOversized(records: NodeRecord[], maxScenarios: number): Finding[] {
|
|
246
|
+
return records
|
|
247
|
+
.filter((r) => r.scenarioCount > maxScenarios)
|
|
248
|
+
.map((r) => ({
|
|
249
|
+
kind: 'oversized-node' as const,
|
|
250
|
+
severity: 'advisory' as const,
|
|
251
|
+
node: r.display,
|
|
252
|
+
detail: `${r.scenarioCount} scenarios > ${maxScenarios} — shape profile: plain ${r.plainCount}, tagged ${r.taggedCount}, clusters ${r.clusterCount} (soft breadth hint); the Warden routes breadth-vs-depth`,
|
|
253
|
+
}))
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
// A project spec with no root glossary.md has nowhere to define its ubiquitous language → advisory.
|
|
257
|
+
export function checkGlossary(specDir: string): Finding[] {
|
|
258
|
+
if (existsSync(join(specDir, 'glossary.md'))) return []
|
|
259
|
+
return [
|
|
260
|
+
{
|
|
261
|
+
kind: 'missing-glossary' as const,
|
|
262
|
+
severity: 'advisory' as const,
|
|
263
|
+
node: 'glossary.md',
|
|
264
|
+
detail:
|
|
265
|
+
'no root glossary.md — the project has no home for its ubiquitous language, so a term can be used without ever being defined; a root file, never a folder',
|
|
266
|
+
},
|
|
267
|
+
]
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
// A behavioral leaf spec (README with `spec-type: behavioral` + a colocated `.feature`) whose
|
|
271
|
+
// spec.md stops short of the four required sections never drew its CFG or scenario map → advisory
|
|
272
|
+
// until a corpus is brought up to the four-section shape, then flipped to blocking by a follow-up.
|
|
273
|
+
export function checkIncomplete(records: NodeRecord[]): Finding[] {
|
|
274
|
+
return records
|
|
275
|
+
.filter((r) => r.specType === 'behavioral' && r.hasFeature)
|
|
276
|
+
.map((r) => {
|
|
277
|
+
const missing = REQUIRED_BEHAVIORAL_SECTIONS.filter((s) => !r.sectionHeadings.includes(s))
|
|
278
|
+
return { record: r, missing }
|
|
279
|
+
})
|
|
280
|
+
.filter(({ missing }) => missing.length > 0)
|
|
281
|
+
.map(({ record, missing }) => ({
|
|
282
|
+
kind: 'incomplete-node' as const,
|
|
283
|
+
severity: 'advisory' as const,
|
|
284
|
+
node: record.display,
|
|
285
|
+
detail: `behavioral leaf spec missing required section(s): ${missing.map((s) => `## ${s}`).join(', ')} — a spec that stops at ## Use Cases never draws its CFG or scenario map (sdd:spec-format-governance)`,
|
|
286
|
+
}))
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
export function audit(records: NodeRecord[], maxScenarios: number, specDir?: string): Finding[] {
|
|
290
|
+
return [
|
|
291
|
+
...checkUntagged(records),
|
|
292
|
+
...checkOversized(records, maxScenarios),
|
|
293
|
+
...checkIncomplete(records),
|
|
294
|
+
...(specDir === undefined ? [] : checkGlossary(specDir)),
|
|
295
|
+
]
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
export function hasBlocking(findings: Finding[]): boolean {
|
|
299
|
+
return findings.some((f) => f.severity === 'blocking')
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
// ── Render ──
|
|
303
|
+
export function renderFindings(findings: Finding[], specDir: string): string {
|
|
304
|
+
const blocking = findings.filter((f) => f.severity === 'blocking')
|
|
305
|
+
const advisory = findings.filter((f) => f.severity === 'advisory')
|
|
306
|
+
const lines: string[] = [`check-spec-structure: spec-dir=${specDir}`]
|
|
307
|
+
lines.push(`blocking[${blocking.length}]:`)
|
|
308
|
+
for (const f of blocking) lines.push(` ${f.node} — ${f.kind}: ${f.detail}`)
|
|
309
|
+
lines.push(`advisory[${advisory.length}]:`)
|
|
310
|
+
for (const f of advisory) lines.push(` ${f.node} — ${f.kind}: ${f.detail}`)
|
|
311
|
+
lines.push('note: advisory — findings feed the Warden formation pass; the engine writes nothing')
|
|
312
|
+
return lines.join('\n')
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
// ── CLI ──
|
|
316
|
+
export function main(argv: string[]): number {
|
|
317
|
+
let specDir = '.'
|
|
318
|
+
let mode: 'audit' | 'check' = 'audit'
|
|
319
|
+
let format: 'toon' | 'json' = 'toon'
|
|
320
|
+
let maxScenarios = DEFAULT_MAX_SCENARIOS
|
|
321
|
+
for (let i = 0; i < argv.length; i++) {
|
|
322
|
+
const a = argv[i]
|
|
323
|
+
if (a === '--spec-dir') specDir = argv[++i] ?? '.'
|
|
324
|
+
else if (a === '--check') mode = 'check'
|
|
325
|
+
else if (a === '--max-scenarios') maxScenarios = Number(argv[++i] ?? DEFAULT_MAX_SCENARIOS)
|
|
326
|
+
else if (a === '--format') format = (argv[++i] as 'toon' | 'json') ?? 'toon'
|
|
327
|
+
}
|
|
328
|
+
const findings = audit(scanProjectSpec(specDir), maxScenarios, specDir)
|
|
329
|
+
if (mode === 'check') {
|
|
330
|
+
if (hasBlocking(findings)) {
|
|
331
|
+
process.stderr.write(
|
|
332
|
+
`check-spec-structure: ${findings.filter((f) => f.severity === 'blocking').length} blocking finding(s)\n`,
|
|
333
|
+
)
|
|
334
|
+
return 1
|
|
335
|
+
}
|
|
336
|
+
process.stdout.write('check-spec-structure: no blocking findings\n')
|
|
337
|
+
return 0
|
|
338
|
+
}
|
|
339
|
+
if (format === 'json') process.stdout.write(`${JSON.stringify(findings)}\n`)
|
|
340
|
+
else process.stdout.write(`${renderFindings(findings, specDir)}\n`)
|
|
341
|
+
return 0
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
if (import.meta.url === `file://${process.argv[1]}`) {
|
|
345
|
+
process.exit(main(process.argv.slice(2)))
|
|
346
|
+
}
|