Repository navigation
Expand file tree
/
Copy pathcheck-arch-docs.mjs
More file actions
161 lines (147 loc) · 5.24 KB
/
Copy pathcheck-arch-docs.mjs
File metadata and controls
161 lines (147 loc) · 5.24 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
import { execSync } from 'node:child_process';
import { readFileSync, readdirSync, existsSync } from 'node:fs';
import { join, relative, sep } from 'node:path';
import { fileURLToPath } from 'node:url';
const ROOT = fileURLToPath(new URL('../../', import.meta.url));
const DOCS_DIR = join(ROOT, 'docs', 'subsystems');
export function parseOwnedPaths(docPath) {
const text = readFileSync(docPath, 'utf8');
const lines = text.split(/\r?\n/);
let inFm = false;
let inOwned = false;
const owned = [];
let subsystem = null;
for (const line of lines) {
if (line.trim() === '---') {
if (!inFm) { inFm = true; continue; }
break;
}
if (!inFm) continue;
const sub = line.match(/^subsystem:\s*(\S+)\s*$/);
if (sub) subsystem = sub[1];
if (/^owned_paths:\s*$/.test(line)) { inOwned = true; continue; }
if (inOwned) {
const item = line.match(/^\s+-\s+(\S+)\s*$/);
if (item) { owned.push(item[1]); continue; }
inOwned = false;
}
}
return { subsystem, ownedPaths: owned };
}
export function pathMatchesOwned(filePath, ownedPrefix) {
const norm = filePath.split(sep).join('/');
const pref = ownedPrefix.split(sep).join('/').replace(/\/+$/, '');
if (norm === pref) return true;
if (pref.endsWith('/')) return norm.startsWith(pref);
return norm === pref || norm.startsWith(pref + '/');
}
export function loadSubsystemMap(docsDir = DOCS_DIR) {
const map = new Map();
if (!existsSync(docsDir)) return map;
for (const name of readdirSync(docsDir)) {
if (!name.endsWith('.md') || name === 'README.md') continue;
const path = join(docsDir, name);
const { subsystem, ownedPaths } = parseOwnedPaths(path);
if (!subsystem) continue;
map.set(subsystem, { path, ownedPaths });
}
return map;
}
function longestMatch(file, map) {
let best = null;
let bestLen = -1;
for (const [subsystem, info] of map) {
for (const owned of info.ownedPaths) {
if (pathMatchesOwned(file, owned) && owned.length > bestLen) {
bestLen = owned.length;
best = { subsystem, owned, docPath: info.path };
}
}
}
return best;
}
function baseRef() {
return process.env.ARCH_DOCS_BASE || 'origin/main';
}
function assertBaseRef(ref) {
try {
execSync(`git rev-parse --verify ${ref}^{commit}`, {
cwd: ROOT,
encoding: 'utf8',
stdio: ['ignore', 'pipe', 'pipe'],
});
} catch {
throw new Error(`cannot diff against ${ref}`);
}
}
function changedFiles(ref) {
const out = execSync(`git diff --name-only ${ref}...HEAD`, {
cwd: ROOT,
encoding: 'utf8',
});
return out.split(/\r?\n/).filter(Boolean);
}
function fileChangedIn(ref, file) {
const out = execSync(`git diff --name-only ${ref}...HEAD -- ${file}`, {
cwd: ROOT,
encoding: 'utf8',
}).trim();
return out.length > 0;
}
export function hasDocsNotNeededTrailer(ref = baseRef()) {
assertBaseRef(ref);
const out = execSync(`git log ${ref}..HEAD --pretty=%B`, {
cwd: ROOT,
encoding: 'utf8',
});
return /Docs-not-needed:/i.test(out);
}
export function filesNeedDocs({ ref = baseRef(), map = null } = {}) {
assertBaseRef(ref);
const subsystemMap = map || loadSubsystemMap();
const files = changedFiles(ref);
if (files.length === 0) return { ok: true, missing: [], files: [] };
if (hasDocsNotNeededTrailer(ref)) return { ok: true, missing: [], files };
const missing = [];
for (const file of files) {
const match = longestMatch(file, subsystemMap);
if (!match) continue;
if (fileChangedIn(ref, match.docPath)) continue;
missing.push({ file, subsystem: match.subsystem, doc: match.docPath });
}
return { ok: missing.length === 0, missing, files };
}
// #1076: the trailer this gate looks for is read from `git log <base>..HEAD`, and on a merge-queue
// entry that range holds the squash of the pull request title and body — the branch commits are not in
// it. So "add a trailer" sent people to the commit they had just pushed, and the entry went red again.
// The refusal names both places, and says why the body counts.
export function failureLines(missing, root = ROOT) {
const lines = ['arch-docs: FAIL — owned file(s) changed without subsystem doc update:'];
for (const m of missing) lines.push(` ${m.file} → ${m.subsystem} (${relative(root, m.doc)})`);
lines.push('Fix it one of two ways:');
lines.push(' - update the subsystem doc in docs/subsystems/ in this pull request, or');
lines.push(' - add a line `Docs-not-needed: <why>` to the pull request body AND to the branch commit.');
lines.push(' The merge queue squashes the title and body into the commit it judges, so a trailer');
lines.push(' that lives only in a branch commit is gone exactly when main is judged');
lines.push(' (AGENTS.md → Docs-not-needed: why both places).');
return lines;
}
function main() {
let result;
try {
result = filesNeedDocs();
} catch (err) {
console.error(`arch-docs: FAIL — ${err.message}`);
process.exit(1);
}
const { ok, missing, files } = result;
if (ok) {
console.log(`arch-docs: OK (${files.length} changed file(s))`);
process.exit(0);
}
for (const line of failureLines(missing)) console.error(line);
process.exit(1);
}
if (process.argv[1] && fileURLToPath(import.meta.url) === process.argv[1]) {
main();
}