@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.
- package/dist/declarations/semantic-edit-script.d.ts +23 -23
- package/dist/internal/index-impl/projectSemanticEditScriptToSource.js +81 -16
- 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/dist/internal/index-impl/semanticSourceEditDedupe.js +99 -0
- package/dist/native-region-scanner-js.js +6 -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;
|
|
@@ -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.
|
|
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,
|
|
@@ -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
|
|
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