cto-ai-cli 1.3.0 → 3.0.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/DOCS.md +351 -0
- package/README.md +189 -263
- package/dist/action/index.js +25730 -0
- package/dist/api/dashboard.js +2073 -0
- package/dist/api/dashboard.js.map +1 -0
- package/dist/api/server.js +3401 -0
- package/dist/api/server.js.map +1 -0
- package/dist/cli/score.js +1971 -0
- package/dist/cli/v2/index.d.ts +2 -0
- package/dist/cli/v2/index.js +3496 -0
- package/dist/cli/v2/index.js.map +1 -0
- package/dist/engine/index.d.ts +816 -0
- package/dist/engine/index.js +4997 -0
- package/dist/engine/index.js.map +1 -0
- package/dist/govern/index.d.ts +261 -0
- package/dist/govern/index.js +662 -0
- package/dist/govern/index.js.map +1 -0
- package/dist/interact/index.d.ts +234 -0
- package/dist/interact/index.js +1343 -0
- package/dist/interact/index.js.map +1 -0
- package/dist/mcp/v2.d.ts +2 -0
- package/dist/mcp/v2.js +18289 -0
- package/dist/mcp/v2.js.map +1 -0
- package/package.json +55 -25
|
@@ -0,0 +1,3401 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
// src/api/server.ts
|
|
4
|
+
import { createServer } from "http";
|
|
5
|
+
import { resolve as resolve7 } from "path";
|
|
6
|
+
|
|
7
|
+
// src/engine/analyzer.ts
|
|
8
|
+
import { readFile as readFile2, readdir, stat as stat2 } from "fs/promises";
|
|
9
|
+
import { join as join2, extname, relative as relative2, resolve as resolve2, basename as basename2 } from "path";
|
|
10
|
+
import { createHash } from "crypto";
|
|
11
|
+
|
|
12
|
+
// src/types/engine.ts
|
|
13
|
+
var DEFAULT_RISK_WEIGHTS = {
|
|
14
|
+
hub: 30,
|
|
15
|
+
typeProvider: 25,
|
|
16
|
+
complexity: 15,
|
|
17
|
+
recency: 15,
|
|
18
|
+
config: 10,
|
|
19
|
+
churn: 5
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
// src/types/config.ts
|
|
23
|
+
var DEFAULT_CONFIG = {
|
|
24
|
+
version: "2.0",
|
|
25
|
+
analysis: {
|
|
26
|
+
extensions: {
|
|
27
|
+
code: ["ts", "tsx", "js", "jsx", "py", "go", "rs", "java", "kt", "rb", "php", "c", "cpp", "h", "hpp", "cs"],
|
|
28
|
+
config: ["json", "yml", "yaml", "toml"],
|
|
29
|
+
docs: ["md", "txt", "rst"]
|
|
30
|
+
},
|
|
31
|
+
ignore: {
|
|
32
|
+
dirs: ["node_modules", "dist", "build", ".git", "coverage", "__pycache__", ".next", "vendor", ".cto"],
|
|
33
|
+
patterns: ["*.min.js", "*.map", "*.lock", "*.generated.*"]
|
|
34
|
+
},
|
|
35
|
+
maxDepth: 20
|
|
36
|
+
},
|
|
37
|
+
risk: {
|
|
38
|
+
weights: {
|
|
39
|
+
hub: 30,
|
|
40
|
+
typeProvider: 25,
|
|
41
|
+
complexity: 15,
|
|
42
|
+
recency: 15,
|
|
43
|
+
config: 10,
|
|
44
|
+
churn: 5
|
|
45
|
+
}
|
|
46
|
+
},
|
|
47
|
+
interaction: {
|
|
48
|
+
defaultBudget: 5e4,
|
|
49
|
+
defaultModel: "claude-sonnet-4"
|
|
50
|
+
},
|
|
51
|
+
tokens: {
|
|
52
|
+
method: "chars4"
|
|
53
|
+
},
|
|
54
|
+
governance: {
|
|
55
|
+
auditEnabled: true,
|
|
56
|
+
secretDetection: true,
|
|
57
|
+
retentionDays: 90
|
|
58
|
+
}
|
|
59
|
+
};
|
|
60
|
+
|
|
61
|
+
// src/engine/tokenizer.ts
|
|
62
|
+
import { encodingForModel } from "js-tiktoken";
|
|
63
|
+
import { readFile, stat } from "fs/promises";
|
|
64
|
+
var CHARS_PER_TOKEN = 4;
|
|
65
|
+
var encoder = null;
|
|
66
|
+
function getEncoder() {
|
|
67
|
+
if (!encoder) {
|
|
68
|
+
encoder = encodingForModel("claude-3-5-sonnet-20241022");
|
|
69
|
+
}
|
|
70
|
+
return encoder;
|
|
71
|
+
}
|
|
72
|
+
function countTokensTiktoken(text) {
|
|
73
|
+
try {
|
|
74
|
+
const enc = getEncoder();
|
|
75
|
+
const tokens = enc.encode(text);
|
|
76
|
+
return tokens.length;
|
|
77
|
+
} catch {
|
|
78
|
+
return Math.ceil(text.length / CHARS_PER_TOKEN);
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
function countTokensChars4(sizeInBytes) {
|
|
82
|
+
return Math.ceil(sizeInBytes / CHARS_PER_TOKEN);
|
|
83
|
+
}
|
|
84
|
+
function estimateTokens(content, sizeInBytes, method = "chars4") {
|
|
85
|
+
if (method === "tiktoken") {
|
|
86
|
+
return countTokensTiktoken(content);
|
|
87
|
+
}
|
|
88
|
+
return countTokensChars4(sizeInBytes);
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
// src/engine/graph.ts
|
|
92
|
+
import { Project, SyntaxKind } from "ts-morph";
|
|
93
|
+
import { resolve, relative, dirname, join } from "path";
|
|
94
|
+
import { existsSync } from "fs";
|
|
95
|
+
var TS_EXTENSIONS = /* @__PURE__ */ new Set(["ts", "tsx", "js", "jsx", "mts", "mjs", "cts", "cjs"]);
|
|
96
|
+
function createProject(projectPath, filePaths) {
|
|
97
|
+
const tsConfigPath = join(projectPath, "tsconfig.json");
|
|
98
|
+
const hasTsConfig = existsSync(tsConfigPath);
|
|
99
|
+
const project = new Project({
|
|
100
|
+
tsConfigFilePath: hasTsConfig ? tsConfigPath : void 0,
|
|
101
|
+
skipAddingFilesFromTsConfig: true,
|
|
102
|
+
compilerOptions: hasTsConfig ? void 0 : {
|
|
103
|
+
allowJs: true,
|
|
104
|
+
jsx: 4,
|
|
105
|
+
// JsxEmit.ReactJSX
|
|
106
|
+
esModuleInterop: true,
|
|
107
|
+
moduleResolution: 100
|
|
108
|
+
// Bundler
|
|
109
|
+
}
|
|
110
|
+
});
|
|
111
|
+
const tsFiles = filePaths.filter((f) => {
|
|
112
|
+
const ext = f.split(".").pop()?.toLowerCase() ?? "";
|
|
113
|
+
return TS_EXTENSIONS.has(ext);
|
|
114
|
+
});
|
|
115
|
+
for (const filePath of tsFiles) {
|
|
116
|
+
try {
|
|
117
|
+
project.addSourceFileAtPath(filePath);
|
|
118
|
+
} catch {
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
return project;
|
|
122
|
+
}
|
|
123
|
+
function buildProjectGraph(projectPath, files) {
|
|
124
|
+
const absPath = resolve(projectPath);
|
|
125
|
+
const tsFiles = files.filter((f) => TS_EXTENSIONS.has(f.extension)).map((f) => f.path);
|
|
126
|
+
if (tsFiles.length === 0) {
|
|
127
|
+
return emptyGraph(files);
|
|
128
|
+
}
|
|
129
|
+
let project;
|
|
130
|
+
try {
|
|
131
|
+
project = createProject(projectPath, tsFiles);
|
|
132
|
+
} catch {
|
|
133
|
+
return emptyGraph(files);
|
|
134
|
+
}
|
|
135
|
+
const edges = [];
|
|
136
|
+
const nodeSet = /* @__PURE__ */ new Set();
|
|
137
|
+
for (const sourceFile of project.getSourceFiles()) {
|
|
138
|
+
const fromRel = relative(absPath, sourceFile.getFilePath());
|
|
139
|
+
if (fromRel.startsWith("..") || fromRel.includes("node_modules")) continue;
|
|
140
|
+
nodeSet.add(fromRel);
|
|
141
|
+
for (const imp of sourceFile.getImportDeclarations()) {
|
|
142
|
+
const moduleSpecifier = imp.getModuleSpecifierValue();
|
|
143
|
+
const resolved = resolveImport(sourceFile, moduleSpecifier, absPath);
|
|
144
|
+
if (resolved) {
|
|
145
|
+
nodeSet.add(resolved);
|
|
146
|
+
edges.push({ from: fromRel, to: resolved, type: "import" });
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
for (const exp of sourceFile.getExportDeclarations()) {
|
|
150
|
+
const moduleSpecifier = exp.getModuleSpecifierValue();
|
|
151
|
+
if (moduleSpecifier) {
|
|
152
|
+
const resolved = resolveImport(sourceFile, moduleSpecifier, absPath);
|
|
153
|
+
if (resolved) {
|
|
154
|
+
nodeSet.add(resolved);
|
|
155
|
+
edges.push({ from: fromRel, to: resolved, type: "re-export" });
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
const nodes = Array.from(nodeSet);
|
|
161
|
+
const importedByCount = /* @__PURE__ */ new Map();
|
|
162
|
+
const importCount = /* @__PURE__ */ new Map();
|
|
163
|
+
for (const edge of edges) {
|
|
164
|
+
importedByCount.set(edge.to, (importedByCount.get(edge.to) ?? 0) + 1);
|
|
165
|
+
importCount.set(edge.from, (importCount.get(edge.from) ?? 0) + 1);
|
|
166
|
+
}
|
|
167
|
+
const N = Math.max(nodes.length, 1);
|
|
168
|
+
const hubs = nodes.map((node) => {
|
|
169
|
+
const inDeg = importedByCount.get(node) ?? 0;
|
|
170
|
+
const outDeg = importCount.get(node) ?? 0;
|
|
171
|
+
const centrality = N > 1 ? inDeg / (N - 1) * 100 : 0;
|
|
172
|
+
const score = Math.round(centrality + outDeg * (100 / (2 * N)));
|
|
173
|
+
return {
|
|
174
|
+
relativePath: node,
|
|
175
|
+
dependents: inDeg,
|
|
176
|
+
dependencies: outDeg,
|
|
177
|
+
score: Math.min(100, score)
|
|
178
|
+
};
|
|
179
|
+
}).filter((h) => h.dependents >= 3 || h.score >= 15).sort((a, b) => b.score - a.score);
|
|
180
|
+
const leaves = nodes.filter(
|
|
181
|
+
(node) => (importedByCount.get(node) ?? 0) === 0 && (importCount.get(node) ?? 0) > 0
|
|
182
|
+
);
|
|
183
|
+
const connectedNodes = /* @__PURE__ */ new Set();
|
|
184
|
+
for (const edge of edges) {
|
|
185
|
+
connectedNodes.add(edge.from);
|
|
186
|
+
connectedNodes.add(edge.to);
|
|
187
|
+
}
|
|
188
|
+
const allFileNodes = new Set(files.map((f) => f.relativePath));
|
|
189
|
+
const orphans = Array.from(allFileNodes).filter((n) => !connectedNodes.has(n));
|
|
190
|
+
const clusters = detectClusters(nodes, edges, files);
|
|
191
|
+
enrichComplexity(project, absPath, files);
|
|
192
|
+
return { nodes, edges, hubs, leaves, orphans, clusters };
|
|
193
|
+
}
|
|
194
|
+
var UnionFind = class {
|
|
195
|
+
parent;
|
|
196
|
+
rank;
|
|
197
|
+
constructor(nodes) {
|
|
198
|
+
this.parent = /* @__PURE__ */ new Map();
|
|
199
|
+
this.rank = /* @__PURE__ */ new Map();
|
|
200
|
+
for (const n of nodes) {
|
|
201
|
+
this.parent.set(n, n);
|
|
202
|
+
this.rank.set(n, 0);
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
find(x) {
|
|
206
|
+
const p = this.parent.get(x);
|
|
207
|
+
if (p === void 0) return x;
|
|
208
|
+
if (p !== x) {
|
|
209
|
+
this.parent.set(x, this.find(p));
|
|
210
|
+
}
|
|
211
|
+
return this.parent.get(x);
|
|
212
|
+
}
|
|
213
|
+
union(a, b) {
|
|
214
|
+
const ra = this.find(a);
|
|
215
|
+
const rb = this.find(b);
|
|
216
|
+
if (ra === rb) return;
|
|
217
|
+
const rankA = this.rank.get(ra) ?? 0;
|
|
218
|
+
const rankB = this.rank.get(rb) ?? 0;
|
|
219
|
+
if (rankA < rankB) {
|
|
220
|
+
this.parent.set(ra, rb);
|
|
221
|
+
} else if (rankA > rankB) {
|
|
222
|
+
this.parent.set(rb, ra);
|
|
223
|
+
} else {
|
|
224
|
+
this.parent.set(rb, ra);
|
|
225
|
+
this.rank.set(ra, rankA + 1);
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
};
|
|
229
|
+
function detectClusters(nodes, edges, files) {
|
|
230
|
+
const uf = new UnionFind(nodes);
|
|
231
|
+
for (const edge of edges) {
|
|
232
|
+
uf.union(edge.from, edge.to);
|
|
233
|
+
}
|
|
234
|
+
const components = /* @__PURE__ */ new Map();
|
|
235
|
+
for (const node of nodes) {
|
|
236
|
+
const root = uf.find(node);
|
|
237
|
+
if (!components.has(root)) components.set(root, []);
|
|
238
|
+
components.get(root).push(node);
|
|
239
|
+
}
|
|
240
|
+
const tokenMap = new Map(files.map((f) => [f.relativePath, f.tokens]));
|
|
241
|
+
const clusters = [];
|
|
242
|
+
for (const [, groupFiles] of components) {
|
|
243
|
+
if (groupFiles.length < 2) continue;
|
|
244
|
+
const name = commonPrefix(groupFiles);
|
|
245
|
+
const fileSet = new Set(groupFiles);
|
|
246
|
+
let internalEdges = 0;
|
|
247
|
+
let externalEdges = 0;
|
|
248
|
+
for (const edge of edges) {
|
|
249
|
+
const fromIn = fileSet.has(edge.from);
|
|
250
|
+
const toIn = fileSet.has(edge.to);
|
|
251
|
+
if (fromIn && toIn) internalEdges++;
|
|
252
|
+
else if (fromIn || toIn) externalEdges++;
|
|
253
|
+
}
|
|
254
|
+
const totalEdges = internalEdges + externalEdges;
|
|
255
|
+
const cohesion = totalEdges > 0 ? internalEdges / totalEdges : 0;
|
|
256
|
+
const totalTokens = groupFiles.reduce((s, f) => s + (tokenMap.get(f) ?? 0), 0);
|
|
257
|
+
clusters.push({
|
|
258
|
+
id: name.replace(/[^a-zA-Z0-9]/g, "-") || `cluster-${clusters.length}`,
|
|
259
|
+
name: name || `cluster-${clusters.length}`,
|
|
260
|
+
files: groupFiles,
|
|
261
|
+
totalTokens,
|
|
262
|
+
internalEdges,
|
|
263
|
+
externalEdges,
|
|
264
|
+
cohesion: Math.round(cohesion * 100) / 100
|
|
265
|
+
});
|
|
266
|
+
}
|
|
267
|
+
return clusters.sort((a, b) => b.files.length - a.files.length);
|
|
268
|
+
}
|
|
269
|
+
function commonPrefix(paths) {
|
|
270
|
+
if (paths.length === 0) return "";
|
|
271
|
+
const parts = paths.map((p) => p.split("/"));
|
|
272
|
+
const prefix = [];
|
|
273
|
+
for (let i = 0; i < parts[0].length - 1; i++) {
|
|
274
|
+
const segment = parts[0][i];
|
|
275
|
+
if (parts.every((p) => p[i] === segment)) {
|
|
276
|
+
prefix.push(segment);
|
|
277
|
+
} else break;
|
|
278
|
+
}
|
|
279
|
+
return prefix.join("/") || parts[0][0];
|
|
280
|
+
}
|
|
281
|
+
function enrichComplexity(project, absPath, files) {
|
|
282
|
+
const fileMap = new Map(files.map((f) => [f.relativePath, f]));
|
|
283
|
+
for (const sourceFile of project.getSourceFiles()) {
|
|
284
|
+
const relPath = relative(absPath, sourceFile.getFilePath());
|
|
285
|
+
if (relPath.startsWith("..") || relPath.includes("node_modules")) continue;
|
|
286
|
+
const file = fileMap.get(relPath);
|
|
287
|
+
if (!file) continue;
|
|
288
|
+
let totalComplexity = 0;
|
|
289
|
+
for (const func of sourceFile.getFunctions()) {
|
|
290
|
+
totalComplexity += calculateCyclomaticComplexity(func);
|
|
291
|
+
}
|
|
292
|
+
for (const cls of sourceFile.getClasses()) {
|
|
293
|
+
for (const method of cls.getMethods()) {
|
|
294
|
+
totalComplexity += calculateCyclomaticComplexity(method);
|
|
295
|
+
}
|
|
296
|
+
}
|
|
297
|
+
for (const varDecl of sourceFile.getVariableDeclarations()) {
|
|
298
|
+
const init = varDecl.getInitializer();
|
|
299
|
+
if (init && (init.getKind() === SyntaxKind.ArrowFunction || init.getKind() === SyntaxKind.FunctionExpression)) {
|
|
300
|
+
totalComplexity += calculateCyclomaticComplexity(init);
|
|
301
|
+
}
|
|
302
|
+
}
|
|
303
|
+
file.complexity = Math.max(1, totalComplexity);
|
|
304
|
+
}
|
|
305
|
+
}
|
|
306
|
+
function calculateCyclomaticComplexity(node) {
|
|
307
|
+
let complexity = 1;
|
|
308
|
+
node.forEachDescendant((descendant) => {
|
|
309
|
+
switch (descendant.getKind()) {
|
|
310
|
+
case SyntaxKind.IfStatement:
|
|
311
|
+
case SyntaxKind.ConditionalExpression:
|
|
312
|
+
case SyntaxKind.ForStatement:
|
|
313
|
+
case SyntaxKind.ForInStatement:
|
|
314
|
+
case SyntaxKind.ForOfStatement:
|
|
315
|
+
case SyntaxKind.WhileStatement:
|
|
316
|
+
case SyntaxKind.DoStatement:
|
|
317
|
+
case SyntaxKind.CaseClause:
|
|
318
|
+
case SyntaxKind.CatchClause:
|
|
319
|
+
complexity++;
|
|
320
|
+
break;
|
|
321
|
+
case SyntaxKind.BinaryExpression: {
|
|
322
|
+
const opToken = descendant.getOperatorToken?.();
|
|
323
|
+
if (opToken) {
|
|
324
|
+
const kind = opToken.getKind();
|
|
325
|
+
if (kind === SyntaxKind.AmpersandAmpersandToken || kind === SyntaxKind.BarBarToken || kind === SyntaxKind.QuestionQuestionToken) {
|
|
326
|
+
complexity++;
|
|
327
|
+
}
|
|
328
|
+
}
|
|
329
|
+
break;
|
|
330
|
+
}
|
|
331
|
+
}
|
|
332
|
+
});
|
|
333
|
+
return complexity;
|
|
334
|
+
}
|
|
335
|
+
function resolveImport(sourceFile, moduleSpecifier, projectRoot) {
|
|
336
|
+
if (!moduleSpecifier.startsWith(".")) return null;
|
|
337
|
+
const sourceDir = dirname(sourceFile.getFilePath());
|
|
338
|
+
const basePath = resolve(sourceDir, moduleSpecifier);
|
|
339
|
+
const extensions = [".ts", ".tsx", ".js", ".jsx", "/index.ts", "/index.tsx", "/index.js", "/index.jsx"];
|
|
340
|
+
for (const ext of extensions) {
|
|
341
|
+
const candidate = basePath.endsWith(ext) ? basePath : basePath + ext;
|
|
342
|
+
if (existsSync(candidate)) {
|
|
343
|
+
const rel = relative(projectRoot, candidate);
|
|
344
|
+
if (!rel.startsWith("..")) return rel;
|
|
345
|
+
}
|
|
346
|
+
}
|
|
347
|
+
if (moduleSpecifier.endsWith(".js")) {
|
|
348
|
+
const tsPath = basePath.replace(/\.js$/, ".ts");
|
|
349
|
+
if (existsSync(tsPath)) {
|
|
350
|
+
const rel = relative(projectRoot, tsPath);
|
|
351
|
+
if (!rel.startsWith("..")) return rel;
|
|
352
|
+
}
|
|
353
|
+
}
|
|
354
|
+
return null;
|
|
355
|
+
}
|
|
356
|
+
function emptyGraph(files) {
|
|
357
|
+
return {
|
|
358
|
+
nodes: files.map((f) => f.relativePath),
|
|
359
|
+
edges: [],
|
|
360
|
+
hubs: [],
|
|
361
|
+
leaves: [],
|
|
362
|
+
orphans: files.map((f) => f.relativePath),
|
|
363
|
+
clusters: []
|
|
364
|
+
};
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
// src/engine/risk.ts
|
|
368
|
+
function scoreAllFiles(files, graph, weights = DEFAULT_RISK_WEIGHTS) {
|
|
369
|
+
const typeProviderUsage = computeTypeProviderUsage(files, graph);
|
|
370
|
+
for (const file of files) {
|
|
371
|
+
const factors = computeRiskFactors(file, graph, typeProviderUsage, weights);
|
|
372
|
+
file.riskFactors = factors;
|
|
373
|
+
file.riskScore = computeWeightedScore(factors);
|
|
374
|
+
file.exclusionImpact = scoreToImpact(file.riskScore);
|
|
375
|
+
}
|
|
376
|
+
}
|
|
377
|
+
function computeRiskFactors(file, graph, typeProviderUsage, weights) {
|
|
378
|
+
const factors = [];
|
|
379
|
+
factors.push(computeHubFactor(file, weights.hub));
|
|
380
|
+
factors.push(computeTypeProviderFactor(file, typeProviderUsage, weights.typeProvider));
|
|
381
|
+
factors.push(computeComplexityFactor(file, weights.complexity));
|
|
382
|
+
factors.push(computeRecencyFactor(file, weights.recency));
|
|
383
|
+
factors.push(computeConfigFactor(file, weights.config));
|
|
384
|
+
factors.push(computeChurnFactor(file, weights.churn));
|
|
385
|
+
return factors;
|
|
386
|
+
}
|
|
387
|
+
function computeHubFactor(file, weight) {
|
|
388
|
+
const dependents = file.importedBy.length;
|
|
389
|
+
const K = 12;
|
|
390
|
+
const score = dependents === 0 ? 0 : Math.min(100, Math.round(100 * Math.log2(1 + dependents) / Math.log2(1 + K)));
|
|
391
|
+
const detail = dependents === 0 ? "No dependents" : `Hub: ${dependents} file(s) depend on this (score ${score}/100)`;
|
|
392
|
+
return { type: "hub", score, weight, detail };
|
|
393
|
+
}
|
|
394
|
+
function computeTypeProviderFactor(file, usage, weight) {
|
|
395
|
+
const isTypeFile = file.kind === "type";
|
|
396
|
+
const consumers = usage.get(file.relativePath) ?? 0;
|
|
397
|
+
let score;
|
|
398
|
+
let detail;
|
|
399
|
+
if (isTypeFile && consumers >= 4) {
|
|
400
|
+
score = 100;
|
|
401
|
+
detail = `Type provider: used by ${consumers} files (critical type source)`;
|
|
402
|
+
} else if (isTypeFile && consumers >= 1) {
|
|
403
|
+
score = 50;
|
|
404
|
+
detail = `Type provider: used by ${consumers} files`;
|
|
405
|
+
} else if (isTypeFile) {
|
|
406
|
+
score = 30;
|
|
407
|
+
detail = "Type file (no detected consumers)";
|
|
408
|
+
} else {
|
|
409
|
+
score = 0;
|
|
410
|
+
detail = "Not a type provider";
|
|
411
|
+
}
|
|
412
|
+
return { type: "type-provider", score, weight, detail };
|
|
413
|
+
}
|
|
414
|
+
function computeComplexityFactor(file, weight) {
|
|
415
|
+
const c = file.complexity;
|
|
416
|
+
const K = 30;
|
|
417
|
+
const score = Math.min(100, Math.round(100 * Math.log(1 + c) / Math.log(1 + K)));
|
|
418
|
+
const detail = c >= 30 ? `Very high complexity: ${c} (AI needs full context)` : c >= 10 ? `High complexity: ${c}` : `Complexity: ${c}`;
|
|
419
|
+
return { type: "complexity", score, weight, detail };
|
|
420
|
+
}
|
|
421
|
+
function computeRecencyFactor(file, weight) {
|
|
422
|
+
const now = Date.now();
|
|
423
|
+
const modified = new Date(file.lastModified).getTime();
|
|
424
|
+
const daysAgo = (now - modified) / (1e3 * 60 * 60 * 24);
|
|
425
|
+
const HALF_LIFE = 7;
|
|
426
|
+
const score = Math.round(100 * Math.pow(2, -daysAgo / HALF_LIFE));
|
|
427
|
+
const detail = daysAgo <= 1 ? "Modified today" : `Modified ${Math.round(daysAgo)} days ago (decay score ${score})`;
|
|
428
|
+
return { type: "recency", score, weight, detail };
|
|
429
|
+
}
|
|
430
|
+
function computeConfigFactor(file, weight) {
|
|
431
|
+
let score;
|
|
432
|
+
let detail;
|
|
433
|
+
if (file.kind === "entry") {
|
|
434
|
+
score = 90;
|
|
435
|
+
detail = "Entry point \u2014 critical for understanding app structure";
|
|
436
|
+
} else if (file.kind === "config") {
|
|
437
|
+
score = 80;
|
|
438
|
+
detail = "Configuration file \u2014 affects runtime behavior";
|
|
439
|
+
} else {
|
|
440
|
+
score = 0;
|
|
441
|
+
detail = "Regular source file";
|
|
442
|
+
}
|
|
443
|
+
return { type: "config", score, weight, detail };
|
|
444
|
+
}
|
|
445
|
+
function computeChurnFactor(file, weight) {
|
|
446
|
+
const complexitySignal = Math.min(file.complexity / 20, 1);
|
|
447
|
+
const now = Date.now();
|
|
448
|
+
const daysAgo = (now - new Date(file.lastModified).getTime()) / (1e3 * 60 * 60 * 24);
|
|
449
|
+
const recencySignal = Math.pow(2, -daysAgo / 7);
|
|
450
|
+
const score = Math.round(Math.sqrt(complexitySignal * recencySignal) * 100);
|
|
451
|
+
const detail = score >= 50 ? "Likely under active development (complex + recent)" : score >= 20 ? "Some recent activity" : "Stable \u2014 low churn (proxy estimate)";
|
|
452
|
+
return { type: "churn", score, weight, detail };
|
|
453
|
+
}
|
|
454
|
+
function computeWeightedScore(factors) {
|
|
455
|
+
let totalWeightedScore = 0;
|
|
456
|
+
let totalWeight = 0;
|
|
457
|
+
for (const factor of factors) {
|
|
458
|
+
totalWeightedScore += factor.score * factor.weight;
|
|
459
|
+
totalWeight += factor.weight;
|
|
460
|
+
}
|
|
461
|
+
if (totalWeight === 0) return 0;
|
|
462
|
+
return Math.round(totalWeightedScore / totalWeight);
|
|
463
|
+
}
|
|
464
|
+
function scoreToImpact(score) {
|
|
465
|
+
if (score >= 80) return "critical";
|
|
466
|
+
if (score >= 60) return "high";
|
|
467
|
+
if (score >= 30) return "medium";
|
|
468
|
+
if (score > 0) return "low";
|
|
469
|
+
return "none";
|
|
470
|
+
}
|
|
471
|
+
function computeTypeProviderUsage(files, graph) {
|
|
472
|
+
const usage = /* @__PURE__ */ new Map();
|
|
473
|
+
const typeFiles = new Set(
|
|
474
|
+
files.filter((f) => f.kind === "type").map((f) => f.relativePath)
|
|
475
|
+
);
|
|
476
|
+
for (const edge of graph.edges) {
|
|
477
|
+
if (typeFiles.has(edge.to)) {
|
|
478
|
+
usage.set(edge.to, (usage.get(edge.to) ?? 0) + 1);
|
|
479
|
+
}
|
|
480
|
+
}
|
|
481
|
+
return usage;
|
|
482
|
+
}
|
|
483
|
+
|
|
484
|
+
// src/engine/analyzer.ts
|
|
485
|
+
function matchesPattern(filename, patterns) {
|
|
486
|
+
for (const pattern of patterns) {
|
|
487
|
+
if (pattern.startsWith("*.")) {
|
|
488
|
+
const ext = pattern.slice(1);
|
|
489
|
+
if (filename.endsWith(ext)) return true;
|
|
490
|
+
} else if (filename === pattern) {
|
|
491
|
+
return true;
|
|
492
|
+
}
|
|
493
|
+
}
|
|
494
|
+
return false;
|
|
495
|
+
}
|
|
496
|
+
async function walkProject(rootPath, options) {
|
|
497
|
+
const results = [];
|
|
498
|
+
const { ignoreDirs, ignorePatterns, extensions, maxDepth = 20 } = options;
|
|
499
|
+
const ignoreDirSet = new Set(ignoreDirs);
|
|
500
|
+
async function walk(dir, depth) {
|
|
501
|
+
if (depth > maxDepth) return;
|
|
502
|
+
let entries;
|
|
503
|
+
try {
|
|
504
|
+
entries = await readdir(dir, { withFileTypes: true });
|
|
505
|
+
} catch {
|
|
506
|
+
return;
|
|
507
|
+
}
|
|
508
|
+
const promises = [];
|
|
509
|
+
for (const entry of entries) {
|
|
510
|
+
const fullPath = join2(dir, entry.name);
|
|
511
|
+
if (entry.isDirectory()) {
|
|
512
|
+
if (!ignoreDirSet.has(entry.name) && !entry.name.startsWith(".")) {
|
|
513
|
+
promises.push(walk(fullPath, depth + 1));
|
|
514
|
+
}
|
|
515
|
+
} else if (entry.isFile()) {
|
|
516
|
+
const ext = extname(entry.name).slice(1).toLowerCase();
|
|
517
|
+
if (ext && extensions.includes(ext) && !matchesPattern(entry.name, ignorePatterns)) {
|
|
518
|
+
promises.push(
|
|
519
|
+
(async () => {
|
|
520
|
+
const fileStat = await stat2(fullPath).catch(() => null);
|
|
521
|
+
if (!fileStat) return;
|
|
522
|
+
let lines = 0;
|
|
523
|
+
try {
|
|
524
|
+
const content = await readFile2(fullPath, "utf-8");
|
|
525
|
+
lines = content.split("\n").length;
|
|
526
|
+
} catch {
|
|
527
|
+
lines = 0;
|
|
528
|
+
}
|
|
529
|
+
results.push({
|
|
530
|
+
path: fullPath,
|
|
531
|
+
relativePath: relative2(rootPath, fullPath),
|
|
532
|
+
extension: ext,
|
|
533
|
+
size: fileStat.size,
|
|
534
|
+
lastModified: fileStat.mtime,
|
|
535
|
+
lines
|
|
536
|
+
});
|
|
537
|
+
})()
|
|
538
|
+
);
|
|
539
|
+
}
|
|
540
|
+
}
|
|
541
|
+
}
|
|
542
|
+
await Promise.all(promises);
|
|
543
|
+
}
|
|
544
|
+
await walk(rootPath, 0);
|
|
545
|
+
return results;
|
|
546
|
+
}
|
|
547
|
+
var TYPE_PATTERNS = [/types?\//i, /\.d\.ts$/, /interfaces?\//i];
|
|
548
|
+
var TEST_PATTERNS = [/\.test\.[jt]sx?$/, /\.spec\.[jt]sx?$/, /\/__tests__\//, /\/tests?\//];
|
|
549
|
+
var CONFIG_PATTERNS = [/\.config\.[jt]s$/, /rc\.[jt]s$/, /\.env/, /tsconfig/, /package\.json$/, /\.yml$/, /\.yaml$/, /\.toml$/];
|
|
550
|
+
var ENTRY_PATTERNS = [/^index\.[jt]sx?$/, /^main\.[jt]sx?$/, /^app\.[jt]sx?$/, /^server\.[jt]sx?$/];
|
|
551
|
+
function classifyFileKind(relativePath) {
|
|
552
|
+
const filename = basename2(relativePath);
|
|
553
|
+
if (TYPE_PATTERNS.some((p) => p.test(relativePath))) return "type";
|
|
554
|
+
if (TEST_PATTERNS.some((p) => p.test(relativePath))) return "test";
|
|
555
|
+
if (CONFIG_PATTERNS.some((p) => p.test(relativePath) || p.test(filename))) return "config";
|
|
556
|
+
if (ENTRY_PATTERNS.some((p) => p.test(filename))) return "entry";
|
|
557
|
+
return "source";
|
|
558
|
+
}
|
|
559
|
+
function detectStack(files) {
|
|
560
|
+
const stack = [];
|
|
561
|
+
const extensions = new Set(files.map((f) => f.extension));
|
|
562
|
+
const paths = files.map((f) => f.relativePath.toLowerCase());
|
|
563
|
+
if (extensions.has("ts") || extensions.has("tsx")) stack.push("TypeScript");
|
|
564
|
+
else if (extensions.has("js") || extensions.has("jsx")) stack.push("JavaScript");
|
|
565
|
+
if (extensions.has("py")) stack.push("Python");
|
|
566
|
+
if (extensions.has("go")) stack.push("Go");
|
|
567
|
+
if (extensions.has("rs")) stack.push("Rust");
|
|
568
|
+
if (extensions.has("java")) stack.push("Java");
|
|
569
|
+
if (extensions.has("kt")) stack.push("Kotlin");
|
|
570
|
+
if (extensions.has("rb")) stack.push("Ruby");
|
|
571
|
+
if (extensions.has("php")) stack.push("PHP");
|
|
572
|
+
if (extensions.has("cs")) stack.push("C#");
|
|
573
|
+
if (extensions.has("c") || extensions.has("cpp")) stack.push("C/C++");
|
|
574
|
+
if (paths.some((p) => p.includes("next.config"))) stack.push("Next.js");
|
|
575
|
+
if (paths.some((p) => p.includes("nuxt.config"))) stack.push("Nuxt");
|
|
576
|
+
if (paths.some((p) => p.includes("angular.json"))) stack.push("Angular");
|
|
577
|
+
return stack;
|
|
578
|
+
}
|
|
579
|
+
async function analyzeProject(projectPath, config) {
|
|
580
|
+
const absPath = resolve2(projectPath);
|
|
581
|
+
const projectName = basename2(absPath);
|
|
582
|
+
const mergedConfig = mergeConfig(DEFAULT_CONFIG, config);
|
|
583
|
+
const allExtensions = [
|
|
584
|
+
...mergedConfig.analysis.extensions.code,
|
|
585
|
+
...mergedConfig.analysis.extensions.config,
|
|
586
|
+
...mergedConfig.analysis.extensions.docs
|
|
587
|
+
];
|
|
588
|
+
const walkEntries = await walkProject(absPath, {
|
|
589
|
+
ignoreDirs: mergedConfig.analysis.ignore.dirs,
|
|
590
|
+
ignorePatterns: mergedConfig.analysis.ignore.patterns,
|
|
591
|
+
extensions: allExtensions,
|
|
592
|
+
maxDepth: mergedConfig.analysis.maxDepth
|
|
593
|
+
});
|
|
594
|
+
const tokenMethod = mergedConfig.tokens.method;
|
|
595
|
+
const files = [];
|
|
596
|
+
for (const entry of walkEntries) {
|
|
597
|
+
let tokens;
|
|
598
|
+
if (tokenMethod === "tiktoken") {
|
|
599
|
+
try {
|
|
600
|
+
const content = await readFile2(entry.path, "utf-8");
|
|
601
|
+
tokens = estimateTokens(content, entry.size, "tiktoken");
|
|
602
|
+
} catch {
|
|
603
|
+
tokens = countTokensChars4(entry.size);
|
|
604
|
+
}
|
|
605
|
+
} else {
|
|
606
|
+
tokens = countTokensChars4(entry.size);
|
|
607
|
+
}
|
|
608
|
+
files.push({
|
|
609
|
+
path: entry.path,
|
|
610
|
+
relativePath: entry.relativePath,
|
|
611
|
+
extension: entry.extension,
|
|
612
|
+
size: entry.size,
|
|
613
|
+
tokens,
|
|
614
|
+
lines: entry.lines,
|
|
615
|
+
lastModified: entry.lastModified,
|
|
616
|
+
kind: classifyFileKind(entry.relativePath),
|
|
617
|
+
// Graph data — populated by graph analysis
|
|
618
|
+
imports: [],
|
|
619
|
+
importedBy: [],
|
|
620
|
+
isHub: false,
|
|
621
|
+
complexity: 0,
|
|
622
|
+
// Risk data — populated by risk analysis
|
|
623
|
+
riskScore: 0,
|
|
624
|
+
riskFactors: [],
|
|
625
|
+
exclusionImpact: "none"
|
|
626
|
+
});
|
|
627
|
+
}
|
|
628
|
+
const graph = buildProjectGraph(absPath, files);
|
|
629
|
+
for (const file of files) {
|
|
630
|
+
const nodeImports = [];
|
|
631
|
+
const nodeImportedBy = [];
|
|
632
|
+
for (const edge of graph.edges) {
|
|
633
|
+
if (edge.from === file.relativePath) nodeImports.push(edge.to);
|
|
634
|
+
if (edge.to === file.relativePath) nodeImportedBy.push(edge.from);
|
|
635
|
+
}
|
|
636
|
+
file.imports = nodeImports;
|
|
637
|
+
file.importedBy = nodeImportedBy;
|
|
638
|
+
file.isHub = graph.hubs.some((h) => h.relativePath === file.relativePath);
|
|
639
|
+
}
|
|
640
|
+
const riskWeights = mergedConfig.risk.weights;
|
|
641
|
+
scoreAllFiles(files, graph, riskWeights);
|
|
642
|
+
const riskProfile = {
|
|
643
|
+
distribution: {
|
|
644
|
+
critical: files.filter((f) => f.riskScore >= 80).length,
|
|
645
|
+
high: files.filter((f) => f.riskScore >= 60 && f.riskScore < 80).length,
|
|
646
|
+
medium: files.filter((f) => f.riskScore >= 30 && f.riskScore < 60).length,
|
|
647
|
+
low: files.filter((f) => f.riskScore < 30).length
|
|
648
|
+
},
|
|
649
|
+
topRiskFiles: [...files].sort((a, b) => b.riskScore - a.riskScore).slice(0, 10),
|
|
650
|
+
overallComplexity: files.length > 0 ? files.reduce((s, f) => s + f.complexity, 0) / files.length : 0
|
|
651
|
+
};
|
|
652
|
+
const totalTokens = files.reduce((s, f) => s + f.tokens, 0);
|
|
653
|
+
const hashInput = files.map((f) => `${f.relativePath}:${f.tokens}:${f.riskScore}`).sort().join("|");
|
|
654
|
+
const hash = createHash("sha256").update(hashInput).digest("hex").substring(0, 16);
|
|
655
|
+
const stack = detectStack(walkEntries);
|
|
656
|
+
return {
|
|
657
|
+
projectPath: absPath,
|
|
658
|
+
projectName,
|
|
659
|
+
analyzedAt: /* @__PURE__ */ new Date(),
|
|
660
|
+
hash,
|
|
661
|
+
files,
|
|
662
|
+
totalFiles: files.length,
|
|
663
|
+
totalTokens,
|
|
664
|
+
graph,
|
|
665
|
+
riskProfile,
|
|
666
|
+
stack,
|
|
667
|
+
tokenMethod
|
|
668
|
+
};
|
|
669
|
+
}
|
|
670
|
+
function mergeConfig(base, overrides) {
|
|
671
|
+
if (!overrides) return base;
|
|
672
|
+
return {
|
|
673
|
+
...base,
|
|
674
|
+
...overrides,
|
|
675
|
+
analysis: {
|
|
676
|
+
...base.analysis,
|
|
677
|
+
...overrides.analysis,
|
|
678
|
+
extensions: {
|
|
679
|
+
...base.analysis.extensions,
|
|
680
|
+
...overrides.analysis?.extensions
|
|
681
|
+
},
|
|
682
|
+
ignore: {
|
|
683
|
+
...base.analysis.ignore,
|
|
684
|
+
...overrides.analysis?.ignore
|
|
685
|
+
}
|
|
686
|
+
},
|
|
687
|
+
risk: {
|
|
688
|
+
...base.risk,
|
|
689
|
+
...overrides.risk,
|
|
690
|
+
weights: {
|
|
691
|
+
...base.risk.weights,
|
|
692
|
+
...overrides.risk?.weights
|
|
693
|
+
}
|
|
694
|
+
},
|
|
695
|
+
interaction: {
|
|
696
|
+
...base.interaction,
|
|
697
|
+
...overrides.interaction
|
|
698
|
+
},
|
|
699
|
+
tokens: {
|
|
700
|
+
...base.tokens,
|
|
701
|
+
...overrides.tokens
|
|
702
|
+
},
|
|
703
|
+
governance: {
|
|
704
|
+
...base.governance,
|
|
705
|
+
...overrides.governance
|
|
706
|
+
}
|
|
707
|
+
};
|
|
708
|
+
}
|
|
709
|
+
|
|
710
|
+
// src/engine/cache.ts
|
|
711
|
+
import { createHash as createHash2 } from "crypto";
|
|
712
|
+
import { readdir as readdir2, stat as stat3 } from "fs/promises";
|
|
713
|
+
import { join as join3, extname as extname2, relative as relative3, resolve as resolve3 } from "path";
|
|
714
|
+
var DEFAULT_CACHE_OPTIONS = {
|
|
715
|
+
maxAgeMs: 5 * 60 * 1e3,
|
|
716
|
+
// 5 minutes
|
|
717
|
+
maxEntries: 10,
|
|
718
|
+
enabled: true
|
|
719
|
+
};
|
|
720
|
+
var cache = /* @__PURE__ */ new Map();
|
|
721
|
+
var cacheOptions = { ...DEFAULT_CACHE_OPTIONS };
|
|
722
|
+
async function computeFingerprint(rootPath, config = DEFAULT_CONFIG) {
|
|
723
|
+
const entries = [];
|
|
724
|
+
const allExtensions = /* @__PURE__ */ new Set([
|
|
725
|
+
...config.analysis.extensions.code,
|
|
726
|
+
...config.analysis.extensions.config,
|
|
727
|
+
...config.analysis.extensions.docs
|
|
728
|
+
]);
|
|
729
|
+
const ignoreDirSet = new Set(config.analysis.ignore.dirs);
|
|
730
|
+
async function walk(dir, depth) {
|
|
731
|
+
if (depth > config.analysis.maxDepth) return;
|
|
732
|
+
let dirEntries;
|
|
733
|
+
try {
|
|
734
|
+
dirEntries = await readdir2(dir, { withFileTypes: true });
|
|
735
|
+
} catch {
|
|
736
|
+
return;
|
|
737
|
+
}
|
|
738
|
+
const promises = [];
|
|
739
|
+
for (const entry of dirEntries) {
|
|
740
|
+
const fullPath = join3(dir, entry.name);
|
|
741
|
+
if (entry.isDirectory()) {
|
|
742
|
+
if (!ignoreDirSet.has(entry.name) && !entry.name.startsWith(".")) {
|
|
743
|
+
promises.push(walk(fullPath, depth + 1));
|
|
744
|
+
}
|
|
745
|
+
} else if (entry.isFile()) {
|
|
746
|
+
const ext = extname2(entry.name).slice(1).toLowerCase();
|
|
747
|
+
if (ext && allExtensions.has(ext)) {
|
|
748
|
+
promises.push(
|
|
749
|
+
(async () => {
|
|
750
|
+
try {
|
|
751
|
+
const s = await stat3(fullPath);
|
|
752
|
+
const rel = relative3(rootPath, fullPath);
|
|
753
|
+
entries.push(`${rel}:${s.mtimeMs.toFixed(0)}:${s.size}`);
|
|
754
|
+
} catch {
|
|
755
|
+
}
|
|
756
|
+
})()
|
|
757
|
+
);
|
|
758
|
+
}
|
|
759
|
+
}
|
|
760
|
+
}
|
|
761
|
+
await Promise.all(promises);
|
|
762
|
+
}
|
|
763
|
+
await walk(rootPath, 0);
|
|
764
|
+
entries.sort();
|
|
765
|
+
return createHash2("sha256").update(entries.join("|")).digest("hex").substring(0, 16);
|
|
766
|
+
}
|
|
767
|
+
async function getCachedAnalysis(projectPath, config) {
|
|
768
|
+
const absPath = resolve3(projectPath);
|
|
769
|
+
if (!cacheOptions.enabled) {
|
|
770
|
+
return analyzeProject(absPath, config);
|
|
771
|
+
}
|
|
772
|
+
const existing = cache.get(absPath);
|
|
773
|
+
if (existing) {
|
|
774
|
+
const age = Date.now() - existing.createdAt;
|
|
775
|
+
if (age > cacheOptions.maxAgeMs) {
|
|
776
|
+
cache.delete(absPath);
|
|
777
|
+
}
|
|
778
|
+
}
|
|
779
|
+
const mergedConfig = config ? { ...DEFAULT_CONFIG, ...config } : DEFAULT_CONFIG;
|
|
780
|
+
const fingerprint = await computeFingerprint(absPath, mergedConfig);
|
|
781
|
+
const cached = cache.get(absPath);
|
|
782
|
+
if (cached && cached.fingerprint === fingerprint) {
|
|
783
|
+
cached.hits++;
|
|
784
|
+
return cached.analysis;
|
|
785
|
+
}
|
|
786
|
+
const analysis = await analyzeProject(absPath, config);
|
|
787
|
+
if (cache.size >= cacheOptions.maxEntries) {
|
|
788
|
+
const oldest = [...cache.entries()].sort(
|
|
789
|
+
(a, b) => a[1].createdAt - b[1].createdAt
|
|
790
|
+
)[0];
|
|
791
|
+
if (oldest) cache.delete(oldest[0]);
|
|
792
|
+
}
|
|
793
|
+
cache.set(absPath, {
|
|
794
|
+
analysis,
|
|
795
|
+
fingerprint,
|
|
796
|
+
createdAt: Date.now(),
|
|
797
|
+
hits: 0
|
|
798
|
+
});
|
|
799
|
+
return analysis;
|
|
800
|
+
}
|
|
801
|
+
|
|
802
|
+
// src/engine/selector.ts
|
|
803
|
+
import { createHash as createHash3 } from "crypto";
|
|
804
|
+
|
|
805
|
+
// src/govern/secrets.ts
|
|
806
|
+
import { readFile as readFile3 } from "fs/promises";
|
|
807
|
+
import { resolve as resolve4, relative as relative4 } from "path";
|
|
808
|
+
var BUILTIN_PATTERNS = [
|
|
809
|
+
// API Keys
|
|
810
|
+
{ type: "api-key", source: `(?:api[_-]?key|apikey)\\s*[:=]\\s*['"]?([a-zA-Z0-9_\\-]{20,})['"]?`, flags: "gi", severity: "critical", description: "API Key" },
|
|
811
|
+
{ type: "api-key", source: "sk-[a-zA-Z0-9]{20,}", flags: "g", severity: "critical", description: "OpenAI/Anthropic API Key" },
|
|
812
|
+
{ type: "api-key", source: "sk-ant-[a-zA-Z0-9\\-]{20,}", flags: "g", severity: "critical", description: "Anthropic API Key" },
|
|
813
|
+
// AWS
|
|
814
|
+
{ type: "aws-key", source: "AKIA[0-9A-Z]{16}", flags: "g", severity: "critical", description: "AWS Access Key ID" },
|
|
815
|
+
{ type: "aws-key", source: `(?:aws_secret_access_key|aws_secret)\\s*[:=]\\s*['"]?([a-zA-Z0-9/+=]{40})['"]?`, flags: "gi", severity: "critical", description: "AWS Secret Key" },
|
|
816
|
+
// Private Keys
|
|
817
|
+
{ type: "private-key", source: "-----BEGIN (?:RSA |EC |DSA )?PRIVATE KEY-----", flags: "g", severity: "critical", description: "Private Key" },
|
|
818
|
+
{ type: "private-key", source: "-----BEGIN OPENSSH PRIVATE KEY-----", flags: "g", severity: "critical", description: "SSH Private Key" },
|
|
819
|
+
// Passwords
|
|
820
|
+
{ type: "password", source: `(?:password|passwd|pwd)\\s*[:=]\\s*['"]([^'"]{8,})['"](?!\\s*\\{)`, flags: "gi", severity: "high", description: "Hardcoded Password" },
|
|
821
|
+
{ type: "password", source: `(?:DB_PASSWORD|DATABASE_PASSWORD|MYSQL_PASSWORD|POSTGRES_PASSWORD)\\s*[:=]\\s*['"]?([^'"{}\\s]{4,})['"]?`, flags: "gi", severity: "high", description: "Database Password" },
|
|
822
|
+
// Tokens
|
|
823
|
+
{ type: "token", source: `(?:bearer|token|auth_token|access_token|refresh_token)\\s*[:=]\\s*['"]([a-zA-Z0-9_\\-.]{20,})['"](?!\\s*\\{)`, flags: "gi", severity: "high", description: "Auth Token" },
|
|
824
|
+
{ type: "token", source: "ghp_[a-zA-Z0-9]{36}", flags: "g", severity: "critical", description: "GitHub Personal Access Token" },
|
|
825
|
+
{ type: "token", source: "gho_[a-zA-Z0-9]{36}", flags: "g", severity: "critical", description: "GitHub OAuth Token" },
|
|
826
|
+
{ type: "token", source: "glpat-[a-zA-Z0-9\\-]{20,}", flags: "g", severity: "critical", description: "GitLab Personal Access Token" },
|
|
827
|
+
{ type: "token", source: "npm_[a-zA-Z0-9]{36}", flags: "g", severity: "high", description: "npm Token" },
|
|
828
|
+
// Connection strings
|
|
829
|
+
{ type: "connection-string", source: `(?:mongodb(?:\\+srv)?|postgres(?:ql)?|mysql|redis|amqp):\\/\\/[^\\s'"]+:[^\\s'"]+@[^\\s'"]+`, flags: "gi", severity: "critical", description: "Database Connection String" },
|
|
830
|
+
{ type: "connection-string", source: `(?:DATABASE_URL|REDIS_URL|MONGODB_URI)\\s*[:=]\\s*['"]?([^\\s'"]{10,})['"]?`, flags: "gi", severity: "high", description: "Database URL" },
|
|
831
|
+
// Environment variables with secrets
|
|
832
|
+
{ type: "env-variable", source: `(?:SECRET|PRIVATE|ENCRYPTION)[_-]?(?:KEY|TOKEN|PASS)\\s*[:=]\\s*['"]?([^\\s'"]{8,})['"]?`, flags: "gi", severity: "high", description: "Secret Environment Variable" }
|
|
833
|
+
];
|
|
834
|
+
function buildPatterns(customPatterns = []) {
|
|
835
|
+
const patterns = BUILTIN_PATTERNS.map((def) => ({
|
|
836
|
+
type: def.type,
|
|
837
|
+
pattern: new RegExp(def.source, def.flags),
|
|
838
|
+
severity: def.severity,
|
|
839
|
+
description: def.description
|
|
840
|
+
}));
|
|
841
|
+
for (const custom of customPatterns) {
|
|
842
|
+
try {
|
|
843
|
+
patterns.push({
|
|
844
|
+
type: "custom",
|
|
845
|
+
pattern: new RegExp(custom, "gi"),
|
|
846
|
+
severity: "medium",
|
|
847
|
+
description: `Custom pattern: ${custom}`
|
|
848
|
+
});
|
|
849
|
+
} catch {
|
|
850
|
+
}
|
|
851
|
+
}
|
|
852
|
+
return patterns;
|
|
853
|
+
}
|
|
854
|
+
function scanContentForSecrets(content, filePath, customPatterns = []) {
|
|
855
|
+
const findings = [];
|
|
856
|
+
const lines = content.split("\n");
|
|
857
|
+
const allPatterns = buildPatterns(customPatterns);
|
|
858
|
+
for (const secretPattern of allPatterns) {
|
|
859
|
+
for (let i = 0; i < lines.length; i++) {
|
|
860
|
+
const line = lines[i];
|
|
861
|
+
secretPattern.pattern.lastIndex = 0;
|
|
862
|
+
let match;
|
|
863
|
+
while ((match = secretPattern.pattern.exec(line)) !== null) {
|
|
864
|
+
const matchText = match[0];
|
|
865
|
+
if (isTemplateOrPlaceholder(matchText)) continue;
|
|
866
|
+
findings.push({
|
|
867
|
+
type: secretPattern.type,
|
|
868
|
+
file: filePath,
|
|
869
|
+
line: i + 1,
|
|
870
|
+
match: matchText,
|
|
871
|
+
redacted: redactSecret(matchText),
|
|
872
|
+
severity: secretPattern.severity
|
|
873
|
+
});
|
|
874
|
+
}
|
|
875
|
+
}
|
|
876
|
+
}
|
|
877
|
+
return deduplicateFindings(findings);
|
|
878
|
+
}
|
|
879
|
+
async function scanFileForSecrets(filePath, projectPath, customPatterns = []) {
|
|
880
|
+
try {
|
|
881
|
+
const content = await readFile3(filePath, "utf-8");
|
|
882
|
+
const relPath = relative4(resolve4(projectPath), resolve4(filePath));
|
|
883
|
+
return scanContentForSecrets(content, relPath, customPatterns);
|
|
884
|
+
} catch {
|
|
885
|
+
return [];
|
|
886
|
+
}
|
|
887
|
+
}
|
|
888
|
+
function redactSecret(value) {
|
|
889
|
+
if (value.length <= 8) return "***REDACTED***";
|
|
890
|
+
const prefix = value.substring(0, 4);
|
|
891
|
+
const suffix = value.substring(value.length - 2);
|
|
892
|
+
return `${prefix}${"*".repeat(Math.min(value.length - 6, 20))}${suffix}`;
|
|
893
|
+
}
|
|
894
|
+
function isTemplateOrPlaceholder(value) {
|
|
895
|
+
const placeholders = [
|
|
896
|
+
/\$\{.*\}/,
|
|
897
|
+
/\{\{.*\}\}/,
|
|
898
|
+
/%[sd]/,
|
|
899
|
+
/<[A-Z_]+>/,
|
|
900
|
+
/YOUR_.*_HERE/i,
|
|
901
|
+
/\bCHANGE_ME\b/i,
|
|
902
|
+
/\bPLACEHOLDER\b/i,
|
|
903
|
+
/\bexample\b/i,
|
|
904
|
+
/\bTODO\b/i,
|
|
905
|
+
/xxx+/i,
|
|
906
|
+
/\breplace.?me\b/i,
|
|
907
|
+
/\bdummy\b/i,
|
|
908
|
+
/\btest_?key\b/i,
|
|
909
|
+
/\bsample\b/i
|
|
910
|
+
];
|
|
911
|
+
return placeholders.some((p) => p.test(value));
|
|
912
|
+
}
|
|
913
|
+
function deduplicateFindings(findings) {
|
|
914
|
+
const seen = /* @__PURE__ */ new Set();
|
|
915
|
+
return findings.filter((f) => {
|
|
916
|
+
const key = `${f.file}:${f.line}:${f.type}:${f.match}`;
|
|
917
|
+
if (seen.has(key)) return false;
|
|
918
|
+
seen.add(key);
|
|
919
|
+
return true;
|
|
920
|
+
});
|
|
921
|
+
}
|
|
922
|
+
|
|
923
|
+
// src/engine/pruner.ts
|
|
924
|
+
import { Project as Project2, SyntaxKind as SyntaxKind2 } from "ts-morph";
|
|
925
|
+
import { readFile as readFile4 } from "fs/promises";
|
|
926
|
+
import { existsSync as existsSync2 } from "fs";
|
|
927
|
+
import { join as join4 } from "path";
|
|
928
|
+
var TS_EXTENSIONS2 = /* @__PURE__ */ new Set(["ts", "tsx", "js", "jsx", "mts", "mjs"]);
|
|
929
|
+
async function pruneFile(file, level) {
|
|
930
|
+
if (level === "excluded") {
|
|
931
|
+
return emptyResult(file, "excluded");
|
|
932
|
+
}
|
|
933
|
+
if (level === "full") {
|
|
934
|
+
return fullContent(file);
|
|
935
|
+
}
|
|
936
|
+
const ext = file.extension.toLowerCase();
|
|
937
|
+
const isTS = TS_EXTENSIONS2.has(ext);
|
|
938
|
+
if (isTS) {
|
|
939
|
+
return pruneTypeScript(file, level);
|
|
940
|
+
}
|
|
941
|
+
return pruneGeneric(file, level);
|
|
942
|
+
}
|
|
943
|
+
async function pruneTypeScript(file, level) {
|
|
944
|
+
let content;
|
|
945
|
+
try {
|
|
946
|
+
content = await readFile4(file.path, "utf-8");
|
|
947
|
+
} catch {
|
|
948
|
+
return emptyResult(file, level);
|
|
949
|
+
}
|
|
950
|
+
let project;
|
|
951
|
+
try {
|
|
952
|
+
const tsConfigPath = findTsConfig(file.path);
|
|
953
|
+
project = new Project2({
|
|
954
|
+
tsConfigFilePath: tsConfigPath,
|
|
955
|
+
skipAddingFilesFromTsConfig: true,
|
|
956
|
+
compilerOptions: tsConfigPath ? void 0 : { allowJs: true, esModuleInterop: true }
|
|
957
|
+
});
|
|
958
|
+
project.createSourceFile(file.path, content, { overwrite: true });
|
|
959
|
+
} catch {
|
|
960
|
+
return pruneGenericFromContent(file, content, level);
|
|
961
|
+
}
|
|
962
|
+
const sourceFile = project.getSourceFiles()[0];
|
|
963
|
+
if (!sourceFile) {
|
|
964
|
+
return pruneGenericFromContent(file, content, level);
|
|
965
|
+
}
|
|
966
|
+
const prunedContent = level === "signatures" ? extractSignaturesAST(sourceFile) : extractSkeletonAST(sourceFile);
|
|
967
|
+
const prunedTokens = countTokensChars4(Buffer.byteLength(prunedContent, "utf-8"));
|
|
968
|
+
const savingsPercent = file.tokens > 0 ? (file.tokens - prunedTokens) / file.tokens * 100 : 0;
|
|
969
|
+
return {
|
|
970
|
+
relativePath: file.relativePath,
|
|
971
|
+
originalTokens: file.tokens,
|
|
972
|
+
prunedTokens,
|
|
973
|
+
pruneLevel: level,
|
|
974
|
+
content: prunedContent,
|
|
975
|
+
savingsPercent: Math.max(0, savingsPercent)
|
|
976
|
+
};
|
|
977
|
+
}
|
|
978
|
+
function extractSignaturesAST(sf) {
|
|
979
|
+
const parts = [];
|
|
980
|
+
for (const imp of sf.getImportDeclarations()) {
|
|
981
|
+
parts.push(imp.getText());
|
|
982
|
+
}
|
|
983
|
+
if (parts.length > 0) parts.push("");
|
|
984
|
+
for (const ta of sf.getTypeAliases()) {
|
|
985
|
+
addJSDoc(ta, parts);
|
|
986
|
+
parts.push(ta.getText());
|
|
987
|
+
}
|
|
988
|
+
for (const iface of sf.getInterfaces()) {
|
|
989
|
+
addJSDoc(iface, parts);
|
|
990
|
+
parts.push(iface.getText());
|
|
991
|
+
}
|
|
992
|
+
for (const en of sf.getEnums()) {
|
|
993
|
+
addJSDoc(en, parts);
|
|
994
|
+
parts.push(en.getText());
|
|
995
|
+
}
|
|
996
|
+
for (const fn of sf.getFunctions()) {
|
|
997
|
+
addJSDoc(fn, parts);
|
|
998
|
+
const isExported = fn.isExported();
|
|
999
|
+
const isAsync = fn.isAsync();
|
|
1000
|
+
const name = fn.getName() ?? "<anonymous>";
|
|
1001
|
+
const params = fn.getParameters().map((p) => p.getText()).join(", ");
|
|
1002
|
+
const returnType = fn.getReturnTypeNode()?.getText();
|
|
1003
|
+
const returnStr = returnType ? `: ${returnType}` : "";
|
|
1004
|
+
const prefix = isExported ? "export " : "";
|
|
1005
|
+
const asyncStr = isAsync ? "async " : "";
|
|
1006
|
+
parts.push(`${prefix}${asyncStr}function ${name}(${params})${returnStr} { /* ... */ }`);
|
|
1007
|
+
}
|
|
1008
|
+
for (const stmt of sf.getVariableStatements()) {
|
|
1009
|
+
for (const decl of stmt.getDeclarations()) {
|
|
1010
|
+
const init = decl.getInitializer();
|
|
1011
|
+
if (init && (init.getKind() === SyntaxKind2.ArrowFunction || init.getKind() === SyntaxKind2.FunctionExpression)) {
|
|
1012
|
+
addJSDoc(stmt, parts);
|
|
1013
|
+
const isExported = stmt.isExported();
|
|
1014
|
+
const prefix = isExported ? "export " : "";
|
|
1015
|
+
const kind = stmt.getDeclarationKind();
|
|
1016
|
+
const name = decl.getName();
|
|
1017
|
+
const typeNode = decl.getTypeNode()?.getText();
|
|
1018
|
+
const typeStr = typeNode ? `: ${typeNode}` : "";
|
|
1019
|
+
parts.push(`${prefix}${kind} ${name}${typeStr} = /* ... */;`);
|
|
1020
|
+
} else {
|
|
1021
|
+
addJSDoc(stmt, parts);
|
|
1022
|
+
parts.push(stmt.getText());
|
|
1023
|
+
}
|
|
1024
|
+
}
|
|
1025
|
+
}
|
|
1026
|
+
for (const cls of sf.getClasses()) {
|
|
1027
|
+
addJSDoc(cls, parts);
|
|
1028
|
+
const isExported = cls.isExported();
|
|
1029
|
+
const prefix = isExported ? "export " : "";
|
|
1030
|
+
const name = cls.getName() ?? "<anonymous>";
|
|
1031
|
+
const ext = cls.getExtends()?.getText();
|
|
1032
|
+
const impl = cls.getImplements().map((i) => i.getText()).join(", ");
|
|
1033
|
+
let header = `${prefix}class ${name}`;
|
|
1034
|
+
if (ext) header += ` extends ${ext}`;
|
|
1035
|
+
if (impl) header += ` implements ${impl}`;
|
|
1036
|
+
header += " {";
|
|
1037
|
+
parts.push(header);
|
|
1038
|
+
for (const prop of cls.getProperties()) {
|
|
1039
|
+
parts.push(` ${prop.getText()}`);
|
|
1040
|
+
}
|
|
1041
|
+
const ctor = cls.getConstructors()[0];
|
|
1042
|
+
if (ctor) {
|
|
1043
|
+
const ctorParams = ctor.getParameters().map((p) => p.getText()).join(", ");
|
|
1044
|
+
parts.push(` constructor(${ctorParams}) { /* ... */ }`);
|
|
1045
|
+
}
|
|
1046
|
+
for (const method of cls.getMethods()) {
|
|
1047
|
+
const isStatic = method.isStatic();
|
|
1048
|
+
const isAsync = method.isAsync();
|
|
1049
|
+
const methodName = method.getName();
|
|
1050
|
+
const methodParams = method.getParameters().map((p) => p.getText()).join(", ");
|
|
1051
|
+
const returnType = method.getReturnTypeNode()?.getText();
|
|
1052
|
+
const returnStr = returnType ? `: ${returnType}` : "";
|
|
1053
|
+
const staticStr = isStatic ? "static " : "";
|
|
1054
|
+
const asyncStr = isAsync ? "async " : "";
|
|
1055
|
+
parts.push(` ${staticStr}${asyncStr}${methodName}(${methodParams})${returnStr} { /* ... */ }`);
|
|
1056
|
+
}
|
|
1057
|
+
parts.push("}");
|
|
1058
|
+
}
|
|
1059
|
+
for (const exp of sf.getExportDeclarations()) {
|
|
1060
|
+
parts.push(exp.getText());
|
|
1061
|
+
}
|
|
1062
|
+
for (const exp of sf.getExportAssignments()) {
|
|
1063
|
+
parts.push(exp.getText());
|
|
1064
|
+
}
|
|
1065
|
+
return parts.join("\n");
|
|
1066
|
+
}
|
|
1067
|
+
function extractSkeletonAST(sf) {
|
|
1068
|
+
const parts = [];
|
|
1069
|
+
for (const imp of sf.getImportDeclarations()) {
|
|
1070
|
+
parts.push(imp.getText());
|
|
1071
|
+
}
|
|
1072
|
+
if (parts.length > 0) parts.push("");
|
|
1073
|
+
for (const ta of sf.getTypeAliases()) {
|
|
1074
|
+
if (ta.isExported()) parts.push(ta.getText());
|
|
1075
|
+
}
|
|
1076
|
+
for (const iface of sf.getInterfaces()) {
|
|
1077
|
+
if (!iface.isExported()) continue;
|
|
1078
|
+
const ext = iface.getExtends().map((e) => e.getText());
|
|
1079
|
+
const extStr = ext.length > 0 ? ` extends ${ext.join(", ")}` : "";
|
|
1080
|
+
parts.push(`export interface ${iface.getName()}${extStr} { /* ${iface.getProperties().length} props */ }`);
|
|
1081
|
+
}
|
|
1082
|
+
for (const en of sf.getEnums()) {
|
|
1083
|
+
if (!en.isExported()) continue;
|
|
1084
|
+
const members = en.getMembers().map((m) => m.getName());
|
|
1085
|
+
parts.push(`export enum ${en.getName()} { ${members.join(", ")} }`);
|
|
1086
|
+
}
|
|
1087
|
+
for (const fn of sf.getFunctions()) {
|
|
1088
|
+
if (!fn.isExported()) continue;
|
|
1089
|
+
const name = fn.getName() ?? "<anonymous>";
|
|
1090
|
+
const params = fn.getParameters().map((p) => p.getText()).join(", ");
|
|
1091
|
+
parts.push(`export function ${name}(${params});`);
|
|
1092
|
+
}
|
|
1093
|
+
for (const cls of sf.getClasses()) {
|
|
1094
|
+
if (!cls.isExported()) continue;
|
|
1095
|
+
const methods = cls.getMethods().map((m) => m.getName());
|
|
1096
|
+
parts.push(`export class ${cls.getName()} { /* methods: ${methods.join(", ")} */ }`);
|
|
1097
|
+
}
|
|
1098
|
+
for (const exp of sf.getExportDeclarations()) {
|
|
1099
|
+
parts.push(exp.getText());
|
|
1100
|
+
}
|
|
1101
|
+
return parts.join("\n");
|
|
1102
|
+
}
|
|
1103
|
+
async function pruneGeneric(file, level) {
|
|
1104
|
+
let content;
|
|
1105
|
+
try {
|
|
1106
|
+
content = await readFile4(file.path, "utf-8");
|
|
1107
|
+
} catch {
|
|
1108
|
+
return emptyResult(file, level);
|
|
1109
|
+
}
|
|
1110
|
+
return pruneGenericFromContent(file, content, level);
|
|
1111
|
+
}
|
|
1112
|
+
function pruneGenericFromContent(file, content, level) {
|
|
1113
|
+
const lines = content.split("\n");
|
|
1114
|
+
let result;
|
|
1115
|
+
if (level === "signatures") {
|
|
1116
|
+
result = lines.filter((line) => {
|
|
1117
|
+
const t = line.trim();
|
|
1118
|
+
return t === "" || t.startsWith("#") || t.startsWith("//") || t.startsWith("import ") || t.startsWith("from ") || t.startsWith("export ") || t.startsWith("def ") || t.startsWith("async def ") || t.startsWith("class ") || t.startsWith("function ") || t.startsWith("const ") || t.startsWith("let ") || t.startsWith("var ") || /^(pub |fn |struct |enum |impl |mod |use )/.test(t);
|
|
1119
|
+
});
|
|
1120
|
+
} else {
|
|
1121
|
+
result = lines.filter((line) => {
|
|
1122
|
+
const t = line.trim();
|
|
1123
|
+
return t.startsWith("import ") || t.startsWith("from ") || t.startsWith("export ") || t.startsWith("def ") || t.startsWith("class ") || t.startsWith("function ") || /^(pub |fn |struct |enum |mod |use )/.test(t);
|
|
1124
|
+
});
|
|
1125
|
+
}
|
|
1126
|
+
const prunedContent = result.join("\n");
|
|
1127
|
+
const prunedTokens = countTokensChars4(Buffer.byteLength(prunedContent, "utf-8"));
|
|
1128
|
+
const savingsPercent = file.tokens > 0 ? (file.tokens - prunedTokens) / file.tokens * 100 : 0;
|
|
1129
|
+
return {
|
|
1130
|
+
relativePath: file.relativePath,
|
|
1131
|
+
originalTokens: file.tokens,
|
|
1132
|
+
prunedTokens,
|
|
1133
|
+
pruneLevel: level,
|
|
1134
|
+
content: prunedContent,
|
|
1135
|
+
savingsPercent: Math.max(0, savingsPercent)
|
|
1136
|
+
};
|
|
1137
|
+
}
|
|
1138
|
+
async function fullContent(file) {
|
|
1139
|
+
let content = "";
|
|
1140
|
+
try {
|
|
1141
|
+
content = await readFile4(file.path, "utf-8");
|
|
1142
|
+
} catch {
|
|
1143
|
+
}
|
|
1144
|
+
return {
|
|
1145
|
+
relativePath: file.relativePath,
|
|
1146
|
+
originalTokens: file.tokens,
|
|
1147
|
+
prunedTokens: file.tokens,
|
|
1148
|
+
pruneLevel: "full",
|
|
1149
|
+
content,
|
|
1150
|
+
savingsPercent: 0
|
|
1151
|
+
};
|
|
1152
|
+
}
|
|
1153
|
+
function emptyResult(file, level) {
|
|
1154
|
+
return {
|
|
1155
|
+
relativePath: file.relativePath,
|
|
1156
|
+
originalTokens: file.tokens,
|
|
1157
|
+
prunedTokens: 0,
|
|
1158
|
+
pruneLevel: level,
|
|
1159
|
+
content: "",
|
|
1160
|
+
savingsPercent: 100
|
|
1161
|
+
};
|
|
1162
|
+
}
|
|
1163
|
+
function addJSDoc(node, parts) {
|
|
1164
|
+
if (!node.getJsDocs) return;
|
|
1165
|
+
const docs = node.getJsDocs();
|
|
1166
|
+
if (docs.length > 0) {
|
|
1167
|
+
parts.push(docs[0].getText());
|
|
1168
|
+
}
|
|
1169
|
+
}
|
|
1170
|
+
function findTsConfig(filePath) {
|
|
1171
|
+
let dir = filePath;
|
|
1172
|
+
for (let i = 0; i < 10; i++) {
|
|
1173
|
+
dir = join4(dir, "..");
|
|
1174
|
+
const candidate = join4(dir, "tsconfig.json");
|
|
1175
|
+
if (existsSync2(candidate)) return candidate;
|
|
1176
|
+
}
|
|
1177
|
+
return void 0;
|
|
1178
|
+
}
|
|
1179
|
+
|
|
1180
|
+
// src/engine/graph-utils.ts
|
|
1181
|
+
function buildAdjacencyList(edges) {
|
|
1182
|
+
const forward = /* @__PURE__ */ new Map();
|
|
1183
|
+
const reverse = /* @__PURE__ */ new Map();
|
|
1184
|
+
for (const edge of edges) {
|
|
1185
|
+
if (!forward.has(edge.from)) forward.set(edge.from, []);
|
|
1186
|
+
forward.get(edge.from).push(edge.to);
|
|
1187
|
+
if (!reverse.has(edge.to)) reverse.set(edge.to, []);
|
|
1188
|
+
reverse.get(edge.to).push(edge.from);
|
|
1189
|
+
}
|
|
1190
|
+
return { forward, reverse };
|
|
1191
|
+
}
|
|
1192
|
+
function bfsBidirectional(seeds, adj, depth) {
|
|
1193
|
+
const result = new Set(seeds);
|
|
1194
|
+
let frontier = [...seeds];
|
|
1195
|
+
const visited = /* @__PURE__ */ new Set();
|
|
1196
|
+
for (let d = 0; d < depth; d++) {
|
|
1197
|
+
const nextFrontier = [];
|
|
1198
|
+
for (const node of frontier) {
|
|
1199
|
+
if (visited.has(node)) continue;
|
|
1200
|
+
visited.add(node);
|
|
1201
|
+
const fwd = adj.forward.get(node);
|
|
1202
|
+
if (fwd) {
|
|
1203
|
+
for (const neighbor of fwd) {
|
|
1204
|
+
if (!visited.has(neighbor)) {
|
|
1205
|
+
result.add(neighbor);
|
|
1206
|
+
nextFrontier.push(neighbor);
|
|
1207
|
+
}
|
|
1208
|
+
}
|
|
1209
|
+
}
|
|
1210
|
+
const rev = adj.reverse.get(node);
|
|
1211
|
+
if (rev) {
|
|
1212
|
+
for (const neighbor of rev) {
|
|
1213
|
+
if (!visited.has(neighbor)) {
|
|
1214
|
+
result.add(neighbor);
|
|
1215
|
+
nextFrontier.push(neighbor);
|
|
1216
|
+
}
|
|
1217
|
+
}
|
|
1218
|
+
}
|
|
1219
|
+
}
|
|
1220
|
+
frontier = nextFrontier;
|
|
1221
|
+
}
|
|
1222
|
+
return result;
|
|
1223
|
+
}
|
|
1224
|
+
function matchGlob(path, pattern) {
|
|
1225
|
+
const regexStr = pattern.replace(/\./g, "\\.").replace(/\*\*/g, "\xA7\xA7").replace(/\*/g, "[^/]*").replace(/§§/g, ".*").replace(/\?/g, ".");
|
|
1226
|
+
try {
|
|
1227
|
+
return new RegExp(`^${regexStr}$`).test(path);
|
|
1228
|
+
} catch {
|
|
1229
|
+
return false;
|
|
1230
|
+
}
|
|
1231
|
+
}
|
|
1232
|
+
|
|
1233
|
+
// src/engine/coverage.ts
|
|
1234
|
+
function calculateCoverage(targetPaths, includedPaths, allFiles, graph, depth = 2) {
|
|
1235
|
+
const adj = buildAdjacencyList(graph.edges);
|
|
1236
|
+
const relevantSet = targetPaths.length > 0 ? bfsBidirectional(targetPaths, adj, depth) : /* @__PURE__ */ new Set();
|
|
1237
|
+
const includedSet = new Set(includedPaths);
|
|
1238
|
+
const tempFileMap = new Map(allFiles.map((f) => [f.relativePath, f]));
|
|
1239
|
+
for (const path of includedPaths) {
|
|
1240
|
+
const file = tempFileMap.get(path);
|
|
1241
|
+
if (!file) continue;
|
|
1242
|
+
for (const imp of file.imports) {
|
|
1243
|
+
const impFile = tempFileMap.get(imp);
|
|
1244
|
+
if (impFile && impFile.kind === "type") {
|
|
1245
|
+
relevantSet.add(imp);
|
|
1246
|
+
}
|
|
1247
|
+
}
|
|
1248
|
+
}
|
|
1249
|
+
const relevantFiles = Array.from(relevantSet);
|
|
1250
|
+
const includedRelevant = relevantFiles.filter((f) => includedSet.has(f));
|
|
1251
|
+
const missingRelevant = relevantFiles.filter((f) => !includedSet.has(f));
|
|
1252
|
+
const missingCritical = missingRelevant.filter((f) => {
|
|
1253
|
+
const file = tempFileMap.get(f);
|
|
1254
|
+
return file && (file.exclusionImpact === "critical" || file.exclusionImpact === "high");
|
|
1255
|
+
});
|
|
1256
|
+
const fileMap = new Map(allFiles.map((f) => [f.relativePath, f]));
|
|
1257
|
+
let totalRelevantRisk = 0;
|
|
1258
|
+
let includedRelevantRisk = 0;
|
|
1259
|
+
for (const f of relevantFiles) {
|
|
1260
|
+
const risk = fileMap.get(f)?.riskScore ?? 1;
|
|
1261
|
+
totalRelevantRisk += risk;
|
|
1262
|
+
if (includedSet.has(f)) {
|
|
1263
|
+
includedRelevantRisk += risk;
|
|
1264
|
+
}
|
|
1265
|
+
}
|
|
1266
|
+
const score = totalRelevantRisk > 0 ? Math.round(includedRelevantRisk / totalRelevantRisk * 100) : relevantFiles.length > 0 ? Math.round(includedRelevant.length / relevantFiles.length * 100) : 100;
|
|
1267
|
+
let explanation;
|
|
1268
|
+
if (score >= 90) {
|
|
1269
|
+
explanation = `Excellent coverage (${score}%): AI has nearly all relevant context.`;
|
|
1270
|
+
} else if (score >= 70) {
|
|
1271
|
+
explanation = `Good coverage (${score}%): Most relevant files included.`;
|
|
1272
|
+
if (missingCritical.length > 0) {
|
|
1273
|
+
explanation += ` Warning: ${missingCritical.length} critical file(s) missing.`;
|
|
1274
|
+
}
|
|
1275
|
+
} else if (score >= 50) {
|
|
1276
|
+
explanation = `Partial coverage (${score}%): Significant context is missing.`;
|
|
1277
|
+
if (missingCritical.length > 0) {
|
|
1278
|
+
explanation += ` ${missingCritical.length} critical file(s) not included \u2014 AI quality will degrade.`;
|
|
1279
|
+
}
|
|
1280
|
+
} else {
|
|
1281
|
+
explanation = `Low coverage (${score}%): Most relevant files are excluded. AI response quality will be poor.`;
|
|
1282
|
+
}
|
|
1283
|
+
return {
|
|
1284
|
+
score,
|
|
1285
|
+
relevantFiles,
|
|
1286
|
+
includedRelevant,
|
|
1287
|
+
missingRelevant,
|
|
1288
|
+
missingCritical,
|
|
1289
|
+
explanation
|
|
1290
|
+
};
|
|
1291
|
+
}
|
|
1292
|
+
|
|
1293
|
+
// src/engine/budget.ts
|
|
1294
|
+
function getPruneLevelForRisk(riskScore) {
|
|
1295
|
+
if (riskScore >= 80) return "full";
|
|
1296
|
+
if (riskScore >= 60) return "full";
|
|
1297
|
+
if (riskScore >= 30) return "signatures";
|
|
1298
|
+
return "skeleton";
|
|
1299
|
+
}
|
|
1300
|
+
|
|
1301
|
+
// src/engine/selector.ts
|
|
1302
|
+
async function selectContext(input) {
|
|
1303
|
+
const { task, analysis, budget, policies, depth = 2 } = input;
|
|
1304
|
+
const decisions = [];
|
|
1305
|
+
const targetPaths = identifyTargetFiles(task, analysis.files);
|
|
1306
|
+
if (targetPaths.length > 0) {
|
|
1307
|
+
decisions.push({
|
|
1308
|
+
file: targetPaths.join(", "),
|
|
1309
|
+
action: "include-full",
|
|
1310
|
+
reason: `Target file(s) identified from task description`
|
|
1311
|
+
});
|
|
1312
|
+
}
|
|
1313
|
+
const adj = buildAdjacencyList(analysis.graph.edges);
|
|
1314
|
+
const expandedPaths = targetPaths.length > 0 ? Array.from(bfsBidirectional(targetPaths, adj, depth)) : [];
|
|
1315
|
+
const expansionCount = expandedPaths.length - targetPaths.length;
|
|
1316
|
+
if (expansionCount > 0) {
|
|
1317
|
+
decisions.push({
|
|
1318
|
+
file: `${expansionCount} dependencies`,
|
|
1319
|
+
action: "include-full",
|
|
1320
|
+
reason: `Expanded ${targetPaths.length} target(s) to ${expandedPaths.length} files via dependency graph (depth ${depth})`
|
|
1321
|
+
});
|
|
1322
|
+
}
|
|
1323
|
+
const allFileMap = new Map(analysis.files.map((f) => [f.relativePath, f]));
|
|
1324
|
+
if (targetPaths.length > 0) {
|
|
1325
|
+
for (const path of expandedPaths) {
|
|
1326
|
+
const file = allFileMap.get(path);
|
|
1327
|
+
if (!file) continue;
|
|
1328
|
+
for (const imp of file.imports) {
|
|
1329
|
+
const impFile = allFileMap.get(imp);
|
|
1330
|
+
if (impFile && impFile.kind === "type") {
|
|
1331
|
+
expandedPaths.push(imp);
|
|
1332
|
+
}
|
|
1333
|
+
}
|
|
1334
|
+
}
|
|
1335
|
+
}
|
|
1336
|
+
const { mustInclude, mustExclude } = applyPolicies(analysis.files, policies);
|
|
1337
|
+
const candidateSet = /* @__PURE__ */ new Set([...expandedPaths, ...mustInclude]);
|
|
1338
|
+
if (targetPaths.length === 0) {
|
|
1339
|
+
for (const f of analysis.files) {
|
|
1340
|
+
candidateSet.add(f.relativePath);
|
|
1341
|
+
}
|
|
1342
|
+
}
|
|
1343
|
+
for (const ex of mustExclude) {
|
|
1344
|
+
candidateSet.delete(ex);
|
|
1345
|
+
decisions.push({
|
|
1346
|
+
file: ex,
|
|
1347
|
+
action: "exclude",
|
|
1348
|
+
reason: "Excluded by policy"
|
|
1349
|
+
});
|
|
1350
|
+
}
|
|
1351
|
+
const hasSecretBlock = policies?.rules.some(
|
|
1352
|
+
(r) => r.type === "secret-block" && r.enabled
|
|
1353
|
+
);
|
|
1354
|
+
if (hasSecretBlock) {
|
|
1355
|
+
for (const path of Array.from(candidateSet)) {
|
|
1356
|
+
const file = allFileMap.get(path);
|
|
1357
|
+
if (!file) continue;
|
|
1358
|
+
const findings = await scanFileForSecrets(
|
|
1359
|
+
file.path,
|
|
1360
|
+
analysis.projectPath
|
|
1361
|
+
);
|
|
1362
|
+
if (findings.length > 0) {
|
|
1363
|
+
candidateSet.delete(path);
|
|
1364
|
+
decisions.push({
|
|
1365
|
+
file: path,
|
|
1366
|
+
action: "exclude",
|
|
1367
|
+
reason: `Blocked: ${findings.length} secret(s) detected (${findings.map((f) => f.type).join(", ")})`
|
|
1368
|
+
});
|
|
1369
|
+
}
|
|
1370
|
+
}
|
|
1371
|
+
}
|
|
1372
|
+
const candidates = Array.from(candidateSet).map((p) => allFileMap.get(p)).filter((f) => f !== void 0).sort((a, b) => {
|
|
1373
|
+
const aIsTarget = targetPaths.includes(a.relativePath) ? 0 : 1;
|
|
1374
|
+
const bIsTarget = targetPaths.includes(b.relativePath) ? 0 : 1;
|
|
1375
|
+
if (aIsTarget !== bIsTarget) return aIsTarget - bIsTarget;
|
|
1376
|
+
const aIsMust = mustInclude.has(a.relativePath) ? 0 : 1;
|
|
1377
|
+
const bIsMust = mustInclude.has(b.relativePath) ? 0 : 1;
|
|
1378
|
+
if (aIsMust !== bIsMust) return aIsMust - bIsMust;
|
|
1379
|
+
return b.riskScore - a.riskScore;
|
|
1380
|
+
});
|
|
1381
|
+
const selectedFiles = [];
|
|
1382
|
+
let usedTokens = 0;
|
|
1383
|
+
for (const file of candidates) {
|
|
1384
|
+
const isTarget = targetPaths.includes(file.relativePath);
|
|
1385
|
+
const isMustInclude = mustInclude.has(file.relativePath);
|
|
1386
|
+
const defaultLevel = isTarget ? "full" : getPruneLevelForRisk(file.riskScore);
|
|
1387
|
+
const levels = getCascadeLevels(defaultLevel);
|
|
1388
|
+
let included = false;
|
|
1389
|
+
for (const level of levels) {
|
|
1390
|
+
if (level === "excluded") break;
|
|
1391
|
+
let tokens;
|
|
1392
|
+
if (level === "full") {
|
|
1393
|
+
tokens = file.tokens;
|
|
1394
|
+
} else {
|
|
1395
|
+
const pruned = await pruneFile(file, level);
|
|
1396
|
+
tokens = pruned.prunedTokens;
|
|
1397
|
+
}
|
|
1398
|
+
if (usedTokens + tokens <= budget) {
|
|
1399
|
+
usedTokens += tokens;
|
|
1400
|
+
selectedFiles.push({
|
|
1401
|
+
relativePath: file.relativePath,
|
|
1402
|
+
tokens,
|
|
1403
|
+
originalTokens: file.tokens,
|
|
1404
|
+
pruneLevel: level,
|
|
1405
|
+
riskScore: file.riskScore,
|
|
1406
|
+
reason: buildReason(file, level, isTarget, isMustInclude)
|
|
1407
|
+
});
|
|
1408
|
+
if (level !== defaultLevel) {
|
|
1409
|
+
decisions.push({
|
|
1410
|
+
file: file.relativePath,
|
|
1411
|
+
action: `include-${level}`,
|
|
1412
|
+
reason: `Downgraded from ${defaultLevel} to ${level} due to budget constraint`,
|
|
1413
|
+
alternatives: `Would need ${file.tokens - tokens} more tokens for ${defaultLevel}`
|
|
1414
|
+
});
|
|
1415
|
+
}
|
|
1416
|
+
included = true;
|
|
1417
|
+
break;
|
|
1418
|
+
}
|
|
1419
|
+
}
|
|
1420
|
+
if (!included) {
|
|
1421
|
+
decisions.push({
|
|
1422
|
+
file: file.relativePath,
|
|
1423
|
+
action: "exclude",
|
|
1424
|
+
reason: `Budget exhausted (risk: ${file.riskScore}, needs ${file.tokens} tokens)`
|
|
1425
|
+
});
|
|
1426
|
+
}
|
|
1427
|
+
}
|
|
1428
|
+
const includedPaths = selectedFiles.map((f) => f.relativePath);
|
|
1429
|
+
const coverage = calculateCoverage(
|
|
1430
|
+
targetPaths,
|
|
1431
|
+
includedPaths,
|
|
1432
|
+
analysis.files,
|
|
1433
|
+
analysis.graph,
|
|
1434
|
+
depth
|
|
1435
|
+
);
|
|
1436
|
+
const includedSet = new Set(includedPaths);
|
|
1437
|
+
const excludedFiles = analysis.files.filter(
|
|
1438
|
+
(f) => !includedSet.has(f.relativePath)
|
|
1439
|
+
);
|
|
1440
|
+
const excludedRisk = excludedFiles.length > 0 ? Math.round(excludedFiles.reduce((s, f) => s + f.riskScore, 0) / excludedFiles.length) : 0;
|
|
1441
|
+
const hashInput = selectedFiles.map((f) => `${f.relativePath}:${f.pruneLevel}`).sort().join("|") + `|budget:${budget}`;
|
|
1442
|
+
const hash = createHash3("sha256").update(hashInput).digest("hex").substring(0, 16);
|
|
1443
|
+
return {
|
|
1444
|
+
files: selectedFiles,
|
|
1445
|
+
totalTokens: usedTokens,
|
|
1446
|
+
budget,
|
|
1447
|
+
usedPercent: budget > 0 ? Math.round(usedTokens / budget * 100 * 10) / 10 : 0,
|
|
1448
|
+
coverage,
|
|
1449
|
+
riskScore: excludedRisk,
|
|
1450
|
+
deterministic: true,
|
|
1451
|
+
hash,
|
|
1452
|
+
decisions
|
|
1453
|
+
};
|
|
1454
|
+
}
|
|
1455
|
+
function identifyTargetFiles(task, files) {
|
|
1456
|
+
const targets = [];
|
|
1457
|
+
const pathPattern = /(?:^|\s|["'`])([.\w/-]+\.[a-zA-Z]{1,4})(?:\s|$|["'`]|,|:)/g;
|
|
1458
|
+
let match;
|
|
1459
|
+
while ((match = pathPattern.exec(task)) !== null) {
|
|
1460
|
+
const candidate = match[1];
|
|
1461
|
+
const found = files.find(
|
|
1462
|
+
(f) => f.relativePath === candidate || f.relativePath.endsWith(candidate)
|
|
1463
|
+
);
|
|
1464
|
+
if (found && !targets.includes(found.relativePath)) {
|
|
1465
|
+
targets.push(found.relativePath);
|
|
1466
|
+
}
|
|
1467
|
+
}
|
|
1468
|
+
return targets;
|
|
1469
|
+
}
|
|
1470
|
+
function applyPolicies(files, policies) {
|
|
1471
|
+
const mustInclude = /* @__PURE__ */ new Set();
|
|
1472
|
+
const mustExclude = /* @__PURE__ */ new Set();
|
|
1473
|
+
if (!policies) return { mustInclude, mustExclude };
|
|
1474
|
+
for (const rule of policies.rules) {
|
|
1475
|
+
if (!rule.enabled) continue;
|
|
1476
|
+
if (rule.type === "include-always" && rule.pattern) {
|
|
1477
|
+
for (const file of files) {
|
|
1478
|
+
if (matchGlob(file.relativePath, rule.pattern)) {
|
|
1479
|
+
mustInclude.add(file.relativePath);
|
|
1480
|
+
}
|
|
1481
|
+
}
|
|
1482
|
+
}
|
|
1483
|
+
if (rule.type === "exclude-always" && rule.pattern) {
|
|
1484
|
+
for (const file of files) {
|
|
1485
|
+
if (matchGlob(file.relativePath, rule.pattern)) {
|
|
1486
|
+
mustExclude.add(file.relativePath);
|
|
1487
|
+
}
|
|
1488
|
+
}
|
|
1489
|
+
}
|
|
1490
|
+
}
|
|
1491
|
+
return { mustInclude, mustExclude };
|
|
1492
|
+
}
|
|
1493
|
+
function getCascadeLevels(startLevel) {
|
|
1494
|
+
const all = ["full", "signatures", "skeleton", "excluded"];
|
|
1495
|
+
const startIdx = all.indexOf(startLevel);
|
|
1496
|
+
return all.slice(startIdx);
|
|
1497
|
+
}
|
|
1498
|
+
function buildReason(file, level, isTarget, isMustInclude) {
|
|
1499
|
+
if (isTarget) return "Target file";
|
|
1500
|
+
if (isMustInclude) return "Required by policy";
|
|
1501
|
+
const impact = file.exclusionImpact;
|
|
1502
|
+
const levelStr = level === "full" ? "full content" : level;
|
|
1503
|
+
if (impact === "critical") return `Critical dependency (risk ${file.riskScore}) \u2014 ${levelStr}`;
|
|
1504
|
+
if (impact === "high") return `High-risk dependency (risk ${file.riskScore}) \u2014 ${levelStr}`;
|
|
1505
|
+
if (impact === "medium") return `Medium relevance (risk ${file.riskScore}) \u2014 ${levelStr}`;
|
|
1506
|
+
return `Low relevance (risk ${file.riskScore}) \u2014 ${levelStr}`;
|
|
1507
|
+
}
|
|
1508
|
+
|
|
1509
|
+
// src/engine/score.ts
|
|
1510
|
+
async function computeContextScore(analysis, task = "general code review and refactoring", budget = 5e4) {
|
|
1511
|
+
const selection = await selectContext({ task, analysis, budget });
|
|
1512
|
+
const insights = [];
|
|
1513
|
+
const efficiency = scoreEfficiency(analysis, selection, insights);
|
|
1514
|
+
const coverage = scoreCoverage(analysis, selection, insights);
|
|
1515
|
+
const riskControl = scoreRiskControl(analysis, selection, insights);
|
|
1516
|
+
const structure = scoreStructure(analysis, insights);
|
|
1517
|
+
const governance = scoreGovernance(analysis, insights);
|
|
1518
|
+
const overall = Math.round(
|
|
1519
|
+
efficiency.weighted + coverage.weighted + riskControl.weighted + structure.weighted + governance.weighted
|
|
1520
|
+
);
|
|
1521
|
+
const grade = scoreToGrade(overall);
|
|
1522
|
+
const naiveTokens = analysis.totalTokens;
|
|
1523
|
+
const optimizedTokens = selection.totalTokens;
|
|
1524
|
+
const savedTokens = naiveTokens - optimizedTokens;
|
|
1525
|
+
const savedPercent = naiveTokens > 0 ? Math.round(savedTokens / naiveTokens * 100) : 0;
|
|
1526
|
+
const interactionsPerMonth = 40 * 20;
|
|
1527
|
+
const costPerMToken = 3;
|
|
1528
|
+
const naiveMonthlyCost = naiveTokens / 1e6 * costPerMToken * interactionsPerMonth;
|
|
1529
|
+
const optimizedMonthlyCost = optimizedTokens / 1e6 * costPerMToken * interactionsPerMonth;
|
|
1530
|
+
const monthlySavingsUSD = Math.round((naiveMonthlyCost - optimizedMonthlyCost) * 100) / 100;
|
|
1531
|
+
return {
|
|
1532
|
+
overall,
|
|
1533
|
+
grade,
|
|
1534
|
+
dimensions: {
|
|
1535
|
+
efficiency,
|
|
1536
|
+
coverage,
|
|
1537
|
+
riskControl,
|
|
1538
|
+
structure,
|
|
1539
|
+
governance
|
|
1540
|
+
},
|
|
1541
|
+
insights: insights.sort((a, b) => {
|
|
1542
|
+
const order = { high: 0, medium: 1, low: 2 };
|
|
1543
|
+
return order[a.impact] - order[b.impact];
|
|
1544
|
+
}),
|
|
1545
|
+
comparison: {
|
|
1546
|
+
naiveTokens,
|
|
1547
|
+
optimizedTokens,
|
|
1548
|
+
savedTokens,
|
|
1549
|
+
savedPercent,
|
|
1550
|
+
monthlySavingsUSD
|
|
1551
|
+
},
|
|
1552
|
+
meta: {
|
|
1553
|
+
projectName: analysis.projectName,
|
|
1554
|
+
totalFiles: analysis.totalFiles,
|
|
1555
|
+
totalTokens: analysis.totalTokens,
|
|
1556
|
+
analyzedAt: analysis.analyzedAt
|
|
1557
|
+
}
|
|
1558
|
+
};
|
|
1559
|
+
}
|
|
1560
|
+
function scoreEfficiency(analysis, selection, insights) {
|
|
1561
|
+
const weight = 30;
|
|
1562
|
+
const ratio = analysis.totalTokens > 0 ? 1 - selection.totalTokens / analysis.totalTokens : 0;
|
|
1563
|
+
const selectivity = analysis.totalFiles > 0 ? 1 - selection.files.length / analysis.totalFiles : 0;
|
|
1564
|
+
const prunedFiles = selection.files.filter(
|
|
1565
|
+
(f) => f.pruneLevel === "signatures" || f.pruneLevel === "skeleton"
|
|
1566
|
+
).length;
|
|
1567
|
+
const pruneRatio = selection.files.length > 0 ? prunedFiles / selection.files.length : 0;
|
|
1568
|
+
const raw = (ratio * 0.5 + selectivity * 0.3 + pruneRatio * 0.2) * 100;
|
|
1569
|
+
const score = Math.min(100, Math.max(0, Math.round(raw)));
|
|
1570
|
+
const weighted = score / 100 * weight;
|
|
1571
|
+
if (ratio > 0.7) {
|
|
1572
|
+
insights.push({
|
|
1573
|
+
type: "strength",
|
|
1574
|
+
title: "Excellent compression",
|
|
1575
|
+
detail: `${Math.round(ratio * 100)}% token reduction while maintaining context quality`,
|
|
1576
|
+
impact: "high"
|
|
1577
|
+
});
|
|
1578
|
+
}
|
|
1579
|
+
if (ratio < 0.3 && analysis.totalTokens > 2e4) {
|
|
1580
|
+
insights.push({
|
|
1581
|
+
type: "weakness",
|
|
1582
|
+
title: "Low compression opportunity",
|
|
1583
|
+
detail: "Most files are needed. Consider splitting the project into smaller modules.",
|
|
1584
|
+
impact: "medium"
|
|
1585
|
+
});
|
|
1586
|
+
}
|
|
1587
|
+
return {
|
|
1588
|
+
score,
|
|
1589
|
+
weight,
|
|
1590
|
+
weighted,
|
|
1591
|
+
detail: `${Math.round(ratio * 100)}% compression, ${prunedFiles}/${selection.files.length} files pruned`
|
|
1592
|
+
};
|
|
1593
|
+
}
|
|
1594
|
+
function scoreCoverage(analysis, selection, insights) {
|
|
1595
|
+
const weight = 25;
|
|
1596
|
+
const coverageScore = selection.coverage.score;
|
|
1597
|
+
const missingCritical = selection.coverage.missingCritical.length;
|
|
1598
|
+
let penalty = 0;
|
|
1599
|
+
if (missingCritical > 0) {
|
|
1600
|
+
penalty = Math.min(30, missingCritical * 10);
|
|
1601
|
+
insights.push({
|
|
1602
|
+
type: "weakness",
|
|
1603
|
+
title: `${missingCritical} critical file(s) missing from context`,
|
|
1604
|
+
detail: `Missing: ${selection.coverage.missingCritical.slice(0, 3).join(", ")}${missingCritical > 3 ? ` +${missingCritical - 3} more` : ""}`,
|
|
1605
|
+
impact: "high"
|
|
1606
|
+
});
|
|
1607
|
+
}
|
|
1608
|
+
const score = Math.min(100, Math.max(0, Math.round(coverageScore - penalty)));
|
|
1609
|
+
const weighted = score / 100 * weight;
|
|
1610
|
+
if (coverageScore >= 90 && missingCritical === 0) {
|
|
1611
|
+
insights.push({
|
|
1612
|
+
type: "strength",
|
|
1613
|
+
title: "Excellent context coverage",
|
|
1614
|
+
detail: `${coverageScore}% of the relevant universe captured with zero critical gaps`,
|
|
1615
|
+
impact: "high"
|
|
1616
|
+
});
|
|
1617
|
+
}
|
|
1618
|
+
return {
|
|
1619
|
+
score,
|
|
1620
|
+
weight,
|
|
1621
|
+
weighted,
|
|
1622
|
+
detail: `${coverageScore}% coverage, ${missingCritical} critical gaps`
|
|
1623
|
+
};
|
|
1624
|
+
}
|
|
1625
|
+
function scoreRiskControl(analysis, selection, insights) {
|
|
1626
|
+
const weight = 20;
|
|
1627
|
+
const dist = analysis.riskProfile.distribution;
|
|
1628
|
+
const totalFiles = analysis.totalFiles;
|
|
1629
|
+
const criticalFiles = analysis.files.filter((f) => f.riskScore >= 80);
|
|
1630
|
+
const highFiles = analysis.files.filter((f) => f.riskScore >= 60 && f.riskScore < 80);
|
|
1631
|
+
const selectedPaths = new Set(selection.files.map((f) => f.relativePath));
|
|
1632
|
+
const criticalIncluded = criticalFiles.filter((f) => selectedPaths.has(f.relativePath)).length;
|
|
1633
|
+
const highIncluded = highFiles.filter((f) => selectedPaths.has(f.relativePath)).length;
|
|
1634
|
+
const criticalCoverage = criticalFiles.length > 0 ? criticalIncluded / criticalFiles.length : 1;
|
|
1635
|
+
const highCoverage = highFiles.length > 0 ? highIncluded / highFiles.length : 1;
|
|
1636
|
+
const criticalRatio = totalFiles > 0 ? dist.critical / totalFiles : 0;
|
|
1637
|
+
const healthScore = Math.max(0, 1 - criticalRatio * 5);
|
|
1638
|
+
const raw = (criticalCoverage * 0.5 + highCoverage * 0.3 + healthScore * 0.2) * 100;
|
|
1639
|
+
const score = Math.min(100, Math.max(0, Math.round(raw)));
|
|
1640
|
+
const weighted = score / 100 * weight;
|
|
1641
|
+
if (criticalCoverage === 1 && criticalFiles.length > 0) {
|
|
1642
|
+
insights.push({
|
|
1643
|
+
type: "strength",
|
|
1644
|
+
title: "All critical files included",
|
|
1645
|
+
detail: `${criticalFiles.length} critical-risk files are captured in context`,
|
|
1646
|
+
impact: "high"
|
|
1647
|
+
});
|
|
1648
|
+
}
|
|
1649
|
+
if (criticalRatio > 0.2) {
|
|
1650
|
+
insights.push({
|
|
1651
|
+
type: "opportunity",
|
|
1652
|
+
title: "High concentration of critical files",
|
|
1653
|
+
detail: `${dist.critical} files (${Math.round(criticalRatio * 100)}%) are critical risk. Consider refactoring complex modules.`,
|
|
1654
|
+
impact: "medium"
|
|
1655
|
+
});
|
|
1656
|
+
}
|
|
1657
|
+
return {
|
|
1658
|
+
score,
|
|
1659
|
+
weight,
|
|
1660
|
+
weighted,
|
|
1661
|
+
detail: `${criticalIncluded}/${criticalFiles.length} critical + ${highIncluded}/${highFiles.length} high-risk files included`
|
|
1662
|
+
};
|
|
1663
|
+
}
|
|
1664
|
+
function scoreStructure(analysis, insights) {
|
|
1665
|
+
const weight = 15;
|
|
1666
|
+
const graph = analysis.graph;
|
|
1667
|
+
const totalFiles = analysis.totalFiles;
|
|
1668
|
+
const avgCohesion = graph.clusters.length > 0 ? graph.clusters.reduce((s, c) => s + c.cohesion, 0) / graph.clusters.length : 0;
|
|
1669
|
+
const orphanRatio = totalFiles > 0 ? graph.orphans.length / totalFiles : 0;
|
|
1670
|
+
const hubRatio = totalFiles > 0 ? graph.hubs.length / totalFiles : 0;
|
|
1671
|
+
const hubHealth = hubRatio > 0.02 && hubRatio < 0.15 ? 1 : Math.max(0, 1 - Math.abs(hubRatio - 0.08) * 10);
|
|
1672
|
+
const typeFiles = analysis.files.filter((f) => f.kind === "type").length;
|
|
1673
|
+
const typeRatio = totalFiles > 0 ? typeFiles / totalFiles : 0;
|
|
1674
|
+
const typeScore = Math.min(1, typeRatio * 10);
|
|
1675
|
+
const raw = (avgCohesion * 0.3 + (1 - orphanRatio) * 0.3 + hubHealth * 0.2 + typeScore * 0.2) * 100;
|
|
1676
|
+
const score = Math.min(100, Math.max(0, Math.round(raw)));
|
|
1677
|
+
const weighted = score / 100 * weight;
|
|
1678
|
+
if (orphanRatio > 0.5) {
|
|
1679
|
+
insights.push({
|
|
1680
|
+
type: "weakness",
|
|
1681
|
+
title: "Many orphan files",
|
|
1682
|
+
detail: `${graph.orphans.length} files (${Math.round(orphanRatio * 100)}%) have no imports/exports. AI gets less context from the dependency graph.`,
|
|
1683
|
+
impact: "medium"
|
|
1684
|
+
});
|
|
1685
|
+
}
|
|
1686
|
+
if (graph.clusters.length > 0 && avgCohesion > 0.7) {
|
|
1687
|
+
insights.push({
|
|
1688
|
+
type: "strength",
|
|
1689
|
+
title: "Well-organized module structure",
|
|
1690
|
+
detail: `${graph.clusters.length} cohesive clusters (avg cohesion: ${(avgCohesion * 100).toFixed(0)}%). CTO can efficiently select relevant modules.`,
|
|
1691
|
+
impact: "medium"
|
|
1692
|
+
});
|
|
1693
|
+
}
|
|
1694
|
+
return {
|
|
1695
|
+
score,
|
|
1696
|
+
weight,
|
|
1697
|
+
weighted,
|
|
1698
|
+
detail: `${graph.clusters.length} clusters, ${graph.orphans.length} orphans, ${graph.hubs.length} hubs`
|
|
1699
|
+
};
|
|
1700
|
+
}
|
|
1701
|
+
function scoreGovernance(analysis, insights) {
|
|
1702
|
+
const weight = 10;
|
|
1703
|
+
const hasTypes = analysis.files.some((f) => f.kind === "type");
|
|
1704
|
+
const hasConfig = analysis.files.some((f) => f.kind === "config");
|
|
1705
|
+
const hasTests = analysis.files.some((f) => f.kind === "test");
|
|
1706
|
+
let score = 50;
|
|
1707
|
+
if (hasTypes) {
|
|
1708
|
+
score += 15;
|
|
1709
|
+
}
|
|
1710
|
+
if (hasConfig) {
|
|
1711
|
+
score += 10;
|
|
1712
|
+
}
|
|
1713
|
+
if (hasTests) {
|
|
1714
|
+
score += 15;
|
|
1715
|
+
}
|
|
1716
|
+
if (analysis.stack.length > 0) {
|
|
1717
|
+
score += 10;
|
|
1718
|
+
}
|
|
1719
|
+
score = Math.min(100, score);
|
|
1720
|
+
const weighted = score / 100 * weight;
|
|
1721
|
+
if (!hasTests) {
|
|
1722
|
+
insights.push({
|
|
1723
|
+
type: "opportunity",
|
|
1724
|
+
title: "No test files detected",
|
|
1725
|
+
detail: "Adding tests helps CTO understand code intent and provides better context boundaries.",
|
|
1726
|
+
impact: "low"
|
|
1727
|
+
});
|
|
1728
|
+
}
|
|
1729
|
+
if (!hasTypes) {
|
|
1730
|
+
insights.push({
|
|
1731
|
+
type: "opportunity",
|
|
1732
|
+
title: "No type definition files",
|
|
1733
|
+
detail: "Type files dramatically improve AI code generation accuracy. Consider adding interfaces/types.",
|
|
1734
|
+
impact: "medium"
|
|
1735
|
+
});
|
|
1736
|
+
}
|
|
1737
|
+
return {
|
|
1738
|
+
score,
|
|
1739
|
+
weight,
|
|
1740
|
+
weighted,
|
|
1741
|
+
detail: `types:${hasTypes ? "\u2713" : "\u2717"} tests:${hasTests ? "\u2713" : "\u2717"} config:${hasConfig ? "\u2713" : "\u2717"} stack:${analysis.stack.join(",") || "unknown"}`
|
|
1742
|
+
};
|
|
1743
|
+
}
|
|
1744
|
+
function scoreToGrade(score) {
|
|
1745
|
+
if (score >= 95) return "A+";
|
|
1746
|
+
if (score >= 90) return "A";
|
|
1747
|
+
if (score >= 85) return "A-";
|
|
1748
|
+
if (score >= 80) return "B+";
|
|
1749
|
+
if (score >= 75) return "B";
|
|
1750
|
+
if (score >= 70) return "B-";
|
|
1751
|
+
if (score >= 65) return "C+";
|
|
1752
|
+
if (score >= 60) return "C";
|
|
1753
|
+
if (score >= 55) return "C-";
|
|
1754
|
+
if (score >= 40) return "D";
|
|
1755
|
+
return "F";
|
|
1756
|
+
}
|
|
1757
|
+
|
|
1758
|
+
// src/engine/benchmark.ts
|
|
1759
|
+
async function runBenchmark(analysis, task = "general code review and refactoring", budget = 5e4) {
|
|
1760
|
+
const criticalFiles = analysis.files.filter((f) => f.riskScore >= 80);
|
|
1761
|
+
const highRiskFiles = analysis.files.filter((f) => f.riskScore >= 60 && f.riskScore < 80);
|
|
1762
|
+
const ctoStart = performance.now();
|
|
1763
|
+
const ctoSelection = await selectContext({ task, analysis, budget });
|
|
1764
|
+
const ctoTime = performance.now() - ctoStart;
|
|
1765
|
+
const ctoSelectedPaths = new Set(ctoSelection.files.map((f) => f.relativePath));
|
|
1766
|
+
const ctoCritical = criticalFiles.filter((f) => ctoSelectedPaths.has(f.relativePath)).length;
|
|
1767
|
+
const ctoHigh = highRiskFiles.filter((f) => ctoSelectedPaths.has(f.relativePath)).length;
|
|
1768
|
+
const cto = {
|
|
1769
|
+
filesSelected: ctoSelection.files.length,
|
|
1770
|
+
tokensUsed: ctoSelection.totalTokens,
|
|
1771
|
+
coverageScore: ctoSelection.coverage.score,
|
|
1772
|
+
criticalFilesCovered: ctoCritical,
|
|
1773
|
+
criticalFilesTotal: criticalFiles.length,
|
|
1774
|
+
highRiskCovered: ctoHigh,
|
|
1775
|
+
highRiskTotal: highRiskFiles.length,
|
|
1776
|
+
costPerInteractionUSD: ctoSelection.totalTokens / 1e6 * 3,
|
|
1777
|
+
// Sonnet pricing
|
|
1778
|
+
timeMs: Math.round(ctoTime)
|
|
1779
|
+
};
|
|
1780
|
+
const naiveStart = performance.now();
|
|
1781
|
+
const naiveFiles = [...analysis.files].sort((a, b) => a.relativePath.localeCompare(b.relativePath));
|
|
1782
|
+
let naiveTokens = 0;
|
|
1783
|
+
let naiveCount = 0;
|
|
1784
|
+
const naiveSelectedPaths = /* @__PURE__ */ new Set();
|
|
1785
|
+
for (const f of naiveFiles) {
|
|
1786
|
+
if (naiveTokens + f.tokens <= budget) {
|
|
1787
|
+
naiveTokens += f.tokens;
|
|
1788
|
+
naiveCount++;
|
|
1789
|
+
naiveSelectedPaths.add(f.relativePath);
|
|
1790
|
+
}
|
|
1791
|
+
}
|
|
1792
|
+
if (naiveTokens === 0 && analysis.totalTokens <= budget) {
|
|
1793
|
+
naiveTokens = analysis.totalTokens;
|
|
1794
|
+
naiveCount = analysis.totalFiles;
|
|
1795
|
+
for (const f of analysis.files) naiveSelectedPaths.add(f.relativePath);
|
|
1796
|
+
}
|
|
1797
|
+
const naiveTime = performance.now() - naiveStart;
|
|
1798
|
+
const naiveCritical = criticalFiles.filter((f) => naiveSelectedPaths.has(f.relativePath)).length;
|
|
1799
|
+
const naiveHigh = highRiskFiles.filter((f) => naiveSelectedPaths.has(f.relativePath)).length;
|
|
1800
|
+
const naiveCoverage = analysis.totalFiles > 0 ? Math.round(naiveCount / analysis.totalFiles * 100) : 0;
|
|
1801
|
+
const naive = {
|
|
1802
|
+
filesSelected: naiveCount,
|
|
1803
|
+
tokensUsed: naiveTokens > 0 ? naiveTokens : analysis.totalTokens,
|
|
1804
|
+
coverageScore: naiveTokens > 0 ? naiveCoverage : 100,
|
|
1805
|
+
criticalFilesCovered: naiveCritical,
|
|
1806
|
+
criticalFilesTotal: criticalFiles.length,
|
|
1807
|
+
highRiskCovered: naiveHigh,
|
|
1808
|
+
highRiskTotal: highRiskFiles.length,
|
|
1809
|
+
costPerInteractionUSD: (naiveTokens > 0 ? naiveTokens : analysis.totalTokens) / 1e6 * 3,
|
|
1810
|
+
timeMs: Math.round(naiveTime)
|
|
1811
|
+
};
|
|
1812
|
+
const randomStart = performance.now();
|
|
1813
|
+
const shuffled = [...analysis.files].sort(() => Math.random() - 0.5);
|
|
1814
|
+
let randomTokens = 0;
|
|
1815
|
+
let randomCount = 0;
|
|
1816
|
+
const randomSelectedPaths = /* @__PURE__ */ new Set();
|
|
1817
|
+
for (const f of shuffled) {
|
|
1818
|
+
if (randomTokens + f.tokens <= budget) {
|
|
1819
|
+
randomTokens += f.tokens;
|
|
1820
|
+
randomCount++;
|
|
1821
|
+
randomSelectedPaths.add(f.relativePath);
|
|
1822
|
+
}
|
|
1823
|
+
}
|
|
1824
|
+
const randomTime = performance.now() - randomStart;
|
|
1825
|
+
const randomCritical = criticalFiles.filter((f) => randomSelectedPaths.has(f.relativePath)).length;
|
|
1826
|
+
const randomHigh = highRiskFiles.filter((f) => randomSelectedPaths.has(f.relativePath)).length;
|
|
1827
|
+
const randomCoverage = analysis.totalFiles > 0 ? Math.round(randomCount / analysis.totalFiles * 100) : 0;
|
|
1828
|
+
const random = {
|
|
1829
|
+
filesSelected: randomCount,
|
|
1830
|
+
tokensUsed: randomTokens,
|
|
1831
|
+
coverageScore: randomCoverage,
|
|
1832
|
+
criticalFilesCovered: randomCritical,
|
|
1833
|
+
criticalFilesTotal: criticalFiles.length,
|
|
1834
|
+
highRiskCovered: randomHigh,
|
|
1835
|
+
highRiskTotal: highRiskFiles.length,
|
|
1836
|
+
costPerInteractionUSD: randomTokens / 1e6 * 3,
|
|
1837
|
+
timeMs: Math.round(randomTime)
|
|
1838
|
+
};
|
|
1839
|
+
const ctoScore = computeStrategyScore(cto, budget);
|
|
1840
|
+
const naiveScore = computeStrategyScore(naive, budget);
|
|
1841
|
+
const randomScore = computeStrategyScore(random, budget);
|
|
1842
|
+
const winner = ctoScore >= naiveScore && ctoScore >= randomScore ? "cto" : naiveScore >= randomScore ? "naive" : "random";
|
|
1843
|
+
const interactionsPerMonth = 800;
|
|
1844
|
+
const vsNaiveCostSaved = (naive.costPerInteractionUSD - cto.costPerInteractionUSD) * interactionsPerMonth;
|
|
1845
|
+
return {
|
|
1846
|
+
project: analysis.projectName,
|
|
1847
|
+
totalFiles: analysis.totalFiles,
|
|
1848
|
+
totalTokens: analysis.totalTokens,
|
|
1849
|
+
budget,
|
|
1850
|
+
task,
|
|
1851
|
+
strategies: { cto, naive, random },
|
|
1852
|
+
winner,
|
|
1853
|
+
ctoAdvantage: {
|
|
1854
|
+
vsNaiveTokensSaved: naive.tokensUsed - cto.tokensUsed,
|
|
1855
|
+
vsNaiveTokensSavedPercent: naive.tokensUsed > 0 ? Math.round((naive.tokensUsed - cto.tokensUsed) / naive.tokensUsed * 100) : 0,
|
|
1856
|
+
vsRandomCoverageGain: cto.coverageScore - random.coverageScore,
|
|
1857
|
+
vsNaiveCostSavedMonthlyUSD: Math.round(vsNaiveCostSaved * 100) / 100
|
|
1858
|
+
}
|
|
1859
|
+
};
|
|
1860
|
+
}
|
|
1861
|
+
function computeStrategyScore(strategy, budget) {
|
|
1862
|
+
const coverageWeight = strategy.coverageScore / 100;
|
|
1863
|
+
const criticalWeight = strategy.criticalFilesTotal > 0 ? strategy.criticalFilesCovered / strategy.criticalFilesTotal : 1;
|
|
1864
|
+
const efficiency = budget > 0 ? 1 - strategy.tokensUsed / budget : 0;
|
|
1865
|
+
return coverageWeight * 0.5 + criticalWeight * 0.3 + Math.max(0, efficiency) * 0.2;
|
|
1866
|
+
}
|
|
1867
|
+
|
|
1868
|
+
// src/engine/quality-benchmark.ts
|
|
1869
|
+
import "path";
|
|
1870
|
+
import "fs/promises";
|
|
1871
|
+
|
|
1872
|
+
// src/interact/router.ts
|
|
1873
|
+
var MODEL_REGISTRY = [
|
|
1874
|
+
{
|
|
1875
|
+
id: "claude-haiku-3.5",
|
|
1876
|
+
name: "Claude 3.5 Haiku",
|
|
1877
|
+
tier: "fast",
|
|
1878
|
+
pricing: { inputPerMillion: 0.8, outputPerMillion: 4, cacheReadPerMillion: 0.08 },
|
|
1879
|
+
contextWindow: 2e5,
|
|
1880
|
+
strengths: ["speed", "simple-tasks", "low-cost"]
|
|
1881
|
+
},
|
|
1882
|
+
{
|
|
1883
|
+
id: "claude-sonnet-4",
|
|
1884
|
+
name: "Claude Sonnet 4",
|
|
1885
|
+
tier: "balanced",
|
|
1886
|
+
pricing: { inputPerMillion: 3, outputPerMillion: 15, cacheReadPerMillion: 0.3 },
|
|
1887
|
+
contextWindow: 2e5,
|
|
1888
|
+
strengths: ["code-generation", "refactoring", "balanced-reasoning"]
|
|
1889
|
+
},
|
|
1890
|
+
{
|
|
1891
|
+
id: "claude-opus-4",
|
|
1892
|
+
name: "Claude Opus 4",
|
|
1893
|
+
tier: "reasoning",
|
|
1894
|
+
pricing: { inputPerMillion: 15, outputPerMillion: 75, cacheReadPerMillion: 1.5 },
|
|
1895
|
+
contextWindow: 2e5,
|
|
1896
|
+
strengths: ["deep-reasoning", "architecture", "complex-debugging"]
|
|
1897
|
+
}
|
|
1898
|
+
];
|
|
1899
|
+
var ROUTING_RULES = [
|
|
1900
|
+
{
|
|
1901
|
+
task: "simple-edit",
|
|
1902
|
+
defaultModel: "claude-haiku-3.5",
|
|
1903
|
+
upgradeIf: () => false,
|
|
1904
|
+
upgradeTo: "claude-haiku-3.5",
|
|
1905
|
+
reason: "Simple edits are best handled by fast models",
|
|
1906
|
+
upgradeReason: ""
|
|
1907
|
+
},
|
|
1908
|
+
{
|
|
1909
|
+
task: "docs",
|
|
1910
|
+
defaultModel: "claude-haiku-3.5",
|
|
1911
|
+
upgradeIf: (a) => a.totalTokens > 1e5,
|
|
1912
|
+
upgradeTo: "claude-sonnet-4",
|
|
1913
|
+
reason: "Documentation tasks are straightforward",
|
|
1914
|
+
upgradeReason: "Large codebase \u2014 Sonnet provides better understanding"
|
|
1915
|
+
},
|
|
1916
|
+
{
|
|
1917
|
+
task: "test",
|
|
1918
|
+
defaultModel: "claude-sonnet-4",
|
|
1919
|
+
upgradeIf: (a) => a.riskProfile.overallComplexity > 15,
|
|
1920
|
+
upgradeTo: "claude-opus-4",
|
|
1921
|
+
reason: "Test generation requires good code understanding",
|
|
1922
|
+
upgradeReason: "High complexity codebase \u2014 Opus for better test coverage"
|
|
1923
|
+
},
|
|
1924
|
+
{
|
|
1925
|
+
task: "debug",
|
|
1926
|
+
defaultModel: "claude-sonnet-4",
|
|
1927
|
+
upgradeIf: (a) => a.riskProfile.distribution.critical > 5,
|
|
1928
|
+
upgradeTo: "claude-opus-4",
|
|
1929
|
+
reason: "Debugging requires solid reasoning about code flow",
|
|
1930
|
+
upgradeReason: "Many critical files involved \u2014 Opus for deeper analysis"
|
|
1931
|
+
},
|
|
1932
|
+
{
|
|
1933
|
+
task: "refactor",
|
|
1934
|
+
defaultModel: "claude-sonnet-4",
|
|
1935
|
+
upgradeIf: (a) => a.totalFiles > 50 && a.riskProfile.overallComplexity > 10,
|
|
1936
|
+
upgradeTo: "claude-opus-4",
|
|
1937
|
+
reason: "Refactoring needs good structural understanding",
|
|
1938
|
+
upgradeReason: "Large + complex project \u2014 Opus for safer refactoring"
|
|
1939
|
+
},
|
|
1940
|
+
{
|
|
1941
|
+
task: "review",
|
|
1942
|
+
defaultModel: "claude-sonnet-4",
|
|
1943
|
+
upgradeIf: (a) => a.riskProfile.distribution.critical > 3,
|
|
1944
|
+
upgradeTo: "claude-opus-4",
|
|
1945
|
+
reason: "Code review benefits from balanced reasoning",
|
|
1946
|
+
upgradeReason: "Critical code under review \u2014 Opus for thorough analysis"
|
|
1947
|
+
},
|
|
1948
|
+
{
|
|
1949
|
+
task: "feature",
|
|
1950
|
+
defaultModel: "claude-sonnet-4",
|
|
1951
|
+
upgradeIf: (a) => a.totalFiles > 100,
|
|
1952
|
+
upgradeTo: "claude-opus-4",
|
|
1953
|
+
reason: "Feature development needs code generation + understanding",
|
|
1954
|
+
upgradeReason: "Large codebase \u2014 Opus for better integration"
|
|
1955
|
+
},
|
|
1956
|
+
{
|
|
1957
|
+
task: "architecture",
|
|
1958
|
+
defaultModel: "claude-opus-4",
|
|
1959
|
+
upgradeIf: () => false,
|
|
1960
|
+
upgradeTo: "claude-opus-4",
|
|
1961
|
+
reason: "Architecture decisions require deep reasoning",
|
|
1962
|
+
upgradeReason: ""
|
|
1963
|
+
}
|
|
1964
|
+
];
|
|
1965
|
+
var TASK_KEYWORDS = {
|
|
1966
|
+
debug: ["debug", "fix", "bug", "error", "issue", "broken", "crash", "failing", "wrong"],
|
|
1967
|
+
review: ["review", "check", "assess", "evaluate", "audit", "inspect", "critique"],
|
|
1968
|
+
refactor: ["refactor", "restructure", "reorganize", "clean up", "simplify", "extract", "move"],
|
|
1969
|
+
test: ["test", "spec", "coverage", "unit test", "integration test", "e2e"],
|
|
1970
|
+
docs: ["document", "docs", "readme", "jsdoc", "comment", "explain"],
|
|
1971
|
+
feature: ["add", "implement", "create", "build", "new", "feature", "endpoint"],
|
|
1972
|
+
architecture: ["architecture", "design", "system", "structure", "migrate", "pattern"],
|
|
1973
|
+
"simple-edit": ["rename", "typo", "update", "change", "modify", "tweak", "adjust"]
|
|
1974
|
+
};
|
|
1975
|
+
function classifyTask(taskDescription) {
|
|
1976
|
+
const lower = taskDescription.toLowerCase();
|
|
1977
|
+
let bestType = "simple-edit";
|
|
1978
|
+
let bestScore = 0;
|
|
1979
|
+
for (const [type, keywords] of Object.entries(TASK_KEYWORDS)) {
|
|
1980
|
+
let score = 0;
|
|
1981
|
+
for (const kw of keywords) {
|
|
1982
|
+
if (lower.includes(kw)) score++;
|
|
1983
|
+
}
|
|
1984
|
+
if (score > bestScore) {
|
|
1985
|
+
bestScore = score;
|
|
1986
|
+
bestType = type;
|
|
1987
|
+
}
|
|
1988
|
+
}
|
|
1989
|
+
return bestType;
|
|
1990
|
+
}
|
|
1991
|
+
function routeModel(taskType, analysis, preferredModel) {
|
|
1992
|
+
if (preferredModel) {
|
|
1993
|
+
const spec = MODEL_REGISTRY.find((m) => m.id === preferredModel);
|
|
1994
|
+
if (spec) {
|
|
1995
|
+
return {
|
|
1996
|
+
model: preferredModel,
|
|
1997
|
+
reason: "User-specified model",
|
|
1998
|
+
confidence: 1,
|
|
1999
|
+
alternatives: buildAlternatives(preferredModel, taskType)
|
|
2000
|
+
};
|
|
2001
|
+
}
|
|
2002
|
+
}
|
|
2003
|
+
const rule = ROUTING_RULES.find((r) => r.task === taskType);
|
|
2004
|
+
if (!rule) {
|
|
2005
|
+
return {
|
|
2006
|
+
model: "claude-sonnet-4",
|
|
2007
|
+
reason: "Default model for unrecognized task type",
|
|
2008
|
+
confidence: 0.5,
|
|
2009
|
+
alternatives: buildAlternatives("claude-sonnet-4", taskType)
|
|
2010
|
+
};
|
|
2011
|
+
}
|
|
2012
|
+
const shouldUpgrade = rule.upgradeIf(analysis);
|
|
2013
|
+
const model = shouldUpgrade ? rule.upgradeTo : rule.defaultModel;
|
|
2014
|
+
const reason = shouldUpgrade ? rule.upgradeReason : rule.reason;
|
|
2015
|
+
return {
|
|
2016
|
+
model,
|
|
2017
|
+
reason,
|
|
2018
|
+
confidence: shouldUpgrade ? 0.8 : 0.9,
|
|
2019
|
+
alternatives: buildAlternatives(model, taskType)
|
|
2020
|
+
};
|
|
2021
|
+
}
|
|
2022
|
+
function buildAlternatives(chosenModel, taskType) {
|
|
2023
|
+
return MODEL_REGISTRY.filter((m) => m.id !== chosenModel).map((m) => {
|
|
2024
|
+
const chosen = MODEL_REGISTRY.find((r) => r.id === chosenModel);
|
|
2025
|
+
const costDelta = m.pricing.inputPerMillion - chosen.pricing.inputPerMillion;
|
|
2026
|
+
const tradeoff = costDelta > 0 ? `More capable but ${(costDelta / chosen.pricing.inputPerMillion * 100).toFixed(0)}% more expensive` : `${Math.abs(costDelta / chosen.pricing.inputPerMillion * 100).toFixed(0)}% cheaper but less capable`;
|
|
2027
|
+
return { model: m.id, costDelta, tradeoff };
|
|
2028
|
+
});
|
|
2029
|
+
}
|
|
2030
|
+
function getModelSpec(modelId) {
|
|
2031
|
+
return MODEL_REGISTRY.find((m) => m.id === modelId);
|
|
2032
|
+
}
|
|
2033
|
+
|
|
2034
|
+
// src/interact/prompt.ts
|
|
2035
|
+
function buildPrompt(options) {
|
|
2036
|
+
const {
|
|
2037
|
+
task,
|
|
2038
|
+
taskType,
|
|
2039
|
+
analysis,
|
|
2040
|
+
selection,
|
|
2041
|
+
enableCoT = true,
|
|
2042
|
+
enableConstraints = true,
|
|
2043
|
+
enableAntiHallucination = true
|
|
2044
|
+
} = options;
|
|
2045
|
+
const sections = [];
|
|
2046
|
+
sections.push(buildSystemSection(analysis.stack, taskType));
|
|
2047
|
+
sections.push(buildContextSection(analysis, selection));
|
|
2048
|
+
sections.push(buildTaskSection(task, taskType));
|
|
2049
|
+
if (enableConstraints) {
|
|
2050
|
+
sections.push(buildConstraintsSection(analysis.stack, taskType));
|
|
2051
|
+
}
|
|
2052
|
+
if (enableCoT) {
|
|
2053
|
+
sections.push(buildCoTSection(taskType));
|
|
2054
|
+
}
|
|
2055
|
+
if (enableAntiHallucination) {
|
|
2056
|
+
sections.push(buildAntiHallucinationSection());
|
|
2057
|
+
}
|
|
2058
|
+
sections.push(buildFormatSection(taskType));
|
|
2059
|
+
const rendered = sections.map((s) => s.content).join("\n\n---\n\n");
|
|
2060
|
+
const totalTokens = sections.reduce((s, sec) => s + sec.tokens, 0);
|
|
2061
|
+
return { sections, totalTokens, rendered };
|
|
2062
|
+
}
|
|
2063
|
+
function buildSystemSection(stack, taskType) {
|
|
2064
|
+
const stackStr = stack.length > 0 ? stack.join(", ") : "software";
|
|
2065
|
+
const taskRole = TASK_ROLES[taskType] ?? "engineer";
|
|
2066
|
+
const content = [
|
|
2067
|
+
`You are a senior ${stackStr} ${taskRole} with deep expertise in clean architecture, testing, and production-quality code.`,
|
|
2068
|
+
"You prioritize correctness, readability, and maintainability.",
|
|
2069
|
+
"You never make assumptions without evidence from the code."
|
|
2070
|
+
].join(" ");
|
|
2071
|
+
return makeSection("system", "system", content);
|
|
2072
|
+
}
|
|
2073
|
+
function buildContextSection(analysis, selection) {
|
|
2074
|
+
const lines = [];
|
|
2075
|
+
lines.push(`## Project: ${analysis.projectName}`);
|
|
2076
|
+
lines.push(`Stack: ${analysis.stack.join(", ") || "Unknown"}`);
|
|
2077
|
+
lines.push(`Files analyzed: ${analysis.totalFiles} | Tokens: ~${Math.round(analysis.totalTokens / 1e3)}K`);
|
|
2078
|
+
lines.push(`Context coverage: ${selection.coverage.score}% | Risk score: ${selection.riskScore}/100`);
|
|
2079
|
+
lines.push("");
|
|
2080
|
+
lines.push("### Included Files");
|
|
2081
|
+
lines.push("");
|
|
2082
|
+
const fullFiles = selection.files.filter((f) => f.pruneLevel === "full");
|
|
2083
|
+
const sigFiles = selection.files.filter((f) => f.pruneLevel === "signatures");
|
|
2084
|
+
const skelFiles = selection.files.filter((f) => f.pruneLevel === "skeleton");
|
|
2085
|
+
if (fullFiles.length > 0) {
|
|
2086
|
+
lines.push("**Full content (read these first):**");
|
|
2087
|
+
for (const f of fullFiles) {
|
|
2088
|
+
lines.push(`- \`${f.relativePath}\` (~${Math.round(f.tokens / 1e3)}K tokens) \u2014 ${f.reason}`);
|
|
2089
|
+
}
|
|
2090
|
+
lines.push("");
|
|
2091
|
+
}
|
|
2092
|
+
if (sigFiles.length > 0) {
|
|
2093
|
+
lines.push("**Signatures only (reference as needed):**");
|
|
2094
|
+
for (const f of sigFiles) {
|
|
2095
|
+
lines.push(`- \`${f.relativePath}\` (~${Math.round(f.tokens / 1e3)}K tokens)`);
|
|
2096
|
+
}
|
|
2097
|
+
lines.push("");
|
|
2098
|
+
}
|
|
2099
|
+
if (skelFiles.length > 0) {
|
|
2100
|
+
lines.push("**Skeleton (structure overview):**");
|
|
2101
|
+
for (const f of skelFiles) {
|
|
2102
|
+
lines.push(`- \`${f.relativePath}\``);
|
|
2103
|
+
}
|
|
2104
|
+
lines.push("");
|
|
2105
|
+
}
|
|
2106
|
+
if (selection.coverage.missingCritical.length > 0) {
|
|
2107
|
+
lines.push("\u26A0\uFE0F **Missing critical files** (not included due to budget):");
|
|
2108
|
+
for (const f of selection.coverage.missingCritical) {
|
|
2109
|
+
lines.push(`- \`${f}\``);
|
|
2110
|
+
}
|
|
2111
|
+
lines.push("");
|
|
2112
|
+
}
|
|
2113
|
+
const content = lines.join("\n");
|
|
2114
|
+
return makeSection("context", "context", content);
|
|
2115
|
+
}
|
|
2116
|
+
function buildTaskSection(task, taskType) {
|
|
2117
|
+
const content = [
|
|
2118
|
+
"## Task",
|
|
2119
|
+
"",
|
|
2120
|
+
task,
|
|
2121
|
+
"",
|
|
2122
|
+
`Task type: **${taskType}**`
|
|
2123
|
+
].join("\n");
|
|
2124
|
+
return makeSection("task", "task", content);
|
|
2125
|
+
}
|
|
2126
|
+
function buildConstraintsSection(stack, taskType) {
|
|
2127
|
+
const lines = ["## Constraints", ""];
|
|
2128
|
+
lines.push("- **Do NOT** delete or modify existing tests unless explicitly asked");
|
|
2129
|
+
lines.push("- **Do NOT** change function signatures that are part of the public API");
|
|
2130
|
+
lines.push("- **Do NOT** introduce new dependencies without mentioning it");
|
|
2131
|
+
lines.push("- **Always** handle errors explicitly (no silent catches)");
|
|
2132
|
+
lines.push("- **Always** preserve existing code style and conventions");
|
|
2133
|
+
lines.push("- **Prefer** minimal changes \u2014 smallest diff that solves the problem");
|
|
2134
|
+
if (stack.includes("TypeScript")) {
|
|
2135
|
+
lines.push("- **Always** use strict TypeScript types (no `any` unless unavoidable)");
|
|
2136
|
+
lines.push("- **Always** add explicit return types to exported functions");
|
|
2137
|
+
}
|
|
2138
|
+
if (taskType === "refactor") {
|
|
2139
|
+
lines.push("- **Do NOT** change behavior \u2014 refactoring must be behavior-preserving");
|
|
2140
|
+
lines.push("- **Verify** all existing tests still pass after changes");
|
|
2141
|
+
}
|
|
2142
|
+
if (taskType === "test") {
|
|
2143
|
+
lines.push("- Use AAA pattern: Arrange, Act, Assert");
|
|
2144
|
+
lines.push("- Test boundaries, null/undefined, async errors, type edges");
|
|
2145
|
+
lines.push('- Use descriptive test names: "should [expected] when [condition]"');
|
|
2146
|
+
}
|
|
2147
|
+
return makeSection("constraints", "constraints", lines.join("\n"));
|
|
2148
|
+
}
|
|
2149
|
+
function buildCoTSection(taskType) {
|
|
2150
|
+
const steps = COT_STEPS[taskType] ?? COT_STEPS["simple-edit"];
|
|
2151
|
+
const lines = ["## Thinking Process", "", "Before writing any code:"];
|
|
2152
|
+
steps.forEach((step, i) => {
|
|
2153
|
+
lines.push(`${i + 1}. ${step}`);
|
|
2154
|
+
});
|
|
2155
|
+
return makeSection("cot", "constraints", lines.join("\n"));
|
|
2156
|
+
}
|
|
2157
|
+
function buildAntiHallucinationSection() {
|
|
2158
|
+
const content = [
|
|
2159
|
+
"## Important",
|
|
2160
|
+
"",
|
|
2161
|
+
"- Only reference files, functions, and APIs that exist in the provided context",
|
|
2162
|
+
"- If you are unsure about something, say so explicitly",
|
|
2163
|
+
"- Do NOT invent function signatures, types, or module paths",
|
|
2164
|
+
"- If the context is insufficient to complete the task, explain what is missing"
|
|
2165
|
+
].join("\n");
|
|
2166
|
+
return makeSection("anti-hallucination", "constraints", content);
|
|
2167
|
+
}
|
|
2168
|
+
function buildFormatSection(taskType) {
|
|
2169
|
+
const lines = ["## Output Format", ""];
|
|
2170
|
+
if (taskType === "review") {
|
|
2171
|
+
lines.push("Provide findings in priority order: Critical > Major > Minor > Nitpick");
|
|
2172
|
+
lines.push("For each finding: file, line, issue, and concrete suggestion with code");
|
|
2173
|
+
} else if (taskType === "architecture") {
|
|
2174
|
+
lines.push("Present 2-3 options with trade-offs, then recommend one with justification");
|
|
2175
|
+
} else if (taskType === "debug") {
|
|
2176
|
+
lines.push("1. Root cause analysis");
|
|
2177
|
+
lines.push("2. Minimal fix");
|
|
2178
|
+
lines.push("3. Explanation of why the fix works");
|
|
2179
|
+
lines.push("4. Edge cases to consider");
|
|
2180
|
+
} else {
|
|
2181
|
+
lines.push("Provide clean, production-ready code with brief explanations of key decisions");
|
|
2182
|
+
}
|
|
2183
|
+
return makeSection("format", "format", lines.join("\n"));
|
|
2184
|
+
}
|
|
2185
|
+
var TASK_ROLES = {
|
|
2186
|
+
debug: "debugger",
|
|
2187
|
+
review: "code reviewer",
|
|
2188
|
+
refactor: "architect",
|
|
2189
|
+
test: "test engineer",
|
|
2190
|
+
docs: "technical writer",
|
|
2191
|
+
feature: "engineer",
|
|
2192
|
+
architecture: "systems architect",
|
|
2193
|
+
"simple-edit": "engineer"
|
|
2194
|
+
};
|
|
2195
|
+
var COT_STEPS = {
|
|
2196
|
+
debug: [
|
|
2197
|
+
"**Reproduce** \u2014 Understand the exact symptom and when it occurs",
|
|
2198
|
+
"**Hypothesize** \u2014 List the most likely root causes (max 3)",
|
|
2199
|
+
"**Verify** \u2014 Check each hypothesis against the code",
|
|
2200
|
+
"**Fix** \u2014 Apply the minimal fix that addresses the root cause",
|
|
2201
|
+
"**Validate** \u2014 Explain why the fix works and what edge cases it covers"
|
|
2202
|
+
],
|
|
2203
|
+
review: [
|
|
2204
|
+
"**Understand** \u2014 Read the code and understand its purpose",
|
|
2205
|
+
"**Assess** \u2014 Evaluate correctness, readability, performance, security",
|
|
2206
|
+
"**Prioritize** \u2014 Rank issues by severity (critical > major > minor)",
|
|
2207
|
+
"**Suggest** \u2014 Provide concrete, actionable improvements with code"
|
|
2208
|
+
],
|
|
2209
|
+
refactor: [
|
|
2210
|
+
"**Analyze** \u2014 Identify code smells and structural issues",
|
|
2211
|
+
"**Plan** \u2014 Define the target structure before changing anything",
|
|
2212
|
+
"**Preserve** \u2014 Ensure behavior doesn't change",
|
|
2213
|
+
"**Refactor** \u2014 Apply changes incrementally",
|
|
2214
|
+
"**Verify** \u2014 Confirm all existing tests still pass"
|
|
2215
|
+
],
|
|
2216
|
+
test: [
|
|
2217
|
+
"**Identify** \u2014 What needs testing? (happy path, edge cases, errors)",
|
|
2218
|
+
"**Structure** \u2014 Use AAA pattern: Arrange, Act, Assert",
|
|
2219
|
+
"**Cover** \u2014 Test boundaries, null/undefined, async errors",
|
|
2220
|
+
"**Isolate** \u2014 Mock external dependencies, test units independently"
|
|
2221
|
+
],
|
|
2222
|
+
docs: [
|
|
2223
|
+
"**Read** \u2014 Understand the code before documenting",
|
|
2224
|
+
"**Structure** \u2014 Organize by audience (API users, contributors, operators)",
|
|
2225
|
+
"**Write** \u2014 Clear, concise, with examples"
|
|
2226
|
+
],
|
|
2227
|
+
feature: [
|
|
2228
|
+
"**Clarify** \u2014 Restate the requirement in your own words",
|
|
2229
|
+
"**Design** \u2014 Plan the approach (types, interfaces, flow)",
|
|
2230
|
+
"**Implement** \u2014 Build incrementally, starting with types",
|
|
2231
|
+
"**Test** \u2014 Write tests alongside implementation",
|
|
2232
|
+
"**Integrate** \u2014 Ensure no regressions"
|
|
2233
|
+
],
|
|
2234
|
+
architecture: [
|
|
2235
|
+
"**Context** \u2014 Understand current architecture and constraints",
|
|
2236
|
+
"**Options** \u2014 Present 2-3 viable approaches with trade-offs",
|
|
2237
|
+
"**Recommend** \u2014 Choose the best and explain why",
|
|
2238
|
+
"**Plan** \u2014 Define migration steps",
|
|
2239
|
+
"**Risks** \u2014 Identify risks and mitigation strategies"
|
|
2240
|
+
],
|
|
2241
|
+
"simple-edit": [
|
|
2242
|
+
"**Understand** \u2014 Read the relevant code",
|
|
2243
|
+
"**Plan** \u2014 Think before writing",
|
|
2244
|
+
"**Implement** \u2014 Write clean, well-typed code",
|
|
2245
|
+
"**Verify** \u2014 Check for edge cases"
|
|
2246
|
+
]
|
|
2247
|
+
};
|
|
2248
|
+
function makeSection(id, role, content) {
|
|
2249
|
+
const tokens = countTokensChars4(Buffer.byteLength(content, "utf-8"));
|
|
2250
|
+
return { id, role, content, tokens };
|
|
2251
|
+
}
|
|
2252
|
+
|
|
2253
|
+
// src/engine/quality-benchmark.ts
|
|
2254
|
+
async function runQualityBenchmark(analysis, task, budget = 5e4) {
|
|
2255
|
+
const taskType = classifyTask(task);
|
|
2256
|
+
const relevantFiles = identifyRelevantFiles(analysis, task, taskType);
|
|
2257
|
+
const relevantPaths = new Set(relevantFiles.map((f) => f.relativePath));
|
|
2258
|
+
const typeFilesNeeded = findTypeFiles(analysis, relevantFiles);
|
|
2259
|
+
const typePaths = new Set(typeFilesNeeded.map((f) => f.relativePath));
|
|
2260
|
+
const adj = buildAdjacencyList(analysis.graph.edges);
|
|
2261
|
+
const depsNeeded = bfsBidirectional(
|
|
2262
|
+
relevantFiles.map((f) => f.relativePath),
|
|
2263
|
+
adj,
|
|
2264
|
+
2
|
|
2265
|
+
);
|
|
2266
|
+
const ctoSelection = await selectContext({ task, analysis, budget });
|
|
2267
|
+
const ctoMetrics = computeMetrics(
|
|
2268
|
+
ctoSelection.files.map((f) => f.relativePath),
|
|
2269
|
+
ctoSelection.totalTokens,
|
|
2270
|
+
analysis,
|
|
2271
|
+
relevantPaths,
|
|
2272
|
+
typePaths,
|
|
2273
|
+
depsNeeded
|
|
2274
|
+
);
|
|
2275
|
+
const naiveFiles = [...analysis.files].sort((a, b) => a.relativePath.localeCompare(b.relativePath));
|
|
2276
|
+
const naiveSelected = [];
|
|
2277
|
+
let naiveTokens = 0;
|
|
2278
|
+
for (const f of naiveFiles) {
|
|
2279
|
+
if (naiveTokens + f.tokens <= budget) {
|
|
2280
|
+
naiveSelected.push(f.relativePath);
|
|
2281
|
+
naiveTokens += f.tokens;
|
|
2282
|
+
}
|
|
2283
|
+
}
|
|
2284
|
+
if (naiveSelected.length === 0 && analysis.totalTokens <= budget) {
|
|
2285
|
+
for (const f of analysis.files) naiveSelected.push(f.relativePath);
|
|
2286
|
+
naiveTokens = analysis.totalTokens;
|
|
2287
|
+
}
|
|
2288
|
+
const naiveMetrics = computeMetrics(
|
|
2289
|
+
naiveSelected,
|
|
2290
|
+
naiveTokens,
|
|
2291
|
+
analysis,
|
|
2292
|
+
relevantPaths,
|
|
2293
|
+
typePaths,
|
|
2294
|
+
depsNeeded
|
|
2295
|
+
);
|
|
2296
|
+
const shuffled = [...analysis.files].sort(() => Math.random() - 0.5);
|
|
2297
|
+
const randomSelected = [];
|
|
2298
|
+
let randomTokens = 0;
|
|
2299
|
+
for (const f of shuffled) {
|
|
2300
|
+
if (randomTokens + f.tokens <= budget) {
|
|
2301
|
+
randomSelected.push(f.relativePath);
|
|
2302
|
+
randomTokens += f.tokens;
|
|
2303
|
+
}
|
|
2304
|
+
}
|
|
2305
|
+
const randomMetrics = computeMetrics(
|
|
2306
|
+
randomSelected,
|
|
2307
|
+
randomTokens,
|
|
2308
|
+
analysis,
|
|
2309
|
+
relevantPaths,
|
|
2310
|
+
typePaths,
|
|
2311
|
+
depsNeeded
|
|
2312
|
+
);
|
|
2313
|
+
const ctoPrompt = buildPrompt({
|
|
2314
|
+
task,
|
|
2315
|
+
taskType,
|
|
2316
|
+
analysis,
|
|
2317
|
+
selection: ctoSelection,
|
|
2318
|
+
enableCoT: true
|
|
2319
|
+
});
|
|
2320
|
+
const naiveSelectionFiles = naiveSelected.map((p) => {
|
|
2321
|
+
const f = analysis.files.find((af) => af.relativePath === p);
|
|
2322
|
+
return {
|
|
2323
|
+
relativePath: p,
|
|
2324
|
+
tokens: f?.tokens ?? 0,
|
|
2325
|
+
originalTokens: f?.tokens ?? 0,
|
|
2326
|
+
pruneLevel: "full",
|
|
2327
|
+
riskScore: f?.riskScore ?? 0,
|
|
2328
|
+
reason: "alphabetical inclusion"
|
|
2329
|
+
};
|
|
2330
|
+
});
|
|
2331
|
+
const naiveFakeSelection = {
|
|
2332
|
+
files: naiveSelectionFiles,
|
|
2333
|
+
totalTokens: naiveTokens,
|
|
2334
|
+
budget,
|
|
2335
|
+
usedPercent: budget > 0 ? naiveTokens / budget * 100 : 0,
|
|
2336
|
+
coverage: { score: naiveMetrics.completenessScore, missingCritical: [], missingRelevant: [], relevantFiles: [], includedRelevant: [], explanation: "naive selection" },
|
|
2337
|
+
riskScore: 0,
|
|
2338
|
+
deterministic: false,
|
|
2339
|
+
hash: "",
|
|
2340
|
+
decisions: []
|
|
2341
|
+
};
|
|
2342
|
+
const naivePrompt = buildPrompt({
|
|
2343
|
+
task,
|
|
2344
|
+
taskType,
|
|
2345
|
+
analysis,
|
|
2346
|
+
selection: naiveFakeSelection,
|
|
2347
|
+
enableCoT: true
|
|
2348
|
+
});
|
|
2349
|
+
const comparison = {
|
|
2350
|
+
ctoVsNaiveRelevance: ctoMetrics.relevanceScore - naiveMetrics.relevanceScore,
|
|
2351
|
+
ctoVsRandomRelevance: ctoMetrics.relevanceScore - randomMetrics.relevanceScore,
|
|
2352
|
+
ctoVsNaiveCompleteness: ctoMetrics.completenessScore - naiveMetrics.completenessScore,
|
|
2353
|
+
ctoVsRandomCompleteness: ctoMetrics.completenessScore - randomMetrics.completenessScore,
|
|
2354
|
+
ctoNoiseReduction: naiveMetrics.noiseRatio - ctoMetrics.noiseRatio
|
|
2355
|
+
};
|
|
2356
|
+
const verdict = generateVerdict(ctoMetrics, naiveMetrics, randomMetrics, comparison);
|
|
2357
|
+
return {
|
|
2358
|
+
project: analysis.projectName,
|
|
2359
|
+
task,
|
|
2360
|
+
taskType,
|
|
2361
|
+
budget,
|
|
2362
|
+
strategies: {
|
|
2363
|
+
cto: ctoMetrics,
|
|
2364
|
+
naive: naiveMetrics,
|
|
2365
|
+
random: randomMetrics
|
|
2366
|
+
},
|
|
2367
|
+
comparison,
|
|
2368
|
+
prompts: {
|
|
2369
|
+
cto: { rendered: ctoPrompt.rendered, tokens: ctoPrompt.totalTokens },
|
|
2370
|
+
naive: { rendered: naivePrompt.rendered, tokens: naivePrompt.totalTokens }
|
|
2371
|
+
},
|
|
2372
|
+
verdict
|
|
2373
|
+
};
|
|
2374
|
+
}
|
|
2375
|
+
function identifyRelevantFiles(analysis, task, taskType) {
|
|
2376
|
+
const keywords = extractKeywords(task);
|
|
2377
|
+
const scored = /* @__PURE__ */ new Map();
|
|
2378
|
+
for (const file of analysis.files) {
|
|
2379
|
+
let relevance = 0;
|
|
2380
|
+
for (const kw of keywords) {
|
|
2381
|
+
if (file.relativePath.toLowerCase().includes(kw)) {
|
|
2382
|
+
relevance += 10;
|
|
2383
|
+
}
|
|
2384
|
+
}
|
|
2385
|
+
if (file.riskScore >= 60) relevance += 5;
|
|
2386
|
+
if (file.isHub) relevance += 3;
|
|
2387
|
+
if (file.kind === "entry") relevance += 3;
|
|
2388
|
+
if (file.kind === "type") relevance += 2;
|
|
2389
|
+
if (taskType === "test" && file.kind === "test") relevance += 8;
|
|
2390
|
+
if (taskType === "refactor" && file.complexity > 10) relevance += 4;
|
|
2391
|
+
if (taskType === "debug" && file.riskScore >= 40) relevance += 4;
|
|
2392
|
+
if (relevance > 0) {
|
|
2393
|
+
scored.set(file.relativePath, relevance);
|
|
2394
|
+
}
|
|
2395
|
+
}
|
|
2396
|
+
const adj = buildAdjacencyList(analysis.graph.edges);
|
|
2397
|
+
const seeds = [...scored.keys()];
|
|
2398
|
+
const expanded = bfsBidirectional(seeds, adj, 1);
|
|
2399
|
+
for (const path of expanded) {
|
|
2400
|
+
if (!scored.has(path)) {
|
|
2401
|
+
scored.set(path, 1);
|
|
2402
|
+
}
|
|
2403
|
+
}
|
|
2404
|
+
return analysis.files.filter((f) => scored.has(f.relativePath)).sort((a, b) => (scored.get(b.relativePath) ?? 0) - (scored.get(a.relativePath) ?? 0));
|
|
2405
|
+
}
|
|
2406
|
+
function extractKeywords(task) {
|
|
2407
|
+
const stopWords = /* @__PURE__ */ new Set([
|
|
2408
|
+
"the",
|
|
2409
|
+
"a",
|
|
2410
|
+
"an",
|
|
2411
|
+
"is",
|
|
2412
|
+
"are",
|
|
2413
|
+
"was",
|
|
2414
|
+
"were",
|
|
2415
|
+
"be",
|
|
2416
|
+
"been",
|
|
2417
|
+
"being",
|
|
2418
|
+
"have",
|
|
2419
|
+
"has",
|
|
2420
|
+
"had",
|
|
2421
|
+
"do",
|
|
2422
|
+
"does",
|
|
2423
|
+
"did",
|
|
2424
|
+
"will",
|
|
2425
|
+
"would",
|
|
2426
|
+
"could",
|
|
2427
|
+
"should",
|
|
2428
|
+
"may",
|
|
2429
|
+
"might",
|
|
2430
|
+
"can",
|
|
2431
|
+
"shall",
|
|
2432
|
+
"to",
|
|
2433
|
+
"of",
|
|
2434
|
+
"in",
|
|
2435
|
+
"for",
|
|
2436
|
+
"on",
|
|
2437
|
+
"with",
|
|
2438
|
+
"at",
|
|
2439
|
+
"by",
|
|
2440
|
+
"from",
|
|
2441
|
+
"as",
|
|
2442
|
+
"into",
|
|
2443
|
+
"through",
|
|
2444
|
+
"and",
|
|
2445
|
+
"but",
|
|
2446
|
+
"or",
|
|
2447
|
+
"nor",
|
|
2448
|
+
"not",
|
|
2449
|
+
"so",
|
|
2450
|
+
"yet",
|
|
2451
|
+
"both",
|
|
2452
|
+
"either",
|
|
2453
|
+
"neither",
|
|
2454
|
+
"this",
|
|
2455
|
+
"that",
|
|
2456
|
+
"these",
|
|
2457
|
+
"those",
|
|
2458
|
+
"it",
|
|
2459
|
+
"its",
|
|
2460
|
+
"my",
|
|
2461
|
+
"your",
|
|
2462
|
+
"our",
|
|
2463
|
+
"their",
|
|
2464
|
+
"his",
|
|
2465
|
+
"her",
|
|
2466
|
+
"fix",
|
|
2467
|
+
"add",
|
|
2468
|
+
"remove",
|
|
2469
|
+
"update",
|
|
2470
|
+
"change",
|
|
2471
|
+
"refactor",
|
|
2472
|
+
"implement",
|
|
2473
|
+
"create",
|
|
2474
|
+
"build",
|
|
2475
|
+
"make",
|
|
2476
|
+
"code",
|
|
2477
|
+
"file"
|
|
2478
|
+
]);
|
|
2479
|
+
return task.toLowerCase().replace(/[^a-z0-9\s]/g, "").split(/\s+/).filter((w) => w.length > 2 && !stopWords.has(w));
|
|
2480
|
+
}
|
|
2481
|
+
function findTypeFiles(analysis, relevantFiles) {
|
|
2482
|
+
const adj = buildAdjacencyList(analysis.graph.edges);
|
|
2483
|
+
const typeFiles = /* @__PURE__ */ new Set();
|
|
2484
|
+
for (const file of relevantFiles) {
|
|
2485
|
+
const deps = adj.forward.get(file.relativePath) ?? [];
|
|
2486
|
+
for (const dep of deps) {
|
|
2487
|
+
const depFile = analysis.files.find((f) => f.relativePath === dep);
|
|
2488
|
+
if (depFile && depFile.kind === "type") {
|
|
2489
|
+
typeFiles.add(dep);
|
|
2490
|
+
}
|
|
2491
|
+
}
|
|
2492
|
+
}
|
|
2493
|
+
return analysis.files.filter((f) => typeFiles.has(f.relativePath));
|
|
2494
|
+
}
|
|
2495
|
+
function computeMetrics(selectedPaths, tokensUsed, analysis, relevantPaths, typePaths, depsNeeded) {
|
|
2496
|
+
const selected = new Set(selectedPaths);
|
|
2497
|
+
const relevantIncluded = selectedPaths.filter((p) => relevantPaths.has(p)).length;
|
|
2498
|
+
const relevanceScore = selectedPaths.length > 0 ? Math.round(relevantIncluded / selectedPaths.length * 100) : 0;
|
|
2499
|
+
const completenessHits = [...relevantPaths].filter((p) => selected.has(p)).length;
|
|
2500
|
+
const completenessScore = relevantPaths.size > 0 ? Math.round(completenessHits / relevantPaths.size * 100) : 100;
|
|
2501
|
+
let irrelevantTokens = 0;
|
|
2502
|
+
for (const path of selectedPaths) {
|
|
2503
|
+
if (!relevantPaths.has(path)) {
|
|
2504
|
+
const f = analysis.files.find((af) => af.relativePath === path);
|
|
2505
|
+
irrelevantTokens += f?.tokens ?? 0;
|
|
2506
|
+
}
|
|
2507
|
+
}
|
|
2508
|
+
const noiseRatio = tokensUsed > 0 ? Math.min(100, Math.round(irrelevantTokens / tokensUsed * 100)) : 0;
|
|
2509
|
+
const typeIncluded = [...typePaths].filter((p) => selected.has(p)).length;
|
|
2510
|
+
const typeCoverage = typePaths.size > 0 ? Math.round(typeIncluded / typePaths.size * 100) : 100;
|
|
2511
|
+
const depsIncluded = [...depsNeeded].filter((p) => selected.has(p)).length;
|
|
2512
|
+
const dependencyClosure = depsNeeded.size > 0 ? Math.round(depsIncluded / depsNeeded.size * 100) : 100;
|
|
2513
|
+
return {
|
|
2514
|
+
filesSelected: selectedPaths.length,
|
|
2515
|
+
tokensUsed,
|
|
2516
|
+
relevanceScore,
|
|
2517
|
+
completenessScore,
|
|
2518
|
+
noiseRatio,
|
|
2519
|
+
typeCoverage,
|
|
2520
|
+
dependencyClosure,
|
|
2521
|
+
relevantFilesIncluded: relevantIncluded,
|
|
2522
|
+
relevantFilesTotal: relevantPaths.size,
|
|
2523
|
+
typeFilesIncluded: typeIncluded,
|
|
2524
|
+
typeFilesNeeded: typePaths.size,
|
|
2525
|
+
depsIncluded,
|
|
2526
|
+
depsNeeded: depsNeeded.size
|
|
2527
|
+
};
|
|
2528
|
+
}
|
|
2529
|
+
function generateVerdict(cto, naive, random, comp) {
|
|
2530
|
+
const lines = [];
|
|
2531
|
+
lines.push("## Quality Verdict");
|
|
2532
|
+
lines.push("");
|
|
2533
|
+
if (comp.ctoVsNaiveRelevance > 0 && comp.ctoVsNaiveCompleteness > 0) {
|
|
2534
|
+
lines.push(`**CTO produces higher-quality context than both naive and random selection.**`);
|
|
2535
|
+
} else if (comp.ctoVsRandomRelevance > 0) {
|
|
2536
|
+
lines.push(`**CTO produces higher-quality context than random selection.**`);
|
|
2537
|
+
} else {
|
|
2538
|
+
lines.push(`**All strategies perform similarly for this task.**`);
|
|
2539
|
+
}
|
|
2540
|
+
lines.push("");
|
|
2541
|
+
lines.push(`### Relevance (higher = less hallucination risk)`);
|
|
2542
|
+
lines.push(`- CTO: ${cto.relevanceScore}% of included files are relevant`);
|
|
2543
|
+
lines.push(`- Naive: ${naive.relevanceScore}% relevant`);
|
|
2544
|
+
lines.push(`- Random: ${random.relevanceScore}% relevant`);
|
|
2545
|
+
if (comp.ctoVsNaiveRelevance > 0) {
|
|
2546
|
+
lines.push(`- **CTO includes ${comp.ctoVsNaiveRelevance}% more relevant files than naive**`);
|
|
2547
|
+
}
|
|
2548
|
+
lines.push("");
|
|
2549
|
+
lines.push(`### Completeness (higher = more correct code generation)`);
|
|
2550
|
+
lines.push(`- CTO: ${cto.completenessScore}% of required files included`);
|
|
2551
|
+
lines.push(`- Naive: ${naive.completenessScore}% complete`);
|
|
2552
|
+
lines.push(`- Random: ${random.completenessScore}% complete`);
|
|
2553
|
+
if (comp.ctoVsNaiveCompleteness > 0) {
|
|
2554
|
+
lines.push(`- **CTO captures ${comp.ctoVsNaiveCompleteness}% more required files**`);
|
|
2555
|
+
}
|
|
2556
|
+
lines.push("");
|
|
2557
|
+
lines.push(`### Noise (lower = less distraction for the AI)`);
|
|
2558
|
+
lines.push(`- CTO: ${cto.noiseRatio}% noise`);
|
|
2559
|
+
lines.push(`- Naive: ${naive.noiseRatio}% noise`);
|
|
2560
|
+
lines.push(`- Random: ${random.noiseRatio}% noise`);
|
|
2561
|
+
if (comp.ctoNoiseReduction > 0) {
|
|
2562
|
+
lines.push(`- **CTO reduces noise by ${comp.ctoNoiseReduction} percentage points**`);
|
|
2563
|
+
}
|
|
2564
|
+
lines.push("");
|
|
2565
|
+
lines.push(`### Type Coverage (higher = fewer type errors in generated code)`);
|
|
2566
|
+
lines.push(`- CTO: ${cto.typeCoverage}% | Naive: ${naive.typeCoverage}% | Random: ${random.typeCoverage}%`);
|
|
2567
|
+
lines.push("");
|
|
2568
|
+
lines.push(`### Dependency Closure (higher = AI understands import chain)`);
|
|
2569
|
+
lines.push(`- CTO: ${cto.dependencyClosure}% | Naive: ${naive.dependencyClosure}% | Random: ${random.dependencyClosure}%`);
|
|
2570
|
+
return lines.join("\n");
|
|
2571
|
+
}
|
|
2572
|
+
|
|
2573
|
+
// src/engine/pr-context.ts
|
|
2574
|
+
import { resolve as resolve6 } from "path";
|
|
2575
|
+
import { execFile } from "child_process";
|
|
2576
|
+
import { promisify } from "util";
|
|
2577
|
+
var exec = promisify(execFile);
|
|
2578
|
+
async function git(args, cwd) {
|
|
2579
|
+
try {
|
|
2580
|
+
const { stdout } = await exec("git", args, { cwd, maxBuffer: 10 * 1024 * 1024 });
|
|
2581
|
+
return stdout.trim();
|
|
2582
|
+
} catch {
|
|
2583
|
+
return "";
|
|
2584
|
+
}
|
|
2585
|
+
}
|
|
2586
|
+
async function isGitRepo(projectPath) {
|
|
2587
|
+
const result = await git(["rev-parse", "--is-inside-work-tree"], projectPath);
|
|
2588
|
+
return result === "true";
|
|
2589
|
+
}
|
|
2590
|
+
async function getCurrentBranch(projectPath) {
|
|
2591
|
+
return git(["rev-parse", "--abbrev-ref", "HEAD"], projectPath);
|
|
2592
|
+
}
|
|
2593
|
+
async function getChangedFilesFromDiff(projectPath, baseBranch) {
|
|
2594
|
+
const results = /* @__PURE__ */ new Map();
|
|
2595
|
+
const workingDiff = await git(["diff", "--numstat", "HEAD"], projectPath);
|
|
2596
|
+
parseDiffNumstat(workingDiff, results, "modified");
|
|
2597
|
+
const stagedDiff = await git(["diff", "--numstat", "--cached"], projectPath);
|
|
2598
|
+
parseDiffNumstat(stagedDiff, results, "modified");
|
|
2599
|
+
const untracked = await git(["ls-files", "--others", "--exclude-standard"], projectPath);
|
|
2600
|
+
for (const line of untracked.split("\n")) {
|
|
2601
|
+
const f = line.trim();
|
|
2602
|
+
if (f && !results.has(f)) {
|
|
2603
|
+
results.set(f, { relativePath: f, changeType: "added", linesAdded: 0, linesRemoved: 0 });
|
|
2604
|
+
}
|
|
2605
|
+
}
|
|
2606
|
+
if (baseBranch) {
|
|
2607
|
+
const branchExists = await git(["rev-parse", "--verify", baseBranch], projectPath);
|
|
2608
|
+
if (branchExists) {
|
|
2609
|
+
const branchDiff = await git(["diff", "--numstat", `${baseBranch}...HEAD`], projectPath);
|
|
2610
|
+
parseDiffNumstat(branchDiff, results, "modified");
|
|
2611
|
+
const nameStatus = await git(["diff", "--name-status", `${baseBranch}...HEAD`], projectPath);
|
|
2612
|
+
for (const line of nameStatus.split("\n")) {
|
|
2613
|
+
const parts = line.trim().split(" ");
|
|
2614
|
+
if (parts.length >= 2) {
|
|
2615
|
+
const status = parts[0];
|
|
2616
|
+
const filePath = parts[parts.length - 1];
|
|
2617
|
+
if (results.has(filePath)) {
|
|
2618
|
+
const existing = results.get(filePath);
|
|
2619
|
+
if (status === "A") existing.changeType = "added";
|
|
2620
|
+
else if (status === "D") existing.changeType = "deleted";
|
|
2621
|
+
else if (status.startsWith("R")) existing.changeType = "renamed";
|
|
2622
|
+
}
|
|
2623
|
+
}
|
|
2624
|
+
}
|
|
2625
|
+
}
|
|
2626
|
+
}
|
|
2627
|
+
return [...results.values()];
|
|
2628
|
+
}
|
|
2629
|
+
function parseDiffNumstat(output, results, defaultType) {
|
|
2630
|
+
for (const line of output.split("\n")) {
|
|
2631
|
+
const parts = line.trim().split(" ");
|
|
2632
|
+
if (parts.length < 3) continue;
|
|
2633
|
+
const added = parts[0] === "-" ? 0 : parseInt(parts[0], 10) || 0;
|
|
2634
|
+
const removed = parts[1] === "-" ? 0 : parseInt(parts[1], 10) || 0;
|
|
2635
|
+
const filePath = parts[2];
|
|
2636
|
+
if (!filePath) continue;
|
|
2637
|
+
const existing = results.get(filePath);
|
|
2638
|
+
if (existing) {
|
|
2639
|
+
existing.linesAdded = Math.max(existing.linesAdded, added);
|
|
2640
|
+
existing.linesRemoved = Math.max(existing.linesRemoved, removed);
|
|
2641
|
+
} else {
|
|
2642
|
+
results.set(filePath, {
|
|
2643
|
+
relativePath: filePath,
|
|
2644
|
+
changeType: defaultType,
|
|
2645
|
+
linesAdded: added,
|
|
2646
|
+
linesRemoved: removed
|
|
2647
|
+
});
|
|
2648
|
+
}
|
|
2649
|
+
}
|
|
2650
|
+
}
|
|
2651
|
+
async function generatePRContext(analysis, options = {}) {
|
|
2652
|
+
const projectPath = resolve6(analysis.projectPath);
|
|
2653
|
+
const baseBranch = options.baseBranch ?? "main";
|
|
2654
|
+
const depth = options.depth ?? 2;
|
|
2655
|
+
const includeTests = options.includeTests ?? false;
|
|
2656
|
+
const gitRepo = await isGitRepo(projectPath);
|
|
2657
|
+
if (!gitRepo) {
|
|
2658
|
+
return emptyResult2(baseBranch);
|
|
2659
|
+
}
|
|
2660
|
+
const currentBranch = await getCurrentBranch(projectPath);
|
|
2661
|
+
const changedFiles = await getChangedFilesFromDiff(projectPath, baseBranch);
|
|
2662
|
+
if (changedFiles.length === 0) {
|
|
2663
|
+
return {
|
|
2664
|
+
...emptyResult2(baseBranch),
|
|
2665
|
+
currentBranch,
|
|
2666
|
+
isGitRepo: true,
|
|
2667
|
+
renderedSummary: "# PR Context\n\nNo changed files detected."
|
|
2668
|
+
};
|
|
2669
|
+
}
|
|
2670
|
+
const analysisFileSet = new Set(analysis.files.map((f) => f.relativePath));
|
|
2671
|
+
const validChangedPaths = changedFiles.filter((c) => c.changeType !== "deleted" && analysisFileSet.has(c.relativePath)).map((c) => c.relativePath);
|
|
2672
|
+
const adj = buildAdjacencyList(analysis.graph.edges);
|
|
2673
|
+
const expanded = bfsBidirectional(validChangedPaths, adj, depth);
|
|
2674
|
+
const changedSet = new Set(validChangedPaths);
|
|
2675
|
+
const dependencyFiles = [...expanded].filter((f) => !changedSet.has(f));
|
|
2676
|
+
const allRelevantPaths = new Set(expanded);
|
|
2677
|
+
if (!includeTests) {
|
|
2678
|
+
for (const path of allRelevantPaths) {
|
|
2679
|
+
const file = analysis.files.find((f) => f.relativePath === path);
|
|
2680
|
+
if (file && file.kind === "test") {
|
|
2681
|
+
if (!changedSet.has(path)) {
|
|
2682
|
+
allRelevantPaths.delete(path);
|
|
2683
|
+
}
|
|
2684
|
+
}
|
|
2685
|
+
}
|
|
2686
|
+
}
|
|
2687
|
+
const allRelevantFiles = analysis.files.filter((f) => allRelevantPaths.has(f.relativePath)).sort((a, b) => b.riskScore - a.riskScore);
|
|
2688
|
+
const changedAnalyzed = allRelevantFiles.filter((f) => changedSet.has(f.relativePath));
|
|
2689
|
+
const totalChangedTokens = changedAnalyzed.reduce((s, f) => s + f.tokens, 0);
|
|
2690
|
+
const totalContextTokens = allRelevantFiles.reduce((s, f) => s + f.tokens, 0);
|
|
2691
|
+
const riskSummary = {
|
|
2692
|
+
critical: allRelevantFiles.filter((f) => f.riskScore >= 80).length,
|
|
2693
|
+
high: allRelevantFiles.filter((f) => f.riskScore >= 60 && f.riskScore < 80).length,
|
|
2694
|
+
medium: allRelevantFiles.filter((f) => f.riskScore >= 30 && f.riskScore < 60).length,
|
|
2695
|
+
low: allRelevantFiles.filter((f) => f.riskScore < 30).length,
|
|
2696
|
+
maxRiskFile: allRelevantFiles[0]?.relativePath ?? "",
|
|
2697
|
+
maxRiskScore: allRelevantFiles[0]?.riskScore ?? 0
|
|
2698
|
+
};
|
|
2699
|
+
const renderedSummary = renderSummary(
|
|
2700
|
+
analysis,
|
|
2701
|
+
currentBranch,
|
|
2702
|
+
baseBranch,
|
|
2703
|
+
changedFiles,
|
|
2704
|
+
dependencyFiles,
|
|
2705
|
+
allRelevantFiles,
|
|
2706
|
+
changedSet,
|
|
2707
|
+
riskSummary,
|
|
2708
|
+
totalChangedTokens,
|
|
2709
|
+
totalContextTokens
|
|
2710
|
+
);
|
|
2711
|
+
return {
|
|
2712
|
+
baseBranch,
|
|
2713
|
+
currentBranch,
|
|
2714
|
+
isGitRepo: true,
|
|
2715
|
+
changedFiles,
|
|
2716
|
+
dependencyFiles: dependencyFiles.filter((f) => allRelevantPaths.has(f)),
|
|
2717
|
+
allRelevantFiles,
|
|
2718
|
+
totalChangedTokens,
|
|
2719
|
+
totalContextTokens,
|
|
2720
|
+
riskSummary,
|
|
2721
|
+
renderedSummary
|
|
2722
|
+
};
|
|
2723
|
+
}
|
|
2724
|
+
function renderSummary(analysis, currentBranch, baseBranch, changedFiles, dependencyFiles, allRelevant, changedSet, risk, changedTokens, totalTokens) {
|
|
2725
|
+
const lines = [];
|
|
2726
|
+
lines.push(`## PR Context \u2014 ${analysis.projectName}`);
|
|
2727
|
+
lines.push("");
|
|
2728
|
+
lines.push(`**Branch:** ${currentBranch} \u2190 ${baseBranch}`);
|
|
2729
|
+
lines.push(`**Changed:** ${changedFiles.length} files | **Dependencies:** ${dependencyFiles.length} files`);
|
|
2730
|
+
lines.push(`**Tokens:** ~${Math.round(changedTokens / 1e3)}K changed + ~${Math.round((totalTokens - changedTokens) / 1e3)}K context = ~${Math.round(totalTokens / 1e3)}K total`);
|
|
2731
|
+
lines.push("");
|
|
2732
|
+
if (risk.critical > 0 || risk.high > 0) {
|
|
2733
|
+
lines.push(`\u26A0\uFE0F **Risk:** ${risk.critical} critical + ${risk.high} high-risk files affected`);
|
|
2734
|
+
lines.push(`**Highest risk:** ${risk.maxRiskFile} (score: ${risk.maxRiskScore})`);
|
|
2735
|
+
lines.push("");
|
|
2736
|
+
}
|
|
2737
|
+
lines.push("### Changed Files");
|
|
2738
|
+
lines.push("");
|
|
2739
|
+
const sortedChanged = changedFiles.filter((c) => c.changeType !== "deleted").sort((a, b) => {
|
|
2740
|
+
const fa = allRelevant.find((f) => f.relativePath === a.relativePath);
|
|
2741
|
+
const fb = allRelevant.find((f) => f.relativePath === b.relativePath);
|
|
2742
|
+
return (fb?.riskScore ?? 0) - (fa?.riskScore ?? 0);
|
|
2743
|
+
});
|
|
2744
|
+
for (const c of sortedChanged) {
|
|
2745
|
+
const file = allRelevant.find((f) => f.relativePath === c.relativePath);
|
|
2746
|
+
const risk2 = file ? ` risk:${file.riskScore}` : "";
|
|
2747
|
+
const delta = c.linesAdded || c.linesRemoved ? ` (+${c.linesAdded}/-${c.linesRemoved})` : "";
|
|
2748
|
+
const badge = c.changeType === "added" ? " \u{1F195}" : c.changeType === "renamed" ? " \u{1F4DD}" : "";
|
|
2749
|
+
lines.push(`- \`${c.relativePath}\`${delta}${risk2}${badge}`);
|
|
2750
|
+
}
|
|
2751
|
+
const deleted = changedFiles.filter((c) => c.changeType === "deleted");
|
|
2752
|
+
if (deleted.length > 0) {
|
|
2753
|
+
lines.push("");
|
|
2754
|
+
lines.push(`**Deleted:** ${deleted.map((d) => `\`${d.relativePath}\``).join(", ")}`);
|
|
2755
|
+
}
|
|
2756
|
+
if (dependencyFiles.length > 0) {
|
|
2757
|
+
lines.push("");
|
|
2758
|
+
lines.push("### Dependencies (included for context)");
|
|
2759
|
+
lines.push("");
|
|
2760
|
+
const depWithInfo = dependencyFiles.map((d) => {
|
|
2761
|
+
const file = allRelevant.find((f) => f.relativePath === d);
|
|
2762
|
+
return { path: d, riskScore: file?.riskScore ?? 0, tokens: file?.tokens ?? 0 };
|
|
2763
|
+
}).sort((a, b) => b.riskScore - a.riskScore);
|
|
2764
|
+
for (const d of depWithInfo) {
|
|
2765
|
+
lines.push(`- \`${d.path}\` risk:${d.riskScore} ~${Math.round(d.tokens / 1e3)}K tokens`);
|
|
2766
|
+
}
|
|
2767
|
+
}
|
|
2768
|
+
lines.push("");
|
|
2769
|
+
lines.push("### Review Focus");
|
|
2770
|
+
lines.push("");
|
|
2771
|
+
lines.push("- Review changed files for correctness, especially high-risk files");
|
|
2772
|
+
lines.push("- Dependencies are included for type/interface context \u2014 not for review");
|
|
2773
|
+
lines.push(`- ${analysis.totalFiles - allRelevant.length} files excluded (not affected by this change)`);
|
|
2774
|
+
return lines.join("\n");
|
|
2775
|
+
}
|
|
2776
|
+
function emptyResult2(baseBranch) {
|
|
2777
|
+
return {
|
|
2778
|
+
baseBranch,
|
|
2779
|
+
currentBranch: "",
|
|
2780
|
+
isGitRepo: false,
|
|
2781
|
+
changedFiles: [],
|
|
2782
|
+
dependencyFiles: [],
|
|
2783
|
+
allRelevantFiles: [],
|
|
2784
|
+
totalChangedTokens: 0,
|
|
2785
|
+
totalContextTokens: 0,
|
|
2786
|
+
riskSummary: { critical: 0, high: 0, medium: 0, low: 0, maxRiskFile: "", maxRiskScore: 0 },
|
|
2787
|
+
renderedSummary: "# PR Context\n\nNot a git repository."
|
|
2788
|
+
};
|
|
2789
|
+
}
|
|
2790
|
+
|
|
2791
|
+
// src/interact/orchestrator.ts
|
|
2792
|
+
import { randomUUID as randomUUID2 } from "crypto";
|
|
2793
|
+
|
|
2794
|
+
// src/interact/estimator.ts
|
|
2795
|
+
function estimateCost(modelId, inputTokens, totalProjectTokens, estimatedOutputRatio = 0.3) {
|
|
2796
|
+
const spec = getModelSpec(modelId) ?? MODEL_REGISTRY[1];
|
|
2797
|
+
const estimatedOutputTokens = Math.round(inputTokens * estimatedOutputRatio);
|
|
2798
|
+
const inputCost = inputTokens / 1e6 * spec.pricing.inputPerMillion;
|
|
2799
|
+
const outputCost = estimatedOutputTokens / 1e6 * spec.pricing.outputPerMillion;
|
|
2800
|
+
const totalCost = inputCost + outputCost;
|
|
2801
|
+
const woInputCost = totalProjectTokens / 1e6 * spec.pricing.inputPerMillion;
|
|
2802
|
+
const woOutputCost = Math.round(totalProjectTokens * estimatedOutputRatio) / 1e6 * spec.pricing.outputPerMillion;
|
|
2803
|
+
const woTotalCost = woInputCost + woOutputCost;
|
|
2804
|
+
const tokensSaved = totalProjectTokens - inputTokens;
|
|
2805
|
+
const costSaved = woTotalCost - totalCost;
|
|
2806
|
+
const savingsPercent = woTotalCost > 0 ? costSaved / woTotalCost * 100 : 0;
|
|
2807
|
+
return {
|
|
2808
|
+
model: modelId,
|
|
2809
|
+
inputTokens,
|
|
2810
|
+
estimatedOutputTokens,
|
|
2811
|
+
inputCost: round(inputCost),
|
|
2812
|
+
outputCost: round(outputCost),
|
|
2813
|
+
totalCost: round(totalCost),
|
|
2814
|
+
formatted: formatCost(totalCost),
|
|
2815
|
+
withoutOptimization: {
|
|
2816
|
+
inputTokens: totalProjectTokens,
|
|
2817
|
+
totalCost: round(woTotalCost),
|
|
2818
|
+
formatted: formatCost(woTotalCost)
|
|
2819
|
+
},
|
|
2820
|
+
savings: {
|
|
2821
|
+
tokensSaved: Math.max(0, tokensSaved),
|
|
2822
|
+
costSaved: round(Math.max(0, costSaved)),
|
|
2823
|
+
percent: Math.max(0, Math.round(savingsPercent)),
|
|
2824
|
+
formatted: costSaved > 0 ? `saved ${formatCost(costSaved)} (${Math.round(savingsPercent)}%)` : "no savings"
|
|
2825
|
+
}
|
|
2826
|
+
};
|
|
2827
|
+
}
|
|
2828
|
+
function round(n) {
|
|
2829
|
+
return Math.round(n * 1e6) / 1e6;
|
|
2830
|
+
}
|
|
2831
|
+
function formatCost(cost) {
|
|
2832
|
+
if (cost < 1e-3) return "<$0.001";
|
|
2833
|
+
if (cost < 0.01) return `$${cost.toFixed(4)}`;
|
|
2834
|
+
if (cost < 1) return `$${cost.toFixed(3)}`;
|
|
2835
|
+
return `$${cost.toFixed(2)}`;
|
|
2836
|
+
}
|
|
2837
|
+
|
|
2838
|
+
// src/govern/audit.ts
|
|
2839
|
+
import { randomUUID, createHash as createHash4 } from "crypto";
|
|
2840
|
+
import { readdir as readdir3, chmod } from "fs/promises";
|
|
2841
|
+
import { join as join5 } from "path";
|
|
2842
|
+
import { userInfo } from "os";
|
|
2843
|
+
import { homedir } from "os";
|
|
2844
|
+
var CTO_DIR = ".cto-ai";
|
|
2845
|
+
var AUDIT_DIR = "audit";
|
|
2846
|
+
var MAX_ENTRIES_PER_FILE = 500;
|
|
2847
|
+
function getAuditDir() {
|
|
2848
|
+
return join5(homedir(), CTO_DIR, AUDIT_DIR);
|
|
2849
|
+
}
|
|
2850
|
+
function getCurrentAuditFile() {
|
|
2851
|
+
const date = (/* @__PURE__ */ new Date()).toISOString().split("T")[0].replace(/-/g, "");
|
|
2852
|
+
return join5(getAuditDir(), `audit_${date}.json`);
|
|
2853
|
+
}
|
|
2854
|
+
function computeIntegrityHash(entry) {
|
|
2855
|
+
const payload = JSON.stringify({
|
|
2856
|
+
id: entry.id,
|
|
2857
|
+
timestamp: entry.timestamp,
|
|
2858
|
+
action: entry.action,
|
|
2859
|
+
user: entry.user,
|
|
2860
|
+
projectPath: entry.projectPath,
|
|
2861
|
+
details: entry.details
|
|
2862
|
+
});
|
|
2863
|
+
return createHash4("sha256").update(payload).digest("hex");
|
|
2864
|
+
}
|
|
2865
|
+
async function ensureDir(dirPath) {
|
|
2866
|
+
const { mkdir } = await import("fs/promises");
|
|
2867
|
+
await mkdir(dirPath, { recursive: true });
|
|
2868
|
+
}
|
|
2869
|
+
async function readJSON(filePath) {
|
|
2870
|
+
const { readFile: readFile6 } = await import("fs/promises");
|
|
2871
|
+
try {
|
|
2872
|
+
const content = await readFile6(filePath, "utf-8");
|
|
2873
|
+
return JSON.parse(content);
|
|
2874
|
+
} catch {
|
|
2875
|
+
return null;
|
|
2876
|
+
}
|
|
2877
|
+
}
|
|
2878
|
+
async function writeJSON(filePath, data) {
|
|
2879
|
+
const { writeFile } = await import("fs/promises");
|
|
2880
|
+
await ensureDir(join5(filePath, ".."));
|
|
2881
|
+
await writeFile(filePath, JSON.stringify(data, null, 2), "utf-8");
|
|
2882
|
+
}
|
|
2883
|
+
async function logAudit(action, projectPath, details = {}) {
|
|
2884
|
+
const auditDir = getAuditDir();
|
|
2885
|
+
await ensureDir(auditDir);
|
|
2886
|
+
let currentUser;
|
|
2887
|
+
try {
|
|
2888
|
+
currentUser = userInfo().username;
|
|
2889
|
+
} catch {
|
|
2890
|
+
currentUser = process.env.USER ?? process.env.USERNAME ?? "unknown";
|
|
2891
|
+
}
|
|
2892
|
+
const partialEntry = {
|
|
2893
|
+
id: randomUUID().substring(0, 12),
|
|
2894
|
+
timestamp: /* @__PURE__ */ new Date(),
|
|
2895
|
+
action,
|
|
2896
|
+
user: currentUser,
|
|
2897
|
+
projectPath,
|
|
2898
|
+
details
|
|
2899
|
+
};
|
|
2900
|
+
const entry = {
|
|
2901
|
+
...partialEntry,
|
|
2902
|
+
integrityHash: computeIntegrityHash(partialEntry)
|
|
2903
|
+
};
|
|
2904
|
+
const auditFile = getCurrentAuditFile();
|
|
2905
|
+
let entries = await readJSON(auditFile) ?? [];
|
|
2906
|
+
entries.push(entry);
|
|
2907
|
+
if (entries.length > MAX_ENTRIES_PER_FILE) {
|
|
2908
|
+
entries = entries.slice(-MAX_ENTRIES_PER_FILE);
|
|
2909
|
+
}
|
|
2910
|
+
await writeJSON(auditFile, entries);
|
|
2911
|
+
try {
|
|
2912
|
+
await chmod(auditFile, 384);
|
|
2913
|
+
} catch {
|
|
2914
|
+
}
|
|
2915
|
+
return entry;
|
|
2916
|
+
}
|
|
2917
|
+
|
|
2918
|
+
// src/interact/orchestrator.ts
|
|
2919
|
+
async function planInteraction(input) {
|
|
2920
|
+
const {
|
|
2921
|
+
task,
|
|
2922
|
+
analysis,
|
|
2923
|
+
budget = 5e4,
|
|
2924
|
+
model: preferredModel,
|
|
2925
|
+
policies,
|
|
2926
|
+
depth = 2,
|
|
2927
|
+
enableCoT = true,
|
|
2928
|
+
enableConstraints = true,
|
|
2929
|
+
enableAntiHallucination = true
|
|
2930
|
+
} = input;
|
|
2931
|
+
const decisions = [];
|
|
2932
|
+
const taskType = classifyTask(task);
|
|
2933
|
+
decisions.push({
|
|
2934
|
+
step: "classify",
|
|
2935
|
+
decision: taskType,
|
|
2936
|
+
reason: `Task classified as "${taskType}" based on keyword analysis`,
|
|
2937
|
+
data: { task, taskType }
|
|
2938
|
+
});
|
|
2939
|
+
const context = await selectContext({
|
|
2940
|
+
task,
|
|
2941
|
+
analysis,
|
|
2942
|
+
budget,
|
|
2943
|
+
policies,
|
|
2944
|
+
depth
|
|
2945
|
+
});
|
|
2946
|
+
decisions.push({
|
|
2947
|
+
step: "select-context",
|
|
2948
|
+
decision: `${context.files.length} files selected (${context.totalTokens} tokens)`,
|
|
2949
|
+
reason: `Coverage: ${context.coverage.score}%, Risk: ${context.riskScore}/100`,
|
|
2950
|
+
data: {
|
|
2951
|
+
filesIncluded: context.files.length,
|
|
2952
|
+
tokensUsed: context.totalTokens,
|
|
2953
|
+
budget,
|
|
2954
|
+
coverage: context.coverage.score,
|
|
2955
|
+
risk: context.riskScore
|
|
2956
|
+
}
|
|
2957
|
+
});
|
|
2958
|
+
const modelChoice = routeModel(taskType, analysis, preferredModel);
|
|
2959
|
+
decisions.push({
|
|
2960
|
+
step: "choose-model",
|
|
2961
|
+
decision: modelChoice.model,
|
|
2962
|
+
reason: modelChoice.reason,
|
|
2963
|
+
data: {
|
|
2964
|
+
confidence: modelChoice.confidence,
|
|
2965
|
+
alternatives: modelChoice.alternatives.length
|
|
2966
|
+
}
|
|
2967
|
+
});
|
|
2968
|
+
const prompt = buildPrompt({
|
|
2969
|
+
task,
|
|
2970
|
+
taskType,
|
|
2971
|
+
analysis,
|
|
2972
|
+
selection: context,
|
|
2973
|
+
enableCoT,
|
|
2974
|
+
enableConstraints,
|
|
2975
|
+
enableAntiHallucination
|
|
2976
|
+
});
|
|
2977
|
+
decisions.push({
|
|
2978
|
+
step: "build-prompt",
|
|
2979
|
+
decision: `${prompt.sections.length} sections, ${prompt.totalTokens} tokens`,
|
|
2980
|
+
reason: `Sections: ${prompt.sections.map((s) => s.id).join(", ")}`
|
|
2981
|
+
});
|
|
2982
|
+
const cost = estimateCost(
|
|
2983
|
+
modelChoice.model,
|
|
2984
|
+
context.totalTokens + prompt.totalTokens,
|
|
2985
|
+
analysis.totalTokens
|
|
2986
|
+
);
|
|
2987
|
+
decisions.push({
|
|
2988
|
+
step: "estimate-cost",
|
|
2989
|
+
decision: cost.formatted,
|
|
2990
|
+
reason: cost.savings.formatted,
|
|
2991
|
+
data: {
|
|
2992
|
+
inputTokens: cost.inputTokens,
|
|
2993
|
+
totalCost: cost.totalCost,
|
|
2994
|
+
savings: cost.savings.percent
|
|
2995
|
+
}
|
|
2996
|
+
});
|
|
2997
|
+
const plan = {
|
|
2998
|
+
id: randomUUID2().substring(0, 8),
|
|
2999
|
+
task,
|
|
3000
|
+
taskType,
|
|
3001
|
+
timestamp: /* @__PURE__ */ new Date(),
|
|
3002
|
+
context,
|
|
3003
|
+
model: modelChoice,
|
|
3004
|
+
prompt,
|
|
3005
|
+
cost,
|
|
3006
|
+
decisions
|
|
3007
|
+
};
|
|
3008
|
+
try {
|
|
3009
|
+
await logAudit("interact", analysis.projectPath, {
|
|
3010
|
+
interactionId: plan.id,
|
|
3011
|
+
task,
|
|
3012
|
+
taskType,
|
|
3013
|
+
contextHash: context.hash,
|
|
3014
|
+
filesIncluded: context.files.length,
|
|
3015
|
+
filesExcluded: analysis.totalFiles - context.files.length,
|
|
3016
|
+
tokensUsed: context.totalTokens,
|
|
3017
|
+
coverageScore: context.coverage.score,
|
|
3018
|
+
riskScore: context.riskScore,
|
|
3019
|
+
model: modelChoice.model,
|
|
3020
|
+
estimatedCost: cost.totalCost
|
|
3021
|
+
});
|
|
3022
|
+
} catch {
|
|
3023
|
+
}
|
|
3024
|
+
return plan;
|
|
3025
|
+
}
|
|
3026
|
+
|
|
3027
|
+
// src/api/server.ts
|
|
3028
|
+
var API_VERSION = "1.0.0";
|
|
3029
|
+
var DEFAULT_PORT = 3141;
|
|
3030
|
+
function validateApiKey(req) {
|
|
3031
|
+
const apiKey = process.env.CTO_API_KEY;
|
|
3032
|
+
if (!apiKey) return true;
|
|
3033
|
+
const authRaw = req.headers["authorization"] ?? req.headers["x-api-key"];
|
|
3034
|
+
if (!authRaw) return false;
|
|
3035
|
+
const auth = Array.isArray(authRaw) ? authRaw[0] : authRaw;
|
|
3036
|
+
const token = auth.startsWith("Bearer ") ? auth.slice(7) : auth;
|
|
3037
|
+
return token === apiKey;
|
|
3038
|
+
}
|
|
3039
|
+
var rateLimitWindow = 6e4;
|
|
3040
|
+
var rateLimitMax = parseInt(process.env.CTO_RATE_LIMIT ?? "60", 10);
|
|
3041
|
+
var requestCounts = /* @__PURE__ */ new Map();
|
|
3042
|
+
function checkRateLimit(ip) {
|
|
3043
|
+
const now = Date.now();
|
|
3044
|
+
const entry = requestCounts.get(ip);
|
|
3045
|
+
if (!entry || now > entry.reset) {
|
|
3046
|
+
requestCounts.set(ip, { count: 1, reset: now + rateLimitWindow });
|
|
3047
|
+
return true;
|
|
3048
|
+
}
|
|
3049
|
+
if (entry.count >= rateLimitMax) return false;
|
|
3050
|
+
entry.count++;
|
|
3051
|
+
return true;
|
|
3052
|
+
}
|
|
3053
|
+
function getIP(req) {
|
|
3054
|
+
return req.headers["x-forwarded-for"]?.split(",")[0]?.trim() ?? req.socket.remoteAddress ?? "unknown";
|
|
3055
|
+
}
|
|
3056
|
+
async function readBody(req) {
|
|
3057
|
+
return new Promise((resolve8, reject) => {
|
|
3058
|
+
const chunks = [];
|
|
3059
|
+
req.on("data", (chunk) => chunks.push(chunk));
|
|
3060
|
+
req.on("end", () => {
|
|
3061
|
+
try {
|
|
3062
|
+
const body = Buffer.concat(chunks).toString("utf-8");
|
|
3063
|
+
resolve8(body ? JSON.parse(body) : {});
|
|
3064
|
+
} catch {
|
|
3065
|
+
reject(new Error("Invalid JSON body"));
|
|
3066
|
+
}
|
|
3067
|
+
});
|
|
3068
|
+
req.on("error", reject);
|
|
3069
|
+
});
|
|
3070
|
+
}
|
|
3071
|
+
function json(res, status, data) {
|
|
3072
|
+
res.writeHead(status, {
|
|
3073
|
+
"Content-Type": "application/json",
|
|
3074
|
+
"Access-Control-Allow-Origin": "*",
|
|
3075
|
+
"Access-Control-Allow-Headers": "Content-Type, Authorization, X-Api-Key",
|
|
3076
|
+
"Access-Control-Allow-Methods": "GET, POST, OPTIONS",
|
|
3077
|
+
"X-CTO-Version": API_VERSION
|
|
3078
|
+
});
|
|
3079
|
+
res.end(JSON.stringify(data));
|
|
3080
|
+
}
|
|
3081
|
+
function error(res, status, message) {
|
|
3082
|
+
json(res, status, { error: message, status });
|
|
3083
|
+
}
|
|
3084
|
+
async function handleAnalyze(body, res) {
|
|
3085
|
+
const { path: projectPath } = body;
|
|
3086
|
+
if (!projectPath) return error(res, 400, "Missing required field: path");
|
|
3087
|
+
const absPath = resolve7(projectPath);
|
|
3088
|
+
const analysis = await getCachedAnalysis(absPath);
|
|
3089
|
+
json(res, 200, {
|
|
3090
|
+
project: analysis.projectName,
|
|
3091
|
+
files: analysis.totalFiles,
|
|
3092
|
+
tokens: analysis.totalTokens,
|
|
3093
|
+
stack: analysis.stack,
|
|
3094
|
+
risk: analysis.riskProfile.distribution,
|
|
3095
|
+
graph: {
|
|
3096
|
+
edges: analysis.graph.edges.length,
|
|
3097
|
+
hubs: analysis.graph.hubs.length,
|
|
3098
|
+
clusters: analysis.graph.clusters.length,
|
|
3099
|
+
orphans: analysis.graph.orphans.length
|
|
3100
|
+
},
|
|
3101
|
+
analyzedAt: analysis.analyzedAt
|
|
3102
|
+
});
|
|
3103
|
+
}
|
|
3104
|
+
async function handleSelect(body, res) {
|
|
3105
|
+
const { path: projectPath, task, budget } = body;
|
|
3106
|
+
if (!projectPath) return error(res, 400, "Missing required field: path");
|
|
3107
|
+
if (!task) return error(res, 400, "Missing required field: task");
|
|
3108
|
+
const absPath = resolve7(projectPath);
|
|
3109
|
+
const analysis = await getCachedAnalysis(absPath);
|
|
3110
|
+
const selection = await selectContext({
|
|
3111
|
+
task,
|
|
3112
|
+
analysis,
|
|
3113
|
+
budget: budget ?? 5e4
|
|
3114
|
+
});
|
|
3115
|
+
json(res, 200, {
|
|
3116
|
+
files: selection.files.map((f) => ({
|
|
3117
|
+
path: f.relativePath,
|
|
3118
|
+
tokens: f.tokens,
|
|
3119
|
+
pruneLevel: f.pruneLevel,
|
|
3120
|
+
riskScore: f.riskScore,
|
|
3121
|
+
reason: f.reason
|
|
3122
|
+
})),
|
|
3123
|
+
totalTokens: selection.totalTokens,
|
|
3124
|
+
budget: selection.budget,
|
|
3125
|
+
usedPercent: selection.usedPercent,
|
|
3126
|
+
coverage: selection.coverage.score,
|
|
3127
|
+
riskScore: selection.riskScore,
|
|
3128
|
+
hash: selection.hash
|
|
3129
|
+
});
|
|
3130
|
+
}
|
|
3131
|
+
async function handleScore(body, res) {
|
|
3132
|
+
const { path: projectPath, task, budget } = body;
|
|
3133
|
+
if (!projectPath) return error(res, 400, "Missing required field: path");
|
|
3134
|
+
const absPath = resolve7(projectPath);
|
|
3135
|
+
const analysis = await getCachedAnalysis(absPath);
|
|
3136
|
+
const score = await computeContextScore(
|
|
3137
|
+
analysis,
|
|
3138
|
+
task ?? "general code review and refactoring",
|
|
3139
|
+
budget ?? 5e4
|
|
3140
|
+
);
|
|
3141
|
+
json(res, 200, {
|
|
3142
|
+
overall: score.overall,
|
|
3143
|
+
grade: score.grade,
|
|
3144
|
+
dimensions: {
|
|
3145
|
+
efficiency: score.dimensions.efficiency.score,
|
|
3146
|
+
coverage: score.dimensions.coverage.score,
|
|
3147
|
+
riskControl: score.dimensions.riskControl.score,
|
|
3148
|
+
structure: score.dimensions.structure.score,
|
|
3149
|
+
governance: score.dimensions.governance.score
|
|
3150
|
+
},
|
|
3151
|
+
comparison: score.comparison,
|
|
3152
|
+
insights: score.insights,
|
|
3153
|
+
meta: score.meta
|
|
3154
|
+
});
|
|
3155
|
+
}
|
|
3156
|
+
async function handleBenchmark(body, res) {
|
|
3157
|
+
const { path: projectPath, task, budget } = body;
|
|
3158
|
+
if (!projectPath) return error(res, 400, "Missing required field: path");
|
|
3159
|
+
const absPath = resolve7(projectPath);
|
|
3160
|
+
const analysis = await getCachedAnalysis(absPath);
|
|
3161
|
+
const result = await runBenchmark(
|
|
3162
|
+
analysis,
|
|
3163
|
+
task ?? "general code review and refactoring",
|
|
3164
|
+
budget ?? 5e4
|
|
3165
|
+
);
|
|
3166
|
+
json(res, 200, result);
|
|
3167
|
+
}
|
|
3168
|
+
async function handleQuality(body, res) {
|
|
3169
|
+
const { path: projectPath, task, budget } = body;
|
|
3170
|
+
if (!projectPath) return error(res, 400, "Missing required field: path");
|
|
3171
|
+
if (!task) return error(res, 400, "Missing required field: task");
|
|
3172
|
+
const absPath = resolve7(projectPath);
|
|
3173
|
+
const analysis = await getCachedAnalysis(absPath);
|
|
3174
|
+
const result = await runQualityBenchmark(analysis, task, budget ?? 5e4);
|
|
3175
|
+
json(res, 200, {
|
|
3176
|
+
strategies: result.strategies,
|
|
3177
|
+
comparison: result.comparison,
|
|
3178
|
+
verdict: result.verdict,
|
|
3179
|
+
promptTokens: {
|
|
3180
|
+
cto: result.prompts.cto.tokens,
|
|
3181
|
+
naive: result.prompts.naive.tokens
|
|
3182
|
+
}
|
|
3183
|
+
});
|
|
3184
|
+
}
|
|
3185
|
+
async function handlePRContext(body, res) {
|
|
3186
|
+
const { path: projectPath, baseBranch, depth, includeTests } = body;
|
|
3187
|
+
if (!projectPath) return error(res, 400, "Missing required field: path");
|
|
3188
|
+
const absPath = resolve7(projectPath);
|
|
3189
|
+
const analysis = await getCachedAnalysis(absPath);
|
|
3190
|
+
const pr = await generatePRContext(analysis, {
|
|
3191
|
+
baseBranch: baseBranch ?? "main",
|
|
3192
|
+
depth: depth ?? 2,
|
|
3193
|
+
includeTests: includeTests ?? false
|
|
3194
|
+
});
|
|
3195
|
+
json(res, 200, {
|
|
3196
|
+
isGitRepo: pr.isGitRepo,
|
|
3197
|
+
baseBranch: pr.baseBranch,
|
|
3198
|
+
currentBranch: pr.currentBranch,
|
|
3199
|
+
changedFiles: pr.changedFiles,
|
|
3200
|
+
dependencyFiles: pr.dependencyFiles,
|
|
3201
|
+
totalChangedTokens: pr.totalChangedTokens,
|
|
3202
|
+
totalContextTokens: pr.totalContextTokens,
|
|
3203
|
+
riskSummary: pr.riskSummary
|
|
3204
|
+
});
|
|
3205
|
+
}
|
|
3206
|
+
async function handleInteract(body, res) {
|
|
3207
|
+
const { path: projectPath, task, budget, model } = body;
|
|
3208
|
+
if (!projectPath) return error(res, 400, "Missing required field: path");
|
|
3209
|
+
if (!task) return error(res, 400, "Missing required field: task");
|
|
3210
|
+
const absPath = resolve7(projectPath);
|
|
3211
|
+
const analysis = await getCachedAnalysis(absPath);
|
|
3212
|
+
const plan = await planInteraction({
|
|
3213
|
+
task,
|
|
3214
|
+
analysis,
|
|
3215
|
+
budget: budget ?? 5e4,
|
|
3216
|
+
model
|
|
3217
|
+
});
|
|
3218
|
+
json(res, 200, {
|
|
3219
|
+
id: plan.id,
|
|
3220
|
+
task: plan.task,
|
|
3221
|
+
model: plan.model,
|
|
3222
|
+
context: {
|
|
3223
|
+
files: plan.context.files.length,
|
|
3224
|
+
totalTokens: plan.context.totalTokens,
|
|
3225
|
+
coverage: plan.context.coverage.score,
|
|
3226
|
+
riskScore: plan.context.riskScore
|
|
3227
|
+
},
|
|
3228
|
+
prompt: {
|
|
3229
|
+
tokens: plan.prompt.totalTokens,
|
|
3230
|
+
sections: plan.prompt.sections.map((s) => s.id),
|
|
3231
|
+
rendered: plan.prompt.rendered
|
|
3232
|
+
},
|
|
3233
|
+
cost: plan.cost,
|
|
3234
|
+
decisions: plan.decisions
|
|
3235
|
+
});
|
|
3236
|
+
}
|
|
3237
|
+
function handleHealth(_body, res) {
|
|
3238
|
+
json(res, 200, {
|
|
3239
|
+
status: "ok",
|
|
3240
|
+
version: API_VERSION,
|
|
3241
|
+
uptime: process.uptime(),
|
|
3242
|
+
rateLimit: {
|
|
3243
|
+
max: rateLimitMax,
|
|
3244
|
+
windowMs: rateLimitWindow
|
|
3245
|
+
}
|
|
3246
|
+
});
|
|
3247
|
+
}
|
|
3248
|
+
function handleOpenAPI(_body, res) {
|
|
3249
|
+
json(res, 200, {
|
|
3250
|
+
openapi: "3.1.0",
|
|
3251
|
+
info: {
|
|
3252
|
+
title: "CTO Context-as-a-Service API",
|
|
3253
|
+
version: API_VERSION,
|
|
3254
|
+
description: "AI context optimization API. Analyzes codebases, scores context quality, selects optimal files."
|
|
3255
|
+
},
|
|
3256
|
+
servers: [{ url: `http://localhost:${process.env.PORT ?? DEFAULT_PORT}` }],
|
|
3257
|
+
security: [{ apiKey: [] }],
|
|
3258
|
+
components: {
|
|
3259
|
+
securitySchemes: {
|
|
3260
|
+
apiKey: {
|
|
3261
|
+
type: "apiKey",
|
|
3262
|
+
in: "header",
|
|
3263
|
+
name: "Authorization",
|
|
3264
|
+
description: "Bearer token. Set CTO_API_KEY env var on server."
|
|
3265
|
+
}
|
|
3266
|
+
}
|
|
3267
|
+
},
|
|
3268
|
+
paths: {
|
|
3269
|
+
"/v1/analyze": {
|
|
3270
|
+
post: {
|
|
3271
|
+
summary: "Analyze a project",
|
|
3272
|
+
requestBody: { content: { "application/json": { schema: { type: "object", required: ["path"], properties: { path: { type: "string", description: "Absolute path to project" } } } } } },
|
|
3273
|
+
responses: { "200": { description: "Project analysis summary" } }
|
|
3274
|
+
}
|
|
3275
|
+
},
|
|
3276
|
+
"/v1/select": {
|
|
3277
|
+
post: {
|
|
3278
|
+
summary: "Select optimal context for a task",
|
|
3279
|
+
requestBody: { content: { "application/json": { schema: { type: "object", required: ["path", "task"], properties: { path: { type: "string" }, task: { type: "string" }, budget: { type: "number", default: 5e4 } } } } } },
|
|
3280
|
+
responses: { "200": { description: "Selected files with tokens, risk, coverage" } }
|
|
3281
|
+
}
|
|
3282
|
+
},
|
|
3283
|
+
"/v1/score": {
|
|
3284
|
+
post: {
|
|
3285
|
+
summary: "Get Context Score\u2122 (0-100)",
|
|
3286
|
+
requestBody: { content: { "application/json": { schema: { type: "object", required: ["path"], properties: { path: { type: "string" }, task: { type: "string" }, budget: { type: "number" } } } } } },
|
|
3287
|
+
responses: { "200": { description: "Context Score with dimensions and savings" } }
|
|
3288
|
+
}
|
|
3289
|
+
},
|
|
3290
|
+
"/v1/benchmark": {
|
|
3291
|
+
post: {
|
|
3292
|
+
summary: "Run CTO vs naive vs random benchmark",
|
|
3293
|
+
requestBody: { content: { "application/json": { schema: { type: "object", required: ["path"], properties: { path: { type: "string" }, task: { type: "string" }, budget: { type: "number" } } } } } },
|
|
3294
|
+
responses: { "200": { description: "Three-way benchmark comparison" } }
|
|
3295
|
+
}
|
|
3296
|
+
},
|
|
3297
|
+
"/v1/quality": {
|
|
3298
|
+
post: {
|
|
3299
|
+
summary: "Quality benchmark \u2014 relevance, completeness, noise",
|
|
3300
|
+
requestBody: { content: { "application/json": { schema: { type: "object", required: ["path", "task"], properties: { path: { type: "string" }, task: { type: "string" }, budget: { type: "number" } } } } } },
|
|
3301
|
+
responses: { "200": { description: "Quality metrics for CTO vs naive vs random" } }
|
|
3302
|
+
}
|
|
3303
|
+
},
|
|
3304
|
+
"/v1/pr-context": {
|
|
3305
|
+
post: {
|
|
3306
|
+
summary: "PR-focused context from git diff",
|
|
3307
|
+
requestBody: { content: { "application/json": { schema: { type: "object", required: ["path"], properties: { path: { type: "string" }, baseBranch: { type: "string", default: "main" }, depth: { type: "number", default: 2 } } } } } },
|
|
3308
|
+
responses: { "200": { description: "Changed files, dependencies, risk summary" } }
|
|
3309
|
+
}
|
|
3310
|
+
},
|
|
3311
|
+
"/v1/interact": {
|
|
3312
|
+
post: {
|
|
3313
|
+
summary: "Full pipeline: analyze \u2192 select \u2192 prompt \u2192 cost",
|
|
3314
|
+
requestBody: { content: { "application/json": { schema: { type: "object", required: ["path", "task"], properties: { path: { type: "string" }, task: { type: "string" }, budget: { type: "number" }, model: { type: "string" } } } } } },
|
|
3315
|
+
responses: { "200": { description: "Full interaction plan with prompt and cost" } }
|
|
3316
|
+
}
|
|
3317
|
+
},
|
|
3318
|
+
"/v1/health": {
|
|
3319
|
+
get: {
|
|
3320
|
+
summary: "Health check",
|
|
3321
|
+
responses: { "200": { description: "Server status" } }
|
|
3322
|
+
}
|
|
3323
|
+
}
|
|
3324
|
+
}
|
|
3325
|
+
});
|
|
3326
|
+
}
|
|
3327
|
+
var routes = {
|
|
3328
|
+
"POST /v1/analyze": handleAnalyze,
|
|
3329
|
+
"POST /v1/select": handleSelect,
|
|
3330
|
+
"POST /v1/score": handleScore,
|
|
3331
|
+
"POST /v1/benchmark": handleBenchmark,
|
|
3332
|
+
"POST /v1/quality": handleQuality,
|
|
3333
|
+
"POST /v1/pr-context": handlePRContext,
|
|
3334
|
+
"POST /v1/interact": handleInteract,
|
|
3335
|
+
"GET /v1/health": handleHealth,
|
|
3336
|
+
"GET /v1/openapi": handleOpenAPI,
|
|
3337
|
+
"GET /v1/openapi.json": handleOpenAPI
|
|
3338
|
+
};
|
|
3339
|
+
function createAPIServer() {
|
|
3340
|
+
const server2 = createServer(async (req, res) => {
|
|
3341
|
+
if (req.method === "OPTIONS") {
|
|
3342
|
+
res.writeHead(204, {
|
|
3343
|
+
"Access-Control-Allow-Origin": "*",
|
|
3344
|
+
"Access-Control-Allow-Headers": "Content-Type, Authorization, X-Api-Key",
|
|
3345
|
+
"Access-Control-Allow-Methods": "GET, POST, OPTIONS"
|
|
3346
|
+
});
|
|
3347
|
+
return res.end();
|
|
3348
|
+
}
|
|
3349
|
+
const ip = getIP(req);
|
|
3350
|
+
const url = new URL(req.url ?? "/", `http://${req.headers.host ?? "localhost"}`);
|
|
3351
|
+
const routeKey = `${req.method} ${url.pathname}`;
|
|
3352
|
+
if (!checkRateLimit(ip)) {
|
|
3353
|
+
return error(res, 429, "Rate limit exceeded. Try again later.");
|
|
3354
|
+
}
|
|
3355
|
+
if (!validateApiKey(req)) {
|
|
3356
|
+
return error(res, 401, "Unauthorized. Provide a valid API key via Authorization header.");
|
|
3357
|
+
}
|
|
3358
|
+
const handler = routes[routeKey];
|
|
3359
|
+
if (!handler) {
|
|
3360
|
+
return error(res, 404, `Not found: ${routeKey}. GET /v1/openapi for docs.`);
|
|
3361
|
+
}
|
|
3362
|
+
try {
|
|
3363
|
+
const body = req.method === "GET" ? {} : await readBody(req);
|
|
3364
|
+
await handler(body, res);
|
|
3365
|
+
} catch (err) {
|
|
3366
|
+
console.error(`[CTO API] Error on ${routeKey}:`, err.message);
|
|
3367
|
+
error(res, 500, err.message ?? "Internal server error");
|
|
3368
|
+
}
|
|
3369
|
+
});
|
|
3370
|
+
return server2;
|
|
3371
|
+
}
|
|
3372
|
+
var port = parseInt(process.env.PORT ?? `${DEFAULT_PORT}`, 10);
|
|
3373
|
+
var server = createAPIServer();
|
|
3374
|
+
server.listen(port, () => {
|
|
3375
|
+
const keyStatus = process.env.CTO_API_KEY ? "\u{1F512} API key required" : "\u{1F513} Open (set CTO_API_KEY to secure)";
|
|
3376
|
+
console.log(`
|
|
3377
|
+
\u2554\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2557
|
|
3378
|
+
\u2551 \u2551
|
|
3379
|
+
\u2551 \u26A1 CTO Context-as-a-Service API v${API_VERSION} \u2551
|
|
3380
|
+
\u2551 \u2551
|
|
3381
|
+
\u2551 http://localhost:${port.toString().padEnd(25)}\u2551
|
|
3382
|
+
\u2551 ${keyStatus.padEnd(42)} \u2551
|
|
3383
|
+
\u2551 Rate limit: ${rateLimitMax} req/min${"".padEnd(20)} \u2551
|
|
3384
|
+
\u2551 \u2551
|
|
3385
|
+
\u2551 GET /v1/health \u2014 Status \u2551
|
|
3386
|
+
\u2551 GET /v1/openapi \u2014 API docs \u2551
|
|
3387
|
+
\u2551 POST /v1/analyze \u2014 Analyze project \u2551
|
|
3388
|
+
\u2551 POST /v1/select \u2014 Select context \u2551
|
|
3389
|
+
\u2551 POST /v1/score \u2014 Context Score\u2122 \u2551
|
|
3390
|
+
\u2551 POST /v1/benchmark \u2014 CTO vs naive \u2551
|
|
3391
|
+
\u2551 POST /v1/quality \u2014 Quality benchmark \u2551
|
|
3392
|
+
\u2551 POST /v1/pr-context \u2014 PR context \u2551
|
|
3393
|
+
\u2551 POST /v1/interact \u2014 Full pipeline \u2551
|
|
3394
|
+
\u2551 \u2551
|
|
3395
|
+
\u255A\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u255D
|
|
3396
|
+
`);
|
|
3397
|
+
});
|
|
3398
|
+
export {
|
|
3399
|
+
createAPIServer
|
|
3400
|
+
};
|
|
3401
|
+
//# sourceMappingURL=server.js.map
|