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.
- package/README.md +2 -2
- package/package.json +1 -1
- package/spec-cli/bin/spex.mjs +13 -10
- package/spec-cli/hooks/harness.sh +32 -6
- package/spec-cli/src/board.ts +2 -1
- package/spec-cli/src/boardCache.ts +27 -1
- package/spec-cli/src/boardStream.ts +5 -4
- package/spec-cli/src/cli.ts +82 -19
- package/spec-cli/src/contract-filter.ts +154 -0
- package/spec-cli/src/git.ts +18 -4
- package/spec-cli/src/guide.ts +91 -38
- package/spec-cli/src/harness.ts +18 -4
- package/spec-cli/src/help.ts +16 -7
- package/spec-cli/src/index.ts +9 -6
- package/spec-cli/src/init.ts +52 -10
- package/spec-cli/src/issues.ts +7 -5
- package/spec-cli/src/layout.ts +20 -8
- package/spec-cli/src/lint.ts +19 -0
- package/spec-cli/src/localIssues.ts +16 -4
- package/spec-cli/src/materialize.ts +218 -124
- package/spec-cli/src/ranker.ts +25 -8
- package/spec-cli/src/runtime-guard.ts +44 -0
- package/spec-cli/src/search.bench.mjs +15 -5
- package/spec-cli/src/sessions.ts +81 -12
- package/spec-cli/src/specs.ts +29 -14
- package/spec-cli/src/supervise.ts +2 -2
- package/spec-cli/src/tsx-bin.ts +6 -8
- package/spec-cli/src/uninstall.ts +15 -17
- package/spec-cli/src/worktree-sources.ts +56 -0
- package/spec-cli/templates/spec/project/.config/core/stop-gate/spec.md +1 -1
- package/spec-cli/templates/spec/project/.config/core/stop-gate/stop-gate.sh +26 -8
- package/spec-dashboard/dist/assets/Dashboard-CMRJGfYI.js +27 -0
- package/spec-dashboard/dist/assets/EvalsPage-Dj2mxcfW.js +2 -0
- package/spec-dashboard/dist/assets/{FoldToggle-B5leylLf.js → FoldToggle-BfNpeyRQ.js} +1 -1
- package/spec-dashboard/dist/assets/IssuesPage-DfY315kt.js +1 -0
- package/spec-dashboard/dist/assets/{MobileApp-RHNECU6x.js → MobileApp-BGdC0A0P.js} +1 -1
- package/spec-dashboard/dist/assets/{SessionInterface-YLD6IOmC.js → SessionInterface-BOBCAR0t.js} +5 -5
- package/spec-dashboard/dist/assets/SessionWindow-D7YmjV0i.js +9 -0
- package/spec-dashboard/dist/assets/{Settings-ZnOwskMZ.js → Settings-BlSNmpH_.js} +1 -1
- package/spec-dashboard/dist/assets/{index-BdRQfrkR.js → index-BFdzpd_O.js} +2 -2
- package/spec-dashboard/dist/assets/index-uGs9v_9o.css +1 -0
- package/spec-dashboard/dist/index.html +2 -2
- package/spec-forge/src/cli.ts +2 -2
- package/spec-forge/src/drivers/gitlab.ts +168 -0
- package/spec-forge/src/drivers.ts +80 -2
- package/spec-forge/src/resident.ts +10 -5
- package/spec-yatsu/src/cli.ts +37 -16
- package/spec-yatsu/src/evaltab.ts +6 -3
- package/spec-yatsu/src/filing.ts +13 -8
- package/spec-yatsu/src/freshness.ts +97 -22
- package/spec-yatsu/src/proof.ts +14 -3
- package/spec-yatsu/src/scenariofresh.ts +38 -11
- package/spec-yatsu/src/yatsu.ts +52 -28
- package/spec-dashboard/dist/assets/Dashboard-Dlg78cbC.js +0 -27
- package/spec-dashboard/dist/assets/EvalsPage-CDxc1-in.js +0 -3
- package/spec-dashboard/dist/assets/IssuesPage-C2yFXiO-.js +0 -1
- package/spec-dashboard/dist/assets/SessionWindow-CmKtpNUX.js +0 -9
- 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' }))
|
package/spec-yatsu/src/filing.ts
CHANGED
|
@@ -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
|
-
|
|
19
|
-
|
|
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
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
const
|
|
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).
|
|
27
|
-
//
|
|
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 (
|
|
32
|
-
|
|
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
|
|
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
|
|
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
|
|
52
|
-
//
|
|
53
|
-
//
|
|
54
|
-
//
|
|
55
|
-
|
|
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 changedSince — the 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 (
|
|
58
|
-
|
|
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 (
|
|
71
|
-
|
|
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
|
}
|
package/spec-yatsu/src/proof.ts
CHANGED
|
@@ -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 =
|
|
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
|
|
9
|
-
// content changed (added / removed / edited),
|
|
10
|
-
// code axis's `changedSince` — a pure ancestry
|
|
11
|
-
// scenario never re-stales its siblings (the
|
|
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:
|
|
19
|
-
//
|
|
20
|
-
//
|
|
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
|
|
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
|
+
}
|
package/spec-yatsu/src/yatsu.ts
CHANGED
|
@@ -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
|
|
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;
|
|
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
|
|
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,
|
|
186
|
-
|
|
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
|
|
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
|
|
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
|
|
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,
|
|
213
|
-
|
|
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
|
|
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
|
}
|