@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/discover.js
ADDED
|
@@ -0,0 +1,235 @@
|
|
|
1
|
+
import { readFileSync, readdirSync, statSync, existsSync } from 'node:fs';
|
|
2
|
+
import { homedir } from 'node:os';
|
|
3
|
+
import { join, relative, basename, sep } from 'node:path';
|
|
4
|
+
import { parseJsonWithPositions, parseFrontmatter, JsonSyntaxError } from './parse.js';
|
|
5
|
+
|
|
6
|
+
/** Directories that never contain agent config worth linting. */
|
|
7
|
+
const SKIP_DIRS = new Set([
|
|
8
|
+
'node_modules', '.git', '.hg', '.svn', 'dist', 'build', 'out', 'target',
|
|
9
|
+
'vendor', '.next', '.nuxt', '.venv', 'venv', '__pycache__', '.cache',
|
|
10
|
+
'coverage', '.turbo', '.gradle', 'Pods', '.terraform',
|
|
11
|
+
]);
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Files that may hold live credentials. agentdoctor never opens these — it can
|
|
15
|
+
* report on their permissions via stat(), but the contents stay unread so the
|
|
16
|
+
* tool can be run safely in CI and on shared machines.
|
|
17
|
+
*/
|
|
18
|
+
const NEVER_READ = new Set(['.credentials.json', 'credentials.json', '.netrc', 'id_rsa', 'id_ed25519']);
|
|
19
|
+
|
|
20
|
+
const MAX_WALK_DEPTH = 6;
|
|
21
|
+
const MAX_FILE_BYTES = 4 * 1024 * 1024;
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* @typedef {Object} ConfigFile
|
|
25
|
+
* @property {string} path absolute path
|
|
26
|
+
* @property {string} display path shown to the user
|
|
27
|
+
* @property {'settings'|'mcp'|'memory'|'agent'|'skill'|'command'|'hook'|'keybindings'} kind
|
|
28
|
+
* @property {'project'|'local'|'user'|'enterprise'} scope
|
|
29
|
+
* @property {string} text
|
|
30
|
+
* @property {unknown} [data]
|
|
31
|
+
* @property {Map<string,{line:number,column:number}>} [positions]
|
|
32
|
+
* @property {Record<string,unknown>|null} [frontmatter]
|
|
33
|
+
* @property {number} [frontmatterLines]
|
|
34
|
+
* @property {string} [body]
|
|
35
|
+
* @property {{message:string,line:number,column:number}} [parseError]
|
|
36
|
+
* @property {number} bytes
|
|
37
|
+
* @property {number} mode
|
|
38
|
+
* @property {number} gid
|
|
39
|
+
* @property {number} uid
|
|
40
|
+
*/
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Collects every agent-configuration file in scope for a run.
|
|
44
|
+
*
|
|
45
|
+
* @param {string} root project root to scan
|
|
46
|
+
* @param {{ includeUserScope?: boolean, home?: string }} [options]
|
|
47
|
+
* @returns {{ root: string, files: ConfigFile[], gitignore: string|null, isGitRepo: boolean, skipped: string[] }}
|
|
48
|
+
*/
|
|
49
|
+
export function discover(root, options = {}) {
|
|
50
|
+
const includeUserScope = options.includeUserScope !== false;
|
|
51
|
+
const home = options.home ?? homedir();
|
|
52
|
+
/** @type {ConfigFile[]} */
|
|
53
|
+
const files = [];
|
|
54
|
+
const skipped = [];
|
|
55
|
+
|
|
56
|
+
const add = (path, kind, scope) => {
|
|
57
|
+
if (!existsSync(path)) return;
|
|
58
|
+
let stats;
|
|
59
|
+
try {
|
|
60
|
+
stats = statSync(path);
|
|
61
|
+
} catch {
|
|
62
|
+
return;
|
|
63
|
+
}
|
|
64
|
+
if (!stats.isFile()) return;
|
|
65
|
+
if (NEVER_READ.has(basename(path))) {
|
|
66
|
+
skipped.push(path);
|
|
67
|
+
return;
|
|
68
|
+
}
|
|
69
|
+
if (stats.size > MAX_FILE_BYTES) {
|
|
70
|
+
skipped.push(path);
|
|
71
|
+
return;
|
|
72
|
+
}
|
|
73
|
+
if (files.some((f) => f.path === path)) return;
|
|
74
|
+
|
|
75
|
+
let text;
|
|
76
|
+
try {
|
|
77
|
+
text = readFileSync(path, 'utf8');
|
|
78
|
+
} catch (error) {
|
|
79
|
+
skipped.push(path);
|
|
80
|
+
return;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/** @type {ConfigFile} */
|
|
84
|
+
const file = {
|
|
85
|
+
path,
|
|
86
|
+
display: displayPath(path, root, home),
|
|
87
|
+
kind,
|
|
88
|
+
scope,
|
|
89
|
+
text,
|
|
90
|
+
bytes: stats.size,
|
|
91
|
+
mode: stats.mode,
|
|
92
|
+
gid: stats.gid,
|
|
93
|
+
uid: stats.uid,
|
|
94
|
+
};
|
|
95
|
+
|
|
96
|
+
if (kind === 'settings' || kind === 'mcp' || kind === 'keybindings') {
|
|
97
|
+
try {
|
|
98
|
+
const parsed = parseJsonWithPositions(text);
|
|
99
|
+
file.data = parsed.value;
|
|
100
|
+
file.positions = parsed.positions;
|
|
101
|
+
} catch (error) {
|
|
102
|
+
if (error instanceof JsonSyntaxError) {
|
|
103
|
+
file.parseError = { message: error.message, line: error.line, column: error.column };
|
|
104
|
+
} else {
|
|
105
|
+
file.parseError = { message: String(error.message ?? error), line: 1, column: 1 };
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
} else if (kind === 'agent' || kind === 'skill' || kind === 'command') {
|
|
109
|
+
const fm = parseFrontmatter(text);
|
|
110
|
+
file.frontmatter = fm.frontmatter;
|
|
111
|
+
file.frontmatterLines = fm.frontmatterLines;
|
|
112
|
+
file.body = fm.body;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
files.push(file);
|
|
116
|
+
};
|
|
117
|
+
|
|
118
|
+
// --- project scope -------------------------------------------------------
|
|
119
|
+
add(join(root, '.claude', 'settings.json'), 'settings', 'project');
|
|
120
|
+
add(join(root, '.claude', 'settings.local.json'), 'settings', 'local');
|
|
121
|
+
add(join(root, '.mcp.json'), 'mcp', 'project');
|
|
122
|
+
add(join(root, '.claude', 'keybindings.json'), 'keybindings', 'project');
|
|
123
|
+
addDirectory(join(root, '.claude', 'agents'), 'agent', 'project', add, '.md');
|
|
124
|
+
addDirectory(join(root, '.claude', 'commands'), 'command', 'project', add, '.md');
|
|
125
|
+
addSkills(join(root, '.claude', 'skills'), 'project', add);
|
|
126
|
+
addDirectory(join(root, '.claude', 'hooks'), 'hook', 'project', add, null);
|
|
127
|
+
|
|
128
|
+
for (const memory of findMemoryFiles(root)) {
|
|
129
|
+
add(memory, 'memory', memory.includes('.local.md') ? 'local' : 'project');
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
// --- user scope ----------------------------------------------------------
|
|
133
|
+
if (includeUserScope) {
|
|
134
|
+
const userClaude = join(home, '.claude');
|
|
135
|
+
add(join(userClaude, 'settings.json'), 'settings', 'user');
|
|
136
|
+
add(join(userClaude, 'keybindings.json'), 'keybindings', 'user');
|
|
137
|
+
add(join(userClaude, 'CLAUDE.md'), 'memory', 'user');
|
|
138
|
+
addDirectory(join(userClaude, 'agents'), 'agent', 'user', add, '.md');
|
|
139
|
+
addDirectory(join(userClaude, 'commands'), 'command', 'user', add, '.md');
|
|
140
|
+
addSkills(join(userClaude, 'skills'), 'user', add);
|
|
141
|
+
addDirectory(join(userClaude, 'hooks'), 'hook', 'user', add, null);
|
|
142
|
+
// Credential file is intentionally recorded as skipped, never read.
|
|
143
|
+
const cred = join(userClaude, '.credentials.json');
|
|
144
|
+
if (existsSync(cred)) skipped.push(cred);
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
let gitignore = null;
|
|
148
|
+
const gitignorePath = join(root, '.gitignore');
|
|
149
|
+
if (existsSync(gitignorePath)) {
|
|
150
|
+
try {
|
|
151
|
+
gitignore = readFileSync(gitignorePath, 'utf8');
|
|
152
|
+
} catch {
|
|
153
|
+
gitignore = null;
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
return {
|
|
158
|
+
root,
|
|
159
|
+
files,
|
|
160
|
+
gitignore,
|
|
161
|
+
isGitRepo: existsSync(join(root, '.git')),
|
|
162
|
+
skipped,
|
|
163
|
+
};
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
function addDirectory(dir, kind, scope, add, extension) {
|
|
167
|
+
if (!existsSync(dir)) return;
|
|
168
|
+
let entries;
|
|
169
|
+
try {
|
|
170
|
+
entries = readdirSync(dir, { withFileTypes: true });
|
|
171
|
+
} catch {
|
|
172
|
+
return;
|
|
173
|
+
}
|
|
174
|
+
for (const entry of entries) {
|
|
175
|
+
if (entry.isDirectory()) {
|
|
176
|
+
// Namespaced subdirectories are supported for agents and commands.
|
|
177
|
+
if (kind === 'agent' || kind === 'command') {
|
|
178
|
+
addDirectory(join(dir, entry.name), kind, scope, add, extension);
|
|
179
|
+
}
|
|
180
|
+
continue;
|
|
181
|
+
}
|
|
182
|
+
if (extension && !entry.name.endsWith(extension)) continue;
|
|
183
|
+
add(join(dir, entry.name), kind, scope);
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
function addSkills(dir, scope, add) {
|
|
188
|
+
if (!existsSync(dir)) return;
|
|
189
|
+
let entries;
|
|
190
|
+
try {
|
|
191
|
+
entries = readdirSync(dir, { withFileTypes: true });
|
|
192
|
+
} catch {
|
|
193
|
+
return;
|
|
194
|
+
}
|
|
195
|
+
for (const entry of entries) {
|
|
196
|
+
if (!entry.isDirectory()) continue;
|
|
197
|
+
add(join(dir, entry.name, 'SKILL.md'), 'skill', scope);
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
/** Walks the project for CLAUDE.md files, which can live at any depth. */
|
|
202
|
+
function findMemoryFiles(root, depth = 0) {
|
|
203
|
+
const found = [];
|
|
204
|
+
if (depth > MAX_WALK_DEPTH) return found;
|
|
205
|
+
let entries;
|
|
206
|
+
try {
|
|
207
|
+
entries = readdirSync(root, { withFileTypes: true });
|
|
208
|
+
} catch {
|
|
209
|
+
return found;
|
|
210
|
+
}
|
|
211
|
+
for (const entry of entries) {
|
|
212
|
+
if (entry.isDirectory()) {
|
|
213
|
+
if (SKIP_DIRS.has(entry.name)) continue;
|
|
214
|
+
if (entry.name.startsWith('.') && entry.name !== '.claude') continue;
|
|
215
|
+
found.push(...findMemoryFiles(join(root, entry.name), depth + 1));
|
|
216
|
+
continue;
|
|
217
|
+
}
|
|
218
|
+
if (entry.name === 'CLAUDE.md' || entry.name === 'CLAUDE.local.md' || entry.name === 'AGENTS.md') {
|
|
219
|
+
found.push(join(root, entry.name));
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
return found;
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
function displayPath(path, root, home) {
|
|
226
|
+
// Always forward slashes: display paths end up in JSON, in SARIF (whose
|
|
227
|
+
// artifact URIs must be URI-form) and in baseline fingerprints, so a
|
|
228
|
+
// baseline written on Windows has to match one written on Linux.
|
|
229
|
+
const toPosix = (value) => value.split(sep).join('/');
|
|
230
|
+
if (path.startsWith(root + sep)) return toPosix(relative(root, path));
|
|
231
|
+
if (path.startsWith(home + sep)) return `~/${toPosix(relative(home, path))}`;
|
|
232
|
+
return toPosix(path);
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
export { NEVER_READ, SKIP_DIRS };
|
package/src/engine.js
ADDED
|
@@ -0,0 +1,218 @@
|
|
|
1
|
+
import { createHash } from 'node:crypto';
|
|
2
|
+
import { allRules } from './rules/index.js';
|
|
3
|
+
import { ISSUES_URL } from './links.js';
|
|
4
|
+
|
|
5
|
+
export const SEVERITY_ORDER = { error: 0, warning: 1, info: 2 };
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* @typedef {Object} Finding
|
|
9
|
+
* @property {string} ruleId
|
|
10
|
+
* @property {'error'|'warning'|'info'} severity
|
|
11
|
+
* @property {string} category
|
|
12
|
+
* @property {string} message
|
|
13
|
+
* @property {string} [help]
|
|
14
|
+
* @property {string} file absolute path
|
|
15
|
+
* @property {string} display path shown to the user
|
|
16
|
+
* @property {number} line
|
|
17
|
+
* @property {number} [column]
|
|
18
|
+
* @property {string} [configPath]
|
|
19
|
+
* @property {string} [snippet]
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Runs every enabled rule over a discovered workspace.
|
|
24
|
+
*
|
|
25
|
+
* @param {import('./discover.js').discover extends (...a:any)=>infer R ? R : never} workspace
|
|
26
|
+
* @param {{ rules?: any[], disabled?: Set<string>|string[], minSeverity?: string,
|
|
27
|
+
* severityOverrides?: Record<string,string>,
|
|
28
|
+
* baseline?: Set<string> }} [options]
|
|
29
|
+
* @returns {{ findings: Finding[], ran: string[], suppressed: number }}
|
|
30
|
+
*/
|
|
31
|
+
export function lint(workspace, options = {}) {
|
|
32
|
+
const rules = options.rules ?? allRules;
|
|
33
|
+
const disabled = normalizeSet(options.disabled);
|
|
34
|
+
const severityOverrides = options.severityOverrides ?? {};
|
|
35
|
+
const baseline = options.baseline ?? new Set();
|
|
36
|
+
|
|
37
|
+
/** @type {Finding[]} */
|
|
38
|
+
const findings = [];
|
|
39
|
+
const ran = [];
|
|
40
|
+
let suppressed = 0;
|
|
41
|
+
|
|
42
|
+
const inlineDisables = collectInlineDisables(workspace);
|
|
43
|
+
|
|
44
|
+
for (const rule of rules) {
|
|
45
|
+
if (disabled.has(rule.id) || disabled.has(rule.category)) continue;
|
|
46
|
+
ran.push(rule.id);
|
|
47
|
+
|
|
48
|
+
const report = (finding) => {
|
|
49
|
+
const file = finding.file ?? {};
|
|
50
|
+
const severity = severityOverrides[rule.id] ?? finding.severity ?? rule.severity;
|
|
51
|
+
const entry = {
|
|
52
|
+
ruleId: rule.id,
|
|
53
|
+
severity,
|
|
54
|
+
category: rule.category,
|
|
55
|
+
message: finding.message,
|
|
56
|
+
help: finding.help ?? rule.help,
|
|
57
|
+
file: file.path ?? finding.absolutePath ?? workspace.root,
|
|
58
|
+
display: file.display ?? finding.display ?? '.',
|
|
59
|
+
line: finding.line ?? 1,
|
|
60
|
+
column: finding.column,
|
|
61
|
+
configPath: finding.configPath,
|
|
62
|
+
snippet: finding.snippet,
|
|
63
|
+
};
|
|
64
|
+
|
|
65
|
+
if (isInlineDisabled(inlineDisables, entry)) {
|
|
66
|
+
suppressed += 1;
|
|
67
|
+
return;
|
|
68
|
+
}
|
|
69
|
+
if (baseline.has(fingerprint(entry))) {
|
|
70
|
+
suppressed += 1;
|
|
71
|
+
return;
|
|
72
|
+
}
|
|
73
|
+
findings.push(entry);
|
|
74
|
+
};
|
|
75
|
+
|
|
76
|
+
try {
|
|
77
|
+
rule.check({ workspace, report, files: workspace.files, helpers });
|
|
78
|
+
} catch (error) {
|
|
79
|
+
findings.push({
|
|
80
|
+
ruleId: 'internal/rule-crashed',
|
|
81
|
+
severity: 'warning',
|
|
82
|
+
category: 'internal',
|
|
83
|
+
message: `Rule "${rule.id}" failed to run: ${error.message}`,
|
|
84
|
+
help: `This is a bug in agentdoctor. Please report it with the config that triggered it: ${ISSUES_URL}`,
|
|
85
|
+
file: workspace.root,
|
|
86
|
+
display: '.',
|
|
87
|
+
line: 1,
|
|
88
|
+
});
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
const minSeverity = options.minSeverity ?? 'info';
|
|
93
|
+
const cutoff = SEVERITY_ORDER[minSeverity] ?? 2;
|
|
94
|
+
const filtered = findings.filter((f) => (SEVERITY_ORDER[f.severity] ?? 2) <= cutoff);
|
|
95
|
+
|
|
96
|
+
filtered.sort((a, b) => {
|
|
97
|
+
const bySeverity = (SEVERITY_ORDER[a.severity] ?? 3) - (SEVERITY_ORDER[b.severity] ?? 3);
|
|
98
|
+
if (bySeverity !== 0) return bySeverity;
|
|
99
|
+
if (a.display !== b.display) return a.display.localeCompare(b.display);
|
|
100
|
+
if (a.line !== b.line) return a.line - b.line;
|
|
101
|
+
return a.ruleId.localeCompare(b.ruleId);
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
return { findings: filtered, ran, suppressed };
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* Stable identity for a finding, used by baselines and suppression.
|
|
109
|
+
*
|
|
110
|
+
* Anchored to the config path where one exists, and otherwise to a hash of the
|
|
111
|
+
* finding's own content. Line numbers are deliberately not part of the identity:
|
|
112
|
+
* a baseline that breaks because someone inserted a line further up the file is
|
|
113
|
+
* a baseline people stop trusting.
|
|
114
|
+
*/
|
|
115
|
+
export function fingerprint(finding) {
|
|
116
|
+
return `${finding.ruleId}::${finding.display}::${anchorOf(finding)}`;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* Anchors are chosen most-stable first:
|
|
121
|
+
*
|
|
122
|
+
* 1. the offending value itself, when the rule captured one. Permission rules
|
|
123
|
+
* live in arrays, so `permissions.allow[0]` changes meaning the moment
|
|
124
|
+
* anyone inserts a rule above it — the rule text does not.
|
|
125
|
+
* 2. the config path, for structural findings that have no single value
|
|
126
|
+
* (an empty deny list, a missing required key).
|
|
127
|
+
* 3. the message, for findings about a whole file.
|
|
128
|
+
*
|
|
129
|
+
* Line numbers are never used: an unrelated edit further up the file must not
|
|
130
|
+
* invalidate an accepted baseline.
|
|
131
|
+
*/
|
|
132
|
+
function anchorOf(finding) {
|
|
133
|
+
if (finding.snippet) return hash(finding.snippet);
|
|
134
|
+
if (finding.configPath) return finding.configPath;
|
|
135
|
+
return hash(finding.message ?? '');
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
const hash = (material) => createHash('sha1').update(String(material)).digest('hex').slice(0, 12);
|
|
139
|
+
|
|
140
|
+
/** Helpers handed to every rule so rule code stays declarative. */
|
|
141
|
+
export const helpers = {
|
|
142
|
+
/** Resolves the source position of a config path within a file. */
|
|
143
|
+
at(file, configPath) {
|
|
144
|
+
const positions = file.positions;
|
|
145
|
+
if (!positions) return { line: 1, column: 1 };
|
|
146
|
+
return positions.get(configPath) ?? positions.get(`${configPath} key`) ?? { line: 1, column: 1 };
|
|
147
|
+
},
|
|
148
|
+
|
|
149
|
+
/** Frontmatter key line number, for markdown-backed config. */
|
|
150
|
+
atFrontmatter(file, key) {
|
|
151
|
+
const lines = file.frontmatter?.__lines;
|
|
152
|
+
if (lines && lines[key]) return { line: lines[key], column: 1 };
|
|
153
|
+
return { line: 1, column: 1 };
|
|
154
|
+
},
|
|
155
|
+
|
|
156
|
+
byKind(files, ...kinds) {
|
|
157
|
+
const wanted = new Set(kinds);
|
|
158
|
+
return files.filter((f) => wanted.has(f.kind));
|
|
159
|
+
},
|
|
160
|
+
|
|
161
|
+
/** Reads a value out of parsed settings by dotted path. */
|
|
162
|
+
get(data, path) {
|
|
163
|
+
return path.split('.').reduce((acc, key) => (acc == null ? undefined : acc[key]), data);
|
|
164
|
+
},
|
|
165
|
+
|
|
166
|
+
/**
|
|
167
|
+
* Splits a permission rule into tool and argument matcher.
|
|
168
|
+
* "Bash(npm run *)" -> { tool: 'Bash', argument: 'npm run *' }
|
|
169
|
+
*/
|
|
170
|
+
parsePermission(rule) {
|
|
171
|
+
if (typeof rule !== 'string') return { tool: null, argument: null, raw: rule };
|
|
172
|
+
const match = /^([A-Za-z_][A-Za-z0-9_-]*)\s*\((.*)\)\s*$/s.exec(rule.trim());
|
|
173
|
+
if (!match) return { tool: rule.trim(), argument: null, raw: rule };
|
|
174
|
+
return { tool: match[1], argument: match[2], raw: rule };
|
|
175
|
+
},
|
|
176
|
+
|
|
177
|
+
/**
|
|
178
|
+
* Rough token estimate. Deliberately conservative and dependency-free: the
|
|
179
|
+
* goal is an order-of-magnitude signal about context cost, not exact billing.
|
|
180
|
+
*/
|
|
181
|
+
estimateTokens(text) {
|
|
182
|
+
if (!text) return 0;
|
|
183
|
+
const words = text.split(/\s+/).filter(Boolean).length;
|
|
184
|
+
const chars = text.length;
|
|
185
|
+
return Math.round(Math.max(chars / 4, words * 1.3));
|
|
186
|
+
},
|
|
187
|
+
};
|
|
188
|
+
|
|
189
|
+
function normalizeSet(value) {
|
|
190
|
+
if (!value) return new Set();
|
|
191
|
+
return value instanceof Set ? value : new Set(value);
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
/**
|
|
195
|
+
* Supports `agentdoctor-disable <rule-id>` comments in markdown and JSON
|
|
196
|
+
* config, scoped to the whole file.
|
|
197
|
+
*/
|
|
198
|
+
function collectInlineDisables(workspace) {
|
|
199
|
+
const map = new Map();
|
|
200
|
+
for (const file of workspace.files) {
|
|
201
|
+
const ids = new Set();
|
|
202
|
+
const pattern = /agentdoctor-disable(?:-file)?\s+([A-Za-z0-9/_,\s-]+)/g;
|
|
203
|
+
let match;
|
|
204
|
+
while ((match = pattern.exec(file.text)) !== null) {
|
|
205
|
+
for (const id of match[1].split(/[,\s]+/)) {
|
|
206
|
+
if (id) ids.add(id.trim());
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
if (ids.size) map.set(file.path, ids);
|
|
210
|
+
}
|
|
211
|
+
return map;
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
function isInlineDisabled(map, finding) {
|
|
215
|
+
const ids = map.get(finding.file);
|
|
216
|
+
if (!ids) return false;
|
|
217
|
+
return ids.has(finding.ruleId) || ids.has(finding.category) || ids.has('all');
|
|
218
|
+
}
|
package/src/grade.js
ADDED
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The health grade: one glanceable letter for a whole config audit.
|
|
3
|
+
*
|
|
4
|
+
* The formula is deliberately simple enough to state in a sentence, because a
|
|
5
|
+
* grade nobody can explain is a grade nobody trusts:
|
|
6
|
+
*
|
|
7
|
+
* A+ zero findings
|
|
8
|
+
* A info only
|
|
9
|
+
* B no errors, 1-2 warnings
|
|
10
|
+
* C no errors, 3+ warnings
|
|
11
|
+
* D 1-2 errors
|
|
12
|
+
* F 3+ errors
|
|
13
|
+
*
|
|
14
|
+
* Grades are computed from the post-filter finding set, so a baseline or
|
|
15
|
+
* suppression that hides a finding also lifts the grade - the grade describes
|
|
16
|
+
* what is actionable today, not history.
|
|
17
|
+
*/
|
|
18
|
+
export function computeGrade(findings) {
|
|
19
|
+
const errors = findings.filter((f) => f.severity === 'error').length;
|
|
20
|
+
const warnings = findings.filter((f) => f.severity === 'warning').length;
|
|
21
|
+
if (errors >= 3) return 'F';
|
|
22
|
+
if (errors >= 1) return 'D';
|
|
23
|
+
if (warnings >= 3) return 'C';
|
|
24
|
+
if (warnings >= 1) return 'B';
|
|
25
|
+
if (findings.length > 0) return 'A';
|
|
26
|
+
return 'A+';
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** Badge color per grade, used by --badge and the share card. */
|
|
30
|
+
export const GRADE_COLORS = {
|
|
31
|
+
'A+': '34D399', A: '34D399', B: 'A3E635', C: 'FBBF24', D: 'FB923C', F: 'F87171',
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
export function gradeSummary(findings) {
|
|
35
|
+
const errors = findings.filter((f) => f.severity === 'error').length;
|
|
36
|
+
const warnings = findings.filter((f) => f.severity === 'warning').length;
|
|
37
|
+
const info = findings.filter((f) => f.severity === 'info').length;
|
|
38
|
+
return { grade: computeGrade(findings), errors, warnings, info };
|
|
39
|
+
}
|
package/src/index.js
ADDED
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import { discover } from './discover.js';
|
|
2
|
+
import { lint, fingerprint, helpers } from './engine.js';
|
|
3
|
+
import { allRules, CATEGORIES } from './rules/index.js';
|
|
4
|
+
import { loadPolicy } from './rules/policy.js';
|
|
5
|
+
|
|
6
|
+
export const VERSION = '0.1.0';
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* One-call entry point: discover config, load any team policy, run every rule.
|
|
10
|
+
*
|
|
11
|
+
* @param {string} root
|
|
12
|
+
* @param {{ includeUserScope?: boolean, policyPath?: string,
|
|
13
|
+
* disabled?: string[], minSeverity?: string, baseline?: Set<string>,
|
|
14
|
+
* only?: string[], home?: string }} [options]
|
|
15
|
+
*/
|
|
16
|
+
export function run(root, options = {}) {
|
|
17
|
+
const started = process.hrtime.bigint();
|
|
18
|
+
const workspace = discover(root, {
|
|
19
|
+
includeUserScope: options.includeUserScope,
|
|
20
|
+
home: options.home,
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
workspace.policy = loadPolicy(root, options.policyPath);
|
|
24
|
+
|
|
25
|
+
let rules = allRules;
|
|
26
|
+
if (options.only && options.only.length > 0) {
|
|
27
|
+
const wanted = new Set(options.only);
|
|
28
|
+
rules = rules.filter((rule) => wanted.has(rule.category) || wanted.has(rule.id));
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
const result = lint(workspace, {
|
|
32
|
+
rules,
|
|
33
|
+
disabled: options.disabled,
|
|
34
|
+
minSeverity: options.minSeverity,
|
|
35
|
+
baseline: options.baseline,
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
const elapsedMs = Number((process.hrtime.bigint() - started) / 1_000_000n);
|
|
39
|
+
return { ...result, workspace, elapsedMs, version: VERSION };
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export { discover, lint, fingerprint, helpers, allRules, CATEGORIES, loadPolicy };
|
package/src/links.js
ADDED
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Every outbound URL the tool prints, in one place.
|
|
3
|
+
*
|
|
4
|
+
* These are placeholders until the repository and sales page exist. Set them
|
|
5
|
+
* once here before publishing; nothing else references a URL directly.
|
|
6
|
+
*/
|
|
7
|
+
export const REPO_URL = 'https://github.com/jqntn/agentdoctor';
|
|
8
|
+
export const ISSUES_URL = 'https://github.com/jqntn/agentdoctor/issues';
|
|
9
|
+
export const BADGE_BASE_URL = 'https://img.shields.io/badge';
|