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.
- package/package.json +1 -1
- package/src/cli/launch.mjs +20 -3
- package/src/cli/plan.mjs +56 -5
- package/src/cli.mjs +5 -1
- package/src/contract/definition-of-done.mjs +55 -0
- package/src/contract/index.mjs +1 -1
- package/src/contract/runtime.mjs +15 -5
- package/src/contract/worker-result.mjs +27 -11
- package/src/engine/live-preflight.mjs +4 -1
- package/src/engine/prompts.mjs +17 -1
- package/src/engine/scheduler.mjs +8 -1
- package/src/plan/freeze.mjs +50 -13
- package/src/plan/pipeline.mjs +123 -20
- package/src/plan/progress.mjs +101 -0
- package/src/plan/proof-check.mjs +13 -1
- package/src/plan/proof-scope.mjs +1 -1
- package/src/plan/repo-facts.mjs +64 -11
- package/src/plan/resolve.mjs +6 -4
- package/src/plan/rounds.mjs +37 -13
- package/src/plan/sizing.mjs +20 -4
- package/src/plan/spec.mjs +82 -6
- package/src/plan/template.mjs +182 -37
- package/src/repo/scope-closure.mjs +22 -0
- package/src/repo/worktree-prune.mjs +13 -5
- package/src/repo/worktree.mjs +54 -0
package/src/plan/rounds.mjs
CHANGED
|
@@ -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
|
-
|
|
292
|
-
|
|
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
|
-
|
|
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
|
|
315
|
-
// it: that
|
|
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,
|
|
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
|
-
|
|
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.
|
package/src/plan/sizing.mjs
CHANGED
|
@@ -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
|
|
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
|
|
397
|
-
|
|
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:
|
|
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 {{
|
|
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 (
|
|
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
|
|
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
|
}
|