spexcode 0.5.2 → 0.5.4
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 +1 -1
- package/package.json +1 -1
- package/spec-cli/bin/spex.mjs +25 -1
- package/spec-cli/hooks/dispatch.sh +24 -1
- package/spec-cli/src/anchors.ts +192 -58
- package/spec-cli/src/attach.ts +11 -8
- package/spec-cli/src/claude-headless.ts +13 -52
- package/spec-cli/src/cli.ts +209 -71
- package/spec-cli/src/client.ts +143 -5
- package/spec-cli/src/gateway-hub.ts +2 -6
- package/spec-cli/src/gateway.ts +96 -11
- package/spec-cli/src/git.ts +1395 -96
- package/spec-cli/src/graph.ts +21 -17
- package/spec-cli/src/graphCache.ts +405 -61
- package/spec-cli/src/graphStream.ts +581 -182
- package/spec-cli/src/guide.ts +15 -4
- package/spec-cli/src/harness.ts +1133 -71
- package/spec-cli/src/headless-controller.ts +63 -0
- package/spec-cli/src/help.ts +120 -64
- package/spec-cli/src/host-resources.ts +698 -0
- package/spec-cli/src/index.ts +95 -21
- package/spec-cli/src/init.ts +73 -15
- package/spec-cli/src/issues.ts +36 -3
- package/spec-cli/src/layout.ts +189 -41
- package/spec-cli/src/lint.ts +152 -27
- package/spec-cli/src/localIssues.ts +5 -1
- package/spec-cli/src/maintenance-wrapper.ts +276 -0
- package/spec-cli/src/materialize.ts +6 -7
- package/spec-cli/src/opencode-headless.ts +4 -8
- package/spec-cli/src/pi-headless.ts +17 -60
- package/spec-cli/src/process-identity.ts +174 -0
- package/spec-cli/src/project-store.ts +17 -0
- package/spec-cli/src/pty-bridge.ts +6 -3
- package/spec-cli/src/reviews.ts +17 -13
- package/spec-cli/src/runtime-ownership.ts +88 -0
- package/spec-cli/src/session-maintenance.ts +750 -0
- package/spec-cli/src/session-timeline.ts +6 -6
- package/spec-cli/src/sessions.ts +1251 -208
- package/spec-cli/src/sh.ts +3 -0
- package/spec-cli/src/source-files.ts +27 -2
- package/spec-cli/src/specs.ts +58 -25
- package/spec-cli/src/supervise.ts +31 -11
- package/spec-cli/src/uninstall.ts +7 -6
- package/spec-cli/templates/hooks/commit-msg +14 -0
- package/spec-cli/templates/hooks/pre-commit +29 -15
- package/spec-cli/templates/hooks/prepare-commit-msg +20 -48
- package/spec-cli/templates/hooks/reference-transaction +105 -0
- package/spec-cli/templates/spec/project/.plugins/commands/supervisor/spec.md +1 -1
- package/spec-cli/templates/spec/project/.plugins/core/mark-active/mark-active.sh +23 -21
- package/spec-cli/templates/spec/project/.plugins/core/spec.md +12 -10
- package/spec-cli/templates/spec/project/.plugins/core/stop-gate/stop-gate.sh +15 -6
- package/spec-cli/templates/spec/project/.plugins/prompts/atomic-landing/spec.md +32 -0
- package/spec-dashboard/dist/assets/App-C5vbTw8Q.js +2 -0
- package/spec-dashboard/dist/assets/Dashboard-u8RIS3NY.js +27 -0
- package/spec-dashboard/dist/assets/EvalsPage-BS7ITcNo.js +2 -0
- package/spec-dashboard/dist/assets/IssuesPage-DXbqQFW_.js +1 -0
- package/spec-dashboard/dist/assets/KaTeX_AMS-Regular-BQhdFMY1.woff2 +0 -0
- package/spec-dashboard/dist/assets/KaTeX_AMS-Regular-DMm9YOAa.woff +0 -0
- package/spec-dashboard/dist/assets/KaTeX_AMS-Regular-DRggAlZN.ttf +0 -0
- package/spec-dashboard/dist/assets/KaTeX_Caligraphic-Bold-ATXxdsX0.ttf +0 -0
- package/spec-dashboard/dist/assets/KaTeX_Caligraphic-Bold-BEiXGLvX.woff +0 -0
- package/spec-dashboard/dist/assets/KaTeX_Caligraphic-Bold-Dq_IR9rO.woff2 +0 -0
- package/spec-dashboard/dist/assets/KaTeX_Caligraphic-Regular-CTRA-rTL.woff +0 -0
- package/spec-dashboard/dist/assets/KaTeX_Caligraphic-Regular-Di6jR-x-.woff2 +0 -0
- package/spec-dashboard/dist/assets/KaTeX_Caligraphic-Regular-wX97UBjC.ttf +0 -0
- package/spec-dashboard/dist/assets/KaTeX_Fraktur-Bold-BdnERNNW.ttf +0 -0
- package/spec-dashboard/dist/assets/KaTeX_Fraktur-Bold-BsDP51OF.woff +0 -0
- package/spec-dashboard/dist/assets/KaTeX_Fraktur-Bold-CL6g_b3V.woff2 +0 -0
- package/spec-dashboard/dist/assets/KaTeX_Fraktur-Regular-CB_wures.ttf +0 -0
- package/spec-dashboard/dist/assets/KaTeX_Fraktur-Regular-CTYiF6lA.woff2 +0 -0
- package/spec-dashboard/dist/assets/KaTeX_Fraktur-Regular-Dxdc4cR9.woff +0 -0
- package/spec-dashboard/dist/assets/KaTeX_Main-Bold-Cx986IdX.woff2 +0 -0
- package/spec-dashboard/dist/assets/KaTeX_Main-Bold-Jm3AIy58.woff +0 -0
- package/spec-dashboard/dist/assets/KaTeX_Main-Bold-waoOVXN0.ttf +0 -0
- package/spec-dashboard/dist/assets/KaTeX_Main-BoldItalic-DxDJ3AOS.woff2 +0 -0
- package/spec-dashboard/dist/assets/KaTeX_Main-BoldItalic-DzxPMmG6.ttf +0 -0
- package/spec-dashboard/dist/assets/KaTeX_Main-BoldItalic-SpSLRI95.woff +0 -0
- package/spec-dashboard/dist/assets/KaTeX_Main-Italic-3WenGoN9.ttf +0 -0
- package/spec-dashboard/dist/assets/KaTeX_Main-Italic-BMLOBm91.woff +0 -0
- package/spec-dashboard/dist/assets/KaTeX_Main-Italic-NWA7e6Wa.woff2 +0 -0
- package/spec-dashboard/dist/assets/KaTeX_Main-Regular-B22Nviop.woff2 +0 -0
- package/spec-dashboard/dist/assets/KaTeX_Main-Regular-Dr94JaBh.woff +0 -0
- package/spec-dashboard/dist/assets/KaTeX_Main-Regular-ypZvNtVU.ttf +0 -0
- package/spec-dashboard/dist/assets/KaTeX_Math-BoldItalic-B3XSjfu4.ttf +0 -0
- package/spec-dashboard/dist/assets/KaTeX_Math-BoldItalic-CZnvNsCZ.woff2 +0 -0
- package/spec-dashboard/dist/assets/KaTeX_Math-BoldItalic-iY-2wyZ7.woff +0 -0
- package/spec-dashboard/dist/assets/KaTeX_Math-Italic-DA0__PXp.woff +0 -0
- package/spec-dashboard/dist/assets/KaTeX_Math-Italic-flOr_0UB.ttf +0 -0
- package/spec-dashboard/dist/assets/KaTeX_Math-Italic-t53AETM-.woff2 +0 -0
- package/spec-dashboard/dist/assets/KaTeX_SansSerif-Bold-CFMepnvq.ttf +0 -0
- package/spec-dashboard/dist/assets/KaTeX_SansSerif-Bold-D1sUS0GD.woff2 +0 -0
- package/spec-dashboard/dist/assets/KaTeX_SansSerif-Bold-DbIhKOiC.woff +0 -0
- package/spec-dashboard/dist/assets/KaTeX_SansSerif-Italic-C3H0VqGB.woff2 +0 -0
- package/spec-dashboard/dist/assets/KaTeX_SansSerif-Italic-DN2j7dab.woff +0 -0
- package/spec-dashboard/dist/assets/KaTeX_SansSerif-Italic-YYjJ1zSn.ttf +0 -0
- package/spec-dashboard/dist/assets/KaTeX_SansSerif-Regular-BNo7hRIc.ttf +0 -0
- package/spec-dashboard/dist/assets/KaTeX_SansSerif-Regular-CS6fqUqJ.woff +0 -0
- package/spec-dashboard/dist/assets/KaTeX_SansSerif-Regular-DDBCnlJ7.woff2 +0 -0
- package/spec-dashboard/dist/assets/KaTeX_Script-Regular-C5JkGWo-.ttf +0 -0
- package/spec-dashboard/dist/assets/KaTeX_Script-Regular-D3wIWfF6.woff2 +0 -0
- package/spec-dashboard/dist/assets/KaTeX_Script-Regular-D5yQViql.woff +0 -0
- package/spec-dashboard/dist/assets/KaTeX_Size1-Regular-C195tn64.woff +0 -0
- package/spec-dashboard/dist/assets/KaTeX_Size1-Regular-Dbsnue_I.ttf +0 -0
- package/spec-dashboard/dist/assets/KaTeX_Size1-Regular-mCD8mA8B.woff2 +0 -0
- package/spec-dashboard/dist/assets/KaTeX_Size2-Regular-B7gKUWhC.ttf +0 -0
- package/spec-dashboard/dist/assets/KaTeX_Size2-Regular-Dy4dx90m.woff2 +0 -0
- package/spec-dashboard/dist/assets/KaTeX_Size2-Regular-oD1tc_U0.woff +0 -0
- package/spec-dashboard/dist/assets/KaTeX_Size3-Regular-CTq5MqoE.woff +0 -0
- package/spec-dashboard/dist/assets/KaTeX_Size3-Regular-DgpXs0kz.ttf +0 -0
- package/spec-dashboard/dist/assets/KaTeX_Size4-Regular-BF-4gkZK.woff +0 -0
- package/spec-dashboard/dist/assets/KaTeX_Size4-Regular-DWFBv043.ttf +0 -0
- package/spec-dashboard/dist/assets/KaTeX_Size4-Regular-Dl5lxZxV.woff2 +0 -0
- package/spec-dashboard/dist/assets/KaTeX_Typewriter-Regular-C0xS9mPB.woff +0 -0
- package/spec-dashboard/dist/assets/KaTeX_Typewriter-Regular-CO6r4hn1.woff2 +0 -0
- package/spec-dashboard/dist/assets/KaTeX_Typewriter-Regular-D3Ib7_Hf.ttf +0 -0
- package/spec-dashboard/dist/assets/MobileApp-DVLnk9hz.js +2 -0
- package/spec-dashboard/dist/assets/Modal-6mHq6fbZ.js +1 -0
- package/spec-dashboard/dist/assets/{PageScroll-CadAKuSy.js → PageScroll-CAY4S4g4.js} +1 -1
- package/spec-dashboard/dist/assets/ProjectsPage-UQyzsTWN.js +1 -0
- package/spec-dashboard/dist/assets/SessionInterface-DKU4c1Z-.js +39 -0
- package/spec-dashboard/dist/assets/SessionWindow-zGwJaGbR.js +1 -0
- package/spec-dashboard/dist/assets/Settings-igR17pns.js +1 -0
- package/spec-dashboard/dist/assets/Thread-B-ZUarN1.js +13 -0
- package/spec-dashboard/dist/assets/TimelineChat-Cp579UoJ.css +1 -0
- package/spec-dashboard/dist/assets/TimelineChat-sc49Qj5d.js +276 -0
- package/spec-dashboard/dist/assets/data-B1ot4PF0.js +1 -0
- package/spec-dashboard/dist/assets/index-BqBNCa1V.js +41 -0
- package/spec-dashboard/dist/assets/index-CzutlTDf.css +1 -0
- package/spec-dashboard/dist/assets/{terminalFont-DmTPNdbU.js → terminalFont-C6oGNKrf.js} +1 -1
- package/spec-dashboard/dist/index.html +5 -4
- package/spec-dashboard/src/reviewFilters.js +46 -8
- package/spec-dashboard/src/session.js +64 -9
- package/spec-eval/src/cli.ts +144 -32
- package/spec-eval/src/evaltab.ts +16 -9
- package/spec-eval/src/freshness.ts +336 -54
- package/spec-eval/src/scenariofresh.ts +51 -1
- package/spec-eval/src/scenarios.ts +324 -25
- package/spec-eval/src/sessioneval.ts +969 -173
- package/spec-cli/src/message-stream.ts +0 -147
- package/spec-dashboard/dist/assets/Dashboard-CTAuTyZ3.js +0 -27
- package/spec-dashboard/dist/assets/EvalsPage-KbMMownG.js +0 -2
- package/spec-dashboard/dist/assets/IssuesPage-DmyLb9Rj.js +0 -1
- package/spec-dashboard/dist/assets/MobileApp-D2RZGt4Z.js +0 -2
- package/spec-dashboard/dist/assets/Modal-3brXUhM0.js +0 -1
- package/spec-dashboard/dist/assets/ProjectsPage-DU3x4Y8l.js +0 -1
- package/spec-dashboard/dist/assets/SessionInterface-BtrzlOPs.js +0 -39
- package/spec-dashboard/dist/assets/SessionWindow-BWH5O0jh.js +0 -13
- package/spec-dashboard/dist/assets/Settings-COgdKTJB.js +0 -1
- package/spec-dashboard/dist/assets/TimelineChat-DQ21GSJK.js +0 -1
- package/spec-dashboard/dist/assets/index-D6HBvKkJ.js +0 -41
- package/spec-dashboard/dist/assets/index-DFdlYy4H.css +0 -1
package/spec-eval/src/cli.ts
CHANGED
|
@@ -1,13 +1,14 @@
|
|
|
1
1
|
import { readFileSync, writeFileSync, existsSync } from 'node:fs'
|
|
2
2
|
import { join, relative, dirname } from 'node:path'
|
|
3
|
-
import { repoRoot, headSha, driftIndex, stagedFiles, git } from '../../spec-cli/src/git.js'
|
|
3
|
+
import { repoRoot, headSha, driftIndex, stagedFiles, git, commitReachable } from '../../spec-cli/src/git.js'
|
|
4
4
|
import { loadSpecs } from '../../spec-cli/src/specs.js'
|
|
5
5
|
import { loadConfig } from '../../spec-cli/src/lint.js'
|
|
6
6
|
import { trackedSourceFiles } from '../../spec-cli/src/source-files.js'
|
|
7
7
|
import { mainBranch, envSessionId, readRawRecord } from '../../spec-cli/src/layout.js'
|
|
8
|
-
import { evalNodes, validateScenarios, resolveEvalNode, scenarioHash, EVAL_FILE, type EvalNode, type ScenarioTestReference } from './scenarios.js'
|
|
8
|
+
import { evalNodes, evalNodesAt, validateScenarios, resolveEvalNode, scenarioCodeAxis, scenarioHash, scenarioProjection, writeScenarioMeasurementMetadata, EVAL_FILE, type EvalNode, type ScenarioTestReference } from './scenarios.js'
|
|
9
9
|
import { readReadings, readSidecar, appendReading, appendRetraction, latestPerScenario, evidenceOf, isJsonBlob, type Reading, type Verdict, type Evidence, type EvidenceKind, type Retraction } from './sidecar.js'
|
|
10
|
-
import { staleAxes, contentProbeFor } from './freshness.js'
|
|
10
|
+
import { staleAxes, contentProbeFor, anchorProbeFor, anchorProblems } from './freshness.js'
|
|
11
|
+
import { parseRelation, relationClaimsPath } from '../../spec-cli/src/anchors.js'
|
|
11
12
|
import { scenarioIndex } from './scenariofresh.js'
|
|
12
13
|
import { loadEvalRemarkTracks, trackKey } from '../../spec-cli/src/issues.js'
|
|
13
14
|
import { stripRefSigil } from '../../spec-cli/src/mentions.js'
|
|
@@ -60,16 +61,31 @@ function currentNodeId(root: string): string | null {
|
|
|
60
61
|
const UI_FILE = /\.(jsx|tsx|vue|svelte|css)$/
|
|
61
62
|
export const isUiPath = (p: string) => UI_FILE.test(p) || p.includes('spec-dashboard/')
|
|
62
63
|
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
64
|
+
type ChangedScope = { base: string; paths: Set<string>; config: string }
|
|
65
|
+
|
|
66
|
+
// Merge-base anchored so main advancing is not counted as this branch's change. Scope construction is
|
|
67
|
+
// indivisible: a missing base/diff is no scope at all, never an empty set that can masquerade as clean.
|
|
68
|
+
function changedScope(root: string): ChangedScope {
|
|
69
|
+
const branch = mainBranch()
|
|
70
|
+
const paths = new Set<string>()
|
|
71
|
+
const add = (path: string | undefined) => { if (path) paths.add(path) }
|
|
67
72
|
try {
|
|
68
|
-
const base = git(['-C', root, 'merge-base',
|
|
69
|
-
if (base)
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
+
const base = git(['-C', root, 'merge-base', branch, 'HEAD']).trim()
|
|
74
|
+
if (!base) throw new Error('git merge-base returned no commit')
|
|
75
|
+
const fields = git(['-C', root, '-c', 'core.quotePath=false', 'diff', '--name-status', '-z', '-M', base])
|
|
76
|
+
.split('\0').filter(Boolean)
|
|
77
|
+
for (let i = 0; i < fields.length;) {
|
|
78
|
+
const status = fields[i++]
|
|
79
|
+
if (/^[RC]/.test(status)) { add(fields[i++]); add(fields[i++]) }
|
|
80
|
+
else add(fields[i++])
|
|
81
|
+
}
|
|
82
|
+
for (const path of git(['-C', root, '-c', 'core.quotePath=false', 'ls-files', '--others', '--exclude-standard']).split('\n')) add(path.trim())
|
|
83
|
+
const config = existsSync(join(root, 'spexcode.json')) ? join(root, 'spexcode.json') : 'defaults'
|
|
84
|
+
return { base, paths, config }
|
|
85
|
+
} catch (error) {
|
|
86
|
+
const detail = error instanceof Error ? error.message.trim() : String(error)
|
|
87
|
+
throw new Error(`cannot establish changed scope against '${branch}': ${detail}`)
|
|
88
|
+
}
|
|
73
89
|
}
|
|
74
90
|
|
|
75
91
|
// A node owns the files under its spec dir except those under a descendant node. Its other selection axis
|
|
@@ -81,27 +97,35 @@ export function nodeChanged(dirRel: string, codeFiles: readonly string[], change
|
|
|
81
97
|
const inDescendant = descendants.some((d) => c === d || c.startsWith(d + '/'))
|
|
82
98
|
if (inNodeDir && !inDescendant) return true
|
|
83
99
|
}
|
|
84
|
-
return codeFiles.some((
|
|
85
|
-
if (changed.has(cf)) return true
|
|
86
|
-
const dir = cf.replace(/\/+$/, '') + '/'
|
|
87
|
-
const re = cf.includes('*') ? new RegExp('^' + cf.replace(/[.+?^${}()|[\]\\]/g, '\\$&').replace(/\*/g, '.*') + '$') : null
|
|
88
|
-
for (const c of changed) { if (c.startsWith(dir)) return true; if (re && re.test(c)) return true }
|
|
89
|
-
return false
|
|
90
|
-
})
|
|
100
|
+
return codeFiles.some((claim) => [...changed].some((file) => relationClaimsPath(claim, file)))
|
|
91
101
|
}
|
|
92
102
|
|
|
93
103
|
async function scan(args: string[] = []): Promise<number> {
|
|
94
104
|
const root = repoRoot()
|
|
95
105
|
const cfg = loadConfig(root)
|
|
106
|
+
const changedOnly = has(args, 'changed')
|
|
107
|
+
let changed: Set<string> | null = null
|
|
108
|
+
if (changedOnly) {
|
|
109
|
+
try {
|
|
110
|
+
const scope = changedScope(root)
|
|
111
|
+
changed = scope.paths
|
|
112
|
+
console.error(`spex eval lint --changed scope: base=${scope.base} paths=${scope.paths.size} config=${scope.config}`)
|
|
113
|
+
} catch (error) {
|
|
114
|
+
console.error(`spex eval lint --changed: ${error instanceof Error ? error.message : String(error)}`)
|
|
115
|
+
return 2
|
|
116
|
+
}
|
|
117
|
+
}
|
|
96
118
|
// eval-coverage fires on ANY governed source file per the same classifier as spec coverage, not just
|
|
97
119
|
// frontend — so backend/CLI/non-web source is held to the loss discipline too, not exempted.
|
|
98
120
|
const sourceFiles = new Set(trackedSourceFiles(root, cfg.governedRoots, cfg))
|
|
99
|
-
const changedOnly = has(args, 'changed')
|
|
100
|
-
const changed = changedOnly ? changedSinceBase(root) : null
|
|
101
121
|
const idx = await driftIndex(root)
|
|
102
122
|
// the off-history content fallback ([[eval-core]]): a rebased/folded-away anchor with byte-identical
|
|
103
123
|
// governed content reads fresh instead of false-positive stale. Lazy — in-history readings never probe.
|
|
104
124
|
const probe = contentProbeFor(root)
|
|
125
|
+
// the code axis's spatial narrowing ([[eval-core]]): an anchored `code:` entry stales a reading only when a
|
|
126
|
+
// window commit touched one of its named units. Same lazy shape as the content probe — a bare entry never
|
|
127
|
+
// reaches it, so an unanchored corpus pays nothing.
|
|
128
|
+
const anchors = anchorProbeFor(root, idx)
|
|
105
129
|
const scidx = await scenarioIndex(root, evalNodes(root).map((n) => n.evalPath))
|
|
106
130
|
const specs = await loadSpecs()
|
|
107
131
|
// the non-git REMARK freshness axis ([[remark-teeth]]): the trunk remark track, read ONCE — the CLI is the
|
|
@@ -133,16 +157,26 @@ async function scan(args: string[] = []): Promise<number> {
|
|
|
133
157
|
// a scenario's own `code` narrows its freshness CODE axis to a subset; a path that does not exist
|
|
134
158
|
// would make that axis silently immortal (changedSince finds no commits for it), so flag it LOUD as a
|
|
135
159
|
// malformed declaration — the same loud-fail spirit as a bad node `code:`.
|
|
160
|
+
// ONE resolution of the axis: base paths for every path consumer (existence, changed-scan selection,
|
|
161
|
+
// drift display), folded selectors for the narrowing. A raw `path#symbol` matches no real file, so
|
|
162
|
+
// reading it as a path would report a ghost and drop the scenario out of --changed selection.
|
|
163
|
+
const axis = scenarioCodeAxis(sc.code, s.code)
|
|
136
164
|
if (nodeSelected) {
|
|
137
|
-
for (const [field, paths] of [['code',
|
|
138
|
-
const ghosts =
|
|
165
|
+
for (const [field, paths] of [['code', axis.paths], ['related', parseRelation(sc.related ?? [], 'related').entries.map((e) => e.path)]] as const) {
|
|
166
|
+
const ghosts = paths.filter((p) => !existsSync(join(root, p)))
|
|
139
167
|
if (ghosts.length) {
|
|
140
168
|
malformed++
|
|
141
169
|
findings.push(` • eval-schema: '${s.id}' scenario '${sc.name}' \`${field}\` path(s) not found: ${ghosts.join(', ')} — fix ${y.evalPath}`)
|
|
142
170
|
}
|
|
143
171
|
}
|
|
172
|
+
// an anchor is a claim a named unit exists — dead/ambiguous/unextractable is LOUD, and until it is
|
|
173
|
+
// repaired the probe issues no verdict, so the reading stays conservatively stale.
|
|
174
|
+
for (const p of [...axis.problems, ...anchorProblems(root, axis.entries)]) {
|
|
175
|
+
malformed++
|
|
176
|
+
findings.push(` • eval-schema: '${s.id}' scenario '${sc.name}' ${p} — fix ${y.evalPath}`)
|
|
177
|
+
}
|
|
144
178
|
}
|
|
145
|
-
const codeFiles =
|
|
179
|
+
const codeFiles = axis.paths // scenario's own subset, else the node's list
|
|
146
180
|
const driftSelected = !changed || nodeChanged(dirRel, codeFiles, changed, nodeDirs)
|
|
147
181
|
// carry the scenario's tags on the finding line — its SURFACE (e.g. frontend-e2e = browser-measured)
|
|
148
182
|
// is what routes a drift/missing gap to the right measuring hand, so the proactive nudge and a human
|
|
@@ -158,7 +192,9 @@ async function scan(args: string[] = []): Promise<number> {
|
|
|
158
192
|
}
|
|
159
193
|
if (!driftSelected) continue
|
|
160
194
|
const remSignals = (remarkTracks.get(trackKey(s.id, sc.name))?.remarks ?? []).map((rm) => ({ resolved: !!rm.resolved, resolvedAt: rm.resolvedAt }))
|
|
161
|
-
|
|
195
|
+
if (!commitReachable(idx, r.codeSha)) await probe.prime?.(r.codeSha, codeFiles, y.evalPath)
|
|
196
|
+
await anchors.prime?.(r.codeSha, axis.entries)
|
|
197
|
+
const axes = staleAxes(r, sc.code?.length ? sc.code : s.code, y.evalPath, idx, scidx, remSignals, probe, sc, anchors)
|
|
162
198
|
if (axes.length) {
|
|
163
199
|
staleScores++
|
|
164
200
|
// a remark-stale scenario is unlocked by a second-party resolve, then a fresh reading; the git axes
|
|
@@ -196,7 +232,11 @@ async function scan(args: string[] = []): Promise<number> {
|
|
|
196
232
|
if (!changedOnly) {
|
|
197
233
|
const maxOwners = cfg.maxOwners
|
|
198
234
|
const govCount = new Map<string, number>()
|
|
199
|
-
|
|
235
|
+
// scoped entries stay OUT of the bound ([[code-anchor]]): a scenario claiming named units does NOT claim
|
|
236
|
+
// the file, so narrowing an axis can never manufacture an eval-owners smell — the count stays a fact
|
|
237
|
+
// about how many scenarios measure the WHOLE file.
|
|
238
|
+
for (const n of yByDir.values()) for (const sc of n.scenarios)
|
|
239
|
+
for (const e of scenarioCodeAxis(sc.code).entries) if (!e.selectors.length) govCount.set(e.path, (govCount.get(e.path) ?? 0) + 1)
|
|
200
240
|
const over = [...govCount].filter(([, c]) => c > maxOwners).sort((a, b) => b[1] - a[1])
|
|
201
241
|
if (over.length) {
|
|
202
242
|
overOwned = over.length
|
|
@@ -621,6 +661,52 @@ export function scenarioListRows(nodes: EvalNode[], unmeasuredOnly = false): Sce
|
|
|
621
661
|
return rows
|
|
622
662
|
}
|
|
623
663
|
|
|
664
|
+
function scenarioProjectionProvenance(root: string): { head: string; treeSha: string } {
|
|
665
|
+
// These are fixed-tree provenance fields, deliberately outside semanticIndexHash/fullIndexHash. A mode or
|
|
666
|
+
// type change therefore remains visible to a tree consumer without changing the content-derived scenario
|
|
667
|
+
// identities. The command runs inside a Git project, so failure is loud instead of manufacturing provenance.
|
|
668
|
+
const head = git(['-C', root, 'rev-parse', 'HEAD']).trim()
|
|
669
|
+
if (!head) throw new Error('cannot resolve one fixed HEAD provenance')
|
|
670
|
+
const treeSha = git(['-C', root, 'rev-parse', `${head}^{tree}`]).trim()
|
|
671
|
+
if (!treeSha) throw new Error(`cannot resolve tree provenance for fixed HEAD ${head}`)
|
|
672
|
+
return { head, treeSha }
|
|
673
|
+
}
|
|
674
|
+
|
|
675
|
+
function scenarioWrite(args: string[]): number {
|
|
676
|
+
let rawMutation: string | undefined
|
|
677
|
+
for (let i = 0; i < args.length; i++) {
|
|
678
|
+
const arg = args[i]
|
|
679
|
+
if (arg !== '--mutation') {
|
|
680
|
+
console.error(`spex eval scenario write: unknown argument '${arg}' — accepts only --mutation <json>; eval.md bytes come from stdin`)
|
|
681
|
+
return 2
|
|
682
|
+
}
|
|
683
|
+
if (rawMutation !== undefined) {
|
|
684
|
+
console.error('spex eval scenario write: --mutation must be supplied exactly once')
|
|
685
|
+
return 2
|
|
686
|
+
}
|
|
687
|
+
const value = args[++i]
|
|
688
|
+
if (value === undefined) {
|
|
689
|
+
console.error('spex eval scenario write: --mutation needs one JSON value')
|
|
690
|
+
return 2
|
|
691
|
+
}
|
|
692
|
+
rawMutation = value
|
|
693
|
+
}
|
|
694
|
+
if (rawMutation === undefined) {
|
|
695
|
+
console.error('spex eval scenario write: usage: spex eval scenario write --mutation <json> < eval.md')
|
|
696
|
+
return 2
|
|
697
|
+
}
|
|
698
|
+
|
|
699
|
+
try {
|
|
700
|
+
const mutation = JSON.parse(rawMutation)
|
|
701
|
+
const source = readFileSync(0, 'utf8')
|
|
702
|
+
process.stdout.write(writeScenarioMeasurementMetadata(source, mutation))
|
|
703
|
+
return 0
|
|
704
|
+
} catch (e: any) {
|
|
705
|
+
console.error(`spex eval scenario write: ${e?.message ?? e}`)
|
|
706
|
+
return 1
|
|
707
|
+
}
|
|
708
|
+
}
|
|
709
|
+
|
|
624
710
|
async function scenarioLs(args: string[]): Promise<number> {
|
|
625
711
|
for (const a of args) {
|
|
626
712
|
if (!a.startsWith('--')) continue
|
|
@@ -632,6 +718,32 @@ async function scenarioLs(args: string[]): Promise<number> {
|
|
|
632
718
|
}
|
|
633
719
|
const root = repoRoot()
|
|
634
720
|
const sel = positional(args)
|
|
721
|
+
const unmeasuredOnly = has(args, 'unmeasured')
|
|
722
|
+
if (has(args, 'json')) {
|
|
723
|
+
if (unmeasuredOnly) {
|
|
724
|
+
console.error('spex eval scenario ls --json: --unmeasured needs evals; JSON is the complete declaration projection and reads no eval sidecar')
|
|
725
|
+
return 2
|
|
726
|
+
}
|
|
727
|
+
try {
|
|
728
|
+
const before = scenarioProjectionProvenance(root)
|
|
729
|
+
// Read declarations from the exact commit named by `before`, never from a dirty/staged working tree.
|
|
730
|
+
const fixedNodes = evalNodesAt(root, before.head)
|
|
731
|
+
const selected = sel
|
|
732
|
+
? (() => {
|
|
733
|
+
const ref = sel === '.' ? currentNodeId(root) : stripRefSigil(sel)
|
|
734
|
+
if (!ref) throw new Error('no current node (no session/node-branch here) — name a node')
|
|
735
|
+
const res = resolveEvalNode(fixedNodes, ref)
|
|
736
|
+
if (!res.ok) throw new Error(res.error)
|
|
737
|
+
return [res.node]
|
|
738
|
+
})()
|
|
739
|
+
: fixedNodes
|
|
740
|
+
console.log(JSON.stringify(scenarioProjection(selected, before), null, 2))
|
|
741
|
+
return 0
|
|
742
|
+
} catch (e: any) {
|
|
743
|
+
console.error(`spex eval scenario ls --json: ${e?.message ?? e}`)
|
|
744
|
+
return 1
|
|
745
|
+
}
|
|
746
|
+
}
|
|
635
747
|
let nodes = evalNodes(root)
|
|
636
748
|
if (sel) {
|
|
637
749
|
const ref = sel === '.' ? currentNodeId(root) : stripRefSigil(sel)
|
|
@@ -640,9 +752,7 @@ async function scenarioLs(args: string[]): Promise<number> {
|
|
|
640
752
|
if (!res.ok) { console.error(`spex eval scenario ls: ${res.error}`); return 1 }
|
|
641
753
|
nodes = [res.node]
|
|
642
754
|
}
|
|
643
|
-
const unmeasuredOnly = has(args, 'unmeasured')
|
|
644
755
|
const rows = scenarioListRows(nodes, unmeasuredOnly)
|
|
645
|
-
if (has(args, 'json')) { console.log(JSON.stringify(rows, null, 2)); return 0 }
|
|
646
756
|
if (!rows.length) {
|
|
647
757
|
console.log(`spex eval scenario ls: ${unmeasuredOnly ? 'no unmeasured scenarios' : 'no scenarios declared'}${sel ? ` on '${nodes[0]?.id ?? sel}'` : ''}`)
|
|
648
758
|
return 0
|
|
@@ -659,8 +769,9 @@ async function scenarioLs(args: string[]): Promise<number> {
|
|
|
659
769
|
}
|
|
660
770
|
|
|
661
771
|
// the `spex eval` drawer's node-scoped verbs ([[cli-surface]]): add (file a measurement) · ls (a node's
|
|
662
|
-
// reading timeline) · scenario ls (the declared contracts
|
|
663
|
-
// measurement-layer lint — advisory
|
|
772
|
+
// reading timeline) · scenario ls/write (the declared contracts and their canonical metadata writer) · lint (the
|
|
773
|
+
// measurement-layer lint — findings are advisory; an unreadable --changed scope fails) · ok (the human
|
|
774
|
+
// sign-off) · retract · clean.
|
|
664
775
|
// The session-scoped read (`spex eval ls --session <SEL>`) is intercepted in cli.ts before this runs;
|
|
665
776
|
// `check-staged` is hook plumbing, exported separately for `spex internal check-staged`.
|
|
666
777
|
export async function runEval(args: string[]): Promise<number> {
|
|
@@ -679,10 +790,11 @@ export async function runEval(args: string[]): Promise<number> {
|
|
|
679
790
|
}
|
|
680
791
|
if (sub === 'scenario') {
|
|
681
792
|
if (args[1] === 'ls') return scenarioLs(args.slice(2))
|
|
682
|
-
|
|
793
|
+
if (args[1] === 'write') return scenarioWrite(args.slice(2))
|
|
794
|
+
console.error('spex eval scenario: ls [<node>|.] [--unmeasured] [--json] | write --mutation <json> < eval.md')
|
|
683
795
|
return 2
|
|
684
796
|
}
|
|
685
|
-
console.error('spex eval: add [.|<node>] [--scenario <name>] (--pass|--fail) [--note <text>] [--image <path> …repeatable] [--result <path|->] [--video <path>] [--timeline <json>] | ls [.|<node>] [--json] | ls --session <SEL> [--export] | scenario ls [<node>|.] [--unmeasured] [--json] | matrix <launcher> [--node <id>] [--rows k1,k2] | lint [--changed] | ok <node> [--scenario <name>] | retract [.|<node>] [--scenario <name>] [--last | --ts <iso>] [--note <why>] | clean [--keep-latest|--all]')
|
|
797
|
+
console.error('spex eval: add [.|<node>] [--scenario <name>] (--pass|--fail) [--note <text>] [--image <path> …repeatable] [--result <path|->] [--video <path>] [--timeline <json>] | ls [.|<node>] [--json] | ls --session <SEL> [--export] | scenario ls [<node>|.] [--unmeasured] [--json] | scenario write --mutation <json> < eval.md | matrix <launcher> [--node <id>] [--rows k1,k2] | lint [--changed] | ok <node> [--scenario <name>] | retract [.|<node>] [--scenario <name>] [--last | --ts <iso>] [--note <why>] | clean [--keep-latest|--all]')
|
|
686
798
|
return 2
|
|
687
799
|
}
|
|
688
800
|
|
package/spec-eval/src/evaltab.ts
CHANGED
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
import { relative, dirname } from 'node:path'
|
|
2
|
-
import { repoRoot, driftIndex, historyIndex, type DriftIndex, type HistoryIndex } from '../../spec-cli/src/git.js'
|
|
2
|
+
import { repoRoot, driftIndex, historyIndex, commitReachable, type DriftIndex, type HistoryIndex } from '../../spec-cli/src/git.js'
|
|
3
3
|
import { loadSpecs } from '../../spec-cli/src/specs.js'
|
|
4
4
|
import { loadEvalRemarkTracks, trackKey, type RemarkTrack, type Issue, type Reply } from '../../spec-cli/src/issues.js'
|
|
5
|
-
import { evalNodes, type EvalNode, type ScenarioTestReference } from './scenarios.js'
|
|
5
|
+
import { evalNodes, scenarioCodeAxis, type EvalNode, type ScenarioTestReference } from './scenarios.js'
|
|
6
6
|
import { readSidecar, applyRetractions, evidenceOf, isJsonBlob, humanOkFor, type Verdict, type EvidenceKind, type Retraction } from './sidecar.js'
|
|
7
|
-
import { staleAxes, codeDrift, contentProbeFor, type StaleAxis } from './freshness.js'
|
|
7
|
+
import { staleAxes, codeDrift, contentProbeFor, anchorProbeFor, type StaleAxis } from './freshness.js'
|
|
8
8
|
import { scenarioIndex, type ScenarioIndex } from './scenariofresh.js'
|
|
9
9
|
import { hasBlob, getBlob, MISS_BLOB } from './cache.js'
|
|
10
10
|
|
|
@@ -171,17 +171,24 @@ export async function evalTimeline(id: string, ctx?: EvalContext): Promise<EvalT
|
|
|
171
171
|
// along as the undo trace (newest-first, like the readings), the human-ok events as the sign-off overlay.
|
|
172
172
|
const { readings: rawReadings, retractions, oks } = readSidecar(ynode.sidecarPath)
|
|
173
173
|
// the off-history content fallback ([[eval-core]]): fed to both git axes so a rebased/folded-away
|
|
174
|
-
// anchor with byte-identical governed content reads fresh.
|
|
174
|
+
// anchor with byte-identical governed content reads fresh. An in-history reading never probes.
|
|
175
175
|
const probe = contentProbeFor(root)
|
|
176
|
-
const
|
|
176
|
+
const anchors = anchorProbeFor(root, idx)
|
|
177
|
+
const readings: EvalEntry[] = []
|
|
178
|
+
for (const r of applyRetractions(rawReadings, retractions)) {
|
|
177
179
|
// a scenario's own `code` is its freshness code axis when it declares one; else the whole node's list.
|
|
178
180
|
const sc = byName.get(r.scenario)
|
|
179
181
|
// the teeth feed the WHOLE scenario track against THIS reading — an unresolved (or not-yet-out-run)
|
|
180
182
|
// remark makes it remark-stale (T1). Display attachment (which reading each remark pins to) is a separate
|
|
181
183
|
// read-time overlay below; freshness never depends on that pin.
|
|
182
184
|
const cf = sc?.code?.length ? sc.code : codeFiles
|
|
185
|
+
// an entry may be anchored (`path#symbol`); every PATH consumer below reads base paths, the narrowing
|
|
186
|
+
// reads the folded selectors ([[eval-core]]).
|
|
187
|
+
const axis = scenarioCodeAxis(sc?.code, codeFiles)
|
|
188
|
+
if (!commitReachable(idx, r.codeSha)) await probe.prime?.(r.codeSha, axis.paths, ynode.evalPath)
|
|
189
|
+
await anchors.prime?.(r.codeSha, axis.entries)
|
|
183
190
|
const axes = staleAxes(r, cf, ynode.evalPath, idx, scidx,
|
|
184
|
-
remarksFor(r.scenario).map((rm) => ({ resolved: !!rm.resolved, resolvedAt: rm.resolvedAt })), probe, sc)
|
|
191
|
+
remarksFor(r.scenario).map((rm) => ({ resolved: !!rm.resolved, resolvedAt: rm.resolvedAt })), probe, sc, anchors)
|
|
185
192
|
// when the code axis is stale, explain it: which of THIS reading's governed files moved, by how many commits.
|
|
186
193
|
const drift = axes.includes('code') ? codeDrift(idx, r.codeSha, cf, probe) : []
|
|
187
194
|
// the reading's evidence list, each entry resolved to its live blob state; the primary (video-first, else
|
|
@@ -191,7 +198,7 @@ export async function evalTimeline(id: string, ctx?: EvalContext): Promise<EvalT
|
|
|
191
198
|
// the sign-off join ([[human-ok]]): the ok binds by exact (scenario, ts), so only the very reading the
|
|
192
199
|
// human blessed carries it — a newer or retract-revealed reading reads unblessed.
|
|
193
200
|
const okRow = humanOkFor(oks, r.scenario, r.ts)
|
|
194
|
-
|
|
201
|
+
readings.push({
|
|
195
202
|
scenario: r.scenario,
|
|
196
203
|
expected: byName.get(r.scenario)?.expected ?? '',
|
|
197
204
|
codeSha: r.codeSha,
|
|
@@ -209,8 +216,8 @@ export async function evalTimeline(id: string, ctx?: EvalContext): Promise<EvalT
|
|
|
209
216
|
blobState: primary ? primary.state : 'none',
|
|
210
217
|
...(threadFor(r.scenario) ? { thread: threadFor(r.scenario) } : {}),
|
|
211
218
|
...(okRow ? { humanOk: { by: okRow.by, ts: okRow.ts } } : {}),
|
|
212
|
-
}
|
|
213
|
-
}
|
|
219
|
+
})
|
|
220
|
+
}
|
|
214
221
|
readings.reverse() // newest-first
|
|
215
222
|
// R2 display overlay ([[remark-teeth]]): pin each remark to the reading it JUDGED (targetCodeSha match),
|
|
216
223
|
// else the scenario's latest reading (first in newest-first order) — a dangling target never HIDES the
|