@jqntn/agentdoctor 0.1.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/LICENSE +21 -0
- package/README.md +215 -0
- package/bin/agentdoctor.js +314 -0
- package/docs/agents.md +119 -0
- package/docs/api.md +100 -0
- package/docs/architecture.md +90 -0
- package/docs/baselines.md +56 -0
- package/docs/ci.md +87 -0
- package/docs/configuration.md +115 -0
- package/docs/faq.md +83 -0
- package/docs/getting-started.md +99 -0
- package/docs/output.md +97 -0
- package/docs/policy.md +94 -0
- package/docs/rules.md +463 -0
- package/package.json +71 -0
- package/schemas/policy.schema.json +36 -0
- package/schemas/report.schema.json +58 -0
- package/skills/config-audit/SKILL.md +72 -0
- package/skills/config-audit/references/fix-recipes.md +107 -0
- package/src/adopt.js +178 -0
- package/src/constants.js +139 -0
- package/src/discover.js +235 -0
- package/src/engine.js +218 -0
- package/src/grade.js +39 -0
- package/src/index.js +42 -0
- package/src/links.js +9 -0
- package/src/parse.js +318 -0
- package/src/report/json.js +36 -0
- package/src/report/sarif.js +68 -0
- package/src/report/terminal.js +135 -0
- package/src/rules/correctness.js +849 -0
- package/src/rules/cost.js +282 -0
- package/src/rules/hygiene.js +199 -0
- package/src/rules/index.js +18 -0
- package/src/rules/policy.js +288 -0
- package/src/rules/security.js +690 -0
package/src/parse.js
ADDED
|
@@ -0,0 +1,318 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tolerant JSON parsing that records the source position of every value.
|
|
3
|
+
*
|
|
4
|
+
* Findings are only actionable if they point at a line, so the whole rule
|
|
5
|
+
* engine is built on top of position-aware parses rather than JSON.parse.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
const WHITESPACE = new Set([' ', '\t', '\n', '\r']);
|
|
9
|
+
|
|
10
|
+
class JsonSyntaxError extends Error {
|
|
11
|
+
constructor(message, line, column) {
|
|
12
|
+
super(`${message} (line ${line}, column ${column})`);
|
|
13
|
+
this.name = 'JsonSyntaxError';
|
|
14
|
+
this.line = line;
|
|
15
|
+
this.column = column;
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* @param {string} text
|
|
21
|
+
* @returns {{ value: unknown, positions: Map<string, {line:number, column:number}> }}
|
|
22
|
+
*/
|
|
23
|
+
export function parseJsonWithPositions(text) {
|
|
24
|
+
const positions = new Map();
|
|
25
|
+
let i = 0;
|
|
26
|
+
let line = 1;
|
|
27
|
+
let lineStart = 0;
|
|
28
|
+
|
|
29
|
+
const column = () => i - lineStart + 1;
|
|
30
|
+
const here = () => ({ line, column: column() });
|
|
31
|
+
|
|
32
|
+
const fail = (msg) => {
|
|
33
|
+
throw new JsonSyntaxError(msg, line, column());
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
function advance(n = 1) {
|
|
37
|
+
for (let k = 0; k < n; k += 1) {
|
|
38
|
+
if (text[i] === '\n') {
|
|
39
|
+
line += 1;
|
|
40
|
+
lineStart = i + 1;
|
|
41
|
+
}
|
|
42
|
+
i += 1;
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function skipWhitespace() {
|
|
47
|
+
while (i < text.length) {
|
|
48
|
+
const ch = text[i];
|
|
49
|
+
if (WHITESPACE.has(ch)) {
|
|
50
|
+
advance();
|
|
51
|
+
continue;
|
|
52
|
+
}
|
|
53
|
+
// Comments are invalid JSON but common in hand-edited config; skip them
|
|
54
|
+
// so a stray comment yields real findings instead of one parse error.
|
|
55
|
+
if (ch === '/' && text[i + 1] === '/') {
|
|
56
|
+
while (i < text.length && text[i] !== '\n') advance();
|
|
57
|
+
continue;
|
|
58
|
+
}
|
|
59
|
+
if (ch === '/' && text[i + 1] === '*') {
|
|
60
|
+
advance(2);
|
|
61
|
+
while (i < text.length && !(text[i] === '*' && text[i + 1] === '/')) advance();
|
|
62
|
+
advance(2);
|
|
63
|
+
continue;
|
|
64
|
+
}
|
|
65
|
+
return;
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function parseString() {
|
|
70
|
+
if (text[i] !== '"') fail('Expected a string');
|
|
71
|
+
advance();
|
|
72
|
+
let out = '';
|
|
73
|
+
while (i < text.length && text[i] !== '"') {
|
|
74
|
+
if (text[i] === '\\') {
|
|
75
|
+
advance();
|
|
76
|
+
const esc = text[i];
|
|
77
|
+
if (esc === undefined) fail('Unterminated escape sequence');
|
|
78
|
+
if (esc === 'u') {
|
|
79
|
+
const hex = text.slice(i + 1, i + 5);
|
|
80
|
+
if (!/^[0-9a-fA-F]{4}$/.test(hex)) fail('Invalid unicode escape');
|
|
81
|
+
out += String.fromCharCode(parseInt(hex, 16));
|
|
82
|
+
advance(5);
|
|
83
|
+
continue;
|
|
84
|
+
}
|
|
85
|
+
const simple = { '"': '"', '\\': '\\', '/': '/', b: '\b', f: '\f', n: '\n', r: '\r', t: '\t' };
|
|
86
|
+
if (!(esc in simple)) fail('Invalid escape character \\' + esc);
|
|
87
|
+
out += simple[esc];
|
|
88
|
+
advance();
|
|
89
|
+
continue;
|
|
90
|
+
}
|
|
91
|
+
out += text[i];
|
|
92
|
+
advance();
|
|
93
|
+
}
|
|
94
|
+
if (text[i] !== '"') fail('Unterminated string');
|
|
95
|
+
advance();
|
|
96
|
+
return out;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
function parseNumber() {
|
|
100
|
+
const start = i;
|
|
101
|
+
if (text[i] === '-') advance();
|
|
102
|
+
while (i < text.length && /[0-9]/.test(text[i])) advance();
|
|
103
|
+
if (text[i] === '.') {
|
|
104
|
+
advance();
|
|
105
|
+
while (i < text.length && /[0-9]/.test(text[i])) advance();
|
|
106
|
+
}
|
|
107
|
+
if (text[i] === 'e' || text[i] === 'E') {
|
|
108
|
+
advance();
|
|
109
|
+
if (text[i] === '+' || text[i] === '-') advance();
|
|
110
|
+
while (i < text.length && /[0-9]/.test(text[i])) advance();
|
|
111
|
+
}
|
|
112
|
+
const raw = text.slice(start, i);
|
|
113
|
+
const num = Number(raw);
|
|
114
|
+
if (raw === '' || !Number.isFinite(num)) fail('Invalid number "' + raw + '"');
|
|
115
|
+
return num;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
function parseLiteral() {
|
|
119
|
+
for (const [word, value] of [['true', true], ['false', false], ['null', null]]) {
|
|
120
|
+
if (text.startsWith(word, i)) {
|
|
121
|
+
advance(word.length);
|
|
122
|
+
return value;
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
return fail('Unexpected token "' + (text[i] ?? 'EOF') + '"');
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
function parseValue(path) {
|
|
129
|
+
skipWhitespace();
|
|
130
|
+
positions.set(path, here());
|
|
131
|
+
const ch = text[i];
|
|
132
|
+
if (ch === '{') return parseObject(path);
|
|
133
|
+
if (ch === '[') return parseArray(path);
|
|
134
|
+
if (ch === '"') return parseString();
|
|
135
|
+
if (ch === '-' || (ch >= '0' && ch <= '9')) return parseNumber();
|
|
136
|
+
return parseLiteral();
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
function parseObject(path) {
|
|
140
|
+
advance(); // {
|
|
141
|
+
const out = {};
|
|
142
|
+
skipWhitespace();
|
|
143
|
+
if (text[i] === '}') {
|
|
144
|
+
advance();
|
|
145
|
+
return out;
|
|
146
|
+
}
|
|
147
|
+
for (;;) {
|
|
148
|
+
skipWhitespace();
|
|
149
|
+
const keyPos = here();
|
|
150
|
+
const key = parseString();
|
|
151
|
+
const childPath = path ? path + '.' + key : key;
|
|
152
|
+
positions.set(childPath + ' key', keyPos);
|
|
153
|
+
skipWhitespace();
|
|
154
|
+
if (text[i] !== ':') fail('Expected ":" after key "' + key + '"');
|
|
155
|
+
advance();
|
|
156
|
+
out[key] = parseValue(childPath);
|
|
157
|
+
skipWhitespace();
|
|
158
|
+
if (text[i] === ',') {
|
|
159
|
+
advance();
|
|
160
|
+
skipWhitespace();
|
|
161
|
+
if (text[i] === '}') { advance(); return out; } // tolerate trailing comma
|
|
162
|
+
continue;
|
|
163
|
+
}
|
|
164
|
+
if (text[i] === '}') {
|
|
165
|
+
advance();
|
|
166
|
+
return out;
|
|
167
|
+
}
|
|
168
|
+
fail('Expected "," or "}" in object');
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
function parseArray(path) {
|
|
173
|
+
advance(); // [
|
|
174
|
+
const out = [];
|
|
175
|
+
skipWhitespace();
|
|
176
|
+
if (text[i] === ']') {
|
|
177
|
+
advance();
|
|
178
|
+
return out;
|
|
179
|
+
}
|
|
180
|
+
for (;;) {
|
|
181
|
+
out.push(parseValue(path + '[' + out.length + ']'));
|
|
182
|
+
skipWhitespace();
|
|
183
|
+
if (text[i] === ',') {
|
|
184
|
+
advance();
|
|
185
|
+
skipWhitespace();
|
|
186
|
+
if (text[i] === ']') { advance(); return out; } // tolerate trailing comma
|
|
187
|
+
continue;
|
|
188
|
+
}
|
|
189
|
+
if (text[i] === ']') {
|
|
190
|
+
advance();
|
|
191
|
+
return out;
|
|
192
|
+
}
|
|
193
|
+
fail('Expected "," or "]" in array');
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
skipWhitespace();
|
|
198
|
+
if (i >= text.length) throw new JsonSyntaxError('File is empty', 1, 1);
|
|
199
|
+
const value = parseValue('');
|
|
200
|
+
skipWhitespace();
|
|
201
|
+
if (i < text.length) fail('Unexpected trailing content after top-level value');
|
|
202
|
+
return { value, positions };
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
/**
|
|
206
|
+
* Splits YAML-ish frontmatter off a markdown file. Agent and skill definitions
|
|
207
|
+
* carry their config in frontmatter, so this is the entry point for both.
|
|
208
|
+
*
|
|
209
|
+
* @param {string} text
|
|
210
|
+
* @returns {{ frontmatter: Record<string, unknown>|null, frontmatterLines: number, body: string, raw: string|null }}
|
|
211
|
+
*/
|
|
212
|
+
export function parseFrontmatter(text) {
|
|
213
|
+
const normalized = text.replace(/^\uFEFF/, '');
|
|
214
|
+
if (!/^---\r?\n/.test(normalized)) {
|
|
215
|
+
return { frontmatter: null, frontmatterLines: 0, body: normalized, raw: null };
|
|
216
|
+
}
|
|
217
|
+
const lines = normalized.split(/\r?\n/);
|
|
218
|
+
let end = -1;
|
|
219
|
+
for (let n = 1; n < lines.length; n += 1) {
|
|
220
|
+
if (lines[n].trim() === '---') {
|
|
221
|
+
end = n;
|
|
222
|
+
break;
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
if (end === -1) {
|
|
226
|
+
return { frontmatter: null, frontmatterLines: 0, body: normalized, raw: null };
|
|
227
|
+
}
|
|
228
|
+
const raw = lines.slice(1, end);
|
|
229
|
+
return {
|
|
230
|
+
frontmatter: parseSimpleYaml(raw),
|
|
231
|
+
frontmatterLines: end + 1,
|
|
232
|
+
body: lines.slice(end + 1).join('\n'),
|
|
233
|
+
raw: raw.join('\n'),
|
|
234
|
+
};
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
/**
|
|
238
|
+
* Deliberately small YAML subset: scalars, inline lists, dash lists and one
|
|
239
|
+
* level of nesting. That covers every documented agent/skill frontmatter field
|
|
240
|
+
* without taking on a YAML dependency in a security tool.
|
|
241
|
+
*/
|
|
242
|
+
export function parseSimpleYaml(lines) {
|
|
243
|
+
const root = {};
|
|
244
|
+
const lineOf = {};
|
|
245
|
+
let currentKey = null;
|
|
246
|
+
let listTarget = null;
|
|
247
|
+
let nested = null;
|
|
248
|
+
let nestedIndent = 0;
|
|
249
|
+
|
|
250
|
+
lines.forEach((rawLine, index) => {
|
|
251
|
+
const line = rawLine.replace(/\s+$/, '');
|
|
252
|
+
if (!line.trim() || line.trim().startsWith('#')) return;
|
|
253
|
+
const indent = line.length - line.trimStart().length;
|
|
254
|
+
const trimmed = line.trim();
|
|
255
|
+
|
|
256
|
+
if (trimmed.startsWith('- ')) {
|
|
257
|
+
const item = stripQuotes(trimmed.slice(2).trim());
|
|
258
|
+
if (nested && listTarget && nested[listTarget] !== undefined) {
|
|
259
|
+
if (!Array.isArray(nested[listTarget])) nested[listTarget] = [];
|
|
260
|
+
nested[listTarget].push(item);
|
|
261
|
+
} else if (currentKey) {
|
|
262
|
+
if (!Array.isArray(root[currentKey])) root[currentKey] = [];
|
|
263
|
+
root[currentKey].push(item);
|
|
264
|
+
}
|
|
265
|
+
return;
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
const match = /^([A-Za-z0-9_.-]+)\s*:\s*(.*)$/.exec(trimmed);
|
|
269
|
+
if (!match) return;
|
|
270
|
+
const key = match[1];
|
|
271
|
+
const rest = match[2];
|
|
272
|
+
|
|
273
|
+
if (indent > 0 && nested && indent >= nestedIndent) {
|
|
274
|
+
nested[key] = rest === '' ? {} : coerceScalar(rest);
|
|
275
|
+
listTarget = rest === '' ? key : null;
|
|
276
|
+
return;
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
lineOf[key] = index + 2; // +1 for the opening ---, +1 for 1-based lines
|
|
280
|
+
if (rest === '') {
|
|
281
|
+
root[key] = {};
|
|
282
|
+
nested = root[key];
|
|
283
|
+
nestedIndent = indent + 1;
|
|
284
|
+
currentKey = key;
|
|
285
|
+
listTarget = null;
|
|
286
|
+
return;
|
|
287
|
+
}
|
|
288
|
+
root[key] = coerceScalar(rest);
|
|
289
|
+
nested = null;
|
|
290
|
+
currentKey = key;
|
|
291
|
+
listTarget = null;
|
|
292
|
+
});
|
|
293
|
+
|
|
294
|
+
Object.defineProperty(root, '__lines', { value: lineOf, enumerable: false });
|
|
295
|
+
return root;
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
function stripQuotes(value) {
|
|
299
|
+
if (value.length >= 2 && ((value[0] === '"' && value.at(-1) === '"') || (value[0] === "'" && value.at(-1) === "'"))) {
|
|
300
|
+
return value.slice(1, -1);
|
|
301
|
+
}
|
|
302
|
+
return value;
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
function coerceScalar(rest) {
|
|
306
|
+
const value = stripQuotes(rest.trim());
|
|
307
|
+
if (value.startsWith('[') && value.endsWith(']')) {
|
|
308
|
+
const inner = value.slice(1, -1).trim();
|
|
309
|
+
if (!inner) return [];
|
|
310
|
+
return inner.split(',').map((part) => stripQuotes(part.trim()));
|
|
311
|
+
}
|
|
312
|
+
if (value === 'true') return true;
|
|
313
|
+
if (value === 'false') return false;
|
|
314
|
+
if (value !== '' && !Number.isNaN(Number(value)) && /^-?[0-9.]+$/.test(value)) return Number(value);
|
|
315
|
+
return value;
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
export { JsonSyntaxError };
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import { computeGrade } from '../grade.js';
|
|
2
|
+
|
|
3
|
+
/** Machine-readable report. Stable shape - treat additions as the only change. */
|
|
4
|
+
export function renderJson(input) {
|
|
5
|
+
return JSON.stringify({
|
|
6
|
+
version: 1,
|
|
7
|
+
tool: 'agentdoctor',
|
|
8
|
+
toolVersion: input.version,
|
|
9
|
+
root: input.workspace.root,
|
|
10
|
+
scannedFiles: input.workspace.files.map((f) => ({
|
|
11
|
+
path: f.display, kind: f.kind, scope: f.scope, bytes: f.bytes,
|
|
12
|
+
})),
|
|
13
|
+
skippedFiles: input.workspace.skipped ?? [],
|
|
14
|
+
rulesRun: input.ran,
|
|
15
|
+
suppressed: input.suppressed,
|
|
16
|
+
grade: computeGrade(input.findings),
|
|
17
|
+
summary: {
|
|
18
|
+
error: input.findings.filter((f) => f.severity === 'error').length,
|
|
19
|
+
warning: input.findings.filter((f) => f.severity === 'warning').length,
|
|
20
|
+
info: input.findings.filter((f) => f.severity === 'info').length,
|
|
21
|
+
},
|
|
22
|
+
findings: input.findings.map((f) => ({
|
|
23
|
+
ruleId: f.ruleId,
|
|
24
|
+
severity: f.severity,
|
|
25
|
+
category: f.category,
|
|
26
|
+
message: f.message,
|
|
27
|
+
help: f.help ?? null,
|
|
28
|
+
file: f.display,
|
|
29
|
+
absolutePath: f.file,
|
|
30
|
+
line: f.line,
|
|
31
|
+
column: f.column ?? null,
|
|
32
|
+
configPath: f.configPath ?? null,
|
|
33
|
+
snippet: f.snippet ?? null,
|
|
34
|
+
})),
|
|
35
|
+
}, null, 2);
|
|
36
|
+
}
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
import { relative, isAbsolute } from 'node:path';
|
|
2
|
+
import { allRules } from '../rules/index.js';
|
|
3
|
+
import { REPO_URL } from '../links.js';
|
|
4
|
+
|
|
5
|
+
const SARIF_LEVEL = { error: 'error', warning: 'warning', info: 'note' };
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* SARIF 2.1.0 output, so findings surface as annotations in GitHub code
|
|
9
|
+
* scanning and any other SARIF-aware CI without extra glue.
|
|
10
|
+
*/
|
|
11
|
+
export function renderSarif(input) {
|
|
12
|
+
const usedRuleIds = [...new Set(input.findings.map((f) => f.ruleId))];
|
|
13
|
+
const ruleIndex = new Map();
|
|
14
|
+
const rules = usedRuleIds.map((id, index) => {
|
|
15
|
+
ruleIndex.set(id, index);
|
|
16
|
+
const rule = allRules.find((r) => r.id === id);
|
|
17
|
+
return {
|
|
18
|
+
id,
|
|
19
|
+
name: id.replace(/[^A-Za-z0-9]/g, '_'),
|
|
20
|
+
shortDescription: { text: rule?.title ?? id },
|
|
21
|
+
fullDescription: { text: rule?.help ?? rule?.title ?? id },
|
|
22
|
+
help: { text: rule?.help ?? '' },
|
|
23
|
+
defaultConfiguration: { level: SARIF_LEVEL[rule?.severity ?? 'warning'] ?? 'warning' },
|
|
24
|
+
properties: { category: rule?.category ?? 'other', tags: [rule?.category ?? 'other', 'agentdoctor'] },
|
|
25
|
+
};
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
return JSON.stringify({
|
|
29
|
+
$schema: 'https://raw.githubusercontent.com/oasis-tcs/sarif-spec/master/Schemata/sarif-schema-2.1.0.json',
|
|
30
|
+
version: '2.1.0',
|
|
31
|
+
runs: [{
|
|
32
|
+
tool: {
|
|
33
|
+
driver: {
|
|
34
|
+
name: 'agentdoctor',
|
|
35
|
+
version: input.version,
|
|
36
|
+
informationUri: REPO_URL,
|
|
37
|
+
rules,
|
|
38
|
+
},
|
|
39
|
+
},
|
|
40
|
+
results: input.findings.map((finding) => ({
|
|
41
|
+
ruleId: finding.ruleId,
|
|
42
|
+
ruleIndex: ruleIndex.get(finding.ruleId),
|
|
43
|
+
level: SARIF_LEVEL[finding.severity] ?? 'warning',
|
|
44
|
+
message: { text: finding.help ? `${finding.message} ${finding.help}` : finding.message },
|
|
45
|
+
locations: [{
|
|
46
|
+
physicalLocation: {
|
|
47
|
+
artifactLocation: { uri: toUri(finding.file, input.workspace.root) },
|
|
48
|
+
region: {
|
|
49
|
+
startLine: Math.max(1, finding.line),
|
|
50
|
+
...(finding.column ? { startColumn: finding.column } : {}),
|
|
51
|
+
},
|
|
52
|
+
},
|
|
53
|
+
}],
|
|
54
|
+
partialFingerprints: {
|
|
55
|
+
agentdoctorFingerprint: `${finding.ruleId}:${finding.display}:${finding.configPath ?? finding.line}`,
|
|
56
|
+
},
|
|
57
|
+
})),
|
|
58
|
+
}],
|
|
59
|
+
}, null, 2);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function toUri(absolutePath, root) {
|
|
63
|
+
if (!isAbsolute(absolutePath)) return absolutePath;
|
|
64
|
+
const rel = relative(root, absolutePath);
|
|
65
|
+
// Paths outside the repo (user-scope config) have no meaningful CI location;
|
|
66
|
+
// keep a short suffix rather than leaking an absolute home directory.
|
|
67
|
+
return rel.startsWith('..') ? absolutePath.split('/').slice(-3).join('/') : rel;
|
|
68
|
+
}
|
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
import { SEVERITY_ORDER } from '../engine.js';
|
|
2
|
+
import { computeGrade } from '../grade.js';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Control Sequence Introducer, built from a char code so this source file
|
|
6
|
+
* stays pure ASCII and survives copy-paste through any pipeline.
|
|
7
|
+
*/
|
|
8
|
+
const CSI = String.fromCharCode(27) + '[';
|
|
9
|
+
|
|
10
|
+
/** ANSI helpers that no-op when colour is unwanted. */
|
|
11
|
+
export function makeStyle(enabled) {
|
|
12
|
+
const wrap = (open, close) => (text) => (enabled ? `${CSI}${open}m${text}${CSI}${close}m` : String(text));
|
|
13
|
+
return {
|
|
14
|
+
bold: wrap(1, 22),
|
|
15
|
+
dim: wrap(2, 22),
|
|
16
|
+
red: wrap(31, 39),
|
|
17
|
+
yellow: wrap(33, 39),
|
|
18
|
+
blue: wrap(34, 39),
|
|
19
|
+
cyan: wrap(36, 39),
|
|
20
|
+
green: wrap(32, 39),
|
|
21
|
+
magenta: wrap(35, 39),
|
|
22
|
+
underline: wrap(4, 24),
|
|
23
|
+
};
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export function shouldUseColor(stream = process.stdout, env = process.env) {
|
|
27
|
+
if (env.NO_COLOR !== undefined && env.NO_COLOR !== '') return false;
|
|
28
|
+
if (env.FORCE_COLOR !== undefined && env.FORCE_COLOR !== '0') return true;
|
|
29
|
+
if (env.CI && !env.GITHUB_ACTIONS) return false;
|
|
30
|
+
return Boolean(stream.isTTY);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
const SEVERITY_LABEL = {
|
|
34
|
+
error: (s) => s.red('error'),
|
|
35
|
+
warning: (s) => s.yellow('warn '),
|
|
36
|
+
info: (s) => s.blue('info '),
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Renders a human-readable report.
|
|
41
|
+
*
|
|
42
|
+
* @param {{ findings: any[], workspace: any, ran: string[], suppressed: number,
|
|
43
|
+
* elapsedMs: number, color?: boolean }} input
|
|
44
|
+
* @returns {string}
|
|
45
|
+
*/
|
|
46
|
+
export function renderTerminal(input) {
|
|
47
|
+
const s = makeStyle(input.color !== false);
|
|
48
|
+
const { findings, workspace } = input;
|
|
49
|
+
const out = [];
|
|
50
|
+
|
|
51
|
+
const counts = { error: 0, warning: 0, info: 0 };
|
|
52
|
+
for (const finding of findings) counts[finding.severity] = (counts[finding.severity] ?? 0) + 1;
|
|
53
|
+
|
|
54
|
+
const fileWord = workspace.files.length === 1 ? 'config file' : 'config files';
|
|
55
|
+
out.push('');
|
|
56
|
+
out.push(`${s.bold('agentdoctor')} ${s.dim(`scanned ${workspace.files.length} ${fileWord} in ${workspace.root}`)}`);
|
|
57
|
+
out.push('');
|
|
58
|
+
|
|
59
|
+
if (findings.length === 0) {
|
|
60
|
+
out.push(` ${s.green('OK')} No problems found.`);
|
|
61
|
+
out.push('');
|
|
62
|
+
out.push(summaryLine(s, counts, input));
|
|
63
|
+
return out.join('\n');
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
// Group by file so the reader fixes one file at a time.
|
|
67
|
+
const byFile = new Map();
|
|
68
|
+
for (const finding of findings) {
|
|
69
|
+
if (!byFile.has(finding.display)) byFile.set(finding.display, []);
|
|
70
|
+
byFile.get(finding.display).push(finding);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
const indent = ' '.repeat(17);
|
|
74
|
+
for (const [display, group] of byFile) {
|
|
75
|
+
out.push(s.underline(s.bold(display)));
|
|
76
|
+
group.sort((a, b) => (SEVERITY_ORDER[a.severity] - SEVERITY_ORDER[b.severity]) || (a.line - b.line));
|
|
77
|
+
for (const finding of group) {
|
|
78
|
+
const raw = `${finding.line}${finding.column ? `:${finding.column}` : ''}`;
|
|
79
|
+
const location = s.dim(raw) + ' '.repeat(Math.max(1, 8 - raw.length));
|
|
80
|
+
const label = (SEVERITY_LABEL[finding.severity] ?? SEVERITY_LABEL.info)(s);
|
|
81
|
+
out.push(` ${location}${label} ${finding.message}`);
|
|
82
|
+
if (finding.snippet) out.push(`${indent}${s.dim('|')} ${s.cyan(finding.snippet)}`);
|
|
83
|
+
if (finding.help) {
|
|
84
|
+
for (const line of wrapText(finding.help, 76)) out.push(`${indent}${s.dim(line)}`);
|
|
85
|
+
}
|
|
86
|
+
out.push(`${indent}${s.dim(s.magenta(finding.ruleId))}`);
|
|
87
|
+
out.push('');
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
out.push(summaryLine(s, counts, input));
|
|
92
|
+
return out.join('\n');
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/** Soft-wraps help text so long guidance stays readable in a narrow terminal. */
|
|
96
|
+
function wrapText(text, width) {
|
|
97
|
+
const words = String(text).split(/\s+/);
|
|
98
|
+
const lines = [];
|
|
99
|
+
let current = '';
|
|
100
|
+
for (const word of words) {
|
|
101
|
+
if (current === '') {
|
|
102
|
+
current = word;
|
|
103
|
+
} else if (current.length + 1 + word.length > width) {
|
|
104
|
+
lines.push(current);
|
|
105
|
+
current = word;
|
|
106
|
+
} else {
|
|
107
|
+
current += ` ${word}`;
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
if (current) lines.push(current);
|
|
111
|
+
return lines.map((line, index) => (index === 0 ? `-> ${line}` : ` ${line}`));
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
function summaryLine(s, counts, input) {
|
|
115
|
+
const lines = [];
|
|
116
|
+
const parts = [];
|
|
117
|
+
if (counts.error) parts.push(s.red(`${counts.error} error${counts.error === 1 ? '' : 's'}`));
|
|
118
|
+
if (counts.warning) parts.push(s.yellow(`${counts.warning} warning${counts.warning === 1 ? '' : 's'}`));
|
|
119
|
+
if (counts.info) parts.push(s.blue(`${counts.info} info`));
|
|
120
|
+
const summary = parts.length ? parts.join(', ') : s.green('clean');
|
|
121
|
+
const grade = computeGrade(input.findings);
|
|
122
|
+
const gradeColor = grade.startsWith('A') ? s.green : (grade === 'B' || grade === 'C' ? s.yellow : s.red);
|
|
123
|
+
lines.push(`${s.bold('Summary')} ${s.bold(gradeColor(`Grade ${grade}`))} ${summary} ${s.dim(`- ${input.ran.length} rules in ${input.elapsedMs}ms`)}`);
|
|
124
|
+
|
|
125
|
+
if (input.suppressed > 0) {
|
|
126
|
+
const word = input.suppressed === 1 ? 'finding' : 'findings';
|
|
127
|
+
lines.push(s.dim(` ${input.suppressed} ${word} suppressed by baseline or inline comment`));
|
|
128
|
+
}
|
|
129
|
+
const skipped = input.workspace.skipped ?? [];
|
|
130
|
+
if (skipped.length > 0) {
|
|
131
|
+
lines.push(s.dim(` ${skipped.length} file(s) skipped - credential files are never read`));
|
|
132
|
+
}
|
|
133
|
+
lines.push(s.dim(` Share the grade: agentdoctor --share - gate it in CI: agentdoctor --init-ci`));
|
|
134
|
+
return lines.join('\n');
|
|
135
|
+
}
|