docguard-cli 0.29.0 → 0.30.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +18 -1
- package/cli/commands/mcp.mjs +20 -0
- package/cli/commands/score.mjs +9 -4
- package/cli/commands/setup.mjs +0 -13
- package/cli/commands/trace.mjs +364 -1
- package/cli/commands/verify.mjs +93 -6
- package/cli/docguard.mjs +11 -3
- package/cli/findings.mjs +12 -0
- package/cli/scanners/instruction-audit.mjs +320 -0
- package/cli/scanners/speckit.mjs +346 -1
- package/cli/shared-git.mjs +8 -1
- 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 +11 -0
- package/templates/ci/gitlab-component.yml +90 -0
package/README.md
CHANGED
|
@@ -13,6 +13,8 @@
|
|
|
13
13
|
[](https://nodejs.org)
|
|
14
14
|
[-green)](package.json)
|
|
15
15
|
[](https://github.com/github/spec-kit)
|
|
16
|
+
[](https://glama.ai/mcp/servers/raccioly/docguard)
|
|
17
|
+
[](https://registry.modelcontextprotocol.io/)
|
|
16
18
|
|
|
17
19
|
---
|
|
18
20
|
|
|
@@ -132,6 +134,19 @@ docguard diagnose
|
|
|
132
134
|
|
|
133
135
|
> **Note:** The Python package is a thin wrapper that delegates to `npx`. Node.js 18+ is required on the system.
|
|
134
136
|
|
|
137
|
+
### More ways to integrate
|
|
138
|
+
|
|
139
|
+
- **pre-commit** — changed-only guard on every commit:
|
|
140
|
+
```yaml
|
|
141
|
+
repos:
|
|
142
|
+
- repo: https://github.com/raccioly/docguard
|
|
143
|
+
rev: v0.29.0
|
|
144
|
+
hooks: [{ id: docguard-guard }] # docguard-guard-full for pre-push
|
|
145
|
+
```
|
|
146
|
+
- **MCP** (Claude, Cursor, any MCP client) — `claude mcp add docguard -- npx -y docguard-cli mcp`; 5 read-only tools (guard, score, explain, verify-claims, diagnose). Registry manifest ships in-repo (`server.json`, Smithery-ready).
|
|
147
|
+
- **GitLab CI** — component staged at [`templates/ci/gitlab-component.yml`](templates/ci/gitlab-component.yml) (guard/score/ci job with a SARIF artifact).
|
|
148
|
+
- **Homebrew** — `brew install raccioly/tap/docguard` (formula in [`packaging/homebrew/`](packaging/homebrew/)).
|
|
149
|
+
|
|
135
150
|
### Core Workflow
|
|
136
151
|
|
|
137
152
|
```bash
|
|
@@ -253,10 +268,11 @@ DocGuard ships **18 commands** (the "Daily 5" + 13 situational tools, including
|
|
|
253
268
|
| `fix` | Generate AI fix instructions for specific docs (`--doc <name> --format prompt`) |
|
|
254
269
|
| `fix --write` | Apply deterministic fixes (no AI — version bumps, counts, anchors, sections) |
|
|
255
270
|
| `fix --history` | Audit log of every mechanical fix applied (from `.docguard/fixed.json`) |
|
|
256
|
-
| `generate` | Reverse-engineer docs from existing codebase (`--plan` for AI scan) |
|
|
271
|
+
| `generate` | Reverse-engineer docs from existing codebase (`--plan` for AI scan) — includes auto-generated Mermaid ER diagrams from your detected schemas (Prisma/Drizzle/TypeORM/Sequelize/Django/Rails) in DATA-MODEL.md |
|
|
257
272
|
| `agent` | One-shot agent task graph — ordered, pre-filled code-truth, per-task verify (`--format json`) |
|
|
258
273
|
| `explain <warning\|CODE>` | Paste any warning — or a finding code like `SEC001` — to get the validator's docstring, fix path, and how to suppress |
|
|
259
274
|
| `verify --semantic` | Extract documented numbers/limits/enums (retention days, rate limits, GSI/role counts, status enums) as a task list for an agent to check against code — the semantic-drift class regex/AST can't see |
|
|
275
|
+
| `verify --instructions` | Audit AGENTS.md/CLAUDE.md themselves for drift: duplicate rules, never-vs-always contradictions, stale file pointers, unknown commands — plus clustered rule pairs as agent judgment tasks |
|
|
260
276
|
| `feedback` | Report likely false positives back to DocGuard — local-first record + a 1-click prefilled, redacted GitHub issue (zero typing) |
|
|
261
277
|
| `mcp` | MCP server over stdio — exposes guard/score/explain/verify/diagnose as native tools for Claude, Cursor, and any MCP client. Setup: `claude mcp add docguard -- npx docguard-cli mcp` |
|
|
262
278
|
| `memory` | Per-domain accuracy headline (endpoints / entities / env / tech) |
|
|
@@ -264,6 +280,7 @@ DocGuard ships **18 commands** (the "Daily 5" + 13 situational tools, including
|
|
|
264
280
|
| `memory --pack` | Write `.docguard/context-pack.md` — compact, code-truth-stamped session-start context for AI agents |
|
|
265
281
|
| `score --diff` | Drill into which checks pulled each category down |
|
|
266
282
|
| `trace` / `trace --reverse <file>` | Requirements traceability — forward AND reverse |
|
|
283
|
+
| `trace --features` | Per-feature spec-adherence scores (requirement coverage, task completion, task evidence, artifacts) — worst-first with fix hints |
|
|
267
284
|
| `upgrade [--apply] [--pr]` | Check + migrate `.docguard.json` schema; `--pr` opens a PR |
|
|
268
285
|
| `watch` | Live mode: re-run guard on file changes |
|
|
269
286
|
|
package/cli/commands/mcp.mjs
CHANGED
|
@@ -53,25 +53,40 @@ const PROJECT_DIR_PROP = {
|
|
|
53
53
|
},
|
|
54
54
|
};
|
|
55
55
|
|
|
56
|
+
// Every DocGuard MCP tool is READ-ONLY: it inspects local project files and
|
|
57
|
+
// never writes, mutates, or reaches the network. These MCP tool hints let
|
|
58
|
+
// clients (and directory scanners like Glama) surface that safety to users.
|
|
59
|
+
const READONLY_ANNOTATIONS = {
|
|
60
|
+
readOnlyHint: true,
|
|
61
|
+
destructiveHint: false,
|
|
62
|
+
idempotentHint: true,
|
|
63
|
+
openWorldHint: false,
|
|
64
|
+
};
|
|
65
|
+
|
|
56
66
|
const TOOLS = [
|
|
57
67
|
{
|
|
58
68
|
name: 'docguard_guard',
|
|
69
|
+
title: 'Guard docs against code',
|
|
59
70
|
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
71
|
inputSchema: {
|
|
61
72
|
type: 'object',
|
|
62
73
|
properties: { ...PROJECT_DIR_PROP },
|
|
63
74
|
},
|
|
75
|
+
annotations: READONLY_ANNOTATIONS,
|
|
64
76
|
},
|
|
65
77
|
{
|
|
66
78
|
name: 'docguard_score',
|
|
79
|
+
title: 'CDD maturity score',
|
|
67
80
|
description: 'Compute the project\'s CDD maturity score (0-100) with letter grade and per-category breakdown.',
|
|
68
81
|
inputSchema: {
|
|
69
82
|
type: 'object',
|
|
70
83
|
properties: { ...PROJECT_DIR_PROP },
|
|
71
84
|
},
|
|
85
|
+
annotations: READONLY_ANNOTATIONS,
|
|
72
86
|
},
|
|
73
87
|
{
|
|
74
88
|
name: 'docguard_explain',
|
|
89
|
+
title: 'Explain a finding code',
|
|
75
90
|
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
91
|
inputSchema: {
|
|
77
92
|
type: 'object',
|
|
@@ -83,22 +98,27 @@ const TOOLS = [
|
|
|
83
98
|
},
|
|
84
99
|
required: ['code'],
|
|
85
100
|
},
|
|
101
|
+
annotations: READONLY_ANNOTATIONS,
|
|
86
102
|
},
|
|
87
103
|
{
|
|
88
104
|
name: 'docguard_verify_claims',
|
|
105
|
+
title: 'Extract claims to verify',
|
|
89
106
|
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
107
|
inputSchema: {
|
|
91
108
|
type: 'object',
|
|
92
109
|
properties: { ...PROJECT_DIR_PROP },
|
|
93
110
|
},
|
|
111
|
+
annotations: READONLY_ANNOTATIONS,
|
|
94
112
|
},
|
|
95
113
|
{
|
|
96
114
|
name: 'docguard_diagnose',
|
|
115
|
+
title: 'Diagnose what to fix',
|
|
97
116
|
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
117
|
inputSchema: {
|
|
99
118
|
type: 'object',
|
|
100
119
|
properties: { ...PROJECT_DIR_PROP },
|
|
101
120
|
},
|
|
121
|
+
annotations: READONLY_ANNOTATIONS,
|
|
102
122
|
},
|
|
103
123
|
];
|
|
104
124
|
|
package/cli/commands/score.mjs
CHANGED
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
|
|
6
6
|
import { existsSync, readFileSync, readdirSync, statSync } from 'node:fs';
|
|
7
7
|
import { resolve, join, extname } from 'node:path';
|
|
8
|
-
import {
|
|
8
|
+
import { execFileSync } 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';
|
|
@@ -1039,11 +1039,16 @@ function estimateDocTax(projectDir, config, scores) {
|
|
|
1039
1039
|
// Estimate code churn (commits in last 30 days)
|
|
1040
1040
|
let recentCommits = 0;
|
|
1041
1041
|
try {
|
|
1042
|
-
|
|
1042
|
+
// execFileSync (argv array) + count in JS — avoids the shell `| wc -l` pipe,
|
|
1043
|
+
// which isn't portable to Windows (no `wc`) and needs a shell at all. Same
|
|
1044
|
+
// pattern freshness.mjs already uses for commit counting.
|
|
1045
|
+
const output = execFileSync('git', ['log', '--oneline', '--since=30 days ago'], {
|
|
1043
1046
|
cwd: projectDir,
|
|
1044
1047
|
encoding: 'utf-8',
|
|
1045
|
-
|
|
1046
|
-
|
|
1048
|
+
stdio: ['pipe', 'pipe', 'ignore'],
|
|
1049
|
+
maxBuffer: 1024 * 1024 * 5,
|
|
1050
|
+
});
|
|
1051
|
+
recentCommits = output.trim() ? output.trim().split('\n').length : 0;
|
|
1047
1052
|
} catch {
|
|
1048
1053
|
recentCommits = 10; // Default assumption
|
|
1049
1054
|
}
|
package/cli/commands/setup.mjs
CHANGED
|
@@ -21,7 +21,6 @@ import { existsSync, mkdirSync, readFileSync, writeFileSync, readdirSync } from
|
|
|
21
21
|
import { resolve, dirname, basename } from 'node:path';
|
|
22
22
|
import { fileURLToPath } from 'node:url';
|
|
23
23
|
import { createInterface } from 'node:readline';
|
|
24
|
-
import { execSync } from 'node:child_process';
|
|
25
24
|
import { c, CURRENT_SCHEMA_VERSION } from '../shared.mjs';
|
|
26
25
|
import { ensureSkills, detectAgentMode, isSpecKitInitialized, getDetectedAgent } from '../ensure-skills.mjs';
|
|
27
26
|
|
|
@@ -70,18 +69,6 @@ function detectProjectType(dir) {
|
|
|
70
69
|
return 'unknown';
|
|
71
70
|
}
|
|
72
71
|
|
|
73
|
-
// ── CLI Detection ───────────────────────────────────────────────────────
|
|
74
|
-
|
|
75
|
-
function isCliAvailable(name) {
|
|
76
|
-
try {
|
|
77
|
-
const cmd = process.platform === 'win32' ? `where ${name}` : `which ${name}`;
|
|
78
|
-
execSync(`${cmd} 2>/dev/null`, { encoding: 'utf-8', timeout: 3000 });
|
|
79
|
-
return true;
|
|
80
|
-
} catch {
|
|
81
|
-
return false;
|
|
82
|
-
}
|
|
83
|
-
}
|
|
84
|
-
|
|
85
72
|
function detectAgentDirs(projectDir) {
|
|
86
73
|
const agentDirs = [
|
|
87
74
|
{ name: 'GitHub Copilot', dir: '.github', commandsPath: '.github/commands' },
|
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)));
|