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
@@ -0,0 +1,643 @@
1
+ import { compareCodeUnits } from '../core/canonical.js';
2
+ import { eventFlowEdges, eventStronglyConnectedComponents, reachableEventNodeIds, shortestEventPath, traceEventGraph, } from './algorithms.js';
3
+ function reachableFromRootsWithout(graph, rootIds, removedNodeIds, signal) {
4
+ signal?.throwIfAborted();
5
+ const retainedNodeIds = new Set(graph.nodes.filter(({ id }) => !removedNodeIds.has(id)).map(({ id }) => id));
6
+ const outgoing = new Map();
7
+ for (const [index, edge] of eventFlowEdges(graph, true).entries()) {
8
+ if ((index & 255) === 0)
9
+ signal?.throwIfAborted();
10
+ if (removedNodeIds.has(edge.from) ||
11
+ removedNodeIds.has(edge.to) ||
12
+ !retainedNodeIds.has(edge.from) ||
13
+ !retainedNodeIds.has(edge.to)) {
14
+ continue;
15
+ }
16
+ const targets = outgoing.get(edge.from) ?? [];
17
+ targets.push(edge.to);
18
+ outgoing.set(edge.from, targets);
19
+ }
20
+ for (const targets of outgoing.values())
21
+ targets.sort(compareCodeUnits);
22
+ const reachable = new Set([...new Set(rootIds)].filter((id) => retainedNodeIds.has(id)).sort(compareCodeUnits));
23
+ const queue = [...reachable];
24
+ let cursor = 0;
25
+ while (cursor < queue.length) {
26
+ if ((cursor & 255) === 0)
27
+ signal?.throwIfAborted();
28
+ const current = queue[cursor++];
29
+ for (const target of outgoing.get(current) ?? []) {
30
+ if (reachable.has(target))
31
+ continue;
32
+ reachable.add(target);
33
+ queue.push(target);
34
+ }
35
+ }
36
+ signal?.throwIfAborted();
37
+ return reachable;
38
+ }
39
+ function locationContains(location, line, column) {
40
+ if (location === undefined)
41
+ return false;
42
+ if (line < location.start.line || line > location.end.line)
43
+ return false;
44
+ if (column === undefined)
45
+ return true;
46
+ if (line === location.start.line && column < location.start.column)
47
+ return false;
48
+ if (line === location.end.line && column > location.end.column)
49
+ return false;
50
+ return true;
51
+ }
52
+ function pathMatches(actual, requested) {
53
+ if (actual === undefined)
54
+ return false;
55
+ const normalizedActual = actual.replaceAll('\\', '/').toLowerCase();
56
+ const normalizedRequested = requested.replaceAll('\\', '/').toLowerCase();
57
+ return (normalizedActual === normalizedRequested ||
58
+ normalizedActual.endsWith(`:${normalizedRequested}`) ||
59
+ normalizedActual.endsWith(`/${normalizedRequested}`));
60
+ }
61
+ function manifestMatches(node, manifest) {
62
+ if (manifest.nodeIds?.includes(node.id) === true)
63
+ return true;
64
+ if (node.eventId !== undefined && manifest.eventIds?.includes(node.eventId) === true)
65
+ return true;
66
+ if (node.namespace !== undefined && manifest.namespaces?.includes(node.namespace) === true)
67
+ return true;
68
+ return (manifest.sourcePaths?.some((sourcePath) => pathMatches(node.sourcePath, sourcePath)) === true);
69
+ }
70
+ export function selectEventNodes(graph, selector, signal) {
71
+ signal?.throwIfAborted();
72
+ const matched = [];
73
+ for (const [index, node] of graph.nodes.entries()) {
74
+ if ((index & 255) === 0)
75
+ signal?.throwIfAborted();
76
+ let selected;
77
+ switch (selector.kind) {
78
+ case 'event':
79
+ selected = node.eventId === selector.eventId;
80
+ break;
81
+ case 'namespace':
82
+ selected = node.namespace === selector.namespace;
83
+ break;
84
+ case 'file':
85
+ selected = pathMatches(node.sourcePath ?? node.location?.path, selector.sourcePath);
86
+ break;
87
+ case 'source':
88
+ selected =
89
+ pathMatches(node.sourcePath ?? node.location?.path, selector.sourcePath) &&
90
+ locationContains(node.location, selector.line, selector.column);
91
+ break;
92
+ case 'node':
93
+ selected = node.id === selector.nodeId;
94
+ break;
95
+ case 'manifest':
96
+ selected = manifestMatches(node, selector.manifest);
97
+ break;
98
+ }
99
+ if (selected)
100
+ matched.push(node);
101
+ }
102
+ signal?.throwIfAborted();
103
+ return matched.sort((left, right) => compareCodeUnits(left.id, right.id));
104
+ }
105
+ export function discoverEventRoots(graph, signal) {
106
+ signal?.throwIfAborted();
107
+ const edges = eventFlowEdges(graph, false);
108
+ const incoming = new Map();
109
+ for (const [index, edge] of edges.entries()) {
110
+ if ((index & 255) === 0)
111
+ signal?.throwIfAborted();
112
+ incoming.set(edge.to, (incoming.get(edge.to) ?? 0) + 1);
113
+ }
114
+ const eventFlowSources = new Set(edges.map(({ from }) => from));
115
+ const entryPoints = graph.nodes
116
+ .filter(({ id, kind }, index) => {
117
+ if ((index & 255) === 0)
118
+ signal?.throwIfAborted();
119
+ return kind === 'entry' && eventFlowSources.has(id);
120
+ })
121
+ .sort((left, right) => compareCodeUnits(left.id, right.id));
122
+ const automaticEvents = graph.nodes
123
+ .filter((node, index) => {
124
+ if ((index & 255) === 0)
125
+ signal?.throwIfAborted();
126
+ return (node.kind === 'event' &&
127
+ (node.metadata.isTriggeredOnly === false || node.metadata.automatic === true));
128
+ })
129
+ .sort((left, right) => compareCodeUnits(left.id, right.id));
130
+ const entryIds = new Set(entryPoints.map(({ id }) => id));
131
+ const rootEventNodeIds = new Set([
132
+ ...automaticEvents.map(({ id }) => id),
133
+ ...edges.filter(({ from }) => entryIds.has(from)).map(({ to }) => to),
134
+ ]);
135
+ const callerlessTriggeredEvents = graph.nodes
136
+ .filter((node, index) => {
137
+ if ((index & 255) === 0)
138
+ signal?.throwIfAborted();
139
+ return (node.kind === 'event' &&
140
+ node.metadata.isTriggeredOnly === true &&
141
+ (incoming.get(node.id) ?? 0) === 0);
142
+ })
143
+ .sort((left, right) => compareCodeUnits(left.id, right.id));
144
+ return {
145
+ entryPoints,
146
+ automaticEvents,
147
+ knownRootEventIds: graph.nodes
148
+ .filter(({ kind, id }) => kind === 'event' && rootEventNodeIds.has(id))
149
+ .flatMap(({ eventId }) => (eventId === undefined ? [] : [eventId]))
150
+ .sort(compareCodeUnits),
151
+ callerlessTriggeredEvents,
152
+ stronglyConnectedComponents: eventStronglyConnectedComponents(graph, false, undefined, signal).filter(({ cyclic }) => cyclic),
153
+ };
154
+ }
155
+ export function traceSelectedEvents(graph, selector, boundary, signal) {
156
+ return traceEventGraph(graph, selectEventNodes(graph, selector, signal).map(({ id }) => id), boundary, signal);
157
+ }
158
+ function stateForNode(graph, nodeId, accesses) {
159
+ return graph.stateAccesses
160
+ .filter(({ ownerId, access }) => ownerId === nodeId &&
161
+ (accesses.includes(access) ||
162
+ (access === 'read_write' && (accesses.includes('read') || accesses.includes('write')))))
163
+ .sort((left, right) => compareCodeUnits(left.id, right.id));
164
+ }
165
+ function relevantUnresolvedFrom(graph, startIds, signal) {
166
+ const reachable = new Set(reachableEventNodeIds(graph, startIds, 'downstream', false, signal));
167
+ const sourcePaths = new Set(graph.nodes
168
+ .filter(({ id }) => reachable.has(id))
169
+ .flatMap(({ sourcePath, location }) => [sourcePath, location?.path])
170
+ .filter((value) => value !== undefined));
171
+ return graph.unresolved.filter((item, index) => {
172
+ if ((index & 255) === 0)
173
+ signal?.throwIfAborted();
174
+ if (reachable.has(item.id) || (item.ownerId !== undefined && reachable.has(item.ownerId))) {
175
+ return true;
176
+ }
177
+ if (item.kind !== 'partial_source')
178
+ return false;
179
+ const path = item.location?.path ??
180
+ item.blockers.find(({ location }) => location !== undefined)?.location?.path;
181
+ if (path !== undefined && sourcePaths.has(path))
182
+ return true;
183
+ return sourcePaths.has(item.expression);
184
+ });
185
+ }
186
+ function pathUnresolvedAssumptions(graph, nodeIds, edges) {
187
+ const assumptions = [];
188
+ for (const edge of edges) {
189
+ if (edge.confidence !== 'confirmed') {
190
+ assumptions.push({
191
+ edgeId: edge.id,
192
+ blocker: typeof edge.metadata.blocker === 'string'
193
+ ? edge.metadata.blocker
194
+ : `Edge confidence is ${edge.confidence}.`,
195
+ });
196
+ }
197
+ if (edge.scope !== undefined &&
198
+ (edge.scope.confidence !== 'confirmed' ||
199
+ edge.scope.source === 'unknown' ||
200
+ edge.scope.destination === 'unknown')) {
201
+ assumptions.push({
202
+ edgeId: edge.id,
203
+ ...(edge.scope.expression === undefined ? {} : { expression: edge.scope.expression }),
204
+ blocker: `Scope transition ${edge.scope.source} to ${edge.scope.destination} is ${edge.scope.confidence}.`,
205
+ });
206
+ }
207
+ if (edge.timing?.mode === 'unknown') {
208
+ assumptions.push({
209
+ edgeId: edge.id,
210
+ ...(edge.timing.expression === undefined ? {} : { expression: edge.timing.expression }),
211
+ blocker: 'Event timing could not be resolved statically.',
212
+ });
213
+ }
214
+ if (edge.weight?.valid === false) {
215
+ assumptions.push({
216
+ edgeId: edge.id,
217
+ expression: edge.weight.value,
218
+ blocker: 'The event weight is missing or invalid.',
219
+ });
220
+ }
221
+ else if (edge.weight?.valid === 'unknown') {
222
+ assumptions.push({
223
+ edgeId: edge.id,
224
+ expression: edge.weight.value,
225
+ blocker: 'The symbolic event weight is evaluated at runtime.',
226
+ });
227
+ }
228
+ for (const condition of [...edge.conditions, ...(edge.weight?.modifiers ?? [])]) {
229
+ if (condition.confidence === 'confirmed')
230
+ continue;
231
+ assumptions.push({
232
+ edgeId: edge.id,
233
+ expression: condition.expression,
234
+ blocker: `${condition.kind.replaceAll('_', ' ')} confidence is ${condition.confidence}.`,
235
+ });
236
+ }
237
+ }
238
+ const pathNodes = new Set(nodeIds);
239
+ for (const access of graph.stateAccesses) {
240
+ if (!pathNodes.has(access.ownerId) || (!access.dynamic && access.confidence === 'confirmed'))
241
+ continue;
242
+ assumptions.push({
243
+ nodeId: access.ownerId,
244
+ expression: `${access.kind}:${access.name}`,
245
+ blocker: access.dynamic
246
+ ? 'A dynamic state name cannot be resolved statically.'
247
+ : `State access confidence is ${access.confidence}.`,
248
+ });
249
+ }
250
+ const retained = new Map();
251
+ for (const assumption of assumptions) {
252
+ const key = JSON.stringify(assumption);
253
+ if (!retained.has(key))
254
+ retained.set(key, assumption);
255
+ }
256
+ return [...retained.values()];
257
+ }
258
+ export function explainEventPath(graph, from, to, boundary, signal) {
259
+ const fromNodes = selectEventNodes(graph, from, signal);
260
+ const toNodes = selectEventNodes(graph, to, signal);
261
+ const fromNodeIds = fromNodes.map(({ id }) => id);
262
+ const toNodeIds = toNodes.map(({ id }) => id);
263
+ if (fromNodeIds.length === 0 || toNodeIds.length === 0) {
264
+ return {
265
+ found: false,
266
+ fromNodeIds,
267
+ toNodeIds,
268
+ steps: [],
269
+ failureReason: 'unsupported_analysis',
270
+ failureMessage: fromNodeIds.length === 0
271
+ ? 'The source selector did not resolve to an indexed event-chain node.'
272
+ : 'The destination selector did not resolve to an indexed event-chain node.',
273
+ unresolvedAssumptions: [],
274
+ boundary,
275
+ };
276
+ }
277
+ const path = shortestEventPath(graph, fromNodeIds, toNodeIds, boundary, signal);
278
+ if (path !== undefined) {
279
+ const byId = new Map(graph.nodes.map((node) => [node.id, node]));
280
+ const steps = path.nodeIds.flatMap((nodeId, index) => {
281
+ const node = byId.get(nodeId);
282
+ if (node === undefined)
283
+ return [];
284
+ const via = index === 0 ? undefined : path.edges[index - 1];
285
+ return [
286
+ {
287
+ index,
288
+ node,
289
+ ...(via === undefined ? {} : { via }),
290
+ requiredState: stateForNode(graph, nodeId, ['read']),
291
+ producedState: stateForNode(graph, nodeId, ['write', 'replace', 'clear']),
292
+ },
293
+ ];
294
+ });
295
+ return {
296
+ found: true,
297
+ fromNodeIds,
298
+ toNodeIds,
299
+ steps,
300
+ unresolvedAssumptions: pathUnresolvedAssumptions(graph, path.nodeIds, path.edges),
301
+ boundary,
302
+ };
303
+ }
304
+ const unrestricted = shortestEventPath(graph, fromNodeIds, toNodeIds, {
305
+ expandHelpers: boundary.expandHelpers,
306
+ maxDepth: Math.max(graph.nodes.length, boundary.maxDepth),
307
+ maxNodes: Math.max(graph.nodes.length, boundary.maxNodes),
308
+ }, signal);
309
+ let failureReason;
310
+ let failureMessage;
311
+ const relevantUnresolved = relevantUnresolvedFrom(graph, fromNodeIds, signal);
312
+ const hasDynamicDispatch = relevantUnresolved.some(({ kind }) => kind === 'dynamic_event' || kind === 'dynamic_helper');
313
+ const hasUnsupportedAnalysis = relevantUnresolved.some(({ kind }) => kind !== 'dynamic_event' && kind !== 'dynamic_helper');
314
+ const inventoryPartial = graph.skippedSourceCount > 0 ||
315
+ graph.diagnostics.some(({ code }) => code === 'EVENT_INVENTORY_PARTIAL');
316
+ if (unrestricted !== undefined) {
317
+ failureReason = 'trace_boundary';
318
+ failureMessage = 'A path exists outside the requested trace boundary.';
319
+ }
320
+ else if (hasUnsupportedAnalysis) {
321
+ failureReason = 'unsupported_analysis';
322
+ failureMessage = 'Reachable missing or unsupported source prevents a conclusive path result.';
323
+ }
324
+ else if (hasDynamicDispatch) {
325
+ failureReason = 'dynamic_dispatch';
326
+ failureMessage = 'A statically unresolved dispatch may connect the selected route.';
327
+ }
328
+ else if (inventoryPartial) {
329
+ failureReason = 'unsupported_analysis';
330
+ failureMessage = 'Skipped or unsupported source prevents a conclusive reachability result.';
331
+ }
332
+ else {
333
+ failureReason = 'unreachable';
334
+ failureMessage = 'No statically proven path connects the selected nodes.';
335
+ }
336
+ return {
337
+ found: false,
338
+ fromNodeIds,
339
+ toNodeIds,
340
+ steps: [],
341
+ failureReason,
342
+ failureMessage,
343
+ unresolvedAssumptions: relevantUnresolved.map((item) => ({
344
+ ...(item.ownerId === undefined ? {} : { nodeId: item.ownerId }),
345
+ expression: item.expression,
346
+ ...(item.blockers[0] === undefined ? {} : { blocker: item.blockers[0].message }),
347
+ })),
348
+ boundary,
349
+ };
350
+ }
351
+ function stateKey(access) {
352
+ const scope = access.kind === 'variable' || access.kind === 'array' ? `:${access.scope}` : '';
353
+ const storage = access.kind === 'saved_scope'
354
+ ? `:${access.metadata.storage === 'global' || access.metadata.storage === 'local'
355
+ ? access.metadata.storage
356
+ : 'unknown'}`
357
+ : '';
358
+ return `${access.kind}:${access.name}${scope}${storage}`;
359
+ }
360
+ function compatibleProducerKeys(access) {
361
+ if (access.kind !== 'saved_scope' ||
362
+ access.metadata.storage === 'global' ||
363
+ access.metadata.storage === 'local') {
364
+ return [stateKey(access)];
365
+ }
366
+ return [
367
+ `saved_scope:${access.name}:global`,
368
+ `saved_scope:${access.name}:local`,
369
+ `saved_scope:${access.name}:unknown`,
370
+ ];
371
+ }
372
+ function hasUnknownSavedScopeStorage(access) {
373
+ return (access.kind === 'saved_scope' &&
374
+ access.metadata.storage !== 'global' &&
375
+ access.metadata.storage !== 'local');
376
+ }
377
+ function compatibleProducers(producers, consumer) {
378
+ const compatible = new Map();
379
+ for (const key of compatibleProducerKeys(consumer)) {
380
+ for (const producer of producers.get(key) ?? [])
381
+ compatible.set(producer.id, producer);
382
+ }
383
+ return [...compatible.values()].sort((left, right) => compareCodeUnits(left.id, right.id));
384
+ }
385
+ function logicalStateKey(access) {
386
+ return `${access.kind}:${access.name}`;
387
+ }
388
+ function selectedOwnershipIds(graph, selector, signal) {
389
+ const selected = new Set(selectEventNodes(graph, selector, signal).map(({ id }) => id));
390
+ const ownershipEdges = graph.edges
391
+ .filter(({ derived, reason }) => !derived && (reason === 'option_branch' || reason === 'scripted_effect_call'))
392
+ .sort((left, right) => compareCodeUnits(left.id, right.id));
393
+ let changed = true;
394
+ let visits = 0;
395
+ while (changed) {
396
+ changed = false;
397
+ for (const edge of ownershipEdges) {
398
+ if ((visits++ & 255) === 0)
399
+ signal?.throwIfAborted();
400
+ if (selected.has(edge.from) && !selected.has(edge.to)) {
401
+ selected.add(edge.to);
402
+ changed = true;
403
+ }
404
+ if (edge.reason === 'option_branch' && selected.has(edge.to) && !selected.has(edge.from)) {
405
+ selected.add(edge.from);
406
+ changed = true;
407
+ }
408
+ }
409
+ }
410
+ return selected;
411
+ }
412
+ export function inspectEventStateFlow(graph, selector, subject, signal) {
413
+ signal?.throwIfAborted();
414
+ const selectedOwners = selector === undefined ? undefined : selectedOwnershipIds(graph, selector, signal);
415
+ const selectedAccesses = graph.stateAccesses
416
+ .filter((access, index) => {
417
+ if ((index & 255) === 0)
418
+ signal?.throwIfAborted();
419
+ return ((selectedOwners === undefined || selectedOwners.has(access.ownerId)) &&
420
+ (subject === undefined ||
421
+ (access.kind === subject.kind &&
422
+ (access.name === subject.name ||
423
+ (access.kind === 'global_event_target' &&
424
+ access.access === 'clear' &&
425
+ access.name === '*')))));
426
+ })
427
+ .sort((left, right) => compareCodeUnits(left.id, right.id));
428
+ const allProducers = new Map();
429
+ const logicalProducers = new Map();
430
+ for (const [index, access] of graph.stateAccesses.entries()) {
431
+ if ((index & 255) === 0)
432
+ signal?.throwIfAborted();
433
+ if (access.access !== 'write' && access.access !== 'read_write' && access.access !== 'replace')
434
+ continue;
435
+ const values = allProducers.get(stateKey(access)) ?? [];
436
+ values.push(access);
437
+ allProducers.set(stateKey(access), values);
438
+ const logical = logicalProducers.get(logicalStateKey(access)) ?? [];
439
+ logical.push(access);
440
+ logicalProducers.set(logicalStateKey(access), logical);
441
+ }
442
+ const accessById = new Map(graph.stateAccesses.map((access) => [access.id, access]));
443
+ const selectedAccessIds = new Set(selectedAccesses.map(({ id }) => id));
444
+ const links = graph.stateLinks.flatMap((link, index) => {
445
+ if ((index & 255) === 0)
446
+ signal?.throwIfAborted();
447
+ const producer = accessById.get(link.producerId);
448
+ const consumer = accessById.get(link.consumerId);
449
+ if (producer === undefined ||
450
+ consumer === undefined ||
451
+ !selectedAccessIds.has(consumer.id) ||
452
+ (subject !== undefined && (link.stateKind !== subject.kind || link.name !== subject.name)))
453
+ return [];
454
+ return [
455
+ {
456
+ id: link.id,
457
+ kind: link.stateKind,
458
+ name: link.name,
459
+ producerId: producer.id,
460
+ consumerId: consumer.id,
461
+ producerOwnerId: producer.ownerId,
462
+ consumerOwnerId: consumer.ownerId,
463
+ confidence: link.confidence,
464
+ pathConfirmed: link.pathConfirmed,
465
+ },
466
+ ];
467
+ });
468
+ const linkedConsumers = new Set(links.map(({ consumerId }) => consumerId));
469
+ let visits = 0;
470
+ for (const consumer of selectedAccesses.filter(({ access }) => access === 'read' || access === 'read_write')) {
471
+ if ((visits++ & 255) === 0)
472
+ signal?.throwIfAborted();
473
+ if (linkedConsumers.has(consumer.id))
474
+ continue;
475
+ const compatible = compatibleProducers(allProducers, consumer);
476
+ for (const producer of compatible) {
477
+ links.push({
478
+ id: `${hasUnknownSavedScopeStorage(consumer) ? 'state-link:storage-ambiguous' : 'state-link'}:${producer.id}:${consumer.id}`,
479
+ kind: consumer.kind,
480
+ name: consumer.name,
481
+ producerId: producer.id,
482
+ consumerId: consumer.id,
483
+ producerOwnerId: producer.ownerId,
484
+ consumerOwnerId: consumer.ownerId,
485
+ confidence: hasUnknownSavedScopeStorage(consumer)
486
+ ? 'unresolved'
487
+ : producer.confidence === 'confirmed' && consumer.confidence === 'confirmed'
488
+ ? 'confirmed'
489
+ : consumer.confidence,
490
+ pathConfirmed: false,
491
+ });
492
+ }
493
+ if (compatible.length === 0 && (consumer.kind === 'variable' || consumer.kind === 'array')) {
494
+ for (const producer of logicalProducers.get(logicalStateKey(consumer)) ?? []) {
495
+ links.push({
496
+ id: `state-link:scope-ambiguous:${producer.id}:${consumer.id}`,
497
+ kind: consumer.kind,
498
+ name: consumer.name,
499
+ producerId: producer.id,
500
+ consumerId: consumer.id,
501
+ producerOwnerId: producer.ownerId,
502
+ consumerOwnerId: consumer.ownerId,
503
+ confidence: 'unresolved',
504
+ pathConfirmed: false,
505
+ });
506
+ }
507
+ }
508
+ }
509
+ const evidenceIds = new Set([
510
+ ...selectedAccesses.map(({ id }) => id),
511
+ ...links.map(({ producerId }) => producerId),
512
+ ]);
513
+ const accesses = graph.stateAccesses
514
+ .filter(({ id }, index) => {
515
+ if ((index & 255) === 0)
516
+ signal?.throwIfAborted();
517
+ return evidenceIds.has(id);
518
+ })
519
+ .sort((left, right) => compareCodeUnits(left.id, right.id));
520
+ const clearKeys = new Set(graph.stateAccesses
521
+ .filter(({ access }, index) => {
522
+ if ((index & 255) === 0)
523
+ signal?.throwIfAborted();
524
+ return access === 'clear';
525
+ })
526
+ .map(stateKey));
527
+ const clearsAllGlobalTargets = clearKeys.has('global_event_target:*');
528
+ return {
529
+ accesses,
530
+ producers: accesses.filter(({ access }) => access === 'write' || access === 'read_write' || access === 'replace'),
531
+ consumers: accesses.filter(({ access }) => access === 'read' || access === 'read_write'),
532
+ clears: accesses.filter(({ access }) => access === 'clear'),
533
+ links: links.sort((left, right) => compareCodeUnits(left.id, right.id)),
534
+ unproducedReads: accesses.filter((access) => (access.access === 'read' || access.access === 'read_write') &&
535
+ compatibleProducers(allProducers, access).length === 0),
536
+ globalTargetLeaks: accesses.filter((access) => access.kind === 'global_event_target' &&
537
+ (access.access === 'write' || access.access === 'replace') &&
538
+ !clearsAllGlobalTargets &&
539
+ !clearKeys.has(stateKey(access))),
540
+ };
541
+ }
542
+ export function lintEventGraph(graph, selector, signal) {
543
+ signal?.throwIfAborted();
544
+ if (selector === undefined)
545
+ return [...graph.issues];
546
+ const selected = selectedOwnershipIds(graph, selector, signal);
547
+ const selectedNodes = graph.nodes.filter(({ id }, index) => {
548
+ if ((index & 255) === 0)
549
+ signal?.throwIfAborted();
550
+ return selected.has(id);
551
+ });
552
+ return graph.issues.filter((issue, index) => {
553
+ if ((index & 255) === 0)
554
+ signal?.throwIfAborted();
555
+ return (issueSubjectIds(issue).some((id) => selected.has(id)) ||
556
+ (issue.location !== undefined &&
557
+ selectedNodes.some(({ location }) => location !== undefined &&
558
+ location.path === issue.location?.path &&
559
+ issue.location.start.offset >= location.start.offset &&
560
+ issue.location.end.offset <= location.end.offset)));
561
+ });
562
+ }
563
+ export function issueSubjectIds(issue) {
564
+ const candidate = issue.details.subjectIds ?? issue.details.subjects;
565
+ return Array.isArray(candidate)
566
+ ? candidate.filter((value) => typeof value === 'string')
567
+ : [];
568
+ }
569
+ export function analyzeEventImpact(graph, subject, signal) {
570
+ signal?.throwIfAborted();
571
+ const directNodeIds = new Set();
572
+ const stateAccessIds = [];
573
+ if (subject.kind === 'event') {
574
+ for (const [index, node] of graph.nodes.entries()) {
575
+ if ((index & 255) === 0)
576
+ signal?.throwIfAborted();
577
+ if (node.eventId === subject.name)
578
+ directNodeIds.add(node.id);
579
+ }
580
+ }
581
+ else if (subject.kind === 'helper') {
582
+ for (const [index, node] of graph.nodes.entries()) {
583
+ if ((index & 255) === 0)
584
+ signal?.throwIfAborted();
585
+ if (node.kind === 'helper' &&
586
+ (node.label === subject.name || node.metadata.name === subject.name))
587
+ directNodeIds.add(node.id);
588
+ }
589
+ }
590
+ else {
591
+ for (const [index, access] of graph.stateAccesses.entries()) {
592
+ if ((index & 255) === 0)
593
+ signal?.throwIfAborted();
594
+ const kindMatches = access.kind === subject.kind ||
595
+ (subject.kind === 'flag' && access.kind.endsWith('_flag')) ||
596
+ (subject.kind === 'variable' && access.kind.endsWith('variable')) ||
597
+ (subject.kind === 'event_target' && access.kind.endsWith('event_target'));
598
+ if (kindMatches && access.name === subject.name) {
599
+ directNodeIds.add(access.ownerId);
600
+ stateAccessIds.push(access.id);
601
+ }
602
+ }
603
+ }
604
+ const direct = [...directNodeIds].sort(compareCodeUnits);
605
+ const expandHelpers = subject.kind === 'helper';
606
+ const upstreamNodeIds = reachableEventNodeIds(graph, direct, 'upstream', expandHelpers, signal);
607
+ const downstreamNodeIds = reachableEventNodeIds(graph, direct, 'downstream', expandHelpers, signal);
608
+ const affected = new Set([...upstreamNodeIds, ...downstreamNodeIds]);
609
+ const roots = discoverEventRoots(graph, signal);
610
+ const rootIds = [
611
+ ...roots.entryPoints.map(({ id }) => id),
612
+ ...roots.automaticEvents.map(({ id }) => id),
613
+ ].sort(compareCodeUnits);
614
+ const structuralRemoval = subject.kind === 'event' || subject.kind === 'helper';
615
+ const removedNodeIds = structuralRemoval ? new Set(direct) : new Set();
616
+ const baselineReachable = reachableFromRootsWithout(graph, rootIds, new Set(), signal);
617
+ const afterRemovalReachable = reachableFromRootsWithout(graph, rootIds, removedNodeIds, signal);
618
+ const wouldDisconnectNodeIds = structuralRemoval
619
+ ? [...baselineReachable]
620
+ .filter((id) => !removedNodeIds.has(id) && !afterRemovalReachable.has(id))
621
+ .sort(compareCodeUnits)
622
+ : [];
623
+ const terminalIds = new Set(graph.nodes.filter(({ kind }) => kind === 'terminal').map(({ id }) => id));
624
+ return {
625
+ subject,
626
+ directNodeIds: direct,
627
+ upstreamNodeIds,
628
+ downstreamNodeIds,
629
+ stateAccessIds: stateAccessIds.sort(compareCodeUnits),
630
+ affectedRootIds: rootIds.filter((id) => affected.has(id)).sort(compareCodeUnits),
631
+ affectedTerminalIds: graph.nodes
632
+ .filter(({ id, kind }) => kind === 'terminal' && affected.has(id))
633
+ .map(({ id }) => id)
634
+ .sort(compareCodeUnits),
635
+ removedRootIds: rootIds.filter((id) => removedNodeIds.has(id)),
636
+ wouldDisconnectNodeIds,
637
+ wouldDisconnectTerminalIds: wouldDisconnectNodeIds.filter((id) => terminalIds.has(id)),
638
+ unresolvedNodeIds: graph.nodes
639
+ .filter(({ id, kind }) => kind === 'unresolved' && affected.has(id))
640
+ .map(({ id }) => id)
641
+ .sort(compareCodeUnits),
642
+ };
643
+ }