@shapeshift-labs/frontier-lang-compiler 0.2.102 → 0.2.103

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.
@@ -0,0 +1,34 @@
1
+ export type NativeProjectAdmissionSemanticEvidenceWarningCode =
2
+ | 'missing-ownership-regions'
3
+ | 'missing-patch-hints'
4
+ | (string & {});
5
+
6
+ export interface NativeProjectAdmissionSemanticEvidenceWarning {
7
+ readonly code: NativeProjectAdmissionSemanticEvidenceWarningCode;
8
+ readonly reasonCode: NativeProjectAdmissionSemanticEvidenceWarningCode;
9
+ readonly severity: 'warning' | string;
10
+ readonly message: string;
11
+ readonly action: string;
12
+ readonly sourcePath?: string;
13
+ readonly sourcePaths: readonly string[];
14
+ readonly semanticSymbols: number;
15
+ readonly ownershipRegions: number;
16
+ readonly patchHints: number;
17
+ readonly semanticImportExpected: boolean;
18
+ readonly changedSource: boolean;
19
+ }
20
+
21
+ export interface NativeProjectAdmissionSemanticEvidence {
22
+ readonly empty: boolean;
23
+ readonly emptySourceCount: number;
24
+ readonly emptySourcePaths: readonly string[];
25
+ readonly symbols: number;
26
+ readonly occurrences: number;
27
+ readonly relations: number;
28
+ readonly facts: number;
29
+ readonly evidenceRecords: number;
30
+ readonly warningCount: number;
31
+ readonly warningReasonCodes: readonly NativeProjectAdmissionSemanticEvidenceWarningCode[];
32
+ readonly warningSourcePaths: readonly string[];
33
+ readonly warnings: readonly NativeProjectAdmissionSemanticEvidenceWarning[];
34
+ }
@@ -2,7 +2,13 @@ import type { FrontierSourceLanguage, SemanticMergeReadiness } from '@shapeshift
2
2
  import type { NativeImportLossSummary } from './native-import-losses.js';
3
3
  import type { NativeImportResultContract } from './native-import-contracts.js';
4
4
  import type { NativeProjectImportResult } from './native-project.js';
5
+ import type { NativeProjectAdmissionSemanticEvidence } from './native-project-admission-semantic-evidence.js';
5
6
  import type { SemanticMergeCandidateAdmissionRecord, SemanticMergeCandidateOverlapRecord, SemanticMergeCandidateProjectionRisk } from './semantic-merge-candidates.js';
7
+ export type {
8
+ NativeProjectAdmissionSemanticEvidence,
9
+ NativeProjectAdmissionSemanticEvidenceWarning,
10
+ NativeProjectAdmissionSemanticEvidenceWarningCode
11
+ } from './native-project-admission-semantic-evidence.js';
6
12
  export type NativeProjectImportAdmissionAction = 'admit' | 'prioritize' | 'reject';
7
13
  export type NativeProjectImportAdmissionPriority = 'low' | 'normal' | 'high' | 'critical' | 'blocker';
8
14
  export type NativeProjectImportAdmissionRisk = 'low' | 'medium' | 'high' | 'unknown';
@@ -133,16 +139,6 @@ export interface NativeProjectAdmissionLanguages {
133
139
  readonly readinessRows: readonly NativeProjectAdmissionLanguageReadinessSummary[];
134
140
  readonly rows: readonly NativeProjectAdmissionLanguageSummary[];
135
141
  }
136
- export interface NativeProjectAdmissionSemanticEvidence {
137
- readonly empty: boolean;
138
- readonly emptySourceCount: number;
139
- readonly emptySourcePaths: readonly string[];
140
- readonly symbols: number;
141
- readonly occurrences: number;
142
- readonly relations: number;
143
- readonly facts: number;
144
- readonly evidenceRecords: number;
145
- }
146
142
  export type NativeProjectAdmissionSourceStalenessReason =
147
143
  | 'source-hash-mismatch'
148
144
  | 'content-hash-mismatch'
@@ -147,7 +147,10 @@ export interface SemanticEditProjectionEdit {
147
147
  readonly replacementBytes: number;
148
148
  readonly deletedTextHash?: string;
149
149
  readonly replacementTextHash?: string;
150
+ readonly deletedTextLineEndingStableHash?: string;
151
+ readonly replacementTextLineEndingStableHash?: string;
150
152
  readonly replacementSpanTextHash?: string;
153
+ readonly replacementSpanTextLineEndingStableHash?: string;
151
154
  readonly insertionMode?: string;
152
155
  readonly insertionAnchorKey?: string;
153
156
  readonly insertionAnchorSymbolName?: string;
@@ -71,6 +71,7 @@ export interface SemanticPatchBundleOverlapRecord {
71
71
  readonly sourceSignals: number;
72
72
  readonly baseHashMismatch: boolean;
73
73
  readonly targetHashMismatch: boolean;
74
+ readonly replayOutputHashMismatch: boolean;
74
75
  };
75
76
  readonly metadata?: Record<string, unknown>;
76
77
  }
@@ -19,7 +19,7 @@ export function createProjectImportAdmissionRecord(projectResult,options={}){
19
19
  ...(projectResult?.mergeCandidates??[]),
20
20
  ...imports.flatMap((imported)=>imported?.mergeCandidates??[])
21
21
  ]);
22
- const importSummaries=projectAdmissionImports(imports,contract?.sources??[],mergeCandidates);
22
+ const importSummaries=projectAdmissionImports(imports,contract?.sources??[],mergeCandidates,projectResult);
23
23
  const languages=admissionLanguages(importSummaries);
24
24
  const semanticEvidence=admissionSemanticEvidence(projectResult,imports,importSummaries);
25
25
  const sourceStaleness=admissionSourceStaleness(imports,importSummaries,contract);
@@ -42,7 +42,10 @@ export function createProjectImportAdmissionRecord(projectResult,options={}){
42
42
  failedEvidenceIds,
43
43
  blockingLossIds:contract?.readiness?.blockingLossIds??lossSummary?.blockingLossIds??[]
44
44
  });
45
- const priorityReasons=admissionPriorityReasons({readiness,semanticEvidence,sourcePreservation,ownership,mergeCandidateRisk});
45
+ const priorityReasons=uniqueStrings([
46
+ ...admissionPriorityReasons({readiness,semanticEvidence,sourcePreservation,ownership,mergeCandidateRisk}),
47
+ ...semanticAdmissionWarningReasons(semanticEvidence)
48
+ ]);
46
49
  const action=rejectionReasons.length?'reject':priorityReasons.length?'prioritize':'admit';
47
50
  const priority=admissionPriority(action,readiness,sourcePreservation,mergeCandidateRisk);
48
51
  const mergeScore=admissionMergeScore({
@@ -221,6 +224,15 @@ function admissionSourcePreservationWithStaleness(sourcePreservation,sourceStale
221
224
  };
222
225
  }
223
226
 
227
+ function semanticAdmissionWarningReasons(semanticEvidence){
228
+ return (semanticEvidence?.warnings??[]).map((warning)=>{
229
+ const reasonCode=warning.reasonCode??warning.code;
230
+ const sourcePaths=warning.sourcePaths?.length?warning.sourcePaths:warning.sourcePath?[warning.sourcePath]:[];
231
+ const sourceText=sourcePaths.length?` for ${sourcePaths.join(', ')}`:'';
232
+ return `Project import semantic admission warning ${reasonCode}${sourceText}.`;
233
+ });
234
+ }
235
+
224
236
  function sourcePaths(records){
225
237
  return uniqueStrings(records.map((record)=>record.sourcePath).filter(Boolean));
226
238
  }
@@ -37,5 +37,86 @@ export function diffNativeSymbols(beforeSymbols, afterSymbols) {
37
37
  readiness: maxSemanticMergeReadiness(before?.readiness ?? 'ready', after?.readiness ?? 'ready')
38
38
  });
39
39
  }
40
- return changed;
40
+ return downgradeCoveredContainerSymbols(changed);
41
41
  }
42
+
43
+ function downgradeCoveredContainerSymbols(symbols) {
44
+ return symbols.filter((symbol) => !nativeDiffContainerCoveredByMorePreciseChange(symbol, symbols));
45
+ }
46
+
47
+ function nativeDiffContainerCoveredByMorePreciseChange(symbol, symbols) {
48
+ if (symbol.changeKind !== 'modified') return false;
49
+ if (!nativeDiffSymbolIsContainer(symbol)) return false;
50
+ if (nativeDiffSymbolHasOwnChange(symbol)) return false;
51
+ return symbols.some((candidate) => candidate !== symbol && nativeDiffSymbolIsMorePreciseNestedChange(candidate, symbol));
52
+ }
53
+
54
+ function nativeDiffSymbolHasOwnChange(symbol) {
55
+ return ((symbol.beforeSignatureHash ?? '') !== (symbol.afterSignatureHash ?? ''))
56
+ || ((symbol.beforeOwnershipKey ?? '') !== (symbol.afterOwnershipKey ?? ''))
57
+ || ((symbol.beforeNativeAstNodeId ?? '') !== (symbol.afterNativeAstNodeId ?? ''));
58
+ }
59
+
60
+ function nativeDiffSymbolIsMorePreciseNestedChange(candidate, container) {
61
+ if (candidate.changeKind === 'unchanged') return false;
62
+ if (nativeDiffSymbolIsContainer(candidate)) return false;
63
+ if ((candidate.ownershipKey ?? '') === (container.ownershipKey ?? '')) return false;
64
+ if (!nativeDiffAnySpanContains(container, candidate)) return false;
65
+ return nativeDiffNestedSymbolName(candidate, container) || nativeDiffSymbolIsMember(candidate);
66
+ }
67
+
68
+ function nativeDiffSymbolIsContainer(symbol) {
69
+ return nativeDiffContainerKinds.has(nativeDiffKind(symbol.ownershipRegionKind ?? symbol.kind));
70
+ }
71
+
72
+ function nativeDiffSymbolIsMember(symbol) {
73
+ return nativeDiffMemberKinds.has(nativeDiffKind(symbol.ownershipRegionKind ?? symbol.kind));
74
+ }
75
+
76
+ function nativeDiffNestedSymbolName(candidate, container) {
77
+ const candidateName = String(candidate.name ?? '');
78
+ const containerName = String(container.name ?? '');
79
+ return Boolean(containerName && candidateName && candidateName !== containerName && candidateName.startsWith(`${containerName}.`));
80
+ }
81
+
82
+ function nativeDiffAnySpanContains(container, candidate) {
83
+ return nativeDiffSymbolSpans(container).some((containerSpan) => nativeDiffSymbolSpans(candidate).some((candidateSpan) => (
84
+ nativeDiffSameSourcePath(candidateSpan, containerSpan) && nativeDiffSpanContains(containerSpan, candidateSpan)
85
+ )));
86
+ }
87
+
88
+ function nativeDiffSymbolSpans(symbol) {
89
+ return [symbol.sourceSpan, symbol.beforeSourceSpan, symbol.afterSourceSpan].filter(Boolean);
90
+ }
91
+
92
+ function nativeDiffSameSourcePath(left, right) {
93
+ const leftPath = left?.path;
94
+ const rightPath = right?.path;
95
+ return !leftPath || !rightPath || leftPath === rightPath;
96
+ }
97
+
98
+ function nativeDiffSpanContains(containerSpan, candidateSpan) {
99
+ const container = nativeDiffSpanRange(containerSpan);
100
+ const candidate = nativeDiffSpanRange(candidateSpan);
101
+ if (!container || !candidate) return false;
102
+ return container.start <= candidate.start && candidate.end <= container.end && (container.start !== candidate.start || container.end !== candidate.end);
103
+ }
104
+
105
+ function nativeDiffSpanRange(span) {
106
+ const startLine = Number(span?.startLine ?? span?.line ?? span?.start?.line);
107
+ const endLine = Number(span?.endLine ?? span?.end?.line ?? startLine);
108
+ if (!Number.isFinite(startLine) || !Number.isFinite(endLine)) return undefined;
109
+ const startColumn = Number(span?.startColumn ?? span?.column ?? span?.start?.column ?? 0);
110
+ const endColumn = Number(span?.endColumn ?? span?.end?.column ?? startColumn);
111
+ return {
112
+ start: startLine * 100000 + (Number.isFinite(startColumn) ? startColumn : 0),
113
+ end: endLine * 100000 + Math.max(Number.isFinite(startColumn) ? startColumn : 0, Number.isFinite(endColumn) ? endColumn : 0)
114
+ };
115
+ }
116
+
117
+ function nativeDiffKind(value) {
118
+ return String(value ?? '').toLowerCase();
119
+ }
120
+
121
+ const nativeDiffContainerKinds = new Set(['type', 'class', 'interface', 'trait', 'protocol', 'struct', 'enum', 'record']);
122
+ const nativeDiffMemberKinds = new Set(['body', 'method', 'function', 'property', 'declaration']);
@@ -1,5 +1,6 @@
1
1
  import{uniqueStrings}from'../../native-import-utils.js';
2
2
  import{nativeImportCategoryForLossKind}from'./nativeImportCategoryForLossKind.js';
3
+ export{summarizeSemanticAdmissionWarnings}from'./projectImportAdmissionSemanticWarnings.js';
3
4
 
4
5
  export function importLosses(imported){
5
6
  const nativeAst=imported?.nativeAst??imported?.nativeSource?.ast;
@@ -157,4 +158,3 @@ export function summarizeImportPreservation(imported,source){
157
158
  const quality=stale?'stale':missing?'missing':truncated||!exactSourceAvailable||sourcePreservationLosses.length?'lossy':'exact';
158
159
  return {quality,missing,stale,truncated,exactSourceAvailable,lossCount:sourcePreservationLosses.length,id:record?.id};
159
160
  }
160
-
@@ -0,0 +1,178 @@
1
+ import { uniqueRecordsById, uniqueStrings } from '../../native-import-utils.js';
2
+ import { semanticOwnershipRegionsFromSemanticIndex } from '../../semantic-import-regions.js';
3
+
4
+ export function summarizeSemanticAdmissionWarnings(imported, context = {}) {
5
+ const semanticIndex = imported?.semanticIndex ?? imported?.universalAst?.semanticIndex;
6
+ const symbols = semanticSymbolsForImport(imported, semanticIndex);
7
+ const ownershipRegions = semanticOwnershipRegionsForImport(imported, semanticIndex);
8
+ const patchHints = semanticPatchHintsForImport(imported, semanticIndex);
9
+ const sourcePath = firstString(context.sourcePath, context.source?.sourcePath, imported?.sourcePath, imported?.nativeSource?.sourcePath, imported?.nativeAst?.sourcePath, semanticIndex?.documents?.[0]?.path);
10
+ const semanticImportExpected = isSemanticImportExpected(imported, context);
11
+ const semanticImportExpectedEmpty = isSemanticImportExpectedEmpty(imported, context);
12
+ const changedSource = isChangedSemanticAdmissionSource(imported, { ...context, sourcePath });
13
+ const warnings = [];
14
+ if (semanticImportExpected && !semanticImportExpectedEmpty && changedSource && symbols.length > 0 && ownershipRegions.length === 0) {
15
+ warnings.push(semanticAdmissionWarning({
16
+ code: 'missing-ownership-regions',
17
+ message: 'Semantic import was expected for a changed source and produced symbols, but no ownership regions were available.',
18
+ action: 'rerun-sidecar-generation-with-ownership-regions',
19
+ sourcePath,
20
+ symbols,
21
+ ownershipRegions,
22
+ patchHints,
23
+ semanticImportExpected,
24
+ changedSource
25
+ }));
26
+ }
27
+ if (semanticImportExpected && !semanticImportExpectedEmpty && changedSource && symbols.length > 0 && patchHints.length === 0) {
28
+ warnings.push(semanticAdmissionWarning({
29
+ code: 'missing-patch-hints',
30
+ message: 'Semantic import was expected for a changed source and produced symbols, but no patch hints were available.',
31
+ action: 'generate-semantic-patch-hints',
32
+ sourcePath,
33
+ symbols,
34
+ ownershipRegions,
35
+ patchHints,
36
+ semanticImportExpected,
37
+ changedSource
38
+ }));
39
+ }
40
+ return {
41
+ semanticImportExpected,
42
+ semanticImportExpectedEmpty,
43
+ changedSource,
44
+ symbolCount: symbols.length,
45
+ ownershipRegionCount: ownershipRegions.length,
46
+ patchHintCount: patchHints.length,
47
+ warningCount: warnings.length,
48
+ reasonCodes: uniqueStrings(warnings.map((warning) => warning.reasonCode)),
49
+ warnings
50
+ };
51
+ }
52
+
53
+ function semanticAdmissionWarning(input) {
54
+ return {
55
+ code: input.code,
56
+ reasonCode: input.code,
57
+ severity: 'warning',
58
+ message: input.message,
59
+ action: input.action,
60
+ ...(input.sourcePath ? { sourcePath: input.sourcePath } : {}),
61
+ sourcePaths: uniqueStrings([input.sourcePath].filter(Boolean)),
62
+ semanticSymbols: input.symbols.length,
63
+ ownershipRegions: input.ownershipRegions.length,
64
+ patchHints: input.patchHints.length,
65
+ semanticImportExpected: input.semanticImportExpected,
66
+ changedSource: input.changedSource
67
+ };
68
+ }
69
+
70
+ function semanticSymbolsForImport(imported, semanticIndex) {
71
+ if (Array.isArray(semanticIndex?.symbols)) return semanticIndex.symbols;
72
+ for (const symbols of [
73
+ imported?.semanticSymbols,
74
+ imported?.symbols,
75
+ imported?.metadata?.semanticSymbols,
76
+ imported?.metadata?.symbols
77
+ ]) {
78
+ if (Array.isArray(symbols)) return symbols;
79
+ }
80
+ return [];
81
+ }
82
+
83
+ function semanticOwnershipRegionsForImport(imported, semanticIndex) {
84
+ return uniqueRecordsById([
85
+ ...(Array.isArray(imported?.ownershipRegions) ? imported.ownershipRegions : []),
86
+ ...(Array.isArray(imported?.semanticOwnershipRegions) ? imported.semanticOwnershipRegions : []),
87
+ ...semanticOwnershipRegionsFromSemanticIndex(semanticIndex),
88
+ ...(Array.isArray(imported?.universalAst?.ownershipRegions) ? imported.universalAst.ownershipRegions : []),
89
+ ...(Array.isArray(imported?.metadata?.ownershipRegions) ? imported.metadata.ownershipRegions : [])
90
+ ]);
91
+ }
92
+
93
+ function semanticPatchHintsForImport(imported, semanticIndex) {
94
+ return uniqueRecordsById([
95
+ ...(Array.isArray(imported?.patchHints) ? imported.patchHints : []),
96
+ ...(Array.isArray(imported?.semanticPatchHints) ? imported.semanticPatchHints : []),
97
+ ...(Array.isArray(semanticIndex?.patchHints) ? semanticIndex.patchHints : []),
98
+ ...(Array.isArray(imported?.universalAst?.patchHints) ? imported.universalAst.patchHints : []),
99
+ ...(Array.isArray(imported?.metadata?.patchHints) ? imported.metadata.patchHints : [])
100
+ ]);
101
+ }
102
+
103
+ function isSemanticImportExpected(imported, context) {
104
+ return semanticExpectationRecords(imported, context).some((entry) =>
105
+ entry.semanticImportExpected === true
106
+ || entry.expectedSemanticImport === true
107
+ || entry.semanticSidecarExpected === true
108
+ || entry.expected === true && looksLikeSemanticSidecarQuality(entry)
109
+ );
110
+ }
111
+
112
+ function isSemanticImportExpectedEmpty(imported, context) {
113
+ return semanticExpectationRecords(imported, context).some((entry) =>
114
+ entry.semanticImportExpectedEmpty === true
115
+ || entry.expectedSemanticImportEmpty === true
116
+ || entry.semanticSidecarExpectedEmpty === true
117
+ || entry.expectedEmpty === true && looksLikeSemanticSidecarQuality(entry)
118
+ );
119
+ }
120
+
121
+ function isChangedSemanticAdmissionSource(imported, context) {
122
+ const sourcePath = context.sourcePath;
123
+ const changedSourcePaths = uniqueStrings([
124
+ ...(context.projectResult?.changedSourcePaths ?? []),
125
+ ...(context.projectResult?.metadata?.changedSourcePaths ?? []),
126
+ ...(context.projectResult?.metadata?.semanticChangedSourcePaths ?? []),
127
+ ...(context.projectResult?.metadata?.semanticImportChangedSourcePaths ?? [])
128
+ ]);
129
+ return Boolean(
130
+ (context.candidates?.length ?? 0) > 0
131
+ || (sourcePath && changedSourcePaths.includes(sourcePath))
132
+ || semanticExpectationRecords(imported, context).some((entry) =>
133
+ entry.changedSource === true
134
+ || entry.sourceChanged === true
135
+ || entry.semanticSourceChanged === true
136
+ || entry.semanticImportChangedSource === true
137
+ )
138
+ );
139
+ }
140
+
141
+ function semanticExpectationRecords(imported, context) {
142
+ const nativeAst = imported?.nativeAst ?? imported?.nativeSource?.ast;
143
+ const semanticIndex = imported?.semanticIndex ?? imported?.universalAst?.semanticIndex;
144
+ return [
145
+ context.projectResult?.metadata,
146
+ context.source?.metadata,
147
+ imported?.metadata,
148
+ imported?.nativeSource?.metadata,
149
+ nativeAst?.metadata,
150
+ imported?.universalAst?.metadata,
151
+ imported?.patch?.metadata,
152
+ semanticIndex?.metadata,
153
+ imported?.semanticSidecarQuality,
154
+ imported?.sidecarQuality,
155
+ imported?.semanticSidecar?.quality,
156
+ imported?.sidecar?.quality,
157
+ imported?.metadata?.semanticSidecarQuality,
158
+ imported?.metadata?.sidecarQuality,
159
+ imported?.patch?.metadata?.semanticSidecarQuality
160
+ ].filter((entry) => entry && typeof entry === 'object');
161
+ }
162
+
163
+ function looksLikeSemanticSidecarQuality(value) {
164
+ return value && typeof value === 'object' && (
165
+ value.schema === 'frontier.lang.semanticSidecarQuality.v1'
166
+ || value.imported !== undefined
167
+ || value.expectedSatisfied !== undefined
168
+ || value.warningCount !== undefined
169
+ || value.proofSummary !== undefined
170
+ );
171
+ }
172
+
173
+ function firstString(...values) {
174
+ for (const value of values) {
175
+ if (value !== undefined && value !== null && String(value)) return String(value);
176
+ }
177
+ return undefined;
178
+ }
@@ -1,12 +1,12 @@
1
1
  import{countBy,maxSemanticMergeReadiness,uniqueRecordsById,uniqueStrings}from'../../native-import-utils.js';
2
2
  import{createSemanticMergeCandidateAdmissionRecord,querySemanticMergeCandidateAdmissionOverlaps,sortSemanticMergeCandidateAdmissionRecords}from'./semanticMergeCandidateRecords.js';
3
- import{compactAdmissionSource,importLosses,sourceLossClasses,summarizeImportPreservation,summarizeParserEvidence}from'./projectImportAdmissionImportEvidence.js';
3
+ import{compactAdmissionSource,importLosses,sourceLossClasses,summarizeImportPreservation,summarizeParserEvidence,summarizeSemanticAdmissionWarnings}from'./projectImportAdmissionImportEvidence.js';
4
4
  import{sourceMissingEvidence,sourceMissingTasks,sourceSemanticMergeScore}from'./projectImportAdmissionTasks.js';
5
5
  import{candidateRisk,maxPreservationQuality,maxRisk,normalizeRisk}from'./projectImportAdmissionRanks.js';
6
6
 
7
7
  export{admissionLanguages}from'./projectImportAdmissionLanguageSummaries.js';
8
8
 
9
- export function projectAdmissionImports(imports,sourceRows,mergeCandidates){
9
+ export function projectAdmissionImports(imports,sourceRows,mergeCandidates,projectResult){
10
10
  return imports.map((imported,index)=>{
11
11
  const source=sourceRows?.[index]??compactAdmissionSource(imported,index);
12
12
  const sourcePath=source.sourcePath??imported?.sourcePath;
@@ -22,6 +22,7 @@ export function projectAdmissionImports(imports,sourceRows,mergeCandidates){
22
22
  };
23
23
  const readiness=source.readiness??imported?.metadata?.semanticMergeReadiness??candidates[0]?.readiness??'ready';
24
24
  const emptySemanticEvidence=Object.values(semanticCounts).reduce((sum,value)=>sum+value,0)===0;
25
+ const semanticAdmission=summarizeSemanticAdmissionWarnings(imported,{source,sourcePath,candidates,projectResult});
25
26
  const sourcePreservation=summarizeImportPreservation(imported,source);
26
27
  const losses=importLosses(imported);
27
28
  const lossClasses=sourceLossClasses(imported,losses);
@@ -55,6 +56,7 @@ export function projectAdmissionImports(imports,sourceRows,mergeCandidates){
55
56
  readiness,
56
57
  semanticCounts,
57
58
  emptySemanticEvidence,
59
+ semanticAdmission,
58
60
  parserEvidence,
59
61
  lossClasses,
60
62
  missingEvidence,
@@ -90,6 +92,7 @@ export function admissionSemanticEvidence(projectResult,imports,importSummaries)
90
92
  .filter((entry)=>entry.emptySemanticEvidence)
91
93
  .map((entry)=>entry.sourcePath)
92
94
  .filter(Boolean));
95
+ const warnings=uniqueSemanticAdmissionWarnings(importSummaries.flatMap((entry)=>entry.semanticAdmission?.warnings??[]));
93
96
  return {
94
97
  empty:Object.values(totals).reduce((sum,value)=>sum+value,0)===0,
95
98
  emptySourceCount:importSummaries.filter((entry)=>entry.emptySemanticEvidence).length,
@@ -98,10 +101,26 @@ export function admissionSemanticEvidence(projectResult,imports,importSummaries)
98
101
  evidenceRecords:uniqueRecordsById([
99
102
  ...(projectResult?.evidence??[]),
100
103
  ...imports.flatMap((imported)=>imported?.evidence??[])
101
- ]).length
104
+ ]).length,
105
+ warningCount:warnings.length,
106
+ warningReasonCodes:uniqueStrings(warnings.map((warning)=>warning.reasonCode??warning.code)),
107
+ warningSourcePaths:uniqueStrings(warnings.flatMap((warning)=>warning.sourcePaths??[warning.sourcePath]).filter(Boolean)),
108
+ warnings
102
109
  };
103
110
  }
104
111
 
112
+ function uniqueSemanticAdmissionWarnings(warnings){
113
+ const seen=new Set();
114
+ const result=[];
115
+ for(const warning of warnings.filter(Boolean)){
116
+ const key=[warning.reasonCode??warning.code,(warning.sourcePaths??[warning.sourcePath]).join('|')].join('\u0000');
117
+ if(seen.has(key)) continue;
118
+ seen.add(key);
119
+ result.push(warning);
120
+ }
121
+ return result;
122
+ }
123
+
105
124
  export function admissionSourcePreservation(importSummaries,contract){
106
125
  const qualities=importSummaries.map((entry)=>entry.sourcePreservation.quality);
107
126
  const quality=qualities.length?qualities.reduce(maxPreservationQuality,'exact'):'empty';
@@ -3,7 +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 { semanticEditIdentityFields } from './semanticEditIdentityRecords.js';
6
+ import { alreadyAppliedImportEditForOperation } from './semanticEditImportProjection.js';
7
+ import { projectionEditRecord } from './semanticEditProjectionRecord.js';
7
8
  import {
8
9
  insertionOffset,
9
10
  insertionReplacement,
@@ -40,7 +41,8 @@ export function projectSemanticEditScriptToSource(input = {}) {
40
41
  }
41
42
  const edit = sourceEditForOperation(operation, workerSourceText, headSourceText, index, {
42
43
  headSourcePath: input.headSourcePath,
43
- headSymbols
44
+ headSymbols,
45
+ symbolIndexAvailable: isJavaScriptLike(language)
44
46
  });
45
47
  if (edit.ok) edits.push(edit.value);
46
48
  else reasonCodes.push(...edit.reasonCodes);
@@ -175,14 +177,20 @@ function insertionEditForOperation(operation, identity, workerSourceText, headSo
175
177
  const workerOffsets = spanOffsets(workerSourceText, operation.spans?.worker);
176
178
  const reasons = [];
177
179
  if (!workerOffsets) reasons.push(`worker-span-not-resolvable:${operation.id}`);
178
- const insertion = insertionOffset(headSourceText, operation.insertion, { symbols: context.headSymbols });
179
- if (!insertion.ok) reasons.push(...insertion.reasonCodes.map((reason) => `${reason}:${operation.id}`));
180
180
  if (reasons.length) return { ok: false, reasonCodes: reasons };
181
181
  const spanText = workerSourceText.slice(workerOffsets.start, workerOffsets.end);
182
182
  if (operation.hashes?.workerTextHash && hashSemanticValue(spanText) !== operation.hashes.workerTextHash) {
183
183
  reasons.push(`worker-span-hash-mismatch:${operation.id}`);
184
184
  }
185
185
  if (reasons.length) return { ok: false, reasonCodes: reasons };
186
+ const alreadyAppliedImport = alreadyAppliedImportEditForOperation(operation, identity, spanText, headSourceText, workerOffsets, order, context);
187
+ if (alreadyAppliedImport) return { ok: true, value: alreadyAppliedImport };
188
+ const insertion = insertionOffset(headSourceText, operation.insertion, {
189
+ symbols: context.headSymbols,
190
+ symbolIndexAvailable: context.symbolIndexAvailable
191
+ });
192
+ if (!insertion.ok) reasons.push(...insertion.reasonCodes.map((reason) => `${reason}:${operation.id}`));
193
+ if (reasons.length) return { ok: false, reasonCodes: reasons };
186
194
  return {
187
195
  ok: true,
188
196
  value: {
@@ -212,64 +220,6 @@ function projectionIdentity(operation, headSourcePath) {
212
220
  : identity.targetSourcePath;
213
221
  return { ...identity, sourcePath, originalSourcePath, targetSourcePath };
214
222
  }
215
- function projectionEditRecord(edit) {
216
- const deletedTextHash = hashSemanticValue(edit.current);
217
- const replacementTextHash = hashSemanticValue(edit.replacement);
218
- const identity = semanticEditIdentityFields(edit);
219
- return compactRecord({
220
- operationId: edit.operationId,
221
- status: edit.alreadyApplied ? 'already-applied' : 'applied',
222
- kind: edit.kind,
223
- editKind: edit.editKind,
224
- changeKind: edit.changeKind,
225
- anchorKey: edit.anchorKey,
226
- conflictKey: edit.conflictKey,
227
- regionId: edit.regionId,
228
- regionKind: edit.regionKind,
229
- sourcePath: edit.sourcePath,
230
- originalSourcePath: edit.originalSourcePath,
231
- targetAnchorKey: edit.targetAnchorKey,
232
- targetSourcePath: edit.targetSourcePath,
233
- targetSymbolName: edit.targetSymbolName,
234
- targetSymbolKind: edit.targetSymbolKind,
235
- symbolId: edit.symbolId,
236
- symbolName: edit.symbolName,
237
- symbolKind: edit.symbolKind,
238
- ...identity,
239
- operationContentHash: edit.operationContentHash,
240
- editContentHash: hashSemanticValue(compactRecord({
241
- semanticIdentityHash: identity.semanticIdentityHash,
242
- sourceRangeKind: edit.sourceRangeKind,
243
- deletedTextHash,
244
- replacementTextHash,
245
- status: edit.alreadyApplied ? 'already-applied' : 'applied'
246
- })),
247
- sourceRangeKind: edit.sourceRangeKind,
248
- headStart: edit.start,
249
- headEnd: edit.end,
250
- workerStart: edit.workerStart,
251
- workerEnd: edit.workerEnd,
252
- editOrder: edit.order,
253
- headAnchorStart: edit.headAnchorStart,
254
- headAnchorEnd: edit.headAnchorEnd,
255
- workerAnchorStart: edit.workerAnchorStart,
256
- workerAnchorEnd: edit.workerAnchorEnd,
257
- deletedBytes: edit.current.length,
258
- replacementBytes: edit.replacement.length,
259
- deletedTextHash,
260
- replacementTextHash,
261
- anchorDeletedTextHash: edit.anchorDeletedTextHash,
262
- anchorReplacementTextHash: edit.anchorReplacementTextHash,
263
- replacementSpanTextHash: hashSemanticValue(edit.replacementSpanText ?? edit.replacement),
264
- insertionMode: edit.insertion?.mode,
265
- insertionAnchorKey: edit.insertion?.anchorKey,
266
- insertionAnchorSymbolName: edit.insertion?.anchorSymbolName,
267
- insertionAnchorSymbolKind: edit.insertion?.anchorSymbolKind,
268
- insertionAnchorCandidates: edit.insertion?.anchorCandidates,
269
- replacementText: edit.replacement
270
- });
271
- }
272
-
273
223
  function sourceSymbolIndex(input) {
274
224
  try {
275
225
  const imported = normalizeNativeDiffImport({
@@ -4,7 +4,14 @@ import { createSemanticImportSidecar } from './createSemanticImportSidecar.js';
4
4
  import { mapDiffSymbols } from './mapDiffSymbols.js';
5
5
  import { normalizeNativeDiffImport } from './normalizeNativeDiffImport.js';
6
6
  import { replayDiagnostics, replayEditDiagnostics, replayEditsWithOverlapDiagnostics } from './semanticEditReplayDiagnostics.js';
7
- import { afterLineOffset, bodyContentRange, spanOffsets } from './semanticEditSourceRanges.js';
7
+ import {
8
+ findCurrentSymbol,
9
+ findInsertionAnchor,
10
+ hasSymbolAnchorIdentity,
11
+ insertionAnchorCandidates,
12
+ insertionRange
13
+ } from './semanticEditReplayAnchors.js';
14
+ import { bodyContentRange, removalRange, spanOffsets } from './semanticEditSourceRanges.js';
8
15
 
9
16
  export function replaySemanticEditProjection(input = {}) {
10
17
  const projection = input.projection ?? input.semanticEditProjection;
@@ -19,7 +26,11 @@ export function replaySemanticEditProjection(input = {}) {
19
26
  ? currentSymbolIndex({ currentSourceText, sourcePath, language, parser: input.parser })
20
27
  : [];
21
28
  const replayedEdits = projection.status === 'projected' && typeof currentSourceText === 'string'
22
- ? (projection.edits ?? []).map((edit, index) => replayProjectionEdit(projectionEditWithOrder(edit, index), { currentSourceText, currentSymbols }))
29
+ ? (projection.edits ?? []).map((edit, index) => replayProjectionEdit(projectionEditWithOrder(edit, index), {
30
+ currentSourceText,
31
+ currentSymbols,
32
+ symbolIndexAvailable: isJavaScriptLike(language)
33
+ }))
23
34
  : [];
24
35
  const edits = replayEditsWithOverlapDiagnostics(replayedEdits);
25
36
  const status = replayStatus(reasonCodes, edits, projection);
@@ -72,7 +83,7 @@ function replayProjectionEdit(edit, context) {
72
83
  const spanRange = currentSymbolEditRange(edit, spanOffsets(context.currentSourceText, symbol?.sourceSpan), context.currentSourceText);
73
84
  if (symbol && spanRange && !sameRange(headRange, spanRange)) {
74
85
  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);
86
+ if (moved) return replayEditRecord(edit, moved.status, replayAppliedRange(edit, moved.range, context.currentSourceText), [moved.reason, 'offset-reanchored-by-symbol'], context.currentSourceText);
76
87
  if (edit.editKind === 'delete' && offset && rangesOverlap(headRange, spanRange)) {
77
88
  return replayEditRecord(edit, offset.status, offset.range, [offset.reason], context.currentSourceText);
78
89
  }
@@ -80,12 +91,17 @@ function replayProjectionEdit(edit, context) {
80
91
  }
81
92
  if (offset) return replayEditRecord(edit, offset.status, offset.range, [offset.reason], context.currentSourceText);
82
93
  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);
94
+ if (anchored) return replayEditRecord(edit, anchored.status, replayAppliedRange(edit, anchored.range, context.currentSourceText), [anchored.reason, 'offset-reanchored-by-symbol'], context.currentSourceText);
84
95
  return replayEditRecord(edit, symbol ? 'conflict' : 'stale', spanRange, [
85
96
  symbol ? `${currentSymbolRangeLabel(edit)}-content-mismatch` : 'current-symbol-anchor-missing'
86
97
  ], context.currentSourceText);
87
98
  }
88
99
 
100
+ function replayAppliedRange(edit, range, sourceText) {
101
+ if (edit.editKind !== 'delete' || !range || typeof sourceText !== 'string') return range;
102
+ return removalRange(sourceText, range);
103
+ }
104
+
89
105
  function replayInsertionEdit(edit, context) {
90
106
  const inserted = findCurrentSymbol(edit, context.currentSymbols);
91
107
  const insertedRange = spanOffsets(context.currentSourceText, inserted?.sourceSpan);
@@ -97,7 +113,8 @@ function replayInsertionEdit(edit, context) {
97
113
  const anchor = findInsertionAnchor(edit, context.currentSymbols);
98
114
  const range = insertionRange(edit, anchor?.candidate, anchor?.symbol, context.currentSourceText);
99
115
  if (range) return replayEditRecord(edit, 'applied', range, [anchor ? 'current-insertion-anchor' : `current-${edit.insertionMode}`], context.currentSourceText);
100
- return replayEditRecord(edit, anchor ? 'conflict' : 'stale', undefined, [
116
+ const missingStableAnchor = context.symbolIndexAvailable && insertionAnchorCandidates(edit).some(hasSymbolAnchorIdentity);
117
+ return replayEditRecord(edit, anchor || missingStableAnchor ? 'conflict' : 'stale', undefined, [
101
118
  anchor ? 'current-insertion-anchor-unusable' : 'current-insertion-anchor-missing'
102
119
  ], context.currentSourceText);
103
120
  }
@@ -106,10 +123,26 @@ function checkRange(edit, range, sourceText, label) {
106
123
  if (!range || range.end < range.start) return undefined;
107
124
  const current = sourceText.slice(range.start, range.end);
108
125
  const currentHash = hashSemanticValue(current);
126
+ const currentLineEndingStableText = lineEndingStableText(current);
127
+ const currentLineEndingStableHash = currentLineEndingStableText === undefined
128
+ ? undefined
129
+ : hashSemanticValue(currentLineEndingStableText);
109
130
  if (edit.replacementSpanTextHash && currentHash === edit.replacementSpanTextHash) return { status: 'already-applied', range, reason: `${label}-matches-replacement-span` };
110
131
  if (edit.replacementTextHash && currentHash === edit.replacementTextHash) return { status: 'already-applied', range, reason: `${label}-matches-replacement` };
111
132
  if (current === edit.replacementText) return { status: 'already-applied', range, reason: `${label}-matches-replacement-text` };
133
+ if (edit.replacementSpanTextLineEndingStableHash && currentLineEndingStableHash === edit.replacementSpanTextLineEndingStableHash) {
134
+ return { status: 'already-applied', range, reason: `${label}-matches-replacement-span-line-ending-stable` };
135
+ }
136
+ if (edit.replacementTextLineEndingStableHash && currentLineEndingStableHash === edit.replacementTextLineEndingStableHash) {
137
+ return { status: 'already-applied', range, reason: `${label}-matches-replacement-line-ending-stable` };
138
+ }
139
+ if (typeof edit.replacementText === 'string' && currentLineEndingStableText === lineEndingStableText(edit.replacementText)) {
140
+ return { status: 'already-applied', range, reason: `${label}-matches-replacement-text-line-ending-stable` };
141
+ }
112
142
  if (edit.deletedTextHash && currentHash === edit.deletedTextHash) return { status: 'applied', range, reason: `${label}-matches-deleted` };
143
+ if (edit.deletedTextLineEndingStableHash && currentLineEndingStableHash === edit.deletedTextLineEndingStableHash) {
144
+ return { status: 'applied', range, reason: `${label}-matches-deleted-line-ending-stable` };
145
+ }
113
146
  return undefined;
114
147
  }
115
148
 
@@ -147,64 +180,6 @@ function currentSymbolIndex(input) {
147
180
  return [...mapDiffSymbols(imported, createSemanticImportSidecar(imported)).values()];
148
181
  }
149
182
 
150
- function findCurrentSymbol(edit, symbols) {
151
- const exact = symbols.find((symbol) => [symbol.ownershipKey, symbol.key, symbol.id].some((key) => key && [
152
- edit.anchorKey,
153
- edit.targetAnchorKey,
154
- edit.symbolId
155
- ].includes(key)));
156
- if (exact) return exact;
157
- const name = edit.targetSymbolName ?? edit.symbolName;
158
- const kind = edit.targetSymbolKind ?? edit.symbolKind;
159
- return symbols.find((symbol) => symbol.name === name && (!kind || symbol.kind === kind));
160
- }
161
-
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;
193
- }
194
-
195
- function insertionRange(edit, candidate, anchor, sourceText) {
196
- if (edit.insertionMode === 'file-start') return { start: 0, end: 0 };
197
- if (edit.insertionMode === 'file-end') return { start: sourceText.length, end: sourceText.length };
198
- const mode = candidate?.mode ?? edit.insertionMode;
199
- const anchorRange = spanOffsets(sourceText, anchor?.sourceSpan);
200
- if (!anchorRange) return undefined;
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) };
204
- }
205
- return undefined;
206
- }
207
-
208
183
  function currentSymbolEditRange(edit, symbolRange, sourceText) {
209
184
  if (!symbolRange) return undefined;
210
185
  if (edit.sourceRangeKind === 'body-content') return bodyContentRange(sourceText, symbolRange);
@@ -296,4 +271,9 @@ function rangesOverlap(left, right) {
296
271
 
297
272
  function isJavaScriptLike(language) { return language === 'javascript' || language === 'typescript'; }
298
273
  function reasonList(values) { return uniqueStrings((values ?? []).filter(Boolean)); }
274
+ function lineEndingStableText(value) {
275
+ if (typeof value !== 'string') return undefined;
276
+ const normalized = value.replace(/\r\n/g, '\n').replace(/\r/g, '\n');
277
+ return normalized.length > 1 && normalized.endsWith('\n') ? normalized.slice(0, -1) : normalized;
278
+ }
299
279
  function compactRecord(value) { return Object.fromEntries(Object.entries(value ?? {}).filter(([, entry]) => entry !== undefined && (!Array.isArray(entry) || entry.length > 0))); }
@@ -0,0 +1,53 @@
1
+ import { hashSemanticValue } from '@shapeshift-labs/frontier-lang-kernel';
2
+ import { spanOffsets } from './semanticEditSourceRanges.js';
3
+
4
+ export function alreadyAppliedImportEditForOperation(operation, identity, spanText, headSourceText, workerOffsets, order, context) {
5
+ if (!isAddImportOperation(operation) || typeof headSourceText !== 'string') return undefined;
6
+ const match = findHeadImportSymbol(operation, context.headSymbols);
7
+ const range = spanOffsets(headSourceText, match?.symbol?.sourceSpan);
8
+ if (!range) return undefined;
9
+ const current = headSourceText.slice(range.start, range.end);
10
+ if (!headImportMatchesOperation(operation, spanText, current, match.symbol)) return undefined;
11
+ return {
12
+ operationId: operation.id,
13
+ order,
14
+ ...identity,
15
+ start: range.start,
16
+ end: range.end,
17
+ workerStart: workerOffsets.start,
18
+ workerEnd: workerOffsets.end,
19
+ replacement: current,
20
+ replacementSpanText: spanText,
21
+ current,
22
+ alreadyApplied: true
23
+ };
24
+ }
25
+
26
+ function findHeadImportSymbol(operation, symbols) {
27
+ const symbolList = Array.isArray(symbols) ? symbols : [];
28
+ const exactKeys = [
29
+ operation.anchor?.key,
30
+ operation.anchor?.symbolId,
31
+ operation.insertion?.insertedSymbolId
32
+ ].filter(Boolean);
33
+ const exact = symbolList.find((symbol) => [symbol.ownershipKey, symbol.key, symbol.id].some((key) => key && exactKeys.includes(key)));
34
+ if (exact) return { symbol: exact, exact: true };
35
+ const name = operation.insertion?.insertedSymbolName ?? operation.anchor?.symbolName;
36
+ const kind = operation.insertion?.insertedSymbolKind ?? operation.anchor?.symbolKind;
37
+ const semantic = symbolList.find((symbol) => symbol.name === name && (!kind || symbol.kind === kind));
38
+ return semantic ? { symbol: semantic, exact: false } : undefined;
39
+ }
40
+
41
+ function headImportMatchesOperation(operation, spanText, current, symbol) {
42
+ const workerTextHash = operation.hashes?.workerTextHash ?? hashSemanticValue(spanText);
43
+ const workerSpanHash = operation.hashes?.workerSpanHash ?? workerTextHash;
44
+ const currentHash = hashSemanticValue(current);
45
+ if ([workerTextHash, workerSpanHash].includes(currentHash)) return true;
46
+ if ([workerTextHash, workerSpanHash].includes(symbol?.spanHash)) return true;
47
+ const signatureHash = operation.hashes?.afterSignatureHash;
48
+ return Boolean(signatureHash && symbol?.signatureHash === signatureHash);
49
+ }
50
+
51
+ function isAddImportOperation(operation) {
52
+ return operation?.kind === 'addImport' || (operation?.changeKind === 'added' && operation?.anchor?.regionKind === 'import');
53
+ }
@@ -0,0 +1,79 @@
1
+ import { hashSemanticValue } from '@shapeshift-labs/frontier-lang-kernel';
2
+ import { semanticEditIdentityFields } from './semanticEditIdentityRecords.js';
3
+
4
+ export function projectionEditRecord(edit) {
5
+ const deletedTextHash = hashSemanticValue(edit.current);
6
+ const replacementTextHash = hashSemanticValue(edit.replacement);
7
+ const replacementSpanText = edit.replacementSpanText ?? edit.replacement;
8
+ const identity = semanticEditIdentityFields(edit);
9
+ return compactRecord({
10
+ operationId: edit.operationId,
11
+ status: edit.alreadyApplied ? 'already-applied' : 'applied',
12
+ kind: edit.kind,
13
+ editKind: edit.editKind,
14
+ changeKind: edit.changeKind,
15
+ anchorKey: edit.anchorKey,
16
+ conflictKey: edit.conflictKey,
17
+ regionId: edit.regionId,
18
+ regionKind: edit.regionKind,
19
+ sourcePath: edit.sourcePath,
20
+ originalSourcePath: edit.originalSourcePath,
21
+ targetAnchorKey: edit.targetAnchorKey,
22
+ targetSourcePath: edit.targetSourcePath,
23
+ targetSymbolName: edit.targetSymbolName,
24
+ targetSymbolKind: edit.targetSymbolKind,
25
+ symbolId: edit.symbolId,
26
+ symbolName: edit.symbolName,
27
+ symbolKind: edit.symbolKind,
28
+ ...identity,
29
+ operationContentHash: edit.operationContentHash,
30
+ editContentHash: hashSemanticValue(compactRecord({
31
+ semanticIdentityHash: identity.semanticIdentityHash,
32
+ sourceRangeKind: edit.sourceRangeKind,
33
+ deletedTextHash,
34
+ replacementTextHash,
35
+ status: edit.alreadyApplied ? 'already-applied' : 'applied'
36
+ })),
37
+ sourceRangeKind: edit.sourceRangeKind,
38
+ headStart: edit.start,
39
+ headEnd: edit.end,
40
+ workerStart: edit.workerStart,
41
+ workerEnd: edit.workerEnd,
42
+ editOrder: edit.order,
43
+ headAnchorStart: edit.headAnchorStart,
44
+ headAnchorEnd: edit.headAnchorEnd,
45
+ workerAnchorStart: edit.workerAnchorStart,
46
+ workerAnchorEnd: edit.workerAnchorEnd,
47
+ deletedBytes: edit.current.length,
48
+ replacementBytes: edit.replacement.length,
49
+ deletedTextHash,
50
+ replacementTextHash,
51
+ deletedTextLineEndingStableHash: lineEndingStableTextHash(edit.current),
52
+ replacementTextLineEndingStableHash: lineEndingStableTextHash(edit.replacement),
53
+ anchorDeletedTextHash: edit.anchorDeletedTextHash,
54
+ anchorReplacementTextHash: edit.anchorReplacementTextHash,
55
+ replacementSpanTextHash: hashSemanticValue(replacementSpanText),
56
+ replacementSpanTextLineEndingStableHash: lineEndingStableTextHash(replacementSpanText),
57
+ insertionMode: edit.insertion?.mode,
58
+ insertionAnchorKey: edit.insertion?.anchorKey,
59
+ insertionAnchorSymbolName: edit.insertion?.anchorSymbolName,
60
+ insertionAnchorSymbolKind: edit.insertion?.anchorSymbolKind,
61
+ insertionAnchorCandidates: edit.insertion?.anchorCandidates,
62
+ replacementText: edit.replacement
63
+ });
64
+ }
65
+
66
+ function lineEndingStableTextHash(value) {
67
+ const normalized = lineEndingStableText(value);
68
+ return normalized === undefined ? undefined : hashSemanticValue(normalized);
69
+ }
70
+
71
+ function lineEndingStableText(value) {
72
+ if (typeof value !== 'string') return undefined;
73
+ const normalized = value.replace(/\r\n/g, '\n').replace(/\r/g, '\n');
74
+ return normalized.length > 1 && normalized.endsWith('\n') ? normalized.slice(0, -1) : normalized;
75
+ }
76
+
77
+ function compactRecord(value) {
78
+ return Object.fromEntries(Object.entries(value ?? {}).filter(([, entry]) => entry !== undefined && (!Array.isArray(entry) || entry.length > 0)));
79
+ }
@@ -0,0 +1,63 @@
1
+ import { afterLineOffset, spanOffsets } from './semanticEditSourceRanges.js';
2
+
3
+ export function findCurrentSymbol(edit, symbols) {
4
+ const exact = symbols.find((symbol) => [symbol.ownershipKey, symbol.key, symbol.id].some((key) => key && [
5
+ edit.anchorKey,
6
+ edit.targetAnchorKey,
7
+ edit.symbolId
8
+ ].includes(key)));
9
+ if (exact) return exact;
10
+ const name = edit.targetSymbolName ?? edit.symbolName;
11
+ const kind = edit.targetSymbolKind ?? edit.symbolKind;
12
+ return symbols.find((symbol) => symbol.name === name && (!kind || symbol.kind === kind));
13
+ }
14
+
15
+ export function findInsertionAnchor(edit, symbols) {
16
+ for (const candidate of insertionAnchorCandidates(edit)) {
17
+ const symbol = findInsertionAnchorSymbol(candidate, symbols);
18
+ if (symbol) return { candidate, symbol };
19
+ }
20
+ return undefined;
21
+ }
22
+
23
+ export function insertionAnchorCandidates(edit) {
24
+ const primary = {
25
+ mode: edit.insertionMode,
26
+ anchorKey: edit.insertionAnchorKey,
27
+ anchorSymbolName: edit.insertionAnchorSymbolName,
28
+ anchorSymbolKind: edit.insertionAnchorSymbolKind
29
+ };
30
+ const seen = new Set();
31
+ const result = [];
32
+ for (const candidate of [primary, ...(Array.isArray(edit.insertionAnchorCandidates) ? edit.insertionAnchorCandidates : [])]) {
33
+ if (!candidate || (candidate.mode !== 'before' && candidate.mode !== 'after')) continue;
34
+ const key = [candidate.mode, candidate.anchorKey, candidate.anchorSymbolId, candidate.anchorSymbolName, candidate.anchorSymbolKind].join('\0');
35
+ if (seen.has(key)) continue;
36
+ seen.add(key);
37
+ result.push(candidate);
38
+ }
39
+ return result;
40
+ }
41
+
42
+ export function hasSymbolAnchorIdentity(candidate) {
43
+ return Boolean(candidate.anchorKey || candidate.anchorSymbolId || candidate.anchorSymbolName);
44
+ }
45
+
46
+ export function insertionRange(edit, candidate, anchor, sourceText) {
47
+ if (edit.insertionMode === 'file-start') return { start: 0, end: 0 };
48
+ if (edit.insertionMode === 'file-end') return { start: sourceText.length, end: sourceText.length };
49
+ const mode = candidate?.mode ?? edit.insertionMode;
50
+ const anchorRange = spanOffsets(sourceText, anchor?.sourceSpan);
51
+ if (!anchorRange) return undefined;
52
+ if (mode === 'before') return { start: anchorRange.start, end: anchorRange.start };
53
+ if (mode === 'after') {
54
+ return { start: afterLineOffset(sourceText, anchorRange.end), end: afterLineOffset(sourceText, anchorRange.end) };
55
+ }
56
+ return undefined;
57
+ }
58
+
59
+ function findInsertionAnchorSymbol(candidate, symbols) {
60
+ const keys = [candidate.anchorKey, candidate.anchorSymbolId].filter(Boolean);
61
+ return symbols.find((symbol) => [symbol.ownershipKey, symbol.key, symbol.id].some((key) => key && keys.includes(key)))
62
+ ?? symbols.find((symbol) => symbol.name === candidate.anchorSymbolName && (!candidate.anchorSymbolKind || symbol.kind === candidate.anchorSymbolKind));
63
+ }
@@ -168,6 +168,7 @@ function insertionAnchorResolution(sourceText, insertion, context) {
168
168
  const range = spanOffsets(sourceText, symbol?.sourceSpan);
169
169
  if (range) return { mode: candidate.mode, range };
170
170
  }
171
+ if (context.symbolIndexAvailable && candidates.some(hasSymbolAnchorIdentity)) return undefined;
171
172
  for (const candidate of candidates) {
172
173
  const range = spanOffsets(sourceText, candidate.headSpan);
173
174
  if (range) return { mode: candidate.mode, range };
@@ -175,6 +176,10 @@ function insertionAnchorResolution(sourceText, insertion, context) {
175
176
  return undefined;
176
177
  }
177
178
 
179
+ function hasSymbolAnchorIdentity(candidate) {
180
+ return Boolean(candidate.anchorKey || candidate.anchorSymbolId || candidate.anchorSymbolName);
181
+ }
182
+
178
183
  function insertionAnchorCandidates(insertion) {
179
184
  const seen = new Set();
180
185
  const result = [];
@@ -2,7 +2,9 @@ import { normalizeSemanticMergeReadiness, uniqueStrings } from '../../native-imp
2
2
 
3
3
  export function createSemanticPatchBundleAdmission(input = {}, context = {}) {
4
4
  const transformAdmission = semanticTransformAdmission(context);
5
- const semanticEditAdmission = context.semanticEditAdmission ?? { status: 'none', action: 'none', readiness: 'needs-review', reasonCodes: [] };
5
+ const semanticEditAdmission = semanticEditAdmissionWithReplayRequirement(
6
+ context.semanticEditAdmission ?? { status: 'none', action: 'none', readiness: 'needs-review', reasonCodes: [] }
7
+ );
6
8
  const evidenceAdmission = autoMergeEvidenceAdmission(context, { transformAdmission, semanticEditAdmission });
7
9
  const fallbackReadiness = fallbackAdmissionReadiness(transformAdmission, semanticEditAdmission, evidenceAdmission, context.readiness);
8
10
  const inputReadiness = normalizeSemanticMergeReadiness(input.readiness ?? fallbackReadiness) ?? input.readiness ?? fallbackReadiness;
@@ -35,7 +37,7 @@ export function createSemanticPatchBundleAdmission(input = {}, context = {}) {
35
37
  ...strings(semanticEditAdmission.reasonCodes),
36
38
  ...strings(evidenceAdmission.reasonCodes)
37
39
  ].filter(Boolean)),
38
- conflictKeys: uniqueStrings([...strings(input.conflictKeys), ...context.conflictKeys]),
40
+ conflictKeys: uniqueStrings([...strings(input.conflictKeys), ...strings(context.conflictKeys)]),
39
41
  admittedAt: input.admittedAt,
40
42
  reviewerId: input.reviewerId,
41
43
  evidenceIds: uniqueStrings([...strings(input.evidenceIds), ...strings(transformAdmission.evidenceIds), ...strings(evidenceAdmission.evidenceIds)]),
@@ -155,6 +157,7 @@ function fallbackAdmissionReadiness(transformAdmission, semanticEditAdmission, e
155
157
  if ([transformAdmission.readiness, semanticEditAdmission.readiness, evidenceAdmission.readiness].includes('blocked')) return 'blocked';
156
158
  if (hasSkipReadyAction(semanticEditAdmission)) return 'ready';
157
159
  if (hasPositiveApplyAction(transformAdmission, semanticEditAdmission)) return evidenceAdmission.action === 'admit' ? 'ready' : evidenceAdmission.readiness;
160
+ if (semanticEditAdmission.action === 'review' || semanticEditAdmission.status === 'needs-review') return 'needs-review';
158
161
  return fallback;
159
162
  }
160
163
 
@@ -183,6 +186,51 @@ function hasSkipReadyAction(semanticEditAdmission) {
183
186
  return semanticEditAdmission.action === 'skip' && semanticEditAdmission.readiness === 'ready' && semanticEditAdmission.reviewRequired === false;
184
187
  }
185
188
 
189
+ function semanticEditAdmissionWithReplayRequirement(admission) {
190
+ if (!requiresSemanticEditReplay(admission) || hasAcceptedCleanSemanticEditReplay(admission)) return admission;
191
+ return compactRecord({
192
+ ...admission,
193
+ status: 'needs-review',
194
+ action: 'review',
195
+ readiness: 'needs-review',
196
+ reviewRequired: true,
197
+ autoApplyCandidate: false,
198
+ reasonCodes: uniqueStrings([
199
+ ...strings(admission.reasonCodes).filter((reason) => reason !== 'semantic-edit-positive-auto-merge-proof'),
200
+ ...semanticEditReplayRequirementReasonCodes(admission)
201
+ ])
202
+ });
203
+ }
204
+
205
+ function requiresSemanticEditReplay(admission) {
206
+ return admission.action === 'admit' ||
207
+ admission.autoApplyCandidate === true ||
208
+ admission.status === 'ready' ||
209
+ strings(admission.reasonCodes).includes('semantic-edit-positive-auto-merge-proof');
210
+ }
211
+
212
+ function hasAcceptedCleanSemanticEditReplay(admission) {
213
+ const summary = admission.summary ?? {};
214
+ const acceptedClean = count(summary.acceptedClean);
215
+ const alreadyApplied = count(summary.alreadyApplied);
216
+ const replays = count(summary.replays);
217
+ return acceptedClean > 0 && replays > 0 && acceptedClean + alreadyApplied === replays;
218
+ }
219
+
220
+ function semanticEditReplayRequirementReasonCodes(admission) {
221
+ const summary = admission.summary ?? {};
222
+ const scripts = count(summary.scripts);
223
+ const projections = count(summary.projections);
224
+ const replays = count(summary.replays);
225
+ const acceptedClean = count(summary.acceptedClean);
226
+ return [
227
+ scripts > 0 && projections === 0 ? 'semantic-edit-projection-missing' : undefined,
228
+ (scripts > 0 || projections > 0) && replays === 0 ? 'semantic-edit-replay-missing' : undefined,
229
+ replays > 0 && acceptedClean === 0 ? 'semantic-edit-replay-accepted-clean-missing' : undefined,
230
+ 'semantic-edit-replay-required'
231
+ ].filter(Boolean);
232
+ }
233
+
186
234
  function hasCrossLanguageTransform(index) {
187
235
  const source = new Set(strings(index.transformSourceLanguages));
188
236
  return strings(index.transformTargetLanguages).some((target) => !source.has(target));
@@ -211,6 +259,7 @@ function uniqueEvidenceRecords(records) {
211
259
  }
212
260
 
213
261
  function evidenceIds(evidence) { return uniqueStrings(evidence.map((record) => record.id)); }
262
+ function count(value) { const number = Number(value ?? 0); return Number.isFinite(number) ? number : 0; }
214
263
  function array(value) { if (value === undefined || value === null) return []; return Array.isArray(value) ? value : [value]; }
215
264
  function strings(value) { return array(value).map((entry) => String(entry ?? '')).filter(Boolean); }
216
265
  function compactRecord(value) { return Object.fromEntries(Object.entries(value ?? {}).filter(([, entry]) => entry !== undefined && (!Array.isArray(entry) || entry.length > 0))); }
@@ -65,7 +65,8 @@ export function compareSemanticPatchBundleRecords(left={},right={},options={}){
65
65
  semanticSignals:shared.semanticEditKeys.length+shared.semanticIdentityHashes.length+shared.sourceIdentityHashes.length+shared.semanticTransformIdentityHashes.length+shared.projectionIdentityHashes.length,
66
66
  sourceSignals:shared.regionKeys.length+shared.conflictKeys.length+shared.sourcePaths.length+shared.semanticEditReplayCurrentHashes.length,
67
67
  baseHashMismatch:admission.reasonCodes.includes('base-hash-mismatch'),
68
- targetHashMismatch:admission.reasonCodes.includes('target-hash-mismatch')
68
+ targetHashMismatch:admission.reasonCodes.includes('target-hash-mismatch'),
69
+ replayOutputHashMismatch:admission.reasonCodes.includes('replay-output-hash-mismatch')
69
70
  },
70
71
  metadata:compactRecord(options.metadata)
71
72
  };
@@ -110,21 +111,32 @@ function sharedIndex(left,right,options){
110
111
  baseHashes:intersect(left.baseHashes,right.baseHashes),
111
112
  targetHashes:intersect(left.targetHashes,right.targetHashes)
112
113
  };
113
- const scopedEdit=hasSharedEditScope(shared);
114
- const scopedSource=hasSharedSourceScope(shared);
115
- return{
114
+ const semanticKeyIndependent=hasDisjointSemanticEditScope(left,right,shared);
115
+ const scopedShared={
116
116
  ...shared,
117
- operationContentHashes:scopedEdit?shared.operationContentHashes:[],
118
- editContentHashes:scopedEdit?shared.editContentHashes:[],
119
- semanticEditKeys:scopedSource?shared.semanticEditKeys:[],
120
- semanticIdentityHashes:scopedSource?shared.semanticIdentityHashes:[],
121
- semanticEditReplayCurrentHashes:scopedSource?shared.semanticEditReplayCurrentHashes:[],
122
- semanticEditReplayOutputHashes:scopedEdit?shared.semanticEditReplayOutputHashes:[],
123
- semanticTransformContentHashes:shared.projectionIdentityHashes.length?shared.semanticTransformContentHashes:[],
124
- semanticTransformIdentityHashes:shared.projectionIdentityHashes.length?shared.semanticTransformIdentityHashes:[]
117
+ sourcePaths:semanticKeyIndependent?[]:shared.sourcePaths
118
+ };
119
+ const scopedEdit=hasSharedEditScope(scopedShared);
120
+ const scopedSource=hasSharedSourceScope(scopedShared);
121
+ return{
122
+ ...scopedShared,
123
+ operationContentHashes:scopedEdit?scopedShared.operationContentHashes:[],
124
+ editContentHashes:scopedEdit?scopedShared.editContentHashes:[],
125
+ semanticEditKeys:scopedSource?scopedShared.semanticEditKeys:[],
126
+ semanticIdentityHashes:scopedSource?scopedShared.semanticIdentityHashes:[],
127
+ semanticEditReplayCurrentHashes:scopedSource?scopedShared.semanticEditReplayCurrentHashes:[],
128
+ semanticEditReplayOutputHashes:scopedEdit?scopedShared.semanticEditReplayOutputHashes:[],
129
+ semanticTransformContentHashes:scopedShared.projectionIdentityHashes.length?scopedShared.semanticTransformContentHashes:[],
130
+ semanticTransformIdentityHashes:scopedShared.projectionIdentityHashes.length?scopedShared.semanticTransformIdentityHashes:[]
125
131
  };
126
132
  }
127
133
 
134
+ function hasDisjointSemanticEditScope(left,right,shared){
135
+ return Boolean(shared.sourcePaths.length&&left.semanticEditKeys.length&&right.semanticEditKeys.length
136
+ &&shared.semanticEditKeys.length===0&&shared.regionKeys.length===0&&shared.conflictKeys.length===0
137
+ &&shared.semanticIdentityHashes.length===0&&shared.sourceIdentityHashes.length===0);
138
+ }
139
+
128
140
  function hasSharedEditScope(shared){
129
141
  return Boolean(shared.regionKeys.length||shared.conflictKeys.length||shared.sourceIdentityHashes.length
130
142
  ||(shared.sourcePaths.length&&(shared.semanticEditKeys.length||shared.semanticIdentityHashes.length)));
@@ -135,7 +147,10 @@ function hasSharedSourceScope(shared){
135
147
  }
136
148
 
137
149
  function overlapAdmission(shared,{leftIndex,rightIndex,options}){
138
- const hashMismatch=disjointNonEmpty(leftIndex.baseHashes,rightIndex.baseHashes)||disjointNonEmpty(leftIndex.targetHashes,rightIndex.targetHashes);
150
+ const baseHashMismatch=disjointNonEmpty(leftIndex.baseHashes,rightIndex.baseHashes);
151
+ const targetHashMismatch=disjointNonEmpty(leftIndex.targetHashes,rightIndex.targetHashes);
152
+ const replayOutputHashMismatch=disjointNonEmpty(leftIndex.semanticEditReplayOutputHashes,rightIndex.semanticEditReplayOutputHashes);
153
+ const hashMismatch=baseHashMismatch||targetHashMismatch||replayOutputHashMismatch;
139
154
  const sourceRelated=shared.sourcePaths.length||shared.regionKeys.length||shared.conflictKeys.length||shared.sourceIdentityHashes.length;
140
155
  const editContent=shared.operationContentHashes.length||shared.editContentHashes.length||shared.semanticEditReplayOutputHashes.length;
141
156
  const transformContent=shared.semanticTransformContentHashes.length&&shared.projectionIdentityHashes.length;
@@ -158,8 +173,9 @@ function overlapAdmission(shared,{leftIndex,rightIndex,options}){
158
173
  shared.regionKeys.length?'same-region-key':undefined,
159
174
  shared.conflictKeys.length?'same-conflict-key':undefined,
160
175
  shared.sourcePaths.length?'same-source-path':undefined,
161
- status!=='independent'&&disjointNonEmpty(leftIndex.baseHashes,rightIndex.baseHashes)?'base-hash-mismatch':undefined,
162
- status!=='independent'&&disjointNonEmpty(leftIndex.targetHashes,rightIndex.targetHashes)?'target-hash-mismatch':undefined
176
+ status!=='independent'&&baseHashMismatch?'base-hash-mismatch':undefined,
177
+ status!=='independent'&&targetHashMismatch?'target-hash-mismatch':undefined,
178
+ status!=='independent'&&replayOutputHashMismatch?'replay-output-hash-mismatch':undefined
163
179
  ]);
164
180
  return{
165
181
  status,
@@ -223,7 +239,8 @@ function matchesOverlap(overlap,query){
223
239
  function overlapScore(status,shared,reasonCodes){
224
240
  const base=status==='duplicate'?100:status==='semantic-overlap'?75:status==='source-overlap'?35:0;
225
241
  const sharedBonus=Math.min(20,countShared(shared));
226
- const stalePenalty=reasonCodes.includes('base-hash-mismatch')||reasonCodes.includes('target-hash-mismatch')?15:0;
242
+ const stalePenalty=reasonCodes.includes('base-hash-mismatch')||reasonCodes.includes('target-hash-mismatch')
243
+ ||reasonCodes.includes('replay-output-hash-mismatch')?15:0;
227
244
  return Math.max(0,base+sharedBonus-stalePenalty);
228
245
  }
229
246
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@shapeshift-labs/frontier-lang-compiler",
3
- "version": "0.2.102",
3
+ "version": "0.2.103",
4
4
  "description": "Compiler facade for Frontier Lang source documents and language projection adapters.",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",