hoi4-agent-tools 1.1.0 → 2.0.0

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 (41) hide show
  1. package/CHANGELOG.md +19 -0
  2. package/README.md +21 -18
  3. package/dist/hoi4_agent_tools/core/engine.js +12 -0
  4. package/dist/hoi4_agent_tools/core/workspace.js +31 -3
  5. package/dist/hoi4_agent_tools/event/algorithms.js +208 -0
  6. package/dist/hoi4_agent_tools/event/artifact-validation.js +572 -0
  7. package/dist/hoi4_agent_tools/event/compare.js +413 -0
  8. package/dist/hoi4_agent_tools/event/graph.js +1586 -0
  9. package/dist/hoi4_agent_tools/event/index.js +10 -0
  10. package/dist/hoi4_agent_tools/event/layout.js +218 -0
  11. package/dist/hoi4_agent_tools/event/limits.js +41 -0
  12. package/dist/hoi4_agent_tools/event/model.js +2 -0
  13. package/dist/hoi4_agent_tools/event/queries.js +643 -0
  14. package/dist/hoi4_agent_tools/event/render.js +443 -0
  15. package/dist/hoi4_agent_tools/event/scc.js +72 -0
  16. package/dist/hoi4_agent_tools/event/service.js +1147 -0
  17. package/dist/hoi4_agent_tools/event/source-analysis.js +1458 -0
  18. package/dist/hoi4_agent_tools/focus/native-effects.js +3 -1
  19. package/dist/hoi4_agent_tools/gui/layout.js +122 -27
  20. package/dist/hoi4_agent_tools/gui/renderer.js +16 -0
  21. package/dist/hoi4_agent_tools/mcp/resources/register.js +1 -1
  22. package/dist/hoi4_agent_tools/mcp/server/create.js +4 -2
  23. package/dist/hoi4_agent_tools/mcp/server/result.js +8 -14
  24. package/dist/hoi4_agent_tools/mcp/tools/event.js +459 -0
  25. package/dist/hoi4_agent_tools/mcp/tools/focus.js +6 -3
  26. package/dist/hoi4_agent_tools/mcp/tools/gui.js +6 -3
  27. package/dist/hoi4_agent_tools/mcp/tools/map.js +6 -3
  28. package/dist/hoi4_agent_tools/runtime.js +112 -2
  29. package/dist/hoi4_agent_tools/schemas/common.js +5 -1
  30. package/dist/hoi4_agent_tools/schemas/event.js +95 -0
  31. package/dist/hoi4_agent_tools/version.js +1 -1
  32. package/docs/README.md +1 -0
  33. package/docs/development.md +10 -0
  34. package/docs/events.md +113 -0
  35. package/docs/focus.md +1 -1
  36. package/docs/gui.md +3 -1
  37. package/docs/map.md +1 -1
  38. package/docs/setup.md +10 -7
  39. package/package.json +7 -3
  40. package/server.json +3 -3
  41. package/dist/hoi4_agent_tools/mcp/tools/mods.js +0 -54
package/CHANGELOG.md CHANGED
@@ -1,5 +1,24 @@
1
1
  # Changelog
2
2
 
3
+ ## 2.0.0 - 2026-07-15
4
+
5
+ - Removed the workspace inventory tool and made the current mod implicit for local MCP calls.
6
+ - Starting the server inside any mod now works without a config file or per-mod setup command.
7
+ - Updated the published client examples, Registry metadata, and installation verification for the twelve-tool surface.
8
+ - Rendered unresolved scripted localisation values as `[X]` and applied supported HOI4 `§` text colours in offline GUI previews.
9
+
10
+ ## 1.2.0 - 2026-07-13
11
+
12
+ - Added a read-only Event Chain Viewer for scanning, tracing, explaining, linting, rendering, and comparing large HOI4 event chains.
13
+ - Kept the event surface to three tools with seven inspect modes and linked authoritative graph artifacts, keeping the public surface to twelve tools within the fixed discovery budget. Local calls now resolve the mod containing the MCP working directory.
14
+ - Added event workflow documentation, package and Registry metadata, and project-owned acceptance coverage for more than 300 event definitions.
15
+ - Reconfirmed MCP protocol `2025-11-25`, TypeScript SDK 1.29.0, Registry schema `2025-12-11`, and Registry publisher 1.7.9 for this release.
16
+
17
+ ## 1.1.1 - 2026-07-13
18
+
19
+ - Replaced the repository About text with a direct description of the focus-tree, GUI, and map tools.
20
+ - Simplified the README introduction and HTTP summary so operational safeguards remain in their dedicated documentation.
21
+
3
22
  ## 1.1.0 - 2026-07-12
4
23
 
5
24
  - Added plan-free `compact` rewrites for existing national focus trees while retaining authored mode for complete plans and new trees.
package/README.md CHANGED
@@ -1,23 +1,23 @@
1
1
  # HOI4 Agent Tools
2
2
 
3
- HOI4 Agent Tools is an MCP server that helps coding agents inspect, create, and clean up Hearts of Iron IV focus trees, scripted GUIs, and maps. It works directly with configured mod folders and produces offline renders for review. It does not launch or control the game.
3
+ HOI4 Agent Tools is an MCP server for coding agents to analyze Hearts of Iron IV event chains and inspect, create, or clean up focus trees, scripted GUIs, and maps. Start it in a mod folder and it works there immediately, with offline renders for review.
4
4
 
5
5
  ## What it does
6
6
 
7
7
  - Focus trees: inspect structure and references, render layouts, create trees, and reorganize existing branches.
8
8
  - Scripted GUIs: trace GUI, GFX, scripted-GUI, and localisation links; render states; create or repair interface source.
9
9
  - Maps: inspect provinces, states, regions, adjacency, supply, and railways; render layers; create and repair exact map data.
10
+ - Event chains: scan definitions and call sites, trace routes and state flow, lint references, render graphs, and compare revisions without editing event source.
10
11
 
11
12
  ## Setup
12
13
 
13
14
  Requires Node.js 22 or 24.
14
15
 
15
16
  ```bash
16
- npm install --global hoi4-agent-tools@1.1.0
17
- hoi4-agent-tools-setup --init
17
+ npm install --global hoi4-agent-tools@2.0.0
18
18
  ```
19
19
 
20
- `--init` discovers the usual HOI4 game and mod locations and writes the default per-user configuration. No `HOI4_AGENT_CONFIG` environment variable is needed. For custom paths, multiple mod roots, or a custom config location, see [Setup](docs/setup.md).
20
+ Start the MCP with its working directory set to the mod you are editing. No config or per-mod registration is required: the server finds that mod automatically. Run `hoi4-agent-tools-setup --init` only when you want persistent discovery for several mod roots, a custom game path, or a remote deployment; see [Setup](docs/setup.md).
21
21
 
22
22
  ## Connect your agent
23
23
 
@@ -29,18 +29,20 @@ Paste the printed Codex or generic global-install entry into your MCP client, th
29
29
 
30
30
  ## Tools
31
31
 
32
- | Tool | Purpose |
33
- | -------------------- | ---------------------------------------------------------------- |
34
- | `hoi4.mods` | List the writable mods available to the coding agent. |
35
- | `hoi4.focus_inspect` | Read focus trees and report structural or reference problems. |
36
- | `hoi4.focus_render` | Render an existing focus tree or continuous palette. |
37
- | `hoi4.focus_rewrite` | Create or update a focus tree. |
38
- | `hoi4.gui_inspect` | Read a scripted GUI and its linked assets and logic. |
39
- | `hoi4.gui_render` | Render GUI states and resolutions offline. |
40
- | `hoi4.gui_rewrite` | Create or update a GUI source package. |
41
- | `hoi4.map_inspect` | Read map, state, province, region, supply, and railway data. |
42
- | `hoi4.map_render` | Render map layers and overlays. |
43
- | `hoi4.map_rewrite` | Create or update map data from an ordered list of exact changes. |
32
+ | Tool | Purpose |
33
+ | -------------------- | --------------------------------------------------------------------------------------- |
34
+ | `hoi4.focus_inspect` | Read focus trees and report structural or reference problems. |
35
+ | `hoi4.focus_render` | Render an existing focus tree or continuous palette. |
36
+ | `hoi4.focus_rewrite` | Create or update a focus tree. |
37
+ | `hoi4.gui_inspect` | Read a scripted GUI and its linked assets and logic. |
38
+ | `hoi4.gui_render` | Render GUI states and resolutions offline. |
39
+ | `hoi4.gui_rewrite` | Create or update a GUI source package. |
40
+ | `hoi4.map_inspect` | Read map, state, province, region, supply, and railway data. |
41
+ | `hoi4.map_render` | Render map layers and overlays. |
42
+ | `hoi4.map_rewrite` | Create or update map data from an ordered list of exact changes. |
43
+ | `hoi4.event_inspect` | Scan, trace, explain, lint, or assess event chains and their state flow. |
44
+ | `hoi4.event_render` | Render source-linked event routes, options, timing, state, scope, and unresolved edges. |
45
+ | `hoi4.event_compare` | Compare event-chain topology and diagnostics between revisions. |
44
46
 
45
47
  Large outputs are linked `hoi4-agent://` resources. For resources over 1 MiB, follow the `continuationUri` returned in `_meta` until it is `null`; clients may also request byte ranges with `?offset=<bytes>&length=<bytes>`.
46
48
 
@@ -48,7 +50,7 @@ Large outputs are linked `hoi4-agent://` resources. For resources over 1 MiB, fo
48
50
 
49
51
  HOI4 Agent Tools provides HOI4 domain operations; it does not register MCP prompts, replace repository instructions such as `AGENTS.md`, manage skills or plans, or start subagents. The coding agent decides when to call it as part of its existing workflow.
50
52
 
51
- Connecting and listing tools does not scan mod source. Compact tool schemas and linked resources keep large diagnostics, renders, and diffs out of the agent's working context until needed. Only `hoi4.*_rewrite` calls edit mod source.
53
+ Connecting and listing tools does not scan mod source. Compact tool schemas and linked resources keep large diagnostics, renders, and diffs out of the agent's working context until needed. The event tools are read-only; only `hoi4.*_rewrite` calls edit mod source.
52
54
 
53
55
  ## Create or clean content
54
56
 
@@ -57,10 +59,11 @@ Ask your agent in normal task language. A typical workflow is inspect, render, r
57
59
  - Focus trees: "Create a complete national focus tree for this route specification," or "Compact this existing tree into a balanced, readable layout." Existing trees can use a plan-free compact reflow; new trees use a complete plan. See [Focus trees](docs/focus.md).
58
60
  - Scripted GUIs: "Create a scripted GUI for this mechanic," or "Render this window at common resolutions and fix clipping, missing assets, and click-region conflicts." See [Scripted GUIs](docs/gui.md).
59
61
  - Maps: "Create a state from these exact provinces," or "Inspect this state and split these provinces while keeping supply and railway references valid." See [Maps](docs/map.md).
62
+ - Event chains: "Trace every route from this event and explain where its flags and variables change," or "Compare the workspace event graph with its previous revision and render the affected routes." See [Event chains](docs/events.md).
60
63
 
61
64
  ## HTTP
62
65
 
63
- Rewrites validate and apply in one call. The installed game is never changed, and an interrupted multi-file rewrite is recovered automatically. Local MCP clients should use stdio; shared or remote deployments use the controls described in [HTTP](docs/http.md).
66
+ Use stdio for local MCP clients. For shared or remote deployments, see [HTTP](docs/http.md).
64
67
 
65
68
  ## Development
66
69
 
@@ -33,7 +33,15 @@ function defaultPatterns(workspace) {
33
33
  'common/characters/**/*.txt',
34
34
  'common/scripted_effects/**/*.txt',
35
35
  'common/scripted_triggers/**/*.txt',
36
+ 'common/on_actions/**/*.txt',
37
+ 'common/operations/**/*.txt',
38
+ 'common/raids/**/*.txt',
39
+ 'common/bop/**/*.txt',
40
+ 'common/resistance_compliance_modifiers/**/*.txt',
41
+ 'common/special_projects/**/*.txt',
36
42
  'events/**/*.txt',
43
+ 'history/countries/**/*.txt',
44
+ 'history/states/**/*.txt',
37
45
  ...under(roots.states, '**/*.txt'),
38
46
  // Binary rasters and positional data tables are domain-selected by Agent
39
47
  // Nudger. The shared symbol scan retains only map sources it can index.
@@ -183,6 +191,10 @@ export class CoreEngine {
183
191
  flight.controller.abort();
184
192
  }
185
193
  }
194
+ /** Monotonic cache generation used by domain services to invalidate derived snapshots. */
195
+ generation(workspaceId) {
196
+ return this.#scanGenerations.get(workspaceId) ?? 0;
197
+ }
186
198
  awaitScanFlight(requestKey, flight, signal) {
187
199
  signal?.throwIfAborted();
188
200
  flight.waiters += 1;
@@ -4,6 +4,8 @@ import { WORKSPACE_MAX_REGISTRATIONS, workspaceRegistrationSchema, } from './con
4
4
  import { compareCodeUnits, hashCanonical, sha256Bytes } from './canonical.js';
5
5
  import { ServiceError } from './result.js';
6
6
  import { ServerState } from './server-state.js';
7
+ /** Omitted MCP workspace arguments resolve to the mod containing the server cwd. */
8
+ export const CURRENT_WORKSPACE_ID = 'current';
7
9
  async function exists(value, signal) {
8
10
  signal?.throwIfAborted();
9
11
  try {
@@ -185,6 +187,9 @@ export class WorkspaceResolver {
185
187
  }
186
188
  }
187
189
  for (const configured of configuration.workspaces) {
190
+ if (configured.id === CURRENT_WORKSPACE_ID) {
191
+ throw new ServiceError('WORKSPACE_ID_RESERVED', `Workspace ID is reserved for the current working mod: ${CURRENT_WORKSPACE_ID}`);
192
+ }
188
193
  const registration = resolver.withGlobalWorkspaceDefaults(configured);
189
194
  const workspace = await resolver.resolveRegistration(registration);
190
195
  if (resolver.#byId.has(workspace.id)) {
@@ -203,6 +208,28 @@ export class WorkspaceResolver {
203
208
  config() {
204
209
  return this.configuration;
205
210
  }
211
+ /** Resolve the implicit workspace used by local coding-agent calls. */
212
+ resolveCurrentWorkspaceId(principal) {
213
+ const workspaces = this.list(principal).filter(({ registration }) => registration.kind === 'mod');
214
+ if (workspaces.length === 0) {
215
+ throw new ServiceError('WORKSPACE_CONTEXT_NOT_FOUND', 'No writable mod workspace is configured for the MCP server');
216
+ }
217
+ const currentDirectory = path.normalize(process.cwd());
218
+ const containing = workspaces.filter((workspace) => isWithin(workspace.modRoot, currentDirectory));
219
+ if (containing.length === 1)
220
+ return containing[0].id;
221
+ if (containing.length > 1) {
222
+ throw new ServiceError('WORKSPACE_CONTEXT_AMBIGUOUS', 'The MCP working directory is inside more than one writable mod workspace', { currentDirectory });
223
+ }
224
+ if (workspaces.length === 1)
225
+ return workspaces[0].id;
226
+ throw new ServiceError('WORKSPACE_CONTEXT_REQUIRED', 'The MCP working directory does not identify one writable mod workspace', { currentDirectory, workspaceIds: workspaces.map(({ id }) => id) });
227
+ }
228
+ resolveWorkspaceId(workspaceId, principal) {
229
+ return workspaceId === CURRENT_WORKSPACE_ID
230
+ ? this.resolveCurrentWorkspaceId(principal)
231
+ : workspaceId;
232
+ }
206
233
  serverState() {
207
234
  return this.#serverState;
208
235
  }
@@ -276,15 +303,16 @@ export class WorkspaceResolver {
276
303
  .sort((a, b) => compareCodeUnits(a.id, b.id));
277
304
  }
278
305
  get(workspaceId, principal) {
279
- if (principal !== undefined && !this.allowedWorkspaceIds(principal).has(workspaceId)) {
306
+ const resolvedWorkspaceId = this.resolveWorkspaceId(workspaceId, principal);
307
+ if (principal !== undefined && !this.allowedWorkspaceIds(principal).has(resolvedWorkspaceId)) {
280
308
  throw new ServiceError('WORKSPACE_INACCESSIBLE', 'Workspace is unavailable to the authenticated principal');
281
309
  }
282
- const workspace = this.#byId.get(workspaceId);
310
+ const workspace = this.#byId.get(resolvedWorkspaceId);
283
311
  if (workspace === undefined) {
284
312
  if (principal !== undefined) {
285
313
  throw new ServiceError('WORKSPACE_INACCESSIBLE', 'Workspace is unavailable to the authenticated principal');
286
314
  }
287
- throw new ServiceError('WORKSPACE_NOT_REGISTERED', `Workspace is not registered: ${workspaceId}`);
315
+ throw new ServiceError('WORKSPACE_NOT_REGISTERED', `Workspace is not registered: ${resolvedWorkspaceId}`);
288
316
  }
289
317
  return workspace;
290
318
  }
@@ -0,0 +1,208 @@
1
+ import { compareCodeUnits } from '../core/canonical.js';
2
+ import { EventAnalysisBudget } from './limits.js';
3
+ import { iterativeStronglyConnectedComponents } from './scc.js';
4
+ function sortedUnique(values) {
5
+ return [...new Set(values)].sort(compareCodeUnits);
6
+ }
7
+ function nodeMap(graph) {
8
+ return new Map(graph.nodes.map((node) => [node.id, node]));
9
+ }
10
+ /**
11
+ * Select either the structural helper graph or its event-to-event projection.
12
+ * Direct, option, entry, unresolved, and terminal edges remain visible in both.
13
+ */
14
+ export function eventFlowEdges(graph, expandHelpers) {
15
+ const nodes = nodeMap(graph);
16
+ return graph.edges
17
+ .filter((edge) => {
18
+ if (expandHelpers)
19
+ return !edge.derived;
20
+ if (edge.derived)
21
+ return true;
22
+ const from = nodes.get(edge.from);
23
+ const to = nodes.get(edge.to);
24
+ return from?.kind !== 'helper' && to?.kind !== 'helper';
25
+ })
26
+ .sort((left, right) => compareCodeUnits(left.id, right.id));
27
+ }
28
+ function edgeMaps(edges) {
29
+ const outgoing = new Map();
30
+ const incoming = new Map();
31
+ for (const edge of edges) {
32
+ const from = outgoing.get(edge.from) ?? [];
33
+ from.push(edge);
34
+ outgoing.set(edge.from, from);
35
+ const to = incoming.get(edge.to) ?? [];
36
+ to.push(edge);
37
+ incoming.set(edge.to, to);
38
+ }
39
+ for (const values of [...outgoing.values(), ...incoming.values()]) {
40
+ values.sort((left, right) => compareCodeUnits(left.id, right.id));
41
+ }
42
+ return { outgoing, incoming };
43
+ }
44
+ function edgesForDirection(nodeId, direction, maps) {
45
+ const results = [];
46
+ if (direction !== 'upstream') {
47
+ for (const edge of maps.outgoing.get(nodeId) ?? [])
48
+ results.push({ edge, next: edge.to });
49
+ }
50
+ if (direction !== 'downstream') {
51
+ for (const edge of maps.incoming.get(nodeId) ?? [])
52
+ results.push({ edge, next: edge.from });
53
+ }
54
+ return results.sort((left, right) => compareCodeUnits(left.next, right.next) || compareCodeUnits(left.edge.id, right.edge.id));
55
+ }
56
+ export function traceEventGraph(graph, startNodeIds, boundary, signal) {
57
+ signal?.throwIfAborted();
58
+ const byId = nodeMap(graph);
59
+ const resolvedStarts = sortedUnique(startNodeIds).filter((id) => byId.has(id));
60
+ const starts = resolvedStarts.slice(0, Math.max(0, boundary.maxNodes));
61
+ const edges = eventFlowEdges(graph, boundary.expandHelpers);
62
+ const maps = edgeMaps(edges);
63
+ const depthByNode = new Map(starts.map((id) => [id, 0]));
64
+ const queue = [...starts];
65
+ const retainedEdges = new Map();
66
+ const boundaryNodes = new Set(resolvedStarts.length > starts.length ? starts : []);
67
+ let truncated = resolvedStarts.length > starts.length;
68
+ let cursor = 0;
69
+ while (cursor < queue.length) {
70
+ if ((cursor & 255) === 0)
71
+ signal?.throwIfAborted();
72
+ const current = queue[cursor++];
73
+ const depth = depthByNode.get(current) ?? 0;
74
+ const nextEdges = edgesForDirection(current, boundary.direction, maps);
75
+ if (depth >= boundary.maxDepth) {
76
+ if (nextEdges.length > 0) {
77
+ boundaryNodes.add(current);
78
+ truncated = true;
79
+ }
80
+ continue;
81
+ }
82
+ for (const candidate of nextEdges) {
83
+ if (retainedEdges.size >= boundary.maxEdges) {
84
+ boundaryNodes.add(current);
85
+ truncated = true;
86
+ break;
87
+ }
88
+ const known = depthByNode.has(candidate.next);
89
+ if (!known && depthByNode.size >= boundary.maxNodes) {
90
+ boundaryNodes.add(current);
91
+ truncated = true;
92
+ continue;
93
+ }
94
+ retainedEdges.set(candidate.edge.id, candidate.edge);
95
+ if (!known) {
96
+ depthByNode.set(candidate.next, depth + 1);
97
+ queue.push(candidate.next);
98
+ }
99
+ }
100
+ }
101
+ const retainedNodes = [...depthByNode.keys()]
102
+ .flatMap((id) => {
103
+ const node = byId.get(id);
104
+ return node === undefined ? [] : [node];
105
+ })
106
+ .sort((left, right) => compareCodeUnits(left.id, right.id));
107
+ const layers = [...new Set(depthByNode.values())]
108
+ .sort((left, right) => left - right)
109
+ .map((depth) => ({
110
+ depth,
111
+ nodeIds: [...depthByNode.entries()]
112
+ .filter(([, value]) => value === depth)
113
+ .map(([id]) => id)
114
+ .sort(compareCodeUnits),
115
+ }));
116
+ return {
117
+ startNodeIds: starts,
118
+ nodes: retainedNodes,
119
+ edges: [...retainedEdges.values()].sort((left, right) => compareCodeUnits(left.id, right.id)),
120
+ layers,
121
+ boundary,
122
+ boundaryNodeIds: [...boundaryNodes].sort(compareCodeUnits),
123
+ truncated,
124
+ };
125
+ }
126
+ export function shortestEventPath(graph, fromNodeIds, toNodeIds, options, signal) {
127
+ signal?.throwIfAborted();
128
+ const nodes = nodeMap(graph);
129
+ const starts = sortedUnique(fromNodeIds).filter((id) => nodes.has(id));
130
+ const targets = new Set(toNodeIds.filter((id) => nodes.has(id)));
131
+ const maps = edgeMaps(eventFlowEdges(graph, options.expandHelpers));
132
+ const queue = starts.map((nodeId) => ({ nodeId, depth: 0 }));
133
+ const visited = new Set(starts);
134
+ const predecessor = new Map();
135
+ let cursor = 0;
136
+ let found = starts.find((id) => targets.has(id));
137
+ while (found === undefined && cursor < queue.length) {
138
+ if ((cursor & 255) === 0)
139
+ signal?.throwIfAborted();
140
+ const current = queue[cursor++];
141
+ if (current.depth >= options.maxDepth)
142
+ continue;
143
+ for (const edge of maps.outgoing.get(current.nodeId) ?? []) {
144
+ if (visited.has(edge.to))
145
+ continue;
146
+ if (visited.size >= options.maxNodes)
147
+ return undefined;
148
+ visited.add(edge.to);
149
+ predecessor.set(edge.to, { nodeId: current.nodeId, edge });
150
+ if (targets.has(edge.to)) {
151
+ found = edge.to;
152
+ break;
153
+ }
154
+ queue.push({ nodeId: edge.to, depth: current.depth + 1 });
155
+ }
156
+ }
157
+ if (found === undefined)
158
+ return undefined;
159
+ const nodeIds = [found];
160
+ const pathEdges = [];
161
+ let cursorNode = found;
162
+ while (!starts.includes(cursorNode)) {
163
+ const previous = predecessor.get(cursorNode);
164
+ if (previous === undefined)
165
+ return undefined;
166
+ pathEdges.push(previous.edge);
167
+ cursorNode = previous.nodeId;
168
+ nodeIds.push(cursorNode);
169
+ }
170
+ nodeIds.reverse();
171
+ pathEdges.reverse();
172
+ return { nodeIds, edges: pathEdges };
173
+ }
174
+ /** Stable, bounded iterative Tarjan SCCs over the selected flow projection. */
175
+ export function eventStronglyConnectedComponents(graph, expandHelpers = false, selectedNodeIds, signal) {
176
+ signal?.throwIfAborted();
177
+ const retainedNodes = graph.nodes
178
+ .filter((node) => selectedNodeIds === undefined || selectedNodeIds.has(node.id))
179
+ .map(({ id }) => id)
180
+ .sort(compareCodeUnits);
181
+ const retained = new Set(retainedNodes);
182
+ const edges = eventFlowEdges(graph, expandHelpers).filter(({ from, to }) => retained.has(from) && retained.has(to));
183
+ const outgoing = edgeMaps(edges).outgoing;
184
+ const adjacency = new Map([...outgoing].map(([nodeId, values]) => [nodeId, values.map(({ to }) => to)]));
185
+ const budget = new EventAnalysisBudget(signal);
186
+ const components = iterativeStronglyConnectedComponents(retainedNodes, adjacency, () => budget.spend('strongly_connected_components'));
187
+ budget.check();
188
+ return components
189
+ .map((nodeIds) => {
190
+ const members = new Set(nodeIds);
191
+ const selfLoop = edges.some(({ from, to }) => from === to && members.has(from));
192
+ return {
193
+ id: `scc:${nodeIds.join('|')}`,
194
+ nodeIds,
195
+ cyclic: nodeIds.length > 1 || selfLoop,
196
+ };
197
+ })
198
+ .sort((left, right) => compareCodeUnits(left.id, right.id));
199
+ }
200
+ export function reachableEventNodeIds(graph, startNodeIds, direction, expandHelpers = false, signal) {
201
+ return traceEventGraph(graph, startNodeIds, {
202
+ maxDepth: Number.MAX_SAFE_INTEGER,
203
+ maxNodes: Math.max(1, graph.nodes.length),
204
+ maxEdges: Math.max(1, graph.edges.length),
205
+ direction,
206
+ expandHelpers,
207
+ }, signal).nodes.map(({ id }) => id);
208
+ }