hoi4-agent-tools 1.1.0 → 1.2.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.
- package/CHANGELOG.md +12 -0
- package/README.md +21 -16
- package/dist/hoi4_agent_tools/core/engine.js +12 -0
- package/dist/hoi4_agent_tools/event/algorithms.js +208 -0
- package/dist/hoi4_agent_tools/event/artifact-validation.js +572 -0
- package/dist/hoi4_agent_tools/event/compare.js +413 -0
- package/dist/hoi4_agent_tools/event/graph.js +1586 -0
- package/dist/hoi4_agent_tools/event/index.js +10 -0
- package/dist/hoi4_agent_tools/event/layout.js +218 -0
- package/dist/hoi4_agent_tools/event/limits.js +41 -0
- package/dist/hoi4_agent_tools/event/model.js +2 -0
- package/dist/hoi4_agent_tools/event/queries.js +643 -0
- package/dist/hoi4_agent_tools/event/render.js +443 -0
- package/dist/hoi4_agent_tools/event/scc.js +72 -0
- package/dist/hoi4_agent_tools/event/service.js +1147 -0
- package/dist/hoi4_agent_tools/event/source-analysis.js +1458 -0
- package/dist/hoi4_agent_tools/focus/native-effects.js +3 -1
- package/dist/hoi4_agent_tools/mcp/resources/register.js +1 -1
- package/dist/hoi4_agent_tools/mcp/server/create.js +4 -0
- package/dist/hoi4_agent_tools/mcp/server/result.js +8 -14
- package/dist/hoi4_agent_tools/mcp/tools/event.js +459 -0
- package/dist/hoi4_agent_tools/schemas/event.js +95 -0
- package/dist/hoi4_agent_tools/version.js +1 -1
- package/docs/README.md +1 -0
- package/docs/development.md +10 -0
- package/docs/events.md +113 -0
- package/docs/setup.md +3 -3
- package/package.json +7 -3
- package/server.json +3 -3
|
@@ -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
|
+
}
|