docguard-cli 0.8.2 → 0.9.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -223,13 +223,13 @@ $ npx docguard-cli guard
223
223
 
224
224
  ---
225
225
 
226
- ## 14 Validators
226
+ ## 19 Validators
227
227
 
228
228
  | # | Validator | What It Checks | Default |
229
229
  |---|-----------|---------------|---------|
230
230
  | 1 | **Structure** | Required CDD files exist | ✅ On |
231
231
  | 2 | **Doc Sections** | Canonical docs have required sections | ✅ On |
232
- | 3 | **Docs-Sync** | Routes/services referenced in docs | ✅ On |
232
+ | 3 | **Docs-Sync** | Routes/services referenced in docs + OpenAPI cross-check | ✅ On |
233
233
  | 4 | **Drift** | `// DRIFT:` comments logged in DRIFT-LOG.md | ✅ On |
234
234
  | 5 | **Changelog** | CHANGELOG.md has [Unreleased] section | ✅ On |
235
235
  | 6 | **Test-Spec** | Tests exist per TEST-SPEC.md rules | ✅ On |
@@ -237,16 +237,19 @@ $ npx docguard-cli guard
237
237
  | 8 | **Security** | No hardcoded secrets in source code | ✅ On |
238
238
  | 9 | **Architecture** | Imports follow layer boundaries | ✅ On |
239
239
  | 10 | **Freshness** | Docs not stale relative to code changes | ✅ On |
240
- | 11 | **Traceability** | Canonical docs linked to source code | ✅ On |
240
+ | 11 | **Traceability** | Canonical docs linked to source + V-Model requirement IDs | ✅ On |
241
241
  | 12 | **Docs-Diff** | Code artifacts match documented entities | ✅ On |
242
242
  | 13 | **Metadata-Sync** | Version refs consistent across docs | ✅ On |
243
243
  | 14 | **Docs-Coverage** | Code features referenced in documentation | ✅ On |
244
244
  | 15 | **Metrics-Consistency** | Hardcoded numbers match actual counts | ✅ On |
245
- | 16 | **Docs-Sync** | Source files have matching doc entries | ✅ On |
245
+ | 16 | **Doc-Quality** | Writing quality (readability, passive voice, atomicity) | ✅ On |
246
+ | 17 | **TODO-Tracking** | Untracked TODOs/FIXMEs and skipped tests | ✅ On |
247
+ | 18 | **Schema-Sync** | Database models documented in DATA-MODEL.md | ✅ On |
248
+ | 19 | **Spec-Kit** | GitHub Spec Kit artifact detection and CDD mapping | ✅ On |
246
249
 
247
250
  ---
248
251
 
249
- ## 16 Templates
252
+ ## 18 Templates
250
253
 
251
254
  Every template includes professional metadata: `docguard:version`, `docguard:status`, badges, and revision history.
252
255
 
@@ -260,6 +263,7 @@ Every template includes professional metadata: `docguard:version`, `docguard:sta
260
263
  | DEPLOYMENT.md | Canonical | Infrastructure, CI/CD, DNS |
261
264
  | ADR.md | Canonical | Architecture Decision Records |
262
265
  | ROADMAP.md | Canonical | Project phases, feature tracking |
266
+ | REQUIREMENTS.md | Canonical | Requirement IDs, V-Model traceability |
263
267
  | KNOWN-GOTCHAS.md | Implementation | Symptom/gotcha/fix entries |
264
268
  | TROUBLESHOOTING.md | Implementation | Error diagnosis guides |
265
269
  | RUNBOOKS.md | Implementation | Operational procedures |
@@ -268,6 +272,7 @@ Every template includes professional metadata: `docguard:version`, `docguard:sta
268
272
  | AGENTS.md | Agent | AI agent behavior rules |
269
273
  | CHANGELOG.md | Tracking | Change log |
270
274
  | DRIFT-LOG.md | Tracking | Deviation tracking |
275
+ | llms.txt | Generated | AI-friendly project summary (llmstxt.org) |
271
276
 
272
277
  ---
273
278
 
@@ -280,7 +285,8 @@ your-project/
280
285
  │ ├── DATA-MODEL.md # Database schemas, entity relationships
281
286
  │ ├── SECURITY.md # Auth, permissions, secrets
282
287
  │ ├── TEST-SPEC.md # Required tests, coverage rules
283
- └── ENVIRONMENT.md # Environment variables, setup
288
+ ├── ENVIRONMENT.md # Environment variables, setup
289
+ │ └── REQUIREMENTS.md # Requirement IDs, V-Model traceability
284
290
 
285
291
  ├── docs-implementation/ # Current state (optional)
286
292
  │ ├── KNOWN-GOTCHAS.md # Lessons learned
@@ -291,6 +297,7 @@ your-project/
291
297
  ├── AGENTS.md # AI agent behavior rules
292
298
  ├── CHANGELOG.md # Change tracking
293
299
  ├── DRIFT-LOG.md # Documented deviations
300
+ ├── llms.txt # AI-friendly project summary
294
301
  └── .docguard.json # DocGuard configuration
295
302
  ```
296
303
 
@@ -22,6 +22,10 @@ import { validateDocsDiff } from '../validators/docs-diff.mjs';
22
22
  import { validateMetadataSync } from '../validators/metadata-sync.mjs';
23
23
  import { validateMetricsConsistency } from '../validators/metrics-consistency.mjs';
24
24
  import { validateDocsCoverage } from '../validators/docs-coverage.mjs';
25
+ import { validateDocQuality } from '../validators/doc-quality.mjs';
26
+ import { validateTodoTracking } from '../validators/todo-tracking.mjs';
27
+ import { validateSchemaSync } from '../validators/schema-sync.mjs';
28
+ import { validateSpecKitIntegration } from '../scanners/speckit.mjs';
25
29
 
26
30
  /**
27
31
  * Internal guard — returns structured data, no console output, no process.exit.
@@ -57,6 +61,10 @@ export function runGuardInternal(projectDir, config) {
57
61
  { key: 'docsDiff', name: 'Docs-Diff', fn: () => validateDocsDiff(projectDir, config) },
58
62
  { key: 'metadataSync', name: 'Metadata-Sync', fn: () => validateMetadataSync(projectDir, config) },
59
63
  { key: 'docsCoverage', name: 'Docs-Coverage', fn: () => validateDocsCoverage(projectDir, config) },
64
+ { key: 'docQuality', name: 'Doc-Quality', fn: () => validateDocQuality(projectDir, config) },
65
+ { key: 'todoTracking', name: 'TODO-Tracking', fn: () => validateTodoTracking(projectDir, config) },
66
+ { key: 'schemaSync', name: 'Schema-Sync', fn: () => validateSchemaSync(projectDir, config) },
67
+ { key: 'specKit', name: 'Spec-Kit', fn: () => validateSpecKitIntegration(projectDir, config) },
60
68
  // Metrics-Consistency runs post-loop (needs guard results)
61
69
  ];
62
70
 
@@ -0,0 +1,159 @@
1
+ /**
2
+ * llms Command — Generate llms.txt from canonical documentation
3
+ *
4
+ * llms.txt is a proposed standard (Jeremy Howard, Answer.AI, 2024) for providing
5
+ * AI-friendly content summaries at a project root. This command generates it
6
+ * from existing canonical docs.
7
+ *
8
+ * Usage:
9
+ * docguard llms — Generate/regenerate llms.txt
10
+ * docguard llms --stdout — Print to stdout instead of file
11
+ *
12
+ * Integration:
13
+ * - `docguard init` includes llms.txt in standard template
14
+ * - `docguard generate` auto-regenerates llms.txt
15
+ * - `docguard guard` validates llms.txt exists and is current
16
+ */
17
+
18
+ import { existsSync, readFileSync, writeFileSync, readdirSync } from 'node:fs';
19
+ import { resolve, join, basename } from 'node:path';
20
+ import { c } from '../shared.mjs';
21
+
22
+ // ──── Doc descriptions for llms.txt ────
23
+ const DOC_DESCRIPTIONS = {
24
+ 'ARCHITECTURE.md': 'System architecture, component boundaries, and tech stack',
25
+ 'DATA-MODEL.md': 'Database schemas, entity relationships, and data flow',
26
+ 'SECURITY.md': 'Authentication, authorization, secrets management, and security policies',
27
+ 'TEST-SPEC.md': 'Test coverage requirements, testing strategy, and quality rules',
28
+ 'ENVIRONMENT.md': 'Setup instructions, environment variables, and prerequisites',
29
+ 'API-REFERENCE.md': 'API endpoints, request/response formats, and integration docs',
30
+ 'DEPLOYMENT.md': 'Deployment procedures, CI/CD pipelines, and infrastructure',
31
+ 'MONITORING.md': 'Observability, logging, alerting, and health checks',
32
+ 'PERFORMANCE.md': 'Performance requirements, benchmarks, and optimization',
33
+ 'ACCESSIBILITY.md': 'WCAG compliance, accessibility standards, and testing',
34
+ };
35
+
36
+ const OPTIONAL_DOCS = {
37
+ 'DRIFT-LOG.md': 'Known deviations from canonical documentation',
38
+ 'CHANGELOG.md': 'Version history and release notes',
39
+ 'ROADMAP.md': 'Planned features and development roadmap',
40
+ 'REQUIREMENTS.md': 'Tracked requirements with traceability IDs',
41
+ 'AGENTS.md': 'AI agent behavior rules and workflow instructions',
42
+ 'CURRENT-STATE.md': 'Current implementation status and known issues',
43
+ };
44
+
45
+ /**
46
+ * Generate llms.txt content from project docs.
47
+ */
48
+ export function generateLlmsTxt(projectDir, config) {
49
+ const lines = [];
50
+
51
+ // ── Header ──
52
+ const projectName = config.projectName || basename(projectDir);
53
+ const description = getProjectDescription(projectDir);
54
+
55
+ lines.push(`# ${projectName}`);
56
+ if (description) {
57
+ lines.push(`> ${description}`);
58
+ }
59
+ lines.push('');
60
+
61
+ // ── Canonical Docs ──
62
+ const docsDir = resolve(projectDir, 'docs-canonical');
63
+ const existingDocs = [];
64
+
65
+ if (existsSync(docsDir)) {
66
+ try {
67
+ const entries = readdirSync(docsDir).filter(f => f.endsWith('.md')).sort();
68
+ for (const entry of entries) {
69
+ const desc = DOC_DESCRIPTIONS[entry] || `${entry.replace('.md', '')} documentation`;
70
+ existingDocs.push({ path: `docs-canonical/${entry}`, name: entry, desc });
71
+ }
72
+ } catch { /* ignore */ }
73
+ }
74
+
75
+ if (existingDocs.length > 0) {
76
+ lines.push('## Docs');
77
+ lines.push('');
78
+ for (const doc of existingDocs) {
79
+ lines.push(`- [${doc.name.replace('.md', '')}](${doc.path}): ${doc.desc}`);
80
+ }
81
+ lines.push('');
82
+ }
83
+
84
+ // ── Optional Docs ──
85
+ const optionalFound = [];
86
+ for (const [file, desc] of Object.entries(OPTIONAL_DOCS)) {
87
+ if (existsSync(resolve(projectDir, file))) {
88
+ optionalFound.push({ path: file, name: file, desc });
89
+ }
90
+ }
91
+
92
+ if (optionalFound.length > 0) {
93
+ lines.push('## Optional');
94
+ lines.push('');
95
+ for (const doc of optionalFound) {
96
+ lines.push(`- [${doc.name.replace('.md', '')}](${doc.path}): ${doc.desc}`);
97
+ }
98
+ lines.push('');
99
+ }
100
+
101
+ // ── Footer ──
102
+ lines.push('---');
103
+ lines.push(`Generated by DocGuard v${config.version || 'unknown'} | [docguard-cli](https://www.npmjs.com/package/docguard-cli)`);
104
+ lines.push('');
105
+
106
+ return lines.join('\n');
107
+ }
108
+
109
+ /**
110
+ * Get project description from package.json or ARCHITECTURE.md.
111
+ */
112
+ function getProjectDescription(projectDir) {
113
+ // Try package.json first
114
+ const pkgPath = resolve(projectDir, 'package.json');
115
+ if (existsSync(pkgPath)) {
116
+ try {
117
+ const pkg = JSON.parse(readFileSync(pkgPath, 'utf-8'));
118
+ if (pkg.description) return pkg.description;
119
+ } catch { /* ignore */ }
120
+ }
121
+
122
+ // Try ARCHITECTURE.md first line after the header
123
+ const archPath = resolve(projectDir, 'docs-canonical', 'ARCHITECTURE.md');
124
+ if (existsSync(archPath)) {
125
+ try {
126
+ const content = readFileSync(archPath, 'utf-8');
127
+ const lines = content.split('\n');
128
+ for (const line of lines) {
129
+ const trimmed = line.trim();
130
+ if (trimmed && !trimmed.startsWith('#') && !trimmed.startsWith('|') && !trimmed.startsWith('-')) {
131
+ return trimmed.substring(0, 200);
132
+ }
133
+ }
134
+ } catch { /* ignore */ }
135
+ }
136
+
137
+ return null;
138
+ }
139
+
140
+ /**
141
+ * Public command — generate llms.txt file.
142
+ */
143
+ export function runLlms(projectDir, config, flags) {
144
+ const content = generateLlmsTxt(projectDir, config);
145
+
146
+ if (flags.stdout) {
147
+ console.log(content);
148
+ return;
149
+ }
150
+
151
+ const outputPath = resolve(projectDir, 'llms.txt');
152
+ writeFileSync(outputPath, content, 'utf-8');
153
+
154
+ console.log(`${c.bold}📄 DocGuard llms.txt Generator${c.reset}`);
155
+ console.log(`${c.green}✅ Generated ${outputPath}${c.reset}`);
156
+ console.log(`${c.dim} Standard: llms.txt (Jeremy Howard, Answer.AI, 2024)${c.reset}`);
157
+ console.log(`${c.dim} DocGuard keeps this in sync with your canonical docs.${c.reset}`);
158
+ console.log('');
159
+ }
@@ -131,6 +131,30 @@ export function runScore(projectDir, config, flags) {
131
131
  console.log(` ${c.dim}Methodology: CJE multi-signal composite (Lopez et al., TRACE, IEEE TMLCN 2026)${c.reset}\n`);
132
132
  }
133
133
 
134
+ // ── ALCOA+ Compliance Scoring ──
135
+ // Maps existing validators to the 9 ALCOA+ attributes (FDA data integrity framework)
136
+ // Always shown — gives enterprise positioning value
137
+ const alcoa = computeAlcoaCompliance(projectDir, config, scores);
138
+
139
+ console.log(` ${c.bold}🏛️ ALCOA+ Compliance${c.reset} ${c.dim}(FDA Data Integrity Framework)${c.reset}`);
140
+ console.log(` ${c.dim}─────────────────────────────────${c.reset}`);
141
+
142
+ for (const attr of alcoa.attributes) {
143
+ const icon = attr.met ? `${c.green}✅` : `${c.yellow}⚠️`;
144
+ const status = attr.met ? `${c.green}${attr.evidence}` : `${c.yellow}${attr.gap}`;
145
+ console.log(` ${icon} ${attr.name.padEnd(16)}${c.reset} — ${status}${c.reset}`);
146
+ if (!attr.met && attr.fix) {
147
+ console.log(` ${c.dim} Fix: ${attr.fix}${c.reset}`);
148
+ }
149
+ }
150
+
151
+ const alcoaColor = alcoa.score >= 78 ? c.green : alcoa.score >= 56 ? c.yellow : c.red;
152
+ console.log(`\n ${alcoaColor}${c.bold}ALCOA+ Score: ${alcoa.score}% (${alcoa.met}/${alcoa.total} attributes)${c.reset}`);
153
+ if (alcoa.met < alcoa.total) {
154
+ console.log(` ${c.dim}${alcoa.total - alcoa.met} action(s) needed for full compliance${c.reset}`);
155
+ }
156
+ console.log('');
157
+
134
158
  // Badge snippet
135
159
  const bColor = totalScore >= 90 ? 'brightgreen' : totalScore >= 80 ? 'green' : totalScore >= 70 ? 'yellowgreen' : totalScore >= 60 ? 'yellow' : totalScore >= 50 ? 'orange' : 'red';
136
160
  const badgeUrl = `https://img.shields.io/badge/CDD_Score-${totalScore}%2F100_(${grade})-${bColor}`;
@@ -146,6 +170,144 @@ export function runScoreInternal(projectDir, config) {
146
170
  return { score: totalScore, grade, categories: scores };
147
171
  }
148
172
 
173
+ /**
174
+ * ALCOA+ Compliance Scoring
175
+ *
176
+ * Maps DocGuard's existing validators to the 9 ALCOA+ attributes
177
+ * (FDA 21 CFR Part 11 / EMA Annex 11 data integrity framework).
178
+ *
179
+ * ALCOA+ = Attributable, Legible, Contemporaneous, Original, Accurate
180
+ * + Complete, Consistent, Enduring, Available
181
+ *
182
+ * Reference: WHO Technical Report Series, No. 996, 2016, Annex 5
183
+ */
184
+ function computeAlcoaCompliance(projectDir, config, scores) {
185
+ const attributes = [];
186
+
187
+ // 1. Attributable — Can we trace who wrote/reviewed docs?
188
+ const hasGit = existsSync(resolve(projectDir, '.git'));
189
+ const docsDir = resolve(projectDir, 'docs-canonical');
190
+ let hasReviewedMeta = false;
191
+ if (existsSync(docsDir)) {
192
+ try {
193
+ const docs = readdirSync(docsDir).filter(f => f.endsWith('.md'));
194
+ for (const doc of docs) {
195
+ const content = readFileSync(join(docsDir, doc), 'utf-8');
196
+ if (content.includes('docguard:last-reviewed') || content.includes('last-reviewed')) {
197
+ hasReviewedMeta = true;
198
+ break;
199
+ }
200
+ }
201
+ } catch { /* ignore */ }
202
+ }
203
+ attributes.push({
204
+ name: 'Attributable',
205
+ met: hasGit,
206
+ evidence: hasGit ? `Git authorship found${hasReviewedMeta ? ', review metadata present' : ''}` : null,
207
+ gap: !hasGit ? 'No version control found' : null,
208
+ fix: !hasGit ? 'Initialize git repository: git init' : null,
209
+ });
210
+
211
+ // 2. Legible — Are docs readable and well-written?
212
+ const legible = scores.docQuality >= 60;
213
+ attributes.push({
214
+ name: 'Legible',
215
+ met: legible,
216
+ evidence: legible ? `Doc quality score: ${scores.docQuality}% (readable)` : null,
217
+ gap: !legible ? `Doc quality score: ${scores.docQuality}% (needs improvement)` : null,
218
+ fix: !legible ? 'Run docguard diagnose for specific readability improvements' : null,
219
+ });
220
+
221
+ // 3. Contemporaneous — Are docs kept current?
222
+ let freshnessMet = true;
223
+ if (existsSync(docsDir)) {
224
+ try {
225
+ const docs = readdirSync(docsDir).filter(f => f.endsWith('.md'));
226
+ for (const doc of docs) {
227
+ const stat_ = statSync(join(docsDir, doc));
228
+ const daysSinceModified = (Date.now() - stat_.mtimeMs) / (1000 * 60 * 60 * 24);
229
+ if (daysSinceModified > 30) {
230
+ freshnessMet = false;
231
+ break;
232
+ }
233
+ }
234
+ } catch { /* ignore */ }
235
+ }
236
+ attributes.push({
237
+ name: 'Contemporaneous',
238
+ met: freshnessMet,
239
+ evidence: freshnessMet ? 'All docs updated within 30 days' : null,
240
+ gap: !freshnessMet ? 'Some docs not updated in 30+ days' : null,
241
+ fix: !freshnessMet ? 'Review and update stale docs, add <!-- docguard:last-reviewed YYYY-MM-DD -->' : null,
242
+ });
243
+
244
+ // 4. Original — Are docs stored as originals (not copies)?
245
+ const hasCanonicalDir = existsSync(docsDir);
246
+ attributes.push({
247
+ name: 'Original',
248
+ met: hasCanonicalDir,
249
+ evidence: hasCanonicalDir ? 'Canonical docs present as markdown originals' : null,
250
+ gap: !hasCanonicalDir ? 'No docs-canonical/ directory found' : null,
251
+ fix: !hasCanonicalDir ? 'Run docguard init to create canonical documentation' : null,
252
+ });
253
+
254
+ // 5. Accurate — Do docs match the code?
255
+ const accurate = scores.drift >= 80 && scores.docQuality >= 50;
256
+ attributes.push({
257
+ name: 'Accurate',
258
+ met: accurate,
259
+ evidence: accurate ? `Drift: ${scores.drift}%, doc quality: ${scores.docQuality}%` : null,
260
+ gap: !accurate ? `Drift: ${scores.drift}%, doc quality: ${scores.docQuality}% — docs may be inaccurate` : null,
261
+ fix: !accurate ? 'Run docguard diagnose to find doc/code mismatches' : null,
262
+ });
263
+
264
+ // 6. Complete — Are all required docs present?
265
+ const complete = scores.structure >= 80;
266
+ attributes.push({
267
+ name: 'Complete',
268
+ met: complete,
269
+ evidence: complete ? `Structure score: ${scores.structure}% — required docs present` : null,
270
+ gap: !complete ? `Structure score: ${scores.structure}% — missing required docs` : null,
271
+ fix: !complete ? 'Run docguard init to create missing documentation' : null,
272
+ });
273
+
274
+ // 7. Consistent — Are versions, metadata, and references in sync?
275
+ const consistent = scores.changelog >= 50;
276
+ attributes.push({
277
+ name: 'Consistent',
278
+ met: consistent,
279
+ evidence: consistent ? `Changelog: ${scores.changelog}% — versions tracked` : null,
280
+ gap: !consistent ? `Changelog: ${scores.changelog}% — version inconsistencies` : null,
281
+ fix: !consistent ? 'Update CHANGELOG.md with [Unreleased] section and version headers' : null,
282
+ });
283
+
284
+ // 8. Enduring — Will docs survive infrastructure changes?
285
+ const enduring = hasGit;
286
+ attributes.push({
287
+ name: 'Enduring',
288
+ met: enduring,
289
+ evidence: enduring ? 'Git-backed repository with version history' : null,
290
+ gap: !enduring ? 'No version control — docs could be lost' : null,
291
+ fix: !enduring ? 'Initialize git repository: git init' : null,
292
+ });
293
+
294
+ // 9. Available — Can anyone access the docs?
295
+ const available = hasCanonicalDir;
296
+ attributes.push({
297
+ name: 'Available',
298
+ met: available,
299
+ evidence: available ? 'Docs in plain markdown — no vendor lock-in, universally accessible' : null,
300
+ gap: !available ? 'No docs directory found' : null,
301
+ fix: !available ? 'Run docguard init to create accessible documentation' : null,
302
+ });
303
+
304
+ const met = attributes.filter(a => a.met).length;
305
+ const total = attributes.length;
306
+ const score = Math.round((met / total) * 100);
307
+
308
+ return { attributes, met, total, score };
309
+ }
310
+
149
311
  function calcAllScores(projectDir, config) {
150
312
  const scores = {};
151
313
  const details = {}; // Per-category failure details for actionable suggestions
@@ -243,38 +405,77 @@ function calcDocQualityScore(dir, config) {
243
405
  function calcTestingScore(dir, config) {
244
406
  let score = 0;
245
407
 
246
- // Check test directory exists
408
+ // ── Check 1: Test files exist (40 pts) ──
409
+ // Check top-level test directories
247
410
  const testDirs = ['tests', 'test', '__tests__', 'spec', 'e2e'];
248
- const hasTestDir = testDirs.some(d => existsSync(resolve(dir, d)));
249
- if (hasTestDir) score += 40;
411
+ const hasTopLevelTestDir = testDirs.some(d => existsSync(resolve(dir, d)));
412
+
413
+ // Check co-located tests: src/**/__tests__/ and src/**/*.test.* / src/**/*.spec.*
414
+ let hasColocatedTests = false;
415
+ if (!hasTopLevelTestDir) {
416
+ hasColocatedTests = findColocatedTests(dir);
417
+ }
250
418
 
251
- // Check test spec exists
419
+ // Check vitest/jest config for custom test patterns
420
+ let hasConfigTests = false;
421
+ if (!hasTopLevelTestDir && !hasColocatedTests) {
422
+ const testConfigs = ['vitest.config.ts', 'vitest.config.js', 'vitest.config.mts', 'jest.config.ts', 'jest.config.js'];
423
+ for (const cfgFile of testConfigs) {
424
+ const cfgPath = resolve(dir, cfgFile);
425
+ if (existsSync(cfgPath)) {
426
+ try {
427
+ const cfgContent = readFileSync(cfgPath, 'utf-8');
428
+ const includeMatch = cfgContent.match(/include\s*:\s*\[([^\]]+)\]/);
429
+ if (includeMatch) {
430
+ const patterns = includeMatch[1].match(/['"]([^'"]+)['"]/g);
431
+ if (patterns) {
432
+ for (const p of patterns) {
433
+ const pattern = p.replace(/['"]|\s/g, '');
434
+ const rootDir = pattern.split('/')[0];
435
+ if (rootDir && rootDir !== '**' && rootDir !== '*') {
436
+ const fullDir = resolve(dir, rootDir);
437
+ if (existsSync(fullDir)) {
438
+ hasConfigTests = true;
439
+ break;
440
+ }
441
+ }
442
+ }
443
+ }
444
+ }
445
+ } catch { /* config parse may fail */ }
446
+ break;
447
+ }
448
+ }
449
+ }
450
+
451
+ if (hasTopLevelTestDir || hasColocatedTests || hasConfigTests) score += 40;
452
+
453
+ // ── Check 2: TEST-SPEC.md exists (30 pts) ──
252
454
  if (existsSync(resolve(dir, 'docs-canonical/TEST-SPEC.md'))) score += 30;
253
455
 
254
- // Check for test config files OR built-in test runner
255
- const testConfigs = ['jest.config.js', 'jest.config.ts', 'vitest.config.ts', 'vitest.config.js', 'pytest.ini', 'setup.cfg', '.mocharc.yml'];
256
- const hasTestConfig = testConfigs.some(f => existsSync(resolve(dir, f)));
456
+ // ── Check 3: Test config or built-in runner (15 pts) ──
457
+ const testConfigs2 = ['jest.config.js', 'jest.config.ts', 'vitest.config.ts', 'vitest.config.js', 'pytest.ini', 'setup.cfg', '.mocharc.yml'];
458
+ const hasTestConfig = testConfigs2.some(f => existsSync(resolve(dir, f)));
257
459
 
258
460
  if (hasTestConfig) {
259
461
  score += 15;
260
462
  } else {
261
- // Check if using node:test (no config needed) — look in package.json scripts
262
463
  const ptc = config.projectTypeConfig || {};
263
464
  const pkgPath = resolve(dir, 'package.json');
264
465
  if (ptc.testFramework === 'node:test') {
265
- score += 15; // Config says node:test — no config file needed
466
+ score += 15;
266
467
  } else if (existsSync(pkgPath)) {
267
468
  try {
268
469
  const pkg = JSON.parse(readFileSync(pkgPath, 'utf-8'));
269
470
  const testScript = pkg.scripts?.test || '';
270
471
  if (testScript.includes('node --test') || testScript.includes('node:test')) {
271
- score += 15; // Using built-in test runner
472
+ score += 15;
272
473
  }
273
474
  } catch { /* skip */ }
274
475
  }
275
476
  }
276
477
 
277
- // Check for CI test step
478
+ // ── Check 4: CI test step (15 pts) ──
278
479
  const ciFiles = ['.github/workflows/ci.yml', '.github/workflows/test.yml'];
279
480
  const hasCITest = ciFiles.some(f => existsSync(resolve(dir, f)));
280
481
  if (hasCITest) score += 15;
@@ -282,6 +483,53 @@ function calcTestingScore(dir, config) {
282
483
  return Math.min(100, score);
283
484
  }
284
485
 
486
+ /**
487
+ * Scan common source directories for co-located test files.
488
+ * Checks: __tests__/ dirs, *.test.*, *.spec.* anywhere in src/, app/, lib/, packages/
489
+ * Also checks root-level for *.test.* files.
490
+ */
491
+ function findColocatedTests(dir) {
492
+ // Scan these common source roots for co-located tests
493
+ const sourceRoots = ['src', 'app', 'lib', 'packages', 'modules'];
494
+ const ignoreSet = new Set(['node_modules', '.git', 'dist', 'build', '.next', 'coverage', '.cache']);
495
+
496
+ for (const root of sourceRoots) {
497
+ const rootDir = resolve(dir, root);
498
+ if (!existsSync(rootDir)) continue;
499
+ if (walkForTests(rootDir, ignoreSet)) return true;
500
+ }
501
+
502
+ // Also check root-level for *.test.* files (some projects put tests at root)
503
+ try {
504
+ const rootEntries = readdirSync(dir);
505
+ for (const entry of rootEntries) {
506
+ if (/\.(test|spec)\.[^.]+$/.test(entry)) return true;
507
+ }
508
+ } catch { /* ignore */ }
509
+
510
+ return false;
511
+ }
512
+
513
+ /** Recursively walk a dir looking for test files. Returns true as soon as one is found. */
514
+ function walkForTests(d, ignoreSet) {
515
+ let entries;
516
+ try { entries = readdirSync(d); } catch { return false; }
517
+ for (const entry of entries) {
518
+ if (ignoreSet.has(entry) || entry.startsWith('.')) continue;
519
+ const full = join(d, entry);
520
+ try {
521
+ const s = statSync(full);
522
+ if (s.isDirectory()) {
523
+ if (entry === '__tests__' || entry === '__test__') return true;
524
+ if (walkForTests(full, ignoreSet)) return true;
525
+ } else {
526
+ if (/\.(test|spec)\.[^.]+$/.test(entry)) return true;
527
+ }
528
+ } catch { continue; }
529
+ }
530
+ return false;
531
+ }
532
+
285
533
  function calcSecurityScore(dir, config) {
286
534
  let score = 0;
287
535
  const ptc = config.projectTypeConfig || {};
package/cli/docguard.mjs CHANGED
@@ -37,6 +37,7 @@ import { runWatch } from './commands/watch.mjs';
37
37
  import { runDiagnose } from './commands/diagnose.mjs';
38
38
  import { runPublish } from './commands/publish.mjs';
39
39
  import { runTrace } from './commands/trace.mjs';
40
+ import { runLlms } from './commands/llms.mjs';
40
41
 
41
42
  // ── Shared constants (imported to break circular dependencies) ──────────
42
43
  import { c, PROFILES } from './shared.mjs';
@@ -354,6 +355,8 @@ async function main() {
354
355
  flags.signals = true;
355
356
  } else if (args[i] === '--debate') {
356
357
  flags.debate = true;
358
+ } else if (args[i] === '--stdout') {
359
+ flags.stdout = true;
357
360
  }
358
361
  }
359
362
 
@@ -427,6 +430,9 @@ async function main() {
427
430
  case 'traceability':
428
431
  runTrace(projectDir, config, flags);
429
432
  break;
433
+ case 'llms':
434
+ runLlms(projectDir, config, flags);
435
+ break;
430
436
  default:
431
437
  console.error(`${c.red}Unknown command: ${command}${c.reset}`);
432
438
  console.log(`Run ${c.cyan}docguard --help${c.reset} for usage.`);