faberun 0.28.0 → 0.30.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.
@@ -27,11 +27,12 @@ import { writeJsonAtomic } from "../run/store.mjs";
27
27
  import { stableJson } from "../util.mjs";
28
28
  import { assertTimeoutsCoverMeasured, raiseTimeoutsToMeasured } from "./freeze.mjs";
29
29
  import { assertFilteredProofsNameTheirTest, declareDirectoryGuards } from "./proof-scope.mjs";
30
- import { validateFindings, validatePlanOutput } from "./template.mjs";
30
+ import { applyPlanPatch, validateFindings, validatePlanOutput } from "./template.mjs";
31
31
 
32
32
  /** @typedef {import("../contract/index.mjs").JsonObject} JsonObject */
33
33
  /** @typedef {import("../contract/index.mjs").ValidatedContract} ValidatedContract */
34
34
  /** @typedef {import("./template.mjs").PlanOutput} PlanOutput */
35
+ /** @typedef {import("./template.mjs").PlanOutputNode} PlanOutputNode */
35
36
  /** @typedef {import("./template.mjs").PlanFindingOutput} PlanFindingOutput */
36
37
  /** @typedef {import("./template.mjs").PlanningKind} PlanningKind */
37
38
  /** @typedef {import("./contest.mjs").ContestedPipelineResult} ContestedPipelineResult */
@@ -288,21 +289,44 @@ export async function runReviewRounds(options) {
288
289
  // the findings redrafted the plan from scratch, renamed files and dropped
289
290
  // six writes, and its retry fixed one validator error while introducing
290
291
  // another.
291
- const revise = await runStage("revise", {
292
- specPath: relativeSpecPath, repoFactsPath: relativeRepoFactsPath, cataloguePath: relativeCataloguePath, findingsPath: relative(cwd, path), planPath: relativeWorkingPlanPath, packageMode,
293
- });
292
+ //
293
+ // RM-110: a revise returns a patch over the nodes it changes rather than
294
+ // the whole plan, so answering one finding costs the size of that
295
+ // finding's nodes instead of the phase's. Two calls died on the full-plan
296
+ // shape at 65,932 and 66,821 output tokens against the 65,536 ceiling.
297
+ // The patch is only asked for when there is a validated plan to apply it
298
+ // to: a first round whose draft never validated has no node list to patch,
299
+ // and that round's revise returns a whole plan.
300
+ const base = plan;
301
+ const patchMode = base !== null;
302
+ const stageInputs = { specPath: relativeSpecPath, repoFactsPath: relativeRepoFactsPath, cataloguePath: relativeCataloguePath, packageMode, ...(patchMode ? { revisePatch: true } : {}) };
303
+ const revise = await runStage("revise", { ...stageInputs, findingsPath: relative(cwd, path), planPath: relativeWorkingPlanPath });
304
+ /**
305
+ * The plan a revise's output produced, before any validation: the merged
306
+ * patch when the reviser patched a plan, its own `output.plan` when there
307
+ * was no plan to patch. Null only when the merge itself was refused, so
308
+ * the retry falls back to the plan the first attempt read.
309
+ *
310
+ * @param {unknown} onto
311
+ * @param {Record<string, unknown>} output
312
+ * @returns {unknown}
313
+ */
314
+ const revisionFrom = (onto, output) => (patchMode ? applyPlanPatch(/** @type {{nodes: PlanOutputNode[]} & Record<string, unknown>} */ (onto), output.patch) : output.plan);
294
315
  // The whole deterministic check R14 names runs on the revise's output:
295
316
  // the shape validator, and the freeze pre-flight a review round would
296
317
  // otherwise only reach a round later. Measured 2026-09-26 on the 3a gate:
297
318
  // four rounds with no critical from review each contested on a
298
319
  // scope-closure gap the same revise had just opened, because the revise
299
320
  // learned of it only after the next review had spent its round.
321
+ /** @type {{nodes: PlanOutputNode[]} & Record<string, unknown>|null} */
322
+ let revision = null;
300
323
  /** @type {PlanOutput|null} */
301
324
  let structurallyValid = null;
302
325
  /** @type {PlanFindingOutput} */
303
326
  let firstInvalid;
304
327
  try {
305
- structurallyValid = validatePlanOutput(revise.output.plan);
328
+ revision = /** @type {{nodes: PlanOutputNode[]} & Record<string, unknown>} */ (revisionFrom(base, revise.output));
329
+ structurallyValid = validatePlanOutput(revision);
306
330
  const preflightError = freezePreflightError(structurallyValid);
307
331
  if (preflightError === null) return { plan: structurallyValid, runId: revise.contract.id, firstInvalid: null };
308
332
  firstInvalid = invalidPlanFinding(`revise-r${round}-attempt1`, preflightError);
@@ -311,18 +335,18 @@ export async function runReviewRounds(options) {
311
335
  }
312
336
  const retryPath = join(scratchDir, `findings-round-${round}-retry.json`);
313
337
  writeJsonAtomic(retryPath, [...findingsForRevise, firstInvalid]);
314
- // The retry repairs the output the check refused, not the plan before
315
- // it: that output already carries this round's resolutions, and only the
338
+ // The retry repairs the revision the check refused, not the plan before
339
+ // it: that revision already carries this round's resolutions, and only the
316
340
  // check's message is left to answer.
317
341
  const retryPlanPath = join(scratchDir, `plan-round-${round}-rejected.json`);
318
- writeJsonAtomic(retryPlanPath, revise.output.plan ?? null);
319
- const retry = await runStage("revise", {
320
- specPath: relativeSpecPath, repoFactsPath: relativeRepoFactsPath, cataloguePath: relativeCataloguePath, findingsPath: relative(cwd, retryPath), planPath: relative(cwd, retryPlanPath), packageMode,
321
- });
342
+ writeJsonAtomic(retryPlanPath, revision ?? base);
343
+ const retry = await runStage("revise", { ...stageInputs, findingsPath: relative(cwd, retryPath), planPath: relative(cwd, retryPlanPath) });
322
344
  try {
323
345
  // A retry that validates goes to review even if the freeze pre-flight
324
- // still refuses it: that round's own pre-flight raises it again.
325
- return { plan: validatePlanOutput(retry.output.plan), runId: retry.contract.id, firstInvalid };
346
+ // still refuses it: that round's own pre-flight raises it again. Its
347
+ // patch applies to the revision the first attempt produced, which is
348
+ // the plan the retry was handed.
349
+ return { plan: validatePlanOutput(revisionFrom(revision ?? base, retry.output)), runId: retry.contract.id, firstInvalid };
326
350
  } catch (secondError) {
327
351
  // The first attempt still validated: it goes to review rather than
328
352
  // contesting a round over a retry that made things worse.
@@ -11,6 +11,7 @@
11
11
  */
12
12
 
13
13
  import { dirname } from "node:path";
14
+ import { VERIFICATION_LIMITS } from "../contract/verification.mjs";
14
15
 
15
16
  /** @typedef {import("../contract/definition-of-done.mjs").DefinitionOfDoneItem} DefinitionOfDoneItem */
16
17
 
@@ -18,7 +19,7 @@ import { dirname } from "node:path";
18
19
  /** @typedef {{writeFiles?: string[], writeRoots?: string[], verification: SizingVerificationCommand[], [key: string]: unknown}} SizingTaskPacket */
19
20
  /** @typedef {{id: string, dependsOn?: string[], taskKind?: string, riskTier?: string, taskPacket: SizingTaskPacket, definitionOfDone?: DefinitionOfDoneItem[], parallel?: boolean, [key: string]: unknown}} PlanNode */
20
21
  /** @typedef {{nodes: PlanNode[], justification?: string, [key: string]: unknown}} Plan */
21
- /** @typedef {{path: string, covers: string|null}} SizingTestFileEntry */
22
+ /** @typedef {{path: string, covers: string[]}} SizingTestFileEntry */
22
23
  /** @typedef {{testFiles?: SizingTestFileEntry[]}} SizingFacts */
23
24
  /** @typedef {"implementation"|"exploratory"} PackageMode */
24
25
  /** @typedef {{nodeBudgetMs: number, targetedFix?: boolean, facts?: SizingFacts, minWriteFiles?: number, maxMergedWriteFiles?: number, turnCeiling?: number, packageMode?: PackageMode, readVolume?: (path: string) => number|null}} SizingOptions */
@@ -374,6 +375,13 @@ function flagOverTurnCeiling(nodes, ceiling, transformations) {
374
375
  * place instead (a repeat pass never re-flags an already-flagged command, nor
375
376
  * re-splits a command that no longer carries a measured duration).
376
377
  *
378
+ * A covering set larger than the contract's own command budget (RM-109) is
379
+ * flagged, not split: a module almost every test references has that many
380
+ * covering files because the change is genuinely wide, and replacing one
381
+ * command with more than `maxCommands` of them freezes a contract that cannot
382
+ * validate. The flag names the count, and the answer for that shape is the
383
+ * phase-terminal suite (`finalVerification`), which runs the whole tree once.
384
+ *
377
385
  * @param {PlanNode[]} nodes
378
386
  * @param {number} nodeBudgetMs
379
387
  * @param {SizingFacts} facts
@@ -393,8 +401,9 @@ function splitOverBudgetVerification(nodes, nodeBudgetMs, facts, transformations
393
401
  verification.push(command);
394
402
  continue;
395
403
  }
396
- const candidates = dedupe(testFiles.filter((file) => file.covers !== null && writeFiles.includes(file.covers)).map((file) => file.path));
397
- if (candidates.length > 0) {
404
+ const candidates = dedupe(testFiles.filter((file) => coversSomething(file, writeFiles)).map((file) => file.path));
405
+ const overflow = candidates.length > VERIFICATION_LIMITS.maxCommands;
406
+ if (candidates.length > 0 && !overflow) {
398
407
  transformations.push({
399
408
  rule: "over-budget-verification-split",
400
409
  nodes: [node.id],
@@ -406,7 +415,9 @@ function splitOverBudgetVerification(nodes, nodeBudgetMs, facts, transformations
406
415
  transformations.push({
407
416
  rule: "over-budget-verification-flagged",
408
417
  nodes: [node.id],
409
- detail: `${command.argv.join(" ")} measured ${command.measuredMs}ms over the ${nodeBudgetMs}ms budget; no repo-facts test file covers this node's writeFiles`,
418
+ detail: overflow
419
+ ? `${command.argv.join(" ")} measured ${command.measuredMs}ms over the ${nodeBudgetMs}ms budget; ${candidates.length} test files reference this node's writeFiles, above the contract's ${VERIFICATION_LIMITS.maxCommands}-command budget: prove them once on the phase-terminal node instead of naming each here`
420
+ : `${command.argv.join(" ")} measured ${command.measuredMs}ms over the ${nodeBudgetMs}ms budget; no repo-facts test file covers this node's writeFiles`,
410
421
  });
411
422
  changed = true;
412
423
  verification.push({ ...command, flaggedOverBudget: true });
@@ -416,6 +427,11 @@ function splitOverBudgetVerification(nodes, nodeBudgetMs, facts, transformations
416
427
  });
417
428
  }
418
429
 
430
+ /** @param {SizingTestFileEntry} file @param {string[]} writeFiles @returns {boolean} */
431
+ function coversSomething(file, writeFiles) {
432
+ return file.covers.some((module) => writeFiles.includes(module));
433
+ }
434
+
419
435
  /**
420
436
  * A node with no dependsOn, that no other node depends on, and whose
421
437
  * writeFiles shares nothing with any other node's, may run in parallel with
package/src/plan/spec.mjs CHANGED
@@ -6,11 +6,14 @@
6
6
  * schedules, or reaches a provider — this module invokes no model.
7
7
  */
8
8
  import { git } from "../repo/worktree.mjs";
9
+ import { shellWords } from "../util.mjs";
10
+ import { nodeTestShape } from "./proof-scope.mjs";
9
11
  import { proveRequirements } from "./proof-run.mjs";
10
12
 
11
13
  /** @typedef {"command"|"path"|"judgment"} ProofKind */
12
14
  /** @typedef {{kind: ProofKind, ref?: string}} SpecProof */
13
- /** @typedef {{id: string|null, title: string, statement: string|null, proof: SpecProof|null, measure: SpecProof|null, constraints: string|null, line: number}} SpecRequirement */
15
+ /** @typedef {{key: string, line: number}} UnknownBulletKey */
16
+ /** @typedef {{id: string|null, title: string, statement: string|null, proof: SpecProof|null, measure: SpecProof|null, constraints: string|null, unknownBulletKeys: UnknownBulletKey[], line: number}} SpecRequirement */
14
17
  /** @typedef {Record<string, string>} SpecFrontMatter */
15
18
  /** @typedef {{heading: string, body: string, line: number}} SpecSection */
16
19
  /** @typedef {{frontMatter: SpecFrontMatter|null, sections: Map<string, SpecSection>, requirements: SpecRequirement[]}} ParsedSpec */
@@ -98,25 +101,41 @@ function extractSections(lines, startIndex) {
98
101
  * A `- **key:** value` bullet, and any following non-blank, non-bullet line as
99
102
  * its wrapped continuation.
100
103
  *
104
+ * A bullet whose key does not match is *not* a continuation of the one above
105
+ * it: measured 2026-09-27 (AP14 of safe-to-hand-to-a-friend), a
106
+ * `- **esclarecimento (27/09):**` line right after `- **proof:**` was dropped
107
+ * and the lines under it were appended to the proof above, so R8 read as
108
+ * having no proof while the text said otherwise. The dropped key is returned
109
+ * so `validateSpec` can name it instead of letting it vanish.
110
+ *
101
111
  * @param {string[]} lines
102
- * @returns {Map<string, string>}
112
+ * @returns {{bullets: Map<string, string>, unknownKeys: Array<{key: string, offset: number}>}}
103
113
  */
104
114
  function parseBullets(lines) {
105
115
  /** @type {Map<string, string>} */
106
116
  const bullets = new Map();
117
+ /** @type {Array<{key: string, offset: number}>} */
118
+ const unknownKeys = [];
107
119
  let currentKey = null;
108
- for (const line of lines) {
120
+ for (let offset = 0; offset < lines.length; offset += 1) {
121
+ const line = lines[offset];
109
122
  const match = /^-\s+\*\*([a-zA-Z-]+):\*\*\s?(.*)$/u.exec(line);
110
123
  if (match) {
111
124
  currentKey = match[1].toLowerCase();
112
125
  bullets.set(currentKey, match[2].trim());
113
126
  continue;
114
127
  }
128
+ const unrecognized = /^-\s+\*\*(.+?):\*\*\s?/u.exec(line);
129
+ if (unrecognized) {
130
+ unknownKeys.push({ key: unrecognized[1], offset });
131
+ currentKey = null;
132
+ continue;
133
+ }
115
134
  const trimmed = line.trim();
116
135
  if (!trimmed) { currentKey = null; continue; }
117
136
  if (currentKey && !trimmed.startsWith("-")) bullets.set(currentKey, `${bullets.get(currentKey)} ${trimmed}`.trim());
118
137
  }
119
- return bullets;
138
+ return { bullets, unknownKeys };
120
139
  }
121
140
 
122
141
  /**
@@ -137,6 +156,44 @@ function parseProof(raw) {
137
156
  return kind === "judgment" ? { kind } : { kind, ref: match[2].trim() };
138
157
  }
139
158
 
159
+ /**
160
+ * A requirement proof that filters `node:test` by name without naming the file
161
+ * the test lives in, or null. The same shape the frozen contract refuses
162
+ * (`plan/proof-scope.mjs`, AP1 of safe-to-hand-to-a-friend); raised here so a
163
+ * spec author sees it when the spec is written, rather than a plan later.
164
+ * Measured 2026-09-27 (AP8): R1 to R7 of the `safe-to-hand-to-a-friend` spec
165
+ * each wrote `--test-name-pattern="<title>"` with no file, and only the
166
+ * planner's own nodes were refused for it.
167
+ *
168
+ * @param {SpecProof|null} proof
169
+ * @returns {string|null}
170
+ */
171
+ function proofWithoutNamedTestFile(proof) {
172
+ if (proof?.kind !== "command" || typeof proof.ref !== "string") return null;
173
+ const shape = nodeTestShape(shellWords(proof.ref));
174
+ if (!shape || shape.nameFilters.length === 0 || shape.paths.length > 0) return null;
175
+ return proof.ref;
176
+ }
177
+
178
+ /**
179
+ * The one finding `strict` does not promote. Measured 2026-09-28: 105 of the
180
+ * 237 `--test-name-pattern` proofs in the 109 documents under
181
+ * `docs/campaigns/` name no test file, across 20 documents. Every one of them
182
+ * is the record of a run that already happened, and the repository forbids
183
+ * editing that record to satisfy a rule written later, so promoting this to
184
+ * blocking would retroactively refuse twenty specs that were accepted when
185
+ * they were written. The shape is refused where it costs something anyway:
186
+ * `assertFilteredProofsNameTheirTest` (plan/proof-scope.mjs) fails the freeze
187
+ * of any plan that carries it, so a plan cannot be built from a fresh spec
188
+ * with this proof whatever the spec validator says about it.
189
+ *
190
+ * @param {SpecFinding} finding
191
+ * @returns {boolean}
192
+ */
193
+ function promotableUnderStrict(finding) {
194
+ return finding.rule !== "proof-filter-names-no-file";
195
+ }
196
+
140
197
  /**
141
198
  * @param {SpecSection|undefined} section
142
199
  * @returns {SpecRequirement[]}
@@ -154,7 +211,8 @@ function extractRequirements(section) {
154
211
  const blockLine = section.line + i + 1;
155
212
  let end = i + 1;
156
213
  while (end < lines.length && !/^###\s+/u.test(lines[end])) end += 1;
157
- const bullets = parseBullets(lines.slice(i + 1, end));
214
+ const body = lines.slice(i + 1, end);
215
+ const { bullets, unknownKeys } = parseBullets(body);
158
216
  const idMatch = /^(R\d+)\.\s*(.*)$/u.exec(heading);
159
217
  requirements.push({
160
218
  id: idMatch ? idMatch[1] : null,
@@ -163,6 +221,7 @@ function extractRequirements(section) {
163
221
  proof: parseProof(bullets.get("proof")),
164
222
  measure: parseProof(bullets.get("measure")),
165
223
  constraints: bullets.get("constraints") ?? null,
224
+ unknownBulletKeys: unknownKeys.map(({ key, offset }) => ({ key, line: blockLine + offset })),
166
225
  line: blockLine,
167
226
  });
168
227
  i = end;
@@ -318,6 +377,23 @@ export function validateSpec(text, options = {}) {
318
377
  if (!requirement.proof) {
319
378
  findings.push({ rule: "requirement-missing-proof", severity: "advisory", message: `requirement ${requirement.id ?? requirement.title} has no proof`, line: requirement.line });
320
379
  }
380
+ for (const { key, line } of requirement.unknownBulletKeys) {
381
+ findings.push({
382
+ rule: "requirement-unknown-bullet-key",
383
+ severity: "advisory",
384
+ message: `requirement ${requirement.id ?? requirement.title} has a bullet keyed "${key}", which the format does not read: a key is letters and hyphens (statement, proof, measure, constraints), so this bullet and the lines under it are dropped instead of reaching the requirement. Reword the bullet or use one of the four keys.`,
385
+ line,
386
+ });
387
+ }
388
+ const unbacked = proofWithoutNamedTestFile(requirement.proof);
389
+ if (unbacked !== null) {
390
+ findings.push({
391
+ rule: "proof-filter-names-no-file",
392
+ severity: "advisory",
393
+ message: `requirement ${requirement.id ?? requirement.title}'s proof "${unbacked}" filters node:test by name but names no test file: with no path node --test runs every test file in the tree, each file the filter selects nothing in prints a zero plan, and the plan's own freeze refuses the shape. Name the file that holds the test, as in node --test --test-name-pattern="<title>" test/<area>/<file>.test.mjs.`,
394
+ line: requirement.line,
395
+ });
396
+ }
321
397
  }
322
398
  const successCriteria = parsed.sections.get("success criteria");
323
399
  if (successCriteria) findings.push(...baselineColumnFindings(successCriteria));
@@ -340,6 +416,6 @@ export function validateSpec(text, options = {}) {
340
416
  });
341
417
  }
342
418
  }
343
- const graded = findings.map((finding) => (strict ? { ...finding, severity: /** @type {const} */ ("blocking") } : finding));
419
+ const graded = findings.map((finding) => (strict && promotableUnderStrict(finding) ? { ...finding, severity: /** @type {const} */ ("blocking") } : finding));
344
420
  return { class: "structured", ok: !graded.some((finding) => finding.severity === "blocking"), findings: graded };
345
421
  }