spexcode 0.2.7 → 0.3.0
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 +56 -44
- package/package.json +3 -3
- package/spec-cli/bin/spex.mjs +2 -2
- package/spec-cli/hooks/dispatch.sh +1 -1
- package/spec-cli/hooks/harness.sh +26 -6
- package/spec-cli/src/attach.ts +2 -2
- package/spec-cli/src/cli.ts +695 -536
- package/spec-cli/src/client.ts +31 -30
- package/spec-cli/src/contract-filter.ts +1 -1
- package/spec-cli/src/doctor.ts +40 -13
- package/spec-cli/src/gateway.ts +11 -7
- package/spec-cli/src/git.ts +2 -2
- package/spec-cli/src/{board.ts → graph.ts} +44 -14
- package/spec-cli/src/{boardCache.ts → graphCache.ts} +41 -21
- package/spec-cli/src/{boardDelta.ts → graphDelta.ts} +1 -1
- package/spec-cli/src/graphStream.ts +288 -0
- package/spec-cli/src/guide.ts +123 -96
- package/spec-cli/src/harness-select.ts +2 -2
- package/spec-cli/src/harness.ts +30 -14
- package/spec-cli/src/help.ts +289 -384
- package/spec-cli/src/hooks.ts +1 -1
- package/spec-cli/src/index.ts +130 -103
- package/spec-cli/src/init.ts +9 -9
- package/spec-cli/src/issues.ts +89 -33
- package/spec-cli/src/layout.ts +5 -5
- package/spec-cli/src/lint.ts +73 -8
- package/spec-cli/src/localIssues.ts +42 -60
- package/spec-cli/src/materialize.ts +1 -1
- package/spec-cli/src/mentions.ts +15 -15
- package/spec-cli/src/migrate-table.ts +397 -0
- package/spec-cli/src/migrate.ts +386 -0
- package/spec-cli/src/ranker.ts +30 -4
- package/spec-cli/src/reaper.ts +117 -0
- package/spec-cli/src/search.bench.mjs +10 -10
- package/spec-cli/src/search.ts +1 -1
- package/spec-cli/src/sessions.ts +244 -138
- package/spec-cli/src/specs.ts +25 -15
- package/spec-cli/src/supervise.ts +2 -2
- package/spec-cli/src/tree.ts +5 -5
- package/spec-cli/src/uninstall.ts +4 -4
- package/spec-cli/templates/hooks/post-checkout +1 -1
- package/spec-cli/templates/hooks/post-merge +3 -3
- package/spec-cli/templates/hooks/pre-commit +9 -9
- package/spec-cli/templates/hooks/prepare-commit-msg +1 -1
- package/spec-cli/templates/spec/project/{.config → .plugins}/core/idle/idle.sh +2 -2
- package/spec-cli/templates/spec/project/{.config → .plugins}/core/mark-active/mark-active.sh +7 -0
- package/spec-cli/templates/spec/project/{.config → .plugins}/core/mark-active/spec.md +2 -0
- package/spec-cli/templates/spec/project/{.config → .plugins}/core/session-fail/fail.sh +1 -1
- package/spec-cli/templates/spec/project/{.config → .plugins}/core/spec-first/spec-first.sh +1 -1
- package/spec-cli/templates/spec/project/{.config → .plugins}/core/spec-of-file/spec-of-file.sh +2 -2
- package/spec-cli/templates/spec/project/.plugins/core/spec.md +21 -0
- package/spec-cli/templates/spec/project/{.config → .plugins}/core/stop-gate/stop-gate.sh +21 -21
- package/spec-cli/templates/spec/project/{.config → .plugins}/distill/spec.md +2 -2
- package/spec-cli/templates/spec/project/{.config → .plugins}/extract/spec.md +4 -4
- package/spec-cli/templates/spec/project/{.config → .plugins}/forge-link/spec.md +1 -1
- package/spec-cli/templates/spec/project/{.config → .plugins}/regroup/spec.md +2 -2
- package/spec-cli/templates/spec/project/{.config → .plugins}/reproduce-before-fix/spec.md +3 -3
- package/spec-cli/templates/spec/project/{.config → .plugins}/spec.md +4 -4
- package/spec-cli/templates/spec/project/.plugins/supervisor/spec.md +8 -0
- package/spec-cli/templates/spec/project/{.config → .plugins}/tidy/spec.md +1 -1
- package/spec-cli/templates/spec/project/spec.md +1 -1
- package/spec-dashboard/dist/assets/{Dashboard-P0B9ukSG.js → Dashboard-C7Bzsv86.js} +9 -9
- package/spec-dashboard/dist/assets/EvalsPage-DKZZIdHq.js +2 -0
- package/spec-dashboard/dist/assets/{FoldToggle-BuQ0lokE.js → FoldToggle-D5iB4Ac2.js} +1 -1
- package/spec-dashboard/dist/assets/{IssuesPage-H-D8aHEl.js → IssuesPage-CMFTsQhg.js} +1 -1
- package/spec-dashboard/dist/assets/{MobileApp-oZXIeCPb.js → MobileApp-DwuTKgdP.js} +1 -1
- package/spec-dashboard/dist/assets/{SessionInterface-Blr_MEdU.js → SessionInterface-CBS5_cmK.js} +3 -3
- package/spec-dashboard/dist/assets/{SessionWindow-LcCzBMU7.js → SessionWindow-CqAnjWfI.js} +2 -2
- package/spec-dashboard/dist/assets/{Settings-_yOye-In.js → Settings-BW5f0OaW.js} +1 -1
- package/spec-dashboard/dist/assets/{index-uGs9v_9o.css → index-Cc26X4ce.css} +1 -1
- package/spec-dashboard/dist/assets/{index-BhIslAau.js → index-Ce0wDyQS.js} +8 -8
- package/spec-dashboard/dist/index.html +2 -2
- package/{spec-yatsu → spec-eval}/src/cache.ts +8 -5
- package/{spec-yatsu → spec-eval}/src/cli.ts +164 -95
- package/{spec-yatsu → spec-eval}/src/evaltab.ts +24 -24
- package/{spec-yatsu → spec-eval}/src/filing.ts +7 -5
- package/{spec-yatsu → spec-eval}/src/freshness.ts +50 -24
- package/{spec-yatsu → spec-eval}/src/scenariofresh.ts +22 -17
- package/{spec-yatsu/src/yatsu.ts → spec-eval/src/scenarios.ts} +41 -26
- package/{spec-yatsu/src/proof.ts → spec-eval/src/sessioneval.ts} +59 -59
- package/{spec-yatsu → spec-eval}/src/sidecar.ts +7 -1
- package/{spec-yatsu → spec-eval}/src/timeline.ts +1 -1
- package/spec-forge/src/__fixtures__/github-forge.json +9 -9
- package/spec-forge/src/cli.ts +14 -13
- package/spec-forge/src/{needs-yatsu-eval.ts → needs-eval.ts} +6 -6
- package/spec-cli/src/boardStream.ts +0 -179
- package/spec-cli/templates/spec/project/.config/core/spec.md +0 -17
- package/spec-cli/templates/spec/project/.config/supervisor/spec.md +0 -8
- package/spec-dashboard/dist/assets/EvalsPage-BrvAGyc4.js +0 -2
- /package/spec-cli/templates/presets/careful/{.config → .plugins}/clarify-before-code/spec.md +0 -0
- /package/spec-cli/templates/spec/project/{.config → .plugins}/core/idle/spec.md +0 -0
- /package/spec-cli/templates/spec/project/{.config → .plugins}/core/session-fail/spec.md +0 -0
- /package/spec-cli/templates/spec/project/{.config → .plugins}/core/spec-first/spec.md +0 -0
- /package/spec-cli/templates/spec/project/{.config → .plugins}/core/spec-of-file/spec.md +0 -0
- /package/spec-cli/templates/spec/project/{.config → .plugins}/core/stop-gate/spec.md +0 -0
- /package/spec-cli/templates/spec/project/{.config → .plugins}/distill/digest.mjs +0 -0
- /package/spec-cli/templates/spec/project/{.config → .plugins}/memory-hygiene/spec.md +0 -0
|
@@ -10,9 +10,9 @@ import { isUiPath } from './cli.js'
|
|
|
10
10
|
|
|
11
11
|
type ScoreState = 'pass' | 'fail' | 'stalePass' | 'staleFail' | 'empty' | null
|
|
12
12
|
|
|
13
|
-
// one
|
|
13
|
+
// one eval reading rendered for the export: the latest measurement of one scenario, with its evidence
|
|
14
14
|
// resolved to inline bytes (an image data-URI, or transcript text) so the document is self-contained.
|
|
15
|
-
export type
|
|
15
|
+
export type ExportReading = {
|
|
16
16
|
scenario: string
|
|
17
17
|
expected: string
|
|
18
18
|
verdict?: EvalEntry['verdict']
|
|
@@ -32,7 +32,7 @@ export type ProofReading = {
|
|
|
32
32
|
}
|
|
33
33
|
|
|
34
34
|
// patch ''/old·new null = nothing to show (added → no old, deleted → no new), past the enrichment cap (omitted), or too large (truncated)
|
|
35
|
-
export type
|
|
35
|
+
export type ExportFile = ReviewDiffFile & {
|
|
36
36
|
patch: string
|
|
37
37
|
oldText: string | null
|
|
38
38
|
newText: string | null
|
|
@@ -41,24 +41,24 @@ export type ProofFile = ReviewDiffFile & {
|
|
|
41
41
|
}
|
|
42
42
|
|
|
43
43
|
// one changed spec node: its diff slice (the files this node owns that the session touched) joined with its
|
|
44
|
-
// measured loss (latest reading per scenario). A frontend node with no
|
|
45
|
-
export type
|
|
44
|
+
// measured loss (latest reading per scenario). A frontend node with no eval.md is an honest blind spot.
|
|
45
|
+
export type ExportNode = {
|
|
46
46
|
id: string
|
|
47
47
|
title: string
|
|
48
48
|
hue: number
|
|
49
49
|
desc: string
|
|
50
|
-
files:
|
|
50
|
+
files: ExportFile[]
|
|
51
51
|
additions: number
|
|
52
52
|
deletions: number
|
|
53
|
-
|
|
53
|
+
hasEvalFile: boolean
|
|
54
54
|
uncoveredFrontend: boolean
|
|
55
55
|
score: ScoreState
|
|
56
|
-
readings:
|
|
56
|
+
readings: ExportReading[]
|
|
57
57
|
}
|
|
58
58
|
|
|
59
|
-
export type
|
|
59
|
+
export type ExportGate = { label: string; ok: boolean; detail: string }
|
|
60
60
|
|
|
61
|
-
export type
|
|
61
|
+
export type ExportModel = {
|
|
62
62
|
id: string
|
|
63
63
|
node: string | null
|
|
64
64
|
branch: string | null
|
|
@@ -66,14 +66,14 @@ export type ProofModel = {
|
|
|
66
66
|
generatedAt: string
|
|
67
67
|
ahead: number
|
|
68
68
|
dirtyNonRuntime: number
|
|
69
|
-
gates:
|
|
70
|
-
score: { passed: number; total: number; fresh: number } // the
|
|
71
|
-
nodes:
|
|
72
|
-
otherFiles:
|
|
69
|
+
gates: ExportGate[]
|
|
70
|
+
score: { passed: number; total: number; fresh: number } // the eval summary across all nodes
|
|
71
|
+
nodes: ExportNode[]
|
|
72
|
+
otherFiles: ExportFile[] // changed files no spec node claims
|
|
73
73
|
}
|
|
74
74
|
|
|
75
75
|
// null when no session has that id (route → 404).
|
|
76
|
-
export async function
|
|
76
|
+
export async function buildExportModel(id: string): Promise<ExportModel | null> {
|
|
77
77
|
const payload = await reviewPayload(id)
|
|
78
78
|
if (!payload) return null
|
|
79
79
|
const specs = await loadSpecs()
|
|
@@ -89,7 +89,7 @@ export async function buildProofModel(id: string): Promise<ProofModel | null> {
|
|
|
89
89
|
// extra fetch. Capped at MAX_ENRICHED_FILES so a huge changeset can't bloat the page; the rest keep their
|
|
90
90
|
// row but say so (omitted), never silently blank.
|
|
91
91
|
const base = wtPath ? (await gitA(['-C', wtPath, 'merge-base', mainBranch(), 'HEAD'])).trim() : ''
|
|
92
|
-
const enriched = new Map<string,
|
|
92
|
+
const enriched = new Map<string, ExportFile>()
|
|
93
93
|
let budget = MAX_ENRICHED_FILES
|
|
94
94
|
for (const f of payload.diff) {
|
|
95
95
|
if (wtPath && base && budget > 0) { enriched.set(f.path, await enrichFile(wtPath, base, f)); budget-- }
|
|
@@ -97,8 +97,8 @@ export async function buildProofModel(id: string): Promise<ProofModel | null> {
|
|
|
97
97
|
}
|
|
98
98
|
|
|
99
99
|
// group the session's real changes (merge-base diff) by the spec node that owns each file.
|
|
100
|
-
const byNode = new Map<string,
|
|
101
|
-
const otherFiles:
|
|
100
|
+
const byNode = new Map<string, ExportFile[]>()
|
|
101
|
+
const otherFiles: ExportFile[] = []
|
|
102
102
|
for (const f of payload.diff) {
|
|
103
103
|
const nid = nodeForFile(f.path, specs, payload.node)
|
|
104
104
|
const pf = enriched.get(f.path)!
|
|
@@ -108,13 +108,13 @@ export async function buildProofModel(id: string): Promise<ProofModel | null> {
|
|
|
108
108
|
// the session's primary node always appears, even if it has no file in the diff yet.
|
|
109
109
|
if (payload.node && specById.has(payload.node) && !byNode.has(payload.node)) byNode.set(payload.node, [])
|
|
110
110
|
|
|
111
|
-
const nodes:
|
|
111
|
+
const nodes: ExportNode[] = []
|
|
112
112
|
let passed = 0, total = 0, fresh = 0
|
|
113
113
|
for (const [nid, files] of byNode) {
|
|
114
114
|
const spec = specById.get(nid)
|
|
115
115
|
const tl = await evalTimeline(nid, ctx)
|
|
116
116
|
const latest = declaredLatest(tl)
|
|
117
|
-
const readings = await Promise.all(latest.map(
|
|
117
|
+
const readings = await Promise.all(latest.map(toExportReading))
|
|
118
118
|
for (const r of latest) {
|
|
119
119
|
total++
|
|
120
120
|
if (r.fresh) fresh++
|
|
@@ -128,9 +128,9 @@ export async function buildProofModel(id: string): Promise<ProofModel | null> {
|
|
|
128
128
|
files,
|
|
129
129
|
additions: files.reduce((a, f) => a + f.additions, 0),
|
|
130
130
|
deletions: files.reduce((a, f) => a + f.deletions, 0),
|
|
131
|
-
|
|
132
|
-
uncoveredFrontend: !tl.
|
|
133
|
-
score: nodeScore(tl.
|
|
131
|
+
hasEvalFile: tl.hasEvalFile,
|
|
132
|
+
uncoveredFrontend: !tl.hasEvalFile && (spec?.code ?? []).some(isUiPath),
|
|
133
|
+
score: nodeScore(tl.hasEvalFile, latest),
|
|
134
134
|
readings,
|
|
135
135
|
})
|
|
136
136
|
}
|
|
@@ -156,8 +156,8 @@ export async function buildProofModel(id: string): Promise<ProofModel | null> {
|
|
|
156
156
|
}
|
|
157
157
|
}
|
|
158
158
|
|
|
159
|
-
// the gate checklist, derived from the cockpit payload's gates (the SAME numbers `spex review` prints).
|
|
160
|
-
function gateRows(p: NonNullable<Awaited<ReturnType<typeof reviewPayload>>>):
|
|
159
|
+
// the gate checklist, derived from the cockpit payload's gates (the SAME numbers `spex session review` prints).
|
|
160
|
+
function gateRows(p: NonNullable<Awaited<ReturnType<typeof reviewPayload>>>): ExportGate[] {
|
|
161
161
|
const g = p.gates
|
|
162
162
|
return [
|
|
163
163
|
{ label: 'lint', ok: g.lint.errorCount === 0, detail: `${g.lint.errorCount} error(s), ${g.lint.warningCount} warning(s)` },
|
|
@@ -170,7 +170,7 @@ function gateRows(p: NonNullable<Awaited<ReturnType<typeof reviewPayload>>>): Pr
|
|
|
170
170
|
// gitA returns '' for a missing path — exactly the added/deleted (and best-effort rename) side; each side capped to MAX_FILE_BYTES
|
|
171
171
|
const MAX_ENRICHED_FILES = 60
|
|
172
172
|
const MAX_FILE_BYTES = 200_000
|
|
173
|
-
async function enrichFile(wtPath: string, base: string, f: ReviewDiffFile): Promise<
|
|
173
|
+
async function enrichFile(wtPath: string, base: string, f: ReviewDiffFile): Promise<ExportFile> {
|
|
174
174
|
const run = (args: string[]) => gitA(['-C', wtPath, '-c', 'core.quotePath=false', ...args])
|
|
175
175
|
const [patchRaw, oldRaw, newRaw] = await Promise.all([
|
|
176
176
|
run(['diff', '-M', `${base}..HEAD`, '--', f.path]),
|
|
@@ -190,9 +190,9 @@ async function enrichFile(wtPath: string, base: string, f: ReviewDiffFile): Prom
|
|
|
190
190
|
}
|
|
191
191
|
|
|
192
192
|
// resolve a reading's evidence to inline bytes so the proof is a self-contained file: an image → a base64
|
|
193
|
-
// data-URI; a transcript → its text; the cache miss / no-capture states pass through.
|
|
193
|
+
// data-URI; a transcript → its text; the cache miss / no-capture states pass through. the eval cache owns the bytes
|
|
194
194
|
// (the content-addressed cache) — the proof only asks for them by hash.
|
|
195
|
-
async function
|
|
195
|
+
async function toExportReading(r: EvalEntry): Promise<ExportReading> {
|
|
196
196
|
const base = {
|
|
197
197
|
scenario: r.scenario, expected: r.expected, verdict: r.verdict, fresh: r.fresh,
|
|
198
198
|
staleAxes: r.staleAxes, score: readingScore(r), evaluator: r.evaluator, ts: r.ts,
|
|
@@ -223,7 +223,7 @@ function latestPerScenario(readings: EvalEntry[]): EvalEntry[] {
|
|
|
223
223
|
|
|
224
224
|
// the DECLARED scenarios' latest reading — the SAME declared-bounded computation every other eval face reads
|
|
225
225
|
// (score.jsx's scenarioStates for the node badge and the eval tab). A reading whose scenario is no longer in
|
|
226
|
-
//
|
|
226
|
+
// eval.md is residual: the append-only sidecar still carries it, but it is not current loss, so it must not
|
|
227
227
|
// become a proof reading card, a passed/total tick, or a node score. The proof was the one face driven by the
|
|
228
228
|
// readings that happen to exist rather than the scenarios that are declared — this bounds it like the rest, so
|
|
229
229
|
// a retired scenario's stale reading can't make the proof disagree with the dashboard (phantom card, off ribbon).
|
|
@@ -246,8 +246,8 @@ function readingScore(r: EvalEntry): ScoreState {
|
|
|
246
246
|
|
|
247
247
|
// worst-first aggregate over the latest reading per scenario: any fresh fail → fail; else any stale → grey
|
|
248
248
|
// (✗ if any stale last-failed, else ✓); else any unscored scenario → empty; else every scenario fresh-passes.
|
|
249
|
-
export function nodeScore(
|
|
250
|
-
if (!
|
|
249
|
+
export function nodeScore(hasEvalFile: boolean, latest: EvalEntry[]): ScoreState {
|
|
250
|
+
if (!hasEvalFile) return null
|
|
251
251
|
if (!latest.length) return 'empty'
|
|
252
252
|
if (latest.some((r) => r.fresh && verdictMark(r) === 'cross')) return 'fail'
|
|
253
253
|
const stale = latest.filter((r) => !r.fresh && verdictMark(r))
|
|
@@ -258,9 +258,9 @@ export function nodeScore(hasYatsu: boolean, latest: EvalEntry[]): ScoreState {
|
|
|
258
258
|
|
|
259
259
|
// ---- file → node mapping ----
|
|
260
260
|
|
|
261
|
-
// which spec node owns a changed file: a file inside a node's directory (its spec.md /
|
|
261
|
+
// which spec node owns a changed file: a file inside a node's directory (its spec.md / eval.md / sidecar)
|
|
262
262
|
// belongs to the NEAREST such node; otherwise the node whose governed `code:` claims it (exact path,
|
|
263
|
-
// directory prefix, or `*` glob — the same matching `spex
|
|
263
|
+
// directory prefix, or `*` glob — the same matching `spex eval lint --changed` uses). A shared file is
|
|
264
264
|
// governed by MANY nodes (ordinary composition); when the session has a primary node that also governs it,
|
|
265
265
|
// attribute it THERE so a node/<id> session's stake in cli.ts/index.ts groups under its own node, not
|
|
266
266
|
// whichever sibling sorts first.
|
|
@@ -324,7 +324,7 @@ function verdictBadge(v: EvalEntry['verdict']): string {
|
|
|
324
324
|
return `<span class="verdict note" title="${esc(v.note ?? '')}">≈ note</span>`
|
|
325
325
|
}
|
|
326
326
|
|
|
327
|
-
function renderReading(r:
|
|
327
|
+
function renderReading(r: ExportReading): string {
|
|
328
328
|
const ev = r.evidence
|
|
329
329
|
const body =
|
|
330
330
|
ev.kind === 'image' ? `<img class="shot" src="${ev.dataUri}" alt="${esc(r.scenario)}">`
|
|
@@ -333,9 +333,9 @@ function renderReading(r: ProofReading): string {
|
|
|
333
333
|
: ev.kind === 'data' ? `<pre class="transcript data">${esc(ev.text)}</pre>`
|
|
334
334
|
: ev.kind === 'miss' ? `<div class="noev">⌀ miss original file — the evidence bytes were pruned</div>`
|
|
335
335
|
: `<div class="noev">attested without a capture</div>`
|
|
336
|
-
const stale = r.fresh ? '' : `<span class="stale" title="${esc(r.staleAxes.join(', '))} changed since
|
|
336
|
+
const stale = r.fresh ? '' : `<span class="stale" title="${esc(r.staleAxes.join(', '))} changed since this eval">stale</span>`
|
|
337
337
|
const note = r.verdict?.note ? `<div class="rnote"><b>note</b> ${esc(r.verdict.note)}</div>` : ''
|
|
338
|
-
return `<div class="
|
|
338
|
+
return `<div class="eval-entry">
|
|
339
339
|
<div class="rhead">
|
|
340
340
|
${scoreBadge(r.score, r.fresh ? undefined : `stale: ${r.staleAxes.join(', ')}`)}
|
|
341
341
|
<span class="scenario">${esc(r.scenario)}</span>
|
|
@@ -359,7 +359,7 @@ function renderPatch(patch: string): string {
|
|
|
359
359
|
}).join('')
|
|
360
360
|
}
|
|
361
361
|
|
|
362
|
-
function renderFile(f:
|
|
362
|
+
function renderFile(f: ExportFile): string {
|
|
363
363
|
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>`
|
|
364
364
|
if (f.omitted) return `<div class="file flat"><div class="frow">${row}<span class="note">diff omitted — large changeset</span></div></div>`
|
|
365
365
|
if (!f.patch && f.oldText == null && f.newText == null) return `<div class="file flat"><div class="frow">${row}</div></div>`
|
|
@@ -372,14 +372,14 @@ function renderFile(f: ProofFile): string {
|
|
|
372
372
|
<div class="file-body">${f.patch ? `<div class="patch">${renderPatch(f.patch)}</div>` : '<div class="note">no textual diff</div>'}${cmp}</div></details>`
|
|
373
373
|
}
|
|
374
374
|
|
|
375
|
-
function renderNode(n:
|
|
375
|
+
function renderNode(n: ExportNode): string {
|
|
376
376
|
const stat = `<span class="diffstat"><span class="add">+${n.additions}</span> <span class="del">−${n.deletions}</span> · ${n.files.length} file(s)</span>`
|
|
377
377
|
const fileList = n.files.length ? `<div class="files">${n.files.map(renderFile).join('')}</div>` : ''
|
|
378
378
|
let proof: string
|
|
379
379
|
if (n.readings.length) proof = n.readings.map(renderReading).join('')
|
|
380
|
-
else if (n.uncoveredFrontend) proof = `<div class="blindspot">⚠ a frontend node with no
|
|
381
|
-
else if (n.
|
|
382
|
-
else proof = `<div class="noev">no measurable surface (no
|
|
380
|
+
else if (n.uncoveredFrontend) proof = `<div class="blindspot">⚠ a frontend node with no eval.md — its loss is unmeasured. Give it a scenario so this change can be verified.</div>`
|
|
381
|
+
else if (n.hasEvalFile) proof = `<div class="blindspot">declares scenarios but has no eval yet — measure with <code>spex eval add ${esc(n.id)}</code></div>`
|
|
382
|
+
else proof = `<div class="noev">no measurable surface (no eval.md)</div>`
|
|
383
383
|
return `<article class="node" style="--hue:${n.hue}">
|
|
384
384
|
<div class="nhead">
|
|
385
385
|
<span class="huedot"></span>
|
|
@@ -390,15 +390,15 @@ function renderNode(n: ProofNode): string {
|
|
|
390
390
|
</div>
|
|
391
391
|
${n.desc ? `<div class="ndesc">${esc(n.desc)}</div>` : ''}
|
|
392
392
|
${fileList}
|
|
393
|
-
<div class="
|
|
393
|
+
<div class="eval-list">${proof}</div>
|
|
394
394
|
</article>`
|
|
395
395
|
}
|
|
396
396
|
|
|
397
|
-
export function
|
|
397
|
+
export function renderExportHtml(m: ExportModel): string {
|
|
398
398
|
const idShort = m.id.slice(0, 8)
|
|
399
399
|
const ribbon = [
|
|
400
400
|
...m.gates.map((g) => `<span class="chip ${g.ok ? 'ok' : 'bad'}" title="${esc(g.detail)}">${g.ok ? '✓' : '✗'} ${esc(g.label)}</span>`),
|
|
401
|
-
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
|
|
401
|
+
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 evals filed on the changed nodes">★ no measured loss</span>`,
|
|
402
402
|
].join('')
|
|
403
403
|
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('')
|
|
404
404
|
const otherBlock = m.otherFiles.length
|
|
@@ -409,12 +409,12 @@ export function renderProofHtml(m: ProofModel): string {
|
|
|
409
409
|
<html lang="en"><head>
|
|
410
410
|
<meta charset="utf-8">
|
|
411
411
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
412
|
-
<title>
|
|
412
|
+
<title>evals · ${esc(m.title)}</title>
|
|
413
413
|
<style>${STYLE}</style>
|
|
414
414
|
</head><body>
|
|
415
|
-
<main class="
|
|
415
|
+
<main class="evals">
|
|
416
416
|
<header class="masthead">
|
|
417
|
-
<div class="eyebrow">SpexCode ·
|
|
417
|
+
<div class="eyebrow">SpexCode · session eval export</div>
|
|
418
418
|
<h1 class="claim">${esc(m.title)}</h1>
|
|
419
419
|
<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>
|
|
420
420
|
<div class="ribbon">${ribbon}</div>
|
|
@@ -428,12 +428,12 @@ export function renderProofHtml(m: ProofModel): string {
|
|
|
428
428
|
<h2>Merge gates</h2>
|
|
429
429
|
<ul class="gates">${gates}</ul>
|
|
430
430
|
</section>
|
|
431
|
-
<footer>Generated by <code>spex
|
|
431
|
+
<footer>Generated by <code>spex eval ls --session ${esc(idShort)} --export</code> — the optimizer's measured loss, presented for the merge decision. A spec is the loss; commits are the optimizer; eval keeps the score.</footer>
|
|
432
432
|
</main>
|
|
433
433
|
</body></html>`
|
|
434
434
|
}
|
|
435
435
|
|
|
436
|
-
// the document's inline stylesheet — dark, matching the board's palette and the
|
|
436
|
+
// the document's inline stylesheet — dark, matching the board's palette and the eval score colours, so the
|
|
437
437
|
// proof reads as one surface with the dashboard. Self-contained (no external font/asset).
|
|
438
438
|
const STYLE = `
|
|
439
439
|
:root{--bg:#0b0e14;--panel:#11161f;--panel2:#0e131b;--ink:#c9d4e3;--dim:#7c899c;--line:#1e2733;--accent:#4cc2a0;--green:#3fb950;--red:#f85149;--grey:#6e7b8c;--amber:#d29922}
|
|
@@ -441,7 +441,7 @@ const STYLE = `
|
|
|
441
441
|
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}
|
|
442
442
|
code,pre,.mono{font-family:ui-monospace,SFMono-Regular,"SF Mono",Menlo,Consolas,monospace}
|
|
443
443
|
code{background:#0006;padding:.05em .35em;border-radius:4px;font-size:.88em;color:#aee1d2}
|
|
444
|
-
.
|
|
444
|
+
.evals{max-width:920px;margin:0 auto;padding:40px 24px 80px}
|
|
445
445
|
.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}
|
|
446
446
|
.eyebrow{font:600 11px/1 ui-monospace,monospace;letter-spacing:.18em;text-transform:uppercase;color:var(--accent)}
|
|
447
447
|
.claim{margin:14px 0 10px;font-size:30px;line-height:1.2;font-weight:700;color:#eef3fa;letter-spacing:-.01em}
|
|
@@ -485,8 +485,8 @@ h2{margin:42px 0 16px;font-size:14px;letter-spacing:.14em;text-transform:upperca
|
|
|
485
485
|
.side-h{font:600 10px/1 ui-monospace,monospace;text-transform:uppercase;letter-spacing:.08em;color:var(--dim);padding:0 0 6px}
|
|
486
486
|
.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}
|
|
487
487
|
.abs{color:var(--dim);font-style:italic}
|
|
488
|
-
.
|
|
489
|
-
.
|
|
488
|
+
.eval-list{margin-top:8px}
|
|
489
|
+
.eval-entry{margin-top:14px;padding:14px 16px;border:1px solid var(--line);border-radius:10px;background:var(--panel2)}
|
|
490
490
|
.rhead{display:flex;align-items:center;gap:10px;flex-wrap:wrap}
|
|
491
491
|
.scenario{font-weight:600;color:#dde7f1}
|
|
492
492
|
.rmeta{margin-left:auto;font:11px/1 ui-monospace,monospace;color:var(--dim)}
|
|
@@ -515,10 +515,10 @@ h2{margin:42px 0 16px;font-size:14px;letter-spacing:.14em;text-transform:upperca
|
|
|
515
515
|
footer{margin-top:48px;padding-top:20px;border-top:1px solid var(--line);color:var(--dim);font-size:12px;text-align:center}
|
|
516
516
|
`
|
|
517
517
|
|
|
518
|
-
// ---- the session EVAL model ([[
|
|
519
|
-
// The lean, TIERED counterpart of
|
|
518
|
+
// ---- the session EVAL model ([[session-eval]]'s interactive face) ----
|
|
519
|
+
// The lean, TIERED counterpart of buildExportModel: the same worktree-rooted marshaling, but rows only —
|
|
520
520
|
// no diff enrichment, no inlined evidence bytes (the dashboard's Eval tab rides the shared eval
|
|
521
|
-
// components: blobs stream lazily from /api/
|
|
521
|
+
// components: blobs stream lazily from /api/evidence on open). Each reading carries `inSession`
|
|
522
522
|
// (this session filed it, or its codeSha is one of the branch's own commits — a diagnostic session
|
|
523
523
|
// that changed no code still owns the readings it filed at the merge-base) so the tab can lead with
|
|
524
524
|
// what THIS session measured, ✦-marked, over the inherited baseline.
|
|
@@ -527,7 +527,7 @@ export type SessionEvalNode = {
|
|
|
527
527
|
title: string
|
|
528
528
|
hue: number
|
|
529
529
|
desc: string
|
|
530
|
-
|
|
530
|
+
hasEvalFile: boolean
|
|
531
531
|
uncoveredFrontend: boolean
|
|
532
532
|
scenarios: { name: string; expected: string; tags?: string[] }[]
|
|
533
533
|
// each reading carries the trunk eval-concern thread for its (node, scenario) ([[remark-teeth]] / directive
|
|
@@ -543,7 +543,7 @@ export type SessionEvals = {
|
|
|
543
543
|
title: string
|
|
544
544
|
ahead: number
|
|
545
545
|
dirtyNonRuntime: number
|
|
546
|
-
gates:
|
|
546
|
+
gates: ExportGate[]
|
|
547
547
|
nodes: SessionEvalNode[]
|
|
548
548
|
}
|
|
549
549
|
|
|
@@ -572,8 +572,8 @@ export async function buildSessionEvals(id: string): Promise<SessionEvals | null
|
|
|
572
572
|
title: spec?.title ?? nid,
|
|
573
573
|
hue: spec?.hue ?? 210,
|
|
574
574
|
desc: spec?.desc ?? '',
|
|
575
|
-
|
|
576
|
-
uncoveredFrontend: !tl.
|
|
575
|
+
hasEvalFile: tl.hasEvalFile,
|
|
576
|
+
uncoveredFrontend: !tl.hasEvalFile && (spec?.code ?? []).some(isUiPath),
|
|
577
577
|
scenarios: tl.scenarios,
|
|
578
578
|
// the per-scenario trunk thread rides each reading as `EvalEntry.thread` (evalTimeline's overlay), so
|
|
579
579
|
// the event detail has the comment/remark track inline — no extra join here. A reading is the
|
|
@@ -27,6 +27,12 @@ export type Evidence = { hash: string; kind: EvidenceKind }
|
|
|
27
27
|
export type Reading = {
|
|
28
28
|
scenario: string
|
|
29
29
|
codeSha: string
|
|
30
|
+
// content hash of the scenario's semantic text (description+expected, normalized — scenarios.ts
|
|
31
|
+
// scenarioHash) as DECLARED at filing time: the record of which contract this measurement was taken
|
|
32
|
+
// against. When present it alone decides the scenario freshness axis (a pure compare against the current
|
|
33
|
+
// declaration's hash); a legacy reading without it is decided by the git-derived rule instead — one
|
|
34
|
+
// track per reading, never both ([[eval-core]]).
|
|
35
|
+
scenarioHash?: string
|
|
30
36
|
evidence?: Evidence[]
|
|
31
37
|
// legacy scalar evidence — read for old readings, never written by new filings.
|
|
32
38
|
blob?: string | null
|
|
@@ -103,7 +109,7 @@ export function applyRetractions(readings: Reading[], retractions: Retraction[])
|
|
|
103
109
|
// the EFFECTIVE readings — what the scoreboard sees: every reading minus the retracted. Every score
|
|
104
110
|
// consumer (freshness, scan, clean's referenced set, the eval tab, the proof) reads through here, so a
|
|
105
111
|
// retract undoes a botched filing on ALL of them at once — the previous reading becomes the latest again,
|
|
106
|
-
// or the scenario honestly returns to
|
|
112
|
+
// or the scenario honestly returns to eval-missing.
|
|
107
113
|
export function readReadings(sidecarPath: string): Reading[] {
|
|
108
114
|
const { readings, retractions } = readSidecar(sidecarPath)
|
|
109
115
|
return applyRetractions(readings, retractions)
|
|
@@ -22,7 +22,7 @@ const V2_EVENT_KEYS = new Set(['at', 'step', 'node'])
|
|
|
22
22
|
const V1_EVENT_KEYS = new Set(['tMs', 'step', 'node'])
|
|
23
23
|
|
|
24
24
|
// validate LOUD — every violation named, [] when well-formed. Both schema versions are accepted: v1 (legacy
|
|
25
|
-
// time axis, `tMs`) and v2 (axis-tagged, `at`). The key set is closed per version (like the
|
|
25
|
+
// time axis, `tMs`) and v2 (axis-tagged, `at`). The key set is closed per version (like the eval.md
|
|
26
26
|
// scenario schema): a malformed timeline is rejected at filing time, never silently reshaped. The `axis`
|
|
27
27
|
// string itself is open — only its ABSENCE is an error, never an unrecognized value.
|
|
28
28
|
export function validateTimeline(raw: unknown): string[] {
|
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
{
|
|
2
|
-
"_note": "Fixture mirroring githubDriver output (the vendor-neutral ForgeIssue[]/ForgePR[] a `gh issue/pr list` collapses to) for a sample repo. Drives the needs-
|
|
2
|
+
"_note": "Fixture mirroring githubDriver output (the vendor-neutral ForgeIssue[]/ForgePR[] a `gh issue/pr list` collapses to) for a sample repo. Drives the needs-eval resolver test with no network.",
|
|
3
3
|
"issues": [
|
|
4
4
|
{
|
|
5
5
|
"number": 1,
|
|
6
6
|
"title": "login redirect is broken on /logout",
|
|
7
|
-
"body": "Steps to repro: click logout, land on a 404.\n\nSpec: sessions\nneeds-
|
|
7
|
+
"body": "Steps to repro: click logout, land on a 404.\n\nSpec: sessions\nneeds-eval\n",
|
|
8
8
|
"url": "https://github.com/acme/repo/issues/1",
|
|
9
9
|
"state": "open",
|
|
10
10
|
"labels": []
|
|
@@ -15,7 +15,7 @@
|
|
|
15
15
|
"body": "The open-issue badge over-counts.\n\nSpec: dashboard-issues",
|
|
16
16
|
"url": "https://github.com/acme/repo/issues/2",
|
|
17
17
|
"state": "open",
|
|
18
|
-
"labels": ["bug", "needs-
|
|
18
|
+
"labels": ["bug", "needs-eval"]
|
|
19
19
|
},
|
|
20
20
|
{
|
|
21
21
|
"number": 3,
|
|
@@ -27,8 +27,8 @@
|
|
|
27
27
|
},
|
|
28
28
|
{
|
|
29
29
|
"number": 4,
|
|
30
|
-
"title": "old
|
|
31
|
-
"body": "Spec:
|
|
30
|
+
"title": "old forge-cache drift already verified",
|
|
31
|
+
"body": "Spec: forge-cache\nNeeds-Eval\n",
|
|
32
32
|
"url": "https://github.com/acme/repo/issues/4",
|
|
33
33
|
"state": "closed",
|
|
34
34
|
"labels": []
|
|
@@ -36,18 +36,18 @@
|
|
|
36
36
|
{
|
|
37
37
|
"number": 5,
|
|
38
38
|
"title": "flagged but names no node",
|
|
39
|
-
"body": "This one is flagged but carries no Spec: marker and no closing PR.\n\nneeds-
|
|
39
|
+
"body": "This one is flagged but carries no Spec: marker and no closing PR.\n\nneeds-eval\n",
|
|
40
40
|
"url": "https://github.com/acme/repo/issues/5",
|
|
41
41
|
"state": "open",
|
|
42
|
-
"labels": ["needs-
|
|
42
|
+
"labels": ["needs-eval"]
|
|
43
43
|
},
|
|
44
44
|
{
|
|
45
45
|
"number": 6,
|
|
46
46
|
"title": "fixed via an open PR, no marker on the issue",
|
|
47
|
-
"body": "A worked bug; the node comes transitively from the closing PR.\n\nneeds-
|
|
47
|
+
"body": "A worked bug; the node comes transitively from the closing PR.\n\nneeds-eval: ignore-the-trailing-text",
|
|
48
48
|
"url": "https://github.com/acme/repo/issues/6",
|
|
49
49
|
"state": "open",
|
|
50
|
-
"labels": ["needs-
|
|
50
|
+
"labels": ["needs-eval"]
|
|
51
51
|
}
|
|
52
52
|
],
|
|
53
53
|
"prs": [
|
package/spec-forge/src/cli.ts
CHANGED
|
@@ -2,7 +2,7 @@ import { loadSpecs } from '../../spec-cli/src/specs.js'
|
|
|
2
2
|
import type { ForgeDriver, ForgeIssue, ForgePR } from './port.js'
|
|
3
3
|
import { FORGE_DRIVERS, forgeDriverFor, resolveForgeHost } from './drivers.js'
|
|
4
4
|
import { resolveLinks, type NodeLinks } from './links.js'
|
|
5
|
-
import { resolveEvalPending, type NodeEvalPending } from './needs-
|
|
5
|
+
import { resolveEvalPending, type NodeEvalPending } from './needs-eval.js'
|
|
6
6
|
|
|
7
7
|
// tiny flag reader over this command's own arg slice (everything after `forge`), so cli.ts stays routing-only.
|
|
8
8
|
function flag(args: string[], name: string): string | undefined {
|
|
@@ -14,10 +14,12 @@ const has = (args: string[], name: string) => args.includes(`--${name}`)
|
|
|
14
14
|
async function readForge(
|
|
15
15
|
args: string[],
|
|
16
16
|
): Promise<{ driver: ForgeDriver; nodeIds: string[]; issues: ForgeIssue[]; prs: ForgePR[] } | null> {
|
|
17
|
-
|
|
17
|
+
// the forge is a VALUE, never a command ([[cli-surface]]): `--store <host>` names it, mirroring the
|
|
18
|
+
// store axis every other issue verb uses.
|
|
19
|
+
const host = flag(args, 'store') ?? resolveForgeHost()
|
|
18
20
|
const driver = forgeDriverFor(host)
|
|
19
21
|
if (!driver) {
|
|
20
|
-
console.error(`
|
|
22
|
+
console.error(`spex issue links: unknown --store '${host}' (known: ${FORGE_DRIVERS.map((d) => d.host).join(', ')})`)
|
|
21
23
|
return null
|
|
22
24
|
}
|
|
23
25
|
const nodeIds = (await loadSpecs()).map((s) => s.id)
|
|
@@ -49,7 +51,7 @@ async function links(args: string[]): Promise<number> {
|
|
|
49
51
|
|
|
50
52
|
const only = flag(args, 'node')
|
|
51
53
|
if (only) {
|
|
52
|
-
if (!nodeIds.includes(only)) { console.error(`
|
|
54
|
+
if (!nodeIds.includes(only)) { console.error(`spex issue links: no such node '${only}'`); return 1 }
|
|
53
55
|
resolved = resolved.filter((n) => n.node === only)
|
|
54
56
|
}
|
|
55
57
|
|
|
@@ -58,7 +60,7 @@ async function links(args: string[]): Promise<number> {
|
|
|
58
60
|
const nPRs = resolved.reduce((a, n) => a + n.prs.length, 0)
|
|
59
61
|
console.log(
|
|
60
62
|
`spec-forge · ${driver.host} · ${resolved.length} linked node(s) · ${nIssues} issue(s), ${nPRs} pr(s)` +
|
|
61
|
-
` ·
|
|
63
|
+
` · traced ${issues.length} issue(s), ${prs.length} pr(s)`,
|
|
62
64
|
)
|
|
63
65
|
if (resolved.length) console.log(render(resolved))
|
|
64
66
|
return 0
|
|
@@ -81,7 +83,7 @@ async function evalPending(args: string[]): Promise<number> {
|
|
|
81
83
|
|
|
82
84
|
const only = flag(args, 'node')
|
|
83
85
|
if (only) {
|
|
84
|
-
if (!nodeIds.includes(only)) { console.error(`
|
|
86
|
+
if (!nodeIds.includes(only)) { console.error(`spex issue links: no such node '${only}'`); return 1 }
|
|
85
87
|
resolved = resolved.filter((n) => n.node === only)
|
|
86
88
|
}
|
|
87
89
|
|
|
@@ -89,16 +91,15 @@ async function evalPending(args: string[]): Promise<number> {
|
|
|
89
91
|
const nPending = resolved.reduce((a, n) => a + n.pending.length, 0)
|
|
90
92
|
console.log(
|
|
91
93
|
`spec-forge · ${driver.host} · ${resolved.length} node(s) with eval pending · ${nPending} issue(s)` +
|
|
92
|
-
` ·
|
|
94
|
+
` · traced ${issues.length} issue(s), ${prs.length} pr(s)`,
|
|
93
95
|
)
|
|
94
96
|
if (resolved.length) console.log(renderPending(resolved))
|
|
95
97
|
return 0
|
|
96
98
|
}
|
|
97
99
|
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
return 2
|
|
100
|
+
// `spex issue links [--pending]` ([[cli-surface]]): the read-only forge→spec trace, folded into the issue
|
|
101
|
+
// drawer (the forge drawer is dissolved — a forge is a value, `--store`). Bare = every linked node's open
|
|
102
|
+
// issues/PRs; --pending narrows to the threads still awaiting an eval reading (the old eval-pending view).
|
|
103
|
+
export async function runIssueLinks(args: string[]): Promise<number> {
|
|
104
|
+
return has(args, 'pending') ? evalPending(args) : links(args)
|
|
104
105
|
}
|
|
@@ -1,13 +1,13 @@
|
|
|
1
1
|
import type { ForgeIssue, ForgePR } from './port.js'
|
|
2
2
|
import { resolveLinks, type LinkedIssue } from './links.js'
|
|
3
3
|
|
|
4
|
-
export const
|
|
4
|
+
export const NEEDS_EVAL = 'needs-eval'
|
|
5
5
|
|
|
6
|
-
// a bare-marker body line: the name alone, case-insensitive, any indent, optional trailing colon — content after it (`needs-
|
|
7
|
-
const BODY_MARKER = new RegExp(`^\\s*${
|
|
6
|
+
// a bare-marker body line: the name alone, case-insensitive, any indent, optional trailing colon — content after it (`needs-eval: foo`) is NOT a match
|
|
7
|
+
const BODY_MARKER = new RegExp(`^\\s*${NEEDS_EVAL}\\s*:?\\s*$`, 'im')
|
|
8
8
|
|
|
9
|
-
export function
|
|
10
|
-
if (issue.labels.some((l) => l.trim().toLowerCase() ===
|
|
9
|
+
export function isNeedsEval(issue: ForgeIssue): boolean {
|
|
10
|
+
if (issue.labels.some((l) => l.trim().toLowerCase() === NEEDS_EVAL)) return true
|
|
11
11
|
return BODY_MARKER.test(issue.body || '')
|
|
12
12
|
}
|
|
13
13
|
|
|
@@ -19,7 +19,7 @@ export function resolveEvalPending(
|
|
|
19
19
|
prs: ForgePR[],
|
|
20
20
|
nodeIds: string[],
|
|
21
21
|
): NodeEvalPending[] {
|
|
22
|
-
const flagged = new Set(issues.filter(
|
|
22
|
+
const flagged = new Set(issues.filter(isNeedsEval).map((i) => i.number))
|
|
23
23
|
if (!flagged.size) return []
|
|
24
24
|
const out: NodeEvalPending[] = []
|
|
25
25
|
for (const { node, issues: linked } of resolveLinks(issues, prs, nodeIds)) {
|