docguard-cli 0.5.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/LICENSE +21 -0
- package/PHILOSOPHY.md +150 -0
- package/README.md +309 -0
- package/STANDARD.md +751 -0
- package/cli/commands/agents.mjs +221 -0
- package/cli/commands/audit.mjs +92 -0
- package/cli/commands/badge.mjs +72 -0
- package/cli/commands/ci.mjs +80 -0
- package/cli/commands/diagnose.mjs +273 -0
- package/cli/commands/diff.mjs +360 -0
- package/cli/commands/fix.mjs +610 -0
- package/cli/commands/generate.mjs +842 -0
- package/cli/commands/guard.mjs +158 -0
- package/cli/commands/hooks.mjs +227 -0
- package/cli/commands/init.mjs +249 -0
- package/cli/commands/score.mjs +396 -0
- package/cli/commands/watch.mjs +143 -0
- package/cli/docguard.mjs +458 -0
- package/cli/validators/architecture.mjs +380 -0
- package/cli/validators/changelog.mjs +39 -0
- package/cli/validators/docs-sync.mjs +110 -0
- package/cli/validators/drift.mjs +101 -0
- package/cli/validators/environment.mjs +70 -0
- package/cli/validators/freshness.mjs +224 -0
- package/cli/validators/security.mjs +101 -0
- package/cli/validators/structure.mjs +88 -0
- package/cli/validators/test-spec.mjs +115 -0
- package/docs/ai-integration.md +179 -0
- package/docs/commands.md +239 -0
- package/docs/configuration.md +96 -0
- package/docs/faq.md +155 -0
- package/docs/installation.md +81 -0
- package/docs/profiles.md +103 -0
- package/docs/quickstart.md +79 -0
- package/package.json +57 -0
- package/templates/ADR.md.template +64 -0
- package/templates/AGENTS.md.template +88 -0
- package/templates/ARCHITECTURE.md.template +78 -0
- package/templates/CHANGELOG.md.template +16 -0
- package/templates/CURRENT-STATE.md.template +64 -0
- package/templates/DATA-MODEL.md.template +66 -0
- package/templates/DEPLOYMENT.md.template +66 -0
- package/templates/DRIFT-LOG.md.template +18 -0
- package/templates/ENVIRONMENT.md.template +43 -0
- package/templates/KNOWN-GOTCHAS.md.template +69 -0
- package/templates/ROADMAP.md.template +82 -0
- package/templates/RUNBOOKS.md.template +115 -0
- package/templates/SECURITY.md.template +42 -0
- package/templates/TEST-SPEC.md.template +55 -0
- package/templates/TROUBLESHOOTING.md.template +96 -0
- package/templates/VENDOR-BUGS.md.template +74 -0
- package/templates/ci/github-actions.yml +39 -0
- package/templates/commands/docguard.fix.md +65 -0
- package/templates/commands/docguard.guard.md +40 -0
- package/templates/commands/docguard.init.md +62 -0
- package/templates/commands/docguard.review.md +44 -0
- package/templates/commands/docguard.update.md +44 -0
|
@@ -0,0 +1,396 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Score Command — Calculate CDD maturity score (0-100)
|
|
3
|
+
* Shows category breakdown with weighted scoring.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { existsSync, readFileSync, readdirSync, statSync } from 'node:fs';
|
|
7
|
+
import { resolve, join, extname } from 'node:path';
|
|
8
|
+
import { execSync } from 'node:child_process';
|
|
9
|
+
import { c } from '../docguard.mjs';
|
|
10
|
+
|
|
11
|
+
const WEIGHTS = {
|
|
12
|
+
structure: 25, // Required files exist
|
|
13
|
+
docQuality: 20, // Docs have required sections + content
|
|
14
|
+
testing: 15, // Test spec alignment
|
|
15
|
+
security: 10, // No hardcoded secrets, .gitignore
|
|
16
|
+
environment: 10, // Env docs, .env.example
|
|
17
|
+
drift: 10, // Drift tracking discipline
|
|
18
|
+
changelog: 5, // Changelog maintenance
|
|
19
|
+
architecture: 5, // Layer boundary compliance
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
export function runScore(projectDir, config, flags) {
|
|
23
|
+
console.log(`${c.bold}📊 DocGuard Score — ${config.projectName}${c.reset}`);
|
|
24
|
+
console.log(`${c.dim} Directory: ${projectDir}${c.reset}\n`);
|
|
25
|
+
|
|
26
|
+
const { scores, totalScore, grade } = calcAllScores(projectDir, config);
|
|
27
|
+
|
|
28
|
+
// ── Display Results ──
|
|
29
|
+
if (flags.format === 'json') {
|
|
30
|
+
const result = {
|
|
31
|
+
project: config.projectName,
|
|
32
|
+
score: totalScore,
|
|
33
|
+
grade,
|
|
34
|
+
categories: {},
|
|
35
|
+
};
|
|
36
|
+
for (const [cat, score] of Object.entries(scores)) {
|
|
37
|
+
result.categories[cat] = {
|
|
38
|
+
score,
|
|
39
|
+
weight: WEIGHTS[cat],
|
|
40
|
+
weighted: Math.round((score / 100) * WEIGHTS[cat]),
|
|
41
|
+
};
|
|
42
|
+
}
|
|
43
|
+
console.log(JSON.stringify(result, null, 2));
|
|
44
|
+
return;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
// Visual display
|
|
48
|
+
console.log(` ${c.bold}Category Breakdown${c.reset}\n`);
|
|
49
|
+
|
|
50
|
+
for (const [category, score] of Object.entries(scores)) {
|
|
51
|
+
const bar = renderBar(score);
|
|
52
|
+
const label = category.padEnd(14);
|
|
53
|
+
const weight = `(×${WEIGHTS[category]})`.padEnd(5);
|
|
54
|
+
const weighted = Math.round((score / 100) * WEIGHTS[category]);
|
|
55
|
+
console.log(` ${label} ${bar} ${score}% ${c.dim}${weight} = ${weighted} pts${c.reset}`);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
console.log(`\n${c.bold} ─────────────────────────────────────${c.reset}`);
|
|
59
|
+
|
|
60
|
+
const gradeColor = totalScore >= 80 ? c.green : totalScore >= 60 ? c.yellow : c.red;
|
|
61
|
+
console.log(` ${gradeColor}${c.bold}CDD Maturity Score: ${totalScore}/100 (${grade})${c.reset}`);
|
|
62
|
+
|
|
63
|
+
// Grade description
|
|
64
|
+
const descriptions = {
|
|
65
|
+
'A+': 'Excellent — CDD fully adopted',
|
|
66
|
+
'A': 'Great — Strong CDD compliance',
|
|
67
|
+
'B': 'Good — Most CDD practices in place',
|
|
68
|
+
'C': 'Fair — Partial CDD adoption',
|
|
69
|
+
'D': 'Needs Work — Significant gaps',
|
|
70
|
+
'F': 'Not Started — Run `docguard init` first',
|
|
71
|
+
};
|
|
72
|
+
console.log(` ${c.dim}${descriptions[grade]}${c.reset}\n`);
|
|
73
|
+
|
|
74
|
+
// Suggestions
|
|
75
|
+
const weakest = Object.entries(scores)
|
|
76
|
+
.filter(([, s]) => s < 100)
|
|
77
|
+
.sort((a, b) => a[1] - b[1])
|
|
78
|
+
.slice(0, 3);
|
|
79
|
+
|
|
80
|
+
if (weakest.length > 0) {
|
|
81
|
+
console.log(` ${c.bold}Top improvements:${c.reset}`);
|
|
82
|
+
for (const [cat, score] of weakest) {
|
|
83
|
+
const suggestion = getSuggestion(cat, score);
|
|
84
|
+
console.log(` ${c.yellow}→ ${cat}${c.reset}: ${suggestion}`);
|
|
85
|
+
}
|
|
86
|
+
console.log('');
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
// ── Tax Estimate (--tax flag) ──
|
|
90
|
+
if (flags.tax) {
|
|
91
|
+
const tax = estimateDocTax(projectDir, config, scores);
|
|
92
|
+
const taxColor = tax.level === 'LOW' ? c.green : tax.level === 'MEDIUM' ? c.yellow : c.red;
|
|
93
|
+
|
|
94
|
+
console.log(` ${c.bold}📋 Documentation Tax Estimate${c.reset}`);
|
|
95
|
+
console.log(` ${c.dim}─────────────────────────────────${c.reset}`);
|
|
96
|
+
console.log(` Tracked docs: ${c.cyan}${tax.docCount}${c.reset} files`);
|
|
97
|
+
console.log(` Active profile: ${c.cyan}${config.profile || 'standard'}${c.reset}`);
|
|
98
|
+
console.log(` Est. maintenance: ${c.bold}~${tax.minutesPerWeek} min/week${c.reset}`);
|
|
99
|
+
console.log(` Tax-to-value ratio: ${taxColor}${c.bold}${tax.level}${c.reset}`);
|
|
100
|
+
console.log(` ${c.dim}${tax.recommendation}${c.reset}\n`);
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* Internal scoring — returns data without printing.
|
|
106
|
+
* Used by badge, ci, and other commands that need the score.
|
|
107
|
+
*/
|
|
108
|
+
export function runScoreInternal(projectDir, config) {
|
|
109
|
+
const { scores, totalScore, grade } = calcAllScores(projectDir, config);
|
|
110
|
+
return { score: totalScore, grade, categories: scores };
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
function calcAllScores(projectDir, config) {
|
|
114
|
+
const scores = {};
|
|
115
|
+
scores.structure = calcStructureScore(projectDir, config);
|
|
116
|
+
scores.docQuality = calcDocQualityScore(projectDir, config);
|
|
117
|
+
scores.testing = calcTestingScore(projectDir, config);
|
|
118
|
+
scores.security = calcSecurityScore(projectDir, config);
|
|
119
|
+
scores.environment = calcEnvironmentScore(projectDir, config);
|
|
120
|
+
scores.drift = calcDriftScore(projectDir, config);
|
|
121
|
+
scores.changelog = calcChangelogScore(projectDir, config);
|
|
122
|
+
scores.architecture = calcArchitectureScore(projectDir, config);
|
|
123
|
+
|
|
124
|
+
let totalScore = 0;
|
|
125
|
+
for (const [category, score] of Object.entries(scores)) {
|
|
126
|
+
totalScore += (score / 100) * WEIGHTS[category];
|
|
127
|
+
}
|
|
128
|
+
totalScore = Math.round(totalScore);
|
|
129
|
+
|
|
130
|
+
return { scores, totalScore, grade: getGrade(totalScore) };
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
// ── Scoring Functions ──────────────────────────────────────────────────────
|
|
134
|
+
|
|
135
|
+
function calcStructureScore(dir, config) {
|
|
136
|
+
let found = 0;
|
|
137
|
+
let total = 0;
|
|
138
|
+
|
|
139
|
+
for (const file of config.requiredFiles.canonical) {
|
|
140
|
+
total++;
|
|
141
|
+
if (existsSync(resolve(dir, file))) found++;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
total++;
|
|
145
|
+
const hasAgent = config.requiredFiles.agentFile.some(f => existsSync(resolve(dir, f)));
|
|
146
|
+
if (hasAgent) found++;
|
|
147
|
+
|
|
148
|
+
total++;
|
|
149
|
+
if (existsSync(resolve(dir, config.requiredFiles.changelog))) found++;
|
|
150
|
+
|
|
151
|
+
total++;
|
|
152
|
+
if (existsSync(resolve(dir, config.requiredFiles.driftLog))) found++;
|
|
153
|
+
|
|
154
|
+
return total === 0 ? 0 : Math.round((found / total) * 100);
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
function calcDocQualityScore(dir, config) {
|
|
158
|
+
const checks = {
|
|
159
|
+
'docs-canonical/ARCHITECTURE.md': ['## System Overview', '## Component Map', '## Tech Stack'],
|
|
160
|
+
'docs-canonical/DATA-MODEL.md': ['## Entities'],
|
|
161
|
+
'docs-canonical/SECURITY.md': ['## Authentication', '## Secrets Management'],
|
|
162
|
+
'docs-canonical/TEST-SPEC.md': ['## Test Categories', '## Coverage Rules'],
|
|
163
|
+
'docs-canonical/ENVIRONMENT.md': ['## Environment Variables', '## Setup Steps'],
|
|
164
|
+
};
|
|
165
|
+
|
|
166
|
+
let found = 0;
|
|
167
|
+
let total = 0;
|
|
168
|
+
|
|
169
|
+
for (const [file, sections] of Object.entries(checks)) {
|
|
170
|
+
const fullPath = resolve(dir, file);
|
|
171
|
+
if (!existsSync(fullPath)) continue;
|
|
172
|
+
|
|
173
|
+
const content = readFileSync(fullPath, 'utf-8');
|
|
174
|
+
|
|
175
|
+
for (const section of sections) {
|
|
176
|
+
total++;
|
|
177
|
+
if (content.includes(section)) found++;
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
// Bonus: check if doc has docguard metadata
|
|
181
|
+
total++;
|
|
182
|
+
if (content.includes('docguard:version')) found++;
|
|
183
|
+
|
|
184
|
+
// Bonus: check if doc has more than just template placeholders
|
|
185
|
+
total++;
|
|
186
|
+
const lines = content.split('\n').filter(l => l.trim() && !l.startsWith('#') && !l.startsWith('|') && !l.startsWith('>') && !l.startsWith('<!--'));
|
|
187
|
+
if (lines.length > 5) found++;
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
return total === 0 ? 0 : Math.round((found / total) * 100);
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
function calcTestingScore(dir) {
|
|
194
|
+
let score = 0;
|
|
195
|
+
|
|
196
|
+
// Check test directory exists
|
|
197
|
+
const testDirs = ['tests', 'test', '__tests__', 'spec', 'e2e'];
|
|
198
|
+
const hasTestDir = testDirs.some(d => existsSync(resolve(dir, d)));
|
|
199
|
+
if (hasTestDir) score += 40;
|
|
200
|
+
|
|
201
|
+
// Check test spec exists
|
|
202
|
+
if (existsSync(resolve(dir, 'docs-canonical/TEST-SPEC.md'))) score += 30;
|
|
203
|
+
|
|
204
|
+
// Check for test config files
|
|
205
|
+
const testConfigs = ['jest.config.js', 'jest.config.ts', 'vitest.config.ts', 'vitest.config.js', 'pytest.ini', 'setup.cfg', '.mocharc.yml'];
|
|
206
|
+
const hasTestConfig = testConfigs.some(f => existsSync(resolve(dir, f)));
|
|
207
|
+
if (hasTestConfig) score += 15;
|
|
208
|
+
|
|
209
|
+
// Check for CI test step
|
|
210
|
+
const ciFiles = ['.github/workflows/ci.yml', '.github/workflows/test.yml'];
|
|
211
|
+
const hasCITest = ciFiles.some(f => existsSync(resolve(dir, f)));
|
|
212
|
+
if (hasCITest) score += 15;
|
|
213
|
+
|
|
214
|
+
return Math.min(100, score);
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
function calcSecurityScore(dir) {
|
|
218
|
+
let score = 0;
|
|
219
|
+
|
|
220
|
+
// SECURITY.md exists
|
|
221
|
+
if (existsSync(resolve(dir, 'docs-canonical/SECURITY.md'))) score += 30;
|
|
222
|
+
|
|
223
|
+
// .gitignore exists and includes .env
|
|
224
|
+
const gitignorePath = resolve(dir, '.gitignore');
|
|
225
|
+
if (existsSync(gitignorePath)) {
|
|
226
|
+
score += 20;
|
|
227
|
+
const content = readFileSync(gitignorePath, 'utf-8');
|
|
228
|
+
if (content.includes('.env')) score += 20;
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
// No .env file committed (check if .env exists but .gitignore covers it)
|
|
232
|
+
if (!existsSync(resolve(dir, '.env')) || existsSync(gitignorePath)) score += 15;
|
|
233
|
+
|
|
234
|
+
// .env.example exists (safe template)
|
|
235
|
+
if (existsSync(resolve(dir, '.env.example'))) score += 15;
|
|
236
|
+
|
|
237
|
+
return Math.min(100, score);
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
function calcEnvironmentScore(dir) {
|
|
241
|
+
let score = 0;
|
|
242
|
+
|
|
243
|
+
if (existsSync(resolve(dir, 'docs-canonical/ENVIRONMENT.md'))) score += 40;
|
|
244
|
+
if (existsSync(resolve(dir, '.env.example'))) score += 30;
|
|
245
|
+
|
|
246
|
+
// Check for setup documentation
|
|
247
|
+
const readmePath = resolve(dir, 'README.md');
|
|
248
|
+
if (existsSync(readmePath)) {
|
|
249
|
+
const content = readFileSync(readmePath, 'utf-8');
|
|
250
|
+
if (content.includes('## Setup') || content.includes('## Getting Started') || content.includes('Quick Start')) {
|
|
251
|
+
score += 30;
|
|
252
|
+
} else {
|
|
253
|
+
score += 15; // README exists but no setup section
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
return Math.min(100, score);
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
function calcDriftScore(dir, config) {
|
|
261
|
+
// Perfect score if drift log exists and no unlogged drift comments
|
|
262
|
+
if (!existsSync(resolve(dir, config.requiredFiles.driftLog))) return 0;
|
|
263
|
+
|
|
264
|
+
let score = 50; // Drift log exists
|
|
265
|
+
|
|
266
|
+
const content = readFileSync(resolve(dir, config.requiredFiles.driftLog), 'utf-8');
|
|
267
|
+
|
|
268
|
+
// Has structure (headers)
|
|
269
|
+
if (content.includes('## ') || content.includes('| ')) score += 25;
|
|
270
|
+
|
|
271
|
+
// Has entries (not just template)
|
|
272
|
+
const lines = content.split('\n').filter(l => l.trim() && !l.startsWith('#') && !l.startsWith('<!--'));
|
|
273
|
+
if (lines.length > 3) score += 25;
|
|
274
|
+
|
|
275
|
+
return Math.min(100, score);
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
function calcChangelogScore(dir, config) {
|
|
279
|
+
const path = resolve(dir, config.requiredFiles.changelog);
|
|
280
|
+
if (!existsSync(path)) return 0;
|
|
281
|
+
|
|
282
|
+
let score = 40; // Exists
|
|
283
|
+
const content = readFileSync(path, 'utf-8');
|
|
284
|
+
|
|
285
|
+
if (content.includes('[Unreleased]') || content.includes('[unreleased]')) score += 30;
|
|
286
|
+
if (/## \[[\d.]+\]/.test(content)) score += 30;
|
|
287
|
+
|
|
288
|
+
return Math.min(100, score);
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
function calcArchitectureScore(dir) {
|
|
292
|
+
const archPath = resolve(dir, 'docs-canonical/ARCHITECTURE.md');
|
|
293
|
+
if (!existsSync(archPath)) return 0;
|
|
294
|
+
|
|
295
|
+
let score = 30;
|
|
296
|
+
const content = readFileSync(archPath, 'utf-8');
|
|
297
|
+
|
|
298
|
+
if (content.includes('## Layer Boundaries') || content.includes('## Component Map')) score += 25;
|
|
299
|
+
if (content.includes('```mermaid') || content.includes('graph ')) score += 20;
|
|
300
|
+
if (content.includes('## External Dependencies')) score += 15;
|
|
301
|
+
if (content.includes('## Revision History')) score += 10;
|
|
302
|
+
|
|
303
|
+
return Math.min(100, score);
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
// ── Helpers ────────────────────────────────────────────────────────────────
|
|
307
|
+
|
|
308
|
+
function renderBar(score) {
|
|
309
|
+
const filled = Math.round(score / 5);
|
|
310
|
+
const empty = 20 - filled;
|
|
311
|
+
const color = score >= 80 ? c.green : score >= 60 ? c.yellow : c.red;
|
|
312
|
+
return `${color}${'█'.repeat(filled)}${c.dim}${'░'.repeat(empty)}${c.reset}`;
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
function getGrade(score) {
|
|
316
|
+
if (score >= 95) return 'A+';
|
|
317
|
+
if (score >= 80) return 'A';
|
|
318
|
+
if (score >= 65) return 'B';
|
|
319
|
+
if (score >= 50) return 'C';
|
|
320
|
+
if (score >= 30) return 'D';
|
|
321
|
+
return 'F';
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
function getSuggestion(category, score) {
|
|
325
|
+
const suggestions = {
|
|
326
|
+
structure: 'Run `docguard init` to create missing documentation',
|
|
327
|
+
docQuality: 'Fill in template sections — replace placeholders with real content',
|
|
328
|
+
testing: 'Add tests/ directory and configure TEST-SPEC.md',
|
|
329
|
+
security: 'Create SECURITY.md and add .env to .gitignore',
|
|
330
|
+
environment: 'Document env variables and create .env.example',
|
|
331
|
+
drift: 'Create DRIFT-LOG.md and log any code deviations',
|
|
332
|
+
changelog: 'Maintain CHANGELOG.md with [Unreleased] section',
|
|
333
|
+
architecture: 'Add layer boundaries and Mermaid diagrams to ARCHITECTURE.md',
|
|
334
|
+
};
|
|
335
|
+
return suggestions[category] || 'Review and improve this area';
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
/**
|
|
339
|
+
* Estimate documentation maintenance "tax" — how much time docs cost per week.
|
|
340
|
+
* Based on: doc count, code churn, and current doc quality scores.
|
|
341
|
+
*/
|
|
342
|
+
function estimateDocTax(projectDir, config, scores) {
|
|
343
|
+
// Count tracked docs
|
|
344
|
+
const canonicalDir = resolve(projectDir, 'docs-canonical');
|
|
345
|
+
let docCount = 0;
|
|
346
|
+
if (existsSync(canonicalDir)) {
|
|
347
|
+
try {
|
|
348
|
+
docCount = readdirSync(canonicalDir).filter(f => f.endsWith('.md')).length;
|
|
349
|
+
} catch { /* ignore */ }
|
|
350
|
+
}
|
|
351
|
+
// Add root tracking files
|
|
352
|
+
if (existsSync(resolve(projectDir, 'CHANGELOG.md'))) docCount++;
|
|
353
|
+
if (existsSync(resolve(projectDir, 'DRIFT-LOG.md'))) docCount++;
|
|
354
|
+
|
|
355
|
+
// Estimate code churn (commits in last 30 days)
|
|
356
|
+
let recentCommits = 0;
|
|
357
|
+
try {
|
|
358
|
+
const output = execSync('git log --oneline --since="30 days ago" 2>/dev/null | wc -l', {
|
|
359
|
+
cwd: projectDir,
|
|
360
|
+
encoding: 'utf-8',
|
|
361
|
+
}).trim();
|
|
362
|
+
recentCommits = parseInt(output, 10) || 0;
|
|
363
|
+
} catch {
|
|
364
|
+
recentCommits = 10; // Default assumption
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
// Calculate estimated minutes per week
|
|
368
|
+
// Base: ~3 min per tracked doc per week (review time)
|
|
369
|
+
// Churn multiplier: more commits = more potential doc updates
|
|
370
|
+
const baseMinutes = docCount * 3;
|
|
371
|
+
const churnMultiplier = recentCommits > 50 ? 1.5 : recentCommits > 20 ? 1.2 : 1.0;
|
|
372
|
+
|
|
373
|
+
// Quality discount: higher scores = less rework needed
|
|
374
|
+
const avgScore = Object.values(scores).reduce((a, b) => a + b, 0) / Object.values(scores).length;
|
|
375
|
+
const qualityDiscount = avgScore > 80 ? 0.7 : avgScore > 60 ? 0.85 : 1.0;
|
|
376
|
+
|
|
377
|
+
// AI discount: if docs are AI-maintained, tax drops significantly
|
|
378
|
+
const aiDiscount = 0.3; // AI writes ~70% of docs
|
|
379
|
+
|
|
380
|
+
const minutesPerWeek = Math.max(5, Math.round(baseMinutes * churnMultiplier * qualityDiscount * aiDiscount));
|
|
381
|
+
|
|
382
|
+
// Determine tax level
|
|
383
|
+
let level, recommendation;
|
|
384
|
+
if (minutesPerWeek <= 10) {
|
|
385
|
+
level = 'LOW';
|
|
386
|
+
recommendation = 'Docs save more time than they cost. Current setup is sustainable.';
|
|
387
|
+
} else if (minutesPerWeek <= 25) {
|
|
388
|
+
level = 'MEDIUM';
|
|
389
|
+
recommendation = 'Consider using `docguard fix --doc` to let AI handle updates.';
|
|
390
|
+
} else {
|
|
391
|
+
level = 'HIGH';
|
|
392
|
+
recommendation = 'Consider switching to "starter" profile to reduce doc overhead.';
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
return { docCount, minutesPerWeek, level, recommendation };
|
|
396
|
+
}
|
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Watch Command — Live mode that watches for file changes and re-runs guard
|
|
3
|
+
*
|
|
4
|
+
* Like `jest --watch` but for CDD compliance.
|
|
5
|
+
* Uses Node.js fs.watch (zero dependencies).
|
|
6
|
+
*
|
|
7
|
+
* --auto-fix: When guard finds issues, output AI fix prompts automatically.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import { watch as fsWatch, existsSync, readdirSync, statSync } from 'node:fs';
|
|
11
|
+
import { resolve, relative, extname } from 'node:path';
|
|
12
|
+
import { c } from '../docguard.mjs';
|
|
13
|
+
import { runGuardInternal } from './guard.mjs';
|
|
14
|
+
|
|
15
|
+
const DEBOUNCE_MS = 500;
|
|
16
|
+
const IGNORE_DIRS = new Set([
|
|
17
|
+
'node_modules', '.git', '.next', 'dist', 'build', 'coverage',
|
|
18
|
+
'.cache', '__pycache__', '.venv', 'vendor', '.turbo',
|
|
19
|
+
]);
|
|
20
|
+
const WATCH_EXTS = new Set([
|
|
21
|
+
'.md', '.json', '.mjs', '.js', '.ts', '.tsx', '.jsx', '.py',
|
|
22
|
+
]);
|
|
23
|
+
|
|
24
|
+
export function runWatch(projectDir, config, flags) {
|
|
25
|
+
console.log(`${c.bold}👁️ DocGuard Watch — ${config.projectName}${c.reset}`);
|
|
26
|
+
console.log(`${c.dim} Directory: ${projectDir}${c.reset}`);
|
|
27
|
+
if (flags.autoFix) {
|
|
28
|
+
console.log(`${c.cyan} Mode: auto-fix (will output AI prompts on failures)${c.reset}`);
|
|
29
|
+
}
|
|
30
|
+
console.log(`${c.dim} Watching for changes... (Ctrl+C to stop)${c.reset}\n`);
|
|
31
|
+
|
|
32
|
+
// Run guard immediately on start
|
|
33
|
+
runGuardQuiet(projectDir, config, flags);
|
|
34
|
+
|
|
35
|
+
// Collect directories to watch
|
|
36
|
+
const watchDirs = collectWatchDirs(projectDir);
|
|
37
|
+
console.log(`${c.dim} Watching ${watchDirs.length} directories${c.reset}\n`);
|
|
38
|
+
|
|
39
|
+
let debounceTimer = null;
|
|
40
|
+
let lastChange = '';
|
|
41
|
+
|
|
42
|
+
for (const dir of watchDirs) {
|
|
43
|
+
try {
|
|
44
|
+
fsWatch(dir, { persistent: true }, (eventType, filename) => {
|
|
45
|
+
if (!filename) return;
|
|
46
|
+
const ext = extname(filename);
|
|
47
|
+
if (!WATCH_EXTS.has(ext)) return;
|
|
48
|
+
|
|
49
|
+
const changePath = relative(projectDir, resolve(dir, filename));
|
|
50
|
+
if (changePath === lastChange) return; // skip duplicates
|
|
51
|
+
lastChange = changePath;
|
|
52
|
+
|
|
53
|
+
// Debounce — wait for rapid saves to settle
|
|
54
|
+
if (debounceTimer) clearTimeout(debounceTimer);
|
|
55
|
+
debounceTimer = setTimeout(() => {
|
|
56
|
+
console.log(`\n${c.dim} Changed: ${c.cyan}${changePath}${c.reset}`);
|
|
57
|
+
runGuardQuiet(projectDir, config, flags);
|
|
58
|
+
lastChange = '';
|
|
59
|
+
}, DEBOUNCE_MS);
|
|
60
|
+
});
|
|
61
|
+
} catch {
|
|
62
|
+
// Some directories may not be watchable
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
// Keep process alive
|
|
67
|
+
process.on('SIGINT', () => {
|
|
68
|
+
console.log(`\n${c.dim} Watch stopped.${c.reset}\n`);
|
|
69
|
+
process.exit(0);
|
|
70
|
+
});
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function runGuardQuiet(projectDir, config, flags) {
|
|
74
|
+
const timestamp = new Date().toLocaleTimeString();
|
|
75
|
+
console.log(`${c.dim} [${timestamp}] Running guard...${c.reset}`);
|
|
76
|
+
|
|
77
|
+
try {
|
|
78
|
+
const data = runGuardInternal(projectDir, config);
|
|
79
|
+
|
|
80
|
+
if (data.status === 'PASS') {
|
|
81
|
+
console.log(` ${c.green}✅ PASS${c.reset} — ${data.passed}/${data.total} checks passed`);
|
|
82
|
+
} else if (data.status === 'WARN') {
|
|
83
|
+
console.log(` ${c.yellow}⚠️ WARN${c.reset} — ${data.passed}/${data.total} passed, ${data.warnings} warning(s)`);
|
|
84
|
+
} else {
|
|
85
|
+
console.log(` ${c.red}❌ FAIL${c.reset} — ${data.passed}/${data.total} passed, ${data.errors} error(s)`);
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
// Auto-fix: output fix prompts for failures
|
|
89
|
+
if (flags.autoFix && data.status !== 'PASS') {
|
|
90
|
+
console.log(`\n ${c.cyan}${c.bold}🤖 Auto-fix prompts:${c.reset}`);
|
|
91
|
+
|
|
92
|
+
for (const v of data.validators) {
|
|
93
|
+
if (v.status === 'pass' || v.status === 'skipped') continue;
|
|
94
|
+
|
|
95
|
+
const docMap = { 'Architecture': 'architecture', 'Security': 'security', 'Test-Spec': 'test-spec', 'Environment': 'environment' };
|
|
96
|
+
const docTarget = docMap[v.name];
|
|
97
|
+
|
|
98
|
+
for (const msg of [...v.errors, ...v.warnings]) {
|
|
99
|
+
console.log(` ${c.yellow}→${c.reset} [${v.name}] ${msg}`);
|
|
100
|
+
if (docTarget) {
|
|
101
|
+
console.log(` ${c.dim}Fix: docguard fix --doc ${docTarget}${c.reset}`);
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
console.log(`\n ${c.dim}Or run: docguard diagnose (for full AI remediation prompt)${c.reset}`);
|
|
107
|
+
}
|
|
108
|
+
} catch (err) {
|
|
109
|
+
console.log(`${c.red} Guard failed: ${err.message}${c.reset}`);
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
function collectWatchDirs(rootDir) {
|
|
114
|
+
const dirs = [rootDir];
|
|
115
|
+
|
|
116
|
+
function walk(dir) {
|
|
117
|
+
try {
|
|
118
|
+
const entries = readdirSync(dir);
|
|
119
|
+
for (const entry of entries) {
|
|
120
|
+
if (IGNORE_DIRS.has(entry)) continue;
|
|
121
|
+
if (entry.startsWith('.')) continue;
|
|
122
|
+
|
|
123
|
+
const fullPath = resolve(dir, entry);
|
|
124
|
+
try {
|
|
125
|
+
const stat = statSync(fullPath);
|
|
126
|
+
if (stat.isDirectory()) {
|
|
127
|
+
dirs.push(fullPath);
|
|
128
|
+
walk(fullPath);
|
|
129
|
+
}
|
|
130
|
+
} catch { /* skip unreadable */ }
|
|
131
|
+
}
|
|
132
|
+
} catch { /* skip unreadable */ }
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
// Always watch docs-canonical explicitly
|
|
136
|
+
const docsDir = resolve(rootDir, 'docs-canonical');
|
|
137
|
+
if (existsSync(docsDir) && !dirs.includes(docsDir)) {
|
|
138
|
+
dirs.push(docsDir);
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
walk(rootDir);
|
|
142
|
+
return dirs;
|
|
143
|
+
}
|