@saptools/service-flow 0.1.5 → 0.1.7
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 +18 -0
- package/README.md +29 -1
- package/TECHNICAL-NOTE.md +11 -1
- package/dist/{chunk-I33ASGX3.js → chunk-YFT57U54.js} +186 -155
- package/dist/chunk-YFT57U54.js.map +1 -0
- package/dist/cli.js +246 -143
- package/dist/cli.js.map +1 -1
- package/dist/index.d.ts +19 -3
- package/dist/index.js +5 -1
- package/dist/index.js.map +1 -1
- package/package.json +2 -2
- package/dist/chunk-I33ASGX3.js.map +0 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,23 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.1.7
|
|
4
|
+
|
|
5
|
+
- Added atomic last-good repository publication so failed source reads preserve the previous complete snapshot and fingerprint.
|
|
6
|
+
- Added explicit graph freshness metadata and stale diagnostics after successful fact changes until relink.
|
|
7
|
+
- Persisted handler registration class/import evidence and linked registered cross-package implementation handlers through application dependency evidence.
|
|
8
|
+
- Made service-only trace selectors return a typed narrowing diagnostic instead of workspace-wide traversal.
|
|
9
|
+
- Expanded link summaries with dependency and implementation categories whose totals reconcile with persisted graph edges.
|
|
10
|
+
- Preserved terminal parser warning evidence separately from routing status.
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
## 0.1.6
|
|
14
|
+
|
|
15
|
+
- Scoped runtime variable resolution to eligible dynamic, ambiguous, or unresolved remote edges with matching placeholders, preserving terminal and static resolved edge status, target, reason, and confidence.
|
|
16
|
+
- Clamped operation-resolution confidence to the `[0, 1]` range and retained original runtime expressions alongside effective substitutions and missing-variable evidence.
|
|
17
|
+
- Resolved helper/package dependency graph edges primarily by indexed package name, persisted ambiguous dependency candidates with evidence, and marked inserted helper edges with explicit statuses.
|
|
18
|
+
- Expanded repository fingerprints to include normalized package metadata, full `cds.requires`, scripts, package file content, and the analyzer version so metadata-only changes invalidate stale facts.
|
|
19
|
+
- Replaced the hard-coded CLI version with package metadata as the release source of truth and documented selector, graph variable, SQLite, fingerprint, freshness, and parser-warning semantics.
|
|
20
|
+
|
|
3
21
|
## 0.1.5
|
|
4
22
|
|
|
5
23
|
- Runtime `--var` substitution now re-runs operation resolution in memory for trace and graph output, clears stale unresolved reasons on exact matches, and traverses into the matched downstream handler without mutating the persisted graph.
|
package/README.md
CHANGED
|
@@ -47,10 +47,21 @@ npm install @saptools/service-flow
|
|
|
47
47
|
```
|
|
48
48
|
|
|
49
49
|
> [!NOTE]
|
|
50
|
-
> Requires **Node.js ≥
|
|
50
|
+
> Requires **Node.js ≥ 24.0.0** for the bundled `node:sqlite` runtime. Version 0.1.7 uses a persistent SQLite driver (`node:sqlite` in supported Node builds) for bound parameters, transactions, WAL, busy timeouts, and read-only query commands. The analyzer is static: it reads files and package metadata, but it does not start CAP services, connect to SAP BTP, or execute application code.
|
|
51
51
|
|
|
52
52
|
---
|
|
53
53
|
|
|
54
|
+
|
|
55
|
+
### Correctness notes for 0.1.7
|
|
56
|
+
|
|
57
|
+
- Runtime `--var` values are considered only for dynamic, ambiguous, or unresolved **remote** graph edges whose alias, destination, service path, or operation path expressions contain supplied placeholders. Local database, external HTTP, event, and already resolved static edges keep their persisted status, target, reason, and confidence. Partial substitutions remain dynamic and report the missing placeholder names.
|
|
58
|
+
- `trace` and `graph` both accept repeatable `--var key=value` options. Effective substitutions are rendered in trace evidence without mutating the persisted graph. Confidence values are bounded to `[0, 1]`.
|
|
59
|
+
- Repository selectors on list, trace, graph, and inspect commands narrow scope. Unknown selectors return empty machine-readable diagnostics instead of falling back to the whole workspace.
|
|
60
|
+
- Helper-package dependency edges prefer exact indexed package names. Duplicate package-name candidates are persisted as ambiguous evidence rather than silently selecting one repository.
|
|
61
|
+
- Repository fingerprints include source content, package name/version, dependencies and devDependencies, scripts, normalized `cds.requires` (including nested credentials), package file content, and the analyzer version. Metadata-only changes therefore trigger reindexing.
|
|
62
|
+
- Index publication is designed around the last-good snapshot: failed parse or persistence attempts are recorded as diagnostics and must not be mixed with older graph facts. After indexing changes, relink before relying on graph/trace output; doctor reports stale or inconsistent stores where detectable.
|
|
63
|
+
- Parser warnings describe analysis completeness, while routing status describes graph behavior. A terminal DB edge can remain terminal while still exposing parser warning evidence about an unknown entity.
|
|
64
|
+
|
|
54
65
|
## 🚀 Quick Start
|
|
55
66
|
|
|
56
67
|
```bash
|
|
@@ -425,3 +436,20 @@ MIT
|
|
|
425
436
|
---
|
|
426
437
|
|
|
427
438
|
Made with ❤️ to make your work life easier!
|
|
439
|
+
|
|
440
|
+
|
|
441
|
+
### Service-only trace policy
|
|
442
|
+
|
|
443
|
+
`service-flow trace --service <path>` is intentionally not a broad workspace traversal. Provide `--operation`, `--path`, or `--handler`; otherwise the command returns a typed `trace_start_not_found` diagnostic and no edges.
|
|
444
|
+
|
|
445
|
+
### Graph freshness and last-good snapshots
|
|
446
|
+
|
|
447
|
+
Repository facts are parsed before publication and committed atomically. Failed indexing attempts record diagnostics and retain the last complete published snapshot and successful fingerprint. Successful fact publication marks the workspace graph stale until `service-flow link` rebuilds dependency, remote-call, and implementation edges for the current fact generation.
|
|
448
|
+
|
|
449
|
+
### Cross-package implementation evidence
|
|
450
|
+
|
|
451
|
+
Handler registrations persist parsed class names and import sources. Linking resolves implementation edges only through registered application evidence plus model and handler package dependency edges; a decorator-name match alone is not enough.
|
|
452
|
+
|
|
453
|
+
### Graph variables
|
|
454
|
+
|
|
455
|
+
The `graph` command accepts repeatable `--var key=value` options, matching `trace`, for runtime substitution previews in JSON or Mermaid output.
|
package/TECHNICAL-NOTE.md
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
# Service Flow 0.1.
|
|
1
|
+
# Service Flow 0.1.7 Resolution Notes
|
|
2
2
|
|
|
3
3
|
- Imported helper bindings: TypeScript imports are resolved for relative modules. When a caller assigns `const client = await connectToService()`, the analyzer follows the imported symbol to an exported helper that returns `cds.connect.to(...)` and persists caller-variable evidence plus the helper source/export chain.
|
|
4
4
|
- Candidate ranking: operation-path matches start as weak candidates. A resolved operation edge requires a strong signal such as exact service path, CDS alias/destination context, or explicit dynamic variable overrides. Otherwise candidates are preserved in edge evidence as ambiguous or unresolved.
|
|
@@ -19,3 +19,13 @@
|
|
|
19
19
|
- Edge evidence and target operation provenance are separate: call file/line and helper chains stay on the edge, while operation nodes are loaded from CDS operation/service/repository rows.
|
|
20
20
|
- The linker stores explicit edge status categories and summary counts. Valid DB, event, and external terminals keep `unresolved_reason` null.
|
|
21
21
|
- Repository-level fingerprints include source paths/hashes, package dependencies, and analyzer schema version. Unchanged repositories are skipped unless `--force` is used.
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
## 0.1.7 correctness additions
|
|
25
|
+
|
|
26
|
+
- Runtime resolution now has an explicit eligibility gate: only remote dynamic/ambiguous/unresolved graph edges with affected placeholders are re-resolved in memory. Terminal and resolved static edges are copied through unchanged, and substitutions keep original expressions, effective values, supplied variables, and missing variables separate.
|
|
27
|
+
- Operation candidate scores are clamped into `[0, 1]` before graph or trace rendering.
|
|
28
|
+
- Helper package linking uses exact `repositories.package_name` matches before normalized directory-name fallback. Ambiguous package names are represented as ambiguous graph edges with all candidates in evidence.
|
|
29
|
+
- Fingerprints hash normalized package facts and package bytes in addition to source file paths/content and analyzer version.
|
|
30
|
+
- The CLI version imports package metadata, so package metadata, `service-flow --version`, changelog, and analyzer/fingerprint version share one release source.
|
|
31
|
+
- Supported runtime is Node.js 24+ with `node:sqlite` validation; older runtimes should fail with a compatibility message instead of a late `DatabaseSync` error.
|
|
@@ -336,6 +336,14 @@ function lineOf2(text, idx) {
|
|
|
336
336
|
async function parseHandlerRegistrations(repoPath, filePath) {
|
|
337
337
|
const text = await fs5.readFile(path6.join(repoPath, filePath), "utf8");
|
|
338
338
|
const out = [];
|
|
339
|
+
const imports = /* @__PURE__ */ new Map();
|
|
340
|
+
for (const m of text.matchAll(/import\s+\{?\s*([A-Za-z0-9_,\s]+)\s*\}?\s+from\s+['"]([^'"]+)['"]/g)) {
|
|
341
|
+
const source = m[2];
|
|
342
|
+
for (const name of (m[1] ?? "").split(",")) {
|
|
343
|
+
const symbol = name.trim().split(/\s+as\s+/).pop()?.trim();
|
|
344
|
+
if (symbol) imports.set(symbol, source);
|
|
345
|
+
}
|
|
346
|
+
}
|
|
339
347
|
for (const m of text.matchAll(
|
|
340
348
|
/createCombinedHandler\s*\(|srv\.prepend\s*\(|cds\.serve\s*\(/g
|
|
341
349
|
))
|
|
@@ -351,6 +359,7 @@ async function parseHandlerRegistrations(repoPath, filePath) {
|
|
|
351
359
|
for (const c of (m[1] ?? "").matchAll(/\b(\w+Handler)\b/g))
|
|
352
360
|
out.push({
|
|
353
361
|
className: c[1],
|
|
362
|
+
importSource: imports.get(c[1]),
|
|
354
363
|
registrationFile: normalizePath(filePath),
|
|
355
364
|
registrationLine: lineOf2(text, (m.index ?? 0) + (c.index ?? 0)),
|
|
356
365
|
registrationKind: "handler-array",
|
|
@@ -873,12 +882,30 @@ function collectClassHelpers(sf) {
|
|
|
873
882
|
}
|
|
874
883
|
|
|
875
884
|
// src/linker/dynamic-edge-resolver.ts
|
|
885
|
+
var PLACEHOLDER = /\$\{\s*(\w+)\s*\}/g;
|
|
876
886
|
function applyVariables(template, vars) {
|
|
877
|
-
|
|
878
|
-
|
|
879
|
-
|
|
880
|
-
|
|
887
|
+
return substituteVariables(template, vars).effective;
|
|
888
|
+
}
|
|
889
|
+
function extractPlaceholders(template) {
|
|
890
|
+
return [...(template ?? "").matchAll(PLACEHOLDER)].map((m) => m[1] ?? "").filter(Boolean);
|
|
891
|
+
}
|
|
892
|
+
function substituteVariables(template, vars) {
|
|
893
|
+
if (!template) return { placeholders: [], missing: [], supplied: [], changed: false };
|
|
894
|
+
const placeholders2 = [...new Set(extractPlaceholders(template))];
|
|
895
|
+
const supplied = placeholders2.filter((key) => Object.hasOwn(vars, key));
|
|
896
|
+
const missing = placeholders2.filter((key) => !Object.hasOwn(vars, key));
|
|
897
|
+
const effective = template.replace(
|
|
898
|
+
PLACEHOLDER,
|
|
899
|
+
(_m, key) => Object.hasOwn(vars, key) ? vars[key] ?? "" : `\${${key}}`
|
|
881
900
|
);
|
|
901
|
+
return {
|
|
902
|
+
original: template,
|
|
903
|
+
effective,
|
|
904
|
+
placeholders: placeholders2,
|
|
905
|
+
missing,
|
|
906
|
+
supplied,
|
|
907
|
+
changed: effective !== template
|
|
908
|
+
};
|
|
882
909
|
}
|
|
883
910
|
|
|
884
911
|
// src/linker/service-resolver.ts
|
|
@@ -936,6 +963,7 @@ function resolveOperation(db, signals, workspaceId) {
|
|
|
936
963
|
c.reasons.push("explicit_dynamic_override");
|
|
937
964
|
}
|
|
938
965
|
}
|
|
966
|
+
for (const c of candidates) c.score = Math.max(0, Math.min(1, c.score));
|
|
939
967
|
candidates.sort(
|
|
940
968
|
(a, b) => b.score - a.score || a.repoName.localeCompare(b.repoName)
|
|
941
969
|
);
|
|
@@ -968,138 +996,125 @@ function resolveOperation(db, signals, workspaceId) {
|
|
|
968
996
|
}
|
|
969
997
|
|
|
970
998
|
// src/linker/helper-package-linker.ts
|
|
971
|
-
function
|
|
972
|
-
|
|
973
|
-
|
|
974
|
-
|
|
975
|
-
|
|
999
|
+
function normalizeName(value) {
|
|
1000
|
+
return value.toLowerCase().replace(/^@[^/]+\//, "").replace(/[^a-z0-9]+/g, "");
|
|
1001
|
+
}
|
|
1002
|
+
function candidatesForDependency(repos, dep, sourceId) {
|
|
1003
|
+
const exact = repos.filter((repo) => repo.id !== sourceId && repo.package_name === dep);
|
|
1004
|
+
if (exact.length > 0) return { candidates: exact, strategy: "exact_package_name" };
|
|
1005
|
+
const normalized = normalizeName(dep);
|
|
1006
|
+
return { candidates: repos.filter((repo) => repo.id !== sourceId && normalizeName(repo.name) === normalized), strategy: "normalized_directory" };
|
|
1007
|
+
}
|
|
1008
|
+
function linkHelperPackages(db, workspaceId, generation) {
|
|
1009
|
+
const repos = db.prepare("SELECT id,name,package_name,dependencies_json FROM repositories WHERE workspace_id=?").all(workspaceId);
|
|
1010
|
+
const summary = { edgeCount: 0, resolvedCount: 0, ambiguousCount: 0 };
|
|
976
1011
|
for (const repo of repos) {
|
|
977
1012
|
const deps = JSON.parse(repo.dependencies_json);
|
|
978
1013
|
for (const dep of Object.keys(deps)) {
|
|
979
|
-
const
|
|
980
|
-
if (
|
|
981
|
-
|
|
982
|
-
|
|
983
|
-
|
|
984
|
-
|
|
985
|
-
|
|
986
|
-
|
|
987
|
-
|
|
988
|
-
|
|
989
|
-
|
|
990
|
-
|
|
991
|
-
|
|
992
|
-
|
|
993
|
-
|
|
994
|
-
|
|
995
|
-
|
|
1014
|
+
const result = candidatesForDependency(repos, dep, repo.id);
|
|
1015
|
+
if (result.candidates.length === 0) continue;
|
|
1016
|
+
const status = result.candidates.length === 1 ? "resolved" : "ambiguous";
|
|
1017
|
+
const helper = status === "resolved" ? result.candidates[0] : void 0;
|
|
1018
|
+
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(
|
|
1019
|
+
workspaceId,
|
|
1020
|
+
"REPO_IMPORTS_HELPER_PACKAGE",
|
|
1021
|
+
status,
|
|
1022
|
+
"repo",
|
|
1023
|
+
String(repo.id),
|
|
1024
|
+
helper ? "repo" : "repo_candidates",
|
|
1025
|
+
helper ? String(helper.id) : result.candidates.map((candidate) => candidate.id).join(","),
|
|
1026
|
+
helper ? 1 : 0.5,
|
|
1027
|
+
JSON.stringify({ dependency: dep, candidates: result.candidates.map((candidate) => ({ id: candidate.id, name: candidate.name, packageName: candidate.package_name })), match: result.strategy }),
|
|
1028
|
+
0,
|
|
1029
|
+
helper ? null : "Ambiguous dependency package candidates",
|
|
1030
|
+
generation
|
|
1031
|
+
);
|
|
1032
|
+
summary.edgeCount += 1;
|
|
1033
|
+
if (helper) summary.resolvedCount += 1;
|
|
1034
|
+
else summary.ambiguousCount += 1;
|
|
996
1035
|
}
|
|
997
1036
|
}
|
|
998
|
-
return
|
|
1037
|
+
return summary;
|
|
999
1038
|
}
|
|
1000
1039
|
|
|
1001
1040
|
// src/linker/cross-repo-linker.ts
|
|
1002
1041
|
function linkWorkspace(db, workspaceId, vars = {}) {
|
|
1003
1042
|
return db.transaction(() => {
|
|
1043
|
+
const generation = nextGraphGeneration(db, workspaceId);
|
|
1004
1044
|
db.prepare("DELETE FROM graph_edges WHERE workspace_id=?").run(workspaceId);
|
|
1005
|
-
|
|
1006
|
-
|
|
1007
|
-
|
|
1008
|
-
|
|
1009
|
-
|
|
1010
|
-
let terminalCount = 0;
|
|
1011
|
-
const calls = db.prepare(
|
|
1012
|
-
`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=?`
|
|
1013
|
-
).all(workspaceId);
|
|
1014
|
-
for (const call of calls) {
|
|
1015
|
-
const callType = String(call.call_type);
|
|
1016
|
-
const op = applyVariables(String(call.operation_path_expr ?? ""), vars);
|
|
1017
|
-
const servicePath = applyVariables(
|
|
1018
|
-
call.servicePathExpr ?? call.requireServicePath,
|
|
1019
|
-
vars
|
|
1020
|
-
);
|
|
1021
|
-
const destination = call.destinationExpr ?? call.requireDestination;
|
|
1022
|
-
const isDynamic = Boolean(Number(call.isDynamic ?? 0));
|
|
1023
|
-
const resolution = callType.startsWith("remote") ? resolveOperation(
|
|
1024
|
-
db,
|
|
1025
|
-
{
|
|
1026
|
-
servicePath,
|
|
1027
|
-
operationPath: op,
|
|
1028
|
-
alias: applyVariables(call.aliasExpr ?? call.alias, vars),
|
|
1029
|
-
destination: applyVariables(destination, vars),
|
|
1030
|
-
isDynamic,
|
|
1031
|
-
hasExplicitOverride: Object.keys(vars).length > 0
|
|
1032
|
-
},
|
|
1033
|
-
workspaceId
|
|
1034
|
-
) : { status: "unresolved", candidates: [], reasons: [] };
|
|
1035
|
-
const target = resolution.target;
|
|
1036
|
-
const evidence = {
|
|
1037
|
-
sourceFile: call.source_file,
|
|
1038
|
-
sourceLine: call.source_line,
|
|
1039
|
-
file: call.source_file,
|
|
1040
|
-
line: call.source_line,
|
|
1041
|
-
repo: call.repoName,
|
|
1042
|
-
serviceAlias: call.alias,
|
|
1043
|
-
serviceAliasExpr: call.aliasExpr,
|
|
1044
|
-
destination: applyVariables(destination, vars),
|
|
1045
|
-
servicePath,
|
|
1046
|
-
operationPath: op,
|
|
1047
|
-
targetRepo: target?.repoName,
|
|
1048
|
-
targetOperation: target?.operationName,
|
|
1049
|
-
helperChain: call.helperChainJson ? JSON.parse(String(call.helperChainJson)) : void 0,
|
|
1050
|
-
candidates: resolution.candidates,
|
|
1051
|
-
candidateCount: resolution.candidates.length,
|
|
1052
|
-
resolutionStatus: resolution.status,
|
|
1053
|
-
resolutionReasons: resolution.reasons
|
|
1054
|
-
};
|
|
1055
|
-
if (target) {
|
|
1056
|
-
db.prepare(
|
|
1057
|
-
"INSERT INTO graph_edges(workspace_id,edge_type,status,from_kind,from_id,to_kind,to_id,confidence,evidence_json,is_dynamic) VALUES(?,?,?,?,?,?,?,?,?,?)"
|
|
1058
|
-
).run(
|
|
1059
|
-
workspaceId,
|
|
1060
|
-
"REMOTE_CALL_RESOLVES_TO_OPERATION",
|
|
1061
|
-
"resolved",
|
|
1062
|
-
"call",
|
|
1063
|
-
String(call.id),
|
|
1064
|
-
"operation",
|
|
1065
|
-
String(target.operationId),
|
|
1066
|
-
target.score,
|
|
1067
|
-
JSON.stringify(evidence),
|
|
1068
|
-
isDynamic ? 1 : 0
|
|
1069
|
-
);
|
|
1070
|
-
edges += 1;
|
|
1071
|
-
resolvedCount += 1;
|
|
1072
|
-
} else {
|
|
1073
|
-
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";
|
|
1074
|
-
const status = edgeType === "DYNAMIC_EDGE_CANDIDATE" ? "dynamic" : resolution.status === "ambiguous" ? "ambiguous" : edgeType === "UNRESOLVED_EDGE" ? "unresolved" : "terminal";
|
|
1075
|
-
const unresolvedReason = status === "terminal" ? null : String(
|
|
1076
|
-
call.unresolved_reason ?? (resolution.status === "ambiguous" ? "Ambiguous operation candidates require a strong service signal" : resolution.status === "dynamic" ? `Dynamic target requires runtime variable overrides: ${(resolution.reasons.length ? resolution.reasons : ["missing runtime variables"]).join(", ")}` : "No indexed target operation matched")
|
|
1077
|
-
);
|
|
1078
|
-
db.prepare(
|
|
1079
|
-
"INSERT INTO graph_edges(workspace_id,edge_type,status,from_kind,from_id,to_kind,to_id,confidence,evidence_json,is_dynamic,unresolved_reason) VALUES(?,?,?,?,?,?,?,?,?,?,?)"
|
|
1080
|
-
).run(
|
|
1081
|
-
workspaceId,
|
|
1082
|
-
edgeType,
|
|
1083
|
-
status,
|
|
1084
|
-
"call",
|
|
1085
|
-
String(call.id),
|
|
1086
|
-
callType.startsWith("async_") ? "event" : "external",
|
|
1087
|
-
String(call.event_name_expr ?? call.query_entity ?? op ?? call.id),
|
|
1088
|
-
Number(call.confidence ?? 0.2),
|
|
1089
|
-
JSON.stringify(evidence),
|
|
1090
|
-
isDynamic || resolution.status === "dynamic" ? 1 : 0,
|
|
1091
|
-
unresolvedReason
|
|
1092
|
-
);
|
|
1093
|
-
edges += 1;
|
|
1094
|
-
unresolved += status === "unresolved" ? 1 : 0;
|
|
1095
|
-
ambiguousCount += status === "ambiguous" ? 1 : 0;
|
|
1096
|
-
dynamicCount += status === "dynamic" ? 1 : 0;
|
|
1097
|
-
terminalCount += status === "terminal" ? 1 : 0;
|
|
1098
|
-
}
|
|
1099
|
-
}
|
|
1100
|
-
return { edgeCount: edges, unresolvedCount: unresolved, resolvedCount, ambiguousCount, dynamicCount, terminalCount };
|
|
1045
|
+
const deps = linkHelperPackages(db, workspaceId, generation);
|
|
1046
|
+
const callSummary = linkCalls(db, workspaceId, vars, generation);
|
|
1047
|
+
const impl = linkImplementations(db, workspaceId, generation);
|
|
1048
|
+
db.prepare("UPDATE repositories SET graph_generation=?, graph_stale_reason=NULL, graph_stale_at=NULL WHERE workspace_id=?").run(generation, workspaceId);
|
|
1049
|
+
return { ...callSummary, edgeCount: deps.edgeCount + callSummary.edgeCount + impl.edgeCount, dependencyResolvedCount: deps.resolvedCount, dependencyAmbiguousCount: deps.ambiguousCount, implementationResolvedCount: impl.resolvedCount, implementationAmbiguousCount: impl.ambiguousCount };
|
|
1101
1050
|
});
|
|
1102
1051
|
}
|
|
1052
|
+
function nextGraphGeneration(db, workspaceId) {
|
|
1053
|
+
const row = db.prepare("SELECT COALESCE(MAX(graph_generation),0) generation FROM repositories WHERE workspace_id=?").get(workspaceId);
|
|
1054
|
+
return Number(row?.generation ?? 0) + 1;
|
|
1055
|
+
}
|
|
1056
|
+
function linkCalls(db, workspaceId, vars, generation) {
|
|
1057
|
+
let edgeCount = 0;
|
|
1058
|
+
let unresolvedCount = 0;
|
|
1059
|
+
let resolvedCount = 0;
|
|
1060
|
+
let ambiguousCount = 0;
|
|
1061
|
+
let dynamicCount = 0;
|
|
1062
|
+
let terminalCount = 0;
|
|
1063
|
+
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);
|
|
1064
|
+
for (const call of calls) {
|
|
1065
|
+
const result = insertCallEdge(db, workspaceId, call, vars, generation);
|
|
1066
|
+
edgeCount += 1;
|
|
1067
|
+
resolvedCount += result.status === "resolved" ? 1 : 0;
|
|
1068
|
+
unresolvedCount += result.status === "unresolved" ? 1 : 0;
|
|
1069
|
+
ambiguousCount += result.status === "ambiguous" ? 1 : 0;
|
|
1070
|
+
dynamicCount += result.status === "dynamic" ? 1 : 0;
|
|
1071
|
+
terminalCount += result.status === "terminal" ? 1 : 0;
|
|
1072
|
+
}
|
|
1073
|
+
return { edgeCount, unresolvedCount, resolvedCount, ambiguousCount, dynamicCount, terminalCount };
|
|
1074
|
+
}
|
|
1075
|
+
function insertCallEdge(db, workspaceId, call, vars, generation) {
|
|
1076
|
+
const callType = String(call.call_type);
|
|
1077
|
+
const op = applyVariables(String(call.operation_path_expr ?? ""), vars);
|
|
1078
|
+
const servicePath = applyVariables(call.servicePathExpr ?? call.requireServicePath, vars);
|
|
1079
|
+
const destination = call.destinationExpr ?? call.requireDestination;
|
|
1080
|
+
const isDynamic = Boolean(Number(call.isDynamic ?? 0));
|
|
1081
|
+
const resolution = callType.startsWith("remote") ? resolveOperation(db, { servicePath, operationPath: op, alias: applyVariables(call.aliasExpr ?? call.alias, vars), destination: destination ? applyVariables(destination, vars) : void 0, isDynamic, hasExplicitOverride: Object.keys(vars).length > 0 }, workspaceId) : { status: "unresolved", candidates: [], reasons: [] };
|
|
1082
|
+
const evidence = callEvidence(call, resolution, servicePath, op, destination ? applyVariables(destination, vars) : void 0);
|
|
1083
|
+
if (resolution.target) {
|
|
1084
|
+
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), isDynamic ? 1 : 0, generation);
|
|
1085
|
+
return { status: "resolved" };
|
|
1086
|
+
}
|
|
1087
|
+
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";
|
|
1088
|
+
const status = edgeType === "DYNAMIC_EDGE_CANDIDATE" ? "dynamic" : resolution.status === "ambiguous" ? "ambiguous" : edgeType === "UNRESOLVED_EDGE" ? "unresolved" : "terminal";
|
|
1089
|
+
const unresolvedReason = status === "terminal" ? null : String(call.unresolved_reason ?? (resolution.status === "ambiguous" ? "Ambiguous operation candidates require a strong service signal" : resolution.status === "dynamic" ? `Dynamic target requires runtime variable overrides: ${(resolution.reasons.length ? resolution.reasons : ["missing runtime variables"]).join(", ")}` : "No indexed target operation matched"));
|
|
1090
|
+
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), callType.startsWith("async_") ? "event" : "external", String(call.event_name_expr ?? call.query_entity ?? op ?? call.id), Number(call.confidence ?? 0.2), JSON.stringify(evidence), isDynamic || resolution.status === "dynamic" ? 1 : 0, unresolvedReason, generation);
|
|
1091
|
+
return { status };
|
|
1092
|
+
}
|
|
1093
|
+
function callEvidence(call, resolution, servicePath, op, destination) {
|
|
1094
|
+
return { sourceFile: call.source_file, sourceLine: call.source_line, file: call.source_file, line: call.source_line, repo: call.repoName, serviceAlias: call.alias, serviceAliasExpr: call.aliasExpr, destination, servicePath, operationPath: op, targetRepo: resolution.target?.repoName, targetOperation: resolution.target?.operationName, helperChain: call.helperChainJson ? JSON.parse(String(call.helperChainJson)) : void 0, candidates: resolution.candidates, candidateCount: resolution.candidates.length, resolutionStatus: resolution.status, resolutionReasons: resolution.reasons, analysisCompleteness: call.unresolved_reason ? "partial" : "complete", parserWarning: call.unresolved_reason ? { code: "parser_warning", message: call.unresolved_reason } : void 0 };
|
|
1095
|
+
}
|
|
1096
|
+
function linkImplementations(db, workspaceId, generation) {
|
|
1097
|
+
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.package_name modelPackage 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);
|
|
1098
|
+
let edgeCount = 0;
|
|
1099
|
+
let resolvedCount = 0;
|
|
1100
|
+
let ambiguousCount = 0;
|
|
1101
|
+
for (const operation of operations) {
|
|
1102
|
+
const rows2 = implementationCandidates(db, workspaceId, operation);
|
|
1103
|
+
if (rows2.length === 0) continue;
|
|
1104
|
+
const unique = rows2.length === 1 ? rows2[0] : void 0;
|
|
1105
|
+
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", String(operation.operationId), unique ? "handler_method" : "handler_method_candidates", unique ? String(unique.methodId) : rows2.map((row) => row.methodId).join(","), unique ? 0.95 : 0.5, JSON.stringify({ servicePath: operation.servicePath, operationPath: operation.operationPath, operationName: operation.operationName, candidates: rows2, evidence: "registered_application_dependency" }), 0, unique ? null : "Ambiguous registered handler implementation candidates", generation);
|
|
1106
|
+
edgeCount += 1;
|
|
1107
|
+
if (unique) resolvedCount += 1;
|
|
1108
|
+
else ambiguousCount += 1;
|
|
1109
|
+
}
|
|
1110
|
+
return { edgeCount, resolvedCount, ambiguousCount };
|
|
1111
|
+
}
|
|
1112
|
+
function implementationCandidates(db, workspaceId, operation) {
|
|
1113
|
+
return db.prepare(`SELECT DISTINCT hm.id methodId,hc.id classId,hc.class_name className,hc.source_file sourceFile,hc.source_line sourceLine,hr.repo_id applicationRepoId,handlerRepo.name handlerRepo,appRepo.name applicationRepo FROM handler_methods hm JOIN handler_classes hc ON hc.id=hm.handler_class_id JOIN repositories handlerRepo ON handlerRepo.id=hc.repo_id JOIN handler_registrations hr ON hr.class_name=hc.class_name JOIN repositories appRepo ON appRepo.id=hr.repo_id JOIN graph_edges modelDep ON modelDep.edge_type='REPO_IMPORTS_HELPER_PACKAGE' AND modelDep.status='resolved' AND modelDep.from_kind='repo' AND modelDep.from_id=CAST(appRepo.id AS TEXT) AND modelDep.to_id=CAST(? AS TEXT) JOIN graph_edges handlerDep ON handlerDep.edge_type='REPO_IMPORTS_HELPER_PACKAGE' AND handlerDep.status='resolved' AND handlerDep.from_kind='repo' AND handlerDep.from_id=CAST(appRepo.id AS TEXT) AND handlerDep.to_id=CAST(handlerRepo.id AS TEXT) WHERE appRepo.workspace_id=? AND (hm.decorator_value=? OR hm.decorator_value=? OR hm.method_name=?)`).all(operation.modelRepoId, workspaceId, normalizedOperation(String(operation.operationPath ?? "")), operation.operationName, operation.operationName);
|
|
1114
|
+
}
|
|
1115
|
+
function normalizedOperation(value) {
|
|
1116
|
+
return value.startsWith("/") ? value.slice(1) : value;
|
|
1117
|
+
}
|
|
1103
1118
|
|
|
1104
1119
|
// src/trace/trace-engine.ts
|
|
1105
1120
|
function normalizeOperation(value) {
|
|
@@ -1147,8 +1162,10 @@ function startScope(db, start) {
|
|
|
1147
1162
|
if (start.repo && !repo) return { repo, selectorMatched: false };
|
|
1148
1163
|
const sourceFiles = sourceFilesForStart(db, repo?.id, start);
|
|
1149
1164
|
const hasSelector = Boolean(
|
|
1150
|
-
start.handler ?? start.operation ?? start.operationPath
|
|
1165
|
+
start.handler ?? start.operation ?? start.operationPath ?? start.servicePath
|
|
1151
1166
|
);
|
|
1167
|
+
if (start.servicePath && !start.operation && !start.operationPath && !start.handler)
|
|
1168
|
+
return { repo, selectorMatched: false };
|
|
1152
1169
|
return {
|
|
1153
1170
|
repo,
|
|
1154
1171
|
sourceFiles,
|
|
@@ -1169,6 +1186,12 @@ function handlerFilesForOperation(db, operationId) {
|
|
|
1169
1186
|
).all(op.repoId, operation, operation, op.operationName);
|
|
1170
1187
|
return new Set(rows2.map((row) => row.sourceFile).filter(Boolean));
|
|
1171
1188
|
}
|
|
1189
|
+
function implementationScope(db, operationId) {
|
|
1190
|
+
const edge = db.prepare("SELECT * FROM graph_edges WHERE edge_type='OPERATION_IMPLEMENTED_BY_HANDLER' AND status='resolved' AND from_kind='operation' AND from_id=? ORDER BY id LIMIT 1").get(operationId);
|
|
1191
|
+
if (!edge) return { files: /* @__PURE__ */ new Set() };
|
|
1192
|
+
const row = db.prepare("SELECT hc.repo_id repoId,hc.source_file sourceFile FROM handler_methods hm JOIN handler_classes hc ON hc.id=hm.handler_class_id WHERE hm.id=?").get(edge.to_id);
|
|
1193
|
+
return { repoId: row?.repoId, files: new Set(row?.sourceFile ? [row.sourceFile] : []) };
|
|
1194
|
+
}
|
|
1172
1195
|
function includeCall(type, options) {
|
|
1173
1196
|
if (!options.includeDb && type === "local_db_query") return false;
|
|
1174
1197
|
if (!options.includeExternal && type === "external_http") return false;
|
|
@@ -1187,30 +1210,30 @@ function graphForCalls(db, callIds) {
|
|
|
1187
1210
|
}
|
|
1188
1211
|
return map;
|
|
1189
1212
|
}
|
|
1190
|
-
function
|
|
1191
|
-
return
|
|
1192
|
-
|
|
1193
|
-
|
|
1213
|
+
function hasRuntimeVariable(value, vars) {
|
|
1214
|
+
return typeof value === "string" && extractPlaceholders(value).some((key) => Object.hasOwn(vars, key));
|
|
1215
|
+
}
|
|
1216
|
+
function isRemoteRuntimeCandidate(row, evidence, vars) {
|
|
1217
|
+
if (!vars || Object.keys(vars).length === 0) return false;
|
|
1218
|
+
if (!["dynamic", "ambiguous", "unresolved"].includes(String(row.status ?? ""))) return false;
|
|
1219
|
+
if (!["DYNAMIC_EDGE_CANDIDATE", "UNRESOLVED_EDGE", "REMOTE_CALL_RESOLVES_TO_OPERATION"].includes(row.edge_type)) return false;
|
|
1220
|
+
if (row.status === "resolved") return false;
|
|
1221
|
+
return ["servicePath", "operationPath", "serviceAliasExpr", "serviceAlias", "destination"].some((key) => hasRuntimeVariable(evidence[key], vars));
|
|
1194
1222
|
}
|
|
1195
1223
|
function evidenceWithRuntimeVariables(evidence, vars) {
|
|
1196
1224
|
if (!vars || Object.keys(vars).length === 0) return evidence;
|
|
1197
|
-
const
|
|
1198
|
-
const
|
|
1199
|
-
|
|
1200
|
-
|
|
1201
|
-
|
|
1202
|
-
|
|
1203
|
-
|
|
1204
|
-
|
|
1205
|
-
|
|
1206
|
-
|
|
1207
|
-
|
|
1208
|
-
|
|
1209
|
-
operationPath: matched.operationPath,
|
|
1210
|
-
operationName: matched.operationName,
|
|
1211
|
-
score: matched.score
|
|
1212
|
-
} : void 0
|
|
1213
|
-
};
|
|
1225
|
+
const substitutions = {};
|
|
1226
|
+
for (const key of ["servicePath", "operationPath", "serviceAliasExpr", "serviceAlias", "destination"]) {
|
|
1227
|
+
const substitution = substituteVariables(typeof evidence[key] === "string" ? String(evidence[key]) : void 0, vars);
|
|
1228
|
+
if (substitution.placeholders.length > 0) substitutions[key] = substitution;
|
|
1229
|
+
}
|
|
1230
|
+
const next = { ...evidence, runtimeVariablesApplied: true, runtimeSubstitutions: substitutions };
|
|
1231
|
+
for (const [key, value] of Object.entries(substitutions)) {
|
|
1232
|
+
if (value.effective) next[key] = value.effective;
|
|
1233
|
+
}
|
|
1234
|
+
const missing = Object.values(substitutions).flatMap((value) => value.missing);
|
|
1235
|
+
if (missing.length > 0) next.missingRuntimeVariables = [...new Set(missing)];
|
|
1236
|
+
return next;
|
|
1214
1237
|
}
|
|
1215
1238
|
function operationNode(db, operationId) {
|
|
1216
1239
|
const row = db.prepare(`SELECT o.id operationId,o.operation_name operationName,o.operation_type operationType,o.operation_path operationPath,o.source_file sourceFile,o.source_line sourceLine,s.id serviceId,s.service_name serviceName,s.qualified_name qualifiedName,s.service_path servicePath,r.id repoId,r.name repoName 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(operationId);
|
|
@@ -1221,20 +1244,22 @@ function workspaceIdForCall(db, callId) {
|
|
|
1221
1244
|
return db.prepare("SELECT r.workspace_id workspaceId FROM outbound_calls c JOIN repositories r ON r.id=c.repo_id WHERE c.id=?").get(callId)?.workspaceId;
|
|
1222
1245
|
}
|
|
1223
1246
|
function runtimeResolution(db, row, evidence, vars) {
|
|
1224
|
-
if (!
|
|
1247
|
+
if (!isRemoteRuntimeCandidate(row, evidence, vars))
|
|
1248
|
+
return { row, evidence, unresolvedReason: row.unresolved_reason };
|
|
1225
1249
|
const nextEvidence = evidenceWithRuntimeVariables(evidence, vars);
|
|
1226
1250
|
const servicePath = typeof nextEvidence.servicePath === "string" ? nextEvidence.servicePath : void 0;
|
|
1227
1251
|
const operationPath = typeof nextEvidence.operationPath === "string" ? nextEvidence.operationPath : void 0;
|
|
1228
|
-
const alias = typeof nextEvidence.serviceAliasExpr === "string" ?
|
|
1229
|
-
const destination = typeof nextEvidence.destination === "string" ?
|
|
1230
|
-
const resolution = resolveOperation(db, { servicePath, operationPath, alias, destination, hasExplicitOverride: true, isDynamic:
|
|
1252
|
+
const alias = typeof nextEvidence.serviceAliasExpr === "string" ? nextEvidence.serviceAliasExpr : typeof nextEvidence.serviceAlias === "string" ? nextEvidence.serviceAlias : void 0;
|
|
1253
|
+
const destination = typeof nextEvidence.destination === "string" ? nextEvidence.destination : void 0;
|
|
1254
|
+
const resolution = resolveOperation(db, { servicePath, operationPath, alias, destination, hasExplicitOverride: true, isDynamic: true }, workspaceIdForCall(db, row.from_id));
|
|
1231
1255
|
nextEvidence.runtimeResolutionStatus = resolution.status;
|
|
1232
1256
|
nextEvidence.runtimeResolutionReasons = resolution.reasons;
|
|
1233
1257
|
if (resolution.target) {
|
|
1234
1258
|
nextEvidence.runtimeResolvedCandidate = resolution.target;
|
|
1235
|
-
return { row: { ...row, to_kind: "operation", to_id: String(resolution.target.operationId), unresolved_reason: void 0, confidence: resolution.target.score }, evidence: nextEvidence, target: resolution.target };
|
|
1259
|
+
return { row: { ...row, to_kind: "operation", to_id: String(resolution.target.operationId), unresolved_reason: void 0, confidence: Math.max(0, Math.min(1, resolution.target.score)) }, evidence: nextEvidence, target: resolution.target };
|
|
1236
1260
|
}
|
|
1237
|
-
|
|
1261
|
+
const unresolvedReason = resolution.status === "dynamic" ? `Dynamic target is missing runtime variables: ${resolution.reasons.join(", ")}` : resolution.status === "ambiguous" ? "Ambiguous runtime operation candidates" : "No runtime operation candidate matched substituted service and operation path";
|
|
1262
|
+
return { row, evidence: nextEvidence, unresolvedReason };
|
|
1238
1263
|
}
|
|
1239
1264
|
function edgeTarget(row, evidence) {
|
|
1240
1265
|
const runtimeCandidate = evidence.runtimeResolvedCandidate;
|
|
@@ -1251,11 +1276,14 @@ function trace(db, start, options) {
|
|
|
1251
1276
|
const diagnostics = db.prepare(
|
|
1252
1277
|
"SELECT severity,code,message,source_file sourceFile,source_line sourceLine FROM diagnostics WHERE (? IS NULL OR repo_id=?)"
|
|
1253
1278
|
).all(scope.repo?.id, scope.repo?.id);
|
|
1279
|
+
const stale = db.prepare("SELECT name,graph_stale_reason reason FROM repositories WHERE graph_stale_reason IS NOT NULL AND (? IS NULL OR id=?)").all(scope.repo?.id, scope.repo?.id);
|
|
1280
|
+
for (const row of stale)
|
|
1281
|
+
diagnostics.unshift({ severity: "warning", code: "graph_stale", message: `Graph is stale for ${row.name ?? "repository"}: ${row.reason ?? "facts_changed"}. Run service-flow link.` });
|
|
1254
1282
|
if (!scope.selectorMatched)
|
|
1255
1283
|
diagnostics.unshift({
|
|
1256
1284
|
severity: "warning",
|
|
1257
1285
|
code: "trace_start_not_found",
|
|
1258
|
-
message: "No handler source matched the requested trace start selector"
|
|
1286
|
+
message: start.servicePath && !start.operation && !start.operationPath && !start.handler ? "Service-only trace requires --operation or --path and will not broaden to the whole workspace" : "No handler source matched the requested trace start selector"
|
|
1259
1287
|
});
|
|
1260
1288
|
const maxDepth = positiveDepth(options.depth);
|
|
1261
1289
|
const edges = [];
|
|
@@ -1317,9 +1345,10 @@ function trace(db, start, options) {
|
|
|
1317
1345
|
unresolvedReason: effective.unresolvedReason
|
|
1318
1346
|
});
|
|
1319
1347
|
if (effectiveRow.to_kind === "operation" && current.depth < maxDepth) {
|
|
1320
|
-
const
|
|
1348
|
+
const implementation = implementationScope(db, effectiveRow.to_id);
|
|
1349
|
+
const files = implementation.files.size > 0 ? implementation.files : handlerFilesForOperation(db, effectiveRow.to_id);
|
|
1321
1350
|
if (files.size > 0) {
|
|
1322
|
-
const targetRepoId = db.prepare(
|
|
1351
|
+
const targetRepoId = implementation.repoId ?? db.prepare(
|
|
1323
1352
|
"SELECT s.repo_id repoId FROM cds_operations o JOIN cds_services s ON s.id=o.service_id WHERE o.id=?"
|
|
1324
1353
|
).get(effectiveRow.to_id)?.repoId;
|
|
1325
1354
|
const nextKey = `${targetRepoId ?? "*"}:${[...files].sort().join(",")}`;
|
|
@@ -1360,7 +1389,9 @@ export {
|
|
|
1360
1389
|
parseOutboundCalls,
|
|
1361
1390
|
parseServiceBindings,
|
|
1362
1391
|
applyVariables,
|
|
1392
|
+
extractPlaceholders,
|
|
1393
|
+
substituteVariables,
|
|
1363
1394
|
linkWorkspace,
|
|
1364
1395
|
trace
|
|
1365
1396
|
};
|
|
1366
|
-
//# sourceMappingURL=chunk-
|
|
1397
|
+
//# sourceMappingURL=chunk-YFT57U54.js.map
|