chore(#2931): cap emitted per-runtime bytes and single-source windsurf (#2984)

* 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>
This commit is contained in:
Tom Boucher
2026-08-01 16:00:14 -04:00
committed by GitHub
parent 4df6d884b3
commit 628648d63a
15 changed files with 2284 additions and 221 deletions

View File

@@ -0,0 +1,462 @@
'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,
};

View File

@@ -4,8 +4,9 @@
* Shared helpers and constants for the install test suites and the
* golden-install-parity harness. Provides the install/uninstall drivers
* (walk, runMinimalInstall, RUNTIME_META, BUILD_SCRIPT) and the single
* canonical golden-parity manifest builder (buildParityManifest) plus its
* exclusion constants (VOLATILE_FILES, HOOK_CONFIG_FILES,
* canonical golden-parity manifest builder (buildParityManifest), its sibling
* byte-size builder over the same normalized walk (buildEmittedSizes, #2931),
* plus their shared exclusion constants (VOLATILE_FILES, HOOK_CONFIG_FILES,
* HOOK_CONFIG_RELATIVE_PATHS, EXCLUDED_PREFIXES). Imported by many
* tests/*.test.cjs and by scripts/gen-golden-install-parity-zcode.cjs — do
* NOT re-declare the builder/constants inline (enforced by
@@ -308,15 +309,34 @@ function walk(dir) {
* (#2891 review FINDING 5).
* @returns {{ [rel: string]: string }}
*/
function buildParityManifest(configDir, root, opts = {}) {
// `opts = {}` only substitutes for an OMITTED (or explicit `undefined`) third
// argument — `null` and other non-object values sail past a default parameter and
// would otherwise reach the `in` check below and throw a raw, unhelpful
// `TypeError: Cannot convert undefined or null to object` (#2891 review FINDING 5).
// Fail with a clear, attributable message instead.
/**
* Shared walk+validate+normalize core for buildParityManifest and
* buildEmittedSizes. Both need the EXACT same file set (VOLATILE_FILES,
* HOOK_CONFIG_FILES, HOOK_CONFIG_RELATIVE_PATHS, EXCLUDED_PREFIXES) and the
* exact same `<HOME>`/version-stamp normalized content — one hashes it, the
* other measures its byte length — so the coverage and the normalization live
* in ONE place instead of being copy-pasted across the two (the copy-paste
* itself is the drift risk this function exists to remove; see #2931 brief).
*
* @param {string} configDir
* @param {string} root
* @param {object} opts - same shape as buildParityManifest's `opts` (see its
* JSDoc for the full pkgVersion/opts contract this validates).
* @param {string} callerName - used only in thrown error messages, so a
* caller-facing error still names the PUBLIC function the caller invoked
* (`buildParityManifest` / `buildEmittedSizes`), not this internal helper.
* @returns {{ [rel: string]: string }} rel -> normalized utf8 content, sorted keys.
*/
function collectNormalizedEmittedFiles(configDir, root, opts, callerName) {
// `opts = {}` at each public call site only substitutes for an OMITTED (or
// explicit `undefined`) third argument — `null` and other non-object values
// sail past that default parameter and would otherwise reach the `in` check
// below and throw a raw, unhelpful `TypeError: Cannot convert undefined or
// null to object` (#2891 review FINDING 5). Fail with a clear, attributable
// message instead.
if (opts === null || typeof opts !== 'object' || Array.isArray(opts)) {
throw new Error(
`buildParityManifest: opts must be a plain object or omitted, got ${JSON.stringify(opts)}.`
`${callerName}: opts must be a plain object or omitted, got ${JSON.stringify(opts)}.`
);
}
@@ -345,7 +365,7 @@ function buildParityManifest(configDir, root, opts = {}) {
// exactly the cross-tree mis-attribution bug this option exists to fix (#2891).
if (typeof pkgVersion !== 'string' || pkgVersion.length === 0 || !SEMVER_ISH_RE.test(pkgVersion)) {
throw new Error(
`buildParityManifest: pkgVersion must be a non-empty semver-ish string ` +
`${callerName}: pkgVersion must be a non-empty semver-ish string ` +
`(MAJOR.MINOR.PATCH, optional -prerelease/+build), got ${JSON.stringify(pkgVersion)}. ` +
'Pass the version of the tree that produced the emitted content at configDir.'
);
@@ -374,10 +394,10 @@ function buildParityManifest(configDir, root, opts = {}) {
if (EXCLUDED_PREFIXES.some((p) => rel.startsWith(p))) continue;
const content = fs.readFileSync(full);
// Normalize every occurrence of the temp root so hashes are stable across runs.
// Normalize every occurrence of the temp root so results are stable across runs.
// Also normalize the PRODUCING tree's package version at its known stamp sites
// (pkgVersion, defaulted to this checkout's own) — via the ANCHORED
// normalizeVersionStamps, not a blind substring replace — so the golden survives
// normalizeVersionStamps, not a blind substring replace — so results survive
// `npm version` bumps (the rc release step bakes the new version into hook files
// before running tests) and, for a cross-tree caller, so the version stamp baked
// in by a DIFFERENT tree's installer doesn't masquerade as a real content diff,
@@ -390,8 +410,7 @@ function buildParityManifest(configDir, root, opts = {}) {
.split(root).join('<HOME>'),
pkgVersion,
);
const hash = crypto.createHash('sha256').update(normalized).digest('hex').slice(0, 16);
unsorted[rel] = hash;
unsorted[rel] = normalized;
}
// Reconstruct with sorted keys for stable JSON serialisation
@@ -402,6 +421,56 @@ function buildParityManifest(configDir, root, opts = {}) {
return sorted;
}
function buildParityManifest(configDir, root, opts = {}) {
const normalizedByRel = collectNormalizedEmittedFiles(configDir, root, opts, 'buildParityManifest');
const out = {};
for (const rel of Object.keys(normalizedByRel)) {
out[rel] = crypto.createHash('sha256').update(normalizedByRel[rel]).digest('hex').slice(0, 16);
}
return out;
}
/**
* Build a deterministic byte-size map for all non-volatile files under configDir,
* measured on the SAME `<HOME>`/version-normalized content buildParityManifest
* hashes (see collectNormalizedEmittedFiles — one walk, one normalization, two
* projections, so the two functions can never diverge on which files they cover).
*
* Byte counting is LF-normalized (CRLF -> LF, stripped BEFORE the byte count),
* matching RULESET.WORKFLOW_SIZE_BUDGET ("BYTES not lines, LF-normalized" per
* #683/#717) and mirroring scripts/workflow-size.cjs's `lfByteCount`. That helper
* is NOT reused directly here: `lfByteCount(filePath)` re-reads a file from disk
* by path and has no `<HOME>`/version normalization, whereas this must count the
* already-normalized IN-MEMORY string collectNormalizedEmittedFiles produced — a
* re-read would both duplicate I/O and measure the wrong (unnormalized, raw
* temp-root-embedding) bytes. Only the one-line CRLF-strip + `Buffer.byteLength`
* formula is duplicated, not a file-reading helper.
*
* NOTE: this counts the NORMALIZED content (post `<HOME>` substitution and
* version-stamp normalization), not raw on-disk bytes. That is deliberate:
* determinism is the whole point of this map — a caller comparing sizes across
* machines/CI runs/temp dirs needs a value that does not vary with the temp
* root's length. Consequence: for any file whose content embeds the temp root
* (e.g. `@`-referenced absolute paths), this UNDERSTATES real on-disk size by
* roughly `(len(configDir) - len('<HOME>')) * occurrences`. A future consumer
* enforcing a byte cap close to a real hard limit must carry its own margin for
* this gap — it is not folded in here.
*
* @param {string} configDir - absolute path to the installed runtime config dir
* @param {string} root - temp root path to replace with '<HOME>'
* @param {object} [opts] - same shape/guards as buildParityManifest's `opts`
* (see its JSDoc for the full pkgVersion/opts contract).
* @returns {{ [rel: string]: number }}
*/
function buildEmittedSizes(configDir, root, opts = {}) {
const normalizedByRel = collectNormalizedEmittedFiles(configDir, root, opts, 'buildEmittedSizes');
const out = {};
for (const rel of Object.keys(normalizedByRel)) {
out[rel] = Buffer.byteLength(normalizedByRel[rel].replace(/\r\n/g, '\n'), 'utf8');
}
return out;
}
/** Sorted list of emitted relative paths for a runtime install (file-set snapshot,
* #2267). Reuses buildParityManifest's exact exclusion set so the tree and the
* content manifest never diverge on which files they cover. Deliberately does NOT
@@ -599,6 +668,7 @@ module.exports = {
stripAnsi,
walk,
buildParityManifest,
buildEmittedSizes,
buildInstallTree,
simulateHookCopy,
installerEnv,

View File

@@ -0,0 +1,120 @@
'use strict';
/**
* trim-safety.cjs — the trim-safety contract gate over `ComposeMetadata`
* (issue #2931, epic #1671, Phase 4).
*
* `composeWithinBudget` (src/context-composer.cts) is a pressure-aware
* budgeter: under load it may shrink, floor, or drop fragments entirely. This
* module is the gate a caller runs AFTER composing to prove that pressure
* never silently touched a fragment the caller has declared load-bearing —
* an id whose full, unshrunk content the caller is relying on to be present.
*
* Pure: no fs, no git, no clock. It only ever reads the `ComposeMetadata`
* shape (`omitted`, `shrunk`, `floored`, `isolatePrefix`, `hardFailed`,
* `hardFailReason`) and a caller-declared `loadBearingIds` list.
*
* ── The anti-vacuity rule is the most important rule in this module ────────
* A trim-safety gate that runs with an EMPTY `loadBearingIds` set would pass
* on every input, forever, having asserted nothing at all — the exact "looks
* like coverage and is not" failure this repo's test-matrix discipline
* exists to catch. `loadBearingIds` empty or absent is therefore a hard
* error (REASON.NO_LOAD_BEARING_DECLARED), not a vacuous pass.
*/
const REASON = Object.freeze({
LOAD_BEARING_OMITTED: 'load_bearing_omitted',
LOAD_BEARING_SHRUNK: 'load_bearing_shrunk',
ISOLATE_PREFIX_DRIFT: 'isolate_prefix_drift',
MINIMUM_SET_HARD_FAIL: 'minimum_set_hard_fail',
NO_LOAD_BEARING_DECLARED: 'no_load_bearing_declared',
});
/** Stable comparator over findings of possibly-different shapes: sort by
* `reason`, then by `id` (absent for ISOLATE_PREFIX_DRIFT, which sorts
* first within its reason bucket via the empty-string fallback). */
function byReasonThenId(a, b) {
if (a.reason !== b.reason) return a.reason < b.reason ? -1 : 1;
const aId = a.id || '';
const bId = b.id || '';
if (aId === bId) return 0;
return aId < bId ? -1 : 1;
}
/**
* Evaluate a compose result against a caller-declared load-bearing set.
*
* @param {object} opts
* @param {object} opts.metadata a `ComposeMetadata` from
* `composeWithinBudget` (src/context-composer.cts): `omitted`, `shrunk`,
* `floored` (string[] fragment ids), `isolatePrefix` (string),
* `hardFailed` (boolean), `hardFailReason` ('minimum-set' | null).
* @param {string[]} opts.loadBearingIds fragment ids the caller declares
* must survive intact. MUST be non-empty — see the anti-vacuity rule above.
* @param {string} [opts.expectedIsolatePrefix] when provided, the exact
* byte-for-byte prefix `metadata.isolatePrefix` must equal.
* @returns {{ findings: Array, errors: Array, ok: boolean }}
*/
function evaluateTrimSafety({ metadata, loadBearingIds, expectedIsolatePrefix } = {}) {
if (!Array.isArray(loadBearingIds) || loadBearingIds.length === 0) {
// Anti-vacuity: return immediately. Every other check below would be
// evaluated against a caller who declared nothing worth protecting, so
// computing them would dress up "proves nothing" as a real result.
return {
findings: [],
errors: [{
reason: REASON.NO_LOAD_BEARING_DECLARED,
message:
'loadBearingIds must be a non-empty array — a trim-safety gate with an empty '
+ 'assertion set proves nothing',
}],
ok: false,
};
}
const errors = [];
const findings = [];
const meta = metadata && typeof metadata === 'object' ? metadata : {};
const omitted = Array.isArray(meta.omitted) ? meta.omitted : [];
const shrunk = Array.isArray(meta.shrunk) ? meta.shrunk : [];
if (meta.hardFailed === true) {
errors.push({
reason: REASON.MINIMUM_SET_HARD_FAIL,
hardFailReason: meta.hardFailReason ?? null,
message: 'compose hard-failed (minimum required set could not fit the budget) — never a silent empty compose',
});
}
for (const id of loadBearingIds) {
if (omitted.includes(id)) {
findings.push({ reason: REASON.LOAD_BEARING_OMITTED, id });
}
if (shrunk.includes(id)) {
findings.push({ reason: REASON.LOAD_BEARING_SHRUNK, id });
}
// `floored` is deliberately never a finding: it means the floor did its
// job and the fragment's declared minimum survived.
}
if (expectedIsolatePrefix !== undefined) {
const actual = typeof meta.isolatePrefix === 'string' ? meta.isolatePrefix : '';
if (actual !== expectedIsolatePrefix) {
// Byte-identical means byte-identical: no trimming, no normalizing —
// a trailing-whitespace-only difference IS drift.
findings.push({ reason: REASON.ISOLATE_PREFIX_DRIFT, expected: expectedIsolatePrefix, actual });
}
}
findings.sort(byReasonThenId);
const ok = errors.length === 0 && findings.length === 0;
return { findings, errors, ok };
}
module.exports = {
REASON,
evaluateTrimSafety,
};