docguard-cli 0.22.1 → 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 +4 -4
- package/cli/commands/demo.mjs +1 -1
- package/cli/commands/diff.mjs +19 -8
- 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 -101
- package/cli/commands/upgrade.mjs +61 -13
- package/cli/config.mjs +245 -0
- package/cli/docguard.mjs +21 -217
- 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/scanners/speckit.mjs +14 -0
- 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 +118 -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 +27 -44
- package/cli/validators/docs-coverage.mjs +13 -0
- package/cli/validators/docs-diff.mjs +16 -6
- 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 +12 -54
- 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
|
@@ -169,24 +169,34 @@ function diffTests(dir, config) {
|
|
|
169
169
|
// match it against code test paths (or basenames when the entry has no slash).
|
|
170
170
|
// Exact-string comparison produced the false "N documented but not found".
|
|
171
171
|
const codeArr = [...codeTests];
|
|
172
|
-
const docArr = [...docTests];
|
|
173
172
|
|
|
174
|
-
|
|
173
|
+
// PERFORMANCE OPTIMIZATION: Pre-compile regular expressions to avoid O(N*M)
|
|
174
|
+
// instantiation bottlenecks inside the nested .filter and .some loops below.
|
|
175
|
+
const docMatchers = [...docTests].map(docEntry => {
|
|
175
176
|
const entry = String(docEntry).trim();
|
|
176
177
|
const hasSlash = entry.includes('/');
|
|
177
178
|
const target = hasSlash ? entry : basename(entry);
|
|
178
|
-
const subject = hasSlash ? codeRel : basename(codeRel);
|
|
179
179
|
// Glob -> regex: escape regex specials, then any run of '*' becomes '.*'.
|
|
180
180
|
const rx = new RegExp('^' + target
|
|
181
181
|
.replace(/[.+^${}()|[\]\\]/g, '\\$&')
|
|
182
182
|
.replace(/\*+/g, '.*') + '$');
|
|
183
|
-
|
|
183
|
+
|
|
184
|
+
return {
|
|
185
|
+
original: docEntry,
|
|
186
|
+
hasSlash,
|
|
187
|
+
rx
|
|
188
|
+
};
|
|
189
|
+
});
|
|
190
|
+
|
|
191
|
+
const matches = (matcher, codeRel) => {
|
|
192
|
+
const subject = matcher.hasSlash ? codeRel : basename(codeRel);
|
|
193
|
+
return matcher.rx.test(subject);
|
|
184
194
|
};
|
|
185
195
|
|
|
186
196
|
return {
|
|
187
197
|
title: 'Test Files',
|
|
188
|
-
onlyInDocs:
|
|
189
|
-
onlyInCode: codeArr.filter(c => !
|
|
198
|
+
onlyInDocs: docMatchers.filter(m => !codeArr.some(c => matches(m, c))).map(m => m.original),
|
|
199
|
+
onlyInCode: codeArr.filter(c => !docMatchers.some(m => matches(m, c))),
|
|
190
200
|
};
|
|
191
201
|
}
|
|
192
202
|
|
|
@@ -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) {
|
|
@@ -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);
|
|
@@ -18,76 +18,97 @@ export function validateTestSpec(projectDir, config) {
|
|
|
18
18
|
const content = readFileSync(testSpecPath, 'utf-8');
|
|
19
19
|
const ptc = config.projectTypeConfig || {};
|
|
20
20
|
|
|
21
|
-
// Parse the Source-to-Test Map
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
)
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
21
|
+
// Parse the Source-to-Test Map (new header) / Service-to-Test Map (old).
|
|
22
|
+
//
|
|
23
|
+
// Column-HEADER-aware: read the header row to locate the source column, the
|
|
24
|
+
// status column, and EVERY test-file column (Unit Test, Integration Test, …),
|
|
25
|
+
// then map each data row by index WITHOUT discarding empty cells. The old
|
|
26
|
+
// parser filtered empty cells — which shifted every column rightward whenever
|
|
27
|
+
// a cell was blank (e.g. an empty Integration Test) — and only ever checked
|
|
28
|
+
// the 2nd column, so the generated 4-column table's Integration Test was
|
|
29
|
+
// never verified (#9). Splitting on the outer pipes and trimming preserves
|
|
30
|
+
// column alignment so a blank cell stays an empty string in its own slot.
|
|
31
|
+
const mapSection = content.match(/## (?:Service-to-Test Map|Source-to-Test Map)[\s\S]*?(?=\n## |$)/);
|
|
32
|
+
if (mapSection) {
|
|
33
|
+
const splitRow = (line) => {
|
|
34
|
+
const parts = line.split('|');
|
|
35
|
+
parts.shift(); // text before the first pipe
|
|
36
|
+
parts.pop(); // text after the last pipe
|
|
37
|
+
return parts.map(s => s.trim());
|
|
38
|
+
};
|
|
39
|
+
const pipeRows = mapSection[0]
|
|
29
40
|
.split('\n')
|
|
30
|
-
.filter(
|
|
41
|
+
.filter(l => l.trim().startsWith('|') && !/^\s*\|[\s|:-]+\|\s*$/.test(l)); // drop the `---` separator
|
|
42
|
+
const headerCells = pipeRows.length ? splitRow(pipeRows[0]) : [];
|
|
43
|
+
const header = headerCells.map(h => h.toLowerCase());
|
|
44
|
+
|
|
45
|
+
// Classify columns by header name, with positional fallbacks.
|
|
46
|
+
let sourceIdx = header.findIndex(h => /\bsource\b/.test(h));
|
|
47
|
+
if (sourceIdx < 0) sourceIdx = 0;
|
|
48
|
+
let statusIdx = header.findIndex(h => /\bstatus\b/.test(h));
|
|
49
|
+
if (statusIdx < 0) statusIdx = header.length - 1;
|
|
50
|
+
let testIdxs = header
|
|
51
|
+
.map((h, i) => (/\btest\b|\be2e\b/.test(h) ? i : -1))
|
|
52
|
+
.filter(i => i >= 0 && i !== sourceIdx && i !== statusIdx);
|
|
53
|
+
if (testIdxs.length === 0) {
|
|
54
|
+
const fallback = sourceIdx === 1 ? 0 : 1; // the non-source early column
|
|
55
|
+
if (fallback !== statusIdx && fallback < header.length) testIdxs = [fallback];
|
|
56
|
+
}
|
|
31
57
|
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
.map(s => s.trim())
|
|
36
|
-
.filter(s => s.length > 0);
|
|
58
|
+
const isPlaceholder = (v) =>
|
|
59
|
+
!v || v === '—' || v.includes('N/A') ||
|
|
60
|
+
['source file', 'test file', 'unit test', 'integration test', 'e2e test'].includes(v.toLowerCase());
|
|
37
61
|
|
|
38
|
-
|
|
62
|
+
// Only existence-check a cell that actually LOOKS like a file path: no
|
|
63
|
+
// internal spaces, and either a directory separator or a file extension.
|
|
64
|
+
// A `## Service-to-Test Map` section often holds several sub-tables of
|
|
65
|
+
// different shapes (Controllers, Services, an "Integration Tests" inventory
|
|
66
|
+
// like `| test-file | what it covers |`). Without this guard a prose
|
|
67
|
+
// "what it covers" cell — "Health endpoint with real dependencies" — gets
|
|
68
|
+
// checked as a missing test file (false positive; field test: wu-whatsappinbox).
|
|
69
|
+
const isPathLike = (v) => !!v && !/\s/.test(v) && (/[\\/]/.test(v) || /\.[A-Za-z0-9]{1,6}$/.test(v));
|
|
39
70
|
|
|
40
|
-
|
|
41
|
-
const
|
|
42
|
-
const
|
|
71
|
+
for (const row of pipeRows.slice(1)) { // skip the header row
|
|
72
|
+
const cells = splitRow(row);
|
|
73
|
+
const sourceFile = cells[sourceIdx] || '';
|
|
74
|
+
const status = cells[statusIdx] || '';
|
|
43
75
|
|
|
44
|
-
// Skip template/example rows and italic placeholder rows
|
|
45
|
-
if (sourceFile.startsWith('<!--') || sourceFile === 'Source File' || sourceFile.startsWith('*')) continue;
|
|
76
|
+
// Skip template/example rows and italic placeholder rows.
|
|
77
|
+
if (!sourceFile || sourceFile.startsWith('<!--') || sourceFile === 'Source File' || sourceFile.startsWith('*')) continue;
|
|
46
78
|
|
|
47
79
|
// Author-declared gaps (❌/⚠️) are surfaced as warnings. A ✅ glyph is the
|
|
48
80
|
// author's CLAIM, not proof — it is NOT counted as a pass. The real pass
|
|
49
81
|
// comes from the file-existence checks below (code truth, not the glyph).
|
|
50
|
-
if (status
|
|
82
|
+
if (status.includes('❌')) {
|
|
51
83
|
results.total++;
|
|
52
|
-
results.warnings.push(
|
|
53
|
-
|
|
54
|
-
);
|
|
55
|
-
} else if (status && status.includes('⚠️')) {
|
|
84
|
+
results.warnings.push(`TEST-SPEC declares ${sourceFile} as ❌ — missing tests`);
|
|
85
|
+
} else if (status.includes('⚠️')) {
|
|
56
86
|
results.total++;
|
|
57
|
-
results.warnings.push(
|
|
58
|
-
`TEST-SPEC declares ${sourceFile} as ⚠️ — partial coverage`
|
|
59
|
-
);
|
|
87
|
+
results.warnings.push(`TEST-SPEC declares ${sourceFile} as ⚠️ — partial coverage`);
|
|
60
88
|
}
|
|
61
89
|
|
|
62
90
|
// ── File existence checks ───────────────────────────────────────
|
|
63
|
-
// Verify source file still exists (catch stale map entries)
|
|
91
|
+
// Verify source file still exists (catch stale map entries).
|
|
64
92
|
const cleanSource = sourceFile.replace(/`/g, '').trim();
|
|
65
|
-
if (cleanSource && cleanSource !== '—' && cleanSource !== 'Source File') {
|
|
66
|
-
|
|
67
|
-
if (
|
|
68
|
-
results.total++;
|
|
69
|
-
results.warnings.push(
|
|
70
|
-
`Source-to-Test Map: source file \`${cleanSource}\` not found on disk — stale entry?`
|
|
71
|
-
);
|
|
72
|
-
} else {
|
|
73
|
-
results.total++;
|
|
93
|
+
if (cleanSource && cleanSource !== '—' && cleanSource !== 'Source File' && isPathLike(cleanSource)) {
|
|
94
|
+
results.total++;
|
|
95
|
+
if (existsSync(resolve(projectDir, cleanSource))) {
|
|
74
96
|
results.passed++;
|
|
97
|
+
} else {
|
|
98
|
+
results.warnings.push(`Source-to-Test Map: source file \`${cleanSource}\` not found on disk — stale entry?`);
|
|
75
99
|
}
|
|
76
100
|
}
|
|
77
101
|
|
|
78
|
-
// Verify test file exists
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
results.warnings.push(
|
|
86
|
-
`Source-to-Test Map: test file \`${cleanTest}\` not found — referenced by ${cleanSource}`
|
|
87
|
-
);
|
|
88
|
-
} else {
|
|
89
|
-
results.total++;
|
|
102
|
+
// Verify EVERY declared test file exists — Unit Test AND Integration Test
|
|
103
|
+
// (the old parser only checked one column).
|
|
104
|
+
for (const ti of testIdxs) {
|
|
105
|
+
const cleanTest = (cells[ti] || '').replace(/`/g, '').trim();
|
|
106
|
+
if (isPlaceholder(cleanTest) || !isPathLike(cleanTest)) continue;
|
|
107
|
+
results.total++;
|
|
108
|
+
if (existsSync(resolve(projectDir, cleanTest))) {
|
|
90
109
|
results.passed++;
|
|
110
|
+
} else {
|
|
111
|
+
results.warnings.push(`Source-to-Test Map: test file \`${cleanTest}\` not found — referenced by ${cleanSource}`);
|
|
91
112
|
}
|
|
92
113
|
}
|
|
93
114
|
}
|
|
@@ -175,7 +196,11 @@ export function validateTestSpec(projectDir, config) {
|
|
|
175
196
|
|
|
176
197
|
if (hasTestDir || hasColocated || hasConfigTests) {
|
|
177
198
|
// Tests exist but the spec maps none of them → not applicable, not a pass.
|
|
178
|
-
|
|
199
|
+
// v0.24: the validator reads column 1 as source, column 2 as the test
|
|
200
|
+
// file, and the last as status — so both the minimal 3-column shape and
|
|
201
|
+
// the 4-column table `docguard generate` emits are accepted. Say so, since
|
|
202
|
+
// the guidance previously contradicted the generated skeleton (field report).
|
|
203
|
+
results.note = 'TEST-SPEC.md declares no service-to-test mappings. Add a "## Source-to-Test Map" table — column 1 is the source, column 2 the test file, the last column the status. Both `| Source | Test file | Status |` and the generated `| Source File | Unit Test | Integration Test | Status |` shapes work. Run `docguard explain testSpec` for details.';
|
|
179
204
|
} else {
|
|
180
205
|
results.warnings.push(
|
|
181
206
|
'No test directory or co-located test files found. ' +
|
|
@@ -35,8 +35,10 @@ const TEST_EXTENSIONS = new Set(['.js', '.mjs', '.cjs', '.ts', '.tsx', '.jsx']);
|
|
|
35
35
|
|
|
36
36
|
// ──── Patterns ────
|
|
37
37
|
|
|
38
|
-
|
|
39
|
-
|
|
38
|
+
// TEMP must be the standalone word — `(?![A-Za-z])` excludes TEMPLATE, TEMPORARY,
|
|
39
|
+
// TEMPO, TEMPEST, etc. (the old `(?!late|orar)` only caught the first two).
|
|
40
|
+
const TODO_PATTERN = /\b(TODO|FIXME|HACK|XXX|TEMP(?![A-Za-z])|WORKAROUND)\s*[(:]/;
|
|
41
|
+
const TODO_EXTRACT = /\b(TODO|FIXME|HACK|XXX|TEMP(?![A-Za-z])|WORKAROUND)\s*[:(]?\s*(.+)/;
|
|
40
42
|
|
|
41
43
|
// Matches a comment-opening marker. Real TODOs live in comments — restricting
|
|
42
44
|
// matches to text AFTER a comment marker prevents false positives from regex
|