@webpieces/nx-webpieces-rules 0.4.700 → 0.4.702

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 (49) hide show
  1. package/package.json +6 -6
  2. package/src/executors/generate/executor.js +5 -1
  3. package/src/executors/generate/executor.js.map +1 -1
  4. package/src/executors/validate-runtime-architecture/executor.d.ts +10 -4
  5. package/src/executors/validate-runtime-architecture/executor.js +46 -35
  6. package/src/executors/validate-runtime-architecture/executor.js.map +1 -1
  7. package/src/lib/api-usage/api-ast.d.ts +1 -16
  8. package/src/lib/api-usage/api-ast.js +1 -42
  9. package/src/lib/api-usage/api-ast.js.map +1 -1
  10. package/src/lib/api-usage/api-relations.d.ts +1 -17
  11. package/src/lib/api-usage/api-relations.js +10 -2
  12. package/src/lib/api-usage/api-relations.js.map +1 -1
  13. package/src/lib/api-usage/api-scanner.d.ts +1 -1
  14. package/src/lib/api-usage/api-scanner.js +1 -10
  15. package/src/lib/api-usage/api-scanner.js.map +1 -1
  16. package/src/lib/cut-legacy-cycle-resolver.d.ts +47 -0
  17. package/src/lib/cut-legacy-cycle-resolver.js +68 -0
  18. package/src/lib/cut-legacy-cycle-resolver.js.map +1 -0
  19. package/src/lib/graph-loader.js +3 -0
  20. package/src/lib/graph-loader.js.map +1 -1
  21. package/src/lib/graph-metadata.js +10 -0
  22. package/src/lib/graph-metadata.js.map +1 -1
  23. package/src/lib/graph-sorter.d.ts +8 -1
  24. package/src/lib/graph-sorter.js.map +1 -1
  25. package/src/lib/runtime-config.d.ts +0 -7
  26. package/src/lib/runtime-config.js +0 -5
  27. package/src/lib/runtime-config.js.map +1 -1
  28. package/src/lib/runtime-cycles.d.ts +10 -12
  29. package/src/lib/runtime-cycles.js +11 -14
  30. package/src/lib/runtime-cycles.js.map +1 -1
  31. package/src/lib/runtime-graph-levels.d.ts +52 -6
  32. package/src/lib/runtime-graph-levels.js +118 -19
  33. package/src/lib/runtime-graph-levels.js.map +1 -1
  34. package/src/lib/runtime-graph-model.d.ts +8 -1
  35. package/src/lib/runtime-graph-model.js.map +1 -1
  36. package/src/lib/runtime-graph.d.ts +1 -1
  37. package/src/lib/runtime-graph.js +10 -41
  38. package/src/lib/runtime-graph.js.map +1 -1
  39. package/src/lib/runtime-visualizer.js +7 -3
  40. package/src/lib/runtime-visualizer.js.map +1 -1
  41. package/src/lib/runtime-viz-theme.d.ts +7 -0
  42. package/src/lib/runtime-viz-theme.js +8 -1
  43. package/src/lib/runtime-viz-theme.js.map +1 -1
  44. package/src/lib/service-name-resolver.d.ts +1 -1
  45. package/src/lib/service-name-resolver.js +1 -1
  46. package/src/lib/service-name-resolver.js.map +1 -1
  47. package/src/lib/runtime-host-nodes.d.ts +0 -10
  48. package/src/lib/runtime-host-nodes.js +0 -15
  49. package/src/lib/runtime-host-nodes.js.map +0 -1
@@ -1,10 +1,14 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.adjacencyFromEdges = adjacencyFromEdges;
4
+ exports.applyCycleCuts = applyCycleCuts;
4
5
  exports.runtimeAdjacency = runtimeAdjacency;
5
6
  exports.assignLevels = assignLevels;
7
+ const rules_config_1 = require("@webpieces/rules-config");
6
8
  const graph_sorter_1 = require("./graph-sorter");
7
- const toError_1 = require("../toError");
9
+ const graph_cycles_1 = require("./graph-cycles");
10
+ const cut_legacy_cycle_resolver_1 = require("./cut-legacy-cycle-resolver");
11
+ const runtime_config_1 = require("./runtime-config");
8
12
  /**
9
13
  * LEVELS and ADJACENCY for the runtime graph — how deep each service sits, and who calls whom.
10
14
  *
@@ -16,11 +20,19 @@ const toError_1 = require("../toError");
16
20
  /**
17
21
  * Adjacency (service -> [targets]) used for leveling + cycle checks.
18
22
  *
19
- * PUBSUB EDGES ARE EXCLUDED. A queue is precisely the thing that decouples producer from consumer:
20
- * the producer returns as soon as the task is enqueued and never waits on the consumer, so a queued
21
- * hop is not a runtime dependency in the sense levels and cycle detection mean. Counting them would
22
- * make the common and correct `A → queue → A` (a service deferring its own work) an architecture
23
- * cycle, and would rank services by an ordering that does not constrain deploy or startup.
23
+ * TWO KINDS OF EDGE ARE EXCLUDED, and the second is modelled on the first:
24
+ *
25
+ * PUBSUB EDGES. A queue is precisely the thing that decouples producer from consumer: the producer
26
+ * returns as soon as the task is enqueued and never waits on the consumer, so a queued hop is not a
27
+ * runtime dependency in the sense levels and cycle detection mean. Counting them would make the
28
+ * common and correct `A → queue → A` (a service deferring its own work) an architecture cycle, and
29
+ * would rank services by an ordering that does not constrain deploy or startup.
30
+ *
31
+ * EDGES STAMPED `cutLegacyCycle`. The calling project carries a `cutLegacyCycle:<target>` nx tag
32
+ * (see cut-legacy-cycle-resolver.ts) ADMITTING that this hop closes a real cycle which is being
33
+ * tolerated as legacy debt. Unlike a queued hop the coupling is real, so the edge is still drawn —
34
+ * it is only kept out of leveling and cycle detection, which are the two things a cycle makes
35
+ * meaningless.
24
36
  */
25
37
  // webpieces-disable no-function-outside-class -- pure graph helper, matches the sibling helpers in this file
26
38
  function adjacencyFromEdges(serviceNames, edges) {
@@ -30,33 +42,120 @@ function adjacencyFromEdges(serviceNames, edges) {
30
42
  for (const edge of edges) {
31
43
  if (edge.type === 'pubsub')
32
44
  continue;
45
+ if (edge.cutLegacyCycle === true)
46
+ continue;
33
47
  if (!adj[edge.from])
34
48
  adj[edge.from] = [];
35
49
  adj[edge.from].push(edge.to);
36
50
  }
37
51
  return adj;
38
52
  }
53
+ /**
54
+ * Stamp `cutLegacyCycle` on every runtime edge a project declared a cut for — the DECLARATION
55
+ * (`cutLegacyCycle:<targetService>` nx tags, resolved into `GraphEntry.cutLegacyCycle` and committed
56
+ * to dependencies.json) turned into the mark {@link adjacencyFromEdges} above reads.
57
+ *
58
+ * IT FAILS THE BUILD ON A TAG THAT CUTS NOTHING, in both directions:
59
+ *
60
+ * - the tag names a service no runtime node answers to — a typo or a rename. Silently doing nothing
61
+ * is the worst outcome available: the check would look exempted while no edge was in fact cut, or
62
+ * the tag would keep reading as a live IOU after the service it named was deleted. `serviceName`
63
+ * is validated against real module names for exactly this reason.
64
+ * - the tag resolves, but there is no direct edge to cut. The debt is PAID — delete the tag, so
65
+ * `grep -rn cutLegacyCycle` keeps enumerating only cycles still actually tolerated.
66
+ *
67
+ * Returned as problems (the deriver's `problems` list) rather than thrown, so one run names every
68
+ * bad tag instead of stopping at the first.
69
+ *
70
+ * @param edges the derived runtime edges, mutated in place.
71
+ * @param projects the committed project entries carrying `cutLegacyCycle`.
72
+ * @param nodeByServiceName addressable name -> runtime node, the SAME map that resolves a call
73
+ * target, so a tag may name a module name or a declared serviceName.
74
+ */
75
+ // webpieces-disable no-function-outside-class -- pure pass over derived edges, sibling of the helpers here
76
+ function applyCycleCuts(edges, projects, nodeByServiceName) {
77
+ const problems = [];
78
+ for (const name of Object.keys(projects).sort()) {
79
+ const entry = projects[name];
80
+ for (const target of entry.cutLegacyCycle ?? []) {
81
+ const node = nodeByServiceName.get(target);
82
+ if (node === undefined) {
83
+ problems.push(`${name} declares ${cut_legacy_cycle_resolver_1.CUT_LEGACY_CYCLE_TAG_PREFIX}${target}, but no runtime service ` +
84
+ `answers to '${target}'. Fix the name or delete the tag — a cut that names ` +
85
+ `nothing exempts nothing.`);
86
+ continue;
87
+ }
88
+ // A queued hop is already excluded from leveling, so it can never be part of a cycle and
89
+ // never needs cutting; only a direct call edge is cuttable.
90
+ const cut = edges.filter((e) => e.from === name && e.to === node && e.type !== 'pubsub');
91
+ if (cut.length === 0) {
92
+ problems.push(`${name} declares ${cut_legacy_cycle_resolver_1.CUT_LEGACY_CYCLE_TAG_PREFIX}${target}, but it has no direct ` +
93
+ `runtime call edge to '${node}'. The debt is paid — delete the tag.`);
94
+ continue;
95
+ }
96
+ for (const e of cut)
97
+ e.cutLegacyCycle = true;
98
+ }
99
+ }
100
+ return problems;
101
+ }
39
102
  /** Adjacency (service -> [targets]) from a loaded runtime graph. */
40
103
  // webpieces-disable no-function-outside-class -- pure graph helper, sibling of the one above
41
104
  function runtimeAdjacency(graph) {
42
105
  return adjacencyFromEdges(Object.keys(graph.services), graph.runtimeEdges);
43
106
  }
44
- /** Assign levels via topological sort; falls back to level 0 when a cycle exists. */
107
+ /** The cures for a cyclic runtime graph, honest ones first and the IOU last. */
108
+ // webpieces-disable no-function-outside-class -- cure list for the throw below, sibling of the helpers in this file
109
+ function cycleCures() {
110
+ return [
111
+ new rules_config_1.Option('Retag a node that is not really a deployed service. A project tagged `role:server` in ' +
112
+ 'its project.json that is in fact a library should carry `role:lib` instead, which ' +
113
+ 'takes it out of the runtime graph entirely and removes every edge through it.', true),
114
+ new rules_config_1.Option('Make the hop asynchronous. A contract marked `@PubSub()` is delivered through a Cloud ' +
115
+ 'Tasks queue, and queued edges are excluded from leveling and cycle detection ' +
116
+ 'because the producer returns without waiting on the consumer.'),
117
+ new rules_config_1.Option('Extract the shared contract into a `role:api-lib` project that both sides depend on, ' +
118
+ 'so the call runs in one direction only.'),
119
+ new rules_config_1.Option('Last resort — admit the debt. Add `' +
120
+ cut_legacy_cycle_resolver_1.CUT_LEGACY_CYCLE_TAG_PREFIX +
121
+ '<targetService>` to the CALLING project\'s project.json tags, naming the service on ' +
122
+ 'the other end of the one edge you are cutting. This does NOT say the edge is ' +
123
+ 'harmless; it records that the cycle is real and is being tolerated as legacy debt. ' +
124
+ 'The edge stays on the drawing as a dashed "legacy cycle" arrow, and ' +
125
+ '`grep -rn cutLegacyCycle` enumerates every one still outstanding.'),
126
+ ];
127
+ }
128
+ /**
129
+ * Assign levels via topological sort — and THROW on a cycle rather than levelling one.
130
+ *
131
+ * This used to swallow the cycle and flatten EVERY service to level 0, which was the worst available
132
+ * outcome twice over: one bad edge anywhere silently restratified the whole graph, and the reason
133
+ * was discarded, so the diagram rendered as one legitimate-looking flat row.
134
+ *
135
+ * Cycles are not allowed. CD deploys services in dependency order, and a cyclic architecture has no
136
+ * such order — whichever member rolls out first is calling one that is not up yet — so the graph is
137
+ * not deployable and its level numbers mean nothing.
138
+ *
139
+ * Detection is {@link ProjectCycleDetector}, the ONE cycle detector in this package (it guards the
140
+ * compile-time project graph too); only the audience and the cures differ, so only the message is
141
+ * written here.
142
+ */
45
143
  // webpieces-disable no-function-outside-class -- pure graph helper, sibling of the two above
46
144
  function assignLevels(adjacency) {
47
- const levels = {};
48
- // eslint-disable-next-line @webpieces/no-unmanaged-exceptions
49
- try {
50
- const sorted = (0, graph_sorter_1.sortGraphTopologically)(adjacency);
51
- for (const name of Object.keys(sorted))
52
- levels[name] = sorted[name].level;
53
- }
54
- catch (err) {
55
- const error = (0, toError_1.toError)(err);
56
- void error;
57
- for (const name of Object.keys(adjacency))
58
- levels[name] = 0;
145
+ const cycles = new graph_cycles_1.ProjectCycleDetector().find(adjacency);
146
+ if (cycles.length > 0) {
147
+ const listed = cycles.map((cycle) => ` ${cycle.describe()}`).join('\n');
148
+ const plural = cycles.length === 1 ? 'cycle' : 'cycles';
149
+ throw new rules_config_1.RuleFailError(runtime_config_1.RUNTIME_RULE_NAME, `The runtime service graph contains ${cycles.length} ${plural}:\n${listed}\n` +
150
+ 'CD deploys services in dependency order and a cycle has no such order — whichever ' +
151
+ 'service in the chain rolls out first calls one that is not up yet — so an ' +
152
+ 'architecture with cycles cannot be deployed and its level numbers are meaningless. ' +
153
+ 'Cut every chain above.', undefined, undefined, cycleCures());
59
154
  }
155
+ const levels = {};
156
+ const sorted = (0, graph_sorter_1.sortGraphTopologically)(adjacency);
157
+ for (const name of Object.keys(sorted))
158
+ levels[name] = sorted[name].level;
60
159
  return levels;
61
160
  }
62
161
  //# sourceMappingURL=runtime-graph-levels.js.map
@@ -1 +1 @@
1
- {"version":3,"file":"runtime-graph-levels.js","sourceRoot":"","sources":["../../../../../../packages/tooling/nx-webpieces-rules/src/lib/runtime-graph-levels.ts"],"names":[],"mappings":";;AAsBA,gDAYC;AAID,4CAEC;AAID,oCAYC;AAxDD,iDAAwD;AACxD,wCAAqC;AAGrC;;;;;;;GAOG;AACH;;;;;;;;GAQG;AACH,6GAA6G;AAC7G,SAAgB,kBAAkB,CAC9B,YAAsB,EACtB,KAAoB;IAEpB,MAAM,GAAG,GAA6B,EAAE,CAAC;IACzC,KAAK,MAAM,IAAI,IAAI,YAAY;QAAE,GAAG,CAAC,IAAI,CAAC,GAAG,EAAE,CAAC;IAChD,KAAK,MAAM,IAAI,IAAI,KAAK,EAAE,CAAC;QACvB,IAAI,IAAI,CAAC,IAAI,KAAK,QAAQ;YAAE,SAAS;QACrC,IAAI,CAAC,GAAG,CAAC,IAAI,CAAC,IAAI,CAAC;YAAE,GAAG,CAAC,IAAI,CAAC,IAAI,CAAC,GAAG,EAAE,CAAC;QACzC,GAAG,CAAC,IAAI,CAAC,IAAI,CAAC,CAAC,IAAI,CAAC,IAAI,CAAC,EAAE,CAAC,CAAC;IACjC,CAAC;IACD,OAAO,GAAG,CAAC;AACf,CAAC;AAED,oEAAoE;AACpE,6FAA6F;AAC7F,SAAgB,gBAAgB,CAAC,KAAmB;IAChD,OAAO,kBAAkB,CAAC,MAAM,CAAC,IAAI,CAAC,KAAK,CAAC,QAAQ,CAAC,EAAE,KAAK,CAAC,YAAY,CAAC,CAAC;AAC/E,CAAC;AAED,qFAAqF;AACrF,6FAA6F;AAC7F,SAAgB,YAAY,CAAC,SAAmC;IAC5D,MAAM,MAAM,GAA2B,EAAE,CAAC;IAC1C,8DAA8D;IAC9D,IAAI,CAAC;QACD,MAAM,MAAM,GAAG,IAAA,qCAAsB,EAAC,SAAS,CAAC,CAAC;QACjD,KAAK,MAAM,IAAI,IAAI,MAAM,CAAC,IAAI,CAAC,MAAM,CAAC;YAAE,MAAM,CAAC,IAAI,CAAC,GAAG,MAAM,CAAC,IAAI,CAAC,CAAC,KAAK,CAAC;IAC9E,CAAC;IAAC,OAAO,GAAY,EAAE,CAAC;QACpB,MAAM,KAAK,GAAG,IAAA,iBAAO,EAAC,GAAG,CAAC,CAAC;QAC3B,KAAK,KAAK,CAAC;QACX,KAAK,MAAM,IAAI,IAAI,MAAM,CAAC,IAAI,CAAC,SAAS,CAAC;YAAE,MAAM,CAAC,IAAI,CAAC,GAAG,CAAC,CAAC;IAChE,CAAC;IACD,OAAO,MAAM,CAAC;AAClB,CAAC","sourcesContent":["import { sortGraphTopologically } from './graph-sorter';\nimport { toError } from '../toError';\nimport type { RuntimeEdge, RuntimeGraph } from './runtime-graph-model';\n\n/**\n * LEVELS and ADJACENCY for the runtime graph — how deep each service sits, and who calls whom.\n *\n * Its own module rather than a block inside runtime-graph.ts: that file owns DERIVATION (turning\n * apiRelations into services, apis, edges, queues and triggers), and this is a pure graph\n * computation over the result, with no knowledge of contracts at all. Splitting it also keeps\n * runtime-graph.ts under the file-size limit, which is the visible symptom of the same thing.\n */\n/**\n * Adjacency (service -> [targets]) used for leveling + cycle checks.\n *\n * PUBSUB EDGES ARE EXCLUDED. A queue is precisely the thing that decouples producer from consumer:\n * the producer returns as soon as the task is enqueued and never waits on the consumer, so a queued\n * hop is not a runtime dependency in the sense levels and cycle detection mean. Counting them would\n * make the common and correct `A → queue → A` (a service deferring its own work) an architecture\n * cycle, and would rank services by an ordering that does not constrain deploy or startup.\n */\n// webpieces-disable no-function-outside-class -- pure graph helper, matches the sibling helpers in this file\nexport function adjacencyFromEdges(\n serviceNames: string[],\n edges: RuntimeEdge[],\n): Record<string, string[]> {\n const adj: Record<string, string[]> = {};\n for (const name of serviceNames) adj[name] = [];\n for (const edge of edges) {\n if (edge.type === 'pubsub') continue;\n if (!adj[edge.from]) adj[edge.from] = [];\n adj[edge.from].push(edge.to);\n }\n return adj;\n}\n\n/** Adjacency (service -> [targets]) from a loaded runtime graph. */\n// webpieces-disable no-function-outside-class -- pure graph helper, sibling of the one above\nexport function runtimeAdjacency(graph: RuntimeGraph): Record<string, string[]> {\n return adjacencyFromEdges(Object.keys(graph.services), graph.runtimeEdges);\n}\n\n/** Assign levels via topological sort; falls back to level 0 when a cycle exists. */\n// webpieces-disable no-function-outside-class -- pure graph helper, sibling of the two above\nexport function assignLevels(adjacency: Record<string, string[]>): Record<string, number> {\n const levels: Record<string, number> = {};\n // eslint-disable-next-line @webpieces/no-unmanaged-exceptions\n try {\n const sorted = sortGraphTopologically(adjacency);\n for (const name of Object.keys(sorted)) levels[name] = sorted[name].level;\n } catch (err: unknown) {\n const error = toError(err);\n void error;\n for (const name of Object.keys(adjacency)) levels[name] = 0;\n }\n return levels;\n}\n"]}
1
+ {"version":3,"file":"runtime-graph-levels.js","sourceRoot":"","sources":["../../../../../../packages/tooling/nx-webpieces-rules/src/lib/runtime-graph-levels.ts"],"names":[],"mappings":"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","sourcesContent":["import { RuleFailError, Option } from '@webpieces/rules-config';\nimport { sortGraphTopologically } from './graph-sorter';\nimport type { GraphEntry, EnhancedGraph } from './graph-sorter';\nimport { ProjectCycleDetector, ProjectCycle } from './graph-cycles';\nimport { CUT_LEGACY_CYCLE_TAG_PREFIX } from './cut-legacy-cycle-resolver';\nimport { RUNTIME_RULE_NAME } from './runtime-config';\nimport type { RuntimeEdge, RuntimeGraph } from './runtime-graph-model';\n\n/**\n * LEVELS and ADJACENCY for the runtime graph — how deep each service sits, and who calls whom.\n *\n * Its own module rather than a block inside runtime-graph.ts: that file owns DERIVATION (turning\n * apiRelations into services, apis, edges, queues and triggers), and this is a pure graph\n * computation over the result, with no knowledge of contracts at all. Splitting it also keeps\n * runtime-graph.ts under the file-size limit, which is the visible symptom of the same thing.\n */\n/**\n * Adjacency (service -> [targets]) used for leveling + cycle checks.\n *\n * TWO KINDS OF EDGE ARE EXCLUDED, and the second is modelled on the first:\n *\n * PUBSUB EDGES. A queue is precisely the thing that decouples producer from consumer: the producer\n * returns as soon as the task is enqueued and never waits on the consumer, so a queued hop is not a\n * runtime dependency in the sense levels and cycle detection mean. Counting them would make the\n * common and correct `A → queue → A` (a service deferring its own work) an architecture cycle, and\n * would rank services by an ordering that does not constrain deploy or startup.\n *\n * EDGES STAMPED `cutLegacyCycle`. The calling project carries a `cutLegacyCycle:<target>` nx tag\n * (see cut-legacy-cycle-resolver.ts) ADMITTING that this hop closes a real cycle which is being\n * tolerated as legacy debt. Unlike a queued hop the coupling is real, so the edge is still drawn —\n * it is only kept out of leveling and cycle detection, which are the two things a cycle makes\n * meaningless.\n */\n// webpieces-disable no-function-outside-class -- pure graph helper, matches the sibling helpers in this file\nexport function adjacencyFromEdges(\n serviceNames: string[],\n edges: RuntimeEdge[],\n): Record<string, string[]> {\n const adj: Record<string, string[]> = {};\n for (const name of serviceNames) adj[name] = [];\n for (const edge of edges) {\n if (edge.type === 'pubsub') continue;\n if (edge.cutLegacyCycle === true) continue;\n if (!adj[edge.from]) adj[edge.from] = [];\n adj[edge.from].push(edge.to);\n }\n return adj;\n}\n\n/**\n * Stamp `cutLegacyCycle` on every runtime edge a project declared a cut for — the DECLARATION\n * (`cutLegacyCycle:<targetService>` nx tags, resolved into `GraphEntry.cutLegacyCycle` and committed\n * to dependencies.json) turned into the mark {@link adjacencyFromEdges} above reads.\n *\n * IT FAILS THE BUILD ON A TAG THAT CUTS NOTHING, in both directions:\n *\n * - the tag names a service no runtime node answers to — a typo or a rename. Silently doing nothing\n * is the worst outcome available: the check would look exempted while no edge was in fact cut, or\n * the tag would keep reading as a live IOU after the service it named was deleted. `serviceName`\n * is validated against real module names for exactly this reason.\n * - the tag resolves, but there is no direct edge to cut. The debt is PAID — delete the tag, so\n * `grep -rn cutLegacyCycle` keeps enumerating only cycles still actually tolerated.\n *\n * Returned as problems (the deriver's `problems` list) rather than thrown, so one run names every\n * bad tag instead of stopping at the first.\n *\n * @param edges the derived runtime edges, mutated in place.\n * @param projects the committed project entries carrying `cutLegacyCycle`.\n * @param nodeByServiceName addressable name -> runtime node, the SAME map that resolves a call\n * target, so a tag may name a module name or a declared serviceName.\n */\n// webpieces-disable no-function-outside-class -- pure pass over derived edges, sibling of the helpers here\nexport function applyCycleCuts(\n edges: RuntimeEdge[],\n projects: EnhancedGraph,\n nodeByServiceName: Map<string, string>,\n): string[] {\n const problems: string[] = [];\n for (const name of Object.keys(projects).sort()) {\n const entry: GraphEntry = projects[name];\n for (const target of entry.cutLegacyCycle ?? []) {\n const node = nodeByServiceName.get(target);\n if (node === undefined) {\n problems.push(\n `${name} declares ${CUT_LEGACY_CYCLE_TAG_PREFIX}${target}, but no runtime service ` +\n `answers to '${target}'. Fix the name or delete the tag — a cut that names ` +\n `nothing exempts nothing.`,\n );\n continue;\n }\n // A queued hop is already excluded from leveling, so it can never be part of a cycle and\n // never needs cutting; only a direct call edge is cuttable.\n const cut = edges.filter(\n (e: RuntimeEdge) => e.from === name && e.to === node && e.type !== 'pubsub',\n );\n if (cut.length === 0) {\n problems.push(\n `${name} declares ${CUT_LEGACY_CYCLE_TAG_PREFIX}${target}, but it has no direct ` +\n `runtime call edge to '${node}'. The debt is paid — delete the tag.`,\n );\n continue;\n }\n for (const e of cut) e.cutLegacyCycle = true;\n }\n }\n return problems;\n}\n\n/** Adjacency (service -> [targets]) from a loaded runtime graph. */\n// webpieces-disable no-function-outside-class -- pure graph helper, sibling of the one above\nexport function runtimeAdjacency(graph: RuntimeGraph): Record<string, string[]> {\n return adjacencyFromEdges(Object.keys(graph.services), graph.runtimeEdges);\n}\n\n/** The cures for a cyclic runtime graph, honest ones first and the IOU last. */\n// webpieces-disable no-function-outside-class -- cure list for the throw below, sibling of the helpers in this file\nfunction cycleCures(): Option[] {\n return [\n new Option(\n 'Retag a node that is not really a deployed service. A project tagged `role:server` in ' +\n 'its project.json that is in fact a library should carry `role:lib` instead, which ' +\n 'takes it out of the runtime graph entirely and removes every edge through it.',\n true,\n ),\n new Option(\n 'Make the hop asynchronous. A contract marked `@PubSub()` is delivered through a Cloud ' +\n 'Tasks queue, and queued edges are excluded from leveling and cycle detection ' +\n 'because the producer returns without waiting on the consumer.',\n ),\n new Option(\n 'Extract the shared contract into a `role:api-lib` project that both sides depend on, ' +\n 'so the call runs in one direction only.',\n ),\n new Option(\n 'Last resort — admit the debt. Add `' +\n CUT_LEGACY_CYCLE_TAG_PREFIX +\n '<targetService>` to the CALLING project\\'s project.json tags, naming the service on ' +\n 'the other end of the one edge you are cutting. This does NOT say the edge is ' +\n 'harmless; it records that the cycle is real and is being tolerated as legacy debt. ' +\n 'The edge stays on the drawing as a dashed \"legacy cycle\" arrow, and ' +\n '`grep -rn cutLegacyCycle` enumerates every one still outstanding.',\n ),\n ];\n}\n\n/**\n * Assign levels via topological sort — and THROW on a cycle rather than levelling one.\n *\n * This used to swallow the cycle and flatten EVERY service to level 0, which was the worst available\n * outcome twice over: one bad edge anywhere silently restratified the whole graph, and the reason\n * was discarded, so the diagram rendered as one legitimate-looking flat row.\n *\n * Cycles are not allowed. CD deploys services in dependency order, and a cyclic architecture has no\n * such order — whichever member rolls out first is calling one that is not up yet — so the graph is\n * not deployable and its level numbers mean nothing.\n *\n * Detection is {@link ProjectCycleDetector}, the ONE cycle detector in this package (it guards the\n * compile-time project graph too); only the audience and the cures differ, so only the message is\n * written here.\n */\n// webpieces-disable no-function-outside-class -- pure graph helper, sibling of the two above\nexport function assignLevels(adjacency: Record<string, string[]>): Record<string, number> {\n const cycles = new ProjectCycleDetector().find(adjacency);\n if (cycles.length > 0) {\n const listed = cycles.map((cycle: ProjectCycle): string => ` ${cycle.describe()}`).join('\\n');\n const plural = cycles.length === 1 ? 'cycle' : 'cycles';\n throw new RuleFailError(\n RUNTIME_RULE_NAME,\n `The runtime service graph contains ${cycles.length} ${plural}:\\n${listed}\\n` +\n 'CD deploys services in dependency order and a cycle has no such order — whichever ' +\n 'service in the chain rolls out first calls one that is not up yet — so an ' +\n 'architecture with cycles cannot be deployed and its level numbers are meaningless. ' +\n 'Cut every chain above.',\n undefined,\n undefined,\n cycleCures(),\n );\n }\n const levels: Record<string, number> = {};\n const sorted = sortGraphTopologically(adjacency);\n for (const name of Object.keys(sorted)) levels[name] = sorted[name].level;\n return levels;\n}\n"]}
@@ -21,7 +21,7 @@ export interface RuntimeService {
21
21
  */
22
22
  role?: string;
23
23
  /**
24
- * The name clients address this service by (`new ClientConfig('helper-fsdb', new DeployedServiceHost())`), declared in its
24
+ * The name clients address this service by (`new ClientConfig('helper-fsdb')`), declared in its
25
25
  * project.json. Absent for a service nothing calls by name (e.g. a browser app).
26
26
  */
27
27
  serviceName?: string;
@@ -86,6 +86,13 @@ export interface RuntimeEdge {
86
86
  * queues rather than one arrow.
87
87
  */
88
88
  queue?: string;
89
+ /**
90
+ * True when the CALLING project carries a `cutLegacyCycle:<target>` nx tag naming this edge's
91
+ * target — an admission that this hop closes a REAL cycle that is being tolerated as legacy
92
+ * debt, not a claim that it is harmless. The edge is still drawn (dashed, labelled "legacy
93
+ * cycle"); it is only excluded from leveling and cycle detection. Absent on every other edge.
94
+ */
95
+ cutLegacyCycle?: boolean;
89
96
  }
90
97
  /**
91
98
  * One Cloud Tasks queue: the async seam between a producer and a consumer, at METHOD granularity.
@@ -1 +1 @@
1
- {"version":3,"file":"runtime-graph-model.js","sourceRoot":"","sources":["../../../../../../packages/tooling/nx-webpieces-rules/src/lib/runtime-graph-model.ts"],"names":[],"mappings":";AAAA;;;;;;;;;;GAUG","sourcesContent":["/**\n * Runtime Graph model\n *\n * The serialization DTOs for architecture/runtime-dependencies.json. Split out of runtime-graph.ts,\n * which owns the DERIVATION (and had grown past the file-size limit), so the committed data shape\n * can be read on its own — it is what every consumer of the file, and any future Terraform\n * cross-check, actually programs against.\n *\n * Interfaces rather than classes: these are parsed straight out of JSON with `JSON.parse`, so a\n * class would only ever be a shape assertion over a plain object, never a constructed instance.\n */\n\nimport type { ApiTransport, ExternalSystemDeclaration, ExternalSystemKind } from './api-usage/api-relations';\n\nexport interface RuntimeService {\n level: number;\n /**\n * The project's declared nx role — 'server' or 'client' (a library is never a node). Carried so\n * the viz can LABEL the node with what it actually is: it used to infer the role from\n * `implements.length > 0`, which labelled every relation-less server \"client\". Optional so a\n * runtime-dependencies.json written before this field existed still parses and still renders\n * exactly as it did (the visualizer falls back to the old inference).\n */\n role?: string;\n /**\n * The name clients address this service by (`new ClientConfig('helper-fsdb', new DeployedServiceHost())`), declared in its\n * project.json. Absent for a service nothing calls by name (e.g. a browser app).\n */\n serviceName?: string;\n /**\n * The service(s) this node's clients call when the call site carries no literal `ClientConfig`,\n * declared in its project.json (metadata.webpieces.callsService). A single name, or an\n * `{ apiClassName: serviceName }` map. Absent when the node declares no target. Mirrors\n * GraphEntry.callsService; it is the CALLING-side counterpart of `serviceName`.\n */\n callsService?: string | Record<string, string>;\n implements: string[];\n /**\n * apiClassName -> the LIBRARY project whose apiRelations declared that implements, for the apis\n * this service serves through an embedded library rather than its own source (e.g. a shared\n * route-registration lib). Answers \"who implements WarmupApi, and where did that come from?\",\n * which previously required walking the dependsOn closure by hand.\n */\n implementsVia?: Record<string, string>;\n uses: string[];\n dependsOn: string[];\n /**\n * When false, this service is hidden from the rendered runtime graph (its\n * node AND every edge touching it are omitted from the HTML/DOT). It stays\n * in runtime-dependencies.json so the data view is complete. Absent means\n * drawn (the default). Mirrors GraphEntry.drawOnGraph from the `drawOnGraph:`\n * nx tag.\n */\n drawOnGraph?: boolean;\n}\n\nexport interface RuntimeApi {\n implementedBy: string[];\n usedBy: string[];\n /** Transport of this API — 'rpc' (direct call) or 'pubsub' (delivered through a queue). */\n type?: ApiTransport;\n /**\n * The api-lib project that OWNS this contract. For a contract nothing in-repo implements, this\n * is the external library the calls leave the repo through (`lib-firestore`, `lib-gmail`), which\n * is what the runtime viz labels its terminal external nodes with.\n */\n owner?: string;\n /**\n * Set when the contract carries an `@externalSystem <kind> [label]` JSDoc tag — the vendor seam\n * declaring WHAT it is a seam to, so the viz can draw firestore as a database rather than as the\n * same grey box as every other external. Absent on everything else.\n */\n externalSystem?: ExternalSystemDeclaration;\n}\n\nexport interface RuntimeEdge {\n from: string;\n to: string;\n via: string[];\n /**\n * Transport of this edge. 'rpc' → a direct call arrow. 'pubsub' → the producer enqueues and the\n * consumer is delivered later, so the runtime viz draws it as producer → QUEUE → consumer.\n * Edges are split by transport, so every edge is a single kind.\n */\n type?: ApiTransport;\n /**\n * `\"ApiClassName.methodName\"` — the queue this edge flows through. Present iff `type` is\n * 'pubsub'. Queues are per METHOD, not per service pair, because that is the unit Cloud Tasks\n * (and Terraform) actually create, so two services exchanging three queued methods are three\n * queues rather than one arrow.\n */\n queue?: string;\n}\n\n/**\n * One Cloud Tasks queue: the async seam between a producer and a consumer, at METHOD granularity.\n *\n * `producedBy` and `consumedBy` are deliberately not symmetric in confidence — see\n * {@link ApiRef.methodsInferred}. The consumer is derived from `addRoutes` plus the contract's\n * method table and is exact; the producer is attributed to every queued method of the contract it\n * built a client for, because which methods it enqueues is not statically recoverable.\n */\nexport interface RuntimeQueue {\n api: string;\n method: string;\n /** `@Queue(...)` override, else `${Api}-${method}` — the name Terraform must match 1:1. */\n queueName: string;\n producedBy: string[];\n consumedBy: string[];\n}\n\n/**\n * An endpoint driven by something that is NOT an in-repo caller — a clock or an outside system.\n * These never appear as runtime EDGES (there is no in-repo `from`), which is exactly why they were\n * invisible until now: a nightly sweep and a GCP push subscription are real runtime entry points\n * with real Terraform behind them, and the graph showed neither.\n */\nexport interface RuntimeTrigger {\n /** 'cron' → a scheduler fires it; 'external' → a system outside this repo posts to it. */\n kind: 'cron' | 'external';\n api: string;\n method: string;\n /** The service that SERVES the endpoint (the arrow's head). */\n service: string;\n /** Present for 'cron': the Cloud Scheduler job / queue name Terraform must match. */\n queueName?: string;\n /**\n * Present for 'external': WHO outside this repo posts to it, declared as\n * `@Endpoint(p, 'external', { calledBy: 'twilio' })`.\n *\n * The SAME `(kind,label)` an OUTBOUND {@link RuntimeExternalSystem} carries, so an inbound\n * `saas twilio` and an outbound `saas twilio` share a node identity and converge on ONE box —\n * `label` is the identity here too, not display text.\n *\n * Optional only for graphs generated BEFORE the caller was required; generation now fails rather\n * than emitting an `external` method without one.\n */\n caller?: ExternalSystemDeclaration;\n}\n\nexport interface RuntimeUnresolved {\n service: string;\n api: string;\n}\n\n/**\n * A system outside this repo that a service TALKS TO, drawn with a shape that says what it is\n * (a database as a cylinder, a bucket as a folder) instead of the one grey box every external\n * used to collapse into.\n *\n * Two declaration sites feed this, because the two real cases differ in whether a contract exists:\n *\n * - **Wrapped** — the repo has a vendor seam (`FirestoreAdminApi`), so the kind is declared with an\n * `@externalSystem <kind> [label]` JSDoc tag on the contract. A decorator cannot go on a TS\n * `interface`, and these seams are interfaces, so JSDoc is the only marker that fits in place.\n * - **Unwrapped** — the service opens the connection itself with no contract to mark (a `pg.Pool`,\n * a TypeORM `DataSource`), so the declaration is an `external:<kind>:<identity>` nx tag on that\n * project's project.json.\n *\n * `label` is the node IDENTITY, not just display text: two services declaring `postgres` converge on\n * ONE node with two arrows into it, rather than drawing a database each.\n */\nexport interface RuntimeExternalSystem {\n kind: ExternalSystemKind;\n /** Display name AND node identity — declarations sharing a label share a node. */\n label: string;\n /** Services with a direct arrow to it. No transitive fan-out: a tag speaks only for its project. */\n usedBy: string[];\n /** Contracts flowing over it. Empty for the unwrapped (tag-declared) case — there are none. */\n apis: string[];\n}\n\nexport interface RuntimeGraph {\n services: Record<string, RuntimeService>;\n apis: Record<string, RuntimeApi>;\n runtimeEdges: RuntimeEdge[];\n unresolvedUses: RuntimeUnresolved[];\n /** `\"Api.method\"` -> the queue between its producers and its consumers. */\n queues: Record<string, RuntimeQueue>;\n /** Clock- and outside-driven entry points, sorted for determinism. */\n triggers: RuntimeTrigger[];\n /**\n * Declared external systems, keyed by identity. Optional so every graph written before this\n * existed still parses — an absent map means \"nothing declared\", which renders exactly as before.\n */\n externalSystems?: Record<string, RuntimeExternalSystem>;\n}\n"]}
1
+ {"version":3,"file":"runtime-graph-model.js","sourceRoot":"","sources":["../../../../../../packages/tooling/nx-webpieces-rules/src/lib/runtime-graph-model.ts"],"names":[],"mappings":";AAAA;;;;;;;;;;GAUG","sourcesContent":["/**\n * Runtime Graph model\n *\n * The serialization DTOs for architecture/runtime-dependencies.json. Split out of runtime-graph.ts,\n * which owns the DERIVATION (and had grown past the file-size limit), so the committed data shape\n * can be read on its own — it is what every consumer of the file, and any future Terraform\n * cross-check, actually programs against.\n *\n * Interfaces rather than classes: these are parsed straight out of JSON with `JSON.parse`, so a\n * class would only ever be a shape assertion over a plain object, never a constructed instance.\n */\n\nimport type { ApiTransport, ExternalSystemDeclaration, ExternalSystemKind } from './api-usage/api-relations';\n\nexport interface RuntimeService {\n level: number;\n /**\n * The project's declared nx role — 'server' or 'client' (a library is never a node). Carried so\n * the viz can LABEL the node with what it actually is: it used to infer the role from\n * `implements.length > 0`, which labelled every relation-less server \"client\". Optional so a\n * runtime-dependencies.json written before this field existed still parses and still renders\n * exactly as it did (the visualizer falls back to the old inference).\n */\n role?: string;\n /**\n * The name clients address this service by (`new ClientConfig('helper-fsdb')`), declared in its\n * project.json. Absent for a service nothing calls by name (e.g. a browser app).\n */\n serviceName?: string;\n /**\n * The service(s) this node's clients call when the call site carries no literal `ClientConfig`,\n * declared in its project.json (metadata.webpieces.callsService). A single name, or an\n * `{ apiClassName: serviceName }` map. Absent when the node declares no target. Mirrors\n * GraphEntry.callsService; it is the CALLING-side counterpart of `serviceName`.\n */\n callsService?: string | Record<string, string>;\n implements: string[];\n /**\n * apiClassName -> the LIBRARY project whose apiRelations declared that implements, for the apis\n * this service serves through an embedded library rather than its own source (e.g. a shared\n * route-registration lib). Answers \"who implements WarmupApi, and where did that come from?\",\n * which previously required walking the dependsOn closure by hand.\n */\n implementsVia?: Record<string, string>;\n uses: string[];\n dependsOn: string[];\n /**\n * When false, this service is hidden from the rendered runtime graph (its\n * node AND every edge touching it are omitted from the HTML/DOT). It stays\n * in runtime-dependencies.json so the data view is complete. Absent means\n * drawn (the default). Mirrors GraphEntry.drawOnGraph from the `drawOnGraph:`\n * nx tag.\n */\n drawOnGraph?: boolean;\n}\n\nexport interface RuntimeApi {\n implementedBy: string[];\n usedBy: string[];\n /** Transport of this API — 'rpc' (direct call) or 'pubsub' (delivered through a queue). */\n type?: ApiTransport;\n /**\n * The api-lib project that OWNS this contract. For a contract nothing in-repo implements, this\n * is the external library the calls leave the repo through (`lib-firestore`, `lib-gmail`), which\n * is what the runtime viz labels its terminal external nodes with.\n */\n owner?: string;\n /**\n * Set when the contract carries an `@externalSystem <kind> [label]` JSDoc tag — the vendor seam\n * declaring WHAT it is a seam to, so the viz can draw firestore as a database rather than as the\n * same grey box as every other external. Absent on everything else.\n */\n externalSystem?: ExternalSystemDeclaration;\n}\n\nexport interface RuntimeEdge {\n from: string;\n to: string;\n via: string[];\n /**\n * Transport of this edge. 'rpc' → a direct call arrow. 'pubsub' → the producer enqueues and the\n * consumer is delivered later, so the runtime viz draws it as producer → QUEUE → consumer.\n * Edges are split by transport, so every edge is a single kind.\n */\n type?: ApiTransport;\n /**\n * `\"ApiClassName.methodName\"` — the queue this edge flows through. Present iff `type` is\n * 'pubsub'. Queues are per METHOD, not per service pair, because that is the unit Cloud Tasks\n * (and Terraform) actually create, so two services exchanging three queued methods are three\n * queues rather than one arrow.\n */\n queue?: string;\n /**\n * True when the CALLING project carries a `cutLegacyCycle:<target>` nx tag naming this edge's\n * target — an admission that this hop closes a REAL cycle that is being tolerated as legacy\n * debt, not a claim that it is harmless. The edge is still drawn (dashed, labelled \"legacy\n * cycle\"); it is only excluded from leveling and cycle detection. Absent on every other edge.\n */\n cutLegacyCycle?: boolean;\n}\n\n/**\n * One Cloud Tasks queue: the async seam between a producer and a consumer, at METHOD granularity.\n *\n * `producedBy` and `consumedBy` are deliberately not symmetric in confidence — see\n * {@link ApiRef.methodsInferred}. The consumer is derived from `addRoutes` plus the contract's\n * method table and is exact; the producer is attributed to every queued method of the contract it\n * built a client for, because which methods it enqueues is not statically recoverable.\n */\nexport interface RuntimeQueue {\n api: string;\n method: string;\n /** `@Queue(...)` override, else `${Api}-${method}` — the name Terraform must match 1:1. */\n queueName: string;\n producedBy: string[];\n consumedBy: string[];\n}\n\n/**\n * An endpoint driven by something that is NOT an in-repo caller — a clock or an outside system.\n * These never appear as runtime EDGES (there is no in-repo `from`), which is exactly why they were\n * invisible until now: a nightly sweep and a GCP push subscription are real runtime entry points\n * with real Terraform behind them, and the graph showed neither.\n */\nexport interface RuntimeTrigger {\n /** 'cron' → a scheduler fires it; 'external' → a system outside this repo posts to it. */\n kind: 'cron' | 'external';\n api: string;\n method: string;\n /** The service that SERVES the endpoint (the arrow's head). */\n service: string;\n /** Present for 'cron': the Cloud Scheduler job / queue name Terraform must match. */\n queueName?: string;\n /**\n * Present for 'external': WHO outside this repo posts to it, declared as\n * `@Endpoint(p, 'external', { calledBy: 'twilio' })`.\n *\n * The SAME `(kind,label)` an OUTBOUND {@link RuntimeExternalSystem} carries, so an inbound\n * `saas twilio` and an outbound `saas twilio` share a node identity and converge on ONE box —\n * `label` is the identity here too, not display text.\n *\n * Optional only for graphs generated BEFORE the caller was required; generation now fails rather\n * than emitting an `external` method without one.\n */\n caller?: ExternalSystemDeclaration;\n}\n\nexport interface RuntimeUnresolved {\n service: string;\n api: string;\n}\n\n/**\n * A system outside this repo that a service TALKS TO, drawn with a shape that says what it is\n * (a database as a cylinder, a bucket as a folder) instead of the one grey box every external\n * used to collapse into.\n *\n * Two declaration sites feed this, because the two real cases differ in whether a contract exists:\n *\n * - **Wrapped** — the repo has a vendor seam (`FirestoreAdminApi`), so the kind is declared with an\n * `@externalSystem <kind> [label]` JSDoc tag on the contract. A decorator cannot go on a TS\n * `interface`, and these seams are interfaces, so JSDoc is the only marker that fits in place.\n * - **Unwrapped** — the service opens the connection itself with no contract to mark (a `pg.Pool`,\n * a TypeORM `DataSource`), so the declaration is an `external:<kind>:<identity>` nx tag on that\n * project's project.json.\n *\n * `label` is the node IDENTITY, not just display text: two services declaring `postgres` converge on\n * ONE node with two arrows into it, rather than drawing a database each.\n */\nexport interface RuntimeExternalSystem {\n kind: ExternalSystemKind;\n /** Display name AND node identity — declarations sharing a label share a node. */\n label: string;\n /** Services with a direct arrow to it. No transitive fan-out: a tag speaks only for its project. */\n usedBy: string[];\n /** Contracts flowing over it. Empty for the unwrapped (tag-declared) case — there are none. */\n apis: string[];\n}\n\nexport interface RuntimeGraph {\n services: Record<string, RuntimeService>;\n apis: Record<string, RuntimeApi>;\n runtimeEdges: RuntimeEdge[];\n unresolvedUses: RuntimeUnresolved[];\n /** `\"Api.method\"` -> the queue between its producers and its consumers. */\n queues: Record<string, RuntimeQueue>;\n /** Clock- and outside-driven entry points, sorted for determinism. */\n triggers: RuntimeTrigger[];\n /**\n * Declared external systems, keyed by identity. Optional so every graph written before this\n * existed still parses — an absent map means \"nothing declared\", which renders exactly as before.\n */\n externalSystems?: Record<string, RuntimeExternalSystem>;\n}\n"]}
@@ -14,7 +14,7 @@
14
14
  *
15
15
  * WHICH X is decided by the call site, not by "everyone who implements Y", in
16
16
  * priority order: (1) a literal at the call site —
17
- * `createRpcClient(Y, new ClientConfig('helper-fsdb', new DeployedServiceHost()))` — kept as
17
+ * `createRpcClient(Y, new ClientConfig('helper-fsdb'))` — kept as
18
18
  * `ApiRef.targetService`; else (2) the calling project's declared `callsService`
19
19
  * (project.json metadata.webpieces.callsService), the symmetric half of
20
20
  * `serviceName` for the shared-library case where the client is built once from a
@@ -15,7 +15,7 @@
15
15
  *
16
16
  * WHICH X is decided by the call site, not by "everyone who implements Y", in
17
17
  * priority order: (1) a literal at the call site —
18
- * `createRpcClient(Y, new ClientConfig('helper-fsdb', new DeployedServiceHost()))` — kept as
18
+ * `createRpcClient(Y, new ClientConfig('helper-fsdb'))` — kept as
19
19
  * `ApiRef.targetService`; else (2) the calling project's declared `callsService`
20
20
  * (project.json metadata.webpieces.callsService), the symmetric half of
21
21
  * `serviceName` for the shared-library case where the client is built once from a
@@ -37,7 +37,6 @@ const runtime_graph_decls_1 = require("./runtime-graph-decls");
37
37
  const runtime_graph_sorters_1 = require("./runtime-graph-sorters");
38
38
  const external_systems_1 = require("./api-usage/external-systems");
39
39
  const runtime_graph_levels_1 = require("./runtime-graph-levels");
40
- const runtime_host_nodes_1 = require("./runtime-host-nodes");
41
40
  // Persistence lives in runtime-graph-io.ts; re-exported for the same reason as the model types.
42
41
  var runtime_graph_io_1 = require("./runtime-graph-io");
43
42
  Object.defineProperty(exports, "DEFAULT_RUNTIME_GRAPH_PATH", { enumerable: true, get: function () { return runtime_graph_io_1.DEFAULT_RUNTIME_GRAPH_PATH; } });
@@ -104,13 +103,6 @@ class RuntimeGraphDeriver {
104
103
  problems = [];
105
104
  /** role:server nodes hidden by isNonParticipantServer, in the order buildServices met them. */
106
105
  autoHidden = [];
107
- /**
108
- * identity -> the services that dial it and the contracts they dial it with, for every client
109
- * whose base URL arrives at RUNTIME. Accumulated while edges are built, then merged into the
110
- * graph's `externalSystems` in {@link assemble} so a partner destination is drawn as the
111
- * external node it is instead of vanishing between fan-out and `unresolvedUses`.
112
- */
113
- runtimeHosts = new Map();
114
106
  /**
115
107
  * False when NO project in the graph carries `webpiecesRuntime`, i.e. the file was written
116
108
  * before the field existed. Auto-hiding is then off ENTIRELY, so an old dependencies.json
@@ -166,6 +158,8 @@ class RuntimeGraphDeriver {
166
158
  const decls = this.collectDecls();
167
159
  const apis = this.buildApis(decls);
168
160
  const edgeResult = this.buildEdges(decls, apis);
161
+ // Cuts are stamped BEFORE buildServices, which is where leveling reads the edges.
162
+ this.problems.push(...(0, runtime_graph_levels_1.applyCycleCuts)(edgeResult.edges, this.projects, this.nodeByServiceName));
169
163
  const services = this.buildServices(decls, edgeResult.edges);
170
164
  const apisObj = {};
171
165
  for (const api of Array.from(apis.keys()).sort())
@@ -178,19 +172,11 @@ class RuntimeGraphDeriver {
178
172
  queues: edgeResult.queues,
179
173
  triggers: this.buildTriggers(decls),
180
174
  };
175
+ // A destination whose address arrives at RUNTIME is declared on its CONTRACT
176
+ // (`@externalSystem runtime partner-webhooks`), so it needs no branch of its own here: it is
177
+ // resolved by the same call every other declared system is, converges on one node when two
178
+ // services deliver over the same contract, and is watched by the same drift check.
181
179
  const systems = (0, external_systems_1.resolveExternalSystems)(this.externalSystemDecls, services);
182
- // Runtime destinations join the SAME table the declared vendor systems live in, so two
183
- // clients naming one identity converge on one node exactly as two `@externalSystem twilio`
184
- // contracts do, and the drift check that already watches `externalSystems` watches these too.
185
- for (const identity of Array.from(this.runtimeHosts.keys()).sort()) {
186
- const usage = this.runtimeHosts.get(identity);
187
- systems[identity] = {
188
- kind: 'runtime',
189
- label: identity,
190
- usedBy: [...usage.usedBy].sort(),
191
- apis: [...usage.apis].sort(),
192
- };
193
- }
194
180
  (0, external_systems_1.attachExternalSystems)(graph, systems);
195
181
  return new RuntimeGraphReport(graph, this.warnings, this.problems, [...this.autoHidden].sort());
196
182
  }
@@ -365,13 +351,6 @@ class RuntimeGraphDeriver {
365
351
  const queues = new Map();
366
352
  for (const decl of decls) {
367
353
  for (const ref of decl.usesApis) {
368
- // A destination supplied at runtime is neither an in-repo implementer nor a missing
369
- // one, so BOTH of the branches below would misreport it — fan-out invents edges to
370
- // services we never call, `unresolvedUses` reads as a gap. It gets its own node.
371
- if (ref.runtimeHost !== undefined) {
372
- this.recordRuntimeHost(decl.name, ref.runtimeHost, ref.api);
373
- continue;
374
- }
375
354
  const implementers = apis.get(ref.api)?.implementedBy ?? [];
376
355
  if (implementers.length === 0) {
377
356
  // Nobody in-repo serves it. For a vendor contract that is the ANSWER, not a gap:
@@ -446,16 +425,6 @@ class RuntimeGraphDeriver {
446
425
  queue.consumedBy.push(to);
447
426
  }
448
427
  }
449
- /** Remember that `user` dials the runtime destination `identity` through contract `api`. */
450
- recordRuntimeHost(user, identity, api) {
451
- let usage = this.runtimeHosts.get(identity);
452
- if (usage === undefined) {
453
- usage = new runtime_host_nodes_1.RuntimeHostUsage();
454
- this.runtimeHosts.set(identity, usage);
455
- }
456
- usage.usedBy.add(user);
457
- usage.apis.add(api);
458
- }
459
428
  /**
460
429
  * WHICH implementers this one `uses` reaches. Resolution order (most specific wins):
461
430
  * 1. a literal `ClientConfig` at the call site (`ref.targetService`) — names ONE node;
@@ -525,7 +494,7 @@ class RuntimeGraphDeriver {
525
494
  if (implementers.length > 1) {
526
495
  this.warnings.push(`${user} uses "${ref.api}" with no literal client config, and ${implementers.length} services ` +
527
496
  `implement it (${implementers.join(', ')}) — an edge is drawn to EVERY one, so all but one ` +
528
- `are fiction. Name the target: createRpcClient(${ref.api}, new ClientConfig('<serviceName>', new DeployedServiceHost())); ` +
497
+ `are fiction. Name the target: createRpcClient(${ref.api}, new ClientConfig('<serviceName>')); ` +
529
498
  `or, when the client is built in a shared library (no literal can sit at the call site), ` +
530
499
  `declare metadata.webpieces.callsService: '<serviceName>' on ${user}'s project.json.`);
531
500
  }
@@ -614,8 +583,8 @@ function deriveRuntimeGraph(projects, hiddenProjects = new Set(), apiContracts =
614
583
  function deriveRuntimeGraphReport(projects, hiddenProjects = new Set(), apiContracts = {}, externalSystems = {}) {
615
584
  return new RuntimeGraphDeriver(projects, hiddenProjects, apiContracts, externalSystems).assemble();
616
585
  }
617
- // Levels + adjacency live in runtime-graph-levels.ts; re-exported for the same reason the model
618
- // types and the io helpers above are — one obvious place to import the runtime graph from.
586
+ // Levels + adjacency live in runtime-graph-levels.ts; re-exported for the same reason the model types
587
+ // and the io helpers above are — one obvious place to import the runtime graph from.
619
588
  var runtime_graph_levels_2 = require("./runtime-graph-levels");
620
589
  Object.defineProperty(exports, "runtimeAdjacency", { enumerable: true, get: function () { return runtime_graph_levels_2.runtimeAdjacency; } });
621
590
  //# sourceMappingURL=runtime-graph.js.map