spexcode 0.6.1 → 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 +171 -28
- package/spec-cli/src/client.ts +112 -13
- package/spec-cli/src/delivery-queue.ts +62 -4
- package/spec-cli/src/execution-trace.ts +444 -0
- package/spec-cli/src/gateway-hub.ts +10 -8
- package/spec-cli/src/gateway.ts +99 -6
- package/spec-cli/src/git.ts +477 -101
- package/spec-cli/src/graph.ts +56 -6
- package/spec-cli/src/graphCache.ts +86 -17
- package/spec-cli/src/graphStream.ts +1 -1
- package/spec-cli/src/guide.ts +93 -4
- package/spec-cli/src/harness-select.ts +2 -2
- package/spec-cli/src/harness.ts +344 -66
- package/spec-cli/src/help.ts +22 -10
- package/spec-cli/src/host-resources.ts +62 -7
- package/spec-cli/src/identity-presets.js +16 -6
- package/spec-cli/src/index.ts +101 -13
- package/spec-cli/src/layout.ts +116 -33
- package/spec-cli/src/lint.ts +37 -7
- package/spec-cli/src/materialize.ts +10 -5
- package/spec-cli/src/plugin-harness.ts +10 -10
- package/spec-cli/src/reviews.ts +57 -28
- 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 +584 -104
- package/spec-cli/src/uninstall.ts +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/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-F9uaAVcH.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-CuKLFhH3.js → IssuesPage-BG9rNOgV.js} +1 -1
- package/spec-dashboard/dist/assets/MobileApp-D9yPMR3A.js +2 -0
- package/spec-dashboard/dist/assets/{Modal-CQgYymmr.js → Modal-B3EcgeA3.js} +1 -1
- package/spec-dashboard/dist/assets/{PageScroll-hT7UTLvD.js → PageScroll-CNZOugWV.js} +1 -1
- package/spec-dashboard/dist/assets/{ProjectsPage-CtXxakF9.js → ProjectsPage-C2CRHmvs.js} +1 -1
- package/spec-dashboard/dist/assets/SessionInterface-DOEjuT3l.js +40 -0
- package/spec-dashboard/dist/assets/{SessionWindow-CixDi4PI.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-DrVao0Ep.js → index-DLVeOHL-.js} +10 -10
- package/spec-dashboard/dist/assets/index-DYLGfPjx.css +1 -0
- package/spec-dashboard/dist/assets/{launch-BBH02b1v.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-eval/src/cli.ts +6 -1
- package/spec-eval/src/evaltab.ts +7 -5
- package/spec-eval/src/freshness.ts +383 -104
- package/spec-eval/src/scenariofresh.ts +2 -2
- package/spec-eval/src/sessioneval.ts +126 -132
- package/spec-dashboard/dist/assets/Dashboard-Ba_jhxp1.js +0 -27
- package/spec-dashboard/dist/assets/EvalsPage-FixoOg_n.js +0 -2
- package/spec-dashboard/dist/assets/MobileApp-CHgEHORJ.js +0 -2
- package/spec-dashboard/dist/assets/SessionInterface-Bpie-9fs.js +0 -39
- package/spec-dashboard/dist/assets/Settings-C2MsucfE.js +0 -1
- package/spec-dashboard/dist/assets/Thread-C6Go8HRh.js +0 -13
- package/spec-dashboard/dist/assets/data-B-RQmit6.js +0 -1
- package/spec-dashboard/dist/assets/index-CsI8DElI.css +0 -1
- package/spec-dashboard/dist/assets/terminalFont-C6oGNKrf.js +0 -1
|
@@ -1,7 +1,7 @@
|
|
|
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'
|
|
@@ -11,6 +11,8 @@ import { scenarioChangeCommits, scenarioBlocksAt, primeScenarioBlocks, type Scen
|
|
|
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,11 @@ 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>
|
|
37
41
|
// answer every scenario-block demand `prime` recorded, in ONE batched pair of children. Optional and
|
|
38
42
|
// idempotent: an unflushed demand degrades to the singular sync lookup, never to a wrong block.
|
|
39
43
|
primeBlocks?(): Promise<void>
|
|
@@ -50,31 +54,40 @@ export type ContentProbe = {
|
|
|
50
54
|
// An anchor entry holds only PER-REQUESTED-PATH verdicts (plus the gone bit and the batch bookkeeping),
|
|
51
55
|
// never a whole-repo path set.
|
|
52
56
|
type AnchorVerdicts = {
|
|
57
|
+
imageOid: string | null | undefined // undefined = interpretation resolution is still in flight
|
|
53
58
|
verdicts: Map<string, boolean> // requested path → content differs between the two immutable trees
|
|
54
59
|
gone: boolean // the anchor commit object is locally unreadable — content can't testify
|
|
55
60
|
pending: Set<string> // paths awaiting the next batch child
|
|
56
61
|
flight: Promise<void> | null // the single in-flight batch for this anchor
|
|
57
62
|
}
|
|
58
63
|
type RootScope = {
|
|
59
|
-
head: string
|
|
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
|
|
60
66
|
anchors: Map<string, AnchorVerdicts>
|
|
61
|
-
|
|
62
|
-
|
|
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
|
|
63
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>
|
|
64
76
|
}
|
|
65
77
|
const rootScopes = new Map<string, RootScope>()
|
|
66
78
|
// Roots come and go (a closed worktree never asks again), so cap how many stay warm — the same bounded-slot
|
|
67
79
|
// guard the index caches use, and the only bound needed once per-root cardinality is the corpus, not history.
|
|
68
80
|
const ROOT_SLOTS = Math.max(4, Number(process.env.SPEXCODE_FRESHNESS_ROOT_SLOTS || 64))
|
|
69
81
|
|
|
70
|
-
function scopeFor(rootKey: string, head: string): RootScope {
|
|
82
|
+
function scopeFor(rootKey: string, head: string, currentOid: string | null): RootScope {
|
|
71
83
|
const current = rootScopes.get(rootKey)
|
|
72
|
-
if (current?.head === head) {
|
|
84
|
+
if (current?.head === head && (current.currentOid === currentOid || currentOid === null || current.currentOid === null)) {
|
|
85
|
+
if (currentOid !== null) current.currentOid = currentOid
|
|
73
86
|
rootScopes.delete(rootKey)
|
|
74
87
|
rootScopes.set(rootKey, current)
|
|
75
88
|
return current
|
|
76
89
|
}
|
|
77
|
-
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() }
|
|
78
91
|
rootScopes.set(rootKey, scope)
|
|
79
92
|
while (rootScopes.size > ROOT_SLOTS) {
|
|
80
93
|
const oldest = rootScopes.keys().next().value
|
|
@@ -99,10 +112,27 @@ export function freshnessCacheSize(root?: string): number {
|
|
|
99
112
|
return total
|
|
100
113
|
}
|
|
101
114
|
|
|
102
|
-
|
|
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 {
|
|
103
127
|
const hit = scope.anchors.get(sha)
|
|
104
|
-
if (hit) return hit
|
|
105
|
-
|
|
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 }
|
|
106
136
|
scope.anchors.set(sha, entry)
|
|
107
137
|
return entry
|
|
108
138
|
}
|
|
@@ -172,95 +202,275 @@ function acquireHeavyDiff(scopeKey: string, signal?: AbortSignal): Promise<() =>
|
|
|
172
202
|
})
|
|
173
203
|
}
|
|
174
204
|
|
|
175
|
-
// @@@ one batch per
|
|
205
|
+
// @@@ one bounded batch per planned population, only the paths actually asked about - the tree comparison is pathspec-scoped
|
|
176
206
|
// (`:(literal)` so a path holding a glob char, a space or a leading colon is matched verbatim, `-z` so the
|
|
177
207
|
// answer needs no unquoting), and the answer retained is one boolean per REQUESTED path. Concurrent primes
|
|
178
208
|
// on the same anchor therefore union their paths BEFORE the child starts: a caller records what it needs in
|
|
179
209
|
// `pending`, then either starts the batch that drains pending or joins the running one and re-checks after —
|
|
180
210
|
// so a path requested mid-flight rides the NEXT batch instead of racing this one, and a settled path is
|
|
181
|
-
// never asked again.
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
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)
|
|
200
256
|
}
|
|
201
257
|
}
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
return flight
|
|
258
|
+
flush()
|
|
259
|
+
return chunks
|
|
205
260
|
}
|
|
206
261
|
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
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() }
|
|
223
345
|
}
|
|
224
|
-
|
|
225
|
-
|
|
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
|
|
226
351
|
return flight
|
|
227
352
|
}
|
|
228
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
|
+
|
|
229
386
|
export function contentProbeFor(root: string): ContentProbe {
|
|
230
387
|
const rootKey = resolve(root)
|
|
231
388
|
let head: string | undefined
|
|
389
|
+
let activeImageKey: string | undefined
|
|
232
390
|
const headOf = () => (head ??= headSha(root))
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
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)
|
|
248
413
|
}
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
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 })
|
|
253
446
|
}
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
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}`)
|
|
260
462
|
}
|
|
463
|
+
}
|
|
464
|
+
}
|
|
465
|
+
return {
|
|
466
|
+
async prime(sha, paths, evalPath) {
|
|
467
|
+
await primeMany([{ anchorSha: sha, paths, evalPath }])
|
|
261
468
|
},
|
|
469
|
+
primeMany,
|
|
262
470
|
async primeBlocks() {
|
|
263
|
-
const
|
|
471
|
+
const image = imageOf()
|
|
472
|
+
activeImageKey = image.key
|
|
473
|
+
const scope = currentScope(rootKey, image.key)
|
|
264
474
|
if (!scope?.blockDemands.size) return
|
|
265
475
|
const demands = [...scope.blockDemands].map((k) => {
|
|
266
476
|
const cut = k.indexOf('\x1f')
|
|
@@ -272,22 +482,28 @@ export function contentProbeFor(root: string): ContentProbe {
|
|
|
272
482
|
changed(sha, path) {
|
|
273
483
|
// The async prime owns all I/O. An unprimed path is 'can't testify', never a fresh sync diff: a miss
|
|
274
484
|
// stays conservative and no abort/transient failure can turn into an unbounded synchronous fallback.
|
|
275
|
-
const entry = currentScope(rootKey,
|
|
485
|
+
const entry = activeImageKey ? currentScope(rootKey, activeImageKey)?.anchors.get(sha) : undefined
|
|
276
486
|
if (!entry || entry.gone) return null
|
|
277
487
|
return entry.verdicts.get(path) ?? null
|
|
278
488
|
},
|
|
279
489
|
canTestify(sha) {
|
|
280
490
|
// a settled verdict — of either polarity — is the proof the anchor's tree was readable
|
|
281
|
-
const entry = currentScope(rootKey,
|
|
491
|
+
const entry = activeImageKey ? currentScope(rootKey, activeImageKey)?.anchors.get(sha) : undefined
|
|
282
492
|
return !!entry && !entry.gone && entry.verdicts.size > 0
|
|
283
493
|
},
|
|
284
494
|
scenarioDiffers(sha, evalPath, scenario) {
|
|
285
|
-
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
|
|
286
498
|
if (!a) return true // eval.md unreadable at the anchor (renamed/absent) → can't prove → stale
|
|
287
|
-
return a.get(scenario) !== scenarioBlocksAt(root,
|
|
499
|
+
return a.get(scenario) !== scenarioBlocksAt(root, scope!.currentOid!, evalPath)?.get(scenario)
|
|
288
500
|
},
|
|
289
|
-
|
|
290
|
-
|
|
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)
|
|
291
507
|
},
|
|
292
508
|
}
|
|
293
509
|
}
|
|
@@ -328,18 +544,35 @@ const anchorKey = (sinceSha: string, path: string, selectors: readonly string[])
|
|
|
328
544
|
// an unparseable file does not re-parse once per entry.
|
|
329
545
|
const CURRENT_TREE_MEMO_MAX = 4096
|
|
330
546
|
const currentTreeUnitMemo = new Map<string, { units: Unit[] } | { failed: string }>()
|
|
331
|
-
|
|
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[] } {
|
|
332
552
|
const source = readFileSync(join(root, path), 'utf8')
|
|
333
553
|
const key = `${x.memoKey(path)}\0${createHash('sha1').update(source).digest('hex')}`
|
|
334
554
|
const hit = currentTreeUnitMemo.get(key)
|
|
335
|
-
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 } }
|
|
336
556
|
let entry: { units: Unit[] } | { failed: string }
|
|
337
557
|
try { entry = { units: x.extract(source, path) } }
|
|
338
558
|
catch (err: any) { entry = { failed: err?.message ?? String(err) } }
|
|
339
559
|
if (currentTreeUnitMemo.size >= CURRENT_TREE_MEMO_MAX) currentTreeUnitMemo.clear()
|
|
340
560
|
currentTreeUnitMemo.set(key, entry)
|
|
341
561
|
if ('failed' in entry) throw new Error(entry.failed)
|
|
342
|
-
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)}` } }
|
|
343
576
|
}
|
|
344
577
|
|
|
345
578
|
// every selector of one entry resolves to exactly one unit in the CURRENT tree, or the entry cannot testify.
|
|
@@ -347,22 +580,19 @@ function currentTreeUnits(root: string, x: Extractor, path: string): Unit[] {
|
|
|
347
580
|
// unit of that name", which for a name that exists nowhere is a vacuous no — true of spec drift, where the
|
|
348
581
|
// dead anchor is a separate blocking error, and dangerously false here, where the same silence would retire
|
|
349
582
|
// a reading's whole code axis. `problem` names the repair for lint; null means the entry is verifiable.
|
|
350
|
-
function
|
|
351
|
-
const x = extractorFor(regs, extOf(entry.path))
|
|
352
|
-
if (!x) return `\`code\` selector \`${entry.path}#${entry.selectors[0]}\` — no designated extractor for that language; drop the #anchor or add a language row`
|
|
353
|
-
const ready = x.ready()
|
|
354
|
-
if (ready !== true) return `\`code\` anchors on ${entry.path} are unverified: ${ready}`
|
|
355
|
-
let units
|
|
356
|
-
try { units = currentTreeUnits(root, x, entry.path) }
|
|
357
|
-
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 {
|
|
358
584
|
// the SAME classifier the gate uses ([[code-anchor]]'s resolveSelectors) — only the wording is ours, so a
|
|
359
585
|
// selector can never be verifiable to one reader and dead to the other.
|
|
360
|
-
for (const r of resolveSelectors(units,
|
|
361
|
-
if ('dead' in r) return `\`code\` selector \`${
|
|
362
|
-
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`
|
|
363
589
|
}
|
|
364
590
|
return null
|
|
365
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
|
+
}
|
|
366
596
|
|
|
367
597
|
// @@@ the verify sweep must reach the MACROTASK queue - resolving every reading's `code:` selectors against
|
|
368
598
|
// the working tree is a doubly-nested SYNCHRONOUS sweep (4,384 demands on this corpus), and nothing in it
|
|
@@ -376,32 +606,76 @@ function entryUnverifiable(root: string, regs: Extractor[], entry: RelationEntry
|
|
|
376
606
|
const VERIFY_YIELD_BUDGET_MS = Number(process.env.SPEXCODE_VERIFY_YIELD_BUDGET_MS || 50)
|
|
377
607
|
const yieldToEventLoop = (): Promise<void> => new Promise<void>((resolve) => { setImmediate(resolve) })
|
|
378
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
|
+
|
|
379
618
|
export function anchorProbeFor(root: string, idx: DriftIndex): AnchorProbe {
|
|
619
|
+
const rootKey = resolve(root)
|
|
380
620
|
const regs = extractors(root)
|
|
381
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')
|
|
382
636
|
return {
|
|
383
637
|
async prime(demands) {
|
|
638
|
+
const scope = scopeOf()
|
|
384
639
|
const keys: string[] = []
|
|
640
|
+
const scoped: (string | null)[] = []
|
|
385
641
|
const queries: AnchorHitQuery[] = []
|
|
386
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
|
+
} }
|
|
387
655
|
let sinceYield = Date.now()
|
|
388
656
|
for (const { sinceSha, entries } of demands) for (const e of entries) {
|
|
389
657
|
if (Date.now() - sinceYield >= VERIFY_YIELD_BUDGET_MS) { await yieldToEventLoop(); sinceYield = Date.now() }
|
|
390
658
|
if (!e.selectors.length) continue
|
|
391
659
|
const key = anchorKey(sinceSha, e.path, e.selectors)
|
|
392
660
|
if (verdicts.has(key) || queued.has(key)) continue
|
|
393
|
-
|
|
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)
|
|
394
667
|
const win = eventsSince(idx, sinceSha, e.path)
|
|
395
668
|
if (win === null) continue
|
|
396
|
-
if (!win.length) { verdicts.set(key, false); continue }
|
|
669
|
+
if (!win.length) { verdicts.set(key, false); remember(name, false); continue }
|
|
397
670
|
queued.add(key)
|
|
398
671
|
keys.push(key)
|
|
672
|
+
scoped.push(name)
|
|
399
673
|
queries.push({ win, symbols: [...e.selectors] })
|
|
400
674
|
}
|
|
401
675
|
|
|
402
676
|
if (!queries.length) return
|
|
403
677
|
const results = await anchorHitExists(root, queries, regs)
|
|
404
|
-
results.forEach((hit, index) => verdicts.set(keys[index], hit))
|
|
678
|
+
results.forEach((hit, index) => { verdicts.set(keys[index], hit); remember(scoped[index], hit) })
|
|
405
679
|
},
|
|
406
680
|
hit(sinceSha, path, selectors) {
|
|
407
681
|
return verdicts.get(anchorKey(sinceSha, path, selectors)) ?? null
|
|
@@ -454,7 +728,8 @@ export function changedSince(idx: DriftIndex, sinceSha: string, path: string, pr
|
|
|
454
728
|
// a stale eval can say "EvalsFeed.jsx +3" instead of a bare "code moved". Same DAG reachability as
|
|
455
729
|
// changedSince (a commit touching the file that is NOT an ancestor of the reading's sha lies in sinceSha..HEAD);
|
|
456
730
|
// an off-history sinceSha reports through the same content fallback (only files whose content differs, counted
|
|
457
|
-
//
|
|
731
|
+
// against the anchor's own ancestry); with no probe or a gone anchor it counts every touch (conservative,
|
|
732
|
+
// matching changedSince).
|
|
458
733
|
// Reporting only — it never decides freshness (staleAxes does); it explains a decision already made.
|
|
459
734
|
export function codeDrift(idx: DriftIndex, sinceSha: string, codeAxis: ScenarioCodeAxisSource, probe?: ContentProbe): { file: string; behind: number }[] {
|
|
460
735
|
// an entry may be anchored (`path#symbol`); drift is reported per BASE FILE — a raw selector string names
|
|
@@ -466,7 +741,11 @@ export function codeDrift(idx: DriftIndex, sinceSha: string, codeAxis: ScenarioC
|
|
|
466
741
|
const events = pathEvents(idx, f)
|
|
467
742
|
const differs = since ? undefined : probe?.changed(sinceSha, f)
|
|
468
743
|
const behind = since ? new Set(since.map((event) => event.commit)).size
|
|
469
|
-
|
|
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)
|
|
470
749
|
: differs === false ? 0
|
|
471
750
|
: new Set(events.map((event) => event.commit)).size
|
|
472
751
|
if (behind > 0) out.push({ file: f, behind })
|