@webpieces/nx-webpieces-rules 0.4.460 → 0.4.462
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/package.json +6 -6
- package/src/executors/generate/executor.js +24 -4
- package/src/executors/generate/executor.js.map +1 -1
- package/src/executors/validate-runtime-architecture/executor.js +7 -2
- package/src/executors/validate-runtime-architecture/executor.js.map +1 -1
- package/src/executors/visualize-runtime/executor.js +4 -1
- package/src/executors/visualize-runtime/executor.js.map +1 -1
- package/src/lib/api-usage/api-relations.d.ts +21 -1
- package/src/lib/api-usage/api-relations.js +14 -2
- package/src/lib/api-usage/api-relations.js.map +1 -1
- package/src/lib/api-usage/api-scanner.d.ts +2 -0
- package/src/lib/api-usage/api-scanner.js +45 -4
- package/src/lib/api-usage/api-scanner.js.map +1 -1
- package/src/lib/graph-loader.js +1 -0
- package/src/lib/graph-loader.js.map +1 -1
- package/src/lib/graph-metadata.d.ts +2 -0
- package/src/lib/graph-metadata.js +16 -0
- package/src/lib/graph-metadata.js.map +1 -1
- package/src/lib/graph-sorter.d.ts +6 -0
- package/src/lib/graph-sorter.js.map +1 -1
- package/src/lib/graph-visualizer.d.ts +15 -0
- package/src/lib/graph-visualizer.js +37 -5
- package/src/lib/graph-visualizer.js.map +1 -1
- package/src/lib/runtime-config.d.ts +4 -1
- package/src/lib/runtime-config.js +5 -1
- package/src/lib/runtime-config.js.map +1 -1
- package/src/lib/runtime-graph.d.ts +64 -0
- package/src/lib/runtime-graph.js +201 -26
- package/src/lib/runtime-graph.js.map +1 -1
- package/src/lib/runtime-visualizer.d.ts +29 -2
- package/src/lib/runtime-visualizer.js +121 -5
- package/src/lib/runtime-visualizer.js.map +1 -1
- package/src/lib/service-name-resolver.d.ts +49 -0
- package/src/lib/service-name-resolver.js +103 -0
- package/src/lib/service-name-resolver.js.map +1 -0
|
@@ -7,7 +7,8 @@
|
|
|
7
7
|
* "runtime-architecture": {
|
|
8
8
|
* "mode": "ON", // "OFF" disables the whole feature
|
|
9
9
|
* "ignoreModifiedUntilEpoch": 0, // whole-rule punt (epoch seconds)
|
|
10
|
-
* "allowedCycles": [ { "services": ["a","b"], "reason": "...", "until": 1771931925 } ]
|
|
10
|
+
* "allowedCycles": [ { "services": ["a","b"], "reason": "...", "until": 1771931925 } ],
|
|
11
|
+
* "showExternalNodes": true // draw firestore/gmail/... as terminal nodes
|
|
11
12
|
* }
|
|
12
13
|
*/
|
|
13
14
|
import { SkipRuleResult } from '@webpieces/rules-config';
|
|
@@ -22,6 +23,8 @@ export interface RuntimeRuleConfig {
|
|
|
22
23
|
ignoreModifiedUntilEpoch?: number;
|
|
23
24
|
ignoreRuleWhileOnBranch?: string;
|
|
24
25
|
allowedCycles: AllowedCycle[];
|
|
26
|
+
/** Render the dashed external terminal nodes in the runtime viz (default true). */
|
|
27
|
+
showExternalNodes: boolean;
|
|
25
28
|
}
|
|
26
29
|
/** Load the runtime-architecture rule config (with safe defaults). */
|
|
27
30
|
export declare function loadRuntimeConfig(workspaceRoot: string): RuntimeRuleConfig;
|
|
@@ -8,7 +8,8 @@
|
|
|
8
8
|
* "runtime-architecture": {
|
|
9
9
|
* "mode": "ON", // "OFF" disables the whole feature
|
|
10
10
|
* "ignoreModifiedUntilEpoch": 0, // whole-rule punt (epoch seconds)
|
|
11
|
-
* "allowedCycles": [ { "services": ["a","b"], "reason": "...", "until": 1771931925 } ]
|
|
11
|
+
* "allowedCycles": [ { "services": ["a","b"], "reason": "...", "until": 1771931925 } ],
|
|
12
|
+
* "showExternalNodes": true // draw firestore/gmail/... as terminal nodes
|
|
12
13
|
* }
|
|
13
14
|
*/
|
|
14
15
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
@@ -32,6 +33,9 @@ function loadRuntimeConfig(workspaceRoot) {
|
|
|
32
33
|
ignoreModifiedUntilEpoch: typeof raw.ignoreModifiedUntilEpoch === 'number' ? raw.ignoreModifiedUntilEpoch : undefined,
|
|
33
34
|
ignoreRuleWhileOnBranch: typeof raw.ignoreRuleWhileOnBranch === 'string' ? raw.ignoreRuleWhileOnBranch : undefined,
|
|
34
35
|
allowedCycles: Array.isArray(raw.allowedCycles) ? raw.allowedCycles.filter(isUsableCycle) : [],
|
|
36
|
+
// Opt-OUT: the external systems are the ones that page you at 3am, so they are drawn unless
|
|
37
|
+
// a repo explicitly says its external surface is too noisy to be useful.
|
|
38
|
+
showExternalNodes: raw.showExternalNodes !== false,
|
|
35
39
|
};
|
|
36
40
|
}
|
|
37
41
|
/**
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"runtime-config.js","sourceRoot":"","sources":["../../../../../../packages/tooling/nx-webpieces-rules/src/lib/runtime-config.ts"],"names":[],"mappings":";AAAA
|
|
1
|
+
{"version":3,"file":"runtime-config.js","sourceRoot":"","sources":["../../../../../../packages/tooling/nx-webpieces-rules/src/lib/runtime-config.ts"],"names":[],"mappings":";AAAA;;;;;;;;;;;;GAYG;;;AAsCH,8CAeC;AAQD,8CAEC;AAMD,sCAGC;AAGD,8BAEC;AA3ED,0DAA0F;AAE7E,QAAA,iBAAiB,GAAG,sBAAsB,CAAC;AA6BxD,SAAS,aAAa,CAAC,KAAmB;IACtC,OAAO,KAAK,CAAC,OAAO,CAAC,KAAK,CAAC,QAAQ,CAAC,IAAI,KAAK,CAAC,QAAQ,CAAC,MAAM,GAAG,CAAC,CAAC;AACtE,CAAC;AAED,sEAAsE;AACtE,SAAgB,iBAAiB,CAAC,aAAqB;IACnD,MAAM,MAAM,GAAG,IAAA,8BAAe,EAAC,aAAa,CAAC,CAAC,QAAQ,CAAC;IACvD,MAAM,IAAI,GAAG,MAAM,CAAC,KAAK,CAAC,GAAG,CAAC,yBAAiB,CAAC,CAAC;IACjD,MAAM,GAAG,GAAG,CAAC,IAAI,EAAE,OAAO,IAAI,EAAE,CAAmB,CAAC;IACpD,OAAO;QACH,GAAG,EAAE,IAAI,EAAE,KAAK,IAAI,KAAK;QACzB,wBAAwB,EACpB,OAAO,GAAG,CAAC,wBAAwB,KAAK,QAAQ,CAAC,CAAC,CAAC,GAAG,CAAC,wBAAwB,CAAC,CAAC,CAAC,SAAS;QAC/F,uBAAuB,EACnB,OAAO,GAAG,CAAC,uBAAuB,KAAK,QAAQ,CAAC,CAAC,CAAC,GAAG,CAAC,uBAAuB,CAAC,CAAC,CAAC,SAAS;QAC7F,aAAa,EAAE,KAAK,CAAC,OAAO,CAAC,GAAG,CAAC,aAAa,CAAC,CAAC,CAAC,CAAC,GAAG,CAAC,aAAa,CAAC,MAAM,CAAC,aAAa,CAAC,CAAC,CAAC,CAAC,EAAE;QAC9F,4FAA4F;QAC5F,yEAAyE;QACzE,iBAAiB,EAAE,GAAG,CAAC,iBAAiB,KAAK,KAAK;KACrD,CAAC;AACN,CAAC;AAED;;;;;GAKG;AACH,SAAgB,iBAAiB,CAAC,MAAyB;IACvD,OAAO,IAAA,6BAAc,EAAC,MAAM,CAAC,wBAAwB,EAAE,MAAM,CAAC,uBAAuB,CAAC,CAAC;AAC3F,CAAC;AAED;;;GAGG;AACH,SAAgB,aAAa,CAAC,KAAyB;IACnD,IAAI,KAAK,KAAK,SAAS;QAAE,OAAO,KAAK,CAAC;IACtC,OAAO,IAAI,CAAC,GAAG,EAAE,GAAG,IAAI,GAAG,KAAK,CAAC;AACrC,CAAC;AAED,wDAAwD;AACxD,SAAgB,SAAS,CAAC,KAAa;IACnC,OAAO,IAAI,IAAI,CAAC,KAAK,GAAG,IAAI,CAAC,CAAC,WAAW,EAAE,CAAC,KAAK,CAAC,GAAG,CAAC,CAAC,CAAC,CAAC,CAAC;AAC9D,CAAC","sourcesContent":["/**\n * Runtime Config\n *\n * Loads the `runtime-architecture` rule from webpieces.config.json and exposes\n * typed accessors shared by the generate + validate + visualize executors.\n *\n * \"runtime-architecture\": {\n * \"mode\": \"ON\", // \"OFF\" disables the whole feature\n * \"ignoreModifiedUntilEpoch\": 0, // whole-rule punt (epoch seconds)\n * \"allowedCycles\": [ { \"services\": [\"a\",\"b\"], \"reason\": \"...\", \"until\": 1771931925 } ],\n * \"showExternalNodes\": true // draw firestore/gmail/... as terminal nodes\n * }\n */\n\nimport { loadAndValidate, shouldSkipRule, SkipRuleResult } from '@webpieces/rules-config';\n\nexport const RUNTIME_RULE_NAME = 'runtime-architecture';\n\nexport interface AllowedCycle {\n services: string[];\n reason?: string;\n until?: number;\n}\n\nexport interface RuntimeRuleConfig {\n off: boolean;\n ignoreModifiedUntilEpoch?: number;\n ignoreRuleWhileOnBranch?: string;\n allowedCycles: AllowedCycle[];\n /** Render the dashed external terminal nodes in the runtime viz (default true). */\n showExternalNodes: boolean;\n}\n\n/**\n * Typed view of the opaque webpieces.config.json option bag for this rule. The\n * config is trusted (it is the workspace's own file), so we cast once here and\n * defensively narrow arrays/numbers rather than threading `unknown` everywhere.\n */\ninterface RuntimeRuleRaw {\n ignoreModifiedUntilEpoch?: number;\n ignoreRuleWhileOnBranch?: string;\n allowedCycles?: AllowedCycle[];\n showExternalNodes?: boolean;\n}\n\nfunction isUsableCycle(cycle: AllowedCycle): boolean {\n return Array.isArray(cycle.services) && cycle.services.length > 0;\n}\n\n/** Load the runtime-architecture rule config (with safe defaults). */\nexport function loadRuntimeConfig(workspaceRoot: string): RuntimeRuleConfig {\n const shared = loadAndValidate(workspaceRoot).resolved;\n const rule = shared.rules.get(RUNTIME_RULE_NAME);\n const raw = (rule?.options ?? {}) as RuntimeRuleRaw;\n return {\n off: rule?.isOff ?? false,\n ignoreModifiedUntilEpoch:\n typeof raw.ignoreModifiedUntilEpoch === 'number' ? raw.ignoreModifiedUntilEpoch : undefined,\n ignoreRuleWhileOnBranch:\n typeof raw.ignoreRuleWhileOnBranch === 'string' ? raw.ignoreRuleWhileOnBranch : undefined,\n allowedCycles: Array.isArray(raw.allowedCycles) ? raw.allowedCycles.filter(isUsableCycle) : [],\n // Opt-OUT: the external systems are the ones that page you at 3am, so they are drawn unless\n // a repo explicitly says its external surface is too noisy to be useful.\n showExternalNodes: raw.showExternalNodes !== false,\n };\n}\n\n/**\n * Whole-rule report-only window honoring BOTH escape hatches: skip while on the\n * named branch (ignoreRuleWhileOnBranch) or until the epoch passes\n * (ignoreModifiedUntilEpoch). When `.skip` is true, problems are reported but\n * the build is not failed.\n */\nexport function runtimeReportOnly(config: RuntimeRuleConfig): SkipRuleResult {\n return shouldSkipRule(config.ignoreModifiedUntilEpoch, config.ignoreRuleWhileOnBranch);\n}\n\n/**\n * Whole-rule grace window: while now < epoch, failures are reported but do not\n * fail the build (warn). Mirrors the other webpieces rules.\n */\nexport function isGraceActive(epoch: number | undefined): boolean {\n if (epoch === undefined) return false;\n return Date.now() / 1000 < epoch;\n}\n\n/** Format the epoch as an ISO date for log messages. */\nexport function epochDate(epoch: number): string {\n return new Date(epoch * 1000).toISOString().split('T')[0];\n}\n"]}
|
|
@@ -11,13 +11,35 @@
|
|
|
11
11
|
* The runtime edge Z -> X (Z depends on X at runtime) is INFERRED: Z `uses` api
|
|
12
12
|
* Y and X `implements` api Y. This edge does not exist in the compile-time
|
|
13
13
|
* dependencies.json (both Z and X only compile-depend on the api library Y).
|
|
14
|
+
*
|
|
15
|
+
* WHICH X is decided by the call site, not by "everyone who implements Y":
|
|
16
|
+
* `createRpcClient(Y, new ClientConfig('helper-fsdb'))` names its target, kept as
|
|
17
|
+
* `ApiRef.targetService`, and matched against each node's DECLARED `serviceName`
|
|
18
|
+
* (project.json metadata.webpieces.serviceName). Fanning an edge out to every
|
|
19
|
+
* implementer instead is catastrophic for a company-wide contract registered in a
|
|
20
|
+
* shared library — it manufactures calls that cannot happen, and cycles that do
|
|
21
|
+
* not exist. When a target cannot be resolved the old fan-out still happens, but
|
|
22
|
+
* a warning names the call site (see RuntimeGraphReport.warnings): a wrong-but-
|
|
23
|
+
* green graph is worse than a failing one, so it must never degrade silently.
|
|
14
24
|
*/
|
|
15
25
|
import type { EnhancedGraph } from './graph-sorter';
|
|
16
26
|
import type { ApiTransport } from './api-usage/api-relations';
|
|
17
27
|
export declare const DEFAULT_RUNTIME_GRAPH_PATH = "architecture/runtime-dependencies.json";
|
|
18
28
|
export interface RuntimeService {
|
|
19
29
|
level: number;
|
|
30
|
+
/**
|
|
31
|
+
* The name clients address this service by (`new ClientConfig('helper-fsdb')`), declared in its
|
|
32
|
+
* project.json. Absent for a service nothing calls by name (e.g. a browser app).
|
|
33
|
+
*/
|
|
34
|
+
serviceName?: string;
|
|
20
35
|
implements: string[];
|
|
36
|
+
/**
|
|
37
|
+
* apiClassName -> the LIBRARY project whose apiRelations declared that implements, for the apis
|
|
38
|
+
* this service serves through an embedded library rather than its own source (e.g. a shared
|
|
39
|
+
* route-registration lib). Answers "who implements WarmupApi, and where did that come from?",
|
|
40
|
+
* which previously required walking the dependsOn closure by hand.
|
|
41
|
+
*/
|
|
42
|
+
implementsVia?: Record<string, string>;
|
|
21
43
|
uses: string[];
|
|
22
44
|
dependsOn: string[];
|
|
23
45
|
/**
|
|
@@ -34,6 +56,12 @@ export interface RuntimeApi {
|
|
|
34
56
|
usedBy: string[];
|
|
35
57
|
/** Transport of this API — 'rpc' (direct call) or 'pubsub' (delivered through a queue). */
|
|
36
58
|
type?: ApiTransport;
|
|
59
|
+
/**
|
|
60
|
+
* The api-lib project that OWNS this contract. For a contract nothing in-repo implements, this
|
|
61
|
+
* is the external library the calls leave the repo through (`lib-firestore`, `lib-gmail`), which
|
|
62
|
+
* is what the runtime viz labels its terminal external nodes with.
|
|
63
|
+
*/
|
|
64
|
+
owner?: string;
|
|
37
65
|
}
|
|
38
66
|
export interface RuntimeEdge {
|
|
39
67
|
from: string;
|
|
@@ -56,6 +84,35 @@ export interface RuntimeGraph {
|
|
|
56
84
|
runtimeEdges: RuntimeEdge[];
|
|
57
85
|
unresolvedUses: RuntimeUnresolved[];
|
|
58
86
|
}
|
|
87
|
+
/**
|
|
88
|
+
* The derived graph PLUS everything the derivation had to guess at. `warnings` is deliberately not
|
|
89
|
+
* part of RuntimeGraph: it is not committed data, it is the report that stops a guessed edge from
|
|
90
|
+
* passing for a derived one. Executors print it.
|
|
91
|
+
*/
|
|
92
|
+
export declare class RuntimeGraphReport {
|
|
93
|
+
readonly graph: RuntimeGraph;
|
|
94
|
+
/** Human-readable lines naming every call site whose target the graph could not pin down. */
|
|
95
|
+
readonly warnings: string[];
|
|
96
|
+
/**
|
|
97
|
+
* Call sites that name a target the repo does not contain. Unlike a warning these FAIL the
|
|
98
|
+
* build: the contract IS served in-repo, so the name is a typo or a stale rename, and the
|
|
99
|
+
* only reason it stayed invisible is that the graph quietly fanned the edge out instead.
|
|
100
|
+
* (A call to a service outside the repo never reaches here — nothing in-repo implements its
|
|
101
|
+
* contract, so it is `unresolvedUses`.)
|
|
102
|
+
*/
|
|
103
|
+
readonly problems: string[];
|
|
104
|
+
constructor(graph: RuntimeGraph,
|
|
105
|
+
/** Human-readable lines naming every call site whose target the graph could not pin down. */
|
|
106
|
+
warnings: string[],
|
|
107
|
+
/**
|
|
108
|
+
* Call sites that name a target the repo does not contain. Unlike a warning these FAIL the
|
|
109
|
+
* build: the contract IS served in-repo, so the name is a typo or a stale rename, and the
|
|
110
|
+
* only reason it stayed invisible is that the graph quietly fanned the edge out instead.
|
|
111
|
+
* (A call to a service outside the repo never reaches here — nothing in-repo implements its
|
|
112
|
+
* contract, so it is `unresolvedUses`.)
|
|
113
|
+
*/
|
|
114
|
+
problems?: string[]);
|
|
115
|
+
}
|
|
59
116
|
/** Adjacency (service -> [targets]) from a loaded runtime graph. */
|
|
60
117
|
export declare function runtimeAdjacency(graph: RuntimeGraph): Record<string, string[]>;
|
|
61
118
|
/**
|
|
@@ -65,6 +122,13 @@ export declare function runtimeAdjacency(graph: RuntimeGraph): Record<string, st
|
|
|
65
122
|
* runtime visualizer omits their nodes + edges.
|
|
66
123
|
*/
|
|
67
124
|
export declare function deriveRuntimeGraph(projects: EnhancedGraph, hiddenProjects?: Set<string>): RuntimeGraph;
|
|
125
|
+
/**
|
|
126
|
+
* The same derivation, plus the warnings it produced (every edge it had to GUESS at). Executors use
|
|
127
|
+
* this form and print the warnings; `deriveRuntimeGraph` is the convenience form for callers that
|
|
128
|
+
* only want the data. The warnings are deliberately kept OUT of runtime-dependencies.json — a graph
|
|
129
|
+
* file that records its own doubts would just get committed and stop being read.
|
|
130
|
+
*/
|
|
131
|
+
export declare function deriveRuntimeGraphReport(projects: EnhancedGraph, hiddenProjects?: Set<string>): RuntimeGraphReport;
|
|
68
132
|
export declare function saveRuntimeGraph(graph: RuntimeGraph, workspaceRoot: string, graphPath?: string): void;
|
|
69
133
|
export declare function runtimeGraphFileExists(workspaceRoot: string, graphPath?: string): boolean;
|
|
70
134
|
export declare function loadRuntimeGraph(workspaceRoot: string, graphPath?: string): RuntimeGraph | null;
|
package/src/lib/runtime-graph.js
CHANGED
|
@@ -12,11 +12,22 @@
|
|
|
12
12
|
* The runtime edge Z -> X (Z depends on X at runtime) is INFERRED: Z `uses` api
|
|
13
13
|
* Y and X `implements` api Y. This edge does not exist in the compile-time
|
|
14
14
|
* dependencies.json (both Z and X only compile-depend on the api library Y).
|
|
15
|
+
*
|
|
16
|
+
* WHICH X is decided by the call site, not by "everyone who implements Y":
|
|
17
|
+
* `createRpcClient(Y, new ClientConfig('helper-fsdb'))` names its target, kept as
|
|
18
|
+
* `ApiRef.targetService`, and matched against each node's DECLARED `serviceName`
|
|
19
|
+
* (project.json metadata.webpieces.serviceName). Fanning an edge out to every
|
|
20
|
+
* implementer instead is catastrophic for a company-wide contract registered in a
|
|
21
|
+
* shared library — it manufactures calls that cannot happen, and cycles that do
|
|
22
|
+
* not exist. When a target cannot be resolved the old fan-out still happens, but
|
|
23
|
+
* a warning names the call site (see RuntimeGraphReport.warnings): a wrong-but-
|
|
24
|
+
* green graph is worse than a failing one, so it must never degrade silently.
|
|
15
25
|
*/
|
|
16
26
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
17
|
-
exports.DEFAULT_RUNTIME_GRAPH_PATH = void 0;
|
|
27
|
+
exports.RuntimeGraphReport = exports.DEFAULT_RUNTIME_GRAPH_PATH = void 0;
|
|
18
28
|
exports.runtimeAdjacency = runtimeAdjacency;
|
|
19
29
|
exports.deriveRuntimeGraph = deriveRuntimeGraph;
|
|
30
|
+
exports.deriveRuntimeGraphReport = deriveRuntimeGraphReport;
|
|
20
31
|
exports.saveRuntimeGraph = saveRuntimeGraph;
|
|
21
32
|
exports.runtimeGraphFileExists = runtimeGraphFileExists;
|
|
22
33
|
exports.loadRuntimeGraph = loadRuntimeGraph;
|
|
@@ -28,6 +39,32 @@ const graph_sorter_1 = require("./graph-sorter");
|
|
|
28
39
|
const api_relations_1 = require("./api-usage/api-relations");
|
|
29
40
|
const toError_1 = require("../toError");
|
|
30
41
|
exports.DEFAULT_RUNTIME_GRAPH_PATH = 'architecture/runtime-dependencies.json';
|
|
42
|
+
/**
|
|
43
|
+
* The derived graph PLUS everything the derivation had to guess at. `warnings` is deliberately not
|
|
44
|
+
* part of RuntimeGraph: it is not committed data, it is the report that stops a guessed edge from
|
|
45
|
+
* passing for a derived one. Executors print it.
|
|
46
|
+
*/
|
|
47
|
+
class RuntimeGraphReport {
|
|
48
|
+
graph;
|
|
49
|
+
warnings;
|
|
50
|
+
problems;
|
|
51
|
+
constructor(graph,
|
|
52
|
+
/** Human-readable lines naming every call site whose target the graph could not pin down. */
|
|
53
|
+
warnings,
|
|
54
|
+
/**
|
|
55
|
+
* Call sites that name a target the repo does not contain. Unlike a warning these FAIL the
|
|
56
|
+
* build: the contract IS served in-repo, so the name is a typo or a stale rename, and the
|
|
57
|
+
* only reason it stayed invisible is that the graph quietly fanned the edge out instead.
|
|
58
|
+
* (A call to a service outside the repo never reaches here — nothing in-repo implements its
|
|
59
|
+
* contract, so it is `unresolvedUses`.)
|
|
60
|
+
*/
|
|
61
|
+
problems = []) {
|
|
62
|
+
this.graph = graph;
|
|
63
|
+
this.warnings = warnings;
|
|
64
|
+
this.problems = problems;
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
exports.RuntimeGraphReport = RuntimeGraphReport;
|
|
31
68
|
/** Adjacency (service -> [targets]) used for leveling + cycle checks. */
|
|
32
69
|
function adjacencyFromEdges(serviceNames, edges) {
|
|
33
70
|
const adj = {};
|
|
@@ -61,6 +98,32 @@ function assignLevels(adjacency) {
|
|
|
61
98
|
}
|
|
62
99
|
return levels;
|
|
63
100
|
}
|
|
101
|
+
/**
|
|
102
|
+
* Accumulates one node's effective relations while its dependsOn closure is walked, keeping the
|
|
103
|
+
* PROVENANCE the walk would otherwise throw away: which library contributed an implements, and
|
|
104
|
+
* which api-lib owns each contract.
|
|
105
|
+
*/
|
|
106
|
+
class RelationSink {
|
|
107
|
+
node;
|
|
108
|
+
implementsApis = [];
|
|
109
|
+
usesApis = [];
|
|
110
|
+
implementsVia = new Map();
|
|
111
|
+
constructor(
|
|
112
|
+
/** The runtime node these relations are attributed to. */
|
|
113
|
+
node) {
|
|
114
|
+
this.node = node;
|
|
115
|
+
}
|
|
116
|
+
/** `from` is the project whose apiRelations declared this — the node itself, or a lib it embeds. */
|
|
117
|
+
addImplements(ref, from) {
|
|
118
|
+
this.implementsApis.push(ref);
|
|
119
|
+
// First contributor wins, matching dedupApiRefs' keep-the-first rule on the ref list.
|
|
120
|
+
if (from !== this.node && !this.implementsVia.has(ref.api))
|
|
121
|
+
this.implementsVia.set(ref.api, from);
|
|
122
|
+
}
|
|
123
|
+
addUses(ref) {
|
|
124
|
+
this.usesApis.push(ref);
|
|
125
|
+
}
|
|
126
|
+
}
|
|
64
127
|
/**
|
|
65
128
|
* Derives the runtime microservice graph from architecture/dependencies.json `apiRelations`:
|
|
66
129
|
* implementers × users per API, split by transport. An rpc edge is a direct call; a pubsub edge flows
|
|
@@ -73,11 +136,40 @@ function assignLevels(adjacency) {
|
|
|
73
136
|
class RuntimeGraphDeriver {
|
|
74
137
|
projects;
|
|
75
138
|
hiddenProjects;
|
|
139
|
+
/** apiClassName -> the api-lib project that owns the contract (from the apiRelations key). */
|
|
140
|
+
apiOwners = new Map();
|
|
141
|
+
/** Addressable name -> the runtime node answering to it; how a targeted call resolves. */
|
|
142
|
+
nodeByServiceName = new Map();
|
|
143
|
+
warnings = [];
|
|
144
|
+
problems = [];
|
|
76
145
|
constructor(projects,
|
|
77
146
|
/** Project names tagged drawOnGraph:false — kept in the JSON but flagged so the viz omits them. */
|
|
78
147
|
hiddenProjects) {
|
|
79
148
|
this.projects = projects;
|
|
80
149
|
this.hiddenProjects = hiddenProjects;
|
|
150
|
+
// A node ALWAYS answers to its own module name, so a repo whose deployed names match its
|
|
151
|
+
// project names needs no declaration at all — and no alias can ever redirect 'ai-chat' away
|
|
152
|
+
// from the ai-chat module. Module names are therefore claimed FIRST and are unshadowable.
|
|
153
|
+
for (const name of Object.keys(projects).sort()) {
|
|
154
|
+
if (this.isNode(name))
|
|
155
|
+
this.nodeByServiceName.set(name, name);
|
|
156
|
+
}
|
|
157
|
+
// `serviceName` then adds the alias a repo needs when its deployed name is NOT its module
|
|
158
|
+
// name (helper-svr serving 'helper-portal'). Colliding with a module is a misconfiguration,
|
|
159
|
+
// not a precedence question: the alias is silently unreachable, so say so.
|
|
160
|
+
for (const name of Object.keys(projects).sort()) {
|
|
161
|
+
const declared = projects[name].serviceName;
|
|
162
|
+
if (!this.isNode(name) || declared === undefined)
|
|
163
|
+
continue;
|
|
164
|
+
const claimant = this.nodeByServiceName.get(declared);
|
|
165
|
+
if (claimant === undefined) {
|
|
166
|
+
this.nodeByServiceName.set(declared, name);
|
|
167
|
+
}
|
|
168
|
+
else if (claimant !== name) {
|
|
169
|
+
this.problems.push(`${name} declares serviceName '${declared}', but that is already the module name of ` +
|
|
170
|
+
`${claimant} — the alias can never be reached. Rename one of them.`);
|
|
171
|
+
}
|
|
172
|
+
}
|
|
81
173
|
}
|
|
82
174
|
assemble() {
|
|
83
175
|
const decls = this.collectDecls();
|
|
@@ -87,7 +179,13 @@ class RuntimeGraphDeriver {
|
|
|
87
179
|
const apisObj = {};
|
|
88
180
|
for (const api of Array.from(apis.keys()).sort())
|
|
89
181
|
apisObj[api] = apis.get(api);
|
|
90
|
-
|
|
182
|
+
const graph = {
|
|
183
|
+
services,
|
|
184
|
+
apis: apisObj,
|
|
185
|
+
runtimeEdges: edgeResult.edges,
|
|
186
|
+
unresolvedUses: edgeResult.unresolved,
|
|
187
|
+
};
|
|
188
|
+
return new RuntimeGraphReport(graph, this.warnings, this.problems);
|
|
91
189
|
}
|
|
92
190
|
/**
|
|
93
191
|
* One ScanDecl per RUNTIME NODE (role:server / role:client). A node's effective
|
|
@@ -104,14 +202,14 @@ class RuntimeGraphDeriver {
|
|
|
104
202
|
for (const name of Object.keys(this.projects).sort()) {
|
|
105
203
|
if (!this.isNode(name))
|
|
106
204
|
continue;
|
|
107
|
-
const
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
if (implementsApis.length > 0 || usesApis.length > 0) {
|
|
205
|
+
const sink = new RelationSink(name);
|
|
206
|
+
this.collectEffectiveRelations(name, sink, new Set([name]));
|
|
207
|
+
if (sink.implementsApis.length > 0 || sink.usesApis.length > 0) {
|
|
111
208
|
decls.push({
|
|
112
209
|
name,
|
|
113
|
-
implementsApis: dedupApiRefs((0, api_relations_1.sortApiRefs)(implementsApis)),
|
|
114
|
-
usesApis: dedupApiRefs((0, api_relations_1.sortApiRefs)(usesApis)),
|
|
210
|
+
implementsApis: dedupApiRefs((0, api_relations_1.sortApiRefs)(sink.implementsApis)),
|
|
211
|
+
usesApis: dedupApiRefs((0, api_relations_1.sortApiRefs)(sink.usesApis)),
|
|
212
|
+
implementsVia: sink.implementsVia,
|
|
115
213
|
});
|
|
116
214
|
}
|
|
117
215
|
}
|
|
@@ -127,15 +225,21 @@ class RuntimeGraphDeriver {
|
|
|
127
225
|
* (skipping other nodes, which own their own relations). `visited` guards against
|
|
128
226
|
* re-walking a lib reachable by more than one path (and any cycle).
|
|
129
227
|
*/
|
|
130
|
-
collectEffectiveRelations(name,
|
|
228
|
+
collectEffectiveRelations(name, sink, visited) {
|
|
131
229
|
const entry = this.projects[name];
|
|
132
230
|
if (entry === undefined)
|
|
133
231
|
return;
|
|
134
232
|
const relations = entry.apiRelations;
|
|
135
233
|
if (relations !== undefined) {
|
|
136
|
-
for (const owner of Object.keys(relations)) {
|
|
137
|
-
|
|
138
|
-
|
|
234
|
+
for (const owner of Object.keys(relations).sort()) {
|
|
235
|
+
for (const ref of relations[owner].implements) {
|
|
236
|
+
this.apiOwners.set(ref.api, owner);
|
|
237
|
+
sink.addImplements(ref, name);
|
|
238
|
+
}
|
|
239
|
+
for (const ref of relations[owner].uses) {
|
|
240
|
+
this.apiOwners.set(ref.api, owner);
|
|
241
|
+
sink.addUses(ref);
|
|
242
|
+
}
|
|
139
243
|
}
|
|
140
244
|
}
|
|
141
245
|
for (const dep of entry.dependsOn) {
|
|
@@ -144,7 +248,7 @@ class RuntimeGraphDeriver {
|
|
|
144
248
|
visited.add(dep);
|
|
145
249
|
if (this.isNode(dep))
|
|
146
250
|
continue; // another server/client owns its own relations
|
|
147
|
-
this.collectEffectiveRelations(dep,
|
|
251
|
+
this.collectEffectiveRelations(dep, sink, visited);
|
|
148
252
|
}
|
|
149
253
|
}
|
|
150
254
|
/** apiClassName -> { implementedBy, usedBy, type }. */
|
|
@@ -164,9 +268,13 @@ class RuntimeGraphDeriver {
|
|
|
164
268
|
for (const ref of decl.usesApis)
|
|
165
269
|
ensure(ref.api, ref.type).usedBy.push(decl.name);
|
|
166
270
|
}
|
|
167
|
-
for (const
|
|
271
|
+
for (const api of apis.keys()) {
|
|
272
|
+
const entry = apis.get(api);
|
|
168
273
|
entry.implementedBy.sort();
|
|
169
|
-
entry.usedBy.sort();
|
|
274
|
+
entry.usedBy = Array.from(new Set(entry.usedBy)).sort();
|
|
275
|
+
const owner = this.apiOwners.get(api);
|
|
276
|
+
if (owner !== undefined)
|
|
277
|
+
entry.owner = owner;
|
|
170
278
|
}
|
|
171
279
|
return apis;
|
|
172
280
|
}
|
|
@@ -181,10 +289,10 @@ class RuntimeGraphDeriver {
|
|
|
181
289
|
unresolved.push({ service: decl.name, api: ref.api });
|
|
182
290
|
continue;
|
|
183
291
|
}
|
|
184
|
-
for (const target of implementers) {
|
|
292
|
+
for (const target of this.targetsFor(decl.name, ref, implementers)) {
|
|
185
293
|
if (target === decl.name)
|
|
186
294
|
continue;
|
|
187
|
-
const key = `${decl.name}
|
|
295
|
+
const key = `${decl.name} ${target} ${ref.type}`;
|
|
188
296
|
if (!viaByKey.has(key))
|
|
189
297
|
viaByKey.set(key, new Set());
|
|
190
298
|
viaByKey.get(key).add(ref.api);
|
|
@@ -193,10 +301,47 @@ class RuntimeGraphDeriver {
|
|
|
193
301
|
}
|
|
194
302
|
return { edges: this.edgesFromKeys(viaByKey), unresolved: sortUnresolved(unresolved) };
|
|
195
303
|
}
|
|
304
|
+
/**
|
|
305
|
+
* WHICH implementers this one `uses` reaches. A call site naming its target resolves to exactly
|
|
306
|
+
* ONE node; anything else falls back to the historical fan-out — the only safe superset — and
|
|
307
|
+
* records WHY, so a fanned-out (i.e. possibly fictional) edge can never pass for a derived one.
|
|
308
|
+
*/
|
|
309
|
+
targetsFor(user, ref, implementers) {
|
|
310
|
+
const wanted = ref.targetService;
|
|
311
|
+
if (wanted === undefined)
|
|
312
|
+
return this.untargetedFanOut(user, ref, implementers);
|
|
313
|
+
const node = this.nodeByServiceName.get(wanted);
|
|
314
|
+
if (node === undefined) {
|
|
315
|
+
this.problems.push(`${user} calls "${ref.api}" at service '${wanted}', but NO module answers to that name. ` +
|
|
316
|
+
`${implementers.length} module(s) serve this contract (${implementers.join(', ')}). Either use ` +
|
|
317
|
+
`the module name, or declare metadata.webpieces.serviceName: '${wanted}' on the module that ` +
|
|
318
|
+
`serves it (and translate any environment prefix in ClientRegistry.setDeriver, not here).`);
|
|
319
|
+
return implementers;
|
|
320
|
+
}
|
|
321
|
+
if (!implementers.includes(node)) {
|
|
322
|
+
this.problems.push(`${user} calls "${ref.api}" at service '${wanted}' (module ${node}), but ${node} does NOT serve ` +
|
|
323
|
+
`that contract — ${implementers.join(', ')} do. At runtime that call has nothing to answer it.`);
|
|
324
|
+
return implementers;
|
|
325
|
+
}
|
|
326
|
+
return [node];
|
|
327
|
+
}
|
|
328
|
+
/**
|
|
329
|
+
* A use with no literal client config. One implementer is unambiguous, so it stays silent; more
|
|
330
|
+
* than one means every edge but one is fiction — exactly the failure this mechanism exists to
|
|
331
|
+
* stop — so it is reported even though the graph still (conservatively) draws them all.
|
|
332
|
+
*/
|
|
333
|
+
untargetedFanOut(user, ref, implementers) {
|
|
334
|
+
if (implementers.length > 1) {
|
|
335
|
+
this.warnings.push(`${user} uses "${ref.api}" with no literal client config, and ${implementers.length} services ` +
|
|
336
|
+
`implement it (${implementers.join(', ')}) — an edge is drawn to EVERY one, so all but one ` +
|
|
337
|
+
`are fiction. Name the target: createRpcClient(${ref.api}, new ClientConfig('<serviceName>')).`);
|
|
338
|
+
}
|
|
339
|
+
return implementers;
|
|
340
|
+
}
|
|
196
341
|
edgesFromKeys(viaByKey) {
|
|
197
342
|
const edges = [];
|
|
198
343
|
for (const key of viaByKey.keys()) {
|
|
199
|
-
const parts = key.split('
|
|
344
|
+
const parts = key.split(' ');
|
|
200
345
|
edges.push({ from: parts[0], to: parts[1], via: Array.from(viaByKey.get(key)).sort(), type: parts[2] });
|
|
201
346
|
}
|
|
202
347
|
edges.sort((a, b) => a.from.localeCompare(b.from) || a.to.localeCompare(b.to) || (a.type ?? '').localeCompare(b.type ?? ''));
|
|
@@ -206,12 +351,18 @@ class RuntimeGraphDeriver {
|
|
|
206
351
|
const services = {};
|
|
207
352
|
for (const decl of decls) {
|
|
208
353
|
const dependsOn = Array.from(new Set(edges.filter((e) => e.from === decl.name).map((e) => e.to))).sort();
|
|
209
|
-
|
|
354
|
+
// Keys are written in this order; an undefined value is omitted by JSON.stringify, so
|
|
355
|
+
// the committed JSON stays clean AND deterministic without conditional assembly.
|
|
356
|
+
const service = {
|
|
210
357
|
level: 0,
|
|
358
|
+
serviceName: this.projects[decl.name]?.serviceName,
|
|
211
359
|
implements: decl.implementsApis.map((r) => r.api),
|
|
212
|
-
|
|
360
|
+
implementsVia: decl.implementsVia.size > 0 ? sortedRecord(decl.implementsVia) : undefined,
|
|
361
|
+
// One api used against two services is two refs but ONE api in this list.
|
|
362
|
+
uses: Array.from(new Set(decl.usesApis.map((r) => r.api))),
|
|
213
363
|
dependsOn,
|
|
214
364
|
};
|
|
365
|
+
services[decl.name] = service;
|
|
215
366
|
if (this.hiddenProjects.has(decl.name))
|
|
216
367
|
services[decl.name].drawOnGraph = false;
|
|
217
368
|
}
|
|
@@ -229,26 +380,50 @@ class RuntimeGraphDeriver {
|
|
|
229
380
|
*/
|
|
230
381
|
// webpieces-disable no-function-outside-class -- module entry point for the runtime graph derivation
|
|
231
382
|
function deriveRuntimeGraph(projects, hiddenProjects = new Set()) {
|
|
383
|
+
return deriveRuntimeGraphReport(projects, hiddenProjects).graph;
|
|
384
|
+
}
|
|
385
|
+
/**
|
|
386
|
+
* The same derivation, plus the warnings it produced (every edge it had to GUESS at). Executors use
|
|
387
|
+
* this form and print the warnings; `deriveRuntimeGraph` is the convenience form for callers that
|
|
388
|
+
* only want the data. The warnings are deliberately kept OUT of runtime-dependencies.json — a graph
|
|
389
|
+
* file that records its own doubts would just get committed and stop being read.
|
|
390
|
+
*/
|
|
391
|
+
// webpieces-disable no-function-outside-class -- module entry point for the runtime graph derivation
|
|
392
|
+
function deriveRuntimeGraphReport(projects, hiddenProjects = new Set()) {
|
|
232
393
|
return new RuntimeGraphDeriver(projects, hiddenProjects).assemble();
|
|
233
394
|
}
|
|
234
|
-
/** Drop duplicate api refs
|
|
235
|
-
*
|
|
236
|
-
*
|
|
395
|
+
/** Drop duplicate api refs, keeping the first — needed after a node absorbs the same api from both
|
|
396
|
+
* its own relations and an embedded lib's. Keyed by api AND target service: the same contract aimed
|
|
397
|
+
* at two different services is two distinct relations (two distinct edges), not a duplicate. Input
|
|
398
|
+
* is pre-sorted, so output stays deterministic. */
|
|
237
399
|
// webpieces-disable no-function-outside-class -- pure list helper, matches the sibling helpers in this file
|
|
238
400
|
function dedupApiRefs(refs) {
|
|
239
401
|
const seen = new Set();
|
|
240
402
|
const out = [];
|
|
241
403
|
for (const ref of refs) {
|
|
242
|
-
|
|
404
|
+
const key = (0, api_relations_1.apiRefKey)(ref);
|
|
405
|
+
if (seen.has(key))
|
|
243
406
|
continue;
|
|
244
|
-
seen.add(
|
|
407
|
+
seen.add(key);
|
|
245
408
|
out.push(ref);
|
|
246
409
|
}
|
|
247
410
|
return out;
|
|
248
411
|
}
|
|
412
|
+
/** Sort a Map into a plain object with sorted keys, so the committed JSON is deterministic. */
|
|
413
|
+
// webpieces-disable no-function-outside-class -- pure data helper, matches the sibling helpers in this file
|
|
414
|
+
function sortedRecord(map) {
|
|
415
|
+
const out = {};
|
|
416
|
+
for (const key of [...map.keys()].sort())
|
|
417
|
+
out[key] = map.get(key);
|
|
418
|
+
return out;
|
|
419
|
+
}
|
|
420
|
+
/** Sort AND de-duplicate: one api used against two targets must not be reported unresolved twice. */
|
|
249
421
|
// webpieces-disable no-function-outside-class -- pure sort helper, matches the sibling helpers in this file
|
|
250
422
|
function sortUnresolved(unresolved) {
|
|
251
|
-
|
|
423
|
+
const byKey = new Map();
|
|
424
|
+
for (const entry of unresolved)
|
|
425
|
+
byKey.set(`${entry.service} ${entry.api}`, entry);
|
|
426
|
+
return [...byKey.values()].sort((a, b) => a.service.localeCompare(b.service) || a.api.localeCompare(b.api));
|
|
252
427
|
}
|
|
253
428
|
/** Deterministic JSON (sorted keys + arrays already sorted during assembly). */
|
|
254
429
|
function formatRuntimeJson(graph) {
|