spexcode 0.1.0 → 0.1.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.
- package/README.md +86 -0
- package/package.json +17 -10
- package/{bin → spec-cli/bin}/spex.mjs +6 -2
- package/spec-cli/hooks/dispatch.sh +74 -0
- package/spec-cli/hooks/harness.sh +178 -0
- package/{src → spec-cli/src}/cli.ts +14 -0
- package/{src → spec-cli/src}/harness.ts +8 -18
- package/{src → spec-cli/src}/layout.ts +6 -23
- package/{src → spec-cli/src}/materialize.ts +2 -1
- package/spec-cli/src/relay.ts +28 -0
- package/{src → spec-cli/src}/supervise.ts +2 -1
- package/spec-cli/src/tsx-bin.ts +11 -0
- package/{dashboard-dist/assets/index-B60MILFg.js → spec-dashboard/dist/assets/index-Bk4E1EQy.js} +29 -29
- package/{dashboard-dist → spec-dashboard/dist}/index.html +1 -1
- package/spec-forge/src/__fixtures__/github-forge.json +63 -0
- package/spec-forge/src/cache.ts +28 -0
- package/spec-forge/src/cli.ts +110 -0
- package/spec-forge/src/drivers/github.ts +73 -0
- package/spec-forge/src/links.ts +76 -0
- package/spec-forge/src/needs-yatsu-eval.ts +30 -0
- package/spec-forge/src/port.ts +23 -0
- package/spec-forge/src/resident.ts +23 -0
- package/spec-yatsu/src/cache.ts +68 -0
- package/spec-yatsu/src/cli.ts +296 -0
- package/spec-yatsu/src/evaltab.ts +112 -0
- package/spec-yatsu/src/evaluator.ts +24 -0
- package/spec-yatsu/src/freshness.ts +51 -0
- package/spec-yatsu/src/proof.ts +491 -0
- package/spec-yatsu/src/sidecar.ts +44 -0
- package/spec-yatsu/src/yatsu.ts +181 -0
- /package/{src → spec-cli/src}/board.ts +0 -0
- /package/{src → spec-cli/src}/client.ts +0 -0
- /package/{src → spec-cli/src}/gateway.ts +0 -0
- /package/{src → spec-cli/src}/git.ts +0 -0
- /package/{src → spec-cli/src}/guide.ts +0 -0
- /package/{src → spec-cli/src}/hooks.ts +0 -0
- /package/{src → spec-cli/src}/index.ts +0 -0
- /package/{src → spec-cli/src}/init.ts +0 -0
- /package/{src → spec-cli/src}/lint.ts +0 -0
- /package/{src → spec-cli/src}/login-page.ts +0 -0
- /package/{src → spec-cli/src}/pty-bridge.ts +0 -0
- /package/{src → spec-cli/src}/ranker.ts +0 -0
- /package/{src → spec-cli/src}/resilience.ts +0 -0
- /package/{src → spec-cli/src}/search.bench.mjs +0 -0
- /package/{src → spec-cli/src}/search.ts +0 -0
- /package/{src → spec-cli/src}/self.ts +0 -0
- /package/{src → spec-cli/src}/sessions.ts +0 -0
- /package/{src → spec-cli/src}/slash-commands.ts +0 -0
- /package/{src → spec-cli/src}/specs.ts +0 -0
- /package/{src → spec-cli/src}/uploads.ts +0 -0
- /package/{templates → spec-cli/templates}/hooks/pre-commit +0 -0
- /package/{templates → spec-cli/templates}/hooks/prepare-commit-msg +0 -0
- /package/{templates → spec-cli/templates}/spec/project/.config/core/idle/idle.sh +0 -0
- /package/{templates → spec-cli/templates}/spec/project/.config/core/idle/spec.md +0 -0
- /package/{templates → spec-cli/templates}/spec/project/.config/core/mark-active/mark-active.sh +0 -0
- /package/{templates → spec-cli/templates}/spec/project/.config/core/mark-active/spec.md +0 -0
- /package/{templates → spec-cli/templates}/spec/project/.config/core/session-fail/fail.sh +0 -0
- /package/{templates → spec-cli/templates}/spec/project/.config/core/session-fail/spec.md +0 -0
- /package/{templates → spec-cli/templates}/spec/project/.config/core/spec-first/spec-first.sh +0 -0
- /package/{templates → spec-cli/templates}/spec/project/.config/core/spec-first/spec.md +0 -0
- /package/{templates → spec-cli/templates}/spec/project/.config/core/spec-of-file/spec-of-file.sh +0 -0
- /package/{templates → spec-cli/templates}/spec/project/.config/core/spec-of-file/spec.md +0 -0
- /package/{templates → spec-cli/templates}/spec/project/.config/core/spec.md +0 -0
- /package/{templates → spec-cli/templates}/spec/project/.config/core/stop-gate/spec.md +0 -0
- /package/{templates → spec-cli/templates}/spec/project/.config/core/stop-gate/stop-gate.sh +0 -0
- /package/{templates → spec-cli/templates}/spec/project/.config/extract/spec.md +0 -0
- /package/{templates → spec-cli/templates}/spec/project/.config/forge-link/spec.md +0 -0
- /package/{templates → spec-cli/templates}/spec/project/.config/memory-hygiene/spec.md +0 -0
- /package/{templates → spec-cli/templates}/spec/project/.config/regroup/spec.md +0 -0
- /package/{templates → spec-cli/templates}/spec/project/.config/scenario/spec.md +0 -0
- /package/{templates → spec-cli/templates}/spec/project/.config/spec.md +0 -0
- /package/{templates → spec-cli/templates}/spec/project/.config/supervisor/spec.md +0 -0
- /package/{templates → spec-cli/templates}/spec/project/.config/tidy/spec.md +0 -0
- /package/{templates → spec-cli/templates}/spec/project/spec.md +0 -0
- /package/{templates → spec-cli/templates}/spexcode.json +0 -0
- /package/{dashboard-dist → spec-dashboard/dist}/assets/index-Cq7hwngj.css +0 -0
|
@@ -0,0 +1,491 @@
|
|
|
1
|
+
import { dirname } from 'node:path'
|
|
2
|
+
import { git, gitA, repoRoot, driftIndex, historyIndex, type ReviewDiffFile } from '../../spec-cli/src/git.js'
|
|
3
|
+
import { loadSpecs } from '../../spec-cli/src/specs.js'
|
|
4
|
+
import { mainBranch } from '../../spec-cli/src/layout.js'
|
|
5
|
+
import { reviewPayload } from '../../spec-cli/src/sessions.js'
|
|
6
|
+
import { evalTimeline, evalContext, readBlobByHash, type EvalEntry } from './evaltab.js'
|
|
7
|
+
import { isUiPath } from './cli.js'
|
|
8
|
+
|
|
9
|
+
// ---- the model ----
|
|
10
|
+
|
|
11
|
+
type ScoreState = 'pass' | 'fail' | 'stalePass' | 'staleFail' | 'empty' | null
|
|
12
|
+
|
|
13
|
+
// one yatsu reading rendered for the proof: the latest measurement of one scenario, with its evidence
|
|
14
|
+
// resolved to inline bytes (an image data-URI, or transcript text) so the document is self-contained.
|
|
15
|
+
export type ProofReading = {
|
|
16
|
+
scenario: string
|
|
17
|
+
expected: string
|
|
18
|
+
verdict?: EvalEntry['verdict']
|
|
19
|
+
fresh: boolean
|
|
20
|
+
staleAxes: string[]
|
|
21
|
+
score: ScoreState
|
|
22
|
+
evaluator: string
|
|
23
|
+
ts: string
|
|
24
|
+
evidence:
|
|
25
|
+
| { kind: 'image'; dataUri: string }
|
|
26
|
+
| { kind: 'transcript'; text: string }
|
|
27
|
+
| { kind: 'miss' }
|
|
28
|
+
| { kind: 'none' }
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
// patch ''/old·new null = nothing to show (added → no old, deleted → no new), past the enrichment cap (omitted), or too large (truncated)
|
|
32
|
+
export type ProofFile = ReviewDiffFile & {
|
|
33
|
+
patch: string
|
|
34
|
+
oldText: string | null
|
|
35
|
+
newText: string | null
|
|
36
|
+
truncated: boolean
|
|
37
|
+
omitted: boolean
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
// one changed spec node: its diff slice (the files this node owns that the session touched) joined with its
|
|
41
|
+
// measured loss (latest reading per scenario). A frontend node with no yatsu.md is an honest blind spot.
|
|
42
|
+
export type ProofNode = {
|
|
43
|
+
id: string
|
|
44
|
+
title: string
|
|
45
|
+
hue: number
|
|
46
|
+
desc: string
|
|
47
|
+
files: ProofFile[]
|
|
48
|
+
additions: number
|
|
49
|
+
deletions: number
|
|
50
|
+
hasYatsu: boolean
|
|
51
|
+
uncoveredFrontend: boolean
|
|
52
|
+
score: ScoreState
|
|
53
|
+
readings: ProofReading[]
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export type ProofGate = { label: string; ok: boolean; detail: string }
|
|
57
|
+
|
|
58
|
+
export type ProofModel = {
|
|
59
|
+
id: string
|
|
60
|
+
node: string | null
|
|
61
|
+
branch: string | null
|
|
62
|
+
title: string // DERIVED headline (the node, else the branch) — no agent-authored claim
|
|
63
|
+
generatedAt: string
|
|
64
|
+
ahead: number
|
|
65
|
+
dirtyNonRuntime: number
|
|
66
|
+
gates: ProofGate[]
|
|
67
|
+
score: { passed: number; total: number; fresh: number } // the yatsu summary across all nodes
|
|
68
|
+
nodes: ProofNode[]
|
|
69
|
+
otherFiles: ProofFile[] // changed files no spec node claims
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
// null when no session has that id (route → 404).
|
|
73
|
+
export async function buildProofModel(id: string): Promise<ProofModel | null> {
|
|
74
|
+
const payload = await reviewPayload(id)
|
|
75
|
+
if (!payload) return null
|
|
76
|
+
const specs = await loadSpecs()
|
|
77
|
+
const specById = new Map(specs.map((s) => [s.id, s]))
|
|
78
|
+
// root the eval context at the SESSION's worktree (its branch's readings/freshness), not the backend checkout which would show main's; specs stay backend-shared (paths/titles/hues), only readings + drift are per-worktree
|
|
79
|
+
const wtPath = worktreePathForBranch(payload.branch)
|
|
80
|
+
const ctxRoot = wtPath ?? repoRoot()
|
|
81
|
+
const ctx = evalContext(ctxRoot, specs, await driftIndex(ctxRoot), await historyIndex(ctxRoot))
|
|
82
|
+
|
|
83
|
+
// enrich each changed file with its unified diff + full before/after content (derived from the session
|
|
84
|
+
// worktree at the merge-base ↔ HEAD), so the proof can drill summary → diff → whole-file comparison with no
|
|
85
|
+
// extra fetch. Capped at MAX_ENRICHED_FILES so a huge changeset can't bloat the page; the rest keep their
|
|
86
|
+
// row but say so (omitted), never silently blank.
|
|
87
|
+
const base = wtPath ? (await gitA(['-C', wtPath, 'merge-base', mainBranch(), 'HEAD'])).trim() : ''
|
|
88
|
+
const enriched = new Map<string, ProofFile>()
|
|
89
|
+
let budget = MAX_ENRICHED_FILES
|
|
90
|
+
for (const f of payload.diff) {
|
|
91
|
+
if (wtPath && base && budget > 0) { enriched.set(f.path, await enrichFile(wtPath, base, f)); budget-- }
|
|
92
|
+
else enriched.set(f.path, { ...f, patch: '', oldText: null, newText: null, truncated: false, omitted: !!(wtPath && base) })
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
// group the session's real changes (merge-base diff) by the spec node that owns each file.
|
|
96
|
+
const byNode = new Map<string, ProofFile[]>()
|
|
97
|
+
const otherFiles: ProofFile[] = []
|
|
98
|
+
for (const f of payload.diff) {
|
|
99
|
+
const nid = nodeForFile(f.path, specs, payload.node)
|
|
100
|
+
const pf = enriched.get(f.path)!
|
|
101
|
+
if (nid) { const arr = byNode.get(nid) ?? []; arr.push(pf); byNode.set(nid, arr) }
|
|
102
|
+
else otherFiles.push(pf)
|
|
103
|
+
}
|
|
104
|
+
// the session's primary node always appears, even if it has no file in the diff yet.
|
|
105
|
+
if (payload.node && specById.has(payload.node) && !byNode.has(payload.node)) byNode.set(payload.node, [])
|
|
106
|
+
|
|
107
|
+
const nodes: ProofNode[] = []
|
|
108
|
+
let passed = 0, total = 0, fresh = 0
|
|
109
|
+
for (const [nid, files] of byNode) {
|
|
110
|
+
const spec = specById.get(nid)
|
|
111
|
+
const tl = await evalTimeline(nid, ctx)
|
|
112
|
+
const latest = latestPerScenario(tl.readings)
|
|
113
|
+
const readings = await Promise.all(latest.map(toProofReading))
|
|
114
|
+
for (const r of latest) {
|
|
115
|
+
total++
|
|
116
|
+
if (r.fresh) fresh++
|
|
117
|
+
if (r.fresh && r.verdict?.status === 'pass') passed++
|
|
118
|
+
}
|
|
119
|
+
nodes.push({
|
|
120
|
+
id: nid,
|
|
121
|
+
title: spec?.title ?? nid,
|
|
122
|
+
hue: spec?.hue ?? 210,
|
|
123
|
+
desc: spec?.desc ?? '',
|
|
124
|
+
files,
|
|
125
|
+
additions: files.reduce((a, f) => a + f.additions, 0),
|
|
126
|
+
deletions: files.reduce((a, f) => a + f.deletions, 0),
|
|
127
|
+
hasYatsu: tl.hasYatsu,
|
|
128
|
+
uncoveredFrontend: !tl.hasYatsu && (spec?.code ?? []).some(isUiPath),
|
|
129
|
+
score: nodeScore(tl.hasYatsu, latest),
|
|
130
|
+
readings,
|
|
131
|
+
})
|
|
132
|
+
}
|
|
133
|
+
// measured nodes first, then by amount changed — the strongest evidence and the biggest change lead.
|
|
134
|
+
nodes.sort((a, b) => (b.readings.length - a.readings.length) || ((b.additions + b.deletions) - (a.additions + a.deletions)))
|
|
135
|
+
|
|
136
|
+
// the headline is DERIVED — the node the session is on, else its branch, else the id. No agent claim.
|
|
137
|
+
const primary = payload.node && specById.has(payload.node) ? specById.get(payload.node)!.title : null
|
|
138
|
+
const title = primary || payload.node || payload.branch || id.slice(0, 8)
|
|
139
|
+
|
|
140
|
+
return {
|
|
141
|
+
id,
|
|
142
|
+
node: payload.node,
|
|
143
|
+
branch: payload.branch,
|
|
144
|
+
title,
|
|
145
|
+
generatedAt: new Date().toISOString(),
|
|
146
|
+
ahead: payload.ahead,
|
|
147
|
+
dirtyNonRuntime: payload.dirtyNonRuntime,
|
|
148
|
+
gates: gateRows(payload),
|
|
149
|
+
score: { passed, total, fresh },
|
|
150
|
+
nodes,
|
|
151
|
+
otherFiles,
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
// the gate checklist, derived from the cockpit payload's gates (the SAME numbers `spex review` prints).
|
|
156
|
+
function gateRows(p: NonNullable<Awaited<ReturnType<typeof reviewPayload>>>): ProofGate[] {
|
|
157
|
+
const g = p.gates
|
|
158
|
+
return [
|
|
159
|
+
{ label: 'typecheck', ok: g.typecheck.ok, detail: g.typecheck.ok ? 'clean' : `${g.typecheck.errorCount} error(s)` },
|
|
160
|
+
{ label: 'lint', ok: g.lint.errorCount === 0, detail: `${g.lint.errorCount} error(s), ${g.lint.warningCount} warning(s)` },
|
|
161
|
+
{ label: 'merge', ok: !g.conflictsWithMain, detail: g.conflictsWithMain ? 'conflicts with main' : 'no conflict' },
|
|
162
|
+
{ label: 'ahead', ok: p.ahead > 0, detail: `${p.ahead} commit(s) ahead of main` },
|
|
163
|
+
{ label: 'committed', ok: p.dirtyNonRuntime === 0, detail: p.dirtyNonRuntime === 0 ? 'nothing uncommitted' : `${p.dirtyNonRuntime} uncommitted file(s)` },
|
|
164
|
+
]
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
// gitA returns '' for a missing path — exactly the added/deleted (and best-effort rename) side; each side capped to MAX_FILE_BYTES
|
|
168
|
+
const MAX_ENRICHED_FILES = 60
|
|
169
|
+
const MAX_FILE_BYTES = 200_000
|
|
170
|
+
async function enrichFile(wtPath: string, base: string, f: ReviewDiffFile): Promise<ProofFile> {
|
|
171
|
+
const run = (args: string[]) => gitA(['-C', wtPath, '-c', 'core.quotePath=false', ...args])
|
|
172
|
+
const [patchRaw, oldRaw, newRaw] = await Promise.all([
|
|
173
|
+
run(['diff', '-M', `${base}..HEAD`, '--', f.path]),
|
|
174
|
+
f.status === 'added' ? Promise.resolve('') : run(['show', `${base}:${f.path}`]),
|
|
175
|
+
f.status === 'deleted' ? Promise.resolve('') : run(['show', `HEAD:${f.path}`]),
|
|
176
|
+
])
|
|
177
|
+
const cap = (s: string) => (s.length > MAX_FILE_BYTES ? { text: s.slice(0, MAX_FILE_BYTES), cut: true } : { text: s, cut: false })
|
|
178
|
+
const p = cap(patchRaw), o = cap(oldRaw), nw = cap(newRaw)
|
|
179
|
+
return {
|
|
180
|
+
...f,
|
|
181
|
+
patch: p.text,
|
|
182
|
+
oldText: f.status === 'added' ? null : o.text,
|
|
183
|
+
newText: f.status === 'deleted' ? null : nw.text,
|
|
184
|
+
truncated: p.cut || o.cut || nw.cut,
|
|
185
|
+
omitted: false,
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
// resolve a reading's evidence to inline bytes so the proof is a self-contained file: an image → a base64
|
|
190
|
+
// data-URI; a transcript → its text; the cache miss / no-capture states pass through. yatsu owns the bytes
|
|
191
|
+
// (the content-addressed cache) — the proof only asks for them by hash.
|
|
192
|
+
async function toProofReading(r: EvalEntry): Promise<ProofReading> {
|
|
193
|
+
const base = {
|
|
194
|
+
scenario: r.scenario, expected: r.expected, verdict: r.verdict, fresh: r.fresh,
|
|
195
|
+
staleAxes: r.staleAxes, score: readingScore(r), evaluator: r.evaluator, ts: r.ts,
|
|
196
|
+
}
|
|
197
|
+
if (r.blobState !== 'present' || !r.blob) return { ...base, evidence: { kind: r.blobState === 'miss' ? 'miss' : 'none' } }
|
|
198
|
+
const blob = readBlobByHash(r.blob)
|
|
199
|
+
if (!blob.ok) return { ...base, evidence: { kind: 'miss' } }
|
|
200
|
+
if (blob.mime.startsWith('image/')) return { ...base, evidence: { kind: 'image', dataUri: `data:${blob.mime};base64,${blob.bytes.toString('base64')}` } }
|
|
201
|
+
return { ...base, evidence: { kind: 'transcript', text: blob.bytes.toString('utf8') } }
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
// the latest reading per scenario from a newest-first timeline (first seen wins) — the eval tab / score
|
|
205
|
+
// badge convention, so the proof shows each scenario's CURRENT loss, not its whole history.
|
|
206
|
+
function latestPerScenario(readings: EvalEntry[]): EvalEntry[] {
|
|
207
|
+
const seen = new Set<string>()
|
|
208
|
+
const out: EvalEntry[] = []
|
|
209
|
+
for (const r of readings) if (!seen.has(r.scenario)) { seen.add(r.scenario); out.push(r) }
|
|
210
|
+
return out
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
// ---- scoring (mirrors the dashboard's score.jsx vocabulary, on the EvalEntry shape) ----
|
|
214
|
+
|
|
215
|
+
const verdictMark = (r: { verdict?: EvalEntry['verdict'] }) =>
|
|
216
|
+
r.verdict?.status === 'pass' ? 'check' : r.verdict?.status === 'fail' ? 'cross' : null
|
|
217
|
+
|
|
218
|
+
function readingScore(r: EvalEntry): ScoreState {
|
|
219
|
+
const m = verdictMark(r)
|
|
220
|
+
if (!m) return 'empty'
|
|
221
|
+
if (!r.fresh) return m === 'cross' ? 'staleFail' : 'stalePass'
|
|
222
|
+
return m === 'cross' ? 'fail' : 'pass'
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
// worst-first aggregate over the latest reading per scenario: any fresh fail → fail; else any stale → grey
|
|
226
|
+
// (✗ if any stale last-failed, else ✓); else any unscored scenario → empty; else every scenario fresh-passes.
|
|
227
|
+
function nodeScore(hasYatsu: boolean, latest: EvalEntry[]): ScoreState {
|
|
228
|
+
if (!hasYatsu) return null
|
|
229
|
+
if (!latest.length) return 'empty'
|
|
230
|
+
if (latest.some((r) => r.fresh && verdictMark(r) === 'cross')) return 'fail'
|
|
231
|
+
const stale = latest.filter((r) => !r.fresh && verdictMark(r))
|
|
232
|
+
if (stale.length) return stale.some((r) => verdictMark(r) === 'cross') ? 'staleFail' : 'stalePass'
|
|
233
|
+
if (latest.some((r) => !verdictMark(r))) return 'empty'
|
|
234
|
+
return 'pass'
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
// ---- file → node mapping ----
|
|
238
|
+
|
|
239
|
+
// which spec node owns a changed file: a file inside a node's directory (its spec.md / yatsu.md / sidecar)
|
|
240
|
+
// belongs to the NEAREST such node; otherwise the node whose governed `code:` claims it (exact path,
|
|
241
|
+
// directory prefix, or `*` glob — the same matching `spex yatsu scan --changed` uses). A shared file is
|
|
242
|
+
// governed by MANY nodes (ordinary composition); when the session has a primary node that also governs it,
|
|
243
|
+
// attribute it THERE so a node/<id> session's stake in cli.ts/index.ts groups under its own node, not
|
|
244
|
+
// whichever sibling sorts first.
|
|
245
|
+
// null = unclaimed.
|
|
246
|
+
function nodeForFile(file: string, specs: Awaited<ReturnType<typeof loadSpecs>>, primary: string | null): string | null {
|
|
247
|
+
let best: string | null = null, bestLen = -1
|
|
248
|
+
for (const s of specs) {
|
|
249
|
+
const dir = dirname(s.path)
|
|
250
|
+
if ((file === dir || file.startsWith(dir + '/')) && dir.length > bestLen) { best = s.id; bestLen = dir.length }
|
|
251
|
+
}
|
|
252
|
+
if (best) return best
|
|
253
|
+
if (primary) { const ps = specs.find((s) => s.id === primary); if (ps && codeClaims(ps.code, file)) return primary }
|
|
254
|
+
for (const s of specs) if (codeClaims(s.code, file)) return s.id
|
|
255
|
+
return null
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
function codeClaims(code: string[], file: string): boolean {
|
|
259
|
+
return code.some((cf) => {
|
|
260
|
+
if (cf === file) return true
|
|
261
|
+
const dir = cf.replace(/\/+$/, '') + '/'
|
|
262
|
+
if (file.startsWith(dir)) return true
|
|
263
|
+
if (cf.includes('*')) return new RegExp('^' + cf.replace(/[.+?^${}()|[\]\\]/g, '\\$&').replace(/\*/g, '.*') + '$').test(file)
|
|
264
|
+
return false
|
|
265
|
+
})
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
// ---- worktree resolution (no sessions.ts edit: read git's own worktree list) ----
|
|
269
|
+
|
|
270
|
+
// the worktree path whose checked-out branch matches — so the eval context is rooted at the SESSION's
|
|
271
|
+
// worktree (its readings + git freshness), not the backend's checkout. `git worktree list --porcelain` emits
|
|
272
|
+
// `worktree <path>` then `branch refs/heads/<b>`.
|
|
273
|
+
function worktreePathForBranch(branch: string | null): string | null {
|
|
274
|
+
if (!branch) return null
|
|
275
|
+
let out = ''
|
|
276
|
+
try { out = git(['worktree', 'list', '--porcelain']) } catch { return null }
|
|
277
|
+
let curPath: string | null = null
|
|
278
|
+
for (const line of out.split('\n')) {
|
|
279
|
+
if (line.startsWith('worktree ')) curPath = line.slice('worktree '.length)
|
|
280
|
+
else if (line.startsWith('branch ') && line.slice('branch '.length) === `refs/heads/${branch}`) return curPath
|
|
281
|
+
}
|
|
282
|
+
return null
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
// ---- the renderer ----
|
|
286
|
+
|
|
287
|
+
// escape interpolated text for HTML (the proof inlines derived data — file paths, scenarios, expected — so
|
|
288
|
+
// every value is escaped; there is no agent-authored markdown to render).
|
|
289
|
+
function esc(s: string): string {
|
|
290
|
+
return s.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"')
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
const SCORE_GLYPH: Record<string, string> = { pass: '✓', fail: '✗', stalePass: '✓', staleFail: '✗', empty: '' }
|
|
294
|
+
function scoreBadge(state: ScoreState, title?: string): string {
|
|
295
|
+
if (!state) return ''
|
|
296
|
+
return `<span class="score ${state}" title="${esc(title ?? state)}">${SCORE_GLYPH[state] ?? ''}</span>`
|
|
297
|
+
}
|
|
298
|
+
function verdictBadge(v: EvalEntry['verdict']): string {
|
|
299
|
+
if (!v) return `<span class="verdict legacy">legacy</span>`
|
|
300
|
+
if (v.status === 'pass') return `<span class="verdict pass">✓ pass</span>`
|
|
301
|
+
if (v.status === 'fail') return `<span class="verdict fail">✗ fail</span>`
|
|
302
|
+
return `<span class="verdict note" title="${esc(v.note ?? '')}">≈ note</span>`
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
function renderReading(r: ProofReading): string {
|
|
306
|
+
const ev = r.evidence
|
|
307
|
+
const body =
|
|
308
|
+
ev.kind === 'image' ? `<img class="shot" src="${ev.dataUri}" alt="${esc(r.scenario)}">`
|
|
309
|
+
: ev.kind === 'transcript' ? `<pre class="transcript">${esc(ev.text)}</pre>`
|
|
310
|
+
: ev.kind === 'miss' ? `<div class="noev">⌀ miss original file — the evidence bytes were pruned</div>`
|
|
311
|
+
: `<div class="noev">attested without a capture</div>`
|
|
312
|
+
const stale = r.fresh ? '' : `<span class="stale" title="${esc(r.staleAxes.join(', '))} changed since the reading">stale</span>`
|
|
313
|
+
const note = r.verdict?.status === 'note' && r.verdict.note ? `<div class="rnote"><b>note</b> ${esc(r.verdict.note)}</div>` : ''
|
|
314
|
+
return `<div class="reading">
|
|
315
|
+
<div class="rhead">
|
|
316
|
+
${scoreBadge(r.score, r.fresh ? undefined : `stale: ${r.staleAxes.join(', ')}`)}
|
|
317
|
+
<span class="scenario">${esc(r.scenario)}</span>
|
|
318
|
+
${verdictBadge(r.verdict)}
|
|
319
|
+
${stale}
|
|
320
|
+
<span class="rmeta">${esc(r.evaluator)} · ${esc(r.ts)}</span>
|
|
321
|
+
</div>
|
|
322
|
+
${r.expected ? `<div class="expected"><b>expected</b> ${esc(r.expected)}</div>` : ''}
|
|
323
|
+
${note}
|
|
324
|
+
<figure class="evidence">${body}</figure>
|
|
325
|
+
</div>`
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
// each diff line is its own block so a long diff scrolls inside its box
|
|
329
|
+
function renderPatch(patch: string): string {
|
|
330
|
+
return patch.split('\n').map((ln) => {
|
|
331
|
+
const cls = /^@@/.test(ln) ? 'h'
|
|
332
|
+
: /^(diff |index |--- |\+\+\+ |rename |similarity |new file|deleted file|old mode|new mode|Binary )/.test(ln) ? 'm'
|
|
333
|
+
: ln[0] === '+' ? 'a' : ln[0] === '-' ? 'd' : ''
|
|
334
|
+
return `<span class="dl ${cls}">${esc(ln) || ' '}</span>`
|
|
335
|
+
}).join('')
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
function renderFile(f: ProofFile): string {
|
|
339
|
+
const row = `<span class="fstatus ${esc(f.status)}">${esc(f.status)}</span> <span class="fpath">${esc(f.path)}</span> <span class="fnum"><span class="add">+${f.additions}</span> <span class="del">−${f.deletions}</span></span>`
|
|
340
|
+
if (f.omitted) return `<div class="file flat"><div class="frow">${row}<span class="note">diff omitted — large changeset</span></div></div>`
|
|
341
|
+
if (!f.patch && f.oldText == null && f.newText == null) return `<div class="file flat"><div class="frow">${row}</div></div>`
|
|
342
|
+
const side = (label: string, text: string | null, absent: string) =>
|
|
343
|
+
`<div class="side"><div class="side-h">${label}</div><pre>${text == null ? `<span class="abs">${absent}</span>` : (esc(text) || '<span class="abs">(empty)</span>')}</pre></div>`
|
|
344
|
+
const cmp = (f.oldText != null || f.newText != null)
|
|
345
|
+
? `<details class="fullfile"><summary>full file · original ↔ new</summary><div class="cmp">${side('original (merge-base)', f.oldText, '— added —')}${side('new (HEAD)', f.newText, '— deleted —')}</div></details>`
|
|
346
|
+
: ''
|
|
347
|
+
return `<details class="file"><summary>${row}${f.truncated ? '<span class="note">truncated</span>' : ''}</summary>
|
|
348
|
+
<div class="file-body">${f.patch ? `<div class="patch">${renderPatch(f.patch)}</div>` : '<div class="note">no textual diff</div>'}${cmp}</div></details>`
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
function renderNode(n: ProofNode): string {
|
|
352
|
+
const stat = `<span class="diffstat"><span class="add">+${n.additions}</span> <span class="del">−${n.deletions}</span> · ${n.files.length} file(s)</span>`
|
|
353
|
+
const fileList = n.files.length ? `<div class="files">${n.files.map(renderFile).join('')}</div>` : ''
|
|
354
|
+
let proof: string
|
|
355
|
+
if (n.readings.length) proof = n.readings.map(renderReading).join('')
|
|
356
|
+
else if (n.uncoveredFrontend) proof = `<div class="blindspot">⚠ a frontend node with no yatsu.md — no loss signal measured. Give it a scenario so this change can be proven.</div>`
|
|
357
|
+
else if (n.hasYatsu) proof = `<div class="blindspot">declares scenarios but has no reading yet — measure with <code>spex yatsu eval ${esc(n.id)}</code></div>`
|
|
358
|
+
else proof = `<div class="noev">no measurable surface (no yatsu.md)</div>`
|
|
359
|
+
return `<article class="node" style="--hue:${n.hue}">
|
|
360
|
+
<div class="nhead">
|
|
361
|
+
<span class="huedot"></span>
|
|
362
|
+
<span class="ntitle">${esc(n.title)}</span>
|
|
363
|
+
<span class="nid">${esc(n.id)}</span>
|
|
364
|
+
${scoreBadge(n.score, n.score ?? undefined)}
|
|
365
|
+
${stat}
|
|
366
|
+
</div>
|
|
367
|
+
${n.desc ? `<div class="ndesc">${esc(n.desc)}</div>` : ''}
|
|
368
|
+
${fileList}
|
|
369
|
+
<div class="readings">${proof}</div>
|
|
370
|
+
</article>`
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
export function renderProofHtml(m: ProofModel): string {
|
|
374
|
+
const idShort = m.id.slice(0, 8)
|
|
375
|
+
const ribbon = [
|
|
376
|
+
...m.gates.map((g) => `<span class="chip ${g.ok ? 'ok' : 'bad'}" title="${esc(g.detail)}">${g.ok ? '✓' : '✗'} ${esc(g.label)}</span>`),
|
|
377
|
+
m.score.total ? `<span class="chip ${m.score.passed === m.score.total ? 'ok' : 'warn'}" title="scenarios fresh-passing (of those measured); ${m.score.fresh}/${m.score.total} fresh">★ ${m.score.passed}/${m.score.total} passing</span>` : `<span class="chip warn" title="no yatsu readings on the changed nodes">★ no measured loss</span>`,
|
|
378
|
+
].join('')
|
|
379
|
+
const gates = m.gates.map((g) => `<li class="${g.ok ? 'ok' : 'bad'}"><span class="gmark">${g.ok ? '✓' : '✗'}</span><span class="glabel">${esc(g.label)}</span><span class="gdetail">${esc(g.detail)}</span></li>`).join('')
|
|
380
|
+
const otherBlock = m.otherFiles.length
|
|
381
|
+
? `<article class="node other"><div class="nhead"><span class="ntitle">other files</span><span class="nid">unclaimed by any node</span></div>
|
|
382
|
+
<div class="files">${m.otherFiles.map(renderFile).join('')}</div></article>`
|
|
383
|
+
: ''
|
|
384
|
+
return `<!doctype html>
|
|
385
|
+
<html lang="en"><head>
|
|
386
|
+
<meta charset="utf-8">
|
|
387
|
+
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
388
|
+
<title>proof · ${esc(m.title)}</title>
|
|
389
|
+
<style>${STYLE}</style>
|
|
390
|
+
</head><body>
|
|
391
|
+
<main class="proof">
|
|
392
|
+
<header class="masthead">
|
|
393
|
+
<div class="eyebrow">SpexCode · review proof</div>
|
|
394
|
+
<h1 class="claim">${esc(m.title)}</h1>
|
|
395
|
+
<div class="meta">session <code>${esc(idShort)}</code>${m.branch ? ` · <code>${esc(m.branch)}</code>` : ''}${m.node ? ` · node <code>${esc(m.node)}</code>` : ''} · ${m.ahead} commit(s) · ${m.nodes.length} node(s) · <span class="ts">${esc(m.generatedAt)}</span></div>
|
|
396
|
+
<div class="ribbon">${ribbon}</div>
|
|
397
|
+
</header>
|
|
398
|
+
<section class="evidence-section">
|
|
399
|
+
<h2>Evidence — measured loss</h2>
|
|
400
|
+
${m.nodes.map(renderNode).join('')}
|
|
401
|
+
${otherBlock}
|
|
402
|
+
</section>
|
|
403
|
+
<section class="gates-section">
|
|
404
|
+
<h2>Merge gates</h2>
|
|
405
|
+
<ul class="gates">${gates}</ul>
|
|
406
|
+
</section>
|
|
407
|
+
<footer>Generated by <code>spex review proof ${esc(idShort)}</code> — the optimizer's measured loss, presented for the merge decision. A spec is the loss; commits are the optimizer; yatsu is the evaluation.</footer>
|
|
408
|
+
</main>
|
|
409
|
+
</body></html>`
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
// the document's inline stylesheet — dark, matching the board's palette and the yatsu score colours, so the
|
|
413
|
+
// proof reads as one surface with the dashboard. Self-contained (no external font/asset).
|
|
414
|
+
const STYLE = `
|
|
415
|
+
:root{--bg:#0b0e14;--panel:#11161f;--panel2:#0e131b;--ink:#c9d4e3;--dim:#7c899c;--line:#1e2733;--accent:#4cc2a0;--green:#3fb950;--red:#f85149;--grey:#6e7b8c;--amber:#d29922}
|
|
416
|
+
*{box-sizing:border-box}
|
|
417
|
+
body{margin:0;background:var(--bg);color:var(--ink);font:15px/1.6 -apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,Helvetica,Arial,sans-serif;-webkit-font-smoothing:antialiased}
|
|
418
|
+
code,pre,.mono{font-family:ui-monospace,SFMono-Regular,"SF Mono",Menlo,Consolas,monospace}
|
|
419
|
+
code{background:#0006;padding:.05em .35em;border-radius:4px;font-size:.88em;color:#aee1d2}
|
|
420
|
+
.proof{max-width:920px;margin:0 auto;padding:40px 24px 80px}
|
|
421
|
+
.masthead{padding:30px 30px 26px;border:1px solid var(--line);border-radius:16px;background:radial-gradient(1200px 240px at 0% 0%,#16352c66,transparent),linear-gradient(160deg,#141b26,#0d121a);box-shadow:0 20px 60px -30px #000}
|
|
422
|
+
.eyebrow{font:600 11px/1 ui-monospace,monospace;letter-spacing:.18em;text-transform:uppercase;color:var(--accent)}
|
|
423
|
+
.claim{margin:14px 0 10px;font-size:30px;line-height:1.2;font-weight:700;color:#eef3fa;letter-spacing:-.01em}
|
|
424
|
+
.meta{color:var(--dim);font-size:13px}
|
|
425
|
+
.meta code{background:#0008;color:#9fb3c8}
|
|
426
|
+
.ribbon{margin-top:18px;display:flex;flex-wrap:wrap;gap:8px}
|
|
427
|
+
.chip{display:inline-flex;align-items:center;gap:.4em;font:600 12px/1 ui-monospace,monospace;padding:6px 10px;border-radius:999px;border:1px solid var(--line);background:#0c1118}
|
|
428
|
+
.chip.ok{color:var(--green);border-color:#1c3a26}
|
|
429
|
+
.chip.bad{color:var(--red);border-color:#3a1f1f}
|
|
430
|
+
.chip.warn{color:var(--amber);border-color:#3a3320}
|
|
431
|
+
h2{margin:42px 0 16px;font-size:14px;letter-spacing:.14em;text-transform:uppercase;color:var(--dim);font-weight:700}
|
|
432
|
+
.node{margin:14px 0;padding:18px 20px;border:1px solid var(--line);border-radius:12px;background:var(--panel);border-left:3px solid hsl(var(--hue,210),55%,55%)}
|
|
433
|
+
.node.other{border-left-color:var(--grey)}
|
|
434
|
+
.nhead{display:flex;align-items:center;gap:10px;flex-wrap:wrap}
|
|
435
|
+
.huedot{width:11px;height:11px;border-radius:3px;background:hsl(var(--hue,210),60%,55%);flex:none}
|
|
436
|
+
.ntitle{font-weight:700;color:#e7eef7;font-size:16px}
|
|
437
|
+
.nid{font:12px/1 ui-monospace,monospace;color:var(--dim)}
|
|
438
|
+
.ndesc{margin:8px 0 4px;color:var(--dim);font-size:13px}
|
|
439
|
+
.diffstat{margin-left:auto;font:12px/1 ui-monospace,monospace;color:var(--dim)}
|
|
440
|
+
.add{color:var(--green)}.del{color:var(--red)}
|
|
441
|
+
.files{margin:12px 0 0;border-top:1px solid var(--line)}
|
|
442
|
+
.file{border-bottom:1px solid #0c1117}
|
|
443
|
+
.file>summary,.frow{display:flex;align-items:center;gap:10px;padding:6px 2px;font:12px/1.4 ui-monospace,monospace}
|
|
444
|
+
.file>summary{cursor:pointer;list-style:none}
|
|
445
|
+
.file>summary::-webkit-details-marker{display:none}
|
|
446
|
+
.file>summary::before{content:'▸ ';color:var(--dim)}
|
|
447
|
+
.file[open]>summary::before{content:'▾ '}
|
|
448
|
+
.file.flat .frow{padding-left:14px}
|
|
449
|
+
.fstatus{flex:none;width:84px;color:var(--dim);text-transform:uppercase;font-size:10px;letter-spacing:.06em}
|
|
450
|
+
.fstatus.added{color:var(--green)}.fstatus.deleted{color:var(--red)}.fstatus.modified{color:var(--amber)}
|
|
451
|
+
.fpath{color:#aebccd;overflow:hidden;text-overflow:ellipsis}.fnum{margin-left:auto;flex:none}
|
|
452
|
+
.note{color:var(--dim);font-style:italic;font-size:11px;margin-left:8px}
|
|
453
|
+
.file-body{padding:2px 0 12px 14px}
|
|
454
|
+
.patch{font:12px/1.5 ui-monospace,monospace;background:#05080d;border:1px solid var(--line);border-radius:8px;overflow:auto;max-height:480px;white-space:pre}
|
|
455
|
+
.dl{display:block;padding:0 10px;min-height:1.5em}
|
|
456
|
+
.dl.a{background:#11331f;color:#86e2ad}.dl.d{background:#3a1616;color:#f3a3a0}.dl.h{color:#74a6f7;background:#0c1830}.dl.m{color:var(--dim)}
|
|
457
|
+
.fullfile{margin-top:10px}
|
|
458
|
+
.fullfile>summary{cursor:pointer;color:var(--accent);font:600 11px/1 ui-monospace,monospace;padding:6px 0}
|
|
459
|
+
.cmp{display:flex;gap:10px;flex-wrap:wrap;margin-top:6px}
|
|
460
|
+
.side{flex:1;min-width:300px}
|
|
461
|
+
.side-h{font:600 10px/1 ui-monospace,monospace;text-transform:uppercase;letter-spacing:.08em;color:var(--dim);padding:0 0 6px}
|
|
462
|
+
.side pre{margin:0;padding:10px 12px;background:#05080d;border:1px solid var(--line);border-radius:8px;max-height:440px;overflow:auto;font:11px/1.5 ui-monospace,monospace;color:#aebccd;white-space:pre}
|
|
463
|
+
.abs{color:var(--dim);font-style:italic}
|
|
464
|
+
.readings{margin-top:8px}
|
|
465
|
+
.reading{margin-top:14px;padding:14px 16px;border:1px solid var(--line);border-radius:10px;background:var(--panel2)}
|
|
466
|
+
.rhead{display:flex;align-items:center;gap:10px;flex-wrap:wrap}
|
|
467
|
+
.scenario{font-weight:600;color:#dde7f1}
|
|
468
|
+
.rmeta{margin-left:auto;font:11px/1 ui-monospace,monospace;color:var(--dim)}
|
|
469
|
+
.verdict{font:600 11px/1 ui-monospace,monospace;padding:3px 8px;border-radius:6px;border:1px solid var(--line)}
|
|
470
|
+
.verdict.pass{color:var(--green);border-color:#1c3a26}.verdict.fail{color:var(--red);border-color:#3a1f1f}
|
|
471
|
+
.verdict.note{color:var(--amber)}.verdict.legacy{color:var(--grey)}
|
|
472
|
+
.stale{font:600 10px/1 ui-monospace,monospace;color:var(--amber);border:1px solid #3a3320;border-radius:6px;padding:3px 7px}
|
|
473
|
+
.score{display:inline-flex;align-items:center;justify-content:center;width:18px;height:18px;border-radius:50%;border:2px solid var(--grey);font-size:11px;font-weight:700;flex:none}
|
|
474
|
+
.score.pass{border-color:var(--green);color:var(--green)}
|
|
475
|
+
.score.fail{border-color:var(--red);color:var(--red)}
|
|
476
|
+
.score.stalePass,.score.staleFail{border-color:var(--grey);color:var(--grey)}
|
|
477
|
+
.score.empty{border-style:dashed}
|
|
478
|
+
.expected{margin:10px 0 0;color:var(--dim);font-size:13px}.expected b{color:#9fb3c8;font-weight:600}
|
|
479
|
+
.rnote{margin:8px 0 0;color:var(--amber);font-size:13px}.rnote b{font-weight:600}
|
|
480
|
+
.evidence{margin:12px 0 0}
|
|
481
|
+
.shot{max-width:100%;border:1px solid var(--line);border-radius:8px;box-shadow:0 14px 40px -24px #000;display:block}
|
|
482
|
+
.transcript{max-height:420px;overflow:auto;margin:0;padding:12px 14px;background:#05080d;border:1px solid var(--line);border-radius:8px;font-size:12px;color:#9fdcc6;white-space:pre-wrap;word-break:break-word}
|
|
483
|
+
.noev,.blindspot{margin-top:10px;padding:10px 12px;border-radius:8px;font-size:13px;color:var(--dim);background:#0a0e15;border:1px dashed var(--line)}
|
|
484
|
+
.blindspot{color:var(--amber);border-color:#3a3320}
|
|
485
|
+
.gates{list-style:none;margin:0;padding:0;border:1px solid var(--line);border-radius:12px;overflow:hidden}
|
|
486
|
+
.gates li{display:flex;align-items:center;gap:14px;padding:12px 18px;border-bottom:1px solid var(--line);background:var(--panel)}
|
|
487
|
+
.gates li:last-child{border-bottom:0}
|
|
488
|
+
.gmark{font-weight:700}.gates li.ok .gmark{color:var(--green)}.gates li.bad .gmark{color:var(--red)}
|
|
489
|
+
.glabel{font-weight:600;width:120px;color:#dbe6f2}.gdetail{color:var(--dim);font:12px/1 ui-monospace,monospace}
|
|
490
|
+
footer{margin-top:48px;padding-top:20px;border-top:1px solid var(--line);color:var(--dim);font-size:12px;text-align:center}
|
|
491
|
+
`
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import { readFileSync, appendFileSync, existsSync } from 'node:fs'
|
|
2
|
+
|
|
3
|
+
export type Verdict = { status: 'pass' | 'fail' | 'note'; note?: string }
|
|
4
|
+
|
|
5
|
+
// blobKind absent on a legacy reading → rendered as an image (every legacy capture was one)
|
|
6
|
+
export type Reading = {
|
|
7
|
+
scenario: string
|
|
8
|
+
codeSha: string
|
|
9
|
+
blob: string | null
|
|
10
|
+
blobKind?: 'image' | 'transcript'
|
|
11
|
+
evaluator: string
|
|
12
|
+
verdict?: Verdict
|
|
13
|
+
ts: string
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
// parse the sidecar: one Reading per non-blank line. A malformed line is skipped (the file is append-only
|
|
17
|
+
// and git-tracked, so a partial write or a hand-edit shouldn't sink the whole read) — fail soft per line.
|
|
18
|
+
export function readReadings(sidecarPath: string): Reading[] {
|
|
19
|
+
if (!existsSync(sidecarPath)) return []
|
|
20
|
+
const out: Reading[] = []
|
|
21
|
+
for (const line of readFileSync(sidecarPath, 'utf8').split('\n')) {
|
|
22
|
+
const t = line.trim()
|
|
23
|
+
if (!t) continue
|
|
24
|
+
try {
|
|
25
|
+
const r = JSON.parse(t)
|
|
26
|
+
if (r && typeof r.scenario === 'string' && typeof r.evaluator === 'string') out.push(r as Reading)
|
|
27
|
+
} catch { /* skip a malformed line */ }
|
|
28
|
+
}
|
|
29
|
+
return out
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
// append ONE reading as a JSON line — the only mutation eval performs (a reading is an event, never an
|
|
33
|
+
// overwrite; superseding readings are newer lines, freshness picks the latest per scenario).
|
|
34
|
+
export function appendReading(sidecarPath: string, r: Reading): void {
|
|
35
|
+
appendFileSync(sidecarPath, JSON.stringify(r) + '\n')
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
// the latest reading per scenario (the file is chronological, so the LAST line for a name wins). clean's
|
|
39
|
+
// --keep-latest uses it to decide which blob to keep.
|
|
40
|
+
export function latestPerScenario(readings: Reading[]): Map<string, Reading> {
|
|
41
|
+
const m = new Map<string, Reading>()
|
|
42
|
+
for (const r of readings) m.set(r.scenario, r) // later lines overwrite earlier → last wins
|
|
43
|
+
return m
|
|
44
|
+
}
|