spexcode 0.5.2 → 0.5.3
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 +1 -1
- package/package.json +1 -1
- package/spec-cli/bin/spex.mjs +25 -1
- package/spec-cli/hooks/dispatch.sh +24 -1
- package/spec-cli/src/anchors.ts +192 -58
- package/spec-cli/src/attach.ts +11 -8
- package/spec-cli/src/claude-headless.ts +13 -52
- package/spec-cli/src/cli.ts +209 -71
- package/spec-cli/src/client.ts +143 -5
- package/spec-cli/src/gateway-hub.ts +2 -6
- package/spec-cli/src/gateway.ts +96 -11
- package/spec-cli/src/git.ts +1395 -96
- package/spec-cli/src/graph.ts +21 -17
- package/spec-cli/src/graphCache.ts +405 -61
- package/spec-cli/src/graphStream.ts +581 -182
- package/spec-cli/src/guide.ts +13 -3
- package/spec-cli/src/harness.ts +946 -71
- package/spec-cli/src/headless-controller.ts +63 -0
- package/spec-cli/src/help.ts +120 -64
- package/spec-cli/src/host-resources.ts +706 -0
- package/spec-cli/src/index.ts +95 -21
- package/spec-cli/src/init.ts +47 -8
- package/spec-cli/src/issues.ts +36 -3
- package/spec-cli/src/layout.ts +193 -37
- package/spec-cli/src/lint.ts +152 -27
- package/spec-cli/src/localIssues.ts +5 -1
- package/spec-cli/src/maintenance-wrapper.ts +276 -0
- package/spec-cli/src/materialize.ts +6 -7
- package/spec-cli/src/opencode-headless.ts +4 -8
- package/spec-cli/src/pi-headless.ts +17 -60
- package/spec-cli/src/process-identity.ts +49 -0
- package/spec-cli/src/project-store.ts +17 -0
- package/spec-cli/src/pty-bridge.ts +6 -3
- package/spec-cli/src/reviews.ts +17 -13
- package/spec-cli/src/runtime-ownership.ts +99 -0
- package/spec-cli/src/session-maintenance.ts +750 -0
- package/spec-cli/src/session-timeline.ts +6 -6
- package/spec-cli/src/sessions.ts +1249 -208
- package/spec-cli/src/sh.ts +3 -0
- package/spec-cli/src/source-files.ts +27 -2
- package/spec-cli/src/specs.ts +58 -25
- package/spec-cli/src/supervise.ts +31 -11
- package/spec-cli/src/uninstall.ts +7 -6
- package/spec-cli/templates/hooks/commit-msg +14 -0
- package/spec-cli/templates/hooks/pre-commit +26 -6
- package/spec-cli/templates/hooks/prepare-commit-msg +20 -48
- package/spec-cli/templates/hooks/reference-transaction +105 -0
- package/spec-cli/templates/spec/project/.plugins/commands/supervisor/spec.md +1 -1
- package/spec-cli/templates/spec/project/.plugins/core/mark-active/mark-active.sh +23 -21
- package/spec-cli/templates/spec/project/.plugins/core/spec.md +12 -10
- package/spec-cli/templates/spec/project/.plugins/core/stop-gate/stop-gate.sh +15 -6
- package/spec-cli/templates/spec/project/.plugins/prompts/atomic-landing/spec.md +32 -0
- package/spec-dashboard/dist/assets/App-C5vbTw8Q.js +2 -0
- package/spec-dashboard/dist/assets/Dashboard-u8RIS3NY.js +27 -0
- package/spec-dashboard/dist/assets/EvalsPage-BS7ITcNo.js +2 -0
- package/spec-dashboard/dist/assets/IssuesPage-DXbqQFW_.js +1 -0
- package/spec-dashboard/dist/assets/KaTeX_AMS-Regular-BQhdFMY1.woff2 +0 -0
- package/spec-dashboard/dist/assets/KaTeX_AMS-Regular-DMm9YOAa.woff +0 -0
- package/spec-dashboard/dist/assets/KaTeX_AMS-Regular-DRggAlZN.ttf +0 -0
- package/spec-dashboard/dist/assets/KaTeX_Caligraphic-Bold-ATXxdsX0.ttf +0 -0
- package/spec-dashboard/dist/assets/KaTeX_Caligraphic-Bold-BEiXGLvX.woff +0 -0
- package/spec-dashboard/dist/assets/KaTeX_Caligraphic-Bold-Dq_IR9rO.woff2 +0 -0
- package/spec-dashboard/dist/assets/KaTeX_Caligraphic-Regular-CTRA-rTL.woff +0 -0
- package/spec-dashboard/dist/assets/KaTeX_Caligraphic-Regular-Di6jR-x-.woff2 +0 -0
- package/spec-dashboard/dist/assets/KaTeX_Caligraphic-Regular-wX97UBjC.ttf +0 -0
- package/spec-dashboard/dist/assets/KaTeX_Fraktur-Bold-BdnERNNW.ttf +0 -0
- package/spec-dashboard/dist/assets/KaTeX_Fraktur-Bold-BsDP51OF.woff +0 -0
- package/spec-dashboard/dist/assets/KaTeX_Fraktur-Bold-CL6g_b3V.woff2 +0 -0
- package/spec-dashboard/dist/assets/KaTeX_Fraktur-Regular-CB_wures.ttf +0 -0
- package/spec-dashboard/dist/assets/KaTeX_Fraktur-Regular-CTYiF6lA.woff2 +0 -0
- package/spec-dashboard/dist/assets/KaTeX_Fraktur-Regular-Dxdc4cR9.woff +0 -0
- package/spec-dashboard/dist/assets/KaTeX_Main-Bold-Cx986IdX.woff2 +0 -0
- package/spec-dashboard/dist/assets/KaTeX_Main-Bold-Jm3AIy58.woff +0 -0
- package/spec-dashboard/dist/assets/KaTeX_Main-Bold-waoOVXN0.ttf +0 -0
- package/spec-dashboard/dist/assets/KaTeX_Main-BoldItalic-DxDJ3AOS.woff2 +0 -0
- package/spec-dashboard/dist/assets/KaTeX_Main-BoldItalic-DzxPMmG6.ttf +0 -0
- package/spec-dashboard/dist/assets/KaTeX_Main-BoldItalic-SpSLRI95.woff +0 -0
- package/spec-dashboard/dist/assets/KaTeX_Main-Italic-3WenGoN9.ttf +0 -0
- package/spec-dashboard/dist/assets/KaTeX_Main-Italic-BMLOBm91.woff +0 -0
- package/spec-dashboard/dist/assets/KaTeX_Main-Italic-NWA7e6Wa.woff2 +0 -0
- package/spec-dashboard/dist/assets/KaTeX_Main-Regular-B22Nviop.woff2 +0 -0
- package/spec-dashboard/dist/assets/KaTeX_Main-Regular-Dr94JaBh.woff +0 -0
- package/spec-dashboard/dist/assets/KaTeX_Main-Regular-ypZvNtVU.ttf +0 -0
- package/spec-dashboard/dist/assets/KaTeX_Math-BoldItalic-B3XSjfu4.ttf +0 -0
- package/spec-dashboard/dist/assets/KaTeX_Math-BoldItalic-CZnvNsCZ.woff2 +0 -0
- package/spec-dashboard/dist/assets/KaTeX_Math-BoldItalic-iY-2wyZ7.woff +0 -0
- package/spec-dashboard/dist/assets/KaTeX_Math-Italic-DA0__PXp.woff +0 -0
- package/spec-dashboard/dist/assets/KaTeX_Math-Italic-flOr_0UB.ttf +0 -0
- package/spec-dashboard/dist/assets/KaTeX_Math-Italic-t53AETM-.woff2 +0 -0
- package/spec-dashboard/dist/assets/KaTeX_SansSerif-Bold-CFMepnvq.ttf +0 -0
- package/spec-dashboard/dist/assets/KaTeX_SansSerif-Bold-D1sUS0GD.woff2 +0 -0
- package/spec-dashboard/dist/assets/KaTeX_SansSerif-Bold-DbIhKOiC.woff +0 -0
- package/spec-dashboard/dist/assets/KaTeX_SansSerif-Italic-C3H0VqGB.woff2 +0 -0
- package/spec-dashboard/dist/assets/KaTeX_SansSerif-Italic-DN2j7dab.woff +0 -0
- package/spec-dashboard/dist/assets/KaTeX_SansSerif-Italic-YYjJ1zSn.ttf +0 -0
- package/spec-dashboard/dist/assets/KaTeX_SansSerif-Regular-BNo7hRIc.ttf +0 -0
- package/spec-dashboard/dist/assets/KaTeX_SansSerif-Regular-CS6fqUqJ.woff +0 -0
- package/spec-dashboard/dist/assets/KaTeX_SansSerif-Regular-DDBCnlJ7.woff2 +0 -0
- package/spec-dashboard/dist/assets/KaTeX_Script-Regular-C5JkGWo-.ttf +0 -0
- package/spec-dashboard/dist/assets/KaTeX_Script-Regular-D3wIWfF6.woff2 +0 -0
- package/spec-dashboard/dist/assets/KaTeX_Script-Regular-D5yQViql.woff +0 -0
- package/spec-dashboard/dist/assets/KaTeX_Size1-Regular-C195tn64.woff +0 -0
- package/spec-dashboard/dist/assets/KaTeX_Size1-Regular-Dbsnue_I.ttf +0 -0
- package/spec-dashboard/dist/assets/KaTeX_Size1-Regular-mCD8mA8B.woff2 +0 -0
- package/spec-dashboard/dist/assets/KaTeX_Size2-Regular-B7gKUWhC.ttf +0 -0
- package/spec-dashboard/dist/assets/KaTeX_Size2-Regular-Dy4dx90m.woff2 +0 -0
- package/spec-dashboard/dist/assets/KaTeX_Size2-Regular-oD1tc_U0.woff +0 -0
- package/spec-dashboard/dist/assets/KaTeX_Size3-Regular-CTq5MqoE.woff +0 -0
- package/spec-dashboard/dist/assets/KaTeX_Size3-Regular-DgpXs0kz.ttf +0 -0
- package/spec-dashboard/dist/assets/KaTeX_Size4-Regular-BF-4gkZK.woff +0 -0
- package/spec-dashboard/dist/assets/KaTeX_Size4-Regular-DWFBv043.ttf +0 -0
- package/spec-dashboard/dist/assets/KaTeX_Size4-Regular-Dl5lxZxV.woff2 +0 -0
- package/spec-dashboard/dist/assets/KaTeX_Typewriter-Regular-C0xS9mPB.woff +0 -0
- package/spec-dashboard/dist/assets/KaTeX_Typewriter-Regular-CO6r4hn1.woff2 +0 -0
- package/spec-dashboard/dist/assets/KaTeX_Typewriter-Regular-D3Ib7_Hf.ttf +0 -0
- package/spec-dashboard/dist/assets/MobileApp-DVLnk9hz.js +2 -0
- package/spec-dashboard/dist/assets/Modal-6mHq6fbZ.js +1 -0
- package/spec-dashboard/dist/assets/{PageScroll-CadAKuSy.js → PageScroll-CAY4S4g4.js} +1 -1
- package/spec-dashboard/dist/assets/ProjectsPage-UQyzsTWN.js +1 -0
- package/spec-dashboard/dist/assets/SessionInterface-DKU4c1Z-.js +39 -0
- package/spec-dashboard/dist/assets/SessionWindow-zGwJaGbR.js +1 -0
- package/spec-dashboard/dist/assets/Settings-igR17pns.js +1 -0
- package/spec-dashboard/dist/assets/Thread-B-ZUarN1.js +13 -0
- package/spec-dashboard/dist/assets/TimelineChat-Cp579UoJ.css +1 -0
- package/spec-dashboard/dist/assets/TimelineChat-sc49Qj5d.js +276 -0
- package/spec-dashboard/dist/assets/data-B1ot4PF0.js +1 -0
- package/spec-dashboard/dist/assets/index-BqBNCa1V.js +41 -0
- package/spec-dashboard/dist/assets/index-CzutlTDf.css +1 -0
- package/spec-dashboard/dist/assets/{terminalFont-DmTPNdbU.js → terminalFont-C6oGNKrf.js} +1 -1
- package/spec-dashboard/dist/index.html +5 -4
- package/spec-dashboard/src/reviewFilters.js +46 -8
- package/spec-dashboard/src/session.js +64 -9
- package/spec-eval/src/cli.ts +144 -32
- package/spec-eval/src/evaltab.ts +16 -9
- package/spec-eval/src/freshness.ts +336 -54
- package/spec-eval/src/scenariofresh.ts +51 -1
- package/spec-eval/src/scenarios.ts +324 -25
- package/spec-eval/src/sessioneval.ts +969 -173
- package/spec-cli/src/message-stream.ts +0 -147
- package/spec-dashboard/dist/assets/Dashboard-CTAuTyZ3.js +0 -27
- package/spec-dashboard/dist/assets/EvalsPage-KbMMownG.js +0 -2
- package/spec-dashboard/dist/assets/IssuesPage-DmyLb9Rj.js +0 -1
- package/spec-dashboard/dist/assets/MobileApp-D2RZGt4Z.js +0 -2
- package/spec-dashboard/dist/assets/Modal-3brXUhM0.js +0 -1
- package/spec-dashboard/dist/assets/ProjectsPage-DU3x4Y8l.js +0 -1
- package/spec-dashboard/dist/assets/SessionInterface-BtrzlOPs.js +0 -39
- package/spec-dashboard/dist/assets/SessionWindow-BWH5O0jh.js +0 -13
- package/spec-dashboard/dist/assets/Settings-COgdKTJB.js +0 -1
- package/spec-dashboard/dist/assets/TimelineChat-DQ21GSJK.js +0 -1
- package/spec-dashboard/dist/assets/index-D6HBvKkJ.js +0 -41
- package/spec-dashboard/dist/assets/index-DFdlYy4H.css +0 -1
|
@@ -1,7 +1,10 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { readFileSync } from 'node:fs'
|
|
2
|
+
import { join, resolve } from 'node:path'
|
|
3
|
+
import { gitA, gitTry, headSha, currentGitBuildAbortSignal, gitAbortError, ancestorsOf, inAncestors, commitReachable, pathEvents, type DriftIndex, type DriftPathEvent } from '../../spec-cli/src/git.js'
|
|
4
|
+
import { anchorHitCommits, extOf, extractorFor, extractors, resolveAnchor, type Extractor, type RelationEntry } from '../../spec-cli/src/anchors.js'
|
|
2
5
|
import type { Reading } from './sidecar.js'
|
|
3
|
-
import { scenarioHash, type Scenario } from './scenarios.js'
|
|
4
|
-
import { scenarioChangeCommits, scenarioBlocksAt, type ScenarioIndex } from './scenariofresh.js'
|
|
6
|
+
import { scenarioCodeAxis, scenarioHash, type Scenario } from './scenarios.js'
|
|
7
|
+
import { scenarioChangeCommits, scenarioBlocksAt, primeScenarioBlocksAt, type ScenarioIndex } from './scenariofresh.js'
|
|
5
8
|
|
|
6
9
|
// 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
|
|
7
10
|
|
|
@@ -9,47 +12,229 @@ export type StaleAxis = 'code' | 'scenario' | 'remark' | 'anchor'
|
|
|
9
12
|
|
|
10
13
|
// @@@ off-history content fallback - ancestry can't testify for a codeSha that isn't reachable from HEAD
|
|
11
14
|
// (fold/rebase/squash-merge/cherry-pick all orphan the anchor), but the TREES still can: while the anchor
|
|
12
|
-
// commit object exists locally, `git diff <anchor> HEAD
|
|
13
|
-
// a
|
|
14
|
-
//
|
|
15
|
-
//
|
|
16
|
-
// (
|
|
17
|
-
//
|
|
15
|
+
// commit object exists locally, a pathspec-scoped `git diff <anchor> HEAD -- :(literal)<path>…` answers,
|
|
16
|
+
// for exactly the governed paths a caller asks about, whether content differs — so a history rewrite that
|
|
17
|
+
// left governed content byte-identical reads FRESH instead of false-positive stale. The question is per
|
|
18
|
+
// REQUESTED path on purpose: one whole-repo changed-path set per anchor is what melted a production fold
|
|
19
|
+
// (522 anchors × ~6k retained paths ≈ 520MB of Node heap), while the verdicts actually consumed are a
|
|
20
|
+
// handful of governed files plus the eval.md. The probe is fed at the call sites (like the remark track) so
|
|
21
|
+
// the decision functions stay pure over their inputs and the in-history fast path pays no extra git call;
|
|
22
|
+
// only when the commit object is truly gone (gc'd orphan) does the conservative rule remain — surfaced as
|
|
23
|
+
// the 'anchor' axis, so "anchor lost" reads differently from "content moved". No probe fed → the old
|
|
24
|
+
// always-conservative rule.
|
|
18
25
|
export type ContentProbe = {
|
|
19
|
-
//
|
|
20
|
-
|
|
26
|
+
// did THIS path's content change between the anchor tree and HEAD's? null = can't testify (anchor gone,
|
|
27
|
+
// or the path never primed) — callers stay conservative. Only primed paths are ever retained.
|
|
28
|
+
changed(anchorSha: string, path: string): boolean | null
|
|
29
|
+
// content CAN testify for this anchor: the object is readable and at least one verdict has settled
|
|
30
|
+
canTestify(anchorSha: string): boolean
|
|
21
31
|
// did THIS scenario's semantic block (description+expected) move between anchor and HEAD ([[scenariofresh]])
|
|
22
32
|
scenarioDiffers(anchorSha: string, evalPath: string, scenario: string): boolean
|
|
23
33
|
// codeDrift's display detail: commits in anchor..HEAD touching path (floored at 1 — the content differs)
|
|
24
34
|
behind(anchorSha: string, path: string): number
|
|
35
|
+
prime?(anchorSha: string, paths: string[], evalPath: string): Promise<void>
|
|
25
36
|
}
|
|
26
37
|
|
|
27
|
-
// (anchor, HEAD) name two immutable trees, so
|
|
28
|
-
//
|
|
29
|
-
//
|
|
30
|
-
//
|
|
31
|
-
//
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
38
|
+
// (anchor, HEAD) name two immutable trees, so a settled verdict never invalidates — but "never invalidates"
|
|
39
|
+
// is not "keep forever". A checkout answers freshness questions at ONE head at a time, so a root owns
|
|
40
|
+
// exactly one head's verdicts ([[source-of-truth]]'s current-root rule, the same one the history and drift
|
|
41
|
+
// indices follow). Keying the head into a shared memo instead made every rebuild ADD a generation: three
|
|
42
|
+
// full invalidations left three heads' worth of anchors resident, and the cache grew with rebuild count
|
|
43
|
+
// rather than with corpus size. A head move therefore swaps the root's scope atomically. An in-flight batch
|
|
44
|
+
// keeps the entry object its caller already holds, so that caller still settles, but a detached entry can
|
|
45
|
+
// never be read back through the root's current scope — an old flight cannot backfill the new head.
|
|
46
|
+
// An anchor entry holds only PER-REQUESTED-PATH verdicts (plus the gone bit and the batch bookkeeping),
|
|
47
|
+
// never a whole-repo path set.
|
|
48
|
+
type AnchorVerdicts = {
|
|
49
|
+
verdicts: Map<string, boolean> // requested path → content differs between the two immutable trees
|
|
50
|
+
gone: boolean // the anchor commit object is locally unreadable — content can't testify
|
|
51
|
+
pending: Set<string> // paths awaiting the next batch child
|
|
52
|
+
flight: Promise<void> | null // the single in-flight batch for this anchor
|
|
53
|
+
}
|
|
54
|
+
type RootScope = { head: string; anchors: Map<string, AnchorVerdicts>; behind: Map<string, number> }
|
|
55
|
+
const rootScopes = new Map<string, RootScope>()
|
|
56
|
+
// Roots come and go (a closed worktree never asks again), so cap how many stay warm — the same bounded-slot
|
|
57
|
+
// guard the index caches use, and the only bound needed once per-root cardinality is the corpus, not history.
|
|
58
|
+
const ROOT_SLOTS = Math.max(4, Number(process.env.SPEXCODE_FRESHNESS_ROOT_SLOTS || 64))
|
|
59
|
+
|
|
60
|
+
function scopeFor(rootKey: string, head: string): RootScope {
|
|
61
|
+
const current = rootScopes.get(rootKey)
|
|
62
|
+
if (current?.head === head) {
|
|
63
|
+
rootScopes.delete(rootKey)
|
|
64
|
+
rootScopes.set(rootKey, current)
|
|
65
|
+
return current
|
|
66
|
+
}
|
|
67
|
+
const scope: RootScope = { head, anchors: new Map(), behind: new Map() }
|
|
68
|
+
rootScopes.set(rootKey, scope)
|
|
69
|
+
while (rootScopes.size > ROOT_SLOTS) {
|
|
70
|
+
const oldest = rootScopes.keys().next().value
|
|
71
|
+
if (oldest === undefined || oldest === rootKey) break
|
|
72
|
+
rootScopes.delete(oldest)
|
|
73
|
+
}
|
|
74
|
+
return scope
|
|
75
|
+
}
|
|
76
|
+
// a read only ever sees the root's CURRENT head — a probe pinned to a superseded head can't testify
|
|
77
|
+
function currentScope(rootKey: string, head: string): RootScope | undefined {
|
|
78
|
+
const scope = rootScopes.get(rootKey)
|
|
79
|
+
return scope?.head === head ? scope : undefined
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
// the cardinality invariant, made observable: how many anchor entries are resident at a root's CURRENT head
|
|
83
|
+
// (every root when none is named). It must track the corpus's anchors, never how many times the board has
|
|
84
|
+
// been rebuilt — one retained generation, not one per rebuild.
|
|
85
|
+
export function freshnessCacheSize(root?: string): number {
|
|
86
|
+
if (root !== undefined) return rootScopes.get(resolve(root))?.anchors.size ?? 0
|
|
87
|
+
let total = 0
|
|
88
|
+
for (const scope of rootScopes.values()) total += scope.anchors.size
|
|
89
|
+
return total
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
function touchAnchor(scope: RootScope, sha: string): AnchorVerdicts {
|
|
93
|
+
const hit = scope.anchors.get(sha)
|
|
94
|
+
if (hit) return hit
|
|
95
|
+
const entry: AnchorVerdicts = { verdicts: new Map(), gone: false, pending: new Set(), flight: null }
|
|
96
|
+
scope.anchors.set(sha, entry)
|
|
97
|
+
return entry
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
type HeavyDiffWaiter = {
|
|
101
|
+
signal?: AbortSignal
|
|
102
|
+
resolve: (release: () => void) => void
|
|
103
|
+
reject: (error: Error) => void
|
|
104
|
+
onAbort: () => void
|
|
105
|
+
}
|
|
106
|
+
type HeavyDiffScope = { active: boolean; waiting: HeavyDiffWaiter[] }
|
|
107
|
+
const heavyDiffScopes = new Map<string, HeavyDiffScope>()
|
|
108
|
+
|
|
109
|
+
// One tree comparison at a time is enough for a production repo's pack/index memory. The graph-wide git
|
|
110
|
+
// permits still bound every child; this narrower domain scheduler keeps these immutable-tree
|
|
111
|
+
// comparisons serial per (repo, HEAD), without teaching the git transport what a content fallback is.
|
|
112
|
+
function acquireHeavyDiff(scopeKey: string, signal?: AbortSignal): Promise<() => void> {
|
|
113
|
+
if (signal?.aborted) return Promise.reject(gitAbortError())
|
|
114
|
+
let scope = heavyDiffScopes.get(scopeKey)
|
|
115
|
+
if (!scope) {
|
|
116
|
+
scope = { active: false, waiting: [] }
|
|
117
|
+
heavyDiffScopes.set(scopeKey, scope)
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
const releaseFor = (): (() => void) => {
|
|
121
|
+
let released = false
|
|
122
|
+
return () => {
|
|
123
|
+
if (released) return
|
|
124
|
+
released = true
|
|
125
|
+
scope!.active = false
|
|
126
|
+
while (scope!.waiting.length) {
|
|
127
|
+
const waiter = scope!.waiting.shift()!
|
|
128
|
+
waiter.signal?.removeEventListener('abort', waiter.onAbort)
|
|
129
|
+
if (waiter.signal?.aborted) {
|
|
130
|
+
waiter.reject(gitAbortError())
|
|
131
|
+
continue
|
|
132
|
+
}
|
|
133
|
+
scope!.active = true
|
|
134
|
+
waiter.resolve(releaseFor())
|
|
135
|
+
return
|
|
136
|
+
}
|
|
137
|
+
if (heavyDiffScopes.get(scopeKey) === scope) heavyDiffScopes.delete(scopeKey)
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
if (!scope.active) {
|
|
142
|
+
scope.active = true
|
|
143
|
+
return Promise.resolve(releaseFor())
|
|
144
|
+
}
|
|
145
|
+
return new Promise((resolvePermit, reject) => {
|
|
146
|
+
const waiter: HeavyDiffWaiter = {
|
|
147
|
+
signal,
|
|
148
|
+
resolve: resolvePermit,
|
|
149
|
+
reject,
|
|
150
|
+
onAbort: () => {
|
|
151
|
+
const index = scope!.waiting.indexOf(waiter)
|
|
152
|
+
if (index < 0) return
|
|
153
|
+
scope!.waiting.splice(index, 1)
|
|
154
|
+
signal?.removeEventListener('abort', waiter.onAbort)
|
|
155
|
+
reject(gitAbortError())
|
|
156
|
+
if (!scope!.active && scope!.waiting.length === 0 && heavyDiffScopes.get(scopeKey) === scope)
|
|
157
|
+
heavyDiffScopes.delete(scopeKey)
|
|
158
|
+
},
|
|
159
|
+
}
|
|
160
|
+
scope!.waiting.push(waiter)
|
|
161
|
+
signal?.addEventListener('abort', waiter.onAbort, { once: true })
|
|
162
|
+
})
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
// @@@ one batch per anchor, only the paths actually asked about - the tree comparison is pathspec-scoped
|
|
166
|
+
// (`:(literal)` so a path holding a glob char, a space or a leading colon is matched verbatim, `-z` so the
|
|
167
|
+
// answer needs no unquoting), and the answer retained is one boolean per REQUESTED path. Concurrent primes
|
|
168
|
+
// on the same anchor therefore union their paths BEFORE the child starts: a caller records what it needs in
|
|
169
|
+
// `pending`, then either starts the batch that drains pending or joins the running one and re-checks after —
|
|
170
|
+
// so a path requested mid-flight rides the NEXT batch instead of racing this one, and a settled path is
|
|
171
|
+
// never asked again. Different anchors stay serial through the same scope permit.
|
|
172
|
+
function startAnchorBatch(root: string, rootKey: string, sha: string, current: string, entry: AnchorVerdicts): Promise<void> {
|
|
173
|
+
const run = async (): Promise<void> => {
|
|
174
|
+
const release = await acquireHeavyDiff(`${rootKey}\x1f${current}`, currentGitBuildAbortSignal())
|
|
175
|
+
// drain AFTER the permit, so every caller that registered while this batch was waiting rides THIS
|
|
176
|
+
// child instead of paying for another one; anything registered after this line is the next batch.
|
|
177
|
+
const batch = [...entry.pending]
|
|
178
|
+
entry.pending.clear()
|
|
179
|
+
try {
|
|
180
|
+
if (!batch.length) return
|
|
181
|
+
const result = await gitTry(['-C', root, 'diff', '--name-only', '-z', '--no-renames', sha, current,
|
|
182
|
+
'--', ...batch.map((path) => `:(literal)${path}`)])
|
|
183
|
+
if (!result.ok && result.failure !== 'exit')
|
|
184
|
+
throw new Error(`git content diff failed (${result.failure ?? 'unknown'}): ${result.stderr.trim() || 'unknown git error'}`)
|
|
185
|
+
if (!result.ok) { entry.gone = true; return } // the anchor commit object is unreadable — content can't testify
|
|
186
|
+
const changed = new Set(result.stdout.split('\0').filter(Boolean))
|
|
187
|
+
for (const path of batch) entry.verdicts.set(path, changed.has(path))
|
|
188
|
+
} finally {
|
|
189
|
+
release()
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
const flight = run().finally(() => { if (entry.flight === flight) entry.flight = null })
|
|
193
|
+
entry.flight = flight
|
|
194
|
+
return flight
|
|
40
195
|
}
|
|
41
196
|
|
|
42
197
|
export function contentProbeFor(root: string): ContentProbe {
|
|
198
|
+
const rootKey = resolve(root)
|
|
43
199
|
let head: string | undefined
|
|
44
200
|
const headOf = () => (head ??= headSha(root))
|
|
45
201
|
return {
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
202
|
+
async prime(sha, paths, evalPath) {
|
|
203
|
+
const current = headOf()
|
|
204
|
+
const wanted = [...new Set([...paths, evalPath])].filter(Boolean)
|
|
205
|
+
// one scope resolve per prime: the entry stays this call's to settle even if the root's head moves
|
|
206
|
+
// under it, but the swap means nothing it writes afterwards can be read back at the new head.
|
|
207
|
+
const scope = scopeFor(rootKey, current)
|
|
208
|
+
const entry = touchAnchor(scope, sha)
|
|
209
|
+
while (!entry.gone) {
|
|
210
|
+
const missing = wanted.filter((path) => !entry.verdicts.has(path))
|
|
211
|
+
if (!missing.length) break
|
|
212
|
+
for (const path of missing) entry.pending.add(path)
|
|
213
|
+
// record first, then join: whoever starts the next batch drains everything pending by now.
|
|
214
|
+
if (entry.flight) await entry.flight
|
|
215
|
+
else await startAnchorBatch(root, rootKey, sha, current, entry)
|
|
216
|
+
}
|
|
217
|
+
if (entry.gone) return
|
|
218
|
+
for (const path of new Set(paths)) {
|
|
219
|
+
if (entry.verdicts.get(path) !== true) continue
|
|
220
|
+
const behindKey = `${sha}\x1f${path}`
|
|
221
|
+
if (scope.behind.has(behindKey)) continue
|
|
222
|
+
const n = Number((await gitA(['-C', root, 'rev-list', '--count', `${sha}..${current}`, '--', path])).trim())
|
|
223
|
+
scope.behind.set(behindKey, Number.isFinite(n) && n > 0 ? n : 1)
|
|
224
|
+
}
|
|
225
|
+
if (entry.verdicts.get(evalPath) === true) await primeScenarioBlocksAt(root, [sha, current], evalPath)
|
|
226
|
+
},
|
|
227
|
+
changed(sha, path) {
|
|
228
|
+
// The async prime owns all I/O. An unprimed path is 'can't testify', never a fresh sync diff: a miss
|
|
229
|
+
// stays conservative and no abort/transient failure can turn into an unbounded synchronous fallback.
|
|
230
|
+
const entry = currentScope(rootKey, headOf())?.anchors.get(sha)
|
|
231
|
+
if (!entry || entry.gone) return null
|
|
232
|
+
return entry.verdicts.get(path) ?? null
|
|
233
|
+
},
|
|
234
|
+
canTestify(sha) {
|
|
235
|
+
// a settled verdict — of either polarity — is the proof the anchor's tree was readable
|
|
236
|
+
const entry = currentScope(rootKey, headOf())?.anchors.get(sha)
|
|
237
|
+
return !!entry && !entry.gone && entry.verdicts.size > 0
|
|
53
238
|
},
|
|
54
239
|
scenarioDiffers(sha, evalPath, scenario) {
|
|
55
240
|
const a = scenarioBlocksAt(root, sha, evalPath)
|
|
@@ -57,16 +242,99 @@ export function contentProbeFor(root: string): ContentProbe {
|
|
|
57
242
|
return a.get(scenario) !== scenarioBlocksAt(root, headOf(), evalPath)?.get(scenario)
|
|
58
243
|
},
|
|
59
244
|
behind(sha, path) {
|
|
60
|
-
return
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
245
|
+
return currentScope(rootKey, headOf())?.behind.get(`${sha}\x1f${path}`) ?? 1
|
|
246
|
+
},
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
// @@@ the code axis's SPATIAL narrowing ([[code-anchor]]'s path#symbol, reused whole) - a shared FILE is not a
|
|
251
|
+
// shared BEHAVIOUR: harness.ts carries eight adapters, so a one-adapter edit re-flagged every other adapter's
|
|
252
|
+
// reading, and those refresh only through a real dispatched session of that harness. An anchored entry
|
|
253
|
+
// therefore asks the spatial question instead of the file question — did a commit in codeSha..HEAD intersect
|
|
254
|
+
// one of the named units? — through the SAME parse/extract/resolve/hunk∩range engine spec drift runs. Two
|
|
255
|
+
// deliberate differences from that engine: the window carries NO ack filter (an ack vindicates a spec, not a
|
|
256
|
+
// reading), and it never widens — the file question runs FIRST and the anchor can only subtract from it.
|
|
257
|
+
// Fed at the call sites like the ContentProbe, so the decision functions stay pure over their inputs.
|
|
258
|
+
export type AnchorProbe = {
|
|
259
|
+
// did any commit in sinceSha..HEAD touch one of THESE anchored units?
|
|
260
|
+
// null = cannot testify (unprimed, off-history, no usable extractor) — callers stay conservatively stale.
|
|
261
|
+
hit(sinceSha: string, path: string, selectors: readonly string[]): boolean | null
|
|
262
|
+
prime?(sinceSha: string, entries: readonly RelationEntry[]): Promise<void>
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
// a verdict answers ONE selector set, so the set is part of its identity: several scenarios anchoring
|
|
266
|
+
// DIFFERENT units of one shared file is the whole point of narrowing, and keying only by (sha, path) would
|
|
267
|
+
// hand the first one's answer to all the others — silently, and in the fresh direction.
|
|
268
|
+
const anchorKey = (sinceSha: string, path: string, selectors: readonly string[]) =>
|
|
269
|
+
`${sinceSha}\x1f${path}\x1f${[...selectors].sort().join('\x1e')}`
|
|
270
|
+
|
|
271
|
+
// the eval code window: commits touching `path` in sinceSha..HEAD by the same true ancestry `changedSince`
|
|
272
|
+
// uses, from the same index source — so the anchor check can only narrow the very set the file question just
|
|
273
|
+
// answered `true` for. null = ancestry cannot testify (off-history anchor) → the caller stays conservative.
|
|
274
|
+
function evalWindowCommits(idx: DriftIndex, sinceSha: string, path: string): DriftPathEvent[] | null {
|
|
275
|
+
const anc = ancestorsOf(idx, sinceSha)
|
|
276
|
+
if (!anc) return null
|
|
277
|
+
return pathEvents(idx, path).filter((event) => !inAncestors(idx, anc, event.commit))
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
// every selector of one entry resolves to exactly one unit in the CURRENT tree, or the entry cannot testify.
|
|
281
|
+
// This gate is what stops a DEAD selector from reading fresh: the hit engine answers "no commit touched a
|
|
282
|
+
// unit of that name", which for a name that exists nowhere is a vacuous no — true of spec drift, where the
|
|
283
|
+
// dead anchor is a separate blocking error, and dangerously false here, where the same silence would retire
|
|
284
|
+
// a reading's whole code axis. `problem` names the repair for lint; null means the entry is verifiable.
|
|
285
|
+
function entryUnverifiable(root: string, regs: Extractor[], entry: RelationEntry): string | null {
|
|
286
|
+
const x = extractorFor(regs, extOf(entry.path))
|
|
287
|
+
if (!x) return `\`code\` selector \`${entry.path}#${entry.selectors[0]}\` — no designated extractor for that language; drop the #anchor or add a language row`
|
|
288
|
+
const ready = x.ready()
|
|
289
|
+
if (ready !== true) return `\`code\` anchors on ${entry.path} are unverified: ${ready}`
|
|
290
|
+
let units
|
|
291
|
+
try { units = x.extract(readFileSync(join(root, entry.path), 'utf8'), entry.path) }
|
|
292
|
+
catch (err: any) { return `\`code\` anchors on ${entry.path} are unverified: ${err?.message ?? String(err)}` }
|
|
293
|
+
for (const sym of entry.selectors) {
|
|
294
|
+
const r = resolveAnchor(units, sym)
|
|
295
|
+
if ('dead' in r) return `\`code\` selector \`${entry.path}#${sym}\` names no unit in that file — follow the rename or drop the selector (evals stay stale until then)`
|
|
296
|
+
if ('ambiguous' in r) return `\`code\` selector \`${entry.path}#${sym}\` is ambiguous — ${r.ambiguous} units share that name; pin a unique one`
|
|
297
|
+
}
|
|
298
|
+
return null
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
export function anchorProbeFor(root: string, idx: DriftIndex): AnchorProbe {
|
|
302
|
+
const regs = extractors(root)
|
|
303
|
+
const verdicts = new Map<string, boolean>()
|
|
304
|
+
return {
|
|
305
|
+
async prime(sinceSha, entries) {
|
|
306
|
+
for (const e of entries) {
|
|
307
|
+
if (!e.selectors.length) continue
|
|
308
|
+
const key = anchorKey(sinceSha, e.path, e.selectors)
|
|
309
|
+
if (verdicts.has(key)) continue
|
|
310
|
+
if (entryUnverifiable(root, regs, e)) continue // no verdict → conservative stale (lint says why)
|
|
311
|
+
const win = evalWindowCommits(idx, sinceSha, e.path)
|
|
312
|
+
if (win === null) continue
|
|
313
|
+
if (!win.length) { verdicts.set(key, false); continue }
|
|
314
|
+
const hits = await anchorHitCommits(root, win, [...e.selectors], regs)
|
|
315
|
+
verdicts.set(key, hits.length > 0)
|
|
316
|
+
}
|
|
317
|
+
},
|
|
318
|
+
hit(sinceSha, path, selectors) {
|
|
319
|
+
return verdicts.get(anchorKey(sinceSha, path, selectors)) ?? null
|
|
66
320
|
},
|
|
67
321
|
}
|
|
68
322
|
}
|
|
69
323
|
|
|
324
|
+
// the LOUD half: a selector is a claim that a named unit EXISTS, held to the same standard as a ghost path.
|
|
325
|
+
// Dead, ambiguous, unparseable, or no designated extractor — each names itself and its repair, and until
|
|
326
|
+
// repaired the probe issues no verdict, so the reading stays stale. Over-warn, never a silent pass.
|
|
327
|
+
export function anchorProblems(root: string, entries: readonly RelationEntry[]): string[] {
|
|
328
|
+
const regs = extractors(root)
|
|
329
|
+
const out: string[] = []
|
|
330
|
+
for (const e of entries) {
|
|
331
|
+
if (!e.selectors.length) continue
|
|
332
|
+
const problem = entryUnverifiable(root, regs, e)
|
|
333
|
+
if (problem) out.push(problem)
|
|
334
|
+
}
|
|
335
|
+
return out
|
|
336
|
+
}
|
|
337
|
+
|
|
70
338
|
// the REMARK axis's input ([[remark-teeth]]): the teeth read only the resolvable bit + when it was resolved,
|
|
71
339
|
// not the whole remark — so freshness stays a PURE function, fed the scenario's remark track at the call
|
|
72
340
|
// sites (never reaching into the issue store). One signal per remark on the (node, scenario).
|
|
@@ -88,9 +356,8 @@ export function remarkStale(reading: { ts: string }, remarks: RemarkSignal[]): b
|
|
|
88
356
|
// from HEAD's history, so it reads stale rather than silently pass.
|
|
89
357
|
export function changedSince(idx: DriftIndex, sinceSha: string, path: string, probe?: ContentProbe): boolean {
|
|
90
358
|
const anc = ancestorsOf(idx, sinceSha)
|
|
91
|
-
if (anc) return (idx
|
|
92
|
-
|
|
93
|
-
return diff ? diff.has(path) : true
|
|
359
|
+
if (anc) return pathEvents(idx, path).some((event) => !inAncestors(idx, anc, event.commit))
|
|
360
|
+
return probe?.changed(sinceSha, path) ?? true
|
|
94
361
|
}
|
|
95
362
|
|
|
96
363
|
// the code axis's DISPLAY detail: which governed files drifted since a reading, and by HOW MANY commits — so
|
|
@@ -99,15 +366,19 @@ export function changedSince(idx: DriftIndex, sinceSha: string, path: string, pr
|
|
|
99
366
|
// an off-history sinceSha reports through the same content fallback (only files whose content differs, counted
|
|
100
367
|
// by rev-list); with no probe or a gone anchor it counts every touch (conservative, matching changedSince).
|
|
101
368
|
// Reporting only — it never decides freshness (staleAxes does); it explains a decision already made.
|
|
102
|
-
export function codeDrift(idx: DriftIndex, sinceSha: string,
|
|
369
|
+
export function codeDrift(idx: DriftIndex, sinceSha: string, codeAxis: string[], probe?: ContentProbe): { file: string; behind: number }[] {
|
|
370
|
+
// an entry may be anchored (`path#symbol`); drift is reported per BASE FILE — a raw selector string names
|
|
371
|
+
// no real path, so counting commits against it would silently report nothing.
|
|
372
|
+
const codeFiles = scenarioCodeAxis(codeAxis).paths
|
|
103
373
|
const anc = ancestorsOf(idx, sinceSha)
|
|
104
|
-
const diff = anc ? undefined : probe?.changedPaths(sinceSha)
|
|
105
374
|
const out: { file: string; behind: number }[] = []
|
|
106
375
|
for (const f of codeFiles) {
|
|
107
|
-
const
|
|
108
|
-
const
|
|
109
|
-
|
|
110
|
-
:
|
|
376
|
+
const events = pathEvents(idx, f)
|
|
377
|
+
const differs = anc ? undefined : probe?.changed(sinceSha, f)
|
|
378
|
+
const behind = anc ? new Set(events.filter((event) => !inAncestors(idx, anc, event.commit)).map((event) => event.commit)).size
|
|
379
|
+
: differs === true ? probe!.behind(sinceSha, f)
|
|
380
|
+
: differs === false ? 0
|
|
381
|
+
: new Set(events.map((event) => event.commit)).size
|
|
111
382
|
if (behind > 0) out.push({ file: f, behind })
|
|
112
383
|
}
|
|
113
384
|
return out
|
|
@@ -140,31 +411,41 @@ function scenarioStaleByHash(reading: Reading, current: Scenario | undefined): b
|
|
|
140
411
|
function scenarioMoved(scIdx: ScenarioIndex, didx: DriftIndex, sinceSha: string, evalPath: string, scenario: string, probe?: ContentProbe): boolean {
|
|
141
412
|
const anc = ancestorsOf(didx, sinceSha)
|
|
142
413
|
if (anc) return scenarioChangeCommits(scIdx, evalPath, scenario).some((h) => !inAncestors(didx, anc, h))
|
|
143
|
-
const
|
|
144
|
-
if (
|
|
145
|
-
if (!
|
|
414
|
+
const differs = probe?.changed(sinceSha, evalPath)
|
|
415
|
+
if (differs == null) return true
|
|
416
|
+
if (!differs) return false // whole file byte-identical → this block too
|
|
146
417
|
return probe!.scenarioDiffers(sinceSha, evalPath, scenario)
|
|
147
418
|
}
|
|
148
419
|
|
|
420
|
+
// one declared entry's contribution to the code axis. The FILE question runs first and is unchanged; only an
|
|
421
|
+
// ANCHORED entry whose file really moved asks the narrower spatial one, so an anchor can subtract from the
|
|
422
|
+
// file verdict but never add to it. No verdict (unprimed, off-history, unverifiable selector) → conservative.
|
|
423
|
+
function entryMoved(idx: DriftIndex, sinceSha: string, entry: RelationEntry, probe?: ContentProbe, anchors?: AnchorProbe): boolean {
|
|
424
|
+
if (!changedSince(idx, sinceSha, entry.path, probe)) return false
|
|
425
|
+
if (!entry.selectors.length) return true
|
|
426
|
+
return anchors?.hit(sinceSha, entry.path, entry.selectors) ?? true
|
|
427
|
+
}
|
|
428
|
+
|
|
149
429
|
export function staleAxes(
|
|
150
430
|
reading: Reading,
|
|
151
|
-
|
|
431
|
+
codeAxis: string[],
|
|
152
432
|
evalPath: string,
|
|
153
433
|
didx: DriftIndex,
|
|
154
434
|
scIdx: ScenarioIndex,
|
|
155
435
|
remarks: RemarkSignal[] = [],
|
|
156
436
|
probe?: ContentProbe,
|
|
157
437
|
current?: Scenario, // the scenario's CURRENT declaration (undefined = gone from eval.md) — the hash compare's other side
|
|
438
|
+
anchors?: AnchorProbe,
|
|
158
439
|
): StaleAxis[] {
|
|
159
440
|
const axes: StaleAxis[] = []
|
|
160
441
|
const byHash = scenarioStaleByHash(reading, current)
|
|
161
|
-
if (probe && !
|
|
442
|
+
if (probe && !commitReachable(didx, reading.codeSha) && !probe.canTestify(reading.codeSha)) {
|
|
162
443
|
// the anchor commit object is GONE — neither git axis can testify; say that, not "content changed".
|
|
163
444
|
// The stored contract hash needs no anchor, so it still decides the scenario axis when present.
|
|
164
445
|
axes.push('anchor')
|
|
165
446
|
if (byHash) axes.push('scenario')
|
|
166
447
|
} else {
|
|
167
|
-
if (
|
|
448
|
+
if (scenarioCodeAxis(codeAxis).entries.some((e) => entryMoved(didx, reading.codeSha, e, probe, anchors))) axes.push('code')
|
|
168
449
|
if (byHash ?? scenarioMoved(scIdx, didx, reading.codeSha, evalPath, reading.scenario, probe)) axes.push('scenario')
|
|
169
450
|
}
|
|
170
451
|
if (remarkStale(reading, remarks)) axes.push('remark')
|
|
@@ -173,13 +454,14 @@ export function staleAxes(
|
|
|
173
454
|
|
|
174
455
|
export function isStale(
|
|
175
456
|
reading: Reading,
|
|
176
|
-
|
|
457
|
+
codeAxis: string[],
|
|
177
458
|
evalPath: string,
|
|
178
459
|
didx: DriftIndex,
|
|
179
460
|
scIdx: ScenarioIndex,
|
|
180
461
|
remarks: RemarkSignal[] = [],
|
|
181
462
|
probe?: ContentProbe,
|
|
182
463
|
current?: Scenario,
|
|
464
|
+
anchors?: AnchorProbe,
|
|
183
465
|
): boolean {
|
|
184
|
-
return staleAxes(reading,
|
|
466
|
+
return staleAxes(reading, codeAxis, evalPath, didx, scIdx, remarks, probe, current, anchors).length > 0
|
|
185
467
|
}
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { spawn } from 'node:child_process'
|
|
2
|
-
import { git, gitA, headSha } from '../../spec-cli/src/git.js'
|
|
2
|
+
import { git, gitA, gitTry, headSha } from '../../spec-cli/src/git.js'
|
|
3
3
|
import { parseScenarios } from './scenarios.js'
|
|
4
4
|
|
|
5
5
|
// @@@ per-scenario content freshness — the SCENARIO axis, sub-file
|
|
@@ -161,9 +161,33 @@ async function build(root: string, evalPaths: string[]): Promise<ScenarioIndex>
|
|
|
161
161
|
// whatever the caller. Holds the in-flight promise so concurrent board builds share one build.
|
|
162
162
|
const SLOTS = 16
|
|
163
163
|
const cache = new Map<string, Promise<ScenarioIndex>>()
|
|
164
|
+
const roots = new Map<string, string>()
|
|
165
|
+
|
|
166
|
+
// A checkout moving from HEAD A to HEAD B no longer needs A's full scenario chains. Keep an old index only
|
|
167
|
+
// while another checkout still points at that immutable HEAD; otherwise a sequence of successful rebuilds
|
|
168
|
+
// retains one whole history-shaped index per commit until the broad LRU fills.
|
|
169
|
+
function touchRoot(root: string, head: string): void {
|
|
170
|
+
const previous = roots.get(root)
|
|
171
|
+
if (previous === head) {
|
|
172
|
+
roots.delete(root)
|
|
173
|
+
roots.set(root, head)
|
|
174
|
+
return
|
|
175
|
+
}
|
|
176
|
+
roots.set(root, head)
|
|
177
|
+
if (previous && ![...roots.values()].includes(previous)) cache.delete(previous)
|
|
178
|
+
while (roots.size > SLOTS) {
|
|
179
|
+
const oldest = roots.keys().next().value as string | undefined
|
|
180
|
+
if (oldest === undefined) break
|
|
181
|
+
const oldHead = roots.get(oldest)
|
|
182
|
+
roots.delete(oldest)
|
|
183
|
+
if (oldHead && ![...roots.values()].includes(oldHead)) cache.delete(oldHead)
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
|
|
164
187
|
export function scenarioIndex(root: string, evalPaths: string[]): Promise<ScenarioIndex> {
|
|
165
188
|
let head: string
|
|
166
189
|
try { head = headSha(root) } catch { return build(root, evalPaths) }
|
|
190
|
+
touchRoot(root, head)
|
|
167
191
|
const hit = cache.get(head)
|
|
168
192
|
if (hit) { cache.delete(head); cache.set(head, hit); return hit }
|
|
169
193
|
const p = build(root, evalPaths)
|
|
@@ -173,6 +197,10 @@ export function scenarioIndex(root: string, evalPaths: string[]): Promise<Scenar
|
|
|
173
197
|
return p
|
|
174
198
|
}
|
|
175
199
|
|
|
200
|
+
export function scenarioCacheStats(): { heads: number; roots: number } {
|
|
201
|
+
return { heads: cache.size, roots: roots.size }
|
|
202
|
+
}
|
|
203
|
+
|
|
176
204
|
export function scenarioChangeCommits(idx: ScenarioIndex, evalPath: string, scenario: string): string[] {
|
|
177
205
|
return idx.get(evalPath)?.get(scenario) ?? []
|
|
178
206
|
}
|
|
@@ -198,6 +226,28 @@ function oidAt(root: string, rev: string, path: string): string {
|
|
|
198
226
|
return v
|
|
199
227
|
}
|
|
200
228
|
|
|
229
|
+
async function oidAtAsync(root: string, rev: string, path: string): Promise<string> {
|
|
230
|
+
if (!FULL_SHA.test(rev)) return (await gitTry(['-C', root, 'rev-parse', `${rev}:${path}`])).stdout.trim()
|
|
231
|
+
const k = `${root}\x1f${rev}\x1f${path}`
|
|
232
|
+
const hit = oidMemo.get(k)
|
|
233
|
+
if (hit !== undefined) { oidMemo.delete(k); oidMemo.set(k, hit); return hit }
|
|
234
|
+
const result = await gitTry(['-C', root, 'rev-parse', `${rev}:${path}`])
|
|
235
|
+
const oid = result.ok ? result.stdout.trim() : ''
|
|
236
|
+
oidMemo.set(k, oid)
|
|
237
|
+
if (oidMemo.size > 4096) oidMemo.delete(oidMemo.keys().next().value!)
|
|
238
|
+
return oid
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
|
|
242
|
+
export async function primeScenarioBlocksAt(root: string, revs: string[], path: string): Promise<void> {
|
|
243
|
+
for (const rev of revs) {
|
|
244
|
+
const oid = await oidAtAsync(root, rev, path)
|
|
245
|
+
if (!oid || blockByOid.has(oid)) continue
|
|
246
|
+
const src = await gitA(['-C', root, 'cat-file', 'blob', oid]) // dead-words-ok: git plumbing
|
|
247
|
+
if (src) blockByOid.set(oid, blockContent(src))
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
|
|
201
251
|
// canonical per-scenario SEMANTIC blocks of `rev:path` (the blockContent projection), for the off-history
|
|
202
252
|
// content fallback ([[eval-core]]'s ContentProbe): resolve the blob oid first (memoized above for a full
|
|
203
253
|
// sha) — oids are content-addressed, so an unchanged file usually hits blockByOid straight from the index
|