@xenos1996/usa 2.25.2 → 2.26.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/USA.md +3 -0
- package/dist/cli-audit.d.ts.map +1 -1
- package/dist/cli-audit.js +6 -0
- package/dist/cli-audit.js.map +1 -1
- package/dist/cli-docs.d.ts +18 -0
- package/dist/cli-docs.d.ts.map +1 -0
- package/dist/cli-docs.js +416 -0
- package/dist/cli-docs.js.map +1 -0
- package/dist/cli-help.d.ts.map +1 -1
- package/dist/cli-help.js +18 -0
- package/dist/cli-help.js.map +1 -1
- package/dist/cli.d.ts.map +1 -1
- package/dist/cli.js +4 -0
- package/dist/cli.js.map +1 -1
- package/dist/config.d.ts +1 -1
- package/dist/config.d.ts.map +1 -1
- package/dist/config.js +15 -1
- package/dist/config.js.map +1 -1
- package/dist/engine/audit.d.ts +8 -0
- package/dist/engine/audit.d.ts.map +1 -1
- package/dist/engine/audit.js +63 -0
- package/dist/engine/audit.js.map +1 -1
- package/dist/engine/docs-audit.d.ts +125 -0
- package/dist/engine/docs-audit.d.ts.map +1 -0
- package/dist/engine/docs-audit.js +405 -0
- package/dist/engine/docs-audit.js.map +1 -0
- package/dist/engine/docs-impact.d.ts +62 -0
- package/dist/engine/docs-impact.d.ts.map +1 -0
- package/dist/engine/docs-impact.js +165 -0
- package/dist/engine/docs-impact.js.map +1 -0
- package/dist/engine/docs-invariants.d.ts +66 -0
- package/dist/engine/docs-invariants.d.ts.map +1 -0
- package/dist/engine/docs-invariants.js +667 -0
- package/dist/engine/docs-invariants.js.map +1 -0
- package/dist/engine/docs-taxonomy.d.ts +72 -0
- package/dist/engine/docs-taxonomy.d.ts.map +1 -0
- package/dist/engine/docs-taxonomy.js +294 -0
- package/dist/engine/docs-taxonomy.js.map +1 -0
- package/dist/engine/gate.d.ts +5 -1
- package/dist/engine/gate.d.ts.map +1 -1
- package/dist/engine/gate.js +14 -3
- package/dist/engine/gate.js.map +1 -1
- package/dist/types.d.ts +8 -0
- package/dist/types.d.ts.map +1 -1
- package/dist/util/project.d.ts +4 -0
- package/dist/util/project.d.ts.map +1 -1
- package/dist/util/project.js +9 -1
- package/dist/util/project.js.map +1 -1
- package/package.json +3 -1
- package/rules/core/supply-chain.yaml +23 -4
- package/rules/docs-taxonomy.yaml +3362 -0
|
@@ -0,0 +1,667 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Documentation invariants — deterministic, read-only consistency checks
|
|
3
|
+
* that no file-presence rule can express. Each invariant answers a question
|
|
4
|
+
* of the form "does the machine-knowable surface in the source have a home
|
|
5
|
+
* in the documentation?" and reports PASS / FAIL (with locations) /
|
|
6
|
+
* NOT_APPLICABLE.
|
|
7
|
+
*
|
|
8
|
+
* These are the mechanical half of the documentation contract:
|
|
9
|
+
* - env-var coverage §6 config docs vs env vars read by the code
|
|
10
|
+
* - route coverage §5 API docs vs HTTP routes defined in the code
|
|
11
|
+
* - docstring coverage §4 public API vs docstrings / JSDoc
|
|
12
|
+
* - broken links §10 relative markdown links that do not resolve
|
|
13
|
+
* - generated provenance §14 generated docs without a discoverable
|
|
14
|
+
* regeneration path
|
|
15
|
+
* - changelog consistency §1 package version vs changelog
|
|
16
|
+
*
|
|
17
|
+
* Everything here is a pure function of the project tree + facts: same
|
|
18
|
+
* tree in, same result out (AC-2), and conservative: when a surface exists
|
|
19
|
+
* but cannot be matched to documentation, it is reported (AC-11).
|
|
20
|
+
*/
|
|
21
|
+
import path from 'node:path';
|
|
22
|
+
import { matchesGlob } from '../util/glob.js';
|
|
23
|
+
/* --------------------------------------------------------------- shared -- */
|
|
24
|
+
/** Source scan excludes: prose, tests, examples, generated output. */
|
|
25
|
+
export const CODE_EXCLUDES = [
|
|
26
|
+
'**/*.md',
|
|
27
|
+
'**/*.mdx',
|
|
28
|
+
'**/*.rst',
|
|
29
|
+
'README*',
|
|
30
|
+
'**/README*',
|
|
31
|
+
'docs/**',
|
|
32
|
+
'doc/**',
|
|
33
|
+
'examples/**',
|
|
34
|
+
'example/**',
|
|
35
|
+
'templates/**',
|
|
36
|
+
'fixtures/**',
|
|
37
|
+
'__mocks__/**',
|
|
38
|
+
'test/**',
|
|
39
|
+
'tests/**',
|
|
40
|
+
'__tests__/**',
|
|
41
|
+
'spec/**',
|
|
42
|
+
'**/*.test.*',
|
|
43
|
+
'**/*.spec.*',
|
|
44
|
+
'**/*.stories.*',
|
|
45
|
+
'**/test_*',
|
|
46
|
+
'**/*_test.*',
|
|
47
|
+
'CHANGELOG*',
|
|
48
|
+
'dist/**',
|
|
49
|
+
'build/**',
|
|
50
|
+
'out/**',
|
|
51
|
+
'coverage/**',
|
|
52
|
+
'**/*.min.js',
|
|
53
|
+
'**/*.map',
|
|
54
|
+
'**/*.generated.*',
|
|
55
|
+
];
|
|
56
|
+
/** Documentation corpus: everywhere a fact may legitimately be documented. */
|
|
57
|
+
const DOC_GLOBS = [
|
|
58
|
+
'**/*.md',
|
|
59
|
+
'**/*.mdx',
|
|
60
|
+
'**/*.rst',
|
|
61
|
+
'**/*.txt',
|
|
62
|
+
'**/.env.example',
|
|
63
|
+
'**/.env.*.example',
|
|
64
|
+
];
|
|
65
|
+
/** Env vars present in virtually every runtime — exempt from the check. */
|
|
66
|
+
const ENV_EXEMPT = new Set([
|
|
67
|
+
'NODE_ENV',
|
|
68
|
+
'PATH',
|
|
69
|
+
'HOME',
|
|
70
|
+
'USER',
|
|
71
|
+
'USERNAME',
|
|
72
|
+
'SHELL',
|
|
73
|
+
'PWD',
|
|
74
|
+
'TMPDIR',
|
|
75
|
+
'TEMP',
|
|
76
|
+
'LANG',
|
|
77
|
+
'LC_ALL',
|
|
78
|
+
'TERM',
|
|
79
|
+
'CI',
|
|
80
|
+
'DEBUG',
|
|
81
|
+
'HOSTNAME',
|
|
82
|
+
'OS',
|
|
83
|
+
]);
|
|
84
|
+
const MAX_LOCATIONS = 15;
|
|
85
|
+
function capLocations(locations) {
|
|
86
|
+
return {
|
|
87
|
+
shown: locations.slice(0, MAX_LOCATIONS),
|
|
88
|
+
extra: Math.max(0, locations.length - MAX_LOCATIONS),
|
|
89
|
+
};
|
|
90
|
+
}
|
|
91
|
+
function fmtNames(names) {
|
|
92
|
+
if (names.length <= 8)
|
|
93
|
+
return names.join(', ');
|
|
94
|
+
return `${names.slice(0, 8).join(', ')} (+${names.length - 8} more)`;
|
|
95
|
+
}
|
|
96
|
+
function isExcluded(file) {
|
|
97
|
+
return CODE_EXCLUDES.some((g) => matchesGlob(file, g));
|
|
98
|
+
}
|
|
99
|
+
/**
|
|
100
|
+
* Re-run the extractor regex over the hit line and collect every group.
|
|
101
|
+
* The `g` flag is load-bearing: without it `exec` never advances `lastIndex`
|
|
102
|
+
* and this loops forever on any line that contains a match (observed OOM on
|
|
103
|
+
* a real repo — the same match got pushed unboundedly).
|
|
104
|
+
*/
|
|
105
|
+
function groupsOf(line, pattern) {
|
|
106
|
+
const re = new RegExp(pattern, 'g');
|
|
107
|
+
const out = [];
|
|
108
|
+
let m;
|
|
109
|
+
while ((m = re.exec(line)) !== null) {
|
|
110
|
+
for (let g = 1; g < m.length; g++) {
|
|
111
|
+
const v = m[g];
|
|
112
|
+
if (v)
|
|
113
|
+
out.push(v);
|
|
114
|
+
}
|
|
115
|
+
if (m.index === re.lastIndex)
|
|
116
|
+
re.lastIndex++;
|
|
117
|
+
}
|
|
118
|
+
return out;
|
|
119
|
+
}
|
|
120
|
+
/* -------------------------------------------------- 1 · env-var coverage -- */
|
|
121
|
+
const ENV_EXTRACTORS = [
|
|
122
|
+
{
|
|
123
|
+
include: [
|
|
124
|
+
'**/*.ts',
|
|
125
|
+
'**/*.tsx',
|
|
126
|
+
'**/*.js',
|
|
127
|
+
'**/*.jsx',
|
|
128
|
+
'**/*.mjs',
|
|
129
|
+
'**/*.cjs',
|
|
130
|
+
'**/*.mts',
|
|
131
|
+
'**/*.cts',
|
|
132
|
+
],
|
|
133
|
+
pattern: 'process\\.env\\.(?:([A-Z][A-Z0-9_]{1,})|\\[\\s*[\'"]([A-Z][A-Z0-9_]{1,})[\'"]\\s*\\])',
|
|
134
|
+
},
|
|
135
|
+
{
|
|
136
|
+
include: ['**/*.py'],
|
|
137
|
+
pattern: 'os\\.environ(?:\\.get)?\\(\\s*[\'"]([A-Z][A-Z0-9_]{1,})[\'"]|os\\.getenv\\(\\s*[\'"]([A-Z][A-Z0-9_]{1,})[\'"]',
|
|
138
|
+
},
|
|
139
|
+
{
|
|
140
|
+
include: ['**/*.go'],
|
|
141
|
+
pattern: 'os\\.(?:Getenv|LookupEnv)\\(\\s*[\'"]([A-Z][A-Z0-9_]{1,})[\'"]',
|
|
142
|
+
},
|
|
143
|
+
{
|
|
144
|
+
include: ['**/*.rs'],
|
|
145
|
+
pattern: '(?:std::)?env::var(?:_os)?\\(\\s*[\'"]([A-Z][A-Z0-9_]{1,})[\'"]',
|
|
146
|
+
},
|
|
147
|
+
{
|
|
148
|
+
include: ['**/*.c', '**/*.h', '**/*.cc', '**/*.cpp'],
|
|
149
|
+
pattern: 'getenv\\(\\s*[\'"]([A-Z][A-Z0-9_]{1,})[\'"]',
|
|
150
|
+
},
|
|
151
|
+
];
|
|
152
|
+
/**
|
|
153
|
+
* §6 invariant: every env var read by source code must appear somewhere in
|
|
154
|
+
* the documentation corpus. Conservative by construction — the corpus is
|
|
155
|
+
* every prose file in the repo, so "documented" is the generous reading.
|
|
156
|
+
*/
|
|
157
|
+
export function checkEnvVarCoverage(project) {
|
|
158
|
+
const used = new Map();
|
|
159
|
+
for (const ex of ENV_EXTRACTORS) {
|
|
160
|
+
for (const hit of project.grep(ex.pattern, ex.include, CODE_EXCLUDES)) {
|
|
161
|
+
for (const name of groupsOf(hit.excerpt, ex.pattern)) {
|
|
162
|
+
if (name.startsWith('NODE_') || ENV_EXEMPT.has(name))
|
|
163
|
+
continue;
|
|
164
|
+
if (!used.has(name))
|
|
165
|
+
used.set(name, { file: hit.file, line: hit.line, excerpt: hit.excerpt });
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
const names = [...used.keys()].sort();
|
|
170
|
+
const base = {
|
|
171
|
+
id: 'DOCU-INV-ENV',
|
|
172
|
+
title: 'Every env var read by the code is documented',
|
|
173
|
+
status: 'PASS',
|
|
174
|
+
detail: '',
|
|
175
|
+
expected: 'Each environment variable read by source code appears in the documentation corpus ' +
|
|
176
|
+
'(docs, README, .env.example) so configuration is discoverable without reading the code.',
|
|
177
|
+
locations: [],
|
|
178
|
+
extra: 0,
|
|
179
|
+
crossCategory: '§6 Configuration Documentation → §8 Operations (deployment guides and runbooks copy the same variables).',
|
|
180
|
+
};
|
|
181
|
+
if (names.length === 0) {
|
|
182
|
+
base.status = 'NOT_APPLICABLE';
|
|
183
|
+
base.detail = 'No environment variables found in source code.';
|
|
184
|
+
return base;
|
|
185
|
+
}
|
|
186
|
+
const undocumented = names.filter((n) => !docCorpus(project).some((text) => new RegExp(`\\b${n}\\b`).test(text)));
|
|
187
|
+
if (undocumented.length === 0) {
|
|
188
|
+
base.detail = `All ${names.length} env var(s) found in code are documented.`;
|
|
189
|
+
return base;
|
|
190
|
+
}
|
|
191
|
+
base.status = 'FAIL';
|
|
192
|
+
const { shown, extra } = capLocations(undocumented.map((n) => used.get(n)));
|
|
193
|
+
base.detail = `${undocumented.length} of ${names.length} env var(s) read by code are not documented: ${fmtNames(undocumented)}`;
|
|
194
|
+
base.locations = shown;
|
|
195
|
+
base.extra = extra;
|
|
196
|
+
return base;
|
|
197
|
+
}
|
|
198
|
+
/** Concatenated documentation corpus (cached per project root). */
|
|
199
|
+
let corpusCache = null;
|
|
200
|
+
function docCorpus(project) {
|
|
201
|
+
if (corpusCache && corpusCache.key === project.root)
|
|
202
|
+
return corpusCache.texts;
|
|
203
|
+
const texts = project
|
|
204
|
+
.glob(DOC_GLOBS)
|
|
205
|
+
.map((f) => project.read(f))
|
|
206
|
+
.filter((t) => t !== null);
|
|
207
|
+
corpusCache = { key: project.root, texts };
|
|
208
|
+
return texts;
|
|
209
|
+
}
|
|
210
|
+
/* ------------------------------------------------------ 2 · route coverage -- */
|
|
211
|
+
// Path characters: `/`, letters, digits, `_`, `-`, `.`, `:`, `{}`, `$`, `*`, `[`, `]`.
|
|
212
|
+
const ROUTE_PATH_CLASS = String.raw `[/A-Za-z0-9_.\-:{}$*[\]]*`;
|
|
213
|
+
const ROUTE_EXTRACTORS = [
|
|
214
|
+
{
|
|
215
|
+
// Express / Fastify / Hono / Koa and friends
|
|
216
|
+
include: ['**/*.ts', '**/*.tsx', '**/*.js', '**/*.jsx', '**/*.mjs', '**/*.cjs'],
|
|
217
|
+
pattern: `\\b(?:app|router|server)\\.(get|post|put|delete|patch|head|options|all)\\(\\s*['"\`](${ROUTE_PATH_CLASS})['"\`]`,
|
|
218
|
+
},
|
|
219
|
+
{
|
|
220
|
+
// Flask / FastAPI decorators
|
|
221
|
+
include: ['**/*.py'],
|
|
222
|
+
pattern: `@(?:app|router|blueprint|bp|api|view)\\.(get|post|put|delete|patch|head|options|route)\\(\\s*['"\`](${ROUTE_PATH_CLASS})['"\`]`,
|
|
223
|
+
},
|
|
224
|
+
{
|
|
225
|
+
// chi / net-http mux / gorilla in Go
|
|
226
|
+
include: ['**/*.go'],
|
|
227
|
+
pattern: `\\b(?:r|router|mux)\\.(Get|Post|Put|Delete|Patch|Handle|HandleFunc|Head|Options)\\(\\s*['"\`](${ROUTE_PATH_CLASS})['"\`]`,
|
|
228
|
+
},
|
|
229
|
+
];
|
|
230
|
+
const API_DOC_GLOBS = [
|
|
231
|
+
'**/openapi.{yaml,yml,json}',
|
|
232
|
+
'**/swagger.{yaml,yml,json}',
|
|
233
|
+
'docs/api/**',
|
|
234
|
+
'docs/reference/**',
|
|
235
|
+
'**/api-reference*.md',
|
|
236
|
+
'**/api.md',
|
|
237
|
+
];
|
|
238
|
+
/**
|
|
239
|
+
* §5 invariant: every defined HTTP route (method + path) must be mentioned
|
|
240
|
+
* in the API documentation corpus. Route extraction is deliberately
|
|
241
|
+
* framework-broad and heuristic; false positives are acceptable, false
|
|
242
|
+
* negatives are not (AC-11).
|
|
243
|
+
*/
|
|
244
|
+
export function checkRouteCoverage(project) {
|
|
245
|
+
const routes = new Map();
|
|
246
|
+
for (const ex of ROUTE_EXTRACTORS) {
|
|
247
|
+
for (const hit of project.grep(ex.pattern, ex.include, CODE_EXCLUDES)) {
|
|
248
|
+
for (const [method, p] of routePairs(hit.excerpt)) {
|
|
249
|
+
if (!p.startsWith('/') || p.length < 2)
|
|
250
|
+
continue;
|
|
251
|
+
const key = `${method} ${p}`;
|
|
252
|
+
if (!routes.has(key))
|
|
253
|
+
routes.set(key, { method, path: p, file: hit.file, line: hit.line });
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
const base = {
|
|
258
|
+
id: 'DOCU-INV-ROUTES',
|
|
259
|
+
title: 'Every HTTP route is covered by API documentation',
|
|
260
|
+
status: 'PASS',
|
|
261
|
+
detail: '',
|
|
262
|
+
expected: 'Each defined HTTP route (method + path) is mentioned in the API documentation ' +
|
|
263
|
+
'(OpenAPI spec, docs/api/**, docs/reference/**) so consumers never rely on code archaeology.',
|
|
264
|
+
locations: [],
|
|
265
|
+
extra: 0,
|
|
266
|
+
crossCategory: '§5 API Documentation → §10 User-Facing (tutorials and how-to guides that use these routes).',
|
|
267
|
+
};
|
|
268
|
+
if (routes.size === 0) {
|
|
269
|
+
base.status = 'NOT_APPLICABLE';
|
|
270
|
+
base.detail = 'No HTTP routes found in source code.';
|
|
271
|
+
return base;
|
|
272
|
+
}
|
|
273
|
+
const corpus = project
|
|
274
|
+
.glob(API_DOC_GLOBS)
|
|
275
|
+
.map((f) => project.read(f))
|
|
276
|
+
.filter((t) => t !== null);
|
|
277
|
+
if (corpus.length === 0) {
|
|
278
|
+
base.status = 'NOT_APPLICABLE';
|
|
279
|
+
base.detail = `${routes.size} route(s) found but no API documentation corpus exists (see the API reference artifact).`;
|
|
280
|
+
return base;
|
|
281
|
+
}
|
|
282
|
+
const undocumented = [...routes.values()]
|
|
283
|
+
.filter((r) => {
|
|
284
|
+
const norm = r.path.replace(/\/+$/, '');
|
|
285
|
+
return !corpus.some((t) => t.includes(r.path) || (norm.length > 0 && t.includes(norm)));
|
|
286
|
+
})
|
|
287
|
+
.sort((a, b) => a.path.localeCompare(b.path) || a.method.localeCompare(b.method));
|
|
288
|
+
if (undocumented.length === 0) {
|
|
289
|
+
base.detail = `All ${routes.size} route(s) are mentioned in the API documentation.`;
|
|
290
|
+
return base;
|
|
291
|
+
}
|
|
292
|
+
base.status = 'FAIL';
|
|
293
|
+
const { shown, extra } = capLocations(undocumented.map((r) => ({ file: r.file, line: r.line, excerpt: `${r.method} ${r.path}` })));
|
|
294
|
+
base.detail = `${undocumented.length} of ${routes.size} route(s) absent from API docs: ${fmtNames(undocumented.map((r) => `${r.method} ${r.path}`))}`;
|
|
295
|
+
base.locations = shown;
|
|
296
|
+
base.extra = extra;
|
|
297
|
+
return base;
|
|
298
|
+
}
|
|
299
|
+
/**
|
|
300
|
+
* Pull (method, path) pairs out of one source line. Mirrors ROUTE_EXTRACTORS
|
|
301
|
+
* exactly (same object names, same quote class) so every line the extractors
|
|
302
|
+
* matched also yields pairs here.
|
|
303
|
+
*/
|
|
304
|
+
function routePairs(line) {
|
|
305
|
+
const out = [];
|
|
306
|
+
const res = [
|
|
307
|
+
new RegExp(`\\b(?:app|router|server)\\.(get|post|put|delete|patch|head|options|all)\\(\\s*['"\`](${ROUTE_PATH_CLASS})['"\`]`, 'g'),
|
|
308
|
+
new RegExp(`@(?:app|router|blueprint|bp|api|view)\\.(get|post|put|delete|patch|head|options|route)\\(\\s*['"\`](${ROUTE_PATH_CLASS})['"\`]`, 'g'),
|
|
309
|
+
new RegExp(`\\b(?:r|router|mux)\\.(Get|Post|Put|Delete|Patch|Handle|HandleFunc|Head|Options)\\(\\s*['"\`](${ROUTE_PATH_CLASS})['"\`]`, 'g'),
|
|
310
|
+
];
|
|
311
|
+
for (const re of res) {
|
|
312
|
+
let m;
|
|
313
|
+
while ((m = re.exec(line)) !== null)
|
|
314
|
+
out.push([m[1].toLowerCase(), m[2]]);
|
|
315
|
+
}
|
|
316
|
+
return out;
|
|
317
|
+
}
|
|
318
|
+
/* ------------------------------------------------- 3 · docstring coverage -- */
|
|
319
|
+
/**
|
|
320
|
+
* §4 invariant: public top-level definitions carry docstrings. Python: the
|
|
321
|
+
* first non-empty line after a module-level `def` must open a docstring.
|
|
322
|
+
* TS/JS: the first non-empty line above `export function` / `export class`
|
|
323
|
+
* must close a JSDoc block. Heuristic and conservative; private (underscore)
|
|
324
|
+
* members are exempt by convention.
|
|
325
|
+
*/
|
|
326
|
+
export function checkDocstringCoverage(project, facts) {
|
|
327
|
+
const missing = [];
|
|
328
|
+
if (facts.flags.has('lang:python'))
|
|
329
|
+
scanPythonDocstrings(project, missing);
|
|
330
|
+
if (facts.flags.has('lang:typescript') || facts.flags.has('lang:javascript')) {
|
|
331
|
+
scanTypeDocstrings(project, missing);
|
|
332
|
+
}
|
|
333
|
+
const hasPy = facts.flags.has('lang:python');
|
|
334
|
+
const hasTs = facts.flags.has('lang:typescript') || facts.flags.has('lang:javascript');
|
|
335
|
+
const base = {
|
|
336
|
+
id: 'DOCU-INV-DOCS',
|
|
337
|
+
title: 'Public functions and classes carry docstrings',
|
|
338
|
+
status: 'PASS',
|
|
339
|
+
detail: '',
|
|
340
|
+
expected: 'Every public (non-underscore) top-level function or class has a docstring (Python) or JSDoc (TS/JS).',
|
|
341
|
+
locations: [],
|
|
342
|
+
extra: 0,
|
|
343
|
+
crossCategory: '§4 Source Code Documentation → §5 API reference (generated docs inherit these strings).',
|
|
344
|
+
};
|
|
345
|
+
if (!hasPy && !hasTs) {
|
|
346
|
+
base.status = 'NOT_APPLICABLE';
|
|
347
|
+
base.detail = 'No Python or TypeScript/JavaScript sources detected.';
|
|
348
|
+
return base;
|
|
349
|
+
}
|
|
350
|
+
if (missing.length === 0) {
|
|
351
|
+
base.detail = 'All public top-level definitions carry a docstring or JSDoc block.';
|
|
352
|
+
return base;
|
|
353
|
+
}
|
|
354
|
+
base.status = 'FAIL';
|
|
355
|
+
const { shown, extra } = capLocations(missing);
|
|
356
|
+
base.detail = `${missing.length} public definition(s) without a docstring/JSDoc.`;
|
|
357
|
+
base.locations = shown;
|
|
358
|
+
base.extra = extra;
|
|
359
|
+
return base;
|
|
360
|
+
}
|
|
361
|
+
function hasDocstringBelow(lines, i) {
|
|
362
|
+
for (let j = i + 1; j < Math.min(i + 4, lines.length); j++) {
|
|
363
|
+
const t = lines[j].trim();
|
|
364
|
+
if (t === '')
|
|
365
|
+
continue;
|
|
366
|
+
return t.startsWith('"""') || t.startsWith("'''");
|
|
367
|
+
}
|
|
368
|
+
return false;
|
|
369
|
+
}
|
|
370
|
+
function scanPythonDocstrings(project, missing) {
|
|
371
|
+
for (const file of project.glob(['**/*.py'])) {
|
|
372
|
+
if (isExcluded(file))
|
|
373
|
+
continue;
|
|
374
|
+
const text = project.read(file);
|
|
375
|
+
if (text === null)
|
|
376
|
+
continue;
|
|
377
|
+
const lines = text.split('\n');
|
|
378
|
+
for (let i = 0; i < lines.length; i++) {
|
|
379
|
+
const m = lines[i].match(/^(?:async )?def ([A-Za-z_][A-Za-z0-9_]*)\(/);
|
|
380
|
+
if (!m || m[1].startsWith('_'))
|
|
381
|
+
continue;
|
|
382
|
+
if (!hasDocstringBelow(lines, i))
|
|
383
|
+
missing.push({ file, line: i + 1, excerpt: `def ${m[1]}(` });
|
|
384
|
+
}
|
|
385
|
+
}
|
|
386
|
+
}
|
|
387
|
+
function scanTypeDocstrings(project, missing) {
|
|
388
|
+
const globs = ['**/*.ts', '**/*.tsx', '**/*.js', '**/*.jsx', '**/*.mjs', '**/*.cjs'];
|
|
389
|
+
for (const file of project.glob(globs)) {
|
|
390
|
+
if (isExcluded(file))
|
|
391
|
+
continue;
|
|
392
|
+
const text = project.read(file);
|
|
393
|
+
if (text === null)
|
|
394
|
+
continue;
|
|
395
|
+
const lines = text.split('\n');
|
|
396
|
+
for (let i = 0; i < lines.length; i++) {
|
|
397
|
+
const m = lines[i].match(/^export (?:async )?function ([A-Za-z_$][\w$]*)|^export class ([A-Za-z_$][\w$]*)/);
|
|
398
|
+
if (!m)
|
|
399
|
+
continue;
|
|
400
|
+
let hasDoc = false;
|
|
401
|
+
for (let j = i - 1; j >= Math.max(0, i - 5); j--) {
|
|
402
|
+
const t = lines[j].trim();
|
|
403
|
+
if (t === '')
|
|
404
|
+
continue;
|
|
405
|
+
hasDoc = t === '*/';
|
|
406
|
+
break;
|
|
407
|
+
}
|
|
408
|
+
if (!hasDoc)
|
|
409
|
+
missing.push({ file, line: i + 1, excerpt: `export ${m[1] ?? m[2]}` });
|
|
410
|
+
}
|
|
411
|
+
}
|
|
412
|
+
}
|
|
413
|
+
/* ------------------------------------------------------- 4 · broken links -- */
|
|
414
|
+
const LINK_RE = /\[[^\]]*\]\(([^)\s]+)\)/g;
|
|
415
|
+
/**
|
|
416
|
+
* §10 invariant: every relative markdown link resolves to an existing file.
|
|
417
|
+
* Root-relative (`/x`), external, mailto, and pure-anchor links are skipped —
|
|
418
|
+
* they cannot be resolved from the repo alone.
|
|
419
|
+
*/
|
|
420
|
+
export function checkBrokenLinks(project) {
|
|
421
|
+
const broken = [];
|
|
422
|
+
for (const file of project.glob(['**/*.md', '**/*.mdx'])) {
|
|
423
|
+
const text = project.read(file);
|
|
424
|
+
if (text === null)
|
|
425
|
+
continue;
|
|
426
|
+
const lines = text.split('\n');
|
|
427
|
+
for (let i = 0; i < lines.length; i++) {
|
|
428
|
+
const found = linkTargets(lines[i]);
|
|
429
|
+
for (const target of found) {
|
|
430
|
+
if (!targetExists(project, file, target)) {
|
|
431
|
+
broken.push({ file, line: i + 1, excerpt: `→ ${target}` });
|
|
432
|
+
}
|
|
433
|
+
}
|
|
434
|
+
}
|
|
435
|
+
}
|
|
436
|
+
const base = {
|
|
437
|
+
id: 'DOCU-INV-LINKS',
|
|
438
|
+
title: 'Relative markdown links resolve to files that exist',
|
|
439
|
+
status: 'PASS',
|
|
440
|
+
detail: '',
|
|
441
|
+
expected: 'Every relative link in markdown documentation resolves to an existing file.',
|
|
442
|
+
locations: [],
|
|
443
|
+
extra: 0,
|
|
444
|
+
crossCategory: '§10 User-Facing Documentation → §14 Meta-Documentation (sitemap drift starts with dead links).',
|
|
445
|
+
};
|
|
446
|
+
if (broken.length === 0) {
|
|
447
|
+
base.detail = 'No broken relative links found.';
|
|
448
|
+
return base;
|
|
449
|
+
}
|
|
450
|
+
base.status = 'FAIL';
|
|
451
|
+
const { shown, extra } = capLocations(broken);
|
|
452
|
+
base.detail = `${broken.length} broken relative link(s).`;
|
|
453
|
+
base.locations = shown;
|
|
454
|
+
base.extra = extra;
|
|
455
|
+
return base;
|
|
456
|
+
}
|
|
457
|
+
function linkTargets(line) {
|
|
458
|
+
const out = [];
|
|
459
|
+
for (const m of line.matchAll(LINK_RE)) {
|
|
460
|
+
const target = m[1].split('#')[0];
|
|
461
|
+
if (!target)
|
|
462
|
+
continue;
|
|
463
|
+
if (/^(https?:|mailto:|tel:|<)/i.test(target))
|
|
464
|
+
continue;
|
|
465
|
+
if (target.startsWith('/'))
|
|
466
|
+
continue;
|
|
467
|
+
out.push(target);
|
|
468
|
+
}
|
|
469
|
+
return out;
|
|
470
|
+
}
|
|
471
|
+
/** Directory prefixes derived from the file index (cached per project root). */
|
|
472
|
+
let dirCache = null;
|
|
473
|
+
function repoDirs(project) {
|
|
474
|
+
if (dirCache && dirCache.key === project.root)
|
|
475
|
+
return dirCache.dirs;
|
|
476
|
+
const dirs = new Set();
|
|
477
|
+
for (const f of project.files) {
|
|
478
|
+
const parts = f.split('/');
|
|
479
|
+
for (let i = 1; i < parts.length; i++)
|
|
480
|
+
dirs.add(parts.slice(0, i).join('/'));
|
|
481
|
+
}
|
|
482
|
+
dirCache = { key: project.root, dirs };
|
|
483
|
+
return dirs;
|
|
484
|
+
}
|
|
485
|
+
function targetExists(project, fromFile, target) {
|
|
486
|
+
const raw = path.posix.join(path.posix.dirname(fromFile), target);
|
|
487
|
+
const segments = raw.split('/');
|
|
488
|
+
const stack = [];
|
|
489
|
+
for (const seg of segments) {
|
|
490
|
+
if (seg === '' || seg === '.')
|
|
491
|
+
continue;
|
|
492
|
+
if (seg === '..') {
|
|
493
|
+
stack.pop();
|
|
494
|
+
continue;
|
|
495
|
+
}
|
|
496
|
+
stack.push(seg);
|
|
497
|
+
}
|
|
498
|
+
const joined = stack.join('/');
|
|
499
|
+
if (!joined)
|
|
500
|
+
return true;
|
|
501
|
+
// Directory links are valid markdown (they render to the directory page).
|
|
502
|
+
if (repoDirs(project).has(joined))
|
|
503
|
+
return true;
|
|
504
|
+
const candidates = new Set([joined]);
|
|
505
|
+
if (!/\.[a-z0-9]+$/i.test(joined)) {
|
|
506
|
+
candidates.add(`${joined}.md`);
|
|
507
|
+
candidates.add(`${joined}.mdx`);
|
|
508
|
+
candidates.add(`${joined}/index.md`);
|
|
509
|
+
}
|
|
510
|
+
return project.files.some((f) => candidates.has(f));
|
|
511
|
+
}
|
|
512
|
+
/* ------------------------------------------- 5 · generated doc provenance -- */
|
|
513
|
+
const GENERATED_HEADER_RE = /generated (?:by|from)|auto-?generated|do not (?:edit|modify)/i;
|
|
514
|
+
/**
|
|
515
|
+
* §14 invariant: a file that declares itself generated must have a
|
|
516
|
+
* discoverable regeneration path — its stem must be referenced by a
|
|
517
|
+
* package.json script, a CI workflow, or the Makefile. Otherwise nobody can
|
|
518
|
+
* tell whether it is fresh (AC-5: generated drift).
|
|
519
|
+
*/
|
|
520
|
+
export function checkGeneratedProvenance(project) {
|
|
521
|
+
const flagged = [];
|
|
522
|
+
const scriptText = genScriptText(project);
|
|
523
|
+
for (const file of project.files) {
|
|
524
|
+
if (/\.(lock|map|wasm|png|jpg|jpeg|gif|ico|mp4|webm)$/i.test(file))
|
|
525
|
+
continue;
|
|
526
|
+
// templates/** is generator input (its "Generated by" line documents how
|
|
527
|
+
// it is used, not where it comes from); experiments/** are frozen
|
|
528
|
+
// protocol records (EXPERIMENT_PROTOCOL.md), not living generated docs.
|
|
529
|
+
if (file.startsWith('templates/') || file.startsWith('experiments/'))
|
|
530
|
+
continue;
|
|
531
|
+
const text = project.read(file);
|
|
532
|
+
if (text === null)
|
|
533
|
+
continue;
|
|
534
|
+
const head = text.split('\n').slice(0, 5).join('\n');
|
|
535
|
+
if (!GENERATED_HEADER_RE.test(head))
|
|
536
|
+
continue;
|
|
537
|
+
const stem = path.basename(file).replace(/\.[^.]+$/, '');
|
|
538
|
+
if (stem.length > 2 && scriptText.includes(stem))
|
|
539
|
+
continue;
|
|
540
|
+
flagged.push({ file, excerpt: `no script/workflow references "${stem}"` });
|
|
541
|
+
}
|
|
542
|
+
const base = {
|
|
543
|
+
id: 'DOCU-INV-GEN',
|
|
544
|
+
title: 'Generated documents have a discoverable regeneration path',
|
|
545
|
+
status: 'PASS',
|
|
546
|
+
detail: '',
|
|
547
|
+
expected: 'Every file marked "generated / do not edit" names a generator that is discoverable in ' +
|
|
548
|
+
'package.json scripts or CI workflows, so drift can be detected and fixed.',
|
|
549
|
+
locations: [],
|
|
550
|
+
extra: 0,
|
|
551
|
+
crossCategory: '§14 Meta-Documentation (toolchain) → any generated target (§1, §5, §7, §9).',
|
|
552
|
+
};
|
|
553
|
+
if (flagged.length === 0) {
|
|
554
|
+
base.detail = 'No generated files without a discoverable regeneration path.';
|
|
555
|
+
return base;
|
|
556
|
+
}
|
|
557
|
+
base.status = 'FAIL';
|
|
558
|
+
const { shown, extra } = capLocations(flagged);
|
|
559
|
+
base.detail = `${flagged.length} generated file(s) without a discoverable regeneration path.`;
|
|
560
|
+
base.locations = shown;
|
|
561
|
+
base.extra = extra;
|
|
562
|
+
return base;
|
|
563
|
+
}
|
|
564
|
+
/** package.json scripts + CI workflows + Makefile: the discoverable surface. */
|
|
565
|
+
function genScriptText(project) {
|
|
566
|
+
const parts = [];
|
|
567
|
+
const pkg = project.readJson('package.json');
|
|
568
|
+
if (pkg?.scripts)
|
|
569
|
+
parts.push(JSON.stringify(pkg.scripts));
|
|
570
|
+
const wfGlobs = [
|
|
571
|
+
'.github/workflows/*.yml',
|
|
572
|
+
'.github/workflows/*.yaml',
|
|
573
|
+
'**/Jenkinsfile',
|
|
574
|
+
'.gitlab-ci.yml',
|
|
575
|
+
];
|
|
576
|
+
for (const wf of project.glob(wfGlobs)) {
|
|
577
|
+
const t = project.read(wf);
|
|
578
|
+
if (t)
|
|
579
|
+
parts.push(t);
|
|
580
|
+
}
|
|
581
|
+
const make = project.read('Makefile');
|
|
582
|
+
if (make)
|
|
583
|
+
parts.push(make);
|
|
584
|
+
return parts.join('\n');
|
|
585
|
+
}
|
|
586
|
+
/* ------------------------------------------------- 6 · changelog consistency -- */
|
|
587
|
+
/**
|
|
588
|
+
* §1 invariant: the changelog must agree with the released version. A tag
|
|
589
|
+
* the changelog has never mentioned means release notes and version history
|
|
590
|
+
* diverged — the exact drift class the contract exists to catch.
|
|
591
|
+
*/
|
|
592
|
+
export function checkChangelogConsistency(project, gitTags) {
|
|
593
|
+
const changelogFile = project.glob([
|
|
594
|
+
'CHANGELOG.md',
|
|
595
|
+
'CHANGELOG.rst',
|
|
596
|
+
'CHANGELOG',
|
|
597
|
+
'**/CHANGELOG.md',
|
|
598
|
+
])[0];
|
|
599
|
+
const base = {
|
|
600
|
+
id: 'DOCU-INV-CHLOG',
|
|
601
|
+
title: 'Changelog agrees with the released version',
|
|
602
|
+
status: 'PASS',
|
|
603
|
+
detail: '',
|
|
604
|
+
expected: 'The current package version (and the newest git tag) appears in the changelog, so ' +
|
|
605
|
+
'versioning, changelog, and release notes cannot drift apart silently.',
|
|
606
|
+
locations: [],
|
|
607
|
+
extra: 0,
|
|
608
|
+
crossCategory: '§1 Project & Governance → §9 Release & Change Management (release notes inherit it).',
|
|
609
|
+
};
|
|
610
|
+
if (!changelogFile) {
|
|
611
|
+
base.status = 'NOT_APPLICABLE';
|
|
612
|
+
base.detail = 'No changelog file found.';
|
|
613
|
+
return base;
|
|
614
|
+
}
|
|
615
|
+
const text = project.read(changelogFile) ?? '';
|
|
616
|
+
const pkg = project.readJson('package.json');
|
|
617
|
+
const problems = [];
|
|
618
|
+
if (pkg?.version && !text.includes(pkg.version)) {
|
|
619
|
+
problems.push(`package.json version ${pkg.version} not found in changelog`);
|
|
620
|
+
}
|
|
621
|
+
const tags = gitTags
|
|
622
|
+
.map((t) => t.replace(/^v/, ''))
|
|
623
|
+
.filter((t) => /^\d+\.\d+\.\d+/.test(t))
|
|
624
|
+
.sort((a, b) => b.localeCompare(a, undefined, { numeric: true }));
|
|
625
|
+
if (tags.length > 0 && !text.includes(tags[0])) {
|
|
626
|
+
problems.push(`newest tag v${tags[0]} not found in changelog`);
|
|
627
|
+
}
|
|
628
|
+
if (problems.length === 0) {
|
|
629
|
+
base.detail = 'Changelog is consistent with the package version and tags.';
|
|
630
|
+
return base;
|
|
631
|
+
}
|
|
632
|
+
base.status = 'FAIL';
|
|
633
|
+
base.detail = problems.join('; ');
|
|
634
|
+
base.locations = [{ file: changelogFile }];
|
|
635
|
+
return base;
|
|
636
|
+
}
|
|
637
|
+
/* -------------------------------------------------------------- findings -- */
|
|
638
|
+
/**
|
|
639
|
+
* Convert an invariant result into the engine's Finding shape. The SAME
|
|
640
|
+
* function feeds `usa docs audit` and the main audit's DOCU injection, so
|
|
641
|
+
* the gate is identical in both paths (AC-8).
|
|
642
|
+
*/
|
|
643
|
+
export function invariantToFinding(r, severity, dampened) {
|
|
644
|
+
const pass = r.status !== 'FAIL';
|
|
645
|
+
const cross = r.crossCategory ? ` Cross-category: ${r.crossCategory}` : '';
|
|
646
|
+
return {
|
|
647
|
+
ruleId: r.id,
|
|
648
|
+
title: r.title,
|
|
649
|
+
section: 'S12',
|
|
650
|
+
sectionTitle: 'Documentation & Knowledge',
|
|
651
|
+
severity,
|
|
652
|
+
baseSeverity: severity,
|
|
653
|
+
status: pass ? 'PASS' : 'FAIL',
|
|
654
|
+
ruleClass: 'documentation',
|
|
655
|
+
dampened,
|
|
656
|
+
message: pass ? r.detail : `${r.detail}${cross}`,
|
|
657
|
+
locations: pass ? [] : r.locations,
|
|
658
|
+
why: 'The machine already knows these facts; documentation that contradicts or omits them is a correctness defect, not a style issue.',
|
|
659
|
+
remediation: pass
|
|
660
|
+
? undefined
|
|
661
|
+
: 'Update the documentation to cover the missing surface, then re-run `usa docs audit`.',
|
|
662
|
+
references: ['Docs-as-Code'],
|
|
663
|
+
automatability: 'full',
|
|
664
|
+
tags: ['doc-invariant'],
|
|
665
|
+
};
|
|
666
|
+
}
|
|
667
|
+
//# sourceMappingURL=docs-invariants.js.map
|