spexcode 0.3.0 → 0.4.1

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.
Files changed (59) hide show
  1. package/package.json +1 -1
  2. package/spec-cli/src/anchors.ts +341 -0
  3. package/spec-cli/src/cli.ts +20 -20
  4. package/spec-cli/src/gateway.ts +8 -1
  5. package/spec-cli/src/git.ts +21 -13
  6. package/spec-cli/src/graph.ts +13 -7
  7. package/spec-cli/src/guide.ts +63 -15
  8. package/spec-cli/src/harness.ts +85 -34
  9. package/spec-cli/src/help.ts +33 -17
  10. package/spec-cli/src/index.ts +39 -7
  11. package/spec-cli/src/init.ts +5 -4
  12. package/spec-cli/src/lint.ts +103 -28
  13. package/spec-cli/src/localIssues.ts +19 -0
  14. package/spec-cli/src/migrate-table.ts +27 -16
  15. package/spec-cli/src/search.bench.mjs +2 -2
  16. package/spec-cli/src/session-timeline.ts +148 -0
  17. package/spec-cli/src/sessions.ts +29 -4
  18. package/spec-cli/src/specs.ts +44 -21
  19. package/spec-cli/templates/hooks/prepare-commit-msg +6 -1
  20. package/spec-cli/templates/spec/project/.plugins/{extract → commands/extract}/spec.md +1 -1
  21. package/spec-cli/templates/spec/project/.plugins/commands/spec.md +16 -0
  22. package/spec-cli/templates/spec/project/.plugins/commands/supervisor/spec.md +8 -0
  23. package/spec-cli/templates/spec/project/.plugins/core/spec.md +1 -1
  24. package/spec-cli/templates/spec/project/.plugins/prompts/spec.md +20 -0
  25. package/spec-cli/templates/spec/project/.plugins/skills/spec.md +17 -0
  26. package/spec-cli/templates/spec/project/.plugins/spec.md +5 -2
  27. package/spec-cli/templates/spec/project/spec.md +1 -1
  28. package/spec-dashboard/dist/assets/{Dashboard-C7Bzsv86.js → Dashboard-C_fGmOKK.js} +3 -3
  29. package/spec-dashboard/dist/assets/EvalsPage-Cnr1s3bq.js +2 -0
  30. package/spec-dashboard/dist/assets/{FoldToggle-D5iB4Ac2.js → FoldToggle-x9gtO1OQ.js} +1 -1
  31. package/spec-dashboard/dist/assets/{IssuesPage-CMFTsQhg.js → IssuesPage-5f_vL-JV.js} +1 -1
  32. package/spec-dashboard/dist/assets/MobileApp-DEO1jgGM.js +1 -0
  33. package/spec-dashboard/dist/assets/SessionInterface-CAlbMOFR.js +66 -0
  34. package/spec-dashboard/dist/assets/SessionWindow-JYbpPwNB.js +13 -0
  35. package/spec-dashboard/dist/assets/{Settings-BW5f0OaW.js → Settings-DKb5Ji_X.js} +1 -1
  36. package/spec-dashboard/dist/assets/index-BQu-oJ8J.js +41 -0
  37. package/spec-dashboard/dist/assets/index-BbMkwuix.css +1 -0
  38. package/spec-dashboard/dist/assets/launch-BM9GgvkX.js +6 -0
  39. package/spec-dashboard/dist/index.html +2 -2
  40. package/spec-eval/src/cli.ts +82 -21
  41. package/spec-eval/src/evaltab.ts +15 -6
  42. package/spec-eval/src/humanok.ts +43 -0
  43. package/spec-eval/src/scenarios.ts +116 -4
  44. package/spec-eval/src/sidecar.ts +35 -9
  45. package/spec-cli/templates/presets/careful/.plugins/clarify-before-code/spec.md +0 -11
  46. package/spec-cli/templates/spec/project/.plugins/supervisor/spec.md +0 -8
  47. package/spec-dashboard/dist/assets/EvalsPage-DKZZIdHq.js +0 -2
  48. package/spec-dashboard/dist/assets/MobileApp-DwuTKgdP.js +0 -1
  49. package/spec-dashboard/dist/assets/SessionInterface-CBS5_cmK.js +0 -71
  50. package/spec-dashboard/dist/assets/SessionWindow-CqAnjWfI.js +0 -9
  51. package/spec-dashboard/dist/assets/index-Cc26X4ce.css +0 -1
  52. package/spec-dashboard/dist/assets/index-Ce0wDyQS.js +0 -41
  53. /package/spec-cli/templates/spec/project/.plugins/{regroup → commands/regroup}/spec.md +0 -0
  54. /package/spec-cli/templates/spec/project/.plugins/{tidy → commands/tidy}/spec.md +0 -0
  55. /package/spec-cli/templates/spec/project/.plugins/{forge-link → prompts/forge-link}/spec.md +0 -0
  56. /package/spec-cli/templates/spec/project/.plugins/{memory-hygiene → prompts/memory-hygiene}/spec.md +0 -0
  57. /package/spec-cli/templates/spec/project/.plugins/{reproduce-before-fix → prompts/reproduce-before-fix}/spec.md +0 -0
  58. /package/spec-cli/templates/spec/project/.plugins/{distill → skills/distill}/digest.mjs +0 -0
  59. /package/spec-cli/templates/spec/project/.plugins/{distill → skills/distill}/spec.md +0 -0
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "spexcode",
3
- "version": "0.3.0",
3
+ "version": "0.4.1",
4
4
  "type": "module",
5
5
  "description": "SpexCode — a spec-driven, self-developing dev tool. The `spex` CLI + spec server reads the .spec tree and its git history, and serves the dashboard.",
6
6
  "license": "MIT",
@@ -0,0 +1,341 @@
1
+ import { join } from 'node:path'
2
+ import { createRequire } from 'node:module'
3
+ import { gitA, type DriftIndex, ancestorsOf, inAncestors, ackCoverFor } from './git.js'
4
+
5
+ // ---- the anchor vocabulary ([[code-anchor]]) ----
6
+ // A spec's `code:` entry may pin ONE named unit: `path#symbol` (`#Class.method` for a class method).
7
+ // Everything below the entry parse splits into two layers:
8
+ // - the LANGUAGE SEAM: pure extractors (content, filename) -> Unit[] — no git, no cache, no fs.
9
+ // Each extension maps to exactly ONE designated extractor; there is NO cross-tier fallback.
10
+ // - the LANGUAGE-AGNOSTIC ENGINE: blob-oid memo, anchor resolution (dead/ambiguous), diff-hunk ∩
11
+ // unit-range intersection over the drift window. It never knows which language it is measuring.
12
+
13
+ export type Unit = { name: string; kind: string; start: number; end: number; typeOnly?: boolean }
14
+
15
+ export type Extractor = {
16
+ id: string
17
+ claims(ext: string): boolean
18
+ // true = usable here. A string is WHY it cannot run — lint turns that into an ERROR with the repair
19
+ // entrypoint (never a silent or degraded pass): the designated extractor either runs or the anchor
20
+ // is unverifiable.
21
+ ready(): true | string
22
+ // PURE function of its arguments (importable by an external benchmark/scorer as-is). Throws when the
23
+ // content cannot be parsed — the caller maps that to a conservative verdict, never a silent skip.
24
+ extract(content: string, filename: string): Unit[]
25
+ }
26
+
27
+ export type CodeEntry = { path: string; anchor: string | null }
28
+ export function parseCodeEntry(raw: string): CodeEntry {
29
+ const i = raw.indexOf('#')
30
+ if (i < 0) return { path: raw.trim(), anchor: null }
31
+ return { path: raw.slice(0, i).trim(), anchor: raw.slice(i + 1).trim() || null }
32
+ }
33
+
34
+ // ---- relation parsing: ONE structured path+selector grammar for code: AND related: ----
35
+ // A relation's raw rows group per base path: a row is bare (`path`, whole-file — today's semantics,
36
+ // unchanged) or scoped (`path#symbol`), and any number of scoped rows on the SAME base file fold into
37
+ // one entry whose selectors are OR'd (a commit hitting any counts once; no selector-count cap — the
38
+ // benchmark roster's 1–3 was an annotation rubric, never product syntax). STRUCTURAL verdicts live
39
+ // here, pure and loud: an exact duplicate row, mixing bare with selectors on one base path, and a
40
+ // selector on a glob are all `problems` the caller turns into integrity errors. Filesystem/git
41
+ // verdicts (existence, directories, dead/ambiguous units, extractor readiness) stay the caller's —
42
+ // this parser never touches fs.
43
+ export type RelationEntry = { path: string; selectors: string[] }
44
+ export type RelationParse = { entries: RelationEntry[]; problems: string[] }
45
+ export function parseRelation(raws: string[], relation: 'code' | 'related'): RelationParse {
46
+ const order: string[] = []
47
+ const byPath = new Map<string, { bare: boolean; selectors: string[] }>()
48
+ const problems: string[] = []
49
+ for (const raw of raws) {
50
+ const { path, anchor } = parseCodeEntry(raw)
51
+ let e = byPath.get(path)
52
+ if (!e) { e = { bare: false, selectors: [] }; byPath.set(path, e); order.push(path) }
53
+ if (anchor === null) {
54
+ if (e.bare) problems.push(`${relation}: lists '${path}' twice — drop the duplicate entry`)
55
+ e.bare = true
56
+ } else if (e.selectors.includes(anchor)) {
57
+ problems.push(`${relation}: lists selector '${path}#${anchor}' twice — drop the duplicate`)
58
+ } else e.selectors.push(anchor)
59
+ }
60
+ for (const path of order) {
61
+ const e = byPath.get(path)!
62
+ if (e.bare && e.selectors.length)
63
+ problems.push(`${relation}: mixes bare '${path}' with '${path}#…' selectors — one base path is either whole-file or selector-scoped, never both; drop one form`)
64
+ if (e.selectors.length && path.includes('*'))
65
+ problems.push(`${relation}: '${path}#${e.selectors[0]}' puts a selector on a glob — a selector scopes ONE real file`)
66
+ }
67
+ return { entries: order.map((p) => ({ path: p, selectors: byPath.get(p)!.selectors })), problems }
68
+ }
69
+
70
+ // ---- extractor: ts-ast (the designated extractor for the JS family) ----
71
+ // Parse-only via the HOST project's own typescript (resolved from the repo root, walking up like any
72
+ // require) — never a bundled copy, so the parse matches what the project itself compiles with. Not
73
+ // resolvable => ready() returns the repair entrypoint and lint ERRORS (no regex fallback for JS).
74
+ const JS_EXTS = new Set(['ts', 'tsx', 'js', 'jsx', 'mjs', 'cjs', 'mts', 'cts'])
75
+
76
+ export function tsAstExtractor(root: string): Extractor {
77
+ let ts: any | null | undefined // undefined = unprobed; null = unresolvable
78
+ let readiness: true | string | undefined
79
+ const probe = () => {
80
+ if (ts !== undefined) return
81
+ try { ts = createRequire(join(root, 'package.json'))('typescript') } catch { ts = null }
82
+ }
83
+ return {
84
+ id: 'ts-ast',
85
+ claims: (ext) => JS_EXTS.has(ext),
86
+ ready() {
87
+ if (readiness !== undefined) return readiness
88
+ probe()
89
+ if (!ts) return (readiness = `typescript is not resolvable from ${root} — anchors on JS-family files need the host project's typescript: run 'npm i -D typescript', or remove the #anchor`)
90
+ // resolvability is not usability: typescript@7 (the Go rewrite) may resolve yet not expose the JS
91
+ // compiler API this extractor drives. Probe the ACTUAL surface with a tiny parse — an incompatible
92
+ // host typescript is a loud error, never a silent pass or downgrade.
93
+ try {
94
+ const sf = ts.createSourceFile('probe.ts', 'const x = 1', ts.ScriptTarget.Latest, true, ts.ScriptKind.TS)
95
+ if (!sf?.statements?.length || sf.parseDiagnostics?.length) throw new Error('probe parse failed')
96
+ readiness = true
97
+ } catch {
98
+ readiness = `host typescript (v${ts?.version ?? 'unknown'}) resolves but its createSourceFile API is unusable (a TS7/Go build?) — pin 'npm i -D typescript@5', or remove the #anchor`
99
+ }
100
+ return readiness
101
+ },
102
+ extract(content, filename) {
103
+ probe()
104
+ if (!ts) throw new Error('ts-ast extractor is not ready (typescript unresolvable)')
105
+ const kind = /\.(tsx)$/.test(filename) ? ts.ScriptKind.TSX
106
+ : /\.(jsx)$/.test(filename) ? ts.ScriptKind.JSX
107
+ : /\.(ts|mts|cts)$/.test(filename) ? ts.ScriptKind.TS
108
+ : ts.ScriptKind.JS
109
+ const sf = ts.createSourceFile(filename, content, ts.ScriptTarget.Latest, true, kind)
110
+ // parse-only gate: a file that does not parse yields GARBAGE units (a shell script's `x=$(...)`
111
+ // parses as a const) — throw so the caller renders an honest "cannot parse" verdict instead.
112
+ if (sf.parseDiagnostics?.length) throw new Error(`${filename} does not parse as ${ts.ScriptKind[kind]} (${sf.parseDiagnostics.length} syntax error(s))`)
113
+ const line = (pos: number) => sf.getLineAndCharacterOfPosition(pos).line + 1
114
+ const units: Unit[] = []
115
+ const push = (name: string, ukind: string, node: any, typeOnly = false) =>
116
+ units.push({ name, kind: ukind, start: line(node.getStart(sf)), end: line(node.end), ...(typeOnly ? { typeOnly } : {}) })
117
+ for (const st of sf.statements) {
118
+ if (ts.isFunctionDeclaration(st)) push(st.name ? st.name.text : '(default)', 'function', st)
119
+ else if (ts.isClassDeclaration(st)) {
120
+ const cname = st.name ? st.name.text : '(default)'
121
+ push(cname, 'class', st)
122
+ for (const m of st.members) {
123
+ if ((ts.isMethodDeclaration(m) || ts.isConstructorDeclaration(m) || ts.isGetAccessorDeclaration(m) || ts.isSetAccessorDeclaration(m)) && m.body) {
124
+ const mname = ts.isConstructorDeclaration(m) ? 'constructor' : (m.name && ts.isIdentifier(m.name) ? m.name.text : '(computed)')
125
+ push(`${cname}.${mname}`, 'method', m)
126
+ }
127
+ }
128
+ } else if (ts.isVariableStatement(st)) {
129
+ for (const d of st.declarationList.declarations) {
130
+ if (!ts.isIdentifier(d.name)) continue // destructuring — not anchorable by one name
131
+ const fn = d.initializer && (ts.isArrowFunction(d.initializer) || ts.isFunctionExpression(d.initializer))
132
+ // range = the whole statement (multi-declarator lines co-move; each name shares the range)
133
+ units.push({ name: d.name.text, kind: fn ? 'const-fn' : 'const-data', start: line(st.getStart(sf)), end: line(st.end) })
134
+ }
135
+ } else if (ts.isEnumDeclaration(st)) push(st.name.text, 'enum', st)
136
+ else if (ts.isInterfaceDeclaration(st)) push(st.name.text, 'interface', st, true)
137
+ else if (ts.isTypeAliasDeclaration(st)) push(st.name.text, 'type', st, true)
138
+ }
139
+ return units
140
+ },
141
+ }
142
+ }
143
+
144
+ // ---- extractor: heuristic(langSpec) — a generic regex engine fed LANGUAGE DATA, not language branches ----
145
+ // The designated extractor for languages described by a LangSpec row; adding a language = adding a data
146
+ // row + a registry entry (never a new engine). The JS family is deliberately NOT routed here (its
147
+ // designated extractor is ts-ast above); JS_LANG_R5B below exists as the validated reference row for the
148
+ // engine's shape and for the external benchmark to score.
149
+ export type LangSpec = {
150
+ id: string
151
+ extensions: string[]
152
+ // column-0 declaration patterns; capture group 1 = the unit name (or the declarator list when declList)
153
+ decls: { re: RegExp; kind: string; typeOnly?: boolean; classOpener?: boolean; declList?: boolean }[]
154
+ // class-member pattern, active while inside a classOpener's balanced-bracket body (name -> Class.name)
155
+ member?: { re: RegExp; blacklist?: RegExp }
156
+ // a column-0 line matching this ENDS the previous unit (comment-aware so trailing comment blocks
157
+ // attach to the NEXT unit, not the previous one)
158
+ boundary: RegExp
159
+ }
160
+
161
+ const balance = (s: string) => { let n = 0; for (const ch of s) { if ('([{'.includes(ch)) n++; else if (')]}'.includes(ch)) n-- } return n }
162
+ // split a declarator-list head on top-level commas: `COLS = 220, ROWS = 50` -> [COLS, ROWS]
163
+ function declNames(head: string): string[] {
164
+ let d = 0, seg = ''
165
+ const segs: string[] = []
166
+ for (const ch of head) {
167
+ if ('([{<'.includes(ch)) d++
168
+ else if (')]}>'.includes(ch)) d--
169
+ if (ch === ',' && d === 0) { segs.push(seg); seg = '' } else seg += ch
170
+ }
171
+ segs.push(seg)
172
+ return segs.map((s) => s.match(/^\s*([A-Za-z_$][\w$]*)\s*(?::|=|$)/)?.[1]).filter((x): x is string => !!x)
173
+ }
174
+
175
+ export function heuristicExtractor(spec: LangSpec): Extractor {
176
+ return {
177
+ id: spec.id,
178
+ claims: (ext) => spec.extensions.includes(ext),
179
+ ready: () => true,
180
+ extract(content) {
181
+ const lines = content.split('\n')
182
+ const units: Unit[] = []
183
+ let cls: string | null = null, depth = 0
184
+ for (let i = 0; i < lines.length; i++) {
185
+ const l = lines[i]
186
+ if (cls) {
187
+ const m = spec.member && l.match(spec.member.re)
188
+ if (m && !spec.member!.blacklist?.test(m[1])) units.push({ name: `${cls}.${m[1]}`, kind: 'method', start: i + 1, end: i + 1 })
189
+ depth += balance(l)
190
+ if (depth <= 0) cls = null
191
+ continue
192
+ }
193
+ for (const d of spec.decls) {
194
+ const m = l.match(d.re)
195
+ if (!m) continue
196
+ for (const name of d.declList ? declNames(m[1]) : [m[1]])
197
+ units.push({ name, kind: d.kind, start: i + 1, end: i + 1, ...(d.typeOnly ? { typeOnly: true } : {}) })
198
+ if (d.classOpener) { cls = m[1]; depth = balance(l) }
199
+ break
200
+ }
201
+ }
202
+ // R5b ranges: a unit ends before the next column-0 boundary line; a method is also capped by the
203
+ // next unit's start (methods sit inside their class's indentation, below boundary's radar).
204
+ const bset: number[] = []
205
+ for (let i = 0; i < lines.length; i++) if (spec.boundary.test(lines[i])) bset.push(i + 1)
206
+ const starts = units.map((u) => u.start).sort((a, b) => a - b)
207
+ for (const u of units) {
208
+ const nb = bset.find((b) => b > u.start)
209
+ let end = nb ?? lines.length + 1
210
+ if (u.kind === 'method') { const ns = starts.find((x) => x > u.start); if (ns && ns < end) end = ns }
211
+ u.end = Math.max(u.start, end - 1)
212
+ }
213
+ return units
214
+ },
215
+ }
216
+ }
217
+
218
+ // The validated JS-family reference row (R5b: name precision 99.7% / recall 100% / range 98.9% on the
219
+ // 41-file oracle) — NOT registered for JS (ts-ast is designated); kept as the engine's reference shape
220
+ // and the benchmark's scoring subject.
221
+ export const JS_LANG_R5B: LangSpec = {
222
+ id: 'heuristic-js',
223
+ extensions: [...JS_EXTS],
224
+ decls: [
225
+ { re: /^(?:export\s+)?(?:default\s+)?(?:async\s+)?function\*?\s+([A-Za-z_$][\w$]*)/, kind: 'function' },
226
+ { re: /^(?:export\s+)?(?:abstract\s+)?class\s+([A-Za-z_$][\w$]*)/, kind: 'class', classOpener: true },
227
+ { re: /^(?:export\s+)?(?:declare\s+)?enum\s+([A-Za-z_$][\w$]*)/, kind: 'enum' },
228
+ { re: /^(?:export\s+)?(?:declare\s+)?interface\s+([A-Za-z_$][\w$]*)/, kind: 'interface', typeOnly: true },
229
+ { re: /^(?:export\s+)?(?:declare\s+)?type\s+([A-Za-z_$][\w$]*)/, kind: 'type', typeOnly: true },
230
+ { re: /^(?:export\s+)?(?:const|let|var)\s+(.+)$/, kind: 'const', declList: true },
231
+ ],
232
+ member: {
233
+ re: /^\s+(?:(?:public|private|protected|static|readonly|async|get|set)\s+)*([A-Za-z_$][\w$]*)\s*(?:<[^>]*>)?\(/,
234
+ blacklist: /^(if|for|while|switch|return|catch|new|await|typeof|throw|else|do)$/,
235
+ },
236
+ boundary: /^(?:[A-Za-z_$]|\/\/|\/\*)/,
237
+ }
238
+
239
+ // ---- registry: extension -> its ONE designated extractor ----
240
+ // The registry's shape is the Extractor INTERFACE, not any engine: a future language row may be a
241
+ // heuristicExtractor(LangSpec) or a web-tree-sitter extractor carrying its own wasm-grammar/query
242
+ // config — whatever the implementation needs rides inside its own factory, never in the registry.
243
+ export function extractors(root: string): Extractor[] {
244
+ return [tsAstExtractor(root)]
245
+ }
246
+ // first claiming extractor IS the designation (the registry order defines it); null = no anchor support
247
+ // for this language yet (lint ERRORS — the remedy is a LangSpec data row, or dropping the anchor).
248
+ export function extractorFor(regs: Extractor[], ext: string): Extractor | null {
249
+ return regs.find((x) => x.claims(ext)) ?? null
250
+ }
251
+ export const extOf = (path: string): string => {
252
+ const base = path.slice(path.lastIndexOf('/') + 1)
253
+ const dot = base.lastIndexOf('.')
254
+ return dot > 0 ? base.slice(dot + 1) : ''
255
+ }
256
+
257
+ // ---- anchor resolution (language-agnostic) ----
258
+ export type AnchorResolution = { ok: Unit } | { dead: true } | { ambiguous: number }
259
+ export function resolveAnchor(units: Unit[], symbol: string): AnchorResolution {
260
+ const hits = units.filter((u) => u.name === symbol)
261
+ if (!hits.length) return { dead: true }
262
+ if (hits.length > 1) return { ambiguous: hits.length }
263
+ return { ok: hits[0] }
264
+ }
265
+
266
+ // ---- the historical hit engine (language-agnostic; batch short-lived git, no resident process) ----
267
+
268
+ // units of a file AS OF a commit, memoized by (blob oid, extractor id) — a blob is immutable, so the
269
+ // memo never invalidates; distinct file versions in a window are few. 'absent' = no blob at that commit;
270
+ // 'unparseable' = the extractor rejected that version's content (the caller treats it conservatively).
271
+ type BlobUnits = { units: Unit[] } | { absent: true } | { unparseable: string }
272
+ const unitMemo = new Map<string, BlobUnits>()
273
+ const MEMO_MAX = 4096
274
+ async function unitsAt(root: string, commit: string, path: string, x: Extractor): Promise<BlobUnits> {
275
+ const oid = (await gitA(['-C', root, 'rev-parse', `${commit}:${path}`])).trim()
276
+ if (!oid) return { absent: true }
277
+ const key = `${oid}\0${x.id}`
278
+ const hit = unitMemo.get(key)
279
+ if (hit) return hit
280
+ const text = await gitA(['-C', root, 'cat-file', 'blob', oid])
281
+ let v: BlobUnits
282
+ try { v = { units: x.extract(text, path) } } catch (e: any) { v = { unparseable: e?.message ?? String(e) } }
283
+ if (unitMemo.size >= MEMO_MAX) unitMemo.clear()
284
+ unitMemo.set(key, v)
285
+ return v
286
+ }
287
+
288
+ // post-image line ranges of one commit's diff to one file (`@@ -a,b +c,d @@`, --unified=0). d>0 → lines
289
+ // c..c+d-1 changed; d==0 (pure deletion) → the point line after which content vanished. Immutable per
290
+ // (commit, file), memoized.
291
+ const hunkMemo = new Map<string, [number, number][]>()
292
+ async function hunksAt(root: string, commit: string, path: string): Promise<[number, number][]> {
293
+ const key = `${commit}\0${path}`
294
+ const hit = hunkMemo.get(key)
295
+ if (hit) return hit
296
+ const out = await gitA(['-C', root, '-c', 'core.quotePath=false', 'show', '--unified=0', '--format=', commit, '--', path])
297
+ const ranges: [number, number][] = []
298
+ for (const m of out.matchAll(/^@@ -\d+(?:,\d+)? \+(\d+)(?:,(\d+))? @@/gm)) {
299
+ const c = +m[1], d = m[2] === undefined ? 1 : +m[2]
300
+ ranges.push(d > 0 ? [c, c + d - 1] : [Math.max(1, c), Math.max(1, c)])
301
+ }
302
+ if (hunkMemo.size >= MEMO_MAX) hunkMemo.clear()
303
+ hunkMemo.set(key, ranges)
304
+ return ranges
305
+ }
306
+
307
+ // the drift window of an anchored file: every commit to `path` not reachable from the spec's version
308
+ // and not covered by a valid Spec-OK ack — the SAME set driftFor counts, exposed as commits so the
309
+ // anchor engine can probe each one. (fileCommits comes from a --name-only walk, which lists no files
310
+ // for merge commits — the window is non-merge by construction.)
311
+ export function windowCommits(idx: DriftIndex, sinceHash: string, path: string): string[] {
312
+ if (!sinceHash) return []
313
+ const base = ancestorsOf(idx, sinceHash)
314
+ if (!base) return []
315
+ const cover = ackCoverFor(idx, sinceHash)
316
+ return (idx.fileCommits.get(path) ?? []).filter((h) => !inAncestors(idx, base, h) && !cover.some((a) => inAncestors(idx, a, h)))
317
+ }
318
+
319
+ // which window commits TOUCHED any of the anchored units: the commit's --unified=0 hunks intersect a
320
+ // unit's line range extracted from the file AS IT EXISTED AT THAT COMMIT (never from HEAD — units later
321
+ // renamed or moved still attribute correctly). Several selectors are OR — a commit appears ONCE, with
322
+ // `selectors` naming exactly which units its hunks intersected (so diagnostics can attribute the hit).
323
+ // A version whose content the extractor cannot parse is a CONSERVATIVE hit for every selector
324
+ // (`unparseable` set) — over-warn, never silently skip.
325
+ export type AnchorHit = { commit: string; selectors: string[]; unparseable?: string }
326
+ export async function anchorHitCommits(root: string, win: string[], path: string, symbols: string[], x: Extractor): Promise<AnchorHit[]> {
327
+ const hits: AnchorHit[] = []
328
+ for (const c of win) {
329
+ const at = await unitsAt(root, c, path, x)
330
+ if ('absent' in at) continue // file not in that commit's tree — nothing of the anchor to touch
331
+ if ('unparseable' in at) { hits.push({ commit: c, selectors: [...symbols], unparseable: at.unparseable }); continue }
332
+ const bySym = symbols
333
+ .map((sym) => ({ sym, ranges: at.units.filter((u) => u.name === sym) }))
334
+ .filter((s) => s.ranges.length) // a unit absent under this name at that commit can't be touched
335
+ if (!bySym.length) continue
336
+ const hunks = await hunksAt(root, c, path)
337
+ const touched = bySym.filter((s) => hunks.some(([a, b]) => s.ranges.some((u) => a <= u.end && u.start <= b))).map((s) => s.sym)
338
+ if (touched.length) hits.push({ commit: c, selectors: touched })
339
+ }
340
+ return hits
341
+ }
@@ -404,7 +404,11 @@ if (cmd === 'serve') {
404
404
  const owners = specOwners(p)
405
405
  const related = specRelated(p)
406
406
  const maxOwners = loadConfig(process.cwd()).maxOwners
407
- const names = (xs: { id: string }[]) => xs.map((o) => `'${o.id}'`).join(', ')
407
+ // a selector-SCOPED governor (every claiming code: entry carries `#symbol` [[code-anchor]]) still
408
+ // DISPLAYS, marked, but does not count toward the too-many-owners bound: it claims named units, not
409
+ // the whole file.
410
+ const whole = owners.filter((o) => !o.scoped)
411
+ const names = (xs: { id: string; scoped?: boolean }[]) => xs.map((o) => `'${o.id}'${o.scoped ? ' (scoped)' : ''}`).join(', ')
408
412
  const relLine = related.length ? `\n also referenced by ${names(related)} (related: coverage only — no drift, no eval freshness)` : ''
409
413
  if (owners.length === 0 && related.length === 0) {
410
414
  console.log(`${rel} — no spec claims this yet (uncovered). If your change is substantive, give it a home before it drifts.`)
@@ -413,32 +417,28 @@ if (cmd === 'serve') {
413
417
  // but a human asking gets the honest nuance: nothing tracks this file's drift.
414
418
  if (has('actionable')) process.exit(0)
415
419
  console.log(`${rel} — not governed (no code: claim), but referenced by ${names(related)} (related: coverage only). Nothing tracks its drift; if your change is substantive, consider giving it a governing home.`)
416
- } else if (owners.length <= maxOwners) {
420
+ } else if (whole.length <= maxOwners) {
417
421
  // a sanely-owned file is NOT actionable: --actionable callers (the per-edit spec-of-file hook) stay
418
422
  // silent here, so the annotation fires only on an OVER-owned or uncovered file — rare and worth acting on.
419
423
  if (has('actionable')) process.exit(0)
420
- const named = owners.map((o) => `'${o.id}'`).join(', ')
424
+ const named = names(owners)
421
425
  const lead = owners.length === 1 ? `${rel} is governed by ${named} — ${owners[0].desc}` : `${rel} is governed by ${named} (shared, fine).`
422
426
  console.log(`${lead} Read/honor the spec; if your change shifts the intent, update the spec in the SAME commit.${relLine}`)
423
427
  } else {
424
- const ids = owners.map((o) => o.id).join(', ')
425
- console.log(`${rel} is governed by ${owners.length} specs (${ids}) — more than one file should hold. This file does TOO MUCH: SPLIT it so each governor owns its own module (or merge the nodes if they're one concern, or give it a single foundation owner + relate the rest).${relLine}`)
428
+ const ids = names(owners)
429
+ console.log(`${rel} is governed whole-file by ${whole.length} specs (all claims: ${ids}) — more than one file should hold. This file does TOO MUCH: SPLIT it so each governor owns its own module (or merge the nodes if they're one concern, or give it a single foundation owner + relate the rest).${relLine}`)
426
430
  }
427
431
  } else if (sub === 'lint') {
428
- const { specLint, driftGate, DRIFT_GUIDANCE } = await import('./lint.js')
432
+ const { specLint, DRIFT_GUIDANCE } = await import('./lint.js')
429
433
  const findings = await specLint()
430
434
  const errors = findings.filter((f) => f.level === 'error')
431
435
  for (const f of findings) console.error(` ${f.level === 'error' ? '✗' : '•'} ${f.rule}: ${f.msg}`)
432
436
  console.error(`spex spec lint: ${errors.length} error(s), ${findings.length - errors.length} warning(s)`)
433
- // drift teaches + gates from the ONE `spex spec lint` (no flag): print the remediation guidance wherever
434
- // drift exists, then apply the commit-local gate which reads the staged index itself, so it only
435
- // blocks an in-flight commit that touches an already heavily-drifted node. CI/manual (nothing staged)
436
- // stays advisory, per the ci-gate contract.
437
- const { blocked, touched, threshold } = await driftGate()
438
- if (findings.some((f) => f.rule === 'drift') || touched.length) console.error(`\n${DRIFT_GUIDANCE}`)
439
- for (const t of touched) console.error(` ${t.drift >= threshold ? '✗' : '•'} drift-gate: '${t.id}' is ${t.drift} behind${t.drift >= threshold ? ' — BLOCKS this commit' : ' (advisory)'}`)
440
- if (blocked.length) console.error(`\n✗ SpexCode: ${blocked.join(', ')} ${blocked.length === 1 ? 'is' : 'are'} ≥ ${threshold} commit(s) behind. Reconcile (above) or bypass with SPEXCODE_SKIP_LINT=1.`)
441
- process.exit(errors.length || blocked.length ? 1 : 0)
437
+ // drift teaches from the ONE `spex spec lint` (no flag). Unanchored drift stays advisory forever; the
438
+ // blocking tier is anchor-drift ([[code-anchor]])an ERROR like any other, so the pre-commit shim
439
+ // (and CI) gates on it with no separate staged-index machinery.
440
+ if (findings.some((f) => f.rule === 'drift' || f.rule === 'anchor-drift')) console.error(`\n${DRIFT_GUIDANCE}`)
441
+ process.exit(errors.length ? 1 : 0)
442
442
  } else if (sub === 'ack') {
443
443
  // An EMPTY stamp commit on top of HEAD, never an amend: driftFor (git.ts) quiets every drift commit
444
444
  // REACHABLE from an ack, so a child stamp covers exactly what amending HEAD would — and it works where
@@ -451,13 +451,13 @@ if (cmd === 'serve') {
451
451
  const reason = (flag('reason') ?? '').trim()
452
452
  if (!nodes.length || !reason) {
453
453
  console.error('usage: spex spec ack <node-id>… --reason "<why this change keeps each spec valid>"')
454
- console.error(' --reason is required (it forces you to check before acking) and is NOT storedgit keeps only the Spec-OK trailer.')
454
+ console.error(' --reason is required (it forces you to check before acking) and is recorded in the ack commit\'s message body an ack that quiets an anchor hit is a strong claim, so its why must be durable.')
455
455
  process.exit(2)
456
456
  }
457
457
  try {
458
- git(['commit', '--only', '--allow-empty', '-m', `ack: Spec-OK ${nodes.join(', ')}`,
458
+ git(['commit', '--only', '--allow-empty', '-m', `ack: Spec-OK ${nodes.join(', ')}`, '-m', reason,
459
459
  ...nodes.flatMap((n) => ['--trailer', `Spec-OK: ${n}`])])
460
- console.log(`Spec-OK: ${nodes.join(', ')} → ${git(['rev-parse', '--short', 'HEAD']).trim()} (empty stamp commit; reason required, not stored)`)
460
+ console.log(`Spec-OK: ${nodes.join(', ')} → ${git(['rev-parse', '--short', 'HEAD']).trim()} (empty stamp commit; reason recorded in the commit body)`)
461
461
  } catch (e: any) {
462
462
  console.error(`ack failed: ${e?.message ?? e}`); process.exit(1)
463
463
  }
@@ -527,12 +527,12 @@ if (cmd === 'serve') {
527
527
  if (!n.hasEvalFile && !n.uncoveredFrontend) console.log(' (no eval.md — nothing declared to measure)')
528
528
  else if (n.hasEvalFile && !n.scenarios.length) console.log(' (eval.md declares no scenarios)')
529
529
  }
530
- } else if (['add', 'ls', 'scenario', 'lint', 'retract', 'clean'].includes(sub)) {
530
+ } else if (['add', 'ls', 'scenario', 'lint', 'ok', 'retract', 'clean'].includes(sub)) {
531
531
  // node-scoped verbs — thin route; the logic lives in spec-eval.
532
532
  const { runEval } = await import('../../spec-eval/src/cli.js')
533
533
  await flushExit(await runEval(process.argv.slice(3)))
534
534
  } else {
535
- console.error(`spex eval: unknown verb '${sub}' — add | ls | scenario ls | lint | retract | clean (spex help eval)`)
535
+ console.error(`spex eval: unknown verb '${sub}' — add | ls | scenario ls | lint | ok | retract | clean (spex help eval)`)
536
536
  if (!sub.startsWith('--')) console.error(` (the old \`spex eval <SEL>\` session read is now \`spex eval ls --session <SEL>\` [--export])`) // dead-words-ok: signpost — one-version tombstone teaching the renamed spelling (0.4.0 removes it)
537
537
  process.exit(2)
538
538
  }
@@ -243,7 +243,14 @@ function serveStatic(req: http.IncomingMessage, res: http.ServerResponse, distDi
243
243
  const rel = normalize(decodeURIComponent(urlPath)).replace(/^(\.\.[/\\])+/, '')
244
244
  let file = join(distDir, rel)
245
245
  if (!file.startsWith(distDir)) file = join(distDir, 'index.html')
246
- if (urlPath === '/' || !existsSync(file)) file = join(distDir, 'index.html')
246
+ if (urlPath === '/' || !existsSync(file)) {
247
+ // @@@ missing-asset 404 - only extensionless SPA routes fall back to index.html. A missing FILE
248
+ // request (it has an extension) is a stale hashed chunk from a pre-rebuild page still open in some
249
+ // browser: answering it with HTML trips the strict module-MIME check and hides the miss from the
250
+ // client — 404 instead, so the shell's vite:preloadError recovery can see it and reload.
251
+ if (urlPath !== '/' && extname(file)) { res.writeHead(404, { 'Content-Type': 'text/plain' }); return res.end('not found') }
252
+ file = join(distDir, 'index.html')
253
+ }
247
254
  if (!existsSync(file)) { res.writeHead(503); return res.end('dashboard build missing') }
248
255
  const type = MIME[extname(file)] || 'application/octet-stream'
249
256
  const raw = readFileSync(file)
@@ -366,28 +366,36 @@ export function inAncestors(idx: DriftIndex, bits: Uint8Array, sha: string): boo
366
366
  return o !== undefined && (bits[o >> 3] & (1 << (o & 7))) !== 0
367
367
  }
368
368
 
369
- // pure lookup, no git: a commit to `path` is drift iff it is NOT an ancestor of `sinceHash` — it lies
370
- // in `sinceHash..HEAD` by true DAG reachability, wherever a date-ordered log happens to place it.
371
- //
372
- // `sinceHash` is the node's OWN latest version commit, so the node(s) it's a version of
373
- // (specNodes[sinceHash]) name the node being measured; an ack counts only if its `Spec-OK:` set names
374
- // one of those — `Spec-OK: A` quiets A's drift, never B's. An ack that is itself an ancestor of the
375
- // version can't speak for it (a re-version invalidates older acks); a valid ack quiets exactly the
376
- // commits reachable from it. An off-history `sinceHash` → 0: no basis on HEAD to measure from.
377
- export function driftFor(idx: DriftIndex, sinceHash: string, path: string): number {
378
- if (!sinceHash) return 0
369
+ // the valid Spec-OK coverage for a node's version commit: `sinceHash` is the node's OWN latest version,
370
+ // so the node(s) it's a version of (specNodes[sinceHash]) name the node being measured; an ack counts
371
+ // only if its `Spec-OK:` set names one of those — `Spec-OK: A` quiets A's drift, never B's. An ack that
372
+ // is itself an ancestor of the version can't speak for it (a re-version invalidates older acks); a valid
373
+ // ack quiets exactly the commits reachable from it. Shared by driftFor (the count) and the anchor
374
+ // engine's windowCommits (the commit set) so both read ONE ack rule.
375
+ export function ackCoverFor(idx: DriftIndex, sinceHash: string): Uint8Array[] {
379
376
  const base = ancestorsOf(idx, sinceHash)
380
- if (!base) return 0
377
+ if (!base) return []
381
378
  const targets = idx.specNodes.get(sinceHash)
382
- const ackCover: Uint8Array[] = []
379
+ const cover: Uint8Array[] = []
383
380
  if (targets) {
384
381
  for (const [h, ackSet] of idx.acks) {
385
382
  if (inAncestors(idx, base, h)) continue
386
383
  if (![...targets].some((t) => ackSet.has(t))) continue
387
384
  const a = ancestorsOf(idx, h)
388
- if (a) ackCover.push(a)
385
+ if (a) cover.push(a)
389
386
  }
390
387
  }
388
+ return cover
389
+ }
390
+
391
+ // pure lookup, no git: a commit to `path` is drift iff it is NOT an ancestor of `sinceHash` — it lies
392
+ // in `sinceHash..HEAD` by true DAG reachability, wherever a date-ordered log happens to place it.
393
+ // An off-history `sinceHash` → 0: no basis on HEAD to measure from.
394
+ export function driftFor(idx: DriftIndex, sinceHash: string, path: string): number {
395
+ if (!sinceHash) return 0
396
+ const base = ancestorsOf(idx, sinceHash)
397
+ if (!base) return 0
398
+ const ackCover = ackCoverFor(idx, sinceHash)
391
399
  let n = 0
392
400
  for (const h of idx.fileCommits.get(path) ?? []) {
393
401
  if (inAncestors(idx, base, h)) continue // reachable from the version → not drift
@@ -7,7 +7,7 @@ import { residentForgeState } from '../../spec-forge/src/resident.js'
7
7
  import { resolveForgeHost } from '../../spec-forge/src/drivers.js'
8
8
  import { mergedIssues } from './issues.js'
9
9
  import { evalContext, evalTimeline } from '../../spec-eval/src/evaltab.js'
10
- import { evalNodesAsync } from '../../spec-eval/src/scenarios.js'
10
+ import { evalNodesAsync, type ScenarioTestReference } from '../../spec-eval/src/scenarios.js'
11
11
 
12
12
  // a ghost (added) node's parent: the existing node whose directory is the longest prefix of the new one.
13
13
  function resolveParent(path: string, byDir: Record<string, string>): string | null {
@@ -29,11 +29,17 @@ export function latestPerScenario<T extends { scenario: string }>(readings: T[])
29
29
  return readings.filter((r) => !seen.has(r.scenario) && (seen.add(r.scenario), true))
30
30
  }
31
31
 
32
- // the board's scenario fold ([[graph-lean]]): the declared set rides SLIM — {name, tags} is everything an
33
- // overview surface joins state onto (badge, stats, focus rows, search rows); the prose (description/
34
- // expected) and per-scenario code stay off the hot poll, carried by `/api/specs/lite` and `/api/specs/:id/evals`.
35
- export function slimScenarios(scenarios: { name: string; tags?: string[] }[]): { name: string; tags?: string[] }[] {
36
- return scenarios.map((s) => ({ name: s.name, ...(s.tags?.length ? { tags: s.tags } : {}) }))
32
+ // the board's scenario fold ([[graph-lean]]): the declared set rides SLIM — name/tags plus the normalized
33
+ // test reference a measuring hand can follow. Prose and per-scenario code stay off the hot poll, carried by
34
+ // `/api/specs/lite` and `/api/specs/:id/evals`; the opaque test case name is metadata, not an executor seam.
35
+ export function slimScenarios(
36
+ scenarios: { name: string; tags?: string[]; test?: ScenarioTestReference }[],
37
+ ): { name: string; tags?: string[]; test?: ScenarioTestReference }[] {
38
+ return scenarios.map((s) => ({
39
+ name: s.name,
40
+ ...(s.tags?.length ? { tags: s.tags } : {}),
41
+ ...(s.test ? { test: s.test } : {}),
42
+ }))
37
43
  }
38
44
 
39
45
  export async function buildBoard() {
@@ -139,7 +145,7 @@ export async function buildBoard() {
139
145
  // the LATEST reading per scenario (newest-first), which is all any overview surface consumes (the score
140
146
  // badge, stats, search all reduce to latest-per-scenario anyway); the full timeline stays off the board
141
147
  // and is lazy-loaded by the eval tab from `/api/specs/:id/evals`. `scenarios` (the declared set) rides
142
- // SLIM — {name, tags} only, the fields every overview surface joins state onto — with its prose
148
+ // SLIM — name/tags/test only, the fields every overview surface or measuring hand needs — with its prose
143
149
  // (description/expected) and per-scenario code off the hot poll: they ride the `/api/specs/lite` corpus
144
150
  // (search palette, focus-panel preview) and the `/api/specs/:id/evals` timeline (eval tab).
145
151
  // evalContext reuses the specs + driftIndex above; evalTimeline short-circuits non-measurable nodes. The