Files
msd-core/scripts/fix-slash-commands.cjs
Jakub Zych a9a7a328e6 refactor: hard-fork GSD -> MSD (Make Software Done)
Mechanical rename produced by scripts/msd-rename.cjs: gsd/Gsd/GSD -> msd/Msd/MSD
across contents and paths, upstream package/repo coordinates -> @golem15/msd-core
and golem15com/msd-core. Deep links into upstream history, sibling upstream
packages, the GSD-2 import feature, CHANGELOG.md and .changeset/ are kept as-is.

Hand edits on top: MSD block-letter banner and logos, LICENSE copyright line,
package/plugin identity, regenerated lockfile, install-tree fixtures, derived
registries and benchmark baseline; migration checksum baseline re-locked
(MSD keeps its own install state, so no install had applied the old sums);
sort-order and regex-escaped expectations in tests adjusted.
2026-10-06 01:47:40 +02:00

160 lines
5.9 KiB
JavaScript

'use strict';
/**
* One-shot script + library: bidirectional MSD slash-command namespace normalizer.
*
* - Default direction (transformContent): retired /msd-<cmd> → /msd:<cmd>
* (keeps monorepo sources, docs, and workflows in the active colon form).
* - Reverse direction (transformContentToHyphen): /msd:<cmd> / msd:<cmd> → msd-<cmd>
* (used during skill installation for runtimes that register skills under the
* canonical hyphen form established in #2808).
*
* Both directions only rewrite known commands from `commands/msd/*.md` (longest-first
* matching + word-boundary safety). Non-commands (msd-sdk, msd-tools, etc.) are
* intentionally left untouched.
*
* The transforms are pure and exported for use by the installer and tests.
*/
const fs = require('node:fs');
const path = require('node:path');
const COMMANDS_DIR = path.join(__dirname, '..', 'commands', 'msd');
const SEARCH_DIRS = [
path.join(__dirname, '..', 'msd-core', 'bin', 'lib'),
path.join(__dirname, '..', 'msd-core', 'workflows'),
path.join(__dirname, '..', 'msd-core', 'references'),
path.join(__dirname, '..', 'msd-core', 'templates'),
path.join(__dirname, '..', 'msd-core', 'contexts'),
path.join(__dirname, '..', 'commands', 'msd'),
path.join(__dirname, '..', 'agents'),
path.join(__dirname, '..', 'hooks'),
];
const TOP_LEVEL_FILES = [
path.join(__dirname, '..', '.clinerules'),
];
const SKIP_DIRS = new Set(['node_modules', 'dist', '.turbo']);
const EXTENSIONS = new Set(['.md', '.cjs', '.js', '.ts', '.tsx']);
// Test files contain intentional fixture strings (e.g. inputs the sanitizer
// is expected to strip). Rewriting them changes test semantics.
function isTestFile(name) {
return /\.test\.(c?js|tsx?)$/.test(name);
}
function buildPattern(cmdNames) {
// Empty input would compile `/msd-()(?=[^a-zA-Z0-9_-]|$)/g`, which the regex
// engine still matches at any `/msd-` token followed by a non-word boundary
// (e.g. EOL, whitespace, punctuation) — rewriting it to a stray `/msd:`.
// Short-circuit so the caller can no-op on a missing/empty registry rather
// than perform an unintended broad rewrite.
if (!Array.isArray(cmdNames) || cmdNames.length === 0) return null;
const sorted = [...cmdNames].sort((a, b) => b.length - a.length); // longest first to avoid partial matches
return new RegExp(`/msd-(${sorted.join('|')})(?=[^a-zA-Z0-9_-]|$)`, 'g');
}
/**
* Pure transform: rewrite retired `/msd-<cmd>` to `/msd:<cmd>` for the given command names.
* Returns the rewritten string. Identifiers not in `cmdNames` (e.g. `/msd-sdk`,
* `/msd-tools`) are left untouched.
*/
function transformContent(src, cmdNames) {
const pattern = buildPattern(cmdNames);
if (!pattern) return src;
return src.replace(pattern, (_, cmd) => `/msd:${cmd}`);
}
/**
* Build regex for the reverse direction (colon form → hyphen form).
* Matches both "msd:cmd" and "/msd:cmd" (the leading / is preserved automatically
* because it is not part of the match). Uses longest-first ordering plus
* bidirectional word-boundary safety (negative lookbehind on the left, lookahead
* on the right) so matches only occur at token boundaries.
*/
function buildColonPattern(cmdNames) {
if (!Array.isArray(cmdNames) || cmdNames.length === 0) return null;
const sorted = [...cmdNames].sort((a, b) => b.length - a.length);
return new RegExp(`(?<![a-zA-Z0-9_-])msd:(${sorted.join('|')})(?=[^a-zA-Z0-9_-]|$)`, 'g');
}
/**
* Pure transform (reverse): rewrite `/msd:<cmd>` / `msd:<cmd>` to hyphen form
* for known MSD commands.
*
* Non-command identifiers (e.g. msd-sdk, msd-tools) are left untouched, matching
* the safety contract of the forward transform.
*/
function transformContentToHyphen(src, cmdNames) {
const pattern = buildColonPattern(cmdNames);
if (!pattern) return src;
return src.replace(pattern, (_, cmd) => `msd-${cmd}`);
}
function readCmdNames() {
try {
return fs.readdirSync(COMMANDS_DIR)
.filter(f => f.endsWith('.md'))
.map(f => f.replace(/\.md$/, ''));
} catch (err) {
// Only swallow the missing-directory case. Any other error (EACCES, ENOTDIR,
// etc.) indicates a real misconfiguration and must propagate so callers are
// not silently handed an empty registry while the real problem goes undetected.
if (err.code !== 'ENOENT') throw err;
// COMMANDS_DIR may not exist on installs that use skill-based runtimes or
// global Claude installs (no local commands/msd/ directory). Return [] so
// callers that handle an empty array gracefully (buildPattern returns null,
// transformContent is a no-op) are not broken by a missing directory.
return [];
}
}
function processFile(file, cmdNames) {
const pattern = buildPattern(cmdNames);
if (!pattern) return;
let src;
try { src = fs.readFileSync(file, 'utf-8'); } catch { return; }
const replaced = transformContent(src, cmdNames);
if (replaced !== src) {
fs.writeFileSync(file, replaced, 'utf-8');
const count = (src.match(pattern) || []).length;
console.log(` ${count} replacements: ${path.relative(path.join(__dirname, '..'), file)}`);
}
}
function processDir(dir, cmdNames) {
const pattern = buildPattern(cmdNames);
if (!pattern) return;
let entries;
try { entries = fs.readdirSync(dir, { withFileTypes: true }); } catch { return; }
for (const e of entries) {
const full = path.join(dir, e.name);
if (e.isDirectory()) {
if (SKIP_DIRS.has(e.name)) continue;
processDir(full, cmdNames);
} else if (EXTENSIONS.has(path.extname(e.name)) && !isTestFile(e.name)) {
processFile(full, cmdNames);
}
}
}
if (require.main === module) {
const cmdNames = readCmdNames();
for (const dir of SEARCH_DIRS) {
processDir(dir, cmdNames);
}
for (const file of TOP_LEVEL_FILES) {
processFile(file, cmdNames);
}
console.log('Done.');
}
module.exports = {
transformContent,
transformContentToHyphen,
buildPattern,
buildColonPattern,
readCmdNames,
SKIP_DIRS
};