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,97 @@
1
+ /**
2
+ * Signing Middleware
3
+ *
4
+ * DESIGN.md §6.2 — Every op can be cryptographically signed by its author.
5
+ *
6
+ * This module provides:
7
+ * - `signOp`: Sign a VcsOp with a local identity's private key.
8
+ * - `verifyOp`: Verify the signature on a VcsOp.
9
+ * - `SignatureVerificationMiddleware`: Middleware that rejects ops with
10
+ * invalid signatures on remote ops.
11
+ */
12
+
13
+ import type { VcsOp } from '../vcs/types.js';
14
+ import { signMessage, verifySignature } from './identity.js';
15
+
16
+ // ---------------------------------------------------------------------------
17
+ // Op signing
18
+ // ---------------------------------------------------------------------------
19
+
20
+ /**
21
+ * Sign a VcsOp in-place using the given private key.
22
+ * Sets `vcs.signature` and `vcs.signedBy` on the op.
23
+ */
24
+ export function signOp(
25
+ op: VcsOp,
26
+ privateKeyBase64: string,
27
+ identityEntityId: string,
28
+ ): VcsOp {
29
+ if (!op.vcs) {
30
+ op.vcs = {};
31
+ }
32
+ op.vcs.signature = signMessage(op.hash, privateKeyBase64);
33
+ op.vcs.signedBy = identityEntityId;
34
+ return op;
35
+ }
36
+
37
+ /**
38
+ * Verify the signature on a VcsOp.
39
+ * Returns true if the op has a valid signature, false if invalid.
40
+ * Returns null if the op has no signature (unsigned).
41
+ */
42
+ export function verifyOp(
43
+ op: VcsOp,
44
+ publicKeyBase64: string,
45
+ ): boolean | null {
46
+ if (!op.vcs?.signature) return null;
47
+ return verifySignature(op.hash, op.vcs.signature, publicKeyBase64);
48
+ }
49
+
50
+ // ---------------------------------------------------------------------------
51
+ // Middleware interface
52
+ // ---------------------------------------------------------------------------
53
+
54
+ export interface IdentityResolver {
55
+ /** Resolve an identity entity ID to its public key (base64). */
56
+ resolvePublicKey(entityId: string): string | null;
57
+ }
58
+
59
+ export interface SignatureVerificationResult {
60
+ valid: boolean;
61
+ op: VcsOp;
62
+ reason?: string;
63
+ }
64
+
65
+ /**
66
+ * Verify all signatures on a batch of ops.
67
+ * Returns results for ops that have signatures.
68
+ */
69
+ export function verifyOpBatch(
70
+ ops: VcsOp[],
71
+ resolver: IdentityResolver,
72
+ ): SignatureVerificationResult[] {
73
+ const results: SignatureVerificationResult[] = [];
74
+
75
+ for (const op of ops) {
76
+ if (!op.vcs?.signature || !op.vcs?.signedBy) continue;
77
+
78
+ const publicKey = resolver.resolvePublicKey(op.vcs.signedBy);
79
+ if (!publicKey) {
80
+ results.push({
81
+ valid: false,
82
+ op,
83
+ reason: `Unknown identity: ${op.vcs.signedBy}`,
84
+ });
85
+ continue;
86
+ }
87
+
88
+ const valid = verifySignature(op.hash, op.vcs.signature, publicKey);
89
+ results.push({
90
+ valid,
91
+ op,
92
+ reason: valid ? undefined : `Invalid signature on op ${op.hash}`,
93
+ });
94
+ }
95
+
96
+ return results;
97
+ }
package/src/index.ts ADDED
@@ -0,0 +1,20 @@
1
+ /**
2
+ * TrellisVCS — Graph-native, code-first version control
3
+ *
4
+ * @module trellisvcs
5
+ *
6
+ * Public API surface. Import {@link TrellisVcsEngine} as the main entry point,
7
+ * plus core VCS types, the {@link FileWatcher}, and the {@link Ingestion}
8
+ * pipeline.
9
+ *
10
+ * For sub-modules, import directly from:
11
+ * - `./garden/index.js` — Idea Garden cluster detection + query
12
+ * - `./semantic/index.js` — Semantic parsing + diff/merge
13
+ * - `./sync/index.js` — Peer sync + CRDT reconciler
14
+ * - `./identity/index.js` — Ed25519 identity + governance
15
+ */
16
+
17
+ export { TrellisVcsEngine } from './engine.js';
18
+ export * from './vcs/index.js';
19
+ export { FileWatcher } from './watcher/fs-watcher.js';
20
+ export { Ingestion } from './watcher/ingestion.js';
@@ -0,0 +1,49 @@
1
+ /**
2
+ * Linked Markdown
3
+ *
4
+ * Wiki-link parser, entity reference resolver, and bidirectional
5
+ * reference index for [[...]] syntax in markdown and doc-comments.
6
+ *
7
+ * @see TRL-11
8
+ */
9
+
10
+ export {
11
+ parseFileRefs,
12
+ parseMarkdownRefs,
13
+ parseDocCommentRefs,
14
+ parseRefContent,
15
+ inferNamespace,
16
+ } from './parser.js';
17
+ export { resolveRef, resolveRefs, createResolverContext } from './resolver.js';
18
+ export type { ResolverContext, Enginelike } from './resolver.js';
19
+ export {
20
+ buildRefIndex,
21
+ updateFileInIndex,
22
+ removeFileFromIndex,
23
+ getOutgoingRefs,
24
+ getBacklinks,
25
+ getReferencedEntities,
26
+ getFilesWithRefs,
27
+ getIndexStats,
28
+ } from './ref-index.js';
29
+ export {
30
+ StaleRefRegistry,
31
+ buildRenameProposal,
32
+ applyRenameProposal,
33
+ handleSymbolDeletion,
34
+ handleFileDeletion,
35
+ getDiagnostics,
36
+ processSemanticPatches,
37
+ } from './lifecycle.js';
38
+ export type { StaleRef, RefDiagnostic, LifecycleEvent } from './lifecycle.js';
39
+ export type {
40
+ EntityRef,
41
+ RefSource,
42
+ RefContext,
43
+ RefNamespace,
44
+ ResolvedRef,
45
+ RefState,
46
+ RefIndex,
47
+ RefUpdateProposal,
48
+ RefRewrite,
49
+ } from './types.js';
@@ -0,0 +1,400 @@
1
+ /**
2
+ * Ref Lifecycle — Rename Prompts & Stale Detection
3
+ *
4
+ * Handles the three-tier diagnostic model for wiki-link references:
5
+ * - Resolved: target exists and resolves
6
+ * - Stale (warning): target was renamed or deleted, known provenance
7
+ * - Broken (error): target never existed
8
+ *
9
+ * On symbol rename → build a RefUpdateProposal for user confirmation.
10
+ * On symbol delete → mark affected refs as stale (no file modification).
11
+ *
12
+ * @see TRL-14
13
+ */
14
+
15
+ import type {
16
+ EntityRef,
17
+ RefIndex,
18
+ RefSource,
19
+ RefUpdateProposal,
20
+ RefRewrite,
21
+ RefState,
22
+ } from './types.js';
23
+ import { getBacklinks } from './ref-index.js';
24
+ import { readFileSync } from 'fs';
25
+ import { join } from 'path';
26
+
27
+ // ---------------------------------------------------------------------------
28
+ // Stale Ref Tracking
29
+ // ---------------------------------------------------------------------------
30
+
31
+ export interface StaleRef {
32
+ /** The entity ID that became stale */
33
+ entityId: string;
34
+ /** Why it became stale */
35
+ reason: 'renamed' | 'deleted';
36
+ /** The op hash that caused the staleness */
37
+ causeOpHash?: string;
38
+ /** For renames: the new name */
39
+ newTarget?: string;
40
+ /** Timestamp when it became stale */
41
+ timestamp: string;
42
+ /** Sources that reference this stale entity */
43
+ sources: RefSource[];
44
+ }
45
+
46
+ /**
47
+ * In-memory stale ref registry.
48
+ * Maps entity ID → StaleRef.
49
+ */
50
+ export class StaleRefRegistry {
51
+ private staleRefs: Map<string, StaleRef> = new Map();
52
+
53
+ /**
54
+ * Mark an entity as stale due to rename or deletion.
55
+ */
56
+ markStale(
57
+ entityId: string,
58
+ reason: 'renamed' | 'deleted',
59
+ sources: RefSource[],
60
+ opts?: { causeOpHash?: string; newTarget?: string },
61
+ ): StaleRef {
62
+ const entry: StaleRef = {
63
+ entityId,
64
+ reason,
65
+ causeOpHash: opts?.causeOpHash,
66
+ newTarget: opts?.newTarget,
67
+ timestamp: new Date().toISOString(),
68
+ sources,
69
+ };
70
+ this.staleRefs.set(entityId, entry);
71
+ return entry;
72
+ }
73
+
74
+ /**
75
+ * Remove stale status (e.g. after user accepts rename update).
76
+ */
77
+ clearStale(entityId: string): void {
78
+ this.staleRefs.delete(entityId);
79
+ }
80
+
81
+ /**
82
+ * Check if an entity is stale.
83
+ */
84
+ isStale(entityId: string): boolean {
85
+ return this.staleRefs.has(entityId);
86
+ }
87
+
88
+ /**
89
+ * Get stale info for an entity.
90
+ */
91
+ getStale(entityId: string): StaleRef | undefined {
92
+ return this.staleRefs.get(entityId);
93
+ }
94
+
95
+ /**
96
+ * Get all stale refs.
97
+ */
98
+ getAllStale(): StaleRef[] {
99
+ return [...this.staleRefs.values()];
100
+ }
101
+
102
+ /**
103
+ * Get stale refs filtered by reason.
104
+ */
105
+ getByReason(reason: 'renamed' | 'deleted'): StaleRef[] {
106
+ return this.getAllStale().filter((s) => s.reason === reason);
107
+ }
108
+ }
109
+
110
+ // ---------------------------------------------------------------------------
111
+ // Rename → RefUpdateProposal
112
+ // ---------------------------------------------------------------------------
113
+
114
+ /**
115
+ * Build a RefUpdateProposal when a symbol is renamed.
116
+ * Scans the ref index for all references to the old symbol name
117
+ * and produces a list of rewrites.
118
+ *
119
+ * Does NOT modify any files — returns a proposal for user confirmation.
120
+ */
121
+ export function buildRenameProposal(
122
+ index: RefIndex,
123
+ filePath: string,
124
+ oldName: string,
125
+ newName: string,
126
+ ): RefUpdateProposal {
127
+ // Entity ID for the old symbol
128
+ const oldEntityId = `symbol:${filePath}#${oldName}`;
129
+ const newEntityId = `symbol:${filePath}#${newName}`;
130
+
131
+ // Find all backlinks to the old symbol
132
+ const sources = getBacklinks(index, oldEntityId);
133
+
134
+ // Also check for refs using the fallback entity ID pattern
135
+ const altEntityId = `symbol:${filePath}#${oldName}`;
136
+ const altSources = getBacklinks(index, altEntityId);
137
+ const allSources = deduplicateSources([...sources, ...altSources]);
138
+
139
+ const rewrites: RefRewrite[] = [];
140
+ const affectedFiles = new Set<string>();
141
+
142
+ for (const source of allSources) {
143
+ affectedFiles.add(source.filePath);
144
+
145
+ // Build the old and new text for the rewrite
146
+ const oldText = buildRefText(filePath, oldName);
147
+ const newText = buildRefText(filePath, newName);
148
+
149
+ rewrites.push({
150
+ filePath: source.filePath,
151
+ line: source.line,
152
+ col: source.col,
153
+ oldText,
154
+ newText,
155
+ });
156
+ }
157
+
158
+ return {
159
+ oldTarget: oldEntityId,
160
+ newTarget: newEntityId,
161
+ affectedFiles: [...affectedFiles],
162
+ rewrites,
163
+ };
164
+ }
165
+
166
+ /**
167
+ * Apply a RefUpdateProposal by rewriting files.
168
+ * Reads each affected file, applies the rewrites, and writes back.
169
+ *
170
+ * Returns the list of files that were actually modified.
171
+ */
172
+ export function applyRenameProposal(
173
+ proposal: RefUpdateProposal,
174
+ rootPath: string,
175
+ ): string[] {
176
+ const modifiedFiles: string[] = [];
177
+
178
+ // Group rewrites by file
179
+ const byFile = new Map<string, RefRewrite[]>();
180
+ for (const rw of proposal.rewrites) {
181
+ const existing = byFile.get(rw.filePath) ?? [];
182
+ existing.push(rw);
183
+ byFile.set(rw.filePath, existing);
184
+ }
185
+
186
+ for (const [filePath, rewrites] of byFile) {
187
+ try {
188
+ const absPath = join(rootPath, filePath);
189
+ let content = readFileSync(absPath, 'utf-8');
190
+ let modified = false;
191
+
192
+ for (const rw of rewrites) {
193
+ // Replace all occurrences of the old ref text with the new one
194
+ if (content.includes(rw.oldText)) {
195
+ content = content.replaceAll(rw.oldText, rw.newText);
196
+ modified = true;
197
+ }
198
+ }
199
+
200
+ if (modified) {
201
+ const { writeFileSync } = require('fs');
202
+ writeFileSync(absPath, content);
203
+ modifiedFiles.push(filePath);
204
+ }
205
+ } catch {
206
+ // File may have been deleted or moved — skip
207
+ }
208
+ }
209
+
210
+ return modifiedFiles;
211
+ }
212
+
213
+ // ---------------------------------------------------------------------------
214
+ // Deletion → Stale marking
215
+ // ---------------------------------------------------------------------------
216
+
217
+ /**
218
+ * Handle a symbol deletion by marking all referencing refs as stale.
219
+ * Does NOT modify any files.
220
+ *
221
+ * Returns the StaleRef entry for diagnostic display.
222
+ */
223
+ export function handleSymbolDeletion(
224
+ index: RefIndex,
225
+ registry: StaleRefRegistry,
226
+ filePath: string,
227
+ symbolName: string,
228
+ causeOpHash?: string,
229
+ ): StaleRef | null {
230
+ const entityId = `symbol:${filePath}#${symbolName}`;
231
+ const sources = getBacklinks(index, entityId);
232
+
233
+ if (sources.length === 0) return null;
234
+
235
+ return registry.markStale(entityId, 'deleted', sources, { causeOpHash });
236
+ }
237
+
238
+ /**
239
+ * Handle a file deletion by marking all refs to that file (and its symbols) as stale.
240
+ */
241
+ export function handleFileDeletion(
242
+ index: RefIndex,
243
+ registry: StaleRefRegistry,
244
+ filePath: string,
245
+ causeOpHash?: string,
246
+ ): StaleRef | null {
247
+ const entityId = `file:${filePath}`;
248
+ const sources = getBacklinks(index, entityId);
249
+
250
+ if (sources.length === 0) return null;
251
+
252
+ return registry.markStale(entityId, 'deleted', sources, { causeOpHash });
253
+ }
254
+
255
+ // ---------------------------------------------------------------------------
256
+ // Diagnostic queries
257
+ // ---------------------------------------------------------------------------
258
+
259
+ export type RefDiagnostic = {
260
+ entityId: string;
261
+ state: RefState;
262
+ source: RefSource;
263
+ message: string;
264
+ };
265
+
266
+ /**
267
+ * Produce diagnostics for all refs in the index.
268
+ * Combines stale registry info with broken ref detection.
269
+ */
270
+ export function getDiagnostics(
271
+ index: RefIndex,
272
+ registry: StaleRefRegistry,
273
+ resolvedEntityIds: Set<string>,
274
+ ): RefDiagnostic[] {
275
+ const diagnostics: RefDiagnostic[] = [];
276
+
277
+ // Walk all outgoing refs
278
+ for (const [filePath, refs] of index.outgoing) {
279
+ for (const ref of refs) {
280
+ const entityId = buildEntityIdFromRef(ref);
281
+
282
+ // Check stale first
283
+ const staleInfo = registry.getStale(entityId);
284
+ if (staleInfo) {
285
+ const reason =
286
+ staleInfo.reason === 'renamed'
287
+ ? `renamed to ${staleInfo.newTarget}`
288
+ : 'removed';
289
+ diagnostics.push({
290
+ entityId,
291
+ state: 'stale',
292
+ source: ref.source,
293
+ message: `Reference to '${ref.target}${ref.anchor ? '#' + ref.anchor : ''}' is stale: target was ${reason}`,
294
+ });
295
+ continue;
296
+ }
297
+
298
+ // Check if resolved
299
+ if (!resolvedEntityIds.has(entityId)) {
300
+ diagnostics.push({
301
+ entityId,
302
+ state: 'broken',
303
+ source: ref.source,
304
+ message: `Cannot resolve reference: '${ref.target}${ref.anchor ? '#' + ref.anchor : ''}' does not exist`,
305
+ });
306
+ }
307
+ }
308
+ }
309
+
310
+ return diagnostics;
311
+ }
312
+
313
+ // ---------------------------------------------------------------------------
314
+ // SemanticPatch integration
315
+ // ---------------------------------------------------------------------------
316
+
317
+ import type { SemanticPatch } from '../semantic/types.js';
318
+
319
+ export interface LifecycleEvent {
320
+ type: 'rename-proposal' | 'stale-detected';
321
+ filePath: string;
322
+ proposal?: RefUpdateProposal;
323
+ staleRef?: StaleRef;
324
+ }
325
+
326
+ /**
327
+ * Process semantic patches and produce lifecycle events.
328
+ * This is the main integration point — call this when sdiff detects changes.
329
+ */
330
+ export function processSemanticPatches(
331
+ patches: SemanticPatch[],
332
+ filePath: string,
333
+ index: RefIndex,
334
+ registry: StaleRefRegistry,
335
+ causeOpHash?: string,
336
+ ): LifecycleEvent[] {
337
+ const events: LifecycleEvent[] = [];
338
+
339
+ for (const patch of patches) {
340
+ if (patch.kind === 'symbolRename') {
341
+ const proposal = buildRenameProposal(
342
+ index,
343
+ filePath,
344
+ patch.oldName,
345
+ patch.newName,
346
+ );
347
+ if (proposal.rewrites.length > 0) {
348
+ events.push({
349
+ type: 'rename-proposal',
350
+ filePath,
351
+ proposal,
352
+ });
353
+ }
354
+ }
355
+
356
+ if (patch.kind === 'symbolRemove') {
357
+ const staleRef = handleSymbolDeletion(
358
+ index,
359
+ registry,
360
+ filePath,
361
+ patch.entityName,
362
+ causeOpHash,
363
+ );
364
+ if (staleRef) {
365
+ events.push({
366
+ type: 'stale-detected',
367
+ filePath,
368
+ staleRef,
369
+ });
370
+ }
371
+ }
372
+ }
373
+
374
+ return events;
375
+ }
376
+
377
+ // ---------------------------------------------------------------------------
378
+ // Helpers
379
+ // ---------------------------------------------------------------------------
380
+
381
+ function buildRefText(filePath: string, symbolName: string): string {
382
+ return `[[${filePath}#${symbolName}]]`;
383
+ }
384
+
385
+ function buildEntityIdFromRef(ref: EntityRef): string {
386
+ if (ref.anchor) {
387
+ return `${ref.namespace}:${ref.target}#${ref.anchor}`;
388
+ }
389
+ return `${ref.namespace}:${ref.target}`;
390
+ }
391
+
392
+ function deduplicateSources(sources: RefSource[]): RefSource[] {
393
+ const seen = new Set<string>();
394
+ return sources.filter((s) => {
395
+ const key = `${s.filePath}:${s.line}:${s.col}`;
396
+ if (seen.has(key)) return false;
397
+ seen.add(key);
398
+ return true;
399
+ });
400
+ }