@chimix/agentscan 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 +475 -0
- package/package.json +56 -0
- package/src/analyze.ts +82 -0
- package/src/checks/budgets.ts +189 -0
- package/src/checks/index.ts +738 -0
- package/src/checks/make.ts +37 -0
- package/src/cli.ts +230 -0
- package/src/commands/check.ts +64 -0
- package/src/commands/explain.ts +64 -0
- package/src/commands/init.ts +42 -0
- package/src/commands/rules.ts +36 -0
- package/src/config/load.ts +26 -0
- package/src/config/schema.ts +63 -0
- package/src/discover/index.ts +888 -0
- package/src/facts/extract.ts +273 -0
- package/src/facts/semver.ts +41 -0
- package/src/facts/types.ts +113 -0
- package/src/report/clipboard.ts +90 -0
- package/src/report/exit-code.ts +36 -0
- package/src/report/json.ts +36 -0
- package/src/report/prompt.ts +102 -0
- package/src/report/safe.ts +0 -0
- package/src/report/score.ts +47 -0
- package/src/report/sort.ts +26 -0
- package/src/report/text.ts +142 -0
- package/src/version.ts +1 -0
|
@@ -0,0 +1,273 @@
|
|
|
1
|
+
import { existsSync, readdirSync, readFileSync } from "node:fs";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
import type { AgentscanConfig } from "../config/schema";
|
|
4
|
+
import { discoverAgentSurface } from "../discover/index";
|
|
5
|
+
import type { ConfigErrorFact, Facts } from "./types";
|
|
6
|
+
|
|
7
|
+
type PackageJson = {
|
|
8
|
+
packageManager?: string;
|
|
9
|
+
dependencies?: Record<string, string>;
|
|
10
|
+
devDependencies?: Record<string, string>;
|
|
11
|
+
scripts?: Record<string, string>;
|
|
12
|
+
};
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* A package.json that will not parse is a config issue like any other — every
|
|
16
|
+
* dependency-based rule would otherwise silently evaluate against an empty
|
|
17
|
+
* project and report nothing wrong.
|
|
18
|
+
*/
|
|
19
|
+
function readPackageJson(
|
|
20
|
+
root: string,
|
|
21
|
+
errors: ConfigErrorFact[],
|
|
22
|
+
): PackageJson {
|
|
23
|
+
const path = join(root, "package.json");
|
|
24
|
+
if (!existsSync(path)) {
|
|
25
|
+
return {};
|
|
26
|
+
}
|
|
27
|
+
let text: string;
|
|
28
|
+
try {
|
|
29
|
+
text = readFileSync(path, "utf8");
|
|
30
|
+
} catch (err) {
|
|
31
|
+
errors.push({
|
|
32
|
+
path,
|
|
33
|
+
kind: "unreadable",
|
|
34
|
+
detail: err instanceof Error ? err.message : String(err),
|
|
35
|
+
});
|
|
36
|
+
return {};
|
|
37
|
+
}
|
|
38
|
+
let raw: unknown;
|
|
39
|
+
try {
|
|
40
|
+
raw = JSON.parse(text) as unknown;
|
|
41
|
+
} catch (err) {
|
|
42
|
+
errors.push({
|
|
43
|
+
path,
|
|
44
|
+
kind: "invalid-json",
|
|
45
|
+
detail: err instanceof Error ? err.message : String(err),
|
|
46
|
+
});
|
|
47
|
+
return {};
|
|
48
|
+
}
|
|
49
|
+
if (raw === null || typeof raw !== "object" || Array.isArray(raw)) {
|
|
50
|
+
errors.push({
|
|
51
|
+
path,
|
|
52
|
+
kind: "unexpected-shape",
|
|
53
|
+
detail: "package.json is not a JSON object",
|
|
54
|
+
});
|
|
55
|
+
return {};
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
const obj = raw as Record<string, unknown>;
|
|
59
|
+
const pkg: PackageJson = {};
|
|
60
|
+
if (typeof obj.packageManager === "string") {
|
|
61
|
+
pkg.packageManager = obj.packageManager;
|
|
62
|
+
}
|
|
63
|
+
for (const field of ["dependencies", "devDependencies", "scripts"] as const) {
|
|
64
|
+
const raw = obj[field];
|
|
65
|
+
const kept = stringEntries(raw);
|
|
66
|
+
if (kept === undefined) {
|
|
67
|
+
// Present but not an object — `"dependencies": "notanobject"` would
|
|
68
|
+
// otherwise be reported as a project with zero dependencies.
|
|
69
|
+
if (raw !== undefined) {
|
|
70
|
+
errors.push({
|
|
71
|
+
path,
|
|
72
|
+
kind: "unexpected-shape",
|
|
73
|
+
detail: `${field} is not an object`,
|
|
74
|
+
});
|
|
75
|
+
}
|
|
76
|
+
continue;
|
|
77
|
+
}
|
|
78
|
+
if (kept.dropped > 0) {
|
|
79
|
+
// Name the field and the count — never the discarded value, which can be
|
|
80
|
+
// a token pasted into a script.
|
|
81
|
+
errors.push({
|
|
82
|
+
path,
|
|
83
|
+
kind: "unexpected-shape",
|
|
84
|
+
detail: `${field}: ${kept.dropped} entr${kept.dropped === 1 ? "y" : "ies"} ignored (value is not a string)`,
|
|
85
|
+
});
|
|
86
|
+
}
|
|
87
|
+
pkg[field] = kept.values;
|
|
88
|
+
}
|
|
89
|
+
return pkg;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* Keep only string-valued entries. The `as PackageJson` assertion this replaces
|
|
94
|
+
* promised `Record<string, string>` and delivered whatever the file held, so a
|
|
95
|
+
* single `null` reached `.includes()` and killed the whole scan.
|
|
96
|
+
*/
|
|
97
|
+
function stringEntries(
|
|
98
|
+
value: unknown,
|
|
99
|
+
): { values: Record<string, string>; dropped: number } | undefined {
|
|
100
|
+
if (value === null || typeof value !== "object" || Array.isArray(value)) {
|
|
101
|
+
return undefined;
|
|
102
|
+
}
|
|
103
|
+
const values: Record<string, string> = {};
|
|
104
|
+
let dropped = 0;
|
|
105
|
+
for (const [key, entry] of Object.entries(value as Record<string, unknown>)) {
|
|
106
|
+
if (typeof entry === "string") {
|
|
107
|
+
values[key] = entry;
|
|
108
|
+
} else {
|
|
109
|
+
dropped += 1;
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
return { values, dropped };
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
function inferPackageManager(
|
|
116
|
+
root: string,
|
|
117
|
+
pkg: PackageJson,
|
|
118
|
+
): Facts["packageManager"] {
|
|
119
|
+
const field = pkg.packageManager;
|
|
120
|
+
if (typeof field === "string" && field.length > 0) {
|
|
121
|
+
const prefix = field.split("@")[0]?.toLowerCase() ?? "";
|
|
122
|
+
if (
|
|
123
|
+
prefix === "bun" ||
|
|
124
|
+
prefix === "npm" ||
|
|
125
|
+
prefix === "pnpm" ||
|
|
126
|
+
prefix === "yarn"
|
|
127
|
+
) {
|
|
128
|
+
return prefix;
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
if (existsSync(join(root, "bun.lock")) || existsSync(join(root, "bun.lockb"))) {
|
|
133
|
+
return "bun";
|
|
134
|
+
}
|
|
135
|
+
if (existsSync(join(root, "pnpm-lock.yaml"))) {
|
|
136
|
+
return "pnpm";
|
|
137
|
+
}
|
|
138
|
+
if (existsSync(join(root, "yarn.lock"))) {
|
|
139
|
+
return "yarn";
|
|
140
|
+
}
|
|
141
|
+
if (existsSync(join(root, "package-lock.json"))) {
|
|
142
|
+
return "npm";
|
|
143
|
+
}
|
|
144
|
+
return "unknown";
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
function hasNextConfig(root: string): boolean {
|
|
148
|
+
try {
|
|
149
|
+
const entries = readdirSync(root);
|
|
150
|
+
return entries.some(
|
|
151
|
+
(name) =>
|
|
152
|
+
name === "next.config.js" ||
|
|
153
|
+
name === "next.config.mjs" ||
|
|
154
|
+
name === "next.config.cjs" ||
|
|
155
|
+
name === "next.config.ts" ||
|
|
156
|
+
name === "next.config.mts",
|
|
157
|
+
);
|
|
158
|
+
} catch {
|
|
159
|
+
return false;
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
function readNextConfigText(root: string): string | undefined {
|
|
164
|
+
const candidates = [
|
|
165
|
+
"next.config.ts",
|
|
166
|
+
"next.config.mjs",
|
|
167
|
+
"next.config.js",
|
|
168
|
+
"next.config.mts",
|
|
169
|
+
"next.config.cjs",
|
|
170
|
+
];
|
|
171
|
+
for (const name of candidates) {
|
|
172
|
+
const path = join(root, name);
|
|
173
|
+
if (!existsSync(path)) {
|
|
174
|
+
continue;
|
|
175
|
+
}
|
|
176
|
+
try {
|
|
177
|
+
return readFileSync(path, "utf8");
|
|
178
|
+
} catch {
|
|
179
|
+
return undefined;
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
return undefined;
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
function extractConfigs(
|
|
186
|
+
root: string,
|
|
187
|
+
deps: Record<string, string>,
|
|
188
|
+
devDeps: Record<string, string>,
|
|
189
|
+
scripts: Record<string, string>,
|
|
190
|
+
): Facts["configs"] {
|
|
191
|
+
const configs: Facts["configs"] = {};
|
|
192
|
+
|
|
193
|
+
if (existsSync(join(root, "components.json"))) {
|
|
194
|
+
configs.shadcn = true;
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
if (
|
|
198
|
+
existsSync(join(root, "biome.json")) ||
|
|
199
|
+
existsSync(join(root, "biome.jsonc"))
|
|
200
|
+
) {
|
|
201
|
+
configs.biome = true;
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
const allDeps = { ...deps, ...devDeps };
|
|
205
|
+
const hasUltraciteDep = "ultracite" in allDeps;
|
|
206
|
+
const hasUltraciteScript = Object.values(scripts).some((s) =>
|
|
207
|
+
s.includes("ultracite"),
|
|
208
|
+
);
|
|
209
|
+
if (hasUltraciteDep || hasUltraciteScript) {
|
|
210
|
+
configs.ultracite = true;
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
if ("next" in allDeps) {
|
|
214
|
+
const next: NonNullable<Facts["configs"]["next"]> = {};
|
|
215
|
+
if (hasNextConfig(root)) {
|
|
216
|
+
next.appRouter = true;
|
|
217
|
+
const text = readNextConfigText(root);
|
|
218
|
+
if (text !== undefined) {
|
|
219
|
+
// best-effort: cacheComponents: true (or experimental.cacheComponents)
|
|
220
|
+
if (
|
|
221
|
+
/\bcacheComponents\s*:\s*true\b/.test(text) ||
|
|
222
|
+
/\bcacheComponents\s*:\s*!0\b/.test(text)
|
|
223
|
+
) {
|
|
224
|
+
next.cacheComponents = true;
|
|
225
|
+
} else if (
|
|
226
|
+
/\bcacheComponents\s*:\s*false\b/.test(text) ||
|
|
227
|
+
/\bcacheComponents\s*:\s*!1\b/.test(text)
|
|
228
|
+
) {
|
|
229
|
+
next.cacheComponents = false;
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
configs.next = next;
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
return configs;
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
/**
|
|
240
|
+
* Build immutable Facts snapshot from package.json + discovered agent surface.
|
|
241
|
+
* No network. No disk writes.
|
|
242
|
+
*/
|
|
243
|
+
export function extractFacts(
|
|
244
|
+
root: string,
|
|
245
|
+
config: AgentscanConfig,
|
|
246
|
+
opts?: { includeGlobal?: boolean },
|
|
247
|
+
): Facts {
|
|
248
|
+
const includeGlobal = opts?.includeGlobal ?? config.includeGlobal;
|
|
249
|
+
const packageErrors: ConfigErrorFact[] = [];
|
|
250
|
+
const pkg = readPackageJson(root, packageErrors);
|
|
251
|
+
const dependencies = { ...(pkg.dependencies ?? {}) };
|
|
252
|
+
const devDependencies = { ...(pkg.devDependencies ?? {}) };
|
|
253
|
+
const scripts = { ...(pkg.scripts ?? {}) };
|
|
254
|
+
|
|
255
|
+
const surface = discoverAgentSurface(root, config, { includeGlobal });
|
|
256
|
+
|
|
257
|
+
return {
|
|
258
|
+
root,
|
|
259
|
+
packageManager: inferPackageManager(root, pkg),
|
|
260
|
+
dependencies,
|
|
261
|
+
devDependencies,
|
|
262
|
+
scripts,
|
|
263
|
+
configs: extractConfigs(root, dependencies, devDependencies, scripts),
|
|
264
|
+
skills: surface.skills,
|
|
265
|
+
agents: surface.agents,
|
|
266
|
+
hooks: surface.hooks,
|
|
267
|
+
mcp: surface.mcp,
|
|
268
|
+
policyFiles: surface.policyFiles,
|
|
269
|
+
lockedSkills: surface.lockedSkills,
|
|
270
|
+
hasSkillsLock: surface.hasSkillsLock,
|
|
271
|
+
configErrors: [...packageErrors, ...surface.configErrors],
|
|
272
|
+
};
|
|
273
|
+
}
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
/** Strip range prefixes and take the first major.minor.patch token. */
|
|
2
|
+
export function coerceVersion(range: string): string | null {
|
|
3
|
+
const cleaned = range.trim().replace(/^(?:>=|<=|>|<|=|\^|~)/, "");
|
|
4
|
+
const match = cleaned.match(/(\d+)\.(\d+)\.(\d+)/);
|
|
5
|
+
if (!match) {
|
|
6
|
+
return null;
|
|
7
|
+
}
|
|
8
|
+
return `${match[1]}.${match[2]}.${match[3]}`;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
function parseParts(version: string): number[] {
|
|
12
|
+
return version.split(".").map((part) => {
|
|
13
|
+
const n = Number.parseInt(part, 10);
|
|
14
|
+
return Number.isFinite(n) ? n : 0;
|
|
15
|
+
});
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
function compare(a: string, b: string): number {
|
|
19
|
+
const left = parseParts(a);
|
|
20
|
+
const right = parseParts(b);
|
|
21
|
+
const len = Math.max(left.length, right.length);
|
|
22
|
+
for (let i = 0; i < len; i++) {
|
|
23
|
+
const l = left[i] ?? 0;
|
|
24
|
+
const r = right[i] ?? 0;
|
|
25
|
+
if (l > r) {
|
|
26
|
+
return 1;
|
|
27
|
+
}
|
|
28
|
+
if (l < r) {
|
|
29
|
+
return -1;
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
return 0;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export function gte(version: string, min: string): boolean {
|
|
36
|
+
return compare(version, min) >= 0;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export function lt(version: string, max: string): boolean {
|
|
40
|
+
return compare(version, max) < 0;
|
|
41
|
+
}
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
export type Action = "keep" | "delete" | "add" | "refresh" | "warn" | "drift";
|
|
2
|
+
export type Severity = "error" | "warning" | "info";
|
|
3
|
+
|
|
4
|
+
export type SkillFact = {
|
|
5
|
+
id: string;
|
|
6
|
+
path: string;
|
|
7
|
+
description?: string;
|
|
8
|
+
source: "project" | "global";
|
|
9
|
+
/** SKILL.md present in the skill dir. */
|
|
10
|
+
hasSkillMd: boolean;
|
|
11
|
+
/** Frontmatter present and parseable as a `---` block. */
|
|
12
|
+
hasFrontmatter: boolean;
|
|
13
|
+
/** SKILL.md exists but could not be read; frontmatter facts are unknown. */
|
|
14
|
+
unreadable?: boolean;
|
|
15
|
+
/** A `---` block exists but does not parse; its fields are unknown. */
|
|
16
|
+
unparseableFrontmatter?: boolean;
|
|
17
|
+
/** Bundled files the body points at that resolve nowhere. */
|
|
18
|
+
brokenReferences?: string[];
|
|
19
|
+
/** `name:` from frontmatter, when present. */
|
|
20
|
+
frontmatterName?: string;
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
export type McpFact = {
|
|
24
|
+
name: string;
|
|
25
|
+
path: string;
|
|
26
|
+
hasCommand: boolean;
|
|
27
|
+
hasUrl: boolean;
|
|
28
|
+
/** Declared transport, if any: http | sse | ws | stdio. */
|
|
29
|
+
transport?: string;
|
|
30
|
+
/** env values that look like literal secrets rather than ${VAR} refs. */
|
|
31
|
+
literalEnvKeys: string[];
|
|
32
|
+
/** Raw entry text, for secret pattern matching. */
|
|
33
|
+
raw: string;
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
export type HookFact = {
|
|
37
|
+
name: string;
|
|
38
|
+
path: string;
|
|
39
|
+
event?: string;
|
|
40
|
+
command?: string;
|
|
41
|
+
/** Script path parsed out of `command`, resolved against the project root. */
|
|
42
|
+
scriptPath?: string;
|
|
43
|
+
/** false only when scriptPath was extracted and does not exist on disk. */
|
|
44
|
+
scriptExists?: boolean;
|
|
45
|
+
};
|
|
46
|
+
|
|
47
|
+
export type AgentFact = {
|
|
48
|
+
name: string;
|
|
49
|
+
path: string;
|
|
50
|
+
/** Frontmatter present and parseable as a `---` block. */
|
|
51
|
+
hasFrontmatter: boolean;
|
|
52
|
+
/** File exists but could not be read; its fields are unknown, not absent. */
|
|
53
|
+
unreadable?: boolean;
|
|
54
|
+
/** A `---` block exists but does not parse; its fields are unknown. */
|
|
55
|
+
unparseableFrontmatter?: boolean;
|
|
56
|
+
/**
|
|
57
|
+
* `name:` from frontmatter — a display name. Deliberately NOT compared to the
|
|
58
|
+
* filename: 16 of 34 real agent files differ intentionally and nothing keys
|
|
59
|
+
* on the filename. See plans/003 and docs/spec/skills.md.
|
|
60
|
+
*/
|
|
61
|
+
frontmatterName?: string;
|
|
62
|
+
description?: string;
|
|
63
|
+
};
|
|
64
|
+
|
|
65
|
+
/** A config file agentscan could not read — itself a finding, never swallowed. */
|
|
66
|
+
export type ConfigErrorFact = {
|
|
67
|
+
path: string;
|
|
68
|
+
kind: "invalid-json" | "unreadable" | "unexpected-shape";
|
|
69
|
+
detail: string;
|
|
70
|
+
};
|
|
71
|
+
|
|
72
|
+
/** One entry of skills-lock.json. */
|
|
73
|
+
export type LockedSkillFact = {
|
|
74
|
+
id: string;
|
|
75
|
+
source?: string;
|
|
76
|
+
skillPath?: string;
|
|
77
|
+
computedHash?: string;
|
|
78
|
+
};
|
|
79
|
+
|
|
80
|
+
export type Facts = {
|
|
81
|
+
root: string;
|
|
82
|
+
packageManager: "bun" | "npm" | "pnpm" | "yarn" | "unknown";
|
|
83
|
+
dependencies: Record<string, string>;
|
|
84
|
+
devDependencies: Record<string, string>;
|
|
85
|
+
scripts: Record<string, string>;
|
|
86
|
+
configs: {
|
|
87
|
+
next?: { cacheComponents?: boolean; appRouter?: boolean };
|
|
88
|
+
shadcn?: boolean;
|
|
89
|
+
ultracite?: boolean;
|
|
90
|
+
biome?: boolean;
|
|
91
|
+
};
|
|
92
|
+
skills: SkillFact[];
|
|
93
|
+
agents: AgentFact[];
|
|
94
|
+
hooks: HookFact[];
|
|
95
|
+
mcp: McpFact[];
|
|
96
|
+
policyFiles: { path: string; text: string }[];
|
|
97
|
+
/** skills-lock.json entries; empty when the project has no lockfile. */
|
|
98
|
+
lockedSkills: LockedSkillFact[];
|
|
99
|
+
hasSkillsLock: boolean;
|
|
100
|
+
configErrors: ConfigErrorFact[];
|
|
101
|
+
};
|
|
102
|
+
|
|
103
|
+
export type Finding = {
|
|
104
|
+
id: string; // stable: `${ruleId}:${subject}`
|
|
105
|
+
ruleId: string;
|
|
106
|
+
action: Action;
|
|
107
|
+
severity: Severity;
|
|
108
|
+
subject: string; // e.g. skill:next-cache-components
|
|
109
|
+
message: string;
|
|
110
|
+
reason: string;
|
|
111
|
+
evidence: { kind: string; value: string }[];
|
|
112
|
+
suggest?: string;
|
|
113
|
+
};
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Copying the report to the system clipboard.
|
|
3
|
+
*
|
|
4
|
+
* This is the only part of agentscan that starts a subprocess, and it runs only
|
|
5
|
+
* when `--copy` is passed. It writes to the clipboard and nowhere else — never
|
|
6
|
+
* to disk, and never into the project being scanned. Saving and piping do not
|
|
7
|
+
* need code: `agentscan check --output prompt > handoff.md` and
|
|
8
|
+
* `agentscan check --json | jq` already work. The clipboard is the one thing a
|
|
9
|
+
* shell cannot do portably, which is why it is the one thing built here.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
/** Ordered by preference; each entry is argv for a tool that reads stdin. */
|
|
13
|
+
const TOOLS: readonly (readonly string[])[] = [
|
|
14
|
+
["wl-copy"],
|
|
15
|
+
// xclip and xsel write to the PRIMARY selection unless told otherwise, and
|
|
16
|
+
// PRIMARY is not the clipboard a paste reads from.
|
|
17
|
+
["xclip", "-selection", "clipboard"],
|
|
18
|
+
["xsel", "--clipboard", "--input"],
|
|
19
|
+
["pbcopy"],
|
|
20
|
+
// WSL: works with no display server at all, so it is the usual survivor here.
|
|
21
|
+
// Last because it round-trips through Windows and rewrites line endings.
|
|
22
|
+
["clip.exe"],
|
|
23
|
+
];
|
|
24
|
+
|
|
25
|
+
const TIMEOUT_MS = 3000;
|
|
26
|
+
|
|
27
|
+
export type CopyResult =
|
|
28
|
+
| { ok: true; tool: string }
|
|
29
|
+
| { ok: false; reason: string };
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Every clipboard tool present on PATH, in preference order.
|
|
33
|
+
*
|
|
34
|
+
* All of them, not the best one: being installed does not mean being usable.
|
|
35
|
+
* On a headless WSL shell `wl-copy` and `xclip` are both installed and both
|
|
36
|
+
* exit 1 for want of a display while `clip.exe` succeeds, so the caller has to
|
|
37
|
+
* try them in turn.
|
|
38
|
+
*/
|
|
39
|
+
export function clipboardCandidates(
|
|
40
|
+
onPath: (name: string) => boolean = (name) => resolve(name) !== null,
|
|
41
|
+
): string[][] {
|
|
42
|
+
return TOOLS.filter((t) => onPath(t[0] as string)).map((t) => [...t]);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Absolute path to a tool, or null.
|
|
47
|
+
*
|
|
48
|
+
* PATH is read from the environment on every call rather than let Bun.which
|
|
49
|
+
* consult its own copy, so the lookup and the spawn agree on one answer.
|
|
50
|
+
*/
|
|
51
|
+
function resolve(name: string): string | null {
|
|
52
|
+
return Bun.which(name, { PATH: process.env.PATH ?? "" });
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** Try each installed tool until one exits 0. */
|
|
56
|
+
export async function copyToClipboard(text: string): Promise<CopyResult> {
|
|
57
|
+
const candidates = clipboardCandidates();
|
|
58
|
+
if (candidates.length === 0) {
|
|
59
|
+
return {
|
|
60
|
+
ok: false,
|
|
61
|
+
reason: `no clipboard tool found (looked for ${TOOLS.map((t) => t[0]).join(", ")})`,
|
|
62
|
+
};
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
const failures: string[] = [];
|
|
66
|
+
for (const argv of candidates) {
|
|
67
|
+
const tool = argv[0] as string;
|
|
68
|
+
const bin = resolve(tool);
|
|
69
|
+
if (bin === null) {
|
|
70
|
+
continue;
|
|
71
|
+
}
|
|
72
|
+
try {
|
|
73
|
+
const proc = Bun.spawn([bin, ...argv.slice(1)], {
|
|
74
|
+
stdin: new TextEncoder().encode(text),
|
|
75
|
+
stdout: "ignore",
|
|
76
|
+
stderr: "ignore",
|
|
77
|
+
// A clipboard tool that hangs must not hang the scan. wl-copy in
|
|
78
|
+
// particular stays resident to serve the selection it just took.
|
|
79
|
+
signal: AbortSignal.timeout(TIMEOUT_MS),
|
|
80
|
+
});
|
|
81
|
+
if ((await proc.exited) === 0) {
|
|
82
|
+
return { ok: true, tool };
|
|
83
|
+
}
|
|
84
|
+
failures.push(tool);
|
|
85
|
+
} catch {
|
|
86
|
+
failures.push(tool);
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
return { ok: false, reason: `clipboard tool failed: ${failures.join(", ")}` };
|
|
90
|
+
}
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import type { Finding } from "../facts/types";
|
|
2
|
+
import { score } from "./score";
|
|
3
|
+
|
|
4
|
+
export type FailOn = "never" | "warning" | "error";
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* - never → always 0
|
|
8
|
+
* - warning → 1 if any non-keep finding with severity warning|error
|
|
9
|
+
* - error → 1 if any finding with severity error
|
|
10
|
+
*/
|
|
11
|
+
export function exitCode(
|
|
12
|
+
findings: Finding[],
|
|
13
|
+
failOn: FailOn,
|
|
14
|
+
failUnder?: number,
|
|
15
|
+
): 0 | 1 {
|
|
16
|
+
// A score floor and a severity gate answer different questions, so either
|
|
17
|
+
// can fail the run on its own.
|
|
18
|
+
if (failUnder !== undefined && score(findings) < failUnder) {
|
|
19
|
+
return 1;
|
|
20
|
+
}
|
|
21
|
+
if (failOn === "never") {
|
|
22
|
+
return 0;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
if (failOn === "error") {
|
|
26
|
+
return findings.some((f) => f.severity === "error") ? 1 : 0;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
// warning threshold: warning or error, excluding keep
|
|
30
|
+
const hits = findings.some(
|
|
31
|
+
(f) =>
|
|
32
|
+
f.action !== "keep" &&
|
|
33
|
+
(f.severity === "warning" || f.severity === "error"),
|
|
34
|
+
);
|
|
35
|
+
return hits ? 1 : 0;
|
|
36
|
+
}
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import type { Facts, Finding } from "../facts/types";
|
|
2
|
+
import { score } from "./score";
|
|
3
|
+
import { sortFindings } from "./sort";
|
|
4
|
+
|
|
5
|
+
export function renderJson(args: {
|
|
6
|
+
version: string;
|
|
7
|
+
root: string;
|
|
8
|
+
facts: Facts;
|
|
9
|
+
findings: Finding[];
|
|
10
|
+
resolvedFrom?: string;
|
|
11
|
+
}): string {
|
|
12
|
+
const { version, root, facts, findings } = args;
|
|
13
|
+
const depCount =
|
|
14
|
+
Object.keys(facts.dependencies).length +
|
|
15
|
+
Object.keys(facts.devDependencies).length;
|
|
16
|
+
|
|
17
|
+
const payload = {
|
|
18
|
+
version,
|
|
19
|
+
root,
|
|
20
|
+
...(args.resolvedFrom === undefined
|
|
21
|
+
? {}
|
|
22
|
+
: { resolvedFrom: args.resolvedFrom }),
|
|
23
|
+
factsSummary: {
|
|
24
|
+
packageManager: facts.packageManager,
|
|
25
|
+
depCount,
|
|
26
|
+
skillCount: facts.skills.length,
|
|
27
|
+
globalSkillCount: facts.skills.filter((s) => s.source === "global")
|
|
28
|
+
.length,
|
|
29
|
+
},
|
|
30
|
+
// max(0, 100 - 10*errors - 3*warnings); see src/report/score.ts
|
|
31
|
+
score: score(findings),
|
|
32
|
+
findings: sortFindings(findings),
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
return `${JSON.stringify(payload, null, 2)}\n`;
|
|
36
|
+
}
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
import { basename } from "node:path";
|
|
2
|
+
import type { Facts, Finding, Severity } from "../facts/types";
|
|
3
|
+
import { safe } from "./safe";
|
|
4
|
+
import { score } from "./score";
|
|
5
|
+
import { sortFindings } from "./sort";
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* A paste-ready handoff for an agent that will fix the findings.
|
|
9
|
+
*
|
|
10
|
+
* Different from the text report, which is for a person deciding what to care
|
|
11
|
+
* about, and from `--json`, which is for a program. This one is for a model
|
|
12
|
+
* that is about to edit files: it carries the reason (so a judgement call lands
|
|
13
|
+
* the right way), the suggestion, and the finding id (so the honest answer to a
|
|
14
|
+
* false positive is suppression rather than a guess).
|
|
15
|
+
*
|
|
16
|
+
* Info-severity findings are left out on purpose. They are budgets and hygiene
|
|
17
|
+
* notes — things for a maintainer to weigh, not work items. Handing them to an
|
|
18
|
+
* executor invites churn on advice that was never a defect.
|
|
19
|
+
*/
|
|
20
|
+
export function renderPrompt(args: {
|
|
21
|
+
version: string;
|
|
22
|
+
facts: Facts;
|
|
23
|
+
findings: Finding[];
|
|
24
|
+
}): string {
|
|
25
|
+
const { version, facts } = args;
|
|
26
|
+
const actionable = sortFindings(args.findings).filter(
|
|
27
|
+
(f) => f.severity !== "info",
|
|
28
|
+
);
|
|
29
|
+
const project = basename(facts.root) || facts.root;
|
|
30
|
+
|
|
31
|
+
if (actionable.length === 0) {
|
|
32
|
+
return [
|
|
33
|
+
`# agentscan v${version} — ${safe(project)}`,
|
|
34
|
+
"",
|
|
35
|
+
"No findings, score 100/100. The agent configuration in this project is intact;",
|
|
36
|
+
"there is nothing to fix.",
|
|
37
|
+
"",
|
|
38
|
+
].join("\n");
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
const lines: string[] = [
|
|
42
|
+
`# Fix the agent configuration in ${safe(project)}`,
|
|
43
|
+
"",
|
|
44
|
+
`\`agentscan\` v${version} scores this project ${score(args.findings)}/100 and found`,
|
|
45
|
+
`${actionable.length} ${actionable.length === 1 ? "issue" : "issues"} in its agent configuration —`,
|
|
46
|
+
"hooks, MCP servers, skills, agent definitions. Each one below is a config file",
|
|
47
|
+
"saying something that is not true of the filesystem.",
|
|
48
|
+
"",
|
|
49
|
+
"## Work items",
|
|
50
|
+
"",
|
|
51
|
+
];
|
|
52
|
+
|
|
53
|
+
for (const group of ["error", "warning"] as const) {
|
|
54
|
+
const items = actionable.filter((f) => f.severity === group);
|
|
55
|
+
if (items.length === 0) {
|
|
56
|
+
continue;
|
|
57
|
+
}
|
|
58
|
+
lines.push(`### ${headingFor(group)}`, "");
|
|
59
|
+
for (const f of items) {
|
|
60
|
+
lines.push(`- **${safe(f.message)}**`);
|
|
61
|
+
lines.push(` - Why it matters: ${safe(f.reason)}`);
|
|
62
|
+
if (f.suggest !== undefined && f.suggest.length > 0) {
|
|
63
|
+
lines.push(` - Suggested fix: ${safe(f.suggest)}`);
|
|
64
|
+
}
|
|
65
|
+
for (const e of f.evidence) {
|
|
66
|
+
lines.push(` - ${safe(e.kind)}: \`${safe(e.value)}\``);
|
|
67
|
+
}
|
|
68
|
+
lines.push(` - Finding id: \`${safe(f.id)}\``);
|
|
69
|
+
lines.push("");
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
lines.push(
|
|
74
|
+
"## Rules for this work",
|
|
75
|
+
"",
|
|
76
|
+
"- Fix the configuration, not the report. Do not silence a finding by deleting the",
|
|
77
|
+
" file it names unless that is genuinely the right fix — several of these name a",
|
|
78
|
+
" guard hook or a pinned skill that someone wanted.",
|
|
79
|
+
"- If a finding is wrong for this project, do not work around it: add its id to",
|
|
80
|
+
" `ignoreFindings` in `.agentscanrc.json` and say why in the commit.",
|
|
81
|
+
"- Change nothing this list does not mention.",
|
|
82
|
+
"",
|
|
83
|
+
"## Verify",
|
|
84
|
+
"",
|
|
85
|
+
"```bash",
|
|
86
|
+
"agentscan check --fail-on error",
|
|
87
|
+
"```",
|
|
88
|
+
"",
|
|
89
|
+
"Exit 0 means every error-severity item above is resolved. Re-read the report",
|
|
90
|
+
"rather than trusting the exit code alone — a fix that removes the file instead",
|
|
91
|
+
"of repairing it also exits 0.",
|
|
92
|
+
"",
|
|
93
|
+
);
|
|
94
|
+
|
|
95
|
+
return lines.join("\n");
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
function headingFor(severity: Severity): string {
|
|
99
|
+
return severity === "error"
|
|
100
|
+
? "Broken — something configured here does not work"
|
|
101
|
+
: "Wrong — the config and the filesystem disagree";
|
|
102
|
+
}
|
|
Binary file
|