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.
- package/README.es.md +102 -0
- package/README.md +80 -32
- 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/generate.mjs +14 -1001
- package/cli/commands/guard.mjs +136 -8
- 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/trace.mjs +364 -1
- package/cli/commands/verify.mjs +93 -6
- package/cli/docguard.mjs +42 -5
- package/cli/findings.mjs +511 -0
- package/cli/scanners/agent-readability.mjs +202 -0
- package/cli/scanners/instruction-audit.mjs +320 -0
- package/cli/scanners/semantic-claims.mjs +7 -1
- package/cli/scanners/speckit.mjs +443 -28
- package/cli/shared-ignore.mjs +148 -16
- package/cli/shared.mjs +45 -1
- package/cli/validators/api-surface.mjs +113 -26
- package/cli/validators/architecture.mjs +66 -43
- 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 +2 -1
- package/schemas/docguard-config.schema.json +28 -0
- package/templates/ci/gitlab-component.yml +90 -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;
|
package/cli/commands/trace.mjs
CHANGED
|
@@ -7,8 +7,9 @@
|
|
|
7
7
|
*/
|
|
8
8
|
|
|
9
9
|
import { existsSync, readFileSync, readdirSync, statSync } from 'node:fs';
|
|
10
|
-
import { resolve, join, extname, basename, relative } from 'node:path';
|
|
10
|
+
import { resolve, join, extname, basename, relative, dirname } from 'node:path';
|
|
11
11
|
import { c } from '../shared.mjs';
|
|
12
|
+
import { detectSpecKit } from '../scanners/speckit.mjs';
|
|
12
13
|
|
|
13
14
|
const IGNORE_DIRS = new Set([
|
|
14
15
|
'node_modules', '.git', '.next', 'dist', 'build', 'coverage',
|
|
@@ -134,6 +135,11 @@ export function runTrace(projectDir, config, flags) {
|
|
|
134
135
|
return runTraceReverse(projectDir, config, flags);
|
|
135
136
|
}
|
|
136
137
|
|
|
138
|
+
// Per-feature spec-kit adherence scoring when --features is set.
|
|
139
|
+
if (flags.features) {
|
|
140
|
+
return runTraceFeatures(projectDir, config, flags);
|
|
141
|
+
}
|
|
142
|
+
|
|
137
143
|
// v0.16-P1: same headless-mode pattern as guard/score. Reported by Python
|
|
138
144
|
// user — trace --format json was leaking ANSI escapes before the body.
|
|
139
145
|
const isJson = flags.format === 'json';
|
|
@@ -366,6 +372,363 @@ function scanDir(rootDir, dir, files) {
|
|
|
366
372
|
}
|
|
367
373
|
}
|
|
368
374
|
|
|
375
|
+
// ── Per-feature spec adherence (trace --features) ───────────────────────────
|
|
376
|
+
//
|
|
377
|
+
// Scores each detected spec-kit feature's implementation adherence
|
|
378
|
+
// individually (inspired by spec-kit-retrospective), instead of only the
|
|
379
|
+
// repo-wide scores that `docguard score` produces. Deterministic signals only —
|
|
380
|
+
// no LLM judgment:
|
|
381
|
+
//
|
|
382
|
+
// reqCoverage 40% FR-/SC- IDs in spec.md referenced by any test file
|
|
383
|
+
// taskCompletion 25% checked/total `- [x]` tasks in tasks.md
|
|
384
|
+
// taskEvidence 20% checked tasks whose line names an existing file
|
|
385
|
+
// artifactCompleteness 15% spec.md (40%) + plan.md (30%) + tasks.md (30%)
|
|
386
|
+
//
|
|
387
|
+
// A signal that cannot be measured (no tasks.md, no requirement IDs, no
|
|
388
|
+
// checked task names a parseable path) is NEUTRAL: excluded from the weighted
|
|
389
|
+
// sum and its weight redistributed across the measurable signals. This mirrors
|
|
390
|
+
// the traceability validator's "no requirement IDs → silently pass" stance —
|
|
391
|
+
// absence of a convention is not evidence of low adherence (missing artifacts
|
|
392
|
+
// are already priced in by artifactCompleteness).
|
|
393
|
+
|
|
394
|
+
const FEATURE_SIGNAL_WEIGHTS = {
|
|
395
|
+
reqCoverage: 0.40,
|
|
396
|
+
taskCompletion: 0.25,
|
|
397
|
+
taskEvidence: 0.20,
|
|
398
|
+
artifactCompleteness: 0.15,
|
|
399
|
+
};
|
|
400
|
+
|
|
401
|
+
// Grade bands mirrored from cli/scanners/agent-readability.mjs GRADES.
|
|
402
|
+
// Display-only — never feeds the gating CDD grade that CI thresholds read.
|
|
403
|
+
const FEATURE_GRADES = [[90, 'A'], [75, 'B'], [60, 'C'], [40, 'D']];
|
|
404
|
+
|
|
405
|
+
function featureGrade(score) {
|
|
406
|
+
for (const [min, g] of FEATURE_GRADES) if (score >= min) return g;
|
|
407
|
+
return 'F';
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
// Spec-kit requirement IDs scored per feature. Subset of the traceability
|
|
411
|
+
// validator's DEFAULT_REQ_PATTERNS (cli/validators/traceability.mjs) — the two
|
|
412
|
+
// ID families spec-kit's spec-template.md mandates.
|
|
413
|
+
const FEATURE_REQ_RE = /\b(?:FR|SC)-\d{2,4}\b/g;
|
|
414
|
+
|
|
415
|
+
// Path-token heuristic mirrored from cli/scanners/semantic-claims.mjs
|
|
416
|
+
// CITED_CODE_RE: a backticked or bare path-like token with a code extension.
|
|
417
|
+
const TASK_PATH_RE = /`?([\w./-]+\.(?:ts|tsx|js|mjs|cjs|jsx|py|go|rs|java|kt|rb|php|sql|yaml|yml|json))`?/g;
|
|
418
|
+
|
|
419
|
+
// 20-char bar mirrored from cli/commands/score.mjs renderBar (not exported
|
|
420
|
+
// there; score.mjs is display-conventions-only for this feature).
|
|
421
|
+
function featureBar(score) {
|
|
422
|
+
const filled = Math.round(score / 5);
|
|
423
|
+
const empty = 20 - filled;
|
|
424
|
+
const color = score >= 80 ? c.green : score >= 60 ? c.yellow : c.red;
|
|
425
|
+
return `${color}${'█'.repeat(filled)}${c.dim}${'░'.repeat(empty)}${c.reset}`;
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
/**
|
|
429
|
+
* Collect every FR-/SC- ID referenced anywhere in a test file, once for the
|
|
430
|
+
* whole project. Test-file discovery mirrors the traceability validator's
|
|
431
|
+
* scanTestFilesForReferences(): TEST_PATTERNS ∪ __tests__/ ∪ tests?/ dirs, and
|
|
432
|
+
* any occurrence of the ID in file content counts (not just @req lines).
|
|
433
|
+
*/
|
|
434
|
+
function collectTestReferencedIds(projectDir) {
|
|
435
|
+
const projectFiles = [];
|
|
436
|
+
scanDir(projectDir, projectDir, projectFiles);
|
|
437
|
+
const testFiles = projectFiles.filter(f =>
|
|
438
|
+
TEST_PATTERNS.some(p => p.test(f)) || /__tests__\//.test(f) || /tests?\//.test(f)
|
|
439
|
+
);
|
|
440
|
+
|
|
441
|
+
const ids = new Set();
|
|
442
|
+
for (const rel of testFiles) {
|
|
443
|
+
let content;
|
|
444
|
+
try { content = readFileSync(resolve(projectDir, rel), 'utf-8'); } catch { continue; }
|
|
445
|
+
FEATURE_REQ_RE.lastIndex = 0;
|
|
446
|
+
let m;
|
|
447
|
+
while ((m = FEATURE_REQ_RE.exec(content)) !== null) ids.add(m[0]);
|
|
448
|
+
}
|
|
449
|
+
return ids;
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
/**
|
|
453
|
+
* Compute the four adherence signals for one detected spec-kit feature.
|
|
454
|
+
* Each signal: { applicable, value (0..1 | null), ...n/m detail fields }.
|
|
455
|
+
*/
|
|
456
|
+
function computeFeatureSignals(projectDir, feature, testRefIds) {
|
|
457
|
+
// ── artifactCompleteness — always measurable ──
|
|
458
|
+
const artifactValue = (feature.hasSpec ? 0.4 : 0)
|
|
459
|
+
+ (feature.hasPlan ? 0.3 : 0)
|
|
460
|
+
+ (feature.hasTasks ? 0.3 : 0);
|
|
461
|
+
|
|
462
|
+
// ── taskCompletion + taskEvidence — parse tasks.md checklist lines ──
|
|
463
|
+
let totalTasks = 0, checkedTasks = 0, evidenced = 0, considered = 0;
|
|
464
|
+
if (feature.hasTasks && feature.tasksPath) {
|
|
465
|
+
let content = null;
|
|
466
|
+
try { content = readFileSync(feature.tasksPath, 'utf-8'); } catch { /* unreadable → no tasks */ }
|
|
467
|
+
if (content !== null) {
|
|
468
|
+
for (const line of content.split('\n')) {
|
|
469
|
+
const box = /^\s*[-*]\s*\[([ xX])\]/.exec(line);
|
|
470
|
+
if (!box) continue;
|
|
471
|
+
totalTasks++;
|
|
472
|
+
if (box[1] === ' ') continue;
|
|
473
|
+
checkedTasks++;
|
|
474
|
+
// Evidence: any named path on the line that exists in the project.
|
|
475
|
+
// Checked tasks with no parseable path are neutral (skip denominator).
|
|
476
|
+
TASK_PATH_RE.lastIndex = 0;
|
|
477
|
+
let tok, sawToken = false, exists = false;
|
|
478
|
+
while ((tok = TASK_PATH_RE.exec(line)) !== null) {
|
|
479
|
+
sawToken = true;
|
|
480
|
+
if (existsSync(resolve(projectDir, tok[1].replace(/^\.\//, '')))) { exists = true; break; }
|
|
481
|
+
}
|
|
482
|
+
if (sawToken) { considered++; if (exists) evidenced++; }
|
|
483
|
+
}
|
|
484
|
+
}
|
|
485
|
+
}
|
|
486
|
+
|
|
487
|
+
// ── reqCoverage — spec.md IDs that appear in ANY test file ──
|
|
488
|
+
const specIds = [];
|
|
489
|
+
if (feature.hasSpec && feature.specPath) {
|
|
490
|
+
try {
|
|
491
|
+
const spec = readFileSync(feature.specPath, 'utf-8');
|
|
492
|
+
const seen = new Set();
|
|
493
|
+
FEATURE_REQ_RE.lastIndex = 0;
|
|
494
|
+
let m;
|
|
495
|
+
while ((m = FEATURE_REQ_RE.exec(spec)) !== null) {
|
|
496
|
+
if (!seen.has(m[0])) { seen.add(m[0]); specIds.push(m[0]); }
|
|
497
|
+
}
|
|
498
|
+
} catch { /* unreadable spec → no IDs */ }
|
|
499
|
+
}
|
|
500
|
+
const covered = specIds.filter(id => testRefIds.has(id));
|
|
501
|
+
const uncovered = specIds.filter(id => !testRefIds.has(id));
|
|
502
|
+
|
|
503
|
+
return {
|
|
504
|
+
reqCoverage: {
|
|
505
|
+
applicable: specIds.length > 0,
|
|
506
|
+
value: specIds.length > 0 ? covered.length / specIds.length : null,
|
|
507
|
+
covered: covered.length,
|
|
508
|
+
total: specIds.length,
|
|
509
|
+
uncovered,
|
|
510
|
+
},
|
|
511
|
+
taskCompletion: {
|
|
512
|
+
applicable: totalTasks > 0,
|
|
513
|
+
value: totalTasks > 0 ? checkedTasks / totalTasks : null,
|
|
514
|
+
checked: checkedTasks,
|
|
515
|
+
total: totalTasks,
|
|
516
|
+
},
|
|
517
|
+
taskEvidence: {
|
|
518
|
+
applicable: considered > 0,
|
|
519
|
+
value: considered > 0 ? evidenced / considered : null,
|
|
520
|
+
evidenced,
|
|
521
|
+
considered,
|
|
522
|
+
},
|
|
523
|
+
artifactCompleteness: {
|
|
524
|
+
applicable: true,
|
|
525
|
+
value: artifactValue,
|
|
526
|
+
spec: feature.hasSpec,
|
|
527
|
+
plan: feature.hasPlan,
|
|
528
|
+
tasks: feature.hasTasks,
|
|
529
|
+
},
|
|
530
|
+
};
|
|
531
|
+
}
|
|
532
|
+
|
|
533
|
+
/** Weighted 0–100 score over the applicable signals (weights renormalized). */
|
|
534
|
+
function scoreFromSignals(signals) {
|
|
535
|
+
let weighted = 0, weightTotal = 0;
|
|
536
|
+
for (const [key, weight] of Object.entries(FEATURE_SIGNAL_WEIGHTS)) {
|
|
537
|
+
const s = signals[key];
|
|
538
|
+
if (!s.applicable) continue;
|
|
539
|
+
weighted += weight * s.value;
|
|
540
|
+
weightTotal += weight;
|
|
541
|
+
}
|
|
542
|
+
return weightTotal > 0 ? Math.round((weighted / weightTotal) * 100) : 0;
|
|
543
|
+
}
|
|
544
|
+
|
|
545
|
+
/**
|
|
546
|
+
* The lowest-valued applicable signal. Iteration order is descending weight,
|
|
547
|
+
* and replacement is strict-less-than, so ties resolve to the highest-impact
|
|
548
|
+
* signal — the one worth fixing first.
|
|
549
|
+
*/
|
|
550
|
+
function weakestSignal(signals) {
|
|
551
|
+
let worstKey = null;
|
|
552
|
+
for (const key of Object.keys(FEATURE_SIGNAL_WEIGHTS)) {
|
|
553
|
+
const s = signals[key];
|
|
554
|
+
if (!s.applicable) continue;
|
|
555
|
+
if (worstKey === null || s.value < signals[worstKey].value) worstKey = key;
|
|
556
|
+
}
|
|
557
|
+
return worstKey;
|
|
558
|
+
}
|
|
559
|
+
|
|
560
|
+
function fixHintFor(key, s, feature) {
|
|
561
|
+
switch (key) {
|
|
562
|
+
case 'reqCoverage':
|
|
563
|
+
return `Cover the untested spec IDs (e.g. ${s.uncovered[0]}) — reference them from tests via @req annotations`;
|
|
564
|
+
case 'taskCompletion':
|
|
565
|
+
return `Complete (or prune) the ${s.total - s.checked} unchecked task(s) in tasks.md`;
|
|
566
|
+
case 'taskEvidence':
|
|
567
|
+
return `${s.considered - s.evidenced} checked task(s) name files that don't exist — fix stale paths or uncheck them`;
|
|
568
|
+
case 'artifactCompleteness': {
|
|
569
|
+
const missing = [
|
|
570
|
+
feature.hasSpec ? null : 'spec.md',
|
|
571
|
+
feature.hasPlan ? null : 'plan.md',
|
|
572
|
+
feature.hasTasks ? null : 'tasks.md',
|
|
573
|
+
].filter(Boolean);
|
|
574
|
+
return `Add ${missing.join(', ')} to complete the artifact set`;
|
|
575
|
+
}
|
|
576
|
+
default:
|
|
577
|
+
return null;
|
|
578
|
+
}
|
|
579
|
+
}
|
|
580
|
+
|
|
581
|
+
/**
|
|
582
|
+
* `docguard trace --features` — per-feature spec-kit adherence report.
|
|
583
|
+
* Reuses detectSpecKit() for feature discovery (no re-implementation).
|
|
584
|
+
*/
|
|
585
|
+
export function runTraceFeatures(projectDir, config, flags) {
|
|
586
|
+
const isJson = flags.format === 'json';
|
|
587
|
+
if (!isJson) {
|
|
588
|
+
console.log(`${c.bold}🎯 DocGuard Trace (features) — ${config.projectName}${c.reset}`);
|
|
589
|
+
console.log(`${c.dim} Scoring per-feature spec adherence (spec-kit)...${c.reset}\n`);
|
|
590
|
+
}
|
|
591
|
+
|
|
592
|
+
const speckit = detectSpecKit(projectDir);
|
|
593
|
+
if (!speckit.detected || speckit.specs.length === 0) {
|
|
594
|
+
// Same empty-state contract as trace --reverse: JSON stays parseable with
|
|
595
|
+
// an `error` field; text gets an actionable pointer.
|
|
596
|
+
if (isJson) {
|
|
597
|
+
console.log(JSON.stringify({
|
|
598
|
+
features: [],
|
|
599
|
+
summary: { features: 0, avgScore: null, worst: null },
|
|
600
|
+
error: 'no spec-kit features detected',
|
|
601
|
+
timestamp: new Date().toISOString(),
|
|
602
|
+
}, null, 2));
|
|
603
|
+
} else {
|
|
604
|
+
console.log(` ${c.yellow}No spec-kit features detected.${c.reset}`);
|
|
605
|
+
console.log(` ${c.dim}Feature scoring needs .specify/specs/** or specs/** (spec.md/plan.md/tasks.md). Run \`specify init\` to start.${c.reset}`);
|
|
606
|
+
}
|
|
607
|
+
return;
|
|
608
|
+
}
|
|
609
|
+
|
|
610
|
+
const testRefIds = collectTestReferencedIds(projectDir);
|
|
611
|
+
|
|
612
|
+
const features = speckit.specs.map(f => {
|
|
613
|
+
const signals = computeFeatureSignals(projectDir, f, testRefIds);
|
|
614
|
+
const score = scoreFromSignals(signals);
|
|
615
|
+
const weakest = weakestSignal(signals);
|
|
616
|
+
const needsFix = weakest !== null && signals[weakest].value < 1;
|
|
617
|
+
return {
|
|
618
|
+
name: f.name,
|
|
619
|
+
dir: relative(projectDir, dirname(f.specPath || f.planPath || f.tasksPath)),
|
|
620
|
+
score,
|
|
621
|
+
grade: featureGrade(score),
|
|
622
|
+
signals,
|
|
623
|
+
weakest,
|
|
624
|
+
fixHint: needsFix ? fixHintFor(weakest, signals[weakest], f) : null,
|
|
625
|
+
};
|
|
626
|
+
});
|
|
627
|
+
|
|
628
|
+
// Worst-first — act on the weakest feature. Name tie-break for determinism.
|
|
629
|
+
features.sort((a, b) => a.score - b.score || a.name.localeCompare(b.name));
|
|
630
|
+
|
|
631
|
+
const avgScore = Math.round(features.reduce((sum, f) => sum + f.score, 0) / features.length);
|
|
632
|
+
const summary = {
|
|
633
|
+
features: features.length,
|
|
634
|
+
avgScore,
|
|
635
|
+
worst: { name: features[0].name, score: features[0].score },
|
|
636
|
+
};
|
|
637
|
+
|
|
638
|
+
if (isJson) {
|
|
639
|
+
outputFeaturesJSON(features, summary);
|
|
640
|
+
} else {
|
|
641
|
+
outputFeaturesText(features, summary);
|
|
642
|
+
}
|
|
643
|
+
}
|
|
644
|
+
|
|
645
|
+
function pctOrNull(signal) {
|
|
646
|
+
return signal.applicable ? Math.round(signal.value * 100) : null;
|
|
647
|
+
}
|
|
648
|
+
|
|
649
|
+
function outputFeaturesJSON(features, summary) {
|
|
650
|
+
console.log(JSON.stringify({
|
|
651
|
+
features: features.map(f => ({
|
|
652
|
+
name: f.name,
|
|
653
|
+
dir: f.dir,
|
|
654
|
+
score: f.score,
|
|
655
|
+
grade: f.grade,
|
|
656
|
+
signals: {
|
|
657
|
+
reqCoverage: {
|
|
658
|
+
pct: pctOrNull(f.signals.reqCoverage),
|
|
659
|
+
covered: f.signals.reqCoverage.covered,
|
|
660
|
+
total: f.signals.reqCoverage.total,
|
|
661
|
+
uncovered: f.signals.reqCoverage.uncovered,
|
|
662
|
+
},
|
|
663
|
+
taskCompletion: {
|
|
664
|
+
pct: pctOrNull(f.signals.taskCompletion),
|
|
665
|
+
checked: f.signals.taskCompletion.checked,
|
|
666
|
+
total: f.signals.taskCompletion.total,
|
|
667
|
+
},
|
|
668
|
+
taskEvidence: {
|
|
669
|
+
pct: pctOrNull(f.signals.taskEvidence),
|
|
670
|
+
evidenced: f.signals.taskEvidence.evidenced,
|
|
671
|
+
considered: f.signals.taskEvidence.considered,
|
|
672
|
+
},
|
|
673
|
+
artifactCompleteness: {
|
|
674
|
+
pct: pctOrNull(f.signals.artifactCompleteness),
|
|
675
|
+
spec: f.signals.artifactCompleteness.spec,
|
|
676
|
+
plan: f.signals.artifactCompleteness.plan,
|
|
677
|
+
tasks: f.signals.artifactCompleteness.tasks,
|
|
678
|
+
},
|
|
679
|
+
},
|
|
680
|
+
weakest: f.weakest,
|
|
681
|
+
fixHint: f.fixHint,
|
|
682
|
+
})),
|
|
683
|
+
summary,
|
|
684
|
+
timestamp: new Date().toISOString(),
|
|
685
|
+
}, null, 2));
|
|
686
|
+
}
|
|
687
|
+
|
|
688
|
+
function outputFeaturesText(features, summary) {
|
|
689
|
+
console.log(` ${c.bold}Feature Adherence${c.reset} ${c.dim}(worst first)${c.reset}\n`);
|
|
690
|
+
|
|
691
|
+
for (const f of features) {
|
|
692
|
+
const gradeColor = f.score >= 80 ? c.green : f.score >= 60 ? c.yellow : c.red;
|
|
693
|
+
console.log(` 📦 ${c.bold}${f.name}${c.reset} — ${gradeColor}${f.score}/100 (${f.grade})${c.reset} ${featureBar(f.score)}`);
|
|
694
|
+
console.log(` ${c.dim}${f.dir}${c.reset}`);
|
|
695
|
+
|
|
696
|
+
const sig = f.signals;
|
|
697
|
+
const line = (label, weightPct, s, detail) => {
|
|
698
|
+
const pct = s.applicable ? `${Math.round(s.value * 100)}%`.padEnd(4) : 'n/a ';
|
|
699
|
+
const color = !s.applicable ? c.dim : s.value >= 0.8 ? c.green : s.value >= 0.5 ? c.yellow : c.red;
|
|
700
|
+
console.log(` ${color}${pct}${c.reset} ${label.padEnd(22)} ${c.dim}${detail} · weight ${weightPct}%${c.reset}`);
|
|
701
|
+
};
|
|
702
|
+
|
|
703
|
+
line('Requirement coverage', 40, sig.reqCoverage,
|
|
704
|
+
sig.reqCoverage.applicable
|
|
705
|
+
? `${sig.reqCoverage.covered}/${sig.reqCoverage.total} spec IDs referenced by tests`
|
|
706
|
+
: 'no FR-/SC- IDs in spec.md');
|
|
707
|
+
line('Task completion', 25, sig.taskCompletion,
|
|
708
|
+
sig.taskCompletion.applicable
|
|
709
|
+
? `${sig.taskCompletion.checked}/${sig.taskCompletion.total} tasks checked`
|
|
710
|
+
: 'no tasks.md checklist');
|
|
711
|
+
line('Task evidence', 20, sig.taskEvidence,
|
|
712
|
+
sig.taskEvidence.applicable
|
|
713
|
+
? `${sig.taskEvidence.evidenced}/${sig.taskEvidence.considered} checked tasks name existing files`
|
|
714
|
+
: 'no checked task names a file path');
|
|
715
|
+
line('Artifacts', 15, sig.artifactCompleteness,
|
|
716
|
+
`${[sig.artifactCompleteness.spec, sig.artifactCompleteness.plan, sig.artifactCompleteness.tasks].filter(Boolean).length}/3 ` +
|
|
717
|
+
`(spec ${sig.artifactCompleteness.spec ? '✓' : '✗'} · plan ${sig.artifactCompleteness.plan ? '✓' : '✗'} · tasks ${sig.artifactCompleteness.tasks ? '✓' : '✗'})`);
|
|
718
|
+
|
|
719
|
+
if (f.fixHint) {
|
|
720
|
+
console.log(` ${c.yellow}⚠ Fix first:${c.reset} ${f.fixHint}`);
|
|
721
|
+
} else {
|
|
722
|
+
console.log(` ${c.green}✓ No weak signal — all applicable signals at 100%${c.reset}`);
|
|
723
|
+
}
|
|
724
|
+
console.log('');
|
|
725
|
+
}
|
|
726
|
+
|
|
727
|
+
console.log(` ${c.bold}─────────────────────────────────────${c.reset}`);
|
|
728
|
+
console.log(` ${summary.features} feature(s) · avg ${summary.avgScore}/100 · worst: ${c.red}${summary.worst.name} (${summary.worst.score}/100)${c.reset}`);
|
|
729
|
+
console.log(`\n ${c.dim}Signals are deterministic (checklist, ID-to-test references, file existence) — adherence of intent, not correctness.${c.reset}\n`);
|
|
730
|
+
}
|
|
731
|
+
|
|
369
732
|
function findRelatedTests(projectFiles, sourcePatterns) {
|
|
370
733
|
// Find test files that might cover the source patterns
|
|
371
734
|
const testFiles = projectFiles.filter(f => TEST_PATTERNS.some(p => p.test(f)));
|