trellis 1.0.7 → 2.0.5

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 +533 -82
  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,681 @@
1
+ /**
2
+ * TypeScript/JavaScript Parser Adapter
3
+ *
4
+ * DESIGN.md §4.2 — Structural extraction of top-level declarations
5
+ * using regex-based parsing. This is a Tier 1 implementation that
6
+ * extracts functions, classes, interfaces, type aliases, enums,
7
+ * variables, imports, and exports without requiring tree-sitter.
8
+ *
9
+ * Tree-sitter can be swapped in later for full Tier 2 AST fidelity.
10
+ */
11
+
12
+ import type {
13
+ ParserAdapter,
14
+ ParseResult,
15
+ ASTEntity,
16
+ ASTEntityKind,
17
+ ImportRelation,
18
+ ExportRelation,
19
+ SemanticPatch,
20
+ } from './types.js';
21
+
22
+ // ---------------------------------------------------------------------------
23
+ // Parser Adapter
24
+ // ---------------------------------------------------------------------------
25
+
26
+ export const typescriptParser: ParserAdapter = {
27
+ languages: ['typescript', 'javascript', 'tsx', 'jsx'],
28
+
29
+ parse(content: string, filePath: string): ParseResult {
30
+ const fileEntityId = `file:${filePath}`;
31
+ const language = detectLanguage(filePath);
32
+
33
+ return {
34
+ fileEntityId,
35
+ filePath,
36
+ language,
37
+ declarations: extractDeclarations(content, filePath),
38
+ imports: extractImports(content),
39
+ exports: extractExports(content),
40
+ };
41
+ },
42
+
43
+ diff(oldResult: ParseResult, newResult: ParseResult): SemanticPatch[] {
44
+ return computeSemanticDiff(oldResult, newResult);
45
+ },
46
+ };
47
+
48
+ // ---------------------------------------------------------------------------
49
+ // Language detection
50
+ // ---------------------------------------------------------------------------
51
+
52
+ function detectLanguage(filePath: string): string {
53
+ if (filePath.endsWith('.tsx')) return 'tsx';
54
+ if (filePath.endsWith('.jsx')) return 'jsx';
55
+ if (filePath.endsWith('.ts')) return 'typescript';
56
+ if (filePath.endsWith('.js') || filePath.endsWith('.mjs') || filePath.endsWith('.cjs'))
57
+ return 'javascript';
58
+ return 'unknown';
59
+ }
60
+
61
+ // ---------------------------------------------------------------------------
62
+ // Declaration extraction
63
+ // ---------------------------------------------------------------------------
64
+
65
+ /**
66
+ * Extract top-level declarations from TypeScript/JavaScript source.
67
+ * Uses regex patterns to identify structural boundaries.
68
+ */
69
+ function extractDeclarations(content: string, filePath: string): ASTEntity[] {
70
+ const declarations: ASTEntity[] = [];
71
+ const lines = content.split('\n');
72
+
73
+ let i = 0;
74
+ while (i < lines.length) {
75
+ const line = lines[i];
76
+ const trimmed = line.trim();
77
+
78
+ // Skip empty lines, comments, imports, exports (handled separately)
79
+ if (
80
+ !trimmed ||
81
+ trimmed.startsWith('//') ||
82
+ trimmed.startsWith('/*') ||
83
+ trimmed.startsWith('*') ||
84
+ trimmed.startsWith('import ') ||
85
+ trimmed.startsWith('export default ') ||
86
+ (trimmed.startsWith('export {') && !trimmed.includes('class') && !trimmed.includes('function'))
87
+ ) {
88
+ i++;
89
+ continue;
90
+ }
91
+
92
+ // Strip leading export/declare/async/abstract keywords for detection
93
+ const stripped = trimmed
94
+ .replace(/^export\s+/, '')
95
+ .replace(/^declare\s+/, '')
96
+ .replace(/^abstract\s+/, '')
97
+ .replace(/^async\s+/, '');
98
+
99
+ const result = tryExtractDeclaration(stripped, trimmed, lines, i, filePath);
100
+ if (result) {
101
+ declarations.push(result.entity);
102
+ i = result.endLine + 1;
103
+ } else {
104
+ i++;
105
+ }
106
+ }
107
+
108
+ return declarations;
109
+ }
110
+
111
+ interface ExtractionResult {
112
+ entity: ASTEntity;
113
+ endLine: number;
114
+ }
115
+
116
+ function tryExtractDeclaration(
117
+ stripped: string,
118
+ originalLine: string,
119
+ lines: string[],
120
+ startLine: number,
121
+ filePath: string,
122
+ ): ExtractionResult | null {
123
+ // Function declaration
124
+ let match = stripped.match(/^function\s+(\w+)/);
125
+ if (match) {
126
+ return extractBlock(match[1], 'FunctionDef', lines, startLine, filePath);
127
+ }
128
+
129
+ // Class declaration
130
+ match = stripped.match(/^class\s+(\w+)/);
131
+ if (match) {
132
+ return extractBlock(match[1], 'ClassDef', lines, startLine, filePath);
133
+ }
134
+
135
+ // Interface declaration
136
+ match = stripped.match(/^interface\s+(\w+)/);
137
+ if (match) {
138
+ return extractBlock(match[1], 'InterfaceDef', lines, startLine, filePath);
139
+ }
140
+
141
+ // Type alias
142
+ match = stripped.match(/^type\s+(\w+)/);
143
+ if (match) {
144
+ return extractTypeAlias(match[1], lines, startLine, filePath);
145
+ }
146
+
147
+ // Enum declaration
148
+ match = stripped.match(/^enum\s+(\w+)/);
149
+ if (match) {
150
+ return extractBlock(match[1], 'EnumDef', lines, startLine, filePath);
151
+ }
152
+
153
+ // Variable declarations (const/let/var at top level)
154
+ match = stripped.match(/^(?:const|let|var)\s+(\w+)/);
155
+ if (match) {
156
+ return extractVariable(match[1], lines, startLine, filePath);
157
+ }
158
+
159
+ return null;
160
+ }
161
+
162
+ /**
163
+ * Extract a brace-delimited block (function, class, interface, enum).
164
+ */
165
+ function extractBlock(
166
+ name: string,
167
+ kind: ASTEntityKind,
168
+ lines: string[],
169
+ startLine: number,
170
+ filePath: string,
171
+ ): ExtractionResult {
172
+ let depth = 0;
173
+ let foundOpen = false;
174
+ let endLine = startLine;
175
+
176
+ for (let i = startLine; i < lines.length; i++) {
177
+ const line = lines[i];
178
+ for (const ch of line) {
179
+ if (ch === '{') {
180
+ depth++;
181
+ foundOpen = true;
182
+ } else if (ch === '}') {
183
+ depth--;
184
+ }
185
+ }
186
+
187
+ if (foundOpen && depth <= 0) {
188
+ endLine = i;
189
+ break;
190
+ }
191
+
192
+ // If we haven't found an opening brace and this is a one-liner
193
+ if (i > startLine + 50) {
194
+ endLine = i;
195
+ break;
196
+ }
197
+ endLine = i;
198
+ }
199
+
200
+ const rawText = lines.slice(startLine, endLine + 1).join('\n');
201
+ const startOffset = lines.slice(0, startLine).join('\n').length + (startLine > 0 ? 1 : 0);
202
+ const endOffset = startOffset + rawText.length;
203
+
204
+ const children = kind === 'ClassDef' || kind === 'InterfaceDef'
205
+ ? extractClassMembers(rawText, name, filePath)
206
+ : [];
207
+
208
+ return {
209
+ entity: {
210
+ id: makeEntityId(filePath, kind, name),
211
+ kind,
212
+ name,
213
+ scopePath: name,
214
+ span: [startOffset, endOffset],
215
+ rawText,
216
+ signature: normalizeSignature(rawText),
217
+ children,
218
+ },
219
+ endLine,
220
+ };
221
+ }
222
+
223
+ /**
224
+ * Extract a type alias (may span multiple lines with = ... ;).
225
+ */
226
+ function extractTypeAlias(
227
+ name: string,
228
+ lines: string[],
229
+ startLine: number,
230
+ filePath: string,
231
+ ): ExtractionResult {
232
+ let endLine = startLine;
233
+ let depth = 0;
234
+
235
+ for (let i = startLine; i < lines.length; i++) {
236
+ const line = lines[i];
237
+ for (const ch of line) {
238
+ if (ch === '{' || ch === '(' || ch === '<') depth++;
239
+ else if (ch === '}' || ch === ')' || ch === '>') depth--;
240
+ }
241
+
242
+ if (line.includes(';') && depth <= 0) {
243
+ endLine = i;
244
+ break;
245
+ }
246
+ endLine = i;
247
+ }
248
+
249
+ const rawText = lines.slice(startLine, endLine + 1).join('\n');
250
+ const startOffset = lines.slice(0, startLine).join('\n').length + (startLine > 0 ? 1 : 0);
251
+
252
+ return {
253
+ entity: {
254
+ id: makeEntityId(filePath, 'TypeAlias', name),
255
+ kind: 'TypeAlias',
256
+ name,
257
+ scopePath: name,
258
+ span: [startOffset, startOffset + rawText.length],
259
+ rawText,
260
+ signature: normalizeSignature(rawText),
261
+ children: [],
262
+ },
263
+ endLine,
264
+ };
265
+ }
266
+
267
+ /**
268
+ * Extract a variable declaration.
269
+ */
270
+ function extractVariable(
271
+ name: string,
272
+ lines: string[],
273
+ startLine: number,
274
+ filePath: string,
275
+ ): ExtractionResult {
276
+ let endLine = startLine;
277
+ let depth = 0;
278
+
279
+ for (let i = startLine; i < lines.length; i++) {
280
+ const line = lines[i];
281
+ for (const ch of line) {
282
+ if (ch === '{' || ch === '(' || ch === '[') depth++;
283
+ else if (ch === '}' || ch === ')' || ch === ']') depth--;
284
+ }
285
+
286
+ if (depth <= 0 && (line.includes(';') || (i > startLine && lines[i + 1]?.trim().match(/^(?:export|const|let|var|function|class|interface|type|enum|import)\s/)))) {
287
+ endLine = i;
288
+ break;
289
+ }
290
+ endLine = i;
291
+ }
292
+
293
+ const rawText = lines.slice(startLine, endLine + 1).join('\n');
294
+ const startOffset = lines.slice(0, startLine).join('\n').length + (startLine > 0 ? 1 : 0);
295
+
296
+ return {
297
+ entity: {
298
+ id: makeEntityId(filePath, 'VariableDecl', name),
299
+ kind: 'VariableDecl',
300
+ name,
301
+ scopePath: name,
302
+ span: [startOffset, startOffset + rawText.length],
303
+ rawText,
304
+ signature: normalizeSignature(rawText),
305
+ children: [],
306
+ },
307
+ endLine,
308
+ };
309
+ }
310
+
311
+ /**
312
+ * Extract class/interface members as child entities.
313
+ */
314
+ function extractClassMembers(
315
+ classText: string,
316
+ className: string,
317
+ filePath: string,
318
+ ): ASTEntity[] {
319
+ const children: ASTEntity[] = [];
320
+ const lines = classText.split('\n');
321
+
322
+ // Skip the class opening line
323
+ for (let i = 1; i < lines.length - 1; i++) {
324
+ const line = lines[i].trim();
325
+ if (!line || line.startsWith('//') || line.startsWith('/*') || line.startsWith('*')) continue;
326
+
327
+ // Method
328
+ const methodMatch = line.match(
329
+ /^(?:(?:public|private|protected|static|async|abstract|readonly)\s+)*(\w+)\s*\(/,
330
+ );
331
+ if (methodMatch && methodMatch[1] !== 'if' && methodMatch[1] !== 'for' && methodMatch[1] !== 'while') {
332
+ const methodName = methodMatch[1];
333
+ const kind: ASTEntityKind = methodName === 'constructor' ? 'Constructor' : 'MethodDef';
334
+ children.push({
335
+ id: makeEntityId(filePath, kind, `${className}.${methodName}`),
336
+ kind,
337
+ name: methodName,
338
+ scopePath: `${className}.${methodName}`,
339
+ span: [0, 0], // Simplified for Tier 1
340
+ rawText: line,
341
+ signature: normalizeSignature(line),
342
+ children: [],
343
+ });
344
+ continue;
345
+ }
346
+
347
+ // Property
348
+ const propMatch = line.match(
349
+ /^(?:(?:public|private|protected|static|readonly)\s+)*(\w+)\s*[?:]/,
350
+ );
351
+ if (propMatch) {
352
+ const propName = propMatch[1];
353
+ children.push({
354
+ id: makeEntityId(filePath, 'PropertyDef', `${className}.${propName}`),
355
+ kind: 'PropertyDef',
356
+ name: propName,
357
+ scopePath: `${className}.${propName}`,
358
+ span: [0, 0],
359
+ rawText: line,
360
+ signature: normalizeSignature(line),
361
+ children: [],
362
+ });
363
+ }
364
+ }
365
+
366
+ return children;
367
+ }
368
+
369
+ // ---------------------------------------------------------------------------
370
+ // Import extraction
371
+ // ---------------------------------------------------------------------------
372
+
373
+ function extractImports(content: string): ImportRelation[] {
374
+ const imports: ImportRelation[] = [];
375
+ const lines = content.split('\n');
376
+
377
+ for (let i = 0; i < lines.length; i++) {
378
+ const line = lines[i].trim();
379
+ if (!line.startsWith('import ')) continue;
380
+
381
+ // Collect multi-line imports
382
+ let full = line;
383
+ while (!full.includes(';') && !full.match(/from\s+['"]/) && i + 1 < lines.length) {
384
+ i++;
385
+ full += ' ' + lines[i].trim();
386
+ }
387
+ if (!full.includes(';') && i + 1 < lines.length) {
388
+ i++;
389
+ full += ' ' + lines[i].trim();
390
+ }
391
+
392
+ const rel = parseImport(full);
393
+ if (rel) imports.push(rel);
394
+ }
395
+
396
+ return imports;
397
+ }
398
+
399
+ function parseImport(text: string): ImportRelation | null {
400
+ // import { a, b } from 'module'
401
+ const namedMatch = text.match(/import\s+\{([^}]+)\}\s+from\s+['"]([^'"]+)['"]/);
402
+ if (namedMatch) {
403
+ const specifiers = namedMatch[1].split(',').map((s) => s.trim()).filter(Boolean);
404
+ return {
405
+ source: namedMatch[2],
406
+ specifiers,
407
+ isDefault: false,
408
+ isNamespace: false,
409
+ rawText: text,
410
+ span: [0, text.length],
411
+ };
412
+ }
413
+
414
+ // import Default from 'module'
415
+ const defaultMatch = text.match(/import\s+(\w+)\s+from\s+['"]([^'"]+)['"]/);
416
+ if (defaultMatch) {
417
+ return {
418
+ source: defaultMatch[2],
419
+ specifiers: [defaultMatch[1]],
420
+ isDefault: true,
421
+ isNamespace: false,
422
+ rawText: text,
423
+ span: [0, text.length],
424
+ };
425
+ }
426
+
427
+ // import * as Name from 'module'
428
+ const nsMatch = text.match(/import\s+\*\s+as\s+(\w+)\s+from\s+['"]([^'"]+)['"]/);
429
+ if (nsMatch) {
430
+ return {
431
+ source: nsMatch[2],
432
+ specifiers: [nsMatch[1]],
433
+ isDefault: false,
434
+ isNamespace: true,
435
+ rawText: text,
436
+ span: [0, text.length],
437
+ };
438
+ }
439
+
440
+ // import 'module' (side-effect only)
441
+ const sideEffectMatch = text.match(/import\s+['"]([^'"]+)['"]/);
442
+ if (sideEffectMatch) {
443
+ return {
444
+ source: sideEffectMatch[1],
445
+ specifiers: [],
446
+ isDefault: false,
447
+ isNamespace: false,
448
+ rawText: text,
449
+ span: [0, text.length],
450
+ };
451
+ }
452
+
453
+ // import type { ... } from 'module'
454
+ const typeMatch = text.match(/import\s+type\s+\{([^}]+)\}\s+from\s+['"]([^'"]+)['"]/);
455
+ if (typeMatch) {
456
+ const specifiers = typeMatch[1].split(',').map((s) => s.trim()).filter(Boolean);
457
+ return {
458
+ source: typeMatch[2],
459
+ specifiers,
460
+ isDefault: false,
461
+ isNamespace: false,
462
+ rawText: text,
463
+ span: [0, text.length],
464
+ };
465
+ }
466
+
467
+ return null;
468
+ }
469
+
470
+ // ---------------------------------------------------------------------------
471
+ // Export extraction
472
+ // ---------------------------------------------------------------------------
473
+
474
+ function extractExports(content: string): ExportRelation[] {
475
+ const exports: ExportRelation[] = [];
476
+ const lines = content.split('\n');
477
+
478
+ for (const line of lines) {
479
+ const trimmed = line.trim();
480
+
481
+ // export default ...
482
+ if (trimmed.startsWith('export default ')) {
483
+ const nameMatch = trimmed.match(/export default (?:class|function)?\s*(\w+)?/);
484
+ exports.push({
485
+ name: nameMatch?.[1] ?? 'default',
486
+ isDefault: true,
487
+ rawText: trimmed,
488
+ span: [0, trimmed.length],
489
+ });
490
+ continue;
491
+ }
492
+
493
+ // export { ... } from '...' or export { ... }
494
+ const reExportMatch = trimmed.match(/export\s+\{([^}]+)\}(?:\s+from\s+['"]([^'"]+)['"])?/);
495
+ if (reExportMatch && !trimmed.match(/export\s+(?:function|class|interface|type|enum|const|let|var)/)) {
496
+ const names = reExportMatch[1].split(',').map((s) => s.trim().split(/\s+as\s+/).pop()!).filter(Boolean);
497
+ for (const name of names) {
498
+ exports.push({
499
+ name,
500
+ isDefault: false,
501
+ source: reExportMatch[2],
502
+ rawText: trimmed,
503
+ span: [0, trimmed.length],
504
+ });
505
+ }
506
+ continue;
507
+ }
508
+
509
+ // export * from '...'
510
+ const starMatch = trimmed.match(/export\s+\*\s+from\s+['"]([^'"]+)['"]/);
511
+ if (starMatch) {
512
+ exports.push({
513
+ name: '*',
514
+ isDefault: false,
515
+ source: starMatch[1],
516
+ rawText: trimmed,
517
+ span: [0, trimmed.length],
518
+ });
519
+ }
520
+ }
521
+
522
+ return exports;
523
+ }
524
+
525
+ // ---------------------------------------------------------------------------
526
+ // Semantic diff
527
+ // ---------------------------------------------------------------------------
528
+
529
+ function computeSemanticDiff(
530
+ oldResult: ParseResult,
531
+ newResult: ParseResult,
532
+ ): SemanticPatch[] {
533
+ const patches: SemanticPatch[] = [];
534
+ const fileId = newResult.fileEntityId;
535
+
536
+ // Diff declarations
537
+ const oldDecls = new Map(oldResult.declarations.map((d) => [d.id, d]));
538
+ const newDecls = new Map(newResult.declarations.map((d) => [d.id, d]));
539
+
540
+ // Also index by name for rename detection
541
+ const oldByName = new Map(oldResult.declarations.map((d) => [d.name, d]));
542
+ const newByName = new Map(newResult.declarations.map((d) => [d.name, d]));
543
+
544
+ // Detect additions
545
+ for (const [id, entity] of newDecls) {
546
+ if (!oldDecls.has(id)) {
547
+ // Check if this is a rename (same signature, different name)
548
+ const oldEntity = findRenamedEntity(entity, oldResult.declarations, newDecls);
549
+ if (oldEntity) {
550
+ patches.push({
551
+ kind: 'symbolRename',
552
+ entityId: oldEntity.id,
553
+ oldName: oldEntity.name,
554
+ newName: entity.name,
555
+ });
556
+ } else {
557
+ patches.push({ kind: 'symbolAdd', entity });
558
+ }
559
+ }
560
+ }
561
+
562
+ // Detect removals
563
+ for (const [id, entity] of oldDecls) {
564
+ if (!newDecls.has(id)) {
565
+ // Skip if this was a rename (already handled above)
566
+ const wasRenamed = findRenamedEntity(entity, newResult.declarations, oldDecls);
567
+ if (!wasRenamed) {
568
+ patches.push({ kind: 'symbolRemove', entityId: id, entityName: entity.name });
569
+ }
570
+ }
571
+ }
572
+
573
+ // Detect modifications
574
+ for (const [id, newEntity] of newDecls) {
575
+ const oldEntity = oldDecls.get(id);
576
+ if (oldEntity && oldEntity.signature !== newEntity.signature) {
577
+ patches.push({
578
+ kind: 'symbolModify',
579
+ entityId: id,
580
+ entityName: newEntity.name,
581
+ oldSignature: oldEntity.signature,
582
+ newSignature: newEntity.signature,
583
+ oldRawText: oldEntity.rawText,
584
+ newRawText: newEntity.rawText,
585
+ });
586
+ }
587
+ }
588
+
589
+ // Diff imports
590
+ const oldImports = new Map(oldResult.imports.map((imp) => [imp.source, imp]));
591
+ const newImports = new Map(newResult.imports.map((imp) => [imp.source, imp]));
592
+
593
+ for (const [source, imp] of newImports) {
594
+ const oldImp = oldImports.get(source);
595
+ if (!oldImp) {
596
+ patches.push({
597
+ kind: 'importAdd',
598
+ fileId,
599
+ source,
600
+ specifiers: imp.specifiers,
601
+ rawText: imp.rawText,
602
+ });
603
+ } else if (JSON.stringify(oldImp.specifiers.sort()) !== JSON.stringify(imp.specifiers.sort())) {
604
+ patches.push({
605
+ kind: 'importModify',
606
+ fileId,
607
+ source,
608
+ oldSpecifiers: oldImp.specifiers,
609
+ newSpecifiers: imp.specifiers,
610
+ });
611
+ }
612
+ }
613
+
614
+ for (const [source] of oldImports) {
615
+ if (!newImports.has(source)) {
616
+ patches.push({ kind: 'importRemove', fileId, source });
617
+ }
618
+ }
619
+
620
+ // Diff exports
621
+ const oldExports = new Map(oldResult.exports.map((exp) => [exp.name, exp]));
622
+ const newExports = new Map(newResult.exports.map((exp) => [exp.name, exp]));
623
+
624
+ for (const [name, exp] of newExports) {
625
+ if (!oldExports.has(name)) {
626
+ patches.push({ kind: 'exportAdd', fileId, name, rawText: exp.rawText });
627
+ }
628
+ }
629
+
630
+ for (const [name] of oldExports) {
631
+ if (!newExports.has(name)) {
632
+ patches.push({ kind: 'exportRemove', fileId, name });
633
+ }
634
+ }
635
+
636
+ return patches;
637
+ }
638
+
639
+ /**
640
+ * Find a possible renamed entity: same kind and similar signature, different name.
641
+ */
642
+ function findRenamedEntity(
643
+ entity: ASTEntity,
644
+ candidates: ASTEntity[],
645
+ existingIds: Map<string, ASTEntity>,
646
+ ): ASTEntity | null {
647
+ for (const candidate of candidates) {
648
+ if (candidate.kind !== entity.kind) continue;
649
+ if (candidate.name === entity.name) continue;
650
+ if (existingIds.has(candidate.id)) continue; // Still exists — not a rename
651
+
652
+ // Check signature similarity (replace name occurrences)
653
+ const normalizedOld = candidate.signature.replace(new RegExp(candidate.name, 'g'), '___');
654
+ const normalizedNew = entity.signature.replace(new RegExp(entity.name, 'g'), '___');
655
+ if (normalizedOld === normalizedNew) {
656
+ return candidate;
657
+ }
658
+ }
659
+ return null;
660
+ }
661
+
662
+ // ---------------------------------------------------------------------------
663
+ // Helpers
664
+ // ---------------------------------------------------------------------------
665
+
666
+ function makeEntityId(filePath: string, kind: string, name: string): string {
667
+ return `${kind}:${filePath}:${name}`;
668
+ }
669
+
670
+ /**
671
+ * Normalize a code snippet to a structural signature:
672
+ * strip comments, collapse whitespace, remove trailing semicolons.
673
+ */
674
+ function normalizeSignature(text: string): string {
675
+ return text
676
+ .replace(/\/\/[^\n]*/g, '') // line comments
677
+ .replace(/\/\*[\s\S]*?\*\//g, '') // block comments
678
+ .replace(/\s+/g, ' ') // collapse whitespace
679
+ .replace(/;\s*$/, '') // trailing semicolons
680
+ .trim();
681
+ }