226 lines
7.6 KiB
JavaScript
226 lines
7.6 KiB
JavaScript
#!/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 };
|