docguard-cli 0.34.9 → 0.36.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 +27 -15
- package/cli/commands/agent.mjs +27 -6
- package/cli/commands/ci.mjs +3 -0
- package/cli/commands/diagnose.mjs +8 -2
- package/cli/commands/feedback.mjs +83 -89
- package/cli/commands/fix.mjs +4 -0
- package/cli/commands/generate.mjs +3 -0
- package/cli/commands/guard.mjs +37 -20
- package/cli/commands/hooks.mjs +61 -40
- package/cli/commands/init.mjs +51 -5
- package/cli/commands/memory.mjs +29 -15
- package/cli/commands/report.mjs +12 -7
- package/cli/commands/score.mjs +39 -19
- package/cli/commands/sync.mjs +2 -0
- package/cli/commands/watch.mjs +113 -70
- package/cli/config.mjs +6 -3
- package/cli/docguard.mjs +12 -4
- package/cli/findings.mjs +13 -13
- package/cli/scanners/memory-plan.mjs +279 -134
- package/cli/scanners/project-type.mjs +6 -1
- package/cli/scanners/semantic-claims.mjs +176 -26
- package/cli/shared-diff.mjs +22 -1
- package/cli/shared-doc-roles.mjs +59 -0
- package/cli/shared-ignore.mjs +15 -2
- package/cli/shared-source.mjs +223 -1
- package/cli/validator-coverage.mjs +20 -0
- package/cli/validators/api-surface.mjs +94 -70
- package/cli/validators/architecture.mjs +19 -5
- package/cli/validators/diff-suspicion.mjs +45 -9
- package/cli/validators/docs-coverage.mjs +6 -5
- package/cli/validators/docs-diff.mjs +51 -7
- package/cli/validators/environment.mjs +3 -2
- package/cli/validators/freshness.mjs +140 -83
- package/cli/validators/schema-sync.mjs +3 -2
- package/cli/validators/security.mjs +58 -23
- package/cli/validators/structure.mjs +3 -1
- package/cli/validators/test-spec.mjs +3 -2
- package/cli/validators/todo-tracking.mjs +61 -28
- package/cli/validators/traceability.mjs +152 -38
- package/docs/configuration.md +41 -0
- package/extensions/spec-kit-docguard/extension.yml +2 -3
- 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/extensions/spec-kit-docguard/templates/extensions.yml +1 -2
- package/extensions/spec-kit-docguard/templates/github-workflows/docguard-guard.yml +74 -29
- package/package.json +1 -1
- package/schemas/docguard-config.schema.json +43 -1
- package/templates/ci/github-actions.yml +51 -11
|
@@ -1,14 +1,72 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Freshness Validator —
|
|
2
|
+
* Freshness Validator — Identify documentation review tasks from code history.
|
|
3
3
|
* Uses git history to compare when docs were last modified vs when code was last changed.
|
|
4
4
|
*
|
|
5
|
-
* This
|
|
6
|
-
* but the code has already been implemented and committed.
|
|
5
|
+
* This is a repository-wide review heuristic, not proof of semantic drift.
|
|
7
6
|
*/
|
|
8
7
|
|
|
9
|
-
import { existsSync, readdirSync, readFileSync,
|
|
8
|
+
import { existsSync, readdirSync, readFileSync, lstatSync } from 'node:fs';
|
|
10
9
|
import { resolve, join, extname } from 'node:path';
|
|
11
|
-
import {
|
|
10
|
+
import { execFileSync } from 'node:child_process';
|
|
11
|
+
import { buildIgnoreFilter, loadDocguardIgnore, DEFAULT_IGNORE_DIRS, relPosix } from '../shared-ignore.mjs';
|
|
12
|
+
|
|
13
|
+
// Keep aligned with shared-source's supported languages, including module variants.
|
|
14
|
+
const CODE_EXTS = ['.js', '.mjs', '.cjs', '.ts', '.tsx', '.jsx', '.mts', '.cts',
|
|
15
|
+
'.py', '.java', '.go', '.rs', '.rb', '.php'];
|
|
16
|
+
const CODE_PATHS = CODE_EXTS.map(ext => `*${ext}`);
|
|
17
|
+
|
|
18
|
+
function pathFilter(dir, config) {
|
|
19
|
+
const ignored = buildIgnoreFilter([...(config.ignore || []), ...loadDocguardIgnore(dir)]);
|
|
20
|
+
return path => path === '..' || path.startsWith('../') ||
|
|
21
|
+
path.split('/').some(part => part === '.local' || DEFAULT_IGNORE_DIRS.has(part)) || ignored(path);
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function safeExistingPath(dir, abs) {
|
|
25
|
+
try {
|
|
26
|
+
let current = dir;
|
|
27
|
+
for (const part of relPosix(dir, abs).split('/')) {
|
|
28
|
+
current = join(current, part);
|
|
29
|
+
if (lstatSync(current).isSymbolicLink()) return false;
|
|
30
|
+
}
|
|
31
|
+
return true;
|
|
32
|
+
} catch { return false; }
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
// Unlike a stat-based walk, this never follows a symlink into private/outside data.
|
|
36
|
+
function collectDocs(dir, config, ignored) {
|
|
37
|
+
const files = new Set();
|
|
38
|
+
function add(path, recurse = false) {
|
|
39
|
+
if (typeof path !== 'string') return;
|
|
40
|
+
const abs = resolve(dir, path);
|
|
41
|
+
const rel = relPosix(dir, abs);
|
|
42
|
+
if (ignored(rel)) return;
|
|
43
|
+
try {
|
|
44
|
+
if (!safeExistingPath(dir, abs)) return;
|
|
45
|
+
const stat = lstatSync(abs);
|
|
46
|
+
if (stat.isFile()) files.add(rel);
|
|
47
|
+
else if (recurse && stat.isDirectory()) {
|
|
48
|
+
for (const entry of readdirSync(abs, { withFileTypes: true })) {
|
|
49
|
+
if (entry.name.startsWith('.')) continue;
|
|
50
|
+
if (entry.isDirectory() || extname(entry.name).toLowerCase() === '.md') add(join(rel, entry.name), true);
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
} catch { /* Missing/unreadable docs are handled by structural validators. */ }
|
|
54
|
+
}
|
|
55
|
+
add('docs-canonical', true);
|
|
56
|
+
// Additional homes are opt-in: inferred doc directories are not review policy.
|
|
57
|
+
for (const path of Array.isArray(config.docs?.dirs) ? config.docs.dirs : []) add(path, true);
|
|
58
|
+
for (const file of config.requiredFiles?.canonical || []) add(file, true);
|
|
59
|
+
const agents = config.requiredFiles?.agentFile || ['AGENTS.md', 'CLAUDE.md'];
|
|
60
|
+
for (const file of Array.isArray(agents) ? agents : [agents]) add(file);
|
|
61
|
+
for (const [file, spec] of Object.entries(config.documentTypes || {})) {
|
|
62
|
+
if (spec?.category === 'canonical') add(file, true);
|
|
63
|
+
}
|
|
64
|
+
add('ROADMAP.md');
|
|
65
|
+
// Changelog and deviation logs have their own review signals below.
|
|
66
|
+
const tracking = [config.requiredFiles?.changelog || 'CHANGELOG.md', config.requiredFiles?.driftLog || 'DRIFT-LOG.md']
|
|
67
|
+
.map(path => relPosix(dir, resolve(dir, path)));
|
|
68
|
+
return [...files].filter(file => !tracking.includes(file)).sort();
|
|
69
|
+
}
|
|
12
70
|
|
|
13
71
|
// B-5 fix (v0.13.1): use a defensive import. If `shared-git.mjs` is missing
|
|
14
72
|
// or unloadable in the end-user install (whatever the root cause — partial
|
|
@@ -28,9 +86,6 @@ try {
|
|
|
28
86
|
_sharedGetLastCommitDate = null;
|
|
29
87
|
}
|
|
30
88
|
|
|
31
|
-
// (v0.29 cleanup: a dead IGNORE_DIRS set lived here — defined but never
|
|
32
|
-
// referenced. Freshness reads specific configured docs; it never walks.)
|
|
33
|
-
|
|
34
89
|
/**
|
|
35
90
|
* Read the `<!-- docguard:last-reviewed YYYY-MM-DD -->` header from a doc file.
|
|
36
91
|
* Returns the parsed Date when present, null otherwise (file missing, header
|
|
@@ -45,13 +100,13 @@ export function readLastReviewedDate(absPath) {
|
|
|
45
100
|
const m = content.match(/<!--\s*docguard:last-reviewed\s+(\d{4}-\d{2}-\d{2})\s*-->/);
|
|
46
101
|
if (!m) return null;
|
|
47
102
|
const d = new Date(m[1] + 'T00:00:00Z');
|
|
48
|
-
if (isNaN(d.getTime())) return null;
|
|
103
|
+
if (isNaN(d.getTime()) || d.toISOString().slice(0, 10) !== m[1]) return null;
|
|
49
104
|
// Reject future-dated headers. A typo'd or copy-pasted future date (e.g.
|
|
50
105
|
// 2030-01-01) would otherwise make a genuinely stale doc look "fresh"
|
|
51
106
|
// forever — its age goes negative and "commits since" rounds to zero. A
|
|
52
107
|
// review can't legitimately have happened in the future, so we ignore the
|
|
53
|
-
// header and fall back to the real git date
|
|
54
|
-
if (d.getTime() > Date.now()
|
|
108
|
+
// header and fall back to the real git date (UTC calendar days).
|
|
109
|
+
if (d.getTime() > Date.now()) return null;
|
|
55
110
|
return d;
|
|
56
111
|
} catch {
|
|
57
112
|
return null;
|
|
@@ -60,15 +115,26 @@ export function readLastReviewedDate(absPath) {
|
|
|
60
115
|
|
|
61
116
|
/**
|
|
62
117
|
* Read the `<!-- docguard:status <value> -->` marker (draft | review | approved
|
|
63
|
-
* | living). Returns the lowercased value, or null. Used by the uncommitted-doc
|
|
118
|
+
* | living | active | deprecated | historical | superseded). Returns the lowercased value, or null. Used by the uncommitted-doc
|
|
64
119
|
* check (Bug #6): a doc the agent generated this session and marked `approved`
|
|
65
120
|
* has an explicit currency signal even before it's committed.
|
|
66
121
|
*/
|
|
67
122
|
function readDocStatus(absPath) {
|
|
68
123
|
try {
|
|
69
124
|
const content = readFileSync(absPath, 'utf-8');
|
|
70
|
-
|
|
71
|
-
|
|
125
|
+
let fence = null;
|
|
126
|
+
for (const line of content.split('\n')) {
|
|
127
|
+
const marker = line.match(/^\s{0,3}(`{3,}|~{3,})/);
|
|
128
|
+
if (fence) {
|
|
129
|
+
if (marker && marker[1][0] === fence[0] && marker[1].length >= fence.length
|
|
130
|
+
&& line.slice(marker[0].length).trim() === '') fence = null;
|
|
131
|
+
continue;
|
|
132
|
+
}
|
|
133
|
+
if (marker) { fence = marker[1]; continue; }
|
|
134
|
+
const m = line.match(/^\s*<!--\s*docguard:status\s+([a-z]+)\s*-->\s*$/i);
|
|
135
|
+
if (m) return m[1].toLowerCase();
|
|
136
|
+
}
|
|
137
|
+
return null;
|
|
72
138
|
} catch {
|
|
73
139
|
return null;
|
|
74
140
|
}
|
|
@@ -105,23 +171,21 @@ function getLastGitDate(filePath, dir) {
|
|
|
105
171
|
}
|
|
106
172
|
|
|
107
173
|
/**
|
|
108
|
-
*
|
|
174
|
+
* Read committed source changes once, including additions and deletions.
|
|
109
175
|
*/
|
|
110
|
-
function
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
return
|
|
122
|
-
}
|
|
123
|
-
return 0;
|
|
124
|
-
}
|
|
176
|
+
function getCodeHistory(dir, ignored) {
|
|
177
|
+
// One query per validation, independent of document count/review dates.
|
|
178
|
+
// NUL-delimited names also handle spaces/newlines without shell parsing.
|
|
179
|
+
const out = execFileSync('git',
|
|
180
|
+
['log', '--format=%x1e%H%x00%aI%x00%cI', '--name-only', '-z', '--no-renames', '--',
|
|
181
|
+
...CODE_PATHS, ':(exclude).local/**', ':(exclude)**/.local/**'],
|
|
182
|
+
{ cwd: dir, encoding: 'utf-8', stdio: ['pipe', 'pipe', 'pipe'], maxBuffer: 16 * 1024 * 1024 });
|
|
183
|
+
return out.split('\x1e').slice(1).map(record => {
|
|
184
|
+
const [hash, authorDate, commitDate, ...names] = record.split('\0');
|
|
185
|
+
const paths = names.map(name => name.replace(/^\n/, '')).filter(name =>
|
|
186
|
+
name && CODE_EXTS.includes(extname(name)) && !ignored(name));
|
|
187
|
+
return { hash, date: new Date(authorDate), since: new Date(commitDate), paths };
|
|
188
|
+
}).filter(commit => commit.paths.length);
|
|
125
189
|
}
|
|
126
190
|
|
|
127
191
|
/**
|
|
@@ -129,7 +193,7 @@ function getCodeCommitsSince(date, dir) {
|
|
|
129
193
|
*/
|
|
130
194
|
function isGitRepo(dir) {
|
|
131
195
|
try {
|
|
132
|
-
|
|
196
|
+
execFileSync('git', ['rev-parse', '--is-inside-work-tree'], {
|
|
133
197
|
cwd: dir, encoding: 'utf-8', stdio: ['pipe', 'pipe', 'pipe']
|
|
134
198
|
});
|
|
135
199
|
return true;
|
|
@@ -143,7 +207,7 @@ function isGitRepo(dir) {
|
|
|
143
207
|
*/
|
|
144
208
|
function getTotalCommits(dir) {
|
|
145
209
|
try {
|
|
146
|
-
return parseInt(
|
|
210
|
+
return parseInt(execFileSync('git', ['rev-list', '--count', 'HEAD'], {
|
|
147
211
|
cwd: dir, encoding: 'utf-8', stdio: ['pipe', 'pipe', 'pipe']
|
|
148
212
|
}).trim()) || 0;
|
|
149
213
|
} catch {
|
|
@@ -152,22 +216,8 @@ function getTotalCommits(dir) {
|
|
|
152
216
|
}
|
|
153
217
|
|
|
154
218
|
/**
|
|
155
|
-
*
|
|
219
|
+
* Evaluate review signals against repository-wide code history.
|
|
156
220
|
*/
|
|
157
|
-
function getRecentCodeCommits(dir, count = 5) {
|
|
158
|
-
try {
|
|
159
|
-
const out = execFileSync(
|
|
160
|
-
'git',
|
|
161
|
-
['log', `-${count}`, '--format=%h %aI %s', '--',
|
|
162
|
-
'*.js', '*.mjs', '*.ts', '*.tsx', '*.py', '*.java'],
|
|
163
|
-
{ cwd: dir, encoding: 'utf-8', stdio: ['pipe', 'pipe', 'pipe'] }
|
|
164
|
-
).trim();
|
|
165
|
-
return out ? out.split('\n') : [];
|
|
166
|
-
} catch {
|
|
167
|
-
return [];
|
|
168
|
-
}
|
|
169
|
-
}
|
|
170
|
-
|
|
171
221
|
export function validateFreshness(dir, config) {
|
|
172
222
|
const results = [];
|
|
173
223
|
|
|
@@ -189,32 +239,32 @@ export function validateFreshness(dir, config) {
|
|
|
189
239
|
}
|
|
190
240
|
|
|
191
241
|
// ── 1. Check each canonical doc's last update vs latest code commit ──
|
|
192
|
-
const
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
'
|
|
197
|
-
'docs-canonical/ENVIRONMENT.md',
|
|
198
|
-
'ROADMAP.md',
|
|
199
|
-
'AGENTS.md',
|
|
200
|
-
];
|
|
201
|
-
|
|
202
|
-
// Get the most recent code commit date
|
|
203
|
-
const recentCodeCommits = getRecentCodeCommits(dir, 1);
|
|
204
|
-
let latestCodeDate = null;
|
|
205
|
-
if (recentCodeCommits.length > 0) {
|
|
206
|
-
const parts = recentCodeCommits[0].split(' ');
|
|
207
|
-
if (parts.length >= 2) {
|
|
208
|
-
latestCodeDate = new Date(parts[1]);
|
|
209
|
-
}
|
|
242
|
+
const ignored = pathFilter(dir, config);
|
|
243
|
+
const docFiles = collectDocs(dir, config, ignored);
|
|
244
|
+
let history;
|
|
245
|
+
try { history = getCodeHistory(dir, ignored); } catch {
|
|
246
|
+
return [{ status: 'skip', message: 'Code history unavailable — freshness check skipped' }];
|
|
210
247
|
}
|
|
248
|
+
const latestCodeDate = history[0]?.date || null;
|
|
249
|
+
const counts = new Map();
|
|
250
|
+
const getCodeCommitsSince = date => {
|
|
251
|
+
const key = date.toISOString();
|
|
252
|
+
if (!counts.has(key)) counts.set(key, history.filter(commit => commit.since >= date).length);
|
|
253
|
+
return counts.get(key);
|
|
254
|
+
};
|
|
211
255
|
|
|
212
|
-
const
|
|
213
|
-
const WARNING_THRESHOLD_COMMITS = 10; //
|
|
256
|
+
const REVIEW_THRESHOLD_DAYS = 30; // Repository-wide trigger, not proof of drift
|
|
257
|
+
const WARNING_THRESHOLD_COMMITS = 10; // Repository-wide review trigger
|
|
214
258
|
|
|
215
259
|
for (const docFile of docFiles) {
|
|
216
260
|
const docPath = resolve(dir, docFile);
|
|
217
261
|
if (!existsSync(docPath)) continue;
|
|
262
|
+
const docStatus = readDocStatus(docPath);
|
|
263
|
+
if (['historical', 'superseded', 'deprecated'].includes(docStatus)) {
|
|
264
|
+
results.push({ status: 'skip', doc: docFile,
|
|
265
|
+
message: `${docFile} is marked ${docStatus} — currentness review not applicable; historical accuracy is not verified` });
|
|
266
|
+
continue;
|
|
267
|
+
}
|
|
218
268
|
|
|
219
269
|
// Prefer the explicit `<!-- docguard:last-reviewed YYYY-MM-DD -->` header
|
|
220
270
|
// over the git commit date. A reviewer who reads a doc and stamps the
|
|
@@ -231,10 +281,10 @@ export function validateFreshness(dir, config) {
|
|
|
231
281
|
// `<!-- docguard:status approved -->` has signaled it's intentionally
|
|
232
282
|
// current. In the generate-then-fill flow the human hasn't committed yet,
|
|
233
283
|
// so the "uncommitted" warning is noise — suppress it for approved docs.
|
|
234
|
-
if (
|
|
284
|
+
if (docStatus === 'approved') {
|
|
235
285
|
results.push({
|
|
236
286
|
status: 'pass',
|
|
237
|
-
message: `${docFile} is marked approved (not yet committed
|
|
287
|
+
message: `${docFile} is marked approved (not yet committed; author signal, not semantic verification)`,
|
|
238
288
|
});
|
|
239
289
|
continue;
|
|
240
290
|
}
|
|
@@ -244,7 +294,7 @@ export function validateFreshness(dir, config) {
|
|
|
244
294
|
status: 'warn',
|
|
245
295
|
code: 'FRS001',
|
|
246
296
|
doc: docFile,
|
|
247
|
-
message: `${docFile} exists but is not yet committed to git — commit it
|
|
297
|
+
message: `${docFile} exists but is not yet committed to git — review due: no dated review signal. After reviewing intent and implementation, commit it or add a <!-- docguard:last-reviewed YYYY-MM-DD --> marker.`,
|
|
248
298
|
});
|
|
249
299
|
continue;
|
|
250
300
|
}
|
|
@@ -260,14 +310,14 @@ export function validateFreshness(dir, config) {
|
|
|
260
310
|
const sinceDate = reviewedDate
|
|
261
311
|
? new Date(reviewedDate.getTime() + 24 * 60 * 60 * 1000 - 1000)
|
|
262
312
|
: docDate;
|
|
263
|
-
const codeCommitsSince = getCodeCommitsSince(sinceDate
|
|
313
|
+
const codeCommitsSince = getCodeCommitsSince(sinceDate);
|
|
264
314
|
|
|
265
315
|
if (codeCommitsSince >= WARNING_THRESHOLD_COMMITS) {
|
|
266
316
|
results.push({
|
|
267
317
|
status: 'warn',
|
|
268
318
|
code: 'FRS002',
|
|
269
319
|
doc: docFile,
|
|
270
|
-
message: `${docFile} — ${codeCommitsSince} code commits since last doc update (${docDate.toISOString().split('T')[0]})`,
|
|
320
|
+
message: `${docFile} — review due: ${codeCommitsSince} code commits since last doc update/review (${docDate.toISOString().split('T')[0]}); repository-wide heuristic, not evidence this document is stale`,
|
|
271
321
|
});
|
|
272
322
|
continue;
|
|
273
323
|
}
|
|
@@ -275,12 +325,12 @@ export function validateFreshness(dir, config) {
|
|
|
275
325
|
// Check age vs latest code commit
|
|
276
326
|
if (latestCodeDate) {
|
|
277
327
|
const daysDiff = Math.floor((latestCodeDate - docDate) / (1000 * 60 * 60 * 24));
|
|
278
|
-
if (daysDiff >
|
|
328
|
+
if (daysDiff > REVIEW_THRESHOLD_DAYS) {
|
|
279
329
|
results.push({
|
|
280
330
|
status: 'warn',
|
|
281
331
|
code: 'FRS003',
|
|
282
332
|
doc: docFile,
|
|
283
|
-
message: `${docFile} — last updated ${daysDiff} days before latest code change`,
|
|
333
|
+
message: `${docFile} — review due: last updated ${daysDiff} days before latest code change; repository-wide heuristic, not evidence this document is stale`,
|
|
284
334
|
});
|
|
285
335
|
continue;
|
|
286
336
|
}
|
|
@@ -288,13 +338,14 @@ export function validateFreshness(dir, config) {
|
|
|
288
338
|
|
|
289
339
|
results.push({
|
|
290
340
|
status: 'pass',
|
|
291
|
-
message: `${docFile} is fresh`,
|
|
341
|
+
message: `${docFile} — no review due by the repository-wide history heuristic; not proof the document is fresh (not semantic verification)`,
|
|
292
342
|
});
|
|
293
343
|
}
|
|
294
344
|
|
|
295
345
|
// ── 2. Check CHANGELOG.md was updated in the last 5 code commits ──
|
|
296
346
|
const changelogPath = resolve(dir, config.requiredFiles?.changelog || 'CHANGELOG.md');
|
|
297
|
-
if (
|
|
347
|
+
if (!ignored(relPosix(dir, changelogPath)) && safeExistingPath(dir, changelogPath)
|
|
348
|
+
&& !['historical', 'superseded', 'deprecated'].includes(readDocStatus(changelogPath))) {
|
|
298
349
|
const changelogDate =
|
|
299
350
|
readLastReviewedDate(changelogPath) ||
|
|
300
351
|
getLastGitDate(config.requiredFiles?.changelog || 'CHANGELOG.md', dir);
|
|
@@ -305,12 +356,12 @@ export function validateFreshness(dir, config) {
|
|
|
305
356
|
status: 'warn',
|
|
306
357
|
code: 'FRS004',
|
|
307
358
|
doc: config.requiredFiles?.changelog || 'CHANGELOG.md',
|
|
308
|
-
message:
|
|
359
|
+
message: `${config.requiredFiles?.changelog || 'CHANGELOG.md'} — review due: last updated ${daysDiff} days before latest code change; verify whether release notes are needed`,
|
|
309
360
|
});
|
|
310
361
|
} else {
|
|
311
362
|
results.push({
|
|
312
363
|
status: 'pass',
|
|
313
|
-
message: 'CHANGELOG.md
|
|
364
|
+
message: `${config.requiredFiles?.changelog || 'CHANGELOG.md'} — no review due by the history heuristic (not semantic verification)`,
|
|
314
365
|
});
|
|
315
366
|
}
|
|
316
367
|
}
|
|
@@ -318,7 +369,8 @@ export function validateFreshness(dir, config) {
|
|
|
318
369
|
|
|
319
370
|
// ── 3. Check DRIFT-LOG.md was updated if there are DRIFT comments ──
|
|
320
371
|
const driftPath = resolve(dir, config.requiredFiles?.driftLog || 'DRIFT-LOG.md');
|
|
321
|
-
if (
|
|
372
|
+
if (history.length && !ignored(relPosix(dir, driftPath)) && safeExistingPath(dir, driftPath)
|
|
373
|
+
&& !['historical', 'superseded', 'deprecated'].includes(readDocStatus(driftPath))) {
|
|
322
374
|
const driftDate = getLastGitDate(config.requiredFiles?.driftLog || 'DRIFT-LOG.md', dir);
|
|
323
375
|
// Check for recent DRIFT comments ADDED to code. The old approach piped
|
|
324
376
|
// `git log --all -p | grep -c DRIFT:`, which counted DRIFT: on removed
|
|
@@ -329,7 +381,8 @@ export function validateFreshness(dir, config) {
|
|
|
329
381
|
try {
|
|
330
382
|
const diff = execFileSync(
|
|
331
383
|
'git',
|
|
332
|
-
['log', '-5', '-p', '--',
|
|
384
|
+
['log', '-5', '-p', '--', ...[...new Set(history.slice(0, 5).flatMap(commit => commit.paths))]
|
|
385
|
+
.map(path => `:(literal)${path}`)],
|
|
333
386
|
{ cwd: dir, encoding: 'utf-8', stdio: ['pipe', 'pipe', 'pipe'] }
|
|
334
387
|
);
|
|
335
388
|
const driftCount = diff
|
|
@@ -337,18 +390,22 @@ export function validateFreshness(dir, config) {
|
|
|
337
390
|
.filter(l => /^\+(?!\+\+)/.test(l) && l.includes('DRIFT:'))
|
|
338
391
|
.length;
|
|
339
392
|
if (driftCount > 0 && driftDate) {
|
|
340
|
-
const codeCommitsSince = getCodeCommitsSince(driftDate
|
|
393
|
+
const codeCommitsSince = getCodeCommitsSince(driftDate);
|
|
341
394
|
if (codeCommitsSince > 3) {
|
|
342
395
|
results.push({
|
|
343
396
|
status: 'warn',
|
|
344
397
|
code: 'FRS005',
|
|
345
398
|
doc: config.requiredFiles?.driftLog || 'DRIFT-LOG.md',
|
|
346
|
-
message:
|
|
399
|
+
message: `${config.requiredFiles?.driftLog || 'DRIFT-LOG.md'} — review due: ${driftCount} added DRIFT comment lines found in recent commits; verify whether deviations are already recorded`,
|
|
347
400
|
});
|
|
348
401
|
}
|
|
349
402
|
}
|
|
350
403
|
} catch { /* skip */ }
|
|
351
404
|
}
|
|
352
405
|
|
|
353
|
-
return results
|
|
406
|
+
return results.map(result => result.status === 'warn' ? {
|
|
407
|
+
...result,
|
|
408
|
+
confidence: 'low',
|
|
409
|
+
suggestion: { kind: 'review', text: 'Review this history signal against the document’s purpose and intended behavior. Confirm whether documentation or code needs a change; record a review date only after reviewing. Preserve intentional historical content.' },
|
|
410
|
+
} : result);
|
|
354
411
|
}
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { docRolePath, resolveDocRole } from '../shared-doc-roles.mjs';
|
|
1
2
|
/**
|
|
2
3
|
* Schema Sync Validator — Ensures database schemas are documented in DATA-MODEL.md
|
|
3
4
|
*
|
|
@@ -90,7 +91,7 @@ export function validateSchemaSync(projectDir, config) {
|
|
|
90
91
|
let total = 0;
|
|
91
92
|
|
|
92
93
|
// Check if DATA-MODEL.md exists
|
|
93
|
-
const dataModelPath =
|
|
94
|
+
const dataModelPath = resolveDocRole(projectDir, config, 'dataModel');
|
|
94
95
|
if (!existsSync(dataModelPath)) {
|
|
95
96
|
// No DATA-MODEL.md — nothing to sync against
|
|
96
97
|
// Only warn if we detect schema files
|
|
@@ -103,7 +104,7 @@ export function validateSchemaSync(projectDir, config) {
|
|
|
103
104
|
severity: 'warn',
|
|
104
105
|
message: `Found ${detectedModels.length} database model(s) (${detectedModels.map(m => m.name).slice(0, 5).join(', ')}${detectedModels.length > 5 ? '...' : ''}) ` +
|
|
105
106
|
`but no DATA-MODEL.md exists. Run \`docguard init\` to create one, then document your schema`,
|
|
106
|
-
location: '
|
|
107
|
+
location: docRolePath(config, 'dataModel'),
|
|
107
108
|
suggestion: { kind: 'fix', text: 'Create DATA-MODEL.md, then document the detected models in it', command: 'docguard init' },
|
|
108
109
|
}));
|
|
109
110
|
}
|
|
@@ -9,6 +9,7 @@ import { existsSync, readFileSync, readdirSync, statSync } from 'node:fs';
|
|
|
9
9
|
import { resolve, join, extname } from 'node:path';
|
|
10
10
|
import { shouldIgnore, relPosix, walkFiles as sharedWalkFiles } from '../shared-ignore.mjs';
|
|
11
11
|
import { mkFinding, resultFromFindings, lineSuppresses } from '../findings.mjs';
|
|
12
|
+
import { parseJsTs, walk } from '../scanners/js-ast.mjs';
|
|
12
13
|
|
|
13
14
|
// Each secret pattern maps to a stable finding code (see cli/findings.mjs CODES)
|
|
14
15
|
// so it is `explain`-able and inline-suppressible (`// docguard:ignore SEC00x`).
|
|
@@ -42,28 +43,19 @@ const SECRET_PATTERNS = [
|
|
|
42
43
|
{ pattern: /(?:sk-|sk_live_|sk_test_)[a-zA-Z0-9]{20,}/g, label: 'API secret key (Stripe/OpenAI pattern)' },
|
|
43
44
|
];
|
|
44
45
|
|
|
45
|
-
// Known-safe placeholder/example values that should never be flagged
|
|
46
|
-
const SAFE_PATTERNS = [
|
|
47
|
-
/EXAMPLE/i, // AWS docs example keys contain "EXAMPLE"
|
|
48
|
-
/placeholder\s*=\s*["']/i, // HTML placeholder attributes
|
|
49
|
-
/example\s*:/i, // OpenAPI example: blocks
|
|
50
|
-
/['"]password123['"]/, // Common test fixture value
|
|
51
|
-
/\/\/\s*example/i, // Code comments with "example"
|
|
52
|
-
/<!--.*-->/, // HTML comments
|
|
53
|
-
];
|
|
54
|
-
|
|
55
46
|
/**
|
|
56
|
-
*
|
|
57
|
-
*
|
|
58
|
-
*
|
|
59
|
-
* @returns {boolean} - true if this is a safe/placeholder value
|
|
47
|
+
* Placeholder exemptions belong to the matched value, never sibling fields
|
|
48
|
+
* or trailing comments. Recognizable provider keys use only the exact public
|
|
49
|
+
* AWS example exception, even when the surrounding text says "example".
|
|
60
50
|
*/
|
|
61
|
-
function isSafePlaceholder(line, matchStr) {
|
|
62
|
-
|
|
63
|
-
if (/
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
51
|
+
function isSafePlaceholder(line, matchStr, label) {
|
|
52
|
+
if (label === 'AWS Access Key ID') return matchStr === 'AKIAIOSFODNN7EXAMPLE';
|
|
53
|
+
if (label === 'API secret key (Stripe/OpenAI pattern)') return false;
|
|
54
|
+
const value = quotedValue(matchStr);
|
|
55
|
+
if (/EXAMPLE/i.test(value) || value === 'password123') return true;
|
|
56
|
+
// Retain documentation-only line comments; executable code followed by an
|
|
57
|
+
// example comment is not documentation-only and must still be checked.
|
|
58
|
+
return /^\s*\/\/\s*example\b/i.test(line);
|
|
67
59
|
}
|
|
68
60
|
|
|
69
61
|
/**
|
|
@@ -99,6 +91,42 @@ function quotedValue(matchStr) {
|
|
|
99
91
|
return m ? m[1] : '';
|
|
100
92
|
}
|
|
101
93
|
|
|
94
|
+
/**
|
|
95
|
+
* Only explicit synthetic password vocabulary inside a mock-call expectation
|
|
96
|
+
* qualifies. Test paths, assertion context, or a private-looking value alone
|
|
97
|
+
* are insufficient. Provider key signatures remain independently scanned.
|
|
98
|
+
* Unknown syntax/parser failure supplies no exemptions.
|
|
99
|
+
*/
|
|
100
|
+
function fixturePasswordRanges(content, filename) {
|
|
101
|
+
if (!/(?:^|\/)__tests?__\/|\.(?:test|spec)\.[cm]?[jt]sx?$/.test(filename)) return [];
|
|
102
|
+
const { ast, ok } = parseJsTs(content, filename);
|
|
103
|
+
if (!ok || ast.errors?.length) return [];
|
|
104
|
+
const ranges = [];
|
|
105
|
+
walk(ast, node => {
|
|
106
|
+
if (node.type !== 'CallExpression') return;
|
|
107
|
+
const callee = node.callee;
|
|
108
|
+
if (callee.type !== 'MemberExpression' || callee.computed ||
|
|
109
|
+
!/^(?:toHaveBeenCalledWith|toHaveBeenLastCalledWith|toHaveBeenNthCalledWith)$/.test(callee.property.name)) return;
|
|
110
|
+
const expectation = callee.object;
|
|
111
|
+
if (expectation.type !== 'CallExpression' || expectation.callee.type !== 'Identifier' ||
|
|
112
|
+
expectation.callee.name !== 'expect') return;
|
|
113
|
+
// Inspect direct object arguments only; executing a nested callback or
|
|
114
|
+
// helper inside an assertion does not make its credentials fixture data.
|
|
115
|
+
for (const arg of node.arguments) {
|
|
116
|
+
if (arg.type !== 'ObjectExpression') continue;
|
|
117
|
+
for (const prop of arg.properties) {
|
|
118
|
+
if (prop.type !== 'ObjectProperty' || prop.computed ||
|
|
119
|
+
!/^(?:password|passwd|pwd)$/i.test(prop.key.name || prop.key.value || '') ||
|
|
120
|
+
prop.value.type !== 'StringLiteral') continue;
|
|
121
|
+
if (/^(?:test|mock|dummy|fixture)[_-]?(?:password|passwd|pwd)[0-9!@#$%_*.-]*$/i.test(prop.value.value)) {
|
|
122
|
+
ranges.push([prop.start, prop.end]);
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
});
|
|
127
|
+
return ranges;
|
|
128
|
+
}
|
|
129
|
+
|
|
102
130
|
export function validateSecurity(projectDir, config) {
|
|
103
131
|
/** @type {import('../findings.mjs').Finding[]} */
|
|
104
132
|
const findings = [];
|
|
@@ -124,6 +152,7 @@ export function validateSecurity(projectDir, config) {
|
|
|
124
152
|
scanned++;
|
|
125
153
|
const content = readFileSync(filePath, 'utf-8');
|
|
126
154
|
let lines = null;
|
|
155
|
+
let fixtureRanges = null;
|
|
127
156
|
|
|
128
157
|
for (const { pattern, label } of SECRET_PATTERNS) {
|
|
129
158
|
pattern.lastIndex = 0;
|
|
@@ -143,14 +172,19 @@ export function validateSecurity(projectDir, config) {
|
|
|
143
172
|
|
|
144
173
|
// Skip known-safe placeholder/example values, but keep scanning for a
|
|
145
174
|
// real one further down the file.
|
|
146
|
-
if (isSafePlaceholder(matchLine, match[0])) continue;
|
|
175
|
+
if (isSafePlaceholder(matchLine, match[0], label)) continue;
|
|
147
176
|
|
|
148
177
|
const code = LABEL_TO_CODE[label];
|
|
178
|
+
if (code === 'SEC001') {
|
|
179
|
+
fixtureRanges ??= fixturePasswordRanges(content, relPath);
|
|
180
|
+
if (fixtureRanges.some(([start, end]) => match.index >= start &&
|
|
181
|
+
match.index + match[0].length <= end)) continue;
|
|
182
|
+
}
|
|
149
183
|
|
|
150
184
|
// v0.27 (#8): honour an inline `// docguard:ignore SEC00x` pragma on the
|
|
151
185
|
// line or the line above — per-line suppression instead of blinding the
|
|
152
186
|
// whole file via `securityIgnore`.
|
|
153
|
-
if (code && lineSuppresses(code, matchLine, prevLine))
|
|
187
|
+
if (code && lineSuppresses(code, matchLine, prevLine)) continue;
|
|
154
188
|
|
|
155
189
|
const location = `${relPath}:${lineNo}`;
|
|
156
190
|
const value = quotedValue(match[0]);
|
|
@@ -186,7 +220,8 @@ export function validateSecurity(projectDir, config) {
|
|
|
186
220
|
},
|
|
187
221
|
}));
|
|
188
222
|
}
|
|
189
|
-
|
|
223
|
+
if (isProse) continue;
|
|
224
|
+
// One blocking finding per (file, label) is enough — the reported message is
|
|
190
225
|
// identical for repeats and we've already proven a match exists.
|
|
191
226
|
break;
|
|
192
227
|
}
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { remapDocPath } from '../shared-doc-roles.mjs';
|
|
1
2
|
/**
|
|
2
3
|
* Structure Validator — Checks that all required CDD files exist
|
|
3
4
|
*
|
|
@@ -105,7 +106,8 @@ export function validateDocSections(projectDir, config) {
|
|
|
105
106
|
: ['## Setup Steps'], // Always need setup steps, env vars optional
|
|
106
107
|
};
|
|
107
108
|
|
|
108
|
-
for (const [
|
|
109
|
+
for (const [defaultFile, sections] of Object.entries(requiredSections)) {
|
|
110
|
+
const file = remapDocPath(config, defaultFile);
|
|
109
111
|
const fullPath = resolve(projectDir, file);
|
|
110
112
|
if (!existsSync(fullPath)) continue;
|
|
111
113
|
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { docRolePath, resolveDocRole } from '../shared-doc-roles.mjs';
|
|
1
2
|
/**
|
|
2
3
|
* Test Spec Validator — Checks that tests exist per TEST-SPEC.md coverage rules
|
|
3
4
|
* Now respects projectTypeConfig (e.g., skip E2E for CLI tools)
|
|
@@ -19,8 +20,8 @@ export function validateTestSpec(projectDir, config) {
|
|
|
19
20
|
let total = 0;
|
|
20
21
|
let note;
|
|
21
22
|
|
|
22
|
-
const specDoc = '
|
|
23
|
-
const testSpecPath =
|
|
23
|
+
const specDoc = docRolePath(config, 'testSpec');
|
|
24
|
+
const testSpecPath = resolveDocRole(projectDir, config, 'testSpec');
|
|
24
25
|
if (!existsSync(testSpecPath)) {
|
|
25
26
|
// Structure validator catches this. Keep the exact legacy shape here
|
|
26
27
|
// (no `findings` key) — tests deep-equal this early return.
|