@renzrollon/interlock 1.5.0 → 1.6.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 +12 -1
- package/CHANGELOG.md +104 -0
- package/README.md +7 -8
- package/bin/interlock +1655 -29
- package/hooks/mod.mjs +21 -20
- package/hooks/notify-option.mjs +69 -0
- package/hooks/preflight.mjs +6 -42
- package/lib/conformance.mjs +75 -0
- package/lib/coverage.mjs +387 -0
- package/lib/doctor.mjs +17 -13
- package/lib/drift.mjs +36 -12
- package/lib/findings.mjs +40 -3
- package/lib/graph/extract/openspec.mjs +6 -15
- package/lib/limits.mjs +78 -1
- package/lib/merge-confidence.mjs +1302 -0
- package/lib/merge-evidence.mjs +527 -0
- package/lib/notify.mjs +10 -20
- package/lib/outcomes.mjs +26 -5
- package/lib/pipeline.mjs +380 -0
- package/lib/prompts/dimensions.mjs +24 -2
- package/lib/prompts/domain-context.mjs +271 -0
- package/lib/prompts/handoff.mjs +32 -1
- package/lib/prompts/index.mjs +57 -0
- package/lib/prompts/remediate.mjs +29 -5
- package/lib/prompts/review.mjs +21 -7
- package/lib/prompts/schemas.mjs +2 -0
- package/lib/ready.mjs +13 -1
- package/lib/receipt.mjs +72 -2
- package/lib/review-core.mjs +224 -21
- package/lib/risk.mjs +8 -0
- package/lib/run-log.mjs +44 -0
- package/lib/run.mjs +859 -27
- package/lib/spec-audit.mjs +232 -0
- package/lib/spec-coverage.mjs +105 -0
- package/lib/spec-text.mjs +257 -0
- package/lib/verify.mjs +17 -4
- package/package.json +1 -1
- package/shared/SESSION-SHAPES.md +1 -1
- package/shared/TEST-PROFILE.md +39 -2
- package/skills/mr/SKILL.md +161 -11
- package/skills/review-code/REVIEW.template.md +24 -6
- package/skills/review-code/SKILL.md +16 -6
- package/skills/review-code/dimensions/domain.md +21 -0
|
@@ -0,0 +1,232 @@
|
|
|
1
|
+
// The spec audit: what a reviewer can rely on a living spec to say.
|
|
2
|
+
//
|
|
3
|
+
// Pure over gathered inputs — no fs, no git, no clock. The CLI walks
|
|
4
|
+
// `openspec/specs/**/spec.md`, reads the graph, and hands this module the texts,
|
|
5
|
+
// the reliable spec-to-file edges, the broken links and the graph's coverable
|
|
6
|
+
// files. Every measure is defined once here, by a rule the `spec/spec-audit`
|
|
7
|
+
// spec states, so a figure can be repeated and a second reader gets the same
|
|
8
|
+
// number. The audit judges nothing and blocks nothing: a measure it could not
|
|
9
|
+
// take is absent with its reason, never zero. design D5 of
|
|
10
|
+
// judge-against-base-and-living-specs.
|
|
11
|
+
|
|
12
|
+
import { parseSpec, capabilityOf } from './spec-text.mjs'
|
|
13
|
+
|
|
14
|
+
/** Whole-word bound or prohibition terms, case-insensitive. `nevertheless` is not `never`. */
|
|
15
|
+
const BOUND = /\b(?:shall not|must not|never|at most|at least|exactly once)\b/i
|
|
16
|
+
const NORMATIVE = /\b(?:SHALL|MUST)\b/
|
|
17
|
+
const INLINE_CODE = /`[^`\n]*`/g
|
|
18
|
+
// A standalone number: not welded to a word (`utf8`), not part of a dotted
|
|
19
|
+
// version or a decimal tail (`1.5.0`).
|
|
20
|
+
const NUMBER = /(?<![\w.])\d+(?:\.\d+)?(?![\w]|\.\d)/g
|
|
21
|
+
const LIST_MARKER = /^\s*\d+[.)]\s+/
|
|
22
|
+
const FENCE = /^\s*(```|~~~)/
|
|
23
|
+
|
|
24
|
+
const FLAG_ORDER = ['no-bound', 'no-failure-scenario', 'no-edge-case']
|
|
25
|
+
|
|
26
|
+
const startsWith = (title, word) => new RegExp(`^${word}`, 'i').test(String(title ?? '').trim())
|
|
27
|
+
|
|
28
|
+
/** The measures of one parsed spec. */
|
|
29
|
+
function measure(path, parsed) {
|
|
30
|
+
const c = {
|
|
31
|
+
capability: capabilityOf(path) ?? path,
|
|
32
|
+
path,
|
|
33
|
+
purpose: parsed.purpose !== null,
|
|
34
|
+
requirements: parsed.requirements.length,
|
|
35
|
+
nonNormative: [],
|
|
36
|
+
scenarios: 0,
|
|
37
|
+
missingGiven: 0,
|
|
38
|
+
missingWhen: 0,
|
|
39
|
+
missingThen: 0,
|
|
40
|
+
failure: 0,
|
|
41
|
+
edgeCase: 0,
|
|
42
|
+
unclassified: 0,
|
|
43
|
+
boundLines: 0,
|
|
44
|
+
numericLiterals: [],
|
|
45
|
+
flags: [],
|
|
46
|
+
links: null
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
for (const req of parsed.requirements) {
|
|
50
|
+
if (!NORMATIVE.test(`${req.heading}\n${req.prose}`)) c.nonNormative.push(req.heading)
|
|
51
|
+
|
|
52
|
+
for (const s of req.scenarios) {
|
|
53
|
+
c.scenarios++
|
|
54
|
+
if (!s.given) c.missingGiven++
|
|
55
|
+
if (!s.when) c.missingWhen++
|
|
56
|
+
if (!s.then) c.missingThen++
|
|
57
|
+
if (startsWith(s.title, 'failure')) c.failure++
|
|
58
|
+
else if (startsWith(s.title, 'edge case')) c.edgeCase++
|
|
59
|
+
else if (!startsWith(s.title, 'happy path')) c.unclassified++
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
// `text` opens on the heading line, so index i is line `req.line + i`.
|
|
63
|
+
// Prose is everything before the first scenario heading.
|
|
64
|
+
const lines = req.text.split('\n')
|
|
65
|
+
const proseEnd = req.scenarios.length ? req.scenarios[0].line - req.line : lines.length
|
|
66
|
+
let inFence = false
|
|
67
|
+
for (let i = 0; i < lines.length; i++) {
|
|
68
|
+
const line = lines[i]
|
|
69
|
+
if (BOUND.test(line)) c.boundLines++
|
|
70
|
+
if (FENCE.test(line)) {
|
|
71
|
+
inFence = !inFence
|
|
72
|
+
continue
|
|
73
|
+
}
|
|
74
|
+
if (i === 0 || i >= proseEnd || inFence) continue
|
|
75
|
+
const bare = line.replace(INLINE_CODE, ' ').replace(LIST_MARKER, '')
|
|
76
|
+
for (const m of bare.matchAll(NUMBER)) {
|
|
77
|
+
c.numericLiterals.push({ spec: path, line: req.line + i, value: Number(m[0]) })
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
if (c.boundLines === 0) c.flags.push('no-bound')
|
|
83
|
+
if (c.failure === 0) c.flags.push('no-failure-scenario')
|
|
84
|
+
if (c.edgeCase === 0) c.flags.push('no-edge-case')
|
|
85
|
+
c.flags.sort((a, b) => FLAG_ORDER.indexOf(a) - FLAG_ORDER.indexOf(b))
|
|
86
|
+
return c
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* @param {{
|
|
91
|
+
* specs: Array<{ path: string, text?: string, error?: string }>,
|
|
92
|
+
* edges?: Array<{ source_file?: string, target?: string }>|null,
|
|
93
|
+
* graphReason?: string|null,
|
|
94
|
+
* broken?: Array<{ spec: string, file: string }>,
|
|
95
|
+
* graphFiles?: string[]
|
|
96
|
+
* }} input
|
|
97
|
+
*/
|
|
98
|
+
export function auditSpecs(input = {}) {
|
|
99
|
+
const specs = Array.isArray(input.specs) ? input.specs : []
|
|
100
|
+
const edges = Array.isArray(input.edges) ? input.edges : null
|
|
101
|
+
const broken = Array.isArray(input.broken) ? input.broken : []
|
|
102
|
+
const graphFiles = Array.isArray(input.graphFiles) ? input.graphFiles : []
|
|
103
|
+
|
|
104
|
+
const graph = { available: edges !== null, reason: null, unspecified: null }
|
|
105
|
+
if (!graph.available) {
|
|
106
|
+
graph.reason = input.graphReason || 'no graph is available'
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
const unreadable = []
|
|
110
|
+
const capabilities = []
|
|
111
|
+
for (const s of specs) {
|
|
112
|
+
if (!s || typeof s.path !== 'string') continue
|
|
113
|
+
if (typeof s.text !== 'string') {
|
|
114
|
+
unreadable.push({ path: s.path, reason: s.error ? String(s.error) : 'the file could not be read' })
|
|
115
|
+
continue
|
|
116
|
+
}
|
|
117
|
+
capabilities.push(measure(s.path, parseSpec(s.text)))
|
|
118
|
+
}
|
|
119
|
+
capabilities.sort((a, b) => a.capability.localeCompare(b.capability))
|
|
120
|
+
|
|
121
|
+
const coverage = { specs: capabilities.length, corpus: specs.length }
|
|
122
|
+
|
|
123
|
+
if (specs.length === 0) {
|
|
124
|
+
return {
|
|
125
|
+
absent: true,
|
|
126
|
+
reason: 'no living spec exists — openspec/specs/ is missing or holds no spec.md',
|
|
127
|
+
coverage,
|
|
128
|
+
unreadable,
|
|
129
|
+
capabilities: [],
|
|
130
|
+
totals: null,
|
|
131
|
+
graph
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
if (edges) {
|
|
136
|
+
const bySpec = new Map()
|
|
137
|
+
const named = new Set()
|
|
138
|
+
for (const e of edges) {
|
|
139
|
+
const spec = e && typeof e.source_file === 'string' ? e.source_file : null
|
|
140
|
+
const target = e && typeof e.target === 'string' ? e.target : ''
|
|
141
|
+
if (!spec || !target.startsWith('file:')) continue
|
|
142
|
+
const file = target.slice('file:'.length)
|
|
143
|
+
if (!bySpec.has(spec)) bySpec.set(spec, new Set())
|
|
144
|
+
bySpec.get(spec).add(file)
|
|
145
|
+
named.add(file)
|
|
146
|
+
}
|
|
147
|
+
for (const c of capabilities) {
|
|
148
|
+
const missing = [...new Set(broken.filter(b => b && b.spec === c.path).map(b => b.file))].sort()
|
|
149
|
+
c.links = { files: (bySpec.get(c.path) ?? new Set()).size, missing }
|
|
150
|
+
}
|
|
151
|
+
const files = [...new Set(graphFiles)].filter(f => !named.has(f)).sort()
|
|
152
|
+
const total = new Set(graphFiles).size
|
|
153
|
+
graph.unspecified = {
|
|
154
|
+
count: files.length,
|
|
155
|
+
total,
|
|
156
|
+
percent: total ? Math.round((files.length / total) * 100) : 0,
|
|
157
|
+
files
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
const sum = key => capabilities.reduce((n, c) => n + c[key], 0)
|
|
162
|
+
const totals = {
|
|
163
|
+
specs: capabilities.length,
|
|
164
|
+
requirements: sum('requirements'),
|
|
165
|
+
nonNormative: capabilities.reduce((n, c) => n + c.nonNormative.length, 0),
|
|
166
|
+
scenarios: sum('scenarios'),
|
|
167
|
+
missingGiven: sum('missingGiven'),
|
|
168
|
+
missingWhen: sum('missingWhen'),
|
|
169
|
+
missingThen: sum('missingThen'),
|
|
170
|
+
failure: sum('failure'),
|
|
171
|
+
edgeCase: sum('edgeCase'),
|
|
172
|
+
unclassified: sum('unclassified'),
|
|
173
|
+
boundLines: sum('boundLines'),
|
|
174
|
+
numericLiterals: capabilities.reduce((n, c) => n + c.numericLiterals.length, 0),
|
|
175
|
+
flagged: capabilities.filter(c => c.flags.length > 0).length
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
return { absent: false, reason: null, coverage, unreadable, capabilities, totals, graph }
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
/** The human report. Every measure not taken is named with its reason. */
|
|
182
|
+
export function formatSpecAudit(report) {
|
|
183
|
+
const out = ['SPEC AUDIT']
|
|
184
|
+
if (report.absent) {
|
|
185
|
+
out.push(` not measured: ${report.reason}`)
|
|
186
|
+
for (const u of report.unreadable) out.push(` unreadable: ${u.path} (${u.reason})`)
|
|
187
|
+
return out.join('\n')
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
const { coverage, totals, graph } = report
|
|
191
|
+
out.push(` covers ${coverage.specs} of ${coverage.corpus} spec${coverage.corpus === 1 ? '' : 's'}`)
|
|
192
|
+
for (const u of report.unreadable) out.push(` unreadable, left out of the totals: ${u.path} (${u.reason})`)
|
|
193
|
+
|
|
194
|
+
out.push(
|
|
195
|
+
` requirements ${totals.requirements} (${totals.nonNormative} without SHALL or MUST)` +
|
|
196
|
+
`, scenarios ${totals.scenarios}` +
|
|
197
|
+
` (no GIVEN ${totals.missingGiven}, no WHEN ${totals.missingWhen}, no THEN ${totals.missingThen})`
|
|
198
|
+
)
|
|
199
|
+
out.push(
|
|
200
|
+
` failure ${totals.failure}, edge case ${totals.edgeCase}, untitled by kind ${totals.unclassified}` +
|
|
201
|
+
`, bound lines ${totals.boundLines}, numeric literals ${totals.numericLiterals}`
|
|
202
|
+
)
|
|
203
|
+
|
|
204
|
+
const flagged = report.capabilities.filter(c => c.flags.length > 0)
|
|
205
|
+
out.push('', `FLAGS — ${flagged.length} of ${report.capabilities.length} capabilities`)
|
|
206
|
+
if (!flagged.length) out.push(' none')
|
|
207
|
+
for (const c of flagged) out.push(` ${c.capability} ${c.flags.join(' ')}`)
|
|
208
|
+
|
|
209
|
+
const nonNormative = report.capabilities.filter(c => c.nonNormative.length > 0)
|
|
210
|
+
if (nonNormative.length) {
|
|
211
|
+
out.push('', 'REQUIREMENTS WITH NEITHER SHALL NOR MUST')
|
|
212
|
+
for (const c of nonNormative) for (const h of c.nonNormative) out.push(` ${c.path} ${h}`)
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
const literals = report.capabilities.flatMap(c => c.numericLiterals)
|
|
216
|
+
out.push('', `NUMERIC LITERALS IN REQUIREMENT PROSE — ${literals.length}, for a person to classify`)
|
|
217
|
+
for (const n of literals) out.push(` ${n.spec}:${n.line} ${n.value}`)
|
|
218
|
+
|
|
219
|
+
out.push('')
|
|
220
|
+
if (!graph.available) {
|
|
221
|
+
out.push(`FILE LINKS — not measured: ${graph.reason}`)
|
|
222
|
+
out.push(`UNSPECIFIED SOURCE — not measured: ${graph.reason}`)
|
|
223
|
+
} else {
|
|
224
|
+
const gone = report.capabilities.filter(c => c.links && c.links.missing.length > 0)
|
|
225
|
+
out.push(`FILE LINKS — ${gone.length} capabilit${gone.length === 1 ? 'y' : 'ies'} name a file that no longer exists`)
|
|
226
|
+
for (const c of gone) for (const f of c.links.missing) out.push(` ${c.path} → ${f} (missing)`)
|
|
227
|
+
const u = graph.unspecified
|
|
228
|
+
out.push(`UNSPECIFIED SOURCE — ${u.count} of ${u.total} coverable files (${u.percent}%) are named by no living spec`)
|
|
229
|
+
for (const f of u.files) out.push(` ${f}`)
|
|
230
|
+
}
|
|
231
|
+
return out.join('\n')
|
|
232
|
+
}
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
// The facts behind the `spec.coverage` signal: which source files a diff adds,
|
|
2
|
+
// edits and keeps, and whether a living spec or one of the change's own delta
|
|
3
|
+
// specs names them.
|
|
4
|
+
//
|
|
5
|
+
// Pure. Spec text and path lists in, a plain structure out: no graph (it is
|
|
6
|
+
// gitignored and absent in CI), no git and no disk. A file is named when a spec
|
|
7
|
+
// mentions its repository-relative path by the one rule the graph's extractor
|
|
8
|
+
// shares (`specMentionedPaths`), compared on the canonical path form, and only
|
|
9
|
+
// coverable source is judged — a test, a configuration file or a document having
|
|
10
|
+
// no spec is not a gap. The gather (`lib/merge-evidence.mjs`) reads the texts;
|
|
11
|
+
// `readSpecCoverage` in `lib/merge-confidence.mjs` judges the facts.
|
|
12
|
+
// design D7 and D8 of complete-and-record-the-merge-verdict.
|
|
13
|
+
|
|
14
|
+
import { isCoverableSource } from './artifacts.mjs'
|
|
15
|
+
import { canonicalizePath } from './risk.mjs'
|
|
16
|
+
import { specMentionedPaths } from './spec-text.mjs'
|
|
17
|
+
|
|
18
|
+
/** The canonical paths a list of `{ path, text }` specs mention. */
|
|
19
|
+
function mentionsOf(specs) {
|
|
20
|
+
const out = new Set()
|
|
21
|
+
for (const spec of Array.isArray(specs) ? specs : []) {
|
|
22
|
+
for (const mentioned of specMentionedPaths(spec?.text)) {
|
|
23
|
+
const canonical = canonicalizePath(mentioned)
|
|
24
|
+
if (canonical) out.add(canonical)
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
return out
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** The canonical, coverable paths of a list, first-seen order, each once. */
|
|
31
|
+
function coverable(paths) {
|
|
32
|
+
const out = []
|
|
33
|
+
const seen = new Set()
|
|
34
|
+
for (const entry of Array.isArray(paths) ? paths : []) {
|
|
35
|
+
const canonical = canonicalizePath(entry)
|
|
36
|
+
if (!canonical || seen.has(canonical) || !isCoverableSource(canonical)) continue
|
|
37
|
+
seen.add(canonical)
|
|
38
|
+
out.push(canonical)
|
|
39
|
+
}
|
|
40
|
+
return out
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* @param {object} input
|
|
45
|
+
* @param {string[]} input.added paths the diff adds (a rename's new path included)
|
|
46
|
+
* @param {string[]} input.modified paths the diff edits
|
|
47
|
+
* @param {string[]} input.sourceFiles every tracked file at the head
|
|
48
|
+
* @param {{path:string,text:string}[]} input.livingAtHead every living spec, as the head has it
|
|
49
|
+
* @param {{path:string,text:string}[]} input.changedSpecsAtBase each living spec the diff changed or deleted, as the base had it
|
|
50
|
+
* @param {{path:string,text:string}[]} input.deltaSpecs the change's own delta specs
|
|
51
|
+
* @returns {{
|
|
52
|
+
* added: { path: string, namedBy: 'living'|'delta'|null }[],
|
|
53
|
+
* lost: { path: string, spec: string }[],
|
|
54
|
+
* legacy: string[],
|
|
55
|
+
* repo: { named: number, total: number }
|
|
56
|
+
* }}
|
|
57
|
+
*/
|
|
58
|
+
export function specCoverageFacts({
|
|
59
|
+
added = [],
|
|
60
|
+
modified = [],
|
|
61
|
+
sourceFiles = [],
|
|
62
|
+
livingAtHead = [],
|
|
63
|
+
changedSpecsAtBase = [],
|
|
64
|
+
deltaSpecs = []
|
|
65
|
+
} = {}) {
|
|
66
|
+
const living = mentionsOf(livingAtHead)
|
|
67
|
+
const delta = mentionsOf(deltaSpecs)
|
|
68
|
+
const named = path => living.has(path) || delta.has(path)
|
|
69
|
+
|
|
70
|
+
const addedFiles = coverable(added)
|
|
71
|
+
const addedSet = new Set(addedFiles)
|
|
72
|
+
|
|
73
|
+
const addedFacts = addedFiles.map(path => ({
|
|
74
|
+
path,
|
|
75
|
+
namedBy: living.has(path) ? 'living' : delta.has(path) ? 'delta' : null
|
|
76
|
+
}))
|
|
77
|
+
|
|
78
|
+
// Lost: a kept coverable file (tracked at the head, not added by this diff)
|
|
79
|
+
// that a spec the diff changed named at the base and that nothing names now.
|
|
80
|
+
const tracked = coverable(sourceFiles)
|
|
81
|
+
const lost = []
|
|
82
|
+
const lostSet = new Set()
|
|
83
|
+
for (const spec of Array.isArray(changedSpecsAtBase) ? changedSpecsAtBase : []) {
|
|
84
|
+
const before = mentionsOf([spec])
|
|
85
|
+
for (const path of tracked) {
|
|
86
|
+
if (addedSet.has(path) || lostSet.has(path)) continue
|
|
87
|
+
if (!before.has(path) || named(path)) continue
|
|
88
|
+
lostSet.add(path)
|
|
89
|
+
lost.push({ path, spec: spec.path })
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
// Legacy: an edited coverable file nothing names that this change did not
|
|
94
|
+
// cause to lose its name.
|
|
95
|
+
const legacy = coverable(modified).filter(
|
|
96
|
+
path => !addedSet.has(path) && !lostSet.has(path) && !named(path)
|
|
97
|
+
)
|
|
98
|
+
|
|
99
|
+
return {
|
|
100
|
+
added: addedFacts,
|
|
101
|
+
lost,
|
|
102
|
+
legacy,
|
|
103
|
+
repo: { named: tracked.filter(named).length, total: tracked.length }
|
|
104
|
+
}
|
|
105
|
+
}
|
|
@@ -0,0 +1,257 @@
|
|
|
1
|
+
// The one parser of living-spec text.
|
|
2
|
+
//
|
|
3
|
+
// Three readers need a spec's `### Requirement:` blocks: the subtraction of a
|
|
4
|
+
// requirement a change supersedes, the check that a finding's citation names a
|
|
5
|
+
// requirement that exists, and the spec audit. Left to themselves each would
|
|
6
|
+
// write its own parser, and they would disagree about what a heading is, where
|
|
7
|
+
// a block ends and what a scenario has — the epic's own hand audits already did.
|
|
8
|
+
// So this module is the only place that knows the shape, and it is pure: text
|
|
9
|
+
// in, a plain structure out. No fs, no clock, no git.
|
|
10
|
+
//
|
|
11
|
+
// `extractScenarios` in lib/ready.mjs keeps its own job (conformance) and is not
|
|
12
|
+
// replaced by this. design D1 of judge-against-base-and-living-specs.
|
|
13
|
+
|
|
14
|
+
/** The delta sections OpenSpec splits a change's spec into. */
|
|
15
|
+
const DELTA_SECTIONS = new Set(['ADDED', 'MODIFIED', 'REMOVED', 'RENAMED'])
|
|
16
|
+
|
|
17
|
+
const REQUIREMENT_HEADING = /^###\s+Requirement:\s*(.*?)\s*$/
|
|
18
|
+
const SCENARIO_HEADING = /^####\s+Scenario:\s*(.*?)\s*$/
|
|
19
|
+
const H2 = /^##\s+(.*?)\s*$/
|
|
20
|
+
const ANY_HEADING = /^#{1,6}\s/
|
|
21
|
+
const FENCE = /^\s*(```|~~~)/
|
|
22
|
+
// Only the bulleted bold keyword line counts. The word in running prose does
|
|
23
|
+
// not, and neither does AND, which continues a line rather than starting one.
|
|
24
|
+
const KEYWORD_LINE = /^\s*[-*]\s+\*\*(GIVEN|WHEN|THEN)\*\*/
|
|
25
|
+
const RENAME_LINE = /^\s*[-*]\s+(FROM|TO):\s*`?\s*(?:###\s+Requirement:\s*)?(.*?)\s*`?\s*$/
|
|
26
|
+
|
|
27
|
+
// Any repo-relative path with an extension. Deliberately NOT restricted to a
|
|
28
|
+
// directory allowlist or an extension allowlist: a caller gates every match on
|
|
29
|
+
// the files it knows, so the repository decides what is real. An allowlist here
|
|
30
|
+
// can only produce false negatives — it used to drop Go/Rust/Java layouts
|
|
31
|
+
// (cmd/, internal/, pkg/, apps/, services/) and indexed config files entirely.
|
|
32
|
+
// The leading boundary class excludes URL path segments, which are always
|
|
33
|
+
// preceded by `/`.
|
|
34
|
+
const PATH_RE = /(?:^|[\s`"'(\[])((?:[\w.@-]+\/)+[\w.@-]+\.[A-Za-z0-9]{1,6})\b/gm
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* The paths a piece of spec text mentions, each once, in first-seen order, with
|
|
38
|
+
* the character index of its first mention. The one rule for "a spec mentions
|
|
39
|
+
* this file": the graph's openspec extractor reads it for its spec-to-file
|
|
40
|
+
* edges (which need the line) and `specMentionedPaths` reads it for the
|
|
41
|
+
* spec-coverage signal, so a fresh graph and the signal cannot disagree.
|
|
42
|
+
*
|
|
43
|
+
* A leading `./` is stripped; a path that climbs out of the repository (`..`)
|
|
44
|
+
* cannot name a repository file and is not returned.
|
|
45
|
+
*
|
|
46
|
+
* @param {unknown} text
|
|
47
|
+
* @returns {{ path: string, index: number }[]}
|
|
48
|
+
*/
|
|
49
|
+
export function specPathMentions(text) {
|
|
50
|
+
if (typeof text !== 'string' || !text) return []
|
|
51
|
+
const seen = new Set()
|
|
52
|
+
const out = []
|
|
53
|
+
const re = new RegExp(PATH_RE.source, PATH_RE.flags)
|
|
54
|
+
let m
|
|
55
|
+
while ((m = re.exec(text))) {
|
|
56
|
+
const path = m[1].replace(/^(\.\/)+/, '')
|
|
57
|
+
if (path.split('/').includes('..')) continue
|
|
58
|
+
if (seen.has(path)) continue
|
|
59
|
+
seen.add(path)
|
|
60
|
+
out.push({ path, index: m.index })
|
|
61
|
+
}
|
|
62
|
+
return out
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* The repository-relative paths a piece of spec text mentions: deduplicated,
|
|
67
|
+
* first-seen order, a leading `./` stripped. See `specPathMentions`.
|
|
68
|
+
*
|
|
69
|
+
* @param {unknown} text
|
|
70
|
+
* @returns {string[]}
|
|
71
|
+
*/
|
|
72
|
+
export function specMentionedPaths(text) {
|
|
73
|
+
return specPathMentions(text).map(mention => mention.path)
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* The canonical form of a requirement heading: strip a leading `Requirement:`,
|
|
78
|
+
* trim, collapse internal whitespace, keep case. OpenSpec itself matches
|
|
79
|
+
* requirements whitespace-insensitively and case-sensitively, so a case
|
|
80
|
+
* difference is a different requirement.
|
|
81
|
+
*
|
|
82
|
+
* @param {string} heading
|
|
83
|
+
* @returns {string}
|
|
84
|
+
*/
|
|
85
|
+
export function requirementKey(heading) {
|
|
86
|
+
return String(heading ?? '')
|
|
87
|
+
.replace(/^\s*Requirement:\s*/, '')
|
|
88
|
+
.replace(/\s+/g, ' ')
|
|
89
|
+
.trim()
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* The capability a spec file belongs to, or null when the path is not a spec.
|
|
94
|
+
* Reads the three spellings callers hold: a living spec
|
|
95
|
+
* (`openspec/specs/<cap>/spec.md`), a change's delta spec
|
|
96
|
+
* (`openspec/changes/<name>/specs/<cap>/spec.md`, or `specs/<cap>/spec.md`) and
|
|
97
|
+
* the form relative to a change's specs directory (`<cap>/spec.md`, which is how
|
|
98
|
+
* `inspectChange(...).specFiles` lists them). Windows separators are read too.
|
|
99
|
+
*
|
|
100
|
+
* @param {string} path
|
|
101
|
+
* @returns {string|null}
|
|
102
|
+
*/
|
|
103
|
+
export function capabilityOf(path) {
|
|
104
|
+
if (typeof path !== 'string' || !path) return null
|
|
105
|
+
const normal = path.replace(/\\/g, '/').replace(/^(\.\/)+/, '')
|
|
106
|
+
if (normal !== 'spec.md' && !normal.endsWith('/spec.md')) return null
|
|
107
|
+
const dir = normal.slice(0, -'spec.md'.length).replace(/\/+$/, '')
|
|
108
|
+
// A spec.md sitting directly in a specs root names no capability.
|
|
109
|
+
if (/(?:^|\/)(?:openspec\/specs|openspec\/changes\/[^/]+\/specs)$/.test(dir) || dir === 'specs') return null
|
|
110
|
+
const rest =
|
|
111
|
+
dir.match(/(?:^|\/)openspec\/changes\/[^/]+\/specs\/(.+)$/)?.[1] ??
|
|
112
|
+
dir.match(/(?:^|\/)openspec\/specs\/(.+)$/)?.[1] ??
|
|
113
|
+
dir.match(/^specs\/(.+)$/)?.[1] ??
|
|
114
|
+
dir
|
|
115
|
+
return rest ? rest : null
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/** Which delta section an `## <NAME> Requirements` heading opens, or null. */
|
|
119
|
+
function deltaSection(h2) {
|
|
120
|
+
const name = h2.trim().split(/\s+/)[0]?.toUpperCase()
|
|
121
|
+
return DELTA_SECTIONS.has(name) ? name : null
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* Parse a spec's text, living or delta.
|
|
126
|
+
*
|
|
127
|
+
* `section` on a requirement is the delta section it sits under, and null in a
|
|
128
|
+
* living spec. `prose` is the block apart from its scenarios; `text` is the
|
|
129
|
+
* whole block, heading and scenarios included, ending where the next heading of
|
|
130
|
+
* the same or a higher level begins. A RENAMED section yields FROM/TO pairs and
|
|
131
|
+
* no requirements.
|
|
132
|
+
*
|
|
133
|
+
* @param {string} text
|
|
134
|
+
* @returns {{
|
|
135
|
+
* purpose: string|null,
|
|
136
|
+
* requirements: Array<{
|
|
137
|
+
* heading: string, key: string, section: string|null, line: number,
|
|
138
|
+
* prose: string, text: string,
|
|
139
|
+
* scenarios: Array<{ title: string, line: number, given: boolean, when: boolean, then: boolean }>
|
|
140
|
+
* }>,
|
|
141
|
+
* renamed: Array<{ from: string, to: string }>
|
|
142
|
+
* }}
|
|
143
|
+
*/
|
|
144
|
+
export function parseSpec(text) {
|
|
145
|
+
const lines = String(text ?? '').split('\n')
|
|
146
|
+
const requirements = []
|
|
147
|
+
const renamed = []
|
|
148
|
+
const purposeLines = []
|
|
149
|
+
|
|
150
|
+
let h2 = null // the current `##` heading's text
|
|
151
|
+
let section = null // the delta section it opens, if any
|
|
152
|
+
let req = null // the requirement being read
|
|
153
|
+
let scenario = null // the scenario being read, inside `req`
|
|
154
|
+
let inFence = false
|
|
155
|
+
let pendingFrom = null
|
|
156
|
+
|
|
157
|
+
const closeRequirement = () => {
|
|
158
|
+
if (req) req.text = req.lines.join('\n').trimEnd()
|
|
159
|
+
if (req) req.prose = req.proseLines.join('\n').trim()
|
|
160
|
+
req = null
|
|
161
|
+
scenario = null
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
for (let i = 0; i < lines.length; i++) {
|
|
165
|
+
const line = lines[i]
|
|
166
|
+
const lineNo = i + 1
|
|
167
|
+
|
|
168
|
+
if (FENCE.test(line)) {
|
|
169
|
+
inFence = !inFence
|
|
170
|
+
if (req) {
|
|
171
|
+
req.lines.push(line)
|
|
172
|
+
if (!scenario) req.proseLines.push(line)
|
|
173
|
+
}
|
|
174
|
+
continue
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
if (!inFence && ANY_HEADING.test(line)) {
|
|
178
|
+
const h2Match = line.match(H2)
|
|
179
|
+
if (h2Match) {
|
|
180
|
+
closeRequirement()
|
|
181
|
+
h2 = h2Match[1]
|
|
182
|
+
section = deltaSection(h2)
|
|
183
|
+
pendingFrom = null
|
|
184
|
+
continue
|
|
185
|
+
}
|
|
186
|
+
const reqMatch = line.match(REQUIREMENT_HEADING)
|
|
187
|
+
if (reqMatch) {
|
|
188
|
+
closeRequirement()
|
|
189
|
+
if (section !== 'RENAMED') {
|
|
190
|
+
const heading = reqMatch[1]
|
|
191
|
+
req = {
|
|
192
|
+
heading,
|
|
193
|
+
key: requirementKey(heading),
|
|
194
|
+
section,
|
|
195
|
+
line: lineNo,
|
|
196
|
+
prose: '',
|
|
197
|
+
text: '',
|
|
198
|
+
scenarios: [],
|
|
199
|
+
lines: [line],
|
|
200
|
+
proseLines: []
|
|
201
|
+
}
|
|
202
|
+
requirements.push(req)
|
|
203
|
+
}
|
|
204
|
+
continue
|
|
205
|
+
}
|
|
206
|
+
const scenarioMatch = line.match(SCENARIO_HEADING)
|
|
207
|
+
if (scenarioMatch && req) {
|
|
208
|
+
scenario = { title: scenarioMatch[1], line: lineNo, given: false, when: false, then: false }
|
|
209
|
+
req.scenarios.push(scenario)
|
|
210
|
+
req.lines.push(line)
|
|
211
|
+
continue
|
|
212
|
+
}
|
|
213
|
+
// A `###` that is not a requirement ends the block it interrupts. A
|
|
214
|
+
// deeper heading that is not a scenario is text inside the block.
|
|
215
|
+
if (req && /^#{4,}\s/.test(line)) {
|
|
216
|
+
req.lines.push(line)
|
|
217
|
+
if (!scenario) req.proseLines.push(line)
|
|
218
|
+
continue
|
|
219
|
+
}
|
|
220
|
+
if (req) closeRequirement()
|
|
221
|
+
continue
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
if (req) {
|
|
225
|
+
req.lines.push(line)
|
|
226
|
+
if (scenario) {
|
|
227
|
+
const kw = line.match(KEYWORD_LINE)
|
|
228
|
+
if (kw) scenario[kw[1].toLowerCase()] = true
|
|
229
|
+
} else {
|
|
230
|
+
req.proseLines.push(line)
|
|
231
|
+
}
|
|
232
|
+
continue
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
if (section === 'RENAMED') {
|
|
236
|
+
const m = line.match(RENAME_LINE)
|
|
237
|
+
if (m) {
|
|
238
|
+
if (m[1] === 'FROM') pendingFrom = m[2]
|
|
239
|
+
else if (pendingFrom !== null) {
|
|
240
|
+
renamed.push({ from: requirementKey(pendingFrom), to: requirementKey(m[2]) })
|
|
241
|
+
pendingFrom = null
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
continue
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
if (h2 !== null && /^purpose$/i.test(h2.trim())) purposeLines.push(line)
|
|
248
|
+
}
|
|
249
|
+
closeRequirement()
|
|
250
|
+
|
|
251
|
+
const purpose = purposeLines.join('\n').trim()
|
|
252
|
+
return {
|
|
253
|
+
purpose: purpose ? purpose : null,
|
|
254
|
+
requirements: requirements.map(({ lines: _lines, proseLines: _proseLines, ...r }) => r),
|
|
255
|
+
renamed
|
|
256
|
+
}
|
|
257
|
+
}
|
package/lib/verify.mjs
CHANGED
|
@@ -829,8 +829,13 @@ export function judgeCoverageResult(result, opts = {}) {
|
|
|
829
829
|
* quietly taking the permissive branch on a typo is how a hard halt goes
|
|
830
830
|
* missing.
|
|
831
831
|
* @returns {{halt: boolean, reason: string|null, context: string, haltingKinds: string[],
|
|
832
|
-
* unit: object|null, e2e: object|null, coverage: object|null,
|
|
833
|
-
*
|
|
832
|
+
* unit: object|null, e2e: object|null, coverage: object|null,
|
|
833
|
+
* typecheck: {ran: boolean, exitCode: number|null, passed: boolean}|null,
|
|
834
|
+
* signals: Array, banners: string[], skipped: Array}}
|
|
835
|
+
* `typecheck` is the type check's own result in every context — `null` when
|
|
836
|
+
* no type-check result was reported, so a pass reads differently from a
|
|
837
|
+
* result that never arrived. It moves no halting set: a failure at the final
|
|
838
|
+
* gate is still the non-blocking banner.
|
|
834
839
|
*/
|
|
835
840
|
export function judgeVerification(plan, results = [], opts = {}) {
|
|
836
841
|
// Both halves matter: `skipped` is iterated below, so a hand-written plan
|
|
@@ -891,16 +896,23 @@ export function judgeVerification(plan, results = [], opts = {}) {
|
|
|
891
896
|
banners.push(text)
|
|
892
897
|
}
|
|
893
898
|
|
|
899
|
+
// The type check's own result, recorded whether it passed or failed and in
|
|
900
|
+
// every context, so a reader can tell "passed" from "never reported" without
|
|
901
|
+
// parsing a banner. `null` only when no type-check result was reported. It
|
|
902
|
+
// decides nothing here: whether a failure halts is still `record`'s call.
|
|
903
|
+
let typecheckResult = null
|
|
894
904
|
const typecheck = byKind.get('typecheck')
|
|
895
905
|
if (typecheck) {
|
|
896
906
|
validateResult(typecheck, 'typecheck')
|
|
897
|
-
const
|
|
898
|
-
const
|
|
907
|
+
const ran = typecheck.ran !== false
|
|
908
|
+
const ok = ran && Number(typecheck.exitCode) === 0
|
|
909
|
+
const detail = !ran
|
|
899
910
|
? trimmedString(typecheck.reason) || 'the typecheck command could not be executed'
|
|
900
911
|
: `exit ${Number(typecheck.exitCode)}`
|
|
901
912
|
record('typecheck', !ok, `typecheck failed (${detail})`, {
|
|
902
913
|
banner: BANNERS.typecheckFailed(detail)
|
|
903
914
|
})
|
|
915
|
+
typecheckResult = { ran, exitCode: ran ? Number(typecheck.exitCode) : null, passed: ok }
|
|
904
916
|
}
|
|
905
917
|
|
|
906
918
|
let unit = null
|
|
@@ -937,6 +949,7 @@ export function judgeVerification(plan, results = [], opts = {}) {
|
|
|
937
949
|
unit,
|
|
938
950
|
e2e,
|
|
939
951
|
coverage,
|
|
952
|
+
typecheck: typecheckResult,
|
|
940
953
|
signals,
|
|
941
954
|
banners,
|
|
942
955
|
skipped: plan.skipped
|
package/package.json
CHANGED
package/shared/SESSION-SHAPES.md
CHANGED
|
@@ -133,7 +133,7 @@ is narrow. Not running tests after the fix.
|
|
|
133
133
|
5. Resumable — a stopped run picks up where it left off
|
|
134
134
|
|
|
135
135
|
**Flow (review):**
|
|
136
|
-
1. `/interlock:review-code` fans out up to
|
|
136
|
+
1. `/interlock:review-code` fans out up to seven dimension reviewers (language, architecture, QA, delivery; devops and security when the diff earns them; domain when a touched capability has a living spec)
|
|
137
137
|
2. Two skeptics try to refute every blocker and warning; a dismissal must cite a `file:line` span
|
|
138
138
|
3. Only surviving findings reach you; the report states how many were dismissed
|
|
139
139
|
|