docguard-cli 0.35.0 → 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.
Files changed (51) hide show
  1. package/README.md +8 -15
  2. package/cli/commands/agent.mjs +27 -6
  3. package/cli/commands/ci.mjs +3 -0
  4. package/cli/commands/diagnose.mjs +8 -2
  5. package/cli/commands/feedback.mjs +83 -89
  6. package/cli/commands/fix.mjs +4 -0
  7. package/cli/commands/generate.mjs +3 -0
  8. package/cli/commands/guard.mjs +37 -20
  9. package/cli/commands/hooks.mjs +61 -40
  10. package/cli/commands/init.mjs +51 -5
  11. package/cli/commands/memory.mjs +29 -15
  12. package/cli/commands/report.mjs +12 -7
  13. package/cli/commands/score.mjs +39 -19
  14. package/cli/commands/sync.mjs +2 -0
  15. package/cli/commands/watch.mjs +113 -70
  16. package/cli/config.mjs +6 -3
  17. package/cli/docguard.mjs +12 -4
  18. package/cli/findings.mjs +13 -13
  19. package/cli/scanners/memory-plan.mjs +279 -134
  20. package/cli/scanners/project-type.mjs +6 -1
  21. package/cli/scanners/semantic-claims.mjs +176 -26
  22. package/cli/shared-diff.mjs +22 -1
  23. package/cli/shared-doc-roles.mjs +59 -0
  24. package/cli/shared-ignore.mjs +15 -2
  25. package/cli/shared-source.mjs +223 -1
  26. package/cli/validator-coverage.mjs +20 -0
  27. package/cli/validators/api-surface.mjs +94 -70
  28. package/cli/validators/architecture.mjs +19 -5
  29. package/cli/validators/diff-suspicion.mjs +45 -9
  30. package/cli/validators/docs-coverage.mjs +6 -5
  31. package/cli/validators/docs-diff.mjs +51 -7
  32. package/cli/validators/environment.mjs +3 -2
  33. package/cli/validators/freshness.mjs +140 -83
  34. package/cli/validators/schema-sync.mjs +3 -2
  35. package/cli/validators/security.mjs +58 -23
  36. package/cli/validators/structure.mjs +3 -1
  37. package/cli/validators/test-spec.mjs +3 -2
  38. package/cli/validators/todo-tracking.mjs +61 -28
  39. package/cli/validators/traceability.mjs +152 -38
  40. package/docs/configuration.md +41 -0
  41. package/extensions/spec-kit-docguard/extension.yml +2 -3
  42. package/extensions/spec-kit-docguard/skills/docguard-fix/SKILL.md +2 -2
  43. package/extensions/spec-kit-docguard/skills/docguard-guard/SKILL.md +2 -2
  44. package/extensions/spec-kit-docguard/skills/docguard-review/SKILL.md +2 -2
  45. package/extensions/spec-kit-docguard/skills/docguard-score/SKILL.md +2 -2
  46. package/extensions/spec-kit-docguard/skills/docguard-sync/SKILL.md +2 -2
  47. package/extensions/spec-kit-docguard/templates/extensions.yml +1 -2
  48. package/extensions/spec-kit-docguard/templates/github-workflows/docguard-guard.yml +74 -29
  49. package/package.json +1 -1
  50. package/schemas/docguard-config.schema.json +43 -1
  51. package/templates/ci/github-actions.yml +51 -11
@@ -1,3 +1,4 @@
1
+ import { docRolePath } from '../shared-doc-roles.mjs';
1
2
  /**
2
3
  * Semantic claim extractor (LLM field report #5).
3
4
  *
@@ -21,10 +22,12 @@
21
22
  * Zero npm dependencies — pure Node.js built-ins.
22
23
  */
23
24
 
24
- import { existsSync, readFileSync } from 'node:fs';
25
- import { resolve } from 'node:path';
25
+ import { lstatSync, realpathSync, readdirSync, openSync, readSync, fstatSync, closeSync, constants } from 'node:fs';
26
+ import { createHash } from 'node:crypto';
27
+ import { execFileSync } from 'node:child_process';
28
+ import { resolve, relative, isAbsolute, sep } from 'node:path';
26
29
  import { loadIgnorePatterns } from '../shared.mjs';
27
- import { listCanonicalDocs } from '../shared-ignore.mjs';
30
+
28
31
 
29
32
  // Numbers are only claims when adjacent to a recognized unit.
30
33
  const NUMBER_PATTERNS = [
@@ -44,28 +47,169 @@ const NUMBER_PATTERNS = [
44
47
  const ENUM_LIST_RE = /\b[A-Z][A-Z0-9_]{2,}(?:\s*(?:\/|,|\||\bor\b)\s*[A-Z][A-Z0-9_]{2,}){1,}\b/g;
45
48
  const ENUM_CONTEXT_RE = /\b(status|state|enum|values?|one of|phase|stage|transitions?)\b/i;
46
49
 
47
- // A code path mentioned in or near the claim the agent's starting point.
48
- const CITED_CODE_RE = /`?([\w./-]+\.(?:ts|tsx|js|mjs|cjs|jsx|py|go|rs|java|kt|rb|php|sql|yaml|yml|json))`?(?::(\d+))?/;
50
+ export const SEMANTIC_COVERAGE_LIMITATION = 'Semantic claim discovery is limited to docs-canonical/**/*.md, explicitly mapped Markdown document roles, README.md, and AGENTS.md, subject to ignores, safety checks, and scan budgets. Other resolved documentation homes are unscanned/unsupported by this extractor; guard coverage labels do not extend this scope. Hashes cover only captured document and cited-source inputs, not all documentation or factual correctness.';
49
51
 
50
52
  const MAX_CLAIMS = 80;
53
+ const MAX_EVIDENCE_FILE_BYTES = 1024 * 1024;
54
+ const MAX_EVIDENCE_BYTES = 8 * MAX_EVIDENCE_FILE_BYTES;
55
+ const MAX_EVIDENCE_FILES = 128;
56
+ const MAX_CITED_SOURCES = 8;
57
+
58
+ export function contentHash(content) {
59
+ return `sha256:${createHash('sha256').update(content).digest('hex')}`;
60
+ }
61
+
62
+ function unknownFile(path, reason) {
63
+ return { path: path || null, status: 'unknown', hash: null, reason };
64
+ }
65
+
66
+ function privateSegment(part) {
67
+ return part.toLowerCase() === '.local' || /^\.env(?:\.|$)/i.test(part);
68
+ }
51
69
 
52
- /** Canonical docs + the root docs where limits/counts commonly live. */
53
- function claimSourceDocs(projectDir) {
54
- // Honor .docguardignore: a doc the user explicitly excluded from validation
55
- // (e.g. a historical audit full of point-in-time counts) must not feed the
56
- // "unverified claims" pool either — it inflated the count and buried the
57
- // claims that ARE actionable (bug-212).
58
- const isIgnored = loadIgnorePatterns(projectDir);
59
- // Recursive nested canonical docs make claims too. The ignore predicate is
60
- // applied per-doc inside the helper against the full relative path, so a
61
- // pattern like `docs-canonical/99-archive/**` still excludes a subtree.
62
- const docs = listCanonicalDocs(projectDir, { isIgnored }).map(d => d.rel);
63
- for (const root of ['README.md', 'AGENTS.md']) {
64
- if (existsSync(resolve(projectDir, root)) && !isIgnored(root)) docs.push(root);
70
+ /** Per-run bounded cache. Never follows symlinks, reads secrets, or leaves root. */
71
+ export function createEvidenceReader(projectDir) {
72
+ let root;
73
+ try { root = realpathSync(projectDir); } catch { /* unknown root */ }
74
+ const cache = new Map();
75
+ let bytes = 0;
76
+ return (citation) => {
77
+ const path = typeof citation === 'string' ? citation.replace(/:\d+(?:-\d+)?$/, '') : null;
78
+ if (!path) return { evidence: unknownFile(path, 'not-cited'), content: null };
79
+ if (cache.has(path)) return cache.get(path);
80
+ const fail = (reason) => ({ evidence: unknownFile(path, reason), content: null });
81
+ if (!root || isAbsolute(path) || /[\\:\0]/.test(path) || path.split('/').some(p => p === '..' || privateSegment(p))) {
82
+ return fail('unsafe-path');
83
+ }
84
+ if (cache.size >= MAX_EVIDENCE_FILES) return fail('file-budget');
85
+ let result;
86
+ let fd;
87
+ try {
88
+ let full = root;
89
+ let inspected;
90
+ for (const part of path.split('/').filter(p => p && p !== '.')) {
91
+ full = resolve(full, part);
92
+ inspected = lstatSync(full);
93
+ if (inspected.isSymbolicLink()) throw new Error('symlink');
94
+ }
95
+ const actual = realpathSync(full);
96
+ const rel = relative(root, actual);
97
+ if (!rel || rel === '..' || rel.startsWith(`..${sep}`) || isAbsolute(rel) || rel.split(sep).some(privateSegment)) {
98
+ throw new Error('unsafe-path');
99
+ }
100
+ fd = openSync(actual, constants.O_RDONLY | constants.O_NOFOLLOW | constants.O_NONBLOCK);
101
+ const stat = fstatSync(fd);
102
+ if (!stat.isFile()) throw new Error('not-file');
103
+ if (stat.dev !== inspected?.dev || stat.ino !== inspected?.ino) throw new Error('changed-during-read');
104
+ if (stat.size > MAX_EVIDENCE_FILE_BYTES) throw new Error('file-too-large');
105
+ if (bytes + stat.size > MAX_EVIDENCE_BYTES) throw new Error('byte-budget');
106
+ const buffer = Buffer.alloc(stat.size);
107
+ let offset = 0;
108
+ while (offset < buffer.length) {
109
+ const n = readSync(fd, buffer, offset, buffer.length - offset, offset);
110
+ if (!n) break;
111
+ offset += n;
112
+ }
113
+ bytes += offset;
114
+ const after = fstatSync(fd);
115
+ if (offset !== stat.size || after.size !== stat.size || after.mtimeMs !== stat.mtimeMs || after.ctimeMs !== stat.ctimeMs) {
116
+ throw new Error('changed-during-read');
117
+ }
118
+ result = { evidence: { path, status: 'snapshot', hash: contentHash(buffer) }, content: buffer.toString('utf8') };
119
+ } catch (err) {
120
+ const reasons = ['symlink', 'unsafe-path', 'not-file', 'file-too-large', 'byte-budget', 'changed-during-read'];
121
+ result = fail(reasons.includes(err.message) ? err.message : 'unavailable');
122
+ } finally {
123
+ if (fd !== undefined) closeSync(fd);
124
+ }
125
+ cache.set(path, result);
126
+ return result;
127
+ };
128
+ }
129
+
130
+ /** Candidate paths only; the reader decides whether they can be used safely. */
131
+ export function citedSources(text, limit = MAX_CITED_SOURCES) {
132
+ const paths = [];
133
+ for (const match of String(text || '').slice(0, 32768).matchAll(/`([^`\n]+)`|([^\s`]+)/g)) {
134
+ const candidate = match[1] || match[2].replace(/^[(["']+|[)\],;.!"']+$/g, '');
135
+ if (!/(?:\.(?:ts|tsx|js|mjs|cjs|jsx|py|go|rs|java|kt|rb|php|sql|yaml|yml|json)|(?:^|\/)\.env(?:\.[\w.-]+)?)(?::\d+(?:-\d+)?)?$/.test(candidate)) continue;
136
+ if (!paths.includes(candidate)) paths.push(candidate);
137
+ if (paths.length >= limit) break;
138
+ }
139
+ return paths;
140
+ }
141
+
142
+ /** Stable identity excludes line numbers, discovery order, and content hashes. */
143
+ export function semanticClaimId(claim) {
144
+ return `claim.${contentHash(JSON.stringify([
145
+ claim.doc, claim.section, claim.kind, claim.subkind, claim.value, claim.unit,
146
+ String(claim.text || '').replace(/\s+/g, ' ').trim(),
147
+ (claim.citedCode || '').replace(/:\d+(?:-\d+)?$/, ''),
148
+ ])).slice(7)}`;
149
+ }
150
+
151
+ /** Hashes bind a snapshot to its inputs; they are never evidence of review. */
152
+ export function taskEvidence(read, doc, citations = [], taskContent = '') {
153
+ const document = read(doc).evidence;
154
+ const citedSourceFiles = [...new Set(citations)].slice(0, MAX_CITED_SOURCES).map(p => read(p).evidence);
155
+ const snapshot = { document, citedSources: citedSourceFiles, taskContentHash: contentHash(taskContent) };
156
+ return {
157
+ kind: 'snapshot', verification: 'unverified', factualAccuracy: 'unknown',
158
+ limitation: SEMANTIC_COVERAGE_LIMITATION,
159
+ ...snapshot,
160
+ sourceCoverage: citedSourceFiles.length && citedSourceFiles.every(s => s.status === 'snapshot') ? 'cited-only' : 'unknown',
161
+ snapshotHash: contentHash(JSON.stringify(snapshot)),
162
+ };
163
+ }
164
+
165
+ /** Two bounded read-only Git queries per artifact, never per task. */
166
+ export function gitEvidence(projectDir) {
167
+ const result = { revision: null, dirty: null, status: 'unknown' };
168
+ const options = { cwd: projectDir, encoding: 'utf8', timeout: 1500, maxBuffer: 65536,
169
+ stdio: ['ignore', 'pipe', 'pipe'], env: { ...process.env, GIT_OPTIONAL_LOCKS: '0' } };
170
+ try {
171
+ const revision = execFileSync('git', ['rev-parse', '--verify', 'HEAD'], options).trim();
172
+ if (/^[a-f0-9]{40,64}$/.test(revision)) result.revision = revision;
173
+ } catch { /* missing Git/revision stays unknown */ }
174
+ try {
175
+ result.dirty = execFileSync('git', ['status', '--porcelain', '--untracked-files=normal'], options).length > 0;
176
+ } catch { /* dirty state unknown */ }
177
+ if (result.revision !== null && result.dirty !== null) result.status = 'snapshot';
178
+ return result;
179
+ }
180
+
181
+ /** Bounded canonical inventory that does not traverse private or symlink dirs. */
182
+ export function evidenceDocs(projectDir, config = {}) {
183
+ const docs = [];
184
+ let directories = 0;
185
+ const walk = (rel) => {
186
+ if (++directories > MAX_EVIDENCE_FILES || docs.length >= MAX_EVIDENCE_FILES) return;
187
+ try {
188
+ if (lstatSync(resolve(projectDir, rel)).isSymbolicLink()) return;
189
+ for (const entry of readdirSync(resolve(projectDir, rel), { withFileTypes: true }).sort((a, b) => a.name.localeCompare(b.name))) {
190
+ if (docs.length >= MAX_EVIDENCE_FILES) break;
191
+ if (entry.isSymbolicLink() || privateSegment(entry.name)) continue;
192
+ const path = `${rel}/${entry.name}`;
193
+ if (entry.isDirectory() && !entry.name.startsWith('.')) walk(path);
194
+ else if (entry.isFile() && /\.md$/i.test(entry.name)) docs.push(path);
195
+ }
196
+ } catch { /* inventory is heuristic, not proof of coverage */ }
197
+ };
198
+ walk('docs-canonical');
199
+ for (const role of Object.keys(config.docs?.roles || {})) {
200
+ const path = docRolePath(config, role);
201
+ if (/\.md$/i.test(path) && !docs.includes(path)) docs.push(path);
65
202
  }
66
203
  return docs;
67
204
  }
68
205
 
206
+ /** Canonical docs + root docs, honoring the existing ignore contract. */
207
+ function claimSourceDocs(projectDir, read, config) {
208
+ // The legacy matcher reads its file itself; never call it for an unsafe file.
209
+ const isIgnored = read('.docguardignore').content === null ? () => false : loadIgnorePatterns(projectDir);
210
+ return [...evidenceDocs(projectDir, config), 'README.md', 'AGENTS.md'].filter(doc => !isIgnored(doc));
211
+ }
212
+
69
213
  /** True if a line is inside a fenced code block (toggled by the caller). */
70
214
  function findCitedCode(lines, idx) {
71
215
  // Search the claim line first, then the immediately adjacent lines. A tight
@@ -74,8 +218,8 @@ function findCitedCode(lines, idx) {
74
218
  for (let d = 0; d <= 1; d++) {
75
219
  for (const j of d === 0 ? [idx] : [idx - d, idx + d]) {
76
220
  if (j < 0 || j >= lines.length) continue;
77
- const m = CITED_CODE_RE.exec(lines[j]);
78
- if (m) return m[2] ? `${m[1]}:${m[2]}` : m[1];
221
+ const cited = citedSources(lines[j], 1)[0];
222
+ if (cited) return cited;
79
223
  }
80
224
  }
81
225
  return null;
@@ -85,13 +229,13 @@ function findCitedCode(lines, idx) {
85
229
  * Extract semantic claims from a project's canonical docs.
86
230
  * @returns {Array<{ doc, line, section, kind, subkind, value, unit, text, citedCode }>}
87
231
  */
88
- export function extractSemanticClaims(projectDir, config = {}) {
232
+ export function extractSemanticClaims(projectDir, config = {}, read = createEvidenceReader(projectDir)) {
89
233
  const claims = [];
90
234
  const seen = new Set();
91
235
 
92
- for (const doc of claimSourceDocs(projectDir)) {
93
- let content;
94
- try { content = readFileSync(resolve(projectDir, doc), 'utf-8'); } catch { continue; }
236
+ for (const doc of claimSourceDocs(projectDir, read, config)) {
237
+ const { content } = read(doc);
238
+ if (content === null) continue;
95
239
  const lines = content.split('\n');
96
240
  let section = '';
97
241
  let inFence = false;
@@ -108,7 +252,11 @@ export function extractSemanticClaims(projectDir, config = {}) {
108
252
  const key = `${doc}:${lineNo}:${claim.kind}:${claim.value}:${claim.unit || ''}`;
109
253
  if (seen.has(key)) return;
110
254
  seen.add(key);
111
- claims.push({ doc, line: lineNo, section, citedCode: findCitedCode(lines, i), text: line.trim().slice(0, 200), ...claim });
255
+ if (claims.length >= MAX_CLAIMS) return;
256
+ const candidate = { doc, line: lineNo, section, citedCode: findCitedCode(lines, i), text: line.trim().slice(0, 200), ...claim };
257
+ candidate.stableId = semanticClaimId({ ...candidate, text: line.trim() });
258
+ candidate.evidence = taskEvidence(read, doc, candidate.citedCode ? [candidate.citedCode] : [], line.trim());
259
+ claims.push(candidate);
112
260
  };
113
261
 
114
262
  for (const { kind, re } of NUMBER_PATTERNS) {
@@ -147,6 +295,8 @@ export function buildSemanticVerifyTasks(claims) {
147
295
  : `the ${c.subkind} value ${c.value}${c.unit ? ` ${c.unit}` : ''}`;
148
296
  return {
149
297
  id: `verify.semantic.${i + 1}`,
298
+ stableId: c.stableId || semanticClaimId(c),
299
+ evidence: c.evidence || taskEvidence(path => ({ evidence: unknownFile(path, 'not-captured') }), c.doc, c.citedCode ? [c.citedCode] : []),
150
300
  doc: c.doc,
151
301
  line: c.line,
152
302
  section: c.section,
@@ -155,7 +305,7 @@ export function buildSemanticVerifyTasks(claims) {
155
305
  unit: c.unit,
156
306
  citedCode: c.citedCode,
157
307
  claim: c.text,
158
- instruction: `Verify ${what} documented in ${c.doc}:${c.line}${c.section ? ` (section "${c.section}")` : ''} against the code.${where} If the code disagrees, the doc (or the code) is wrong — report the mismatch with both values.`,
308
+ instruction: `Verify ${what} documented in ${c.doc}:${c.line}${c.section ? ` (section "${c.section}")` : ''} against the code.${where} If the code disagrees, the doc (or the code) is wrong — report the mismatch with both values. Attached hashes capture an unverified snapshot; guard does not verify this claim.`,
159
309
  confidence: 'requires-human',
160
310
  };
161
311
  });
@@ -169,7 +169,7 @@ export function activityLabeledDiff(fileDiff) {
169
169
  }
170
170
 
171
171
  /**
172
- * Tokens that LEFT the code in this file diff union over every '-' line
172
+ * Old-side tokens absent from additions, plus removed declaration names, from every '-' line
173
173
  * (both pure deletes and the "old" side of replaces). This is the
174
174
  * `deleted ∪ replaceOld` span the outdated-comment research keys on: a doc
175
175
  * that still talks about these tokens is a drift suspect.
@@ -181,6 +181,27 @@ export function removedTokens(fileDiff, opts) {
181
181
  if (ln.op === '-') for (const t of tokenize(ln.text, opts)) set.add(t);
182
182
  }
183
183
  }
184
+ // Reformatting or movement into another hunk does not remove a token.
185
+ for (const token of addedTokens(fileDiff, opts)) set.delete(token);
186
+ // A retained call is not a retained declaration. Preserve name evidence when
187
+ // declarations disappear, even if additions still reference those names.
188
+ const declarationNames = op => {
189
+ const text = (fileDiff.hunks || []).flatMap(h => h.lines)
190
+ .filter(line => line.op === op).map(line => line.text).join('\n');
191
+ const names = new Map();
192
+ const pattern = /\b(?:function\s*\*?\s*|def\s+|class\s+|(?:const|let|var)\s+)([A-Za-z_$][\w$]*)/g;
193
+ for (const match of text.matchAll(pattern)) {
194
+ names.set(match[1], (names.get(match[1]) || 0) + 1);
195
+ }
196
+ return names;
197
+ };
198
+ const declarationsAdded = declarationNames('+');
199
+ for (const [name, count] of declarationNames('-')) {
200
+ if (count > (declarationsAdded.get(name) || 0)) {
201
+ for (const token of tokenize(name, opts)) set.add(token);
202
+ }
203
+ }
204
+
184
205
  return set;
185
206
  }
186
207
 
@@ -0,0 +1,59 @@
1
+ /** Explicit document roles let existing repository layouts serve as canonical input. */
2
+ import { lstatSync } from 'node:fs';
3
+ import { resolve, isAbsolute, join } from 'node:path';
4
+ export const DOC_ROLES = Object.freeze({
5
+ architecture: 'docs-canonical/ARCHITECTURE.md', dataModel: 'docs-canonical/DATA-MODEL.md',
6
+ security: 'docs-canonical/SECURITY.md', testSpec: 'docs-canonical/TEST-SPEC.md',
7
+ environment: 'docs-canonical/ENVIRONMENT.md', apiReference: 'docs-canonical/API-REFERENCE.md',
8
+ requirements: 'docs-canonical/REQUIREMENTS.md',
9
+ });
10
+ export function docRolePath(config = {}, role) {
11
+ if (!Object.hasOwn(DOC_ROLES, role)) throw new Error('Unknown document role: ' + role);
12
+ const value = config.docs?.roles?.[role] ?? DOC_ROLES[role];
13
+ if (typeof value !== 'string' || !value.trim() || value.includes('\0')) throw new Error('Invalid document path for role: ' + role);
14
+ const path = value.replace(/\\/g, '/');
15
+ if (config.docs?.roles?.[role] !== undefined && !path.toLowerCase().endsWith('.md')) throw new Error('Mapped document roles currently require Markdown (.md): ' + role);
16
+ if (isAbsolute(path) || /^[A-Za-z]:/.test(path) || path.split('/').some(p => p === '..' || p.toLowerCase() === '.local' || p.toLowerCase() === '.git')) throw new Error('Document role path must stay within the project: ' + role);
17
+ return path.startsWith('./') ? path.slice(2) : path;
18
+ }
19
+ export function resolveDocRole(projectDir, config, role) {
20
+ const rel = docRolePath(config, role);
21
+ // Opt-in mappings must not follow links into another repository/private data.
22
+ if (config.docs?.roles?.[role] !== undefined) {
23
+ let current = resolve(projectDir);
24
+ for (const part of rel.split('/')) {
25
+ current = join(current, part);
26
+ try { if (lstatSync(current).isSymbolicLink()) throw new Error('Document role path contains a symlink: ' + role); }
27
+ catch (e) { if (e.code !== 'ENOENT') throw e; }
28
+ }
29
+ }
30
+ return resolve(projectDir, rel);
31
+ }
32
+ export function applyDocRoles(projectDir, config) {
33
+ const roles = config.docs?.roles;
34
+ if (roles === undefined) return config;
35
+ if (!roles || typeof roles !== 'object' || Array.isArray(roles)) throw new Error('docs.roles must be an object');
36
+ const replacements = new Map();
37
+ for (const role of Object.keys(roles)) {
38
+ resolveDocRole(projectDir, config, role);
39
+ replacements.set(DOC_ROLES[role], docRolePath(config, role));
40
+ }
41
+ const requiredFiles = { ...config.requiredFiles, canonical: [...new Set([...(config.requiredFiles?.canonical || []).map(p => replacements.get(p) || p), ...replacements.values()])] };
42
+ const documentTypes = { ...config.documentTypes };
43
+ for (const [oldPath, newPath] of replacements) {
44
+ const old = documentTypes[oldPath] || {};
45
+ if (oldPath !== newPath) delete documentTypes[oldPath];
46
+ documentTypes[newPath] = { ...old, ...documentTypes[newPath], required: true, category: 'canonical' };
47
+ }
48
+ return { ...config, requiredFiles, documentTypes };
49
+ }
50
+ export function remapDocPath(config, path) {
51
+ const role = Object.keys(DOC_ROLES).find(key => DOC_ROLES[key] === path);
52
+ return role ? docRolePath(config, role) : path;
53
+ }
54
+
55
+ export function assertDefaultDocWrites(config) {
56
+ if (Object.entries(config.docs?.roles || {}).some(([role, path]) => path !== DOC_ROLES[role])) {
57
+ throw new Error('Custom docs.roles currently support validation and read-only planning. Automatic document generation/repair is unavailable for mapped layouts; review and edit the existing documents directly.');
58
+ }
59
+ }
@@ -1,3 +1,4 @@
1
+ import { resolveDocRole } from './shared-doc-roles.mjs';
1
2
  /**
2
3
  * Shared Ignore Utility — Unified file filtering for all validators.
3
4
  *
@@ -384,11 +385,11 @@ export function walkFiles(dir, callback, opts = {}) {
384
385
  export function listCanonicalDocs(projectDir, opts = {}) {
385
386
  const { dirName = 'docs-canonical', isIgnored = null } = opts;
386
387
  const root = resolvePath(projectDir, dirName);
387
- if (!existsSync(root)) return [];
388
+ // An explicit role may live outside the conventional canonical directory.
388
389
 
389
390
  const isMarkdown = (name) => name.toLowerCase().endsWith('.md');
390
391
  const out = [];
391
- walkFiles(root, (abs) => {
392
+ if (existsSync(root)) walkFiles(root, (abs) => {
392
393
  if (!isMarkdown(abs)) return;
393
394
  const rel = relPosix(projectDir, abs);
394
395
  if (isIgnored && isIgnored(rel)) return;
@@ -399,6 +400,18 @@ export function listCanonicalDocs(projectDir, opts = {}) {
399
400
  keepDot: isMarkdown,
400
401
  });
401
402
 
403
+ let roleConfig = opts.config;
404
+ if (!roleConfig) {
405
+ const configFile = resolvePath(projectDir, '.docguard.json');
406
+ try { if (existsSync(configFile)) roleConfig = JSON.parse(readFileSync(configFile, 'utf-8')); }
407
+ catch { /* loadConfig owns malformed configuration errors */ }
408
+ }
409
+ for (const role of Object.keys(roleConfig?.docs?.roles || {})) {
410
+ const abs = resolveDocRole(projectDir, roleConfig, role);
411
+ const rel = relPosix(projectDir, abs);
412
+ if (!existsSync(abs) || !isMarkdown(abs) || (isIgnored && isIgnored(rel))) continue;
413
+ if (!out.some(doc => doc.abs === abs)) out.push({ abs, rel });
414
+ }
402
415
  out.sort((a, b) => (a.rel < b.rel ? -1 : a.rel > b.rel ? 1 : 0));
403
416
  return out;
404
417
  }
@@ -10,9 +10,11 @@
10
10
  * - pnpm-workspace.yaml (packages:)
11
11
  * - turbo.json (presence → trust package.json workspaces)
12
12
  *
13
- * Zero NPM dependencies — pure Node.js built-ins only.
13
+ * Source discovery uses Node.js built-ins; Worker binding analysis optionally
14
+ * loads the existing @babel/parser dependency, with a lexical fallback.
14
15
  */
15
16
 
17
+ import { createRequire } from 'node:module';
16
18
  import { existsSync, readFileSync, readdirSync, statSync } from 'node:fs';
17
19
  import { resolve, join, dirname, relative, extname } from 'node:path';
18
20
  import { shouldIgnore, isNonProductDir, isNonProductPath } from './shared-ignore.mjs';
@@ -316,6 +318,223 @@ export function isRunnerEnvVar(name) {
316
318
  return RUNNER_ENV_PREFIXES.some((p) => name.startsWith(p));
317
319
  }
318
320
 
321
+
322
+ /** Wrangler is static evidence only: never load or execute project config. */
323
+ export function hasWorkerConfig(dir) {
324
+ return ['wrangler.toml', 'wrangler.json', 'wrangler.jsonc'].some(name => {
325
+ try { return statSync(join(dir, name)).isFile(); } catch { return false; }
326
+ });
327
+ }
328
+
329
+ function workerConfigForFile(projectDir, file) {
330
+ const root = resolve(projectDir);
331
+ let dir = dirname(file);
332
+ while (dir === root || (!relative(root, dir).startsWith('..') && !relative(root, dir).startsWith('/'))) {
333
+ if (hasWorkerConfig(dir)) return true;
334
+ if (dir === root) break;
335
+ dir = dirname(dir);
336
+ }
337
+ return false;
338
+ }
339
+
340
+ // Optional-load the existing parser directly: shared helpers must not depend
341
+ // on the scanner layer. No project modules or configuration are executed.
342
+ let workerParse = null;
343
+ try { workerParse = createRequire(import.meta.url)('@babel/parser').parse; } catch { /* lexical fallback */ }
344
+
345
+ function workerType(param) {
346
+ const type = param?.typeAnnotation?.typeAnnotation;
347
+ return type?.type === 'TSTypeReference' && type.typeName?.type === 'Identifier' ? type.typeName.name : '';
348
+ }
349
+
350
+ /** Static lexical binding analysis; collect declarations before resolving reads. */
351
+ function workerAstBindings(ast, configured) {
352
+ const root = { parent: null, functionScope: true, env: undefined };
353
+ const reads = [];
354
+ const bind = (pattern, scope, value = false) => {
355
+ if (!pattern) return;
356
+ if (pattern.type === 'Identifier' && pattern.name === 'env') scope.env = value;
357
+ else if (pattern.type === 'AssignmentPattern') bind(pattern.left, scope, value);
358
+ else if (pattern.type === 'RestElement') bind(pattern.argument, scope, value);
359
+ else if (pattern.type === 'ArrayPattern') for (const element of pattern.elements) bind(element, scope, value);
360
+ else if (pattern.type === 'ObjectPattern') {
361
+ for (const property of pattern.properties) bind(property.type === 'RestElement' ? property.argument : property.value, scope, value);
362
+ }
363
+ };
364
+ const functionTypes = new Set(['FunctionDeclaration', 'FunctionExpression', 'ArrowFunctionExpression', 'ObjectMethod', 'ClassMethod', 'ClassPrivateMethod']);
365
+ function visit(node, scope, parent) {
366
+ if (!node || typeof node.type !== 'string') return;
367
+ if (node.type === 'FunctionDeclaration' || node.type === 'ClassDeclaration') bind(node.id, scope);
368
+ if (functionTypes.has(node.type)) {
369
+ scope = { parent: scope, functionScope: true, env: undefined };
370
+ if (node.type === 'FunctionExpression') bind(node.id, scope);
371
+ const key = node.key?.name || node.key?.value || node.id?.name ||
372
+ (parent?.type === 'ObjectProperty' ? parent.key?.name || parent.key?.value : parent?.type === 'VariableDeclarator' ? parent.id?.name : '');
373
+ const request = workerType(node.params[0]) === 'Request';
374
+ for (const param of node.params) {
375
+ const target = param.type === 'AssignmentPattern' ? param.left : param;
376
+ const worker = target.type === 'Identifier' && target.name === 'env' &&
377
+ /^(?:Env|[\w$]*Env|[\w$]*Bindings)$/.test(workerType(target)) && (configured || (key === 'fetch' && request));
378
+ bind(param, scope, worker);
379
+ }
380
+ } else if (['BlockStatement', 'ForStatement', 'ForOfStatement', 'ForInStatement', 'CatchClause', 'SwitchStatement', 'ClassExpression', 'ClassDeclaration', 'StaticBlock'].includes(node.type)) {
381
+ scope = { parent: scope, functionScope: node.type === 'StaticBlock', env: undefined };
382
+ if (node.type === 'CatchClause') bind(node.param, scope);
383
+ if (node.type === 'ClassExpression' || node.type === 'ClassDeclaration') bind(node.id, scope);
384
+ }
385
+ if (node.type === 'VariableDeclaration') {
386
+ let declarationScope = scope;
387
+ if (node.kind === 'var') while (!declarationScope.functionScope && declarationScope.parent) declarationScope = declarationScope.parent;
388
+ for (const declaration of node.declarations) bind(declaration.id, declarationScope);
389
+ }
390
+ if (node.type === 'ImportDeclaration') for (const spec of node.specifiers) bind(spec.local, scope);
391
+ if ((node.type === 'MemberExpression' || node.type === 'OptionalMemberExpression') && node.object?.type === 'Identifier' && node.object.name === 'env') {
392
+ const name = node.computed ? (node.property.type === 'StringLiteral' ? node.property.value : null) : node.property.name;
393
+ if (name) reads.push({ scope, name });
394
+ }
395
+ for (const [key, child] of Object.entries(node)) {
396
+ if (['loc', 'start', 'end', 'extra', 'comments', 'tokens'].includes(key)) continue;
397
+ if (Array.isArray(child)) for (const item of child) visit(item, scope, node);
398
+ else if (child && typeof child === 'object') visit(child, scope, node);
399
+ }
400
+ }
401
+ visit(ast.program, root, null);
402
+ const names = new Set();
403
+ for (const read of reads) {
404
+ let scope = read.scope;
405
+ while (scope && scope.env === undefined) scope = scope.parent;
406
+ if (scope?.env === true) names.add(read.name);
407
+ }
408
+ return names;
409
+ }
410
+
411
+ /** Optional parser argument makes the absent/failed-parser contract testable. */
412
+ export function extractWorkerEnvBindings(content, filename = 'file.ts', configured = false, parse = workerParse) {
413
+ if (parse) {
414
+ try {
415
+ const plugins = ['decorators-legacy', 'classProperties', 'topLevelAwait'];
416
+ if (/\.[cm]?tsx?$/.test(filename)) plugins.push('typescript');
417
+ if (/\.[jt]sx?$/.test(filename) && !filename.endsWith('.ts')) plugins.push('jsx');
418
+ const ast = parse(content, { sourceType: 'unambiguous', allowReturnOutsideFunction: true, plugins });
419
+ return workerAstBindings(ast, configured);
420
+ } catch { /* Failed parsing retains conservative lexical evidence. */ }
421
+ }
422
+ return workerEnvUsageFallback(content, classifyChars(content, extname(filename)), configured);
423
+ }
424
+
425
+ // Resolve binding positions in simple lexical patterns, not property names.
426
+ // Defaults may refer to env without introducing a new local env binding.
427
+ function lexicalEnvBinding(pattern) {
428
+ const split = (text, delimiter) => {
429
+ const parts = []; let depth = 0, start = 0;
430
+ for (let i = 0; i < text.length; i++) {
431
+ if ('([{'.includes(text[i])) depth++;
432
+ else if (')]}'.includes(text[i])) depth--;
433
+ else if (text[i] === delimiter && depth === 0) { parts.push(text.slice(start, i)); start = i + 1; }
434
+ }
435
+ return [...parts, text.slice(start)];
436
+ };
437
+ const binding = text => {
438
+ text = split(text.trim().replace(/^\.\.\./, ''), '=')[0].trim();
439
+ if (text.startsWith('{') && text.endsWith('}')) {
440
+ return split(text.slice(1, -1), ',').some(property => {
441
+ const pair = split(property, ':');
442
+ return binding(pair.length > 1 ? pair.slice(1).join(':') : property);
443
+ });
444
+ }
445
+ if (text.startsWith('[') && text.endsWith(']')) return split(text.slice(1, -1), ',').some(binding);
446
+ return /^env\s*(?::[^]*)?$/.test(text);
447
+ };
448
+ return split(pattern, ',').some(binding);
449
+ }
450
+
451
+ /** Conservative, parser-independent support for ordinary typed function bodies. */
452
+ function workerEnvUsageFallback(content, kind, configured) {
453
+ // Mask regex literals where an expression may start. Division remains code.
454
+ for (let i = 0; i < content.length; i++) {
455
+ if (kind[i] || content[i] !== '/') continue;
456
+ const prefix = content.slice(0, i).trimEnd();
457
+ if (prefix && !/[=(:,[!&|?{};]$/.test(prefix) && !/(?:\b(?:return|throw|yield|case)|=>)$/.test(prefix)) continue;
458
+ let end = i + 1, bracket = false;
459
+ for (; end < content.length && content[end] !== '\n'; end++) {
460
+ if (content[end] === '\\') { end++; continue; }
461
+ if (content[end] === '[') bracket = true;
462
+ if (content[end] === ']') bracket = false;
463
+ if (content[end] === '/' && !bracket) break;
464
+ }
465
+ if (content[end] === '/') { kind.fill(1, i, end + 1); i = end; }
466
+ }
467
+ const code = content.split('').map((ch, i) => kind[i] === 0 ? ch : (ch === '\n' ? '\n' : ' ')).join('');
468
+ const braces = new Map(), parens = new Map();
469
+ const stack = [], parentheses = [];
470
+ for (let i = 0; i < code.length; i++) {
471
+ if (code[i] === '{') stack.push(i);
472
+ else if (code[i] === '}' && stack.length) braces.set(stack.pop(), i);
473
+ else if (code[i] === '(') parentheses.push(i);
474
+ else if (code[i] === ')' && parentheses.length) parens.set(parentheses.pop(), i);
475
+ }
476
+ const scopes = [], functionScopes = [], loops = [];
477
+ const functions = /\(([^()]*)\)\s*(?::\s*[\w$.[\]<>| ,]+)?\s*(?:=>\s*)?\{/g;
478
+ for (const match of code.matchAll(functions)) {
479
+ const prefix = code.slice(Math.max(0, match.index - 80), match.index);
480
+ if (/\b(?:if|for|while|switch|with)\s*$/.test(prefix)) continue;
481
+ const start = match.index + match[0].length - 1, end = braces.get(start);
482
+ if (end === undefined) continue;
483
+ const scope = { start, end };
484
+ if (!/\bcatch\s*$/.test(prefix)) functionScopes.push(scope);
485
+ const params = match[1];
486
+ if (!lexicalEnvBinding(params)) continue;
487
+ const typed = /(?:^|,)\s*env\s*:\s*(?:Env|[\w$]*Env|[\w$]*Bindings)\s*(?=,|$)/.test(params);
488
+ const fetch = /\bfetch\s*(?::|=)?\s*$/.test(prefix);
489
+ const request = /^\s*[\w$]+\s*:\s*Request\s*,/.test(params);
490
+ scopes.push({ ...scope, binding: typed && (configured || (fetch && request)) });
491
+ }
492
+ for (const match of code.matchAll(/(?:\(\s*env\s*\)|\benv)\s*=>\s*/g)) {
493
+ const start = match.index + match[0].length;
494
+ let end = braces.get(start);
495
+ if (end === undefined) {
496
+ end = start; let depth = 0;
497
+ for (; end < code.length; end++) {
498
+ const ch = code[end];
499
+ if (depth === 0 && /[,;\n)}\]]/.test(ch)) break;
500
+ if ('({['.includes(ch)) depth++;
501
+ else if (')}]'.includes(ch)) depth--;
502
+ }
503
+ }
504
+ scopes.push({ start: start - 1, end, binding: false });
505
+ }
506
+ for (const match of code.matchAll(/\bfor\s*(?:await\s*)?\(/g)) {
507
+ const open = match.index + match[0].length - 1, close = parens.get(open);
508
+ if (close === undefined) continue;
509
+ let body = close + 1;
510
+ while (/\s/.test(code[body] || '') && body < code.length) body++;
511
+ const end = braces.get(body) ?? code.indexOf(';', body);
512
+ if (end >= 0) loops.push({ start: match.index, end, headerEnd: close });
513
+ }
514
+ for (const match of code.matchAll(/\b(const|let|var)\s+(env\b|\{[^;]*?\}|\[[^;]*?\])/g)) {
515
+ if (!lexicalEnvBinding(match[2])) continue;
516
+ let scope;
517
+ if (match[1] === 'var') {
518
+ scope = functionScopes.filter(s => s.start < match.index && match.index < s.end).sort((a, b) => b.start - a.start)[0];
519
+ } else {
520
+ scope = loops.find(s => s.start < match.index && match.index < s.headerEnd);
521
+ if (!scope) scope = [...braces].filter(([start, end]) => start < match.index && match.index < end)
522
+ .sort(([a], [b]) => b - a).map(([start, end]) => ({ start, end }))[0];
523
+ }
524
+ scopes.push({ ...(scope || { start: -1, end: code.length }), binding: false });
525
+ }
526
+ const names = new Set();
527
+ const access = /\benv\s*(?:(?:\?\.|\.)\s*([A-Za-z_$][\w$]*)|(?:\?\.)?\s*\[\s*['"]([A-Za-z_$][\w$]*)['"]\s*\])/g;
528
+ for (const match of content.matchAll(access)) {
529
+ if (kind[match.index] !== 0) continue;
530
+ if (/[\w$]$/.test(code.slice(0, match.index)) || /[.?]$/.test(code.slice(0, match.index).trimEnd())) continue;
531
+ const scope = scopes.filter(s => s.start < match.index && match.index < s.end)
532
+ .sort((a, b) => b.start - a.start || Number(a.binding) - Number(b.binding))[0];
533
+ if (scope?.binding) names.add(match[1] || match[2]);
534
+ }
535
+ return names;
536
+ }
537
+
319
538
  export function grepEnvUsage(projectDir, config = {}) {
320
539
  const names = new Set();
321
540
  const roots = resolveSourceRoots(projectDir, config);
@@ -359,6 +578,9 @@ export function grepEnvUsage(projectDir, config = {}) {
359
578
  // the access KEYWORD (process/os/import) — for a real read the keyword is
360
579
  // code while only the argument 'X' is a string, so the name is still caught.
361
580
  const kind = classifyChars(content, extname(filePath));
581
+ if (['.ts', '.tsx', '.js', '.jsx', '.mjs', '.cjs'].includes(extname(filePath))) {
582
+ for (const name of extractWorkerEnvBindings(content, filePath, workerConfigForFile(projectDir, filePath))) names.add(name);
583
+ }
362
584
  // patterns[2] is the import.meta.env one — its matches are Vite-injected
363
585
  // when the name is an intrinsic, and must not be reported as user env vars.
364
586
  for (let i = 0; i < patterns.length; i++) {