docguard-cli 0.25.1 → 0.27.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 +7 -3
- package/cli/commands/agent.mjs +135 -0
- package/cli/commands/explain.mjs +23 -1
- package/cli/commands/feedback.mjs +163 -0
- package/cli/commands/generate.mjs +12 -1
- package/cli/commands/guard.mjs +77 -15
- package/cli/commands/score.mjs +65 -32
- package/cli/config.mjs +6 -1
- package/cli/docguard.mjs +61 -5
- package/cli/findings.mjs +194 -0
- package/cli/scanners/inventory.mjs +140 -0
- package/cli/scanners/memory-plan.mjs +84 -14
- package/cli/scanners/project-type.mjs +60 -4
- package/cli/scanners/routes.mjs +11 -5
- package/cli/shared-ignore.mjs +40 -0
- package/cli/shared-source.mjs +93 -3
- package/cli/validators/doc-quality.mjs +14 -3
- package/cli/validators/freshness.mjs +31 -2
- package/cli/validators/metrics-consistency.mjs +26 -2
- package/cli/validators/security.mjs +117 -31
- package/cli/validators/todo-tracking.mjs +4 -0
- package/cli/writers/mechanical.mjs +21 -3
- 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
|
@@ -19,6 +19,8 @@ import { scanSchemasDeep } from './schemas.mjs';
|
|
|
19
19
|
import { scanFrontend } from './frontend.mjs';
|
|
20
20
|
import { grepEnvUsage } from '../shared-source.mjs';
|
|
21
21
|
import { detectIntegrations } from './integrations.mjs';
|
|
22
|
+
import { PROFILES } from '../shared.mjs';
|
|
23
|
+
import { scanComponents, scanTestInventory } from './inventory.mjs';
|
|
22
24
|
|
|
23
25
|
const md = {
|
|
24
26
|
table(headers, rows) {
|
|
@@ -209,6 +211,9 @@ function _buildMemoryPlanUncached(projectDir, config = {}) {
|
|
|
209
211
|
framework: null, stateLib: null, dataLib: null };
|
|
210
212
|
const envVars = [...grepEnvUsage(projectDir, config)].sort();
|
|
211
213
|
const integrations = detectIntegrations(projectDir, config);
|
|
214
|
+
// Pre-filled code-truth (field report §5): real source modules + test inventory.
|
|
215
|
+
const modules = scanComponents(projectDir, config);
|
|
216
|
+
const tests = scanTestInventory(projectDir, config);
|
|
212
217
|
|
|
213
218
|
const surface = {
|
|
214
219
|
profile,
|
|
@@ -224,8 +229,35 @@ function _buildMemoryPlanUncached(projectDir, config = {}) {
|
|
|
224
229
|
apiCalls: fe.apiCalls,
|
|
225
230
|
i18n: fe.i18n,
|
|
226
231
|
frontend: { framework: fe.framework, stateLib: fe.stateLib, dataLib: fe.dataLib },
|
|
232
|
+
modules, // top-level source modules → ARCHITECTURE Component Map (pre-filled)
|
|
233
|
+
tests, // { files:[{file,cases}], totalCases, totalFiles } → TEST-SPEC inventory
|
|
227
234
|
};
|
|
228
235
|
|
|
236
|
+
// ── Profile gate (Bug #5) ──
|
|
237
|
+
// generate must respect the active COMPLIANCE profile, not just surface
|
|
238
|
+
// counts. For non-web profiles (cli/library) we only emit an optional
|
|
239
|
+
// web/UI/DB-shaped canonical doc when the profile explicitly requires it — so
|
|
240
|
+
// a CLI never gets API-REFERENCE/INTEGRATIONS just because the surface scan
|
|
241
|
+
// tripped on a stray HTTP call or SDK string. Other profiles
|
|
242
|
+
// (standard/enterprise/…) stay surface-driven.
|
|
243
|
+
const profileName = config.profile || 'standard';
|
|
244
|
+
const allowedCanonical = new Set(PROFILES[profileName]?.requiredFiles?.canonical || []);
|
|
245
|
+
const constrainedProfile = profileName === 'cli' || profileName === 'library';
|
|
246
|
+
const profileAllows = (docPath) => !constrainedProfile || allowedCanonical.has(docPath);
|
|
247
|
+
|
|
248
|
+
// Anti-false-green: when the profile suppresses a doc the surface WOULD have
|
|
249
|
+
// produced, say so — a web app mislabeled with the wrong --profile is still
|
|
250
|
+
// recoverable instead of silently under-documented.
|
|
251
|
+
const notes = [];
|
|
252
|
+
if (constrainedProfile) {
|
|
253
|
+
if (surface.endpoints.length > 0 && !allowedCanonical.has('docs-canonical/API-REFERENCE.md')) {
|
|
254
|
+
notes.push(`Detected ${surface.endpoints.length} endpoint(s) but the '${profileName}' profile omits API-REFERENCE.md — not generated. If this project genuinely exposes an HTTP API, re-run with --profile standard.`);
|
|
255
|
+
}
|
|
256
|
+
if (surface.integrations.length > 0 && !allowedCanonical.has('docs-canonical/INTEGRATIONS.md')) {
|
|
257
|
+
notes.push(`Detected ${surface.integrations.length} integration(s) but the '${profileName}' profile omits INTEGRATIONS.md — not generated.`);
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
|
|
229
261
|
// ── Compose documents + sections (language/kind-aware) ──
|
|
230
262
|
const docs = [];
|
|
231
263
|
const agentTasks = [];
|
|
@@ -248,9 +280,24 @@ function _buildMemoryPlanUncached(projectDir, config = {}) {
|
|
|
248
280
|
sections.push(addTask('docs-canonical/ARCHITECTURE.md', 'overview',
|
|
249
281
|
'Write a 2-3 sentence System Overview: what this project does and who uses it.',
|
|
250
282
|
{ languages: profile.languages, frameworks: profile.frameworks, kind: profile.kind }));
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
283
|
+
|
|
284
|
+
// Component Map — PRE-FILLED from the real source layout (field report §5):
|
|
285
|
+
// the agent gets the module list for free and only annotates responsibilities.
|
|
286
|
+
if (surface.modules.length > 0) {
|
|
287
|
+
sections.push({
|
|
288
|
+
id: 'component-map',
|
|
289
|
+
source: 'code',
|
|
290
|
+
body: md.table(['Module', 'Kind', 'Responsibility'],
|
|
291
|
+
surface.modules.map(m => [`\`${m.path}\``, m.kind, '<!-- one-line responsibility -->'])),
|
|
292
|
+
});
|
|
293
|
+
sections.push(addTask('docs-canonical/ARCHITECTURE.md', 'components',
|
|
294
|
+
'Fill in a one-line responsibility for each module in the Component Map above (replace each `<!-- one-line responsibility -->`). Group related modules into layers if it aids understanding.',
|
|
295
|
+
{ modules: surface.modules.map(m => m.path) }));
|
|
296
|
+
} else {
|
|
297
|
+
sections.push(addTask('docs-canonical/ARCHITECTURE.md', 'components',
|
|
298
|
+
'Describe the major components/modules and their responsibilities, using the real directories below.',
|
|
299
|
+
{ ecosystems: profile.ecosystems.map(e => ({ dir: e.dir, language: e.language, framework: e.framework })) }));
|
|
300
|
+
}
|
|
254
301
|
|
|
255
302
|
// Frontend modules (stores/hooks/contexts) — code-truth section when present.
|
|
256
303
|
const feCounts = surface.stores.length + surface.hooks.length + surface.contexts.length;
|
|
@@ -269,8 +316,31 @@ function _buildMemoryPlanUncached(projectDir, config = {}) {
|
|
|
269
316
|
docs.push({ path: 'docs-canonical/ARCHITECTURE.md', sections });
|
|
270
317
|
}
|
|
271
318
|
|
|
272
|
-
//
|
|
273
|
-
|
|
319
|
+
// TEST-SPEC — always (a required canonical doc in every profile). The test
|
|
320
|
+
// INVENTORY is pre-filled from the real test files (field report §5); the
|
|
321
|
+
// agent writes only the coverage rules + the service→test mapping.
|
|
322
|
+
{
|
|
323
|
+
const ti = surface.tests;
|
|
324
|
+
const sections = [];
|
|
325
|
+
if (ti.totalFiles > 0) {
|
|
326
|
+
const header = `**${ti.totalFiles} test file(s)${ti.totalCases > 0 ? `, ${ti.totalCases} test case(s)` : ''}**`;
|
|
327
|
+
const rows = ti.files.map(t => [`\`${t.file}\``, t.cases > 0 ? String(t.cases) : '—']);
|
|
328
|
+
sections.push({
|
|
329
|
+
id: 'test-inventory',
|
|
330
|
+
source: 'code',
|
|
331
|
+
body: `${header}\n\n${md.table(['Test file', 'Cases'], rows)}`,
|
|
332
|
+
});
|
|
333
|
+
}
|
|
334
|
+
sections.push(addTask('docs-canonical/TEST-SPEC.md', 'coverage',
|
|
335
|
+
ti.totalFiles > 0
|
|
336
|
+
? 'Document the test categories (unit / integration / e2e), the coverage rules, and the service→test mapping. The detected test files + case counts are listed above.'
|
|
337
|
+
: 'No test files were detected. Document the intended test strategy: categories, coverage targets, and where tests will live.',
|
|
338
|
+
{ totalFiles: ti.totalFiles, totalCases: ti.totalCases }));
|
|
339
|
+
docs.push({ path: 'docs-canonical/TEST-SPEC.md', sections });
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
// API-REFERENCE — only if there's an API surface AND the profile allows it.
|
|
343
|
+
if (surface.endpoints.length > 0 && profileAllows('docs-canonical/API-REFERENCE.md')) {
|
|
274
344
|
const rows = surface.endpoints.map(e => [`\`${e.method}\``, `\`${e.path}\``, e.auth ? '🔒' : '🔓']);
|
|
275
345
|
const sections = [{
|
|
276
346
|
id: 'endpoints',
|
|
@@ -283,8 +353,8 @@ function _buildMemoryPlanUncached(projectDir, config = {}) {
|
|
|
283
353
|
docs.push({ path: 'docs-canonical/API-REFERENCE.md', sections });
|
|
284
354
|
}
|
|
285
355
|
|
|
286
|
-
// DATA-MODEL — only if entities detected.
|
|
287
|
-
if (surface.entities.length > 0) {
|
|
356
|
+
// DATA-MODEL — only if entities detected AND the profile allows it.
|
|
357
|
+
if (surface.entities.length > 0 && profileAllows('docs-canonical/DATA-MODEL.md')) {
|
|
288
358
|
const rows = surface.entities.map(e => [`\`${e.name}\``, String((e.fields || []).length)]);
|
|
289
359
|
const sections = [{
|
|
290
360
|
id: 'entities',
|
|
@@ -297,8 +367,8 @@ function _buildMemoryPlanUncached(projectDir, config = {}) {
|
|
|
297
367
|
docs.push({ path: 'docs-canonical/DATA-MODEL.md', sections });
|
|
298
368
|
}
|
|
299
369
|
|
|
300
|
-
// SCREENS — only for web frontends with screens.
|
|
301
|
-
if (surface.screens.length > 0) {
|
|
370
|
+
// SCREENS — only for web frontends with screens AND if the profile allows it.
|
|
371
|
+
if (surface.screens.length > 0 && profileAllows('docs-canonical/SCREENS.md')) {
|
|
302
372
|
const rows = surface.screens.map(s => [`\`${s.path}\``, s.component || '—']);
|
|
303
373
|
const sections = [{
|
|
304
374
|
id: 'screens',
|
|
@@ -311,8 +381,8 @@ function _buildMemoryPlanUncached(projectDir, config = {}) {
|
|
|
311
381
|
docs.push({ path: 'docs-canonical/SCREENS.md', sections });
|
|
312
382
|
}
|
|
313
383
|
|
|
314
|
-
// INTEGRATIONS — external services / SDKs detected from deps.
|
|
315
|
-
if (surface.integrations.length > 0) {
|
|
384
|
+
// INTEGRATIONS — external services / SDKs detected from deps (profile-gated).
|
|
385
|
+
if (surface.integrations.length > 0 && profileAllows('docs-canonical/INTEGRATIONS.md')) {
|
|
316
386
|
const rows = surface.integrations.map(i => [i.category, `**${i.name}**`, i.evidence.slice(0, 3).join(', ')]);
|
|
317
387
|
const sections = [{
|
|
318
388
|
id: 'integrations',
|
|
@@ -325,8 +395,8 @@ function _buildMemoryPlanUncached(projectDir, config = {}) {
|
|
|
325
395
|
docs.push({ path: 'docs-canonical/INTEGRATIONS.md', sections });
|
|
326
396
|
}
|
|
327
397
|
|
|
328
|
-
// FEATURES — derived from screens + endpoints when there's a UI surface.
|
|
329
|
-
if (surface.screens.length > 0) {
|
|
398
|
+
// FEATURES — derived from screens + endpoints when there's a UI surface (profile-gated).
|
|
399
|
+
if (surface.screens.length > 0 && profileAllows('docs-canonical/FEATURES.md')) {
|
|
330
400
|
const groups = {};
|
|
331
401
|
for (const s of surface.screens) {
|
|
332
402
|
const seg = (s.path.split('/').filter(Boolean)[0] || 'root');
|
|
@@ -395,5 +465,5 @@ function _buildMemoryPlanUncached(projectDir, config = {}) {
|
|
|
395
465
|
],
|
|
396
466
|
});
|
|
397
467
|
|
|
398
|
-
return { profile, surface, docs, agentTasks };
|
|
468
|
+
return { profile, surface, docs, agentTasks, notes };
|
|
399
469
|
}
|
|
@@ -15,7 +15,7 @@
|
|
|
15
15
|
|
|
16
16
|
import { existsSync, readFileSync, readdirSync, statSync } from 'node:fs';
|
|
17
17
|
import { resolve, join, relative, dirname, basename } from 'node:path';
|
|
18
|
-
import { shouldIgnore, relPosix } from '../shared-ignore.mjs';
|
|
18
|
+
import { shouldIgnore, relPosix, isNonProductDir } from '../shared-ignore.mjs';
|
|
19
19
|
|
|
20
20
|
const IGNORE_DIRS = new Set([
|
|
21
21
|
'node_modules', '.git', '.next', 'dist', 'build', 'coverage', 'target',
|
|
@@ -53,9 +53,12 @@ function findManifests(projectDir, maxDepth = 4, config = {}) {
|
|
|
53
53
|
for (const e of entries) {
|
|
54
54
|
if (e.isDirectory()) {
|
|
55
55
|
if (IGNORE_DIRS.has(e.name) || e.name.startsWith('.')) continue;
|
|
56
|
-
//
|
|
57
|
-
//
|
|
58
|
-
//
|
|
56
|
+
// v0.26 (Bug #1): skip non-product dirs (tests/fixtures/examples/…) by
|
|
57
|
+
// DEFAULT. A fixture `package.json` declaring express or a `py_app`
|
|
58
|
+
// requirements.txt with flask must never set the PROJECT's stack/kind.
|
|
59
|
+
// This is the first-run fix — it needs no `.docguardignore`.
|
|
60
|
+
if (isNonProductDir(e.name, config)) continue;
|
|
61
|
+
// Also honor explicit config.ignore / .docguardignore patterns.
|
|
59
62
|
if (shouldIgnore(relPosix(root, join(dir, e.name)), config)) continue;
|
|
60
63
|
walk(join(dir, e.name), depth + 1);
|
|
61
64
|
} else if (e.isFile()) {
|
|
@@ -315,3 +318,56 @@ export function detectProjectProfile(projectDir, config = {}) {
|
|
|
315
318
|
kind: primary?.kind || 'unknown',
|
|
316
319
|
};
|
|
317
320
|
}
|
|
321
|
+
|
|
322
|
+
/**
|
|
323
|
+
* Find a `name = "..."` entry inside a TOML `[section]` (e.g. `[project]`,
|
|
324
|
+
* `[package]`, `[tool.poetry]`). Header match is exact on the bracket content.
|
|
325
|
+
*/
|
|
326
|
+
function tomlSectionName(content, section) {
|
|
327
|
+
if (!content) return null;
|
|
328
|
+
let inSection = false;
|
|
329
|
+
for (const line of content.split(/\r?\n/)) {
|
|
330
|
+
const header = line.match(/^\s*\[([^\]]+)\]/);
|
|
331
|
+
if (header) { inSection = header[1].trim() === section; continue; }
|
|
332
|
+
if (inSection) {
|
|
333
|
+
const m = line.match(/^\s*name\s*=\s*['"]([^'"]+)['"]/);
|
|
334
|
+
if (m) return m[1].trim();
|
|
335
|
+
}
|
|
336
|
+
}
|
|
337
|
+
return null;
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
/**
|
|
341
|
+
* Resolve the project's declared NAME from its ROOT manifest, falling back to
|
|
342
|
+
* the directory basename.
|
|
343
|
+
*
|
|
344
|
+
* Fixes Bug #4: inside a git worktree the directory is an auto-generated slug
|
|
345
|
+
* (e.g. `compassionate-chaplygin-c91f47`), but the real name lives in
|
|
346
|
+
* `pyproject.toml [project].name` / `package.json` name / `Cargo.toml [package]
|
|
347
|
+
* name` / `composer.json` name / `go.mod` module. Reads only root manifests —
|
|
348
|
+
* cheap, no tree walk — so it's safe to call at config-load time.
|
|
349
|
+
*/
|
|
350
|
+
export function detectProjectName(projectDir) {
|
|
351
|
+
const root = resolve(projectDir);
|
|
352
|
+
|
|
353
|
+
const pkg = readJson(join(root, 'package.json'));
|
|
354
|
+
if (pkg && typeof pkg.name === 'string' && pkg.name.trim()) return pkg.name.trim();
|
|
355
|
+
|
|
356
|
+
const py = tomlSectionName(readSafe(join(root, 'pyproject.toml')), 'project')
|
|
357
|
+
|| tomlSectionName(readSafe(join(root, 'pyproject.toml')), 'tool.poetry');
|
|
358
|
+
if (py) return py;
|
|
359
|
+
|
|
360
|
+
const cargo = tomlSectionName(readSafe(join(root, 'Cargo.toml')), 'package');
|
|
361
|
+
if (cargo) return cargo;
|
|
362
|
+
|
|
363
|
+
const composer = readJson(join(root, 'composer.json'));
|
|
364
|
+
if (composer && typeof composer.name === 'string' && composer.name.trim()) {
|
|
365
|
+
const n = composer.name.trim();
|
|
366
|
+
return n.includes('/') ? n.slice(n.lastIndexOf('/') + 1) : n; // vendor/name → name
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
const goMod = readSafe(join(root, 'go.mod')).match(/^\s*module\s+(\S+)/m);
|
|
370
|
+
if (goMod) return basename(goMod[1].replace(/\/+$/, ''));
|
|
371
|
+
|
|
372
|
+
return basename(root);
|
|
373
|
+
}
|
package/cli/scanners/routes.mjs
CHANGED
|
@@ -9,7 +9,7 @@
|
|
|
9
9
|
import { existsSync, readFileSync, readdirSync, statSync } from 'node:fs';
|
|
10
10
|
import { resolve, join, relative, basename, extname, dirname } from 'node:path';
|
|
11
11
|
import { resolveSourceRoots, readScannable } from '../shared-source.mjs';
|
|
12
|
-
import { DEFAULT_IGNORE_DIRS as IGNORE_DIRS, shouldIgnore, relPosix } from '../shared-ignore.mjs';
|
|
12
|
+
import { DEFAULT_IGNORE_DIRS as IGNORE_DIRS, shouldIgnore, relPosix, isNonProductPath } from '../shared-ignore.mjs';
|
|
13
13
|
import { extractJsRouteCalls, extractJsRouteObjects, extractJsMountsAndImports } from './js-ast.mjs';
|
|
14
14
|
import { extractPythonFiles } from './py-ast.mjs';
|
|
15
15
|
|
|
@@ -78,16 +78,22 @@ export function scanRoutesDeep(dir, stack, docTools, opts = {}) {
|
|
|
78
78
|
routes.push(...scanFastAPIRoutes(dir));
|
|
79
79
|
}
|
|
80
80
|
|
|
81
|
-
// Deduplicate by method+path, and
|
|
82
|
-
// fixtures dir with fake routes doesn't pollute
|
|
83
|
-
// RESULTS (route.file → project-relative) keeps
|
|
81
|
+
// Deduplicate by method+path, and drop routes that live in non-product dirs
|
|
82
|
+
// (tests/fixtures/examples) so a fixtures dir with fake routes doesn't pollute
|
|
83
|
+
// the API surface. Filtering the RESULTS (route.file → project-relative) keeps
|
|
84
|
+
// the per-framework walkers as-is. v0.26 (Bug #1): isNonProductPath applies by
|
|
85
|
+
// DEFAULT (no .docguardignore needed); shouldIgnore honors explicit config.
|
|
84
86
|
const cfg = opts.config || {};
|
|
85
87
|
const seen = new Set();
|
|
86
88
|
return routes.filter(r => {
|
|
87
89
|
const key = `${r.method}:${r.path}`;
|
|
88
90
|
if (seen.has(key)) return false;
|
|
89
91
|
seen.add(key);
|
|
90
|
-
if (r.file
|
|
92
|
+
if (r.file) {
|
|
93
|
+
const rel = relPosix(dir, resolve(dir, r.file));
|
|
94
|
+
if (isNonProductPath(rel, cfg)) return false;
|
|
95
|
+
if (shouldIgnore(rel, cfg)) return false;
|
|
96
|
+
}
|
|
91
97
|
return true;
|
|
92
98
|
});
|
|
93
99
|
}
|
package/cli/shared-ignore.mjs
CHANGED
|
@@ -40,6 +40,46 @@ export const DEFAULT_IGNORE_DIRS = new Set([
|
|
|
40
40
|
const ALWAYS_REJECT_PATH_RE =
|
|
41
41
|
/(?:^|[/\\])(?:node_modules|\.claude[/\\]worktrees|\.git[/\\]worktrees|\.jj)(?:[/\\]|$)/;
|
|
42
42
|
|
|
43
|
+
/**
|
|
44
|
+
* Directory names that hold NON-PRODUCT code — test fixtures, sample apps,
|
|
45
|
+
* example projects, mocks. Excluded from SURFACE DETECTION (framework / route /
|
|
46
|
+
* integration / env-var inference) BY DEFAULT, with no `.docguardignore`
|
|
47
|
+
* required.
|
|
48
|
+
*
|
|
49
|
+
* Why this exists (v0.26, field report Bug #1): a tool's own test fixtures —
|
|
50
|
+
* e.g. a deliberately-vulnerable Express sample under `tests/fixtures/` — were
|
|
51
|
+
* being read as the PRODUCT's architecture, so a pure-Python CLI got documented
|
|
52
|
+
* as an Express/Flask web app. Honoring `config.ignore` (added v0.25) wasn't
|
|
53
|
+
* enough: the realistic first run has no `.docguardignore` yet.
|
|
54
|
+
*
|
|
55
|
+
* SCOPE: detection/generate scanners ONLY — deliberately NOT guard's structural
|
|
56
|
+
* validators. A user's real `examples/` dir still counts toward docs coverage.
|
|
57
|
+
* Anti-false-green: when a surface signal appears ONLY under these dirs, callers
|
|
58
|
+
* SHOULD surface a low-confidence "confirm these are fixtures" note rather than
|
|
59
|
+
* silently drop it. Override via `config.detection.includeNonProduct = true`.
|
|
60
|
+
*/
|
|
61
|
+
export const DEFAULT_DETECTION_IGNORE_DIRS = new Set([
|
|
62
|
+
'fixtures', '__fixtures__', 'test-fixtures', 'testfixtures', 'testdata',
|
|
63
|
+
'test', 'tests', '__tests__', 'spec', 'specs', '__mocks__', 'mocks',
|
|
64
|
+
'examples', 'example', 'sample', 'samples',
|
|
65
|
+
]);
|
|
66
|
+
|
|
67
|
+
/** True if `dirName` is a non-product dir detection should skip by default. */
|
|
68
|
+
export function isNonProductDir(dirName, config = {}) {
|
|
69
|
+
if (config?.detection?.includeNonProduct) return false;
|
|
70
|
+
return DEFAULT_DETECTION_IGNORE_DIRS.has(dirName);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* True if ANY path segment of `relPath` (POSIX, project-relative) is a
|
|
75
|
+
* non-product detection dir — for filtering file-level detection results.
|
|
76
|
+
*/
|
|
77
|
+
export function isNonProductPath(relPath, config = {}) {
|
|
78
|
+
if (config?.detection?.includeNonProduct) return false;
|
|
79
|
+
if (!relPath) return false;
|
|
80
|
+
return relPath.split('/').some(seg => DEFAULT_DETECTION_IGNORE_DIRS.has(seg));
|
|
81
|
+
}
|
|
82
|
+
|
|
43
83
|
/**
|
|
44
84
|
* Read `.docguardignore` from a project directory and return its patterns.
|
|
45
85
|
*
|
package/cli/shared-source.mjs
CHANGED
|
@@ -15,7 +15,7 @@
|
|
|
15
15
|
|
|
16
16
|
import { existsSync, readFileSync, readdirSync, statSync } from 'node:fs';
|
|
17
17
|
import { resolve, join, dirname, relative, extname } from 'node:path';
|
|
18
|
-
import { shouldIgnore } from './shared-ignore.mjs';
|
|
18
|
+
import { shouldIgnore, isNonProductDir, isNonProductPath } from './shared-ignore.mjs';
|
|
19
19
|
|
|
20
20
|
const IGNORE_DIRS = new Set([
|
|
21
21
|
'node_modules', '.git', '.next', '.nuxt', 'dist', 'build', 'out',
|
|
@@ -232,12 +232,90 @@ export function detectDocker(projectDir, config = {}) {
|
|
|
232
232
|
return false;
|
|
233
233
|
}
|
|
234
234
|
|
|
235
|
+
const HASH_COMMENT_EXTS = new Set(['.py', '.rb', '.php', '.sh']);
|
|
236
|
+
const SLASH_COMMENT_EXTS = new Set(['.js', '.mjs', '.cjs', '.ts', '.tsx', '.jsx', '.go', '.rs', '.java', '.php', '.kt', '.scala']);
|
|
237
|
+
|
|
238
|
+
/**
|
|
239
|
+
* Classify every character of `content` as code (0), string-literal (1), or
|
|
240
|
+
* comment (2) with a single-pass, dependency-free lexer. Used by env detection
|
|
241
|
+
* (Bug #7) so a variable is counted only when actually READ in code — not when
|
|
242
|
+
* merely mentioned inside a string literal (e.g. a detection signature like
|
|
243
|
+
* `r"os.environ.get('JWT_SECRET')"`) or a comment. Handles ' " ` quotes,
|
|
244
|
+
* Python triple-quotes, `#` and `//` line comments, and `/_ _/` block comments.
|
|
245
|
+
* Best-effort: on an unterminated single-line string it bails at the newline so
|
|
246
|
+
* it never swallows the rest of the file (errs toward marking code, so a real
|
|
247
|
+
* read is never dropped).
|
|
248
|
+
*/
|
|
249
|
+
function classifyChars(content, ext) {
|
|
250
|
+
const n = content.length;
|
|
251
|
+
const kind = new Uint8Array(n); // 0 = code, 1 = string, 2 = comment
|
|
252
|
+
const hashC = HASH_COMMENT_EXTS.has(ext);
|
|
253
|
+
const slashC = SLASH_COMMENT_EXTS.has(ext);
|
|
254
|
+
const triple = ext === '.py';
|
|
255
|
+
let i = 0;
|
|
256
|
+
while (i < n) {
|
|
257
|
+
const ch = content[i];
|
|
258
|
+
if (hashC && ch === '#') { while (i < n && content[i] !== '\n') kind[i++] = 2; continue; }
|
|
259
|
+
if (slashC && ch === '/' && content[i + 1] === '/') { while (i < n && content[i] !== '\n') kind[i++] = 2; continue; }
|
|
260
|
+
if (slashC && ch === '/' && content[i + 1] === '*') {
|
|
261
|
+
kind[i++] = 2; if (i < n) kind[i++] = 2;
|
|
262
|
+
while (i < n && !(content[i] === '*' && content[i + 1] === '/')) kind[i++] = 2;
|
|
263
|
+
if (i < n) { kind[i++] = 2; if (i < n) kind[i++] = 2; }
|
|
264
|
+
continue;
|
|
265
|
+
}
|
|
266
|
+
if (triple && (ch === '"' || ch === "'") && content[i + 1] === ch && content[i + 2] === ch) {
|
|
267
|
+
const q = ch;
|
|
268
|
+
kind[i++] = 1; kind[i++] = 1; kind[i++] = 1;
|
|
269
|
+
while (i < n && !(content[i] === q && content[i + 1] === q && content[i + 2] === q)) {
|
|
270
|
+
if (content[i] === '\\') { kind[i++] = 1; if (i < n) kind[i++] = 1; continue; }
|
|
271
|
+
kind[i++] = 1;
|
|
272
|
+
}
|
|
273
|
+
if (i < n) { kind[i++] = 1; if (i < n) kind[i++] = 1; if (i < n) kind[i++] = 1; }
|
|
274
|
+
continue;
|
|
275
|
+
}
|
|
276
|
+
if (ch === '"' || ch === "'" || ch === '`') {
|
|
277
|
+
const q = ch;
|
|
278
|
+
kind[i++] = 1; // opening quote
|
|
279
|
+
while (i < n && content[i] !== q) {
|
|
280
|
+
if (content[i] === '\\') { kind[i++] = 1; if (i < n) kind[i++] = 1; continue; }
|
|
281
|
+
if (content[i] === '\n' && q !== '`') break; // unterminated single-line string — bail
|
|
282
|
+
kind[i++] = 1;
|
|
283
|
+
}
|
|
284
|
+
if (i < n && content[i] === q) kind[i++] = 1; // closing quote
|
|
285
|
+
continue;
|
|
286
|
+
}
|
|
287
|
+
kind[i++] = 0;
|
|
288
|
+
}
|
|
289
|
+
return kind;
|
|
290
|
+
}
|
|
291
|
+
|
|
235
292
|
/**
|
|
236
293
|
* Grep source files under the resolved source roots for environment variable
|
|
237
294
|
* usage in both the Node (process dot env) and Vite (import meta env) styles,
|
|
238
295
|
* including bracket access.
|
|
239
296
|
* @returns {Set<string>} variable names referenced in code
|
|
240
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
|
+
|
|
241
319
|
export function grepEnvUsage(projectDir, config = {}) {
|
|
242
320
|
const names = new Set();
|
|
243
321
|
const roots = resolveSourceRoots(projectDir, config);
|
|
@@ -269,9 +347,18 @@ export function grepEnvUsage(projectDir, config = {}) {
|
|
|
269
347
|
if (!CODE_EXTENSIONS.has(extname(filePath))) return;
|
|
270
348
|
const rel = relative(projectDir, filePath);
|
|
271
349
|
if (shouldIgnore(rel, config)) return;
|
|
350
|
+
// v0.26 (Bug #7): a token that appears only in a test/fixture file is not a
|
|
351
|
+
// product env read. Skip non-product paths by default (no .docguardignore).
|
|
352
|
+
if (isNonProductPath(rel.replace(/\\/g, '/'), config)) return;
|
|
272
353
|
const content = readScannable(filePath);
|
|
273
354
|
if (content === null) return; // unreadable, generated, or too large to scan
|
|
274
355
|
if (!content.includes('env')) return;
|
|
356
|
+
// v0.26 (Bug #7): classify chars so we count env vars actually READ in code,
|
|
357
|
+
// not ones MENTIONED inside a string literal (a detection signature like
|
|
358
|
+
// `r"os.environ.get('JWT_SECRET')"`) or a comment. We test the position of
|
|
359
|
+
// the access KEYWORD (process/os/import) — for a real read the keyword is
|
|
360
|
+
// code while only the argument 'X' is a string, so the name is still caught.
|
|
361
|
+
const kind = classifyChars(content, extname(filePath));
|
|
275
362
|
// patterns[2] is the import.meta.env one — its matches are Vite-injected
|
|
276
363
|
// when the name is an intrinsic, and must not be reported as user env vars.
|
|
277
364
|
for (let i = 0; i < patterns.length; i++) {
|
|
@@ -279,7 +366,9 @@ export function grepEnvUsage(projectDir, config = {}) {
|
|
|
279
366
|
const rx = new RegExp(patterns[i].source, 'g');
|
|
280
367
|
const isViteSource = i === 2;
|
|
281
368
|
while ((m = rx.exec(content)) !== null) {
|
|
369
|
+
if (kind[m.index] !== 0) continue; // keyword inside a string/comment → a mention, not a read
|
|
282
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
|
|
283
372
|
names.add(m[1]);
|
|
284
373
|
}
|
|
285
374
|
}
|
|
@@ -299,12 +388,12 @@ export function grepEnvUsage(projectDir, config = {}) {
|
|
|
299
388
|
// camelCase keys, so requiring UPPER_SNAKE keeps this env-specific.
|
|
300
389
|
const keyRe = /^\s*['"]?([A-Z][A-Z0-9_]*[A-Z0-9])['"]?\s*:/gm;
|
|
301
390
|
while ((km = keyRe.exec(content)) !== null) {
|
|
302
|
-
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]);
|
|
303
392
|
}
|
|
304
393
|
// convict: the env var name is the `env:` property value, not the key.
|
|
305
394
|
const convictRe = /\benv\s*:\s*['"]([A-Z][A-Z0-9_]*[A-Z0-9])['"]/g;
|
|
306
395
|
while ((km = convictRe.exec(content)) !== null) {
|
|
307
|
-
if (km[1].length >= 3) names.add(km[1]);
|
|
396
|
+
if (km[1].length >= 3 && !isRunnerEnvVar(km[1])) names.add(km[1]);
|
|
308
397
|
}
|
|
309
398
|
}
|
|
310
399
|
};
|
|
@@ -314,6 +403,7 @@ export function grepEnvUsage(projectDir, config = {}) {
|
|
|
314
403
|
try { entries = readdirSync(dir, { withFileTypes: true }); } catch { return; }
|
|
315
404
|
for (const e of entries) {
|
|
316
405
|
if (IGNORE_DIRS.has(e.name) || e.name.startsWith('.')) continue;
|
|
406
|
+
if (e.isDirectory() && isNonProductDir(e.name, config)) continue; // v0.26: skip test/fixture dirs in env detection
|
|
317
407
|
const full = join(dir, e.name);
|
|
318
408
|
if (e.isDirectory()) walk(full);
|
|
319
409
|
else if (e.isFile()) visit(full);
|
|
@@ -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
|
|
|
@@ -61,6 +61,22 @@ export function readLastReviewedDate(absPath) {
|
|
|
61
61
|
}
|
|
62
62
|
}
|
|
63
63
|
|
|
64
|
+
/**
|
|
65
|
+
* Read the `<!-- docguard:status <value> -->` marker (draft | review | approved
|
|
66
|
+
* | living). Returns the lowercased value, or null. Used by the uncommitted-doc
|
|
67
|
+
* check (Bug #6): a doc the agent generated this session and marked `approved`
|
|
68
|
+
* has an explicit currency signal even before it's committed.
|
|
69
|
+
*/
|
|
70
|
+
function readDocStatus(absPath) {
|
|
71
|
+
try {
|
|
72
|
+
const content = readFileSync(absPath, 'utf-8');
|
|
73
|
+
const m = content.match(/<!--\s*docguard:status\s+([a-z]+)\s*-->/i);
|
|
74
|
+
return m ? m[1].toLowerCase() : null;
|
|
75
|
+
} catch {
|
|
76
|
+
return null;
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
64
80
|
/**
|
|
65
81
|
* Get the last git commit date for a file.
|
|
66
82
|
* Returns null if the file isn't tracked or git isn't available.
|
|
@@ -213,10 +229,23 @@ export function validateFreshness(dir, config) {
|
|
|
213
229
|
const reviewedDate = readLastReviewedDate(docPath);
|
|
214
230
|
const docDate = reviewedDate || getLastGitDate(docFile, dir);
|
|
215
231
|
if (!docDate) {
|
|
216
|
-
// File exists but
|
|
232
|
+
// File exists but has no freshness signal (not in git, no last-reviewed).
|
|
233
|
+
// Bug #6: an agent that generated the doc THIS session and stamped it
|
|
234
|
+
// `<!-- docguard:status approved -->` has signaled it's intentionally
|
|
235
|
+
// current. In the generate-then-fill flow the human hasn't committed yet,
|
|
236
|
+
// so the "uncommitted" warning is noise — suppress it for approved docs.
|
|
237
|
+
if (readDocStatus(docPath) === 'approved') {
|
|
238
|
+
results.push({
|
|
239
|
+
status: 'pass',
|
|
240
|
+
message: `${docFile} is marked approved (not yet committed — fine mid-session)`,
|
|
241
|
+
});
|
|
242
|
+
continue;
|
|
243
|
+
}
|
|
244
|
+
// State BOTH satisfiers — the warning used to mention only committing, so
|
|
245
|
+
// an agent that can stamp a marker but not commit was left guessing.
|
|
217
246
|
results.push({
|
|
218
247
|
status: 'warn',
|
|
219
|
-
message: `${docFile} exists but is not yet committed to git
|
|
248
|
+
message: `${docFile} exists but is not yet committed to git — commit it, or add a <!-- docguard:last-reviewed YYYY-MM-DD --> marker (or <!-- docguard:status approved -->).`,
|
|
220
249
|
});
|
|
221
250
|
continue;
|
|
222
251
|
}
|
|
@@ -93,6 +93,11 @@ export function validateMetricsConsistency(projectDir, config, guardResults) {
|
|
|
93
93
|
// "19" on line 50 are two distinct drifts.
|
|
94
94
|
const distinctFoundInFile = new Set();
|
|
95
95
|
while ((match = regex.exec(content)) !== null) {
|
|
96
|
+
// Bug #2 (subject-binding): only validate a number BOUND to DocGuard.
|
|
97
|
+
// An unbound "N checks" (a proof harness, a CI job, a third-party tool)
|
|
98
|
+
// describes a DIFFERENT subject — comparing it to DocGuard's own count
|
|
99
|
+
// is a false positive, and auto-fixing it overwrites a correct number.
|
|
100
|
+
if (!isDocguardBound(content, match.index)) continue;
|
|
96
101
|
distinctFoundInFile.add(parseInt(match[1], 10));
|
|
97
102
|
}
|
|
98
103
|
if (distinctFoundInFile.size === 0) continue;
|
|
@@ -104,9 +109,12 @@ export function validateMetricsConsistency(projectDir, config, guardResults) {
|
|
|
104
109
|
reportedDrift.add(driftKey);
|
|
105
110
|
total++;
|
|
106
111
|
warnings.push(
|
|
107
|
-
`${relPath} says "${found} ${label}" but
|
|
112
|
+
`${relPath} says "${found} ${label}" but DocGuard's own ${label} count is ${actuals[key]}. Fix with \`docguard fix --write\``
|
|
108
113
|
);
|
|
109
|
-
|
|
114
|
+
// actualSource records WHAT the actual count describes, so the applier
|
|
115
|
+
// (and a human) can confirm both sides are the same subject before any
|
|
116
|
+
// overwrite. Without it the fix is refused (fail-closed). See Bug #2.
|
|
117
|
+
fixes.push({ type: 'replace-count', file: relPath, label, found, actual: actuals[key], actualSource: `docguard.guard.${key}` });
|
|
110
118
|
} else {
|
|
111
119
|
// Matches the actual count — one pass per (file, label), not per occurrence.
|
|
112
120
|
const passKey = `${relPath}|${label}`;
|
|
@@ -124,6 +132,22 @@ export function validateMetricsConsistency(projectDir, config, guardResults) {
|
|
|
124
132
|
|
|
125
133
|
// ── Helpers ──────────────────────────────────────────────────────────────────
|
|
126
134
|
|
|
135
|
+
/**
|
|
136
|
+
* Bug #2 — subject binding. A "N checks/validators" claim is DocGuard's to
|
|
137
|
+
* govern ONLY if it's bound to DocGuard: the line containing the number must
|
|
138
|
+
* reference "docguard" (case-insensitive), which also covers an explicit
|
|
139
|
+
* `<!-- docguard:metric ... -->` marker on that line. Numbers describing
|
|
140
|
+
* anything else (a proof harness, a CI pipeline, a competitor's tool) are out
|
|
141
|
+
* of scope — validating them is a false positive and auto-fixing them corrupts
|
|
142
|
+
* a correct number with DocGuard's unrelated count.
|
|
143
|
+
*/
|
|
144
|
+
function isDocguardBound(content, index) {
|
|
145
|
+
const lineStart = content.lastIndexOf('\n', index) + 1;
|
|
146
|
+
let lineEnd = content.indexOf('\n', index);
|
|
147
|
+
if (lineEnd === -1) lineEnd = content.length;
|
|
148
|
+
return /docguard/i.test(content.slice(lineStart, lineEnd));
|
|
149
|
+
}
|
|
150
|
+
|
|
127
151
|
function findTestFiles(dir) {
|
|
128
152
|
const tests = [];
|
|
129
153
|
const testDirs = ['tests', 'test', '__tests__', 'spec', 'e2e'];
|