@papyruslabsai/seshat-mcp 0.19.0 → 0.20.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +87 -0
- package/dist/index.js +29 -8
- package/package.json +41 -48
- package/dist/bootstrap.d.ts +0 -29
- package/dist/bootstrap.js +0 -190
- package/dist/graph.d.ts +0 -42
- package/dist/graph.js +0 -272
- package/dist/index.d.ts +0 -9
- package/dist/loader.d.ts +0 -62
- package/dist/loader.js +0 -264
- package/dist/supabase.d.ts +0 -99
- package/dist/supabase.js +0 -132
- package/dist/tools/dbops.d.ts +0 -24
- package/dist/tools/dbops.js +0 -78
- package/dist/tools/diff.d.ts +0 -28
- package/dist/tools/diff.js +0 -604
- package/dist/tools/functors.d.ts +0 -124
- package/dist/tools/functors.js +0 -1567
- package/dist/tools/index.d.ts +0 -66
- package/dist/tools/index.js +0 -389
- package/dist/types.d.ts +0 -174
- package/dist/types.js +0 -8
package/dist/tools/diff.js
DELETED
|
@@ -1,604 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Cross-Bundle Analysis Tools: diff_bundle + conflict_matrix
|
|
3
|
-
*
|
|
4
|
-
* diff_bundle: Compare entities between a worktree and the loaded project.
|
|
5
|
-
* conflict_matrix: Classify conflict tiers for parallel task scheduling.
|
|
6
|
-
*
|
|
7
|
-
* These tools compare TWO entity sets (base vs branch, or task vs task),
|
|
8
|
-
* unlike the single-bundle queries in index.ts and functors.ts.
|
|
9
|
-
*/
|
|
10
|
-
import fs from 'fs';
|
|
11
|
-
import path from 'path';
|
|
12
|
-
import { bootstrap } from '../bootstrap.js';
|
|
13
|
-
import { computeBlastRadius } from '../graph.js';
|
|
14
|
-
import { getGraph, validateProject, entityName, entityLayer, } from './index.js';
|
|
15
|
-
import { tableContract } from './dbops.js';
|
|
16
|
-
// ─── Entity Identity ─────────────────────────────────────────────
|
|
17
|
-
// Ported from api-v2/translator/seshat-pipeline/src/incremental/diff-engine.mjs
|
|
18
|
-
/**
|
|
19
|
-
* Generate a unique key for an entity: <relative_path>::<id>
|
|
20
|
-
*
|
|
21
|
-
* Uses _sourceFile (already relative in bundles) or derives from
|
|
22
|
-
* sourceFile (absolute path) by stripping the repo root.
|
|
23
|
-
*/
|
|
24
|
-
function entityKey(entity, repoRoot) {
|
|
25
|
-
let relativePath = entity._sourceFile || null;
|
|
26
|
-
// If _sourceFile not set, try deriving from sourceFile (absolute)
|
|
27
|
-
if (!relativePath) {
|
|
28
|
-
const raw = entity;
|
|
29
|
-
const sourceFile = (raw.sourceFile || '').replace(/\\/g, '/');
|
|
30
|
-
if (repoRoot && sourceFile) {
|
|
31
|
-
const normalizedRoot = repoRoot.replace(/\\/g, '/').replace(/\/+$/, '') + '/';
|
|
32
|
-
if (sourceFile.startsWith(normalizedRoot)) {
|
|
33
|
-
relativePath = sourceFile.substring(normalizedRoot.length);
|
|
34
|
-
}
|
|
35
|
-
else {
|
|
36
|
-
// Case-insensitive match (Windows paths)
|
|
37
|
-
const lowerFile = sourceFile.toLowerCase();
|
|
38
|
-
const lowerRoot = normalizedRoot.toLowerCase();
|
|
39
|
-
if (lowerFile.startsWith(lowerRoot)) {
|
|
40
|
-
relativePath = sourceFile.substring(normalizedRoot.length);
|
|
41
|
-
}
|
|
42
|
-
}
|
|
43
|
-
}
|
|
44
|
-
if (!relativePath) {
|
|
45
|
-
relativePath = sourceFile || 'unknown';
|
|
46
|
-
}
|
|
47
|
-
}
|
|
48
|
-
// Normalize path separators
|
|
49
|
-
relativePath = (relativePath || 'unknown').replace(/\\/g, '/');
|
|
50
|
-
const id = entity.id || (typeof entity.struct === 'string' ? entity.struct : entity.struct?.name) || 'anonymous';
|
|
51
|
-
return `${relativePath}::${id}`;
|
|
52
|
-
}
|
|
53
|
-
// ─── Bundle Loading ───────────────────────────────────────────────
|
|
54
|
-
/**
|
|
55
|
-
* Load a branch bundle from a worktree path.
|
|
56
|
-
* Does NOT add to the global MultiLoader — this is a transient comparison target.
|
|
57
|
-
*
|
|
58
|
-
* 1. Check <worktree_path>/.seshat/_bundle.json — parse if exists
|
|
59
|
-
* 2. If not, run bootstrap() to extract on-the-fly
|
|
60
|
-
* 3. Apply the same field remapping as BundleLoader (sourceFile → _sourceFile, etc.)
|
|
61
|
-
*/
|
|
62
|
-
async function loadBranchBundle(worktreePath) {
|
|
63
|
-
const absPath = path.resolve(worktreePath);
|
|
64
|
-
const bundlePath = path.join(absPath, '.seshat', '_bundle.json');
|
|
65
|
-
let bundle;
|
|
66
|
-
if (fs.existsSync(bundlePath)) {
|
|
67
|
-
const raw = fs.readFileSync(bundlePath, 'utf-8');
|
|
68
|
-
bundle = JSON.parse(raw);
|
|
69
|
-
}
|
|
70
|
-
else {
|
|
71
|
-
// Auto-extract via bootstrap
|
|
72
|
-
const result = await bootstrap(absPath);
|
|
73
|
-
if (!result.success) {
|
|
74
|
-
throw new Error(`Failed to extract bundle from ${absPath}: ${result.error}`);
|
|
75
|
-
}
|
|
76
|
-
// Read the freshly generated bundle
|
|
77
|
-
if (!fs.existsSync(bundlePath)) {
|
|
78
|
-
throw new Error(`Bootstrap succeeded but no bundle found at ${bundlePath}`);
|
|
79
|
-
}
|
|
80
|
-
const raw = fs.readFileSync(bundlePath, 'utf-8');
|
|
81
|
-
bundle = JSON.parse(raw);
|
|
82
|
-
}
|
|
83
|
-
const entities = bundle.entities || [];
|
|
84
|
-
// Apply the same field remapping as BundleLoader.load()
|
|
85
|
-
for (const e of entities) {
|
|
86
|
-
const raw = e;
|
|
87
|
-
if (raw.sourceFile && !e._sourceFile) {
|
|
88
|
-
e._sourceFile = raw.sourceFile;
|
|
89
|
-
}
|
|
90
|
-
if (raw.sourceLanguage && !e._sourceLanguage) {
|
|
91
|
-
e._sourceLanguage = raw.sourceLanguage;
|
|
92
|
-
}
|
|
93
|
-
}
|
|
94
|
-
// Try to get commit SHA from manifest
|
|
95
|
-
let commitSha = '';
|
|
96
|
-
const manifestPath = path.join(absPath, '.seshat', 'manifest.json');
|
|
97
|
-
if (fs.existsSync(manifestPath)) {
|
|
98
|
-
try {
|
|
99
|
-
const manifest = JSON.parse(fs.readFileSync(manifestPath, 'utf-8'));
|
|
100
|
-
commitSha = manifest.commitSha || '';
|
|
101
|
-
}
|
|
102
|
-
catch { /* ignore */ }
|
|
103
|
-
}
|
|
104
|
-
return {
|
|
105
|
-
entities,
|
|
106
|
-
source: bundle.source || absPath,
|
|
107
|
-
commitSha,
|
|
108
|
-
entityCount: entities.length,
|
|
109
|
-
};
|
|
110
|
-
}
|
|
111
|
-
// ─── Field Comparison ─────────────────────────────────────────────
|
|
112
|
-
/** The entity fields to compare, mapped to user-facing names (v0.4.2 convention) */
|
|
113
|
-
const FIELD_MAP = [
|
|
114
|
-
['struct', 'structure'],
|
|
115
|
-
['edges', 'call_graph'],
|
|
116
|
-
['data', 'data_flow'],
|
|
117
|
-
['constraints', 'constraints'],
|
|
118
|
-
['context', 'context'],
|
|
119
|
-
['ownership', 'ownership'],
|
|
120
|
-
['traits', 'type_info'],
|
|
121
|
-
['runtime', 'runtime'],
|
|
122
|
-
['semantics', 'logic'],
|
|
123
|
-
];
|
|
124
|
-
/**
|
|
125
|
-
* Compare two entities field-by-field. Returns which fields changed
|
|
126
|
-
* using user-facing names.
|
|
127
|
-
*/
|
|
128
|
-
function compareEntityFields(baseEntity, branchEntity) {
|
|
129
|
-
const changed = [];
|
|
130
|
-
for (const [field, displayName] of FIELD_MAP) {
|
|
131
|
-
const baseVal = baseEntity[field];
|
|
132
|
-
const branchVal = branchEntity[field];
|
|
133
|
-
// Both undefined/null → no change
|
|
134
|
-
if (baseVal == null && branchVal == null)
|
|
135
|
-
continue;
|
|
136
|
-
// One null, other not → changed
|
|
137
|
-
if (baseVal == null || branchVal == null) {
|
|
138
|
-
changed.push(displayName);
|
|
139
|
-
continue;
|
|
140
|
-
}
|
|
141
|
-
// Deep compare via JSON
|
|
142
|
-
if (JSON.stringify(baseVal) !== JSON.stringify(branchVal)) {
|
|
143
|
-
changed.push(displayName);
|
|
144
|
-
}
|
|
145
|
-
}
|
|
146
|
-
return changed;
|
|
147
|
-
}
|
|
148
|
-
// ─── Tool: diff_bundle ───────────────────────────────────────────
|
|
149
|
-
export async function diffBundle(args, loader) {
|
|
150
|
-
const projErr = validateProject(args.project, loader);
|
|
151
|
-
if (projErr)
|
|
152
|
-
return { error: projErr };
|
|
153
|
-
const baseEntities = loader.getEntities(args.project);
|
|
154
|
-
const baseManifest = loader.getManifest(args.project);
|
|
155
|
-
if (baseEntities.length === 0) {
|
|
156
|
-
return { error: 'No base entities loaded. Ensure the project has been extracted.' };
|
|
157
|
-
}
|
|
158
|
-
// Load branch bundle
|
|
159
|
-
let branchData;
|
|
160
|
-
try {
|
|
161
|
-
branchData = await loadBranchBundle(args.worktree_path);
|
|
162
|
-
}
|
|
163
|
-
catch (err) {
|
|
164
|
-
return { error: `Failed to load branch bundle: ${err.message}` };
|
|
165
|
-
}
|
|
166
|
-
const branchEntities = branchData.entities;
|
|
167
|
-
// Build entity maps by key
|
|
168
|
-
const baseSource = baseManifest?.commitSha ? '' : ''; // base entities have relative paths already
|
|
169
|
-
const baseMap = new Map();
|
|
170
|
-
for (const entity of baseEntities) {
|
|
171
|
-
const key = entityKey(entity);
|
|
172
|
-
baseMap.set(key, entity);
|
|
173
|
-
}
|
|
174
|
-
const branchMap = new Map();
|
|
175
|
-
for (const entity of branchEntities) {
|
|
176
|
-
const key = entityKey(entity);
|
|
177
|
-
branchMap.set(key, entity);
|
|
178
|
-
}
|
|
179
|
-
// Classify entities
|
|
180
|
-
const added = [];
|
|
181
|
-
const removed = [];
|
|
182
|
-
const modified = [];
|
|
183
|
-
const unchanged = [];
|
|
184
|
-
// Added: in branch but not base
|
|
185
|
-
for (const [key, entity] of branchMap) {
|
|
186
|
-
if (!baseMap.has(key)) {
|
|
187
|
-
added.push({
|
|
188
|
-
id: entity.id,
|
|
189
|
-
name: entityName(entity),
|
|
190
|
-
sourceFile: entity._sourceFile || null,
|
|
191
|
-
layer: entityLayer(entity),
|
|
192
|
-
});
|
|
193
|
-
}
|
|
194
|
-
}
|
|
195
|
-
// Removed: in base but not branch
|
|
196
|
-
for (const [key, entity] of baseMap) {
|
|
197
|
-
if (!branchMap.has(key)) {
|
|
198
|
-
removed.push({
|
|
199
|
-
id: entity.id,
|
|
200
|
-
name: entityName(entity),
|
|
201
|
-
sourceFile: entity._sourceFile || null,
|
|
202
|
-
layer: entityLayer(entity),
|
|
203
|
-
});
|
|
204
|
-
}
|
|
205
|
-
}
|
|
206
|
-
// Modified / Unchanged: in both
|
|
207
|
-
for (const [key, branchEntity] of branchMap) {
|
|
208
|
-
const baseEntity = baseMap.get(key);
|
|
209
|
-
if (!baseEntity)
|
|
210
|
-
continue; // already in added
|
|
211
|
-
const changedFields = compareEntityFields(baseEntity, branchEntity);
|
|
212
|
-
if (changedFields.length > 0) {
|
|
213
|
-
modified.push({
|
|
214
|
-
id: branchEntity.id,
|
|
215
|
-
name: entityName(branchEntity),
|
|
216
|
-
sourceFile: branchEntity._sourceFile || null,
|
|
217
|
-
layer: entityLayer(branchEntity),
|
|
218
|
-
changedFields,
|
|
219
|
-
});
|
|
220
|
-
}
|
|
221
|
-
else if (args.include_unchanged) {
|
|
222
|
-
unchanged.push({
|
|
223
|
-
id: branchEntity.id,
|
|
224
|
-
name: entityName(branchEntity),
|
|
225
|
-
sourceFile: branchEntity._sourceFile || null,
|
|
226
|
-
layer: entityLayer(branchEntity),
|
|
227
|
-
});
|
|
228
|
-
}
|
|
229
|
-
}
|
|
230
|
-
// Count unique files touched
|
|
231
|
-
const touchedFiles = new Set();
|
|
232
|
-
for (const e of [...added, ...removed, ...modified]) {
|
|
233
|
-
if (e.sourceFile)
|
|
234
|
-
touchedFiles.add(e.sourceFile);
|
|
235
|
-
}
|
|
236
|
-
const totalChanges = added.length + removed.length + modified.length;
|
|
237
|
-
const result = {
|
|
238
|
-
base: {
|
|
239
|
-
project: baseManifest?.projectName || args.project || 'default',
|
|
240
|
-
commitSha: baseManifest?.commitSha || '',
|
|
241
|
-
entityCount: baseEntities.length,
|
|
242
|
-
},
|
|
243
|
-
branch: {
|
|
244
|
-
path: args.worktree_path,
|
|
245
|
-
commitSha: branchData.commitSha,
|
|
246
|
-
entityCount: branchData.entityCount,
|
|
247
|
-
},
|
|
248
|
-
summary: {
|
|
249
|
-
added: added.length,
|
|
250
|
-
removed: removed.length,
|
|
251
|
-
modified: modified.length,
|
|
252
|
-
unchanged: baseEntities.length - removed.length - modified.length,
|
|
253
|
-
},
|
|
254
|
-
added,
|
|
255
|
-
removed,
|
|
256
|
-
modified,
|
|
257
|
-
_summary: `Branch modifies ${totalChanges} entities (${added.length} added, ${removed.length} removed, ${modified.length} modified) across ${touchedFiles.size} files`,
|
|
258
|
-
};
|
|
259
|
-
if (args.include_unchanged) {
|
|
260
|
-
result.unchanged = unchanged;
|
|
261
|
-
}
|
|
262
|
-
return result;
|
|
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
|
-
// Historical co-change from the lineage record (entity_transitions):
|
|
284
|
-
// entities that changed together in past commits are coupled IN PRACTICE,
|
|
285
|
-
// whatever static analysis says. Weight provisional — revisit once
|
|
286
|
-
// corpus-wide lineage allows an entropy-style calibration.
|
|
287
|
-
history: 0.10,
|
|
288
|
-
};
|
|
289
|
-
function jaccard(a, b) {
|
|
290
|
-
if (a.size === 0 && b.size === 0)
|
|
291
|
-
return 0;
|
|
292
|
-
let inter = 0;
|
|
293
|
-
for (const x of a)
|
|
294
|
-
if (b.has(x))
|
|
295
|
-
inter++;
|
|
296
|
-
return inter / (a.size + b.size - inter);
|
|
297
|
-
}
|
|
298
|
-
function intersect(a, b) {
|
|
299
|
-
const out = [];
|
|
300
|
-
for (const x of a)
|
|
301
|
-
if (b.has(x))
|
|
302
|
-
out.push(x);
|
|
303
|
-
return out;
|
|
304
|
-
}
|
|
305
|
-
/** Co-change pairs between two tasks, from a pre-fetched pair→count map keyed `${idA}${idB}` (sorted). */
|
|
306
|
-
function coChangePairs(taskA, taskB, coChange) {
|
|
307
|
-
if (!coChange || coChange.size === 0)
|
|
308
|
-
return [];
|
|
309
|
-
const out = [];
|
|
310
|
-
for (const a of taskA.entityIds) {
|
|
311
|
-
for (const b of taskB.entityIds) {
|
|
312
|
-
if (a === b)
|
|
313
|
-
continue;
|
|
314
|
-
const key = a < b ? `${a}${b}` : `${b}${a}`;
|
|
315
|
-
const n = coChange.get(key);
|
|
316
|
-
if (n && n >= 2)
|
|
317
|
-
out.push({ a, b, sharedCommits: n });
|
|
318
|
-
}
|
|
319
|
-
}
|
|
320
|
-
return out.sort((x, y) => y.sharedCommits - x.sharedCommits);
|
|
321
|
-
}
|
|
322
|
-
// ≥ this many shared commits between a cross-task entity pair = coupled in
|
|
323
|
-
// practice, escalate to Tier 2 even when statically disjoint everywhere.
|
|
324
|
-
const CO_CHANGE_TIER2_THRESHOLD = 3;
|
|
325
|
-
function classifyConflictTier(taskA, taskB, coChange = null) {
|
|
326
|
-
const sharedEntities = intersect(taskA.entityIds, taskB.entityIds);
|
|
327
|
-
const sharedFiles = intersect(taskA.files, taskB.files);
|
|
328
|
-
const sharedNeighbors = intersect(taskA.neighbors, taskB.neighbors);
|
|
329
|
-
// δ-contract overlap with read/write direction per side
|
|
330
|
-
const sharedTables = [];
|
|
331
|
-
const allTables = new Set([
|
|
332
|
-
...taskA.tablesRead, ...taskA.tablesWritten,
|
|
333
|
-
...taskB.tablesRead, ...taskB.tablesWritten,
|
|
334
|
-
]);
|
|
335
|
-
for (const table of allTables) {
|
|
336
|
-
const aW = taskA.tablesWritten.has(table);
|
|
337
|
-
const aR = taskA.tablesRead.has(table) || aW;
|
|
338
|
-
const bW = taskB.tablesWritten.has(table);
|
|
339
|
-
const bR = taskB.tablesRead.has(table) || bW;
|
|
340
|
-
if (aR && bR && (aW || bW)) {
|
|
341
|
-
// overlap matters only when at least one side writes
|
|
342
|
-
sharedTables.push({ table, taskA: aW ? 'write' : 'read', taskB: bW ? 'write' : 'read' });
|
|
343
|
-
}
|
|
344
|
-
}
|
|
345
|
-
const writeWrite = sharedTables.filter(t => t.taskA === 'write' && t.taskB === 'write');
|
|
346
|
-
const readWrite = sharedTables.filter(t => t.taskA !== t.taskB);
|
|
347
|
-
// Historical co-change (lineage evidence)
|
|
348
|
-
const sharedHistory = coChangePairs(taskA, taskB, coChange);
|
|
349
|
-
const maxShared = sharedHistory[0]?.sharedCommits || 0;
|
|
350
|
-
// Entropy-weighted evidence score in [0,1]
|
|
351
|
-
const tablesA = new Set([...taskA.tablesRead, ...taskA.tablesWritten]);
|
|
352
|
-
const tablesB = new Set([...taskB.tablesRead, ...taskB.tablesWritten]);
|
|
353
|
-
const w = CONFLICT_WEIGHTS;
|
|
354
|
-
const totalW = w.symbols + w.epsilon + w.delta + w.files + w.history;
|
|
355
|
-
const conflictScore = Math.round(((w.symbols * jaccard(taskA.entityIds, taskB.entityIds) +
|
|
356
|
-
w.epsilon * jaccard(taskA.neighbors, taskB.neighbors) +
|
|
357
|
-
w.delta * jaccard(tablesA, tablesB) +
|
|
358
|
-
w.files * jaccard(taskA.files, taskB.files) +
|
|
359
|
-
w.history * Math.min(1, maxShared / 5)) / totalW) * 1000) / 1000;
|
|
360
|
-
const base = { conflictScore, sharedFiles, sharedEntities, sharedTables, sharedNeighbors, sharedHistory };
|
|
361
|
-
// Tier 3 — must sequence
|
|
362
|
-
if (sharedEntities.length > 0) {
|
|
363
|
-
return {
|
|
364
|
-
...base,
|
|
365
|
-
tier: 3,
|
|
366
|
-
reason: `${sharedEntities.length} shared symbols — MUST sequence to maintain surgical splice integrity`,
|
|
367
|
-
};
|
|
368
|
-
}
|
|
369
|
-
if (writeWrite.length > 0) {
|
|
370
|
-
return {
|
|
371
|
-
...base,
|
|
372
|
-
tier: 3,
|
|
373
|
-
reason: `shared data contract — both tasks WRITE table(s) ${writeWrite.map(t => t.table).join(', ')}; ` +
|
|
374
|
-
'symbols are disjoint but the tasks collide through the data layer — MUST sequence',
|
|
375
|
-
};
|
|
376
|
-
}
|
|
377
|
-
// Tier 2 — parallelize with care
|
|
378
|
-
if (readWrite.length > 0) {
|
|
379
|
-
return {
|
|
380
|
-
...base,
|
|
381
|
-
tier: 2,
|
|
382
|
-
reason: `read/write data contract overlap on table(s) ${readWrite.map(t => t.table).join(', ')} — ` +
|
|
383
|
-
'parallel-safe only if the reader tolerates the writer\'s changes',
|
|
384
|
-
};
|
|
385
|
-
}
|
|
386
|
-
if (sharedFiles.length > 0) {
|
|
387
|
-
return {
|
|
388
|
-
...base,
|
|
389
|
-
tier: 2,
|
|
390
|
-
reason: `${sharedFiles.length} shared files but different symbols — safe to parallelize via surgical splicing`,
|
|
391
|
-
};
|
|
392
|
-
}
|
|
393
|
-
if (sharedNeighbors.length > 0) {
|
|
394
|
-
return {
|
|
395
|
-
...base,
|
|
396
|
-
tier: 2,
|
|
397
|
-
reason: `call-graph adjacency — tasks touch ${sharedNeighbors.length} shared neighbor(s) within 1 hop ` +
|
|
398
|
-
`(e.g. ${sharedNeighbors.slice(0, 3).join(', ')}) — review the shared interface before parallelizing`,
|
|
399
|
-
};
|
|
400
|
-
}
|
|
401
|
-
if (maxShared >= CO_CHANGE_TIER2_THRESHOLD) {
|
|
402
|
-
const top = sharedHistory[0];
|
|
403
|
-
return {
|
|
404
|
-
...base,
|
|
405
|
-
tier: 2,
|
|
406
|
-
reason: `historical co-change — ${top.a.split('#').pop()} and ${top.b.split('#').pop()} changed together ` +
|
|
407
|
-
`in ${top.sharedCommits} past commits (${sharedHistory.length} coupled pair${sharedHistory.length === 1 ? '' : 's'} total); ` +
|
|
408
|
-
'statically disjoint but coupled in practice — review for an implicit shared contract before parallelizing',
|
|
409
|
-
};
|
|
410
|
-
}
|
|
411
|
-
if (conflictScore > 0) {
|
|
412
|
-
return {
|
|
413
|
-
...base,
|
|
414
|
-
tier: 1,
|
|
415
|
-
reason: 'No conflicting overlap (weak affinity only — shared read-only tables or nearby code) — safe to parallelize',
|
|
416
|
-
};
|
|
417
|
-
}
|
|
418
|
-
return { ...base, tier: 1, reason: 'No shared symbols, files, data contracts, or call-graph adjacency — safe to parallelize' };
|
|
419
|
-
}
|
|
420
|
-
// ─── Execution Plan Builder ───────────────────────────────────────
|
|
421
|
-
/**
|
|
422
|
-
* Build execution plan from tier-3 conflict graph using connected components.
|
|
423
|
-
* Tasks with no tier-3 edges to each other run in parallel.
|
|
424
|
-
* Tasks in the same tier-3 component run sequentially.
|
|
425
|
-
*/
|
|
426
|
-
function buildExecutionPlan(taskIds, tier3Edges) {
|
|
427
|
-
// Build adjacency list for tier-3 conflicts
|
|
428
|
-
const adj = new Map();
|
|
429
|
-
for (const id of taskIds) {
|
|
430
|
-
adj.set(id, new Set());
|
|
431
|
-
}
|
|
432
|
-
for (const [a, b] of tier3Edges) {
|
|
433
|
-
adj.get(a)?.add(b);
|
|
434
|
-
adj.get(b)?.add(a);
|
|
435
|
-
}
|
|
436
|
-
// Find connected components via BFS
|
|
437
|
-
const visited = new Set();
|
|
438
|
-
const components = [];
|
|
439
|
-
for (const id of taskIds) {
|
|
440
|
-
if (visited.has(id))
|
|
441
|
-
continue;
|
|
442
|
-
const component = [];
|
|
443
|
-
const queue = [id];
|
|
444
|
-
visited.add(id);
|
|
445
|
-
while (queue.length > 0) {
|
|
446
|
-
const current = queue.shift();
|
|
447
|
-
component.push(current);
|
|
448
|
-
const neighbors = adj.get(current);
|
|
449
|
-
if (neighbors) {
|
|
450
|
-
for (const neighbor of neighbors) {
|
|
451
|
-
if (!visited.has(neighbor)) {
|
|
452
|
-
visited.add(neighbor);
|
|
453
|
-
queue.push(neighbor);
|
|
454
|
-
}
|
|
455
|
-
}
|
|
456
|
-
}
|
|
457
|
-
}
|
|
458
|
-
components.push(component);
|
|
459
|
-
}
|
|
460
|
-
// Build groups
|
|
461
|
-
const groups = components.map(component => {
|
|
462
|
-
if (component.length === 1) {
|
|
463
|
-
return { tasks: component, sequential: false };
|
|
464
|
-
}
|
|
465
|
-
return { tasks: component, sequential: true, order: component };
|
|
466
|
-
});
|
|
467
|
-
// Count parallelizable groups
|
|
468
|
-
const parallelGroups = groups.length;
|
|
469
|
-
const sequentialCount = groups.filter(g => g.sequential).length;
|
|
470
|
-
const parallelCount = groups.filter(g => !g.sequential).length;
|
|
471
|
-
let summaryParts = [`${taskIds.length} tasks`];
|
|
472
|
-
if (parallelCount > 0) {
|
|
473
|
-
summaryParts.push(`${parallelCount === taskIds.length ? 'all' : parallelCount} can run in parallel`);
|
|
474
|
-
}
|
|
475
|
-
if (sequentialCount > 0) {
|
|
476
|
-
const seqTasks = groups.filter(g => g.sequential).reduce((sum, g) => sum + g.tasks.length, 0);
|
|
477
|
-
summaryParts.push(`${seqTasks} must be sequenced (${sequentialCount} sequential group${sequentialCount > 1 ? 's' : ''})`);
|
|
478
|
-
}
|
|
479
|
-
return {
|
|
480
|
-
parallelGroups,
|
|
481
|
-
groups,
|
|
482
|
-
_summary: summaryParts.join(': '),
|
|
483
|
-
};
|
|
484
|
-
}
|
|
485
|
-
// ─── Tool: conflict_matrix ────────────────────────────────────────
|
|
486
|
-
export async function conflictMatrix(args, loader, extras = {}) {
|
|
487
|
-
const projErr = validateProject(args.project, loader);
|
|
488
|
-
if (projErr)
|
|
489
|
-
return { error: projErr };
|
|
490
|
-
const { tasks } = args;
|
|
491
|
-
if (!tasks || tasks.length < 2) {
|
|
492
|
-
return { error: 'At least 2 tasks are required to compute a conflict matrix.' };
|
|
493
|
-
}
|
|
494
|
-
const warnings = [];
|
|
495
|
-
// Resolve entity sets for each task
|
|
496
|
-
const resolvedTasks = [];
|
|
497
|
-
const graph = getGraph(args.project, loader);
|
|
498
|
-
for (const task of tasks) {
|
|
499
|
-
const entityIds = new Set();
|
|
500
|
-
const files = new Set();
|
|
501
|
-
const entities = [];
|
|
502
|
-
const notFound = [];
|
|
503
|
-
const dimensions = new Set(task.dimensions || []);
|
|
504
|
-
for (const nameOrId of task.entity_ids) {
|
|
505
|
-
const entity = loader.getEntityById(nameOrId, args.project)
|
|
506
|
-
|| loader.getEntityByName(nameOrId, args.project);
|
|
507
|
-
if (entity) {
|
|
508
|
-
entityIds.add(entity.id);
|
|
509
|
-
entities.push(entity);
|
|
510
|
-
if (entity._sourceFile)
|
|
511
|
-
files.add(entity._sourceFile);
|
|
512
|
-
}
|
|
513
|
-
else {
|
|
514
|
-
notFound.push(nameOrId);
|
|
515
|
-
}
|
|
516
|
-
}
|
|
517
|
-
if (notFound.length > 0) {
|
|
518
|
-
warnings.push(`Task "${task.id}": entities not found: ${notFound.join(', ')}`);
|
|
519
|
-
}
|
|
520
|
-
// Expand blast radius if requested
|
|
521
|
-
if (task.expand_blast_radius && entityIds.size > 0) {
|
|
522
|
-
const blastResult = computeBlastRadius(graph, entityIds);
|
|
523
|
-
for (const affectedId of blastResult.affected) {
|
|
524
|
-
if (!entityIds.has(affectedId)) {
|
|
525
|
-
entityIds.add(affectedId);
|
|
526
|
-
const affectedEntity = graph.entityById.get(affectedId);
|
|
527
|
-
if (affectedEntity) {
|
|
528
|
-
entities.push(affectedEntity);
|
|
529
|
-
if (affectedEntity._sourceFile)
|
|
530
|
-
files.add(affectedEntity._sourceFile);
|
|
531
|
-
}
|
|
532
|
-
}
|
|
533
|
-
}
|
|
534
|
-
}
|
|
535
|
-
// δ-contract surface: tables read/written by the task's entities
|
|
536
|
-
const { reads: tablesRead, writes: tablesWritten } = tableContract(entities);
|
|
537
|
-
// ε surface: 1-hop call-graph neighborhood (always computed — cheap, and
|
|
538
|
-
// it catches adjacent-task conflicts even without blast-radius expansion)
|
|
539
|
-
const neighbors = new Set();
|
|
540
|
-
for (const id of entityIds) {
|
|
541
|
-
for (const c of graph.callers.get(id) || [])
|
|
542
|
-
if (!entityIds.has(c))
|
|
543
|
-
neighbors.add(c);
|
|
544
|
-
for (const c of graph.callees.get(id) || [])
|
|
545
|
-
if (!entityIds.has(c))
|
|
546
|
-
neighbors.add(c);
|
|
547
|
-
}
|
|
548
|
-
resolvedTasks.push({ id: task.id, entityIds, files, entities, dimensions, tablesRead, tablesWritten, neighbors });
|
|
549
|
-
}
|
|
550
|
-
// Pairwise comparison
|
|
551
|
-
const matrix = [];
|
|
552
|
-
// Lineage-backed co-change evidence, fetched once for all tasks' entities
|
|
553
|
-
let coChange = null;
|
|
554
|
-
if (extras.fetchCoChanges) {
|
|
555
|
-
try {
|
|
556
|
-
const allIds = [...new Set(resolvedTasks.flatMap(t => [...t.entityIds]))];
|
|
557
|
-
coChange = await extras.fetchCoChanges(allIds);
|
|
558
|
-
}
|
|
559
|
-
catch {
|
|
560
|
-
warnings.push('co-change history unavailable for this run (lineage lookup failed) — tiers computed from static evidence only');
|
|
561
|
-
}
|
|
562
|
-
}
|
|
563
|
-
const tier3Edges = [];
|
|
564
|
-
for (let i = 0; i < resolvedTasks.length; i++) {
|
|
565
|
-
for (let j = i + 1; j < resolvedTasks.length; j++) {
|
|
566
|
-
const taskA = resolvedTasks[i];
|
|
567
|
-
const taskB = resolvedTasks[j];
|
|
568
|
-
const result = classifyConflictTier(taskA, taskB, coChange);
|
|
569
|
-
const entry = {
|
|
570
|
-
taskA: taskA.id,
|
|
571
|
-
taskB: taskB.id,
|
|
572
|
-
tier: result.tier,
|
|
573
|
-
reason: result.reason,
|
|
574
|
-
conflictScore: result.conflictScore,
|
|
575
|
-
};
|
|
576
|
-
if (result.sharedFiles.length > 0)
|
|
577
|
-
entry.sharedFiles = result.sharedFiles;
|
|
578
|
-
if (result.sharedEntities.length > 0)
|
|
579
|
-
entry.sharedEntities = result.sharedEntities;
|
|
580
|
-
if (result.sharedTables.length > 0)
|
|
581
|
-
entry.sharedTables = result.sharedTables;
|
|
582
|
-
if (result.sharedNeighbors.length > 0)
|
|
583
|
-
entry.sharedNeighbors = result.sharedNeighbors.slice(0, 10);
|
|
584
|
-
if (result.sharedHistory && result.sharedHistory.length > 0)
|
|
585
|
-
entry.sharedHistory = result.sharedHistory.slice(0, 10);
|
|
586
|
-
matrix.push(entry);
|
|
587
|
-
if (result.tier === 3) {
|
|
588
|
-
tier3Edges.push([taskA.id, taskB.id]);
|
|
589
|
-
}
|
|
590
|
-
}
|
|
591
|
-
}
|
|
592
|
-
// Build execution plan
|
|
593
|
-
const taskIds = resolvedTasks.map(t => t.id);
|
|
594
|
-
const executionPlan = buildExecutionPlan(taskIds, tier3Edges);
|
|
595
|
-
const result = {
|
|
596
|
-
taskCount: tasks.length,
|
|
597
|
-
matrix,
|
|
598
|
-
executionPlan,
|
|
599
|
-
};
|
|
600
|
-
if (warnings.length > 0) {
|
|
601
|
-
result.warnings = warnings;
|
|
602
|
-
}
|
|
603
|
-
return result;
|
|
604
|
-
}
|