docguard-cli 0.5.2 → 0.7.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/PHILOSOPHY.md +22 -0
- package/README.md +13 -0
- package/cli/commands/diagnose.mjs +224 -33
- package/cli/commands/generate.mjs +501 -87
- package/cli/commands/guard.mjs +23 -6
- package/cli/commands/publish.mjs +246 -0
- package/cli/commands/score.mjs +31 -0
- package/cli/commands/trace.mjs +311 -0
- package/cli/docguard.mjs +31 -3
- package/cli/scanners/doc-tools.mjs +351 -0
- package/cli/scanners/routes.mjs +461 -0
- package/cli/scanners/schemas.mjs +567 -0
- package/package.json +1 -1
package/cli/commands/guard.mjs
CHANGED
|
@@ -52,7 +52,7 @@ export function runGuardInternal(projectDir, config) {
|
|
|
52
52
|
|
|
53
53
|
for (const { key, name, fn } of validatorMap) {
|
|
54
54
|
if (validators[key] === false) {
|
|
55
|
-
results.push({ name, key, status: 'skipped', errors: [], warnings: [], passed: 0, total: 0 });
|
|
55
|
+
results.push({ name, key, status: 'skipped', quality: null, errors: [], warnings: [], passed: 0, total: 0 });
|
|
56
56
|
continue;
|
|
57
57
|
}
|
|
58
58
|
|
|
@@ -61,9 +61,22 @@ export function runGuardInternal(projectDir, config) {
|
|
|
61
61
|
const hasErrors = result.errors.length > 0;
|
|
62
62
|
const hasWarnings = result.warnings.length > 0;
|
|
63
63
|
const status = hasErrors ? 'fail' : hasWarnings ? 'warn' : 'pass';
|
|
64
|
-
|
|
64
|
+
|
|
65
|
+
// Quality label: HIGH/MEDIUM/LOW (inspired by CJE quality stratification, Lopez et al. TRACE 2026)
|
|
66
|
+
let quality;
|
|
67
|
+
if (hasErrors) {
|
|
68
|
+
quality = 'LOW';
|
|
69
|
+
} else if (hasWarnings) {
|
|
70
|
+
quality = 'MEDIUM';
|
|
71
|
+
} else {
|
|
72
|
+
// Pass — check coverage ratio for HIGH vs MEDIUM
|
|
73
|
+
const ratio = result.total > 0 ? result.passed / result.total : 1;
|
|
74
|
+
quality = ratio >= 0.9 ? 'HIGH' : 'MEDIUM';
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
results.push({ ...result, name, key, status, quality });
|
|
65
78
|
} catch (err) {
|
|
66
|
-
results.push({ name, key, status: 'fail', errors: [err.message], warnings: [], passed: 0, total: 1 });
|
|
79
|
+
results.push({ name, key, status: 'fail', quality: 'LOW', errors: [err.message], warnings: [], passed: 0, total: 1 });
|
|
67
80
|
}
|
|
68
81
|
}
|
|
69
82
|
|
|
@@ -114,12 +127,16 @@ export function runGuard(projectDir, config, flags) {
|
|
|
114
127
|
continue;
|
|
115
128
|
}
|
|
116
129
|
|
|
130
|
+
// Quality label badge
|
|
131
|
+
const qColor = v.quality === 'HIGH' ? c.green : v.quality === 'MEDIUM' ? c.yellow : c.red;
|
|
132
|
+
const qBadge = `${qColor}[${v.quality}]${c.reset}`;
|
|
133
|
+
|
|
117
134
|
if (v.status === 'pass') {
|
|
118
|
-
console.log(` ${c.green}✅ ${v.name}${c.reset}${c.dim}
|
|
135
|
+
console.log(` ${c.green}✅ ${v.name}${c.reset} ${qBadge}${c.dim} ${v.passed}/${v.total} checks passed${c.reset}`);
|
|
119
136
|
} else if (v.status === 'fail') {
|
|
120
|
-
console.log(` ${c.red}❌ ${v.name}${c.reset}${c.dim}
|
|
137
|
+
console.log(` ${c.red}❌ ${v.name}${c.reset} ${qBadge}${c.dim} ${v.passed}/${v.total} checks passed${c.reset}`);
|
|
121
138
|
} else {
|
|
122
|
-
console.log(` ${c.yellow}⚠️ ${v.name}${c.reset}${c.dim}
|
|
139
|
+
console.log(` ${c.yellow}⚠️ ${v.name}${c.reset} ${qBadge}${c.dim} ${v.passed}/${v.total} checks passed${c.reset}`);
|
|
123
140
|
}
|
|
124
141
|
|
|
125
142
|
if (flags.verbose || v.status === 'fail') {
|
|
@@ -0,0 +1,246 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* DocGuard Publish Command
|
|
3
|
+
* Scaffolds documentation publishing config for external doc platforms.
|
|
4
|
+
* Currently supports: Mintlify
|
|
5
|
+
*
|
|
6
|
+
* Usage: docguard publish --platform mintlify [--dir .]
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { existsSync, readFileSync, writeFileSync, mkdirSync } from 'node:fs';
|
|
10
|
+
import { resolve, basename } from 'node:path';
|
|
11
|
+
import { c } from '../docguard.mjs';
|
|
12
|
+
|
|
13
|
+
const SUPPORTED_PLATFORMS = ['mintlify'];
|
|
14
|
+
|
|
15
|
+
export function runPublish(projectDir, config, flags) {
|
|
16
|
+
const platform = flags.platform || 'mintlify';
|
|
17
|
+
|
|
18
|
+
if (!SUPPORTED_PLATFORMS.includes(platform)) {
|
|
19
|
+
console.error(`${c.red}✗ Unsupported platform: ${platform}${c.reset}`);
|
|
20
|
+
console.log(` Supported: ${SUPPORTED_PLATFORMS.join(', ')}`);
|
|
21
|
+
process.exit(1);
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
console.log(`${c.bold}📚 DocGuard Publish — ${platform}${c.reset}`);
|
|
25
|
+
console.log(`${c.dim} Scaffolding ${platform} docs from canonical documentation...${c.reset}\n`);
|
|
26
|
+
|
|
27
|
+
switch (platform) {
|
|
28
|
+
case 'mintlify':
|
|
29
|
+
scaffoldMintlify(projectDir, config, flags);
|
|
30
|
+
break;
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function scaffoldMintlify(dir, config, flags) {
|
|
35
|
+
const docsDir = resolve(dir, 'docs');
|
|
36
|
+
|
|
37
|
+
// Check for existing Mintlify setup
|
|
38
|
+
if (existsSync(resolve(dir, 'docs.json')) && !flags.force) {
|
|
39
|
+
console.log(` ${c.yellow}⚠️ docs.json already exists.${c.reset} Use --force to overwrite.`);
|
|
40
|
+
return;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
// Create docs directory
|
|
44
|
+
if (!existsSync(docsDir)) {
|
|
45
|
+
mkdirSync(docsDir, { recursive: true });
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
let created = 0;
|
|
49
|
+
|
|
50
|
+
// ── 1. Generate docs.json (Mintlify v2 config) ──
|
|
51
|
+
const docsJson = {
|
|
52
|
+
"$schema": "https://mintlify.com/docs.json",
|
|
53
|
+
name: config.projectName || basename(dir),
|
|
54
|
+
logo: {
|
|
55
|
+
dark: "/logo/dark.svg",
|
|
56
|
+
light: "/logo/light.svg",
|
|
57
|
+
},
|
|
58
|
+
favicon: "/favicon.svg",
|
|
59
|
+
colors: {
|
|
60
|
+
primary: "#0D9373",
|
|
61
|
+
light: "#07C983",
|
|
62
|
+
dark: "#0D9373",
|
|
63
|
+
},
|
|
64
|
+
topbarLinks: [
|
|
65
|
+
{
|
|
66
|
+
name: "GitHub",
|
|
67
|
+
url: `https://github.com/${config.repository || 'your-org/your-repo'}`,
|
|
68
|
+
},
|
|
69
|
+
],
|
|
70
|
+
topbarCtaButton: {
|
|
71
|
+
name: "Get Started",
|
|
72
|
+
url: "/quickstart",
|
|
73
|
+
},
|
|
74
|
+
tabs: [
|
|
75
|
+
{
|
|
76
|
+
name: "Architecture",
|
|
77
|
+
url: "architecture",
|
|
78
|
+
},
|
|
79
|
+
{
|
|
80
|
+
name: "API Reference",
|
|
81
|
+
url: "api-reference",
|
|
82
|
+
},
|
|
83
|
+
],
|
|
84
|
+
navigation: buildMintlifyNavigation(dir),
|
|
85
|
+
footerSocials: {
|
|
86
|
+
github: `https://github.com/${config.repository || 'your-org/your-repo'}`,
|
|
87
|
+
},
|
|
88
|
+
};
|
|
89
|
+
|
|
90
|
+
writeFileSync(resolve(dir, 'docs.json'), JSON.stringify(docsJson, null, 2), 'utf-8');
|
|
91
|
+
console.log(` ${c.green}✅ docs.json${c.reset} (Mintlify v2 config)`);
|
|
92
|
+
created++;
|
|
93
|
+
|
|
94
|
+
// ── 2. Generate introduction.mdx ──
|
|
95
|
+
const readmePath = resolve(dir, 'README.md');
|
|
96
|
+
let readmeContent = '';
|
|
97
|
+
if (existsSync(readmePath)) {
|
|
98
|
+
readmeContent = readFileSync(readmePath, 'utf-8');
|
|
99
|
+
// Extract first paragraph for description
|
|
100
|
+
const firstPara = readmeContent.split('\n\n').slice(1, 3).join('\n\n');
|
|
101
|
+
readmeContent = firstPara;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
const introContent = `---
|
|
105
|
+
title: Introduction
|
|
106
|
+
description: "${config.projectName} documentation"
|
|
107
|
+
---
|
|
108
|
+
|
|
109
|
+
# ${config.projectName}
|
|
110
|
+
|
|
111
|
+
${readmeContent || `Welcome to the ${config.projectName} documentation.`}
|
|
112
|
+
|
|
113
|
+
## Quick Links
|
|
114
|
+
|
|
115
|
+
<CardGroup cols={2}>
|
|
116
|
+
<Card title="Quick Start" icon="rocket" href="/quickstart">
|
|
117
|
+
Get up and running in 5 minutes
|
|
118
|
+
</Card>
|
|
119
|
+
<Card title="Architecture" icon="building" href="/architecture">
|
|
120
|
+
Understand the system design
|
|
121
|
+
</Card>
|
|
122
|
+
<Card title="API Reference" icon="code" href="/api-reference">
|
|
123
|
+
Explore the API endpoints
|
|
124
|
+
</Card>
|
|
125
|
+
<Card title="Data Model" icon="database" href="/data-model">
|
|
126
|
+
Learn about the data structure
|
|
127
|
+
</Card>
|
|
128
|
+
</CardGroup>
|
|
129
|
+
`;
|
|
130
|
+
|
|
131
|
+
writeFileSync(resolve(docsDir, 'introduction.mdx'), introContent, 'utf-8');
|
|
132
|
+
console.log(` ${c.green}✅ docs/introduction.mdx${c.reset}`);
|
|
133
|
+
created++;
|
|
134
|
+
|
|
135
|
+
// ── 3. Generate quickstart.mdx ──
|
|
136
|
+
const quickstartContent = `---
|
|
137
|
+
title: Quick Start
|
|
138
|
+
description: "Get started with ${config.projectName}"
|
|
139
|
+
---
|
|
140
|
+
|
|
141
|
+
# Quick Start
|
|
142
|
+
|
|
143
|
+
## Prerequisites
|
|
144
|
+
|
|
145
|
+
- Node.js 18+
|
|
146
|
+
- npm or pnpm
|
|
147
|
+
|
|
148
|
+
## Installation
|
|
149
|
+
|
|
150
|
+
\`\`\`bash
|
|
151
|
+
npm install
|
|
152
|
+
\`\`\`
|
|
153
|
+
|
|
154
|
+
## Setup
|
|
155
|
+
|
|
156
|
+
\`\`\`bash
|
|
157
|
+
cp .env.example .env.local
|
|
158
|
+
# Fill in environment variables
|
|
159
|
+
npm run dev
|
|
160
|
+
\`\`\`
|
|
161
|
+
|
|
162
|
+
## Verify
|
|
163
|
+
|
|
164
|
+
\`\`\`bash
|
|
165
|
+
npx docguard-cli guard # Check documentation compliance
|
|
166
|
+
npx docguard-cli score # View CDD maturity score
|
|
167
|
+
\`\`\`
|
|
168
|
+
`;
|
|
169
|
+
|
|
170
|
+
writeFileSync(resolve(docsDir, 'quickstart.mdx'), quickstartContent, 'utf-8');
|
|
171
|
+
console.log(` ${c.green}✅ docs/quickstart.mdx${c.reset}`);
|
|
172
|
+
created++;
|
|
173
|
+
|
|
174
|
+
// ── 4. Map canonical docs to Mintlify pages ──
|
|
175
|
+
const canonicalDir = resolve(dir, 'docs-canonical');
|
|
176
|
+
const mappings = [
|
|
177
|
+
{ source: 'ARCHITECTURE.md', target: 'architecture.mdx', title: 'Architecture' },
|
|
178
|
+
{ source: 'API-REFERENCE.md', target: 'api-reference.mdx', title: 'API Reference' },
|
|
179
|
+
{ source: 'DATA-MODEL.md', target: 'data-model.mdx', title: 'Data Model' },
|
|
180
|
+
{ source: 'SECURITY.md', target: 'security.mdx', title: 'Security' },
|
|
181
|
+
{ source: 'ENVIRONMENT.md', target: 'environment.mdx', title: 'Environment' },
|
|
182
|
+
{ source: 'TEST-SPEC.md', target: 'test-spec.mdx', title: 'Test Specification' },
|
|
183
|
+
];
|
|
184
|
+
|
|
185
|
+
for (const mapping of mappings) {
|
|
186
|
+
const sourcePath = resolve(canonicalDir, mapping.source);
|
|
187
|
+
if (existsSync(sourcePath)) {
|
|
188
|
+
let content = readFileSync(sourcePath, 'utf-8');
|
|
189
|
+
|
|
190
|
+
// Add Mintlify frontmatter
|
|
191
|
+
const frontmatter = `---\ntitle: "${mapping.title}"\ndescription: "${config.projectName} ${mapping.title.toLowerCase()}"\n---\n\n`;
|
|
192
|
+
|
|
193
|
+
// Remove docguard metadata comments
|
|
194
|
+
content = content.replace(/<!--\s*docguard:\w+\s+[^>]+\s*-->\n?/g, '');
|
|
195
|
+
content = content.replace(/> \*\*Auto-generated by DocGuard\.\*\*[^\n]*\n?/g, '');
|
|
196
|
+
|
|
197
|
+
writeFileSync(resolve(docsDir, mapping.target), frontmatter + content, 'utf-8');
|
|
198
|
+
console.log(` ${c.green}✅ docs/${mapping.target}${c.reset} ← ${mapping.source}`);
|
|
199
|
+
created++;
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
// ── Summary ──
|
|
204
|
+
console.log(`\n${c.bold} ─────────────────────────────────────────${c.reset}`);
|
|
205
|
+
console.log(` ${c.green}Created: ${created} files${c.reset}`);
|
|
206
|
+
console.log(`\n ${c.bold}Next steps:${c.reset}`);
|
|
207
|
+
console.log(` ${c.cyan}1.${c.reset} Install Mintlify: ${c.dim}npm install -g mintlify${c.reset}`);
|
|
208
|
+
console.log(` ${c.cyan}2.${c.reset} Preview locally: ${c.dim}mintlify dev${c.reset}`);
|
|
209
|
+
console.log(` ${c.cyan}3.${c.reset} Push to GitHub → auto-deploys on Mintlify${c.reset}`);
|
|
210
|
+
console.log(` ${c.dim}\n Mintlify is free for open-source projects.${c.reset}`);
|
|
211
|
+
console.log(` ${c.dim} Docs: https://mintlify.com/docs${c.reset}\n`);
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
function buildMintlifyNavigation(dir) {
|
|
215
|
+
const nav = [
|
|
216
|
+
{
|
|
217
|
+
group: "Getting Started",
|
|
218
|
+
pages: ["introduction", "quickstart"],
|
|
219
|
+
},
|
|
220
|
+
];
|
|
221
|
+
|
|
222
|
+
// Add architecture group if docs exist
|
|
223
|
+
const canonicalDir = resolve(dir, 'docs-canonical');
|
|
224
|
+
const architecturePages = [];
|
|
225
|
+
if (existsSync(resolve(canonicalDir, 'ARCHITECTURE.md'))) architecturePages.push("architecture");
|
|
226
|
+
if (existsSync(resolve(canonicalDir, 'DATA-MODEL.md'))) architecturePages.push("data-model");
|
|
227
|
+
if (existsSync(resolve(canonicalDir, 'SECURITY.md'))) architecturePages.push("security");
|
|
228
|
+
if (architecturePages.length > 0) {
|
|
229
|
+
nav.push({ group: "Architecture", pages: architecturePages });
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
// Add operations group
|
|
233
|
+
const opsPages = [];
|
|
234
|
+
if (existsSync(resolve(canonicalDir, 'ENVIRONMENT.md'))) opsPages.push("environment");
|
|
235
|
+
if (existsSync(resolve(canonicalDir, 'TEST-SPEC.md'))) opsPages.push("test-spec");
|
|
236
|
+
if (opsPages.length > 0) {
|
|
237
|
+
nav.push({ group: "Operations", pages: opsPages });
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
// Add API reference
|
|
241
|
+
if (existsSync(resolve(canonicalDir, 'API-REFERENCE.md'))) {
|
|
242
|
+
nav.push({ group: "API Reference", pages: ["api-reference"] });
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
return nav;
|
|
246
|
+
}
|
package/cli/commands/score.mjs
CHANGED
|
@@ -99,6 +99,37 @@ export function runScore(projectDir, config, flags) {
|
|
|
99
99
|
console.log(` Tax-to-value ratio: ${taxColor}${c.bold}${tax.level}${c.reset}`);
|
|
100
100
|
console.log(` ${c.dim}${tax.recommendation}${c.reset}\n`);
|
|
101
101
|
}
|
|
102
|
+
|
|
103
|
+
// ── Multi-Signal Breakdown (--signals flag) ──
|
|
104
|
+
// Inspired by CJE multi-signal composite scoring (Lopez et al., TRACE, IEEE TMLCN 2026)
|
|
105
|
+
if (flags.signals) {
|
|
106
|
+
console.log(` ${c.bold}📡 Multi-Signal Quality Breakdown${c.reset}`);
|
|
107
|
+
console.log(` ${c.dim}─────────────────────────────────${c.reset}`);
|
|
108
|
+
|
|
109
|
+
const signals = [
|
|
110
|
+
{ name: 'Structure', score: scores.structure, weight: WEIGHTS.structure, description: 'Required files exist' },
|
|
111
|
+
{ name: 'Doc Quality', score: scores.docQuality, weight: WEIGHTS.docQuality, description: 'Docs have required sections + content' },
|
|
112
|
+
{ name: 'Testing', score: scores.testing, weight: WEIGHTS.testing, description: 'Test spec alignment' },
|
|
113
|
+
{ name: 'Security', score: scores.security, weight: WEIGHTS.security, description: 'No hardcoded secrets, .gitignore' },
|
|
114
|
+
{ name: 'Environment', score: scores.environment, weight: WEIGHTS.environment, description: 'Env docs, .env.example' },
|
|
115
|
+
{ name: 'Drift', score: scores.drift, weight: WEIGHTS.drift, description: 'Drift tracking discipline' },
|
|
116
|
+
{ name: 'Changelog', score: scores.changelog, weight: WEIGHTS.changelog, description: 'Changelog maintenance' },
|
|
117
|
+
{ name: 'Architecture', score: scores.architecture, weight: WEIGHTS.architecture, description: 'Layer boundary compliance' },
|
|
118
|
+
];
|
|
119
|
+
|
|
120
|
+
for (const sig of signals) {
|
|
121
|
+
const weighted = Math.round((sig.score / 100) * sig.weight);
|
|
122
|
+
const quality = sig.score >= 90 ? 'HIGH' : sig.score >= 50 ? 'MEDIUM' : 'LOW';
|
|
123
|
+
const qColor = quality === 'HIGH' ? c.green : quality === 'MEDIUM' ? c.yellow : c.red;
|
|
124
|
+
const bar = renderBar(sig.score);
|
|
125
|
+
|
|
126
|
+
console.log(` ${bar} ${qColor}[${quality}]${c.reset} ${sig.name.padEnd(14)} ${sig.score}% → ${c.bold}${weighted}/${sig.weight}${c.reset} pts ${c.dim}${sig.description}${c.reset}`);
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
console.log(`\n ${c.dim}Composite: Σ(signal_score × weight) = ${totalScore}/100${c.reset}`);
|
|
130
|
+
console.log(` ${c.dim}Quality labels: HIGH (≥90%), MEDIUM (50-89%), LOW (<50%)${c.reset}`);
|
|
131
|
+
console.log(` ${c.dim}Methodology: CJE multi-signal composite (Lopez et al., TRACE, IEEE TMLCN 2026)${c.reset}\n`);
|
|
132
|
+
}
|
|
102
133
|
}
|
|
103
134
|
|
|
104
135
|
/**
|
|
@@ -0,0 +1,311 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Trace Command — Generate a requirements traceability matrix
|
|
3
|
+
* Maps canonical docs ↔ source code ↔ tests → produces a traceability report.
|
|
4
|
+
*
|
|
5
|
+
* Inspired by requirements traceability in Lopez et al., AITPG (IEEE TSE 2026)
|
|
6
|
+
* and ISO/IEC/IEEE 29119 traceability requirements.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { existsSync, readFileSync, readdirSync, statSync } from 'node:fs';
|
|
10
|
+
import { resolve, join, extname, basename, relative } from 'node:path';
|
|
11
|
+
import { c } from '../docguard.mjs';
|
|
12
|
+
|
|
13
|
+
const IGNORE_DIRS = new Set([
|
|
14
|
+
'node_modules', '.git', '.next', 'dist', 'build', 'coverage',
|
|
15
|
+
'.cache', '__pycache__', '.venv', 'vendor', '.turbo', '.vercel',
|
|
16
|
+
'.amplify-hosting', '.serverless',
|
|
17
|
+
]);
|
|
18
|
+
|
|
19
|
+
const CODE_EXTENSIONS = new Set([
|
|
20
|
+
'.js', '.mjs', '.cjs', '.ts', '.tsx', '.jsx',
|
|
21
|
+
'.py', '.java', '.go', '.rs', '.rb', '.php', '.cs',
|
|
22
|
+
]);
|
|
23
|
+
|
|
24
|
+
const TEST_PATTERNS = [
|
|
25
|
+
/\.test\.[jt]sx?$/,
|
|
26
|
+
/\.spec\.[jt]sx?$/,
|
|
27
|
+
/test_.*\.py$/,
|
|
28
|
+
/_test\.go$/,
|
|
29
|
+
/Test\.java$/,
|
|
30
|
+
];
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Mapping of canonical documents to the code/config artifacts they trace to.
|
|
34
|
+
* Each entry defines what source patterns prove coverage of that canonical doc.
|
|
35
|
+
*/
|
|
36
|
+
const TRACE_MAP = {
|
|
37
|
+
'ARCHITECTURE.md': {
|
|
38
|
+
standard: 'arc42 / C4 Model',
|
|
39
|
+
sourcePatterns: [
|
|
40
|
+
{ label: 'Entry points', glob: /^(index|main|app|server)\.[jt]sx?$/ },
|
|
41
|
+
{ label: 'Config files', glob: /^(package\.json|tsconfig.*|next\.config|vite\.config)/ },
|
|
42
|
+
{ label: 'Route handlers', glob: /(routes?|api|pages|app)\// },
|
|
43
|
+
],
|
|
44
|
+
},
|
|
45
|
+
'DATA-MODEL.md': {
|
|
46
|
+
standard: 'C4 Component / ER (Chen)',
|
|
47
|
+
sourcePatterns: [
|
|
48
|
+
{ label: 'Schema definitions', glob: /(schema|model|entity|migration|prisma)/i },
|
|
49
|
+
{ label: 'Type definitions', glob: /types?\.[jt]sx?$/ },
|
|
50
|
+
{ label: 'Database configs', glob: /(drizzle|knex|sequelize|typeorm)/i },
|
|
51
|
+
],
|
|
52
|
+
},
|
|
53
|
+
'TEST-SPEC.md': {
|
|
54
|
+
standard: 'ISO/IEC/IEEE 29119-3',
|
|
55
|
+
sourcePatterns: [
|
|
56
|
+
{ label: 'Test files', glob: /\.(test|spec)\.[jt]sx?$/ },
|
|
57
|
+
{ label: 'Test config', glob: /(jest|vitest|playwright|cypress)\.config/ },
|
|
58
|
+
{ label: 'E2E tests', glob: /(e2e|integration)\// },
|
|
59
|
+
],
|
|
60
|
+
},
|
|
61
|
+
'SECURITY.md': {
|
|
62
|
+
standard: 'OWASP ASVS v4.0',
|
|
63
|
+
sourcePatterns: [
|
|
64
|
+
{ label: 'Auth modules', glob: /(auth|login|session|jwt|oauth|middleware)/i },
|
|
65
|
+
{ label: 'Secret configs', glob: /\.(env|env\.example|env\.local)$/ },
|
|
66
|
+
{ label: 'Gitignore', glob: /^\.gitignore$/ },
|
|
67
|
+
],
|
|
68
|
+
},
|
|
69
|
+
'ENVIRONMENT.md': {
|
|
70
|
+
standard: '12-Factor App',
|
|
71
|
+
sourcePatterns: [
|
|
72
|
+
{ label: 'Env files', glob: /\.env/ },
|
|
73
|
+
{ label: 'Docker configs', glob: /(Dockerfile|docker-compose|\.dockerignore)/ },
|
|
74
|
+
{ label: 'CI/CD configs', glob: /\.(github|gitlab-ci|circleci)/ },
|
|
75
|
+
],
|
|
76
|
+
},
|
|
77
|
+
'API-REFERENCE.md': {
|
|
78
|
+
standard: 'OpenAPI 3.1',
|
|
79
|
+
sourcePatterns: [
|
|
80
|
+
{ label: 'Route handlers', glob: /(routes?|controllers?|handlers?)\// },
|
|
81
|
+
{ label: 'OpenAPI spec', glob: /(openapi|swagger)\.(json|ya?ml)/ },
|
|
82
|
+
{ label: 'API middleware', glob: /middleware\// },
|
|
83
|
+
],
|
|
84
|
+
},
|
|
85
|
+
};
|
|
86
|
+
|
|
87
|
+
export function runTrace(projectDir, config, flags) {
|
|
88
|
+
console.log(`${c.bold}🔗 DocGuard Trace — ${config.projectName}${c.reset}`);
|
|
89
|
+
console.log(`${c.dim} Directory: ${projectDir}${c.reset}`);
|
|
90
|
+
console.log(`${c.dim} Generating requirements traceability matrix...${c.reset}\n`);
|
|
91
|
+
|
|
92
|
+
// ── 1. Inventory canonical docs ──
|
|
93
|
+
const docsDir = resolve(projectDir, 'docs-canonical');
|
|
94
|
+
const canonicalDocs = [];
|
|
95
|
+
if (existsSync(docsDir)) {
|
|
96
|
+
for (const f of readdirSync(docsDir)) {
|
|
97
|
+
if (f.endsWith('.md')) canonicalDocs.push(f);
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
// ── 2. Scan project files ──
|
|
102
|
+
const projectFiles = [];
|
|
103
|
+
scanDir(projectDir, projectDir, projectFiles);
|
|
104
|
+
|
|
105
|
+
// ── 3. Build traceability matrix ──
|
|
106
|
+
const matrix = [];
|
|
107
|
+
|
|
108
|
+
for (const [docName, traceInfo] of Object.entries(TRACE_MAP)) {
|
|
109
|
+
const docPath = resolve(docsDir, docName);
|
|
110
|
+
const docExists = existsSync(docPath);
|
|
111
|
+
let lastModified = null;
|
|
112
|
+
let docSize = 0;
|
|
113
|
+
|
|
114
|
+
if (docExists) {
|
|
115
|
+
const stat = statSync(docPath);
|
|
116
|
+
lastModified = stat.mtime.toISOString().split('T')[0];
|
|
117
|
+
docSize = stat.size;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
// Find matching source files for each pattern
|
|
121
|
+
const traces = [];
|
|
122
|
+
for (const pattern of traceInfo.sourcePatterns) {
|
|
123
|
+
const matches = projectFiles.filter(f => pattern.glob.test(f));
|
|
124
|
+
traces.push({
|
|
125
|
+
label: pattern.label,
|
|
126
|
+
matchCount: matches.length,
|
|
127
|
+
files: matches.slice(0, 5), // Cap at 5 for display
|
|
128
|
+
hasMore: matches.length > 5,
|
|
129
|
+
});
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
// Find test coverage (files that test code related to this doc)
|
|
133
|
+
const relatedTests = findRelatedTests(projectFiles, traceInfo.sourcePatterns);
|
|
134
|
+
|
|
135
|
+
// Calculate coverage signal
|
|
136
|
+
const totalSources = traces.reduce((sum, t) => sum + t.matchCount, 0);
|
|
137
|
+
const coverageSignal = !docExists ? 'MISSING'
|
|
138
|
+
: totalSources === 0 ? 'UNLINKED'
|
|
139
|
+
: relatedTests.length > 0 ? 'TRACED'
|
|
140
|
+
: 'PARTIAL';
|
|
141
|
+
|
|
142
|
+
matrix.push({
|
|
143
|
+
document: docName,
|
|
144
|
+
standard: traceInfo.standard,
|
|
145
|
+
exists: docExists,
|
|
146
|
+
lastModified,
|
|
147
|
+
docSize,
|
|
148
|
+
traces,
|
|
149
|
+
relatedTests,
|
|
150
|
+
totalSources,
|
|
151
|
+
coverageSignal,
|
|
152
|
+
});
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
// ── 4. Output ──
|
|
156
|
+
if (flags.format === 'json') {
|
|
157
|
+
outputJSON(config.projectName, matrix);
|
|
158
|
+
} else {
|
|
159
|
+
outputText(config.projectName, matrix, canonicalDocs);
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
function outputJSON(projectName, matrix) {
|
|
164
|
+
const result = {
|
|
165
|
+
project: projectName,
|
|
166
|
+
traceability: matrix.map(m => ({
|
|
167
|
+
document: m.document,
|
|
168
|
+
standard: m.standard,
|
|
169
|
+
exists: m.exists,
|
|
170
|
+
lastModified: m.lastModified,
|
|
171
|
+
coverageSignal: m.coverageSignal,
|
|
172
|
+
sources: m.totalSources,
|
|
173
|
+
tests: m.relatedTests.length,
|
|
174
|
+
traces: m.traces,
|
|
175
|
+
})),
|
|
176
|
+
summary: {
|
|
177
|
+
total: matrix.length,
|
|
178
|
+
traced: matrix.filter(m => m.coverageSignal === 'TRACED').length,
|
|
179
|
+
partial: matrix.filter(m => m.coverageSignal === 'PARTIAL').length,
|
|
180
|
+
unlinked: matrix.filter(m => m.coverageSignal === 'UNLINKED').length,
|
|
181
|
+
missing: matrix.filter(m => m.coverageSignal === 'MISSING').length,
|
|
182
|
+
},
|
|
183
|
+
timestamp: new Date().toISOString(),
|
|
184
|
+
};
|
|
185
|
+
console.log(JSON.stringify(result, null, 2));
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
function outputText(projectName, matrix, canonicalDocs) {
|
|
189
|
+
// Header table
|
|
190
|
+
console.log(` ${c.bold}Traceability Matrix${c.reset}\n`);
|
|
191
|
+
console.log(` ${c.dim}${'Document'.padEnd(22)} ${'Standard'.padEnd(28)} ${'Status'.padEnd(10)} ${'Sources'.padEnd(9)} ${'Tests'.padEnd(7)} ${'Last Modified'}${c.reset}`);
|
|
192
|
+
console.log(` ${c.dim}${'─'.repeat(22)} ${'─'.repeat(28)} ${'─'.repeat(10)} ${'─'.repeat(9)} ${'─'.repeat(7)} ${'─'.repeat(14)}${c.reset}`);
|
|
193
|
+
|
|
194
|
+
for (const entry of matrix) {
|
|
195
|
+
const statusColor = entry.coverageSignal === 'TRACED' ? c.green
|
|
196
|
+
: entry.coverageSignal === 'PARTIAL' ? c.yellow
|
|
197
|
+
: entry.coverageSignal === 'UNLINKED' ? c.yellow
|
|
198
|
+
: c.red;
|
|
199
|
+
const statusIcon = entry.coverageSignal === 'TRACED' ? '✅'
|
|
200
|
+
: entry.coverageSignal === 'PARTIAL' ? '⚠️ '
|
|
201
|
+
: entry.coverageSignal === 'UNLINKED' ? '🔗'
|
|
202
|
+
: '❌';
|
|
203
|
+
|
|
204
|
+
console.log(` ${statusIcon} ${entry.document.padEnd(19)} ${c.dim}${entry.standard.padEnd(28)}${c.reset} ${statusColor}${entry.coverageSignal.padEnd(10)}${c.reset} ${String(entry.totalSources).padEnd(9)} ${String(entry.relatedTests.length).padEnd(7)} ${entry.lastModified || c.dim + 'n/a' + c.reset}`);
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
// Detailed traces (verbose)
|
|
208
|
+
console.log(`\n ${c.bold}Detailed Traces${c.reset}\n`);
|
|
209
|
+
|
|
210
|
+
for (const entry of matrix) {
|
|
211
|
+
if (!entry.exists) {
|
|
212
|
+
console.log(` ${c.red}❌ ${entry.document}${c.reset} — ${c.dim}Document not found. Run \`docguard generate\` to create.${c.reset}`);
|
|
213
|
+
continue;
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
console.log(` ${c.bold}📄 ${entry.document}${c.reset} ${c.dim}(${entry.standard})${c.reset}`);
|
|
217
|
+
|
|
218
|
+
for (const trace of entry.traces) {
|
|
219
|
+
const icon = trace.matchCount > 0 ? `${c.green}✓${c.reset}` : `${c.dim}○${c.reset}`;
|
|
220
|
+
console.log(` ${icon} ${trace.label}: ${trace.matchCount} file(s)`);
|
|
221
|
+
if (trace.matchCount > 0 && trace.files.length > 0) {
|
|
222
|
+
for (const f of trace.files) {
|
|
223
|
+
console.log(` ${c.dim}→ ${f}${c.reset}`);
|
|
224
|
+
}
|
|
225
|
+
if (trace.hasMore) {
|
|
226
|
+
console.log(` ${c.dim} ... and ${trace.matchCount - 5} more${c.reset}`);
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
if (entry.relatedTests.length > 0) {
|
|
232
|
+
console.log(` ${c.green}✓${c.reset} Test coverage: ${entry.relatedTests.length} test file(s)`);
|
|
233
|
+
for (const t of entry.relatedTests.slice(0, 3)) {
|
|
234
|
+
console.log(` ${c.dim}→ ${t}${c.reset}`);
|
|
235
|
+
}
|
|
236
|
+
if (entry.relatedTests.length > 3) {
|
|
237
|
+
console.log(` ${c.dim} ... and ${entry.relatedTests.length - 3} more${c.reset}`);
|
|
238
|
+
}
|
|
239
|
+
} else {
|
|
240
|
+
console.log(` ${c.yellow}○${c.reset} ${c.dim}No related test files found${c.reset}`);
|
|
241
|
+
}
|
|
242
|
+
console.log('');
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
// Summary
|
|
246
|
+
const traced = matrix.filter(m => m.coverageSignal === 'TRACED').length;
|
|
247
|
+
const partial = matrix.filter(m => m.coverageSignal === 'PARTIAL').length;
|
|
248
|
+
const unlinked = matrix.filter(m => m.coverageSignal === 'UNLINKED').length;
|
|
249
|
+
const missing = matrix.filter(m => m.coverageSignal === 'MISSING').length;
|
|
250
|
+
|
|
251
|
+
console.log(` ${c.bold}─────────────────────────────────────${c.reset}`);
|
|
252
|
+
console.log(` ${c.green}Traced: ${traced}${c.reset} ${c.yellow}Partial: ${partial}${c.reset} ${c.yellow}Unlinked: ${unlinked}${c.reset} ${c.red}Missing: ${missing}${c.reset}`);
|
|
253
|
+
console.log(` ${c.dim}Total: ${matrix.length} canonical documents evaluated${c.reset}`);
|
|
254
|
+
|
|
255
|
+
if (missing > 0 || unlinked > 0) {
|
|
256
|
+
console.log(`\n ${c.dim}Run ${c.cyan}docguard generate${c.dim} to create missing docs.${c.reset}`);
|
|
257
|
+
console.log(` ${c.dim}Run ${c.cyan}docguard diagnose${c.dim} to fix coverage gaps.${c.reset}`);
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
console.log(`\n ${c.dim}Traceability methodology: ISO/IEC/IEEE 29119 (Lopez et al., AITPG, IEEE TSE 2026)${c.reset}\n`);
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
// ── Helpers ────────────────────────────────────────────────────────────────
|
|
264
|
+
|
|
265
|
+
function scanDir(rootDir, dir, files) {
|
|
266
|
+
let entries;
|
|
267
|
+
try {
|
|
268
|
+
entries = readdirSync(dir);
|
|
269
|
+
} catch { return; }
|
|
270
|
+
|
|
271
|
+
for (const entry of entries) {
|
|
272
|
+
if (IGNORE_DIRS.has(entry)) continue;
|
|
273
|
+
if (entry.startsWith('.') && entry !== '.env' && entry !== '.env.example'
|
|
274
|
+
&& entry !== '.gitignore' && !entry.startsWith('.github')) continue;
|
|
275
|
+
|
|
276
|
+
const full = join(dir, entry);
|
|
277
|
+
let stat;
|
|
278
|
+
try { stat = statSync(full); } catch { continue; }
|
|
279
|
+
|
|
280
|
+
if (stat.isDirectory()) {
|
|
281
|
+
scanDir(rootDir, full, files);
|
|
282
|
+
} else {
|
|
283
|
+
files.push(relative(rootDir, full));
|
|
284
|
+
}
|
|
285
|
+
}
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
function findRelatedTests(projectFiles, sourcePatterns) {
|
|
289
|
+
// Find test files that might cover the source patterns
|
|
290
|
+
const testFiles = projectFiles.filter(f => TEST_PATTERNS.some(p => p.test(f)));
|
|
291
|
+
|
|
292
|
+
// Match tests to source patterns by directory/name proximity
|
|
293
|
+
const relatedTests = new Set();
|
|
294
|
+
|
|
295
|
+
for (const pattern of sourcePatterns) {
|
|
296
|
+
const sourceFiles = projectFiles.filter(f => pattern.glob.test(f));
|
|
297
|
+
for (const src of sourceFiles) {
|
|
298
|
+
const srcBase = basename(src).replace(/\.[^.]+$/, '');
|
|
299
|
+
const srcDir = src.split('/')[0];
|
|
300
|
+
|
|
301
|
+
for (const test of testFiles) {
|
|
302
|
+
// Match by name similarity or directory proximity
|
|
303
|
+
if (test.includes(srcBase) || test.includes(srcDir)) {
|
|
304
|
+
relatedTests.add(test);
|
|
305
|
+
}
|
|
306
|
+
}
|
|
307
|
+
}
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
return [...relatedTests];
|
|
311
|
+
}
|