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.
Files changed (72) 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 +171 -28
  6. package/spec-cli/src/client.ts +112 -13
  7. package/spec-cli/src/delivery-queue.ts +62 -4
  8. package/spec-cli/src/execution-trace.ts +444 -0
  9. package/spec-cli/src/gateway-hub.ts +10 -8
  10. package/spec-cli/src/gateway.ts +99 -6
  11. package/spec-cli/src/git.ts +477 -101
  12. package/spec-cli/src/graph.ts +56 -6
  13. package/spec-cli/src/graphCache.ts +86 -17
  14. package/spec-cli/src/graphStream.ts +1 -1
  15. package/spec-cli/src/guide.ts +93 -4
  16. package/spec-cli/src/harness-select.ts +2 -2
  17. package/spec-cli/src/harness.ts +344 -66
  18. package/spec-cli/src/help.ts +22 -10
  19. package/spec-cli/src/host-resources.ts +62 -7
  20. package/spec-cli/src/identity-presets.js +16 -6
  21. package/spec-cli/src/index.ts +101 -13
  22. package/spec-cli/src/layout.ts +116 -33
  23. package/spec-cli/src/lint.ts +37 -7
  24. package/spec-cli/src/materialize.ts +10 -5
  25. package/spec-cli/src/plugin-harness.ts +10 -10
  26. package/spec-cli/src/reviews.ts +57 -28
  27. package/spec-cli/src/session-execution.ts +68 -0
  28. package/spec-cli/src/session-files.ts +121 -0
  29. package/spec-cli/src/session-follow.ts +4 -4
  30. package/spec-cli/src/session-reparent.ts +33 -0
  31. package/spec-cli/src/session-timeline.ts +163 -14
  32. package/spec-cli/src/session-web.ts +135 -0
  33. package/spec-cli/src/sessions.ts +584 -104
  34. package/spec-cli/src/uninstall.ts +1 -1
  35. package/spec-cli/templates/spec/project/.plugins/core/comment-altitude/spec.md +2 -21
  36. package/spec-cli/templates/spec/project/.plugins/core/spec.md +12 -13
  37. package/spec-cli/templates/spec/project/.plugins/prompts/atomic-landing/spec.md +5 -22
  38. package/spec-cli/templates/spec/project/.plugins/prompts/forge-link/spec.md +2 -1
  39. package/spec-cli/templates/spec/project/.plugins/prompts/memory-hygiene/spec.md +4 -6
  40. package/spec-cli/templates/spec/project/.plugins/prompts/reproduce-before-fix/spec.md +3 -9
  41. package/spec-dashboard/dist/assets/{App-F9uaAVcH.js → App-B6IbFbGu.js} +2 -2
  42. package/spec-dashboard/dist/assets/Dashboard-_sDk7Rcg.js +27 -0
  43. package/spec-dashboard/dist/assets/EvalsPage-BT357Lyt.js +2 -0
  44. package/spec-dashboard/dist/assets/{IssuesPage-CuKLFhH3.js → IssuesPage-BG9rNOgV.js} +1 -1
  45. package/spec-dashboard/dist/assets/MobileApp-D9yPMR3A.js +2 -0
  46. package/spec-dashboard/dist/assets/{Modal-CQgYymmr.js → Modal-B3EcgeA3.js} +1 -1
  47. package/spec-dashboard/dist/assets/{PageScroll-hT7UTLvD.js → PageScroll-CNZOugWV.js} +1 -1
  48. package/spec-dashboard/dist/assets/{ProjectsPage-CtXxakF9.js → ProjectsPage-C2CRHmvs.js} +1 -1
  49. package/spec-dashboard/dist/assets/SessionInterface-DOEjuT3l.js +40 -0
  50. package/spec-dashboard/dist/assets/{SessionWindow-CixDi4PI.js → SessionWindow-1nYgZ4CL.js} +1 -1
  51. package/spec-dashboard/dist/assets/Settings-7WeTJn0V.js +1 -0
  52. package/spec-dashboard/dist/assets/Thread-CEbzAFQn.js +13 -0
  53. package/spec-dashboard/dist/assets/data-SlvxNA2r.js +1 -0
  54. package/spec-dashboard/dist/assets/{index-DrVao0Ep.js → index-DLVeOHL-.js} +10 -10
  55. package/spec-dashboard/dist/assets/index-DYLGfPjx.css +1 -0
  56. package/spec-dashboard/dist/assets/{launch-BBH02b1v.js → launch-yxCYr64x.js} +29 -29
  57. package/spec-dashboard/dist/assets/sessionSurface--7l69h4F.js +1 -0
  58. package/spec-dashboard/dist/index.html +2 -2
  59. package/spec-eval/src/cli.ts +6 -1
  60. package/spec-eval/src/evaltab.ts +7 -5
  61. package/spec-eval/src/freshness.ts +383 -104
  62. package/spec-eval/src/scenariofresh.ts +2 -2
  63. package/spec-eval/src/sessioneval.ts +126 -132
  64. package/spec-dashboard/dist/assets/Dashboard-Ba_jhxp1.js +0 -27
  65. package/spec-dashboard/dist/assets/EvalsPage-FixoOg_n.js +0 -2
  66. package/spec-dashboard/dist/assets/MobileApp-CHgEHORJ.js +0 -2
  67. package/spec-dashboard/dist/assets/SessionInterface-Bpie-9fs.js +0 -39
  68. package/spec-dashboard/dist/assets/Settings-C2MsucfE.js +0 -1
  69. package/spec-dashboard/dist/assets/Thread-C6Go8HRh.js +0 -13
  70. package/spec-dashboard/dist/assets/data-B-RQmit6.js +0 -1
  71. package/spec-dashboard/dist/assets/index-CsI8DElI.css +0 -1
  72. 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: 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>
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
- behind: Map<string, number>
62
- behindFlight: Map<string, Promise<number>> // the single in-flight count per (anchor, path)
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(), behind: new Map(), behindFlight: new Map(), blockDemands: new Set() }
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
- 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 {
103
127
  const hit = scope.anchors.get(sha)
104
- if (hit) return hit
105
- 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 }
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 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
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. Different anchors stay serial through the same scope permit.
182
- function startAnchorBatch(root: string, rootKey: string, sha: string, current: string, entry: AnchorVerdicts): Promise<void> {
183
- const run = async (): Promise<void> => {
184
- const release = await acquireHeavyDiff(`${rootKey}\x1f${current}`, currentGitBuildAbortSignal())
185
- // drain AFTER the permit, so every caller that registered while this batch was waiting rides THIS
186
- // child instead of paying for another one; anything registered after this line is the next batch.
187
- const batch = [...entry.pending]
188
- entry.pending.clear()
189
- try {
190
- if (!batch.length) return
191
- const result = await gitTry(['-C', root, 'diff', '--name-only', '-z', '--no-renames', sha, current,
192
- '--', ...batch.map((path) => `:(literal)${path}`)])
193
- if (!result.ok && result.failure !== 'exit')
194
- throw new Error(`git content diff failed (${result.failure ?? 'unknown'}): ${result.stderr.trim() || 'unknown git error'}`)
195
- if (!result.ok) { entry.gone = true; return } // the anchor commit object is unreadable — content can't testify
196
- const changed = new Set(result.stdout.split('\0').filter(Boolean))
197
- for (const path of batch) entry.verdicts.set(path, changed.has(path))
198
- } finally {
199
- 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)
200
256
  }
201
257
  }
202
- const flight = run().finally(() => { if (entry.flight === flight) entry.flight = null })
203
- entry.flight = flight
204
- return flight
258
+ flush()
259
+ return chunks
205
260
  }
206
261
 
207
- // @@@ join the in-flight count, don't re-fork it - `behind` holds only SETTLED counts, so N concurrent
208
- // primes naming one (anchor, path) all missed and each forked its own `rev-list --count`. Measured on
209
- // z-code's 415-node session scope: 1532 children for 806 distinct counts. `sha..current` names two
210
- // immutable commits, so a joiner can never be handed a different question's answer. A rejected flight is
211
- // cached nowhere and every joiner sees the same failure, matching the anchor batch above.
212
- function behindCount(root: string, scope: RootScope, sha: string, current: string, path: string): Promise<number> {
213
- const key = `${sha}\x1f${path}`
214
- const settled = scope.behind.get(key)
215
- if (settled !== undefined) return Promise.resolve(settled)
216
- const joined = scope.behindFlight.get(key)
217
- if (joined) return joined
218
- const run = async (): Promise<number> => {
219
- const n = Number((await gitA(['-C', root, 'rev-list', '--count', `${sha}..${current}`, '--', path])).trim())
220
- const count = Number.isFinite(n) && n > 0 ? n : 1
221
- scope.behind.set(key, count)
222
- return count
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
- const flight = run().finally(() => { if (scope.behindFlight.get(key) === flight) scope.behindFlight.delete(key) })
225
- scope.behindFlight.set(key, flight)
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
- return {
234
- async prime(sha, paths, evalPath) {
235
- const current = headOf()
236
- const wanted = [...new Set([...paths, evalPath])].filter(Boolean)
237
- // one scope resolve per prime: the entry stays this call's to settle even if the root's head moves
238
- // under it, but the swap means nothing it writes afterwards can be read back at the new head.
239
- const scope = scopeFor(rootKey, current)
240
- const entry = touchAnchor(scope, sha)
241
- while (!entry.gone) {
242
- const missing = wanted.filter((path) => !entry.verdicts.has(path))
243
- if (!missing.length) break
244
- for (const path of missing) entry.pending.add(path)
245
- // record first, then join: whoever starts the next batch drains everything pending by now.
246
- if (entry.flight) await entry.flight
247
- 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)
248
413
  }
249
- if (entry.gone) return
250
- for (const path of new Set(paths)) {
251
- if (entry.verdicts.get(path) !== true) continue
252
- await behindCount(root, scope, sha, current, path)
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
- // RECORD the scenario-block demand; primeBlocks() below answers the whole read's worth in two
255
- // children. A caller that never flushes still reads correct blocks — scenarioDiffers falls back to
256
- // the singular sync lookup — so this is a cost seam, not a correctness one.
257
- if (entry.verdicts.get(evalPath) === true) {
258
- scope.blockDemands.add(`${sha}\x1f${evalPath}`)
259
- scope.blockDemands.add(`${current}\x1f${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}`)
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 scope = currentScope(rootKey, headOf())
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, headOf())?.anchors.get(sha)
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, headOf())?.anchors.get(sha)
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 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
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, headOf(), evalPath)?.get(scenario)
499
+ return a.get(scenario) !== scenarioBlocksAt(root, scope!.currentOid!, evalPath)?.get(scenario)
288
500
  },
289
- behind(sha, path) {
290
- 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)
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
- 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[] } {
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 entryUnverifiable(root: string, regs: Extractor[], entry: RelationEntry): string | null {
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, entry.selectors)) {
361
- 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)`
362
- 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`
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
- 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)
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
- // 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).
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
- : 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)
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 })