docguard-cli 0.22.1 → 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 +4 -4
- package/cli/commands/demo.mjs +1 -1
- package/cli/commands/diff.mjs +19 -8
- 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 -101
- package/cli/commands/upgrade.mjs +61 -13
- package/cli/config.mjs +245 -0
- package/cli/docguard.mjs +21 -217
- 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/scanners/speckit.mjs +14 -0
- 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 +118 -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 +27 -44
- package/cli/validators/docs-coverage.mjs +13 -0
- package/cli/validators/docs-diff.mjs +16 -6
- 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 +12 -54
- 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) => {
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared trace patterns — the single source of truth for doc→code traceability,
|
|
3
|
+
* used by BOTH the `docguard trace` command (cli/commands/trace.mjs) and the
|
|
4
|
+
* guard-time Traceability validator (cli/validators/traceability.mjs).
|
|
5
|
+
*
|
|
6
|
+
* Previously each file had its own copy: trace.mjs was multilingual (v0.16-P2)
|
|
7
|
+
* while the validator stayed JS/TS-only, so the README's "language-aware trace
|
|
8
|
+
* mapping" claim was false for the `guard` path. Sharing here makes the claim
|
|
9
|
+
* true and prevents the two from drifting again (field-report Issue 3).
|
|
10
|
+
*
|
|
11
|
+
* The API-REFERENCE entry additionally carries the explicit Next.js App Router
|
|
12
|
+
* pattern (app/api, pages/api) preserved from the v0.22.0 #195 fix.
|
|
13
|
+
*/
|
|
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
|
+
|
|
28
|
+
export const TEST_PATTERNS = [
|
|
29
|
+
// JS/TS
|
|
30
|
+
/\.test\.[jt]sx?$/, /\.spec\.[jt]sx?$/, /\.test\.(mjs|cjs)$/,
|
|
31
|
+
// Python — pytest conventions
|
|
32
|
+
/(^|\/)test_[^/]+\.py$/, /[^/]+_test\.py$/, /(^|\/)tests?\/[^/]+\.py$/,
|
|
33
|
+
// Go
|
|
34
|
+
/_test\.go$/,
|
|
35
|
+
// Java/Kotlin — JUnit/TestNG conventions
|
|
36
|
+
/(?:Test|Tests|Spec|IT)\.(?:java|kt)$/,
|
|
37
|
+
// Rust — tests live in tests/ or as #[cfg(test)] modules; pattern below covers integration tests
|
|
38
|
+
/(^|\/)tests\/[^/]+\.rs$/,
|
|
39
|
+
// Ruby/RSpec
|
|
40
|
+
/_spec\.rb$/, /_test\.rb$/,
|
|
41
|
+
// PHP/PHPUnit
|
|
42
|
+
/Test\.php$/, /(^|\/)tests?\/[^/]+\.php$/,
|
|
43
|
+
];
|
|
44
|
+
|
|
45
|
+
export const TRACE_MAP = {
|
|
46
|
+
'ARCHITECTURE.md': {
|
|
47
|
+
standard: 'arc42 / C4 Model',
|
|
48
|
+
sourcePatterns: [
|
|
49
|
+
// Entry points: JS (index/main/app/server.[jt]sx?), Python (__main__.py, main.py, app.py, cli.py),
|
|
50
|
+
// Go (main.go, cmd/), Rust (main.rs, lib.rs), Java (Application.java, Main.java)
|
|
51
|
+
{ label: 'Entry points', glob: /(?:^|\/)(?:index|main|app|server|cli|__main__|Application|Main)\.(?:[jt]sx?|mjs|cjs|py|go|rs|java|kt|rb)$|(?:^|\/)cmd\// },
|
|
52
|
+
// Config files: JS (package.json/tsconfig/next.config/vite.config), Python (pyproject.toml/setup.py/setup.cfg),
|
|
53
|
+
// Rust (Cargo.toml), Go (go.mod), Java/Kotlin (pom.xml/build.gradle), Ruby (Gemfile), PHP (composer.json)
|
|
54
|
+
{ label: 'Config files', glob: /(?:^|\/)(?:package\.json|tsconfig|next\.config|vite\.config|pyproject\.toml|setup\.(?:py|cfg)|Cargo\.toml|go\.mod|pom\.xml|build\.gradle|Gemfile|composer\.json)/ },
|
|
55
|
+
// Route handlers + module dirs
|
|
56
|
+
{ label: 'Route handlers / modules', glob: /(?:^|\/)(?:routes?|api|pages|app|controllers?|handlers?|views?|services?)\// },
|
|
57
|
+
],
|
|
58
|
+
},
|
|
59
|
+
'DATA-MODEL.md': {
|
|
60
|
+
standard: 'C4 Component / ER (Chen)',
|
|
61
|
+
sourcePatterns: [
|
|
62
|
+
// Schema/model files: JS (schema/model/entity/migration/prisma), Python (models.py/schema.py/Pydantic/SQLAlchemy),
|
|
63
|
+
// Go (models/), Rust (struct definitions in models/), Java (entities/)
|
|
64
|
+
{ label: 'Schema definitions', glob: /(?:schema|model|entity|migration|prisma)/i },
|
|
65
|
+
// Type definitions: JS types.ts, Python types.py, Rust types.rs
|
|
66
|
+
{ label: 'Type definitions', glob: /(?:^|\/)types?\.(?:[jt]sx?|mjs|py|rs|go|java|kt)$/ },
|
|
67
|
+
// ORM/database libs (any language)
|
|
68
|
+
{ label: 'Database configs', glob: /(?:drizzle|knex|sequelize|typeorm|sqlalchemy|alembic|django|diesel|sqlx|gorm|hibernate|active.?record)/i },
|
|
69
|
+
],
|
|
70
|
+
},
|
|
71
|
+
'TEST-SPEC.md': {
|
|
72
|
+
standard: 'ISO/IEC/IEEE 29119-3',
|
|
73
|
+
sourcePatterns: [
|
|
74
|
+
// Test files in any ecosystem (mirrors TEST_PATTERNS above)
|
|
75
|
+
{ label: 'Test files', glob: /\.(?:test|spec)\.(?:mjs|cjs|[jt]sx?)$|(?:^|\/)test_[^/]+\.py$|[^/]+_test\.py$|_test\.go$|(?:Test|Spec|IT)\.(?:java|kt)$|(?:^|\/)tests?\/[^/]+\.(?:rs|py|rb|php)$|_(?:spec|test)\.rb$|Test\.php$/ },
|
|
76
|
+
// Test runner configs: JS (jest/vitest/playwright/cypress), Python (pytest.ini/tox.ini), Rust (Cargo.toml has [[test]]),
|
|
77
|
+
// Java (pom.xml/build.gradle), Go (no config file typically)
|
|
78
|
+
{ label: 'Test config', glob: /(?:jest|vitest|playwright|cypress|pytest|tox|phpunit)\.config|(?:^|\/)pytest\.ini$|(?:^|\/)tox\.ini$|(?:^|\/)phpunit\.xml$/ },
|
|
79
|
+
{ label: 'E2E / integration tests', glob: /(?:^|\/)(?:e2e|integration|tests?\/integration)\// },
|
|
80
|
+
],
|
|
81
|
+
},
|
|
82
|
+
'SECURITY.md': {
|
|
83
|
+
standard: 'OWASP ASVS v4.0',
|
|
84
|
+
sourcePatterns: [
|
|
85
|
+
// Auth modules — semantic, language-agnostic
|
|
86
|
+
{ label: 'Auth modules', glob: /(?:auth|login|session|jwt|oauth|middleware|guard|csrf|cors|permissions?|policy)/i },
|
|
87
|
+
// Secret configs — .env family + secrets.* / keyring patterns
|
|
88
|
+
{ label: 'Secret configs', glob: /\.env(?:\.|$)|(?:^|\/)secrets?\.(?:py|js|ts|yaml|yml|json)$|keyring/i },
|
|
89
|
+
// Gitignore + ignore files
|
|
90
|
+
{ label: 'Ignore files', glob: /^\.(?:git|docker|npm)ignore$/ },
|
|
91
|
+
],
|
|
92
|
+
},
|
|
93
|
+
'ENVIRONMENT.md': {
|
|
94
|
+
standard: '12-Factor App',
|
|
95
|
+
sourcePatterns: [
|
|
96
|
+
// .env family across all ecosystems
|
|
97
|
+
{ label: 'Env files', glob: /\.env(?:\.|$)|(?:^|\/)\.envrc$/ },
|
|
98
|
+
// Containerization
|
|
99
|
+
{ label: 'Container configs', glob: /(?:^|\/)(?:Dockerfile|docker-compose|\.dockerignore|Containerfile)/ },
|
|
100
|
+
// Python venv / requirements / lock files
|
|
101
|
+
{ label: 'Python env', glob: /(?:^|\/)(?:requirements[^/]*\.txt|Pipfile|poetry\.lock|uv\.lock|pyproject\.toml)$/ },
|
|
102
|
+
// CI/CD configs
|
|
103
|
+
{ label: 'CI/CD configs', glob: /(?:^|\/)\.(?:github|gitlab-ci|circleci|drone|gitea)/ },
|
|
104
|
+
],
|
|
105
|
+
},
|
|
106
|
+
'API-REFERENCE.md': {
|
|
107
|
+
standard: 'OpenAPI 3.1',
|
|
108
|
+
sourcePatterns: [
|
|
109
|
+
// Route handlers + Python views/urls + Java/Spring controllers
|
|
110
|
+
{ label: 'Route handlers', glob: /(?:^|\/)(?:routes?|controllers?|handlers?|views?|urls?\.py)/ },
|
|
111
|
+
{ label: 'Next.js API routes', glob: /(^|\/)(app|pages)\/api\// },
|
|
112
|
+
// OpenAPI / API specs
|
|
113
|
+
{ label: 'API spec', glob: /(?:openapi|swagger|asyncapi)\.(?:json|ya?ml)/ },
|
|
114
|
+
// Middleware / decorators
|
|
115
|
+
{ label: 'API middleware', glob: /(?:^|\/)middleware\/|decorators?\.py$/ },
|
|
116
|
+
],
|
|
117
|
+
},
|
|
118
|
+
};
|
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
|
/**
|
|
@@ -516,6 +480,21 @@ function getCanonicalDocs(projectDir) {
|
|
|
516
480
|
* paragraphs are scored. Documents that are mostly tables/code/reference
|
|
517
481
|
* material are skipped for readability (they'd score 0/100 unfairly).
|
|
518
482
|
*/
|
|
483
|
+
/**
|
|
484
|
+
* Parse a per-doc quality-rule override marker, e.g.
|
|
485
|
+
* <!-- docguard:quality negation-load off — security doc, prohibitive language is precise -->
|
|
486
|
+
* <!-- docguard:quality negation-load 0.35 — operational doc -->
|
|
487
|
+
* Returns { off: true } | { threshold: <number> } | null. A required reason
|
|
488
|
+
* after the value is encouraged (and self-documenting) but not enforced here.
|
|
489
|
+
*/
|
|
490
|
+
function parseQualityOverride(content, rule) {
|
|
491
|
+
const re = new RegExp('<!--\\s*docguard:quality\\s+' + rule + '\\s+(off|\\d*\\.?\\d+)\\b', 'i');
|
|
492
|
+
const m = content.match(re);
|
|
493
|
+
if (!m) return null;
|
|
494
|
+
const v = m[1].toLowerCase();
|
|
495
|
+
return v === 'off' ? { off: true } : { threshold: parseFloat(v) };
|
|
496
|
+
}
|
|
497
|
+
|
|
519
498
|
function analyzeDocument(doc) {
|
|
520
499
|
const content = readFileSync(doc.path, 'utf-8');
|
|
521
500
|
const proseText = extractProse(content);
|
|
@@ -554,6 +533,7 @@ function analyzeDocument(doc) {
|
|
|
554
533
|
conditionalLoad: conditional.ratio,
|
|
555
534
|
},
|
|
556
535
|
details: { passive, ambiguous, atomicity, negation, conditional },
|
|
536
|
+
overrides: { negationLoad: parseQualityOverride(content, 'negation-load') },
|
|
557
537
|
};
|
|
558
538
|
}
|
|
559
539
|
|
|
@@ -571,10 +551,6 @@ export function validateDocQuality(projectDir, config) {
|
|
|
571
551
|
return results;
|
|
572
552
|
}
|
|
573
553
|
|
|
574
|
-
// Check for optional understanding CLI
|
|
575
|
-
const understandingCli = findUnderstandingCli();
|
|
576
|
-
const useDeepScan = config.docQuality?.deepScan !== false && understandingCli;
|
|
577
|
-
|
|
578
554
|
for (const doc of docs) {
|
|
579
555
|
if (!existsSync(doc.path)) continue;
|
|
580
556
|
|
|
@@ -650,13 +626,20 @@ export function validateDocQuality(projectDir, config) {
|
|
|
650
626
|
}
|
|
651
627
|
|
|
652
628
|
// ── Check 7: Negation Load ──
|
|
629
|
+
// Per-doc override (security/operational docs legitimately use "never",
|
|
630
|
+
// "must not", "cannot") and a project-wide config threshold both honored.
|
|
653
631
|
results.total++;
|
|
654
|
-
|
|
632
|
+
const negOv = analysis.overrides?.negationLoad;
|
|
633
|
+
const negThreshold = negOv?.threshold
|
|
634
|
+
?? config.docQuality?.negationLoadThreshold
|
|
635
|
+
?? THRESHOLDS.negationLoad.warn;
|
|
636
|
+
if (negOv?.off || m.negationLoad <= negThreshold) {
|
|
655
637
|
results.passed++;
|
|
656
638
|
} else {
|
|
657
639
|
results.warnings.push(
|
|
658
640
|
`${doc.name}: High negation load (${(m.negationLoad * 100).toFixed(0)}% of sentences use negation). ` +
|
|
659
|
-
`Rephrase in positive terms: "must not fail" → "must succeed" (IEEE 830 §4.3)`
|
|
641
|
+
`Rephrase in positive terms: "must not fail" → "must succeed" (IEEE 830 §4.3). ` +
|
|
642
|
+
`If the negation is intentional, add: <!-- docguard:quality negation-load off — your reason -->`
|
|
660
643
|
);
|
|
661
644
|
}
|
|
662
645
|
|
|
@@ -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.
|