docguard-cli 0.28.0 → 0.30.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.
Files changed (69) hide show
  1. package/README.es.md +102 -0
  2. package/README.md +80 -32
  3. package/README.pt-BR.md +101 -0
  4. package/STANDARD.md +20 -10
  5. package/cli/commands/agents.mjs +149 -0
  6. package/cli/commands/diff.mjs +6 -15
  7. package/cli/commands/generate.mjs +14 -1001
  8. package/cli/commands/guard.mjs +136 -8
  9. package/cli/commands/llms.mjs +67 -5
  10. package/cli/commands/mcp.mjs +263 -0
  11. package/cli/commands/memory.mjs +115 -0
  12. package/cli/commands/score.mjs +76 -12
  13. package/cli/commands/trace.mjs +364 -1
  14. package/cli/commands/verify.mjs +93 -6
  15. package/cli/docguard.mjs +42 -5
  16. package/cli/findings.mjs +511 -0
  17. package/cli/scanners/agent-readability.mjs +202 -0
  18. package/cli/scanners/instruction-audit.mjs +320 -0
  19. package/cli/scanners/semantic-claims.mjs +7 -1
  20. package/cli/scanners/speckit.mjs +443 -28
  21. package/cli/shared-ignore.mjs +148 -16
  22. package/cli/shared.mjs +45 -1
  23. package/cli/validators/api-surface.mjs +113 -26
  24. package/cli/validators/architecture.mjs +66 -43
  25. package/cli/validators/canonical-sync.mjs +59 -28
  26. package/cli/validators/changelog.mjs +41 -17
  27. package/cli/validators/cross-reference.mjs +28 -11
  28. package/cli/validators/doc-quality.mjs +78 -44
  29. package/cli/validators/docs-coverage.mjs +90 -63
  30. package/cli/validators/docs-diff.mjs +63 -64
  31. package/cli/validators/docs-sync.mjs +48 -33
  32. package/cli/validators/drift.mjs +40 -34
  33. package/cli/validators/environment.mjs +67 -27
  34. package/cli/validators/freshness.mjs +12 -5
  35. package/cli/validators/generated-staleness.mjs +26 -10
  36. package/cli/validators/metadata-sync.mjs +28 -25
  37. package/cli/validators/metrics-consistency.mjs +89 -47
  38. package/cli/validators/schema-sync.mjs +37 -32
  39. package/cli/validators/security.mjs +7 -20
  40. package/cli/validators/spec-kit.mjs +3 -0
  41. package/cli/validators/structure.mjs +58 -23
  42. package/cli/validators/surface-sync.mjs +34 -15
  43. package/cli/validators/test-spec.mjs +87 -29
  44. package/cli/validators/todo-tracking.mjs +83 -74
  45. package/cli/validators/traceability.mjs +67 -39
  46. package/cli/writers/doc-generators.mjs +853 -0
  47. package/cli/writers/generate-io.mjs +142 -0
  48. package/cli/writers/sarif.mjs +129 -0
  49. package/commands/docguard.fix.md +56 -53
  50. package/commands/docguard.guard.md +53 -47
  51. package/commands/docguard.review.md +49 -31
  52. package/docs/ai-integration.md +133 -134
  53. package/docs/commands.md +49 -3
  54. package/docs/configuration.md +38 -0
  55. package/docs/faq.md +15 -0
  56. package/extensions/spec-kit-docguard/extension.yml +1 -1
  57. package/extensions/spec-kit-docguard/skills/docguard-fix/SKILL.md +2 -2
  58. package/extensions/spec-kit-docguard/skills/docguard-guard/SKILL.md +2 -2
  59. package/extensions/spec-kit-docguard/skills/docguard-review/SKILL.md +2 -2
  60. package/extensions/spec-kit-docguard/skills/docguard-score/SKILL.md +2 -2
  61. package/extensions/spec-kit-docguard/skills/docguard-sync/SKILL.md +2 -2
  62. package/package.json +2 -1
  63. package/schemas/docguard-config.schema.json +28 -0
  64. package/templates/ci/gitlab-component.yml +90 -0
  65. package/templates/commands/docguard.fix.md +33 -10
  66. package/templates/commands/docguard.guard.md +40 -26
  67. package/templates/commands/docguard.init.md +23 -11
  68. package/templates/commands/docguard.review.md +25 -8
  69. package/templates/commands/docguard.update.md +14 -4
@@ -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 errors = [];
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
- else if (r.status === 'warn') warnings.push(r.message);
226
- else if (r.status === 'fail') errors.push(r.message);
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 { errors, warnings, passed, total: passed + warnings.length + errors.length };
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,6 +549,16 @@ 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
564
  // Use severity-aware effective counts for exit code; raw counts stay in the JSON
@@ -591,6 +690,35 @@ export function runGuard(projectDir, config, flags) {
591
690
  console.log(` ${c.dim}💡 Install ${c.cyan}/docguard.*${c.dim} commands for your agent: ${c.cyan}docguard init${c.reset}`);
592
691
  }
593
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
+
594
722
  // Badge snippet
595
723
  const pct = data.total > 0 ? Math.round((data.passed / data.total) * 100) : 0;
596
724
  const bColor = pct >= 90 ? 'brightgreen' : pct >= 70 ? 'green' : pct >= 50 ? 'yellow' : 'red';
@@ -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 file.
201
+ * Public command — generate llms.txt (or llms-full.txt with --full).
142
202
  */
143
203
  export function runLlms(projectDir, config, flags) {
144
- const content = generateLlmsTxt(projectDir, config);
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 outputPath = resolve(projectDir, 'llms.txt');
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 llms.txt Generator${c.reset}`);
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
+ }