docguard-cli 0.9.11 → 0.11.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 +59 -106
- package/README.md +26 -3
- package/cli/commands/diagnose.mjs +171 -58
- package/cli/commands/diff.mjs +110 -137
- package/cli/commands/fix.mjs +152 -4
- package/cli/commands/generate.mjs +148 -27
- package/cli/commands/guard.mjs +45 -24
- package/cli/commands/hooks.mjs +40 -2
- package/cli/commands/score.mjs +22 -0
- package/cli/commands/sync.mjs +123 -0
- package/cli/docguard.mjs +22 -0
- package/cli/scanners/api-doc.mjs +122 -0
- package/cli/scanners/doc-tools.mjs +1 -1
- package/cli/scanners/frontend.mjs +438 -0
- package/cli/scanners/integrations.mjs +116 -0
- package/cli/scanners/memory-plan.mjs +242 -0
- package/cli/scanners/project-type.mjs +310 -0
- package/cli/scanners/routes.mjs +194 -32
- package/cli/scanners/schemas.mjs +174 -1
- package/cli/shared-source.mjs +247 -0
- package/cli/validators/api-surface.mjs +254 -0
- package/cli/validators/architecture.mjs +4 -3
- package/cli/validators/changelog.mjs +45 -4
- package/cli/validators/doc-quality.mjs +3 -2
- package/cli/validators/docs-coverage.mjs +9 -14
- package/cli/validators/docs-diff.mjs +117 -66
- package/cli/validators/docs-sync.mjs +30 -24
- package/cli/validators/drift.mjs +6 -2
- package/cli/validators/environment.mjs +43 -3
- package/cli/validators/freshness.mjs +4 -3
- package/cli/validators/metadata-sync.mjs +17 -7
- package/cli/validators/metrics-consistency.mjs +9 -4
- package/cli/validators/schema-sync.mjs +19 -10
- package/cli/validators/security.mjs +20 -7
- package/cli/validators/structure.mjs +8 -1
- package/cli/validators/test-spec.mjs +26 -17
- package/cli/validators/todo-tracking.mjs +21 -8
- package/cli/validators/traceability.mjs +61 -36
- package/cli/writers/api-reference.mjs +101 -0
- package/cli/writers/mechanical.mjs +116 -0
- package/cli/writers/sections.mjs +148 -0
- package/commands/docguard.fix.md +19 -3
- package/commands/docguard.guard.md +5 -4
- package/docs/doc-sections.md +37 -0
- package/docs/quickstart.md +1 -1
- package/extensions/spec-kit-docguard/README.md +8 -5
- package/extensions/spec-kit-docguard/commands/fix.md +74 -0
- package/extensions/spec-kit-docguard/commands/generate.md +25 -2
- package/extensions/spec-kit-docguard/commands/guard.md +6 -5
- package/extensions/spec-kit-docguard/commands/sync.md +62 -0
- package/extensions/spec-kit-docguard/skills/docguard-fix/SKILL.md +11 -1
- package/extensions/spec-kit-docguard/skills/docguard-guard/SKILL.md +3 -2
- package/extensions/spec-kit-docguard/skills/docguard-sync/SKILL.md +111 -0
- package/package.json +1 -1
- package/templates/commands/docguard.guard.md +3 -3
|
@@ -11,6 +11,8 @@ import { c } from '../shared.mjs';
|
|
|
11
11
|
import { detectDocTools } from '../scanners/doc-tools.mjs';
|
|
12
12
|
import { scanRoutesDeep } from '../scanners/routes.mjs';
|
|
13
13
|
import { scanSchemasDeep, generateERDiagram } from '../scanners/schemas.mjs';
|
|
14
|
+
import { buildMemoryPlan } from '../scanners/memory-plan.mjs';
|
|
15
|
+
import { upsertSection } from '../writers/sections.mjs';
|
|
14
16
|
|
|
15
17
|
const IGNORE_DIRS = new Set([
|
|
16
18
|
'node_modules', '.git', '.next', 'dist', 'build', 'coverage',
|
|
@@ -111,7 +113,96 @@ function appendStandardsCitation(content, docName) {
|
|
|
111
113
|
return content.trimEnd() + '\n' + footer;
|
|
112
114
|
}
|
|
113
115
|
|
|
116
|
+
/**
|
|
117
|
+
* `docguard generate --plan` — AI-powered Generate.
|
|
118
|
+
* Builds the code-truth skeleton (marked sections) and emits the agent task
|
|
119
|
+
* manifest. `--format json` → machine manifest for an agent; text → summary.
|
|
120
|
+
* `--write` → scaffold the skeleton docs (code sections filled; prose sections
|
|
121
|
+
* inserted as agent-task placeholders), respecting human prose via markers.
|
|
122
|
+
*/
|
|
123
|
+
export function runGeneratePlan(projectDir, config, flags) {
|
|
124
|
+
const plan = buildMemoryPlan(projectDir, config);
|
|
125
|
+
|
|
126
|
+
if (flags.format === 'json') {
|
|
127
|
+
console.log(JSON.stringify({
|
|
128
|
+
project: config.projectName,
|
|
129
|
+
profile: {
|
|
130
|
+
languages: plan.profile.languages,
|
|
131
|
+
frameworks: plan.profile.frameworks,
|
|
132
|
+
polyglot: plan.profile.polyglot,
|
|
133
|
+
kind: plan.profile.kind,
|
|
134
|
+
ecosystems: plan.profile.ecosystems.map(e => ({ dir: e.dir, language: e.language, framework: e.framework, kind: e.kind })),
|
|
135
|
+
},
|
|
136
|
+
surface: {
|
|
137
|
+
endpoints: plan.surface.endpoints.length,
|
|
138
|
+
entities: plan.surface.entities.length,
|
|
139
|
+
screens: plan.surface.screens.length,
|
|
140
|
+
components: plan.surface.components.length,
|
|
141
|
+
envVars: plan.surface.envVars.length,
|
|
142
|
+
},
|
|
143
|
+
docs: plan.docs.map(d => ({
|
|
144
|
+
path: d.path,
|
|
145
|
+
sections: d.sections.map(s => s.source === 'code'
|
|
146
|
+
? { id: s.id, source: 'code' }
|
|
147
|
+
: { id: s.id, source: 'human', task: s.task, grounding: s.grounding }),
|
|
148
|
+
})),
|
|
149
|
+
agentTasks: plan.agentTasks,
|
|
150
|
+
timestamp: new Date().toISOString(),
|
|
151
|
+
}, null, 2));
|
|
152
|
+
return;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
// --write: scaffold the skeleton docs with code sections + agent-task placeholders.
|
|
156
|
+
if (flags.write) {
|
|
157
|
+
const docsDir = resolve(projectDir, 'docs-canonical');
|
|
158
|
+
if (!existsSync(docsDir)) mkdirSync(docsDir, { recursive: true });
|
|
159
|
+
let wrote = 0;
|
|
160
|
+
for (const doc of plan.docs) {
|
|
161
|
+
const full = resolve(projectDir, doc.path);
|
|
162
|
+
const title = basename(doc.path, '.md').replace(/-/g, ' ');
|
|
163
|
+
let content = existsSync(full)
|
|
164
|
+
? readFileSync(full, 'utf-8')
|
|
165
|
+
: `# ${title}\n\n<!-- docguard:generated true -->\n`;
|
|
166
|
+
for (const sec of doc.sections) {
|
|
167
|
+
const body = sec.source === 'code'
|
|
168
|
+
? sec.body
|
|
169
|
+
: `> **AI task:** ${sec.task}\n<!-- docguard:pending agent writes this section -->`;
|
|
170
|
+
content = upsertSection(content, sec.id, body, { source: sec.source }).content;
|
|
171
|
+
}
|
|
172
|
+
writeFileSync(full, content, 'utf-8');
|
|
173
|
+
wrote++;
|
|
174
|
+
}
|
|
175
|
+
console.log(`${c.bold}🔮 DocGuard Generate --plan --write — ${config.projectName}${c.reset}`);
|
|
176
|
+
console.log(` ${c.green}✅ Scaffolded ${wrote} doc(s)${c.reset} with code-truth sections + ${plan.agentTasks.length} agent task(s).`);
|
|
177
|
+
console.log(` ${c.dim}Now run your AI agent (/docguard.fix) to write the prose sections, then ${c.cyan}docguard guard${c.dim}.${c.reset}\n`);
|
|
178
|
+
return;
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
// Text summary.
|
|
182
|
+
console.log(`${c.bold}🔮 DocGuard Generate Plan — ${config.projectName}${c.reset}`);
|
|
183
|
+
console.log(`${c.dim} ${plan.profile.polyglot ? 'Polyglot' : 'Single-language'}: ${plan.profile.languages.join(', ')} | frameworks: ${plan.profile.frameworks.join(', ') || '—'} | kind: ${plan.profile.kind}${c.reset}\n`);
|
|
184
|
+
console.log(` ${c.bold}Code-truth surface:${c.reset} ${plan.surface.endpoints.length} endpoints · ${plan.surface.entities.length} entities · ${plan.surface.screens.length} screens · ${plan.surface.components.length} components · ${plan.surface.envVars.length} env vars\n`);
|
|
185
|
+
console.log(` ${c.bold}Documents to build (${plan.docs.length}):${c.reset}`);
|
|
186
|
+
for (const d of plan.docs) {
|
|
187
|
+
const code = d.sections.filter(s => s.source === 'code').length;
|
|
188
|
+
const prose = d.sections.filter(s => s.source === 'human').length;
|
|
189
|
+
console.log(` ${c.cyan}${d.path}${c.reset} ${c.dim}(${code} code section(s), ${prose} agent task(s))${c.reset}`);
|
|
190
|
+
}
|
|
191
|
+
console.log(`\n ${c.bold}🤖 Agent tasks (${plan.agentTasks.length}):${c.reset} ${c.dim}prose the AI must write, grounded in scanned facts.${c.reset}`);
|
|
192
|
+
for (const t of plan.agentTasks) {
|
|
193
|
+
console.log(` ${c.dim}• [${t.doc} → ${t.sectionId}] ${t.instruction}${c.reset}`);
|
|
194
|
+
}
|
|
195
|
+
console.log(`\n ${c.dim}Scaffold the skeleton: ${c.cyan}docguard generate --plan --write${c.dim} · Machine manifest: ${c.cyan}--plan --format json${c.reset}\n`);
|
|
196
|
+
}
|
|
197
|
+
|
|
114
198
|
export function runGenerate(projectDir, config, flags) {
|
|
199
|
+
// --plan: emit the AI-powered "memory plan" — the agent task manifest. The CLI
|
|
200
|
+
// builds the code-truth skeleton (marked sections) + tells the agent exactly
|
|
201
|
+
// what prose to write per section. This is the language-aware Generate path.
|
|
202
|
+
if (flags.plan) {
|
|
203
|
+
return runGeneratePlan(projectDir, config, flags);
|
|
204
|
+
}
|
|
205
|
+
|
|
115
206
|
console.log(`${c.bold}🔮 DocGuard Generate — ${config.projectName}${c.reset}`);
|
|
116
207
|
console.log(`${c.dim} Directory: ${projectDir}${c.reset}`);
|
|
117
208
|
console.log(`${c.dim} Scanning codebase to generate canonical documentation...${c.reset}\n`);
|
|
@@ -311,6 +402,8 @@ function detectStack(dir) {
|
|
|
311
402
|
|
|
312
403
|
// ── Project Scanner ────────────────────────────────────────────────────────
|
|
313
404
|
|
|
405
|
+
|
|
406
|
+
|
|
314
407
|
function scanProject(dir) {
|
|
315
408
|
const scan = {
|
|
316
409
|
routes: [],
|
|
@@ -324,6 +417,30 @@ function scanProject(dir) {
|
|
|
324
417
|
totalLines: 0,
|
|
325
418
|
};
|
|
326
419
|
|
|
420
|
+
scanRoutes(dir, scan);
|
|
421
|
+
scanModels(dir, scan);
|
|
422
|
+
scanServices(dir, scan);
|
|
423
|
+
scanTests(dir, scan);
|
|
424
|
+
scanComponents(dir, scan);
|
|
425
|
+
scanMiddlewares(dir, scan);
|
|
426
|
+
scanEnvVars(dir, scan);
|
|
427
|
+
|
|
428
|
+
// Count files and lines
|
|
429
|
+
countFilesAndLines(dir, scan);
|
|
430
|
+
|
|
431
|
+
// ── Filter test files out of source lists ──
|
|
432
|
+
// Test files (*.test.*, *.spec.*, __tests__/) should NOT appear as source files
|
|
433
|
+
const isTestFile = (f) => f.includes('__tests__') || f.includes('__test__') || /\.(test|spec)\.[^.]+$/.test(f);
|
|
434
|
+
scan.routes = scan.routes.filter(f => !isTestFile(f));
|
|
435
|
+
scan.models = scan.models.filter(f => !isTestFile(f));
|
|
436
|
+
scan.services = scan.services.filter(f => !isTestFile(f));
|
|
437
|
+
scan.components = scan.components.filter(f => !isTestFile(f));
|
|
438
|
+
scan.middlewares = scan.middlewares.filter(f => !isTestFile(f));
|
|
439
|
+
|
|
440
|
+
return scan;
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
function scanRoutes(dir, scan) {
|
|
327
444
|
// Find routes
|
|
328
445
|
['src/app/api', 'src/routes', 'routes', 'api', 'src/api'].forEach(routeDir => {
|
|
329
446
|
const fullDir = resolve(dir, routeDir);
|
|
@@ -334,7 +451,10 @@ function scanProject(dir) {
|
|
|
334
451
|
}
|
|
335
452
|
}
|
|
336
453
|
});
|
|
454
|
+
}
|
|
337
455
|
|
|
456
|
+
|
|
457
|
+
function scanModels(dir, scan) {
|
|
338
458
|
// Find models/entities
|
|
339
459
|
['src/models', 'models', 'src/entities', 'entities', 'src/schema', 'schema', 'prisma'].forEach(modelDir => {
|
|
340
460
|
const fullDir = resolve(dir, modelDir);
|
|
@@ -345,7 +465,10 @@ function scanProject(dir) {
|
|
|
345
465
|
}
|
|
346
466
|
}
|
|
347
467
|
});
|
|
468
|
+
}
|
|
348
469
|
|
|
470
|
+
|
|
471
|
+
function scanServices(dir, scan) {
|
|
349
472
|
// Find services
|
|
350
473
|
['src/services', 'services', 'src/lib', 'lib'].forEach(svcDir => {
|
|
351
474
|
const fullDir = resolve(dir, svcDir);
|
|
@@ -356,7 +479,10 @@ function scanProject(dir) {
|
|
|
356
479
|
}
|
|
357
480
|
}
|
|
358
481
|
});
|
|
482
|
+
}
|
|
359
483
|
|
|
484
|
+
|
|
485
|
+
function scanTests(dir, scan) {
|
|
360
486
|
// Find tests — top-level test dirs
|
|
361
487
|
['tests', 'test', '__tests__', 'spec', 'e2e'].forEach(testDir => {
|
|
362
488
|
const fullDir = resolve(dir, testDir);
|
|
@@ -416,7 +542,10 @@ function scanProject(dir) {
|
|
|
416
542
|
break; // Use first found config
|
|
417
543
|
}
|
|
418
544
|
}
|
|
545
|
+
}
|
|
419
546
|
|
|
547
|
+
|
|
548
|
+
function scanComponents(dir, scan) {
|
|
420
549
|
// Find components
|
|
421
550
|
['src/components', 'components', 'src/ui'].forEach(compDir => {
|
|
422
551
|
const fullDir = resolve(dir, compDir);
|
|
@@ -427,7 +556,10 @@ function scanProject(dir) {
|
|
|
427
556
|
}
|
|
428
557
|
}
|
|
429
558
|
});
|
|
559
|
+
}
|
|
430
560
|
|
|
561
|
+
|
|
562
|
+
function scanMiddlewares(dir, scan) {
|
|
431
563
|
// Find middleware
|
|
432
564
|
['src/middleware', 'middleware', 'src/middlewares'].forEach(mwDir => {
|
|
433
565
|
const fullDir = resolve(dir, mwDir);
|
|
@@ -438,7 +570,10 @@ function scanProject(dir) {
|
|
|
438
570
|
}
|
|
439
571
|
}
|
|
440
572
|
});
|
|
573
|
+
}
|
|
574
|
+
|
|
441
575
|
|
|
576
|
+
function scanEnvVars(dir, scan) {
|
|
442
577
|
// Parse .env.example for env vars
|
|
443
578
|
const envExample = resolve(dir, '.env.example');
|
|
444
579
|
if (existsSync(envExample)) {
|
|
@@ -451,20 +586,6 @@ function scanProject(dir) {
|
|
|
451
586
|
}
|
|
452
587
|
}
|
|
453
588
|
}
|
|
454
|
-
|
|
455
|
-
// Count files and lines
|
|
456
|
-
countFilesAndLines(dir, scan);
|
|
457
|
-
|
|
458
|
-
// ── Filter test files out of source lists ──
|
|
459
|
-
// Test files (*.test.*, *.spec.*, __tests__/) should NOT appear as source files
|
|
460
|
-
const isTestFile = (f) => f.includes('__tests__') || f.includes('__test__') || /\.(test|spec)\.[^.]+$/.test(f);
|
|
461
|
-
scan.routes = scan.routes.filter(f => !isTestFile(f));
|
|
462
|
-
scan.models = scan.models.filter(f => !isTestFile(f));
|
|
463
|
-
scan.services = scan.services.filter(f => !isTestFile(f));
|
|
464
|
-
scan.components = scan.components.filter(f => !isTestFile(f));
|
|
465
|
-
scan.middlewares = scan.middlewares.filter(f => !isTestFile(f));
|
|
466
|
-
|
|
467
|
-
return scan;
|
|
468
589
|
}
|
|
469
590
|
|
|
470
591
|
function countFilesAndLines(dir, scan) {
|
|
@@ -535,7 +656,7 @@ function generateArchitecture(dir, config, stack, scan, flags, docTools) {
|
|
|
535
656
|
## 1. Introduction & Goals
|
|
536
657
|
<!-- arc42: §1 — Introduction and Goals -->
|
|
537
658
|
|
|
538
|
-
<!--
|
|
659
|
+
<!-- TBD: Describe what this system does, who it's for, and key quality goals -->
|
|
539
660
|
${config.projectName} is a ${stack.framework || stack.language || 'software'} application.
|
|
540
661
|
|
|
541
662
|
### Quality Goals
|
|
@@ -611,8 +732,8 @@ See \\\`docs-canonical/DEPLOYMENT.md\\\` for details.
|
|
|
611
732
|
| Environment | Infrastructure | URL |
|
|
612
733
|
|-------------|---------------|-----|
|
|
613
734
|
| Development | localhost | http://localhost:3000 |
|
|
614
|
-
| Staging | ${stack.hosting || 'TBD'} | <!--
|
|
615
|
-
| Production | ${stack.hosting || 'TBD'} | <!--
|
|
735
|
+
| Staging | ${stack.hosting || 'TBD'} | <!-- TBD --> |
|
|
736
|
+
| Production | ${stack.hosting || 'TBD'} | <!-- TBD --> |
|
|
616
737
|
|
|
617
738
|
## 8. Crosscutting Concepts
|
|
618
739
|
<!-- arc42: §8 — Crosscutting Concepts -->
|
|
@@ -715,7 +836,7 @@ ${r.description ? `- **Description:** ${r.description}` : ''}
|
|
|
715
836
|
|
|
716
837
|
| Parameter | In | Type | Required | Description |
|
|
717
838
|
|-----------|-----|------|:--------:|-------------|
|
|
718
|
-
| <!--
|
|
839
|
+
| <!-- TBD --> | | | | |
|
|
719
840
|
|
|
720
841
|
| Status | Response |
|
|
721
842
|
|--------|----------|
|
|
@@ -742,7 +863,7 @@ ${routeDetails}`;
|
|
|
742
863
|
|----------|-------|
|
|
743
864
|
| **Status** |  |
|
|
744
865
|
| **Base URL** | \`http://localhost:3000\` |
|
|
745
|
-
| **Auth** | <!--
|
|
866
|
+
| **Auth** | <!-- TBD: Describe auth mechanism --> |
|
|
746
867
|
| **Total Endpoints** | ${deepRoutes.length} |
|
|
747
868
|
| **Source** | ${deepRoutes[0]?.source || 'code scan'} |
|
|
748
869
|
|
|
@@ -826,7 +947,7 @@ function generateDataModel(dir, config, stack, scan, flags, deepSchemas) {
|
|
|
826
947
|
|
|
827
948
|
| Field | Type | Required | Default | Constraints | Description |
|
|
828
949
|
|-------|------|----------|---------|-------------|-------------|
|
|
829
|
-
| <!--
|
|
950
|
+
| <!-- TBD: Fill in fields --> | | | | | |
|
|
830
951
|
`;
|
|
831
952
|
}
|
|
832
953
|
const fieldRows = e.fields.map(f =>
|
|
@@ -913,7 +1034,7 @@ ${erDiagram}
|
|
|
913
1034
|
|
|
914
1035
|
| Table | Index Name | Fields | Type | Purpose |
|
|
915
1036
|
|-------|-----------|--------|------|---------|
|
|
916
|
-
| <!--
|
|
1037
|
+
| <!-- TBD: Document indexes --> | | | | |
|
|
917
1038
|
|
|
918
1039
|
---
|
|
919
1040
|
|
|
@@ -1046,9 +1167,9 @@ function generateTestSpec(dir, config, stack, scan, flags) {
|
|
|
1046
1167
|
|
|
1047
1168
|
| Metric | Target | Current |
|
|
1048
1169
|
|--------|:------:|:-------:|
|
|
1049
|
-
| Line Coverage | 80% | <!--
|
|
1050
|
-
| Branch Coverage | 70% | <!--
|
|
1051
|
-
| Function Coverage | 80% | <!--
|
|
1170
|
+
| Line Coverage | 80% | <!-- TBD --> |
|
|
1171
|
+
| Branch Coverage | 70% | <!-- TBD --> |
|
|
1172
|
+
| Function Coverage | 80% | <!-- TBD --> |
|
|
1052
1173
|
|
|
1053
1174
|
## Service-to-Test Map
|
|
1054
1175
|
|
|
@@ -1103,7 +1224,7 @@ function generateSecurity(dir, config, stack, scan, flags) {
|
|
|
1103
1224
|
|
|
1104
1225
|
| Method | Provider | Token Type | Expiry |
|
|
1105
1226
|
|--------|---------|-----------|--------|
|
|
1106
|
-
| ${stack.auth || '<!--
|
|
1227
|
+
| ${stack.auth || '<!-- TBD -->'} | | | |
|
|
1107
1228
|
|
|
1108
1229
|
## Authorization
|
|
1109
1230
|
|
|
@@ -1117,8 +1238,8 @@ function generateSecurity(dir, config, stack, scan, flags) {
|
|
|
1117
1238
|
| Secret | Storage | Rotation | Access |
|
|
1118
1239
|
|--------|---------|----------|--------|
|
|
1119
1240
|
${scan.envVars.filter(v => isSecretVar(v.name)).map(v =>
|
|
1120
|
-
`| \`${v.name}\` | Environment Variable | <!--
|
|
1121
|
-
).join('\n') || '| <!--
|
|
1241
|
+
`| \`${v.name}\` | Environment Variable | <!-- TBD --> | Application |`
|
|
1242
|
+
).join('\n') || '| <!-- TBD --> | | | |'}
|
|
1122
1243
|
|
|
1123
1244
|
## Security Rules
|
|
1124
1245
|
|
package/cli/commands/guard.mjs
CHANGED
|
@@ -20,6 +20,7 @@ import { validateArchitecture } from '../validators/architecture.mjs';
|
|
|
20
20
|
import { validateFreshness } from '../validators/freshness.mjs';
|
|
21
21
|
import { validateTraceability } from '../validators/traceability.mjs';
|
|
22
22
|
import { validateDocsDiff } from '../validators/docs-diff.mjs';
|
|
23
|
+
import { validateApiSurface } from '../validators/api-surface.mjs';
|
|
23
24
|
import { validateMetadataSync } from '../validators/metadata-sync.mjs';
|
|
24
25
|
import { validateMetricsConsistency } from '../validators/metrics-consistency.mjs';
|
|
25
26
|
import { validateDocsCoverage } from '../validators/docs-coverage.mjs';
|
|
@@ -32,6 +33,38 @@ import { validateSpecKitIntegration } from '../scanners/speckit.mjs';
|
|
|
32
33
|
* Internal guard — returns structured data, no console output, no process.exit.
|
|
33
34
|
* Used by diagnose, ci, and guard --format json.
|
|
34
35
|
*/
|
|
36
|
+
/**
|
|
37
|
+
* Classify a validator result into a status + quality badge.
|
|
38
|
+
*
|
|
39
|
+
* Critically, a check that found NOTHING to validate (no errors, no warnings,
|
|
40
|
+
* total === 0) — or that explicitly reports `applicable === false` — is status
|
|
41
|
+
* 'na' (not applicable), NOT 'pass'. This prevents a validator from rendering a
|
|
42
|
+
* confident green ✅ when it actually checked nothing (the root cause of the
|
|
43
|
+
* "clean bill of health on out-of-sync docs" incident).
|
|
44
|
+
*/
|
|
45
|
+
export function classifyResult(result) {
|
|
46
|
+
const hasErrors = result.errors.length > 0;
|
|
47
|
+
const hasWarnings = result.warnings.length > 0;
|
|
48
|
+
|
|
49
|
+
if (!hasErrors && !hasWarnings && (result.applicable === false || result.total === 0)) {
|
|
50
|
+
return { status: 'na', quality: null };
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
const status = hasErrors ? 'fail' : hasWarnings ? 'warn' : 'pass';
|
|
54
|
+
|
|
55
|
+
// Quality label: HIGH/MEDIUM/LOW (inspired by CJE quality stratification, Lopez et al. TRACE 2026)
|
|
56
|
+
let quality;
|
|
57
|
+
if (hasErrors) {
|
|
58
|
+
quality = 'LOW';
|
|
59
|
+
} else if (hasWarnings) {
|
|
60
|
+
quality = 'MEDIUM';
|
|
61
|
+
} else {
|
|
62
|
+
const ratio = result.total > 0 ? result.passed / result.total : 1;
|
|
63
|
+
quality = ratio >= 0.9 ? 'HIGH' : 'MEDIUM';
|
|
64
|
+
}
|
|
65
|
+
return { status, quality };
|
|
66
|
+
}
|
|
67
|
+
|
|
35
68
|
export function runGuardInternal(projectDir, config) {
|
|
36
69
|
const validators = config.validators || {};
|
|
37
70
|
const results = [];
|
|
@@ -40,7 +73,7 @@ export function runGuardInternal(projectDir, config) {
|
|
|
40
73
|
{ key: 'structure', name: 'Structure', fn: () => validateStructure(projectDir, config) },
|
|
41
74
|
{ key: 'structure', name: 'Doc Sections', fn: () => validateDocSections(projectDir, config) },
|
|
42
75
|
{ key: 'docsSync', name: 'Docs-Sync', fn: () => validateDocsSync(projectDir, config) },
|
|
43
|
-
{ key: 'drift', name: 'Drift', fn: () => validateDrift(projectDir, config) },
|
|
76
|
+
{ key: 'drift', name: 'Drift-Comments', fn: () => validateDrift(projectDir, config) },
|
|
44
77
|
{ key: 'changelog', name: 'Changelog', fn: () => validateChangelog(projectDir, config) },
|
|
45
78
|
{ key: 'testSpec', name: 'Test-Spec', fn: () => validateTestSpec(projectDir, config) },
|
|
46
79
|
{ key: 'environment', name: 'Environment', fn: () => validateEnvironment(projectDir, config) },
|
|
@@ -60,6 +93,7 @@ export function runGuardInternal(projectDir, config) {
|
|
|
60
93
|
}},
|
|
61
94
|
{ key: 'traceability', name: 'Traceability', fn: () => validateTraceability(projectDir, config) },
|
|
62
95
|
{ key: 'docsDiff', name: 'Docs-Diff', fn: () => validateDocsDiff(projectDir, config) },
|
|
96
|
+
{ key: 'apiSurface', name: 'API-Surface', fn: () => validateApiSurface(projectDir, config) },
|
|
63
97
|
{ key: 'metadataSync', name: 'Metadata-Sync', fn: () => validateMetadataSync(projectDir, config) },
|
|
64
98
|
{ key: 'docsCoverage', name: 'Docs-Coverage', fn: () => validateDocsCoverage(projectDir, config) },
|
|
65
99
|
{ key: 'docQuality', name: 'Doc-Quality', fn: () => validateDocQuality(projectDir, config) },
|
|
@@ -77,23 +111,7 @@ export function runGuardInternal(projectDir, config) {
|
|
|
77
111
|
|
|
78
112
|
try {
|
|
79
113
|
const result = fn();
|
|
80
|
-
|
|
81
|
-
const hasWarnings = result.warnings.length > 0;
|
|
82
|
-
const status = hasErrors ? 'fail' : hasWarnings ? 'warn' : 'pass';
|
|
83
|
-
|
|
84
|
-
// Quality label: HIGH/MEDIUM/LOW (inspired by CJE quality stratification, Lopez et al. TRACE 2026)
|
|
85
|
-
let quality;
|
|
86
|
-
if (hasErrors) {
|
|
87
|
-
quality = 'LOW';
|
|
88
|
-
} else if (hasWarnings) {
|
|
89
|
-
quality = 'MEDIUM';
|
|
90
|
-
} else {
|
|
91
|
-
// Pass — check coverage ratio for HIGH vs MEDIUM
|
|
92
|
-
const ratio = result.total > 0 ? result.passed / result.total : 1;
|
|
93
|
-
quality = ratio >= 0.9 ? 'HIGH' : 'MEDIUM';
|
|
94
|
-
}
|
|
95
|
-
|
|
96
|
-
results.push({ ...result, name, key, status, quality });
|
|
114
|
+
results.push({ ...result, name, key, ...classifyResult(result) });
|
|
97
115
|
} catch (err) {
|
|
98
116
|
results.push({ name, key, status: 'fail', quality: 'LOW', errors: [err.message], warnings: [], passed: 0, total: 1 });
|
|
99
117
|
}
|
|
@@ -103,12 +121,7 @@ export function runGuardInternal(projectDir, config) {
|
|
|
103
121
|
if (validators.metricsConsistency !== false) {
|
|
104
122
|
try {
|
|
105
123
|
const result = validateMetricsConsistency(projectDir, config, results);
|
|
106
|
-
|
|
107
|
-
const hasWarnings = result.warnings.length > 0;
|
|
108
|
-
const status = hasErrors ? 'fail' : hasWarnings ? 'warn' : 'pass';
|
|
109
|
-
const ratio = result.total > 0 ? result.passed / result.total : 1;
|
|
110
|
-
const quality = hasErrors ? 'LOW' : hasWarnings ? 'MEDIUM' : ratio >= 0.9 ? 'HIGH' : 'MEDIUM';
|
|
111
|
-
results.push({ ...result, name: 'Metrics-Consistency', key: 'metricsConsistency', status, quality });
|
|
124
|
+
results.push({ ...result, name: 'Metrics-Consistency', key: 'metricsConsistency', ...classifyResult(result) });
|
|
112
125
|
} catch (err) {
|
|
113
126
|
results.push({ name: 'Metrics-Consistency', key: 'metricsConsistency', status: 'fail', quality: 'LOW', errors: [err.message], warnings: [], passed: 0, total: 1 });
|
|
114
127
|
}
|
|
@@ -161,6 +174,14 @@ export function runGuard(projectDir, config, flags) {
|
|
|
161
174
|
continue;
|
|
162
175
|
}
|
|
163
176
|
|
|
177
|
+
// Not applicable — nothing to validate. Render neutrally (NOT a green pass)
|
|
178
|
+
// so the reader can tell "checked and clean" apart from "nothing checked".
|
|
179
|
+
if (v.status === 'na') {
|
|
180
|
+
const reason = v.note ? ` ${c.dim}(${v.note})${c.reset}` : ` ${c.dim}(nothing to validate)${c.reset}`;
|
|
181
|
+
console.log(` ${c.dim}➖ ${v.name}${c.reset} ${c.dim}[N/A]${c.reset}${reason}`);
|
|
182
|
+
continue;
|
|
183
|
+
}
|
|
184
|
+
|
|
164
185
|
// Quality label badge
|
|
165
186
|
const qColor = v.quality === 'HIGH' ? c.green : v.quality === 'MEDIUM' ? c.yellow : c.red;
|
|
166
187
|
const qBadge = `${qColor}[${v.quality}]${c.reset}`;
|
package/cli/commands/hooks.mjs
CHANGED
|
@@ -128,6 +128,40 @@ exit 0
|
|
|
128
128
|
},
|
|
129
129
|
};
|
|
130
130
|
|
|
131
|
+
// Auto-fix variant of the pre-commit hook: apply deterministic fixes, re-stage,
|
|
132
|
+
// then validate. Installed with: docguard hooks --type pre-commit --auto-fix
|
|
133
|
+
const PRE_COMMIT_AUTOFIX = `#!/bin/sh
|
|
134
|
+
# DocGuard pre-commit hook (auto-fix mode)
|
|
135
|
+
# Applies deterministic (no-LLM) fixes, then validates.
|
|
136
|
+
# Install: docguard hooks --type pre-commit --auto-fix
|
|
137
|
+
# Remove: rm .git/hooks/pre-commit
|
|
138
|
+
|
|
139
|
+
RUN="npx docguard-cli"
|
|
140
|
+
if command -v docguard >/dev/null 2>&1; then RUN="docguard"; fi
|
|
141
|
+
|
|
142
|
+
echo "🛡️ DocGuard: applying mechanical fixes…"
|
|
143
|
+
# 1. Deterministically remove stale documented endpoints (safe, no AI).
|
|
144
|
+
$RUN fix --write
|
|
145
|
+
# 2. Re-stage anything DocGuard rewrote so the fix is part of THIS commit.
|
|
146
|
+
git add docs-canonical/ 2>/dev/null
|
|
147
|
+
|
|
148
|
+
# 3. Validate.
|
|
149
|
+
$RUN guard
|
|
150
|
+
EXIT_CODE=$?
|
|
151
|
+
|
|
152
|
+
if [ $EXIT_CODE -eq 1 ]; then
|
|
153
|
+
echo ""
|
|
154
|
+
echo "❌ DocGuard guard FAILED — commit blocked."
|
|
155
|
+
echo " Remaining issues need an AI agent (content rewrites, not mechanical):"
|
|
156
|
+
echo " Run: $RUN diagnose (emits ready-to-paste agent fix prompts)"
|
|
157
|
+
echo " To skip: git commit --no-verify"
|
|
158
|
+
exit 1
|
|
159
|
+
elif [ $EXIT_CODE -eq 2 ]; then
|
|
160
|
+
echo "⚠️ DocGuard guard found warnings — commit allowed"
|
|
161
|
+
fi
|
|
162
|
+
exit 0
|
|
163
|
+
`;
|
|
164
|
+
|
|
131
165
|
export function runHooks(projectDir, config, flags) {
|
|
132
166
|
console.log(`${c.bold}🪝 DocGuard Hooks — ${config.projectName}${c.reset}`);
|
|
133
167
|
console.log(`${c.dim} Directory: ${projectDir}${c.reset}\n`);
|
|
@@ -208,9 +242,13 @@ export function runHooks(projectDir, config, flags) {
|
|
|
208
242
|
continue;
|
|
209
243
|
}
|
|
210
244
|
|
|
211
|
-
|
|
245
|
+
// pre-commit supports an auto-fix variant (applies mechanical fixes first).
|
|
246
|
+
const useAutofix = name === 'pre-commit' && flags.autoFix;
|
|
247
|
+
const content = useAutofix ? PRE_COMMIT_AUTOFIX : HOOKS[name].content;
|
|
248
|
+
writeFileSync(hookPath, content, 'utf-8');
|
|
212
249
|
chmodSync(hookPath, 0o755); // Make executable
|
|
213
|
-
|
|
250
|
+
const desc = useAutofix ? 'Apply mechanical fixes (fix --write) then guard' : HOOKS[name].description;
|
|
251
|
+
console.log(` ${c.green}✅ ${name}${c.reset}: ${desc}`);
|
|
214
252
|
installed++;
|
|
215
253
|
}
|
|
216
254
|
|
package/cli/commands/score.mjs
CHANGED
|
@@ -26,12 +26,30 @@ export function runScore(projectDir, config, flags) {
|
|
|
26
26
|
|
|
27
27
|
const { scores, totalScore, grade, details } = calcAllScores(projectDir, config);
|
|
28
28
|
|
|
29
|
+
// ── "Memory" framing: split signals into Completeness vs Accuracy ──
|
|
30
|
+
// Completeness = "is the memory whole?" Accuracy = "does it match code?"
|
|
31
|
+
// No weight changes — just a derived view of the existing per-category scores.
|
|
32
|
+
const COMPLETENESS = new Set(['structure', 'docQuality']);
|
|
33
|
+
const memory = (() => {
|
|
34
|
+
let cW = 0, cP = 0, aW = 0, aP = 0;
|
|
35
|
+
for (const [cat, s] of Object.entries(scores)) {
|
|
36
|
+
const w = WEIGHTS[cat] || 0;
|
|
37
|
+
if (COMPLETENESS.has(cat)) { cW += w; cP += s * w; }
|
|
38
|
+
else { aW += w; aP += s * w; }
|
|
39
|
+
}
|
|
40
|
+
return {
|
|
41
|
+
completeness: cW ? Math.round(cP / cW) : 0,
|
|
42
|
+
accuracy: aW ? Math.round(aP / aW) : 0,
|
|
43
|
+
};
|
|
44
|
+
})();
|
|
45
|
+
|
|
29
46
|
// ── Display Results ──
|
|
30
47
|
if (flags.format === 'json') {
|
|
31
48
|
const result = {
|
|
32
49
|
project: config.projectName,
|
|
33
50
|
score: totalScore,
|
|
34
51
|
grade,
|
|
52
|
+
memory,
|
|
35
53
|
categories: {},
|
|
36
54
|
};
|
|
37
55
|
for (const [cat, score] of Object.entries(scores)) {
|
|
@@ -39,6 +57,7 @@ export function runScore(projectDir, config, flags) {
|
|
|
39
57
|
score,
|
|
40
58
|
weight: WEIGHTS[cat],
|
|
41
59
|
weighted: Math.round((score / 100) * WEIGHTS[cat]),
|
|
60
|
+
axis: COMPLETENESS.has(cat) ? 'completeness' : 'accuracy',
|
|
42
61
|
};
|
|
43
62
|
}
|
|
44
63
|
console.log(JSON.stringify(result, null, 2));
|
|
@@ -60,6 +79,9 @@ export function runScore(projectDir, config, flags) {
|
|
|
60
79
|
|
|
61
80
|
const gradeColor = totalScore >= 80 ? c.green : totalScore >= 60 ? c.yellow : c.red;
|
|
62
81
|
console.log(` ${gradeColor}${c.bold}CDD Maturity Score: ${totalScore}/100 (${grade})${c.reset}`);
|
|
82
|
+
// Memory framing: is the documentation memory COMPLETE and ACCURATE?
|
|
83
|
+
const memColor = (s) => s >= 80 ? c.green : s >= 60 ? c.yellow : c.red;
|
|
84
|
+
console.log(` ${c.dim}Memory:${c.reset} ${memColor(memory.completeness)}Completeness ${memory.completeness}%${c.reset} ${c.dim}·${c.reset} ${memColor(memory.accuracy)}Accuracy ${memory.accuracy}%${c.reset}`);
|
|
63
85
|
|
|
64
86
|
// Grade description
|
|
65
87
|
const descriptions = {
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Sync Command — keep the documentation memory ALWAYS UP TO DATE.
|
|
3
|
+
*
|
|
4
|
+
* Re-derives the code-truth surface (endpoints, entities, screens, tech-stack,
|
|
5
|
+
* env vars) and refreshes the matching `source=code` sections of existing
|
|
6
|
+
* canonical docs IN PLACE — mechanically, no LLM, idempotent. Human prose is
|
|
7
|
+
* never touched (it lives outside markers / in `source=human` sections).
|
|
8
|
+
*
|
|
9
|
+
* When a code section changes, the prose sections in that doc are flagged for
|
|
10
|
+
* agent review (e.g. "endpoints changed → re-read the API overview").
|
|
11
|
+
*
|
|
12
|
+
* Default is a DRY RUN (preview); `--write` applies. `--since <ref>` adds the
|
|
13
|
+
* git diff as context. Only edits docguard:generated docs unless `--force`.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import { existsSync, readFileSync, writeFileSync } from 'node:fs';
|
|
17
|
+
import { resolve } from 'node:path';
|
|
18
|
+
import { execFileSync } from 'node:child_process';
|
|
19
|
+
import { c } from '../shared.mjs';
|
|
20
|
+
import { buildMemoryPlan } from '../scanners/memory-plan.mjs';
|
|
21
|
+
import { getSection, replaceSection } from '../writers/sections.mjs';
|
|
22
|
+
import { hasGeneratedMarker } from '../writers/api-reference.mjs';
|
|
23
|
+
|
|
24
|
+
function gitChangedFiles(projectDir, since) {
|
|
25
|
+
const run = (args) => {
|
|
26
|
+
try {
|
|
27
|
+
return execFileSync('git', args, { cwd: projectDir, encoding: 'utf-8', stdio: ['ignore', 'pipe', 'ignore'] })
|
|
28
|
+
.split('\n').map(s => s.trim()).filter(Boolean);
|
|
29
|
+
} catch { return null; }
|
|
30
|
+
};
|
|
31
|
+
const committed = run(['diff', '--name-only', `${since}...HEAD`]);
|
|
32
|
+
if (committed === null) return null;
|
|
33
|
+
const working = run(['diff', '--name-only', since]) || [];
|
|
34
|
+
return [...new Set([...committed, ...working])];
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export function runSync(projectDir, config, flags) {
|
|
38
|
+
const plan = buildMemoryPlan(projectDir, config);
|
|
39
|
+
const apply = !!flags.write;
|
|
40
|
+
const isJson = flags.format === 'json';
|
|
41
|
+
const changed = flags.since ? gitChangedFiles(projectDir, flags.since) : null;
|
|
42
|
+
|
|
43
|
+
const updates = []; // { doc, section, status }
|
|
44
|
+
const reviews = []; // { doc, section, reason }
|
|
45
|
+
const skipped = []; // { doc, reason }
|
|
46
|
+
|
|
47
|
+
for (const doc of plan.docs) {
|
|
48
|
+
const full = resolve(projectDir, doc.path);
|
|
49
|
+
if (!existsSync(full)) {
|
|
50
|
+
skipped.push({ doc: doc.path, reason: 'not present — run `generate --plan --write` to create it' });
|
|
51
|
+
continue;
|
|
52
|
+
}
|
|
53
|
+
let content = readFileSync(full, 'utf-8');
|
|
54
|
+
if (!hasGeneratedMarker(content) && !flags.force) {
|
|
55
|
+
skipped.push({ doc: doc.path, reason: 'not marked docguard:generated (use --force to sync anyway)' });
|
|
56
|
+
continue;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
let docChanged = false;
|
|
60
|
+
let codeSectionChanged = false;
|
|
61
|
+
for (const sec of doc.sections) {
|
|
62
|
+
if (sec.source !== 'code') continue;
|
|
63
|
+
const existing = getSection(content, sec.id);
|
|
64
|
+
if (!existing) continue; // sync refreshes sections that already exist
|
|
65
|
+
if (existing.body.trim() === String(sec.body).trim()) continue; // already current
|
|
66
|
+
codeSectionChanged = true;
|
|
67
|
+
updates.push({ doc: doc.path, section: sec.id, status: apply ? 'updated' : 'stale' });
|
|
68
|
+
if (apply) { content = replaceSection(content, sec.id, sec.body).content; docChanged = true; }
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
// If code changed, the prose around it may need an agent's eyes.
|
|
72
|
+
if (codeSectionChanged) {
|
|
73
|
+
for (const sec of doc.sections) {
|
|
74
|
+
if (sec.source === 'human') {
|
|
75
|
+
reviews.push({ doc: doc.path, section: sec.id, reason: 'a code section in this doc changed — review the prose' });
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
if (apply && docChanged) writeFileSync(full, content, 'utf-8');
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
if (isJson) {
|
|
84
|
+
console.log(JSON.stringify({
|
|
85
|
+
project: config.projectName,
|
|
86
|
+
since: flags.since || null,
|
|
87
|
+
changedFiles: changed,
|
|
88
|
+
applied: apply,
|
|
89
|
+
updates,
|
|
90
|
+
reviews,
|
|
91
|
+
skipped,
|
|
92
|
+
timestamp: new Date().toISOString(),
|
|
93
|
+
}, null, 2));
|
|
94
|
+
return;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
console.log(`${c.bold}🔄 DocGuard Sync — ${config.projectName}${c.reset}`);
|
|
98
|
+
if (flags.since) {
|
|
99
|
+
const n = changed === null ? 'git unavailable' : `${changed.length} file(s) changed since ${flags.since}`;
|
|
100
|
+
console.log(`${c.dim} ${n}${c.reset}`);
|
|
101
|
+
}
|
|
102
|
+
console.log(`${c.dim} ${apply ? 'Applying' : 'Dry run (use --write to apply)'}${c.reset}\n`);
|
|
103
|
+
|
|
104
|
+
if (updates.length === 0) {
|
|
105
|
+
console.log(` ${c.green}✅ Documentation memory is up to date — no code-truth sections drifted.${c.reset}\n`);
|
|
106
|
+
} else {
|
|
107
|
+
console.log(` ${apply ? c.green : c.yellow}${apply ? '✅ Refreshed' : '⚠️ Stale'} ${updates.length} code-truth section(s):${c.reset}`);
|
|
108
|
+
for (const u of updates) console.log(` ${apply ? c.green : c.yellow}${apply ? '↻' : '•'} ${u.doc} → ${u.section}${c.reset}`);
|
|
109
|
+
if (reviews.length > 0) {
|
|
110
|
+
console.log(`\n ${c.bold}🤖 Prose to review (${reviews.length}) — code changed near these sections:${c.reset}`);
|
|
111
|
+
for (const r of reviews) console.log(` ${c.dim}• ${r.doc} → ${r.section}${c.reset}`);
|
|
112
|
+
console.log(` ${c.dim}Run your AI agent (/docguard.fix) to refresh the prose, then ${c.cyan}docguard guard${c.dim}.${c.reset}`);
|
|
113
|
+
}
|
|
114
|
+
if (!apply) console.log(`\n ${c.dim}Apply mechanical refreshes: ${c.cyan}docguard sync --write${c.reset}`);
|
|
115
|
+
console.log('');
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
if (skipped.length > 0 && flags.verbose) {
|
|
119
|
+
console.log(` ${c.dim}Skipped:${c.reset}`);
|
|
120
|
+
for (const s of skipped) console.log(` ${c.dim}- ${s.doc}: ${s.reason}${c.reset}`);
|
|
121
|
+
console.log('');
|
|
122
|
+
}
|
|
123
|
+
}
|