Skip to content
Merged
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
25 changes: 25 additions & 0 deletions .github/workflows/docs-lint.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
name: docs-lint

on:
push:
branches: [master]
paths:
- "**.md"
- "tools/**"
- ".github/workflows/docs-lint.yml"
pull_request:
branches: [master, develop]
paths:
- "**.md"
- "tools/**"
- ".github/workflows/docs-lint.yml"

jobs:
docs-lint:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
with:
node-version: "22"
- run: node tools/docs-lint.mjs
9 changes: 8 additions & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,16 +5,23 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co
## Commands

```bash
# Run all checks (dump-autoload + ide-helper + rector + phpstan + pint)
# Run all checks (dump-autoload + ide-helper + rector + phpstan + pint + docs-lint)
composer run all

composer run pint # Laravel Pint (format)
composer run pint:check # Pint dry-run
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)
```

## Architecture

ドキュメントは [docs/README.md](docs/README.md) の規約に従う。
生きた文書は allowlist 制(`tools/docs-policy.json`)。記録は `docs/records/` に日付付きで不変。

## グローバル規約

`~/.claude/CLAUDE.md` のポリシーに従う。パッケージ更新は `/repo-maintenance`、Dependabot PR 整理は `/dependabot-maintenance`、複数エージェント作業は `/orchestrate` スキルを使う。
6 changes: 5 additions & 1 deletion composer.json
Original file line number Diff line number Diff line change
Expand Up @@ -62,7 +62,8 @@
"@php artisan ide-helper:models -WR",
"@rector",
"@stan",
"@pint"
"@pint",
"@docs"
],
"pint": [
"@php ./vendor/bin/pint"
Expand All @@ -75,6 +76,9 @@
],
"rector": [
"@php ./vendor/bin/rector --no-diffs"
],
"docs": [
"node tools/docs-lint.mjs"
]
},
"extra": {
Expand Down
83 changes: 83 additions & 0 deletions docs/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
# ドキュメント規約

このリポジトリのドキュメントは「実装とテストが SSOT(唯一の真実)」を前提に、
md として残すものを次の3種類に限定する。この規約は `tools/docs-lint.mjs` で機械検証される。

## 原則

1. コードを読めば分かることは書かない。
2. 残すのは「意思決定の理由」(ADR)と「過去時点の記録」(`docs/records/`)だけ。どちらも書いたら変更しない。
3. 変化する事実(ステータス・日付・採番)は台帳に集約し、md 本文には書かない。
4. 手動維持の索引は持たない。一覧は `ls docs/records/`(日付順に並ぶ)で得る。

## 「実装が SSOT」を削除の言い訳にする前に

「コードを読めば分かる」は思い込みで成立していないことがある。実在するクラス名を
含みながら内部構造・メソッドシグネチャが実装と全く異なる「一見実装がありそうで
実は架空」のケースが、他リポジトリへの実地移行で複数見つかっている。現在形の
spec 相当文書を削除する前に:

1. **対応する実装が実在するか確認する**。存在しなければ削除しない。`docs/records/` へ
日付付きで凍結する。
2. **内容が実装と一致するか確認する**。クラス名が実在するだけでは不十分。文書が挙げる
具体例のうち最低1つは実際のソースファイルを開き、クラス構成・メソッドシグネチャ・
データ構造まで突き合わせる。
3. **テストが仕様の主張を実際に検証しているか確認する**。記述量に対してテストケース数が
見合っているか照合する。名ばかりのテストしかない場合は、テスト拡充が先か、当面は
生きた文書として残すかを判断する。
4. **原理的にテストが検出できない契約でないか確認する**。外部フォーマット/外部APIの
reverse-engineering 文書や、自動検証のない人手同期の契約は、生きた文書 allowlist の
正当な例外として認める。

## 分類と置き場所

### 不変記録 — docs/records/

- 命名: `YYYY-MM-DD_slug.md`(例: `2026-06-22_assurance-audit.md`)
- 対象: 調査メモ / 作業ログ / postmortem / 実験結果
- 作成後は変更しない。内容を更新したくなったら**新しい日付で新規作成**し、
旧ファイル冒頭に `> Superseded by:` + 新記録への相対リンク、の1行だけを追記する。
もう1つ許可される編集は、参照先ファイルが移動・削除された際の**リンクパスのみの追従**
(主張・内容は変えず、リンク先を現在の場所や後継 ADR に向け直すだけ)。内容の書き換えは
一切許可しない。

### 意思決定記録 — docs/adr/

- 命名: `NNNN-slug.md`(連番4桁、例: `0001-use-sqlite.md`)
- 「なぜ」だけを書く。「どうなっているか」は実装を参照させる。
- ステータス行 `> ステータス: Accepted (YYYY-MM-DD)` が必須(lint 検査対象)。
覆すときは新 ADR を書き、旧 ADR のステータスを `Superseded by ADR-NNNN` に変える。

### 台帳 — docs/dependency-debt.md, docs/known-risks.md

- 変化する事実の SSOT。行の追加・更新・削除が正規の運用。
- [dependency-debt.md](dependency-debt.md) のスキーマは変更禁止(`/dependabot-maintenance` スキル互換)。
- [known-risks.md](known-risks.md) は解消しても行を削除せず、Status とテスト欄を更新して
履歴として残す運用(dependency-debt.md の delete-on-resolve とは異なる)。初回診断の根拠は
[records/2026-06-22_assurance-audit.md](records/2026-06-22_assurance-audit.md) に凍結済み。
- 新しい台帳を作るには `tools/docs-policy.json` の `ledgers` への登録が必要。

### 生きた文書 — allowlist 制

- `tools/docs-policy.json` の `livingDocs` に列挙されたファイルのみ許可。現在の一覧:
`README.md` / `CLAUDE.md` / `docs/README.md`
- allowlist へ追加する場合は、追加理由を ADR として残すこと。
- 生きた文書は「常に現在を反映する義務」を負う。義務を果たせない文書は
records 化(日付を付けて凍結)するか削除する。

## 禁止事項(lint がエラーにする)

- allowlist 外の「現在形 md」を docs/ やルートに置くこと
- `temp` / `tmp` / `draft` / `wip` という名前のディレクトリ(下書きは PR 説明・issue・セッションの scratchpad へ)
- `INDEX.md` / `index.md` / `TODO.md` というファイル名(索引は持たない、タスクは台帳か issue へ)
- リポジトリ外への絶対パスリンク(`~/`、ドライブレター、`file://`)
- 壊れた相対リンク

## 検証

```bash
node tools/docs-lint.mjs
```

CI(`.github/workflows/docs-lint.yml`)でも同じものが走る。`composer run docs`(`composer run all` にも
含まれる)からも実行できる。
2 changes: 1 addition & 1 deletion docs/known-risks.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@
- **制御**: None(無し)/ Detective(事後に検知・通知のみ)/ Preventive(事前に阻止)
- **Status**: 🔴 Missing/Stale/Weak ・ 🟡 Structural Weakness/SPOF ・ 🟢 OK
- 是正が完了したら Status とテスト欄を更新する(行は削除せず履歴として残す)。
- 初回診断の根拠は [assurance-audit-2026-06-22.md](assurance-audit-2026-06-22.md) を参照。
- 初回診断の根拠は [records/2026-06-22_assurance-audit.md](records/2026-06-22_assurance-audit.md) を参照。

## A. Scrape / Extract パイプライン

Expand Down
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
# Assurance Audit スナップショット(2026-06-22)

これは初回の Assurance Audit(assurance-audit スキル)の診断記録。
「現在の状態」は [known-risks.md](known-risks.md) を参照。本ファイルは**初回診断の根拠**を残すための凍結記録。
「現在の状態」は [../known-risks.md](../known-risks.md) を参照。本ファイルは**初回診断の根拠**を残すための凍結記録。

採点モデル: `Intent → Behavior → Control → Evidence`。
Coverage(量)ではなく **Confidence(守られているか)** を採点する。
Expand All @@ -12,7 +12,7 @@ Coverage(量)ではなく **Confidence(守られているか)** を採
## Step -1 所見(最優先)

**脅威モデル・既知リスク・runbook が一切存在しない。** `docs/dependency-debt.md` は依存負債のみ。
「何を守るべきか」の台帳が無いこと自体が最大の欠陥。本監査を機に [known-risks.md](known-risks.md) を新設した。
「何を守るべきか」の台帳が無いこと自体が最大の欠陥。本監査を機に [known-risks.md](../known-risks.md) を新設した。

## New Candidate Risks(脅威探索で発見、既存台帳に無かった項目)

Expand Down
181 changes: 181 additions & 0 deletions tools/docs-lint.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,181 @@
#!/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);
23 changes: 23 additions & 0 deletions tools/docs-policy.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
{
"$comment": "docs-lint.mjs が読む機械可読ポリシー。生きた文書を増やす場合は livingDocs に追記し、理由を ADR として残すこと。",
"scanRoots": {
"recursive": ["docs"],
"flat": ["."]
},
"livingDocs": [
"README.md",
"CLAUDE.md",
"docs/README.md"
],
"ledgers": [
"docs/dependency-debt.md",
"docs/known-risks.md"
],
"recordDirs": ["docs/records"],
"adrDir": "docs/adr",
"templateDir": "docs/templates",
"forbiddenFileNames": ["INDEX.md", "index.md", "TODO.md"],
"forbiddenDirNames": ["temp", "tmp", "draft", "wip"],
"dependencyDebtHeader": "| Package | Current | Target | Blocker | Type | Revisit condition | Recorded |",
"dependencyDebtTypes": ["temporary", "infra", "behavior-change"]
}
Loading