ai-cost-audit 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/README.md +177 -0
- package/dist/cli.js +1286 -0
- package/package.json +50 -0
package/dist/cli.js
ADDED
|
@@ -0,0 +1,1286 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
// src/cli.ts
|
|
4
|
+
import { writeFile as writeFile2 } from "fs/promises";
|
|
5
|
+
import path8 from "path";
|
|
6
|
+
import { Command } from "commander";
|
|
7
|
+
import pc from "picocolors";
|
|
8
|
+
|
|
9
|
+
// src/config.ts
|
|
10
|
+
import { readFile } from "fs/promises";
|
|
11
|
+
import { existsSync } from "fs";
|
|
12
|
+
import path from "path";
|
|
13
|
+
import { z } from "zod";
|
|
14
|
+
var rangeSchema = z.tuple([z.number().nonnegative(), z.number().nonnegative()]);
|
|
15
|
+
var configSchema = z.object({
|
|
16
|
+
providers: z.array(z.string()).default(["anthropic"]),
|
|
17
|
+
models: z.array(z.string()).default(["claude-opus-4-8", "claude-sonnet-5"]),
|
|
18
|
+
monthlyBudget: z.number().positive().nullable().default(null),
|
|
19
|
+
developers: z.number().int().positive().default(1),
|
|
20
|
+
baselineTokenLimit: z.number().positive().nullable().default(null),
|
|
21
|
+
growthThresholdPct: z.number().positive().default(20),
|
|
22
|
+
requestsPerDay: z.array(z.number().positive()).default([50, 200, 1e3]),
|
|
23
|
+
cache: z.object({
|
|
24
|
+
enabled: z.boolean().default(true),
|
|
25
|
+
requestsPerSession: z.number().int().positive().default(10)
|
|
26
|
+
}).default({}),
|
|
27
|
+
variable: z.object({
|
|
28
|
+
conversationHistory: rangeSchema.default([8e3, 25e3]),
|
|
29
|
+
taskFiles: rangeSchema.default([5e3, 15e3])
|
|
30
|
+
}).default({}),
|
|
31
|
+
calibration: z.record(z.number().positive()).default({}),
|
|
32
|
+
pricingOverrides: z.record(
|
|
33
|
+
z.object({
|
|
34
|
+
inputPerMTok: z.number().nonnegative(),
|
|
35
|
+
outputPerMTok: z.number().nonnegative().optional()
|
|
36
|
+
})
|
|
37
|
+
).default({}),
|
|
38
|
+
mcp: z.object({
|
|
39
|
+
knownSchemaTokens: z.record(z.number().nonnegative()).default({})
|
|
40
|
+
}).default({}),
|
|
41
|
+
duplication: z.object({
|
|
42
|
+
minBlockTokens: z.number().positive().default(40),
|
|
43
|
+
similarityThreshold: z.number().min(0).max(1).default(0.8)
|
|
44
|
+
}).default({}),
|
|
45
|
+
scan: z.object({
|
|
46
|
+
include: z.array(z.string()).default([
|
|
47
|
+
"CLAUDE.md",
|
|
48
|
+
"CLAUDE.local.md",
|
|
49
|
+
"AGENTS.md",
|
|
50
|
+
".github/copilot-instructions.md",
|
|
51
|
+
".github/**/*.instructions.md",
|
|
52
|
+
".claude/**/*",
|
|
53
|
+
".cursor/rules/**/*",
|
|
54
|
+
".cursorrules",
|
|
55
|
+
".mcp.json",
|
|
56
|
+
"mcp.json",
|
|
57
|
+
"prompts/**/*"
|
|
58
|
+
]),
|
|
59
|
+
exclude: z.array(z.string()).default(["**/node_modules/**", "**/dist/**", "**/.git/**"])
|
|
60
|
+
}).default({}),
|
|
61
|
+
refDepth: z.number().int().min(0).max(10).default(3),
|
|
62
|
+
includeGlobal: z.boolean().default(true)
|
|
63
|
+
}).strict();
|
|
64
|
+
var CONFIG_FILENAME = "ai-cost-audit.json";
|
|
65
|
+
async function loadConfig(projectPath, explicitPath) {
|
|
66
|
+
let configPath = null;
|
|
67
|
+
if (explicitPath) {
|
|
68
|
+
configPath = path.resolve(explicitPath);
|
|
69
|
+
if (!existsSync(configPath)) {
|
|
70
|
+
throw new Error(`Config file not found: ${configPath}`);
|
|
71
|
+
}
|
|
72
|
+
} else {
|
|
73
|
+
const candidate = path.join(projectPath, CONFIG_FILENAME);
|
|
74
|
+
if (existsSync(candidate)) configPath = candidate;
|
|
75
|
+
}
|
|
76
|
+
let raw = {};
|
|
77
|
+
if (configPath) {
|
|
78
|
+
try {
|
|
79
|
+
raw = JSON.parse(await readFile(configPath, "utf8"));
|
|
80
|
+
} catch (err) {
|
|
81
|
+
throw new Error(
|
|
82
|
+
`Failed to parse ${configPath}: ${err instanceof Error ? err.message : String(err)}`
|
|
83
|
+
);
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
const parsed = configSchema.safeParse(raw);
|
|
87
|
+
if (!parsed.success) {
|
|
88
|
+
const issues = parsed.error.issues.map((i) => ` ${i.path.join(".") || "(root)"}: ${i.message}`).join("\n");
|
|
89
|
+
throw new Error(`Invalid config${configPath ? ` (${configPath})` : ""}:
|
|
90
|
+
${issues}`);
|
|
91
|
+
}
|
|
92
|
+
return { config: parsed.data, configPath };
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
// src/scan.ts
|
|
96
|
+
import path6 from "path";
|
|
97
|
+
|
|
98
|
+
// src/adapters/claudeCode.ts
|
|
99
|
+
import { readFile as readFile3 } from "fs/promises";
|
|
100
|
+
import { existsSync as existsSync3 } from "fs";
|
|
101
|
+
import os from "os";
|
|
102
|
+
import path3 from "path";
|
|
103
|
+
import fg from "fast-glob";
|
|
104
|
+
|
|
105
|
+
// src/tokenizer.ts
|
|
106
|
+
import { Tiktoken } from "js-tiktoken/lite";
|
|
107
|
+
import o200k_base from "js-tiktoken/ranks/o200k_base";
|
|
108
|
+
|
|
109
|
+
// src/pricing.ts
|
|
110
|
+
var PRICING_AS_OF = "2026-07-01";
|
|
111
|
+
var PRICING_STALE_AFTER_DAYS = 90;
|
|
112
|
+
var PROVIDERS = {
|
|
113
|
+
anthropic: {
|
|
114
|
+
// o200k_base undercounts Claude tokens on typical instruction text;
|
|
115
|
+
// Claude counts are roughly 1.15-1.25x the o200k count.
|
|
116
|
+
calibration: 1.2,
|
|
117
|
+
// Prompt-cache multipliers relative to base input price (5-minute TTL).
|
|
118
|
+
cache: { write: 1.25, read: 0.1 }
|
|
119
|
+
},
|
|
120
|
+
openai: {
|
|
121
|
+
calibration: 1
|
|
122
|
+
// No cache modeling seeded; OpenAI's automatic caching differs — users can
|
|
123
|
+
// approximate via pricingOverrides + a custom provider entry later.
|
|
124
|
+
}
|
|
125
|
+
};
|
|
126
|
+
var MODELS = [
|
|
127
|
+
{
|
|
128
|
+
id: "claude-opus-4-8",
|
|
129
|
+
provider: "anthropic",
|
|
130
|
+
inputPerMTok: 5,
|
|
131
|
+
outputPerMTok: 25
|
|
132
|
+
},
|
|
133
|
+
{
|
|
134
|
+
id: "claude-sonnet-5",
|
|
135
|
+
provider: "anthropic",
|
|
136
|
+
inputPerMTok: 3,
|
|
137
|
+
outputPerMTok: 15,
|
|
138
|
+
note: "intro pricing $2/$10 per MTok through 2026-08-31"
|
|
139
|
+
},
|
|
140
|
+
{
|
|
141
|
+
id: "claude-haiku-4-5",
|
|
142
|
+
provider: "anthropic",
|
|
143
|
+
inputPerMTok: 1,
|
|
144
|
+
outputPerMTok: 5
|
|
145
|
+
},
|
|
146
|
+
{
|
|
147
|
+
id: "gpt",
|
|
148
|
+
provider: "openai",
|
|
149
|
+
inputPerMTok: null,
|
|
150
|
+
outputPerMTok: null,
|
|
151
|
+
note: "set inputPerMTok via config.pricingOverrides.gpt"
|
|
152
|
+
}
|
|
153
|
+
];
|
|
154
|
+
function isPricingStale(now = /* @__PURE__ */ new Date()) {
|
|
155
|
+
const asOf = new Date(PRICING_AS_OF);
|
|
156
|
+
const ageDays = (now.getTime() - asOf.getTime()) / (1e3 * 60 * 60 * 24);
|
|
157
|
+
return ageDays > PRICING_STALE_AFTER_DAYS;
|
|
158
|
+
}
|
|
159
|
+
function resolveModels(cfg) {
|
|
160
|
+
return cfg.models.map((id) => {
|
|
161
|
+
const base = MODELS.find((m) => m.id === id);
|
|
162
|
+
const override = cfg.pricingOverrides[id];
|
|
163
|
+
if (!base && !override) {
|
|
164
|
+
return {
|
|
165
|
+
id,
|
|
166
|
+
provider: "unknown",
|
|
167
|
+
inputPerMTok: null,
|
|
168
|
+
outputPerMTok: null,
|
|
169
|
+
note: "unknown model \u2014 set pricing via config.pricingOverrides"
|
|
170
|
+
};
|
|
171
|
+
}
|
|
172
|
+
return {
|
|
173
|
+
id,
|
|
174
|
+
provider: base?.provider ?? "unknown",
|
|
175
|
+
inputPerMTok: override?.inputPerMTok ?? base?.inputPerMTok ?? null,
|
|
176
|
+
outputPerMTok: override?.outputPerMTok ?? base?.outputPerMTok ?? null,
|
|
177
|
+
note: base?.note
|
|
178
|
+
};
|
|
179
|
+
});
|
|
180
|
+
}
|
|
181
|
+
function resolveProvider(name, cfg) {
|
|
182
|
+
const base = PROVIDERS[name] ?? { calibration: 1 };
|
|
183
|
+
const calibration = cfg.calibration[name] ?? base.calibration;
|
|
184
|
+
return { ...base, calibration };
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
// src/tokenizer.ts
|
|
188
|
+
var encoder = null;
|
|
189
|
+
function getEncoder() {
|
|
190
|
+
if (!encoder) encoder = new Tiktoken(o200k_base);
|
|
191
|
+
return encoder;
|
|
192
|
+
}
|
|
193
|
+
function countRawTokens(text) {
|
|
194
|
+
if (text.length === 0) return 0;
|
|
195
|
+
return getEncoder().encode(text).length;
|
|
196
|
+
}
|
|
197
|
+
function estimateTokens(text, provider, cfg) {
|
|
198
|
+
const raw = countRawTokens(text);
|
|
199
|
+
const { calibration } = resolveProvider(provider, cfg);
|
|
200
|
+
return Math.round(raw * calibration);
|
|
201
|
+
}
|
|
202
|
+
var DISCLOSURE = "Token counts are offline estimates (\xB1~15-20% for Anthropic models). No model API is called.";
|
|
203
|
+
|
|
204
|
+
// src/discovery.ts
|
|
205
|
+
import { readFile as readFile2 } from "fs/promises";
|
|
206
|
+
import { existsSync as existsSync2, statSync } from "fs";
|
|
207
|
+
import path2 from "path";
|
|
208
|
+
var AT_IMPORT_RE = /(?:^|\s)@((?:\.{0,2}\/)?[\w./-]+\.(?:md|txt|json))\b/gm;
|
|
209
|
+
var MD_LINK_RE = /\[[^\]]*\]\((?!https?:\/\/|mailto:|#)([^)\s]+?\.(?:md|txt|json))(?:#[^)]*)?\)/g;
|
|
210
|
+
function extractReferences(text) {
|
|
211
|
+
const refs = /* @__PURE__ */ new Set();
|
|
212
|
+
for (const match of text.matchAll(AT_IMPORT_RE)) {
|
|
213
|
+
refs.add(match[1]);
|
|
214
|
+
}
|
|
215
|
+
for (const match of text.matchAll(MD_LINK_RE)) {
|
|
216
|
+
refs.add(match[1]);
|
|
217
|
+
}
|
|
218
|
+
return [...refs];
|
|
219
|
+
}
|
|
220
|
+
async function followReferences(rootFile, rootText, projectPath, maxDepth) {
|
|
221
|
+
const found = [];
|
|
222
|
+
const visited = /* @__PURE__ */ new Set([path2.resolve(rootFile)]);
|
|
223
|
+
let frontier = [{ file: rootFile, text: rootText }];
|
|
224
|
+
for (let depth = 0; depth < maxDepth && frontier.length > 0; depth++) {
|
|
225
|
+
const next = [];
|
|
226
|
+
for (const { file, text } of frontier) {
|
|
227
|
+
for (const ref of extractReferences(text)) {
|
|
228
|
+
const baseDir = path2.dirname(file);
|
|
229
|
+
const candidates = [path2.resolve(baseDir, ref), path2.resolve(projectPath, ref)];
|
|
230
|
+
const resolved = candidates.find(
|
|
231
|
+
(c) => existsSync2(c) && statSync(c).isFile() && insideProject(c, projectPath)
|
|
232
|
+
);
|
|
233
|
+
if (!resolved || visited.has(resolved)) continue;
|
|
234
|
+
visited.add(resolved);
|
|
235
|
+
try {
|
|
236
|
+
const refText = await readFile2(resolved, "utf8");
|
|
237
|
+
found.push({ absPath: resolved, referencedFrom: file });
|
|
238
|
+
next.push({ file: resolved, text: refText });
|
|
239
|
+
} catch {
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
frontier = next;
|
|
244
|
+
}
|
|
245
|
+
return found;
|
|
246
|
+
}
|
|
247
|
+
function insideProject(absPath, projectPath) {
|
|
248
|
+
const rel = path2.relative(path2.resolve(projectPath), absPath);
|
|
249
|
+
return !rel.startsWith("..") && !path2.isAbsolute(rel);
|
|
250
|
+
}
|
|
251
|
+
function displayPath(absPath, projectPath) {
|
|
252
|
+
const rel = path2.relative(path2.resolve(projectPath), absPath);
|
|
253
|
+
return rel.startsWith("..") ? absPath : rel;
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
// src/adapters/claudeCode.ts
|
|
257
|
+
var claudeCodeAdapter = {
|
|
258
|
+
name: "claude-code",
|
|
259
|
+
async discover(projectPath, cfg) {
|
|
260
|
+
const sources = [];
|
|
261
|
+
const provider = "anthropic";
|
|
262
|
+
const addFile = async (absPath, partial) => {
|
|
263
|
+
if (!existsSync3(absPath)) return null;
|
|
264
|
+
let text;
|
|
265
|
+
try {
|
|
266
|
+
text = await readFile3(absPath, "utf8");
|
|
267
|
+
} catch {
|
|
268
|
+
return null;
|
|
269
|
+
}
|
|
270
|
+
sources.push({
|
|
271
|
+
path: partial.path ?? displayPath(absPath, projectPath),
|
|
272
|
+
adapter: "claude-code",
|
|
273
|
+
kind: partial.kind,
|
|
274
|
+
usage: partial.usage,
|
|
275
|
+
scope: partial.scope,
|
|
276
|
+
tokens: estimateTokens(text, provider, cfg),
|
|
277
|
+
confidence: partial.confidence,
|
|
278
|
+
note: partial.note,
|
|
279
|
+
referencedFrom: partial.referencedFrom,
|
|
280
|
+
text
|
|
281
|
+
});
|
|
282
|
+
return text;
|
|
283
|
+
};
|
|
284
|
+
const rootClaudeMd = path3.join(projectPath, "CLAUDE.md");
|
|
285
|
+
const rootText = await addFile(rootClaudeMd, {
|
|
286
|
+
kind: "repo-instructions",
|
|
287
|
+
usage: "guaranteed",
|
|
288
|
+
scope: "repo",
|
|
289
|
+
confidence: "high"
|
|
290
|
+
});
|
|
291
|
+
await addFile(path3.join(projectPath, "CLAUDE.local.md"), {
|
|
292
|
+
kind: "local-instructions",
|
|
293
|
+
usage: "guaranteed",
|
|
294
|
+
scope: "repo",
|
|
295
|
+
confidence: "high",
|
|
296
|
+
note: "typically gitignored; loads for the local developer only"
|
|
297
|
+
});
|
|
298
|
+
if (rootText) {
|
|
299
|
+
const refs = await followReferences(rootClaudeMd, rootText, projectPath, cfg.refDepth);
|
|
300
|
+
for (const ref of refs) {
|
|
301
|
+
await addFile(ref.absPath, {
|
|
302
|
+
kind: "referenced-doc",
|
|
303
|
+
usage: "guaranteed",
|
|
304
|
+
scope: "repo",
|
|
305
|
+
confidence: "medium",
|
|
306
|
+
note: "pulled in via reference from an instruction file",
|
|
307
|
+
referencedFrom: displayPath(ref.referencedFrom, projectPath)
|
|
308
|
+
});
|
|
309
|
+
}
|
|
310
|
+
}
|
|
311
|
+
if (cfg.includeGlobal) {
|
|
312
|
+
await addFile(path3.join(os.homedir(), ".claude", "CLAUDE.md"), {
|
|
313
|
+
path: "~/.claude/CLAUDE.md",
|
|
314
|
+
kind: "global-instructions",
|
|
315
|
+
usage: "guaranteed",
|
|
316
|
+
scope: "global",
|
|
317
|
+
confidence: "high",
|
|
318
|
+
note: "user-global; reported but not counted toward the CI budget"
|
|
319
|
+
});
|
|
320
|
+
}
|
|
321
|
+
const agentFiles = await fg(".claude/agents/**/*.md", {
|
|
322
|
+
cwd: projectPath,
|
|
323
|
+
absolute: true,
|
|
324
|
+
dot: true
|
|
325
|
+
});
|
|
326
|
+
for (const file of agentFiles.sort()) {
|
|
327
|
+
await addFile(file, {
|
|
328
|
+
kind: "agent",
|
|
329
|
+
usage: "conditional",
|
|
330
|
+
scope: "repo",
|
|
331
|
+
confidence: "medium",
|
|
332
|
+
note: "loads when the agent is invoked; its description line loads every session"
|
|
333
|
+
});
|
|
334
|
+
}
|
|
335
|
+
const skillFiles = await fg(".claude/skills/*/SKILL.md", {
|
|
336
|
+
cwd: projectPath,
|
|
337
|
+
absolute: true,
|
|
338
|
+
dot: true
|
|
339
|
+
});
|
|
340
|
+
for (const file of skillFiles.sort()) {
|
|
341
|
+
let text;
|
|
342
|
+
try {
|
|
343
|
+
text = await readFile3(file, "utf8");
|
|
344
|
+
} catch {
|
|
345
|
+
continue;
|
|
346
|
+
}
|
|
347
|
+
const { frontmatter, body } = splitFrontmatter(text);
|
|
348
|
+
const rel = displayPath(file, projectPath);
|
|
349
|
+
if (frontmatter) {
|
|
350
|
+
sources.push({
|
|
351
|
+
path: `${rel} (description)`,
|
|
352
|
+
adapter: "claude-code",
|
|
353
|
+
kind: "skill-description",
|
|
354
|
+
usage: "guaranteed",
|
|
355
|
+
scope: "repo",
|
|
356
|
+
tokens: estimateTokens(frontmatter, "anthropic", cfg),
|
|
357
|
+
confidence: "high",
|
|
358
|
+
note: "skill metadata loads every session (progressive disclosure)",
|
|
359
|
+
text: frontmatter
|
|
360
|
+
});
|
|
361
|
+
}
|
|
362
|
+
if (body.trim().length > 0) {
|
|
363
|
+
sources.push({
|
|
364
|
+
path: `${rel} (body)`,
|
|
365
|
+
adapter: "claude-code",
|
|
366
|
+
kind: "skill-body",
|
|
367
|
+
usage: "conditional",
|
|
368
|
+
scope: "repo",
|
|
369
|
+
tokens: estimateTokens(body, "anthropic", cfg),
|
|
370
|
+
confidence: "high",
|
|
371
|
+
note: "loads only when the skill is invoked",
|
|
372
|
+
text: body
|
|
373
|
+
});
|
|
374
|
+
}
|
|
375
|
+
}
|
|
376
|
+
const commandFiles = await fg(".claude/commands/**/*.md", {
|
|
377
|
+
cwd: projectPath,
|
|
378
|
+
absolute: true,
|
|
379
|
+
dot: true
|
|
380
|
+
});
|
|
381
|
+
for (const file of commandFiles.sort()) {
|
|
382
|
+
await addFile(file, {
|
|
383
|
+
kind: "command",
|
|
384
|
+
usage: "conditional",
|
|
385
|
+
scope: "repo",
|
|
386
|
+
confidence: "high",
|
|
387
|
+
note: "loads when the command is invoked"
|
|
388
|
+
});
|
|
389
|
+
}
|
|
390
|
+
return sources;
|
|
391
|
+
}
|
|
392
|
+
};
|
|
393
|
+
function splitFrontmatter(text) {
|
|
394
|
+
const match = text.match(/^---\r?\n([\s\S]*?)\r?\n---\r?\n?/);
|
|
395
|
+
if (!match) return { frontmatter: null, body: text };
|
|
396
|
+
return { frontmatter: match[0], body: text.slice(match[0].length) };
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
// src/adapters/genericMcp.ts
|
|
400
|
+
import { readFile as readFile4 } from "fs/promises";
|
|
401
|
+
import { existsSync as existsSync4 } from "fs";
|
|
402
|
+
import path4 from "path";
|
|
403
|
+
var genericMcpAdapter = {
|
|
404
|
+
name: "generic-mcp",
|
|
405
|
+
async discover(projectPath, cfg) {
|
|
406
|
+
const sources = [];
|
|
407
|
+
for (const filename of [".mcp.json", "mcp.json"]) {
|
|
408
|
+
const absPath = path4.join(projectPath, filename);
|
|
409
|
+
if (!existsSync4(absPath)) continue;
|
|
410
|
+
let text;
|
|
411
|
+
try {
|
|
412
|
+
text = await readFile4(absPath, "utf8");
|
|
413
|
+
} catch {
|
|
414
|
+
continue;
|
|
415
|
+
}
|
|
416
|
+
let servers = {};
|
|
417
|
+
try {
|
|
418
|
+
const parsed = JSON.parse(text);
|
|
419
|
+
servers = parsed.mcpServers ?? {};
|
|
420
|
+
} catch {
|
|
421
|
+
sources.push({
|
|
422
|
+
path: filename,
|
|
423
|
+
adapter: "generic-mcp",
|
|
424
|
+
kind: "mcp-config",
|
|
425
|
+
usage: "guaranteed",
|
|
426
|
+
scope: "repo",
|
|
427
|
+
tokens: estimateTokens(text, "anthropic", cfg),
|
|
428
|
+
confidence: "low",
|
|
429
|
+
note: "file is not valid JSON; counted raw",
|
|
430
|
+
text
|
|
431
|
+
});
|
|
432
|
+
continue;
|
|
433
|
+
}
|
|
434
|
+
const serverNames = Object.keys(servers);
|
|
435
|
+
if (serverNames.length === 0) continue;
|
|
436
|
+
for (const name of serverNames) {
|
|
437
|
+
const known = cfg.mcp.knownSchemaTokens[name];
|
|
438
|
+
const configJson = JSON.stringify(servers[name] ?? {}, null, 2);
|
|
439
|
+
if (known !== void 0) {
|
|
440
|
+
sources.push({
|
|
441
|
+
path: `${filename} \u2192 ${name}`,
|
|
442
|
+
adapter: "generic-mcp",
|
|
443
|
+
kind: "mcp-config",
|
|
444
|
+
usage: "guaranteed",
|
|
445
|
+
scope: "repo",
|
|
446
|
+
tokens: known,
|
|
447
|
+
confidence: "medium",
|
|
448
|
+
note: "schema size pinned via config.mcp.knownSchemaTokens",
|
|
449
|
+
text: configJson
|
|
450
|
+
});
|
|
451
|
+
} else {
|
|
452
|
+
sources.push({
|
|
453
|
+
path: `${filename} \u2192 ${name}`,
|
|
454
|
+
adapter: "generic-mcp",
|
|
455
|
+
kind: "mcp-config",
|
|
456
|
+
usage: "guaranteed",
|
|
457
|
+
scope: "repo",
|
|
458
|
+
tokens: estimateTokens(configJson, "anthropic", cfg),
|
|
459
|
+
confidence: "low",
|
|
460
|
+
note: "live tool schemas not measured (requires a running server); actual context load is usually far larger \u2014 pin via config.mcp.knownSchemaTokens",
|
|
461
|
+
text: configJson
|
|
462
|
+
});
|
|
463
|
+
}
|
|
464
|
+
}
|
|
465
|
+
}
|
|
466
|
+
return sources;
|
|
467
|
+
}
|
|
468
|
+
};
|
|
469
|
+
|
|
470
|
+
// src/adapters/instructions.ts
|
|
471
|
+
import { readFile as readFile5 } from "fs/promises";
|
|
472
|
+
import path5 from "path";
|
|
473
|
+
import fg2 from "fast-glob";
|
|
474
|
+
var instructionsAdapter = {
|
|
475
|
+
name: "instructions",
|
|
476
|
+
async discover(projectPath, cfg) {
|
|
477
|
+
const sources = [];
|
|
478
|
+
const add = async (relPath, kind, usage, confidence, note) => {
|
|
479
|
+
let text;
|
|
480
|
+
try {
|
|
481
|
+
text = await readFile5(path5.join(projectPath, relPath), "utf8");
|
|
482
|
+
} catch {
|
|
483
|
+
return;
|
|
484
|
+
}
|
|
485
|
+
sources.push({
|
|
486
|
+
path: relPath,
|
|
487
|
+
adapter: "instructions",
|
|
488
|
+
kind,
|
|
489
|
+
usage,
|
|
490
|
+
scope: "repo",
|
|
491
|
+
tokens: estimateTokens(text, "anthropic", cfg),
|
|
492
|
+
confidence,
|
|
493
|
+
note,
|
|
494
|
+
text
|
|
495
|
+
});
|
|
496
|
+
};
|
|
497
|
+
const glob = (pattern) => fg2(pattern, { cwd: projectPath, dot: true, onlyFiles: true }).then((r) => r.sort());
|
|
498
|
+
for (const file of await glob("AGENTS.md")) {
|
|
499
|
+
await add(file, "repo-instructions", "guaranteed", "high", "loaded by AGENTS.md-aware tools");
|
|
500
|
+
}
|
|
501
|
+
for (const file of await glob(".cursor/rules/**/*.mdc")) {
|
|
502
|
+
await add(
|
|
503
|
+
file,
|
|
504
|
+
"cursor-rules",
|
|
505
|
+
"guaranteed",
|
|
506
|
+
"medium",
|
|
507
|
+
"Cursor rule; may be glob-scoped or manual (frontmatter not parsed in v1)"
|
|
508
|
+
);
|
|
509
|
+
}
|
|
510
|
+
for (const file of await glob(".cursorrules")) {
|
|
511
|
+
await add(
|
|
512
|
+
file,
|
|
513
|
+
"cursor-rules",
|
|
514
|
+
"guaranteed",
|
|
515
|
+
"high",
|
|
516
|
+
"legacy .cursorrules format (Cursor now uses .cursor/rules/*.mdc)"
|
|
517
|
+
);
|
|
518
|
+
}
|
|
519
|
+
for (const file of await glob(".github/copilot-instructions.md")) {
|
|
520
|
+
await add(file, "copilot-instructions", "guaranteed", "high", "loaded on every Copilot chat request");
|
|
521
|
+
}
|
|
522
|
+
for (const file of await glob(".github/**/*.instructions.md")) {
|
|
523
|
+
await add(
|
|
524
|
+
file,
|
|
525
|
+
"copilot-instructions",
|
|
526
|
+
"conditional",
|
|
527
|
+
"medium",
|
|
528
|
+
"path-scoped Copilot instructions; loads when matching files are in play"
|
|
529
|
+
);
|
|
530
|
+
}
|
|
531
|
+
return sources;
|
|
532
|
+
}
|
|
533
|
+
};
|
|
534
|
+
|
|
535
|
+
// src/adapters/index.ts
|
|
536
|
+
var ADAPTERS = [
|
|
537
|
+
claudeCodeAdapter,
|
|
538
|
+
genericMcpAdapter,
|
|
539
|
+
instructionsAdapter
|
|
540
|
+
];
|
|
541
|
+
async function discoverAll(projectPath, cfg) {
|
|
542
|
+
const seen = /* @__PURE__ */ new Set();
|
|
543
|
+
const all = [];
|
|
544
|
+
for (const adapter of ADAPTERS) {
|
|
545
|
+
const sources = await adapter.discover(projectPath, cfg);
|
|
546
|
+
for (const source of sources) {
|
|
547
|
+
if (seen.has(source.path)) continue;
|
|
548
|
+
seen.add(source.path);
|
|
549
|
+
all.push(source);
|
|
550
|
+
}
|
|
551
|
+
}
|
|
552
|
+
return all;
|
|
553
|
+
}
|
|
554
|
+
|
|
555
|
+
// src/analysis/duplication.ts
|
|
556
|
+
import { createHash } from "crypto";
|
|
557
|
+
function splitBlocks(source, minBlockTokens) {
|
|
558
|
+
const parts = source.text.split(/\r?\n(?=#{1,6}\s)|\r?\n\s*\r?\n/).map((p) => p.trim()).filter((p) => p.length > 0);
|
|
559
|
+
const blocks = [];
|
|
560
|
+
for (const text of parts) {
|
|
561
|
+
const normalized = normalize(text);
|
|
562
|
+
if (normalized.length === 0) continue;
|
|
563
|
+
const tokens = countRawTokens(normalized);
|
|
564
|
+
if (tokens < minBlockTokens) continue;
|
|
565
|
+
blocks.push({ sourcePath: source.path, text, normalized, tokens });
|
|
566
|
+
}
|
|
567
|
+
return blocks;
|
|
568
|
+
}
|
|
569
|
+
function normalize(text) {
|
|
570
|
+
return text.toLowerCase().replace(/\s+/g, " ").trim();
|
|
571
|
+
}
|
|
572
|
+
function shingles(normalized, k = 3) {
|
|
573
|
+
const words = normalized.split(" ");
|
|
574
|
+
const result = /* @__PURE__ */ new Set();
|
|
575
|
+
if (words.length <= k) {
|
|
576
|
+
result.add(words.join(" "));
|
|
577
|
+
return result;
|
|
578
|
+
}
|
|
579
|
+
for (let i = 0; i <= words.length - k; i++) {
|
|
580
|
+
result.add(words.slice(i, i + k).join(" "));
|
|
581
|
+
}
|
|
582
|
+
return result;
|
|
583
|
+
}
|
|
584
|
+
function containment(a, b) {
|
|
585
|
+
let intersection = 0;
|
|
586
|
+
const [small, large] = a.size <= b.size ? [a, b] : [b, a];
|
|
587
|
+
for (const item of small) if (large.has(item)) intersection++;
|
|
588
|
+
const minSize = Math.min(a.size, b.size);
|
|
589
|
+
return minSize === 0 ? 0 : intersection / minSize;
|
|
590
|
+
}
|
|
591
|
+
function findDuplicates(sources, cfg) {
|
|
592
|
+
const blocks = sources.flatMap((s) => splitBlocks(s, cfg.duplication.minBlockTokens));
|
|
593
|
+
const groups = [];
|
|
594
|
+
const byHash = /* @__PURE__ */ new Map();
|
|
595
|
+
for (const block of blocks) {
|
|
596
|
+
const hash = createHash("sha256").update(block.normalized).digest("hex");
|
|
597
|
+
const list = byHash.get(hash);
|
|
598
|
+
if (list) list.push(block);
|
|
599
|
+
else byHash.set(hash, [block]);
|
|
600
|
+
}
|
|
601
|
+
const inExactGroup = /* @__PURE__ */ new Set();
|
|
602
|
+
for (const group of byHash.values()) {
|
|
603
|
+
const uniquePaths = [...new Set(group.map((b) => b.sourcePath))];
|
|
604
|
+
if (group.length < 2 || uniquePaths.length < 2) continue;
|
|
605
|
+
for (const b of group) inExactGroup.add(b);
|
|
606
|
+
groups.push({
|
|
607
|
+
sources: uniquePaths,
|
|
608
|
+
redundantTokens: group[0].tokens * (group.length - 1),
|
|
609
|
+
excerpt: excerpt(group[0].text),
|
|
610
|
+
exact: true
|
|
611
|
+
});
|
|
612
|
+
}
|
|
613
|
+
const remaining = blocks.filter((b) => !inExactGroup.has(b));
|
|
614
|
+
const shingleCache = /* @__PURE__ */ new Map();
|
|
615
|
+
const getShingles = (b) => {
|
|
616
|
+
let s = shingleCache.get(b);
|
|
617
|
+
if (!s) {
|
|
618
|
+
s = shingles(b.normalized);
|
|
619
|
+
shingleCache.set(b, s);
|
|
620
|
+
}
|
|
621
|
+
return s;
|
|
622
|
+
};
|
|
623
|
+
const claimed = /* @__PURE__ */ new Set();
|
|
624
|
+
for (let i = 0; i < remaining.length; i++) {
|
|
625
|
+
const a = remaining[i];
|
|
626
|
+
if (claimed.has(a)) continue;
|
|
627
|
+
const cluster = [a];
|
|
628
|
+
for (let j = i + 1; j < remaining.length; j++) {
|
|
629
|
+
const b = remaining[j];
|
|
630
|
+
if (claimed.has(b) || b.sourcePath === a.sourcePath) continue;
|
|
631
|
+
if (containment(getShingles(a), getShingles(b)) >= cfg.duplication.similarityThreshold) {
|
|
632
|
+
cluster.push(b);
|
|
633
|
+
}
|
|
634
|
+
}
|
|
635
|
+
if (cluster.length >= 2) {
|
|
636
|
+
for (const b of cluster) claimed.add(b);
|
|
637
|
+
const minTokens = Math.min(...cluster.map((b) => b.tokens));
|
|
638
|
+
groups.push({
|
|
639
|
+
sources: [...new Set(cluster.map((b) => b.sourcePath))],
|
|
640
|
+
redundantTokens: minTokens * (cluster.length - 1),
|
|
641
|
+
excerpt: excerpt(a.text),
|
|
642
|
+
exact: false
|
|
643
|
+
});
|
|
644
|
+
}
|
|
645
|
+
}
|
|
646
|
+
return groups.sort((a, b) => b.redundantTokens - a.redundantTokens);
|
|
647
|
+
}
|
|
648
|
+
function excerpt(text) {
|
|
649
|
+
const flat = text.replace(/\s+/g, " ").trim();
|
|
650
|
+
return flat.length > 80 ? `${flat.slice(0, 77)}...` : flat;
|
|
651
|
+
}
|
|
652
|
+
|
|
653
|
+
// src/analysis/findings.ts
|
|
654
|
+
var LARGE_SOURCE_PCT = 25;
|
|
655
|
+
var MCP_SHARE_PCT = 30;
|
|
656
|
+
function deriveFindings(sources, duplicates, gatedBaseline, snapshot, cfg) {
|
|
657
|
+
const findings = [];
|
|
658
|
+
for (const group of duplicates) {
|
|
659
|
+
findings.push({
|
|
660
|
+
rule: group.exact ? "duplicate-content" : "near-duplicate-content",
|
|
661
|
+
severity: "warn",
|
|
662
|
+
message: group.exact ? `${group.redundantTokens.toLocaleString()} redundant tokens: identical content in ${group.sources.join(", ")} ("${group.excerpt}")` : `~${group.redundantTokens.toLocaleString()} redundant tokens: nearly identical content in ${group.sources.join(", ")} ("${group.excerpt}")`,
|
|
663
|
+
sources: group.sources,
|
|
664
|
+
tokens: group.redundantTokens
|
|
665
|
+
});
|
|
666
|
+
}
|
|
667
|
+
if (gatedBaseline > 0) {
|
|
668
|
+
for (const s of sources) {
|
|
669
|
+
if (s.scope !== "repo" || s.usage !== "guaranteed") continue;
|
|
670
|
+
const pct = s.tokens / gatedBaseline * 100;
|
|
671
|
+
if (pct > LARGE_SOURCE_PCT) {
|
|
672
|
+
findings.push({
|
|
673
|
+
rule: "large-source",
|
|
674
|
+
severity: "warn",
|
|
675
|
+
message: `${s.path} alone is ${Math.round(pct)}% of the baseline (${s.tokens.toLocaleString()} tokens). Consider splitting into on-demand skills or conditional instructions.`,
|
|
676
|
+
sources: [s.path],
|
|
677
|
+
tokens: s.tokens
|
|
678
|
+
});
|
|
679
|
+
}
|
|
680
|
+
}
|
|
681
|
+
const mcpTokens = sources.filter((s) => s.adapter === "generic-mcp" && s.scope === "repo" && s.usage === "guaranteed").reduce((sum, s) => sum + s.tokens, 0);
|
|
682
|
+
const mcpPct = mcpTokens / gatedBaseline * 100;
|
|
683
|
+
if (mcpPct > MCP_SHARE_PCT) {
|
|
684
|
+
findings.push({
|
|
685
|
+
rule: "mcp-schema-share",
|
|
686
|
+
severity: "info",
|
|
687
|
+
message: `MCP configuration is ${Math.round(mcpPct)}% of the baseline (${mcpTokens.toLocaleString()} tokens, likely an undercount \u2014 live schemas unmeasured). Consider restricting MCP tools by task.`,
|
|
688
|
+
sources: sources.filter((s) => s.adapter === "generic-mcp").map((s) => s.path),
|
|
689
|
+
tokens: mcpTokens
|
|
690
|
+
});
|
|
691
|
+
}
|
|
692
|
+
}
|
|
693
|
+
if (snapshot && snapshot.gatedBaseline > 0) {
|
|
694
|
+
const growthPct = (gatedBaseline - snapshot.gatedBaseline) / snapshot.gatedBaseline * 100;
|
|
695
|
+
if (growthPct > cfg.growthThresholdPct) {
|
|
696
|
+
const causes = topGrowthCauses(sources, snapshot);
|
|
697
|
+
findings.push({
|
|
698
|
+
rule: "baseline-growth",
|
|
699
|
+
severity: "warn",
|
|
700
|
+
message: `Baseline grew ${growthPct.toFixed(0)}% since the last snapshot (${snapshot.gatedBaseline.toLocaleString()} \u2192 ${gatedBaseline.toLocaleString()} tokens).` + (causes.length > 0 ? ` Primary cause: ${causes[0]}` : ""),
|
|
701
|
+
sources: [],
|
|
702
|
+
tokens: gatedBaseline - snapshot.gatedBaseline
|
|
703
|
+
});
|
|
704
|
+
}
|
|
705
|
+
}
|
|
706
|
+
const legacy = sources.filter((s) => s.path === ".cursorrules");
|
|
707
|
+
if (legacy.length > 0) {
|
|
708
|
+
findings.push({
|
|
709
|
+
rule: "legacy-cursorrules",
|
|
710
|
+
severity: "info",
|
|
711
|
+
message: "Legacy .cursorrules file found. Cursor now uses .cursor/rules/*.mdc, which supports glob scoping (loading rules only when relevant).",
|
|
712
|
+
sources: [".cursorrules"]
|
|
713
|
+
});
|
|
714
|
+
}
|
|
715
|
+
const unmeasured = sources.filter(
|
|
716
|
+
(s) => s.adapter === "generic-mcp" && s.confidence === "low"
|
|
717
|
+
);
|
|
718
|
+
if (unmeasured.length > 0) {
|
|
719
|
+
findings.push({
|
|
720
|
+
rule: "unmeasured-mcp",
|
|
721
|
+
severity: "info",
|
|
722
|
+
message: `${unmeasured.length} MCP server(s) counted by config size only \u2014 live tool schemas require a running server and are usually far larger. Pin measured sizes via config.mcp.knownSchemaTokens.`,
|
|
723
|
+
sources: unmeasured.map((s) => s.path)
|
|
724
|
+
});
|
|
725
|
+
}
|
|
726
|
+
const severityRank = { error: 0, warn: 1, info: 2 };
|
|
727
|
+
return findings.sort(
|
|
728
|
+
(a, b) => severityRank[a.severity] - severityRank[b.severity] || (b.tokens ?? 0) - (a.tokens ?? 0)
|
|
729
|
+
);
|
|
730
|
+
}
|
|
731
|
+
function topGrowthCauses(sources, snapshot) {
|
|
732
|
+
const previous = new Map(snapshot.sources.map((s) => [s.path, s.tokens]));
|
|
733
|
+
const current = new Map(
|
|
734
|
+
sources.filter((s) => s.scope === "repo" && s.usage === "guaranteed").map((s) => [s.path, s.tokens])
|
|
735
|
+
);
|
|
736
|
+
const diffs = [];
|
|
737
|
+
for (const [path9, tokens] of current) {
|
|
738
|
+
const prev = previous.get(path9);
|
|
739
|
+
if (prev === void 0) diffs.push({ path: path9, delta: tokens, added: true });
|
|
740
|
+
else if (tokens !== prev) diffs.push({ path: path9, delta: tokens - prev, added: false });
|
|
741
|
+
}
|
|
742
|
+
for (const [path9, tokens] of previous) {
|
|
743
|
+
if (!current.has(path9)) diffs.push({ path: path9, delta: -tokens, added: false });
|
|
744
|
+
}
|
|
745
|
+
return diffs.sort((a, b) => Math.abs(b.delta) - Math.abs(a.delta)).slice(0, 3).map(({ path: path9, delta, added }) => {
|
|
746
|
+
const sign = delta >= 0 ? "+" : "\u2212";
|
|
747
|
+
const amount = `${sign}${Math.abs(delta).toLocaleString()} tokens`;
|
|
748
|
+
if (added) return `Added ${path9} (${amount})`;
|
|
749
|
+
if (delta < 0 && !added) return `${path9} (${amount})`;
|
|
750
|
+
return `${path9} grew (${amount})`;
|
|
751
|
+
});
|
|
752
|
+
}
|
|
753
|
+
|
|
754
|
+
// src/costModel.ts
|
|
755
|
+
function cacheEffectiveMultiplier(write, read, requestsPerSession) {
|
|
756
|
+
const n = Math.max(1, requestsPerSession);
|
|
757
|
+
return (write + read * (n - 1)) / n;
|
|
758
|
+
}
|
|
759
|
+
function cacheFormulaDescription(cfg) {
|
|
760
|
+
const n = cfg.cache.requestsPerSession;
|
|
761
|
+
return `cached cost/request = baseline_tokens x input_price x (write + read x (n-1)) / n, with n = ${n} requests/session (anthropic: write 1.25x, read 0.1x, 5-min TTL)`;
|
|
762
|
+
}
|
|
763
|
+
function computeCosts(baselineTokensByProvider, cfg) {
|
|
764
|
+
return resolveModels(cfg).map((model) => {
|
|
765
|
+
const tokens = baselineTokensByProvider[model.provider] ?? null;
|
|
766
|
+
if (tokens === null || model.inputPerMTok === null) {
|
|
767
|
+
return {
|
|
768
|
+
model: model.id,
|
|
769
|
+
provider: model.provider,
|
|
770
|
+
perRequestUncached: null,
|
|
771
|
+
perRequestCached: null,
|
|
772
|
+
daily: cfg.requestsPerDay.map((r) => ({
|
|
773
|
+
requestsPerDay: r,
|
|
774
|
+
uncached: null,
|
|
775
|
+
cached: null
|
|
776
|
+
})),
|
|
777
|
+
runwayDays: null
|
|
778
|
+
};
|
|
779
|
+
}
|
|
780
|
+
const perRequestUncached = tokens / 1e6 * model.inputPerMTok;
|
|
781
|
+
const provider = resolveProvider(model.provider, cfg);
|
|
782
|
+
let perRequestCached = null;
|
|
783
|
+
if (cfg.cache.enabled && provider.cache) {
|
|
784
|
+
const mult = cacheEffectiveMultiplier(
|
|
785
|
+
provider.cache.write,
|
|
786
|
+
provider.cache.read,
|
|
787
|
+
cfg.cache.requestsPerSession
|
|
788
|
+
);
|
|
789
|
+
perRequestCached = perRequestUncached * mult;
|
|
790
|
+
}
|
|
791
|
+
const daily = cfg.requestsPerDay.map((requestsPerDay) => ({
|
|
792
|
+
requestsPerDay,
|
|
793
|
+
uncached: perRequestUncached * requestsPerDay,
|
|
794
|
+
cached: perRequestCached === null ? null : perRequestCached * requestsPerDay
|
|
795
|
+
}));
|
|
796
|
+
let runwayDays = null;
|
|
797
|
+
if (cfg.monthlyBudget !== null && cfg.requestsPerDay.length > 0) {
|
|
798
|
+
const sorted = [...cfg.requestsPerDay].sort((a, b) => a - b);
|
|
799
|
+
const midRate = sorted[Math.floor(sorted.length / 2)];
|
|
800
|
+
const perDay = (perRequestCached ?? perRequestUncached) * midRate * cfg.developers;
|
|
801
|
+
if (perDay > 0) runwayDays = cfg.monthlyBudget / perDay;
|
|
802
|
+
}
|
|
803
|
+
return {
|
|
804
|
+
model: model.id,
|
|
805
|
+
provider: model.provider,
|
|
806
|
+
perRequestUncached,
|
|
807
|
+
perRequestCached,
|
|
808
|
+
daily,
|
|
809
|
+
runwayDays
|
|
810
|
+
};
|
|
811
|
+
});
|
|
812
|
+
}
|
|
813
|
+
|
|
814
|
+
// src/scan.ts
|
|
815
|
+
var TOOL_NAME = "ai-cost-audit";
|
|
816
|
+
var TOOL_VERSION = "0.1.0";
|
|
817
|
+
async function runScan(projectPath, cfg, snapshot) {
|
|
818
|
+
const sources = await discoverAll(projectPath, cfg);
|
|
819
|
+
const repoGuaranteed = sources.filter((s) => s.scope === "repo" && s.usage === "guaranteed");
|
|
820
|
+
const globalGuaranteed = sources.filter(
|
|
821
|
+
(s) => s.scope === "global" && s.usage === "guaranteed"
|
|
822
|
+
);
|
|
823
|
+
const conditional = sources.filter((s) => s.usage === "conditional");
|
|
824
|
+
const sum = (list) => list.reduce((total, s) => total + s.tokens, 0);
|
|
825
|
+
const gatedBaseline = sum(repoGuaranteed);
|
|
826
|
+
const globalBaseline = sum(globalGuaranteed);
|
|
827
|
+
const guaranteed = gatedBaseline + globalBaseline;
|
|
828
|
+
const byKind = {};
|
|
829
|
+
const byAdapter = {};
|
|
830
|
+
for (const s of sources) {
|
|
831
|
+
byKind[s.kind] = (byKind[s.kind] ?? 0) + s.tokens;
|
|
832
|
+
byAdapter[s.adapter] = (byAdapter[s.adapter] ?? 0) + s.tokens;
|
|
833
|
+
}
|
|
834
|
+
const anthropicCalibration = resolveProvider("anthropic", cfg).calibration;
|
|
835
|
+
const rawBaseline = guaranteed / anthropicCalibration;
|
|
836
|
+
const baselineByProvider = {};
|
|
837
|
+
for (const provider of cfg.providers) {
|
|
838
|
+
baselineByProvider[provider] = Math.round(
|
|
839
|
+
rawBaseline * resolveProvider(provider, cfg).calibration
|
|
840
|
+
);
|
|
841
|
+
}
|
|
842
|
+
const duplicates = findDuplicates(sources, cfg);
|
|
843
|
+
const findings = deriveFindings(sources, duplicates, gatedBaseline, snapshot, cfg);
|
|
844
|
+
const costs = computeCosts(baselineByProvider, cfg);
|
|
845
|
+
const requestRange = {
|
|
846
|
+
min: guaranteed + cfg.variable.conversationHistory[0] + cfg.variable.taskFiles[0],
|
|
847
|
+
max: guaranteed + cfg.variable.conversationHistory[1] + cfg.variable.taskFiles[1]
|
|
848
|
+
};
|
|
849
|
+
const calibration = {};
|
|
850
|
+
for (const provider of cfg.providers) {
|
|
851
|
+
calibration[provider] = resolveProvider(provider, cfg).calibration;
|
|
852
|
+
}
|
|
853
|
+
const report = {
|
|
854
|
+
meta: {
|
|
855
|
+
tool: TOOL_NAME,
|
|
856
|
+
version: TOOL_VERSION,
|
|
857
|
+
scannedAt: (/* @__PURE__ */ new Date()).toISOString(),
|
|
858
|
+
projectPath: path6.resolve(projectPath),
|
|
859
|
+
pricingAsOf: PRICING_AS_OF,
|
|
860
|
+
pricingStale: isPricingStale(),
|
|
861
|
+
calibration,
|
|
862
|
+
cacheFormula: cacheFormulaDescription(cfg),
|
|
863
|
+
disclosure: DISCLOSURE
|
|
864
|
+
},
|
|
865
|
+
totals: {
|
|
866
|
+
gatedBaseline,
|
|
867
|
+
globalBaseline,
|
|
868
|
+
guaranteed,
|
|
869
|
+
conditional: sum(conditional),
|
|
870
|
+
byKind,
|
|
871
|
+
byAdapter
|
|
872
|
+
},
|
|
873
|
+
sources: sources.map(({ text: _text, ...rest }) => rest),
|
|
874
|
+
costs,
|
|
875
|
+
requestRange,
|
|
876
|
+
findings
|
|
877
|
+
};
|
|
878
|
+
return { report, sources };
|
|
879
|
+
}
|
|
880
|
+
|
|
881
|
+
// src/report/markdown.ts
|
|
882
|
+
var KIND_LABELS = {
|
|
883
|
+
"repo-instructions": "Repository instructions",
|
|
884
|
+
"local-instructions": "Local instructions",
|
|
885
|
+
"global-instructions": "Global instructions (user)",
|
|
886
|
+
agent: "Agent definitions",
|
|
887
|
+
"skill-description": "Skill descriptions (always loaded)",
|
|
888
|
+
"skill-body": "Skill bodies (on demand)",
|
|
889
|
+
command: "Slash commands",
|
|
890
|
+
"mcp-config": "MCP configuration",
|
|
891
|
+
"cursor-rules": "Cursor rules",
|
|
892
|
+
"copilot-instructions": "Copilot instructions",
|
|
893
|
+
"referenced-doc": "Referenced documentation"
|
|
894
|
+
};
|
|
895
|
+
function formatUSD(value) {
|
|
896
|
+
if (value === null) return "\u2014";
|
|
897
|
+
if (value === 0) return "$0.00";
|
|
898
|
+
if (value < 0.01) return `$${value.toFixed(4)}`;
|
|
899
|
+
return `$${value.toFixed(2)}`;
|
|
900
|
+
}
|
|
901
|
+
function num(n) {
|
|
902
|
+
return n.toLocaleString("en-US");
|
|
903
|
+
}
|
|
904
|
+
function renderMarkdown(report, cfg) {
|
|
905
|
+
const lines = [];
|
|
906
|
+
const { totals, costs, findings, meta } = report;
|
|
907
|
+
lines.push(`# AI Context Cost Audit`);
|
|
908
|
+
lines.push("");
|
|
909
|
+
lines.push(`Scanned \`${meta.projectPath}\` at ${meta.scannedAt}`);
|
|
910
|
+
lines.push("");
|
|
911
|
+
lines.push(`## Guaranteed Context (loaded on every request)`);
|
|
912
|
+
lines.push("");
|
|
913
|
+
lines.push(`| Source kind | Tokens |`);
|
|
914
|
+
lines.push(`|---|---:|`);
|
|
915
|
+
const guaranteedSources = report.sources.filter((s) => s.usage === "guaranteed");
|
|
916
|
+
const kindTotals = /* @__PURE__ */ new Map();
|
|
917
|
+
for (const s of guaranteedSources) {
|
|
918
|
+
kindTotals.set(s.kind, (kindTotals.get(s.kind) ?? 0) + s.tokens);
|
|
919
|
+
}
|
|
920
|
+
for (const [kind, tokens] of [...kindTotals.entries()].sort((a, b) => b[1] - a[1])) {
|
|
921
|
+
lines.push(`| ${KIND_LABELS[kind] ?? kind} | ${num(tokens)} |`);
|
|
922
|
+
}
|
|
923
|
+
lines.push(`| **Repo baseline (CI-gated)** | **${num(totals.gatedBaseline)}** |`);
|
|
924
|
+
if (totals.globalBaseline > 0) {
|
|
925
|
+
lines.push(
|
|
926
|
+
`| Global user files (not counted toward CI budget) | ${num(totals.globalBaseline)} |`
|
|
927
|
+
);
|
|
928
|
+
}
|
|
929
|
+
lines.push(`| **Total baseline** | **${num(totals.guaranteed)}** |`);
|
|
930
|
+
lines.push("");
|
|
931
|
+
if (totals.conditional > 0) {
|
|
932
|
+
lines.push(
|
|
933
|
+
`Conditional context (loads for some tasks): ${num(totals.conditional)} tokens across ${report.sources.filter((s) => s.usage === "conditional").length} sources.`
|
|
934
|
+
);
|
|
935
|
+
lines.push("");
|
|
936
|
+
}
|
|
937
|
+
lines.push(`## Estimated cost per request (baseline input only)`);
|
|
938
|
+
lines.push("");
|
|
939
|
+
lines.push(`| Model | Uncached | With caching (typical) |`);
|
|
940
|
+
lines.push(`|---|---:|---:|`);
|
|
941
|
+
for (const c of costs) {
|
|
942
|
+
lines.push(
|
|
943
|
+
`| ${c.model} | ${formatUSD(c.perRequestUncached)} | ${formatUSD(c.perRequestCached)} |`
|
|
944
|
+
);
|
|
945
|
+
}
|
|
946
|
+
lines.push("");
|
|
947
|
+
lines.push(
|
|
948
|
+
`Cost figures use the full guaranteed baseline (repo + global). "With caching" models prompt caching: ${meta.cacheFormula}.`
|
|
949
|
+
);
|
|
950
|
+
lines.push("");
|
|
951
|
+
lines.push(`## Daily usage projections`);
|
|
952
|
+
lines.push("");
|
|
953
|
+
for (const c of costs) {
|
|
954
|
+
if (c.perRequestUncached === null) {
|
|
955
|
+
lines.push(`- ${c.model}: pricing not set (see config.pricingOverrides)`);
|
|
956
|
+
continue;
|
|
957
|
+
}
|
|
958
|
+
lines.push(`**${c.model}**`);
|
|
959
|
+
lines.push("");
|
|
960
|
+
lines.push(`| Requests/day | Uncached | With caching |`);
|
|
961
|
+
lines.push(`|---:|---:|---:|`);
|
|
962
|
+
for (const d of c.daily) {
|
|
963
|
+
lines.push(
|
|
964
|
+
`| ${num(d.requestsPerDay)} | ${formatUSD(d.uncached)}/day | ${formatUSD(d.cached)}/day |`
|
|
965
|
+
);
|
|
966
|
+
}
|
|
967
|
+
lines.push("");
|
|
968
|
+
if (c.runwayDays !== null && cfg.monthlyBudget !== null) {
|
|
969
|
+
const sorted = [...cfg.requestsPerDay].sort((a, b) => a - b);
|
|
970
|
+
const midRate = sorted[Math.floor(sorted.length / 2)];
|
|
971
|
+
lines.push(
|
|
972
|
+
`At ${num(midRate)} requests/day per developer` + (cfg.developers > 1 ? ` (${cfg.developers} developers)` : "") + `, your $${cfg.monthlyBudget}/month budget lasts ~${c.runwayDays.toFixed(1)} days.`
|
|
973
|
+
);
|
|
974
|
+
lines.push("");
|
|
975
|
+
}
|
|
976
|
+
}
|
|
977
|
+
lines.push(`## Typical full-request range`);
|
|
978
|
+
lines.push("");
|
|
979
|
+
lines.push(
|
|
980
|
+
`Baseline plus variable context (conversation history ${num(cfg.variable.conversationHistory[0])}\u2013${num(cfg.variable.conversationHistory[1])}, task files ${num(cfg.variable.taskFiles[0])}\u2013${num(cfg.variable.taskFiles[1])} tokens):`
|
|
981
|
+
);
|
|
982
|
+
lines.push("");
|
|
983
|
+
lines.push(
|
|
984
|
+
`**Estimated request range: ${num(report.requestRange.min)}\u2013${num(report.requestRange.max)} input tokens.**`
|
|
985
|
+
);
|
|
986
|
+
lines.push("");
|
|
987
|
+
lines.push(
|
|
988
|
+
`Variable context cannot be predicted exactly; these are configurable ranges, not measurements.`
|
|
989
|
+
);
|
|
990
|
+
lines.push("");
|
|
991
|
+
lines.push(`## High-impact findings`);
|
|
992
|
+
lines.push("");
|
|
993
|
+
if (findings.length === 0) {
|
|
994
|
+
lines.push(`No findings. Baseline looks lean.`);
|
|
995
|
+
} else {
|
|
996
|
+
for (const [i, f] of findings.entries()) {
|
|
997
|
+
lines.push(`${i + 1}. **[${f.severity}] ${f.rule}** \u2014 ${f.message}`);
|
|
998
|
+
}
|
|
999
|
+
}
|
|
1000
|
+
lines.push("");
|
|
1001
|
+
lines.push(`## All discovered sources`);
|
|
1002
|
+
lines.push("");
|
|
1003
|
+
lines.push(`| Path | Kind | Usage | Scope | Tokens | Confidence |`);
|
|
1004
|
+
lines.push(`|---|---|---|---|---:|---|`);
|
|
1005
|
+
for (const s of [...report.sources].sort((a, b) => b.tokens - a.tokens)) {
|
|
1006
|
+
lines.push(
|
|
1007
|
+
`| ${s.path} | ${s.kind} | ${s.usage} | ${s.scope} | ${num(s.tokens)} | ${s.confidence} |`
|
|
1008
|
+
);
|
|
1009
|
+
}
|
|
1010
|
+
lines.push("");
|
|
1011
|
+
const noted = report.sources.filter((s) => s.note);
|
|
1012
|
+
if (noted.length > 0) {
|
|
1013
|
+
lines.push(`Notes:`);
|
|
1014
|
+
for (const s of noted) {
|
|
1015
|
+
lines.push(`- \`${s.path}\`: ${s.note}`);
|
|
1016
|
+
}
|
|
1017
|
+
lines.push("");
|
|
1018
|
+
}
|
|
1019
|
+
lines.push(`---`);
|
|
1020
|
+
lines.push("");
|
|
1021
|
+
lines.push(`*${meta.disclosure}*`);
|
|
1022
|
+
lines.push("");
|
|
1023
|
+
lines.push(
|
|
1024
|
+
`*Calibration factors (applied to o200k_base counts): ${Object.entries(meta.calibration).map(([provider, factor]) => `${provider} \xD7${factor}`).join(", ")}. Pricing as of ${meta.pricingAsOf}` + (meta.pricingStale ? ` \u2014 **stale (>90 days old)**: verify current pricing and override via config.pricingOverrides` : "") + `.*`
|
|
1025
|
+
);
|
|
1026
|
+
lines.push("");
|
|
1027
|
+
lines.push(`*Generated by ${meta.tool} v${meta.version}.*`);
|
|
1028
|
+
return lines.join("\n");
|
|
1029
|
+
}
|
|
1030
|
+
|
|
1031
|
+
// src/report/json.ts
|
|
1032
|
+
function renderJson(report) {
|
|
1033
|
+
return JSON.stringify(report, null, 2) + "\n";
|
|
1034
|
+
}
|
|
1035
|
+
|
|
1036
|
+
// src/report/html.ts
|
|
1037
|
+
function esc(text) {
|
|
1038
|
+
return text.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """);
|
|
1039
|
+
}
|
|
1040
|
+
function num2(n) {
|
|
1041
|
+
return n.toLocaleString("en-US");
|
|
1042
|
+
}
|
|
1043
|
+
function renderHtml(report, cfg) {
|
|
1044
|
+
const { totals, costs, findings, meta } = report;
|
|
1045
|
+
const severityColor = {
|
|
1046
|
+
error: "#c0392b",
|
|
1047
|
+
warn: "#b7791f",
|
|
1048
|
+
info: "#2b6cb0"
|
|
1049
|
+
};
|
|
1050
|
+
const kindRows = (() => {
|
|
1051
|
+
const kindTotals = /* @__PURE__ */ new Map();
|
|
1052
|
+
for (const s of report.sources.filter((x) => x.usage === "guaranteed")) {
|
|
1053
|
+
kindTotals.set(s.kind, (kindTotals.get(s.kind) ?? 0) + s.tokens);
|
|
1054
|
+
}
|
|
1055
|
+
return [...kindTotals.entries()].sort((a, b) => b[1] - a[1]).map(([kind, tokens]) => `<tr><td>${esc(kind)}</td><td class="r">${num2(tokens)}</td></tr>`).join("\n");
|
|
1056
|
+
})();
|
|
1057
|
+
const costRows = costs.map(
|
|
1058
|
+
(c) => `<tr><td>${esc(c.model)}</td><td class="r">${formatUSD(c.perRequestUncached)}</td><td class="r">${formatUSD(c.perRequestCached)}</td></tr>`
|
|
1059
|
+
).join("\n");
|
|
1060
|
+
const findingItems = findings.length === 0 ? `<li>No findings. Baseline looks lean.</li>` : findings.map(
|
|
1061
|
+
(f) => `<li><span class="sev" style="background:${severityColor[f.severity]}">${f.severity}</span> <code>${esc(f.rule)}</code> \u2014 ${esc(f.message)}</li>`
|
|
1062
|
+
).join("\n");
|
|
1063
|
+
const sourceRows = [...report.sources].sort((a, b) => b.tokens - a.tokens).map(
|
|
1064
|
+
(s) => `<tr><td><code>${esc(s.path)}</code></td><td>${esc(s.kind)}</td><td>${esc(s.usage)}</td><td>${esc(s.scope)}</td><td class="r">${num2(s.tokens)}</td><td>${esc(s.confidence)}</td></tr>`
|
|
1065
|
+
).join("\n");
|
|
1066
|
+
return `<!doctype html>
|
|
1067
|
+
<html lang="en">
|
|
1068
|
+
<head>
|
|
1069
|
+
<meta charset="utf-8">
|
|
1070
|
+
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
1071
|
+
<title>AI Context Cost Audit</title>
|
|
1072
|
+
<style>
|
|
1073
|
+
:root { color-scheme: light dark; }
|
|
1074
|
+
body { font: 15px/1.5 -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; max-width: 900px; margin: 2rem auto; padding: 0 1rem; }
|
|
1075
|
+
h1 { font-size: 1.6rem; } h2 { font-size: 1.2rem; margin-top: 2rem; }
|
|
1076
|
+
table { border-collapse: collapse; width: 100%; margin: .75rem 0; }
|
|
1077
|
+
th, td { text-align: left; padding: .35rem .6rem; border-bottom: 1px solid rgba(128,128,128,.3); }
|
|
1078
|
+
td.r, th.r { text-align: right; font-variant-numeric: tabular-nums; }
|
|
1079
|
+
tr.total td { font-weight: 700; border-top: 2px solid rgba(128,128,128,.5); }
|
|
1080
|
+
code { font: 13px ui-monospace, Menlo, monospace; }
|
|
1081
|
+
.sev { color: #fff; font-size: 11px; padding: 1px 6px; border-radius: 3px; text-transform: uppercase; }
|
|
1082
|
+
.muted { opacity: .7; font-size: 13px; }
|
|
1083
|
+
li { margin: .4rem 0; }
|
|
1084
|
+
.big { font-size: 1.1rem; font-weight: 600; }
|
|
1085
|
+
</style>
|
|
1086
|
+
</head>
|
|
1087
|
+
<body>
|
|
1088
|
+
<h1>AI Context Cost Audit</h1>
|
|
1089
|
+
<p class="muted">Scanned <code>${esc(meta.projectPath)}</code> at ${esc(meta.scannedAt)}</p>
|
|
1090
|
+
|
|
1091
|
+
<h2>Guaranteed context (loaded on every request)</h2>
|
|
1092
|
+
<table>
|
|
1093
|
+
<tr><th>Source kind</th><th class="r">Tokens</th></tr>
|
|
1094
|
+
${kindRows}
|
|
1095
|
+
<tr class="total"><td>Repo baseline (CI-gated)</td><td class="r">${num2(totals.gatedBaseline)}</td></tr>
|
|
1096
|
+
${totals.globalBaseline > 0 ? `<tr><td>Global user files (not gated)</td><td class="r">${num2(totals.globalBaseline)}</td></tr>` : ""}
|
|
1097
|
+
<tr class="total"><td>Total baseline</td><td class="r">${num2(totals.guaranteed)}</td></tr>
|
|
1098
|
+
</table>
|
|
1099
|
+
|
|
1100
|
+
<h2>Estimated cost per request (baseline input only)</h2>
|
|
1101
|
+
<table>
|
|
1102
|
+
<tr><th>Model</th><th class="r">Uncached</th><th class="r">With caching (typical)</th></tr>
|
|
1103
|
+
${costRows}
|
|
1104
|
+
</table>
|
|
1105
|
+
<p class="muted">${esc(meta.cacheFormula)}</p>
|
|
1106
|
+
|
|
1107
|
+
<h2>Typical full-request range</h2>
|
|
1108
|
+
<p class="big">${num2(report.requestRange.min)}–${num2(report.requestRange.max)} input tokens</p>
|
|
1109
|
+
<p class="muted">Baseline + conversation history (${num2(cfg.variable.conversationHistory[0])}–${num2(cfg.variable.conversationHistory[1])}) + task files (${num2(cfg.variable.taskFiles[0])}–${num2(cfg.variable.taskFiles[1])}). Ranges, not measurements.</p>
|
|
1110
|
+
|
|
1111
|
+
<h2>High-impact findings</h2>
|
|
1112
|
+
<ol>
|
|
1113
|
+
${findingItems}
|
|
1114
|
+
</ol>
|
|
1115
|
+
|
|
1116
|
+
<h2>All discovered sources</h2>
|
|
1117
|
+
<table>
|
|
1118
|
+
<tr><th>Path</th><th>Kind</th><th>Usage</th><th>Scope</th><th class="r">Tokens</th><th>Confidence</th></tr>
|
|
1119
|
+
${sourceRows}
|
|
1120
|
+
</table>
|
|
1121
|
+
|
|
1122
|
+
<hr>
|
|
1123
|
+
<p class="muted">${esc(meta.disclosure)}</p>
|
|
1124
|
+
<p class="muted">Calibration: ${esc(
|
|
1125
|
+
Object.entries(meta.calibration).map(([provider, factor]) => `${provider} \xD7${factor}`).join(", ")
|
|
1126
|
+
)}. Pricing as of ${esc(meta.pricingAsOf)}${meta.pricingStale ? " \u2014 <strong>stale, verify and override via config</strong>" : ""}.</p>
|
|
1127
|
+
<p class="muted">Generated by ${esc(meta.tool)} v${esc(meta.version)}.</p>
|
|
1128
|
+
</body>
|
|
1129
|
+
</html>
|
|
1130
|
+
`;
|
|
1131
|
+
}
|
|
1132
|
+
|
|
1133
|
+
// src/snapshot.ts
|
|
1134
|
+
import { mkdir, readFile as readFile6, writeFile } from "fs/promises";
|
|
1135
|
+
import { existsSync as existsSync5 } from "fs";
|
|
1136
|
+
import path7 from "path";
|
|
1137
|
+
var SNAPSHOT_DIR = ".ai-cost-audit";
|
|
1138
|
+
var SNAPSHOT_FILE = "snapshot.json";
|
|
1139
|
+
function snapshotPath(projectPath) {
|
|
1140
|
+
return path7.join(projectPath, SNAPSHOT_DIR, SNAPSHOT_FILE);
|
|
1141
|
+
}
|
|
1142
|
+
async function readSnapshot(projectPath) {
|
|
1143
|
+
const file = snapshotPath(projectPath);
|
|
1144
|
+
if (!existsSync5(file)) return null;
|
|
1145
|
+
try {
|
|
1146
|
+
const parsed = JSON.parse(await readFile6(file, "utf8"));
|
|
1147
|
+
if (parsed.version !== 1 || !Array.isArray(parsed.sources)) return null;
|
|
1148
|
+
return parsed;
|
|
1149
|
+
} catch {
|
|
1150
|
+
return null;
|
|
1151
|
+
}
|
|
1152
|
+
}
|
|
1153
|
+
async function writeSnapshot(projectPath, sources, gatedBaseline) {
|
|
1154
|
+
const snapshot = {
|
|
1155
|
+
version: 1,
|
|
1156
|
+
createdAt: (/* @__PURE__ */ new Date()).toISOString(),
|
|
1157
|
+
gatedBaseline,
|
|
1158
|
+
// Per-source tokens (repo-scoped guaranteed only) so growth attribution works.
|
|
1159
|
+
sources: sources.filter((s) => s.scope === "repo" && s.usage === "guaranteed").map((s) => ({ path: s.path, tokens: s.tokens })).sort((a, b) => a.path.localeCompare(b.path))
|
|
1160
|
+
};
|
|
1161
|
+
const file = snapshotPath(projectPath);
|
|
1162
|
+
await mkdir(path7.dirname(file), { recursive: true });
|
|
1163
|
+
await writeFile(file, JSON.stringify(snapshot, null, 2) + "\n", "utf8");
|
|
1164
|
+
return file;
|
|
1165
|
+
}
|
|
1166
|
+
|
|
1167
|
+
// src/budget.ts
|
|
1168
|
+
var EXIT_PASS = 0;
|
|
1169
|
+
var EXIT_VIOLATION = 1;
|
|
1170
|
+
var EXIT_ERROR = 2;
|
|
1171
|
+
function evaluateBudget(gatedBaseline, sources, snapshot, cfg) {
|
|
1172
|
+
const messages = [];
|
|
1173
|
+
let pass = true;
|
|
1174
|
+
if (cfg.baselineTokenLimit !== null && gatedBaseline > cfg.baselineTokenLimit) {
|
|
1175
|
+
pass = false;
|
|
1176
|
+
messages.push(
|
|
1177
|
+
`AI context budget failed: baseline ${gatedBaseline.toLocaleString()} tokens exceeds limit ${cfg.baselineTokenLimit.toLocaleString()}.`
|
|
1178
|
+
);
|
|
1179
|
+
}
|
|
1180
|
+
if (snapshot && snapshot.gatedBaseline > 0) {
|
|
1181
|
+
const growthPct = (gatedBaseline - snapshot.gatedBaseline) / snapshot.gatedBaseline * 100;
|
|
1182
|
+
if (growthPct > cfg.growthThresholdPct) {
|
|
1183
|
+
pass = false;
|
|
1184
|
+
const causes = topGrowthCauses(sources, snapshot);
|
|
1185
|
+
messages.push(
|
|
1186
|
+
`AI context budget failed: baseline grew ${growthPct.toFixed(0)}% (threshold ${cfg.growthThresholdPct}%).
|
|
1187
|
+
Previous: ${snapshot.gatedBaseline.toLocaleString()} tokens
|
|
1188
|
+
Current: ${gatedBaseline.toLocaleString()} tokens
|
|
1189
|
+
Change: ${growthPct >= 0 ? "+" : ""}${growthPct.toFixed(0)}%` + (causes.length > 0 ? `
|
|
1190
|
+
Primary cause: ${causes.join("; ")}` : "")
|
|
1191
|
+
);
|
|
1192
|
+
} else {
|
|
1193
|
+
messages.push(
|
|
1194
|
+
`Baseline vs snapshot: ${snapshot.gatedBaseline.toLocaleString()} \u2192 ${gatedBaseline.toLocaleString()} tokens (${growthPct >= 0 ? "+" : ""}${growthPct.toFixed(1)}%, within ${cfg.growthThresholdPct}% threshold).`
|
|
1195
|
+
);
|
|
1196
|
+
}
|
|
1197
|
+
} else if (!snapshot) {
|
|
1198
|
+
messages.push(
|
|
1199
|
+
`No snapshot found (${cfg.growthThresholdPct}% growth check skipped). Run \`ai-cost-audit scan --update-snapshot\` and commit .ai-cost-audit/snapshot.json.`
|
|
1200
|
+
);
|
|
1201
|
+
}
|
|
1202
|
+
if (pass && cfg.baselineTokenLimit !== null) {
|
|
1203
|
+
messages.push(
|
|
1204
|
+
`Baseline ${gatedBaseline.toLocaleString()} tokens within limit ${cfg.baselineTokenLimit.toLocaleString()}.`
|
|
1205
|
+
);
|
|
1206
|
+
}
|
|
1207
|
+
return { pass, messages };
|
|
1208
|
+
}
|
|
1209
|
+
|
|
1210
|
+
// src/cli.ts
|
|
1211
|
+
var program = new Command();
|
|
1212
|
+
program.name(TOOL_NAME).description(
|
|
1213
|
+
"AI context cost profiler and linter \u2014 know what every AI coding request costs before it's sent"
|
|
1214
|
+
).version(TOOL_VERSION);
|
|
1215
|
+
program.command("scan").description("Scan a repository for AI context sources and report baseline cost").argument("[path]", "project path to scan", ".").option("-c, --config <file>", "path to config file (default: ./ai-cost-audit.json)").option("-f, --format <format>", "output format: md | json | html", "md").option("-o, --out <file>", "write the report to a file instead of stdout").option("--ci", "run the budget gate: exit 1 on limit breach or growth over threshold").option("--update-snapshot", "write .ai-cost-audit/snapshot.json (commit it for CI diffs)").option("--no-global", "skip user-global files (~/.claude)").option("--ref-depth <n>", "how many levels of @imports / links to follow", parseIntArg).action(async (projectPath, options) => {
|
|
1216
|
+
try {
|
|
1217
|
+
const resolved = path8.resolve(projectPath);
|
|
1218
|
+
const { config, configPath } = await loadConfig(resolved, options.config);
|
|
1219
|
+
if (options.global === false) config.includeGlobal = false;
|
|
1220
|
+
if (options.refDepth !== void 0) config.refDepth = options.refDepth;
|
|
1221
|
+
const snapshot = await readSnapshot(resolved);
|
|
1222
|
+
const { report, sources } = await runScan(resolved, config, snapshot);
|
|
1223
|
+
const format = String(options.format).toLowerCase();
|
|
1224
|
+
let output;
|
|
1225
|
+
if (format === "json") output = renderJson(report);
|
|
1226
|
+
else if (format === "html") output = renderHtml(report, config);
|
|
1227
|
+
else if (format === "md") output = renderMarkdown(report, config);
|
|
1228
|
+
else {
|
|
1229
|
+
process.stderr.write(pc.red(`Unknown format: ${options.format} (use md, json, or html)
|
|
1230
|
+
`));
|
|
1231
|
+
process.exit(EXIT_ERROR);
|
|
1232
|
+
return;
|
|
1233
|
+
}
|
|
1234
|
+
if (options.out) {
|
|
1235
|
+
const outPath = path8.resolve(options.out);
|
|
1236
|
+
await writeFile2(outPath, output, "utf8");
|
|
1237
|
+
process.stderr.write(pc.green(`Report written to ${outPath}
|
|
1238
|
+
`));
|
|
1239
|
+
} else {
|
|
1240
|
+
process.stdout.write(format === "md" ? colorize(output) : output);
|
|
1241
|
+
}
|
|
1242
|
+
if (configPath === null) {
|
|
1243
|
+
process.stderr.write(
|
|
1244
|
+
pc.dim(`(no ai-cost-audit.json found \u2014 ran with defaults)
|
|
1245
|
+
`)
|
|
1246
|
+
);
|
|
1247
|
+
}
|
|
1248
|
+
if (options.updateSnapshot) {
|
|
1249
|
+
const file = await writeSnapshot(resolved, sources, report.totals.gatedBaseline);
|
|
1250
|
+
process.stderr.write(pc.green(`Snapshot written to ${file} \u2014 commit it for CI diffs.
|
|
1251
|
+
`));
|
|
1252
|
+
}
|
|
1253
|
+
if (options.ci) {
|
|
1254
|
+
const gate = evaluateBudget(report.totals.gatedBaseline, sources, snapshot, config);
|
|
1255
|
+
for (const message of gate.messages) {
|
|
1256
|
+
process.stderr.write(
|
|
1257
|
+
(gate.pass ? pc.green(message) : pc.red(message)) + "\n"
|
|
1258
|
+
);
|
|
1259
|
+
}
|
|
1260
|
+
process.exit(gate.pass ? EXIT_PASS : EXIT_VIOLATION);
|
|
1261
|
+
}
|
|
1262
|
+
} catch (err) {
|
|
1263
|
+
process.stderr.write(
|
|
1264
|
+
pc.red(`Error: ${err instanceof Error ? err.message : String(err)}
|
|
1265
|
+
`)
|
|
1266
|
+
);
|
|
1267
|
+
process.exit(EXIT_ERROR);
|
|
1268
|
+
}
|
|
1269
|
+
});
|
|
1270
|
+
function parseIntArg(value) {
|
|
1271
|
+
const n = Number.parseInt(value, 10);
|
|
1272
|
+
if (Number.isNaN(n) || n < 0) throw new Error(`Invalid number: ${value}`);
|
|
1273
|
+
return n;
|
|
1274
|
+
}
|
|
1275
|
+
function colorize(markdown) {
|
|
1276
|
+
return markdown.split("\n").map((line) => {
|
|
1277
|
+
if (line.startsWith("# ")) return pc.bold(pc.cyan(line));
|
|
1278
|
+
if (line.startsWith("## ")) return pc.bold(line);
|
|
1279
|
+
if (/^\d+\. \*\*\[error\]/.test(line)) return pc.red(line);
|
|
1280
|
+
if (/^\d+\. \*\*\[warn\]/.test(line)) return pc.yellow(line);
|
|
1281
|
+
if (/^\d+\. \*\*\[info\]/.test(line)) return pc.blue(line);
|
|
1282
|
+
if (line.startsWith("*") && line.endsWith("*")) return pc.dim(line);
|
|
1283
|
+
return line;
|
|
1284
|
+
}).join("\n");
|
|
1285
|
+
}
|
|
1286
|
+
program.parseAsync(process.argv);
|