@saptools/service-flow 0.1.51 → 0.1.53

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 (56) hide show
  1. package/CHANGELOG.md +13 -0
  2. package/README.md +16 -14
  3. package/TECHNICAL-NOTE.md +18 -6
  4. package/dist/{chunk-YZJKE5UX.js → chunk-LFH7C46B.js} +4290 -1261
  5. package/dist/chunk-LFH7C46B.js.map +1 -0
  6. package/dist/cli.js +435 -515
  7. package/dist/cli.js.map +1 -1
  8. package/dist/index.d.ts +45 -7
  9. package/dist/index.js +1 -1
  10. package/package.json +1 -1
  11. package/src/cli/000-clean.ts +82 -0
  12. package/src/cli/001-doctor-projection.ts +140 -0
  13. package/src/cli/doctor.ts +20 -3
  14. package/src/cli.ts +75 -57
  15. package/src/db/connection.ts +1 -1
  16. package/src/db/migrations.ts +5 -3
  17. package/src/db/repositories.ts +130 -24
  18. package/src/db/schema.ts +7 -2
  19. package/src/indexer/repository-indexer.ts +57 -29
  20. package/src/indexer/workspace-indexer.ts +84 -6
  21. package/src/linker/000-implementation-candidates.ts +641 -0
  22. package/src/linker/001-implementation-evidence-projection.ts +119 -0
  23. package/src/linker/002-call-evidence.ts +226 -0
  24. package/src/linker/cross-repo-linker.ts +27 -441
  25. package/src/linker/dynamic-edge-resolver.ts +35 -0
  26. package/src/linker/external-http-target.ts +24 -3
  27. package/src/linker/helper-package-linker.ts +18 -2
  28. package/src/linker/service-resolver.ts +45 -2
  29. package/src/output/doctor-output.ts +13 -4
  30. package/src/output/table-output.ts +33 -5
  31. package/src/parsers/cds-parser.ts +8 -2
  32. package/src/parsers/decorator-parser.ts +382 -48
  33. package/src/parsers/handler-registration-parser.ts +38 -21
  34. package/src/parsers/imported-wrapper-parser.ts +18 -5
  35. package/src/parsers/outbound-call-parser.ts +25 -8
  36. package/src/parsers/package-json-parser.ts +36 -11
  37. package/src/parsers/service-binding-parser-helpers.ts +8 -1
  38. package/src/parsers/service-binding-parser.ts +6 -3
  39. package/src/parsers/symbol-parser.ts +13 -3
  40. package/src/parsers/ts-project.ts +54 -0
  41. package/src/trace/000-dynamic-target-types.ts +96 -0
  42. package/src/trace/001-dynamic-identity.ts +308 -0
  43. package/src/trace/002-trace-diagnostics.ts +54 -0
  44. package/src/trace/003-dynamic-references.ts +283 -0
  45. package/src/trace/004-dynamic-candidate-sources.ts +155 -0
  46. package/src/trace/005-implementation-selection.ts +187 -0
  47. package/src/trace/006-contextual-projection.ts +30 -0
  48. package/src/trace/007-implementation-start-diagnostic.ts +61 -0
  49. package/src/trace/dynamic-targets.ts +582 -306
  50. package/src/trace/evidence.ts +331 -46
  51. package/src/trace/implementation-hints.ts +148 -8
  52. package/src/trace/selectors.ts +551 -3
  53. package/src/trace/trace-engine.ts +129 -135
  54. package/src/types.ts +27 -1
  55. package/src/utils/000-bounded-projection.ts +161 -0
  56. package/dist/chunk-YZJKE5UX.js.map +0 -1
@@ -1,15 +1,34 @@
1
1
  import type { Db } from '../db/connection.js';
2
+ import { reposByName } from '../db/repositories.js';
2
3
  import { extractPlaceholders } from '../linker/dynamic-edge-resolver.js';
3
4
  import { normalizeODataOperationInvocationPath } from '../linker/odata-path-normalizer.js';
4
5
  import { resolveOperation } from '../linker/service-resolver.js';
5
6
  import type { ImplementationHint, TraceEdge, TraceOptions, TraceResult, TraceStart } from '../types.js';
6
- import { baseTraceEvidence, edgeTarget, runtimeResolution, runtimeVariableDiagnostic, type TraceGraphRow } from './evidence.js';
7
+ import { baseTraceEvidence, edgeTarget, runtimeNoCandidateDiagnostics, runtimeResolution, runtimeVariableDiagnostic, type TraceGraphRow } from './evidence.js';
7
8
  import { dynamicCandidateBranches } from './dynamic-branches.js';
8
- import { implementationHintDiagnostic, selectImplementation, type ImplementationSelection } from './implementation-hints.js';
9
- import { ambiguousStartDiagnostic } from './selectors.js';
9
+ import {
10
+ loadTraceDiagnostics,
11
+ prependTraceDiagnostic,
12
+ } from './002-trace-diagnostics.js';
13
+ import { implementationHintDiagnostic } from './implementation-hints.js';
14
+ import {
15
+ contextualImplementationSelection,
16
+ hintedImplementationSelection,
17
+ } from './005-implementation-selection.js';
18
+ import { boundedContextCandidates } from './006-contextual-projection.js';
19
+ import { implementationStartDiagnostic } from './007-implementation-start-diagnostic.js';
20
+ import { boundCandidateLikeEvidence } from '../utils/000-bounded-projection.js';
21
+ import {
22
+ ambiguousStartDiagnostic,
23
+ selectorNotFoundDiagnostic,
24
+ selectorRepoAmbiguousDiagnostic,
25
+ selectorRepoNotFoundDiagnostic,
26
+ sourceScopeForSelector,
27
+ } from './selectors.js';
10
28
  interface RepoRef {
11
29
  id: number;
12
30
  name: string;
31
+ packageName?: string;
13
32
  }
14
33
  interface StartScope {
15
34
  repo?: RepoRef;
@@ -78,13 +97,14 @@ function normalizeOperation(value: string | undefined): string | undefined {
78
97
  function positiveDepth(value: number): number {
79
98
  return Number.isFinite(value) && value > 0 ? Math.floor(value) : 25;
80
99
  }
81
- function operationStartScope(db: Db, repoId: number | undefined, start: TraceStart, hintOptions: ImplementationHintOptions): { files?: Set<string>; symbols?: Set<number>; repoId?: number; operationId?: string; diagnostics?: Array<Record<string, unknown>> } | undefined {
100
+ function operationStartScope(db: Db, repoId: number | undefined, start: TraceStart, hintOptions: ImplementationHintOptions, workspaceId?: number): { files?: Set<string>; symbols?: Set<number>; repoId?: number; operationId?: string; diagnostics?: Array<Record<string, unknown>> } | undefined {
82
101
  const requested = normalizeOperation(start.operationPath ?? start.operation);
83
102
  if (!requested) return undefined;
84
103
  const rows = db.prepare(`SELECT o.id operationId,o.operation_name operationName,o.operation_path operationPath,o.source_file sourceFile,o.source_line sourceLine,s.service_path servicePath,r.id repoId,r.name repoName
85
104
  FROM cds_operations o JOIN cds_services s ON s.id=o.service_id JOIN repositories r ON r.id=s.repo_id
86
- WHERE (? IS NULL OR r.id=?) AND (? IS NULL OR s.service_path=?) AND (o.operation_name=? OR o.operation_path=? OR o.operation_path=?)
87
- ORDER BY r.name,s.service_path,o.operation_name,o.id`).all(repoId, repoId, start.servicePath, start.servicePath, requested, requested, requested.startsWith('/') ? requested : `/${requested}`) as Array<Record<string, unknown>>;
105
+ WHERE (? IS NULL OR r.workspace_id=?) AND (? IS NULL OR r.id=?)
106
+ AND (? IS NULL OR s.service_path=?) AND (o.operation_name=? OR o.operation_path=? OR o.operation_path=?)
107
+ ORDER BY r.name,s.service_path,o.operation_name,o.id`).all(workspaceId, workspaceId, repoId, repoId, start.servicePath, start.servicePath, requested, requested, requested.startsWith('/') ? requested : `/${requested}`) as Array<Record<string, unknown>>;
88
108
  if (rows.length === 0) return undefined;
89
109
  const repoCount = new Set(rows.map((row) => String(row.repoName))).size;
90
110
  const serviceCount = new Set(rows.map((row) => `${String(row.repoName)}:${String(row.servicePath)}`)).size;
@@ -97,89 +117,52 @@ function operationStartScope(db: Db, repoId: number | undefined, start: TraceSta
97
117
  const operationId = String(rows[0]?.operationId);
98
118
  const impl = implementationScope(db, operationId);
99
119
  if (impl.edge?.status === 'resolved' && impl.files.size > 0) return { files: impl.files, symbols: impl.symbolId ? new Set([impl.symbolId]) : undefined, repoId: impl.repoId, operationId, diagnostics: [] };
100
- const hinted = implementationMethodIdFromHint(impl.edge, hintOptions);
120
+ const hinted = hintedImplementationSelection(
121
+ db, impl.edge, operationId, hintOptions,
122
+ );
101
123
  if (hinted.methodId) {
102
124
  const hintedScope = handlerScope(db, hinted.methodId);
103
125
  if (hintedScope?.files.size) return { files: hintedScope.files, symbols: hintedScope.symbolId ? new Set([hintedScope.symbolId]) : undefined, repoId: hintedScope.repoId, operationId, diagnostics: [] };
104
126
  }
105
127
  if (impl.edge) {
106
128
  const evidence = parseEvidence(impl.edge.evidence_json);
107
- const hintDiagnostic = implementationHintDiagnostic(hinted, evidence.implementationHintSuggestions);
108
- const diagnostics = [{ severity: 'warning', code: impl.edge.status === 'ambiguous' ? 'trace_start_ambiguous' : 'trace_start_implementation_unresolved', message: `Indexed operation matched but implementation edge is ${String(impl.edge.status ?? 'unresolved')}`, resolutionStage: 'implementation', resolutionStatus: impl.edge.status === 'ambiguous' ? 'ambiguous_implementation' : 'rejected_implementation', implementationEdgeId: impl.edge.id, implementationStatus: impl.edge.status, implementationAmbiguityReasons: evidence.ambiguityReasons, implementationRejectionReasons: implementationRejectionReasons(evidence), implementationHintSuggestions: evidence.implementationHintSuggestions, candidates: evidence.candidates }];
129
+ const hintDiagnostic = implementationHintDiagnostic(hinted, evidence);
130
+ const diagnostics = [implementationStartDiagnostic(impl.edge, evidence)];
109
131
  return { operationId, diagnostics: hintDiagnostic ? [hintDiagnostic, ...diagnostics] : diagnostics };
110
132
  }
111
133
  return { operationId, diagnostics: [{ severity: 'warning', code: 'trace_start_implementation_unresolved', message: 'Indexed operation matched but no implementation candidate exists', resolutionStage: 'implementation', resolutionStatus: 'operation_without_implementation' }] };
112
134
  }
113
- function implementationRejectionReasons(evidence: Record<string, unknown>): string[] {
114
- const candidates = Array.isArray(evidence.candidates)
115
- ? evidence.candidates.filter((item): item is Record<string, unknown> =>
116
- Boolean(item && typeof item === 'object' && !Array.isArray(item)))
117
- : [];
118
- const reasons = candidates.flatMap((candidate) =>
119
- Array.isArray(candidate.rejectedReasons)
120
- ? candidate.rejectedReasons.filter((reason): reason is string =>
121
- typeof reason === 'string')
122
- : []);
123
- return [...new Set(reasons)].sort();
124
- }
125
135
  function sourceFilesForStart(
126
136
  db: Db,
127
137
  repoId: number | undefined,
128
138
  start: TraceStart,
129
- ): { files?: Set<string>; symbols?: Set<number>; repoId?: number } | undefined {
130
- const handler = start.handler;
131
- const operation = normalizeOperation(start.operation ?? start.operationPath);
132
- if (!handler && !operation) return undefined;
133
- const rows = db
134
- .prepare(
135
- `SELECT DISTINCT hc.source_file sourceFile,s.id symbolId
136
- FROM handler_classes hc LEFT JOIN handler_methods hm ON hm.handler_class_id=hc.id LEFT JOIN symbols s ON s.repo_id=hc.repo_id AND s.source_file=hc.source_file AND s.name=hm.method_name
137
- WHERE (? IS NULL OR hc.repo_id=?) AND (? IS NULL OR hc.class_name=? OR hm.method_name=?)
138
- AND (? IS NULL OR hm.decorator_value=? OR hm.method_name=?)
139
- AND (? IS NULL OR EXISTS (SELECT 1 FROM cds_services s JOIN cds_operations o ON o.service_id=s.id WHERE s.repo_id=hc.repo_id AND s.service_path=? AND (? IS NULL OR o.operation_path=? OR o.operation_name=? OR hm.decorator_value=? OR hm.method_name=?)))`,
140
- )
141
- .all(
142
- repoId,
143
- repoId,
144
- handler,
145
- handler,
146
- handler,
147
- operation,
148
- operation,
149
- operation,
150
- start.servicePath,
151
- start.servicePath,
152
- operation,
153
- operation,
154
- operation,
155
- operation,
156
- operation,
157
- ) as Array<{ sourceFile?: string; symbolId?: number }>;
158
- if (rows.length > 0) return { files: new Set(rows.map((row) => row.sourceFile).filter(Boolean) as string[]), symbols: new Set(rows.map((row) => Number(row.symbolId)).filter(Boolean)), repoId };
159
- if (start.servicePath && operation) {
160
- const implRows = db.prepare(`SELECT DISTINCT hc.source_file sourceFile,sym.id symbolId
161
- FROM cds_services s JOIN cds_operations o ON o.service_id=s.id
162
- JOIN graph_edges e ON e.edge_type='OPERATION_IMPLEMENTED_BY_HANDLER' AND e.status='resolved' AND e.from_kind='operation' AND e.from_id=CAST(o.id AS TEXT)
163
- JOIN handler_methods hm ON hm.id=CAST(e.to_id AS INTEGER)
164
- JOIN handler_classes hc ON hc.id=hm.handler_class_id
165
- LEFT JOIN symbols sym ON sym.repo_id=hc.repo_id AND sym.source_file=hc.source_file AND sym.name=hm.method_name
166
- WHERE (? IS NULL OR s.repo_id=?) AND s.service_path=? AND (o.operation_path=? OR o.operation_name=?)`).all(repoId, repoId, start.servicePath, operation, operation) as Array<{ sourceFile?: string; symbolId?: number }>;
167
- if (implRows.length > 0) return { files: new Set(implRows.map((row) => row.sourceFile).filter(Boolean) as string[]), symbols: new Set(implRows.map((row) => Number(row.symbolId)).filter(Boolean)), repoId };
168
- }
169
- return undefined;
170
- }
171
- function startScope(db: Db, start: TraceStart, hintOptions: ImplementationHintOptions): StartScope {
172
- const repo = start.repo
173
- ? (db
174
- .prepare(
175
- 'SELECT id,name FROM repositories WHERE name=? OR package_name=?',
176
- )
177
- .get(start.repo, start.repo) as RepoRef | undefined)
178
- : undefined;
179
- if (start.repo && !repo) return { repo, selectorMatched: false };
180
- const operationScope = operationStartScope(db, repo?.id, start, hintOptions);
139
+ workspaceId: number | undefined,
140
+ ): ReturnType<typeof sourceScopeForSelector> {
141
+ return sourceScopeForSelector(db, repoId, start, workspaceId);
142
+ }
143
+ function startScope(db: Db, start: TraceStart, hintOptions: ImplementationHintOptions, workspaceId?: number): StartScope {
144
+ const repos: RepoRef[] = start.repo
145
+ ? reposByName(db, start.repo, workspaceId).map((row) => ({
146
+ id: row.id,
147
+ name: row.name,
148
+ packageName: row.package_name ?? undefined,
149
+ }))
150
+ : [];
151
+ if (start.repo && repos.length === 0) return {
152
+ selectorMatched: false,
153
+ startDiagnostics: [selectorRepoNotFoundDiagnostic(start.repo)],
154
+ };
155
+ if (start.repo && repos.length > 1) return {
156
+ selectorMatched: false,
157
+ startDiagnostics: [selectorRepoAmbiguousDiagnostic(start.repo, repos)],
158
+ };
159
+ const repo = repos[0];
160
+ const operationScope = operationStartScope(
161
+ db, repo?.id, start, hintOptions, workspaceId,
162
+ );
181
163
  const terminalOperationScope = operationScope && !operationScope.files && (operationScope.diagnostics ?? []).some((d) => d.resolutionStage === 'operation' || d.resolutionStage === 'implementation');
182
- const sourceScope = operationScope?.files || terminalOperationScope ? operationScope : sourceFilesForStart(db, repo?.id, start);
164
+ const sourceScope = operationScope?.files || terminalOperationScope ? operationScope : sourceFilesForStart(db, repo?.id, start, workspaceId);
165
+ const terminalSelectorScope = Boolean(sourceScope?.diagnostics?.length && !sourceScope.files);
183
166
  const sourceFiles = sourceScope?.files;
184
167
  const hasSelector = Boolean(
185
168
  start.handler ?? start.operation ?? start.operationPath ?? start.servicePath,
@@ -191,9 +174,12 @@ function startScope(db: Db, start: TraceStart, hintOptions: ImplementationHintOp
191
174
  executionRepoId: sourceScope?.repoId ?? repo?.id,
192
175
  sourceFiles,
193
176
  symbolIds: sourceScope?.symbols,
194
- selectorMatched: !terminalOperationScope && (!hasSelector || sourceFiles !== undefined),
177
+ selectorMatched: !terminalOperationScope && !terminalSelectorScope
178
+ && (!hasSelector || sourceFiles !== undefined),
195
179
  startOperationId: operationScope?.operationId,
196
- startDiagnostics: operationScope?.diagnostics,
180
+ startDiagnostics: operationScope?.diagnostics?.length
181
+ ? operationScope.diagnostics
182
+ : sourceScope?.diagnostics,
197
183
  };
198
184
  }
199
185
  function handlerFilesForOperation(db: Db, operationId: string): Set<string> {
@@ -211,8 +197,19 @@ function handlerFilesForOperation(db: Db, operationId: string): Set<string> {
211
197
  .prepare(
212
198
  `SELECT DISTINCT hc.source_file sourceFile,sym.id symbolId FROM handler_classes hc
213
199
  JOIN handler_methods hm ON hm.handler_class_id=hc.id
214
- LEFT JOIN symbols sym ON sym.repo_id=hc.repo_id AND sym.source_file=hc.source_file AND sym.name=hm.method_name
215
- WHERE hc.repo_id=? AND (hm.decorator_value=? OR hm.method_name=? OR hm.decorator_value=?)`,
200
+ LEFT JOIN symbols sym ON sym.repo_id=hc.repo_id
201
+ AND sym.source_file=hc.source_file
202
+ AND sym.qualified_name=hc.class_name || '.' || hm.method_name
203
+ AND sym.start_line=hm.source_line
204
+ WHERE hc.repo_id=?
205
+ AND COALESCE(json_extract(hm.decorator_resolution_json,'$.handlerKind'),
206
+ CASE WHEN hm.decorator_kind='Event' THEN 'event'
207
+ WHEN hm.decorator_kind IN ('Action','Func','On') THEN 'operation'
208
+ ELSE 'unsupported' END)='operation'
209
+ AND COALESCE(json_extract(hm.decorator_resolution_json,'$.executable'),
210
+ CASE WHEN hm.decorator_kind IN ('Action','Func','On')
211
+ THEN 1 ELSE 0 END)=1
212
+ AND (hm.decorator_value=? OR hm.method_name=? OR hm.decorator_value=?)`,
216
213
  )
217
214
  .all(op.repoId, operation, operation, op.operationName) as Array<{
218
215
  sourceFile?: string;
@@ -230,35 +227,14 @@ function handlerMethodNode(db: Db, methodId: string): Record<string, unknown> |
230
227
  function implementationScope(db: Db, operationId: string): { repoId?: number; files: Set<string>; symbolId?: number; edge?: GraphRow } {
231
228
  const edge = implementationEdge(db, operationId);
232
229
  if (!edge || edge.status !== 'resolved') return { files: new Set(), edge };
233
- const row = db.prepare('SELECT hc.repo_id repoId,hc.source_file sourceFile,s.id symbolId FROM handler_methods hm JOIN handler_classes hc ON hc.id=hm.handler_class_id LEFT JOIN symbols s ON s.repo_id=hc.repo_id AND s.source_file=hc.source_file AND s.name=hm.method_name WHERE hm.id=?').get(edge.to_id) as { repoId?: number; sourceFile?: string; symbolId?: number } | undefined;
230
+ const row = db.prepare("SELECT hc.repo_id repoId,hc.source_file sourceFile,s.id symbolId FROM handler_methods hm JOIN handler_classes hc ON hc.id=hm.handler_class_id LEFT JOIN symbols s ON s.repo_id=hc.repo_id AND s.source_file=hc.source_file AND s.qualified_name=hc.class_name || '.' || hm.method_name AND s.start_line=hm.source_line WHERE hm.id=?").get(edge.to_id) as { repoId?: number; sourceFile?: string; symbolId?: number } | undefined;
231
+ if (!row || typeof row.symbolId !== 'number')
232
+ return { repoId: row?.repoId, files: new Set(), edge };
234
233
  return { repoId: row?.repoId, files: new Set(row?.sourceFile ? [row.sourceFile] : []), symbolId: row?.symbolId, edge };
235
234
  }
236
- function implementationMethodIdFromHint(edge: GraphRow | undefined, options: ImplementationHintOptions): ImplementationSelection {
237
- if (!edge || edge.status !== 'ambiguous') return { blocksAutomatic: false, evidence: { status: 'not_applicable' } };
238
- return selectImplementation(parseEvidence(edge.evidence_json), options.implementationHints, options.implementationRepo);
239
- }
240
- function contextImplementationMethodId(edge: GraphRow | undefined, callerRepoId: number | undefined, remoteEvidence: Record<string, unknown>, hintOptions: ImplementationHintOptions): ImplementationSelection {
241
- const hinted = implementationMethodIdFromHint(edge, hintOptions);
242
- if (hinted.methodId) return hinted;
243
- if (hinted.blocksAutomatic) return hinted;
244
- if (!edge || edge.status !== 'ambiguous' || callerRepoId === undefined) return hinted;
245
- const evidence = JSON.parse(String(edge.evidence_json || '{}')) as { candidates?: Array<{ accepted?: boolean; methodId?: number; handlerPackage?: { id?: number; name?: string }; applicationPackage?: { id?: number; name?: string }; reasons?: string[]; score?: number }> };
246
- const scores = (evidence.candidates ?? []).filter((item) => item.accepted).map((item) => {
247
- const reasons: string[] = [];
248
- let score = Number(item.score ?? 0);
249
- if (Number(item.handlerPackage?.id) === callerRepoId) { score += 10; reasons.push('handler_package_matches_caller_repository'); }
250
- if (Number(item.applicationPackage?.id) === callerRepoId) { score += 10; reasons.push('registration_package_matches_caller_repository'); }
251
- if (typeof remoteEvidence.effectiveServicePath === 'string' || typeof remoteEvidence.effectiveDestination === 'string' || typeof remoteEvidence.effectiveAlias === 'string') { score += 1; reasons.push('remote_call_context_available'); }
252
- return { methodId: item.methodId, score, reasons, handlerPackage: item.handlerPackage, applicationPackage: item.applicationPackage };
253
- }).sort((a, b) => b.score - a.score);
254
- if (scores.length === 0) return { blocksAutomatic: false, evidence: { status: 'not_applicable', candidateScores: [] } };
255
- const [first, second] = scores;
256
- if (first && first.methodId !== undefined && first.score > 0 && (!second || first.score > second.score)) return { methodId: String(first.methodId), blocksAutomatic: false, evidence: { status: 'selected', selectedMethodId: first.methodId, candidateScores: scores } };
257
- return { blocksAutomatic: false, evidence: hinted.evidence.reason === 'no_scoped_hint_matched_edge' ? hinted.evidence : { status: 'tied', tieReason: scores.length > 1 ? 'duplicate_helper_implementation_candidates' : 'no_unique_materially_stronger_candidate', candidateScores: scores } };
258
- }
259
235
  function handlerScope(db: Db, methodId: string): { repoId?: number; files: Set<string>; symbolId?: number } | undefined {
260
- const row = db.prepare('SELECT hc.repo_id repoId,hc.source_file sourceFile,s.id symbolId FROM handler_methods hm JOIN handler_classes hc ON hc.id=hm.handler_class_id LEFT JOIN symbols s ON s.repo_id=hc.repo_id AND s.source_file=hc.source_file AND s.name=hm.method_name WHERE hm.id=?').get(methodId) as { repoId?: number; sourceFile?: string; symbolId?: number } | undefined;
261
- if (!row) return undefined;
236
+ const row = db.prepare("SELECT hc.repo_id repoId,hc.source_file sourceFile,s.id symbolId FROM handler_methods hm JOIN handler_classes hc ON hc.id=hm.handler_class_id LEFT JOIN symbols s ON s.repo_id=hc.repo_id AND s.source_file=hc.source_file AND s.qualified_name=hc.class_name || '.' || hm.method_name AND s.start_line=hm.source_line WHERE hm.id=?").get(methodId) as { repoId?: number; sourceFile?: string; symbolId?: number } | undefined;
237
+ if (!row || typeof row.symbolId !== 'number') return undefined;
262
238
  return { repoId: row.repoId, files: new Set(row.sourceFile ? [row.sourceFile] : []), symbolId: row.symbolId };
263
239
  }
264
240
 
@@ -315,11 +291,15 @@ function workspaceIdForCall(db: Db, callId: string): number | undefined {
315
291
  function parseEvidence(value: unknown): Record<string, unknown> {
316
292
  try {
317
293
  const parsed = JSON.parse(String(value || '{}')) as unknown;
318
- return parsed && typeof parsed === 'object' && !Array.isArray(parsed) ? parsed as Record<string, unknown> : {};
294
+ return isEvidenceRecord(parsed) ? boundCandidateLikeEvidence(parsed) : {};
319
295
  } catch {
320
296
  return {};
321
297
  }
322
298
  }
299
+
300
+ function isEvidenceRecord(value: unknown): value is Record<string, unknown> {
301
+ return Boolean(value && typeof value === 'object' && !Array.isArray(value));
302
+ }
323
303
  function receiverFromEvidence(value: unknown): string | undefined {
324
304
  const evidence = parseEvidence(value);
325
305
  return typeof evidence.receiver === 'string' ? evidence.receiver : undefined;
@@ -459,16 +439,20 @@ function contextForSymbolCall(db: Db, symbolCall: Record<string, unknown>, calle
459
439
  function contextualRuntimeResolution(db: Db, call: CallRow, binding: ContextBinding | undefined, workspaceId: number | undefined, persistedRows: GraphRow[] = []): { row?: GraphRow; evidence?: Record<string, unknown>; unresolvedReason?: string } {
460
440
  if (!binding || String(call.call_type) !== 'remote_action' || call.operation_path_expr === undefined || call.operation_path_expr === null) return {};
461
441
  if (binding.resolutionStatus === 'ambiguous') {
442
+ const candidates = boundedContextCandidates(binding.bindingCandidates ?? []);
462
443
  return {
463
444
  evidence: {
464
445
  contextualServiceBindingAttempted: true,
465
446
  contextualBinding: {
466
447
  source: binding.source,
467
448
  status: 'tied',
468
- candidates: binding.bindingCandidates,
449
+ candidates: candidates.candidates,
450
+ candidateCount: candidates.candidateCount,
451
+ shownCandidateCount: candidates.shownCandidateCount,
452
+ omittedCandidateCount: candidates.omittedCandidateCount,
469
453
  },
470
454
  contextualResolutionStatus: 'ambiguous',
471
- contextualCandidateCount: binding.bindingCandidates?.length ?? 0,
455
+ contextualCandidateCount: candidates.candidateCount,
472
456
  },
473
457
  unresolvedReason: 'Ambiguous contextual service binding candidates',
474
458
  };
@@ -478,7 +462,8 @@ function contextualRuntimeResolution(db: Db, call: CallRow, binding: ContextBind
478
462
  const servicePath = binding.effectiveServicePath ?? binding.servicePathExpr ?? binding.requireServicePath;
479
463
  const destination = binding.effectiveDestination ?? binding.destinationExpr ?? binding.requireDestination;
480
464
  const resolution = resolveOperation(db, { servicePath, operationPath: op, alias: binding.aliasExpr ?? binding.alias, destination, hasExplicitOverride: true, isDynamic: false }, workspaceId);
481
- const evidence: Record<string, unknown> = { contextualServiceBindingAttempted: true, contextualBinding: { source: binding.source, callerArgument: binding.callerArgument, callerProperty: binding.callerProperty, calleeParameter: binding.calleeParameter, calleeReceiver: binding.calleeReceiver, callerSite: binding.callerSite, calleeSite: binding.calleeSite, bindingSourceFile: binding.sourceFile, bindingSourceLine: binding.sourceLine, alias: binding.alias, aliasExpr: binding.aliasExpr, requireServicePath: binding.requireServicePath, requireDestination: binding.requireDestination, effectiveServicePath: binding.effectiveServicePath, effectiveDestination: binding.effectiveDestination }, operationPath: op, rawOperationPath: normalized?.rawOperationPath, normalizedOperationPath: normalized?.wasInvocation ? normalized.normalizedOperationPath : undefined, invocationArgumentPlaceholderKeys: normalized?.invocationArgumentPlaceholderKeys.length ? normalized.invocationArgumentPlaceholderKeys : undefined, servicePath, serviceAlias: binding.alias, serviceAliasExpr: binding.aliasExpr, destination, requireServicePath: binding.requireServicePath, requireDestination: binding.requireDestination, effectiveServicePath: binding.effectiveServicePath, effectiveDestination: binding.effectiveDestination, contextualResolutionStatus: resolution.status, contextualCandidateCount: resolution.candidates.length, candidates: resolution.candidates, contextualResolutionReasons: resolution.reasons, resolutionReasons: resolution.reasons };
465
+ const candidates = boundedContextCandidates(resolution.candidates);
466
+ const evidence: Record<string, unknown> = { contextualServiceBindingAttempted: true, contextualBinding: { source: binding.source, callerArgument: binding.callerArgument, callerProperty: binding.callerProperty, calleeParameter: binding.calleeParameter, calleeReceiver: binding.calleeReceiver, callerSite: binding.callerSite, calleeSite: binding.calleeSite, bindingSourceFile: binding.sourceFile, bindingSourceLine: binding.sourceLine, alias: binding.alias, aliasExpr: binding.aliasExpr, requireServicePath: binding.requireServicePath, requireDestination: binding.requireDestination, effectiveServicePath: binding.effectiveServicePath, effectiveDestination: binding.effectiveDestination }, operationPath: op, rawOperationPath: normalized?.rawOperationPath, normalizedOperationPath: normalized?.wasInvocation ? normalized.normalizedOperationPath : undefined, invocationArgumentPlaceholderKeys: normalized?.invocationArgumentPlaceholderKeys.length ? normalized.invocationArgumentPlaceholderKeys : undefined, servicePath, serviceAlias: binding.alias, serviceAliasExpr: binding.aliasExpr, destination, requireServicePath: binding.requireServicePath, requireDestination: binding.requireDestination, effectiveServicePath: binding.effectiveServicePath, effectiveDestination: binding.effectiveDestination, contextualResolutionStatus: resolution.status, contextualCandidateCount: candidates.candidateCount, shownContextualCandidateCount: candidates.shownCandidateCount, omittedContextualCandidateCount: candidates.omittedCandidateCount, candidates: candidates.candidates, contextualResolutionReasons: resolution.reasons, resolutionReasons: resolution.reasons };
482
467
  if (!resolution.target) return { evidence, unresolvedReason: resolution.status === 'ambiguous' ? 'Ambiguous contextual operation candidates' : resolution.status === 'dynamic' ? `Dynamic contextual target is missing runtime variables: ${resolution.reasons.join(', ')}` : 'No contextual operation candidate matched' };
483
468
  const resolvedEvidence = { ...evidence, contextualServiceBindingSelected: true, targetRepo: resolution.target.repoName, targetServicePath: resolution.target.servicePath, targetOperationPath: resolution.target.operationPath, targetOperation: resolution.target.operationName };
484
469
  const persistedResolved = persistedRows.find((item) => item.status === 'resolved');
@@ -491,22 +476,25 @@ export function trace(
491
476
  options: TraceOptions,
492
477
  ): TraceResult {
493
478
  const hintOptions = { implementationRepo: options.implementationRepo, implementationHints: options.implementationHints };
494
- const scope = startScope(db, start, hintOptions);
495
- const diagnostics = db
496
- .prepare(
497
- 'SELECT severity,code,message,source_file sourceFile,source_line sourceLine FROM diagnostics WHERE (? IS NULL OR repo_id=?)',
498
- )
499
- .all(scope.repo?.id, scope.repo?.id) as Array<Record<string, unknown>>;
500
- const stale = db.prepare('SELECT name,graph_stale_reason reason FROM repositories WHERE graph_stale_reason IS NOT NULL AND (? IS NULL OR id=?)').all(scope.repo?.id, scope.repo?.id) as Array<{ name?: string; reason?: string }>;
479
+ const scope = startScope(db, start, hintOptions, options.workspaceId);
480
+ const hasSelector = Boolean(start.repo || start.handler || start.operation
481
+ || start.operationPath || start.servicePath);
482
+ const diagnosticRepoId = scope.executionRepoId ?? scope.repo?.id;
483
+ const diagnostics = loadTraceDiagnostics(
484
+ db,
485
+ diagnosticRepoId,
486
+ !hasSelector,
487
+ options.workspaceId,
488
+ );
489
+ const stale = diagnosticRepoId !== undefined || !hasSelector
490
+ ? db.prepare('SELECT name,graph_stale_reason reason FROM repositories WHERE graph_stale_reason IS NOT NULL AND (? IS NULL OR id=?) AND (? IS NULL OR workspace_id=?) ORDER BY name,id').all(diagnosticRepoId, diagnosticRepoId, options.workspaceId, options.workspaceId) as Array<{ name?: string; reason?: string }>
491
+ : [];
501
492
  for (const row of stale)
502
- diagnostics.unshift({ severity: 'warning', code: 'graph_stale', message: `Graph is stale for ${row.name ?? 'repository'}: ${row.reason ?? 'facts_changed'}. Run service-flow link.` });
503
- for (const diagnostic of scope.startDiagnostics ?? []) diagnostics.unshift(diagnostic);
493
+ prependTraceDiagnostic(diagnostics, { severity: 'warning', code: 'graph_stale', message: `Graph is stale for ${row.name ?? 'repository'}: ${row.reason ?? 'facts_changed'}. Run service-flow link.` });
494
+ for (const diagnostic of scope.startDiagnostics ?? [])
495
+ prependTraceDiagnostic(diagnostics, diagnostic);
504
496
  if (!scope.selectorMatched && !(scope.startDiagnostics?.length))
505
- diagnostics.unshift({
506
- severity: 'warning',
507
- code: 'trace_start_not_found',
508
- message: start.servicePath && !start.operation && !start.operationPath && !start.handler ? 'Service-only trace requires --operation or --path and will not broaden to the whole workspace' : 'No handler source matched the requested trace start selector',
509
- });
497
+ prependTraceDiagnostic(diagnostics, selectorNotFoundDiagnostic(start));
510
498
  const maxDepth = positiveDepth(options.depth);
511
499
  const edges: TraceEdge[] = [];
512
500
  const nodes = new Map<string, Record<string, unknown>>();
@@ -519,7 +507,9 @@ export function trace(
519
507
  const op = operationNode(db, scope.startOperationId);
520
508
  const impl = implementationScope(db, scope.startOperationId);
521
509
  if (op) nodes.set(String(op.id), op);
522
- const startSelection = implementationMethodIdFromHint(impl.edge, hintOptions);
510
+ const startSelection = hintedImplementationSelection(
511
+ db, impl.edge, scope.startOperationId, hintOptions,
512
+ );
523
513
  if (impl.edge && (impl.edge.status === 'resolved' || startSelection.methodId)) {
524
514
  const selectedMethodId = impl.edge.status === 'resolved' ? impl.edge.to_id : startSelection.methodId;
525
515
  const implEvidence = { ...parseEvidence(impl.edge.evidence_json), startResolution: { strategy: 'indexed_operation_graph', matchedOperationId: scope.startOperationId, implementationEdgeId: impl.edge.id, implementationStatus: impl.edge.status, selectedHandlerMethodId: selectedMethodId }, implementationSelection: startSelection.methodId ? startSelection.evidence : undefined };
@@ -539,9 +529,9 @@ export function trace(
539
529
  seenScopes.add(key);
540
530
  const calls = db
541
531
  .prepare(
542
- `SELECT c.*,r.name repoName FROM outbound_calls c JOIN repositories r ON r.id=c.repo_id WHERE (? IS NULL OR c.repo_id=?) ORDER BY c.source_file,c.source_line`,
532
+ `SELECT c.*,r.name repoName FROM outbound_calls c JOIN repositories r ON r.id=c.repo_id WHERE (? IS NULL OR c.repo_id=?) AND (? IS NULL OR r.workspace_id=?) ORDER BY c.source_file,c.source_line`,
543
533
  )
544
- .all(current.repoId, current.repoId) as CallRow[];
534
+ .all(current.repoId, current.repoId, options.workspaceId, options.workspaceId) as CallRow[];
545
535
  const filtered = calls.filter(
546
536
  (c) =>
547
537
  (!current.symbolIds || current.symbolIds.has(Number(c.source_symbol_id))) && (!current.files || current.files.has(String(c.source_file))) &&
@@ -559,7 +549,7 @@ export function trace(
559
549
  const nextKey = `${nextRepoId}:${[...nextSymbols].join(',')}:${[...nextFiles].join(',')}`;
560
550
  const calleeNode = symbolNode(db, Number(symbolCall.callee_symbol_id));
561
551
  if (calleeNode) nodes.set(String(calleeNode.id), calleeNode);
562
- const evidence = { ...(JSON.parse(String(symbolCall.evidence_json || '{}')) as Record<string, unknown>), sourceFile: symbolCall.source_file, sourceLine: symbolCall.source_line, calleeSymbolId: symbolCall.callee_symbol_id, calleeSymbolName: calleeNode?.symbolName, calleeSymbolFile: calleeNode?.sourceFile, resolutionStatus: symbolCall.status };
552
+ const evidence = { ...parseEvidence(symbolCall.evidence_json), sourceFile: symbolCall.source_file, sourceLine: symbolCall.source_line, calleeSymbolId: symbolCall.callee_symbol_id, calleeSymbolName: calleeNode?.symbolName, calleeSymbolFile: calleeNode?.sourceFile, resolutionStatus: symbolCall.status };
563
553
  edges.push({ step: current.depth, type: 'local_symbol_call', from: String(symbolCall.callee_expression), to: calleeNode?.label ? String(calleeNode.label) : `symbol:${String(symbolCall.callee_symbol_id)}`, evidence, confidence: Number(symbolCall.confidence ?? 0.8), unresolvedReason: String(symbolCall.status) === 'resolved' ? undefined : symbolCall.unresolved_reason ? String(symbolCall.unresolved_reason) : undefined });
564
554
  if (seenScopes.has(nextKey)) edges.push({ step: current.depth, type: 'cycle', from: String(symbolCall.callee_expression), to: nextKey, evidence: { cycle: true, symbolCallId: symbolCall.id }, confidence: 1, unresolvedReason: 'Cycle detected; downstream symbol already visited' });
565
555
  else queue.push({ repoId: nextRepoId, files: nextFiles, symbolIds: nextSymbols, depth: current.depth + 1, context: contextForSymbolCall(db, symbolCall, callerBindings) });
@@ -585,9 +575,7 @@ export function trace(
585
575
  for (const row of graphRows) {
586
576
  if (seenEdges.has(Number(row.id))) continue;
587
577
  seenEdges.add(Number(row.id));
588
- const persistedEvidence = JSON.parse(
589
- String(row.evidence_json || '{}'),
590
- ) as Record<string, unknown>;
578
+ const persistedEvidence = parseEvidence(row.evidence_json);
591
579
  const rawEvidence = baseTraceEvidence(row as TraceGraphRow, call, persistedEvidence, contextual.evidence);
592
580
  const effective = runtimeResolution(db, row as TraceGraphRow, rawEvidence, {
593
581
  vars: options.vars,
@@ -613,17 +601,21 @@ export function trace(
613
601
  confidence: Number(effectiveRow.confidence ?? call.confidence),
614
602
  unresolvedReason: effective.unresolvedReason,
615
603
  });
616
- if ((options.dynamicMode ?? 'strict') === 'candidates')
604
+ if ((options.dynamicMode ?? 'strict') === 'candidates'
605
+ && effectiveRow.status !== 'resolved')
617
606
  edges.push(...dynamicCandidateBranches(current.depth, call, evidence));
618
607
  if (effectiveRow.to_kind === 'operation') {
619
608
  const implementation = implementationScope(db, effectiveRow.to_id);
620
- const contextSelection = contextImplementationMethodId(implementation.edge, current.repoId, evidence, hintOptions);
609
+ const contextSelection = contextualImplementationSelection(
610
+ db, implementation.edge, effectiveRow.to_id, current.repoId, evidence,
611
+ hintOptions,
612
+ );
621
613
  const contextMethodId = contextSelection.methodId;
622
614
  const contextNode = contextMethodId ? handlerMethodNode(db, contextMethodId) : undefined;
623
615
  if (implementation.edge) {
624
- const implEvidence = JSON.parse(String(implementation.edge.evidence_json || '{}')) as Record<string, unknown>;
625
- const hintDiagnostic = implementationHintDiagnostic(contextSelection, implEvidence.implementationHintSuggestions);
626
- if (hintDiagnostic) diagnostics.unshift(hintDiagnostic);
616
+ const implEvidence = parseEvidence(implementation.edge.evidence_json);
617
+ const hintDiagnostic = implementationHintDiagnostic(contextSelection, implEvidence);
618
+ if (hintDiagnostic) prependTraceDiagnostic(diagnostics, hintDiagnostic);
627
619
  const handlerNode = implementation.edge.status === 'resolved' ? handlerMethodNode(db, implementation.edge.to_id) : contextNode;
628
620
  const implTo = handlerNode?.label ? String(handlerNode.label) : `${implementation.edge.to_kind}:${implementation.edge.to_id}`;
629
621
  if (handlerNode) nodes.set(String(handlerNode.id), handlerNode);
@@ -674,6 +666,8 @@ export function trace(
674
666
  }
675
667
  }
676
668
  const runtimeDiagnostic = runtimeVariableDiagnostic(edges);
677
- if (runtimeDiagnostic) diagnostics.unshift(runtimeDiagnostic);
669
+ if (runtimeDiagnostic) prependTraceDiagnostic(diagnostics, runtimeDiagnostic);
670
+ for (const diagnostic of runtimeNoCandidateDiagnostics(edges))
671
+ prependTraceDiagnostic(diagnostics, diagnostic);
678
672
  return { start, nodes: [...nodes.values()], edges, diagnostics };
679
673
  }
package/src/types.ts CHANGED
@@ -95,14 +95,34 @@ export interface HandlerClassFact {
95
95
  sourceFile: string;
96
96
  sourceLine: number;
97
97
  methods: HandlerMethodFact[];
98
- }
98
+ hasHandlerDecorator?: boolean;
99
+ classDecoratorNames?: string[];
100
+ observedDecoratorNames?: string[];
101
+ unsupportedDecoratorNames?: string[];
102
+ }
103
+ export type HandlerMethodKind =
104
+ | 'operation'
105
+ | 'entity_lifecycle'
106
+ | 'event'
107
+ | 'unsupported_lifecycle'
108
+ | 'unsupported_decorator';
109
+ export type HandlerLifecyclePhase = 'on' | 'before' | 'after';
110
+ export type HandlerLifecycleEvent = 'CREATE' | 'READ' | 'UPDATE' | 'DELETE';
99
111
  export interface HandlerMethodFact {
100
112
  methodName: string;
101
113
  decoratorKind: string;
102
114
  decoratorValue?: string;
103
115
  decoratorRawExpression: string;
116
+ handlerKind?: HandlerMethodKind;
117
+ executable?: boolean;
118
+ lifecyclePhase?: HandlerLifecyclePhase;
119
+ lifecycleEvent?: HandlerLifecycleEvent;
104
120
  decoratorResolution: {
105
121
  rawExpression: string;
122
+ decoratorExpression?: string;
123
+ argumentExpression?: string;
124
+ resolvedDecoratorKind?: string;
125
+ decoratorImportSource?: string;
106
126
  resolvedValue?: string;
107
127
  resolutionKind:
108
128
  | 'literal'
@@ -110,8 +130,13 @@ export interface HandlerMethodFact {
110
130
  | 'enum_member'
111
131
  | 'const_object_property'
112
132
  | 'generated_constant_name'
133
+ | 'lifecycle_implicit'
113
134
  | 'unresolved';
114
135
  unresolvedReason?: string;
136
+ handlerKind?: HandlerMethodKind;
137
+ executable?: boolean;
138
+ lifecyclePhase?: HandlerLifecyclePhase;
139
+ lifecycleEvent?: HandlerLifecycleEvent;
115
140
  };
116
141
  sourceFile: string;
117
142
  sourceLine: number;
@@ -202,6 +227,7 @@ export interface ImplementationHint {
202
227
  export type DynamicMode = 'strict' | 'candidates' | 'infer';
203
228
  export interface TraceOptions {
204
229
  depth: number;
230
+ workspaceId?: number;
205
231
  vars?: Record<string, string>;
206
232
  includeExternal?: boolean;
207
233
  includeDb?: boolean;