-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathinventory.mjs
More file actions
172 lines (155 loc) · 7.75 KB
/
Copy pathinventory.mjs
File metadata and controls
172 lines (155 loc) · 7.75 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
/**
* ============================================================================
* inventory.mjs — 現状のファイル構成と不変条件を「コードから導出」して出す
* ============================================================================
*
* ★ なぜ生成物にするのか
* 構成や仕様を散文の md に手で書くと、必ずコードと食い違う。
* 食い違った時に困るのは「読んだ人が古い前提で判断する」こと。
* なのでここでは **コードを読んで毎回作り直す**。手で編集しない。
*
* ── 出すもの ────────────────────────────────────────────────────────────────
* 1. サブシステム(正典ファイル)ごとのファイル一覧・行数・エクスポート
* 2. 各ファイルが宣言している不変条件(@invariant)と、それを守るテスト(@test)
* 3. テスト本数と ★(退行防止テスト)の本数
*
* ── 書き方の約束 ────────────────────────────────────────────────────────────
* ソース側(関数の直上のコメント内):
* @canon <サブシステム名> … ファイル冒頭に1つ。正典ファイルに書く
* @invariant <破ってはいけない条件> … 何個でも
* @test <テスト名の一部> … 直前の @invariant を守るテスト
*
* `--check` を付けると、@test が実在するテストに当たらない場合に落ちる。
* つまり「不変条件を書いたのにテストが無い」を機械で防ぐ。
*
* ── 使い方 ──────────────────────────────────────────────────────────────────
* node scripts/inventory.mjs # 標準出力へ
* node scripts/inventory.mjs --check # 対応の欠けがあれば終了コード 1
* node scripts/inventory.mjs --write # docs/INVENTORY.md を作り直す
* ============================================================================
*/
import fs from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
const CHECK = process.argv.includes('--check');
const WRITE = process.argv.includes('--write');
const SRC_DIRS = ['core/src', 'extension/src', 'scripts'];
/**
* 不変条件を探す対象。scripts/ は道具であって製品の約束ではないので入れない
* (このファイル自身が説明文に @invariant と書いているので、入れると自分を誤検出する)。
*/
const INVARIANT_DIRS = ['core/src', 'extension/src'];
const TEST_DIR = 'core/test';
function walk(dir) {
const abs = path.join(ROOT, dir);
if (!fs.existsSync(abs)) return [];
return fs.readdirSync(abs, { withFileTypes: true }).flatMap((e) => {
const rel = `${dir}/${e.name}`;
if (e.isDirectory()) return walk(rel);
return e.name.endsWith('.js') || e.name.endsWith('.mjs') ? [rel] : [];
});
}
/** ファイルから構造を読む */
function analyze(rel) {
const src = fs.readFileSync(path.join(ROOT, rel), 'utf8');
const lines = src.split(/\r?\n/).length;
const exports = [...src.matchAll(/^export\s+(?:async\s+)?(?:function|const|class)\s+([A-Za-z0-9_]+)/gm)].map(
(m) => m[1],
);
const isProduct = INVARIANT_DIRS.some((d) => rel.startsWith(d + '/'));
const canon = isProduct ? (src.match(/@canon\s+(.+)/)?.[1]?.trim() ?? null) : null;
// 「@ invariant」… に続く「@ test」… を組にする(対象は製品コードだけ)
const invariants = [];
const re = /@invariant\s+(.+?)\s*$/gm;
if (!isProduct) return { rel, lines, exports, canon, invariants };
for (const m of src.matchAll(re)) {
const after = src.slice(m.index + m[0].length, m.index + m[0].length + 400);
const tests = [...after.matchAll(/@test\s+(.+?)\s*$/gm)]
.filter((t) => !after.slice(0, t.index).includes('@invariant'))
.map((t) => t[1].trim());
invariants.push({ text: m[1].trim(), tests, file: rel, line: src.slice(0, m.index).split(/\r?\n/).length });
}
return { rel, lines, exports, canon, invariants };
}
const files = SRC_DIRS.flatMap(walk).map(analyze);
// テスト名を全部集める
const testNames = [];
for (const f of fs.readdirSync(path.join(ROOT, TEST_DIR)).filter((n) => n.endsWith('.test.js'))) {
const src = fs.readFileSync(path.join(ROOT, TEST_DIR, f), 'utf8');
for (const m of src.matchAll(/^test\(\s*(['"])(.+?)\1/gm)) testNames.push({ file: f, name: m[2] });
}
const starCount = testNames.filter((t) => t.name.startsWith('★')).length;
// ---------------------------------------------------------------------------
const out = [];
const p = (s = '') => out.push(s);
p('# 構成インベントリ(自動生成 — 手で編集しない)');
p('');
p('`node scripts/inventory.mjs --write` で作り直す。');
p('内容はすべてコードから導出しているので、コードを変えればここも変わる。');
p('');
p(`- ソースファイル: ${files.length} 本`);
p(`- テスト: ${testNames.length} 件(うち退行防止 ★ が ${starCount} 件)`);
p(`- 宣言されている不変条件: ${files.reduce((n, f) => n + f.invariants.length, 0)} 件`);
p('');
p('## サブシステムと正典ファイル');
p('');
p('各サブシステムの仕様は、正典ファイル**冒頭のコメント**が唯一の定義。');
p('ここを読まずに触らないこと。');
p('');
p('| サブシステム | 正典ファイル |');
p('|---|---|');
for (const f of files.filter((x) => x.canon)) p(`| ${f.canon} | \`${f.rel}\` |`);
p('');
p('## ファイル一覧');
p('');
p('| ファイル | 行 | エクスポート |');
p('|---|---:|---|');
for (const f of files.sort((a, b) => a.rel.localeCompare(b.rel))) {
const ex = f.exports.length ? f.exports.join(', ') : '—';
p(`| \`${f.rel}\` | ${f.lines} | ${ex.length > 110 ? ex.slice(0, 107) + '…' : ex} |`);
}
p('');
p('## 不変条件 → それを守るテスト');
p('');
p('「破ると壊れる約束」と、それを機械で守っているテストの対応。');
p('テストの無い不変条件は約束していないのと同じなので、`--check` で落ちる。');
p('');
const missing = [];
for (const f of files) {
if (!f.invariants.length) continue;
p(`### \`${f.rel}\``);
p('');
for (const inv of f.invariants) {
p(`- **${inv.text}**(${f.rel}:${inv.line})`);
if (!inv.tests.length) {
p(' - ⚠️ 守るテストが宣言されていない');
missing.push({ ...inv, reason: '@test が無い' });
}
for (const t of inv.tests) {
const hit = testNames.find((x) => x.name.includes(t));
if (hit) p(` - ✅ \`${hit.name}\``);
else {
p(` - ⚠️ テストが見つからない: \`${t}\``);
missing.push({ ...inv, reason: `テスト "${t}" が存在しない` });
}
}
}
p('');
}
const text = out.join('\n');
if (WRITE) {
fs.mkdirSync(path.join(ROOT, 'docs'), { recursive: true });
fs.writeFileSync(path.join(ROOT, 'docs', 'INVENTORY.md'), text + '\n', 'utf8');
console.log('docs/INVENTORY.md を書き出しました。');
} else {
console.log(text);
}
if (CHECK) {
if (missing.length) {
console.error(`\n不変条件とテストの対応が ${missing.length} 件欠けています:`);
for (const m of missing) console.error(` ${m.file}:${m.line} ${m.text} — ${m.reason}`);
process.exit(1);
}
console.log('\n不変条件とテストの対応: 欠けなし');
}