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/guard.mjs
CHANGED
|
@@ -7,13 +7,17 @@
|
|
|
7
7
|
* runGuardInternal() → returns data, no side effects (for diagnose, ci)
|
|
8
8
|
*/
|
|
9
9
|
|
|
10
|
-
import { c, resolveSeverity } from '../shared.mjs';
|
|
10
|
+
import { c, resolveSeverity, loadIgnorePatterns, resolveDocDirs } from '../shared.mjs';
|
|
11
|
+
import { walkFiles } from '../shared-ignore.mjs';
|
|
12
|
+
import { mkFinding, resultFromFindings } from '../findings.mjs';
|
|
11
13
|
import { loadValidatorSuppressions } from '../validator-markers.mjs';
|
|
12
14
|
import { detectAgentMode, isSpecKitInitialized } from '../ensure-skills.mjs';
|
|
13
15
|
import { checkUpgradeStatus } from './upgrade.mjs';
|
|
14
16
|
import { changedFilesSince, isGitRepo } from '../shared-git.mjs';
|
|
17
|
+
import { extractSemanticClaims } from '../scanners/semantic-claims.mjs';
|
|
18
|
+
import { toSarif } from '../writers/sarif.mjs';
|
|
15
19
|
import { readFileSync, writeFileSync, existsSync } from 'node:fs';
|
|
16
|
-
import { resolve as resolvePath } from 'node:path';
|
|
20
|
+
import { resolve as resolvePath, relative as relativePath } from 'node:path';
|
|
17
21
|
import { fileURLToPath as fp } from 'node:url';
|
|
18
22
|
import { dirname as dn } from 'node:path';
|
|
19
23
|
|
|
@@ -201,6 +205,64 @@ function renderableItems(v) {
|
|
|
201
205
|
];
|
|
202
206
|
}
|
|
203
207
|
|
|
208
|
+
// ── Doc coverage map (v0.29) ──────────────────────────────────────────────────
|
|
209
|
+
// Field report #6, Gap 1: only allow-listed docs were ever validated, so a new
|
|
210
|
+
// .md could drift forever while guard stayed green — the human had to REMEMBER to
|
|
211
|
+
// enroll each doc, which is exactly the step that fails silently. We deliberately
|
|
212
|
+
// do NOT deep-scan every doc for claims (that floods false positives — see the
|
|
213
|
+
// wu-whatsappinbox scar in metrics-consistency). Instead we cheaply report what's
|
|
214
|
+
// under a validation tier and what isn't, turning silent non-coverage into a
|
|
215
|
+
// visible nudge. Pure visibility — never gates the build.
|
|
216
|
+
//
|
|
217
|
+
// DocGuard's OWN installed slash-command docs are tool-managed, not the project's
|
|
218
|
+
// docs — counting them as "untracked drift" is noise the user can't act on.
|
|
219
|
+
const DOCGUARD_OWN_DOC_RE = /(^|\/)commands\/docguard\.[a-z-]+\.md$/i;
|
|
220
|
+
|
|
221
|
+
function collectMarkdown(projectDir) {
|
|
222
|
+
const out = [];
|
|
223
|
+
// Shared canonical walker (v0.29 consolidation) — same ignore set and dot-entry
|
|
224
|
+
// skipping as every other validator, instead of a private IGNORE_DIRS copy.
|
|
225
|
+
walkFiles(projectDir, (full) => {
|
|
226
|
+
if (full.toLowerCase().endsWith('.md')) {
|
|
227
|
+
out.push(relativePath(projectDir, full).replace(/\\/g, '/'));
|
|
228
|
+
}
|
|
229
|
+
});
|
|
230
|
+
return out;
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
/**
|
|
234
|
+
* Classify every discoverable Markdown file into a validation tier:
|
|
235
|
+
* canonical — in requiredFiles.canonical (structure + review-gated)
|
|
236
|
+
* tracked — under a doc home or root-level (claim/freshness checks reach it)
|
|
237
|
+
* ignored — matched by .docguardignore
|
|
238
|
+
* unclassified — under NO tier; drift here is invisible (the Gap-1 trap)
|
|
239
|
+
*/
|
|
240
|
+
function computeDocCoverage(projectDir, config) {
|
|
241
|
+
const isIgnored = loadIgnorePatterns(projectDir);
|
|
242
|
+
const canonical = new Set(
|
|
243
|
+
((config.requiredFiles && config.requiredFiles.canonical) || []).map(p => p.replace(/\\/g, '/'))
|
|
244
|
+
);
|
|
245
|
+
// Any path declared in documentTypes is a KNOWN doc (even if optional) — not
|
|
246
|
+
// "untracked." This keeps the warning specific to genuinely-unenrolled files.
|
|
247
|
+
const known = new Set(Object.keys(config.documentTypes || {}).map(p => p.replace(/\\/g, '/')));
|
|
248
|
+
// Same doc-home set the claim scanner uses — so "tracked" provably means
|
|
249
|
+
// "actually scanned," never a label the scanner ignores. With trailing slash
|
|
250
|
+
// for prefix matching.
|
|
251
|
+
const docHomePrefixes = resolveDocDirs(projectDir, config).map(d => d.replace(/\/?$/, '/'));
|
|
252
|
+
const all = collectMarkdown(projectDir);
|
|
253
|
+
let canonicalCount = 0, tracked = 0, ignored = 0;
|
|
254
|
+
const unclassified = [];
|
|
255
|
+
for (const rel of all) {
|
|
256
|
+
if (canonical.has(rel)) { canonicalCount++; continue; }
|
|
257
|
+
if (isIgnored(rel) || DOCGUARD_OWN_DOC_RE.test(rel)) { ignored++; continue; }
|
|
258
|
+
const inHome = docHomePrefixes.some(h => rel.startsWith(h));
|
|
259
|
+
const atRoot = !rel.includes('/');
|
|
260
|
+
if (inHome || atRoot || known.has(rel)) { tracked++; continue; }
|
|
261
|
+
unclassified.push(rel);
|
|
262
|
+
}
|
|
263
|
+
return { discovered: all.length, canonical: canonicalCount, tracked, ignored, unclassified };
|
|
264
|
+
}
|
|
265
|
+
|
|
204
266
|
export function runGuardInternal(projectDir, config) {
|
|
205
267
|
const validators = config.validators || {};
|
|
206
268
|
const results = [];
|
|
@@ -216,16 +278,28 @@ export function runGuardInternal(projectDir, config) {
|
|
|
216
278
|
{ key: 'security', name: 'Security', fn: () => validateSecurity(projectDir, config) },
|
|
217
279
|
{ key: 'architecture', name: 'Architecture', fn: () => validateArchitecture(projectDir, config) },
|
|
218
280
|
{ key: 'freshness', name: 'Freshness', fn: () => {
|
|
281
|
+
// v0.29: adapter now emits structured findings (FRS001–FRS005). The
|
|
282
|
+
// validator keeps its array-of-{status, code, doc, message} contract;
|
|
283
|
+
// messages are byte-identical (the sweep-needed nudge below regex-matches
|
|
284
|
+
// them), so counts/exit codes are unchanged.
|
|
219
285
|
const freshnessResults = validateFreshness(projectDir, config);
|
|
220
|
-
const
|
|
221
|
-
const warnings = [];
|
|
286
|
+
const findings = [];
|
|
222
287
|
let passed = 0;
|
|
223
288
|
for (const r of freshnessResults) {
|
|
224
|
-
if (r.status === 'pass') passed++;
|
|
225
|
-
|
|
226
|
-
|
|
289
|
+
if (r.status === 'pass') { passed++; continue; }
|
|
290
|
+
if (r.status !== 'warn' && r.status !== 'fail') continue; // skip entries
|
|
291
|
+
findings.push(mkFinding({
|
|
292
|
+
code: r.code || null,
|
|
293
|
+
validator: 'freshness',
|
|
294
|
+
severity: r.status === 'fail' ? 'error' : 'warn',
|
|
295
|
+
message: r.message,
|
|
296
|
+
location: r.doc || null,
|
|
297
|
+
suggestion: r.code === 'FRS001'
|
|
298
|
+
? { kind: 'fix', text: 'Commit the doc, or stamp it reviewed', pragma: '<!-- docguard:last-reviewed YYYY-MM-DD -->' }
|
|
299
|
+
: { kind: 'fix', text: 'Refresh the stale code-truth sections', command: 'docguard sync --write' },
|
|
300
|
+
}));
|
|
227
301
|
}
|
|
228
|
-
return
|
|
302
|
+
return resultFromFindings(findings, { passed, total: passed + findings.length });
|
|
229
303
|
}},
|
|
230
304
|
{ key: 'traceability', name: 'Traceability', fn: () => validateTraceability(projectDir, config) },
|
|
231
305
|
{ key: 'docsDiff', name: 'Docs-Diff', fn: () => validateDocsDiff(projectDir, config) },
|
|
@@ -353,6 +427,19 @@ export function runGuardInternal(projectDir, config) {
|
|
|
353
427
|
const nextStep =
|
|
354
428
|
overallStatus === 'PASS' ? null : 'docguard diagnose';
|
|
355
429
|
|
|
430
|
+
// v0.29: coverage map + semantic-claim surfacing. Both are pure visibility —
|
|
431
|
+
// they never change errors/warnings/exit code. Skipped on the --changed-only
|
|
432
|
+
// lite path, which trades coverage for sub-2s speed and shouldn't pay for a
|
|
433
|
+
// repo-wide Markdown walk.
|
|
434
|
+
const lite = Array.isArray(config.changedFiles);
|
|
435
|
+
let coverage = null;
|
|
436
|
+
let semanticClaims = null;
|
|
437
|
+
if (!lite) {
|
|
438
|
+
try { coverage = computeDocCoverage(projectDir, config); } catch { coverage = null; }
|
|
439
|
+
try { semanticClaims = { count: extractSemanticClaims(projectDir, config).length }; }
|
|
440
|
+
catch { semanticClaims = null; }
|
|
441
|
+
}
|
|
442
|
+
|
|
356
443
|
return {
|
|
357
444
|
project: config.projectName,
|
|
358
445
|
profile: config.profile || 'standard',
|
|
@@ -369,6 +456,8 @@ export function runGuardInternal(projectDir, config) {
|
|
|
369
456
|
// things they've marked as high-severity.
|
|
370
457
|
effectiveErrors,
|
|
371
458
|
effectiveWarnings,
|
|
459
|
+
coverage,
|
|
460
|
+
semanticClaims,
|
|
372
461
|
validators: results,
|
|
373
462
|
// Unknown keys in `docguard:validator … n/a` markers — typo protection so
|
|
374
463
|
// a mistyped key doesn't silently fail to suppress. Surfaced by runGuard.
|
|
@@ -460,14 +549,29 @@ export function runGuard(projectDir, config, flags) {
|
|
|
460
549
|
|
|
461
550
|
const data = runGuardInternal(projectDir, config);
|
|
462
551
|
|
|
552
|
+
// ── SARIF output (2.1.0) ──
|
|
553
|
+
// Same flush discipline as the JSON branch below (bug-105): set exitCode and
|
|
554
|
+
// write+return so a piped consumer never gets a truncated payload.
|
|
555
|
+
if (flags.format === 'sarif') {
|
|
556
|
+
const sarif = toSarif(data, { projectDir });
|
|
557
|
+
process.exitCode = data.effectiveErrors > 0 ? 1 : data.effectiveWarnings > 0 ? 2 : 0;
|
|
558
|
+
process.stdout.write(JSON.stringify(sarif, null, 2) + '\n');
|
|
559
|
+
return;
|
|
560
|
+
}
|
|
561
|
+
|
|
463
562
|
// ── JSON output ──
|
|
464
563
|
if (flags.format === 'json') {
|
|
465
|
-
console.log(JSON.stringify(data, null, 2));
|
|
466
564
|
// Use severity-aware effective counts for exit code; raw counts stay in the JSON
|
|
467
565
|
// for display tools that want to show the full picture.
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
566
|
+
const code = data.effectiveErrors > 0 ? 1 : data.effectiveWarnings > 0 ? 2 : 0;
|
|
567
|
+
// v0.28: set exitCode + return instead of process.exit(). A large JSON
|
|
568
|
+
// payload (>~8 KB) written to a PIPE flushes asynchronously; an immediate
|
|
569
|
+
// process.exit() truncates it mid-string, so a CI consumer parsing stdout
|
|
570
|
+
// gets "Unterminated string in JSON" on exactly the big reports that matter.
|
|
571
|
+
// Returning lets Node drain stdout and exit naturally with process.exitCode.
|
|
572
|
+
process.exitCode = code;
|
|
573
|
+
process.stdout.write(JSON.stringify(data, null, 2) + '\n');
|
|
574
|
+
return;
|
|
471
575
|
}
|
|
472
576
|
|
|
473
577
|
// ── Text output ──
|
|
@@ -586,6 +690,35 @@ export function runGuard(projectDir, config, flags) {
|
|
|
586
690
|
console.log(` ${c.dim}💡 Install ${c.cyan}/docguard.*${c.dim} commands for your agent: ${c.cyan}docguard init${c.reset}`);
|
|
587
691
|
}
|
|
588
692
|
|
|
693
|
+
// ── Coverage + claim visibility (v0.29, field report #6) ──
|
|
694
|
+
// "Green" must mean "I checked these and they're clean," not "I checked the few
|
|
695
|
+
// files I was told about." Show what's under no tier (Gap 1) and that documented
|
|
696
|
+
// factual claims remain unverified vs code (Gap 2). Neither gates the build — but
|
|
697
|
+
// both must SHOW, or a green run misleads.
|
|
698
|
+
if (data.coverage) {
|
|
699
|
+
const cov = data.coverage;
|
|
700
|
+
const unclassN = cov.unclassified.length;
|
|
701
|
+
const tierLine = `${cov.canonical} canonical · ${cov.tracked} tracked · ${cov.ignored} ignored`
|
|
702
|
+
+ (unclassN ? ` · ${c.yellow}${unclassN} outside any tier${c.reset}${c.dim}` : '');
|
|
703
|
+
console.log(`\n ${c.dim}📑 Docs: ${tierLine} ${c.reset}${c.dim}(${cov.discovered} Markdown files)${c.reset}`);
|
|
704
|
+
if (unclassN > 0) {
|
|
705
|
+
// Calm by default — surface the COUNT every run (so non-coverage is never
|
|
706
|
+
// silent), but don't enumerate or cry "invisible drift": much of this is
|
|
707
|
+
// legitimately untracked (fixtures, templates, specs). The file list is one
|
|
708
|
+
// `--verbose` away. Loud-by-default here would just train users to ignore it.
|
|
709
|
+
console.log(` ${c.dim}↪ ${unclassN} file(s) in no validation tier — add to requiredFiles.canonical, a docs/ home, or .docguardignore${flags.verbose ? ':' : ` (${skill('guard')} --verbose to list)`}${c.reset}`);
|
|
710
|
+
if (flags.verbose) {
|
|
711
|
+
for (const f of cov.unclassified.slice(0, 10)) console.log(` ${c.dim}• ${f}${c.reset}`);
|
|
712
|
+
if (unclassN > 10) console.log(` ${c.dim}... and ${unclassN - 10} more${c.reset}`);
|
|
713
|
+
}
|
|
714
|
+
}
|
|
715
|
+
}
|
|
716
|
+
if (data.semanticClaims && data.semanticClaims.count > 0) {
|
|
717
|
+
console.log(`\n ${c.cyan}🔍 ${data.semanticClaims.count} documented claim(s) (counts/limits/enums) are unverified against code.${c.reset}`);
|
|
718
|
+
console.log(` ${c.dim}A green guard means the structure is sound — NOT that these values still match the code.${c.reset}`);
|
|
719
|
+
console.log(` ${c.dim}Confirm them: ${c.cyan}${skill('verify')} --semantic${c.reset}`);
|
|
720
|
+
}
|
|
721
|
+
|
|
589
722
|
// Badge snippet
|
|
590
723
|
const pct = data.total > 0 ? Math.round((data.passed / data.total) * 100) : 0;
|
|
591
724
|
const bColor = pct >= 90 ? 'brightgreen' : pct >= 70 ? 'green' : pct >= 50 ? 'yellow' : 'red';
|
|
@@ -707,7 +840,8 @@ export function runGuard(projectDir, config, flags) {
|
|
|
707
840
|
}
|
|
708
841
|
|
|
709
842
|
// v0.5: severity-aware exit codes (see runGuardInternal for the rollup).
|
|
710
|
-
|
|
711
|
-
|
|
712
|
-
|
|
843
|
+
// v0.28: exitCode + return (not process.exit) so the buffered text output
|
|
844
|
+
// flushes to a pipe before the process exits — same truncation fix as the
|
|
845
|
+
// JSON path above.
|
|
846
|
+
process.exitCode = data.effectiveErrors > 0 ? 1 : data.effectiveWarnings > 0 ? 2 : 0;
|
|
713
847
|
}
|
package/cli/commands/init.mjs
CHANGED
|
@@ -78,6 +78,20 @@ const __filename = fileURLToPath(import.meta.url);
|
|
|
78
78
|
const __dirname = dirname(__filename);
|
|
79
79
|
const TEMPLATES_DIR = resolve(__dirname, '../../templates');
|
|
80
80
|
|
|
81
|
+
/**
|
|
82
|
+
* v0.28 (field report #11): inject a `<!-- docguard:last-reviewed DATE -->`
|
|
83
|
+
* marker right after the first H1, so a canonical doc has a freshness signal the
|
|
84
|
+
* Freshness validator reads directly (not git mtime). No-op if one is present.
|
|
85
|
+
*/
|
|
86
|
+
function stampLastReviewed(content, date) {
|
|
87
|
+
const marker = `<!-- docguard:last-reviewed ${date} -->`;
|
|
88
|
+
const lines = content.split('\n');
|
|
89
|
+
const h1 = lines.findIndex(l => /^#\s/.test(l));
|
|
90
|
+
if (h1 === -1) return `${marker}\n\n${content}`;
|
|
91
|
+
lines.splice(h1 + 1, 0, '', marker);
|
|
92
|
+
return lines.join('\n');
|
|
93
|
+
}
|
|
94
|
+
|
|
81
95
|
// ── Readline helper ──────────────────────────────────────────────────────
|
|
82
96
|
|
|
83
97
|
function askQuestion(prompt) {
|
|
@@ -263,7 +277,15 @@ export async function runInit(projectDir, config, flags) {
|
|
|
263
277
|
if (existsSync(templatePath)) {
|
|
264
278
|
const content = readFileSync(templatePath, 'utf-8');
|
|
265
279
|
const today = new Date().toISOString().split('T')[0];
|
|
266
|
-
|
|
280
|
+
let processed = content.replace(/YYYY-MM-DD/g, today);
|
|
281
|
+
// v0.28 (field report #11): every canonical doc must ship with a freshness
|
|
282
|
+
// marker so the Freshness validator is marker-based (consistent across docs,
|
|
283
|
+
// and satisfiable in a pre-commit review loop) rather than silently falling
|
|
284
|
+
// back to git mtime. Templates now all carry one; this is the belt-and-
|
|
285
|
+
// suspenders guarantee for any future template that forgets.
|
|
286
|
+
if (mapping.dest.startsWith('docs-canonical/') && !/docguard:last-reviewed/.test(processed)) {
|
|
287
|
+
processed = stampLastReviewed(processed, today);
|
|
288
|
+
}
|
|
267
289
|
writeFileSync(destPath, processed, 'utf-8');
|
|
268
290
|
created.push(mapping.dest);
|
|
269
291
|
console.log(` ${c.green}✅${c.reset} Created: ${c.cyan}${mapping.dest}${c.reset}`);
|
package/cli/commands/llms.mjs
CHANGED
|
@@ -137,23 +137,85 @@ function getProjectDescription(projectDir) {
|
|
|
137
137
|
return null;
|
|
138
138
|
}
|
|
139
139
|
|
|
140
|
+
// llms-full.txt (v0.29): the Mintlify-popularized companion form — full doc
|
|
141
|
+
// bodies inlined, so an AI consumer gets everything in one fetch instead of
|
|
142
|
+
// chasing the llms.txt link index. Any single doc is capped to keep one
|
|
143
|
+
// runaway file from consuming the whole context window.
|
|
144
|
+
const FULL_DOC_LINE_CAP = 400;
|
|
145
|
+
|
|
146
|
+
/**
|
|
147
|
+
* Generate llms-full.txt content — llms.txt header + inlined doc bodies.
|
|
148
|
+
*/
|
|
149
|
+
export function generateLlmsFullTxt(projectDir, config) {
|
|
150
|
+
const lines = [];
|
|
151
|
+
const projectName = config.projectName || basename(projectDir);
|
|
152
|
+
const description = getProjectDescription(projectDir);
|
|
153
|
+
|
|
154
|
+
lines.push(`# ${projectName}`);
|
|
155
|
+
if (description) lines.push(`> ${description}`);
|
|
156
|
+
lines.push('');
|
|
157
|
+
lines.push('<!-- llms-full.txt — full-content form. The link-index form is llms.txt. -->');
|
|
158
|
+
lines.push('<!-- Generated by DocGuard (docguard llms --full). Regenerate after doc changes. -->');
|
|
159
|
+
lines.push('');
|
|
160
|
+
|
|
161
|
+
// Same doc discovery as the index form: canonical docs + present optional docs.
|
|
162
|
+
const docPaths = [];
|
|
163
|
+
const docsDir = resolve(projectDir, 'docs-canonical');
|
|
164
|
+
if (existsSync(docsDir)) {
|
|
165
|
+
try {
|
|
166
|
+
for (const entry of readdirSync(docsDir).filter(f => f.endsWith('.md')).sort()) {
|
|
167
|
+
docPaths.push({ path: `docs-canonical/${entry}`, desc: DOC_DESCRIPTIONS[entry] || null });
|
|
168
|
+
}
|
|
169
|
+
} catch { /* ignore */ }
|
|
170
|
+
}
|
|
171
|
+
for (const [file, desc] of Object.entries(OPTIONAL_DOCS)) {
|
|
172
|
+
if (existsSync(resolve(projectDir, file))) docPaths.push({ path: file, desc });
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
for (const { path, desc } of docPaths) {
|
|
176
|
+
let content;
|
|
177
|
+
try { content = readFileSync(resolve(projectDir, path), 'utf-8'); } catch { continue; }
|
|
178
|
+
lines.push('---');
|
|
179
|
+
lines.push('');
|
|
180
|
+
lines.push(`## ${path}`);
|
|
181
|
+
if (desc) lines.push(`> ${desc}`);
|
|
182
|
+
lines.push('');
|
|
183
|
+
const docLines = content.split('\n');
|
|
184
|
+
if (docLines.length > FULL_DOC_LINE_CAP) {
|
|
185
|
+
lines.push(...docLines.slice(0, FULL_DOC_LINE_CAP));
|
|
186
|
+
lines.push('');
|
|
187
|
+
lines.push(`<!-- truncated: ${docLines.length - FULL_DOC_LINE_CAP} more lines — read ${path} directly -->`);
|
|
188
|
+
} else {
|
|
189
|
+
lines.push(...docLines);
|
|
190
|
+
}
|
|
191
|
+
lines.push('');
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
lines.push('---');
|
|
195
|
+
lines.push(`Generated by DocGuard | [docguard-cli](https://www.npmjs.com/package/docguard-cli)`);
|
|
196
|
+
lines.push('');
|
|
197
|
+
return lines.join('\n');
|
|
198
|
+
}
|
|
199
|
+
|
|
140
200
|
/**
|
|
141
|
-
* Public command — generate llms.txt
|
|
201
|
+
* Public command — generate llms.txt (or llms-full.txt with --full).
|
|
142
202
|
*/
|
|
143
203
|
export function runLlms(projectDir, config, flags) {
|
|
144
|
-
const
|
|
204
|
+
const full = !!flags.full;
|
|
205
|
+
const content = full ? generateLlmsFullTxt(projectDir, config) : generateLlmsTxt(projectDir, config);
|
|
145
206
|
|
|
146
207
|
if (flags.stdout) {
|
|
147
208
|
console.log(content);
|
|
148
209
|
return;
|
|
149
210
|
}
|
|
150
211
|
|
|
151
|
-
const
|
|
212
|
+
const fileName = full ? 'llms-full.txt' : 'llms.txt';
|
|
213
|
+
const outputPath = resolve(projectDir, fileName);
|
|
152
214
|
writeFileSync(outputPath, content, 'utf-8');
|
|
153
215
|
|
|
154
|
-
console.log(`${c.bold}📄 DocGuard
|
|
216
|
+
console.log(`${c.bold}📄 DocGuard ${fileName} Generator${c.reset}`);
|
|
155
217
|
console.log(`${c.green}✅ Generated ${outputPath}${c.reset}`);
|
|
156
|
-
console.log(`${c.dim} Standard: llms.txt (Jeremy Howard, Answer.AI, 2024)${c.reset}`);
|
|
218
|
+
console.log(`${c.dim} Standard: llms.txt (Jeremy Howard, Answer.AI, 2024)${full ? ' — full-content form' : ''}${c.reset}`);
|
|
157
219
|
console.log(`${c.dim} DocGuard keeps this in sync with your canonical docs.${c.reset}`);
|
|
158
220
|
console.log('');
|
|
159
221
|
}
|
|
@@ -0,0 +1,263 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* MCP Command — DocGuard as a Model Context Protocol server (stdio).
|
|
3
|
+
*
|
|
4
|
+
* `docguard mcp` exposes the read-only core (guard / score / explain /
|
|
5
|
+
* verify-claims / diagnose) as MCP tools any MCP client (Claude, Cursor,
|
|
6
|
+
* agent SDKs) can call over stdio. JSON-RPC 2.0, newline-delimited, per the
|
|
7
|
+
* MCP stdio transport (protocol revision 2024-11-05).
|
|
8
|
+
*
|
|
9
|
+
* Contract constraints:
|
|
10
|
+
* - stdout IS the transport. Nothing else may be written there — the
|
|
11
|
+
* dispatcher suppresses the banner for this command, and every diagnostic
|
|
12
|
+
* goes to stderr.
|
|
13
|
+
* - Tool failures are isolated: an exception inside a tool becomes an
|
|
14
|
+
* `isError: true` tool RESULT (per MCP), never a JSON-RPC error and never
|
|
15
|
+
* a server crash. Protocol-level problems (unparseable line, unknown
|
|
16
|
+
* method, unknown tool) get the standard JSON-RPC error codes.
|
|
17
|
+
* - Config is loaded PER tool call: the server is long-lived, .docguard.json
|
|
18
|
+
* may change between calls, and the optional `projectDir` argument may
|
|
19
|
+
* point each call at a different project.
|
|
20
|
+
*
|
|
21
|
+
* Zero npm dependencies — node:readline over process.stdin.
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
import { createInterface } from 'node:readline';
|
|
25
|
+
import { existsSync, readFileSync } from 'node:fs';
|
|
26
|
+
import { resolve, dirname } from 'node:path';
|
|
27
|
+
import { fileURLToPath } from 'node:url';
|
|
28
|
+
import { runGuardInternal } from './guard.mjs';
|
|
29
|
+
import { runScoreInternal } from './score.mjs';
|
|
30
|
+
import { loadConfig } from '../config.mjs';
|
|
31
|
+
import { CODES } from '../findings.mjs';
|
|
32
|
+
import { extractSemanticClaims, buildSemanticVerifyTasks } from '../scanners/semantic-claims.mjs';
|
|
33
|
+
|
|
34
|
+
const _PKG = JSON.parse(readFileSync(resolve(dirname(fileURLToPath(import.meta.url)), '..', '..', 'package.json'), 'utf-8'));
|
|
35
|
+
|
|
36
|
+
// Oldest MCP revision this server implements; echoed back on initialize when
|
|
37
|
+
// the client requests a version we recognize the shape of.
|
|
38
|
+
const PROTOCOL_VERSION = '2024-11-05';
|
|
39
|
+
|
|
40
|
+
// JSON-RPC 2.0 reserved error codes.
|
|
41
|
+
const E_PARSE = -32700;
|
|
42
|
+
const E_INVALID_REQUEST = -32600;
|
|
43
|
+
const E_METHOD_NOT_FOUND = -32601;
|
|
44
|
+
const E_INVALID_PARAMS = -32602;
|
|
45
|
+
const E_INTERNAL = -32603;
|
|
46
|
+
|
|
47
|
+
// Shared schema fragment: every project-scoped tool accepts an optional
|
|
48
|
+
// projectDir and falls back to the server's working directory.
|
|
49
|
+
const PROJECT_DIR_PROP = {
|
|
50
|
+
projectDir: {
|
|
51
|
+
type: 'string',
|
|
52
|
+
description: 'Path to the project to inspect (absolute, or relative to the server\'s working directory). Defaults to the working directory the server was started in.',
|
|
53
|
+
},
|
|
54
|
+
};
|
|
55
|
+
|
|
56
|
+
const TOOLS = [
|
|
57
|
+
{
|
|
58
|
+
name: 'docguard_guard',
|
|
59
|
+
description: 'Run every enabled DocGuard validator against the project\'s canonical docs. Returns the full guard JSON contract: status (PASS/WARN/FAIL), structured findings with stable codes and suggestions, nextStep, doc coverage map, semantic-claim count, and per-validator results.',
|
|
60
|
+
inputSchema: {
|
|
61
|
+
type: 'object',
|
|
62
|
+
properties: { ...PROJECT_DIR_PROP },
|
|
63
|
+
},
|
|
64
|
+
},
|
|
65
|
+
{
|
|
66
|
+
name: 'docguard_score',
|
|
67
|
+
description: 'Compute the project\'s CDD maturity score (0-100) with letter grade and per-category breakdown.',
|
|
68
|
+
inputSchema: {
|
|
69
|
+
type: 'object',
|
|
70
|
+
properties: { ...PROJECT_DIR_PROP },
|
|
71
|
+
},
|
|
72
|
+
},
|
|
73
|
+
{
|
|
74
|
+
name: 'docguard_explain',
|
|
75
|
+
description: 'Explain a stable DocGuard finding code (e.g. STR001, ENV003): what it means, which validator emits it, and the inline suppression to use if it\'s a confirmed false positive.',
|
|
76
|
+
inputSchema: {
|
|
77
|
+
type: 'object',
|
|
78
|
+
properties: {
|
|
79
|
+
code: {
|
|
80
|
+
type: 'string',
|
|
81
|
+
description: 'The finding code guard prints next to each finding, e.g. STR001 or ENV003. Case-insensitive.',
|
|
82
|
+
},
|
|
83
|
+
},
|
|
84
|
+
required: ['code'],
|
|
85
|
+
},
|
|
86
|
+
},
|
|
87
|
+
{
|
|
88
|
+
name: 'docguard_verify_claims',
|
|
89
|
+
description: 'Extract the semantic claims in the project\'s canonical docs — documented numbers, limits, and enums — as a verification task list. Deterministic discovery, LLM judgment — the caller verifies each claim against the code.',
|
|
90
|
+
inputSchema: {
|
|
91
|
+
type: 'object',
|
|
92
|
+
properties: { ...PROJECT_DIR_PROP },
|
|
93
|
+
},
|
|
94
|
+
},
|
|
95
|
+
{
|
|
96
|
+
name: 'docguard_diagnose',
|
|
97
|
+
description: 'Run guard and return only what needs fixing: failing/warning validators with their messages, structured findings, and suggested next actions — shaped for an agent to act on.',
|
|
98
|
+
inputSchema: {
|
|
99
|
+
type: 'object',
|
|
100
|
+
properties: { ...PROJECT_DIR_PROP },
|
|
101
|
+
},
|
|
102
|
+
},
|
|
103
|
+
];
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* Resolve a tool call's target project + config. loadConfig() process.exit(1)s
|
|
107
|
+
* on a malformed .docguard.json — fatal for a long-lived server — so the file
|
|
108
|
+
* is pre-parsed here and a broken config surfaces as an isError tool result.
|
|
109
|
+
*/
|
|
110
|
+
function resolveTarget(args, defaultDir) {
|
|
111
|
+
const dir = resolve(args && typeof args.projectDir === 'string' && args.projectDir.trim() !== '' ? args.projectDir : defaultDir);
|
|
112
|
+
if (!existsSync(dir)) throw new Error(`projectDir does not exist: ${dir}`);
|
|
113
|
+
const cfgPath = resolve(dir, '.docguard.json');
|
|
114
|
+
if (existsSync(cfgPath)) {
|
|
115
|
+
try { JSON.parse(readFileSync(cfgPath, 'utf-8')); }
|
|
116
|
+
catch (e) { throw new Error(`Cannot parse ${cfgPath}: ${e.message}`); }
|
|
117
|
+
}
|
|
118
|
+
return { dir, config: loadConfig(dir) };
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
const TOOL_HANDLERS = {
|
|
122
|
+
docguard_guard(args, defaultDir) {
|
|
123
|
+
const { dir, config } = resolveTarget(args, defaultDir);
|
|
124
|
+
return runGuardInternal(dir, config);
|
|
125
|
+
},
|
|
126
|
+
|
|
127
|
+
docguard_score(args, defaultDir) {
|
|
128
|
+
const { dir, config } = resolveTarget(args, defaultDir);
|
|
129
|
+
return runScoreInternal(dir, config);
|
|
130
|
+
},
|
|
131
|
+
|
|
132
|
+
docguard_explain(args) {
|
|
133
|
+
const code = String((args && args.code) || '').trim().toUpperCase();
|
|
134
|
+
if (!code) throw new Error('Missing required argument "code" (a stable finding code, e.g. STR001).');
|
|
135
|
+
const entry = CODES[code];
|
|
136
|
+
if (!entry) {
|
|
137
|
+
throw new Error(`Unknown finding code "${code}". Codes are the stable handles guard prints next to each finding (e.g. STR001, ENV003) — run docguard_guard and use a code from its findings.`);
|
|
138
|
+
}
|
|
139
|
+
return { code, title: entry.title, help: entry.help, suppress: entry.suppress, validator: entry.validator };
|
|
140
|
+
},
|
|
141
|
+
|
|
142
|
+
docguard_verify_claims(args, defaultDir) {
|
|
143
|
+
const { dir, config } = resolveTarget(args, defaultDir);
|
|
144
|
+
const claims = extractSemanticClaims(dir, config);
|
|
145
|
+
return {
|
|
146
|
+
claimCount: claims.length,
|
|
147
|
+
note: 'Deterministic discovery, LLM judgment — the caller verifies each claim against the code and reports any mismatch with both values.',
|
|
148
|
+
tasks: buildSemanticVerifyTasks(claims),
|
|
149
|
+
};
|
|
150
|
+
},
|
|
151
|
+
|
|
152
|
+
docguard_diagnose(args, defaultDir) {
|
|
153
|
+
const { dir, config } = resolveTarget(args, defaultDir);
|
|
154
|
+
const data = runGuardInternal(dir, config);
|
|
155
|
+
// Only what needs acting on: validators with errors/warnings, each carrying
|
|
156
|
+
// its structured findings (code + location + suggestion) when available.
|
|
157
|
+
const problems = (data.validators || [])
|
|
158
|
+
.filter((v) => (v.errors || []).length + (v.warnings || []).length > 0)
|
|
159
|
+
.map((v) => ({
|
|
160
|
+
validator: v.name,
|
|
161
|
+
key: v.key,
|
|
162
|
+
severity: v.severity || 'medium',
|
|
163
|
+
errors: v.errors || [],
|
|
164
|
+
warnings: v.warnings || [],
|
|
165
|
+
findings: (Array.isArray(v.findings) ? v.findings : []).map((f) => ({
|
|
166
|
+
code: f.code,
|
|
167
|
+
severity: f.severity,
|
|
168
|
+
message: f.message,
|
|
169
|
+
location: f.location,
|
|
170
|
+
suggestion: f.suggestion,
|
|
171
|
+
})),
|
|
172
|
+
}));
|
|
173
|
+
return {
|
|
174
|
+
status: data.status,
|
|
175
|
+
errors: data.errors,
|
|
176
|
+
warnings: data.warnings,
|
|
177
|
+
nextStep: data.nextStep,
|
|
178
|
+
problems,
|
|
179
|
+
hint: problems.length === 0
|
|
180
|
+
? 'Nothing to fix — guard is clean.'
|
|
181
|
+
: 'Fix errors first, then warnings. Use docguard_explain with a finding code for the full remediation help.',
|
|
182
|
+
};
|
|
183
|
+
},
|
|
184
|
+
};
|
|
185
|
+
|
|
186
|
+
/**
|
|
187
|
+
* Serve MCP over stdio until stdin closes. The returned promise keeps the
|
|
188
|
+
* dispatcher's `await` (and thus the process) alive for the server's lifetime.
|
|
189
|
+
*/
|
|
190
|
+
export function runMcp(projectDir, _config, _flags) {
|
|
191
|
+
const send = (msg) => {
|
|
192
|
+
// A vanished client (EPIPE) is a normal shutdown, not a crash.
|
|
193
|
+
try { process.stdout.write(JSON.stringify(msg) + '\n'); }
|
|
194
|
+
catch { /* client gone — the readline close handler ends the server */ }
|
|
195
|
+
};
|
|
196
|
+
const reply = (id, result) => send({ jsonrpc: '2.0', id, result });
|
|
197
|
+
const replyError = (id, code, message) => send({ jsonrpc: '2.0', id, error: { code, message } });
|
|
198
|
+
|
|
199
|
+
const handleMessage = (msg) => {
|
|
200
|
+
if (!msg || typeof msg !== 'object' || Array.isArray(msg) || msg.jsonrpc !== '2.0' || typeof msg.method !== 'string') {
|
|
201
|
+
replyError(msg && msg.id !== undefined ? msg.id : null, E_INVALID_REQUEST, 'Invalid Request');
|
|
202
|
+
return;
|
|
203
|
+
}
|
|
204
|
+
const { id, method, params } = msg;
|
|
205
|
+
const isNotification = id === undefined || id === null;
|
|
206
|
+
|
|
207
|
+
switch (method) {
|
|
208
|
+
case 'initialize':
|
|
209
|
+
reply(id, {
|
|
210
|
+
protocolVersion: typeof params?.protocolVersion === 'string' ? params.protocolVersion : PROTOCOL_VERSION,
|
|
211
|
+
capabilities: { tools: {} },
|
|
212
|
+
serverInfo: { name: 'docguard', version: _PKG.version },
|
|
213
|
+
});
|
|
214
|
+
return;
|
|
215
|
+
case 'ping':
|
|
216
|
+
reply(id, {});
|
|
217
|
+
return;
|
|
218
|
+
case 'tools/list':
|
|
219
|
+
reply(id, { tools: TOOLS });
|
|
220
|
+
return;
|
|
221
|
+
case 'tools/call': {
|
|
222
|
+
const handler = TOOL_HANDLERS[params?.name];
|
|
223
|
+
if (!handler) {
|
|
224
|
+
replyError(id, E_INVALID_PARAMS, `Unknown tool: ${params?.name}`);
|
|
225
|
+
return;
|
|
226
|
+
}
|
|
227
|
+
// In-tool failures are tool RESULTS (isError), not protocol errors —
|
|
228
|
+
// one bad call must never take down the server or the session.
|
|
229
|
+
try {
|
|
230
|
+
const payload = handler(params?.arguments || {}, projectDir);
|
|
231
|
+
reply(id, { content: [{ type: 'text', text: JSON.stringify(payload, null, 2) }] });
|
|
232
|
+
} catch (err) {
|
|
233
|
+
reply(id, { content: [{ type: 'text', text: String((err && err.message) || err) }], isError: true });
|
|
234
|
+
}
|
|
235
|
+
return;
|
|
236
|
+
}
|
|
237
|
+
default:
|
|
238
|
+
// Notifications (initialized, cancelled, …) get no response by spec.
|
|
239
|
+
if (isNotification) return;
|
|
240
|
+
replyError(id, E_METHOD_NOT_FOUND, `Method not found: ${method}`);
|
|
241
|
+
}
|
|
242
|
+
};
|
|
243
|
+
|
|
244
|
+
process.stderr.write(`docguard mcp v${_PKG.version} — serving ${TOOLS.length} tools on stdio (project: ${projectDir})\n`);
|
|
245
|
+
|
|
246
|
+
return new Promise((done) => {
|
|
247
|
+
const rl = createInterface({ input: process.stdin, terminal: false });
|
|
248
|
+
rl.on('line', (line) => {
|
|
249
|
+
const trimmed = line.trim();
|
|
250
|
+
if (!trimmed) return;
|
|
251
|
+
let msg;
|
|
252
|
+
try { msg = JSON.parse(trimmed); }
|
|
253
|
+
catch { replyError(null, E_PARSE, 'Parse error'); return; }
|
|
254
|
+
try { handleMessage(msg); }
|
|
255
|
+
catch (err) {
|
|
256
|
+
// Last-resort trap: a protocol-handler bug must not kill the server.
|
|
257
|
+
process.stderr.write(`docguard mcp: internal error: ${err && err.stack || err}\n`);
|
|
258
|
+
if (msg && msg.id !== undefined && msg.id !== null) replyError(msg.id, E_INTERNAL, 'Internal error');
|
|
259
|
+
}
|
|
260
|
+
});
|
|
261
|
+
rl.on('close', () => done());
|
|
262
|
+
});
|
|
263
|
+
}
|