docguard-cli 0.23.0 → 0.25.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/README.md +1 -1
- package/cli/commands/diff.mjs +1 -1
- package/cli/commands/explain.mjs +178 -17
- package/cli/commands/fix.mjs +17 -2
- package/cli/commands/generate.mjs +69 -3
- package/cli/commands/guard.mjs +86 -11
- package/cli/commands/hooks.mjs +12 -7
- package/cli/commands/init.mjs +24 -8
- package/cli/commands/score.mjs +147 -61
- package/cli/commands/setup.mjs +2 -2
- package/cli/commands/sync.mjs +6 -0
- package/cli/commands/trace.mjs +3 -3
- package/cli/commands/upgrade.mjs +61 -13
- package/cli/config.mjs +18 -1
- package/cli/docguard.mjs +156 -2
- package/cli/ensure-skills.mjs +24 -26
- package/cli/scanners/api-doc.mjs +17 -3
- package/cli/scanners/doc-tools.mjs +32 -15
- package/cli/scanners/frontend.mjs +24 -8
- package/cli/scanners/js-ast.mjs +432 -0
- package/cli/scanners/memory-plan.mjs +1 -1
- package/cli/scanners/project-type.mjs +11 -4
- package/cli/scanners/py-ast.mjs +213 -0
- package/cli/scanners/routes.mjs +194 -69
- package/cli/scanners/schemas.mjs +97 -51
- package/cli/shared-git.mjs +0 -0
- package/cli/shared-ignore.mjs +23 -2
- package/cli/shared-source.mjs +59 -2
- package/cli/shared-trace-patterns.mjs +13 -0
- package/cli/shared.mjs +92 -1
- package/cli/validator-markers.mjs +91 -0
- package/cli/validators/api-surface.mjs +37 -3
- package/cli/validators/canonical-sync.mjs +22 -19
- package/cli/validators/doc-quality.mjs +2 -42
- package/cli/validators/docs-coverage.mjs +13 -0
- package/cli/validators/docs-sync.mjs +4 -3
- package/cli/validators/drift.mjs +3 -2
- package/cli/validators/freshness.mjs +47 -15
- package/cli/validators/generated-staleness.mjs +16 -1
- package/cli/validators/metadata-sync.mjs +21 -11
- package/cli/validators/metrics-consistency.mjs +45 -17
- package/cli/validators/security.mjs +13 -5
- package/cli/validators/structure.mjs +6 -5
- package/cli/validators/surface-sync.mjs +7 -5
- package/cli/validators/test-spec.mjs +76 -51
- package/cli/validators/todo-tracking.mjs +4 -2
- package/cli/validators/traceability.mjs +11 -3
- package/cli/writers/sections.mjs +32 -19
- package/docs/commands.md +1 -1
- package/docs/configuration.md +11 -0
- package/docs/faq.md +1 -1
- package/extensions/spec-kit-docguard/README.md +1 -1
- package/extensions/spec-kit-docguard/extension.yml +2 -2
- package/extensions/spec-kit-docguard/skills/docguard-fix/SKILL.md +2 -2
- package/extensions/spec-kit-docguard/skills/docguard-guard/SKILL.md +2 -2
- package/extensions/spec-kit-docguard/skills/docguard-review/SKILL.md +2 -2
- package/extensions/spec-kit-docguard/skills/docguard-score/SKILL.md +2 -2
- package/extensions/spec-kit-docguard/skills/docguard-sync/SKILL.md +2 -1
- package/extensions/spec-kit-docguard/templates/github-workflows/docguard-autofix.yml +3 -2
- package/extensions/spec-kit-docguard/templates/github-workflows/docguard-guard.yml +2 -2
- package/package.json +5 -3
package/cli/scanners/schemas.mjs
CHANGED
|
@@ -8,11 +8,10 @@
|
|
|
8
8
|
|
|
9
9
|
import { existsSync, readFileSync, readdirSync } from 'node:fs';
|
|
10
10
|
import { resolve, join, relative, basename, extname } from 'node:path';
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
]);
|
|
11
|
+
import { extractJsSchemaBodies } from './js-ast.mjs';
|
|
12
|
+
import { extractPythonFiles } from './py-ast.mjs';
|
|
13
|
+
import { readScannable } from '../shared-source.mjs';
|
|
14
|
+
import { DEFAULT_IGNORE_DIRS as IGNORE_DIRS, shouldIgnore, relPosix } from '../shared-ignore.mjs';
|
|
16
15
|
|
|
17
16
|
/**
|
|
18
17
|
* Deep scan schemas from ORM definitions, validation libraries, and OpenAPI specs.
|
|
@@ -21,7 +20,7 @@ const IGNORE_DIRS = new Set([
|
|
|
21
20
|
* @param {object} docTools - Detected doc tools (may include OpenAPI)
|
|
22
21
|
* @returns {object} { entities: [...], relationships: [...], source: string }
|
|
23
22
|
*/
|
|
24
|
-
export function scanSchemasDeep(dir, stack, docTools) {
|
|
23
|
+
export function scanSchemasDeep(dir, stack, docTools, config = {}) {
|
|
25
24
|
// Priority 1: OpenAPI schemas
|
|
26
25
|
if (docTools?.openapi?.found && docTools.openapi.schemas?.length > 0) {
|
|
27
26
|
return {
|
|
@@ -78,10 +77,22 @@ export function scanSchemasDeep(dir, stack, docTools) {
|
|
|
78
77
|
}
|
|
79
78
|
}
|
|
80
79
|
|
|
80
|
+
// Honor .docguardignore / config.ignore: drop entities whose source file the
|
|
81
|
+
// user excluded (e.g. test/fixtures/**), then drop relationships that point at
|
|
82
|
+
// a dropped entity. Filtering the RESULTS (not the walk) keeps the cache and
|
|
83
|
+
// the per-ORM walkers untouched. entity.file is project-relative already.
|
|
84
|
+
const keptEntities = entities.filter(
|
|
85
|
+
e => !e.file || !shouldIgnore(relPosix(dir, resolve(dir, e.file)), config)
|
|
86
|
+
);
|
|
87
|
+
const keptNames = new Set(keptEntities.map(e => e.name));
|
|
88
|
+
const keptRelationships = keptEntities.length === entities.length
|
|
89
|
+
? relationships
|
|
90
|
+
: relationships.filter(r => keptNames.has(r.from) && keptNames.has(r.to));
|
|
91
|
+
|
|
81
92
|
return {
|
|
82
|
-
entities,
|
|
83
|
-
relationships,
|
|
84
|
-
source:
|
|
93
|
+
entities: keptEntities,
|
|
94
|
+
relationships: keptRelationships,
|
|
95
|
+
source: keptEntities.length > 0 ? keptEntities[0].source : 'none',
|
|
85
96
|
};
|
|
86
97
|
}
|
|
87
98
|
|
|
@@ -220,26 +231,15 @@ function scanDrizzleSchemas(dir) {
|
|
|
220
231
|
const content = readFileSafe(filePath);
|
|
221
232
|
if (!content || !content.includes('Table(')) return;
|
|
222
233
|
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
const varName = match[1];
|
|
227
|
-
const tableName = match[2];
|
|
228
|
-
const body = match[3];
|
|
234
|
+
// Emit one entity from a (tableName, body) pair. `body` is the balanced
|
|
235
|
+
// inner text of the table's column object.
|
|
236
|
+
const emit = (tableName, body) => {
|
|
229
237
|
const fields = parseDrizzleColumns(body);
|
|
230
|
-
|
|
231
|
-
// Look for references (foreign keys)
|
|
232
238
|
for (const field of fields) {
|
|
233
239
|
if (field._ref) {
|
|
234
|
-
relationships.push({
|
|
235
|
-
from: tableName,
|
|
236
|
-
to: field._ref,
|
|
237
|
-
type: 'many-to-one',
|
|
238
|
-
field: field.name,
|
|
239
|
-
});
|
|
240
|
+
relationships.push({ from: tableName, to: field._ref, type: 'many-to-one', field: field.name });
|
|
240
241
|
}
|
|
241
242
|
}
|
|
242
|
-
|
|
243
243
|
entities.push({
|
|
244
244
|
name: tableName,
|
|
245
245
|
fields: fields.map(f => ({ ...f, _ref: undefined })),
|
|
@@ -247,6 +247,17 @@ function scanDrizzleSchemas(dir) {
|
|
|
247
247
|
source: 'drizzle',
|
|
248
248
|
description: '',
|
|
249
249
|
});
|
|
250
|
+
};
|
|
251
|
+
|
|
252
|
+
// Full-support tier: AST extraction (nested braces handled). Falls back
|
|
253
|
+
// to the legacy regex only when @babel/parser can't parse the file.
|
|
254
|
+
const ast = extractJsSchemaBodies(content, filePath);
|
|
255
|
+
if (ast) {
|
|
256
|
+
for (const s of ast) if (s.kind === 'drizzle') emit(s.table, s.body);
|
|
257
|
+
} else {
|
|
258
|
+
let match;
|
|
259
|
+
const regex = new RegExp(tablePattern.source, 'g');
|
|
260
|
+
while ((match = regex.exec(content)) !== null) emit(match[2], match[3]);
|
|
250
261
|
}
|
|
251
262
|
});
|
|
252
263
|
}
|
|
@@ -327,20 +338,28 @@ function scanZodSchemas(dir) {
|
|
|
327
338
|
const content = readFileSafe(filePath);
|
|
328
339
|
if (!content || !content.includes('z.object')) return;
|
|
329
340
|
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
while ((match = regex.exec(content)) !== null) {
|
|
333
|
-
const schemaName = match[1].replace(/Schema$|Validator$/, '');
|
|
334
|
-
const body = match[2];
|
|
335
|
-
const fields = parseZodFields(body);
|
|
336
|
-
|
|
341
|
+
const emit = (rawName, body) => {
|
|
342
|
+
const schemaName = rawName.replace(/Schema$|Validator$/, '');
|
|
337
343
|
entities.push({
|
|
338
344
|
name: schemaName,
|
|
339
|
-
fields,
|
|
345
|
+
fields: parseZodFields(body),
|
|
340
346
|
file: relative(dir, filePath),
|
|
341
347
|
source: 'zod',
|
|
342
348
|
description: '',
|
|
343
349
|
});
|
|
350
|
+
};
|
|
351
|
+
|
|
352
|
+
const ast = extractJsSchemaBodies(content, filePath);
|
|
353
|
+
if (ast) {
|
|
354
|
+
// Keep the legacy naming gate (only *Schema/Validator/Input/Output) so
|
|
355
|
+
// inline z.object() validations aren't treated as data-model entities.
|
|
356
|
+
for (const s of ast) {
|
|
357
|
+
if (s.kind === 'zod' && /(?:Schema|Validator|Input|Output)$/.test(s.name)) emit(s.name, s.body);
|
|
358
|
+
}
|
|
359
|
+
} else {
|
|
360
|
+
let match;
|
|
361
|
+
const regex = new RegExp(zodPattern.source, 'g');
|
|
362
|
+
while ((match = regex.exec(content)) !== null) emit(match[1], match[2]);
|
|
344
363
|
}
|
|
345
364
|
});
|
|
346
365
|
}
|
|
@@ -407,25 +426,14 @@ function scanMongooseSchemas(dir) {
|
|
|
407
426
|
const content = readFileSafe(filePath);
|
|
408
427
|
if (!content || !content.includes('Schema(')) return;
|
|
409
428
|
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
while ((match = regex.exec(content)) !== null) {
|
|
413
|
-
const schemaName = match[1].replace(/Schema$/i, '');
|
|
414
|
-
const body = match[2];
|
|
429
|
+
const emit = (rawName, body) => {
|
|
430
|
+
const schemaName = rawName.replace(/Schema$/i, '');
|
|
415
431
|
const fields = parseMongooseFields(body);
|
|
416
|
-
|
|
417
|
-
// Check for refs (relationships)
|
|
418
432
|
for (const field of fields) {
|
|
419
433
|
if (field._ref) {
|
|
420
|
-
relationships.push({
|
|
421
|
-
from: schemaName,
|
|
422
|
-
to: field._ref,
|
|
423
|
-
type: 'many-to-one',
|
|
424
|
-
field: field.name,
|
|
425
|
-
});
|
|
434
|
+
relationships.push({ from: schemaName, to: field._ref, type: 'many-to-one', field: field.name });
|
|
426
435
|
}
|
|
427
436
|
}
|
|
428
|
-
|
|
429
437
|
entities.push({
|
|
430
438
|
name: schemaName.charAt(0).toUpperCase() + schemaName.slice(1),
|
|
431
439
|
fields: fields.map(f => ({ ...f, _ref: undefined })),
|
|
@@ -433,6 +441,15 @@ function scanMongooseSchemas(dir) {
|
|
|
433
441
|
source: 'mongoose',
|
|
434
442
|
description: '',
|
|
435
443
|
});
|
|
444
|
+
};
|
|
445
|
+
|
|
446
|
+
const ast = extractJsSchemaBodies(content, filePath);
|
|
447
|
+
if (ast) {
|
|
448
|
+
for (const s of ast) if (s.kind === 'mongoose') emit(s.name, s.body);
|
|
449
|
+
} else {
|
|
450
|
+
let match;
|
|
451
|
+
const regex = new RegExp(schemaPattern.source, 'g');
|
|
452
|
+
while ((match = regex.exec(content)) !== null) emit(match[1], match[2]);
|
|
436
453
|
}
|
|
437
454
|
});
|
|
438
455
|
}
|
|
@@ -504,8 +521,38 @@ function mapMongooseType(type) {
|
|
|
504
521
|
function scanPythonModels(dir) {
|
|
505
522
|
const entities = [];
|
|
506
523
|
const relationships = [];
|
|
507
|
-
|
|
508
|
-
|
|
524
|
+
|
|
525
|
+
// Collect .py files first so the AST tier parses them in ONE python3
|
|
526
|
+
// subprocess. `null` → Python unavailable / subprocess failed → regex
|
|
527
|
+
// fallback for all; a per-file `ok:false` falls back for that file only.
|
|
528
|
+
// The AST tier gets every field exactly (no body-capture truncation, no
|
|
529
|
+
// miss on multi-base classes) — undercounting fields is what makes the
|
|
530
|
+
// data-model validators falsely pass on a stale DATA-MODEL.md.
|
|
531
|
+
const pyFiles = [];
|
|
532
|
+
walkDir(dir, (filePath) => { if (filePath.endsWith('.py')) pyFiles.push(filePath); });
|
|
533
|
+
const astByFile = extractPythonFiles(pyFiles);
|
|
534
|
+
|
|
535
|
+
for (const filePath of pyFiles) {
|
|
536
|
+
const parsed = astByFile && astByFile[filePath];
|
|
537
|
+
if (parsed && parsed.ok) {
|
|
538
|
+
for (const s of parsed.schemas || []) {
|
|
539
|
+
const fields = (s.fields || []).map(f => ({
|
|
540
|
+
name: f.name, type: f.type || '', required: f.required !== false, description: '',
|
|
541
|
+
}));
|
|
542
|
+
if (fields.length > 0) entities.push({ name: s.name, fields, file: filePath, source: s.kind });
|
|
543
|
+
for (const to of s.rels || []) relationships.push({ from: s.name, to, type: 'related' });
|
|
544
|
+
}
|
|
545
|
+
continue;
|
|
546
|
+
}
|
|
547
|
+
scanPythonModelsRegex(filePath, entities, relationships);
|
|
548
|
+
}
|
|
549
|
+
return { entities, relationships };
|
|
550
|
+
}
|
|
551
|
+
|
|
552
|
+
// Regex (beta) fallback — used per-file when the Python AST tier is unavailable
|
|
553
|
+
// or couldn't parse that file. Identical behavior to the pre-AST scanner.
|
|
554
|
+
function scanPythonModelsRegex(filePath, entities, relationships) {
|
|
555
|
+
{
|
|
509
556
|
const content = readFileSafe(filePath);
|
|
510
557
|
if (!content) return;
|
|
511
558
|
if (!/class\s+\w+\s*\([^)]*(Base|BaseModel|db\.Model|Model|SQLModel)/.test(content)) return;
|
|
@@ -549,8 +596,7 @@ function scanPythonModels(dir) {
|
|
|
549
596
|
}
|
|
550
597
|
if (fields.length > 0) entities.push({ name, fields, file: filePath, source: 'pydantic' });
|
|
551
598
|
}
|
|
552
|
-
}
|
|
553
|
-
return { entities, relationships };
|
|
599
|
+
}
|
|
554
600
|
}
|
|
555
601
|
|
|
556
602
|
// ── Rust: Diesel `table! { ... }` ─────────────────────────────────────────────
|
|
@@ -687,7 +733,7 @@ function extractOpenAPIRelationships(schemas) {
|
|
|
687
733
|
}
|
|
688
734
|
|
|
689
735
|
function readFileSafe(path) {
|
|
690
|
-
|
|
736
|
+
return readScannable(path); // size-capped; skips minified/generated bundles
|
|
691
737
|
}
|
|
692
738
|
|
|
693
739
|
// v0.15-P2: walkDir is called 8 times across schemas.mjs (Pydantic, Mongoose,
|
package/cli/shared-git.mjs
CHANGED
|
Binary file
|
package/cli/shared-ignore.mjs
CHANGED
|
@@ -50,7 +50,22 @@ const ALWAYS_REJECT_PATH_RE =
|
|
|
50
50
|
* Returns [] if the file is missing or unreadable — never throws.
|
|
51
51
|
*/
|
|
52
52
|
import { readFileSync, existsSync } from 'node:fs';
|
|
53
|
-
import { resolve as resolvePath } from 'node:path';
|
|
53
|
+
import { resolve as resolvePath, relative as relativePath, sep } from 'node:path';
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Project-relative path with POSIX (`/`) separators — the canonical form that
|
|
57
|
+
* every validator should compare against docs, ignore globs, and changed-file
|
|
58
|
+
* sets.
|
|
59
|
+
*
|
|
60
|
+
* Replaces the old `absPath.replace(projectDir + '/', '')` idiom, which failed
|
|
61
|
+
* two ways: on Windows the `/` literal never matched the OS `\` separators, and
|
|
62
|
+
* for a sibling dir sharing a prefix (`/repo` vs `/repo-staging`) the replace
|
|
63
|
+
* was a no-op — both cases left an ABSOLUTE path, silently breaking
|
|
64
|
+
* `content.includes(relPath)`, glob matching, and `--changed-only` scoping.
|
|
65
|
+
*/
|
|
66
|
+
export function relPosix(projectDir, absPath) {
|
|
67
|
+
return relativePath(projectDir, absPath).split(sep).join('/');
|
|
68
|
+
}
|
|
54
69
|
|
|
55
70
|
export function loadDocguardIgnore(projectDir) {
|
|
56
71
|
const p = resolvePath(projectDir, '.docguardignore');
|
|
@@ -98,7 +113,13 @@ export function mergeIgnoreFile(projectDir, config) {
|
|
|
98
113
|
* @returns {RegExp}
|
|
99
114
|
*/
|
|
100
115
|
function globToRegex(pattern) {
|
|
101
|
-
|
|
116
|
+
// gitignore-style trailing slash ("dir/") means "this directory and everything
|
|
117
|
+
// under it". Strip it so "dir/" matches identically to "dir" — otherwise the
|
|
118
|
+
// escaped pattern keeps the slash and the alternation below can only match a
|
|
119
|
+
// literal "dir//" (double slash), so the pattern silently matches nothing.
|
|
120
|
+
// `|| pattern` guards the degenerate all-slashes case (e.g. "/") from emptying.
|
|
121
|
+
const normalized = pattern.replace(/\/+$/, '') || pattern;
|
|
122
|
+
const escaped = normalized
|
|
102
123
|
.replace(/\./g, '\\.')
|
|
103
124
|
.replace(/\*\*/g, '§§') // temp placeholder for **
|
|
104
125
|
.replace(/\*/g, '[^/]*')
|
package/cli/shared-source.mjs
CHANGED
|
@@ -39,6 +39,39 @@ function safeReadJson(path) {
|
|
|
39
39
|
try { return JSON.parse(readFileSync(path, 'utf-8')); } catch { return null; }
|
|
40
40
|
}
|
|
41
41
|
|
|
42
|
+
/**
|
|
43
|
+
* Files DocGuard reads WHOLE and regex/AST-scans. A bundle, minified vendor
|
|
44
|
+
* file, or generated client checked into source is slow to read, hostile to
|
|
45
|
+
* regex, expensive to AST-parse, and is never the project's authored truth.
|
|
46
|
+
* 1.5 MB sits far above any hand-written module yet below typical bundles.
|
|
47
|
+
*/
|
|
48
|
+
export const MAX_SCAN_BYTES = 1_500_000;
|
|
49
|
+
|
|
50
|
+
/** True for build artifacts / minified / generated / declaration files. */
|
|
51
|
+
export function isGeneratedPath(p) {
|
|
52
|
+
const b = String(p);
|
|
53
|
+
return /\.min\.[cm]?js$/i.test(b)
|
|
54
|
+
|| /\.(bundle|chunk)\.[cm]?jsx?$/i.test(b)
|
|
55
|
+
|| /[.-]generated\.[a-z0-9]+$/i.test(b)
|
|
56
|
+
|| /\.d\.ts$/i.test(b);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Read a source file for scanning, or return null when it should be skipped:
|
|
61
|
+
* unreadable, a generated/minified artifact, or larger than `maxBytes`. This is
|
|
62
|
+
* the single guard that keeps every scanner from choking on a checked-in
|
|
63
|
+
* bundle. Skipping is logged by callers that care (most just see "no match").
|
|
64
|
+
*/
|
|
65
|
+
export function readScannable(absPath, { maxBytes = MAX_SCAN_BYTES } = {}) {
|
|
66
|
+
try {
|
|
67
|
+
if (isGeneratedPath(absPath)) return null;
|
|
68
|
+
if (statSync(absPath).size > maxBytes) return null;
|
|
69
|
+
return readFileSync(absPath, 'utf-8');
|
|
70
|
+
} catch {
|
|
71
|
+
return null;
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
42
75
|
/**
|
|
43
76
|
* Expand a workspace glob (e.g. "packages/*") into concrete directories
|
|
44
77
|
* that contain a package.json. Only the trailing single-level "/*" glob is
|
|
@@ -236,8 +269,8 @@ export function grepEnvUsage(projectDir, config = {}) {
|
|
|
236
269
|
if (!CODE_EXTENSIONS.has(extname(filePath))) return;
|
|
237
270
|
const rel = relative(projectDir, filePath);
|
|
238
271
|
if (shouldIgnore(rel, config)) return;
|
|
239
|
-
|
|
240
|
-
|
|
272
|
+
const content = readScannable(filePath);
|
|
273
|
+
if (content === null) return; // unreadable, generated, or too large to scan
|
|
241
274
|
if (!content.includes('env')) return;
|
|
242
275
|
// patterns[2] is the import.meta.env one — its matches are Vite-injected
|
|
243
276
|
// when the name is an intrinsic, and must not be reported as user env vars.
|
|
@@ -250,6 +283,30 @@ export function grepEnvUsage(projectDir, config = {}) {
|
|
|
250
283
|
names.add(m[1]);
|
|
251
284
|
}
|
|
252
285
|
}
|
|
286
|
+
|
|
287
|
+
// v0.24: env vars are increasingly declared in a validation schema
|
|
288
|
+
// (Zod / envalid / convict) and read via a typed `config` object instead of
|
|
289
|
+
// `process.env.X` — so the direct-access patterns above miss them and every
|
|
290
|
+
// documented var looked "missing from code" (field report). Only harvest
|
|
291
|
+
// when the file actually validates process.env through such a schema.
|
|
292
|
+
const validatesEnv =
|
|
293
|
+
/(?:safeParse|parse)\s*\(\s*process\.env\b/.test(content) || // zod: schema.parse(process.env)
|
|
294
|
+
/\bcleanEnv\s*\(\s*process\.env\b/.test(content) || // envalid
|
|
295
|
+
/\bconvict\s*\(/.test(content); // convict
|
|
296
|
+
if (validatesEnv) {
|
|
297
|
+
let km;
|
|
298
|
+
// Zod / envalid: the schema KEYS are the env var names. Data schemas use
|
|
299
|
+
// camelCase keys, so requiring UPPER_SNAKE keeps this env-specific.
|
|
300
|
+
const keyRe = /^\s*['"]?([A-Z][A-Z0-9_]*[A-Z0-9])['"]?\s*:/gm;
|
|
301
|
+
while ((km = keyRe.exec(content)) !== null) {
|
|
302
|
+
if (km[1].length >= 3 && !VITE_INTRINSICS.has(km[1])) names.add(km[1]);
|
|
303
|
+
}
|
|
304
|
+
// convict: the env var name is the `env:` property value, not the key.
|
|
305
|
+
const convictRe = /\benv\s*:\s*['"]([A-Z][A-Z0-9_]*[A-Z0-9])['"]/g;
|
|
306
|
+
while ((km = convictRe.exec(content)) !== null) {
|
|
307
|
+
if (km[1].length >= 3) names.add(km[1]);
|
|
308
|
+
}
|
|
309
|
+
}
|
|
253
310
|
};
|
|
254
311
|
|
|
255
312
|
const walk = (dir) => {
|
|
@@ -12,6 +12,19 @@
|
|
|
12
12
|
* pattern (app/api, pages/api) preserved from the v0.22.0 #195 fix.
|
|
13
13
|
*/
|
|
14
14
|
|
|
15
|
+
/**
|
|
16
|
+
* A markdown file is documentation, never the source that *implements* a
|
|
17
|
+
* canonical doc — so it must not count as a doc→code match. Without this,
|
|
18
|
+
* SECURITY.md's "Auth modules" glob (which includes `guard`) matched
|
|
19
|
+
* `commands/docguard.guard.md` and listed DocGuard's own command docs as the
|
|
20
|
+
* project's auth modules (field report). Real config-file matches (.env,
|
|
21
|
+
* Dockerfile, pyproject.toml, .gitignore) are unaffected — none are `.md`.
|
|
22
|
+
* Used by both `docguard trace` and the guard-time Traceability validator.
|
|
23
|
+
*/
|
|
24
|
+
export function isTraceableSource(relPath) {
|
|
25
|
+
return !relPath.endsWith('.md');
|
|
26
|
+
}
|
|
27
|
+
|
|
15
28
|
export const TEST_PATTERNS = [
|
|
16
29
|
// JS/TS
|
|
17
30
|
/\.test\.[jt]sx?$/, /\.spec\.[jt]sx?$/, /\.test\.(mjs|cjs)$/,
|
package/cli/shared.mjs
CHANGED
|
@@ -39,6 +39,65 @@ export function resolveSeverity(config, validatorKey) {
|
|
|
39
39
|
return 'medium';
|
|
40
40
|
}
|
|
41
41
|
|
|
42
|
+
// ── Canonical section heading matching ─────────────────────────────────────
|
|
43
|
+
/**
|
|
44
|
+
* Required canonical sections used to be matched by literal substring, so an
|
|
45
|
+
* arc42/C4 doc with "## 5.4 Layer boundaries" or "## Building Block View"
|
|
46
|
+
* scored as if the section were absent — the validator made well-structured
|
|
47
|
+
* docs look WORSE than the skeleton (field report). These synonyms + section-
|
|
48
|
+
* number tolerance let equivalent headings count. Synonyms only ever ADD
|
|
49
|
+
* matches; the literal canonical heading always still passes.
|
|
50
|
+
*
|
|
51
|
+
* Keyed by the normalized canonical heading (lowercase, alphanumerics + spaces).
|
|
52
|
+
*/
|
|
53
|
+
export const SECTION_SYNONYMS = {
|
|
54
|
+
'system overview': ['system context', 'system summary', 'introduction and goals', 'context and scope', 'overview and goals'],
|
|
55
|
+
'component map': ['components', 'component overview', 'building block view', 'building blocks', 'containers', 'module overview'],
|
|
56
|
+
'tech stack': ['technology stack', 'technologies', 'technical stack'],
|
|
57
|
+
'entities': ['entity definitions', 'data model', 'domain model', 'data entities'],
|
|
58
|
+
'authentication': ['auth', 'authn', 'authentication and authorization', 'identity'],
|
|
59
|
+
'secrets management': ['secrets', 'secret management', 'secrets handling', 'credentials', 'credential management'],
|
|
60
|
+
'test categories': ['test types', 'categories of tests', 'test strategy', 'testing strategy', 'test approach'],
|
|
61
|
+
'coverage rules': ['coverage', 'coverage targets', 'coverage goals', 'coverage requirements', 'coverage policy'],
|
|
62
|
+
'environment variables': ['env vars', 'environment', 'configuration', 'config variables'],
|
|
63
|
+
'setup steps': ['setup', 'getting started', 'installation', 'setup instructions', 'local setup', 'quick start'],
|
|
64
|
+
'layer boundaries': ['layers', 'layering', 'module boundaries', 'layer boundary', 'boundaries'],
|
|
65
|
+
'external dependencies': ['dependencies', 'external systems', 'third party dependencies', 'integrations', 'external interfaces'],
|
|
66
|
+
'revision history': ['changelog', 'change history', 'history', 'revisions', 'document history', 'change log'],
|
|
67
|
+
};
|
|
68
|
+
|
|
69
|
+
/** Normalize a markdown heading: drop #, leading arc42-style numbers, punctuation. */
|
|
70
|
+
function normalizeHeadingText(line) {
|
|
71
|
+
return line
|
|
72
|
+
.replace(/^#{1,6}\s*/, '') // strip leading #s
|
|
73
|
+
.replace(/^\d+(?:\.\d+)*\.?\s+/, '') // strip leading "5.4 " / "3. " section numbers
|
|
74
|
+
.toLowerCase()
|
|
75
|
+
.replace(/[^a-z0-9]+/g, ' ') // non-alphanumeric → single space
|
|
76
|
+
.trim();
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* True if `content` has an H2–H6 heading equivalent to `canonicalHeading` —
|
|
81
|
+
* the exact text, a known synonym, or the same text behind an arc42-style
|
|
82
|
+
* section number (e.g. "## 5.4 Layer boundaries" satisfies "## Layer Boundaries").
|
|
83
|
+
*
|
|
84
|
+
* @param {string} content - markdown file contents
|
|
85
|
+
* @param {string} canonicalHeading - e.g. '## Component Map' or 'Component Map'
|
|
86
|
+
*/
|
|
87
|
+
export function docHasSection(content, canonicalHeading) {
|
|
88
|
+
const key = normalizeHeadingText(canonicalHeading);
|
|
89
|
+
if (!key) return false;
|
|
90
|
+
const accepted = [key, ...(SECTION_SYNONYMS[key] || [])];
|
|
91
|
+
const headings = content.match(/^#{2,6}\s+.+$/gm) || [];
|
|
92
|
+
for (const line of headings) {
|
|
93
|
+
const norm = normalizeHeadingText(line);
|
|
94
|
+
for (const phrase of accepted) {
|
|
95
|
+
if (norm.includes(phrase)) return true;
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
return false;
|
|
99
|
+
}
|
|
100
|
+
|
|
42
101
|
/**
|
|
43
102
|
* Parse a dotted-decimal version string into a tuple of integers for
|
|
44
103
|
* comparison. Tolerates extra suffixes (e.g. `0.4-beta` → [0, 4]).
|
|
@@ -85,7 +144,7 @@ export const c = {
|
|
|
85
144
|
// ── Compliance Profiles ───────────────────────────────────────────────────
|
|
86
145
|
export const PROFILES = {
|
|
87
146
|
starter: {
|
|
88
|
-
description: 'Minimal CDD —
|
|
147
|
+
description: 'Minimal CDD — architecture + changelog, no Spec Kit framework scaffold (pass --spec-kit to add it). For side projects and prototypes.',
|
|
89
148
|
requiredFiles: {
|
|
90
149
|
canonical: [
|
|
91
150
|
'docs-canonical/ARCHITECTURE.md',
|
|
@@ -124,6 +183,38 @@ export const PROFILES = {
|
|
|
124
183
|
freshness: true,
|
|
125
184
|
},
|
|
126
185
|
},
|
|
186
|
+
// F3 (field report): non-web-centric profiles. The default doc set assumes an
|
|
187
|
+
// HTTP API + database; for a CLI or library that structure fights the project.
|
|
188
|
+
// These drop the HTTP/DB-shaped requirements. (A bespoke CLI-REFERENCE doc type
|
|
189
|
+
// is a separate, larger follow-up; until then API-REFERENCE doubles as the
|
|
190
|
+
// library's module-API reference.)
|
|
191
|
+
cli: {
|
|
192
|
+
description: 'CLI / command-line tool — no HTTP API or database assumed.',
|
|
193
|
+
requiredFiles: {
|
|
194
|
+
canonical: [
|
|
195
|
+
'docs-canonical/ARCHITECTURE.md',
|
|
196
|
+
'docs-canonical/TEST-SPEC.md',
|
|
197
|
+
'docs-canonical/SECURITY.md',
|
|
198
|
+
'docs-canonical/ENVIRONMENT.md',
|
|
199
|
+
],
|
|
200
|
+
agentFile: ['AGENTS.md', 'CLAUDE.md'],
|
|
201
|
+
changelog: 'CHANGELOG.md',
|
|
202
|
+
driftLog: 'DRIFT-LOG.md',
|
|
203
|
+
},
|
|
204
|
+
},
|
|
205
|
+
library: {
|
|
206
|
+
description: 'Library / package — public API matters; no HTTP server or DB assumed.',
|
|
207
|
+
requiredFiles: {
|
|
208
|
+
canonical: [
|
|
209
|
+
'docs-canonical/ARCHITECTURE.md',
|
|
210
|
+
'docs-canonical/API-REFERENCE.md',
|
|
211
|
+
'docs-canonical/TEST-SPEC.md',
|
|
212
|
+
],
|
|
213
|
+
agentFile: ['AGENTS.md', 'CLAUDE.md'],
|
|
214
|
+
changelog: 'CHANGELOG.md',
|
|
215
|
+
driftLog: 'DRIFT-LOG.md',
|
|
216
|
+
},
|
|
217
|
+
},
|
|
127
218
|
'enterprise-ai': {
|
|
128
219
|
description: 'EU AI Act compliance — Annex IV documentation requirements, ALCOA+ alignment, strict freshness. For AI/ML projects under regulatory scrutiny.',
|
|
129
220
|
requiredFiles: {
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Inline whole-validator N/A markers — "declare intentional non-applicability,
|
|
3
|
+
* visibly."
|
|
4
|
+
*
|
|
5
|
+
* A project can mute an entire validator from inside its docs, with the
|
|
6
|
+
* rationale right next to the declaration and tracked in git:
|
|
7
|
+
*
|
|
8
|
+
* <!-- docguard:validator testSpec n/a — POC, no automated tests yet -->
|
|
9
|
+
* <!-- docguard:validator traceability n/a — no formal requirements doc -->
|
|
10
|
+
*
|
|
11
|
+
* This is the validator-level sibling of the section-level
|
|
12
|
+
* `<!-- docguard:section <id> n/a — reason -->`. Unlike the config switch
|
|
13
|
+
* (`validators: { testSpec: false }`), which renders as a silent "disabled",
|
|
14
|
+
* a marked validator renders as a visible `➖ [N/A] (declared N/A: reason)` —
|
|
15
|
+
* honest non-applicability, not an invisible skip or a fake green check.
|
|
16
|
+
*
|
|
17
|
+
* Markers are read from the project's primary docs (canonical docs + the root
|
|
18
|
+
* agent/readme files) so the rationale lives where humans and agents read.
|
|
19
|
+
*
|
|
20
|
+
* Zero NPM dependencies — pure Node.js built-ins.
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
import { existsSync, readFileSync, readdirSync } from 'node:fs';
|
|
24
|
+
import { resolve, join } from 'node:path';
|
|
25
|
+
|
|
26
|
+
// `<!-- docguard:validator <key> n/a [— reason] -->`
|
|
27
|
+
// Separator before the reason may be —, :, or one-or-more hyphens. Reason
|
|
28
|
+
// is optional. Case-insensitive on the keyword and "n/a".
|
|
29
|
+
const MARKER_RE = /<!--\s*docguard:validator\s+([A-Za-z0-9_-]+)\s+n\/a\b\s*(?:[—:\-]+\s*([^>]*?))?\s*-->/gi;
|
|
30
|
+
|
|
31
|
+
/** Files where a validator marker is honored — the docs humans actually read. */
|
|
32
|
+
function markerSourceFiles(projectDir) {
|
|
33
|
+
const files = [];
|
|
34
|
+
const canonicalDir = resolve(projectDir, 'docs-canonical');
|
|
35
|
+
if (existsSync(canonicalDir)) {
|
|
36
|
+
try {
|
|
37
|
+
for (const f of readdirSync(canonicalDir)) {
|
|
38
|
+
if (f.toLowerCase().endsWith('.md')) files.push(join(canonicalDir, f));
|
|
39
|
+
}
|
|
40
|
+
} catch { /* ignore */ }
|
|
41
|
+
}
|
|
42
|
+
for (const root of ['AGENTS.md', 'README.md', 'CLAUDE.md']) {
|
|
43
|
+
const p = resolve(projectDir, root);
|
|
44
|
+
if (existsSync(p)) files.push(p);
|
|
45
|
+
}
|
|
46
|
+
return files;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** Normalize a key for tolerant matching: `Test-Spec`/`test_spec` → `testspec`. */
|
|
50
|
+
function norm(key) {
|
|
51
|
+
return String(key).toLowerCase().replace(/[^a-z0-9]/g, '');
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Scan the project's primary docs for `docguard:validator <key> n/a` markers.
|
|
56
|
+
*
|
|
57
|
+
* @param {string} projectDir
|
|
58
|
+
* @param {Iterable<string>} validKeys - the canonical validator keys (camelCase)
|
|
59
|
+
* @returns {{ suppressed: Map<string,string>, unknown: Array<{raw:string, file:string}> }}
|
|
60
|
+
* `suppressed` maps a canonical validator key → reason ('' if none given).
|
|
61
|
+
* `unknown` lists markers whose key didn't resolve (typo protection).
|
|
62
|
+
*/
|
|
63
|
+
export function loadValidatorSuppressions(projectDir, validKeys) {
|
|
64
|
+
const canonicalByNorm = new Map();
|
|
65
|
+
for (const k of validKeys) canonicalByNorm.set(norm(k), k);
|
|
66
|
+
|
|
67
|
+
const suppressed = new Map();
|
|
68
|
+
const unknown = [];
|
|
69
|
+
|
|
70
|
+
for (const file of markerSourceFiles(projectDir)) {
|
|
71
|
+
let content;
|
|
72
|
+
try { content = readFileSync(file, 'utf-8'); } catch { continue; }
|
|
73
|
+
if (!content.includes('docguard:validator')) continue;
|
|
74
|
+
|
|
75
|
+
MARKER_RE.lastIndex = 0;
|
|
76
|
+
let m;
|
|
77
|
+
while ((m = MARKER_RE.exec(content)) !== null) {
|
|
78
|
+
const rawKey = m[1];
|
|
79
|
+
const reason = (m[2] || '').trim();
|
|
80
|
+
const canonical = canonicalByNorm.get(norm(rawKey));
|
|
81
|
+
if (!canonical) {
|
|
82
|
+
unknown.push({ raw: rawKey, file });
|
|
83
|
+
continue;
|
|
84
|
+
}
|
|
85
|
+
// First marker wins; keep its reason. Re-declaring is harmless.
|
|
86
|
+
if (!suppressed.has(canonical)) suppressed.set(canonical, reason);
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
return { suppressed, unknown };
|
|
91
|
+
}
|
|
@@ -29,6 +29,7 @@ import { detectOpenAPI } from '../scanners/doc-tools.mjs';
|
|
|
29
29
|
import { scanRoutesDeep } from '../scanners/routes.mjs';
|
|
30
30
|
import { parseApiReferenceDoc, compareEndpoints, endpointKey } from '../scanners/api-doc.mjs';
|
|
31
31
|
import { collectPackageJsons, getWorkspaceDirs } from '../shared-source.mjs';
|
|
32
|
+
import { relPosix } from '../shared-ignore.mjs';
|
|
32
33
|
|
|
33
34
|
const MAX_REPORTED = 15;
|
|
34
35
|
const API_DOC = 'docs-canonical/API-REFERENCE.md';
|
|
@@ -86,15 +87,35 @@ export function findAllOpenApiSpecs(projectDir, config) {
|
|
|
86
87
|
seenAbs.add(absPath);
|
|
87
88
|
specs.push({
|
|
88
89
|
absPath,
|
|
89
|
-
relPath:
|
|
90
|
-
? absPath.slice(resolve(projectDir).length + 1)
|
|
91
|
-
: absPath,
|
|
90
|
+
relPath: relPosix(projectDir, absPath),
|
|
92
91
|
endpoints: oa.endpoints.filter(e => e && e.method && e.path),
|
|
93
92
|
});
|
|
94
93
|
}
|
|
95
94
|
return specs;
|
|
96
95
|
}
|
|
97
96
|
|
|
97
|
+
/**
|
|
98
|
+
* OpenAPI specs that exist and declare a `paths:` section but parsed to ZERO
|
|
99
|
+
* endpoints — i.e. DocGuard's minimal YAML/JSON parser couldn't extract them
|
|
100
|
+
* (an unsupported feature: `$ref`, anchors, folded scalars). These are silently
|
|
101
|
+
* skipped by findAllOpenApiSpecs (good — code scanning takes over), but the
|
|
102
|
+
* parse failure must be SURFACED so a broken spec doesn't masquerade as a
|
|
103
|
+
* clean "no API surface" pass. Returns relative spec paths.
|
|
104
|
+
*/
|
|
105
|
+
export function findUnparseableSpecs(projectDir, config) {
|
|
106
|
+
const out = [];
|
|
107
|
+
const seen = new Set();
|
|
108
|
+
for (const dir of orderedSpecDirs(projectDir, config)) {
|
|
109
|
+
const oa = detectOpenAPI(dir);
|
|
110
|
+
if (!oa.found || !oa.parseIncomplete) continue;
|
|
111
|
+
const abs = resolve(dir, oa.path);
|
|
112
|
+
if (seen.has(abs)) continue;
|
|
113
|
+
seen.add(abs);
|
|
114
|
+
out.push(relPosix(projectDir, abs));
|
|
115
|
+
}
|
|
116
|
+
return out;
|
|
117
|
+
}
|
|
118
|
+
|
|
98
119
|
/**
|
|
99
120
|
* Detect divergence between multiple canonical OpenAPI specs.
|
|
100
121
|
* @returns {null | { specs, divergent: string[], authoritative: string }}
|
|
@@ -208,6 +229,19 @@ export function validateApiSurface(projectDir, config) {
|
|
|
208
229
|
}
|
|
209
230
|
}
|
|
210
231
|
|
|
232
|
+
// ── Honest-failure: an OpenAPI spec we couldn't parse ──
|
|
233
|
+
// A spec that declares paths but yielded zero endpoints means our parser
|
|
234
|
+
// choked on it. We fall back to code scanning (below), but the parse failure
|
|
235
|
+
// is surfaced here rather than silently producing a clean "no surface" pass.
|
|
236
|
+
for (const specPath of findUnparseableSpecs(projectDir, config)) {
|
|
237
|
+
warnings.push(
|
|
238
|
+
`OpenAPI spec ${specPath} declares paths but DocGuard parsed 0 endpoints from it ` +
|
|
239
|
+
`(likely an unsupported YAML feature — $ref, anchors, or folded scalars). ` +
|
|
240
|
+
`Falling back to code scanning; the spec's own endpoint list is unavailable. ` +
|
|
241
|
+
`Validate it with a full OpenAPI linter.`
|
|
242
|
+
);
|
|
243
|
+
}
|
|
244
|
+
|
|
211
245
|
const drift = computeApiSurfaceDrift(projectDir, config);
|
|
212
246
|
|
|
213
247
|
// ── Multi-spec divergence (independent of the API-REFERENCE doc) ──
|