Files
ECC/scripts/lib/install/link-rewrite.js
T
Gaurav DubeyandGitHub 0a926b45ba fix(install): rewrite relative skill links for injected ecc namespace (#2399)
* fix(install): rewrite relative skill links for injected ecc namespace

Skill and rule markdown is byte-copied during a claude install, but the
home/project adapters inject an `ecc/` namespace segment
(skills/<id> -> skills/ecc/<id>, rules/<x> -> rules/ecc/<x>). Source-relative
links such as `../../rules/react/hooks.md` therefore broke after install: the
extra level changed what `../..` resolved to, and the link target itself moved
under rules/ecc/.

Rewrite relative links in namespaced markdown so they resolve to the file's
actual installed location, derived from the plan's own file placements (no
hardcoded namespace literal, so the links cannot drift from where files land).
Non-namespacing adapters and links to non-installed targets are left untouched;
URLs, anchors, absolute paths, and fenced code blocks are never rewritten.

Fixes #2340

* fix(install): keep non-namespaced markdown on the byte-for-byte copy path

Address review feedback: the markdown branch in applyInstallPlan diverted every
copy-file markdown operation through read+rewrite+write, so identity-mapped
markdown (source path == install path, no namespace injected) lost byte-for-byte
content and source mode bits even though no link rewrite was needed.

Gate the rewrite on isNamespacedSource() so only files whose install path
actually changed (e.g. skills/x -> skills/ecc/x) leave the copyFileSync path;
everything else is copied verbatim as before.

* test(install): emit failure stack in the link-rewrite test runner

Address review feedback: the local test() harness logged only error.message,
so a failing assertion lost its source line and diff. Print error.stack on
stderr on failure so broken rewrite cases stay diagnosable.
2026-07-03 20:39:24 -07:00

180 lines
6.6 KiB
JavaScript

'use strict';
const path = require('path');
const posix = path.posix;
// Matches inline markdown links and images: `](target)` / `](target "title")`.
// We deliberately scope to the inline form because that is what skill/rule docs
// use for cross-directory references. Reference-style and autolinks are left
// untouched (they are rare in these files and carry higher false-positive risk).
const INLINE_LINK_PATTERN = /(!?\]\()([^()\s]+)(\s+"[^"]*")?(\))/g;
function toPosix(relativePath) {
return String(relativePath || '').replace(/\\/g, '/').replace(/^\.\//, '');
}
function stripTrailingSlash(value) {
return value.length > 1 ? value.replace(/\/+$/, '') : value;
}
// Build file + directory lookup maps from the plan's own file placements.
// `fileMappings` is a list of { sourceRel, destRel } where both are paths
// relative to the repo root and the install root respectively. The directory
// map is derived by walking shared ancestors of each source/dest pair, which is
// exact for prefix-insertion namespacing (e.g. `skills/x` -> `skills/ecc/x`):
// the path suffix below the inserted segment is preserved, so ancestor `k`
// of the source maps to the dest with the matching number of trailing
// segments removed.
function buildInstallIndex(fileMappings) {
const byFile = new Map();
const byDir = new Map();
for (const mapping of fileMappings || []) {
const sourceRel = toPosix(mapping.sourceRel);
const destRel = toPosix(mapping.destRel);
if (!sourceRel || !destRel) {
continue;
}
byFile.set(sourceRel, destRel);
const sourceParts = sourceRel.split('/');
const destParts = destRel.split('/');
// Map every source ancestor directory to its installed counterpart by
// removing the same count of trailing segments from the dest path.
for (let depth = 1; depth < sourceParts.length; depth += 1) {
const trailing = sourceParts.length - depth;
const destDepth = destParts.length - trailing;
if (destDepth < 1) {
continue;
}
const sourceDir = sourceParts.slice(0, depth).join('/');
const destDir = destParts.slice(0, destDepth).join('/');
// Only record real prefix-insertion mappings (suffix preserved). If a
// directory resolves to itself (no namespace change) we skip it so the
// rewriter leaves those links alone.
if (sourceDir !== destDir) {
byDir.set(sourceDir, destDir);
}
}
}
return { byFile, byDir };
}
function isExternalOrAnchor(target) {
return (
target === ''
|| target.startsWith('#')
|| target.startsWith('/')
|| target.startsWith('mailto:')
|| /^[a-zA-Z][a-zA-Z0-9+.-]*:/.test(target) // has a URL scheme (http:, https:, file:, ...)
);
}
// Resolve `target` (a relative link from `sourceDir`) to its repo-relative
// path, then return the install-relative path it should point to, or null when
// the target is not installed by this plan (leave such links untouched).
function resolveInstalledTarget(target, sourceDir, index) {
const hadTrailingSlash = target.endsWith('/');
const resolved = stripTrailingSlash(toPosix(posix.normalize(posix.join(sourceDir, target))));
// Escapes the repo root (starts with `..`) -> not something we placed.
if (resolved === '' || resolved === '.' || resolved.startsWith('..')) {
return null;
}
if (!hadTrailingSlash && index.byFile.has(resolved)) {
return { installed: index.byFile.get(resolved), trailingSlash: false };
}
if (index.byDir.has(resolved)) {
return { installed: index.byDir.get(resolved), trailingSlash: hadTrailingSlash };
}
return null;
}
// True when the plan installs `sourceRel` at a different relative path than the
// source (i.e. a namespace segment was injected, e.g. skills/x -> skills/ecc/x).
// Callers use this to keep non-namespaced files on the byte-for-byte copy path.
function isNamespacedSource(sourceRel, index) {
const normalizedSource = toPosix(sourceRel);
const installedSource = index && index.byFile.get(normalizedSource);
return Boolean(installedSource) && installedSource !== normalizedSource;
}
// Rewrite relative links in a single namespaced markdown file so they resolve
// to the file's installed location. Returns the content unchanged when the
// file itself was not namespaced or when no link needs adjustment. Pure: no IO.
function rewriteRelativeLinks(content, options) {
const { sourceRel, index } = options || {};
const normalizedSource = toPosix(sourceRel);
const installedSource = index && index.byFile.get(normalizedSource);
// Only rewrite when the file's own install path gained/changed a namespace
// segment. If it lands at the same relative path, every link recomputes to
// itself, so there is nothing to do.
if (!installedSource || installedSource === normalizedSource) {
return content;
}
const installedSourceDir = posix.dirname(installedSource);
const sourceDir = posix.dirname(normalizedSource);
const lines = String(content).split('\n');
let inFence = false;
for (let i = 0; i < lines.length; i += 1) {
const fenceToggle = /^\s*(```|~~~)/.test(lines[i]);
if (fenceToggle) {
inFence = !inFence;
continue;
}
if (inFence) {
continue; // never rewrite inside fenced code blocks
}
lines[i] = lines[i].replace(
INLINE_LINK_PATTERN,
(match, open, target, title, close) => {
// Preserve any `#fragment` so anchors survive the rewrite.
const hashIdx = target.indexOf('#');
const pathPart = hashIdx === -1 ? target : target.slice(0, hashIdx);
const fragment = hashIdx === -1 ? '' : target.slice(hashIdx);
if (isExternalOrAnchor(pathPart)) {
return match;
}
const resolution = resolveInstalledTarget(pathPart, sourceDir, index);
if (!resolution) {
return match;
}
let rewritten = posix.relative(installedSourceDir, resolution.installed);
if (rewritten === '') {
rewritten = '.';
}
if (resolution.trailingSlash && !rewritten.endsWith('/')) {
rewritten += '/';
}
// If the recomputed link points to the same place as the original
// (e.g. an intra-namespace `./sibling.md` whose endpoints both shift by
// the same prefix), keep the original text verbatim — including any
// leading `./` — so the rewrite stays a strict no-op where it must.
if (posix.normalize(rewritten) === posix.normalize(pathPart)) {
return match;
}
return `${open}${rewritten}${fragment}${title || ''}${close}`;
}
);
}
return lines.join('\n');
}
module.exports = {
buildInstallIndex,
isNamespacedSource,
rewriteRelativeLinks,
};