docguard-cli 0.40.4 → 0.41.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 (57) hide show
  1. package/CHANGELOG.md +3218 -0
  2. package/README.md +25 -16
  3. package/cli/assessment.mjs +94 -0
  4. package/cli/commands/ci.mjs +15 -5
  5. package/cli/commands/diagnose.mjs +20 -13
  6. package/cli/commands/fix.mjs +14 -45
  7. package/cli/commands/guard.mjs +53 -25
  8. package/cli/commands/hooks.mjs +51 -10
  9. package/cli/commands/init.mjs +15 -0
  10. package/cli/commands/reconcile.mjs +10 -3
  11. package/cli/commands/report.mjs +5 -1
  12. package/cli/commands/score.mjs +2 -1
  13. package/cli/commands/upgrade.mjs +4 -1
  14. package/cli/commands/verify.mjs +9 -2
  15. package/cli/commands/watch.mjs +3 -2
  16. package/cli/config.mjs +23 -0
  17. package/cli/evidence/adapters.mjs +14 -0
  18. package/cli/evidence/manifest.mjs +15 -0
  19. package/cli/evidence/python-literal.mjs +304 -0
  20. package/cli/findings.mjs +17 -3
  21. package/cli/scanners/instruction-audit.mjs +88 -11
  22. package/cli/scanners/js-ast.mjs +156 -18
  23. package/cli/scanners/reconciliation.mjs +56 -6
  24. package/cli/scanners/routes.mjs +84 -9
  25. package/cli/scanners/spec-registry.mjs +29 -0
  26. package/cli/shared-git.mjs +98 -0
  27. package/cli/shared-ignore.mjs +1 -1
  28. package/cli/shared.mjs +30 -1
  29. package/cli/validators/api-doc-smells.mjs +2 -2
  30. package/cli/validators/api-surface.mjs +4 -9
  31. package/cli/validators/diff-suspicion.mjs +3 -2
  32. package/cli/validators/docs-sync.mjs +45 -29
  33. package/cli/validators/environment.mjs +64 -6
  34. package/cli/validators/metrics-consistency.mjs +52 -11
  35. package/cli/validators/reference-existence.mjs +4 -2
  36. package/cli/validators/security.mjs +37 -12
  37. package/cli/validators/spec-registry.mjs +10 -7
  38. package/cli/validators/todo-tracking.mjs +31 -11
  39. package/cli/validators/traceability.mjs +29 -4
  40. package/cli/writers/junit.mjs +3 -3
  41. package/cli/writers/sarif.mjs +13 -9
  42. package/docs/configuration.md +12 -1
  43. package/extensions/spec-kit-docguard/README.md +3 -0
  44. package/extensions/spec-kit-docguard/commands/review.md +50 -0
  45. package/extensions/spec-kit-docguard/extension.yml +16 -4
  46. package/extensions/spec-kit-docguard/skills/docguard-fix/SKILL.md +2 -2
  47. package/extensions/spec-kit-docguard/skills/docguard-guard/SKILL.md +2 -2
  48. package/extensions/spec-kit-docguard/skills/docguard-review/SKILL.md +2 -2
  49. package/extensions/spec-kit-docguard/skills/docguard-score/SKILL.md +2 -2
  50. package/extensions/spec-kit-docguard/skills/docguard-sync/SKILL.md +2 -2
  51. package/extensions/spec-kit-docguard/templates/github-workflows/docguard-autofix.yml +1 -1
  52. package/extensions/spec-kit-docguard/templates/github-workflows/docguard-guard.yml +1 -1
  53. package/package.json +2 -1
  54. package/schemas/docguard-config.schema.json +15 -1
  55. package/schemas/docguard-evidence.schema.json +12 -0
  56. package/templates/ci/github-actions.yml +1 -1
  57. package/templates/evidence-manifest.json +16 -0
@@ -0,0 +1,304 @@
1
+ /**
2
+ * Bounded, non-executable Python container-literal inspection.
3
+ * Counts syntactic top-level entries without importing Python or project code.
4
+ * @implements docguard.evidence-scoped-verification#FR-002
5
+ * @implements docguard.adoption-workflow-integrity#FR-011
6
+ */
7
+
8
+ export const PYTHON_LITERAL_LIMITS = Object.freeze({
9
+ sourceCharacters: 1_048_576,
10
+ tokens: 100_000,
11
+ nesting: 64,
12
+ entries: 20_000,
13
+ });
14
+
15
+ export const PYTHON_SYMBOL_RE = /^[A-Za-z_][A-Za-z0-9_]{0,127}$/;
16
+
17
+ const OPEN_TO_CLOSE = new Map([['[', ']'], ['(', ')'], ['{', '}']]);
18
+ const CLOSE_TO_OPEN = new Map([...OPEN_TO_CLOSE].map(([open, close]) => [close, open]));
19
+ const ASSIGNMENT_OPERATORS = new Set(['=', '+=', '-=', '*=', '/=', '//=', '%=', '@=', '&=', '|=', '^=', '>>=', '<<=', '**=']);
20
+ const STRING_PREFIX = /^[rRuUbBfF]$/;
21
+
22
+ const result = (status, reasonCode, message, extra = {}) => ({ status, reasonCode, message, ...extra });
23
+
24
+ function stringStart(source, index) {
25
+ if (source[index] === "'" || source[index] === '"') return { quoteIndex: index };
26
+ if (index > 0 && /[A-Za-z0-9_]/.test(source[index - 1])) return null;
27
+ let cursor = index;
28
+ while (cursor < source.length && cursor - index < 3 && STRING_PREFIX.test(source[cursor])) cursor++;
29
+ if (cursor === index || (source[cursor] !== "'" && source[cursor] !== '"')) return null;
30
+ return { quoteIndex: cursor };
31
+ }
32
+
33
+ function consumeString(source, start) {
34
+ const found = stringStart(source, start);
35
+ if (!found) return null;
36
+ const quote = source[found.quoteIndex];
37
+ const triple = source.slice(found.quoteIndex, found.quoteIndex + 3) === quote.repeat(3);
38
+ let cursor = found.quoteIndex + (triple ? 3 : 1);
39
+ while (cursor < source.length) {
40
+ if (triple && source.slice(cursor, cursor + 3) === quote.repeat(3)) return cursor + 3;
41
+ if (!triple && source[cursor] === quote) return cursor + 1;
42
+ if (source[cursor] === '\\') {
43
+ cursor += Math.min(2, source.length - cursor);
44
+ continue;
45
+ }
46
+ if (!triple && (source[cursor] === '\n' || source[cursor] === '\r')) return -1;
47
+ cursor++;
48
+ }
49
+ return -1;
50
+ }
51
+
52
+ function operatorAt(source, index) {
53
+ for (const width of [3, 2]) {
54
+ const candidate = source.slice(index, index + width);
55
+ if (ASSIGNMENT_OPERATORS.has(candidate) || ['**', '//', ':=', '==', '!=', '<=', '>=', '<<', '>>', '->'].includes(candidate)) {
56
+ return candidate;
57
+ }
58
+ }
59
+ return source[index];
60
+ }
61
+
62
+ function tokenize(source) {
63
+ if (typeof source !== 'string') return result('inconclusive', 'python-source-not-text', 'Python source is not text.');
64
+ if (source.length > PYTHON_LITERAL_LIMITS.sourceCharacters) {
65
+ return result('inconclusive', 'python-source-budget', `Python source exceeds the ${PYTHON_LITERAL_LIMITS.sourceCharacters}-character parser budget.`);
66
+ }
67
+
68
+ const tokens = [];
69
+ const brackets = [];
70
+ let cursor = 0;
71
+ let statement = 0;
72
+ let logicalStart = true;
73
+ let lineIndent = 0;
74
+ let atLineStart = true;
75
+
76
+ const push = (value, type = 'operator') => {
77
+ if (tokens.length >= PYTHON_LITERAL_LIMITS.tokens) throw new Error('python-token-budget');
78
+ tokens.push({ value, type, statement, statementStart: logicalStart && lineIndent === 0 && brackets.length === 0 });
79
+ logicalStart = false;
80
+ };
81
+
82
+ try {
83
+ while (cursor < source.length) {
84
+ const char = source[cursor];
85
+ if (cursor === 0 && char === '\uFEFF') {
86
+ cursor++;
87
+ continue;
88
+ }
89
+ if (char === ' ' || char === '\t' || char === '\f') {
90
+ if (atLineStart) lineIndent++;
91
+ cursor++;
92
+ continue;
93
+ }
94
+ if (char === '\r' || char === '\n') {
95
+ if (char === '\r' && source[cursor + 1] === '\n') cursor++;
96
+ cursor++;
97
+ atLineStart = true;
98
+ lineIndent = 0;
99
+ if (brackets.length === 0) {
100
+ statement++;
101
+ logicalStart = true;
102
+ }
103
+ continue;
104
+ }
105
+ atLineStart = false;
106
+ if (char === '#') {
107
+ while (cursor < source.length && source[cursor] !== '\n' && source[cursor] !== '\r') cursor++;
108
+ continue;
109
+ }
110
+ if (char === '\\' && (source[cursor + 1] === '\n' || source[cursor + 1] === '\r')) {
111
+ cursor++;
112
+ if (source[cursor] === '\r' && source[cursor + 1] === '\n') cursor++;
113
+ cursor++;
114
+ atLineStart = true;
115
+ lineIndent = 0;
116
+ continue;
117
+ }
118
+ const stringEnd = consumeString(source, cursor);
119
+ if (stringEnd !== null) {
120
+ if (stringEnd < 0) return result('inconclusive', 'python-unterminated-string', 'Python source contains an unterminated string.');
121
+ push('<string>', 'atom');
122
+ cursor = stringEnd;
123
+ continue;
124
+ }
125
+ if (/[A-Za-z_]/.test(char)) {
126
+ let end = cursor + 1;
127
+ while (end < source.length && /[A-Za-z0-9_]/.test(source[end])) end++;
128
+ push(source.slice(cursor, end), 'name');
129
+ cursor = end;
130
+ continue;
131
+ }
132
+ if (OPEN_TO_CLOSE.has(char)) {
133
+ push(char, 'punctuation');
134
+ brackets.push(char);
135
+ if (brackets.length > PYTHON_LITERAL_LIMITS.nesting) throw new Error('python-nesting-budget');
136
+ cursor++;
137
+ continue;
138
+ }
139
+ if (CLOSE_TO_OPEN.has(char)) {
140
+ if (brackets.pop() !== CLOSE_TO_OPEN.get(char)) return result('inconclusive', 'python-unbalanced-brackets', 'Python source contains unbalanced brackets.');
141
+ push(char, 'punctuation');
142
+ cursor++;
143
+ continue;
144
+ }
145
+ if (char === ';' && brackets.length === 0) {
146
+ statement++;
147
+ logicalStart = true;
148
+ cursor++;
149
+ continue;
150
+ }
151
+ if (char === ',' || char === ':') {
152
+ push(char, 'punctuation');
153
+ cursor++;
154
+ continue;
155
+ }
156
+ if (/[-+*/%@&|^<>=!~.]/.test(char)) {
157
+ const operator = operatorAt(source, cursor);
158
+ push(operator);
159
+ cursor += operator.length;
160
+ continue;
161
+ }
162
+ let end = cursor + 1;
163
+ while (end < source.length && !/[\s#'"A-Za-z_\[\](){};,:\\\-+*/%@&|^<>=!~.]/.test(source[end])) end++;
164
+ push(source.slice(cursor, end), 'atom');
165
+ cursor = end;
166
+ }
167
+ } catch (error) {
168
+ if (error.message === 'python-token-budget') {
169
+ return result('inconclusive', error.message, `Python source exceeds the ${PYTHON_LITERAL_LIMITS.tokens}-token parser budget.`);
170
+ }
171
+ if (error.message === 'python-nesting-budget') {
172
+ return result('unsupported', error.message, `Python literal exceeds the ${PYTHON_LITERAL_LIMITS.nesting}-level nesting budget.`);
173
+ }
174
+ throw error;
175
+ }
176
+ if (brackets.length) return result('inconclusive', 'python-unbalanced-brackets', 'Python source contains unbalanced brackets.');
177
+ return result('ok', 'python-tokenized', 'Python source tokenized.', { tokens });
178
+ }
179
+
180
+ function assignmentOperatorIndex(tokens) {
181
+ const stack = [];
182
+ for (let index = 1; index < tokens.length; index++) {
183
+ const token = tokens[index].value;
184
+ if (OPEN_TO_CLOSE.has(token)) stack.push(token);
185
+ else if (CLOSE_TO_OPEN.has(token)) stack.pop();
186
+ else if (stack.length === 0 && ASSIGNMENT_OPERATORS.has(token)) return index;
187
+ }
188
+ return -1;
189
+ }
190
+
191
+ function countEntries(tokens, opener) {
192
+ if (!tokens.length) return result('ok', 'python-literal-counted', 'Python container literal counted.', { value: 0 });
193
+ const stack = [];
194
+ let entries = 0;
195
+ let segmentHasToken = false;
196
+ let segmentStartsWithUnpack = false;
197
+ let sawTopLevelComma = false;
198
+
199
+ for (const token of tokens) {
200
+ const value = token.value;
201
+ if (OPEN_TO_CLOSE.has(value)) {
202
+ stack.push(value);
203
+ segmentHasToken = true;
204
+ continue;
205
+ }
206
+ if (CLOSE_TO_OPEN.has(value)) {
207
+ if (stack.pop() !== CLOSE_TO_OPEN.get(value)) return result('inconclusive', 'python-unbalanced-literal', 'Python literal contains unbalanced brackets.');
208
+ segmentHasToken = true;
209
+ continue;
210
+ }
211
+ if (stack.length > 0) {
212
+ segmentHasToken = true;
213
+ continue;
214
+ }
215
+ if (value === 'for' || value === 'lambda') {
216
+ return result('unsupported', 'python-dynamic-literal', 'Python comprehensions and lambda expressions are unsupported evidence sources.');
217
+ }
218
+ if (!segmentHasToken && (value === '*' || value === '**')) segmentStartsWithUnpack = true;
219
+ if (segmentStartsWithUnpack) return result('unsupported', 'python-unpacked-literal', 'Python literal unpacking is unsupported evidence syntax.');
220
+ if (value === ',') {
221
+ if (!segmentHasToken) return result('inconclusive', 'python-empty-literal-entry', 'Python literal contains an empty entry.');
222
+ entries++;
223
+ if (entries > PYTHON_LITERAL_LIMITS.entries) {
224
+ return result('unsupported', 'python-entry-budget', `Python literal exceeds the ${PYTHON_LITERAL_LIMITS.entries}-entry budget.`);
225
+ }
226
+ segmentHasToken = false;
227
+ segmentStartsWithUnpack = false;
228
+ sawTopLevelComma = true;
229
+ continue;
230
+ }
231
+ segmentHasToken = true;
232
+ }
233
+ if (stack.length) return result('inconclusive', 'python-unbalanced-literal', 'Python literal contains unbalanced brackets.');
234
+ if (segmentHasToken) entries++;
235
+ if (opener === '(' && entries > 0 && !sawTopLevelComma) {
236
+ return result('unsupported', 'python-not-tuple-literal', 'Parenthesized Python expressions require a trailing comma to be tuple evidence.');
237
+ }
238
+ if (entries > PYTHON_LITERAL_LIMITS.entries) {
239
+ return result('unsupported', 'python-entry-budget', `Python literal exceeds the ${PYTHON_LITERAL_LIMITS.entries}-entry budget.`);
240
+ }
241
+ return result('ok', 'python-literal-counted', 'Python container literal counted.', { value: entries });
242
+ }
243
+
244
+ function inspectAssignment(tokens) {
245
+ const operatorIndex = assignmentOperatorIndex(tokens);
246
+ if (operatorIndex < 0) return null;
247
+ if (tokens[operatorIndex].value !== '=') {
248
+ return result('unsupported', 'python-augmented-assignment', 'Python evidence symbol uses an unsupported assignment operator.');
249
+ }
250
+ const left = tokens.slice(1, operatorIndex);
251
+ if (left.length && (left[0].value !== ':' || left.length === 1)) {
252
+ return result('unsupported', 'python-assignment-target', 'Python evidence must assign directly to one module-level symbol.');
253
+ }
254
+ const right = tokens.slice(operatorIndex + 1);
255
+ const opener = right[0]?.value;
256
+ if (!OPEN_TO_CLOSE.has(opener)) {
257
+ return result('unsupported', 'python-nonliteral-assignment', 'Python evidence symbol must be assigned a list, tuple, set, or dictionary literal.');
258
+ }
259
+ const stack = [];
260
+ let closingIndex = -1;
261
+ for (let index = 0; index < right.length; index++) {
262
+ const value = right[index].value;
263
+ if (OPEN_TO_CLOSE.has(value)) stack.push(value);
264
+ else if (CLOSE_TO_OPEN.has(value)) {
265
+ if (stack.pop() !== CLOSE_TO_OPEN.get(value)) return result('inconclusive', 'python-unbalanced-literal', 'Python literal contains unbalanced brackets.');
266
+ if (stack.length === 0) {
267
+ closingIndex = index;
268
+ break;
269
+ }
270
+ }
271
+ }
272
+ if (closingIndex < 0) return result('inconclusive', 'python-unbalanced-literal', 'Python literal is not closed.');
273
+ if (closingIndex !== right.length - 1) {
274
+ return result('unsupported', 'python-trailing-expression', 'Python evidence literal has a chained, conditional, or concatenated expression.');
275
+ }
276
+ return countEntries(right.slice(1, closingIndex), opener);
277
+ }
278
+
279
+ /** Count entries in one uniquely assigned module-level Python container literal. */
280
+ export function countPythonLiteralEntries(source, symbol) {
281
+ if (!PYTHON_SYMBOL_RE.test(symbol || '')) {
282
+ return result('unsupported', 'python-symbol-unsupported', 'Python evidence symbol must be an ASCII identifier containing at most 128 characters.');
283
+ }
284
+ const scanned = tokenize(source);
285
+ if (scanned.status !== 'ok') return scanned;
286
+ const statements = new Map();
287
+ for (const token of scanned.tokens) {
288
+ if (!statements.has(token.statement)) statements.set(token.statement, []);
289
+ statements.get(token.statement).push(token);
290
+ }
291
+ const assignments = [];
292
+ for (const tokens of statements.values()) {
293
+ if (!tokens[0]?.statementStart || tokens[0].type !== 'name' || tokens[0].value !== symbol) continue;
294
+ const inspected = inspectAssignment(tokens);
295
+ if (inspected) assignments.push(inspected);
296
+ }
297
+ if (assignments.length === 0) {
298
+ return result('inconclusive', 'python-symbol-unassigned', `Python symbol ${symbol} has no supported module-level assignment.`);
299
+ }
300
+ if (assignments.length !== 1) {
301
+ return result('inconclusive', 'python-symbol-ambiguous', `Python symbol ${symbol} has multiple module-level assignments.`);
302
+ }
303
+ return assignments[0];
304
+ }
package/cli/findings.mjs CHANGED
@@ -17,6 +17,7 @@
17
17
  * hand-built results and render exactly as before. Nothing regresses.
18
18
  *
19
19
  * Zero npm dependencies — pure Node.js built-ins.
20
+ * @implements docguard.adoption-workflow-integrity#FR-002
20
21
  *
21
22
  * @typedef {Object} Suggestion
22
23
  * @property {'fix'|'suppress'|'review'|'report'} kind
@@ -597,8 +598,8 @@ export const CODES = {
597
598
  },
598
599
  API004: {
599
600
  validator: 'apiSurface',
600
- title: 'Documented endpoint not found in code',
601
- help: 'docs-canonical/API-REFERENCE.md documents an endpoint absent from the actual surface. Spec-confirmed absences are errors and `docguard fix --write` removes them; code-scan-only absences are low-confidence warnings ([code-scan verify]) verify before pruning.',
601
+ title: 'Documented endpoint missing from authoritative contract',
602
+ help: 'docs-canonical/API-REFERENCE.md documents an endpoint absent from the authoritative OpenAPI contract. The contract mismatch is certain; route presence or absence is separate, low-confidence evidence. Reconcile intent, contract, implementation, and documentation manually. DocGuard never deletes an endpoint from negative route-scan evidence.',
602
603
  suppress: null,
603
604
  },
604
605
  API005: {
@@ -729,6 +730,19 @@ export const CODES = {
729
730
  export function mkFinding(f) {
730
731
  const severity = f.severity === 'error' ? 'error' : 'warn';
731
732
  const confidence = f.confidence === 'low' ? 'low' : 'high';
733
+ const rawSuggestion = f.suggestion;
734
+ const suggestion = rawSuggestion && typeof rawSuggestion === 'object'
735
+ && typeof rawSuggestion.text === 'string' && rawSuggestion.text.trim()
736
+ ? {
737
+ kind: ['fix', 'suppress', 'review', 'report'].includes(rawSuggestion.kind)
738
+ ? rawSuggestion.kind : 'review',
739
+ text: rawSuggestion.text.trim(),
740
+ ...(typeof rawSuggestion.command === 'string' && rawSuggestion.command.trim()
741
+ ? { command: rawSuggestion.command.trim() } : {}),
742
+ ...(typeof rawSuggestion.pragma === 'string' && rawSuggestion.pragma.trim()
743
+ ? { pragma: rawSuggestion.pragma.trim() } : {}),
744
+ }
745
+ : null;
732
746
  return {
733
747
  code: f.code || null,
734
748
  validator: f.validator || null,
@@ -736,7 +750,7 @@ export function mkFinding(f) {
736
750
  confidence,
737
751
  message: f.message || '',
738
752
  location: f.location || null,
739
- suggestion: f.suggestion || null,
753
+ suggestion,
740
754
  reportable: f.reportable === true || confidence === 'low',
741
755
  redactedContext: f.redactedContext || null,
742
756
  };
@@ -28,11 +28,13 @@
28
28
  * against its source would flag every rule as a duplicate.
29
29
  *
30
30
  * Zero npm dependencies — pure Node.js built-ins.
31
+ * @implements docguard.adoption-workflow-integrity#FR-005
31
32
  */
32
33
 
33
- import { existsSync, readFileSync, readdirSync } from 'node:fs';
34
- import { resolve, join, dirname } from 'node:path';
34
+ import { readFileSync, readdirSync, lstatSync, realpathSync } from 'node:fs';
35
+ import { resolve, join, dirname, relative, isAbsolute, sep } from 'node:path';
35
36
  import { fileURLToPath } from 'node:url';
37
+ import { buildIgnoreFilter, loadDocguardIgnore, DEFAULT_IGNORE_DIRS, relPosix } from '../shared-ignore.mjs';
36
38
 
37
39
  const __dirname = dirname(fileURLToPath(import.meta.url));
38
40
 
@@ -44,6 +46,7 @@ const SIGNAL_RE = /\b(must|never|always|do not|don't|should|require[sd]?|forbid|
44
46
 
45
47
  const MAX_RULES = 400;
46
48
  const MAX_TASKS = 40;
49
+ const MAX_POINTER_ENTRIES = 20_000;
47
50
 
48
51
  // ── Normalization ───────────────────────────────────────────────────────────
49
52
 
@@ -111,7 +114,7 @@ export function extractInstructionRules(projectDir) {
111
114
 
112
115
  const PATH_EXTS = 'md|mjs|cjs|js|ts|tsx|jsx|json|ya?ml|py|sh|toml|txt|rs|go|css|html';
113
116
  // Backticked token: no spaces, lexes as a relative path with a known extension.
114
- const PATH_LIKE_RE = new RegExp(`^[\\w.-][\\w./-]*\\.(?:${PATH_EXTS})$`, 'i');
117
+ const PATH_LIKE_RE = new RegExp(`\\.(?:${PATH_EXTS})$`, 'i');
115
118
  // Bare (unbackticked) token: requires a directory separator for precision.
116
119
  const BARE_PATH_RE = new RegExp(`(?:^|[\\s("'])([\\w.-]+\\/[\\w./-]+\\.(?:${PATH_EXTS}))\\b`, 'gi');
117
120
  const BACKTICK_RE = /`([^`]+)`/g;
@@ -123,7 +126,11 @@ function pathCandidates(text) {
123
126
  BACKTICK_RE.lastIndex = 0;
124
127
  let m;
125
128
  while ((m = BACKTICK_RE.exec(text)) !== null) {
126
- const tok = m[1].replace(/[#:].*$/, '').trim();
129
+ // Remove only a Markdown anchor or numeric line suffix. Preserve drive
130
+ // colons, backslashes, traversal, and absolute prefixes so the caller can
131
+ // report them as unsafe instead of silently dropping the evidence.
132
+ const tok = m[1].replace(/#.*$/, '').replace(/:\d+$/, '').trim();
133
+ if (/^https?:\/\//i.test(tok)) continue;
127
134
  if (!tok.includes(' ') && PATH_LIKE_RE.test(tok)) found.add(tok);
128
135
  }
129
136
  BARE_PATH_RE.lastIndex = 0;
@@ -131,6 +138,59 @@ function pathCandidates(text) {
131
138
  return [...found];
132
139
  }
133
140
 
141
+ function safePointerPath(path) {
142
+ if (typeof path !== 'string' || !path || isAbsolute(path) || /[\\:\0]/.test(path)) return false;
143
+ return !path.split('/').some(part => part === '..' || part.toLowerCase() === '.local' || /^\.env(?:\.|$)/i.test(part));
144
+ }
145
+
146
+ function exactRegularFile(projectDir, path) {
147
+ if (!safePointerPath(path)) return false;
148
+ let root;
149
+ try { root = realpathSync(projectDir); } catch { return false; }
150
+ let cursor = root;
151
+ try {
152
+ for (const part of path.split('/').filter(Boolean)) {
153
+ cursor = resolve(cursor, part);
154
+ if (lstatSync(cursor).isSymbolicLink()) return false;
155
+ }
156
+ const rel = relative(root, realpathSync(cursor));
157
+ return !isAbsolute(rel) && rel !== '..' && !rel.startsWith(`..${sep}`) && lstatSync(cursor).isFile();
158
+ } catch { return false; }
159
+ }
160
+
161
+ function basenameIndex(projectDir, wanted, config = {}) {
162
+ const matches = new Map([...wanted].map(name => [name, []]));
163
+ if (wanted.size === 0) return { matches, complete: true, visited: 0, reason: null };
164
+ const ignored = buildIgnoreFilter([...(config.ignore || []), ...loadDocguardIgnore(projectDir)]);
165
+ let root;
166
+ try { root = realpathSync(projectDir); }
167
+ catch { return { matches, complete: false, visited: 0, reason: 'repository-unavailable' }; }
168
+ let visited = 0;
169
+ let complete = true;
170
+ let reason = null;
171
+ const walk = dir => {
172
+ let entries;
173
+ try { entries = readdirSync(dir, { withFileTypes: true }); }
174
+ catch { complete = false; reason ||= 'unreadable-directory'; return; }
175
+ for (const entry of entries) {
176
+ if (++visited > MAX_POINTER_ENTRIES) { complete = false; reason = 'entry-budget'; return; }
177
+ if (DEFAULT_IGNORE_DIRS.has(entry.name) || entry.name === '.local' || /^\.env(?:\.|$)/i.test(entry.name)) continue;
178
+ const full = resolve(dir, entry.name);
179
+ const rel = relPosix(root, full);
180
+ if (ignored(rel)) continue;
181
+ let stat;
182
+ try { stat = lstatSync(full); }
183
+ catch { complete = false; reason ||= 'unreadable-entry'; continue; }
184
+ if (stat.isSymbolicLink()) continue;
185
+ if (stat.isDirectory()) { walk(full); if (reason === 'entry-budget') return; }
186
+ else if (stat.isFile() && matches.has(entry.name)) matches.get(entry.name).push(rel);
187
+ }
188
+ };
189
+ walk(root);
190
+ for (const paths of matches.values()) paths.sort();
191
+ return { matches, complete, visited, reason };
192
+ }
193
+
134
194
  /**
135
195
  * Known docguard subcommands: cli/commands/*.mjs basenames + permanent
136
196
  * aliases. Read from disk at runtime so the list can't drift from the code.
@@ -150,7 +210,7 @@ function knownDocguardCommands() {
150
210
  * The findings string logic can prove — no LLM involved.
151
211
  * @returns {{ duplicates, negations, stalePointers, staleCommands }}
152
212
  */
153
- export function findDeterministicFindings(rules, projectDir) {
213
+ export function findDeterministicFindings(rules, projectDir, config = {}) {
154
214
  // duplicates: exact-normalized matches, within or across files.
155
215
  const byNorm = new Map();
156
216
  for (const r of rules) {
@@ -187,16 +247,30 @@ export function findDeterministicFindings(rules, projectDir) {
187
247
 
188
248
  // stale pointers: referenced file paths that don't exist in the repo.
189
249
  const stalePointers = [];
250
+ const ambiguousPointers = [];
251
+ const unsafePointers = [];
252
+ const resolvedPointers = [];
190
253
  const seenPtr = new Set();
191
- for (const r of rules) {
192
- for (const p of pathCandidates(r.text)) {
254
+ const candidates = rules.flatMap(r => pathCandidates(r.text).map(path => ({ rule: r, path })));
255
+ const bareNames = new Set(candidates.filter(item => !item.path.includes('/') && safePointerPath(item.path)).map(item => item.path));
256
+ const index = basenameIndex(projectDir, bareNames, config);
257
+ for (const { rule: r, path: p } of candidates) {
193
258
  const key = `${r.file}:${r.line}:${p}`;
194
259
  if (seenPtr.has(key)) continue;
195
260
  seenPtr.add(key);
196
- if (!existsSync(resolve(projectDir, p))) {
261
+ const finding = { file: r.file, line: r.line, section: r.section, text: r.text, path: p };
262
+ if (!safePointerPath(p)) {
263
+ unsafePointers.push({ ...finding, reason: 'unsafe-relative-path' });
264
+ } else if (exactRegularFile(projectDir, p)) {
265
+ resolvedPointers.push({ ...finding, resolvedPath: p, resolution: 'exact' });
266
+ } else if (!p.includes('/')) {
267
+ const matches = index.matches.get(p) || [];
268
+ if (matches.length === 1) resolvedPointers.push({ ...finding, resolvedPath: matches[0], resolution: 'unique-basename' });
269
+ else if (matches.length > 1) ambiguousPointers.push({ ...finding, matches: matches.slice(0, 10), matchCount: matches.length });
270
+ else if (index.complete) stalePointers.push(finding);
271
+ } else {
197
272
  stalePointers.push({ file: r.file, line: r.line, section: r.section, text: r.text, path: p });
198
273
  }
199
- }
200
274
  }
201
275
 
202
276
  // stale commands: `docguard <cmd>` (backticked — an invocation, not prose)
@@ -222,7 +296,10 @@ export function findDeterministicFindings(rules, projectDir) {
222
296
  }
223
297
  }
224
298
 
225
- return { duplicates, negations, stalePointers, staleCommands };
299
+ return {
300
+ duplicates, negations, stalePointers, ambiguousPointers, unsafePointers, resolvedPointers, staleCommands,
301
+ pointerCoverage: { status: index.complete ? 'complete' : 'partial', visited: index.visited, reason: index.reason },
302
+ };
226
303
  }
227
304
 
228
305
  // ── LLM tasks (topical-cluster pairs) ───────────────────────────────────────
@@ -314,7 +391,7 @@ export function buildInstructionAuditTasks(rules) {
314
391
  */
315
392
  export function auditInstructions(projectDir, config = {}) {
316
393
  const rules = extractInstructionRules(projectDir);
317
- const deterministic = findDeterministicFindings(rules, projectDir);
394
+ const deterministic = findDeterministicFindings(rules, projectDir, config);
318
395
  const tasks = buildInstructionAuditTasks(rules);
319
396
  return { rules, deterministic, tasks };
320
397
  }