spexcode 0.6.0 → 0.6.2
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 +97 -149
- package/package.json +1 -1
- package/spec-cli/hooks/harness.sh +26 -6
- package/spec-cli/src/anchors.ts +27 -5
- package/spec-cli/src/cli.ts +239 -75
- package/spec-cli/src/client.ts +113 -14
- package/spec-cli/src/codex-runtime-generations.ts +97 -7
- package/spec-cli/src/commit-surgery.ts +2 -1
- package/spec-cli/src/contract-filter.ts +58 -42
- package/spec-cli/src/delivery-queue.ts +62 -4
- package/spec-cli/src/doctor.ts +2 -1
- package/spec-cli/src/execution-trace.ts +444 -0
- package/spec-cli/src/file-write.ts +22 -0
- package/spec-cli/src/gateway-hub.ts +10 -8
- package/spec-cli/src/gateway.ts +99 -6
- package/spec-cli/src/git.ts +503 -105
- package/spec-cli/src/graph.ts +56 -6
- package/spec-cli/src/graphCache.ts +86 -17
- package/spec-cli/src/graphStream.ts +79 -11
- package/spec-cli/src/guide.ts +93 -4
- package/spec-cli/src/harness-select.ts +2 -2
- package/spec-cli/src/harness.ts +355 -75
- package/spec-cli/src/help.ts +30 -23
- package/spec-cli/src/host-resources.ts +62 -7
- package/spec-cli/src/identity-presets.js +16 -6
- package/spec-cli/src/index.ts +109 -14
- package/spec-cli/src/init.ts +8 -17
- package/spec-cli/src/layout.ts +151 -40
- package/spec-cli/src/lint.ts +37 -7
- package/spec-cli/src/materialize.ts +160 -112
- package/spec-cli/src/plugin-harness.ts +29 -18
- package/spec-cli/src/process-identity.ts +13 -0
- package/spec-cli/src/reviews.ts +103 -30
- package/spec-cli/src/session-execution.ts +68 -0
- package/spec-cli/src/session-files.ts +121 -0
- package/spec-cli/src/session-follow.ts +4 -4
- package/spec-cli/src/session-reparent.ts +33 -0
- package/spec-cli/src/session-timeline.ts +163 -14
- package/spec-cli/src/session-web.ts +135 -0
- package/spec-cli/src/sessions.ts +748 -111
- package/spec-cli/src/uninstall.ts +3 -2
- package/spec-cli/templates/hooks/post-checkout +3 -0
- package/spec-cli/templates/hooks/post-merge +1 -0
- package/spec-cli/templates/spec/project/.plugins/commands/supervisor/spec.md +1 -1
- package/spec-cli/templates/spec/project/.plugins/core/comment-altitude/spec.md +2 -21
- package/spec-cli/templates/spec/project/.plugins/core/spec.md +12 -13
- package/spec-cli/templates/spec/project/.plugins/core/stop-gate/spec.md +1 -1
- package/spec-cli/templates/spec/project/.plugins/core/stop-gate/stop-gate.sh +2 -2
- package/spec-cli/templates/spec/project/.plugins/prompts/atomic-landing/spec.md +5 -22
- package/spec-cli/templates/spec/project/.plugins/prompts/forge-link/spec.md +2 -1
- package/spec-cli/templates/spec/project/.plugins/prompts/memory-hygiene/spec.md +4 -6
- package/spec-cli/templates/spec/project/.plugins/prompts/reproduce-before-fix/spec.md +3 -9
- package/spec-dashboard/dist/assets/{App-b8Nh0sgk.js → App-B6IbFbGu.js} +2 -2
- package/spec-dashboard/dist/assets/Dashboard-_sDk7Rcg.js +27 -0
- package/spec-dashboard/dist/assets/EvalsPage-BT357Lyt.js +2 -0
- package/spec-dashboard/dist/assets/{IssuesPage-CAP64YWE.js → IssuesPage-BG9rNOgV.js} +1 -1
- package/spec-dashboard/dist/assets/MobileApp-D9yPMR3A.js +2 -0
- package/spec-dashboard/dist/assets/{Modal-Drscez-d.js → Modal-B3EcgeA3.js} +1 -1
- package/spec-dashboard/dist/assets/{PageScroll-qW6uOJL8.js → PageScroll-CNZOugWV.js} +1 -1
- package/spec-dashboard/dist/assets/{ProjectsPage-CjybFBmR.js → ProjectsPage-C2CRHmvs.js} +1 -1
- package/spec-dashboard/dist/assets/SessionInterface-DOEjuT3l.js +40 -0
- package/spec-dashboard/dist/assets/{SessionWindow-iOk0yHoU.js → SessionWindow-1nYgZ4CL.js} +1 -1
- package/spec-dashboard/dist/assets/Settings-7WeTJn0V.js +1 -0
- package/spec-dashboard/dist/assets/Thread-CEbzAFQn.js +13 -0
- package/spec-dashboard/dist/assets/data-SlvxNA2r.js +1 -0
- package/spec-dashboard/dist/assets/{index-paP-z_Vd.js → index-DLVeOHL-.js} +10 -10
- package/spec-dashboard/dist/assets/index-DYLGfPjx.css +1 -0
- package/spec-dashboard/dist/assets/{launch-B-bYdWmh.js → launch-yxCYr64x.js} +29 -29
- package/spec-dashboard/dist/assets/sessionSurface--7l69h4F.js +1 -0
- package/spec-dashboard/dist/index.html +2 -2
- package/spec-dashboard/src/reviewFilters.js +5 -0
- package/spec-dashboard/src/session.js +5 -1
- package/spec-eval/src/cli.ts +13 -9
- package/spec-eval/src/evaltab.ts +32 -10
- package/spec-eval/src/freshness.ts +404 -81
- package/spec-eval/src/scenariofresh.ts +58 -8
- package/spec-eval/src/scenarios.ts +60 -12
- package/spec-eval/src/sessioneval.ts +234 -148
- package/spec-dashboard/dist/assets/Dashboard-CvAjfRC2.js +0 -27
- package/spec-dashboard/dist/assets/EvalsPage-Bz-nMKoS.js +0 -2
- package/spec-dashboard/dist/assets/MobileApp-D9L1Va8Z.js +0 -2
- package/spec-dashboard/dist/assets/SessionInterface-Dl9v0JFM.js +0 -39
- package/spec-dashboard/dist/assets/Settings-BZ1lGRJs.js +0 -1
- package/spec-dashboard/dist/assets/Thread-D_kcDnfd.js +0 -13
- package/spec-dashboard/dist/assets/data-Bwd3kAVL.js +0 -1
- package/spec-dashboard/dist/assets/index-DAbQBBK_.css +0 -1
- package/spec-dashboard/dist/assets/terminalFont-C6oGNKrf.js +0 -1
- package/spec-eval/src/matrix.ts +0 -693
|
@@ -1,16 +1,18 @@
|
|
|
1
1
|
import { readFileSync } from 'node:fs'
|
|
2
2
|
import { createHash } from 'node:crypto'
|
|
3
3
|
import { join, resolve } from 'node:path'
|
|
4
|
-
import { gitA, gitTry, headSha, currentGitBuildAbortSignal, gitAbortError, ancestorsOf, inAncestors, commitReachable, pathEvents, type DriftIndex, type DriftPathEvent, eventsSince } from '../../spec-cli/src/git.js'
|
|
4
|
+
import { batchRevisionOids, gitA, gitTry, headSha, gitObjectInterpretation, currentGitBuildAbortSignal, gitAbortError, ancestorsOf, inAncestors, commitReachable, pathEvents, primeAncestorClosures, unionTopology, type DriftIndex, type DriftPathEvent, type Reachability, eventsSince } from '../../spec-cli/src/git.js'
|
|
5
5
|
import { anchorHitExists, extOf, extractorFor, extractors, resolveAnchor, resolveSelectors, type AnchorHitQuery, type Extractor, type RelationEntry, type Unit } from '../../spec-cli/src/anchors.js'
|
|
6
6
|
import type { Reading } from './sidecar.js'
|
|
7
7
|
import { scenarioCodeAxis, scenarioHash, type Scenario, type ScenarioCodeAxisSource } from './scenarios.js'
|
|
8
|
-
import { scenarioChangeCommits, scenarioBlocksAt,
|
|
8
|
+
import { scenarioChangeCommits, scenarioBlocksAt, primeScenarioBlocks, type ScenarioIndex } from './scenariofresh.js'
|
|
9
9
|
|
|
10
10
|
// 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
|
|
11
11
|
|
|
12
12
|
export type StaleAxis = 'code' | 'scenario' | 'remark' | 'anchor'
|
|
13
13
|
|
|
14
|
+
export type ContentProbeDemand = { anchorSha: string; paths: readonly string[]; evalPath: string }
|
|
15
|
+
|
|
14
16
|
// @@@ off-history content fallback - ancestry can't testify for a codeSha that isn't reachable from HEAD
|
|
15
17
|
// (fold/rebase/squash-merge/cherry-pick all orphan the anchor), but the TREES still can: while the anchor
|
|
16
18
|
// commit object exists locally, a pathspec-scoped `git diff <anchor> HEAD -- :(literal)<path>…` answers,
|
|
@@ -31,9 +33,14 @@ export type ContentProbe = {
|
|
|
31
33
|
canTestify(anchorSha: string): boolean
|
|
32
34
|
// did THIS scenario's semantic block (description+expected) move between anchor and HEAD ([[scenariofresh]])
|
|
33
35
|
scenarioDiffers(anchorSha: string, evalPath: string, scenario: string): boolean
|
|
34
|
-
// codeDrift's display detail
|
|
35
|
-
|
|
36
|
+
// codeDrift's display detail, the half HEAD's index cannot answer: is `commit` already in the ANCHOR's own
|
|
37
|
+
// past? false when the anchor's topology can't testify — the caller then counts conservatively.
|
|
38
|
+
inAnchorPast(anchorSha: string, commit: string): boolean
|
|
36
39
|
prime?(anchorSha: string, paths: string[], evalPath: string): Promise<void>
|
|
40
|
+
primeMany?(demands: readonly ContentProbeDemand[]): Promise<void>
|
|
41
|
+
// answer every scenario-block demand `prime` recorded, in ONE batched pair of children. Optional and
|
|
42
|
+
// idempotent: an unflushed demand degrades to the singular sync lookup, never to a wrong block.
|
|
43
|
+
primeBlocks?(): Promise<void>
|
|
37
44
|
}
|
|
38
45
|
|
|
39
46
|
// (anchor, HEAD) name two immutable trees, so a settled verdict never invalidates — but "never invalidates"
|
|
@@ -47,25 +54,40 @@ export type ContentProbe = {
|
|
|
47
54
|
// An anchor entry holds only PER-REQUESTED-PATH verdicts (plus the gone bit and the batch bookkeeping),
|
|
48
55
|
// never a whole-repo path set.
|
|
49
56
|
type AnchorVerdicts = {
|
|
57
|
+
imageOid: string | null | undefined // undefined = interpretation resolution is still in flight
|
|
50
58
|
verdicts: Map<string, boolean> // requested path → content differs between the two immutable trees
|
|
51
59
|
gone: boolean // the anchor commit object is locally unreadable — content can't testify
|
|
52
60
|
pending: Set<string> // paths awaiting the next batch child
|
|
53
61
|
flight: Promise<void> | null // the single in-flight batch for this anchor
|
|
54
62
|
}
|
|
55
|
-
type RootScope = {
|
|
63
|
+
type RootScope = {
|
|
64
|
+
head: string // raw HEAD + the full Git interpretation identity
|
|
65
|
+
currentOid: string | null // exact current image; null only while first resolution is in flight
|
|
66
|
+
anchors: Map<string, AnchorVerdicts>
|
|
67
|
+
topology: Reachability | null // the anchors' OWN ancestry: one walk per scope, never per pair
|
|
68
|
+
topologyRoster: Set<string> // the anchor images that walk already carries
|
|
69
|
+
topologyFlight: Promise<void> | null // the single in-flight walk
|
|
70
|
+
blockDemands: Set<string> // (rev, evalPath) awaiting the one batched scenario-block read
|
|
71
|
+
// the SELECTOR-anchor probe's verdicts, in the same scope for the same reason ([[selector-anchor-scope]]):
|
|
72
|
+
// a `sinceSha..HEAD` window lengthens as HEAD advances, so these are per-head immutable, never forever
|
|
73
|
+
// immutable. One scope object means one head move drops both probes' answers at once, and the cardinality
|
|
74
|
+
// invariant this scope exists to hold is observable for both.
|
|
75
|
+
anchorVerdicts: Map<string, boolean>
|
|
76
|
+
}
|
|
56
77
|
const rootScopes = new Map<string, RootScope>()
|
|
57
78
|
// Roots come and go (a closed worktree never asks again), so cap how many stay warm — the same bounded-slot
|
|
58
79
|
// guard the index caches use, and the only bound needed once per-root cardinality is the corpus, not history.
|
|
59
80
|
const ROOT_SLOTS = Math.max(4, Number(process.env.SPEXCODE_FRESHNESS_ROOT_SLOTS || 64))
|
|
60
81
|
|
|
61
|
-
function scopeFor(rootKey: string, head: string): RootScope {
|
|
82
|
+
function scopeFor(rootKey: string, head: string, currentOid: string | null): RootScope {
|
|
62
83
|
const current = rootScopes.get(rootKey)
|
|
63
|
-
if (current?.head === head) {
|
|
84
|
+
if (current?.head === head && (current.currentOid === currentOid || currentOid === null || current.currentOid === null)) {
|
|
85
|
+
if (currentOid !== null) current.currentOid = currentOid
|
|
64
86
|
rootScopes.delete(rootKey)
|
|
65
87
|
rootScopes.set(rootKey, current)
|
|
66
88
|
return current
|
|
67
89
|
}
|
|
68
|
-
const scope: RootScope = { head, anchors: new Map(),
|
|
90
|
+
const scope: RootScope = { head, currentOid, anchors: new Map(), topology: null, topologyRoster: new Set(), topologyFlight: null, blockDemands: new Set(), anchorVerdicts: new Map() }
|
|
69
91
|
rootScopes.set(rootKey, scope)
|
|
70
92
|
while (rootScopes.size > ROOT_SLOTS) {
|
|
71
93
|
const oldest = rootScopes.keys().next().value
|
|
@@ -90,10 +112,27 @@ export function freshnessCacheSize(root?: string): number {
|
|
|
90
112
|
return total
|
|
91
113
|
}
|
|
92
114
|
|
|
93
|
-
|
|
115
|
+
// The same invariant for the OTHER probe in this scope. It needs its own counter rather than a bigger number
|
|
116
|
+
// from the one above: the two hold different things (content anchor entries vs selector-anchor verdicts), and
|
|
117
|
+
// a counter that cannot see a case is exactly how "one retained generation, not one per rebuild" came to be
|
|
118
|
+
// declared, observable, and still violated ([[selector-anchor-scope]]).
|
|
119
|
+
export function anchorVerdictCacheSize(root?: string): number {
|
|
120
|
+
if (root !== undefined) return rootScopes.get(resolve(root))?.anchorVerdicts.size ?? 0
|
|
121
|
+
let total = 0
|
|
122
|
+
for (const scope of rootScopes.values()) total += scope.anchorVerdicts.size
|
|
123
|
+
return total
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
function touchAnchor(scope: RootScope, sha: string, imageOid: string | null | undefined): AnchorVerdicts {
|
|
94
127
|
const hit = scope.anchors.get(sha)
|
|
95
|
-
if (hit) return hit
|
|
96
|
-
|
|
128
|
+
if (hit && imageOid === undefined) return hit
|
|
129
|
+
if (hit && hit.imageOid == null && imageOid !== undefined) {
|
|
130
|
+
hit.imageOid = imageOid
|
|
131
|
+
hit.gone = imageOid === null
|
|
132
|
+
return hit
|
|
133
|
+
}
|
|
134
|
+
if (hit && hit.imageOid === imageOid) return hit
|
|
135
|
+
const entry: AnchorVerdicts = { imageOid, verdicts: new Map(), gone: imageOid === null, pending: new Set(), flight: null }
|
|
97
136
|
scope.anchors.set(sha, entry)
|
|
98
137
|
return entry
|
|
99
138
|
}
|
|
@@ -163,87 +202,308 @@ function acquireHeavyDiff(scopeKey: string, signal?: AbortSignal): Promise<() =>
|
|
|
163
202
|
})
|
|
164
203
|
}
|
|
165
204
|
|
|
166
|
-
// @@@ one batch per
|
|
205
|
+
// @@@ one bounded batch per planned population, only the paths actually asked about - the tree comparison is pathspec-scoped
|
|
167
206
|
// (`:(literal)` so a path holding a glob char, a space or a leading colon is matched verbatim, `-z` so the
|
|
168
207
|
// answer needs no unquoting), and the answer retained is one boolean per REQUESTED path. Concurrent primes
|
|
169
208
|
// on the same anchor therefore union their paths BEFORE the child starts: a caller records what it needs in
|
|
170
209
|
// `pending`, then either starts the batch that drains pending or joins the running one and re-checks after —
|
|
171
210
|
// so a path requested mid-flight rides the NEXT batch instead of racing this one, and a settled path is
|
|
172
|
-
// never asked again.
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
211
|
+
// never asked again. Every bounded chunk stays serial through the same scope permit.
|
|
212
|
+
const CONTENT_BATCH_RECORD_LIMIT = 8_192
|
|
213
|
+
const CONTENT_BATCH_ARGV_BYTE_LIMIT = 16 * 1_024
|
|
214
|
+
const CONTENT_BATCH_MARKER = '\x1espex-content:'
|
|
215
|
+
type ContentBatchRow = { sha: string; oid: string; entry: AnchorVerdicts; requested: string[] }
|
|
216
|
+
|
|
217
|
+
function contentBatchArgs(root: string, paths: readonly string[]): string[] {
|
|
218
|
+
return [
|
|
219
|
+
'-C', root, '-c', 'core.quotePath=false', 'diff-tree', '--stdin', '--always', '-r', '--name-status', '-z',
|
|
220
|
+
'--no-renames', '--no-color', '--no-ext-diff', '--no-textconv', `--format=${CONTENT_BATCH_MARKER}%H`,
|
|
221
|
+
'--', ...paths.map((path) => `:(literal)${path}`),
|
|
222
|
+
]
|
|
223
|
+
}
|
|
224
|
+
function argvBytes(args: readonly string[]): number {
|
|
225
|
+
return args.reduce((total, arg) => total + Buffer.byteLength(arg) + 1, 0)
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
function contentBatchChunks(root: string, rows: ContentBatchRow[]): ContentBatchRow[][] {
|
|
229
|
+
const chunks: ContentBatchRow[][] = []
|
|
230
|
+
let byRow = new Map<ContentBatchRow, ContentBatchRow>()
|
|
231
|
+
let paths = new Set<string>()
|
|
232
|
+
const flush = () => {
|
|
233
|
+
if (byRow.size) chunks.push([...byRow.values()])
|
|
234
|
+
byRow = new Map()
|
|
235
|
+
paths = new Set()
|
|
236
|
+
}
|
|
237
|
+
for (const row of rows) {
|
|
238
|
+
for (const path of row.requested) {
|
|
239
|
+
for (;;) {
|
|
240
|
+
const nextPaths = paths.has(path) ? paths : new Set(paths).add(path)
|
|
241
|
+
const nextRows = byRow.size + (byRow.has(row) ? 0 : 1)
|
|
242
|
+
const withinRecords = nextRows * nextPaths.size <= CONTENT_BATCH_RECORD_LIMIT
|
|
243
|
+
const withinArgv = argvBytes(contentBatchArgs(root, [...nextPaths])) <= CONTENT_BATCH_ARGV_BYTE_LIMIT
|
|
244
|
+
if (withinRecords && withinArgv) break
|
|
245
|
+
if (!byRow.size)
|
|
246
|
+
throw new Error(`git content diff path exceeds the ${CONTENT_BATCH_ARGV_BYTE_LIMIT}-byte argv bound: ${path}`)
|
|
247
|
+
flush()
|
|
248
|
+
}
|
|
249
|
+
let slice = byRow.get(row)
|
|
250
|
+
if (!slice) {
|
|
251
|
+
slice = { ...row, requested: [] }
|
|
252
|
+
byRow.set(row, slice)
|
|
253
|
+
}
|
|
254
|
+
slice.requested.push(path)
|
|
255
|
+
paths.add(path)
|
|
191
256
|
}
|
|
192
257
|
}
|
|
193
|
-
|
|
194
|
-
|
|
258
|
+
flush()
|
|
259
|
+
return chunks
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
function parseContentBatch(out: string, rows: ContentBatchRow[]): Set<string>[] {
|
|
263
|
+
let rowIndex = -1
|
|
264
|
+
let status: string | null = null
|
|
265
|
+
const changed = rows.map(() => new Set<string>())
|
|
266
|
+
for (const raw of out.split('\0')) {
|
|
267
|
+
if (!raw) continue
|
|
268
|
+
const token = raw.startsWith('\n') ? raw.slice(1) : raw
|
|
269
|
+
if (status !== null) {
|
|
270
|
+
if (rowIndex < 0 || rowIndex >= rows.length) throw new Error('git content diff batch emitted a path before its pair marker')
|
|
271
|
+
changed[rowIndex].add(token)
|
|
272
|
+
status = null
|
|
273
|
+
continue
|
|
274
|
+
}
|
|
275
|
+
if (token.startsWith(CONTENT_BATCH_MARKER)) {
|
|
276
|
+
rowIndex++
|
|
277
|
+
if (rowIndex >= rows.length) throw new Error('git content diff batch emitted too many pair markers')
|
|
278
|
+
continue
|
|
279
|
+
}
|
|
280
|
+
if (!/^[A-Z](?:\d+)?$/.test(token)) throw new Error(`git content diff batch emitted malformed status '${token}'`)
|
|
281
|
+
status = token
|
|
282
|
+
}
|
|
283
|
+
if (status !== null) throw new Error(`git content diff batch ended after status '${status}'`)
|
|
284
|
+
if (rowIndex + 1 !== rows.length)
|
|
285
|
+
throw new Error(`git content diff batch emitted ${rowIndex + 1} pair markers for ${rows.length} pairs`)
|
|
286
|
+
return changed
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
async function resolvedContentImages(
|
|
290
|
+
root: string,
|
|
291
|
+
raws: readonly string[],
|
|
292
|
+
replacements: ReadonlyMap<string, string>,
|
|
293
|
+
): Promise<(string | null)[]> {
|
|
294
|
+
const targets = raws.map((raw) => {
|
|
295
|
+
let target = raw
|
|
296
|
+
const seen = new Set<string>()
|
|
297
|
+
while (replacements.has(target)) {
|
|
298
|
+
if (seen.has(target)) throw new Error(`refs/replace cycle while resolving content image '${raw}'`)
|
|
299
|
+
seen.add(target)
|
|
300
|
+
target = replacements.get(target)!
|
|
301
|
+
}
|
|
302
|
+
return target
|
|
303
|
+
})
|
|
304
|
+
// One no-replace child proves both the names callers supplied and the exact final replacement targets.
|
|
305
|
+
// A target cannot make a nonexistent raw id look available, and a missing replacement target is loud.
|
|
306
|
+
const queries = [...new Set([...raws, ...targets])]
|
|
307
|
+
const answers = await batchRevisionOids(root, queries, { replaceObjects: false })
|
|
308
|
+
const checked = new Map(queries.map((query, index) => [query, answers[index]]))
|
|
309
|
+
return raws.map((raw, index) => {
|
|
310
|
+
if (!checked.get(raw)) return null
|
|
311
|
+
const target = checked.get(targets[index])
|
|
312
|
+
if (!target) throw new Error(`replacement target '${targets[index]}' for content image '${raw}' is unreadable`)
|
|
313
|
+
return target
|
|
314
|
+
})
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
async function runContentBatch(root: string, currentOid: string, rows: ContentBatchRow[]): Promise<void> {
|
|
318
|
+
const settled: { row: ContentBatchRow; changed: Set<string> }[] = []
|
|
319
|
+
for (const chunk of contentBatchChunks(root, rows)) {
|
|
320
|
+
const paths = [...new Set(chunk.flatMap((row) => row.requested))].sort()
|
|
321
|
+
const result = await gitTry(contentBatchArgs(root, paths), {
|
|
322
|
+
input: chunk.map((row) => `${row.oid} ${currentOid}`).join('\n') + '\n',
|
|
323
|
+
// batchRevisionOids already froze replacements/grafts into exact object ids. Do not reinterpret those
|
|
324
|
+
// ids if refs/replace moves between the resolution child and this content child.
|
|
325
|
+
extraEnv: { GIT_NO_REPLACE_OBJECTS: '1' },
|
|
326
|
+
})
|
|
327
|
+
if (!result.ok)
|
|
328
|
+
throw new Error(`git content diff batch failed (${result.failure ?? 'unknown'}): ${result.stderr.trim() || 'unknown git error'}`)
|
|
329
|
+
parseContentBatch(result.stdout, chunk).forEach((changed, index) => settled.push({ row: chunk[index], changed }))
|
|
330
|
+
}
|
|
331
|
+
// Publish only after every chunk succeeds. A late transport failure leaves the entire planned population
|
|
332
|
+
// retryable instead of turning its early chunks into an accidental partial freshness answer.
|
|
333
|
+
for (const { row, changed } of settled)
|
|
334
|
+
for (const path of row.requested) row.entry.verdicts.set(path, changed.has(path))
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
function startPluralContentBatch(root: string, rootKey: string, scope: RootScope, rows: { sha: string; entry: AnchorVerdicts }[]): Promise<void> {
|
|
338
|
+
const run = async (): Promise<void> => {
|
|
339
|
+
const release = await acquireHeavyDiff(`${rootKey}\x1f${scope.head}`, currentGitBuildAbortSignal())
|
|
340
|
+
const batch = rows.map((row) => ({ ...row, oid: row.entry.imageOid!, requested: [...row.entry.pending] }))
|
|
341
|
+
.filter((row) => row.requested.length)
|
|
342
|
+
for (const row of batch) row.entry.pending.clear()
|
|
343
|
+
try { await runContentBatch(root, scope.currentOid!, batch) }
|
|
344
|
+
finally { release() }
|
|
345
|
+
}
|
|
346
|
+
let flight!: Promise<void>
|
|
347
|
+
flight = run().finally(() => {
|
|
348
|
+
for (const row of rows) if (row.entry.flight === flight) row.entry.flight = null
|
|
349
|
+
})
|
|
350
|
+
for (const row of rows) row.entry.flight = flight
|
|
195
351
|
return flight
|
|
196
352
|
}
|
|
197
353
|
|
|
354
|
+
// @@@ the drift COUNT is a reachability question, so ONE walk answers the whole roster - counting it as
|
|
355
|
+
// `rev-list --count <anchor>..<HEAD> -- <path>` names an OFF-HISTORY range, so Git can never cut the walk
|
|
356
|
+
// short: every (anchor, path) pair traverses the entire history, and the pairs multiply. Measured cold on a
|
|
357
|
+
// 437-anchor deployment scope: 1748 of the read's 1750 git children were those counts. What HEAD's index
|
|
358
|
+
// genuinely lacks is only the ANCHORS' own ancestry, and one `rev-list --parents` walk over the whole roster
|
|
359
|
+
// carries it; the count is then the same in-memory rule `eventsSince` applies on the in-history side. The
|
|
360
|
+
// walk is a SEPARATE structure from the shared drift index on purpose: grafting off-history tips into that
|
|
361
|
+
// index would make `ancestorsOf` stop answering undefined for them and silently switch the freshness
|
|
362
|
+
// DECISION from this content probe to ancestry. A rejected walk caches nothing and every joiner sees the
|
|
363
|
+
// same failure, matching the anchor batch above.
|
|
364
|
+
async function primeAnchorTopology(root: string, scope: RootScope, oids: readonly string[]): Promise<void> {
|
|
365
|
+
for (;;) {
|
|
366
|
+
const missing = oids.filter((oid) => !scope.topologyRoster.has(oid))
|
|
367
|
+
if (!missing.length) return
|
|
368
|
+
// an anchor that arrives mid-walk rides the NEXT one instead of racing this one, exactly as a
|
|
369
|
+
// mid-flight path rides the next content batch.
|
|
370
|
+
if (scope.topologyFlight) { await scope.topologyFlight; continue }
|
|
371
|
+
const roster = [...new Set([...scope.topologyRoster, ...missing])]
|
|
372
|
+
const run = async (): Promise<void> => {
|
|
373
|
+
const reach = await unionTopology(root, roster)
|
|
374
|
+
// one child-before-parent pass for the whole roster; the bytes are identical to asking one by one.
|
|
375
|
+
primeAncestorClosures(reach, roster)
|
|
376
|
+
scope.topology = reach
|
|
377
|
+
scope.topologyRoster = new Set(roster)
|
|
378
|
+
}
|
|
379
|
+
let flight!: Promise<void>
|
|
380
|
+
flight = run().finally(() => { if (scope.topologyFlight === flight) scope.topologyFlight = null })
|
|
381
|
+
scope.topologyFlight = flight
|
|
382
|
+
await flight
|
|
383
|
+
}
|
|
384
|
+
}
|
|
385
|
+
|
|
198
386
|
export function contentProbeFor(root: string): ContentProbe {
|
|
199
387
|
const rootKey = resolve(root)
|
|
200
388
|
let head: string | undefined
|
|
389
|
+
let activeImageKey: string | undefined
|
|
201
390
|
const headOf = () => (head ??= headSha(root))
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
391
|
+
const imageOf = () => {
|
|
392
|
+
const rawHead = headOf()
|
|
393
|
+
const interpretation = gitObjectInterpretation(root)
|
|
394
|
+
return { rawHead, key: `${rawHead}\x1f${interpretation.identity}`, interpretation }
|
|
395
|
+
}
|
|
396
|
+
const primeMany = async (demands: readonly ContentProbeDemand[]): Promise<void> => {
|
|
397
|
+
if (!demands.length) return
|
|
398
|
+
const planned = new Map<string, { paths: Set<string>; codePaths: Set<string>; evalPaths: Set<string> }>()
|
|
399
|
+
for (const demand of demands) {
|
|
400
|
+
const row = planned.get(demand.anchorSha) ?? { paths: new Set(), codePaths: new Set(), evalPaths: new Set() }
|
|
401
|
+
for (const path of demand.paths) { if (path) { row.paths.add(path); row.codePaths.add(path) } }
|
|
402
|
+
if (demand.evalPath) { row.paths.add(demand.evalPath); row.evalPaths.add(demand.evalPath) }
|
|
403
|
+
planned.set(demand.anchorSha, row)
|
|
404
|
+
}
|
|
405
|
+
let scope!: RootScope
|
|
406
|
+
for (;;) {
|
|
407
|
+
const image = imageOf()
|
|
408
|
+
activeImageKey = image.key
|
|
409
|
+
const current = scopeFor(rootKey, image.key, null)
|
|
410
|
+
for (const [sha, row] of planned) {
|
|
411
|
+
const entry = touchAnchor(current, sha, undefined)
|
|
412
|
+
for (const path of row.paths) if (!entry.verdicts.has(path)) entry.pending.add(path)
|
|
217
413
|
}
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
414
|
+
const needsResolution = current.currentOid === null || [...planned].some(([sha]) => {
|
|
415
|
+
const entry = current.anchors.get(sha)!
|
|
416
|
+
return entry.imageOid === undefined || entry.gone
|
|
417
|
+
})
|
|
418
|
+
if (!needsResolution) { scope = current; break }
|
|
419
|
+
// Resolve HEAD and every anchor in ONE child, then require the interpretation inputs to remain stable.
|
|
420
|
+
// The returned object ids become the only images downstream commands may execute against.
|
|
421
|
+
const shas = [...planned.keys()]
|
|
422
|
+
const resolved = await resolvedContentImages(root, [image.rawHead, ...shas], image.interpretation.replacements)
|
|
423
|
+
if (imageOf().key !== image.key) continue
|
|
424
|
+
const currentOid = resolved[0]
|
|
425
|
+
if (!currentOid) throw new Error(`git content diff current image '${image.rawHead}' is unreadable`)
|
|
426
|
+
scope = scopeFor(rootKey, image.key, currentOid)
|
|
427
|
+
shas.forEach((sha, index) => touchAnchor(scope, sha, resolved[index + 1]))
|
|
428
|
+
break
|
|
429
|
+
}
|
|
430
|
+
const grouped = new Map<string, { entry: AnchorVerdicts; paths: Set<string>; codePaths: Set<string>; evalPaths: Set<string> }>()
|
|
431
|
+
for (const [sha, row] of planned) {
|
|
432
|
+
const entry = scope.anchors.get(sha)
|
|
433
|
+
if (!entry) throw new Error(`git content diff did not resolve planned anchor '${sha}'`)
|
|
434
|
+
grouped.set(sha, { entry, ...row })
|
|
435
|
+
}
|
|
436
|
+
for (;;) {
|
|
437
|
+
const waiting = new Set<Promise<void>>()
|
|
438
|
+
const ready: { sha: string; entry: AnchorVerdicts }[] = []
|
|
439
|
+
for (const [sha, row] of grouped) {
|
|
440
|
+
if (row.entry.gone) continue
|
|
441
|
+
const missing = [...row.paths].filter((path) => !row.entry.verdicts.has(path))
|
|
442
|
+
if (!missing.length) continue
|
|
443
|
+
for (const path of missing) row.entry.pending.add(path)
|
|
444
|
+
if (row.entry.flight) waiting.add(row.entry.flight)
|
|
445
|
+
else ready.push({ sha, entry: row.entry })
|
|
225
446
|
}
|
|
226
|
-
if (
|
|
447
|
+
if (waiting.size) { await Promise.all(waiting); continue }
|
|
448
|
+
if (!ready.length) break
|
|
449
|
+
await startPluralContentBatch(root, rootKey, scope, ready)
|
|
450
|
+
}
|
|
451
|
+
// only anchors whose governed content actually differs need their past: those are the readings that
|
|
452
|
+
// will render a drift count, and a byte-identical population walks nothing at all.
|
|
453
|
+
const drifted = [...grouped.values()]
|
|
454
|
+
.filter((row) => !row.entry.gone && [...row.codePaths].some((path) => row.entry.verdicts.get(path) === true))
|
|
455
|
+
.map((row) => row.entry.imageOid!)
|
|
456
|
+
if (drifted.length) await primeAnchorTopology(root, scope, drifted)
|
|
457
|
+
for (const [sha, row] of grouped) {
|
|
458
|
+
if (row.entry.gone) continue
|
|
459
|
+
for (const evalPath of row.evalPaths) if (row.entry.verdicts.get(evalPath) === true) {
|
|
460
|
+
scope.blockDemands.add(`${row.entry.imageOid}\x1f${evalPath}`)
|
|
461
|
+
scope.blockDemands.add(`${scope.currentOid}\x1f${evalPath}`)
|
|
462
|
+
}
|
|
463
|
+
}
|
|
464
|
+
}
|
|
465
|
+
return {
|
|
466
|
+
async prime(sha, paths, evalPath) {
|
|
467
|
+
await primeMany([{ anchorSha: sha, paths, evalPath }])
|
|
468
|
+
},
|
|
469
|
+
primeMany,
|
|
470
|
+
async primeBlocks() {
|
|
471
|
+
const image = imageOf()
|
|
472
|
+
activeImageKey = image.key
|
|
473
|
+
const scope = currentScope(rootKey, image.key)
|
|
474
|
+
if (!scope?.blockDemands.size) return
|
|
475
|
+
const demands = [...scope.blockDemands].map((k) => {
|
|
476
|
+
const cut = k.indexOf('\x1f')
|
|
477
|
+
return { rev: k.slice(0, cut), path: k.slice(cut + 1) }
|
|
478
|
+
})
|
|
479
|
+
scope.blockDemands.clear()
|
|
480
|
+
await primeScenarioBlocks(root, demands)
|
|
227
481
|
},
|
|
228
482
|
changed(sha, path) {
|
|
229
483
|
// The async prime owns all I/O. An unprimed path is 'can't testify', never a fresh sync diff: a miss
|
|
230
484
|
// stays conservative and no abort/transient failure can turn into an unbounded synchronous fallback.
|
|
231
|
-
const entry = currentScope(rootKey,
|
|
485
|
+
const entry = activeImageKey ? currentScope(rootKey, activeImageKey)?.anchors.get(sha) : undefined
|
|
232
486
|
if (!entry || entry.gone) return null
|
|
233
487
|
return entry.verdicts.get(path) ?? null
|
|
234
488
|
},
|
|
235
489
|
canTestify(sha) {
|
|
236
490
|
// a settled verdict — of either polarity — is the proof the anchor's tree was readable
|
|
237
|
-
const entry = currentScope(rootKey,
|
|
491
|
+
const entry = activeImageKey ? currentScope(rootKey, activeImageKey)?.anchors.get(sha) : undefined
|
|
238
492
|
return !!entry && !entry.gone && entry.verdicts.size > 0
|
|
239
493
|
},
|
|
240
494
|
scenarioDiffers(sha, evalPath, scenario) {
|
|
241
|
-
const
|
|
495
|
+
const scope = activeImageKey ? currentScope(rootKey, activeImageKey) : undefined
|
|
496
|
+
const entry = scope?.anchors.get(sha)
|
|
497
|
+
const a = entry?.imageOid ? scenarioBlocksAt(root, entry.imageOid, evalPath) : null
|
|
242
498
|
if (!a) return true // eval.md unreadable at the anchor (renamed/absent) → can't prove → stale
|
|
243
|
-
return a.get(scenario) !== scenarioBlocksAt(root,
|
|
499
|
+
return a.get(scenario) !== scenarioBlocksAt(root, scope!.currentOid!, evalPath)?.get(scenario)
|
|
244
500
|
},
|
|
245
|
-
|
|
246
|
-
|
|
501
|
+
inAnchorPast(sha, commit) {
|
|
502
|
+
const scope = activeImageKey ? currentScope(rootKey, activeImageKey) : undefined
|
|
503
|
+
const oid = scope?.anchors.get(sha)?.imageOid
|
|
504
|
+
if (!scope?.topology || !oid) return false
|
|
505
|
+
const bits = ancestorsOf(scope.topology, oid)
|
|
506
|
+
return !!bits && inAncestors(scope.topology, bits, commit)
|
|
247
507
|
},
|
|
248
508
|
}
|
|
249
509
|
}
|
|
@@ -284,18 +544,35 @@ const anchorKey = (sinceSha: string, path: string, selectors: readonly string[])
|
|
|
284
544
|
// an unparseable file does not re-parse once per entry.
|
|
285
545
|
const CURRENT_TREE_MEMO_MAX = 4096
|
|
286
546
|
const currentTreeUnitMemo = new Map<string, { units: Unit[] } | { failed: string }>()
|
|
287
|
-
|
|
547
|
+
// The memo key doubles as the IMAGE's name — the extractor that answered plus the exact bytes it read — so a
|
|
548
|
+
// caller retaining a verdict derived from these units names it by the same identity that decides the parse
|
|
549
|
+
// ([[selector-anchor-scope]]). Returning the key rather than re-deriving it elsewhere is what keeps the two
|
|
550
|
+
// from drifting apart.
|
|
551
|
+
function currentTreeImage(root: string, x: Extractor, path: string): { key: string; units: Unit[] } {
|
|
288
552
|
const source = readFileSync(join(root, path), 'utf8')
|
|
289
553
|
const key = `${x.memoKey(path)}\0${createHash('sha1').update(source).digest('hex')}`
|
|
290
554
|
const hit = currentTreeUnitMemo.get(key)
|
|
291
|
-
if (hit) { if ('failed' in hit) throw new Error(hit.failed); return hit.units }
|
|
555
|
+
if (hit) { if ('failed' in hit) throw new Error(hit.failed); return { key, units: hit.units } }
|
|
292
556
|
let entry: { units: Unit[] } | { failed: string }
|
|
293
557
|
try { entry = { units: x.extract(source, path) } }
|
|
294
558
|
catch (err: any) { entry = { failed: err?.message ?? String(err) } }
|
|
295
559
|
if (currentTreeUnitMemo.size >= CURRENT_TREE_MEMO_MAX) currentTreeUnitMemo.clear()
|
|
296
560
|
currentTreeUnitMemo.set(key, entry)
|
|
297
561
|
if ('failed' in entry) throw new Error(entry.failed)
|
|
298
|
-
return entry.units
|
|
562
|
+
return { key, units: entry.units }
|
|
563
|
+
}
|
|
564
|
+
|
|
565
|
+
// what selector resolution READS for one path, named by its identity — or the reason nothing could be read.
|
|
566
|
+
// Split out from the verdict below so a caller can name the image before deciding whether it still has to
|
|
567
|
+
// derive the verdict at all; a `problem` image has no name, and an unnameable input is never retained.
|
|
568
|
+
type EntryImage = { key: string; units: Unit[] } | { problem: string }
|
|
569
|
+
function entryImage(root: string, regs: Extractor[], path: string, selector: string | undefined): EntryImage {
|
|
570
|
+
const x = extractorFor(regs, extOf(path))
|
|
571
|
+
if (!x) return { problem: `\`code\` selector \`${path}#${selector}\` — no designated extractor for that language; drop the #anchor or add a language row` }
|
|
572
|
+
const ready = x.ready()
|
|
573
|
+
if (ready !== true) return { problem: `\`code\` anchors on ${path} are unverified: ${ready}` }
|
|
574
|
+
try { return currentTreeImage(root, x, path) }
|
|
575
|
+
catch (err: any) { return { problem: `\`code\` anchors on ${path} are unverified: ${err?.message ?? String(err)}` } }
|
|
299
576
|
}
|
|
300
577
|
|
|
301
578
|
// every selector of one entry resolves to exactly one unit in the CURRENT tree, or the entry cannot testify.
|
|
@@ -303,22 +580,19 @@ function currentTreeUnits(root: string, x: Extractor, path: string): Unit[] {
|
|
|
303
580
|
// unit of that name", which for a name that exists nowhere is a vacuous no — true of spec drift, where the
|
|
304
581
|
// dead anchor is a separate blocking error, and dangerously false here, where the same silence would retire
|
|
305
582
|
// a reading's whole code axis. `problem` names the repair for lint; null means the entry is verifiable.
|
|
306
|
-
function
|
|
307
|
-
const x = extractorFor(regs, extOf(entry.path))
|
|
308
|
-
if (!x) return `\`code\` selector \`${entry.path}#${entry.selectors[0]}\` — no designated extractor for that language; drop the #anchor or add a language row`
|
|
309
|
-
const ready = x.ready()
|
|
310
|
-
if (ready !== true) return `\`code\` anchors on ${entry.path} are unverified: ${ready}`
|
|
311
|
-
let units
|
|
312
|
-
try { units = currentTreeUnits(root, x, entry.path) }
|
|
313
|
-
catch (err: any) { return `\`code\` anchors on ${entry.path} are unverified: ${err?.message ?? String(err)}` }
|
|
583
|
+
function selectorProblem(units: Unit[], path: string, selectors: readonly string[]): string | null {
|
|
314
584
|
// the SAME classifier the gate uses ([[code-anchor]]'s resolveSelectors) — only the wording is ours, so a
|
|
315
585
|
// selector can never be verifiable to one reader and dead to the other.
|
|
316
|
-
for (const r of resolveSelectors(units,
|
|
317
|
-
if ('dead' in r) return `\`code\` selector \`${
|
|
318
|
-
if ('ambiguous' in r) return `\`code\` selector \`${
|
|
586
|
+
for (const r of resolveSelectors(units, selectors)) {
|
|
587
|
+
if ('dead' in r) return `\`code\` selector \`${path}#${r.selector}\` names no unit in that file — follow the rename or drop the selector (evals stay stale until then)`
|
|
588
|
+
if ('ambiguous' in r) return `\`code\` selector \`${path}#${r.selector}\` is ambiguous — ${r.ambiguous} units share that name; pin a unique one`
|
|
319
589
|
}
|
|
320
590
|
return null
|
|
321
591
|
}
|
|
592
|
+
function entryUnverifiable(root: string, regs: Extractor[], entry: RelationEntry): string | null {
|
|
593
|
+
const image = entryImage(root, regs, entry.path, entry.selectors[0])
|
|
594
|
+
return 'problem' in image ? image.problem : selectorProblem(image.units, entry.path, entry.selectors)
|
|
595
|
+
}
|
|
322
596
|
|
|
323
597
|
// @@@ the verify sweep must reach the MACROTASK queue - resolving every reading's `code:` selectors against
|
|
324
598
|
// the working tree is a doubly-nested SYNCHRONOUS sweep (4,384 demands on this corpus), and nothing in it
|
|
@@ -332,32 +606,76 @@ function entryUnverifiable(root: string, regs: Extractor[], entry: RelationEntry
|
|
|
332
606
|
const VERIFY_YIELD_BUDGET_MS = Number(process.env.SPEXCODE_VERIFY_YIELD_BUDGET_MS || 50)
|
|
333
607
|
const yieldToEventLoop = (): Promise<void> => new Promise<void>((resolve) => { setImmediate(resolve) })
|
|
334
608
|
|
|
609
|
+
// A verdict's window is `sinceSha..HEAD`, and that window LENGTHENS as HEAD advances — the same
|
|
610
|
+
// (sinceSha, path, selectors) that answers false at one head can legitimately answer true at the next. So
|
|
611
|
+
// these are per-head immutable, never content-addressed-forever, and they belong in the root's current-head
|
|
612
|
+
// scope above rather than in a memo of their own ([[selector-anchor-scope]]).
|
|
613
|
+
// The working tree, however, does NOT stand still at one head: a session re-images the files a selector
|
|
614
|
+
// resolves against, and each image is its own key. Bound that with the same drop-wholesale idiom the
|
|
615
|
+
// current-tree parse memo uses instead of inventing a second eviction policy.
|
|
616
|
+
const ANCHOR_VERDICT_MAX = Math.max(1024, Number(process.env.SPEXCODE_ANCHOR_VERDICT_SLOTS || 8192))
|
|
617
|
+
|
|
335
618
|
export function anchorProbeFor(root: string, idx: DriftIndex): AnchorProbe {
|
|
619
|
+
const rootKey = resolve(root)
|
|
336
620
|
const regs = extractors(root)
|
|
337
621
|
const verdicts = new Map<string, boolean>()
|
|
622
|
+
// The scope is the one the content probe already owns, entered only when the index's own tip IS the
|
|
623
|
+
// current head. An index built at another tip cannot borrow this head's answers, and — the reason this is
|
|
624
|
+
// a guard rather than a rotation — a head that moved mid-read must not make THIS probe displace the
|
|
625
|
+
// content probe's just-settled scope. Either the two probes agree on the head and share one scope, or this
|
|
626
|
+
// one keeps nothing and recomputes.
|
|
627
|
+
const scopeOf = (): RootScope | null => {
|
|
628
|
+
if (!idx.tip) return null
|
|
629
|
+
const rawHead = headSha(root)
|
|
630
|
+
if (idx.tip !== rawHead) return null
|
|
631
|
+
return scopeFor(rootKey, `${rawHead}\x1f${gitObjectInterpretation(root).identity}`, null)
|
|
632
|
+
}
|
|
633
|
+
// which extractor BUILD answered is part of a verdict's identity too — a host TypeScript resolving to a
|
|
634
|
+
// different module or version must not inherit the previous one's units.
|
|
635
|
+
const registry = regs.map((x) => x.memoKey('\0registry')).join('\x1e')
|
|
338
636
|
return {
|
|
339
637
|
async prime(demands) {
|
|
638
|
+
const scope = scopeOf()
|
|
340
639
|
const keys: string[] = []
|
|
640
|
+
const scoped: (string | null)[] = []
|
|
341
641
|
const queries: AnchorHitQuery[] = []
|
|
342
642
|
const queued = new Set<string>()
|
|
643
|
+
// one coherent read of each source per sweep: every entry on a path resolves against the same bytes,
|
|
644
|
+
// and the read that names the verdict is the read the verdict was derived from.
|
|
645
|
+
const images = new Map<string, EntryImage>()
|
|
646
|
+
const imageOf = (path: string, selector: string | undefined): EntryImage => {
|
|
647
|
+
let hit = images.get(path)
|
|
648
|
+
if (hit === undefined) { hit = entryImage(root, regs, path, selector); images.set(path, hit) }
|
|
649
|
+
return hit
|
|
650
|
+
}
|
|
651
|
+
const remember = (name: string | null, hit: boolean) => { if (scope && name !== null) {
|
|
652
|
+
if (scope.anchorVerdicts.size >= ANCHOR_VERDICT_MAX) scope.anchorVerdicts.clear()
|
|
653
|
+
scope.anchorVerdicts.set(name, hit)
|
|
654
|
+
} }
|
|
343
655
|
let sinceYield = Date.now()
|
|
344
656
|
for (const { sinceSha, entries } of demands) for (const e of entries) {
|
|
345
657
|
if (Date.now() - sinceYield >= VERIFY_YIELD_BUDGET_MS) { await yieldToEventLoop(); sinceYield = Date.now() }
|
|
346
658
|
if (!e.selectors.length) continue
|
|
347
659
|
const key = anchorKey(sinceSha, e.path, e.selectors)
|
|
348
660
|
if (verdicts.has(key) || queued.has(key)) continue
|
|
349
|
-
|
|
661
|
+
const image = imageOf(e.path, e.selectors[0])
|
|
662
|
+
const name = 'problem' in image ? null : `${registry}\x1f${image.key}\x1f${key}`
|
|
663
|
+
const remembered = scope && name !== null ? scope.anchorVerdicts.get(name) : undefined
|
|
664
|
+
if (remembered !== undefined) { verdicts.set(key, remembered); continue }
|
|
665
|
+
// a miss runs the FULL derivation below, unchanged — it is what a verdict MEANS, not a fallback
|
|
666
|
+
if ('problem' in image || selectorProblem(image.units, e.path, e.selectors)) continue // no verdict → conservative stale (lint says why)
|
|
350
667
|
const win = eventsSince(idx, sinceSha, e.path)
|
|
351
668
|
if (win === null) continue
|
|
352
|
-
if (!win.length) { verdicts.set(key, false); continue }
|
|
669
|
+
if (!win.length) { verdicts.set(key, false); remember(name, false); continue }
|
|
353
670
|
queued.add(key)
|
|
354
671
|
keys.push(key)
|
|
672
|
+
scoped.push(name)
|
|
355
673
|
queries.push({ win, symbols: [...e.selectors] })
|
|
356
674
|
}
|
|
357
675
|
|
|
358
676
|
if (!queries.length) return
|
|
359
677
|
const results = await anchorHitExists(root, queries, regs)
|
|
360
|
-
results.forEach((hit, index) => verdicts.set(keys[index], hit))
|
|
678
|
+
results.forEach((hit, index) => { verdicts.set(keys[index], hit); remember(scoped[index], hit) })
|
|
361
679
|
},
|
|
362
680
|
hit(sinceSha, path, selectors) {
|
|
363
681
|
return verdicts.get(anchorKey(sinceSha, path, selectors)) ?? null
|
|
@@ -410,7 +728,8 @@ export function changedSince(idx: DriftIndex, sinceSha: string, path: string, pr
|
|
|
410
728
|
// a stale eval can say "EvalsFeed.jsx +3" instead of a bare "code moved". Same DAG reachability as
|
|
411
729
|
// changedSince (a commit touching the file that is NOT an ancestor of the reading's sha lies in sinceSha..HEAD);
|
|
412
730
|
// an off-history sinceSha reports through the same content fallback (only files whose content differs, counted
|
|
413
|
-
//
|
|
731
|
+
// against the anchor's own ancestry); with no probe or a gone anchor it counts every touch (conservative,
|
|
732
|
+
// matching changedSince).
|
|
414
733
|
// Reporting only — it never decides freshness (staleAxes does); it explains a decision already made.
|
|
415
734
|
export function codeDrift(idx: DriftIndex, sinceSha: string, codeAxis: ScenarioCodeAxisSource, probe?: ContentProbe): { file: string; behind: number }[] {
|
|
416
735
|
// an entry may be anchored (`path#symbol`); drift is reported per BASE FILE — a raw selector string names
|
|
@@ -422,7 +741,11 @@ export function codeDrift(idx: DriftIndex, sinceSha: string, codeAxis: ScenarioC
|
|
|
422
741
|
const events = pathEvents(idx, f)
|
|
423
742
|
const differs = since ? undefined : probe?.changed(sinceSha, f)
|
|
424
743
|
const behind = since ? new Set(since.map((event) => event.commit)).size
|
|
425
|
-
|
|
744
|
+
// off-history and the content differs: the SAME rule as the in-history branch above — the path's events
|
|
745
|
+
// this anchor has not already seen — read against the anchor's own ancestry instead of HEAD's. Floored
|
|
746
|
+
// at one: the trees demonstrably differ, so 'drifted by 0 commits' would be a lie whatever the walk
|
|
747
|
+
// could and could not project.
|
|
748
|
+
: differs === true ? Math.max(1, new Set(events.filter((event) => !probe!.inAnchorPast(sinceSha, event.commit)).map((event) => event.commit)).size)
|
|
426
749
|
: differs === false ? 0
|
|
427
750
|
: new Set(events.map((event) => event.commit)).size
|
|
428
751
|
if (behind > 0) out.push({ file: f, behind })
|