faberun 0.7.0 → 0.8.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/package.json +1 -1
- package/skills/faberun/SKILL.md +1 -0
- package/skills/faberun/references/spec-format.md +87 -0
- package/src/campaign/index.mjs +46 -3
- package/src/cli/brand.mjs +2 -0
- package/src/cli/campaign.mjs +1 -0
- package/src/cli/manual.mjs +3 -1
- package/src/cli/spec.mjs +119 -0
- package/src/cli.mjs +2 -0
- package/src/plan/freeze.mjs +97 -0
- package/src/plan/repo-facts.mjs +148 -0
- package/src/plan/routing.mjs +200 -0
- package/src/plan/sizing.mjs +0 -0
- package/src/plan/spec.mjs +320 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "faberun",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.8.0",
|
|
4
4
|
"description": "Faberun is a development orchestration system that turns intent into verified software: harness- and model-agnostic, it keeps the intent, coordinates the work, verifies the result and decides what happens next.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
package/skills/faberun/SKILL.md
CHANGED
|
@@ -10,6 +10,7 @@ Read [rules](references/rules.md) first.
|
|
|
10
10
|
|
|
11
11
|
| Action | Read |
|
|
12
12
|
| --- | --- |
|
|
13
|
+
| Write or validate a spec | [spec-format](references/spec-format.md) |
|
|
13
14
|
| Author a contract (fallback) | [contract](references/contract.md), [engineering](references/engineering.md) |
|
|
14
15
|
| Launch and resume | [workflow](references/workflow.md), [operations](references/operations.md) |
|
|
15
16
|
| Dispatch a node | [handoffs](references/handoffs.md) |
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
# Spec format reference
|
|
2
|
+
|
|
3
|
+
Format version `1`. A spec is the free-form input the operator hands the
|
|
4
|
+
planner; this is the structured shape it validates against
|
|
5
|
+
(`faberun spec validate`, no model invoked). A document without the front
|
|
6
|
+
matter below is classified `legacy` and accepted, not rejected — the
|
|
7
|
+
validator says so explicitly, so old campaign records keep working.
|
|
8
|
+
|
|
9
|
+
## Front matter
|
|
10
|
+
|
|
11
|
+
```yaml
|
|
12
|
+
---
|
|
13
|
+
id: kebab-case-campaign-id
|
|
14
|
+
title: "Human-readable title"
|
|
15
|
+
version: 1.1.0
|
|
16
|
+
status: draft
|
|
17
|
+
date: 2026-09-17
|
|
18
|
+
owner: Author Name
|
|
19
|
+
target: org/repo
|
|
20
|
+
baseline: <git sha the spec was measured against>
|
|
21
|
+
---
|
|
22
|
+
```
|
|
23
|
+
|
|
24
|
+
`id`, `title`, `version`, `status`, `date`, `owner`, `target`, `baseline` are
|
|
25
|
+
required. `derived_from` and `followed_by` are optional cross-references to
|
|
26
|
+
other spec ids (a prior spec this one revises, or the campaign meant to
|
|
27
|
+
follow it).
|
|
28
|
+
|
|
29
|
+
## Sections
|
|
30
|
+
|
|
31
|
+
Mandatory, in order: **Intent**, **Requirements**, **Non-goals**. The
|
|
32
|
+
reference proposal
|
|
33
|
+
(`docs/campaigns/spec-format-and-planning-stages/spec/PROPOSAL.md`) writes
|
|
34
|
+
these as `Intenção`, `Requisitos`, `Não-objetivos` — the section role is what
|
|
35
|
+
matters, not the language of the heading text.
|
|
36
|
+
|
|
37
|
+
- **Intent** — prose: why this work, what problem, what it unblocks.
|
|
38
|
+
- **Requirements** — one `### R<n>. <title>` block per requirement (see
|
|
39
|
+
below).
|
|
40
|
+
- **Non-goals** — a bullet list of what this spec explicitly excludes, so a
|
|
41
|
+
planner never infers scope from silence.
|
|
42
|
+
|
|
43
|
+
Optional sections, any subset, any order after Non-goals:
|
|
44
|
+
|
|
45
|
+
- **Constraints** — bullets binding every requirement at once (e.g. "no node
|
|
46
|
+
runs the full suite").
|
|
47
|
+
- **Success criteria** — a table with at least a `Baseline` column, so
|
|
48
|
+
validation can catch a metric nobody measured before claiming a delta.
|
|
49
|
+
- **Risks** — a table of risk / impact / mitigation.
|
|
50
|
+
|
|
51
|
+
## Requirement shape
|
|
52
|
+
|
|
53
|
+
```markdown
|
|
54
|
+
### R7. Repo facts are deterministic and carry measured duration
|
|
55
|
+
|
|
56
|
+
- **statement:** the target repo inventory is generated without invoking a
|
|
57
|
+
model, is identical across two runs at the same HEAD, and every candidate
|
|
58
|
+
verification command carries a duration measured by
|
|
59
|
+
`preflight --time-verification`.
|
|
60
|
+
- **proof:** command: node --test --test-name-pattern="repo facts"
|
|
61
|
+
```
|
|
62
|
+
|
|
63
|
+
`R<n>` is a stable id — never renumbered once referenced elsewhere (a
|
|
64
|
+
comparative arm, a follow-up spec). `statement` is the testable claim.
|
|
65
|
+
`proof` is exactly one of:
|
|
66
|
+
|
|
67
|
+
- `command: <shell command>` — re-run it, exit zero proves the requirement.
|
|
68
|
+
- `path: <repo-relative path>` — the file or directory must exist.
|
|
69
|
+
- `judgment: true` — no deterministic check; a reviewer decides.
|
|
70
|
+
|
|
71
|
+
A requirement may add its own `- **constraints:** ...` line for a rule
|
|
72
|
+
scoped to it alone, distinct from the spec-wide Constraints section.
|
|
73
|
+
|
|
74
|
+
## What `faberun spec validate` checks
|
|
75
|
+
|
|
76
|
+
Deterministic, no model call. Rejects:
|
|
77
|
+
|
|
78
|
+
- a requirement without a stable id, or without a `proof` line;
|
|
79
|
+
- a spec with no Non-goals section;
|
|
80
|
+
- a Success criteria table row with no Baseline value;
|
|
81
|
+
- a `target` or `baseline` that does not resolve to a real commit.
|
|
82
|
+
|
|
83
|
+
These are **advisory** by default — recorded as findings, spec still
|
|
84
|
+
validates — and become **blocking** under `--strict-traceability`, which
|
|
85
|
+
fails validation on any of the above. A `legacy`-class document (no front
|
|
86
|
+
matter) is exempt from every check above; it is accepted and labeled, never
|
|
87
|
+
scored against these rules.
|
package/src/campaign/index.mjs
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import {
|
|
2
|
+
copyFileSync,
|
|
2
3
|
existsSync,
|
|
3
4
|
mkdirSync,
|
|
4
5
|
readFileSync,
|
|
@@ -9,7 +10,7 @@ import { join, resolve } from "node:path";
|
|
|
9
10
|
import { writeJsonAtomic } from "../run/store.mjs";
|
|
10
11
|
import { requireId, requirePacketHash, requireString, requireTimestamp } from "../contract/assert.mjs";
|
|
11
12
|
import { promoteRun } from "../repo/integrate.mjs";
|
|
12
|
-
import { CAMPAIGN_FILE, GOAL_TEXT_BYTES, PROJECTION_FILE, campaignDir, campaignsDir } from "./layout.mjs";
|
|
13
|
+
import { CAMPAIGN_FILE, GOAL_TEXT_BYTES, JOURNAL_FILE, PROJECTION_FILE, campaignDir, campaignsDir } from "./layout.mjs";
|
|
13
14
|
import { readCampaign } from "./record.mjs";
|
|
14
15
|
import { appendJournal, normalizeText, readJournalForDedupe } from "./journal.mjs";
|
|
15
16
|
import { readProjectionState } from "./projection.mjs";
|
|
@@ -122,7 +123,7 @@ export function resolveCampaign(runsDir, campaignId) {
|
|
|
122
123
|
/**
|
|
123
124
|
* @param {string} campaignPath
|
|
124
125
|
* @param {{at?: string, eventId?: string}} options
|
|
125
|
-
* @returns {{path: string, campaign: Campaign}}
|
|
126
|
+
* @returns {{path: string, campaign: Campaign, ledgerFiles: string[]}}
|
|
126
127
|
*/
|
|
127
128
|
export function closeCampaign(campaignPath, { at = new Date().toISOString(), eventId = randomUUID() } = {}) {
|
|
128
129
|
requireTimestamp(at, "at");
|
|
@@ -131,10 +132,52 @@ export function closeCampaign(campaignPath, { at = new Date().toISOString(), eve
|
|
|
131
132
|
if (!readJournalForDedupe(campaignPath).some((entry) => entry.type === "retrospective")) {
|
|
132
133
|
throw new Error(`campaign ${campaign.id} has no recorded retrospective; record one with note --kind retrospective before close`);
|
|
133
134
|
}
|
|
135
|
+
const repoRoot = resolve(campaignPath, "..", "..", "..");
|
|
136
|
+
const ledgerFiles = preserveCampaignLedger(campaignPath, repoRoot);
|
|
134
137
|
const closed = /** @type {Campaign} */ ({ ...campaign, status: "closed", closedAt: at, updatedAt: at });
|
|
135
138
|
writeJsonAtomic(join(campaignPath, CAMPAIGN_FILE), closed);
|
|
136
139
|
appendJournal(campaignPath, { type: "campaign.closed", at, eventId });
|
|
137
|
-
return { path: campaignPath, campaign: closed };
|
|
140
|
+
return { path: campaignPath, campaign: closed, ledgerFiles };
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/**
|
|
144
|
+
* Copy a campaign's journal, record and each linked run's usage into
|
|
145
|
+
* `<repoRoot>/docs/campaigns/<id>/ledger/` so the comparative arm of the
|
|
146
|
+
* planner has a session-side baseline even after `.runs/` (gitignored) is
|
|
147
|
+
* pruned. Nothing in this tree redacts token counts, costs or operator notes
|
|
148
|
+
* before this point, so the copy is verbatim; the pre-commit secret scan is
|
|
149
|
+
* the guard against anything that should not land in git.
|
|
150
|
+
*
|
|
151
|
+
* Idempotent: re-running it (a second `close` on an already-closed campaign
|
|
152
|
+
* cannot reach this, but a direct call can) overwrites the same destination
|
|
153
|
+
* files rather than duplicating them. A linked run without a `usage.jsonl`
|
|
154
|
+
* (never launched, or pruned) is skipped rather than thrown.
|
|
155
|
+
*
|
|
156
|
+
* @param {string} campaignPath
|
|
157
|
+
* @param {string} repoRoot
|
|
158
|
+
* @returns {string[]}
|
|
159
|
+
*/
|
|
160
|
+
export function preserveCampaignLedger(campaignPath, repoRoot) {
|
|
161
|
+
const campaign = readCampaign(campaignPath);
|
|
162
|
+
const runsDir = resolve(campaignPath, "..", "..");
|
|
163
|
+
const ledgerDir = join(repoRoot, "docs", "campaigns", campaign.id, "ledger");
|
|
164
|
+
mkdirSync(ledgerDir, { recursive: true });
|
|
165
|
+
const written = [];
|
|
166
|
+
for (const name of [JOURNAL_FILE, CAMPAIGN_FILE]) {
|
|
167
|
+
const source = join(campaignPath, name);
|
|
168
|
+
if (!existsSync(source)) continue;
|
|
169
|
+
const destination = join(ledgerDir, name);
|
|
170
|
+
copyFileSync(source, destination);
|
|
171
|
+
written.push(destination);
|
|
172
|
+
}
|
|
173
|
+
for (const runId of campaign.linkedRunIds) {
|
|
174
|
+
const source = join(runsDir, runId, "usage.jsonl");
|
|
175
|
+
if (!existsSync(source)) continue;
|
|
176
|
+
const destination = join(ledgerDir, `${runId}.usage.jsonl`);
|
|
177
|
+
copyFileSync(source, destination);
|
|
178
|
+
written.push(destination);
|
|
179
|
+
}
|
|
180
|
+
return written;
|
|
138
181
|
}
|
|
139
182
|
|
|
140
183
|
/**
|
package/src/cli/brand.mjs
CHANGED
|
@@ -195,6 +195,8 @@ export function renderUsage() {
|
|
|
195
195
|
"next [--cwd <dir>] [--json]",
|
|
196
196
|
"bulk-read --question <text> --paths <a,b,c> [--json]",
|
|
197
197
|
"contract validate <contract.json>",
|
|
198
|
+
"spec validate <file> [--strict-traceability] [--json]",
|
|
199
|
+
"spec scaffold <path> [--id <id>]",
|
|
198
200
|
"metrics <campaign-id> [--cwd <dir>] [--json]",
|
|
199
201
|
"campaign <init|watch|attach|note|resolve|close|supervise|show|list|sync|ack> ...",
|
|
200
202
|
"seat <start|attach|status|stop> [<campaign-id>] [--cwd <dir>] ...",
|
package/src/cli/campaign.mjs
CHANGED
|
@@ -447,6 +447,7 @@ function close(campaignId, values) {
|
|
|
447
447
|
const closed = closeCampaign(path, { eventId: values.eventId ?? randomUUID() });
|
|
448
448
|
renderHandoff(path, runsDir);
|
|
449
449
|
process.stdout.write(`[campaign] ${closed.campaign.id} closed\n`);
|
|
450
|
+
process.stdout.write(`[campaign] ledger · docs/campaigns/${closed.campaign.id}/ledger · ${closed.ledgerFiles.length} files\n`);
|
|
450
451
|
if (syncAgentSignal(runsDir)) process.stdout.write(`[campaign] AGENTS.md signal updated\n`);
|
|
451
452
|
}
|
|
452
453
|
|
package/src/cli/manual.mjs
CHANGED
|
@@ -14,6 +14,7 @@ import CAMPAIGN_OPERATIONS from "./campaign.mjs";
|
|
|
14
14
|
import SEAT_OPERATIONS from "./seat.mjs";
|
|
15
15
|
import CONTRACT_OPERATIONS from "./contract.mjs";
|
|
16
16
|
import SKILLS_OPERATIONS from "./skills.mjs";
|
|
17
|
+
import SPEC_OPERATIONS from "./spec.mjs";
|
|
17
18
|
|
|
18
19
|
/** @typedef {{type: "string"|"boolean", multiple?: boolean}} FlagSpec */
|
|
19
20
|
/** @typedef {{flags?: Record<string, FlagSpec>, operations?: Record<string, Record<string, FlagSpec>>}} VerbSurface */
|
|
@@ -22,7 +23,7 @@ import SKILLS_OPERATIONS from "./skills.mjs";
|
|
|
22
23
|
const MANUAL_PATH = fileURLToPath(new URL("../../docs/COMMANDS.md", import.meta.url));
|
|
23
24
|
|
|
24
25
|
/**
|
|
25
|
-
* `campaign`, `seat`, `contract` and `
|
|
26
|
+
* `campaign`, `seat`, `contract`, `skills` and `spec` are dispatched before
|
|
26
27
|
* `COMMAND_OPTIONS` is ever consulted (`cli.mjs` routes them by `argv[0]`), so
|
|
27
28
|
* they carry no flags of their own — only the operations their own module
|
|
28
29
|
* declares. Their top-level `## faberun <verb>` section is therefore never
|
|
@@ -35,6 +36,7 @@ const CONTAINER_OPERATIONS = {
|
|
|
35
36
|
seat: SEAT_OPERATIONS,
|
|
36
37
|
contract: CONTRACT_OPERATIONS,
|
|
37
38
|
skills: SKILLS_OPERATIONS,
|
|
39
|
+
spec: SPEC_OPERATIONS,
|
|
38
40
|
};
|
|
39
41
|
|
|
40
42
|
/**
|
package/src/cli/spec.mjs
ADDED
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `spec` argv: validate and scaffold a spec document. Both operations are
|
|
3
|
+
* deterministic — `src/plan/spec.mjs` invokes no model — so this file only
|
|
4
|
+
* owns the wire, the same split every other verb module in this directory
|
|
5
|
+
* uses.
|
|
6
|
+
*/
|
|
7
|
+
import { existsSync, readFileSync, writeFileSync } from "node:fs";
|
|
8
|
+
import { resolve } from "node:path";
|
|
9
|
+
import { parseArgs as parseFlags } from "node:util";
|
|
10
|
+
import { validateSpec } from "../plan/spec.mjs";
|
|
11
|
+
|
|
12
|
+
/** @typedef {import("../plan/spec.mjs").SpecValidation} SpecValidation */
|
|
13
|
+
|
|
14
|
+
/** Flags are scoped to the operation that declares them; all others are rejected. */
|
|
15
|
+
/** @type {Record<string, import("node:util").ParseArgsOptionsConfig>} */
|
|
16
|
+
const OPERATION_OPTIONS = {
|
|
17
|
+
validate: { "strict-traceability": { type: "boolean" }, json: { type: "boolean" } },
|
|
18
|
+
scaffold: { id: { type: "string" } },
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
const SCAFFOLD_TEMPLATE = `---
|
|
22
|
+
id: <id>
|
|
23
|
+
title: "<title>"
|
|
24
|
+
version: 1.0.0
|
|
25
|
+
status: draft
|
|
26
|
+
date: <yyyy-mm-dd>
|
|
27
|
+
owner: <owner>
|
|
28
|
+
target: <org/repo>
|
|
29
|
+
baseline: <git sha>
|
|
30
|
+
---
|
|
31
|
+
|
|
32
|
+
# <title>
|
|
33
|
+
|
|
34
|
+
## Intent
|
|
35
|
+
|
|
36
|
+
<Why this work, what problem, what it unblocks.>
|
|
37
|
+
|
|
38
|
+
## Requirements
|
|
39
|
+
|
|
40
|
+
### R1. <title>
|
|
41
|
+
|
|
42
|
+
- **statement:** <the testable claim>
|
|
43
|
+
- **proof:** \`command: <shell command>\`
|
|
44
|
+
|
|
45
|
+
## Non-goals
|
|
46
|
+
|
|
47
|
+
- <what this spec explicitly excludes>
|
|
48
|
+
`;
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* @param {string[]} args
|
|
52
|
+
* @returns {void}
|
|
53
|
+
*/
|
|
54
|
+
export function specCli(args) {
|
|
55
|
+
const operation = args[0];
|
|
56
|
+
if (!operation || !Object.hasOwn(OPERATION_OPTIONS, operation)) return usage();
|
|
57
|
+
let parsed;
|
|
58
|
+
try {
|
|
59
|
+
parsed = parseFlags({ args: args.slice(1), options: OPERATION_OPTIONS[operation], allowPositionals: true, strict: true });
|
|
60
|
+
} catch (error) {
|
|
61
|
+
process.stderr.write(`${error instanceof Error ? error.message : String(error)}\n`);
|
|
62
|
+
return usage();
|
|
63
|
+
}
|
|
64
|
+
const target = parsed.positionals[0];
|
|
65
|
+
if (!target || parsed.positionals.length > 1) return usage();
|
|
66
|
+
const values = /** @type {{"strict-traceability"?: boolean, json?: boolean, id?: string}} */ (parsed.values);
|
|
67
|
+
if (operation === "validate") {
|
|
68
|
+
validateSpecFile(resolve(target), { strict: values["strict-traceability"] === true, json: values.json === true });
|
|
69
|
+
return;
|
|
70
|
+
}
|
|
71
|
+
try {
|
|
72
|
+
scaffoldSpec(resolve(target), typeof values.id === "string" ? values.id : undefined);
|
|
73
|
+
} catch (error) {
|
|
74
|
+
process.stderr.write(`${error instanceof Error ? error.message : String(error)}\n`);
|
|
75
|
+
process.exitCode = 1;
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* Validate a spec file and print its class, its overall verdict, and one
|
|
81
|
+
* line per finding. Exits `1` when the verdict is not `ok`.
|
|
82
|
+
*
|
|
83
|
+
* @param {string} path
|
|
84
|
+
* @param {{strict: boolean, json: boolean}} options
|
|
85
|
+
* @returns {SpecValidation}
|
|
86
|
+
*/
|
|
87
|
+
export function validateSpecFile(path, { strict, json }) {
|
|
88
|
+
const result = validateSpec(readFileSync(path, "utf8"), { cwd: process.cwd(), strict });
|
|
89
|
+
if (json) {
|
|
90
|
+
process.stdout.write(`${JSON.stringify(result)}\n`);
|
|
91
|
+
} else {
|
|
92
|
+
process.stdout.write(`${result.class} · ${result.class === "legacy" ? "accepted" : result.ok ? "ok" : "not ok"}\n`);
|
|
93
|
+
for (const finding of result.findings) process.stdout.write(`[${finding.severity}] ${finding.rule}: ${finding.message}\n`);
|
|
94
|
+
}
|
|
95
|
+
if (!result.ok) process.exitCode = 1;
|
|
96
|
+
return result;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* Write an empty document in the spec format at `path`. Refuses to overwrite
|
|
101
|
+
* an existing file.
|
|
102
|
+
*
|
|
103
|
+
* @param {string} path
|
|
104
|
+
* @param {string} [id]
|
|
105
|
+
* @returns {void}
|
|
106
|
+
*/
|
|
107
|
+
export function scaffoldSpec(path, id) {
|
|
108
|
+
if (existsSync(path)) throw new Error(`refusing to overwrite an existing file: ${path}`);
|
|
109
|
+
writeFileSync(path, id ? SCAFFOLD_TEMPLATE.replace("<id>", id) : SCAFFOLD_TEMPLATE);
|
|
110
|
+
process.stdout.write(`scaffolded ${path}\n`);
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/** @returns {void} */
|
|
114
|
+
function usage() {
|
|
115
|
+
process.stderr.write("usage: faberun spec <validate|scaffold> <path> [--strict-traceability] [--json] [--id <value>]\n");
|
|
116
|
+
process.exitCode = 2;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
export default OPERATION_OPTIONS;
|
package/src/cli.mjs
CHANGED
|
@@ -31,6 +31,7 @@ import { setupCommand } from "./cli/setup.mjs";
|
|
|
31
31
|
import { skillsCli } from "./cli/skills.mjs";
|
|
32
32
|
import { updateCommand } from "./cli/update.mjs";
|
|
33
33
|
import { contractCli, validateContractFile } from "./cli/contract.mjs";
|
|
34
|
+
import { specCli } from "./cli/spec.mjs";
|
|
34
35
|
import { METRICS_OPTIONS, renderCampaignMetrics } from "./campaign/metrics.mjs";
|
|
35
36
|
import { runContract } from "./engine/scheduler.mjs";
|
|
36
37
|
import { resumeRun } from "./engine/resume.mjs";
|
|
@@ -209,6 +210,7 @@ async function main(argv) {
|
|
|
209
210
|
if (argv[0] === "seat") { seatCli(argv.slice(1)); return; }
|
|
210
211
|
if (argv[0] === "skills") { skillsCli(argv.slice(1)); return; }
|
|
211
212
|
if (argv[0] === "contract") { contractCli(argv.slice(1)); return; }
|
|
213
|
+
if (argv[0] === "spec") { specCli(argv.slice(1)); return; }
|
|
212
214
|
const parsed = parseCli(argv);
|
|
213
215
|
if (!parsed) { usage(); return; }
|
|
214
216
|
const { command, values } = parsed;
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Freezing a plan: the boundary between a session's draft and a contract the
|
|
3
|
+
* engine can execute. `freezePlan` writes the plan's nodes as a validated
|
|
4
|
+
* contract.json, plus a plan.json carrying that contract's digest and the
|
|
5
|
+
* full provenance of how it was produced — the two files travel together so
|
|
6
|
+
* a later launch and this record agree on exactly what was reviewed.
|
|
7
|
+
* `verifyFrozenPlan` is the one check that the pair still agree.
|
|
8
|
+
*
|
|
9
|
+
* Nothing here invokes a model or the engine; it only writes and hashes
|
|
10
|
+
* bytes, so freezing a plan can never be mistaken for starting a run.
|
|
11
|
+
*/
|
|
12
|
+
import { mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
|
13
|
+
import { join } from "node:path";
|
|
14
|
+
import { fileURLToPath } from "node:url";
|
|
15
|
+
import { CONTRACT_VERSION, PROTOCOL_SCHEMA_VERSION, contractDigest, validateContract } from "../contract/index.mjs";
|
|
16
|
+
|
|
17
|
+
/** @typedef {import("../contract/index.mjs").JsonObject} JsonObject */
|
|
18
|
+
|
|
19
|
+
/** @typedef {{runtimeId: string, model: string}} PlanParticipant */
|
|
20
|
+
/** @typedef {{id: string, severity: "minor"|"major"|"critical", nodeId?: string, text: string}} PlanFinding */
|
|
21
|
+
/** @typedef {{targetGitHead: string|null, planner: PlanParticipant, reviewer: PlanParticipant, sizing: unknown, findings: PlanFinding[]}} PlanProvenanceInput */
|
|
22
|
+
/** @typedef {PlanProvenanceInput & {packageVersion: string, schemaVersion: number, contractVersion: string}} PlanProvenance */
|
|
23
|
+
/** @typedef {{formatVersion: number, contractDigest: string, provenance: PlanProvenance}} FrozenPlan */
|
|
24
|
+
/** @typedef {{ok: boolean, digest: string, expectedDigest: string}} FrozenPlanVerdict */
|
|
25
|
+
|
|
26
|
+
const PLAN_FORMAT_VERSION = 1;
|
|
27
|
+
|
|
28
|
+
/** @returns {string} the installed package's own version, read once per call so a freeze always names the toolchain that produced it */
|
|
29
|
+
function packageVersion() {
|
|
30
|
+
const packageJsonPath = fileURLToPath(new URL("../../package.json", import.meta.url));
|
|
31
|
+
return JSON.parse(readFileSync(packageJsonPath, "utf8")).version;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Validate `plan` as a contract and, only once it is valid, write it and a
|
|
36
|
+
* sibling plan.json naming its digest and provenance. `plan` supplies
|
|
37
|
+
* `schemaVersion`/`contractVersion` itself; when it does not, this fills in
|
|
38
|
+
* the runner's own current values.
|
|
39
|
+
*
|
|
40
|
+
* contract.json is written before validation runs, because a packet may
|
|
41
|
+
* declare `readFiles: ["contract.json"]` — an execution packet's own file,
|
|
42
|
+
* self-referenced the same way every fixture in this codebase already does.
|
|
43
|
+
* A validation failure removes that file again, so a caller never observes a
|
|
44
|
+
* contract.json that failed its own check.
|
|
45
|
+
*
|
|
46
|
+
* @param {JsonObject} plan
|
|
47
|
+
* @param {{outDir: string, provenance: PlanProvenanceInput}} options
|
|
48
|
+
* @returns {FrozenPlan}
|
|
49
|
+
*/
|
|
50
|
+
export function freezePlan(plan, { outDir, provenance }) {
|
|
51
|
+
mkdirSync(outDir, { recursive: true });
|
|
52
|
+
const contractPath = join(outDir, "contract.json");
|
|
53
|
+
const raw = /** @type {JsonObject} */ ({
|
|
54
|
+
schemaVersion: PROTOCOL_SCHEMA_VERSION,
|
|
55
|
+
contractVersion: CONTRACT_VERSION,
|
|
56
|
+
...plan,
|
|
57
|
+
});
|
|
58
|
+
writeFileSync(contractPath, `${JSON.stringify(raw, null, 2)}\n`);
|
|
59
|
+
try {
|
|
60
|
+
validateContract(raw, contractPath);
|
|
61
|
+
} catch (error) {
|
|
62
|
+
rmSync(contractPath, { force: true });
|
|
63
|
+
throw error;
|
|
64
|
+
}
|
|
65
|
+
const frozen = /** @type {FrozenPlan} */ ({
|
|
66
|
+
formatVersion: PLAN_FORMAT_VERSION,
|
|
67
|
+
contractDigest: contractDigest(raw),
|
|
68
|
+
provenance: {
|
|
69
|
+
packageVersion: packageVersion(),
|
|
70
|
+
schemaVersion: /** @type {number} */ (raw.schemaVersion),
|
|
71
|
+
contractVersion: /** @type {string} */ (raw.contractVersion),
|
|
72
|
+
targetGitHead: provenance.targetGitHead,
|
|
73
|
+
planner: provenance.planner,
|
|
74
|
+
reviewer: provenance.reviewer,
|
|
75
|
+
sizing: provenance.sizing,
|
|
76
|
+
findings: provenance.findings,
|
|
77
|
+
},
|
|
78
|
+
});
|
|
79
|
+
writeFileSync(join(outDir, "plan.json"), `${JSON.stringify(frozen, null, 2)}\n`);
|
|
80
|
+
return frozen;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* Recompute contract.json's digest from the bytes on disk and compare it
|
|
85
|
+
* with the digest plan.json recorded at freeze time. A single byte changed
|
|
86
|
+
* in either file — the contract re-authored after review, or the plan
|
|
87
|
+
* record itself tampered with — is a mismatch.
|
|
88
|
+
*
|
|
89
|
+
* @param {string} outDir
|
|
90
|
+
* @returns {FrozenPlanVerdict}
|
|
91
|
+
*/
|
|
92
|
+
export function verifyFrozenPlan(outDir) {
|
|
93
|
+
const raw = JSON.parse(readFileSync(join(outDir, "contract.json"), "utf8"));
|
|
94
|
+
const plan = JSON.parse(readFileSync(join(outDir, "plan.json"), "utf8"));
|
|
95
|
+
const digest = contractDigest(raw);
|
|
96
|
+
return { ok: digest === plan.contractDigest, digest, expectedDigest: plan.contractDigest };
|
|
97
|
+
}
|
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Repo facts: a deterministic, bounded inventory of the target repository —
|
|
3
|
+
* tracked paths, declared scripts, timed verification candidates, and which
|
|
4
|
+
* test file covers which source module — collected without invoking a model.
|
|
5
|
+
* A planning stage's draft is authored against exactly this JSON instead of
|
|
6
|
+
* the session reading the repository by hand.
|
|
7
|
+
*
|
|
8
|
+
* Sorting and the absence of any clock in the output itself (only inside an
|
|
9
|
+
* injected measurer's own numbers) is what makes two calls at the same HEAD
|
|
10
|
+
* byte-identical.
|
|
11
|
+
*/
|
|
12
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
13
|
+
import { join } from "node:path";
|
|
14
|
+
import { timeVerificationCommands } from "../host/preflight.mjs";
|
|
15
|
+
import { boundedGitSync, gitHead } from "../repo/worktree.mjs";
|
|
16
|
+
|
|
17
|
+
/** @typedef {{argv: string[], measuredMs: number, eligible: boolean}} VerificationCandidate */
|
|
18
|
+
/** @typedef {{path: string, covers: string|null}} TestFileEntry */
|
|
19
|
+
/** @typedef {{formatVersion: number, gitHead: string|null, paths: string[], truncated: boolean, scripts: Record<string, string>, verificationCandidates: VerificationCandidate[], testFiles: TestFileEntry[]}} RepoFacts */
|
|
20
|
+
/** @typedef {{now?: () => number, run?: typeof import("node:child_process").spawnSync}} MeasureProbes */
|
|
21
|
+
|
|
22
|
+
const FORMAT_VERSION = 1;
|
|
23
|
+
const DEFAULT_MAX_PATHS = 2000;
|
|
24
|
+
const ELIGIBLE_MS_CEILING = 600_000;
|
|
25
|
+
const CANDIDATE_TIMEOUT_SEC = ELIGIBLE_MS_CEILING / 1_000;
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Every path git tracks at HEAD, sorted. The bounded spawn is the same
|
|
29
|
+
* pattern `src/repo/source-identity.mjs` uses for its own git reads: a
|
|
30
|
+
* `boundedGitSync` call, thrown on a non-zero exit or a killed process,
|
|
31
|
+
* never a raw `spawnSync`.
|
|
32
|
+
*
|
|
33
|
+
* @param {string} cwd
|
|
34
|
+
* @returns {string[]}
|
|
35
|
+
*/
|
|
36
|
+
function listTrackedPaths(cwd) {
|
|
37
|
+
const result = boundedGitSync(["-C", cwd, "ls-files"], { encoding: "utf8", stdio: ["ignore", "pipe", "ignore"] });
|
|
38
|
+
if (result.error || result.status !== 0) throw result.error ?? new Error(`git ls-files exited ${result.status}`);
|
|
39
|
+
return String(result.stdout).split("\n").filter(Boolean).sort();
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** @param {string} cwd @returns {Record<string, string>} */
|
|
43
|
+
function readScripts(cwd) {
|
|
44
|
+
const packagePath = join(cwd, "package.json");
|
|
45
|
+
if (!existsSync(packagePath)) return {};
|
|
46
|
+
const parsed = JSON.parse(readFileSync(packagePath, "utf8"));
|
|
47
|
+
return parsed.scripts && typeof parsed.scripts === "object" ? parsed.scripts : {};
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* The first-level directories under test/ that any tracked path is nested
|
|
52
|
+
* inside. `node --test <dir>` recurses through everything below it, so one
|
|
53
|
+
* candidate per directory is the whole layout, not one per file.
|
|
54
|
+
*
|
|
55
|
+
* @param {string[]} paths
|
|
56
|
+
* @returns {string[]}
|
|
57
|
+
*/
|
|
58
|
+
function testDirectories(paths) {
|
|
59
|
+
/** @type {Set<string>} */
|
|
60
|
+
const directories = new Set();
|
|
61
|
+
for (const path of paths) {
|
|
62
|
+
const match = /^test\/([^/]+)\//u.exec(path);
|
|
63
|
+
if (match) directories.add(`test/${match[1]}`);
|
|
64
|
+
}
|
|
65
|
+
return [...directories].sort();
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* @param {string[]} paths
|
|
70
|
+
* @param {Set<string>} pathSet
|
|
71
|
+
* @returns {TestFileEntry[]}
|
|
72
|
+
*/
|
|
73
|
+
function testFileEntries(paths, pathSet) {
|
|
74
|
+
return paths
|
|
75
|
+
.filter((path) => path.startsWith("test/") && path.endsWith(".test.mjs"))
|
|
76
|
+
.map((path) => {
|
|
77
|
+
const modulePath = `src/${path.slice("test/".length, -".test.mjs".length)}.mjs`;
|
|
78
|
+
return { path, covers: pathSet.has(modulePath) ? modulePath : null };
|
|
79
|
+
});
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* @param {Record<string, string>} scripts
|
|
84
|
+
* @param {string[]} paths
|
|
85
|
+
* @returns {{argv: string[]}[]}
|
|
86
|
+
*/
|
|
87
|
+
function candidateCommands(scripts, paths) {
|
|
88
|
+
const commands = testDirectories(paths).map((directory) => ({ argv: ["node", "--test", directory] }));
|
|
89
|
+
for (const name of ["check", "typecheck"]) {
|
|
90
|
+
if (typeof scripts[name] === "string") commands.push({ argv: ["npm", "run", name] });
|
|
91
|
+
}
|
|
92
|
+
return commands;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* Time every candidate through `timeVerificationCommands`'s own probe —
|
|
97
|
+
* real `spawnSync` and `Date.now` by default, or the caller's fake — instead
|
|
98
|
+
* of re-implementing the spawn, ceiling and ENOENT handling it already owns.
|
|
99
|
+
* That function calls `now()` exactly twice per command, in order (start,
|
|
100
|
+
* then stop); wrapping it to record every mark it produces is how the real
|
|
101
|
+
* elapsed ms is recovered without parsing its human-readable report.
|
|
102
|
+
*
|
|
103
|
+
* @param {string} cwd
|
|
104
|
+
* @param {{argv: string[]}[]} commands
|
|
105
|
+
* @param {MeasureProbes} probes
|
|
106
|
+
* @returns {VerificationCandidate[]}
|
|
107
|
+
*/
|
|
108
|
+
function measureCandidates(cwd, commands, probes) {
|
|
109
|
+
if (commands.length === 0) return [];
|
|
110
|
+
const now = probes.now ?? (() => Date.now());
|
|
111
|
+
/** @type {number[]} */
|
|
112
|
+
const marks = [];
|
|
113
|
+
const contract = /** @type {import("../contract/index.mjs").ValidatedContract} */ (/** @type {any} */ ({
|
|
114
|
+
cwd,
|
|
115
|
+
nodes: commands.map((command, index) => ({
|
|
116
|
+
id: `repo-facts-${index}`,
|
|
117
|
+
taskPacket: { verification: [{ argv: command.argv, timeoutSec: CANDIDATE_TIMEOUT_SEC }] },
|
|
118
|
+
})),
|
|
119
|
+
}));
|
|
120
|
+
timeVerificationCommands(contract, { ...probes, now: () => { const mark = now(); marks.push(mark); return mark; } });
|
|
121
|
+
return commands.map((command, index) => {
|
|
122
|
+
const measuredMs = marks[index * 2 + 1] - marks[index * 2];
|
|
123
|
+
return { argv: command.argv, measuredMs, eligible: measuredMs <= ELIGIBLE_MS_CEILING };
|
|
124
|
+
});
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/**
|
|
128
|
+
* @param {string} cwd
|
|
129
|
+
* @param {{measure?: MeasureProbes, maxPaths?: number}} [options]
|
|
130
|
+
* @returns {RepoFacts}
|
|
131
|
+
*/
|
|
132
|
+
export function collectRepoFacts(cwd, options = {}) {
|
|
133
|
+
const maxPaths = options.maxPaths ?? DEFAULT_MAX_PATHS;
|
|
134
|
+
const allPaths = listTrackedPaths(cwd);
|
|
135
|
+
const pathSet = new Set(allPaths);
|
|
136
|
+
const scripts = readScripts(cwd);
|
|
137
|
+
const commands = candidateCommands(scripts, allPaths);
|
|
138
|
+
const truncated = allPaths.length > maxPaths;
|
|
139
|
+
return {
|
|
140
|
+
formatVersion: FORMAT_VERSION,
|
|
141
|
+
gitHead: gitHead(cwd),
|
|
142
|
+
paths: truncated ? allPaths.slice(0, maxPaths) : allPaths,
|
|
143
|
+
truncated,
|
|
144
|
+
scripts,
|
|
145
|
+
verificationCandidates: measureCandidates(cwd, commands, options.measure ?? {}),
|
|
146
|
+
testFiles: testFileEntries(allPaths, pathSet),
|
|
147
|
+
};
|
|
148
|
+
}
|
|
@@ -0,0 +1,200 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Declarative runtime routing: cross a taskKind/riskTier table with live
|
|
3
|
+
* discovery availability to assign a worker and a judge runtime to each of a
|
|
4
|
+
* plan's draft nodes. Separate from runtime-discovery.mjs because that module
|
|
5
|
+
* resolves a *contract's* already-declared runtimes; a plan's draft node
|
|
6
|
+
* never names one (the constitution reserves model choice to runtimes,
|
|
7
|
+
* runtimeDefaults, or an explicit node override) — this module is what turns
|
|
8
|
+
* a taskKind/riskTier classification into one of those three.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import { cheapest, strongest } from "../engine/runtime-discovery.mjs";
|
|
12
|
+
|
|
13
|
+
/** @typedef {{available: boolean, exhaustedUntil: string|null, [key: string]: unknown}} RoutingAvailability */
|
|
14
|
+
/** @typedef {{vendor: string, tier?: number|string, costRank?: number, fallback?: string, [key: string]: unknown}} RoutingRuntime */
|
|
15
|
+
/** @typedef {{id: string, taskKind?: string, riskTier?: string}} RoutingNode */
|
|
16
|
+
/** @typedef {{taskKind?: string, riskTier?: string}} RoutingWhen */
|
|
17
|
+
/** @typedef {{name?: string, when: RoutingWhen, prefer: string[], role: "worker"|"judge"}} RoutingRule */
|
|
18
|
+
/** @typedef {{worker?: string, judge?: string}} RoutingRoleMap */
|
|
19
|
+
/** @typedef {{table?: RoutingRule[], runtimes: Record<string, RoutingRuntime>, availability?: Record<string, RoutingAvailability>, runtimeDefaults?: RoutingRoleMap, overrides?: Record<string, RoutingRoleMap>}} RoutingConfig */
|
|
20
|
+
/** @typedef {{worker: string|null, judge: string|null, rule: {worker: string, judge: string}}} RoutingAssignment */
|
|
21
|
+
/** @typedef {{nodeId: string, role: "worker"|"judge", rule: string}} RoutingUnmet */
|
|
22
|
+
/** @typedef {{assignments: Record<string, RoutingAssignment>, unmet: RoutingUnmet[]}} RoutingResult */
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* @param {RoutingNode[]} nodes
|
|
26
|
+
* @param {RoutingConfig} config
|
|
27
|
+
* @param {{partial?: boolean}} [options]
|
|
28
|
+
* @returns {RoutingResult}
|
|
29
|
+
*/
|
|
30
|
+
export function resolveRuntimes(nodes, config, options = {}) {
|
|
31
|
+
const table = config.table ?? [];
|
|
32
|
+
const runtimes = config.runtimes ?? {};
|
|
33
|
+
const availability = config.availability ?? {};
|
|
34
|
+
const runtimeDefaults = config.runtimeDefaults ?? {};
|
|
35
|
+
const overrides = config.overrides ?? {};
|
|
36
|
+
|
|
37
|
+
/** @type {Record<string, RoutingAssignment>} */
|
|
38
|
+
const assignments = {};
|
|
39
|
+
/** @type {RoutingUnmet[]} */
|
|
40
|
+
const unmet = [];
|
|
41
|
+
|
|
42
|
+
for (const node of nodes) {
|
|
43
|
+
const override = overrides[node.id] ?? {};
|
|
44
|
+
const worker = resolveRole(node, "worker", { runtimes, availability, runtimeDefaults, table, override, forbiddenVendors: EMPTY_VENDORS });
|
|
45
|
+
if (worker.runtimeId === null) unmet.push({ nodeId: node.id, role: "worker", rule: worker.rule });
|
|
46
|
+
// The judge's forbidden vendors follow the worker runtime that was
|
|
47
|
+
// actually chosen, never the row that named it — a worker unmet leaves
|
|
48
|
+
// nothing to conflict with, so the judge resolves without restriction.
|
|
49
|
+
const forbiddenVendors = worker.runtimeId ? forbiddenJudgeVendors(worker.runtimeId, runtimes) : EMPTY_VENDORS;
|
|
50
|
+
const judge = resolveRole(node, "judge", { runtimes, availability, runtimeDefaults, table, override, forbiddenVendors });
|
|
51
|
+
if (judge.runtimeId === null) unmet.push({ nodeId: node.id, role: "judge", rule: judge.rule });
|
|
52
|
+
assignments[node.id] = { worker: worker.runtimeId, judge: judge.runtimeId, rule: { worker: worker.rule, judge: judge.rule } };
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
if (unmet.length && options.partial !== true) {
|
|
56
|
+
const detail = unmet.map(({ nodeId, role, rule }) => `${nodeId}.${role} (rule: ${rule})`).join("; ");
|
|
57
|
+
throw new Error(`runtime_routing_unmet: ${detail}`);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
return { assignments, unmet };
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** @type {ReadonlySet<string>} */
|
|
64
|
+
const EMPTY_VENDORS = Object.freeze(new Set());
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Precedence for one role on one node: an explicit node override, then the
|
|
68
|
+
* operator's runtimeDefaults, then the first table row whose `when` matches
|
|
69
|
+
* this node's classification, then plain discovery. A row or default that
|
|
70
|
+
* names an unavailable or vendor-forbidden runtime is unmet by that rule —
|
|
71
|
+
* it does not fall through to a lower-precedence source, since falling
|
|
72
|
+
* through would silently discard an explicit declaration; only the
|
|
73
|
+
* candidates *within* a row's `prefer` list, and within discovery, are
|
|
74
|
+
* skipped for exhaustion or vendor conflict.
|
|
75
|
+
*
|
|
76
|
+
* @param {RoutingNode} node
|
|
77
|
+
* @param {"worker"|"judge"} role
|
|
78
|
+
* @param {{runtimes: Record<string, RoutingRuntime>, availability: Record<string, RoutingAvailability>, runtimeDefaults: RoutingRoleMap, table: RoutingRule[], override: RoutingRoleMap, forbiddenVendors: ReadonlySet<string>}} context
|
|
79
|
+
* @returns {{runtimeId: string|null, rule: string}}
|
|
80
|
+
*/
|
|
81
|
+
function resolveRole(node, role, context) {
|
|
82
|
+
const { runtimes, availability, runtimeDefaults, table, override, forbiddenVendors } = context;
|
|
83
|
+
|
|
84
|
+
if (override[role] !== undefined) {
|
|
85
|
+
const id = override[role];
|
|
86
|
+
return { runtimeId: admits(id, runtimes, availability, forbiddenVendors) ? id : null, rule: "override" };
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
if (runtimeDefaults[role] !== undefined) {
|
|
90
|
+
const id = runtimeDefaults[role];
|
|
91
|
+
return { runtimeId: admits(id, runtimes, availability, forbiddenVendors) ? id : null, rule: "runtimeDefaults" };
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
const row = table.find((candidate) => candidate.role === role
|
|
95
|
+
&& (candidate.when.taskKind === undefined || candidate.when.taskKind === node.taskKind)
|
|
96
|
+
&& (candidate.when.riskTier === undefined || candidate.when.riskTier === node.riskTier));
|
|
97
|
+
if (row) {
|
|
98
|
+
const rule = ruleLabel(row);
|
|
99
|
+
const id = row.prefer.find((candidate) => admits(candidate, runtimes, availability, forbiddenVendors)) ?? null;
|
|
100
|
+
return { runtimeId: id, rule };
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
const discovered = role === "worker"
|
|
104
|
+
? cheapestAvailable(runtimes, availability, forbiddenVendors)
|
|
105
|
+
: strongestAvailable(runtimes, availability, forbiddenVendors);
|
|
106
|
+
return { runtimeId: discovered, rule: "discovery" };
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* @param {string} id
|
|
111
|
+
* @param {Record<string, RoutingRuntime>} runtimes
|
|
112
|
+
* @param {Record<string, RoutingAvailability>} availability
|
|
113
|
+
* @param {ReadonlySet<string>} forbiddenVendors
|
|
114
|
+
* @returns {boolean}
|
|
115
|
+
*/
|
|
116
|
+
function admits(id, runtimes, availability, forbiddenVendors) {
|
|
117
|
+
const runtime = runtimes[id];
|
|
118
|
+
if (!runtime) return false;
|
|
119
|
+
if (forbiddenVendors.has(runtime.vendor)) return false;
|
|
120
|
+
return isAvailable(availability[id]);
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/** @param {RoutingAvailability|undefined} entry @returns {boolean} */
|
|
124
|
+
function isAvailable(entry) {
|
|
125
|
+
if (!entry) return false;
|
|
126
|
+
if (entry.available === true) return !entry.exhaustedUntil || Date.parse(entry.exhaustedUntil) <= Date.now();
|
|
127
|
+
return Boolean(entry.exhaustedUntil && Date.parse(entry.exhaustedUntil) <= Date.now());
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/**
|
|
131
|
+
* Every vendor a judge may not carry: the worker's own vendor, plus the
|
|
132
|
+
* vendor of each runtime reachable through the worker's declared `fallback`
|
|
133
|
+
* chain — the same independence the contract validator enforces statically
|
|
134
|
+
* once a worker is actually chosen dynamically here.
|
|
135
|
+
*
|
|
136
|
+
* @param {string} workerId
|
|
137
|
+
* @param {Record<string, RoutingRuntime>} runtimes
|
|
138
|
+
* @returns {Set<string>}
|
|
139
|
+
*/
|
|
140
|
+
function forbiddenJudgeVendors(workerId, runtimes) {
|
|
141
|
+
const vendors = new Set();
|
|
142
|
+
const seen = new Set();
|
|
143
|
+
/** @type {string|undefined} */
|
|
144
|
+
let id = workerId;
|
|
145
|
+
while (id !== undefined && runtimes[id] && !seen.has(id)) {
|
|
146
|
+
seen.add(id);
|
|
147
|
+
vendors.add(runtimes[id].vendor);
|
|
148
|
+
id = runtimes[id].fallback;
|
|
149
|
+
}
|
|
150
|
+
return vendors;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/** @param {RoutingRule} row @returns {string} */
|
|
154
|
+
function ruleLabel(row) {
|
|
155
|
+
if (row.name) return row.name;
|
|
156
|
+
return `table:${row.role}:${row.when.taskKind ?? "*"}:${row.when.riskTier ?? "*"}`;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/**
|
|
160
|
+
* @param {Record<string, RoutingRuntime>} runtimes
|
|
161
|
+
* @param {Record<string, RoutingAvailability>} availability
|
|
162
|
+
* @param {ReadonlySet<string>} forbiddenVendors
|
|
163
|
+
* @returns {{id: string, runtime: RoutingRuntime, order: number}[]}
|
|
164
|
+
*/
|
|
165
|
+
function candidateEntries(runtimes, availability, forbiddenVendors) {
|
|
166
|
+
return Object.entries(runtimes)
|
|
167
|
+
.map(([id, runtime], order) => ({ id, runtime, order }))
|
|
168
|
+
.filter(({ id, runtime }) => !forbiddenVendors.has(runtime.vendor) && isAvailable(availability[id]));
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/**
|
|
172
|
+
* The plain discovery default for a worker: `runtime-discovery.mjs`'s own
|
|
173
|
+
* cheapest-first ranking, over candidates already filtered to what's
|
|
174
|
+
* available and vendor-permitted here. The ranking lives there, not here, so
|
|
175
|
+
* a contract's default and a plan's routed default never drift apart.
|
|
176
|
+
*
|
|
177
|
+
* @param {Record<string, RoutingRuntime>} runtimes
|
|
178
|
+
* @param {Record<string, RoutingAvailability>} availability
|
|
179
|
+
* @param {ReadonlySet<string>} forbiddenVendors
|
|
180
|
+
* @returns {string|null}
|
|
181
|
+
*/
|
|
182
|
+
function cheapestAvailable(runtimes, availability, forbiddenVendors) {
|
|
183
|
+
return cheapest(candidateEntries(runtimes, availability, forbiddenVendors))?.id ?? null;
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
/**
|
|
187
|
+
* The plain discovery default for a judge: `runtime-discovery.mjs`'s own
|
|
188
|
+
* strongest-first ranking, over candidates already filtered to exclude the
|
|
189
|
+
* worker's vendor and fallback-chain vendors — `strongest`'s own single-vendor
|
|
190
|
+
* exclusion is passed the empty string, no runtime's actual vendor label, so
|
|
191
|
+
* it is a no-op on top of the filtering already done here.
|
|
192
|
+
*
|
|
193
|
+
* @param {Record<string, RoutingRuntime>} runtimes
|
|
194
|
+
* @param {Record<string, RoutingAvailability>} availability
|
|
195
|
+
* @param {ReadonlySet<string>} forbiddenVendors
|
|
196
|
+
* @returns {string|null}
|
|
197
|
+
*/
|
|
198
|
+
function strongestAvailable(runtimes, availability, forbiddenVendors) {
|
|
199
|
+
return strongest(candidateEntries(runtimes, availability, forbiddenVendors), "")?.id ?? null;
|
|
200
|
+
}
|
|
Binary file
|
|
@@ -0,0 +1,320 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The spec format (skills/faberun/references/spec-format.md): parsing and
|
|
3
|
+
* deterministic validation of the document a spec author hands the planner.
|
|
4
|
+
* Separate from `contract/` because a spec is pre-planning input, never an
|
|
5
|
+
* authored contract, and from `engine/` because nothing here dispatches,
|
|
6
|
+
* schedules, or reaches a provider — this module invokes no model.
|
|
7
|
+
*/
|
|
8
|
+
import { git } from "../repo/worktree.mjs";
|
|
9
|
+
|
|
10
|
+
/** @typedef {"command"|"path"|"judgment"} ProofKind */
|
|
11
|
+
/** @typedef {{kind: ProofKind, ref?: string}} SpecProof */
|
|
12
|
+
/** @typedef {{id: string|null, title: string, statement: string|null, proof: SpecProof|null, constraints: string|null, line: number}} SpecRequirement */
|
|
13
|
+
/** @typedef {Record<string, string>} SpecFrontMatter */
|
|
14
|
+
/** @typedef {{heading: string, body: string, line: number}} SpecSection */
|
|
15
|
+
/** @typedef {{frontMatter: SpecFrontMatter|null, sections: Map<string, SpecSection>, requirements: SpecRequirement[]}} ParsedSpec */
|
|
16
|
+
/** @typedef {{rule: string, severity: "advisory"|"blocking", message: string, line: number}} SpecFinding */
|
|
17
|
+
/** @typedef {{class: "structured"|"legacy", ok: boolean, findings: SpecFinding[]}} SpecValidation */
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Section headings the format recognizes, in the language the reference
|
|
21
|
+
* proposal actually writes them (skills/faberun/references/spec-format.md):
|
|
22
|
+
* the section's role is what a rule checks, never the language of the
|
|
23
|
+
* heading text.
|
|
24
|
+
*/
|
|
25
|
+
const SECTION_ALIASES = new Map([
|
|
26
|
+
["intenção", "intent"],
|
|
27
|
+
["intencao", "intent"],
|
|
28
|
+
["requisitos", "requirements"],
|
|
29
|
+
["não-objetivos", "non-goals"],
|
|
30
|
+
["nao-objetivos", "non-goals"],
|
|
31
|
+
["restrições", "constraints"],
|
|
32
|
+
["restricoes", "constraints"],
|
|
33
|
+
["critério de sucesso", "success criteria"],
|
|
34
|
+
["criterio de sucesso", "success criteria"],
|
|
35
|
+
["riscos", "risks"],
|
|
36
|
+
]);
|
|
37
|
+
|
|
38
|
+
/** @param {string} raw @returns {string} */
|
|
39
|
+
function normalizeHeading(raw) {
|
|
40
|
+
const key = raw.trim().toLowerCase();
|
|
41
|
+
return SECTION_ALIASES.get(key) ?? key;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* @param {string[]} lines
|
|
46
|
+
* @returns {{data: SpecFrontMatter, end: number}|null}
|
|
47
|
+
*/
|
|
48
|
+
function extractFrontMatter(lines) {
|
|
49
|
+
if (lines[0]?.trim() !== "---") return null;
|
|
50
|
+
let end = -1;
|
|
51
|
+
for (let i = 1; i < lines.length; i += 1) {
|
|
52
|
+
if (lines[i].trim() === "---") { end = i; break; }
|
|
53
|
+
}
|
|
54
|
+
if (end === -1) return null;
|
|
55
|
+
/** @type {SpecFrontMatter} */
|
|
56
|
+
const data = {};
|
|
57
|
+
for (let i = 1; i < end; i += 1) {
|
|
58
|
+
const match = /^([A-Za-z_][A-Za-z0-9_]*):\s*(.*)$/u.exec(lines[i]);
|
|
59
|
+
if (!match) continue;
|
|
60
|
+
data[match[1]] = unquote(match[2].trim());
|
|
61
|
+
}
|
|
62
|
+
return { data, end };
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** @param {string} value @returns {string} */
|
|
66
|
+
function unquote(value) {
|
|
67
|
+
return value.length >= 2 && value.startsWith("\"") && value.endsWith("\"") ? value.slice(1, -1) : value;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Every level-2 (`## `) section from `startIndex` to the end of the document.
|
|
72
|
+
* A level-3 (`### `) heading, which a requirement block owns, is left inside
|
|
73
|
+
* its parent section's body.
|
|
74
|
+
*
|
|
75
|
+
* @param {string[]} lines
|
|
76
|
+
* @param {number} startIndex
|
|
77
|
+
* @returns {Map<string, SpecSection>}
|
|
78
|
+
*/
|
|
79
|
+
function extractSections(lines, startIndex) {
|
|
80
|
+
/** @type {Map<string, SpecSection>} */
|
|
81
|
+
const sections = new Map();
|
|
82
|
+
let i = startIndex;
|
|
83
|
+
while (i < lines.length) {
|
|
84
|
+
const match = /^##\s+(.+?)\s*$/u.exec(lines[i]);
|
|
85
|
+
if (!match) { i += 1; continue; }
|
|
86
|
+
const heading = match[1];
|
|
87
|
+
const bodyStart = i + 1;
|
|
88
|
+
let end = bodyStart;
|
|
89
|
+
while (end < lines.length && !/^##\s+/u.test(lines[end])) end += 1;
|
|
90
|
+
sections.set(normalizeHeading(heading), { heading, body: lines.slice(bodyStart, end).join("\n"), line: bodyStart + 1 });
|
|
91
|
+
i = end;
|
|
92
|
+
}
|
|
93
|
+
return sections;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* A `- **key:** value` bullet, and any following non-blank, non-bullet line as
|
|
98
|
+
* its wrapped continuation.
|
|
99
|
+
*
|
|
100
|
+
* @param {string[]} lines
|
|
101
|
+
* @returns {Map<string, string>}
|
|
102
|
+
*/
|
|
103
|
+
function parseBullets(lines) {
|
|
104
|
+
/** @type {Map<string, string>} */
|
|
105
|
+
const bullets = new Map();
|
|
106
|
+
let currentKey = null;
|
|
107
|
+
for (const line of lines) {
|
|
108
|
+
const match = /^-\s+\*\*([a-zA-Z-]+):\*\*\s?(.*)$/u.exec(line);
|
|
109
|
+
if (match) {
|
|
110
|
+
currentKey = match[1].toLowerCase();
|
|
111
|
+
bullets.set(currentKey, match[2].trim());
|
|
112
|
+
continue;
|
|
113
|
+
}
|
|
114
|
+
const trimmed = line.trim();
|
|
115
|
+
if (!trimmed) { currentKey = null; continue; }
|
|
116
|
+
if (currentKey && !trimmed.startsWith("-")) bullets.set(currentKey, `${bullets.get(currentKey)} ${trimmed}`.trim());
|
|
117
|
+
}
|
|
118
|
+
return bullets;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* `command: <shell command>`, `path: <repo-relative path>`, or
|
|
123
|
+
* `judgment: true`, optionally wrapped in one pair of backticks (the shape
|
|
124
|
+
* the reference proposal writes).
|
|
125
|
+
*
|
|
126
|
+
* @param {string|undefined} raw
|
|
127
|
+
* @returns {SpecProof|null}
|
|
128
|
+
*/
|
|
129
|
+
function parseProof(raw) {
|
|
130
|
+
if (!raw) return null;
|
|
131
|
+
const unwrapped = /^`(.*)`$/u.exec(raw.trim());
|
|
132
|
+
const value = unwrapped ? unwrapped[1] : raw.trim();
|
|
133
|
+
const match = /^(command|path|judgment):\s*(.*)$/u.exec(value);
|
|
134
|
+
if (!match) return null;
|
|
135
|
+
const kind = /** @type {ProofKind} */ (match[1]);
|
|
136
|
+
return kind === "judgment" ? { kind } : { kind, ref: match[2].trim() };
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/**
|
|
140
|
+
* @param {SpecSection|undefined} section
|
|
141
|
+
* @returns {SpecRequirement[]}
|
|
142
|
+
*/
|
|
143
|
+
function extractRequirements(section) {
|
|
144
|
+
if (!section) return [];
|
|
145
|
+
const lines = section.body.split("\n");
|
|
146
|
+
/** @type {SpecRequirement[]} */
|
|
147
|
+
const requirements = [];
|
|
148
|
+
let i = 0;
|
|
149
|
+
while (i < lines.length) {
|
|
150
|
+
const match = /^###\s+(.+?)\s*$/u.exec(lines[i]);
|
|
151
|
+
if (!match) { i += 1; continue; }
|
|
152
|
+
const heading = match[1];
|
|
153
|
+
const blockLine = section.line + i + 1;
|
|
154
|
+
let end = i + 1;
|
|
155
|
+
while (end < lines.length && !/^###\s+/u.test(lines[end])) end += 1;
|
|
156
|
+
const bullets = parseBullets(lines.slice(i + 1, end));
|
|
157
|
+
const idMatch = /^(R\d+)\.\s*(.*)$/u.exec(heading);
|
|
158
|
+
requirements.push({
|
|
159
|
+
id: idMatch ? idMatch[1] : null,
|
|
160
|
+
title: idMatch ? idMatch[2].trim() : heading,
|
|
161
|
+
statement: bullets.get("statement") ?? null,
|
|
162
|
+
proof: parseProof(bullets.get("proof")),
|
|
163
|
+
constraints: bullets.get("constraints") ?? null,
|
|
164
|
+
line: blockLine,
|
|
165
|
+
});
|
|
166
|
+
i = end;
|
|
167
|
+
}
|
|
168
|
+
return requirements;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/**
|
|
172
|
+
* Parse a spec document into its front matter, sections and requirements.
|
|
173
|
+
* Pure text processing: no file I/O, no git, no model.
|
|
174
|
+
*
|
|
175
|
+
* @param {string} text
|
|
176
|
+
* @returns {ParsedSpec}
|
|
177
|
+
*/
|
|
178
|
+
export function parseSpec(text) {
|
|
179
|
+
const lines = text.split("\n");
|
|
180
|
+
const frontMatter = extractFrontMatter(lines);
|
|
181
|
+
const sections = extractSections(lines, frontMatter ? frontMatter.end + 1 : 0);
|
|
182
|
+
const requirements = extractRequirements(sections.get("requirements"));
|
|
183
|
+
return { frontMatter: frontMatter?.data ?? null, sections, requirements };
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
/**
|
|
187
|
+
* @param {string} body
|
|
188
|
+
* @returns {string[]}
|
|
189
|
+
*/
|
|
190
|
+
function tableRows(body) {
|
|
191
|
+
return body.split("\n").filter((line) => line.trim().startsWith("|"));
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
/** @param {string} row @returns {string[]} */
|
|
195
|
+
function splitRow(row) {
|
|
196
|
+
return row.trim().replace(/^\|/u, "").replace(/\|$/u, "").split("|").map((cell) => cell.trim());
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
/**
|
|
200
|
+
* A Success criteria table with no Baseline column at all, or a data row
|
|
201
|
+
* whose Baseline cell is empty or a bare dash.
|
|
202
|
+
*
|
|
203
|
+
* @param {SpecSection} section
|
|
204
|
+
* @returns {SpecFinding[]}
|
|
205
|
+
*/
|
|
206
|
+
function baselineColumnFindings(section) {
|
|
207
|
+
const rows = tableRows(section.body);
|
|
208
|
+
if (rows.length < 2) return [];
|
|
209
|
+
const header = splitRow(rows[0]);
|
|
210
|
+
const baselineIndex = header.findIndex((cell) => /baseline/iu.test(cell));
|
|
211
|
+
if (baselineIndex === -1) {
|
|
212
|
+
return [{ rule: "success-criteria-missing-baseline", severity: "advisory", message: "Success criteria table has no Baseline column", line: section.line }];
|
|
213
|
+
}
|
|
214
|
+
/** @type {SpecFinding[]} */
|
|
215
|
+
const findings = [];
|
|
216
|
+
for (let i = 2; i < rows.length; i += 1) {
|
|
217
|
+
const value = splitRow(rows[i])[baselineIndex]?.trim();
|
|
218
|
+
if (!value || value === "-" || value === "—") {
|
|
219
|
+
findings.push({ rule: "success-criteria-missing-baseline", severity: "advisory", message: `Success criteria row ${i - 1} has no Baseline value`, line: section.line + i });
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
return findings;
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
/**
|
|
226
|
+
* `git@host:owner/repo.git` and `https://host/owner/repo.git` both reduce to
|
|
227
|
+
* the same lowercase `owner/repo` suffix for comparison.
|
|
228
|
+
*
|
|
229
|
+
* @param {string} url
|
|
230
|
+
* @returns {string}
|
|
231
|
+
*/
|
|
232
|
+
function normalizeRemoteUrl(url) {
|
|
233
|
+
return url.trim().replace(/\.git$/u, "").replace(/^git@([^:]+):/u, "https://$1/").toLowerCase();
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
/**
|
|
237
|
+
* Whether `ref` names a commit that actually exists in `cwd`. `git rev-parse
|
|
238
|
+
* <ref>` alone is not enough: given a 40-hex string it echoes the string back
|
|
239
|
+
* unverified even when no such object exists, so this peels it as `^{commit}`
|
|
240
|
+
* instead, which fails for an absent or non-commit object.
|
|
241
|
+
*
|
|
242
|
+
* @param {string} cwd
|
|
243
|
+
* @param {string} ref
|
|
244
|
+
* @returns {boolean}
|
|
245
|
+
*/
|
|
246
|
+
function resolvesToCommit(cwd, ref) {
|
|
247
|
+
try {
|
|
248
|
+
git(cwd, ["rev-parse", "--verify", "--quiet", `${ref}^{commit}`]);
|
|
249
|
+
return true;
|
|
250
|
+
} catch {
|
|
251
|
+
return false;
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
/**
|
|
256
|
+
* Whether `target` (an `owner/repo` slug) names the repository this `cwd`'s
|
|
257
|
+
* `origin` remote points at. Checked against the remote name only, never a
|
|
258
|
+
* network call.
|
|
259
|
+
*
|
|
260
|
+
* @param {string} cwd
|
|
261
|
+
* @param {string} target
|
|
262
|
+
* @returns {boolean}
|
|
263
|
+
*/
|
|
264
|
+
function targetMatchesOrigin(cwd, target) {
|
|
265
|
+
let url;
|
|
266
|
+
try {
|
|
267
|
+
url = git(cwd, ["remote", "get-url", "origin"]);
|
|
268
|
+
} catch {
|
|
269
|
+
return false;
|
|
270
|
+
}
|
|
271
|
+
return normalizeRemoteUrl(url).endsWith(`/${target.toLowerCase()}`);
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
/**
|
|
275
|
+
* Validate a spec's traceability rules: no model call, ever
|
|
276
|
+
* (skills/faberun/references/spec-format.md). A document without front matter
|
|
277
|
+
* is classified `legacy` and accepted outright, exempt from every rule below.
|
|
278
|
+
*
|
|
279
|
+
* Advisory by default — every violation is recorded and `ok` stays `true` —
|
|
280
|
+
* and blocking under `strict`, where any violation makes `ok` `false`.
|
|
281
|
+
*
|
|
282
|
+
* @param {string} text
|
|
283
|
+
* @param {{cwd?: string, strict?: boolean}} [options]
|
|
284
|
+
* @returns {SpecValidation}
|
|
285
|
+
*/
|
|
286
|
+
export function validateSpec(text, options = {}) {
|
|
287
|
+
const parsed = parseSpec(text);
|
|
288
|
+
if (!parsed.frontMatter) {
|
|
289
|
+
return {
|
|
290
|
+
class: "legacy",
|
|
291
|
+
ok: true,
|
|
292
|
+
findings: [{ rule: "legacy-document", severity: "advisory", message: "no front matter: accepted as a legacy-class document, not scored against the structured rules", line: 1 }],
|
|
293
|
+
};
|
|
294
|
+
}
|
|
295
|
+
const cwd = options.cwd ?? process.cwd();
|
|
296
|
+
const strict = options.strict === true;
|
|
297
|
+
/** @type {SpecFinding[]} */
|
|
298
|
+
const findings = [];
|
|
299
|
+
if (!parsed.sections.has("non-goals")) {
|
|
300
|
+
findings.push({ rule: "missing-non-goals", severity: "advisory", message: "spec has no Non-goals section", line: 1 });
|
|
301
|
+
}
|
|
302
|
+
for (const requirement of parsed.requirements) {
|
|
303
|
+
if (!requirement.id) {
|
|
304
|
+
findings.push({ rule: "requirement-missing-id", severity: "advisory", message: `requirement "${requirement.title}" has no stable R<n> id`, line: requirement.line });
|
|
305
|
+
}
|
|
306
|
+
if (!requirement.proof) {
|
|
307
|
+
findings.push({ rule: "requirement-missing-proof", severity: "advisory", message: `requirement ${requirement.id ?? requirement.title} has no proof`, line: requirement.line });
|
|
308
|
+
}
|
|
309
|
+
}
|
|
310
|
+
const successCriteria = parsed.sections.get("success criteria");
|
|
311
|
+
if (successCriteria) findings.push(...baselineColumnFindings(successCriteria));
|
|
312
|
+
if (typeof parsed.frontMatter.baseline === "string" && !resolvesToCommit(cwd, parsed.frontMatter.baseline)) {
|
|
313
|
+
findings.push({ rule: "baseline-unresolved", severity: "advisory", message: `baseline "${parsed.frontMatter.baseline}" does not resolve to a commit`, line: 1 });
|
|
314
|
+
}
|
|
315
|
+
if (typeof parsed.frontMatter.target === "string" && !targetMatchesOrigin(cwd, parsed.frontMatter.target)) {
|
|
316
|
+
findings.push({ rule: "target-unresolved", severity: "advisory", message: `target "${parsed.frontMatter.target}" does not match the origin remote`, line: 1 });
|
|
317
|
+
}
|
|
318
|
+
const graded = findings.map((finding) => (strict ? { ...finding, severity: /** @type {const} */ ("blocking") } : finding));
|
|
319
|
+
return { class: "structured", ok: !graded.some((finding) => finding.severity === "blocking"), findings: graded };
|
|
320
|
+
}
|