docguard-cli 0.23.0 → 0.24.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 +2 -2
- package/cli/commands/guard.mjs +86 -11
- package/cli/commands/hooks.mjs +12 -7
- package/cli/commands/init.mjs +18 -6
- package/cli/commands/score.mjs +147 -61
- package/cli/commands/setup.mjs +2 -2
- package/cli/commands/trace.mjs +3 -3
- package/cli/commands/upgrade.mjs +61 -13
- package/cli/config.mjs +18 -1
- package/cli/docguard.mjs +19 -0
- 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/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 +16 -1
- package/cli/shared-source.mjs +59 -2
- package/cli/shared-trace-patterns.mjs +13 -0
- package/cli/shared.mjs +60 -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/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/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/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];
|
package/cli/commands/upgrade.mjs
CHANGED
|
@@ -71,14 +71,48 @@ function readProjectSchemaVersion(projectDir) {
|
|
|
71
71
|
}
|
|
72
72
|
}
|
|
73
73
|
|
|
74
|
+
/**
|
|
75
|
+
* Normalize the `requiredFiles` block to the canonical object shape
|
|
76
|
+
* (`{ canonical: [...], agentFile, changelog, driftLog }`). Validators read
|
|
77
|
+
* `config.requiredFiles?.canonical || []`, so a config whose `requiredFiles` is
|
|
78
|
+
* a bare array (a legacy/hand-edited shape) or an object that lost its
|
|
79
|
+
* `canonical` key makes Traceability/Structure/Freshness silently see an empty
|
|
80
|
+
* list and PASS — the exact false-green this tool exists to prevent.
|
|
81
|
+
*
|
|
82
|
+
* Returns `{ cfg, changed, notes }`. We only TRANSFORM the unambiguous case (a
|
|
83
|
+
* bare array can only ever have been the canonical doc list); any other broken
|
|
84
|
+
* shape is SURFACED as a note rather than guessed at.
|
|
85
|
+
*/
|
|
86
|
+
function normalizeRequiredFiles(cfg) {
|
|
87
|
+
const rf = cfg.requiredFiles;
|
|
88
|
+
const notes = [];
|
|
89
|
+
if (rf === undefined || rf === null) {
|
|
90
|
+
// No requiredFiles at all — validators default to empty and check nothing.
|
|
91
|
+
// Don't fabricate a list (we can't know the project's docs); surface it.
|
|
92
|
+
notes.push('⚠ requiredFiles is missing — Traceability/Structure/Freshness have nothing to verify (silent-pass risk). Run `docguard init` to generate it.');
|
|
93
|
+
return { cfg, changed: false, notes };
|
|
94
|
+
}
|
|
95
|
+
if (Array.isArray(rf)) {
|
|
96
|
+
notes.push('Normalized legacy requiredFiles array → { canonical: [...] } so canonical-doc checks see your docs again.');
|
|
97
|
+
return { cfg: { ...cfg, requiredFiles: { canonical: rf } }, changed: true, notes };
|
|
98
|
+
}
|
|
99
|
+
if (typeof rf === 'object' && !Array.isArray(rf.canonical)) {
|
|
100
|
+
notes.push('⚠ requiredFiles.canonical is missing or not an array — canonical-doc checks will silently pass. Restore it or run `docguard init`.');
|
|
101
|
+
}
|
|
102
|
+
return { cfg, changed: false, notes };
|
|
103
|
+
}
|
|
104
|
+
|
|
74
105
|
/**
|
|
75
106
|
* Idempotent migration: walk the project config and add any fields introduced
|
|
76
|
-
* since the stored schema version
|
|
107
|
+
* since the stored schema version, then normalize the requiredFiles shape.
|
|
108
|
+
* Returns { changed, newConfig, notes }.
|
|
77
109
|
*
|
|
78
|
-
* Each migration is keyed by the version it migrates TO. Adding a new
|
|
79
|
-
* version means adding one entry here.
|
|
110
|
+
* Each version migration is keyed by the version it migrates TO. Adding a new
|
|
111
|
+
* schema version means adding one entry here. The requiredFiles normalization
|
|
112
|
+
* runs regardless of the version delta — a config can sit at the current
|
|
113
|
+
* version yet still carry a hand-edited shape that breaks validators silently.
|
|
80
114
|
*/
|
|
81
|
-
function migrateSchema(cfg, fromVersion) {
|
|
115
|
+
export function migrateSchema(cfg, fromVersion) {
|
|
82
116
|
const migrations = {
|
|
83
117
|
// v0.4 — pre-0.4 schemas (no `version` field, often `project` instead
|
|
84
118
|
// of `projectName`) normalize here. Rename `project` → `projectName`
|
|
@@ -99,16 +133,24 @@ function migrateSchema(cfg, fromVersion) {
|
|
|
99
133
|
let current = { ...cfg };
|
|
100
134
|
let changed = false;
|
|
101
135
|
const target = CURRENT_SCHEMA_VERSION;
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
136
|
+
|
|
137
|
+
// Version-keyed field migrations (additive). Skipped when already current.
|
|
138
|
+
if (compareVersions(fromVersion, target) < 0) {
|
|
139
|
+
for (const [ver, fn] of Object.entries(migrations)) {
|
|
140
|
+
if (compareVersions(fromVersion, ver) < 0 && compareVersions(ver, target) <= 0) {
|
|
141
|
+
current = fn(current);
|
|
142
|
+
changed = true;
|
|
143
|
+
}
|
|
108
144
|
}
|
|
145
|
+
if (changed) current.version = target;
|
|
109
146
|
}
|
|
110
|
-
|
|
111
|
-
|
|
147
|
+
|
|
148
|
+
// requiredFiles shape normalization — independent of the version delta.
|
|
149
|
+
const norm = normalizeRequiredFiles(current);
|
|
150
|
+
current = norm.cfg;
|
|
151
|
+
if (norm.changed) changed = true;
|
|
152
|
+
|
|
153
|
+
return { changed, newConfig: current, notes: norm.notes };
|
|
112
154
|
}
|
|
113
155
|
|
|
114
156
|
/**
|
|
@@ -291,7 +333,13 @@ export async function runUpgrade(projectDir, _config, flags) {
|
|
|
291
333
|
if (schemaBehind && projectSchema) {
|
|
292
334
|
const cfgPath = resolve(projectDir, '.docguard.json');
|
|
293
335
|
const cfg = JSON.parse(readFileSync(cfgPath, 'utf-8'));
|
|
294
|
-
const { changed, newConfig } = migrateSchema(cfg, projectSchema);
|
|
336
|
+
const { changed, newConfig, notes } = migrateSchema(cfg, projectSchema);
|
|
337
|
+
// Surface requiredFiles findings (a normalization that happened, or a
|
|
338
|
+
// broken shape we refuse to guess at) before reporting the version bump.
|
|
339
|
+
for (const n of notes || []) {
|
|
340
|
+
const warn = n.startsWith('⚠');
|
|
341
|
+
console.log(` ${warn ? c.yellow : c.dim}${warn ? '' : '• '}${n}${c.reset}`);
|
|
342
|
+
}
|
|
295
343
|
if (changed) {
|
|
296
344
|
// v0.14-P4: --pr opens a PR for review instead of in-place editing.
|
|
297
345
|
// Useful when the team wants a reviewable diff or has branch-protected
|
package/cli/config.mjs
CHANGED
|
@@ -10,13 +10,17 @@
|
|
|
10
10
|
|
|
11
11
|
import { existsSync, readFileSync } from 'node:fs';
|
|
12
12
|
import { resolve, basename } from 'node:path';
|
|
13
|
-
import { c, PROFILES } from './shared.mjs';
|
|
13
|
+
import { c, PROFILES, SEVERITY_LEVELS } from './shared.mjs';
|
|
14
14
|
import { mergeIgnoreFile } from './shared-ignore.mjs';
|
|
15
15
|
|
|
16
16
|
export function loadConfig(projectDir) {
|
|
17
17
|
const configPath = resolve(projectDir, '.docguard.json');
|
|
18
18
|
const defaults = {
|
|
19
19
|
projectName: basename(projectDir),
|
|
20
|
+
// Legacy/unversioned fallback ONLY — the value a config is ASSUMED to be
|
|
21
|
+
// when its file has no `version` field. NOT the current schema version
|
|
22
|
+
// (that's CURRENT_SCHEMA_VERSION in shared.mjs, written by `init`). Kept low
|
|
23
|
+
// on purpose so a versionless (pre-0.4) config still trips the upgrade nudge.
|
|
20
24
|
version: '0.2',
|
|
21
25
|
profile: 'standard',
|
|
22
26
|
requiredFiles: {
|
|
@@ -89,6 +93,19 @@ export function loadConfig(projectDir) {
|
|
|
89
93
|
const merged = deepMerge(withProfile, normalizeConfig(userConfig));
|
|
90
94
|
merged.profile = profileName;
|
|
91
95
|
|
|
96
|
+
// v0.24: severity accepts only high|medium|low and changes EXIT-CODE
|
|
97
|
+
// weight — it never mutes a warning from display. A value like "off"
|
|
98
|
+
// silently fell back to "medium", so users who wrote severity:{k:"off"}
|
|
99
|
+
// expecting silence still saw the warning and got no feedback (field
|
|
100
|
+
// report). Surface the misconfig and point at the real disable switch.
|
|
101
|
+
if (merged.severity && typeof merged.severity === 'object') {
|
|
102
|
+
for (const [key, val] of Object.entries(merged.severity)) {
|
|
103
|
+
if (typeof val === 'string' && !SEVERITY_LEVELS.has(val.toLowerCase())) {
|
|
104
|
+
console.error(`${c.yellow}⚠ .docguard.json: severity.${key} = "${val}" is not a valid level${c.reset} ${c.dim}(use high | medium | low). To silence a validator entirely, set ${c.reset}${c.cyan}validators.${key}: false${c.dim}.${c.reset}`);
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
|
|
92
109
|
// Auto-detect project type if not set
|
|
93
110
|
if (!merged.projectType) {
|
|
94
111
|
merged.projectType = autoDetectProjectType(projectDir);
|
package/cli/docguard.mjs
CHANGED
|
@@ -241,6 +241,12 @@ async function main() {
|
|
|
241
241
|
// Default stays on (discoverability), but lets minimalist library
|
|
242
242
|
// projects skip the .specify/.agent/commands scaffolding.
|
|
243
243
|
flags.noSpecKit = true;
|
|
244
|
+
} else if (args[i] === '--spec-kit') {
|
|
245
|
+
// v0.24: explicit opt-IN to the Spec Kit framework scaffold. The
|
|
246
|
+
// `starter` profile skips that scaffold by default (minimal, for side
|
|
247
|
+
// projects — it would otherwise drop ~30 files); pass --spec-kit to
|
|
248
|
+
// include it anyway. No effect on other profiles (already on by default).
|
|
249
|
+
flags.specKit = true;
|
|
244
250
|
} else if (args[i] === '--pin') {
|
|
245
251
|
// v0.17-P1: `docguard guard --pin` records the running CLI version
|
|
246
252
|
// into .docguard.json (`docguardVersion` field) after a successful run.
|
|
@@ -298,6 +304,11 @@ async function main() {
|
|
|
298
304
|
flags.debate = true;
|
|
299
305
|
} else if (args[i] === '--stdout') {
|
|
300
306
|
flags.stdout = true;
|
|
307
|
+
} else if (args[i] === '--help' || args[i] === '-h') {
|
|
308
|
+
// v0.24: capture --help anywhere on the line, not just as the bare
|
|
309
|
+
// command. Previously `docguard generate --help` fell through the parser
|
|
310
|
+
// and executed generate, scaffolding files into the cwd (field report).
|
|
311
|
+
flags.help = true;
|
|
301
312
|
}
|
|
302
313
|
}
|
|
303
314
|
|
|
@@ -313,6 +324,14 @@ async function main() {
|
|
|
313
324
|
process.exit(0);
|
|
314
325
|
}
|
|
315
326
|
|
|
327
|
+
// v0.24: `docguard <command> --help` shows usage instead of running the
|
|
328
|
+
// command. There is no per-command help yet, so global help is correct — and,
|
|
329
|
+
// unlike before, non-destructive (generate no longer scaffolds on --help).
|
|
330
|
+
if (flags.help) {
|
|
331
|
+
printHelp();
|
|
332
|
+
process.exit(0);
|
|
333
|
+
}
|
|
334
|
+
|
|
316
335
|
// In JSON mode the entire stdout MUST be parseable JSON. The banner and
|
|
317
336
|
// ensureSkills' install message would corrupt the output for any
|
|
318
337
|
// programmatic consumer (CI, dashboards, the Score-on-PR Action recipe).
|