spexcode 0.1.1 → 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/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,296 @@
|
|
|
1
|
+
import { readFileSync, existsSync } from 'node:fs'
|
|
2
|
+
import { join, relative, dirname } from 'node:path'
|
|
3
|
+
import { repoRoot, headSha, driftIndex, historyIndex, stagedFiles, git } from '../../spec-cli/src/git.js'
|
|
4
|
+
import { loadSpecs } from '../../spec-cli/src/specs.js'
|
|
5
|
+
import { loadConfig } from '../../spec-cli/src/lint.js'
|
|
6
|
+
import { mainBranch, envSessionId, readRawRecord } from '../../spec-cli/src/layout.js'
|
|
7
|
+
import { yatsuNodes, validateScenarios, YATSU_FILE, type YatsuNode } from './yatsu.js'
|
|
8
|
+
import { readReadings, appendReading, latestPerScenario, type Reading, type Verdict } from './sidecar.js'
|
|
9
|
+
import { staleAxes } from './freshness.js'
|
|
10
|
+
import { evaluatorTag } from './evaluator.js'
|
|
11
|
+
import { putBlob, listBlobs, gc, isStrayBlob } from './cache.js'
|
|
12
|
+
import { evalTimeline, type EvalTimeline } from './evaltab.js'
|
|
13
|
+
|
|
14
|
+
function flag(args: string[], name: string): string | undefined {
|
|
15
|
+
const i = args.indexOf(`--${name}`)
|
|
16
|
+
return i >= 0 ? args[i + 1] : undefined
|
|
17
|
+
}
|
|
18
|
+
const has = (args: string[], name: string) => args.includes(`--${name}`)
|
|
19
|
+
const positional = (args: string[]) => args.find((a) => !a.startsWith('--'))
|
|
20
|
+
|
|
21
|
+
// join by node directory (not id) so a reparent/rename is seen the way lint sees it
|
|
22
|
+
type EvalNode = YatsuNode & { codeFiles: string[] }
|
|
23
|
+
|
|
24
|
+
async function gatherNodes(root: string): Promise<EvalNode[]> {
|
|
25
|
+
const specs = await loadSpecs()
|
|
26
|
+
const codeByDir = new Map<string, string[]>()
|
|
27
|
+
for (const s of specs) codeByDir.set(dirname(s.path), s.code) // s.path = repo-relative spec.md path
|
|
28
|
+
return yatsuNodes(root).map((n) => ({ ...n, codeFiles: codeByDir.get(relative(root, n.dir)) ?? [] }))
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
// resolve `.` → the node this worktree works on: the session record's `node` (the authoritative ref a
|
|
32
|
+
// dashboard session was bound to — NOT derivable from the branch, whose slug carries a `-<id4>` suffix),
|
|
33
|
+
// else the `node/<id>` branch. The record now lives in the GLOBAL store keyed by the harness session id
|
|
34
|
+
// ([[state]]), so we read it via the env session id; a self-launched agent with no record falls back to the branch.
|
|
35
|
+
function currentNodeId(root: string): string | null {
|
|
36
|
+
const id = envSessionId()
|
|
37
|
+
if (id) { const rec = readRawRecord(id); if (rec?.node) return rec.node }
|
|
38
|
+
try {
|
|
39
|
+
const branch = git(['-C', root, 'symbolic-ref', '--short', 'HEAD']).trim()
|
|
40
|
+
if (branch.startsWith('node/')) return branch.slice('node/'.length)
|
|
41
|
+
} catch { /* detached / no branch */ }
|
|
42
|
+
return null
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
const UI_FILE = /\.(jsx|tsx|vue|svelte|css)$/
|
|
46
|
+
export const isUiPath = (p: string) => UI_FILE.test(p) || p.includes('spec-dashboard/')
|
|
47
|
+
|
|
48
|
+
// merge-base anchored so the main branch advancing isn't counted as this branch's change; a git error yields ∅ (--changed scan stays silent)
|
|
49
|
+
function changedSinceBase(root: string): Set<string> {
|
|
50
|
+
const out = new Set<string>()
|
|
51
|
+
const add = (s: string) => { for (const l of s.split('\n')) { const t = l.trim(); if (t) out.add(t) } }
|
|
52
|
+
try {
|
|
53
|
+
const base = git(['-C', root, 'merge-base', mainBranch(), 'HEAD']).trim()
|
|
54
|
+
if (base) add(git(['-C', root, '-c', 'core.quotePath=false', 'diff', '--name-only', base]))
|
|
55
|
+
add(git(['-C', root, '-c', 'core.quotePath=false', 'ls-files', '--others', '--exclude-standard']))
|
|
56
|
+
} catch { /* no base → empty set */ }
|
|
57
|
+
return out
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
// a node is "changed" if the branch touched its node dir (spec.md / yatsu.md / sidecar all live there) or
|
|
61
|
+
// any governed code path — matched as an exact file, a directory prefix, or a `*` glob.
|
|
62
|
+
export function nodeChanged(dirRel: string, codeFiles: string[], changed: Set<string>): boolean {
|
|
63
|
+
for (const c of changed) if (c === dirRel || c.startsWith(dirRel + '/')) return true
|
|
64
|
+
return codeFiles.some((cf) => {
|
|
65
|
+
if (changed.has(cf)) return true
|
|
66
|
+
const dir = cf.replace(/\/+$/, '') + '/'
|
|
67
|
+
const re = cf.includes('*') ? new RegExp('^' + cf.replace(/[.+?^${}()|[\]\\]/g, '\\$&').replace(/\*/g, '.*') + '$') : null
|
|
68
|
+
for (const c of changed) { if (c.startsWith(dir)) return true; if (re && re.test(c)) return true }
|
|
69
|
+
return false
|
|
70
|
+
})
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
async function scan(args: string[] = []): Promise<number> {
|
|
74
|
+
const root = repoRoot()
|
|
75
|
+
const changedOnly = has(args, 'changed')
|
|
76
|
+
const changed = changedOnly ? changedSinceBase(root) : null
|
|
77
|
+
const idx = await driftIndex(root)
|
|
78
|
+
const hidx = await historyIndex(root)
|
|
79
|
+
const specs = await loadSpecs()
|
|
80
|
+
// a file may be governed by several nodes — ordinary composition, not a hub to skip (see governed-related).
|
|
81
|
+
// A change to a shared governed file legitimately triggers EVERY governing node's yatsu, mirroring how
|
|
82
|
+
// lint's drift now fans to every owner; nobody's loss signal is suppressed. An over-owned file is lint's
|
|
83
|
+
// `owners` concern (split it), not a reason to go silent here.
|
|
84
|
+
const yByDir = new Map(yatsuNodes(root).map((n) => [relative(root, n.dir), n]))
|
|
85
|
+
let flaggedNodes = 0, malformed = 0, staleScores = 0, missingScores = 0, uncovered = 0
|
|
86
|
+
for (const s of specs) {
|
|
87
|
+
const dirRel = dirname(s.path)
|
|
88
|
+
if (changed && !nodeChanged(dirRel, s.code, changed)) continue
|
|
89
|
+
const y = yByDir.get(dirRel)
|
|
90
|
+
const findings: string[] = []
|
|
91
|
+
if (y) {
|
|
92
|
+
// schema first: a malformed yatsu.md is the loudest gap — report each violation, then still scan its
|
|
93
|
+
// (leniently-parsed) scenarios for stale/missing so a typo doesn't mask a real freshness gap.
|
|
94
|
+
for (const e of validateScenarios(readFileSync(join(y.dir, YATSU_FILE), 'utf8'))) {
|
|
95
|
+
malformed++
|
|
96
|
+
findings.push(` • yatsu-schema: '${s.id}' ${e} — fix ${y.yatsuPath}`)
|
|
97
|
+
}
|
|
98
|
+
const latest = latestPerScenario(readReadings(y.sidecarPath))
|
|
99
|
+
for (const sc of y.scenarios) {
|
|
100
|
+
// a scenario's own `code` narrows its freshness CODE axis to a subset; a path that does not exist
|
|
101
|
+
// would make that axis silently immortal (changedSince finds no commits for it), so flag it LOUD as a
|
|
102
|
+
// malformed declaration — the same loud-fail spirit as a bad node `code:`.
|
|
103
|
+
for (const [field, paths] of [['code', sc.code], ['related', sc.related]] as const) {
|
|
104
|
+
const ghosts = (paths ?? []).filter((p) => !existsSync(join(root, p)))
|
|
105
|
+
if (ghosts.length) {
|
|
106
|
+
malformed++
|
|
107
|
+
findings.push(` • yatsu-schema: '${s.id}' scenario '${sc.name}' \`${field}\` path(s) not found: ${ghosts.join(', ')} — fix ${y.yatsuPath}`)
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
const codeFiles = sc.code?.length ? sc.code : s.code // scenario's own subset, else the node's list
|
|
111
|
+
const r = latest.get(sc.name)
|
|
112
|
+
if (!r) {
|
|
113
|
+
missingScores++
|
|
114
|
+
findings.push(` • yatsu-missing: '${s.id}' scenario '${sc.name}' has no reading yet — measure with \`spex yatsu eval ${s.id}\``)
|
|
115
|
+
continue
|
|
116
|
+
}
|
|
117
|
+
const axes = staleAxes(r, codeFiles, y.yatsuPath, idx, hidx)
|
|
118
|
+
if (axes.length) {
|
|
119
|
+
staleScores++
|
|
120
|
+
findings.push(` • yatsu-drift: '${s.id}' scenario '${sc.name}' is stale (${axes.join(', ')} changed since ${r.codeSha.slice(0, 7)}) — re-measure with \`spex yatsu eval ${s.id}\``)
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
} else if (s.code.some(isUiPath)) {
|
|
124
|
+
uncovered++
|
|
125
|
+
findings.push(` • yatsu-uncovered: '${s.id}' governs frontend code but has no yatsu.md — give it a scenario (description + expected) so its loss can be measured`)
|
|
126
|
+
}
|
|
127
|
+
if (!findings.length) continue
|
|
128
|
+
flaggedNodes++
|
|
129
|
+
for (const f of findings) console.error(f)
|
|
130
|
+
}
|
|
131
|
+
// whole-repo only (never --changed): a structural fact, not a per-branch freshness gap. Counts only explicit scenario `code:`.
|
|
132
|
+
let overOwned = 0
|
|
133
|
+
if (!changedOnly) {
|
|
134
|
+
const maxOwners = loadConfig(root).maxOwners
|
|
135
|
+
const govCount = new Map<string, number>()
|
|
136
|
+
for (const n of yByDir.values()) for (const sc of n.scenarios) for (const f of sc.code ?? []) govCount.set(f, (govCount.get(f) ?? 0) + 1)
|
|
137
|
+
const over = [...govCount].filter(([, c]) => c > maxOwners).sort((a, b) => b[1] - a[1])
|
|
138
|
+
if (over.length) {
|
|
139
|
+
overOwned = over.length
|
|
140
|
+
const top = over.slice(0, 5).map(([f, c]) => `${f.split('/').pop()}(${c})`).join(', ')
|
|
141
|
+
console.error(` • yatsu-owners: ${over.length} file(s) are governed by > ${maxOwners} scenarios — each carries more separately-measured behaviour than one file should. Worst: ${top}. SPLIT the file so each scenario measures its own surface.`)
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
const scope = changedOnly ? ' --changed' : ''
|
|
145
|
+
const ownersNote = overOwned ? `, ${overOwned} over-owned` : ''
|
|
146
|
+
console.error(`spex yatsu scan${scope}: ${flaggedNodes} node(s) flagged (${malformed} malformed, ${staleScores} stale, ${missingScores} missing, ${uncovered} uncovered${ownersNote})`)
|
|
147
|
+
return 0
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
async function evalCmd(args: string[]): Promise<number> {
|
|
151
|
+
const root = repoRoot()
|
|
152
|
+
const sel = positional(args)
|
|
153
|
+
const id = !sel || sel === '.' ? currentNodeId(root) : sel
|
|
154
|
+
if (!id) { console.error('spex yatsu eval .: no current node (no .session/node-branch here) — name a node'); return 2 }
|
|
155
|
+
const node = (await gatherNodes(root)).find((n) => n.id === id)
|
|
156
|
+
if (!node) { console.error(`spex yatsu eval: no yatsu node '${id}' (a node needs a yatsu.md)`); return 1 }
|
|
157
|
+
if (!node.scenarios.length) { console.error(`spex yatsu eval: '${id}' declares no scenarios in its yatsu.md`); return 1 }
|
|
158
|
+
|
|
159
|
+
// which scenario this measurement is OF: --scenario, or the sole scenario when there is exactly one.
|
|
160
|
+
const names = node.scenarios.map((s) => s.name)
|
|
161
|
+
const scName = flag(args, 'scenario')
|
|
162
|
+
let scenario = scName ? node.scenarios.find((s) => s.name === scName) : node.scenarios.length === 1 ? node.scenarios[0] : undefined
|
|
163
|
+
if (!scenario) {
|
|
164
|
+
const why = scName ? `has no scenario '${scName}'` : `declares ${node.scenarios.length} scenarios — name one with --scenario <name>`
|
|
165
|
+
console.error(`spex yatsu eval: '${id}' ${why} — declared: ${names.join(', ')}`)
|
|
166
|
+
return 1
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
// the verdict the agent reached (required — a measurement without one is the legacy shape, not a filing).
|
|
170
|
+
const verdict = parseVerdict(args)
|
|
171
|
+
if (!verdict) { console.error('spex yatsu eval: a verdict is required — one of --pass | --fail | --note <text>'); return 2 }
|
|
172
|
+
|
|
173
|
+
// the evidence the agent captured (optional; --image XOR --result). The bytes go to the content-addressed
|
|
174
|
+
// cache exactly the same whether image or transcript; only `blobKind` records which they are.
|
|
175
|
+
const image = flag(args, 'image')
|
|
176
|
+
const result = flag(args, 'result')
|
|
177
|
+
if (image !== undefined && result !== undefined) { console.error('spex yatsu eval: pass at most one of --image / --result'); return 2 }
|
|
178
|
+
let blob: string | null = null
|
|
179
|
+
let blobKind: 'image' | 'transcript' | undefined
|
|
180
|
+
if (image !== undefined) { blob = putBlob(readFileSync(image)); blobKind = 'image' }
|
|
181
|
+
else if (result !== undefined) { blob = putBlob(readFileSync(result === '-' ? 0 : result)); blobKind = 'transcript' }
|
|
182
|
+
|
|
183
|
+
const reading: Reading = {
|
|
184
|
+
scenario: scenario.name,
|
|
185
|
+
codeSha: headSha(root),
|
|
186
|
+
blob,
|
|
187
|
+
...(blobKind ? { blobKind } : {}),
|
|
188
|
+
evaluator: evaluatorTag(),
|
|
189
|
+
verdict,
|
|
190
|
+
ts: new Date().toISOString(),
|
|
191
|
+
}
|
|
192
|
+
appendReading(node.sidecarPath, reading)
|
|
193
|
+
const ev = blobKind === 'transcript' ? `transcript ${blob!.slice(0, 12)}…` : blobKind === 'image' ? `image ${blob!.slice(0, 12)}…` : 'no evidence'
|
|
194
|
+
console.log(` ✓ '${id}' scenario '${scenario.name}' → ${verdictText(verdict)} @ ${reading.codeSha.slice(0, 7)} [${reading.evaluator}] (${ev})`)
|
|
195
|
+
console.log(`spex yatsu eval: 1 measurement filed`)
|
|
196
|
+
return 0
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
// the verdict from the flags: exactly one of --pass / --fail / --note <text> (precedence pass > fail > note).
|
|
200
|
+
function parseVerdict(args: string[]): Verdict | null {
|
|
201
|
+
if (has(args, 'pass')) return { status: 'pass' }
|
|
202
|
+
if (has(args, 'fail')) return { status: 'fail' }
|
|
203
|
+
const note = flag(args, 'note')
|
|
204
|
+
if (note !== undefined) return { status: 'note', note }
|
|
205
|
+
return null
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
async function clean(args: string[]): Promise<number> {
|
|
209
|
+
const root = repoRoot()
|
|
210
|
+
const all = has(args, 'all')
|
|
211
|
+
const keepLatest = has(args, 'keep-latest')
|
|
212
|
+
const referenced = new Set<string>()
|
|
213
|
+
if (!all) {
|
|
214
|
+
for (const n of await gatherNodes(root)) {
|
|
215
|
+
const readings = readReadings(n.sidecarPath)
|
|
216
|
+
const keep = keepLatest ? [...latestPerScenario(readings).values()] : readings
|
|
217
|
+
for (const r of keep) if (r.blob) referenced.add(r.blob)
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
const before = listBlobs().length
|
|
221
|
+
const removed = gc(referenced)
|
|
222
|
+
const mode = all ? 'all' : keepLatest ? 'keep-latest' : 'unreferenced'
|
|
223
|
+
console.log(`spex yatsu clean: removed ${removed.length} blob(s), kept ${before - removed.length} (${mode})`)
|
|
224
|
+
return 0
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
function checkStaged(): number {
|
|
228
|
+
const root = repoRoot()
|
|
229
|
+
const staged = stagedFiles(root)
|
|
230
|
+
let bad = false
|
|
231
|
+
|
|
232
|
+
const blobs = staged.filter(isStrayBlob)
|
|
233
|
+
if (blobs.length) {
|
|
234
|
+
bad = true
|
|
235
|
+
console.error('✗ SpexCode yatsu: stray evidence blob(s) staged — blobs live in the shared git common dir, never in the tree:')
|
|
236
|
+
for (const o of blobs) console.error(` ${o}`)
|
|
237
|
+
console.error(' Unstage them (git rm --cached <path>); a reading references its blob by hash, it never commits the bytes.')
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
for (const rel of staged.filter((p) => p === YATSU_FILE || p.endsWith('/' + YATSU_FILE))) {
|
|
241
|
+
const abs = join(root, rel)
|
|
242
|
+
if (!existsSync(abs)) continue // staged deletion — nothing to validate
|
|
243
|
+
const errs = validateScenarios(readFileSync(abs, 'utf8'))
|
|
244
|
+
if (!errs.length) continue
|
|
245
|
+
bad = true
|
|
246
|
+
console.error(`✗ SpexCode yatsu: ${rel} — invalid scenario schema:`)
|
|
247
|
+
for (const e of errs) console.error(` ${e}`)
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
return bad ? 1 : 0
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
async function show(args: string[]): Promise<number> {
|
|
254
|
+
const root = repoRoot()
|
|
255
|
+
const sel = positional(args)
|
|
256
|
+
const id = !sel || sel === '.' ? currentNodeId(root) : sel
|
|
257
|
+
if (!id) { console.error('spex yatsu show .: no current node (no .session/node-branch here) — name a node'); return 2 }
|
|
258
|
+
const tl = await evalTimeline(id) // no ctx → evalTimeline derives specs + driftIndex itself for this one id
|
|
259
|
+
if (has(args, 'json')) { console.log(JSON.stringify(tl, null, 2)); return 0 }
|
|
260
|
+
console.log(formatTimeline(tl))
|
|
261
|
+
return 0
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
// the verdict as a short tag for the terminal: ✓ pass / ✗ fail / ≈ note: <text>, or `legacy` for a reading
|
|
265
|
+
// taken before verdicts existed.
|
|
266
|
+
function verdictText(v: Verdict | undefined): string {
|
|
267
|
+
if (!v) return 'legacy'
|
|
268
|
+
if (v.status === 'pass') return '✓ pass'
|
|
269
|
+
if (v.status === 'fail') return '✗ fail'
|
|
270
|
+
return `≈ note: ${v.note ?? ''}`
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
export function formatTimeline(tl: EvalTimeline): string {
|
|
274
|
+
if (!tl.hasYatsu) return `spex yatsu show: '${tl.node}' declares no scenarios (no yatsu.md)`
|
|
275
|
+
if (!tl.readings.length) return `spex yatsu show: '${tl.node}' has scenarios but no reading yet — run \`spex yatsu eval ${tl.node}\``
|
|
276
|
+
const w = Math.max(...tl.readings.map((r) => r.scenario.length))
|
|
277
|
+
const lines = tl.readings.flatMap((r) => {
|
|
278
|
+
const badge = r.fresh ? '✓ current' : `⚠ stale (${r.staleAxes.join(', ')})`
|
|
279
|
+
const ev = r.blobState === 'present' ? `${r.blobKind === 'transcript' ? 'transcript' : 'image'} ${(r.blob ?? '').slice(0, 12)}…`
|
|
280
|
+
: r.blobState === 'miss' ? 'miss original file' : 'no evidence'
|
|
281
|
+
const head = ` ${r.scenario.padEnd(w)} ${verdictText(r.verdict)} ${badge} ${r.evaluator} ${r.codeSha.slice(0, 7)} ${ev} ${r.ts}`
|
|
282
|
+
return r.expected ? [head, ` ${' '.repeat(w)} expected: ${r.expected}`] : [head]
|
|
283
|
+
})
|
|
284
|
+
return [`spex yatsu show: '${tl.node}' — ${tl.readings.length} reading(s), newest first`, '', ...lines].join('\n')
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
export async function runYatsu(args: string[]): Promise<number> {
|
|
288
|
+
const sub = args[0]
|
|
289
|
+
if (sub === 'scan') return scan(args.slice(1))
|
|
290
|
+
if (sub === 'eval') return evalCmd(args.slice(1))
|
|
291
|
+
if (sub === 'clean') return clean(args.slice(1))
|
|
292
|
+
if (sub === 'show') return show(args.slice(1))
|
|
293
|
+
if (sub === 'check-staged') return checkStaged()
|
|
294
|
+
console.error('spex yatsu: scan [--changed] | eval [.|<node>] [--scenario <name>] (--pass|--fail|--note <text>) [--image <path>|--result <path|->] | show [.|<node>] [--json] | clean [--keep-latest|--all]')
|
|
295
|
+
return 2
|
|
296
|
+
}
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
import { relative, dirname } from 'node:path'
|
|
2
|
+
import { repoRoot, driftIndex, historyIndex, type DriftIndex, type HistoryIndex } from '../../spec-cli/src/git.js'
|
|
3
|
+
import { loadSpecs } from '../../spec-cli/src/specs.js'
|
|
4
|
+
import { yatsuNodes, type YatsuNode } from './yatsu.js'
|
|
5
|
+
import { readReadings, type Verdict } from './sidecar.js'
|
|
6
|
+
import { staleAxes, type StaleAxis } from './freshness.js'
|
|
7
|
+
import { hasBlob, getBlob, MISS_BLOB } from './cache.js'
|
|
8
|
+
|
|
9
|
+
export type EvalEntry = {
|
|
10
|
+
scenario: string
|
|
11
|
+
expected: string
|
|
12
|
+
codeSha: string
|
|
13
|
+
blob: string | null
|
|
14
|
+
blobKind?: 'image' | 'transcript'
|
|
15
|
+
evaluator: string
|
|
16
|
+
verdict?: Verdict
|
|
17
|
+
ts: string
|
|
18
|
+
fresh: boolean
|
|
19
|
+
staleAxes: StaleAxis[]
|
|
20
|
+
blobState: 'present' | 'miss' | 'none'
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export type ScenarioInfo = { name: string; expected: string; code?: string[] }
|
|
24
|
+
|
|
25
|
+
// `hasYatsu` distinguishes a node that declares no scenarios (no yatsu.md) from one that declares some but
|
|
26
|
+
// has no readings yet — the tab says different things for each. `scenarios` is the declared set; `readings`
|
|
27
|
+
// is NEWEST-FIRST (the sidecar is append-only oldest→newest; the tab leads with the latest measurement,
|
|
28
|
+
// like the history tab).
|
|
29
|
+
export type EvalTimeline = {
|
|
30
|
+
node: string
|
|
31
|
+
hasYatsu: boolean
|
|
32
|
+
scenarios: ScenarioInfo[]
|
|
33
|
+
readings: EvalEntry[]
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export type EvalContext = {
|
|
37
|
+
root: string
|
|
38
|
+
specs: Awaited<ReturnType<typeof loadSpecs>>
|
|
39
|
+
idx: DriftIndex
|
|
40
|
+
hidx: HistoryIndex
|
|
41
|
+
ynodes: YatsuNode[]
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
// build the shared context with ONE yatsu walk, reusing the caller's already-computed specs + the two
|
|
45
|
+
// HEAD-keyed git indices (drift for the code axis, history for the rename-safe scenario axis — both warm
|
|
46
|
+
// hits, loadSpecs already derived them).
|
|
47
|
+
export function evalContext(root: string, specs: Awaited<ReturnType<typeof loadSpecs>>, idx: DriftIndex, hidx: HistoryIndex): EvalContext {
|
|
48
|
+
return { root, specs, idx, hidx, ynodes: yatsuNodes(root) }
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export async function evalTimeline(id: string, ctx?: EvalContext): Promise<EvalTimeline> {
|
|
52
|
+
const root = ctx?.root ?? repoRoot()
|
|
53
|
+
// short-circuit a non-yatsu node on the (short) yatsu walk — the board attaches `evals` to every node, so
|
|
54
|
+
// this is the common case and must stay cheap (a list the size of the few yatsu nodes, not the whole tree).
|
|
55
|
+
const ynode = (ctx?.ynodes ?? yatsuNodes(root)).find((n) => n.id === id)
|
|
56
|
+
if (!ynode) return { node: id, hasYatsu: false, scenarios: [], readings: [] }
|
|
57
|
+
// the governed `code:` files are the freshness CODE axis; read them from the canonical spec loader so a
|
|
58
|
+
// reparent/rename is seen the same way `spex lint` and `spex yatsu eval` see it (joined by directory).
|
|
59
|
+
const specs = ctx?.specs ?? await loadSpecs()
|
|
60
|
+
const codeFiles = specs.find((s) => dirname(s.path) === relative(root, ynode.dir))?.code ?? []
|
|
61
|
+
const idx = ctx?.idx ?? await driftIndex(root)
|
|
62
|
+
const hidx = ctx?.hidx ?? await historyIndex(root)
|
|
63
|
+
const byName = new Map(ynode.scenarios.map((s) => [s.name, s])) // join each reading to its scenario's expected + code
|
|
64
|
+
const scenarios: ScenarioInfo[] = ynode.scenarios.map((s) => ({
|
|
65
|
+
name: s.name, expected: s.expected, ...(s.code?.length ? { code: s.code } : {}),
|
|
66
|
+
}))
|
|
67
|
+
const readings: EvalEntry[] = readReadings(ynode.sidecarPath).map((r) => {
|
|
68
|
+
// a scenario's own `code` is its freshness code axis when it declares one; else the whole node's list.
|
|
69
|
+
const sc = byName.get(r.scenario)
|
|
70
|
+
const axes = staleAxes(r, sc?.code?.length ? sc.code : codeFiles, ynode.yatsuPath, idx, hidx)
|
|
71
|
+
return {
|
|
72
|
+
scenario: r.scenario,
|
|
73
|
+
expected: byName.get(r.scenario)?.expected ?? '',
|
|
74
|
+
codeSha: r.codeSha,
|
|
75
|
+
blob: r.blob,
|
|
76
|
+
...(r.blobKind ? { blobKind: r.blobKind } : {}),
|
|
77
|
+
evaluator: r.evaluator,
|
|
78
|
+
...(r.verdict ? { verdict: r.verdict } : {}),
|
|
79
|
+
ts: r.ts,
|
|
80
|
+
fresh: axes.length === 0,
|
|
81
|
+
staleAxes: axes,
|
|
82
|
+
blobState: r.blob == null ? 'none' : hasBlob(r.blob) ? 'present' : 'miss',
|
|
83
|
+
}
|
|
84
|
+
})
|
|
85
|
+
readings.reverse()
|
|
86
|
+
return { node: id, hasYatsu: true, scenarios, readings }
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
const HEX64 = /^[0-9a-f]{64}$/
|
|
90
|
+
|
|
91
|
+
export type BlobResult =
|
|
92
|
+
| { ok: true; bytes: Buffer; mime: string }
|
|
93
|
+
| { ok: false; reason: 'invalid' | 'miss'; message: string }
|
|
94
|
+
|
|
95
|
+
export function readBlobByHash(hash: string, dir?: string): BlobResult {
|
|
96
|
+
if (!HEX64.test(hash)) return { ok: false, reason: 'invalid', message: 'bad blob hash' }
|
|
97
|
+
const bytes = getBlob(hash, dir) // undefined dir → the live cache (cache.ts default); a temp dir in tests
|
|
98
|
+
if (!bytes) return { ok: false, reason: 'miss', message: MISS_BLOB }
|
|
99
|
+
return { ok: true, bytes, mime: sniffBlobMime(bytes) }
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
// PNG/JPEG/GIF/WebP cover every screenshot (a manual --image); a transcript (--result) is text, so bytes
|
|
103
|
+
// with no NUL and no image header sniff to text/plain; anything else falls back to a generic binary type so
|
|
104
|
+
// it still downloads rather than being mislabeled.
|
|
105
|
+
function sniffBlobMime(b: Buffer): string {
|
|
106
|
+
if (b.length >= 4 && b[0] === 0x89 && b[1] === 0x50 && b[2] === 0x4e && b[3] === 0x47) return 'image/png'
|
|
107
|
+
if (b.length >= 3 && b[0] === 0xff && b[1] === 0xd8 && b[2] === 0xff) return 'image/jpeg'
|
|
108
|
+
if (b.length >= 4 && b[0] === 0x47 && b[1] === 0x49 && b[2] === 0x46) return 'image/gif'
|
|
109
|
+
if (b.length >= 12 && b.toString('ascii', 0, 4) === 'RIFF' && b.toString('ascii', 8, 12) === 'WEBP') return 'image/webp'
|
|
110
|
+
if (b.length && !b.includes(0)) return 'text/plain; charset=utf-8'
|
|
111
|
+
return 'application/octet-stream'
|
|
112
|
+
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
export const EVALUATORS: Record<string, number> = { manual: 1 }
|
|
2
|
+
export const DEFAULT_EVALUATOR = 'manual'
|
|
3
|
+
|
|
4
|
+
// the tag stamped on a reading. With no name → the default `manual`; an unknown name still tags (version 1)
|
|
5
|
+
// so an out-of-band evaluator can record without the core having to know it yet.
|
|
6
|
+
export function evaluatorTag(name: string = DEFAULT_EVALUATOR): string {
|
|
7
|
+
return `${name}@${EVALUATORS[name] ?? 1}`
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
// parse a recorded evaluator tag back into name + version (for comparing against the current version).
|
|
11
|
+
export function parseEvaluator(tag: string): { name: string; version: number } {
|
|
12
|
+
const at = tag.lastIndexOf('@')
|
|
13
|
+
if (at < 0) return { name: tag, version: NaN }
|
|
14
|
+
return { name: tag.slice(0, at), version: Number(tag.slice(at + 1)) }
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
// a reading's evaluator tag is stale iff its evaluator is KNOWN to the core and its version is behind the
|
|
18
|
+
// current one. An unknown evaluator invents no staleness — we can't version an instrument we don't define.
|
|
19
|
+
export function isEvaluatorStale(tag: string): boolean {
|
|
20
|
+
const { name, version } = parseEvaluator(tag)
|
|
21
|
+
const cur = EVALUATORS[name]
|
|
22
|
+
if (cur === undefined) return false
|
|
23
|
+
return version !== cur
|
|
24
|
+
}
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import { rowsFor, type DriftIndex, type HistoryIndex } from '../../spec-cli/src/git.js'
|
|
2
|
+
import type { Reading } from './sidecar.js'
|
|
3
|
+
import { isEvaluatorStale } from './evaluator.js'
|
|
4
|
+
|
|
5
|
+
// 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
|
|
6
|
+
|
|
7
|
+
export type StaleAxis = 'code' | 'scenario' | 'evaluator'
|
|
8
|
+
|
|
9
|
+
// true iff some commit touched `path` strictly NEWER than `sinceSha`. An unknown `sinceSha` (a reading
|
|
10
|
+
// taken off the current history — e.g. on a since-rebased commit) returns true: we can't prove freshness,
|
|
11
|
+
// so we treat it as stale rather than silently pass.
|
|
12
|
+
export function changedSince(idx: DriftIndex, sinceSha: string, path: string): boolean {
|
|
13
|
+
const sp = idx.pos.get(sinceSha)
|
|
14
|
+
if (sp === undefined) return true
|
|
15
|
+
for (const h of idx.fileCommits.get(path) ?? []) {
|
|
16
|
+
const p = idx.pos.get(h)
|
|
17
|
+
if (p !== undefined && p < sp) return true // smaller position = newer than the reading
|
|
18
|
+
}
|
|
19
|
+
return false
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
// scenario freshness uses rowsFor (rename-followed content versions, like a spec node), not touch-based fileCommits, so a bare git-mv reparent isn't a change; off-history codeSha → stale
|
|
23
|
+
function scenarioMoved(hidx: HistoryIndex, pos: Map<string, number>, sinceSha: string, yatsuPath: string): boolean {
|
|
24
|
+
const sp = pos.get(sinceSha)
|
|
25
|
+
if (sp === undefined) return true
|
|
26
|
+
return rowsFor(hidx, yatsuPath).some((v) => { const p = pos.get(v.hash); return p !== undefined && p < sp })
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export function staleAxes(
|
|
30
|
+
reading: Reading,
|
|
31
|
+
codeFiles: string[],
|
|
32
|
+
yatsuPath: string,
|
|
33
|
+
didx: DriftIndex,
|
|
34
|
+
hidx: HistoryIndex,
|
|
35
|
+
): StaleAxis[] {
|
|
36
|
+
const axes: StaleAxis[] = []
|
|
37
|
+
if (codeFiles.some((f) => changedSince(didx, reading.codeSha, f))) axes.push('code')
|
|
38
|
+
if (scenarioMoved(hidx, didx.pos, reading.codeSha, yatsuPath)) axes.push('scenario')
|
|
39
|
+
if (isEvaluatorStale(reading.evaluator)) axes.push('evaluator')
|
|
40
|
+
return axes
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export function isStale(
|
|
44
|
+
reading: Reading,
|
|
45
|
+
codeFiles: string[],
|
|
46
|
+
yatsuPath: string,
|
|
47
|
+
didx: DriftIndex,
|
|
48
|
+
hidx: HistoryIndex,
|
|
49
|
+
): boolean {
|
|
50
|
+
return staleAxes(reading, codeFiles, yatsuPath, didx, hidx).length > 0
|
|
51
|
+
}
|