* enhance(#4377): opt-in project-relative includes for local installs A local install wrote the includes that point at GSD's own files as absolute paths — whatever the installer resolved at install time. For one checkout that is invisible. Across git worktrees it is not: each worktree gets its own .claude/ copy, but all of them point back at the checkout that ran the installer, so a worktree runs its own gsd-tools.cjs while reading workflow prose from a different checkout. Update that one checkout and every other worktree is running new instructions against an old engine, with nothing to stage the update with. --relative-includes (or GSD_RELATIVE_INCLUDES=1) makes a local install emit `@.claude/gsd-core/...`. Opt-in, and staying opt-in: absolute works for a single checkout, which is most people, and flipping the default would change every existing local install to solve a problem those users do not have. The prefix is the runtime's own localConfigDir descriptor value, never a literal — the same value resolveScope joins onto the cwd to produce the install target, and the same one the rewrite engine already uses for its ./.claude/ -> ./<dir>/ substitutions. Copilot and Antigravity have shipped this shape for local installs since they were added, with hardcoded .github/ and .agents/; this is that behavior, derived rather than written down. Six seams compute a path prefix and all six had to be threaded, which is why the opt-in travels through the environment the way --portable-hooks already does: one variable they all read cannot fall out of sync the way six signatures can. The launcher shim deliberately keeps its ABSOLUTE fallbacks. It probes gsd-tools through ${CLAUDE_CONFIG_DIR:-$HOME/.claude} and one such default per runtime; those are shell word expansions, not includes, and a relative value there resolves against the shell's cwd rather than the project. Trading an include that points at the wrong checkout for a path that points at nothing is not a fix. All three rewrite paths mask ${VAR:-default} spans before substituting and restore them after, and the mask only runs when the prefix is relative, so an absolute install is byte-for-byte unchanged. Every unexpressible case falls back to absolute: no opt-in, a global install, a missing dir name, the configHome.kind === 'none' sentinel, an absolute descriptor value, or one climbing out of the project with '..'. * chore(#4377): add changeset for project-relative local includes * fix(#4377): compare against POSIX-normalized roots in the install e2e arms The emitted prefix is POSIX-normalized by design — it is substituted into markdown @-references, which use forward slashes universally, so a backslash would leak into shipped content (#1615). The e2e arms compared against the raw temp root, which on Windows is `D:\a\...` and appears in no emitted file. That reddened the control arm on the windows shard, and it was worse than a red: the negative arm ("nothing references the checkout") was passing VACUOUSLY there, because a string that cannot occur is trivially absent. Both now go through the same normalization, so the Windows lane asserts what the Linux lane does. * fix(#4377): tolerate a resolved temp root, and make the e2e diff self-diagnosing Two changes, one confirmed and one to stop guessing. Confirmed: the emitted content carries the RESOLVED root, not the spelling mkdtemp handed back. Reproduced on Linux with a symlinked install root — 236 emitted files carry the realpath, zero carry the link path. macOS has this structurally, since /var is a symlink to /private/var. Comparisons now go through both spellings, or the negative arms pass vacuously: "nothing references the checkout" is trivially true when the string being searched for cannot occur. Not confirmed: the macOS shard reported ~every workflow file differing in the "differ ONLY" arm while the five arms around it passed, and the assertion printed a list of filenames — which says a difference exists somewhere across 236 files and leaves the reader to guess which bytes. I cannot reproduce that platform locally, and guessing turns one CI round-trip into four. The assertion now reports the first divergence as text: the file, the byte offset, and a bounded window of both sides. * fix(#4377): strip the longest root spelling first in the install e2e diff The macOS failure was my test corrupting its own comparison, not a product defect. /var/folders/…/X is a SUBSTRING of /private/var/folders/…/X, so stripping the unresolved spelling first matched inside the resolved one and left the /private prefix glued to what followed: @/private/var/…/X/.claude/gsd-core/… -> @/private.claude/gsd-core/… a string present in neither install, which is why all 236 files "differed". Sorting the spellings longest-first consumes the whole occurrence, and the short form then has nothing left to match. Proven in isolation on the exact macOS shapes: short-first yields @/private.claude/…, longest-first yields @.claude/…. The self-diagnosing assertion added in the previous commit is what found this — it named the file, the byte offset, and printed both sides, so the corrupted string was visible rather than inferred from a list of 236 filenames. Keeping it. * fix(#4377): address review findings * test(#4377): scan nested shell defaults without regex backtracking * fix(#4377): close relative include review gaps * fix(#4377): preserve root-target runtime includes * fix(#4377): guard project-root relative includes * test(#4377): normalize Cline fallback roots * fix(#4377): persist relative include style --------- Co-authored-by: Tom Boucher <trekkie@nomorestars.com>
This commit is contained in:
@@ -27,6 +27,7 @@ const path = require('node:path');
|
||||
const crypto = require('node:crypto');
|
||||
const os = require('node:os');
|
||||
const espree = require('espree');
|
||||
const fc = require('./helpers/fast-check-setup.cjs');
|
||||
const { splitLines, joinLines } = require('../gsd-core/bin/lib/text-lines.cjs');
|
||||
|
||||
const { createTempDir, cleanup, writePackageSourceMarkerFixture } = require('./helpers.cjs');
|
||||
@@ -5219,6 +5220,213 @@ describe('rewriteStagedCommandBodies', () => {
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// #4377: project-relative includes for local installs
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('#4377 _computePathPrefix — project-relative local includes', () => {
|
||||
// This block sits inside the enh-1511 fold, whose scope has its own
|
||||
// `conversion` binding and does NOT see the outer file's
|
||||
// `runtimeNamePolicy` (that one belongs to the fold that closed above).
|
||||
// require() is cached, so this is a lookup, not a second load.
|
||||
const namePolicy = require('../gsd-core/bin/lib/runtime-name-policy.cjs');
|
||||
// A local install baked the install-time absolute path into every generated
|
||||
// @ include. Worked from several git worktrees, that means each worktree
|
||||
// runs its own engine but reads its workflow prose out of ONE checkout —
|
||||
// and updating that checkout breaks every other worktree at once, with no
|
||||
// way to stage it. The relative form lets each worktree read its own copy.
|
||||
//
|
||||
// Every arm below passes `projectRelative` explicitly rather than leaning on
|
||||
// the environment default, so these assertions cannot flip on an ambient
|
||||
// GSD_RELATIVE_INCLUDES leaking in from the runner.
|
||||
const prefix = (over) => conversion._computePathPrefix({
|
||||
isGlobal: false,
|
||||
isOpencode: false,
|
||||
isWindowsHost: false,
|
||||
resolvedTarget: '/project/.cursor',
|
||||
homeDir: '/home/u',
|
||||
...over,
|
||||
});
|
||||
|
||||
test('opted in, a local install emits the descriptor dir, not the checkout path', () => {
|
||||
assert.equal(prefix({ projectRelative: true, localDirName: '.cursor' }), '.cursor/');
|
||||
});
|
||||
|
||||
test('opted OUT, a local install is byte-identical to the pre-#4377 behavior', () => {
|
||||
// The default must not change for the single-checkout majority.
|
||||
assert.equal(prefix({ projectRelative: false, localDirName: '.cursor' }), '/project/.cursor/');
|
||||
});
|
||||
|
||||
test('a GLOBAL install ignores the opt-in entirely', () => {
|
||||
// The $HOME-shorthand branch is the global contract and #4377 does not
|
||||
// touch it — a relative include in a global install would resolve against
|
||||
// whatever project the user happens to be sitting in.
|
||||
assert.equal(
|
||||
conversion._computePathPrefix({
|
||||
isGlobal: true, isOpencode: false, isWindowsHost: false,
|
||||
resolvedTarget: '/home/u/.cursor', homeDir: '/home/u',
|
||||
projectRelative: true, localDirName: '.cursor',
|
||||
}),
|
||||
'$HOME/.cursor/',
|
||||
);
|
||||
});
|
||||
|
||||
test('a nested descriptor dir survives as a nested relative prefix', () => {
|
||||
assert.equal(
|
||||
prefix({ projectRelative: true, localDirName: '.config/opencode', resolvedTarget: '/project/.config/opencode' }),
|
||||
'.config/opencode/',
|
||||
);
|
||||
});
|
||||
|
||||
test('an explicit local target derives its prefix from the resolved target, not the runtime default', () => {
|
||||
assert.equal(
|
||||
prefix({ projectRelative: true, projectRoot: '/project', resolvedTarget: '/project/.custom/claude', localDirName: '.claude' }),
|
||||
'.custom/claude/',
|
||||
);
|
||||
});
|
||||
|
||||
test('a Windows-style descriptor value is normalized to POSIX', () => {
|
||||
// The prefix is substituted into markdown @-references, which are POSIX
|
||||
// universally — a backslash here leaks into shipped content (#1615).
|
||||
assert.equal(prefix({ projectRelative: true, localDirName: '.claude\\nested' }), '.claude/nested/');
|
||||
});
|
||||
|
||||
test('a trailing slash in the descriptor does not double up', () => {
|
||||
assert.equal(prefix({ projectRelative: true, localDirName: '.cursor/' }), '.cursor/');
|
||||
});
|
||||
|
||||
// ── fail-safe arms: anything unexpressible falls back to absolute ──────────
|
||||
// A wrong-but-absolute include still points at a real file. A wrong RELATIVE
|
||||
// one silently resolves against whatever the reader's cwd happens to be,
|
||||
// which is a worse failure than the one being fixed.
|
||||
for (const [label, localDirName] of [
|
||||
['the no-local-config-dir sentinel (vscode)', namePolicy.NO_LOCAL_CONFIG_DIR_SENTINEL],
|
||||
['an absolute descriptor value', '/etc/gsd'],
|
||||
['a Windows-absolute descriptor value', 'C:/gsd'],
|
||||
['a value climbing out of the project', '../outside'],
|
||||
['a value climbing out of the project mid-path', 'nested/../../outside'],
|
||||
['a bare ..', '..'],
|
||||
['an empty value', ''],
|
||||
['a missing value', undefined],
|
||||
]) {
|
||||
test(`falls back to the absolute prefix for ${label}`, () => {
|
||||
assert.equal(prefix({ projectRelative: true, localDirName }), '/project/.cursor/');
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
describe('#4377 _relativeIncludesEnabled — the opt-in is off unless asked for', () => {
|
||||
test('reads GSD_RELATIVE_INCLUDES=1 from the injected environment', () => {
|
||||
assert.equal(conversion._relativeIncludesEnabled({ GSD_RELATIVE_INCLUDES: '1' }), true);
|
||||
});
|
||||
|
||||
test('anything other than the exact string 1 is off', () => {
|
||||
// No truthiness coercion: 'true'/'0'/'' must not silently opt a user in.
|
||||
for (const value of ['true', 'yes', '0', '', 'TRUE', ' 1']) {
|
||||
assert.equal(conversion._relativeIncludesEnabled({ GSD_RELATIVE_INCLUDES: value }), false, `value=${JSON.stringify(value)}`);
|
||||
}
|
||||
});
|
||||
|
||||
test('an absent variable is off', () => {
|
||||
assert.equal(conversion._relativeIncludesEnabled({}), false);
|
||||
});
|
||||
|
||||
test('the opt-in is true for exactly the string 1 over arbitrary JSON values', () => {
|
||||
fc.assert(fc.property(fc.jsonValue(), (value) => {
|
||||
assert.equal(
|
||||
conversion._relativeIncludesEnabled({ GSD_RELATIVE_INCLUDES: value }),
|
||||
value === '1',
|
||||
);
|
||||
}));
|
||||
});
|
||||
});
|
||||
|
||||
describe('#4377 project-relative prefix properties', () => {
|
||||
const segment = fc.array(
|
||||
fc.constantFrom(...'abcdefghijklmnopqrstuvwxyz0123456789_-'),
|
||||
{ minLength: 1, maxLength: 12 },
|
||||
).map((chars) => chars.join(''));
|
||||
|
||||
test('safe descriptor segments normalize to one POSIX prefix', () => {
|
||||
fc.assert(fc.property(
|
||||
fc.array(segment, { minLength: 1, maxLength: 5 }),
|
||||
fc.constantFrom('/', '\\'),
|
||||
fc.boolean(),
|
||||
(segments, separator, trailingSlash) => {
|
||||
const localDirName = segments.join(separator) + (trailingSlash ? separator : '');
|
||||
assert.equal(conversion._projectRelativePrefix(localDirName), `${segments.join('/')}/`);
|
||||
},
|
||||
));
|
||||
});
|
||||
|
||||
test('a traversal segment is rejected at every generated depth', () => {
|
||||
fc.assert(fc.property(
|
||||
fc.array(segment, { maxLength: 4 }),
|
||||
fc.array(segment, { maxLength: 4 }),
|
||||
(before, after) => {
|
||||
assert.equal(conversion._projectRelativePrefix([...before, '..', ...after].join('/')), '');
|
||||
},
|
||||
));
|
||||
});
|
||||
});
|
||||
|
||||
test('#4377: a project-root local target falls back to absolute includes', () => {
|
||||
assert.equal(conversion._localIncludeDirName('cline'), undefined);
|
||||
assert.equal(conversion._localIncludeDirName('claude'), '.claude');
|
||||
});
|
||||
|
||||
describe('#4377 relative rewrites preserve every runtime launcher shell default', () => {
|
||||
test('the shared mask preserves a complete nested shell default as one unit', () => {
|
||||
const nested = '${OUTER:-${INNER:-$HOME/.claude}/gsd-core}';
|
||||
const input = `outside=$HOME/.claude inside=${nested}`;
|
||||
const rewritten = conversion._withShellDefaultsPreserved(
|
||||
input,
|
||||
(body) => body.replace(/\$HOME\/\.claude/g, '.claude'),
|
||||
);
|
||||
assert.equal(rewritten, `outside=.claude inside=${nested}`);
|
||||
});
|
||||
|
||||
test('all emitted runtime conversions leave ${VAR:-default} probes verbatim', () => {
|
||||
const launcher = fs.readFileSync(
|
||||
path.join(__dirname, '..', 'gsd-core', 'workflows', '_runtime-launcher.snippet.sh'),
|
||||
'utf8',
|
||||
);
|
||||
const defaults = [];
|
||||
for (let start = launcher.indexOf('${'); start !== -1; start = launcher.indexOf('${', start + 2)) {
|
||||
let depth = 1;
|
||||
let end = start + 2;
|
||||
while (end < launcher.length && depth > 0) {
|
||||
if (launcher.startsWith('${', end)) {
|
||||
depth += 1;
|
||||
end += 2;
|
||||
} else {
|
||||
if (launcher[end] === '}') depth -= 1;
|
||||
end += 1;
|
||||
}
|
||||
}
|
||||
if (depth !== 0) break;
|
||||
const expansion = launcher.slice(start, end);
|
||||
if (/^\$\{[A-Za-z_][A-Za-z0-9_]*:-/.test(expansion)) defaults.push(expansion);
|
||||
start = end - 2;
|
||||
}
|
||||
assert.ok(defaults.length > 0, 'the launcher fixture must contain shell-default probes');
|
||||
|
||||
for (const runtime of ['claude', ...conversion.NON_CLAUDE_RUNTIMES]) {
|
||||
const rewritten = conversion._applyRuntimeRewrites(
|
||||
launcher,
|
||||
runtime,
|
||||
`.${runtime}/`,
|
||||
);
|
||||
for (const shellDefault of defaults) {
|
||||
assert.ok(
|
||||
rewritten.includes(shellDefault),
|
||||
`${runtime} relative rewrite must preserve ${shellDefault}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Error-path: applyRuntimeContentRewritesForCommandsInPlace must rm the tempDir
|
||||
// on any exception and NOT leave an orphaned gsd-cmd-rewrites-* directory.
|
||||
|
||||
Reference in New Issue
Block a user