Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -23,3 +23,5 @@ jobs:
run: node scripts/check-catalog-sync.cjs
- name: Deploy-home SSOT drift-guard
run: node scripts/check-deploy-home.cjs
- name: Localized README bold-link roster drift-guard (warn-only)
run: node scripts/check-readme-sync.cjs
2 changes: 1 addition & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -83,7 +83,7 @@ Two canonical docs, two registers: **[CLAUDE.md](./CLAUDE.md) is LLM + human dua
Before committing, confirm:

1. **SKILL.md** follows the [anatomy](#skillmd-format).
2. **[README](./README.md)** and its localized copies under [`docs/readme/`](./docs/readme/) list it — perspective group, invocation column, linked, in the perspective's logical order ([Layout](#layout)); the root README is the English source, and every translation keeps the `<sub>Read in: …</sub>` switcher with links rebased from its own directory. That order holds identically across `plugin.json`, [`scripts/catalog.cjs`](./scripts/catalog.cjs), and `re0-upgrade`'s catalog — a new skill or rename lands in place across all four surfaces with `reorder` (`check-catalog-sync` guards the set, not the order).
2. **[README](./README.md)** and its localized copies under [`docs/readme/`](./docs/readme/) list it — perspective group, invocation column, linked, in the perspective's logical order ([Layout](#layout)); the root README is the English source, and every translation keeps the `<sub>Read in: …</sub>` switcher with links rebased from its own directory. That order holds identically across `plugin.json`, [`scripts/catalog.cjs`](./scripts/catalog.cjs), and `re0-upgrade`'s catalog — a new skill or rename lands in place across all four surfaces with `reorder` (`check-catalog-sync` guards the set, not the order). Localized READMEs are downstream mirrors of the English source, and [`check-readme-sync`](./scripts/check-readme-sync.cjs) warns in CI when a translation's bold-link roster drifts from English on set or order — warn-only by default so a translation lag never blocks a merge; pass `--strict` to promote it to a failure.
3. **[plugin.json](./.claude-plugin/plugin.json)** registers its path.
4. **[package.json](./package.json)** bumps by **kind, not size**: against the artifact's own prior spec, was the old behavior *wrong* (a fix → patch, even with much new plumbing) or *correct but narrower* (a new capability a user reaches for → minor)? A skill removed with no replacement is major. State that answer before naming the bump; keep `keywords` grouped.
5. A **rename** appends its old → new row to [`re0-upgrade`](./skills/breadth/re0-upgrade/SKILL.md)'s deprecations, in release order.
Expand Down
105 changes: 105 additions & 0 deletions scripts/check-readme-sync.cjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,105 @@
#!/usr/bin/env node
'use strict';
/*
* README bold-link roster drift-guard (EN vs localized).
*
* The English README.md at repo root is the source of truth for the skill roster's presentation
* to human readers. The 10 localized copies under docs/readme/README.<lang>.md are hand-maintained
* translations that snapshotted at ba9ae6e and have received no content updates since — every
* skill added or renamed on the English side needs to be mirrored, and CLAUDE.md's shipping
* checklist (step 2) already requires it. This check enforces the mechanical half of that rule:
* each translation must list the same **[<skill>](path) bold-links as English, in the same order.
*
* Scope — intentionally narrow. This script verifies only the bold-link skill SET and ORDER; it
* does not judge translated prose, tone, or completeness. The "not the order" carve-out from
* check-catalog-sync applies to the four internal roster surfaces (plugin.json / catalog.cjs /
* re0-upgrade / EN README) which the maintainer holds by hand; localized READMEs are outside
* that boundary because they are downstream mirrors of the EN README, not peers of it.
*
* Default is warn-only (exit 0) so a translation lag never blocks CI. Pass --strict to fail on
* drift once the maintainer wants to promote enforcement.
*/

const fs = require('fs');
const path = require('path');

const ROOT = path.join(__dirname, '..');
const EN_PATH = path.join(ROOT, 'README.md');
const L10N_DIR = path.join(ROOT, 'docs', 'readme');
const STRICT = process.argv.includes('--strict');

// Bold-link skill token: **[name](path) — name is lowercase-alnum with hyphens.
// The path group anchors it to a real Markdown link so plain **[foo]** emphasis
// (no link target) is ignored. Fenced code blocks are stripped first to avoid
// matching example snippets that document the syntax.
const SKILL_RE = /\*\*\[([a-z][a-z0-9-]*)\]\([^)]+\)/g;

function stripFences(src) {
const lines = src.split('\n');
let inFence = false;
const out = [];
for (const line of lines) {
if (/^\s*```/.test(line)) { inFence = !inFence; continue; }
if (!inFence) out.push(line);
}
return out.join('\n');
}

function extract(file) {
const src = stripFences(fs.readFileSync(file, 'utf8'));
const seen = new Set();
const order = [];
for (const m of src.matchAll(SKILL_RE)) {
const name = m[1];
if (seen.has(name)) continue; // dedupe: switcher/footer relinks would double-count
seen.add(name);
order.push(name);
}
return order;
}

if (!fs.existsSync(EN_PATH)) {
console.error('::error::check-readme-sync: README.md not found at repo root');
process.exit(1);
}
if (!fs.existsSync(L10N_DIR)) {
console.error('::error::check-readme-sync: docs/readme/ not found');
process.exit(1);
}

const en = extract(EN_PATH);
const localized = fs.readdirSync(L10N_DIR)
.filter((f) => /^README\.[a-zA-Z-]+\.md$/.test(f))
.sort();

let anyDrift = false;
const report = [];

for (const f of localized) {
const local = extract(path.join(L10N_DIR, f));
const localSet = new Set(local);
const missing = en.filter((s) => !localSet.has(s));
const extras = local.filter((s) => !en.includes(s));
const enSubset = en.filter((s) => localSet.has(s));
const orderMismatch = enSubset.join(',') !== local.filter((s) => en.includes(s)).join(',');

if (missing.length || extras.length || orderMismatch) {
anyDrift = true;
const parts = [];
if (missing.length) parts.push(`${missing.length} missing (${missing.join(', ')})`);
if (extras.length) parts.push(`${extras.length} unknown (${extras.join(', ')})`);
if (orderMismatch) parts.push('order mismatch');
report.push(`${f}: ${parts.join('; ')}`);
}
}

const level = STRICT ? 'error' : 'warning';
if (anyDrift) {
console.error(`::${level}::README bold-link roster drift across ${localized.length} localizations (EN has ${en.length} skills)`);
for (const line of report) console.error(` ${line}`);
if (STRICT) process.exit(1);
console.error(' (warn-only; pass --strict to fail CI)');
process.exit(0);
}

console.log(`README sync OK: ${en.length} bold-link skills match across ${localized.length} localizations`);
116 changes: 116 additions & 0 deletions tests/check-readme-sync.test.cjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,116 @@
#!/usr/bin/env node
'use strict';
/*
* Integration tests for scripts/check-readme-sync.cjs.
*
* Uses a temp scratch repo with a synthetic README.md + docs/readme/*.md so the assertions
* pin the script's behavior without depending on the real paperthin state (which happens to
* be in sync at the time of writing). Four cases: normal, missing, order-swap, --strict.
*/

const fs = require('fs');
const os = require('os');
const path = require('path');
const { spawnSync } = require('child_process');

const SCRIPT = path.join(__dirname, '..', 'scripts', 'check-readme-sync.cjs');

function mkRepo() {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'paperthin-sync-'));
fs.mkdirSync(path.join(dir, 'scripts'));
fs.mkdirSync(path.join(dir, 'docs', 'readme'), { recursive: true });
fs.copyFileSync(SCRIPT, path.join(dir, 'scripts', 'check-readme-sync.cjs'));
return dir;
}

function boldLink(name) {
return `**[${name}](./skills/${name}/SKILL.md)**`;
}

function readmeWith(names, opts = {}) {
const rel = opts.rel || '.';
const lines = ['# Test README', ''];
for (const n of names) {
const link = `**[${n}](${rel}/skills/${n}/SKILL.md)**`;
lines.push(`| ${link} | desc | scope | invoker |`);
}
return lines.join('\n') + '\n';
}

function run(repo, args = []) {
return spawnSync('node', [path.join(repo, 'scripts', 'check-readme-sync.cjs'), ...args], {
cwd: repo,
encoding: 'utf8',
});
}

const cases = [];

// Case 1 — normal (all translations match EN)
cases.push(['normal', () => {
const dir = mkRepo();
const skills = ['re0', 'readchk', 'aim'];
fs.writeFileSync(path.join(dir, 'README.md'), readmeWith(skills));
for (const lang of ['ko', 'ja', 'de']) {
fs.writeFileSync(
path.join(dir, 'docs', 'readme', `README.${lang}.md`),
readmeWith(skills, { rel: '../..' })
);
}
const r = run(dir);
return r.status === 0 && /README sync OK: 3/.test(r.stdout);
}]);

// Case 2 — one translation missing a skill (warn-only, exit 0)
cases.push(['missing', () => {
const dir = mkRepo();
fs.writeFileSync(path.join(dir, 'README.md'), readmeWith(['re0', 'readchk', 'aim']));
fs.writeFileSync(
path.join(dir, 'docs', 'readme', 'README.ko.md'),
readmeWith(['re0', 'aim'], { rel: '../..' }) // missing readchk
);
const r = run(dir);
return r.status === 0
&& /1 missing \(readchk\)/.test(r.stderr)
&& /warn-only/.test(r.stderr);
}]);

// Case 3 — order mismatch (two skills swapped)
cases.push(['order-swap', () => {
const dir = mkRepo();
fs.writeFileSync(path.join(dir, 'README.md'), readmeWith(['re0', 'readchk', 'aim']));
fs.writeFileSync(
path.join(dir, 'docs', 'readme', 'README.ko.md'),
readmeWith(['re0', 'aim', 'readchk'], { rel: '../..' }) // swapped
);
const r = run(dir);
return r.status === 0 && /order mismatch/.test(r.stderr);
}]);

// Case 4 — --strict fails on drift with exit 1
cases.push(['strict-fails', () => {
const dir = mkRepo();
fs.writeFileSync(path.join(dir, 'README.md'), readmeWith(['re0', 'readchk', 'aim']));
fs.writeFileSync(
path.join(dir, 'docs', 'readme', 'README.ko.md'),
readmeWith(['re0', 'aim'], { rel: '../..' })
);
const r = run(dir, ['--strict']);
return r.status === 1 && /::error::/.test(r.stderr);
}]);

let pass = 0;
let fail = 0;
for (const [name, fn] of cases) {
try {
const ok = fn();
if (ok) { pass++; console.log(` ok ${name}`); }
else { fail++; console.error(` FAIL ${name}`); }
} catch (e) {
fail++;
console.error(` FAIL ${name}: ${e.message}`);
}
}

console.log(`\n${pass}/${cases.length} passed`);
process.exit(fail ? 1 : 0);