@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.
Files changed (35) hide show
  1. package/package.json +6 -6
  2. package/src/executors/generate/executor.js +24 -4
  3. package/src/executors/generate/executor.js.map +1 -1
  4. package/src/executors/validate-runtime-architecture/executor.js +7 -2
  5. package/src/executors/validate-runtime-architecture/executor.js.map +1 -1
  6. package/src/executors/visualize-runtime/executor.js +4 -1
  7. package/src/executors/visualize-runtime/executor.js.map +1 -1
  8. package/src/lib/api-usage/api-relations.d.ts +21 -1
  9. package/src/lib/api-usage/api-relations.js +14 -2
  10. package/src/lib/api-usage/api-relations.js.map +1 -1
  11. package/src/lib/api-usage/api-scanner.d.ts +2 -0
  12. package/src/lib/api-usage/api-scanner.js +45 -4
  13. package/src/lib/api-usage/api-scanner.js.map +1 -1
  14. package/src/lib/graph-loader.js +1 -0
  15. package/src/lib/graph-loader.js.map +1 -1
  16. package/src/lib/graph-metadata.d.ts +2 -0
  17. package/src/lib/graph-metadata.js +16 -0
  18. package/src/lib/graph-metadata.js.map +1 -1
  19. package/src/lib/graph-sorter.d.ts +6 -0
  20. package/src/lib/graph-sorter.js.map +1 -1
  21. package/src/lib/graph-visualizer.d.ts +15 -0
  22. package/src/lib/graph-visualizer.js +37 -5
  23. package/src/lib/graph-visualizer.js.map +1 -1
  24. package/src/lib/runtime-config.d.ts +4 -1
  25. package/src/lib/runtime-config.js +5 -1
  26. package/src/lib/runtime-config.js.map +1 -1
  27. package/src/lib/runtime-graph.d.ts +64 -0
  28. package/src/lib/runtime-graph.js +201 -26
  29. package/src/lib/runtime-graph.js.map +1 -1
  30. package/src/lib/runtime-visualizer.d.ts +29 -2
  31. package/src/lib/runtime-visualizer.js +121 -5
  32. package/src/lib/runtime-visualizer.js.map +1 -1
  33. package/src/lib/service-name-resolver.d.ts +49 -0
  34. package/src/lib/service-name-resolver.js +103 -0
  35. 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;;;;;;;;;;;GAWG;;;AAmCH,8CAYC;AAQD,8CAEC;AAMD,sCAGC;AAGD,8BAEC;AArED,0DAA0F;AAE7E,QAAA,iBAAiB,GAAG,sBAAsB,CAAC;AA0BxD,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;KACjG,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 * }\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}\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}\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 };\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"]}
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;
@@ -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
- return { services, apis: apisObj, runtimeEdges: edgeResult.edges, unresolvedUses: edgeResult.unresolved };
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 implementsApis = [];
108
- const usesApis = [];
109
- this.collectEffectiveRelations(name, implementsApis, usesApis, new Set([name]));
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, impl, uses, visited) {
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
- impl.push(...relations[owner].implements);
138
- uses.push(...relations[owner].uses);
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, impl, uses, visited);
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 entry of apis.values()) {
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}${target}${ref.type}`;
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
- services[decl.name] = {
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
- uses: decl.usesApis.map((r) => r.api),
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 (same api class), keeping the first — needed after a node absorbs the
235
- * same api from both its own relations and an embedded lib's. Input is pre-sorted, so output stays
236
- * deterministic. */
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
- if (seen.has(ref.api))
404
+ const key = (0, api_relations_1.apiRefKey)(ref);
405
+ if (seen.has(key))
243
406
  continue;
244
- seen.add(ref.api);
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
- return [...unresolved].sort((a, b) => a.service.localeCompare(b.service) || a.api.localeCompare(b.api));
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) {