@saptools/service-flow 0.1.53 → 0.1.55
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 +11 -0
- package/README.md +12 -5
- package/TECHNICAL-NOTE.md +13 -0
- package/dist/{chunk-LFH7C46B.js → chunk-GXYVIHJ5.js} +635 -135
- package/dist/chunk-GXYVIHJ5.js.map +1 -0
- package/dist/cli.js +71 -26
- package/dist/cli.js.map +1 -1
- package/dist/index.js +1 -1
- package/package.json +1 -1
- package/src/cli/001-doctor-projection.ts +3 -7
- package/src/cli.ts +24 -16
- package/src/linker/000-implementation-candidates.ts +35 -2
- package/src/linker/001-implementation-evidence-projection.ts +78 -6
- package/src/output/000-stdout-policy.ts +65 -0
- package/src/output/table-output.ts +11 -2
- package/src/parsers/outbound-call-parser.ts +116 -11
- package/src/trace/008-contextual-runtime-state.ts +294 -0
- package/src/trace/009-selected-handler-provenance.ts +186 -0
- package/src/trace/evidence.ts +103 -31
- package/src/trace/trace-engine.ts +67 -82
- package/src/utils/000-bounded-projection.ts +20 -15
- package/dist/chunk-LFH7C46B.js.map +0 -1
package/dist/index.js
CHANGED
package/package.json
CHANGED
|
@@ -1,6 +1,5 @@
|
|
|
1
1
|
import {
|
|
2
|
-
|
|
3
|
-
stableProjectionValue,
|
|
2
|
+
projectBoundedInOrder,
|
|
4
3
|
type BoundedProjection,
|
|
5
4
|
} from '../utils/000-bounded-projection.js';
|
|
6
5
|
|
|
@@ -54,7 +53,8 @@ function boundDoctorValue(value: unknown): unknown {
|
|
|
54
53
|
output[key] = boundDoctorValue(child);
|
|
55
54
|
continue;
|
|
56
55
|
}
|
|
57
|
-
|
|
56
|
+
// Doctor producers already query or assemble deterministic semantic order.
|
|
57
|
+
const projection = projectBoundedInOrder(child.map(boundDoctorValue));
|
|
58
58
|
output[key] = projection.items;
|
|
59
59
|
addProjectionMetadata(output, input, key, projection);
|
|
60
60
|
}
|
|
@@ -127,10 +127,6 @@ function projectionStem(key: string): string {
|
|
|
127
127
|
return stems[key] ?? 'repository';
|
|
128
128
|
}
|
|
129
129
|
|
|
130
|
-
function compareDiagnostic(left: unknown, right: unknown): number {
|
|
131
|
-
return stableProjectionValue(left).localeCompare(stableProjectionValue(right));
|
|
132
|
-
}
|
|
133
|
-
|
|
134
130
|
function numericValue(value: unknown): number {
|
|
135
131
|
return typeof value === 'number' && Number.isFinite(value) ? value : 0;
|
|
136
132
|
}
|
package/src/cli.ts
CHANGED
|
@@ -30,9 +30,17 @@ import { renderTraceTable } from './output/table-output.js';
|
|
|
30
30
|
import { renderTraceJson, renderJson } from './output/json-output.js';
|
|
31
31
|
import { renderDoctorDiagnostics } from './output/doctor-output.js';
|
|
32
32
|
import { renderMermaid } from './output/mermaid-output.js';
|
|
33
|
+
import { createStdoutWriter } from './output/000-stdout-policy.js';
|
|
33
34
|
import { VERSION } from './version.js';
|
|
34
35
|
import type { DynamicMode } from './types.js';
|
|
35
36
|
import { cleanWorkspaceState } from './cli/000-clean.js';
|
|
37
|
+
|
|
38
|
+
const stdout = createStdoutWriter(process.stdout, fail);
|
|
39
|
+
|
|
40
|
+
function writeStdout(value: string): void {
|
|
41
|
+
stdout.write(value);
|
|
42
|
+
}
|
|
43
|
+
|
|
36
44
|
async function init(
|
|
37
45
|
workspace: string,
|
|
38
46
|
options: { db?: string; ignore?: string[] },
|
|
@@ -58,7 +66,7 @@ async function init(
|
|
|
58
66
|
});
|
|
59
67
|
}
|
|
60
68
|
db.close();
|
|
61
|
-
|
|
69
|
+
writeStdout(
|
|
62
70
|
`Workspace: ${config.rootPath}\nDatabase: ${config.dbPath}\nRepositories: ${repos.length}\nIgnored: ${config.ignore.join(', ')}\nNext: service-flow index --workspace ${config.rootPath}\n`,
|
|
63
71
|
);
|
|
64
72
|
}
|
|
@@ -148,7 +156,7 @@ export function createProgram(): Command {
|
|
|
148
156
|
repo: opts.repo,
|
|
149
157
|
force: Boolean(opts.force),
|
|
150
158
|
});
|
|
151
|
-
|
|
159
|
+
writeStdout(
|
|
152
160
|
`Indexed ${r.indexedCount} repositories, skipped ${r.skippedCount}, ${r.fileCount} files, ${r.diagnosticCount} diagnostics\n`,
|
|
153
161
|
);
|
|
154
162
|
}).catch(fail),
|
|
@@ -162,7 +170,7 @@ export function createProgram(): Command {
|
|
|
162
170
|
void withWorkspace(opts.workspace, (db, workspaceId) => {
|
|
163
171
|
const r = linkWorkspace(db, workspaceId);
|
|
164
172
|
const upgradeWarnings = linkUpgradeWarnings(db);
|
|
165
|
-
|
|
173
|
+
writeStdout(
|
|
166
174
|
`${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`,
|
|
167
175
|
);
|
|
168
176
|
}).catch(fail),
|
|
@@ -227,7 +235,7 @@ export function createProgram(): Command {
|
|
|
227
235
|
maxDynamicCandidates: parsePositiveInteger(opts.maxDynamicCandidates, 5),
|
|
228
236
|
},
|
|
229
237
|
);
|
|
230
|
-
|
|
238
|
+
writeStdout(
|
|
231
239
|
opts.format === 'json'
|
|
232
240
|
? renderTraceJson(result)
|
|
233
241
|
: opts.format === 'mermaid'
|
|
@@ -243,7 +251,7 @@ export function createProgram(): Command {
|
|
|
243
251
|
.action(
|
|
244
252
|
(opts: { workspace?: string }) =>
|
|
245
253
|
void withReadOnlyWorkspace(opts.workspace, (db, workspaceId) =>
|
|
246
|
-
|
|
254
|
+
writeStdout(
|
|
247
255
|
renderJson(
|
|
248
256
|
listRepositories(db, workspaceId).map((r) => ({
|
|
249
257
|
name: r.name,
|
|
@@ -265,7 +273,7 @@ export function createProgram(): Command {
|
|
|
265
273
|
? selectRepository(db, opts.repo, workspaceId)
|
|
266
274
|
: {};
|
|
267
275
|
if (selection.diagnostic) {
|
|
268
|
-
|
|
276
|
+
writeStdout(renderJson([selection.diagnostic]));
|
|
269
277
|
return;
|
|
270
278
|
}
|
|
271
279
|
const repo = selection.repo;
|
|
@@ -274,7 +282,7 @@ export function createProgram(): Command {
|
|
|
274
282
|
'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 r.workspace_id=? AND (? IS NULL OR s.repo_id=?) ORDER BY r.name,s.service_path',
|
|
275
283
|
)
|
|
276
284
|
.all(workspaceId, repo?.id, repo?.id);
|
|
277
|
-
|
|
285
|
+
writeStdout(renderJson(rows));
|
|
278
286
|
}).catch(fail),
|
|
279
287
|
);
|
|
280
288
|
list
|
|
@@ -289,7 +297,7 @@ export function createProgram(): Command {
|
|
|
289
297
|
? selectRepository(db, opts.repo, workspaceId)
|
|
290
298
|
: {};
|
|
291
299
|
if (selection.diagnostic) {
|
|
292
|
-
|
|
300
|
+
writeStdout(renderJson([selection.diagnostic]));
|
|
293
301
|
return;
|
|
294
302
|
}
|
|
295
303
|
const repo = selection.repo;
|
|
@@ -298,7 +306,7 @@ export function createProgram(): Command {
|
|
|
298
306
|
'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 r.workspace_id=? AND (? IS NULL OR s.repo_id=?) AND (? IS NULL OR s.service_path=?)',
|
|
299
307
|
)
|
|
300
308
|
.all(workspaceId, repo?.id, repo?.id, opts.service, opts.service);
|
|
301
|
-
|
|
309
|
+
writeStdout(renderJson(rows));
|
|
302
310
|
}).catch(fail),
|
|
303
311
|
);
|
|
304
312
|
list
|
|
@@ -313,7 +321,7 @@ export function createProgram(): Command {
|
|
|
313
321
|
? selectRepository(db, opts.repo, workspaceId)
|
|
314
322
|
: {};
|
|
315
323
|
if (selection.diagnostic) {
|
|
316
|
-
|
|
324
|
+
writeStdout(renderJson([selection.diagnostic]));
|
|
317
325
|
return;
|
|
318
326
|
}
|
|
319
327
|
const repo = selection.repo;
|
|
@@ -330,7 +338,7 @@ export function createProgram(): Command {
|
|
|
330
338
|
opts.operation ? `/${opts.operation}` : undefined,
|
|
331
339
|
opts.operation ? `%${opts.operation}%` : undefined,
|
|
332
340
|
);
|
|
333
|
-
|
|
341
|
+
writeStdout(renderJson(rows));
|
|
334
342
|
}).catch(fail),
|
|
335
343
|
);
|
|
336
344
|
program
|
|
@@ -382,7 +390,7 @@ export function createProgram(): Command {
|
|
|
382
390
|
maxDynamicCandidates: parsePositiveInteger(opts.maxDynamicCandidates, 5),
|
|
383
391
|
},
|
|
384
392
|
);
|
|
385
|
-
|
|
393
|
+
writeStdout(
|
|
386
394
|
opts.format === 'json'
|
|
387
395
|
? renderTraceJson(result)
|
|
388
396
|
: renderMermaid(result),
|
|
@@ -398,7 +406,7 @@ export function createProgram(): Command {
|
|
|
398
406
|
(name: string, opts: { workspace?: string }) =>
|
|
399
407
|
void withReadOnlyWorkspace(opts.workspace, (db, workspaceId) => {
|
|
400
408
|
const selection = selectRepository(db, name, workspaceId);
|
|
401
|
-
|
|
409
|
+
writeStdout(renderJson(
|
|
402
410
|
selection.repo ?? selection.diagnostic ?? { error: 'repo not found' },
|
|
403
411
|
));
|
|
404
412
|
}).catch(fail),
|
|
@@ -415,7 +423,7 @@ export function createProgram(): Command {
|
|
|
415
423
|
'SELECT o.* FROM cds_operations o JOIN cds_services s ON s.id=o.service_id JOIN repositories r ON r.id=s.repo_id WHERE r.workspace_id=? AND (o.operation_name=? OR o.operation_path=?)',
|
|
416
424
|
)
|
|
417
425
|
.all(workspaceId, selector, selector);
|
|
418
|
-
|
|
426
|
+
writeStdout(renderJson(rows));
|
|
419
427
|
}).catch(fail),
|
|
420
428
|
);
|
|
421
429
|
program
|
|
@@ -428,7 +436,7 @@ export function createProgram(): Command {
|
|
|
428
436
|
(opts: { workspace?: string; strict?: boolean; detail?: boolean; format?: string }) =>
|
|
429
437
|
void withReadOnlyWorkspace(opts.workspace, (db) => {
|
|
430
438
|
const allDiagnostics = doctorDiagnostics(db, Boolean(opts.strict), { detail: Boolean(opts.detail) });
|
|
431
|
-
|
|
439
|
+
writeStdout(renderDoctorDiagnostics(allDiagnostics, opts.format));
|
|
432
440
|
}).catch(fail),
|
|
433
441
|
);
|
|
434
442
|
program
|
|
@@ -440,7 +448,7 @@ export function createProgram(): Command {
|
|
|
440
448
|
void (async () => {
|
|
441
449
|
const config = await loadWorkspaceConfig(opts.workspace);
|
|
442
450
|
await cleanWorkspaceState(config, Boolean(opts.dbOnly));
|
|
443
|
-
|
|
451
|
+
writeStdout('Cleaned service-flow state\n');
|
|
444
452
|
})().catch(fail),
|
|
445
453
|
);
|
|
446
454
|
return program;
|
|
@@ -4,6 +4,8 @@ import { normalizeDecoratorOperationSignal, normalizedOperationName } from './op
|
|
|
4
4
|
import {
|
|
5
5
|
boundedImplementationEvidence,
|
|
6
6
|
boundedImplementationTargetIds,
|
|
7
|
+
displayImplementationCandidates,
|
|
8
|
+
selectedHandlerProvenance,
|
|
7
9
|
} from './001-implementation-evidence-projection.js';
|
|
8
10
|
|
|
9
11
|
interface ImplementationCandidate extends Record<string, unknown> {
|
|
@@ -107,6 +109,7 @@ function implementationDecision(
|
|
|
107
109
|
: winners.length > 1 ? ['multiple_equal_score_implementation_candidates'] : [];
|
|
108
110
|
const evidence = implementationEvidence(
|
|
109
111
|
operation, implementationContext, candidates, duplicateFamilies, ambiguityReasons,
|
|
112
|
+
unique,
|
|
110
113
|
);
|
|
111
114
|
const hintProjection = implementationHintSuggestionProjection(evidence);
|
|
112
115
|
return {
|
|
@@ -174,6 +177,7 @@ function implementationEvidence(
|
|
|
174
177
|
candidates: ImplementationCandidate[],
|
|
175
178
|
duplicateFamilies: Array<Record<string, unknown>>,
|
|
176
179
|
ambiguityReasons: string[],
|
|
180
|
+
selected: ImplementationCandidate | undefined,
|
|
177
181
|
): Record<string, unknown> {
|
|
178
182
|
return {
|
|
179
183
|
servicePath: operation.servicePath,
|
|
@@ -191,7 +195,13 @@ function implementationEvidence(
|
|
|
191
195
|
implementationOperationId: context.operationId,
|
|
192
196
|
ambiguityReasons,
|
|
193
197
|
candidateFamilies: duplicateFamilies,
|
|
194
|
-
|
|
198
|
+
selectedHandler: selected
|
|
199
|
+
? selectedHandlerProvenance(selectedHandlerSource(selected))
|
|
200
|
+
: undefined,
|
|
201
|
+
candidates: displayImplementationCandidates(
|
|
202
|
+
candidates.map((candidate, index) => candidateEvidence(candidate, index + 1)),
|
|
203
|
+
selected?.methodId,
|
|
204
|
+
),
|
|
195
205
|
};
|
|
196
206
|
}
|
|
197
207
|
|
|
@@ -350,7 +360,7 @@ function implementationCandidates(
|
|
|
350
360
|
hm.id methodId,hm.method_name methodName,hm.decorator_value decoratorValue,
|
|
351
361
|
hm.decorator_raw_expression decoratorRawExpression,
|
|
352
362
|
hm.decorator_resolution_json decoratorResolutionJson,hc.id classId,
|
|
353
|
-
hc.class_name className,hc.source_file sourceFile,
|
|
363
|
+
hc.class_name className,hc.source_file sourceFile,hm.source_line sourceLine,
|
|
354
364
|
hr.id registrationId,hr.handler_class_id registrationHandlerClassId,
|
|
355
365
|
hr.class_name registrationClassName,hr.repo_id applicationRepoId,
|
|
356
366
|
hr.registration_file registrationFile,hr.registration_line registrationLine,
|
|
@@ -526,6 +536,7 @@ function ownershipScore(
|
|
|
526
536
|
function candidateEvidence(candidate: ImplementationCandidate, rank: number): Record<string, unknown> {
|
|
527
537
|
return {
|
|
528
538
|
rank,
|
|
539
|
+
rankKind: 'discovery_score',
|
|
529
540
|
score: candidate.score,
|
|
530
541
|
accepted: candidate.accepted,
|
|
531
542
|
acceptedReasons: candidate.acceptedReasons,
|
|
@@ -580,6 +591,28 @@ function candidateEvidence(candidate: ImplementationCandidate, rank: number): Re
|
|
|
580
591
|
};
|
|
581
592
|
}
|
|
582
593
|
|
|
594
|
+
function selectedHandlerSource(candidate: ImplementationCandidate): {
|
|
595
|
+
methodId: number;
|
|
596
|
+
className?: string;
|
|
597
|
+
methodName?: string;
|
|
598
|
+
repositoryId?: number;
|
|
599
|
+
repositoryName?: string;
|
|
600
|
+
repositoryPackageName?: string;
|
|
601
|
+
sourceFile?: string;
|
|
602
|
+
sourceLine?: number;
|
|
603
|
+
} {
|
|
604
|
+
return {
|
|
605
|
+
methodId: candidate.methodId,
|
|
606
|
+
className: stringValue(candidate.className),
|
|
607
|
+
methodName: stringValue(candidate.methodName),
|
|
608
|
+
repositoryId: numberValue(candidate.handlerRepoId),
|
|
609
|
+
repositoryName: stringValue(candidate.handlerRepo),
|
|
610
|
+
repositoryPackageName: stringValue(candidate.handlerPackage),
|
|
611
|
+
sourceFile: stringValue(candidate.sourceFile),
|
|
612
|
+
sourceLine: numberValue(candidate.sourceLine),
|
|
613
|
+
};
|
|
614
|
+
}
|
|
615
|
+
|
|
583
616
|
function implementationMethodSignal(
|
|
584
617
|
row: Record<string, unknown>,
|
|
585
618
|
operation: Record<string, unknown>,
|
|
@@ -1,14 +1,91 @@
|
|
|
1
1
|
import {
|
|
2
2
|
projectBounded,
|
|
3
|
+
projectBoundedInOrder,
|
|
3
4
|
type BoundedProjection,
|
|
4
5
|
} from '../utils/000-bounded-projection.js';
|
|
5
6
|
|
|
7
|
+
export interface SelectedHandlerSource {
|
|
8
|
+
methodId: number;
|
|
9
|
+
className?: string;
|
|
10
|
+
methodName?: string;
|
|
11
|
+
repositoryId?: number;
|
|
12
|
+
repositoryName?: string;
|
|
13
|
+
repositoryPackageName?: string;
|
|
14
|
+
sourceFile?: string;
|
|
15
|
+
sourceLine?: number;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export interface SelectedHandlerProvenance {
|
|
19
|
+
status: 'selected';
|
|
20
|
+
accepted: true;
|
|
21
|
+
graphTargetId: string;
|
|
22
|
+
methodId: number;
|
|
23
|
+
className?: string;
|
|
24
|
+
methodName?: string;
|
|
25
|
+
repository?: {
|
|
26
|
+
id?: number;
|
|
27
|
+
name?: string;
|
|
28
|
+
packageName?: string;
|
|
29
|
+
};
|
|
30
|
+
sourceFile?: string;
|
|
31
|
+
sourceLine?: number;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export function selectedHandlerProvenance(
|
|
35
|
+
source: SelectedHandlerSource,
|
|
36
|
+
): SelectedHandlerProvenance {
|
|
37
|
+
return {
|
|
38
|
+
status: 'selected',
|
|
39
|
+
accepted: true,
|
|
40
|
+
graphTargetId: String(source.methodId),
|
|
41
|
+
methodId: source.methodId,
|
|
42
|
+
className: source.className,
|
|
43
|
+
methodName: source.methodName,
|
|
44
|
+
repository: {
|
|
45
|
+
id: source.repositoryId,
|
|
46
|
+
name: source.repositoryName,
|
|
47
|
+
packageName: source.repositoryPackageName,
|
|
48
|
+
},
|
|
49
|
+
sourceFile: source.sourceFile,
|
|
50
|
+
sourceLine: source.sourceLine,
|
|
51
|
+
};
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export function displayImplementationCandidates(
|
|
55
|
+
candidates: Array<Record<string, unknown>>,
|
|
56
|
+
selectedMethodId: number | undefined,
|
|
57
|
+
): Array<Record<string, unknown>> {
|
|
58
|
+
return [...candidates]
|
|
59
|
+
.sort((left, right) => compareDisplayCandidate(
|
|
60
|
+
left, right, selectedMethodId,
|
|
61
|
+
))
|
|
62
|
+
.map((candidate, index) => ({
|
|
63
|
+
...candidate,
|
|
64
|
+
discoveryRank: candidate.rank,
|
|
65
|
+
displayRank: index + 1,
|
|
66
|
+
selected: selectedMethodId !== undefined
|
|
67
|
+
&& Number(candidate.methodId) === selectedMethodId,
|
|
68
|
+
}));
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function compareDisplayCandidate(
|
|
72
|
+
left: Record<string, unknown>,
|
|
73
|
+
right: Record<string, unknown>,
|
|
74
|
+
selectedMethodId: number | undefined,
|
|
75
|
+
): number {
|
|
76
|
+
return Number(Number(right.methodId) === selectedMethodId)
|
|
77
|
+
- Number(Number(left.methodId) === selectedMethodId)
|
|
78
|
+
|| Number(right.accepted === true) - Number(left.accepted === true)
|
|
79
|
+
|| numberValue(left.rank) - numberValue(right.rank)
|
|
80
|
+
|| compareTargetCandidates(left, right);
|
|
81
|
+
}
|
|
82
|
+
|
|
6
83
|
export function boundedImplementationEvidence(
|
|
7
84
|
evidence: Record<string, unknown>,
|
|
8
85
|
targetCandidateCount: number,
|
|
9
86
|
): Record<string, unknown> {
|
|
10
87
|
const candidates = recordArray(evidence.candidates);
|
|
11
|
-
const candidateProjection =
|
|
88
|
+
const candidateProjection = projectBoundedInOrder(candidates);
|
|
12
89
|
const families = recordArray(evidence.candidateFamilies);
|
|
13
90
|
const familyProjection = projectBounded(families, compareFamilies);
|
|
14
91
|
const hints = recordArray(evidence.implementationHintSuggestions);
|
|
@@ -80,11 +157,6 @@ function compareTargetCandidates(left: Record<string, unknown>, right: Record<st
|
|
|
80
157
|
|| Number(left.methodId ?? 0) - Number(right.methodId ?? 0);
|
|
81
158
|
}
|
|
82
159
|
|
|
83
|
-
function compareCandidateEvidence(left: Record<string, unknown>, right: Record<string, unknown>): number {
|
|
84
|
-
return Number(left.rank ?? 0) - Number(right.rank ?? 0)
|
|
85
|
-
|| compareTargetCandidates(left, right);
|
|
86
|
-
}
|
|
87
|
-
|
|
88
160
|
function compareFamilies(left: Record<string, unknown>, right: Record<string, unknown>): number {
|
|
89
161
|
return String(left.packageName ?? '').localeCompare(String(right.packageName ?? ''))
|
|
90
162
|
|| String(left.reason ?? '').localeCompare(String(right.reason ?? ''));
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
import type { Writable } from 'node:stream';
|
|
2
|
+
|
|
3
|
+
export interface StdoutWriter {
|
|
4
|
+
write(value: string): boolean;
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
interface OutputState {
|
|
8
|
+
blocked: boolean;
|
|
9
|
+
unexpectedReported: boolean;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
interface WriterEntry {
|
|
13
|
+
state: OutputState;
|
|
14
|
+
writer: StdoutWriter;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
const writers = new WeakMap<Writable, WriterEntry>();
|
|
18
|
+
|
|
19
|
+
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
20
|
+
return Boolean(value && typeof value === 'object' && !Array.isArray(value));
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function isBrokenPipe(error: Error): boolean {
|
|
24
|
+
return isRecord(error) && error.code === 'EPIPE';
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function asError(value: unknown): Error {
|
|
28
|
+
return value instanceof Error ? value : new Error(String(value));
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function handleStreamError(
|
|
32
|
+
state: OutputState,
|
|
33
|
+
error: Error,
|
|
34
|
+
onUnexpectedError: (error: Error) => void,
|
|
35
|
+
): void {
|
|
36
|
+
state.blocked = true;
|
|
37
|
+
if (isBrokenPipe(error) || state.unexpectedReported) return;
|
|
38
|
+
state.unexpectedReported = true;
|
|
39
|
+
onUnexpectedError(error);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export function createStdoutWriter(
|
|
43
|
+
stream: Writable,
|
|
44
|
+
onUnexpectedError: (error: Error) => void,
|
|
45
|
+
): StdoutWriter {
|
|
46
|
+
const existing = writers.get(stream);
|
|
47
|
+
if (existing) return existing.writer;
|
|
48
|
+
const state: OutputState = { blocked: false, unexpectedReported: false };
|
|
49
|
+
const writer: StdoutWriter = {
|
|
50
|
+
write(value: string): boolean {
|
|
51
|
+
if (state.blocked) return false;
|
|
52
|
+
try {
|
|
53
|
+
stream.write(value);
|
|
54
|
+
return true;
|
|
55
|
+
} catch (error) {
|
|
56
|
+
handleStreamError(state, asError(error), onUnexpectedError);
|
|
57
|
+
return false;
|
|
58
|
+
}
|
|
59
|
+
},
|
|
60
|
+
};
|
|
61
|
+
stream.on('error', (error: Error) =>
|
|
62
|
+
handleStreamError(state, error, onUnexpectedError));
|
|
63
|
+
writers.set(stream, { state, writer });
|
|
64
|
+
return writer;
|
|
65
|
+
}
|
|
@@ -1,13 +1,22 @@
|
|
|
1
1
|
import type { TraceResult } from '../types.js';
|
|
2
2
|
|
|
3
3
|
function location(evidence: Record<string, unknown>): string {
|
|
4
|
+
const selected = isRecord(evidence.selectedHandler)
|
|
5
|
+
? evidence.selectedHandler : undefined;
|
|
6
|
+
const selectedFile = selected?.sourceFile;
|
|
7
|
+
const selectedLine = selected?.sourceLine;
|
|
8
|
+
if (selectedFile || selectedLine)
|
|
9
|
+
return `${String(selectedFile ?? '')}:${String(selectedLine ?? '')}`;
|
|
4
10
|
const file = evidence.file ?? evidence.sourceFile ?? evidence.handlerSourceFile ?? evidence.operationSourceFile ?? evidence.registrationSourceFile;
|
|
5
11
|
const line = evidence.line ?? evidence.sourceLine ?? evidence.handlerSourceLine ?? evidence.operationSourceLine ?? evidence.registrationSourceLine;
|
|
6
12
|
if (file || line) return `${String(file ?? '')}:${String(line ?? '')}`;
|
|
7
13
|
const candidates = evidence.candidates;
|
|
14
|
+
if (Array.isArray(candidates) && candidates.some((candidate) =>
|
|
15
|
+
isRecord(candidate) && candidate.methodId !== undefined)) return ':';
|
|
8
16
|
if (Array.isArray(candidates) && candidates.length > 0) {
|
|
9
|
-
const first = candidates
|
|
10
|
-
|
|
17
|
+
const first = candidates.find(isRecord);
|
|
18
|
+
if (first)
|
|
19
|
+
return `${String(first.sourceFile ?? '')}:${String(first.sourceLine ?? '')}`;
|
|
11
20
|
}
|
|
12
21
|
return ':';
|
|
13
22
|
}
|
|
@@ -40,19 +40,121 @@ function variableInitializers(source: ts.SourceFile): Map<string, ts.Expression>
|
|
|
40
40
|
}
|
|
41
41
|
return initializers;
|
|
42
42
|
}
|
|
43
|
+
function unwrapQueryExpression(expr: ts.Expression): ts.Expression {
|
|
44
|
+
if (ts.isParenthesizedExpression(expr) || ts.isAwaitExpression(expr)
|
|
45
|
+
|| ts.isAsExpression(expr) || ts.isTypeAssertionExpression(expr)
|
|
46
|
+
|| ts.isNonNullExpression(expr) || ts.isSatisfiesExpression(expr))
|
|
47
|
+
return unwrapQueryExpression(expr.expression);
|
|
48
|
+
return expr;
|
|
49
|
+
}
|
|
43
50
|
function queryEntityFromAst(expr: ts.Expression, initializers = new Map<string, ts.Expression>()): string | undefined {
|
|
44
|
-
|
|
45
|
-
if (ts.isIdentifier(
|
|
46
|
-
if (ts.isCallExpression(
|
|
47
|
-
const name = expressionName(
|
|
48
|
-
if (name === 'cds.run') return queryEntityFromAst(
|
|
49
|
-
if (
|
|
50
|
-
|
|
51
|
-
const receiver = ts.isPropertyAccessExpression(expr.expression) ? expr.expression.expression : undefined;
|
|
51
|
+
const unwrapped = unwrapQueryExpression(expr);
|
|
52
|
+
if (ts.isIdentifier(unwrapped) && initializers.has(unwrapped.text)) return queryEntityFromAst(initializers.get(unwrapped.text) as ts.Expression, initializers);
|
|
53
|
+
if (ts.isCallExpression(unwrapped)) {
|
|
54
|
+
const name = expressionName(unwrapped.expression);
|
|
55
|
+
if (name === 'cds.run') return queryEntityFromAst(unwrapped.arguments[0], initializers);
|
|
56
|
+
if (capQueryBuilderRoots.has(name)) return entityFromExpression(unwrapped.arguments[0]);
|
|
57
|
+
const receiver = ts.isPropertyAccessExpression(unwrapped.expression) ? unwrapped.expression.expression : undefined;
|
|
52
58
|
if (receiver) return queryEntityFromAst(receiver, initializers);
|
|
53
59
|
}
|
|
54
60
|
return undefined;
|
|
55
61
|
}
|
|
62
|
+
const capQueryBuilderRoots = new Set([
|
|
63
|
+
'SELECT.from',
|
|
64
|
+
'SELECT.one.from',
|
|
65
|
+
'SELECT.one',
|
|
66
|
+
'INSERT.into',
|
|
67
|
+
'UPSERT.into',
|
|
68
|
+
'UPDATE.entity',
|
|
69
|
+
'UPDATE',
|
|
70
|
+
'DELETE.from',
|
|
71
|
+
]);
|
|
72
|
+
interface DirectQueryBuilderStatement {
|
|
73
|
+
root: ts.CallExpression;
|
|
74
|
+
logicalCall: ts.CallExpression;
|
|
75
|
+
awaitExpression: ts.AwaitExpression;
|
|
76
|
+
}
|
|
77
|
+
function wrapperParent(node: ts.Expression): ts.Expression | undefined {
|
|
78
|
+
const parent = node.parent;
|
|
79
|
+
if ((ts.isParenthesizedExpression(parent) || ts.isAsExpression(parent)
|
|
80
|
+
|| ts.isTypeAssertionExpression(parent) || ts.isNonNullExpression(parent)
|
|
81
|
+
|| ts.isSatisfiesExpression(parent)) && parent.expression === node)
|
|
82
|
+
return parent;
|
|
83
|
+
return undefined;
|
|
84
|
+
}
|
|
85
|
+
function fluentCallParent(node: ts.Expression): ts.CallExpression | undefined {
|
|
86
|
+
const property = node.parent;
|
|
87
|
+
if (!ts.isPropertyAccessExpression(property) || property.expression !== node) return undefined;
|
|
88
|
+
const call = property.parent;
|
|
89
|
+
return ts.isCallExpression(call) && call.expression === property ? call : undefined;
|
|
90
|
+
}
|
|
91
|
+
function queryBuilderRoot(expr: ts.Expression): ts.CallExpression | undefined {
|
|
92
|
+
const unwrapped = unwrapQueryExpression(expr);
|
|
93
|
+
if (!ts.isCallExpression(unwrapped)) return undefined;
|
|
94
|
+
if (capQueryBuilderRoots.has(expressionName(unwrapped.expression))) return unwrapped;
|
|
95
|
+
return ts.isPropertyAccessExpression(unwrapped.expression)
|
|
96
|
+
? queryBuilderRoot(unwrapped.expression.expression)
|
|
97
|
+
: undefined;
|
|
98
|
+
}
|
|
99
|
+
function outerFluentQueryCall(root: ts.CallExpression): ts.CallExpression {
|
|
100
|
+
let current: ts.Expression = root;
|
|
101
|
+
let outer = root;
|
|
102
|
+
while (true) {
|
|
103
|
+
const wrapper = wrapperParent(current);
|
|
104
|
+
if (wrapper) {
|
|
105
|
+
current = wrapper;
|
|
106
|
+
continue;
|
|
107
|
+
}
|
|
108
|
+
const next = fluentCallParent(current);
|
|
109
|
+
if (!next) return outer;
|
|
110
|
+
outer = next;
|
|
111
|
+
current = next;
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
function directQueryBuilderStatement(node: ts.CallExpression): DirectQueryBuilderStatement | undefined {
|
|
115
|
+
const root = queryBuilderRoot(node);
|
|
116
|
+
if (!root) return undefined;
|
|
117
|
+
const logicalCall = outerFluentQueryCall(root);
|
|
118
|
+
if (logicalCall !== node) return undefined;
|
|
119
|
+
let current: ts.Expression = logicalCall;
|
|
120
|
+
while (true) {
|
|
121
|
+
const wrapper = wrapperParent(current);
|
|
122
|
+
if (wrapper) {
|
|
123
|
+
current = wrapper;
|
|
124
|
+
continue;
|
|
125
|
+
}
|
|
126
|
+
const parent = current.parent;
|
|
127
|
+
return ts.isAwaitExpression(parent) && parent.expression === current
|
|
128
|
+
? { root, logicalCall, awaitExpression: parent }
|
|
129
|
+
: undefined;
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
function queryBuilderEvidence(source: ts.SourceFile, statement: DirectQueryBuilderStatement): Record<string, unknown> {
|
|
133
|
+
return {
|
|
134
|
+
classifier: 'cap_query_builder_direct',
|
|
135
|
+
queryDispatch: 'direct_query_builder',
|
|
136
|
+
queryRoot: expressionName(statement.root.expression),
|
|
137
|
+
queryRootStartOffset: statement.root.getStart(source),
|
|
138
|
+
queryRootEndOffset: statement.root.getEnd(),
|
|
139
|
+
queryStatementStartOffset: statement.awaitExpression.getStart(source),
|
|
140
|
+
queryStatementEndOffset: statement.awaitExpression.getEnd(),
|
|
141
|
+
};
|
|
142
|
+
}
|
|
143
|
+
function queryRunEvidence(
|
|
144
|
+
source: ts.SourceFile,
|
|
145
|
+
argument: ts.Expression | undefined,
|
|
146
|
+
): Record<string, unknown> {
|
|
147
|
+
const root = argument ? queryBuilderRoot(argument) : undefined;
|
|
148
|
+
return {
|
|
149
|
+
classifier: 'cap_query_run_wrapper',
|
|
150
|
+
queryDispatch: 'cds_run_wrapper',
|
|
151
|
+
...(root ? {
|
|
152
|
+
queryRoot: expressionName(root.expression),
|
|
153
|
+
queryRootStartOffset: root.getStart(source),
|
|
154
|
+
queryRootEndOffset: root.getEnd(),
|
|
155
|
+
} : {}),
|
|
156
|
+
};
|
|
157
|
+
}
|
|
56
158
|
function extractQueryEntity(expr: string): string | undefined {
|
|
57
159
|
const source = ts.createSourceFile('query.ts', `const __query = (${expr});`, ts.ScriptTarget.Latest, true, ts.ScriptKind.TS);
|
|
58
160
|
const initializers = variableInitializers(source);
|
|
@@ -98,7 +200,6 @@ function nameOfProperty(name: ts.PropertyName): string | undefined {
|
|
|
98
200
|
if (ts.isIdentifier(name) || ts.isStringLiteral(name) || ts.isNumericLiteral(name)) return name.text;
|
|
99
201
|
return undefined;
|
|
100
202
|
}
|
|
101
|
-
|
|
102
203
|
type ExpressionStatus = 'static' | 'dynamic' | 'ambiguous' | 'unknown';
|
|
103
204
|
type ExpressionSourceKind = 'string_literal' | 'no_substitution_template' | 'template_with_substitutions' | 'const_alias' | 'conditional_candidates' | 'dynamic_expression';
|
|
104
205
|
interface ExpressionResolution { status: ExpressionStatus; sourceKind: ExpressionSourceKind; value?: string; rawExpression?: string; placeholderKeys: string[]; evidence: string[]; constName?: string }
|
|
@@ -303,7 +404,6 @@ function externalHttpEvidence(node: ts.CallExpression, source: ts.SourceFile): {
|
|
|
303
404
|
}
|
|
304
405
|
return undefined;
|
|
305
406
|
}
|
|
306
|
-
|
|
307
407
|
function collectServiceVariables(source: ts.SourceFile): Set<string> {
|
|
308
408
|
const vars = new Set<string>(['cds', 'messaging', 'messageClient', 'eventClient']);
|
|
309
409
|
const visit = (node: ts.Node): void => {
|
|
@@ -423,11 +523,16 @@ export function classifyOutboundCallsInSource(source: ts.SourceFile, filePath: s
|
|
|
423
523
|
}
|
|
424
524
|
const expr = node.expression;
|
|
425
525
|
const exprText = expr.getText(source);
|
|
526
|
+
const directQuery = directQueryBuilderStatement(node);
|
|
426
527
|
if (exprText === 'cds.run') {
|
|
427
528
|
const arg = node.arguments[0];
|
|
428
529
|
const entity = arg ? queryEntityFromAst(arg, initializers) : undefined;
|
|
429
530
|
const payload = arg?.getText(source) ?? '';
|
|
430
|
-
add(node, { callType: 'local_db_query', queryEntity: entity, payloadSummary: summarizeExpression(payload), confidence: entity ? 0.9 : 0.55, unresolvedReason: entity ? undefined : queryWarning(payload) });
|
|
531
|
+
add(node, { callType: 'local_db_query', queryEntity: entity, payloadSummary: summarizeExpression(payload), confidence: entity ? 0.9 : 0.55, unresolvedReason: entity ? undefined : queryWarning(payload) }, queryRunEvidence(source, arg));
|
|
532
|
+
} else if (directQuery) {
|
|
533
|
+
const entity = queryEntityFromAst(directQuery.logicalCall, initializers);
|
|
534
|
+
const payload = directQuery.logicalCall.getText(source);
|
|
535
|
+
add(directQuery.logicalCall, { callType: 'local_db_query', queryEntity: entity, payloadSummary: summarizeExpression(payload), confidence: entity ? 0.9 : 0.55, unresolvedReason: entity ? undefined : queryWarning(payload) }, queryBuilderEvidence(source, directQuery));
|
|
431
536
|
} else if (ts.isPropertyAccessExpression(expr) && expr.name.text === 'send' && (ts.isIdentifier(expr.expression) || ts.isPropertyAccessExpression(expr.expression))) {
|
|
432
537
|
const objectArg = node.arguments[0];
|
|
433
538
|
if (objectArg && ts.isObjectLiteralExpression(objectArg)) {
|