#!/usr/bin/env node /** * msd-rename.cjs — one-shot, re-runnable codemod for the hard fork GSD -> MSD * ("Make Software Done"). * * node scripts/msd-rename.cjs # dry run: report only, writes nothing * node scripts/msd-rename.cjs --apply # rewrite contents + `git mv` paths * node scripts/msd-rename.cjs --stdin # transform a patch/text on stdin -> stdout * # (bridge for porting an upstream diff) * * Rules, in order: * 1. SPECIFIC rewrites — upstream coordinates that become ours. * 2. PROTECTED patterns — upstream/external names that must survive verbatim * (issue links, sibling packages, the GSD-2 product). * 3. The three casings — GSD -> MSD, Gsd -> Msd, gsd -> msd. No other casing * exists in the tree, so mixed-case noise (base64, * `learningsDelete`) is left alone by construction. * * Not swept (same precedent as upstream's own get-shit-done -> gsd-core rename): // msd-allow-legacy-name * CHANGELOG.md and .changeset/ are historical record. package-lock.json is * regenerated, LICENSE is edited by hand, and this script skips itself. * * Files are processed as latin1 so every non-ASCII byte round-trips untouched. */ 'use strict'; const { execFileSync } = require('child_process'); const fs = require('fs'); const path = require('path'); const ROOT = path.resolve(__dirname, '..'); const SELF = 'scripts/msd-rename.cjs'; const SKIP_EXACT = new Set(['CHANGELOG.md', 'package-lock.json', 'LICENSE', SELF]); const SKIP_PREFIX = ['.changeset/']; // Files that describe the external GSD-2 product, whose project dir really is `.gsd/`. const GSD2_FILE_RE = /gsd-?2/i; // Split so the package-name single-source lint does not flag this script. const OURS_NPM = '@golem15/' + 'msd-core'; const OURS_FORGE = 'https://git.golem15.com/golem15/msd-core'; const SPECIFIC = [ // Regex-escaped forms (`@opengsd\/gsd-core` inside test patterns) come first. [/@opengsd\\\/gsd-core/g, `${OURS_NPM.replace('/', '\\/')}`], [/open-gsd\\\/gsd-core(?!\\\/(?:issues|pull|discussions|commit|releases|compare|security|actions)\b)/g, 'golem15\\/msd-core'], [/@opengsd\/gsd-core/g, OURS_NPM], [/https:\/\/raw\.githubusercontent\.com\/open-gsd\/gsd-core\/main\//g, `${OURS_FORGE}/raw/branch/next/`], [/https:\/\/github\.com\/open-gsd\/gsd-core(?!\/(?:issues|pull|discussions|commit|releases|compare|security|actions)\b)/g, OURS_FORGE], [/https:\/\/github\.com\/open-gsd(?![\w./-])/g, 'https://git.golem15.com/golem15'], [/%40opengsd%2Fgsd-core/g, '%40golem15%2Fmsd-core'], [/opengsd-gsd-core/g, 'golem15-msd-core'], // Bare repo slug becomes ours; deep links into upstream history are protected below. [/open-gsd\/gsd-core(?!\/(?:issues|pull|discussions|commit|releases|compare|security|actions)\b)/g, 'golem15/msd-core'], [/Git\. Ship\. Done\./g, 'Make Software Done.'], ]; const PROTECTED = [ /open-gsd\\\/gsd-core\\\/(?:issues|pull|discussions|commit|releases|compare|security|actions)\b/g, /open-gsd\/gsd-core\/(?:issues|pull|discussions|commit|releases|compare|security|actions)\b/g, /open-gsd\/(?!gsd-core\b)[A-Za-z0-9_.-]+/g, /@opengsd\/[A-Za-z0-9_.-]*/g, /@gsd-build\/[A-Za-z0-9_.-]*/g, /\bopen-gsd\b/g, /\bopengsd\b/g, /\bOpen ?GSD\b/g, /\bgsd-build\b/g, /\bgsd-pi\b/g, /\bgsd-opencode\b/g, /\bGSD[- ]2\b/g, /\bGSD[- ]Pi\b/g, /gsd-?2(?![0-9])/g, ]; const PROTECTED_GSD2_ONLY = [/\.gsd\b/g]; // Outside those files, a line that names GSD-2 is talking about its `.gsd/` dir too. const GSD2_LINE_RE = /^.*(?:GSD[- ]2\b|gsd-?2(?![0-9])).*$/gm; const CASINGS = [ [/GSD/g, 'MSD'], [/Gsd/g, 'Msd'], [/gsd/g, 'msd'], ]; /** * Pure: apply the rule pipeline to one string. * `stats` (optional) accumulates { specific, protected, casing } counts. */ function transform(text, { gsd2File = false, stats = null } = {}) { const held = []; const count = (key, n) => { if (stats && n) stats[key] = (stats[key] || 0) + n; }; let out = text; for (const [re, to] of SPECIFIC) { out = out.replace(re, () => { count('specific', 1); return to; }); } const hold = (m) => { count('protected', 1); if (stats) stats.protectedHits[m] = (stats.protectedHits[m] || 0) + 1; held.push(m); return `\u0000${held.length - 1}\u0000`; }; if (!gsd2File) { out = out.replace(GSD2_LINE_RE, (line) => line.replace(PROTECTED_GSD2_ONLY[0], hold)); } const protect = gsd2File ? PROTECTED.concat(PROTECTED_GSD2_ONLY) : PROTECTED; for (const re of protect) out = out.replace(re, hold); for (const [re, to] of CASINGS) { out = out.replace(re, () => { count('casing', 1); return to; }); } // eslint-disable-next-line no-control-regex return out.replace(/\u0000(\d+)\u0000/g, (_, i) => held[Number(i)]); } function isSkipped(file) { return SKIP_EXACT.has(file) || SKIP_PREFIX.some((p) => file.startsWith(p)); } function listFiles() { return execFileSync('git', ['ls-files', '-z'], { cwd: ROOT, encoding: 'utf8', maxBuffer: 1 << 28 }) .split('\0') .filter(Boolean); } /** Tokens whose `gsd` sits mid-word: the places a blind replace could be wrong. */ function suspiciousTokens(text, into) { const re = /[A-Za-z0-9_]*(?:gsd|Gsd|GSD)[A-Za-z0-9_]*/g; for (const tok of text.match(re) || []) { const i = tok.search(/gsd|Gsd|GSD/); const hit = tok.slice(i, i + 3); const prev = tok[i - 1] || ''; const odd = tok.length > 48 || (hit === 'gsd' && /[a-z0-9]/.test(prev)) || (hit === 'GSD' && /[A-Z0-9]/.test(prev)) || (hit === 'Gsd' && /[A-Z]/.test(prev)); if (odd) into[tok] = (into[tok] || 0) + 1; } } function main(argv) { if (argv.includes('--stdin')) { const input = fs.readFileSync(0, 'latin1'); process.stdout.write(Buffer.from(transform(input), 'latin1')); return; } const apply = argv.includes('--apply'); const stats = { specific: 0, protected: 0, casing: 0, protectedHits: {} }; const suspicious = {}; const skipped = { files: 0, hits: 0 }; const changed = []; const renames = []; const binaries = []; for (const file of listFiles()) { const abs = path.join(ROOT, file); const gsd2File = GSD2_FILE_RE.test(file); const st = fs.lstatSync(abs, { throwIfNoEntry: false }); if (!st) continue; if (isSkipped(file)) { if (st.isFile()) { const n = (fs.readFileSync(abs, 'latin1').match(/gsd/gi) || []).length; if (n) { skipped.files += 1; skipped.hits += n; } } continue; } const next = transform(file, { gsd2File }); if (next !== file) renames.push([file, next]); if (!st.isFile()) continue; const buf = fs.readFileSync(abs); const head = buf.subarray(0, 8000); if (head.includes(0) && !/\.(?:c?js|mjs|cts|ts|md|json|ya?ml|sh|txt)$/.test(file)) { if (/gsd/i.test(buf.toString('latin1'))) binaries.push(file); continue; } const before = buf.toString('latin1'); if (!/gsd/i.test(before)) continue; suspiciousTokens(before, suspicious); const after = transform(before, { gsd2File, stats }); if (after !== before) { changed.push(file); if (apply) fs.writeFileSync(abs, Buffer.from(after, 'latin1')); } } const targets = new Map(); const collisions = []; const tracked = new Set(listFiles()); for (const [from, to] of renames) { if (targets.has(to) || (tracked.has(to) && !renames.some(([f]) => f === to))) collisions.push([from, to]); targets.set(to, from); } if (apply) { if (collisions.length) throw new Error(`refusing to rename: ${collisions.length} path collisions`); for (const [from, to] of renames) { fs.mkdirSync(path.dirname(path.join(ROOT, to)), { recursive: true }); execFileSync('git', ['mv', '-k', '--', from, to], { cwd: ROOT }); } } const top = (obj, n) => Object.entries(obj) .sort((a, b) => b[1] - a[1]) .slice(0, n); const report = { mode: apply ? 'apply' : 'dry-run', filesChanged: changed.length, pathsRenamed: renames.length, pathCollisions: collisions, replacements: { casing: stats.casing, specific: stats.specific, protected: stats.protected }, protectedTop: top(stats.protectedHits, 40), notSwept: skipped, binariesWithNameInside: binaries, suspiciousTokens: top(suspicious, 60), renameSample: renames.slice(0, 12), }; process.stdout.write(`${JSON.stringify(report, null, 2)}\n`); } if (require.main === module) main(process.argv.slice(2)); module.exports = { transform };