From c09acb748968464598872df0fb14aa3b10b02a55 Mon Sep 17 00:00:00 2001 From: 128Na Date: Wed, 26 Aug 2026 17:52:26 +0900 Subject: [PATCH] =?UTF-8?q?docs-lint=20=E3=82=92=E3=83=AD=E3=83=BC?= =?UTF-8?q?=E3=82=AB=E3=83=AB=E3=82=B3=E3=83=94=E3=83=BC=E3=81=8B=E3=82=89?= =?UTF-8?q?=E7=8B=AC=E7=AB=8B=E3=83=91=E3=83=83=E3=82=B1=E3=83=BC=E3=82=B8?= =?UTF-8?q?=E4=BE=9D=E5=AD=98=E3=81=AB=E7=BD=AE=E3=81=8D=E6=8F=9B=E3=81=88?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 7リポジトリに tools/docs-lint.mjs を個別コピーして運用した結果、 同じバグが複数リポジトリで独立に再発見・再修正される重複管理問題が 発生したため、正式版パッケージ (github:128na/docs-lint#v1.0.0) に 一本化する。package.json を持たないリポジトリのため npm 依存としては 追加せず、composer scripts・CI から npx --yes github:128na/docs-lint#v1.0.0 で直接呼び出す。tools/docs-policy.json(設定ファイル)は変更せず維持。 置き換え後の出力は旧ローカルコピーと完全一致(6 files scanned, 0 error(s), 0 warning(s))を確認済み。 --- .github/workflows/docs-lint.yml | 2 +- CLAUDE.md | 2 +- composer.json | 2 +- docs/README.md | 8 +- tools/docs-lint.mjs | 181 -------------------------------- tools/docs-policy.json | 2 +- 6 files changed, 9 insertions(+), 188 deletions(-) delete mode 100644 tools/docs-lint.mjs diff --git a/.github/workflows/docs-lint.yml b/.github/workflows/docs-lint.yml index d3e120b..b919737 100644 --- a/.github/workflows/docs-lint.yml +++ b/.github/workflows/docs-lint.yml @@ -22,4 +22,4 @@ jobs: - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 with: node-version: "22" - - run: node tools/docs-lint.mjs + - run: npx --yes github:128na/docs-lint#v1.0.0 diff --git a/CLAUDE.md b/CLAUDE.md index a6a99c3..7a92938 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -14,7 +14,7 @@ composer run stan # PHPStan composer run rector # Rector (automated refactoring, dry-run by default) php artisan test # PHPUnit -composer run docs # docs-lint (node tools/docs-lint.mjs, requires Node.js) +composer run docs # docs-lint (npx github:128na/docs-lint#v1.0.0, requires Node.js) ``` ## Architecture diff --git a/composer.json b/composer.json index fba5697..da4c46e 100644 --- a/composer.json +++ b/composer.json @@ -78,7 +78,7 @@ "@php ./vendor/bin/rector --no-diffs" ], "docs": [ - "node tools/docs-lint.mjs" + "npx --yes github:128na/docs-lint#v1.0.0" ] }, "extra": { diff --git a/docs/README.md b/docs/README.md index f9bd607..2b87467 100644 --- a/docs/README.md +++ b/docs/README.md @@ -1,7 +1,7 @@ # ドキュメント規約 このリポジトリのドキュメントは「実装とテストが SSOT(唯一の真実)」を前提に、 -md として残すものを次の3種類に限定する。この規約は `tools/docs-lint.mjs` で機械検証される。 +md として残すものを次の3種類に限定する。この規約は [docs-lint](https://github.com/128na/docs-lint) で機械検証される。 ## 原則 @@ -76,8 +76,10 @@ spec 相当文書を削除する前に: ## 検証 ```bash -node tools/docs-lint.mjs +npx --yes github:128na/docs-lint#v1.0.0 ``` CI(`.github/workflows/docs-lint.yml`)でも同じものが走る。`composer run docs`(`composer run all` にも -含まれる)からも実行できる。 +含まれる)からも実行できる。実体は [docs-lint](https://github.com/128na/docs-lint) +パッケージ(複数リポジトリで共有、タグ `v1.0.0` 固定)。設定ファイルは引き続き +`tools/docs-policy.json`。 diff --git a/tools/docs-lint.mjs b/tools/docs-lint.mjs deleted file mode 100644 index e9f7067..0000000 --- a/tools/docs-lint.mjs +++ /dev/null @@ -1,181 +0,0 @@ -#!/usr/bin/env node -// docs-lint: ドキュメント規約(docs/README.md)の機械検証。依存ゼロ・Node 標準モジュールのみ。 -// 使い方: node tools/docs-lint.mjs (リポジトリルートで実行。exit 0 = green / 1 = error あり) -import fs from "node:fs"; -import path from "node:path"; -import { fileURLToPath } from "node:url"; - -const toolsDir = path.dirname(fileURLToPath(import.meta.url)); -const repoRoot = path.dirname(toolsDir); -const policy = JSON.parse(fs.readFileSync(path.join(toolsDir, "docs-policy.json"), "utf8")); - -const errors = []; -const warns = []; -const rel = (p) => path.relative(repoRoot, p).replaceAll("\\", "/"); -const error = (file, msg) => errors.push(`ERROR ${rel(file)}: ${msg}`); -const warn = (file, msg) => warns.push(`WARN ${rel(file)}: ${msg}`); - -// ---- 走査 ------------------------------------------------------------- -const SKIP_DIRS = new Set([".git", "node_modules", ".github", ".claude", ".idea", ".vscode"]); -const mdFiles = []; // { abs, relPath, kind } - -function walk(dir) { - for (const entry of fs.readdirSync(dir, { withFileTypes: true })) { - const abs = path.join(dir, entry.name); - if (entry.isDirectory()) { - if (SKIP_DIRS.has(entry.name)) continue; - if (policy.forbiddenDirNames.includes(entry.name.toLowerCase())) { - error(abs, `禁止されたディレクトリ名です(${entry.name})。下書きは PR 説明や scratchpad へ、残す価値があるなら docs/records/ に日付付きで置く`); - } - walk(abs); - } else if (entry.name.endsWith(".md")) { - mdFiles.push({ abs, relPath: rel(abs) }); - } - } -} -for (const root of policy.scanRoots.recursive) { - const abs = path.join(repoRoot, root); - if (fs.existsSync(abs)) walk(abs); -} -for (const root of policy.scanRoots.flat) { - const abs = path.join(repoRoot, root); - for (const entry of fs.readdirSync(abs, { withFileTypes: true })) { - if (entry.isFile() && entry.name.endsWith(".md")) { - mdFiles.push({ abs: path.join(abs, entry.name), relPath: rel(path.join(abs, entry.name)) }); - } - } -} - -// ---- 分類(allowlist 検査) ------------------------------------------ -const inDir = (relPath, dir) => relPath.startsWith(dir.replaceAll("\\", "/") + "/"); -for (const f of mdFiles) { - if (policy.forbiddenFileNames.includes(path.basename(f.relPath))) { - error(f.abs, `禁止されたファイル名です。手動索引や TODO.md は持たない(一覧は ls docs/records/、タスクは台帳か issue へ)`); - f.kind = "forbidden"; - } else if (policy.livingDocs.includes(f.relPath)) f.kind = "living"; - else if (policy.ledgers.includes(f.relPath)) f.kind = "ledger"; - else if (policy.recordDirs.some((d) => inDir(f.relPath, d))) f.kind = "record"; - else if (inDir(f.relPath, policy.adrDir)) f.kind = "adr"; - else if (inDir(f.relPath, policy.templateDir)) f.kind = "template"; - else { - f.kind = "unclassified"; - error(f.abs, `分類できない md です。records/ADR/台帳のいずれかに置くか、生きた文書として tools/docs-policy.json の livingDocs に登録(+理由を ADR 化)する`); - } -} - -// ---- records 命名 ----------------------------------------------------- -const RECORD_NAME = /^(\d{4})-(\d{2})-(\d{2})_[a-z0-9-]+\.md$/; -const now = new Date(); -const today = `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, "0")}-${String(now.getDate()).padStart(2, "0")}`; -// ローカル日付で判定する(UTCだとJST等UTC+では日付が変わる前0〜9時台に today 扱いの記録が「未来」誤判定される) -for (const f of mdFiles.filter((x) => x.kind === "record")) { - const m = path.basename(f.relPath).match(RECORD_NAME); - if (!m) { - error(f.abs, "records の命名は YYYY-MM-DD_slug.md(slug は小文字英数とハイフン)"); - continue; - } - const [, y, mo, d] = m; - const dt = new Date(`${y}-${mo}-${d}T00:00:00Z`); - if (Number.isNaN(dt.getTime()) || dt.toISOString().slice(0, 10) !== `${y}-${mo}-${d}`) { - error(f.abs, `実在しない日付です(${y}-${mo}-${d})`); - } else if (`${y}-${mo}-${d}` > today) { - error(f.abs, `未来の日付です(${y}-${mo}-${d})`); - } -} - -// ---- ADR 命名 + ステータス行 ----------------------------------------- -const ADR_NAME = /^(\d{4})-[a-z0-9-]+\.md$/; -const adrNumbers = new Map(); -for (const f of mdFiles.filter((x) => x.kind === "adr")) { - const m = path.basename(f.relPath).match(ADR_NAME); - if (!m) { - error(f.abs, "ADR の命名は NNNN-slug.md(連番4桁 + 小文字英数とハイフン)"); - continue; - } - if (adrNumbers.has(m[1])) { - error(f.abs, `ADR 番号 ${m[1]} が重複しています(${adrNumbers.get(m[1])})`); - } else { - adrNumbers.set(m[1], f.relPath); - } - const body = fs.readFileSync(f.abs, "utf8"); - if (!/^> ステータス: /m.test(body)) { - error(f.abs, "「> ステータス: Accepted (YYYY-MM-DD)」形式のステータス行が必要です"); - } -} - -// ---- リンク検査 ------------------------------------------------------- -const LINK = /\[[^\]]*\]\(([^)\s]+)(?:\s+"[^"]*")?\)/g; -for (const f of mdFiles) { - if (f.kind === "template" || f.kind === "forbidden") continue; - const body = fs.readFileSync(f.abs, "utf8"); - for (const m of body.matchAll(LINK)) { - const target = m[1]; - if (/^(https?|mailto):/.test(target) || target.startsWith("#")) continue; - if (target.startsWith("~") || /^[A-Za-z]:[\\/]/.test(target) || target.startsWith("file://") || target.startsWith("/")) { - error(f.abs, `リポジトリ外への絶対パスリンクは禁止(${target})。リポジトリ内の相対リンクにするか、経緯なら records に書く`); - continue; - } - const resolved = path.resolve(path.dirname(f.abs), target.split("#")[0]); - if (!fs.existsSync(resolved)) { - error(f.abs, `リンク切れ: ${target}`); - } - } -} - -// ---- 台帳スキーマ ----------------------------------------------------- -const depDebt = path.join(repoRoot, "docs", "dependency-debt.md"); -if (fs.existsSync(depDebt)) { - const lines = fs.readFileSync(depDebt, "utf8").split(/\r?\n/); - const headerIdx = lines.findIndex((l) => l.trim() === policy.dependencyDebtHeader); - if (headerIdx === -1) { - error(depDebt, `ヘッダ行が規定と一致しません。/dependabot-maintenance 互換のため次を維持: ${policy.dependencyDebtHeader}`); - } else { - for (let i = headerIdx + 2; i < lines.length; i++) { - const line = lines[i].trim(); - if (!line.startsWith("|")) break; - const cells = line.split("|").map((c) => c.trim()); - const type = cells[5]; - if (type && !policy.dependencyDebtTypes.includes(type)) { - error(depDebt, `L${i + 1}: Type「${type}」は不正。許可値: ${policy.dependencyDebtTypes.join(" / ")}`); - } - } - } -} -const consDebt = path.join(repoRoot, "docs", "consistency-debt.md"); -if (fs.existsSync(consDebt)) { - const seen = new Map(); - const lines = fs.readFileSync(consDebt, "utf8").split(/\r?\n/); - lines.forEach((line, i) => { - const m = line.match(/^\|\s*(CD-\d+)\s*\|/); - if (!m) return; - if (seen.has(m[1])) error(consDebt, `L${i + 1}: ${m[1]} が重複しています(初出 L${seen.get(m[1])})`); - else seen.set(m[1], i + 1); - }); -} - -// ---- 生きた文書のパス参照検査(warn) -------------------------------- -const PATHLIKE = /`((?:docs|tools|src|scripts|tests)\/[^`\s]+)`/g; -for (const f of mdFiles.filter((x) => x.kind === "living")) { - const body = fs.readFileSync(f.abs, "utf8"); - for (const m of body.matchAll(PATHLIKE)) { - const token = m[1]; - if (/[*{}<>()?$]|YYYY|NNNN|\.\.\./.test(token)) continue; // プレースホルダはスキップ - if (!fs.existsSync(path.join(repoRoot, token))) { - warn(f.abs, `参照パスが見つかりません: ${token}(リネーム未追随の可能性)`); - } - } -} - -// ---- テンプレマーカー残存(warn) ------------------------------------ -for (const f of mdFiles) { - if (f.kind === "template") continue; - const body = fs.readFileSync(f.abs, "utf8"); - const count = (body.match(/TODO\(template\):/g) || []).length; - if (count > 0) warn(f.abs, `TODO(template) マーカーが ${count} 件残っています`); -} - -// ---- 出力 ------------------------------------------------------------- -for (const line of errors) console.error(line); -for (const line of warns) console.log(line); -console.log(`docs-lint: ${mdFiles.length} files scanned, ${errors.length} error(s), ${warns.length} warning(s)`); -process.exit(errors.length > 0 ? 1 : 0); diff --git a/tools/docs-policy.json b/tools/docs-policy.json index c652174..3664bdc 100644 --- a/tools/docs-policy.json +++ b/tools/docs-policy.json @@ -1,5 +1,5 @@ { - "$comment": "docs-lint.mjs が読む機械可読ポリシー。生きた文書を増やす場合は livingDocs に追記し、理由を ADR として残すこと。", + "$comment": "docs-lint(github:128na/docs-lint)が読む機械可読ポリシー。生きた文書を増やす場合は livingDocs に追記し、理由を ADR として残すこと。", "scanRoots": { "recursive": ["docs"], "flat": ["."]