* 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>
201 lines
9.7 KiB
JavaScript
201 lines
9.7 KiB
JavaScript
'use strict';
|
|
|
|
/**
|
|
* emitted-sizes.test.cjs — matrix section B (#2931 `.gsd/phase/chore-2931-emitted-byte-caps/50-test-matrix.md`).
|
|
*
|
|
* Covers `buildEmittedSizes` (tests/helpers/install-shared.cjs), the sibling of
|
|
* `buildParityManifest` that measures emitted-artifact BYTE SIZES over the same
|
|
* walk + `<HOME>`/version normalization instead of hashing. B1/B2/B7 exercise it
|
|
* against a real spawn-install (one shared fixture, built once in `before()`);
|
|
* B3-B6 exercise it against small synthetic config trees so the CRLF, multi-byte,
|
|
* `<HOME>`-normalization, and IO-fault behaviors are isolated and fast.
|
|
*/
|
|
|
|
const { test, before, after } = require('node:test');
|
|
const assert = require('node:assert/strict');
|
|
const fs = require('node:fs');
|
|
const path = require('node:path');
|
|
const os = require('node:os');
|
|
const { execFileSync } = require('node:child_process');
|
|
|
|
const { cleanup } = require('./helpers.cjs');
|
|
const {
|
|
BUILD_SCRIPT,
|
|
runMinimalInstall,
|
|
buildEmittedSizes,
|
|
buildParityManifest,
|
|
} = require('./helpers/install-shared.cjs');
|
|
|
|
// hooks/dist is gitignored and built (DEFECT.HOOKS-DIST-SCOPED-CI). Build it
|
|
// idempotently before the shared real-install fixture, mirroring
|
|
// tests/golden-install-tree.test.cjs.
|
|
before(() => {
|
|
execFileSync(process.execPath, [BUILD_SCRIPT], { encoding: 'utf-8', stdio: 'pipe' });
|
|
});
|
|
|
|
// ─── Shared real-install fixture (B1, B2, B7) ─────────────────────────────────
|
|
// One windsurf global install, built once and shared read-only across the
|
|
// assertions that need a REAL emitted tree — a shared FIXTURE, not shared
|
|
// mutable state. Cleaned up in the top-level after().
|
|
let fixture = null;
|
|
|
|
before(() => {
|
|
const { configDir, root } = runMinimalInstall({ runtime: 'windsurf', scope: 'global' });
|
|
fixture = { configDir, root };
|
|
});
|
|
|
|
after(() => {
|
|
if (fixture) cleanup(fixture.root);
|
|
});
|
|
|
|
// ─── Synthetic config-tree helper (B3-B6) ─────────────────────────────────────
|
|
|
|
/**
|
|
* Build a throwaway config dir under a fresh temp root and populate it with
|
|
* the given `{ relPath: content }` files (content is written as utf8, or as a
|
|
* Buffer if one is passed directly). Returns `{ configDir, root }` where
|
|
* `configDir === root` (no runtime-specific subdirectory layout needed for
|
|
* these synthetic cases).
|
|
*/
|
|
function makeSyntheticConfig(filesOrFactory) {
|
|
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-emitted-sizes-'));
|
|
// `filesOrFactory` may be a `(root) => files` factory for callers whose
|
|
// file CONTENT must embed the just-created temp root path (e.g. simulating
|
|
// an `@`-reference). It cannot be the root's own destructured binding —
|
|
// `const { root } = makeSyntheticConfig({ ...: root })` would read `root`
|
|
// from within its own TDZ and throw "Cannot access 'root' before
|
|
// initialization" before this function is ever called.
|
|
const files = typeof filesOrFactory === 'function' ? filesOrFactory(root) : filesOrFactory;
|
|
for (const [rel, content] of Object.entries(files)) {
|
|
const full = path.join(root, ...rel.split('/'));
|
|
fs.mkdirSync(path.dirname(full), { recursive: true });
|
|
fs.writeFileSync(full, content);
|
|
}
|
|
return { configDir: root, root };
|
|
}
|
|
|
|
// ─── B1 ────────────────────────────────────────────────────────────────────
|
|
|
|
test('capturesEmittedBytesFromRealInstall', () => {
|
|
const sizes = buildEmittedSizes(fixture.configDir, fixture.root);
|
|
const keys = Object.keys(sizes);
|
|
|
|
assert.ok(keys.length > 0, 'expected at least one emitted artifact');
|
|
for (const rel of keys) {
|
|
assert.strictEqual(typeof sizes[rel], 'number', `${rel}: expected numeric byte count`);
|
|
assert.ok(Number.isInteger(sizes[rel]) && sizes[rel] >= 0, `${rel}: expected a non-negative integer`);
|
|
}
|
|
});
|
|
|
|
// ─── B2 ────────────────────────────────────────────────────────────────────
|
|
// The two must never diverge on which files they cover — sizeKeySetMatchesParityManifestKeySet
|
|
// is the coverage-parity guard for that.
|
|
|
|
test('sizeKeySetMatchesParityManifestKeySet', () => {
|
|
const sizes = buildEmittedSizes(fixture.configDir, fixture.root);
|
|
const manifest = buildParityManifest(fixture.configDir, fixture.root);
|
|
|
|
assert.deepStrictEqual(Object.keys(sizes), Object.keys(manifest));
|
|
});
|
|
|
|
// ─── B3 ────────────────────────────────────────────────────────────────────
|
|
|
|
test('countsCrlfIdenticallyToLf', (t) => {
|
|
const body = 'line one\nline two\nline three\n';
|
|
const crlfConfig = makeSyntheticConfig({ 'artifact.md': body.replace(/\n/g, '\r\n') });
|
|
const lfConfig = makeSyntheticConfig({ 'artifact.md': body });
|
|
t.after(() => {
|
|
cleanup(crlfConfig.root);
|
|
cleanup(lfConfig.root);
|
|
});
|
|
|
|
const crlfSizes = buildEmittedSizes(crlfConfig.configDir, crlfConfig.root);
|
|
const lfSizes = buildEmittedSizes(lfConfig.configDir, lfConfig.root);
|
|
|
|
assert.strictEqual(crlfSizes['artifact.md'], lfSizes['artifact.md']);
|
|
});
|
|
|
|
// ─── B4 ────────────────────────────────────────────────────────────────────
|
|
|
|
test('countsMultiByteUtf8AsBytes', (t) => {
|
|
// em-dash (—, U+2014) and right-arrow (→, U+2192) are each 3 bytes in UTF-8
|
|
// but 1 UTF-16 code unit — a `.length`-based counter would undercount.
|
|
const content = 'a—b→c';
|
|
const { configDir, root } = makeSyntheticConfig({ 'artifact.md': content });
|
|
t.after(() => cleanup(root));
|
|
|
|
const sizes = buildEmittedSizes(configDir, root);
|
|
assert.strictEqual(sizes['artifact.md'], Buffer.byteLength(content, 'utf8'));
|
|
assert.notStrictEqual(sizes['artifact.md'], content.length, 'byte count must not equal UTF-16 length');
|
|
});
|
|
|
|
// ─── B5 ────────────────────────────────────────────────────────────────────
|
|
|
|
test('normalizesConfigRootBeforeCounting', (t) => {
|
|
// Factory form: the temp root doesn't exist until `makeSyntheticConfig`
|
|
// creates it, so the content that embeds it (simulating an `@`-reference
|
|
// to the absolute temp root, as a real install's projected
|
|
// agents/commands/workflows do) must be built from the `root` the
|
|
// factory receives, not a `root` this destructuring is still declaring.
|
|
const { configDir, root } = makeSyntheticConfig((r) => ({
|
|
'artifact.md': `see @${r}/gsd-core/CONTEXT.md for details\n`,
|
|
}));
|
|
t.after(() => cleanup(root));
|
|
|
|
const sizes = buildEmittedSizes(configDir, root);
|
|
const expectedNormalized = `see @<HOME>/gsd-core/CONTEXT.md for details\n`;
|
|
|
|
assert.strictEqual(sizes['artifact.md'], Buffer.byteLength(expectedNormalized, 'utf8'));
|
|
// The raw on-disk byte count (root not collapsed to '<HOME>') must differ
|
|
// whenever the root path is not already exactly 6 characters ('<HOME>' length) —
|
|
// proving the measured count really is post-normalization, not raw disk bytes.
|
|
const rawContent = fs.readFileSync(path.join(configDir, 'artifact.md'));
|
|
if (root.length !== '<HOME>'.length) {
|
|
assert.notStrictEqual(sizes['artifact.md'], rawContent.length);
|
|
}
|
|
});
|
|
|
|
// ─── B6 ────────────────────────────────────────────────────────────────────
|
|
|
|
test('propagatesReadFailureRatherThanReturningPartialMap', (t) => {
|
|
const { configDir, root } = makeSyntheticConfig({
|
|
'a.md': 'alpha\n',
|
|
'b.md': 'beta\n',
|
|
});
|
|
|
|
const original = fs.readFileSync;
|
|
const injected = new Error('injected read failure');
|
|
const mockedReadFileSync = t.mock.method(fs, 'readFileSync', () => {
|
|
throw injected;
|
|
});
|
|
t.after(() => {
|
|
mockedReadFileSync.mock.restore();
|
|
assert.strictEqual(fs.readFileSync, original, 'fs.readFileSync must be restored');
|
|
cleanup(root);
|
|
});
|
|
|
|
assert.throws(() => buildEmittedSizes(configDir, root), (err) => err === injected);
|
|
});
|
|
|
|
// ─── B7 ────────────────────────────────────────────────────────────────────
|
|
// The highest-value test in this file. buildEmittedSizes must be a SIBLING of
|
|
// buildParityManifest, never a replacement folded into it: diffEmitted
|
|
// (tests/helpers/emitted-diff.cjs) compares two manifests with
|
|
// `before[rel] === after[rel]`, which requires plain STRING hash values. If a
|
|
// future refactor merged bytes into buildParityManifest's own return shape
|
|
// (e.g. `{ hash, bytes }` objects), every one of the 8,529 emitted paths across
|
|
// the 19 runtime manifests would become reference-unequal and the sole merge
|
|
// gate would report universal false-positive "modified" drift. This test pins
|
|
// the string shape so that regression cannot land silently.
|
|
|
|
test('parityManifestStillReturnsStringHashValues', () => {
|
|
const manifest = buildParityManifest(fixture.configDir, fixture.root);
|
|
const keys = Object.keys(manifest);
|
|
|
|
assert.ok(keys.length > 0, 'expected at least one manifest entry to check');
|
|
for (const rel of keys) {
|
|
assert.strictEqual(typeof manifest[rel], 'string', `${rel}: buildParityManifest value must be a string`);
|
|
assert.match(manifest[rel], /^[0-9a-f]{16}$/, `${rel}: expected a 16-char lowercase hex hash`);
|
|
}
|
|
});
|