docguard-cli 0.33.1 → 0.34.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/cli/commands/hooks.mjs +7 -9
- package/cli/commands/impact.mjs +12 -12
- package/cli/commands/init.mjs +4 -7
- package/cli/commands/llms.mjs +9 -19
- package/cli/commands/memory.mjs +7 -8
- package/cli/commands/score.mjs +17 -24
- package/cli/commands/trace.mjs +10 -11
- package/cli/scanners/agent-readability.mjs +6 -10
- package/cli/scanners/semantic-claims.mjs +7 -13
- package/cli/shared-ignore.mjs +53 -0
- package/cli/validator-markers.mjs +7 -11
- package/cli/validators/api-doc-smells.mjs +11 -8
- package/cli/validators/cross-reference.mjs +4 -15
- package/cli/validators/diff-suspicion.mjs +5 -9
- package/cli/validators/doc-quality.mjs +9 -20
- package/cli/validators/docs-coverage.mjs +2 -9
- package/cli/validators/docs-sync.mjs +6 -8
- package/cli/validators/generated-staleness.mjs +28 -25
- package/cli/validators/reference-existence.mjs +4 -7
- package/cli/validators/traceability.mjs +23 -26
- package/extensions/spec-kit-docguard/extension.yml +1 -1
- 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 -2
- package/package.json +1 -1
package/cli/commands/hooks.mjs
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
* Creates git hooks that run guard/score before commits.
|
|
4
4
|
*/
|
|
5
5
|
|
|
6
|
-
import { existsSync, writeFileSync, mkdirSync, chmodSync, readFileSync, unlinkSync
|
|
6
|
+
import { existsSync, writeFileSync, mkdirSync, chmodSync, readFileSync, unlinkSync } from 'node:fs';
|
|
7
7
|
|
|
8
8
|
// v0.16-P3: managed-block markers. Letting users extend the hook with their
|
|
9
9
|
// own commands (data-file guards, lint checks, etc.) without us clobbering
|
|
@@ -57,6 +57,7 @@ function spliceManagedBlock(existing, newBody) {
|
|
|
57
57
|
import { resolve, relative, basename } from 'node:path';
|
|
58
58
|
import { c } from '../shared.mjs';
|
|
59
59
|
import { getHooksDir } from '../shared-git.mjs';
|
|
60
|
+
import { listCanonicalDocs } from '../shared-ignore.mjs';
|
|
60
61
|
|
|
61
62
|
const HOOKS = {
|
|
62
63
|
'pre-commit': {
|
|
@@ -488,14 +489,11 @@ function docsReferencingFile(projectDir, base) {
|
|
|
488
489
|
if (readFileSync(full, 'utf-8').includes(base)) docs.push(name);
|
|
489
490
|
} catch { /* unreadable */ }
|
|
490
491
|
};
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
}
|
|
497
|
-
} catch { /* unreadable dir */ }
|
|
498
|
-
}
|
|
492
|
+
// Recursive — a nested doc mentioning the edited file must still trigger
|
|
493
|
+
// the post-commit nudge. `name` stays the bare basename to match this
|
|
494
|
+
// function's pre-existing flat-tree display (the nudge message lists
|
|
495
|
+
// "ARCHITECTURE.md, SECURITY.md", not full nested paths).
|
|
496
|
+
for (const doc of listCanonicalDocs(projectDir)) check(basename(doc.rel), doc.abs);
|
|
499
497
|
for (const a of NUDGE_AGENT_FILES) {
|
|
500
498
|
const p = resolve(projectDir, a);
|
|
501
499
|
if (existsSync(p)) check(a, p);
|
package/cli/commands/impact.mjs
CHANGED
|
@@ -40,13 +40,14 @@
|
|
|
40
40
|
* @req SC-S11-009 — docs referencing an importer of a changed file are flagged as indirect
|
|
41
41
|
*/
|
|
42
42
|
|
|
43
|
-
import { existsSync, readFileSync
|
|
43
|
+
import { existsSync, readFileSync } from 'node:fs';
|
|
44
44
|
import { resolve, basename } from 'node:path';
|
|
45
45
|
import { execFileSync } from 'node:child_process';
|
|
46
46
|
|
|
47
47
|
import { c } from '../shared.mjs';
|
|
48
48
|
import { changedFilesSince, isGitRepo } from '../shared-git.mjs';
|
|
49
49
|
import { buildImportGraph } from '../validators/architecture.mjs';
|
|
50
|
+
import { listCanonicalDocs } from '../shared-ignore.mjs';
|
|
50
51
|
|
|
51
52
|
/**
|
|
52
53
|
* File extensions we consider "code" for the purposes of impact analysis.
|
|
@@ -254,19 +255,18 @@ export function runImpact(projectDir, config, flags) {
|
|
|
254
255
|
|
|
255
256
|
// Index canonical docs once, PLUS root agent-instruction files (they name
|
|
256
257
|
// code and other docs, so they belong in both code→doc and doc→doc analysis).
|
|
257
|
-
const
|
|
258
|
-
const docsIndex = new Map(); // docName → lines[]
|
|
258
|
+
const docsIndex = new Map(); // docName (basename) → lines[]
|
|
259
259
|
const agentDocs = new Set(); // which indexed docs are agent-instruction files
|
|
260
|
-
|
|
260
|
+
// Recursive — a nested canonical doc must be indexed too. Keyed by basename
|
|
261
|
+
// (not full path) to match this file's existing basename-based lookups
|
|
262
|
+
// (`docsIndex.has(basename(f))`, `indexBasenames`); a doc in a subfolder
|
|
263
|
+
// with a name clashing an already-indexed doc will overwrite it in the Map,
|
|
264
|
+
// same pre-existing behavior as a canonical doc clashing an agent doc name.
|
|
265
|
+
for (const doc of listCanonicalDocs(projectDir)) {
|
|
261
266
|
try {
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
const content = readFileSync(resolve(docsDir, f), 'utf-8');
|
|
266
|
-
docsIndex.set(f, content.split('\n'));
|
|
267
|
-
} catch { /* skip unreadable */ }
|
|
268
|
-
}
|
|
269
|
-
} catch { /* skip if dir unreadable */ }
|
|
267
|
+
const content = readFileSync(doc.abs, 'utf-8');
|
|
268
|
+
docsIndex.set(basename(doc.rel), content.split('\n'));
|
|
269
|
+
} catch { /* skip unreadable */ }
|
|
270
270
|
}
|
|
271
271
|
for (const a of AGENT_FILES) {
|
|
272
272
|
const p = resolve(projectDir, a);
|
package/cli/commands/init.mjs
CHANGED
|
@@ -11,6 +11,7 @@
|
|
|
11
11
|
|
|
12
12
|
import { existsSync, mkdirSync, readFileSync, writeFileSync, readdirSync } from 'node:fs';
|
|
13
13
|
import { resolve, dirname } from 'node:path';
|
|
14
|
+
import { listCanonicalDocs } from '../shared-ignore.mjs';
|
|
14
15
|
import { fileURLToPath } from 'node:url';
|
|
15
16
|
import { createInterface } from 'node:readline';
|
|
16
17
|
import { execSync } from 'node:child_process';
|
|
@@ -135,13 +136,9 @@ function shouldRunGenerate(projectDir, flags) {
|
|
|
135
136
|
if (flags.fix) return false; // --fix = deterministic create-missing-from-templates (headless)
|
|
136
137
|
|
|
137
138
|
// If canonical docs already exist, this is a re-init, not a first-run.
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
const entries = readdirSync(canonicalDir).filter(f => f.endsWith('.md'));
|
|
142
|
-
if (entries.length > 0) return false;
|
|
143
|
-
} catch { /* fall through */ }
|
|
144
|
-
}
|
|
139
|
+
// Recursive — a project whose only canonical docs are nested must still be
|
|
140
|
+
// detected as already-initialized, or the wizard re-triggers on every run.
|
|
141
|
+
if (listCanonicalDocs(projectDir).length > 0) return false;
|
|
145
142
|
|
|
146
143
|
// Existing-code signals: any of cli/, src/, lib/, app/ as a directory.
|
|
147
144
|
const codeDirs = ['cli', 'src', 'lib', 'app'];
|
package/cli/commands/llms.mjs
CHANGED
|
@@ -15,9 +15,10 @@
|
|
|
15
15
|
* - `docguard guard` validates llms.txt exists and is current
|
|
16
16
|
*/
|
|
17
17
|
|
|
18
|
-
import { existsSync, readFileSync, writeFileSync
|
|
18
|
+
import { existsSync, readFileSync, writeFileSync } from 'node:fs';
|
|
19
19
|
import { resolve, join, basename } from 'node:path';
|
|
20
20
|
import { c } from '../shared.mjs';
|
|
21
|
+
import { listCanonicalDocs } from '../shared-ignore.mjs';
|
|
21
22
|
|
|
22
23
|
// ──── Doc descriptions for llms.txt ────
|
|
23
24
|
const DOC_DESCRIPTIONS = {
|
|
@@ -58,18 +59,12 @@ export function generateLlmsTxt(projectDir, config) {
|
|
|
58
59
|
}
|
|
59
60
|
lines.push('');
|
|
60
61
|
|
|
61
|
-
// ── Canonical Docs ──
|
|
62
|
-
const docsDir = resolve(projectDir, 'docs-canonical');
|
|
62
|
+
// ── Canonical Docs ── (recursive — a nested doc still belongs in llms.txt)
|
|
63
63
|
const existingDocs = [];
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
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 */ }
|
|
64
|
+
for (const doc of listCanonicalDocs(projectDir)) {
|
|
65
|
+
const entry = basename(doc.rel);
|
|
66
|
+
const desc = DOC_DESCRIPTIONS[entry] || `${entry.replace('.md', '')} documentation`;
|
|
67
|
+
existingDocs.push({ path: doc.rel, name: entry, desc });
|
|
73
68
|
}
|
|
74
69
|
|
|
75
70
|
if (existingDocs.length > 0) {
|
|
@@ -160,13 +155,8 @@ export function generateLlmsFullTxt(projectDir, config) {
|
|
|
160
155
|
|
|
161
156
|
// Same doc discovery as the index form: canonical docs + present optional docs.
|
|
162
157
|
const docPaths = [];
|
|
163
|
-
const
|
|
164
|
-
|
|
165
|
-
try {
|
|
166
|
-
for (const entry of readdirSync(docsDir).filter(f => f.endsWith('.md')).sort()) {
|
|
167
|
-
docPaths.push({ path: `docs-canonical/${entry}`, desc: DOC_DESCRIPTIONS[entry] || null });
|
|
168
|
-
}
|
|
169
|
-
} catch { /* ignore */ }
|
|
158
|
+
for (const doc of listCanonicalDocs(projectDir)) {
|
|
159
|
+
docPaths.push({ path: doc.rel, desc: DOC_DESCRIPTIONS[basename(doc.rel)] || null });
|
|
170
160
|
}
|
|
171
161
|
for (const [file, desc] of Object.entries(OPTIONAL_DOCS)) {
|
|
172
162
|
if (existsSync(resolve(projectDir, file))) docPaths.push({ path: file, desc });
|
package/cli/commands/memory.mjs
CHANGED
|
@@ -21,9 +21,10 @@
|
|
|
21
21
|
* Zero NPM dependencies. Pure orchestration of existing diff helpers.
|
|
22
22
|
*/
|
|
23
23
|
|
|
24
|
-
import { existsSync, readFileSync,
|
|
24
|
+
import { existsSync, readFileSync, mkdirSync, writeFileSync } from 'node:fs';
|
|
25
25
|
import { resolve } from 'node:path';
|
|
26
26
|
import { c } from '../shared.mjs';
|
|
27
|
+
import { listCanonicalDocs } from '../shared-ignore.mjs';
|
|
27
28
|
import { diffRoutes, diffEntities, diffEnvVars, diffTechStack } from './diff.mjs';
|
|
28
29
|
import { buildMemoryPlan } from '../scanners/memory-plan.mjs';
|
|
29
30
|
import { runGuardInternal } from './guard.mjs';
|
|
@@ -97,19 +98,17 @@ function runMemoryPack(projectDir, config, flags) {
|
|
|
97
98
|
lines.push(`- Tests: ${plan.surface.tests.totalFiles} files, ${plan.surface.tests.totalCases} cases`);
|
|
98
99
|
lines.push('');
|
|
99
100
|
|
|
100
|
-
const
|
|
101
|
-
if (
|
|
101
|
+
const canonicalDocs = listCanonicalDocs(projectDir);
|
|
102
|
+
if (canonicalDocs.length > 0) {
|
|
102
103
|
lines.push('## Canonical docs');
|
|
103
104
|
lines.push('');
|
|
104
|
-
|
|
105
|
-
try { entries = readdirSync(docsDir).filter(f => f.endsWith('.md')).sort(); } catch { /* ignore */ }
|
|
106
|
-
for (const doc of entries) {
|
|
105
|
+
for (const doc of canonicalDocs) {
|
|
107
106
|
let reviewed = '';
|
|
108
107
|
try {
|
|
109
|
-
const m = readFileSync(
|
|
108
|
+
const m = readFileSync(doc.abs, 'utf-8').match(/docguard:last-reviewed\s+(\d{4}-\d{2}-\d{2})/);
|
|
110
109
|
if (m) reviewed = ` (last-reviewed ${m[1]})`;
|
|
111
110
|
} catch { /* ignore */ }
|
|
112
|
-
lines.push(`-
|
|
111
|
+
lines.push(`- ${doc.rel}${reviewed}`);
|
|
113
112
|
}
|
|
114
113
|
lines.push('');
|
|
115
114
|
}
|
package/cli/commands/score.mjs
CHANGED
|
@@ -12,6 +12,7 @@ import { runGuardInternal } from './guard.mjs';
|
|
|
12
12
|
import { extractSemanticClaims } from '../scanners/semantic-claims.mjs';
|
|
13
13
|
import { assessAgentReadability } from '../scanners/agent-readability.mjs';
|
|
14
14
|
import { loadHistory, sparkline } from '../writers/history.mjs';
|
|
15
|
+
import { listCanonicalDocs } from '../shared-ignore.mjs';
|
|
15
16
|
|
|
16
17
|
/**
|
|
17
18
|
* Detect whether the project configures a test runner (the "Check 3" of the
|
|
@@ -424,16 +425,16 @@ export function computeAlcoaCompliance(projectDir, config, scores) {
|
|
|
424
425
|
// 1. Attributable — Can we trace who wrote/reviewed docs?
|
|
425
426
|
const hasGit = existsSync(resolve(projectDir, '.git'));
|
|
426
427
|
const docsDir = resolve(projectDir, 'docs-canonical');
|
|
428
|
+
// Recursive — a nested canonical tree is still the canonical tree. A flat
|
|
429
|
+
// read scored ALCOA against an empty doc set on those projects.
|
|
430
|
+
const canonicalDocs = listCanonicalDocs(projectDir);
|
|
427
431
|
let hasReviewedMeta = false;
|
|
428
|
-
|
|
432
|
+
for (const doc of canonicalDocs) {
|
|
429
433
|
try {
|
|
430
|
-
const
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
hasReviewedMeta = true;
|
|
435
|
-
break;
|
|
436
|
-
}
|
|
434
|
+
const content = readFileSync(doc.abs, 'utf-8');
|
|
435
|
+
if (content.includes('docguard:last-reviewed') || content.includes('last-reviewed')) {
|
|
436
|
+
hasReviewedMeta = true;
|
|
437
|
+
break;
|
|
437
438
|
}
|
|
438
439
|
} catch { /* ignore */ }
|
|
439
440
|
}
|
|
@@ -457,16 +458,13 @@ export function computeAlcoaCompliance(projectDir, config, scores) {
|
|
|
457
458
|
|
|
458
459
|
// 3. Contemporaneous — Are docs kept current?
|
|
459
460
|
let freshnessMet = true;
|
|
460
|
-
|
|
461
|
+
for (const doc of canonicalDocs) {
|
|
461
462
|
try {
|
|
462
|
-
const
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
freshnessMet = false;
|
|
468
|
-
break;
|
|
469
|
-
}
|
|
463
|
+
const stat_ = statSync(doc.abs);
|
|
464
|
+
const daysSinceModified = (Date.now() - stat_.mtimeMs) / (1000 * 60 * 60 * 24);
|
|
465
|
+
if (daysSinceModified > 30) {
|
|
466
|
+
freshnessMet = false;
|
|
467
|
+
break;
|
|
470
468
|
}
|
|
471
469
|
} catch { /* ignore */ }
|
|
472
470
|
}
|
|
@@ -1079,13 +1077,8 @@ function getSuggestion(category, score, details) {
|
|
|
1079
1077
|
*/
|
|
1080
1078
|
function estimateDocTax(projectDir, config, scores) {
|
|
1081
1079
|
// Count tracked docs
|
|
1082
|
-
|
|
1083
|
-
let docCount =
|
|
1084
|
-
if (existsSync(canonicalDir)) {
|
|
1085
|
-
try {
|
|
1086
|
-
docCount = readdirSync(canonicalDir).filter(f => f.endsWith('.md')).length;
|
|
1087
|
-
} catch { /* ignore */ }
|
|
1088
|
-
}
|
|
1080
|
+
// Recursive — nested canonical docs cost maintenance too, so they count.
|
|
1081
|
+
let docCount = listCanonicalDocs(projectDir).length;
|
|
1089
1082
|
// Add root tracking files
|
|
1090
1083
|
if (existsSync(resolve(projectDir, 'CHANGELOG.md'))) docCount++;
|
|
1091
1084
|
if (existsSync(resolve(projectDir, 'DRIFT-LOG.md'))) docCount++;
|
package/cli/commands/trace.mjs
CHANGED
|
@@ -10,6 +10,7 @@ import { existsSync, readFileSync, readdirSync, statSync } from 'node:fs';
|
|
|
10
10
|
import { resolve, join, extname, basename, relative, dirname } from 'node:path';
|
|
11
11
|
import { c } from '../shared.mjs';
|
|
12
12
|
import { detectSpecKit } from '../scanners/speckit.mjs';
|
|
13
|
+
import { listCanonicalDocs } from '../shared-ignore.mjs';
|
|
13
14
|
|
|
14
15
|
const IGNORE_DIRS = new Set([
|
|
15
16
|
'node_modules', '.git', '.next', 'dist', 'build', 'coverage',
|
|
@@ -74,11 +75,11 @@ export function runTraceReverse(projectDir, config, flags) {
|
|
|
74
75
|
const stem = base.replace(/\.[^.]+$/, '');
|
|
75
76
|
|
|
76
77
|
const matches = []; // { doc, line, content, kind }
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
78
|
+
// Recursive — a nested doc mentioning this file must still be found, or
|
|
79
|
+
// `trace --reverse` falsely reports "no canonical doc references this".
|
|
80
|
+
for (const doc of listCanonicalDocs(projectDir)) {
|
|
80
81
|
let content;
|
|
81
|
-
try { content = readFileSync(
|
|
82
|
+
try { content = readFileSync(doc.abs, 'utf-8'); } catch { continue; }
|
|
82
83
|
const lines = content.split('\n');
|
|
83
84
|
for (let i = 0; i < lines.length; i++) {
|
|
84
85
|
const line = lines[i];
|
|
@@ -87,7 +88,7 @@ export function runTraceReverse(projectDir, config, flags) {
|
|
|
87
88
|
else if (line.includes(base)) kind = 'basename';
|
|
88
89
|
else if (new RegExp(`\`${escapeRegex(stem)}\``).test(line)) kind = 'module';
|
|
89
90
|
if (kind) {
|
|
90
|
-
matches.push({ doc:
|
|
91
|
+
matches.push({ doc: doc.rel, line: i + 1, content: line.trim(), kind });
|
|
91
92
|
}
|
|
92
93
|
}
|
|
93
94
|
}
|
|
@@ -155,13 +156,11 @@ export function runTrace(projectDir, config, flags) {
|
|
|
155
156
|
);
|
|
156
157
|
|
|
157
158
|
// ── 2. Inventory canonical docs ──
|
|
159
|
+
// NOTE: currently unused downstream (outputText ignores its 3rd param) —
|
|
160
|
+
// kept for API stability and future use. Recursive so it isn't ANOTHER
|
|
161
|
+
// silent flat-read landmine if something starts consuming it.
|
|
158
162
|
const docsDir = resolve(projectDir, 'docs-canonical');
|
|
159
|
-
const canonicalDocs =
|
|
160
|
-
if (existsSync(docsDir)) {
|
|
161
|
-
for (const f of readdirSync(docsDir)) {
|
|
162
|
-
if (f.endsWith('.md')) canonicalDocs.push(f);
|
|
163
|
-
}
|
|
164
|
-
}
|
|
163
|
+
const canonicalDocs = listCanonicalDocs(projectDir).map(d => d.rel);
|
|
165
164
|
|
|
166
165
|
// ── 3. Scan project files ──
|
|
167
166
|
const projectFiles = [];
|
|
@@ -12,9 +12,10 @@
|
|
|
12
12
|
* read that.
|
|
13
13
|
*/
|
|
14
14
|
|
|
15
|
-
import { existsSync, readFileSync
|
|
15
|
+
import { existsSync, readFileSync } from 'node:fs';
|
|
16
16
|
import { resolve, dirname } from 'node:path';
|
|
17
17
|
import { loadIgnorePatterns } from '../shared.mjs';
|
|
18
|
+
import { listCanonicalDocs } from '../shared-ignore.mjs';
|
|
18
19
|
|
|
19
20
|
/** chars/4 — the standard rough token estimate; consistency matters more than precision. */
|
|
20
21
|
const estTokens = (s) => Math.ceil(s.length / 4);
|
|
@@ -30,19 +31,14 @@ function readIfExists(path) {
|
|
|
30
31
|
}
|
|
31
32
|
|
|
32
33
|
function canonicalDocs(projectDir) {
|
|
33
|
-
const dir = resolve(projectDir, 'docs-canonical');
|
|
34
|
-
if (!existsSync(dir)) return [];
|
|
35
34
|
// Honor .docguardignore — an excluded doc (e.g. a historical audit) must
|
|
36
35
|
// not drag down the readability metrics either (same rule as the
|
|
37
36
|
// semantic-claim extractor, bug-212).
|
|
38
37
|
const isIgnored = loadIgnorePatterns(projectDir);
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
.map(f => ({ name: `docs-canonical/${f}`, content: readIfExists(resolve(dir, f)) }))
|
|
44
|
-
.filter(d => d.content !== null);
|
|
45
|
-
} catch { return []; }
|
|
38
|
+
// Recursive: docs in subfolders are canonical docs and are scored as such.
|
|
39
|
+
return listCanonicalDocs(projectDir, { isIgnored })
|
|
40
|
+
.map(d => ({ name: d.rel, content: readIfExists(d.abs) }))
|
|
41
|
+
.filter(d => d.content !== null);
|
|
46
42
|
}
|
|
47
43
|
|
|
48
44
|
/**
|
|
@@ -21,9 +21,10 @@
|
|
|
21
21
|
* Zero npm dependencies — pure Node.js built-ins.
|
|
22
22
|
*/
|
|
23
23
|
|
|
24
|
-
import { existsSync, readFileSync
|
|
25
|
-
import { resolve
|
|
24
|
+
import { existsSync, readFileSync } from 'node:fs';
|
|
25
|
+
import { resolve } from 'node:path';
|
|
26
26
|
import { loadIgnorePatterns } from '../shared.mjs';
|
|
27
|
+
import { listCanonicalDocs } from '../shared-ignore.mjs';
|
|
27
28
|
|
|
28
29
|
// Numbers are only claims when adjacent to a recognized unit.
|
|
29
30
|
const NUMBER_PATTERNS = [
|
|
@@ -55,17 +56,10 @@ function claimSourceDocs(projectDir) {
|
|
|
55
56
|
// "unverified claims" pool either — it inflated the count and buried the
|
|
56
57
|
// claims that ARE actionable (bug-212).
|
|
57
58
|
const isIgnored = loadIgnorePatterns(projectDir);
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
for (const f of readdirSync(canonical)) {
|
|
63
|
-
if (f.toLowerCase().endsWith('.md') && !isIgnored(`docs-canonical/${f}`)) {
|
|
64
|
-
docs.push(`docs-canonical/${f}`);
|
|
65
|
-
}
|
|
66
|
-
}
|
|
67
|
-
} catch { /* ignore */ }
|
|
68
|
-
}
|
|
59
|
+
// Recursive — nested canonical docs make claims too. The ignore predicate is
|
|
60
|
+
// applied per-doc inside the helper against the full relative path, so a
|
|
61
|
+
// pattern like `docs-canonical/99-archive/**` still excludes a subtree.
|
|
62
|
+
const docs = listCanonicalDocs(projectDir, { isIgnored }).map(d => d.rel);
|
|
69
63
|
for (const root of ['README.md', 'AGENTS.md']) {
|
|
70
64
|
if (existsSync(resolve(projectDir, root)) && !isIgnored(root)) docs.push(root);
|
|
71
65
|
}
|
package/cli/shared-ignore.mjs
CHANGED
|
@@ -350,6 +350,59 @@ export function walkFiles(dir, callback, opts = {}) {
|
|
|
350
350
|
return complete;
|
|
351
351
|
}
|
|
352
352
|
|
|
353
|
+
/**
|
|
354
|
+
* Enumerate the project's canonical docs — RECURSIVELY.
|
|
355
|
+
*
|
|
356
|
+
* Five call sites used to do this by hand with a flat
|
|
357
|
+
* `readdirSync('docs-canonical').filter(f => f.endsWith('.md'))`. That works
|
|
358
|
+
* only for a flat tree; a project that groups its canonical docs in subfolders
|
|
359
|
+
* (`docs-canonical/01-architecture/MODULE-MAP.md` — a common convention once a
|
|
360
|
+
* repo has more than a handful) was invisible to ALL of them. The failure was
|
|
361
|
+
* silent and pointed the wrong way: docs-sync flagged every service as
|
|
362
|
+
* undocumented while the docs sat right there, `docguard:validator … n/a`
|
|
363
|
+
* markers in nested docs were ignored, and readability/freshness scored an
|
|
364
|
+
* empty set. This is the single shared implementation — same rule as
|
|
365
|
+
* `walkFiles`: exactly one correct way to enumerate, so the answers can't drift.
|
|
366
|
+
*
|
|
367
|
+
* Contract:
|
|
368
|
+
* - Recursive; delegates traversal to `walkFiles`, so `ignoreDirs` and the
|
|
369
|
+
* skip-dot-directories rule apply. A dot-FILE ending in `.md` is kept, to
|
|
370
|
+
* preserve the flat behavior these call sites had.
|
|
371
|
+
* - `.md` matching is case-INSENSITIVE. Three of the five original call sites
|
|
372
|
+
* lowercased and two did not; one tool must not hold two opinions about
|
|
373
|
+
* what a canonical doc is. The inclusive reading wins.
|
|
374
|
+
* - Sorted by relative path, so output is deterministic across platforms.
|
|
375
|
+
* - NEVER throws. An unreadable subtree yields the files that were readable.
|
|
376
|
+
*
|
|
377
|
+
* @param {string} projectDir - Project root (absolute)
|
|
378
|
+
* @param {{dirName?: string, isIgnored?: ((relPath: string) => boolean) | null}} [opts]
|
|
379
|
+
* `isIgnored` — optional `.docguardignore` predicate from the caller (see
|
|
380
|
+
* `loadIgnorePatterns` in shared.mjs). Passed in rather than imported so this
|
|
381
|
+
* module stays a leaf with no local imports.
|
|
382
|
+
* @returns {Array<{abs: string, rel: string}>} `rel` is project-relative POSIX
|
|
383
|
+
*/
|
|
384
|
+
export function listCanonicalDocs(projectDir, opts = {}) {
|
|
385
|
+
const { dirName = 'docs-canonical', isIgnored = null } = opts;
|
|
386
|
+
const root = resolvePath(projectDir, dirName);
|
|
387
|
+
if (!existsSync(root)) return [];
|
|
388
|
+
|
|
389
|
+
const isMarkdown = (name) => name.toLowerCase().endsWith('.md');
|
|
390
|
+
const out = [];
|
|
391
|
+
walkFiles(root, (abs) => {
|
|
392
|
+
if (!isMarkdown(abs)) return;
|
|
393
|
+
const rel = relPosix(projectDir, abs);
|
|
394
|
+
if (isIgnored && isIgnored(rel)) return;
|
|
395
|
+
out.push({ abs, rel });
|
|
396
|
+
}, {
|
|
397
|
+
// Keep dot-FILES that are markdown (old flat behavior included them);
|
|
398
|
+
// dot-DIRECTORIES still get skipped, since the predicate only matches `.md`.
|
|
399
|
+
keepDot: isMarkdown,
|
|
400
|
+
});
|
|
401
|
+
|
|
402
|
+
out.sort((a, b) => (a.rel < b.rel ? -1 : a.rel > b.rel ? 1 : 0));
|
|
403
|
+
return out;
|
|
404
|
+
}
|
|
405
|
+
|
|
353
406
|
/**
|
|
354
407
|
* Count files under `projectDir` matching an anchored glob (project-relative).
|
|
355
408
|
* The code-truth side of `config.collections` (metrics-consistency).
|
|
@@ -20,8 +20,9 @@
|
|
|
20
20
|
* Zero NPM dependencies — pure Node.js built-ins.
|
|
21
21
|
*/
|
|
22
22
|
|
|
23
|
-
import { existsSync, readFileSync
|
|
24
|
-
import { resolve
|
|
23
|
+
import { existsSync, readFileSync } from 'node:fs';
|
|
24
|
+
import { resolve } from 'node:path';
|
|
25
|
+
import { listCanonicalDocs } from './shared-ignore.mjs';
|
|
25
26
|
|
|
26
27
|
// `<!-- docguard:validator <key> n/a [— reason] -->`
|
|
27
28
|
// Separator before the reason may be —, :, or one-or-more hyphens. Reason
|
|
@@ -30,15 +31,10 @@ const MARKER_RE = /<!--\s*docguard:validator\s+([A-Za-z0-9_-]+)\s+n\/a\b\s*(?:[
|
|
|
30
31
|
|
|
31
32
|
/** Files where a validator marker is honored — the docs humans actually read. */
|
|
32
33
|
function markerSourceFiles(projectDir) {
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
for (const f of readdirSync(canonicalDir)) {
|
|
38
|
-
if (f.toLowerCase().endsWith('.md')) files.push(join(canonicalDir, f));
|
|
39
|
-
}
|
|
40
|
-
} catch { /* ignore */ }
|
|
41
|
-
}
|
|
34
|
+
// Recursive: a marker declared in docs-canonical/01-architecture/FOO.md must
|
|
35
|
+
// be honored too — a flat read silently dropped the suppression and the
|
|
36
|
+
// validator ran anyway, which reads as "DocGuard ignored my n/a".
|
|
37
|
+
const files = listCanonicalDocs(projectDir).map(d => d.abs);
|
|
42
38
|
for (const root of ['AGENTS.md', 'README.md', 'CLAUDE.md']) {
|
|
43
39
|
const p = resolve(projectDir, root);
|
|
44
40
|
if (existsSync(p)) files.push(p);
|
|
@@ -20,9 +20,10 @@
|
|
|
20
20
|
* All findings confidence:'low' / soft — a nudge to right-size the doc.
|
|
21
21
|
*/
|
|
22
22
|
|
|
23
|
-
import {
|
|
24
|
-
import {
|
|
23
|
+
import { readFileSync } from 'node:fs';
|
|
24
|
+
import { basename } from 'node:path';
|
|
25
25
|
import { mkFinding, resultFromFindings } from '../findings.mjs';
|
|
26
|
+
import { listCanonicalDocs } from '../shared-ignore.mjs';
|
|
26
27
|
|
|
27
28
|
const HEADING = /^(#{1,6})\s+(.*)$/;
|
|
28
29
|
// A heading that documents an API/code unit — NOT a prose section heading.
|
|
@@ -94,18 +95,20 @@ export function validateApiDocSmells(projectDir, config = {}) {
|
|
|
94
95
|
const lazyMax = Number.isInteger(cfg.lazyMaxWords) ? cfg.lazyMaxWords : 6;
|
|
95
96
|
const bloatedMin = Number.isInteger(cfg.bloatedMinWords) ? cfg.bloatedMinWords : 300;
|
|
96
97
|
|
|
97
|
-
const
|
|
98
|
-
if (
|
|
98
|
+
const docFiles = listCanonicalDocs(projectDir); // recursive
|
|
99
|
+
if (docFiles.length === 0) {
|
|
99
100
|
return resultFromFindings([], { passed: 0, total: 0, applicable: false });
|
|
100
101
|
}
|
|
101
|
-
let docFiles = [];
|
|
102
|
-
try { docFiles = readdirSync(docsDir).filter(f => f.endsWith('.md')); } catch { /* skip */ }
|
|
103
102
|
|
|
104
103
|
const findings = [];
|
|
105
104
|
let unitCount = 0;
|
|
106
|
-
for (const
|
|
105
|
+
for (const doc of docFiles) {
|
|
107
106
|
let content;
|
|
108
|
-
try { content = readFileSync(
|
|
107
|
+
try { content = readFileSync(doc.abs, 'utf-8'); } catch { continue; }
|
|
108
|
+
// `f` stays the bare basename — matches this validator's pre-existing
|
|
109
|
+
// flat-tree message/location format exactly (both were already bare
|
|
110
|
+
// filenames, not project-relative paths, before this fix).
|
|
111
|
+
const f = basename(doc.rel);
|
|
109
112
|
const units = extractUnits(content);
|
|
110
113
|
for (const u of units) {
|
|
111
114
|
unitCount++;
|
|
@@ -33,11 +33,11 @@
|
|
|
33
33
|
* @req SC-K7-004 — code-fenced examples don't trigger false positives
|
|
34
34
|
*/
|
|
35
35
|
|
|
36
|
-
import { existsSync, readFileSync
|
|
37
|
-
import { resolve,
|
|
36
|
+
import { existsSync, readFileSync } from 'node:fs';
|
|
37
|
+
import { resolve, dirname, basename, relative } from 'node:path';
|
|
38
38
|
import { mkFinding, resultFromFindings } from '../findings.mjs';
|
|
39
39
|
import { resolveDocDirs } from '../shared.mjs';
|
|
40
|
-
import { walkFiles } from '../shared-ignore.mjs';
|
|
40
|
+
import { walkFiles, listCanonicalDocs } from '../shared-ignore.mjs';
|
|
41
41
|
|
|
42
42
|
/**
|
|
43
43
|
* Slugify a heading the way GitHub's markdown anchors work.
|
|
@@ -323,18 +323,7 @@ function resolveTarget(sourcePath, targetRel, projectDir) {
|
|
|
323
323
|
* validators — `docs-canonical/`, root tracking files, and AGENTS.md.
|
|
324
324
|
*/
|
|
325
325
|
function collectCanonicalDocs(projectDir) {
|
|
326
|
-
const docs =
|
|
327
|
-
const cdir = resolve(projectDir, 'docs-canonical');
|
|
328
|
-
if (existsSync(cdir)) {
|
|
329
|
-
try {
|
|
330
|
-
for (const f of readdirSync(cdir)) {
|
|
331
|
-
if (f.endsWith('.md')) {
|
|
332
|
-
const p = join(cdir, f);
|
|
333
|
-
if (statSync(p).isFile()) docs.push(p);
|
|
334
|
-
}
|
|
335
|
-
}
|
|
336
|
-
} catch {}
|
|
337
|
-
}
|
|
326
|
+
const docs = listCanonicalDocs(projectDir).map(d => d.abs); // recursive
|
|
338
327
|
// Standard root-level docs that are commonly cross-referenced. We index
|
|
339
328
|
// them so links like [CONTRIBUTING.md](CONTRIBUTING.md#some-section) can
|
|
340
329
|
// resolve. The list is conservative — adding everything would pull in
|
|
@@ -18,11 +18,12 @@
|
|
|
18
18
|
* or no code change carries removed tokens, so it stays silent off-CI.
|
|
19
19
|
*/
|
|
20
20
|
|
|
21
|
-
import { existsSync, readFileSync
|
|
21
|
+
import { existsSync, readFileSync } from 'node:fs';
|
|
22
22
|
import { resolve, basename } from 'node:path';
|
|
23
23
|
import { isGitRepo, getDiffText } from '../shared-git.mjs';
|
|
24
24
|
import { parseUnifiedDiff, removedTokens, tokenize, tokenOverlap } from '../shared-diff.mjs';
|
|
25
25
|
import { mkFinding, resultFromFindings } from '../findings.mjs';
|
|
26
|
+
import { listCanonicalDocs } from '../shared-ignore.mjs';
|
|
26
27
|
|
|
27
28
|
const CODE_EXTENSIONS = /\.(ts|tsx|js|jsx|mjs|cjs|py|go|rs|java|kt|rb|php|cs|swift|scala|dart)$/;
|
|
28
29
|
|
|
@@ -64,14 +65,9 @@ function indexDocs(projectDir) {
|
|
|
64
65
|
docs.set(name, { lines: content.split('\n'), tokens: tokenize(content) });
|
|
65
66
|
} catch { /* skip unreadable */ }
|
|
66
67
|
};
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
for (const f of readdirSync(docsDir)) {
|
|
71
|
-
if (f.endsWith('.md')) add(f, resolve(docsDir, f));
|
|
72
|
-
}
|
|
73
|
-
} catch { /* skip */ }
|
|
74
|
-
}
|
|
68
|
+
// Recursive. Keyed by bare basename — matches this validator's pre-existing
|
|
69
|
+
// flat-tree DSP001 message format ("ARCHITECTURE.md describes...").
|
|
70
|
+
for (const doc of listCanonicalDocs(projectDir)) add(basename(doc.rel), doc.abs);
|
|
75
71
|
// Agent-instruction files are documentation too — they routinely name code.
|
|
76
72
|
for (const agent of ['AGENTS.md', 'CLAUDE.md', 'GEMINI.md']) {
|
|
77
73
|
const p = resolve(projectDir, agent);
|
|
@@ -20,9 +20,10 @@
|
|
|
20
20
|
* built-ins reading files only.
|
|
21
21
|
*/
|
|
22
22
|
|
|
23
|
-
import { existsSync, readFileSync,
|
|
24
|
-
import { resolve, join, extname, relative } from 'node:path';
|
|
23
|
+
import { existsSync, readFileSync, statSync } from 'node:fs';
|
|
24
|
+
import { resolve, join, extname, relative, basename } from 'node:path';
|
|
25
25
|
import { mkFinding, resultFromFindings } from '../findings.mjs';
|
|
26
|
+
import { listCanonicalDocs } from '../shared-ignore.mjs';
|
|
26
27
|
|
|
27
28
|
// ──── Metric Thresholds ────
|
|
28
29
|
// These define "good" vs "warning" boundaries for each metric.
|
|
@@ -446,24 +447,12 @@ function getGradeLabel(grade) {
|
|
|
446
447
|
* Collect all markdown files in docs-canonical/ directory.
|
|
447
448
|
*/
|
|
448
449
|
function getCanonicalDocs(projectDir) {
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
const entries = readdirSync(docsDir);
|
|
456
|
-
for (const entry of entries) {
|
|
457
|
-
if (extname(entry).toLowerCase() === '.md') {
|
|
458
|
-
docs.push({
|
|
459
|
-
name: entry,
|
|
460
|
-
path: join(docsDir, entry),
|
|
461
|
-
});
|
|
462
|
-
}
|
|
463
|
-
}
|
|
464
|
-
} catch {
|
|
465
|
-
// Directory read failed silently
|
|
466
|
-
}
|
|
450
|
+
// Recursive. `name` stays the bare basename (not the full nested path) to
|
|
451
|
+
// match this function's pre-existing flat-tree display format exactly —
|
|
452
|
+
// messages read "ARCHITECTURE.md: ..." not "docs-canonical/x/ARCHITECTURE.md: ...".
|
|
453
|
+
// Same collision tradeoff as elsewhere: two nested docs sharing a basename
|
|
454
|
+
// are indistinguishable by name (pre-existing risk, not new).
|
|
455
|
+
const docs = listCanonicalDocs(projectDir).map(d => ({ name: basename(d.rel), path: d.abs }));
|
|
467
456
|
|
|
468
457
|
// Also check README.md at project root
|
|
469
458
|
const readmePath = resolve(projectDir, 'README.md');
|
|
@@ -21,7 +21,7 @@
|
|
|
21
21
|
import { existsSync, readFileSync, readdirSync, statSync } from 'node:fs';
|
|
22
22
|
import { resolve, join, relative, basename, extname } from 'node:path';
|
|
23
23
|
import { resolveSourceRoots } from '../shared-source.mjs';
|
|
24
|
-
import { shouldIgnore, walkFiles as sharedWalkFiles } from '../shared-ignore.mjs';
|
|
24
|
+
import { shouldIgnore, walkFiles as sharedWalkFiles, listCanonicalDocs } from '../shared-ignore.mjs';
|
|
25
25
|
import { detectIaC, hasInfrastructureHeading, buildIaCWarning } from '../scanners/iac.mjs';
|
|
26
26
|
import { mkFinding, resultFromFindings } from '../findings.mjs';
|
|
27
27
|
|
|
@@ -508,14 +508,7 @@ function collectDocContent(projectDir) {
|
|
|
508
508
|
if (existsSync(p)) docPaths.push(p);
|
|
509
509
|
}
|
|
510
510
|
|
|
511
|
-
const
|
|
512
|
-
if (existsSync(canonDir)) {
|
|
513
|
-
try {
|
|
514
|
-
for (const entry of readdirSync(canonDir)) {
|
|
515
|
-
if (entry.endsWith('.md')) docPaths.push(resolve(canonDir, entry));
|
|
516
|
-
}
|
|
517
|
-
} catch { /* skip */ }
|
|
518
|
-
}
|
|
511
|
+
for (const doc of listCanonicalDocs(projectDir)) docPaths.push(doc.abs); // recursive
|
|
519
512
|
|
|
520
513
|
const extDir = resolve(projectDir, 'extensions');
|
|
521
514
|
if (existsSync(extDir)) {
|
|
@@ -10,7 +10,7 @@
|
|
|
10
10
|
import { existsSync, readFileSync, readdirSync, statSync } from 'node:fs';
|
|
11
11
|
import { resolve, join, extname, basename } from 'node:path';
|
|
12
12
|
import { resolveSourceRoots } from '../shared-source.mjs';
|
|
13
|
-
import { relPosix, walkFiles as sharedWalkFiles } from '../shared-ignore.mjs';
|
|
13
|
+
import { relPosix, walkFiles as sharedWalkFiles, listCanonicalDocs } from '../shared-ignore.mjs';
|
|
14
14
|
import { mkFinding, resultFromFindings } from '../findings.mjs';
|
|
15
15
|
|
|
16
16
|
const IGNORE_DIRS = new Set([
|
|
@@ -71,15 +71,13 @@ export function validateDocsSync(projectDir, config) {
|
|
|
71
71
|
let passed = 0;
|
|
72
72
|
let total = 0;
|
|
73
73
|
|
|
74
|
-
// Load all canonical doc content for checking
|
|
75
|
-
|
|
74
|
+
// Load all canonical doc content for checking. Recursive — docs grouped in
|
|
75
|
+
// subfolders (docs-canonical/01-architecture/…) count as canonical too; a
|
|
76
|
+
// flat read made every service they documented look undocumented.
|
|
76
77
|
let canonicalContent = '';
|
|
77
|
-
|
|
78
|
+
for (const doc of listCanonicalDocs(projectDir)) {
|
|
78
79
|
try {
|
|
79
|
-
|
|
80
|
-
for (const f of files) {
|
|
81
|
-
canonicalContent += readFileSync(resolve(canonicalDir, f), 'utf-8') + '\n';
|
|
82
|
-
}
|
|
80
|
+
canonicalContent += readFileSync(doc.abs, 'utf-8') + '\n';
|
|
83
81
|
} catch {
|
|
84
82
|
// Skip if can't read
|
|
85
83
|
}
|
|
@@ -29,6 +29,7 @@ import { resolve, basename, join } from 'node:path';
|
|
|
29
29
|
import { buildMemoryPlan } from '../scanners/memory-plan.mjs';
|
|
30
30
|
import { getSection } from '../writers/sections.mjs';
|
|
31
31
|
import { mkFinding, resultFromFindings } from '../findings.mjs';
|
|
32
|
+
import { listCanonicalDocs } from '../shared-ignore.mjs';
|
|
32
33
|
|
|
33
34
|
/**
|
|
34
35
|
* v0.18-P1 fast-path: cheap pre-flight to detect whether ANY canonical doc
|
|
@@ -41,34 +42,36 @@ import { mkFinding, resultFromFindings } from '../findings.mjs';
|
|
|
41
42
|
*/
|
|
42
43
|
function _quickScan(projectDir) {
|
|
43
44
|
const out = { hasMarkers: false, hasDrafts: false };
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
45
|
+
// Recursive for docs-canonical/ — a marker in a nested doc must still be
|
|
46
|
+
// found, or this pre-flight false-negatives and skips the ENTIRE validator
|
|
47
|
+
// (buildMemoryPlan never even runs), silently disabling drift detection.
|
|
48
|
+
// projectDir itself stays a SHALLOW, non-recursive scan by design — it's
|
|
49
|
+
// here only to catch root-level README.md/AGENTS.md, not to walk the repo.
|
|
50
|
+
const candidateFiles = listCanonicalDocs(projectDir).map(d => d.abs);
|
|
51
|
+
try {
|
|
52
|
+
for (const entry of readdirSync(projectDir)) {
|
|
53
|
+
if (entry.endsWith('.md')) candidateFiles.push(join(projectDir, entry));
|
|
54
|
+
}
|
|
55
|
+
} catch { /* ignore */ }
|
|
56
|
+
|
|
48
57
|
// We only need a single match in any file to know the validator has work.
|
|
49
58
|
// Short-circuit aggressively: stop the moment we find either signal.
|
|
50
|
-
for (const
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
if (!out.hasMarkers && /<!--\s*docguard:section\s+[^>]*source=code/i.test(content)) {
|
|
65
|
-
out.hasMarkers = true;
|
|
66
|
-
}
|
|
67
|
-
if (!out.hasDrafts && /(?:^---\s*\n[\s\S]*?\bstatus:\s*draft\b[\s\S]*?\n---|<!--\s*status:\s*draft\s*-->)/im.test(content)) {
|
|
68
|
-
out.hasDrafts = true;
|
|
69
|
-
}
|
|
70
|
-
if (out.hasMarkers && out.hasDrafts) return out;
|
|
59
|
+
for (const full of candidateFiles) {
|
|
60
|
+
// Skip very large files quickly — for canonical docs, > 200 KB is unusual
|
|
61
|
+
// and almost certainly not the marker-heavy file we're looking for.
|
|
62
|
+
let stat;
|
|
63
|
+
try { stat = statSync(full); } catch { continue; }
|
|
64
|
+
if (!stat.isFile()) continue;
|
|
65
|
+
if (stat.size > 200_000) continue;
|
|
66
|
+
let content;
|
|
67
|
+
try { content = readFileSync(full, 'utf-8'); } catch { continue; }
|
|
68
|
+
if (!out.hasMarkers && /<!--\s*docguard:section\s+[^>]*source=code/i.test(content)) {
|
|
69
|
+
out.hasMarkers = true;
|
|
70
|
+
}
|
|
71
|
+
if (!out.hasDrafts && /(?:^---\s*\n[\s\S]*?\bstatus:\s*draft\b[\s\S]*?\n---|<!--\s*status:\s*draft\s*-->)/im.test(content)) {
|
|
72
|
+
out.hasDrafts = true;
|
|
71
73
|
}
|
|
74
|
+
if (out.hasMarkers && out.hasDrafts) return out;
|
|
72
75
|
}
|
|
73
76
|
return out;
|
|
74
77
|
}
|
|
@@ -41,7 +41,7 @@
|
|
|
41
41
|
import { existsSync, readFileSync, readdirSync } from 'node:fs';
|
|
42
42
|
import { resolve, extname, relative, basename } from 'node:path';
|
|
43
43
|
import { isGitRepo, lastCommitHash, symbolExistsAtRev } from '../shared-git.mjs';
|
|
44
|
-
import { walkFiles, isNonProductPath } from '../shared-ignore.mjs';
|
|
44
|
+
import { walkFiles, isNonProductPath, listCanonicalDocs } from '../shared-ignore.mjs';
|
|
45
45
|
import { readScannable } from '../shared-source.mjs';
|
|
46
46
|
import { resolveDocDirs } from '../shared.mjs';
|
|
47
47
|
import { mkFinding, resultFromFindings, lineSuppresses } from '../findings.mjs';
|
|
@@ -170,12 +170,9 @@ function indexDocs(projectDir) {
|
|
|
170
170
|
docs.push({ name, path: full, refs: extractRefs(content) });
|
|
171
171
|
} catch { /* skip */ }
|
|
172
172
|
};
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
for (const f of readdirSync(docsDir)) if (f.endsWith('.md')) push(f, resolve(docsDir, f));
|
|
177
|
-
} catch { /* skip */ }
|
|
178
|
-
}
|
|
173
|
+
// Recursive. `name` stays the bare basename — matches this validator's
|
|
174
|
+
// pre-existing flat-tree message/location format exactly.
|
|
175
|
+
for (const doc of listCanonicalDocs(projectDir)) push(basename(doc.rel), doc.abs);
|
|
179
176
|
for (const agent of ['AGENTS.md', 'CLAUDE.md', 'GEMINI.md']) {
|
|
180
177
|
const p = resolve(projectDir, agent);
|
|
181
178
|
if (existsSync(p)) push(agent, p);
|
|
@@ -16,7 +16,7 @@
|
|
|
16
16
|
import { existsSync, readFileSync, readdirSync, statSync } from 'node:fs';
|
|
17
17
|
import { resolve, join, relative, basename, extname } from 'node:path';
|
|
18
18
|
import { TRACE_MAP, TEST_PATTERNS, isTraceableSource } from '../shared-trace-patterns.mjs';
|
|
19
|
-
import { walkFiles as sharedWalkFiles } from '../shared-ignore.mjs';
|
|
19
|
+
import { walkFiles as sharedWalkFiles, listCanonicalDocs } from '../shared-ignore.mjs';
|
|
20
20
|
import { mkFinding, resultFromFindings } from '../findings.mjs';
|
|
21
21
|
import { tokenize } from '../shared-diff.mjs';
|
|
22
22
|
import { rankBySimilarity } from '../shared-ir.mjs';
|
|
@@ -231,21 +231,25 @@ export function validateTraceability(projectDir, config) {
|
|
|
231
231
|
}
|
|
232
232
|
|
|
233
233
|
// ── Detect orphaned files (exist but not required) ──
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
234
|
+
// Recursive — a nested stray doc must be visible too. TRACE_MAP/requiredDocs
|
|
235
|
+
// matching stays keyed by bare basename (TRACE_MAP's own keys are
|
|
236
|
+
// conventional top-level names); `location` uses the real path so the
|
|
237
|
+
// finding points at the actual file instead of a fabricated flat one — for
|
|
238
|
+
// a flat tree `doc.rel` already equals the old `docs-canonical/${docFile}`
|
|
239
|
+
// template exactly, so this is a no-op on the flat case.
|
|
240
|
+
for (const doc of listCanonicalDocs(projectDir)) {
|
|
241
|
+
const docFile = basename(doc.rel);
|
|
242
|
+
if (!requiredDocs.has(docFile) && TRACE_MAP[docFile]) {
|
|
243
|
+
findings.push(mkFinding({
|
|
244
|
+
code: 'TRC003',
|
|
245
|
+
validator: 'traceability',
|
|
246
|
+
severity: 'warn',
|
|
247
|
+
message: `${docFile} — file exists in docs-canonical/ but is not in your requiredFiles config. Consider deleting it or adding it to .docguard.json requiredFiles.canonical`,
|
|
248
|
+
location: doc.rel,
|
|
249
|
+
suggestion: { kind: 'review', text: 'Delete the doc, or add it to requiredFiles.canonical in .docguard.json so it gets validated' },
|
|
250
|
+
}));
|
|
247
251
|
}
|
|
248
|
-
}
|
|
252
|
+
}
|
|
249
253
|
|
|
250
254
|
// ── Part 2: Requirement ID Traceability (V-Model) ──
|
|
251
255
|
const reqResult = validateRequirementTraceability(projectDir, config, projectFiles);
|
|
@@ -427,17 +431,10 @@ function scanTestFilesForReferences(projectDir, projectFiles, patterns) {
|
|
|
427
431
|
function getRequirementDocPaths(projectDir, config) {
|
|
428
432
|
const paths = [];
|
|
429
433
|
|
|
430
|
-
// docs-canonical/ directory
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
for (const f of readdirSync(docsDir)) {
|
|
435
|
-
if (extname(f).toLowerCase() === '.md') {
|
|
436
|
-
paths.push(join(docsDir, f));
|
|
437
|
-
}
|
|
438
|
-
}
|
|
439
|
-
} catch { /* ignore */ }
|
|
440
|
-
}
|
|
434
|
+
// docs-canonical/ directory — recursive. Consumer re-derives the display
|
|
435
|
+
// path via relative(projectDir, docPath), so nested docs already report
|
|
436
|
+
// their real path with no further change needed there.
|
|
437
|
+
for (const doc of listCanonicalDocs(projectDir)) paths.push(doc.abs);
|
|
441
438
|
|
|
442
439
|
// Root-level docs
|
|
443
440
|
const rootDocs = ['REQUIREMENTS.md', 'spec.md', 'README.md'];
|
|
@@ -3,7 +3,7 @@ schema_version: "1.0"
|
|
|
3
3
|
extension:
|
|
4
4
|
id: "docguard"
|
|
5
5
|
name: "DocGuard — CDD Enforcement"
|
|
6
|
-
version: "0.
|
|
6
|
+
version: "0.34.0"
|
|
7
7
|
description: "Canonical-Driven Development enforcement as a true spec-kit extension. LLM-first design with automated validators, 4 AI behavior skills, spec-kit skill chaining, and workflow hooks. One pinned runtime dependency (@babel/parser); pure Node.js otherwise."
|
|
8
8
|
author: "Ricardo Accioly"
|
|
9
9
|
repository: "https://github.com/raccioly/docguard"
|
|
@@ -6,10 +6,10 @@ description: AI-driven documentation repair with structured research workflow, t
|
|
|
6
6
|
compatibility: Requires DocGuard CLI installed (npm i -g docguard-cli or npx docguard-cli)
|
|
7
7
|
metadata:
|
|
8
8
|
author: docguard
|
|
9
|
-
version: 0.
|
|
9
|
+
version: 0.34.0
|
|
10
10
|
source: extensions/spec-kit-docguard/skills/docguard-fix
|
|
11
11
|
---
|
|
12
|
-
<!-- docguard:version: 0.
|
|
12
|
+
<!-- docguard:version: 0.34.0 -->
|
|
13
13
|
|
|
14
14
|
# DocGuard Fix Skill
|
|
15
15
|
|
|
@@ -7,10 +7,10 @@ description: Run DocGuard guard validation against Canonical-Driven Development
|
|
|
7
7
|
compatibility: Requires DocGuard CLI installed (npm i -g docguard-cli or npx docguard-cli)
|
|
8
8
|
metadata:
|
|
9
9
|
author: docguard
|
|
10
|
-
version: 0.
|
|
10
|
+
version: 0.34.0
|
|
11
11
|
source: extensions/spec-kit-docguard/skills/docguard-guard
|
|
12
12
|
---
|
|
13
|
-
<!-- docguard:version: 0.
|
|
13
|
+
<!-- docguard:version: 0.34.0 -->
|
|
14
14
|
|
|
15
15
|
# DocGuard Guard Skill
|
|
16
16
|
|
|
@@ -6,10 +6,10 @@ description: Cross-document consistency analysis and quality assessment. Perform
|
|
|
6
6
|
compatibility: Requires DocGuard CLI installed (npm i -g docguard-cli or npx docguard-cli)
|
|
7
7
|
metadata:
|
|
8
8
|
author: docguard
|
|
9
|
-
version: 0.
|
|
9
|
+
version: 0.34.0
|
|
10
10
|
source: extensions/spec-kit-docguard/skills/docguard-review
|
|
11
11
|
---
|
|
12
|
-
<!-- docguard:version: 0.
|
|
12
|
+
<!-- docguard:version: 0.34.0 -->
|
|
13
13
|
|
|
14
14
|
# DocGuard Review Skill
|
|
15
15
|
|
|
@@ -6,10 +6,10 @@ description: CDD maturity assessment with category-aware improvement roadmap. Ru
|
|
|
6
6
|
compatibility: Requires DocGuard CLI installed (npm i -g docguard-cli or npx docguard-cli)
|
|
7
7
|
metadata:
|
|
8
8
|
author: docguard
|
|
9
|
-
version: 0.
|
|
9
|
+
version: 0.34.0
|
|
10
10
|
source: extensions/spec-kit-docguard/skills/docguard-score
|
|
11
11
|
---
|
|
12
|
-
<!-- docguard:version: 0.
|
|
12
|
+
<!-- docguard:version: 0.34.0 -->
|
|
13
13
|
|
|
14
14
|
# DocGuard Score Skill
|
|
15
15
|
|
|
@@ -4,10 +4,10 @@ description: Keep canonical documentation ALWAYS UP TO DATE. Refreshes code-trut
|
|
|
4
4
|
compatibility: Requires DocGuard CLI installed (npm i -g docguard-cli or npx docguard-cli)
|
|
5
5
|
metadata:
|
|
6
6
|
author: docguard
|
|
7
|
-
version: 0.
|
|
7
|
+
version: 0.34.0
|
|
8
8
|
source: extensions/spec-kit-docguard/skills/docguard-sync
|
|
9
9
|
---
|
|
10
|
-
<!-- docguard:version: 0.
|
|
10
|
+
<!-- docguard:version: 0.34.0 -->
|
|
11
11
|
|
|
12
12
|
# DocGuard Sync Skill
|
|
13
13
|
|
package/package.json
CHANGED