chore(migration): standalone reference-rot rewriter
This commit is contained in:
parent
b59240ad32
commit
45322d297a
2 changed files with 144 additions and 0 deletions
|
|
@ -0,0 +1,97 @@
|
|||
#!/usr/bin/env node
|
||||
// Step 5 — Reference-rot rewriter. Operates on an extracted repo in $WORK/<key>.
|
||||
// 1. Replaces the "[Full disclosure →](../../README.md#ai-generated-code-disclosure)" footnote with
|
||||
// INLINE disclosure text (M13 — self-contained, no dangling cross-repo anchor; the monorepo root
|
||||
// README has no such section, so the link was dead even in-repo).
|
||||
// 2. Rewrites any remaining ../../README.md and ../../.claude-plugin/marketplace.json reference
|
||||
// (e.g. graceful-handoff README footer, linkedin-studio remediation docs) to the absolute catalog URL.
|
||||
// 3. Sets plugin.json `repository` (and package.json homepage/repository/bugs, when present) to the
|
||||
// standalone open/<plugin> HTTPS URL read from plugin-map.json.
|
||||
// ai-psychosis already points at open/ai-psychosis (verify-only no-op, M14). Idempotent: a second run
|
||||
// rewrites zero files. Reports every file it touched. NULL push (D8) — operates only inside $WORK/<key>.
|
||||
//
|
||||
// Usage: node 30-fix-references.mjs <target-key>
|
||||
import { promises as fs } from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { spawnSync } from 'node:child_process';
|
||||
|
||||
const here = path.dirname(fileURLToPath(import.meta.url));
|
||||
const MAP = path.join(here, 'plugin-map.json');
|
||||
const WORK = process.env.WORK || '/tmp/polyrepo-migration';
|
||||
const CATALOG = 'https://git.fromaitochitta.com/open/ktg-plugin-marketplace/src/branch/main';
|
||||
|
||||
const DISCLOSURE_LINK = /\[Full disclosure →\]\(\.\.\/\.\.\/README\.md#ai-generated-code-disclosure\)/g;
|
||||
const DISCLOSURE_INLINE = 'Every change is human-directed, reviewed, and validated before commit.';
|
||||
|
||||
const readJson = async (p) => JSON.parse(await fs.readFile(p, 'utf8'));
|
||||
const exists = async (p) => { try { await fs.access(p); return true; } catch { return false; } };
|
||||
|
||||
async function walkMd(dir, out = []) {
|
||||
for (const e of await fs.readdir(dir, { withFileTypes: true })) {
|
||||
if (e.name === '.git') continue;
|
||||
const full = path.join(dir, e.name);
|
||||
if (e.isDirectory()) await walkMd(full, out);
|
||||
else if (e.isFile() && e.name.endsWith('.md')) out.push(full);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const key = process.argv[2];
|
||||
if (!key) { console.error('usage: 30-fix-references.mjs <target-key>'); process.exit(2); }
|
||||
const map = await readJson(MAP);
|
||||
const t = map.targets[key];
|
||||
if (!t) { console.error(`unknown target ${key}`); process.exit(2); }
|
||||
const dest = path.join(WORK, key);
|
||||
|
||||
if (!(await exists(path.join(dest, '.git')))) {
|
||||
const r = spawnSync('bash', [path.join(here, '10-extract.sh'), key], { stdio: 'inherit', env: { ...process.env, WORK } });
|
||||
if (r.status !== 0) { console.error('extract failed'); process.exit(1); }
|
||||
}
|
||||
|
||||
const base = t.repo_url.replace(/\.git$/, ''); // https://git.fromaitochitta.com/open/<key>
|
||||
const changed = [];
|
||||
|
||||
// 1) + 2) markdown rewrites
|
||||
for (const file of await walkMd(dest)) {
|
||||
const orig = await fs.readFile(file, 'utf8');
|
||||
let next = orig.replace(DISCLOSURE_LINK, DISCLOSURE_INLINE);
|
||||
next = next.split('../../README.md').join(`${CATALOG}/README.md`);
|
||||
next = next.split('../../.claude-plugin/marketplace.json').join(`${CATALOG}/.claude-plugin/marketplace.json`);
|
||||
if (next !== orig) { await fs.writeFile(file, next, 'utf8'); changed.push(path.relative(dest, file)); }
|
||||
}
|
||||
|
||||
// 3a) plugin.json repository → standalone URL
|
||||
const pjPath = path.join(dest, '.claude-plugin', 'plugin.json');
|
||||
if (await exists(pjPath)) {
|
||||
const pj = await readJson(pjPath);
|
||||
const cur = pj.repository && typeof pj.repository === 'object' ? pj.repository.url : pj.repository;
|
||||
if (cur !== base) {
|
||||
if (pj.repository && typeof pj.repository === 'object') pj.repository.url = base;
|
||||
else pj.repository = base;
|
||||
await fs.writeFile(pjPath, JSON.stringify(pj, null, 2) + '\n', 'utf8');
|
||||
changed.push('.claude-plugin/plugin.json');
|
||||
}
|
||||
}
|
||||
|
||||
// 3b) package.json homepage/repository/bugs (when present)
|
||||
const pkgPath = path.join(dest, 'package.json');
|
||||
if (await exists(pkgPath)) {
|
||||
const pkg = await readJson(pkgPath);
|
||||
let touched = false;
|
||||
if (pkg.homepage !== base) { pkg.homepage = base; touched = true; }
|
||||
if (pkg.repository && typeof pkg.repository === 'object') {
|
||||
if (pkg.repository.url !== base) { pkg.repository.url = base; touched = true; }
|
||||
} else if (pkg.repository && pkg.repository !== base) { pkg.repository = base; touched = true; }
|
||||
if (pkg.bugs && typeof pkg.bugs === 'object' && pkg.bugs.url !== `${base}/issues`) {
|
||||
pkg.bugs.url = `${base}/issues`; touched = true;
|
||||
}
|
||||
if (touched) { await fs.writeFile(pkgPath, JSON.stringify(pkg, null, 2) + '\n', 'utf8'); changed.push('package.json'); }
|
||||
}
|
||||
|
||||
for (const f of changed) console.log(` rewrote ${f}`);
|
||||
console.log(`FIX-REFERENCES OK ${key} → rewrote ${changed.length} file(s)`);
|
||||
}
|
||||
|
||||
main().catch((e) => { console.error(`Error: ${e.message}`); process.exit(1); });
|
||||
|
|
@ -0,0 +1,47 @@
|
|||
// Step 5 test — against an extracted graceful-handoff: no ../../README.md remains, plugin.json
|
||||
// repository reconciled, and a second run is a no-op. Pattern: plugins/linkedin-studio/render/__tests__/*.test.mjs.
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { spawnSync } from 'node:child_process';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
const here = path.dirname(fileURLToPath(import.meta.url));
|
||||
const SCRIPT = path.join(here, '30-fix-references.mjs');
|
||||
const WORK = process.env.WORK || '/tmp/polyrepo-migration';
|
||||
const DEST = path.join(WORK, 'graceful-handoff');
|
||||
|
||||
const fix = () => spawnSync(process.execPath, [SCRIPT, 'graceful-handoff'], { encoding: 'utf8', env: { ...process.env, WORK } });
|
||||
|
||||
// Ensure a fixed state (self-heals extract if needed).
|
||||
{
|
||||
const r = fix();
|
||||
if (r.status !== 0) throw new Error(`fix-references failed: ${r.stdout}\n${r.stderr}`);
|
||||
}
|
||||
|
||||
const grepRel = (needle) =>
|
||||
spawnSync('grep', ['-rn', needle, DEST, '--include=*.md'], { encoding: 'utf8' });
|
||||
|
||||
test('no ../../README.md reference remains in any .md', () => {
|
||||
const r = grepRel('../../README.md');
|
||||
assert.equal(r.stdout.trim(), '', `unexpected ../../README.md references:\n${r.stdout}`);
|
||||
});
|
||||
|
||||
test('plugin.json repository points at the standalone repo', () => {
|
||||
const pj = JSON.parse(readFileSync(path.join(DEST, '.claude-plugin', 'plugin.json'), 'utf8'));
|
||||
const url = typeof pj.repository === 'object' ? pj.repository.url : pj.repository;
|
||||
assert.ok(url.endsWith('/open/graceful-handoff'), `repository is '${url}'`);
|
||||
});
|
||||
|
||||
test('disclosure footnote is inlined (no dangling cross-repo anchor)', () => {
|
||||
const readme = readFileSync(path.join(DEST, 'README.md'), 'utf8');
|
||||
assert.ok(!readme.includes('#ai-generated-code-disclosure'), 'the dangling disclosure anchor must be gone');
|
||||
assert.ok(readme.includes('AI-generated'), 'the disclosure statement itself is retained inline');
|
||||
});
|
||||
|
||||
test('a second run rewrites zero files (idempotent)', () => {
|
||||
const r = fix();
|
||||
assert.equal(r.status, 0, r.stderr);
|
||||
assert.match(r.stdout, /rewrote 0 file\(s\)/, `expected no-op, got:\n${r.stdout}`);
|
||||
});
|
||||
Loading…
Add table
Add a link
Reference in a new issue