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
package/cli/commands/score.mjs
CHANGED
|
@@ -10,6 +10,70 @@ import { c, docHasSection } from '../shared.mjs';
|
|
|
10
10
|
import { validateSecurity } from '../validators/security.mjs';
|
|
11
11
|
import { runGuardInternal } from './guard.mjs';
|
|
12
12
|
|
|
13
|
+
/**
|
|
14
|
+
* Detect whether the project configures a test runner (the "Check 3" of the
|
|
15
|
+
* testing score). Extracted as an exported seam so it's unit-testable without
|
|
16
|
+
* the full score pipeline.
|
|
17
|
+
*
|
|
18
|
+
* Recognises, in order: standalone config files; pytest config inside
|
|
19
|
+
* pyproject.toml / tox.ini; node:test via projectTypeConfig or scripts.test;
|
|
20
|
+
* a `scripts.test` that invokes a known runner; Vitest configured INSIDE
|
|
21
|
+
* vite.config.* (field report #3 — `vitest/config` import or a `test:` block);
|
|
22
|
+
* and runner configs in common workspace subdirs.
|
|
23
|
+
*
|
|
24
|
+
* @param {string} dir
|
|
25
|
+
* @param {object} config
|
|
26
|
+
* @returns {boolean}
|
|
27
|
+
*/
|
|
28
|
+
export function detectTestRunner(dir, config = {}) {
|
|
29
|
+
const testConfigFiles = ['jest.config.js', 'jest.config.ts', 'vitest.config.ts', 'vitest.config.js', 'pytest.ini', 'setup.cfg', '.mocharc.yml'];
|
|
30
|
+
if (testConfigFiles.some((f) => existsSync(resolve(dir, f)))) return true;
|
|
31
|
+
|
|
32
|
+
// Python: pytest config usually lives inside pyproject.toml ([tool.pytest.ini_options])
|
|
33
|
+
// or tox.ini ([pytest]) — not a standalone file.
|
|
34
|
+
for (const [file, marker] of [['pyproject.toml', /\[tool\.pytest/], ['tox.ini', /\[pytest\]/]]) {
|
|
35
|
+
const p = resolve(dir, file);
|
|
36
|
+
if (!existsSync(p)) continue;
|
|
37
|
+
try { if (marker.test(readFileSync(p, 'utf-8'))) return true; } catch { /* skip */ }
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
// node:test has no config file — recognize it via projectTypeConfig or package.json.
|
|
41
|
+
const ptc = config.projectTypeConfig || {};
|
|
42
|
+
if (ptc.testFramework === 'node:test') return true;
|
|
43
|
+
const pkgPath = resolve(dir, 'package.json');
|
|
44
|
+
if (existsSync(pkgPath)) {
|
|
45
|
+
try {
|
|
46
|
+
const pkg = JSON.parse(readFileSync(pkgPath, 'utf-8'));
|
|
47
|
+
const testScript = pkg.scripts?.test || '';
|
|
48
|
+
if (testScript.includes('node --test') || testScript.includes('node:test')) return true;
|
|
49
|
+
// v0.27 (field report #3): a `scripts.test` that runs a known runner IS a
|
|
50
|
+
// configured test runner, even without a standalone config file.
|
|
51
|
+
if (/\b(vitest|jest|mocha|ava|playwright|cypress|pytest)\b/.test(testScript)) return true;
|
|
52
|
+
} catch { /* skip */ }
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
// v0.27 (field report #3): Vitest configured INSIDE vite.config.* rather than a
|
|
56
|
+
// standalone vitest.config (`vitest/config` import + a `test:` block).
|
|
57
|
+
for (const f of ['vite.config.ts', 'vite.config.js', 'vite.config.mts', 'vite.config.mjs']) {
|
|
58
|
+
const p = resolve(dir, f);
|
|
59
|
+
if (!existsSync(p)) continue;
|
|
60
|
+
try {
|
|
61
|
+
const src = readFileSync(p, 'utf-8');
|
|
62
|
+
if (/vitest\/config/.test(src) || /^\s*test\s*:/m.test(src)) return true;
|
|
63
|
+
} catch { /* skip */ }
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
// Workspace subdirs: a runner config one level down still configures the project.
|
|
67
|
+
const subConfigs = ['vitest.config.ts', 'vitest.config.js', 'jest.config.ts', 'jest.config.js', 'vite.config.ts'];
|
|
68
|
+
for (const sub of ['backend', 'frontend', 'server', 'client', 'app', 'web', 'api']) {
|
|
69
|
+
for (const f of subConfigs) {
|
|
70
|
+
if (existsSync(resolve(dir, sub, f))) return true;
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
return false;
|
|
75
|
+
}
|
|
76
|
+
|
|
13
77
|
/**
|
|
14
78
|
* v0.18-P3: map score categories to the validator keys that contribute.
|
|
15
79
|
* One category can roll up multiple validators (e.g. "environment" pulls
|
|
@@ -585,38 +649,7 @@ function calcTestingScore(dir, config) {
|
|
|
585
649
|
else failures.push({ issue: 'TEST-SPEC.md missing', fixCmd: 'docguard fix --doc test-spec' });
|
|
586
650
|
|
|
587
651
|
// ── Check 3: Test config or built-in runner (15 pts) ──
|
|
588
|
-
|
|
589
|
-
let hasTestRunner = testConfigFiles.some(f => existsSync(resolve(dir, f)));
|
|
590
|
-
|
|
591
|
-
// Python: pytest config usually lives inside pyproject.toml ([tool.pytest.ini_options])
|
|
592
|
-
// or tox.ini ([pytest]) — not a standalone file. Detect those too, so a uv/pytest
|
|
593
|
-
// project isn't told to "add a test runner" it already configured (field report, Issue B).
|
|
594
|
-
if (!hasTestRunner) {
|
|
595
|
-
for (const [file, marker] of [['pyproject.toml', /\[tool\.pytest/], ['tox.ini', /\[pytest\]/]]) {
|
|
596
|
-
const p = resolve(dir, file);
|
|
597
|
-
if (!existsSync(p)) continue;
|
|
598
|
-
try { if (marker.test(readFileSync(p, 'utf-8'))) { hasTestRunner = true; break; } } catch { /* skip */ }
|
|
599
|
-
}
|
|
600
|
-
}
|
|
601
|
-
|
|
602
|
-
// node:test has no config file — recognize it via projectTypeConfig or package.json.
|
|
603
|
-
if (!hasTestRunner) {
|
|
604
|
-
const ptc = config.projectTypeConfig || {};
|
|
605
|
-
if (ptc.testFramework === 'node:test') {
|
|
606
|
-
hasTestRunner = true;
|
|
607
|
-
} else {
|
|
608
|
-
const pkgPath = resolve(dir, 'package.json');
|
|
609
|
-
if (existsSync(pkgPath)) {
|
|
610
|
-
try {
|
|
611
|
-
const pkg = JSON.parse(readFileSync(pkgPath, 'utf-8'));
|
|
612
|
-
const testScript = pkg.scripts?.test || '';
|
|
613
|
-
if (testScript.includes('node --test') || testScript.includes('node:test')) hasTestRunner = true;
|
|
614
|
-
} catch { /* skip */ }
|
|
615
|
-
}
|
|
616
|
-
}
|
|
617
|
-
}
|
|
618
|
-
|
|
619
|
-
if (hasTestRunner) score += 15;
|
|
652
|
+
if (detectTestRunner(dir, config)) score += 15;
|
|
620
653
|
else failures.push({ issue: 'no test runner config detected (jest/vitest/pytest/node:test)' });
|
|
621
654
|
|
|
622
655
|
// ── Check 4: CI test step (15 pts) ──
|
package/cli/config.mjs
CHANGED
|
@@ -12,11 +12,16 @@ import { existsSync, readFileSync } from 'node:fs';
|
|
|
12
12
|
import { resolve, basename } from 'node:path';
|
|
13
13
|
import { c, PROFILES, SEVERITY_LEVELS } from './shared.mjs';
|
|
14
14
|
import { mergeIgnoreFile } from './shared-ignore.mjs';
|
|
15
|
+
import { detectProjectName } from './scanners/project-type.mjs';
|
|
15
16
|
|
|
16
17
|
export function loadConfig(projectDir) {
|
|
17
18
|
const configPath = resolve(projectDir, '.docguard.json');
|
|
18
19
|
const defaults = {
|
|
19
|
-
|
|
20
|
+
// v0.26 (Bug #4): read the declared name from the root manifest
|
|
21
|
+
// (pyproject/package.json/Cargo/composer/go.mod) before falling back to the
|
|
22
|
+
// dir basename — otherwise a git-worktree slug becomes the project name.
|
|
23
|
+
// An explicit `projectName` in .docguard.json still wins via deepMerge.
|
|
24
|
+
projectName: detectProjectName(projectDir),
|
|
20
25
|
// Legacy/unversioned fallback ONLY — the value a config is ASSUMED to be
|
|
21
26
|
// when its file has no `version` field. NOT the current schema version
|
|
22
27
|
// (that's CURRENT_SCHEMA_VERSION in shared.mjs, written by `init`). Kept low
|
package/cli/docguard.mjs
CHANGED
|
@@ -43,8 +43,10 @@ import { runSetup } from './commands/setup.mjs';
|
|
|
43
43
|
import { runUpgrade } from './commands/upgrade.mjs';
|
|
44
44
|
import { runImpact } from './commands/impact.mjs';
|
|
45
45
|
import { runExplain } from './commands/explain.mjs';
|
|
46
|
+
import { runFeedback } from './commands/feedback.mjs';
|
|
46
47
|
import { runMemory } from './commands/memory.mjs';
|
|
47
48
|
import { runDemo } from './commands/demo.mjs';
|
|
49
|
+
import { runAgent } from './commands/agent.mjs';
|
|
48
50
|
import { ensureSkills } from './ensure-skills.mjs';
|
|
49
51
|
|
|
50
52
|
// ── Shared constants (imported to break circular dependencies) ──────────
|
|
@@ -83,7 +85,9 @@ ${c.bold}Tools (situational, but day-to-day useful)${c.reset}
|
|
|
83
85
|
${c.green}diagnose${c.reset} AI orchestrator — guard → emit fix prompts in one command
|
|
84
86
|
${c.green}fix${c.reset} Generate AI fix instructions for specific docs
|
|
85
87
|
${c.green}generate${c.reset} Reverse-engineer canonical docs from existing code (${c.cyan}--plan${c.reset} for AI scan)
|
|
86
|
-
${c.green}
|
|
88
|
+
${c.green}agent${c.reset} One-shot agent task graph — ordered tasks, pre-filled code-truth, per-task verify (${c.cyan}--format json${c.reset})
|
|
89
|
+
${c.green}explain${c.reset} Explain a validator key, warning text, or finding code (${c.cyan}docguard explain SEC001${c.reset})
|
|
90
|
+
${c.green}feedback${c.reset} Report likely false positives back to DocGuard (local-first + 1-click prefilled issue)
|
|
87
91
|
${c.green}memory${c.reset} Show what DocGuard remembers (${c.cyan}--diff${c.reset} drills into drift)
|
|
88
92
|
${c.green}trace${c.reset} Requirements traceability matrix (${c.cyan}--reverse${c.reset} for code→doc map)
|
|
89
93
|
${c.green}upgrade${c.reset} Migrate ${c.cyan}.docguard.json${c.reset} schema + CLI (${c.cyan}--apply --pr${c.reset} for team-wide PR)
|
|
@@ -192,6 +196,15 @@ const COMMAND_HELP = {
|
|
|
192
196
|
],
|
|
193
197
|
examples: ['docguard generate', 'docguard generate --plan', 'docguard generate --plan --write', 'docguard generate --plan --format json'],
|
|
194
198
|
},
|
|
199
|
+
agent: {
|
|
200
|
+
summary: 'One-shot agent task graph: ordered, dependency-aware, with pre-filled code-truth + per-task verify.',
|
|
201
|
+
usage: 'docguard agent [--profile <name>] [--format json]',
|
|
202
|
+
flags: [
|
|
203
|
+
['--format json', 'Machine-readable task graph (the agent-executable artifact)'],
|
|
204
|
+
['--profile <name>', 'Preview a profile (cli/library/standard/…) without running init first'],
|
|
205
|
+
],
|
|
206
|
+
examples: ['docguard agent', 'docguard agent --format json', 'docguard agent --profile cli --format json'],
|
|
207
|
+
},
|
|
195
208
|
guard: {
|
|
196
209
|
summary: 'Validate code against canonical docs (all validators).',
|
|
197
210
|
usage: 'docguard guard [--format json] [--changed-only] [--fail-on-warning]',
|
|
@@ -268,6 +281,12 @@ const COMMAND_HELP = {
|
|
|
268
281
|
flags: [['--diff', 'Drill into drift between memory and code']],
|
|
269
282
|
examples: ['docguard memory', 'docguard memory --diff'],
|
|
270
283
|
},
|
|
284
|
+
feedback: {
|
|
285
|
+
summary: 'Report likely false positives back to DocGuard. Collects the low-confidence findings of a guard run, saves a full local record under .docguard/feedback/, and prints a one-click, prefilled, redacted GitHub issue URL (zero typing, no source code or secret values).',
|
|
286
|
+
usage: 'docguard feedback [--format json]',
|
|
287
|
+
flags: [['--format json', 'Machine-readable list of reportable findings + URLs']],
|
|
288
|
+
examples: ['docguard feedback'],
|
|
289
|
+
},
|
|
271
290
|
};
|
|
272
291
|
|
|
273
292
|
function printCommandHelp(command) {
|
|
@@ -474,16 +493,42 @@ async function main() {
|
|
|
474
493
|
// .agent/.specify writes, which were a surprising side effect of a bare
|
|
475
494
|
// `generate --plan` (and were already suppressed for `--plan --write`).
|
|
476
495
|
const jsonMode = flags.format === 'json';
|
|
477
|
-
|
|
496
|
+
// `agent` emits a machine task graph (JSON by default) — it must be banner-
|
|
497
|
+
// free and side-effect-free like the other read-only commands.
|
|
498
|
+
const headless = jsonMode || flags.write || flags.checkOnly || flags.changedOnly || flags.quiet || flags.plan || command === 'agent';
|
|
478
499
|
|
|
479
500
|
if (!headless) printBanner();
|
|
480
501
|
|
|
481
502
|
const config = loadConfig(projectDir);
|
|
482
503
|
|
|
504
|
+
// Commands that only READ and REPORT — they must never mutate the working
|
|
505
|
+
// tree. Scaffolding (ensureSkills → .agent/.specify, spawning `specify`)
|
|
506
|
+
// belongs to setup/init/generate and the `init --with` family, where the
|
|
507
|
+
// user is establishing or expanding their setup, not auditing it.
|
|
508
|
+
//
|
|
509
|
+
// v0.26 (field report Bug #3): a bare `docguard guard` used to run
|
|
510
|
+
// ensureSkills → auto-init Spec Kit → spawn `specify` and write ~9 files into
|
|
511
|
+
// the tree BEFORE printing results. Surprising for a *validate* command, and
|
|
512
|
+
// fatal for a read-only CI audit or a clean-tree precondition check. These
|
|
513
|
+
// commands are now exempt regardless of flags. (`audit` is the guard alias;
|
|
514
|
+
// `diff`/`impact` only read; `demo` runs against a throwaway fixture.)
|
|
515
|
+
const READ_ONLY_COMMANDS = new Set([
|
|
516
|
+
'guard', 'audit', 'score', 'diff', 'impact',
|
|
517
|
+
'diagnose', 'trace', 'explain', 'memory', 'demo', 'agent',
|
|
518
|
+
// feedback only writes its own .docguard/feedback/ — it must NOT scaffold
|
|
519
|
+
// skills or touch source, so it's gated out of ensureSkills like the rest.
|
|
520
|
+
'feedback',
|
|
521
|
+
]);
|
|
522
|
+
|
|
483
523
|
// Silent auto-check: install skills/commands if missing. Skip entirely in
|
|
484
|
-
// headless modes
|
|
485
|
-
//
|
|
486
|
-
if (
|
|
524
|
+
// headless modes (deterministic, parseable output; no side effects expected)
|
|
525
|
+
// and for read-only commands (see above).
|
|
526
|
+
if (
|
|
527
|
+
command !== 'setup' &&
|
|
528
|
+
command !== 'init' &&
|
|
529
|
+
!READ_ONLY_COMMANDS.has(command) &&
|
|
530
|
+
!headless
|
|
531
|
+
) {
|
|
487
532
|
ensureSkills(projectDir, flags);
|
|
488
533
|
}
|
|
489
534
|
|
|
@@ -566,6 +611,11 @@ async function main() {
|
|
|
566
611
|
case 'generate':
|
|
567
612
|
runGenerate(projectDir, config, flags);
|
|
568
613
|
break;
|
|
614
|
+
case 'agent':
|
|
615
|
+
// v0.26 (field report §2): one-shot, dependency-ordered task graph with
|
|
616
|
+
// pre-filled code-truth + per-task verify. Read-only; JSON by default.
|
|
617
|
+
runAgent(projectDir, config, flags);
|
|
618
|
+
break;
|
|
569
619
|
case 'hooks':
|
|
570
620
|
await runInit(projectDir, config, { ...flags, with: ['hooks'], skipPrompts: true });
|
|
571
621
|
break;
|
|
@@ -607,6 +657,12 @@ async function main() {
|
|
|
607
657
|
case 'explain':
|
|
608
658
|
runExplain(projectDir, config, flags);
|
|
609
659
|
break;
|
|
660
|
+
case 'feedback':
|
|
661
|
+
// v0.27 (field report #3 / LLM feedback loop): collect low-confidence
|
|
662
|
+
// findings (likely false positives) → local record + 1-click prefilled,
|
|
663
|
+
// redacted, capped GitHub issue URL. Opt-in; nothing filed automatically.
|
|
664
|
+
runFeedback(projectDir, config, flags);
|
|
665
|
+
break;
|
|
610
666
|
case 'memory':
|
|
611
667
|
runMemory(projectDir, config, flags);
|
|
612
668
|
break;
|
package/cli/findings.mjs
ADDED
|
@@ -0,0 +1,194 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Findings — the structured, LLM-addressable result unit (v0.27).
|
|
3
|
+
*
|
|
4
|
+
* Background (LLM field report #3): DocGuard's whole job is to tell an agent
|
|
5
|
+
* what to do NEXT. A free-text `errors`/`warnings` string can't carry a stable
|
|
6
|
+
* code (for `explain <CODE>` + inline suppression), a confidence (the signal
|
|
7
|
+
* the false-positive feedback loop runs on), or a machine-readable suggested
|
|
8
|
+
* action. A Finding carries all three.
|
|
9
|
+
*
|
|
10
|
+
* The migration is INCREMENTAL and BACKWARD-COMPATIBLE. A validator that opts in
|
|
11
|
+
* builds `Finding[]` and returns `resultFromFindings(...)`, which still emits the
|
|
12
|
+
* exact `{ errors, warnings, passed, total }` shape every existing consumer
|
|
13
|
+
* (guard counts + exit code, diagnose, score, ci, `--format json`) already reads
|
|
14
|
+
* — PLUS a `findings` array that guard renders richly (each issue gets its
|
|
15
|
+
* `→ suggestion`). Validators that haven't migrated keep returning their
|
|
16
|
+
* hand-built results and render exactly as before. Nothing regresses.
|
|
17
|
+
*
|
|
18
|
+
* Zero npm dependencies — pure Node.js built-ins.
|
|
19
|
+
*
|
|
20
|
+
* @typedef {Object} Suggestion
|
|
21
|
+
* @property {'fix'|'suppress'|'review'|'report'} kind
|
|
22
|
+
* @property {string} text One concise line: what to do next.
|
|
23
|
+
* @property {string} [command] Optional CLI/skill command to run.
|
|
24
|
+
* @property {string} [pragma] Optional inline suppression snippet.
|
|
25
|
+
*
|
|
26
|
+
* @typedef {Object} Finding
|
|
27
|
+
* @property {string} code Stable code, e.g. 'SEC001' (see CODES).
|
|
28
|
+
* @property {string} validator Owning validator key.
|
|
29
|
+
* @property {'error'|'warn'} severity
|
|
30
|
+
* @property {'high'|'low'} confidence 'low' = candidate false positive.
|
|
31
|
+
* @property {string} message Concise, NO ansi colour.
|
|
32
|
+
* @property {string|null} location 'path:line' or 'path'.
|
|
33
|
+
* @property {Suggestion|null} suggestion
|
|
34
|
+
* @property {boolean} reportable Surface in `docguard feedback`.
|
|
35
|
+
* @property {string|null} redactedContext Safe-to-share context for a report.
|
|
36
|
+
*/
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Stable finding-code registry. `docguard explain <CODE>` reads this, and
|
|
40
|
+
* inline `// docguard:ignore <CODE>` keys off it. Keep codes append-only — a
|
|
41
|
+
* published code is a public surface we don't renumber.
|
|
42
|
+
*/
|
|
43
|
+
export const CODES = {
|
|
44
|
+
SEC001: {
|
|
45
|
+
validator: 'security',
|
|
46
|
+
title: 'Hardcoded password',
|
|
47
|
+
help: 'A `password`/`passwd`/`pwd` assignment with a quoted literal value (8+ chars). If the value is natural-language UI copy or a validation message — not a credential — this is a false positive: DocGuard now flags those low-confidence, but you can suppress inline.',
|
|
48
|
+
suppress: '// docguard:ignore SEC001 — UI copy, not a credential',
|
|
49
|
+
},
|
|
50
|
+
SEC002: {
|
|
51
|
+
validator: 'security',
|
|
52
|
+
title: 'Hardcoded API key',
|
|
53
|
+
help: 'An `api_key`/`apikey` assignment with a quoted literal value (16+ chars). Move it to an environment variable and read it via `process.env`.',
|
|
54
|
+
suppress: '// docguard:ignore SEC002 — sample value in fixture',
|
|
55
|
+
},
|
|
56
|
+
SEC003: {
|
|
57
|
+
validator: 'security',
|
|
58
|
+
title: 'Hardcoded secret key',
|
|
59
|
+
help: 'A `secret_key`/`secretkey` assignment with a quoted literal value (16+ chars). Move it to an environment variable.',
|
|
60
|
+
suppress: '// docguard:ignore SEC003 — reason',
|
|
61
|
+
},
|
|
62
|
+
SEC004: {
|
|
63
|
+
validator: 'security',
|
|
64
|
+
title: 'Hardcoded access token',
|
|
65
|
+
help: 'An `access_token`/`accesstoken` assignment with a quoted literal value (16+ chars). Move it to an environment variable.',
|
|
66
|
+
suppress: '// docguard:ignore SEC004 — reason',
|
|
67
|
+
},
|
|
68
|
+
SEC005: {
|
|
69
|
+
validator: 'security',
|
|
70
|
+
title: 'AWS Access Key ID',
|
|
71
|
+
help: 'A string matching the AWS Access Key ID format (AKIA…). Rotate it immediately if real, and move credentials to the AWS credential chain / environment.',
|
|
72
|
+
suppress: '// docguard:ignore SEC005 — documented example key',
|
|
73
|
+
},
|
|
74
|
+
SEC006: {
|
|
75
|
+
validator: 'security',
|
|
76
|
+
title: 'API secret key (Stripe/OpenAI pattern)',
|
|
77
|
+
help: 'A string matching a live/test secret-key format (sk-…, sk_live_…). Rotate it if real and move it to an environment variable.',
|
|
78
|
+
suppress: '// docguard:ignore SEC006 — reason',
|
|
79
|
+
},
|
|
80
|
+
SEC010: {
|
|
81
|
+
validator: 'security',
|
|
82
|
+
title: '.env not in .gitignore',
|
|
83
|
+
help: 'No `.env` entry was found in .gitignore, so a local `.env` could be committed. Add `.env` (and `.env.local`) to .gitignore.',
|
|
84
|
+
suppress: null,
|
|
85
|
+
},
|
|
86
|
+
SEC011: {
|
|
87
|
+
validator: 'security',
|
|
88
|
+
title: 'No source files scanned for secrets',
|
|
89
|
+
help: 'The secret scan matched zero source files — usually a too-broad ignore config or a wrong sourceRoot. A scan that checks nothing is a dangerous false ✅.',
|
|
90
|
+
suppress: null,
|
|
91
|
+
},
|
|
92
|
+
};
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* Build a Finding with sane defaults. `reportable` defaults to true for
|
|
96
|
+
* low-confidence findings — low confidence IS the feedback signal.
|
|
97
|
+
*
|
|
98
|
+
* @param {Partial<Finding>} f
|
|
99
|
+
* @returns {Finding}
|
|
100
|
+
*/
|
|
101
|
+
export function mkFinding(f) {
|
|
102
|
+
const severity = f.severity === 'error' ? 'error' : 'warn';
|
|
103
|
+
const confidence = f.confidence === 'low' ? 'low' : 'high';
|
|
104
|
+
return {
|
|
105
|
+
code: f.code || null,
|
|
106
|
+
validator: f.validator || null,
|
|
107
|
+
severity,
|
|
108
|
+
confidence,
|
|
109
|
+
message: f.message || '',
|
|
110
|
+
location: f.location || null,
|
|
111
|
+
suggestion: f.suggestion || null,
|
|
112
|
+
reportable: f.reportable === true || confidence === 'low',
|
|
113
|
+
redactedContext: f.redactedContext || null,
|
|
114
|
+
};
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* Derive the legacy `{ errors, warnings, passed, total }` result from a list of
|
|
119
|
+
* findings, keeping `findings` attached for the rich renderer. ONE source of
|
|
120
|
+
* truth — the strings guard counts and the findings guard renders can never
|
|
121
|
+
* disagree because they're computed from the same array.
|
|
122
|
+
*
|
|
123
|
+
* @param {Finding[]} findings
|
|
124
|
+
* @param {{passed?:number, total?:number, applicable?:boolean}} [opts]
|
|
125
|
+
*/
|
|
126
|
+
export function resultFromFindings(findings, opts = {}) {
|
|
127
|
+
const errors = [];
|
|
128
|
+
const warnings = [];
|
|
129
|
+
for (const f of findings) {
|
|
130
|
+
if (f.severity === 'error') errors.push(f.message);
|
|
131
|
+
else warnings.push(f.message);
|
|
132
|
+
}
|
|
133
|
+
const res = {
|
|
134
|
+
errors,
|
|
135
|
+
warnings,
|
|
136
|
+
passed: opts.passed || 0,
|
|
137
|
+
total: opts.total != null ? opts.total : 0,
|
|
138
|
+
findings,
|
|
139
|
+
};
|
|
140
|
+
if (opts.applicable !== undefined) res.applicable = opts.applicable;
|
|
141
|
+
return res;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/**
|
|
145
|
+
* Does an inline `docguard:ignore` pragma in `text` suppress finding `code`?
|
|
146
|
+
*
|
|
147
|
+
* Accepted forms (mirrors the ergonomics of eslint-disable / ruff `# noqa`):
|
|
148
|
+
* docguard:ignore → suppresses ANY code on the line
|
|
149
|
+
* docguard:ignore SEC001 → suppresses exactly SEC001
|
|
150
|
+
* docguard:ignore SEC001,DQ002 → comma list
|
|
151
|
+
* docguard:ignore SEC* → prefix wildcard
|
|
152
|
+
* docguard:ignore all → suppresses any code
|
|
153
|
+
* docguard:ignore-secret → convenience alias for any SEC* code
|
|
154
|
+
*
|
|
155
|
+
* @param {string} text
|
|
156
|
+
* @param {string} code
|
|
157
|
+
* @returns {boolean}
|
|
158
|
+
*/
|
|
159
|
+
export function suppressesCode(text, code) {
|
|
160
|
+
if (!text || !code) return false;
|
|
161
|
+
const m = text.match(/docguard:ignore(-secret)?\b[ \t]*([A-Za-z0-9_,*-]+)?/i);
|
|
162
|
+
if (!m) return false;
|
|
163
|
+
if (m[1]) return /^SEC/i.test(code); // ignore-secret alias
|
|
164
|
+
const arg = (m[2] || '').trim();
|
|
165
|
+
if (!arg) return true; // bare ignore → any code
|
|
166
|
+
return arg.split(',').map((s) => s.trim()).some((tok) => {
|
|
167
|
+
if (!tok) return false;
|
|
168
|
+
if (tok.toLowerCase() === 'all') return true;
|
|
169
|
+
if (tok.endsWith('*')) return code.toUpperCase().startsWith(tok.slice(0, -1).toUpperCase());
|
|
170
|
+
return tok.toUpperCase() === code.toUpperCase();
|
|
171
|
+
});
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
/**
|
|
175
|
+
* Source-line suppression: an ignore pragma counts if it's on the flagged line
|
|
176
|
+
* OR the line directly above it (so a comment can sit above the offending
|
|
177
|
+
* statement, the common style for non-trailing-comment languages).
|
|
178
|
+
*/
|
|
179
|
+
export function lineSuppresses(code, line, prevLine = '') {
|
|
180
|
+
return suppressesCode(line, code) || suppressesCode(prevLine, code);
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
/**
|
|
184
|
+
* Flatten a one-line, colour-free rendering of a suggestion — used by JSON
|
|
185
|
+
* consumers, diagnose, and the feedback body. Guard does its own coloured
|
|
186
|
+
* rendering and does not use this.
|
|
187
|
+
*/
|
|
188
|
+
export function suggestionLine(s) {
|
|
189
|
+
if (!s) return '';
|
|
190
|
+
let out = s.text || '';
|
|
191
|
+
if (s.command) out += ` → ${s.command}`;
|
|
192
|
+
else if (s.pragma) out += ` → ${s.pragma}`;
|
|
193
|
+
return out;
|
|
194
|
+
}
|
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Inventory scanners — code-truth the generator PRE-FILLS instead of leaving
|
|
3
|
+
* empty placeholders.
|
|
4
|
+
*
|
|
5
|
+
* Field report §2/§5: `generate` markets "reverse-engineer docs from code" but
|
|
6
|
+
* shipped empty templates, so the agent hand-greps the structure that was right
|
|
7
|
+
* there in the code. These two extractors populate the `source:"code"` sections
|
|
8
|
+
* so the agent is left with only the genuine prose (the *why*):
|
|
9
|
+
* - scanComponents: top-level source modules → ARCHITECTURE Component Map
|
|
10
|
+
* - scanTestInventory: test files + per-file case counts → TEST-SPEC inventory
|
|
11
|
+
*
|
|
12
|
+
* Language-agnostic, best-effort, zero NPM deps.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import { existsSync, readdirSync } from 'node:fs';
|
|
16
|
+
import { resolve, join, relative, extname } from 'node:path';
|
|
17
|
+
import { resolveSourceRoots, readScannable } from '../shared-source.mjs';
|
|
18
|
+
import { DEFAULT_IGNORE_DIRS as IGNORE_DIRS, isNonProductDir } from '../shared-ignore.mjs';
|
|
19
|
+
|
|
20
|
+
const CODE_EXT = new Set(['.js', '.mjs', '.cjs', '.ts', '.tsx', '.jsx', '.py', '.go', '.rs', '.rb', '.php', '.java', '.kt']);
|
|
21
|
+
const toPosix = (p) => p.split(/[\\/]/).join('/');
|
|
22
|
+
|
|
23
|
+
// ── Component map ─────────────────────────────────────────────────────────────
|
|
24
|
+
|
|
25
|
+
// Descend through single-package wrappers (src/ → src/<pkg>/) so we list the
|
|
26
|
+
// REAL modules, not just the wrapper folder. A wrapper is a dir whose only
|
|
27
|
+
// non-ignored child is another dir (e.g. a Python `src/<pkg>/` layout).
|
|
28
|
+
function componentRoot(root, config) {
|
|
29
|
+
let cur = root;
|
|
30
|
+
for (let depth = 0; depth < 3; depth++) {
|
|
31
|
+
let entries;
|
|
32
|
+
try { entries = readdirSync(cur, { withFileTypes: true }); } catch { break; }
|
|
33
|
+
const dirs = entries.filter(e => e.isDirectory() && !e.name.startsWith('.')
|
|
34
|
+
&& !IGNORE_DIRS.has(e.name) && !isNonProductDir(e.name, config));
|
|
35
|
+
const codeFiles = entries.filter(e => e.isFile() && CODE_EXT.has(extname(e.name))
|
|
36
|
+
&& !/^(index|main|mod|lib|__init__|__main__)\./.test(e.name));
|
|
37
|
+
if (dirs.length === 1 && codeFiles.length === 0) { cur = join(cur, dirs[0].name); continue; }
|
|
38
|
+
break;
|
|
39
|
+
}
|
|
40
|
+
return cur;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Top-level source modules under the project's (de-wrapped) source root(s).
|
|
45
|
+
* Each is a directory (a module) or a significant source file directly under
|
|
46
|
+
* the root. Non-product dirs (tests/fixtures/examples) and barrel/entry files
|
|
47
|
+
* are excluded. Capped to keep the table readable.
|
|
48
|
+
* @returns {Array<{ name: string, kind: 'module'|'file', path: string }>}
|
|
49
|
+
*/
|
|
50
|
+
export function scanComponents(projectDir, config = {}, limit = 30) {
|
|
51
|
+
const out = [];
|
|
52
|
+
const seen = new Set();
|
|
53
|
+
for (const root of resolveSourceRoots(projectDir, config)) {
|
|
54
|
+
const croot = componentRoot(root, config);
|
|
55
|
+
let entries;
|
|
56
|
+
try { entries = readdirSync(croot, { withFileTypes: true }); } catch { continue; }
|
|
57
|
+
for (const e of entries) {
|
|
58
|
+
if (e.name.startsWith('.')) continue;
|
|
59
|
+
let kind;
|
|
60
|
+
if (e.isDirectory()) {
|
|
61
|
+
if (IGNORE_DIRS.has(e.name) || isNonProductDir(e.name, config)) continue;
|
|
62
|
+
kind = 'module';
|
|
63
|
+
} else if (e.isFile() && CODE_EXT.has(extname(e.name))) {
|
|
64
|
+
if (/^(index|__init__)\./.test(e.name)) continue; // barrels/entry-init aren't components
|
|
65
|
+
kind = 'file';
|
|
66
|
+
} else continue;
|
|
67
|
+
const rel = toPosix(relative(projectDir, join(croot, e.name)));
|
|
68
|
+
if (seen.has(rel)) continue;
|
|
69
|
+
seen.add(rel);
|
|
70
|
+
out.push({ name: e.name, kind, path: rel });
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
out.sort((a, b) => (a.kind === b.kind ? a.path.localeCompare(b.path) : (a.kind === 'module' ? -1 : 1)));
|
|
74
|
+
return out.slice(0, limit);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
// ── Test inventory ────────────────────────────────────────────────────────────
|
|
78
|
+
|
|
79
|
+
// Test FILE conventions across ecosystems: JS *.test/*.spec, Python test_*.py /
|
|
80
|
+
// *_test.py, Go *_test.go, Ruby *_spec.rb / *_test.rb.
|
|
81
|
+
const TEST_FILE_RE = /(?:\.(?:test|spec)\.[cm]?[jt]sx?|(?:^|\/)test_[^/]*\.py|_test\.(?:py|go)|_spec\.rb|(?:^|\/)[^/]*_test\.rb)$/i;
|
|
82
|
+
// Fixture/mock subdirs that sit INSIDE a test dir but aren't themselves tests.
|
|
83
|
+
const FIXTURE_DIRS = new Set(['fixtures', '__fixtures__', 'testdata', 'test-fixtures', 'testfixtures', 'mocks', '__mocks__', 'snapshots', '__snapshots__']);
|
|
84
|
+
const TEST_DIRS = ['tests', 'test', '__tests__', 'spec', 'e2e'];
|
|
85
|
+
|
|
86
|
+
function countCases(content, ext) {
|
|
87
|
+
let re;
|
|
88
|
+
if (['.js', '.mjs', '.cjs', '.ts', '.tsx', '.jsx'].includes(ext)) re = /\b(?:it|test)\s*\(/g;
|
|
89
|
+
else if (ext === '.py') re = /^[ \t]*(?:async[ \t]+)?def[ \t]+test_/gm;
|
|
90
|
+
else if (ext === '.go') re = /^[ \t]*func[ \t]+Test[A-Z]/gm;
|
|
91
|
+
else if (ext === '.rs') re = /#\[(?:test|tokio::test)\]/g;
|
|
92
|
+
else if (ext === '.rb') re = /^[ \t]*(?:it|test|specify)\b/gm;
|
|
93
|
+
else return 0;
|
|
94
|
+
let n = 0;
|
|
95
|
+
while (re.exec(content) !== null) n++;
|
|
96
|
+
return n;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
function walkTestFiles(dir, projectDir, acc) {
|
|
100
|
+
let entries;
|
|
101
|
+
try { entries = readdirSync(dir, { withFileTypes: true }); } catch { return; }
|
|
102
|
+
for (const e of entries) {
|
|
103
|
+
if (e.name.startsWith('.') || IGNORE_DIRS.has(e.name)) continue;
|
|
104
|
+
if (e.isDirectory()) {
|
|
105
|
+
if (FIXTURE_DIRS.has(e.name)) continue; // fixtures/mocks inside a test dir aren't tests
|
|
106
|
+
walkTestFiles(join(dir, e.name), projectDir, acc);
|
|
107
|
+
} else if (e.isFile() && CODE_EXT.has(extname(e.name))) {
|
|
108
|
+
const rel = toPosix(relative(projectDir, join(dir, e.name)));
|
|
109
|
+
if (TEST_FILE_RE.test(rel)) acc.add(rel);
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* Test files + per-file case counts. Walks the conventional test dirs (tests/,
|
|
116
|
+
* test/, __tests__, spec/, e2e/) plus each source root (co-located tests),
|
|
117
|
+
* skipping fixture/mock subdirs. Case counts are per-language (it()/test(),
|
|
118
|
+
* `def test_`, `func Test`, `#[test]`, …).
|
|
119
|
+
* @returns {{ files: Array<{file:string,cases:number}>, totalCases:number, totalFiles:number }}
|
|
120
|
+
*/
|
|
121
|
+
export function scanTestInventory(projectDir, config = {}) {
|
|
122
|
+
const root = resolve(projectDir);
|
|
123
|
+
const acc = new Set();
|
|
124
|
+
for (const td of TEST_DIRS) {
|
|
125
|
+
const d = join(root, td);
|
|
126
|
+
if (existsSync(d)) walkTestFiles(d, root, acc);
|
|
127
|
+
}
|
|
128
|
+
for (const sr of resolveSourceRoots(projectDir, config)) walkTestFiles(sr, root, acc);
|
|
129
|
+
|
|
130
|
+
const files = [];
|
|
131
|
+
let totalCases = 0;
|
|
132
|
+
for (const rel of acc) {
|
|
133
|
+
const content = readScannable(join(root, rel));
|
|
134
|
+
const cases = content === null ? 0 : countCases(content, extname(rel));
|
|
135
|
+
files.push({ file: rel, cases });
|
|
136
|
+
totalCases += cases;
|
|
137
|
+
}
|
|
138
|
+
files.sort((a, b) => b.cases - a.cases || a.file.localeCompare(b.file));
|
|
139
|
+
return { files, totalCases, totalFiles: files.length };
|
|
140
|
+
}
|