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