@shapeshift-labs/frontier-lang-compiler 0.2.95 → 0.2.96

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.
@@ -28,39 +28,32 @@ export interface SemanticEditScriptOperation {
28
28
  readonly kind: string;
29
29
  readonly changeKind?: string;
30
30
  readonly anchor: {
31
- readonly key?: string;
32
- readonly conflictKey?: string;
33
- readonly regionId?: string;
34
- readonly regionKind?: string;
35
- readonly granularity?: string;
31
+ readonly key?: string; readonly conflictKey?: string; readonly regionId?: string;
32
+ readonly regionKind?: string; readonly granularity?: string;
36
33
  readonly language?: FrontierSourceLanguage | string;
37
- readonly sourcePath?: string;
38
- readonly symbolId?: string;
39
- readonly symbolName?: string;
40
- readonly symbolKind?: string;
34
+ readonly sourcePath?: string; readonly symbolId?: string; readonly symbolName?: string; readonly symbolKind?: string;
41
35
  readonly sourceSpan?: SourceSpan;
42
36
  };
37
+ readonly insertion?: {
38
+ readonly mode?: 'before' | 'after' | 'file-start' | 'file-end' | string;
39
+ readonly anchorKey?: string; readonly anchorSymbolId?: string; readonly anchorSymbolName?: string; readonly anchorSymbolKind?: string;
40
+ readonly baseSpan?: SourceSpan; readonly workerAnchorSpan?: SourceSpan; readonly headSpan?: SourceSpan; readonly sourcePath?: string;
41
+ readonly insertedSymbolId?: string; readonly insertedSymbolName?: string; readonly insertedSymbolKind?: string;
42
+ readonly insertedSourceSpan?: SourceSpan; readonly insertedSourcePath?: string;
43
+ readonly reasonCodes?: readonly string[];
44
+ };
43
45
  readonly semanticKey?: string;
44
46
  readonly semanticIdentityHash?: string;
45
47
  readonly sourceIdentityHash?: string;
46
48
  readonly operationContentHash?: string;
47
49
  readonly spans?: {
48
- readonly base?: SourceSpan;
49
- readonly worker?: SourceSpan;
50
- readonly head?: SourceSpan;
50
+ readonly base?: SourceSpan; readonly worker?: SourceSpan; readonly head?: SourceSpan;
51
51
  };
52
52
  readonly hashes?: {
53
- readonly baseSourceHash?: string;
54
- readonly workerSourceHash?: string;
55
- readonly headSourceHash?: string;
56
- readonly baseSpanHash?: string;
57
- readonly workerSpanHash?: string;
58
- readonly headSpanHash?: string;
59
- readonly baseTextHash?: string;
60
- readonly workerTextHash?: string;
61
- readonly headTextHash?: string;
62
- readonly beforeSignatureHash?: string;
63
- readonly afterSignatureHash?: string;
53
+ readonly baseSourceHash?: string; readonly workerSourceHash?: string; readonly headSourceHash?: string;
54
+ readonly baseSpanHash?: string; readonly workerSpanHash?: string; readonly headSpanHash?: string;
55
+ readonly baseTextHash?: string; readonly workerTextHash?: string; readonly headTextHash?: string;
56
+ readonly beforeSignatureHash?: string; readonly afterSignatureHash?: string;
64
57
  };
65
58
  readonly status: SemanticEditScriptOperationStatus;
66
59
  readonly reanchor?: {
@@ -135,6 +128,7 @@ export interface SemanticEditProjectionEdit {
135
128
  readonly operationId?: string;
136
129
  readonly status: 'applied' | 'already-applied';
137
130
  readonly kind?: string;
131
+ readonly editKind?: 'replace' | 'insert' | string;
138
132
  readonly changeKind?: string;
139
133
  readonly anchorKey?: string;
140
134
  readonly conflictKey?: string;
@@ -162,6 +156,11 @@ export interface SemanticEditProjectionEdit {
162
156
  readonly replacementBytes: number;
163
157
  readonly deletedTextHash?: string;
164
158
  readonly replacementTextHash?: string;
159
+ readonly replacementSpanTextHash?: string;
160
+ readonly insertionMode?: string;
161
+ readonly insertionAnchorKey?: string;
162
+ readonly insertionAnchorSymbolName?: string;
163
+ readonly insertionAnchorSymbolKind?: string;
165
164
  readonly replacementText?: string;
166
165
  }
167
166
 
@@ -206,6 +205,7 @@ export interface SemanticEditReplayEdit {
206
205
  readonly semanticIdentityHash?: string;
207
206
  readonly sourceIdentityHash?: string;
208
207
  readonly editContentHash?: string;
208
+ readonly editKind?: 'replace' | 'insert' | string;
209
209
  readonly sourcePath?: string;
210
210
  readonly symbolName?: string;
211
211
  readonly symbolKind?: string;
@@ -12,13 +12,15 @@ export function projectSemanticEditScriptToSource(input = {}) {
12
12
  if (typeof workerSourceText !== 'string') reasonCodes.push('missing-worker-source-text');
13
13
  if (typeof headSourceText !== 'string') reasonCodes.push('missing-head-source-text');
14
14
  const edits = [];
15
- for (const operation of script.operations ?? []) {
16
- const edit = sourceEditForOperation(operation, workerSourceText, headSourceText);
15
+ for (const [index, operation] of (script.operations ?? []).entries()) {
16
+ const edit = sourceEditForOperation(operation, workerSourceText, headSourceText, index);
17
17
  if (edit.ok) edits.push(edit.value);
18
18
  else reasonCodes.push(...edit.reasonCodes);
19
19
  }
20
+ const deduped = dedupeSourceEdits(edits);
21
+ reasonCodes.push(...validateSourceEdits(deduped.edits));
20
22
  const blocked = reasonCodes.length > 0;
21
- const sourceText = blocked ? undefined : applySourceEdits(headSourceText, edits);
23
+ const sourceText = blocked ? undefined : applySourceEdits(headSourceText, deduped.edits);
22
24
  const core = {
23
25
  kind: 'frontier.lang.semanticEditProjection',
24
26
  version: 1,
@@ -31,9 +33,9 @@ export function projectSemanticEditScriptToSource(input = {}) {
31
33
  workerHash: script.workerHash,
32
34
  headHash: script.headHash,
33
35
  projectedHash: sourceText === undefined ? undefined : hashSemanticValue(sourceText),
34
- appliedOperations: blocked ? [] : edits.map((edit) => edit.operationId),
35
- skippedOperations: blocked ? (script.operations ?? []).map((operation) => operation.id) : [],
36
- edits: blocked ? [] : edits.map(projectionEditRecord),
36
+ appliedOperations: blocked ? [] : deduped.edits.map((edit) => edit.operationId),
37
+ skippedOperations: blocked ? (script.operations ?? []).map((operation) => operation.id) : deduped.skippedOperationIds,
38
+ edits: blocked ? [] : deduped.edits.map(projectionEditRecord),
37
39
  sourceText,
38
40
  admission: {
39
41
  status: blocked ? 'blocked' : 'auto-merge-candidate',
@@ -45,20 +47,24 @@ export function projectSemanticEditScriptToSource(input = {}) {
45
47
  autoMergeClaim: false,
46
48
  semanticEquivalenceClaim: false,
47
49
  editCount: edits.length,
48
- appliedEditCount: edits.filter((edit) => !edit.alreadyApplied).length,
49
- alreadyAppliedEditCount: edits.filter((edit) => edit.alreadyApplied).length,
50
+ appliedEditCount: deduped.edits.filter((edit) => !edit.alreadyApplied).length,
51
+ alreadyAppliedEditCount: deduped.edits.filter((edit) => edit.alreadyApplied).length,
52
+ dedupedEditCount: deduped.skippedOperationIds.length,
50
53
  ...input.metadata
51
54
  })
52
55
  };
53
56
  return { ...core, hash: hashSemanticValue(core) };
54
57
  }
55
58
 
56
- function sourceEditForOperation(operation, workerSourceText, headSourceText) {
59
+ function sourceEditForOperation(operation, workerSourceText, headSourceText, order) {
57
60
  const identity = projectionIdentity(operation);
58
61
  if (operation.status === 'already-applied') {
59
- return { ok: true, value: { ...identity, operationId: operation.id, start: 0, end: 0, replacement: '', current: '', alreadyApplied: true } };
62
+ return { ok: true, value: { ...identity, operationId: operation.id, order, start: 0, end: 0, replacement: '', current: '', alreadyApplied: true } };
60
63
  }
61
64
  if (operation.status !== 'portable') return { ok: false, reasonCodes: [`operation-not-portable:${operation.id}`] };
65
+ if (operation.changeKind === 'added' || String(operation.kind ?? '').startsWith('add')) {
66
+ return insertionEditForOperation(operation, identity, workerSourceText, headSourceText, order);
67
+ }
62
68
  const workerOffsets = spanOffsets(workerSourceText, operation.spans?.worker);
63
69
  const headOffsets = spanOffsets(headSourceText, operation.spans?.head ?? operation.spans?.base ?? operation.anchor?.sourceSpan);
64
70
  const reasons = [];
@@ -79,7 +85,9 @@ function sourceEditForOperation(operation, workerSourceText, headSourceText) {
79
85
  ok: true,
80
86
  value: {
81
87
  operationId: operation.id,
88
+ order,
82
89
  ...identity,
90
+ editKind: 'replace',
83
91
  start: headOffsets.start,
84
92
  end: headOffsets.end,
85
93
  workerStart: workerOffsets.start,
@@ -90,6 +98,37 @@ function sourceEditForOperation(operation, workerSourceText, headSourceText) {
90
98
  };
91
99
  }
92
100
 
101
+ function insertionEditForOperation(operation, identity, workerSourceText, headSourceText, order) {
102
+ const workerOffsets = spanOffsets(workerSourceText, operation.spans?.worker);
103
+ const reasons = [];
104
+ if (!workerOffsets) reasons.push(`worker-span-not-resolvable:${operation.id}`);
105
+ const insertion = insertionOffset(headSourceText, operation.insertion);
106
+ if (!insertion.ok) reasons.push(...insertion.reasonCodes.map((reason) => `${reason}:${operation.id}`));
107
+ if (reasons.length) return { ok: false, reasonCodes: reasons };
108
+ const spanText = workerSourceText.slice(workerOffsets.start, workerOffsets.end);
109
+ if (operation.hashes?.workerTextHash && hashSemanticValue(spanText) !== operation.hashes.workerTextHash) {
110
+ reasons.push(`worker-span-hash-mismatch:${operation.id}`);
111
+ }
112
+ if (reasons.length) return { ok: false, reasonCodes: reasons };
113
+ return {
114
+ ok: true,
115
+ value: {
116
+ operationId: operation.id,
117
+ order,
118
+ ...identity,
119
+ editKind: 'insert',
120
+ insertion: operation.insertion,
121
+ start: insertion.offset,
122
+ end: insertion.offset,
123
+ workerStart: workerOffsets.start,
124
+ workerEnd: workerOffsets.end,
125
+ replacement: insertionReplacement(spanText, headSourceText, insertion.offset),
126
+ replacementSpanText: spanText,
127
+ current: ''
128
+ }
129
+ };
130
+ }
131
+
93
132
  function projectionIdentity(operation) {
94
133
  const identity = semanticEditIdentity(operation);
95
134
  return { ...identity, sourcePath: operation.reanchor?.toSourcePath ?? identity.sourcePath };
@@ -103,6 +142,7 @@ function projectionEditRecord(edit) {
103
142
  operationId: edit.operationId,
104
143
  status: edit.alreadyApplied ? 'already-applied' : 'applied',
105
144
  kind: edit.kind,
145
+ editKind: edit.editKind,
106
146
  changeKind: edit.changeKind,
107
147
  anchorKey: edit.anchorKey,
108
148
  conflictKey: edit.conflictKey,
@@ -133,6 +173,11 @@ function projectionEditRecord(edit) {
133
173
  replacementBytes: edit.replacement.length,
134
174
  deletedTextHash,
135
175
  replacementTextHash,
176
+ replacementSpanTextHash: hashSemanticValue(edit.replacementSpanText ?? edit.replacement),
177
+ insertionMode: edit.insertion?.mode,
178
+ insertionAnchorKey: edit.insertion?.anchorKey,
179
+ insertionAnchorSymbolName: edit.insertion?.anchorSymbolName,
180
+ insertionAnchorSymbolKind: edit.insertion?.anchorSymbolKind,
136
181
  replacementText: edit.replacement
137
182
  });
138
183
  }
@@ -164,10 +209,42 @@ function semanticEditIdentity(operation) {
164
209
 
165
210
  function applySourceEdits(sourceText, edits) {
166
211
  return edits.filter((edit) => !edit.alreadyApplied)
167
- .sort((left, right) => right.start - left.start)
212
+ .sort(sourceEditSort)
168
213
  .reduce((text, edit) => text.slice(0, edit.start) + edit.replacement + text.slice(edit.end), sourceText);
169
214
  }
170
215
 
216
+ function dedupeSourceEdits(edits) {
217
+ const seen = new Map();
218
+ const result = [];
219
+ const skippedOperationIds = [];
220
+ for (const edit of edits) {
221
+ const key = duplicateEditKey(edit);
222
+ if (key && seen.has(key)) {
223
+ skippedOperationIds.push(edit.operationId);
224
+ continue;
225
+ }
226
+ if (key) seen.set(key, edit.operationId);
227
+ result.push(edit);
228
+ }
229
+ return { edits: result, skippedOperationIds };
230
+ }
231
+
232
+ function duplicateEditKey(edit) {
233
+ if (edit.editKind !== 'insert') return undefined;
234
+ return [
235
+ 'insert',
236
+ edit.start,
237
+ edit.end,
238
+ edit.insertion?.mode,
239
+ edit.insertion?.anchorKey,
240
+ hashSemanticValue(edit.replacementSpanText ?? edit.replacement)
241
+ ].join(':');
242
+ }
243
+
244
+ function sourceEditSort(left, right) {
245
+ return right.start - left.start || right.end - left.end || (right.order ?? 0) - (left.order ?? 0);
246
+ }
247
+
171
248
  function projectedSourcePath(script, edits) {
172
249
  return edits.map((edit) => edit.sourcePath).find(Boolean) ?? script.sourcePath;
173
250
  }
@@ -189,6 +266,48 @@ function spanOffsets(sourceText, span) {
189
266
  return { start: start + startColumn, end: endLineStart + endColumn };
190
267
  }
191
268
 
269
+ function insertionOffset(sourceText, insertion) {
270
+ if (typeof sourceText !== 'string') return { ok: false, reasonCodes: ['missing-head-source-text'] };
271
+ const mode = insertion?.mode;
272
+ if (mode === 'file-start') return { ok: true, offset: 0 };
273
+ if (mode === 'file-end') return { ok: true, offset: sourceText.length };
274
+ const range = spanOffsets(sourceText, insertion?.headSpan);
275
+ if (!range) return { ok: false, reasonCodes: ['insertion-anchor-not-resolvable'] };
276
+ if (mode === 'before') return { ok: true, offset: range.start };
277
+ if (mode === 'after') return { ok: true, offset: afterLineOffset(sourceText, range.end) };
278
+ return { ok: false, reasonCodes: ['insertion-mode-unsupported'] };
279
+ }
280
+
281
+ function insertionReplacement(text, sourceText, offset) {
282
+ let replacement = String(text ?? '');
283
+ if (offset > 0 && sourceText[offset - 1] !== '\n') replacement = `\n${replacement}`;
284
+ if (offset < sourceText.length && !replacement.endsWith('\n')) replacement += '\n';
285
+ if (offset === sourceText.length && sourceText && !sourceText.endsWith('\n')) replacement = `\n${replacement}`;
286
+ if (offset === sourceText.length && !replacement.endsWith('\n')) replacement += '\n';
287
+ return replacement;
288
+ }
289
+
290
+ function afterLineOffset(sourceText, offset) {
291
+ return sourceText[offset] === '\n' ? offset + 1 : offset;
292
+ }
293
+
294
+ function validateSourceEdits(edits) {
295
+ const reasons = [];
296
+ const ordered = edits.filter((edit) => !edit.alreadyApplied).sort((left, right) => left.start - right.start || left.end - right.end);
297
+ for (let index = 1; index < ordered.length; index += 1) {
298
+ const previous = ordered[index - 1];
299
+ const current = ordered[index];
300
+ if (editsOverlap(previous, current)) reasons.push(`source-edit-overlap:${previous.operationId}:${current.operationId}`);
301
+ }
302
+ return uniqueStrings(reasons);
303
+ }
304
+
305
+ function editsOverlap(left, right) {
306
+ if (left.start === left.end) return right.start < left.start && left.start < right.end;
307
+ if (right.start === right.end) return left.start < right.start && right.start < left.end;
308
+ return left.start < right.end && right.start < left.end;
309
+ }
310
+
192
311
  function compactRecord(value) {
193
312
  return Object.fromEntries(Object.entries(value ?? {}).filter(([, entry]) => entry !== undefined && (!Array.isArray(entry) || entry.length > 0)));
194
313
  }
@@ -53,6 +53,7 @@ export function replaySemanticEditProjection(input = {}) {
53
53
  function replayProjectionEdit(edit, context) {
54
54
  if (edit.status === 'already-applied') return replayEditRecord(edit, 'already-applied', undefined, ['projection-edit-already-applied']);
55
55
  if (typeof edit.replacementText !== 'string') return replayEditRecord(edit, 'blocked', undefined, ['missing-replacement-text']);
56
+ if (edit.editKind === 'insert') return replayInsertionEdit(edit, context);
56
57
  const offset = checkRange(edit, { start: edit.headStart, end: edit.headEnd }, context.currentSourceText, 'head-offset');
57
58
  if (offset) return replayEditRecord(edit, offset.status, offset.range, [offset.reason]);
58
59
  const symbol = findCurrentSymbol(edit, context.currentSymbols);
@@ -64,13 +65,27 @@ function replayProjectionEdit(edit, context) {
64
65
  ]);
65
66
  }
66
67
 
68
+ function replayInsertionEdit(edit, context) {
69
+ const inserted = findCurrentSymbol(edit, context.currentSymbols);
70
+ const insertedRange = spanOffsets(context.currentSourceText, inserted?.sourceSpan);
71
+ const already = checkRange(edit, insertedRange, context.currentSourceText, 'current-inserted-symbol');
72
+ if (already?.status === 'already-applied') return replayEditRecord(edit, 'already-applied', already.range, [already.reason]);
73
+ const anchor = findInsertionAnchorSymbol(edit, context.currentSymbols);
74
+ const range = insertionRange(edit, anchor, context.currentSourceText);
75
+ if (range) return replayEditRecord(edit, 'applied', range, [anchor ? 'current-insertion-anchor' : `current-${edit.insertionMode}`]);
76
+ return replayEditRecord(edit, anchor ? 'conflict' : 'stale', undefined, [
77
+ anchor ? 'current-insertion-anchor-unusable' : 'current-insertion-anchor-missing'
78
+ ]);
79
+ }
80
+
67
81
  function checkRange(edit, range, sourceText, label) {
68
82
  if (!range || range.end < range.start) return undefined;
69
83
  const current = sourceText.slice(range.start, range.end);
70
84
  const currentHash = hashSemanticValue(current);
71
- if (edit.deletedTextHash && currentHash === edit.deletedTextHash) return { status: 'applied', range, reason: `${label}-matches-deleted` };
85
+ if (edit.replacementSpanTextHash && currentHash === edit.replacementSpanTextHash) return { status: 'already-applied', range, reason: `${label}-matches-replacement-span` };
72
86
  if (edit.replacementTextHash && currentHash === edit.replacementTextHash) return { status: 'already-applied', range, reason: `${label}-matches-replacement` };
73
87
  if (current === edit.replacementText) return { status: 'already-applied', range, reason: `${label}-matches-replacement-text` };
88
+ if (edit.deletedTextHash && currentHash === edit.deletedTextHash) return { status: 'applied', range, reason: `${label}-matches-deleted` };
74
89
  return undefined;
75
90
  }
76
91
 
@@ -81,6 +96,7 @@ function replayEditRecord(edit, status, range, reasonCodes) {
81
96
  semanticIdentityHash: edit.semanticIdentityHash,
82
97
  sourceIdentityHash: edit.sourceIdentityHash,
83
98
  editContentHash: edit.editContentHash,
99
+ editKind: edit.editKind,
84
100
  sourcePath: edit.targetSourcePath ?? edit.sourcePath,
85
101
  symbolName: edit.targetSymbolName ?? edit.symbolName,
86
102
  symbolKind: edit.targetSymbolKind ?? edit.symbolKind,
@@ -115,6 +131,24 @@ function findCurrentSymbol(edit, symbols) {
115
131
  return symbols.find((symbol) => symbol.name === name && (!kind || symbol.kind === kind));
116
132
  }
117
133
 
134
+ function findInsertionAnchorSymbol(edit, symbols) {
135
+ return symbols.find((symbol) => [symbol.ownershipKey, symbol.key, symbol.id].some((key) => key && key === edit.insertionAnchorKey))
136
+ ?? symbols.find((symbol) => symbol.name === edit.insertionAnchorSymbolName && (!edit.insertionAnchorSymbolKind || symbol.kind === edit.insertionAnchorSymbolKind));
137
+ }
138
+
139
+ function insertionRange(edit, anchor, sourceText) {
140
+ if (edit.insertionMode === 'file-start') return { start: 0, end: 0 };
141
+ if (edit.insertionMode === 'file-end') return { start: sourceText.length, end: sourceText.length };
142
+ const anchorRange = spanOffsets(sourceText, anchor?.sourceSpan);
143
+ if (!anchorRange) return undefined;
144
+ if (edit.insertionMode === 'before') return { start: anchorRange.start, end: anchorRange.start };
145
+ if (edit.insertionMode === 'after') {
146
+ const offset = sourceText[anchorRange.end] === '\n' ? anchorRange.end + 1 : anchorRange.end;
147
+ return { start: offset, end: offset };
148
+ }
149
+ return undefined;
150
+ }
151
+
118
152
  function replayStatus(reasonCodes, edits, projection) {
119
153
  if (reasonCodes.some((reason) => reason !== 'current-source-hash-mismatch')) return 'blocked';
120
154
  if (!edits.length && !(projection.edits ?? []).length) return 'evidence-only';
@@ -0,0 +1,108 @@
1
+ export function semanticEditInsertionAnchor(region, context) {
2
+ if (region.changeKind !== 'added') return undefined;
3
+ const workerSymbol = symbolForRegion(context.workerSymbols, region);
4
+ if (!workerSymbol?.sourceSpan) return fallbackInsertion(region, context, 'worker-symbol-span-missing');
5
+ const workers = uniqueSymbols(context.workerSymbols)
6
+ .filter((symbol) => symbol.id !== workerSymbol.id && symbol.key !== workerSymbol.key)
7
+ .filter((symbol) => hasSymbol(context.baseSymbols, symbol));
8
+ const before = nearestBefore(workers, workerSymbol);
9
+ const after = nearestAfter(workers, workerSymbol);
10
+ const anchor = before
11
+ ? insertionFromSymbol('after', before, context, 'nearest-previous-base-symbol')
12
+ : after
13
+ ? insertionFromSymbol('before', after, context, 'nearest-next-base-symbol')
14
+ : fallbackInsertion(region, context, 'no-neighbor-base-symbol');
15
+ return compactRecord({
16
+ ...anchor,
17
+ insertedSymbolId: workerSymbol.id,
18
+ insertedSymbolName: workerSymbol.name,
19
+ insertedSymbolKind: workerSymbol.kind,
20
+ insertedSourceSpan: workerSymbol.sourceSpan,
21
+ insertedSourcePath: workerSymbol.sourcePath
22
+ });
23
+ }
24
+
25
+ function insertionFromSymbol(mode, symbol, context, reasonCode) {
26
+ const headSymbol = symbolForExisting(context.headSymbols, symbol);
27
+ return compactRecord({
28
+ mode,
29
+ anchorKey: symbol.key ?? symbol.ownershipKey ?? symbol.id,
30
+ anchorSymbolId: symbol.id,
31
+ anchorSymbolName: symbol.name,
32
+ anchorSymbolKind: symbol.kind,
33
+ baseSpan: symbolForExisting(context.baseSymbols, symbol)?.sourceSpan,
34
+ workerAnchorSpan: symbol.sourceSpan,
35
+ headSpan: headSymbol?.sourceSpan,
36
+ sourcePath: headSymbol?.sourcePath ?? symbol.sourcePath,
37
+ reasonCodes: [headSymbol ? reasonCode : `${reasonCode}:head-anchor-missing`]
38
+ });
39
+ }
40
+
41
+ function fallbackInsertion(region, context, reasonCode) {
42
+ const mode = region.regionKind === 'import' ? 'file-start' : 'file-end';
43
+ return compactRecord({
44
+ mode,
45
+ sourcePath: region.sourcePath ?? context.workerChangeSet.sourcePath,
46
+ reasonCodes: [reasonCode, `fallback-${mode}`]
47
+ });
48
+ }
49
+
50
+ function nearestBefore(symbols, target) {
51
+ return symbols
52
+ .filter((symbol) => spanEndLine(symbol.sourceSpan) <= spanStartLine(target.sourceSpan))
53
+ .sort((left, right) => spanEndLine(right.sourceSpan) - spanEndLine(left.sourceSpan))[0];
54
+ }
55
+
56
+ function nearestAfter(symbols, target) {
57
+ return symbols
58
+ .filter((symbol) => spanStartLine(symbol.sourceSpan) >= spanEndLine(target.sourceSpan))
59
+ .sort((left, right) => spanStartLine(left.sourceSpan) - spanStartLine(right.sourceSpan))[0];
60
+ }
61
+
62
+ function symbolForRegion(symbols, region) {
63
+ return symbolForKeys(symbols, [region.key, region.symbolId, region.symbolName].filter(Boolean));
64
+ }
65
+
66
+ function symbolForExisting(symbols, symbol) {
67
+ return symbolForKeys(symbols, symbolKeys(symbol));
68
+ }
69
+
70
+ function hasSymbol(symbols, symbol) {
71
+ return Boolean(symbolForExisting(symbols, symbol));
72
+ }
73
+
74
+ function symbolForKeys(symbols, keys) {
75
+ for (const key of keys) {
76
+ const symbol = symbols.get(key);
77
+ if (symbol) return symbol;
78
+ }
79
+ return undefined;
80
+ }
81
+
82
+ function uniqueSymbols(symbols) {
83
+ const seen = new Set();
84
+ const result = [];
85
+ for (const symbol of symbols.values()) {
86
+ const key = symbol.id ?? `${symbol.key}:${symbol.name}`;
87
+ if (seen.has(key)) continue;
88
+ seen.add(key);
89
+ result.push(symbol);
90
+ }
91
+ return result;
92
+ }
93
+
94
+ function symbolKeys(symbol) {
95
+ return [symbol.key, symbol.ownershipKey, symbol.id, symbol.name].filter(Boolean);
96
+ }
97
+
98
+ function spanStartLine(span) {
99
+ return typeof span?.startLine === 'number' ? span.startLine : Number.MAX_SAFE_INTEGER;
100
+ }
101
+
102
+ function spanEndLine(span) {
103
+ return typeof span?.endLine === 'number' ? span.endLine : spanStartLine(span);
104
+ }
105
+
106
+ function compactRecord(value) {
107
+ return Object.fromEntries(Object.entries(value ?? {}).filter(([, entry]) => entry !== undefined && (!Array.isArray(entry) || entry.length > 0)));
108
+ }
@@ -12,6 +12,7 @@ import {
12
12
  semanticEditAdmission,
13
13
  summarizeSemanticEditOperations
14
14
  } from './semanticEditScriptClassification.js';
15
+ import { semanticEditInsertionAnchor } from './semanticEditInsertionAnchors.js';
15
16
  import { sourceTextForSpan } from './sourceTextForSpan.js';
16
17
  import { semanticEditIdentityFields, semanticEditOperationContentHash } from './semanticEditIdentityRecords.js';
17
18
 
@@ -168,10 +169,11 @@ function semanticEditOperation(region, index, context, input) {
168
169
  const identityRecord = semanticEditIdentityRecord({ kind, region, anchor });
169
170
  const identity = semanticEditIdentityFields(identityRecord);
170
171
  return compactRecord({
171
- id: `semantic_edit_op_${idFragment(firstString(input.id, anchorKey, index))}`,
172
+ id: `semantic_edit_op_${idFragment([input.id ?? 'semantic_edit', anchorKey, index].join(':'))}`,
172
173
  kind,
173
174
  changeKind: region.changeKind,
174
175
  anchor,
176
+ insertion: semanticEditInsertionAnchor(region, context),
175
177
  ...identity,
176
178
  spans: compactRecord({
177
179
  base: baseSymbol?.sourceSpan ?? region.metadata?.changedRegionProjection?.before?.sourceSpan,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@shapeshift-labs/frontier-lang-compiler",
3
- "version": "0.2.95",
3
+ "version": "0.2.96",
4
4
  "description": "Compiler facade for Frontier Lang source documents and language projection adapters.",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",