-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathlint-docs.ts
More file actions
174 lines (151 loc) · 5.77 KB
/
Copy pathlint-docs.ts
File metadata and controls
174 lines (151 loc) · 5.77 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
173
174
// SPDX-License-Identifier: AGPL-3.0-or-later
// Copyright (c) 2025-2026 Matthew Kissinger
/**
* Doc-discipline linter.
*
* Enforces three rules from the 2026-05-09 Phase 0 realignment plan
* (`C:/Users/Mattm/.claude/plans/can-we-make-a-lexical-mitten.md`):
*
* 1. Every doc starts with `Last verified: YYYY-MM-DD` or
* `Last updated: YYYY-MM-DD` within the first 10 lines.
* 2. Top-level `docs/*.md` files SHOULD stay under 800 LOC (warn over 800,
* fail over 1500).
* 3. The canonical vision sentence appears verbatim in
* `docs/ROADMAP.md`. Other top-level docs claiming a NPC count must
* either include the qualifier or link to ROADMAP.md.
*
* Skips `docs/archive/**`, `docs/cycles/**`, `docs/tasks/archive/**`.
*
* Usage:
* npx tsx scripts/lint-docs.ts # warn-only run (exit 0 unless hard fail)
* npx tsx scripts/lint-docs.ts --strict # fail on any warning
*/
import { readdirSync, readFileSync, statSync } from 'node:fs';
import { join, relative } from 'node:path';
const repoRoot = process.cwd();
const docsRoot = join(repoRoot, 'docs');
const SKIP_PREFIXES = ['archive', 'cycles', 'tasks/archive', 'tasks\\archive'];
const SOFT_LOC_LIMIT = 800;
const HARD_LOC_LIMIT = 1500;
const DATE_RE = /^Last (verified|updated):\s*\d{4}-\d{2}-\d{2}/m;
/**
* Grandfather list (relative posix paths from repo root). These docs were
* already failing the date-header or LOC rules at Phase 0 install time.
* Phase 1 of the realignment plan moves / splits / dates them; new doc
* authors cannot add to this list without orchestrator note.
*/
const GRANDFATHER_LOC: Set<string> = new Set([
// docs/PERFORMANCE.md split into docs/perf/ in cycle-2026-05-09-doc-decomposition-and-wiring (Phase 1).
// docs/STATE_OF_REPO.md split into docs/state/ in cycle-2026-05-09-doc-decomposition-and-wiring (Phase 1).
'docs/archive/FLIGHT_REBUILD_ORCHESTRATION.md', // 1160 LOC; archived 2026-05-13
]);
function relPosix(absPath: string): string {
return relative(repoRoot, absPath).replace(/\\/g, '/');
}
const CANONICAL_VISION_SUBSTR =
'3,000 combatants via materialization tiers; live-fire combat verified at 120';
interface Finding {
file: string;
level: 'warn' | 'fail';
rule: string;
message: string;
}
const findings: Finding[] = [];
function shouldSkip(rel: string): boolean {
const normalized = rel.replace(/\\/g, '/');
return SKIP_PREFIXES.some((p) => normalized.startsWith(`${p}/`) || normalized.startsWith(p));
}
function walk(dir: string): string[] {
const out: string[] = [];
for (const entry of readdirSync(dir)) {
const abs = join(dir, entry);
const rel = relative(docsRoot, abs);
if (shouldSkip(rel)) continue;
const st = statSync(abs);
if (st.isDirectory()) {
out.push(...walk(abs));
} else if (entry.endsWith('.md')) {
out.push(abs);
}
}
return out;
}
function checkDateHeader(file: string, content: string): void {
// After the 2026-05-10 status-mirror consolidation, only the canonical status
// source (docs/DIRECTIVES.md) carries a `Last verified:` header — it was
// intentionally stripped from every other doc. Enforce the header only there.
const rel = relative(docsRoot, file).replace(/\\/g, '/');
if (rel !== 'DIRECTIVES.md') return;
const head = content.split(/\r?\n/).slice(0, 10).join('\n');
if (!DATE_RE.test(head)) {
findings.push({
file,
level: 'fail',
rule: 'date-header',
message: 'docs/DIRECTIVES.md must carry `Last verified: YYYY-MM-DD` (canonical status source).',
});
}
}
function checkLocBudget(file: string, content: string): void {
const loc = content.split(/\r?\n/).length;
const grandfathered = GRANDFATHER_LOC.has(relPosix(file));
if (loc > HARD_LOC_LIMIT) {
findings.push({
file,
level: grandfathered ? 'warn' : 'fail',
rule: grandfathered ? 'loc-hard-limit [grandfathered]' : 'loc-hard-limit',
message: `${loc} LOC exceeds hard limit ${HARD_LOC_LIMIT}; split into a docs/<topic>/ subdir.`,
});
} else if (loc > SOFT_LOC_LIMIT) {
findings.push({
file,
level: 'warn',
rule: 'loc-soft-limit',
message: `${loc} LOC exceeds soft limit ${SOFT_LOC_LIMIT}; consider splitting.`,
});
}
}
function checkVisionStatementInRoadmap(file: string, content: string): void {
const rel = relative(docsRoot, file).replace(/\\/g, '/');
if (rel !== 'ROADMAP.md') return;
if (!content.includes(CANONICAL_VISION_SUBSTR)) {
findings.push({
file,
level: 'fail',
rule: 'canonical-vision',
message:
'docs/ROADMAP.md must contain the canonical vision sentence verbatim. ' +
'Search for: "3,000 combatants via materialization tiers; live-fire combat verified at 120".',
});
}
}
function main(): void {
const strict = process.argv.includes('--strict');
let files: string[];
try {
files = walk(docsRoot);
} catch (err) {
console.error(`[lint-docs] could not read ${docsRoot}: ${(err as Error).message}`);
process.exit(2);
}
for (const file of files) {
const content = readFileSync(file, 'utf8');
checkDateHeader(file, content);
checkLocBudget(file, content);
checkVisionStatementInRoadmap(file, content);
}
if (findings.length === 0) {
console.log(`[lint-docs] OK — ${files.length} docs checked.`);
return;
}
const warns = findings.filter((f) => f.level === 'warn');
const fails = findings.filter((f) => f.level === 'fail');
for (const f of findings) {
const rel = relative(repoRoot, f.file).replace(/\\/g, '/');
console.log(`[${f.level.toUpperCase()}] ${rel} (${f.rule}): ${f.message}`);
}
console.log(`\n[lint-docs] ${files.length} docs checked, ${warns.length} warnings, ${fails.length} failures.`);
if (fails.length > 0) process.exit(1);
if (strict && warns.length > 0) process.exit(1);
}
main();