@saptools/service-flow 0.1.49 → 0.1.50
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 +7 -0
- package/README.md +1 -1
- package/dist/{chunk-XOROZHW4.js → chunk-52OUS3MO.js} +303 -109
- package/dist/chunk-52OUS3MO.js.map +1 -0
- package/dist/cli.js +67 -16
- package/dist/cli.js.map +1 -1
- package/dist/index.d.ts +6 -0
- package/dist/index.js +1 -1
- package/package.json +1 -1
- package/src/cli/doctor.ts +59 -1
- package/src/db/migrations.ts +4 -1
- package/src/db/repositories.ts +2 -1
- package/src/db/schema.ts +1 -1
- package/src/linker/cross-repo-linker.ts +102 -5
- package/src/linker/operation-decorator-normalizer.ts +3 -3
- package/src/parsers/decorator-parser.ts +128 -22
- package/src/trace/selectors.ts +36 -0
- package/src/trace/trace-engine.ts +1 -20
- package/src/types.ts +12 -0
- package/dist/chunk-XOROZHW4.js.map +0 -1
package/dist/index.d.ts
CHANGED
|
@@ -61,6 +61,12 @@ interface HandlerMethodFact {
|
|
|
61
61
|
decoratorKind: string;
|
|
62
62
|
decoratorValue?: string;
|
|
63
63
|
decoratorRawExpression: string;
|
|
64
|
+
decoratorResolution: {
|
|
65
|
+
rawExpression: string;
|
|
66
|
+
resolvedValue?: string;
|
|
67
|
+
resolutionKind: 'literal' | 'const_identifier' | 'enum_member' | 'const_object_property' | 'generated_constant_name' | 'unresolved';
|
|
68
|
+
unresolvedReason?: string;
|
|
69
|
+
};
|
|
64
70
|
sourceFile: string;
|
|
65
71
|
sourceLine: number;
|
|
66
72
|
}
|
package/dist/index.js
CHANGED
package/package.json
CHANGED
package/src/cli/doctor.ts
CHANGED
|
@@ -150,6 +150,8 @@ function parserQualityDiagnostics(db: Db, strict: boolean, options: DoctorOption
|
|
|
150
150
|
classInstanceNoiseQuality(db),
|
|
151
151
|
contextualBindingPropagationQuality(db),
|
|
152
152
|
serviceBindingQuality(db, Boolean(options.detail)),
|
|
153
|
+
decoratorResolutionQuality(db),
|
|
154
|
+
handlerRegistrationPairingQuality(db),
|
|
153
155
|
nestedThisReceiverQuality(db),
|
|
154
156
|
wrapperPathPropagationQuality(db),
|
|
155
157
|
remoteQueryTargetQuality(db),
|
|
@@ -249,9 +251,18 @@ function addImplementationCategory(grouped: Map<string, Diagnostic & { count: nu
|
|
|
249
251
|
const key = [category, baseOperation, reason, family].join('\0');
|
|
250
252
|
const current = grouped.get(key) ?? { category, baseOperation, reason, candidateFamily: family, count: 0, servicePaths: [], examples: [] };
|
|
251
253
|
const hintSuggestions = implementationSuggestions(evidence);
|
|
254
|
+
const candidates = asRecords(evidence.candidates);
|
|
252
255
|
current.count += 1;
|
|
253
256
|
current.servicePaths.push(String(row.servicePath ?? evidence.servicePath ?? ''));
|
|
254
|
-
current.examples.push({
|
|
257
|
+
current.examples.push({
|
|
258
|
+
servicePath: row.servicePath,
|
|
259
|
+
operation: row.operationName,
|
|
260
|
+
status: row.status,
|
|
261
|
+
reason: row.unresolvedReason,
|
|
262
|
+
candidateCount: candidates.length,
|
|
263
|
+
candidateEvidence: candidates.slice(0, 3),
|
|
264
|
+
implementationHintSuggestions: hintSuggestions,
|
|
265
|
+
});
|
|
255
266
|
grouped.set(key, current);
|
|
256
267
|
}
|
|
257
268
|
|
|
@@ -452,6 +463,53 @@ function bindingCategoryAction(category: string): string {
|
|
|
452
463
|
return 'Add a direct CAP client binding or statically provable helper-return binding.';
|
|
453
464
|
}
|
|
454
465
|
|
|
466
|
+
function decoratorResolutionQuality(db: Db): Diagnostic {
|
|
467
|
+
const aggregate = db.prepare(`SELECT
|
|
468
|
+
SUM(CASE WHEN json_extract(decorator_resolution_json,'$.resolutionKind')
|
|
469
|
+
IN ('const_identifier','enum_member','const_object_property','generated_constant_name') THEN 1 ELSE 0 END) resolvedFromConstants,
|
|
470
|
+
SUM(CASE WHEN json_extract(decorator_resolution_json,'$.resolutionKind')
|
|
471
|
+
='unresolved' THEN 1 ELSE 0 END) unresolvedExpressions
|
|
472
|
+
FROM handler_methods`).get() as Diagnostic;
|
|
473
|
+
const unresolved = Number(aggregate.unresolvedExpressions ?? 0);
|
|
474
|
+
const examples = db.prepare(`SELECT hm.method_name methodName,
|
|
475
|
+
hm.decorator_raw_expression rawExpression,
|
|
476
|
+
json_extract(hm.decorator_resolution_json,'$.unresolvedReason') unresolvedReason,
|
|
477
|
+
hm.source_file sourceFile,hm.source_line sourceLine
|
|
478
|
+
FROM handler_methods hm
|
|
479
|
+
WHERE json_extract(hm.decorator_resolution_json,'$.resolutionKind')='unresolved'
|
|
480
|
+
ORDER BY hm.source_file,hm.source_line LIMIT 5`).all() as Diagnostic[];
|
|
481
|
+
return {
|
|
482
|
+
severity: unresolved > 0 ? 'warning' : 'info',
|
|
483
|
+
code: 'strict_decorator_resolution_quality',
|
|
484
|
+
message: 'Handler decorator string-resolution aggregate',
|
|
485
|
+
resolvedFromConstants: Number(aggregate.resolvedFromConstants ?? 0),
|
|
486
|
+
unresolvedExpressions: unresolved,
|
|
487
|
+
unresolvedExamples: examples,
|
|
488
|
+
};
|
|
489
|
+
}
|
|
490
|
+
|
|
491
|
+
function handlerRegistrationPairingQuality(db: Db): Diagnostic {
|
|
492
|
+
const mismatch = db.prepare(`SELECT COUNT(*) count
|
|
493
|
+
FROM handler_registrations hr
|
|
494
|
+
JOIN handler_classes hc ON hc.id=hr.handler_class_id
|
|
495
|
+
WHERE hr.handler_class_id IS NOT NULL
|
|
496
|
+
AND (hc.repo_id<>hr.repo_id OR hc.class_name<>hr.class_name)`).get() as Diagnostic;
|
|
497
|
+
const prevented = db.prepare(`SELECT COUNT(*) count
|
|
498
|
+
FROM handler_registrations hr
|
|
499
|
+
JOIN handler_classes exactClass ON exactClass.id=hr.handler_class_id
|
|
500
|
+
JOIN handler_classes otherClass ON otherClass.class_name=hr.class_name
|
|
501
|
+
AND otherClass.repo_id<>hr.repo_id
|
|
502
|
+
WHERE hr.handler_class_id IS NOT NULL AND hr.import_source IS NOT NULL`).get() as Diagnostic;
|
|
503
|
+
const mismatched = Number(mismatch.count ?? 0);
|
|
504
|
+
return {
|
|
505
|
+
severity: mismatched > 0 ? 'error' : 'info',
|
|
506
|
+
code: 'strict_handler_registration_pairing_quality',
|
|
507
|
+
message: 'Handler registration and class ownership aggregate',
|
|
508
|
+
mismatchedExactRegistrations: mismatched,
|
|
509
|
+
preventedSyntheticCrossRepositoryPairs: Number(prevented.count ?? 0),
|
|
510
|
+
};
|
|
511
|
+
}
|
|
512
|
+
|
|
455
513
|
function wrapperPathPropagationQuality(db: Db): Diagnostic {
|
|
456
514
|
const examples = db.prepare("SELECT source_file sourceFile,source_line sourceLine,json_extract(evidence_json,'$.receiver') receiverName,json_extract(evidence_json,'$.operationPathExpression') pathIdentifier 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 Diagnostic[];
|
|
457
515
|
return { severity: examples.length > 0 ? 'warning' : 'info', code: 'strict_wrapper_path_propagation_quality', message: 'Dynamic path sends where send({ path }) used a path identifier', dynamicPathIdentifierCalls: examples.length, examples };
|
package/src/db/migrations.ts
CHANGED
|
@@ -1,7 +1,10 @@
|
|
|
1
1
|
import type { Db } from './connection.js';
|
|
2
2
|
import { schemaSql } from './schema.js';
|
|
3
|
-
const CURRENT_SCHEMA_VERSION =
|
|
3
|
+
const CURRENT_SCHEMA_VERSION = 10;
|
|
4
4
|
const columns: Record<string, Array<{ name: string; ddl: string }>> = {
|
|
5
|
+
handler_methods: [
|
|
6
|
+
{ name: 'decorator_resolution_json', ddl: "ALTER TABLE handler_methods ADD COLUMN decorator_resolution_json TEXT NOT NULL DEFAULT '{}'" },
|
|
7
|
+
],
|
|
5
8
|
service_bindings: [
|
|
6
9
|
{ name: 'helper_chain_json', ddl: 'ALTER TABLE service_bindings ADD COLUMN helper_chain_json TEXT' },
|
|
7
10
|
{ name: 'alias_expr', ddl: 'ALTER TABLE service_bindings ADD COLUMN alias_expr TEXT' },
|
package/src/db/repositories.ts
CHANGED
|
@@ -211,7 +211,7 @@ export function insertHandler(
|
|
|
211
211
|
.get(repoId, sid, h.className, h.sourceFile, h.sourceLine)?.id,
|
|
212
212
|
);
|
|
213
213
|
const stmt = db.prepare(
|
|
214
|
-
'INSERT INTO handler_methods(handler_class_id,method_name,decorator_kind,decorator_value,decorator_raw_expression,source_file,source_line) VALUES(
|
|
214
|
+
'INSERT INTO handler_methods(handler_class_id,method_name,decorator_kind,decorator_value,decorator_raw_expression,decorator_resolution_json,source_file,source_line) VALUES(?,?,?,?,?,?,?,?)',
|
|
215
215
|
);
|
|
216
216
|
for (const m of h.methods)
|
|
217
217
|
stmt.run(
|
|
@@ -220,6 +220,7 @@ export function insertHandler(
|
|
|
220
220
|
m.decoratorKind,
|
|
221
221
|
m.decoratorValue,
|
|
222
222
|
m.decoratorRawExpression,
|
|
223
|
+
JSON.stringify(m.decoratorResolution),
|
|
223
224
|
m.sourceFile,
|
|
224
225
|
m.sourceLine,
|
|
225
226
|
);
|
package/src/db/schema.ts
CHANGED
|
@@ -7,7 +7,7 @@ CREATE TABLE IF NOT EXISTS cds_services (id INTEGER PRIMARY KEY, repo_id INTEGER
|
|
|
7
7
|
CREATE TABLE IF NOT EXISTS cds_operations (id INTEGER PRIMARY KEY, service_id INTEGER NOT NULL, operation_type TEXT NOT NULL, operation_name TEXT NOT NULL, operation_path TEXT NOT NULL, params_json TEXT NOT NULL, return_type TEXT, source_file TEXT NOT NULL, source_line INTEGER NOT NULL, provenance TEXT NOT NULL DEFAULT 'direct', base_operation_id INTEGER, FOREIGN KEY(service_id) REFERENCES cds_services(id) ON DELETE CASCADE, FOREIGN KEY(base_operation_id) REFERENCES cds_operations(id) ON DELETE SET NULL);
|
|
8
8
|
CREATE TABLE IF NOT EXISTS symbols (id INTEGER PRIMARY KEY, repo_id INTEGER NOT NULL, file_id INTEGER, kind TEXT NOT NULL, name TEXT NOT NULL, qualified_name TEXT NOT NULL, exported INTEGER NOT NULL, start_line INTEGER NOT NULL, end_line INTEGER NOT NULL, start_offset INTEGER, end_offset INTEGER, source_file TEXT, exported_name TEXT, evidence_json TEXT, FOREIGN KEY(repo_id) REFERENCES repositories(id) ON DELETE CASCADE, FOREIGN KEY(file_id) REFERENCES files(id) ON DELETE CASCADE);
|
|
9
9
|
CREATE TABLE IF NOT EXISTS handler_classes (id INTEGER PRIMARY KEY, repo_id INTEGER NOT NULL, symbol_id INTEGER, class_name TEXT NOT NULL, source_file TEXT NOT NULL, source_line INTEGER NOT NULL, FOREIGN KEY(repo_id) REFERENCES repositories(id) ON DELETE CASCADE, FOREIGN KEY(symbol_id) REFERENCES symbols(id) ON DELETE SET NULL);
|
|
10
|
-
CREATE TABLE IF NOT EXISTS handler_methods (id INTEGER PRIMARY KEY, handler_class_id INTEGER NOT NULL, method_name TEXT NOT NULL, decorator_kind TEXT NOT NULL, decorator_value TEXT, decorator_raw_expression TEXT NOT NULL, source_file TEXT NOT NULL, source_line INTEGER NOT NULL, FOREIGN KEY(handler_class_id) REFERENCES handler_classes(id) ON DELETE CASCADE);
|
|
10
|
+
CREATE TABLE IF NOT EXISTS handler_methods (id INTEGER PRIMARY KEY, handler_class_id INTEGER NOT NULL, method_name TEXT NOT NULL, decorator_kind TEXT NOT NULL, decorator_value TEXT, decorator_raw_expression TEXT NOT NULL, decorator_resolution_json TEXT NOT NULL DEFAULT '{}', source_file TEXT NOT NULL, source_line INTEGER NOT NULL, FOREIGN KEY(handler_class_id) REFERENCES handler_classes(id) ON DELETE CASCADE);
|
|
11
11
|
CREATE TABLE IF NOT EXISTS handler_registrations (id INTEGER PRIMARY KEY, repo_id INTEGER NOT NULL, handler_class_id INTEGER, class_name TEXT, import_source TEXT, registration_file TEXT NOT NULL, registration_line INTEGER NOT NULL, registration_kind TEXT NOT NULL, confidence REAL NOT NULL, FOREIGN KEY(repo_id) REFERENCES repositories(id) ON DELETE CASCADE, FOREIGN KEY(handler_class_id) REFERENCES handler_classes(id) ON DELETE SET NULL);
|
|
12
12
|
CREATE TABLE IF NOT EXISTS service_bindings (id INTEGER PRIMARY KEY, repo_id INTEGER NOT NULL, symbol_id INTEGER, variable_name TEXT NOT NULL, alias TEXT, alias_expr TEXT, destination_expr TEXT, service_path_expr TEXT, is_dynamic INTEGER NOT NULL, placeholders_json TEXT NOT NULL, source_file TEXT NOT NULL, source_line INTEGER NOT NULL, helper_chain_json TEXT, FOREIGN KEY(repo_id) REFERENCES repositories(id) ON DELETE CASCADE, FOREIGN KEY(symbol_id) REFERENCES symbols(id) ON DELETE SET NULL);
|
|
13
13
|
CREATE TABLE IF NOT EXISTS outbound_calls (id INTEGER PRIMARY KEY, repo_id INTEGER NOT NULL, source_symbol_id INTEGER, call_type TEXT NOT NULL, service_binding_id INTEGER, method TEXT, operation_path_expr TEXT, query_entity TEXT, event_name_expr TEXT, payload_summary TEXT, source_file TEXT NOT NULL, source_line INTEGER NOT NULL, confidence REAL NOT NULL, unresolved_reason TEXT, local_service_name TEXT, local_service_lookup TEXT, alias_chain_json TEXT, evidence_json TEXT, external_target_kind TEXT, external_target_id TEXT, external_target_label TEXT, external_target_dynamic INTEGER NOT NULL DEFAULT 0, FOREIGN KEY(repo_id) REFERENCES repositories(id) ON DELETE CASCADE, FOREIGN KEY(source_symbol_id) REFERENCES symbols(id) ON DELETE SET NULL, FOREIGN KEY(service_binding_id) REFERENCES service_bindings(id) ON DELETE SET NULL);
|
|
@@ -301,14 +301,64 @@ function implementationContextForOperation(db: Db, operation: Record<string, unk
|
|
|
301
301
|
}
|
|
302
302
|
function rankedImplementationCandidates(db: Db, workspaceId: number, operation: Record<string, unknown>): ImplementationCandidate[] {
|
|
303
303
|
const rows = implementationCandidates(db, workspaceId, operation);
|
|
304
|
-
|
|
304
|
+
const invalid = rows.filter((row) => !validRegistrationPair(row));
|
|
305
|
+
recordRegistrationInvariantDiagnostics(db, invalid);
|
|
306
|
+
return deduplicateCandidates(
|
|
307
|
+
rows.filter(validRegistrationPair)
|
|
308
|
+
.map((row) => scoreImplementationCandidate(row, operation)),
|
|
309
|
+
).sort((a, b) => b.score - a.score || String(a.className).localeCompare(String(b.className)) || a.methodId - b.methodId);
|
|
310
|
+
}
|
|
311
|
+
function validRegistrationPair(row: Record<string, unknown>): boolean {
|
|
312
|
+
if (row.registrationHandlerClassId === null
|
|
313
|
+
|| row.registrationHandlerClassId === undefined)
|
|
314
|
+
return registrationPairingStrategy(row) !== 'unproven';
|
|
315
|
+
return Number(row.registrationHandlerClassId) === Number(row.classId);
|
|
316
|
+
}
|
|
317
|
+
function registrationPairingStrategy(row: Record<string, unknown>): string {
|
|
318
|
+
if (row.registrationHandlerClassId !== null
|
|
319
|
+
&& row.registrationHandlerClassId !== undefined)
|
|
320
|
+
return 'exact_handler_class_id';
|
|
321
|
+
if (Number(row.applicationRepoId) === Number(row.handlerRepoId))
|
|
322
|
+
return 'same_repository_class_name_fallback';
|
|
323
|
+
const source = stringValue(row.importSource);
|
|
324
|
+
const separator = source?.lastIndexOf('#') ?? -1;
|
|
325
|
+
if (!source || separator <= 0) return 'unproven';
|
|
326
|
+
const moduleName = source.slice(0, separator);
|
|
327
|
+
const importedName = source.slice(separator + 1);
|
|
328
|
+
const matchesClass = importedName === row.className
|
|
329
|
+
|| (importedName === 'default' && row.registrationClassName === row.className);
|
|
330
|
+
return moduleName === row.handlerPackage && matchesClass
|
|
331
|
+
? 'explicit_package_import'
|
|
332
|
+
: 'unproven';
|
|
333
|
+
}
|
|
334
|
+
function recordRegistrationInvariantDiagnostics(
|
|
335
|
+
db: Db,
|
|
336
|
+
rows: Array<Record<string, unknown>>,
|
|
337
|
+
): void {
|
|
338
|
+
const insert = db.prepare(`INSERT INTO diagnostics(repo_id,severity,code,message,source_file,source_line)
|
|
339
|
+
SELECT ?,'error','handler_registration_class_mismatch',
|
|
340
|
+
'Implementation candidate registration did not match its persisted handler class id',?,?
|
|
341
|
+
WHERE NOT EXISTS (
|
|
342
|
+
SELECT 1 FROM diagnostics
|
|
343
|
+
WHERE repo_id=? AND code='handler_registration_class_mismatch'
|
|
344
|
+
AND source_file=? AND source_line=?
|
|
345
|
+
)`);
|
|
346
|
+
for (const row of rows)
|
|
347
|
+
insert.run(
|
|
348
|
+
row.applicationRepoId,
|
|
349
|
+
row.registrationFile,
|
|
350
|
+
row.registrationLine,
|
|
351
|
+
row.applicationRepoId,
|
|
352
|
+
row.registrationFile,
|
|
353
|
+
row.registrationLine,
|
|
354
|
+
);
|
|
305
355
|
}
|
|
306
356
|
|
|
307
357
|
function deduplicateCandidates(rows: ImplementationCandidate[]): ImplementationCandidate[] {
|
|
308
358
|
const merged = new Map<string, ImplementationCandidate>();
|
|
309
359
|
for (const row of rows) {
|
|
310
360
|
const key = [row.methodId, row.classId, row.handlerRepoId].join(':');
|
|
311
|
-
const registration =
|
|
361
|
+
const registration = registrationEvidence(row);
|
|
312
362
|
const existing = merged.get(key);
|
|
313
363
|
if (!existing) {
|
|
314
364
|
merged.set(key, { ...row, registrations: [registration] });
|
|
@@ -322,6 +372,19 @@ function deduplicateCandidates(rows: ImplementationCandidate[]): ImplementationC
|
|
|
322
372
|
}
|
|
323
373
|
return [...merged.values()];
|
|
324
374
|
}
|
|
375
|
+
function registrationEvidence(
|
|
376
|
+
row: Record<string, unknown>,
|
|
377
|
+
): Record<string, unknown> {
|
|
378
|
+
return {
|
|
379
|
+
id: row.registrationId,
|
|
380
|
+
handlerClassId: row.registrationHandlerClassId,
|
|
381
|
+
file: row.registrationFile,
|
|
382
|
+
line: row.registrationLine,
|
|
383
|
+
kind: row.registrationKind,
|
|
384
|
+
importSource: row.importSource,
|
|
385
|
+
pairingStrategy: registrationPairingStrategy(row),
|
|
386
|
+
};
|
|
387
|
+
}
|
|
325
388
|
function uniqueRegistrations(rows: Array<Record<string, unknown>>): Array<Record<string, unknown>> {
|
|
326
389
|
const seen = new Set<string>();
|
|
327
390
|
return rows.filter((row) => {
|
|
@@ -352,10 +415,14 @@ function implementationCandidates(db: Db, workspaceId: number, operation: Record
|
|
|
352
415
|
hm.method_name methodName,
|
|
353
416
|
hm.decorator_value decoratorValue,
|
|
354
417
|
hm.decorator_raw_expression decoratorRawExpression,
|
|
418
|
+
hm.decorator_resolution_json decoratorResolutionJson,
|
|
355
419
|
hc.id classId,
|
|
356
420
|
hc.class_name className,
|
|
357
421
|
hc.source_file sourceFile,
|
|
358
422
|
hc.source_line sourceLine,
|
|
423
|
+
hr.id registrationId,
|
|
424
|
+
hr.handler_class_id registrationHandlerClassId,
|
|
425
|
+
hr.class_name registrationClassName,
|
|
359
426
|
hr.repo_id applicationRepoId,
|
|
360
427
|
hr.registration_file registrationFile,
|
|
361
428
|
hr.registration_line registrationLine,
|
|
@@ -378,14 +445,36 @@ function implementationCandidates(db: Db, workspaceId: number, operation: Record
|
|
|
378
445
|
CASE WHEN appRepo.id=handlerRepo.id THEN 1 ELSE 0 END sameRepoRegistration,
|
|
379
446
|
CASE WHEN EXISTS (SELECT 1 FROM cds_services localService WHERE localService.repo_id=appRepo.id AND localService.service_path=?) THEN 1 ELSE 0 END localServicePathMatch,
|
|
380
447
|
CASE WHEN EXISTS (SELECT 1 FROM cds_services localService WHERE localService.repo_id=appRepo.id) THEN 1 ELSE 0 END applicationHasLocalServices,
|
|
381
|
-
CASE WHEN EXISTS (SELECT 1 FROM handler_registrations localReg JOIN handler_classes localClass ON (localClass.id=localReg.handler_class_id OR localClass.class_name=localReg.class_name) JOIN handler_methods localMethod ON localMethod.handler_class_id=localClass.id WHERE localReg.repo_id=appRepo.id AND (localMethod.decorator_value=? OR localMethod.decorator_value=? OR localMethod.method_name=? OR localMethod.decorator_raw_expression LIKE ?)) THEN 1 ELSE 0 END applicationHasLocalRegistrationForOperation,
|
|
448
|
+
CASE WHEN EXISTS (SELECT 1 FROM handler_registrations localReg JOIN handler_classes localClass ON ((localReg.handler_class_id IS NOT NULL AND localClass.id=localReg.handler_class_id) OR (localReg.handler_class_id IS NULL AND localClass.class_name=localReg.class_name AND localClass.repo_id=localReg.repo_id)) JOIN handler_methods localMethod ON localMethod.handler_class_id=localClass.id WHERE localReg.repo_id=appRepo.id AND (localMethod.decorator_value=? OR localMethod.decorator_value=? OR localMethod.method_name=? OR localMethod.decorator_raw_expression LIKE ?)) THEN 1 ELSE 0 END applicationHasLocalRegistrationForOperation,
|
|
382
449
|
CASE WHEN EXISTS (SELECT 1 FROM graph_edges dep WHERE dep.edge_type='REPO_IMPORTS_HELPER_PACKAGE' AND dep.status='resolved' AND dep.from_kind='repo' AND dep.from_id=CAST(appRepo.id AS TEXT) AND dep.to_id=?) THEN 1 ELSE 0 END appDependsOnModel,
|
|
383
450
|
CASE WHEN EXISTS (SELECT 1 FROM graph_edges dep WHERE dep.edge_type='REPO_IMPORTS_HELPER_PACKAGE' AND dep.status='resolved' AND dep.from_kind='repo' AND dep.from_id=CAST(appRepo.id AS TEXT) AND dep.to_id=CAST(handlerRepo.id AS TEXT)) THEN 1 ELSE 0 END appDependsOnHandler,
|
|
384
451
|
CASE WHEN EXISTS (SELECT 1 FROM graph_edges dep WHERE dep.edge_type='REPO_IMPORTS_HELPER_PACKAGE' AND dep.status='resolved' AND dep.from_kind='repo' AND dep.from_id=CAST(handlerRepo.id AS TEXT) AND dep.to_id=?) THEN 1 ELSE 0 END handlerDependsOnModel
|
|
385
452
|
FROM handler_methods hm
|
|
386
453
|
JOIN handler_classes hc ON hc.id=hm.handler_class_id
|
|
387
454
|
JOIN repositories handlerRepo ON handlerRepo.id=hc.repo_id
|
|
388
|
-
JOIN handler_registrations hr ON (
|
|
455
|
+
JOIN handler_registrations hr ON (
|
|
456
|
+
(hr.handler_class_id IS NOT NULL AND hr.handler_class_id=hc.id)
|
|
457
|
+
OR (
|
|
458
|
+
hr.handler_class_id IS NULL
|
|
459
|
+
AND (
|
|
460
|
+
(hr.class_name=hc.class_name AND hr.repo_id=hc.repo_id)
|
|
461
|
+
OR (
|
|
462
|
+
instr(hr.import_source,'#')>1
|
|
463
|
+
AND substr(hr.import_source,1,instr(hr.import_source,'#')-1)
|
|
464
|
+
=handlerRepo.package_name
|
|
465
|
+
AND (
|
|
466
|
+
substr(hr.import_source,instr(hr.import_source,'#')+1)
|
|
467
|
+
=hc.class_name
|
|
468
|
+
OR (
|
|
469
|
+
substr(hr.import_source,instr(hr.import_source,'#')+1)
|
|
470
|
+
='default'
|
|
471
|
+
AND hr.class_name=hc.class_name
|
|
472
|
+
)
|
|
473
|
+
)
|
|
474
|
+
)
|
|
475
|
+
)
|
|
476
|
+
)
|
|
477
|
+
)
|
|
389
478
|
JOIN repositories appRepo ON appRepo.id=hr.repo_id
|
|
390
479
|
WHERE appRepo.workspace_id=?
|
|
391
480
|
AND (hm.decorator_value=? OR hm.decorator_value=? OR hm.method_name=? OR hm.decorator_raw_expression LIKE ?)`).all(
|
|
@@ -487,8 +576,16 @@ function candidateEvidence(candidate: ImplementationCandidate, rank: number): Re
|
|
|
487
576
|
className: candidate.className,
|
|
488
577
|
sourceFile: candidate.sourceFile,
|
|
489
578
|
sourceLine: candidate.sourceLine,
|
|
490
|
-
|
|
579
|
+
decoratorResolution: objectJson(candidate.decoratorResolutionJson),
|
|
580
|
+
registration: registrationEvidence(candidate),
|
|
491
581
|
registrations: candidate.registrations ?? [],
|
|
582
|
+
registrationPairing: {
|
|
583
|
+
strategy: registrationPairingStrategy(candidate),
|
|
584
|
+
registrationId: candidate.registrationId,
|
|
585
|
+
registrationHandlerClassId: candidate.registrationHandlerClassId,
|
|
586
|
+
candidateHandlerClassId: candidate.classId,
|
|
587
|
+
invariantStatus: validRegistrationPair(candidate) ? 'valid' : 'invalid',
|
|
588
|
+
},
|
|
492
589
|
applicationPackage: { id: candidate.applicationRepoId, name: candidate.applicationRepo, packageName: candidate.applicationPackage },
|
|
493
590
|
handlerPackage: { id: candidate.handlerRepoId, name: candidate.handlerRepo, packageName: candidate.handlerPackage },
|
|
494
591
|
modelPackage: { id: candidate.modelRepoId, name: candidate.modelRepo, packageName: candidate.modelPackage },
|
|
@@ -12,7 +12,7 @@ export function normalizedOperationName(value: string): string {
|
|
|
12
12
|
function clean(value: string): string {
|
|
13
13
|
return value.replace(/^[`'"]|[`'"]$/g, '');
|
|
14
14
|
}
|
|
15
|
-
function
|
|
15
|
+
export function generatedOperationNameFromConstant(value: string): string | undefined {
|
|
16
16
|
for (const prefix of ['Action', 'Func']) {
|
|
17
17
|
if (value.startsWith(prefix) && value.length > prefix.length && /^[A-Z]/.test(value.slice(prefix.length))) return lowerFirst(value.slice(prefix.length));
|
|
18
18
|
}
|
|
@@ -20,7 +20,7 @@ function generatedFromConstantName(value: string): string | undefined {
|
|
|
20
20
|
}
|
|
21
21
|
function resolved(value: string, raw?: string): DecoratorOperationSignal {
|
|
22
22
|
const literal = clean(value);
|
|
23
|
-
const generated =
|
|
23
|
+
const generated = generatedOperationNameFromConstant(literal);
|
|
24
24
|
return { status: 'resolved', operationName: generated ?? normalizedOperationName(literal), raw };
|
|
25
25
|
}
|
|
26
26
|
export function normalizeDecoratorOperationSignal(value: string | undefined, raw: string | undefined, candidateOperation?: string): DecoratorOperationSignal {
|
|
@@ -32,7 +32,7 @@ export function normalizeDecoratorOperationSignal(value: string | undefined, raw
|
|
|
32
32
|
const stringMatch = /^String\(([A-Za-z_$][\w$]*)\)$/.exec(expression);
|
|
33
33
|
if (stringMatch?.[1]) {
|
|
34
34
|
const identifier = stringMatch[1];
|
|
35
|
-
const generated =
|
|
35
|
+
const generated = generatedOperationNameFromConstant(identifier);
|
|
36
36
|
const normalizedCandidate = candidateOperation ? normalizedOperationName(candidateOperation) : undefined;
|
|
37
37
|
if (generated) return { status: 'resolved', operationName: generated, raw: expression };
|
|
38
38
|
if (normalizedCandidate && identifier === normalizedCandidate) return { status: 'resolved', operationName: identifier, raw: expression };
|
|
@@ -1,9 +1,18 @@
|
|
|
1
1
|
import fs from 'node:fs/promises';
|
|
2
2
|
import path from 'node:path';
|
|
3
3
|
import ts from 'typescript';
|
|
4
|
-
import type { HandlerClassFact } from '../types.js';
|
|
4
|
+
import type { HandlerClassFact, HandlerMethodFact } from '../types.js';
|
|
5
|
+
import { generatedOperationNameFromConstant } from '../linker/operation-decorator-normalizer.js';
|
|
5
6
|
import { createSourceFile } from './ts-project.js';
|
|
6
|
-
import { normalizePath
|
|
7
|
+
import { normalizePath } from '../utils/path-utils.js';
|
|
8
|
+
|
|
9
|
+
type DecoratorResolution = HandlerMethodFact['decoratorResolution'];
|
|
10
|
+
interface StringLookups {
|
|
11
|
+
identifiers: Map<string, string>;
|
|
12
|
+
enumMembers: Map<string, string>;
|
|
13
|
+
objectProperties: Map<string, string>;
|
|
14
|
+
}
|
|
15
|
+
|
|
7
16
|
function line(sf: ts.SourceFile, pos: number): number {
|
|
8
17
|
return sf.getLineAndCharacterOfPosition(pos).line + 1;
|
|
9
18
|
}
|
|
@@ -14,11 +23,119 @@ function callName(d: ts.Decorator): string {
|
|
|
14
23
|
const e = d.expression;
|
|
15
24
|
return ts.isCallExpression(e) ? e.expression.getText() : e.getText();
|
|
16
25
|
}
|
|
17
|
-
function firstArg(d: ts.Decorator):
|
|
26
|
+
function firstArg(d: ts.Decorator): ts.Expression | undefined {
|
|
18
27
|
const e = d.expression;
|
|
19
|
-
return ts.isCallExpression(e)
|
|
20
|
-
|
|
21
|
-
|
|
28
|
+
return ts.isCallExpression(e) ? e.arguments[0] : undefined;
|
|
29
|
+
}
|
|
30
|
+
function unwrapExpression(expression: ts.Expression): ts.Expression {
|
|
31
|
+
let current = expression;
|
|
32
|
+
while (
|
|
33
|
+
ts.isParenthesizedExpression(current)
|
|
34
|
+
|| ts.isAsExpression(current)
|
|
35
|
+
|| ts.isTypeAssertionExpression(current)
|
|
36
|
+
|| ts.isSatisfiesExpression(current)
|
|
37
|
+
) current = current.expression;
|
|
38
|
+
return current;
|
|
39
|
+
}
|
|
40
|
+
function stringValue(expression: ts.Expression | undefined): string | undefined {
|
|
41
|
+
if (!expression) return undefined;
|
|
42
|
+
const unwrapped = unwrapExpression(expression);
|
|
43
|
+
return ts.isStringLiteralLike(unwrapped) ? unwrapped.text : undefined;
|
|
44
|
+
}
|
|
45
|
+
function propertyName(node: ts.PropertyName): string | undefined {
|
|
46
|
+
if (ts.isIdentifier(node) || ts.isStringLiteralLike(node)) return node.text;
|
|
47
|
+
return undefined;
|
|
48
|
+
}
|
|
49
|
+
function collectEnumMembers(
|
|
50
|
+
statement: ts.EnumDeclaration,
|
|
51
|
+
lookups: StringLookups,
|
|
52
|
+
): void {
|
|
53
|
+
for (const member of statement.members) {
|
|
54
|
+
const name = propertyName(member.name);
|
|
55
|
+
const value = stringValue(member.initializer);
|
|
56
|
+
if (name && value !== undefined)
|
|
57
|
+
lookups.enumMembers.set(`${statement.name.text}.${name}`, value);
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
function collectObjectProperties(
|
|
61
|
+
name: string,
|
|
62
|
+
initializer: ts.Expression,
|
|
63
|
+
lookups: StringLookups,
|
|
64
|
+
): void {
|
|
65
|
+
const object = unwrapExpression(initializer);
|
|
66
|
+
if (!ts.isObjectLiteralExpression(object)) return;
|
|
67
|
+
for (const property of object.properties) {
|
|
68
|
+
if (!ts.isPropertyAssignment(property)) continue;
|
|
69
|
+
const key = propertyName(property.name);
|
|
70
|
+
const value = stringValue(property.initializer);
|
|
71
|
+
if (key && value !== undefined)
|
|
72
|
+
lookups.objectProperties.set(`${name}.${key}`, value);
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
function collectStringLookups(source: ts.SourceFile): StringLookups {
|
|
76
|
+
const lookups: StringLookups = {
|
|
77
|
+
identifiers: new Map(),
|
|
78
|
+
enumMembers: new Map(),
|
|
79
|
+
objectProperties: new Map(),
|
|
80
|
+
};
|
|
81
|
+
for (const statement of source.statements) {
|
|
82
|
+
if (ts.isEnumDeclaration(statement)) collectEnumMembers(statement, lookups);
|
|
83
|
+
if (!ts.isVariableStatement(statement)
|
|
84
|
+
|| !(statement.declarationList.flags & ts.NodeFlags.Const)) continue;
|
|
85
|
+
for (const declaration of statement.declarationList.declarations) {
|
|
86
|
+
if (!ts.isIdentifier(declaration.name) || !declaration.initializer) continue;
|
|
87
|
+
const value = stringValue(declaration.initializer);
|
|
88
|
+
if (value !== undefined) lookups.identifiers.set(declaration.name.text, value);
|
|
89
|
+
collectObjectProperties(declaration.name.text, declaration.initializer, lookups);
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
return lookups;
|
|
93
|
+
}
|
|
94
|
+
function unresolved(rawExpression: string, reason: string): DecoratorResolution {
|
|
95
|
+
return { rawExpression, resolutionKind: 'unresolved', unresolvedReason: reason };
|
|
96
|
+
}
|
|
97
|
+
function generatedConstant(rawExpression: string): string | undefined {
|
|
98
|
+
const match = /(?:^|\.)(Action[A-Z][\w$]*|Func[A-Z][\w$]*)\.name$/
|
|
99
|
+
.exec(rawExpression.trim());
|
|
100
|
+
return match?.[1]
|
|
101
|
+
? generatedOperationNameFromConstant(match[1])
|
|
102
|
+
: undefined;
|
|
103
|
+
}
|
|
104
|
+
function resolveDecoratorArgument(
|
|
105
|
+
argument: ts.Expression | undefined,
|
|
106
|
+
lookups: StringLookups,
|
|
107
|
+
): DecoratorResolution {
|
|
108
|
+
if (!argument) return unresolved('', 'decorator_argument_missing');
|
|
109
|
+
const rawExpression = argument.getText();
|
|
110
|
+
const expression = unwrapExpression(argument);
|
|
111
|
+
if (ts.isStringLiteralLike(expression))
|
|
112
|
+
return { rawExpression, resolvedValue: expression.text, resolutionKind: 'literal' };
|
|
113
|
+
if (ts.isIdentifier(expression)) {
|
|
114
|
+
const value = lookups.identifiers.get(expression.text);
|
|
115
|
+
return value === undefined
|
|
116
|
+
? unresolved(rawExpression, 'identifier_not_resolved_to_local_const_string')
|
|
117
|
+
: { rawExpression, resolvedValue: value, resolutionKind: 'const_identifier' };
|
|
118
|
+
}
|
|
119
|
+
if (ts.isPropertyAccessExpression(expression)
|
|
120
|
+
&& ts.isIdentifier(expression.expression)) {
|
|
121
|
+
const key = `${expression.expression.text}.${expression.name.text}`;
|
|
122
|
+
const enumValue = lookups.enumMembers.get(key);
|
|
123
|
+
if (enumValue !== undefined)
|
|
124
|
+
return { rawExpression, resolvedValue: enumValue, resolutionKind: 'enum_member' };
|
|
125
|
+
const objectValue = lookups.objectProperties.get(key);
|
|
126
|
+
if (objectValue !== undefined)
|
|
127
|
+
return { rawExpression, resolvedValue: objectValue, resolutionKind: 'const_object_property' };
|
|
128
|
+
}
|
|
129
|
+
const generatedValue = generatedConstant(rawExpression);
|
|
130
|
+
if (generatedValue !== undefined)
|
|
131
|
+
return {
|
|
132
|
+
rawExpression,
|
|
133
|
+
resolvedValue: generatedValue,
|
|
134
|
+
resolutionKind: 'generated_constant_name',
|
|
135
|
+
};
|
|
136
|
+
if (ts.isPropertyAccessExpression(expression))
|
|
137
|
+
return unresolved(rawExpression, 'property_access_not_resolved_to_local_string');
|
|
138
|
+
return unresolved(rawExpression, 'unsupported_decorator_expression');
|
|
22
139
|
}
|
|
23
140
|
export async function parseDecorators(
|
|
24
141
|
repoPath: string,
|
|
@@ -26,16 +143,9 @@ export async function parseDecorators(
|
|
|
26
143
|
): Promise<HandlerClassFact[]> {
|
|
27
144
|
const text = await fs.readFile(path.join(repoPath, filePath), 'utf8');
|
|
28
145
|
const sf = createSourceFile(filePath, text);
|
|
29
|
-
const
|
|
146
|
+
const lookups = collectStringLookups(sf);
|
|
30
147
|
const handlers: HandlerClassFact[] = [];
|
|
31
148
|
function visit(node: ts.Node): void {
|
|
32
|
-
if (
|
|
33
|
-
ts.isVariableDeclaration(node) &&
|
|
34
|
-
ts.isIdentifier(node.name) &&
|
|
35
|
-
node.initializer &&
|
|
36
|
-
ts.isStringLiteralLike(node.initializer)
|
|
37
|
-
)
|
|
38
|
-
constants.set(node.name.text, node.initializer.text);
|
|
39
149
|
if (ts.isClassDeclaration(node)) {
|
|
40
150
|
const className = node.name?.text ?? 'AnonymousHandler';
|
|
41
151
|
const hasHandler = decs(node).some((d) => callName(d) === 'Handler');
|
|
@@ -45,17 +155,13 @@ export async function parseDecorators(
|
|
|
45
155
|
['Func', 'Action', 'On', 'Event'].includes(callName(d))
|
|
46
156
|
)
|
|
47
157
|
.map((d) => {
|
|
48
|
-
const
|
|
49
|
-
const value =
|
|
50
|
-
raw.startsWith('"') || raw.startsWith("'") || raw.startsWith('`')
|
|
51
|
-
? stripQuotes(raw)
|
|
52
|
-
: (constants.get(raw) ??
|
|
53
|
-
(raw.endsWith('.name') ? raw.split('.').at(-2) : undefined));
|
|
158
|
+
const decoratorResolution = resolveDecoratorArgument(firstArg(d), lookups);
|
|
54
159
|
return {
|
|
55
160
|
methodName: m.name.getText(),
|
|
56
161
|
decoratorKind: callName(d),
|
|
57
|
-
decoratorValue:
|
|
58
|
-
decoratorRawExpression:
|
|
162
|
+
decoratorValue: decoratorResolution.resolvedValue,
|
|
163
|
+
decoratorRawExpression: decoratorResolution.rawExpression,
|
|
164
|
+
decoratorResolution,
|
|
59
165
|
sourceFile: normalizePath(filePath),
|
|
60
166
|
sourceLine: line(sf, m.getStart())
|
|
61
167
|
};
|
package/src/trace/selectors.ts
CHANGED
|
@@ -18,3 +18,39 @@ export function startLabel(start: TraceStart): string {
|
|
|
18
18
|
.filter(Boolean)
|
|
19
19
|
.join(' ');
|
|
20
20
|
}
|
|
21
|
+
export function ambiguousStartDiagnostic(
|
|
22
|
+
requested: string,
|
|
23
|
+
candidates: Array<Record<string, unknown>>,
|
|
24
|
+
message: string,
|
|
25
|
+
): Record<string, unknown> {
|
|
26
|
+
const serviceSuggestions = [...new Set(candidates
|
|
27
|
+
.flatMap((row) => typeof row.servicePath === 'string'
|
|
28
|
+
? [`--service ${row.servicePath}`]
|
|
29
|
+
: []))].sort();
|
|
30
|
+
return {
|
|
31
|
+
severity: 'warning',
|
|
32
|
+
code: 'trace_start_ambiguous',
|
|
33
|
+
message,
|
|
34
|
+
normalizedSelectorValue: requested,
|
|
35
|
+
resolutionStage: 'operation',
|
|
36
|
+
resolutionStatus: 'ambiguous_operation',
|
|
37
|
+
candidates,
|
|
38
|
+
serviceSuggestions,
|
|
39
|
+
selectorSuggestions: fullSelectorSuggestions(candidates),
|
|
40
|
+
};
|
|
41
|
+
}
|
|
42
|
+
function fullSelectorSuggestions(
|
|
43
|
+
candidates: Array<Record<string, unknown>>,
|
|
44
|
+
): string[] {
|
|
45
|
+
const includeRepo = new Set(candidates.map((row) => row.repoName)).size > 1;
|
|
46
|
+
return [...new Set(candidates.flatMap((row) => {
|
|
47
|
+
if (typeof row.servicePath !== 'string'
|
|
48
|
+
|| typeof row.operationPath !== 'string') return [];
|
|
49
|
+
const repoSelector = includeRepo && typeof row.repoName === 'string'
|
|
50
|
+
? `--repo ${row.repoName} `
|
|
51
|
+
: '';
|
|
52
|
+
return [
|
|
53
|
+
`${repoSelector}--service ${row.servicePath} --path ${row.operationPath}`,
|
|
54
|
+
];
|
|
55
|
+
}))].sort();
|
|
56
|
+
}
|
|
@@ -5,6 +5,7 @@ import { resolveOperation } from '../linker/service-resolver.js';
|
|
|
5
5
|
import type { ImplementationHint, TraceEdge, TraceResult, TraceStart } from '../types.js';
|
|
6
6
|
import { baseTraceEvidence, edgeTarget, runtimeResolution, runtimeVariableDiagnostic, type TraceGraphRow } from './evidence.js';
|
|
7
7
|
import { implementationHintDiagnostic, selectImplementation, type ImplementationSelection } from './implementation-hints.js';
|
|
8
|
+
import { ambiguousStartDiagnostic } from './selectors.js';
|
|
8
9
|
interface RepoRef {
|
|
9
10
|
id: number;
|
|
10
11
|
name: string;
|
|
@@ -120,26 +121,6 @@ function implementationRejectionReasons(evidence: Record<string, unknown>): stri
|
|
|
120
121
|
: []);
|
|
121
122
|
return [...new Set(reasons)].sort();
|
|
122
123
|
}
|
|
123
|
-
function ambiguousStartDiagnostic(
|
|
124
|
-
requested: string,
|
|
125
|
-
candidates: Array<Record<string, unknown>>,
|
|
126
|
-
message: string,
|
|
127
|
-
): Record<string, unknown> {
|
|
128
|
-
const serviceSuggestions = [...new Set(candidates
|
|
129
|
-
.flatMap((row) => typeof row.servicePath === 'string'
|
|
130
|
-
? [`--service ${row.servicePath}`]
|
|
131
|
-
: []))].sort();
|
|
132
|
-
return {
|
|
133
|
-
severity: 'warning',
|
|
134
|
-
code: 'trace_start_ambiguous',
|
|
135
|
-
message,
|
|
136
|
-
normalizedSelectorValue: requested,
|
|
137
|
-
resolutionStage: 'operation',
|
|
138
|
-
resolutionStatus: 'ambiguous_operation',
|
|
139
|
-
candidates,
|
|
140
|
-
serviceSuggestions,
|
|
141
|
-
};
|
|
142
|
-
}
|
|
143
124
|
function sourceFilesForStart(
|
|
144
125
|
db: Db,
|
|
145
126
|
repoId: number | undefined,
|
package/src/types.ts
CHANGED
|
@@ -101,6 +101,18 @@ export interface HandlerMethodFact {
|
|
|
101
101
|
decoratorKind: string;
|
|
102
102
|
decoratorValue?: string;
|
|
103
103
|
decoratorRawExpression: string;
|
|
104
|
+
decoratorResolution: {
|
|
105
|
+
rawExpression: string;
|
|
106
|
+
resolvedValue?: string;
|
|
107
|
+
resolutionKind:
|
|
108
|
+
| 'literal'
|
|
109
|
+
| 'const_identifier'
|
|
110
|
+
| 'enum_member'
|
|
111
|
+
| 'const_object_property'
|
|
112
|
+
| 'generated_constant_name'
|
|
113
|
+
| 'unresolved';
|
|
114
|
+
unresolvedReason?: string;
|
|
115
|
+
};
|
|
104
116
|
sourceFile: string;
|
|
105
117
|
sourceLine: number;
|
|
106
118
|
}
|