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.
Files changed (88) hide show
  1. package/README.md +97 -149
  2. package/package.json +1 -1
  3. package/spec-cli/hooks/harness.sh +26 -6
  4. package/spec-cli/src/anchors.ts +27 -5
  5. package/spec-cli/src/cli.ts +239 -75
  6. package/spec-cli/src/client.ts +113 -14
  7. package/spec-cli/src/codex-runtime-generations.ts +97 -7
  8. package/spec-cli/src/commit-surgery.ts +2 -1
  9. package/spec-cli/src/contract-filter.ts +58 -42
  10. package/spec-cli/src/delivery-queue.ts +62 -4
  11. package/spec-cli/src/doctor.ts +2 -1
  12. package/spec-cli/src/execution-trace.ts +444 -0
  13. package/spec-cli/src/file-write.ts +22 -0
  14. package/spec-cli/src/gateway-hub.ts +10 -8
  15. package/spec-cli/src/gateway.ts +99 -6
  16. package/spec-cli/src/git.ts +503 -105
  17. package/spec-cli/src/graph.ts +56 -6
  18. package/spec-cli/src/graphCache.ts +86 -17
  19. package/spec-cli/src/graphStream.ts +79 -11
  20. package/spec-cli/src/guide.ts +93 -4
  21. package/spec-cli/src/harness-select.ts +2 -2
  22. package/spec-cli/src/harness.ts +355 -75
  23. package/spec-cli/src/help.ts +30 -23
  24. package/spec-cli/src/host-resources.ts +62 -7
  25. package/spec-cli/src/identity-presets.js +16 -6
  26. package/spec-cli/src/index.ts +109 -14
  27. package/spec-cli/src/init.ts +8 -17
  28. package/spec-cli/src/layout.ts +151 -40
  29. package/spec-cli/src/lint.ts +37 -7
  30. package/spec-cli/src/materialize.ts +160 -112
  31. package/spec-cli/src/plugin-harness.ts +29 -18
  32. package/spec-cli/src/process-identity.ts +13 -0
  33. package/spec-cli/src/reviews.ts +103 -30
  34. package/spec-cli/src/session-execution.ts +68 -0
  35. package/spec-cli/src/session-files.ts +121 -0
  36. package/spec-cli/src/session-follow.ts +4 -4
  37. package/spec-cli/src/session-reparent.ts +33 -0
  38. package/spec-cli/src/session-timeline.ts +163 -14
  39. package/spec-cli/src/session-web.ts +135 -0
  40. package/spec-cli/src/sessions.ts +748 -111
  41. package/spec-cli/src/uninstall.ts +3 -2
  42. package/spec-cli/templates/hooks/post-checkout +3 -0
  43. package/spec-cli/templates/hooks/post-merge +1 -0
  44. package/spec-cli/templates/spec/project/.plugins/commands/supervisor/spec.md +1 -1
  45. package/spec-cli/templates/spec/project/.plugins/core/comment-altitude/spec.md +2 -21
  46. package/spec-cli/templates/spec/project/.plugins/core/spec.md +12 -13
  47. package/spec-cli/templates/spec/project/.plugins/core/stop-gate/spec.md +1 -1
  48. package/spec-cli/templates/spec/project/.plugins/core/stop-gate/stop-gate.sh +2 -2
  49. package/spec-cli/templates/spec/project/.plugins/prompts/atomic-landing/spec.md +5 -22
  50. package/spec-cli/templates/spec/project/.plugins/prompts/forge-link/spec.md +2 -1
  51. package/spec-cli/templates/spec/project/.plugins/prompts/memory-hygiene/spec.md +4 -6
  52. package/spec-cli/templates/spec/project/.plugins/prompts/reproduce-before-fix/spec.md +3 -9
  53. package/spec-dashboard/dist/assets/{App-b8Nh0sgk.js → App-B6IbFbGu.js} +2 -2
  54. package/spec-dashboard/dist/assets/Dashboard-_sDk7Rcg.js +27 -0
  55. package/spec-dashboard/dist/assets/EvalsPage-BT357Lyt.js +2 -0
  56. package/spec-dashboard/dist/assets/{IssuesPage-CAP64YWE.js → IssuesPage-BG9rNOgV.js} +1 -1
  57. package/spec-dashboard/dist/assets/MobileApp-D9yPMR3A.js +2 -0
  58. package/spec-dashboard/dist/assets/{Modal-Drscez-d.js → Modal-B3EcgeA3.js} +1 -1
  59. package/spec-dashboard/dist/assets/{PageScroll-qW6uOJL8.js → PageScroll-CNZOugWV.js} +1 -1
  60. package/spec-dashboard/dist/assets/{ProjectsPage-CjybFBmR.js → ProjectsPage-C2CRHmvs.js} +1 -1
  61. package/spec-dashboard/dist/assets/SessionInterface-DOEjuT3l.js +40 -0
  62. package/spec-dashboard/dist/assets/{SessionWindow-iOk0yHoU.js → SessionWindow-1nYgZ4CL.js} +1 -1
  63. package/spec-dashboard/dist/assets/Settings-7WeTJn0V.js +1 -0
  64. package/spec-dashboard/dist/assets/Thread-CEbzAFQn.js +13 -0
  65. package/spec-dashboard/dist/assets/data-SlvxNA2r.js +1 -0
  66. package/spec-dashboard/dist/assets/{index-paP-z_Vd.js → index-DLVeOHL-.js} +10 -10
  67. package/spec-dashboard/dist/assets/index-DYLGfPjx.css +1 -0
  68. package/spec-dashboard/dist/assets/{launch-B-bYdWmh.js → launch-yxCYr64x.js} +29 -29
  69. package/spec-dashboard/dist/assets/sessionSurface--7l69h4F.js +1 -0
  70. package/spec-dashboard/dist/index.html +2 -2
  71. package/spec-dashboard/src/reviewFilters.js +5 -0
  72. package/spec-dashboard/src/session.js +5 -1
  73. package/spec-eval/src/cli.ts +13 -9
  74. package/spec-eval/src/evaltab.ts +32 -10
  75. package/spec-eval/src/freshness.ts +404 -81
  76. package/spec-eval/src/scenariofresh.ts +58 -8
  77. package/spec-eval/src/scenarios.ts +60 -12
  78. package/spec-eval/src/sessioneval.ts +234 -148
  79. package/spec-dashboard/dist/assets/Dashboard-CvAjfRC2.js +0 -27
  80. package/spec-dashboard/dist/assets/EvalsPage-Bz-nMKoS.js +0 -2
  81. package/spec-dashboard/dist/assets/MobileApp-D9L1Va8Z.js +0 -2
  82. package/spec-dashboard/dist/assets/SessionInterface-Dl9v0JFM.js +0 -39
  83. package/spec-dashboard/dist/assets/Settings-BZ1lGRJs.js +0 -1
  84. package/spec-dashboard/dist/assets/Thread-D_kcDnfd.js +0 -13
  85. package/spec-dashboard/dist/assets/data-Bwd3kAVL.js +0 -1
  86. package/spec-dashboard/dist/assets/index-DAbQBBK_.css +0 -1
  87. package/spec-dashboard/dist/assets/terminalFont-C6oGNKrf.js +0 -1
  88. 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, primeScenarioBlocksAt, type ScenarioIndex } from './scenariofresh.js'
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: commits in anchor..HEAD touching path (floored at 1 the content differs)
35
- behind(anchorSha: string, path: string): number
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 = { head: string; anchors: Map<string, AnchorVerdicts>; behind: Map<string, number> }
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(), behind: 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
- function touchAnchor(scope: RootScope, sha: string): AnchorVerdicts {
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
- const entry: AnchorVerdicts = { verdicts: new Map(), gone: false, pending: new Set(), flight: null }
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 anchor, only the paths actually asked about - the tree comparison is pathspec-scoped
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. Different anchors stay serial through the same scope permit.
173
- function startAnchorBatch(root: string, rootKey: string, sha: string, current: string, entry: AnchorVerdicts): Promise<void> {
174
- const run = async (): Promise<void> => {
175
- const release = await acquireHeavyDiff(`${rootKey}\x1f${current}`, currentGitBuildAbortSignal())
176
- // drain AFTER the permit, so every caller that registered while this batch was waiting rides THIS
177
- // child instead of paying for another one; anything registered after this line is the next batch.
178
- const batch = [...entry.pending]
179
- entry.pending.clear()
180
- try {
181
- if (!batch.length) return
182
- const result = await gitTry(['-C', root, 'diff', '--name-only', '-z', '--no-renames', sha, current,
183
- '--', ...batch.map((path) => `:(literal)${path}`)])
184
- if (!result.ok && result.failure !== 'exit')
185
- throw new Error(`git content diff failed (${result.failure ?? 'unknown'}): ${result.stderr.trim() || 'unknown git error'}`)
186
- if (!result.ok) { entry.gone = true; return } // the anchor commit object is unreadable — content can't testify
187
- const changed = new Set(result.stdout.split('\0').filter(Boolean))
188
- for (const path of batch) entry.verdicts.set(path, changed.has(path))
189
- } finally {
190
- release()
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
- const flight = run().finally(() => { if (entry.flight === flight) entry.flight = null })
194
- entry.flight = flight
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
- return {
203
- async prime(sha, paths, evalPath) {
204
- const current = headOf()
205
- const wanted = [...new Set([...paths, evalPath])].filter(Boolean)
206
- // one scope resolve per prime: the entry stays this call's to settle even if the root's head moves
207
- // under it, but the swap means nothing it writes afterwards can be read back at the new head.
208
- const scope = scopeFor(rootKey, current)
209
- const entry = touchAnchor(scope, sha)
210
- while (!entry.gone) {
211
- const missing = wanted.filter((path) => !entry.verdicts.has(path))
212
- if (!missing.length) break
213
- for (const path of missing) entry.pending.add(path)
214
- // record first, then join: whoever starts the next batch drains everything pending by now.
215
- if (entry.flight) await entry.flight
216
- else await startAnchorBatch(root, rootKey, sha, current, entry)
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
- if (entry.gone) return
219
- for (const path of new Set(paths)) {
220
- if (entry.verdicts.get(path) !== true) continue
221
- const behindKey = `${sha}\x1f${path}`
222
- if (scope.behind.has(behindKey)) continue
223
- const n = Number((await gitA(['-C', root, 'rev-list', '--count', `${sha}..${current}`, '--', path])).trim())
224
- scope.behind.set(behindKey, Number.isFinite(n) && n > 0 ? n : 1)
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 (entry.verdicts.get(evalPath) === true) await primeScenarioBlocksAt(root, [sha, current], evalPath)
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, headOf())?.anchors.get(sha)
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, headOf())?.anchors.get(sha)
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 a = scenarioBlocksAt(root, sha, evalPath)
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, headOf(), evalPath)?.get(scenario)
499
+ return a.get(scenario) !== scenarioBlocksAt(root, scope!.currentOid!, evalPath)?.get(scenario)
244
500
  },
245
- behind(sha, path) {
246
- return currentScope(rootKey, headOf())?.behind.get(`${sha}\x1f${path}`) ?? 1
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
- function currentTreeUnits(root: string, x: Extractor, path: string): Unit[] {
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 entryUnverifiable(root: string, regs: Extractor[], entry: RelationEntry): string | null {
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, entry.selectors)) {
317
- if ('dead' in r) return `\`code\` selector \`${entry.path}#${r.selector}\` names no unit in that file — follow the rename or drop the selector (evals stay stale until then)`
318
- if ('ambiguous' in r) return `\`code\` selector \`${entry.path}#${r.selector}\` is ambiguous — ${r.ambiguous} units share that name; pin a unique one`
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
- if (entryUnverifiable(root, regs, e)) continue // no verdict → conservative stale (lint says why)
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
- // by rev-list); with no probe or a gone anchor it counts every touch (conservative, matching changedSince).
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
- : differs === true ? probe!.behind(sinceSha, f)
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 })