faberun 0.7.0 → 0.9.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/campaign/journal.mjs +48 -0
- package/src/cli/brand.mjs +3 -0
- package/src/cli/campaign.mjs +16 -2
- package/src/cli/manual.mjs +3 -1
- package/src/cli/plan.mjs +142 -0
- package/src/cli/spec.mjs +119 -0
- package/src/cli.mjs +17 -0
- package/src/contract/task-packet.mjs +1 -1
- package/src/contract/worker-result.mjs +40 -8
- package/src/engine/lifecycle.mjs +11 -0
- package/src/engine/result-file.mjs +18 -4
- package/src/harnesses/protocol.mjs +91 -11
- package/src/plan/freeze.mjs +101 -0
- package/src/plan/pipeline.mjs +371 -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/src/plan/template.mjs +278 -0
- package/src/seat/allowance.mjs +177 -0
|
@@ -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
|
+
}
|