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

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;
@@ -1,6 +1,7 @@
1
1
  import { hashSemanticValue } from '@shapeshift-labs/frontier-lang-kernel';
2
2
  import { idFragment, uniqueStrings } from '../../native-import-utils.js';
3
3
  import { semanticEditIdentityFields } from './semanticEditIdentityRecords.js';
4
+ import { applySourceEdits, dedupeSourceEdits, validateSourceEdits } from './semanticSourceEditDedupe.js';
4
5
 
5
6
  export function projectSemanticEditScriptToSource(input = {}) {
6
7
  const script = input.script;
@@ -12,13 +13,15 @@ export function projectSemanticEditScriptToSource(input = {}) {
12
13
  if (typeof workerSourceText !== 'string') reasonCodes.push('missing-worker-source-text');
13
14
  if (typeof headSourceText !== 'string') reasonCodes.push('missing-head-source-text');
14
15
  const edits = [];
15
- for (const operation of script.operations ?? []) {
16
- const edit = sourceEditForOperation(operation, workerSourceText, headSourceText);
16
+ for (const [index, operation] of (script.operations ?? []).entries()) {
17
+ const edit = sourceEditForOperation(operation, workerSourceText, headSourceText, index);
17
18
  if (edit.ok) edits.push(edit.value);
18
19
  else reasonCodes.push(...edit.reasonCodes);
19
20
  }
21
+ const deduped = dedupeSourceEdits(edits);
22
+ reasonCodes.push(...validateSourceEdits(deduped.edits));
20
23
  const blocked = reasonCodes.length > 0;
21
- const sourceText = blocked ? undefined : applySourceEdits(headSourceText, edits);
24
+ const sourceText = blocked ? undefined : applySourceEdits(headSourceText, deduped.edits);
22
25
  const core = {
23
26
  kind: 'frontier.lang.semanticEditProjection',
24
27
  version: 1,
@@ -31,9 +34,9 @@ export function projectSemanticEditScriptToSource(input = {}) {
31
34
  workerHash: script.workerHash,
32
35
  headHash: script.headHash,
33
36
  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),
37
+ appliedOperations: blocked ? [] : deduped.edits.map((edit) => edit.operationId),
38
+ skippedOperations: blocked ? (script.operations ?? []).map((operation) => operation.id) : deduped.skippedOperationIds,
39
+ edits: blocked ? [] : deduped.edits.map(projectionEditRecord),
37
40
  sourceText,
38
41
  admission: {
39
42
  status: blocked ? 'blocked' : 'auto-merge-candidate',
@@ -45,20 +48,24 @@ export function projectSemanticEditScriptToSource(input = {}) {
45
48
  autoMergeClaim: false,
46
49
  semanticEquivalenceClaim: false,
47
50
  editCount: edits.length,
48
- appliedEditCount: edits.filter((edit) => !edit.alreadyApplied).length,
49
- alreadyAppliedEditCount: edits.filter((edit) => edit.alreadyApplied).length,
51
+ appliedEditCount: deduped.edits.filter((edit) => !edit.alreadyApplied).length,
52
+ alreadyAppliedEditCount: deduped.edits.filter((edit) => edit.alreadyApplied).length,
53
+ dedupedEditCount: deduped.skippedOperationIds.length,
50
54
  ...input.metadata
51
55
  })
52
56
  };
53
57
  return { ...core, hash: hashSemanticValue(core) };
54
58
  }
55
59
 
56
- function sourceEditForOperation(operation, workerSourceText, headSourceText) {
60
+ function sourceEditForOperation(operation, workerSourceText, headSourceText, order) {
57
61
  const identity = projectionIdentity(operation);
58
62
  if (operation.status === 'already-applied') {
59
- return { ok: true, value: { ...identity, operationId: operation.id, start: 0, end: 0, replacement: '', current: '', alreadyApplied: true } };
63
+ return { ok: true, value: { ...identity, operationId: operation.id, order, start: 0, end: 0, replacement: '', current: '', alreadyApplied: true } };
60
64
  }
61
65
  if (operation.status !== 'portable') return { ok: false, reasonCodes: [`operation-not-portable:${operation.id}`] };
66
+ if (operation.changeKind === 'added' || String(operation.kind ?? '').startsWith('add')) {
67
+ return insertionEditForOperation(operation, identity, workerSourceText, headSourceText, order);
68
+ }
62
69
  const workerOffsets = spanOffsets(workerSourceText, operation.spans?.worker);
63
70
  const headOffsets = spanOffsets(headSourceText, operation.spans?.head ?? operation.spans?.base ?? operation.anchor?.sourceSpan);
64
71
  const reasons = [];
@@ -79,7 +86,9 @@ function sourceEditForOperation(operation, workerSourceText, headSourceText) {
79
86
  ok: true,
80
87
  value: {
81
88
  operationId: operation.id,
89
+ order,
82
90
  ...identity,
91
+ editKind: 'replace',
83
92
  start: headOffsets.start,
84
93
  end: headOffsets.end,
85
94
  workerStart: workerOffsets.start,
@@ -90,6 +99,37 @@ function sourceEditForOperation(operation, workerSourceText, headSourceText) {
90
99
  };
91
100
  }
92
101
 
102
+ function insertionEditForOperation(operation, identity, workerSourceText, headSourceText, order) {
103
+ const workerOffsets = spanOffsets(workerSourceText, operation.spans?.worker);
104
+ const reasons = [];
105
+ if (!workerOffsets) reasons.push(`worker-span-not-resolvable:${operation.id}`);
106
+ const insertion = insertionOffset(headSourceText, operation.insertion);
107
+ if (!insertion.ok) reasons.push(...insertion.reasonCodes.map((reason) => `${reason}:${operation.id}`));
108
+ if (reasons.length) return { ok: false, reasonCodes: reasons };
109
+ const spanText = workerSourceText.slice(workerOffsets.start, workerOffsets.end);
110
+ if (operation.hashes?.workerTextHash && hashSemanticValue(spanText) !== operation.hashes.workerTextHash) {
111
+ reasons.push(`worker-span-hash-mismatch:${operation.id}`);
112
+ }
113
+ if (reasons.length) return { ok: false, reasonCodes: reasons };
114
+ return {
115
+ ok: true,
116
+ value: {
117
+ operationId: operation.id,
118
+ order,
119
+ ...identity,
120
+ editKind: 'insert',
121
+ insertion: operation.insertion,
122
+ start: insertion.offset,
123
+ end: insertion.offset,
124
+ workerStart: workerOffsets.start,
125
+ workerEnd: workerOffsets.end,
126
+ replacement: insertionReplacement(spanText, headSourceText, insertion.offset),
127
+ replacementSpanText: spanText,
128
+ current: ''
129
+ }
130
+ };
131
+ }
132
+
93
133
  function projectionIdentity(operation) {
94
134
  const identity = semanticEditIdentity(operation);
95
135
  return { ...identity, sourcePath: operation.reanchor?.toSourcePath ?? identity.sourcePath };
@@ -103,6 +143,7 @@ function projectionEditRecord(edit) {
103
143
  operationId: edit.operationId,
104
144
  status: edit.alreadyApplied ? 'already-applied' : 'applied',
105
145
  kind: edit.kind,
146
+ editKind: edit.editKind,
106
147
  changeKind: edit.changeKind,
107
148
  anchorKey: edit.anchorKey,
108
149
  conflictKey: edit.conflictKey,
@@ -133,6 +174,11 @@ function projectionEditRecord(edit) {
133
174
  replacementBytes: edit.replacement.length,
134
175
  deletedTextHash,
135
176
  replacementTextHash,
177
+ replacementSpanTextHash: hashSemanticValue(edit.replacementSpanText ?? edit.replacement),
178
+ insertionMode: edit.insertion?.mode,
179
+ insertionAnchorKey: edit.insertion?.anchorKey,
180
+ insertionAnchorSymbolName: edit.insertion?.anchorSymbolName,
181
+ insertionAnchorSymbolKind: edit.insertion?.anchorSymbolKind,
136
182
  replacementText: edit.replacement
137
183
  });
138
184
  }
@@ -162,12 +208,6 @@ function semanticEditIdentity(operation) {
162
208
  });
163
209
  }
164
210
 
165
- function applySourceEdits(sourceText, edits) {
166
- return edits.filter((edit) => !edit.alreadyApplied)
167
- .sort((left, right) => right.start - left.start)
168
- .reduce((text, edit) => text.slice(0, edit.start) + edit.replacement + text.slice(edit.end), sourceText);
169
- }
170
-
171
211
  function projectedSourcePath(script, edits) {
172
212
  return edits.map((edit) => edit.sourcePath).find(Boolean) ?? script.sourcePath;
173
213
  }
@@ -189,6 +229,31 @@ function spanOffsets(sourceText, span) {
189
229
  return { start: start + startColumn, end: endLineStart + endColumn };
190
230
  }
191
231
 
232
+ function insertionOffset(sourceText, insertion) {
233
+ if (typeof sourceText !== 'string') return { ok: false, reasonCodes: ['missing-head-source-text'] };
234
+ const mode = insertion?.mode;
235
+ if (mode === 'file-start') return { ok: true, offset: 0 };
236
+ if (mode === 'file-end') return { ok: true, offset: sourceText.length };
237
+ const range = spanOffsets(sourceText, insertion?.headSpan);
238
+ if (!range) return { ok: false, reasonCodes: ['insertion-anchor-not-resolvable'] };
239
+ if (mode === 'before') return { ok: true, offset: range.start };
240
+ if (mode === 'after') return { ok: true, offset: afterLineOffset(sourceText, range.end) };
241
+ return { ok: false, reasonCodes: ['insertion-mode-unsupported'] };
242
+ }
243
+
244
+ function insertionReplacement(text, sourceText, offset) {
245
+ let replacement = String(text ?? '');
246
+ if (offset > 0 && sourceText[offset - 1] !== '\n') replacement = `\n${replacement}`;
247
+ if (offset < sourceText.length && !replacement.endsWith('\n')) replacement += '\n';
248
+ if (offset === sourceText.length && sourceText && !sourceText.endsWith('\n')) replacement = `\n${replacement}`;
249
+ if (offset === sourceText.length && !replacement.endsWith('\n')) replacement += '\n';
250
+ return replacement;
251
+ }
252
+
253
+ function afterLineOffset(sourceText, offset) {
254
+ return sourceText[offset] === '\n' ? offset + 1 : offset;
255
+ }
256
+
192
257
  function compactRecord(value) {
193
258
  return Object.fromEntries(Object.entries(value ?? {}).filter(([, entry]) => entry !== undefined && (!Array.isArray(entry) || entry.length > 0)));
194
259
  }
@@ -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,
@@ -0,0 +1,99 @@
1
+ import { hashSemanticValue } from '@shapeshift-labs/frontier-lang-kernel';
2
+ import { uniqueStrings } from '../../native-import-utils.js';
3
+
4
+ export function applySourceEdits(sourceText, edits) {
5
+ return edits.filter((edit) => !edit.alreadyApplied)
6
+ .sort(sourceEditSort)
7
+ .reduce((text, edit) => text.slice(0, edit.start) + edit.replacement + text.slice(edit.end), sourceText);
8
+ }
9
+
10
+ export function dedupeSourceEdits(edits) {
11
+ const exact = dedupeExactInsertions(edits);
12
+ const covered = removeCoveredContainerReplacements(exact.edits);
13
+ return {
14
+ edits: covered.edits,
15
+ skippedOperationIds: [...exact.skippedOperationIds, ...covered.skippedOperationIds]
16
+ };
17
+ }
18
+
19
+ export function validateSourceEdits(edits) {
20
+ const reasons = [];
21
+ const ordered = edits.filter((edit) => !edit.alreadyApplied).sort((left, right) => left.start - right.start || left.end - right.end);
22
+ for (let index = 1; index < ordered.length; index += 1) {
23
+ const previous = ordered[index - 1];
24
+ const current = ordered[index];
25
+ if (editsOverlap(previous, current)) reasons.push(`source-edit-overlap:${previous.operationId}:${current.operationId}`);
26
+ }
27
+ return uniqueStrings(reasons);
28
+ }
29
+
30
+ function dedupeExactInsertions(edits) {
31
+ const seen = new Map();
32
+ const result = [];
33
+ const skippedOperationIds = [];
34
+ for (const edit of edits) {
35
+ const key = duplicateEditKey(edit);
36
+ if (key && seen.has(key)) {
37
+ skippedOperationIds.push(edit.operationId);
38
+ continue;
39
+ }
40
+ if (key) seen.set(key, edit.operationId);
41
+ result.push(edit);
42
+ }
43
+ return { edits: result, skippedOperationIds };
44
+ }
45
+
46
+ function removeCoveredContainerReplacements(edits) {
47
+ const skippedOperationIds = [];
48
+ const result = [];
49
+ for (const edit of edits) {
50
+ if (containerReplacementCoveredByInsertions(edit, edits)) {
51
+ skippedOperationIds.push(edit.operationId);
52
+ continue;
53
+ }
54
+ result.push(edit);
55
+ }
56
+ return { edits: result, skippedOperationIds };
57
+ }
58
+
59
+ function containerReplacementCoveredByInsertions(edit, edits) {
60
+ if (edit.editKind !== 'replace' || edit.alreadyApplied) return false;
61
+ const insertions = containedInsertions(edit, edits);
62
+ if (!insertions.length) return false;
63
+ const localEdits = insertions.map((insertion) => ({
64
+ ...insertion,
65
+ start: insertion.start - edit.start,
66
+ end: insertion.end - edit.start
67
+ }));
68
+ return applySourceEdits(edit.current, localEdits) === edit.replacement;
69
+ }
70
+
71
+ function containedInsertions(container, edits) {
72
+ return edits
73
+ .filter((edit) => edit.editKind === 'insert' && !edit.alreadyApplied)
74
+ .filter((edit) => edit.operationId !== container.operationId)
75
+ .filter((edit) => container.start <= edit.start && edit.end <= container.end)
76
+ .sort((left, right) => left.start - right.start || (left.order ?? 0) - (right.order ?? 0));
77
+ }
78
+
79
+ function duplicateEditKey(edit) {
80
+ if (edit.editKind !== 'insert') return undefined;
81
+ return [
82
+ 'insert',
83
+ edit.start,
84
+ edit.end,
85
+ edit.insertion?.mode,
86
+ edit.insertion?.anchorKey,
87
+ hashSemanticValue(edit.replacementSpanText ?? edit.replacement)
88
+ ].join(':');
89
+ }
90
+
91
+ function sourceEditSort(left, right) {
92
+ return right.start - left.start || right.end - left.end || (right.order ?? 0) - (left.order ?? 0);
93
+ }
94
+
95
+ function editsOverlap(left, right) {
96
+ if (left.start === left.end) return right.start < left.start && left.start < right.end;
97
+ if (right.start === right.end) return left.start < right.start && right.start < left.end;
98
+ return left.start < right.end && right.start < left.end;
99
+ }
@@ -160,11 +160,16 @@ function jsDeclarationWithSourceSpan(input, declaration, lines) {
160
160
  startLine,
161
161
  endLine,
162
162
  startColumn: declaration.span?.startColumn ?? 1,
163
- endColumn: declaration.span?.endColumn ?? endLineText.length + 1
163
+ endColumn: declarationEndColumn(declaration, startLine, endLine, endLineText)
164
164
  }
165
165
  };
166
166
  }
167
167
 
168
+ function declarationEndColumn(declaration, startLine, endLine, endLineText) {
169
+ if (endLine !== startLine) return endLineText.length + 1;
170
+ return declaration.span?.endColumn ?? endLineText.length + 1;
171
+ }
172
+
168
173
  function jsBalancedDeclarationEndLine(input, lines, startLine) {
169
174
  const state = { inBlockComment: false, inTemplateString: false };
170
175
  let depth = 0;
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.97",
4
4
  "description": "Compiler facade for Frontier Lang source documents and language projection adapters.",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",