fix(#3544): restore tilde form for at-refs in the global spec tree (#3551)

* fix(#3544): restore tilde form for at-refs in the global spec tree

A global claude install emitted @$HOME/.claude/gsd-core/references/*.md
in its workflows and references. $HOME does not expand in a Claude Code
@-import - only relative, absolute and ~ are documented, and a controlled
/context test confirmed a $HOME import loads nothing - so 54 includes
across 22 files silently resolved to nothing on a live install.

This is a divergence, not a new bug. #3133 already applies exactly this
correction to skill and command bodies through _applyRuntimeRewrites's
claude case; copyWithPathReplacement, the spec-tree emit path, never had
it. Both now call one exported helper, so the two surfaces cannot drift
apart again.

Deliberately narrower than changing computePathPrefix's return value:
shipped markdown also carries double-quoted "$HOME/.claude/..." shell
invocations, and ~ does not expand inside double quotes, so rewriting the
prefix wholesale would regress #1284. Only @-prefixed references move.

Refs #3544

* fix(#3544): derive the tilde restore from the resolved prefix

Three review findings, one batch.

The restore was hardcoded to the literal .claude directory, so a global
install with --config-dir pointing anywhere else silently no-opped and
reproduced the very defect this fixes. It now derives the tilde form from
the resolved prefix, which also closes the same latent gap in #3133's
original path since both call sites share the helper.

The @-anchor is quote-aware, so a double-quoted shell path is never
rewritten into a form the shell does not expand. Deliberately a lookbehind
rather than a line-start anchor: @-references are documented to work
mid-line, and anchoring would have traded a theoretical bug for a real one.

Found while testing the above: the bare-form rewrites re-matched their own
output whenever a config dir name extends .claude, emitting
.claude-work-work. Guarded with the same negative-lookahead convention
this file already uses to preserve .claude-plugin.

The tests prove the emitted form, never that the host resolves it - no CI
test can - and both the helper and the suite now say so, because an
undocumented verification boundary is how this defect stayed green for its
whole life.

Refs #3544

* test(#3544): acknowledge the tilde-restore emitted drift

The converter change moves 94 emitted paths that no source-file diff can
explain, which is exactly the case the per-PR ack fragment exists for.
Verified before acknowledging rather than after: both trees were built
from real installs and every one of the 211 changed lines across all 94
paths is @$HOME becoming @~, with nothing outside that single kind.

Nine spent entries were pruned from the #3151 and #2658 fragments. Those
paths moved again here, and two ack sources naming one path is a hard
duplicate error rather than last-wins, so the inert entries had to go
before this one could land. Both fragments retain their remaining
entries.

Refs #3544

* chore(#3544): backfill changeset pr number

---------

Co-authored-by: sim <sim@local>
This commit is contained in:
Tom Boucher
2026-08-15 09:31:59 -04:00
committed by GitHub
parent b7cca0363f
commit fd2b97a52a
7 changed files with 675 additions and 35 deletions

View File

@@ -2785,6 +2785,81 @@ function _stampNonClaudeRuntimeDefaults(content: string, runtime: string): strin
return content;
}
/**
* #3544 (extending #3133's fix): restore `@$HOME<suffix>` `@`-file-reference
* lines back to their tilde equivalent (`@~<suffix>`) in Claude-emitted
* content whose pathPrefix is the `$HOME` form. This is a NARROW,
* context-sensitive correction layered on top of the blanket `~/.claude/` /
* `$HOME/.claude/` -> pathPrefix substitution every Claude emit path
* applies: that blanket substitution MUST keep emitting `$HOME` for global
* installs — shell commands embedded in workflow/command bodies (e.g.
* `node "$HOME/.claude/gsd-core/bin/gsd-tools.cjs"`) need it, since `~` does
* not expand inside double-quoted shell strings (#1284). But Claude Code's
* own `@`-import resolver does the opposite: it documents `~` expansion and
* does NOT expand `$HOME`. That is not merely undocumented — a controlled
* `/context` measurement showed an `@$HOME/…` import loading nothing (see
* .gsd/bug/fix-3544-home-expansion-spec-tree/10-diagnosis.md's ADDENDUM). No
* automated test can verify *resolution* inside a live Claude Code session
* (nothing in CI can spawn one and read `/context`); every test here — unit
* and spawned-installer alike — verifies only the emitted STRING takes the
* `~` form Claude Code documents as expanding. A single pathPrefix string
* cannot satisfy both the shell and the `@`-import consumer, so this runs as
* a second, `@`-anchored pass AFTER the blanket substitution.
*
* #3133 first applied this restore inline in `_applyRuntimeRewrites`'s
* `case 'claude'` below (the skill/command staging pipeline). #3544 found
* the identical defect in bin/install.js's `copyWithPathReplacement` — the
* `gsd-core/` spec-tree emit path, which never had the restore step, so
* every `@~/.claude/gsd-core/…` include in a global install's workflows/
* references tree silently resolved to nothing (54 includes across 22 files
* on a live install, per the diagnosis). Both call sites now share this one
* implementation instead of drifting independently (DEFECT.GENERATIVE-FIX).
*
* No-op unless `pathPrefix` is the `$HOME` form — local installs already
* bake an absolute, `@`-resolvable pathPrefix and are unaffected, as are
* every non-Claude runtime (never called for them).
*
* #3544 review (2nd pass): the first cut of this function hardcoded the
* literal `.claude/` segment, so it silently no-opped for any global install
* under a non-default `--config-dir` (e.g. `~/.claude-work`) — reproducing
* the exact defect #3544 fixes, just one directory name later. This ALSO
* corrects the same latent gap in #3133's original path, since both call
* sites share this one implementation. Fixed by deriving the rewrite from
* `pathPrefix` itself rather than a hardcoded directory name: the tilde
* equivalent of any `$HOME`-form prefix is `'~' + pathPrefix.slice(5)`
* (`'$HOME'.length === 5`), so the transform generalizes to any config-dir
* name with no runtime-specific literal.
*
* #3544 review (2nd pass), quote-awareness: the anchor is a negative
* lookbehind for a preceding quote character, NOT a line-start anchor —
* Claude Code documents `@`-references as valid "anywhere in your
* CLAUDE.md" (e.g. `See @README for project overview`), so anchoring to
* line-start would miss a legitimate mid-line reference. The lookbehind
* instead guards the one demonstrated false-positive: a quoted shell string
* like `echo "@$HOME/.claude/x"`, where rewriting `$HOME` to `~` inside
* double quotes reintroduces the #1284 failure mode (`~` does not expand in
* double-quoted shell). Deliberately NOT fenced-code-block aware (unlike
* `resolveSpecRootReference`'s `scanFencedBlocks` use above): this pass
* targets genuine `@`-import lines and inline shell references across the
* whole emitted corpus, and today there are zero occurrences anywhere in the
* tree of an `@$HOME<suffix>` sequence inside a fenced code block (the
* quote-guard already closes the one reachable false-positive class).
* Layering `scanFencedBlocks` on top would roughly double this function's
* size to guard an undemonstrated case — the opposite of the brief's
* "simpler, not more complex" direction. If a fenced example ever needs this
* literal sequence, add fence-awareness then, with a regression test proving
* the fence is real.
*
* @private — exported as `_restoreClaudeGlobalAtRefTilde` for tests and for
* bin/install.js's `copyWithPathReplacement`.
*/
function restoreClaudeGlobalAtRefTilde(content, pathPrefix) {
if (typeof pathPrefix !== 'string' || !pathPrefix.startsWith('$HOME')) return content;
const tildeEquivalent = '~' + pathPrefix.slice('$HOME'.length);
const atRefRe = new RegExp(`(?<!["'])@${escapeRegExp(pathPrefix)}`, 'g');
return content.replace(atRefRe, `@${tildeEquivalent}`);
}
/**
* Apply the per-runtime rewrite table to a single content string.
* Relocated from bin/install.js `_applyRuntimeRewrites`.
@@ -2916,18 +2991,10 @@ function _applyRuntimeRewrites(content, runtime, pathPrefix, isGlobal = false, a
content = content.replace(/~\/\.claude\//g, pathPrefix);
content = content.replace(/\$HOME\/\.claude\//g, pathPrefix);
content = content.replace(/\.\/\.claude\//g, `./${dirName}/`);
// #3133: Claude Code expands `~` and absolute paths in @-file references
// but NOT `$HOME`. computePathPrefix returns the $HOME/.claude/ form for
// global installs under $HOME (correct for double-quoted shell commands —
// `~` does not expand inside double quotes, causing MODULE_NOT_FOUND,
// #1284), so the substitutions above rewrite @~/.claude/… → @$HOME/.claude/…
// which silently resolves to nothing and leaves skills with an empty
// execution_context. Restore the tilde form Claude expands (the form the
// shipped tarball uses). Local installs use an absolute pathPrefix (already
// @-resolvable) and are unaffected — the guard fires only for the $HOME form.
if (pathPrefix.startsWith('$HOME')) {
content = content.replace(/@\$HOME\/\.claude\//g, '@~/.claude/');
}
// #3133 / #3544: restore @-file-reference lines to the tilde form
// Claude actually expands — see restoreClaudeGlobalAtRefTilde's doc
// comment above for why this must be a separate, @-anchored pass.
content = restoreClaudeGlobalAtRefTilde(content, pathPrefix);
content = processAttribution(content, attribution);
break;
@@ -3384,6 +3451,7 @@ export = {
applyAgentPathRewrites,
normalizeAgentBodyForRuntime,
_computePathPrefix: computePathPrefix,
_restoreClaudeGlobalAtRefTilde: restoreClaudeGlobalAtRefTilde,
_applyRuntimeRewrites,
_stampNonClaudeRuntimeDefaults,
// #2652: registry-resolved dispatch isolation, mirroring routeDispatchIsolation