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,501 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// Static .feature analysis for SDD specs — Gherkin validity, boolean form, and
|
|
3
|
+
// scenario ordering/sectioning checks. Pure functions are exported for node:test;
|
|
4
|
+
// running the file directly drives the CLI.
|
|
5
|
+
|
|
6
|
+
import { type Dirent, readdirSync, readFileSync } from 'node:fs'
|
|
7
|
+
import { basename, dirname, join } from 'node:path'
|
|
8
|
+
import { validateFeatures } from 'gherkin-cli'
|
|
9
|
+
|
|
10
|
+
// ─── types ────────────────────────────────────────────────────────────────────
|
|
11
|
+
|
|
12
|
+
export interface ParsedSuite {
|
|
13
|
+
hasFeatureLine: boolean
|
|
14
|
+
scenarios: ParsedScenario[]
|
|
15
|
+
sectionCommentCount: number
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export interface ParsedScenario {
|
|
19
|
+
name: string
|
|
20
|
+
steps: string[]
|
|
21
|
+
tags: string[]
|
|
22
|
+
// A `Scenario Outline` drives its steps from an `Examples:` table; a plain
|
|
23
|
+
// `Scenario` has neither. `placeholders` are the `<name>` tokens used in steps.
|
|
24
|
+
isOutline: boolean
|
|
25
|
+
placeholders: string[]
|
|
26
|
+
examples: { header: string[]; rows: string[][] } | null
|
|
27
|
+
// Step DocStrings (`"""..."""` blocks), in encounter order. A @rubric scenario's
|
|
28
|
+
// rubric YAML lives in one of these — the permissive scan otherwise skips them.
|
|
29
|
+
docStrings: string[]
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
// ─── hedge words that signal probabilistic / rubric assertions ────────────────
|
|
33
|
+
|
|
34
|
+
// Probabilistic adverbs make any step non-boolean — flag them on every step.
|
|
35
|
+
const ADVERB_PATTERNS = ['sometimes', 'usually', 'often', 'occasionally'].map((w) => new RegExp(`\\b${w}\\b`, 'i'))
|
|
36
|
+
|
|
37
|
+
// Rubric nouns (a graded scale leaking into the contract) are only a violation
|
|
38
|
+
// when they form a positive Then/And/But assertion. A negated/absence assertion
|
|
39
|
+
// ("no rubric appears") or one expressing a boolean verdict ("reports failing
|
|
40
|
+
// when the score is below the threshold") is fine — and a meta-spec mentioning
|
|
41
|
+
// the rule in a Given/When setup is not the contract embedding a rubric.
|
|
42
|
+
const RUBRIC_PATTERNS = [...['score', 'threshold', 'rubric'].map((w) => new RegExp(`\\b${w}\\b`, 'i')), /\b1[-–]5\b/]
|
|
43
|
+
const ASSERTION_RE = /^(Then|And|But)\b/i
|
|
44
|
+
const RUBRIC_EXEMPT_RE = /\b(no|not|never|without|nor|passing|failing|pass|fail|boolean|true|false|verdict)\b/i
|
|
45
|
+
|
|
46
|
+
// ─── parse ────────────────────────────────────────────────────────────────────
|
|
47
|
+
|
|
48
|
+
export function parseSuite(text: string): ParsedSuite {
|
|
49
|
+
const lines = text.split('\n')
|
|
50
|
+
let hasFeatureLine = false
|
|
51
|
+
let sectionCommentCount = 0
|
|
52
|
+
const scenarios: ParsedScenario[] = []
|
|
53
|
+
let current: ParsedScenario | null = null
|
|
54
|
+
// Tags on the line(s) above a Scenario apply to the scenario that follows.
|
|
55
|
+
let pendingTags: string[] = []
|
|
56
|
+
// A step DocString (`"""` ... `"""`) attaches to the step immediately above it —
|
|
57
|
+
// content is captured verbatim (not keyword-parsed) until the closing `"""`.
|
|
58
|
+
let inDocString = false
|
|
59
|
+
let docStringLines: string[] = []
|
|
60
|
+
|
|
61
|
+
for (const raw of lines) {
|
|
62
|
+
const line = raw.trimStart()
|
|
63
|
+
|
|
64
|
+
if (inDocString) {
|
|
65
|
+
if (line.startsWith('"""')) {
|
|
66
|
+
inDocString = false
|
|
67
|
+
if (current) current.docStrings.push(docStringLines.join('\n'))
|
|
68
|
+
docStringLines = []
|
|
69
|
+
} else {
|
|
70
|
+
docStringLines.push(raw)
|
|
71
|
+
}
|
|
72
|
+
continue
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
if (current && line.startsWith('"""')) {
|
|
76
|
+
inDocString = true
|
|
77
|
+
continue
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
if (/^Feature:/i.test(line)) {
|
|
81
|
+
hasFeatureLine = true
|
|
82
|
+
continue
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
// Section comment: a comment line that contains ── or -- (box-drawing or dashes)
|
|
86
|
+
if (/^#/.test(line) && (/──/.test(line) || /--/.test(line))) {
|
|
87
|
+
sectionCommentCount++
|
|
88
|
+
continue
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
// Tag line: one or more @tags preceding a Scenario.
|
|
92
|
+
if (/^@/.test(line)) {
|
|
93
|
+
pendingTags.push(...line.split(/\s+/).filter((t) => t.startsWith('@')))
|
|
94
|
+
continue
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
if (/^Scenario:/i.test(line) || /^Scenario Outline:/i.test(line)) {
|
|
98
|
+
if (current) scenarios.push(current)
|
|
99
|
+
const isOutline = /^Scenario Outline:/i.test(line)
|
|
100
|
+
const name = line.replace(/^Scenario(?: Outline)?:/i, '').trim()
|
|
101
|
+
current = { name, steps: [], tags: pendingTags, isOutline, placeholders: [], examples: null, docStrings: [] }
|
|
102
|
+
pendingTags = []
|
|
103
|
+
continue
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
// Examples: opens an outline's data table; the first table row is its header.
|
|
107
|
+
if (current && /^Examples:/i.test(line)) {
|
|
108
|
+
current.examples = { header: [], rows: [] }
|
|
109
|
+
continue
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
// A table row (`| a | b |`) fills the open Examples table — header first.
|
|
113
|
+
if (current?.examples && line.startsWith('|')) {
|
|
114
|
+
const cells = line
|
|
115
|
+
.split('|')
|
|
116
|
+
.slice(1, -1)
|
|
117
|
+
.map((c) => c.trim())
|
|
118
|
+
if (current.examples.header.length === 0) current.examples.header = cells
|
|
119
|
+
else current.examples.rows.push(cells)
|
|
120
|
+
continue
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
if (current && /^(Given|When|Then|And|But)\b/i.test(line)) {
|
|
124
|
+
current.steps.push(line)
|
|
125
|
+
// Collect `<placeholder>` tokens so an outline's table can be checked to cover them.
|
|
126
|
+
for (const m of line.matchAll(/<([^>]+)>/g)) current.placeholders.push(m[1])
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
if (current) scenarios.push(current)
|
|
131
|
+
|
|
132
|
+
return { hasFeatureLine, scenarios, sectionCommentCount }
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
// ─── Gherkin validity — the pinned parser, not the permissive scan below ───────
|
|
136
|
+
|
|
137
|
+
export interface ParseError {
|
|
138
|
+
line: number
|
|
139
|
+
message: string
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
// Maps the pinned parser's per-file report to its errors (empty array when it parses) so callers
|
|
143
|
+
// can look a path up directly.
|
|
144
|
+
export function runGherkinValidate(paths: string[]): Map<string, ParseError[]> {
|
|
145
|
+
const { files } = validateFeatures(paths)
|
|
146
|
+
const out = new Map<string, ParseError[]>()
|
|
147
|
+
for (const f of files) {
|
|
148
|
+
out.set(
|
|
149
|
+
f.file,
|
|
150
|
+
f.errors.map((e) => ({ line: e.line, message: e.message })),
|
|
151
|
+
)
|
|
152
|
+
}
|
|
153
|
+
return out
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
// ─── dead rubric — a rubric whose attainable maximum can't reach its threshold ─
|
|
157
|
+
|
|
158
|
+
export interface DeadRubric {
|
|
159
|
+
dimensionsTotal: number
|
|
160
|
+
threshold: number
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
// Hand-rolled line scan (no YAML dependency) over a rubric DocString of the shape:
|
|
164
|
+
// dimensions:
|
|
165
|
+
// - name: correctness
|
|
166
|
+
// max: 3
|
|
167
|
+
// threshold: 4
|
|
168
|
+
// Sums every `max:` value (at any indent, so it doesn't depend on YAML nesting rules)
|
|
169
|
+
// and reads the (last) `threshold:` value. Returns the violation only when the sum is
|
|
170
|
+
// STRICTLY less than the threshold — sum === threshold is a legal all-or-nothing bar
|
|
171
|
+
// and must never be reported. Missing/non-numeric threshold or no `max:` lines found
|
|
172
|
+
// return null: malformed rubric form is not this check's job to police.
|
|
173
|
+
export function findDeadRubric(docString: string): DeadRubric | null {
|
|
174
|
+
let dimensionsTotal = 0
|
|
175
|
+
let sawMax = false
|
|
176
|
+
let threshold: number | null = null
|
|
177
|
+
|
|
178
|
+
for (const raw of docString.split('\n')) {
|
|
179
|
+
const line = raw.trim()
|
|
180
|
+
const maxMatch = /^max:\s*(-?\d+(?:\.\d+)?)\s*$/.exec(line)
|
|
181
|
+
if (maxMatch) {
|
|
182
|
+
sawMax = true
|
|
183
|
+
dimensionsTotal += Number(maxMatch[1])
|
|
184
|
+
continue
|
|
185
|
+
}
|
|
186
|
+
const thresholdMatch = /^threshold:\s*(-?\d+(?:\.\d+)?)\s*$/.exec(line)
|
|
187
|
+
if (thresholdMatch) {
|
|
188
|
+
threshold = Number(thresholdMatch[1])
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
if (!sawMax || threshold === null) return null
|
|
193
|
+
if (dimensionsTotal >= threshold) return null
|
|
194
|
+
return { dimensionsTotal, threshold }
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
// ─── checks ──────────────────────────────────────────────────────────────────
|
|
198
|
+
|
|
199
|
+
// `parseErrors` carries the pinned parser's verdict for this file (empty when it parses, or
|
|
200
|
+
// omitted by 3-arg callers that predate this guard). A parse failure REPLACES every other
|
|
201
|
+
// finding below rather than joining them: every other check reads the file through the
|
|
202
|
+
// permissive `parseSuite` scan, so on an unparseable file those findings come from a partial
|
|
203
|
+
// view and are not evidence — reporting them as "the form" would still be the fail-open this
|
|
204
|
+
// guard exists to close, just with company.
|
|
205
|
+
export function checkSuite(slug: string, file: string, text: string, parseErrors: ParseError[] = []): string[] {
|
|
206
|
+
const tag = (msg: string) => `${slug}/${file}: ${msg}`
|
|
207
|
+
|
|
208
|
+
if (parseErrors.length > 0) {
|
|
209
|
+
return parseErrors.map((e) => tag(`cannot parse as Gherkin at line ${e.line} — ${e.message}`))
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
const v: string[] = []
|
|
213
|
+
const ref = parseSuite(text)
|
|
214
|
+
|
|
215
|
+
// Gherkin validity: must have Feature: line
|
|
216
|
+
if (!ref.hasFeatureLine) {
|
|
217
|
+
v.push(tag('missing Feature: line'))
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
for (const scenario of ref.scenarios) {
|
|
221
|
+
const steps = scenario.steps
|
|
222
|
+
const label = scenario.name ? `Scenario "${scenario.name}"` : 'unnamed scenario'
|
|
223
|
+
// A @rubric-tagged scenario is the sanctioned rubric form: its rubric/score/
|
|
224
|
+
// threshold lingo is the contract, not a leaked grade. Skip the rubric-noun
|
|
225
|
+
// ban for it (adverb hedges still apply — a rubric is not an excuse to hedge).
|
|
226
|
+
const isRubric = scenario.tags.includes('@rubric')
|
|
227
|
+
|
|
228
|
+
// Dead rubric: sum(dimension max) < threshold means no subject can ever reach
|
|
229
|
+
// the cut — the rubric grades nothing. Only checked for @rubric scenarios;
|
|
230
|
+
// sum === threshold is a legal strict (all-or-nothing) bar, not a violation.
|
|
231
|
+
if (isRubric) {
|
|
232
|
+
for (const docString of scenario.docStrings) {
|
|
233
|
+
const dead = findDeadRubric(docString)
|
|
234
|
+
if (dead) {
|
|
235
|
+
v.push(
|
|
236
|
+
tag(
|
|
237
|
+
`${label}: rubric cannot be passed — dimensions total ${dead.dimensionsTotal}, threshold ${dead.threshold}`,
|
|
238
|
+
),
|
|
239
|
+
)
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
// Must have at least one step
|
|
245
|
+
if (steps.length === 0) {
|
|
246
|
+
v.push(tag(`${label}: has no steps`))
|
|
247
|
+
continue
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
// Must have a When or Given
|
|
251
|
+
const hasGivenOrWhen = steps.some((s) => /^(Given|When)\b/i.test(s))
|
|
252
|
+
if (!hasGivenOrWhen) {
|
|
253
|
+
v.push(tag(`${label}: missing Given or When step`))
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
// Must have a Then (or And/But after a Then)
|
|
257
|
+
const hasThen = steps.some((s) => /^Then\b/i.test(s))
|
|
258
|
+
if (!hasThen) {
|
|
259
|
+
v.push(tag(`${label}: missing Then step — scenario has no assertion`))
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
// Boolean form
|
|
263
|
+
for (const step of steps) {
|
|
264
|
+
// Probabilistic adverbs are never boolean — flag on any step.
|
|
265
|
+
const adverb = ADVERB_PATTERNS.find((p) => p.test(step))
|
|
266
|
+
if (adverb) {
|
|
267
|
+
v.push(tag(`${label}: step contains non-boolean hedge — "${step.trim()}" (matched ${adverb.source})`))
|
|
268
|
+
continue
|
|
269
|
+
}
|
|
270
|
+
// Rubric nouns only count as a leaked grade in a positive Then/And/But
|
|
271
|
+
// assertion of an untagged scenario; a @rubric scenario admits them.
|
|
272
|
+
if (isRubric || !ASSERTION_RE.test(step) || RUBRIC_EXEMPT_RE.test(step)) continue
|
|
273
|
+
const rubric = RUBRIC_PATTERNS.find((p) => p.test(step))
|
|
274
|
+
if (rubric) {
|
|
275
|
+
v.push(
|
|
276
|
+
tag(`${label}: step embeds a rubric/score in its assertion — "${step.trim()}" (matched ${rubric.source})`),
|
|
277
|
+
)
|
|
278
|
+
}
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
// Scenario Outline: the Examples table must be present, non-empty, and cover
|
|
282
|
+
// every <placeholder> used in the steps. A bare outline with no table (or a
|
|
283
|
+
// table missing a placeholder's column) would silently drive nothing.
|
|
284
|
+
if (scenario.isOutline) {
|
|
285
|
+
const ex = scenario.examples
|
|
286
|
+
if (!ex || ex.header.length === 0 || ex.rows.length === 0) {
|
|
287
|
+
v.push(tag(`${label}: Scenario Outline has no non-empty Examples table`))
|
|
288
|
+
} else {
|
|
289
|
+
const missing = [...new Set(scenario.placeholders)].filter((p) => !ex.header.includes(p))
|
|
290
|
+
if (missing.length) {
|
|
291
|
+
v.push(tag(`${label}: Examples table missing column(s) for placeholder(s): ${missing.join(', ')}`))
|
|
292
|
+
}
|
|
293
|
+
}
|
|
294
|
+
}
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
// Ordering / sectioning: many scenarios must have at least one section comment
|
|
298
|
+
const SECTION_THRESHOLD = 6
|
|
299
|
+
if (ref.scenarios.length > SECTION_THRESHOLD && ref.sectionCommentCount === 0) {
|
|
300
|
+
v.push(
|
|
301
|
+
tag(
|
|
302
|
+
`${ref.scenarios.length} scenarios but no section comments (add # ── Stage comments to group lifecycle stages)`,
|
|
303
|
+
),
|
|
304
|
+
)
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
return v
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
// ─── CLI entry ────────────────────────────────────────────────────────────────
|
|
311
|
+
|
|
312
|
+
// Discovery walks the tree recursively so nested spec folders (sdd/sdd-skill)
|
|
313
|
+
// are analyzed too — a .feature is a real contract wherever it lives. Returns
|
|
314
|
+
// each dir (root-relative slug) paired with its .feature files.
|
|
315
|
+
export function discoverSuiteDirs(root: string): { slug: string; files: string[] }[] {
|
|
316
|
+
const out: { slug: string; files: string[] }[] = []
|
|
317
|
+
const walk = (dir: string, rel: string) => {
|
|
318
|
+
let entries: Dirent[]
|
|
319
|
+
try {
|
|
320
|
+
entries = readdirSync(dir, { withFileTypes: true })
|
|
321
|
+
} catch {
|
|
322
|
+
return
|
|
323
|
+
}
|
|
324
|
+
const files = entries.filter((e) => e.isFile() && e.name.endsWith('.feature')).map((e) => e.name)
|
|
325
|
+
if (files.length) out.push({ slug: rel, files })
|
|
326
|
+
for (const e of entries) {
|
|
327
|
+
if (!e.isDirectory() || e.name === 'node_modules' || e.name.startsWith('.')) continue
|
|
328
|
+
walk(join(dir, e.name), rel ? join(rel, e.name) : e.name)
|
|
329
|
+
}
|
|
330
|
+
}
|
|
331
|
+
walk(root, '')
|
|
332
|
+
return out
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
// The gate scopes to a CR's *touched* .feature files, not the whole tree. In
|
|
336
|
+
// --files mode the caller passes an explicit path list and only those files are
|
|
337
|
+
// checked (tree discovery is skipped). An unreadable path fails closed — the
|
|
338
|
+
// gate must never silently pass a file it could not read.
|
|
339
|
+
//
|
|
340
|
+
// `validate` is injected so the unit tests exercise this wiring with a fake parser (fast,
|
|
341
|
+
// offline) while `main` wires the real pinned in-process parser. The parser is the SOLE source of
|
|
342
|
+
// Gherkin validity — if it cannot be run at all, every readable path fails closed rather than
|
|
343
|
+
// falling back to the permissive scan; if it runs but omits a path from its report, that path
|
|
344
|
+
// fails closed too rather than defaulting to "parses fine".
|
|
345
|
+
export function checkFilePaths(paths: string[], validate: typeof runGherkinValidate = runGherkinValidate): string[] {
|
|
346
|
+
const violations: string[] = []
|
|
347
|
+
const readable: { path: string; text: string }[] = []
|
|
348
|
+
for (const p of paths) {
|
|
349
|
+
try {
|
|
350
|
+
readable.push({ path: p, text: readFileSync(p, 'utf8') })
|
|
351
|
+
} catch {
|
|
352
|
+
violations.push(`${p}: cannot read file`)
|
|
353
|
+
}
|
|
354
|
+
}
|
|
355
|
+
if (readable.length === 0) return violations
|
|
356
|
+
|
|
357
|
+
let parseErrorsByPath: Map<string, ParseError[]>
|
|
358
|
+
try {
|
|
359
|
+
parseErrorsByPath = validate(readable.map((r) => r.path))
|
|
360
|
+
} catch (err) {
|
|
361
|
+
for (const { path } of readable) {
|
|
362
|
+
violations.push(`${path}: cannot verify Gherkin validity — ${(err as Error).message}`)
|
|
363
|
+
}
|
|
364
|
+
return violations
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
for (const { path: p, text } of readable) {
|
|
368
|
+
const errs = parseErrorsByPath.get(p)
|
|
369
|
+
if (errs === undefined) {
|
|
370
|
+
// Defaulting a missing report to "parses fine" is the exact fail-open bug being fixed —
|
|
371
|
+
// the parser said nothing about this file, so it cannot be classified as valid.
|
|
372
|
+
violations.push(`${p}: the Gherkin parser returned no result for this file`)
|
|
373
|
+
continue
|
|
374
|
+
}
|
|
375
|
+
violations.push(...checkSuite(dirname(p), basename(p), text, errs))
|
|
376
|
+
if (errs.length === 0) {
|
|
377
|
+
const specPath = join(dirname(p), 'README.md')
|
|
378
|
+
let specText: string | undefined
|
|
379
|
+
try {
|
|
380
|
+
specText = readFileSync(specPath, 'utf8')
|
|
381
|
+
} catch {
|
|
382
|
+
specText = undefined
|
|
383
|
+
}
|
|
384
|
+
if (specText !== undefined) {
|
|
385
|
+
violations.push(...checkScenarioMap(dirname(p), basename(p), text, specText))
|
|
386
|
+
}
|
|
387
|
+
}
|
|
388
|
+
}
|
|
389
|
+
return violations
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
// ─── scenario-map binding ─────────────────────────────────────────────────────
|
|
393
|
+
// The sibling spec's `## Scenario map` binds each scenario to a (path class, edge) pair
|
|
394
|
+
// (`| Edge | Path (Given) | Scenario |`). Form only: this checks the BINDING is complete and
|
|
395
|
+
// non-duplicated. Whether the edges cover the control-flow graph (CFG) is judged, not linted — that
|
|
396
|
+
// needs the drawn CFG's semantics, and a green check clears no coverage question.
|
|
397
|
+
//
|
|
398
|
+
// A spec carrying no `## Scenario map` section is SKIPPED, not failed: the map is the rebuilt node
|
|
399
|
+
// format, and a node still on the older shape is not in violation of a section it does not claim.
|
|
400
|
+
export interface MapRow {
|
|
401
|
+
edge: string
|
|
402
|
+
path: string
|
|
403
|
+
scenario: string
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
export function parseScenarioMap(specText: string): MapRow[] | undefined {
|
|
407
|
+
const start = specText.indexOf('## Scenario map')
|
|
408
|
+
if (start === -1) return undefined
|
|
409
|
+
const body = specText.slice(start)
|
|
410
|
+
const rows: MapRow[] = []
|
|
411
|
+
for (const line of body.split('\n')) {
|
|
412
|
+
const t = line.trim()
|
|
413
|
+
if (!t.startsWith('|')) continue
|
|
414
|
+
const cells = t
|
|
415
|
+
.split('|')
|
|
416
|
+
.slice(1, -1)
|
|
417
|
+
.map((c) => c.trim())
|
|
418
|
+
if (cells.length !== 3) continue
|
|
419
|
+
const scenario = cells[2] ?? ''
|
|
420
|
+
// Skip the header row and its separator; a data row names its scenario in backticks.
|
|
421
|
+
const m = scenario.match(/^`(.+)`$/)
|
|
422
|
+
if (m === null) continue
|
|
423
|
+
rows.push({ edge: cells[0] ?? '', path: cells[1] ?? '', scenario: m[1] ?? '' })
|
|
424
|
+
}
|
|
425
|
+
return rows
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
export function checkScenarioMap(slug: string, file: string, featureText: string, specText: string): string[] {
|
|
429
|
+
const rows = parseScenarioMap(specText)
|
|
430
|
+
if (rows === undefined) return []
|
|
431
|
+
const tag = (msg: string) => `${slug}/${file}: ${msg}`
|
|
432
|
+
const v: string[] = []
|
|
433
|
+
|
|
434
|
+
const titles = [...featureText.matchAll(/^\s*Scenario(?: Outline)?:\s*(.+?)\s*$/gm)].map((m) => m[1] ?? '')
|
|
435
|
+
const mapped = new Set(rows.map((r) => r.scenario))
|
|
436
|
+
|
|
437
|
+
for (const t of titles) {
|
|
438
|
+
if (!mapped.has(t)) v.push(tag(`scenario is not on the scenario map — "${t}"`))
|
|
439
|
+
}
|
|
440
|
+
const titleSet = new Set(titles)
|
|
441
|
+
for (const r of rows) {
|
|
442
|
+
if (!titleSet.has(r.scenario)) v.push(tag(`scenario map row names no such scenario — "${r.scenario}"`))
|
|
443
|
+
}
|
|
444
|
+
const seen = new Map<string, string>()
|
|
445
|
+
for (const r of rows) {
|
|
446
|
+
const key = `${r.edge}\u0000${r.path}`
|
|
447
|
+
const prior = seen.get(key)
|
|
448
|
+
if (prior !== undefined) {
|
|
449
|
+
v.push(
|
|
450
|
+
tag(
|
|
451
|
+
`duplicate map pair — edge "${r.edge}" and path "${r.path}" cover both "${prior}" and "${r.scenario}"; a repeated edge needs a DIFFERENT path class`,
|
|
452
|
+
),
|
|
453
|
+
)
|
|
454
|
+
} else seen.set(key, r.scenario)
|
|
455
|
+
}
|
|
456
|
+
return v
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
// Collect the path list following --files, stopping at the next flag.
|
|
460
|
+
export function parseFilesArg(argv: string[]): string[] {
|
|
461
|
+
const idx = argv.indexOf('--files')
|
|
462
|
+
if (idx === -1) return []
|
|
463
|
+
const paths: string[] = []
|
|
464
|
+
for (let i = idx + 1; i < argv.length; i++) {
|
|
465
|
+
if (argv[i].startsWith('--')) break
|
|
466
|
+
paths.push(argv[i])
|
|
467
|
+
}
|
|
468
|
+
return paths
|
|
469
|
+
}
|
|
470
|
+
|
|
471
|
+
export function main(argv: string[]): number {
|
|
472
|
+
let violations: string[] = []
|
|
473
|
+
|
|
474
|
+
if (argv.includes('--files')) {
|
|
475
|
+
const paths = parseFilesArg(argv)
|
|
476
|
+
if (paths.length === 0) {
|
|
477
|
+
console.error('✗ --files requires at least one .feature path')
|
|
478
|
+
return 1
|
|
479
|
+
}
|
|
480
|
+
violations = checkFilePaths(paths)
|
|
481
|
+
} else {
|
|
482
|
+
const root = argv.includes('--root') ? argv[argv.indexOf('--root') + 1] : '.agents/specs'
|
|
483
|
+
// The tree-wide sweep must fail closed on a parse failure exactly like --files — route the
|
|
484
|
+
// full discovered path list through the same validated path rather than the bare parseSuite
|
|
485
|
+
// scan, so an unparseable suite anywhere in the corpus fails the sweep closed.
|
|
486
|
+
const paths: string[] = []
|
|
487
|
+
for (const { slug, files } of discoverSuiteDirs(root)) {
|
|
488
|
+
for (const file of files) paths.push(join(root, slug, file))
|
|
489
|
+
}
|
|
490
|
+
violations = checkFilePaths(paths)
|
|
491
|
+
}
|
|
492
|
+
|
|
493
|
+
if (violations.length) {
|
|
494
|
+
for (const line of violations) console.error(`✗ ${line}`)
|
|
495
|
+
return 1
|
|
496
|
+
}
|
|
497
|
+
process.stdout.write('suite checks OK\n')
|
|
498
|
+
return 0
|
|
499
|
+
}
|
|
500
|
+
|
|
501
|
+
if (import.meta.main) process.exit(main(process.argv.slice(2)))
|