chore: add GSD -> MSD rename codemod and record the name
This commit is contained in:
10
README.md
10
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.
|
||||
|
||||
@@ -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
225
scripts/msd-rename.cjs
Normal 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 };
|
||||
Reference in New Issue
Block a user