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
|
@@ -3,6 +3,8 @@ import { readFile, readdir } from 'node:fs/promises'
|
|
|
3
3
|
import { createHash } from 'node:crypto'
|
|
4
4
|
import { join, relative, basename } from 'node:path'
|
|
5
5
|
import { mintIds } from '../../spec-cli/src/specs.js'
|
|
6
|
+
import { parseRelation, type RelationEntry } from '../../spec-cli/src/anchors.js'
|
|
7
|
+
import { treeTextFiles } from '../../spec-cli/src/git.js'
|
|
6
8
|
|
|
7
9
|
export const EVAL_FILE = 'eval.md'
|
|
8
10
|
export const SIDECAR_FILE = 'evals.ndjson'
|
|
@@ -22,12 +24,46 @@ export type Scenario = {
|
|
|
22
24
|
related?: string[]
|
|
23
25
|
}
|
|
24
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
|
+
export const SCENARIO_PROJECTION = 'spex.eval.scenario-index'
|
|
32
|
+
export const SCENARIO_SCHEMA_VERSION = 1
|
|
33
|
+
export type ScenarioSemanticRow = {
|
|
34
|
+
node: string
|
|
35
|
+
name: string
|
|
36
|
+
description: string
|
|
37
|
+
expected: string
|
|
38
|
+
scenarioHash: string
|
|
39
|
+
code: RelationEntry[]
|
|
40
|
+
related: RelationEntry[]
|
|
41
|
+
tags: string[]
|
|
42
|
+
}
|
|
43
|
+
export type ScenarioMeasurementRow = { test: ScenarioTestReference | null }
|
|
44
|
+
export type ScenarioProjectionRow = {
|
|
45
|
+
semantic: ScenarioSemanticRow
|
|
46
|
+
measurement: ScenarioMeasurementRow
|
|
47
|
+
}
|
|
48
|
+
export type ScenarioProjectionProvenance = { head: string | null; treeSha: string | null }
|
|
49
|
+
export type ScenarioProjection = {
|
|
50
|
+
projection: typeof SCENARIO_PROJECTION
|
|
51
|
+
schemaVersion: typeof SCENARIO_SCHEMA_VERSION
|
|
52
|
+
provenance: ScenarioProjectionProvenance
|
|
53
|
+
semanticIndexHash: string
|
|
54
|
+
fullIndexHash: string
|
|
55
|
+
rows: ScenarioProjectionRow[]
|
|
56
|
+
}
|
|
57
|
+
|
|
25
58
|
export type EvalNode = {
|
|
26
59
|
id: string // the node's CANONICAL spec id (leaf name, '_'-disambiguated on a leaf collision)
|
|
27
60
|
dir: string // absolute node directory
|
|
28
61
|
evalPath: string // repo-relative path to eval.md — the SCENARIO freshness axis
|
|
29
62
|
sidecarPath: string // absolute path to evals.ndjson
|
|
30
63
|
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
|
+
evalSource?: string
|
|
31
67
|
}
|
|
32
68
|
|
|
33
69
|
const SCENARIO_KEYS = ['name', 'description', 'expected', 'tags', 'test', 'code', 'related'] as const
|
|
@@ -43,41 +79,71 @@ type RawTestObject = {
|
|
|
43
79
|
malformed: string[]
|
|
44
80
|
}
|
|
45
81
|
|
|
82
|
+
type RawFieldLocation = { startLine: number; endLine: number; indent: string }
|
|
83
|
+
|
|
46
84
|
// a raw scenario item straight off the frontmatter walk: the known fields it set, plus any UNKNOWN keys it
|
|
47
85
|
// carried — kept (not dropped) so the validator can name a typo'd field instead of silently swallowing it.
|
|
48
|
-
type RawItem = {
|
|
86
|
+
type RawItem = {
|
|
87
|
+
fields: Partial<Record<ScenarioKey, string>>
|
|
88
|
+
testObject?: RawTestObject
|
|
89
|
+
unknownKeys: string[]
|
|
90
|
+
duplicateKeys: string[]
|
|
91
|
+
malformed: string[]
|
|
92
|
+
locations: Partial<Record<ScenarioKey, RawFieldLocation>>
|
|
93
|
+
fieldIndent?: string
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
const leadingIndent = (line: string): string => line.match(/^[ \t]*/)?.[0] ?? ''
|
|
49
97
|
|
|
50
98
|
// tiny indentation parser for eval.md's frontmatter `scenarios:` block (no YAML dep), shared by parseScenarios and validateScenarios so they can't disagree; reports hasFrontmatter/hasKey so the validator can tell "none declared" from "malformed"
|
|
51
|
-
function walkScenarios(src: string): { hasFrontmatter: boolean; hasKey: boolean; items: RawItem[] } {
|
|
52
|
-
const
|
|
53
|
-
|
|
99
|
+
function walkScenarios(src: string): { hasFrontmatter: boolean; hasKey: boolean; items: RawItem[]; malformed: string[] } {
|
|
100
|
+
const normalized = src.replace(/\r\n?/g, '\n')
|
|
101
|
+
const m = normalized.match(/^---\n([\s\S]*?)\n---(?:\n|$)/)
|
|
102
|
+
if (!m) return { hasFrontmatter: false, hasKey: false, items: [], malformed: [] }
|
|
54
103
|
const lines = m[1].split('\n')
|
|
55
|
-
|
|
56
|
-
|
|
104
|
+
const scenarioKeys = lines.flatMap((line, index) => /^scenarios:\s*$/.test(line) ? [index] : [])
|
|
105
|
+
let i = scenarioKeys[0] ?? -1
|
|
106
|
+
if (i < 0) return { hasFrontmatter: true, hasKey: false, items: [], malformed: [] }
|
|
57
107
|
const items: RawItem[] = []
|
|
108
|
+
const malformed = scenarioKeys.length > 1
|
|
109
|
+
? [`duplicate top-level \`scenarios:\` key (${scenarioKeys.length}×) — eval.md must have exactly one declaration list`]
|
|
110
|
+
: []
|
|
58
111
|
let cur: RawItem | null = null
|
|
59
112
|
let itemIndent = -1 // the indent of the `- ` that starts each scenario (set by the first one)
|
|
60
|
-
const indentOf = (l: string) => l.length - l.replace(/^\s+/, '').length
|
|
61
113
|
for (i++; i < lines.length; i++) {
|
|
62
114
|
const line = lines[i]
|
|
63
115
|
if (!line.trim()) continue
|
|
64
|
-
const
|
|
116
|
+
const prefix = leadingIndent(line)
|
|
117
|
+
if (prefix.includes('\t')) malformed.push(`line ${i + 2}: tab indentation is not valid in an eval.md scenario mapping`)
|
|
118
|
+
const indent = prefix.length
|
|
65
119
|
if (indent === 0) break // dedented to another top-level key — scenarios block is done
|
|
66
120
|
const trimmed = line.trim()
|
|
67
121
|
const dash = trimmed.startsWith('- ') || trimmed === '-'
|
|
68
122
|
if (dash && (itemIndent < 0 || indent <= itemIndent)) {
|
|
69
123
|
// a new scenario item. start fresh; the `- ` may carry the first field inline.
|
|
70
|
-
cur = {
|
|
124
|
+
cur = {
|
|
125
|
+
fields: {}, unknownKeys: [], duplicateKeys: [], malformed: [], locations: {},
|
|
126
|
+
...(trimmed.slice(1).trim() ? { fieldIndent: `${prefix} ` } : {}),
|
|
127
|
+
}
|
|
71
128
|
items.push(cur)
|
|
72
129
|
itemIndent = indent
|
|
73
130
|
const inline = trimmed.slice(1).trim() // text after the dash
|
|
74
|
-
if (inline) i = assignField(cur, inline, lines, i, indent)
|
|
131
|
+
if (inline) i = assignField(cur, inline, lines, i, indent, true)
|
|
132
|
+
continue
|
|
133
|
+
}
|
|
134
|
+
if (!cur) {
|
|
135
|
+
if (!trimmed.startsWith('#')) malformed.push(`invalid scenarios entry \`${trimmed}\` before the first scenario`)
|
|
75
136
|
continue
|
|
76
137
|
}
|
|
77
|
-
if (!
|
|
138
|
+
if (!trimmed.startsWith('#')) {
|
|
139
|
+
if (cur.fieldIndent === undefined) cur.fieldIndent = prefix
|
|
140
|
+
else if (prefix !== cur.fieldIndent) {
|
|
141
|
+
cur.malformed.push(`inconsistent scenario field indentation: expected ${cur.fieldIndent.length} spaces, got ${prefix.length}`)
|
|
142
|
+
}
|
|
143
|
+
}
|
|
78
144
|
i = assignField(cur, trimmed, lines, i, indent)
|
|
79
145
|
}
|
|
80
|
-
return { hasFrontmatter: true, hasKey: true, items }
|
|
146
|
+
return { hasFrontmatter: true, hasKey: true, items, malformed }
|
|
81
147
|
}
|
|
82
148
|
|
|
83
149
|
// assign a `key: value` field to the current item. When the value is a block-scalar indicator (`|`
|
|
@@ -85,21 +151,40 @@ function walkScenarios(src: string): { hasFrontmatter: boolean; hasKey: boolean;
|
|
|
85
151
|
// the LAST consumed line (the for-loop's ++ then moves past it); otherwise return `idx` unchanged. A key
|
|
86
152
|
// outside the schema is recorded under unknownKeys (still consuming its block, so the body isn't misread as
|
|
87
153
|
// new items) rather than dropped — validateScenarios needs to see it to reject the typo.
|
|
88
|
-
function assignField(cur: RawItem, kv: string, lines: string[], idx: number, keyIndent: number): number {
|
|
154
|
+
function assignField(cur: RawItem, kv: string, lines: string[], idx: number, keyIndent: number, inline = false): number {
|
|
89
155
|
const f = kv.match(/^([A-Za-z_][\w-]*):\s*(.*)$/)
|
|
90
|
-
if (!f)
|
|
156
|
+
if (!f) {
|
|
157
|
+
if (!kv.startsWith('#')) cur.malformed.push(`invalid scenario entry \`${kv}\``)
|
|
158
|
+
return idx
|
|
159
|
+
}
|
|
91
160
|
const key = f[1]
|
|
161
|
+
const scenarioKey = (SCENARIO_KEYS as readonly string[]).includes(key) ? key as ScenarioKey : null
|
|
162
|
+
const finish = (parserEnd: number, locationEnd = parserEnd): number => {
|
|
163
|
+
if (scenarioKey) {
|
|
164
|
+
if (cur.locations[scenarioKey]) cur.duplicateKeys.push(key)
|
|
165
|
+
else cur.locations[scenarioKey] = {
|
|
166
|
+
startLine: idx,
|
|
167
|
+
endLine: locationEnd,
|
|
168
|
+
indent: `${leadingIndent(lines[idx])}${inline ? ' ' : ''}`,
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
return parserEnd
|
|
172
|
+
}
|
|
92
173
|
if (key === 'test') {
|
|
93
174
|
const raw = f[2].trim()
|
|
94
175
|
if (!raw) {
|
|
95
176
|
const parsed = emptyTestObject()
|
|
96
177
|
let childIndent = -1
|
|
178
|
+
let lastChild = idx
|
|
97
179
|
let j = idx + 1
|
|
98
180
|
for (; j < lines.length; j++) {
|
|
99
181
|
const line = lines[j]
|
|
100
182
|
if (!line.trim()) continue
|
|
101
|
-
const
|
|
183
|
+
const prefix = leadingIndent(line)
|
|
184
|
+
if (prefix.includes('\t')) parsed.malformed.push(`tab indentation is not valid in nested \`test\` metadata`)
|
|
185
|
+
const indent = prefix.length
|
|
102
186
|
if (indent <= keyIndent) break
|
|
187
|
+
lastChild = j
|
|
103
188
|
if (childIndent < 0) childIndent = indent
|
|
104
189
|
if (indent !== childIndent) {
|
|
105
190
|
parsed.malformed.push(`invalid nested test object entry \`${line.trim()}\``)
|
|
@@ -108,27 +193,31 @@ function assignField(cur: RawItem, kv: string, lines: string[], idx: number, key
|
|
|
108
193
|
assignTestField(parsed, line.trim())
|
|
109
194
|
}
|
|
110
195
|
cur.testObject = parsed
|
|
111
|
-
return j - 1
|
|
196
|
+
return finish(j - 1, lastChild)
|
|
112
197
|
}
|
|
113
198
|
if (raw.startsWith('{') || raw.endsWith('}')) {
|
|
114
199
|
cur.testObject = parseFlowTestObject(raw)
|
|
115
|
-
return idx
|
|
200
|
+
return finish(idx)
|
|
116
201
|
}
|
|
117
202
|
}
|
|
118
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
|
|
119
204
|
if ((LIST_KEYS as readonly string[]).includes(key) && f[2].trim() === '') {
|
|
120
205
|
const items: string[] = []
|
|
206
|
+
let lastItem = idx
|
|
121
207
|
let j = idx + 1
|
|
122
208
|
for (; j < lines.length; j++) {
|
|
123
209
|
const l = lines[j]
|
|
124
210
|
if (!l.trim()) continue
|
|
125
|
-
const
|
|
211
|
+
const prefix = leadingIndent(l)
|
|
212
|
+
if (prefix.includes('\t')) cur.malformed.push(`tab indentation is not valid in \`${key}\` metadata`)
|
|
213
|
+
const ind = prefix.length
|
|
126
214
|
if (ind <= keyIndent) break
|
|
127
215
|
const it = l.trim().match(/^-\s*(.+)$/)
|
|
128
216
|
if (!it) break
|
|
129
217
|
items.push(unquote(it[1]))
|
|
218
|
+
lastItem = j
|
|
130
219
|
}
|
|
131
|
-
if (items.length) { cur.fields[key as ScenarioKey] = items.join(','); return j - 1 }
|
|
220
|
+
if (items.length) { cur.fields[key as ScenarioKey] = items.join(','); return finish(j - 1, lastItem) }
|
|
132
221
|
}
|
|
133
222
|
let value: string
|
|
134
223
|
let end = idx
|
|
@@ -139,21 +228,29 @@ function assignField(cur: RawItem, kv: string, lines: string[], idx: number, key
|
|
|
139
228
|
let base = -1, j = idx + 1
|
|
140
229
|
for (; j < lines.length; j++) {
|
|
141
230
|
const l = lines[j]
|
|
142
|
-
|
|
143
|
-
const
|
|
231
|
+
const spaces = l.match(/^ */)?.[0] ?? ''
|
|
232
|
+
const tabBeforeContent = l[spaces.length] === '\t'
|
|
233
|
+
const requiredIndent = base < 0 ? keyIndent + 1 : base
|
|
234
|
+
const tabInIndent = tabBeforeContent && spaces.length < requiredIndent
|
|
235
|
+
if (tabInIndent) cur.malformed.push(`tab indentation is not valid in block scalar \`${key}\``)
|
|
236
|
+
if (!l.trim() && !tabBeforeContent) { body.push(''); continue }
|
|
237
|
+
// YAML indentation is spaces-only. Once the line is deeper than its key, a leading TAB belongs to the
|
|
238
|
+
// scalar content and must survive parsing; before the block's required depth it is illegal indentation.
|
|
239
|
+
const ind = tabInIndent ? leadingIndent(l).length : spaces.length
|
|
144
240
|
if (ind <= keyIndent) break // dedented to a sibling field / next item → the block is done
|
|
145
241
|
if (base < 0) base = ind
|
|
242
|
+
else if (ind < base) cur.malformed.push(`inconsistent block scalar indentation in \`${key}\`: expected at least ${base} spaces, got ${ind}`)
|
|
146
243
|
body.push(l.slice(base))
|
|
147
244
|
}
|
|
148
245
|
while (body.length && body[body.length - 1] === '') body.pop() // strip trailing blanks
|
|
149
246
|
value = fold ? body.join(' ').replace(/\s+/g, ' ').trim() : body.join('\n')
|
|
150
247
|
end = j - 1
|
|
151
248
|
} else {
|
|
152
|
-
value = unquote(f[2])
|
|
249
|
+
value = key === 'test' ? testValue(f[2]) : unquote(f[2])
|
|
153
250
|
}
|
|
154
251
|
if ((SCENARIO_KEYS as readonly string[]).includes(key)) cur.fields[key as ScenarioKey] = value
|
|
155
252
|
else cur.unknownKeys.push(key)
|
|
156
|
-
return end
|
|
253
|
+
return finish(end)
|
|
157
254
|
}
|
|
158
255
|
|
|
159
256
|
const unquote = (s: string) => s.replace(/^["'](.*)["']$/, '$1').trim()
|
|
@@ -162,6 +259,9 @@ const emptyTestObject = (): RawTestObject => ({ fields: {}, unknownKeys: [], dup
|
|
|
162
259
|
|
|
163
260
|
function testValue(raw: string, opaque = false): string {
|
|
164
261
|
const value = raw.trim()
|
|
262
|
+
if (value.startsWith('"') && value.endsWith('"')) {
|
|
263
|
+
try { return JSON.parse(value) } catch { /* fall through to the parser's legacy quote handling */ }
|
|
264
|
+
}
|
|
165
265
|
const quoted = value.match(/^(["'])([\s\S]*)\1$/)
|
|
166
266
|
return quoted ? quoted[2] : opaque ? value : unquote(value)
|
|
167
267
|
}
|
|
@@ -231,6 +331,20 @@ export function scenarioHash(s: Pick<Scenario, 'description' | 'expected'>): str
|
|
|
231
331
|
return createHash('sha256').update(`${normSemantic(s.description)}\n${normSemantic(s.expected)}`, 'utf8').digest('hex')
|
|
232
332
|
}
|
|
233
333
|
|
|
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
|
+
export type ScenarioCodeAxis = { entries: RelationEntry[]; paths: string[]; problems: string[] }
|
|
343
|
+
export function scenarioCodeAxis(scenarioCode: readonly string[] | undefined, nodeCode: readonly string[] = []): ScenarioCodeAxis {
|
|
344
|
+
const { entries, problems } = parseRelation([...(scenarioCode?.length ? scenarioCode : nodeCode)], 'code')
|
|
345
|
+
return { entries, paths: entries.map((e) => e.path), problems }
|
|
346
|
+
}
|
|
347
|
+
|
|
234
348
|
// a scenario's optional list field (`code:`/`related:`) is a comma-separated path list (a YAML flow list
|
|
235
349
|
// `[a, b]` or bare `a, b`, or a single path) — the tiny parser stays scalar-only, so it is split here.
|
|
236
350
|
function parseCodeList(raw: string): string[] {
|
|
@@ -257,16 +371,71 @@ export function parseScenarios(src: string): Scenario[] {
|
|
|
257
371
|
.filter((s) => s.name) // a scenario with no name is malformed — drop it (validateScenarios reports it)
|
|
258
372
|
}
|
|
259
373
|
|
|
374
|
+
const compareStable = (a: string, b: string): number => a < b ? -1 : a > b ? 1 : 0
|
|
375
|
+
const relationRows = (raw: readonly string[] | undefined, relation: 'code' | 'related'): RelationEntry[] =>
|
|
376
|
+
parseRelation([...(raw ?? [])], relation).entries.map((entry) => ({ path: entry.path, selectors: [...entry.selectors] }))
|
|
377
|
+
|
|
378
|
+
const hashProjection = (value: unknown): string =>
|
|
379
|
+
createHash('sha256').update(JSON.stringify(value), 'utf8').digest('hex')
|
|
380
|
+
|
|
381
|
+
const semanticOnly = (row: ScenarioProjectionRow): ScenarioSemanticRow => row.semantic
|
|
382
|
+
|
|
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
|
+
export function scenarioProjection(
|
|
386
|
+
nodes: readonly Pick<EvalNode, 'id' | 'scenarios' | 'evalSource'>[],
|
|
387
|
+
provenance: Partial<ScenarioProjectionProvenance> = {},
|
|
388
|
+
): ScenarioProjection {
|
|
389
|
+
const rows: ScenarioProjectionRow[] = []
|
|
390
|
+
for (const node of nodes) {
|
|
391
|
+
if ('evalSource' in node && node.evalSource !== undefined) {
|
|
392
|
+
const schemaErrors = validateScenarios(node.evalSource)
|
|
393
|
+
const relationErrors = node.scenarios.flatMap((scenario) => [
|
|
394
|
+
...parseRelation(scenario.code ?? [], 'code').problems,
|
|
395
|
+
...parseRelation(scenario.related ?? [], 'related').problems,
|
|
396
|
+
])
|
|
397
|
+
const errors = [...schemaErrors, ...relationErrors]
|
|
398
|
+
if (errors.length) throw new Error(`node '${node.id}' has malformed eval.md:\n${errors.map((e) => ` - ${e}`).join('\n')}`)
|
|
399
|
+
}
|
|
400
|
+
for (const scenario of node.scenarios) rows.push({
|
|
401
|
+
semantic: {
|
|
402
|
+
node: node.id,
|
|
403
|
+
name: scenario.name,
|
|
404
|
+
description: scenario.description,
|
|
405
|
+
expected: scenario.expected,
|
|
406
|
+
scenarioHash: scenarioHash(scenario),
|
|
407
|
+
code: relationRows(scenario.code, 'code'),
|
|
408
|
+
related: relationRows(scenario.related, 'related'),
|
|
409
|
+
tags: [...(scenario.tags ?? [])],
|
|
410
|
+
},
|
|
411
|
+
measurement: { test: scenario.test ? { ...scenario.test } : null },
|
|
412
|
+
})
|
|
413
|
+
}
|
|
414
|
+
rows.sort((a, b) => {
|
|
415
|
+
const node = compareStable(a.semantic.node, b.semantic.node)
|
|
416
|
+
return node || compareStable(a.semantic.name, b.semantic.name)
|
|
417
|
+
})
|
|
418
|
+
const semanticRows = rows.map(semanticOnly)
|
|
419
|
+
return {
|
|
420
|
+
projection: SCENARIO_PROJECTION,
|
|
421
|
+
schemaVersion: SCENARIO_SCHEMA_VERSION,
|
|
422
|
+
provenance: { head: provenance.head ?? null, treeSha: provenance.treeSha ?? null },
|
|
423
|
+
semanticIndexHash: hashProjection(semanticRows),
|
|
424
|
+
fullIndexHash: hashProjection(rows),
|
|
425
|
+
rows,
|
|
426
|
+
}
|
|
427
|
+
}
|
|
428
|
+
|
|
260
429
|
// `tagLibrary` is the closed vocabulary a scenario's `tags:` must draw from (config's `lint.scenarioTags`).
|
|
261
430
|
// Every scenario needs ≥1 tag; each tag must be IN the library — an out-of-library tag is rejected LOUD with
|
|
262
431
|
// the repair the user owns: pick an existing tag, or extend the library. An empty library (none configured)
|
|
263
432
|
// disables only the membership check, never the ≥1-tag requirement.
|
|
264
433
|
export function validateScenarios(src: string, tagLibrary: string[] = [], pathRoot?: string): string[] {
|
|
265
|
-
const { hasFrontmatter, hasKey, items } = walkScenarios(src)
|
|
434
|
+
const { hasFrontmatter, hasKey, items, malformed } = walkScenarios(src)
|
|
266
435
|
if (!hasFrontmatter) return ['no frontmatter block — an eval.md must declare a `scenarios:` list']
|
|
267
436
|
if (!hasKey) return ['frontmatter has no `scenarios:` key — declare at least one scenario']
|
|
268
437
|
if (!items.length) return ['`scenarios:` declares no scenarios — add one (name + description + expected)']
|
|
269
|
-
const errs: string[] = []
|
|
438
|
+
const errs: string[] = [...malformed]
|
|
270
439
|
const counts = new Map<string, number>()
|
|
271
440
|
const lib = tagLibrary.length ? ` (library: ${tagLibrary.join(', ')})` : ''
|
|
272
441
|
items.forEach((it, idx) => {
|
|
@@ -282,6 +451,8 @@ export function validateScenarios(src: string, tagLibrary: string[] = [], pathRo
|
|
|
282
451
|
errs.push(`${label}: tag \`${t}\` is not in the configured tag library${lib} — use an existing tag, or add \`${t}\` to lint.scenarioTags in spexcode.json to create it`)
|
|
283
452
|
}
|
|
284
453
|
}
|
|
454
|
+
for (const entry of it.malformed) errs.push(`${label}: ${entry}`)
|
|
455
|
+
for (const d of it.duplicateKeys) errs.push(`${label}: duplicate field \`${d}\``)
|
|
285
456
|
for (const u of it.unknownKeys) errs.push(`${label}: unknown field \`${u}\` (allowed: ${SCENARIO_KEYS.join(', ')})`)
|
|
286
457
|
if (it.testObject) {
|
|
287
458
|
for (const u of it.testObject.unknownKeys) errs.push(`${label}: unknown \`test\` field \`${u}\` (allowed: ${TEST_KEYS.join(', ')})`)
|
|
@@ -301,6 +472,116 @@ export function validateScenarios(src: string, tagLibrary: string[] = [], pathRo
|
|
|
301
472
|
return errs
|
|
302
473
|
}
|
|
303
474
|
|
|
475
|
+
export type ScenarioMeasurementMetadataMutation =
|
|
476
|
+
| { scenario: string; insert: { test: string | { path: string; name: string } } }
|
|
477
|
+
| { scenario: string; delete: 'test' }
|
|
478
|
+
|
|
479
|
+
type ParsedMetadataMutation =
|
|
480
|
+
| { scenario: string; action: 'insert'; test: ScenarioTestReference }
|
|
481
|
+
| { scenario: string; action: 'delete' }
|
|
482
|
+
|
|
483
|
+
const recordOf = (value: unknown): Record<string, unknown> | null =>
|
|
484
|
+
value !== null && typeof value === 'object' && !Array.isArray(value) ? value as Record<string, unknown> : null
|
|
485
|
+
|
|
486
|
+
function parseMetadataMutation(value: unknown): ParsedMetadataMutation {
|
|
487
|
+
const mutation = recordOf(value)
|
|
488
|
+
if (!mutation || typeof mutation.scenario !== 'string' || !mutation.scenario.trim()) {
|
|
489
|
+
throw new Error('a metadata mutation must name exactly one scenario with a non-empty `scenario` string')
|
|
490
|
+
}
|
|
491
|
+
const unknown = Object.keys(mutation).filter((key) => !['scenario', 'insert', 'delete'].includes(key))
|
|
492
|
+
if (unknown.length) throw new Error(`metadata mutation has unknown field(s): ${unknown.join(', ')}`)
|
|
493
|
+
const actions = ['insert', 'delete'].filter((key) => key in mutation)
|
|
494
|
+
if (actions.length !== 1) throw new Error('a metadata mutation must contain exactly one action: `insert` or `delete`')
|
|
495
|
+
|
|
496
|
+
if ('delete' in mutation) {
|
|
497
|
+
if (mutation.delete !== 'test') throw new Error('`delete` must name exactly one measurement field: `test`')
|
|
498
|
+
return { scenario: mutation.scenario, action: 'delete' }
|
|
499
|
+
}
|
|
500
|
+
|
|
501
|
+
const insert = recordOf(mutation.insert)
|
|
502
|
+
if (!insert || Object.keys(insert).length !== 1 || !('test' in insert)) {
|
|
503
|
+
throw new Error('`insert` must contain exactly one measurement field: `test`')
|
|
504
|
+
}
|
|
505
|
+
if (typeof insert.test === 'string') {
|
|
506
|
+
if (!insert.test.trim()) throw new Error('`insert.test` path must be a non-empty string')
|
|
507
|
+
return { scenario: mutation.scenario, action: 'insert', test: { path: insert.test } }
|
|
508
|
+
}
|
|
509
|
+
const test = recordOf(insert.test)
|
|
510
|
+
if (!test || Object.keys(test).sort().join(',') !== 'name,path'
|
|
511
|
+
|| typeof test.path !== 'string' || !test.path.trim()
|
|
512
|
+
|| typeof test.name !== 'string' || !test.name.trim()) {
|
|
513
|
+
throw new Error('`insert.test` must be a path string or an exact `{path,name}` string mapping')
|
|
514
|
+
}
|
|
515
|
+
return { scenario: mutation.scenario, action: 'insert', test: { path: test.path, name: test.name } }
|
|
516
|
+
}
|
|
517
|
+
|
|
518
|
+
function declarationLineEnding(source: string): '\n' | '\r\n' {
|
|
519
|
+
const withoutCrlf = source.replace(/\r\n/g, '')
|
|
520
|
+
if (withoutCrlf.includes('\r')) throw new Error('eval.md uses unsupported bare CR line endings')
|
|
521
|
+
if (source.includes('\r\n') && withoutCrlf.includes('\n')) {
|
|
522
|
+
throw new Error('eval.md mixes LF and CRLF line endings; normalize it before applying metadata')
|
|
523
|
+
}
|
|
524
|
+
return source.includes('\r\n') ? '\r\n' : '\n'
|
|
525
|
+
}
|
|
526
|
+
|
|
527
|
+
function malformedDeclaration(errors: string[]): Error {
|
|
528
|
+
return new Error(`malformed eval.md:\n${errors.map((error) => ` - ${error}`).join('\n')}`)
|
|
529
|
+
}
|
|
530
|
+
|
|
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
|
+
export function writeScenarioMeasurementMetadata(source: string, request: unknown): string {
|
|
535
|
+
const mutation = parseMetadataMutation(request)
|
|
536
|
+
const beforeErrors = validateScenarios(source)
|
|
537
|
+
if (beforeErrors.length) throw malformedDeclaration(beforeErrors)
|
|
538
|
+
|
|
539
|
+
const walked = walkScenarios(source)
|
|
540
|
+
const matches = walked.items.filter((item) => item.fields.name === mutation.scenario)
|
|
541
|
+
if (matches.length !== 1) {
|
|
542
|
+
throw new Error(matches.length
|
|
543
|
+
? `scenario '${mutation.scenario}' is ambiguous (${matches.length} declarations)`
|
|
544
|
+
: `scenario '${mutation.scenario}' was not found in eval.md`)
|
|
545
|
+
}
|
|
546
|
+
const item = matches[0]
|
|
547
|
+
const lineEnding = declarationLineEnding(source)
|
|
548
|
+
const lines = source.split(lineEnding)
|
|
549
|
+
|
|
550
|
+
if (mutation.action === 'insert') {
|
|
551
|
+
if (item.locations.test) throw new Error(`scenario '${mutation.scenario}' already has \`test\`; refusing to overwrite authoritative metadata`)
|
|
552
|
+
const tags = item.locations.tags
|
|
553
|
+
if (!tags) throw new Error(`scenario '${mutation.scenario}' has no structural \`tags\` field`)
|
|
554
|
+
const keyIndent = tags.indent
|
|
555
|
+
const childIndent = `${tags.indent} `
|
|
556
|
+
const rendered = mutation.test.name === undefined
|
|
557
|
+
? [`${keyIndent}test: ${JSON.stringify(mutation.test.path)}`]
|
|
558
|
+
: [
|
|
559
|
+
`${keyIndent}test:`,
|
|
560
|
+
`${childIndent}path: ${JSON.stringify(mutation.test.path)}`,
|
|
561
|
+
`${childIndent}name: ${JSON.stringify(mutation.test.name)}`,
|
|
562
|
+
]
|
|
563
|
+
lines.splice(tags.endLine + 2, 0, ...rendered)
|
|
564
|
+
} else {
|
|
565
|
+
const test = item.locations.test
|
|
566
|
+
if (!test) throw new Error(`scenario '${mutation.scenario}' has no \`test\` field to delete`)
|
|
567
|
+
lines.splice(test.startLine + 1, test.endLine - test.startLine + 1)
|
|
568
|
+
}
|
|
569
|
+
|
|
570
|
+
const proposed = lines.join(lineEnding)
|
|
571
|
+
const afterErrors = validateScenarios(proposed)
|
|
572
|
+
if (afterErrors.length) throw new Error(`metadata mutation produced ${malformedDeclaration(afterErrors).message}`)
|
|
573
|
+
const after = parseScenarios(proposed).filter((scenario) => scenario.name === mutation.scenario)
|
|
574
|
+
if (after.length !== 1) throw new Error(`metadata mutation lost the unique scenario '${mutation.scenario}'`)
|
|
575
|
+
if (mutation.action === 'insert') {
|
|
576
|
+
if (JSON.stringify(after[0].test) !== JSON.stringify(mutation.test)) {
|
|
577
|
+
throw new Error(`metadata mutation did not round-trip the exact requested \`test\` mapping for scenario '${mutation.scenario}'`)
|
|
578
|
+
}
|
|
579
|
+
} else if (after[0].test !== undefined) {
|
|
580
|
+
throw new Error(`metadata mutation did not delete \`test\` from scenario '${mutation.scenario}'`)
|
|
581
|
+
}
|
|
582
|
+
return proposed
|
|
583
|
+
}
|
|
584
|
+
|
|
304
585
|
// walk `.spec` for every dir holding an eval.md; the node id is its CANONICAL spec id ([[id-url-safe]]) —
|
|
305
586
|
// minted by the SAME rule as specs.ts's loader (mintIds: the leaf dir name, or on a leaf collision the
|
|
306
587
|
// shortest globally-unique '_'-joined trailing suffix) over the SAME universe (every dir holding a spec.md,
|
|
@@ -318,6 +599,7 @@ function assembleNodes(root: string, specDirs: string[], hits: { dir: string; sr
|
|
|
318
599
|
evalPath: relative(root, join(dir, EVAL_FILE)),
|
|
319
600
|
sidecarPath: join(dir, SIDECAR_FILE),
|
|
320
601
|
scenarios: parseScenarios(src),
|
|
602
|
+
evalSource: src,
|
|
321
603
|
}))
|
|
322
604
|
.sort((a, b) => a.id.localeCompare(b.id))
|
|
323
605
|
}
|
|
@@ -338,6 +620,23 @@ export function evalNodes(root: string): EvalNode[] {
|
|
|
338
620
|
return assembleNodes(root, specDirs, hits)
|
|
339
621
|
}
|
|
340
622
|
|
|
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
|
+
export function evalNodesAt(root: string, tip: string): EvalNode[] {
|
|
626
|
+
const files = treeTextFiles(root, tip, '.spec')
|
|
627
|
+
const paths = [...files.keys()]
|
|
628
|
+
const specDirs = paths
|
|
629
|
+
.filter((path) => path.endsWith('/spec.md'))
|
|
630
|
+
.map((path) => join(root, path.slice(0, -'/spec.md'.length)))
|
|
631
|
+
const hits = paths
|
|
632
|
+
.filter((path) => path.endsWith(`/${EVAL_FILE}`))
|
|
633
|
+
.map((path) => ({
|
|
634
|
+
dir: join(root, path.slice(0, -`/${EVAL_FILE}`.length)),
|
|
635
|
+
src: files.get(path)!,
|
|
636
|
+
}))
|
|
637
|
+
return assembleNodes(root, specDirs, hits)
|
|
638
|
+
}
|
|
639
|
+
|
|
341
640
|
// async twin of evalNodes for the HOT board build ([[graph-cache]]): reading each eval.md through
|
|
342
641
|
// fs/promises YIELDS the event loop between files, so the walk no longer stalls a `/health` probe in one
|
|
343
642
|
// ~600ms uninterrupted stretch. Same output (canonical ids, id-sorted) as evalNodes; only buildBoard uses
|