spexcode 0.1.6 → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +86 -25
- package/package.json +5 -6
- package/spec-cli/README.md +86 -0
- package/spec-cli/bin/spex.mjs +15 -3
- package/spec-cli/hooks/dispatch.sh +20 -8
- package/spec-cli/hooks/harness.sh +18 -11
- package/spec-cli/src/board.ts +47 -18
- package/spec-cli/src/boardCache.ts +70 -0
- package/spec-cli/src/boardDelta.ts +90 -0
- package/spec-cli/src/boardStream.ts +178 -0
- package/spec-cli/src/cli.ts +172 -119
- package/spec-cli/src/client.ts +6 -4
- package/spec-cli/src/gateway.ts +49 -13
- package/spec-cli/src/git.ts +105 -92
- package/spec-cli/src/guide.ts +175 -12
- package/spec-cli/src/harness-select.ts +63 -0
- package/spec-cli/src/harness.ts +506 -100
- package/spec-cli/src/help.ts +360 -0
- package/spec-cli/src/hooks.ts +0 -14
- package/spec-cli/src/index.ts +272 -32
- package/spec-cli/src/init.ts +41 -1
- package/spec-cli/src/issues.ts +250 -0
- package/spec-cli/src/layout.ts +70 -28
- package/spec-cli/src/lint.ts +12 -10
- package/spec-cli/src/listen.ts +28 -0
- package/spec-cli/src/localIssues.ts +683 -0
- package/spec-cli/src/materialize.ts +182 -27
- package/spec-cli/src/mentions.ts +192 -0
- package/spec-cli/src/plugin-harness.ts +145 -0
- package/spec-cli/src/pty-bridge.ts +378 -81
- package/spec-cli/src/self.ts +123 -20
- package/spec-cli/src/sessions.ts +461 -298
- package/spec-cli/src/specs.ts +55 -14
- package/spec-cli/src/supervise.ts +23 -3
- package/spec-cli/src/tsx-bin.ts +14 -5
- package/spec-cli/src/uninstall.ts +146 -0
- package/spec-cli/templates/hooks/post-merge +27 -0
- package/spec-cli/templates/hooks/pre-commit +51 -31
- package/spec-cli/templates/hooks/prepare-commit-msg +31 -8
- package/spec-cli/templates/presets/careful/.config/clarify-before-code/spec.md +11 -0
- package/spec-cli/templates/spec/project/.config/core/spec-of-file/spec-of-file.sh +26 -3
- package/spec-cli/templates/spec/project/.config/core/spec.md +4 -0
- package/spec-cli/templates/spec/project/.config/core/stop-gate/stop-gate.sh +16 -4
- package/spec-cli/templates/spec/project/.config/extract/spec.md +1 -1
- package/spec-cli/templates/spec/project/.config/regroup/spec.md +1 -1
- package/spec-cli/templates/spec/project/.config/reproduce-before-fix/spec.md +18 -0
- package/spec-cli/templates/spec/project/.config/spec.md +3 -3
- package/spec-cli/templates/spec/project/.config/supervisor/spec.md +2 -2
- package/spec-cli/templates/spec/project/.config/tidy/spec.md +1 -1
- package/spec-cli/templates/spec/project/spec.md +2 -2
- package/spec-dashboard/dist/assets/index-B0tgHeEQ.js +145 -0
- package/spec-dashboard/dist/assets/index-BTU-44Os.css +32 -0
- package/spec-dashboard/dist/index.html +17 -5
- package/spec-forge/src/cache.ts +16 -0
- package/spec-forge/src/drivers/github.ts +74 -4
- package/spec-forge/src/port.ts +25 -0
- package/spec-forge/src/resident.ts +40 -6
- package/spec-yatsu/src/cli.ts +227 -38
- package/spec-yatsu/src/evaltab.ts +169 -19
- package/spec-yatsu/src/filing.ts +48 -0
- package/spec-yatsu/src/freshness.ts +55 -20
- package/spec-yatsu/src/proof.ts +89 -3
- package/spec-yatsu/src/scenariofresh.ts +92 -0
- package/spec-yatsu/src/sidecar.ts +75 -11
- package/spec-yatsu/src/timeline.ts +47 -0
- package/spec-yatsu/src/yatsu.ts +47 -3
- package/spec-cli/src/relay.ts +0 -28
- package/spec-cli/templates/spec/project/.config/scenario/spec.md +0 -32
- package/spec-dashboard/dist/assets/index-Bk4E1EQy.js +0 -139
- package/spec-dashboard/dist/assets/index-Cq7hwngj.css +0 -32
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
import { gitA, headSha } from '../../spec-cli/src/git.js'
|
|
2
|
+
import { parseScenarios } from './yatsu.js'
|
|
3
|
+
|
|
4
|
+
// @@@ per-scenario content freshness — the SCENARIO axis, sub-file
|
|
5
|
+
// A yatsu.md holds many scenarios, but a reading measures ONE. yatsu-core's contract says "a scenario is the
|
|
6
|
+
// unit of measurement, so its freshness is its OWN — two scenarios stale independently." Git has no sub-file
|
|
7
|
+
// history, so we build it: for each scenario NAME in a yatsu.md, the commits where THAT scenario's block
|
|
8
|
+
// content changed (added / removed / edited), rename-followed. `scenarioMoved` then reads exactly like the
|
|
9
|
+
// code axis's `changedSince` — a pure ancestry lookup over this per-scenario commit list — so editing one
|
|
10
|
+
// scenario never re-stales its siblings (the file-granular bug this replaces).
|
|
11
|
+
|
|
12
|
+
const RS = '\x1e'
|
|
13
|
+
|
|
14
|
+
// yatsuPath (head path) -> scenario name -> commit hashes that changed that scenario's block (newest-first)
|
|
15
|
+
export type ScenarioIndex = Map<string, Map<string, string[]>>
|
|
16
|
+
|
|
17
|
+
// the block content that stales a reading: everything a measurement is taken AGAINST, minus the name (the
|
|
18
|
+
// join key — a renamed scenario is a remove+add, surfaced as a change-commit on each name). parseScenarios
|
|
19
|
+
// already folds YAML block scalars, so a pure prose re-wrap yields the same string and does NOT stale.
|
|
20
|
+
function blockContent(src: string): Map<string, string> {
|
|
21
|
+
const m = new Map<string, string>()
|
|
22
|
+
for (const s of parseScenarios(src)) {
|
|
23
|
+
m.set(s.name, JSON.stringify({ d: s.description, e: s.expected, t: s.tags ?? [], c: s.code ?? [], r: s.related ?? [], x: s.test ?? '' }))
|
|
24
|
+
}
|
|
25
|
+
return m
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
// the file's content-versions newest-first, each { hash, blocks }. Skips a version we can't read (git show
|
|
29
|
+
// '') so an unreadable blob never fabricates a remove+re-add diff.
|
|
30
|
+
async function fileVersions(root: string, headPath: string): Promise<{ hash: string; blocks: Map<string, string> }[]> {
|
|
31
|
+
// --follow + --name-only pairs each commit with the path the file had THERE, so `git show <hash>:<pathAtCommit>`
|
|
32
|
+
// works across reparents (yatsu.md moves when its node does). `-M` keeps a rename+edit's content.
|
|
33
|
+
const log = await gitA(['-C', root, '-c', 'core.quotePath=false', 'log', '-M', '--follow', `--format=${RS}%H`, '--name-only', '--', headPath])
|
|
34
|
+
const out: { hash: string; blocks: Map<string, string> }[] = []
|
|
35
|
+
for (const rec of log.split(RS)) {
|
|
36
|
+
const lines = rec.split('\n').map((l) => l.trim()).filter(Boolean)
|
|
37
|
+
if (!lines.length) continue
|
|
38
|
+
const hash = lines[0]
|
|
39
|
+
const pathAt = lines.slice(1).find((l) => l.endsWith('/yatsu.md') || l === 'yatsu.md') ?? headPath
|
|
40
|
+
const content = await gitA(['-C', root, 'show', `${hash}:${pathAt}`])
|
|
41
|
+
if (!content) continue
|
|
42
|
+
out.push({ hash, blocks: blockContent(content) })
|
|
43
|
+
}
|
|
44
|
+
return out
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
// per-scenario change-commits for one file: walk versions newest->oldest, attribute a commit to every
|
|
48
|
+
// scenario whose block differs from the next-older version (undefined on either side = add/remove = a change).
|
|
49
|
+
async function scenarioCommits(root: string, headPath: string): Promise<Map<string, string[]>> {
|
|
50
|
+
const versions = await fileVersions(root, headPath)
|
|
51
|
+
const commits = new Map<string, string[]>()
|
|
52
|
+
const push = (name: string, hash: string) => { const a = commits.get(name); if (a) a.push(hash); else commits.set(name, [hash]) }
|
|
53
|
+
for (let i = 0; i < versions.length; i++) {
|
|
54
|
+
const cur = versions[i].blocks
|
|
55
|
+
const older = versions[i + 1]?.blocks ?? new Map<string, string>()
|
|
56
|
+
for (const name of new Set([...cur.keys(), ...older.keys()])) {
|
|
57
|
+
if (cur.get(name) !== older.get(name)) push(name, versions[i].hash)
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
return commits
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
async function build(root: string, yatsuPaths: string[]): Promise<ScenarioIndex> {
|
|
64
|
+
const idx: ScenarioIndex = new Map()
|
|
65
|
+
const CONC = 8 // bound the git-show fan-out; the whole index is built once per HEAD then cached
|
|
66
|
+
for (let i = 0; i < yatsuPaths.length; i += CONC) {
|
|
67
|
+
const batch = yatsuPaths.slice(i, i + CONC)
|
|
68
|
+
const done = await Promise.all(batch.map((p) => scenarioCommits(root, p)))
|
|
69
|
+
batch.forEach((p, j) => idx.set(p, done[j]))
|
|
70
|
+
}
|
|
71
|
+
return idx
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
// HEAD-keyed LRU, mirroring historyIndex/driftIndex in git.ts: same head ⇒ same per-scenario history,
|
|
75
|
+
// whatever the caller. Holds the in-flight promise so concurrent board builds share one build.
|
|
76
|
+
const SLOTS = 16
|
|
77
|
+
const cache = new Map<string, Promise<ScenarioIndex>>()
|
|
78
|
+
export function scenarioIndex(root: string, yatsuPaths: string[]): Promise<ScenarioIndex> {
|
|
79
|
+
let head: string
|
|
80
|
+
try { head = headSha(root) } catch { return build(root, yatsuPaths) }
|
|
81
|
+
const hit = cache.get(head)
|
|
82
|
+
if (hit) { cache.delete(head); cache.set(head, hit); return hit }
|
|
83
|
+
const p = build(root, yatsuPaths)
|
|
84
|
+
p.catch(() => cache.delete(head))
|
|
85
|
+
cache.set(head, p)
|
|
86
|
+
while (cache.size > SLOTS) cache.delete(cache.keys().next().value!)
|
|
87
|
+
return p
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
export function scenarioChangeCommits(idx: ScenarioIndex, yatsuPath: string, scenario: string): string[] {
|
|
91
|
+
return idx.get(yatsuPath)?.get(scenario) ?? []
|
|
92
|
+
}
|
|
@@ -1,32 +1,90 @@
|
|
|
1
1
|
import { readFileSync, appendFileSync, existsSync } from 'node:fs'
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
// the verdict is pass | fail; `note` is an OPTIONAL one-line annotation on either (why it failed, how far
|
|
4
|
+
// a pass is from ideal) — not a third status. (Legacy readings filed when `note` was its own status survive
|
|
5
|
+
// on disk with status:'note'; render stays tolerant of them, the CLI no longer mints them.)
|
|
6
|
+
export type Verdict = { status: 'pass' | 'fail'; note?: string }
|
|
4
7
|
|
|
5
|
-
|
|
8
|
+
export type EvidenceKind = 'image' | 'transcript' | 'video'
|
|
9
|
+
// one piece of a reading's evidence: a content-addressed blob (`hash`) tagged by `kind`. `video` is a
|
|
10
|
+
// screenshot with a time axis — the same content-addressed blob, distinguished only by this tag.
|
|
11
|
+
export type Evidence = { hash: string; kind: EvidenceKind }
|
|
12
|
+
|
|
13
|
+
// A reading's evidence is a LIST of typed entries — N images and/or a video (with its step-timeline) and/or
|
|
14
|
+
// a transcript, in filing order. New filings write `evidence`; the legacy scalar shape (`blob` + `blobKind`,
|
|
15
|
+
// absent kind → image) is still READ and normalized to a one-entry list by `evidenceOf`, so old readings
|
|
16
|
+
// still render. A video reading may carry `timelineBlob`: the content hash of its step-timeline sidecar
|
|
17
|
+
// (timeline.ts) mapping clip moments to named steps — it anchors the reading's VIDEO evidence entry.
|
|
18
|
+
// `by` is the SESSION that filed this reading (the filer, from envSessionId) — the ORIGINATOR an eval-comment
|
|
19
|
+
// thread loops in on a reply ([[mentions]] implicit loop-in). Pure additive: a legacy reading without it simply
|
|
20
|
+
// has no originator → silent. `evaluator` is WHO/WHAT measured (a tag like `manual@1`); `by` is the reachable
|
|
21
|
+
// session behind the filing — two different axes.
|
|
6
22
|
export type Reading = {
|
|
7
23
|
scenario: string
|
|
8
24
|
codeSha: string
|
|
9
|
-
|
|
10
|
-
|
|
25
|
+
evidence?: Evidence[]
|
|
26
|
+
// legacy scalar evidence — read for old readings, never written by new filings.
|
|
27
|
+
blob?: string | null
|
|
28
|
+
blobKind?: EvidenceKind
|
|
29
|
+
timelineBlob?: string
|
|
11
30
|
evaluator: string
|
|
31
|
+
by?: string
|
|
12
32
|
verdict?: Verdict
|
|
13
33
|
ts: string
|
|
14
34
|
}
|
|
15
35
|
|
|
16
|
-
//
|
|
17
|
-
//
|
|
18
|
-
export function
|
|
19
|
-
if (
|
|
20
|
-
|
|
36
|
+
// the one scalar→list bridge every evidence consumer passes through: the `evidence` list when present, else
|
|
37
|
+
// the legacy scalar (blob + blobKind, absent kind → image) as a one-entry list, else empty.
|
|
38
|
+
export function evidenceOf(r: { evidence?: Evidence[]; blob?: string | null; blobKind?: EvidenceKind }): Evidence[] {
|
|
39
|
+
if (r.evidence?.length) return r.evidence
|
|
40
|
+
if (r.blob) return [{ hash: r.blob, kind: r.blobKind ?? 'image' }]
|
|
41
|
+
return []
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
// a RETRACTION is the sanctioned inverse of a filing — itself an appended event, never a deleted line
|
|
45
|
+
// (the sidecar stays append-only; git shows who retracted what, when). `retracts` is the target reading's
|
|
46
|
+
// `ts` within `scenario` (its natural key). Deliberately NO `evaluator` field: an old reader's line filter
|
|
47
|
+
// (which requires an evaluator string) skips a retraction entirely, so version skew degrades to "the
|
|
48
|
+
// retraction isn't applied yet", never to a mis-rendered reading. `by` is the retracting session; `note`
|
|
49
|
+
// says why (a botched e2e filing, a wrong verdict).
|
|
50
|
+
export type Retraction = { retracts: string; scenario: string; note?: string; by?: string; ts: string }
|
|
51
|
+
|
|
52
|
+
// parse the sidecar RAW: one event per non-blank line — a Reading, or a Retraction (a line carrying a
|
|
53
|
+
// string `retracts`). A malformed line is skipped (the file is append-only and git-tracked, so a partial
|
|
54
|
+
// write or a hand-edit shouldn't sink the whole read) — fail soft per line.
|
|
55
|
+
export function readSidecar(sidecarPath: string): { readings: Reading[]; retractions: Retraction[] } {
|
|
56
|
+
const readings: Reading[] = []
|
|
57
|
+
const retractions: Retraction[] = []
|
|
58
|
+
if (!existsSync(sidecarPath)) return { readings, retractions }
|
|
21
59
|
for (const line of readFileSync(sidecarPath, 'utf8').split('\n')) {
|
|
22
60
|
const t = line.trim()
|
|
23
61
|
if (!t) continue
|
|
24
62
|
try {
|
|
25
63
|
const r = JSON.parse(t)
|
|
26
|
-
if (r
|
|
64
|
+
if (!r || typeof r.scenario !== 'string') continue
|
|
65
|
+
if (typeof r.retracts === 'string') retractions.push(r as Retraction)
|
|
66
|
+
else if (typeof r.evaluator === 'string') readings.push(r as Reading)
|
|
27
67
|
} catch { /* skip a malformed line */ }
|
|
28
68
|
}
|
|
29
|
-
return
|
|
69
|
+
return { readings, retractions }
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
// the retraction join, shared by every effective-view reader: drop each reading a retraction targets by
|
|
73
|
+
// (scenario, ts) — NUL-joined, since a scenario name may contain spaces. A retraction matching nothing is
|
|
74
|
+
// inert: it excludes no reading and harms no read.
|
|
75
|
+
export function applyRetractions(readings: Reading[], retractions: Retraction[]): Reading[] {
|
|
76
|
+
if (!retractions.length) return readings
|
|
77
|
+
const gone = new Set(retractions.map((x) => `${x.scenario}\0${x.retracts}`))
|
|
78
|
+
return readings.filter((r) => !gone.has(`${r.scenario}\0${r.ts}`))
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
// the EFFECTIVE readings — what the scoreboard sees: every reading minus the retracted. Every score
|
|
82
|
+
// consumer (freshness, scan, clean's referenced set, the eval tab, the proof) reads through here, so a
|
|
83
|
+
// retract undoes a botched filing on ALL of them at once — the previous reading becomes the latest again,
|
|
84
|
+
// or the scenario honestly returns to yatsu-missing.
|
|
85
|
+
export function readReadings(sidecarPath: string): Reading[] {
|
|
86
|
+
const { readings, retractions } = readSidecar(sidecarPath)
|
|
87
|
+
return applyRetractions(readings, retractions)
|
|
30
88
|
}
|
|
31
89
|
|
|
32
90
|
// append ONE reading as a JSON line — the only mutation eval performs (a reading is an event, never an
|
|
@@ -35,6 +93,12 @@ export function appendReading(sidecarPath: string, r: Reading): void {
|
|
|
35
93
|
appendFileSync(sidecarPath, JSON.stringify(r) + '\n')
|
|
36
94
|
}
|
|
37
95
|
|
|
96
|
+
// append ONE retraction as a JSON line — the sanctioned undo writes through the same append-only surface
|
|
97
|
+
// that filed the reading; the target line stays in place as history.
|
|
98
|
+
export function appendRetraction(sidecarPath: string, r: Retraction): void {
|
|
99
|
+
appendFileSync(sidecarPath, JSON.stringify(r) + '\n')
|
|
100
|
+
}
|
|
101
|
+
|
|
38
102
|
// the latest reading per scenario (the file is chronological, so the LAST line for a name wins). clean's
|
|
39
103
|
// --keep-latest uses it to decide which blob to keep.
|
|
40
104
|
export function latestPerScenario(readings: Reading[]): Map<string, Reading> {
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
// step-timeline — the map from a moment in a video reading's clip to a named step. SpexCode owns only
|
|
2
|
+
// this FORMAT (a tiny data contract any userland emitter satisfies — a Playwright reporter, a WebDriver
|
|
3
|
+
// listener, a computer-use hand narrating as it drives); aligning the emitter's clock to the clip is the
|
|
4
|
+
// emitter's own job. The timeline rides as a second content-addressed blob on the reading, never a new
|
|
5
|
+
// ndjson column beyond the one hash.
|
|
6
|
+
|
|
7
|
+
export type TimelineEvent = { tMs: number; step: string; node?: string }
|
|
8
|
+
export type StepTimeline = { v: 1; events: TimelineEvent[] }
|
|
9
|
+
|
|
10
|
+
const EVENT_KEYS = new Set(['tMs', 'step', 'node'])
|
|
11
|
+
|
|
12
|
+
// validate LOUD — every violation named, [] when well-formed. The key set is closed (like the yatsu.md
|
|
13
|
+
// scenario schema): a malformed timeline is rejected at filing time, never silently reshaped.
|
|
14
|
+
export function validateTimeline(raw: unknown): string[] {
|
|
15
|
+
if (typeof raw !== 'object' || raw === null || Array.isArray(raw)) return ['timeline must be a JSON object { v, events }']
|
|
16
|
+
const errs: string[] = []
|
|
17
|
+
const o = raw as Record<string, unknown>
|
|
18
|
+
for (const k of Object.keys(o)) if (k !== 'v' && k !== 'events') errs.push(`unknown field \`${k}\` (allowed: v, events)`)
|
|
19
|
+
if (o.v !== 1) errs.push('`v` must be 1')
|
|
20
|
+
if (!Array.isArray(o.events)) { errs.push('`events` must be an array'); return errs }
|
|
21
|
+
let prev = -Infinity
|
|
22
|
+
o.events.forEach((e, i) => {
|
|
23
|
+
if (typeof e !== 'object' || e === null || Array.isArray(e)) { errs.push(`events[${i}] must be an object`); return }
|
|
24
|
+
const ev = e as Record<string, unknown>
|
|
25
|
+
for (const k of Object.keys(ev)) if (!EVENT_KEYS.has(k)) errs.push(`events[${i}]: unknown field \`${k}\` (allowed: tMs, step, node)`)
|
|
26
|
+
if (typeof ev.tMs !== 'number' || !Number.isFinite(ev.tMs) || ev.tMs < 0) {
|
|
27
|
+
errs.push(`events[${i}].tMs must be a finite number ≥ 0`)
|
|
28
|
+
} else {
|
|
29
|
+
if (ev.tMs < prev) errs.push(`events[${i}].tMs is out of order (the list is ordered by time)`)
|
|
30
|
+
prev = ev.tMs
|
|
31
|
+
}
|
|
32
|
+
if (typeof ev.step !== 'string' || !ev.step.trim()) errs.push(`events[${i}].step must be a non-empty string`)
|
|
33
|
+
if (ev.node !== undefined && (typeof ev.node !== 'string' || !ev.node.trim())) errs.push(`events[${i}].node must be a non-empty string when present`)
|
|
34
|
+
})
|
|
35
|
+
return errs
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
// the whole of "which step is this moment": the last event at or before T; null before the first event
|
|
39
|
+
// (a plain player moment, no step to name — graceful, never an error).
|
|
40
|
+
export function stepAt(events: TimelineEvent[], tMs: number): TimelineEvent | null {
|
|
41
|
+
let hit: TimelineEvent | null = null
|
|
42
|
+
for (const e of events) {
|
|
43
|
+
if (e.tMs <= tMs) hit = e
|
|
44
|
+
else break
|
|
45
|
+
}
|
|
46
|
+
return hit
|
|
47
|
+
}
|
package/spec-yatsu/src/yatsu.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { readFileSync, readdirSync, existsSync } from 'node:fs'
|
|
2
|
+
import { readFile, readdir } from 'node:fs/promises'
|
|
2
3
|
import { join, relative, basename } from 'node:path'
|
|
3
4
|
|
|
4
5
|
export const YATSU_FILE = 'yatsu.md'
|
|
@@ -8,6 +9,7 @@ export type Scenario = {
|
|
|
8
9
|
name: string
|
|
9
10
|
description: string
|
|
10
11
|
expected: string
|
|
12
|
+
tags?: string[]
|
|
11
13
|
test?: string
|
|
12
14
|
code?: string[]
|
|
13
15
|
related?: string[]
|
|
@@ -21,9 +23,9 @@ export type YatsuNode = {
|
|
|
21
23
|
scenarios: Scenario[]
|
|
22
24
|
}
|
|
23
25
|
|
|
24
|
-
const SCENARIO_KEYS = ['name', 'description', 'expected', 'test', 'code', 'related'] as const
|
|
26
|
+
const SCENARIO_KEYS = ['name', 'description', 'expected', 'tags', 'test', 'code', 'related'] as const
|
|
25
27
|
type ScenarioKey = (typeof SCENARIO_KEYS)[number]
|
|
26
|
-
const LIST_KEYS: readonly ScenarioKey[] = ['code', 'related']
|
|
28
|
+
const LIST_KEYS: readonly ScenarioKey[] = ['tags', 'code', 'related']
|
|
27
29
|
|
|
28
30
|
// a raw scenario item straight off the frontmatter walk: the known fields it set, plus any UNKNOWN keys it
|
|
29
31
|
// carried — kept (not dropped) so the validator can name a typo'd field instead of silently swallowing it.
|
|
@@ -123,12 +125,14 @@ function parseCodeList(raw: string): string[] {
|
|
|
123
125
|
export function parseScenarios(src: string): Scenario[] {
|
|
124
126
|
return walkScenarios(src).items
|
|
125
127
|
.map((it): Scenario => {
|
|
128
|
+
const tags = it.fields.tags ? parseCodeList(it.fields.tags) : []
|
|
126
129
|
const code = it.fields.code ? parseCodeList(it.fields.code) : []
|
|
127
130
|
const related = it.fields.related ? parseCodeList(it.fields.related) : []
|
|
128
131
|
return {
|
|
129
132
|
name: it.fields.name ?? '',
|
|
130
133
|
description: it.fields.description ?? '',
|
|
131
134
|
expected: it.fields.expected ?? '',
|
|
135
|
+
...(tags.length ? { tags } : {}),
|
|
132
136
|
...(it.fields.test ? { test: it.fields.test } : {}),
|
|
133
137
|
...(code.length ? { code } : {}),
|
|
134
138
|
...(related.length ? { related } : {}),
|
|
@@ -137,18 +141,31 @@ export function parseScenarios(src: string): Scenario[] {
|
|
|
137
141
|
.filter((s) => s.name) // a scenario with no name is malformed — drop it (validateScenarios reports it)
|
|
138
142
|
}
|
|
139
143
|
|
|
140
|
-
|
|
144
|
+
// `tagLibrary` is the closed vocabulary a scenario's `tags:` must draw from (config's `lint.scenarioTags`).
|
|
145
|
+
// Every scenario needs ≥1 tag; each tag must be IN the library — an out-of-library tag is rejected LOUD with
|
|
146
|
+
// the repair the user owns: pick an existing tag, or extend the library. An empty library (none configured)
|
|
147
|
+
// disables only the membership check, never the ≥1-tag requirement.
|
|
148
|
+
export function validateScenarios(src: string, tagLibrary: string[] = []): string[] {
|
|
141
149
|
const { hasFrontmatter, hasKey, items } = walkScenarios(src)
|
|
142
150
|
if (!hasFrontmatter) return ['no frontmatter block — a yatsu.md must declare a `scenarios:` list']
|
|
143
151
|
if (!hasKey) return ['frontmatter has no `scenarios:` key — declare at least one scenario']
|
|
144
152
|
if (!items.length) return ['`scenarios:` declares no scenarios — add one (name + description + expected)']
|
|
145
153
|
const errs: string[] = []
|
|
146
154
|
const counts = new Map<string, number>()
|
|
155
|
+
const lib = tagLibrary.length ? ` (library: ${tagLibrary.join(', ')})` : ''
|
|
147
156
|
items.forEach((it, idx) => {
|
|
148
157
|
const label = it.fields.name ? `scenario '${it.fields.name}'` : `scenario #${idx + 1}`
|
|
149
158
|
for (const k of ['name', 'description', 'expected'] as const) {
|
|
150
159
|
if (!it.fields[k]?.trim()) errs.push(`${label}: missing required field \`${k}\``)
|
|
151
160
|
}
|
|
161
|
+
const tags = it.fields.tags ? parseCodeList(it.fields.tags) : []
|
|
162
|
+
if (!tags.length) {
|
|
163
|
+
errs.push(`${label}: missing required field \`tags\` — every scenario needs ≥1 tag from the library${lib}; pick one, or add a new tag to lint.scenarioTags in spexcode.json to create it`)
|
|
164
|
+
} else if (tagLibrary.length) {
|
|
165
|
+
for (const t of tags) if (!tagLibrary.includes(t)) {
|
|
166
|
+
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`)
|
|
167
|
+
}
|
|
168
|
+
}
|
|
152
169
|
for (const u of it.unknownKeys) errs.push(`${label}: unknown field \`${u}\` (allowed: ${SCENARIO_KEYS.join(', ')})`)
|
|
153
170
|
if (it.fields.name) counts.set(it.fields.name, (counts.get(it.fields.name) ?? 0) + 1)
|
|
154
171
|
})
|
|
@@ -179,3 +196,30 @@ export function yatsuNodes(root: string): YatsuNode[] {
|
|
|
179
196
|
}
|
|
180
197
|
return out.sort((a, b) => a.id.localeCompare(b.id))
|
|
181
198
|
}
|
|
199
|
+
|
|
200
|
+
// async twin of yatsuNodes for the HOT board build ([[board-cache]]): reading each yatsu.md through
|
|
201
|
+
// fs/promises YIELDS the event loop between files, so the walk no longer stalls a `/health` probe in one
|
|
202
|
+
// ~600ms uninterrupted stretch. Same output (id-sorted) as yatsuNodes; only buildBoard uses it, other
|
|
203
|
+
// callers keep the sync form.
|
|
204
|
+
export async function yatsuNodesAsync(root: string): Promise<YatsuNode[]> {
|
|
205
|
+
const specDir = join(root, '.spec')
|
|
206
|
+
const out: YatsuNode[] = []
|
|
207
|
+
const stack = existsSync(specDir) ? [specDir] : []
|
|
208
|
+
while (stack.length) {
|
|
209
|
+
const dir = stack.pop()!
|
|
210
|
+
let ents
|
|
211
|
+
try { ents = await readdir(dir, { withFileTypes: true }) } catch { continue }
|
|
212
|
+
if (existsSync(join(dir, YATSU_FILE))) {
|
|
213
|
+
const yatsuPath = relative(root, join(dir, YATSU_FILE))
|
|
214
|
+
out.push({
|
|
215
|
+
id: basename(dir),
|
|
216
|
+
dir,
|
|
217
|
+
yatsuPath,
|
|
218
|
+
sidecarPath: join(dir, SIDECAR_FILE),
|
|
219
|
+
scenarios: parseScenarios(await readFile(join(dir, YATSU_FILE), 'utf8')),
|
|
220
|
+
})
|
|
221
|
+
}
|
|
222
|
+
for (const e of ents) if (e.isDirectory()) stack.push(join(dir, e.name))
|
|
223
|
+
}
|
|
224
|
+
return out.sort((a, b) => a.id.localeCompare(b.id))
|
|
225
|
+
}
|
package/spec-cli/src/relay.ts
DELETED
|
@@ -1,28 +0,0 @@
|
|
|
1
|
-
import { searchSpecs } from './search.js'
|
|
2
|
-
import { loadSpecs } from './specs.js'
|
|
3
|
-
|
|
4
|
-
export type RelayHit = { id: string; title: string; path: string; score: number; code: string[] }
|
|
5
|
-
|
|
6
|
-
export async function relaySearch(query: string, opts: { limit?: number } = {}): Promise<RelayHit[]> {
|
|
7
|
-
const limit = opts.limit ?? 3
|
|
8
|
-
const hits = await searchSpecs(query, { limit })
|
|
9
|
-
if (!hits.length) return []
|
|
10
|
-
// one spec-index read; per node: its frontmatter `code:` governed paths (files/dirs/globs) + the tree shape.
|
|
11
|
-
const specs = await loadSpecs()
|
|
12
|
-
const codeById = new Map(specs.map((s) => [s.id, (s.code as string[]) ?? []]))
|
|
13
|
-
const childrenOf = new Map<string, string[]>()
|
|
14
|
-
for (const s of specs) if (s.parent) childrenOf.set(s.parent, [...(childrenOf.get(s.parent) ?? []), s.id])
|
|
15
|
-
const subtreeCode = (id: string): string[] => {
|
|
16
|
-
const own = codeById.get(id) ?? []
|
|
17
|
-
if (own.length) return own
|
|
18
|
-
const acc: string[] = []
|
|
19
|
-
const stack = [...(childrenOf.get(id) ?? [])]
|
|
20
|
-
while (stack.length) {
|
|
21
|
-
const c = stack.pop()!
|
|
22
|
-
acc.push(...(codeById.get(c) ?? []))
|
|
23
|
-
stack.push(...(childrenOf.get(c) ?? []))
|
|
24
|
-
}
|
|
25
|
-
return [...new Set(acc)]
|
|
26
|
-
}
|
|
27
|
-
return hits.map((h) => ({ id: h.id, title: h.title, path: h.path, score: h.score, code: subtreeCode(h.id) }))
|
|
28
|
-
}
|
|
@@ -1,32 +0,0 @@
|
|
|
1
|
-
---
|
|
2
|
-
title: scenario
|
|
3
|
-
surface: slash
|
|
4
|
-
status: active
|
|
5
|
-
hue: 160
|
|
6
|
-
kind: mutating
|
|
7
|
-
desc: Enter Scenario mode — author, refine, and re-measure a node's yatsu.md loss scenarios as first-class, file-scoped units.
|
|
8
|
-
---
|
|
9
|
-
You are in **Scenario mode** — a focused flow for managing a node's yatsu.md **scenarios** as first-class
|
|
10
|
-
units of loss. A scenario is one declared way to measure a node's loss: a `name`, a `description` of what to
|
|
11
|
-
check, the `expected` result that is zero loss, and optionally a `test` (a co-located runnable file) and
|
|
12
|
-
`code` (the concrete repo files THIS scenario depends on — its own freshness axis). Your job is to **edit,
|
|
13
|
-
create, and manage** these scenarios for the target node(s) — nothing else; do not write feature code.
|
|
14
|
-
|
|
15
|
-
Work through the real `spex` surface, never by reverse-engineering files:
|
|
16
|
-
|
|
17
|
-
1. **Read the schema and the node.** Run `spex guide yatsu` for the exact yatsu.md format. Read the target
|
|
18
|
-
node's `spec.md` (its present intent) and its governed `code:` files, plus its existing `yatsu.md` if any.
|
|
19
|
-
A scenario must measure what the SPEC promises, through the real product surface (YATU — You As The User),
|
|
20
|
-
not an internal helper chosen to make the proof easy.
|
|
21
|
-
2. **Author or refine scenarios.** For a node with no yatsu.md, create one with at least one scenario. For an
|
|
22
|
-
existing one, sharpen weak scenarios and add missing coverage. Give a scenario its own `code:` list when it
|
|
23
|
-
depends on a specific subset of the node's files, so it goes stale independently of its siblings. Keep
|
|
24
|
-
names unique and the schema valid — `spex yatsu scan <node>` reports violations (`yatsu-schema`), and the
|
|
25
|
-
pre-commit gate rejects a malformed file, so fix every finding before committing.
|
|
26
|
-
3. **Re-measure when the change warrants it.** If you changed a scenario or the code it measures, file a fresh
|
|
27
|
-
reading: measure through the product, then `spex yatsu eval <node> --scenario <name> (--pass|--fail|--note)
|
|
28
|
-
[--image <png>|--result <txt>]`. Use `spex yatsu show <node>` to read the timeline.
|
|
29
|
-
4. **Commit spec + yatsu.md together** on the node's branch, then report what scenarios now exist and their
|
|
30
|
-
satisfaction status. Do not bundle unrelated edits.
|
|
31
|
-
|
|
32
|
-
The node(s) to manage scenarios for: {{targets}}
|