docguard-cli 0.23.0 → 0.24.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 +2 -2
- package/cli/commands/guard.mjs +86 -11
- package/cli/commands/hooks.mjs +12 -7
- package/cli/commands/init.mjs +18 -6
- package/cli/commands/score.mjs +147 -61
- package/cli/commands/setup.mjs +2 -2
- package/cli/commands/trace.mjs +3 -3
- package/cli/commands/upgrade.mjs +61 -13
- package/cli/config.mjs +18 -1
- package/cli/docguard.mjs +19 -0
- 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/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 +16 -1
- package/cli/shared-source.mjs +59 -2
- package/cli/shared-trace-patterns.mjs +13 -0
- package/cli/shared.mjs +60 -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/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/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',
|
|
@@ -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) ──
|
|
@@ -124,37 +124,40 @@ export function validateCanonicalSync(projectDir, config, guardResults) {
|
|
|
124
124
|
actualValidatorNames = guardResults.map(r => r.name).filter(Boolean);
|
|
125
125
|
}
|
|
126
126
|
|
|
127
|
-
// ── Read README
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
127
|
+
// ── Read surface docs (README.md + AGENTS.md) ──────────────────────
|
|
128
|
+
// Both carry "N commands / N validators" surface claims. Scanning only the
|
|
129
|
+
// README is why AGENTS.md's counts ("Commands (15 total)", "24 validators")
|
|
130
|
+
// drifted unchecked for releases — close that gap by checking both.
|
|
131
|
+
const surfaceFiles = ['README.md', 'AGENTS.md'];
|
|
132
|
+
let readme = '';
|
|
133
|
+
let readAny = false;
|
|
134
|
+
for (const f of surfaceFiles) {
|
|
135
|
+
const p = resolve(projectDir, f);
|
|
136
|
+
if (!existsSync(p)) continue;
|
|
137
|
+
try { readme += readFileSync(p, 'utf-8') + '\n'; readAny = true; } catch { /* skip unreadable */ }
|
|
133
138
|
}
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
try {
|
|
137
|
-
readme = readFileSync(readmePath, 'utf-8');
|
|
138
|
-
} catch {
|
|
139
|
-
result.warnings.push('canonical-sync: README.md unreadable');
|
|
139
|
+
if (!readAny) {
|
|
140
|
+
result.warnings.push('canonical-sync: no README.md or AGENTS.md found — cannot check surface claims');
|
|
140
141
|
result.total = 1;
|
|
141
142
|
return result;
|
|
142
143
|
}
|
|
143
144
|
|
|
144
145
|
// ── Check 1: "ships N commands" ─────────────────────────────────────
|
|
146
|
+
// Check ALL claims (matchAll), not just the first: with README + AGENTS.md
|
|
147
|
+
// concatenated, a correct claim in one file must not mask a stale claim in
|
|
148
|
+
// the other (the same first-match-masking trap the secret scanner had).
|
|
145
149
|
result.total++;
|
|
146
|
-
const
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
if (claimed === actualCommandCount) {
|
|
150
|
+
const cmdMatches = [...readme.matchAll(/ships\s+\*{0,2}(\d+)\s+commands?\*{0,2}/gi)];
|
|
151
|
+
if (cmdMatches.length > 0) {
|
|
152
|
+
const wrong = [...new Set(cmdMatches.map(m => Number(m[1])).filter(n => n !== actualCommandCount))];
|
|
153
|
+
if (wrong.length === 0) {
|
|
151
154
|
result.passed++;
|
|
152
155
|
} else {
|
|
153
156
|
const detail = actualUserFacingCount !== actualCommandFileCount
|
|
154
157
|
? `${actualCommandCount} user-facing commands in --help (${actualCommandFileCount} files including deprecation aliases)`
|
|
155
158
|
: `${actualCommandCount} command file(s)`;
|
|
156
159
|
result.warnings.push(
|
|
157
|
-
`README.md claims "ships ${
|
|
160
|
+
`A surface doc (README.md/AGENTS.md) claims ${wrong.map(n => `"ships ${n} commands"`).join(' / ')} but the real count is ${detail}. Update it.`
|
|
158
161
|
);
|
|
159
162
|
}
|
|
160
163
|
} else {
|
|
@@ -177,7 +180,7 @@ export function validateCanonicalSync(projectDir, config, guardResults) {
|
|
|
177
180
|
} else {
|
|
178
181
|
const uniqueWrong = [...new Set(wrongClaims)];
|
|
179
182
|
result.warnings.push(
|
|
180
|
-
`README.md claims ${uniqueWrong.map(n => `"${n} validators"`).join(' / ')} but guard reports ${actualValidatorCount}. Update
|
|
183
|
+
`A surface doc (README.md/AGENTS.md) claims ${uniqueWrong.map(n => `"${n} validators"`).join(' / ')} but guard reports ${actualValidatorCount}. Update it.`
|
|
181
184
|
);
|
|
182
185
|
}
|
|
183
186
|
} else {
|
|
@@ -16,14 +16,12 @@
|
|
|
16
16
|
* cells as "long sentences"), this version extracts ONLY actual prose
|
|
17
17
|
* paragraphs. Docs that are mostly tables/code skip readability scoring.
|
|
18
18
|
*
|
|
19
|
-
*
|
|
20
|
-
*
|
|
21
|
-
* Zero NPM runtime dependencies — pure Node.js built-ins only.
|
|
19
|
+
* Zero NPM runtime dependencies, and zero process execution — pure Node.js
|
|
20
|
+
* built-ins reading files only.
|
|
22
21
|
*/
|
|
23
22
|
|
|
24
23
|
import { existsSync, readFileSync, readdirSync, statSync } from 'node:fs';
|
|
25
24
|
import { resolve, join, extname } from 'node:path';
|
|
26
|
-
import { execSync, execFileSync } from 'node:child_process';
|
|
27
25
|
|
|
28
26
|
// ──── Metric Thresholds ────
|
|
29
27
|
// These define "good" vs "warning" boundaries for each metric.
|
|
@@ -441,40 +439,6 @@ function getGradeLabel(grade) {
|
|
|
441
439
|
return 'graduate+';
|
|
442
440
|
}
|
|
443
441
|
|
|
444
|
-
// ──── Understanding CLI Integration ────
|
|
445
|
-
|
|
446
|
-
/**
|
|
447
|
-
* Check if the `understanding` CLI is available on the system.
|
|
448
|
-
*/
|
|
449
|
-
function findUnderstandingCli() {
|
|
450
|
-
try {
|
|
451
|
-
const cmd = process.platform === 'win32' ? 'where understanding' : 'which understanding';
|
|
452
|
-
const result = execSync(`${cmd} 2>/dev/null`, {
|
|
453
|
-
encoding: 'utf-8',
|
|
454
|
-
timeout: 3000,
|
|
455
|
-
}).trim();
|
|
456
|
-
return result || null;
|
|
457
|
-
} catch {
|
|
458
|
-
return null;
|
|
459
|
-
}
|
|
460
|
-
}
|
|
461
|
-
|
|
462
|
-
/**
|
|
463
|
-
* Run the `understanding` CLI on a file and parse results.
|
|
464
|
-
*/
|
|
465
|
-
function runUnderstandingDeepScan(filePath) {
|
|
466
|
-
try {
|
|
467
|
-
const result = execFileSync('understanding', ['analyze', filePath, '--enhanced', '--json'], {
|
|
468
|
-
encoding: 'utf-8',
|
|
469
|
-
timeout: 10000,
|
|
470
|
-
stdio: ['pipe', 'pipe', 'ignore'],
|
|
471
|
-
});
|
|
472
|
-
return JSON.parse(result);
|
|
473
|
-
} catch {
|
|
474
|
-
return null;
|
|
475
|
-
}
|
|
476
|
-
}
|
|
477
|
-
|
|
478
442
|
// ──── Main Validator ────
|
|
479
443
|
|
|
480
444
|
/**
|
|
@@ -587,10 +551,6 @@ export function validateDocQuality(projectDir, config) {
|
|
|
587
551
|
return results;
|
|
588
552
|
}
|
|
589
553
|
|
|
590
|
-
// Check for optional understanding CLI
|
|
591
|
-
const understandingCli = findUnderstandingCli();
|
|
592
|
-
const useDeepScan = config.docQuality?.deepScan !== false && understandingCli;
|
|
593
|
-
|
|
594
554
|
for (const doc of docs) {
|
|
595
555
|
if (!existsSync(doc.path)) continue;
|
|
596
556
|
|
|
@@ -35,8 +35,20 @@ const COMMON_DOTFILES = new Set([
|
|
|
35
35
|
'.env', '.env.local', '.env.development', '.env.production',
|
|
36
36
|
'.vscode', '.idea', '.github', '.husky',
|
|
37
37
|
'.babelrc', '.browserslistrc', '.stylelintrc',
|
|
38
|
+
'.dockerignore', '.python-version', '.tool-versions', '.ruby-version',
|
|
39
|
+
'.gitkeep', '.keep',
|
|
38
40
|
]);
|
|
39
41
|
|
|
42
|
+
// Generated tool artifacts (caches, coverage data, lock-data) that land at the
|
|
43
|
+
// repo root but are NOT configuration a human authors or documents. Treating
|
|
44
|
+
// them as "undocumented config files" is a false positive (field test:
|
|
45
|
+
// quick-recon-tool flagged pytest's `.coverage` SQLite data file). Matched by
|
|
46
|
+
// exact name OR prefix (`.coverage.<host>.<pid>` is coverage.py's parallel form).
|
|
47
|
+
const GENERATED_DOTFILE_PREFIXES = ['.coverage', '.eslintcache', '.stylelintcache', '.tsbuildinfo'];
|
|
48
|
+
function isGeneratedArtifact(name) {
|
|
49
|
+
return GENERATED_DOTFILE_PREFIXES.some(p => name === p || name.startsWith(p + '.'));
|
|
50
|
+
}
|
|
51
|
+
|
|
40
52
|
/**
|
|
41
53
|
* Validate that code artifacts are referenced in documentation.
|
|
42
54
|
* @param {string} projectDir - Project root directory
|
|
@@ -125,6 +137,7 @@ function checkConfigFiles(projectDir, allDocContent, config = {}) {
|
|
|
125
137
|
|
|
126
138
|
if (!isDotFile && !isProjectConfig) continue;
|
|
127
139
|
if (COMMON_DOTFILES.has(entry)) continue;
|
|
140
|
+
if (isGeneratedArtifact(entry)) continue;
|
|
128
141
|
if (entry === 'tsconfig.json' || entry === 'package-lock.json') continue;
|
|
129
142
|
|
|
130
143
|
// Skip directories — this check is for configuration FILES, not dirs.
|
|
@@ -5,6 +5,7 @@
|
|
|
5
5
|
import { existsSync, readFileSync, readdirSync, statSync } from 'node:fs';
|
|
6
6
|
import { resolve, join, extname, basename } from 'node:path';
|
|
7
7
|
import { resolveSourceRoots } from '../shared-source.mjs';
|
|
8
|
+
import { relPosix } from '../shared-ignore.mjs';
|
|
8
9
|
|
|
9
10
|
const IGNORE_DIRS = new Set([
|
|
10
11
|
'node_modules', '.git', '.next', '.nuxt', 'dist', 'build', 'out',
|
|
@@ -103,7 +104,7 @@ export function validateDocsSync(projectDir, config) {
|
|
|
103
104
|
const ext = extname(file);
|
|
104
105
|
if (!['.ts', '.tsx', '.js', '.jsx', '.mjs', '.py', '.java', '.go'].includes(ext)) continue;
|
|
105
106
|
|
|
106
|
-
const relPath =
|
|
107
|
+
const relPath = relPosix(projectDir, file);
|
|
107
108
|
if (isTestFile(relPath)) continue;
|
|
108
109
|
if (!isValidRouteFile(relPath)) continue;
|
|
109
110
|
// N-1: skip files outside the --changed-only scope.
|
|
@@ -129,7 +130,7 @@ export function validateDocsSync(projectDir, config) {
|
|
|
129
130
|
const ext = extname(file);
|
|
130
131
|
if (!['.ts', '.tsx', '.js', '.jsx', '.mjs', '.py', '.java', '.go'].includes(ext)) continue;
|
|
131
132
|
|
|
132
|
-
const relPath =
|
|
133
|
+
const relPath = relPosix(projectDir, file);
|
|
133
134
|
if (isTestFile(relPath)) continue;
|
|
134
135
|
// N-1: skip files outside the --changed-only scope.
|
|
135
136
|
if (!inScope(relPath)) continue;
|
|
@@ -175,7 +176,7 @@ export function validateDocsSync(projectDir, config) {
|
|
|
175
176
|
const ext = extname(file);
|
|
176
177
|
if (!['.ts', '.tsx', '.js', '.jsx', '.mjs'].includes(ext)) continue;
|
|
177
178
|
|
|
178
|
-
const relPathForFilter =
|
|
179
|
+
const relPathForFilter = relPosix(projectDir, file);
|
|
179
180
|
if (isTestFile(relPathForFilter)) continue;
|
|
180
181
|
if (!isValidRouteFile(relPathForFilter)) continue;
|
|
181
182
|
|
package/cli/validators/drift.mjs
CHANGED
|
@@ -4,6 +4,7 @@
|
|
|
4
4
|
|
|
5
5
|
import { existsSync, readFileSync, readdirSync, statSync } from 'node:fs';
|
|
6
6
|
import { resolve, join, extname } from 'node:path';
|
|
7
|
+
import { relPosix } from '../shared-ignore.mjs';
|
|
7
8
|
|
|
8
9
|
const CODE_EXTENSIONS = new Set([
|
|
9
10
|
'.js', '.mjs', '.cjs', '.ts', '.tsx', '.jsx',
|
|
@@ -31,7 +32,7 @@ export function validateDrift(projectDir, config) {
|
|
|
31
32
|
// string fixtures (e.g. `'// DRIFT: a-drift\n'`). Reading the test as
|
|
32
33
|
// source would treat the string as a real drift comment. Skip test
|
|
33
34
|
// files unless the user opts in — same pattern TODO-Tracking uses.
|
|
34
|
-
const rel =
|
|
35
|
+
const rel = relPosix(projectDir, filePath);
|
|
35
36
|
const includeTests = config?.drift?.includeTestFiles === true;
|
|
36
37
|
if (!includeTests && /(^|\/)(__tests__|tests?|spec)\/|\.(test|spec)\.[^.]+$/.test(rel)) {
|
|
37
38
|
return;
|
|
@@ -44,7 +45,7 @@ export function validateDrift(projectDir, config) {
|
|
|
44
45
|
const match = line.match(/(?:\/\/|#|\/\*|\-\-)\s*DRIFT:\s*(.+)/i);
|
|
45
46
|
if (match) {
|
|
46
47
|
driftComments.push({
|
|
47
|
-
file:
|
|
48
|
+
file: relPosix(projectDir, filePath),
|
|
48
49
|
line: i + 1,
|
|
49
50
|
comment: match[1].trim(),
|
|
50
51
|
});
|