From fe069b2a561823edf1cbcb76e17684902679b598 Mon Sep 17 00:00:00 2001 From: Jakub Zych Date: Tue, 6 Oct 2026 01:03:26 +0200 Subject: [PATCH] chore: add GSD -> MSD rename codemod and record the name --- README.md | 10 ++ package.json | 1 + scripts/msd-rename.cjs | 225 +++++++++++++++++++++++++++++++++++++++++ 3 files changed, 236 insertions(+) create mode 100644 scripts/msd-rename.cjs diff --git a/README.md b/README.md index 827703331..f79460908 100644 --- a/README.md +++ b/README.md @@ -115,6 +115,16 @@ Troubleshooting? See [docs/how-to/recover-and-troubleshoot.md](docs/how-to/recov --- +## What MSD stands for + +**Make Software Done.** The runners-up, preserved for posterity: + +- Mostly Sane Development +- Minimum Suffering Delivery +- Merge, Ship, Disappear + +--- + ## License MIT License. See [LICENSE](LICENSE) for details. diff --git a/package.json b/package.json index 292dba2af..6a568842f 100644 --- a/package.json +++ b/package.json @@ -29,6 +29,7 @@ "!scripts/run-affected-tests.cjs", "!scripts/lint-no-adhoc-regex-escape.cjs", "!scripts/lint-allow-test-rule-refs.cjs", + "!scripts/msd-rename.cjs", "pi", "vscode" ], diff --git a/scripts/msd-rename.cjs b/scripts/msd-rename.cjs new file mode 100644 index 000000000..b591c17db --- /dev/null +++ b/scripts/msd-rename.cjs @@ -0,0 +1,225 @@ +#!/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): + * 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; + +const SPECIFIC = [ + [/@opengsd\/gsd-core/g, '@golem15/msd-core'], + [/%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, 'golem15com/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\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 };