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.
- package/package.json +1 -1
- package/spec-cli/src/anchors.ts +341 -0
- package/spec-cli/src/cli.ts +20 -20
- package/spec-cli/src/gateway.ts +8 -1
- package/spec-cli/src/git.ts +21 -13
- package/spec-cli/src/graph.ts +13 -7
- package/spec-cli/src/guide.ts +63 -15
- package/spec-cli/src/harness.ts +85 -34
- package/spec-cli/src/help.ts +33 -17
- package/spec-cli/src/index.ts +39 -7
- package/spec-cli/src/init.ts +5 -4
- package/spec-cli/src/lint.ts +103 -28
- package/spec-cli/src/localIssues.ts +19 -0
- package/spec-cli/src/migrate-table.ts +27 -16
- package/spec-cli/src/search.bench.mjs +2 -2
- package/spec-cli/src/session-timeline.ts +148 -0
- package/spec-cli/src/sessions.ts +29 -4
- package/spec-cli/src/specs.ts +44 -21
- package/spec-cli/templates/hooks/prepare-commit-msg +6 -1
- package/spec-cli/templates/spec/project/.plugins/{extract → commands/extract}/spec.md +1 -1
- package/spec-cli/templates/spec/project/.plugins/commands/spec.md +16 -0
- package/spec-cli/templates/spec/project/.plugins/commands/supervisor/spec.md +8 -0
- package/spec-cli/templates/spec/project/.plugins/core/spec.md +1 -1
- package/spec-cli/templates/spec/project/.plugins/prompts/spec.md +20 -0
- package/spec-cli/templates/spec/project/.plugins/skills/spec.md +17 -0
- package/spec-cli/templates/spec/project/.plugins/spec.md +5 -2
- package/spec-cli/templates/spec/project/spec.md +1 -1
- package/spec-dashboard/dist/assets/{Dashboard-C7Bzsv86.js → Dashboard-C_fGmOKK.js} +3 -3
- package/spec-dashboard/dist/assets/EvalsPage-Cnr1s3bq.js +2 -0
- package/spec-dashboard/dist/assets/{FoldToggle-D5iB4Ac2.js → FoldToggle-x9gtO1OQ.js} +1 -1
- package/spec-dashboard/dist/assets/{IssuesPage-CMFTsQhg.js → IssuesPage-5f_vL-JV.js} +1 -1
- package/spec-dashboard/dist/assets/MobileApp-DEO1jgGM.js +1 -0
- package/spec-dashboard/dist/assets/SessionInterface-CAlbMOFR.js +66 -0
- package/spec-dashboard/dist/assets/SessionWindow-JYbpPwNB.js +13 -0
- package/spec-dashboard/dist/assets/{Settings-BW5f0OaW.js → Settings-DKb5Ji_X.js} +1 -1
- package/spec-dashboard/dist/assets/index-BQu-oJ8J.js +41 -0
- package/spec-dashboard/dist/assets/index-BbMkwuix.css +1 -0
- package/spec-dashboard/dist/assets/launch-BM9GgvkX.js +6 -0
- package/spec-dashboard/dist/index.html +2 -2
- package/spec-eval/src/cli.ts +82 -21
- package/spec-eval/src/evaltab.ts +15 -6
- package/spec-eval/src/humanok.ts +43 -0
- package/spec-eval/src/scenarios.ts +116 -4
- package/spec-eval/src/sidecar.ts +35 -9
- package/spec-cli/templates/presets/careful/.plugins/clarify-before-code/spec.md +0 -11
- package/spec-cli/templates/spec/project/.plugins/supervisor/spec.md +0 -8
- package/spec-dashboard/dist/assets/EvalsPage-DKZZIdHq.js +0 -2
- package/spec-dashboard/dist/assets/MobileApp-DwuTKgdP.js +0 -1
- package/spec-dashboard/dist/assets/SessionInterface-CBS5_cmK.js +0 -71
- package/spec-dashboard/dist/assets/SessionWindow-CqAnjWfI.js +0 -9
- package/spec-dashboard/dist/assets/index-Cc26X4ce.css +0 -1
- package/spec-dashboard/dist/assets/index-Ce0wDyQS.js +0 -41
- /package/spec-cli/templates/spec/project/.plugins/{regroup → commands/regroup}/spec.md +0 -0
- /package/spec-cli/templates/spec/project/.plugins/{tidy → commands/tidy}/spec.md +0 -0
- /package/spec-cli/templates/spec/project/.plugins/{forge-link → prompts/forge-link}/spec.md +0 -0
- /package/spec-cli/templates/spec/project/.plugins/{memory-hygiene → prompts/memory-hygiene}/spec.md +0 -0
- /package/spec-cli/templates/spec/project/.plugins/{reproduce-before-fix → prompts/reproduce-before-fix}/spec.md +0 -0
- /package/spec-cli/templates/spec/project/.plugins/{distill → skills/distill}/digest.mjs +0 -0
- /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
|
+
"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
|
+
}
|
package/spec-cli/src/cli.ts
CHANGED
|
@@ -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
|
-
|
|
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 (
|
|
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
|
|
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
|
|
425
|
-
console.log(`${rel} is governed by ${
|
|
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,
|
|
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
|
|
434
|
-
//
|
|
435
|
-
//
|
|
436
|
-
|
|
437
|
-
|
|
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
|
|
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
|
|
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
|
}
|
package/spec-cli/src/gateway.ts
CHANGED
|
@@ -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))
|
|
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)
|
package/spec-cli/src/git.ts
CHANGED
|
@@ -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
|
-
//
|
|
370
|
-
//
|
|
371
|
-
//
|
|
372
|
-
//
|
|
373
|
-
//
|
|
374
|
-
//
|
|
375
|
-
|
|
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
|
|
377
|
+
if (!base) return []
|
|
381
378
|
const targets = idx.specNodes.get(sinceHash)
|
|
382
|
-
const
|
|
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)
|
|
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
|
package/spec-cli/src/graph.ts
CHANGED
|
@@ -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 —
|
|
33
|
-
//
|
|
34
|
-
//
|
|
35
|
-
export function slimScenarios(
|
|
36
|
-
|
|
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 —
|
|
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
|