docguard-cli 0.23.0 → 0.25.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/README.md +1 -1
- package/cli/commands/diff.mjs +1 -1
- package/cli/commands/explain.mjs +178 -17
- package/cli/commands/fix.mjs +17 -2
- package/cli/commands/generate.mjs +69 -3
- package/cli/commands/guard.mjs +86 -11
- package/cli/commands/hooks.mjs +12 -7
- package/cli/commands/init.mjs +24 -8
- package/cli/commands/score.mjs +147 -61
- package/cli/commands/setup.mjs +2 -2
- package/cli/commands/sync.mjs +6 -0
- package/cli/commands/trace.mjs +3 -3
- package/cli/commands/upgrade.mjs +61 -13
- package/cli/config.mjs +18 -1
- package/cli/docguard.mjs +156 -2
- package/cli/ensure-skills.mjs +24 -26
- package/cli/scanners/api-doc.mjs +17 -3
- package/cli/scanners/doc-tools.mjs +32 -15
- package/cli/scanners/frontend.mjs +24 -8
- package/cli/scanners/js-ast.mjs +432 -0
- package/cli/scanners/memory-plan.mjs +1 -1
- package/cli/scanners/project-type.mjs +11 -4
- package/cli/scanners/py-ast.mjs +213 -0
- package/cli/scanners/routes.mjs +194 -69
- package/cli/scanners/schemas.mjs +97 -51
- package/cli/shared-git.mjs +0 -0
- package/cli/shared-ignore.mjs +23 -2
- package/cli/shared-source.mjs +59 -2
- package/cli/shared-trace-patterns.mjs +13 -0
- package/cli/shared.mjs +92 -1
- package/cli/validator-markers.mjs +91 -0
- package/cli/validators/api-surface.mjs +37 -3
- package/cli/validators/canonical-sync.mjs +22 -19
- package/cli/validators/doc-quality.mjs +2 -42
- package/cli/validators/docs-coverage.mjs +13 -0
- package/cli/validators/docs-sync.mjs +4 -3
- package/cli/validators/drift.mjs +3 -2
- package/cli/validators/freshness.mjs +47 -15
- package/cli/validators/generated-staleness.mjs +16 -1
- package/cli/validators/metadata-sync.mjs +21 -11
- package/cli/validators/metrics-consistency.mjs +45 -17
- package/cli/validators/security.mjs +13 -5
- package/cli/validators/structure.mjs +6 -5
- package/cli/validators/surface-sync.mjs +7 -5
- package/cli/validators/test-spec.mjs +76 -51
- package/cli/validators/todo-tracking.mjs +4 -2
- package/cli/validators/traceability.mjs +11 -3
- package/cli/writers/sections.mjs +32 -19
- package/docs/commands.md +1 -1
- package/docs/configuration.md +11 -0
- package/docs/faq.md +1 -1
- package/extensions/spec-kit-docguard/README.md +1 -1
- package/extensions/spec-kit-docguard/extension.yml +2 -2
- package/extensions/spec-kit-docguard/skills/docguard-fix/SKILL.md +2 -2
- package/extensions/spec-kit-docguard/skills/docguard-guard/SKILL.md +2 -2
- package/extensions/spec-kit-docguard/skills/docguard-review/SKILL.md +2 -2
- package/extensions/spec-kit-docguard/skills/docguard-score/SKILL.md +2 -2
- package/extensions/spec-kit-docguard/skills/docguard-sync/SKILL.md +2 -1
- package/extensions/spec-kit-docguard/templates/github-workflows/docguard-autofix.yml +3 -2
- package/extensions/spec-kit-docguard/templates/github-workflows/docguard-guard.yml +2 -2
- package/package.json +5 -3
package/cli/commands/hooks.mjs
CHANGED
|
@@ -56,6 +56,7 @@ function spliceManagedBlock(existing, newBody) {
|
|
|
56
56
|
}
|
|
57
57
|
import { resolve } from 'node:path';
|
|
58
58
|
import { c } from '../shared.mjs';
|
|
59
|
+
import { getHooksDir } from '../shared-git.mjs';
|
|
59
60
|
|
|
60
61
|
const HOOKS = {
|
|
61
62
|
'pre-commit': {
|
|
@@ -216,17 +217,20 @@ export function runHooks(projectDir, config, flags) {
|
|
|
216
217
|
console.log(`${c.bold}🪝 DocGuard Hooks — ${config.projectName}${c.reset}`);
|
|
217
218
|
console.log(`${c.dim} Directory: ${projectDir}${c.reset}\n`);
|
|
218
219
|
|
|
219
|
-
//
|
|
220
|
-
|
|
221
|
-
|
|
220
|
+
// Resolve the real hooks dir via git — NOT `<projectDir>/.git/hooks`, which
|
|
221
|
+
// is wrong inside a linked worktree (where `.git` is a file, not a dir) and
|
|
222
|
+
// ignores a custom core.hooksPath.
|
|
223
|
+
const hooksDir = getHooksDir(projectDir);
|
|
224
|
+
if (!hooksDir) {
|
|
222
225
|
console.log(` ${c.red}❌ Not a git repository. Run ${c.cyan}git init${c.red} first.${c.reset}\n`);
|
|
223
226
|
process.exit(1);
|
|
224
227
|
}
|
|
225
228
|
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
229
|
+
// Only create the dir when we're actually going to write a hook. Read-only
|
|
230
|
+
// modes (--list, --remove) must not have a filesystem side effect.
|
|
231
|
+
const ensureHooksDir = () => {
|
|
232
|
+
if (!existsSync(hooksDir)) mkdirSync(hooksDir, { recursive: true });
|
|
233
|
+
};
|
|
230
234
|
|
|
231
235
|
// Determine which hooks to install
|
|
232
236
|
let hookTypes = Object.keys(HOOKS);
|
|
@@ -273,6 +277,7 @@ export function runHooks(projectDir, config, flags) {
|
|
|
273
277
|
}
|
|
274
278
|
|
|
275
279
|
// Install mode
|
|
280
|
+
ensureHooksDir();
|
|
276
281
|
let installed = 0;
|
|
277
282
|
let skipped = 0;
|
|
278
283
|
|
package/cli/commands/init.mjs
CHANGED
|
@@ -14,7 +14,7 @@ import { resolve, dirname } from 'node:path';
|
|
|
14
14
|
import { fileURLToPath } from 'node:url';
|
|
15
15
|
import { createInterface } from 'node:readline';
|
|
16
16
|
import { execSync } from 'node:child_process';
|
|
17
|
-
import { c, PROFILES } from '../shared.mjs';
|
|
17
|
+
import { c, PROFILES, CURRENT_SCHEMA_VERSION } from '../shared.mjs';
|
|
18
18
|
import { ensureSkills, detectAgentMode, detectAIAgent, isSpecKitAvailable, isSpecKitInitialized, getDetectedAgent, safeSpawnSpecify } from '../ensure-skills.mjs';
|
|
19
19
|
|
|
20
20
|
// v0.20: scaffolder names that can be passed via `init --with <name>` and
|
|
@@ -118,6 +118,7 @@ function shouldRunGenerate(projectDir, flags) {
|
|
|
118
118
|
if (flags.skipPrompts) return false; // non-interactive (CI) keeps deterministic skeleton path
|
|
119
119
|
if (flags.wizard) return false; // wizard has its own scan step
|
|
120
120
|
if (flags.profile) return false; // explicit profile = user knows what they want
|
|
121
|
+
if (flags.fix) return false; // --fix = deterministic create-missing-from-templates (headless)
|
|
121
122
|
|
|
122
123
|
// If canonical docs already exist, this is a re-init, not a first-run.
|
|
123
124
|
const canonicalDir = resolve(projectDir, 'docs-canonical');
|
|
@@ -198,8 +199,11 @@ export async function runInit(projectDir, config, flags) {
|
|
|
198
199
|
|
|
199
200
|
let selectedDocs;
|
|
200
201
|
|
|
201
|
-
if (flags.skipPrompts || flags.force) {
|
|
202
|
-
// Non-interactive — use profile defaults
|
|
202
|
+
if (flags.skipPrompts || flags.force || flags.fix) {
|
|
203
|
+
// Non-interactive — use profile defaults. `--fix` lands here too: its
|
|
204
|
+
// documented contract is "auto-create missing files from templates", so it
|
|
205
|
+
// must never block on prompts (CI / headless / agent use). The create-loop
|
|
206
|
+
// below already skips existing files, so --fix only fills gaps.
|
|
203
207
|
const profileCanonical = profile.requiredFiles?.canonical || allDocs.map(d => d.file);
|
|
204
208
|
selectedDocs = allDocs.filter(d => profileCanonical.includes(d.file));
|
|
205
209
|
console.log(` ${c.dim}Non-interactive mode — using ${profileName} profile defaults${c.reset}\n`);
|
|
@@ -287,7 +291,7 @@ export async function runInit(projectDir, config, flags) {
|
|
|
287
291
|
// JSON-Schema-aware editor; ignored by DocGuard itself.
|
|
288
292
|
$schema: 'https://raccioly.github.io/docguard/schemas/docguard-config.schema.json',
|
|
289
293
|
projectName: config.projectName,
|
|
290
|
-
version:
|
|
294
|
+
version: CURRENT_SCHEMA_VERSION, // single source of truth (shared.mjs) — never hardcode
|
|
291
295
|
profile: profileName,
|
|
292
296
|
projectType: detectedType,
|
|
293
297
|
projectTypeConfig: ptc,
|
|
@@ -373,8 +377,18 @@ poetry.lock
|
|
|
373
377
|
const specKitAvailable = isSpecKitAvailable();
|
|
374
378
|
const specKitInitialized = isSpecKitInitialized(projectDir);
|
|
375
379
|
|
|
376
|
-
|
|
377
|
-
|
|
380
|
+
// v0.24 (field report #1): the `starter` profile is "minimal, for side
|
|
381
|
+
// projects" — it skips the heavy Spec Kit framework scaffold (.specify/
|
|
382
|
+
// templates/scripts/memory, ~30 files) by default. DocGuard's own canonical
|
|
383
|
+
// docs and its lightweight agent skills/commands still install (ensureSkills
|
|
384
|
+
// below). Opt back in with --spec-kit. Other profiles are unaffected.
|
|
385
|
+
const starterSkipsSpecKit = profileName === 'starter' && !flags.specKit;
|
|
386
|
+
|
|
387
|
+
if (flags.noSpecKit || starterSkipsSpecKit) {
|
|
388
|
+
const why = flags.noSpecKit
|
|
389
|
+
? '--no-spec-kit'
|
|
390
|
+
: 'starter profile is minimal — pass --spec-kit to include the framework scaffold';
|
|
391
|
+
console.log(`\n ${c.dim}⏭️ Spec Kit framework scaffold skipped (${why}).${c.reset}`);
|
|
378
392
|
} else if (specKitAvailable && !specKitInitialized) {
|
|
379
393
|
console.log(`\n ${c.bold}🌱 Spec Kit Integration${c.reset}`);
|
|
380
394
|
|
|
@@ -487,8 +501,10 @@ poetry.lock
|
|
|
487
501
|
}
|
|
488
502
|
}
|
|
489
503
|
|
|
490
|
-
// Auto-install DocGuard skills and commands
|
|
491
|
-
ensureSkills
|
|
504
|
+
// Auto-install DocGuard's own skills and commands. Thread the spec-kit skip
|
|
505
|
+
// decision through so ensureSkills doesn't re-trigger the framework scaffold
|
|
506
|
+
// we just declined for the starter profile (or --no-spec-kit).
|
|
507
|
+
ensureSkills(projectDir, { ...flags, noSpecKit: flags.noSpecKit || starterSkipsSpecKit });
|
|
492
508
|
|
|
493
509
|
// v0.20: `docguard init --with agents,hooks,ci,badge,llms,publish` runs
|
|
494
510
|
// the named scaffolders after init has finished. Each one runs in sequence
|
package/cli/commands/score.mjs
CHANGED
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
import { existsSync, readFileSync, readdirSync, statSync } from 'node:fs';
|
|
7
7
|
import { resolve, join, extname } from 'node:path';
|
|
8
8
|
import { execSync } from 'node:child_process';
|
|
9
|
-
import { c } from '../shared.mjs';
|
|
9
|
+
import { c, docHasSection } from '../shared.mjs';
|
|
10
10
|
import { validateSecurity } from '../validators/security.mjs';
|
|
11
11
|
import { runGuardInternal } from './guard.mjs';
|
|
12
12
|
|
|
@@ -407,16 +407,23 @@ function calcAllScores(projectDir, config) {
|
|
|
407
407
|
const scores = {};
|
|
408
408
|
const details = {}; // Per-category failure details for actionable suggestions
|
|
409
409
|
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
410
|
+
// Every calc*Score returns { score, failures } so the "Top improvements"
|
|
411
|
+
// line can name the sub-checks that actually failed instead of printing a
|
|
412
|
+
// static per-category template (field report, Issue B).
|
|
413
|
+
for (const [cat, fn] of [
|
|
414
|
+
['structure', calcStructureScore],
|
|
415
|
+
['docQuality', calcDocQualityScore],
|
|
416
|
+
['testing', calcTestingScore],
|
|
417
|
+
['security', calcSecurityScore],
|
|
418
|
+
['environment', calcEnvironmentScore],
|
|
419
|
+
['drift', calcDriftScore],
|
|
420
|
+
['changelog', calcChangelogScore],
|
|
421
|
+
['architecture', calcArchitectureScore],
|
|
422
|
+
]) {
|
|
423
|
+
const { score, failures } = fn(projectDir, config);
|
|
424
|
+
scores[cat] = score;
|
|
425
|
+
details[cat] = failures || [];
|
|
426
|
+
}
|
|
420
427
|
|
|
421
428
|
let totalScore = 0;
|
|
422
429
|
for (const [category, score] of Object.entries(scores)) {
|
|
@@ -432,23 +439,29 @@ function calcAllScores(projectDir, config) {
|
|
|
432
439
|
function calcStructureScore(dir, config) {
|
|
433
440
|
let found = 0;
|
|
434
441
|
let total = 0;
|
|
442
|
+
const failures = [];
|
|
435
443
|
|
|
436
444
|
for (const file of config.requiredFiles.canonical) {
|
|
437
445
|
total++;
|
|
438
446
|
if (existsSync(resolve(dir, file))) found++;
|
|
447
|
+
else failures.push({ issue: `missing ${file}` });
|
|
439
448
|
}
|
|
440
449
|
|
|
441
450
|
total++;
|
|
442
451
|
const hasAgent = config.requiredFiles.agentFile.some(f => existsSync(resolve(dir, f)));
|
|
443
452
|
if (hasAgent) found++;
|
|
453
|
+
else failures.push({ issue: `missing agent file (${config.requiredFiles.agentFile.join(' or ')})` });
|
|
444
454
|
|
|
445
455
|
total++;
|
|
446
456
|
if (existsSync(resolve(dir, config.requiredFiles.changelog))) found++;
|
|
457
|
+
else failures.push({ issue: `missing ${config.requiredFiles.changelog}` });
|
|
447
458
|
|
|
448
459
|
total++;
|
|
449
460
|
if (existsSync(resolve(dir, config.requiredFiles.driftLog))) found++;
|
|
461
|
+
else failures.push({ issue: `missing ${config.requiredFiles.driftLog}` });
|
|
450
462
|
|
|
451
|
-
|
|
463
|
+
const score = total === 0 ? 0 : Math.round((found / total) * 100);
|
|
464
|
+
return { score, failures };
|
|
452
465
|
}
|
|
453
466
|
|
|
454
467
|
function calcDocQualityScore(dir, config) {
|
|
@@ -476,7 +489,10 @@ function calcDocQualityScore(dir, config) {
|
|
|
476
489
|
|
|
477
490
|
for (const section of sections) {
|
|
478
491
|
total++;
|
|
479
|
-
|
|
492
|
+
// v0.24: synonym/number-tolerant (docHasSection) so arc42/C4 headings
|
|
493
|
+
// count — was a literal substring check that flagged equivalent sections
|
|
494
|
+
// as missing, making structured docs score worse than the skeleton.
|
|
495
|
+
if (docHasSection(content, section)) {
|
|
480
496
|
found++;
|
|
481
497
|
} else {
|
|
482
498
|
failures.push({ file, issue: `missing section: ${section}`, fixCmd: `docguard fix --doc ${docName}` });
|
|
@@ -499,6 +515,7 @@ function calcDocQualityScore(dir, config) {
|
|
|
499
515
|
|
|
500
516
|
function calcTestingScore(dir, config) {
|
|
501
517
|
let score = 0;
|
|
518
|
+
const failures = [];
|
|
502
519
|
|
|
503
520
|
// ── Check 1: Test files exist (40 pts) ──
|
|
504
521
|
// Check top-level test directories
|
|
@@ -561,32 +578,47 @@ function calcTestingScore(dir, config) {
|
|
|
561
578
|
}
|
|
562
579
|
|
|
563
580
|
if (hasTopLevelTestDir || hasColocatedTests || hasPatternTests || hasConfigTests) score += 40;
|
|
581
|
+
else failures.push({ issue: 'no test files found (looked in tests/, src/**/__tests__, and configured testPatterns)' });
|
|
564
582
|
|
|
565
583
|
// ── Check 2: TEST-SPEC.md exists (30 pts) ──
|
|
566
584
|
if (existsSync(resolve(dir, 'docs-canonical/TEST-SPEC.md'))) score += 30;
|
|
585
|
+
else failures.push({ issue: 'TEST-SPEC.md missing', fixCmd: 'docguard fix --doc test-spec' });
|
|
567
586
|
|
|
568
587
|
// ── Check 3: Test config or built-in runner (15 pts) ──
|
|
569
|
-
const
|
|
570
|
-
|
|
588
|
+
const testConfigFiles = ['jest.config.js', 'jest.config.ts', 'vitest.config.ts', 'vitest.config.js', 'pytest.ini', 'setup.cfg', '.mocharc.yml'];
|
|
589
|
+
let hasTestRunner = testConfigFiles.some(f => existsSync(resolve(dir, f)));
|
|
590
|
+
|
|
591
|
+
// Python: pytest config usually lives inside pyproject.toml ([tool.pytest.ini_options])
|
|
592
|
+
// or tox.ini ([pytest]) — not a standalone file. Detect those too, so a uv/pytest
|
|
593
|
+
// project isn't told to "add a test runner" it already configured (field report, Issue B).
|
|
594
|
+
if (!hasTestRunner) {
|
|
595
|
+
for (const [file, marker] of [['pyproject.toml', /\[tool\.pytest/], ['tox.ini', /\[pytest\]/]]) {
|
|
596
|
+
const p = resolve(dir, file);
|
|
597
|
+
if (!existsSync(p)) continue;
|
|
598
|
+
try { if (marker.test(readFileSync(p, 'utf-8'))) { hasTestRunner = true; break; } } catch { /* skip */ }
|
|
599
|
+
}
|
|
600
|
+
}
|
|
571
601
|
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
} else {
|
|
602
|
+
// node:test has no config file — recognize it via projectTypeConfig or package.json.
|
|
603
|
+
if (!hasTestRunner) {
|
|
575
604
|
const ptc = config.projectTypeConfig || {};
|
|
576
|
-
const pkgPath = resolve(dir, 'package.json');
|
|
577
605
|
if (ptc.testFramework === 'node:test') {
|
|
578
|
-
|
|
579
|
-
} else
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
|
|
606
|
+
hasTestRunner = true;
|
|
607
|
+
} else {
|
|
608
|
+
const pkgPath = resolve(dir, 'package.json');
|
|
609
|
+
if (existsSync(pkgPath)) {
|
|
610
|
+
try {
|
|
611
|
+
const pkg = JSON.parse(readFileSync(pkgPath, 'utf-8'));
|
|
612
|
+
const testScript = pkg.scripts?.test || '';
|
|
613
|
+
if (testScript.includes('node --test') || testScript.includes('node:test')) hasTestRunner = true;
|
|
614
|
+
} catch { /* skip */ }
|
|
615
|
+
}
|
|
587
616
|
}
|
|
588
617
|
}
|
|
589
618
|
|
|
619
|
+
if (hasTestRunner) score += 15;
|
|
620
|
+
else failures.push({ issue: 'no test runner config detected (jest/vitest/pytest/node:test)' });
|
|
621
|
+
|
|
590
622
|
// ── Check 4: CI test step (15 pts) ──
|
|
591
623
|
// Support multiple CI systems — not just GitHub Actions
|
|
592
624
|
const ciFiles = [
|
|
@@ -613,8 +645,9 @@ function calcTestingScore(dir, config) {
|
|
|
613
645
|
}
|
|
614
646
|
|
|
615
647
|
if (hasCITest) score += 15;
|
|
648
|
+
else failures.push({ issue: 'no CI test step (.github/workflows, .gitlab-ci.yml, Jenkinsfile, etc.)' });
|
|
616
649
|
|
|
617
|
-
return Math.min(100, score);
|
|
650
|
+
return { score: Math.min(100, score), failures };
|
|
618
651
|
}
|
|
619
652
|
|
|
620
653
|
/**
|
|
@@ -668,9 +701,11 @@ function walkForTests(d, ignoreSet) {
|
|
|
668
701
|
function calcSecurityScore(dir, config) {
|
|
669
702
|
let score = 0;
|
|
670
703
|
const ptc = config.projectTypeConfig || {};
|
|
704
|
+
const failures = [];
|
|
671
705
|
|
|
672
706
|
// SECURITY.md exists (25 pts)
|
|
673
707
|
if (existsSync(resolve(dir, 'docs-canonical/SECURITY.md'))) score += 25;
|
|
708
|
+
else failures.push({ issue: 'SECURITY.md missing', fixCmd: 'docguard fix --doc security' });
|
|
674
709
|
|
|
675
710
|
// .gitignore exists and includes .env (15 + 15 pts)
|
|
676
711
|
const gitignorePath = resolve(dir, '.gitignore');
|
|
@@ -678,16 +713,22 @@ function calcSecurityScore(dir, config) {
|
|
|
678
713
|
score += 15;
|
|
679
714
|
const content = readFileSync(gitignorePath, 'utf-8');
|
|
680
715
|
if (content.includes('.env')) score += 15;
|
|
716
|
+
else failures.push({ issue: '.gitignore does not list .env' });
|
|
717
|
+
} else {
|
|
718
|
+
failures.push({ issue: '.gitignore missing' });
|
|
681
719
|
}
|
|
682
720
|
|
|
683
721
|
// No .env file committed (10 pts)
|
|
684
722
|
if (!existsSync(resolve(dir, '.env')) || existsSync(gitignorePath)) score += 10;
|
|
723
|
+
else failures.push({ issue: '.env is committed without a .gitignore' });
|
|
685
724
|
|
|
686
725
|
// .env.example exists (safe template) — only check if project needs env vars (10 pts)
|
|
687
726
|
if (ptc.needsEnvExample === false) {
|
|
688
727
|
score += 10; // Full marks — project doesn't need env vars
|
|
689
728
|
} else if (existsSync(resolve(dir, '.env.example'))) {
|
|
690
729
|
score += 10;
|
|
730
|
+
} else {
|
|
731
|
+
failures.push({ issue: '.env.example missing' });
|
|
691
732
|
}
|
|
692
733
|
|
|
693
734
|
// No hardcoded secrets found by security validator (25 pts)
|
|
@@ -703,26 +744,31 @@ function calcSecurityScore(dir, config) {
|
|
|
703
744
|
if (findingCount <= 2) score += 15;
|
|
704
745
|
else if (findingCount <= 5) score += 5;
|
|
705
746
|
// 6+ findings = 0 pts for this check
|
|
747
|
+
failures.push({ issue: `${findingCount} possible secret(s) / unsafe pattern(s) in code — run \`docguard guard --verbose\`` });
|
|
706
748
|
}
|
|
707
749
|
} catch {
|
|
708
750
|
// If validator fails to run, give benefit of the doubt
|
|
709
751
|
score += 25;
|
|
710
752
|
}
|
|
711
753
|
|
|
712
|
-
return Math.min(100, score);
|
|
754
|
+
return { score: Math.min(100, score), failures };
|
|
713
755
|
}
|
|
714
756
|
|
|
715
757
|
function calcEnvironmentScore(dir, config) {
|
|
716
758
|
let score = 0;
|
|
717
759
|
const ptc = config.projectTypeConfig || {};
|
|
760
|
+
const failures = [];
|
|
718
761
|
|
|
719
762
|
if (existsSync(resolve(dir, 'docs-canonical/ENVIRONMENT.md'))) score += 40;
|
|
763
|
+
else failures.push({ issue: 'ENVIRONMENT.md missing', fixCmd: 'docguard fix --doc environment' });
|
|
720
764
|
|
|
721
765
|
// .env.example — only check if project needs env vars
|
|
722
766
|
if (ptc.needsEnvExample === false) {
|
|
723
767
|
score += 30; // Full marks — project doesn't need env vars
|
|
724
768
|
} else if (existsSync(resolve(dir, '.env.example'))) {
|
|
725
769
|
score += 30;
|
|
770
|
+
} else {
|
|
771
|
+
failures.push({ issue: '.env.example missing' });
|
|
726
772
|
}
|
|
727
773
|
|
|
728
774
|
// Check for setup documentation
|
|
@@ -733,56 +779,79 @@ function calcEnvironmentScore(dir, config) {
|
|
|
733
779
|
score += 30;
|
|
734
780
|
} else {
|
|
735
781
|
score += 15; // README exists but no setup section
|
|
782
|
+
failures.push({ issue: 'README has no Setup / Getting Started section' });
|
|
736
783
|
}
|
|
784
|
+
} else {
|
|
785
|
+
failures.push({ issue: 'README.md missing' });
|
|
737
786
|
}
|
|
738
787
|
|
|
739
|
-
return Math.min(100, score);
|
|
788
|
+
return { score: Math.min(100, score), failures };
|
|
740
789
|
}
|
|
741
790
|
|
|
742
791
|
function calcDriftScore(dir, config) {
|
|
743
792
|
// Perfect score if drift log exists and no unlogged drift comments
|
|
744
|
-
if (!existsSync(resolve(dir, config.requiredFiles.driftLog)))
|
|
793
|
+
if (!existsSync(resolve(dir, config.requiredFiles.driftLog))) {
|
|
794
|
+
return { score: 0, failures: [{ issue: `${config.requiredFiles.driftLog} missing` }] };
|
|
795
|
+
}
|
|
745
796
|
|
|
746
797
|
let score = 50; // Drift log exists
|
|
798
|
+
const failures = [];
|
|
747
799
|
|
|
748
800
|
const content = readFileSync(resolve(dir, config.requiredFiles.driftLog), 'utf-8');
|
|
749
801
|
|
|
750
802
|
// Has structure (headers)
|
|
751
803
|
if (content.includes('## ') || content.includes('| ')) score += 25;
|
|
804
|
+
else failures.push({ issue: `${config.requiredFiles.driftLog} has no headers or table structure` });
|
|
752
805
|
|
|
753
806
|
// Has entries (not just template)
|
|
754
807
|
const lines = content.split('\n').filter(l => l.trim() && !l.startsWith('#') && !l.startsWith('<!--'));
|
|
755
808
|
if (lines.length > 3) score += 25;
|
|
809
|
+
else failures.push({ issue: `${config.requiredFiles.driftLog} has no entries yet (template only)` });
|
|
756
810
|
|
|
757
|
-
return Math.min(100, score);
|
|
811
|
+
return { score: Math.min(100, score), failures };
|
|
758
812
|
}
|
|
759
813
|
|
|
760
814
|
function calcChangelogScore(dir, config) {
|
|
761
815
|
const path = resolve(dir, config.requiredFiles.changelog);
|
|
762
|
-
if (!existsSync(path))
|
|
816
|
+
if (!existsSync(path)) {
|
|
817
|
+
return { score: 0, failures: [{ issue: `${config.requiredFiles.changelog} missing` }] };
|
|
818
|
+
}
|
|
763
819
|
|
|
764
820
|
let score = 40; // Exists
|
|
821
|
+
const failures = [];
|
|
765
822
|
const content = readFileSync(path, 'utf-8');
|
|
766
823
|
|
|
767
824
|
if (content.includes('[Unreleased]') || content.includes('[unreleased]')) score += 30;
|
|
825
|
+
else failures.push({ issue: 'no [Unreleased] section' });
|
|
768
826
|
if (/## \[[\d.]+\]/.test(content)) score += 30;
|
|
827
|
+
else failures.push({ issue: 'no versioned release headings (## [x.y.z])' });
|
|
769
828
|
|
|
770
|
-
return Math.min(100, score);
|
|
829
|
+
return { score: Math.min(100, score), failures };
|
|
771
830
|
}
|
|
772
831
|
|
|
773
832
|
function calcArchitectureScore(dir) {
|
|
774
833
|
const archPath = resolve(dir, 'docs-canonical/ARCHITECTURE.md');
|
|
775
|
-
if (!existsSync(archPath))
|
|
834
|
+
if (!existsSync(archPath)) {
|
|
835
|
+
return { score: 0, failures: [{ issue: 'ARCHITECTURE.md missing', fixCmd: 'docguard fix --doc architecture' }] };
|
|
836
|
+
}
|
|
776
837
|
|
|
777
838
|
let score = 30;
|
|
839
|
+
const failures = [];
|
|
778
840
|
const content = readFileSync(archPath, 'utf-8');
|
|
779
841
|
|
|
780
|
-
|
|
842
|
+
// v0.24: heading checks are synonym/number-tolerant (docHasSection) so arc42
|
|
843
|
+
// ("## 5.4 Layer boundaries") and C4 ("## Building Block View") docs score
|
|
844
|
+
// their real content instead of being told to add sections they have.
|
|
845
|
+
if (docHasSection(content, '## Layer Boundaries') || docHasSection(content, '## Component Map')) score += 25;
|
|
846
|
+
else failures.push({ issue: 'no Layer Boundaries / Component Map section' });
|
|
781
847
|
if (content.includes('```mermaid') || content.includes('graph ')) score += 20;
|
|
782
|
-
|
|
783
|
-
if (content
|
|
848
|
+
else failures.push({ issue: 'no architecture diagram (mermaid / graph)' });
|
|
849
|
+
if (docHasSection(content, '## External Dependencies')) score += 15;
|
|
850
|
+
else failures.push({ issue: 'no External Dependencies section' });
|
|
851
|
+
if (docHasSection(content, '## Revision History')) score += 10;
|
|
852
|
+
else failures.push({ issue: 'no Revision History section' });
|
|
784
853
|
|
|
785
|
-
return Math.min(100, score);
|
|
854
|
+
return { score: Math.min(100, score), failures };
|
|
786
855
|
}
|
|
787
856
|
|
|
788
857
|
// ── Helpers ────────────────────────────────────────────────────────────────
|
|
@@ -803,37 +872,54 @@ function getGrade(score) {
|
|
|
803
872
|
return 'F';
|
|
804
873
|
}
|
|
805
874
|
|
|
875
|
+
// Default fix command per category, used when a failure doesn't carry its own.
|
|
876
|
+
const CATEGORY_FIX = {
|
|
877
|
+
structure: 'docguard init',
|
|
878
|
+
docQuality: 'docguard fix',
|
|
879
|
+
testing: 'docguard fix --doc test-spec',
|
|
880
|
+
security: 'docguard fix --doc security',
|
|
881
|
+
environment: 'docguard fix --doc environment',
|
|
882
|
+
architecture: 'docguard fix --doc architecture',
|
|
883
|
+
};
|
|
884
|
+
|
|
885
|
+
// Static fallback — only reached if a category scores < 100 but recorded no
|
|
886
|
+
// specific failure (shouldn't happen now that every deduction tracks one).
|
|
887
|
+
const STATIC_SUGGESTIONS = {
|
|
888
|
+
structure: 'Run `docguard init` to create missing documentation',
|
|
889
|
+
docQuality: 'Run `docguard fix` to get AI prompts for each doc that needs content',
|
|
890
|
+
testing: 'Add test files and create TEST-SPEC.md → Run `docguard fix --doc test-spec`',
|
|
891
|
+
security: 'Create SECURITY.md and add .env to .gitignore → Run `docguard fix --doc security`',
|
|
892
|
+
environment: 'Document env variables and create .env.example → Run `docguard fix --doc environment`',
|
|
893
|
+
drift: 'Create DRIFT-LOG.md and log any code deviations',
|
|
894
|
+
changelog: 'Maintain CHANGELOG.md with [Unreleased] section',
|
|
895
|
+
architecture: 'Add layer boundaries and Mermaid diagrams → Run `docguard fix --doc architecture`',
|
|
896
|
+
};
|
|
897
|
+
|
|
806
898
|
function getSuggestion(category, score, details) {
|
|
807
|
-
|
|
808
|
-
|
|
809
|
-
|
|
810
|
-
|
|
899
|
+
const failures = details?.[category];
|
|
900
|
+
|
|
901
|
+
// docQuality groups its failures by doc (they carry { file, issue, fixCmd }).
|
|
902
|
+
if (category === 'docQuality' && failures?.length > 0) {
|
|
811
903
|
const byDoc = {};
|
|
812
904
|
for (const f of failures) {
|
|
813
|
-
const doc = f.file.replace('docs-canonical/', '');
|
|
905
|
+
const doc = (f.file || '').replace('docs-canonical/', '') || 'docs';
|
|
814
906
|
if (!byDoc[doc]) byDoc[doc] = [];
|
|
815
907
|
byDoc[doc].push(f.issue);
|
|
816
908
|
}
|
|
817
909
|
const parts = Object.entries(byDoc).map(([doc, issues]) => `${doc}: ${issues.join(', ')}`);
|
|
818
|
-
const fixCmd = failures.find(f => f.fixCmd)?.fixCmd ||
|
|
910
|
+
const fixCmd = failures.find(f => f.fixCmd)?.fixCmd || CATEGORY_FIX.docQuality;
|
|
819
911
|
return `${parts.join(' | ')} → Run \`${fixCmd}\``;
|
|
820
912
|
}
|
|
821
913
|
|
|
822
|
-
|
|
823
|
-
|
|
824
|
-
|
|
825
|
-
|
|
826
|
-
|
|
827
|
-
|
|
828
|
-
|
|
829
|
-
|
|
830
|
-
|
|
831
|
-
environment: 'Document env variables and create .env.example → Run `docguard fix --doc environment`',
|
|
832
|
-
drift: 'Create DRIFT-LOG.md and log any code deviations',
|
|
833
|
-
changelog: 'Maintain CHANGELOG.md with [Unreleased] section',
|
|
834
|
-
architecture: 'Add layer boundaries and Mermaid diagrams → Run `docguard fix --doc architecture`',
|
|
835
|
-
};
|
|
836
|
-
return suggestions[category] || 'Review and improve this area';
|
|
914
|
+
// Every other category: name the sub-checks that actually failed, so the line
|
|
915
|
+
// never describes work that's already done (field report, Issue B).
|
|
916
|
+
if (failures?.length > 0) {
|
|
917
|
+
const base = failures.map(f => f.issue).join('; ');
|
|
918
|
+
const fixCmd = failures.find(f => f.fixCmd)?.fixCmd || CATEGORY_FIX[category];
|
|
919
|
+
return fixCmd ? `${base} → Run \`${fixCmd}\`` : base;
|
|
920
|
+
}
|
|
921
|
+
|
|
922
|
+
return STATIC_SUGGESTIONS[category] || 'Review and improve this area';
|
|
837
923
|
}
|
|
838
924
|
|
|
839
925
|
/**
|
package/cli/commands/setup.mjs
CHANGED
|
@@ -22,7 +22,7 @@ import { resolve, dirname, basename } from 'node:path';
|
|
|
22
22
|
import { fileURLToPath } from 'node:url';
|
|
23
23
|
import { createInterface } from 'node:readline';
|
|
24
24
|
import { execSync } from 'node:child_process';
|
|
25
|
-
import { c } from '../shared.mjs';
|
|
25
|
+
import { c, CURRENT_SCHEMA_VERSION } from '../shared.mjs';
|
|
26
26
|
import { ensureSkills, detectAgentMode, isSpecKitInitialized, getDetectedAgent } from '../ensure-skills.mjs';
|
|
27
27
|
|
|
28
28
|
const __filename = fileURLToPath(import.meta.url);
|
|
@@ -133,7 +133,7 @@ export async function runSetup(projectDir, config, flags) {
|
|
|
133
133
|
|
|
134
134
|
const defaultConfig = {
|
|
135
135
|
projectName: config.projectName,
|
|
136
|
-
version:
|
|
136
|
+
version: CURRENT_SCHEMA_VERSION, // single source of truth (shared.mjs)
|
|
137
137
|
profile: 'standard',
|
|
138
138
|
projectType: detectedType,
|
|
139
139
|
projectTypeConfig: typeDefaults[detectedType] || typeDefaults.unknown,
|
package/cli/commands/sync.mjs
CHANGED
|
@@ -104,6 +104,12 @@ export function runSync(projectDir, config, flags) {
|
|
|
104
104
|
if (sec.source !== 'code') continue;
|
|
105
105
|
const existing = getSection(content, sec.id);
|
|
106
106
|
if (!existing) continue; // sync refreshes sections that already exist
|
|
107
|
+
// B5: a pinned section is intentionally hand-maintained — never revert it.
|
|
108
|
+
// (Pairs with the Generated-Staleness exemption for the same marker.)
|
|
109
|
+
if (existing.attrs?.pinned !== undefined) {
|
|
110
|
+
skipped.push({ doc: doc.path, reason: `section ${sec.id} is pinned (hand-maintained) — not synced` });
|
|
111
|
+
continue;
|
|
112
|
+
}
|
|
107
113
|
if (existing.body.trim() === String(sec.body).trim()) continue; // already current
|
|
108
114
|
// L-1: when --since is provided, only update sections whose underlying
|
|
109
115
|
// source files appear in the changed set. Avoids spurious updates when
|
package/cli/commands/trace.mjs
CHANGED
|
@@ -25,7 +25,7 @@ const CODE_EXTENSIONS = new Set([
|
|
|
25
25
|
// false-negative warnings on Python/Rust/Go/Java projects (reported by the
|
|
26
26
|
// quick-recon-tool Python user: TEST-SPEC.md was flagged unlinked even
|
|
27
27
|
// though Python tests existed because `.test.mjs` didn't match `test_*.py`).
|
|
28
|
-
import { TEST_PATTERNS, TRACE_MAP } from '../shared-trace-patterns.mjs';
|
|
28
|
+
import { TEST_PATTERNS, TRACE_MAP, isTraceableSource } from '../shared-trace-patterns.mjs';
|
|
29
29
|
|
|
30
30
|
|
|
31
31
|
/**
|
|
@@ -189,7 +189,7 @@ export function runTrace(projectDir, config, flags) {
|
|
|
189
189
|
// Find matching source files for each pattern
|
|
190
190
|
const traces = [];
|
|
191
191
|
for (const pattern of traceInfo.sourcePatterns) {
|
|
192
|
-
const matches = projectFiles.filter(f => pattern.glob.test(f));
|
|
192
|
+
const matches = projectFiles.filter(f => isTraceableSource(f) && pattern.glob.test(f));
|
|
193
193
|
traces.push({
|
|
194
194
|
label: pattern.label,
|
|
195
195
|
matchCount: matches.length,
|
|
@@ -374,7 +374,7 @@ function findRelatedTests(projectFiles, sourcePatterns) {
|
|
|
374
374
|
const relatedTests = new Set();
|
|
375
375
|
|
|
376
376
|
for (const pattern of sourcePatterns) {
|
|
377
|
-
const sourceFiles = projectFiles.filter(f => pattern.glob.test(f));
|
|
377
|
+
const sourceFiles = projectFiles.filter(f => isTraceableSource(f) && pattern.glob.test(f));
|
|
378
378
|
for (const src of sourceFiles) {
|
|
379
379
|
const srcBase = basename(src).replace(/\.[^.]+$/, '');
|
|
380
380
|
const srcDir = src.split('/')[0];
|