@papyruslabsai/seshat-mcp 0.16.9 → 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 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
- // Suffix index: map last dotted segment first matching entity ID (for O(1) suffix lookups)
33
- const bySuffix = new Map();
34
- for (const [id] of entityById) {
35
- const suffix = id.split('.').pop();
36
- if (suffix && !bySuffix.has(suffix))
37
- bySuffix.set(suffix, id);
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
- // Filepath index: map source file paths and basenames entity ID (for O(1) import resolution)
40
- const byFilePath = new Map();
41
- for (const [id, entity] of entityById) {
42
- const src = entity._sourceFile || entity.context?.module || '';
43
- if (src) {
44
- const basename = src.split('/').pop()?.replace(/\.[^.]+$/, '') || '';
45
- if (basename && !byFilePath.has(basename))
46
- byFilePath.set(basename, id);
47
- if (!byFilePath.has(src))
48
- byFilePath.set(src, id);
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 target = call.target;
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 (wiring file-level dependencies into the graph)
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
- // Attempt to find the imported module or entity via filepath index (O(1) lookup)
114
- const sourceBasename = source.split('/').pop()?.replace(/\.[^.]+$/, '') || '';
115
- calleeId = byFilePath.get(source) || (sourceBasename ? byFilePath.get(sourceBasename) : null) || null;
116
- if (calleeId && calleeId !== caller.id) {
117
- callees.get(caller.id)?.add(calleeId);
118
- callers.get(calleeId)?.add(caller.id);
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
- const source = cb.source;
127
- if (!source)
188
+ if (!cb.source)
128
189
  continue;
129
- let callerId = null;
130
- if (entityById.has(source)) {
131
- callerId = source;
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. Propose adding or removing constraints/traits on a symbol and see which other symbols would break and what fixes they\'d need. Use this to plan a change before making it.',
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,15 +607,18 @@ function getCloudUrl(path) {
588
607
  async function main() {
589
608
  const server = new Server({
590
609
  name: 'seshat',
591
- version: '0.16.6',
610
+ version: '0.16.10',
592
611
  }, {
593
612
  capabilities: { tools: {} },
594
613
  instructions: SERVER_INSTRUCTIONS,
595
614
  });
596
- // ─── ListTools — all public tools, Architect tools hidden (Ptah IP) ──
615
+ // ─── ListTools — all public tools, Architect tools hidden unless Pro mode ──
616
+ const isProMode = !!process.env.SESHAT_SUPABASE_KEY;
597
617
  server.setRequestHandler(ListToolsRequestSchema, async () => {
598
- const visibleTools = TOOLS.filter((tool) => !PRIVATE_TOOLS.has(tool.name));
599
- process.stderr.write(`[Seshat] ListTools: ${visibleTools.length} tools (${PRIVATE_TOOLS.size} private)\n`);
618
+ const visibleTools = isProMode
619
+ ? TOOLS
620
+ : TOOLS.filter((tool) => !PRIVATE_TOOLS.has(tool.name));
621
+ process.stderr.write(`[Seshat${isProMode ? ' Pro' : ''}] ListTools: ${visibleTools.length} tools${isProMode ? '' : ` (${PRIVATE_TOOLS.size} private)`}\n`);
600
622
  return { tools: visibleTools };
601
623
  });
602
624
  // ─── CallTool handler ──────────────────────────────────────────
@@ -621,8 +643,8 @@ async function main() {
621
643
  isError: true,
622
644
  };
623
645
  }
624
- // Block private Ptah tools from being called
625
- if (PRIVATE_TOOLS.has(name)) {
646
+ // Block private Ptah tools from being called (unless Pro mode)
647
+ if (!isProMode && PRIVATE_TOOLS.has(name)) {
626
648
  return {
627
649
  content: [{ type: 'text', text: JSON.stringify({ error: `Unknown tool: ${name}` }, null, 2) }],
628
650
  isError: true,
@@ -643,9 +665,9 @@ async function main() {
643
665
  };
644
666
  }
645
667
  const account = await res.json();
646
- // List all public tools
668
+ // List available tools (all tools in Pro mode, public only otherwise)
647
669
  const publicTools = TOOLS
648
- .filter(t => !PRIVATE_TOOLS.has(t.name))
670
+ .filter(t => isProMode || !PRIVATE_TOOLS.has(t.name))
649
671
  .map(t => t.name);
650
672
  const response = {
651
673
  status: 'active',
@@ -872,7 +894,7 @@ async function main() {
872
894
  });
873
895
  const transport = new StdioServerTransport();
874
896
  await server.connect(transport);
875
- process.stderr.write(`Seshat MCP v0.16.7 connected. Structural intelligence ready.\n`);
897
+ process.stderr.write(`Seshat MCP v0.16.10 connected. Structural intelligence ready.\n`);
876
898
  }
877
899
  main().catch((err) => {
878
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
+ }
@@ -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
- // Check symbol overlap → Tier 3 (MUST sequence)
265
- const sharedEntities = [];
266
- for (const id of taskA.entityIds) {
267
- if (taskB.entityIds.has(id)) {
268
- sharedEntities.push(id);
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
- // Check file overlap → Tier 1 or 2
280
- const sharedFiles = [];
281
- for (const file of taskA.files) {
282
- if (taskB.files.has(file)) {
283
- sharedFiles.push(file);
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 (sharedFiles.length === 0) {
372
+ if (conflictScore > 0) {
287
373
  return {
374
+ ...base,
288
375
  tier: 1,
289
- reason: 'No shared files — safe to parallelize',
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
- // Same file, different symbols Tier 2 (Safe via Surgical Splicer)
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 g = getGraph(args.project, loader);
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 = g.entityById.get(affectedId);
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
- resolvedTasks.push({ id: task.id, entityIds, files, entities, dimensions });
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]);
@@ -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;
@@ -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);
@@ -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.
@@ -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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@papyruslabsai/seshat-mcp",
3
- "version": "0.16.9",
3
+ "version": "0.17.0",
4
4
  "description": "Semantic MCP server — exposes a codebase's structure, dependencies, and constraints as queryable tools",
5
5
  "type": "module",
6
6
  "bin": {