@saptools/service-flow 0.1.51 → 0.1.53
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +13 -0
- package/README.md +16 -14
- package/TECHNICAL-NOTE.md +18 -6
- package/dist/{chunk-YZJKE5UX.js → chunk-LFH7C46B.js} +4290 -1261
- package/dist/chunk-LFH7C46B.js.map +1 -0
- package/dist/cli.js +435 -515
- package/dist/cli.js.map +1 -1
- package/dist/index.d.ts +45 -7
- package/dist/index.js +1 -1
- package/package.json +1 -1
- package/src/cli/000-clean.ts +82 -0
- package/src/cli/001-doctor-projection.ts +140 -0
- package/src/cli/doctor.ts +20 -3
- package/src/cli.ts +75 -57
- package/src/db/connection.ts +1 -1
- package/src/db/migrations.ts +5 -3
- package/src/db/repositories.ts +130 -24
- package/src/db/schema.ts +7 -2
- package/src/indexer/repository-indexer.ts +57 -29
- package/src/indexer/workspace-indexer.ts +84 -6
- package/src/linker/000-implementation-candidates.ts +641 -0
- package/src/linker/001-implementation-evidence-projection.ts +119 -0
- package/src/linker/002-call-evidence.ts +226 -0
- package/src/linker/cross-repo-linker.ts +27 -441
- package/src/linker/dynamic-edge-resolver.ts +35 -0
- package/src/linker/external-http-target.ts +24 -3
- package/src/linker/helper-package-linker.ts +18 -2
- package/src/linker/service-resolver.ts +45 -2
- package/src/output/doctor-output.ts +13 -4
- package/src/output/table-output.ts +33 -5
- package/src/parsers/cds-parser.ts +8 -2
- package/src/parsers/decorator-parser.ts +382 -48
- package/src/parsers/handler-registration-parser.ts +38 -21
- package/src/parsers/imported-wrapper-parser.ts +18 -5
- package/src/parsers/outbound-call-parser.ts +25 -8
- package/src/parsers/package-json-parser.ts +36 -11
- package/src/parsers/service-binding-parser-helpers.ts +8 -1
- package/src/parsers/service-binding-parser.ts +6 -3
- package/src/parsers/symbol-parser.ts +13 -3
- package/src/parsers/ts-project.ts +54 -0
- package/src/trace/000-dynamic-target-types.ts +96 -0
- package/src/trace/001-dynamic-identity.ts +308 -0
- package/src/trace/002-trace-diagnostics.ts +54 -0
- package/src/trace/003-dynamic-references.ts +283 -0
- package/src/trace/004-dynamic-candidate-sources.ts +155 -0
- package/src/trace/005-implementation-selection.ts +187 -0
- package/src/trace/006-contextual-projection.ts +30 -0
- package/src/trace/007-implementation-start-diagnostic.ts +61 -0
- package/src/trace/dynamic-targets.ts +582 -306
- package/src/trace/evidence.ts +331 -46
- package/src/trace/implementation-hints.ts +148 -8
- package/src/trace/selectors.ts +551 -3
- package/src/trace/trace-engine.ts +129 -135
- package/src/types.ts +27 -1
- package/src/utils/000-bounded-projection.ts +161 -0
- package/dist/chunk-YZJKE5UX.js.map +0 -1
|
@@ -1,12 +1,17 @@
|
|
|
1
1
|
import type { Db } from '../db/connection.js';
|
|
2
2
|
import { applyVariables } from './dynamic-edge-resolver.js';
|
|
3
|
-
import { classifyODataPathIntent, normalizeODataOperationInvocationPath
|
|
3
|
+
import { classifyODataPathIntent, normalizeODataOperationInvocationPath } from './odata-path-normalizer.js';
|
|
4
4
|
import { buildRemoteQueryTarget } from './remote-query-target.js';
|
|
5
5
|
import { resolveOperation } from './service-resolver.js';
|
|
6
6
|
import { linkHelperPackages } from './helper-package-linker.js';
|
|
7
|
-
import { normalizeDecoratorOperationSignal, normalizedOperationName } from './operation-decorator-normalizer.js';
|
|
8
7
|
import { externalHttpTarget } from './external-http-target.js';
|
|
9
|
-
import {
|
|
8
|
+
import { linkImplementations as linkCanonicalImplementations } from './000-implementation-candidates.js';
|
|
9
|
+
import {
|
|
10
|
+
ambiguousPathCandidates,
|
|
11
|
+
linkedCallEvidence,
|
|
12
|
+
objectJson,
|
|
13
|
+
objectValue,
|
|
14
|
+
} from './002-call-evidence.js';
|
|
10
15
|
export interface LinkWorkspaceResult {
|
|
11
16
|
edgeCount: number;
|
|
12
17
|
unresolvedCount: number;
|
|
@@ -27,7 +32,7 @@ export function linkWorkspace(db: Db, workspaceId: number, vars: Record<string,
|
|
|
27
32
|
const generation = nextGraphGeneration(db, workspaceId);
|
|
28
33
|
db.prepare('DELETE FROM graph_edges WHERE workspace_id=?').run(workspaceId);
|
|
29
34
|
const deps = linkHelperPackages(db, workspaceId, generation);
|
|
30
|
-
const impl =
|
|
35
|
+
const impl = linkCanonicalImplementations(db, workspaceId, generation);
|
|
31
36
|
const callSummary = linkCalls(db, workspaceId, vars, generation);
|
|
32
37
|
db.prepare("UPDATE repositories SET graph_generation=?, graph_stale_reason=NULL, graph_stale_at=NULL WHERE workspace_id=?").run(generation, workspaceId);
|
|
33
38
|
return { ...callSummary, edgeCount: deps.edgeCount + callSummary.edgeCount + impl.edgeCount, dependencyResolvedCount: deps.resolvedCount, dependencyAmbiguousCount: deps.ambiguousCount, implementationResolvedCount: impl.resolvedCount, implementationAmbiguousCount: impl.ambiguousCount, implementationUnresolvedCount: impl.unresolvedCount };
|
|
@@ -46,7 +51,7 @@ function linkCalls(db: Db, workspaceId: number, vars: Record<string, string>, ge
|
|
|
46
51
|
let ambiguousCount = 0;
|
|
47
52
|
let dynamicCount = 0;
|
|
48
53
|
let terminalCount = 0;
|
|
49
|
-
const calls = db.prepare(`SELECT c.*,r.name repoName,b.alias,b.alias_expr aliasExpr,b.destination_expr destinationExpr,b.service_path_expr servicePathExpr,b.is_dynamic isDynamic,b.placeholders_json placeholdersJson,b.helper_chain_json helperChainJson,req.service_path requireServicePath,req.destination requireDestination FROM outbound_calls c JOIN repositories r ON r.id=c.repo_id LEFT JOIN service_bindings b ON b.id=c.service_binding_id LEFT JOIN cds_requires req ON req.repo_id=c.repo_id AND req.alias=b.alias WHERE r.workspace_id=?`).all(workspaceId) as Array<Record<string, unknown>>;
|
|
54
|
+
const calls = db.prepare(`SELECT c.*,r.name repoName,b.id selectedBindingId,b.alias,b.alias_expr aliasExpr,b.destination_expr destinationExpr,b.service_path_expr servicePathExpr,b.is_dynamic isDynamic,b.placeholders_json placeholdersJson,b.source_file bindingSourceFile,b.source_line bindingSourceLine,b.helper_chain_json helperChainJson,req.service_path requireServicePath,req.destination requireDestination FROM outbound_calls c JOIN repositories r ON r.id=c.repo_id LEFT JOIN service_bindings b ON b.id=c.service_binding_id LEFT JOIN cds_requires req ON req.repo_id=c.repo_id AND req.alias=b.alias WHERE r.workspace_id=?`).all(workspaceId) as Array<Record<string, unknown>>;
|
|
50
55
|
for (const call of calls) {
|
|
51
56
|
const result = insertCallEdge(db, workspaceId, call, vars, generation);
|
|
52
57
|
edgeCount += 1;
|
|
@@ -79,7 +84,15 @@ function insertCallEdge(db: Db, workspaceId: number, call: Record<string, unknow
|
|
|
79
84
|
const isOperationCall = operationLikeRemoteEntity || ((callType === 'remote_action' || callType === 'local_service_call') || (callType === 'remote_query' && Boolean(op)));
|
|
80
85
|
const resolution = isOperationCall ? resolveOperation(db, { servicePath, operationPath: op, serviceName: call.local_service_name as string | undefined, repoId: callType === 'local_service_call' ? Number(call.repo_id) : undefined, alias: applyVariables((call.aliasExpr as string | undefined) ?? (call.alias as string | undefined), vars), destination: destination ? applyVariables(destination, vars) : undefined, isDynamic, hasExplicitOverride: Object.keys(vars).length > 0 || callType === 'local_service_call' }, workspaceId) : { status: 'unresolved' as const, candidates: [], reasons: [] };
|
|
81
86
|
const evidence: Record<string, unknown> = {
|
|
82
|
-
...
|
|
87
|
+
...linkedCallEvidence(
|
|
88
|
+
call,
|
|
89
|
+
resolution,
|
|
90
|
+
servicePath,
|
|
91
|
+
op,
|
|
92
|
+
destination ? applyVariables(destination, vars) : undefined,
|
|
93
|
+
normalized,
|
|
94
|
+
intent,
|
|
95
|
+
),
|
|
83
96
|
indexedOperationCandidateCount,
|
|
84
97
|
parserCallType: callType,
|
|
85
98
|
entityOperationPrecedence: operationPrecedence(
|
|
@@ -91,9 +104,7 @@ function insertCallEdge(db: Db, workspaceId: number, call: Record<string, unknow
|
|
|
91
104
|
};
|
|
92
105
|
const pathAnalysis = objectValue(objectJson(call.evidence_json)?.pathAnalysis);
|
|
93
106
|
if (callType === 'remote_action' && pathAnalysis?.status === 'ambiguous') {
|
|
94
|
-
const candidatePaths =
|
|
95
|
-
? pathAnalysis.candidateRawPaths
|
|
96
|
-
: [];
|
|
107
|
+
const candidatePaths = ambiguousPathCandidates(pathAnalysis);
|
|
97
108
|
db.prepare('INSERT INTO graph_edges(workspace_id,edge_type,status,from_kind,from_id,to_kind,to_id,confidence,evidence_json,is_dynamic,unresolved_reason,generation) VALUES(?,?,?,?,?,?,?,?,?,?,?,?)').run(
|
|
98
109
|
workspaceId,
|
|
99
110
|
'UNRESOLVED_EDGE',
|
|
@@ -101,9 +112,14 @@ function insertCallEdge(db: Db, workspaceId: number, call: Record<string, unknow
|
|
|
101
112
|
'call',
|
|
102
113
|
String(call.id),
|
|
103
114
|
'operation_candidates',
|
|
104
|
-
candidatePaths.join(','),
|
|
115
|
+
candidatePaths.items.join(','),
|
|
105
116
|
Number(call.confidence ?? 0.5),
|
|
106
|
-
JSON.stringify(
|
|
117
|
+
JSON.stringify({
|
|
118
|
+
...evidence,
|
|
119
|
+
ambiguousOperationPathCandidateCount: candidatePaths.totalCount,
|
|
120
|
+
shownAmbiguousOperationPathCandidateCount: candidatePaths.shownCount,
|
|
121
|
+
omittedAmbiguousOperationPathCandidateCount: candidatePaths.omittedCount,
|
|
122
|
+
}),
|
|
107
123
|
0,
|
|
108
124
|
'Ambiguous operation path candidates require explicit disambiguation',
|
|
109
125
|
generation,
|
|
@@ -202,433 +218,3 @@ function unresolvedOperationReason(resolution: { candidates: unknown[]; status:
|
|
|
202
218
|
if (resolution.status === 'ambiguous') return 'Ambiguous operation candidates require a strong service signal';
|
|
203
219
|
return 'Operation candidates found but resolution could not select a target';
|
|
204
220
|
}
|
|
205
|
-
function parseJson(value: unknown): unknown {
|
|
206
|
-
if (!value) return undefined;
|
|
207
|
-
try {
|
|
208
|
-
return JSON.parse(String(value)) as unknown;
|
|
209
|
-
} catch {
|
|
210
|
-
return undefined;
|
|
211
|
-
}
|
|
212
|
-
}
|
|
213
|
-
function objectJson(value: unknown): Record<string, unknown> | undefined {
|
|
214
|
-
const parsed = parseJson(value);
|
|
215
|
-
return parsed && typeof parsed === 'object' && !Array.isArray(parsed) ? parsed as Record<string, unknown> : undefined;
|
|
216
|
-
}
|
|
217
|
-
function callEvidence(call: Record<string, unknown>, resolution: { target?: { repoName?: string; servicePath?: string; operationPath?: string; operationName?: string }; candidates: unknown[]; status: string; reasons: string[] }, servicePath: string | undefined, op: string | undefined, destination: string | undefined, normalized?: NormalizedODataOperationPath, odataPathIntent?: ReturnType<typeof classifyODataPathIntent>): Record<string, unknown> {
|
|
218
|
-
const bindingHasDynamicExpression = Boolean(Number(call.isDynamic ?? 0));
|
|
219
|
-
const routingPlaceholderKeys = placeholderKeys([servicePath, destination, stringValue(call.aliasExpr), stringValue(call.alias)]);
|
|
220
|
-
return { sourceFile: call.source_file, sourceLine: call.source_line, file: call.source_file, line: call.source_line, callId: call.id, repo: call.repoName, serviceAlias: call.alias, serviceAliasExpr: call.aliasExpr, destination, servicePath, operationPath: op, rawOperationPath: normalized?.wasInvocation ? normalized.rawOperationPath : odataPathIntent?.rawPath, normalizedOperationPath: normalized?.wasInvocation ? normalized.normalizedOperationPath : undefined, invocationArguments: normalized?.wasInvocation ? normalized.invocationArguments : undefined, invocationArgumentPlaceholderKeys: normalized?.invocationArgumentPlaceholderKeys.length ? normalized.invocationArgumentPlaceholderKeys : undefined, routingPlaceholderKeys: routingPlaceholderKeys.length ? routingPlaceholderKeys : undefined, odataOperationNormalizationReason: normalized?.normalizationReason, odataOperationNormalizationRejectedReason: normalized?.normalizationRejectedReason, localServiceName: call.local_service_name, localServiceLookup: call.local_service_lookup, aliasChain: parseJson(call.alias_chain_json), transport: call.call_type === 'local_service_call' ? 'local' : undefined, targetRepo: resolution.target?.repoName, targetServicePath: resolution.target?.servicePath, targetOperationPath: resolution.target?.operationPath, targetOperation: resolution.target?.operationName, helperChain: parseJson(call.helperChainJson), candidates: resolution.candidates, candidateScores: compactCandidateScores(resolution.candidates), candidateCount: resolution.candidates.length, resolutionStatus: resolution.status, resolutionReasons: resolution.reasons, odataPathIntent, queryStringPresent: odataPathIntent?.hasQueryString || undefined, queryPlaceholderKeys: odataPathIntent?.placeholderKeys.length ? odataPathIntent.placeholderKeys : undefined, bindingHasDynamicExpression: bindingHasDynamicExpression || undefined, outboundEvidence: objectJson(call.evidence_json), analysisCompleteness: call.unresolved_reason ? 'partial' : 'complete', parserWarning: call.unresolved_reason ? { code: 'parser_warning', message: call.unresolved_reason } : undefined };
|
|
221
|
-
}
|
|
222
|
-
|
|
223
|
-
function compactCandidateScores(candidates: unknown[]): Array<Record<string, unknown>> {
|
|
224
|
-
return candidates.flatMap((candidate): Array<Record<string, unknown>> => {
|
|
225
|
-
const row = objectValue(candidate);
|
|
226
|
-
if (!row) return [];
|
|
227
|
-
return [{
|
|
228
|
-
repo: row.repoName,
|
|
229
|
-
servicePath: row.servicePath,
|
|
230
|
-
operationPath: row.operationPath,
|
|
231
|
-
score: row.score,
|
|
232
|
-
reasons: Array.isArray(row.reasons)
|
|
233
|
-
? row.reasons.filter((reason): reason is string => typeof reason === 'string')
|
|
234
|
-
: ['operation_path_match'],
|
|
235
|
-
}];
|
|
236
|
-
});
|
|
237
|
-
}
|
|
238
|
-
|
|
239
|
-
function placeholderKeys(values: Array<string | undefined>): string[] {
|
|
240
|
-
const keys = values.flatMap((value) => [...(value ?? '').matchAll(/\$\{([^}]*)\}/g)].map((match) => (match[1] ?? '').trim()).filter(Boolean));
|
|
241
|
-
return [...new Set(keys)].sort();
|
|
242
|
-
}
|
|
243
|
-
|
|
244
|
-
function objectValue(value: unknown): Record<string, unknown> | undefined {
|
|
245
|
-
return value && typeof value === 'object' && !Array.isArray(value) ? value as Record<string, unknown> : undefined;
|
|
246
|
-
}
|
|
247
|
-
|
|
248
|
-
function stringValue(value: unknown): string | undefined {
|
|
249
|
-
return typeof value === 'string' ? value : undefined;
|
|
250
|
-
}
|
|
251
|
-
|
|
252
|
-
function linkImplementations(db: Db, workspaceId: number, generation: number): { edgeCount: number; resolvedCount: number; ambiguousCount: number; unresolvedCount: number } {
|
|
253
|
-
const operations = db.prepare(`SELECT o.id operationId,o.operation_path operationPath,o.operation_name operationName,o.provenance provenance,o.base_operation_id baseOperationId,s.service_path servicePath,s.repo_id modelRepoId,r.name modelRepo,r.package_name modelPackage,r.kind modelKind 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=?`).all(workspaceId) as Array<Record<string, unknown>>;
|
|
254
|
-
let edgeCount = 0;
|
|
255
|
-
let resolvedCount = 0;
|
|
256
|
-
let ambiguousCount = 0;
|
|
257
|
-
let unresolvedCount = 0;
|
|
258
|
-
for (const operation of operations) {
|
|
259
|
-
const implementationContext = implementationContextForOperation(db, operation);
|
|
260
|
-
const candidates = rankedImplementationCandidates(db, workspaceId, implementationContext);
|
|
261
|
-
if (candidates.length === 0) continue;
|
|
262
|
-
const accepted = candidates.filter((candidate) => candidate.accepted);
|
|
263
|
-
const topScore = accepted[0]?.score ?? 0;
|
|
264
|
-
const winners = accepted.filter((candidate) => candidate.score === topScore);
|
|
265
|
-
const duplicateFamilies = duplicatePackageFamilies(accepted);
|
|
266
|
-
const duplicatePackageAmbiguous = duplicateFamilies.length > 0 && !accepted.some(hasDirectOwnershipEvidence);
|
|
267
|
-
const selected = duplicatePackageAmbiguous ? accepted : winners;
|
|
268
|
-
const unique = !duplicatePackageAmbiguous && winners.length === 1 ? winners[0] : undefined;
|
|
269
|
-
const ambiguityReasons = duplicatePackageAmbiguous ? ['duplicate_package_name_candidates'] : winners.length > 1 ? ['multiple_equal_score_implementation_candidates'] : [];
|
|
270
|
-
const evidence = {
|
|
271
|
-
servicePath: operation.servicePath,
|
|
272
|
-
operationPath: operation.operationPath,
|
|
273
|
-
operationName: operation.operationName,
|
|
274
|
-
modelPackage: { id: operation.modelRepoId, name: operation.modelRepo, packageName: operation.modelPackage },
|
|
275
|
-
implementationSource: implementationContext.operationId === operation.operationId ? 'direct_or_concrete_override' : 'inherited_from_base_operation',
|
|
276
|
-
baseOperationId: operation.baseOperationId,
|
|
277
|
-
implementationOperationId: implementationContext.operationId,
|
|
278
|
-
ambiguityReasons,
|
|
279
|
-
candidateFamilies: duplicateFamilies,
|
|
280
|
-
candidates: candidates.map((candidate, index) => candidateEvidence(candidate, index + 1)),
|
|
281
|
-
};
|
|
282
|
-
const evidenceWithHints = unique ? evidence : { ...evidence, implementationHintSuggestions: implementationHintSuggestions(evidence) };
|
|
283
|
-
if (accepted.length === 0) {
|
|
284
|
-
db.prepare('INSERT INTO graph_edges(workspace_id,edge_type,status,from_kind,from_id,to_kind,to_id,confidence,evidence_json,is_dynamic,unresolved_reason,generation) VALUES(?,?,?,?,?,?,?,?,?,?,?,?)').run(workspaceId, 'OPERATION_IMPLEMENTED_BY_HANDLER', 'unresolved', 'operation', graphId(operation.operationId), 'handler_method_candidates', candidates.map((row) => graphId(row.methodId)).join(','), 0, JSON.stringify(evidenceWithHints), 0, 'No implementation candidate passed policy', generation);
|
|
285
|
-
edgeCount += 1;
|
|
286
|
-
unresolvedCount += 1;
|
|
287
|
-
continue;
|
|
288
|
-
}
|
|
289
|
-
db.prepare('INSERT INTO graph_edges(workspace_id,edge_type,status,from_kind,from_id,to_kind,to_id,confidence,evidence_json,is_dynamic,unresolved_reason,generation) VALUES(?,?,?,?,?,?,?,?,?,?,?,?)').run(workspaceId, 'OPERATION_IMPLEMENTED_BY_HANDLER', unique ? 'resolved' : 'ambiguous', 'operation', graphId(operation.operationId), unique ? 'handler_method' : 'handler_method_candidates', unique ? graphId(unique.methodId) : selected.map((row) => graphId(row.methodId)).join(','), unique ? 0.95 : 0.5, JSON.stringify(evidenceWithHints), 0, unique ? null : 'Ambiguous registered handler implementation candidates', generation);
|
|
290
|
-
edgeCount += 1;
|
|
291
|
-
if (unique) resolvedCount += 1;
|
|
292
|
-
else ambiguousCount += 1;
|
|
293
|
-
}
|
|
294
|
-
return { edgeCount, resolvedCount, ambiguousCount, unresolvedCount };
|
|
295
|
-
}
|
|
296
|
-
interface ImplementationCandidate extends Record<string, unknown> {
|
|
297
|
-
methodId: number;
|
|
298
|
-
registrations?: Array<Record<string, unknown>>;
|
|
299
|
-
score: number;
|
|
300
|
-
accepted: boolean;
|
|
301
|
-
acceptedReasons: string[];
|
|
302
|
-
rejectedReasons: string[];
|
|
303
|
-
}
|
|
304
|
-
function implementationContextForOperation(db: Db, operation: Record<string, unknown>): Record<string, unknown> {
|
|
305
|
-
if (operation.provenance !== 'inherited' || !operation.baseOperationId) return operation;
|
|
306
|
-
const base = db.prepare(`SELECT o.id operationId,o.operation_path operationPath,o.operation_name operationName,o.provenance provenance,o.base_operation_id baseOperationId,s.service_path servicePath,s.repo_id modelRepoId,r.name modelRepo,r.package_name modelPackage,r.kind modelKind FROM cds_operations o JOIN cds_services s ON s.id=o.service_id JOIN repositories r ON r.id=s.repo_id WHERE o.id=?`).get(operation.baseOperationId) as Record<string, unknown> | undefined;
|
|
307
|
-
if (!base) return operation;
|
|
308
|
-
return { ...base, effectiveOperationId: operation.operationId, effectiveServicePath: operation.servicePath, effectiveOperationPath: operation.operationPath };
|
|
309
|
-
}
|
|
310
|
-
function rankedImplementationCandidates(db: Db, workspaceId: number, operation: Record<string, unknown>): ImplementationCandidate[] {
|
|
311
|
-
const rows = implementationCandidates(db, workspaceId, operation);
|
|
312
|
-
const invalid = rows.filter((row) => !validRegistrationPair(row));
|
|
313
|
-
recordRegistrationInvariantDiagnostics(db, invalid);
|
|
314
|
-
return deduplicateCandidates(
|
|
315
|
-
rows.filter(validRegistrationPair)
|
|
316
|
-
.map((row) => scoreImplementationCandidate(row, operation)),
|
|
317
|
-
).sort((a, b) => b.score - a.score || String(a.className).localeCompare(String(b.className)) || a.methodId - b.methodId);
|
|
318
|
-
}
|
|
319
|
-
function validRegistrationPair(row: Record<string, unknown>): boolean {
|
|
320
|
-
if (row.registrationHandlerClassId === null
|
|
321
|
-
|| row.registrationHandlerClassId === undefined)
|
|
322
|
-
return registrationPairingStrategy(row) !== 'unproven';
|
|
323
|
-
return Number(row.registrationHandlerClassId) === Number(row.classId);
|
|
324
|
-
}
|
|
325
|
-
function registrationPairingStrategy(row: Record<string, unknown>): string {
|
|
326
|
-
if (row.registrationHandlerClassId !== null
|
|
327
|
-
&& row.registrationHandlerClassId !== undefined)
|
|
328
|
-
return 'exact_handler_class_id';
|
|
329
|
-
if (Number(row.applicationRepoId) === Number(row.handlerRepoId))
|
|
330
|
-
return 'same_repository_class_name_fallback';
|
|
331
|
-
const source = stringValue(row.importSource);
|
|
332
|
-
const separator = source?.lastIndexOf('#') ?? -1;
|
|
333
|
-
if (!source || separator <= 0) return 'unproven';
|
|
334
|
-
const moduleName = source.slice(0, separator);
|
|
335
|
-
const importedName = source.slice(separator + 1);
|
|
336
|
-
const matchesClass = importedName === row.className
|
|
337
|
-
|| (importedName === 'default' && row.registrationClassName === row.className);
|
|
338
|
-
return moduleName === row.handlerPackage && matchesClass
|
|
339
|
-
? 'explicit_package_import'
|
|
340
|
-
: 'unproven';
|
|
341
|
-
}
|
|
342
|
-
function recordRegistrationInvariantDiagnostics(
|
|
343
|
-
db: Db,
|
|
344
|
-
rows: Array<Record<string, unknown>>,
|
|
345
|
-
): void {
|
|
346
|
-
const insert = db.prepare(`INSERT INTO diagnostics(repo_id,severity,code,message,source_file,source_line)
|
|
347
|
-
SELECT ?,'error','handler_registration_class_mismatch',
|
|
348
|
-
'Implementation candidate registration did not match its persisted handler class id',?,?
|
|
349
|
-
WHERE NOT EXISTS (
|
|
350
|
-
SELECT 1 FROM diagnostics
|
|
351
|
-
WHERE repo_id=? AND code='handler_registration_class_mismatch'
|
|
352
|
-
AND source_file=? AND source_line=?
|
|
353
|
-
)`);
|
|
354
|
-
for (const row of rows)
|
|
355
|
-
insert.run(
|
|
356
|
-
row.applicationRepoId,
|
|
357
|
-
row.registrationFile,
|
|
358
|
-
row.registrationLine,
|
|
359
|
-
row.applicationRepoId,
|
|
360
|
-
row.registrationFile,
|
|
361
|
-
row.registrationLine,
|
|
362
|
-
);
|
|
363
|
-
}
|
|
364
|
-
|
|
365
|
-
function deduplicateCandidates(rows: ImplementationCandidate[]): ImplementationCandidate[] {
|
|
366
|
-
const merged = new Map<string, ImplementationCandidate>();
|
|
367
|
-
for (const row of rows) {
|
|
368
|
-
const key = [row.methodId, row.classId, row.handlerRepoId].join(':');
|
|
369
|
-
const registration = registrationEvidence(row);
|
|
370
|
-
const existing = merged.get(key);
|
|
371
|
-
if (!existing) {
|
|
372
|
-
merged.set(key, { ...row, registrations: [registration] });
|
|
373
|
-
continue;
|
|
374
|
-
}
|
|
375
|
-
existing.registrations = uniqueRegistrations([...(existing.registrations ?? []), registration]);
|
|
376
|
-
existing.score = Math.max(existing.score, row.score);
|
|
377
|
-
existing.accepted = existing.accepted || row.accepted;
|
|
378
|
-
existing.acceptedReasons = [...new Set([...existing.acceptedReasons, ...row.acceptedReasons])];
|
|
379
|
-
existing.rejectedReasons = [...new Set([...existing.rejectedReasons, ...row.rejectedReasons])];
|
|
380
|
-
}
|
|
381
|
-
return [...merged.values()];
|
|
382
|
-
}
|
|
383
|
-
function registrationEvidence(
|
|
384
|
-
row: Record<string, unknown>,
|
|
385
|
-
): Record<string, unknown> {
|
|
386
|
-
return {
|
|
387
|
-
id: row.registrationId,
|
|
388
|
-
handlerClassId: row.registrationHandlerClassId,
|
|
389
|
-
file: row.registrationFile,
|
|
390
|
-
line: row.registrationLine,
|
|
391
|
-
kind: row.registrationKind,
|
|
392
|
-
importSource: row.importSource,
|
|
393
|
-
pairingStrategy: registrationPairingStrategy(row),
|
|
394
|
-
};
|
|
395
|
-
}
|
|
396
|
-
function uniqueRegistrations(rows: Array<Record<string, unknown>>): Array<Record<string, unknown>> {
|
|
397
|
-
const seen = new Set<string>();
|
|
398
|
-
return rows.filter((row) => {
|
|
399
|
-
const key = JSON.stringify(row);
|
|
400
|
-
if (seen.has(key)) return false;
|
|
401
|
-
seen.add(key);
|
|
402
|
-
return true;
|
|
403
|
-
});
|
|
404
|
-
}
|
|
405
|
-
function duplicatePackageFamilies(candidates: ImplementationCandidate[]): Array<Record<string, unknown>> {
|
|
406
|
-
const byPackage = new Map<string, ImplementationCandidate[]>();
|
|
407
|
-
for (const candidate of candidates) {
|
|
408
|
-
const packageName = typeof candidate.handlerPackage === 'string' ? candidate.handlerPackage : undefined;
|
|
409
|
-
if (!packageName) continue;
|
|
410
|
-
byPackage.set(packageName, [...(byPackage.get(packageName) ?? []), candidate]);
|
|
411
|
-
}
|
|
412
|
-
return [...byPackage.entries()]
|
|
413
|
-
.filter(([, rows]) => new Set(rows.map((row) => Number(row.handlerRepoId))).size > 1)
|
|
414
|
-
.map(([packageName, rows]) => ({ reason: 'duplicate_package_name_candidates', packageName, count: rows.length, repositories: rows.map((row) => row.handlerRepo).sort() }));
|
|
415
|
-
}
|
|
416
|
-
function hasDirectOwnershipEvidence(candidate: ImplementationCandidate): boolean {
|
|
417
|
-
return candidate.acceptedReasons.some((reason) => reason === 'model package equals registration package' || reason === 'model package equals handler package' || reason === 'registration package contains exact local service path');
|
|
418
|
-
}
|
|
419
|
-
function implementationCandidates(db: Db, workspaceId: number, operation: Record<string, unknown>): Array<Record<string, unknown>> {
|
|
420
|
-
const modelRepoGraphId = graphId(operation.modelRepoId);
|
|
421
|
-
return db.prepare(`SELECT DISTINCT
|
|
422
|
-
hm.id methodId,
|
|
423
|
-
hm.method_name methodName,
|
|
424
|
-
hm.decorator_value decoratorValue,
|
|
425
|
-
hm.decorator_raw_expression decoratorRawExpression,
|
|
426
|
-
hm.decorator_resolution_json decoratorResolutionJson,
|
|
427
|
-
hc.id classId,
|
|
428
|
-
hc.class_name className,
|
|
429
|
-
hc.source_file sourceFile,
|
|
430
|
-
hc.source_line sourceLine,
|
|
431
|
-
hr.id registrationId,
|
|
432
|
-
hr.handler_class_id registrationHandlerClassId,
|
|
433
|
-
hr.class_name registrationClassName,
|
|
434
|
-
hr.repo_id applicationRepoId,
|
|
435
|
-
hr.registration_file registrationFile,
|
|
436
|
-
hr.registration_line registrationLine,
|
|
437
|
-
hr.registration_kind registrationKind,
|
|
438
|
-
hr.import_source importSource,
|
|
439
|
-
handlerRepo.id handlerRepoId,
|
|
440
|
-
handlerRepo.name handlerRepo,
|
|
441
|
-
handlerRepo.package_name handlerPackage,
|
|
442
|
-
appRepo.name applicationRepo,
|
|
443
|
-
appRepo.package_name applicationPackage,
|
|
444
|
-
? modelRepoId,
|
|
445
|
-
? modelRepo,
|
|
446
|
-
? modelPackage,
|
|
447
|
-
? modelKind,
|
|
448
|
-
? servicePath,
|
|
449
|
-
? operationPath,
|
|
450
|
-
? operationName,
|
|
451
|
-
CASE WHEN appRepo.id=? THEN 1 ELSE 0 END modelIsApplicationRepo,
|
|
452
|
-
CASE WHEN handlerRepo.id=? THEN 1 ELSE 0 END modelIsHandlerRepo,
|
|
453
|
-
CASE WHEN appRepo.id=handlerRepo.id THEN 1 ELSE 0 END sameRepoRegistration,
|
|
454
|
-
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,
|
|
455
|
-
CASE WHEN EXISTS (SELECT 1 FROM cds_services localService WHERE localService.repo_id=appRepo.id) THEN 1 ELSE 0 END applicationHasLocalServices,
|
|
456
|
-
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,
|
|
457
|
-
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,
|
|
458
|
-
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,
|
|
459
|
-
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
|
|
460
|
-
FROM handler_methods hm
|
|
461
|
-
JOIN handler_classes hc ON hc.id=hm.handler_class_id
|
|
462
|
-
JOIN repositories handlerRepo ON handlerRepo.id=hc.repo_id
|
|
463
|
-
JOIN handler_registrations hr ON (
|
|
464
|
-
(hr.handler_class_id IS NOT NULL AND hr.handler_class_id=hc.id)
|
|
465
|
-
OR (
|
|
466
|
-
hr.handler_class_id IS NULL
|
|
467
|
-
AND (
|
|
468
|
-
(hr.class_name=hc.class_name AND hr.repo_id=hc.repo_id)
|
|
469
|
-
OR (
|
|
470
|
-
instr(hr.import_source,'#')>1
|
|
471
|
-
AND substr(hr.import_source,1,instr(hr.import_source,'#')-1)
|
|
472
|
-
=handlerRepo.package_name
|
|
473
|
-
AND (
|
|
474
|
-
substr(hr.import_source,instr(hr.import_source,'#')+1)
|
|
475
|
-
=hc.class_name
|
|
476
|
-
OR (
|
|
477
|
-
substr(hr.import_source,instr(hr.import_source,'#')+1)
|
|
478
|
-
='default'
|
|
479
|
-
AND hr.class_name=hc.class_name
|
|
480
|
-
)
|
|
481
|
-
)
|
|
482
|
-
)
|
|
483
|
-
)
|
|
484
|
-
)
|
|
485
|
-
)
|
|
486
|
-
JOIN repositories appRepo ON appRepo.id=hr.repo_id
|
|
487
|
-
WHERE appRepo.workspace_id=?
|
|
488
|
-
AND (hm.decorator_value=? OR hm.decorator_value=? OR hm.method_name=? OR hm.decorator_raw_expression LIKE ?)`).all(
|
|
489
|
-
operation.modelRepoId,
|
|
490
|
-
operation.modelRepo,
|
|
491
|
-
operation.modelPackage,
|
|
492
|
-
operation.modelKind,
|
|
493
|
-
operation.servicePath,
|
|
494
|
-
operation.operationPath,
|
|
495
|
-
operation.operationName,
|
|
496
|
-
operation.modelRepoId,
|
|
497
|
-
operation.modelRepoId,
|
|
498
|
-
operation.servicePath,
|
|
499
|
-
normalizedOperation(String(operation.operationPath ?? '')),
|
|
500
|
-
operation.operationName,
|
|
501
|
-
operation.operationName,
|
|
502
|
-
`%${upperFirst(normalizedOperation(String(operation.operationPath ?? operation.operationName ?? '')))}%`,
|
|
503
|
-
modelRepoGraphId,
|
|
504
|
-
modelRepoGraphId,
|
|
505
|
-
workspaceId,
|
|
506
|
-
normalizedOperation(String(operation.operationPath ?? '')),
|
|
507
|
-
operation.operationName,
|
|
508
|
-
operation.operationName,
|
|
509
|
-
`%${upperFirst(normalizedOperation(String(operation.operationPath ?? operation.operationName ?? '')))}%`,
|
|
510
|
-
) as Array<Record<string, unknown>>;
|
|
511
|
-
}
|
|
512
|
-
function scoreImplementationCandidate(row: Record<string, unknown>, operation: Record<string, unknown>): ImplementationCandidate {
|
|
513
|
-
const acceptedReasons: string[] = [];
|
|
514
|
-
const rejectedReasons: string[] = [];
|
|
515
|
-
let score = 0;
|
|
516
|
-
const modelIsApplicationRepo = flag(row.modelIsApplicationRepo);
|
|
517
|
-
const modelIsHandlerRepo = flag(row.modelIsHandlerRepo);
|
|
518
|
-
const localServicePathMatch = flag(row.localServicePathMatch);
|
|
519
|
-
const applicationHasLocalServices = flag(row.applicationHasLocalServices);
|
|
520
|
-
const appDependsOnModel = flag(row.appDependsOnModel);
|
|
521
|
-
const applicationHasLocalRegistrationForOperation = flag(row.applicationHasLocalRegistrationForOperation);
|
|
522
|
-
const appDependsOnHandler = flag(row.appDependsOnHandler);
|
|
523
|
-
const handlerDependsOnModel = flag(row.handlerDependsOnModel);
|
|
524
|
-
const importSource = typeof row.importSource === 'string' && row.importSource.length > 0;
|
|
525
|
-
const sameRepoRegistration = flag(row.sameRepoRegistration);
|
|
526
|
-
const modelOriented = row.modelKind === 'cap-db-model' || !applicationHasLocalRegistrationForOperation;
|
|
527
|
-
const methodSignal = implementationMethodSignal(row, operation);
|
|
528
|
-
const methodMatches = methodSignal.matches;
|
|
529
|
-
acceptedReasons.push(...methodSignal.acceptedReasons);
|
|
530
|
-
rejectedReasons.push(...methodSignal.rejectedReasons);
|
|
531
|
-
const registeredAndLinked = sameRepoRegistration && importSource;
|
|
532
|
-
const helperOwned = modelOriented && methodMatches && registeredAndLinked && sameRepoRegistration && !applicationHasLocalServices && !modelIsApplicationRepo && !modelIsHandlerRepo && !localServicePathMatch && !appDependsOnModel && !appDependsOnHandler && !handlerDependsOnModel;
|
|
533
|
-
if (modelIsApplicationRepo) {
|
|
534
|
-
score += 100;
|
|
535
|
-
acceptedReasons.push('model package equals registration package');
|
|
536
|
-
}
|
|
537
|
-
if (modelIsHandlerRepo) {
|
|
538
|
-
score += 100;
|
|
539
|
-
acceptedReasons.push('model package equals handler package');
|
|
540
|
-
}
|
|
541
|
-
if (localServicePathMatch) {
|
|
542
|
-
score += 80;
|
|
543
|
-
acceptedReasons.push('registration package contains exact local service path');
|
|
544
|
-
} else if (applicationHasLocalServices && !appDependsOnModel && !modelIsApplicationRepo) {
|
|
545
|
-
rejectedReasons.push(`registration package has local services but none match ${String(operation.servicePath ?? '')}`);
|
|
546
|
-
}
|
|
547
|
-
if (appDependsOnModel) {
|
|
548
|
-
score += 70;
|
|
549
|
-
acceptedReasons.push('registration package depends on model package');
|
|
550
|
-
}
|
|
551
|
-
if (appDependsOnHandler) {
|
|
552
|
-
score += 30;
|
|
553
|
-
acceptedReasons.push('registration package depends on handler package');
|
|
554
|
-
}
|
|
555
|
-
if (handlerDependsOnModel) {
|
|
556
|
-
score += 20;
|
|
557
|
-
acceptedReasons.push('handler package depends on model package');
|
|
558
|
-
}
|
|
559
|
-
if (helperOwned) {
|
|
560
|
-
score += 60;
|
|
561
|
-
acceptedReasons.push('unique registered helper implementation for model-only operation');
|
|
562
|
-
}
|
|
563
|
-
if (importSource) {
|
|
564
|
-
score += 10;
|
|
565
|
-
acceptedReasons.push('registration imports handler class');
|
|
566
|
-
}
|
|
567
|
-
const hasOwnership = modelIsApplicationRepo || modelIsHandlerRepo;
|
|
568
|
-
const hasCrossPackage = appDependsOnModel && (modelIsHandlerRepo || appDependsOnHandler || !importSource);
|
|
569
|
-
const contradicted = applicationHasLocalServices && !localServicePathMatch && !appDependsOnModel && !hasOwnership;
|
|
570
|
-
if (!hasOwnership && !localServicePathMatch && !hasCrossPackage && !helperOwned) rejectedReasons.push('missing direct ownership, exact local service path, or validated cross-package dependency evidence');
|
|
571
|
-
const accepted = methodMatches && !methodSignal.contradicted && !contradicted && (hasOwnership || localServicePathMatch || hasCrossPackage || handlerDependsOnModel || helperOwned);
|
|
572
|
-
if (!accepted && rejectedReasons.length === 0) rejectedReasons.push('candidate did not meet implementation ownership policy');
|
|
573
|
-
return { ...row, methodId: Number(row.methodId), score, accepted, acceptedReasons, rejectedReasons };
|
|
574
|
-
}
|
|
575
|
-
function candidateEvidence(candidate: ImplementationCandidate, rank: number): Record<string, unknown> {
|
|
576
|
-
return {
|
|
577
|
-
rank,
|
|
578
|
-
score: candidate.score,
|
|
579
|
-
accepted: candidate.accepted,
|
|
580
|
-
acceptedReasons: candidate.acceptedReasons,
|
|
581
|
-
rejectedReasons: candidate.rejectedReasons,
|
|
582
|
-
methodId: candidate.methodId,
|
|
583
|
-
classId: candidate.classId,
|
|
584
|
-
className: candidate.className,
|
|
585
|
-
sourceFile: candidate.sourceFile,
|
|
586
|
-
sourceLine: candidate.sourceLine,
|
|
587
|
-
decoratorResolution: objectJson(candidate.decoratorResolutionJson),
|
|
588
|
-
registration: registrationEvidence(candidate),
|
|
589
|
-
registrations: candidate.registrations ?? [],
|
|
590
|
-
registrationPairing: {
|
|
591
|
-
strategy: registrationPairingStrategy(candidate),
|
|
592
|
-
registrationId: candidate.registrationId,
|
|
593
|
-
registrationHandlerClassId: candidate.registrationHandlerClassId,
|
|
594
|
-
candidateHandlerClassId: candidate.classId,
|
|
595
|
-
invariantStatus: validRegistrationPair(candidate) ? 'valid' : 'invalid',
|
|
596
|
-
},
|
|
597
|
-
applicationPackage: { id: candidate.applicationRepoId, name: candidate.applicationRepo, packageName: candidate.applicationPackage },
|
|
598
|
-
handlerPackage: { id: candidate.handlerRepoId, name: candidate.handlerRepo, packageName: candidate.handlerPackage },
|
|
599
|
-
modelPackage: { id: candidate.modelRepoId, name: candidate.modelRepo, packageName: candidate.modelPackage },
|
|
600
|
-
servicePath: candidate.servicePath,
|
|
601
|
-
operationPath: candidate.operationPath,
|
|
602
|
-
operationName: candidate.operationName,
|
|
603
|
-
signals: {
|
|
604
|
-
directOwnership: { modelIsApplicationRepo: flag(candidate.modelIsApplicationRepo), modelIsHandlerRepo: flag(candidate.modelIsHandlerRepo) },
|
|
605
|
-
localServicePathMatch: flag(candidate.localServicePathMatch),
|
|
606
|
-
applicationHasLocalServices: flag(candidate.applicationHasLocalServices),
|
|
607
|
-
applicationHasLocalRegistrationForOperation: flag(candidate.applicationHasLocalRegistrationForOperation),
|
|
608
|
-
appDependsOnModel: flag(candidate.appDependsOnModel),
|
|
609
|
-
appDependsOnHandler: flag(candidate.appDependsOnHandler),
|
|
610
|
-
handlerDependsOnModel: flag(candidate.handlerDependsOnModel),
|
|
611
|
-
sameRepoRegistration: flag(candidate.sameRepoRegistration),
|
|
612
|
-
},
|
|
613
|
-
};
|
|
614
|
-
}
|
|
615
|
-
function implementationMethodSignal(row: Record<string, unknown>, operation: Record<string, unknown>): { matches: boolean; contradicted: boolean; acceptedReasons: string[]; rejectedReasons: string[] } {
|
|
616
|
-
const op = normalizedOperationName(String(operation.operationPath ?? operation.operationName ?? ''));
|
|
617
|
-
const decorator = normalizeDecoratorOperationSignal(typeof row.decoratorValue === 'string' ? row.decoratorValue : undefined, typeof row.decoratorRawExpression === 'string' ? row.decoratorRawExpression : undefined, op);
|
|
618
|
-
if (decorator.status === 'resolved' && decorator.operationName === op) return { matches: true, contradicted: false, acceptedReasons: ['decorator targets operation'], rejectedReasons: [] };
|
|
619
|
-
if (decorator.status === 'resolved' && decorator.operationName !== op) return { matches: false, contradicted: true, acceptedReasons: [], rejectedReasons: ['method_name_matches_but_decorator_targets_different_operation'] };
|
|
620
|
-
if (String(row.methodName ?? '') === op) return { matches: true, contradicted: false, acceptedReasons: ['method name fallback matched operation'], rejectedReasons: [] };
|
|
621
|
-
return { matches: false, contradicted: false, acceptedReasons: [], rejectedReasons: ['method name does not match operation'] };
|
|
622
|
-
}
|
|
623
|
-
function upperFirst(value: string): string {
|
|
624
|
-
return value ? `${value[0]?.toUpperCase() ?? ''}${value.slice(1)}` : value;
|
|
625
|
-
}
|
|
626
|
-
function flag(value: unknown): boolean {
|
|
627
|
-
return Boolean(Number(value ?? 0));
|
|
628
|
-
}
|
|
629
|
-
function graphId(value: unknown): string {
|
|
630
|
-
return String(value);
|
|
631
|
-
}
|
|
632
|
-
function normalizedOperation(value: string): string {
|
|
633
|
-
return value.startsWith('/') ? value.slice(1) : value;
|
|
634
|
-
}
|
|
@@ -22,6 +22,26 @@ export function extractPlaceholders(template: string | undefined): string[] {
|
|
|
22
22
|
.filter(Boolean);
|
|
23
23
|
}
|
|
24
24
|
|
|
25
|
+
export function matchRuntimeTemplate(
|
|
26
|
+
template: string | undefined,
|
|
27
|
+
concrete: string | undefined,
|
|
28
|
+
): Record<string, string> | undefined {
|
|
29
|
+
if (!template || !concrete) return undefined;
|
|
30
|
+
const keys = extractPlaceholders(template);
|
|
31
|
+
if (keys.length === 0) return template === concrete ? {} : undefined;
|
|
32
|
+
const match = new RegExp(`^${runtimeTemplatePattern(template)}$`).exec(concrete);
|
|
33
|
+
if (!match) return undefined;
|
|
34
|
+
const values: Record<string, string> = {};
|
|
35
|
+
for (let index = 0; index < keys.length; index += 1) {
|
|
36
|
+
const key = keys[index];
|
|
37
|
+
const value = match[index + 1];
|
|
38
|
+
if (!key || value === undefined) return undefined;
|
|
39
|
+
if (values[key] !== undefined && values[key] !== value) return undefined;
|
|
40
|
+
values[key] = value;
|
|
41
|
+
}
|
|
42
|
+
return values;
|
|
43
|
+
}
|
|
44
|
+
|
|
25
45
|
export function substituteVariables(
|
|
26
46
|
template: string | undefined,
|
|
27
47
|
vars: Record<string, string>,
|
|
@@ -43,3 +63,18 @@ export function substituteVariables(
|
|
|
43
63
|
changed: effective !== template,
|
|
44
64
|
};
|
|
45
65
|
}
|
|
66
|
+
|
|
67
|
+
function runtimeTemplatePattern(template: string): string {
|
|
68
|
+
let pattern = '';
|
|
69
|
+
let lastIndex = 0;
|
|
70
|
+
for (const match of template.matchAll(PLACEHOLDER)) {
|
|
71
|
+
pattern += escapeRegex(template.slice(lastIndex, match.index));
|
|
72
|
+
pattern += '([^/]+?)';
|
|
73
|
+
lastIndex = (match.index ?? 0) + match[0].length;
|
|
74
|
+
}
|
|
75
|
+
return `${pattern}${escapeRegex(template.slice(lastIndex))}`;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function escapeRegex(value: string): string {
|
|
79
|
+
return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
80
|
+
}
|
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
import { createHash } from 'node:crypto';
|
|
2
|
+
import { projectBounded } from '../utils/000-bounded-projection.js';
|
|
2
3
|
|
|
3
4
|
export type ExternalTargetKind = 'destination' | 'static_url' | 'url_expression' | 'unknown';
|
|
4
|
-
export interface ExternalHttpTarget { kind: ExternalTargetKind; toKind: 'external_destination' | 'external_endpoint'; toId: string; label: string; method?: string; dynamic: boolean; expression?: string; }
|
|
5
|
+
export interface ExternalHttpTarget { kind: ExternalTargetKind; toKind: 'external_destination' | 'external_endpoint'; toId: string; label: string; method?: string; dynamic: boolean; expression?: string; candidateLiteralCount?: number; shownCandidateLiteralCount?: number; omittedCandidateLiteralCount?: number; }
|
|
5
6
|
const sensitiveKeys = new Set(['token','access_token','id_token','api_key','apikey','key','password','passwd','pwd','secret','client_secret','authorization','cookie','signature']);
|
|
6
7
|
function hash(value: string): string { return createHash('sha256').update(value).digest('hex').slice(0, 12); }
|
|
7
8
|
function methodPrefix(method: unknown): string { return typeof method === 'string' && method.length > 0 ? `${method.toUpperCase()} ` : ''; }
|
|
@@ -24,8 +25,22 @@ export function externalHttpTarget(call: Record<string, unknown>): ExternalHttpT
|
|
|
24
25
|
const expression = typeof target.expression === 'string' ? target.expression : undefined;
|
|
25
26
|
if (kind === 'destination' && target.dynamic === true) {
|
|
26
27
|
const shape = typeof target.expressionShape === 'string' ? target.expressionShape : 'expression';
|
|
27
|
-
const candidates =
|
|
28
|
-
|
|
28
|
+
const candidates = staticDestinationCandidates(target.candidateLiterals);
|
|
29
|
+
const projection = projectBounded(candidates, (left, right) => left.localeCompare(right));
|
|
30
|
+
return {
|
|
31
|
+
kind,
|
|
32
|
+
toKind: 'external_destination',
|
|
33
|
+
toId: `destination:dynamic:${hash(`${shape}:${candidates.join('|')}`)}`,
|
|
34
|
+
label: 'External destination: dynamic destination',
|
|
35
|
+
method,
|
|
36
|
+
dynamic: true,
|
|
37
|
+
expression: projection.items.length
|
|
38
|
+
? `candidates:${projection.items.join('|')}`
|
|
39
|
+
: `shape:${shape}`,
|
|
40
|
+
candidateLiteralCount: projection.totalCount,
|
|
41
|
+
shownCandidateLiteralCount: projection.shownCount,
|
|
42
|
+
omittedCandidateLiteralCount: projection.omittedCount,
|
|
43
|
+
};
|
|
29
44
|
}
|
|
30
45
|
if (kind === 'destination' && expression) return { kind, toKind: 'external_destination', toId: `destination:${expression}`, label: `External destination: ${expression}`, method, dynamic: false, expression };
|
|
31
46
|
if (kind === 'static_url' && expression) {
|
|
@@ -35,4 +50,10 @@ export function externalHttpTarget(call: Record<string, unknown>): ExternalHttpT
|
|
|
35
50
|
if (kind === 'url_expression' && expression) return { kind, toKind: 'external_endpoint', toId: `dynamic:${hash(expression)}`, label: `External endpoint: ${methodPrefix(method)}dynamic URL`, method, dynamic: true, expression: `expr:${hash(expression)}` };
|
|
36
51
|
return { kind: 'unknown', toKind: 'external_endpoint', toId: 'unknown', label: 'External endpoint: unknown', method, dynamic: false };
|
|
37
52
|
}
|
|
53
|
+
function staticDestinationCandidates(value: unknown): string[] {
|
|
54
|
+
const candidates = Array.isArray(value)
|
|
55
|
+
? value.filter((item): item is string => typeof item === 'string')
|
|
56
|
+
: [];
|
|
57
|
+
return [...new Set(candidates)].sort();
|
|
58
|
+
}
|
|
38
59
|
function safeParse(value: string): Record<string, unknown> { try { const parsed = JSON.parse(value) as unknown; return parsed && typeof parsed === 'object' && !Array.isArray(parsed) ? parsed as Record<string, unknown> : {}; } catch { return {}; } }
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import type { Db } from '../db/connection.js';
|
|
2
|
+
import { projectBounded } from '../utils/000-bounded-projection.js';
|
|
2
3
|
|
|
3
4
|
interface RepoDependencyRow {
|
|
4
5
|
id: number;
|
|
@@ -34,6 +35,10 @@ export function linkHelperPackages(db: Db, workspaceId: number, generation: numb
|
|
|
34
35
|
if (result.candidates.length === 0) continue;
|
|
35
36
|
const status = result.candidates.length === 1 ? 'resolved' : 'ambiguous';
|
|
36
37
|
const helper = status === 'resolved' ? result.candidates[0] : undefined;
|
|
38
|
+
const projection = projectBounded(result.candidates, (left, right) =>
|
|
39
|
+
left.name.localeCompare(right.name)
|
|
40
|
+
|| String(left.package_name ?? '').localeCompare(String(right.package_name ?? ''))
|
|
41
|
+
|| left.id - right.id);
|
|
37
42
|
db.prepare('INSERT INTO graph_edges(workspace_id,edge_type,status,from_kind,from_id,to_kind,to_id,confidence,evidence_json,is_dynamic,unresolved_reason,generation) VALUES(?,?,?,?,?,?,?,?,?,?,?,?)').run(
|
|
38
43
|
workspaceId,
|
|
39
44
|
'REPO_IMPORTS_HELPER_PACKAGE',
|
|
@@ -41,9 +46,20 @@ export function linkHelperPackages(db: Db, workspaceId: number, generation: numb
|
|
|
41
46
|
'repo',
|
|
42
47
|
String(repo.id),
|
|
43
48
|
helper ? 'repo' : 'repo_candidates',
|
|
44
|
-
helper ? String(helper.id) :
|
|
49
|
+
helper ? String(helper.id) : projection.items.map((candidate) => candidate.id).join(','),
|
|
45
50
|
helper ? 1 : 0.5,
|
|
46
|
-
JSON.stringify({
|
|
51
|
+
JSON.stringify({
|
|
52
|
+
dependency: dep,
|
|
53
|
+
candidates: projection.items.map((candidate) => ({
|
|
54
|
+
id: candidate.id,
|
|
55
|
+
name: candidate.name,
|
|
56
|
+
packageName: candidate.package_name,
|
|
57
|
+
})),
|
|
58
|
+
candidateCount: projection.totalCount,
|
|
59
|
+
shownCandidateCount: projection.shownCount,
|
|
60
|
+
omittedCandidateCount: projection.omittedCount,
|
|
61
|
+
match: result.strategy,
|
|
62
|
+
}),
|
|
47
63
|
0,
|
|
48
64
|
helper ? null : 'Ambiguous dependency package candidates',
|
|
49
65
|
generation,
|