spexcode 0.2.3 → 0.2.5

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 (58) hide show
  1. package/README.md +2 -2
  2. package/package.json +1 -1
  3. package/spec-cli/bin/spex.mjs +13 -10
  4. package/spec-cli/hooks/harness.sh +32 -6
  5. package/spec-cli/src/board.ts +2 -1
  6. package/spec-cli/src/boardCache.ts +27 -1
  7. package/spec-cli/src/boardStream.ts +5 -4
  8. package/spec-cli/src/cli.ts +82 -19
  9. package/spec-cli/src/contract-filter.ts +154 -0
  10. package/spec-cli/src/git.ts +18 -4
  11. package/spec-cli/src/guide.ts +91 -38
  12. package/spec-cli/src/harness.ts +18 -4
  13. package/spec-cli/src/help.ts +16 -7
  14. package/spec-cli/src/index.ts +9 -6
  15. package/spec-cli/src/init.ts +52 -10
  16. package/spec-cli/src/issues.ts +7 -5
  17. package/spec-cli/src/layout.ts +20 -8
  18. package/spec-cli/src/lint.ts +19 -0
  19. package/spec-cli/src/localIssues.ts +16 -4
  20. package/spec-cli/src/materialize.ts +218 -124
  21. package/spec-cli/src/ranker.ts +25 -8
  22. package/spec-cli/src/runtime-guard.ts +44 -0
  23. package/spec-cli/src/search.bench.mjs +15 -5
  24. package/spec-cli/src/sessions.ts +81 -12
  25. package/spec-cli/src/specs.ts +29 -14
  26. package/spec-cli/src/supervise.ts +2 -2
  27. package/spec-cli/src/tsx-bin.ts +6 -8
  28. package/spec-cli/src/uninstall.ts +15 -17
  29. package/spec-cli/src/worktree-sources.ts +56 -0
  30. package/spec-cli/templates/spec/project/.config/core/stop-gate/spec.md +1 -1
  31. package/spec-cli/templates/spec/project/.config/core/stop-gate/stop-gate.sh +26 -8
  32. package/spec-dashboard/dist/assets/Dashboard-CMRJGfYI.js +27 -0
  33. package/spec-dashboard/dist/assets/EvalsPage-Dj2mxcfW.js +2 -0
  34. package/spec-dashboard/dist/assets/{FoldToggle-B5leylLf.js → FoldToggle-BfNpeyRQ.js} +1 -1
  35. package/spec-dashboard/dist/assets/IssuesPage-DfY315kt.js +1 -0
  36. package/spec-dashboard/dist/assets/{MobileApp-RHNECU6x.js → MobileApp-BGdC0A0P.js} +1 -1
  37. package/spec-dashboard/dist/assets/{SessionInterface-YLD6IOmC.js → SessionInterface-BOBCAR0t.js} +5 -5
  38. package/spec-dashboard/dist/assets/SessionWindow-D7YmjV0i.js +9 -0
  39. package/spec-dashboard/dist/assets/{Settings-ZnOwskMZ.js → Settings-BlSNmpH_.js} +1 -1
  40. package/spec-dashboard/dist/assets/{index-BdRQfrkR.js → index-BFdzpd_O.js} +2 -2
  41. package/spec-dashboard/dist/assets/index-uGs9v_9o.css +1 -0
  42. package/spec-dashboard/dist/index.html +2 -2
  43. package/spec-forge/src/cli.ts +2 -2
  44. package/spec-forge/src/drivers/gitlab.ts +168 -0
  45. package/spec-forge/src/drivers.ts +80 -2
  46. package/spec-forge/src/resident.ts +10 -5
  47. package/spec-yatsu/src/cli.ts +37 -16
  48. package/spec-yatsu/src/evaltab.ts +6 -3
  49. package/spec-yatsu/src/filing.ts +13 -8
  50. package/spec-yatsu/src/freshness.ts +97 -22
  51. package/spec-yatsu/src/proof.ts +14 -3
  52. package/spec-yatsu/src/scenariofresh.ts +38 -11
  53. package/spec-yatsu/src/yatsu.ts +52 -28
  54. package/spec-dashboard/dist/assets/Dashboard-Dlg78cbC.js +0 -27
  55. package/spec-dashboard/dist/assets/EvalsPage-CDxc1-in.js +0 -3
  56. package/spec-dashboard/dist/assets/IssuesPage-C2yFXiO-.js +0 -1
  57. package/spec-dashboard/dist/assets/SessionWindow-CmKtpNUX.js +0 -9
  58. package/spec-dashboard/dist/assets/index-DEc5Ru3l.css +0 -1
@@ -4,7 +4,7 @@ import { loadSpecs } from '../../spec-cli/src/specs.js'
4
4
  import { loadEvalRemarkTracks, trackKey, type RemarkTrack, type Issue, type Reply } from '../../spec-cli/src/issues.js'
5
5
  import { yatsuNodes, type YatsuNode } from './yatsu.js'
6
6
  import { readSidecar, applyRetractions, evidenceOf, isJsonBlob, type Verdict, type EvidenceKind, type Retraction } from './sidecar.js'
7
- import { staleAxes, codeDrift, type StaleAxis } from './freshness.js'
7
+ import { staleAxes, codeDrift, contentProbeFor, type StaleAxis } from './freshness.js'
8
8
  import { scenarioIndex, type ScenarioIndex } from './scenariofresh.js'
9
9
  import { hasBlob, getBlob, MISS_BLOB } from './cache.js'
10
10
 
@@ -164,6 +164,9 @@ export async function evalTimeline(id: string, ctx?: EvalContext): Promise<EvalT
164
164
  // one raw sidecar read: the effective readings feed the scoreboard rows below; the retraction events ride
165
165
  // along as the undo trace (newest-first, like the readings).
166
166
  const { readings: rawReadings, retractions } = readSidecar(ynode.sidecarPath)
167
+ // the off-history content fallback ([[yatsu-core]]): fed to both git axes so a rebased/folded-away
168
+ // anchor with byte-identical governed content reads fresh. Lazy — an in-history reading never probes.
169
+ const probe = contentProbeFor(root)
167
170
  const readings: EvalEntry[] = applyRetractions(rawReadings, retractions).map((r) => {
168
171
  // a scenario's own `code` is its freshness code axis when it declares one; else the whole node's list.
169
172
  const sc = byName.get(r.scenario)
@@ -172,9 +175,9 @@ export async function evalTimeline(id: string, ctx?: EvalContext): Promise<EvalT
172
175
  // read-time overlay below; freshness never depends on that pin.
173
176
  const cf = sc?.code?.length ? sc.code : codeFiles
174
177
  const axes = staleAxes(r, cf, ynode.yatsuPath, idx, scidx,
175
- remarksFor(r.scenario).map((rm) => ({ resolved: !!rm.resolved, resolvedAt: rm.resolvedAt })))
178
+ remarksFor(r.scenario).map((rm) => ({ resolved: !!rm.resolved, resolvedAt: rm.resolvedAt })), probe)
176
179
  // when the code axis is stale, explain it: which of THIS reading's governed files moved, by how many commits.
177
- const drift = axes.includes('code') ? codeDrift(idx, r.codeSha, cf) : []
180
+ const drift = axes.includes('code') ? codeDrift(idx, r.codeSha, cf, probe) : []
178
181
  // the reading's evidence list, each entry resolved to its live blob state; the primary (video-first, else
179
182
  // first) drives the scalar compat fields for single-evidence consumers.
180
183
  const evidence: EvidenceView[] = evidenceOf(r).map((e) => ({ hash: e.hash, kind: e.kind, state: hasBlob(e.hash) ? 'present' : 'miss' }))
@@ -1,5 +1,5 @@
1
1
  import { repoRoot, headSha } from '../../spec-cli/src/git.js'
2
- import { yatsuNodes } from './yatsu.js'
2
+ import { yatsuNodes, resolveYatsuNode } from './yatsu.js'
3
3
  import { appendReading, readReadings, isJsonBlob, type Reading, type EvidenceKind } from './sidecar.js'
4
4
  import { putBlob } from './cache.js'
5
5
 
@@ -15,8 +15,11 @@ export function fileHumanReading(
15
15
  input: { scenario: string; status: 'pass' | 'fail'; note?: string; transcript?: string; by?: string },
16
16
  ): FileResult {
17
17
  const root = repoRoot()
18
- const node = yatsuNodes(root).find((n) => n.id === nodeId)
19
- if (!node) return { ok: false, error: `no yatsu node '${nodeId}' (a node needs a yatsu.md)` }
18
+ // the same loud resolution the CLI applies ([[yatsu-core]]): exact canonical id, else a unique bare
19
+ // leaf; an ambiguous leaf returns the candidate list instead of filing against an arbitrary node.
20
+ const res = resolveYatsuNode(yatsuNodes(root), nodeId)
21
+ if (!res.ok) return { ok: false, error: res.error }
22
+ const node = res.node
20
23
  const sc = node.scenarios.find((s) => s.name === input.scenario)
21
24
  if (!sc) return { ok: false, error: `'${nodeId}' has no scenario '${input.scenario}'` }
22
25
  if (input.status !== 'pass' && input.status !== 'fail') return { ok: false, error: 'status must be pass or fail' }
@@ -42,10 +45,12 @@ export function fileHumanReading(
42
45
  // The session that filed the LATEST reading for (node, scenario) — the ORIGINATOR an eval-comment thread
43
46
  // loops in on a reply ([[mentions]] implicit loop-in). Null when the node/scenario has no reading, or the
44
47
  // latest reading is legacy (no `by`). Store-agnostic: the caller resolves this id to a live session or nobody.
45
- export function evalReadingFiler(nodeId: string, scenario: string): string | null {
46
- const root = repoRoot()
47
- const node = yatsuNodes(root).find((n) => n.id === nodeId)
48
- if (!node) return null
49
- const forScenario = readReadings(node.sidecarPath).filter((r) => r.scenario === scenario)
48
+ // `root` defaults to the trunk; the loop-in chain also passes each LIVE session's worktree here, because an
49
+ // in-flight reading (filed on an unmerged branch) is invisible to the trunk sidecar — exactly the
50
+ // review-time case where the remark must still reach its filer ([[remark-polish]] strand 2).
51
+ export function evalReadingFiler(nodeId: string, scenario: string, root: string = repoRoot()): string | null {
52
+ const res = resolveYatsuNode(yatsuNodes(root), nodeId)
53
+ if (!res.ok) return null
54
+ const forScenario = readReadings(res.node.sidecarPath).filter((r) => r.scenario === scenario)
50
55
  return forScenario.length ? forScenario[forScenario.length - 1].by ?? null : null
51
56
  }
@@ -1,11 +1,67 @@
1
- import { ancestorsOf, inAncestors, type DriftIndex } from '../../spec-cli/src/git.js'
1
+ import { git, headSha, ancestorsOf, inAncestors, type DriftIndex } from '../../spec-cli/src/git.js'
2
2
  import type { Reading } from './sidecar.js'
3
3
  import { isEvaluatorStale } from './evaluator.js'
4
- import { scenarioChangeCommits, type ScenarioIndex } from './scenariofresh.js'
4
+ import { scenarioChangeCommits, scenarioBlocksAt, type ScenarioIndex } from './scenariofresh.js'
5
5
 
6
6
  // 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
7
7
 
8
- export type StaleAxis = 'code' | 'scenario' | 'evaluator' | 'remark'
8
+ export type StaleAxis = 'code' | 'scenario' | 'evaluator' | 'remark' | 'anchor'
9
+
10
+ // @@@ off-history content fallback - ancestry can't testify for a codeSha that isn't reachable from HEAD
11
+ // (fold/rebase/squash-merge/cherry-pick all orphan the anchor), but the TREES still can: while the anchor
12
+ // commit object exists locally, `git diff <anchor> HEAD` names exactly the paths whose content differs, so
13
+ // a history rewrite that left governed content byte-identical reads FRESH instead of false-positive stale.
14
+ // The probe is fed at the call sites (like the remark track) so the decision functions stay pure over their
15
+ // inputs and the in-history fast path pays no extra git call; only when the commit object is truly gone
16
+ // (gc'd orphan) does the conservative rule remain — surfaced as the 'anchor' axis, so "anchor lost" reads
17
+ // differently from "content moved". No probe fed → the old always-conservative rule.
18
+ export type ContentProbe = {
19
+ // paths whose content differs between the anchor commit's tree and HEAD's; null = anchor object gone
20
+ changedPaths(anchorSha: string): Set<string> | null
21
+ // did THIS scenario's semantic block (description+expected) move between anchor and HEAD ([[scenariofresh]])
22
+ scenarioDiffers(anchorSha: string, yatsuPath: string, scenario: string): boolean
23
+ // codeDrift's display detail: commits in anchor..HEAD touching path (floored at 1 — the content differs)
24
+ behind(anchorSha: string, path: string): number
25
+ }
26
+
27
+ // (anchor, HEAD) name two immutable trees, so entries never invalidate; the LRU only bounds memory.
28
+ const diffMemo = new Map<string, Set<string> | null>()
29
+ const behindMemo = new Map<string, number>()
30
+ function memo<V>(m: Map<string, V>, k: string, build: () => V): V {
31
+ if (m.has(k)) { const v = m.get(k)!; m.delete(k); m.set(k, v); return v }
32
+ const v = build()
33
+ m.set(k, v)
34
+ if (m.size > 512) m.delete(m.keys().next().value!)
35
+ return v
36
+ }
37
+
38
+ export function contentProbeFor(root: string): ContentProbe {
39
+ let head: string | undefined
40
+ const headOf = () => (head ??= headSha(root))
41
+ return {
42
+ changedPaths(sha) {
43
+ return memo(diffMemo, `${root}\x1f${sha}\x1f${headOf()}`, () => {
44
+ try {
45
+ return new Set(git(['-C', root, '-c', 'core.quotePath=false', 'diff', '--name-only', '--no-renames', sha, headOf()])
46
+ .split('\n').map((s) => s.trim()).filter(Boolean))
47
+ } catch { return null } // the anchor commit object is gone — content can't testify
48
+ })
49
+ },
50
+ scenarioDiffers(sha, yatsuPath, scenario) {
51
+ const a = scenarioBlocksAt(root, sha, yatsuPath)
52
+ if (!a) return true // yatsu.md unreadable at the anchor (renamed/absent) → can't prove → stale
53
+ return a.get(scenario) !== scenarioBlocksAt(root, headOf(), yatsuPath)?.get(scenario)
54
+ },
55
+ behind(sha, path) {
56
+ return memo(behindMemo, `${root}\x1f${sha}\x1f${headOf()}\x1f${path}`, () => {
57
+ try {
58
+ const n = Number(git(['-C', root, 'rev-list', '--count', `${sha}..${headOf()}`, '--', path]).trim())
59
+ return Number.isFinite(n) && n > 0 ? n : 1
60
+ } catch { return 1 }
61
+ })
62
+ },
63
+ }
64
+ }
9
65
 
10
66
  // the REMARK axis's input ([[remark-teeth]]): the teeth read only the resolvable bit + when it was resolved,
11
67
  // not the whole remark — so freshness stays a PURE function, fed the scenario's remark track at the call
@@ -23,39 +79,51 @@ export function remarkStale(reading: { ts: string }, remarks: RemarkSignal[]): b
23
79
 
24
80
  // true iff some commit touched `path` that is NOT an ancestor of `sinceSha` — i.e. it lies in
25
81
  // `sinceSha..HEAD` by true DAG reachability, never a log-position/date compare (which under-reports on
26
- // branchy history). ONE conservative rule for an off-history `sinceSha` whether rebased away (orphan)
27
- // or on a reachable-but-unmerged branch: we can't prove freshness from HEAD's history, so it reads
28
- // stale rather than silently pass.
29
- export function changedSince(idx: DriftIndex, sinceSha: string, path: string): boolean {
82
+ // branchy history). An off-history `sinceSha` falls back to the content probe when one is fed (see
83
+ // ContentProbe above); without a probe or when the anchor object is gone freshness can't be proven
84
+ // from HEAD's history, so it reads stale rather than silently pass.
85
+ export function changedSince(idx: DriftIndex, sinceSha: string, path: string, probe?: ContentProbe): boolean {
30
86
  const anc = ancestorsOf(idx, sinceSha)
31
- if (!anc) return true
32
- return (idx.fileCommits.get(path) ?? []).some((h) => !inAncestors(idx, anc, h))
87
+ if (anc) return (idx.fileCommits.get(path) ?? []).some((h) => !inAncestors(idx, anc, h))
88
+ const diff = probe?.changedPaths(sinceSha)
89
+ return diff ? diff.has(path) : true
33
90
  }
34
91
 
35
92
  // the code axis's DISPLAY detail: which governed files drifted since a reading, and by HOW MANY commits — so
36
93
  // a stale eval can say "EvalsFeed.jsx +3" instead of a bare "code moved". Same DAG reachability as
37
94
  // changedSince (a commit touching the file that is NOT an ancestor of the reading's sha lies in sinceSha..HEAD);
38
- // an off-history sinceSha counts every touch (conservative, matching changedSince's stale-rather-than-pass rule).
95
+ // an off-history sinceSha reports through the same content fallback (only files whose content differs, counted
96
+ // by rev-list); with no probe or a gone anchor it counts every touch (conservative, matching changedSince).
39
97
  // Reporting only — it never decides freshness (staleAxes does); it explains a decision already made.
40
- export function codeDrift(idx: DriftIndex, sinceSha: string, codeFiles: string[]): { file: string; behind: number }[] {
98
+ export function codeDrift(idx: DriftIndex, sinceSha: string, codeFiles: string[], probe?: ContentProbe): { file: string; behind: number }[] {
41
99
  const anc = ancestorsOf(idx, sinceSha)
100
+ const diff = anc ? undefined : probe?.changedPaths(sinceSha)
42
101
  const out: { file: string; behind: number }[] = []
43
102
  for (const f of codeFiles) {
44
103
  const commits = idx.fileCommits.get(f) ?? []
45
- const behind = anc ? commits.filter((h) => !inAncestors(idx, anc, h)).length : commits.length
104
+ const behind = anc ? commits.filter((h) => !inAncestors(idx, anc, h)).length
105
+ : diff ? (diff.has(f) ? probe!.behind(sinceSha, f) : 0)
106
+ : commits.length
46
107
  if (behind > 0) out.push({ file: f, behind })
47
108
  }
48
109
  return out
49
110
  }
50
111
 
51
- // scenario freshness is PER-SCENARIO, not per-file: a reading stales only when ITS OWN scenario block moved
52
- // (edited/added/removed) in scenarioSha..HEAD, never when a sibling in the same yatsu.md did. Reads exactly
53
- // like the code axis's changedSince the per-scenario change-commits ([[scenariofresh]], rename-followed so a
54
- // bare git-mv reparent isn't a change) tested for ancestry off-history codeSha → conservatively stale.
55
- function scenarioMoved(scIdx: ScenarioIndex, didx: DriftIndex, sinceSha: string, yatsuPath: string, scenario: string): boolean {
112
+ // scenario freshness is PER-SCENARIO and SEMANTIC, not per-file: a reading stales only when ITS OWN
113
+ // scenario's semantic block (description+expected [[scenariofresh]]'s blockContent projection) moved in
114
+ // scenarioSha..HEAD never when a sibling in the same yatsu.md did, and never on a metadata-only edit
115
+ // (tags/test/code/related). Reads exactly like the code axis's changedSincethe per-scenario
116
+ // change-commits ([[scenariofresh]], rename-followed so a bare git-mv reparent isn't a change) tested for
117
+ // ancestry — and an off-history codeSha takes the same content fallback at the same granularity and the
118
+ // same projection: an unchanged yatsu.md clears it outright, a changed one stales only if THIS scenario's
119
+ // semantic block differs between the anchor and HEAD.
120
+ function scenarioMoved(scIdx: ScenarioIndex, didx: DriftIndex, sinceSha: string, yatsuPath: string, scenario: string, probe?: ContentProbe): boolean {
56
121
  const anc = ancestorsOf(didx, sinceSha)
57
- if (!anc) return true
58
- return scenarioChangeCommits(scIdx, yatsuPath, scenario).some((h) => !inAncestors(didx, anc, h))
122
+ if (anc) return scenarioChangeCommits(scIdx, yatsuPath, scenario).some((h) => !inAncestors(didx, anc, h))
123
+ const diff = probe?.changedPaths(sinceSha)
124
+ if (!diff) return true
125
+ if (!diff.has(yatsuPath)) return false // whole file byte-identical → this block too
126
+ return probe!.scenarioDiffers(sinceSha, yatsuPath, scenario)
59
127
  }
60
128
 
61
129
  export function staleAxes(
@@ -65,10 +133,16 @@ export function staleAxes(
65
133
  didx: DriftIndex,
66
134
  scIdx: ScenarioIndex,
67
135
  remarks: RemarkSignal[] = [],
136
+ probe?: ContentProbe,
68
137
  ): StaleAxis[] {
69
138
  const axes: StaleAxis[] = []
70
- if (codeFiles.some((f) => changedSince(didx, reading.codeSha, f))) axes.push('code')
71
- if (scenarioMoved(scIdx, didx, reading.codeSha, yatsuPath, reading.scenario)) axes.push('scenario')
139
+ if (probe && !ancestorsOf(didx, reading.codeSha) && probe.changedPaths(reading.codeSha) === null) {
140
+ // the anchor commit object is GONE — neither git axis can testify; say that, not "content changed"
141
+ axes.push('anchor')
142
+ } else {
143
+ if (codeFiles.some((f) => changedSince(didx, reading.codeSha, f, probe))) axes.push('code')
144
+ if (scenarioMoved(scIdx, didx, reading.codeSha, yatsuPath, reading.scenario, probe)) axes.push('scenario')
145
+ }
72
146
  if (isEvaluatorStale(reading.evaluator)) axes.push('evaluator')
73
147
  if (remarkStale(reading, remarks)) axes.push('remark')
74
148
  return axes
@@ -81,6 +155,7 @@ export function isStale(
81
155
  didx: DriftIndex,
82
156
  scIdx: ScenarioIndex,
83
157
  remarks: RemarkSignal[] = [],
158
+ probe?: ContentProbe,
84
159
  ): boolean {
85
- return staleAxes(reading, codeFiles, yatsuPath, didx, scIdx, remarks).length > 0
160
+ return staleAxes(reading, codeFiles, yatsuPath, didx, scIdx, remarks, probe).length > 0
86
161
  }
@@ -3,7 +3,7 @@ import { git, gitA, repoRoot, driftIndex, historyIndex, type ReviewDiffFile } fr
3
3
  import { loadSpecs } from '../../spec-cli/src/specs.js'
4
4
  import { mainBranch } from '../../spec-cli/src/layout.js'
5
5
  import { reviewPayload } from '../../spec-cli/src/sessions.js'
6
- import { evalTimeline, evalContext, readBlobByHash, type EvalEntry } from './evaltab.js'
6
+ import { evalTimeline, evalContext, readBlobByHash, type EvalEntry, type EvalTimeline } from './evaltab.js'
7
7
  import { isUiPath } from './cli.js'
8
8
 
9
9
  // ---- the model ----
@@ -112,7 +112,7 @@ export async function buildProofModel(id: string): Promise<ProofModel | null> {
112
112
  for (const [nid, files] of byNode) {
113
113
  const spec = specById.get(nid)
114
114
  const tl = await evalTimeline(nid, ctx)
115
- const latest = latestPerScenario(tl.readings)
115
+ const latest = declaredLatest(tl)
116
116
  const readings = await Promise.all(latest.map(toProofReading))
117
117
  for (const r of latest) {
118
118
  total++
@@ -220,6 +220,17 @@ function latestPerScenario(readings: EvalEntry[]): EvalEntry[] {
220
220
  return out
221
221
  }
222
222
 
223
+ // the DECLARED scenarios' latest reading — the SAME declared-bounded computation every other eval face reads
224
+ // (score.jsx's scenarioStates for the node badge and the eval tab). A reading whose scenario is no longer in
225
+ // yatsu.md is residual: the append-only sidecar still carries it, but it is not current loss, so it must not
226
+ // become a proof reading card, a passed/total tick, or a node score. The proof was the one face driven by the
227
+ // readings that happen to exist rather than the scenarios that are declared — this bounds it like the rest, so
228
+ // a retired scenario's stale reading can't make the proof disagree with the dashboard (phantom card, off ribbon).
229
+ export function declaredLatest(tl: EvalTimeline): EvalEntry[] {
230
+ const declared = new Set(tl.scenarios.map((s) => s.name))
231
+ return latestPerScenario(tl.readings).filter((r) => declared.has(r.scenario))
232
+ }
233
+
223
234
  // ---- scoring (mirrors the dashboard's score.jsx vocabulary, on the EvalEntry shape) ----
224
235
 
225
236
  const verdictMark = (r: { verdict?: EvalEntry['verdict'] }) =>
@@ -234,7 +245,7 @@ function readingScore(r: EvalEntry): ScoreState {
234
245
 
235
246
  // worst-first aggregate over the latest reading per scenario: any fresh fail → fail; else any stale → grey
236
247
  // (✗ if any stale last-failed, else ✓); else any unscored scenario → empty; else every scenario fresh-passes.
237
- function nodeScore(hasYatsu: boolean, latest: EvalEntry[]): ScoreState {
248
+ export function nodeScore(hasYatsu: boolean, latest: EvalEntry[]): ScoreState {
238
249
  if (!hasYatsu) return null
239
250
  if (!latest.length) return 'empty'
240
251
  if (latest.some((r) => r.fresh && verdictMark(r) === 'cross')) return 'fail'
@@ -1,27 +1,35 @@
1
1
  import { spawn } from 'node:child_process'
2
- import { gitA, headSha } from '../../spec-cli/src/git.js'
2
+ import { git, gitA, headSha } from '../../spec-cli/src/git.js'
3
3
  import { parseScenarios } from './yatsu.js'
4
4
 
5
5
  // @@@ per-scenario content freshness — the SCENARIO axis, sub-file
6
6
  // A yatsu.md holds many scenarios, but a reading measures ONE. yatsu-core's contract says "a scenario is the
7
7
  // unit of measurement, so its freshness is its OWN — two scenarios stale independently." Git has no sub-file
8
- // history, so we build it: for each scenario NAME in a yatsu.md, the commits where THAT scenario's block
9
- // content changed (added / removed / edited), rename-followed. `scenarioMoved` then reads exactly like the
10
- // code axis's `changedSince` — a pure ancestry lookup over this per-scenario commit list — so editing one
11
- // scenario never re-stales its siblings (the file-granular bug this replaces).
8
+ // history, so we build it: for each scenario NAME in a yatsu.md, the commits where THAT scenario's SEMANTIC
9
+ // block content (description + expected — see blockContent) changed (added / removed / edited),
10
+ // rename-followed. `scenarioMoved` then reads exactly like the code axis's `changedSince` — a pure ancestry
11
+ // lookup over this per-scenario commit list — so editing one scenario never re-stales its siblings (the
12
+ // file-granular bug this replaces), and retagging one never re-stales itself.
12
13
 
13
14
  const RS = '\x1e'
14
15
 
15
16
  // yatsuPath (head path) -> scenario name -> commit hashes that changed that scenario's block (newest-first)
16
17
  export type ScenarioIndex = Map<string, Map<string, string[]>>
17
18
 
18
- // the block content that stales a reading: everything a measurement is taken AGAINST, minus the name (the
19
- // join key a renamed scenario is a remove+add, surfaced as a change-commit on each name). parseScenarios
20
- // already folds YAML block scalars, so a pure prose re-wrap yields the same string and does NOT stale.
19
+ // the block content that stales a reading: the scenario's SEMANTIC fields only description (what to
20
+ // measure) + expected (what zero loss looks like), the contract a measurement is taken AGAINST. The name is
21
+ // the join key (a renamed scenario is a remove+add, surfaced as a change-commit on each name), and the
22
+ // metadata fields are OUT: tags route a scenario to a measuring hand, test/code/related point at files —
23
+ // none of them changes what an already-taken reading proved, so a tags sweep or a coverage retune must not
24
+ // re-stale every reading in the tree. Both freshness paths (the in-history change-commits and the
25
+ // off-history scenarioDiffers probe) read THIS one projection — narrowing it here narrows both at once, and
26
+ // because freshness is derived live from git, historical metadata-only commits stop registering as scenario
27
+ // changes with no migration. parseScenarios already folds YAML block scalars, so a pure prose re-wrap
28
+ // yields the same string and does NOT stale.
21
29
  function blockContent(src: string): Map<string, string> {
22
30
  const m = new Map<string, string>()
23
31
  for (const s of parseScenarios(src)) {
24
- m.set(s.name, JSON.stringify({ d: s.description, e: s.expected, t: s.tags ?? [], c: s.code ?? [], r: s.related ?? [], x: s.test ?? '' }))
32
+ m.set(s.name, JSON.stringify({ d: s.description, e: s.expected }))
25
33
  }
26
34
  return m
27
35
  }
@@ -100,11 +108,14 @@ function catFileBatch(root: string, oids: string[]): Promise<Map<string, string>
100
108
  return new Promise((resolve, reject) => {
101
109
  const env = { ...process.env }
102
110
  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 })
111
+ const p = spawn('git', ['-C', root, 'cat-file', '--batch'], { env, timeout: Number(process.env.SPEXCODE_GIT_TIMEOUT_MS || 120000), killSignal: 'SIGKILL' })
104
112
  const chunks: Buffer[] = []
105
113
  p.stdout.on('data', (c: Buffer) => chunks.push(c))
106
114
  p.on('error', reject)
107
- p.on('close', () => {
115
+ p.on('close', (_code, signal) => {
116
+ // a child that never exited was SIGKILLed at the timeout (a hung git must not pin this promise —
117
+ // same bound as git.ts's helpers); warn loudly and parse whatever arrived.
118
+ if (signal === 'SIGKILL') console.warn(`spec-yatsu: git cat-file --batch killed after timeout — child never exited`)
108
119
  const buf = Buffer.concat(chunks)
109
120
  let i = 0
110
121
  while (i < buf.length) {
@@ -160,3 +171,19 @@ export function scenarioIndex(root: string, yatsuPaths: string[]): Promise<Scena
160
171
  export function scenarioChangeCommits(idx: ScenarioIndex, yatsuPath: string, scenario: string): string[] {
161
172
  return idx.get(yatsuPath)?.get(scenario) ?? []
162
173
  }
174
+
175
+ // canonical per-scenario SEMANTIC blocks of `rev:path` (the blockContent projection), for the off-history
176
+ // content fallback ([[yatsu-core]]'s ContentProbe): resolve the blob oid first — oids are content-addressed,
177
+ // so an unchanged file usually hits blockByOid straight from the index build — and parse only on a genuine
178
+ // miss. null = the path is unreadable at that rev (absent, renamed since, or the rev itself is gone).
179
+ export function scenarioBlocksAt(root: string, rev: string, path: string): Map<string, string> | null {
180
+ let oid: string
181
+ try { oid = git(['-C', root, 'rev-parse', `${rev}:${path}`]).trim() } catch { return null }
182
+ const hit = blockByOid.get(oid)
183
+ if (hit) return hit
184
+ try {
185
+ const m = blockContent(git(['-C', root, 'cat-file', 'blob', oid]))
186
+ blockByOid.set(oid, m)
187
+ return m
188
+ } catch { return null }
189
+ }
@@ -1,6 +1,7 @@
1
1
  import { readFileSync, readdirSync, existsSync } from 'node:fs'
2
2
  import { readFile, readdir } from 'node:fs/promises'
3
3
  import { join, relative, basename } from 'node:path'
4
+ import { mintIds } from '../../spec-cli/src/specs.js'
4
5
 
5
6
  export const YATSU_FILE = 'yatsu.md'
6
7
  export const SIDECAR_FILE = 'yatsu.evals.ndjson'
@@ -16,7 +17,7 @@ export type Scenario = {
16
17
  }
17
18
 
18
19
  export type YatsuNode = {
19
- id: string // the node's leaf dir name (its spec-node id)
20
+ id: string // the node's CANONICAL spec id (leaf name, '_'-disambiguated on a leaf collision)
20
21
  dir: string // absolute node directory
21
22
  yatsuPath: string // repo-relative path to yatsu.md — the SCENARIO freshness axis
22
23
  sidecarPath: string // absolute path to yatsu.evals.ndjson
@@ -173,53 +174,76 @@ export function validateScenarios(src: string, tagLibrary: string[] = []): strin
173
174
  return errs
174
175
  }
175
176
 
176
- // walk `.spec` for every dir holding a yatsu.md; a node's id is its leaf dir name (the same its spec.md carries)
177
+ // walk `.spec` for every dir holding a yatsu.md; the node id is its CANONICAL spec id ([[id-url-safe]])
178
+ // minted by the SAME rule as specs.ts's loader (mintIds: the leaf dir name, or on a leaf collision the
179
+ // shortest globally-unique '_'-joined trailing suffix) over the SAME universe (every dir holding a spec.md,
180
+ // not just the yatsu subset — a leaf that collides among spec nodes is disambiguated even when only one of
181
+ // them measures). So the id yatsu answers to is exactly the id board/scan/search already print — never a
182
+ // second, diverging bare-leaf scheme. A yatsu.md beside no spec.md keeps its leaf name (no spec id to align with).
183
+ function assembleNodes(root: string, specDirs: string[], hits: { dir: string; src: string }[]): YatsuNode[] {
184
+ const specBase = join(root, '.spec')
185
+ const ids = mintIds(specDirs.map((d) => relative(specBase, d).split(/[/\\]/)))
186
+ const idByDir = new Map(specDirs.map((d, i) => [d, ids[i]]))
187
+ return hits
188
+ .map(({ dir, src }) => ({
189
+ id: idByDir.get(dir) ?? basename(dir),
190
+ dir,
191
+ yatsuPath: relative(root, join(dir, YATSU_FILE)),
192
+ sidecarPath: join(dir, SIDECAR_FILE),
193
+ scenarios: parseScenarios(src),
194
+ }))
195
+ .sort((a, b) => a.id.localeCompare(b.id))
196
+ }
197
+
177
198
  export function yatsuNodes(root: string): YatsuNode[] {
178
199
  const specDir = join(root, '.spec')
179
- const out: YatsuNode[] = []
200
+ const specDirs: string[] = []
201
+ const hits: { dir: string; src: string }[] = []
180
202
  const stack = existsSync(specDir) ? [specDir] : []
181
203
  while (stack.length) {
182
204
  const dir = stack.pop()!
183
205
  let ents
184
206
  try { ents = readdirSync(dir, { withFileTypes: true }) } catch { continue }
185
- if (existsSync(join(dir, YATSU_FILE))) {
186
- const yatsuPath = relative(root, join(dir, YATSU_FILE))
187
- out.push({
188
- id: basename(dir),
189
- dir,
190
- yatsuPath,
191
- sidecarPath: join(dir, SIDECAR_FILE),
192
- scenarios: parseScenarios(readFileSync(join(dir, YATSU_FILE), 'utf8')),
193
- })
194
- }
207
+ if (existsSync(join(dir, 'spec.md'))) specDirs.push(dir)
208
+ if (existsSync(join(dir, YATSU_FILE))) hits.push({ dir, src: readFileSync(join(dir, YATSU_FILE), 'utf8') })
195
209
  for (const e of ents) if (e.isDirectory()) stack.push(join(dir, e.name))
196
210
  }
197
- return out.sort((a, b) => a.id.localeCompare(b.id))
211
+ return assembleNodes(root, specDirs, hits)
198
212
  }
199
213
 
200
214
  // async twin of yatsuNodes for the HOT board build ([[board-cache]]): reading each yatsu.md through
201
215
  // fs/promises YIELDS the event loop between files, so the walk no longer stalls a `/health` probe in one
202
- // ~600ms uninterrupted stretch. Same output (id-sorted) as yatsuNodes; only buildBoard uses it, other
203
- // callers keep the sync form.
216
+ // ~600ms uninterrupted stretch. Same output (canonical ids, id-sorted) as yatsuNodes; only buildBoard uses
217
+ // it, other callers keep the sync form.
204
218
  export async function yatsuNodesAsync(root: string): Promise<YatsuNode[]> {
205
219
  const specDir = join(root, '.spec')
206
- const out: YatsuNode[] = []
220
+ const specDirs: string[] = []
221
+ const hits: { dir: string; src: string }[] = []
207
222
  const stack = existsSync(specDir) ? [specDir] : []
208
223
  while (stack.length) {
209
224
  const dir = stack.pop()!
210
225
  let ents
211
226
  try { ents = await readdir(dir, { withFileTypes: true }) } catch { continue }
212
- if (existsSync(join(dir, YATSU_FILE))) {
213
- const yatsuPath = relative(root, join(dir, YATSU_FILE))
214
- out.push({
215
- id: basename(dir),
216
- dir,
217
- yatsuPath,
218
- sidecarPath: join(dir, SIDECAR_FILE),
219
- scenarios: parseScenarios(await readFile(join(dir, YATSU_FILE), 'utf8')),
220
- })
221
- }
227
+ if (existsSync(join(dir, 'spec.md'))) specDirs.push(dir)
228
+ if (existsSync(join(dir, YATSU_FILE))) hits.push({ dir, src: await readFile(join(dir, YATSU_FILE), 'utf8') })
222
229
  for (const e of ents) if (e.isDirectory()) stack.push(join(dir, e.name))
223
230
  }
224
- return out.sort((a, b) => a.id.localeCompare(b.id))
231
+ return assembleNodes(root, specDirs, hits)
232
+ }
233
+
234
+ export type YatsuResolution<T> = { ok: true; node: T } | { ok: false; ambiguous: boolean; error: string }
235
+
236
+ // resolve a user-supplied node ref against the yatsu set: an EXACT canonical id always wins; a bare leaf
237
+ // name stays the convenience it always was while it names exactly ONE yatsu node; a leaf several nodes
238
+ // share fails LOUD listing the candidate canonical ids — never an arbitrary first hit, so a reading can
239
+ // only land on the node the caller actually named.
240
+ export function resolveYatsuNode<T extends Pick<YatsuNode, 'id' | 'dir'>>(nodes: T[], ref: string): YatsuResolution<T> {
241
+ const exact = nodes.find((n) => n.id === ref)
242
+ if (exact) return { ok: true, node: exact }
243
+ const byLeaf = nodes.filter((n) => basename(n.dir) === ref)
244
+ if (byLeaf.length === 1) return { ok: true, node: byLeaf[0] }
245
+ if (byLeaf.length > 1) {
246
+ return { ok: false, ambiguous: true, error: `'${ref}' is ambiguous — ${byLeaf.length} yatsu nodes share that leaf name; use a canonical id: ${byLeaf.map((n) => n.id).sort().join(', ')}` }
247
+ }
248
+ return { ok: false, ambiguous: false, error: `no yatsu node '${ref}' (a node needs a yatsu.md)` }
225
249
  }