@saptools/service-flow 0.1.36 → 0.1.38
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/dist/{chunk-2UOIY2NI.js → chunk-WE3A6TOJ.js} +59 -38
- package/dist/chunk-WE3A6TOJ.js.map +1 -0
- package/dist/cli.js +4 -3
- package/dist/cli.js.map +1 -1
- package/dist/index.js +1 -1
- package/package.json +3 -2
- package/src/cli.ts +865 -0
- package/src/config/defaults.ts +14 -0
- package/src/config/workspace-config.ts +61 -0
- package/src/db/connection.ts +131 -0
- package/src/db/migrations.ts +81 -0
- package/src/db/repositories.ts +353 -0
- package/src/db/schema.ts +24 -0
- package/src/discovery/classify-repository.ts +50 -0
- package/src/discovery/discover-repositories.ts +58 -0
- package/src/index.ts +13 -0
- package/src/indexer/incremental-index.ts +25 -0
- package/src/indexer/repository-indexer.ts +137 -0
- package/src/indexer/workspace-indexer.ts +29 -0
- package/src/linker/cross-repo-linker.ts +406 -0
- package/src/linker/dynamic-edge-resolver.ts +45 -0
- package/src/linker/external-http-target.ts +38 -0
- package/src/linker/helper-package-linker.ts +57 -0
- package/src/linker/odata-path-normalizer.ts +236 -0
- package/src/linker/operation-decorator-normalizer.ts +47 -0
- package/src/linker/remote-query-target.ts +39 -0
- package/src/linker/service-resolver.ts +223 -0
- package/src/output/json-output.ts +7 -0
- package/src/output/mermaid-output.ts +16 -0
- package/src/output/table-output.ts +21 -0
- package/src/parsers/cds-parser.ts +147 -0
- package/src/parsers/decorator-parser.ts +76 -0
- package/src/parsers/generated-constants-parser.ts +23 -0
- package/src/parsers/handler-registration-parser.ts +177 -0
- package/src/parsers/outbound-call-parser.ts +398 -0
- package/src/parsers/package-json-parser.ts +63 -0
- package/src/parsers/service-binding-parser.ts +826 -0
- package/src/parsers/symbol-parser.ts +328 -0
- package/src/parsers/ts-project.ts +13 -0
- package/src/trace/selectors.ts +20 -0
- package/src/trace/trace-engine.ts +647 -0
- package/src/trace/traversal.ts +4 -0
- package/src/types.ts +186 -0
- package/src/utils/diagnostics.ts +10 -0
- package/src/utils/hashing.ts +10 -0
- package/src/utils/path-utils.ts +13 -0
- package/src/utils/redaction.ts +20 -0
- package/src/version.ts +4 -0
- package/dist/chunk-2UOIY2NI.js.map +0 -1
|
@@ -0,0 +1,406 @@
|
|
|
1
|
+
import type { Db } from '../db/connection.js';
|
|
2
|
+
import { applyVariables } from './dynamic-edge-resolver.js';
|
|
3
|
+
import { classifyODataPathIntent, normalizeODataOperationInvocationPath, type NormalizedODataOperationPath } from './odata-path-normalizer.js';
|
|
4
|
+
import { buildRemoteQueryTarget } from './remote-query-target.js';
|
|
5
|
+
import { resolveOperation } from './service-resolver.js';
|
|
6
|
+
import { linkHelperPackages } from './helper-package-linker.js';
|
|
7
|
+
import { normalizeDecoratorOperationSignal, normalizedOperationName } from './operation-decorator-normalizer.js';
|
|
8
|
+
import { externalHttpTarget } from './external-http-target.js';
|
|
9
|
+
export interface LinkWorkspaceResult {
|
|
10
|
+
edgeCount: number;
|
|
11
|
+
unresolvedCount: number;
|
|
12
|
+
resolvedCount: number;
|
|
13
|
+
remoteResolvedCount: number;
|
|
14
|
+
localResolvedCount: number;
|
|
15
|
+
ambiguousCount: number;
|
|
16
|
+
dynamicCount: number;
|
|
17
|
+
terminalCount: number;
|
|
18
|
+
dependencyResolvedCount: number;
|
|
19
|
+
dependencyAmbiguousCount: number;
|
|
20
|
+
implementationResolvedCount: number;
|
|
21
|
+
implementationAmbiguousCount: number;
|
|
22
|
+
implementationUnresolvedCount: number;
|
|
23
|
+
}
|
|
24
|
+
export function linkWorkspace(db: Db, workspaceId: number, vars: Record<string, string> = {}): LinkWorkspaceResult {
|
|
25
|
+
return db.transaction(() => {
|
|
26
|
+
const generation = nextGraphGeneration(db, workspaceId);
|
|
27
|
+
db.prepare('DELETE FROM graph_edges WHERE workspace_id=?').run(workspaceId);
|
|
28
|
+
const deps = linkHelperPackages(db, workspaceId, generation);
|
|
29
|
+
const impl = linkImplementations(db, workspaceId, generation);
|
|
30
|
+
const callSummary = linkCalls(db, workspaceId, vars, generation);
|
|
31
|
+
db.prepare("UPDATE repositories SET graph_generation=?, graph_stale_reason=NULL, graph_stale_at=NULL WHERE workspace_id=?").run(generation, workspaceId);
|
|
32
|
+
return { ...callSummary, edgeCount: deps.edgeCount + callSummary.edgeCount + impl.edgeCount, dependencyResolvedCount: deps.resolvedCount, dependencyAmbiguousCount: deps.ambiguousCount, implementationResolvedCount: impl.resolvedCount, implementationAmbiguousCount: impl.ambiguousCount, implementationUnresolvedCount: impl.unresolvedCount };
|
|
33
|
+
});
|
|
34
|
+
}
|
|
35
|
+
function nextGraphGeneration(db: Db, workspaceId: number): number {
|
|
36
|
+
const row = db.prepare('SELECT COALESCE(MAX(graph_generation),0) generation FROM repositories WHERE workspace_id=?').get(workspaceId) as { generation?: number } | undefined;
|
|
37
|
+
return Number(row?.generation ?? 0) + 1;
|
|
38
|
+
}
|
|
39
|
+
function linkCalls(db: Db, workspaceId: number, vars: Record<string, string>, generation: number): Omit<LinkWorkspaceResult, 'dependencyResolvedCount' | 'dependencyAmbiguousCount' | 'implementationResolvedCount' | 'implementationAmbiguousCount' | 'implementationUnresolvedCount'> {
|
|
40
|
+
let edgeCount = 0;
|
|
41
|
+
let unresolvedCount = 0;
|
|
42
|
+
let resolvedCount = 0;
|
|
43
|
+
let remoteResolvedCount = 0;
|
|
44
|
+
let localResolvedCount = 0;
|
|
45
|
+
let ambiguousCount = 0;
|
|
46
|
+
let dynamicCount = 0;
|
|
47
|
+
let terminalCount = 0;
|
|
48
|
+
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>>;
|
|
49
|
+
for (const call of calls) {
|
|
50
|
+
const result = insertCallEdge(db, workspaceId, call, vars, generation);
|
|
51
|
+
edgeCount += 1;
|
|
52
|
+
resolvedCount += result.status === 'resolved' ? 1 : 0;
|
|
53
|
+
remoteResolvedCount += result.status === 'resolved' && result.callType !== 'local_service_call' ? 1 : 0;
|
|
54
|
+
localResolvedCount += result.status === 'resolved' && result.callType === 'local_service_call' ? 1 : 0;
|
|
55
|
+
unresolvedCount += result.status === 'unresolved' ? 1 : 0;
|
|
56
|
+
ambiguousCount += result.status === 'ambiguous' ? 1 : 0;
|
|
57
|
+
dynamicCount += result.status === 'dynamic' ? 1 : 0;
|
|
58
|
+
terminalCount += result.status === 'terminal' ? 1 : 0;
|
|
59
|
+
}
|
|
60
|
+
return { edgeCount, unresolvedCount, resolvedCount, remoteResolvedCount, localResolvedCount, ambiguousCount, dynamicCount, terminalCount };
|
|
61
|
+
}
|
|
62
|
+
function insertCallEdge(db: Db, workspaceId: number, call: Record<string, unknown>, vars: Record<string, string>, generation: number): { status: string; callType: string } {
|
|
63
|
+
const callType = String(call.call_type);
|
|
64
|
+
const rawOp = applyVariables(String(call.operation_path_expr ?? ''), vars);
|
|
65
|
+
const intent = classifyODataPathIntent(rawOp, call.method as string | undefined);
|
|
66
|
+
const isEntityQueryIntent = ['entity_query', 'entity_key_read', 'entity_navigation_query'].includes(intent.kind);
|
|
67
|
+
const resolutionRawOp = callType === 'remote_query' && isEntityQueryIntent ? intent.pathWithoutQuery : rawOp;
|
|
68
|
+
const normalized = normalizeODataOperationInvocationPath(resolutionRawOp);
|
|
69
|
+
const op = normalized?.normalizedOperationPath ?? resolutionRawOp;
|
|
70
|
+
const servicePath = applyVariables((call.servicePathExpr as string | undefined) ?? (call.requireServicePath as string | undefined), vars);
|
|
71
|
+
const destination = (call.destinationExpr as string | undefined) ?? (call.requireDestination as string | undefined);
|
|
72
|
+
const isDynamic = Boolean(Number(call.isDynamic ?? 0));
|
|
73
|
+
const isRemoteEntityCall = callType.startsWith('remote_entity_');
|
|
74
|
+
const indexedOperationCandidateCount = operationCandidateCount(db, workspaceId, op, intent.topLevelOperationName);
|
|
75
|
+
const credibleOperationSignal = Boolean(normalized?.wasInvocation) || (Boolean(intent.topLevelOperationNameCandidate) && indexedOperationCandidateCount > 0);
|
|
76
|
+
const strongEntitySignal = ['entity_media', 'entity_delete', 'entity_key_read', 'entity_navigation_query'].includes(intent.kind) || (intent.kind === 'entity_mutation' && (intent.hasEntityKeyPredicate || intent.hasNavigationSuffix));
|
|
77
|
+
const operationLikeRemoteEntity = isRemoteEntityCall && Boolean(op) && credibleOperationSignal && (!strongEntitySignal || indexedOperationCandidateCount > 0);
|
|
78
|
+
const isOperationCall = operationLikeRemoteEntity || ((callType === 'remote_action' || callType === 'local_service_call') || (callType === 'remote_query' && Boolean(op)));
|
|
79
|
+
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: [] };
|
|
80
|
+
const evidence: Record<string, unknown> = { ...callEvidence(call, resolution, servicePath, op, destination ? applyVariables(destination, vars) : undefined, normalized, intent), indexedOperationCandidateCount, parserCallType: callType };
|
|
81
|
+
if (isRemoteEntityCall && (resolution.target || resolution.candidates.length > 0 || resolution.status === 'dynamic')) {
|
|
82
|
+
if (resolution.target) {
|
|
83
|
+
db.prepare('INSERT INTO graph_edges(workspace_id,edge_type,status,from_kind,from_id,to_kind,to_id,confidence,evidence_json,is_dynamic,generation) VALUES(?,?,?,?,?,?,?,?,?,?,?)').run(workspaceId, 'REMOTE_CALL_RESOLVES_TO_OPERATION', 'resolved', 'call', String(call.id), 'operation', String(resolution.target.operationId), resolution.target.score, JSON.stringify({ ...evidence, operationEntityPrecedence: 'indexed_operation_over_parser_entity' }), 0, generation);
|
|
84
|
+
return { status: 'resolved', callType };
|
|
85
|
+
}
|
|
86
|
+
const status = resolution.status === 'dynamic' ? 'dynamic' : resolution.status === 'ambiguous' ? 'ambiguous' : 'unresolved';
|
|
87
|
+
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, status === 'dynamic' ? 'DYNAMIC_EDGE_CANDIDATE' : 'UNRESOLVED_EDGE', status, 'call', String(call.id), 'operation_candidate', op ? `Remote action: ${op}` : 'Remote action: unknown path', Number(call.confidence ?? 0.2), JSON.stringify({ ...evidence, operationEntityPrecedence: resolution.candidates.length > 0 ? 'parser_entity_with_indexed_operation_candidates' : 'parser_entity_operation_candidate_without_indexed_match' }), status === 'dynamic' ? 1 : 0, unresolvedOperationReason(resolution), generation);
|
|
88
|
+
return { status, callType };
|
|
89
|
+
}
|
|
90
|
+
if (isRemoteEntityCall) {
|
|
91
|
+
const target = buildRemoteQueryTarget({ queryEntity: intent.entitySegment ?? call.query_entity, servicePath, serviceAlias: call.alias, serviceAliasExpr: call.aliasExpr, destination: destination ? applyVariables(destination, vars) : undefined, isDynamic, parserWarning: evidence.parserWarning });
|
|
92
|
+
const entityKind = callType.replace('remote_entity_', 'remote_entity_');
|
|
93
|
+
db.prepare('INSERT INTO graph_edges(workspace_id,edge_type,status,from_kind,from_id,to_kind,to_id,confidence,evidence_json,is_dynamic,generation) VALUES(?,?,?,?,?,?,?,?,?,?,?)').run(workspaceId, 'HANDLER_ACCESSES_REMOTE_ENTITY', 'terminal', 'call', String(call.id), target.toKind, target.toId, Number(call.confidence ?? 0.5), JSON.stringify({ ...evidence, ...target.evidence, remoteEntityAccess: entityKind }), 0, generation);
|
|
94
|
+
return { status: 'terminal', callType };
|
|
95
|
+
}
|
|
96
|
+
if (callType === 'remote_query' && (isEntityQueryIntent || !op) && !resolution.target) {
|
|
97
|
+
const target = buildRemoteQueryTarget({ queryEntity: call.query_entity, servicePath, serviceAlias: call.alias, serviceAliasExpr: call.aliasExpr, destination: destination ? applyVariables(destination, vars) : undefined, isDynamic, parserWarning: evidence.parserWarning });
|
|
98
|
+
db.prepare('INSERT INTO graph_edges(workspace_id,edge_type,status,from_kind,from_id,to_kind,to_id,confidence,evidence_json,is_dynamic,generation) VALUES(?,?,?,?,?,?,?,?,?,?,?)').run(workspaceId, 'HANDLER_RUNS_REMOTE_QUERY', 'terminal', 'call', String(call.id), target.toKind, target.toId, Number(call.confidence ?? 0.5), JSON.stringify({ ...evidence, ...target.evidence }), 0, generation);
|
|
99
|
+
return { status: 'terminal', callType };
|
|
100
|
+
}
|
|
101
|
+
if (callType === 'local_service_call' && call.unresolved_reason === 'transport_client_method' && !resolution.target && resolution.candidates.length === 0) {
|
|
102
|
+
db.prepare('INSERT INTO graph_edges(workspace_id,edge_type,status,from_kind,from_id,to_kind,to_id,confidence,evidence_json,is_dynamic,generation) VALUES(?,?,?,?,?,?,?,?,?,?,?)').run(workspaceId, 'HANDLER_CALLS_TRANSPORT_METHOD', 'terminal', 'call', String(call.id), 'transport_method', String(op || 'transport_client_method'), Number(call.confidence ?? 0.5), JSON.stringify({ ...evidence, classification: 'transport_client_method' }), 0, generation);
|
|
103
|
+
return { status: 'terminal', callType };
|
|
104
|
+
}
|
|
105
|
+
if (resolution.target) {
|
|
106
|
+
db.prepare('INSERT INTO graph_edges(workspace_id,edge_type,status,from_kind,from_id,to_kind,to_id,confidence,evidence_json,is_dynamic,generation) VALUES(?,?,?,?,?,?,?,?,?,?,?)').run(workspaceId, callType === 'local_service_call' ? 'LOCAL_CALL_RESOLVES_TO_OPERATION' : 'REMOTE_CALL_RESOLVES_TO_OPERATION', 'resolved', 'call', String(call.id), 'operation', String(resolution.target.operationId), resolution.target.score, JSON.stringify(evidence), 0, generation);
|
|
107
|
+
return { status: 'resolved', callType };
|
|
108
|
+
}
|
|
109
|
+
const edgeType = callType === 'local_db_query' ? 'HANDLER_RUNS_DB_QUERY' : callType === 'external_http' ? 'HANDLER_CALLS_EXTERNAL_HTTP' : callType === 'async_emit' ? 'HANDLER_EMITS_EVENT' : callType === 'async_subscribe' ? 'EVENT_CONSUMED_BY_HANDLER' : resolution.status === 'dynamic' ? 'DYNAMIC_EDGE_CANDIDATE' : 'UNRESOLVED_EDGE';
|
|
110
|
+
const status = edgeType === 'DYNAMIC_EDGE_CANDIDATE' ? 'dynamic' : resolution.status === 'ambiguous' ? 'ambiguous' : edgeType === 'UNRESOLVED_EDGE' ? 'unresolved' : 'terminal';
|
|
111
|
+
const unresolvedReason = status === 'terminal' ? null : String(call.unresolved_reason ?? unresolvedOperationReason(resolution));
|
|
112
|
+
const externalTarget = callType === 'external_http' ? externalHttpTarget(call) : undefined;
|
|
113
|
+
const targetKind = callType === 'local_db_query' ? 'db_entity' : callType.startsWith('async_') ? 'event' : callType === 'external_http' ? (externalTarget?.toKind ?? 'external_endpoint') : 'operation_candidate';
|
|
114
|
+
const targetId = callType === 'local_db_query' ? String(call.query_entity ?? 'unknown') : callType === 'remote_action' ? (op ? `Remote action: ${op}` : (call.unresolved_reason === 'dynamic_operation_path_identifier' ? 'Remote action: dynamic path' : 'Remote action: unknown path')) : callType === 'external_http' ? String(externalTarget?.toId ?? 'unknown') : String(call.event_name_expr ?? op ?? 'unknown');
|
|
115
|
+
const graphLevelDynamic = edgeType === 'DYNAMIC_EDGE_CANDIDATE' && resolution.status === 'dynamic';
|
|
116
|
+
const finalEvidence = externalTarget ? { ...evidence, externalTarget } : evidence;
|
|
117
|
+
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, edgeType, status, 'call', String(call.id), targetKind, targetId, Number(call.confidence ?? 0.2), JSON.stringify(finalEvidence), graphLevelDynamic ? 1 : 0, unresolvedReason, generation);
|
|
118
|
+
return { status, callType };
|
|
119
|
+
}
|
|
120
|
+
function operationCandidateCount(db: Db, workspaceId: number, operationPath: string | undefined, operationName: string | undefined): number {
|
|
121
|
+
if (!operationPath && !operationName) return 0;
|
|
122
|
+
const normalizedName = operationName ?? operationPath?.replace(/^\//, '').split('.').at(-1);
|
|
123
|
+
const row = db.prepare(`SELECT COUNT(*) count 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_path=? OR o.operation_path=? OR o.operation_name=?)`).get(workspaceId, operationPath, normalizedName ? `/${normalizedName}` : operationPath, normalizedName) as { count?: number } | undefined;
|
|
124
|
+
return Number(row?.count ?? 0);
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
function unresolvedOperationReason(resolution: { candidates: unknown[]; status: string; reasons: string[] }): string {
|
|
128
|
+
if (resolution.status === 'dynamic') return `Dynamic target requires runtime variable overrides: ${(resolution.reasons.length ? resolution.reasons : ['missing runtime variables']).join(', ')}`;
|
|
129
|
+
if (resolution.candidates.length === 0) return 'No indexed target operation matched';
|
|
130
|
+
if (resolution.reasons.includes('operation_path_only_has_no_strong_target_signal')) return 'Operation candidates found but no strong service signal is available';
|
|
131
|
+
if (resolution.reasons.includes('candidate_score_below_resolution_threshold')) return 'Operation candidates found but resolution score is below threshold';
|
|
132
|
+
if (resolution.status === 'ambiguous') return 'Ambiguous operation candidates require a strong service signal';
|
|
133
|
+
return 'Operation candidates found but resolution could not select a target';
|
|
134
|
+
}
|
|
135
|
+
function parseJson(value: unknown): unknown {
|
|
136
|
+
if (!value) return undefined;
|
|
137
|
+
try {
|
|
138
|
+
return JSON.parse(String(value)) as unknown;
|
|
139
|
+
} catch {
|
|
140
|
+
return undefined;
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
function objectJson(value: unknown): Record<string, unknown> | undefined {
|
|
144
|
+
const parsed = parseJson(value);
|
|
145
|
+
return parsed && typeof parsed === 'object' && !Array.isArray(parsed) ? parsed as Record<string, unknown> : undefined;
|
|
146
|
+
}
|
|
147
|
+
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> {
|
|
148
|
+
const bindingHasDynamicExpression = Boolean(Number(call.isDynamic ?? 0));
|
|
149
|
+
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, invocationArgumentPlaceholderKeys: normalized?.invocationArgumentPlaceholderKeys.length ? normalized.invocationArgumentPlaceholderKeys : 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, 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 };
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
function linkImplementations(db: Db, workspaceId: number, generation: number): { edgeCount: number; resolvedCount: number; ambiguousCount: number; unresolvedCount: number } {
|
|
153
|
+
const operations = db.prepare(`SELECT o.id operationId,o.operation_path operationPath,o.operation_name operationName,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>>;
|
|
154
|
+
let edgeCount = 0;
|
|
155
|
+
let resolvedCount = 0;
|
|
156
|
+
let ambiguousCount = 0;
|
|
157
|
+
let unresolvedCount = 0;
|
|
158
|
+
for (const operation of operations) {
|
|
159
|
+
const candidates = rankedImplementationCandidates(db, workspaceId, operation);
|
|
160
|
+
if (candidates.length === 0) continue;
|
|
161
|
+
const accepted = candidates.filter((candidate) => candidate.accepted);
|
|
162
|
+
const topScore = accepted[0]?.score ?? 0;
|
|
163
|
+
const winners = accepted.filter((candidate) => candidate.score === topScore);
|
|
164
|
+
const unique = winners.length === 1 ? winners[0] : undefined;
|
|
165
|
+
const evidence = {
|
|
166
|
+
servicePath: operation.servicePath,
|
|
167
|
+
operationPath: operation.operationPath,
|
|
168
|
+
operationName: operation.operationName,
|
|
169
|
+
modelPackage: { id: operation.modelRepoId, name: operation.modelRepo, packageName: operation.modelPackage },
|
|
170
|
+
candidates: candidates.map((candidate, index) => candidateEvidence(candidate, index + 1)),
|
|
171
|
+
};
|
|
172
|
+
if (accepted.length === 0) {
|
|
173
|
+
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(evidence), 0, 'No implementation candidate passed policy', generation);
|
|
174
|
+
edgeCount += 1;
|
|
175
|
+
unresolvedCount += 1;
|
|
176
|
+
continue;
|
|
177
|
+
}
|
|
178
|
+
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) : winners.map((row) => graphId(row.methodId)).join(','), unique ? 0.95 : 0.5, JSON.stringify(evidence), 0, unique ? null : 'Ambiguous registered handler implementation candidates', generation);
|
|
179
|
+
edgeCount += 1;
|
|
180
|
+
if (unique) resolvedCount += 1;
|
|
181
|
+
else ambiguousCount += 1;
|
|
182
|
+
}
|
|
183
|
+
return { edgeCount, resolvedCount, ambiguousCount, unresolvedCount };
|
|
184
|
+
}
|
|
185
|
+
interface ImplementationCandidate extends Record<string, unknown> {
|
|
186
|
+
methodId: number;
|
|
187
|
+
registrations?: Array<Record<string, unknown>>;
|
|
188
|
+
score: number;
|
|
189
|
+
accepted: boolean;
|
|
190
|
+
acceptedReasons: string[];
|
|
191
|
+
rejectedReasons: string[];
|
|
192
|
+
}
|
|
193
|
+
function rankedImplementationCandidates(db: Db, workspaceId: number, operation: Record<string, unknown>): ImplementationCandidate[] {
|
|
194
|
+
const rows = implementationCandidates(db, workspaceId, operation);
|
|
195
|
+
return deduplicateCandidates(rows.map((row) => scoreImplementationCandidate(row, operation))).sort((a, b) => b.score - a.score || String(a.className).localeCompare(String(b.className)) || a.methodId - b.methodId);
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
function deduplicateCandidates(rows: ImplementationCandidate[]): ImplementationCandidate[] {
|
|
199
|
+
const merged = new Map<string, ImplementationCandidate>();
|
|
200
|
+
for (const row of rows) {
|
|
201
|
+
const key = [row.methodId, row.classId, row.handlerRepoId].join(':');
|
|
202
|
+
const registration = { file: row.registrationFile, line: row.registrationLine, kind: row.registrationKind, importSource: row.importSource };
|
|
203
|
+
const existing = merged.get(key);
|
|
204
|
+
if (!existing) {
|
|
205
|
+
merged.set(key, { ...row, registrations: [registration] });
|
|
206
|
+
continue;
|
|
207
|
+
}
|
|
208
|
+
existing.registrations = uniqueRegistrations([...(existing.registrations ?? []), registration]);
|
|
209
|
+
existing.score = Math.max(existing.score, row.score);
|
|
210
|
+
existing.accepted = existing.accepted || row.accepted;
|
|
211
|
+
existing.acceptedReasons = [...new Set([...existing.acceptedReasons, ...row.acceptedReasons])];
|
|
212
|
+
existing.rejectedReasons = [...new Set([...existing.rejectedReasons, ...row.rejectedReasons])];
|
|
213
|
+
}
|
|
214
|
+
return [...merged.values()];
|
|
215
|
+
}
|
|
216
|
+
function uniqueRegistrations(rows: Array<Record<string, unknown>>): Array<Record<string, unknown>> {
|
|
217
|
+
const seen = new Set<string>();
|
|
218
|
+
return rows.filter((row) => {
|
|
219
|
+
const key = JSON.stringify(row);
|
|
220
|
+
if (seen.has(key)) return false;
|
|
221
|
+
seen.add(key);
|
|
222
|
+
return true;
|
|
223
|
+
});
|
|
224
|
+
}
|
|
225
|
+
function implementationCandidates(db: Db, workspaceId: number, operation: Record<string, unknown>): Array<Record<string, unknown>> {
|
|
226
|
+
const modelRepoGraphId = graphId(operation.modelRepoId);
|
|
227
|
+
return db.prepare(`SELECT DISTINCT
|
|
228
|
+
hm.id methodId,
|
|
229
|
+
hm.method_name methodName,
|
|
230
|
+
hm.decorator_value decoratorValue,
|
|
231
|
+
hm.decorator_raw_expression decoratorRawExpression,
|
|
232
|
+
hc.id classId,
|
|
233
|
+
hc.class_name className,
|
|
234
|
+
hc.source_file sourceFile,
|
|
235
|
+
hc.source_line sourceLine,
|
|
236
|
+
hr.repo_id applicationRepoId,
|
|
237
|
+
hr.registration_file registrationFile,
|
|
238
|
+
hr.registration_line registrationLine,
|
|
239
|
+
hr.registration_kind registrationKind,
|
|
240
|
+
hr.import_source importSource,
|
|
241
|
+
handlerRepo.id handlerRepoId,
|
|
242
|
+
handlerRepo.name handlerRepo,
|
|
243
|
+
handlerRepo.package_name handlerPackage,
|
|
244
|
+
appRepo.name applicationRepo,
|
|
245
|
+
appRepo.package_name applicationPackage,
|
|
246
|
+
? modelRepoId,
|
|
247
|
+
? modelRepo,
|
|
248
|
+
? modelPackage,
|
|
249
|
+
? modelKind,
|
|
250
|
+
? servicePath,
|
|
251
|
+
? operationPath,
|
|
252
|
+
? operationName,
|
|
253
|
+
CASE WHEN appRepo.id=? THEN 1 ELSE 0 END modelIsApplicationRepo,
|
|
254
|
+
CASE WHEN handlerRepo.id=? THEN 1 ELSE 0 END modelIsHandlerRepo,
|
|
255
|
+
CASE WHEN appRepo.id=handlerRepo.id THEN 1 ELSE 0 END sameRepoRegistration,
|
|
256
|
+
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,
|
|
257
|
+
CASE WHEN EXISTS (SELECT 1 FROM cds_services localService WHERE localService.repo_id=appRepo.id) THEN 1 ELSE 0 END applicationHasLocalServices,
|
|
258
|
+
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,
|
|
259
|
+
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,
|
|
260
|
+
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,
|
|
261
|
+
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
|
|
262
|
+
FROM handler_methods hm
|
|
263
|
+
JOIN handler_classes hc ON hc.id=hm.handler_class_id
|
|
264
|
+
JOIN repositories handlerRepo ON handlerRepo.id=hc.repo_id
|
|
265
|
+
JOIN handler_registrations hr ON (hr.handler_class_id=hc.id OR (hr.class_name=hc.class_name AND (hr.repo_id=hc.repo_id OR hr.import_source IS NOT NULL)))
|
|
266
|
+
JOIN repositories appRepo ON appRepo.id=hr.repo_id
|
|
267
|
+
WHERE appRepo.workspace_id=?
|
|
268
|
+
AND (hm.decorator_value=? OR hm.decorator_value=? OR hm.method_name=? OR hm.decorator_raw_expression LIKE ?)`).all(
|
|
269
|
+
operation.modelRepoId,
|
|
270
|
+
operation.modelRepo,
|
|
271
|
+
operation.modelPackage,
|
|
272
|
+
operation.modelKind,
|
|
273
|
+
operation.servicePath,
|
|
274
|
+
operation.operationPath,
|
|
275
|
+
operation.operationName,
|
|
276
|
+
operation.modelRepoId,
|
|
277
|
+
operation.modelRepoId,
|
|
278
|
+
operation.servicePath,
|
|
279
|
+
normalizedOperation(String(operation.operationPath ?? '')),
|
|
280
|
+
operation.operationName,
|
|
281
|
+
operation.operationName,
|
|
282
|
+
`%${upperFirst(normalizedOperation(String(operation.operationPath ?? operation.operationName ?? '')))}%`,
|
|
283
|
+
modelRepoGraphId,
|
|
284
|
+
modelRepoGraphId,
|
|
285
|
+
workspaceId,
|
|
286
|
+
normalizedOperation(String(operation.operationPath ?? '')),
|
|
287
|
+
operation.operationName,
|
|
288
|
+
operation.operationName,
|
|
289
|
+
`%${upperFirst(normalizedOperation(String(operation.operationPath ?? operation.operationName ?? '')))}%`,
|
|
290
|
+
) as Array<Record<string, unknown>>;
|
|
291
|
+
}
|
|
292
|
+
function scoreImplementationCandidate(row: Record<string, unknown>, operation: Record<string, unknown>): ImplementationCandidate {
|
|
293
|
+
const acceptedReasons: string[] = [];
|
|
294
|
+
const rejectedReasons: string[] = [];
|
|
295
|
+
let score = 0;
|
|
296
|
+
const modelIsApplicationRepo = flag(row.modelIsApplicationRepo);
|
|
297
|
+
const modelIsHandlerRepo = flag(row.modelIsHandlerRepo);
|
|
298
|
+
const localServicePathMatch = flag(row.localServicePathMatch);
|
|
299
|
+
const applicationHasLocalServices = flag(row.applicationHasLocalServices);
|
|
300
|
+
const appDependsOnModel = flag(row.appDependsOnModel);
|
|
301
|
+
const applicationHasLocalRegistrationForOperation = flag(row.applicationHasLocalRegistrationForOperation);
|
|
302
|
+
const appDependsOnHandler = flag(row.appDependsOnHandler);
|
|
303
|
+
const handlerDependsOnModel = flag(row.handlerDependsOnModel);
|
|
304
|
+
const importSource = typeof row.importSource === 'string' && row.importSource.length > 0;
|
|
305
|
+
const sameRepoRegistration = flag(row.sameRepoRegistration);
|
|
306
|
+
const modelOriented = row.modelKind === 'cap-db-model' || !applicationHasLocalRegistrationForOperation;
|
|
307
|
+
const methodSignal = implementationMethodSignal(row, operation);
|
|
308
|
+
const methodMatches = methodSignal.matches;
|
|
309
|
+
acceptedReasons.push(...methodSignal.acceptedReasons);
|
|
310
|
+
rejectedReasons.push(...methodSignal.rejectedReasons);
|
|
311
|
+
const registeredAndLinked = sameRepoRegistration && importSource;
|
|
312
|
+
const helperOwned = modelOriented && methodMatches && registeredAndLinked && sameRepoRegistration && !applicationHasLocalServices && !modelIsApplicationRepo && !modelIsHandlerRepo && !localServicePathMatch && !appDependsOnModel && !appDependsOnHandler && !handlerDependsOnModel;
|
|
313
|
+
if (modelIsApplicationRepo) {
|
|
314
|
+
score += 100;
|
|
315
|
+
acceptedReasons.push('model package equals registration package');
|
|
316
|
+
}
|
|
317
|
+
if (modelIsHandlerRepo) {
|
|
318
|
+
score += 100;
|
|
319
|
+
acceptedReasons.push('model package equals handler package');
|
|
320
|
+
}
|
|
321
|
+
if (localServicePathMatch) {
|
|
322
|
+
score += 80;
|
|
323
|
+
acceptedReasons.push('registration package contains exact local service path');
|
|
324
|
+
} else if (applicationHasLocalServices && !appDependsOnModel && !modelIsApplicationRepo) {
|
|
325
|
+
rejectedReasons.push(`registration package has local services but none match ${String(operation.servicePath ?? '')}`);
|
|
326
|
+
}
|
|
327
|
+
if (appDependsOnModel) {
|
|
328
|
+
score += 70;
|
|
329
|
+
acceptedReasons.push('registration package depends on model package');
|
|
330
|
+
}
|
|
331
|
+
if (appDependsOnHandler) {
|
|
332
|
+
score += 30;
|
|
333
|
+
acceptedReasons.push('registration package depends on handler package');
|
|
334
|
+
}
|
|
335
|
+
if (handlerDependsOnModel) {
|
|
336
|
+
score += 20;
|
|
337
|
+
acceptedReasons.push('handler package depends on model package');
|
|
338
|
+
}
|
|
339
|
+
if (helperOwned) {
|
|
340
|
+
score += 60;
|
|
341
|
+
acceptedReasons.push('unique registered helper implementation for model-only operation');
|
|
342
|
+
}
|
|
343
|
+
if (importSource) {
|
|
344
|
+
score += 10;
|
|
345
|
+
acceptedReasons.push('registration imports handler class');
|
|
346
|
+
}
|
|
347
|
+
const hasOwnership = modelIsApplicationRepo || modelIsHandlerRepo;
|
|
348
|
+
const hasCrossPackage = appDependsOnModel && (modelIsHandlerRepo || appDependsOnHandler || !importSource);
|
|
349
|
+
const contradicted = applicationHasLocalServices && !localServicePathMatch && !appDependsOnModel && !hasOwnership;
|
|
350
|
+
if (!hasOwnership && !localServicePathMatch && !hasCrossPackage && !helperOwned) rejectedReasons.push('missing direct ownership, exact local service path, or validated cross-package dependency evidence');
|
|
351
|
+
const accepted = methodMatches && !methodSignal.contradicted && !contradicted && (hasOwnership || localServicePathMatch || hasCrossPackage || handlerDependsOnModel || helperOwned);
|
|
352
|
+
if (!accepted && rejectedReasons.length === 0) rejectedReasons.push('candidate did not meet implementation ownership policy');
|
|
353
|
+
return { ...row, methodId: Number(row.methodId), score, accepted, acceptedReasons, rejectedReasons };
|
|
354
|
+
}
|
|
355
|
+
function candidateEvidence(candidate: ImplementationCandidate, rank: number): Record<string, unknown> {
|
|
356
|
+
return {
|
|
357
|
+
rank,
|
|
358
|
+
score: candidate.score,
|
|
359
|
+
accepted: candidate.accepted,
|
|
360
|
+
acceptedReasons: candidate.acceptedReasons,
|
|
361
|
+
rejectedReasons: candidate.rejectedReasons,
|
|
362
|
+
methodId: candidate.methodId,
|
|
363
|
+
classId: candidate.classId,
|
|
364
|
+
className: candidate.className,
|
|
365
|
+
sourceFile: candidate.sourceFile,
|
|
366
|
+
sourceLine: candidate.sourceLine,
|
|
367
|
+
registration: { file: candidate.registrationFile, line: candidate.registrationLine, kind: candidate.registrationKind, importSource: candidate.importSource },
|
|
368
|
+
registrations: candidate.registrations ?? [],
|
|
369
|
+
applicationPackage: { id: candidate.applicationRepoId, name: candidate.applicationRepo, packageName: candidate.applicationPackage },
|
|
370
|
+
handlerPackage: { id: candidate.handlerRepoId, name: candidate.handlerRepo, packageName: candidate.handlerPackage },
|
|
371
|
+
modelPackage: { id: candidate.modelRepoId, name: candidate.modelRepo, packageName: candidate.modelPackage },
|
|
372
|
+
servicePath: candidate.servicePath,
|
|
373
|
+
operationPath: candidate.operationPath,
|
|
374
|
+
operationName: candidate.operationName,
|
|
375
|
+
signals: {
|
|
376
|
+
directOwnership: { modelIsApplicationRepo: flag(candidate.modelIsApplicationRepo), modelIsHandlerRepo: flag(candidate.modelIsHandlerRepo) },
|
|
377
|
+
localServicePathMatch: flag(candidate.localServicePathMatch),
|
|
378
|
+
applicationHasLocalServices: flag(candidate.applicationHasLocalServices),
|
|
379
|
+
applicationHasLocalRegistrationForOperation: flag(candidate.applicationHasLocalRegistrationForOperation),
|
|
380
|
+
appDependsOnModel: flag(candidate.appDependsOnModel),
|
|
381
|
+
appDependsOnHandler: flag(candidate.appDependsOnHandler),
|
|
382
|
+
handlerDependsOnModel: flag(candidate.handlerDependsOnModel),
|
|
383
|
+
sameRepoRegistration: flag(candidate.sameRepoRegistration),
|
|
384
|
+
},
|
|
385
|
+
};
|
|
386
|
+
}
|
|
387
|
+
function implementationMethodSignal(row: Record<string, unknown>, operation: Record<string, unknown>): { matches: boolean; contradicted: boolean; acceptedReasons: string[]; rejectedReasons: string[] } {
|
|
388
|
+
const op = normalizedOperationName(String(operation.operationPath ?? operation.operationName ?? ''));
|
|
389
|
+
const decorator = normalizeDecoratorOperationSignal(typeof row.decoratorValue === 'string' ? row.decoratorValue : undefined, typeof row.decoratorRawExpression === 'string' ? row.decoratorRawExpression : undefined, op);
|
|
390
|
+
if (decorator.status === 'resolved' && decorator.operationName === op) return { matches: true, contradicted: false, acceptedReasons: ['decorator targets operation'], rejectedReasons: [] };
|
|
391
|
+
if (decorator.status === 'resolved' && decorator.operationName !== op) return { matches: false, contradicted: true, acceptedReasons: [], rejectedReasons: ['method_name_matches_but_decorator_targets_different_operation'] };
|
|
392
|
+
if (String(row.methodName ?? '') === op) return { matches: true, contradicted: false, acceptedReasons: ['method name fallback matched operation'], rejectedReasons: [] };
|
|
393
|
+
return { matches: false, contradicted: false, acceptedReasons: [], rejectedReasons: ['method name does not match operation'] };
|
|
394
|
+
}
|
|
395
|
+
function upperFirst(value: string): string {
|
|
396
|
+
return value ? `${value[0]?.toUpperCase() ?? ''}${value.slice(1)}` : value;
|
|
397
|
+
}
|
|
398
|
+
function flag(value: unknown): boolean {
|
|
399
|
+
return Boolean(Number(value ?? 0));
|
|
400
|
+
}
|
|
401
|
+
function graphId(value: unknown): string {
|
|
402
|
+
return String(value);
|
|
403
|
+
}
|
|
404
|
+
function normalizedOperation(value: string): string {
|
|
405
|
+
return value.startsWith('/') ? value.slice(1) : value;
|
|
406
|
+
}
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
export interface RuntimeSubstitution {
|
|
2
|
+
original?: string;
|
|
3
|
+
effective?: string;
|
|
4
|
+
placeholders: string[];
|
|
5
|
+
missing: string[];
|
|
6
|
+
supplied: string[];
|
|
7
|
+
changed: boolean;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
const PLACEHOLDER = /\$\{([^}]*)\}/g;
|
|
11
|
+
|
|
12
|
+
export function applyVariables(
|
|
13
|
+
template: string | undefined,
|
|
14
|
+
vars: Record<string, string>,
|
|
15
|
+
): string | undefined {
|
|
16
|
+
return substituteVariables(template, vars).effective;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export function extractPlaceholders(template: string | undefined): string[] {
|
|
20
|
+
return [...(template ?? '').matchAll(PLACEHOLDER)]
|
|
21
|
+
.map((m) => (m[1] ?? '').trim())
|
|
22
|
+
.filter(Boolean);
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export function substituteVariables(
|
|
26
|
+
template: string | undefined,
|
|
27
|
+
vars: Record<string, string>,
|
|
28
|
+
): RuntimeSubstitution {
|
|
29
|
+
if (!template) return { placeholders: [], missing: [], supplied: [], changed: false };
|
|
30
|
+
const placeholders = [...new Set(extractPlaceholders(template))];
|
|
31
|
+
const supplied = placeholders.filter((key) => Object.hasOwn(vars, key));
|
|
32
|
+
const missing = placeholders.filter((key) => !Object.hasOwn(vars, key));
|
|
33
|
+
const effective = template.replace(PLACEHOLDER, (_m, key: string) => {
|
|
34
|
+
const trimmed = key.trim();
|
|
35
|
+
return Object.hasOwn(vars, trimmed) ? vars[trimmed] ?? '' : `\${${trimmed}}`;
|
|
36
|
+
});
|
|
37
|
+
return {
|
|
38
|
+
original: template,
|
|
39
|
+
effective,
|
|
40
|
+
placeholders,
|
|
41
|
+
missing,
|
|
42
|
+
supplied,
|
|
43
|
+
changed: effective !== template,
|
|
44
|
+
};
|
|
45
|
+
}
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import { createHash } from 'node:crypto';
|
|
2
|
+
|
|
3
|
+
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
|
+
const sensitiveKeys = new Set(['token','access_token','id_token','api_key','apikey','key','password','passwd','pwd','secret','client_secret','authorization','cookie','signature']);
|
|
6
|
+
function hash(value: string): string { return createHash('sha256').update(value).digest('hex').slice(0, 12); }
|
|
7
|
+
function methodPrefix(method: unknown): string { return typeof method === 'string' && method.length > 0 ? `${method.toUpperCase()} ` : ''; }
|
|
8
|
+
export function redactUrl(value: string): string {
|
|
9
|
+
try {
|
|
10
|
+
const url = new URL(value, value.startsWith('/') ? 'https://relative.invalid' : undefined);
|
|
11
|
+
url.username = ''; url.password = '';
|
|
12
|
+
for (const key of [...url.searchParams.keys()]) url.searchParams.set(key, sensitiveKeys.has(key.toLowerCase()) ? '<redacted>' : '<redacted>');
|
|
13
|
+
const path = `${url.pathname}${url.search ? url.search : ''}`;
|
|
14
|
+
return value.startsWith('/') ? path : `${url.origin}${path}`;
|
|
15
|
+
} catch {
|
|
16
|
+
return value.replace(/([?&][^=;&]*(?:token|key|password|secret|cookie|authorization)[^=;&]*=)[^&]*/gi, '$1<redacted>');
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
export function externalHttpTarget(call: Record<string, unknown>): ExternalHttpTarget {
|
|
20
|
+
const evidence = typeof call.evidence_json === 'string' ? safeParse(call.evidence_json) : {};
|
|
21
|
+
const target = evidence.externalTarget && typeof evidence.externalTarget === 'object' && !Array.isArray(evidence.externalTarget) ? evidence.externalTarget as Record<string, unknown> : {};
|
|
22
|
+
const method = typeof call.method === 'string' ? call.method : typeof target.method === 'string' ? target.method : undefined;
|
|
23
|
+
const kind = typeof target.kind === 'string' ? target.kind : 'unknown';
|
|
24
|
+
const expression = typeof target.expression === 'string' ? target.expression : undefined;
|
|
25
|
+
if (kind === 'destination' && target.dynamic === true) {
|
|
26
|
+
const shape = typeof target.expressionShape === 'string' ? target.expressionShape : 'expression';
|
|
27
|
+
const candidates = Array.isArray(target.candidateLiterals) ? target.candidateLiterals.filter((item): item is string => typeof item === 'string') : [];
|
|
28
|
+
return { kind, toKind: 'external_destination', toId: `destination:dynamic:${hash(`${shape}:${candidates.join('|')}`)}`, label: 'External destination: dynamic destination', method, dynamic: true, expression: candidates.length ? `candidates:${candidates.join('|')}` : `shape:${shape}` };
|
|
29
|
+
}
|
|
30
|
+
if (kind === 'destination' && expression) return { kind, toKind: 'external_destination', toId: `destination:${expression}`, label: `External destination: ${expression}`, method, dynamic: false, expression };
|
|
31
|
+
if (kind === 'static_url' && expression) {
|
|
32
|
+
const redacted = redactUrl(expression);
|
|
33
|
+
return { kind, toKind: 'external_endpoint', toId: `endpoint:${hash(`${method ?? ''}:${redacted}`)}`, label: `External endpoint: ${methodPrefix(method)}${redacted}`, method, dynamic: false, expression: redacted };
|
|
34
|
+
}
|
|
35
|
+
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
|
+
return { kind: 'unknown', toKind: 'external_endpoint', toId: 'unknown', label: 'External endpoint: unknown', method, dynamic: false };
|
|
37
|
+
}
|
|
38
|
+
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 {}; } }
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import type { Db } from '../db/connection.js';
|
|
2
|
+
|
|
3
|
+
interface RepoDependencyRow {
|
|
4
|
+
id: number;
|
|
5
|
+
name: string;
|
|
6
|
+
package_name: string | null;
|
|
7
|
+
dependencies_json: string;
|
|
8
|
+
}
|
|
9
|
+
export interface DependencyLinkSummary {
|
|
10
|
+
edgeCount: number;
|
|
11
|
+
resolvedCount: number;
|
|
12
|
+
ambiguousCount: number;
|
|
13
|
+
}
|
|
14
|
+
interface CandidateResult {
|
|
15
|
+
candidates: RepoDependencyRow[];
|
|
16
|
+
strategy: 'exact_package_name' | 'normalized_directory';
|
|
17
|
+
}
|
|
18
|
+
function normalizeName(value: string): string {
|
|
19
|
+
return value.toLowerCase().replace(/^@[^/]+\//, '').replace(/[^a-z0-9]+/g, '');
|
|
20
|
+
}
|
|
21
|
+
function candidatesForDependency(repos: RepoDependencyRow[], dep: string, sourceId: number): CandidateResult {
|
|
22
|
+
const exact = repos.filter((repo) => repo.id !== sourceId && repo.package_name === dep);
|
|
23
|
+
if (exact.length > 0) return { candidates: exact, strategy: 'exact_package_name' };
|
|
24
|
+
const normalized = normalizeName(dep);
|
|
25
|
+
return { candidates: repos.filter((repo) => repo.id !== sourceId && normalizeName(repo.name) === normalized), strategy: 'normalized_directory' };
|
|
26
|
+
}
|
|
27
|
+
export function linkHelperPackages(db: Db, workspaceId: number, generation: number): DependencyLinkSummary {
|
|
28
|
+
const repos = db.prepare('SELECT id,name,package_name,dependencies_json FROM repositories WHERE workspace_id=?').all(workspaceId) as unknown as RepoDependencyRow[];
|
|
29
|
+
const summary: DependencyLinkSummary = { edgeCount: 0, resolvedCount: 0, ambiguousCount: 0 };
|
|
30
|
+
for (const repo of repos) {
|
|
31
|
+
const deps = JSON.parse(repo.dependencies_json) as Record<string, string>;
|
|
32
|
+
for (const dep of Object.keys(deps)) {
|
|
33
|
+
const result = candidatesForDependency(repos, dep, repo.id);
|
|
34
|
+
if (result.candidates.length === 0) continue;
|
|
35
|
+
const status = result.candidates.length === 1 ? 'resolved' : 'ambiguous';
|
|
36
|
+
const helper = status === 'resolved' ? result.candidates[0] : undefined;
|
|
37
|
+
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
|
+
workspaceId,
|
|
39
|
+
'REPO_IMPORTS_HELPER_PACKAGE',
|
|
40
|
+
status,
|
|
41
|
+
'repo',
|
|
42
|
+
String(repo.id),
|
|
43
|
+
helper ? 'repo' : 'repo_candidates',
|
|
44
|
+
helper ? String(helper.id) : result.candidates.map((candidate) => candidate.id).join(','),
|
|
45
|
+
helper ? 1 : 0.5,
|
|
46
|
+
JSON.stringify({ dependency: dep, candidates: result.candidates.map((candidate) => ({ id: candidate.id, name: candidate.name, packageName: candidate.package_name })), match: result.strategy }),
|
|
47
|
+
0,
|
|
48
|
+
helper ? null : 'Ambiguous dependency package candidates',
|
|
49
|
+
generation,
|
|
50
|
+
);
|
|
51
|
+
summary.edgeCount += 1;
|
|
52
|
+
if (helper) summary.resolvedCount += 1;
|
|
53
|
+
else summary.ambiguousCount += 1;
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
return summary;
|
|
57
|
+
}
|