* 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:
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user