@shapeshift-labs/frontier-lang-compiler 0.2.100 → 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 (43) 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-replay-diagnostics.d.ts +24 -0
  5. package/dist/declarations/semantic-edit-script.d.ts +20 -15
  6. package/dist/declarations/semantic-lineage.d.ts +3 -21
  7. package/dist/declarations/semantic-merge-candidates.d.ts +39 -0
  8. package/dist/declarations/semantic-sidecar-admission.d.ts +14 -0
  9. package/dist/declarations/semantic-sidecar.d.ts +12 -14
  10. package/dist/internal/index-impl/bidirectionalTargetRoundtripEvidence.js +200 -0
  11. package/dist/internal/index-impl/createBidirectionalTargetChangeRecord.js +62 -17
  12. package/dist/internal/index-impl/createNativeSourcePreservation.js +16 -1
  13. package/dist/internal/index-impl/createProjectImportAdmissionRecord.js +151 -1
  14. package/dist/internal/index-impl/createSemanticImportSidecar.js +5 -0
  15. package/dist/internal/index-impl/createSemanticImportSidecarAdmission.js +29 -11
  16. package/dist/internal/index-impl/nativeChangeProjectionEndpoint.js +56 -16
  17. package/dist/internal/index-impl/projectImportAdmissionMergeScore.js +26 -74
  18. package/dist/internal/index-impl/projectImportAdmissionProjectionCoverage.js +74 -0
  19. package/dist/internal/index-impl/projectSemanticEditScriptToSource.js +39 -13
  20. package/dist/internal/index-impl/replaySemanticEditProjection.js +65 -23
  21. package/dist/internal/index-impl/semanticEditInsertionAnchors.js +8 -5
  22. package/dist/internal/index-impl/semanticEditReplayDiagnostics.js +167 -0
  23. package/dist/internal/index-impl/semanticEditSourceRanges.js +94 -15
  24. package/dist/internal/index-impl/semanticHistoryLineageResolution.js +21 -2
  25. package/dist/internal/index-impl/semanticLineageHashEvidence.js +97 -0
  26. package/dist/internal/index-impl/semanticLineageInferenceMatching.js +8 -0
  27. package/dist/internal/index-impl/semanticLineageResolutionRecords.js +18 -1
  28. package/dist/internal/index-impl/semanticMergeCandidateRecords.js +22 -2
  29. package/dist/internal/index-impl/semanticMergeCandidateScoreFacets.js +221 -0
  30. package/dist/internal/index-impl/semanticPatchBundleOverlaps.js +23 -1
  31. package/dist/internal/index-impl/sourcePreservationFromProjectionContext.js +9 -2
  32. package/dist/native-import-language-profiles.js +10 -2
  33. package/dist/native-region-scanner-js-helpers.js +8 -2
  34. package/dist/native-region-scanner-js-imports.js +7 -0
  35. package/dist/native-region-scanner-js.js +4 -4
  36. package/dist/native-region-scanner.js +2 -1
  37. package/dist/semantic-import-regions.js +6 -4
  38. package/dist/semantic-import-sidecar-admission-types.d.ts +14 -0
  39. package/dist/semantic-import-sidecar-entry.js +151 -7
  40. package/dist/semantic-import-sidecar-types.d.ts +18 -13
  41. package/dist/semantic-import-source-preservation-utils.js +55 -0
  42. package/dist/semantic-import-source-preservation.js +98 -3
  43. package/package.json +1 -1
@@ -3,6 +3,7 @@ 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';
6
7
  import { afterLineOffset, bodyContentRange, spanOffsets } from './semanticEditSourceRanges.js';
7
8
 
8
9
  export function replaySemanticEditProjection(input = {}) {
@@ -17,11 +18,20 @@ export function replaySemanticEditProjection(input = {}) {
17
18
  const currentSymbols = currentSourceText && isJavaScriptLike(language)
18
19
  ? currentSymbolIndex({ currentSourceText, sourcePath, language, parser: input.parser })
19
20
  : [];
20
- const edits = projection.status === 'projected' && typeof currentSourceText === 'string'
21
+ const replayedEdits = projection.status === 'projected' && typeof currentSourceText === 'string'
21
22
  ? (projection.edits ?? []).map((edit, index) => replayProjectionEdit(projectionEditWithOrder(edit, index), { currentSourceText, currentSymbols }))
22
23
  : [];
24
+ const edits = replayEditsWithOverlapDiagnostics(replayedEdits);
23
25
  const status = replayStatus(reasonCodes, edits, projection);
24
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
+ });
25
35
  const core = {
26
36
  kind: 'frontier.lang.semanticEditReplay',
27
37
  version: 1,
@@ -38,6 +48,7 @@ export function replaySemanticEditProjection(input = {}) {
38
48
  edits,
39
49
  appliedOperations: edits.filter((edit) => edit.status === 'applied').map((edit) => edit.operationId).filter(Boolean),
40
50
  skippedOperations: edits.filter((edit) => edit.status !== 'applied').map((edit) => edit.operationId).filter(Boolean),
51
+ diagnostics,
41
52
  admission: replayAdmission(status, reasonCodes, edits),
42
53
  outputSourceText,
43
54
  summary: replaySummary(edits, reasonCodes),
@@ -52,8 +63,8 @@ export function replaySemanticEditProjection(input = {}) {
52
63
  }
53
64
 
54
65
  function replayProjectionEdit(edit, context) {
55
- if (edit.status === 'already-applied') return replayEditRecord(edit, 'already-applied', undefined, ['projection-edit-already-applied']);
56
- 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);
57
68
  if (edit.editKind === 'insert') return replayInsertionEdit(edit, context);
58
69
  const headRange = { start: edit.headStart, end: edit.headEnd };
59
70
  const offset = checkRange(edit, headRange, context.currentSourceText, 'head-offset');
@@ -61,34 +72,34 @@ function replayProjectionEdit(edit, context) {
61
72
  const spanRange = currentSymbolEditRange(edit, spanOffsets(context.currentSourceText, symbol?.sourceSpan), context.currentSourceText);
62
73
  if (symbol && spanRange && !sameRange(headRange, spanRange)) {
63
74
  const moved = checkRange(edit, spanRange, context.currentSourceText, currentSymbolRangeLabel(edit));
64
- if (moved) return replayEditRecord(edit, moved.status, moved.range, [moved.reason, 'offset-reanchored-by-symbol']);
75
+ if (moved) return replayEditRecord(edit, moved.status, moved.range, [moved.reason, 'offset-reanchored-by-symbol'], context.currentSourceText);
65
76
  if (edit.editKind === 'delete' && offset && rangesOverlap(headRange, spanRange)) {
66
- return replayEditRecord(edit, offset.status, offset.range, [offset.reason]);
77
+ return replayEditRecord(edit, offset.status, offset.range, [offset.reason], context.currentSourceText);
67
78
  }
68
- return replayEditRecord(edit, 'conflict', spanRange, [`${currentSymbolRangeLabel(edit)}-content-mismatch`]);
79
+ return replayEditRecord(edit, 'conflict', spanRange, [`${currentSymbolRangeLabel(edit)}-content-mismatch`], context.currentSourceText);
69
80
  }
70
- if (offset) return replayEditRecord(edit, offset.status, offset.range, [offset.reason]);
81
+ if (offset) return replayEditRecord(edit, offset.status, offset.range, [offset.reason], context.currentSourceText);
71
82
  const anchored = checkRange(edit, spanRange, context.currentSourceText, currentSymbolRangeLabel(edit));
72
- if (anchored) return replayEditRecord(edit, anchored.status, anchored.range, [anchored.reason, 'offset-reanchored-by-symbol']);
83
+ if (anchored) return replayEditRecord(edit, anchored.status, anchored.range, [anchored.reason, 'offset-reanchored-by-symbol'], context.currentSourceText);
73
84
  return replayEditRecord(edit, symbol ? 'conflict' : 'stale', spanRange, [
74
85
  symbol ? `${currentSymbolRangeLabel(edit)}-content-mismatch` : 'current-symbol-anchor-missing'
75
- ]);
86
+ ], context.currentSourceText);
76
87
  }
77
88
 
78
89
  function replayInsertionEdit(edit, context) {
79
90
  const inserted = findCurrentSymbol(edit, context.currentSymbols);
80
91
  const insertedRange = spanOffsets(context.currentSourceText, inserted?.sourceSpan);
81
92
  const already = checkRange(edit, insertedRange, context.currentSourceText, 'current-inserted-symbol');
82
- if (already?.status === 'already-applied') return replayEditRecord(edit, 'already-applied', already.range, [already.reason]);
93
+ if (already?.status === 'already-applied') return replayEditRecord(edit, 'already-applied', already.range, [already.reason], context.currentSourceText);
83
94
  if (inserted && insertedRange) {
84
- return replayEditRecord(edit, 'conflict', insertedRange, ['current-inserted-symbol-content-mismatch']);
95
+ return replayEditRecord(edit, 'conflict', insertedRange, ['current-inserted-symbol-content-mismatch'], context.currentSourceText);
85
96
  }
86
- const anchor = findInsertionAnchorSymbol(edit, context.currentSymbols);
87
- const range = insertionRange(edit, anchor, context.currentSourceText);
88
- if (range) return replayEditRecord(edit, 'applied', range, [anchor ? 'current-insertion-anchor' : `current-${edit.insertionMode}`]);
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);
89
100
  return replayEditRecord(edit, anchor ? 'conflict' : 'stale', undefined, [
90
101
  anchor ? 'current-insertion-anchor-unusable' : 'current-insertion-anchor-missing'
91
- ]);
102
+ ], context.currentSourceText);
92
103
  }
93
104
 
94
105
  function checkRange(edit, range, sourceText, label) {
@@ -102,7 +113,8 @@ function checkRange(edit, range, sourceText, label) {
102
113
  return undefined;
103
114
  }
104
115
 
105
- function replayEditRecord(edit, status, range, reasonCodes) {
116
+ function replayEditRecord(edit, status, range, reasonCodes, sourceText) {
117
+ const normalizedReasonCodes = reasonList(reasonCodes);
106
118
  return compactRecord({
107
119
  operationId: edit.operationId,
108
120
  semanticKey: edit.semanticKey,
@@ -120,7 +132,8 @@ function replayEditRecord(edit, status, range, reasonCodes) {
120
132
  end: range?.end,
121
133
  replacementBytes: edit.replacementBytes,
122
134
  replacementText: edit.replacementText,
123
- reasonCodes: reasonList(reasonCodes)
135
+ reasonCodes: normalizedReasonCodes,
136
+ diagnostics: replayEditDiagnostics(edit, status, range, normalizedReasonCodes, sourceText)
124
137
  });
125
138
  }
126
139
 
@@ -146,18 +159,47 @@ function findCurrentSymbol(edit, symbols) {
146
159
  return symbols.find((symbol) => symbol.name === name && (!kind || symbol.kind === kind));
147
160
  }
148
161
 
149
- function findInsertionAnchorSymbol(edit, symbols) {
150
- return symbols.find((symbol) => [symbol.ownershipKey, symbol.key, symbol.id].some((key) => key && key === edit.insertionAnchorKey))
151
- ?? 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;
168
+ }
169
+
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;
152
193
  }
153
194
 
154
- function insertionRange(edit, anchor, sourceText) {
195
+ function insertionRange(edit, candidate, anchor, sourceText) {
155
196
  if (edit.insertionMode === 'file-start') return { start: 0, end: 0 };
156
197
  if (edit.insertionMode === 'file-end') return { start: sourceText.length, end: sourceText.length };
198
+ const mode = candidate?.mode ?? edit.insertionMode;
157
199
  const anchorRange = spanOffsets(sourceText, anchor?.sourceSpan);
158
200
  if (!anchorRange) return undefined;
159
- if (edit.insertionMode === 'before') return { start: anchorRange.start, end: anchorRange.start };
160
- if (edit.insertionMode === 'after') {
201
+ if (mode === 'before') return { start: anchorRange.start, end: anchorRange.start };
202
+ if (mode === 'after') {
161
203
  return { start: afterLineOffset(sourceText, anchorRange.end), end: afterLineOffset(sourceText, anchorRange.end) };
162
204
  }
163
205
  return undefined;
@@ -7,13 +7,16 @@ export function semanticEditInsertionAnchor(region, context) {
7
7
  .filter((symbol) => hasSymbol(context.baseSymbols, symbol));
8
8
  const before = nearestBefore(workers, workerSymbol);
9
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');
10
+ const anchorCandidates = [
11
+ before ? insertionFromSymbol('after', before, context, 'nearest-previous-base-symbol') : undefined,
12
+ after ? insertionFromSymbol('before', after, context, 'nearest-next-base-symbol') : undefined
13
+ ].filter(Boolean);
14
+ const anchor = anchorCandidates.find((candidate) => candidate.headSpan)
15
+ ?? anchorCandidates[0]
16
+ ?? fallbackInsertion(region, context, 'no-neighbor-base-symbol');
15
17
  return compactRecord({
16
18
  ...anchor,
19
+ anchorCandidates,
17
20
  insertedSymbolId: workerSymbol.id,
18
21
  insertedSymbolName: workerSymbol.name,
19
22
  insertedSymbolKind: workerSymbol.kind,
@@ -0,0 +1,167 @@
1
+ import { hashSemanticValue } from '@shapeshift-labs/frontier-lang-kernel';
2
+ import { uniqueStrings } from '../../native-import-utils.js';
3
+
4
+ export function replayEditDiagnostics(edit, status, range, reasonCodes, sourceText) {
5
+ if (status === 'applied' || status === 'already-applied') return [];
6
+ return reasonCodes.map((code) => replayDiagnostic(code, {
7
+ scope: 'edit',
8
+ status,
9
+ operationId: edit.operationId,
10
+ sourcePath: edit.targetSourcePath ?? edit.sourcePath,
11
+ symbolName: edit.targetSymbolName ?? edit.symbolName,
12
+ symbolKind: edit.targetSymbolKind ?? edit.symbolKind,
13
+ editKind: edit.editKind,
14
+ start: range?.start,
15
+ end: range?.end,
16
+ expectedHash: replayDiagnosticExpectedHash(code, edit),
17
+ actualHash: replayDiagnosticActualHash(range, sourceText),
18
+ replacementHash: edit.replacementTextHash ?? edit.replacementSpanTextHash
19
+ }));
20
+ }
21
+
22
+ export function replayEditsWithOverlapDiagnostics(edits) {
23
+ const overlapDiagnostics = new Map();
24
+ const ordered = edits
25
+ .filter((edit) => edit.status === 'applied' && hasNumericReplayRange(edit))
26
+ .sort((left, right) => left.start - right.start || left.end - right.end || (left.editOrder ?? 0) - (right.editOrder ?? 0));
27
+ for (let leftIndex = 0; leftIndex < ordered.length; leftIndex += 1) {
28
+ for (let rightIndex = leftIndex + 1; rightIndex < ordered.length; rightIndex += 1) {
29
+ const left = ordered[leftIndex];
30
+ const right = ordered[rightIndex];
31
+ if (!rangesOverlap(left, right)) continue;
32
+ const operationIds = [left.operationId, right.operationId].filter(Boolean);
33
+ const fallbackId = `${left.editOrder ?? leftIndex}:${right.editOrder ?? rightIndex}`;
34
+ const code = `replay-edit-overlap:${operationIds.join(':') || fallbackId}`;
35
+ appendOverlapDiagnostic(overlapDiagnostics, left, code, operationIds);
36
+ appendOverlapDiagnostic(overlapDiagnostics, right, code, operationIds);
37
+ }
38
+ }
39
+ if (!overlapDiagnostics.size) return edits;
40
+ return edits.map((edit) => {
41
+ const diagnostics = overlapDiagnostics.get(edit);
42
+ if (!diagnostics) return edit;
43
+ return diagnostics.reduce((record, diagnostic) => appendReplayEditDiagnostic(record, diagnostic), edit);
44
+ });
45
+ }
46
+
47
+ export function replayDiagnostics(input) {
48
+ return uniqueDiagnostics([
49
+ ...reasonList(input.reasonCodes).map((code) => replayDiagnostic(code, {
50
+ scope: 'replay',
51
+ status: input.status,
52
+ sourcePath: input.sourcePath,
53
+ expectedHash: code === 'current-source-hash-mismatch' ? input.expectedCurrentHash : undefined,
54
+ actualHash: code === 'current-source-hash-mismatch' ? input.currentHash : undefined
55
+ })),
56
+ ...input.edits.flatMap((edit) => edit.diagnostics ?? [])
57
+ ]);
58
+ }
59
+
60
+ function appendOverlapDiagnostic(overlapDiagnostics, edit, code, operationIds) {
61
+ const diagnostics = overlapDiagnostics.get(edit) ?? [];
62
+ diagnostics.push(replayDiagnostic(code, {
63
+ scope: 'edit',
64
+ status: 'conflict',
65
+ operationId: edit.operationId,
66
+ sourcePath: edit.sourcePath,
67
+ symbolName: edit.symbolName,
68
+ symbolKind: edit.symbolKind,
69
+ editKind: edit.editKind,
70
+ start: edit.start,
71
+ end: edit.end,
72
+ overlapOperationIds: operationIds
73
+ }));
74
+ overlapDiagnostics.set(edit, diagnostics);
75
+ }
76
+
77
+ function appendReplayEditDiagnostic(edit, diagnostic) {
78
+ return compactRecord({
79
+ ...edit,
80
+ status: edit.status === 'applied' ? 'conflict' : edit.status,
81
+ reasonCodes: reasonList([...(edit.reasonCodes ?? []), diagnostic.code]),
82
+ diagnostics: uniqueDiagnostics([...(edit.diagnostics ?? []), diagnostic])
83
+ });
84
+ }
85
+
86
+ function replayDiagnostic(code, context) {
87
+ const category = replayDiagnosticCategory(code, context.status);
88
+ return compactRecord({
89
+ code,
90
+ category,
91
+ severity: replayDiagnosticSeverity(code, category, context.status),
92
+ scope: context.scope,
93
+ status: context.status,
94
+ operationId: context.operationId,
95
+ sourcePath: context.sourcePath,
96
+ symbolName: context.symbolName,
97
+ symbolKind: context.symbolKind,
98
+ editKind: context.editKind,
99
+ start: context.start,
100
+ end: context.end,
101
+ expectedHash: context.expectedHash,
102
+ actualHash: context.actualHash,
103
+ replacementHash: context.replacementHash,
104
+ overlapOperationIds: context.overlapOperationIds
105
+ });
106
+ }
107
+
108
+ function replayDiagnosticCategory(code, status) {
109
+ if (code.includes('overlap')) return 'overlap';
110
+ if (code.startsWith('missing-current-source') || code.startsWith('missing-head-source') || code.startsWith('missing-worker-source')) return 'missing-source';
111
+ if (code === 'current-symbol-anchor-missing' || code.includes('anchor-missing') || code.includes('anchor-unusable')) return 'stale-anchor';
112
+ if (code.includes('content-mismatch') || code.includes('hash-mismatch') || code.includes('span-not-resolvable') || code.startsWith('projection-not-') || code === 'missing-replacement-text') return 'projection-mismatch';
113
+ if (code.includes('reanchored')) return 'reanchored';
114
+ if (code.includes('matches-')) return 'matched-source';
115
+ if (status === 'stale') return 'stale-anchor';
116
+ if (status === 'conflict' || status === 'blocked') return 'projection-mismatch';
117
+ return 'replay';
118
+ }
119
+
120
+ function replayDiagnosticSeverity(code, category, status) {
121
+ if (code === 'current-source-hash-mismatch' && (status === 'accepted-clean' || status === 'already-applied')) return 'warning';
122
+ if (category === 'matched-source' || category === 'reanchored' || status === 'applied' || status === 'already-applied') return 'info';
123
+ if (category === 'overlap' || category === 'missing-source' || category === 'stale-anchor' || category === 'projection-mismatch') return 'error';
124
+ return status === 'accepted-clean' ? 'info' : 'warning';
125
+ }
126
+
127
+ function replayDiagnosticExpectedHash(code, edit) {
128
+ if (code.includes('matches-replacement')) return edit.replacementTextHash ?? edit.replacementSpanTextHash;
129
+ if (code.includes('content-mismatch') || code.includes('matches-deleted')) return edit.deletedTextHash ?? edit.anchorDeletedTextHash;
130
+ return undefined;
131
+ }
132
+
133
+ function replayDiagnosticActualHash(range, sourceText) {
134
+ if (!range || typeof sourceText !== 'string') return undefined;
135
+ return hashSemanticValue(sourceText.slice(range.start, range.end));
136
+ }
137
+
138
+ function uniqueDiagnostics(diagnostics) {
139
+ const seen = new Set();
140
+ const result = [];
141
+ for (const diagnostic of diagnostics.filter(Boolean)) {
142
+ const key = [
143
+ diagnostic.scope,
144
+ diagnostic.operationId,
145
+ diagnostic.status,
146
+ diagnostic.code,
147
+ diagnostic.start,
148
+ diagnostic.end,
149
+ (diagnostic.overlapOperationIds ?? []).join('|')
150
+ ].join(':');
151
+ if (seen.has(key)) continue;
152
+ seen.add(key);
153
+ result.push(diagnostic);
154
+ }
155
+ return result;
156
+ }
157
+
158
+ function rangesOverlap(left, right) {
159
+ return Boolean(left && right && left.start < right.end && right.start < left.end);
160
+ }
161
+
162
+ function hasNumericReplayRange(edit) {
163
+ return typeof edit.start === 'number' && typeof edit.end === 'number';
164
+ }
165
+
166
+ function reasonList(values) { return uniqueStrings((values ?? []).filter(Boolean)); }
167
+ function compactRecord(value) { return Object.fromEntries(Object.entries(value ?? {}).filter(([, entry]) => entry !== undefined && (!Array.isArray(entry) || entry.length > 0))); }
@@ -24,7 +24,7 @@ export function spanOffsets(sourceText, span) {
24
24
  const endLineStart = lineStarts[endLine - 1];
25
25
  if (start === undefined || endLineStart === undefined) return undefined;
26
26
  const startColumn = Math.max(1, span.startColumn ?? 1) - 1;
27
- const lineEnd = lineStarts[endLine] === undefined ? sourceText.length : lineStarts[endLine] - 1;
27
+ const lineEnd = lineContentEndOffset(sourceText, lineStarts[endLine]);
28
28
  const endColumn = span.endColumn === undefined ? lineEnd - endLineStart : Math.max(1, span.endColumn) - 1;
29
29
  return { start: start + startColumn, end: endLineStart + endColumn };
30
30
  }
@@ -49,36 +49,41 @@ export function bodyContentRange(sourceText, range) {
49
49
  return pair ? { start: pair.open + 1, end: pair.close } : undefined;
50
50
  }
51
51
 
52
- export function insertionOffset(sourceText, insertion) {
52
+ export function insertionOffset(sourceText, insertion, context = {}) {
53
53
  if (typeof sourceText !== 'string') return { ok: false, reasonCodes: ['missing-head-source-text'] };
54
54
  const mode = insertion?.mode;
55
55
  if (mode === 'file-start') return { ok: true, offset: 0 };
56
56
  if (mode === 'file-end') return { ok: true, offset: sourceText.length };
57
- const range = spanOffsets(sourceText, insertion?.headSpan);
58
- if (!range) return { ok: false, reasonCodes: ['insertion-anchor-not-resolvable'] };
59
- if (mode === 'before') return { ok: true, offset: range.start };
60
- if (mode === 'after') return { ok: true, offset: afterLineOffset(sourceText, range.end) };
57
+ const resolved = insertionAnchorResolution(sourceText, insertion, context);
58
+ if (!resolved?.range) return { ok: false, reasonCodes: ['insertion-anchor-not-resolvable'] };
59
+ if (resolved.mode === 'before') return { ok: true, offset: resolved.range.start };
60
+ if (resolved.mode === 'after') return { ok: true, offset: afterLineOffset(sourceText, resolved.range.end) };
61
61
  return { ok: false, reasonCodes: ['insertion-mode-unsupported'] };
62
62
  }
63
63
 
64
64
  export function removalRange(sourceText, span) {
65
65
  const range = { ...span };
66
- if (range.end < sourceText.length && sourceText[range.end] === '\n') range.end += 1;
67
- else if (range.start > 0 && sourceText[range.start - 1] === '\n') range.start -= 1;
66
+ const next = lineBreakEndOffset(sourceText, range.end);
67
+ if (next !== range.end) range.end = next;
68
+ else {
69
+ const previous = previousLineBreakStartOffset(sourceText, range.start);
70
+ if (previous !== range.start) range.start = previous;
71
+ }
68
72
  return range;
69
73
  }
70
74
 
71
75
  export function insertionReplacement(text, sourceText, offset) {
72
76
  let replacement = String(text ?? '');
73
- if (offset > 0 && sourceText[offset - 1] !== '\n') replacement = `\n${replacement}`;
74
- if (offset < sourceText.length && !replacement.endsWith('\n')) replacement += '\n';
75
- if (offset === sourceText.length && sourceText && !sourceText.endsWith('\n')) replacement = `\n${replacement}`;
76
- if (offset === sourceText.length && !replacement.endsWith('\n')) replacement += '\n';
77
+ const newline = sourceLineEnding(sourceText);
78
+ if (offset > 0 && !isLineBreak(sourceText[offset - 1])) replacement = `${newline}${replacement}`;
79
+ if (offset < sourceText.length && !endsWithLineBreak(replacement)) replacement += newline;
80
+ if (offset === sourceText.length && sourceText && !endsWithLineBreak(sourceText)) replacement = `${newline}${replacement}`;
81
+ if (offset === sourceText.length && !endsWithLineBreak(replacement)) replacement += newline;
77
82
  return replacement;
78
83
  }
79
84
 
80
85
  export function afterLineOffset(sourceText, offset) {
81
- return sourceText[offset] === '\n' ? offset + 1 : offset;
86
+ return lineBreakEndOffset(sourceText, offset);
82
87
  }
83
88
 
84
89
  function isProjectionCoverableContainer(operation) {
@@ -112,15 +117,89 @@ function containedRange(inner, outer) {
112
117
 
113
118
  function insertionRemovalRange(sourceText, span, container) {
114
119
  const range = { ...span };
115
- if (range.end < container.end && sourceText[range.end] === '\n') range.end += 1;
116
- else if (range.start > container.start && sourceText[range.start - 1] === '\n') range.start -= 1;
120
+ const next = lineBreakEndOffset(sourceText, range.end);
121
+ if (next !== range.end && next <= container.end) range.end = next;
122
+ else {
123
+ const previous = previousLineBreakStartOffset(sourceText, range.start);
124
+ if (previous !== range.start && previous >= container.start) range.start = previous;
125
+ }
117
126
  return range;
118
127
  }
119
128
 
129
+ function lineContentEndOffset(sourceText, nextLineStart) {
130
+ if (nextLineStart === undefined) return sourceText.length;
131
+ const lineBreakStart = sourceText[nextLineStart - 2] === '\r' ? nextLineStart - 2 : nextLineStart - 1;
132
+ return Math.max(0, lineBreakStart);
133
+ }
134
+
135
+ function lineBreakEndOffset(sourceText, offset) {
136
+ if (sourceText[offset] === '\r' && sourceText[offset + 1] === '\n') return offset + 2;
137
+ if (isLineBreak(sourceText[offset])) return offset + 1;
138
+ return offset;
139
+ }
140
+
141
+ function previousLineBreakStartOffset(sourceText, offset) {
142
+ if (sourceText[offset - 1] === '\n') return sourceText[offset - 2] === '\r' ? offset - 2 : offset - 1;
143
+ if (sourceText[offset - 1] === '\r') return offset - 1;
144
+ return offset;
145
+ }
146
+
147
+ function sourceLineEnding(sourceText) {
148
+ if (sourceText.includes('\r\n')) return '\r\n';
149
+ return sourceText.includes('\r') ? '\r' : '\n';
150
+ }
151
+
152
+ function endsWithLineBreak(value) {
153
+ return isLineBreak(value[value.length - 1]);
154
+ }
155
+
156
+ function isLineBreak(char) {
157
+ return char === '\n' || char === '\r';
158
+ }
159
+
120
160
  function isBodyReplacement(operation) {
121
161
  return operation.changeKind === 'modified' && (operation.kind === 'replaceBody' || operation.anchor?.regionKind === 'body');
122
162
  }
123
163
 
164
+ function insertionAnchorResolution(sourceText, insertion, context) {
165
+ const candidates = insertionAnchorCandidates(insertion);
166
+ for (const candidate of candidates) {
167
+ const symbol = insertionAnchorSymbol(candidate, context.symbols);
168
+ const range = spanOffsets(sourceText, symbol?.sourceSpan);
169
+ if (range) return { mode: candidate.mode, range };
170
+ }
171
+ for (const candidate of candidates) {
172
+ const range = spanOffsets(sourceText, candidate.headSpan);
173
+ if (range) return { mode: candidate.mode, range };
174
+ }
175
+ return undefined;
176
+ }
177
+
178
+ function insertionAnchorCandidates(insertion) {
179
+ const seen = new Set();
180
+ const result = [];
181
+ for (const candidate of [insertion, ...(Array.isArray(insertion?.anchorCandidates) ? insertion.anchorCandidates : [])]) {
182
+ if (!candidate || (candidate.mode !== 'before' && candidate.mode !== 'after')) continue;
183
+ const key = [candidate.mode, candidate.anchorKey, candidate.anchorSymbolId, candidate.anchorSymbolName, candidate.anchorSymbolKind].join('\0');
184
+ if (seen.has(key)) continue;
185
+ seen.add(key);
186
+ result.push(candidate);
187
+ }
188
+ return result;
189
+ }
190
+
191
+ function insertionAnchorSymbol(candidate, symbols) {
192
+ const symbolList = Array.isArray(symbols)
193
+ ? symbols
194
+ : symbols?.values
195
+ ? [...symbols.values()]
196
+ : [];
197
+ const keys = [candidate.anchorKey, candidate.anchorSymbolId].filter(Boolean);
198
+ const exact = symbolList.find((symbol) => [symbol.ownershipKey, symbol.key, symbol.id].some((key) => key && keys.includes(key)));
199
+ if (exact) return exact;
200
+ return symbolList.find((symbol) => symbol.name === candidate.anchorSymbolName && (!candidate.anchorSymbolKind || symbol.kind === candidate.anchorSymbolKind));
201
+ }
202
+
124
203
  function trailingBodyCloseOffset(sourceText, range) {
125
204
  if (typeof sourceText !== 'string' || !range) return undefined;
126
205
  let index = range.end - 1;
@@ -137,12 +137,17 @@ function resolveIndexKeys(values, resolutions, options) {
137
137
  const key = String(value);
138
138
  const resolution = resolutions.get(key);
139
139
  if (!resolution) return [key];
140
+ const current = resolution.currentAnchors.map((anchor) => anchor.key).filter(Boolean);
140
141
  if (resolution.status === 'deleted' && options.keepDeletedAnchors !== true) return [];
141
142
  if (resolution.status === 'cycle' && options.keepBlockedAnchors !== true) return [];
142
143
  if (resolution.status === 'max-depth' && options.keepBlockedAnchors !== true) return [];
143
144
  if (resolution.status === 'not-found' && options.keepUnresolvedAnchors !== true) return [];
145
+ if (resolution.status === 'ambiguous' && resolutionHasInactiveTerminal(resolution)) {
146
+ if (options.keepCandidateAnchors === true) return current.length ? current : [key];
147
+ if (options.keepDeletedAnchors === true || options.keepInactiveAnchors === true) return [key];
148
+ return [];
149
+ }
144
150
  if (resolution.status === 'ambiguous' && options.keepCandidateAnchors === false) return [];
145
- const current = resolution.currentAnchors.map((anchor) => anchor.key).filter(Boolean);
146
151
  return current.length ? current : [key];
147
152
  }));
148
153
  }
@@ -193,7 +198,10 @@ function createAnchorInventory(resolutions) {
193
198
  const current = resolution.currentAnchors.map((anchor) => anchorEntry(anchor, resolution));
194
199
  if (resolution.status === 'ambiguous') {
195
200
  inventory.candidate.push(...current);
196
- if (start) inventory.inactive.push(start);
201
+ if (start) {
202
+ inventory.inactive.push(start);
203
+ if (resolutionHasDeletedTerminal(resolution)) inventory.deleted.push(start);
204
+ }
197
205
  continue;
198
206
  }
199
207
  if (resolution.status === 'deleted') {
@@ -254,6 +262,17 @@ function anchorEntry(anchor, resolution) {
254
262
  });
255
263
  }
256
264
 
265
+ function resolutionHasInactiveTerminal(resolution) {
266
+ return array(resolution.terminalEventIds).length > 0
267
+ || resolutionHasDeletedTerminal(resolution)
268
+ || array(resolution.reasonCodes).some((code) => code === 'lineage-event-without-target-anchor' || code === 'inactive-anchor-has-active-candidates');
269
+ }
270
+
271
+ function resolutionHasDeletedTerminal(resolution) {
272
+ return array(resolution.lineageEventKinds).includes('deleted')
273
+ || array(resolution.reasonCodes).includes('anchor-deleted');
274
+ }
275
+
257
276
  function queryAnchor(resolution) {
258
277
  return compactRecord({
259
278
  key: resolution.query?.anchorKey,
@@ -0,0 +1,97 @@
1
+ import { uniqueStrings } from '../../native-import-utils.js';
2
+
3
+ export function addIdentityHashEvidence(before, after, add, note) {
4
+ const matches = matchingIdentityHashReasons(before, after);
5
+ if (matches.length === 0) return;
6
+ if (!compatibleLineageSurface(before, after)) {
7
+ note('identity-hash-match-surface-mismatch');
8
+ return;
9
+ }
10
+ const primary = matches.includes('semantic-identity-hash-match')
11
+ ? 'semantic-identity-hash-match'
12
+ : matches.includes('source-identity-hash-match')
13
+ ? 'source-identity-hash-match'
14
+ : 'identity-hash-match';
15
+ add(0.62, primary);
16
+ for (const reason of matches) {
17
+ if (reason !== primary) note(reason);
18
+ }
19
+ }
20
+
21
+ export function addSourceHashEvidence(before, after, add, note, reasons, sameSymbolSurface) {
22
+ const beforeHash = firstString(before.anchor.sourceHash, before.sourceHash);
23
+ const afterHash = firstString(after.anchor.sourceHash, after.sourceHash);
24
+ if (!beforeHash || !afterHash) return;
25
+ if (beforeHash !== afterHash) {
26
+ note('source-hash-changed');
27
+ return;
28
+ }
29
+ if (!hasSourceHashSupport(before, after, reasons, sameSymbolSurface)) {
30
+ note('source-hash-match-without-lineage-support');
31
+ return;
32
+ }
33
+ add(0.04, 'source-hash-match');
34
+ if (before.anchor.sourcePath && after.anchor.sourcePath && before.anchor.sourcePath !== after.anchor.sourcePath) {
35
+ note('source-hash-preserved-across-path');
36
+ }
37
+ }
38
+
39
+ export function hashEvidenceSummary(reasons) {
40
+ return {
41
+ semanticIdentityHashMatch: reasons.includes('semantic-identity-hash-match'),
42
+ sourceIdentityHashMatch: reasons.includes('source-identity-hash-match'),
43
+ identityHashMatch: reasons.includes('identity-hash-match'),
44
+ sourceHashMatch: reasons.includes('source-hash-match'),
45
+ signatureHashMatch: reasons.includes('signature-hash-match'),
46
+ bodyHashMatch: reasons.includes('body-hash-match')
47
+ };
48
+ }
49
+
50
+ function matchingIdentityHashReasons(before, after) {
51
+ const beforeHashes = identityHashEntries(before);
52
+ const afterHashes = new Map(identityHashEntries(after).map((entry) => [entry.value, entry.reason]));
53
+ const reasons = [];
54
+ for (const entry of beforeHashes) {
55
+ const afterReason = afterHashes.get(entry.value);
56
+ if (!afterReason) continue;
57
+ reasons.push(identityHashMatchReason(entry.reason, afterReason));
58
+ }
59
+ return uniqueStrings(reasons);
60
+ }
61
+
62
+ function identityHashEntries(symbol) {
63
+ return [
64
+ { reason: 'semantic-identity-hash-match', value: firstString(symbol.semanticIdentityHash, symbol.anchor.metadata?.semanticIdentityHash) },
65
+ { reason: 'source-identity-hash-match', value: firstString(symbol.sourceIdentityHash, symbol.anchor.metadata?.sourceIdentityHash) },
66
+ { reason: 'identity-hash-match', value: firstString(symbol.identityHash, symbol.anchor.metadata?.identityHash) }
67
+ ].filter((entry) => entry.value);
68
+ }
69
+
70
+ function identityHashMatchReason(beforeReason, afterReason) {
71
+ if (beforeReason === afterReason) return beforeReason;
72
+ if (beforeReason === 'semantic-identity-hash-match' || afterReason === 'semantic-identity-hash-match') return 'semantic-identity-hash-match';
73
+ if (beforeReason === 'source-identity-hash-match' || afterReason === 'source-identity-hash-match') return 'source-identity-hash-match';
74
+ return 'identity-hash-match';
75
+ }
76
+
77
+ function compatibleLineageSurface(before, after) {
78
+ return (!before.language || !after.language || before.language === after.language)
79
+ && (!before.kind || !after.kind || before.kind === after.kind)
80
+ && (!before.anchor.kind || !after.anchor.kind || before.anchor.kind === after.anchor.kind);
81
+ }
82
+
83
+ function hasSourceHashSupport(before, after, reasons, sameSymbolSurface) {
84
+ return reasons.some((reason) => [
85
+ 'semantic-identity-hash-match',
86
+ 'source-identity-hash-match',
87
+ 'identity-hash-match',
88
+ 'signature-hash-match',
89
+ 'body-hash-match',
90
+ 'symbol-name-match'
91
+ ].includes(reason))
92
+ || sameSymbolSurface(before, after);
93
+ }
94
+
95
+ function firstString(...values) {
96
+ return values.map((value) => value === undefined || value === null ? '' : String(value)).find(Boolean);
97
+ }