@saptools/service-flow 0.1.37 → 0.1.39

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 (49) hide show
  1. package/dist/{chunk-WE3A6TOJ.js → chunk-SAZ5OK7R.js} +50 -9
  2. package/dist/chunk-SAZ5OK7R.js.map +1 -0
  3. package/dist/cli.js +4 -3
  4. package/dist/cli.js.map +1 -1
  5. package/dist/index.js +1 -1
  6. package/package.json +3 -2
  7. package/src/cli.ts +865 -0
  8. package/src/config/defaults.ts +14 -0
  9. package/src/config/workspace-config.ts +61 -0
  10. package/src/db/connection.ts +131 -0
  11. package/src/db/migrations.ts +81 -0
  12. package/src/db/repositories.ts +353 -0
  13. package/src/db/schema.ts +24 -0
  14. package/src/discovery/classify-repository.ts +50 -0
  15. package/src/discovery/discover-repositories.ts +58 -0
  16. package/src/index.ts +13 -0
  17. package/src/indexer/incremental-index.ts +25 -0
  18. package/src/indexer/repository-indexer.ts +137 -0
  19. package/src/indexer/workspace-indexer.ts +29 -0
  20. package/src/linker/cross-repo-linker.ts +406 -0
  21. package/src/linker/dynamic-edge-resolver.ts +45 -0
  22. package/src/linker/external-http-target.ts +38 -0
  23. package/src/linker/helper-package-linker.ts +57 -0
  24. package/src/linker/odata-path-normalizer.ts +236 -0
  25. package/src/linker/operation-decorator-normalizer.ts +47 -0
  26. package/src/linker/remote-query-target.ts +39 -0
  27. package/src/linker/service-resolver.ts +223 -0
  28. package/src/output/json-output.ts +7 -0
  29. package/src/output/mermaid-output.ts +16 -0
  30. package/src/output/table-output.ts +21 -0
  31. package/src/parsers/cds-parser.ts +147 -0
  32. package/src/parsers/decorator-parser.ts +76 -0
  33. package/src/parsers/generated-constants-parser.ts +23 -0
  34. package/src/parsers/handler-registration-parser.ts +177 -0
  35. package/src/parsers/outbound-call-parser.ts +441 -0
  36. package/src/parsers/package-json-parser.ts +63 -0
  37. package/src/parsers/service-binding-parser.ts +826 -0
  38. package/src/parsers/symbol-parser.ts +328 -0
  39. package/src/parsers/ts-project.ts +13 -0
  40. package/src/trace/selectors.ts +20 -0
  41. package/src/trace/trace-engine.ts +647 -0
  42. package/src/trace/traversal.ts +4 -0
  43. package/src/types.ts +186 -0
  44. package/src/utils/diagnostics.ts +10 -0
  45. package/src/utils/hashing.ts +10 -0
  46. package/src/utils/path-utils.ts +13 -0
  47. package/src/utils/redaction.ts +20 -0
  48. package/src/version.ts +4 -0
  49. package/dist/chunk-WE3A6TOJ.js.map +0 -1
package/src/cli.ts ADDED
@@ -0,0 +1,865 @@
1
+ import { Command } from 'commander';
2
+ import path from 'node:path';
3
+ import fs from 'node:fs/promises';
4
+ import pc from 'picocolors';
5
+ import { DEFAULT_IGNORES } from './config/defaults.js';
6
+ import {
7
+ createWorkspaceConfig,
8
+ loadWorkspaceConfig,
9
+ saveWorkspaceConfig,
10
+ } from './config/workspace-config.js';
11
+ import { openDatabase, openReadOnlyDatabase } from './db/connection.js';
12
+ import {
13
+ getWorkspace,
14
+ listRepositories,
15
+ repoByName,
16
+ upsertRepository,
17
+ upsertWorkspace,
18
+ } from './db/repositories.js';
19
+ import { discoverRepositories } from './discovery/discover-repositories.js';
20
+ import { parsePackageJson } from './parsers/package-json-parser.js';
21
+ import { classifyRepository } from './discovery/classify-repository.js';
22
+ import { indexWorkspace } from './indexer/workspace-indexer.js';
23
+ import { linkWorkspace } from './linker/cross-repo-linker.js';
24
+ import { classifyODataPathIntent, normalizeODataOperationInvocationPath } from './linker/odata-path-normalizer.js';
25
+ import { trace } from './trace/trace-engine.js';
26
+ import { parseVars } from './trace/selectors.js';
27
+ import { renderTraceTable } from './output/table-output.js';
28
+ import { renderTraceJson, renderJson } from './output/json-output.js';
29
+ import { renderMermaid } from './output/mermaid-output.js';
30
+ import { ANALYZER_VERSION, VERSION } from './version.js';
31
+ async function init(
32
+ workspace: string,
33
+ options: { db?: string; ignore?: string[] },
34
+ ): Promise<void> {
35
+ const config = createWorkspaceConfig(
36
+ workspace,
37
+ options.db,
38
+ options.ignore?.length ? options.ignore : [...DEFAULT_IGNORES],
39
+ );
40
+ const repos = await discoverRepositories(config.rootPath, config.ignore);
41
+ await saveWorkspaceConfig(config);
42
+ const db = openDatabase(config.dbPath);
43
+ const workspaceId = upsertWorkspace(db, config.rootPath, config.dbPath);
44
+ for (const repo of repos) {
45
+ const pkg = await parsePackageJson(repo.absolutePath);
46
+ const kind = await classifyRepository(repo.absolutePath, pkg);
47
+ upsertRepository(db, workspaceId, {
48
+ ...repo,
49
+ packageName: pkg.packageName,
50
+ packageVersion: pkg.packageVersion,
51
+ dependencies: pkg.dependencies,
52
+ kind,
53
+ });
54
+ }
55
+ db.close();
56
+ process.stdout.write(
57
+ `Workspace: ${config.rootPath}\nDatabase: ${config.dbPath}\nRepositories: ${repos.length}\nIgnored: ${config.ignore.join(', ')}\nNext: service-flow index --workspace ${config.rootPath}\n`,
58
+ );
59
+ }
60
+ async function withWorkspace<T>(
61
+ workspace: string | undefined,
62
+ fn: (
63
+ db: ReturnType<typeof openDatabase>,
64
+ workspaceId: number,
65
+ rootPath: string,
66
+ ) => Promise<T> | T,
67
+ ): Promise<T> {
68
+ const config = await loadWorkspaceConfig(workspace);
69
+ const db = openDatabase(config.dbPath);
70
+ try {
71
+ const row = getWorkspace(db, config.rootPath);
72
+ const workspaceId =
73
+ row?.id ?? upsertWorkspace(db, config.rootPath, config.dbPath);
74
+ return await fn(db, workspaceId, config.rootPath);
75
+ } finally {
76
+ db.close();
77
+ }
78
+ }
79
+ async function withReadOnlyWorkspace<T>(
80
+ workspace: string | undefined,
81
+ fn: (db: ReturnType<typeof openDatabase>, workspaceId: number, rootPath: string) => Promise<T> | T,
82
+ ): Promise<T> {
83
+ const config = await loadWorkspaceConfig(workspace);
84
+ const db = openReadOnlyDatabase(config.dbPath);
85
+ try {
86
+ const row = getWorkspace(db, config.rootPath);
87
+ if (!row) throw new Error(`Workspace is not initialized in ${config.dbPath}`);
88
+ return await fn(db, row.id, config.rootPath);
89
+ } finally {
90
+ db.close();
91
+ }
92
+ }
93
+ function schemaDriftDiagnostics(db: ReturnType<typeof openDatabase>, strict: boolean): Array<Record<string, unknown>> {
94
+ if (!strict) return [];
95
+ const symbolColumns = db.prepare("PRAGMA table_info(symbols)").all() as Array<{ name?: string }>;
96
+ const legacy = symbolColumns.filter((row) => ['external_target_kind','external_target_id','external_target_label','external_target_dynamic'].includes(String(row.name))).map((row) => row.name);
97
+ const missingExternal = db.prepare("SELECT id id,source_file sourceFile,source_line sourceLine FROM outbound_calls WHERE call_type='external_http' AND (external_target_id IS NULL OR external_target_label IS NULL OR external_target_kind IS NULL) LIMIT 20").all() as Array<Record<string, unknown>>;
98
+ const diagnostics: Array<Record<string, unknown>> = [];
99
+ if (legacy.length > 0) diagnostics.push({ severity: 'warning', code: 'schema_legacy_columns_present', message: 'Legacy external-target columns are present on symbols; run service-flow clean --db-only, then init/index/link to rebuild with the current schema.', scope: 'workspace', affectedColumns: legacy, remediation: 'service-flow clean --db-only && service-flow init <workspace> && service-flow index && service-flow link' });
100
+ if (missingExternal.length > 0) diagnostics.push({ severity: 'warning', code: 'external_target_columns_missing_data', message: 'External HTTP calls are missing queryable external target metadata; reindex is required after upgrade.', scope: 'workspace', affectedRows: missingExternal, remediation: 'service-flow index --force && service-flow link' });
101
+ if (legacy.length > 0 || missingExternal.length > 0) diagnostics.push({ severity: 'warning', code: 'reindex_required_after_upgrade', message: 'This database cannot be made equivalent to a fresh index by relink alone.', scope: 'workspace', remediation: 'Rebuild or force reindex the workspace, then run service-flow doctor --strict.' });
102
+ return diagnostics;
103
+ }
104
+ function linkUpgradeWarnings(db: ReturnType<typeof openDatabase>): Array<Record<string, unknown>> {
105
+ return [...schemaDriftDiagnostics(db, true), ...analyzerVersionDiagnostics(db, true)].filter((item) => ['schema_legacy_columns_present','external_target_columns_missing_data','reindex_required_after_upgrade','reindex_required_after_analyzer_upgrade'].includes(String(item.code)));
106
+ }
107
+
108
+ function analyzerVersionDiagnostics(db: ReturnType<typeof openDatabase>, strict: boolean): Array<Record<string, unknown>> {
109
+ if (!strict) return [];
110
+ const rows = db.prepare("SELECT name,COALESCE(fact_analyzer_version,'legacy') factAnalyzerVersion FROM repositories WHERE index_status='indexed' AND COALESCE(fact_analyzer_version,'legacy')<>?").all(ANALYZER_VERSION) as Array<Record<string, unknown>>;
111
+ if (rows.length === 0) return [];
112
+ return [{ severity: 'warning', code: 'reindex_required_after_analyzer_upgrade', message: 'Repository facts were produced by an older or unknown analyzer; run service-flow index --force before relink to apply current parser semantics.', scope: 'workspace', affectedRepositoryCount: rows.length, currentAnalyzerVersion: ANALYZER_VERSION, repositories: rows, remediation: 'service-flow index --force && service-flow link' }];
113
+ }
114
+
115
+ function remoteEntityOperationCollisionQuality(db: ReturnType<typeof openDatabase>): Record<string, unknown> {
116
+ const rows = db.prepare(`SELECT c.id callId,c.source_file sourceFile,c.source_line sourceLine,c.method method,c.operation_path_expr rawPath,c.query_entity entitySegment,e.to_id selectedTerminalEntityTarget,e.evidence_json evidenceJson
117
+ FROM outbound_calls c JOIN graph_edges e ON e.from_kind='call' AND e.from_id=CAST(c.id AS TEXT)
118
+ WHERE c.call_type LIKE 'remote_entity_%' AND e.edge_type='HANDLER_ACCESSES_REMOTE_ENTITY' AND e.status='terminal'
119
+ ORDER BY c.source_file,c.source_line LIMIT 100`).all() as Array<Record<string, unknown>>;
120
+ const examples: Array<Record<string, unknown>> = [];
121
+ for (const row of rows) {
122
+ const normalized = normalizeODataOperationInvocationPath(String(row.rawPath ?? ''));
123
+ const rawPath = String(row.rawPath ?? '');
124
+ const candidatePath = normalized?.wasInvocation ? normalized.normalizedOperationPath : rawPath;
125
+ const name = candidatePath.replace(/^\//, '');
126
+ const simple = name.split('.').at(-1) ?? name;
127
+ const candidates = db.prepare('SELECT COUNT(*) count FROM cds_operations WHERE operation_path IN (?,?) OR operation_name IN (?,?)').get(candidatePath, `/${simple}`, name, simple) as { count?: number };
128
+ const candidateCount = Number(candidates.count ?? 0);
129
+ const operationLike = Boolean(normalized?.wasInvocation) || candidateCount > 0;
130
+ if (!operationLike) continue;
131
+ let classifierReason: unknown;
132
+ try {
133
+ const evidence = JSON.parse(String(row.evidenceJson ?? '{}')) as { odataPathIntent?: { reason?: unknown } };
134
+ classifierReason = evidence.odataPathIntent?.reason;
135
+ } catch {
136
+ classifierReason = undefined;
137
+ }
138
+ examples.push({ callId: row.callId, sourceFile: row.sourceFile, sourceLine: row.sourceLine, method: row.method, rawPath, normalizedOperationPath: normalized?.wasInvocation ? normalized.normalizedOperationPath : candidatePath, entitySegment: row.entitySegment, operationCandidateCount: candidateCount, selectedTerminalEntityTarget: row.selectedTerminalEntityTarget, classifierReason });
139
+ }
140
+ return { severity: examples.length > 0 ? 'warning' : 'info', code: 'strict_remote_entity_operation_collision_quality', message: 'Terminal remote entity edges that look like indexed operation invocations', collisionCount: examples.length, examples: examples.slice(0, 10) };
141
+ }
142
+
143
+
144
+ function remoteEntityDynamicOperationFalsePositiveQuality(db: ReturnType<typeof openDatabase>): Record<string, unknown> {
145
+ const rows = db.prepare(`SELECT c.id callId,c.source_file sourceFile,c.source_line sourceLine,c.method method,c.operation_path_expr rawPath,e.id graphEdgeId,e.status status,e.to_kind targetKind,e.unresolved_reason unresolvedReason,e.evidence_json evidenceJson
146
+ FROM outbound_calls c JOIN graph_edges e ON e.from_kind='call' AND e.from_id=CAST(c.id AS TEXT)
147
+ WHERE c.call_type LIKE 'remote_entity_%' AND e.status IN ('dynamic','unresolved') AND e.to_kind='operation_candidate'
148
+ ORDER BY c.source_file,c.source_line LIMIT 100`).all() as Array<Record<string, unknown>>;
149
+ const examples: Array<Record<string, unknown>> = [];
150
+ for (const row of rows) {
151
+ const rawPath = String(row.rawPath ?? '');
152
+ const method = String(row.method ?? 'GET');
153
+ const intent = classifyODataPathIntent(rawPath, method);
154
+ const entityIntent = ['entity_key_read', 'entity_navigation_query', 'entity_media'].includes(intent.kind) || (intent.kind === 'entity_mutation' && (intent.hasEntityKeyPredicate || intent.hasNavigationSuffix));
155
+ if (!entityIntent) continue;
156
+ let candidateCount: number;
157
+ try {
158
+ const evidence = JSON.parse(String(row.evidenceJson ?? '{}')) as { indexedOperationCandidateCount?: unknown; candidateCount?: unknown };
159
+ candidateCount = Number(evidence.indexedOperationCandidateCount ?? evidence.candidateCount ?? 0);
160
+ } catch {
161
+ candidateCount = 0;
162
+ }
163
+ const reason = String(row.unresolvedReason ?? '');
164
+ const keyEvidence = intent.keyPredicatePlaceholderKeys.length > 0 || reason.includes('runtime variable') || reason.includes('placeholder');
165
+ if (candidateCount > 0 || !keyEvidence) continue;
166
+ examples.push({ sourceFile: row.sourceFile, sourceLine: row.sourceLine, rawPath, method, pathIntent: intent.kind, keyPlaceholderKeys: intent.keyPredicatePlaceholderKeys, navigationOrMediaSuffix: intent.navigationSuffix ?? intent.mediaOrPropertySuffix, operationCandidateCount: candidateCount, graphEdgeId: row.graphEdgeId, recommendedRemediation: 'Reindex and relink with service-flow 0.1.35 or newer so entity key placeholders remain entity-addressing evidence.' });
167
+ }
168
+ return { severity: examples.length > 0 ? 'warning' : 'info', code: 'strict_remote_entity_dynamic_operation_false_positive_quality', message: 'Parser-classified entity paths linked as dynamic operation candidates without indexed operation evidence', falsePositiveCount: examples.length, examples: examples.slice(0, 10) };
169
+ }
170
+
171
+ function localServiceDiagnostics(db: ReturnType<typeof openDatabase>, strict: boolean): Array<Record<string, unknown>> {
172
+ const rows = db.prepare(`SELECT e.status status,e.unresolved_reason reason,e.evidence_json evidenceJson FROM graph_edges e JOIN outbound_calls c ON e.from_kind='call' AND c.id=CAST(e.from_id AS INTEGER) WHERE c.call_type='local_service_call'`).all() as Array<{ status?: string; reason?: string | null; evidenceJson?: string }>;
173
+ const implementationContext = rows.filter((row) => row.status === 'resolved' && String(row.evidenceJson ?? '').includes('implementation_context_caller_ownership')).length;
174
+ const withoutOwnership = rows.filter((row) => row.reason === 'local_service_candidate_without_caller_ownership' || String(row.evidenceJson ?? '').includes('local_service_candidate_without_caller_ownership')).length;
175
+ const unresolved = rows.filter((row) => row.status === 'unresolved').length;
176
+ const outsideScope = rows.filter((row) => {
177
+ if (row.status !== 'unresolved') return false;
178
+ try {
179
+ const evidence = JSON.parse(String(row.evidenceJson ?? '{}')) as { candidateCount?: unknown };
180
+ return Number(evidence.candidateCount ?? 0) > 0;
181
+ } catch {
182
+ return false;
183
+ }
184
+ }).length;
185
+ const out: Array<Record<string, unknown>> = [];
186
+ if (withoutOwnership > 0) out.push({ severity: 'warning', code: 'local_service_candidate_without_caller_ownership', message: `Local service calls have operation candidates but no caller ownership evidence: ${withoutOwnership}` });
187
+ if (outsideScope > 0) out.push({ severity: 'warning', code: 'local_service_candidates_outside_local_scope', message: `Local service calls found candidates outside same-repository scope: ${outsideScope}` });
188
+ if (strict && unresolved > 0) out.push({ severity: 'warning', code: 'local_service_calls_unresolved', message: `Unresolved local service calls: ${unresolved}` });
189
+ if (strict && implementationContext > 0) out.push({ severity: 'info', code: 'local_service_calls_resolved_by_implementation_context', message: `Local service calls resolved by implementation-context ownership: ${implementationContext}` });
190
+ return out;
191
+ }
192
+
193
+ function parserQualityDiagnostics(db: ReturnType<typeof openDatabase>, strict: boolean): Array<Record<string, unknown>> {
194
+ if (!strict) return [];
195
+ const symbolUnresolvedThreshold = 0.05;
196
+ const dbUnknownThreshold = 0.25;
197
+ const outboundUnownedThreshold = 0.01;
198
+ const symbol = db.prepare("SELECT COUNT(*) total, SUM(CASE WHEN status='resolved' THEN 1 ELSE 0 END) resolved, SUM(CASE WHEN status='unresolved' THEN 1 ELSE 0 END) unresolved FROM symbol_calls").get() as { total?: number; resolved?: number; unresolved?: number };
199
+ const top = db.prepare("SELECT callee_expression calleeExpression,COUNT(*) count FROM symbol_calls WHERE status='unresolved' GROUP BY callee_expression ORDER BY count DESC,callee_expression LIMIT 5").all() as Array<Record<string, unknown>>;
200
+ const evidence = db.prepare("SELECT COUNT(*) total, SUM(CASE WHEN json_valid(evidence_json)=0 OR json_type(evidence_json) <> 'object' THEN 1 ELSE 0 END) nonObject FROM symbol_calls").get() as { total?: number; nonObject?: number };
201
+ const dbq = db.prepare("SELECT COUNT(*) total, SUM(CASE WHEN query_entity IS NOT NULL THEN 1 ELSE 0 END) known, SUM(CASE WHEN query_entity IS NULL THEN 1 ELSE 0 END) unknown FROM outbound_calls WHERE call_type='local_db_query'").get() as { total?: number; known?: number; unknown?: number };
202
+ const outbound = db.prepare("SELECT COUNT(*) total, SUM(CASE WHEN source_symbol_id IS NULL THEN 1 ELSE 0 END) withoutOwnership FROM outbound_calls").get() as { total?: number; withoutOwnership?: number };
203
+ const outboundEvidence = db.prepare("SELECT COUNT(*) total, SUM(CASE WHEN evidence_json IS NULL THEN 1 ELSE 0 END) missing, SUM(CASE WHEN evidence_json IS NOT NULL AND json_valid(evidence_json)=0 THEN 1 ELSE 0 END) invalid, SUM(CASE WHEN evidence_json IS NOT NULL AND json_valid(evidence_json)=1 AND json_type(evidence_json) <> 'object' THEN 1 ELSE 0 END) nonObject FROM outbound_calls").get() as { total?: number; missing?: number; invalid?: number; nonObject?: number };
204
+ const outboundEvidenceExamples = db.prepare("SELECT call_type callType, source_file sourceFile, source_line sourceLine FROM outbound_calls WHERE evidence_json IS NULL OR json_valid(evidence_json)=0 OR json_type(evidence_json) <> 'object' ORDER BY source_file, source_line LIMIT 10").all() as Array<Record<string, unknown>>;
205
+ const graphEvidence = db.prepare("SELECT COUNT(*) total, SUM(CASE WHEN e.evidence_json IS NULL OR json_valid(e.evidence_json)=0 OR json_type(e.evidence_json) <> 'object' THEN 1 ELSE 0 END) nonObject, SUM(CASE WHEN e.evidence_json IS NOT NULL AND json_valid(e.evidence_json)=1 AND json_extract(e.evidence_json,'$.outboundEvidence.parser') IS NOT NULL THEN 1 ELSE 0 END) withOutboundEvidence FROM graph_edges e WHERE e.from_kind='call'").get() as { total?: number; nonObject?: number; withOutboundEvidence?: number };
206
+ const graphEvidenceExamples = db.prepare("SELECT c.call_type callType,c.source_file sourceFile,c.source_line sourceLine,e.edge_type edgeType FROM graph_edges e JOIN outbound_calls c ON e.from_kind='call' AND c.id=CAST(e.from_id AS INTEGER) WHERE e.evidence_json IS NULL OR json_valid(e.evidence_json)=0 OR json_type(e.evidence_json) <> 'object' OR json_extract(e.evidence_json,'$.outboundEvidence.parser') IS NULL ORDER BY c.source_file,c.source_line LIMIT 10").all() as Array<Record<string, unknown>>;
207
+ const eventReceiver = db.prepare("SELECT COUNT(*) total, SUM(CASE WHEN call_type IN ('async_emit','async_subscribe') THEN 1 ELSE 0 END) eventTotal, SUM(CASE WHEN call_type IN ('async_emit','async_subscribe') AND (json_extract(evidence_json,'$.receiverClassification') IS NULL OR json_extract(evidence_json,'$.receiverClassification') <> 'cap_evidence') THEN 1 ELSE 0 END) questionable FROM outbound_calls").get() as { total?: number; eventTotal?: number; questionable?: number };
208
+ const dynamicTerminal = db.prepare("SELECT COUNT(*) count FROM graph_edges WHERE status='terminal' AND is_dynamic=1").get() as { count?: number };
209
+ const ownerlessByType = db.prepare("SELECT call_type callType, COUNT(*) count FROM outbound_calls WHERE source_symbol_id IS NULL GROUP BY call_type ORDER BY count DESC, call_type").all() as Array<Record<string, unknown>>;
210
+ const ownerlessByCategory = db.prepare(`SELECT CASE
211
+ WHEN COALESCE(evidence_json,'') LIKE '%comment_or_non_executable_source%' THEN 'comment_or_non_executable_source'
212
+ WHEN call_type='async_subscribe' AND COALESCE(evidence_json,'') LIKE '%cap_service_event_subscription%' THEN 'top_level_event_registration'
213
+ WHEN call_type='async_subscribe' THEN 'generic_event_listener_ignored_or_unowned'
214
+ WHEN EXISTS (SELECT 1 FROM symbols s WHERE s.repo_id=outbound_calls.repo_id AND s.source_file=outbound_calls.source_file) THEN 'line_range_mismatch'
215
+ WHEN source_line <= 1 THEN 'unsupported_function_shape'
216
+ WHEN source_line > 1 THEN 'unsupported_callback_shape'
217
+ ELSE 'unknown' END category, COUNT(*) count
218
+ FROM outbound_calls WHERE source_symbol_id IS NULL GROUP BY category ORDER BY count DESC, category`).all() as Array<Record<string, unknown>>;
219
+ const ownerlessExamples = db.prepare(`SELECT CASE
220
+ WHEN COALESCE(evidence_json,'') LIKE '%comment_or_non_executable_source%' THEN 'comment_or_non_executable_source'
221
+ WHEN call_type='async_subscribe' AND COALESCE(evidence_json,'') LIKE '%cap_service_event_subscription%' THEN 'top_level_event_registration'
222
+ WHEN call_type='async_subscribe' THEN 'generic_event_listener_ignored_or_unowned'
223
+ WHEN EXISTS (SELECT 1 FROM symbols s WHERE s.repo_id=outbound_calls.repo_id AND s.source_file=outbound_calls.source_file) THEN 'line_range_mismatch'
224
+ WHEN source_line <= 1 THEN 'unsupported_function_shape'
225
+ WHEN source_line > 1 THEN 'unsupported_callback_shape'
226
+ ELSE 'unknown' END category, call_type callType, source_file sourceFile, source_line sourceLine, unresolved_reason unresolvedReason
227
+ FROM outbound_calls WHERE source_symbol_id IS NULL ORDER BY category, source_file, source_line LIMIT 10`).all() as Array<Record<string, unknown>>;
228
+ const symbolTotal = Number(symbol.total ?? 0);
229
+ const symbolUnresolved = Number(symbol.unresolved ?? 0);
230
+ const symbolUnresolvedRatio = symbolTotal === 0 ? 0 : Number((symbolUnresolved / symbolTotal).toFixed(4));
231
+ const queryTotal = Number(dbq.total ?? 0);
232
+ const queryUnknown = Number(dbq.unknown ?? 0);
233
+ const queryUnknownRatio = queryTotal === 0 ? 0 : Number((queryUnknown / queryTotal).toFixed(4));
234
+ const outboundTotal = Number(outbound.total ?? 0);
235
+ const outboundWithoutOwnership = Number(outbound.withoutOwnership ?? 0);
236
+ const outboundWithoutOwnershipRatio = outboundTotal === 0 ? 0 : Number((outboundWithoutOwnership / outboundTotal).toFixed(4));
237
+ const remoteQuery = remoteQueryTargetQuality(db);
238
+ const invocation = odataInvocationResolutionQuality(db);
239
+ const remoteAction = remoteActionTargetQuality(db);
240
+ const entityOperationCollision = remoteEntityOperationCollisionQuality(db);
241
+ const entityDynamicFalsePositive = remoteEntityDynamicOperationFalsePositiveQuality(db);
242
+ const externalHttp = externalHttpTargetQuality(db);
243
+ const aliasQuality = identityAliasBindingQuality(db);
244
+ const noBindingQuality = remoteActionNoBindingQuality(db);
245
+ const contextualQuality = contextualImplementationQuality(db);
246
+ const classInstanceQuality = classInstanceNoiseQuality(db);
247
+ const bindingPropagationQuality = contextualBindingPropagationQuality(db);
248
+ const wrapperQuality = wrapperPathPropagationQuality(db);
249
+ const nestedThisQuality = nestedThisReceiverQuality(db);
250
+ return [
251
+ aliasQuality,
252
+ noBindingQuality,
253
+ contextualQuality,
254
+ classInstanceQuality,
255
+ bindingPropagationQuality,
256
+ wrapperQuality,
257
+ nestedThisQuality,
258
+ remoteQuery,
259
+ entityOperationCollision,
260
+ entityDynamicFalsePositive,
261
+ invocation,
262
+ remoteAction,
263
+ externalHttp,
264
+ { severity: Number(evidence.nonObject ?? 0) > 0 ? 'warning' : 'info', code: 'strict_symbol_call_evidence_quality', message: 'Symbol-call evidence JSON object aggregate', total: Number(evidence.total ?? 0), nonObject: Number(evidence.nonObject ?? 0) },
265
+ { severity: Number(outboundEvidence.missing ?? 0) + Number(outboundEvidence.invalid ?? 0) + Number(outboundEvidence.nonObject ?? 0) > 0 ? 'warning' : 'info', code: 'strict_outbound_evidence_quality', message: 'Outbound parser evidence JSON object aggregate', total: Number(outboundEvidence.total ?? 0), missing: Number(outboundEvidence.missing ?? 0), invalid: Number(outboundEvidence.invalid ?? 0), nonObject: Number(outboundEvidence.nonObject ?? 0), examples: outboundEvidenceExamples },
266
+ { severity: Number(graphEvidence.nonObject ?? 0) > 0 || Number(graphEvidence.withOutboundEvidence ?? 0) < Number(graphEvidence.total ?? 0) ? 'warning' : 'info', code: 'strict_graph_evidence_quality', message: 'Call-derived graph evidence and parser-evidence propagation aggregate', total: Number(graphEvidence.total ?? 0), nonObject: Number(graphEvidence.nonObject ?? 0), withOutboundEvidence: Number(graphEvidence.withOutboundEvidence ?? 0), examples: graphEvidenceExamples },
267
+ { severity: Number(eventReceiver.questionable ?? 0) > 0 ? 'warning' : 'info', code: 'strict_event_receiver_classification_quality', message: 'CAP event receiver classification aggregate', eventTotal: Number(eventReceiver.eventTotal ?? 0), questionable: Number(eventReceiver.questionable ?? 0) },
268
+ { severity: Number(dynamicTerminal.count ?? 0) > 0 ? 'warning' : 'info', code: 'strict_graph_dynamic_flag_consistency', message: 'Graph dynamic flag consistency aggregate', dynamicTerminalEdges: Number(dynamicTerminal.count ?? 0) },
269
+ { severity: symbolUnresolvedRatio > symbolUnresolvedThreshold ? 'warning' : 'info', code: 'strict_symbol_call_quality', message: 'Symbol-call quality aggregate', total: symbolTotal, resolved: Number(symbol.resolved ?? 0), unresolved: symbolUnresolved, unresolvedRatio: symbolUnresolvedRatio, unresolvedRatioThreshold: symbolUnresolvedThreshold, topUnresolvedCallees: top },
270
+ { severity: queryUnknownRatio > dbUnknownThreshold ? 'warning' : 'info', code: 'strict_db_query_quality', message: 'Local DB query quality aggregate', total: queryTotal, known: Number(dbq.known ?? 0), unknown: queryUnknown, unknownRatio: queryUnknownRatio, unknownRatioThreshold: dbUnknownThreshold },
271
+ { severity: outboundWithoutOwnershipRatio > outboundUnownedThreshold ? 'warning' : 'info', code: 'strict_outbound_source_ownership_quality', message: 'Outbound call source-symbol ownership aggregate', total: outboundTotal, withoutOwnership: outboundWithoutOwnership, withoutOwnershipRatio: outboundWithoutOwnershipRatio, withoutOwnershipRatioThreshold: outboundUnownedThreshold, ownerlessByType, ownerlessByCategory, ownerlessExamples },
272
+ ];
273
+ }
274
+
275
+
276
+ function identityAliasBindingQuality(db: ReturnType<typeof openDatabase>): Record<string, unknown> {
277
+ const examples = db.prepare(`SELECT c.source_file sourceFile,c.source_line sourceLine,c.service_binding_id serviceBindingId,json_extract(c.evidence_json,'$.receiver') receiverName,b.variable_name aliasSourceVariable,'same-file identifier alias still lacks a binding id' parserReason
278
+ FROM outbound_calls c JOIN service_bindings b ON b.repo_id=c.repo_id AND b.source_file=c.source_file
279
+ WHERE c.call_type='remote_action' AND c.service_binding_id IS NULL AND json_extract(c.evidence_json,'$.receiver') IS NOT NULL
280
+ AND c.evidence_json LIKE '%' || '"aliasOf":"' || json_extract(c.evidence_json,'$.receiver') || '"' || '%'
281
+ ORDER BY c.source_file,c.source_line LIMIT 5`).all() as Array<Record<string, unknown>>;
282
+ return { severity: examples.length > 0 ? 'warning' : 'info', code: 'strict_identity_alias_binding_quality', message: 'Remote sends that look like missed same-file identity aliases', missedAliasBindingCalls: examples.length, examples };
283
+ }
284
+
285
+ function remoteActionNoBindingQuality(db: ReturnType<typeof openDatabase>): Record<string, unknown> {
286
+ const categoryCase = `CASE
287
+ WHEN c.unresolved_reason='dynamic_operation_path_identifier' THEN 'dynamic_path_identifier'
288
+ WHEN json_extract(c.evidence_json,'$.classifier')='higher_order_wrapper_literal_path' OR json_extract(c.evidence_json,'$.operationPathExpression') IS NOT NULL THEN 'likely_higher_order_wrapper_path_needed'
289
+ WHEN json_extract(c.evidence_json,'$.receiver') LIKE '%.%' THEN 'likely_parameter_context_needed'
290
+ WHEN EXISTS (
291
+ SELECT 1 FROM symbol_calls sc
292
+ JOIN symbols caller ON caller.id=sc.caller_symbol_id
293
+ JOIN symbols callee ON callee.id=sc.callee_symbol_id
294
+ WHERE sc.status='resolved'
295
+ AND sc.source_file=c.source_file
296
+ AND caller.id=c.source_symbol_id
297
+ AND json_extract(sc.evidence_json,'$.relation')='class_instance_method'
298
+ AND (callee.evidence_json IS NULL OR json_extract(callee.evidence_json,'$.parameterBindings') IS NULL)
299
+ ) THEN 'likely_instance_method_parameter_metadata_needed'
300
+ WHEN EXISTS (SELECT 1 FROM service_bindings b WHERE b.repo_id=c.repo_id AND b.source_file=c.source_file AND ABS(b.source_line-c.source_line) < 50) THEN 'likely_missing_assignment_binding'
301
+ WHEN e.status='unresolved' AND COALESCE(e.unresolved_reason,'') LIKE '%No indexed target operation%' THEN 'no_indexed_target_operation'
302
+ WHEN c.operation_path_expr IS NOT NULL AND (c.operation_path_expr LIKE '/%' OR c.operation_path_expr NOT LIKE '%/%') THEN 'operation_path_only_no_static_service_signal'
303
+ ELSE 'external_or_entity_path_not_action' END`;
304
+ const rows = db.prepare(`SELECT ${categoryCase} category,COALESCE(e.status,'missing_edge') status,COUNT(*) count
305
+ FROM outbound_calls c LEFT JOIN graph_edges e ON e.from_kind='call' AND e.from_id=CAST(c.id AS TEXT)
306
+ WHERE c.call_type='remote_action' AND c.operation_path_expr IS NOT NULL AND c.service_binding_id IS NULL
307
+ GROUP BY category,status ORDER BY count DESC,category,status`).all() as Array<Record<string, unknown>>;
308
+ const examples = db.prepare(`SELECT c.source_file sourceFile,c.source_line sourceLine,json_extract(c.evidence_json,'$.receiver') receiverName,c.operation_path_expr operationPath,COALESCE(e.status,'missing_edge') status,${categoryCase} category
309
+ FROM outbound_calls c LEFT JOIN graph_edges e ON e.from_kind='call' AND e.from_id=CAST(c.id AS TEXT)
310
+ WHERE c.call_type='remote_action' AND c.operation_path_expr IS NOT NULL AND c.service_binding_id IS NULL ORDER BY c.source_file,c.source_line LIMIT 8`).all() as Array<Record<string, unknown>>;
311
+ const total = rows.reduce((sum, row) => sum + Number(row.count ?? 0), 0);
312
+ return { severity: total > 0 ? 'warning' : 'info', code: 'strict_remote_action_no_binding_quality', message: 'Remote actions with operation paths but no service binding id', total, breakdown: rows, examples };
313
+ }
314
+
315
+ function classInstanceNoiseQuality(db: ReturnType<typeof openDatabase>): Record<string, unknown> {
316
+ const builtIns = ['Set', 'Map', 'WeakSet', 'WeakMap', 'Date', 'RegExp', 'URL', 'URLSearchParams', 'Error', 'EvalError', 'RangeError', 'ReferenceError', 'SyntaxError', 'TypeError', 'URIError', 'AggregateError', 'ArrayBuffer', 'SharedArrayBuffer', 'DataView', 'Int8Array', 'Uint8Array', 'Uint8ClampedArray', 'Int16Array', 'Uint16Array', 'Int32Array', 'Uint32Array', 'Float32Array', 'Float64Array', 'BigInt64Array', 'BigUint64Array', 'Promise', 'AbortController'];
317
+ const placeholders = builtIns.map(() => '?').join(',');
318
+ const aggregate = db.prepare(`SELECT COUNT(*) total,
319
+ SUM(CASE WHEN status='unresolved' THEN 1 ELSE 0 END) unresolved,
320
+ SUM(CASE WHEN status='unresolved' AND json_extract(evidence_json,'$.className') IN (${placeholders}) THEN 1 ELSE 0 END) unresolvedBuiltIn
321
+ FROM symbol_calls WHERE json_extract(evidence_json,'$.relation')='class_instance_method'`).get(...builtIns) as { total?: number; unresolved?: number; unresolvedBuiltIn?: number };
322
+ const byConstructor = db.prepare(`SELECT json_extract(evidence_json,'$.className') constructorName,COUNT(*) unresolvedCount
323
+ FROM symbol_calls WHERE status='unresolved' AND json_extract(evidence_json,'$.relation')='class_instance_method'
324
+ GROUP BY constructorName ORDER BY unresolvedCount DESC,constructorName LIMIT 10`).all() as Array<Record<string, unknown>>;
325
+ return { severity: Number(aggregate.unresolvedBuiltIn ?? 0) > 0 ? 'warning' : 'info', code: 'strict_class_instance_noise_quality', message: 'Class-instance symbol-call aggregate with built-in constructor guard', totalClassInstanceCalls: Number(aggregate.total ?? 0), unresolvedClassInstanceCalls: Number(aggregate.unresolved ?? 0), unresolvedBuiltInClassInstanceCalls: Number(aggregate.unresolvedBuiltIn ?? 0), unresolvedByConstructor: byConstructor };
326
+ }
327
+
328
+ function contextualBindingPropagationQuality(db: ReturnType<typeof openDatabase>): Record<string, unknown> {
329
+ const serviceClientCalls = db.prepare(`SELECT COUNT(*) count FROM symbol_calls sc
330
+ WHERE json_extract(sc.evidence_json,'$.callArguments[0].kind') IN ('identifier','object_literal')`).get() as { count?: number };
331
+ const missingMetadata = db.prepare(`SELECT COUNT(*) count FROM symbol_calls sc JOIN symbols s ON s.id=sc.callee_symbol_id
332
+ WHERE sc.status='resolved' AND json_extract(sc.evidence_json,'$.callArguments[0].kind') IN ('identifier','object_literal')
333
+ AND (s.evidence_json IS NULL OR json_extract(s.evidence_json,'$.parameterBindings') IS NULL)`).get() as { count?: number };
334
+ const destructuredUnmapped = db.prepare(`SELECT COUNT(*) count FROM symbol_calls sc JOIN symbols s ON s.id=sc.callee_symbol_id
335
+ WHERE json_extract(sc.evidence_json,'$.callArguments[0].kind')='object_literal'
336
+ AND json_extract(s.evidence_json,'$.parameterBindings[0].kind')='object_pattern'
337
+ AND json_array_length(json_extract(sc.evidence_json,'$.callArguments[0].properties')) > json_array_length(json_extract(s.evidence_json,'$.parameterBindings[0].properties'))`).get() as { count?: number };
338
+ const opportunities = db.prepare(`SELECT c.source_file sourceFile,c.source_line sourceLine,json_extract(c.evidence_json,'$.receiver') receiverName,c.operation_path_expr operationPath,b.alias bindingAlias,b.alias_expr bindingAliasExpr,b.service_path_expr servicePathExpr,b.destination_expr destinationExpr,req.service_path requireServicePath,req.destination requireDestination,COALESCE(e.status,'missing_edge') persistedStatus,
339
+ CASE
340
+ WHEN (b.alias_expr LIKE '%$%' OR b.service_path_expr LIKE '%$%' OR b.destination_expr LIKE '%$%') THEN 'runtime_variables_required'
341
+ WHEN b.alias IS NOT NULL AND req.id IS NULL AND b.service_path_expr IS NULL THEN 'alias_without_matching_cds_requires'
342
+ WHEN req.id IS NOT NULL AND COALESCE(e.status,'missing_edge')!='resolved' THEN 'cds_requires_present_but_persisted_resolution_unresolved'
343
+ ELSE 'trace_time_contextual_binding_candidate'
344
+ END contextualStatus
345
+ FROM outbound_calls c
346
+ LEFT JOIN graph_edges e ON e.from_kind='call' AND e.from_id=CAST(c.id AS TEXT)
347
+ LEFT JOIN service_bindings b ON b.id=c.service_binding_id
348
+ LEFT JOIN cds_requires req ON req.repo_id=c.repo_id AND req.alias=b.alias
349
+ WHERE c.call_type='remote_action' AND json_extract(c.evidence_json,'$.receiver') IS NOT NULL
350
+ AND (c.service_binding_id IS NULL OR e.status IS NULL OR e.status!='resolved')
351
+ AND EXISTS (SELECT 1 FROM symbol_calls sc WHERE sc.status='resolved' AND sc.source_file=c.source_file)
352
+ ORDER BY c.source_file,c.source_line LIMIT 8`).all() as Array<Record<string, unknown>>;
353
+ const statusRows = db.prepare(`SELECT contextualStatus,COUNT(*) count FROM (
354
+ SELECT CASE
355
+ WHEN (b.alias_expr LIKE '%$%' OR b.service_path_expr LIKE '%$%' OR b.destination_expr LIKE '%$%') THEN 'runtime_variables_required'
356
+ WHEN b.alias IS NOT NULL AND req.id IS NULL AND b.service_path_expr IS NULL THEN 'alias_without_matching_cds_requires'
357
+ WHEN req.id IS NOT NULL AND COALESCE(e.status,'missing_edge')!='resolved' THEN 'cds_requires_present_but_persisted_resolution_unresolved'
358
+ ELSE 'trace_time_contextual_binding_candidate'
359
+ END contextualStatus
360
+ FROM outbound_calls c
361
+ LEFT JOIN graph_edges e ON e.from_kind='call' AND e.from_id=CAST(c.id AS TEXT)
362
+ LEFT JOIN service_bindings b ON b.id=c.service_binding_id
363
+ LEFT JOIN cds_requires req ON req.repo_id=c.repo_id AND req.alias=b.alias
364
+ WHERE c.call_type='remote_action' AND json_extract(c.evidence_json,'$.receiver') IS NOT NULL
365
+ AND (c.service_binding_id IS NULL OR e.status IS NULL OR e.status!='resolved')
366
+ AND EXISTS (SELECT 1 FROM symbol_calls sc WHERE sc.status='resolved' AND sc.source_file=c.source_file)
367
+ ) GROUP BY contextualStatus ORDER BY count DESC,contextualStatus`).all() as Array<Record<string, unknown>>;
368
+ const resolvedContextual = db.prepare(`SELECT COUNT(*) count FROM outbound_calls c JOIN graph_edges e ON e.from_kind='call' AND e.from_id=CAST(c.id AS TEXT) WHERE c.call_type='remote_action' AND e.status='resolved' AND c.service_binding_id IS NOT NULL`).get() as { count?: number };
369
+ const totalOpportunities = statusRows.reduce((sum, row) => sum + Number(row.count ?? 0), 0);
370
+ const actionableStatuses = new Set(['alias_without_matching_cds_requires', 'cds_requires_present_but_persisted_resolution_unresolved', 'trace_time_contextual_binding_candidate']);
371
+ const actionableOpportunityCount = statusRows.reduce((sum, row) => actionableStatuses.has(String(row.contextualStatus)) ? sum + Number(row.count ?? 0) : sum, 0);
372
+ const severity = Number(missingMetadata.count ?? 0) + Number(destructuredUnmapped.count ?? 0) + actionableOpportunityCount > 0 ? 'warning' : 'info';
373
+ return { severity, code: 'strict_contextual_binding_propagation_quality', message: 'Contextual service-client propagation opportunities for trace-time helper resolution', localSymbolCallsWithServiceClientArguments: Number(serviceClientCalls.count ?? 0), calleeSymbolsMissingParameterMetadata: Number(missingMetadata.count ?? 0), destructuredObjectParametersPossiblyUnmapped: Number(destructuredUnmapped.count ?? 0), contextualHelperSendsResolvedDuringPersistedLink: Number(resolvedContextual.count ?? 0), traceTimeContextualOpportunities: totalOpportunities, traceTimeContextualOpportunityBreakdown: statusRows.length > 0 ? statusRows : [{ contextualStatus: 'no_contextual_opportunity', count: 0 }], exampleCount: opportunities.length, examples: opportunities };
374
+ }
375
+
376
+ function nestedThisReceiverQuality(db: ReturnType<typeof openDatabase>): Record<string, unknown> {
377
+ const aggregate = db.prepare(`SELECT COUNT(*) total,
378
+ SUM(CASE WHEN json_extract(evidence_json,'$.relation')='indexed_this_method' THEN 1 ELSE 0 END) resolvedToCurrentClass,
379
+ SUM(CASE WHEN json_extract(evidence_json,'$.relation')='class_instance_method' THEN 1 ELSE 0 END) withExplicitHelperInstanceEvidence
380
+ FROM symbol_calls WHERE callee_expression LIKE 'this.%.%'`).get() as { total?: number; resolvedToCurrentClass?: number; withExplicitHelperInstanceEvidence?: number };
381
+ const examples = db.prepare(`SELECT source_file sourceFile,source_line sourceLine,callee_expression calleeExpression,json_extract(evidence_json,'$.relation') relation,json_extract(evidence_json,'$.targetName') targetName
382
+ FROM symbol_calls WHERE callee_expression LIKE 'this.%.%' AND json_extract(evidence_json,'$.relation')='indexed_this_method'
383
+ ORDER BY source_file,source_line LIMIT 8`).all() as Array<Record<string, unknown>>;
384
+ return { severity: Number(aggregate.resolvedToCurrentClass ?? 0) > 0 ? 'warning' : 'info', code: 'strict_nested_this_receiver_quality', message: 'Nested this receiver symbol-call aggregate', nestedThisReceiverCallsConsidered: Number(aggregate.total ?? 0), nestedThisResolvedToCurrentClass: Number(aggregate.resolvedToCurrentClass ?? 0), nestedThisWithExplicitHelperInstanceEvidence: Number(aggregate.withExplicitHelperInstanceEvidence ?? 0), warningExamples: examples };
385
+ }
386
+
387
+ function contextualImplementationQuality(db: ReturnType<typeof openDatabase>): Record<string, unknown> {
388
+ const rows = db.prepare(`SELECT status,COALESCE(unresolved_reason,status) reason,COUNT(*) count
389
+ FROM graph_edges WHERE edge_type='OPERATION_IMPLEMENTED_BY_HANDLER' AND status IN ('ambiguous','unresolved') GROUP BY status,reason ORDER BY status,count DESC,reason`).all() as Array<Record<string, unknown>>;
390
+ const examples = db.prepare(`SELECT json_extract(evidence_json,'$.servicePath') servicePath,json_extract(evidence_json,'$.operationPath') operationPath,status,unresolved_reason unresolvedReason,
391
+ json_extract(evidence_json,'$.candidates[0].rejectedReasons[0]') topRejectedReason,
392
+ json_extract(evidence_json,'$.candidates[0].acceptedReasons[0]') topAcceptedReason
393
+ FROM graph_edges WHERE edge_type='OPERATION_IMPLEMENTED_BY_HANDLER' AND status IN ('ambiguous','unresolved') ORDER BY status,id LIMIT 6`).all() as Array<Record<string, unknown>>;
394
+ const total = rows.reduce((sum, row) => sum + Number(row.count ?? 0), 0);
395
+ return { severity: total > 0 ? 'warning' : 'info', code: 'strict_contextual_implementation_quality', message: 'Implementation hops stopped by ambiguous or unresolved implementation edges', total, breakdown: rows, examples };
396
+ }
397
+
398
+ function wrapperPathPropagationQuality(db: ReturnType<typeof openDatabase>): Record<string, unknown> {
399
+ const examples = db.prepare(`SELECT source_file sourceFile,source_line sourceLine,json_extract(evidence_json,'$.receiver') receiverName,json_extract(evidence_json,'$.operationPathExpression') pathIdentifier,CASE WHEN json_extract(evidence_json,'$.literalCallerArgumentDetected') IS NOT NULL THEN 1 ELSE 0 END literalCallerArgumentDetected
400
+ FROM outbound_calls WHERE call_type='remote_action' AND unresolved_reason='dynamic_operation_path_identifier' ORDER BY source_file,source_line LIMIT 5`).all() as Array<Record<string, unknown>>;
401
+ const aggregate = db.prepare("SELECT COUNT(*) count FROM outbound_calls WHERE call_type='remote_action' AND unresolved_reason='dynamic_operation_path_identifier'").get() as { count?: number };
402
+ return { severity: Number(aggregate.count ?? 0) > 0 ? 'warning' : 'info', code: 'strict_wrapper_path_propagation_quality', message: 'Dynamic path sends where send({ path }) used a path identifier', dynamicPathIdentifierCalls: Number(aggregate.count ?? 0), examples };
403
+ }
404
+
405
+ function remoteQueryTargetQuality(db: ReturnType<typeof openDatabase>): Record<string, unknown> {
406
+ const aggregate = db.prepare(`SELECT COUNT(*) total,
407
+ SUM(CASE WHEN e.edge_type='HANDLER_RUNS_REMOTE_QUERY' AND e.status='terminal' THEN 1 ELSE 0 END) terminal,
408
+ SUM(CASE WHEN e.edge_type='HANDLER_RUNS_REMOTE_QUERY' AND e.to_id GLOB '[0-9]*' THEN 1 ELSE 0 END) numericTargets,
409
+ SUM(CASE WHEN e.edge_type='UNRESOLVED_EDGE' OR e.status='unresolved' THEN 1 ELSE 0 END) unresolved
410
+ FROM outbound_calls c LEFT JOIN graph_edges e ON e.from_kind='call' AND e.from_id=CAST(c.id AS TEXT) WHERE c.call_type='remote_query'`).get() as { total?: number; terminal?: number; numericTargets?: number; unresolved?: number };
411
+ const examples = db.prepare(`SELECT c.source_file sourceFile,c.source_line sourceLine,c.query_entity queryEntity,e.edge_type edgeType,e.status status,e.to_id target
412
+ FROM outbound_calls c LEFT JOIN graph_edges e ON e.from_kind='call' AND e.from_id=CAST(c.id AS TEXT)
413
+ WHERE c.call_type='remote_query' AND (e.id IS NULL OR e.edge_type<>'HANDLER_RUNS_REMOTE_QUERY' OR e.status<>'terminal' OR e.to_id GLOB '[0-9]*')
414
+ ORDER BY c.source_file,c.source_line LIMIT 5`).all() as Array<Record<string, unknown>>;
415
+ const numericTargets = Number(aggregate.numericTargets ?? 0);
416
+ const unresolved = Number(aggregate.unresolved ?? 0);
417
+ return { severity: numericTargets + unresolved > 0 ? 'warning' : 'info', code: 'strict_remote_query_target_quality', message: 'Remote query terminal target quality aggregate', totalRemoteQueryCalls: Number(aggregate.total ?? 0), terminalRemoteQueryEdges: Number(aggregate.terminal ?? 0), numericTargetCount: numericTargets, unresolvedRemoteQueryCount: unresolved, examples };
418
+ }
419
+
420
+ function remoteActionTargetQuality(db: ReturnType<typeof openDatabase>): Record<string, unknown> {
421
+ const aggregate = db.prepare(`SELECT COUNT(*) total,
422
+ SUM(CASE WHEN e.status='unresolved' THEN 1 ELSE 0 END) unresolved,
423
+ SUM(CASE WHEN e.status='unresolved' AND e.to_id GLOB '[0-9]*' THEN 1 ELSE 0 END) numericTargets,
424
+ SUM(CASE WHEN e.status='unresolved' AND (e.to_id='Remote action: unknown path' OR e.to_id='Remote action: dynamic path') THEN 1 ELSE 0 END) semanticTargets
425
+ FROM outbound_calls c LEFT JOIN graph_edges e ON e.from_kind='call' AND e.from_id=CAST(c.id AS TEXT) WHERE c.call_type='remote_action'`).get() as { total?: number; unresolved?: number; numericTargets?: number; semanticTargets?: number };
426
+ const examples = db.prepare(`SELECT c.source_file sourceFile,c.source_line sourceLine,c.operation_path_expr operationPath,e.status status,e.to_id target
427
+ FROM outbound_calls c LEFT JOIN graph_edges e ON e.from_kind='call' AND e.from_id=CAST(c.id AS TEXT)
428
+ WHERE c.call_type='remote_action' AND e.status='unresolved' AND e.to_id GLOB '[0-9]*' ORDER BY c.source_file,c.source_line LIMIT 5`).all() as Array<Record<string, unknown>>;
429
+ const numericTargets = Number(aggregate.numericTargets ?? 0);
430
+ return { severity: numericTargets > 0 ? 'warning' : 'info', code: 'strict_remote_action_target_quality', message: 'Remote action unresolved target quality aggregate', totalRemoteActionCalls: Number(aggregate.total ?? 0), unresolvedRemoteActionCalls: Number(aggregate.unresolved ?? 0), numericUnresolvedTargetCount: numericTargets, semanticUnknownOrDynamicTargetCount: Number(aggregate.semanticTargets ?? 0), examples };
431
+ }
432
+
433
+
434
+ function externalHttpTargetQuality(db: ReturnType<typeof openDatabase>): Record<string, unknown> {
435
+ const aggregate = db.prepare(`SELECT COUNT(*) total,
436
+ SUM(CASE WHEN e.to_kind='external_destination' THEN 1 ELSE 0 END) destinationTargets,
437
+ SUM(CASE WHEN e.to_kind='external_endpoint' AND json_extract(e.evidence_json,'$.externalTarget.kind')='static_url' THEN 1 ELSE 0 END) staticEndpointTargets,
438
+ SUM(CASE WHEN e.to_kind='external_endpoint' AND json_extract(e.evidence_json,'$.externalTarget.dynamic')=1 THEN 1 ELSE 0 END) dynamicEndpointTargets,
439
+ SUM(CASE WHEN e.to_kind='external_endpoint' AND json_extract(e.evidence_json,'$.externalTarget.kind')='unknown' THEN 1 ELSE 0 END) unknownEndpointTargets,
440
+ SUM(CASE WHEN e.to_id GLOB '[0-9]*' THEN 1 ELSE 0 END) numericTargets,
441
+ SUM(CASE WHEN e.evidence_json IS NULL OR json_valid(e.evidence_json)=0 OR json_extract(e.evidence_json,'$.externalTarget.kind') IS NULL THEN 1 ELSE 0 END) invalidEvidence
442
+ FROM outbound_calls c LEFT JOIN graph_edges e ON e.from_kind='call' AND e.from_id=CAST(c.id AS TEXT) WHERE c.call_type='external_http'`).get() as Record<string, unknown>;
443
+ const examples = db.prepare(`SELECT c.source_file sourceFile,c.source_line sourceLine,e.to_kind targetKind,e.to_id targetId,json_extract(e.evidence_json,'$.externalTarget.label') label,json_extract(e.evidence_json,'$.externalTarget.kind') kind
444
+ FROM outbound_calls c LEFT JOIN graph_edges e ON e.from_kind='call' AND e.from_id=CAST(c.id AS TEXT)
445
+ WHERE c.call_type='external_http' AND (e.to_id GLOB '[0-9]*' OR e.evidence_json IS NULL OR json_valid(e.evidence_json)=0 OR json_extract(e.evidence_json,'$.externalTarget.kind') IS NULL)
446
+ ORDER BY c.source_file,c.source_line LIMIT 5`).all() as Array<Record<string, unknown>>;
447
+ const numericTargets = Number(aggregate.numericTargets ?? 0);
448
+ const invalidEvidence = Number(aggregate.invalidEvidence ?? 0);
449
+ return { severity: numericTargets + invalidEvidence > 0 ? 'warning' : 'info', code: 'strict_external_http_target_quality', message: 'External HTTP semantic target aggregate', totalExternalHttpCalls: Number(aggregate.total ?? 0), semanticDestinationTargets: Number(aggregate.destinationTargets ?? 0), semanticStaticEndpointTargets: Number(aggregate.staticEndpointTargets ?? 0), dynamicEndpointTargets: Number(aggregate.dynamicEndpointTargets ?? 0), unknownEndpointTargets: Number(aggregate.unknownEndpointTargets ?? 0), numericTargetCount: numericTargets, invalidOrMissingExternalTargetEvidence: invalidEvidence, examples };
450
+ }
451
+
452
+ function odataInvocationResolutionQuality(db: ReturnType<typeof openDatabase>): Record<string, unknown> {
453
+ const aggregate = db.prepare(`SELECT COUNT(*) total,
454
+ SUM(CASE WHEN e.status='resolved' THEN 1 ELSE 0 END) resolved,
455
+ SUM(CASE WHEN e.status='dynamic' THEN 1 ELSE 0 END) dynamic,
456
+ SUM(CASE WHEN e.status='ambiguous' THEN 1 ELSE 0 END) ambiguous,
457
+ SUM(CASE WHEN e.status='unresolved' THEN 1 ELSE 0 END) unresolved
458
+ FROM outbound_calls c JOIN graph_edges e ON e.from_kind='call' AND e.from_id=CAST(c.id AS TEXT)
459
+ WHERE c.call_type='remote_action' AND c.operation_path_expr LIKE '%(%'`) .get() as { total?: number; resolved?: number; dynamic?: number; ambiguous?: number; unresolved?: number };
460
+ const rows = db.prepare(`SELECT c.id id,c.operation_path_expr operationPathExpr,c.source_file sourceFile,c.source_line sourceLine,e.status status,e.unresolved_reason unresolvedReason
461
+ FROM outbound_calls c JOIN graph_edges e ON e.from_kind='call' AND e.from_id=CAST(c.id AS TEXT)
462
+ WHERE c.call_type='remote_action' AND e.status IN ('unresolved','ambiguous') AND c.operation_path_expr LIKE '%(%'
463
+ ORDER BY c.source_file,c.source_line LIMIT 100`).all() as Array<{ id?: number; operationPathExpr?: string; sourceFile?: string; sourceLine?: number; status?: string; unresolvedReason?: string }>;
464
+ const examples: Array<Record<string, unknown>> = [];
465
+ let unresolvedMatchingIndexedOperation = 0;
466
+ let ambiguousNormalizedCalls = 0;
467
+ for (const row of rows) {
468
+ const normalized = normalizeODataOperationInvocationPath(row.operationPathExpr);
469
+ if (!normalized?.wasInvocation) continue;
470
+ const normalizedName = normalized.normalizedOperationPath.replace(/^\//, '');
471
+ const simpleName = normalizedName.split('.').at(-1) ?? normalizedName;
472
+ const candidates = db.prepare('SELECT s.service_path servicePath,o.operation_path operationPath,o.operation_name operationName FROM cds_operations o JOIN cds_services s ON s.id=o.service_id WHERE o.operation_path IN (?,?) OR o.operation_name IN (?,?) ORDER BY s.service_path,o.operation_name LIMIT 5').all(normalized.normalizedOperationPath, `/${simpleName}`, normalizedName, simpleName) as Array<Record<string, unknown>>;
473
+ if (candidates.length === 0) continue;
474
+ if (row.status === 'ambiguous') ambiguousNormalizedCalls += 1;
475
+ if (row.status === 'unresolved') unresolvedMatchingIndexedOperation += 1;
476
+ if (examples.length < 5) examples.push({ sourceFile: row.sourceFile, sourceLine: row.sourceLine, rawOperationPath: row.operationPathExpr, normalizedOperationPath: normalized.normalizedOperationPath, candidateCount: candidates.length, candidates });
477
+ }
478
+ return { severity: unresolvedMatchingIndexedOperation + ambiguousNormalizedCalls > 0 ? 'warning' : 'info', code: 'strict_odata_invocation_resolution_quality', message: 'OData invocation-path resolution quality aggregate', totalInvocationRemoteActions: Number(aggregate.total ?? 0), resolvedInvocationCalls: Number(aggregate.resolved ?? 0), dynamicInvocationCalls: Number(aggregate.dynamic ?? 0), ambiguousInvocationCalls: Number(aggregate.ambiguous ?? 0), unresolvedInvocationCalls: Number(aggregate.unresolved ?? 0), ambiguousNormalizedCalls, unresolvedNormalizedCallsWithIndexedCandidates: unresolvedMatchingIndexedOperation, examples };
479
+ }
480
+
481
+ export function createProgram(): Command {
482
+ const program = new Command();
483
+ program
484
+ .name('service-flow')
485
+ .description(
486
+ 'Trace SAP CAP service-to-service flows across multi-repository workspaces',
487
+ )
488
+ .version(VERSION);
489
+ program
490
+ .command('init')
491
+ .argument('<workspace>')
492
+ .option('--db <path>')
493
+ .option('--ignore <pattern...>')
494
+ .action(
495
+ (workspace: string, opts: { db?: string; ignore?: string[] }) =>
496
+ void init(workspace, opts).catch(fail),
497
+ );
498
+ program
499
+ .command('index')
500
+ .option('--workspace <path>')
501
+ .option('--repo <name>')
502
+ .option('--force')
503
+ .action(
504
+ (opts: { workspace?: string; repo?: string; force?: boolean }) =>
505
+ void withWorkspace(opts.workspace, async (db, workspaceId) => {
506
+ const r = await indexWorkspace(db, workspaceId, {
507
+ repo: opts.repo,
508
+ force: Boolean(opts.force),
509
+ });
510
+ process.stdout.write(
511
+ `Indexed ${r.indexedCount} repositories, skipped ${r.skippedCount}, ${r.fileCount} files, ${r.diagnosticCount} diagnostics\n`,
512
+ );
513
+ }).catch(fail),
514
+ );
515
+ program
516
+ .command('link')
517
+ .option('--workspace <path>')
518
+ .option('--force')
519
+ .action(
520
+ (opts: { workspace?: string }) =>
521
+ void withWorkspace(opts.workspace, (db, workspaceId) => {
522
+ const r = linkWorkspace(db, workspaceId);
523
+ const upgradeWarnings = linkUpgradeWarnings(db);
524
+ process.stdout.write(
525
+ `${upgradeWarnings.length ? `Warnings: ${upgradeWarnings.map((item) => String(item.code)).join(', ')}. Run service-flow doctor --strict for remediation.\n` : ''}Linked ${r.edgeCount} edges: ${r.remoteResolvedCount} remote operation calls resolved, ${r.localResolvedCount} local operation calls resolved, ${r.unresolvedCount} unresolved operation calls, ${r.ambiguousCount} ambiguous operation calls, ${r.dynamicCount} dynamic operation calls, ${r.terminalCount} terminal call edges, ${r.dependencyResolvedCount} dependency resolved, ${r.dependencyAmbiguousCount} dependency ambiguous, ${r.implementationResolvedCount} implementation resolved, ${r.implementationAmbiguousCount} implementation ambiguous, ${r.implementationUnresolvedCount} implementation unresolved\n`,
526
+ );
527
+ }).catch(fail),
528
+ );
529
+ program
530
+ .command('trace')
531
+ .option('--workspace <path>')
532
+ .option('--repo <name>')
533
+ .option('--operation <name>')
534
+ .option('--service <path>')
535
+ .option('--path <operationPath>')
536
+ .option('--handler <name>')
537
+ .option('--depth <n>', 'trace depth', '25')
538
+ .option('--format <format>', 'table|json|mermaid', 'table')
539
+ .option('--include-external')
540
+ .option('--include-db')
541
+ .option('--include-async')
542
+ .option('--var <key=value>', 'dynamic variable', collect, [])
543
+ .action(
544
+ (opts: {
545
+ workspace?: string;
546
+ repo?: string;
547
+ operation?: string;
548
+ service?: string;
549
+ path?: string;
550
+ handler?: string;
551
+ depth: string;
552
+ format: string;
553
+ includeExternal?: boolean;
554
+ includeDb?: boolean;
555
+ includeAsync?: boolean;
556
+ var: string[];
557
+ }) =>
558
+ void withReadOnlyWorkspace(opts.workspace, (db) => {
559
+ const result = trace(
560
+ db,
561
+ {
562
+ repo: opts.repo,
563
+ servicePath: opts.service,
564
+ operation: opts.operation,
565
+ operationPath: opts.path,
566
+ handler: opts.handler,
567
+ },
568
+ {
569
+ depth: Number(opts.depth),
570
+ vars: parseVars(opts.var),
571
+ includeExternal: Boolean(opts.includeExternal),
572
+ includeDb: Boolean(opts.includeDb),
573
+ includeAsync: Boolean(opts.includeAsync),
574
+ },
575
+ );
576
+ process.stdout.write(
577
+ opts.format === 'json'
578
+ ? renderTraceJson(result)
579
+ : opts.format === 'mermaid'
580
+ ? renderMermaid(result)
581
+ : renderTraceTable(result),
582
+ );
583
+ }).catch(fail),
584
+ );
585
+ const list = program.command('list');
586
+ list
587
+ .command('repos')
588
+ .option('--workspace <path>')
589
+ .action(
590
+ (opts: { workspace?: string }) =>
591
+ void withReadOnlyWorkspace(opts.workspace, (db) =>
592
+ process.stdout.write(
593
+ renderJson(
594
+ listRepositories(db).map((r) => ({
595
+ name: r.name,
596
+ kind: r.kind,
597
+ packageName: r.package_name,
598
+ })),
599
+ ),
600
+ ),
601
+ ).catch(fail),
602
+ );
603
+ list
604
+ .command('services')
605
+ .option('--workspace <path>')
606
+ .option('--repo <name>')
607
+ .action(
608
+ (opts: { workspace?: string; repo?: string }) =>
609
+ void withReadOnlyWorkspace(opts.workspace, (db) => {
610
+ const repo = opts.repo ? repoByName(db, opts.repo) : undefined;
611
+ if (opts.repo && !repo) {
612
+ process.stdout.write(renderJson([{ severity: 'warning', code: 'selector_repo_not_found', message: `Repository selector not found: ${opts.repo}` }]));
613
+ return;
614
+ }
615
+ const rows = db
616
+ .prepare(
617
+ 'SELECT r.name repo,s.service_path servicePath,s.qualified_name qualifiedName FROM cds_services s JOIN repositories r ON r.id=s.repo_id WHERE (? IS NULL OR s.repo_id=?) ORDER BY r.name,s.service_path',
618
+ )
619
+ .all(repo?.id, repo?.id);
620
+ process.stdout.write(renderJson(rows));
621
+ }).catch(fail),
622
+ );
623
+ list
624
+ .command('operations')
625
+ .option('--workspace <path>')
626
+ .option('--repo <name>')
627
+ .option('--service <path>')
628
+ .action(
629
+ (opts: { workspace?: string; repo?: string; service?: string }) =>
630
+ void withReadOnlyWorkspace(opts.workspace, (db) => {
631
+ const repo = opts.repo ? repoByName(db, opts.repo) : undefined;
632
+ if (opts.repo && !repo) {
633
+ process.stdout.write(renderJson([{ severity: 'warning', code: 'selector_repo_not_found', message: `Repository selector not found: ${opts.repo}` }]));
634
+ return;
635
+ }
636
+ const rows = db
637
+ .prepare(
638
+ 'SELECT r.name repo,s.service_path servicePath,o.operation_name operation,o.operation_path path FROM cds_operations o JOIN cds_services s ON s.id=o.service_id JOIN repositories r ON r.id=s.repo_id WHERE (? IS NULL OR s.repo_id=?) AND (? IS NULL OR s.service_path=?)',
639
+ )
640
+ .all(repo?.id, repo?.id, opts.service, opts.service);
641
+ process.stdout.write(renderJson(rows));
642
+ }).catch(fail),
643
+ );
644
+ list
645
+ .command('calls')
646
+ .option('--workspace <path>')
647
+ .option('--repo <name>')
648
+ .option('--operation <name>')
649
+ .action(
650
+ (opts: { workspace?: string; repo?: string; operation?: string }) =>
651
+ void withReadOnlyWorkspace(opts.workspace, (db) => {
652
+ const repo = opts.repo ? repoByName(db, opts.repo) : undefined;
653
+ if (opts.repo && !repo) {
654
+ process.stdout.write(renderJson([{ severity: 'warning', code: 'selector_repo_not_found', message: `Repository selector not found: ${opts.repo}` }]));
655
+ return;
656
+ }
657
+ const rows = db
658
+ .prepare(
659
+ 'SELECT r.name repo,c.call_type type,c.operation_path_expr path,c.source_file file,c.source_line line FROM outbound_calls c JOIN repositories r ON r.id=c.repo_id WHERE (? IS NULL OR c.repo_id=?) AND (? IS NULL OR c.operation_path_expr=? OR c.operation_path_expr=? OR c.payload_summary LIKE ?)',
660
+ )
661
+ .all(
662
+ repo?.id,
663
+ repo?.id,
664
+ opts.operation,
665
+ opts.operation,
666
+ opts.operation ? `/${opts.operation}` : undefined,
667
+ opts.operation ? `%${opts.operation}%` : undefined,
668
+ );
669
+ process.stdout.write(renderJson(rows));
670
+ }).catch(fail),
671
+ );
672
+ program
673
+ .command('graph')
674
+ .option('--workspace <path>')
675
+ .option('--repo <name>')
676
+ .option('--operation <name>')
677
+ .option('--service <path>')
678
+ .option('--path <operationPath>')
679
+ .option('--format <format>', 'mermaid|json', 'mermaid')
680
+ .option('--var <key=value>', 'dynamic variable', collect, [])
681
+ .action(
682
+ (opts: {
683
+ workspace?: string;
684
+ repo?: string;
685
+ operation?: string;
686
+ service?: string;
687
+ path?: string;
688
+ format: string;
689
+ var: string[];
690
+ }) =>
691
+ void withReadOnlyWorkspace(opts.workspace, (db) => {
692
+ const result = trace(
693
+ db,
694
+ {
695
+ repo: opts.repo,
696
+ operation: opts.operation,
697
+ servicePath: opts.service,
698
+ operationPath: opts.path,
699
+ },
700
+ {
701
+ depth: 100,
702
+ includeAsync: true,
703
+ includeDb: true,
704
+ includeExternal: true,
705
+ vars: parseVars(opts.var),
706
+ },
707
+ );
708
+ process.stdout.write(
709
+ opts.format === 'json'
710
+ ? renderTraceJson(result)
711
+ : renderMermaid(result),
712
+ );
713
+ }).catch(fail),
714
+ );
715
+ const inspect = program.command('inspect');
716
+ inspect
717
+ .command('repo')
718
+ .argument('<name>')
719
+ .option('--workspace <path>')
720
+ .action(
721
+ (name: string, opts: { workspace?: string }) =>
722
+ void withReadOnlyWorkspace(opts.workspace, (db) =>
723
+ process.stdout.write(
724
+ renderJson(repoByName(db, name) ?? { error: 'repo not found' }),
725
+ ),
726
+ ).catch(fail),
727
+ );
728
+ inspect
729
+ .command('operation')
730
+ .argument('<selector>')
731
+ .option('--workspace <path>')
732
+ .action(
733
+ (selector: string, opts: { workspace?: string }) =>
734
+ void withReadOnlyWorkspace(opts.workspace, (db) => {
735
+ const rows = db
736
+ .prepare(
737
+ 'SELECT * FROM cds_operations WHERE operation_name=? OR operation_path=?',
738
+ )
739
+ .all(selector, selector);
740
+ process.stdout.write(renderJson(rows));
741
+ }).catch(fail),
742
+ );
743
+ program
744
+ .command('doctor')
745
+ .option('--workspace <path>')
746
+ .option('--strict')
747
+ .action(
748
+ (opts: { workspace?: string; strict?: boolean }) =>
749
+ void withReadOnlyWorkspace(opts.workspace, (db) => {
750
+ const diagnostics = db
751
+ .prepare(
752
+ 'SELECT severity,code,message,source_file sourceFile,source_line sourceLine FROM diagnostics ORDER BY id',
753
+ )
754
+ .all() as Array<Record<string, unknown>>;
755
+ const health = db
756
+ .prepare(
757
+ `SELECT 'info' severity,'entity_only_service' code,'CDS service has no action/function/event operations; this can be valid for entity-only services' message,s.source_file sourceFile,s.source_line sourceLine
758
+ FROM cds_services s LEFT JOIN cds_operations o ON o.service_id=s.id WHERE o.id IS NULL AND ?
759
+ UNION ALL
760
+ SELECT 'warning','extend_service_unresolved_base','Extend service has no indexed local operations; verify base service resolution',s.source_file,s.source_line
761
+ FROM cds_services s LEFT JOIN cds_operations o ON o.service_id=s.id WHERE o.id IS NULL AND s.is_extend=1 AND ?
762
+ UNION ALL
763
+ SELECT 'warning','handler_without_service','Repository has handlers but no CDS services',hc.source_file,hc.source_line
764
+ FROM handler_classes hc JOIN repositories r ON r.id=hc.repo_id
765
+ WHERE r.kind IN ('cap-service','mixed') AND NOT EXISTS (SELECT 1 FROM cds_services s WHERE s.repo_id=hc.repo_id)
766
+ UNION ALL
767
+ SELECT 'warning','search_index_empty','Search index is empty after indexing',NULL,NULL
768
+ WHERE NOT EXISTS (SELECT 1 FROM search_index)
769
+ UNION ALL
770
+ SELECT 'error','foreign_key_violation','SQLite foreign_key_check reported integrity failures',NULL,NULL
771
+ WHERE EXISTS (SELECT 1 FROM pragma_foreign_key_check)
772
+ UNION ALL
773
+ SELECT 'warning','legacy_schema_weaker_foreign_keys','Legacy table lacks fresh-schema foreign-key metadata; rebuild the database or re-run init/index in a new database',NULL,NULL
774
+ WHERE (SELECT COUNT(*) FROM pragma_foreign_key_list('graph_edges'))=0 OR (SELECT COUNT(*) FROM pragma_foreign_key_list('index_runs'))=0 OR (SELECT COUNT(*) FROM pragma_foreign_key_list('diagnostics'))=0
775
+ UNION ALL
776
+ SELECT 'warning','implementation_candidates_rejected','Implementation candidates were rejected for ' || s.service_path || o.operation_path,o.source_file,o.source_line
777
+ FROM graph_edges e
778
+ JOIN cds_operations o ON o.id=CAST(e.from_id AS INTEGER)
779
+ JOIN cds_services s ON s.id=o.service_id
780
+ WHERE e.edge_type='OPERATION_IMPLEMENTED_BY_HANDLER' AND e.status='unresolved' AND (? OR EXISTS (SELECT 1 FROM graph_edges remote WHERE remote.edge_type='REMOTE_CALL_RESOLVES_TO_OPERATION' AND remote.to_kind='operation' AND remote.to_id=e.from_id))
781
+ UNION ALL
782
+ SELECT 'warning','remote_target_without_implementation','Remote target operation has no implementation edge: ' || s.service_path || o.operation_path,o.source_file,o.source_line
783
+ FROM graph_edges remote
784
+ JOIN cds_operations o ON o.id=CAST(remote.to_id AS INTEGER)
785
+ JOIN cds_services s ON s.id=o.service_id
786
+ WHERE remote.edge_type='REMOTE_CALL_RESOLVES_TO_OPERATION' AND remote.to_kind='operation' AND NOT EXISTS (SELECT 1 FROM graph_edges impl WHERE impl.edge_type='OPERATION_IMPLEMENTED_BY_HANDLER' AND impl.from_kind='operation' AND impl.from_id=remote.to_id) AND ?
787
+ UNION ALL
788
+ SELECT CASE WHEN ? THEN 'warning' ELSE 'error' END,'local_service_calls_all_unresolved','All local service calls are unresolved; verify local service alias parsing and linking',NULL,NULL
789
+ WHERE EXISTS (SELECT 1 FROM outbound_calls WHERE call_type='local_service_call') AND NOT EXISTS (SELECT 1 FROM graph_edges e JOIN outbound_calls c ON e.from_kind='call' AND c.id=CAST(e.from_id AS INTEGER) WHERE c.call_type='local_service_call' AND e.status='resolved')
790
+ UNION ALL
791
+ SELECT 'error','local_service_accessor_misclassified','Entity accessor calls were indexed as /entities operations',source_file,source_line
792
+ FROM outbound_calls WHERE call_type='local_service_call' AND operation_path_expr='/entities' AND (? OR 1)
793
+ UNION ALL
794
+ SELECT 'warning','outbound_calls_without_source_symbol','Outbound calls lack source symbol ownership: ' || COUNT(*),NULL,NULL
795
+ FROM outbound_calls WHERE source_symbol_id IS NULL AND ? HAVING COUNT(*) >= 1
796
+ UNION ALL
797
+ SELECT 'warning','trace_scope_fell_back_to_file','Trace may fall back to source-file scope for calls without symbols',NULL,NULL
798
+ WHERE EXISTS (SELECT 1 FROM outbound_calls WHERE source_symbol_id IS NULL) AND ?
799
+ UNION ALL
800
+ SELECT 'warning','graph_stale','Graph is stale after repository fact changes; run service-flow link',NULL,NULL
801
+ WHERE EXISTS (SELECT 1 FROM repositories WHERE graph_stale_reason IS NOT NULL)
802
+ UNION ALL
803
+ SELECT 'warning','index_run_abandoned','Index run ' || id || ' started at ' || started_at || ' is still running after the 60 minute abandonment threshold',NULL,NULL
804
+ FROM index_runs WHERE status='running' AND datetime(started_at) < datetime('now','-60 minutes')`,
805
+ )
806
+ .all(Boolean(opts.strict), Boolean(opts.strict), Boolean(opts.strict), Boolean(opts.strict), Boolean(opts.strict), Boolean(opts.strict), Boolean(opts.strict), Boolean(opts.strict)) as Array<Record<string, unknown>>;
807
+ const localServiceHealth = localServiceDiagnostics(db, Boolean(opts.strict));
808
+ const parserQualityHealth = parserQualityDiagnostics(db, Boolean(opts.strict));
809
+ const schemaDriftHealth = schemaDriftDiagnostics(db, Boolean(opts.strict));
810
+ const analyzerVersionHealth = analyzerVersionDiagnostics(db, Boolean(opts.strict));
811
+ const allDiagnostics = [...diagnostics, ...health, ...localServiceHealth, ...schemaDriftHealth, ...analyzerVersionHealth, ...parserQualityHealth];
812
+ process.stdout.write(
813
+ allDiagnostics.length
814
+ ? renderJson(allDiagnostics)
815
+ : `${pc.green('No diagnostics recorded')}\n`,
816
+ );
817
+ }).catch(fail),
818
+ );
819
+ program
820
+ .command('clean')
821
+ .option('--workspace <path>')
822
+ .option('--db-only')
823
+ .action(
824
+ (opts: { workspace?: string; dbOnly?: boolean }) =>
825
+ void (async () => {
826
+ const config = await loadWorkspaceConfig(opts.workspace);
827
+ const dbDir = path.resolve(path.dirname(config.dbPath));
828
+ const workspaceRoot = path.resolve(config.rootPath);
829
+ await fs.rm(config.dbPath, { force: true });
830
+ if (!opts.dbOnly) {
831
+ const marker = path.join(dbDir, '.service-flow-state');
832
+ const dangerous = new Set([
833
+ path.parse(dbDir).root,
834
+ '/tmp',
835
+ process.env.HOME ? path.resolve(process.env.HOME) : '',
836
+ workspaceRoot,
837
+ ]);
838
+ let ownsState: boolean;
839
+ try {
840
+ ownsState = (await fs.stat(marker)).isFile();
841
+ } catch {
842
+ ownsState = false;
843
+ }
844
+ if (!ownsState || dangerous.has(dbDir))
845
+ throw new Error(
846
+ `Refusing to recursively delete unowned or dangerous state directory: ${dbDir}. Use --db-only to remove only the database file.`,
847
+ );
848
+ await fs.rm(dbDir, { recursive: true, force: true });
849
+ }
850
+ process.stdout.write('Cleaned service-flow state\n');
851
+ })().catch(fail),
852
+ );
853
+ return program;
854
+ }
855
+ function collect(value: string, previous: string[]): string[] {
856
+ previous.push(value);
857
+ return previous;
858
+ }
859
+ function fail(error: unknown): void {
860
+ process.stderr.write(
861
+ `${error instanceof Error ? error.message : String(error)}\n`,
862
+ );
863
+ process.exitCode = 1;
864
+ }
865
+ createProgram().parse(process.argv);