spexcode 0.5.8 → 0.5.9
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 +5 -3
- package/spec-cli/bin/spex.mjs +1 -13
- package/spec-cli/hooks/compat/mark-active-0.5.2-eef1.fixture +53 -0
- package/spec-cli/hooks/compat/mark-active-sed-v0.fixture +46 -0
- package/spec-cli/hooks/dispatch.sh +15 -19
- package/spec-cli/src/anchors.ts +185 -56
- package/spec-cli/src/attach.ts +7 -17
- package/spec-cli/src/cli.ts +144 -225
- package/spec-cli/src/client.ts +114 -132
- package/spec-cli/src/cockpit.ts +43 -0
- package/spec-cli/src/codex-runtime-generations.ts +556 -0
- package/spec-cli/src/commit-surgery.ts +0 -14
- package/spec-cli/src/contract-filter.ts +0 -14
- package/spec-cli/src/doctor.ts +0 -16
- package/spec-cli/src/gateway-auth.ts +0 -13
- package/spec-cli/src/gateway.ts +0 -22
- package/spec-cli/src/git.ts +151 -48
- package/spec-cli/src/graph.ts +28 -7
- package/spec-cli/src/graphCache.ts +253 -72
- package/spec-cli/src/graphDelta.ts +0 -13
- package/spec-cli/src/graphStream.ts +70 -37
- package/spec-cli/src/guide.ts +29 -6
- package/spec-cli/src/harness.ts +232 -248
- package/spec-cli/src/help.ts +27 -26
- package/spec-cli/src/host-resources.ts +11 -2
- package/spec-cli/src/index.ts +70 -95
- package/spec-cli/src/issues-cli.ts +276 -0
- package/spec-cli/src/issues.ts +15 -197
- package/spec-cli/src/layout.ts +78 -2
- package/spec-cli/src/lint.ts +5 -3
- package/spec-cli/src/localIssues.ts +22 -185
- package/spec-cli/src/loop-in.ts +78 -0
- package/spec-cli/src/materialize.ts +0 -42
- package/spec-cli/src/mentions.ts +49 -193
- package/spec-cli/src/opencode-headless.ts +7 -7
- package/spec-cli/src/opencode.ts +2 -5
- package/spec-cli/src/pi-headless.ts +5 -5
- package/spec-cli/src/pty-bridge.ts +4 -5
- package/spec-cli/src/reviewSnapshot.ts +1 -0
- package/spec-cli/src/reviews.ts +6 -1
- package/spec-cli/src/root-lru.ts +54 -0
- package/spec-cli/src/runtime-guard.ts +0 -9
- package/spec-cli/src/session-cursors.ts +97 -0
- package/spec-cli/src/session-follow.ts +182 -0
- package/spec-cli/src/session-timeline.ts +55 -127
- package/spec-cli/src/sessions.ts +207 -1022
- package/spec-cli/src/shim-runtime.ts +9 -16
- package/spec-cli/src/specs.ts +33 -10
- package/spec-cli/src/uninstall.ts +0 -9
- package/spec-cli/src/uploads.ts +240 -18
- package/spec-cli/src/worktree-sources.ts +0 -16
- package/spec-cli/templates/spec/project/.plugins/commands/supervisor/spec.md +1 -1
- package/spec-cli/templates/spec/project/.plugins/core/comment-altitude/spec.md +28 -0
- package/spec-cli/templates/spec/project/.plugins/core/mark-active/mark-active.sh +43 -3
- package/spec-cli/templates/spec/project/.plugins/core/mark-active/spec.md +3 -1
- package/spec-cli/templates/spec/project/.plugins/core/stop-gate/stop-gate.sh +2 -2
- package/spec-cli/templates/spec/project/.plugins/skills/distill/spec.md +5 -0
- package/spec-cli/templates/spexcode.json +12 -0
- package/spec-dashboard/dist/assets/{App-u2P7KdSg.js → App-b8Nh0sgk.js} +2 -2
- package/spec-dashboard/dist/assets/{Dashboard-B8wp5_61.js → Dashboard-CvAjfRC2.js} +7 -7
- package/spec-dashboard/dist/assets/{EvalsPage-Bq1Tkb8y.js → EvalsPage-Bz-nMKoS.js} +1 -1
- package/spec-dashboard/dist/assets/IssuesPage-CAP64YWE.js +1 -0
- package/spec-dashboard/dist/assets/MobileApp-D9L1Va8Z.js +2 -0
- package/spec-dashboard/dist/assets/{Modal-bAkq9IIT.js → Modal-Drscez-d.js} +1 -1
- package/spec-dashboard/dist/assets/{PageScroll-px_rUZVJ.js → PageScroll-qW6uOJL8.js} +1 -1
- package/spec-dashboard/dist/assets/{ProjectsPage-8uGqYM12.js → ProjectsPage-CjybFBmR.js} +1 -1
- package/spec-dashboard/dist/assets/{SessionInterface-CswwbewF.js → SessionInterface-Dl9v0JFM.js} +13 -13
- package/spec-dashboard/dist/assets/SessionWindow-iOk0yHoU.js +1 -0
- package/spec-dashboard/dist/assets/{Settings-bpAbfnmS.js → Settings-BZ1lGRJs.js} +1 -1
- package/spec-dashboard/dist/assets/Thread-D_kcDnfd.js +13 -0
- package/spec-dashboard/dist/assets/{data-CQFbQEMH.js → data-Bwd3kAVL.js} +1 -1
- package/spec-dashboard/dist/assets/{index-CixSnz1H.css → index-DAbQBBK_.css} +1 -1
- package/spec-dashboard/dist/assets/{index-Di1ch5dd.js → index-paP-z_Vd.js} +6 -6
- package/spec-dashboard/dist/assets/{TimelineChat-Ckmb1Ez2.js → launch-B-bYdWmh.js} +25 -25
- package/spec-dashboard/dist/index.html +2 -2
- package/spec-dashboard/src/session.js +1 -28
- package/spec-eval/src/cache.ts +0 -14
- package/spec-eval/src/cli.ts +26 -5
- package/spec-eval/src/evaltab.ts +67 -108
- package/spec-eval/src/filing.ts +0 -18
- package/spec-eval/src/freshness.ts +78 -32
- package/spec-eval/src/humanok.ts +0 -15
- package/spec-eval/src/scenariofresh.ts +8 -80
- package/spec-eval/src/scenarios.ts +16 -67
- package/spec-eval/src/sessioneval.ts +157 -78
- package/spec-eval/src/sidecar.ts +1 -70
- package/spec-eval/src/timeline.ts +0 -23
- package/spec-forge/src/cache.ts +34 -12
- package/spec-forge/src/cli.ts +0 -6
- package/spec-forge/src/drivers/github.ts +1 -12
- package/spec-forge/src/drivers/gitlab.ts +4 -19
- package/spec-forge/src/drivers.ts +0 -18
- package/spec-forge/src/links.ts +0 -4
- package/spec-forge/src/needs-eval.ts +0 -1
- package/spec-forge/src/port.ts +0 -12
- package/spec-forge/src/resident.ts +4 -20
- package/spec-cli/src/maintenance-wrapper.ts +0 -276
- package/spec-cli/src/session-maintenance.ts +0 -751
- package/spec-dashboard/dist/assets/IssuesPage-BlkPSkmv.js +0 -1
- package/spec-dashboard/dist/assets/MobileApp-B1GxRZXK.js +0 -2
- package/spec-dashboard/dist/assets/SessionWindow-IspcLjFA.js +0 -1
- package/spec-dashboard/dist/assets/Thread-BpL3N3kw.js +0 -13
- /package/spec-dashboard/dist/assets/{TimelineChat-Cp579UoJ.css → launch-Cp579UoJ.css} +0 -0
|
@@ -1,9 +1,10 @@
|
|
|
1
1
|
import { readFileSync } from 'node:fs'
|
|
2
|
+
import { createHash } from 'node:crypto'
|
|
2
3
|
import { join, resolve } from 'node:path'
|
|
3
|
-
import { gitA, gitTry, headSha, currentGitBuildAbortSignal, gitAbortError, ancestorsOf, inAncestors, commitReachable, pathEvents, type DriftIndex, type DriftPathEvent } from '../../spec-cli/src/git.js'
|
|
4
|
-
import {
|
|
4
|
+
import { gitA, gitTry, headSha, currentGitBuildAbortSignal, gitAbortError, ancestorsOf, inAncestors, commitReachable, pathEvents, type DriftIndex, type DriftPathEvent, eventsSince } from '../../spec-cli/src/git.js'
|
|
5
|
+
import { anchorHitExists, extOf, extractorFor, extractors, resolveAnchor, resolveSelectors, type AnchorHitQuery, type Extractor, type RelationEntry, type Unit } from '../../spec-cli/src/anchors.js'
|
|
5
6
|
import type { Reading } from './sidecar.js'
|
|
6
|
-
import { scenarioCodeAxis, scenarioHash, type Scenario } from './scenarios.js'
|
|
7
|
+
import { scenarioCodeAxis, scenarioHash, type Scenario, type ScenarioCodeAxisSource } from './scenarios.js'
|
|
7
8
|
import { scenarioChangeCommits, scenarioBlocksAt, primeScenarioBlocksAt, type ScenarioIndex } from './scenariofresh.js'
|
|
8
9
|
|
|
9
10
|
// 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
|
|
@@ -259,8 +260,12 @@ export type AnchorProbe = {
|
|
|
259
260
|
// did any commit in sinceSha..HEAD touch one of THESE anchored units?
|
|
260
261
|
// null = cannot testify (unprimed, off-history, no usable extractor) — callers stay conservatively stale.
|
|
261
262
|
hit(sinceSha: string, path: string, selectors: readonly string[]): boolean | null
|
|
262
|
-
|
|
263
|
+
// The demand set is PLURAL because the engine underneath is: every window's Git images and ordinary hunks
|
|
264
|
+
// are immutable, so one call owns them once for the whole read. A caller that primes one reading at a time
|
|
265
|
+
// re-forks that batch per reading, which on this corpus was ~2.5k children for ~800 verdicts.
|
|
266
|
+
prime?(demands: readonly AnchorDemand[]): Promise<void>
|
|
263
267
|
}
|
|
268
|
+
export type AnchorDemand = { sinceSha: string; entries: readonly RelationEntry[] }
|
|
264
269
|
|
|
265
270
|
// a verdict answers ONE selector set, so the set is part of its identity: several scenarios anchoring
|
|
266
271
|
// DIFFERENT units of one shared file is the whole point of narrowing, and keying only by (sha, path) would
|
|
@@ -268,13 +273,29 @@ export type AnchorProbe = {
|
|
|
268
273
|
const anchorKey = (sinceSha: string, path: string, selectors: readonly string[]) =>
|
|
269
274
|
`${sinceSha}\x1f${path}\x1f${[...selectors].sort().join('\x1e')}`
|
|
270
275
|
|
|
271
|
-
//
|
|
272
|
-
//
|
|
273
|
-
//
|
|
274
|
-
function
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
276
|
+
// @@@ one parse per CONTENT, not per selector entry - a node's `code:` entries are asked one at a time, and
|
|
277
|
+
// the same working-tree file backs many of them, so the unmemoized read parsed 46 distinct files 922 times
|
|
278
|
+
// per build (40.4 MB through the TypeScript parser) and did it again on every rebuild. `extract` is a pure
|
|
279
|
+
// function of (text, path, extractor), so its result is reusable exactly as far as the CONTENT is unchanged.
|
|
280
|
+
// The key is a content digest and never mtime/size: this gate decides whether a reading may testify, so a
|
|
281
|
+
// stale unit list would let a dead selector read as alive — the precise failure the comment below warns
|
|
282
|
+
// about. Digesting is ~10x cheaper than parsing, so the read stays and only the parse is saved. Bounded like
|
|
283
|
+
// the historical-revision memo it mirrors ([[code-anchor]]), and it caches the extractor's REJECTION too, so
|
|
284
|
+
// an unparseable file does not re-parse once per entry.
|
|
285
|
+
const CURRENT_TREE_MEMO_MAX = 4096
|
|
286
|
+
const currentTreeUnitMemo = new Map<string, { units: Unit[] } | { failed: string }>()
|
|
287
|
+
function currentTreeUnits(root: string, x: Extractor, path: string): Unit[] {
|
|
288
|
+
const source = readFileSync(join(root, path), 'utf8')
|
|
289
|
+
const key = `${x.memoKey(path)}\0${createHash('sha1').update(source).digest('hex')}`
|
|
290
|
+
const hit = currentTreeUnitMemo.get(key)
|
|
291
|
+
if (hit) { if ('failed' in hit) throw new Error(hit.failed); return hit.units }
|
|
292
|
+
let entry: { units: Unit[] } | { failed: string }
|
|
293
|
+
try { entry = { units: x.extract(source, path) } }
|
|
294
|
+
catch (err: any) { entry = { failed: err?.message ?? String(err) } }
|
|
295
|
+
if (currentTreeUnitMemo.size >= CURRENT_TREE_MEMO_MAX) currentTreeUnitMemo.clear()
|
|
296
|
+
currentTreeUnitMemo.set(key, entry)
|
|
297
|
+
if ('failed' in entry) throw new Error(entry.failed)
|
|
298
|
+
return entry.units
|
|
278
299
|
}
|
|
279
300
|
|
|
280
301
|
// every selector of one entry resolves to exactly one unit in the CURRENT tree, or the entry cannot testify.
|
|
@@ -288,32 +309,55 @@ function entryUnverifiable(root: string, regs: Extractor[], entry: RelationEntry
|
|
|
288
309
|
const ready = x.ready()
|
|
289
310
|
if (ready !== true) return `\`code\` anchors on ${entry.path} are unverified: ${ready}`
|
|
290
311
|
let units
|
|
291
|
-
try { units =
|
|
312
|
+
try { units = currentTreeUnits(root, x, entry.path) }
|
|
292
313
|
catch (err: any) { return `\`code\` anchors on ${entry.path} are unverified: ${err?.message ?? String(err)}` }
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
if ('
|
|
314
|
+
// the SAME classifier the gate uses ([[code-anchor]]'s resolveSelectors) — only the wording is ours, so a
|
|
315
|
+
// selector can never be verifiable to one reader and dead to the other.
|
|
316
|
+
for (const r of resolveSelectors(units, entry.selectors)) {
|
|
317
|
+
if ('dead' in r) return `\`code\` selector \`${entry.path}#${r.selector}\` names no unit in that file — follow the rename or drop the selector (evals stay stale until then)`
|
|
318
|
+
if ('ambiguous' in r) return `\`code\` selector \`${entry.path}#${r.selector}\` is ambiguous — ${r.ambiguous} units share that name; pin a unique one`
|
|
297
319
|
}
|
|
298
320
|
return null
|
|
299
321
|
}
|
|
300
322
|
|
|
323
|
+
// @@@ the verify sweep must reach the MACROTASK queue - resolving every reading's `code:` selectors against
|
|
324
|
+
// the working tree is a doubly-nested SYNCHRONOUS sweep (4,384 demands on this corpus), and nothing in it
|
|
325
|
+
// awaits, so it ran as one uninterruptible stretch: measured, it held the loop for 1,104ms, which is the
|
|
326
|
+
// `/health` p99 this gate is judged by. The bound defended here is a LIVENESS signal, not a latency taste —
|
|
327
|
+
// a probe that cannot answer is indistinguishable from a dead backend, and the CLI allows it 600ms while the
|
|
328
|
+
// supervisor allows 1,000ms before it keeps the old child. `setImmediate` is what actually returns to the
|
|
329
|
+
// loop's I/O phase (awaiting a synchronous-bodied async fn only drains microtasks). A time budget rather
|
|
330
|
+
// than every-iteration keeps the common cheap iteration from paying a turn: the sweep's own longest single
|
|
331
|
+
// step is one file parse, so the worst hold stays near budget + that step.
|
|
332
|
+
const VERIFY_YIELD_BUDGET_MS = Number(process.env.SPEXCODE_VERIFY_YIELD_BUDGET_MS || 50)
|
|
333
|
+
const yieldToEventLoop = (): Promise<void> => new Promise<void>((resolve) => { setImmediate(resolve) })
|
|
334
|
+
|
|
301
335
|
export function anchorProbeFor(root: string, idx: DriftIndex): AnchorProbe {
|
|
302
336
|
const regs = extractors(root)
|
|
303
337
|
const verdicts = new Map<string, boolean>()
|
|
304
338
|
return {
|
|
305
|
-
async prime(
|
|
306
|
-
|
|
339
|
+
async prime(demands) {
|
|
340
|
+
const keys: string[] = []
|
|
341
|
+
const queries: AnchorHitQuery[] = []
|
|
342
|
+
const queued = new Set<string>()
|
|
343
|
+
let sinceYield = Date.now()
|
|
344
|
+
for (const { sinceSha, entries } of demands) for (const e of entries) {
|
|
345
|
+
if (Date.now() - sinceYield >= VERIFY_YIELD_BUDGET_MS) { await yieldToEventLoop(); sinceYield = Date.now() }
|
|
307
346
|
if (!e.selectors.length) continue
|
|
308
347
|
const key = anchorKey(sinceSha, e.path, e.selectors)
|
|
309
|
-
if (verdicts.has(key)) continue
|
|
348
|
+
if (verdicts.has(key) || queued.has(key)) continue
|
|
310
349
|
if (entryUnverifiable(root, regs, e)) continue // no verdict → conservative stale (lint says why)
|
|
311
|
-
const win =
|
|
350
|
+
const win = eventsSince(idx, sinceSha, e.path)
|
|
312
351
|
if (win === null) continue
|
|
313
352
|
if (!win.length) { verdicts.set(key, false); continue }
|
|
314
|
-
|
|
315
|
-
|
|
353
|
+
queued.add(key)
|
|
354
|
+
keys.push(key)
|
|
355
|
+
queries.push({ win, symbols: [...e.selectors] })
|
|
316
356
|
}
|
|
357
|
+
|
|
358
|
+
if (!queries.length) return
|
|
359
|
+
const results = await anchorHitExists(root, queries, regs)
|
|
360
|
+
results.forEach((hit, index) => verdicts.set(keys[index], hit))
|
|
317
361
|
},
|
|
318
362
|
hit(sinceSha, path, selectors) {
|
|
319
363
|
return verdicts.get(anchorKey(sinceSha, path, selectors)) ?? null
|
|
@@ -355,8 +399,10 @@ export function remarkStale(reading: { ts: string }, remarks: RemarkSignal[]): b
|
|
|
355
399
|
// ContentProbe above); without a probe — or when the anchor object is gone — freshness can't be proven
|
|
356
400
|
// from HEAD's history, so it reads stale rather than silently pass.
|
|
357
401
|
export function changedSince(idx: DriftIndex, sinceSha: string, path: string, probe?: ContentProbe): boolean {
|
|
358
|
-
const
|
|
359
|
-
|
|
402
|
+
const events = eventsSince(idx, sinceSha, path)
|
|
403
|
+
// null = ancestry cannot testify for this anchor; only then does content get a say ([[root-lru]]'s sibling
|
|
404
|
+
// rule: one meaning of changed-since, each layer's own fallback on top).
|
|
405
|
+
if (events) return events.length > 0
|
|
360
406
|
return probe?.changed(sinceSha, path) ?? true
|
|
361
407
|
}
|
|
362
408
|
|
|
@@ -366,16 +412,16 @@ export function changedSince(idx: DriftIndex, sinceSha: string, path: string, pr
|
|
|
366
412
|
// an off-history sinceSha reports through the same content fallback (only files whose content differs, counted
|
|
367
413
|
// by rev-list); with no probe or a gone anchor it counts every touch (conservative, matching changedSince).
|
|
368
414
|
// Reporting only — it never decides freshness (staleAxes does); it explains a decision already made.
|
|
369
|
-
export function codeDrift(idx: DriftIndex, sinceSha: string, codeAxis:
|
|
415
|
+
export function codeDrift(idx: DriftIndex, sinceSha: string, codeAxis: ScenarioCodeAxisSource, probe?: ContentProbe): { file: string; behind: number }[] {
|
|
370
416
|
// an entry may be anchored (`path#symbol`); drift is reported per BASE FILE — a raw selector string names
|
|
371
417
|
// no real path, so counting commits against it would silently report nothing.
|
|
372
|
-
const codeFiles = scenarioCodeAxis(codeAxis).paths
|
|
373
|
-
const anc = ancestorsOf(idx, sinceSha)
|
|
418
|
+
const codeFiles = scenarioCodeAxis(undefined, codeAxis).paths
|
|
374
419
|
const out: { file: string; behind: number }[] = []
|
|
375
420
|
for (const f of codeFiles) {
|
|
421
|
+
const since = eventsSince(idx, sinceSha, f)
|
|
376
422
|
const events = pathEvents(idx, f)
|
|
377
|
-
const differs =
|
|
378
|
-
const behind =
|
|
423
|
+
const differs = since ? undefined : probe?.changed(sinceSha, f)
|
|
424
|
+
const behind = since ? new Set(since.map((event) => event.commit)).size
|
|
379
425
|
: differs === true ? probe!.behind(sinceSha, f)
|
|
380
426
|
: differs === false ? 0
|
|
381
427
|
: new Set(events.map((event) => event.commit)).size
|
|
@@ -428,7 +474,7 @@ function entryMoved(idx: DriftIndex, sinceSha: string, entry: RelationEntry, pro
|
|
|
428
474
|
|
|
429
475
|
export function staleAxes(
|
|
430
476
|
reading: Reading,
|
|
431
|
-
codeAxis:
|
|
477
|
+
codeAxis: ScenarioCodeAxisSource,
|
|
432
478
|
evalPath: string,
|
|
433
479
|
didx: DriftIndex,
|
|
434
480
|
scIdx: ScenarioIndex,
|
|
@@ -445,7 +491,7 @@ export function staleAxes(
|
|
|
445
491
|
axes.push('anchor')
|
|
446
492
|
if (byHash) axes.push('scenario')
|
|
447
493
|
} else {
|
|
448
|
-
if (scenarioCodeAxis(codeAxis).entries.some((e) => entryMoved(didx, reading.codeSha, e, probe, anchors))) axes.push('code')
|
|
494
|
+
if (scenarioCodeAxis(undefined, codeAxis).entries.some((e) => entryMoved(didx, reading.codeSha, e, probe, anchors))) axes.push('code')
|
|
449
495
|
if (byHash ?? scenarioMoved(scIdx, didx, reading.codeSha, evalPath, reading.scenario, probe)) axes.push('scenario')
|
|
450
496
|
}
|
|
451
497
|
if (remarkStale(reading, remarks)) axes.push('remark')
|
|
@@ -454,7 +500,7 @@ export function staleAxes(
|
|
|
454
500
|
|
|
455
501
|
export function isStale(
|
|
456
502
|
reading: Reading,
|
|
457
|
-
codeAxis:
|
|
503
|
+
codeAxis: ScenarioCodeAxisSource,
|
|
458
504
|
evalPath: string,
|
|
459
505
|
didx: DriftIndex,
|
|
460
506
|
scIdx: ScenarioIndex,
|
package/spec-eval/src/humanok.ts
CHANGED
|
@@ -4,36 +4,21 @@ import { commitTrunkData } from '../../spec-cli/src/localIssues.js'
|
|
|
4
4
|
import { evalNodes, resolveEvalNode } from './scenarios.js'
|
|
5
5
|
import { readReadings, readSidecar, appendHumanOk, humanOkFor, type HumanOk } from './sidecar.js'
|
|
6
6
|
|
|
7
|
-
// @@@ human-ok - the human sign-off on an eval reading ([[human-ok]]). One write, both surfaces: the CLI
|
|
8
|
-
// (`spex eval ok`) and the HTTP route (`POST /api/specs/:id/evals/ok`, identity server-derived 'human')
|
|
9
|
-
// call THIS. The ok binds to ONE immutable reading — the scenario's latest effective reading at ok-time,
|
|
10
|
-
// anchored by its (ts, codeSha) — and is MONOTONIC: no un-ok verb exists, because a newer reading is a
|
|
11
|
-
// different object the ok never transfers to, and staleness is computed live; both bring the scenario back
|
|
12
|
-
// on their own. Durability follows the checkout: on the trunk checkout the append is committed straight to
|
|
13
|
-
// trunk (`--no-verify`, path-scoped, under the shared store lock — the [[local-issues]] discipline,
|
|
14
|
-
// commitTrunkData); on a linked worktree the append stays in that tree and the session's own ritual commit
|
|
15
|
-
// carries it, exactly like every other sidecar write.
|
|
16
7
|
export type OkResult =
|
|
17
8
|
| { ok: true; humanOk: HumanOk; already: boolean; landed: 'committed' | 'uncommitted' }
|
|
18
9
|
| { ok: false; error: string }
|
|
19
10
|
|
|
20
11
|
export function fileHumanOk(nodeId: string, scenario: string, by: string): OkResult {
|
|
21
12
|
const root = repoRoot()
|
|
22
|
-
// the same loud resolution every eval verb applies ([[eval-core]]): exact canonical id, else a unique
|
|
23
|
-
// bare leaf; an ambiguous leaf returns the candidate list instead of blessing an arbitrary node.
|
|
24
13
|
const res = resolveEvalNode(evalNodes(root), nodeId)
|
|
25
14
|
if (!res.ok) return { ok: false, error: res.error }
|
|
26
15
|
const node = res.node
|
|
27
16
|
if (!node.scenarios.some((s) => s.name === scenario) &&
|
|
28
17
|
!readSidecar(node.sidecarPath).readings.some((r) => r.scenario === scenario))
|
|
29
18
|
return { ok: false, error: `'${node.id}' has no scenario '${scenario}'` }
|
|
30
|
-
// the ok's one possible target: the latest EFFECTIVE reading — an ok is a judgment on a measurement that
|
|
31
|
-
// exists and currently counts; an unmeasured (or fully-retracted) scenario has nothing to bless.
|
|
32
19
|
const forScenario = readReadings(node.sidecarPath).filter((r) => r.scenario === scenario)
|
|
33
20
|
if (!forScenario.length) return { ok: false, error: `'${node.id}' scenario '${scenario}' has no effective eval — nothing to ok` }
|
|
34
21
|
const latest = forScenario[forScenario.length - 1]
|
|
35
|
-
// a duplicate ok is idempotent success (the store already IS the requested state — the local-issue
|
|
36
|
-
// close's `already` semantics), never an error and never a second appended row.
|
|
37
22
|
const existing = humanOkFor(readSidecar(node.sidecarPath).oks, scenario, latest.ts)
|
|
38
23
|
if (existing) return { ok: true, humanOk: existing, already: true, landed: 'committed' }
|
|
39
24
|
const row: HumanOk = { kind: 'human-ok', scenario, okTs: latest.ts, okSha: latest.codeSha, by, ts: new Date().toISOString() }
|
|
@@ -1,31 +1,12 @@
|
|
|
1
1
|
import { spawn } from 'node:child_process'
|
|
2
2
|
import { git, gitA, gitTry, headSha } from '../../spec-cli/src/git.js'
|
|
3
3
|
import { parseScenarios } from './scenarios.js'
|
|
4
|
-
|
|
5
|
-
// @@@ per-scenario content freshness — the SCENARIO axis, sub-file
|
|
6
|
-
// An eval.md holds many scenarios, but a reading measures ONE. eval-core's contract says "a scenario is the
|
|
7
|
-
// unit of measurement, so its freshness is its OWN — two scenarios stale independently." Git has no sub-file
|
|
8
|
-
// history, so we build it: for each scenario NAME in an eval.md, the commits where THAT scenario's SEMANTIC
|
|
9
|
-
// block content (description + expected — see blockContent) changed (added / removed / edited),
|
|
10
|
-
// rename-followed. `scenarioMoved` then reads exactly like the code axis's `changedSince` — a pure ancestry
|
|
11
|
-
// lookup over this per-scenario commit list — so editing one scenario never re-stales its siblings (the
|
|
12
|
-
// file-granular bug this replaces), and retagging one never re-stales itself.
|
|
4
|
+
import { rootSlots, touchRoot as touchRootLru } from '../../spec-cli/src/root-lru.js'
|
|
13
5
|
|
|
14
6
|
const RS = '\x1e'
|
|
15
7
|
|
|
16
|
-
// evalPath (head path) -> scenario name -> commit hashes that changed that scenario's block (newest-first)
|
|
17
8
|
export type ScenarioIndex = Map<string, Map<string, string[]>>
|
|
18
9
|
|
|
19
|
-
// the block content that stales a reading: the scenario's SEMANTIC fields only — description (what to
|
|
20
|
-
// measure) + expected (what zero loss looks like), the contract a measurement is taken AGAINST. The name is
|
|
21
|
-
// the join key (a renamed scenario is a remove+add, surfaced as a change-commit on each name), and the
|
|
22
|
-
// metadata fields are OUT: tags route a scenario to a measuring hand, test/code/related point at files —
|
|
23
|
-
// none of them changes what an already-taken reading proved, so a tags sweep or a coverage retune must not
|
|
24
|
-
// re-stale every reading in the tree. Both freshness paths (the in-history change-commits and the
|
|
25
|
-
// off-history scenarioDiffers probe) read THIS one projection — narrowing it here narrows both at once, and
|
|
26
|
-
// because freshness is derived live from git, historical metadata-only commits stop registering as scenario
|
|
27
|
-
// changes with no migration. parseScenarios already folds YAML block scalars, so a pure prose re-wrap
|
|
28
|
-
// yields the same string and does NOT stale.
|
|
29
10
|
function blockContent(src: string): Map<string, string> {
|
|
30
11
|
const m = new Map<string, string>()
|
|
31
12
|
for (const s of parseScenarios(src)) {
|
|
@@ -34,30 +15,14 @@ function blockContent(src: string): Map<string, string> {
|
|
|
34
15
|
return m
|
|
35
16
|
}
|
|
36
17
|
|
|
37
|
-
const ZERO = '0'.repeat(40)
|
|
18
|
+
const ZERO = '0'.repeat(40)
|
|
38
19
|
const EMPTY: Map<string, string> = new Map()
|
|
39
20
|
|
|
40
|
-
// content-addressed: a blob OID -> its canonical per-scenario block map. Git objects are IMMUTABLE, so this
|
|
41
|
-
// memo never needs invalidation and is shared across every root — a per-worktree build reuses the blocks main
|
|
42
|
-
// already parsed, which is what lets a worktree's freshness build stay cheap instead of re-reading everything.
|
|
43
21
|
const blockByOid = new Map<string, Map<string, string>>()
|
|
44
22
|
|
|
45
|
-
// ONE `git log --raw` walk over all eval.md → per head-path version chain [{hash, oid}], newest-first,
|
|
46
|
-
// rename-followed via the `alias` idiom (git.ts buildIndex): the newest sighting of a path IS its head path;
|
|
47
|
-
// an `R` row remaps the older `from` path onto that head. The raw row carries the new blob OID directly, so
|
|
48
|
-
// no per-version path resolution and no `git show`. --full-history is REQUIRED: default pathspec
|
|
49
|
-
// history-simplification prunes commits off HEAD's first-parent chain, but in this repo every spec edit lands
|
|
50
|
-
// via a --no-ff merge of a node branch, so those pruned commits ARE the scenario edits — dropping them would
|
|
51
|
-
// under-report staleness (a stale reading judged fresh). Merge commits emit no raw diff row, so they add no
|
|
52
|
-
// version (exactly as the old `--follow` did).
|
|
53
23
|
async function fileChains(root: string, wanted: Set<string>): Promise<Map<string, { hash: string; oid: string }[]>> {
|
|
54
24
|
const chains = new Map<string, { hash: string; oid: string }[]>()
|
|
55
25
|
const alias = new Map<string, string>()
|
|
56
|
-
// DUAL pathspec — the live name AND the archived one ([[eval-core]]): this walk reads IMMUTABLE history,
|
|
57
|
-
// and pre-rename commits touched files literally named yatsu.md, so an archive answers only to its
|
|
58
|
-
// archive name. A single '*eval.md' pathspec truncates every chain at the yatsu.md→eval.md rename commit,
|
|
59
|
-
// making the rename read as each scenario's birth — a change-commit newer than every pre-rename reading's
|
|
60
|
-
// codeSha — and false-stales whole adopter corpora (the scenariofresh.test.ts regression pins this).
|
|
61
26
|
const out = await gitA(['-C', root, '-c', 'core.quotePath=false', 'log',
|
|
62
27
|
'--raw', '--no-abbrev', '--full-history', '-M', `--format=${RS}%H`, '--', '*eval.md', '*yatsu.md']) // dead-words-ok: archived pathspec — immutable pre-rename history is read under its archived name
|
|
63
28
|
for (const rec of out.split(RS)) {
|
|
@@ -72,22 +37,18 @@ async function fileChains(root: string, wanted: Set<string>): Promise<Map<string
|
|
|
72
37
|
const meta = line.slice(1, tab).split(' ')
|
|
73
38
|
const oid = meta[3], rename = meta[4][0] === 'R' || meta[4][0] === 'C'
|
|
74
39
|
const paths = line.slice(tab + 1).split('\t')
|
|
75
|
-
const to = rename ? paths[1] : paths[0]
|
|
40
|
+
const to = rename ? paths[1] : paths[0]
|
|
76
41
|
let head = alias.get(to)
|
|
77
42
|
if (head === undefined) { head = to; alias.set(to, to) }
|
|
78
43
|
let arr = chains.get(head); if (!arr) { arr = []; chains.set(head, arr) }
|
|
79
44
|
arr.push({ hash, oid })
|
|
80
|
-
if (rename && paths[0] !== to) { alias.set(paths[0], head); alias.delete(to) }
|
|
45
|
+
if (rename && paths[0] !== to) { alias.set(paths[0], head); alias.delete(to) }
|
|
81
46
|
}
|
|
82
47
|
}
|
|
83
|
-
for (const k of [...chains.keys()]) if (!wanted.has(k)) chains.delete(k)
|
|
48
|
+
for (const k of [...chains.keys()]) if (!wanted.has(k)) chains.delete(k)
|
|
84
49
|
return chains
|
|
85
50
|
}
|
|
86
51
|
|
|
87
|
-
// per-scenario change-commits for one file's version chain: newest->oldest, attribute a commit to every
|
|
88
|
-
// scenario whose canonical block differs from the next-older version (undefined either side = add/remove = a
|
|
89
|
-
// change). A ZERO-oid (delete) version is dropped — it carries no readable content, matching the old
|
|
90
|
-
// `git show ''` skip; a pure rename (R100, oid == the older version's oid) diffs to no change for free.
|
|
91
52
|
function scenarioCommits(chain: { hash: string; oid: string }[]): Map<string, string[]> {
|
|
92
53
|
const commits = new Map<string, string[]>()
|
|
93
54
|
const push = (name: string, hash: string) => { const a = commits.get(name); if (a) a.push(hash); else commits.set(name, [hash]) }
|
|
@@ -142,8 +103,6 @@ function catFileBatch(root: string, oids: string[]): Promise<Map<string, string>
|
|
|
142
103
|
})
|
|
143
104
|
}
|
|
144
105
|
|
|
145
|
-
// TWO git subprocesses for the whole index (was F logs + V shows): one `--raw` log for the rename-followed
|
|
146
|
-
// chains, one `cat-file --batch` for every distinct blob not already memoized. Then a pure in-memory diff.
|
|
147
106
|
async function build(root: string, evalPaths: string[]): Promise<ScenarioIndex> {
|
|
148
107
|
const chains = await fileChains(root, new Set(evalPaths))
|
|
149
108
|
const need = new Set<string>()
|
|
@@ -153,35 +112,16 @@ async function build(root: string, evalPaths: string[]): Promise<ScenarioIndex>
|
|
|
153
112
|
for (const [oid, src] of blobs) blockByOid.set(oid, blockContent(src))
|
|
154
113
|
}
|
|
155
114
|
const idx: ScenarioIndex = new Map()
|
|
156
|
-
for (const p of evalPaths) idx.set(p, scenarioCommits(chains.get(p) ?? []))
|
|
115
|
+
for (const p of evalPaths) idx.set(p, scenarioCommits(chains.get(p) ?? []))
|
|
157
116
|
return idx
|
|
158
117
|
}
|
|
159
118
|
|
|
160
|
-
|
|
161
|
-
// whatever the caller. Holds the in-flight promise so concurrent board builds share one build.
|
|
162
|
-
const SLOTS = 16
|
|
119
|
+
const SLOTS = rootSlots(process.env.SPEXCODE_SCENARIO_CACHE_ROOTS, 16)
|
|
163
120
|
const cache = new Map<string, Promise<ScenarioIndex>>()
|
|
164
121
|
const roots = new Map<string, string>()
|
|
165
122
|
|
|
166
|
-
// A checkout moving from HEAD A to HEAD B no longer needs A's full scenario chains. Keep an old index only
|
|
167
|
-
// while another checkout still points at that immutable HEAD; otherwise a sequence of successful rebuilds
|
|
168
|
-
// retains one whole history-shaped index per commit until the broad LRU fills.
|
|
169
123
|
function touchRoot(root: string, head: string): void {
|
|
170
|
-
|
|
171
|
-
if (previous === head) {
|
|
172
|
-
roots.delete(root)
|
|
173
|
-
roots.set(root, head)
|
|
174
|
-
return
|
|
175
|
-
}
|
|
176
|
-
roots.set(root, head)
|
|
177
|
-
if (previous && ![...roots.values()].includes(previous)) cache.delete(previous)
|
|
178
|
-
while (roots.size > SLOTS) {
|
|
179
|
-
const oldest = roots.keys().next().value as string | undefined
|
|
180
|
-
if (oldest === undefined) break
|
|
181
|
-
const oldHead = roots.get(oldest)
|
|
182
|
-
roots.delete(oldest)
|
|
183
|
-
if (oldHead && ![...roots.values()].includes(oldHead)) cache.delete(oldHead)
|
|
184
|
-
}
|
|
124
|
+
touchRootLru(roots, cache, root, head, SLOTS)
|
|
185
125
|
}
|
|
186
126
|
|
|
187
127
|
export function scenarioIndex(root: string, evalPaths: string[]): Promise<ScenarioIndex> {
|
|
@@ -205,13 +145,6 @@ export function scenarioChangeCommits(idx: ScenarioIndex, evalPath: string, scen
|
|
|
205
145
|
return idx.get(evalPath)?.get(scenario) ?? []
|
|
206
146
|
}
|
|
207
147
|
|
|
208
|
-
// (root, sha, path) -> the blob oid at that commit ('' = unresolvable: path absent there, or the commit
|
|
209
|
-
// object gone). A FULL sha names an immutable tree, so entries never invalidate (freshness.ts's diffMemo
|
|
210
|
-
// reasoning); the LRU only bounds memory, sized above the largest adopter reading corpus — one entry per
|
|
211
|
-
// (reading, path) worst case — so a repeat board build never thrashes back into forking. This memo is what
|
|
212
|
-
// keeps a fully off-history corpus (an adopter history rewrite) cheap on REPEAT builds: without it every
|
|
213
|
-
// scenarioDiffers call re-forked `git rev-parse` for BOTH sides, per reading, per build (spexcode#39). A
|
|
214
|
-
// symbolic rev (HEAD, a branch) moves, so it resolves live and is never cached.
|
|
215
148
|
const FULL_SHA = /^[0-9a-f]{40}$/
|
|
216
149
|
const oidMemo = new Map<string, string>()
|
|
217
150
|
function oidAt(root: string, rev: string, path: string): string {
|
|
@@ -248,11 +181,6 @@ export async function primeScenarioBlocksAt(root: string, revs: string[], path:
|
|
|
248
181
|
}
|
|
249
182
|
}
|
|
250
183
|
|
|
251
|
-
// canonical per-scenario SEMANTIC blocks of `rev:path` (the blockContent projection), for the off-history
|
|
252
|
-
// content fallback ([[eval-core]]'s ContentProbe): resolve the blob oid first (memoized above for a full
|
|
253
|
-
// sha) — oids are content-addressed, so an unchanged file usually hits blockByOid straight from the index
|
|
254
|
-
// build — and parse only on a genuine miss. null = the path is unreadable at that rev (absent, renamed
|
|
255
|
-
// since, or the rev itself is gone).
|
|
256
184
|
export function scenarioBlocksAt(root: string, rev: string, path: string): Map<string, string> | null {
|
|
257
185
|
const oid = oidAt(root, rev, path)
|
|
258
186
|
if (!oid) return null
|
|
@@ -24,10 +24,6 @@ export type Scenario = {
|
|
|
24
24
|
related?: string[]
|
|
25
25
|
}
|
|
26
26
|
|
|
27
|
-
// The scenario index is a declaration projection, not a reading view. Keep its three identity layers
|
|
28
|
-
// explicit: scenarioHash is the measurement-contract hash, semanticIndexHash is the canonical declaration
|
|
29
|
-
// index, and fullIndexHash adds only the measuring-hand test mapping. Git provenance is supplied by the CLI
|
|
30
|
-
// outside both hashes, so a mode/type change cannot masquerade as a scenario-content change.
|
|
31
27
|
export const SCENARIO_PROJECTION = 'spex.eval.scenario-index'
|
|
32
28
|
export const SCENARIO_SCHEMA_VERSION = 1
|
|
33
29
|
export type ScenarioSemanticRow = {
|
|
@@ -56,13 +52,11 @@ export type ScenarioProjection = {
|
|
|
56
52
|
}
|
|
57
53
|
|
|
58
54
|
export type EvalNode = {
|
|
59
|
-
id: string
|
|
60
|
-
dir: string
|
|
61
|
-
evalPath: string
|
|
62
|
-
sidecarPath: string
|
|
55
|
+
id: string
|
|
56
|
+
dir: string
|
|
57
|
+
evalPath: string
|
|
58
|
+
sidecarPath: string
|
|
63
59
|
scenarios: Scenario[]
|
|
64
|
-
// The exact declaration bytes used to build this node, present for real filesystem and fixed-tree walks.
|
|
65
|
-
// Synthetic EvalNode values in narrow unit tests may omit it.
|
|
66
60
|
evalSource?: string
|
|
67
61
|
}
|
|
68
62
|
|
|
@@ -81,8 +75,6 @@ type RawTestObject = {
|
|
|
81
75
|
|
|
82
76
|
type RawFieldLocation = { startLine: number; endLine: number; indent: string }
|
|
83
77
|
|
|
84
|
-
// a raw scenario item straight off the frontmatter walk: the known fields it set, plus any UNKNOWN keys it
|
|
85
|
-
// carried — kept (not dropped) so the validator can name a typo'd field instead of silently swallowing it.
|
|
86
78
|
type RawItem = {
|
|
87
79
|
fields: Partial<Record<ScenarioKey, string>>
|
|
88
80
|
testObject?: RawTestObject
|
|
@@ -95,7 +87,7 @@ type RawItem = {
|
|
|
95
87
|
|
|
96
88
|
const leadingIndent = (line: string): string => line.match(/^[ \t]*/)?.[0] ?? ''
|
|
97
89
|
|
|
98
|
-
//
|
|
90
|
+
// Keep parsing and validation on the same structural walk.
|
|
99
91
|
function walkScenarios(src: string): { hasFrontmatter: boolean; hasKey: boolean; items: RawItem[]; malformed: string[] } {
|
|
100
92
|
const normalized = src.replace(/\r\n?/g, '\n')
|
|
101
93
|
const m = normalized.match(/^---\n([\s\S]*?)\n---(?:\n|$)/)
|
|
@@ -120,7 +112,6 @@ function walkScenarios(src: string): { hasFrontmatter: boolean; hasKey: boolean;
|
|
|
120
112
|
const trimmed = line.trim()
|
|
121
113
|
const dash = trimmed.startsWith('- ') || trimmed === '-'
|
|
122
114
|
if (dash && (itemIndent < 0 || indent <= itemIndent)) {
|
|
123
|
-
// a new scenario item. start fresh; the `- ` may carry the first field inline.
|
|
124
115
|
cur = {
|
|
125
116
|
fields: {}, unknownKeys: [], duplicateKeys: [], malformed: [], locations: {},
|
|
126
117
|
...(trimmed.slice(1).trim() ? { fieldIndent: `${prefix} ` } : {}),
|
|
@@ -146,11 +137,6 @@ function walkScenarios(src: string): { hasFrontmatter: boolean; hasKey: boolean;
|
|
|
146
137
|
return { hasFrontmatter: true, hasKey: true, items, malformed }
|
|
147
138
|
}
|
|
148
139
|
|
|
149
|
-
// assign a `key: value` field to the current item. When the value is a block-scalar indicator (`|`
|
|
150
|
-
// literal / `>` folded), consume the following more-indented lines as the value and return the index of
|
|
151
|
-
// the LAST consumed line (the for-loop's ++ then moves past it); otherwise return `idx` unchanged. A key
|
|
152
|
-
// outside the schema is recorded under unknownKeys (still consuming its block, so the body isn't misread as
|
|
153
|
-
// new items) rather than dropped — validateScenarios needs to see it to reject the typo.
|
|
154
140
|
function assignField(cur: RawItem, kv: string, lines: string[], idx: number, keyIndent: number, inline = false): number {
|
|
155
141
|
const f = kv.match(/^([A-Za-z_][\w-]*):\s*(.*)$/)
|
|
156
142
|
if (!f) {
|
|
@@ -200,7 +186,6 @@ function assignField(cur: RawItem, kv: string, lines: string[], idx: number, key
|
|
|
200
186
|
return finish(idx)
|
|
201
187
|
}
|
|
202
188
|
}
|
|
203
|
-
// a list field (`code:`/`related:`) may be a YAML block sequence (`- item` lines); the scalar reader can't see those, so collect them here into the comma form parseCodeList expects
|
|
204
189
|
if ((LIST_KEYS as readonly string[]).includes(key) && f[2].trim() === '') {
|
|
205
190
|
const items: string[] = []
|
|
206
191
|
let lastItem = idx
|
|
@@ -234,8 +219,7 @@ function assignField(cur: RawItem, kv: string, lines: string[], idx: number, key
|
|
|
234
219
|
const tabInIndent = tabBeforeContent && spaces.length < requiredIndent
|
|
235
220
|
if (tabInIndent) cur.malformed.push(`tab indentation is not valid in block scalar \`${key}\``)
|
|
236
221
|
if (!l.trim() && !tabBeforeContent) { body.push(''); continue }
|
|
237
|
-
//
|
|
238
|
-
// scalar content and must survive parsing; before the block's required depth it is illegal indentation.
|
|
222
|
+
// A tab after the required indent is scalar content, not indentation.
|
|
239
223
|
const ind = tabInIndent ? leadingIndent(l).length : spaces.length
|
|
240
224
|
if (ind <= keyIndent) break // dedented to a sibling field / next item → the block is done
|
|
241
225
|
if (base < 0) base = ind
|
|
@@ -275,8 +259,7 @@ function assignTestField(obj: RawTestObject, entry: string): void {
|
|
|
275
259
|
obj.fields[key as TestKey] = testValue(f[2], key === 'name')
|
|
276
260
|
}
|
|
277
261
|
|
|
278
|
-
//
|
|
279
|
-
// an opaque case name such as "allows admin, user" survives byte-for-byte after YAML quote removal.
|
|
262
|
+
// Test names may contain commas inside quotes.
|
|
280
263
|
function parseFlowTestObject(raw: string): RawTestObject {
|
|
281
264
|
const obj = emptyTestObject()
|
|
282
265
|
if (!raw.startsWith('{') || !raw.endsWith('}')) {
|
|
@@ -317,36 +300,23 @@ function normalizedTest(it: RawItem): ScenarioTestReference | undefined {
|
|
|
317
300
|
return path ? { path } : undefined
|
|
318
301
|
}
|
|
319
302
|
|
|
320
|
-
// @@@scenario contract hash - the deterministic content hash of a scenario's SEMANTIC text, stamped on each
|
|
321
|
-
// reading at filing time ([[eval-core]]'s scenario freshness axis). Hashes ONLY the measurement contract —
|
|
322
|
-
// description (what to measure) + expected (what zero loss looks like) — never name/tags/test/code/related.
|
|
323
|
-
// Normalization (spec'd, don't drift): each field independently collapses every whitespace run (space, tab,
|
|
324
|
-
// CR, LF) to a single space and trims its ends, so a prose re-wrap, an indent shift, CRLF churn or trailing
|
|
325
|
-
// whitespace never changes the hash; the two normalized fields join with a single '\n' (neither can contain
|
|
326
|
-
// one after normalization, so the join is unambiguous) and sha256-hex the UTF-8 bytes. The hash is pure text
|
|
327
|
-
// over the parsed declaration — no git, no file position — so it is identical wherever and however the same
|
|
328
|
-
// contract text is read.
|
|
329
303
|
const normSemantic = (s: string) => s.replace(/\s+/g, ' ').trim()
|
|
330
304
|
export function scenarioHash(s: Pick<Scenario, 'description' | 'expected'>): string {
|
|
331
305
|
return createHash('sha256').update(`${normSemantic(s.description)}\n${normSemantic(s.expected)}`, 'utf8').digest('hex')
|
|
332
306
|
}
|
|
333
307
|
|
|
334
|
-
// @@@scenario code axis - the ONE resolution of a scenario's code freshness axis, so a declaration can never
|
|
335
|
-
// mean two things to two consumers. A scenario's own `code:` narrows the node's list; absent, it inherits it
|
|
336
|
-
// whole ([[eval-core]]). Each entry may carry [[code-anchor]]'s `path#symbol` selectors, folded per base file
|
|
337
|
-
// by the SAME structural parser spec relations use (several selectors on one file OR together; duplicates,
|
|
338
|
-
// bare+scoped mixing and a selector on a glob come back as `problems` for lint to report).
|
|
339
|
-
// `paths` is what every PATH consumer must read — changed-scan selection, session impact, drift display,
|
|
340
|
-
// the ghost-path check — because a raw `path#symbol` string matches no real file and would silently drop the
|
|
341
|
-
// scenario out of those sets instead of narrowing it. `entries` is what the freshness code axis narrows with.
|
|
342
308
|
export type ScenarioCodeAxis = { entries: RelationEntry[]; paths: string[]; problems: string[] }
|
|
343
|
-
export
|
|
344
|
-
|
|
309
|
+
export type ScenarioCodeAxisSource = readonly string[] | readonly RelationEntry[]
|
|
310
|
+
export function scenarioCodeAxis(scenarioCode: readonly string[] | undefined, nodeCode: ScenarioCodeAxisSource = []): ScenarioCodeAxis {
|
|
311
|
+
const parsed = scenarioCode?.length
|
|
312
|
+
? parseRelation([...scenarioCode], 'code')
|
|
313
|
+
: nodeCode.length && typeof nodeCode[0] !== 'string'
|
|
314
|
+
? { entries: (nodeCode as readonly RelationEntry[]).map((e) => ({ path: e.path, selectors: [...e.selectors] })), problems: [] }
|
|
315
|
+
: parseRelation([...(nodeCode as readonly string[])], 'code')
|
|
316
|
+
const { entries, problems } = parsed
|
|
345
317
|
return { entries, paths: entries.map((e) => e.path), problems }
|
|
346
318
|
}
|
|
347
319
|
|
|
348
|
-
// a scenario's optional list field (`code:`/`related:`) is a comma-separated path list (a YAML flow list
|
|
349
|
-
// `[a, b]` or bare `a, b`, or a single path) — the tiny parser stays scalar-only, so it is split here.
|
|
350
320
|
function parseCodeList(raw: string): string[] {
|
|
351
321
|
return raw.replace(/^\[|\]$/g, '').split(',').map((s) => unquote(s.trim())).filter(Boolean)
|
|
352
322
|
}
|
|
@@ -368,7 +338,7 @@ export function parseScenarios(src: string): Scenario[] {
|
|
|
368
338
|
...(related.length ? { related } : {}),
|
|
369
339
|
}
|
|
370
340
|
})
|
|
371
|
-
.filter((s) => s.name)
|
|
341
|
+
.filter((s) => s.name)
|
|
372
342
|
}
|
|
373
343
|
|
|
374
344
|
const compareStable = (a: string, b: string): number => a < b ? -1 : a > b ? 1 : 0
|
|
@@ -380,8 +350,6 @@ const hashProjection = (value: unknown): string =>
|
|
|
380
350
|
|
|
381
351
|
const semanticOnly = (row: ScenarioProjectionRow): ScenarioSemanticRow => row.semantic
|
|
382
352
|
|
|
383
|
-
// Build one canonical declaration index from the already-parsed scenarios. No eval sidecar is touched here;
|
|
384
|
-
// callers can safely use this projection to compare fixed trees without importing the reading/freshness path.
|
|
385
353
|
export function scenarioProjection(
|
|
386
354
|
nodes: readonly Pick<EvalNode, 'id' | 'scenarios' | 'evalSource'>[],
|
|
387
355
|
provenance: Partial<ScenarioProjectionProvenance> = {},
|
|
@@ -426,10 +394,6 @@ export function scenarioProjection(
|
|
|
426
394
|
}
|
|
427
395
|
}
|
|
428
396
|
|
|
429
|
-
// `tagLibrary` is the closed vocabulary a scenario's `tags:` must draw from (config's `lint.scenarioTags`).
|
|
430
|
-
// Every scenario needs ≥1 tag; each tag must be IN the library — an out-of-library tag is rejected LOUD with
|
|
431
|
-
// the repair the user owns: pick an existing tag, or extend the library. An empty library (none configured)
|
|
432
|
-
// disables only the membership check, never the ≥1-tag requirement.
|
|
433
397
|
export function validateScenarios(src: string, tagLibrary: string[] = [], pathRoot?: string): string[] {
|
|
434
398
|
const { hasFrontmatter, hasKey, items, malformed } = walkScenarios(src)
|
|
435
399
|
if (!hasFrontmatter) return ['no frontmatter block — an eval.md must declare a `scenarios:` list']
|
|
@@ -528,9 +492,6 @@ function malformedDeclaration(errors: string[]): Error {
|
|
|
528
492
|
return new Error(`malformed eval.md:\n${errors.map((error) => ` - ${error}`).join('\n')}`)
|
|
529
493
|
}
|
|
530
494
|
|
|
531
|
-
// Canonical write half of the declaration identity. The caller supplies authoritative bytes and one closed
|
|
532
|
-
// semantic mutation; source locations come only from the same structural walk parseScenarios/validation use.
|
|
533
|
-
// Untouched lines are never serialized, which is what makes insert -> delete a byte-exact inverse.
|
|
534
495
|
export function writeScenarioMeasurementMetadata(source: string, request: unknown): string {
|
|
535
496
|
const mutation = parseMetadataMutation(request)
|
|
536
497
|
const beforeErrors = validateScenarios(source)
|
|
@@ -582,12 +543,6 @@ export function writeScenarioMeasurementMetadata(source: string, request: unknow
|
|
|
582
543
|
return proposed
|
|
583
544
|
}
|
|
584
545
|
|
|
585
|
-
// walk `.spec` for every dir holding an eval.md; the node id is its CANONICAL spec id ([[id-url-safe]]) —
|
|
586
|
-
// minted by the SAME rule as specs.ts's loader (mintIds: the leaf dir name, or on a leaf collision the
|
|
587
|
-
// shortest globally-unique '_'-joined trailing suffix) over the SAME universe (every dir holding a spec.md,
|
|
588
|
-
// not just the eval subset — a leaf that collides among spec nodes is disambiguated even when only one of
|
|
589
|
-
// them measures). So the id eval verbs answer to is exactly the id board/scan/search already print — never a
|
|
590
|
-
// second, diverging bare-leaf scheme. An eval.md beside no spec.md keeps its leaf name (no spec id to align with).
|
|
591
546
|
function assembleNodes(root: string, specDirs: string[], hits: { dir: string; src: string }[]): EvalNode[] {
|
|
592
547
|
const specBase = join(root, '.spec')
|
|
593
548
|
const ids = mintIds(specDirs.map((d) => relative(specBase, d).split(/[/\\]/)))
|
|
@@ -620,8 +575,6 @@ export function evalNodes(root: string): EvalNode[] {
|
|
|
620
575
|
return assembleNodes(root, specDirs, hits)
|
|
621
576
|
}
|
|
622
577
|
|
|
623
|
-
// Exact fixed-tree twin for the canonical JSON seam. It reads only eval.md/spec.md bytes from `tip`, then
|
|
624
|
-
// reuses assembleNodes/parseScenarios; no working-tree declaration can be paired with that tree's provenance.
|
|
625
578
|
export function evalNodesAt(root: string, tip: string): EvalNode[] {
|
|
626
579
|
const files = treeTextFiles(root, tip, '.spec')
|
|
627
580
|
const paths = [...files.keys()]
|
|
@@ -659,10 +612,6 @@ export async function evalNodesAsync(root: string): Promise<EvalNode[]> {
|
|
|
659
612
|
|
|
660
613
|
export type EvalResolution<T> = { ok: true; node: T } | { ok: false; ambiguous: boolean; error: string }
|
|
661
614
|
|
|
662
|
-
// resolve a user-supplied node ref against the measurable set: an EXACT canonical id always wins; a bare leaf
|
|
663
|
-
// name stays the convenience it always was while it names exactly ONE measurable node; a leaf several nodes
|
|
664
|
-
// share fails LOUD listing the candidate canonical ids — never an arbitrary first hit, so a reading can
|
|
665
|
-
// only land on the node the caller actually named.
|
|
666
615
|
export function resolveEvalNode<T extends Pick<EvalNode, 'id' | 'dir'>>(nodes: T[], ref: string): EvalResolution<T> {
|
|
667
616
|
const exact = nodes.find((n) => n.id === ref)
|
|
668
617
|
if (exact) return { ok: true, node: exact }
|