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
|
@@ -124,37 +124,40 @@ export function validateCanonicalSync(projectDir, config, guardResults) {
|
|
|
124
124
|
actualValidatorNames = guardResults.map(r => r.name).filter(Boolean);
|
|
125
125
|
}
|
|
126
126
|
|
|
127
|
-
// ── Read README
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
127
|
+
// ── Read surface docs (README.md + AGENTS.md) ──────────────────────
|
|
128
|
+
// Both carry "N commands / N validators" surface claims. Scanning only the
|
|
129
|
+
// README is why AGENTS.md's counts ("Commands (15 total)", "24 validators")
|
|
130
|
+
// drifted unchecked for releases — close that gap by checking both.
|
|
131
|
+
const surfaceFiles = ['README.md', 'AGENTS.md'];
|
|
132
|
+
let readme = '';
|
|
133
|
+
let readAny = false;
|
|
134
|
+
for (const f of surfaceFiles) {
|
|
135
|
+
const p = resolve(projectDir, f);
|
|
136
|
+
if (!existsSync(p)) continue;
|
|
137
|
+
try { readme += readFileSync(p, 'utf-8') + '\n'; readAny = true; } catch { /* skip unreadable */ }
|
|
133
138
|
}
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
try {
|
|
137
|
-
readme = readFileSync(readmePath, 'utf-8');
|
|
138
|
-
} catch {
|
|
139
|
-
result.warnings.push('canonical-sync: README.md unreadable');
|
|
139
|
+
if (!readAny) {
|
|
140
|
+
result.warnings.push('canonical-sync: no README.md or AGENTS.md found — cannot check surface claims');
|
|
140
141
|
result.total = 1;
|
|
141
142
|
return result;
|
|
142
143
|
}
|
|
143
144
|
|
|
144
145
|
// ── Check 1: "ships N commands" ─────────────────────────────────────
|
|
146
|
+
// Check ALL claims (matchAll), not just the first: with README + AGENTS.md
|
|
147
|
+
// concatenated, a correct claim in one file must not mask a stale claim in
|
|
148
|
+
// the other (the same first-match-masking trap the secret scanner had).
|
|
145
149
|
result.total++;
|
|
146
|
-
const
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
if (claimed === actualCommandCount) {
|
|
150
|
+
const cmdMatches = [...readme.matchAll(/ships\s+\*{0,2}(\d+)\s+commands?\*{0,2}/gi)];
|
|
151
|
+
if (cmdMatches.length > 0) {
|
|
152
|
+
const wrong = [...new Set(cmdMatches.map(m => Number(m[1])).filter(n => n !== actualCommandCount))];
|
|
153
|
+
if (wrong.length === 0) {
|
|
151
154
|
result.passed++;
|
|
152
155
|
} else {
|
|
153
156
|
const detail = actualUserFacingCount !== actualCommandFileCount
|
|
154
157
|
? `${actualCommandCount} user-facing commands in --help (${actualCommandFileCount} files including deprecation aliases)`
|
|
155
158
|
: `${actualCommandCount} command file(s)`;
|
|
156
159
|
result.warnings.push(
|
|
157
|
-
`README.md claims "ships ${
|
|
160
|
+
`A surface doc (README.md/AGENTS.md) claims ${wrong.map(n => `"ships ${n} commands"`).join(' / ')} but the real count is ${detail}. Update it.`
|
|
158
161
|
);
|
|
159
162
|
}
|
|
160
163
|
} else {
|
|
@@ -177,7 +180,7 @@ export function validateCanonicalSync(projectDir, config, guardResults) {
|
|
|
177
180
|
} else {
|
|
178
181
|
const uniqueWrong = [...new Set(wrongClaims)];
|
|
179
182
|
result.warnings.push(
|
|
180
|
-
`README.md claims ${uniqueWrong.map(n => `"${n} validators"`).join(' / ')} but guard reports ${actualValidatorCount}. Update
|
|
183
|
+
`A surface doc (README.md/AGENTS.md) claims ${uniqueWrong.map(n => `"${n} validators"`).join(' / ')} but guard reports ${actualValidatorCount}. Update it.`
|
|
181
184
|
);
|
|
182
185
|
}
|
|
183
186
|
} else {
|
|
@@ -16,14 +16,12 @@
|
|
|
16
16
|
* cells as "long sentences"), this version extracts ONLY actual prose
|
|
17
17
|
* paragraphs. Docs that are mostly tables/code skip readability scoring.
|
|
18
18
|
*
|
|
19
|
-
*
|
|
20
|
-
*
|
|
21
|
-
* Zero NPM runtime dependencies — pure Node.js built-ins only.
|
|
19
|
+
* Zero NPM runtime dependencies, and zero process execution — pure Node.js
|
|
20
|
+
* built-ins reading files only.
|
|
22
21
|
*/
|
|
23
22
|
|
|
24
23
|
import { existsSync, readFileSync, readdirSync, statSync } from 'node:fs';
|
|
25
24
|
import { resolve, join, extname } from 'node:path';
|
|
26
|
-
import { execSync, execFileSync } from 'node:child_process';
|
|
27
25
|
|
|
28
26
|
// ──── Metric Thresholds ────
|
|
29
27
|
// These define "good" vs "warning" boundaries for each metric.
|
|
@@ -441,40 +439,6 @@ function getGradeLabel(grade) {
|
|
|
441
439
|
return 'graduate+';
|
|
442
440
|
}
|
|
443
441
|
|
|
444
|
-
// ──── Understanding CLI Integration ────
|
|
445
|
-
|
|
446
|
-
/**
|
|
447
|
-
* Check if the `understanding` CLI is available on the system.
|
|
448
|
-
*/
|
|
449
|
-
function findUnderstandingCli() {
|
|
450
|
-
try {
|
|
451
|
-
const cmd = process.platform === 'win32' ? 'where understanding' : 'which understanding';
|
|
452
|
-
const result = execSync(`${cmd} 2>/dev/null`, {
|
|
453
|
-
encoding: 'utf-8',
|
|
454
|
-
timeout: 3000,
|
|
455
|
-
}).trim();
|
|
456
|
-
return result || null;
|
|
457
|
-
} catch {
|
|
458
|
-
return null;
|
|
459
|
-
}
|
|
460
|
-
}
|
|
461
|
-
|
|
462
|
-
/**
|
|
463
|
-
* Run the `understanding` CLI on a file and parse results.
|
|
464
|
-
*/
|
|
465
|
-
function runUnderstandingDeepScan(filePath) {
|
|
466
|
-
try {
|
|
467
|
-
const result = execFileSync('understanding', ['analyze', filePath, '--enhanced', '--json'], {
|
|
468
|
-
encoding: 'utf-8',
|
|
469
|
-
timeout: 10000,
|
|
470
|
-
stdio: ['pipe', 'pipe', 'ignore'],
|
|
471
|
-
});
|
|
472
|
-
return JSON.parse(result);
|
|
473
|
-
} catch {
|
|
474
|
-
return null;
|
|
475
|
-
}
|
|
476
|
-
}
|
|
477
|
-
|
|
478
442
|
// ──── Main Validator ────
|
|
479
443
|
|
|
480
444
|
/**
|
|
@@ -587,10 +551,6 @@ export function validateDocQuality(projectDir, config) {
|
|
|
587
551
|
return results;
|
|
588
552
|
}
|
|
589
553
|
|
|
590
|
-
// Check for optional understanding CLI
|
|
591
|
-
const understandingCli = findUnderstandingCli();
|
|
592
|
-
const useDeepScan = config.docQuality?.deepScan !== false && understandingCli;
|
|
593
|
-
|
|
594
554
|
for (const doc of docs) {
|
|
595
555
|
if (!existsSync(doc.path)) continue;
|
|
596
556
|
|
|
@@ -35,8 +35,20 @@ const COMMON_DOTFILES = new Set([
|
|
|
35
35
|
'.env', '.env.local', '.env.development', '.env.production',
|
|
36
36
|
'.vscode', '.idea', '.github', '.husky',
|
|
37
37
|
'.babelrc', '.browserslistrc', '.stylelintrc',
|
|
38
|
+
'.dockerignore', '.python-version', '.tool-versions', '.ruby-version',
|
|
39
|
+
'.gitkeep', '.keep',
|
|
38
40
|
]);
|
|
39
41
|
|
|
42
|
+
// Generated tool artifacts (caches, coverage data, lock-data) that land at the
|
|
43
|
+
// repo root but are NOT configuration a human authors or documents. Treating
|
|
44
|
+
// them as "undocumented config files" is a false positive (field test:
|
|
45
|
+
// quick-recon-tool flagged pytest's `.coverage` SQLite data file). Matched by
|
|
46
|
+
// exact name OR prefix (`.coverage.<host>.<pid>` is coverage.py's parallel form).
|
|
47
|
+
const GENERATED_DOTFILE_PREFIXES = ['.coverage', '.eslintcache', '.stylelintcache', '.tsbuildinfo'];
|
|
48
|
+
function isGeneratedArtifact(name) {
|
|
49
|
+
return GENERATED_DOTFILE_PREFIXES.some(p => name === p || name.startsWith(p + '.'));
|
|
50
|
+
}
|
|
51
|
+
|
|
40
52
|
/**
|
|
41
53
|
* Validate that code artifacts are referenced in documentation.
|
|
42
54
|
* @param {string} projectDir - Project root directory
|
|
@@ -125,6 +137,7 @@ function checkConfigFiles(projectDir, allDocContent, config = {}) {
|
|
|
125
137
|
|
|
126
138
|
if (!isDotFile && !isProjectConfig) continue;
|
|
127
139
|
if (COMMON_DOTFILES.has(entry)) continue;
|
|
140
|
+
if (isGeneratedArtifact(entry)) continue;
|
|
128
141
|
if (entry === 'tsconfig.json' || entry === 'package-lock.json') continue;
|
|
129
142
|
|
|
130
143
|
// Skip directories — this check is for configuration FILES, not dirs.
|
|
@@ -5,6 +5,7 @@
|
|
|
5
5
|
import { existsSync, readFileSync, readdirSync, statSync } from 'node:fs';
|
|
6
6
|
import { resolve, join, extname, basename } from 'node:path';
|
|
7
7
|
import { resolveSourceRoots } from '../shared-source.mjs';
|
|
8
|
+
import { relPosix } from '../shared-ignore.mjs';
|
|
8
9
|
|
|
9
10
|
const IGNORE_DIRS = new Set([
|
|
10
11
|
'node_modules', '.git', '.next', '.nuxt', 'dist', 'build', 'out',
|
|
@@ -103,7 +104,7 @@ export function validateDocsSync(projectDir, config) {
|
|
|
103
104
|
const ext = extname(file);
|
|
104
105
|
if (!['.ts', '.tsx', '.js', '.jsx', '.mjs', '.py', '.java', '.go'].includes(ext)) continue;
|
|
105
106
|
|
|
106
|
-
const relPath =
|
|
107
|
+
const relPath = relPosix(projectDir, file);
|
|
107
108
|
if (isTestFile(relPath)) continue;
|
|
108
109
|
if (!isValidRouteFile(relPath)) continue;
|
|
109
110
|
// N-1: skip files outside the --changed-only scope.
|
|
@@ -129,7 +130,7 @@ export function validateDocsSync(projectDir, config) {
|
|
|
129
130
|
const ext = extname(file);
|
|
130
131
|
if (!['.ts', '.tsx', '.js', '.jsx', '.mjs', '.py', '.java', '.go'].includes(ext)) continue;
|
|
131
132
|
|
|
132
|
-
const relPath =
|
|
133
|
+
const relPath = relPosix(projectDir, file);
|
|
133
134
|
if (isTestFile(relPath)) continue;
|
|
134
135
|
// N-1: skip files outside the --changed-only scope.
|
|
135
136
|
if (!inScope(relPath)) continue;
|
|
@@ -175,7 +176,7 @@ export function validateDocsSync(projectDir, config) {
|
|
|
175
176
|
const ext = extname(file);
|
|
176
177
|
if (!['.ts', '.tsx', '.js', '.jsx', '.mjs'].includes(ext)) continue;
|
|
177
178
|
|
|
178
|
-
const relPathForFilter =
|
|
179
|
+
const relPathForFilter = relPosix(projectDir, file);
|
|
179
180
|
if (isTestFile(relPathForFilter)) continue;
|
|
180
181
|
if (!isValidRouteFile(relPathForFilter)) continue;
|
|
181
182
|
|
package/cli/validators/drift.mjs
CHANGED
|
@@ -4,6 +4,7 @@
|
|
|
4
4
|
|
|
5
5
|
import { existsSync, readFileSync, readdirSync, statSync } from 'node:fs';
|
|
6
6
|
import { resolve, join, extname } from 'node:path';
|
|
7
|
+
import { relPosix } from '../shared-ignore.mjs';
|
|
7
8
|
|
|
8
9
|
const CODE_EXTENSIONS = new Set([
|
|
9
10
|
'.js', '.mjs', '.cjs', '.ts', '.tsx', '.jsx',
|
|
@@ -31,7 +32,7 @@ export function validateDrift(projectDir, config) {
|
|
|
31
32
|
// string fixtures (e.g. `'// DRIFT: a-drift\n'`). Reading the test as
|
|
32
33
|
// source would treat the string as a real drift comment. Skip test
|
|
33
34
|
// files unless the user opts in — same pattern TODO-Tracking uses.
|
|
34
|
-
const rel =
|
|
35
|
+
const rel = relPosix(projectDir, filePath);
|
|
35
36
|
const includeTests = config?.drift?.includeTestFiles === true;
|
|
36
37
|
if (!includeTests && /(^|\/)(__tests__|tests?|spec)\/|\.(test|spec)\.[^.]+$/.test(rel)) {
|
|
37
38
|
return;
|
|
@@ -44,7 +45,7 @@ export function validateDrift(projectDir, config) {
|
|
|
44
45
|
const match = line.match(/(?:\/\/|#|\/\*|\-\-)\s*DRIFT:\s*(.+)/i);
|
|
45
46
|
if (match) {
|
|
46
47
|
driftComments.push({
|
|
47
|
-
file:
|
|
48
|
+
file: relPosix(projectDir, filePath),
|
|
48
49
|
line: i + 1,
|
|
49
50
|
comment: match[1].trim(),
|
|
50
51
|
});
|
|
@@ -42,13 +42,20 @@ const IGNORE_DIRS = new Set([
|
|
|
42
42
|
* (e.g., the reviewer read the file, confirmed it still matches reality, and
|
|
43
43
|
* stamped the header without touching content, so there is no commit to find).
|
|
44
44
|
*/
|
|
45
|
-
function readLastReviewedDate(absPath) {
|
|
45
|
+
export function readLastReviewedDate(absPath) {
|
|
46
46
|
try {
|
|
47
47
|
const content = readFileSync(absPath, 'utf-8');
|
|
48
48
|
const m = content.match(/<!--\s*docguard:last-reviewed\s+(\d{4}-\d{2}-\d{2})\s*-->/);
|
|
49
49
|
if (!m) return null;
|
|
50
50
|
const d = new Date(m[1] + 'T00:00:00Z');
|
|
51
|
-
|
|
51
|
+
if (isNaN(d.getTime())) return null;
|
|
52
|
+
// Reject future-dated headers. A typo'd or copy-pasted future date (e.g.
|
|
53
|
+
// 2030-01-01) would otherwise make a genuinely stale doc look "fresh"
|
|
54
|
+
// forever — its age goes negative and "commits since" rounds to zero. A
|
|
55
|
+
// review can't legitimately have happened in the future, so we ignore the
|
|
56
|
+
// header and fall back to the real git date. (1-day grace for timezones.)
|
|
57
|
+
if (d.getTime() > Date.now() + 24 * 60 * 60 * 1000) return null;
|
|
58
|
+
return d;
|
|
52
59
|
} catch {
|
|
53
60
|
return null;
|
|
54
61
|
}
|
|
@@ -90,11 +97,15 @@ function getLastGitDate(filePath, dir) {
|
|
|
90
97
|
function getCodeCommitsSince(date, dir) {
|
|
91
98
|
try {
|
|
92
99
|
const isoDate = date.toISOString();
|
|
93
|
-
|
|
94
|
-
|
|
100
|
+
// execFileSync (argv array) + count in JS — no shell `| wc -l` pipe, which
|
|
101
|
+
// isn't portable (Windows) and made the count depend on an external binary.
|
|
102
|
+
const out = execFileSync(
|
|
103
|
+
'git',
|
|
104
|
+
['log', `--since=${isoDate}`, '--oneline', '--diff-filter=M', '--',
|
|
105
|
+
'*.js', '*.mjs', '*.ts', '*.tsx', '*.py', '*.java', '*.go'],
|
|
95
106
|
{ cwd: dir, encoding: 'utf-8', stdio: ['pipe', 'pipe', 'pipe'] }
|
|
96
107
|
).trim();
|
|
97
|
-
return
|
|
108
|
+
return out ? out.split('\n').filter(Boolean).length : 0;
|
|
98
109
|
} catch {
|
|
99
110
|
return 0;
|
|
100
111
|
}
|
|
@@ -132,11 +143,13 @@ function getTotalCommits(dir) {
|
|
|
132
143
|
*/
|
|
133
144
|
function getRecentCodeCommits(dir, count = 5) {
|
|
134
145
|
try {
|
|
135
|
-
const
|
|
136
|
-
|
|
146
|
+
const out = execFileSync(
|
|
147
|
+
'git',
|
|
148
|
+
['log', `-${count}`, '--format=%h %aI %s', '--',
|
|
149
|
+
'*.js', '*.mjs', '*.ts', '*.tsx', '*.py', '*.java'],
|
|
137
150
|
{ cwd: dir, encoding: 'utf-8', stdio: ['pipe', 'pipe', 'pipe'] }
|
|
138
151
|
).trim();
|
|
139
|
-
return
|
|
152
|
+
return out ? out.split('\n') : [];
|
|
140
153
|
} catch {
|
|
141
154
|
return [];
|
|
142
155
|
}
|
|
@@ -208,8 +221,18 @@ export function validateFreshness(dir, config) {
|
|
|
208
221
|
continue;
|
|
209
222
|
}
|
|
210
223
|
|
|
211
|
-
// Check how many code commits happened since this doc was last updated
|
|
212
|
-
|
|
224
|
+
// Check how many code commits happened since this doc was last updated.
|
|
225
|
+
// A `last-reviewed` HEADER is day-granular and signals "I reviewed this ON
|
|
226
|
+
// this day" — so it covers commits made that same day. Counting from
|
|
227
|
+
// midnight would flag a doc as stale on the very day it was genuinely
|
|
228
|
+
// reviewed whenever >10 code commits also landed that day (a heavy-dev day),
|
|
229
|
+
// undermining the explicit-review signal this validator otherwise honors.
|
|
230
|
+
// Advance a header date to end-of-day so only commits on LATER days count.
|
|
231
|
+
// Git fallback dates are real timestamps and are used as-is.
|
|
232
|
+
const sinceDate = reviewedDate
|
|
233
|
+
? new Date(reviewedDate.getTime() + 24 * 60 * 60 * 1000 - 1000)
|
|
234
|
+
: docDate;
|
|
235
|
+
const codeCommitsSince = getCodeCommitsSince(sinceDate, dir);
|
|
213
236
|
|
|
214
237
|
if (codeCommitsSince >= WARNING_THRESHOLD_COMMITS) {
|
|
215
238
|
results.push({
|
|
@@ -263,13 +286,22 @@ export function validateFreshness(dir, config) {
|
|
|
263
286
|
const driftPath = resolve(dir, config.requiredFiles?.driftLog || 'DRIFT-LOG.md');
|
|
264
287
|
if (existsSync(driftPath)) {
|
|
265
288
|
const driftDate = getLastGitDate(config.requiredFiles?.driftLog || 'DRIFT-LOG.md', dir);
|
|
266
|
-
// Check for recent DRIFT comments
|
|
289
|
+
// Check for recent DRIFT comments ADDED to code. The old approach piped
|
|
290
|
+
// `git log --all -p | grep -c DRIFT:`, which counted DRIFT: on removed
|
|
291
|
+
// lines, unchanged context, and every branch (`--all`) — wildly inflating
|
|
292
|
+
// the count and depending on `grep`. Here we read the last-5-commits diff
|
|
293
|
+
// for the current branch and count only ADDED lines (`+`, not the `+++`
|
|
294
|
+
// file header) that introduce a DRIFT comment.
|
|
267
295
|
try {
|
|
268
|
-
const
|
|
269
|
-
|
|
296
|
+
const diff = execFileSync(
|
|
297
|
+
'git',
|
|
298
|
+
['log', '-5', '-p', '--', '*.js', '*.mjs', '*.ts', '*.tsx', '*.py'],
|
|
270
299
|
{ cwd: dir, encoding: 'utf-8', stdio: ['pipe', 'pipe', 'pipe'] }
|
|
271
|
-
)
|
|
272
|
-
const driftCount =
|
|
300
|
+
);
|
|
301
|
+
const driftCount = diff
|
|
302
|
+
.split('\n')
|
|
303
|
+
.filter(l => /^\+(?!\+\+)/.test(l) && l.includes('DRIFT:'))
|
|
304
|
+
.length;
|
|
273
305
|
if (driftCount > 0 && driftDate) {
|
|
274
306
|
const codeCommitsSince = getCodeCommitsSince(driftDate, dir);
|
|
275
307
|
if (codeCommitsSince > 3) {
|
|
@@ -173,6 +173,19 @@ export function validateGeneratedStaleness(projectDir, config = {}) {
|
|
|
173
173
|
if (!onDisk) continue;
|
|
174
174
|
|
|
175
175
|
result.total++;
|
|
176
|
+
|
|
177
|
+
// B5 (field report): a `pinned` attribute on the section's open marker
|
|
178
|
+
// <!-- docguard:section id=… source=code pinned="reason" -->
|
|
179
|
+
// marks the section as intentionally hand-maintained — the scanner
|
|
180
|
+
// mislabeled this surface (e.g. a scanner/tool repo whose source contains
|
|
181
|
+
// framework-like strings; see F1). Exempt it from staleness and count it
|
|
182
|
+
// as a pass, mirroring the docguard:quality opt-out marker. This is the
|
|
183
|
+
// escape hatch from the "stale forever / sync --write reverts it" trap.
|
|
184
|
+
if (onDisk.attrs?.pinned !== undefined) {
|
|
185
|
+
result.passed++;
|
|
186
|
+
continue;
|
|
187
|
+
}
|
|
188
|
+
|
|
176
189
|
const expected = String(sec.body || '').trim();
|
|
177
190
|
const actual = String(onDisk.body || '').trim();
|
|
178
191
|
|
|
@@ -194,7 +207,9 @@ export function validateGeneratedStaleness(projectDir, config = {}) {
|
|
|
194
207
|
: '';
|
|
195
208
|
|
|
196
209
|
result.warnings.push(
|
|
197
|
-
`${basename(doc.path)} → section "${sec.id}" is stale${hint}. Run \`docguard sync --write\` to refresh code-truth sections
|
|
210
|
+
`${basename(doc.path)} → section "${sec.id}" is stale${hint}. Run \`docguard sync --write\` to refresh code-truth sections. ` +
|
|
211
|
+
`If this section is intentionally hand-maintained (the scanner mislabeled it), pin it: ` +
|
|
212
|
+
`add \`pinned="reason"\` to its \`<!-- docguard:section id=${sec.id} … -->\` marker.`
|
|
198
213
|
);
|
|
199
214
|
// v0.14-P3: structured fix so `docguard fix --write` can fix this
|
|
200
215
|
// mechanically (no AI needed — scanner already produced the right body).
|
|
@@ -32,21 +32,28 @@ export function validateMetadataSync(projectDir, config) {
|
|
|
32
32
|
// workspace manifest with no version, fall back to a source-root package.
|
|
33
33
|
const pkgPath = resolve(projectDir, 'package.json');
|
|
34
34
|
let currentVersion = null;
|
|
35
|
+
let currentName = null;
|
|
35
36
|
if (existsSync(pkgPath)) {
|
|
36
|
-
try {
|
|
37
|
+
try {
|
|
38
|
+
const pj = JSON.parse(readFileSync(pkgPath, 'utf-8'));
|
|
39
|
+
currentVersion = pj.version || null;
|
|
40
|
+
currentName = pj.name || null;
|
|
41
|
+
} catch { /* ignore */ }
|
|
37
42
|
}
|
|
38
43
|
if (!currentVersion) {
|
|
39
44
|
for (const { pkg } of collectPackageJsons(projectDir, config)) {
|
|
40
|
-
if (pkg.version) { currentVersion = pkg.version; break; }
|
|
45
|
+
if (pkg.version) { currentVersion = pkg.version; currentName = currentName || pkg.name || null; break; }
|
|
41
46
|
}
|
|
42
47
|
}
|
|
43
48
|
if (!currentVersion) return { errors: [], warnings, passed: 0, total: 0 };
|
|
44
49
|
|
|
45
|
-
// Parse into components for smart comparison
|
|
50
|
+
// Parse into components for smart comparison. `|| 0` guards two-part versions
|
|
51
|
+
// (e.g. "1.2"): without it vParts[2] is undefined → parseInt → NaN, and every
|
|
52
|
+
// `fPatch < patch` comparison silently becomes false, disabling the check.
|
|
46
53
|
const vParts = currentVersion.split('.');
|
|
47
|
-
const major = parseInt(vParts[0], 10);
|
|
48
|
-
const minor = parseInt(vParts[1], 10);
|
|
49
|
-
const patch = parseInt(vParts[2], 10);
|
|
54
|
+
const major = parseInt(vParts[0], 10) || 0;
|
|
55
|
+
const minor = parseInt(vParts[1], 10) || 0;
|
|
56
|
+
const patch = parseInt(vParts[2], 10) || 0;
|
|
50
57
|
|
|
51
58
|
// ── Check 1: extension.yml version sync ──
|
|
52
59
|
const extFiles = findExtensionYmls(projectDir);
|
|
@@ -72,8 +79,6 @@ export function validateMetadataSync(projectDir, config) {
|
|
|
72
79
|
// ── Check 2: Version references in markdown files ──
|
|
73
80
|
const isIgnored = loadIgnorePatterns(projectDir);
|
|
74
81
|
const mdFiles = findMarkdownFiles(projectDir);
|
|
75
|
-
// Version patterns to find: v0.7.2, @0.7.2, /v0.7.2/, docguard-cli@0.7.2
|
|
76
|
-
const versionRegex = /(?:v|@|\/v?)(\d+\.\d+\.\d+)/g;
|
|
77
82
|
|
|
78
83
|
for (const mdFile of mdFiles) {
|
|
79
84
|
const relPath = relative(projectDir, mdFile);
|
|
@@ -93,13 +98,18 @@ export function validateMetadataSync(projectDir, config) {
|
|
|
93
98
|
// - Badge URLs
|
|
94
99
|
// NOT in prose text like "In v0.2.0 we added..." or roadmap discussions
|
|
95
100
|
const actionablePatterns = [
|
|
96
|
-
// URLs with version: /v0.7.2/, /tags/v0.7.2,
|
|
101
|
+
// URLs with version: /v0.7.2/, /tags/v0.7.2, /releases/0.7.2
|
|
97
102
|
/(?:archive|tags|releases|download)\/v?(\d+\.\d+\.\d+)/g,
|
|
98
|
-
// npm install/npx commands: docguard-cli@0.7.2
|
|
99
|
-
/@(\d+\.\d+\.\d+)/g,
|
|
100
103
|
// YAML-style: version: "0.7.2" or version: 0.7.2
|
|
101
104
|
/version:\s*["']?(\d+\.\d+\.\d+)["']?/g,
|
|
102
105
|
];
|
|
106
|
+
// npm/npx refs to THIS package only (e.g. docguard-cli@0.7.2), anchored to
|
|
107
|
+
// the package name. A bare /@(\d+\.\d+\.\d+)/ used to over-match unrelated
|
|
108
|
+
// versions — node@18.2.0, @types/node@1.2.3, or "@1.2.3" in prose.
|
|
109
|
+
if (currentName) {
|
|
110
|
+
const escaped = currentName.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
111
|
+
actionablePatterns.push(new RegExp(`${escaped}@v?(\\d+\\.\\d+\\.\\d+)`, 'g'));
|
|
112
|
+
}
|
|
103
113
|
|
|
104
114
|
for (const pattern of actionablePatterns) {
|
|
105
115
|
pattern.lastIndex = 0;
|
|
@@ -57,7 +57,7 @@ export function validateMetricsConsistency(projectDir, config, guardResults) {
|
|
|
57
57
|
|
|
58
58
|
// ── Scan markdown files for hardcoded numbers ──
|
|
59
59
|
const isIgnored = loadIgnorePatterns(projectDir);
|
|
60
|
-
const mdFiles = findMarkdownFiles(projectDir);
|
|
60
|
+
const mdFiles = findMarkdownFiles(projectDir, config);
|
|
61
61
|
// Patterns must match standalone number references, not ratio-style "8/8 checks"
|
|
62
62
|
const patterns = [
|
|
63
63
|
{ key: 'checks', regex: /(?<!\d\/)\b(\d{2,})\s+(?:automated\s+)?checks?\b/gi, label: 'checks' },
|
|
@@ -151,24 +151,52 @@ function findTestFiles(dir) {
|
|
|
151
151
|
return tests;
|
|
152
152
|
}
|
|
153
153
|
|
|
154
|
-
|
|
154
|
+
// DocGuard's OWN installed slash-command docs (commands/docguard.*.md, and the
|
|
155
|
+
// .agent/commands/ variant). These are tool-managed, not the project's docs —
|
|
156
|
+
// scanning them flags DocGuard's own (sometimes stale) shipped "N validators"
|
|
157
|
+
// count as the USER's drift, which they can't meaningfully act on. (.agent/ and
|
|
158
|
+
// .specify/ are already dot-skipped by walkFiles; this catches the legacy ROOT
|
|
159
|
+
// commands/ install location. A user's own commands/<name>.md is NOT excluded.)
|
|
160
|
+
const DOCGUARD_OWN_DOC_RE = /[\\/](?:\.agent[\\/])?commands[\\/]docguard\.[a-z-]+\.md$/i;
|
|
161
|
+
|
|
162
|
+
function findMarkdownFiles(dir, config = {}) {
|
|
155
163
|
const seen = new Set();
|
|
156
164
|
const mdFiles = [];
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
165
|
+
const add = (f) => {
|
|
166
|
+
if (f.endsWith('.md') && !seen.has(f) && !DOCGUARD_OWN_DOC_RE.test(f)) {
|
|
167
|
+
seen.add(f);
|
|
168
|
+
mdFiles.push(f);
|
|
169
|
+
}
|
|
170
|
+
};
|
|
171
|
+
|
|
172
|
+
// Root LEVEL ONLY (non-recursive): README and other top-level docs. A
|
|
173
|
+
// "N validators / N checks" claim that refers to DocGuard lives in the README
|
|
174
|
+
// or the canonical docs — not five levels deep under security/ or backend/.
|
|
175
|
+
// The old code recursively walked the WHOLE repo from the root, so it swept in
|
|
176
|
+
// OpenWolf session archives (security/wolf-archive/**/memory.md) and vendored
|
|
177
|
+
// toolkit READMEs whose unrelated "N checks" prose was then reported as the
|
|
178
|
+
// USER's drift (field test: wu-whatsappinbox, ~39 false warnings the author
|
|
179
|
+
// could not act on). Scoping to the docs DocGuard actually governs fixes it.
|
|
180
|
+
try {
|
|
181
|
+
for (const entry of readdirSync(dir)) {
|
|
182
|
+
const full = join(dir, entry);
|
|
183
|
+
try { if (statSync(full).isFile()) add(full); } catch { /* unreadable entry */ }
|
|
184
|
+
}
|
|
185
|
+
} catch { /* unreadable root */ }
|
|
186
|
+
|
|
187
|
+
// Configured canonical docs (wherever they live), plus the conventional
|
|
188
|
+
// doc homes (docs/, docs-canonical/, extensions/) — scanned in full
|
|
189
|
+
// (recursive). Code/tooling dirs (security/, backend/, src/, …) are NOT doc
|
|
190
|
+
// homes and are deliberately excluded.
|
|
191
|
+
const canonical = config && config.requiredFiles && Array.isArray(config.requiredFiles.canonical)
|
|
192
|
+
? config.requiredFiles.canonical : [];
|
|
193
|
+
for (const rel of canonical) {
|
|
194
|
+
const full = resolve(dir, rel);
|
|
195
|
+
if (existsSync(full)) { try { if (statSync(full).isFile()) add(full); } catch { /* skip */ } }
|
|
196
|
+
}
|
|
197
|
+
for (const sub of ['docs', 'docs-canonical', 'extensions']) {
|
|
198
|
+
const searchDir = resolve(dir, sub);
|
|
199
|
+
if (existsSync(searchDir)) walkFiles(searchDir, add);
|
|
172
200
|
}
|
|
173
201
|
|
|
174
202
|
return mdFiles;
|
|
@@ -7,7 +7,7 @@
|
|
|
7
7
|
|
|
8
8
|
import { existsSync, readFileSync, readdirSync, statSync } from 'node:fs';
|
|
9
9
|
import { resolve, join, extname } from 'node:path';
|
|
10
|
-
import { shouldIgnore } from '../shared-ignore.mjs';
|
|
10
|
+
import { shouldIgnore, relPosix } from '../shared-ignore.mjs';
|
|
11
11
|
|
|
12
12
|
const CODE_EXTENSIONS = new Set([
|
|
13
13
|
'.js', '.mjs', '.cjs', '.ts', '.tsx', '.jsx',
|
|
@@ -69,7 +69,7 @@ export function validateSecurity(projectDir, config) {
|
|
|
69
69
|
// Skip .env.example — it should have placeholder values
|
|
70
70
|
if (filePath.endsWith('.env.example')) return;
|
|
71
71
|
|
|
72
|
-
const relPath =
|
|
72
|
+
const relPath = relPosix(projectDir, filePath);
|
|
73
73
|
|
|
74
74
|
// Apply config ignore patterns (securityIgnore + global ignore)
|
|
75
75
|
if (shouldIgnore(relPath, config, 'securityIgnore')) return;
|
|
@@ -80,8 +80,12 @@ export function validateSecurity(projectDir, config) {
|
|
|
80
80
|
|
|
81
81
|
for (const { pattern, label } of SECRET_PATTERNS) {
|
|
82
82
|
pattern.lastIndex = 0;
|
|
83
|
-
|
|
84
|
-
|
|
83
|
+
let match;
|
|
84
|
+
// Scan ALL matches for this pattern, not just the first. A real secret
|
|
85
|
+
// can sit BELOW a safe placeholder of the same kind (e.g. an
|
|
86
|
+
// `apiKey = "EXAMPLE..."` line above a hardcoded real key). Bailing on
|
|
87
|
+
// the first match — as this loop used to — silently missed the real one.
|
|
88
|
+
while ((match = pattern.exec(content)) !== null) {
|
|
85
89
|
// Lazily initialize lines only when a match is found
|
|
86
90
|
if (!lines) lines = content.split('\n');
|
|
87
91
|
|
|
@@ -97,10 +101,14 @@ export function validateSecurity(projectDir, config) {
|
|
|
97
101
|
}
|
|
98
102
|
}
|
|
99
103
|
|
|
100
|
-
// Skip known-safe placeholder/example values
|
|
104
|
+
// Skip known-safe placeholder/example values, but keep scanning for a
|
|
105
|
+
// real one further down the file.
|
|
101
106
|
if (isSafePlaceholder(matchLine, match[0])) continue;
|
|
102
107
|
|
|
103
108
|
findings.push({ file: relPath, label, match: match[0].substring(0, 30) + '...' });
|
|
109
|
+
// One finding per (file, label) is enough — the reported message is
|
|
110
|
+
// identical for repeats and we've already proven a real secret exists.
|
|
111
|
+
break;
|
|
104
112
|
}
|
|
105
113
|
}
|
|
106
114
|
});
|
|
@@ -4,6 +4,7 @@
|
|
|
4
4
|
|
|
5
5
|
import { existsSync, readFileSync } from 'node:fs';
|
|
6
6
|
import { resolve } from 'node:path';
|
|
7
|
+
import { docHasSection } from '../shared.mjs';
|
|
7
8
|
|
|
8
9
|
export function validateStructure(projectDir, config) {
|
|
9
10
|
const results = { name: 'structure', errors: [], warnings: [], passed: 0, total: 0 };
|
|
@@ -86,11 +87,11 @@ export function validateDocSections(projectDir, config) {
|
|
|
86
87
|
|
|
87
88
|
for (const section of sections) {
|
|
88
89
|
results.total++;
|
|
89
|
-
// Match
|
|
90
|
-
//
|
|
90
|
+
// Match a real heading (H2–H6), not a substring in a TOC link or code
|
|
91
|
+
// block. v0.24: synonym- and section-number-tolerant via docHasSection, so
|
|
92
|
+
// arc42/C4 docs ("## 5.4 Layer boundaries", "## Building Block View")
|
|
93
|
+
// count instead of being told to add a section they already have.
|
|
91
94
|
const headingText = section.replace(/^#+\s*/, '');
|
|
92
|
-
const escapedHeading = headingText.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
93
|
-
const headingRe = new RegExp('^#{2,6}\\s+' + escapedHeading + '\\b', 'm');
|
|
94
95
|
// v0.16-P7: N/A marker. A project can declare a required section as
|
|
95
96
|
// "not applicable" via an HTML comment instead of writing boilerplate
|
|
96
97
|
// "Absent by design" prose. Format:
|
|
@@ -108,7 +109,7 @@ export function validateDocSections(projectDir, config) {
|
|
|
108
109
|
'<!--\\s*docguard:section\\s+' + slug.replace(/-/g, '[-_]') + '\\s+n/a\\s*[—-]+\\s*[A-Za-z0-9]',
|
|
109
110
|
'i'
|
|
110
111
|
);
|
|
111
|
-
if (
|
|
112
|
+
if (docHasSection(content, section)) {
|
|
112
113
|
results.passed++;
|
|
113
114
|
} else if (naRe.test(content)) {
|
|
114
115
|
// v0.16-P7: explicit N/A — counts as passed (the project has owned
|
|
@@ -206,11 +206,13 @@ function extractDocumentedTokens(content) {
|
|
|
206
206
|
if (t) tokens.add(t);
|
|
207
207
|
}
|
|
208
208
|
|
|
209
|
-
// Pattern B: bolded token in a table row. Matches the validators-style
|
|
210
|
-
// tables that use `| N | **Name** | description |` — backticks alone
|
|
211
|
-
//
|
|
212
|
-
//
|
|
213
|
-
|
|
209
|
+
// Pattern B: bolded NAME token in a table row. Matches the validators-style
|
|
210
|
+
// tables that use `| N | **Name** | description |` — backticks alone miss
|
|
211
|
+
// every entry in those tables. The bold must be the FIRST cell, or the second
|
|
212
|
+
// cell after a numeric first cell — NOT any bold further right. The old
|
|
213
|
+
// `\|.*?\*\*` grabbed the first bold ANYWHERE in the row, so a bold status
|
|
214
|
+
// column like `| guard | **High** |` polluted the surface set with "High".
|
|
215
|
+
const boldRowRe = /^\s*\|\s*(?:\d+\s*\|\s*)?\*\*([^*\n]+)\*\*/gim;
|
|
214
216
|
while ((m = boldRowRe.exec(stripped)) !== null) {
|
|
215
217
|
const t = normalize(m[1]);
|
|
216
218
|
if (t) tokens.add(t);
|