trellis 1.0.8 → 2.0.6

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.
Files changed (107) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +564 -83
  3. package/bin/trellis.mjs +2 -0
  4. package/dist/cli/index.js +4718 -0
  5. package/dist/core/index.js +12 -0
  6. package/dist/decisions/index.js +19 -0
  7. package/dist/embeddings/index.js +43 -0
  8. package/dist/index-1j1anhmr.js +4038 -0
  9. package/dist/index-3s0eak0p.js +1556 -0
  10. package/dist/index-8pce39mh.js +272 -0
  11. package/dist/index-a76rekgs.js +67 -0
  12. package/dist/index-cy9k1g6v.js +684 -0
  13. package/dist/index-fd4e26s4.js +69 -0
  14. package/dist/{store/eav-store.js → index-gkvhzm9f.js} +4 -6
  15. package/dist/index-gnw8d7d6.js +51 -0
  16. package/dist/index-vkpkfwhq.js +817 -0
  17. package/dist/index.js +118 -2876
  18. package/dist/links/index.js +55 -0
  19. package/dist/transformers-m9je15kg.js +32491 -0
  20. package/dist/vcs/index.js +110 -0
  21. package/logo.png +0 -0
  22. package/logo.svg +9 -0
  23. package/package.json +79 -76
  24. package/src/cli/index.ts +2340 -0
  25. package/src/core/index.ts +35 -0
  26. package/src/core/kernel/middleware.ts +44 -0
  27. package/src/core/persist/backend.ts +64 -0
  28. package/src/core/store/eav-store.ts +467 -0
  29. package/src/decisions/auto-capture.ts +136 -0
  30. package/src/decisions/hooks.ts +163 -0
  31. package/src/decisions/index.ts +261 -0
  32. package/src/decisions/types.ts +103 -0
  33. package/src/embeddings/chunker.ts +327 -0
  34. package/src/embeddings/index.ts +41 -0
  35. package/src/embeddings/model.ts +95 -0
  36. package/src/embeddings/search.ts +305 -0
  37. package/src/embeddings/store.ts +313 -0
  38. package/src/embeddings/types.ts +85 -0
  39. package/src/engine.ts +1083 -0
  40. package/src/garden/cluster.ts +330 -0
  41. package/src/garden/garden.ts +306 -0
  42. package/src/garden/index.ts +29 -0
  43. package/src/git/git-exporter.ts +286 -0
  44. package/src/git/git-importer.ts +329 -0
  45. package/src/git/git-reader.ts +189 -0
  46. package/src/git/index.ts +22 -0
  47. package/src/identity/governance.ts +211 -0
  48. package/src/identity/identity.ts +224 -0
  49. package/src/identity/index.ts +30 -0
  50. package/src/identity/signing-middleware.ts +97 -0
  51. package/src/index.ts +20 -0
  52. package/src/links/index.ts +49 -0
  53. package/src/links/lifecycle.ts +400 -0
  54. package/src/links/parser.ts +484 -0
  55. package/src/links/ref-index.ts +186 -0
  56. package/src/links/resolver.ts +314 -0
  57. package/src/links/types.ts +108 -0
  58. package/src/mcp/index.ts +22 -0
  59. package/src/mcp/server.ts +1278 -0
  60. package/src/semantic/csharp-parser.ts +493 -0
  61. package/src/semantic/go-parser.ts +585 -0
  62. package/src/semantic/index.ts +34 -0
  63. package/src/semantic/java-parser.ts +456 -0
  64. package/src/semantic/python-parser.ts +659 -0
  65. package/src/semantic/ruby-parser.ts +446 -0
  66. package/src/semantic/rust-parser.ts +784 -0
  67. package/src/semantic/semantic-merge.ts +210 -0
  68. package/src/semantic/ts-parser.ts +681 -0
  69. package/src/semantic/types.ts +175 -0
  70. package/src/sync/index.ts +32 -0
  71. package/src/sync/memory-transport.ts +66 -0
  72. package/src/sync/reconciler.ts +237 -0
  73. package/src/sync/sync-engine.ts +258 -0
  74. package/src/sync/types.ts +104 -0
  75. package/src/vcs/blob-store.ts +124 -0
  76. package/src/vcs/branch.ts +150 -0
  77. package/src/vcs/checkpoint.ts +64 -0
  78. package/src/vcs/decompose.ts +469 -0
  79. package/src/vcs/diff.ts +409 -0
  80. package/src/vcs/engine-context.ts +26 -0
  81. package/src/vcs/index.ts +23 -0
  82. package/src/vcs/issue.ts +800 -0
  83. package/src/vcs/merge.ts +425 -0
  84. package/src/vcs/milestone.ts +124 -0
  85. package/src/vcs/ops.ts +59 -0
  86. package/src/vcs/types.ts +213 -0
  87. package/src/vcs/vcs-middleware.ts +81 -0
  88. package/src/watcher/fs-watcher.ts +217 -0
  89. package/src/watcher/index.ts +9 -0
  90. package/src/watcher/ingestion.ts +116 -0
  91. package/dist/ai/index.js +0 -688
  92. package/dist/cli/server.js +0 -3321
  93. package/dist/cli/tql.js +0 -5282
  94. package/dist/client/tql-client.js +0 -108
  95. package/dist/graph/index.js +0 -2248
  96. package/dist/kernel/logic-middleware.js +0 -179
  97. package/dist/kernel/middleware.js +0 -0
  98. package/dist/kernel/operations.js +0 -32
  99. package/dist/kernel/schema-middleware.js +0 -34
  100. package/dist/kernel/security-middleware.js +0 -53
  101. package/dist/kernel/trellis-kernel.js +0 -2239
  102. package/dist/kernel/workspace.js +0 -91
  103. package/dist/persist/backend.js +0 -0
  104. package/dist/persist/sqlite-backend.js +0 -123
  105. package/dist/query/index.js +0 -1643
  106. package/dist/server/index.js +0 -3309
  107. package/dist/workflows/index.js +0 -3160
@@ -0,0 +1,659 @@
1
+ /**
2
+ * Python Parser Adapter
3
+ *
4
+ * Tier 1 regex-based parser for Python source files.
5
+ * Extracts classes, functions, decorators, async functions,
6
+ * type hints, imports, and module-level variables.
7
+ *
8
+ * @see TRL-5
9
+ */
10
+
11
+ import type {
12
+ ParserAdapter,
13
+ ParseResult,
14
+ ASTEntity,
15
+ ASTEntityKind,
16
+ ImportRelation,
17
+ ExportRelation,
18
+ SemanticPatch,
19
+ } from './types.js';
20
+
21
+ // ---------------------------------------------------------------------------
22
+ // Parser Adapter
23
+ // ---------------------------------------------------------------------------
24
+
25
+ export const pythonParser: ParserAdapter = {
26
+ languages: ['python'],
27
+
28
+ parse(content: string, filePath: string): ParseResult {
29
+ const fileEntityId = `file:${filePath}`;
30
+
31
+ return {
32
+ fileEntityId,
33
+ filePath,
34
+ language: 'python',
35
+ declarations: extractDeclarations(content, filePath),
36
+ imports: extractImports(content),
37
+ exports: extractExports(content),
38
+ };
39
+ },
40
+
41
+ diff(oldResult: ParseResult, newResult: ParseResult): SemanticPatch[] {
42
+ return computeSemanticDiff(oldResult, newResult);
43
+ },
44
+ };
45
+
46
+ // ---------------------------------------------------------------------------
47
+ // Declaration extraction
48
+ // ---------------------------------------------------------------------------
49
+
50
+ /**
51
+ * Extract top-level declarations from Python source.
52
+ * Handles: class, def, async def, and module-level assignments.
53
+ * Respects indentation to determine block boundaries.
54
+ */
55
+ function extractDeclarations(content: string, filePath: string): ASTEntity[] {
56
+ const declarations: ASTEntity[] = [];
57
+ const lines = content.split('\n');
58
+
59
+ let i = 0;
60
+ while (i < lines.length) {
61
+ const line = lines[i];
62
+ const trimmed = line.trim();
63
+
64
+ // Skip empty lines, comments, string literals used as docstrings
65
+ if (
66
+ !trimmed ||
67
+ trimmed.startsWith('#') ||
68
+ trimmed.startsWith('"""') ||
69
+ trimmed.startsWith("'''")
70
+ ) {
71
+ i++;
72
+ continue;
73
+ }
74
+
75
+ // Only consider top-level (zero indentation)
76
+ if ((line.length > 0 && line[0] === ' ') || line[0] === '\t') {
77
+ i++;
78
+ continue;
79
+ }
80
+
81
+ // Collect decorators
82
+ const decorators: string[] = [];
83
+ const decoratorStart = i;
84
+ while (i < lines.length && lines[i].trim().startsWith('@')) {
85
+ decorators.push(lines[i].trim());
86
+ i++;
87
+ }
88
+
89
+ if (i >= lines.length) break;
90
+ const declLine = lines[i].trim();
91
+
92
+ // Class definition
93
+ let match = declLine.match(/^class\s+(\w+)/);
94
+ if (match) {
95
+ const result = extractIndentedBlock(
96
+ match[1],
97
+ 'ClassDef',
98
+ lines,
99
+ decorators.length > 0 ? decoratorStart : i,
100
+ i,
101
+ filePath,
102
+ decorators,
103
+ );
104
+ declarations.push(result.entity);
105
+ i = result.endLine + 1;
106
+ continue;
107
+ }
108
+
109
+ // Async function
110
+ match = declLine.match(/^async\s+def\s+(\w+)/);
111
+ if (match) {
112
+ const result = extractIndentedBlock(
113
+ match[1],
114
+ 'FunctionDef',
115
+ lines,
116
+ decorators.length > 0 ? decoratorStart : i,
117
+ i,
118
+ filePath,
119
+ decorators,
120
+ );
121
+ declarations.push(result.entity);
122
+ i = result.endLine + 1;
123
+ continue;
124
+ }
125
+
126
+ // Function definition
127
+ match = declLine.match(/^def\s+(\w+)/);
128
+ if (match) {
129
+ const result = extractIndentedBlock(
130
+ match[1],
131
+ 'FunctionDef',
132
+ lines,
133
+ decorators.length > 0 ? decoratorStart : i,
134
+ i,
135
+ filePath,
136
+ decorators,
137
+ );
138
+ declarations.push(result.entity);
139
+ i = result.endLine + 1;
140
+ continue;
141
+ }
142
+
143
+ // Module-level variable assignment (e.g. `MY_VAR = ...` or `MY_VAR: int = ...`)
144
+ match = declLine.match(/^([A-Za-z_]\w*)\s*(?::\s*\w[^=]*)?\s*=/);
145
+ if (
146
+ match &&
147
+ match[1] !== '__all__' &&
148
+ !declLine.startsWith('import ') &&
149
+ !declLine.startsWith('from ')
150
+ ) {
151
+ const result = extractAssignment(match[1], lines, i, filePath);
152
+ if (decorators.length === 0) {
153
+ declarations.push(result.entity);
154
+ }
155
+ i = result.endLine + 1;
156
+ continue;
157
+ }
158
+
159
+ // If we collected decorators but no matching def/class, skip
160
+ if (decorators.length > 0) {
161
+ i++;
162
+ continue;
163
+ }
164
+
165
+ i++;
166
+ }
167
+
168
+ return declarations;
169
+ }
170
+
171
+ // ---------------------------------------------------------------------------
172
+ // Block extraction (indentation-based)
173
+ // ---------------------------------------------------------------------------
174
+
175
+ interface ExtractionResult {
176
+ entity: ASTEntity;
177
+ endLine: number;
178
+ }
179
+
180
+ /**
181
+ * Extract a Python indentation-delimited block (class or function).
182
+ */
183
+ function extractIndentedBlock(
184
+ name: string,
185
+ kind: ASTEntityKind,
186
+ lines: string[],
187
+ startLine: number,
188
+ defLine: number,
189
+ filePath: string,
190
+ decorators: string[],
191
+ ): ExtractionResult {
192
+ // Find the colon at end of def/class line (may span multiple lines for long signatures)
193
+ let headerEnd = defLine;
194
+ while (headerEnd < lines.length && !lines[headerEnd].includes(':')) {
195
+ headerEnd++;
196
+ }
197
+
198
+ // Determine body indentation from the first non-empty line after header
199
+ let bodyIndent = -1;
200
+ let endLine = headerEnd;
201
+
202
+ for (let i = headerEnd + 1; i < lines.length; i++) {
203
+ const line = lines[i];
204
+ const trimmed = line.trim();
205
+
206
+ // Skip empty lines and comment-only lines
207
+ if (!trimmed || trimmed.startsWith('#')) {
208
+ endLine = i;
209
+ continue;
210
+ }
211
+
212
+ const indent = line.length - line.trimStart().length;
213
+
214
+ if (bodyIndent < 0) {
215
+ // First non-empty line in body sets the expected indent
216
+ bodyIndent = indent;
217
+ endLine = i;
218
+ continue;
219
+ }
220
+
221
+ if (indent >= bodyIndent) {
222
+ endLine = i;
223
+ } else {
224
+ // Dedented — block is over
225
+ break;
226
+ }
227
+ }
228
+
229
+ // Trim trailing empty lines from the block
230
+ while (endLine > headerEnd && lines[endLine].trim() === '') {
231
+ endLine--;
232
+ }
233
+
234
+ const rawText = lines.slice(startLine, endLine + 1).join('\n');
235
+ const startOffset =
236
+ lines.slice(0, startLine).join('\n').length + (startLine > 0 ? 1 : 0);
237
+
238
+ const children =
239
+ kind === 'ClassDef'
240
+ ? extractClassMembers(
241
+ lines,
242
+ headerEnd,
243
+ endLine,
244
+ bodyIndent,
245
+ name,
246
+ filePath,
247
+ )
248
+ : [];
249
+
250
+ return {
251
+ entity: {
252
+ id: makeEntityId(filePath, kind, name),
253
+ kind,
254
+ name,
255
+ scopePath: name,
256
+ span: [startOffset, startOffset + rawText.length],
257
+ rawText,
258
+ signature: normalizeSignature(rawText),
259
+ children,
260
+ },
261
+ endLine,
262
+ };
263
+ }
264
+
265
+ /**
266
+ * Extract a module-level variable assignment (may span multiple lines).
267
+ */
268
+ function extractAssignment(
269
+ name: string,
270
+ lines: string[],
271
+ startLine: number,
272
+ filePath: string,
273
+ ): ExtractionResult {
274
+ let endLine = startLine;
275
+ let depth = 0;
276
+
277
+ for (let i = startLine; i < lines.length; i++) {
278
+ const line = lines[i];
279
+ for (const ch of line) {
280
+ if (ch === '(' || ch === '[' || ch === '{') depth++;
281
+ else if (ch === ')' || ch === ']' || ch === '}') depth--;
282
+ }
283
+
284
+ endLine = i;
285
+
286
+ // Assignment ends when depth returns to 0 and we're past the first line,
287
+ // or the next line is a new top-level statement
288
+ if (depth <= 0 && i > startLine) break;
289
+ if (depth <= 0 && i === startLine) {
290
+ // Check if next line is indented (continuation) or a new statement
291
+ if (i + 1 < lines.length) {
292
+ const next = lines[i + 1];
293
+ if (!next.trim() || (!next.startsWith(' ') && !next.startsWith('\t'))) {
294
+ break;
295
+ }
296
+ } else {
297
+ break;
298
+ }
299
+ }
300
+ }
301
+
302
+ const rawText = lines.slice(startLine, endLine + 1).join('\n');
303
+ const startOffset =
304
+ lines.slice(0, startLine).join('\n').length + (startLine > 0 ? 1 : 0);
305
+
306
+ return {
307
+ entity: {
308
+ id: makeEntityId(filePath, 'VariableDecl', name),
309
+ kind: 'VariableDecl',
310
+ name,
311
+ scopePath: name,
312
+ span: [startOffset, startOffset + rawText.length],
313
+ rawText,
314
+ signature: normalizeSignature(rawText),
315
+ children: [],
316
+ },
317
+ endLine,
318
+ };
319
+ }
320
+
321
+ // ---------------------------------------------------------------------------
322
+ // Class member extraction
323
+ // ---------------------------------------------------------------------------
324
+
325
+ function extractClassMembers(
326
+ lines: string[],
327
+ headerEnd: number,
328
+ blockEnd: number,
329
+ bodyIndent: number,
330
+ className: string,
331
+ filePath: string,
332
+ ): ASTEntity[] {
333
+ const children: ASTEntity[] = [];
334
+ if (bodyIndent < 0) return children;
335
+
336
+ for (let i = headerEnd + 1; i <= blockEnd; i++) {
337
+ const line = lines[i];
338
+ const trimmed = line.trim();
339
+ if (
340
+ !trimmed ||
341
+ trimmed.startsWith('#') ||
342
+ trimmed.startsWith('"""') ||
343
+ trimmed.startsWith("'''")
344
+ )
345
+ continue;
346
+
347
+ const indent = line.length - line.trimStart().length;
348
+ if (indent !== bodyIndent) continue;
349
+
350
+ // Skip decorators (they precede a method)
351
+ if (trimmed.startsWith('@')) continue;
352
+
353
+ // Method (def / async def)
354
+ let match = trimmed.match(/^(?:async\s+)?def\s+(\w+)/);
355
+ if (match) {
356
+ const methodName = match[1];
357
+ const kind: ASTEntityKind =
358
+ methodName === '__init__' ? 'Constructor' : 'MethodDef';
359
+ children.push({
360
+ id: makeEntityId(filePath, kind, `${className}.${methodName}`),
361
+ kind,
362
+ name: methodName,
363
+ scopePath: `${className}.${methodName}`,
364
+ span: [0, 0],
365
+ rawText: trimmed,
366
+ signature: normalizeSignature(trimmed),
367
+ children: [],
368
+ });
369
+ continue;
370
+ }
371
+
372
+ // Class-level variable / property (e.g. `name: str = "default"`)
373
+ match = trimmed.match(/^(\w+)\s*(?::\s*\S[^=]*)?\s*=/);
374
+ if (match) {
375
+ children.push({
376
+ id: makeEntityId(filePath, 'PropertyDef', `${className}.${match[1]}`),
377
+ kind: 'PropertyDef',
378
+ name: match[1],
379
+ scopePath: `${className}.${match[1]}`,
380
+ span: [0, 0],
381
+ rawText: trimmed,
382
+ signature: normalizeSignature(trimmed),
383
+ children: [],
384
+ });
385
+ }
386
+ }
387
+
388
+ return children;
389
+ }
390
+
391
+ // ---------------------------------------------------------------------------
392
+ // Import extraction
393
+ // ---------------------------------------------------------------------------
394
+
395
+ function extractImports(content: string): ImportRelation[] {
396
+ const imports: ImportRelation[] = [];
397
+ const lines = content.split('\n');
398
+
399
+ for (let i = 0; i < lines.length; i++) {
400
+ const trimmed = lines[i].trim();
401
+
402
+ // from module import ...
403
+ let match = trimmed.match(/^from\s+([\w.]+)\s+import\s+(.+)/);
404
+ if (match) {
405
+ let specText = match[2];
406
+
407
+ // Handle multi-line imports: from mod import (a, b, ...)
408
+ if (specText.includes('(') && !specText.includes(')')) {
409
+ while (i + 1 < lines.length && !specText.includes(')')) {
410
+ i++;
411
+ specText += ' ' + lines[i].trim();
412
+ }
413
+ }
414
+
415
+ // Strip parens and trailing comments
416
+ specText = specText.replace(/[()]/g, '').replace(/#.*$/, '');
417
+ const specifiers = specText
418
+ .split(',')
419
+ .map(
420
+ (s) =>
421
+ s
422
+ .trim()
423
+ .split(/\s+as\s+/)
424
+ .pop()!,
425
+ )
426
+ .filter(Boolean);
427
+
428
+ const isWildcard = specifiers.includes('*');
429
+
430
+ imports.push({
431
+ source: match[1],
432
+ specifiers: isWildcard ? ['*'] : specifiers,
433
+ isDefault: false,
434
+ isNamespace: isWildcard,
435
+ rawText: trimmed,
436
+ span: [0, trimmed.length],
437
+ });
438
+ continue;
439
+ }
440
+
441
+ // import module [as alias]
442
+ match = trimmed.match(/^import\s+([\w.,\s]+)/);
443
+ if (match) {
444
+ const modules = match[1].split(',').map((m) => m.trim());
445
+ for (const mod of modules) {
446
+ const parts = mod.split(/\s+as\s+/);
447
+ const source = parts[0].trim();
448
+ const alias = parts.length > 1 ? parts[1].trim() : source;
449
+ imports.push({
450
+ source,
451
+ specifiers: [alias],
452
+ isDefault: true,
453
+ isNamespace: false,
454
+ rawText: trimmed,
455
+ span: [0, trimmed.length],
456
+ });
457
+ }
458
+ }
459
+ }
460
+
461
+ return imports;
462
+ }
463
+
464
+ // ---------------------------------------------------------------------------
465
+ // Export extraction
466
+ // ---------------------------------------------------------------------------
467
+
468
+ /**
469
+ * Python uses __all__ to declare public API. We also treat
470
+ * top-level public names (not starting with _) as exports.
471
+ */
472
+ function extractExports(content: string): ExportRelation[] {
473
+ const exports: ExportRelation[] = [];
474
+
475
+ // Look for __all__ = [...]
476
+ const allMatch = content.match(/__all__\s*=\s*\[([^\]]*)\]/s);
477
+ if (allMatch) {
478
+ const names = allMatch[1]
479
+ .replace(/['"]/g, '')
480
+ .split(',')
481
+ .map((s) => s.trim())
482
+ .filter(Boolean);
483
+
484
+ for (const name of names) {
485
+ exports.push({
486
+ name,
487
+ isDefault: false,
488
+ rawText: `__all__: ${name}`,
489
+ span: [0, 0],
490
+ });
491
+ }
492
+ }
493
+
494
+ return exports;
495
+ }
496
+
497
+ // ---------------------------------------------------------------------------
498
+ // Semantic diff (reuses generic algorithm from ts-parser pattern)
499
+ // ---------------------------------------------------------------------------
500
+
501
+ function computeSemanticDiff(
502
+ oldResult: ParseResult,
503
+ newResult: ParseResult,
504
+ ): SemanticPatch[] {
505
+ const patches: SemanticPatch[] = [];
506
+ const fileId = newResult.fileEntityId;
507
+
508
+ const oldDecls = new Map(oldResult.declarations.map((d) => [d.id, d]));
509
+ const newDecls = new Map(newResult.declarations.map((d) => [d.id, d]));
510
+
511
+ // Detect additions (+ rename detection)
512
+ for (const [id, entity] of newDecls) {
513
+ if (!oldDecls.has(id)) {
514
+ const oldEntity = findRenamedEntity(
515
+ entity,
516
+ oldResult.declarations,
517
+ newDecls,
518
+ );
519
+ if (oldEntity) {
520
+ patches.push({
521
+ kind: 'symbolRename',
522
+ entityId: oldEntity.id,
523
+ oldName: oldEntity.name,
524
+ newName: entity.name,
525
+ });
526
+ } else {
527
+ patches.push({ kind: 'symbolAdd', entity });
528
+ }
529
+ }
530
+ }
531
+
532
+ // Detect removals
533
+ for (const [id, entity] of oldDecls) {
534
+ if (!newDecls.has(id)) {
535
+ const wasRenamed = findRenamedEntity(
536
+ entity,
537
+ newResult.declarations,
538
+ oldDecls,
539
+ );
540
+ if (!wasRenamed) {
541
+ patches.push({
542
+ kind: 'symbolRemove',
543
+ entityId: id,
544
+ entityName: entity.name,
545
+ });
546
+ }
547
+ }
548
+ }
549
+
550
+ // Detect modifications
551
+ for (const [id, newEntity] of newDecls) {
552
+ const oldEntity = oldDecls.get(id);
553
+ if (oldEntity && oldEntity.signature !== newEntity.signature) {
554
+ patches.push({
555
+ kind: 'symbolModify',
556
+ entityId: id,
557
+ entityName: newEntity.name,
558
+ oldSignature: oldEntity.signature,
559
+ newSignature: newEntity.signature,
560
+ oldRawText: oldEntity.rawText,
561
+ newRawText: newEntity.rawText,
562
+ });
563
+ }
564
+ }
565
+
566
+ // Diff imports
567
+ const oldImports = new Map(oldResult.imports.map((imp) => [imp.source, imp]));
568
+ const newImports = new Map(newResult.imports.map((imp) => [imp.source, imp]));
569
+
570
+ for (const [source, imp] of newImports) {
571
+ const oldImp = oldImports.get(source);
572
+ if (!oldImp) {
573
+ patches.push({
574
+ kind: 'importAdd',
575
+ fileId,
576
+ source,
577
+ specifiers: imp.specifiers,
578
+ rawText: imp.rawText,
579
+ });
580
+ } else if (
581
+ JSON.stringify(oldImp.specifiers.sort()) !==
582
+ JSON.stringify(imp.specifiers.sort())
583
+ ) {
584
+ patches.push({
585
+ kind: 'importModify',
586
+ fileId,
587
+ source,
588
+ oldSpecifiers: oldImp.specifiers,
589
+ newSpecifiers: imp.specifiers,
590
+ });
591
+ }
592
+ }
593
+
594
+ for (const [source] of oldImports) {
595
+ if (!newImports.has(source)) {
596
+ patches.push({ kind: 'importRemove', fileId, source });
597
+ }
598
+ }
599
+
600
+ // Diff exports
601
+ const oldExports = new Map(oldResult.exports.map((exp) => [exp.name, exp]));
602
+ const newExports = new Map(newResult.exports.map((exp) => [exp.name, exp]));
603
+
604
+ for (const [name, exp] of newExports) {
605
+ if (!oldExports.has(name)) {
606
+ patches.push({ kind: 'exportAdd', fileId, name, rawText: exp.rawText });
607
+ }
608
+ }
609
+
610
+ for (const [name] of oldExports) {
611
+ if (!newExports.has(name)) {
612
+ patches.push({ kind: 'exportRemove', fileId, name });
613
+ }
614
+ }
615
+
616
+ return patches;
617
+ }
618
+
619
+ // ---------------------------------------------------------------------------
620
+ // Helpers
621
+ // ---------------------------------------------------------------------------
622
+
623
+ function findRenamedEntity(
624
+ entity: ASTEntity,
625
+ candidates: ASTEntity[],
626
+ existingIds: Map<string, ASTEntity>,
627
+ ): ASTEntity | null {
628
+ for (const candidate of candidates) {
629
+ if (candidate.kind !== entity.kind) continue;
630
+ if (candidate.name === entity.name) continue;
631
+ if (existingIds.has(candidate.id)) continue;
632
+
633
+ const normalizedOld = candidate.signature.replace(
634
+ new RegExp(candidate.name, 'g'),
635
+ '___',
636
+ );
637
+ const normalizedNew = entity.signature.replace(
638
+ new RegExp(entity.name, 'g'),
639
+ '___',
640
+ );
641
+ if (normalizedOld === normalizedNew) {
642
+ return candidate;
643
+ }
644
+ }
645
+ return null;
646
+ }
647
+
648
+ function makeEntityId(filePath: string, kind: string, name: string): string {
649
+ return `${kind}:${filePath}:${name}`;
650
+ }
651
+
652
+ function normalizeSignature(text: string): string {
653
+ return text
654
+ .replace(/#[^\n]*/g, '') // line comments
655
+ .replace(/"""[\s\S]*?"""/g, '') // docstrings (triple double)
656
+ .replace(/'''[\s\S]*?'''/g, '') // docstrings (triple single)
657
+ .replace(/\s+/g, ' ') // collapse whitespace
658
+ .trim();
659
+ }