@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.
@@ -0,0 +1,189 @@
1
+ import { basename } from "node:path";
2
+ import type { Facts, Finding } from "../facts/types";
3
+ import { make } from "./make";
4
+
5
+ type PolicyFile = Facts["policyFiles"][number];
6
+
7
+ /**
8
+ * Budget checks: the config is well-formed, but there is too much of it.
9
+ *
10
+ * These used to be five YAML files evaluated by a 581-line rule engine. Across
11
+ * 21 real projects that engine produced 9 findings from 3 of its 5 rules, while
12
+ * every actionable finding came from the structural checks next door. The
13
+ * engine's one remaining job was letting users write their own rules — a
14
+ * feature nobody had used, on a tool with no users, whose concrete case
15
+ * (retuning a budget) `thresholds` in `.agentscanrc.json` already covered
16
+ * without any YAML. It was deleted; see plans/010.
17
+ *
18
+ * Every finding these emit is byte-identical to what the rules produced,
19
+ * including the `rule:` subjects, which is why the ids look the way they do.
20
+ *
21
+ * All of them are **info**. They are judgement calls about size, sourced to
22
+ * docs/spec/thresholds.md, not defects — a project can sit over any of these
23
+ * budgets deliberately. Nothing here should ever fail a build.
24
+ */
25
+
26
+ type BudgetOptions = {
27
+ agentsMdLines: number;
28
+ claudeMdLines: number;
29
+ agents: number;
30
+ mcp: number;
31
+ };
32
+
33
+ /**
34
+ * Lines the way `wc -l` counts them: a trailing newline terminates the last
35
+ * line, it does not start another one.
36
+ */
37
+ function lineCount(text: string): number {
38
+ return text.length === 0 ? 0 : text.replace(/\r?\n$/, "").split(/\r?\n/).length;
39
+ }
40
+
41
+ /** First policy file with this basename that is over `limit`, if any. */
42
+ function overBudget(
43
+ facts: Facts,
44
+ file: string,
45
+ limit: number,
46
+ ): { policy: PolicyFile; lines: number } | null {
47
+ for (const policy of facts.policyFiles) {
48
+ if (basename(policy.path) !== file) {
49
+ continue;
50
+ }
51
+ const lines = lineCount(policy.text);
52
+ if (lines > limit) {
53
+ return { policy, lines };
54
+ }
55
+ }
56
+ return null;
57
+ }
58
+
59
+ function policyLengthFinding(args: {
60
+ ruleId: string;
61
+ file: string;
62
+ limit: number;
63
+ facts: Facts;
64
+ reason: string;
65
+ suggest: string;
66
+ }): Finding[] {
67
+ const hit = overBudget(args.facts, args.file, args.limit);
68
+ if (hit === null) {
69
+ return [];
70
+ }
71
+ return [
72
+ make(args.ruleId, `rule:${args.ruleId}`, {
73
+ action: "warn",
74
+ severity: "info",
75
+ message: `${args.file} is long (${hit.lines} lines > ${args.limit})`,
76
+ reason: args.reason,
77
+ evidence: [
78
+ { kind: "policy", value: hit.policy.path },
79
+ { kind: "count", value: `lines=${hit.lines}` },
80
+ { kind: "threshold", value: String(args.limit) },
81
+ ],
82
+ suggest: args.suggest,
83
+ }),
84
+ ];
85
+ }
86
+
87
+ function countFinding(args: {
88
+ ruleId: string;
89
+ count: number;
90
+ limit: number;
91
+ message: string;
92
+ countLabel: string;
93
+ reason: string;
94
+ suggest: string;
95
+ }): Finding[] {
96
+ if (args.count <= args.limit) {
97
+ return [];
98
+ }
99
+ return [
100
+ make(args.ruleId, `rule:${args.ruleId}`, {
101
+ action: "warn",
102
+ severity: "info",
103
+ message: args.message,
104
+ reason: args.reason,
105
+ evidence: [
106
+ { kind: "count", value: `${args.countLabel}=${args.count}` },
107
+ { kind: "threshold", value: String(args.limit) },
108
+ ],
109
+ suggest: args.suggest,
110
+ }),
111
+ ];
112
+ }
113
+
114
+ /**
115
+ * The package manager a policy file tells an agent to use, versus the one the
116
+ * project actually declares. Narrow on purpose: it fires only on the exact
117
+ * string `npm install` in a project whose `packageManager` is bun, because a
118
+ * looser match would flag every doc that mentions npm in passing.
119
+ */
120
+ function packageManagerDrift(facts: Facts): Finding[] {
121
+ if (facts.packageManager !== "bun") {
122
+ return [];
123
+ }
124
+ const hits = facts.policyFiles.filter((p) => p.text.includes("npm install"));
125
+ if (hits.length === 0) {
126
+ return [];
127
+ }
128
+ return [
129
+ make("policy.package-manager-drift", "policy:package-manager", {
130
+ action: "drift",
131
+ severity: "info",
132
+ message: "Policy mentions npm install but packageManager is bun",
133
+ reason: "Docs drift vs packageManager field / lockfile convention",
134
+ evidence: [
135
+ { kind: "packageManager", value: facts.packageManager },
136
+ ...hits.map((p) => ({ kind: "policy" as const, value: p.path })),
137
+ ],
138
+ suggest: "Prefer bun install in AGENTS.md / CLAUDE.md",
139
+ }),
140
+ ];
141
+ }
142
+
143
+ export function runBudgets(facts: Facts, options: BudgetOptions): Finding[] {
144
+ return [
145
+ ...policyLengthFinding({
146
+ ruleId: "budget.agents-md",
147
+ file: "AGENTS.md",
148
+ limit: options.agentsMdLines,
149
+ facts,
150
+ suggest:
151
+ "Delete rather than reorganise — keep only what an agent cannot infer from the code. Or set thresholds.agentsMdLines in .agentscanrc.json",
152
+ reason:
153
+ "Measured across 2,500+ repositories: past ~150 lines additional content delivers diminishing returns and raises inference cost 20-23% without improving agent performance. Unnecessary requirements actively hurt, by broadening exploration. A lean file is 40-60 lines of commands and boundaries.",
154
+ }),
155
+ ...policyLengthFinding({
156
+ ruleId: "budget.claude-md",
157
+ file: "CLAUDE.md",
158
+ limit: options.claudeMdLines,
159
+ facts,
160
+ suggest:
161
+ "Keep only what is true every session; move the rest into a skill, which loads on demand. Or set thresholds.claudeMdLines in .agentscanrc.json",
162
+ reason:
163
+ "Frontier models reliably follow roughly 150-200 instructions, and Claude Code's own system prompt already spends about 50 of them. Past that the file is skim-read rather than obeyed. Imports do not help: imported files load at launch too, so splitting organises the text without reducing what competes for attention.",
164
+ }),
165
+ ...countFinding({
166
+ ruleId: "budget.agents",
167
+ count: facts.agents.length,
168
+ limit: options.agents,
169
+ countLabel: "agents",
170
+ message: `Agent roster large (${facts.agents.length} > ${options.agents} definitions)`,
171
+ reason:
172
+ "Guidance converges on three or four specialised agents being the productive ceiling — past that, output drops rather than rises. That is about agents you run, not files on disk, so this counts definitions as a proxy: each one's description is loaded so the main session can choose between them, and a long roster dilutes that choice the same way a long skill list does. See docs/spec/thresholds.md.",
173
+ suggest:
174
+ "Archive definitions you do not dispatch, or set thresholds.agents in .agentscanrc.json",
175
+ }),
176
+ ...countFinding({
177
+ ruleId: "budget.mcp",
178
+ count: facts.mcp.length,
179
+ limit: options.mcp,
180
+ countLabel: "mcp",
181
+ message: `MCP surface above sweet spot (${facts.mcp.length} > ${options.mcp})`,
182
+ reason:
183
+ "What actually degrades an agent is the number of tool definitions in context, not the number of servers: benchmarks show large models near-perfect at ~20 tools and failing outright past ~100, and a typical server carries tens of tools. Server count is the only proxy available without connecting to them, so treat this as a hint to go count the tools, not as a measurement.",
184
+ suggest:
185
+ "Count the tools your servers actually expose; disable unused servers in mcp.json or set thresholds.mcp in .agentscanrc.json",
186
+ }),
187
+ ...packageManagerDrift(facts),
188
+ ];
189
+ }