docguard-cli 0.27.0 โ 0.29.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.es.md +102 -0
- package/README.md +65 -31
- package/README.pt-BR.md +101 -0
- package/STANDARD.md +20 -10
- package/cli/commands/agents.mjs +149 -0
- package/cli/commands/diff.mjs +6 -15
- package/cli/commands/explain.mjs +8 -6
- package/cli/commands/generate.mjs +14 -1001
- package/cli/commands/guard.mjs +149 -15
- package/cli/commands/init.mjs +23 -1
- package/cli/commands/llms.mjs +67 -5
- package/cli/commands/mcp.mjs +263 -0
- package/cli/commands/memory.mjs +115 -0
- package/cli/commands/score.mjs +76 -12
- 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 +62 -5
- package/cli/findings.mjs +499 -0
- package/cli/scanners/agent-readability.mjs +202 -0
- package/cli/scanners/semantic-claims.mjs +160 -0
- package/cli/scanners/speckit.mjs +98 -28
- package/cli/shared-ignore.mjs +148 -16
- package/cli/shared.mjs +45 -1
- package/cli/validators/api-surface.mjs +182 -29
- package/cli/validators/architecture.mjs +91 -56
- package/cli/validators/canonical-sync.mjs +59 -28
- package/cli/validators/changelog.mjs +41 -17
- package/cli/validators/cross-reference.mjs +28 -11
- package/cli/validators/doc-quality.mjs +78 -44
- package/cli/validators/docs-coverage.mjs +90 -63
- package/cli/validators/docs-diff.mjs +63 -64
- package/cli/validators/docs-sync.mjs +48 -33
- package/cli/validators/drift.mjs +40 -34
- package/cli/validators/environment.mjs +67 -27
- package/cli/validators/freshness.mjs +12 -5
- package/cli/validators/generated-staleness.mjs +26 -10
- package/cli/validators/metadata-sync.mjs +28 -25
- package/cli/validators/metrics-consistency.mjs +89 -47
- package/cli/validators/schema-sync.mjs +37 -32
- package/cli/validators/security.mjs +7 -20
- package/cli/validators/spec-kit.mjs +3 -0
- package/cli/validators/structure.mjs +58 -23
- package/cli/validators/surface-sync.mjs +34 -15
- package/cli/validators/test-spec.mjs +87 -29
- package/cli/validators/todo-tracking.mjs +83 -74
- package/cli/validators/traceability.mjs +67 -39
- package/cli/writers/doc-generators.mjs +853 -0
- package/cli/writers/generate-io.mjs +142 -0
- package/cli/writers/sarif.mjs +129 -0
- package/commands/docguard.fix.md +56 -53
- package/commands/docguard.guard.md +53 -47
- package/commands/docguard.review.md +49 -31
- package/docs/ai-integration.md +133 -134
- package/docs/commands.md +49 -3
- package/docs/configuration.md +38 -0
- package/docs/faq.md +15 -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/schemas/docguard-config.schema.json +17 -0
- 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
- package/templates/commands/docguard.fix.md +33 -10
- package/templates/commands/docguard.guard.md +40 -26
- package/templates/commands/docguard.init.md +23 -11
- package/templates/commands/docguard.review.md +25 -8
- package/templates/commands/docguard.update.md +14 -4
package/cli/commands/memory.mjs
CHANGED
|
@@ -21,8 +21,12 @@
|
|
|
21
21
|
* Zero NPM dependencies. Pure orchestration of existing diff helpers.
|
|
22
22
|
*/
|
|
23
23
|
|
|
24
|
+
import { existsSync, readFileSync, readdirSync, mkdirSync, writeFileSync } from 'node:fs';
|
|
25
|
+
import { resolve } from 'node:path';
|
|
24
26
|
import { c } from '../shared.mjs';
|
|
25
27
|
import { diffRoutes, diffEntities, diffEnvVars, diffTechStack } from './diff.mjs';
|
|
28
|
+
import { buildMemoryPlan } from '../scanners/memory-plan.mjs';
|
|
29
|
+
import { runGuardInternal } from './guard.mjs';
|
|
26
30
|
|
|
27
31
|
/**
|
|
28
32
|
* Compute an accuracy score for a single domain. Returns:
|
|
@@ -47,7 +51,118 @@ function _domainAccuracy(d) {
|
|
|
47
51
|
};
|
|
48
52
|
}
|
|
49
53
|
|
|
54
|
+
// โโ Context pack (v0.29) โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
|
|
55
|
+
|
|
56
|
+
/** H2 sections of AGENTS.md whose heading reads like rules/conventions/workflow. */
|
|
57
|
+
function extractConventions(agentsMd, capLines = 60) {
|
|
58
|
+
const out = [];
|
|
59
|
+
const lines = agentsMd.split('\n');
|
|
60
|
+
let taking = false;
|
|
61
|
+
for (const line of lines) {
|
|
62
|
+
const h2 = line.match(/^##\s+(.+)$/);
|
|
63
|
+
if (h2) taking = /rules|conventions|workflow/i.test(h2[1]);
|
|
64
|
+
if (taking) {
|
|
65
|
+
out.push(line);
|
|
66
|
+
if (out.length >= capLines) {
|
|
67
|
+
out.push('<!-- truncated โ read AGENTS.md for the full rules -->');
|
|
68
|
+
break;
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
return out;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* `docguard memory --pack` โ write .docguard/context-pack.md: a compact,
|
|
77
|
+
* code-truth-stamped session-start context for an AI agent. Everything in it
|
|
78
|
+
* is derived from scanners (buildMemoryPlan) and guard โ numbers, not prose โ
|
|
79
|
+
* so it can't hallucinate and is always regenerable.
|
|
80
|
+
*/
|
|
81
|
+
function runMemoryPack(projectDir, config, flags) {
|
|
82
|
+
const plan = buildMemoryPlan(projectDir, config);
|
|
83
|
+
const guard = runGuardInternal(projectDir, config);
|
|
84
|
+
const lines = [];
|
|
85
|
+
|
|
86
|
+
lines.push(`# Context Pack โ ${config.projectName}`);
|
|
87
|
+
lines.push('');
|
|
88
|
+
lines.push(`<!-- Generated by \`docguard memory --pack\` ${new Date().toISOString()} โ regenerate, don't edit -->`);
|
|
89
|
+
lines.push('');
|
|
90
|
+
lines.push(`**Guard:** ${guard.status} โ ${guard.passed}/${guard.total} checks (${guard.errors} error(s), ${guard.warnings} warning(s))`);
|
|
91
|
+
lines.push('');
|
|
92
|
+
|
|
93
|
+
lines.push('## Code-truth surface');
|
|
94
|
+
lines.push('');
|
|
95
|
+
lines.push(`- Stack: ${plan.profile.languages.join(', ') || 'unknown'}${plan.profile.frameworks.length ? ` ยท ${plan.profile.frameworks.join(', ')}` : ''} ยท kind: ${plan.profile.kind}`);
|
|
96
|
+
lines.push(`- Modules: ${plan.surface.modules.length} ยท Endpoints: ${plan.surface.endpoints.length} ยท Entities: ${plan.surface.entities.length} ยท Env vars: ${plan.surface.envVars.length}`);
|
|
97
|
+
lines.push(`- Tests: ${plan.surface.tests.totalFiles} files, ${plan.surface.tests.totalCases} cases`);
|
|
98
|
+
lines.push('');
|
|
99
|
+
|
|
100
|
+
const docsDir = resolve(projectDir, 'docs-canonical');
|
|
101
|
+
if (existsSync(docsDir)) {
|
|
102
|
+
lines.push('## Canonical docs');
|
|
103
|
+
lines.push('');
|
|
104
|
+
let entries = [];
|
|
105
|
+
try { entries = readdirSync(docsDir).filter(f => f.endsWith('.md')).sort(); } catch { /* ignore */ }
|
|
106
|
+
for (const doc of entries) {
|
|
107
|
+
let reviewed = '';
|
|
108
|
+
try {
|
|
109
|
+
const m = readFileSync(resolve(docsDir, doc), 'utf-8').match(/docguard:last-reviewed\s+(\d{4}-\d{2}-\d{2})/);
|
|
110
|
+
if (m) reviewed = ` (last-reviewed ${m[1]})`;
|
|
111
|
+
} catch { /* ignore */ }
|
|
112
|
+
lines.push(`- docs-canonical/${doc}${reviewed}`);
|
|
113
|
+
}
|
|
114
|
+
lines.push('');
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
const agentsPath = resolve(projectDir, 'AGENTS.md');
|
|
118
|
+
if (existsSync(agentsPath)) {
|
|
119
|
+
let conventions = [];
|
|
120
|
+
try { conventions = extractConventions(readFileSync(agentsPath, 'utf-8')); } catch { /* ignore */ }
|
|
121
|
+
if (conventions.length > 0) {
|
|
122
|
+
lines.push('## Project rules (from AGENTS.md)');
|
|
123
|
+
lines.push('');
|
|
124
|
+
lines.push(...conventions);
|
|
125
|
+
lines.push('');
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
const driftPath = resolve(projectDir, 'DRIFT-LOG.md');
|
|
130
|
+
if (existsSync(driftPath)) {
|
|
131
|
+
try {
|
|
132
|
+
const drift = readFileSync(driftPath, 'utf-8');
|
|
133
|
+
const entries = drift.match(/^##\s+.+$/gm) || [];
|
|
134
|
+
if (entries.length > 0) {
|
|
135
|
+
lines.push('## Known drift');
|
|
136
|
+
lines.push('');
|
|
137
|
+
lines.push(`- ${entries.length} logged deviation(s); latest: ${entries[entries.length - 1].replace(/^##\s+/, '')}`);
|
|
138
|
+
lines.push('');
|
|
139
|
+
}
|
|
140
|
+
} catch { /* ignore */ }
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
lines.push('---');
|
|
144
|
+
lines.push('Verify claims: `docguard verify --semantic` ยท Full docs: `llms-full.txt`');
|
|
145
|
+
lines.push('');
|
|
146
|
+
const content = lines.join('\n');
|
|
147
|
+
|
|
148
|
+
if (flags.stdout) {
|
|
149
|
+
console.log(content);
|
|
150
|
+
return;
|
|
151
|
+
}
|
|
152
|
+
const outDir = resolve(projectDir, '.docguard');
|
|
153
|
+
if (!existsSync(outDir)) mkdirSync(outDir, { recursive: true });
|
|
154
|
+
const outPath = resolve(outDir, 'context-pack.md');
|
|
155
|
+
writeFileSync(outPath, content, 'utf-8');
|
|
156
|
+
console.log(`${c.bold}๐ง DocGuard Context Pack${c.reset}`);
|
|
157
|
+
console.log(`${c.green}โ
Wrote ${outPath}${c.reset} ${c.dim}(${lines.length} lines โ load at agent session start)${c.reset}`);
|
|
158
|
+
console.log('');
|
|
159
|
+
}
|
|
160
|
+
|
|
50
161
|
export function runMemory(projectDir, config, flags) {
|
|
162
|
+
// v0.29: --pack writes the agent context pack and exits โ a separate output
|
|
163
|
+
// artifact, not a display mode of the accuracy drill-down below.
|
|
164
|
+
if (flags.pack) return runMemoryPack(projectDir, config, flags);
|
|
165
|
+
|
|
51
166
|
const isJson = flags.format === 'json';
|
|
52
167
|
const wantsDiff = flags.diff || (flags.args || []).includes('--diff');
|
|
53
168
|
|
package/cli/commands/score.mjs
CHANGED
|
@@ -9,6 +9,8 @@ import { execSync } from 'node:child_process';
|
|
|
9
9
|
import { c, docHasSection } from '../shared.mjs';
|
|
10
10
|
import { validateSecurity } from '../validators/security.mjs';
|
|
11
11
|
import { runGuardInternal } from './guard.mjs';
|
|
12
|
+
import { extractSemanticClaims } from '../scanners/semantic-claims.mjs';
|
|
13
|
+
import { assessAgentReadability } from '../scanners/agent-readability.mjs';
|
|
12
14
|
|
|
13
15
|
/**
|
|
14
16
|
* Detect whether the project configures a test runner (the "Check 3" of the
|
|
@@ -299,11 +301,17 @@ export function runScore(projectDir, config, flags) {
|
|
|
299
301
|
console.log(` ${c.dim}โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ${c.reset}`);
|
|
300
302
|
|
|
301
303
|
for (const attr of alcoa.attributes) {
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
304
|
+
// `unverified` is a third state: not a green pass, but not a red gap either โ
|
|
305
|
+
// "checked the structure, can't confirm the facts." Render it neutrally (๐,
|
|
306
|
+
// cyan) so it reads as a to-do, not a failure.
|
|
307
|
+
const unverified = attr.status === 'unverified';
|
|
308
|
+
const icon = attr.met ? `${c.green}โ
` : unverified ? `${c.cyan}๐` : `${c.yellow}โ ๏ธ`;
|
|
309
|
+
const tone = attr.met ? c.green : unverified ? c.cyan : c.yellow;
|
|
310
|
+
const body = attr.met ? attr.evidence : attr.gap;
|
|
311
|
+
console.log(` ${icon} ${attr.name.padEnd(16)}${c.reset} โ ${tone}${body}${c.reset}`);
|
|
305
312
|
if (!attr.met && attr.fix) {
|
|
306
|
-
|
|
313
|
+
const verb = unverified ? 'Verify' : 'Fix';
|
|
314
|
+
console.log(` ${c.dim} ${verb}: ${attr.fix}${c.reset}`);
|
|
307
315
|
}
|
|
308
316
|
}
|
|
309
317
|
|
|
@@ -314,6 +322,22 @@ export function runScore(projectDir, config, flags) {
|
|
|
314
322
|
}
|
|
315
323
|
console.log('');
|
|
316
324
|
|
|
325
|
+
// โโ Agent Readability (v0.29) โโ
|
|
326
|
+
// Display-only, like ALCOA+ โ never feeds the gating CDD grade. Answers the
|
|
327
|
+
// 2026 question: can an AI consumer FIND, QUOTE, and TRUST these docs?
|
|
328
|
+
const agentRead = assessAgentReadability(projectDir, config);
|
|
329
|
+
console.log(` ${c.bold}๐ค Agent Readability${c.reset} ${c.dim}(how well AI consumers can read this repo)${c.reset}`);
|
|
330
|
+
console.log(` ${c.dim}โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ${c.reset}`);
|
|
331
|
+
for (const m of agentRead.metrics) {
|
|
332
|
+
const icon = m.score >= 60 ? `${c.green}โ
` : `${c.yellow}โ ๏ธ`;
|
|
333
|
+
const tone = m.score >= 60 ? c.green : c.yellow;
|
|
334
|
+
console.log(` ${icon} ${m.label.padEnd(28)}${c.reset} โ ${tone}${m.detail}${c.reset}`);
|
|
335
|
+
if (m.fix) console.log(` ${c.dim} Fix: ${m.fix}${c.reset}`);
|
|
336
|
+
}
|
|
337
|
+
const arColor = agentRead.score >= 75 ? c.green : agentRead.score >= 40 ? c.yellow : c.red;
|
|
338
|
+
console.log(`\n ${arColor}${c.bold}Agent Readability: ${agentRead.score}% (${agentRead.grade})${c.reset}`);
|
|
339
|
+
console.log('');
|
|
340
|
+
|
|
317
341
|
// Badge snippet
|
|
318
342
|
const bColor = totalScore >= 90 ? 'brightgreen' : totalScore >= 80 ? 'green' : totalScore >= 70 ? 'yellowgreen' : totalScore >= 60 ? 'yellow' : totalScore >= 50 ? 'orange' : 'red';
|
|
319
343
|
const badgeUrl = `https://img.shields.io/badge/CDD_Score-${totalScore}%2F100_(${grade})-${bColor}`;
|
|
@@ -411,14 +435,54 @@ function computeAlcoaCompliance(projectDir, config, scores) {
|
|
|
411
435
|
});
|
|
412
436
|
|
|
413
437
|
// 5. Accurate โ Do docs match the code?
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
438
|
+
//
|
|
439
|
+
// Field report #6: this attribute used to read `met` purely from structural
|
|
440
|
+
// signals (drift markers + prose quality). That let it show โ
"100%" while a
|
|
441
|
+
// watched doc stated a factually wrong number โ the confidence-inverting false
|
|
442
|
+
// negative the field report is about. Structure passing is necessary but NOT
|
|
443
|
+
// sufficient for "accurate"; the factual claims (counts/limits/enums) have to be
|
|
444
|
+
// verified against code, and DocGuard's deterministic core can't do that โ only
|
|
445
|
+
// an agent via `verify --semantic` can. So we add a third, honest state:
|
|
446
|
+
// met โ structure sound AND no unverified factual claims exist
|
|
447
|
+
// unverified โ structure sound BUT documented claims remain unchecked vs code
|
|
448
|
+
// unmet โ structural drift/quality below bar
|
|
449
|
+
// `unverified` counts as not-met for the ALCOA percentage (so the score stops
|
|
450
|
+
// overclaiming), but renders as a neutral ๐ (not a โ ๏ธ failure) โ "I haven't
|
|
451
|
+
// confirmed this," not "this is wrong." This is display-only: it never touches
|
|
452
|
+
// the gating CDD grade (totalScore), which CI thresholds read.
|
|
453
|
+
const structurallyAccurate = scores.drift >= 80 && scores.docQuality >= 50;
|
|
454
|
+
let unverifiedClaims = 0;
|
|
455
|
+
if (structurallyAccurate) {
|
|
456
|
+
try { unverifiedClaims = extractSemanticClaims(projectDir, config).length; } catch { /* extractor best-effort */ }
|
|
457
|
+
}
|
|
458
|
+
if (!structurallyAccurate) {
|
|
459
|
+
attributes.push({
|
|
460
|
+
name: 'Accurate',
|
|
461
|
+
met: false,
|
|
462
|
+
status: 'unmet',
|
|
463
|
+
evidence: null,
|
|
464
|
+
gap: `Drift: ${scores.drift}%, doc quality: ${scores.docQuality}% โ docs may be inaccurate`,
|
|
465
|
+
fix: 'Run docguard diagnose to find doc/code mismatches',
|
|
466
|
+
});
|
|
467
|
+
} else if (unverifiedClaims > 0) {
|
|
468
|
+
attributes.push({
|
|
469
|
+
name: 'Accurate',
|
|
470
|
+
met: false,
|
|
471
|
+
status: 'unverified',
|
|
472
|
+
evidence: null,
|
|
473
|
+
gap: `Structure sound (drift ${scores.drift}%, quality ${scores.docQuality}%), but ${unverifiedClaims} documented claim(s) (counts/limits/enums) are unverified against code`,
|
|
474
|
+
fix: 'Run docguard verify --semantic to check the documented values against the code',
|
|
475
|
+
});
|
|
476
|
+
} else {
|
|
477
|
+
attributes.push({
|
|
478
|
+
name: 'Accurate',
|
|
479
|
+
met: true,
|
|
480
|
+
status: 'met',
|
|
481
|
+
evidence: `Drift: ${scores.drift}%, doc quality: ${scores.docQuality}%, no unverified factual claims`,
|
|
482
|
+
gap: null,
|
|
483
|
+
fix: null,
|
|
484
|
+
});
|
|
485
|
+
}
|
|
422
486
|
|
|
423
487
|
// 6. Complete โ Are all required docs present?
|
|
424
488
|
const complete = scores.structure >= 80;
|
|
@@ -0,0 +1,272 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `docguard sync --tests` โ reconcile the TEST-SPEC Source-to-Test Map from disk.
|
|
3
|
+
*
|
|
4
|
+
* Background (LLM field report #10): the sourceโtest table in TEST-SPEC.md is
|
|
5
|
+
* hand-maintained, so plain `docguard sync` (which only refreshes
|
|
6
|
+
* docguard:generated code-truth SECTIONS) reports "nothing drifted" even when the
|
|
7
|
+
* table has a ghost service (source deleted), a ghost test (test file deleted),
|
|
8
|
+
* and N services that gained tests. The Test-Spec validator already detects the
|
|
9
|
+
* ghosts; this writes the reconciliation back.
|
|
10
|
+
*
|
|
11
|
+
* SAFETY โ this edits a human-curated table, so it does ONLY the two unambiguous
|
|
12
|
+
* operations and previews by default (`--write` applies):
|
|
13
|
+
* - REMOVE a row whose SOURCE file no longer exists on disk (ghost service).
|
|
14
|
+
* - APPEND a row for a co-located sourceโtest pair found on disk but absent
|
|
15
|
+
* from the table (newly-covered service).
|
|
16
|
+
* Ghost TEST references (source still exists, test file gone) are REPORTED but
|
|
17
|
+
* never auto-edited โ blanking a hand-maintained status/notes cell is too
|
|
18
|
+
* destructive, and the Test-Spec validator already warns on them.
|
|
19
|
+
*
|
|
20
|
+
* Zero npm dependencies โ pure Node.js built-ins.
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
import { existsSync, readFileSync, writeFileSync, readdirSync } from 'node:fs';
|
|
24
|
+
import { resolve } from 'node:path';
|
|
25
|
+
import { c } from '../shared.mjs';
|
|
26
|
+
import { shouldIgnore } from '../shared-ignore.mjs';
|
|
27
|
+
|
|
28
|
+
const TEST_SPEC_DOC = 'docs-canonical/TEST-SPEC.md';
|
|
29
|
+
const CODE_EXT = /\.[cm]?[jt]sx?$/;
|
|
30
|
+
const TEST_RE = /\.(test|spec)\.[cm]?[jt]sx?$/;
|
|
31
|
+
const WALK_SKIP = new Set(['node_modules', 'dist', 'build', 'coverage', '.git', '.next', '__pycache__', '.venv', 'vendor']);
|
|
32
|
+
|
|
33
|
+
// โโ On-disk discovery โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
|
|
34
|
+
|
|
35
|
+
function walkCodeFiles(projectDir, config) {
|
|
36
|
+
const out = [];
|
|
37
|
+
const visit = (absDir, relDir) => {
|
|
38
|
+
let entries;
|
|
39
|
+
try { entries = readdirSync(absDir, { withFileTypes: true }); } catch { return; }
|
|
40
|
+
for (const e of entries) {
|
|
41
|
+
if (e.name.startsWith('.')) continue;
|
|
42
|
+
if (WALK_SKIP.has(e.name)) continue;
|
|
43
|
+
const relPath = relDir ? `${relDir}/${e.name}` : e.name;
|
|
44
|
+
if (e.isDirectory()) { visit(resolve(absDir, e.name), relPath); continue; }
|
|
45
|
+
if (!CODE_EXT.test(e.name)) continue;
|
|
46
|
+
if (shouldIgnore(relPath, config)) continue;
|
|
47
|
+
out.push(relPath);
|
|
48
|
+
}
|
|
49
|
+
};
|
|
50
|
+
visit(resolve(projectDir), '');
|
|
51
|
+
return out;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
const dirOf = (p) => (p.includes('/') ? p.slice(0, p.lastIndexOf('/')) : '');
|
|
55
|
+
const baseOf = (p) => (p.includes('/') ? p.slice(p.lastIndexOf('/') + 1) : p);
|
|
56
|
+
const stemOf = (b) => b.replace(CODE_EXT, '').replace(/\.(test|spec)$/, '');
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Discover co-located sourceโtest pairs on disk. A test counts as covering a
|
|
60
|
+
* source when their stems match AND the test sits in the same directory or a
|
|
61
|
+
* sibling `__tests__/`. Conservative by design โ cross-directory basename
|
|
62
|
+
* collisions (two `index.ts`) would pollute the table, so they're excluded.
|
|
63
|
+
*
|
|
64
|
+
* @returns {Array<{ source: string, test: string }>}
|
|
65
|
+
*/
|
|
66
|
+
export function discoverTestPairs(projectDir, config = {}) {
|
|
67
|
+
const files = walkCodeFiles(projectDir, config);
|
|
68
|
+
const tests = files.filter((f) => TEST_RE.test(f));
|
|
69
|
+
const sources = files.filter((f) => !TEST_RE.test(f));
|
|
70
|
+
const pairs = [];
|
|
71
|
+
for (const src of sources) {
|
|
72
|
+
const sDir = dirOf(src);
|
|
73
|
+
const sStem = stemOf(baseOf(src));
|
|
74
|
+
const match = tests.find((t) => {
|
|
75
|
+
if (stemOf(baseOf(t)) !== sStem) return false;
|
|
76
|
+
const tDir = dirOf(t);
|
|
77
|
+
return tDir === sDir || tDir === `${sDir}/__tests__` || (sDir === '' && tDir === '__tests__');
|
|
78
|
+
});
|
|
79
|
+
if (match) pairs.push({ source: src, test: match });
|
|
80
|
+
}
|
|
81
|
+
return pairs;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
// โโ Table parsing (mirrors test-spec.mjs column detection) โโโโโโโโโโโโโโโโโ
|
|
85
|
+
|
|
86
|
+
const isPathLike = (v) => !!v && !/\s/.test(v) && (/[\\/]/.test(v) || /\.[A-Za-z0-9]{1,6}$/.test(v));
|
|
87
|
+
const splitRow = (line) => {
|
|
88
|
+
const parts = line.split('|');
|
|
89
|
+
parts.shift();
|
|
90
|
+
parts.pop();
|
|
91
|
+
return parts.map((s) => s.trim());
|
|
92
|
+
};
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* Locate the Source-to-Test Map table and classify its rows against disk.
|
|
96
|
+
* @returns {null | { headerLine, sepLine, sourceIdx, testIdxs, ncols, keep:string[],
|
|
97
|
+
* removed:object[], ghostTests:object[], blockStart, blockEnd }}
|
|
98
|
+
*/
|
|
99
|
+
function parseMapTable(content, projectDir) {
|
|
100
|
+
const sectionRe = /## (?:Service-to-Test Map|Source-to-Test Map)[\s\S]*?(?=\n## |$)/;
|
|
101
|
+
const m = sectionRe.exec(content);
|
|
102
|
+
if (!m) return null;
|
|
103
|
+
const sectionStart = m.index;
|
|
104
|
+
const sectionText = m[0];
|
|
105
|
+
const sectionLines = sectionText.split('\n');
|
|
106
|
+
|
|
107
|
+
// Find the FIRST pipe table inside the section (header + separator + rows).
|
|
108
|
+
let headerLineIdx = -1;
|
|
109
|
+
for (let i = 0; i < sectionLines.length - 1; i++) {
|
|
110
|
+
if (sectionLines[i].trim().startsWith('|') && /^\s*\|[\s|:-]+\|\s*$/.test(sectionLines[i + 1])) {
|
|
111
|
+
headerLineIdx = i;
|
|
112
|
+
break;
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
if (headerLineIdx === -1) return null;
|
|
116
|
+
|
|
117
|
+
const header = splitRow(sectionLines[headerLineIdx]).map((h) => h.toLowerCase());
|
|
118
|
+
const ncols = header.length;
|
|
119
|
+
let sourceIdx = header.findIndex((h) => /\bsource\b/.test(h));
|
|
120
|
+
if (sourceIdx < 0) sourceIdx = 0;
|
|
121
|
+
let statusIdx = header.findIndex((h) => /\bstatus\b/.test(h));
|
|
122
|
+
if (statusIdx < 0) statusIdx = ncols - 1;
|
|
123
|
+
let testIdxs = header.map((h, i) => (/\btest\b|\be2e\b/.test(h) ? i : -1)).filter((i) => i >= 0 && i !== sourceIdx && i !== statusIdx);
|
|
124
|
+
if (testIdxs.length === 0) { const fb = sourceIdx === 1 ? 0 : 1; if (fb !== statusIdx && fb < ncols) testIdxs = [fb]; }
|
|
125
|
+
|
|
126
|
+
// Walk data rows after the separator until the table ends (a non-pipe line).
|
|
127
|
+
const keep = []; // raw row lines to retain
|
|
128
|
+
const removed = []; // { source } ghost-source rows dropped
|
|
129
|
+
const ghostTests = []; // { source, test } source exists but a test ref is gone
|
|
130
|
+
const documentedSources = new Set();
|
|
131
|
+
let dataEndIdx = headerLineIdx + 2;
|
|
132
|
+
for (let i = headerLineIdx + 2; i < sectionLines.length; i++) {
|
|
133
|
+
const line = sectionLines[i];
|
|
134
|
+
if (!line.trim().startsWith('|')) break;
|
|
135
|
+
dataEndIdx = i + 1;
|
|
136
|
+
const cells = splitRow(line);
|
|
137
|
+
const rawSource = (cells[sourceIdx] || '').replace(/`/g, '').trim();
|
|
138
|
+
// Template/example/placeholder rows are left untouched.
|
|
139
|
+
if (!rawSource || rawSource.startsWith('<!--') || rawSource.startsWith('*') || !isPathLike(rawSource)) {
|
|
140
|
+
keep.push(line);
|
|
141
|
+
continue;
|
|
142
|
+
}
|
|
143
|
+
if (!existsSync(resolve(projectDir, rawSource))) {
|
|
144
|
+
removed.push({ source: rawSource });
|
|
145
|
+
continue; // drop ghost-source row
|
|
146
|
+
}
|
|
147
|
+
documentedSources.add(rawSource);
|
|
148
|
+
// Source exists โ report (don't edit) any dead test reference.
|
|
149
|
+
for (const ti of testIdxs) {
|
|
150
|
+
const t = (cells[ti] || '').replace(/`/g, '').trim();
|
|
151
|
+
if (isPathLike(t) && !existsSync(resolve(projectDir, t))) ghostTests.push({ source: rawSource, test: t });
|
|
152
|
+
}
|
|
153
|
+
keep.push(line);
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
return {
|
|
157
|
+
sectionStart,
|
|
158
|
+
headerAbsLine: headerLineIdx,
|
|
159
|
+
sepLine: sectionLines[headerLineIdx + 1],
|
|
160
|
+
headerLine: sectionLines[headerLineIdx],
|
|
161
|
+
sourceIdx, testIdxs, statusIdx, ncols,
|
|
162
|
+
keep, removed, ghostTests, documentedSources,
|
|
163
|
+
// absolute char offsets of the table block within `content`
|
|
164
|
+
blockStartLine: headerLineIdx,
|
|
165
|
+
blockEndLine: dataEndIdx,
|
|
166
|
+
sectionLines,
|
|
167
|
+
sectionTextStart: sectionStart,
|
|
168
|
+
};
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/**
|
|
172
|
+
* Compute the reconciliation. Pure: returns the diff + the rewritten content.
|
|
173
|
+
* @returns {{ applicable:boolean, removed:object[], added:object[], ghostTests:object[], newContent:string|null, reason?:string }}
|
|
174
|
+
*/
|
|
175
|
+
export function reconcileTestMap(content, projectDir, config) {
|
|
176
|
+
const parsed = parseMapTable(content, projectDir);
|
|
177
|
+
if (!parsed) {
|
|
178
|
+
return { applicable: false, removed: [], added: [], ghostTests: [], newContent: null, reason: 'no Source-to-Test Map table found' };
|
|
179
|
+
}
|
|
180
|
+
const pairs = discoverTestPairs(projectDir, config);
|
|
181
|
+
const added = pairs.filter((p) => !parsed.documentedSources.has(p.source));
|
|
182
|
+
|
|
183
|
+
// Build the new table block: header, separator, kept rows, appended rows.
|
|
184
|
+
const newRowFor = ({ source, test }) => {
|
|
185
|
+
const cells = new Array(parsed.ncols).fill('โ');
|
|
186
|
+
cells[parsed.sourceIdx] = `\`${source}\``;
|
|
187
|
+
if (parsed.testIdxs.length) cells[parsed.testIdxs[0]] = `\`${test}\``;
|
|
188
|
+
cells[parsed.statusIdx] = 'โ ๏ธ auto-added โ verify';
|
|
189
|
+
return `| ${cells.join(' | ')} |`;
|
|
190
|
+
};
|
|
191
|
+
const addedRows = added.map(newRowFor);
|
|
192
|
+
const newBlock = [parsed.headerLine, parsed.sepLine, ...parsed.keep, ...addedRows].join('\n');
|
|
193
|
+
|
|
194
|
+
// Splice the new block back into the original section text, then back into content.
|
|
195
|
+
const sectionLines = parsed.sectionLines.slice();
|
|
196
|
+
const before = sectionLines.slice(0, parsed.blockStartLine);
|
|
197
|
+
const after = sectionLines.slice(parsed.blockEndLine);
|
|
198
|
+
const newSection = [...before, newBlock, ...after].join('\n');
|
|
199
|
+
const oldSection = parsed.sectionLines.join('\n');
|
|
200
|
+
const newContent = content.slice(0, parsed.sectionTextStart) + newSection + content.slice(parsed.sectionTextStart + oldSection.length);
|
|
201
|
+
|
|
202
|
+
const changed = parsed.removed.length > 0 || added.length > 0;
|
|
203
|
+
return {
|
|
204
|
+
applicable: true,
|
|
205
|
+
removed: parsed.removed,
|
|
206
|
+
added,
|
|
207
|
+
ghostTests: parsed.ghostTests,
|
|
208
|
+
newContent: changed ? newContent : null,
|
|
209
|
+
};
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
// โโ CLI โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
|
|
213
|
+
|
|
214
|
+
export function runSyncTests(projectDir, config, flags) {
|
|
215
|
+
const apply = !!flags.write;
|
|
216
|
+
const isJson = flags.format === 'json';
|
|
217
|
+
const docPath = resolve(projectDir, TEST_SPEC_DOC);
|
|
218
|
+
|
|
219
|
+
if (!existsSync(docPath)) {
|
|
220
|
+
if (isJson) { console.log(JSON.stringify({ applicable: false, reason: 'TEST-SPEC.md not present' }, null, 2)); return; }
|
|
221
|
+
console.log(`${c.yellow}TEST-SPEC.md not found โ run ${c.cyan}docguard init${c.yellow} first.${c.reset}\n`);
|
|
222
|
+
return;
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
const content = readFileSync(docPath, 'utf-8');
|
|
226
|
+
const r = reconcileTestMap(content, projectDir, config);
|
|
227
|
+
|
|
228
|
+
if (isJson) {
|
|
229
|
+
console.log(JSON.stringify({
|
|
230
|
+
applicable: r.applicable, applied: apply && !!r.newContent,
|
|
231
|
+
removed: r.removed, added: r.added, ghostTests: r.ghostTests, reason: r.reason || null,
|
|
232
|
+
}, null, 2));
|
|
233
|
+
if (apply && r.newContent) writeFileSync(docPath, r.newContent, 'utf-8');
|
|
234
|
+
return;
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
console.log(`${c.bold}๐ DocGuard Sync --tests โ ${config.projectName}${c.reset}`);
|
|
238
|
+
console.log(`${c.dim} ${TEST_SPEC_DOC} ยท ${apply ? 'Applying' : 'Dry run (use --write to apply)'}${c.reset}\n`);
|
|
239
|
+
|
|
240
|
+
if (!r.applicable) {
|
|
241
|
+
console.log(` ${c.yellow}No Source-to-Test Map table found in TEST-SPEC.md.${c.reset}`);
|
|
242
|
+
console.log(` ${c.dim}Add a "## Source-to-Test Map" table (col 1 = source, last col = status), then re-run.${c.reset}\n`);
|
|
243
|
+
return;
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
if (r.removed.length === 0 && r.added.length === 0 && r.ghostTests.length === 0) {
|
|
247
|
+
console.log(` ${c.green}โ
Source-to-Test Map matches disk โ nothing to reconcile.${c.reset}\n`);
|
|
248
|
+
return;
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
if (r.removed.length) {
|
|
252
|
+
console.log(` ${apply ? c.green : c.yellow}${apply ? 'โ
Removed' : 'โข Remove'} ${r.removed.length} ghost-source row(s) (source file deleted):${c.reset}`);
|
|
253
|
+
for (const x of r.removed) console.log(` ${c.dim}- ${x.source}${c.reset}`);
|
|
254
|
+
}
|
|
255
|
+
if (r.added.length) {
|
|
256
|
+
console.log(` ${apply ? c.green : c.yellow}${apply ? 'โ
Added' : 'โข Add'} ${r.added.length} newly-covered source(s):${c.reset}`);
|
|
257
|
+
for (const x of r.added) console.log(` ${c.dim}+ ${x.source} โ ${x.test}${c.reset}`);
|
|
258
|
+
}
|
|
259
|
+
if (r.ghostTests.length) {
|
|
260
|
+
console.log(` ${c.yellow}โ ${r.ghostTests.length} ghost test reference(s) (source exists, test file gone) โ fix by hand:${c.reset}`);
|
|
261
|
+
for (const x of r.ghostTests) console.log(` ${c.dim}~ ${x.source} โ ${x.test} (missing)${c.reset}`);
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
if (apply && r.newContent) {
|
|
265
|
+
writeFileSync(docPath, r.newContent, 'utf-8');
|
|
266
|
+
console.log(`\n ${c.green}โป ${TEST_SPEC_DOC} updated. Review the โ ๏ธ auto-added rows, then ${c.cyan}docguard guard${c.green}.${c.reset}\n`);
|
|
267
|
+
} else if (!apply) {
|
|
268
|
+
console.log(`\n ${c.dim}Apply: ${c.cyan}docguard sync --tests --write${c.reset}\n`);
|
|
269
|
+
} else {
|
|
270
|
+
console.log('');
|
|
271
|
+
}
|
|
272
|
+
}
|
package/cli/commands/sync.mjs
CHANGED
|
@@ -20,6 +20,7 @@ import { c } from '../shared.mjs';
|
|
|
20
20
|
import { buildMemoryPlan } from '../scanners/memory-plan.mjs';
|
|
21
21
|
import { getSection, replaceSection } from '../writers/sections.mjs';
|
|
22
22
|
import { hasGeneratedMarker } from '../writers/api-reference.mjs';
|
|
23
|
+
import { runSyncTests } from './sync-tests.mjs';
|
|
23
24
|
|
|
24
25
|
function gitChangedFiles(projectDir, since) {
|
|
25
26
|
const run = (args) => {
|
|
@@ -77,6 +78,11 @@ function sectionTouchedByChanges(sectionId, changedFiles) {
|
|
|
77
78
|
}
|
|
78
79
|
|
|
79
80
|
export function runSync(projectDir, config, flags) {
|
|
81
|
+
// v0.28 (field report #10): `--tests` reconciles the hand-maintained TEST-SPEC
|
|
82
|
+
// Source-to-Test Map from disk (ghost-source removal + new co-located pairs) โ
|
|
83
|
+
// a distinct path from the generated code-truth section refresh below.
|
|
84
|
+
if (flags.tests) return runSyncTests(projectDir, config, flags);
|
|
85
|
+
|
|
80
86
|
const plan = buildMemoryPlan(projectDir, config);
|
|
81
87
|
const apply = !!flags.write;
|
|
82
88
|
const isJson = flags.format === 'json';
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Verify Command โ `docguard verify --semantic` (LLM field report #5).
|
|
3
|
+
*
|
|
4
|
+
* Surfaces the semantic claims in the canonical docs (documented numbers, limits,
|
|
5
|
+
* and enums) as a structured verification task list for the agent to check
|
|
6
|
+
* against the code. DocGuard does the deterministic discovery; the LLM does the
|
|
7
|
+
* judgment โ the same division of labour as `docguard agent`.
|
|
8
|
+
*
|
|
9
|
+
* Read-only. JSON is the machine artifact (the agent-executable task list);
|
|
10
|
+
* text is the human summary.
|
|
11
|
+
*
|
|
12
|
+
* docguard verify [--semantic] [--format json]
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import { c } from '../shared.mjs';
|
|
16
|
+
import { detectAgentMode } from '../ensure-skills.mjs';
|
|
17
|
+
import { extractSemanticClaims, buildSemanticVerifyTasks } from '../scanners/semantic-claims.mjs';
|
|
18
|
+
|
|
19
|
+
export function runVerify(projectDir, config, flags) {
|
|
20
|
+
const isJson = flags.format === 'json';
|
|
21
|
+
const claims = extractSemanticClaims(projectDir, config);
|
|
22
|
+
const tasks = buildSemanticVerifyTasks(claims);
|
|
23
|
+
|
|
24
|
+
if (isJson) {
|
|
25
|
+
console.log(JSON.stringify({
|
|
26
|
+
command: 'verify --semantic',
|
|
27
|
+
project: config.projectName,
|
|
28
|
+
claimCount: tasks.length,
|
|
29
|
+
// How to act on this: each task is a claim to confirm against the code.
|
|
30
|
+
howToVerify: 'For each task, read the cited code (or grep for the constant/config), compare it to the documented value, and report any mismatch with both values. DocGuard cannot judge these โ they require reading the code.',
|
|
31
|
+
tasks,
|
|
32
|
+
}, null, 2));
|
|
33
|
+
return;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
console.log(`${c.bold}๐ฌ DocGuard Verify โ semantic claims${c.reset}`);
|
|
37
|
+
console.log(`${c.dim} ${config.projectName} ยท documented numbers / limits / enums to check against code${c.reset}\n`);
|
|
38
|
+
|
|
39
|
+
if (tasks.length === 0) {
|
|
40
|
+
console.log(` ${c.green}โ
No semantic claims found in the canonical docs.${c.reset}`);
|
|
41
|
+
console.log(` ${c.dim}(Looks for numbers with units โ days/ms/req-s/GSIs/roles/โฆ โ and status/enum lists.)${c.reset}\n`);
|
|
42
|
+
return;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
// Group by doc for a readable summary.
|
|
46
|
+
const byDoc = new Map();
|
|
47
|
+
for (const t of tasks) {
|
|
48
|
+
if (!byDoc.has(t.doc)) byDoc.set(t.doc, []);
|
|
49
|
+
byDoc.get(t.doc).push(t);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
console.log(` ${c.yellow}${tasks.length} claim(s) to verify against the code:${c.reset}\n`);
|
|
53
|
+
for (const [doc, ts] of byDoc) {
|
|
54
|
+
console.log(` ${c.bold}${doc}${c.reset}`);
|
|
55
|
+
for (const t of ts) {
|
|
56
|
+
const val = t.kind === 'enum' ? `enum ${t.value}` : `${t.value}${t.unit ? ` ${t.unit}` : ''}`;
|
|
57
|
+
const cited = t.citedCode ? `${c.cyan}${t.citedCode}${c.reset}` : `${c.dim}(no cited code โ grep for it)${c.reset}`;
|
|
58
|
+
console.log(` ${c.yellow}โข${c.reset} L${t.line} ${c.dim}${t.section ? `[${t.section}] ` : ''}${c.reset}${c.bold}${val}${c.reset} โ check ${cited}`);
|
|
59
|
+
}
|
|
60
|
+
console.log('');
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
const mode = detectAgentMode(projectDir);
|
|
64
|
+
const cmd = mode === 'llm' ? '/docguard.verify' : 'docguard verify --semantic --format json';
|
|
65
|
+
console.log(` ${c.dim}This is the highest-value bug class and DocGuard can't judge it โ an agent must.${c.reset}`);
|
|
66
|
+
console.log(` ${c.dim}Get the machine task list: ${c.cyan}${cmd}${c.dim}, then read each cited file and confirm the value.${c.reset}\n`);
|
|
67
|
+
}
|