@shapeshift-labs/frontier-lang-compiler 0.2.99 → 0.2.101

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (57) hide show
  1. package/dist/declarations/bidirectional-target-change-evidence.d.ts +299 -0
  2. package/dist/declarations/bidirectional-target-change.d.ts +19 -120
  3. package/dist/declarations/native-project-admission.d.ts +43 -22
  4. package/dist/declarations/semantic-edit-bundle.d.ts +13 -1
  5. package/dist/declarations/semantic-edit-replay-diagnostics.d.ts +24 -0
  6. package/dist/declarations/semantic-edit-script.d.ts +53 -51
  7. package/dist/declarations/semantic-lineage.d.ts +62 -51
  8. package/dist/declarations/semantic-merge-candidates.d.ts +39 -0
  9. package/dist/declarations/semantic-patch-bundle.d.ts +13 -0
  10. package/dist/declarations/semantic-sidecar-admission.d.ts +14 -0
  11. package/dist/declarations/semantic-sidecar.d.ts +12 -14
  12. package/dist/internal/index-impl/bidirectionalTargetRoundtripEvidence.js +200 -0
  13. package/dist/internal/index-impl/createBidirectionalTargetChangeRecord.js +62 -17
  14. package/dist/internal/index-impl/createNativeSourcePreservation.js +16 -1
  15. package/dist/internal/index-impl/createProjectImportAdmissionRecord.js +151 -1
  16. package/dist/internal/index-impl/createSemanticImportSidecar.js +5 -0
  17. package/dist/internal/index-impl/createSemanticImportSidecarAdmission.js +29 -11
  18. package/dist/internal/index-impl/declarationRecord.js +2 -2
  19. package/dist/internal/index-impl/inferSemanticLineageEvents.js +8 -0
  20. package/dist/internal/index-impl/nativeChangeProjectionEndpoint.js +56 -16
  21. package/dist/internal/index-impl/projectImportAdmissionMergeScore.js +26 -74
  22. package/dist/internal/index-impl/projectImportAdmissionProjectionCoverage.js +74 -0
  23. package/dist/internal/index-impl/projectSemanticEditScriptToSource.js +92 -74
  24. package/dist/internal/index-impl/replaySemanticEditProjection.js +114 -40
  25. package/dist/internal/index-impl/semanticEditBundleAdmission.js +95 -12
  26. package/dist/internal/index-impl/semanticEditBundleIndex.js +16 -10
  27. package/dist/internal/index-impl/semanticEditInsertionAnchors.js +8 -5
  28. package/dist/internal/index-impl/semanticEditReplayDiagnostics.js +167 -0
  29. package/dist/internal/index-impl/semanticEditSourceRanges.js +283 -0
  30. package/dist/internal/index-impl/semanticHistoryLineageResolution.js +56 -3
  31. package/dist/internal/index-impl/semanticIndexFromNativeDeclarations.js +2 -2
  32. package/dist/internal/index-impl/semanticLineageHashEvidence.js +97 -0
  33. package/dist/internal/index-impl/semanticLineageInferenceMatching.js +158 -13
  34. package/dist/internal/index-impl/semanticLineageResolutionRecords.js +46 -2
  35. package/dist/internal/index-impl/semanticMergeCandidateRecords.js +22 -2
  36. package/dist/internal/index-impl/semanticMergeCandidateScoreFacets.js +221 -0
  37. package/dist/internal/index-impl/semanticPatchBundleAdmission.js +122 -20
  38. package/dist/internal/index-impl/semanticPatchBundleLineageLinks.js +199 -0
  39. package/dist/internal/index-impl/semanticPatchBundleOverlaps.js +29 -3
  40. package/dist/internal/index-impl/semanticPatchBundleRecords.js +28 -104
  41. package/dist/internal/index-impl/semanticPatchBundleSourceRecords.js +127 -0
  42. package/dist/internal/index-impl/sourcePreservationFromProjectionContext.js +9 -2
  43. package/dist/internal/index-impl/sourceTextForSpan.js +4 -9
  44. package/dist/lightweight-dependency-relations.js +113 -7
  45. package/dist/native-import-language-profiles.js +10 -2
  46. package/dist/native-import-utils.js +15 -1
  47. package/dist/native-region-scanner-js-helpers.js +68 -18
  48. package/dist/native-region-scanner-js-imports.js +7 -0
  49. package/dist/native-region-scanner-js.js +16 -8
  50. package/dist/native-region-scanner.js +2 -1
  51. package/dist/semantic-import-regions.js +8 -6
  52. package/dist/semantic-import-sidecar-admission-types.d.ts +14 -0
  53. package/dist/semantic-import-sidecar-entry.js +151 -7
  54. package/dist/semantic-import-sidecar-types.d.ts +18 -13
  55. package/dist/semantic-import-source-preservation-utils.js +55 -0
  56. package/dist/semantic-import-source-preservation.js +98 -3
  57. package/package.json +1 -1
@@ -1,30 +1,55 @@
1
1
  import { hashSemanticValue } from '@shapeshift-labs/frontier-lang-kernel';
2
- import { idFragment, uniqueStrings } from '../../native-import-utils.js';
2
+ import { idFragment, normalizeNativeLanguageId, uniqueStrings } from '../../native-import-utils.js';
3
+ import { createSemanticImportSidecar } from './createSemanticImportSidecar.js';
4
+ import { mapDiffSymbols } from './mapDiffSymbols.js';
5
+ import { normalizeNativeDiffImport } from './normalizeNativeDiffImport.js';
3
6
  import { semanticEditIdentityFields } from './semanticEditIdentityRecords.js';
7
+ import {
8
+ insertionOffset,
9
+ insertionReplacement,
10
+ projectionCoveredContainerOperationIds,
11
+ removalRange,
12
+ scopedBodyReplacement,
13
+ spanOffsets
14
+ } from './semanticEditSourceRanges.js';
4
15
  import { applySourceEdits, dedupeSourceEdits, validateSourceEdits } from './semanticSourceEditDedupe.js';
5
-
6
16
  export function projectSemanticEditScriptToSource(input = {}) {
7
17
  const script = input.script;
8
18
  const workerSourceText = input.workerSourceText;
9
19
  const headSourceText = input.headSourceText;
10
20
  const reasonCodes = [];
11
21
  if (!script) throw new Error('projectSemanticEditScriptToSource requires a script');
12
- if (script.admission?.status !== 'auto-merge-candidate') reasonCodes.push('script-not-auto-merge-candidate');
13
22
  if (typeof workerSourceText !== 'string') reasonCodes.push('missing-worker-source-text');
14
23
  if (typeof headSourceText !== 'string') reasonCodes.push('missing-head-source-text');
24
+ const language = normalizeNativeLanguageId(script.language);
25
+ const headSymbols = typeof headSourceText === 'string' && isJavaScriptLike(language)
26
+ ? sourceSymbolIndex({
27
+ sourceText: headSourceText,
28
+ sourcePath: input.headSourcePath ?? script.sourcePath,
29
+ language,
30
+ parser: input.parser
31
+ })
32
+ : [];
15
33
  const edits = [];
16
34
  const coveredOperationIds = [];
35
+ const projectionCoveredOperationIds = projectionCoveredContainerOperationIds(script.operations ?? [], workerSourceText);
17
36
  for (const [index, operation] of (script.operations ?? []).entries()) {
18
- if (operation.status === 'covered') {
37
+ if (operation.status === 'covered' || projectionCoveredOperationIds.has(operation.id)) {
19
38
  coveredOperationIds.push(operation.id);
20
39
  continue;
21
40
  }
22
- const edit = sourceEditForOperation(operation, workerSourceText, headSourceText, index);
41
+ const edit = sourceEditForOperation(operation, workerSourceText, headSourceText, index, {
42
+ headSourcePath: input.headSourcePath,
43
+ headSymbols
44
+ });
23
45
  if (edit.ok) edits.push(edit.value);
24
46
  else reasonCodes.push(...edit.reasonCodes);
25
47
  }
26
48
  const deduped = dedupeSourceEdits(edits);
27
49
  reasonCodes.push(...validateSourceEdits(deduped.edits));
50
+ if (script.admission?.status !== 'auto-merge-candidate' && (reasonCodes.length > 0 || (!edits.length && !coveredOperationIds.length))) {
51
+ reasonCodes.push('script-not-auto-merge-candidate');
52
+ }
28
53
  const blocked = reasonCodes.length > 0;
29
54
  const sourceText = blocked ? undefined : applySourceEdits(headSourceText, deduped.edits);
30
55
  const core = {
@@ -56,20 +81,20 @@ export function projectSemanticEditScriptToSource(input = {}) {
56
81
  appliedEditCount: deduped.edits.filter((edit) => !edit.alreadyApplied).length,
57
82
  alreadyAppliedEditCount: deduped.edits.filter((edit) => edit.alreadyApplied).length,
58
83
  dedupedEditCount: deduped.skippedOperationIds.length,
84
+ anchorMode: headSymbols.length ? 'javascript-like-symbols' : 'offsets',
59
85
  ...input.metadata
60
86
  })
61
87
  };
62
88
  return { ...core, hash: hashSemanticValue(core) };
63
89
  }
64
-
65
- function sourceEditForOperation(operation, workerSourceText, headSourceText, order) {
66
- const identity = projectionIdentity(operation);
90
+ function sourceEditForOperation(operation, workerSourceText, headSourceText, order, context) {
91
+ const identity = projectionIdentity(operation, context.headSourcePath);
67
92
  if (operation.status === 'already-applied') {
68
93
  return { ok: true, value: { ...identity, operationId: operation.id, order, start: 0, end: 0, replacement: '', current: '', alreadyApplied: true } };
69
94
  }
70
95
  if (operation.status !== 'portable') return { ok: false, reasonCodes: [`operation-not-portable:${operation.id}`] };
71
96
  if (operation.changeKind === 'added' || String(operation.kind ?? '').startsWith('add')) {
72
- return insertionEditForOperation(operation, identity, workerSourceText, headSourceText, order);
97
+ return insertionEditForOperation(operation, identity, workerSourceText, headSourceText, order, context);
73
98
  }
74
99
  if (operation.changeKind === 'removed' || String(operation.kind ?? '').startsWith('remove')) {
75
100
  return removalEditForOperation(operation, identity, headSourceText, order);
@@ -80,16 +105,23 @@ function sourceEditForOperation(operation, workerSourceText, headSourceText, ord
80
105
  if (!workerOffsets) reasons.push(`worker-span-not-resolvable:${operation.id}`);
81
106
  if (!headOffsets) reasons.push(`head-span-not-resolvable:${operation.id}`);
82
107
  if (reasons.length) return { ok: false, reasonCodes: reasons };
83
- const replacement = workerSourceText.slice(workerOffsets.start, workerOffsets.end);
84
- const current = headSourceText.slice(headOffsets.start, headOffsets.end);
85
- if (operation.hashes?.workerTextHash && hashSemanticValue(replacement) !== operation.hashes.workerTextHash) {
108
+ const anchorReplacement = workerSourceText.slice(workerOffsets.start, workerOffsets.end);
109
+ const anchorCurrent = headSourceText.slice(headOffsets.start, headOffsets.end);
110
+ if (operation.hashes?.workerTextHash && hashSemanticValue(anchorReplacement) !== operation.hashes.workerTextHash) {
86
111
  reasons.push(`worker-span-hash-mismatch:${operation.id}`);
87
112
  }
88
113
  const expectedHeadHash = operation.hashes?.headTextHash ?? operation.hashes?.baseTextHash;
89
- if (expectedHeadHash && hashSemanticValue(current) !== expectedHeadHash) {
114
+ if (expectedHeadHash && hashSemanticValue(anchorCurrent) !== expectedHeadHash) {
90
115
  reasons.push(`head-span-hash-mismatch:${operation.id}`);
91
116
  }
92
117
  if (reasons.length) return { ok: false, reasonCodes: reasons };
118
+ const scoped = scopedBodyReplacement(operation, headSourceText, workerSourceText, headOffsets, workerOffsets);
119
+ const replacement = scoped
120
+ ? workerSourceText.slice(scoped.worker.start, scoped.worker.end)
121
+ : anchorReplacement;
122
+ const current = scoped
123
+ ? headSourceText.slice(scoped.head.start, scoped.head.end)
124
+ : anchorCurrent;
93
125
  return {
94
126
  ok: true,
95
127
  value: {
@@ -97,16 +129,22 @@ function sourceEditForOperation(operation, workerSourceText, headSourceText, ord
97
129
  order,
98
130
  ...identity,
99
131
  editKind: 'replace',
100
- start: headOffsets.start,
101
- end: headOffsets.end,
102
- workerStart: workerOffsets.start,
103
- workerEnd: workerOffsets.end,
132
+ sourceRangeKind: scoped?.sourceRangeKind,
133
+ start: scoped?.head.start ?? headOffsets.start,
134
+ end: scoped?.head.end ?? headOffsets.end,
135
+ workerStart: scoped?.worker.start ?? workerOffsets.start,
136
+ workerEnd: scoped?.worker.end ?? workerOffsets.end,
137
+ headAnchorStart: scoped ? headOffsets.start : undefined,
138
+ headAnchorEnd: scoped ? headOffsets.end : undefined,
139
+ workerAnchorStart: scoped ? workerOffsets.start : undefined,
140
+ workerAnchorEnd: scoped ? workerOffsets.end : undefined,
141
+ anchorDeletedTextHash: scoped ? hashSemanticValue(anchorCurrent) : undefined,
142
+ anchorReplacementTextHash: scoped ? hashSemanticValue(anchorReplacement) : undefined,
104
143
  replacement,
105
144
  current
106
145
  }
107
146
  };
108
147
  }
109
-
110
148
  function removalEditForOperation(operation, identity, headSourceText, order) {
111
149
  const headOffsets = spanOffsets(headSourceText, operation.spans?.head ?? operation.spans?.base ?? operation.anchor?.sourceSpan);
112
150
  const reasons = [];
@@ -133,12 +171,11 @@ function removalEditForOperation(operation, identity, headSourceText, order) {
133
171
  }
134
172
  };
135
173
  }
136
-
137
- function insertionEditForOperation(operation, identity, workerSourceText, headSourceText, order) {
174
+ function insertionEditForOperation(operation, identity, workerSourceText, headSourceText, order, context) {
138
175
  const workerOffsets = spanOffsets(workerSourceText, operation.spans?.worker);
139
176
  const reasons = [];
140
177
  if (!workerOffsets) reasons.push(`worker-span-not-resolvable:${operation.id}`);
141
- const insertion = insertionOffset(headSourceText, operation.insertion);
178
+ const insertion = insertionOffset(headSourceText, operation.insertion, { symbols: context.headSymbols });
142
179
  if (!insertion.ok) reasons.push(...insertion.reasonCodes.map((reason) => `${reason}:${operation.id}`));
143
180
  if (reasons.length) return { ok: false, reasonCodes: reasons };
144
181
  const spanText = workerSourceText.slice(workerOffsets.start, workerOffsets.end);
@@ -164,12 +201,17 @@ function insertionEditForOperation(operation, identity, workerSourceText, headSo
164
201
  }
165
202
  };
166
203
  }
167
-
168
- function projectionIdentity(operation) {
204
+ function projectionIdentity(operation, headSourcePath) {
169
205
  const identity = semanticEditIdentity(operation);
170
- return { ...identity, sourcePath: operation.reanchor?.toSourcePath ?? identity.sourcePath };
206
+ const sourcePath = operation.reanchor?.toSourcePath ?? headSourcePath ?? operation.insertion?.sourcePath ?? identity.sourcePath;
207
+ const originalSourcePath = sourcePath && identity.sourcePath && sourcePath !== identity.sourcePath
208
+ ? identity.sourcePath
209
+ : identity.originalSourcePath;
210
+ const targetSourcePath = sourcePath && sourcePath !== identity.sourcePath
211
+ ? sourcePath
212
+ : identity.targetSourcePath;
213
+ return { ...identity, sourcePath, originalSourcePath, targetSourcePath };
171
214
  }
172
-
173
215
  function projectionEditRecord(edit) {
174
216
  const deletedTextHash = hashSemanticValue(edit.current);
175
217
  const replacementTextHash = hashSemanticValue(edit.replacement);
@@ -197,27 +239,51 @@ function projectionEditRecord(edit) {
197
239
  operationContentHash: edit.operationContentHash,
198
240
  editContentHash: hashSemanticValue(compactRecord({
199
241
  semanticIdentityHash: identity.semanticIdentityHash,
242
+ sourceRangeKind: edit.sourceRangeKind,
200
243
  deletedTextHash,
201
244
  replacementTextHash,
202
245
  status: edit.alreadyApplied ? 'already-applied' : 'applied'
203
246
  })),
247
+ sourceRangeKind: edit.sourceRangeKind,
204
248
  headStart: edit.start,
205
249
  headEnd: edit.end,
206
250
  workerStart: edit.workerStart,
207
251
  workerEnd: edit.workerEnd,
252
+ editOrder: edit.order,
253
+ headAnchorStart: edit.headAnchorStart,
254
+ headAnchorEnd: edit.headAnchorEnd,
255
+ workerAnchorStart: edit.workerAnchorStart,
256
+ workerAnchorEnd: edit.workerAnchorEnd,
208
257
  deletedBytes: edit.current.length,
209
258
  replacementBytes: edit.replacement.length,
210
259
  deletedTextHash,
211
260
  replacementTextHash,
261
+ anchorDeletedTextHash: edit.anchorDeletedTextHash,
262
+ anchorReplacementTextHash: edit.anchorReplacementTextHash,
212
263
  replacementSpanTextHash: hashSemanticValue(edit.replacementSpanText ?? edit.replacement),
213
264
  insertionMode: edit.insertion?.mode,
214
265
  insertionAnchorKey: edit.insertion?.anchorKey,
215
266
  insertionAnchorSymbolName: edit.insertion?.anchorSymbolName,
216
267
  insertionAnchorSymbolKind: edit.insertion?.anchorSymbolKind,
268
+ insertionAnchorCandidates: edit.insertion?.anchorCandidates,
217
269
  replacementText: edit.replacement
218
270
  });
219
271
  }
220
272
 
273
+ function sourceSymbolIndex(input) {
274
+ try {
275
+ const imported = normalizeNativeDiffImport({
276
+ language: input.language,
277
+ sourcePath: input.sourcePath,
278
+ sourceText: input.sourceText,
279
+ parser: input.parser
280
+ }, input, 'head');
281
+ return [...mapDiffSymbols(imported, createSemanticImportSidecar(imported)).values()];
282
+ } catch {
283
+ return [];
284
+ }
285
+ }
286
+
221
287
  function semanticEditIdentity(operation) {
222
288
  const anchor = operation.anchor ?? {};
223
289
  return compactRecord({
@@ -247,55 +313,7 @@ function projectedSourcePath(script, edits) {
247
313
  return edits.map((edit) => edit.sourcePath).find(Boolean) ?? script.sourcePath;
248
314
  }
249
315
 
250
- function spanOffsets(sourceText, span) {
251
- if (typeof sourceText !== 'string' || !span) return undefined;
252
- if (typeof span.start === 'number' && typeof span.end === 'number' && span.end >= span.start) return { start: span.start, end: span.end };
253
- if (typeof span.startLine !== 'number') return undefined;
254
- const lineStarts = [0];
255
- for (let index = 0; index < sourceText.length; index += 1) if (sourceText[index] === '\n') lineStarts.push(index + 1);
256
- const startLine = Math.max(1, span.startLine);
257
- const endLine = Math.max(startLine, typeof span.endLine === 'number' ? span.endLine : startLine);
258
- const start = lineStarts[startLine - 1];
259
- const endLineStart = lineStarts[endLine - 1];
260
- if (start === undefined || endLineStart === undefined) return undefined;
261
- const startColumn = Math.max(1, span.startColumn ?? 1) - 1;
262
- const lineEnd = lineStarts[endLine] === undefined ? sourceText.length : lineStarts[endLine] - 1;
263
- const endColumn = span.endColumn === undefined ? lineEnd - endLineStart : Math.max(1, span.endColumn) - 1;
264
- return { start: start + startColumn, end: endLineStart + endColumn };
265
- }
266
-
267
- function insertionOffset(sourceText, insertion) {
268
- if (typeof sourceText !== 'string') return { ok: false, reasonCodes: ['missing-head-source-text'] };
269
- const mode = insertion?.mode;
270
- if (mode === 'file-start') return { ok: true, offset: 0 };
271
- if (mode === 'file-end') return { ok: true, offset: sourceText.length };
272
- const range = spanOffsets(sourceText, insertion?.headSpan);
273
- if (!range) return { ok: false, reasonCodes: ['insertion-anchor-not-resolvable'] };
274
- if (mode === 'before') return { ok: true, offset: range.start };
275
- if (mode === 'after') return { ok: true, offset: afterLineOffset(sourceText, range.end) };
276
- return { ok: false, reasonCodes: ['insertion-mode-unsupported'] };
277
- }
278
-
279
- function removalRange(sourceText, span) {
280
- const range = { ...span };
281
- if (range.end < sourceText.length && sourceText[range.end] === '\n') range.end += 1;
282
- else if (range.start > 0 && sourceText[range.start - 1] === '\n') range.start -= 1;
283
- return range;
284
- }
285
-
286
- function insertionReplacement(text, sourceText, offset) {
287
- let replacement = String(text ?? '');
288
- if (offset > 0 && sourceText[offset - 1] !== '\n') replacement = `\n${replacement}`;
289
- if (offset < sourceText.length && !replacement.endsWith('\n')) replacement += '\n';
290
- if (offset === sourceText.length && sourceText && !sourceText.endsWith('\n')) replacement = `\n${replacement}`;
291
- if (offset === sourceText.length && !replacement.endsWith('\n')) replacement += '\n';
292
- return replacement;
293
- }
294
-
295
- function afterLineOffset(sourceText, offset) {
296
- return sourceText[offset] === '\n' ? offset + 1 : offset;
297
- }
298
-
316
+ function isJavaScriptLike(language) { return language === 'javascript' || language === 'typescript'; }
299
317
  function compactRecord(value) {
300
318
  return Object.fromEntries(Object.entries(value ?? {}).filter(([, entry]) => entry !== undefined && (!Array.isArray(entry) || entry.length > 0)));
301
319
  }
@@ -3,6 +3,8 @@ import { idFragment, normalizeNativeLanguageId, uniqueStrings } from '../../nati
3
3
  import { createSemanticImportSidecar } from './createSemanticImportSidecar.js';
4
4
  import { mapDiffSymbols } from './mapDiffSymbols.js';
5
5
  import { normalizeNativeDiffImport } from './normalizeNativeDiffImport.js';
6
+ import { replayDiagnostics, replayEditDiagnostics, replayEditsWithOverlapDiagnostics } from './semanticEditReplayDiagnostics.js';
7
+ import { afterLineOffset, bodyContentRange, spanOffsets } from './semanticEditSourceRanges.js';
6
8
 
7
9
  export function replaySemanticEditProjection(input = {}) {
8
10
  const projection = input.projection ?? input.semanticEditProjection;
@@ -16,11 +18,20 @@ export function replaySemanticEditProjection(input = {}) {
16
18
  const currentSymbols = currentSourceText && isJavaScriptLike(language)
17
19
  ? currentSymbolIndex({ currentSourceText, sourcePath, language, parser: input.parser })
18
20
  : [];
19
- const edits = projection.status === 'projected' && typeof currentSourceText === 'string'
20
- ? (projection.edits ?? []).map((edit) => replayProjectionEdit(edit, { currentSourceText, currentSymbols }))
21
+ const replayedEdits = projection.status === 'projected' && typeof currentSourceText === 'string'
22
+ ? (projection.edits ?? []).map((edit, index) => replayProjectionEdit(projectionEditWithOrder(edit, index), { currentSourceText, currentSymbols }))
21
23
  : [];
24
+ const edits = replayEditsWithOverlapDiagnostics(replayedEdits);
22
25
  const status = replayStatus(reasonCodes, edits, projection);
23
26
  const outputSourceText = replayOutputSource(status, currentSourceText, edits);
27
+ const diagnostics = replayDiagnostics({
28
+ status,
29
+ reasonCodes,
30
+ edits,
31
+ sourcePath,
32
+ currentHash,
33
+ expectedCurrentHash: input.currentSourceHash
34
+ });
24
35
  const core = {
25
36
  kind: 'frontier.lang.semanticEditReplay',
26
37
  version: 1,
@@ -37,6 +48,7 @@ export function replaySemanticEditProjection(input = {}) {
37
48
  edits,
38
49
  appliedOperations: edits.filter((edit) => edit.status === 'applied').map((edit) => edit.operationId).filter(Boolean),
39
50
  skippedOperations: edits.filter((edit) => edit.status !== 'applied').map((edit) => edit.operationId).filter(Boolean),
51
+ diagnostics,
40
52
  admission: replayAdmission(status, reasonCodes, edits),
41
53
  outputSourceText,
42
54
  summary: replaySummary(edits, reasonCodes),
@@ -51,31 +63,43 @@ export function replaySemanticEditProjection(input = {}) {
51
63
  }
52
64
 
53
65
  function replayProjectionEdit(edit, context) {
54
- if (edit.status === 'already-applied') return replayEditRecord(edit, 'already-applied', undefined, ['projection-edit-already-applied']);
55
- if (typeof edit.replacementText !== 'string') return replayEditRecord(edit, 'blocked', undefined, ['missing-replacement-text']);
66
+ if (edit.status === 'already-applied') return replayEditRecord(edit, 'already-applied', undefined, ['projection-edit-already-applied'], context.currentSourceText);
67
+ if (typeof edit.replacementText !== 'string') return replayEditRecord(edit, 'blocked', undefined, ['missing-replacement-text'], context.currentSourceText);
56
68
  if (edit.editKind === 'insert') return replayInsertionEdit(edit, context);
57
- const offset = checkRange(edit, { start: edit.headStart, end: edit.headEnd }, context.currentSourceText, 'head-offset');
58
- if (offset) return replayEditRecord(edit, offset.status, offset.range, [offset.reason]);
69
+ const headRange = { start: edit.headStart, end: edit.headEnd };
70
+ const offset = checkRange(edit, headRange, context.currentSourceText, 'head-offset');
59
71
  const symbol = findCurrentSymbol(edit, context.currentSymbols);
60
- const spanRange = spanOffsets(context.currentSourceText, symbol?.sourceSpan);
61
- const anchored = checkRange(edit, spanRange, context.currentSourceText, 'current-symbol-anchor');
62
- if (anchored) return replayEditRecord(edit, anchored.status, anchored.range, [anchored.reason, 'offset-reanchored-by-symbol']);
72
+ const spanRange = currentSymbolEditRange(edit, spanOffsets(context.currentSourceText, symbol?.sourceSpan), context.currentSourceText);
73
+ if (symbol && spanRange && !sameRange(headRange, spanRange)) {
74
+ const moved = checkRange(edit, spanRange, context.currentSourceText, currentSymbolRangeLabel(edit));
75
+ if (moved) return replayEditRecord(edit, moved.status, moved.range, [moved.reason, 'offset-reanchored-by-symbol'], context.currentSourceText);
76
+ if (edit.editKind === 'delete' && offset && rangesOverlap(headRange, spanRange)) {
77
+ return replayEditRecord(edit, offset.status, offset.range, [offset.reason], context.currentSourceText);
78
+ }
79
+ return replayEditRecord(edit, 'conflict', spanRange, [`${currentSymbolRangeLabel(edit)}-content-mismatch`], context.currentSourceText);
80
+ }
81
+ if (offset) return replayEditRecord(edit, offset.status, offset.range, [offset.reason], context.currentSourceText);
82
+ const anchored = checkRange(edit, spanRange, context.currentSourceText, currentSymbolRangeLabel(edit));
83
+ if (anchored) return replayEditRecord(edit, anchored.status, anchored.range, [anchored.reason, 'offset-reanchored-by-symbol'], context.currentSourceText);
63
84
  return replayEditRecord(edit, symbol ? 'conflict' : 'stale', spanRange, [
64
- symbol ? 'current-symbol-anchor-content-mismatch' : 'current-symbol-anchor-missing'
65
- ]);
85
+ symbol ? `${currentSymbolRangeLabel(edit)}-content-mismatch` : 'current-symbol-anchor-missing'
86
+ ], context.currentSourceText);
66
87
  }
67
88
 
68
89
  function replayInsertionEdit(edit, context) {
69
90
  const inserted = findCurrentSymbol(edit, context.currentSymbols);
70
91
  const insertedRange = spanOffsets(context.currentSourceText, inserted?.sourceSpan);
71
92
  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}`]);
93
+ if (already?.status === 'already-applied') return replayEditRecord(edit, 'already-applied', already.range, [already.reason], context.currentSourceText);
94
+ if (inserted && insertedRange) {
95
+ return replayEditRecord(edit, 'conflict', insertedRange, ['current-inserted-symbol-content-mismatch'], context.currentSourceText);
96
+ }
97
+ const anchor = findInsertionAnchor(edit, context.currentSymbols);
98
+ const range = insertionRange(edit, anchor?.candidate, anchor?.symbol, context.currentSourceText);
99
+ if (range) return replayEditRecord(edit, 'applied', range, [anchor ? 'current-insertion-anchor' : `current-${edit.insertionMode}`], context.currentSourceText);
76
100
  return replayEditRecord(edit, anchor ? 'conflict' : 'stale', undefined, [
77
101
  anchor ? 'current-insertion-anchor-unusable' : 'current-insertion-anchor-missing'
78
- ]);
102
+ ], context.currentSourceText);
79
103
  }
80
104
 
81
105
  function checkRange(edit, range, sourceText, label) {
@@ -89,7 +113,8 @@ function checkRange(edit, range, sourceText, label) {
89
113
  return undefined;
90
114
  }
91
115
 
92
- function replayEditRecord(edit, status, range, reasonCodes) {
116
+ function replayEditRecord(edit, status, range, reasonCodes, sourceText) {
117
+ const normalizedReasonCodes = reasonList(reasonCodes);
93
118
  return compactRecord({
94
119
  operationId: edit.operationId,
95
120
  semanticKey: edit.semanticKey,
@@ -97,6 +122,8 @@ function replayEditRecord(edit, status, range, reasonCodes) {
97
122
  sourceIdentityHash: edit.sourceIdentityHash,
98
123
  editContentHash: edit.editContentHash,
99
124
  editKind: edit.editKind,
125
+ editOrder: edit.editOrder,
126
+ sourceRangeKind: edit.sourceRangeKind,
100
127
  sourcePath: edit.targetSourcePath ?? edit.sourcePath,
101
128
  symbolName: edit.targetSymbolName ?? edit.symbolName,
102
129
  symbolKind: edit.targetSymbolKind ?? edit.symbolKind,
@@ -105,7 +132,8 @@ function replayEditRecord(edit, status, range, reasonCodes) {
105
132
  end: range?.end,
106
133
  replacementBytes: edit.replacementBytes,
107
134
  replacementText: edit.replacementText,
108
- reasonCodes: reasonList(reasonCodes)
135
+ reasonCodes: normalizedReasonCodes,
136
+ diagnostics: replayEditDiagnostics(edit, status, range, normalizedReasonCodes, sourceText)
109
137
  });
110
138
  }
111
139
 
@@ -131,24 +159,62 @@ function findCurrentSymbol(edit, symbols) {
131
159
  return symbols.find((symbol) => symbol.name === name && (!kind || symbol.kind === kind));
132
160
  }
133
161
 
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));
162
+ function findInsertionAnchor(edit, symbols) {
163
+ for (const candidate of insertionAnchorCandidates(edit)) {
164
+ const symbol = findInsertionAnchorSymbol(candidate, symbols);
165
+ if (symbol) return { candidate, symbol };
166
+ }
167
+ return undefined;
137
168
  }
138
169
 
139
- function insertionRange(edit, anchor, sourceText) {
170
+ function findInsertionAnchorSymbol(candidate, symbols) {
171
+ const keys = [candidate.anchorKey, candidate.anchorSymbolId].filter(Boolean);
172
+ return symbols.find((symbol) => [symbol.ownershipKey, symbol.key, symbol.id].some((key) => key && keys.includes(key)))
173
+ ?? symbols.find((symbol) => symbol.name === candidate.anchorSymbolName && (!candidate.anchorSymbolKind || symbol.kind === candidate.anchorSymbolKind));
174
+ }
175
+
176
+ function insertionAnchorCandidates(edit) {
177
+ const primary = {
178
+ mode: edit.insertionMode,
179
+ anchorKey: edit.insertionAnchorKey,
180
+ anchorSymbolName: edit.insertionAnchorSymbolName,
181
+ anchorSymbolKind: edit.insertionAnchorSymbolKind
182
+ };
183
+ const seen = new Set();
184
+ const result = [];
185
+ for (const candidate of [primary, ...(Array.isArray(edit.insertionAnchorCandidates) ? edit.insertionAnchorCandidates : [])]) {
186
+ if (!candidate || (candidate.mode !== 'before' && candidate.mode !== 'after')) continue;
187
+ const key = [candidate.mode, candidate.anchorKey, candidate.anchorSymbolId, candidate.anchorSymbolName, candidate.anchorSymbolKind].join('\0');
188
+ if (seen.has(key)) continue;
189
+ seen.add(key);
190
+ result.push(candidate);
191
+ }
192
+ return result;
193
+ }
194
+
195
+ function insertionRange(edit, candidate, anchor, sourceText) {
140
196
  if (edit.insertionMode === 'file-start') return { start: 0, end: 0 };
141
197
  if (edit.insertionMode === 'file-end') return { start: sourceText.length, end: sourceText.length };
198
+ const mode = candidate?.mode ?? edit.insertionMode;
142
199
  const anchorRange = spanOffsets(sourceText, anchor?.sourceSpan);
143
200
  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 };
201
+ if (mode === 'before') return { start: anchorRange.start, end: anchorRange.start };
202
+ if (mode === 'after') {
203
+ return { start: afterLineOffset(sourceText, anchorRange.end), end: afterLineOffset(sourceText, anchorRange.end) };
148
204
  }
149
205
  return undefined;
150
206
  }
151
207
 
208
+ function currentSymbolEditRange(edit, symbolRange, sourceText) {
209
+ if (!symbolRange) return undefined;
210
+ if (edit.sourceRangeKind === 'body-content') return bodyContentRange(sourceText, symbolRange);
211
+ return symbolRange;
212
+ }
213
+
214
+ function currentSymbolRangeLabel(edit) {
215
+ return edit.sourceRangeKind === 'body-content' ? 'current-symbol-body' : 'current-symbol-anchor';
216
+ }
217
+
152
218
  function replayStatus(reasonCodes, edits, projection) {
153
219
  if (reasonCodes.some((reason) => reason !== 'current-source-hash-mismatch')) return 'blocked';
154
220
  if (!edits.length && !(projection.edits ?? []).length) return 'evidence-only';
@@ -189,10 +255,25 @@ function replayOutputSource(status, sourceText, edits) {
189
255
  if (status === 'already-applied') return sourceText;
190
256
  if (status !== 'accepted-clean') return undefined;
191
257
  return edits.filter((edit) => edit.status === 'applied')
192
- .sort((left, right) => right.start - left.start)
258
+ .sort(replaySourceEditSort)
193
259
  .reduce((text, edit) => text.slice(0, edit.start) + editReplacement(edit, edits) + text.slice(edit.end), sourceText);
194
260
  }
195
261
 
262
+ function replaySourceEditSort(left, right) {
263
+ return right.start - left.start || right.end - left.end || (right.editOrder ?? 0) - (left.editOrder ?? 0);
264
+ }
265
+
266
+ function projectionEditWithOrder(edit, index) {
267
+ return {
268
+ ...edit,
269
+ editOrder: typeof edit.editOrder === 'number'
270
+ ? edit.editOrder
271
+ : typeof edit.order === 'number'
272
+ ? edit.order
273
+ : index
274
+ };
275
+ }
276
+
196
277
  function editReplacement(edit, edits) {
197
278
  return edits.find((candidate) => candidate.operationId === edit.operationId)?.replacementText ?? '';
198
279
  }
@@ -205,19 +286,12 @@ function baseReasonCodes(projection, currentSourceText) {
205
286
  ]);
206
287
  }
207
288
 
208
- function spanOffsets(sourceText, span) {
209
- if (typeof sourceText !== 'string' || !span) return undefined;
210
- if (typeof span.start === 'number' && typeof span.end === 'number' && span.end >= span.start) return { start: span.start, end: span.end };
211
- if (typeof span.startLine !== 'number') return undefined;
212
- const starts = [0];
213
- for (let index = 0; index < sourceText.length; index += 1) if (sourceText[index] === '\n') starts.push(index + 1);
214
- const startLine = Math.max(1, span.startLine);
215
- const endLine = Math.max(startLine, typeof span.endLine === 'number' ? span.endLine : startLine);
216
- const lineStart = starts[startLine - 1];
217
- const endLineStart = starts[endLine - 1];
218
- if (lineStart === undefined || endLineStart === undefined) return undefined;
219
- const lineEnd = starts[endLine] === undefined ? sourceText.length : starts[endLine] - 1;
220
- return { start: lineStart + Math.max(0, (span.startColumn ?? 1) - 1), end: endLineStart + (span.endColumn === undefined ? lineEnd - endLineStart : Math.max(0, span.endColumn - 1)) };
289
+ function sameRange(left, right) {
290
+ return left?.start === right?.start && left?.end === right?.end;
291
+ }
292
+
293
+ function rangesOverlap(left, right) {
294
+ return Boolean(left && right && left.start < right.end && right.start < left.end);
221
295
  }
222
296
 
223
297
  function isJavaScriptLike(language) { return language === 'javascript' || language === 'typescript'; }