cyber-sdd 0.3.1 → 0.4.1
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/.claude-plugin/plugin.json +1 -1
- package/.codex-plugin/plugin.json +1 -1
- package/.plugin/plugin.json +1 -1
- package/agents/sdd-automaton.md +5 -1
- package/dist/align-spec/scripts/align-spec.js +102 -0
- package/dist/check-project-specs/scripts/check-project-specs.js +311 -0
- package/dist/check-scenario-overlap/scripts/check-scenario-overlap.js +175 -0
- package/dist/check-spec-references/scripts/check-spec-references.js +285 -0
- package/dist/check-spec-structure/scripts/check-spec-structure.js +241 -0
- package/dist/concept-index/scripts/concept-index.js +181 -0
- package/dist/discover-specs/scripts/discover-specs.js +318 -0
- package/dist/spec-gate/scripts/check-spec-state.js +420 -0
- package/dist/spec-gate/scripts/check-suite.js +317 -0
- package/dist/spec-gate/scripts/classify-edit-class.js +263 -0
- package/license +21 -0
- package/package.json +11 -3
- package/skills/check-project-specs/scripts/check-project-specs.mts +13 -2
- package/skills/concept-index/scripts/concept-index.mts +2 -2
- package/skills/spec-gate/scripts/check-suite.mts +2 -2
- package/skills/spec-gate/scripts/classify-edit-class.mts +7 -7
- package/skills/start-mission/SKILL.md +44 -3
- package/skills/touch-set-correction/scripts/touch-set-correction.mts +7 -7
- package/skills/verify-scenarios/scripts/verify-scenarios.mts +2 -2
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
import { type Dirent, readdirSync, readFileSync, realpathSync } from 'node:fs'
|
|
7
7
|
import { basename, dirname, join } from 'node:path'
|
|
8
8
|
import { pathToFileURL } from 'node:url'
|
|
9
|
-
import {
|
|
9
|
+
import { validate } from 'gherkin-cli'
|
|
10
10
|
|
|
11
11
|
// ─── types ────────────────────────────────────────────────────────────────────
|
|
12
12
|
|
|
@@ -143,7 +143,7 @@ export interface ParseError {
|
|
|
143
143
|
// Maps the pinned parser's per-file report to its errors (empty array when it parses) so callers
|
|
144
144
|
// can look a path up directly.
|
|
145
145
|
export function runGherkinValidate(paths: string[]): Map<string, ParseError[]> {
|
|
146
|
-
const { files } =
|
|
146
|
+
const { files } = validate(paths)
|
|
147
147
|
const out = new Map<string, ParseError[]>()
|
|
148
148
|
for (const f of files) {
|
|
149
149
|
out.set(
|
|
@@ -9,7 +9,7 @@
|
|
|
9
9
|
// The classification is STRUCTURAL, never a raw git line-diff. A raw line-diff is fooled by a
|
|
10
10
|
// trailing step orphaned off a frozen scenario onto a newly added adjacent scenario: the orphan
|
|
11
11
|
// shows no `-` line and reads as purely additive, so a narrowing self-clears silently and
|
|
12
|
-
// Clearance never fires. The pinned `gherkin-cli
|
|
12
|
+
// Clearance never fires. The pinned `gherkin-cli` `diffFeatures(paths, {base})` is AST-level
|
|
13
13
|
// and is not fooled — it reports the losing baseline scenario as `modified` (`addOnly: false`).
|
|
14
14
|
//
|
|
15
15
|
// The pin is load-bearing, not incidental. Through `0.0.1` the differ's scenario identity covered
|
|
@@ -36,7 +36,7 @@ import { execFileSync } from 'node:child_process'
|
|
|
36
36
|
import { readFileSync, realpathSync } from 'node:fs'
|
|
37
37
|
import { dirname, join, relative, resolve, sep } from 'node:path'
|
|
38
38
|
import { pathToFileURL } from 'node:url'
|
|
39
|
-
import {
|
|
39
|
+
import { GitError, diff as gherkinDiff, type ReadsGitDiff } from 'gherkin-cli'
|
|
40
40
|
|
|
41
41
|
// ─── types ────────────────────────────────────────────────────────────────────
|
|
42
42
|
|
|
@@ -193,13 +193,13 @@ function readGitShow(base: string, path: string, cwd: string): string {
|
|
|
193
193
|
}
|
|
194
194
|
}
|
|
195
195
|
|
|
196
|
-
// Thin boundary around the pinned `gherkin-cli` `
|
|
196
|
+
// Thin boundary around the pinned `gherkin-cli` `diff` — never a re-implemented differ.
|
|
197
197
|
// classifyFromDiff / classifyFromFileResult carry the tested logic; this only wires the engine in.
|
|
198
|
-
// Takes a batch of paths (`
|
|
198
|
+
// Takes a batch of paths (`diff` is already variadic over paths) so a multi-file caller
|
|
199
199
|
// pays for one parse pass, not one per file.
|
|
200
200
|
export type GherkinDiffRunner = (base: string, paths: string[], cwd: string) => GherkinDiffOutput
|
|
201
201
|
|
|
202
|
-
// `
|
|
202
|
+
// `diff`'s default reader resolves each path via `path.resolve(file)` against
|
|
203
203
|
// `process.cwd()` and derives git's own cwd from THAT resolved location (`dirname` of the
|
|
204
204
|
// resolved path, then `git ls-files --full-name` to recover the repo-relative path) — it never
|
|
205
205
|
// trusts a caller-supplied cwd for the git commands at all. That self-derivation is why the
|
|
@@ -208,7 +208,7 @@ export type GherkinDiffRunner = (base: string, paths: string[], cwd: string) =>
|
|
|
208
208
|
// This reader is the library's own extension seam ("Injectable so tests can skip git"), replicated
|
|
209
209
|
// verbatim with the one substitution that matters here — `resolve(cwd, file)` instead of
|
|
210
210
|
// `resolve(file)` — so a caller's `cwd` participates without discarding that self-correction.
|
|
211
|
-
function makeCwdReader(cwd: string):
|
|
211
|
+
function makeCwdReader(cwd: string): ReadsGitDiff['readDiff'] {
|
|
212
212
|
return (file, base) => {
|
|
213
213
|
const abs = resolve(cwd, file)
|
|
214
214
|
const dir = dirname(abs)
|
|
@@ -249,7 +249,7 @@ function makeCwdReader(cwd: string): DiffReader {
|
|
|
249
249
|
}
|
|
250
250
|
|
|
251
251
|
export const runGherkinDiff: GherkinDiffRunner = (base, paths, cwd) =>
|
|
252
|
-
|
|
252
|
+
gherkinDiff(paths, { base, full: true }, { readDiff: makeCwdReader(cwd) })
|
|
253
253
|
|
|
254
254
|
// ─── per-file classification ────────────────────────────────────────────────────
|
|
255
255
|
|
|
@@ -13,7 +13,47 @@ A CR is the **unit of change-intent** (git-PR-shaped); the mission loop is the w
|
|
|
13
13
|
|
|
14
14
|
> **Advise a capable model (e.g. Opus) on entry.** The explore grill runs in this session, so its quality tracks the session model. Surface this **before** the grill so the user can switch if needed. (The harness cannot switch the session model on your behalf.)
|
|
15
15
|
|
|
16
|
-
|
|
16
|
+
## Governances — load before you start
|
|
17
|
+
|
|
18
|
+
You are **bound by** these bars, not merely aware of them. Read the **entry set** now, before
|
|
19
|
+
Step 1 — each one governs every mission, so deferring it means acting unbound:
|
|
20
|
+
|
|
21
|
+
| Governance | What it binds |
|
|
22
|
+
|---|---|
|
|
23
|
+
| `sdd:lifecycle-governance` | the status enum and the freeze re-open transition |
|
|
24
|
+
| `sdd:ownership-governance` | who writes each field |
|
|
25
|
+
| `sdd:spec-format-governance` | the node skeleton |
|
|
26
|
+
| `sdd:suite-format-governance` | the suite bars |
|
|
27
|
+
| `sdd:spec-producer-governance` | the grilling procedure you run inline |
|
|
28
|
+
| `sdd:combat-log-governance` | the provenance shapes you write |
|
|
29
|
+
| `sdd:gate-validation-governance` | the legal gate-state tuples you derive at both gates |
|
|
30
|
+
|
|
31
|
+
The rest are **named now, read at the decision that invokes them** — each governs one moment rather
|
|
32
|
+
than the run: `sdd:remediation-governance` (answering a `change` verdict — only when a judge returns
|
|
33
|
+
one) and `sdd:impl-producer-governance` (what the spawned builder loads — at deliver). Naming them
|
|
34
|
+
here is not loading them; load the body when you reach that moment.
|
|
35
|
+
|
|
36
|
+
**Why `gate-validation` sits in the entry set although it fires only at a gate.** Every mission
|
|
37
|
+
reaches a gate, and the set you declare is read downstream: the spec-judge's pre-flight derives its
|
|
38
|
+
expected set from its **own** fixed-universal floor — which carries `gate-validation` — and checks it
|
|
39
|
+
against the `producer_governances_declared` relay of your inline spec-producer's declaration
|
|
40
|
+
(`sdd:sdd-spec-judge`). Deferring it makes that pre-flight fail by construction on a producer that
|
|
41
|
+
did nothing wrong. Reach, not phase, decides the split.
|
|
42
|
+
|
|
43
|
+
**Declare what you loaded.** On entry, state your own `governances_loaded` set — the same shape the
|
|
44
|
+
spec-producer declares and you already relay as `producer_governances_declared`. The declaration is
|
|
45
|
+
the conductor's, about the conductor: it makes a skipped load **visible instead of silent**. Declare
|
|
46
|
+
the entry set at entry, and each deferred governance when you load it. Declare it **as it is** —
|
|
47
|
+
never list a governance you did not read. Your declaration and the `producer_governances_declared`
|
|
48
|
+
set you relay are **recorded separately; neither replaces the other** — one is what *you* loaded, the
|
|
49
|
+
other is what the producer declared passing through you.
|
|
50
|
+
|
|
51
|
+
The partition covers the conductor's **fixed-universal** bars only. A **resolved-actor** bar — one
|
|
52
|
+
`resolve-governances` names per artifact-type, such as `sdd:spec-structure-governance` at placement —
|
|
53
|
+
is neither entry-set nor deferred: it is resolved and loaded per touched file (Step 2), under the
|
|
54
|
+
digest discipline there.
|
|
55
|
+
|
|
56
|
+
The autonomy bar is baked in (below).
|
|
17
57
|
|
|
18
58
|
## Step 1 — intake: open the CR and scaffold the plan
|
|
19
59
|
|
|
@@ -32,7 +72,7 @@ Run authoring **in-session** as the conductor. Explore **builds the implementati
|
|
|
32
72
|
|
|
33
73
|
**If plan mode is active, run the `### Plan-mode preview` (end of this step) instead of the live grill loop** — same reasoning, no repo writes, no build-to-learn spikes; the phase ends at **ExitPlanMode**, not the spec gate.
|
|
34
74
|
|
|
35
|
-
**Resolution first.** Run `resolve-governances` over **only** the project registry `.agents/universal-plugin.json` (never scan plugin dirs), passing the current project's anchors (`--project`, plus `--project-root` in a monorepo — you know the project from `discover-specs` / context). For **each touched file's** `artifact-type` it names each production-chain role's agent (a plugin delegate or the SDD default) plus the resolved-actor bar **candidates bucketed by tier** (`project` / `project-root` / `plugin` / `sdd`). It does **not** compose — **load each candidate and compose them yourself** by precedence `sdd-default < plugin < project-root < project` (most-specific wins on conflict; a governance's own `compose: replace` supersedes its bar's lower-precedence candidates); the fixed-universal governances are loaded from the role/agent definition (the matcher does not emit them) —
|
|
75
|
+
**Resolution first.** Run `resolve-governances` over **only** the project registry `.agents/universal-plugin.json` (never scan plugin dirs), passing the current project's anchors (`--project`, plus `--project-root` in a monorepo — you know the project from `discover-specs` / context). For **each touched file's** `artifact-type` it names each production-chain role's agent (a plugin delegate or the SDD default) plus the resolved-actor bar **candidates bucketed by tier** (`project` / `project-root` / `plugin` / `sdd`). It does **not** compose — **load each candidate and compose them yourself** by precedence `sdd-default < plugin < project-root < project` (most-specific wins on conflict; a governance's own `compose: replace` supersedes its bar's lower-precedence candidates); the fixed-universal governances are loaded from the role/agent definition (the matcher does not emit them) — the **entry set** is already read (above), and the **deferred** ones are read at the decision that invokes them (`remediation` on a `change` verdict, `impl-producer` at deliver), so a one-line change never reads the deferred bodies it never reaches. A required role with no real delegate **fails closed**. A **resolved** delegate that **recuses** from a subject (produces nothing, declaring it outside its domain — e.g. a plugin bound by artifact-type meets a subject its lens does not fit) is **not** a fail-closed: **re-resolve that one unit's chain to the SDD defaults** (default producer + SDD-default bars + judge) and proceed, recording the recusal as a combat-log line (never a halt); other units keep their squad (`sdd:lifecycle-governance`). A domain claimed by two plugins → ask (answered live in-session).
|
|
36
76
|
|
|
37
77
|
For each unit the CR touches:
|
|
38
78
|
|
|
@@ -69,7 +109,7 @@ Run **only** when plan mode is active (the harness blocks every write except the
|
|
|
69
109
|
|
|
70
110
|
On entering the gate, overwrite the statusline file with `spec gate` (same opt-in, best-effort write as explore — skip when plan mode escaped this step via ExitPlanMode).
|
|
71
111
|
|
|
72
|
-
Run the spec gate as an **internal step** (not a user-invocable skill). Judge each touched unit suite against `sdd:suite-format-governance` (untagged scenarios boolean; `@rubric` well-formed) and the spec-format bars;
|
|
112
|
+
Run the spec gate as an **internal step** (not a user-invocable skill). Judge each touched unit suite against `sdd:suite-format-governance` (untagged scenarios boolean; `@rubric` well-formed) and the spec-format bars; apply `sdd:lifecycle-governance` + `sdd:ownership-governance` + `sdd:gate-validation-governance` — all three already read at entry — for the legal state tuple. **Never advance** with judge failures, open markers, or a suite that does not cover the spec. On a **change** verdict the findings are **evidence, not a work order**: substantiate each before acting, state the **rule** each instantiates and sweep for its other instances, re-derive every correction against the rule **governing the artifact** rather than against the finding alone, and account for each finding's **provenance** — a finding naming an artifact the previous round's commits changed is a **regression**, which stops the loop for a re-plan instead of another round (`sdd:remediation-governance`). ("Regression" here is finding provenance, distinct from the grill loop's convergence above.) On **approve**: **freeze** each touched `.feature` via its `@frozen` tag, record a per-CR `gate` line in **your own shard** in the `ledger/` directory sibling to `spec.md`, and set `status: approved`. `spec.md` stays in sync, never frozen.
|
|
73
113
|
|
|
74
114
|
## Step 3 — deliver: build to keep
|
|
75
115
|
|
|
@@ -104,6 +144,7 @@ Once landed, **do not spawn** the formation Warden. Surface a **one-line nudge**
|
|
|
104
144
|
## Autonomy, provenance, and the hard floor (baked in)
|
|
105
145
|
|
|
106
146
|
- **Dispatch transport.** Every spawn beyond this session states a **dispatch intent** — role, brief, expected verdict schema — never a pinned command. When a harness-agnostic dispatch capability is available (detected at runtime; the concrete case is the Legate's `dispatch-governance` composing `cyberlegion` primitives — `agent resolve` + `unit spawn` + `mail await` — with **no** `dispatch` CLI verb, the seam named in the SDD project spec's `design/harness-spawning` node, repo-only), route through its intent seam and let it pick `subagent | channel | run-inline`, **preferring a warm unit** over a cold one-shot spawn; with no capability present, fall back to the portable cold subagent (depth-1) default — grader independence intact either way. **Warmth is a property of the unit/process; coldness of the context**: a judge's fresh-context guarantee (ADR-0016) is transport-agnostic — satisfied by a newly spawned cold subagent **or** a warm unit **context-cleared** to a fresh context before **each** judgment (re-deriving its oracle, carrying none of a prior round's context). Clear a warm unit with **`npx cyberlegion@0.3.1 unit clear <ref>`** (`<ref>` = unit id / handle / worktree branch or CR ref) — it injects the harness's own fresh-context command (`/clear` on Claude/Codex/Copilot, `/new-chat` on Cursor; fail-loud on a harness with no honest reset) so the **pane stays warm** while the **context goes cold**; it tears nothing down. The **impl-producer builder** instead stays warm and **keeps** its context across the explore spikes and the deliver build (never cleared between those uses). Warm units stay warm for **one mission** — reused within it, then **`unit clear`**'d or torn down at **handoff**, never carrying this mission's context into the next.
|
|
147
|
+
- **SDD's own judges go to the seam by file, not by name.** A dispatch capability may resolve a definition by name only in the project's own agent folder (cyberlegion's `agent resolve` does), so it cannot find one SDD ships. When the judge role resolves to SDD's own `sdd-spec-judge` or `sdd-impl-judge`, locate the definition yourself at **`agents/<name>.md` under the SDD plugin root** — two levels above this skill's own base directory (`<skill dir>/../../agents/<name>.md`) — and hand the capability **that path** (cyberlegion: `agent resolve --file <path>`, `unit spawn --agent-file <path>`), never the bare name. If no file is there, send the capability **no request for that judge** (a by-name one would miss) and take the no-capability route: spawn it as a portable cold subagent through the harness's own plugin-agent spawn (`sdd:sdd-spec-judge` / `sdd:sdd-impl-judge`), which knows the plugin. A plugin-delegated judge is outside this rule.
|
|
107
148
|
- **Initial strategy** (run start): assess blast radius + the other dimensions and emit a run-level `kind: leash` block to **your own ledger shard** (`ledger/<cr-ref>.<hash>.jsonl` — mint `<hash>` as 6 random hex **once per session** and reuse it for every line you append; `sdd:combat-log-governance`) — `leash` (`auto-none | auto-spec | auto-all`), `by: derived | user`, `approach[]`. It may be user-specified. This block is `kind: leash`, **not** `strategy` — `strategy` is the doctrine Scanner's alone. Ledger lines carry **no `ts`**.
|
|
108
149
|
- **Per-gate verdict.** At each gate, derive the leash against discovered state and either **self-assert within leash** (write `approval.<gate>: { verdict: approve, by: agent, why }`; the spec lands in the async review queue) or **stop** with a verdict packet for the human. **Never advance** when any judge fails, any open marker remains, or (at the impl gate) any frozen scenario's verification does not pass. Human ratification (`by: <name>`, advance `status`) is reserved to the in-session position holding the user channel — by default you, in-session; a headless `automaton` emits the verdict packet and stops, **even when a coordinator relays "the user approved."**
|
|
109
150
|
- **Combat log.** Append `report` / `correction` lines (and the halt that stopped you) to the plan's `*.log.jsonl` (these carry a UTC `ts`); your run-start `leash` block, self-asserted `gate` lines, and the handoff `followup` records go to **your own shard** in the durable `ledger/` directory sibling to `spec.md` — never another writer's shard, never a shared file (`strategy` there is the Scanner's alone). Free text is commit-message-grade — never code, prompts, secrets, or literal values.
|
|
@@ -13,7 +13,7 @@
|
|
|
13
13
|
// a live git diff or the live mission-graph store.
|
|
14
14
|
// - readChangedFiles / resolveArtifactType / changedScenarios / collectChangedFiles /
|
|
15
15
|
// discoverLayouts are the thin IO SEAM: they shell out to `git`, `resolve-governances.mts`, and
|
|
16
|
-
// the pinned `gherkin-cli
|
|
16
|
+
// the pinned `gherkin-cli` `diff` (the same differ classify-edit-class.mts uses —
|
|
17
17
|
// this tool never reimplements a differ). NOT unit-tested (binary/fs boundary) — the tested
|
|
18
18
|
// logic is everything downstream of the file list.
|
|
19
19
|
// - main() is a thin CLI: argv -> collectChangedFiles + assembleCorrection, rendering TOON by
|
|
@@ -30,7 +30,7 @@ import { execFileSync } from 'node:child_process'
|
|
|
30
30
|
import { readFileSync, realpathSync } from 'node:fs'
|
|
31
31
|
import { dirname, join, relative, resolve, sep } from 'node:path'
|
|
32
32
|
import { fileURLToPath, pathToFileURL } from 'node:url'
|
|
33
|
-
import {
|
|
33
|
+
import { diff as gherkinDiff, type ReadsGitDiff } from 'gherkin-cli'
|
|
34
34
|
|
|
35
35
|
// ── Types ──
|
|
36
36
|
|
|
@@ -226,7 +226,7 @@ export function resolveArtifactType(path: string, root: string, cwd: string): st
|
|
|
226
226
|
}
|
|
227
227
|
}
|
|
228
228
|
|
|
229
|
-
// `
|
|
229
|
+
// `diff`'s default reader resolves paths against `process.cwd()` and derives git's own cwd
|
|
230
230
|
// from that resolved location (`git ls-files --full-name` to recover the repo-relative path) —
|
|
231
231
|
// this reader is that same algorithm, re-pointed at `cwd` (same seam classify-edit-class.mts
|
|
232
232
|
// uses), so it stays robust to a caller whose relative-path bookkeeping doesn't line up with its
|
|
@@ -234,7 +234,7 @@ export function resolveArtifactType(path: string, root: string, cwd: string): st
|
|
|
234
234
|
// reads as "absent" — the outer `changedScenarios` catch-all is this call site's real fail-open
|
|
235
235
|
// boundary, so a thrown `GitError` here is caught there rather than escalated.
|
|
236
236
|
const cwdReader =
|
|
237
|
-
(cwd: string):
|
|
237
|
+
(cwd: string): ReadsGitDiff['readDiff'] =>
|
|
238
238
|
(file, base) => {
|
|
239
239
|
const abs = resolve(cwd, file)
|
|
240
240
|
const dir = dirname(abs)
|
|
@@ -272,15 +272,15 @@ const cwdReader =
|
|
|
272
272
|
return { head, base: baseText }
|
|
273
273
|
}
|
|
274
274
|
|
|
275
|
-
/** The changed scenario names of a touched `.feature`, via the pinned `gherkin-cli
|
|
276
|
-
* `
|
|
275
|
+
/** The changed scenario names of a touched `.feature`, via the pinned `gherkin-cli`
|
|
276
|
+
* `diff` (same tool classify-edit-class.mts uses — never a reimplemented differ). Gated
|
|
277
277
|
* by isFeature — a non-.feature never calls out. On any failure returns []. Reads any `.feature`
|
|
278
278
|
* regardless of freeze — the freeze gate is a separate concern (spec-gate), not this tool's
|
|
279
279
|
* business. */
|
|
280
280
|
export function changedScenarios(base: string, path: string, cwd: string): string[] {
|
|
281
281
|
if (!isFeature(path)) return []
|
|
282
282
|
try {
|
|
283
|
-
const { files } =
|
|
283
|
+
const { files } = gherkinDiff([path], { base, full: true }, { readDiff: cwdReader(cwd) })
|
|
284
284
|
const fileResult = files.find((f) => f.file === path) ?? files[0]
|
|
285
285
|
return (fileResult?.scenarios ?? []).filter((s) => s.change !== 'unchanged').map((s) => s.name)
|
|
286
286
|
} catch {
|
|
@@ -49,7 +49,7 @@ import { execSync } from 'node:child_process'
|
|
|
49
49
|
import { existsSync, readFileSync, realpathSync } from 'node:fs'
|
|
50
50
|
import { isAbsolute, join } from 'node:path'
|
|
51
51
|
import { pathToFileURL } from 'node:url'
|
|
52
|
-
import {
|
|
52
|
+
import { parse } from 'gherkin-cli'
|
|
53
53
|
|
|
54
54
|
// Resolves a path argument against `--root`: relative paths join beneath root (which defaults to the
|
|
55
55
|
// current directory); an absolute path is used verbatim, never double-prefixed under root.
|
|
@@ -132,7 +132,7 @@ export function scenarioKeysFromParse(parsed: GherkinParseOutput): ScenarioKey[]
|
|
|
132
132
|
// `--feature-root` — see main()).
|
|
133
133
|
export function getScenarioKeys(root: string, featurePath: string, featureRoot: string = root): ScenarioKey[] {
|
|
134
134
|
const abs = underRoot(featureRoot, featurePath)
|
|
135
|
-
return scenarioKeysFromParse(
|
|
135
|
+
return scenarioKeysFromParse(parse([abs]))
|
|
136
136
|
}
|
|
137
137
|
|
|
138
138
|
// ── JUnit parsing (hand-rolled, no xml dep) ──
|