@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.
- package/dist/declarations/semantic-edit-script.d.ts +23 -23
- package/dist/internal/index-impl/projectSemanticEditScriptToSource.js +130 -11
- package/dist/internal/index-impl/replaySemanticEditProjection.js +35 -1
- package/dist/internal/index-impl/semanticEditInsertionAnchors.js +108 -0
- package/dist/internal/index-impl/semanticEditScripts.js +3 -1
- package/package.json +1 -1
|
@@ -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
|
|
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
|
|
55
|
-
readonly
|
|
56
|
-
readonly
|
|
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(
|
|
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.
|
|
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(
|
|
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