@saptools/service-flow 0.1.65 → 0.1.67
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.
- package/CHANGELOG.md +16 -0
- package/README.md +67 -11
- package/TECHNICAL-NOTE.md +41 -1
- package/dist/{chunk-OONNRIDL.js → chunk-ZQABU7MR.js} +3764 -643
- package/dist/chunk-ZQABU7MR.js.map +1 -0
- package/dist/cli.js +158 -295
- package/dist/cli.js.map +1 -1
- package/dist/index.d.ts +196 -1
- package/dist/index.js +7 -3
- package/dist/index.js.map +1 -1
- package/package.json +1 -1
- package/src/cli/002-doctor-lifecycle.ts +66 -0
- package/src/cli/doctor.ts +14 -27
- package/src/cli.ts +130 -102
- package/src/db/000-call-fact-repository.ts +537 -0
- package/src/db/001-fact-lifecycle.ts +111 -0
- package/src/db/migrations.ts +16 -1
- package/src/db/repositories.ts +1 -315
- package/src/db/schema.ts +4 -2
- package/src/index.ts +22 -0
- package/src/linker/004-event-subscription-handler-linker.ts +300 -0
- package/src/linker/cross-repo-linker.ts +23 -2
- package/src/output/001-compact-json-output.ts +6 -0
- package/src/parsers/imported-wrapper-parser.ts +4 -0
- package/src/parsers/outbound-call-parser.ts +11 -1
- package/src/parsers/symbol-parser.ts +7 -4
- package/src/trace/010-traversal-scope.ts +188 -0
- package/src/trace/011-event-subscriber-traversal.ts +366 -0
- package/src/trace/012-trace-graph-lookups.ts +74 -0
- package/src/trace/013-trace-root-scopes.ts +279 -0
- package/src/trace/014-compact-contract.ts +347 -0
- package/src/trace/015-trace-edge-recorder.ts +336 -0
- package/src/trace/016-compact-projector.ts +697 -0
- package/src/trace/017-trace-context.ts +378 -0
- package/src/trace/018-compact-trace.ts +87 -0
- package/src/trace/019-trace-edge-semantics.ts +328 -0
- package/src/trace/020-compact-field-projection.ts +387 -0
- package/src/trace/dynamic-branches.ts +35 -13
- package/src/trace/trace-engine.ts +271 -245
- package/src/types.ts +10 -0
- package/src/version.ts +1 -1
- package/dist/chunk-OONNRIDL.js.map +0 -1
|
@@ -0,0 +1,537 @@
|
|
|
1
|
+
import { posix } from 'node:path';
|
|
2
|
+
import type { OutboundCallFact, SymbolCallFact } from '../types.js';
|
|
3
|
+
import { projectBounded } from '../utils/000-bounded-projection.js';
|
|
4
|
+
import type { Db, Statement } from './connection.js';
|
|
5
|
+
|
|
6
|
+
export function insertSymbolCalls(db: Db, repoId: number, rows: SymbolCallFact[]): void {
|
|
7
|
+
const callerStmt = db.prepare('SELECT id FROM symbols WHERE repo_id=? AND source_file=? AND qualified_name=? ORDER BY id LIMIT 1');
|
|
8
|
+
const insertStmt = db.prepare('INSERT INTO symbol_calls(repo_id,caller_symbol_id,callee_symbol_id,callee_expression,import_source,source_file,source_line,call_site_start_offset,call_site_end_offset,call_role,status,confidence,evidence_json,unresolved_reason) VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,?)');
|
|
9
|
+
for (const r of rows) {
|
|
10
|
+
const caller = callerStmt.get(repoId, r.sourceFile, r.callerQualifiedName) as { id?: number } | undefined;
|
|
11
|
+
const target = resolveSymbolCallTarget(db, repoId, r);
|
|
12
|
+
insertStmt.run(
|
|
13
|
+
repoId,
|
|
14
|
+
caller?.id,
|
|
15
|
+
target.id,
|
|
16
|
+
r.calleeExpression,
|
|
17
|
+
r.importSource,
|
|
18
|
+
r.sourceFile,
|
|
19
|
+
r.sourceLine,
|
|
20
|
+
r.callSiteStartOffset,
|
|
21
|
+
r.callSiteEndOffset,
|
|
22
|
+
r.callRole,
|
|
23
|
+
target.status,
|
|
24
|
+
0.8,
|
|
25
|
+
JSON.stringify({
|
|
26
|
+
...r.evidence,
|
|
27
|
+
candidateStrategy: target.strategy,
|
|
28
|
+
candidateCount: target.candidateCount,
|
|
29
|
+
resolvedModulePath: target.resolvedModulePath,
|
|
30
|
+
}),
|
|
31
|
+
target.reason,
|
|
32
|
+
);
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
interface SymbolTargetRow {
|
|
37
|
+
id: number;
|
|
38
|
+
kind?: string;
|
|
39
|
+
sourceFile?: string | null;
|
|
40
|
+
evidenceJson?: string | null;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
interface SymbolCallResolution {
|
|
44
|
+
id: number | null;
|
|
45
|
+
status: 'resolved' | 'ambiguous' | 'unresolved';
|
|
46
|
+
reason: string | null;
|
|
47
|
+
strategy: string;
|
|
48
|
+
candidateCount: number;
|
|
49
|
+
resolvedModulePath?: string;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
const stripExt = (value: string): string => value.replace(/\.(ts|tsx|js|jsx|cds)$/, '');
|
|
53
|
+
|
|
54
|
+
function symbolTargetRows(rows: Array<Record<string, unknown>>): SymbolTargetRow[] {
|
|
55
|
+
return rows.flatMap((row) => typeof row.id === 'number' ? [{
|
|
56
|
+
id: row.id,
|
|
57
|
+
kind: typeof row.kind === 'string' ? row.kind : undefined,
|
|
58
|
+
sourceFile: nullableString(row.sourceFile),
|
|
59
|
+
evidenceJson: nullableString(row.evidenceJson),
|
|
60
|
+
}] : []);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function relativeModuleTargets(callerSourceFile: string, importSource: string): Set<string> {
|
|
64
|
+
const base = posix.dirname(callerSourceFile);
|
|
65
|
+
const joined = stripExt(posix.normalize(posix.join(base, importSource)));
|
|
66
|
+
return new Set([joined, `${joined}/index`]);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function moduleRows(rows: SymbolTargetRow[], r: SymbolCallFact): SymbolTargetRow[] {
|
|
70
|
+
if (!r.importSource) return [];
|
|
71
|
+
const targets = relativeModuleTargets(r.sourceFile, r.importSource);
|
|
72
|
+
return rows.filter((row) => typeof row.sourceFile === 'string'
|
|
73
|
+
&& targets.has(stripExt(row.sourceFile)));
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function resolvedSymbol(
|
|
77
|
+
row: SymbolTargetRow,
|
|
78
|
+
strategy: string,
|
|
79
|
+
candidateCount: number,
|
|
80
|
+
moduleScoped = false,
|
|
81
|
+
): SymbolCallResolution {
|
|
82
|
+
return {
|
|
83
|
+
id: row.id,
|
|
84
|
+
status: 'resolved',
|
|
85
|
+
reason: null,
|
|
86
|
+
strategy,
|
|
87
|
+
candidateCount,
|
|
88
|
+
resolvedModulePath: moduleScoped && row.sourceFile
|
|
89
|
+
? stripExt(row.sourceFile)
|
|
90
|
+
: undefined,
|
|
91
|
+
};
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
function exportedSymbolRows(db: Db, repoId: number, r: SymbolCallFact): SymbolTargetRow[] {
|
|
95
|
+
return symbolTargetRows(db.prepare('SELECT id,kind,source_file sourceFile,evidence_json evidenceJson FROM symbols WHERE repo_id=? AND source_file<>? AND exported=1 AND (exported_name=? OR name=? OR qualified_name=?) ORDER BY id').all(repoId, r.sourceFile, r.calleeLocalName, r.calleeLocalName, r.calleeLocalName));
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
function isRelativeImportedSymbolCall(r: SymbolCallFact): boolean {
|
|
99
|
+
return Boolean(r.importSource?.startsWith('.'));
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
function sameFileResolution(
|
|
103
|
+
db: Db,
|
|
104
|
+
repoId: number,
|
|
105
|
+
r: SymbolCallFact,
|
|
106
|
+
relation: unknown,
|
|
107
|
+
): SymbolCallResolution | undefined {
|
|
108
|
+
const bareImport = relation === 'relative_import' && isRelativeImportedSymbolCall(r)
|
|
109
|
+
&& !String(r.calleeLocalName).includes('.');
|
|
110
|
+
if (bareImport || relation === 'relative_import_namespace_member'
|
|
111
|
+
|| relation === 'package_import') return undefined;
|
|
112
|
+
const rows = symbolTargetRows(db.prepare('SELECT id FROM symbols WHERE repo_id=? AND source_file=? AND (name=? OR qualified_name=?) ORDER BY id').all(repoId, r.sourceFile, r.calleeLocalName, r.calleeLocalName));
|
|
113
|
+
if (rows.length === 1 && rows[0]) return resolvedSymbol(rows[0], 'same_file_exact', 1);
|
|
114
|
+
return rows.length > 1
|
|
115
|
+
? {
|
|
116
|
+
id: null,
|
|
117
|
+
status: 'ambiguous',
|
|
118
|
+
reason: 'Multiple same-file symbol targets matched exactly',
|
|
119
|
+
strategy: 'same_file_exact',
|
|
120
|
+
candidateCount: rows.length,
|
|
121
|
+
}
|
|
122
|
+
: undefined;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
function classInstanceResolution(
|
|
126
|
+
db: Db,
|
|
127
|
+
repoId: number,
|
|
128
|
+
r: SymbolCallFact,
|
|
129
|
+
relation: unknown,
|
|
130
|
+
): SymbolCallResolution | undefined {
|
|
131
|
+
if (relation !== 'class_instance_method' || !isRelativeImportedSymbolCall(r))
|
|
132
|
+
return undefined;
|
|
133
|
+
const rows = symbolTargetRows(db.prepare('SELECT id FROM symbols WHERE repo_id=? AND source_file<>? AND qualified_name=? ORDER BY id').all(repoId, r.sourceFile, r.calleeLocalName));
|
|
134
|
+
if (rows.length === 1 && rows[0])
|
|
135
|
+
return resolvedSymbol(rows[0], 'relative_import_class_instance_method', 1);
|
|
136
|
+
return rows.length > 1
|
|
137
|
+
? {
|
|
138
|
+
id: null,
|
|
139
|
+
status: 'ambiguous',
|
|
140
|
+
reason: 'Multiple relative class instance method targets matched exactly',
|
|
141
|
+
strategy: 'relative_import_class_instance_method',
|
|
142
|
+
candidateCount: rows.length,
|
|
143
|
+
}
|
|
144
|
+
: undefined;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
function namespaceResolution(
|
|
148
|
+
db: Db,
|
|
149
|
+
repoId: number,
|
|
150
|
+
r: SymbolCallFact,
|
|
151
|
+
relation: unknown,
|
|
152
|
+
): SymbolCallResolution | undefined {
|
|
153
|
+
if (relation !== 'relative_import_namespace_member'
|
|
154
|
+
|| !isRelativeImportedSymbolCall(r)) return undefined;
|
|
155
|
+
const rows = moduleRows(exportedSymbolRows(db, repoId, r), r);
|
|
156
|
+
if (rows.length === 1 && rows[0])
|
|
157
|
+
return resolvedSymbol(rows[0], 'relative_import_namespace_member', 1, true);
|
|
158
|
+
if (rows.length > 1) return {
|
|
159
|
+
id: null,
|
|
160
|
+
status: 'ambiguous',
|
|
161
|
+
reason: 'Multiple namespace member targets matched the imported module',
|
|
162
|
+
strategy: 'relative_import_namespace_member',
|
|
163
|
+
candidateCount: rows.length,
|
|
164
|
+
};
|
|
165
|
+
return {
|
|
166
|
+
id: null,
|
|
167
|
+
status: 'unresolved',
|
|
168
|
+
reason: 'No namespace member target matched the imported module',
|
|
169
|
+
strategy: 'relative_import_namespace_member',
|
|
170
|
+
candidateCount: 0,
|
|
171
|
+
};
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
function proxyResolution(
|
|
175
|
+
rows: SymbolTargetRow[],
|
|
176
|
+
r: SymbolCallFact,
|
|
177
|
+
relation: unknown,
|
|
178
|
+
): SymbolCallResolution | undefined {
|
|
179
|
+
if (relation !== 'relative_import_proxy_member' || rows.length <= 1) return undefined;
|
|
180
|
+
const mapped = rows.filter(isExportedObjectMapping);
|
|
181
|
+
if (mapped.length > 0) {
|
|
182
|
+
const concrete = rows.find((row) => row.kind !== 'object_alias') ?? mapped[0];
|
|
183
|
+
return {
|
|
184
|
+
id: concrete?.id ?? null,
|
|
185
|
+
status: 'resolved',
|
|
186
|
+
reason: null,
|
|
187
|
+
strategy: 'proxy_member_exported_object_map',
|
|
188
|
+
candidateCount: rows.length,
|
|
189
|
+
};
|
|
190
|
+
}
|
|
191
|
+
const scoped = moduleRows(rows, r);
|
|
192
|
+
if (scoped.length === 1 && scoped[0])
|
|
193
|
+
return resolvedSymbol(scoped[0], 'relative_import_path_disambiguated', rows.length, true);
|
|
194
|
+
return {
|
|
195
|
+
id: null,
|
|
196
|
+
status: 'ambiguous',
|
|
197
|
+
reason: 'Proxy member target requires explicit factory/module/type evidence; global member name is ambiguous',
|
|
198
|
+
strategy: 'proxy_member_no_global_name_fallback',
|
|
199
|
+
candidateCount: rows.length,
|
|
200
|
+
};
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
function isExportedObjectMapping(row: SymbolTargetRow): boolean {
|
|
204
|
+
const evidence = String(row.evidenceJson ?? '');
|
|
205
|
+
return evidence.includes('exported_object_shorthand')
|
|
206
|
+
|| evidence.includes('exported_object_literal');
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
function exportedResolution(
|
|
210
|
+
rows: SymbolTargetRow[],
|
|
211
|
+
r: SymbolCallFact,
|
|
212
|
+
relation: unknown,
|
|
213
|
+
): SymbolCallResolution | undefined {
|
|
214
|
+
if (rows.length === 1 && rows[0]) return resolvedSymbol(
|
|
215
|
+
rows[0],
|
|
216
|
+
relation === 'relative_import_proxy_member'
|
|
217
|
+
? 'proxy_member_unique_exported_candidate'
|
|
218
|
+
: 'relative_import_exported_exact',
|
|
219
|
+
1,
|
|
220
|
+
moduleRows(rows, r).length === 1,
|
|
221
|
+
);
|
|
222
|
+
if (rows.length <= 1) return undefined;
|
|
223
|
+
const scoped = isRelativeImportedSymbolCall(r) ? moduleRows(rows, r) : [];
|
|
224
|
+
if (scoped.length === 1 && scoped[0])
|
|
225
|
+
return resolvedSymbol(scoped[0], 'relative_import_path_disambiguated', rows.length, true);
|
|
226
|
+
return {
|
|
227
|
+
id: null,
|
|
228
|
+
status: 'ambiguous',
|
|
229
|
+
reason: 'Multiple exported symbol targets matched exactly',
|
|
230
|
+
strategy: 'exported_exact',
|
|
231
|
+
candidateCount: rows.length,
|
|
232
|
+
};
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
function accessorResolution(
|
|
236
|
+
db: Db,
|
|
237
|
+
repoId: number,
|
|
238
|
+
r: SymbolCallFact,
|
|
239
|
+
relation: unknown,
|
|
240
|
+
): SymbolCallResolution | undefined {
|
|
241
|
+
if (relation !== 'relative_import' || !isRelativeImportedSymbolCall(r)
|
|
242
|
+
|| !/^[^.]+\.[^.]+$/.test(String(r.calleeLocalName))) return undefined;
|
|
243
|
+
const methodRows = symbolTargetRows(db.prepare("SELECT id,kind,source_file sourceFile FROM symbols WHERE repo_id=? AND source_file<>? AND kind='method' AND qualified_name=? ORDER BY id").all(repoId, r.sourceFile, r.calleeLocalName));
|
|
244
|
+
const scoped = moduleRows(methodRows, r);
|
|
245
|
+
if (scoped.length === 1 && scoped[0]) return resolvedSymbol(
|
|
246
|
+
scoped[0],
|
|
247
|
+
'relative_import_static_accessor_instance_method',
|
|
248
|
+
1,
|
|
249
|
+
true,
|
|
250
|
+
);
|
|
251
|
+
return scoped.length > 1
|
|
252
|
+
? {
|
|
253
|
+
id: null,
|
|
254
|
+
status: 'ambiguous',
|
|
255
|
+
reason: 'Multiple static-accessor instance method targets matched the imported module',
|
|
256
|
+
strategy: 'relative_import_static_accessor_instance_method',
|
|
257
|
+
candidateCount: scoped.length,
|
|
258
|
+
}
|
|
259
|
+
: undefined;
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
function resolveSymbolCallTarget(
|
|
263
|
+
db: Db,
|
|
264
|
+
repoId: number,
|
|
265
|
+
r: SymbolCallFact,
|
|
266
|
+
): SymbolCallResolution {
|
|
267
|
+
const relation = r.evidence.relation;
|
|
268
|
+
const early = sameFileResolution(db, repoId, r, relation)
|
|
269
|
+
?? classInstanceResolution(db, repoId, r, relation)
|
|
270
|
+
?? namespaceResolution(db, repoId, r, relation);
|
|
271
|
+
if (early) return early;
|
|
272
|
+
const rows = relation === 'package_import' ? [] : exportedSymbolRows(db, repoId, r);
|
|
273
|
+
const matched = proxyResolution(rows, r, relation)
|
|
274
|
+
?? exportedResolution(rows, r, relation)
|
|
275
|
+
?? accessorResolution(db, repoId, r, relation);
|
|
276
|
+
if (matched) return matched;
|
|
277
|
+
if (relation === 'package_import') return {
|
|
278
|
+
id: null,
|
|
279
|
+
status: 'unresolved',
|
|
280
|
+
reason: 'Package import target resolution requires a post-publication workspace pass',
|
|
281
|
+
strategy: 'package_import_unresolved',
|
|
282
|
+
candidateCount: 0,
|
|
283
|
+
};
|
|
284
|
+
return {
|
|
285
|
+
id: null,
|
|
286
|
+
status: 'unresolved',
|
|
287
|
+
reason: 'No local symbol target matched exactly',
|
|
288
|
+
strategy: relation === 'relative_import_proxy_member'
|
|
289
|
+
? 'proxy_member_no_global_name_fallback'
|
|
290
|
+
: 'exact_symbol_match',
|
|
291
|
+
candidateCount: 0,
|
|
292
|
+
};
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
export function insertCalls(
|
|
296
|
+
db: Db,
|
|
297
|
+
repoId: number,
|
|
298
|
+
rows: OutboundCallFact[],
|
|
299
|
+
): void {
|
|
300
|
+
const stmt = outboundCallInsertStatement(db);
|
|
301
|
+
for (const row of rows) insertOutboundCall(db, stmt, repoId, row);
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
function outboundCallInsertStatement(db: Db): Statement {
|
|
305
|
+
return db.prepare(`INSERT INTO outbound_calls(
|
|
306
|
+
repo_id,source_symbol_id,call_type,method,operation_path_expr,query_entity,
|
|
307
|
+
event_name_expr,payload_summary,source_file,source_line,call_site_start_offset,
|
|
308
|
+
call_site_end_offset,confidence,unresolved_reason,local_service_name,
|
|
309
|
+
local_service_lookup,alias_chain_json,evidence_json,external_target_kind,
|
|
310
|
+
external_target_id,external_target_label,external_target_dynamic,service_binding_id
|
|
311
|
+
) VALUES(
|
|
312
|
+
?,COALESCE(
|
|
313
|
+
(SELECT id FROM symbols WHERE repo_id=? AND source_file=? AND qualified_name=? ORDER BY id LIMIT 1),
|
|
314
|
+
(SELECT id FROM symbols WHERE repo_id=? AND source_file=? AND start_line<=? AND end_line>=? ORDER BY (end_line-start_line),id LIMIT 1)
|
|
315
|
+
),?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?
|
|
316
|
+
)`);
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
function insertOutboundCall(
|
|
320
|
+
db: Db,
|
|
321
|
+
stmt: Statement,
|
|
322
|
+
repoId: number,
|
|
323
|
+
call: OutboundCallFact,
|
|
324
|
+
): void {
|
|
325
|
+
const binding = resolvePersistedBinding(db, repoId, call);
|
|
326
|
+
const external = externalTargetValues(call.externalTarget);
|
|
327
|
+
const evidence = {
|
|
328
|
+
...(call.evidence ?? {}),
|
|
329
|
+
serviceBindingResolution: binding.evidence,
|
|
330
|
+
};
|
|
331
|
+
stmt.run(
|
|
332
|
+
repoId, repoId, call.sourceFile, call.sourceSymbolQualifiedName,
|
|
333
|
+
repoId, call.sourceFile, call.sourceLine, call.sourceLine,
|
|
334
|
+
call.callType, call.method, call.operationPathExpr, call.queryEntity,
|
|
335
|
+
call.eventNameExpr, call.payloadSummary, call.sourceFile, call.sourceLine,
|
|
336
|
+
call.callSiteStartOffset, call.callSiteEndOffset, call.confidence,
|
|
337
|
+
call.unresolvedReason ?? binding.unresolvedReason,
|
|
338
|
+
call.localServiceName, call.localServiceLookup,
|
|
339
|
+
serializedAliasChain(call.aliasChain),
|
|
340
|
+
JSON.stringify(evidence), external.kind, external.stableId, external.label,
|
|
341
|
+
external.dynamic, binding.bindingId,
|
|
342
|
+
);
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
function serializedAliasChain(
|
|
346
|
+
aliasChain: OutboundCallFact['aliasChain'],
|
|
347
|
+
): string | null {
|
|
348
|
+
return aliasChain ? JSON.stringify(aliasChain) : null;
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
function externalTargetValues(
|
|
352
|
+
target: OutboundCallFact['externalTarget'],
|
|
353
|
+
): { kind: string | null; stableId: string | null; label: string | null;
|
|
354
|
+
dynamic: number } {
|
|
355
|
+
if (!target) return { kind: null, stableId: null, label: null, dynamic: 0 };
|
|
356
|
+
return {
|
|
357
|
+
kind: target.kind, stableId: target.stableId, label: target.label,
|
|
358
|
+
dynamic: target.dynamic ? 1 : 0,
|
|
359
|
+
};
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
interface BindingCandidate {
|
|
363
|
+
id: number;
|
|
364
|
+
symbolId?: number | null;
|
|
365
|
+
variableName: string;
|
|
366
|
+
alias?: string | null;
|
|
367
|
+
aliasExpr?: string | null;
|
|
368
|
+
destinationExpr?: string | null;
|
|
369
|
+
servicePathExpr?: string | null;
|
|
370
|
+
sourceFile: string;
|
|
371
|
+
sourceLine: number;
|
|
372
|
+
helperChainJson?: string | null;
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
function resolvePersistedBinding(
|
|
376
|
+
db: Db,
|
|
377
|
+
repoId: number,
|
|
378
|
+
call: OutboundCallFact,
|
|
379
|
+
): {
|
|
380
|
+
bindingId: number | null;
|
|
381
|
+
unresolvedReason?: string;
|
|
382
|
+
evidence: Record<string, unknown>;
|
|
383
|
+
} {
|
|
384
|
+
if (!call.serviceVariableName)
|
|
385
|
+
return { bindingId: null, evidence: { status: 'not_applicable', candidateCount: 0 } };
|
|
386
|
+
const candidates = bindingCandidates(db, repoId, call);
|
|
387
|
+
const prior = candidates.filter((candidate) => candidate.sourceLine <= call.sourceLine);
|
|
388
|
+
const families = new Set(prior.map(bindingSignature));
|
|
389
|
+
if (prior.length > 0 && families.size === 1) {
|
|
390
|
+
const selected = prior.at(-1);
|
|
391
|
+
return {
|
|
392
|
+
bindingId: selected?.id ?? null,
|
|
393
|
+
evidence: bindingEvidence('selected', prior, selected),
|
|
394
|
+
};
|
|
395
|
+
}
|
|
396
|
+
if (prior.length > 1) {
|
|
397
|
+
return {
|
|
398
|
+
bindingId: null,
|
|
399
|
+
unresolvedReason: 'ambiguous_service_binding_candidates',
|
|
400
|
+
evidence: bindingEvidence('ambiguous', prior),
|
|
401
|
+
};
|
|
402
|
+
}
|
|
403
|
+
if (candidates.length > 0) {
|
|
404
|
+
return {
|
|
405
|
+
bindingId: null,
|
|
406
|
+
unresolvedReason: 'service_binding_declared_after_call',
|
|
407
|
+
evidence: bindingEvidence('rejected_future_binding', candidates),
|
|
408
|
+
};
|
|
409
|
+
}
|
|
410
|
+
return {
|
|
411
|
+
bindingId: null,
|
|
412
|
+
evidence: bindingEvidence('unrecoverable', []),
|
|
413
|
+
};
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
function bindingCandidates(
|
|
417
|
+
db: Db,
|
|
418
|
+
repoId: number,
|
|
419
|
+
call: OutboundCallFact,
|
|
420
|
+
): BindingCandidate[] {
|
|
421
|
+
const ownerId = callSymbolId(db, repoId, call);
|
|
422
|
+
const rows = db.prepare(`
|
|
423
|
+
SELECT id,symbol_id symbolId,variable_name variableName,alias,alias_expr aliasExpr,
|
|
424
|
+
destination_expr destinationExpr,service_path_expr servicePathExpr,
|
|
425
|
+
source_file sourceFile,source_line sourceLine,helper_chain_json helperChainJson
|
|
426
|
+
FROM service_bindings
|
|
427
|
+
WHERE repo_id=? AND variable_name=? AND source_file=?
|
|
428
|
+
AND (? IS NULL OR symbol_id IS NULL OR symbol_id=?)
|
|
429
|
+
ORDER BY source_line,id
|
|
430
|
+
`).all(
|
|
431
|
+
repoId,
|
|
432
|
+
call.serviceVariableName,
|
|
433
|
+
call.sourceFile,
|
|
434
|
+
ownerId,
|
|
435
|
+
ownerId,
|
|
436
|
+
) as Array<Record<string, unknown>>;
|
|
437
|
+
return rows.flatMap((row) => {
|
|
438
|
+
if (typeof row.id !== 'number' || typeof row.variableName !== 'string'
|
|
439
|
+
|| typeof row.sourceFile !== 'string' || typeof row.sourceLine !== 'number')
|
|
440
|
+
return [];
|
|
441
|
+
return [{
|
|
442
|
+
id: row.id,
|
|
443
|
+
symbolId: nullableNumber(row.symbolId),
|
|
444
|
+
variableName: row.variableName,
|
|
445
|
+
alias: nullableString(row.alias),
|
|
446
|
+
aliasExpr: nullableString(row.aliasExpr),
|
|
447
|
+
destinationExpr: nullableString(row.destinationExpr),
|
|
448
|
+
servicePathExpr: nullableString(row.servicePathExpr),
|
|
449
|
+
sourceFile: row.sourceFile,
|
|
450
|
+
sourceLine: row.sourceLine,
|
|
451
|
+
helperChainJson: nullableString(row.helperChainJson),
|
|
452
|
+
}];
|
|
453
|
+
});
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
function nullableString(value: unknown): string | null | undefined {
|
|
457
|
+
return value === null || typeof value === 'string' ? value : undefined;
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
function nullableNumber(value: unknown): number | null | undefined {
|
|
461
|
+
return value === null || typeof value === 'number' ? value : undefined;
|
|
462
|
+
}
|
|
463
|
+
|
|
464
|
+
function callSymbolId(
|
|
465
|
+
db: Db,
|
|
466
|
+
repoId: number,
|
|
467
|
+
call: OutboundCallFact,
|
|
468
|
+
): number | undefined {
|
|
469
|
+
const row = db.prepare(`
|
|
470
|
+
SELECT id FROM symbols
|
|
471
|
+
WHERE repo_id=? AND source_file=?
|
|
472
|
+
AND ((? IS NOT NULL AND qualified_name=?)
|
|
473
|
+
OR (start_line<=? AND end_line>=?))
|
|
474
|
+
ORDER BY CASE WHEN qualified_name=? THEN 0 ELSE 1 END,
|
|
475
|
+
(end_line-start_line),id
|
|
476
|
+
LIMIT 1
|
|
477
|
+
`).get(
|
|
478
|
+
repoId,
|
|
479
|
+
call.sourceFile,
|
|
480
|
+
call.sourceSymbolQualifiedName,
|
|
481
|
+
call.sourceSymbolQualifiedName,
|
|
482
|
+
call.sourceLine,
|
|
483
|
+
call.sourceLine,
|
|
484
|
+
call.sourceSymbolQualifiedName,
|
|
485
|
+
);
|
|
486
|
+
return typeof row?.id === 'number' ? row.id : undefined;
|
|
487
|
+
}
|
|
488
|
+
|
|
489
|
+
function bindingEvidence(
|
|
490
|
+
status: string,
|
|
491
|
+
candidates: BindingCandidate[],
|
|
492
|
+
selected?: BindingCandidate,
|
|
493
|
+
): Record<string, unknown> {
|
|
494
|
+
const projection = projectBounded(candidates, (left, right) =>
|
|
495
|
+
Number(right.id === selected?.id) - Number(left.id === selected?.id)
|
|
496
|
+
|| left.sourceFile.localeCompare(right.sourceFile)
|
|
497
|
+
|| left.sourceLine - right.sourceLine
|
|
498
|
+
|| left.id - right.id);
|
|
499
|
+
return {
|
|
500
|
+
status,
|
|
501
|
+
candidateCount: projection.totalCount,
|
|
502
|
+
shownCandidateCount: projection.shownCount,
|
|
503
|
+
omittedCandidateCount: projection.omittedCount,
|
|
504
|
+
selectedBindingId: selected?.id,
|
|
505
|
+
sourceOrderRule: 'binding_source_line_must_not_follow_call',
|
|
506
|
+
candidates: projection.items.map((candidate) => ({
|
|
507
|
+
bindingId: candidate.id,
|
|
508
|
+
symbolId: candidate.symbolId,
|
|
509
|
+
variableName: candidate.variableName,
|
|
510
|
+
alias: candidate.alias,
|
|
511
|
+
aliasExpr: candidate.aliasExpr,
|
|
512
|
+
destinationExpr: candidate.destinationExpr,
|
|
513
|
+
servicePathExpr: candidate.servicePathExpr,
|
|
514
|
+
sourceFile: candidate.sourceFile,
|
|
515
|
+
sourceLine: candidate.sourceLine,
|
|
516
|
+
helperChain: parseBindingChain(candidate.helperChainJson),
|
|
517
|
+
})),
|
|
518
|
+
};
|
|
519
|
+
}
|
|
520
|
+
|
|
521
|
+
function bindingSignature(candidate: BindingCandidate): string {
|
|
522
|
+
return JSON.stringify([
|
|
523
|
+
candidate.alias,
|
|
524
|
+
candidate.aliasExpr,
|
|
525
|
+
candidate.destinationExpr,
|
|
526
|
+
candidate.servicePathExpr,
|
|
527
|
+
]);
|
|
528
|
+
}
|
|
529
|
+
|
|
530
|
+
function parseBindingChain(value: string | null | undefined): unknown {
|
|
531
|
+
if (!value) return undefined;
|
|
532
|
+
try {
|
|
533
|
+
return JSON.parse(value) as unknown;
|
|
534
|
+
} catch {
|
|
535
|
+
return undefined;
|
|
536
|
+
}
|
|
537
|
+
}
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
import type { Db } from './connection.js';
|
|
2
|
+
import { CURRENT_SCHEMA_VERSION, schemaVersion } from './migrations.js';
|
|
3
|
+
import { ANALYZER_VERSION } from '../version.js';
|
|
4
|
+
|
|
5
|
+
export type FactLifecycleCode =
|
|
6
|
+
| 'schema_upgrade_required'
|
|
7
|
+
| 'unsupported_future_schema'
|
|
8
|
+
| 'reindex_required';
|
|
9
|
+
|
|
10
|
+
export interface FactLifecycleDiagnostic extends Record<string, unknown> {
|
|
11
|
+
severity: 'error';
|
|
12
|
+
code: FactLifecycleCode;
|
|
13
|
+
message: string;
|
|
14
|
+
remediation: string;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
const remediation = [
|
|
18
|
+
'service-flow index --workspace /workspace --force',
|
|
19
|
+
'service-flow link --workspace /workspace --force',
|
|
20
|
+
].join('\n');
|
|
21
|
+
|
|
22
|
+
function count(db: Db, sql: string, ...params: unknown[]): number {
|
|
23
|
+
const row = db.prepare(sql).get(...params);
|
|
24
|
+
return Number(row?.count ?? 0);
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function oldAnalyzerCount(db: Db, workspaceId?: number): number {
|
|
28
|
+
return count(db, `SELECT COUNT(*) count FROM repositories
|
|
29
|
+
WHERE (? IS NULL OR workspace_id=?)
|
|
30
|
+
AND (COALESCE(index_status,'pending')<>'indexed'
|
|
31
|
+
OR COALESCE(fact_analyzer_version,'legacy')<>?)`,
|
|
32
|
+
workspaceId, workspaceId, ANALYZER_VERSION);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function invalidCurrentFactCount(db: Db, workspaceId?: number): number {
|
|
36
|
+
const outbound = count(db, `SELECT COUNT(*) count
|
|
37
|
+
FROM outbound_calls c JOIN repositories r ON r.id=c.repo_id
|
|
38
|
+
WHERE (? IS NULL OR r.workspace_id=?) AND r.fact_analyzer_version=?
|
|
39
|
+
AND (typeof(c.call_site_start_offset)<>'integer'
|
|
40
|
+
OR typeof(c.call_site_end_offset)<>'integer'
|
|
41
|
+
OR c.call_site_start_offset<0
|
|
42
|
+
OR c.call_site_end_offset<=c.call_site_start_offset)`,
|
|
43
|
+
workspaceId, workspaceId, ANALYZER_VERSION);
|
|
44
|
+
const symbols = count(db, `SELECT COUNT(*) count
|
|
45
|
+
FROM symbol_calls c JOIN repositories r ON r.id=c.repo_id
|
|
46
|
+
WHERE (? IS NULL OR r.workspace_id=?)
|
|
47
|
+
AND (c.call_role='legacy_unknown'
|
|
48
|
+
OR (r.fact_analyzer_version=? AND (
|
|
49
|
+
typeof(c.call_site_start_offset)<>'integer'
|
|
50
|
+
OR typeof(c.call_site_end_offset)<>'integer'
|
|
51
|
+
OR c.call_site_start_offset<0
|
|
52
|
+
OR c.call_site_end_offset<=c.call_site_start_offset
|
|
53
|
+
OR c.call_role NOT IN ('ordinary_call','event_subscribe_handler'))))`,
|
|
54
|
+
workspaceId, workspaceId, ANALYZER_VERSION);
|
|
55
|
+
return outbound + symbols;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export function factLifecycleDiagnostic(
|
|
59
|
+
db: Db,
|
|
60
|
+
workspaceId?: number,
|
|
61
|
+
): FactLifecycleDiagnostic | undefined {
|
|
62
|
+
return schemaLifecycleDiagnostic(db)
|
|
63
|
+
?? currentFactLifecycleDiagnostic(db, workspaceId);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export function schemaLifecycleDiagnostic(
|
|
67
|
+
db: Db,
|
|
68
|
+
): FactLifecycleDiagnostic | undefined {
|
|
69
|
+
const currentSchema = schemaVersion(db);
|
|
70
|
+
if (currentSchema > CURRENT_SCHEMA_VERSION) return {
|
|
71
|
+
severity: 'error',
|
|
72
|
+
code: 'unsupported_future_schema',
|
|
73
|
+
message: `Database schema ${currentSchema} is newer than the supported schema ${CURRENT_SCHEMA_VERSION}; upgrade service-flow before reading this database.`,
|
|
74
|
+
remediation: 'Install a service-flow version that supports this database schema.',
|
|
75
|
+
currentSchemaVersion: currentSchema,
|
|
76
|
+
supportedSchemaVersion: CURRENT_SCHEMA_VERSION,
|
|
77
|
+
};
|
|
78
|
+
if (currentSchema < CURRENT_SCHEMA_VERSION) return {
|
|
79
|
+
severity: 'error',
|
|
80
|
+
code: 'schema_upgrade_required',
|
|
81
|
+
message: `Database schema ${currentSchema} must be upgraded to ${CURRENT_SCHEMA_VERSION} before this command can read current call-site facts.`,
|
|
82
|
+
remediation,
|
|
83
|
+
currentSchemaVersion: currentSchema,
|
|
84
|
+
requiredSchemaVersion: CURRENT_SCHEMA_VERSION,
|
|
85
|
+
};
|
|
86
|
+
return undefined;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
export function currentFactLifecycleDiagnostic(
|
|
90
|
+
db: Db,
|
|
91
|
+
workspaceId?: number,
|
|
92
|
+
): FactLifecycleDiagnostic | undefined {
|
|
93
|
+
const staleRepositories = oldAnalyzerCount(db, workspaceId);
|
|
94
|
+
const invalidFacts = invalidCurrentFactCount(db, workspaceId);
|
|
95
|
+
if (staleRepositories === 0 && invalidFacts === 0) return undefined;
|
|
96
|
+
return {
|
|
97
|
+
severity: 'error',
|
|
98
|
+
code: 'reindex_required',
|
|
99
|
+
message: 'Call-site facts are stale or lack typed roles and exact spans; force index and link before tracing or rebuilding graph edges.',
|
|
100
|
+
remediation,
|
|
101
|
+
staleRepositoryCount: staleRepositories,
|
|
102
|
+
invalidCallFactCount: invalidFacts,
|
|
103
|
+
requiredAnalyzerVersion: ANALYZER_VERSION,
|
|
104
|
+
};
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
export function assertWorkspaceLinkable(db: Db, workspaceId: number): void {
|
|
108
|
+
const diagnostic = factLifecycleDiagnostic(db, workspaceId);
|
|
109
|
+
if (!diagnostic) return;
|
|
110
|
+
throw new Error(`${diagnostic.code}: ${diagnostic.message}\n${diagnostic.remediation}`);
|
|
111
|
+
}
|
package/src/db/migrations.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import type { Db } from './connection.js';
|
|
2
2
|
import { schemaIndexesSql, schemaTablesSql } from './schema.js';
|
|
3
|
-
const CURRENT_SCHEMA_VERSION =
|
|
3
|
+
export const CURRENT_SCHEMA_VERSION = 12;
|
|
4
4
|
const columns: Record<string, Array<{ name: string; ddl: string }>> = {
|
|
5
5
|
handler_methods: [
|
|
6
6
|
{ name: 'decorator_resolution_json', ddl: "ALTER TABLE handler_methods ADD COLUMN decorator_resolution_json TEXT NOT NULL DEFAULT '{}'" },
|
|
@@ -54,6 +54,13 @@ const columns: Record<string, Array<{ name: string; ddl: string }>> = {
|
|
|
54
54
|
{ name: 'external_target_id', ddl: 'ALTER TABLE outbound_calls ADD COLUMN external_target_id TEXT' },
|
|
55
55
|
{ name: 'external_target_label', ddl: 'ALTER TABLE outbound_calls ADD COLUMN external_target_label TEXT' },
|
|
56
56
|
{ name: 'external_target_dynamic', ddl: 'ALTER TABLE outbound_calls ADD COLUMN external_target_dynamic INTEGER NOT NULL DEFAULT 0' },
|
|
57
|
+
{ name: 'call_site_start_offset', ddl: 'ALTER TABLE outbound_calls ADD COLUMN call_site_start_offset INTEGER' },
|
|
58
|
+
{ name: 'call_site_end_offset', ddl: 'ALTER TABLE outbound_calls ADD COLUMN call_site_end_offset INTEGER' },
|
|
59
|
+
],
|
|
60
|
+
symbol_calls: [
|
|
61
|
+
{ name: 'call_site_start_offset', ddl: 'ALTER TABLE symbol_calls ADD COLUMN call_site_start_offset INTEGER' },
|
|
62
|
+
{ name: 'call_site_end_offset', ddl: 'ALTER TABLE symbol_calls ADD COLUMN call_site_end_offset INTEGER' },
|
|
63
|
+
{ name: 'call_role', ddl: "ALTER TABLE symbol_calls ADD COLUMN call_role TEXT NOT NULL DEFAULT 'legacy_unknown'" },
|
|
57
64
|
],
|
|
58
65
|
index_runs: [
|
|
59
66
|
{ name: 'error_message', ddl: 'ALTER TABLE index_runs ADD COLUMN error_message TEXT' },
|
|
@@ -78,6 +85,13 @@ function normalizeLegacyStatus(db: Db): void {
|
|
|
78
85
|
db.prepare("UPDATE graph_edges SET status=CASE WHEN edge_type='REMOTE_CALL_RESOLVES_TO_OPERATION' THEN 'resolved' WHEN edge_type IN ('HANDLER_RUNS_DB_QUERY','HANDLER_CALLS_EXTERNAL_HTTP','HANDLER_EMITS_EVENT','EVENT_CONSUMED_BY_HANDLER') THEN 'terminal' WHEN edge_type='DYNAMIC_EDGE_CANDIDATE' THEN 'dynamic' WHEN status='ambiguous' THEN 'ambiguous' ELSE status END").run();
|
|
79
86
|
db.prepare("UPDATE repositories SET graph_stale_reason='schema_migration_requires_relink', graph_stale_at=COALESCE(graph_stale_at, datetime('now')) WHERE EXISTS (SELECT 1 FROM graph_edges WHERE graph_edges.workspace_id=repositories.workspace_id) AND graph_generation=0").run();
|
|
80
87
|
}
|
|
88
|
+
function markCallSiteMigrationStale(db: Db, priorVersion: number): void {
|
|
89
|
+
if (priorVersion >= 12) return;
|
|
90
|
+
db.prepare(`UPDATE repositories
|
|
91
|
+
SET graph_stale_reason='schema_v12_call_sites_require_reindex',
|
|
92
|
+
graph_stale_at=COALESCE(graph_stale_at,datetime('now'))
|
|
93
|
+
WHERE index_status='indexed' OR last_indexed_at IS NOT NULL`).run();
|
|
94
|
+
}
|
|
81
95
|
export function migrate(db: Db): void {
|
|
82
96
|
db.transaction(() => {
|
|
83
97
|
const version = userVersion(db);
|
|
@@ -86,6 +100,7 @@ export function migrate(db: Db): void {
|
|
|
86
100
|
addMissingColumns(db);
|
|
87
101
|
db.exec(schemaIndexesSql);
|
|
88
102
|
normalizeLegacyStatus(db);
|
|
103
|
+
markCallSiteMigrationStale(db, version);
|
|
89
104
|
const violations = db.pragma('foreign_key_check');
|
|
90
105
|
if (violations.length > 0) throw new Error('SQLite foreign_key_check failed during migration');
|
|
91
106
|
db.pragma(`user_version = ${CURRENT_SCHEMA_VERSION}`);
|