spexcode 0.2.8 → 0.4.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/README.md +56 -44
- package/package.json +3 -3
- package/spec-cli/bin/spex.mjs +2 -2
- package/spec-cli/hooks/dispatch.sh +1 -1
- package/spec-cli/hooks/harness.sh +26 -6
- package/spec-cli/src/anchors.ts +300 -0
- package/spec-cli/src/attach.ts +2 -2
- package/spec-cli/src/cli.ts +691 -536
- package/spec-cli/src/client.ts +31 -30
- package/spec-cli/src/contract-filter.ts +1 -1
- package/spec-cli/src/doctor.ts +40 -13
- package/spec-cli/src/gateway.ts +11 -7
- package/spec-cli/src/git.ts +23 -15
- package/spec-cli/src/{board.ts → graph.ts} +44 -14
- package/spec-cli/src/{boardCache.ts → graphCache.ts} +41 -21
- package/spec-cli/src/{boardDelta.ts → graphDelta.ts} +1 -1
- package/spec-cli/src/graphStream.ts +288 -0
- package/spec-cli/src/guide.ts +142 -98
- package/spec-cli/src/harness-select.ts +2 -2
- package/spec-cli/src/harness.ts +30 -14
- package/spec-cli/src/help.ts +300 -385
- package/spec-cli/src/hooks.ts +1 -1
- package/spec-cli/src/index.ts +144 -103
- package/spec-cli/src/init.ts +14 -13
- package/spec-cli/src/issues.ts +89 -33
- package/spec-cli/src/layout.ts +5 -5
- package/spec-cli/src/lint.ts +128 -30
- package/spec-cli/src/localIssues.ts +61 -60
- package/spec-cli/src/materialize.ts +1 -1
- package/spec-cli/src/mentions.ts +15 -15
- package/spec-cli/src/migrate-table.ts +398 -0
- package/spec-cli/src/migrate.ts +386 -0
- package/spec-cli/src/ranker.ts +30 -4
- package/spec-cli/src/reaper.ts +117 -0
- package/spec-cli/src/search.bench.mjs +12 -12
- package/spec-cli/src/search.ts +1 -1
- package/spec-cli/src/sessions.ts +244 -138
- package/spec-cli/src/specs.ts +40 -25
- package/spec-cli/src/supervise.ts +2 -2
- package/spec-cli/src/tree.ts +5 -5
- package/spec-cli/src/uninstall.ts +4 -4
- package/spec-cli/templates/hooks/post-checkout +1 -1
- package/spec-cli/templates/hooks/post-merge +3 -3
- package/spec-cli/templates/hooks/pre-commit +9 -9
- package/spec-cli/templates/hooks/prepare-commit-msg +1 -1
- package/spec-cli/templates/spec/project/{.config → .plugins}/core/idle/idle.sh +2 -2
- package/spec-cli/templates/spec/project/{.config → .plugins}/core/mark-active/mark-active.sh +7 -0
- package/spec-cli/templates/spec/project/{.config → .plugins}/core/mark-active/spec.md +2 -0
- package/spec-cli/templates/spec/project/{.config → .plugins}/core/session-fail/fail.sh +1 -1
- package/spec-cli/templates/spec/project/{.config → .plugins}/core/spec-first/spec-first.sh +1 -1
- package/spec-cli/templates/spec/project/{.config → .plugins}/core/spec-of-file/spec-of-file.sh +2 -2
- package/spec-cli/templates/spec/project/.plugins/core/spec.md +21 -0
- package/spec-cli/templates/spec/project/{.config → .plugins}/core/stop-gate/stop-gate.sh +21 -21
- package/spec-cli/templates/spec/project/{.config → .plugins}/distill/spec.md +2 -2
- package/spec-cli/templates/spec/project/{.config → .plugins}/extract/spec.md +5 -5
- package/spec-cli/templates/spec/project/{.config → .plugins/prompts}/forge-link/spec.md +1 -1
- package/spec-cli/templates/spec/project/{.config → .plugins/prompts}/reproduce-before-fix/spec.md +3 -3
- package/spec-cli/templates/spec/project/.plugins/prompts/spec.md +20 -0
- package/spec-cli/templates/spec/project/{.config → .plugins}/regroup/spec.md +2 -2
- package/spec-cli/templates/spec/project/.plugins/spec.md +18 -0
- package/spec-cli/templates/spec/project/.plugins/supervisor/spec.md +8 -0
- package/spec-cli/templates/spec/project/{.config → .plugins}/tidy/spec.md +1 -1
- package/spec-cli/templates/spec/project/spec.md +1 -1
- package/spec-dashboard/dist/assets/{Dashboard-P0B9ukSG.js → Dashboard-CTcH2eW9.js} +9 -9
- package/spec-dashboard/dist/assets/EvalsPage-CJNKwHLN.js +2 -0
- package/spec-dashboard/dist/assets/{FoldToggle-BuQ0lokE.js → FoldToggle-CVFbBpyW.js} +1 -1
- package/spec-dashboard/dist/assets/{IssuesPage-H-D8aHEl.js → IssuesPage-kULjonqj.js} +1 -1
- package/spec-dashboard/dist/assets/{MobileApp-oZXIeCPb.js → MobileApp-B0ZJju8K.js} +1 -1
- package/spec-dashboard/dist/assets/{SessionInterface-Blr_MEdU.js → SessionInterface-BRKJqU2U.js} +3 -3
- package/spec-dashboard/dist/assets/{SessionWindow-LcCzBMU7.js → SessionWindow-CDhEL7wO.js} +7 -7
- package/spec-dashboard/dist/assets/{Settings-_yOye-In.js → Settings-BL6FV_8S.js} +1 -1
- package/spec-dashboard/dist/assets/{index-uGs9v_9o.css → index-DmQsNYKK.css} +1 -1
- package/spec-dashboard/dist/assets/{index-BhIslAau.js → index-DulGPk6A.js} +8 -8
- package/spec-dashboard/dist/index.html +2 -2
- package/{spec-yatsu → spec-eval}/src/cache.ts +8 -5
- package/{spec-yatsu → spec-eval}/src/cli.ts +212 -96
- package/{spec-yatsu → spec-eval}/src/evaltab.ts +35 -27
- package/{spec-yatsu → spec-eval}/src/filing.ts +7 -5
- package/{spec-yatsu → spec-eval}/src/freshness.ts +44 -22
- package/spec-eval/src/humanok.ts +43 -0
- package/{spec-yatsu → spec-eval}/src/scenariofresh.ts +22 -17
- package/{spec-yatsu/src/yatsu.ts → spec-eval/src/scenarios.ts} +41 -26
- package/{spec-yatsu/src/proof.ts → spec-eval/src/sessioneval.ts} +59 -59
- package/{spec-yatsu → spec-eval}/src/sidecar.ts +42 -10
- package/{spec-yatsu → spec-eval}/src/timeline.ts +1 -1
- package/spec-forge/src/__fixtures__/github-forge.json +9 -9
- package/spec-forge/src/cli.ts +14 -13
- package/spec-forge/src/{needs-yatsu-eval.ts → needs-eval.ts} +6 -6
- package/spec-cli/src/boardStream.ts +0 -179
- package/spec-cli/templates/presets/careful/.config/clarify-before-code/spec.md +0 -11
- package/spec-cli/templates/spec/project/.config/core/spec.md +0 -17
- package/spec-cli/templates/spec/project/.config/spec.md +0 -15
- package/spec-cli/templates/spec/project/.config/supervisor/spec.md +0 -8
- package/spec-dashboard/dist/assets/EvalsPage-BrvAGyc4.js +0 -2
- /package/spec-cli/templates/spec/project/{.config → .plugins}/core/idle/spec.md +0 -0
- /package/spec-cli/templates/spec/project/{.config → .plugins}/core/session-fail/spec.md +0 -0
- /package/spec-cli/templates/spec/project/{.config → .plugins}/core/spec-first/spec.md +0 -0
- /package/spec-cli/templates/spec/project/{.config → .plugins}/core/spec-of-file/spec.md +0 -0
- /package/spec-cli/templates/spec/project/{.config → .plugins}/core/stop-gate/spec.md +0 -0
- /package/spec-cli/templates/spec/project/{.config → .plugins}/distill/digest.mjs +0 -0
- /package/spec-cli/templates/spec/project/{.config → .plugins/prompts}/memory-hygiene/spec.md +0 -0
|
@@ -2,8 +2,8 @@ import { relative, dirname } from 'node:path'
|
|
|
2
2
|
import { repoRoot, driftIndex, historyIndex, type DriftIndex, type HistoryIndex } from '../../spec-cli/src/git.js'
|
|
3
3
|
import { loadSpecs } from '../../spec-cli/src/specs.js'
|
|
4
4
|
import { loadEvalRemarkTracks, trackKey, type RemarkTrack, type Issue, type Reply } from '../../spec-cli/src/issues.js'
|
|
5
|
-
import {
|
|
6
|
-
import { readSidecar, applyRetractions, evidenceOf, isJsonBlob, type Verdict, type EvidenceKind, type Retraction } from './sidecar.js'
|
|
5
|
+
import { evalNodes, type EvalNode } from './scenarios.js'
|
|
6
|
+
import { readSidecar, applyRetractions, evidenceOf, isJsonBlob, humanOkFor, type Verdict, type EvidenceKind, type Retraction } from './sidecar.js'
|
|
7
7
|
import { staleAxes, codeDrift, contentProbeFor, type StaleAxis } from './freshness.js'
|
|
8
8
|
import { scenarioIndex, type ScenarioIndex } from './scenariofresh.js'
|
|
9
9
|
import { hasBlob, getBlob, MISS_BLOB } from './cache.js'
|
|
@@ -18,7 +18,7 @@ export type EvidenceView = { hash: string; kind: EvidenceKind; state: 'present'
|
|
|
18
18
|
// per-reading display attachment.
|
|
19
19
|
export type RemarkView = {
|
|
20
20
|
rid: string
|
|
21
|
-
ref: string // `<thread-id>#<rid>` — the address `spex resolve`/`spex retract` take
|
|
21
|
+
ref: string // `<thread-id>#<rid>` — the address `spex remark resolve`/`spex remark retract` take
|
|
22
22
|
by: string
|
|
23
23
|
at: string
|
|
24
24
|
body: string
|
|
@@ -53,7 +53,7 @@ export type EvalEntry = {
|
|
|
53
53
|
fresh: boolean
|
|
54
54
|
staleAxes: StaleAxis[]
|
|
55
55
|
blobState: 'present' | 'miss' | 'none'
|
|
56
|
-
// the code axis's drift detail for a code-stale reading ([[
|
|
56
|
+
// the code axis's drift detail for a code-stale reading ([[eval-core]]'s codeDrift): each governed file
|
|
57
57
|
// that moved since this reading + how many commits behind, so the eval detail can EXPLAIN the staleness
|
|
58
58
|
// ("EvalsFeed.jsx +3") rather than just flag it. Absent when the reading isn't code-stale.
|
|
59
59
|
codeDrift?: { file: string; behind: number }[]
|
|
@@ -64,6 +64,10 @@ export type EvalEntry = {
|
|
|
64
64
|
// so the eval detail pane reads its whole comment thread from the reading overlay — the counterpart to
|
|
65
65
|
// splitting eval-remark threads OUT of the issue surfaces (mergedIssues). Absent until the first remark.
|
|
66
66
|
thread?: Issue
|
|
67
|
+
// the human sign-off bound to THIS reading ([[human-ok]]) — present only on the exact reading an ok row
|
|
68
|
+
// anchors (by scenario + ts), so a newer reading arrives unblessed and the feed's hide releases itself.
|
|
69
|
+
// Rides the board fold verbatim (latestPerScenario is a filter, never a projection).
|
|
70
|
+
humanOk?: { by: string; ts: string }
|
|
67
71
|
}
|
|
68
72
|
|
|
69
73
|
// a remark overlaid onto its display host (a reading, above) → the RemarkView the surfaces read.
|
|
@@ -89,16 +93,16 @@ export type DanglingTrack = { scenario: string; threadId: string; thread: Issue;
|
|
|
89
93
|
|
|
90
94
|
export type ScenarioInfo = { name: string; expected: string; tags?: string[]; code?: string[] }
|
|
91
95
|
|
|
92
|
-
// `
|
|
96
|
+
// `hasEvalFile` distinguishes a node that declares no scenarios (no eval.md) from one that declares some but
|
|
93
97
|
// has no readings yet — the tab says different things for each. `scenarios` is the declared set; `readings`
|
|
94
98
|
// is NEWEST-FIRST (the sidecar is append-only oldest→newest; the tab leads with the latest measurement,
|
|
95
99
|
// like the history tab).
|
|
96
100
|
export type EvalTimeline = {
|
|
97
101
|
node: string
|
|
98
|
-
|
|
102
|
+
hasEvalFile: boolean
|
|
99
103
|
scenarios: ScenarioInfo[]
|
|
100
104
|
readings: EvalEntry[]
|
|
101
|
-
// retraction events ([[
|
|
105
|
+
// retraction events ([[eval-core]]'s retract verb), newest first — the sanctioned-undo TRACE. `readings`
|
|
102
106
|
// above is already the effective view (a retracted reading is dropped from the scoreboard everywhere);
|
|
103
107
|
// this list is how a surface still shows that the undo happened: which (scenario, ts) was withdrawn,
|
|
104
108
|
// by whom, why. Additive — a consumer that ignores it sees exactly the effective scoreboard.
|
|
@@ -115,13 +119,13 @@ export type EvalContext = {
|
|
|
115
119
|
idx: DriftIndex
|
|
116
120
|
hidx: HistoryIndex
|
|
117
121
|
scidx: ScenarioIndex // per-scenario block-change history ([[scenariofresh]]) — the SCENARIO axis, built ONCE per build
|
|
118
|
-
ynodes:
|
|
122
|
+
ynodes: EvalNode[]
|
|
119
123
|
// the trunk remark tracks ([[remark-teeth]]), keyed (node, scenario) — loaded ONCE per board/proof build
|
|
120
124
|
// and reused for every node, so the fold never re-reads the issue store per node.
|
|
121
125
|
remarks: Map<string, RemarkTrack>
|
|
122
126
|
}
|
|
123
127
|
|
|
124
|
-
// build the shared context with ONE
|
|
128
|
+
// build the shared context with ONE eval-file walk, reusing the caller's already-computed specs + the two
|
|
125
129
|
// HEAD-keyed git indices (drift for the code axis, history for the rename-safe scenario axis — both warm
|
|
126
130
|
// hits, loadSpecs already derived them). The remark tracks are the fourth, non-git freshness input
|
|
127
131
|
// ([[remark-teeth]]); a caller that omits them gets a live load, so a bare evalTimeline still has teeth.
|
|
@@ -131,28 +135,28 @@ export async function evalContext(
|
|
|
131
135
|
idx: DriftIndex,
|
|
132
136
|
hidx: HistoryIndex,
|
|
133
137
|
remarks?: Map<string, RemarkTrack>,
|
|
134
|
-
ynodes?:
|
|
138
|
+
ynodes?: EvalNode[], // the hot board build precomputes these off the event loop (evalNodesAsync); a bare caller walks sync
|
|
135
139
|
): Promise<EvalContext> {
|
|
136
|
-
const nodes = ynodes ??
|
|
137
|
-
const scidx = await scenarioIndex(root, nodes.map((n) => n.
|
|
140
|
+
const nodes = ynodes ?? evalNodes(root)
|
|
141
|
+
const scidx = await scenarioIndex(root, nodes.map((n) => n.evalPath))
|
|
138
142
|
return { root, specs, idx, hidx, scidx, ynodes: nodes, remarks: remarks ?? loadEvalRemarkTracks() }
|
|
139
143
|
}
|
|
140
144
|
|
|
141
145
|
export async function evalTimeline(id: string, ctx?: EvalContext): Promise<EvalTimeline> {
|
|
142
146
|
const root = ctx?.root ?? repoRoot()
|
|
143
|
-
// short-circuit a non-
|
|
144
|
-
// this is the common case and must stay cheap (a list the size of the few
|
|
145
|
-
const ynode = (ctx?.ynodes ??
|
|
146
|
-
if (!ynode) return { node: id,
|
|
147
|
+
// short-circuit a non-measurable node on the (short) eval-file walk — the board attaches `evals` to every node, so
|
|
148
|
+
// this is the common case and must stay cheap (a list the size of the few measurable nodes, not the whole tree).
|
|
149
|
+
const ynode = (ctx?.ynodes ?? evalNodes(root)).find((n) => n.id === id)
|
|
150
|
+
if (!ynode) return { node: id, hasEvalFile: false, scenarios: [], readings: [], retractions: [], dangling: [] }
|
|
147
151
|
// the governed `code:` files are the freshness CODE axis; read them from the canonical spec loader so a
|
|
148
|
-
// reparent/rename is seen the same way `spex lint` and `spex
|
|
152
|
+
// reparent/rename is seen the same way `spex spec lint` and `spex eval add` see it (joined by directory).
|
|
149
153
|
const specs = ctx?.specs ?? await loadSpecs()
|
|
150
154
|
const codeFiles = specs.find((s) => dirname(s.path) === relative(root, ynode.dir))?.code ?? []
|
|
151
155
|
const idx = ctx?.idx ?? await driftIndex(root)
|
|
152
156
|
const hidx = ctx?.hidx ?? await historyIndex(root)
|
|
153
157
|
// the SCENARIO axis: per-scenario block-change history, built once per HEAD (cached). A bare call builds it
|
|
154
|
-
// for the WHOLE
|
|
155
|
-
const scidx = ctx?.scidx ?? await scenarioIndex(root, (ctx?.ynodes ??
|
|
158
|
+
// for the WHOLE measurable set (not just this node) so the shared HEAD-keyed cache is complete for later callers.
|
|
159
|
+
const scidx = ctx?.scidx ?? await scenarioIndex(root, (ctx?.ynodes ?? evalNodes(root)).map((n) => n.evalPath))
|
|
156
160
|
const byName = new Map(ynode.scenarios.map((s) => [s.name, s])) // join each reading to its scenario's expected + code
|
|
157
161
|
// the trunk remark track per scenario ([[remark-teeth]]) — the non-git freshness input, fed to the teeth.
|
|
158
162
|
const tracks = ctx?.remarks ?? loadEvalRemarkTracks()
|
|
@@ -163,9 +167,9 @@ export async function evalTimeline(id: string, ctx?: EvalContext): Promise<EvalT
|
|
|
163
167
|
...(s.tags?.length ? { tags: s.tags } : {}), ...(s.code?.length ? { code: s.code } : {}),
|
|
164
168
|
}))
|
|
165
169
|
// one raw sidecar read: the effective readings feed the scoreboard rows below; the retraction events ride
|
|
166
|
-
// along as the undo trace (newest-first, like the readings).
|
|
167
|
-
const { readings: rawReadings, retractions } = readSidecar(ynode.sidecarPath)
|
|
168
|
-
// the off-history content fallback ([[
|
|
170
|
+
// along as the undo trace (newest-first, like the readings), the human-ok events as the sign-off overlay.
|
|
171
|
+
const { readings: rawReadings, retractions, oks } = readSidecar(ynode.sidecarPath)
|
|
172
|
+
// the off-history content fallback ([[eval-core]]): fed to both git axes so a rebased/folded-away
|
|
169
173
|
// anchor with byte-identical governed content reads fresh. Lazy — an in-history reading never probes.
|
|
170
174
|
const probe = contentProbeFor(root)
|
|
171
175
|
const readings: EvalEntry[] = applyRetractions(rawReadings, retractions).map((r) => {
|
|
@@ -175,14 +179,17 @@ export async function evalTimeline(id: string, ctx?: EvalContext): Promise<EvalT
|
|
|
175
179
|
// remark makes it remark-stale (T1). Display attachment (which reading each remark pins to) is a separate
|
|
176
180
|
// read-time overlay below; freshness never depends on that pin.
|
|
177
181
|
const cf = sc?.code?.length ? sc.code : codeFiles
|
|
178
|
-
const axes = staleAxes(r, cf, ynode.
|
|
179
|
-
remarksFor(r.scenario).map((rm) => ({ resolved: !!rm.resolved, resolvedAt: rm.resolvedAt })), probe)
|
|
182
|
+
const axes = staleAxes(r, cf, ynode.evalPath, idx, scidx,
|
|
183
|
+
remarksFor(r.scenario).map((rm) => ({ resolved: !!rm.resolved, resolvedAt: rm.resolvedAt })), probe, sc)
|
|
180
184
|
// when the code axis is stale, explain it: which of THIS reading's governed files moved, by how many commits.
|
|
181
185
|
const drift = axes.includes('code') ? codeDrift(idx, r.codeSha, cf, probe) : []
|
|
182
186
|
// the reading's evidence list, each entry resolved to its live blob state; the primary (video-first, else
|
|
183
187
|
// first) drives the scalar compat fields for single-evidence consumers.
|
|
184
188
|
const evidence: EvidenceView[] = evidenceOf(r).map((e) => ({ hash: e.hash, kind: e.kind, state: hasBlob(e.hash) ? 'present' : 'miss' }))
|
|
185
189
|
const primary = evidence.find((e) => e.kind === 'video') ?? evidence[0]
|
|
190
|
+
// the sign-off join ([[human-ok]]): the ok binds by exact (scenario, ts), so only the very reading the
|
|
191
|
+
// human blessed carries it — a newer or retract-revealed reading reads unblessed.
|
|
192
|
+
const okRow = humanOkFor(oks, r.scenario, r.ts)
|
|
186
193
|
return {
|
|
187
194
|
scenario: r.scenario,
|
|
188
195
|
expected: byName.get(r.scenario)?.expected ?? '',
|
|
@@ -200,6 +207,7 @@ export async function evalTimeline(id: string, ctx?: EvalContext): Promise<EvalT
|
|
|
200
207
|
...(drift.length ? { codeDrift: drift } : {}),
|
|
201
208
|
blobState: primary ? primary.state : 'none',
|
|
202
209
|
...(threadFor(r.scenario) ? { thread: threadFor(r.scenario) } : {}),
|
|
210
|
+
...(okRow ? { humanOk: { by: okRow.by, ts: okRow.ts } } : {}),
|
|
203
211
|
}
|
|
204
212
|
})
|
|
205
213
|
readings.reverse() // newest-first
|
|
@@ -215,7 +223,7 @@ export async function evalTimeline(id: string, ctx?: EvalContext): Promise<EvalT
|
|
|
215
223
|
const rows = readings.filter((r) => r.scenario === track.scenario)
|
|
216
224
|
if (!rows.length) {
|
|
217
225
|
// no reading joins this track. If the scenario is still DECLARED it is just a blind spot (unmeasured),
|
|
218
|
-
// not orphaned — its remarks wait for a reading. Only a scenario that is BOTH gone from
|
|
226
|
+
// not orphaned — its remarks wait for a reading. Only a scenario that is BOTH gone from eval.md AND
|
|
219
227
|
// has no reading is truly dangling (renamed/deleted), and that is the one we surface at node level.
|
|
220
228
|
if (!declared.has(track.scenario)) {
|
|
221
229
|
dangling.push({
|
|
@@ -232,7 +240,7 @@ export async function evalTimeline(id: string, ctx?: EvalContext): Promise<EvalT
|
|
|
232
240
|
;(host.remarks ??= []).push(toRemarkView(rm, track.threadId, !target))
|
|
233
241
|
}
|
|
234
242
|
}
|
|
235
|
-
return { node: id,
|
|
243
|
+
return { node: id, hasEvalFile: true, scenarios, readings, retractions: [...retractions].reverse(), dangling }
|
|
236
244
|
}
|
|
237
245
|
|
|
238
246
|
const HEX64 = /^[0-9a-f]{64}$/
|
|
@@ -242,7 +250,7 @@ export type BlobResult =
|
|
|
242
250
|
| { ok: false; reason: 'invalid' | 'miss'; message: string }
|
|
243
251
|
|
|
244
252
|
export function readBlobByHash(hash: string, dir?: string): BlobResult {
|
|
245
|
-
if (!HEX64.test(hash)) return { ok: false, reason: 'invalid', message: 'bad
|
|
253
|
+
if (!HEX64.test(hash)) return { ok: false, reason: 'invalid', message: 'bad evidence hash' }
|
|
246
254
|
const bytes = getBlob(hash, dir) // undefined dir → the live cache (cache.ts default); a temp dir in tests
|
|
247
255
|
if (!bytes) return { ok: false, reason: 'miss', message: MISS_BLOB }
|
|
248
256
|
return { ok: true, bytes, mime: sniffBlobMime(bytes) }
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { repoRoot, headSha } from '../../spec-cli/src/git.js'
|
|
2
|
-
import {
|
|
2
|
+
import { evalNodes, resolveEvalNode, scenarioHash } from './scenarios.js'
|
|
3
3
|
import { appendReading, readReadings, isJsonBlob, type Reading, type EvidenceKind } from './sidecar.js'
|
|
4
4
|
import { putBlob } from './cache.js'
|
|
5
5
|
|
|
@@ -7,7 +7,7 @@ export type FileResult = { ok: true; reading: Reading } | { ok: false; error: st
|
|
|
7
7
|
|
|
8
8
|
// the eval seam over DATA (no argv, no file paths): a caller with a verdict in hand — the HTTP eval route,
|
|
9
9
|
// a programmatic filer — appends through the SAME seam the CLI uses. Optional evidence arrives as text (a
|
|
10
|
-
// report referencing the clip by hash) → a transcript blob in the same content-addressed cache.
|
|
10
|
+
// report referencing the clip by hash) → a transcript blob in the same content-addressed cache. eval
|
|
11
11
|
// still runs nothing — this only records. The dashboard files nothing through this: [[event-detail]] is
|
|
12
12
|
// read-side on readings.
|
|
13
13
|
export function fileHumanReading(
|
|
@@ -15,9 +15,9 @@ export function fileHumanReading(
|
|
|
15
15
|
input: { scenario: string; status: 'pass' | 'fail'; note?: string; transcript?: string; by?: string },
|
|
16
16
|
): FileResult {
|
|
17
17
|
const root = repoRoot()
|
|
18
|
-
// the same loud resolution the CLI applies ([[
|
|
18
|
+
// the same loud resolution the CLI applies ([[eval-core]]): exact canonical id, else a unique bare
|
|
19
19
|
// leaf; an ambiguous leaf returns the candidate list instead of filing against an arbitrary node.
|
|
20
|
-
const res =
|
|
20
|
+
const res = resolveEvalNode(evalNodes(root), nodeId)
|
|
21
21
|
if (!res.ok) return { ok: false, error: res.error }
|
|
22
22
|
const node = res.node
|
|
23
23
|
const sc = node.scenarios.find((s) => s.name === input.scenario)
|
|
@@ -30,6 +30,8 @@ export function fileHumanReading(
|
|
|
30
30
|
const reading: Reading = {
|
|
31
31
|
scenario: sc.name,
|
|
32
32
|
codeSha: headSha(root),
|
|
33
|
+
// the contract this measurement was taken against — the HTTP filer stamps it exactly like the CLI
|
|
34
|
+
scenarioHash: scenarioHash(sc),
|
|
33
35
|
...(blob ? { evidence: [{ hash: blob, kind: (buf && isJsonBlob(buf) ? 'data' : 'transcript') as EvidenceKind }] } : {}),
|
|
34
36
|
// the filing session (caller-passed — the human annotator has no reachable session, so it stays absent
|
|
35
37
|
// there and the eval-comment loop-in is silent, per [[mentions]])
|
|
@@ -48,7 +50,7 @@ export function fileHumanReading(
|
|
|
48
50
|
// in-flight reading (filed on an unmerged branch) is invisible to the trunk sidecar — exactly the
|
|
49
51
|
// review-time case where the remark must still reach its filer ([[remark-polish]] strand 2).
|
|
50
52
|
export function evalReadingFiler(nodeId: string, scenario: string, root: string = repoRoot()): string | null {
|
|
51
|
-
const res =
|
|
53
|
+
const res = resolveEvalNode(evalNodes(root), nodeId)
|
|
52
54
|
if (!res.ok) return null
|
|
53
55
|
const forScenario = readReadings(res.node.sidecarPath).filter((r) => r.scenario === scenario)
|
|
54
56
|
return forScenario.length ? forScenario[forScenario.length - 1].by ?? null : null
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { git, headSha, ancestorsOf, inAncestors, type DriftIndex } from '../../spec-cli/src/git.js'
|
|
2
2
|
import type { Reading } from './sidecar.js'
|
|
3
|
+
import { scenarioHash, type Scenario } from './scenarios.js'
|
|
3
4
|
import { scenarioChangeCommits, scenarioBlocksAt, type ScenarioIndex } from './scenariofresh.js'
|
|
4
5
|
|
|
5
6
|
// the CODE axis is touch-based (DriftIndex), so a code-file rename is out of scope — the same blind spot lint's code-drift has
|
|
@@ -18,7 +19,7 @@ export type ContentProbe = {
|
|
|
18
19
|
// paths whose content differs between the anchor commit's tree and HEAD's; null = anchor object gone
|
|
19
20
|
changedPaths(anchorSha: string): Set<string> | null
|
|
20
21
|
// did THIS scenario's semantic block (description+expected) move between anchor and HEAD ([[scenariofresh]])
|
|
21
|
-
scenarioDiffers(anchorSha: string,
|
|
22
|
+
scenarioDiffers(anchorSha: string, evalPath: string, scenario: string): boolean
|
|
22
23
|
// codeDrift's display detail: commits in anchor..HEAD touching path (floored at 1 — the content differs)
|
|
23
24
|
behind(anchorSha: string, path: string): number
|
|
24
25
|
}
|
|
@@ -50,10 +51,10 @@ export function contentProbeFor(root: string): ContentProbe {
|
|
|
50
51
|
} catch { return null } // the anchor commit object is gone — content can't testify
|
|
51
52
|
})
|
|
52
53
|
},
|
|
53
|
-
scenarioDiffers(sha,
|
|
54
|
-
const a = scenarioBlocksAt(root, sha,
|
|
55
|
-
if (!a) return true //
|
|
56
|
-
return a.get(scenario) !== scenarioBlocksAt(root, headOf(),
|
|
54
|
+
scenarioDiffers(sha, evalPath, scenario) {
|
|
55
|
+
const a = scenarioBlocksAt(root, sha, evalPath)
|
|
56
|
+
if (!a) return true // eval.md unreadable at the anchor (renamed/absent) → can't prove → stale
|
|
57
|
+
return a.get(scenario) !== scenarioBlocksAt(root, headOf(), evalPath)?.get(scenario)
|
|
57
58
|
},
|
|
58
59
|
behind(sha, path) {
|
|
59
60
|
return memo(behindMemo, `${root}\x1f${sha}\x1f${headOf()}\x1f${path}`, () => {
|
|
@@ -112,39 +113,59 @@ export function codeDrift(idx: DriftIndex, sinceSha: string, codeFiles: string[]
|
|
|
112
113
|
return out
|
|
113
114
|
}
|
|
114
115
|
|
|
115
|
-
// scenario
|
|
116
|
-
//
|
|
117
|
-
//
|
|
118
|
-
//
|
|
119
|
-
//
|
|
120
|
-
//
|
|
121
|
-
//
|
|
122
|
-
//
|
|
123
|
-
|
|
116
|
+
// @@@scenario axis decides by stored contract hash - a reading filed since #61 carries `scenarioHash`, the
|
|
117
|
+
// content hash of the semantic text it was measured against (scenarios.ts scenarioHash — normalized
|
|
118
|
+
// description+expected). For such a reading the scenario axis is a PURE TEXT COMPARE: recorded hash vs the
|
|
119
|
+
// CURRENT declaration's hash — no git walk, no chain, no ancestry. That is what makes it converge under
|
|
120
|
+
// fleet-parallel filing+merging: a sibling scenario's edit, a sidecar-only commit, a merge's textual shift,
|
|
121
|
+
// a whitespace re-wrap — none of them move THIS scenario's hash, so none can re-stale its reading; only the
|
|
122
|
+
// contract actually changing (or the scenario disappearing from eval.md — `current` undefined) does. A
|
|
123
|
+
// LEGACY reading without the hash is decided by the git-derived per-scenario rule below, unchanged — the
|
|
124
|
+
// one-shot degradation: exactly ONE track decides each reading (hash if present, else git), never both
|
|
125
|
+
// OR-ed together.
|
|
126
|
+
function scenarioStaleByHash(reading: Reading, current: Scenario | undefined): boolean | undefined {
|
|
127
|
+
if (reading.scenarioHash === undefined) return undefined // legacy → the git rule decides
|
|
128
|
+
return current ? scenarioHash(current) !== reading.scenarioHash : true
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
// LEGACY scenario freshness (readings filed before the stored contract hash) — PER-SCENARIO and SEMANTIC,
|
|
132
|
+
// not per-file: a reading stales only when ITS OWN scenario's semantic block (description+expected —
|
|
133
|
+
// [[scenariofresh]]'s blockContent projection) moved in scenarioSha..HEAD — never when a sibling in the
|
|
134
|
+
// same eval.md did, and never on a metadata-only edit (tags/test/code/related). Reads like the code axis's
|
|
135
|
+
// changedSince — the per-scenario change-commits ([[scenariofresh]], rename-followed so a bare git-mv
|
|
136
|
+
// reparent isn't a change) tested for ancestry — and an off-history codeSha takes the same content fallback
|
|
137
|
+
// at the same granularity and the same projection. Known limit (#61, why the hash replaced it for new
|
|
138
|
+
// readings): the change-commit chain is built off a LINEARIZED log walk, so parallel branches editing one
|
|
139
|
+
// eval.md cross-attribute each other's edits and can false-stale a sibling's reading across a merge.
|
|
140
|
+
function scenarioMoved(scIdx: ScenarioIndex, didx: DriftIndex, sinceSha: string, evalPath: string, scenario: string, probe?: ContentProbe): boolean {
|
|
124
141
|
const anc = ancestorsOf(didx, sinceSha)
|
|
125
|
-
if (anc) return scenarioChangeCommits(scIdx,
|
|
142
|
+
if (anc) return scenarioChangeCommits(scIdx, evalPath, scenario).some((h) => !inAncestors(didx, anc, h))
|
|
126
143
|
const diff = probe?.changedPaths(sinceSha)
|
|
127
144
|
if (!diff) return true
|
|
128
|
-
if (!diff.has(
|
|
129
|
-
return probe!.scenarioDiffers(sinceSha,
|
|
145
|
+
if (!diff.has(evalPath)) return false // whole file byte-identical → this block too
|
|
146
|
+
return probe!.scenarioDiffers(sinceSha, evalPath, scenario)
|
|
130
147
|
}
|
|
131
148
|
|
|
132
149
|
export function staleAxes(
|
|
133
150
|
reading: Reading,
|
|
134
151
|
codeFiles: string[],
|
|
135
|
-
|
|
152
|
+
evalPath: string,
|
|
136
153
|
didx: DriftIndex,
|
|
137
154
|
scIdx: ScenarioIndex,
|
|
138
155
|
remarks: RemarkSignal[] = [],
|
|
139
156
|
probe?: ContentProbe,
|
|
157
|
+
current?: Scenario, // the scenario's CURRENT declaration (undefined = gone from eval.md) — the hash compare's other side
|
|
140
158
|
): StaleAxis[] {
|
|
141
159
|
const axes: StaleAxis[] = []
|
|
160
|
+
const byHash = scenarioStaleByHash(reading, current)
|
|
142
161
|
if (probe && !ancestorsOf(didx, reading.codeSha) && probe.changedPaths(reading.codeSha) === null) {
|
|
143
|
-
// the anchor commit object is GONE — neither git axis can testify; say that, not "content changed"
|
|
162
|
+
// the anchor commit object is GONE — neither git axis can testify; say that, not "content changed".
|
|
163
|
+
// The stored contract hash needs no anchor, so it still decides the scenario axis when present.
|
|
144
164
|
axes.push('anchor')
|
|
165
|
+
if (byHash) axes.push('scenario')
|
|
145
166
|
} else {
|
|
146
167
|
if (codeFiles.some((f) => changedSince(didx, reading.codeSha, f, probe))) axes.push('code')
|
|
147
|
-
if (scenarioMoved(scIdx, didx, reading.codeSha,
|
|
168
|
+
if (byHash ?? scenarioMoved(scIdx, didx, reading.codeSha, evalPath, reading.scenario, probe)) axes.push('scenario')
|
|
148
169
|
}
|
|
149
170
|
if (remarkStale(reading, remarks)) axes.push('remark')
|
|
150
171
|
return axes
|
|
@@ -153,11 +174,12 @@ export function staleAxes(
|
|
|
153
174
|
export function isStale(
|
|
154
175
|
reading: Reading,
|
|
155
176
|
codeFiles: string[],
|
|
156
|
-
|
|
177
|
+
evalPath: string,
|
|
157
178
|
didx: DriftIndex,
|
|
158
179
|
scIdx: ScenarioIndex,
|
|
159
180
|
remarks: RemarkSignal[] = [],
|
|
160
181
|
probe?: ContentProbe,
|
|
182
|
+
current?: Scenario,
|
|
161
183
|
): boolean {
|
|
162
|
-
return staleAxes(reading, codeFiles,
|
|
184
|
+
return staleAxes(reading, codeFiles, evalPath, didx, scIdx, remarks, probe, current).length > 0
|
|
163
185
|
}
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import { relative } from 'node:path'
|
|
2
|
+
import { repoRoot } from '../../spec-cli/src/git.js'
|
|
3
|
+
import { commitTrunkData } from '../../spec-cli/src/localIssues.js'
|
|
4
|
+
import { evalNodes, resolveEvalNode } from './scenarios.js'
|
|
5
|
+
import { readReadings, readSidecar, appendHumanOk, humanOkFor, type HumanOk } from './sidecar.js'
|
|
6
|
+
|
|
7
|
+
// @@@ human-ok - the human sign-off on an eval reading ([[human-ok]]). One write, both surfaces: the CLI
|
|
8
|
+
// (`spex eval ok`) and the HTTP route (`POST /api/specs/:id/evals/ok`, identity server-derived 'human')
|
|
9
|
+
// call THIS. The ok binds to ONE immutable reading — the scenario's latest effective reading at ok-time,
|
|
10
|
+
// anchored by its (ts, codeSha) — and is MONOTONIC: no un-ok verb exists, because a newer reading is a
|
|
11
|
+
// different object the ok never transfers to, and staleness is computed live; both bring the scenario back
|
|
12
|
+
// on their own. Durability follows the checkout: on the trunk checkout the append is committed straight to
|
|
13
|
+
// trunk (`--no-verify`, path-scoped, under the shared store lock — the [[local-issues]] discipline,
|
|
14
|
+
// commitTrunkData); on a linked worktree the append stays in that tree and the session's own ritual commit
|
|
15
|
+
// carries it, exactly like every other sidecar write.
|
|
16
|
+
export type OkResult =
|
|
17
|
+
| { ok: true; humanOk: HumanOk; already: boolean; landed: 'committed' | 'uncommitted' }
|
|
18
|
+
| { ok: false; error: string }
|
|
19
|
+
|
|
20
|
+
export function fileHumanOk(nodeId: string, scenario: string, by: string): OkResult {
|
|
21
|
+
const root = repoRoot()
|
|
22
|
+
// the same loud resolution every eval verb applies ([[eval-core]]): exact canonical id, else a unique
|
|
23
|
+
// bare leaf; an ambiguous leaf returns the candidate list instead of blessing an arbitrary node.
|
|
24
|
+
const res = resolveEvalNode(evalNodes(root), nodeId)
|
|
25
|
+
if (!res.ok) return { ok: false, error: res.error }
|
|
26
|
+
const node = res.node
|
|
27
|
+
if (!node.scenarios.some((s) => s.name === scenario) &&
|
|
28
|
+
!readSidecar(node.sidecarPath).readings.some((r) => r.scenario === scenario))
|
|
29
|
+
return { ok: false, error: `'${node.id}' has no scenario '${scenario}'` }
|
|
30
|
+
// the ok's one possible target: the latest EFFECTIVE reading — an ok is a judgment on a measurement that
|
|
31
|
+
// exists and currently counts; an unmeasured (or fully-retracted) scenario has nothing to bless.
|
|
32
|
+
const forScenario = readReadings(node.sidecarPath).filter((r) => r.scenario === scenario)
|
|
33
|
+
if (!forScenario.length) return { ok: false, error: `'${node.id}' scenario '${scenario}' has no effective reading — nothing to ok` }
|
|
34
|
+
const latest = forScenario[forScenario.length - 1]
|
|
35
|
+
// a duplicate ok is idempotent success (the store already IS the requested state — the local-issue
|
|
36
|
+
// close's `already` semantics), never an error and never a second appended row.
|
|
37
|
+
const existing = humanOkFor(readSidecar(node.sidecarPath).oks, scenario, latest.ts)
|
|
38
|
+
if (existing) return { ok: true, humanOk: existing, already: true, landed: 'committed' }
|
|
39
|
+
const row: HumanOk = { kind: 'human-ok', scenario, okTs: latest.ts, okSha: latest.codeSha, by, ts: new Date().toISOString() }
|
|
40
|
+
appendHumanOk(node.sidecarPath, row)
|
|
41
|
+
const landed = commitTrunkData(relative(root, node.sidecarPath), `eval(${node.id}): human-ok '${scenario}' @ ${latest.ts} by ${by}`)
|
|
42
|
+
return { ok: true, humanOk: row, already: false, landed: landed === 'not-primary' ? 'uncommitted' : 'committed' }
|
|
43
|
+
}
|
|
@@ -1,11 +1,11 @@
|
|
|
1
1
|
import { spawn } from 'node:child_process'
|
|
2
2
|
import { git, gitA, headSha } from '../../spec-cli/src/git.js'
|
|
3
|
-
import { parseScenarios } from './
|
|
3
|
+
import { parseScenarios } from './scenarios.js'
|
|
4
4
|
|
|
5
5
|
// @@@ per-scenario content freshness — the SCENARIO axis, sub-file
|
|
6
|
-
//
|
|
6
|
+
// An eval.md holds many scenarios, but a reading measures ONE. eval-core's contract says "a scenario is the
|
|
7
7
|
// unit of measurement, so its freshness is its OWN — two scenarios stale independently." Git has no sub-file
|
|
8
|
-
// history, so we build it: for each scenario NAME in
|
|
8
|
+
// history, so we build it: for each scenario NAME in an eval.md, the commits where THAT scenario's SEMANTIC
|
|
9
9
|
// block content (description + expected — see blockContent) changed (added / removed / edited),
|
|
10
10
|
// rename-followed. `scenarioMoved` then reads exactly like the code axis's `changedSince` — a pure ancestry
|
|
11
11
|
// lookup over this per-scenario commit list — so editing one scenario never re-stales its siblings (the
|
|
@@ -13,7 +13,7 @@ import { parseScenarios } from './yatsu.js'
|
|
|
13
13
|
|
|
14
14
|
const RS = '\x1e'
|
|
15
15
|
|
|
16
|
-
//
|
|
16
|
+
// evalPath (head path) -> scenario name -> commit hashes that changed that scenario's block (newest-first)
|
|
17
17
|
export type ScenarioIndex = Map<string, Map<string, string[]>>
|
|
18
18
|
|
|
19
19
|
// the block content that stales a reading: the scenario's SEMANTIC fields only — description (what to
|
|
@@ -42,7 +42,7 @@ const EMPTY: Map<string, string> = new Map()
|
|
|
42
42
|
// already parsed, which is what lets a worktree's freshness build stay cheap instead of re-reading everything.
|
|
43
43
|
const blockByOid = new Map<string, Map<string, string>>()
|
|
44
44
|
|
|
45
|
-
// ONE `git log --raw` walk over all
|
|
45
|
+
// ONE `git log --raw` walk over all eval.md → per head-path version chain [{hash, oid}], newest-first,
|
|
46
46
|
// rename-followed via the `alias` idiom (git.ts buildIndex): the newest sighting of a path IS its head path;
|
|
47
47
|
// an `R` row remaps the older `from` path onto that head. The raw row carries the new blob OID directly, so
|
|
48
48
|
// no per-version path resolution and no `git show`. --full-history is REQUIRED: default pathspec
|
|
@@ -53,8 +53,13 @@ const blockByOid = new Map<string, Map<string, string>>()
|
|
|
53
53
|
async function fileChains(root: string, wanted: Set<string>): Promise<Map<string, { hash: string; oid: string }[]>> {
|
|
54
54
|
const chains = new Map<string, { hash: string; oid: string }[]>()
|
|
55
55
|
const alias = new Map<string, string>()
|
|
56
|
+
// DUAL pathspec — the live name AND the archived one ([[eval-core]]): this walk reads IMMUTABLE history,
|
|
57
|
+
// and pre-rename commits touched files literally named yatsu.md, so an archive answers only to its
|
|
58
|
+
// archive name. A single '*eval.md' pathspec truncates every chain at the yatsu.md→eval.md rename commit,
|
|
59
|
+
// making the rename read as each scenario's birth — a change-commit newer than every pre-rename reading's
|
|
60
|
+
// codeSha — and false-stales whole adopter corpora (the scenariofresh.test.ts regression pins this).
|
|
56
61
|
const out = await gitA(['-C', root, '-c', 'core.quotePath=false', 'log',
|
|
57
|
-
'--raw', '--no-abbrev', '--full-history', '-M', `--format=${RS}%H`, '--', '*yatsu.md'])
|
|
62
|
+
'--raw', '--no-abbrev', '--full-history', '-M', `--format=${RS}%H`, '--', '*eval.md', '*yatsu.md']) // dead-words-ok: archived pathspec — immutable pre-rename history is read under its archived name
|
|
58
63
|
for (const rec of out.split(RS)) {
|
|
59
64
|
const nl = rec.indexOf('\n')
|
|
60
65
|
if (nl < 0) continue
|
|
@@ -115,7 +120,7 @@ function catFileBatch(root: string, oids: string[]): Promise<Map<string, string>
|
|
|
115
120
|
p.on('close', (_code, signal) => {
|
|
116
121
|
// a child that never exited was SIGKILLed at the timeout (a hung git must not pin this promise —
|
|
117
122
|
// same bound as git.ts's helpers); warn loudly and parse whatever arrived.
|
|
118
|
-
if (signal === 'SIGKILL') console.warn(`spec-
|
|
123
|
+
if (signal === 'SIGKILL') console.warn(`spec-eval: git cat-file --batch killed after timeout — child never exited`)
|
|
119
124
|
const buf = Buffer.concat(chunks)
|
|
120
125
|
let i = 0
|
|
121
126
|
while (i < buf.length) {
|
|
@@ -139,8 +144,8 @@ function catFileBatch(root: string, oids: string[]): Promise<Map<string, string>
|
|
|
139
144
|
|
|
140
145
|
// TWO git subprocesses for the whole index (was F logs + V shows): one `--raw` log for the rename-followed
|
|
141
146
|
// chains, one `cat-file --batch` for every distinct blob not already memoized. Then a pure in-memory diff.
|
|
142
|
-
async function build(root: string,
|
|
143
|
-
const chains = await fileChains(root, new Set(
|
|
147
|
+
async function build(root: string, evalPaths: string[]): Promise<ScenarioIndex> {
|
|
148
|
+
const chains = await fileChains(root, new Set(evalPaths))
|
|
144
149
|
const need = new Set<string>()
|
|
145
150
|
for (const chain of chains.values()) for (const v of chain) if (v.oid !== ZERO && !blockByOid.has(v.oid)) need.add(v.oid)
|
|
146
151
|
if (need.size) {
|
|
@@ -148,7 +153,7 @@ async function build(root: string, yatsuPaths: string[]): Promise<ScenarioIndex>
|
|
|
148
153
|
for (const [oid, src] of blobs) blockByOid.set(oid, blockContent(src))
|
|
149
154
|
}
|
|
150
155
|
const idx: ScenarioIndex = new Map()
|
|
151
|
-
for (const p of
|
|
156
|
+
for (const p of evalPaths) idx.set(p, scenarioCommits(chains.get(p) ?? [])) // every asked path gets an entry (empty if untracked)
|
|
152
157
|
return idx
|
|
153
158
|
}
|
|
154
159
|
|
|
@@ -156,20 +161,20 @@ async function build(root: string, yatsuPaths: string[]): Promise<ScenarioIndex>
|
|
|
156
161
|
// whatever the caller. Holds the in-flight promise so concurrent board builds share one build.
|
|
157
162
|
const SLOTS = 16
|
|
158
163
|
const cache = new Map<string, Promise<ScenarioIndex>>()
|
|
159
|
-
export function scenarioIndex(root: string,
|
|
164
|
+
export function scenarioIndex(root: string, evalPaths: string[]): Promise<ScenarioIndex> {
|
|
160
165
|
let head: string
|
|
161
|
-
try { head = headSha(root) } catch { return build(root,
|
|
166
|
+
try { head = headSha(root) } catch { return build(root, evalPaths) }
|
|
162
167
|
const hit = cache.get(head)
|
|
163
168
|
if (hit) { cache.delete(head); cache.set(head, hit); return hit }
|
|
164
|
-
const p = build(root,
|
|
169
|
+
const p = build(root, evalPaths)
|
|
165
170
|
p.catch(() => cache.delete(head))
|
|
166
171
|
cache.set(head, p)
|
|
167
172
|
while (cache.size > SLOTS) cache.delete(cache.keys().next().value!)
|
|
168
173
|
return p
|
|
169
174
|
}
|
|
170
175
|
|
|
171
|
-
export function scenarioChangeCommits(idx: ScenarioIndex,
|
|
172
|
-
return idx.get(
|
|
176
|
+
export function scenarioChangeCommits(idx: ScenarioIndex, evalPath: string, scenario: string): string[] {
|
|
177
|
+
return idx.get(evalPath)?.get(scenario) ?? []
|
|
173
178
|
}
|
|
174
179
|
|
|
175
180
|
// (root, sha, path) -> the blob oid at that commit ('' = unresolvable: path absent there, or the commit
|
|
@@ -194,7 +199,7 @@ function oidAt(root: string, rev: string, path: string): string {
|
|
|
194
199
|
}
|
|
195
200
|
|
|
196
201
|
// canonical per-scenario SEMANTIC blocks of `rev:path` (the blockContent projection), for the off-history
|
|
197
|
-
// content fallback ([[
|
|
202
|
+
// content fallback ([[eval-core]]'s ContentProbe): resolve the blob oid first (memoized above for a full
|
|
198
203
|
// sha) — oids are content-addressed, so an unchanged file usually hits blockByOid straight from the index
|
|
199
204
|
// build — and parse only on a genuine miss. null = the path is unreadable at that rev (absent, renamed
|
|
200
205
|
// since, or the rev itself is gone).
|
|
@@ -204,7 +209,7 @@ export function scenarioBlocksAt(root: string, rev: string, path: string): Map<s
|
|
|
204
209
|
const hit = blockByOid.get(oid)
|
|
205
210
|
if (hit) return hit
|
|
206
211
|
try {
|
|
207
|
-
const m = blockContent(git(['-C', root, 'cat-file', 'blob', oid]))
|
|
212
|
+
const m = blockContent(git(['-C', root, 'cat-file', 'blob', oid])) // dead-words-ok: git plumbing — 'blob' is git's object type, not our vocabulary
|
|
208
213
|
blockByOid.set(oid, m)
|
|
209
214
|
return m
|
|
210
215
|
} catch { return null }
|