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.
Files changed (151) hide show
  1. package/README.md +1 -1
  2. package/package.json +1 -1
  3. package/spec-cli/bin/spex.mjs +25 -1
  4. package/spec-cli/hooks/dispatch.sh +24 -1
  5. package/spec-cli/src/anchors.ts +192 -58
  6. package/spec-cli/src/attach.ts +11 -8
  7. package/spec-cli/src/claude-headless.ts +13 -52
  8. package/spec-cli/src/cli.ts +209 -71
  9. package/spec-cli/src/client.ts +143 -5
  10. package/spec-cli/src/gateway-hub.ts +2 -6
  11. package/spec-cli/src/gateway.ts +96 -11
  12. package/spec-cli/src/git.ts +1395 -96
  13. package/spec-cli/src/graph.ts +21 -17
  14. package/spec-cli/src/graphCache.ts +405 -61
  15. package/spec-cli/src/graphStream.ts +581 -182
  16. package/spec-cli/src/guide.ts +15 -4
  17. package/spec-cli/src/harness.ts +1133 -71
  18. package/spec-cli/src/headless-controller.ts +63 -0
  19. package/spec-cli/src/help.ts +120 -64
  20. package/spec-cli/src/host-resources.ts +698 -0
  21. package/spec-cli/src/index.ts +95 -21
  22. package/spec-cli/src/init.ts +73 -15
  23. package/spec-cli/src/issues.ts +36 -3
  24. package/spec-cli/src/layout.ts +189 -41
  25. package/spec-cli/src/lint.ts +152 -27
  26. package/spec-cli/src/localIssues.ts +5 -1
  27. package/spec-cli/src/maintenance-wrapper.ts +276 -0
  28. package/spec-cli/src/materialize.ts +6 -7
  29. package/spec-cli/src/opencode-headless.ts +4 -8
  30. package/spec-cli/src/pi-headless.ts +17 -60
  31. package/spec-cli/src/process-identity.ts +174 -0
  32. package/spec-cli/src/project-store.ts +17 -0
  33. package/spec-cli/src/pty-bridge.ts +6 -3
  34. package/spec-cli/src/reviews.ts +17 -13
  35. package/spec-cli/src/runtime-ownership.ts +88 -0
  36. package/spec-cli/src/session-maintenance.ts +750 -0
  37. package/spec-cli/src/session-timeline.ts +6 -6
  38. package/spec-cli/src/sessions.ts +1251 -208
  39. package/spec-cli/src/sh.ts +3 -0
  40. package/spec-cli/src/source-files.ts +27 -2
  41. package/spec-cli/src/specs.ts +58 -25
  42. package/spec-cli/src/supervise.ts +31 -11
  43. package/spec-cli/src/uninstall.ts +7 -6
  44. package/spec-cli/templates/hooks/commit-msg +14 -0
  45. package/spec-cli/templates/hooks/pre-commit +29 -15
  46. package/spec-cli/templates/hooks/prepare-commit-msg +20 -48
  47. package/spec-cli/templates/hooks/reference-transaction +105 -0
  48. package/spec-cli/templates/spec/project/.plugins/commands/supervisor/spec.md +1 -1
  49. package/spec-cli/templates/spec/project/.plugins/core/mark-active/mark-active.sh +23 -21
  50. package/spec-cli/templates/spec/project/.plugins/core/spec.md +12 -10
  51. package/spec-cli/templates/spec/project/.plugins/core/stop-gate/stop-gate.sh +15 -6
  52. package/spec-cli/templates/spec/project/.plugins/prompts/atomic-landing/spec.md +32 -0
  53. package/spec-dashboard/dist/assets/App-C5vbTw8Q.js +2 -0
  54. package/spec-dashboard/dist/assets/Dashboard-u8RIS3NY.js +27 -0
  55. package/spec-dashboard/dist/assets/EvalsPage-BS7ITcNo.js +2 -0
  56. package/spec-dashboard/dist/assets/IssuesPage-DXbqQFW_.js +1 -0
  57. package/spec-dashboard/dist/assets/KaTeX_AMS-Regular-BQhdFMY1.woff2 +0 -0
  58. package/spec-dashboard/dist/assets/KaTeX_AMS-Regular-DMm9YOAa.woff +0 -0
  59. package/spec-dashboard/dist/assets/KaTeX_AMS-Regular-DRggAlZN.ttf +0 -0
  60. package/spec-dashboard/dist/assets/KaTeX_Caligraphic-Bold-ATXxdsX0.ttf +0 -0
  61. package/spec-dashboard/dist/assets/KaTeX_Caligraphic-Bold-BEiXGLvX.woff +0 -0
  62. package/spec-dashboard/dist/assets/KaTeX_Caligraphic-Bold-Dq_IR9rO.woff2 +0 -0
  63. package/spec-dashboard/dist/assets/KaTeX_Caligraphic-Regular-CTRA-rTL.woff +0 -0
  64. package/spec-dashboard/dist/assets/KaTeX_Caligraphic-Regular-Di6jR-x-.woff2 +0 -0
  65. package/spec-dashboard/dist/assets/KaTeX_Caligraphic-Regular-wX97UBjC.ttf +0 -0
  66. package/spec-dashboard/dist/assets/KaTeX_Fraktur-Bold-BdnERNNW.ttf +0 -0
  67. package/spec-dashboard/dist/assets/KaTeX_Fraktur-Bold-BsDP51OF.woff +0 -0
  68. package/spec-dashboard/dist/assets/KaTeX_Fraktur-Bold-CL6g_b3V.woff2 +0 -0
  69. package/spec-dashboard/dist/assets/KaTeX_Fraktur-Regular-CB_wures.ttf +0 -0
  70. package/spec-dashboard/dist/assets/KaTeX_Fraktur-Regular-CTYiF6lA.woff2 +0 -0
  71. package/spec-dashboard/dist/assets/KaTeX_Fraktur-Regular-Dxdc4cR9.woff +0 -0
  72. package/spec-dashboard/dist/assets/KaTeX_Main-Bold-Cx986IdX.woff2 +0 -0
  73. package/spec-dashboard/dist/assets/KaTeX_Main-Bold-Jm3AIy58.woff +0 -0
  74. package/spec-dashboard/dist/assets/KaTeX_Main-Bold-waoOVXN0.ttf +0 -0
  75. package/spec-dashboard/dist/assets/KaTeX_Main-BoldItalic-DxDJ3AOS.woff2 +0 -0
  76. package/spec-dashboard/dist/assets/KaTeX_Main-BoldItalic-DzxPMmG6.ttf +0 -0
  77. package/spec-dashboard/dist/assets/KaTeX_Main-BoldItalic-SpSLRI95.woff +0 -0
  78. package/spec-dashboard/dist/assets/KaTeX_Main-Italic-3WenGoN9.ttf +0 -0
  79. package/spec-dashboard/dist/assets/KaTeX_Main-Italic-BMLOBm91.woff +0 -0
  80. package/spec-dashboard/dist/assets/KaTeX_Main-Italic-NWA7e6Wa.woff2 +0 -0
  81. package/spec-dashboard/dist/assets/KaTeX_Main-Regular-B22Nviop.woff2 +0 -0
  82. package/spec-dashboard/dist/assets/KaTeX_Main-Regular-Dr94JaBh.woff +0 -0
  83. package/spec-dashboard/dist/assets/KaTeX_Main-Regular-ypZvNtVU.ttf +0 -0
  84. package/spec-dashboard/dist/assets/KaTeX_Math-BoldItalic-B3XSjfu4.ttf +0 -0
  85. package/spec-dashboard/dist/assets/KaTeX_Math-BoldItalic-CZnvNsCZ.woff2 +0 -0
  86. package/spec-dashboard/dist/assets/KaTeX_Math-BoldItalic-iY-2wyZ7.woff +0 -0
  87. package/spec-dashboard/dist/assets/KaTeX_Math-Italic-DA0__PXp.woff +0 -0
  88. package/spec-dashboard/dist/assets/KaTeX_Math-Italic-flOr_0UB.ttf +0 -0
  89. package/spec-dashboard/dist/assets/KaTeX_Math-Italic-t53AETM-.woff2 +0 -0
  90. package/spec-dashboard/dist/assets/KaTeX_SansSerif-Bold-CFMepnvq.ttf +0 -0
  91. package/spec-dashboard/dist/assets/KaTeX_SansSerif-Bold-D1sUS0GD.woff2 +0 -0
  92. package/spec-dashboard/dist/assets/KaTeX_SansSerif-Bold-DbIhKOiC.woff +0 -0
  93. package/spec-dashboard/dist/assets/KaTeX_SansSerif-Italic-C3H0VqGB.woff2 +0 -0
  94. package/spec-dashboard/dist/assets/KaTeX_SansSerif-Italic-DN2j7dab.woff +0 -0
  95. package/spec-dashboard/dist/assets/KaTeX_SansSerif-Italic-YYjJ1zSn.ttf +0 -0
  96. package/spec-dashboard/dist/assets/KaTeX_SansSerif-Regular-BNo7hRIc.ttf +0 -0
  97. package/spec-dashboard/dist/assets/KaTeX_SansSerif-Regular-CS6fqUqJ.woff +0 -0
  98. package/spec-dashboard/dist/assets/KaTeX_SansSerif-Regular-DDBCnlJ7.woff2 +0 -0
  99. package/spec-dashboard/dist/assets/KaTeX_Script-Regular-C5JkGWo-.ttf +0 -0
  100. package/spec-dashboard/dist/assets/KaTeX_Script-Regular-D3wIWfF6.woff2 +0 -0
  101. package/spec-dashboard/dist/assets/KaTeX_Script-Regular-D5yQViql.woff +0 -0
  102. package/spec-dashboard/dist/assets/KaTeX_Size1-Regular-C195tn64.woff +0 -0
  103. package/spec-dashboard/dist/assets/KaTeX_Size1-Regular-Dbsnue_I.ttf +0 -0
  104. package/spec-dashboard/dist/assets/KaTeX_Size1-Regular-mCD8mA8B.woff2 +0 -0
  105. package/spec-dashboard/dist/assets/KaTeX_Size2-Regular-B7gKUWhC.ttf +0 -0
  106. package/spec-dashboard/dist/assets/KaTeX_Size2-Regular-Dy4dx90m.woff2 +0 -0
  107. package/spec-dashboard/dist/assets/KaTeX_Size2-Regular-oD1tc_U0.woff +0 -0
  108. package/spec-dashboard/dist/assets/KaTeX_Size3-Regular-CTq5MqoE.woff +0 -0
  109. package/spec-dashboard/dist/assets/KaTeX_Size3-Regular-DgpXs0kz.ttf +0 -0
  110. package/spec-dashboard/dist/assets/KaTeX_Size4-Regular-BF-4gkZK.woff +0 -0
  111. package/spec-dashboard/dist/assets/KaTeX_Size4-Regular-DWFBv043.ttf +0 -0
  112. package/spec-dashboard/dist/assets/KaTeX_Size4-Regular-Dl5lxZxV.woff2 +0 -0
  113. package/spec-dashboard/dist/assets/KaTeX_Typewriter-Regular-C0xS9mPB.woff +0 -0
  114. package/spec-dashboard/dist/assets/KaTeX_Typewriter-Regular-CO6r4hn1.woff2 +0 -0
  115. package/spec-dashboard/dist/assets/KaTeX_Typewriter-Regular-D3Ib7_Hf.ttf +0 -0
  116. package/spec-dashboard/dist/assets/MobileApp-DVLnk9hz.js +2 -0
  117. package/spec-dashboard/dist/assets/Modal-6mHq6fbZ.js +1 -0
  118. package/spec-dashboard/dist/assets/{PageScroll-CadAKuSy.js → PageScroll-CAY4S4g4.js} +1 -1
  119. package/spec-dashboard/dist/assets/ProjectsPage-UQyzsTWN.js +1 -0
  120. package/spec-dashboard/dist/assets/SessionInterface-DKU4c1Z-.js +39 -0
  121. package/spec-dashboard/dist/assets/SessionWindow-zGwJaGbR.js +1 -0
  122. package/spec-dashboard/dist/assets/Settings-igR17pns.js +1 -0
  123. package/spec-dashboard/dist/assets/Thread-B-ZUarN1.js +13 -0
  124. package/spec-dashboard/dist/assets/TimelineChat-Cp579UoJ.css +1 -0
  125. package/spec-dashboard/dist/assets/TimelineChat-sc49Qj5d.js +276 -0
  126. package/spec-dashboard/dist/assets/data-B1ot4PF0.js +1 -0
  127. package/spec-dashboard/dist/assets/index-BqBNCa1V.js +41 -0
  128. package/spec-dashboard/dist/assets/index-CzutlTDf.css +1 -0
  129. package/spec-dashboard/dist/assets/{terminalFont-DmTPNdbU.js → terminalFont-C6oGNKrf.js} +1 -1
  130. package/spec-dashboard/dist/index.html +5 -4
  131. package/spec-dashboard/src/reviewFilters.js +46 -8
  132. package/spec-dashboard/src/session.js +64 -9
  133. package/spec-eval/src/cli.ts +144 -32
  134. package/spec-eval/src/evaltab.ts +16 -9
  135. package/spec-eval/src/freshness.ts +336 -54
  136. package/spec-eval/src/scenariofresh.ts +51 -1
  137. package/spec-eval/src/scenarios.ts +324 -25
  138. package/spec-eval/src/sessioneval.ts +969 -173
  139. package/spec-cli/src/message-stream.ts +0 -147
  140. package/spec-dashboard/dist/assets/Dashboard-CTAuTyZ3.js +0 -27
  141. package/spec-dashboard/dist/assets/EvalsPage-KbMMownG.js +0 -2
  142. package/spec-dashboard/dist/assets/IssuesPage-DmyLb9Rj.js +0 -1
  143. package/spec-dashboard/dist/assets/MobileApp-D2RZGt4Z.js +0 -2
  144. package/spec-dashboard/dist/assets/Modal-3brXUhM0.js +0 -1
  145. package/spec-dashboard/dist/assets/ProjectsPage-DU3x4Y8l.js +0 -1
  146. package/spec-dashboard/dist/assets/SessionInterface-BtrzlOPs.js +0 -39
  147. package/spec-dashboard/dist/assets/SessionWindow-BWH5O0jh.js +0 -13
  148. package/spec-dashboard/dist/assets/Settings-COgdKTJB.js +0 -1
  149. package/spec-dashboard/dist/assets/TimelineChat-DQ21GSJK.js +0 -1
  150. package/spec-dashboard/dist/assets/index-D6HBvKkJ.js +0 -41
  151. 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 = { fields: Partial<Record<ScenarioKey, string>>; testObject?: RawTestObject; unknownKeys: string[] }
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 m = src.match(/^---\n([\s\S]*?)\n---/)
53
- if (!m) return { hasFrontmatter: false, hasKey: false, items: [] }
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
- let i = lines.findIndex((l) => /^scenarios:\s*$/.test(l))
56
- if (i < 0) return { hasFrontmatter: true, hasKey: false, items: [] }
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 indent = indentOf(line)
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 = { fields: {}, unknownKeys: [] }
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 (!cur) continue // content before the first dash — ignore
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) return idx
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 indent = line.length - line.replace(/^\s+/, '').length
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 ind = l.length - l.replace(/^\s+/, '').length
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
- if (!l.trim()) { body.push(''); continue }
143
- const ind = l.length - l.replace(/^\s+/, '').length
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