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.
- package/CHANGELOG.md +3218 -0
- package/README.md +25 -16
- package/cli/assessment.mjs +94 -0
- package/cli/commands/ci.mjs +15 -5
- package/cli/commands/diagnose.mjs +20 -13
- package/cli/commands/fix.mjs +14 -45
- package/cli/commands/guard.mjs +53 -25
- package/cli/commands/hooks.mjs +51 -10
- package/cli/commands/init.mjs +15 -0
- package/cli/commands/reconcile.mjs +10 -3
- package/cli/commands/report.mjs +5 -1
- package/cli/commands/score.mjs +2 -1
- package/cli/commands/upgrade.mjs +4 -1
- package/cli/commands/verify.mjs +9 -2
- package/cli/commands/watch.mjs +3 -2
- package/cli/config.mjs +23 -0
- package/cli/evidence/adapters.mjs +14 -0
- package/cli/evidence/manifest.mjs +15 -0
- package/cli/evidence/python-literal.mjs +304 -0
- package/cli/findings.mjs +17 -3
- package/cli/scanners/instruction-audit.mjs +88 -11
- package/cli/scanners/js-ast.mjs +156 -18
- package/cli/scanners/reconciliation.mjs +56 -6
- package/cli/scanners/routes.mjs +84 -9
- package/cli/scanners/spec-registry.mjs +29 -0
- package/cli/shared-git.mjs +98 -0
- package/cli/shared-ignore.mjs +1 -1
- package/cli/shared.mjs +30 -1
- package/cli/validators/api-doc-smells.mjs +2 -2
- package/cli/validators/api-surface.mjs +4 -9
- package/cli/validators/diff-suspicion.mjs +3 -2
- package/cli/validators/docs-sync.mjs +45 -29
- package/cli/validators/environment.mjs +64 -6
- package/cli/validators/metrics-consistency.mjs +52 -11
- package/cli/validators/reference-existence.mjs +4 -2
- package/cli/validators/security.mjs +37 -12
- package/cli/validators/spec-registry.mjs +10 -7
- package/cli/validators/todo-tracking.mjs +31 -11
- package/cli/validators/traceability.mjs +29 -4
- package/cli/writers/junit.mjs +3 -3
- package/cli/writers/sarif.mjs +13 -9
- package/docs/configuration.md +12 -1
- package/extensions/spec-kit-docguard/README.md +3 -0
- package/extensions/spec-kit-docguard/commands/review.md +50 -0
- package/extensions/spec-kit-docguard/extension.yml +16 -4
- 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/github-workflows/docguard-autofix.yml +1 -1
- package/extensions/spec-kit-docguard/templates/github-workflows/docguard-guard.yml +1 -1
- package/package.json +2 -1
- package/schemas/docguard-config.schema.json +15 -1
- package/schemas/docguard-evidence.schema.json +12 -0
- package/templates/ci/github-actions.yml +1 -1
- 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
|
|
601
|
-
help: 'docs-canonical/API-REFERENCE.md documents an endpoint absent from the
|
|
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
|
|
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 {
|
|
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(
|
|
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
|
-
|
|
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
|
-
|
|
192
|
-
|
|
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
|
-
|
|
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 {
|
|
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
|
}
|