diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index daacff8..5162bf8 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -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 diff --git a/CLAUDE.md b/CLAUDE.md index c5257b1..de9fa19 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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 `Read in: …` 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 `Read in: …` 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. diff --git a/scripts/check-readme-sync.cjs b/scripts/check-readme-sync.cjs new file mode 100755 index 0000000..a0907d3 --- /dev/null +++ b/scripts/check-readme-sync.cjs @@ -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..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 **[](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`); diff --git a/tests/check-readme-sync.test.cjs b/tests/check-readme-sync.test.cjs new file mode 100644 index 0000000..c2d9fb2 --- /dev/null +++ b/tests/check-readme-sync.test.cjs @@ -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);