chore: add GSD -> MSD rename codemod and record the name

This commit is contained in:
Jakub Zych
2026-10-06 01:03:26 +02:00
parent c8ec304684
commit fe069b2a56
3 changed files with 236 additions and 0 deletions

View File

@@ -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.

View File

@@ -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"
],

225
scripts/msd-rename.cjs Normal file
View File

@@ -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 };