spexcode 0.2.0 → 0.2.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 (48) hide show
  1. package/README.md +149 -102
  2. package/README.zh-CN.md +170 -0
  3. package/package.json +1 -1
  4. package/spec-cli/bin/spex.mjs +24 -1
  5. package/spec-cli/src/attach.ts +50 -0
  6. package/spec-cli/src/cli.ts +227 -66
  7. package/spec-cli/src/client.ts +47 -9
  8. package/spec-cli/src/{self.ts → doctor.ts} +26 -25
  9. package/spec-cli/src/gateway.ts +15 -11
  10. package/spec-cli/src/guide.ts +73 -17
  11. package/spec-cli/src/harness.ts +48 -19
  12. package/spec-cli/src/help.ts +141 -51
  13. package/spec-cli/src/index.ts +41 -14
  14. package/spec-cli/src/issues.ts +109 -31
  15. package/spec-cli/src/layout.ts +4 -4
  16. package/spec-cli/src/localIssues.ts +59 -58
  17. package/spec-cli/src/materialize.ts +4 -2
  18. package/spec-cli/src/mentions.ts +22 -1
  19. package/spec-cli/src/pty-bridge.ts +39 -4
  20. package/spec-cli/src/ranker.ts +31 -12
  21. package/spec-cli/src/search.bench.mjs +30 -7
  22. package/spec-cli/src/search.ts +39 -0
  23. package/spec-cli/src/sessions.ts +149 -62
  24. package/spec-cli/src/specs.ts +16 -4
  25. package/spec-cli/src/supervise.ts +30 -6
  26. package/spec-cli/src/tree.ts +118 -0
  27. package/spec-cli/templates/hooks/post-merge +2 -2
  28. package/spec-cli/templates/hooks/pre-commit +34 -15
  29. package/spec-cli/templates/hooks/prepare-commit-msg +8 -1
  30. package/spec-dashboard/dist/assets/Dashboard-BwZ2KzxB.js +27 -0
  31. package/spec-dashboard/dist/assets/Dashboard-C5ap-Sga.css +1 -0
  32. package/spec-dashboard/dist/assets/EvalsPage-DV75EdP4.js +3 -0
  33. package/spec-dashboard/dist/assets/FoldToggle-GwE0-k1d.js +1 -0
  34. package/spec-dashboard/dist/assets/IssuesPage-B17pnl9I.js +1 -0
  35. package/spec-dashboard/dist/assets/MobileApp-WEZbR8M1.js +1 -0
  36. package/spec-dashboard/dist/assets/SessionInterface-DYP7pi_n.css +32 -0
  37. package/spec-dashboard/dist/assets/SessionInterface-Sh8kHpnj.js +71 -0
  38. package/spec-dashboard/dist/assets/SessionWindow-EzFq-hLG.js +9 -0
  39. package/spec-dashboard/dist/assets/Settings-Dgtg-Xb9.js +1 -0
  40. package/spec-dashboard/dist/assets/index-CCmnCbKS.css +1 -0
  41. package/spec-dashboard/dist/assets/index-Dd0_U5rk.js +41 -0
  42. package/spec-dashboard/dist/index.html +2 -2
  43. package/spec-forge/src/cli.ts +4 -10
  44. package/spec-forge/src/drivers.ts +13 -0
  45. package/spec-yatsu/src/cli.ts +89 -15
  46. package/spec-yatsu/src/scenariofresh.ts +100 -30
  47. package/spec-dashboard/dist/assets/index-B0tgHeEQ.js +0 -145
  48. package/spec-dashboard/dist/assets/index-BTU-44Os.css +0 -32
@@ -1,4 +1,4 @@
1
- import { readFileSync, existsSync } from 'node:fs'
1
+ import { readFileSync, writeFileSync, existsSync } from 'node:fs'
2
2
  import { join, relative, dirname } from 'node:path'
3
3
  import { repoRoot, headSha, driftIndex, stagedFiles, git } from '../../spec-cli/src/git.js'
4
4
  import { loadSpecs } from '../../spec-cli/src/specs.js'
@@ -9,10 +9,11 @@ import { readReadings, readSidecar, appendReading, appendRetraction, latestPerSc
9
9
  import { staleAxes } from './freshness.js'
10
10
  import { scenarioIndex } from './scenariofresh.js'
11
11
  import { loadEvalRemarkTracks, trackKey } from '../../spec-cli/src/issues.js'
12
+ import { stripRefSigil } from '../../spec-cli/src/mentions.js'
12
13
  import { evaluatorTag } from './evaluator.js'
13
- import { putBlob, listBlobs, gc, isStrayBlob } from './cache.js'
14
+ import { putBlob, blobPath, listBlobs, gc, isStrayBlob } from './cache.js'
14
15
  import { validateTimeline } from './timeline.js'
15
- import { evalTimeline, type EvalTimeline } from './evaltab.js'
16
+ import { evalTimeline, readBlobByHash, type EvalTimeline } from './evaltab.js'
16
17
 
17
18
  function flag(args: string[], name: string): string | undefined {
18
19
  const i = args.indexOf(`--${name}`)
@@ -206,7 +207,7 @@ async function evalCmd(args: string[]): Promise<number> {
206
207
  return 2
207
208
  }
208
209
  const sel = positional(args)
209
- const id = !sel || sel === '.' ? currentNodeId(root) : sel
210
+ const id = !sel || sel === '.' ? currentNodeId(root) : stripRefSigil(sel) // node args tolerate @/[[ ]] sigils ([[mentions]])
210
211
  if (!id) { console.error('spex yatsu eval .: no current node (no .session/node-branch here) — name a node'); return 2 }
211
212
  const node = (await gatherNodes(root)).find((n) => n.id === id)
212
213
  if (!node) { console.error(`spex yatsu eval: no yatsu node '${id}' (a node needs a yatsu.md)`); return 1 }
@@ -273,9 +274,34 @@ async function evalCmd(args: string[]): Promise<number> {
273
274
  : 'no evidence'
274
275
  console.log(` ✓ '${id}' scenario '${scenario.name}' → ${verdictText(verdict)} @ ${reading.codeSha.slice(0, 7)} [${reading.evaluator}] (${ev})`)
275
276
  console.log(`spex yatsu eval: 1 measurement filed`)
277
+
278
+ // @@@mis-anchor guard - a codeSha names a COMMIT, never a working tree: filed over uncommitted governed
279
+ // edits, this reading claims a verdict at HEAD while HEAD lacks the code actually measured — wrong from
280
+ // birth, and the stale flag after the next commit is freshness exposing it, not an engine bug. Warn,
281
+ // never block: the honest flow is measure on the tree until green → commit that tested tree → file.
282
+ const dirty = dirtyGoverned(root, [...(scenario.code?.length ? scenario.code : node.codeFiles), node.yatsuPath])
283
+ if (dirty.length) {
284
+ console.error(` ⚠ mis-anchored reading: uncommitted changes in governed ${dirty.join(', ')}`)
285
+ console.error(` this reading anchors to HEAD ${reading.codeSha.slice(0, 7)}, which does NOT contain those edits — it claims a ${verdict.status} for code that never ran, and the commit that lands them will (correctly) read it stale.`)
286
+ console.error(` the honest flow: measure on the tree until green → commit that just-tested tree (code+spec) → THEN file the reading against the clean HEAD (retract this one if it recorded the dirty run).`)
287
+ }
276
288
  return 0
277
289
  }
278
290
 
291
+ // the governed paths with uncommitted changes (staged, unstaged, or untracked) — the mis-anchor guard's
292
+ // probe. Paths are repo-relative pathspecs, so a governed dir prefix or `*` glob scopes the same way
293
+ // nodeChanged matches it; a rename line reports its new name.
294
+ function dirtyGoverned(root: string, paths: string[]): string[] {
295
+ if (!paths.length) return []
296
+ const out = git(['-C', root, 'status', '--porcelain', '--', ...paths])
297
+ const files = out.split('\n').filter(Boolean).map((l) => {
298
+ const p = l.slice(3)
299
+ const i = p.indexOf(' -> ')
300
+ return i >= 0 ? p.slice(i + 4) : p
301
+ })
302
+ return [...new Set(files)]
303
+ }
304
+
279
305
  // the verdict from the flags: --pass or --fail sets the status (pass wins if both given); --note <text> is an
280
306
  // OPTIONAL annotation attached to either. No status flag → null (a measurement must commit to pass or fail).
281
307
  function parseVerdict(args: string[]): Verdict | null {
@@ -309,7 +335,7 @@ async function retractCmd(args: string[]): Promise<number> {
309
335
  return 2
310
336
  }
311
337
  const sel = positional(args)
312
- const id = !sel || sel === '.' ? currentNodeId(root) : sel
338
+ const id = !sel || sel === '.' ? currentNodeId(root) : stripRefSigil(sel)
313
339
  if (!id) { console.error('spex yatsu retract .: no current node (no .session/node-branch here) — name a node'); return 2 }
314
340
  const node = yatsuNodes(root).find((n) => n.id === id)
315
341
  if (!node) { console.error(`spex yatsu retract: no yatsu node '${id}' (a node needs a yatsu.md)`); return 1 }
@@ -410,7 +436,7 @@ function checkStaged(): number {
410
436
  async function show(args: string[]): Promise<number> {
411
437
  const root = repoRoot()
412
438
  const sel = positional(args)
413
- const id = !sel || sel === '.' ? currentNodeId(root) : sel
439
+ const id = !sel || sel === '.' ? currentNodeId(root) : stripRefSigil(sel)
414
440
  if (!id) { console.error('spex yatsu show .: no current node (no .session/node-branch here) — name a node'); return 2 }
415
441
  const tl = await evalTimeline(id) // no ctx → evalTimeline derives specs + driftIndex itself for this one id
416
442
  if (has(args, 'json')) { console.log(JSON.stringify(tl, null, 2)); return 0 }
@@ -464,16 +490,20 @@ export async function runYatsu(args: string[]): Promise<number> {
464
490
  return 2
465
491
  }
466
492
 
467
- // `spex blob put <file|->` ([[blob-put]]) — the bare transport half of evidence: stash bytes in the shared
468
- // content-addressed cache and print the hash, WITHOUT filing a reading (`yatsu eval --video` couples the
469
- // two). putBlob is idempotent by content, so the same command re-seeds a checkout whose cache lacks a blob
493
+ // `spex blob put <file|->` / `spex blob get <hash> [-o <file>]` ([[blob-put]], [[blob-get]]) — the bare
494
+ // evidence-transport pair: put stashes bytes in the shared content-addressed cache and prints the hash,
495
+ // WITHOUT filing a reading (`yatsu eval --video` couples the two); get is its symmetric read hash in,
496
+ // bytes out. putBlob is idempotent by content, so re-putting re-seeds a checkout whose cache lacks a blob
470
497
  // some thread already references by hash (the clone-evidence-404 repair).
471
- export function runBlob(args: string[]): number {
472
- const file = args[1]
473
- if (args[0] !== 'put' || file === undefined) {
474
- console.error('spex blob: put <file|-> — stash bytes in the shared evidence cache, print the content hash')
475
- return 2
476
- }
498
+ export async function runBlob(args: string[]): Promise<number> {
499
+ if (args[0] === 'put' && args[1] !== undefined) return blobPut(args[1])
500
+ if (args[0] === 'get') return blobGet(args.slice(1))
501
+ console.error('spex blob: put <file|-> — stash bytes in the shared evidence cache, print the content hash')
502
+ console.error(' get <hash> [-o <file>] — read a blob back: local cache first, backend fallback')
503
+ return 2
504
+ }
505
+
506
+ function blobPut(file: string): number {
477
507
  let bytes: Buffer
478
508
  try { bytes = readFileSync(file === '-' ? 0 : file) } catch (e) {
479
509
  console.error(`spex blob put: cannot read ${file}: ${(e as Error).message}`)
@@ -483,3 +513,47 @@ export function runBlob(args: string[]): number {
483
513
  console.log(putBlob(bytes))
484
514
  return 0
485
515
  }
516
+
517
+ // the read half: ① the local content-addressed cache (the evidence usually IS on this disk — no backend
518
+ // needed), ② on a local miss the same GET /api/yatsu/blob/:hash the dashboard streams from (the blob may
519
+ // have been pruned here, or put on another machine sharing the backend), ③ both missed → fail loud naming
520
+ // both paths. No third read mechanism — this reuses readBlobByHash and the existing endpoint verbatim.
521
+ async function blobGet(args: string[]): Promise<number> {
522
+ const oIdx = args.indexOf('-o')
523
+ const out = oIdx >= 0 ? args[oIdx + 1] : undefined
524
+ if (oIdx >= 0 && (out === undefined || out.startsWith('-'))) { console.error('spex blob get: -o needs a <file>'); return 2 }
525
+ const hash = args.find((a, i) => (oIdx < 0 || (i !== oIdx && i !== oIdx + 1)) && !a.startsWith('-'))
526
+ if (!hash) { console.error('spex blob get: usage: spex blob get <hash> [-o <file>]'); return 2 }
527
+ const local = readBlobByHash(hash) // validates 64-hex before touching the fs, then reads the shared cache
528
+ if (local.ok) return emitBlob(local.bytes, out)
529
+ if (local.reason === 'invalid') { console.error(`spex blob get: bad hash '${hash}' — a blob hash is 64 hex chars`); return 2 }
530
+ const { apiBase } = await import('../../spec-cli/src/sessions.js')
531
+ const url = `${await apiBase()}/api/yatsu/blob/${hash}`
532
+ let backendMiss: string
533
+ try {
534
+ const r = await fetch(url)
535
+ if (r.ok) return emitBlob(Buffer.from(await r.arrayBuffer()), out)
536
+ backendMiss = `HTTP ${r.status}`
537
+ } catch (e) {
538
+ backendMiss = `unreachable (${(e as Error).message})`
539
+ }
540
+ console.error(`spex blob get: ${hash} — not found on either path:`)
541
+ console.error(` local cache: ${blobPath(hash)} — no such blob (pruned, or put on another machine)`)
542
+ console.error(` backend: ${url} — ${backendMiss}`)
543
+ return 1
544
+ }
545
+
546
+ // default stdout (pipe-friendly; the cli.ts flushExit drains a large piped dump before exit), -o writes a
547
+ // file. Raw bytes straight at a human's terminal get a one-line stderr warning, not a block.
548
+ function emitBlob(bytes: Buffer, out?: string): number {
549
+ if (out !== undefined) {
550
+ try { writeFileSync(out, bytes) } catch (e) {
551
+ console.error(`spex blob get: cannot write ${out}: ${(e as Error).message}`)
552
+ return 2
553
+ }
554
+ return 0
555
+ }
556
+ if (process.stdout.isTTY) console.error(`spex blob get: writing ${bytes.length} raw bytes to a tty — pipe it or use -o <file>`)
557
+ process.stdout.write(bytes)
558
+ return 0
559
+ }
@@ -1,3 +1,4 @@
1
+ import { spawn } from 'node:child_process'
1
2
  import { gitA, headSha } from '../../spec-cli/src/git.js'
2
3
  import { parseScenarios } from './yatsu.js'
3
4
 
@@ -25,49 +26,118 @@ function blockContent(src: string): Map<string, string> {
25
26
  return m
26
27
  }
27
28
 
28
- // the file's content-versions newest-first, each { hash, blocks }. Skips a version we can't read (git show
29
- // '') so an unreadable blob never fabricates a remove+re-add diff.
30
- async function fileVersions(root: string, headPath: string): Promise<{ hash: string; blocks: Map<string, string> }[]> {
31
- // --follow + --name-only pairs each commit with the path the file had THERE, so `git show <hash>:<pathAtCommit>`
32
- // works across reparents (yatsu.md moves when its node does). `-M` keeps a rename+edit's content.
33
- const log = await gitA(['-C', root, '-c', 'core.quotePath=false', 'log', '-M', '--follow', `--format=${RS}%H`, '--name-only', '--', headPath])
34
- const out: { hash: string; blocks: Map<string, string> }[] = []
35
- for (const rec of log.split(RS)) {
36
- const lines = rec.split('\n').map((l) => l.trim()).filter(Boolean)
37
- if (!lines.length) continue
38
- const hash = lines[0]
39
- const pathAt = lines.slice(1).find((l) => l.endsWith('/yatsu.md') || l === 'yatsu.md') ?? headPath
40
- const content = await gitA(['-C', root, 'show', `${hash}:${pathAt}`])
41
- if (!content) continue
42
- out.push({ hash, blocks: blockContent(content) })
29
+ const ZERO = '0'.repeat(40) // the null OID a delete/add carries on its absent side
30
+ const EMPTY: Map<string, string> = new Map()
31
+
32
+ // content-addressed: a blob OID -> its canonical per-scenario block map. Git objects are IMMUTABLE, so this
33
+ // memo never needs invalidation and is shared across every root a per-worktree build reuses the blocks main
34
+ // already parsed, which is what lets a worktree's freshness build stay cheap instead of re-reading everything.
35
+ const blockByOid = new Map<string, Map<string, string>>()
36
+
37
+ // ONE `git log --raw` walk over all yatsu.md per head-path version chain [{hash, oid}], newest-first,
38
+ // rename-followed via the `alias` idiom (git.ts buildIndex): the newest sighting of a path IS its head path;
39
+ // an `R` row remaps the older `from` path onto that head. The raw row carries the new blob OID directly, so
40
+ // no per-version path resolution and no `git show`. --full-history is REQUIRED: default pathspec
41
+ // history-simplification prunes commits off HEAD's first-parent chain, but in this repo every spec edit lands
42
+ // via a --no-ff merge of a node branch, so those pruned commits ARE the scenario edits — dropping them would
43
+ // under-report staleness (a stale reading judged fresh). Merge commits emit no raw diff row, so they add no
44
+ // version (exactly as the old `--follow` did).
45
+ async function fileChains(root: string, wanted: Set<string>): Promise<Map<string, { hash: string; oid: string }[]>> {
46
+ const chains = new Map<string, { hash: string; oid: string }[]>()
47
+ const alias = new Map<string, string>()
48
+ const out = await gitA(['-C', root, '-c', 'core.quotePath=false', 'log',
49
+ '--raw', '--no-abbrev', '--full-history', '-M', `--format=${RS}%H`, '--', '*yatsu.md'])
50
+ for (const rec of out.split(RS)) {
51
+ const nl = rec.indexOf('\n')
52
+ if (nl < 0) continue
53
+ const hash = rec.slice(0, nl)
54
+ if (!hash) continue
55
+ for (const line of rec.slice(nl + 1).split('\n')) {
56
+ if (line[0] !== ':') continue // `:<oldmode> <newmode> <oldoid> <newoid> <status>\t<path>[\t<path2>]`
57
+ const tab = line.indexOf('\t')
58
+ if (tab < 0) continue
59
+ const meta = line.slice(1, tab).split(' ')
60
+ const oid = meta[3], rename = meta[4][0] === 'R' || meta[4][0] === 'C'
61
+ const paths = line.slice(tab + 1).split('\t')
62
+ const to = rename ? paths[1] : paths[0] // the path on the newer side of this commit
63
+ let head = alias.get(to)
64
+ if (head === undefined) { head = to; alias.set(to, to) }
65
+ let arr = chains.get(head); if (!arr) { arr = []; chains.set(head, arr) }
66
+ arr.push({ hash, oid })
67
+ if (rename && paths[0] !== to) { alias.set(paths[0], head); alias.delete(to) } // older history calls it `from`
68
+ }
43
69
  }
44
- return out
70
+ for (const k of [...chains.keys()]) if (!wanted.has(k)) chains.delete(k) // keep only the head paths asked for
71
+ return chains
45
72
  }
46
73
 
47
- // per-scenario change-commits for one file: walk versions newest->oldest, attribute a commit to every
48
- // scenario whose block differs from the next-older version (undefined on either side = add/remove = a change).
49
- async function scenarioCommits(root: string, headPath: string): Promise<Map<string, string[]>> {
50
- const versions = await fileVersions(root, headPath)
74
+ // per-scenario change-commits for one file's version chain: newest->oldest, attribute a commit to every
75
+ // scenario whose canonical block differs from the next-older version (undefined either side = add/remove = a
76
+ // change). A ZERO-oid (delete) version is dropped — it carries no readable content, matching the old
77
+ // `git show ''` skip; a pure rename (R100, oid == the older version's oid) diffs to no change for free.
78
+ function scenarioCommits(chain: { hash: string; oid: string }[]): Map<string, string[]> {
51
79
  const commits = new Map<string, string[]>()
52
80
  const push = (name: string, hash: string) => { const a = commits.get(name); if (a) a.push(hash); else commits.set(name, [hash]) }
53
- for (let i = 0; i < versions.length; i++) {
54
- const cur = versions[i].blocks
55
- const older = versions[i + 1]?.blocks ?? new Map<string, string>()
81
+ const real = chain.filter((v) => v.oid !== ZERO)
82
+ for (let i = 0; i < real.length; i++) {
83
+ const cur = blockByOid.get(real[i].oid) ?? EMPTY
84
+ const older = i + 1 < real.length ? (blockByOid.get(real[i + 1].oid) ?? EMPTY) : EMPTY
56
85
  for (const name of new Set([...cur.keys(), ...older.keys()])) {
57
- if (cur.get(name) !== older.get(name)) push(name, versions[i].hash)
86
+ if (cur.get(name) !== older.get(name)) push(name, real[i].hash)
58
87
  }
59
88
  }
60
89
  return commits
61
90
  }
62
91
 
92
+ // read MANY blobs in ONE `git cat-file --batch` process (vs one `git show` per blob). Feeds the OIDs on
93
+ // stdin, parses the `<oid> <type> <size>\n<payload>\n` records byte-accurately (size is bytes; blobs are
94
+ // UTF-8). A `<oid> missing` line yields no entry. Env-stripped like git.ts's helpers (a stray GIT_DIR would
95
+ // misroute repo discovery); kept here beside its only caller — a general git-seam primitive if a second
96
+ // caller ever wants one.
97
+ function catFileBatch(root: string, oids: string[]): Promise<Map<string, string>> {
98
+ const out = new Map<string, string>()
99
+ if (!oids.length) return Promise.resolve(out)
100
+ return new Promise((resolve, reject) => {
101
+ const env = { ...process.env }
102
+ delete env.GIT_DIR; delete env.GIT_WORK_TREE; delete env.GIT_INDEX_FILE; delete env.GIT_OBJECT_DIRECTORY
103
+ const p = spawn('git', ['-C', root, 'cat-file', '--batch'], { env })
104
+ const chunks: Buffer[] = []
105
+ p.stdout.on('data', (c: Buffer) => chunks.push(c))
106
+ p.on('error', reject)
107
+ p.on('close', () => {
108
+ const buf = Buffer.concat(chunks)
109
+ let i = 0
110
+ while (i < buf.length) {
111
+ const nl = buf.indexOf(0x0a, i)
112
+ if (nl < 0) break
113
+ const header = buf.toString('utf8', i, nl)
114
+ i = nl + 1
115
+ if (header.endsWith(' missing')) continue // unknown OID — no payload follows
116
+ const size = Number(header.slice(header.lastIndexOf(' ') + 1))
117
+ if (!Number.isFinite(size)) break
118
+ out.set(header.slice(0, header.indexOf(' ')), buf.toString('utf8', i, i + size))
119
+ i += size + 1 // payload + its trailing newline
120
+ }
121
+ resolve(out)
122
+ })
123
+ p.stdin.on('error', () => { /* EPIPE if git exits early; the close handler reports what arrived */ })
124
+ p.stdin.write(oids.join('\n') + '\n')
125
+ p.stdin.end()
126
+ })
127
+ }
128
+
129
+ // TWO git subprocesses for the whole index (was F logs + V shows): one `--raw` log for the rename-followed
130
+ // chains, one `cat-file --batch` for every distinct blob not already memoized. Then a pure in-memory diff.
63
131
  async function build(root: string, yatsuPaths: string[]): Promise<ScenarioIndex> {
64
- const idx: ScenarioIndex = new Map()
65
- const CONC = 8 // bound the git-show fan-out; the whole index is built once per HEAD then cached
66
- for (let i = 0; i < yatsuPaths.length; i += CONC) {
67
- const batch = yatsuPaths.slice(i, i + CONC)
68
- const done = await Promise.all(batch.map((p) => scenarioCommits(root, p)))
69
- batch.forEach((p, j) => idx.set(p, done[j]))
132
+ const chains = await fileChains(root, new Set(yatsuPaths))
133
+ const need = new Set<string>()
134
+ for (const chain of chains.values()) for (const v of chain) if (v.oid !== ZERO && !blockByOid.has(v.oid)) need.add(v.oid)
135
+ if (need.size) {
136
+ const blobs = await catFileBatch(root, [...need])
137
+ for (const [oid, src] of blobs) blockByOid.set(oid, blockContent(src))
70
138
  }
139
+ const idx: ScenarioIndex = new Map()
140
+ for (const p of yatsuPaths) idx.set(p, scenarioCommits(chains.get(p) ?? [])) // every asked path gets an entry (empty if untracked)
71
141
  return idx
72
142
  }
73
143