@saptools/service-flow 0.1.67 → 0.1.68

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 (80) hide show
  1. package/CHANGELOG.md +10 -0
  2. package/README.md +20 -8
  3. package/TECHNICAL-NOTE.md +27 -0
  4. package/dist/{chunk-ZQABU7MR.js → chunk-AEM4JY22.js} +12676 -6714
  5. package/dist/chunk-AEM4JY22.js.map +1 -0
  6. package/dist/cli.js +2325 -413
  7. package/dist/cli.js.map +1 -1
  8. package/dist/index.d.ts +61 -2
  9. package/dist/index.js +1 -1
  10. package/package.json +1 -1
  11. package/src/cli/003-doctor-package-resolution.ts +68 -0
  12. package/src/cli/doctor.ts +25 -14
  13. package/src/db/000-call-fact-repository.ts +475 -342
  14. package/src/db/001-fact-lifecycle.ts +60 -30
  15. package/src/db/002-fact-json-inventory.ts +169 -0
  16. package/src/db/003-current-fact-semantics.ts +698 -0
  17. package/src/db/004-package-target-invalidation.ts +173 -0
  18. package/src/db/005-schema-structure.ts +201 -0
  19. package/src/db/006-relative-symbol-resolution.ts +443 -0
  20. package/src/db/007-package-fact-semantics.ts +573 -0
  21. package/src/db/008-relative-fact-semantics.ts +207 -0
  22. package/src/db/009-binding-fact-semantics.ts +347 -0
  23. package/src/db/010-package-symbol-surface-semantics.ts +320 -0
  24. package/src/db/011-symbol-call-semantics.ts +144 -0
  25. package/src/db/012-binding-reference-proof.ts +264 -0
  26. package/src/db/migrations.ts +16 -3
  27. package/src/db/repositories.ts +113 -6
  28. package/src/db/schema.ts +4 -2
  29. package/src/index.ts +12 -0
  30. package/src/indexer/repository-indexer.ts +72 -11
  31. package/src/indexer/workspace-indexer.ts +123 -32
  32. package/src/linker/003-package-import-symbol-resolver.ts +363 -131
  33. package/src/linker/004-event-subscription-handler-linker.ts +2 -0
  34. package/src/linker/005-odata-path-structure.ts +371 -0
  35. package/src/linker/cross-repo-linker.ts +2 -1
  36. package/src/linker/odata-path-normalizer.ts +273 -180
  37. package/src/linker/service-resolver.ts +197 -77
  38. package/src/parsers/002-symbol-import-bindings.ts +516 -0
  39. package/src/parsers/003-package-public-surface.ts +661 -0
  40. package/src/parsers/004-fact-identity.ts +108 -0
  41. package/src/parsers/005-event-subscription-facts.ts +281 -0
  42. package/src/parsers/006-binding-identity.ts +343 -0
  43. package/src/parsers/007-source-fact-reconciliation.ts +105 -0
  44. package/src/parsers/008-package-surface-publication.ts +82 -0
  45. package/src/parsers/009-symbol-call-facts.ts +528 -0
  46. package/src/parsers/010-package-public-surface-analysis.ts +352 -0
  47. package/src/parsers/011-binding-lexical-scope.ts +583 -0
  48. package/src/parsers/012-package-fact-contract.ts +306 -0
  49. package/src/parsers/013-executable-body-eligibility.ts +35 -0
  50. package/src/parsers/014-service-binding-helper-flow.ts +240 -0
  51. package/src/parsers/015-service-binding-collector.ts +693 -0
  52. package/src/parsers/016-local-symbol-reference.ts +261 -0
  53. package/src/parsers/017-symbol-derived-contexts.ts +268 -0
  54. package/src/parsers/018-package-commonjs-syntax.ts +142 -0
  55. package/src/parsers/019-binding-assignment-targets.ts +76 -0
  56. package/src/parsers/020-stable-local-value.ts +217 -0
  57. package/src/parsers/021-binding-visibility.ts +152 -0
  58. package/src/parsers/operation-path-analysis.ts +6 -1
  59. package/src/parsers/outbound-call-parser.ts +19 -6
  60. package/src/parsers/package-json-parser.ts +45 -3
  61. package/src/parsers/service-binding-parser-helpers.ts +86 -15
  62. package/src/parsers/service-binding-parser.ts +147 -597
  63. package/src/parsers/symbol-parser.ts +482 -353
  64. package/src/trace/002-trace-diagnostics.ts +36 -1
  65. package/src/trace/016-compact-projector.ts +16 -17
  66. package/src/trace/020-compact-field-projection.ts +48 -37
  67. package/src/trace/021-compact-decision-normalization.ts +105 -0
  68. package/src/trace/022-trace-fact-preflight.ts +21 -0
  69. package/src/trace/023-nested-event-scopes.ts +23 -0
  70. package/src/trace/024-compact-observation-decision.ts +76 -0
  71. package/src/trace/025-trace-implementation-scope.ts +123 -0
  72. package/src/trace/026-trace-start-scope.ts +335 -0
  73. package/src/trace/027-trace-scope-execution.ts +566 -0
  74. package/src/trace/028-trace-operation-execution.ts +336 -0
  75. package/src/trace/029-trace-start-implementation.ts +172 -0
  76. package/src/trace/trace-engine.ts +122 -624
  77. package/src/types.ts +56 -0
  78. package/src/utils/001-placeholders.ts +188 -10
  79. package/src/version.ts +1 -1
  80. package/dist/chunk-ZQABU7MR.js.map +0 -1
@@ -1,3 +1,8 @@
1
+ import {
2
+ analyzeODataPathStructure,
3
+ type ODataPathStructure,
4
+ } from './005-odata-path-structure.js';
5
+
1
6
  export interface NormalizedODataOperationPath {
2
7
  rawOperationPath: string;
3
8
  normalizedOperationPath: string;
@@ -33,207 +38,295 @@ export interface ODataPathIntent {
33
38
  reason: string;
34
39
  }
35
40
 
36
- export function normalizeODataOperationInvocationPath(path: string | undefined): NormalizedODataOperationPath | undefined {
41
+ interface NavigationEvidence {
42
+ readonly navigation: boolean;
43
+ readonly suffix?: string;
44
+ readonly navigationSuffix?: string;
45
+ }
46
+
47
+ interface OperationEvidence {
48
+ readonly invocation: boolean;
49
+ readonly name?: string;
50
+ }
51
+
52
+ export function normalizeODataOperationInvocationPath(
53
+ path: string | undefined,
54
+ ): NormalizedODataOperationPath | undefined {
37
55
  if (path === undefined) return undefined;
38
56
  const raw = path.trim();
39
57
  if (!raw) return undefined;
40
- const rejected = (reason: string): NormalizedODataOperationPath => ({ rawOperationPath: raw, normalizedOperationPath: raw, wasInvocation: false, invocationArgumentPlaceholderKeys: [], normalizationRejectedReason: reason });
41
- const open = raw.indexOf('(');
42
- if (open < 0) return rejected('no_top_level_parenthesis');
43
- const query = topLevelQueryIndex(raw);
44
- if (query >= 0) return rejected('query_string_paths_are_not_operation_invocations');
45
- if (!raw.startsWith('/')) return rejected('path_is_not_absolute');
46
- if (raw.slice(1, open).includes('/')) return rejected('operation_segment_contains_navigation_separator');
47
- const close = matchingClose(raw, open);
48
- if (close === undefined) return rejected('top_level_invocation_parenthesis_is_unbalanced');
49
- if (raw.slice(close + 1).trim().length > 0) return rejected('top_level_invocation_does_not_cover_remaining_path');
50
- const operationSegment = raw.slice(0, open).trim();
51
- if (operationSegment.length <= 1) return rejected('operation_segment_is_empty');
58
+ const structure = analyzeODataPathStructure(raw);
59
+ const rejection = normalizationRejection(raw, structure);
60
+ if (rejection) return rejectedNormalization(raw, rejection);
61
+ const operationSegment = raw.slice(
62
+ 0, structure.firstParenthesisOpen ?? 0,
63
+ ).trim();
64
+ if (operationSegment.length <= 1)
65
+ return rejectedNormalization(raw, 'operation_segment_is_empty');
52
66
  return {
53
67
  rawOperationPath: raw,
54
68
  normalizedOperationPath: operationSegment,
55
69
  wasInvocation: true,
56
- invocationArguments: raw.slice(open + 1, close),
57
- invocationArgumentPlaceholderKeys: [...new Set(extractTemplatePlaceholders(raw.slice(open + 1, close)))],
70
+ invocationArguments: structure.firstParenthesisArguments,
71
+ invocationArgumentPlaceholderKeys:
72
+ structure.firstParenthesisPlaceholderKeys,
58
73
  normalizationReason: 'balanced_top_level_operation_invocation',
59
74
  };
60
75
  }
61
76
 
62
- export function classifyODataPathIntent(path: string | undefined, method: string | undefined): ODataPathIntent {
77
+ function normalizationRejection(
78
+ raw: string,
79
+ structure: ODataPathStructure,
80
+ ): string | undefined {
81
+ if (structure.status === 'malformed')
82
+ return structure.reason ?? 'path_structure_is_malformed';
83
+ if (structure.firstParenthesisOpen === undefined)
84
+ return 'no_top_level_parenthesis';
85
+ if (structure.queryIndex !== undefined)
86
+ return 'query_string_paths_are_not_operation_invocations';
87
+ if (!raw.startsWith('/')) return 'path_is_not_absolute';
88
+ if (structure.segments.length !== 1)
89
+ return 'operation_segment_contains_navigation_separator';
90
+ if (structure.firstSegmentHeadRuntimeDependent)
91
+ return 'operation_segment_is_runtime_dependent';
92
+ if (structure.firstParenthesisClose === undefined)
93
+ return 'top_level_invocation_parenthesis_is_unbalanced';
94
+ return undefined;
95
+ }
96
+
97
+ function rejectedNormalization(
98
+ raw: string,
99
+ reason: string,
100
+ ): NormalizedODataOperationPath {
101
+ return {
102
+ rawOperationPath: raw, normalizedOperationPath: raw,
103
+ wasInvocation: false, invocationArgumentPlaceholderKeys: [],
104
+ normalizationRejectedReason: reason,
105
+ };
106
+ }
107
+
108
+ export function classifyODataPathIntent(
109
+ path: string | undefined,
110
+ method: string | undefined,
111
+ ): ODataPathIntent {
63
112
  const rawPath = (path ?? '').trim();
64
113
  const normalizedMethod = (method ?? 'GET').trim().toUpperCase() || 'GET';
65
- const queryIndex = rawPath.indexOf('?');
66
- const pathWithoutQuery = queryIndex >= 0 ? rawPath.slice(0, queryIndex) : rawPath;
67
- const queryString = queryIndex >= 0 ? rawPath.slice(queryIndex + 1) : undefined;
68
- const segments = pathWithoutQuery.replace(/^\//, '').split('/').filter(Boolean);
69
- const firstSegment = segments[0] ?? '';
70
- const hasNavigationSegments = segments.length > 1;
71
- const entitySegment = entitySegmentFromPath(pathWithoutQuery);
72
- const placeholderKeys = [...new Set(extractTemplatePlaceholders(rawPath))];
73
- const firstOpen = firstSegment.indexOf('(');
74
- const firstClose = firstOpen >= 0 ? matchingClose(firstSegment, firstOpen) : undefined;
75
- const keyPredicateText = firstOpen >= 0 && firstClose !== undefined ? firstSegment.slice(firstOpen + 1, firstClose) : '';
76
- const rawKeyPredicatePlaceholderKeys = [...new Set(extractTemplatePlaceholders(keyPredicateText))];
77
- const navigationSuffix = hasNavigationSegments ? segments.slice(1).join('/') : undefined;
78
- const lastSegment = segments.at(-1) ?? '';
79
- const mediaOrPropertySuffix = hasNavigationSegments ? lastSegment : undefined;
80
- const invocation = normalizeODataOperationInvocationPath(pathWithoutQuery);
81
- const operationNameFromInvocation = invocation?.wasInvocation ? invocation.normalizedOperationPath.replace(/^\//, '').split('.').at(-1) : undefined;
82
- const topLevelName = firstSegment.split('(')[0]?.replace(/^\//, '');
83
- const topLevelOperationName = operationNameFromInvocation ?? (!hasNavigationSegments && !firstSegment.includes('(') ? topLevelName : undefined);
84
- const topLevelOperationInvocation = Boolean(invocation?.wasInvocation && looksLikeLowerCamelInvocation(firstSegment));
85
- const keyPredicatePlaceholderKeys = topLevelOperationInvocation ? [] : rawKeyPredicatePlaceholderKeys;
86
- const topLevelOperationNameCandidate = Boolean(topLevelOperationName && !hasNavigationSegments && !firstSegment.includes('('));
87
- const base = { rawPath, method: normalizedMethod, pathWithoutQuery, queryString, hasQueryString: queryIndex >= 0, entitySegment, placeholderKeys, keyPredicatePlaceholderKeys, invocationArguments: invocation?.invocationArguments, invocationArgumentPlaceholderKeys: invocation?.invocationArgumentPlaceholderKeys ?? [], navigationSuffix, mediaOrPropertySuffix, hasEntityKeyPredicate: firstOpen >= 0 && firstClose !== undefined, hasNavigationSuffix: hasNavigationSegments, hasMediaOrPropertySuffix: Boolean(mediaOrPropertySuffix && isMediaOrPropertySuffix(mediaOrPropertySuffix)), topLevelOperationName, topLevelOperationNameCandidate, topLevelOperationInvocation };
88
- if (!rawPath || !rawPath.startsWith('/')) return { ...base, kind: 'unknown', reason: 'path_missing_or_not_absolute' };
89
- const upperEntityLike = /^[A-Z][A-Za-z0-9_]*$/.test(entitySegment ?? firstSegment);
90
- const mediaLike = isMediaOrPropertySuffix(segments.at(-1) ?? '');
91
- if (normalizedMethod !== 'GET') {
92
- if (invocation?.wasInvocation && looksLikeLowerCamelInvocation(firstSegment)) return { ...base, kind: 'operation_invocation', reason: 'non_get_balanced_top_level_operation_invocation' };
93
- if (mediaLike) return { ...base, kind: 'entity_media', reason: 'non_get_entity_media_stream_path' };
94
- if (hasNavigationSegments || firstSegment.includes('(')) return { ...base, kind: normalizedMethod === 'DELETE' ? 'entity_delete' : 'entity_mutation', reason: firstSegment.includes('(') ? 'non_get_entity_key_or_navigation_path_shape' : 'non_get_entity_navigation_path_shape' };
95
- if (upperEntityLike) return { ...base, kind: normalizedMethod === 'DELETE' ? 'entity_delete' : 'entity_mutation', reason: 'non_get_entity_path_shape' };
96
- return { ...base, kind: 'operation_invocation', reason: 'non_get_lowercase_path_may_be_operation' };
97
- }
98
- if (queryIndex >= 0) {
99
- if (hasNavigationSegments) return { ...base, kind: 'entity_navigation_query', reason: 'get_path_has_navigation_and_query_string' };
100
- if (looksLikeLowerCamelInvocation(firstSegment)) return { ...base, kind: 'unknown', reason: 'get_invocation_with_query_string_requires_indexed_operation_evidence' };
101
- return { ...base, kind: 'entity_query', reason: 'get_collection_path_has_query_string' };
102
- }
103
- if (hasNavigationSegments) return mediaLike ? { ...base, kind: 'entity_media', reason: 'get_entity_media_stream_path' } : { ...base, kind: 'entity_navigation_query', reason: 'get_path_has_navigation_segments' };
104
- if (firstSegment.includes('(')) {
105
- if (invocation?.wasInvocation && looksLikeLowerCamelInvocation(firstSegment)) return { ...base, kind: 'operation_invocation', reason: 'get_balanced_top_level_operation_invocation' };
106
- return looksLikeLowerCamelInvocation(firstSegment)
107
- ? { ...base, kind: 'operation_invocation', reason: 'get_single_lower_camel_segment_has_top_level_invocation' }
108
- : { ...base, kind: 'entity_key_read', reason: 'get_entity_segment_has_key_predicate' };
109
- }
110
- if (/^[A-Z][A-Za-z0-9_]*$/.test(firstSegment)) return { ...base, kind: 'entity_candidate', reason: 'uppercase_collection_segment_without_indexed_entity_evidence' };
111
- return { ...base, kind: 'unknown', reason: 'get_path_has_no_query_key_or_navigation_signal' };
112
- }
113
-
114
- function entitySegmentFromPath(path: string): string | undefined {
115
- const first = path.replace(/^\//, '').split('/')[0]?.trim();
116
- if (!first) return undefined;
117
- const open = first.indexOf('(');
118
- const entity = (open >= 0 ? first.slice(0, open) : first).trim();
119
- return entity || undefined;
114
+ const structure = analyzeODataPathStructure(rawPath);
115
+ const invocation = normalizeODataOperationInvocationPath(
116
+ structure.pathWithoutQuery,
117
+ );
118
+ const base = intentBase(structure, normalizedMethod, invocation);
119
+ if (!rawPath || !rawPath.startsWith('/'))
120
+ return { ...base, kind: 'unknown', reason: 'path_missing_or_not_absolute' };
121
+ if (structure.status === 'malformed')
122
+ return {
123
+ ...base, kind: 'unknown',
124
+ reason: structure.reason ?? 'path_structure_is_malformed',
125
+ };
126
+ if (structure.firstSegmentHeadRuntimeDependent)
127
+ return {
128
+ ...base, kind: 'unknown',
129
+ reason: 'path_classification_head_is_runtime_dependent',
130
+ };
131
+ return normalizedMethod === 'GET'
132
+ ? classifyGet(structure, base, invocation)
133
+ : classifyMutation(structure, base, invocation, normalizedMethod);
120
134
  }
121
135
 
122
- function isMediaOrPropertySuffix(segment: string): boolean {
123
- return ['file', 'content', '$value', 'metadata', 'items'].includes(segment.toLowerCase());
136
+ function intentBase(
137
+ structure: ODataPathStructure,
138
+ method: string,
139
+ invocation: NormalizedODataOperationPath | undefined,
140
+ ): Omit<ODataPathIntent, 'kind' | 'reason'> {
141
+ const segments = structure.segments;
142
+ const navigation = navigationEvidence(segments);
143
+ const operation = operationEvidence(structure, invocation, navigation.navigation);
144
+ return {
145
+ rawPath: structure.rawPath, method,
146
+ pathWithoutQuery: structure.pathWithoutQuery,
147
+ queryString: structure.queryString,
148
+ hasQueryString: structure.queryIndex !== undefined,
149
+ entitySegment: structure.firstSegmentHead,
150
+ placeholderKeys: structure.placeholderKeys,
151
+ keyPredicatePlaceholderKeys: operation.invocation
152
+ ? [] : structure.firstParenthesisPlaceholderKeys,
153
+ invocationArgumentPlaceholderKeys:
154
+ invocation?.invocationArgumentPlaceholderKeys ?? [],
155
+ invocationArguments: invocation?.invocationArguments,
156
+ navigationSuffix: navigation.navigationSuffix,
157
+ mediaOrPropertySuffix: navigation.suffix,
158
+ hasEntityKeyPredicate: structure.firstParenthesisOpen !== undefined,
159
+ hasNavigationSuffix: navigation.navigation,
160
+ hasMediaOrPropertySuffix: Boolean(
161
+ navigation.suffix && isMediaOrPropertySuffix(navigation.suffix),
162
+ ),
163
+ topLevelOperationName: operation.name,
164
+ topLevelOperationNameCandidate:
165
+ operationNameIsCandidate(operation, structure, navigation.navigation),
166
+ topLevelOperationInvocation: operation.invocation,
167
+ };
124
168
  }
125
169
 
126
- function looksLikeLowerCamelInvocation(segment: string): boolean {
127
- const open = segment.indexOf('(');
128
- if (open <= 0) return false;
129
- const name = segment.slice(0, open).split('.').at(-1) ?? segment.slice(0, open);
130
- return /^[a-z][A-Za-z0-9_]*$/.test(name);
170
+ function navigationEvidence(
171
+ segments: readonly { text: string }[],
172
+ ): NavigationEvidence {
173
+ if (segments.length <= 1) return { navigation: false };
174
+ return {
175
+ navigation: true,
176
+ suffix: segments.at(-1)?.text,
177
+ navigationSuffix: segments.slice(1).map((item) => item.text).join('/'),
178
+ };
131
179
  }
132
180
 
133
- function extractTemplatePlaceholders(text: string): string[] {
134
- const keys: string[] = [];
135
- for (let index = 0; index < text.length - 1; index += 1) {
136
- if (text[index] !== '$' || text[index + 1] !== '{') continue;
137
- const close = matchingPlaceholderClose(text, index + 1);
138
- if (close === undefined) continue;
139
- const key = text.slice(index + 2, close).trim();
140
- if (key) keys.push(key);
141
- index = close;
142
- }
143
- return keys;
144
- }
145
-
146
- function matchingClose(text: string, openIndex: number): number | undefined {
147
- let depth = 0;
148
- let quote: 'single' | 'double' | 'template' | undefined;
149
- for (let index = openIndex; index < text.length; index += 1) {
150
- const char = text[index];
151
- const prev = text[index - 1];
152
- if (quote) {
153
- if (prev === '\\') continue;
154
- if (quote === 'template' && char === '$' && text[index + 1] === '{') {
155
- const close = matchingPlaceholderClose(text, index + 1);
156
- if (close === undefined) return undefined;
157
- index = close;
158
- continue;
159
- }
160
- if ((quote === 'single' && char === "'") || (quote === 'double' && char === '"') || (quote === 'template' && char === '`')) quote = undefined;
161
- continue;
162
- }
163
- if (char === '$' && text[index + 1] === '{') {
164
- const close = matchingPlaceholderClose(text, index + 1);
165
- if (close === undefined) return undefined;
166
- index = close;
167
- continue;
168
- }
169
- if (char === "'") { quote = 'single'; continue; }
170
- if (char === '"') { quote = 'double'; continue; }
171
- if (char === '`') { quote = 'template'; continue; }
172
- if (char === '(') depth += 1;
173
- if (char === ')') {
174
- depth -= 1;
175
- if (depth === 0) return index;
176
- if (depth < 0) return undefined;
177
- }
178
- }
179
- return undefined;
181
+ function operationEvidence(
182
+ structure: ODataPathStructure,
183
+ normalized: NormalizedODataOperationPath | undefined,
184
+ navigation: boolean,
185
+ ): OperationEvidence {
186
+ if (normalized?.wasInvocation)
187
+ return {
188
+ invocation: looksLikeLowerCamelHead(structure.firstSegmentHead),
189
+ name: simpleName(normalized.normalizedOperationPath),
190
+ };
191
+ if (!navigation && structure.firstParenthesisOpen === undefined)
192
+ return { invocation: false, name: structure.firstSegmentHead };
193
+ return { invocation: false };
180
194
  }
181
195
 
182
- function matchingPlaceholderClose(text: string, openBraceIndex: number): number | undefined {
183
- let depth = 0;
184
- let quote: 'single' | 'double' | 'template' | undefined;
185
- for (let index = openBraceIndex; index < text.length; index += 1) {
186
- const char = text[index];
187
- const prev = text[index - 1];
188
- if (quote) {
189
- if (prev === '\\') continue;
190
- if (quote === 'template' && char === '$' && text[index + 1] === '{') {
191
- depth += 1;
192
- index += 1;
193
- continue;
194
- }
195
- if ((quote === 'single' && char === "'") || (quote === 'double' && char === '"') || (quote === 'template' && char === '`')) quote = undefined;
196
- continue;
197
- }
198
- if (char === "'") { quote = 'single'; continue; }
199
- if (char === '"') { quote = 'double'; continue; }
200
- if (char === '`') { quote = 'template'; continue; }
201
- if (char === '{') depth += 1;
202
- if (char === '}') {
203
- depth -= 1;
204
- if (depth === 0) return index;
205
- if (depth < 0) return undefined;
206
- }
207
- }
208
- return undefined;
196
+ function operationNameIsCandidate(
197
+ operation: OperationEvidence,
198
+ structure: ODataPathStructure,
199
+ navigation: boolean,
200
+ ): boolean {
201
+ return Boolean(
202
+ operation.name && !navigation
203
+ && structure.firstParenthesisOpen === undefined,
204
+ );
205
+ }
206
+
207
+ function classifyGet(
208
+ structure: ODataPathStructure,
209
+ base: Omit<ODataPathIntent, 'kind' | 'reason'>,
210
+ invocation: NormalizedODataOperationPath | undefined,
211
+ ): ODataPathIntent {
212
+ if (structure.queryIndex !== undefined) return classifyGetQuery(base);
213
+ if (base.hasNavigationSuffix)
214
+ return classifyGetNavigation(base);
215
+ if (structure.firstParenthesisOpen !== undefined)
216
+ return classifyGetParenthesis(base, invocation);
217
+ if (isUpperEntity(base.entitySegment))
218
+ return {
219
+ ...base, kind: 'entity_candidate',
220
+ reason: 'uppercase_collection_segment_without_indexed_entity_evidence',
221
+ };
222
+ return {
223
+ ...base, kind: 'unknown',
224
+ reason: 'get_path_has_no_query_key_or_navigation_signal',
225
+ };
226
+ }
227
+
228
+ function classifyGetQuery(
229
+ base: Omit<ODataPathIntent, 'kind' | 'reason'>,
230
+ ): ODataPathIntent {
231
+ if (base.hasNavigationSuffix)
232
+ return {
233
+ ...base, kind: 'entity_navigation_query',
234
+ reason: 'get_path_has_navigation_and_query_string',
235
+ };
236
+ if (base.topLevelOperationInvocation)
237
+ return {
238
+ ...base, kind: 'unknown',
239
+ reason: 'get_invocation_with_query_string_requires_indexed_operation_evidence',
240
+ };
241
+ return {
242
+ ...base, kind: 'entity_query',
243
+ reason: 'get_collection_path_has_query_string',
244
+ };
245
+ }
246
+
247
+ function classifyGetNavigation(
248
+ base: Omit<ODataPathIntent, 'kind' | 'reason'>,
249
+ ): ODataPathIntent {
250
+ return base.hasMediaOrPropertySuffix
251
+ ? { ...base, kind: 'entity_media', reason: 'get_entity_media_stream_path' }
252
+ : {
253
+ ...base, kind: 'entity_navigation_query',
254
+ reason: 'get_path_has_navigation_segments',
255
+ };
256
+ }
257
+
258
+ function classifyGetParenthesis(
259
+ base: Omit<ODataPathIntent, 'kind' | 'reason'>,
260
+ invocation: NormalizedODataOperationPath | undefined,
261
+ ): ODataPathIntent {
262
+ if (invocation?.wasInvocation && base.topLevelOperationInvocation)
263
+ return {
264
+ ...base, kind: 'operation_invocation',
265
+ reason: 'get_balanced_top_level_operation_invocation',
266
+ };
267
+ return {
268
+ ...base, kind: 'entity_key_read',
269
+ reason: 'get_entity_segment_has_key_predicate',
270
+ };
271
+ }
272
+
273
+ function classifyMutation(
274
+ structure: ODataPathStructure,
275
+ base: Omit<ODataPathIntent, 'kind' | 'reason'>,
276
+ invocation: NormalizedODataOperationPath | undefined,
277
+ method: string,
278
+ ): ODataPathIntent {
279
+ if (invocation?.wasInvocation && base.topLevelOperationInvocation)
280
+ return {
281
+ ...base, kind: 'operation_invocation',
282
+ reason: 'non_get_balanced_top_level_operation_invocation',
283
+ };
284
+ if (base.hasMediaOrPropertySuffix)
285
+ return {
286
+ ...base, kind: 'entity_media',
287
+ reason: 'non_get_entity_media_stream_path',
288
+ };
289
+ if (base.hasNavigationSuffix || structure.firstParenthesisOpen !== undefined)
290
+ return mutationEntityIntent(base, method,
291
+ structure.firstParenthesisOpen !== undefined
292
+ ? 'non_get_entity_key_or_navigation_path_shape'
293
+ : 'non_get_entity_navigation_path_shape');
294
+ if (isUpperEntity(base.entitySegment))
295
+ return mutationEntityIntent(base, method, 'non_get_entity_path_shape');
296
+ return {
297
+ ...base, kind: 'operation_invocation',
298
+ reason: 'non_get_lowercase_path_may_be_operation',
299
+ };
209
300
  }
210
301
 
211
- function topLevelQueryIndex(text: string): number {
212
- let quote: 'single' | 'double' | 'template' | undefined;
213
- for (let index = 0; index < text.length; index += 1) {
214
- const char = text[index];
215
- const prev = text[index - 1];
216
- if (quote) {
217
- if (prev === '\\') continue;
218
- if (quote === 'template' && char === '$' && text[index + 1] === '{') {
219
- const close = matchingPlaceholderClose(text, index + 1);
220
- if (close === undefined) return -1;
221
- index = close;
222
- continue;
223
- }
224
- if ((quote === 'single' && char === "'") || (quote === 'double' && char === '"') || (quote === 'template' && char === '`')) quote = undefined;
225
- continue;
226
- }
227
- if (char === '$' && text[index + 1] === '{') {
228
- const close = matchingPlaceholderClose(text, index + 1);
229
- if (close === undefined) return -1;
230
- index = close;
231
- continue;
232
- }
233
- if (char === "'") { quote = 'single'; continue; }
234
- if (char === '"') { quote = 'double'; continue; }
235
- if (char === '`') { quote = 'template'; continue; }
236
- if (char === '?') return index;
237
- }
238
- return -1;
302
+ function mutationEntityIntent(
303
+ base: Omit<ODataPathIntent, 'kind' | 'reason'>,
304
+ method: string,
305
+ reason: string,
306
+ ): ODataPathIntent {
307
+ return {
308
+ ...base,
309
+ kind: method === 'DELETE' ? 'entity_delete' : 'entity_mutation',
310
+ reason,
311
+ };
312
+ }
313
+
314
+ function simpleName(path: string): string {
315
+ const value = path.replace(/^\//, '');
316
+ return value.split('.').at(-1) ?? value;
317
+ }
318
+
319
+ function isUpperEntity(value: string | undefined): boolean {
320
+ return Boolean(value && /^[A-Z][A-Za-z0-9_]*$/.test(value));
321
+ }
322
+
323
+ function looksLikeLowerCamelHead(value: string | undefined): boolean {
324
+ if (!value) return false;
325
+ const name = value.split('.').at(-1) ?? value;
326
+ return /^[a-z][A-Za-z0-9_]*$/.test(name);
327
+ }
328
+
329
+ function isMediaOrPropertySuffix(segment: string): boolean {
330
+ return ['file', 'content', '$value', 'metadata', 'items']
331
+ .includes(segment.toLowerCase());
239
332
  }