@papyruslabsai/seshat-mcp 0.16.10 → 0.17.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/graph.d.ts +12 -0
- package/dist/graph.js +132 -77
- package/dist/index.js +86 -67
- package/dist/loader.js +8 -2
- package/dist/tools/dbops.d.ts +24 -0
- package/dist/tools/dbops.js +78 -0
- package/dist/tools/diff.js +125 -28
- package/dist/tools/functors.d.ts +12 -1
- package/dist/tools/functors.js +184 -0
- package/dist/tools/index.d.ts +1 -0
- package/dist/tools/index.js +2 -0
- package/package.json +1 -1
package/dist/graph.d.ts
CHANGED
|
@@ -12,6 +12,12 @@ export interface CallGraph {
|
|
|
12
12
|
callers: Map<string, Set<string>>;
|
|
13
13
|
callees: Map<string, Set<string>>;
|
|
14
14
|
entityById: Map<string, JstfEntity>;
|
|
15
|
+
/** Reference-resolution outcomes — honest sparsity as a reported number. */
|
|
16
|
+
resolutionStats?: {
|
|
17
|
+
resolved: number;
|
|
18
|
+
ambiguous: number;
|
|
19
|
+
unknown: number;
|
|
20
|
+
};
|
|
15
21
|
}
|
|
16
22
|
export interface BlastRadiusResult {
|
|
17
23
|
affected: string[];
|
|
@@ -21,6 +27,12 @@ export interface BlastRadiusResult {
|
|
|
21
27
|
}
|
|
22
28
|
/**
|
|
23
29
|
* Build a bidirectional call graph from entities' dependency data.
|
|
30
|
+
*
|
|
31
|
+
* Reference resolution is identity-honest: a bare-name target resolves to the
|
|
32
|
+
* unique entity with that local name, or the unique same-file match when the
|
|
33
|
+
* name is ambiguous repo-wide. A reference that stays ambiguous produces NO
|
|
34
|
+
* edge — first-wins matching used to silently attribute edges to whichever
|
|
35
|
+
* same-named entity was indexed first.
|
|
24
36
|
*/
|
|
25
37
|
export declare function buildCallGraph(entities: JstfEntity[]): CallGraph;
|
|
26
38
|
/**
|
package/dist/graph.js
CHANGED
|
@@ -7,14 +7,34 @@
|
|
|
7
7
|
* Ancestors = all transitive callers (upstream).
|
|
8
8
|
* Descendants = all transitive callees (downstream).
|
|
9
9
|
*/
|
|
10
|
+
/**
|
|
11
|
+
* Local part of a (possibly file-qualified) entity id.
|
|
12
|
+
* `src/rooms/participants.js#addParticipant~2` → `addParticipant`.
|
|
13
|
+
*/
|
|
14
|
+
function localIdOf(id) {
|
|
15
|
+
const i = id.lastIndexOf('#');
|
|
16
|
+
const local = i === -1 ? id : id.slice(i + 1);
|
|
17
|
+
return local.replace(/~\d+$/, '');
|
|
18
|
+
}
|
|
19
|
+
function sourceFileOf(entity) {
|
|
20
|
+
return entity.sourceFile || entity._sourceFile || null;
|
|
21
|
+
}
|
|
10
22
|
/**
|
|
11
23
|
* Build a bidirectional call graph from entities' dependency data.
|
|
24
|
+
*
|
|
25
|
+
* Reference resolution is identity-honest: a bare-name target resolves to the
|
|
26
|
+
* unique entity with that local name, or the unique same-file match when the
|
|
27
|
+
* name is ambiguous repo-wide. A reference that stays ambiguous produces NO
|
|
28
|
+
* edge — first-wins matching used to silently attribute edges to whichever
|
|
29
|
+
* same-named entity was indexed first.
|
|
12
30
|
*/
|
|
13
31
|
export function buildCallGraph(entities) {
|
|
14
32
|
const callers = new Map();
|
|
15
33
|
const callees = new Map();
|
|
16
34
|
const entityById = new Map();
|
|
17
35
|
const entityByModule = new Map();
|
|
36
|
+
const byLocal = new Map(); // local name → entities
|
|
37
|
+
const bySuffix = new Map(); // last dotted segment of local → entities
|
|
18
38
|
// Index entities
|
|
19
39
|
for (const entity of entities) {
|
|
20
40
|
if (!entity.id)
|
|
@@ -22,6 +42,16 @@ export function buildCallGraph(entities) {
|
|
|
22
42
|
entityById.set(entity.id, entity);
|
|
23
43
|
callers.set(entity.id, new Set());
|
|
24
44
|
callees.set(entity.id, new Set());
|
|
45
|
+
const local = localIdOf(entity.id);
|
|
46
|
+
if (!byLocal.has(local))
|
|
47
|
+
byLocal.set(local, []);
|
|
48
|
+
byLocal.get(local).push(entity);
|
|
49
|
+
const suffix = local.includes('.') ? local.split('.').pop() : null;
|
|
50
|
+
if (suffix) {
|
|
51
|
+
if (!bySuffix.has(suffix))
|
|
52
|
+
bySuffix.set(suffix, []);
|
|
53
|
+
bySuffix.get(suffix).push(entity);
|
|
54
|
+
}
|
|
25
55
|
if (entity.context?.module) {
|
|
26
56
|
const mod = entity.context.module;
|
|
27
57
|
if (!entityByModule.has(mod))
|
|
@@ -29,93 +59,125 @@ export function buildCallGraph(entities) {
|
|
|
29
59
|
entityByModule.get(mod).push(entity);
|
|
30
60
|
}
|
|
31
61
|
}
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
if (
|
|
37
|
-
|
|
62
|
+
/** Unique candidate, or unique same-file candidate, else null. */
|
|
63
|
+
function pick(candidates, contextFile) {
|
|
64
|
+
if (!candidates || candidates.length === 0)
|
|
65
|
+
return null;
|
|
66
|
+
if (candidates.length === 1)
|
|
67
|
+
return candidates[0];
|
|
68
|
+
if (contextFile) {
|
|
69
|
+
const same = candidates.filter(e => sourceFileOf(e) === contextFile);
|
|
70
|
+
if (same.length === 1)
|
|
71
|
+
return same[0];
|
|
72
|
+
}
|
|
73
|
+
return null;
|
|
38
74
|
}
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
const
|
|
43
|
-
if (
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
75
|
+
const stats = { resolved: 0, ambiguous: 0, unknown: 0 };
|
|
76
|
+
/** Resolve a reference string to an entity id, with file context. */
|
|
77
|
+
function resolveRef(target, contextFile) {
|
|
78
|
+
const found = (id) => { stats.resolved++; return id; };
|
|
79
|
+
if (entityById.has(target))
|
|
80
|
+
return found(target);
|
|
81
|
+
const direct = pick(byLocal.get(target), contextFile);
|
|
82
|
+
if (direct)
|
|
83
|
+
return found(direct.id);
|
|
84
|
+
if (target.includes('.')) {
|
|
85
|
+
const dotIdx = target.indexOf('.');
|
|
86
|
+
const modulePart = target.substring(0, dotIdx);
|
|
87
|
+
const methodPart = target.substring(dotIdx + 1);
|
|
88
|
+
const moduleEntities = entityByModule.get(modulePart);
|
|
89
|
+
if (moduleEntities) {
|
|
90
|
+
const matches = moduleEntities.filter(e => {
|
|
91
|
+
const name = typeof e.struct === 'string' ? e.struct : e.struct?.name;
|
|
92
|
+
return localIdOf(e.id) === methodPart || name === methodPart;
|
|
93
|
+
});
|
|
94
|
+
const m = pick(matches, contextFile);
|
|
95
|
+
if (m)
|
|
96
|
+
return found(m.id);
|
|
97
|
+
}
|
|
98
|
+
const method = pick(byLocal.get(methodPart), contextFile);
|
|
99
|
+
if (method)
|
|
100
|
+
return found(method.id);
|
|
49
101
|
}
|
|
102
|
+
const suffixed = pick(bySuffix.get(target), contextFile);
|
|
103
|
+
if (suffixed)
|
|
104
|
+
return found(suffixed.id);
|
|
105
|
+
const hadCandidates = (byLocal.get(target)?.length || 0) + (bySuffix.get(target)?.length || 0) > 1;
|
|
106
|
+
if (hadCandidates)
|
|
107
|
+
stats.ambiguous++;
|
|
108
|
+
else
|
|
109
|
+
stats.unknown++;
|
|
110
|
+
return null;
|
|
111
|
+
}
|
|
112
|
+
// File index for import resolution: repo-relative path (sans extension) → entities
|
|
113
|
+
const byFileBase = new Map();
|
|
114
|
+
for (const entity of entities) {
|
|
115
|
+
const f = sourceFileOf(entity);
|
|
116
|
+
if (!f)
|
|
117
|
+
continue;
|
|
118
|
+
const base = f.replace(/\.[^./]+$/, '');
|
|
119
|
+
if (!byFileBase.has(base))
|
|
120
|
+
byFileBase.set(base, []);
|
|
121
|
+
byFileBase.get(base).push(entity);
|
|
122
|
+
}
|
|
123
|
+
/** `src/app.js` + `./rooms/participants.js` → `src/rooms/participants` (extensionless). */
|
|
124
|
+
function resolveRelativeImport(importerFile, source) {
|
|
125
|
+
const stack = importerFile.split('/').slice(0, -1);
|
|
126
|
+
for (const part of source.split('/')) {
|
|
127
|
+
if (part === '.' || part === '')
|
|
128
|
+
continue;
|
|
129
|
+
if (part === '..') {
|
|
130
|
+
if (stack.length === 0)
|
|
131
|
+
return null;
|
|
132
|
+
stack.pop();
|
|
133
|
+
continue;
|
|
134
|
+
}
|
|
135
|
+
stack.push(part);
|
|
136
|
+
}
|
|
137
|
+
return stack.join('/').replace(/\.[^./]+$/, '');
|
|
50
138
|
}
|
|
51
139
|
// Build call edges from dependency data
|
|
52
140
|
for (const caller of entities) {
|
|
53
141
|
if (!caller.id || !caller.edges)
|
|
54
142
|
continue;
|
|
143
|
+
const callerFile = sourceFileOf(caller);
|
|
55
144
|
// Process direct function/method calls
|
|
56
145
|
const callsArray = caller.edges.calls;
|
|
57
146
|
if (Array.isArray(callsArray)) {
|
|
58
147
|
for (const call of callsArray) {
|
|
59
148
|
if (!call.target)
|
|
60
149
|
continue;
|
|
61
|
-
const
|
|
62
|
-
let calleeId = null;
|
|
63
|
-
// Strategy 1: Exact match
|
|
64
|
-
if (entityById.has(target)) {
|
|
65
|
-
calleeId = target;
|
|
66
|
-
}
|
|
67
|
-
// Strategy 2: Method match (target = "module.method")
|
|
68
|
-
else if (target.includes('.')) {
|
|
69
|
-
const dotIdx = target.indexOf('.');
|
|
70
|
-
const modulePart = target.substring(0, dotIdx);
|
|
71
|
-
const methodPart = target.substring(dotIdx + 1);
|
|
72
|
-
// First try "module.method" as a full entity ID
|
|
73
|
-
if (entityById.has(target)) {
|
|
74
|
-
calleeId = target;
|
|
75
|
-
}
|
|
76
|
-
// Search within the module's entities for one named methodPart
|
|
77
|
-
if (!calleeId) {
|
|
78
|
-
const moduleEntities = entityByModule.get(modulePart);
|
|
79
|
-
if (moduleEntities) {
|
|
80
|
-
const match = moduleEntities.find(e => {
|
|
81
|
-
const name = typeof e.struct === 'string' ? e.struct : e.struct?.name;
|
|
82
|
-
return e.id === methodPart || name === methodPart;
|
|
83
|
-
});
|
|
84
|
-
if (match) {
|
|
85
|
-
calleeId = match.id;
|
|
86
|
-
}
|
|
87
|
-
}
|
|
88
|
-
}
|
|
89
|
-
// Also try "module.method" concatenated as an ID
|
|
90
|
-
if (!calleeId && entityById.has(`${modulePart}.${methodPart}`)) {
|
|
91
|
-
calleeId = `${modulePart}.${methodPart}`;
|
|
92
|
-
}
|
|
93
|
-
}
|
|
94
|
-
// Strategy 3: Suffix match via index (O(1) lookup)
|
|
95
|
-
if (!calleeId) {
|
|
96
|
-
calleeId = bySuffix.get(target) || null;
|
|
97
|
-
}
|
|
150
|
+
const calleeId = resolveRef(call.target, callerFile);
|
|
98
151
|
if (calleeId && calleeId !== caller.id) {
|
|
99
152
|
callees.get(caller.id)?.add(calleeId);
|
|
100
153
|
callers.get(calleeId)?.add(caller.id);
|
|
101
154
|
}
|
|
102
155
|
}
|
|
103
156
|
}
|
|
104
|
-
// Process lexical imports
|
|
157
|
+
// Process lexical imports — resolve the import PATH against the importer's
|
|
158
|
+
// file, then link each specifier to the same-named entity in the resolved
|
|
159
|
+
// file. Basename matching used to link any same-named file first-wins.
|
|
105
160
|
const importsArray = caller.edges.imports;
|
|
106
|
-
if (Array.isArray(importsArray)) {
|
|
161
|
+
if (Array.isArray(importsArray) && callerFile) {
|
|
107
162
|
for (const imp of importsArray) {
|
|
108
|
-
// If it's a module-level entity, we link to the module
|
|
109
|
-
let calleeId = null;
|
|
110
163
|
const source = imp.source || imp.module;
|
|
111
|
-
if (!source)
|
|
164
|
+
if (!source || !source.startsWith('.'))
|
|
165
|
+
continue; // package import — no repo entity
|
|
166
|
+
const resolvedBase = resolveRelativeImport(callerFile, source);
|
|
167
|
+
if (!resolvedBase)
|
|
112
168
|
continue;
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
169
|
+
const targetEntities = byFileBase.get(resolvedBase) || byFileBase.get(`${resolvedBase}/index`);
|
|
170
|
+
if (!targetEntities)
|
|
171
|
+
continue;
|
|
172
|
+
const names = (imp.specifiers || [])
|
|
173
|
+
.map((s) => s.imported || s.local)
|
|
174
|
+
.filter(Boolean);
|
|
175
|
+
for (const name of names) {
|
|
176
|
+
const match = targetEntities.find(e => localIdOf(e.id) === name);
|
|
177
|
+
if (match && match.id !== caller.id) {
|
|
178
|
+
callees.get(caller.id)?.add(match.id);
|
|
179
|
+
callers.get(match.id)?.add(caller.id);
|
|
180
|
+
}
|
|
119
181
|
}
|
|
120
182
|
}
|
|
121
183
|
}
|
|
@@ -123,19 +185,12 @@ export function buildCallGraph(entities) {
|
|
|
123
185
|
const calledByArray = caller.edges.calledBy;
|
|
124
186
|
if (Array.isArray(calledByArray)) {
|
|
125
187
|
for (const cb of calledByArray) {
|
|
126
|
-
|
|
127
|
-
if (!source)
|
|
188
|
+
if (!cb.source)
|
|
128
189
|
continue;
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
else {
|
|
134
|
-
// Suffix match via index (O(1) lookup)
|
|
135
|
-
callerId = bySuffix.get(source) || null;
|
|
136
|
-
}
|
|
137
|
-
// In this relationship, the current entity (`caller` in this loop context, despite the name)
|
|
138
|
-
// is actually the CALLEE, and the `source` is the true CALLER.
|
|
190
|
+
// In this relationship, the current entity (`caller` in this loop
|
|
191
|
+
// context, despite the name) is actually the CALLEE, and the
|
|
192
|
+
// `source` is the true CALLER.
|
|
193
|
+
const callerId = resolveRef(cb.source, callerFile);
|
|
139
194
|
if (callerId && callerId !== caller.id) {
|
|
140
195
|
callees.get(callerId)?.add(caller.id); // true caller calls this entity
|
|
141
196
|
callers.get(caller.id)?.add(callerId); // this entity is called by true caller
|
|
@@ -143,7 +198,7 @@ export function buildCallGraph(entities) {
|
|
|
143
198
|
}
|
|
144
199
|
}
|
|
145
200
|
}
|
|
146
|
-
return { callers, callees, entityById };
|
|
201
|
+
return { callers, callees, entityById, resolutionStats: stats };
|
|
147
202
|
}
|
|
148
203
|
/**
|
|
149
204
|
* Compute blast radius: all entities affected by changes to the given IDs.
|
package/dist/index.js
CHANGED
|
@@ -59,67 +59,67 @@ if (process.argv[2] === 'setup') {
|
|
|
59
59
|
}
|
|
60
60
|
// ─── Server Instructions ─────────────────────────────────────────
|
|
61
61
|
// Sent to the LLM at connection time. This is the "first contact" pitch.
|
|
62
|
-
const SERVER_INSTRUCTIONS = `Seshat provides structural code analysis backed by a compiled intermediate representation — not heuristic guesses or text search. Every function, class, and route in the synced codebase has been extracted into a typed symbol graph with dependency edges, data flow, constraints, and architectural layer tags. Results are precise and complete — if Seshat says a function has 3 callers, it has exactly 3 callers.
|
|
63
|
-
|
|
64
|
-
HOW TO USE SESHAT — Seshat tools are designed for iterative exploration, not one-shot lookups. A single tool call answers a single structural question. Understanding a system requires several calls that build on each other — the same way a senior developer investigates code before changing it.
|
|
65
|
-
|
|
66
|
-
A typical investigation:
|
|
67
|
-
1. query_entities or list_modules → orient yourself in the codebase
|
|
68
|
-
2. get_entity → deep-dive the function you care about
|
|
69
|
-
3. get_blast_radius → discover what's connected and what could break
|
|
70
|
-
4. get_dependencies or get_data_flow → trace specific edges
|
|
71
|
-
5. get_optimal_context → decide what source to read before making changes
|
|
72
|
-
6. Repeat steps 2-5 on newly discovered symbols until you have a complete picture
|
|
73
|
-
|
|
74
|
-
Each call reveals structure that informs the next. The goal is understanding, not minimum calls. 5-15 queries per investigation is normal and expected.
|
|
75
|
-
|
|
76
|
-
All tools are read-only and safe to call at any time.
|
|
77
|
-
|
|
78
|
-
GETTING STARTED — If list_projects returns empty, the current project hasn't been synced yet. Use the sync_project tool to import it:
|
|
79
|
-
1. Detect the git remote: run \`git remote get-url origin\` in the user's terminal
|
|
80
|
-
2. Call sync_project with that repo URL
|
|
81
|
-
3. Wait for extraction to complete (typically 5-30 seconds depending on repo size)
|
|
82
|
-
4. Then call list_projects again — the project will now appear
|
|
83
|
-
Note: For private repos, sync_project will return a GitHub authorization URL. Direct the user to open it in their browser to connect their GitHub account, then retry sync_project.
|
|
84
|
-
|
|
85
|
-
TOOL REFERENCE — Each tool maps to a structural question:
|
|
86
|
-
|
|
87
|
-
Setup & Navigation:
|
|
88
|
-
- "What projects are loaded?" → list_projects
|
|
89
|
-
- "Sync this repo to Seshat" → sync_project
|
|
90
|
-
- "How is the codebase organized?" → list_modules
|
|
91
|
-
- "What's the full API surface?" → get_topology
|
|
92
|
-
- "What tier am I on?" → get_account_status
|
|
93
|
-
|
|
94
|
-
Understanding Code:
|
|
95
|
-
- "Find functions by name or layer" → query_entities
|
|
96
|
-
- "Deep-dive a single function" → get_entity
|
|
97
|
-
- "Who calls this / what does it call?" → get_dependencies
|
|
98
|
-
- "What data does this read/write/mutate?" → get_data_flow
|
|
99
|
-
- "What should I read before modifying X?" → get_optimal_context
|
|
100
|
-
- "Which functions touch the DB / require auth / throw?" → find_by_constraint
|
|
101
|
-
- "What reads or writes the 'users' table?" → find_by_constraint(table="users")
|
|
102
|
-
- "Find functions that can fail, including transitively" → query_traits(trait="fallible")
|
|
103
|
-
|
|
104
|
-
Change Planning:
|
|
105
|
-
- "What breaks if I change this?" → get_blast_radius
|
|
106
|
-
- "Is there dead code I can safely delete?" → find_dead_code
|
|
107
|
-
- "Is there copy-pasted logic I should consolidate?" → find_semantic_clones
|
|
108
|
-
|
|
109
|
-
Security & Quality Audits:
|
|
110
|
-
- "Which endpoints require auth and which don't?" → get_auth_matrix
|
|
111
|
-
- "Where is sensitive data exposed without protection?" → find_exposure_leaks
|
|
112
|
-
- "Where are errors thrown but never caught?" → find_error_gaps
|
|
113
|
-
- "Are there architecture violations?" → find_layer_violations
|
|
114
|
-
- "Does framework-agnostic code import framework-specific code?" → find_runtime_violations
|
|
115
|
-
- "Are there memory/lifecycle/ownership issues?" → find_ownership_violations
|
|
116
|
-
|
|
117
|
-
Metrics:
|
|
118
|
-
- "How coupled is the codebase?" → get_coupling_metrics
|
|
119
|
-
- "Which functions are tested and which aren't?" → get_test_coverage
|
|
120
|
-
|
|
121
|
-
TEMPORAL ANALYSIS — Any tool accepts an optional temporal parameter: { temporal: { last_n: N } }. This runs the tool across recent snapshots and returns a trend. Requires at least 2 snapshots. Example:
|
|
122
|
-
get_coupling_metrics({ project: "myapp", temporal: { last_n: 5 } })
|
|
62
|
+
const SERVER_INSTRUCTIONS = `Seshat provides structural code analysis backed by a compiled intermediate representation — not heuristic guesses or text search. Every function, class, and route in the synced codebase has been extracted into a typed symbol graph with dependency edges, data flow, constraints, and architectural layer tags. Results are precise and complete — if Seshat says a function has 3 callers, it has exactly 3 callers.
|
|
63
|
+
|
|
64
|
+
HOW TO USE SESHAT — Seshat tools are designed for iterative exploration, not one-shot lookups. A single tool call answers a single structural question. Understanding a system requires several calls that build on each other — the same way a senior developer investigates code before changing it.
|
|
65
|
+
|
|
66
|
+
A typical investigation:
|
|
67
|
+
1. query_entities or list_modules → orient yourself in the codebase
|
|
68
|
+
2. get_entity → deep-dive the function you care about
|
|
69
|
+
3. get_blast_radius → discover what's connected and what could break
|
|
70
|
+
4. get_dependencies or get_data_flow → trace specific edges
|
|
71
|
+
5. get_optimal_context → decide what source to read before making changes
|
|
72
|
+
6. Repeat steps 2-5 on newly discovered symbols until you have a complete picture
|
|
73
|
+
|
|
74
|
+
Each call reveals structure that informs the next. The goal is understanding, not minimum calls. 5-15 queries per investigation is normal and expected.
|
|
75
|
+
|
|
76
|
+
All tools are read-only and safe to call at any time.
|
|
77
|
+
|
|
78
|
+
GETTING STARTED — If list_projects returns empty, the current project hasn't been synced yet. Use the sync_project tool to import it:
|
|
79
|
+
1. Detect the git remote: run \`git remote get-url origin\` in the user's terminal
|
|
80
|
+
2. Call sync_project with that repo URL
|
|
81
|
+
3. Wait for extraction to complete (typically 5-30 seconds depending on repo size)
|
|
82
|
+
4. Then call list_projects again — the project will now appear
|
|
83
|
+
Note: For private repos, sync_project will return a GitHub authorization URL. Direct the user to open it in their browser to connect their GitHub account, then retry sync_project.
|
|
84
|
+
|
|
85
|
+
TOOL REFERENCE — Each tool maps to a structural question:
|
|
86
|
+
|
|
87
|
+
Setup & Navigation:
|
|
88
|
+
- "What projects are loaded?" → list_projects
|
|
89
|
+
- "Sync this repo to Seshat" → sync_project
|
|
90
|
+
- "How is the codebase organized?" → list_modules
|
|
91
|
+
- "What's the full API surface?" → get_topology
|
|
92
|
+
- "What tier am I on?" → get_account_status
|
|
93
|
+
|
|
94
|
+
Understanding Code:
|
|
95
|
+
- "Find functions by name or layer" → query_entities
|
|
96
|
+
- "Deep-dive a single function" → get_entity
|
|
97
|
+
- "Who calls this / what does it call?" → get_dependencies
|
|
98
|
+
- "What data does this read/write/mutate?" → get_data_flow
|
|
99
|
+
- "What should I read before modifying X?" → get_optimal_context
|
|
100
|
+
- "Which functions touch the DB / require auth / throw?" → find_by_constraint
|
|
101
|
+
- "What reads or writes the 'users' table?" → find_by_constraint(table="users")
|
|
102
|
+
- "Find functions that can fail, including transitively" → query_traits(trait="fallible")
|
|
103
|
+
|
|
104
|
+
Change Planning:
|
|
105
|
+
- "What breaks if I change this?" → get_blast_radius
|
|
106
|
+
- "Is there dead code I can safely delete?" → find_dead_code
|
|
107
|
+
- "Is there copy-pasted logic I should consolidate?" → find_semantic_clones
|
|
108
|
+
|
|
109
|
+
Security & Quality Audits:
|
|
110
|
+
- "Which endpoints require auth and which don't?" → get_auth_matrix
|
|
111
|
+
- "Where is sensitive data exposed without protection?" → find_exposure_leaks
|
|
112
|
+
- "Where are errors thrown but never caught?" → find_error_gaps
|
|
113
|
+
- "Are there architecture violations?" → find_layer_violations
|
|
114
|
+
- "Does framework-agnostic code import framework-specific code?" → find_runtime_violations
|
|
115
|
+
- "Are there memory/lifecycle/ownership issues?" → find_ownership_violations
|
|
116
|
+
|
|
117
|
+
Metrics:
|
|
118
|
+
- "How coupled is the codebase?" → get_coupling_metrics
|
|
119
|
+
- "Which functions are tested and which aren't?" → get_test_coverage
|
|
120
|
+
|
|
121
|
+
TEMPORAL ANALYSIS — Any tool accepts an optional temporal parameter: { temporal: { last_n: N } }. This runs the tool across recent snapshots and returns a trend. Requires at least 2 snapshots. Example:
|
|
122
|
+
get_coupling_metrics({ project: "myapp", temporal: { last_n: 5 } })
|
|
123
123
|
`;
|
|
124
124
|
// ─── Private Tools (Ptah IP — never exposed publicly) ────────────
|
|
125
125
|
// These tools are reserved for the Ptah write layer. They exist in
|
|
@@ -264,6 +264,20 @@ const TOOLS = [
|
|
|
264
264
|
},
|
|
265
265
|
annotations: READ_ONLY_OPEN,
|
|
266
266
|
},
|
|
267
|
+
{
|
|
268
|
+
name: 'get_lineage',
|
|
269
|
+
title: 'Get Lineage',
|
|
270
|
+
description: 'The change history of one symbol, typed by what kind of change each commit made (body, calls, data, signature, constraints…), with CI verdicts, reverts, rename tracking, co-change partners, and rejected PRs that touched it. Call before modifying anything load-bearing: it answers "how does this entity usually change, and what happened last time someone tried?" — which no text diff can. Complements get_blast_radius (current impact) with history (past behavior).',
|
|
271
|
+
inputSchema: {
|
|
272
|
+
type: 'object',
|
|
273
|
+
properties: {
|
|
274
|
+
project: projectParam,
|
|
275
|
+
entity_id: { type: 'string', description: 'Entity ID or name to fetch change history for' },
|
|
276
|
+
},
|
|
277
|
+
required: ['entity_id'],
|
|
278
|
+
},
|
|
279
|
+
annotations: READ_ONLY_OPEN,
|
|
280
|
+
},
|
|
267
281
|
{
|
|
268
282
|
name: 'list_modules',
|
|
269
283
|
title: 'List Modules',
|
|
@@ -444,7 +458,7 @@ const TOOLS = [
|
|
|
444
458
|
{
|
|
445
459
|
name: 'simulate_mutation',
|
|
446
460
|
title: 'Simulate Mutation',
|
|
447
|
-
description: 'Simulate a hypothetical code change without writing anything.
|
|
461
|
+
description: 'Simulate a hypothetical code change without writing anything. Five mutation dimensions: constraints/traits (behavioral tags — who upstream breaks), edges (add/remove calls — orphaned callees, inherited error handling and data contracts), data (add/remove table access — which co-tenants of the table must be reviewed), signature (param changes — which call sites break). Use this to plan a change before making it.',
|
|
448
462
|
inputSchema: {
|
|
449
463
|
type: 'object',
|
|
450
464
|
properties: {
|
|
@@ -454,12 +468,17 @@ const TOOLS = [
|
|
|
454
468
|
type: 'object',
|
|
455
469
|
description: 'The hypothetical change to apply.',
|
|
456
470
|
properties: {
|
|
457
|
-
dimension: { type: 'string', enum: ['constraints', 'traits'], description: 'The attribute scope to mutate.' },
|
|
471
|
+
dimension: { type: 'string', enum: ['constraints', 'traits', 'edges', 'data', 'signature'], description: 'The attribute scope to mutate.' },
|
|
458
472
|
change: {
|
|
459
473
|
type: 'object',
|
|
460
474
|
properties: {
|
|
461
|
-
add: { type: 'array', items: { type: 'string' } },
|
|
462
|
-
remove: { type: 'array', items: { type: 'string' } },
|
|
475
|
+
add: { type: 'array', items: { type: 'string' }, description: 'constraints/traits: tags to add (e.g. "fallible", "auth")' },
|
|
476
|
+
remove: { type: 'array', items: { type: 'string' }, description: 'constraints/traits: tags to remove' },
|
|
477
|
+
add_calls: { type: 'array', items: { type: 'string' }, description: 'edges: callees the target would start calling' },
|
|
478
|
+
remove_calls: { type: 'array', items: { type: 'string' }, description: 'edges: callees the target would stop calling' },
|
|
479
|
+
add_tables: { type: 'array', items: { type: 'object', properties: { table: { type: 'string' }, operation: { type: 'string' } } }, description: 'data: tables the target would start touching' },
|
|
480
|
+
remove_tables: { type: 'array', items: { type: 'string' }, description: 'data: tables the target would stop touching' },
|
|
481
|
+
params: { type: 'object', properties: { add: { type: 'array', items: { type: 'string' } }, remove: { type: 'array', items: { type: 'string' } } }, description: 'signature: parameter changes' },
|
|
463
482
|
},
|
|
464
483
|
},
|
|
465
484
|
},
|
|
@@ -588,7 +607,7 @@ function getCloudUrl(path) {
|
|
|
588
607
|
async function main() {
|
|
589
608
|
const server = new Server({
|
|
590
609
|
name: 'seshat',
|
|
591
|
-
version: '0.16.
|
|
610
|
+
version: '0.16.10',
|
|
592
611
|
}, {
|
|
593
612
|
capabilities: { tools: {} },
|
|
594
613
|
instructions: SERVER_INSTRUCTIONS,
|
|
@@ -875,7 +894,7 @@ async function main() {
|
|
|
875
894
|
});
|
|
876
895
|
const transport = new StdioServerTransport();
|
|
877
896
|
await server.connect(transport);
|
|
878
|
-
process.stderr.write(`Seshat MCP v0.16.
|
|
897
|
+
process.stderr.write(`Seshat MCP v0.16.10 connected. Structural intelligence ready.\n`);
|
|
879
898
|
}
|
|
880
899
|
main().catch((err) => {
|
|
881
900
|
process.stderr.write(`Fatal: ${err.message}\n`);
|
package/dist/loader.js
CHANGED
|
@@ -92,13 +92,19 @@ export class BundleLoader {
|
|
|
92
92
|
getEntityByName(name) {
|
|
93
93
|
return this.getEntities().find(e => {
|
|
94
94
|
const structName = typeof e.struct === 'string' ? e.struct : e.struct?.name;
|
|
95
|
-
return e.id === name || structName === name;
|
|
95
|
+
return e.id === name || localIdOfRef(e.id) === name || structName === name;
|
|
96
96
|
});
|
|
97
97
|
}
|
|
98
98
|
isLoaded() {
|
|
99
99
|
return this.loaded;
|
|
100
100
|
}
|
|
101
101
|
}
|
|
102
|
+
/** Local part of a (possibly file-qualified) entity id. */
|
|
103
|
+
function localIdOfRef(id) {
|
|
104
|
+
const i = id.lastIndexOf('#');
|
|
105
|
+
const local = i === -1 ? id : id.slice(i + 1);
|
|
106
|
+
return local.replace(/~\d+$/, '');
|
|
107
|
+
}
|
|
102
108
|
// ─── Multi-Project Loader ─────────────────────────────────────────
|
|
103
109
|
/**
|
|
104
110
|
* Wraps N BundleLoader instances for multi-project access.
|
|
@@ -191,7 +197,7 @@ export class MultiLoader {
|
|
|
191
197
|
getEntityByName(name, project) {
|
|
192
198
|
return this.getEntities(project).find(e => {
|
|
193
199
|
const structName = typeof e.struct === 'string' ? e.struct : e.struct?.name;
|
|
194
|
-
return e.id === name || structName === name;
|
|
200
|
+
return e.id === name || localIdOfRef(e.id) === name || structName === name;
|
|
195
201
|
});
|
|
196
202
|
}
|
|
197
203
|
getProjectNames() {
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Database operation extraction from entity coordinates.
|
|
3
|
+
*
|
|
4
|
+
* Parses Supabase/Prisma call targets in the edge set and db statements in
|
|
5
|
+
* the semantics dimension to answer: which tables does this entity read or
|
|
6
|
+
* write? This is the δ-contract surface that conflict_matrix uses to detect
|
|
7
|
+
* tasks that collide through shared data even when their symbols and files
|
|
8
|
+
* are disjoint (the "spawner oscillation" class of conflict).
|
|
9
|
+
*
|
|
10
|
+
* Ported from apps/api/routes/mcp.js extractDbOperations so the package's
|
|
11
|
+
* own tools (conflict_matrix) can use it without the cloud layer.
|
|
12
|
+
*/
|
|
13
|
+
import type { JstfEntity } from '../types.js';
|
|
14
|
+
export interface DbOperation {
|
|
15
|
+
table: string;
|
|
16
|
+
operation: string;
|
|
17
|
+
type: 'db_query' | 'db_mutate';
|
|
18
|
+
}
|
|
19
|
+
export declare function extractDbOps(entity: JstfEntity): DbOperation[];
|
|
20
|
+
/** Aggregate db ops for a set of entities into read/write table sets. */
|
|
21
|
+
export declare function tableContract(entities: JstfEntity[]): {
|
|
22
|
+
reads: Set<string>;
|
|
23
|
+
writes: Set<string>;
|
|
24
|
+
};
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Database operation extraction from entity coordinates.
|
|
3
|
+
*
|
|
4
|
+
* Parses Supabase/Prisma call targets in the edge set and db statements in
|
|
5
|
+
* the semantics dimension to answer: which tables does this entity read or
|
|
6
|
+
* write? This is the δ-contract surface that conflict_matrix uses to detect
|
|
7
|
+
* tasks that collide through shared data even when their symbols and files
|
|
8
|
+
* are disjoint (the "spawner oscillation" class of conflict).
|
|
9
|
+
*
|
|
10
|
+
* Ported from apps/api/routes/mcp.js extractDbOperations so the package's
|
|
11
|
+
* own tools (conflict_matrix) can use it without the cloud layer.
|
|
12
|
+
*/
|
|
13
|
+
const WRITE_OPS = new Set(['insert', 'update', 'delete', 'upsert', 'create']);
|
|
14
|
+
export function extractDbOps(entity) {
|
|
15
|
+
const ops = [];
|
|
16
|
+
const seen = new Set();
|
|
17
|
+
const push = (table, operation, isWrite) => {
|
|
18
|
+
const key = `${table}:${operation}`;
|
|
19
|
+
if (seen.has(key))
|
|
20
|
+
return;
|
|
21
|
+
seen.add(key);
|
|
22
|
+
ops.push({ table, operation, type: isWrite ? 'db_mutate' : 'db_query' });
|
|
23
|
+
};
|
|
24
|
+
const edges = entity.edges;
|
|
25
|
+
const callees = (edges?.callees || edges?.calls || []);
|
|
26
|
+
for (const callee of callees) {
|
|
27
|
+
const target = typeof callee === 'string' ? callee : callee?.target || callee?.id || '';
|
|
28
|
+
const fromMatch = target.match(/\.from\s*\(\s*['"`](\w+)['"`]\s*\)/);
|
|
29
|
+
if (fromMatch) {
|
|
30
|
+
const opMatch = target.match(/\.(insert|update|delete|select|upsert|rpc)\s*\(/);
|
|
31
|
+
const operation = opMatch ? opMatch[1] : 'query';
|
|
32
|
+
push(fromMatch[1], operation, WRITE_OPS.has(operation));
|
|
33
|
+
}
|
|
34
|
+
const prismaMatch = target.match(/prisma\.(\w+)\.(findMany|findUnique|findFirst|create|update|delete|upsert|count|aggregate)\s*\(/);
|
|
35
|
+
if (prismaMatch) {
|
|
36
|
+
push(prismaMatch[1], prismaMatch[2], WRITE_OPS.has(prismaMatch[2]));
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
function scanSemantics(stmts) {
|
|
40
|
+
if (!Array.isArray(stmts))
|
|
41
|
+
return;
|
|
42
|
+
for (const stmt of stmts) {
|
|
43
|
+
if (!stmt || typeof stmt !== 'object')
|
|
44
|
+
continue;
|
|
45
|
+
if (stmt.type === 'db_query' || stmt.type === 'db_mutate') {
|
|
46
|
+
push(String(stmt.model || 'unknown'), String(stmt.operation || 'unknown'), stmt.type === 'db_mutate');
|
|
47
|
+
}
|
|
48
|
+
if (stmt.then)
|
|
49
|
+
scanSemantics(stmt.then);
|
|
50
|
+
if (stmt.else)
|
|
51
|
+
scanSemantics(stmt.else);
|
|
52
|
+
if (stmt.body)
|
|
53
|
+
scanSemantics(stmt.body);
|
|
54
|
+
if (stmt.catch?.body)
|
|
55
|
+
scanSemantics(stmt.catch.body);
|
|
56
|
+
if (stmt.finally)
|
|
57
|
+
scanSemantics(stmt.finally);
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
scanSemantics(entity.semantics);
|
|
61
|
+
return ops;
|
|
62
|
+
}
|
|
63
|
+
/** Aggregate db ops for a set of entities into read/write table sets. */
|
|
64
|
+
export function tableContract(entities) {
|
|
65
|
+
const reads = new Set();
|
|
66
|
+
const writes = new Set();
|
|
67
|
+
for (const e of entities) {
|
|
68
|
+
for (const op of extractDbOps(e)) {
|
|
69
|
+
if (op.table === 'unknown')
|
|
70
|
+
continue;
|
|
71
|
+
if (op.type === 'db_mutate')
|
|
72
|
+
writes.add(op.table);
|
|
73
|
+
else
|
|
74
|
+
reads.add(op.table);
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
return { reads, writes };
|
|
78
|
+
}
|
package/dist/tools/diff.js
CHANGED
|
@@ -12,6 +12,7 @@ import path from 'path';
|
|
|
12
12
|
import { bootstrap } from '../bootstrap.js';
|
|
13
13
|
import { computeBlastRadius } from '../graph.js';
|
|
14
14
|
import { getGraph, validateProject, entityName, entityLayer, } from './index.js';
|
|
15
|
+
import { tableContract } from './dbops.js';
|
|
15
16
|
// ─── Entity Identity ─────────────────────────────────────────────
|
|
16
17
|
// Ported from api-v2/translator/seshat-pipeline/src/incremental/diff-engine.mjs
|
|
17
18
|
/**
|
|
@@ -260,44 +261,122 @@ export async function diffBundle(args, loader) {
|
|
|
260
261
|
}
|
|
261
262
|
return result;
|
|
262
263
|
}
|
|
264
|
+
/**
|
|
265
|
+
* Entropy weights — measured, not asserted. Instance-weighted Shannon entropy
|
|
266
|
+
* per dimension, FUNCTION-LEVEL entities only (conflict tasks target functions;
|
|
267
|
+
* import/const entities dilute all-entities numbers). Re-measured 2026-06-11 on
|
|
268
|
+
* the CLEAN substrate (honest κ, derived χ, collapsed ε — sniffr-backend snap
|
|
269
|
+
* 205 + ptah snap 207 + second-brain snap 210, ~2,059 JS function instances):
|
|
270
|
+
* the original 56-project corpus was extracted pre-Testimony-Rule, so part of
|
|
271
|
+
* its κ entropy was false-positive variety and its χ was path-substring noise.
|
|
272
|
+
* Clean JS vector: ε .225, Σ .146, δ .140, χ .114, κ .107, σ .089; ρ 0
|
|
273
|
+
* everywhere. Notable shift vs the corrupted corpus: χ (.087→.114) overtakes
|
|
274
|
+
* σ (.107→.089) — topology-derived layers carry real discrimination. See
|
|
275
|
+
* ptah/docs/dimension-entropy-2026-06-clean.json (by_language_functions) and
|
|
276
|
+
* DIMENSION-ENTROPY-FINDINGS.md (clean re-run section).
|
|
277
|
+
*/
|
|
278
|
+
const CONFLICT_WEIGHTS = {
|
|
279
|
+
symbols: 0.09, // σ — direct entity overlap
|
|
280
|
+
epsilon: 0.23, // ε — shared call-graph neighborhood
|
|
281
|
+
delta: 0.14, // δ — shared data contracts (tables)
|
|
282
|
+
files: 0.11, // χ-adjacent — shared files
|
|
283
|
+
};
|
|
284
|
+
function jaccard(a, b) {
|
|
285
|
+
if (a.size === 0 && b.size === 0)
|
|
286
|
+
return 0;
|
|
287
|
+
let inter = 0;
|
|
288
|
+
for (const x of a)
|
|
289
|
+
if (b.has(x))
|
|
290
|
+
inter++;
|
|
291
|
+
return inter / (a.size + b.size - inter);
|
|
292
|
+
}
|
|
293
|
+
function intersect(a, b) {
|
|
294
|
+
const out = [];
|
|
295
|
+
for (const x of a)
|
|
296
|
+
if (b.has(x))
|
|
297
|
+
out.push(x);
|
|
298
|
+
return out;
|
|
299
|
+
}
|
|
263
300
|
function classifyConflictTier(taskA, taskB) {
|
|
264
|
-
|
|
265
|
-
const
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
301
|
+
const sharedEntities = intersect(taskA.entityIds, taskB.entityIds);
|
|
302
|
+
const sharedFiles = intersect(taskA.files, taskB.files);
|
|
303
|
+
const sharedNeighbors = intersect(taskA.neighbors, taskB.neighbors);
|
|
304
|
+
// δ-contract overlap with read/write direction per side
|
|
305
|
+
const sharedTables = [];
|
|
306
|
+
const allTables = new Set([
|
|
307
|
+
...taskA.tablesRead, ...taskA.tablesWritten,
|
|
308
|
+
...taskB.tablesRead, ...taskB.tablesWritten,
|
|
309
|
+
]);
|
|
310
|
+
for (const table of allTables) {
|
|
311
|
+
const aW = taskA.tablesWritten.has(table);
|
|
312
|
+
const aR = taskA.tablesRead.has(table) || aW;
|
|
313
|
+
const bW = taskB.tablesWritten.has(table);
|
|
314
|
+
const bR = taskB.tablesRead.has(table) || bW;
|
|
315
|
+
if (aR && bR && (aW || bW)) {
|
|
316
|
+
// overlap matters only when at least one side writes
|
|
317
|
+
sharedTables.push({ table, taskA: aW ? 'write' : 'read', taskB: bW ? 'write' : 'read' });
|
|
269
318
|
}
|
|
270
319
|
}
|
|
320
|
+
const writeWrite = sharedTables.filter(t => t.taskA === 'write' && t.taskB === 'write');
|
|
321
|
+
const readWrite = sharedTables.filter(t => t.taskA !== t.taskB);
|
|
322
|
+
// Entropy-weighted evidence score in [0,1]
|
|
323
|
+
const tablesA = new Set([...taskA.tablesRead, ...taskA.tablesWritten]);
|
|
324
|
+
const tablesB = new Set([...taskB.tablesRead, ...taskB.tablesWritten]);
|
|
325
|
+
const w = CONFLICT_WEIGHTS;
|
|
326
|
+
const totalW = w.symbols + w.epsilon + w.delta + w.files;
|
|
327
|
+
const conflictScore = Math.round(((w.symbols * jaccard(taskA.entityIds, taskB.entityIds) +
|
|
328
|
+
w.epsilon * jaccard(taskA.neighbors, taskB.neighbors) +
|
|
329
|
+
w.delta * jaccard(tablesA, tablesB) +
|
|
330
|
+
w.files * jaccard(taskA.files, taskB.files)) / totalW) * 1000) / 1000;
|
|
331
|
+
const base = { conflictScore, sharedFiles, sharedEntities, sharedTables, sharedNeighbors };
|
|
332
|
+
// Tier 3 — must sequence
|
|
271
333
|
if (sharedEntities.length > 0) {
|
|
272
334
|
return {
|
|
335
|
+
...base,
|
|
273
336
|
tier: 3,
|
|
274
337
|
reason: `${sharedEntities.length} shared symbols — MUST sequence to maintain surgical splice integrity`,
|
|
275
|
-
sharedFiles: [],
|
|
276
|
-
sharedEntities,
|
|
277
338
|
};
|
|
278
339
|
}
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
340
|
+
if (writeWrite.length > 0) {
|
|
341
|
+
return {
|
|
342
|
+
...base,
|
|
343
|
+
tier: 3,
|
|
344
|
+
reason: `shared data contract — both tasks WRITE table(s) ${writeWrite.map(t => t.table).join(', ')}; ` +
|
|
345
|
+
'symbols are disjoint but the tasks collide through the data layer — MUST sequence',
|
|
346
|
+
};
|
|
347
|
+
}
|
|
348
|
+
// Tier 2 — parallelize with care
|
|
349
|
+
if (readWrite.length > 0) {
|
|
350
|
+
return {
|
|
351
|
+
...base,
|
|
352
|
+
tier: 2,
|
|
353
|
+
reason: `read/write data contract overlap on table(s) ${readWrite.map(t => t.table).join(', ')} — ` +
|
|
354
|
+
'parallel-safe only if the reader tolerates the writer\'s changes',
|
|
355
|
+
};
|
|
356
|
+
}
|
|
357
|
+
if (sharedFiles.length > 0) {
|
|
358
|
+
return {
|
|
359
|
+
...base,
|
|
360
|
+
tier: 2,
|
|
361
|
+
reason: `${sharedFiles.length} shared files but different symbols — safe to parallelize via surgical splicing`,
|
|
362
|
+
};
|
|
363
|
+
}
|
|
364
|
+
if (sharedNeighbors.length > 0) {
|
|
365
|
+
return {
|
|
366
|
+
...base,
|
|
367
|
+
tier: 2,
|
|
368
|
+
reason: `call-graph adjacency — tasks touch ${sharedNeighbors.length} shared neighbor(s) within 1 hop ` +
|
|
369
|
+
`(e.g. ${sharedNeighbors.slice(0, 3).join(', ')}) — review the shared interface before parallelizing`,
|
|
370
|
+
};
|
|
285
371
|
}
|
|
286
|
-
if (
|
|
372
|
+
if (conflictScore > 0) {
|
|
287
373
|
return {
|
|
374
|
+
...base,
|
|
288
375
|
tier: 1,
|
|
289
|
-
reason: 'No shared
|
|
290
|
-
sharedFiles: [],
|
|
291
|
-
sharedEntities: [],
|
|
376
|
+
reason: 'No conflicting overlap (weak affinity only — shared read-only tables or nearby code) — safe to parallelize',
|
|
292
377
|
};
|
|
293
378
|
}
|
|
294
|
-
|
|
295
|
-
return {
|
|
296
|
-
tier: 2,
|
|
297
|
-
reason: `${sharedFiles.length} shared files but different symbols — safe to parallelize via surgical splicing`,
|
|
298
|
-
sharedFiles,
|
|
299
|
-
sharedEntities: [],
|
|
300
|
-
};
|
|
379
|
+
return { ...base, tier: 1, reason: 'No shared symbols, files, data contracts, or call-graph adjacency — safe to parallelize' };
|
|
301
380
|
}
|
|
302
381
|
// ─── Execution Plan Builder ───────────────────────────────────────
|
|
303
382
|
/**
|
|
@@ -376,6 +455,7 @@ export function conflictMatrix(args, loader) {
|
|
|
376
455
|
const warnings = [];
|
|
377
456
|
// Resolve entity sets for each task
|
|
378
457
|
const resolvedTasks = [];
|
|
458
|
+
const graph = getGraph(args.project, loader);
|
|
379
459
|
for (const task of tasks) {
|
|
380
460
|
const entityIds = new Set();
|
|
381
461
|
const files = new Set();
|
|
@@ -400,12 +480,11 @@ export function conflictMatrix(args, loader) {
|
|
|
400
480
|
}
|
|
401
481
|
// Expand blast radius if requested
|
|
402
482
|
if (task.expand_blast_radius && entityIds.size > 0) {
|
|
403
|
-
const
|
|
404
|
-
const blastResult = computeBlastRadius(g, entityIds);
|
|
483
|
+
const blastResult = computeBlastRadius(graph, entityIds);
|
|
405
484
|
for (const affectedId of blastResult.affected) {
|
|
406
485
|
if (!entityIds.has(affectedId)) {
|
|
407
486
|
entityIds.add(affectedId);
|
|
408
|
-
const affectedEntity =
|
|
487
|
+
const affectedEntity = graph.entityById.get(affectedId);
|
|
409
488
|
if (affectedEntity) {
|
|
410
489
|
entities.push(affectedEntity);
|
|
411
490
|
if (affectedEntity._sourceFile)
|
|
@@ -414,7 +493,20 @@ export function conflictMatrix(args, loader) {
|
|
|
414
493
|
}
|
|
415
494
|
}
|
|
416
495
|
}
|
|
417
|
-
|
|
496
|
+
// δ-contract surface: tables read/written by the task's entities
|
|
497
|
+
const { reads: tablesRead, writes: tablesWritten } = tableContract(entities);
|
|
498
|
+
// ε surface: 1-hop call-graph neighborhood (always computed — cheap, and
|
|
499
|
+
// it catches adjacent-task conflicts even without blast-radius expansion)
|
|
500
|
+
const neighbors = new Set();
|
|
501
|
+
for (const id of entityIds) {
|
|
502
|
+
for (const c of graph.callers.get(id) || [])
|
|
503
|
+
if (!entityIds.has(c))
|
|
504
|
+
neighbors.add(c);
|
|
505
|
+
for (const c of graph.callees.get(id) || [])
|
|
506
|
+
if (!entityIds.has(c))
|
|
507
|
+
neighbors.add(c);
|
|
508
|
+
}
|
|
509
|
+
resolvedTasks.push({ id: task.id, entityIds, files, entities, dimensions, tablesRead, tablesWritten, neighbors });
|
|
418
510
|
}
|
|
419
511
|
// Pairwise comparison
|
|
420
512
|
const matrix = [];
|
|
@@ -429,11 +521,16 @@ export function conflictMatrix(args, loader) {
|
|
|
429
521
|
taskB: taskB.id,
|
|
430
522
|
tier: result.tier,
|
|
431
523
|
reason: result.reason,
|
|
524
|
+
conflictScore: result.conflictScore,
|
|
432
525
|
};
|
|
433
526
|
if (result.sharedFiles.length > 0)
|
|
434
527
|
entry.sharedFiles = result.sharedFiles;
|
|
435
528
|
if (result.sharedEntities.length > 0)
|
|
436
529
|
entry.sharedEntities = result.sharedEntities;
|
|
530
|
+
if (result.sharedTables.length > 0)
|
|
531
|
+
entry.sharedTables = result.sharedTables;
|
|
532
|
+
if (result.sharedNeighbors.length > 0)
|
|
533
|
+
entry.sharedNeighbors = result.sharedNeighbors.slice(0, 10);
|
|
437
534
|
matrix.push(entry);
|
|
438
535
|
if (result.tier === 3) {
|
|
439
536
|
tier3Edges.push([taskA.id, taskB.id]);
|
package/dist/tools/functors.d.ts
CHANGED
|
@@ -81,10 +81,21 @@ export declare function query_traits(args: {
|
|
|
81
81
|
export declare function simulate_mutation(args: {
|
|
82
82
|
entity_id: string;
|
|
83
83
|
mutation: {
|
|
84
|
-
dimension: 'constraints' | 'traits';
|
|
84
|
+
dimension: 'constraints' | 'traits' | 'edges' | 'data' | 'signature';
|
|
85
85
|
change: {
|
|
86
86
|
add?: string[];
|
|
87
87
|
remove?: string[];
|
|
88
|
+
add_calls?: string[];
|
|
89
|
+
remove_calls?: string[];
|
|
90
|
+
add_tables?: Array<{
|
|
91
|
+
table: string;
|
|
92
|
+
operation?: string;
|
|
93
|
+
}>;
|
|
94
|
+
remove_tables?: string[];
|
|
95
|
+
params?: {
|
|
96
|
+
add?: string[];
|
|
97
|
+
remove?: string[];
|
|
98
|
+
};
|
|
88
99
|
};
|
|
89
100
|
};
|
|
90
101
|
project?: string;
|
package/dist/tools/functors.js
CHANGED
|
@@ -9,6 +9,7 @@ import path from 'path';
|
|
|
9
9
|
import { computeBlastRadius } from '../graph.js';
|
|
10
10
|
import { getGraph, validateProject, entityName, entityLayer, entitySummary, } from './index.js';
|
|
11
11
|
import { isSupabaseConfigured, insertPrediction, updateActualBurn, abandonPrediction, } from '../supabase.js';
|
|
12
|
+
import { extractDbOps } from './dbops.js';
|
|
12
13
|
// ─── Layer ordering for violation detection ──────────────────────
|
|
13
14
|
const LAYER_ORDER = {
|
|
14
15
|
route: 0,
|
|
@@ -984,6 +985,19 @@ export function simulate_mutation(args, loader) {
|
|
|
984
985
|
}
|
|
985
986
|
const targetId = targetEntity.id;
|
|
986
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
|
+
}
|
|
987
1001
|
const addedTags = (change.add || []).map(t => t.toLowerCase());
|
|
988
1002
|
// We are currently simulating topological fallout for newly ADDED constraints/traits.
|
|
989
1003
|
// Example: If we add "fallible" or "auth", who upstream breaks because they don't handle it?
|
|
@@ -1051,6 +1065,176 @@ export function simulate_mutation(args, loader) {
|
|
|
1051
1065
|
fallout,
|
|
1052
1066
|
};
|
|
1053
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
|
+
}
|
|
1054
1238
|
// ─── Tool: query_data_targets (Data Flow Analysis) ──────────────
|
|
1055
1239
|
export function query_data_targets(args, loader) {
|
|
1056
1240
|
const projErr = validateProject(args.project, loader);
|
package/dist/tools/index.d.ts
CHANGED
|
@@ -9,6 +9,7 @@
|
|
|
9
9
|
*/
|
|
10
10
|
import type { JstfEntity, ProjectLoader } from '../types.js';
|
|
11
11
|
import { type CallGraph } from '../graph.js';
|
|
12
|
+
export { extractDbOps, tableContract, type DbOperation } from './dbops.js';
|
|
12
13
|
export declare function getGraph(project: string | undefined, loader: ProjectLoader): CallGraph;
|
|
13
14
|
/**
|
|
14
15
|
* Validate project param. Returns error string if invalid, null if OK.
|
package/dist/tools/index.js
CHANGED
|
@@ -8,6 +8,7 @@
|
|
|
8
8
|
* `project` parameter. In multi-project mode, `project` is required.
|
|
9
9
|
*/
|
|
10
10
|
import { buildCallGraph, computeBlastRadius } from '../graph.js';
|
|
11
|
+
export { extractDbOps, tableContract } from './dbops.js';
|
|
11
12
|
export function getGraph(project, loader) {
|
|
12
13
|
// Fast path: use pre-computed call graph edges cached on the snapshot row
|
|
13
14
|
// (avoids 3-10s CPU rebuild on first request for large codebases)
|
|
@@ -80,6 +81,7 @@ export function entitySummary(e) {
|
|
|
80
81
|
layer: entityLayer(e),
|
|
81
82
|
module: e.context?.module || null,
|
|
82
83
|
sourceFile: e._sourceFile || null,
|
|
84
|
+
line: e._sourceLine ?? undefined,
|
|
83
85
|
sourceLanguage: e._sourceLanguage || null,
|
|
84
86
|
async: typeof e.struct !== 'string' ? e.struct?.async : undefined,
|
|
85
87
|
exported: typeof e.struct !== 'string' ? e.struct?.exported : undefined,
|
package/package.json
CHANGED