* fix(#2931): preserve protected regions and cap emitted per-runtime bytes Route every runtime brand swap through applyClaudeCodeBrandSwap so "Claude Code" survives verbatim inside <runtime_compatibility> regions (#2284b). The fix existed only in bin/install.js's local copies; the src/*.cts exports still used a naive replace, so binding install.js to the single source -- as this phase does for the Windsurf family -- would have silently regressed those runtimes. A table-driven parity guard now covers all nine brand-swapping converters. De-duplicate the Windsurf converter family: delete the six local copies in bin/install.js and bind the four exported ones by reference, guarded by reference-identity assertions (the ADR-1508/#1675 pattern). The two unexported helpers and an unused tool table go with them. Replace the Windsurf 12,000-byte throw with description truncation, matching the bound its sibling skill converter already applied. The throw could only fire on an ~11.7 KB frontmatter description: the largest emitted workflow is 311 bytes. Truncation makes the cap unreachable by construction and leaves 12,000 in exactly one place, eliminating the dual-surface duplication rather than testing for it. Add the emitted-byte cap gate: buildEmittedSizes captures LF- and <HOME>-normalized bytes from the walk buildParityManifest already performs, and evaluateEmittedCaps asserts them against a per-runtime cap table with dead-rule detection. buildParityManifest's return shape is deliberately unchanged -- diffEmitted compares its values with ===, so making them objects would report all 8,529 emitted paths as moved. A regression test pins the values as strings. Add a deterministic trim-safety gate over composeWithinBudget's omitted/shrunk/floored/isolatePrefix metadata, with an anti-vacuity rule, replacing the model-graded eval gate the issue described. * docs(#2931): correct ADR-1671 windsurf premise and trim-safety contract * fix(#2931): bound the windsurf command name and single-source the brand swap Review findings from the orthogonal passes, all fixed inline. The claim that removing the 12,000-byte throw left total emission "bounded by construction" was false. The #1615 regex constrains the character class but not the length, and commandName is interpolated three times into the emitted workflow: a 20,000-character name emitted 60,162 bytes silently. Add WINDSURF_COMMAND_NAME_MAX=128 as a separate, clearly-labelled size control that THROWS -- commandName is the @-ref path target, so truncating it would point the workflow at a file that does not exist (DEFECT.WORKFLOW-DELEGATION-TARGET-NOT-INSTALLED). The #1615 security regex is untouched and still runs first. 128 is generous: the longest shipped name is gsd-plan-review-convergence at 27. Harmonize convertClaudeCommandToWindsurfSkill onto the code-point-safe truncation helper. It still used a UTF-16 slice(0,177) -- the exact surrogate-splitting bug the helper was written to avoid, in the very sibling the helper's comment cites as its model. Bounds are unchanged, so output is byte-identical for every shipped command (descriptions max out at 99 chars). Export applyClaudeCodeBrandSwap and bind it in bin/install.js, deleting the local copy. Adding it to the .cts left two unlinked implementations of identical logic -- the drift class this change exists to remove. Verified byte-identical across eight fixtures and five sequential calls before merging, and guarded by a reference-identity assertion. Convert three try/finally test bodies to t.after (CONTRIBUTING.md:344), add fast-check property coverage for the trim-safety contract, and use fc.pre instead of a bare return in a property callback. * test(#2931): fix three test-authoring bugs the remote matrix caught The remote runner returned 8 unique failures on 6f15cdeb8. All three causes were in the test files, not the modules under test -- local harnesses exercise the modules directly, so nothing executed the test bodies until the matrix did. `{ __proto__: [...] }` in an object literal sets the prototype instead of an own key, so the JSON round-trip erased it and the cap table never saw a reserved runtime key. The production rejection was already correct; the test could not reach it. Use a computed key. Two cap fixtures tripped orthogonal error paths rather than the paths they name: one declared windsurf in the cap table but omitted it from sizes (UNKNOWN_RUNTIME), the other left the sole windsurf pattern matching nothing (a genuine dead rule). Both now include a compliant artifact so the intended branch is what is asserted. The dead-rule and unknown-runtime contracts are deliberate and unchanged. `const { root } = makeSyntheticConfig({ ... `${root}` })` referenced `root` from inside its own initializer -- a temporal dead zone error. makeSyntheticConfig now optionally takes a (root) => files factory. Also raise the npm pack --dry-run bound 60s -> 120s in the shipped- scripts packaging test. That failure is NOT from this branch: the file is byte-identical to next, a fresh tsc measures 1.98s there vs 2.14s here, and the run recorded 60,637ms against a 60,000ms bound -- a timeout under 28,948-test parallel contention, not a slowdown. Fixed rather than deferred because a bound that tight is fragile regardless of which branch trips it. * chore(#2931): backfill changeset pr number to 2984 --------- Co-authored-by: sim <sim@local>
463 lines
17 KiB
JavaScript
463 lines
17 KiB
JavaScript
'use strict';
|
|
|
|
/**
|
|
* emitted-caps.cjs — the per-runtime emitted-byte cap decision (issue #2931,
|
|
* epic #1671, Phase 4). Sibling law to `emitted-diff.cjs`'s conservation law:
|
|
* same pure/IO split, same error-accumulation shape, same reserved-key guard.
|
|
*
|
|
* ── Why this module is pure ──────────────────────────────────────────────────
|
|
* No fs, no git, no clock, no process. The expensive part — spawning an
|
|
* install and measuring real emitted bytes — lives elsewhere; this module only
|
|
* decides, given a `{ [runtime]: { [rel]: bytes } }` map and a cap table,
|
|
* which artifacts violate, which are unmeasured, and which comply. Keeping the
|
|
* decision pure makes every boundary (`cap-1`/`cap`/`cap+1`) a millisecond
|
|
* table test and keeps the Stryker gate able to bite.
|
|
*
|
|
* ── Why the cap table is HARD-CODED here, not read from capability.json ─────
|
|
* ADR-2719's guiding principle (already load-bearing in `emitted-diff.cjs`:
|
|
* it never re-derives a byte, because asserting `emitted == transform(source)`
|
|
* is the tautology ADR-2264's Amendment rejected) applies here too, one level
|
|
* up: a cap that is DERIVED from the same descriptor it is meant to guard
|
|
* (`capabilities/<rt>/capability.json`) would silently follow any edit to that
|
|
* descriptor. Bump the descriptor, the guard bumps with it, and a real
|
|
* regression sails through unnoticed. `EMITTED_CAPS` is a second, independent
|
|
* source of truth, edited deliberately by a human who has to look Windsurf's
|
|
* actual 12,000-byte platform limit in the eye — exactly the friction a guard
|
|
* exists to provide.
|
|
*/
|
|
|
|
// ─── REASON enum ───────────────────────────────────────────────────────────
|
|
|
|
const REASON = Object.freeze({
|
|
CAP_EXCEEDED: 'cap_exceeded',
|
|
DEAD_RULE: 'dead_rule',
|
|
UNKNOWN_RUNTIME: 'unknown_runtime',
|
|
NO_ARTIFACTS: 'no_artifacts',
|
|
INVALID_SIZES: 'invalid_sizes',
|
|
INVALID_CAP_TABLE: 'invalid_cap_table',
|
|
INVALID_CAP_VALUE: 'invalid_cap_value',
|
|
RESERVED_KEY: 'reserved_key',
|
|
UNSAFE_PATTERN: 'unsafe_pattern',
|
|
});
|
|
|
|
/**
|
|
* Keys that can never legitimately name a runtime or an emitted path, and
|
|
* that also happen to be the JS-object footguns. Mirrors
|
|
* `emitted-diff.cjs`'s `RESERVED_ACK_KEYS`: rejected LOUDLY (REASON.RESERVED_KEY),
|
|
* never silently dropped.
|
|
*/
|
|
const RESERVED_KEYS = new Set(['__proto__', 'constructor', 'prototype']);
|
|
|
|
/**
|
|
* The declared per-runtime emitted-byte cap table. Exactly one real entry
|
|
* today: Windsurf hard-caps a workspace workflow file at 12,000 bytes.
|
|
*
|
|
* Shape: `{ [runtime]: Array<{ pattern, maxBytes, note }> }`. `pattern` is a
|
|
* simple glob, evaluated against the emitted-relative path: `*` matches
|
|
* within one path segment (never crosses `/`), `**` matches across segments.
|
|
* Frozen two levels deep so a test cannot mutate the shipped table out from
|
|
* under a later assertion in the same run.
|
|
*/
|
|
const EMITTED_CAPS = Object.freeze({
|
|
windsurf: Object.freeze([
|
|
Object.freeze({
|
|
pattern: 'workflows/*.md',
|
|
maxBytes: 12000,
|
|
note: 'Windsurf hard-caps workspace workflow files at 12,000 bytes.',
|
|
}),
|
|
]),
|
|
});
|
|
|
|
// ─── Small predicates ────────────────────────────────────────────────────────
|
|
|
|
function isPlainObject(value) {
|
|
return value !== null && typeof value === 'object' && !Array.isArray(value);
|
|
}
|
|
|
|
function typeNameOf(value) {
|
|
if (value === null) return 'null';
|
|
if (Array.isArray(value)) return 'array';
|
|
return typeof value;
|
|
}
|
|
|
|
/** A legal byte count or cap value: a `number`, never a coerced string, never
|
|
* negative, NaN, Infinity, or fractional. `Number.isSafeInteger` alone
|
|
* already excludes NaN/Infinity/non-integers; `>= 0` excludes negatives. */
|
|
function isNonNegativeSafeInteger(value) {
|
|
return typeof value === 'number' && Number.isSafeInteger(value) && value >= 0;
|
|
}
|
|
|
|
/** `..` traversal or a leading `/` in a cap-table pattern is never legitimate
|
|
* — every emitted-relative path in this repo is already relative and
|
|
* segment-clean, so either shape can only be an authoring mistake or a
|
|
* hostile table entry. */
|
|
function isUnsafePattern(pattern) {
|
|
return pattern.includes('..') || pattern.startsWith('/');
|
|
}
|
|
|
|
/**
|
|
* Translate a `pattern` (already validated safe) into an anchored RegExp.
|
|
* `*` -> one path segment (`[^/]*`); `**` -> across segments (`.*`). Every
|
|
* other character is escaped, so a pattern is never accidentally read as a
|
|
* richer regex than the two glob tokens it declares.
|
|
*/
|
|
function compileGlobPattern(pattern) {
|
|
let out = '';
|
|
for (let i = 0; i < pattern.length; i += 1) {
|
|
const ch = pattern[i];
|
|
if (ch === '*' && pattern[i + 1] === '*') {
|
|
out += '.*';
|
|
i += 1;
|
|
} else if (ch === '*') {
|
|
out += '[^/]*';
|
|
} else if (/[.+^${}()|[\]\\]/.test(ch)) {
|
|
out += `\\${ch}`;
|
|
} else {
|
|
out += ch;
|
|
}
|
|
}
|
|
return new RegExp(`^${out}$`);
|
|
}
|
|
|
|
/** Stable comparator: runtime, then rel/pattern. Plain `<`/`>` on strings,
|
|
* not `localeCompare`, so ordering is locale-independent and reproducible. */
|
|
function byRuntimeThen(field) {
|
|
return (a, b) => {
|
|
if (a.runtime !== b.runtime) return a.runtime < b.runtime ? -1 : 1;
|
|
if (a[field] === b[field]) return 0;
|
|
return a[field] < b[field] ? -1 : 1;
|
|
};
|
|
}
|
|
|
|
// ─── The decision ────────────────────────────────────────────────────────────
|
|
|
|
/**
|
|
* Evaluate every measured emitted artifact against the declared cap table.
|
|
*
|
|
* ── The conservation law (by construction) ───────────────────────────────
|
|
* Every `(runtime, rel)` key in `sizes` whose byte value is a legal
|
|
* non-negative safe integer, and whose `runtime`/`rel` are not reserved
|
|
* keys, is placed into EXACTLY ONE of `violations`, `unmeasured`, or
|
|
* `compliant` — the single walk below assigns each such key to precisely one
|
|
* push. A key excluded by a RESERVED_KEY or INVALID_SIZES error is not a
|
|
* legitimate size measurement at all (its own error already names it), so it
|
|
* is not counted as a fourth bucket; the law is over the WELL-FORMED subset,
|
|
* exactly as `diffEmitted`'s conservation law is over the entries `parseAck`
|
|
* accepted.
|
|
*
|
|
* Internal bookkeeping (which rule matched which path, for dead-rule
|
|
* detection) is kept as index-based tracking, never as an object keyed by an
|
|
* external string — so a hostile `rel`/`runtime` value cannot pollute
|
|
* anything even transiently.
|
|
*
|
|
* @param {object} opts
|
|
* @param {object} opts.sizes { [runtime]: { [rel]: bytes } }
|
|
* @param {object} [opts.capTable] defaults to the shipped `EMITTED_CAPS`
|
|
* @returns {{
|
|
* violations: Array, unmeasured: Array, compliant: Array,
|
|
* deadRules: Array, errors: Array, ok: boolean
|
|
* }}
|
|
*/
|
|
function evaluateEmittedCaps({ sizes, capTable = EMITTED_CAPS } = {}) {
|
|
const errors = [];
|
|
|
|
if (sizes === null || sizes === undefined) {
|
|
errors.push({
|
|
reason: REASON.INVALID_SIZES,
|
|
receivedType: sizes === null ? 'null' : 'undefined',
|
|
message: `sizes is required, got ${sizes === null ? 'null' : 'undefined'}`,
|
|
});
|
|
} else if (!isPlainObject(sizes)) {
|
|
errors.push({
|
|
reason: REASON.INVALID_SIZES,
|
|
receivedType: typeNameOf(sizes),
|
|
message: `sizes must be a plain object keyed by runtime, got ${typeNameOf(sizes)}`,
|
|
});
|
|
}
|
|
|
|
if (capTable === null || capTable === undefined || !isPlainObject(capTable)) {
|
|
errors.push({
|
|
reason: REASON.INVALID_CAP_TABLE,
|
|
receivedType: typeNameOf(capTable),
|
|
message: `capTable must be a plain object keyed by runtime, got ${typeNameOf(capTable)}`,
|
|
});
|
|
}
|
|
|
|
if (errors.length) {
|
|
return { violations: [], unmeasured: [], compliant: [], deadRules: [], errors, ok: false };
|
|
}
|
|
|
|
const violations = [];
|
|
const unmeasured = [];
|
|
const compliant = [];
|
|
const deadRules = [];
|
|
|
|
// ── capTable validation, once, up front — never at compare time ──────────
|
|
// Maps `runtime -> Array<{ regex, maxBytes, note, pattern, matches: number }>`
|
|
// for runtimes that ARE known (present in `sizes`) and whose rules parsed
|
|
// cleanly. Keyed on `runtime`, but only ever assigned via `Map.set`, never
|
|
// bracket-property assignment — a hostile `__proto__` runtime name cannot
|
|
// pollute anything here either.
|
|
const compiledRules = new Map();
|
|
const knownRuntimeNoArtifacts = new Set();
|
|
|
|
for (const runtime of Object.keys(capTable)) {
|
|
if (RESERVED_KEYS.has(runtime)) {
|
|
errors.push({
|
|
reason: REASON.RESERVED_KEY,
|
|
scope: 'capTable-runtime',
|
|
key: runtime,
|
|
message: `capTable key "${runtime}" is reserved and can never be a real runtime name`,
|
|
});
|
|
continue;
|
|
}
|
|
|
|
if (!Object.prototype.hasOwnProperty.call(sizes, runtime)) {
|
|
// A cap declared for a runtime that never even reported sizes. Not
|
|
// installable/measured in this run at all — a hard error, distinct
|
|
// from "measured but produced zero files" (REASON.NO_ARTIFACTS below).
|
|
errors.push({
|
|
reason: REASON.UNKNOWN_RUNTIME,
|
|
runtime,
|
|
message: `capTable declares caps for "${runtime}", but sizes has no entry for it`,
|
|
});
|
|
continue;
|
|
}
|
|
|
|
const rules = capTable[runtime];
|
|
if (!Array.isArray(rules)) {
|
|
errors.push({
|
|
reason: REASON.INVALID_CAP_TABLE,
|
|
runtime,
|
|
message: `capTable.${runtime} must be an array of rules, got ${typeNameOf(rules)}`,
|
|
});
|
|
continue;
|
|
}
|
|
|
|
const parsed = [];
|
|
rules.forEach((rule, index) => {
|
|
if (!isPlainObject(rule)) {
|
|
errors.push({
|
|
reason: REASON.INVALID_CAP_TABLE,
|
|
runtime,
|
|
index,
|
|
message: `capTable.${runtime}[${index}] must be an object with pattern/maxBytes`,
|
|
});
|
|
return;
|
|
}
|
|
|
|
const { pattern, maxBytes, note } = rule;
|
|
|
|
let patternOk = true;
|
|
if (typeof pattern !== 'string' || pattern.length === 0) {
|
|
errors.push({
|
|
reason: REASON.INVALID_CAP_TABLE,
|
|
runtime,
|
|
index,
|
|
message: `capTable.${runtime}[${index}].pattern must be a non-empty string`,
|
|
});
|
|
patternOk = false;
|
|
} else if (isUnsafePattern(pattern)) {
|
|
errors.push({
|
|
reason: REASON.UNSAFE_PATTERN,
|
|
runtime,
|
|
pattern,
|
|
message: `capTable.${runtime}[${index}].pattern "${pattern}" contains a ".." traversal or a leading "/"`,
|
|
});
|
|
patternOk = false;
|
|
}
|
|
|
|
let capOk = true;
|
|
if (!isNonNegativeSafeInteger(maxBytes)) {
|
|
errors.push({
|
|
reason: REASON.INVALID_CAP_VALUE,
|
|
runtime,
|
|
pattern: typeof pattern === 'string' ? pattern : null,
|
|
value: maxBytes,
|
|
message:
|
|
`capTable.${runtime}[${index}].maxBytes must be a non-negative safe integer, `
|
|
+ `got ${JSON.stringify(maxBytes)} (${typeNameOf(maxBytes)})`,
|
|
});
|
|
capOk = false;
|
|
}
|
|
|
|
if (patternOk && capOk) {
|
|
parsed.push({
|
|
pattern,
|
|
regex: compileGlobPattern(pattern),
|
|
maxBytes,
|
|
note: typeof note === 'string' ? note : undefined,
|
|
matches: 0,
|
|
});
|
|
}
|
|
});
|
|
|
|
compiledRules.set(runtime, parsed);
|
|
}
|
|
|
|
// ── Walk sizes, sorted, and assign every well-formed key exactly once ────
|
|
for (const runtime of Object.keys(sizes).sort()) {
|
|
if (RESERVED_KEYS.has(runtime)) {
|
|
errors.push({
|
|
reason: REASON.RESERVED_KEY,
|
|
scope: 'sizes-runtime',
|
|
key: runtime,
|
|
message: `sizes key "${runtime}" is reserved and can never be a real runtime name`,
|
|
});
|
|
continue;
|
|
}
|
|
|
|
const artifacts = sizes[runtime];
|
|
if (!isPlainObject(artifacts)) {
|
|
errors.push({
|
|
reason: REASON.INVALID_SIZES,
|
|
runtime,
|
|
message: `sizes.${runtime} must be a plain object of { rel: bytes }, got ${typeNameOf(artifacts)}`,
|
|
});
|
|
continue;
|
|
}
|
|
|
|
const rels = Object.keys(artifacts);
|
|
if (rels.length === 0) {
|
|
errors.push({
|
|
reason: REASON.NO_ARTIFACTS,
|
|
runtime,
|
|
message: `sizes.${runtime} produced no artifacts — never read "nothing to check" as "pass"`,
|
|
});
|
|
knownRuntimeNoArtifacts.add(runtime);
|
|
continue;
|
|
}
|
|
|
|
const rules = compiledRules.get(runtime) || [];
|
|
|
|
for (const rel of rels.sort()) {
|
|
if (RESERVED_KEYS.has(rel)) {
|
|
errors.push({
|
|
reason: REASON.RESERVED_KEY,
|
|
scope: 'sizes-rel',
|
|
runtime,
|
|
key: rel,
|
|
message: `sizes.${runtime} key "${rel}" is reserved and can never be a real emitted path`,
|
|
});
|
|
continue;
|
|
}
|
|
|
|
const bytes = artifacts[rel];
|
|
if (!isNonNegativeSafeInteger(bytes)) {
|
|
errors.push({
|
|
reason: REASON.INVALID_SIZES,
|
|
runtime,
|
|
rel,
|
|
message:
|
|
`sizes.${runtime}["${rel}"] must be a non-negative safe integer, `
|
|
+ `got ${JSON.stringify(bytes)} (${typeNameOf(bytes)})`,
|
|
});
|
|
continue;
|
|
}
|
|
|
|
const rule = rules.find((r) => r.regex.test(rel));
|
|
if (!rule) {
|
|
unmeasured.push({ runtime, rel, bytes });
|
|
continue;
|
|
}
|
|
|
|
rule.matches += 1;
|
|
if (bytes <= rule.maxBytes) {
|
|
compliant.push({ runtime, rel, bytes, cap: rule.maxBytes });
|
|
} else {
|
|
violations.push({
|
|
runtime,
|
|
rel,
|
|
bytes,
|
|
cap: rule.maxBytes,
|
|
delta: bytes - rule.maxBytes,
|
|
reason: REASON.CAP_EXCEEDED,
|
|
note: rule.note,
|
|
});
|
|
}
|
|
}
|
|
}
|
|
|
|
// ── Dead rules: matched nothing across the WHOLE run ──────────────────────
|
|
// Skipped for runtimes already flagged UNKNOWN_RUNTIME or NO_ARTIFACTS —
|
|
// those are more specific, more actionable errors, and a rule for a
|
|
// runtime with zero measured artifacts trivially matches nothing for a
|
|
// reason this walk already named.
|
|
for (const [runtime, rules] of compiledRules) {
|
|
if (knownRuntimeNoArtifacts.has(runtime)) continue;
|
|
for (const rule of rules) {
|
|
if (rule.matches === 0) {
|
|
deadRules.push({
|
|
runtime,
|
|
pattern: rule.pattern,
|
|
cap: rule.maxBytes,
|
|
reason: REASON.DEAD_RULE,
|
|
message: `capTable.${runtime} rule "${rule.pattern}" matched zero emitted paths — a cap guarding nothing is rot`,
|
|
});
|
|
}
|
|
}
|
|
}
|
|
|
|
violations.sort(byRuntimeThen('rel'));
|
|
unmeasured.sort(byRuntimeThen('rel'));
|
|
compliant.sort(byRuntimeThen('rel'));
|
|
deadRules.sort(byRuntimeThen('pattern'));
|
|
|
|
const ok = errors.length === 0 && violations.length === 0 && deadRules.length === 0;
|
|
|
|
return { violations, unmeasured, compliant, deadRules, errors, ok };
|
|
}
|
|
|
|
// ─── Rendering ────────────────────────────────────────────────────────────────
|
|
|
|
/**
|
|
* Pure renderer. Tests assert on `evaluateEmittedCaps`'s structured result,
|
|
* never on this string (CONTRIBUTING.md, "Prohibited: Raw Text Matching on
|
|
* Test Outputs").
|
|
*
|
|
* The CAP_EXCEEDED message deliberately warns against the Goodhart's-Law
|
|
* escape hatch: moving bytes behind an EAGERLY `@`-imported reference file
|
|
* changes where the bytes are typed, not how many bytes load. An eager
|
|
* `@`-import is inlined at load time, so the cap would read green while the
|
|
* runtime still pays every byte — gaming the metric, not complying with it
|
|
* (this exact failure mode is recorded in CONTEXT.md
|
|
* `RULESET.WORKFLOW_SIZE_BUDGET`).
|
|
*/
|
|
function formatCapReport(result) {
|
|
const parts = [];
|
|
|
|
if (result.errors.length) {
|
|
parts.push(
|
|
`${result.errors.length} error(s):\n ${result.errors.map((e) => e.message).join('\n ')}`,
|
|
);
|
|
}
|
|
|
|
if (result.violations.length) {
|
|
const list = result.violations.map(
|
|
(v) => ` ${v.runtime}: ${v.rel} is ${v.bytes} bytes — exceeds the ${v.cap}-byte cap by ${v.delta}`
|
|
+ (v.note ? ` (${v.note})` : ''),
|
|
);
|
|
parts.push(
|
|
`${result.violations.length} emitted artifact(s) exceed their declared cap:\n${list.join('\n')}\n\n`
|
|
+ 'Moving these bytes behind an EAGERLY `@`-imported reference file is gaming this '
|
|
+ 'metric, not complying with it: an eager @-import is inlined before the cap is ever '
|
|
+ 'measured, so the runtime still pays every byte at load time (CONTEXT.md '
|
|
+ 'RULESET.WORKFLOW_SIZE_BUDGET). The fix is to reduce what actually loads.',
|
|
);
|
|
}
|
|
|
|
if (result.deadRules.length) {
|
|
const list = result.deadRules.map((r) => ` ${r.runtime}: "${r.pattern}" (cap ${r.cap}) matched nothing`);
|
|
parts.push(`${result.deadRules.length} cap rule(s) matched zero emitted paths — a cap guarding nothing is rot:\n${list.join('\n')}`);
|
|
}
|
|
|
|
return parts.join('\n\n');
|
|
}
|
|
|
|
module.exports = {
|
|
REASON,
|
|
EMITTED_CAPS,
|
|
evaluateEmittedCaps,
|
|
formatCapReport,
|
|
};
|