@papyruslabsai/seshat-mcp 0.20.0 → 0.20.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +87 -0
- package/package.json +41 -48
- package/dist/bootstrap.d.ts +0 -29
- package/dist/bootstrap.js +0 -190
- package/dist/graph.d.ts +0 -42
- package/dist/graph.js +0 -272
- package/dist/index.d.ts +0 -9
- package/dist/loader.d.ts +0 -62
- package/dist/loader.js +0 -264
- package/dist/supabase.d.ts +0 -99
- package/dist/supabase.js +0 -132
- package/dist/tools/dbops.d.ts +0 -24
- package/dist/tools/dbops.js +0 -78
- package/dist/tools/diff.d.ts +0 -28
- package/dist/tools/diff.js +0 -604
- package/dist/tools/functors.d.ts +0 -150
- package/dist/tools/functors.js +0 -1727
- package/dist/tools/index.d.ts +0 -66
- package/dist/tools/index.js +0 -389
- package/dist/types.d.ts +0 -174
- package/dist/types.js +0 -8
package/dist/tools/functors.js
DELETED
|
@@ -1,1727 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Analysis Tools
|
|
3
|
-
*
|
|
4
|
-
* Composite analyses built from the extracted code entity graph.
|
|
5
|
-
* Each tool computes derived insights (dead code, layer violations,
|
|
6
|
-
* coupling metrics, auth coverage, etc.) from the entity data.
|
|
7
|
-
*/
|
|
8
|
-
import path from 'path';
|
|
9
|
-
import { computeBlastRadius } from '../graph.js';
|
|
10
|
-
import { getGraph, validateProject, entityName, entityLayer, entitySummary, normalizeConstraints, } from './index.js';
|
|
11
|
-
import { isSupabaseConfigured, insertPrediction, updateActualBurn, abandonPrediction, } from '../supabase.js';
|
|
12
|
-
import { extractDbOps } from './dbops.js';
|
|
13
|
-
// ─── Layer ordering for violation detection ──────────────────────
|
|
14
|
-
const LAYER_ORDER = {
|
|
15
|
-
route: 0,
|
|
16
|
-
controller: 1,
|
|
17
|
-
middleware: 2,
|
|
18
|
-
service: 3,
|
|
19
|
-
hook: 4,
|
|
20
|
-
repository: 5,
|
|
21
|
-
model: 6,
|
|
22
|
-
schema: 7,
|
|
23
|
-
utility: 8,
|
|
24
|
-
component: 1, // UI components are peers to controllers
|
|
25
|
-
};
|
|
26
|
-
// ─── Shared: Token estimation ────────────────────────────────────
|
|
27
|
-
/**
|
|
28
|
-
* Estimate the token cost of loading an entity's source code into an LLM context.
|
|
29
|
-
* Uses real sourceTokens from the extraction pipeline when available (v0.3.2+),
|
|
30
|
-
* falls back to heuristic estimation for older bundles.
|
|
31
|
-
*/
|
|
32
|
-
export function estimateTokens(e) {
|
|
33
|
-
// Use real source token count from extraction pipeline
|
|
34
|
-
const raw = e;
|
|
35
|
-
const st = raw.sourceTokens;
|
|
36
|
-
if (st?.estimated)
|
|
37
|
-
return st.estimated;
|
|
38
|
-
// Fallback: heuristic estimation (pre-v0.3.2 bundles)
|
|
39
|
-
let tokens = 50; // Base: name, id, layer
|
|
40
|
-
if (e.struct && typeof e.struct !== 'string') {
|
|
41
|
-
tokens += 20; // signature
|
|
42
|
-
tokens += (e.struct.params?.length || 0) * 10;
|
|
43
|
-
}
|
|
44
|
-
if (e.edges?.calls)
|
|
45
|
-
tokens += e.edges.calls.length * 8;
|
|
46
|
-
if (e.edges?.imports)
|
|
47
|
-
tokens += e.edges.imports.length * 6;
|
|
48
|
-
if (e.data?.inputs)
|
|
49
|
-
tokens += e.data.inputs.length * 10;
|
|
50
|
-
if (e.constraints && typeof e.constraints === 'object' && !Array.isArray(e.constraints)) {
|
|
51
|
-
tokens += 30;
|
|
52
|
-
}
|
|
53
|
-
return tokens;
|
|
54
|
-
}
|
|
55
|
-
/**
|
|
56
|
-
* Detect which estimator was used for an entity.
|
|
57
|
-
* Returns 'syntactic' if tree-sitter leaf node count was used,
|
|
58
|
-
* 'charDiv4' if chars/4 heuristic was used, 'heuristic' for legacy fallback.
|
|
59
|
-
*/
|
|
60
|
-
function detectEstimator(e) {
|
|
61
|
-
const raw = e;
|
|
62
|
-
const st = raw.sourceTokens;
|
|
63
|
-
if (!st?.estimated)
|
|
64
|
-
return 'heuristic';
|
|
65
|
-
if (st.syntactic && st.estimated === st.syntactic)
|
|
66
|
-
return 'syntactic';
|
|
67
|
-
if (st.charDiv4 && st.estimated === st.charDiv4)
|
|
68
|
-
return 'charDiv4';
|
|
69
|
-
return 'charDiv4'; // estimated present but can't distinguish → default to charDiv4
|
|
70
|
-
}
|
|
71
|
-
/**
|
|
72
|
-
* Determine the dominant estimator across a set of entities.
|
|
73
|
-
*/
|
|
74
|
-
function dominantEstimator(entities) {
|
|
75
|
-
const counts = { syntactic: 0, charDiv4: 0, heuristic: 0 };
|
|
76
|
-
for (const e of entities) {
|
|
77
|
-
counts[detectEstimator(e)]++;
|
|
78
|
-
}
|
|
79
|
-
if (counts.syntactic >= counts.charDiv4 && counts.syntactic >= counts.heuristic)
|
|
80
|
-
return 'syntactic';
|
|
81
|
-
if (counts.charDiv4 >= counts.heuristic)
|
|
82
|
-
return 'charDiv4';
|
|
83
|
-
return 'heuristic';
|
|
84
|
-
}
|
|
85
|
-
// ─── Tool: find_dead_code ────────────────────────────────────────
|
|
86
|
-
export function findDeadCode(args, loader) {
|
|
87
|
-
const projErr = validateProject(args.project, loader);
|
|
88
|
-
if (projErr)
|
|
89
|
-
return { error: projErr };
|
|
90
|
-
const { include_tests = false } = args;
|
|
91
|
-
const g = getGraph(args.project, loader);
|
|
92
|
-
const entities = loader.getEntities(args.project);
|
|
93
|
-
// Entry points: routes, exported functions, test files, plugin registrations
|
|
94
|
-
const entryPointIds = new Set();
|
|
95
|
-
for (const e of entities) {
|
|
96
|
-
if (!e.id)
|
|
97
|
-
continue;
|
|
98
|
-
const layer = entityLayer(e);
|
|
99
|
-
// Routes and controllers are entry points
|
|
100
|
-
if (layer === 'route' || layer === 'controller') {
|
|
101
|
-
entryPointIds.add(e.id);
|
|
102
|
-
continue;
|
|
103
|
-
}
|
|
104
|
-
// Test entities are entry points
|
|
105
|
-
if (layer === 'test') {
|
|
106
|
-
entryPointIds.add(e.id);
|
|
107
|
-
continue;
|
|
108
|
-
}
|
|
109
|
-
// Plugin registrations
|
|
110
|
-
if (e.context?.exposure === 'framework') {
|
|
111
|
-
entryPointIds.add(e.id);
|
|
112
|
-
continue;
|
|
113
|
-
}
|
|
114
|
-
// Exported functions at the top level are entry points
|
|
115
|
-
if (typeof e.struct !== 'string' && e.struct?.exported) {
|
|
116
|
-
entryPointIds.add(e.id);
|
|
117
|
-
}
|
|
118
|
-
}
|
|
119
|
-
// BFS from all entry points through callees
|
|
120
|
-
const reachable = new Set(entryPointIds);
|
|
121
|
-
const queue = [...entryPointIds];
|
|
122
|
-
while (queue.length > 0) {
|
|
123
|
-
const current = queue.shift();
|
|
124
|
-
const calleeSet = g.callees.get(current);
|
|
125
|
-
if (!calleeSet)
|
|
126
|
-
continue;
|
|
127
|
-
for (const calleeId of calleeSet) {
|
|
128
|
-
if (!reachable.has(calleeId)) {
|
|
129
|
-
reachable.add(calleeId);
|
|
130
|
-
queue.push(calleeId);
|
|
131
|
-
}
|
|
132
|
-
}
|
|
133
|
-
}
|
|
134
|
-
// Unreachable = dead code candidates
|
|
135
|
-
let deadEntities = entities.filter((e) => e.id && !reachable.has(e.id));
|
|
136
|
-
if (!include_tests) {
|
|
137
|
-
deadEntities = deadEntities.filter((e) => entityLayer(e) !== 'test');
|
|
138
|
-
}
|
|
139
|
-
// Separate schemas — they're often registered dynamically (e.g., fastify.addSchema())
|
|
140
|
-
// and appear "dead" from a static call graph but are alive at runtime.
|
|
141
|
-
const SCHEMA_TYPES = new Set(['schema', 'typealias', 'interface', 'type']);
|
|
142
|
-
const schemaEntities = deadEntities.filter((e) => {
|
|
143
|
-
const type = (typeof e.struct === 'string' ? e.struct : e.struct?.type || '').toLowerCase();
|
|
144
|
-
const layer = entityLayer(e);
|
|
145
|
-
return SCHEMA_TYPES.has(type) || layer === 'schema';
|
|
146
|
-
});
|
|
147
|
-
deadEntities = deadEntities.filter((e) => !schemaEntities.includes(e));
|
|
148
|
-
// Group by layer for overview
|
|
149
|
-
const byLayer = new Map();
|
|
150
|
-
for (const e of deadEntities) {
|
|
151
|
-
const l = entityLayer(e);
|
|
152
|
-
byLayer.set(l, (byLayer.get(l) || 0) + 1);
|
|
153
|
-
}
|
|
154
|
-
return {
|
|
155
|
-
totalEntities: entities.length,
|
|
156
|
-
entryPoints: entryPointIds.size,
|
|
157
|
-
reachable: reachable.size,
|
|
158
|
-
deadCount: deadEntities.length,
|
|
159
|
-
deadByLayer: Object.fromEntries([...byLayer.entries()].sort((a, b) => b[1] - a[1])),
|
|
160
|
-
dead: deadEntities.slice(0, 100).map(entitySummary),
|
|
161
|
-
...(schemaEntities.length > 0 ? {
|
|
162
|
-
schemas_excluded: schemaEntities.length,
|
|
163
|
-
_note_schemas: `${schemaEntities.length} schema/type definitions excluded — no static references found, but schemas are often registered dynamically (e.g., fastify.addSchema()) and may be alive at runtime.`,
|
|
164
|
-
} : {}),
|
|
165
|
-
};
|
|
166
|
-
}
|
|
167
|
-
// ─── Tool: find_entry_points (B6) ────────────────────────────────
|
|
168
|
-
/**
|
|
169
|
-
* The ways into the system — the reachability roots find_dead_code already
|
|
170
|
-
* computes, surfaced as a tool and classified by KIND. An entry point is a
|
|
171
|
-
* route/controller, a test, a framework plugin registration, or an exported
|
|
172
|
-
* symbol (the library's public API surface). entryPointCount here reconciles
|
|
173
|
-
* with find_dead_code's `entryPoints` (same seed definition).
|
|
174
|
-
*/
|
|
175
|
-
export function findEntryPoints(args, loader) {
|
|
176
|
-
const projErr = validateProject(args.project, loader);
|
|
177
|
-
if (projErr)
|
|
178
|
-
return { error: projErr };
|
|
179
|
-
const g = getGraph(args.project, loader);
|
|
180
|
-
const entities = loader.getEntities(args.project);
|
|
181
|
-
// route/controller first (runtime entries), then plugins, tests, exports.
|
|
182
|
-
const KIND_RANK = { route: 0, plugin: 1, test: 2, export: 3 };
|
|
183
|
-
const entryPoints = [];
|
|
184
|
-
for (const e of entities) {
|
|
185
|
-
if (!e.id)
|
|
186
|
-
continue;
|
|
187
|
-
const layer = entityLayer(e);
|
|
188
|
-
let kind = null;
|
|
189
|
-
if (layer === 'route' || layer === 'controller')
|
|
190
|
-
kind = 'route';
|
|
191
|
-
else if (layer === 'test')
|
|
192
|
-
kind = 'test';
|
|
193
|
-
else if (e.context?.exposure === 'framework')
|
|
194
|
-
kind = 'plugin';
|
|
195
|
-
else if (typeof e.struct !== 'string' && e.struct?.exported)
|
|
196
|
-
kind = 'export';
|
|
197
|
-
if (!kind)
|
|
198
|
-
continue;
|
|
199
|
-
entryPoints.push({ ...entitySummary(e), kind, directCallees: g.callees.get(e.id)?.size || 0 });
|
|
200
|
-
}
|
|
201
|
-
const byKind = {};
|
|
202
|
-
for (const ep of entryPoints)
|
|
203
|
-
byKind[ep.kind] = (byKind[ep.kind] || 0) + 1;
|
|
204
|
-
entryPoints.sort((a, b) => (KIND_RANK[a.kind] - KIND_RANK[b.kind]) ||
|
|
205
|
-
(b.directCallees - a.directCallees));
|
|
206
|
-
return {
|
|
207
|
-
totalEntities: entities.length,
|
|
208
|
-
entryPointCount: entryPoints.length,
|
|
209
|
-
byKind,
|
|
210
|
-
entryPoints: entryPoints.slice(0, 100),
|
|
211
|
-
};
|
|
212
|
-
}
|
|
213
|
-
// ─── Tool: trace_data_path (B6, δ×ε compose) ─────────────────────
|
|
214
|
-
/**
|
|
215
|
-
* Follow data from one entity through the call graph to its sinks. Composes
|
|
216
|
-
* the data dimension (inputs/outputs/mutations/db) with call edges: from a
|
|
217
|
-
* start entity, walk callees up to max_depth, record the data ops at each hop,
|
|
218
|
-
* and report the chain from the start to every sink it reaches (db write,
|
|
219
|
-
* network egress, fs write). Answers "I'm touching this function — where does
|
|
220
|
-
* its data end up?" — the cross-edge composition get_data_flow can't give for
|
|
221
|
-
* a single entity. Pairs with trace_boundaries (observability) and
|
|
222
|
-
* query_data_targets (who-touches-a-target); this one is the path itself.
|
|
223
|
-
*/
|
|
224
|
-
export function traceDataPath(args, loader) {
|
|
225
|
-
const projErr = validateProject(args.project, loader);
|
|
226
|
-
if (projErr)
|
|
227
|
-
return { error: projErr };
|
|
228
|
-
const ref = args.entity_id || args.entity;
|
|
229
|
-
if (!ref)
|
|
230
|
-
return { error: 'trace_data_path requires an entity (id or name)' };
|
|
231
|
-
const start = loader.getEntityById(ref, args.project) || loader.getEntityByName(ref, args.project);
|
|
232
|
-
if (!start || !start.id)
|
|
233
|
-
return { error: `Entity not found: ${ref}` };
|
|
234
|
-
const g = getGraph(args.project, loader);
|
|
235
|
-
const maxDepth = Math.min(Math.max(args.max_depth ?? 5, 1), 8);
|
|
236
|
-
const dataOf = (e) => {
|
|
237
|
-
const arr = (v) => (Array.isArray(v) ? v : []);
|
|
238
|
-
const data = (e.data || {});
|
|
239
|
-
const inputs = arr(data.inputs).length ? arr(data.inputs) : arr(data.sources);
|
|
240
|
-
const untrusted = inputs.filter((i) => i && typeof i === 'object' && i.untrusted).length;
|
|
241
|
-
const tags = normalizeConstraints(e.constraints);
|
|
242
|
-
const egress = [];
|
|
243
|
-
if (tags.includes('NETWORK_IO'))
|
|
244
|
-
egress.push('network');
|
|
245
|
-
if (tags.includes('FS_ACCESS'))
|
|
246
|
-
egress.push('fs');
|
|
247
|
-
return {
|
|
248
|
-
in: inputs.length,
|
|
249
|
-
out: (arr(data.outputs).length ? arr(data.outputs) : arr(data.returns)).length,
|
|
250
|
-
mut: arr(data.mutations).length,
|
|
251
|
-
untrusted,
|
|
252
|
-
db: extractDbOps(e).map((o) => ({ table: o.table, op: o.type === 'db_mutate' ? 'write' : 'read' })),
|
|
253
|
-
egress,
|
|
254
|
-
};
|
|
255
|
-
};
|
|
256
|
-
// A node is a sink when data leaves the program through it.
|
|
257
|
-
const sinkKind = (d) => {
|
|
258
|
-
if (d.db.some((o) => o.op === 'write'))
|
|
259
|
-
return 'db-write';
|
|
260
|
-
if (d.egress.includes('network'))
|
|
261
|
-
return 'network';
|
|
262
|
-
if (d.egress.includes('fs'))
|
|
263
|
-
return 'fs';
|
|
264
|
-
return null;
|
|
265
|
-
};
|
|
266
|
-
// BFS downstream over callees; keep one parent per node to reconstruct paths.
|
|
267
|
-
const nodes = new Map();
|
|
268
|
-
const order = [];
|
|
269
|
-
const queue = [[start.id, 0, null]];
|
|
270
|
-
const seen = new Set([start.id]);
|
|
271
|
-
while (queue.length > 0) {
|
|
272
|
-
const [id, depth, parent] = queue.shift();
|
|
273
|
-
const e = g.entityById.get(id);
|
|
274
|
-
if (!e)
|
|
275
|
-
continue;
|
|
276
|
-
const data = dataOf(e);
|
|
277
|
-
nodes.set(id, { entity: e, data, depth, parent, sink: sinkKind(data) });
|
|
278
|
-
order.push(id);
|
|
279
|
-
if (depth >= maxDepth)
|
|
280
|
-
continue;
|
|
281
|
-
for (const callee of g.callees.get(id) || []) {
|
|
282
|
-
if (!seen.has(callee)) {
|
|
283
|
-
seen.add(callee);
|
|
284
|
-
queue.push([callee, depth + 1, id]);
|
|
285
|
-
}
|
|
286
|
-
}
|
|
287
|
-
}
|
|
288
|
-
const pathTo = (id) => {
|
|
289
|
-
const chain = [];
|
|
290
|
-
let cur = id;
|
|
291
|
-
const guard = new Set();
|
|
292
|
-
while (cur != null && !guard.has(cur)) {
|
|
293
|
-
guard.add(cur);
|
|
294
|
-
chain.unshift(cur);
|
|
295
|
-
cur = nodes.get(cur)?.parent ?? null;
|
|
296
|
-
}
|
|
297
|
-
return chain;
|
|
298
|
-
};
|
|
299
|
-
const sinkIds = order.filter((id) => nodes.get(id).sink);
|
|
300
|
-
const chainNode = (id) => {
|
|
301
|
-
const n = nodes.get(id);
|
|
302
|
-
return { ...entitySummary(n.entity), depth: n.depth, data: n.data, sink: n.sink };
|
|
303
|
-
};
|
|
304
|
-
const paths = sinkIds.slice(0, 30).map((id) => ({
|
|
305
|
-
sinkKind: nodes.get(id).sink,
|
|
306
|
-
chain: pathTo(id).map(chainNode),
|
|
307
|
-
}));
|
|
308
|
-
const tableOps = new Map();
|
|
309
|
-
for (const id of order) {
|
|
310
|
-
for (const op of nodes.get(id).data.db) {
|
|
311
|
-
if (!tableOps.has(op.table))
|
|
312
|
-
tableOps.set(op.table, new Set());
|
|
313
|
-
tableOps.get(op.table).add(op.op);
|
|
314
|
-
}
|
|
315
|
-
}
|
|
316
|
-
return {
|
|
317
|
-
entity: { id: start.id, name: entityName(start) },
|
|
318
|
-
maxDepth,
|
|
319
|
-
reached: Math.max(0, order.length - 1),
|
|
320
|
-
sinkCount: sinkIds.length,
|
|
321
|
-
untrustedAtSource: nodes.get(start.id).data.untrusted,
|
|
322
|
-
tables: [...tableOps.entries()].map(([table, ops]) => ({ table, ops: [...ops].sort() })),
|
|
323
|
-
paths,
|
|
324
|
-
pathsTruncated: sinkIds.length > 30 ? sinkIds.length - 30 : 0,
|
|
325
|
-
};
|
|
326
|
-
}
|
|
327
|
-
// ─── Tool: find_layer_violations ─────────────────────────────────
|
|
328
|
-
export function findLayerViolations(args, loader) {
|
|
329
|
-
const projErr = validateProject(args?.project, loader);
|
|
330
|
-
if (projErr)
|
|
331
|
-
return { error: projErr };
|
|
332
|
-
const g = getGraph(args?.project, loader);
|
|
333
|
-
const violations = [];
|
|
334
|
-
for (const [callerId, calleeIds] of g.callees) {
|
|
335
|
-
const callerEntity = g.entityById.get(callerId);
|
|
336
|
-
if (!callerEntity)
|
|
337
|
-
continue;
|
|
338
|
-
const callerLayer = entityLayer(callerEntity);
|
|
339
|
-
const callerOrder = LAYER_ORDER[callerLayer];
|
|
340
|
-
if (callerOrder === undefined)
|
|
341
|
-
continue;
|
|
342
|
-
for (const calleeId of calleeIds) {
|
|
343
|
-
const calleeEntity = g.entityById.get(calleeId);
|
|
344
|
-
if (!calleeEntity)
|
|
345
|
-
continue;
|
|
346
|
-
const calleeLayer = entityLayer(calleeEntity);
|
|
347
|
-
const calleeOrder = LAYER_ORDER[calleeLayer];
|
|
348
|
-
if (calleeOrder === undefined)
|
|
349
|
-
continue;
|
|
350
|
-
// Skip same-layer calls
|
|
351
|
-
if (callerLayer === calleeLayer)
|
|
352
|
-
continue;
|
|
353
|
-
// Backward call: lower layer calling higher layer
|
|
354
|
-
if (callerOrder > calleeOrder) {
|
|
355
|
-
violations.push({
|
|
356
|
-
from: entitySummary(callerEntity),
|
|
357
|
-
to: entitySummary(calleeEntity),
|
|
358
|
-
type: `backward: ${callerLayer}(${callerOrder}) -> ${calleeLayer}(${calleeOrder})`,
|
|
359
|
-
});
|
|
360
|
-
}
|
|
361
|
-
// Skip-layer: jumping over more than 1 layer
|
|
362
|
-
else if (calleeOrder - callerOrder > 2) {
|
|
363
|
-
violations.push({
|
|
364
|
-
from: entitySummary(callerEntity),
|
|
365
|
-
to: entitySummary(calleeEntity),
|
|
366
|
-
type: `skip-layer: ${callerLayer}(${callerOrder}) -> ${calleeLayer}(${calleeOrder})`,
|
|
367
|
-
});
|
|
368
|
-
}
|
|
369
|
-
}
|
|
370
|
-
}
|
|
371
|
-
// Group violations by type
|
|
372
|
-
const byType = new Map();
|
|
373
|
-
for (const v of violations) {
|
|
374
|
-
const typePrefix = v.type.split(':')[0];
|
|
375
|
-
byType.set(typePrefix, (byType.get(typePrefix) || 0) + 1);
|
|
376
|
-
}
|
|
377
|
-
return {
|
|
378
|
-
totalViolations: violations.length,
|
|
379
|
-
byType: Object.fromEntries(byType),
|
|
380
|
-
violations: violations.slice(0, 100),
|
|
381
|
-
};
|
|
382
|
-
}
|
|
383
|
-
// ─── Tool: get_coupling_metrics ──────────────────────────────────
|
|
384
|
-
export function getCouplingMetrics(args, loader) {
|
|
385
|
-
const projErr = validateProject(args.project, loader);
|
|
386
|
-
if (projErr)
|
|
387
|
-
return { error: projErr };
|
|
388
|
-
const { group_by = 'module' } = args;
|
|
389
|
-
const g = getGraph(args.project, loader);
|
|
390
|
-
const entities = loader.getEntities(args.project);
|
|
391
|
-
// Group entities
|
|
392
|
-
const groups = new Map();
|
|
393
|
-
for (const e of entities) {
|
|
394
|
-
if (!e.id)
|
|
395
|
-
continue;
|
|
396
|
-
const key = group_by === 'module'
|
|
397
|
-
? (e.context?.module || 'unknown')
|
|
398
|
-
: entityLayer(e);
|
|
399
|
-
if (!groups.has(key))
|
|
400
|
-
groups.set(key, new Set());
|
|
401
|
-
groups.get(key).add(e.id);
|
|
402
|
-
}
|
|
403
|
-
const metrics = [];
|
|
404
|
-
for (const [groupName, memberIds] of groups) {
|
|
405
|
-
let internalEdges = 0;
|
|
406
|
-
let outgoingEdges = 0;
|
|
407
|
-
let incomingEdges = 0;
|
|
408
|
-
for (const memberId of memberIds) {
|
|
409
|
-
const calleeSet = g.callees.get(memberId);
|
|
410
|
-
if (calleeSet) {
|
|
411
|
-
for (const calleeId of calleeSet) {
|
|
412
|
-
if (memberIds.has(calleeId)) {
|
|
413
|
-
internalEdges++;
|
|
414
|
-
}
|
|
415
|
-
else {
|
|
416
|
-
outgoingEdges++;
|
|
417
|
-
}
|
|
418
|
-
}
|
|
419
|
-
}
|
|
420
|
-
const callerSet = g.callers.get(memberId);
|
|
421
|
-
if (callerSet) {
|
|
422
|
-
for (const callerId of callerSet) {
|
|
423
|
-
if (!memberIds.has(callerId)) {
|
|
424
|
-
incomingEdges++;
|
|
425
|
-
}
|
|
426
|
-
}
|
|
427
|
-
}
|
|
428
|
-
}
|
|
429
|
-
const size = memberIds.size;
|
|
430
|
-
const maxInternalEdges = size * (size - 1); // directed
|
|
431
|
-
const cohesion = maxInternalEdges > 0 ? internalEdges / maxInternalEdges : 0;
|
|
432
|
-
const totalExternal = outgoingEdges + incomingEdges;
|
|
433
|
-
const coupling = totalExternal;
|
|
434
|
-
const instability = totalExternal > 0 ? outgoingEdges / totalExternal : 0;
|
|
435
|
-
metrics.push({
|
|
436
|
-
group: groupName,
|
|
437
|
-
size,
|
|
438
|
-
internalEdges,
|
|
439
|
-
externalEdges: totalExternal,
|
|
440
|
-
cohesion: Math.round(cohesion * 1000) / 1000,
|
|
441
|
-
coupling,
|
|
442
|
-
instability: Math.round(instability * 1000) / 1000,
|
|
443
|
-
_summary: `${groupName}: ${internalEdges} internal / ${totalExternal} external edges, cohesion ${Math.round(cohesion * 1000) / 1000} (${size} entities)`,
|
|
444
|
-
});
|
|
445
|
-
}
|
|
446
|
-
// Sort by coupling (most coupled first)
|
|
447
|
-
metrics.sort((a, b) => b.coupling - a.coupling);
|
|
448
|
-
return {
|
|
449
|
-
groupBy: group_by,
|
|
450
|
-
groupCount: metrics.length,
|
|
451
|
-
metrics: metrics.slice(0, 50),
|
|
452
|
-
};
|
|
453
|
-
}
|
|
454
|
-
// ─── Tool: get_auth_matrix ───────────────────────────────────────
|
|
455
|
-
export function getAuthMatrix(args, loader) {
|
|
456
|
-
const projErr = validateProject(args?.project, loader);
|
|
457
|
-
if (projErr)
|
|
458
|
-
return { error: projErr };
|
|
459
|
-
const entities = loader.getEntities(args?.project);
|
|
460
|
-
let apiEntities = entities.filter((e) => {
|
|
461
|
-
const layer = entityLayer(e);
|
|
462
|
-
return layer === 'route' || layer === 'controller' ||
|
|
463
|
-
e.context?.exposure === 'api';
|
|
464
|
-
});
|
|
465
|
-
// Optional module filter — drill into a specific module/directory
|
|
466
|
-
if (args?.module) {
|
|
467
|
-
const mod = args.module.toLowerCase();
|
|
468
|
-
apiEntities = apiEntities.filter((e) => {
|
|
469
|
-
const eModule = (e.context?.module || e._sourceFile || '').toLowerCase();
|
|
470
|
-
return eModule.includes(mod);
|
|
471
|
-
});
|
|
472
|
-
}
|
|
473
|
-
// Optional layer filter
|
|
474
|
-
if (args?.layer) {
|
|
475
|
-
const targetLayer = args.layer.toLowerCase();
|
|
476
|
-
apiEntities = apiEntities.filter((e) => entityLayer(e) === targetLayer);
|
|
477
|
-
}
|
|
478
|
-
const withAuth = [];
|
|
479
|
-
const withoutAuth = [];
|
|
480
|
-
const inconsistencies = [];
|
|
481
|
-
for (const e of apiEntities) {
|
|
482
|
-
const constraints = e.constraints;
|
|
483
|
-
const hasAuth = constraints && !Array.isArray(constraints) &&
|
|
484
|
-
constraints.auth && constraints.auth !== 'none';
|
|
485
|
-
const summary = entitySummary(e);
|
|
486
|
-
if (hasAuth) {
|
|
487
|
-
withAuth.push({
|
|
488
|
-
...summary,
|
|
489
|
-
auth: constraints.auth,
|
|
490
|
-
});
|
|
491
|
-
}
|
|
492
|
-
else {
|
|
493
|
-
withoutAuth.push(summary);
|
|
494
|
-
}
|
|
495
|
-
// Check for inconsistencies: has auth decorator but marked as public
|
|
496
|
-
if (hasAuth && e.context?.visibility === 'public') {
|
|
497
|
-
inconsistencies.push({
|
|
498
|
-
entity: summary,
|
|
499
|
-
issue: 'Has auth requirements but visibility is public',
|
|
500
|
-
});
|
|
501
|
-
}
|
|
502
|
-
// Has DB access but no auth
|
|
503
|
-
if (!hasAuth) {
|
|
504
|
-
const sideEffects = constraints && !Array.isArray(constraints)
|
|
505
|
-
? constraints.sideEffects : undefined;
|
|
506
|
-
if (Array.isArray(sideEffects) && sideEffects.some((s) => s === 'db' || s === 'database')) {
|
|
507
|
-
inconsistencies.push({
|
|
508
|
-
entity: summary,
|
|
509
|
-
issue: 'DB access without auth — potential security gap',
|
|
510
|
-
});
|
|
511
|
-
}
|
|
512
|
-
}
|
|
513
|
-
}
|
|
514
|
-
return {
|
|
515
|
-
totalApiEntities: apiEntities.length,
|
|
516
|
-
withAuth: withAuth.length,
|
|
517
|
-
withoutAuth: withoutAuth.length,
|
|
518
|
-
inconsistencies: inconsistencies.length,
|
|
519
|
-
authenticated: withAuth.slice(0, 50),
|
|
520
|
-
unauthenticated: withoutAuth.slice(0, 50),
|
|
521
|
-
issues: inconsistencies.slice(0, 50),
|
|
522
|
-
};
|
|
523
|
-
}
|
|
524
|
-
// ─── Tool: find_error_gaps ───────────────────────────────────────
|
|
525
|
-
export function findErrorGaps(args, loader) {
|
|
526
|
-
const projErr = validateProject(args?.project, loader);
|
|
527
|
-
if (projErr)
|
|
528
|
-
return { error: projErr };
|
|
529
|
-
const g = getGraph(args?.project, loader);
|
|
530
|
-
const entities = loader.getEntities(args?.project);
|
|
531
|
-
// Find all fallible entities (throws === true or has THROWS tag)
|
|
532
|
-
const fallibleIds = new Set();
|
|
533
|
-
for (const e of entities) {
|
|
534
|
-
if (!e.id)
|
|
535
|
-
continue;
|
|
536
|
-
// Check constraints.throws
|
|
537
|
-
const constraints = e.constraints;
|
|
538
|
-
if (constraints && !Array.isArray(constraints) && constraints.throws) {
|
|
539
|
-
fallibleIds.add(e.id);
|
|
540
|
-
continue;
|
|
541
|
-
}
|
|
542
|
-
// Check traits.self.fallible
|
|
543
|
-
const traits = e.traits;
|
|
544
|
-
if (traits && !Array.isArray(traits) && traits.self?.fallible) {
|
|
545
|
-
fallibleIds.add(e.id);
|
|
546
|
-
continue;
|
|
547
|
-
}
|
|
548
|
-
// Check for network/db side effects (implicitly fallible)
|
|
549
|
-
if (constraints && !Array.isArray(constraints) && Array.isArray(constraints.sideEffects)) {
|
|
550
|
-
const effects = constraints.sideEffects;
|
|
551
|
-
if (effects.some((s) => s === 'network' || s === 'db' || s === 'database' || s === 'filesystem' || s === 'fs')) {
|
|
552
|
-
fallibleIds.add(e.id);
|
|
553
|
-
}
|
|
554
|
-
}
|
|
555
|
-
}
|
|
556
|
-
// Find callers of fallible entities that lack error handling
|
|
557
|
-
const gaps = [];
|
|
558
|
-
for (const fallibleId of fallibleIds) {
|
|
559
|
-
const callerSet = g.callers.get(fallibleId);
|
|
560
|
-
if (!callerSet)
|
|
561
|
-
continue;
|
|
562
|
-
const fallibleEntity = g.entityById.get(fallibleId);
|
|
563
|
-
if (!fallibleEntity)
|
|
564
|
-
continue;
|
|
565
|
-
for (const callerId of callerSet) {
|
|
566
|
-
const callerEntity = g.entityById.get(callerId);
|
|
567
|
-
if (!callerEntity)
|
|
568
|
-
continue;
|
|
569
|
-
// Check if caller has error handling
|
|
570
|
-
const callerConstraints = callerEntity.constraints;
|
|
571
|
-
const hasErrorHandling = callerConstraints && !Array.isArray(callerConstraints) &&
|
|
572
|
-
callerConstraints.errorHandling &&
|
|
573
|
-
(callerConstraints.errorHandling.tryCatch || callerConstraints.errorHandling.catchClause);
|
|
574
|
-
if (!hasErrorHandling) {
|
|
575
|
-
gaps.push({
|
|
576
|
-
caller: entitySummary(callerEntity),
|
|
577
|
-
fallibleCallee: entitySummary(fallibleEntity),
|
|
578
|
-
issue: 'Calls fallible function without try/catch',
|
|
579
|
-
});
|
|
580
|
-
}
|
|
581
|
-
}
|
|
582
|
-
}
|
|
583
|
-
return {
|
|
584
|
-
totalFallible: fallibleIds.size,
|
|
585
|
-
errorGaps: gaps.length,
|
|
586
|
-
_summary: `${gaps.length} error handling gaps across ${fallibleIds.size} fallible entities`,
|
|
587
|
-
gaps: gaps.slice(0, 100),
|
|
588
|
-
};
|
|
589
|
-
}
|
|
590
|
-
// ─── Tool: get_test_coverage ─────────────────────────────────────
|
|
591
|
-
export function getTestCoverage(args, loader) {
|
|
592
|
-
const projErr = validateProject(args.project, loader);
|
|
593
|
-
if (projErr)
|
|
594
|
-
return { error: projErr };
|
|
595
|
-
const { weight_by_blast_radius = false } = args;
|
|
596
|
-
const g = getGraph(args.project, loader);
|
|
597
|
-
const entities = loader.getEntities(args.project);
|
|
598
|
-
// Partition into test and non-test entities
|
|
599
|
-
const testIds = new Set();
|
|
600
|
-
const productionEntities = [];
|
|
601
|
-
for (const e of entities) {
|
|
602
|
-
if (!e.id)
|
|
603
|
-
continue;
|
|
604
|
-
if (entityLayer(e) === 'test') {
|
|
605
|
-
testIds.add(e.id);
|
|
606
|
-
}
|
|
607
|
-
else {
|
|
608
|
-
productionEntities.push(e);
|
|
609
|
-
}
|
|
610
|
-
}
|
|
611
|
-
// BFS from test entities through callees to find what they exercise
|
|
612
|
-
const exercised = new Set();
|
|
613
|
-
const queue = [...testIds];
|
|
614
|
-
const visited = new Set(testIds);
|
|
615
|
-
while (queue.length > 0) {
|
|
616
|
-
const current = queue.shift();
|
|
617
|
-
const calleeSet = g.callees.get(current);
|
|
618
|
-
if (!calleeSet)
|
|
619
|
-
continue;
|
|
620
|
-
for (const calleeId of calleeSet) {
|
|
621
|
-
if (!visited.has(calleeId)) {
|
|
622
|
-
visited.add(calleeId);
|
|
623
|
-
if (!testIds.has(calleeId)) {
|
|
624
|
-
exercised.add(calleeId);
|
|
625
|
-
}
|
|
626
|
-
queue.push(calleeId);
|
|
627
|
-
}
|
|
628
|
-
}
|
|
629
|
-
}
|
|
630
|
-
const covered = productionEntities.filter((e) => exercised.has(e.id));
|
|
631
|
-
const uncovered = productionEntities.filter((e) => !exercised.has(e.id));
|
|
632
|
-
const coveragePercent = productionEntities.length > 0
|
|
633
|
-
? Math.round((covered.length / productionEntities.length) * 1000) / 10
|
|
634
|
-
: 0;
|
|
635
|
-
const result = {
|
|
636
|
-
totalProduction: productionEntities.length,
|
|
637
|
-
totalTests: testIds.size,
|
|
638
|
-
coveredCount: covered.length,
|
|
639
|
-
uncoveredCount: uncovered.length,
|
|
640
|
-
coveragePercent,
|
|
641
|
-
_summary: `${testIds.size} test entities exercise ${covered.length} of ${productionEntities.length} production entities (${coveragePercent}% coverage)`,
|
|
642
|
-
};
|
|
643
|
-
if (weight_by_blast_radius && uncovered.length > 0) {
|
|
644
|
-
// Compute blast radius for each uncovered entity to prioritize what to test
|
|
645
|
-
const prioritized = uncovered
|
|
646
|
-
.map(e => {
|
|
647
|
-
const br = computeBlastRadius(g, new Set([e.id]));
|
|
648
|
-
return {
|
|
649
|
-
...entitySummary(e),
|
|
650
|
-
blastRadius: br.affected.length,
|
|
651
|
-
};
|
|
652
|
-
})
|
|
653
|
-
.sort((a, b) => b.blastRadius - a.blastRadius);
|
|
654
|
-
result.uncoveredByPriority = prioritized.slice(0, 50);
|
|
655
|
-
}
|
|
656
|
-
else {
|
|
657
|
-
// Group uncovered by layer
|
|
658
|
-
const byLayer = new Map();
|
|
659
|
-
for (const e of uncovered) {
|
|
660
|
-
const l = entityLayer(e);
|
|
661
|
-
byLayer.set(l, (byLayer.get(l) || 0) + 1);
|
|
662
|
-
}
|
|
663
|
-
result.uncoveredByLayer = Object.fromEntries([...byLayer.entries()].sort((a, b) => b[1] - a[1]));
|
|
664
|
-
result.uncovered = uncovered.slice(0, 50).map(entitySummary);
|
|
665
|
-
}
|
|
666
|
-
return result;
|
|
667
|
-
}
|
|
668
|
-
// ─── Tool: get_optimal_context ───────────────────────────────────
|
|
669
|
-
export function getOptimalContext(args, loader) {
|
|
670
|
-
const projErr = validateProject(args.project, loader);
|
|
671
|
-
if (projErr)
|
|
672
|
-
return { error: projErr };
|
|
673
|
-
const { target_entity, max_tokens = 8000, strategy = 'bfs' } = args;
|
|
674
|
-
const g = getGraph(args.project, loader);
|
|
675
|
-
const entity = loader.getEntityById(target_entity, args.project) || loader.getEntityByName(target_entity, args.project);
|
|
676
|
-
if (!entity) {
|
|
677
|
-
return { error: `Entity not found: ${target_entity}` };
|
|
678
|
-
}
|
|
679
|
-
const targetId = entity.id;
|
|
680
|
-
const candidates = [];
|
|
681
|
-
if (strategy === 'blast_radius') {
|
|
682
|
-
// Use blast radius to get all related entities with depth
|
|
683
|
-
const br = computeBlastRadius(g, new Set([targetId]));
|
|
684
|
-
for (const id of br.affected) {
|
|
685
|
-
if (id === targetId)
|
|
686
|
-
continue;
|
|
687
|
-
const e = g.entityById.get(id);
|
|
688
|
-
if (!e)
|
|
689
|
-
continue;
|
|
690
|
-
const depth = Math.abs(br.depthMap[id] || 99);
|
|
691
|
-
const tokens = estimateTokens(e);
|
|
692
|
-
// Relevance decays with distance; direct callers/callees are most valuable
|
|
693
|
-
const relevance = 1 / (1 + depth);
|
|
694
|
-
candidates.push({
|
|
695
|
-
entity: entitySummary(e),
|
|
696
|
-
relevance: Math.round(relevance * 1000) / 1000,
|
|
697
|
-
distance: depth,
|
|
698
|
-
direction: (br.depthMap[id] || 0) < 0 ? 'upstream' : 'downstream',
|
|
699
|
-
tokens,
|
|
700
|
-
});
|
|
701
|
-
}
|
|
702
|
-
}
|
|
703
|
-
else {
|
|
704
|
-
// BFS from target in both directions with distance tracking
|
|
705
|
-
const distances = new Map();
|
|
706
|
-
// BFS callees (downstream)
|
|
707
|
-
const downQueue = [[targetId, 0]];
|
|
708
|
-
const downVisited = new Set([targetId]);
|
|
709
|
-
while (downQueue.length > 0) {
|
|
710
|
-
const [current, d] = downQueue.shift();
|
|
711
|
-
if (d > 5)
|
|
712
|
-
continue;
|
|
713
|
-
const calleeSet = g.callees.get(current);
|
|
714
|
-
if (!calleeSet)
|
|
715
|
-
continue;
|
|
716
|
-
for (const id of calleeSet) {
|
|
717
|
-
if (!downVisited.has(id)) {
|
|
718
|
-
downVisited.add(id);
|
|
719
|
-
distances.set(id, { dist: d + 1, dir: 'downstream' });
|
|
720
|
-
downQueue.push([id, d + 1]);
|
|
721
|
-
}
|
|
722
|
-
}
|
|
723
|
-
}
|
|
724
|
-
// BFS callers (upstream)
|
|
725
|
-
const upQueue = [[targetId, 0]];
|
|
726
|
-
const upVisited = new Set([targetId]);
|
|
727
|
-
while (upQueue.length > 0) {
|
|
728
|
-
const [current, d] = upQueue.shift();
|
|
729
|
-
if (d > 5)
|
|
730
|
-
continue;
|
|
731
|
-
const callerSet = g.callers.get(current);
|
|
732
|
-
if (!callerSet)
|
|
733
|
-
continue;
|
|
734
|
-
for (const id of callerSet) {
|
|
735
|
-
if (!upVisited.has(id)) {
|
|
736
|
-
upVisited.add(id);
|
|
737
|
-
// Only override if not already found with shorter distance
|
|
738
|
-
if (!distances.has(id) || distances.get(id).dist > d + 1) {
|
|
739
|
-
distances.set(id, { dist: d + 1, dir: 'upstream' });
|
|
740
|
-
}
|
|
741
|
-
upQueue.push([id, d + 1]);
|
|
742
|
-
}
|
|
743
|
-
}
|
|
744
|
-
}
|
|
745
|
-
for (const [id, { dist, dir }] of distances) {
|
|
746
|
-
const e = g.entityById.get(id);
|
|
747
|
-
if (!e)
|
|
748
|
-
continue;
|
|
749
|
-
const tokens = estimateTokens(e);
|
|
750
|
-
const relevance = 1 / (1 + dist);
|
|
751
|
-
candidates.push({
|
|
752
|
-
entity: entitySummary(e),
|
|
753
|
-
relevance: Math.round(relevance * 1000) / 1000,
|
|
754
|
-
distance: dist,
|
|
755
|
-
direction: dir,
|
|
756
|
-
tokens,
|
|
757
|
-
});
|
|
758
|
-
}
|
|
759
|
-
}
|
|
760
|
-
// Greedy knapsack: sort by relevance/token ratio, fill until budget
|
|
761
|
-
candidates.sort((a, b) => (b.relevance / b.tokens) - (a.relevance / a.tokens));
|
|
762
|
-
const selected = [];
|
|
763
|
-
const targetTokens = estimateTokens(entity);
|
|
764
|
-
let usedTokens = targetTokens; // Reserve space for the target itself
|
|
765
|
-
for (const candidate of candidates) {
|
|
766
|
-
if (usedTokens + candidate.tokens > max_tokens)
|
|
767
|
-
continue;
|
|
768
|
-
selected.push(candidate);
|
|
769
|
-
usedTokens += candidate.tokens;
|
|
770
|
-
}
|
|
771
|
-
return {
|
|
772
|
-
target: {
|
|
773
|
-
...entitySummary(entity),
|
|
774
|
-
tokens: targetTokens,
|
|
775
|
-
},
|
|
776
|
-
maxTokens: max_tokens,
|
|
777
|
-
usedTokens,
|
|
778
|
-
contextEntities: selected.length,
|
|
779
|
-
totalCandidates: candidates.length,
|
|
780
|
-
context: selected,
|
|
781
|
-
};
|
|
782
|
-
}
|
|
783
|
-
// ─── Tool: estimate_task_cost ────────────────────────────────────
|
|
784
|
-
/**
|
|
785
|
-
* Estimate token cost of a code change BEFORE starting work.
|
|
786
|
-
* Computes blast radius, sums source token counts, and projects total burn.
|
|
787
|
-
* Logs prediction to Supabase when configured (for calibration feedback loop).
|
|
788
|
-
*/
|
|
789
|
-
export async function estimateTaskCost(args, loader) {
|
|
790
|
-
const projErr = validateProject(args.project, loader);
|
|
791
|
-
if (projErr)
|
|
792
|
-
return { error: projErr };
|
|
793
|
-
const { target_entities, context_budget = 200000 } = args;
|
|
794
|
-
const g = getGraph(args.project, loader);
|
|
795
|
-
// Resolve target entities
|
|
796
|
-
const resolvedTargets = [];
|
|
797
|
-
const unresolvedNames = [];
|
|
798
|
-
const changedIds = new Set();
|
|
799
|
-
for (const name of target_entities) {
|
|
800
|
-
const entity = loader.getEntityById(name, args.project)
|
|
801
|
-
|| loader.getEntityByName(name, args.project);
|
|
802
|
-
if (entity) {
|
|
803
|
-
resolvedTargets.push(entity);
|
|
804
|
-
changedIds.add(entity.id);
|
|
805
|
-
}
|
|
806
|
-
else {
|
|
807
|
-
unresolvedNames.push(name);
|
|
808
|
-
}
|
|
809
|
-
}
|
|
810
|
-
if (resolvedTargets.length === 0) {
|
|
811
|
-
return {
|
|
812
|
-
error: `No entities resolved. Unresolved: ${unresolvedNames.join(', ')}`,
|
|
813
|
-
hint: 'Use query_entities to find the correct entity IDs.',
|
|
814
|
-
};
|
|
815
|
-
}
|
|
816
|
-
// Compute blast radius across all targets
|
|
817
|
-
const br = computeBlastRadius(g, changedIds);
|
|
818
|
-
// Collect all affected entities (targets + blast radius)
|
|
819
|
-
const allAffectedIds = new Set([...changedIds, ...br.affected]);
|
|
820
|
-
const affectedEntities = [];
|
|
821
|
-
for (const id of allAffectedIds) {
|
|
822
|
-
const e = g.entityById.get(id);
|
|
823
|
-
if (e)
|
|
824
|
-
affectedEntities.push(e);
|
|
825
|
-
}
|
|
826
|
-
// Sum token estimates
|
|
827
|
-
let contextLoad = 0;
|
|
828
|
-
const fileTokens = new Map();
|
|
829
|
-
const layerSet = new Set();
|
|
830
|
-
for (const e of affectedEntities) {
|
|
831
|
-
const tokens = estimateTokens(e);
|
|
832
|
-
contextLoad += tokens;
|
|
833
|
-
const file = e._sourceFile || 'unknown';
|
|
834
|
-
if (!fileTokens.has(file))
|
|
835
|
-
fileTokens.set(file, { entities: 0, tokens: 0 });
|
|
836
|
-
const ft = fileTokens.get(file);
|
|
837
|
-
ft.entities++;
|
|
838
|
-
ft.tokens += tokens;
|
|
839
|
-
layerSet.add(entityLayer(e));
|
|
840
|
-
}
|
|
841
|
-
// Compute iteration multiplier
|
|
842
|
-
let iterationMultiplier = 1.5; // base: read + write pass
|
|
843
|
-
// +0.5 if cross-cutting (3+ layers)
|
|
844
|
-
if (layerSet.size >= 3)
|
|
845
|
-
iterationMultiplier += 0.5;
|
|
846
|
-
// +0.5 if any affected module has high instability
|
|
847
|
-
const affectedModules = new Set();
|
|
848
|
-
for (const e of affectedEntities) {
|
|
849
|
-
if (e.context?.module)
|
|
850
|
-
affectedModules.add(e.context.module);
|
|
851
|
-
}
|
|
852
|
-
// Quick instability check: compute outgoing / (outgoing + incoming) for affected modules
|
|
853
|
-
for (const mod of affectedModules) {
|
|
854
|
-
let outgoing = 0;
|
|
855
|
-
let incoming = 0;
|
|
856
|
-
const entities = loader.getEntities(args.project);
|
|
857
|
-
const modIds = new Set(entities.filter((e) => e.context?.module === mod).map(e => e.id));
|
|
858
|
-
for (const id of modIds) {
|
|
859
|
-
const calleeSet = g.callees.get(id);
|
|
860
|
-
if (calleeSet) {
|
|
861
|
-
for (const cid of calleeSet) {
|
|
862
|
-
if (!modIds.has(cid))
|
|
863
|
-
outgoing++;
|
|
864
|
-
}
|
|
865
|
-
}
|
|
866
|
-
const callerSet = g.callers.get(id);
|
|
867
|
-
if (callerSet) {
|
|
868
|
-
for (const cid of callerSet) {
|
|
869
|
-
if (!modIds.has(cid))
|
|
870
|
-
incoming++;
|
|
871
|
-
}
|
|
872
|
-
}
|
|
873
|
-
}
|
|
874
|
-
const total = outgoing + incoming;
|
|
875
|
-
if (total > 0 && outgoing / total > 0.8) {
|
|
876
|
-
iterationMultiplier += 0.5;
|
|
877
|
-
break; // only add once
|
|
878
|
-
}
|
|
879
|
-
}
|
|
880
|
-
// +0.5 if large blast radius
|
|
881
|
-
if (allAffectedIds.size > 50)
|
|
882
|
-
iterationMultiplier += 0.5;
|
|
883
|
-
// Cap at 4.0
|
|
884
|
-
iterationMultiplier = Math.min(4.0, iterationMultiplier);
|
|
885
|
-
const projectedTotal = Math.round(contextLoad * iterationMultiplier);
|
|
886
|
-
const fitsInSinglePass = contextLoad <= context_budget;
|
|
887
|
-
const passesRequired = fitsInSinglePass ? 1 : Math.ceil(contextLoad / context_budget);
|
|
888
|
-
// Build file breakdown sorted by token count
|
|
889
|
-
const breakdown = [...fileTokens.entries()]
|
|
890
|
-
.map(([file, data]) => ({ file, entities: data.entities, tokens: data.tokens }))
|
|
891
|
-
.sort((a, b) => b.tokens - a.tokens);
|
|
892
|
-
// Build target summaries with token estimates
|
|
893
|
-
const targetSummaries = resolvedTargets.map(e => ({
|
|
894
|
-
...entitySummary(e),
|
|
895
|
-
sourceTokens: estimateTokens(e),
|
|
896
|
-
}));
|
|
897
|
-
const affectedLayers = [...layerSet].sort();
|
|
898
|
-
const estimator = dominantEstimator(affectedEntities);
|
|
899
|
-
// Resolve project name for logging
|
|
900
|
-
const projectName = args.project || loader.getProjectNames()[0] || 'unknown';
|
|
901
|
-
const result = {
|
|
902
|
-
targets: targetSummaries,
|
|
903
|
-
...(unresolvedNames.length > 0 ? { unresolved: unresolvedNames } : {}),
|
|
904
|
-
affectedEntities: allAffectedIds.size,
|
|
905
|
-
affectedFiles: fileTokens.size,
|
|
906
|
-
affectedLayers,
|
|
907
|
-
tokenEstimate: {
|
|
908
|
-
contextLoad,
|
|
909
|
-
iterationMultiplier: Math.round(iterationMultiplier * 10) / 10,
|
|
910
|
-
projectedTotal,
|
|
911
|
-
estimatorUsed: estimator,
|
|
912
|
-
},
|
|
913
|
-
feasibility: {
|
|
914
|
-
contextBudget: context_budget,
|
|
915
|
-
fitsInSinglePass,
|
|
916
|
-
passesRequired,
|
|
917
|
-
},
|
|
918
|
-
breakdown: breakdown.slice(0, 30),
|
|
919
|
-
_summary: `Changing ${target_entities.join(', ')} affects ${allAffectedIds.size} entities across ${fileTokens.size} files. Context load: ~${Math.round(contextLoad / 1000)}K tokens. Projected total with ${Math.round(iterationMultiplier * 10) / 10}x iteration: ~${Math.round(projectedTotal / 1000)}K tokens. ${fitsInSinglePass ? `Fits in ${Math.round(context_budget / 1000)}K budget (${passesRequired} pass).` : `Exceeds ${Math.round(context_budget / 1000)}K budget — needs ${passesRequired} passes.`}`,
|
|
920
|
-
};
|
|
921
|
-
// Log prediction to Supabase if configured (calibration feedback loop)
|
|
922
|
-
if (isSupabaseConfigured()) {
|
|
923
|
-
try {
|
|
924
|
-
const predictionId = await insertPrediction({
|
|
925
|
-
project: projectName,
|
|
926
|
-
target_entities,
|
|
927
|
-
predicted_context_load: contextLoad,
|
|
928
|
-
predicted_iteration_mult: Math.round(iterationMultiplier * 10) / 10,
|
|
929
|
-
predicted_total: projectedTotal,
|
|
930
|
-
affected_entities: allAffectedIds.size,
|
|
931
|
-
affected_files: fileTokens.size,
|
|
932
|
-
affected_layers: affectedLayers,
|
|
933
|
-
estimator_used: estimator,
|
|
934
|
-
context_budget,
|
|
935
|
-
});
|
|
936
|
-
if (predictionId) {
|
|
937
|
-
result.predictionId = predictionId;
|
|
938
|
-
result._summary += ` Prediction logged (${predictionId.slice(0, 8)}…).`;
|
|
939
|
-
}
|
|
940
|
-
}
|
|
941
|
-
catch {
|
|
942
|
-
// Silently swallow — prediction logging is best-effort
|
|
943
|
-
}
|
|
944
|
-
}
|
|
945
|
-
return result;
|
|
946
|
-
}
|
|
947
|
-
// ─── Tool: report_actual_burn ────────────────────────────────────
|
|
948
|
-
/**
|
|
949
|
-
* Close the calibration feedback loop by reporting actual token usage
|
|
950
|
-
* against a prior prediction from estimate_task_cost.
|
|
951
|
-
*
|
|
952
|
-
* Can also abandon a prediction (task was cancelled/not completed),
|
|
953
|
-
* or list recent predictions for calibration analysis.
|
|
954
|
-
*/
|
|
955
|
-
export async function reportActualBurn(args, loader) {
|
|
956
|
-
if (!isSupabaseConfigured()) {
|
|
957
|
-
return {
|
|
958
|
-
error: 'Supabase not configured. Set SESHAT_SUPABASE_URL and SESHAT_SUPABASE_KEY env vars.',
|
|
959
|
-
hint: 'The calibration feedback loop requires a Supabase connection to store predictions.',
|
|
960
|
-
};
|
|
961
|
-
}
|
|
962
|
-
const { action = 'complete' } = args;
|
|
963
|
-
// List mode: show recent predictions for calibration analysis
|
|
964
|
-
if (action === 'list') {
|
|
965
|
-
return {
|
|
966
|
-
message: 'List predictions has been migrated to the Ptah Dashboard.',
|
|
967
|
-
predictions: []
|
|
968
|
-
};
|
|
969
|
-
}
|
|
970
|
-
// Complete or abandon requires prediction_id
|
|
971
|
-
if (!args.prediction_id) {
|
|
972
|
-
return {
|
|
973
|
-
error: 'prediction_id is required for complete/abandon actions.',
|
|
974
|
-
hint: 'Use estimate_task_cost first to get a predictionId, then pass it here.',
|
|
975
|
-
};
|
|
976
|
-
}
|
|
977
|
-
// Abandon mode
|
|
978
|
-
if (action === 'abandon') {
|
|
979
|
-
const ok = await abandonPrediction(args.prediction_id);
|
|
980
|
-
return ok
|
|
981
|
-
? { status: 'abandoned', predictionId: args.prediction_id }
|
|
982
|
-
: { error: `Failed to abandon prediction ${args.prediction_id}. It may already be completed or not exist.` };
|
|
983
|
-
}
|
|
984
|
-
// Complete mode: requires actual token counts
|
|
985
|
-
if (!args.actual_input_tokens || !args.actual_output_tokens || !args.actual_total_tokens || !args.model) {
|
|
986
|
-
return {
|
|
987
|
-
error: 'actual_input_tokens, actual_output_tokens, actual_total_tokens, and model are required to complete a prediction.',
|
|
988
|
-
};
|
|
989
|
-
}
|
|
990
|
-
const updated = await updateActualBurn(args.prediction_id, {
|
|
991
|
-
actual_input_tokens: args.actual_input_tokens,
|
|
992
|
-
actual_output_tokens: args.actual_output_tokens,
|
|
993
|
-
actual_total_tokens: args.actual_total_tokens,
|
|
994
|
-
model: args.model,
|
|
995
|
-
notes: args.notes,
|
|
996
|
-
});
|
|
997
|
-
if (!updated) {
|
|
998
|
-
return {
|
|
999
|
-
error: `Failed to update prediction ${args.prediction_id}. It may not exist or may already be completed.`,
|
|
1000
|
-
};
|
|
1001
|
-
}
|
|
1002
|
-
return {
|
|
1003
|
-
status: 'completed',
|
|
1004
|
-
predictionId: updated.id,
|
|
1005
|
-
predicted: updated.predicted_total,
|
|
1006
|
-
actual: updated.actual_total_tokens,
|
|
1007
|
-
drift: updated.drift_ratio,
|
|
1008
|
-
_summary: `Prediction ${updated.id.slice(0, 8)}… closed. Predicted ${updated.predicted_total} tokens, actual ${updated.actual_total_tokens} tokens. Drift: ${updated.drift_ratio != null ? `${(updated.drift_ratio * 100).toFixed(1)}%` : 'N/A'}.`,
|
|
1009
|
-
};
|
|
1010
|
-
}
|
|
1011
|
-
// ─── Tool: find_runtime_violations (Runtime Context) ────────────
|
|
1012
|
-
export function find_runtime_violations(args, loader) {
|
|
1013
|
-
const projErr = validateProject(args?.project, loader);
|
|
1014
|
-
if (projErr)
|
|
1015
|
-
return { error: projErr };
|
|
1016
|
-
const g = getGraph(args?.project, loader);
|
|
1017
|
-
const violations = [];
|
|
1018
|
-
for (const [callerId, calleeIds] of g.callees) {
|
|
1019
|
-
const callerEntity = g.entityById.get(callerId);
|
|
1020
|
-
if (!callerEntity)
|
|
1021
|
-
continue;
|
|
1022
|
-
const callerFramework = callerEntity.runtime?.framework || 'agnostic';
|
|
1023
|
-
for (const calleeId of calleeIds) {
|
|
1024
|
-
const calleeEntity = g.entityById.get(calleeId);
|
|
1025
|
-
if (!calleeEntity)
|
|
1026
|
-
continue;
|
|
1027
|
-
const calleeFramework = calleeEntity.runtime?.framework;
|
|
1028
|
-
// If caller is framework-agnostic but calls framework-specific code (e.g., pure logic calling React)
|
|
1029
|
-
if (callerFramework === 'agnostic' && calleeFramework && calleeFramework !== 'agnostic') {
|
|
1030
|
-
violations.push({
|
|
1031
|
-
caller: { ...entitySummary(callerEntity), framework: callerFramework },
|
|
1032
|
-
callee: { ...entitySummary(calleeEntity), framework: calleeFramework },
|
|
1033
|
-
issue: `Framework leak: Agnostic caller depends on ${calleeFramework}-specific callee`,
|
|
1034
|
-
});
|
|
1035
|
-
}
|
|
1036
|
-
// If mixing frameworks directly (e.g., Vue calling React)
|
|
1037
|
-
if (callerFramework !== 'agnostic' && calleeFramework && calleeFramework !== 'agnostic' && callerFramework !== calleeFramework) {
|
|
1038
|
-
violations.push({
|
|
1039
|
-
caller: { ...entitySummary(callerEntity), framework: callerFramework },
|
|
1040
|
-
callee: { ...entitySummary(calleeEntity), framework: calleeFramework },
|
|
1041
|
-
issue: `Cross-framework boundary: ${callerFramework} calling ${calleeFramework}`,
|
|
1042
|
-
});
|
|
1043
|
-
}
|
|
1044
|
-
}
|
|
1045
|
-
}
|
|
1046
|
-
return {
|
|
1047
|
-
totalViolations: violations.length,
|
|
1048
|
-
_summary: `Found ${violations.length} runtime/framework boundary violations`,
|
|
1049
|
-
violations: violations.slice(0, 100),
|
|
1050
|
-
};
|
|
1051
|
-
}
|
|
1052
|
-
// ─── Tool: find_ownership_violations (Ownership & Lifetimes) ─────
|
|
1053
|
-
export function find_ownership_violations(args, loader) {
|
|
1054
|
-
const projErr = validateProject(args?.project, loader);
|
|
1055
|
-
if (projErr)
|
|
1056
|
-
return { error: projErr };
|
|
1057
|
-
const entities = loader.getEntities(args?.project);
|
|
1058
|
-
const violations = [];
|
|
1059
|
-
for (const e of entities) {
|
|
1060
|
-
if (!e.ownership || Object.keys(e.ownership).length === 0)
|
|
1061
|
-
continue;
|
|
1062
|
-
// We look for 'unsafe', 'escapes', 'mutates_borrowed', or complex lifetime constraints
|
|
1063
|
-
const own = e.ownership;
|
|
1064
|
-
if (own.unsafe) {
|
|
1065
|
-
violations.push({
|
|
1066
|
-
entity: entitySummary(e),
|
|
1067
|
-
ownership_details: own,
|
|
1068
|
-
issue: 'Unsafe memory access or pointer manipulation flagged',
|
|
1069
|
-
});
|
|
1070
|
-
}
|
|
1071
|
-
else if (own.escapes) {
|
|
1072
|
-
violations.push({
|
|
1073
|
-
entity: entitySummary(e),
|
|
1074
|
-
ownership_details: own,
|
|
1075
|
-
issue: 'Value potentially escapes its lifetime boundary',
|
|
1076
|
-
});
|
|
1077
|
-
}
|
|
1078
|
-
else if (own.mutates_borrowed) {
|
|
1079
|
-
violations.push({
|
|
1080
|
-
entity: entitySummary(e),
|
|
1081
|
-
ownership_details: own,
|
|
1082
|
-
issue: 'Attempts to mutate an immutably borrowed reference',
|
|
1083
|
-
});
|
|
1084
|
-
}
|
|
1085
|
-
}
|
|
1086
|
-
return {
|
|
1087
|
-
totalViolations: violations.length,
|
|
1088
|
-
_summary: `Found ${violations.length} symbols with strict/violating ownership constraints`,
|
|
1089
|
-
violations: violations.slice(0, 100),
|
|
1090
|
-
};
|
|
1091
|
-
}
|
|
1092
|
-
// ─── Tool: query_traits (Trait Search) ───────────────────────────
|
|
1093
|
-
export function query_traits(args, loader) {
|
|
1094
|
-
const projErr = validateProject(args.project, loader);
|
|
1095
|
-
if (projErr)
|
|
1096
|
-
return { error: projErr };
|
|
1097
|
-
const target = args.trait.toLowerCase();
|
|
1098
|
-
const entities = loader.getEntities(args.project);
|
|
1099
|
-
const results = entities.filter((e) => {
|
|
1100
|
-
if (!e.traits)
|
|
1101
|
-
return false;
|
|
1102
|
-
// Handle array of strings [ 'asyncContext', 'generator' ]
|
|
1103
|
-
if (Array.isArray(e.traits)) {
|
|
1104
|
-
return e.traits.some((t) => t.toLowerCase().includes(target));
|
|
1105
|
-
}
|
|
1106
|
-
// Handle structured traits { self: { fallible: true } }
|
|
1107
|
-
if (typeof e.traits === 'object') {
|
|
1108
|
-
const tr = e.traits;
|
|
1109
|
-
if (tr.self && typeof tr.self === 'object') {
|
|
1110
|
-
const selfTraits = tr.self;
|
|
1111
|
-
for (const [key, val] of Object.entries(selfTraits)) {
|
|
1112
|
-
if (key.toLowerCase().includes(target) && val === true)
|
|
1113
|
-
return true;
|
|
1114
|
-
}
|
|
1115
|
-
}
|
|
1116
|
-
if (tr.params && typeof tr.params === 'object') {
|
|
1117
|
-
const paramTraits = tr.params;
|
|
1118
|
-
for (const p of Object.values(paramTraits)) {
|
|
1119
|
-
if (p.bounds && Array.isArray(p.bounds) && p.bounds.some((b) => b.toLowerCase().includes(target)))
|
|
1120
|
-
return true;
|
|
1121
|
-
if (p.markers && Array.isArray(p.markers) && p.markers.some((m) => m.toLowerCase().includes(target)))
|
|
1122
|
-
return true;
|
|
1123
|
-
}
|
|
1124
|
-
}
|
|
1125
|
-
}
|
|
1126
|
-
return false;
|
|
1127
|
-
});
|
|
1128
|
-
return {
|
|
1129
|
-
trait: args.trait,
|
|
1130
|
-
total: results.length,
|
|
1131
|
-
_summary: `Found ${results.length} symbols implementing the '${args.trait}' trait`,
|
|
1132
|
-
entities: results.slice(0, 100).map(entitySummary),
|
|
1133
|
-
};
|
|
1134
|
-
}
|
|
1135
|
-
// ─── Tool: simulate_mutation (Impact Simulator) ──────────────────
|
|
1136
|
-
export function simulate_mutation(args, loader) {
|
|
1137
|
-
const projErr = validateProject(args.project, loader);
|
|
1138
|
-
if (projErr)
|
|
1139
|
-
return { error: projErr };
|
|
1140
|
-
const g = getGraph(args.project, loader);
|
|
1141
|
-
const targetEntity = loader.getEntityById(args.entity_id, args.project)
|
|
1142
|
-
|| loader.getEntityByName(args.entity_id, args.project);
|
|
1143
|
-
if (!targetEntity) {
|
|
1144
|
-
return { error: `Symbol not found: ${args.entity_id}` };
|
|
1145
|
-
}
|
|
1146
|
-
const targetId = targetEntity.id;
|
|
1147
|
-
const { dimension, change } = args.mutation;
|
|
1148
|
-
// Structural-dimension projections. Logic (body) changes are deliberately
|
|
1149
|
-
// not simulated statement-level: an edit to the body manifests structurally
|
|
1150
|
-
// as call-edge and data-contract deltas, so simulating those IS simulating
|
|
1151
|
-
// the body at the resolution the graph supports.
|
|
1152
|
-
if (dimension === 'edges') {
|
|
1153
|
-
return simulateEdgeMutation(targetEntity, change, g, args.mutation, loader, args.project);
|
|
1154
|
-
}
|
|
1155
|
-
if (dimension === 'data') {
|
|
1156
|
-
return simulateDataMutation(targetEntity, change, g, args.mutation);
|
|
1157
|
-
}
|
|
1158
|
-
if (dimension === 'signature') {
|
|
1159
|
-
return simulateSignatureMutation(targetEntity, change, g, args.mutation);
|
|
1160
|
-
}
|
|
1161
|
-
const addedTags = (change.add || []).map(t => t.toLowerCase());
|
|
1162
|
-
// We are currently simulating topological fallout for newly ADDED constraints/traits.
|
|
1163
|
-
// Example: If we add "fallible" or "auth", who upstream breaks because they don't handle it?
|
|
1164
|
-
const fallout = [];
|
|
1165
|
-
// Simulate Fallout: Traversal Upstream (Callers)
|
|
1166
|
-
const upQueue = [[targetId, 0]];
|
|
1167
|
-
const upVisited = new Set([targetId]);
|
|
1168
|
-
while (upQueue.length > 0) {
|
|
1169
|
-
const [currentId, depth] = upQueue.shift();
|
|
1170
|
-
if (depth > 5)
|
|
1171
|
-
continue; // bound simulation depth
|
|
1172
|
-
const callerSet = g.callers.get(currentId);
|
|
1173
|
-
if (!callerSet)
|
|
1174
|
-
continue;
|
|
1175
|
-
for (const callerId of callerSet) {
|
|
1176
|
-
if (upVisited.has(callerId))
|
|
1177
|
-
continue;
|
|
1178
|
-
upVisited.add(callerId);
|
|
1179
|
-
const callerEntity = g.entityById.get(callerId);
|
|
1180
|
-
if (!callerEntity)
|
|
1181
|
-
continue;
|
|
1182
|
-
const callerConstraints = callerEntity.constraints;
|
|
1183
|
-
const callerTraits = callerEntity.traits;
|
|
1184
|
-
// Rule 1: Fallible Simulation
|
|
1185
|
-
if (addedTags.includes('fallible') || addedTags.includes('throws')) {
|
|
1186
|
-
const hasErrorHandling = callerConstraints?.errorHandling &&
|
|
1187
|
-
(callerConstraints.errorHandling.tryCatch || callerConstraints.errorHandling.catchClause);
|
|
1188
|
-
if (!hasErrorHandling) {
|
|
1189
|
-
fallout.push({
|
|
1190
|
-
entity: entitySummary(callerEntity),
|
|
1191
|
-
distance: depth + 1,
|
|
1192
|
-
reason: `Calls a newly fallible path but lacks error handling.`,
|
|
1193
|
-
requiredFix: `Wrap call to ${currentId} in try/catch or propagate error.`,
|
|
1194
|
-
});
|
|
1195
|
-
// Error propagates up if not caught, so we continue queueing
|
|
1196
|
-
upQueue.push([callerId, depth + 1]);
|
|
1197
|
-
}
|
|
1198
|
-
}
|
|
1199
|
-
// Rule 2: Auth / Security Simulation
|
|
1200
|
-
else if (addedTags.includes('auth') || addedTags.includes('secure')) {
|
|
1201
|
-
// Does the caller provide Auth? Check constraints.auth or if they are a known auth middleware
|
|
1202
|
-
const hasAuth = callerConstraints?.auth || callerTraits?.self?.authContext;
|
|
1203
|
-
const isMiddleware = entityLayer(callerEntity) === 'middleware';
|
|
1204
|
-
if (!hasAuth && !isMiddleware) {
|
|
1205
|
-
fallout.push({
|
|
1206
|
-
entity: entitySummary(callerEntity),
|
|
1207
|
-
distance: depth + 1,
|
|
1208
|
-
reason: `Upstream path is unauthenticated but downstream requires auth.`,
|
|
1209
|
-
requiredFix: `Add authentication context to ${entityName(callerEntity)} or its router.`,
|
|
1210
|
-
});
|
|
1211
|
-
upQueue.push([callerId, depth + 1]);
|
|
1212
|
-
}
|
|
1213
|
-
}
|
|
1214
|
-
// Generic unhandled propagation
|
|
1215
|
-
else {
|
|
1216
|
-
upQueue.push([callerId, depth + 1]);
|
|
1217
|
-
}
|
|
1218
|
-
}
|
|
1219
|
-
}
|
|
1220
|
-
return {
|
|
1221
|
-
target: entitySummary(targetEntity),
|
|
1222
|
-
mutation: args.mutation,
|
|
1223
|
-
affectedSymbolsCount: fallout.length,
|
|
1224
|
-
_summary: `Simulating adding [${addedTags.join(', ')}] to ${entityName(targetEntity)}. This structurally impacts ${fallout.length} upstream symbols.`,
|
|
1225
|
-
fallout,
|
|
1226
|
-
};
|
|
1227
|
-
}
|
|
1228
|
-
/** ε: adding/removing call edges — orphan detection + inherited contracts/fallibility. */
|
|
1229
|
-
function simulateEdgeMutation(target, change, g, mutation, loader, project) {
|
|
1230
|
-
const fallout = [];
|
|
1231
|
-
const contractsAdded = [];
|
|
1232
|
-
const contractsDropped = [];
|
|
1233
|
-
const orphanCandidates = [];
|
|
1234
|
-
const notFound = [];
|
|
1235
|
-
const resolve = (nameOrId) => g.entityById.get(nameOrId)
|
|
1236
|
-
|| (loader.getEntityById(nameOrId, project) ?? loader.getEntityByName(nameOrId, project) ?? undefined);
|
|
1237
|
-
for (const removed of change.remove_calls || []) {
|
|
1238
|
-
const callee = resolve(removed);
|
|
1239
|
-
if (!callee) {
|
|
1240
|
-
notFound.push(removed);
|
|
1241
|
-
continue;
|
|
1242
|
-
}
|
|
1243
|
-
// Orphan check: is the target the callee's ONLY in-graph caller?
|
|
1244
|
-
const callers = g.callers.get(callee.id) || new Set();
|
|
1245
|
-
const otherCallers = [...callers].filter(c => c !== target.id);
|
|
1246
|
-
if (otherCallers.length === 0) {
|
|
1247
|
-
orphanCandidates.push(callee.id);
|
|
1248
|
-
fallout.push({
|
|
1249
|
-
entity: entitySummary(callee),
|
|
1250
|
-
distance: 1,
|
|
1251
|
-
reason: `${entityName(target)} is its only caller — removing the call orphans it (dead-code candidate).`,
|
|
1252
|
-
requiredFix: `Delete ${callee.id} as well, or confirm a dynamic/runtime caller exists.`,
|
|
1253
|
-
});
|
|
1254
|
-
}
|
|
1255
|
-
// Contracts the target loses transitive access to via this callee
|
|
1256
|
-
for (const op of extractDbOps(callee)) {
|
|
1257
|
-
contractsDropped.push({ table: op.table, operation: op.operation, via: callee.id });
|
|
1258
|
-
}
|
|
1259
|
-
}
|
|
1260
|
-
for (const added of change.add_calls || []) {
|
|
1261
|
-
const callee = resolve(added);
|
|
1262
|
-
if (!callee) {
|
|
1263
|
-
notFound.push(added);
|
|
1264
|
-
continue;
|
|
1265
|
-
}
|
|
1266
|
-
// Inherited fallibility: caller must handle what the new callee can throw
|
|
1267
|
-
const calleeConstraints = callee.constraints;
|
|
1268
|
-
const calleeTraits = callee.traits;
|
|
1269
|
-
const calleeThrows = !!(calleeConstraints?.throws || calleeTraits?.self?.fallible);
|
|
1270
|
-
const targetConstraints = target.constraints;
|
|
1271
|
-
const targetCatches = !!(targetConstraints?.errorHandling
|
|
1272
|
-
&& (targetConstraints.errorHandling.tryCatch
|
|
1273
|
-
|| targetConstraints.errorHandling.catchClause
|
|
1274
|
-
|| targetConstraints.errorHandling.catches));
|
|
1275
|
-
if (calleeThrows && !targetCatches) {
|
|
1276
|
-
fallout.push({
|
|
1277
|
-
entity: entitySummary(target),
|
|
1278
|
-
distance: 0,
|
|
1279
|
-
reason: `New callee ${callee.id} can throw; ${entityName(target)} has no error handling.`,
|
|
1280
|
-
requiredFix: `Wrap the new call in try/catch or propagate (and re-check ${entityName(target)}'s callers).`,
|
|
1281
|
-
});
|
|
1282
|
-
}
|
|
1283
|
-
// Inherited data contracts: the target's δ surface grows
|
|
1284
|
-
for (const op of extractDbOps(callee)) {
|
|
1285
|
-
contractsAdded.push({ table: op.table, operation: op.operation, via: callee.id });
|
|
1286
|
-
}
|
|
1287
|
-
}
|
|
1288
|
-
const result = {
|
|
1289
|
-
target: entitySummary(target),
|
|
1290
|
-
mutation,
|
|
1291
|
-
affectedSymbolsCount: fallout.length,
|
|
1292
|
-
fallout,
|
|
1293
|
-
_summary: `Edge mutation on ${entityName(target)}: ${(change.add_calls || []).length} call(s) added, ` +
|
|
1294
|
-
`${(change.remove_calls || []).length} removed → ${orphanCandidates.length} orphan candidate(s), ` +
|
|
1295
|
-
`${contractsAdded.length} contract(s) inherited, ${contractsDropped.length} dropped.`,
|
|
1296
|
-
};
|
|
1297
|
-
if (contractsAdded.length > 0)
|
|
1298
|
-
result.contractsAdded = contractsAdded;
|
|
1299
|
-
if (contractsDropped.length > 0)
|
|
1300
|
-
result.contractsDropped = contractsDropped;
|
|
1301
|
-
if (orphanCandidates.length > 0)
|
|
1302
|
-
result.orphanCandidates = orphanCandidates;
|
|
1303
|
-
if (notFound.length > 0)
|
|
1304
|
-
result.notFound = notFound;
|
|
1305
|
-
return result;
|
|
1306
|
-
}
|
|
1307
|
-
/** δ: changing the target's table contract — who else shares that surface. */
|
|
1308
|
-
function simulateDataMutation(target, change, g, mutation) {
|
|
1309
|
-
const fallout = [];
|
|
1310
|
-
// Index every entity's table contract once
|
|
1311
|
-
const coTenants = new Map();
|
|
1312
|
-
for (const [, entity] of g.entityById) {
|
|
1313
|
-
if (entity.id === target.id)
|
|
1314
|
-
continue;
|
|
1315
|
-
for (const op of extractDbOps(entity)) {
|
|
1316
|
-
if (!coTenants.has(op.table))
|
|
1317
|
-
coTenants.set(op.table, []);
|
|
1318
|
-
coTenants.get(op.table).push({ entity, writes: op.type === 'db_mutate' });
|
|
1319
|
-
}
|
|
1320
|
-
}
|
|
1321
|
-
const WRITE_OPS = new Set(['insert', 'update', 'delete', 'upsert', 'create']);
|
|
1322
|
-
for (const { table, operation } of change.add_tables || []) {
|
|
1323
|
-
const isWrite = WRITE_OPS.has((operation || 'query').toLowerCase());
|
|
1324
|
-
const tenants = coTenants.get(table) || [];
|
|
1325
|
-
const seen = new Set();
|
|
1326
|
-
for (const { entity, writes } of tenants) {
|
|
1327
|
-
if (seen.has(entity.id))
|
|
1328
|
-
continue;
|
|
1329
|
-
seen.add(entity.id);
|
|
1330
|
-
// a new WRITE conflicts with everyone on the table; a new READ conflicts with writers
|
|
1331
|
-
if (isWrite || writes) {
|
|
1332
|
-
fallout.push({
|
|
1333
|
-
entity: entitySummary(entity),
|
|
1334
|
-
distance: 1,
|
|
1335
|
-
reason: `Shares the '${table}' contract (${writes ? 'writes' : 'reads'} it) — ` +
|
|
1336
|
-
`${entityName(target)} would now ${isWrite ? 'write' : 'read'} the same table.`,
|
|
1337
|
-
requiredFix: isWrite && writes
|
|
1338
|
-
? `Sequence changes with ${entity.id} or establish row/column ownership on '${table}'.`
|
|
1339
|
-
: `Verify ${entity.id} tolerates the new ${isWrite ? 'writes' : 'read dependency'} on '${table}'.`,
|
|
1340
|
-
});
|
|
1341
|
-
}
|
|
1342
|
-
}
|
|
1343
|
-
}
|
|
1344
|
-
for (const table of change.remove_tables || []) {
|
|
1345
|
-
const tenants = coTenants.get(table) || [];
|
|
1346
|
-
const seen = new Set();
|
|
1347
|
-
for (const { entity } of tenants) {
|
|
1348
|
-
if (seen.has(entity.id))
|
|
1349
|
-
continue;
|
|
1350
|
-
seen.add(entity.id);
|
|
1351
|
-
fallout.push({
|
|
1352
|
-
entity: entitySummary(entity),
|
|
1353
|
-
distance: 1,
|
|
1354
|
-
reason: `Remaining tenant of '${table}' after ${entityName(target)} exits the contract.`,
|
|
1355
|
-
requiredFix: `Confirm ${entity.id} does not depend on rows/state ${entityName(target)} was maintaining in '${table}'.`,
|
|
1356
|
-
});
|
|
1357
|
-
}
|
|
1358
|
-
}
|
|
1359
|
-
return {
|
|
1360
|
-
target: entitySummary(target),
|
|
1361
|
-
mutation,
|
|
1362
|
-
affectedSymbolsCount: fallout.length,
|
|
1363
|
-
fallout,
|
|
1364
|
-
_summary: `Data-contract mutation on ${entityName(target)}: ${fallout.length} co-tenant(s) of the affected table(s) to review.`,
|
|
1365
|
-
};
|
|
1366
|
-
}
|
|
1367
|
-
/** σ signature: every direct caller's call site breaks. */
|
|
1368
|
-
function simulateSignatureMutation(target, change, g, mutation) {
|
|
1369
|
-
const fallout = [];
|
|
1370
|
-
const callers = g.callers.get(target.id) || new Set();
|
|
1371
|
-
const added = change.params?.add || [];
|
|
1372
|
-
const removed = change.params?.remove || [];
|
|
1373
|
-
const desc = [
|
|
1374
|
-
added.length > 0 ? `+${added.join(', +')}` : null,
|
|
1375
|
-
removed.length > 0 ? `-${removed.join(', -')}` : null,
|
|
1376
|
-
].filter(Boolean).join(' ');
|
|
1377
|
-
for (const callerId of callers) {
|
|
1378
|
-
const caller = g.entityById.get(callerId);
|
|
1379
|
-
if (!caller)
|
|
1380
|
-
continue;
|
|
1381
|
-
fallout.push({
|
|
1382
|
-
entity: entitySummary(caller),
|
|
1383
|
-
distance: 1,
|
|
1384
|
-
reason: `Direct caller — call site breaks when the signature changes (${desc || 'params modified'}).`,
|
|
1385
|
-
requiredFix: `Update the call to ${entityName(target)} in ${caller._sourceFile || callerId}.`,
|
|
1386
|
-
});
|
|
1387
|
-
}
|
|
1388
|
-
const exported = typeof target.struct !== 'string' && !!target.struct?.exported;
|
|
1389
|
-
return {
|
|
1390
|
-
target: entitySummary(target),
|
|
1391
|
-
mutation,
|
|
1392
|
-
affectedSymbolsCount: fallout.length,
|
|
1393
|
-
fallout,
|
|
1394
|
-
...(exported ? { warning: 'Target is exported — external/dynamic callers outside this graph may also break.' } : {}),
|
|
1395
|
-
_summary: `Signature mutation on ${entityName(target)} (${desc || 'params'}): ${fallout.length} direct call site(s) must be updated.`,
|
|
1396
|
-
};
|
|
1397
|
-
}
|
|
1398
|
-
// ─── Tool: query_data_targets (Data Flow Analysis) ──────────────
|
|
1399
|
-
export function query_data_targets(args, loader) {
|
|
1400
|
-
const projErr = validateProject(args.project, loader);
|
|
1401
|
-
if (projErr)
|
|
1402
|
-
return { error: projErr };
|
|
1403
|
-
const entities = loader.getEntities(args.project);
|
|
1404
|
-
const target = args.target_name.toLowerCase();
|
|
1405
|
-
const readers = [];
|
|
1406
|
-
const writers = [];
|
|
1407
|
-
for (const e of entities) {
|
|
1408
|
-
if (!e.data)
|
|
1409
|
-
continue;
|
|
1410
|
-
let isReader = false;
|
|
1411
|
-
let isWriter = false;
|
|
1412
|
-
// Check tables
|
|
1413
|
-
if (Array.isArray(e.data.tables) && e.data.tables.some((t) => String(t).toLowerCase() === target)) {
|
|
1414
|
-
// By default, if they touch a table but don't specify mutation, we assume read
|
|
1415
|
-
isReader = true;
|
|
1416
|
-
}
|
|
1417
|
-
// Check inputs (sources)
|
|
1418
|
-
if (Array.isArray(e.data.inputs)) {
|
|
1419
|
-
if (e.data.inputs.some((i) => i && typeof i === 'object' && String(i.name || i.source).toLowerCase() === target)) {
|
|
1420
|
-
isReader = true;
|
|
1421
|
-
}
|
|
1422
|
-
}
|
|
1423
|
-
else if (Array.isArray(e.data.sources)) {
|
|
1424
|
-
if (e.data.sources.some((s) => String(s).toLowerCase() === target)) {
|
|
1425
|
-
isReader = true;
|
|
1426
|
-
}
|
|
1427
|
-
}
|
|
1428
|
-
// Check mutations
|
|
1429
|
-
if (Array.isArray(e.data.mutations)) {
|
|
1430
|
-
if (e.data.mutations.some((m) => m && typeof m === 'object' && String(m.target).toLowerCase() === target)) {
|
|
1431
|
-
isWriter = true;
|
|
1432
|
-
}
|
|
1433
|
-
}
|
|
1434
|
-
if (isWriter) {
|
|
1435
|
-
writers.push(entitySummary(e));
|
|
1436
|
-
}
|
|
1437
|
-
else if (isReader) {
|
|
1438
|
-
readers.push(entitySummary(e));
|
|
1439
|
-
}
|
|
1440
|
-
}
|
|
1441
|
-
return {
|
|
1442
|
-
target: args.target_name,
|
|
1443
|
-
totalInteractions: readers.length + writers.length,
|
|
1444
|
-
writersCount: writers.length,
|
|
1445
|
-
readersCount: readers.length,
|
|
1446
|
-
_summary: `Found ${writers.length} symbols mutating and ${readers.length} symbols reading data target '${args.target_name}'`,
|
|
1447
|
-
writers: writers.slice(0, 50),
|
|
1448
|
-
readers: readers.slice(0, 50),
|
|
1449
|
-
};
|
|
1450
|
-
}
|
|
1451
|
-
// ─── Tool: find_exposure_leaks (Architectural Visibility) ───────
|
|
1452
|
-
export function find_exposure_leaks(args, loader) {
|
|
1453
|
-
const projErr = validateProject(args?.project, loader);
|
|
1454
|
-
if (projErr)
|
|
1455
|
-
return { error: projErr };
|
|
1456
|
-
const g = getGraph(args?.project, loader);
|
|
1457
|
-
const leaks = [];
|
|
1458
|
-
for (const [callerId, calleeIds] of g.callees) {
|
|
1459
|
-
const callerEntity = g.entityById.get(callerId);
|
|
1460
|
-
if (!callerEntity)
|
|
1461
|
-
continue;
|
|
1462
|
-
const callerVisibility = callerEntity.context?.visibility || callerEntity.context?.exposure;
|
|
1463
|
-
if (callerVisibility !== 'public' && callerVisibility !== 'api')
|
|
1464
|
-
continue;
|
|
1465
|
-
for (const calleeId of calleeIds) {
|
|
1466
|
-
const calleeEntity = g.entityById.get(calleeId);
|
|
1467
|
-
if (!calleeEntity)
|
|
1468
|
-
continue;
|
|
1469
|
-
const calleeVisibility = calleeEntity.context?.visibility;
|
|
1470
|
-
if (calleeVisibility === 'private') {
|
|
1471
|
-
leaks.push({
|
|
1472
|
-
publicCaller: entitySummary(callerEntity),
|
|
1473
|
-
privateCallee: entitySummary(calleeEntity),
|
|
1474
|
-
issue: `Exposure Leak: Public/API symbol directly calls deeply private internal implementation.`,
|
|
1475
|
-
});
|
|
1476
|
-
}
|
|
1477
|
-
}
|
|
1478
|
-
}
|
|
1479
|
-
return {
|
|
1480
|
-
totalLeaks: leaks.length,
|
|
1481
|
-
_summary: `Found ${leaks.length} architectural visibility leaks where public paths bypass internal boundaries`,
|
|
1482
|
-
leaks: leaks.slice(0, 100),
|
|
1483
|
-
};
|
|
1484
|
-
}
|
|
1485
|
-
// ─── Tool: find_semantic_clones (Logic Analysis) ────────────────
|
|
1486
|
-
import { createHash } from 'crypto';
|
|
1487
|
-
export function find_semantic_clones(args, loader) {
|
|
1488
|
-
const projErr = validateProject(args?.project, loader);
|
|
1489
|
-
if (projErr)
|
|
1490
|
-
return { error: projErr };
|
|
1491
|
-
const entities = loader.getEntities(args?.project);
|
|
1492
|
-
const { min_complexity = 5 } = args || {};
|
|
1493
|
-
const logicHashes = new Map();
|
|
1494
|
-
for (const e of entities) {
|
|
1495
|
-
if (!e.semantics || !Array.isArray(e.semantics) || e.semantics.length < min_complexity) {
|
|
1496
|
-
continue;
|
|
1497
|
-
}
|
|
1498
|
-
// A highly simplified logic stringification that ignores specific variable names
|
|
1499
|
-
// but captures the structural AST shape.
|
|
1500
|
-
// In a production scenario, you would normalize variable identifiers ($1, $2) here.
|
|
1501
|
-
const shapeString = JSON.stringify(e.semantics, (key, value) => {
|
|
1502
|
-
// Omit line numbers and exact variable names to find structural clones
|
|
1503
|
-
if (key === 'loc' || key === 'name' || key === 'value' || key === 'id')
|
|
1504
|
-
return undefined;
|
|
1505
|
-
return value;
|
|
1506
|
-
});
|
|
1507
|
-
const hash = createHash('sha256').update(shapeString).digest('hex');
|
|
1508
|
-
if (!logicHashes.has(hash))
|
|
1509
|
-
logicHashes.set(hash, []);
|
|
1510
|
-
logicHashes.get(hash).push(e);
|
|
1511
|
-
}
|
|
1512
|
-
const clones = [];
|
|
1513
|
-
for (const [hash, group] of logicHashes.entries()) {
|
|
1514
|
-
if (group.length > 1) {
|
|
1515
|
-
clones.push({
|
|
1516
|
-
count: group.length,
|
|
1517
|
-
entities: group.map(e => entitySummary(e)),
|
|
1518
|
-
});
|
|
1519
|
-
}
|
|
1520
|
-
}
|
|
1521
|
-
// Sort by highest duplication count
|
|
1522
|
-
clones.sort((a, b) => b.count - a.count);
|
|
1523
|
-
let totalDuplicatedEntities = 0;
|
|
1524
|
-
for (const c of clones)
|
|
1525
|
-
totalDuplicatedEntities += c.count;
|
|
1526
|
-
return {
|
|
1527
|
-
cloneGroupsFound: clones.length,
|
|
1528
|
-
totalDuplicatedSymbols: totalDuplicatedEntities,
|
|
1529
|
-
_summary: `Found ${clones.length} logic clone groups involving ${totalDuplicatedEntities} symbols`,
|
|
1530
|
-
cloneGroups: clones.slice(0, 50),
|
|
1531
|
-
};
|
|
1532
|
-
}
|
|
1533
|
-
// ─── Tool: create_symbol (Virtual Entry) ─────────────────────────
|
|
1534
|
-
export function create_symbol(args, loader) {
|
|
1535
|
-
const projErr = validateProject(args.project, loader);
|
|
1536
|
-
if (projErr)
|
|
1537
|
-
return { error: projErr };
|
|
1538
|
-
// Create a skeleton JSTF-T entity
|
|
1539
|
-
const virtualEntity = {
|
|
1540
|
-
id: args.id,
|
|
1541
|
-
_sourceFile: args.source_file,
|
|
1542
|
-
sourceFile: args.source_file,
|
|
1543
|
-
context: {
|
|
1544
|
-
layer: args.layer || 'other',
|
|
1545
|
-
visibility: 'public',
|
|
1546
|
-
module: path.basename(args.source_file, path.extname(args.source_file)),
|
|
1547
|
-
},
|
|
1548
|
-
struct: {
|
|
1549
|
-
name: args.id,
|
|
1550
|
-
type: 'pending_creation'
|
|
1551
|
-
},
|
|
1552
|
-
constraints: {},
|
|
1553
|
-
traits: { self: {} },
|
|
1554
|
-
runtime: { framework: 'agnostic' },
|
|
1555
|
-
edges: { calls: [], imports: [] },
|
|
1556
|
-
semantics: [],
|
|
1557
|
-
_virtual: true,
|
|
1558
|
-
_description: args.description
|
|
1559
|
-
};
|
|
1560
|
-
loader.registerVirtualEntity(virtualEntity, args.project);
|
|
1561
|
-
return {
|
|
1562
|
-
success: true,
|
|
1563
|
-
id: args.id,
|
|
1564
|
-
sourceFile: args.source_file,
|
|
1565
|
-
_summary: `Registered virtual symbol '${args.id}' in ${args.source_file}. Other tools can now reason about this symbol prior to its physical creation.`,
|
|
1566
|
-
};
|
|
1567
|
-
}
|
|
1568
|
-
// ─── Tool: trace_boundaries (Cross-Project Boundary Surface Analysis) ────
|
|
1569
|
-
/**
|
|
1570
|
-
* Identify the boundary surface of a codebase — entities that face
|
|
1571
|
-
* outward (expose an API, accept network input, make network calls).
|
|
1572
|
-
*
|
|
1573
|
-
* When two loaders are provided, surfaces both boundary sets and
|
|
1574
|
-
* lets the LLM reason about the handshake between them.
|
|
1575
|
-
*/
|
|
1576
|
-
const IMPORT_STRUCT_TYPES = new Set([
|
|
1577
|
-
'import', 'importspecifier', 'importdeclaration', 'reexport',
|
|
1578
|
-
]);
|
|
1579
|
-
function isBoundaryEntity(e) {
|
|
1580
|
-
const structType = (typeof e.struct === 'string' ? e.struct : e.struct?.type || '').toLowerCase();
|
|
1581
|
-
if (IMPORT_STRUCT_TYPES.has(structType))
|
|
1582
|
-
return null;
|
|
1583
|
-
const ctx = e.context || {};
|
|
1584
|
-
const constraints = e.constraints;
|
|
1585
|
-
const sideEffects = (typeof constraints === 'object' && !Array.isArray(constraints))
|
|
1586
|
-
? constraints.sideEffects
|
|
1587
|
-
: undefined;
|
|
1588
|
-
// Inbound boundary: entities that receive external requests
|
|
1589
|
-
// Coordinates: exposure=api, layer=route/controller, or has auth constraints
|
|
1590
|
-
const isApiExposed = ctx.exposure === 'api' || ctx.exposure === 'public';
|
|
1591
|
-
const isRouteLayer = ctx.layer === 'route' || ctx.layer === 'controller';
|
|
1592
|
-
if (isApiExposed || isRouteLayer)
|
|
1593
|
-
return 'inbound';
|
|
1594
|
-
// Outbound boundary: entities that make external calls
|
|
1595
|
-
// Coordinates: sideEffects includes 'network', or data sources include 'api'/'fetch'
|
|
1596
|
-
const hasNetworkIO = Array.isArray(sideEffects) && sideEffects.includes('network');
|
|
1597
|
-
const dataSources = e.data?.sources || [];
|
|
1598
|
-
const hasApiSource = dataSources.some(s => typeof s === 'string' && ['api', 'fetch', 'network'].includes(s));
|
|
1599
|
-
if (hasNetworkIO || hasApiSource)
|
|
1600
|
-
return 'outbound';
|
|
1601
|
-
return null;
|
|
1602
|
-
}
|
|
1603
|
-
function boundaryEntitySummary(e) {
|
|
1604
|
-
const base = entitySummary(e);
|
|
1605
|
-
const ctx = e.context || {};
|
|
1606
|
-
const constraints = e.constraints;
|
|
1607
|
-
const sideEffects = (typeof constraints === 'object' && !Array.isArray(constraints))
|
|
1608
|
-
? constraints.sideEffects
|
|
1609
|
-
: undefined;
|
|
1610
|
-
const dataSources = e.data?.sources || [];
|
|
1611
|
-
// Add boundary-relevant coordinate data
|
|
1612
|
-
if (ctx.exposure)
|
|
1613
|
-
base.exposure = ctx.exposure;
|
|
1614
|
-
if (sideEffects && sideEffects.length > 0)
|
|
1615
|
-
base.sideEffects = sideEffects;
|
|
1616
|
-
if (dataSources.length > 0)
|
|
1617
|
-
base.dataSources = dataSources;
|
|
1618
|
-
// Error handling info (relevant for boundary robustness)
|
|
1619
|
-
const errorHandling = (typeof constraints === 'object' && !Array.isArray(constraints))
|
|
1620
|
-
? constraints.errorHandling
|
|
1621
|
-
: undefined;
|
|
1622
|
-
if (errorHandling)
|
|
1623
|
-
base.errorHandling = errorHandling;
|
|
1624
|
-
return base;
|
|
1625
|
-
}
|
|
1626
|
-
export function trace_boundaries(args, loaderA, loaderB) {
|
|
1627
|
-
const entitiesA = loaderA.getEntities(args.project_a);
|
|
1628
|
-
const topologyA = loaderA.getTopology(args.project_a);
|
|
1629
|
-
const surfaceA = {
|
|
1630
|
-
inbound: [], outbound: [],
|
|
1631
|
-
};
|
|
1632
|
-
for (const e of entitiesA) {
|
|
1633
|
-
const direction = isBoundaryEntity(e);
|
|
1634
|
-
if (direction) {
|
|
1635
|
-
surfaceA[direction].push(boundaryEntitySummary(e));
|
|
1636
|
-
}
|
|
1637
|
-
}
|
|
1638
|
-
const result = {
|
|
1639
|
-
project_a: {
|
|
1640
|
-
name: args.project_a,
|
|
1641
|
-
totalEntities: entitiesA.length,
|
|
1642
|
-
boundary: {
|
|
1643
|
-
inbound: surfaceA.inbound.length,
|
|
1644
|
-
outbound: surfaceA.outbound.length,
|
|
1645
|
-
},
|
|
1646
|
-
inbound_surface: surfaceA.inbound.slice(0, 100),
|
|
1647
|
-
outbound_surface: surfaceA.outbound.slice(0, 100),
|
|
1648
|
-
},
|
|
1649
|
-
};
|
|
1650
|
-
// Include topology routes if available (structured route data)
|
|
1651
|
-
if (topologyA) {
|
|
1652
|
-
const routes = [];
|
|
1653
|
-
const prefixes = topologyA.prefixes;
|
|
1654
|
-
if (prefixes) {
|
|
1655
|
-
for (const [prefix, info] of Object.entries(prefixes)) {
|
|
1656
|
-
const routeList = info.routes;
|
|
1657
|
-
if (Array.isArray(routeList)) {
|
|
1658
|
-
for (const r of routeList) {
|
|
1659
|
-
routes.push({ ...r, prefix });
|
|
1660
|
-
}
|
|
1661
|
-
}
|
|
1662
|
-
}
|
|
1663
|
-
}
|
|
1664
|
-
const unregistered = topologyA.unregistered;
|
|
1665
|
-
if (Array.isArray(unregistered)) {
|
|
1666
|
-
routes.push(...unregistered);
|
|
1667
|
-
}
|
|
1668
|
-
if (routes.length > 0) {
|
|
1669
|
-
result.project_a.topology_routes = routes.slice(0, 100);
|
|
1670
|
-
}
|
|
1671
|
-
}
|
|
1672
|
-
// If second project provided, surface its boundaries too
|
|
1673
|
-
if (loaderB && args.project_b) {
|
|
1674
|
-
const entitiesB = loaderB.getEntities(args.project_b);
|
|
1675
|
-
const topologyB = loaderB.getTopology(args.project_b);
|
|
1676
|
-
const surfaceB = {
|
|
1677
|
-
inbound: [], outbound: [],
|
|
1678
|
-
};
|
|
1679
|
-
for (const e of entitiesB) {
|
|
1680
|
-
const direction = isBoundaryEntity(e);
|
|
1681
|
-
if (direction) {
|
|
1682
|
-
surfaceB[direction].push(boundaryEntitySummary(e));
|
|
1683
|
-
}
|
|
1684
|
-
}
|
|
1685
|
-
result.project_b = {
|
|
1686
|
-
name: args.project_b,
|
|
1687
|
-
totalEntities: entitiesB.length,
|
|
1688
|
-
boundary: {
|
|
1689
|
-
inbound: surfaceB.inbound.length,
|
|
1690
|
-
outbound: surfaceB.outbound.length,
|
|
1691
|
-
},
|
|
1692
|
-
inbound_surface: surfaceB.inbound.slice(0, 100),
|
|
1693
|
-
outbound_surface: surfaceB.outbound.slice(0, 100),
|
|
1694
|
-
};
|
|
1695
|
-
if (topologyB) {
|
|
1696
|
-
const routes = [];
|
|
1697
|
-
const prefixes = topologyB.prefixes;
|
|
1698
|
-
if (prefixes) {
|
|
1699
|
-
for (const [prefix, info] of Object.entries(prefixes)) {
|
|
1700
|
-
const routeList = info.routes;
|
|
1701
|
-
if (Array.isArray(routeList)) {
|
|
1702
|
-
for (const r of routeList) {
|
|
1703
|
-
routes.push({ ...r, prefix });
|
|
1704
|
-
}
|
|
1705
|
-
}
|
|
1706
|
-
}
|
|
1707
|
-
}
|
|
1708
|
-
const unregistered = topologyB.unregistered;
|
|
1709
|
-
if (Array.isArray(unregistered)) {
|
|
1710
|
-
routes.push(...unregistered);
|
|
1711
|
-
}
|
|
1712
|
-
if (routes.length > 0) {
|
|
1713
|
-
result.project_b.topology_routes = routes.slice(0, 100);
|
|
1714
|
-
}
|
|
1715
|
-
}
|
|
1716
|
-
// Summary for cross-project analysis
|
|
1717
|
-
const aIn = surfaceA.inbound.length;
|
|
1718
|
-
const aOut = surfaceA.outbound.length;
|
|
1719
|
-
const bIn = surfaceB.inbound.length;
|
|
1720
|
-
const bOut = surfaceB.outbound.length;
|
|
1721
|
-
result._summary = `${args.project_a}: ${aIn} inbound + ${aOut} outbound boundary entities. ${args.project_b}: ${bIn} inbound + ${bOut} outbound boundary entities. Examine inbound surfaces (API handlers) against outbound surfaces (network callers) to trace the handshake.`;
|
|
1722
|
-
}
|
|
1723
|
-
else {
|
|
1724
|
-
result._summary = `${args.project_a}: ${surfaceA.inbound.length} inbound + ${surfaceA.outbound.length} outbound boundary entities.`;
|
|
1725
|
-
}
|
|
1726
|
-
return result;
|
|
1727
|
-
}
|