docguard-cli 0.40.5 → 0.41.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/CHANGELOG.md +3218 -0
- package/README.md +25 -16
- package/cli/assessment.mjs +94 -0
- package/cli/commands/ci.mjs +15 -5
- package/cli/commands/diagnose.mjs +20 -13
- package/cli/commands/fix.mjs +14 -45
- package/cli/commands/guard.mjs +53 -25
- package/cli/commands/hooks.mjs +51 -10
- package/cli/commands/init.mjs +15 -0
- package/cli/commands/reconcile.mjs +10 -3
- package/cli/commands/report.mjs +5 -1
- package/cli/commands/score.mjs +2 -1
- package/cli/commands/upgrade.mjs +4 -1
- package/cli/commands/verify.mjs +9 -2
- package/cli/commands/watch.mjs +3 -2
- package/cli/config.mjs +23 -0
- package/cli/evidence/adapters.mjs +14 -0
- package/cli/evidence/manifest.mjs +15 -0
- package/cli/evidence/python-literal.mjs +304 -0
- package/cli/findings.mjs +17 -3
- package/cli/scanners/instruction-audit.mjs +88 -11
- package/cli/scanners/js-ast.mjs +156 -18
- package/cli/scanners/reconciliation.mjs +56 -6
- package/cli/scanners/routes.mjs +84 -9
- package/cli/scanners/spec-registry.mjs +29 -0
- package/cli/shared-git.mjs +98 -0
- package/cli/shared-ignore.mjs +1 -1
- package/cli/shared.mjs +30 -1
- package/cli/validators/api-doc-smells.mjs +2 -2
- package/cli/validators/api-surface.mjs +4 -9
- package/cli/validators/diff-suspicion.mjs +3 -2
- package/cli/validators/docs-sync.mjs +45 -29
- package/cli/validators/environment.mjs +64 -6
- package/cli/validators/metrics-consistency.mjs +52 -11
- package/cli/validators/reference-existence.mjs +4 -2
- package/cli/validators/security.mjs +37 -12
- package/cli/validators/spec-registry.mjs +10 -7
- package/cli/validators/todo-tracking.mjs +31 -11
- package/cli/validators/traceability.mjs +29 -4
- package/cli/writers/junit.mjs +3 -3
- package/cli/writers/sarif.mjs +13 -9
- package/docs/configuration.md +12 -1
- package/extensions/spec-kit-docguard/extension.yml +1 -1
- 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 -2
- package/extensions/spec-kit-docguard/templates/github-workflows/docguard-autofix.yml +1 -1
- package/extensions/spec-kit-docguard/templates/github-workflows/docguard-guard.yml +1 -1
- package/package.json +2 -1
- package/schemas/docguard-config.schema.json +15 -1
- package/schemas/docguard-evidence.schema.json +12 -0
- package/templates/ci/github-actions.yml +1 -1
- package/templates/evidence-manifest.json +16 -0
package/cli/shared.mjs
CHANGED
|
@@ -2,6 +2,8 @@
|
|
|
2
2
|
* Shared constants for DocGuard CLI — colors, profiles, version.
|
|
3
3
|
* Extracted from docguard.mjs to break circular dependencies.
|
|
4
4
|
* All commands import from here instead of docguard.mjs.
|
|
5
|
+
* @implements docguard.adoption-workflow-integrity#FR-007
|
|
6
|
+
* @implements docguard.adoption-workflow-integrity#FR-008
|
|
5
7
|
*/
|
|
6
8
|
|
|
7
9
|
/**
|
|
@@ -13,7 +15,7 @@
|
|
|
13
15
|
* `.docguard.json.version` is BEHIND this constant — pointing users at
|
|
14
16
|
* `docguard upgrade` to migrate.
|
|
15
17
|
*/
|
|
16
|
-
export const CURRENT_SCHEMA_VERSION = '0.
|
|
18
|
+
export const CURRENT_SCHEMA_VERSION = '0.6';
|
|
17
19
|
|
|
18
20
|
/**
|
|
19
21
|
* Allowed severity values for per-validator `severity` overrides in
|
|
@@ -39,6 +41,33 @@ export function resolveSeverity(config, validatorKey) {
|
|
|
39
41
|
return 'medium';
|
|
40
42
|
}
|
|
41
43
|
|
|
44
|
+
/**
|
|
45
|
+
* Resolve the enforcement level for one structured finding. Exact finding-code
|
|
46
|
+
* policy wins over validator policy. Without an exact override, intrinsic
|
|
47
|
+
* errors remain blocking and validator severity only reweights warnings.
|
|
48
|
+
*/
|
|
49
|
+
export function resolveFindingEnforcement(config, finding, validatorKey) {
|
|
50
|
+
const code = typeof finding?.code === 'string' ? finding.code.toUpperCase() : null;
|
|
51
|
+
const exact = code && config?.findingSeverity?.[code];
|
|
52
|
+
const normalizedExact = typeof exact === 'string' ? exact.toLowerCase() : null;
|
|
53
|
+
if (normalizedExact && SEVERITY_LEVELS.has(normalizedExact)) {
|
|
54
|
+
return {
|
|
55
|
+
level: normalizedExact === 'high' ? 'error' : normalizedExact === 'medium' ? 'warn' : 'info',
|
|
56
|
+
source: 'finding',
|
|
57
|
+
key: code,
|
|
58
|
+
};
|
|
59
|
+
}
|
|
60
|
+
if (finding?.severity === 'error') {
|
|
61
|
+
return { level: 'error', source: 'intrinsic', key: code };
|
|
62
|
+
}
|
|
63
|
+
const validatorSeverity = resolveSeverity(config, validatorKey);
|
|
64
|
+
return {
|
|
65
|
+
level: validatorSeverity === 'high' ? 'error' : validatorSeverity === 'low' ? 'info' : 'warn',
|
|
66
|
+
source: 'validator',
|
|
67
|
+
key: validatorKey,
|
|
68
|
+
};
|
|
69
|
+
}
|
|
70
|
+
|
|
42
71
|
// ── Canonical section heading matching ─────────────────────────────────────
|
|
43
72
|
/**
|
|
44
73
|
* Required canonical sections used to be matched by literal substring, so an
|
|
@@ -122,7 +122,7 @@ export function validateApiDocSmells(projectDir, config = {}) {
|
|
|
122
122
|
confidence: 'low',
|
|
123
123
|
message: `${f}: "${u.heading.slice(0, 60)}" is documented in name only (${prose} words of explanation) — Lazy API doc.`,
|
|
124
124
|
location: { file: f, line: u.line },
|
|
125
|
-
suggestion: {
|
|
125
|
+
suggestion: { kind: 'review', text: `Describe what "${u.heading.slice(0, 40)}" does, its params, return, and errors — not just its signature.` },
|
|
126
126
|
}));
|
|
127
127
|
} else if (total >= bloatedMin) {
|
|
128
128
|
findings.push(mkFinding({
|
|
@@ -132,7 +132,7 @@ export function validateApiDocSmells(projectDir, config = {}) {
|
|
|
132
132
|
confidence: 'low',
|
|
133
133
|
message: `${f}: "${u.heading.slice(0, 60)}" is ${total} words for one unit — Bloated API doc; trim to the essential contract.`,
|
|
134
134
|
location: { file: f, line: u.line },
|
|
135
|
-
suggestion: {
|
|
135
|
+
suggestion: { kind: 'review', text: `Split or trim "${u.heading.slice(0, 40)}" — move examples/edge-cases elsewhere and keep the core contract.` },
|
|
136
136
|
}));
|
|
137
137
|
}
|
|
138
138
|
}
|
|
@@ -405,9 +405,7 @@ export function validateApiSurface(projectDir, config) {
|
|
|
405
405
|
confidence: 'high', // certain contract omission, NOT certain code absence
|
|
406
406
|
message: `Documented endpoint missing from OpenAPI contract (${source}): ${e.method} ${e.path} (${API_DOC}). ${codeDescription}`,
|
|
407
407
|
location: API_DOC,
|
|
408
|
-
suggestion: e.codeEvidence.status === '
|
|
409
|
-
? { kind: 'fix', text: 'Remove the endpoint omitted from the contract and not found by the code scan; verify scanner coverage before applying', command: 'docguard fix --write' }
|
|
410
|
-
: { kind: 'review', text: e.codeEvidence.status === 'present'
|
|
408
|
+
suggestion: { kind: 'review', text: e.codeEvidence.status === 'present'
|
|
411
409
|
? 'Reconcile the implementation with the intended contract; update OpenAPI if the route is intended. Preserve the documented endpoint during review.'
|
|
412
410
|
: 'Reconcile the contract and documentation with the intended API. Verify implementation coverage and whether the endpoint was removed before editing documentation.' },
|
|
413
411
|
}),
|
|
@@ -424,12 +422,9 @@ export function validateApiSurface(projectDir, config) {
|
|
|
424
422
|
}
|
|
425
423
|
}
|
|
426
424
|
|
|
427
|
-
//
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
fixes.push({ type: 'remove-endpoint', method: e.method, path: e.path, doc: API_DOC });
|
|
431
|
-
}
|
|
432
|
-
}
|
|
425
|
+
// A route scanner can prove presence, but unsupported syntax means it cannot
|
|
426
|
+
// prove absence. Contract omissions therefore remain review-only and never
|
|
427
|
+
// become mechanical deletion candidates, even when no route was extracted.
|
|
433
428
|
|
|
434
429
|
// Without a spec, a negative scan remains a low-confidence review candidate.
|
|
435
430
|
if (confidence !== 'spec' && documentedButAbsent.length) {
|
|
@@ -185,7 +185,8 @@ export function validateDiffSuspicion(projectDir, config = {}) {
|
|
|
185
185
|
message: `${docName} describes ${h.path} (${h.kind} ref), and overlaps old-side diff tokens: ${h.shared.slice(0, 5).join(', ')}${h.shared.length > 5 ? '…' : ''} or removed declaration names (${ref}..HEAD) — possible drift; review whether the documentation is affected.`,
|
|
186
186
|
location: { file: docName },
|
|
187
187
|
suggestion: {
|
|
188
|
-
|
|
188
|
+
kind: 'review',
|
|
189
|
+
text: `Re-read ${docName} against the current ${h.path}; the old-side tokens (${h.shared.slice(0, 8).join(', ')}) do not establish a semantic contradiction.`,
|
|
189
190
|
},
|
|
190
191
|
}));
|
|
191
192
|
}
|
|
@@ -197,7 +198,7 @@ export function validateDiffSuspicion(projectDir, config = {}) {
|
|
|
197
198
|
confidence: 'low',
|
|
198
199
|
message: `${docName} references ${hits.length - maxPerDoc} more changed file(s) with old-side token overlap (${ref}..HEAD) — a broad change; review ${docName} as a whole.`,
|
|
199
200
|
location: { file: docName },
|
|
200
|
-
suggestion: {
|
|
201
|
+
suggestion: { kind: 'review', text: `${docName} looks broadly affected by this change set — review it end-to-end rather than line by line.` },
|
|
201
202
|
}));
|
|
202
203
|
}
|
|
203
204
|
}
|
|
@@ -12,6 +12,7 @@ import { resolve, join, extname, basename } from 'node:path';
|
|
|
12
12
|
import { resolveSourceRoots } from '../shared-source.mjs';
|
|
13
13
|
import { relPosix, walkFiles as sharedWalkFiles, listCanonicalDocs } from '../shared-ignore.mjs';
|
|
14
14
|
import { mkFinding, resultFromFindings } from '../findings.mjs';
|
|
15
|
+
import { findAllOpenApiSpecs } from './api-surface.mjs';
|
|
15
16
|
|
|
16
17
|
const IGNORE_DIRS = new Set([
|
|
17
18
|
'node_modules', '.git', '.next', '.nuxt', 'dist', 'build', 'out',
|
|
@@ -46,6 +47,38 @@ function isValidRouteFile(relPath) {
|
|
|
46
47
|
return true;
|
|
47
48
|
}
|
|
48
49
|
|
|
50
|
+
/**
|
|
51
|
+
* `src/lib` is a generic utility convention in frontend and full-stack repos,
|
|
52
|
+
* not evidence that every file beneath it is an architectural service. Keep
|
|
53
|
+
* explicit service directories exhaustive; require a service-shaped filename
|
|
54
|
+
* for the ambiguous `src/lib` fallback.
|
|
55
|
+
*/
|
|
56
|
+
function isServiceCandidate(file, serviceDir) {
|
|
57
|
+
const normalizedDir = serviceDir.replace(/\\/g, '/').replace(/\/$/, '');
|
|
58
|
+
if (!normalizedDir.endsWith('/src/lib')) return true;
|
|
59
|
+
|
|
60
|
+
const name = basename(file, extname(file));
|
|
61
|
+
return /(?:^|[-_.])services?$/i.test(name) || /Service$/i.test(name);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function normalizeApiPath(path) {
|
|
65
|
+
const normalized = String(path || '')
|
|
66
|
+
.trim()
|
|
67
|
+
.toLowerCase()
|
|
68
|
+
.replace(/:[a-z_][a-z0-9_]*/gi, '{}')
|
|
69
|
+
.replace(/\{[^/{}]+\}/g, '{}')
|
|
70
|
+
.replace(/\/+$/g, '');
|
|
71
|
+
return normalized || '/';
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function routeMatchesOpenApi(routePath, specPaths) {
|
|
75
|
+
const route = normalizeApiPath(routePath);
|
|
76
|
+
return specPaths.some(specPath => {
|
|
77
|
+
const spec = normalizeApiPath(specPath);
|
|
78
|
+
return spec === route || (route !== '/' && spec.endsWith(route));
|
|
79
|
+
});
|
|
80
|
+
}
|
|
81
|
+
|
|
49
82
|
/**
|
|
50
83
|
* Expand sub-path patterns (e.g. 'routes', 'src/routes') against the project
|
|
51
84
|
* root AND every configured source root, returning de-duplicated existing dirs.
|
|
@@ -146,6 +179,7 @@ export function validateDocsSync(projectDir, config) {
|
|
|
146
179
|
|
|
147
180
|
const relPath = relPosix(projectDir, file);
|
|
148
181
|
if (isTestFile(relPath)) continue;
|
|
182
|
+
if (!isServiceCandidate(file, serviceDir)) continue;
|
|
149
183
|
// N-1: skip files outside the --changed-only scope.
|
|
150
184
|
if (!inScope(relPath)) continue;
|
|
151
185
|
|
|
@@ -169,27 +203,11 @@ export function validateDocsSync(projectDir, config) {
|
|
|
169
203
|
|
|
170
204
|
// ── Cross-check route files against OpenAPI spec ──
|
|
171
205
|
// If an OpenAPI spec exists AND route files exist, verify routes have matching paths
|
|
172
|
-
const
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
'api/openapi.yaml', 'api/openapi.yml', 'api/openapi.json',
|
|
176
|
-
'docs/openapi.yaml', 'docs/openapi.yml',
|
|
177
|
-
];
|
|
178
|
-
|
|
179
|
-
let openapiContent = '';
|
|
180
|
-
let openapiFile = null;
|
|
181
|
-
for (const pattern of openapiPatterns) {
|
|
182
|
-
const specPath = resolve(projectDir, pattern);
|
|
183
|
-
if (existsSync(specPath)) {
|
|
184
|
-
try {
|
|
185
|
-
openapiContent = readFileSync(specPath, 'utf-8').toLowerCase();
|
|
186
|
-
openapiFile = pattern;
|
|
187
|
-
} catch { /* ignore */ }
|
|
188
|
-
break;
|
|
189
|
-
}
|
|
190
|
-
}
|
|
206
|
+
const authoritativeSpec = findAllOpenApiSpecs(projectDir, config)[0] || null;
|
|
207
|
+
const openapiPaths = authoritativeSpec?.endpoints.map(endpoint => endpoint.path) || [];
|
|
208
|
+
const openapiFile = authoritativeSpec?.relPath || null;
|
|
191
209
|
|
|
192
|
-
if (
|
|
210
|
+
if (openapiPaths.length > 0 && openapiFile) {
|
|
193
211
|
// Check that route files have corresponding paths in OpenAPI spec (monorepo-aware)
|
|
194
212
|
for (const routeDir of expandDirs(projectDir, config, ['src/routes', 'src/app/api', 'routes', 'app/api'])) {
|
|
195
213
|
const files = getFilesRecursive(routeDir);
|
|
@@ -222,12 +240,11 @@ export function validateDocsSync(projectDir, config) {
|
|
|
222
240
|
let matched = false;
|
|
223
241
|
|
|
224
242
|
if (actualRoutes.length > 0) {
|
|
225
|
-
//
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
});
|
|
243
|
+
// Express :param and OpenAPI {param} are equivalent. Compare whole
|
|
244
|
+
// path segments so `/users` cannot accidentally match `/superusers`.
|
|
245
|
+
// A route-local path may omit an application mount prefix, so an
|
|
246
|
+
// exact segment suffix is also accepted.
|
|
247
|
+
matched = actualRoutes.some(route => routeMatchesOpenApi(route, openapiPaths));
|
|
231
248
|
} else {
|
|
232
249
|
// Strategy 2 (fallback): Strip common suffixes and check filename
|
|
233
250
|
// userRoutes.ts → 'user', conversationRoutes.ts → 'conversation'
|
|
@@ -238,9 +255,8 @@ export function validateDocsSync(projectDir, config) {
|
|
|
238
255
|
.replace(/router$/i, '');
|
|
239
256
|
|
|
240
257
|
if (cleanName.length > 0) {
|
|
241
|
-
matched =
|
|
242
|
-
|
|
243
|
-
openapiContent.includes(`'${cleanName}'`);
|
|
258
|
+
matched = openapiPaths.some(path =>
|
|
259
|
+
normalizeApiPath(path).split('/').some(segment => segment === cleanName));
|
|
244
260
|
}
|
|
245
261
|
}
|
|
246
262
|
|
|
@@ -14,11 +14,71 @@ import { docRolePath, resolveDocRole } from '../shared-doc-roles.mjs';
|
|
|
14
14
|
* existing tests are unaffected; guard just renders richer output.
|
|
15
15
|
*/
|
|
16
16
|
|
|
17
|
-
import { existsSync, readFileSync } from 'node:fs';
|
|
18
|
-
import { resolve } from 'node:path';
|
|
19
|
-
import { grepEnvUsage } from '../shared-source.mjs';
|
|
17
|
+
import { existsSync, lstatSync, readFileSync } from 'node:fs';
|
|
18
|
+
import { dirname, isAbsolute, relative, resolve, sep } from 'node:path';
|
|
19
|
+
import { grepEnvUsage, resolveSourceRoots } from '../shared-source.mjs';
|
|
20
|
+
import { shouldIgnore } from '../shared-ignore.mjs';
|
|
20
21
|
import { mkFinding, resultFromFindings } from '../findings.mjs';
|
|
21
22
|
|
|
23
|
+
const ENV_TEMPLATE_NAMES = ['.env.example', '.env.template'];
|
|
24
|
+
|
|
25
|
+
function isWithin(projectRoot, path) {
|
|
26
|
+
const rel = relative(projectRoot, path);
|
|
27
|
+
return rel === '' || (rel !== '..' && !rel.startsWith(`..${sep}`) && !isAbsolute(rel));
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Discover repository-owned env templates at the root and package boundaries.
|
|
32
|
+
* Source roots may point inside a package (for example `backend/src`), so every
|
|
33
|
+
* ancestor up to the repository root is considered. `resolveSourceRoots()` also
|
|
34
|
+
* contributes declared npm/pnpm workspace packages. Candidates are de-duplicated
|
|
35
|
+
* before reading and rejected when ignored, outside the repository, or reached
|
|
36
|
+
* through a symlink.
|
|
37
|
+
*/
|
|
38
|
+
function discoverEnvTemplates(projectDir, config) {
|
|
39
|
+
const projectRoot = resolve(projectDir);
|
|
40
|
+
const candidateDirs = new Set([projectRoot]);
|
|
41
|
+
|
|
42
|
+
for (const sourceRoot of resolveSourceRoots(projectRoot, config)) {
|
|
43
|
+
let current = resolve(sourceRoot);
|
|
44
|
+
while (isWithin(projectRoot, current)) {
|
|
45
|
+
candidateDirs.add(current);
|
|
46
|
+
if (current === projectRoot) break;
|
|
47
|
+
const parent = dirname(current);
|
|
48
|
+
if (parent === current) break;
|
|
49
|
+
current = parent;
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
const templates = new Set();
|
|
54
|
+
for (const dir of candidateDirs) {
|
|
55
|
+
for (const name of ENV_TEMPLATE_NAMES) {
|
|
56
|
+
const abs = resolve(dir, name);
|
|
57
|
+
const rel = relative(projectRoot, abs);
|
|
58
|
+
if (!rel || !isWithin(projectRoot, abs)) continue;
|
|
59
|
+
const relPosix = rel.split(sep).join('/');
|
|
60
|
+
if (shouldIgnore(relPosix, config)) continue;
|
|
61
|
+
|
|
62
|
+
let current = projectRoot;
|
|
63
|
+
let safe = true;
|
|
64
|
+
for (const segment of rel.split(sep)) {
|
|
65
|
+
current = resolve(current, segment);
|
|
66
|
+
try {
|
|
67
|
+
const stat = lstatSync(current);
|
|
68
|
+
if (stat.isSymbolicLink()) { safe = false; break; }
|
|
69
|
+
if (current === abs && !stat.isFile()) { safe = false; break; }
|
|
70
|
+
} catch {
|
|
71
|
+
safe = false;
|
|
72
|
+
break;
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
if (safe) templates.add(abs);
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
return [...templates].sort();
|
|
80
|
+
}
|
|
81
|
+
|
|
22
82
|
export function validateEnvironment(projectDir, config) {
|
|
23
83
|
const findings = [];
|
|
24
84
|
let passed = 0;
|
|
@@ -109,9 +169,7 @@ export function validateEnvironment(projectDir, config) {
|
|
|
109
169
|
if (SYSTEM.has(m[1])) continue;
|
|
110
170
|
documented.add(m[1]);
|
|
111
171
|
}
|
|
112
|
-
for (const
|
|
113
|
-
const p = resolve(projectDir, envFile);
|
|
114
|
-
if (!existsSync(p)) continue;
|
|
172
|
+
for (const p of discoverEnvTemplates(projectDir, config)) {
|
|
115
173
|
const re = /^([A-Z][A-Z0-9_]*[A-Z0-9])\s*=/gm;
|
|
116
174
|
const ex = readFileSync(p, 'utf-8');
|
|
117
175
|
let em;
|
|
@@ -131,7 +131,7 @@ export function validateMetricsConsistency(projectDir, config, guardResults) {
|
|
|
131
131
|
// then emit ONE warning per distinct value. A file that says "20" on
|
|
132
132
|
// line 5 and "20" on line 50 is the same drift; "20" on line 5 and
|
|
133
133
|
// "19" on line 50 are two distinct drifts.
|
|
134
|
-
const
|
|
134
|
+
const occurrencesByValue = new Map();
|
|
135
135
|
while ((match = regex.exec(content)) !== null) {
|
|
136
136
|
// Bug #2 (subject-binding): for the built-in meta-counts, only validate a
|
|
137
137
|
// number BOUND to DocGuard. An unbound "N checks" (a proof harness, a CI
|
|
@@ -140,11 +140,17 @@ export function validateMetricsConsistency(projectDir, config, guardResults) {
|
|
|
140
140
|
// correct number. Project-declared collections (requireBind:false) skip
|
|
141
141
|
// this: naming the noun in `config.collections` IS the explicit binding.
|
|
142
142
|
if (requireBind && !isDocguardBound(content, match.index)) continue;
|
|
143
|
-
|
|
143
|
+
const found = parseInt(match[1], 10);
|
|
144
|
+
const occurrences = occurrencesByValue.get(found) || { current: 0, historical: 0 };
|
|
145
|
+
occurrences[isHistoricalMetricContext(content, match.index) ? 'historical' : 'current']++;
|
|
146
|
+
occurrencesByValue.set(found, occurrences);
|
|
144
147
|
}
|
|
145
|
-
if (
|
|
148
|
+
if (occurrencesByValue.size === 0) continue;
|
|
146
149
|
|
|
147
|
-
for (const found of
|
|
150
|
+
for (const [found, occurrences] of occurrencesByValue) {
|
|
151
|
+
// Historical transitions are evidence about an earlier release, not an
|
|
152
|
+
// assertion of the current count. Rewriting them would falsify history.
|
|
153
|
+
if (occurrences.current === 0) continue;
|
|
148
154
|
if (found > 0 && found !== actuals[key]) {
|
|
149
155
|
const driftKey = `${relPath}|${label}|${found}`;
|
|
150
156
|
if (reportedDrift.has(driftKey)) continue;
|
|
@@ -153,24 +159,33 @@ export function validateMetricsConsistency(projectDir, config, guardResults) {
|
|
|
153
159
|
const phrase = isCollection
|
|
154
160
|
? `the code has ${actuals[key]} (${glob})`
|
|
155
161
|
: `${subject} ${label} count is ${actuals[key]}`;
|
|
162
|
+
const safeToRewrite = occurrences.historical === 0;
|
|
156
163
|
findings.push(mkFinding({
|
|
157
164
|
code: isCollection ? 'MET002' : 'MET001',
|
|
158
165
|
validator: 'metricsConsistency',
|
|
159
166
|
severity: 'warn',
|
|
160
|
-
|
|
167
|
+
confidence: safeToRewrite ? 'high' : 'low',
|
|
168
|
+
reportable: !safeToRewrite,
|
|
169
|
+
message: `${relPath} says "${found} ${label}" but ${phrase}. ${safeToRewrite
|
|
170
|
+
? 'Fix with `docguard fix --write`'
|
|
171
|
+
: 'Review manually because the same count also appears in historical context'}`,
|
|
161
172
|
location: relPath,
|
|
162
173
|
suggestion: {
|
|
163
|
-
kind: 'fix',
|
|
164
|
-
text:
|
|
165
|
-
?
|
|
166
|
-
|
|
167
|
-
|
|
174
|
+
kind: safeToRewrite ? 'fix' : 'review',
|
|
175
|
+
text: safeToRewrite
|
|
176
|
+
? (isCollection
|
|
177
|
+
? `Confirm which side is right, then rewrite the stale count (${found} → ${actuals[key]})`
|
|
178
|
+
: `Rewrite the stale docguard-bound count (${found} → ${actuals[key]})`)
|
|
179
|
+
: `Review the current assertion without changing historical ${found} ${label} statements`,
|
|
180
|
+
...(safeToRewrite ? { command: 'docguard fix --write' } : {}),
|
|
168
181
|
},
|
|
169
182
|
}));
|
|
170
183
|
// actualSource records WHAT the actual count describes, so the applier
|
|
171
184
|
// (and a human) can confirm both sides are the same subject before any
|
|
172
185
|
// overwrite. Without it the fix is refused (fail-closed). See Bug #2.
|
|
173
|
-
|
|
186
|
+
if (safeToRewrite) {
|
|
187
|
+
fixes.push({ type: 'replace-count', file: relPath, label, found, actual: actuals[key], actualSource });
|
|
188
|
+
}
|
|
174
189
|
} else {
|
|
175
190
|
// Matches the actual count — one pass per (file, label), not per occurrence.
|
|
176
191
|
const passKey = `${relPath}|${label}`;
|
|
@@ -204,6 +219,32 @@ function isDocguardBound(content, index) {
|
|
|
204
219
|
return /docguard/i.test(content.slice(lineStart, lineEnd));
|
|
205
220
|
}
|
|
206
221
|
|
|
222
|
+
/**
|
|
223
|
+
* Return true when a metric occurrence describes history rather than current
|
|
224
|
+
* state. The classifier deliberately recognizes only explicit historical
|
|
225
|
+
* evidence: transition arrows/from-to wording, historical metadata, and
|
|
226
|
+
* release-history headings. Ambiguous prose remains visible, but a count that
|
|
227
|
+
* appears in both contexts is never handed to the mechanical replace-all fix.
|
|
228
|
+
*/
|
|
229
|
+
function isHistoricalMetricContext(content, index) {
|
|
230
|
+
const lineStart = content.lastIndexOf('\n', index) + 1;
|
|
231
|
+
let lineEnd = content.indexOf('\n', index);
|
|
232
|
+
if (lineEnd === -1) lineEnd = content.length;
|
|
233
|
+
const line = content.slice(lineStart, lineEnd);
|
|
234
|
+
|
|
235
|
+
if (/\d[\d,]*\s*(?:→|->|=>)\s*\d[\d,]*/.test(line)) return true;
|
|
236
|
+
if (/\b(?:increased|grew|rose|decreased|dropped|fell|expanded|reduced|changed|moved|went|bumped|upgraded)\s+from\s+\d[\d,]*\s+to\s+\d[\d,]*/i.test(line)) return true;
|
|
237
|
+
if (/\b(?:previously|formerly|historically|back then|at launch|in (?:release|version|v)\s*\d|during (?:the )?(?:release|upgrade|migration))\b/i.test(line)) return true;
|
|
238
|
+
|
|
239
|
+
const before = content.slice(0, lineStart);
|
|
240
|
+
const headings = [...before.matchAll(/^#{1,6}\s+(.+)$/gm)];
|
|
241
|
+
const heading = headings.at(-1)?.[1] || '';
|
|
242
|
+
if (/\b(?:change\s*log|release (?:notes|history)|version history|migration history|what changed)\b/i.test(heading)) return true;
|
|
243
|
+
|
|
244
|
+
const prefix = content.slice(0, Math.min(content.length, 4096));
|
|
245
|
+
return /<!--\s*docguard:status\s+(?:historical|superseded|deprecated|archived)\s*-->/i.test(prefix);
|
|
246
|
+
}
|
|
247
|
+
|
|
207
248
|
function findTestFiles(dir) {
|
|
208
249
|
const tests = [];
|
|
209
250
|
const testDirs = ['tests', 'test', '__tests__', 'spec', 'e2e'];
|
|
@@ -252,7 +252,8 @@ export function validateReferenceExistence(projectDir, config = {}) {
|
|
|
252
252
|
message: `${doc.name} references \`${sym}\`, which existed in the code when the doc was last updated but has ZERO matches at HEAD — likely renamed or removed.`,
|
|
253
253
|
location: { file: doc.name },
|
|
254
254
|
suggestion: {
|
|
255
|
-
|
|
255
|
+
kind: 'review',
|
|
256
|
+
text: `Update or remove the \`${sym}\` reference in ${doc.name} (or suppress if it is a still-relevant user-facing name).`,
|
|
256
257
|
},
|
|
257
258
|
}));
|
|
258
259
|
}
|
|
@@ -287,7 +288,8 @@ export function validateReferenceExistence(projectDir, config = {}) {
|
|
|
287
288
|
message,
|
|
288
289
|
location: { file: first.file, line: first.line },
|
|
289
290
|
suggestion: {
|
|
290
|
-
|
|
291
|
+
kind: 'review',
|
|
292
|
+
text: known.size > 0
|
|
291
293
|
? `Fix the number or write the missing ADR entry (or suppress with // docguard:ignore REF002 on the citation line).`
|
|
292
294
|
: `Create an ADR doc (docguard init writes templates/ADR.md) or suppress with // docguard:ignore REF002.`,
|
|
293
295
|
},
|
|
@@ -98,16 +98,21 @@ function quotedValue(matchStr) {
|
|
|
98
98
|
* are insufficient. Provider key signatures remain independently scanned.
|
|
99
99
|
* Unknown syntax/parser failure supplies no exemptions.
|
|
100
100
|
*/
|
|
101
|
-
function
|
|
101
|
+
function fixturePasswordEvidence(content, filename) {
|
|
102
102
|
if (!/(?:^|\/)__tests?__\/|\.(?:test|spec)\.[cm]?[jt]sx?$/.test(filename)) return [];
|
|
103
103
|
const { ast, ok } = parseJsTs(content, filename);
|
|
104
104
|
if (!ok || ast.errors?.length) return [];
|
|
105
|
-
const
|
|
105
|
+
const evidence = [];
|
|
106
|
+
const literalCounts = new Map();
|
|
107
|
+
walk(ast, node => {
|
|
108
|
+
if (node.type !== 'StringLiteral') return;
|
|
109
|
+
literalCounts.set(node.value, (literalCounts.get(node.value) || 0) + 1);
|
|
110
|
+
});
|
|
106
111
|
walk(ast, node => {
|
|
107
112
|
if (node.type !== 'CallExpression') return;
|
|
108
113
|
const callee = node.callee;
|
|
109
114
|
if (callee.type !== 'MemberExpression' || callee.computed ||
|
|
110
|
-
!/^(?:toHaveBeenCalledWith|toHaveBeenLastCalledWith|toHaveBeenNthCalledWith)$/.test(callee.property.name)) return;
|
|
115
|
+
!/^(?:toHaveBeenCalledWith|toHaveBeenLastCalledWith|toHaveBeenNthCalledWith|toMatchObject|toEqual|toStrictEqual)$/.test(callee.property.name)) return;
|
|
111
116
|
const expectation = callee.object;
|
|
112
117
|
if (expectation.type !== 'CallExpression' || expectation.callee.type !== 'Identifier' ||
|
|
113
118
|
expectation.callee.name !== 'expect') return;
|
|
@@ -119,13 +124,17 @@ function fixturePasswordRanges(content, filename) {
|
|
|
119
124
|
if (prop.type !== 'ObjectProperty' || prop.computed ||
|
|
120
125
|
!/^(?:password|passwd|pwd)$/i.test(prop.key.name || prop.key.value || '') ||
|
|
121
126
|
prop.value.type !== 'StringLiteral') continue;
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
127
|
+
const explicitSynthetic = /^(?:test|mock|dummy|fixture)[_-]?(?:password|passwd|pwd)[0-9!@#$%_*.-]*$/i.test(prop.value.value);
|
|
128
|
+
const repeatedFixtureValue = (literalCounts.get(prop.value.value) || 0) > 1;
|
|
129
|
+
if (explicitSynthetic || repeatedFixtureValue) evidence.push({
|
|
130
|
+
start: prop.start,
|
|
131
|
+
end: prop.end,
|
|
132
|
+
classification: explicitSynthetic ? 'safe' : 'probable',
|
|
133
|
+
});
|
|
125
134
|
}
|
|
126
135
|
}
|
|
127
136
|
});
|
|
128
|
-
return
|
|
137
|
+
return evidence;
|
|
129
138
|
}
|
|
130
139
|
|
|
131
140
|
export function validateSecurity(projectDir, config) {
|
|
@@ -153,7 +162,7 @@ export function validateSecurity(projectDir, config) {
|
|
|
153
162
|
scanned++;
|
|
154
163
|
const content = readFileSync(filePath, 'utf-8');
|
|
155
164
|
let lines = null;
|
|
156
|
-
let
|
|
165
|
+
let passwordFixtureEvidence = null;
|
|
157
166
|
|
|
158
167
|
for (const { pattern, label } of SECRET_PATTERNS) {
|
|
159
168
|
pattern.lastIndex = 0;
|
|
@@ -176,10 +185,27 @@ export function validateSecurity(projectDir, config) {
|
|
|
176
185
|
if (isSafePlaceholder(matchLine, match[0], label)) continue;
|
|
177
186
|
|
|
178
187
|
const code = LABEL_TO_CODE[label];
|
|
188
|
+
const location = `${relPath}:${lineNo}`;
|
|
179
189
|
if (code === 'SEC001') {
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
match.index + match[0].length <= end)
|
|
190
|
+
passwordFixtureEvidence ??= fixturePasswordEvidence(content, relPath);
|
|
191
|
+
const fixture = passwordFixtureEvidence.find(({ start, end }) => match.index >= start &&
|
|
192
|
+
match.index + match[0].length <= end);
|
|
193
|
+
if (fixture?.classification === 'safe') continue;
|
|
194
|
+
if (fixture?.classification === 'probable') {
|
|
195
|
+
findings.push(mkFinding({
|
|
196
|
+
code, validator: 'security', severity: 'warn', confidence: 'low',
|
|
197
|
+
message: `${location}: possible ${label}, but the same redacted value is used as test input and expected output`,
|
|
198
|
+
location,
|
|
199
|
+
suggestion: {
|
|
200
|
+
kind: 'review',
|
|
201
|
+
text: 'Confirm this repeated test value is synthetic. Use a clearly named fixture password or suppress this line with a reason.',
|
|
202
|
+
pragma: `// docguard:ignore ${code} — repeated synthetic test fixture`,
|
|
203
|
+
},
|
|
204
|
+
reportable: true,
|
|
205
|
+
redactedContext: `${label} pattern fired inside a test assertion and the same literal appears elsewhere in the test. Literal omitted.`,
|
|
206
|
+
}));
|
|
207
|
+
continue;
|
|
208
|
+
}
|
|
183
209
|
}
|
|
184
210
|
|
|
185
211
|
// v0.27 (#8): honour an inline `// docguard:ignore SEC00x` pragma on the
|
|
@@ -187,7 +213,6 @@ export function validateSecurity(projectDir, config) {
|
|
|
187
213
|
// whole file via `securityIgnore`.
|
|
188
214
|
if (code && lineSuppresses(code, matchLine, prevLine)) continue;
|
|
189
215
|
|
|
190
|
-
const location = `${relPath}:${lineNo}`;
|
|
191
216
|
const value = quotedValue(match[0]);
|
|
192
217
|
const isProse = looksLikeProse(value);
|
|
193
218
|
|
|
@@ -13,13 +13,16 @@ export function validateSpecRegistry(projectDir, config = {}) {
|
|
|
13
13
|
confidence: 'high',
|
|
14
14
|
message: issue.message,
|
|
15
15
|
location: issue.path,
|
|
16
|
-
suggestion:
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
16
|
+
suggestion: issue.code === 'SPR002'
|
|
17
|
+
? {
|
|
18
|
+
kind: 'review',
|
|
19
|
+
text: 'Add a unique immutable Spec ID to the authoritative spec using the `**Spec ID**: <unique-id>` or `<!-- docguard:spec-id <unique-id> -->` form; then run `docguard specs --write`.',
|
|
20
|
+
}
|
|
21
|
+
: {
|
|
22
|
+
kind: 'review',
|
|
23
|
+
text: 'Resolve the lifecycle or registry integrity conflict, then refresh the registry.',
|
|
24
|
+
command: 'docguard specs --write',
|
|
25
|
+
},
|
|
23
26
|
}));
|
|
24
27
|
if (!projection.current && projection.issues.length === 0) {
|
|
25
28
|
findings.push(mkFinding({
|
|
@@ -74,21 +74,38 @@ const SKIP_PATTERNS = [
|
|
|
74
74
|
/\bit\.todo\s*\(/,
|
|
75
75
|
];
|
|
76
76
|
|
|
77
|
-
|
|
78
|
-
|
|
77
|
+
function hasReasonText(value) {
|
|
78
|
+
return /(?:REASON|SKIP|TODO|FIXME|NOTE|WHY)\s*:[\s\S]*\S/i.test(value);
|
|
79
|
+
}
|
|
79
80
|
|
|
80
81
|
function hasAdjacentReason(content, call, comments) {
|
|
81
|
-
|
|
82
|
-
|
|
82
|
+
const ordered = [...comments].sort((a, b) => a.start - b.start);
|
|
83
|
+
return ordered.some((comment, index) => {
|
|
83
84
|
if (comment.end <= call.start) {
|
|
84
85
|
// A trailing comment belongs to the preceding statement.
|
|
85
86
|
const lineStart = content.lastIndexOf('\n', comment.start - 1) + 1;
|
|
86
87
|
if (!/^\s*$/.test(content.slice(lineStart, comment.start))) return false;
|
|
87
88
|
const gap = content.slice(comment.end, call.start);
|
|
88
|
-
|
|
89
|
+
if (call.loc.start.line - comment.loc.end.line !== 1 || !/^\s*$/.test(gap)) return false;
|
|
90
|
+
|
|
91
|
+
// Babel represents adjacent // lines as separate comments. Walk backward
|
|
92
|
+
// across only contiguous, full-line comments so `// REASON:` can introduce
|
|
93
|
+
// a multiline explanation without borrowing a disconnected annotation.
|
|
94
|
+
let first = index;
|
|
95
|
+
while (first > 0) {
|
|
96
|
+
const previous = ordered[first - 1];
|
|
97
|
+
const current = ordered[first];
|
|
98
|
+
if (previous.type !== 'CommentLine' || current.type !== 'CommentLine') break;
|
|
99
|
+
if (current.loc.start.line - previous.loc.end.line !== 1) break;
|
|
100
|
+
const previousLineStart = content.lastIndexOf('\n', previous.start - 1) + 1;
|
|
101
|
+
if (!/^\s*$/.test(content.slice(previousLineStart, previous.start))) break;
|
|
102
|
+
if (!/^\s*$/.test(content.slice(previous.end, current.start))) break;
|
|
103
|
+
first--;
|
|
104
|
+
}
|
|
105
|
+
return hasReasonText(ordered.slice(first, index + 1).map(item => item.value).join('\n'));
|
|
89
106
|
}
|
|
90
107
|
if (comment.start >= call.end && comment.loc.start.line === call.loc.end.line) {
|
|
91
|
-
return /^[\s;]*$/.test(content.slice(call.end, comment.start));
|
|
108
|
+
return hasReasonText(comment.value) && /^[\s;]*$/.test(content.slice(call.end, comment.start));
|
|
92
109
|
}
|
|
93
110
|
return false;
|
|
94
111
|
});
|
|
@@ -123,10 +140,13 @@ function fallbackSkippedCalls(content) {
|
|
|
123
140
|
const calls = [];
|
|
124
141
|
for (let i = 0; i < codeLines.length; i++) {
|
|
125
142
|
if (!SKIP_PATTERNS.some(p => p.test(codeLines[i]))) continue;
|
|
126
|
-
// Only a
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
143
|
+
// Only a contiguous block of directly preceding line comments is
|
|
144
|
+
// unambiguous without a parser. Blank lines and code sever ownership.
|
|
145
|
+
const commentBlock = [];
|
|
146
|
+
for (let cursor = i - 1; cursor >= 0 && /^\s*\/\//.test(lines[cursor] || ''); cursor--) {
|
|
147
|
+
commentBlock.unshift(lines[cursor].replace(/^\s*\/\//, ''));
|
|
148
|
+
}
|
|
149
|
+
calls.push({ line: i + 1, hasReason: hasReasonText(commentBlock.join('\n')) });
|
|
130
150
|
}
|
|
131
151
|
return calls;
|
|
132
152
|
}
|
|
@@ -251,7 +271,7 @@ function checkSkippedTests(projectDir, config) {
|
|
|
251
271
|
location: `${relPath}:${line}`,
|
|
252
272
|
suggestion: {
|
|
253
273
|
kind: 'fix',
|
|
254
|
-
text: 'Add a // REASON: comment
|
|
274
|
+
text: 'Add a // REASON: comment immediately above the skip explaining why',
|
|
255
275
|
pragma: '// REASON: <why this test is skipped>',
|
|
256
276
|
},
|
|
257
277
|
}));
|