docguard-cli 0.26.0 → 0.28.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 +5 -3
- package/cli/commands/explain.mjs +31 -7
- package/cli/commands/feedback.mjs +163 -0
- package/cli/commands/guard.mjs +90 -22
- package/cli/commands/init.mjs +23 -1
- package/cli/commands/score.mjs +65 -32
- package/cli/commands/sync-tests.mjs +272 -0
- package/cli/commands/sync.mjs +6 -0
- package/cli/commands/verify.mjs +67 -0
- package/cli/docguard.mjs +49 -4
- package/cli/findings.mjs +194 -0
- package/cli/scanners/semantic-claims.mjs +154 -0
- package/cli/shared-source.mjs +24 -2
- package/cli/validators/api-surface.mjs +75 -9
- package/cli/validators/architecture.mjs +25 -13
- package/cli/validators/doc-quality.mjs +14 -3
- package/cli/validators/security.mjs +117 -31
- package/cli/validators/todo-tracking.mjs +4 -0
- 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/package.json +1 -1
- package/templates/ENVIRONMENT.md.template +5 -0
- package/templates/REQUIREMENTS.md.template +2 -0
- package/templates/SECURITY.md.template +6 -1
- package/templates/TEST-SPEC.md.template +5 -0
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Semantic claim extractor (LLM field report #5).
|
|
3
|
+
*
|
|
4
|
+
* The highest-value class of doc bug is SEMANTIC: a documented number/enum/limit
|
|
5
|
+
* that no longer matches the code — DLP retention "30 days" vs code 730, a status
|
|
6
|
+
* enum "PENDING/IDLE" vs "WAITING", "100/min" vs "500 req/s", "29+ roles" vs 44,
|
|
7
|
+
* "4 GSIs" vs 6. Regex/AST can't judge these (the doc value and the code value
|
|
8
|
+
* are both just numbers), so they slip through every deterministic validator.
|
|
9
|
+
*
|
|
10
|
+
* DocGuard is zero-dependency and does NOT call an LLM itself. So this is an
|
|
11
|
+
* EXTRACTOR: it surfaces the verifiable claims — value, unit, doc:line, section,
|
|
12
|
+
* and the nearest cited code path — as a structured task list. The agent running
|
|
13
|
+
* `docguard verify --semantic` does the actual comparison against the code. This
|
|
14
|
+
* mirrors the `docguard agent` task-graph: deterministic discovery, LLM judgment.
|
|
15
|
+
*
|
|
16
|
+
* Precision over recall: a number is only a claim when it carries a recognized
|
|
17
|
+
* unit (days/ms/req-s/GSIs/roles/…); an enum only when it's a list of 2+
|
|
18
|
+
* UPPER_SNAKE tokens in a status/state/enum context. Bare version strings, dates,
|
|
19
|
+
* and prose numbers are ignored.
|
|
20
|
+
*
|
|
21
|
+
* Zero npm dependencies — pure Node.js built-ins.
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
import { existsSync, readFileSync, readdirSync } from 'node:fs';
|
|
25
|
+
import { resolve, join } from 'node:path';
|
|
26
|
+
|
|
27
|
+
// Numbers are only claims when adjacent to a recognized unit.
|
|
28
|
+
const NUMBER_PATTERNS = [
|
|
29
|
+
{ kind: 'duration', re: /\b(\d+(?:\.\d+)?)\s*(milliseconds?|ms|seconds?|secs?|minutes?|mins?|hours?|hrs?|days?|weeks?|months?|years?)\b/gi },
|
|
30
|
+
{ kind: 'rate', re: /\b(\d+)\s*(?:\/|\bper\b|\breq(?:uests?)?\s*\/?)\s*(s|sec|seconds?|min|minutes?|hours?|h)\b/gi },
|
|
31
|
+
{ kind: 'count', re: /\b(\d+)\s*\+?\s*(GSIs?|LSIs?|indexes|indices|roles?|permissions?|scopes?|tables?|queues?|topics?|buckets?|endpoints?|routes?|validators?|columns?|fields?|shards?|partitions?|replicas?|retries|workers?|threads?|connections?)\b/gi },
|
|
32
|
+
];
|
|
33
|
+
|
|
34
|
+
// A list of 2+ UPPER_SNAKE tokens separated by / , | or "or" — an enum claim,
|
|
35
|
+
// but only when the line or its heading reads like a status/state/enum context.
|
|
36
|
+
const ENUM_LIST_RE = /\b[A-Z][A-Z0-9_]{2,}(?:\s*(?:\/|,|\||\bor\b)\s*[A-Z][A-Z0-9_]{2,}){1,}\b/g;
|
|
37
|
+
const ENUM_CONTEXT_RE = /\b(status|state|enum|values?|one of|phase|stage|transitions?)\b/i;
|
|
38
|
+
|
|
39
|
+
// A code path mentioned in or near the claim — the agent's starting point.
|
|
40
|
+
const CITED_CODE_RE = /`?([\w./-]+\.(?:ts|tsx|js|mjs|cjs|jsx|py|go|rs|java|kt|rb|php|sql|yaml|yml|json))`?(?::(\d+))?/;
|
|
41
|
+
|
|
42
|
+
const MAX_CLAIMS = 80;
|
|
43
|
+
|
|
44
|
+
/** Canonical docs + the root docs where limits/counts commonly live. */
|
|
45
|
+
function claimSourceDocs(projectDir) {
|
|
46
|
+
const docs = [];
|
|
47
|
+
const canonical = resolve(projectDir, 'docs-canonical');
|
|
48
|
+
if (existsSync(canonical)) {
|
|
49
|
+
try {
|
|
50
|
+
for (const f of readdirSync(canonical)) {
|
|
51
|
+
if (f.toLowerCase().endsWith('.md')) docs.push(`docs-canonical/${f}`);
|
|
52
|
+
}
|
|
53
|
+
} catch { /* ignore */ }
|
|
54
|
+
}
|
|
55
|
+
for (const root of ['README.md', 'AGENTS.md']) {
|
|
56
|
+
if (existsSync(resolve(projectDir, root))) docs.push(root);
|
|
57
|
+
}
|
|
58
|
+
return docs;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** True if a line is inside a fenced code block (toggled by the caller). */
|
|
62
|
+
function findCitedCode(lines, idx) {
|
|
63
|
+
// Search the claim line first, then the immediately adjacent lines. A tight
|
|
64
|
+
// window avoids cross-attributing a path from an unrelated nearby claim (e.g.
|
|
65
|
+
// a rate limit grabbing the retention doc's cited file three lines up).
|
|
66
|
+
for (let d = 0; d <= 1; d++) {
|
|
67
|
+
for (const j of d === 0 ? [idx] : [idx - d, idx + d]) {
|
|
68
|
+
if (j < 0 || j >= lines.length) continue;
|
|
69
|
+
const m = CITED_CODE_RE.exec(lines[j]);
|
|
70
|
+
if (m) return m[2] ? `${m[1]}:${m[2]}` : m[1];
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
return null;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* Extract semantic claims from a project's canonical docs.
|
|
78
|
+
* @returns {Array<{ doc, line, section, kind, subkind, value, unit, text, citedCode }>}
|
|
79
|
+
*/
|
|
80
|
+
export function extractSemanticClaims(projectDir, config = {}) {
|
|
81
|
+
const claims = [];
|
|
82
|
+
const seen = new Set();
|
|
83
|
+
|
|
84
|
+
for (const doc of claimSourceDocs(projectDir)) {
|
|
85
|
+
let content;
|
|
86
|
+
try { content = readFileSync(resolve(projectDir, doc), 'utf-8'); } catch { continue; }
|
|
87
|
+
const lines = content.split('\n');
|
|
88
|
+
let section = '';
|
|
89
|
+
let inFence = false;
|
|
90
|
+
|
|
91
|
+
for (let i = 0; i < lines.length; i++) {
|
|
92
|
+
const line = lines[i];
|
|
93
|
+
if (/^\s*```/.test(line)) { inFence = !inFence; continue; }
|
|
94
|
+
if (inFence) continue; // numbers in code samples are examples, not claims
|
|
95
|
+
const h = line.match(/^#{1,6}\s+(.*)$/);
|
|
96
|
+
if (h) { section = h[1].trim(); continue; }
|
|
97
|
+
|
|
98
|
+
const lineNo = i + 1;
|
|
99
|
+
const push = (claim) => {
|
|
100
|
+
const key = `${doc}:${lineNo}:${claim.kind}:${claim.value}:${claim.unit || ''}`;
|
|
101
|
+
if (seen.has(key)) return;
|
|
102
|
+
seen.add(key);
|
|
103
|
+
claims.push({ doc, line: lineNo, section, citedCode: findCitedCode(lines, i), text: line.trim().slice(0, 200), ...claim });
|
|
104
|
+
};
|
|
105
|
+
|
|
106
|
+
for (const { kind, re } of NUMBER_PATTERNS) {
|
|
107
|
+
re.lastIndex = 0;
|
|
108
|
+
let m;
|
|
109
|
+
while ((m = re.exec(line)) !== null) {
|
|
110
|
+
push({ kind: 'number', subkind: kind, value: m[1], unit: m[2].toLowerCase() });
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
if (ENUM_CONTEXT_RE.test(line) || ENUM_CONTEXT_RE.test(section)) {
|
|
115
|
+
ENUM_LIST_RE.lastIndex = 0;
|
|
116
|
+
let m;
|
|
117
|
+
while ((m = ENUM_LIST_RE.exec(line)) !== null) {
|
|
118
|
+
// Skip all-caps acronym runs joined by slash that are really one token.
|
|
119
|
+
const values = m[0].split(/\s*(?:\/|,|\||\bor\b)\s*/).filter(Boolean);
|
|
120
|
+
if (values.length >= 2) push({ kind: 'enum', subkind: 'enum-list', value: values.join('/'), unit: null });
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
if (claims.length >= MAX_CLAIMS) return claims;
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
return claims;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/**
|
|
131
|
+
* Turn extracted claims into agent-executable verification tasks (one per claim).
|
|
132
|
+
* Pure — reused by the command and any task-graph consumer.
|
|
133
|
+
*/
|
|
134
|
+
export function buildSemanticVerifyTasks(claims) {
|
|
135
|
+
return claims.map((c, i) => {
|
|
136
|
+
const where = c.citedCode ? ` Start at the cited code: ${c.citedCode}.` : ' No code path is cited nearby — grep the codebase for the relevant constant/config.';
|
|
137
|
+
const what = c.kind === 'enum'
|
|
138
|
+
? `the enum/status set "${c.value}"`
|
|
139
|
+
: `the ${c.subkind} value ${c.value}${c.unit ? ` ${c.unit}` : ''}`;
|
|
140
|
+
return {
|
|
141
|
+
id: `verify.semantic.${i + 1}`,
|
|
142
|
+
doc: c.doc,
|
|
143
|
+
line: c.line,
|
|
144
|
+
section: c.section,
|
|
145
|
+
kind: c.kind,
|
|
146
|
+
value: c.value,
|
|
147
|
+
unit: c.unit,
|
|
148
|
+
citedCode: c.citedCode,
|
|
149
|
+
claim: c.text,
|
|
150
|
+
instruction: `Verify ${what} documented in ${c.doc}:${c.line}${c.section ? ` (section "${c.section}")` : ''} against the code.${where} If the code disagrees, the doc (or the code) is wrong — report the mismatch with both values.`,
|
|
151
|
+
confidence: 'requires-human',
|
|
152
|
+
};
|
|
153
|
+
});
|
|
154
|
+
}
|
package/cli/shared-source.mjs
CHANGED
|
@@ -295,6 +295,27 @@ function classifyChars(content, ext) {
|
|
|
295
295
|
* including bracket access.
|
|
296
296
|
* @returns {Set<string>} variable names referenced in code
|
|
297
297
|
*/
|
|
298
|
+
/**
|
|
299
|
+
* v0.27 (field report #7): env vars injected by the test runner / CI / cloud
|
|
300
|
+
* SDK are READ in code (e.g. `if (process.env.VITEST)` as a test guard) but no
|
|
301
|
+
* application documents them as config — flagging them "undocumented" is a
|
|
302
|
+
* false positive. This is the env equivalent of the SYSTEM allowlist already
|
|
303
|
+
* applied on the docs side in environment.mjs.
|
|
304
|
+
*
|
|
305
|
+
* Deliberately conservative — NODE_ENV is intentionally NOT here: this project
|
|
306
|
+
* already decided NODE_ENV is legitimate app config (see environment.mjs).
|
|
307
|
+
*/
|
|
308
|
+
const RUNNER_ENV_VARS = new Set([
|
|
309
|
+
'VITEST', 'CI', 'JEST_WORKER_ID', 'AWS_SESSION_TOKEN', 'AWS_EXECUTION_ENV',
|
|
310
|
+
]);
|
|
311
|
+
const RUNNER_ENV_PREFIXES = ['GITHUB_', 'RUNNER_', 'VITEST_', 'JEST_', 'CIRCLE_', 'GITLAB_CI'];
|
|
312
|
+
|
|
313
|
+
/** True when `name` is a runner/CI/SDK-injected var, not product config. */
|
|
314
|
+
export function isRunnerEnvVar(name) {
|
|
315
|
+
if (RUNNER_ENV_VARS.has(name)) return true;
|
|
316
|
+
return RUNNER_ENV_PREFIXES.some((p) => name.startsWith(p));
|
|
317
|
+
}
|
|
318
|
+
|
|
298
319
|
export function grepEnvUsage(projectDir, config = {}) {
|
|
299
320
|
const names = new Set();
|
|
300
321
|
const roots = resolveSourceRoots(projectDir, config);
|
|
@@ -347,6 +368,7 @@ export function grepEnvUsage(projectDir, config = {}) {
|
|
|
347
368
|
while ((m = rx.exec(content)) !== null) {
|
|
348
369
|
if (kind[m.index] !== 0) continue; // keyword inside a string/comment → a mention, not a read
|
|
349
370
|
if (isViteSource && VITE_INTRINSICS.has(m[1])) continue;
|
|
371
|
+
if (isRunnerEnvVar(m[1])) continue; // v0.27 (#7): runner/CI/SDK var, not product config
|
|
350
372
|
names.add(m[1]);
|
|
351
373
|
}
|
|
352
374
|
}
|
|
@@ -366,12 +388,12 @@ export function grepEnvUsage(projectDir, config = {}) {
|
|
|
366
388
|
// camelCase keys, so requiring UPPER_SNAKE keeps this env-specific.
|
|
367
389
|
const keyRe = /^\s*['"]?([A-Z][A-Z0-9_]*[A-Z0-9])['"]?\s*:/gm;
|
|
368
390
|
while ((km = keyRe.exec(content)) !== null) {
|
|
369
|
-
if (km[1].length >= 3 && !VITE_INTRINSICS.has(km[1])) names.add(km[1]);
|
|
391
|
+
if (km[1].length >= 3 && !VITE_INTRINSICS.has(km[1]) && !isRunnerEnvVar(km[1])) names.add(km[1]);
|
|
370
392
|
}
|
|
371
393
|
// convict: the env var name is the `env:` property value, not the key.
|
|
372
394
|
const convictRe = /\benv\s*:\s*['"]([A-Z][A-Z0-9_]*[A-Z0-9])['"]/g;
|
|
373
395
|
while ((km = convictRe.exec(content)) !== null) {
|
|
374
|
-
if (km[1].length >= 3) names.add(km[1]);
|
|
396
|
+
if (km[1].length >= 3 && !isRunnerEnvVar(km[1])) names.add(km[1]);
|
|
375
397
|
}
|
|
376
398
|
}
|
|
377
399
|
};
|
|
@@ -208,10 +208,56 @@ export function computeApiSurfaceDrift(projectDir, config) {
|
|
|
208
208
|
};
|
|
209
209
|
}
|
|
210
210
|
|
|
211
|
+
/**
|
|
212
|
+
* v0.28 (field report #4): diff the OpenAPI spec against the routes actually
|
|
213
|
+
* REGISTERED in code. When a spec exists, resolveApiSurface treats it as ground
|
|
214
|
+
* truth and the API-REFERENCE doc reconciles against it — so a spec that declares
|
|
215
|
+
* a phantom endpoint (no Express/Fastify route registers it) passes doc-vs-spec
|
|
216
|
+
* clean while the spec itself is wrong. This catches that.
|
|
217
|
+
*
|
|
218
|
+
* Conservative on purpose: only runs when code routes are actually scannable.
|
|
219
|
+
* If the scanner finds zero routes (unsupported framework, dynamically-registered
|
|
220
|
+
* routes), we can't tell "no route" from "scanner blind", so we skip rather than
|
|
221
|
+
* flag every spec endpoint as phantom. Reuses compareEndpoints so path-param /
|
|
222
|
+
* mount-prefix normalization matches the rest of the validator.
|
|
223
|
+
*
|
|
224
|
+
* @returns {{ applicable:boolean, specPath:string|null, routeCount:number,
|
|
225
|
+
* matched:object[], specDeclaredNoRoute:object[], reason?:string }}
|
|
226
|
+
*/
|
|
227
|
+
export function computeSpecVsRouteDrift(projectDir, config) {
|
|
228
|
+
const specs = findAllOpenApiSpecs(projectDir, config);
|
|
229
|
+
if (specs.length === 0) {
|
|
230
|
+
return { applicable: false, specPath: null, routeCount: 0, matched: [], specDeclaredNoRoute: [], reason: 'no openapi spec' };
|
|
231
|
+
}
|
|
232
|
+
const spec = specs[0]; // authoritative (sourceRoot first, root last)
|
|
233
|
+
const framework = detectFramework(projectDir, config);
|
|
234
|
+
const routes = scanRoutesDeep(projectDir, { framework }, { openapi: { found: false } }, { config });
|
|
235
|
+
if (routes.length === 0) {
|
|
236
|
+
return { applicable: false, specPath: spec.relPath, routeCount: 0, matched: [], specDeclaredNoRoute: [], reason: 'no routes scannable' };
|
|
237
|
+
}
|
|
238
|
+
// documentedButAbsent = in the SPEC (first arg) but absent from the ROUTES
|
|
239
|
+
// (second arg) = spec-declares-but-no-route.
|
|
240
|
+
const cmp = compareEndpoints(
|
|
241
|
+
spec.endpoints.map(e => ({ method: e.method, path: e.path })),
|
|
242
|
+
routes.map(r => ({ method: r.method, path: r.path }))
|
|
243
|
+
);
|
|
244
|
+
return {
|
|
245
|
+
applicable: true,
|
|
246
|
+
specPath: spec.relPath,
|
|
247
|
+
routeCount: routes.length,
|
|
248
|
+
matched: cmp.matched,
|
|
249
|
+
specDeclaredNoRoute: cmp.documentedButAbsent,
|
|
250
|
+
};
|
|
251
|
+
}
|
|
252
|
+
|
|
211
253
|
export function validateApiSurface(projectDir, config) {
|
|
212
254
|
const errors = [];
|
|
213
255
|
const warnings = [];
|
|
214
256
|
const fixes = [];
|
|
257
|
+
const trim = (arr) => {
|
|
258
|
+
const shown = arr.slice(0, MAX_REPORTED);
|
|
259
|
+
return { shown, extra: arr.length - shown.length };
|
|
260
|
+
};
|
|
215
261
|
|
|
216
262
|
// v0.14-P2: when --changed-only scoping is active and NONE of the changed
|
|
217
263
|
// files look like route/spec/controller files, this validator has nothing
|
|
@@ -256,19 +302,39 @@ export function validateApiSurface(projectDir, config) {
|
|
|
256
302
|
);
|
|
257
303
|
}
|
|
258
304
|
|
|
305
|
+
// ── #4: spec declares an endpoint with no registered route ──
|
|
306
|
+
// Independent of the API-REFERENCE doc — it checks the spec against code, so it
|
|
307
|
+
// runs even when no doc exists. Conservative (only when routes are scannable).
|
|
308
|
+
const specRoute = computeSpecVsRouteDrift(projectDir, config);
|
|
309
|
+
let specRouteTotal = 0;
|
|
310
|
+
let specRoutePassed = 0;
|
|
311
|
+
if (specRoute.applicable) {
|
|
312
|
+
specRouteTotal = specRoute.matched.length + specRoute.specDeclaredNoRoute.length;
|
|
313
|
+
specRoutePassed = specRoute.matched.length;
|
|
314
|
+
if (specRoute.specDeclaredNoRoute.length) {
|
|
315
|
+
const { shown, extra } = trim(specRoute.specDeclaredNoRoute);
|
|
316
|
+
for (const e of shown) {
|
|
317
|
+
warnings.push(
|
|
318
|
+
`OpenAPI spec (${specRoute.specPath}) declares ${e.method} ${e.path} but no route registers it in code — ` +
|
|
319
|
+
`the spec may be wrong, and the API-REFERENCE doc reconciles clean against it, hiding the gap.`
|
|
320
|
+
);
|
|
321
|
+
}
|
|
322
|
+
if (extra > 0) warnings.push(`…and ${extra} more spec-declared endpoint(s) with no registered route`);
|
|
323
|
+
}
|
|
324
|
+
}
|
|
325
|
+
|
|
259
326
|
if (!drift.applicable) {
|
|
260
|
-
// Nothing to validate against the API-REFERENCE doc
|
|
261
|
-
|
|
327
|
+
// Nothing to validate against the API-REFERENCE doc — but the spec-vs-route
|
|
328
|
+
// check above may still have produced findings.
|
|
329
|
+
return {
|
|
330
|
+
errors, warnings, passed: specRoutePassed, total: specRouteTotal, fixes,
|
|
331
|
+
authoritativeSpec: drift.source || specRoute.specPath,
|
|
332
|
+
};
|
|
262
333
|
}
|
|
263
334
|
|
|
264
335
|
const { documentedButAbsent, presentButUndocumented, matched, confidence, source } = drift;
|
|
265
|
-
const total = matched.length + documentedButAbsent.length + presentButUndocumented.length;
|
|
266
|
-
const passed = matched.length;
|
|
267
|
-
|
|
268
|
-
const trim = (arr) => {
|
|
269
|
-
const shown = arr.slice(0, MAX_REPORTED);
|
|
270
|
-
return { shown, extra: arr.length - shown.length };
|
|
271
|
-
};
|
|
336
|
+
const total = matched.length + documentedButAbsent.length + presentButUndocumented.length + specRouteTotal;
|
|
337
|
+
const passed = matched.length + specRoutePassed;
|
|
272
338
|
|
|
273
339
|
// documented-but-absent → deterministic remove-endpoint fixes
|
|
274
340
|
if (documentedButAbsent.length) {
|
|
@@ -97,11 +97,11 @@ function validateConfigLayers(projectDir, config, layers, results) {
|
|
|
97
97
|
const relPath = relative(projectDir, file);
|
|
98
98
|
const imports = extractImports(content);
|
|
99
99
|
|
|
100
|
-
for (const
|
|
101
|
-
if (!
|
|
100
|
+
for (const { spec } of imports) {
|
|
101
|
+
if (!spec.startsWith('.') && !spec.startsWith('/')) continue;
|
|
102
102
|
|
|
103
103
|
for (const forbiddenDir of layer.forbidden) {
|
|
104
|
-
if (
|
|
104
|
+
if (spec.includes(forbiddenDir) || spec.includes(`/${forbiddenDir}/`)) {
|
|
105
105
|
results.total++;
|
|
106
106
|
results.errors.push(
|
|
107
107
|
`${relPath}: ${layer.name} layer imports from forbidden layer (${forbiddenDir})`
|
|
@@ -135,14 +135,19 @@ function buildImportGraph(projectDir, config) {
|
|
|
135
135
|
|
|
136
136
|
const resolvedImports = [];
|
|
137
137
|
for (const imp of imports) {
|
|
138
|
-
if (!imp.startsWith('.') && !imp.startsWith('/')) continue;
|
|
138
|
+
if (!imp.spec.startsWith('.') && !imp.spec.startsWith('/')) continue;
|
|
139
139
|
|
|
140
140
|
// Resolve relative imports
|
|
141
141
|
const fromDir = dirname(file);
|
|
142
|
-
const resolved = resolveImport(fromDir, imp, projectDir);
|
|
142
|
+
const resolved = resolveImport(fromDir, imp.spec, projectDir);
|
|
143
143
|
if (resolved) {
|
|
144
|
-
|
|
145
|
-
|
|
144
|
+
graph.edges.push({ from: relPath, to: resolved, dynamic: imp.dynamic });
|
|
145
|
+
// v0.28 (field report #2): a dynamic `await import()` does NOT create a
|
|
146
|
+
// load-time edge — it's the canonical way to BREAK an import cycle. So
|
|
147
|
+
// it's excluded from the cycle-detection adjacency (fileMap) while still
|
|
148
|
+
// recorded in graph.edges for layer-boundary checks (an import is still
|
|
149
|
+
// an import for layering).
|
|
150
|
+
if (!imp.dynamic) resolvedImports.push(resolved);
|
|
146
151
|
}
|
|
147
152
|
}
|
|
148
153
|
|
|
@@ -153,26 +158,33 @@ function buildImportGraph(projectDir, config) {
|
|
|
153
158
|
return graph;
|
|
154
159
|
}
|
|
155
160
|
|
|
161
|
+
/**
|
|
162
|
+
* Extract a file's imports as `{ spec, dynamic }`. `dynamic:true` marks a
|
|
163
|
+
* runtime `import('…')` — which does NOT create a load-time dependency edge and
|
|
164
|
+
* is the canonical way to break an import cycle (field report #2). ES `import …
|
|
165
|
+
* from` and CommonJS `require()` are load-time (static).
|
|
166
|
+
*/
|
|
156
167
|
function extractImports(content) {
|
|
157
168
|
const imports = [];
|
|
158
169
|
|
|
159
|
-
// ES module imports
|
|
170
|
+
// ES module imports (static, load-time). `import\s+` requires whitespace after
|
|
171
|
+
// `import`, so it never matches a dynamic `import(` call.
|
|
160
172
|
const esImportRegex = /import\s+(?:.*?\s+from\s+)?['"]([^'"]+)['"]/g;
|
|
161
173
|
let match;
|
|
162
174
|
while ((match = esImportRegex.exec(content)) !== null) {
|
|
163
|
-
imports.push(match[1]);
|
|
175
|
+
imports.push({ spec: match[1], dynamic: false });
|
|
164
176
|
}
|
|
165
177
|
|
|
166
|
-
// Dynamic imports
|
|
178
|
+
// Dynamic imports (runtime — NOT a load-time cycle edge)
|
|
167
179
|
const dynamicRegex = /import\s*\(\s*['"]([^'"]+)['"]\s*\)/g;
|
|
168
180
|
while ((match = dynamicRegex.exec(content)) !== null) {
|
|
169
|
-
imports.push(match[1]);
|
|
181
|
+
imports.push({ spec: match[1], dynamic: true });
|
|
170
182
|
}
|
|
171
183
|
|
|
172
|
-
// CommonJS require
|
|
184
|
+
// CommonJS require (static, load-time)
|
|
173
185
|
const requireRegex = /require\s*\(\s*['"]([^'"]+)['"]\s*\)/g;
|
|
174
186
|
while ((match = requireRegex.exec(content)) !== null) {
|
|
175
|
-
imports.push(match[1]);
|
|
187
|
+
imports.push({ spec: match[1], dynamic: false });
|
|
176
188
|
}
|
|
177
189
|
|
|
178
190
|
return imports;
|
|
@@ -533,7 +533,13 @@ function analyzeDocument(doc) {
|
|
|
533
533
|
conditionalLoad: conditional.ratio,
|
|
534
534
|
},
|
|
535
535
|
details: { passive, ambiguous, atomicity, negation, conditional },
|
|
536
|
-
overrides: {
|
|
536
|
+
overrides: {
|
|
537
|
+
negationLoad: parseQualityOverride(content, 'negation-load'),
|
|
538
|
+
// v0.27 (#9): parity with negation-load. Sequence/flow docs (MESSAGE-FLOWS,
|
|
539
|
+
// INTEGRATIONS) are legitimately passive; let them opt out per-doc instead
|
|
540
|
+
// of warning unconditionally.
|
|
541
|
+
passiveVoice: parseQualityOverride(content, 'passive-voice'),
|
|
542
|
+
},
|
|
537
543
|
};
|
|
538
544
|
}
|
|
539
545
|
|
|
@@ -561,12 +567,17 @@ export function validateDocQuality(projectDir, config) {
|
|
|
561
567
|
|
|
562
568
|
// ── Check 1: Passive Voice ──
|
|
563
569
|
results.total++;
|
|
564
|
-
|
|
570
|
+
const passiveOv = analysis.overrides?.passiveVoice;
|
|
571
|
+
const passiveThreshold = passiveOv?.threshold
|
|
572
|
+
?? config.docQuality?.passiveVoiceThreshold
|
|
573
|
+
?? THRESHOLDS.passiveVoiceRatio.warn;
|
|
574
|
+
if (passiveOv?.off || m.passiveVoiceRatio <= passiveThreshold) {
|
|
565
575
|
results.passed++;
|
|
566
576
|
} else {
|
|
567
577
|
results.warnings.push(
|
|
568
578
|
`${doc.name}: High passive voice ratio (${(m.passiveVoiceRatio * 100).toFixed(0)}% of sentences). ` +
|
|
569
|
-
`Use active voice for clarity. Found ${analysis.details.passive.count}/${analysis.details.passive.total} passive sentences`
|
|
579
|
+
`Use active voice for clarity. Found ${analysis.details.passive.count}/${analysis.details.passive.total} passive sentences. ` +
|
|
580
|
+
`If the passive voice is intentional (sequence/flow doc), add: <!-- docguard:quality passive-voice off — your reason -->`
|
|
570
581
|
);
|
|
571
582
|
}
|
|
572
583
|
|
|
@@ -8,6 +8,18 @@
|
|
|
8
8
|
import { existsSync, readFileSync, readdirSync, statSync } from 'node:fs';
|
|
9
9
|
import { resolve, join, extname } from 'node:path';
|
|
10
10
|
import { shouldIgnore, relPosix } from '../shared-ignore.mjs';
|
|
11
|
+
import { mkFinding, resultFromFindings, lineSuppresses } from '../findings.mjs';
|
|
12
|
+
|
|
13
|
+
// Each secret pattern maps to a stable finding code (see cli/findings.mjs CODES)
|
|
14
|
+
// so it is `explain`-able and inline-suppressible (`// docguard:ignore SEC00x`).
|
|
15
|
+
const LABEL_TO_CODE = {
|
|
16
|
+
'hardcoded password': 'SEC001',
|
|
17
|
+
'hardcoded API key': 'SEC002',
|
|
18
|
+
'hardcoded secret key': 'SEC003',
|
|
19
|
+
'hardcoded access token': 'SEC004',
|
|
20
|
+
'AWS Access Key ID': 'SEC005',
|
|
21
|
+
'API secret key (Stripe/OpenAI pattern)': 'SEC006',
|
|
22
|
+
};
|
|
11
23
|
|
|
12
24
|
const CODE_EXTENSIONS = new Set([
|
|
13
25
|
'.js', '.mjs', '.cjs', '.ts', '.tsx', '.jsx',
|
|
@@ -54,11 +66,46 @@ function isSafePlaceholder(line, matchStr) {
|
|
|
54
66
|
return SAFE_PATTERNS.some(p => p.test(line));
|
|
55
67
|
}
|
|
56
68
|
|
|
57
|
-
|
|
58
|
-
|
|
69
|
+
/**
|
|
70
|
+
* v0.27 (field report #1): a password-style key whose VALUE is natural
|
|
71
|
+
* language — an error message, validation copy, UI string — is almost never a
|
|
72
|
+
* credential. e.g. a "New password must differ from recent passwords"
|
|
73
|
+
* validation message assigned to such a key.
|
|
74
|
+
*
|
|
75
|
+
* We don't drop these (a real secret that happens to read like prose must still
|
|
76
|
+
* surface — false-green is the failure mode this tool exists to prevent); we
|
|
77
|
+
* downgrade them to a LOW-CONFIDENCE warning the agent can suppress inline,
|
|
78
|
+
* instead of a blocking error. Heuristic per the field report: ≥3 words, OR
|
|
79
|
+
* ≥2 internal spaces, OR ends in sentence punctuation.
|
|
80
|
+
*
|
|
81
|
+
* @param {string} value - the literal inside the quotes
|
|
82
|
+
*/
|
|
83
|
+
function looksLikeProse(value) {
|
|
84
|
+
if (!value) return false;
|
|
85
|
+
const v = value.trim();
|
|
86
|
+
const words = v.split(/\s+/).filter(Boolean);
|
|
87
|
+
// Multi-word natural language (validation messages, UI copy, sentences).
|
|
88
|
+
if (words.length >= 3) return true;
|
|
89
|
+
// A 2-word sentence fragment ending in terminal punctuation — but NOT a
|
|
90
|
+
// single token like "SuperSecretPassword!" (strong passwords end in !/? too,
|
|
91
|
+
// so terminal punctuation ALONE must never reclassify a one-word value).
|
|
92
|
+
if (words.length >= 2 && /[.!?]$/.test(v)) return true;
|
|
93
|
+
return false;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/** Pull the first quoted literal out of a matched secret expression. */
|
|
97
|
+
function quotedValue(matchStr) {
|
|
98
|
+
const m = matchStr.match(/['"]([^'"]*)['"]/);
|
|
99
|
+
return m ? m[1] : '';
|
|
100
|
+
}
|
|
59
101
|
|
|
102
|
+
export function validateSecurity(projectDir, config) {
|
|
103
|
+
/** @type {import('../findings.mjs').Finding[]} */
|
|
60
104
|
const findings = [];
|
|
105
|
+
let passed = 0;
|
|
106
|
+
let total = 0;
|
|
61
107
|
let scanned = 0;
|
|
108
|
+
let realSecretCount = 0;
|
|
62
109
|
|
|
63
110
|
walkDir(projectDir, (filePath) => {
|
|
64
111
|
const ext = extname(filePath);
|
|
@@ -89,25 +136,58 @@ export function validateSecurity(projectDir, config) {
|
|
|
89
136
|
// Lazily initialize lines only when a match is found
|
|
90
137
|
if (!lines) lines = content.split('\n');
|
|
91
138
|
|
|
92
|
-
//
|
|
93
|
-
const
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
for (const line of lines) {
|
|
97
|
-
charCount += line.length + 1; // +1 for newline
|
|
98
|
-
if (charCount > matchPos) {
|
|
99
|
-
matchLine = line;
|
|
100
|
-
break;
|
|
101
|
-
}
|
|
102
|
-
}
|
|
139
|
+
// 1-based line number + the line above (for inline-pragma suppression).
|
|
140
|
+
const lineNo = content.slice(0, match.index).split('\n').length;
|
|
141
|
+
const matchLine = lines[lineNo - 1] || '';
|
|
142
|
+
const prevLine = lines[lineNo - 2] || '';
|
|
103
143
|
|
|
104
144
|
// Skip known-safe placeholder/example values, but keep scanning for a
|
|
105
145
|
// real one further down the file.
|
|
106
146
|
if (isSafePlaceholder(matchLine, match[0])) continue;
|
|
107
147
|
|
|
108
|
-
|
|
148
|
+
const code = LABEL_TO_CODE[label];
|
|
149
|
+
|
|
150
|
+
// v0.27 (#8): honour an inline `// docguard:ignore SEC00x` pragma on the
|
|
151
|
+
// line or the line above — per-line suppression instead of blinding the
|
|
152
|
+
// whole file via `securityIgnore`.
|
|
153
|
+
if (code && lineSuppresses(code, matchLine, prevLine)) break;
|
|
154
|
+
|
|
155
|
+
const location = `${relPath}:${lineNo}`;
|
|
156
|
+
const value = quotedValue(match[0]);
|
|
157
|
+
const isProse = looksLikeProse(value);
|
|
158
|
+
|
|
159
|
+
if (isProse) {
|
|
160
|
+
// v0.27 (#1): natural-language value → low-confidence warning, not a
|
|
161
|
+
// blocking error. Still surfaced (no false-green), still suppressible,
|
|
162
|
+
// and now reportable via `docguard feedback`.
|
|
163
|
+
findings.push(mkFinding({
|
|
164
|
+
code, validator: 'security', severity: 'warn', confidence: 'low',
|
|
165
|
+
message: `${location}: possible ${label} — but the value reads like natural-language text (likely UI copy / a validation message, not a credential)`,
|
|
166
|
+
location,
|
|
167
|
+
suggestion: {
|
|
168
|
+
kind: 'suppress',
|
|
169
|
+
text: 'If this is UI copy or a message and not a real secret, suppress it inline.',
|
|
170
|
+
pragma: `// docguard:ignore ${code} — UI copy, not a credential`,
|
|
171
|
+
},
|
|
172
|
+
reportable: true,
|
|
173
|
+
redactedContext: `${label} pattern fired on a value that is natural-language text (~${value.trim().split(/\s+/).filter(Boolean).length} words). Literal omitted.`,
|
|
174
|
+
}));
|
|
175
|
+
} else {
|
|
176
|
+
realSecretCount++;
|
|
177
|
+
findings.push(mkFinding({
|
|
178
|
+
code, validator: 'security', severity: 'error', confidence: 'high',
|
|
179
|
+
message: `${location}: possible ${label} found`,
|
|
180
|
+
location,
|
|
181
|
+
suggestion: {
|
|
182
|
+
kind: 'fix',
|
|
183
|
+
text: 'Move the secret to an environment variable and read it via process.env / the platform secret store. Never commit credentials.',
|
|
184
|
+
command: code ? `docguard explain ${code}` : undefined,
|
|
185
|
+
pragma: code ? `// docguard:ignore ${code} — reason (only if a confirmed false positive)` : undefined,
|
|
186
|
+
},
|
|
187
|
+
}));
|
|
188
|
+
}
|
|
109
189
|
// One finding per (file, label) is enough — the reported message is
|
|
110
|
-
// identical for repeats and we've already proven a
|
|
190
|
+
// identical for repeats and we've already proven a match exists.
|
|
111
191
|
break;
|
|
112
192
|
}
|
|
113
193
|
}
|
|
@@ -116,35 +196,41 @@ export function validateSecurity(projectDir, config) {
|
|
|
116
196
|
// Only count the secret scan as a passed check if we actually scanned files.
|
|
117
197
|
// An empty scan that reports "no secrets" is a dangerous false ✅ — surface it.
|
|
118
198
|
if (scanned > 0) {
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
} else {
|
|
123
|
-
for (const f of findings) {
|
|
124
|
-
results.errors.push(`${f.file}: possible ${f.label} found`);
|
|
125
|
-
}
|
|
126
|
-
}
|
|
199
|
+
total++;
|
|
200
|
+
// Low-confidence (prose) findings do not fail the check — only real secrets do.
|
|
201
|
+
if (realSecretCount === 0) passed++;
|
|
127
202
|
} else {
|
|
128
|
-
|
|
129
|
-
'
|
|
130
|
-
|
|
203
|
+
findings.push(mkFinding({
|
|
204
|
+
code: 'SEC011', validator: 'security', severity: 'warn', confidence: 'high',
|
|
205
|
+
message: 'No source files were scanned for secrets — check config.sourceRoot / ignore patterns',
|
|
206
|
+
suggestion: { kind: 'review', text: 'Verify config.sourceRoot and ignore patterns actually include your source tree.' },
|
|
207
|
+
}));
|
|
131
208
|
}
|
|
132
209
|
|
|
133
210
|
// Check .gitignore includes .env
|
|
134
|
-
|
|
211
|
+
total++;
|
|
135
212
|
const gitignorePath = resolve(projectDir, '.gitignore');
|
|
136
213
|
if (existsSync(gitignorePath)) {
|
|
137
214
|
const gitignore = readFileSync(gitignorePath, 'utf-8');
|
|
138
215
|
if (gitignore.includes('.env') || gitignore.includes('.env.local')) {
|
|
139
|
-
|
|
216
|
+
passed++;
|
|
140
217
|
} else {
|
|
141
|
-
|
|
218
|
+
findings.push(mkFinding({
|
|
219
|
+
code: 'SEC010', validator: 'security', severity: 'warn', confidence: 'high',
|
|
220
|
+
message: '.gitignore does not include .env — secrets may be committed',
|
|
221
|
+
location: '.gitignore',
|
|
222
|
+
suggestion: { kind: 'fix', text: 'Add `.env` and `.env.local` to .gitignore.' },
|
|
223
|
+
}));
|
|
142
224
|
}
|
|
143
225
|
} else {
|
|
144
|
-
|
|
226
|
+
findings.push(mkFinding({
|
|
227
|
+
code: 'SEC010', validator: 'security', severity: 'warn', confidence: 'high',
|
|
228
|
+
message: 'No .gitignore found — secrets may be committed',
|
|
229
|
+
suggestion: { kind: 'fix', text: 'Create a .gitignore that excludes `.env` and `.env.local`.' },
|
|
230
|
+
}));
|
|
145
231
|
}
|
|
146
232
|
|
|
147
|
-
return
|
|
233
|
+
return resultFromFindings(findings, { passed, total });
|
|
148
234
|
}
|
|
149
235
|
|
|
150
236
|
function walkDir(dir, callback) {
|
|
@@ -262,6 +262,10 @@ function loadTrackingDocs(projectDir, config) {
|
|
|
262
262
|
const trackingFiles = [
|
|
263
263
|
'ROADMAP.md', 'CURRENT-STATE.md', 'TODO.md', 'BACKLOG.md',
|
|
264
264
|
'docs-canonical/ARCHITECTURE.md', 'CHANGELOG.md',
|
|
265
|
+
// v0.27 (field report #6): many projects keep the roadmap/backlog under
|
|
266
|
+
// docs-canonical/ — a TODO tracked there was wrongly read as "untracked".
|
|
267
|
+
'docs-canonical/ROADMAP.md', 'docs-canonical/CURRENT-STATE.md',
|
|
268
|
+
'docs-canonical/BACKLOG.md', 'docs-canonical/TODO.md',
|
|
265
269
|
...(config.todoTracking?.trackingFiles || []),
|
|
266
270
|
];
|
|
267
271
|
|
|
@@ -3,7 +3,7 @@ schema_version: "1.0"
|
|
|
3
3
|
extension:
|
|
4
4
|
id: "docguard"
|
|
5
5
|
name: "DocGuard — CDD Enforcement"
|
|
6
|
-
version: "0.
|
|
6
|
+
version: "0.28.0"
|
|
7
7
|
description: "Canonical-Driven Development enforcement as a true spec-kit extension. LLM-first design with automated validators, 4 AI behavior skills, spec-kit skill chaining, and workflow hooks. One pinned runtime dependency (@babel/parser); pure Node.js otherwise."
|
|
8
8
|
author: "Ricardo Accioly"
|
|
9
9
|
repository: "https://github.com/raccioly/docguard"
|
|
@@ -6,10 +6,10 @@ description: AI-driven documentation repair with structured research workflow, t
|
|
|
6
6
|
compatibility: Requires DocGuard CLI installed (npm i -g docguard-cli or npx docguard-cli)
|
|
7
7
|
metadata:
|
|
8
8
|
author: docguard
|
|
9
|
-
version: 0.
|
|
9
|
+
version: 0.28.0
|
|
10
10
|
source: extensions/spec-kit-docguard/skills/docguard-fix
|
|
11
11
|
---
|
|
12
|
-
<!-- docguard:version: 0.
|
|
12
|
+
<!-- docguard:version: 0.28.0 -->
|
|
13
13
|
|
|
14
14
|
# DocGuard Fix Skill
|
|
15
15
|
|