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,1586 @@
|
|
|
1
|
+
import { compareCodeUnits, hashCanonical } from '../core/canonical.js';
|
|
2
|
+
import { sortDiagnostics } from '../core/diagnostics.js';
|
|
3
|
+
import { ServiceError } from '../core/result.js';
|
|
4
|
+
import { EVENT_GRAPH_MAX_EDGES, EVENT_GRAPH_MAX_HELPER_DEPTH, EVENT_GRAPH_MAX_HELPER_PROJECTIONS, EVENT_GRAPH_MAX_HELPER_STATE_PROJECTIONS, EVENT_GRAPH_MAX_ISSUES, EVENT_GRAPH_MAX_NODES, EVENT_GRAPH_MAX_STATE_ACCESSES, EVENT_GRAPH_MAX_STATE_LINK_CANDIDATES, EVENT_GRAPH_MAX_STATE_LINKS, EVENT_GRAPH_MAX_UNRESOLVED, EventAnalysisBudget, } from './limits.js';
|
|
5
|
+
import { EVENT_GRAPH_PARSER_VERSION, EVENT_GRAPH_SCHEMA_VERSION, } from './model.js';
|
|
6
|
+
import { iterativeStronglyConnectedComponents } from './scc.js';
|
|
7
|
+
import { analyzeEventSource, eventSemanticFragmentCacheKey } from './source-analysis.js';
|
|
8
|
+
const confidenceRank = {
|
|
9
|
+
confirmed: 0,
|
|
10
|
+
high: 1,
|
|
11
|
+
medium: 2,
|
|
12
|
+
low: 3,
|
|
13
|
+
unresolved: 4,
|
|
14
|
+
};
|
|
15
|
+
function stableUnique(values) {
|
|
16
|
+
const retained = new Map();
|
|
17
|
+
for (const value of values)
|
|
18
|
+
if (!retained.has(value.id))
|
|
19
|
+
retained.set(value.id, value);
|
|
20
|
+
return [...retained.values()].sort((left, right) => compareCodeUnits(left.id, right.id));
|
|
21
|
+
}
|
|
22
|
+
function worstConfidence(...values) {
|
|
23
|
+
return values.reduce((worst, value) => confidenceRank[value] > confidenceRank[worst] ? value : worst);
|
|
24
|
+
}
|
|
25
|
+
function activeFiles(snapshot) {
|
|
26
|
+
return snapshot.files
|
|
27
|
+
.filter(({ relativePath, shadowedBy }) => shadowedBy === undefined &&
|
|
28
|
+
/\.(?:txt|ya?ml)$/iu.test(relativePath) &&
|
|
29
|
+
!/(?:^|\/)(?:supply_nodes|railways|buildings|unitstacks|weatherpositions)\.txt$/iu.test(relativePath.replaceAll('\\', '/')))
|
|
30
|
+
.sort((left, right) => compareCodeUnits(left.displayPath, right.displayPath));
|
|
31
|
+
}
|
|
32
|
+
function catalogFor(snapshot) {
|
|
33
|
+
const unshadowedEventSymbols = snapshot.index
|
|
34
|
+
.findAll('event')
|
|
35
|
+
.filter(({ sourceShadowed }) => !sourceShadowed);
|
|
36
|
+
const activeEventLoadOrder = new Map();
|
|
37
|
+
for (const symbol of unshadowedEventSymbols) {
|
|
38
|
+
const current = activeEventLoadOrder.get(symbol.id);
|
|
39
|
+
if (current === undefined || symbol.loadOrder > current) {
|
|
40
|
+
activeEventLoadOrder.set(symbol.id, symbol.loadOrder);
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
// Event IDs are global, but two definitions in different files at the same
|
|
44
|
+
// active load level are an invalid duplicate rather than an overlay. Retain
|
|
45
|
+
// every such definition so the semantic graph can report EVENT_DUPLICATE_ID.
|
|
46
|
+
const eventSymbols = unshadowedEventSymbols
|
|
47
|
+
.filter(({ id, loadOrder }) => activeEventLoadOrder.get(id) === loadOrder)
|
|
48
|
+
.sort((left, right) => compareCodeUnits(left.id, right.id) || compareCodeUnits(left.path, right.path));
|
|
49
|
+
const helperSymbols = snapshot.index
|
|
50
|
+
.findAll('scripted_effect')
|
|
51
|
+
.filter(({ overridden, sourceShadowed }) => !overridden && !sourceShadowed);
|
|
52
|
+
const decisionSymbols = snapshot.index
|
|
53
|
+
.findAll('decision')
|
|
54
|
+
.filter(({ overridden, sourceShadowed }) => !overridden && !sourceShadowed);
|
|
55
|
+
const helperIds = new Set(helperSymbols.map(({ id }) => id));
|
|
56
|
+
const decisionIds = new Set(decisionSymbols.map(({ id }) => id));
|
|
57
|
+
const eventTypes = new Map();
|
|
58
|
+
for (const symbol of eventSymbols) {
|
|
59
|
+
const eventType = symbol.metadata.eventType;
|
|
60
|
+
if ((eventType === 'country_event' ||
|
|
61
|
+
eventType === 'news_event' ||
|
|
62
|
+
eventType === 'state_event' ||
|
|
63
|
+
eventType === 'unit_leader_event' ||
|
|
64
|
+
eventType === 'operative_leader_event') &&
|
|
65
|
+
!eventTypes.has(symbol.id)) {
|
|
66
|
+
eventTypes.set(symbol.id, eventType);
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
const eventIds = new Set(eventSymbols.map(({ id }) => id));
|
|
70
|
+
const activeEventPaths = new Map();
|
|
71
|
+
const retainedEventPaths = new Map();
|
|
72
|
+
for (const { id, path } of eventSymbols) {
|
|
73
|
+
if (!activeEventPaths.has(id))
|
|
74
|
+
activeEventPaths.set(id, path);
|
|
75
|
+
const paths = retainedEventPaths.get(id) ?? new Set();
|
|
76
|
+
paths.add(path);
|
|
77
|
+
retainedEventPaths.set(id, paths);
|
|
78
|
+
}
|
|
79
|
+
const activeHelperPaths = new Map(helperSymbols.map(({ id, path }) => [id, path]));
|
|
80
|
+
const activeDecisionPaths = new Map(decisionSymbols.map(({ id, path }) => [id, path]));
|
|
81
|
+
const fingerprint = hashCanonical({
|
|
82
|
+
eventIds: [...eventIds].sort(compareCodeUnits),
|
|
83
|
+
eventTypes: [...eventTypes].sort(([left], [right]) => compareCodeUnits(left, right)),
|
|
84
|
+
helperIds: [...helperIds].sort(compareCodeUnits),
|
|
85
|
+
decisionIds: [...decisionIds].sort(compareCodeUnits),
|
|
86
|
+
activeEventPaths: [...activeEventPaths].sort(([left], [right]) => compareCodeUnits(left, right)),
|
|
87
|
+
retainedEventPaths: [...retainedEventPaths]
|
|
88
|
+
.sort(([left], [right]) => compareCodeUnits(left, right))
|
|
89
|
+
.map(([id, paths]) => [id, [...paths].sort(compareCodeUnits)]),
|
|
90
|
+
activeHelperPaths: [...activeHelperPaths].sort(([left], [right]) => compareCodeUnits(left, right)),
|
|
91
|
+
activeDecisionPaths: [...activeDecisionPaths].sort(([left], [right]) => compareCodeUnits(left, right)),
|
|
92
|
+
complete: snapshot.complete,
|
|
93
|
+
});
|
|
94
|
+
return {
|
|
95
|
+
eventIds,
|
|
96
|
+
eventTypes,
|
|
97
|
+
helperIds,
|
|
98
|
+
decisionIds,
|
|
99
|
+
activeEventPaths,
|
|
100
|
+
retainedEventPaths,
|
|
101
|
+
activeHelperPaths,
|
|
102
|
+
activeDecisionPaths,
|
|
103
|
+
fingerprint,
|
|
104
|
+
};
|
|
105
|
+
}
|
|
106
|
+
function locationKey(location) {
|
|
107
|
+
return `${location?.path ?? ''}:${location?.start.offset ?? -1}:${location?.end.offset ?? -1}`;
|
|
108
|
+
}
|
|
109
|
+
function issueKey(issue) {
|
|
110
|
+
const subjectIds = issue.details.subjectIds;
|
|
111
|
+
return hashCanonical({
|
|
112
|
+
code: issue.code,
|
|
113
|
+
classification: issue.classification,
|
|
114
|
+
subjectIds: Array.isArray(subjectIds) ? subjectIds : [],
|
|
115
|
+
});
|
|
116
|
+
}
|
|
117
|
+
function stableIssues(values) {
|
|
118
|
+
const retained = new Map();
|
|
119
|
+
for (const value of values) {
|
|
120
|
+
const key = issueKey(value);
|
|
121
|
+
if (!retained.has(key))
|
|
122
|
+
retained.set(key, value);
|
|
123
|
+
}
|
|
124
|
+
return [...retained.values()].sort((left, right) => compareCodeUnits(left.code, right.code) ||
|
|
125
|
+
compareCodeUnits(locationKey(left.location), locationKey(right.location)) ||
|
|
126
|
+
compareCodeUnits(issueKey(left), issueKey(right)));
|
|
127
|
+
}
|
|
128
|
+
function normalizeIssueSubjects(graph) {
|
|
129
|
+
const nodes = nodeById(graph.nodes);
|
|
130
|
+
const parent = optionParents(graph.edges);
|
|
131
|
+
graph.issues = graph.issues.map((issue) => {
|
|
132
|
+
const subjectIds = issue.details.subjectIds;
|
|
133
|
+
if (!Array.isArray(subjectIds))
|
|
134
|
+
return issue;
|
|
135
|
+
const mapped = subjectIds
|
|
136
|
+
.flatMap((value) => typeof value === 'string' ? [semanticOwnerId(value, nodes, parent)] : [])
|
|
137
|
+
.filter((id, _index, all) => {
|
|
138
|
+
if (nodes.get(id)?.kind !== 'unresolved')
|
|
139
|
+
return true;
|
|
140
|
+
return !all.some((candidate) => nodes.get(candidate)?.kind !== 'unresolved');
|
|
141
|
+
});
|
|
142
|
+
return {
|
|
143
|
+
...issue,
|
|
144
|
+
details: { ...issue.details, subjectIds: [...new Set(mapped)].sort(compareCodeUnits) },
|
|
145
|
+
};
|
|
146
|
+
});
|
|
147
|
+
}
|
|
148
|
+
function assertGraphLimit(current, maximum, code, label) {
|
|
149
|
+
if (current <= maximum)
|
|
150
|
+
return;
|
|
151
|
+
throw new ServiceError(code, `Event-chain ${label} exceeds the fixed result ceiling`, {
|
|
152
|
+
count: current,
|
|
153
|
+
maximum,
|
|
154
|
+
});
|
|
155
|
+
}
|
|
156
|
+
function sourceHashes(files) {
|
|
157
|
+
return Object.fromEntries(files
|
|
158
|
+
.map(({ displayPath, sha256 }) => [displayPath, sha256])
|
|
159
|
+
.sort(([left], [right]) => compareCodeUnits(left, right)));
|
|
160
|
+
}
|
|
161
|
+
function addIssue(issues, issue) {
|
|
162
|
+
const subjectIds = [...new Set(issue.subjectIds ?? [])].sort(compareCodeUnits);
|
|
163
|
+
issues.push({
|
|
164
|
+
code: issue.code,
|
|
165
|
+
classification: issue.classification,
|
|
166
|
+
severity: issue.severity,
|
|
167
|
+
message: issue.message,
|
|
168
|
+
confidence: issue.confidence,
|
|
169
|
+
...(issue.location === undefined ? {} : { location: issue.location }),
|
|
170
|
+
...(issue.related === undefined ? {} : { related: issue.related }),
|
|
171
|
+
blockers: issue.blockers ?? [],
|
|
172
|
+
details: {
|
|
173
|
+
...(issue.details ?? {}),
|
|
174
|
+
...(subjectIds.length === 0 ? {} : { subjectIds }),
|
|
175
|
+
},
|
|
176
|
+
});
|
|
177
|
+
}
|
|
178
|
+
function nodeById(nodes) {
|
|
179
|
+
return new Map(nodes.map((node) => [node.id, node]));
|
|
180
|
+
}
|
|
181
|
+
function optionParents(edges) {
|
|
182
|
+
const result = new Map();
|
|
183
|
+
for (const edge of edges) {
|
|
184
|
+
if (edge.reason === 'option_branch' && !result.has(edge.to))
|
|
185
|
+
result.set(edge.to, edge.from);
|
|
186
|
+
}
|
|
187
|
+
return result;
|
|
188
|
+
}
|
|
189
|
+
function semanticOwnerId(ownerId, nodes, optionParent) {
|
|
190
|
+
const node = nodes.get(ownerId);
|
|
191
|
+
if (node?.kind === 'option')
|
|
192
|
+
return optionParent.get(ownerId) ?? ownerId;
|
|
193
|
+
const parent = node?.metadata.eventNodeId;
|
|
194
|
+
if (typeof parent === 'string')
|
|
195
|
+
return parent;
|
|
196
|
+
const eventId = node?.metadata.eventId;
|
|
197
|
+
if (typeof eventId === 'string')
|
|
198
|
+
return `event:${eventId}`;
|
|
199
|
+
return ownerId;
|
|
200
|
+
}
|
|
201
|
+
function mergeFragments(fragments) {
|
|
202
|
+
return {
|
|
203
|
+
nodes: fragments.flatMap(({ nodes }) => nodes),
|
|
204
|
+
edges: fragments.flatMap(({ edges }) => edges),
|
|
205
|
+
stateAccesses: fragments.flatMap(({ stateAccesses }) => stateAccesses),
|
|
206
|
+
issues: fragments.flatMap(({ issues }) => issues),
|
|
207
|
+
unresolved: fragments.flatMap(({ unresolved }) => unresolved),
|
|
208
|
+
localisation: fragments.flatMap(({ localisation }) => localisation),
|
|
209
|
+
};
|
|
210
|
+
}
|
|
211
|
+
function normalizedEntryReason(node) {
|
|
212
|
+
switch (node.metadata.entryKind) {
|
|
213
|
+
case 'on_action':
|
|
214
|
+
return 'on_action_entry';
|
|
215
|
+
case 'focus':
|
|
216
|
+
return 'focus_entry';
|
|
217
|
+
case 'decision':
|
|
218
|
+
return 'decision_entry';
|
|
219
|
+
case 'mission':
|
|
220
|
+
return 'mission_entry';
|
|
221
|
+
case 'country_setup':
|
|
222
|
+
return 'country_setup_entry';
|
|
223
|
+
case 'state_setup':
|
|
224
|
+
return 'state_setup_entry';
|
|
225
|
+
case 'implicit_event_trigger':
|
|
226
|
+
return 'implicit_event_entry';
|
|
227
|
+
case 'other':
|
|
228
|
+
return 'other_entry';
|
|
229
|
+
default:
|
|
230
|
+
return undefined;
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
function normalizePublicEventNodeIds(graph) {
|
|
234
|
+
const definitions = new Map();
|
|
235
|
+
for (const node of graph.nodes) {
|
|
236
|
+
if (node.kind !== 'event' || node.eventId === undefined)
|
|
237
|
+
continue;
|
|
238
|
+
const values = definitions.get(node.eventId) ?? [];
|
|
239
|
+
values.push(node);
|
|
240
|
+
definitions.set(node.eventId, values);
|
|
241
|
+
}
|
|
242
|
+
const replacements = new Map();
|
|
243
|
+
for (const [eventId, values] of [...definitions].sort(([left], [right]) => compareCodeUnits(left, right))) {
|
|
244
|
+
values.sort((left, right) => compareCodeUnits(locationKey(left.location), locationKey(right.location)) ||
|
|
245
|
+
compareCodeUnits(left.id, right.id));
|
|
246
|
+
values.forEach((node, index) => {
|
|
247
|
+
replacements.set(node.id, index === 0
|
|
248
|
+
? `event:${eventId}`
|
|
249
|
+
: `event:${eventId}:duplicate:${hashCanonical({
|
|
250
|
+
location: locationKey(node.location),
|
|
251
|
+
ordinal: index,
|
|
252
|
+
}).slice(0, 16)}`);
|
|
253
|
+
});
|
|
254
|
+
}
|
|
255
|
+
if (replacements.size === 0)
|
|
256
|
+
return;
|
|
257
|
+
graph.nodes = graph.nodes.map((node) => ({ ...node, id: replacements.get(node.id) ?? node.id }));
|
|
258
|
+
graph.edges = graph.edges.map((edge) => ({
|
|
259
|
+
...edge,
|
|
260
|
+
from: replacements.get(edge.from) ?? edge.from,
|
|
261
|
+
to: replacements.get(edge.to) ?? edge.to,
|
|
262
|
+
}));
|
|
263
|
+
graph.stateAccesses = graph.stateAccesses.map((access) => ({
|
|
264
|
+
...access,
|
|
265
|
+
ownerId: replacements.get(access.ownerId) ?? access.ownerId,
|
|
266
|
+
}));
|
|
267
|
+
graph.unresolved = graph.unresolved.map((unresolved) => ({
|
|
268
|
+
...unresolved,
|
|
269
|
+
...(unresolved.ownerId === undefined
|
|
270
|
+
? {}
|
|
271
|
+
: { ownerId: replacements.get(unresolved.ownerId) ?? unresolved.ownerId }),
|
|
272
|
+
}));
|
|
273
|
+
graph.issues = graph.issues.map((issue) => {
|
|
274
|
+
const subjectIds = issue.details.subjectIds;
|
|
275
|
+
if (!Array.isArray(subjectIds))
|
|
276
|
+
return issue;
|
|
277
|
+
return {
|
|
278
|
+
...issue,
|
|
279
|
+
details: {
|
|
280
|
+
...issue.details,
|
|
281
|
+
subjectIds: subjectIds.map((value) => typeof value === 'string' ? (replacements.get(value) ?? value) : value),
|
|
282
|
+
},
|
|
283
|
+
};
|
|
284
|
+
});
|
|
285
|
+
}
|
|
286
|
+
/** Resolve file-local call placeholders against the active cross-file definition catalog. */
|
|
287
|
+
function normalizeCrossFileEdges(graph) {
|
|
288
|
+
const definitions = new Map();
|
|
289
|
+
for (const node of graph.nodes) {
|
|
290
|
+
if (node.kind !== 'event' || node.eventId === undefined)
|
|
291
|
+
continue;
|
|
292
|
+
const values = definitions.get(node.eventId) ?? [];
|
|
293
|
+
values.push(node);
|
|
294
|
+
definitions.set(node.eventId, values);
|
|
295
|
+
}
|
|
296
|
+
for (const values of definitions.values()) {
|
|
297
|
+
values.sort((left, right) => compareCodeUnits(locationKey(left.location), locationKey(right.location)) ||
|
|
298
|
+
compareCodeUnits(left.id, right.id));
|
|
299
|
+
}
|
|
300
|
+
const nodes = nodeById(graph.nodes);
|
|
301
|
+
graph.edges = graph.edges.map((edge) => {
|
|
302
|
+
const targetEventId = edge.metadata.targetEventId;
|
|
303
|
+
const target = typeof targetEventId === 'string' ? definitions.get(targetEventId)?.[0]?.id : undefined;
|
|
304
|
+
const fromNode = nodes.get(edge.from);
|
|
305
|
+
const entryReason = fromNode?.kind === 'entry' ? normalizedEntryReason(fromNode) : undefined;
|
|
306
|
+
const helperExpansion = fromNode?.kind === 'helper' &&
|
|
307
|
+
typeof targetEventId === 'string' &&
|
|
308
|
+
edge.reason !== 'unresolved_dynamic_reference';
|
|
309
|
+
const reason = helperExpansion
|
|
310
|
+
? 'scripted_effect_expansion'
|
|
311
|
+
: entryReason !== undefined && edge.reason !== 'scripted_effect_call'
|
|
312
|
+
? entryReason
|
|
313
|
+
: edge.reason;
|
|
314
|
+
if (target === undefined && reason === edge.reason)
|
|
315
|
+
return edge;
|
|
316
|
+
return {
|
|
317
|
+
...edge,
|
|
318
|
+
...(target === undefined ? {} : { to: target }),
|
|
319
|
+
reason,
|
|
320
|
+
provenance: edge.provenance.map((item) => ({
|
|
321
|
+
...item,
|
|
322
|
+
role: helperExpansion ? 'dispatch' : entryReason === undefined ? item.role : 'entry',
|
|
323
|
+
})),
|
|
324
|
+
};
|
|
325
|
+
});
|
|
326
|
+
}
|
|
327
|
+
function helperName(node) {
|
|
328
|
+
const name = node.metadata.name;
|
|
329
|
+
if (typeof name === 'string')
|
|
330
|
+
return name;
|
|
331
|
+
return node.label || node.id.replace(/^helper:/u, '');
|
|
332
|
+
}
|
|
333
|
+
function helperProjections(graph, budget) {
|
|
334
|
+
const nodes = nodeById(graph.nodes);
|
|
335
|
+
const optionParent = optionParents(graph.edges);
|
|
336
|
+
const outgoing = new Map();
|
|
337
|
+
for (const edge of graph.edges) {
|
|
338
|
+
const values = outgoing.get(edge.from) ?? [];
|
|
339
|
+
values.push(edge);
|
|
340
|
+
outgoing.set(edge.from, values);
|
|
341
|
+
}
|
|
342
|
+
for (const values of outgoing.values()) {
|
|
343
|
+
values.sort((left, right) => compareCodeUnits(left.id, right.id));
|
|
344
|
+
}
|
|
345
|
+
const starts = graph.edges
|
|
346
|
+
.filter((edge) => !edge.derived &&
|
|
347
|
+
nodes.get(edge.to)?.kind === 'helper' &&
|
|
348
|
+
nodes.get(edge.from)?.kind !== 'helper')
|
|
349
|
+
.sort((left, right) => compareCodeUnits(left.id, right.id));
|
|
350
|
+
const projected = [];
|
|
351
|
+
const projectedKeys = new Set();
|
|
352
|
+
const reportedCycles = new Set();
|
|
353
|
+
const reportedDepth = new Set();
|
|
354
|
+
for (const start of starts) {
|
|
355
|
+
const first = nodes.get(start.to);
|
|
356
|
+
if (first === undefined)
|
|
357
|
+
continue;
|
|
358
|
+
const caller = nodes.get(optionParent.get(start.from) ?? start.from);
|
|
359
|
+
const callerScopeCandidate = start.scope?.source ?? caller?.metadata.expectedScope ?? caller?.metadata.scope;
|
|
360
|
+
const callerScope = callerScopeCandidate === 'country' ||
|
|
361
|
+
callerScopeCandidate === 'state' ||
|
|
362
|
+
callerScopeCandidate === 'unit_leader' ||
|
|
363
|
+
callerScopeCandidate === 'operative' ||
|
|
364
|
+
callerScopeCandidate === 'character' ||
|
|
365
|
+
callerScopeCandidate === 'global'
|
|
366
|
+
? callerScopeCandidate
|
|
367
|
+
: 'unknown';
|
|
368
|
+
const reachedHelpers = new Set([start.to]);
|
|
369
|
+
const stack = [
|
|
370
|
+
{
|
|
371
|
+
currentId: start.to,
|
|
372
|
+
helperStack: [helperName(first)],
|
|
373
|
+
edgeStack: [start],
|
|
374
|
+
visited: new Set([start.to]),
|
|
375
|
+
},
|
|
376
|
+
];
|
|
377
|
+
while (stack.length > 0) {
|
|
378
|
+
budget.spend('helper_projection');
|
|
379
|
+
const frame = stack.pop();
|
|
380
|
+
const candidates = outgoing.get(frame.currentId) ?? [];
|
|
381
|
+
for (const edge of [...candidates].reverse()) {
|
|
382
|
+
if (edge.derived)
|
|
383
|
+
continue;
|
|
384
|
+
const target = nodes.get(edge.to);
|
|
385
|
+
if (target?.kind === 'helper') {
|
|
386
|
+
if (frame.visited.has(target.id)) {
|
|
387
|
+
const cycleKey = `${start.id}:${target.id}`;
|
|
388
|
+
if (!reportedCycles.has(cycleKey)) {
|
|
389
|
+
reportedCycles.add(cycleKey);
|
|
390
|
+
addIssue(graph.issues, {
|
|
391
|
+
code: 'EVENT_HELPER_CYCLE',
|
|
392
|
+
classification: 'unresolved_analysis',
|
|
393
|
+
severity: 'warning',
|
|
394
|
+
message: 'A scripted-effect cycle prevents complete helper expansion.',
|
|
395
|
+
confidence: 'unresolved',
|
|
396
|
+
location: edge.location,
|
|
397
|
+
blockers: [
|
|
398
|
+
{
|
|
399
|
+
code: 'HELPER_CYCLE',
|
|
400
|
+
message: 'Static expansion stopped at the repeated scripted effect.',
|
|
401
|
+
location: edge.location,
|
|
402
|
+
},
|
|
403
|
+
],
|
|
404
|
+
subjectIds: [start.from, ...frame.visited, target.id],
|
|
405
|
+
});
|
|
406
|
+
}
|
|
407
|
+
continue;
|
|
408
|
+
}
|
|
409
|
+
if (frame.helperStack.length >= EVENT_GRAPH_MAX_HELPER_DEPTH) {
|
|
410
|
+
const depthKey = `${start.id}:${target.id}`;
|
|
411
|
+
if (!reportedDepth.has(depthKey)) {
|
|
412
|
+
reportedDepth.add(depthKey);
|
|
413
|
+
addIssue(graph.issues, {
|
|
414
|
+
code: 'EVENT_HELPER_DEPTH_LIMIT',
|
|
415
|
+
classification: 'unresolved_analysis',
|
|
416
|
+
severity: 'blocker',
|
|
417
|
+
message: 'Scripted-effect expansion reached the fixed nesting ceiling.',
|
|
418
|
+
confidence: 'unresolved',
|
|
419
|
+
location: edge.location,
|
|
420
|
+
blockers: [
|
|
421
|
+
{
|
|
422
|
+
code: 'HELPER_DEPTH_LIMIT',
|
|
423
|
+
message: 'The helper chain is deeper than the supported static boundary.',
|
|
424
|
+
location: edge.location,
|
|
425
|
+
details: { maximumDepth: EVENT_GRAPH_MAX_HELPER_DEPTH },
|
|
426
|
+
},
|
|
427
|
+
],
|
|
428
|
+
subjectIds: [start.from, target.id],
|
|
429
|
+
});
|
|
430
|
+
}
|
|
431
|
+
continue;
|
|
432
|
+
}
|
|
433
|
+
// The expanded structural graph retains every alternate helper
|
|
434
|
+
// route. The collapsed projection needs one deterministic proof per
|
|
435
|
+
// call site and reachable helper, otherwise diamond-shaped helper
|
|
436
|
+
// graphs grow exponentially on real vanilla workspaces.
|
|
437
|
+
if (reachedHelpers.has(target.id))
|
|
438
|
+
continue;
|
|
439
|
+
reachedHelpers.add(target.id);
|
|
440
|
+
stack.push({
|
|
441
|
+
currentId: target.id,
|
|
442
|
+
helperStack: [...frame.helperStack, helperName(target)],
|
|
443
|
+
edgeStack: [...frame.edgeStack, edge],
|
|
444
|
+
visited: new Set([...frame.visited, target.id]),
|
|
445
|
+
});
|
|
446
|
+
continue;
|
|
447
|
+
}
|
|
448
|
+
const structural = [...frame.edgeStack, edge];
|
|
449
|
+
const final = structural.at(-1);
|
|
450
|
+
const projectionKey = `${start.id}\0${edge.to}`;
|
|
451
|
+
if (projectedKeys.has(projectionKey))
|
|
452
|
+
continue;
|
|
453
|
+
projectedKeys.add(projectionKey);
|
|
454
|
+
projected.push({
|
|
455
|
+
id: `edge:helper-projection:${hashCanonical({
|
|
456
|
+
from: start.from,
|
|
457
|
+
to: edge.to,
|
|
458
|
+
edges: structural.map(({ id }) => id),
|
|
459
|
+
}).slice(0, 32)}`,
|
|
460
|
+
from: start.from,
|
|
461
|
+
to: edge.to,
|
|
462
|
+
reason: 'scripted_effect_expansion',
|
|
463
|
+
conditions: structural.flatMap(({ conditions }) => conditions),
|
|
464
|
+
helperStack: frame.helperStack,
|
|
465
|
+
location: start.location,
|
|
466
|
+
provenance: structural.flatMap(({ provenance }) => provenance),
|
|
467
|
+
confidence: worstConfidence(...structural.map(({ confidence }) => confidence)),
|
|
468
|
+
derived: true,
|
|
469
|
+
...(final.timing === undefined ? {} : { timing: final.timing }),
|
|
470
|
+
...(final.weight === undefined ? {} : { weight: final.weight }),
|
|
471
|
+
...(final.scope === undefined
|
|
472
|
+
? {}
|
|
473
|
+
: {
|
|
474
|
+
scope: final.scope.source === 'unknown' && callerScope !== 'unknown'
|
|
475
|
+
? { ...final.scope, source: callerScope, confidence: 'high' }
|
|
476
|
+
: final.scope,
|
|
477
|
+
}),
|
|
478
|
+
metadata: {
|
|
479
|
+
structuralEdgeIds: structural.map(({ id }) => id),
|
|
480
|
+
terminalDispatchLocation: locationKey(final.location),
|
|
481
|
+
},
|
|
482
|
+
});
|
|
483
|
+
if (projected.length > EVENT_GRAPH_MAX_HELPER_PROJECTIONS) {
|
|
484
|
+
throw new ServiceError('EVENT_HELPER_PROJECTION_LIMIT', 'Event-chain helper expansion exceeds the fixed projection ceiling', { maximum: EVENT_GRAPH_MAX_HELPER_PROJECTIONS });
|
|
485
|
+
}
|
|
486
|
+
}
|
|
487
|
+
}
|
|
488
|
+
}
|
|
489
|
+
return stableUnique(projected);
|
|
490
|
+
}
|
|
491
|
+
function projectHelperStateAccesses(graph, budget) {
|
|
492
|
+
const nodes = nodeById(graph.nodes);
|
|
493
|
+
const optionParent = optionParents(graph.edges);
|
|
494
|
+
const accesses = new Map();
|
|
495
|
+
for (const access of graph.stateAccesses) {
|
|
496
|
+
const values = accesses.get(access.ownerId) ?? [];
|
|
497
|
+
values.push(access);
|
|
498
|
+
accesses.set(access.ownerId, values);
|
|
499
|
+
}
|
|
500
|
+
const outgoing = new Map();
|
|
501
|
+
for (const edge of graph.edges) {
|
|
502
|
+
if (edge.derived)
|
|
503
|
+
continue;
|
|
504
|
+
const values = outgoing.get(edge.from) ?? [];
|
|
505
|
+
values.push(edge);
|
|
506
|
+
outgoing.set(edge.from, values);
|
|
507
|
+
}
|
|
508
|
+
for (const values of outgoing.values())
|
|
509
|
+
values.sort((a, b) => compareCodeUnits(a.id, b.id));
|
|
510
|
+
const starts = graph.edges
|
|
511
|
+
.filter((edge) => !edge.derived &&
|
|
512
|
+
nodes.get(edge.to)?.kind === 'helper' &&
|
|
513
|
+
nodes.get(edge.from)?.kind !== 'helper')
|
|
514
|
+
.sort((left, right) => compareCodeUnits(left.id, right.id));
|
|
515
|
+
const projected = [];
|
|
516
|
+
for (const start of starts) {
|
|
517
|
+
const first = nodes.get(start.to);
|
|
518
|
+
if (first === undefined)
|
|
519
|
+
continue;
|
|
520
|
+
const caller = nodes.get(optionParent.get(start.from) ?? start.from);
|
|
521
|
+
const callerScopeCandidate = start.scope?.source ?? caller?.metadata.expectedScope ?? caller?.metadata.scope;
|
|
522
|
+
const callerScope = callerScopeCandidate === 'country' ||
|
|
523
|
+
callerScopeCandidate === 'state' ||
|
|
524
|
+
callerScopeCandidate === 'unit_leader' ||
|
|
525
|
+
callerScopeCandidate === 'operative' ||
|
|
526
|
+
callerScopeCandidate === 'character' ||
|
|
527
|
+
callerScopeCandidate === 'global'
|
|
528
|
+
? callerScopeCandidate
|
|
529
|
+
: 'unknown';
|
|
530
|
+
const reachedHelpers = new Set([first.id]);
|
|
531
|
+
const projectedAccesses = new Set();
|
|
532
|
+
const stack = [
|
|
533
|
+
{
|
|
534
|
+
helperId: first.id,
|
|
535
|
+
helperStack: [helperName(first)],
|
|
536
|
+
callEdges: [start],
|
|
537
|
+
visited: new Set([first.id]),
|
|
538
|
+
},
|
|
539
|
+
];
|
|
540
|
+
while (stack.length > 0) {
|
|
541
|
+
budget.spend('helper_state_projection');
|
|
542
|
+
const frame = stack.pop();
|
|
543
|
+
for (const access of accesses.get(frame.helperId) ?? []) {
|
|
544
|
+
const projectionKey = `${start.id}\0${access.id}`;
|
|
545
|
+
if (projectedAccesses.has(projectionKey))
|
|
546
|
+
continue;
|
|
547
|
+
projectedAccesses.add(projectionKey);
|
|
548
|
+
if (projected.length >= EVENT_GRAPH_MAX_HELPER_STATE_PROJECTIONS ||
|
|
549
|
+
graph.stateAccesses.length + projected.length >= EVENT_GRAPH_MAX_STATE_ACCESSES) {
|
|
550
|
+
addIssue(graph.issues, {
|
|
551
|
+
code: 'EVENT_HELPER_STATE_PROJECTION_LIMIT',
|
|
552
|
+
classification: 'unresolved_analysis',
|
|
553
|
+
severity: 'blocker',
|
|
554
|
+
message: 'Helper state-flow projection reached its fixed materialization ceiling; structural helper evidence remains available.',
|
|
555
|
+
confidence: 'unresolved',
|
|
556
|
+
location: start.location,
|
|
557
|
+
blockers: [
|
|
558
|
+
{
|
|
559
|
+
code: 'HELPER_STATE_PROJECTION_LIMIT',
|
|
560
|
+
message: 'Use an expanded bounded helper trace for state evidence omitted from the collapsed workspace graph.',
|
|
561
|
+
location: start.location,
|
|
562
|
+
details: { maximum: EVENT_GRAPH_MAX_HELPER_STATE_PROJECTIONS },
|
|
563
|
+
},
|
|
564
|
+
],
|
|
565
|
+
subjectIds: [start.from, start.to],
|
|
566
|
+
});
|
|
567
|
+
return projected;
|
|
568
|
+
}
|
|
569
|
+
projected.push({
|
|
570
|
+
...access,
|
|
571
|
+
id: `state:helper-projection:${hashCanonical({
|
|
572
|
+
caller: start.from,
|
|
573
|
+
access: access.id,
|
|
574
|
+
edges: frame.callEdges.map(({ id }) => id),
|
|
575
|
+
}).slice(0, 32)}`,
|
|
576
|
+
ownerId: start.from,
|
|
577
|
+
scope: access.scope === 'unknown' ? callerScope : access.scope,
|
|
578
|
+
confidence: worstConfidence(access.confidence, ...frame.callEdges.map(({ confidence }) => confidence)),
|
|
579
|
+
helperStack: frame.helperStack,
|
|
580
|
+
conditions: [
|
|
581
|
+
...frame.callEdges.flatMap(({ conditions }) => conditions),
|
|
582
|
+
...access.conditions,
|
|
583
|
+
],
|
|
584
|
+
metadata: {
|
|
585
|
+
...access.metadata,
|
|
586
|
+
projectedFromAccessId: access.id,
|
|
587
|
+
structuralEdgeIds: frame.callEdges.map(({ id }) => id),
|
|
588
|
+
},
|
|
589
|
+
});
|
|
590
|
+
}
|
|
591
|
+
if (frame.helperStack.length >= EVENT_GRAPH_MAX_HELPER_DEPTH)
|
|
592
|
+
continue;
|
|
593
|
+
for (const edge of [...(outgoing.get(frame.helperId) ?? [])].reverse()) {
|
|
594
|
+
const target = nodes.get(edge.to);
|
|
595
|
+
if (target?.kind !== 'helper' ||
|
|
596
|
+
frame.visited.has(target.id) ||
|
|
597
|
+
reachedHelpers.has(target.id))
|
|
598
|
+
continue;
|
|
599
|
+
reachedHelpers.add(target.id);
|
|
600
|
+
stack.push({
|
|
601
|
+
helperId: target.id,
|
|
602
|
+
helperStack: [...frame.helperStack, helperName(target)],
|
|
603
|
+
callEdges: [...frame.callEdges, edge],
|
|
604
|
+
visited: new Set([...frame.visited, target.id]),
|
|
605
|
+
});
|
|
606
|
+
}
|
|
607
|
+
}
|
|
608
|
+
}
|
|
609
|
+
return stableUnique(projected);
|
|
610
|
+
}
|
|
611
|
+
function fallbackLocation(node) {
|
|
612
|
+
return (node.location ?? {
|
|
613
|
+
path: node.sourcePath ?? '<event-graph>',
|
|
614
|
+
start: { line: 1, column: 1, offset: 0 },
|
|
615
|
+
end: { line: 1, column: 1, offset: 0 },
|
|
616
|
+
symbol: node.eventId ?? node.label,
|
|
617
|
+
});
|
|
618
|
+
}
|
|
619
|
+
function addTerminalNodes(graph) {
|
|
620
|
+
const nodes = nodeById(graph.nodes);
|
|
621
|
+
const structuralOutgoing = new Set(graph.edges
|
|
622
|
+
.filter(({ derived, reason }) => !derived && reason !== 'option_branch' && reason !== 'terminal')
|
|
623
|
+
.map(({ from }) => from));
|
|
624
|
+
const optionParent = optionParents(graph.edges);
|
|
625
|
+
const eventsWithOptions = new Set(optionParent.values());
|
|
626
|
+
const stateOwners = new Set(graph.stateAccesses.map(({ ownerId }) => ownerId));
|
|
627
|
+
const candidates = graph.nodes
|
|
628
|
+
.filter((node) => (node.kind === 'option' && !structuralOutgoing.has(node.id)) ||
|
|
629
|
+
(node.kind === 'event' &&
|
|
630
|
+
!eventsWithOptions.has(node.id) &&
|
|
631
|
+
!structuralOutgoing.has(node.id)))
|
|
632
|
+
.sort((left, right) => compareCodeUnits(left.id, right.id));
|
|
633
|
+
for (const owner of candidates) {
|
|
634
|
+
const terminalId = `terminal:${owner.id}`;
|
|
635
|
+
if (!nodes.has(terminalId)) {
|
|
636
|
+
const terminal = {
|
|
637
|
+
id: terminalId,
|
|
638
|
+
kind: 'terminal',
|
|
639
|
+
label: `${owner.label} exit`,
|
|
640
|
+
...(owner.eventId === undefined ? {} : { eventId: owner.eventId }),
|
|
641
|
+
...(owner.namespace === undefined ? {} : { namespace: owner.namespace }),
|
|
642
|
+
...(owner.sourcePath === undefined ? {} : { sourcePath: owner.sourcePath }),
|
|
643
|
+
...(owner.location === undefined ? {} : { location: owner.location }),
|
|
644
|
+
metadata: { ownerId: owner.id },
|
|
645
|
+
};
|
|
646
|
+
graph.nodes.push(terminal);
|
|
647
|
+
nodes.set(terminalId, terminal);
|
|
648
|
+
}
|
|
649
|
+
const location = fallbackLocation(owner);
|
|
650
|
+
graph.edges.push({
|
|
651
|
+
id: `edge:terminal:${hashCanonical({ from: owner.id, to: terminalId }).slice(0, 32)}`,
|
|
652
|
+
from: owner.id,
|
|
653
|
+
to: terminalId,
|
|
654
|
+
reason: 'terminal',
|
|
655
|
+
conditions: [],
|
|
656
|
+
helperStack: [],
|
|
657
|
+
location,
|
|
658
|
+
provenance: [{ role: 'invocation', location }],
|
|
659
|
+
confidence: 'confirmed',
|
|
660
|
+
derived: false,
|
|
661
|
+
metadata: {},
|
|
662
|
+
});
|
|
663
|
+
if (owner.kind !== 'option')
|
|
664
|
+
continue;
|
|
665
|
+
const explicitEffectState = owner.metadata.hasEffect ?? owner.metadata.hasEffects ?? owner.metadata.effectCount;
|
|
666
|
+
if (explicitEffectState !== false && explicitEffectState !== 0 && stateOwners.has(owner.id))
|
|
667
|
+
continue;
|
|
668
|
+
if (explicitEffectState !== false && explicitEffectState !== 0)
|
|
669
|
+
continue;
|
|
670
|
+
addIssue(graph.issues, {
|
|
671
|
+
code: 'EVENT_OPTION_DANGLING',
|
|
672
|
+
classification: 'design_warning',
|
|
673
|
+
severity: 'warning',
|
|
674
|
+
message: 'An event option has no visible effect or dispatch.',
|
|
675
|
+
confidence: 'confirmed',
|
|
676
|
+
location: owner.location,
|
|
677
|
+
subjectIds: [optionParent.get(owner.id) ?? owner.id],
|
|
678
|
+
});
|
|
679
|
+
}
|
|
680
|
+
}
|
|
681
|
+
function stateKey(value) {
|
|
682
|
+
const scope = value.kind === 'variable' || value.kind === 'array' ? `:${value.scope}` : '';
|
|
683
|
+
const storage = value.kind === 'saved_scope'
|
|
684
|
+
? `:${value.metadata.storage === 'global' || value.metadata.storage === 'local'
|
|
685
|
+
? value.metadata.storage
|
|
686
|
+
: 'unknown'}`
|
|
687
|
+
: '';
|
|
688
|
+
return `${value.kind}:${value.name}${scope}${storage}`;
|
|
689
|
+
}
|
|
690
|
+
function compatibleProducerKeys(value) {
|
|
691
|
+
if (value.kind !== 'saved_scope' ||
|
|
692
|
+
value.metadata.storage === 'global' ||
|
|
693
|
+
value.metadata.storage === 'local') {
|
|
694
|
+
return [stateKey(value)];
|
|
695
|
+
}
|
|
696
|
+
return [
|
|
697
|
+
`saved_scope:${value.name}:global`,
|
|
698
|
+
`saved_scope:${value.name}:local`,
|
|
699
|
+
`saved_scope:${value.name}:unknown`,
|
|
700
|
+
];
|
|
701
|
+
}
|
|
702
|
+
function hasUnknownSavedScopeStorage(value) {
|
|
703
|
+
return (value.kind === 'saved_scope' &&
|
|
704
|
+
value.metadata.storage !== 'global' &&
|
|
705
|
+
value.metadata.storage !== 'local');
|
|
706
|
+
}
|
|
707
|
+
function collapsedFlowEdges(nodes, edges) {
|
|
708
|
+
return edges
|
|
709
|
+
.filter((edge) => {
|
|
710
|
+
if (edge.derived)
|
|
711
|
+
return true;
|
|
712
|
+
return nodes.get(edge.from)?.kind !== 'helper' && nodes.get(edge.to)?.kind !== 'helper';
|
|
713
|
+
})
|
|
714
|
+
.sort((left, right) => compareCodeUnits(left.id, right.id));
|
|
715
|
+
}
|
|
716
|
+
function stateLinksFor(graph, budget) {
|
|
717
|
+
const nodes = nodeById(graph.nodes);
|
|
718
|
+
const parent = optionParents(graph.edges);
|
|
719
|
+
const flow = collapsedFlowEdges(nodes, graph.edges);
|
|
720
|
+
const outgoing = new Map();
|
|
721
|
+
const ownerIdSet = new Set();
|
|
722
|
+
for (const edge of flow) {
|
|
723
|
+
const from = semanticOwnerId(edge.from, nodes, parent);
|
|
724
|
+
const to = semanticOwnerId(edge.to, nodes, parent);
|
|
725
|
+
ownerIdSet.add(from);
|
|
726
|
+
ownerIdSet.add(to);
|
|
727
|
+
const values = outgoing.get(from) ?? [];
|
|
728
|
+
values.push(to);
|
|
729
|
+
outgoing.set(from, values);
|
|
730
|
+
}
|
|
731
|
+
for (const access of graph.stateAccesses) {
|
|
732
|
+
ownerIdSet.add(semanticOwnerId(access.ownerId, nodes, parent));
|
|
733
|
+
}
|
|
734
|
+
for (const [from, values] of outgoing) {
|
|
735
|
+
const unique = [...new Set(values)].sort(compareCodeUnits);
|
|
736
|
+
outgoing.set(from, unique);
|
|
737
|
+
}
|
|
738
|
+
const ownerIds = [...ownerIdSet].sort(compareCodeUnits);
|
|
739
|
+
const ownerIndex = new Map(ownerIds.map((id, index) => [id, index]));
|
|
740
|
+
const adjacency = Array.from({ length: ownerIds.length }, () => []);
|
|
741
|
+
for (const [from, targets] of outgoing) {
|
|
742
|
+
const fromIndex = ownerIndex.get(from);
|
|
743
|
+
if (fromIndex === undefined)
|
|
744
|
+
continue;
|
|
745
|
+
adjacency[fromIndex] = targets.flatMap((target) => {
|
|
746
|
+
const targetIndex = ownerIndex.get(target);
|
|
747
|
+
return targetIndex === undefined ? [] : [targetIndex];
|
|
748
|
+
});
|
|
749
|
+
}
|
|
750
|
+
const bitsetWords = Math.ceil(ownerIds.length / 32);
|
|
751
|
+
const reachabilityCacheMaximumBytes = 134_217_728;
|
|
752
|
+
const reachabilityCacheMaximumEntries = Math.max(1, Math.floor(reachabilityCacheMaximumBytes / Math.max(4, bitsetWords * 4)));
|
|
753
|
+
const reachableCache = new Map();
|
|
754
|
+
const traversalQueue = new Int32Array(ownerIds.length);
|
|
755
|
+
const reachableFrom = (start) => {
|
|
756
|
+
const startIndex = ownerIndex.get(start);
|
|
757
|
+
if (startIndex === undefined)
|
|
758
|
+
return new Uint32Array(bitsetWords);
|
|
759
|
+
const cached = reachableCache.get(startIndex);
|
|
760
|
+
if (cached !== undefined) {
|
|
761
|
+
reachableCache.delete(startIndex);
|
|
762
|
+
reachableCache.set(startIndex, cached);
|
|
763
|
+
return cached;
|
|
764
|
+
}
|
|
765
|
+
const reached = new Uint32Array(bitsetWords);
|
|
766
|
+
const startWord = startIndex >>> 5;
|
|
767
|
+
reached[startWord] = (reached[startWord] ?? 0) | (1 << (startIndex & 31));
|
|
768
|
+
let queueStart = 0;
|
|
769
|
+
let queueEnd = 1;
|
|
770
|
+
traversalQueue[0] = startIndex;
|
|
771
|
+
while (queueStart < queueEnd) {
|
|
772
|
+
budget.spend('state_reachability');
|
|
773
|
+
const current = traversalQueue[queueStart++];
|
|
774
|
+
for (const target of adjacency[current] ?? []) {
|
|
775
|
+
const word = target >>> 5;
|
|
776
|
+
const mask = 1 << (target & 31);
|
|
777
|
+
if ((reached[word] & mask) !== 0)
|
|
778
|
+
continue;
|
|
779
|
+
reached[word] |= mask;
|
|
780
|
+
traversalQueue[queueEnd++] = target;
|
|
781
|
+
}
|
|
782
|
+
}
|
|
783
|
+
reachableCache.set(startIndex, reached);
|
|
784
|
+
while (reachableCache.size > reachabilityCacheMaximumEntries) {
|
|
785
|
+
const oldest = reachableCache.keys().next().value;
|
|
786
|
+
if (oldest === undefined)
|
|
787
|
+
break;
|
|
788
|
+
reachableCache.delete(oldest);
|
|
789
|
+
}
|
|
790
|
+
return reached;
|
|
791
|
+
};
|
|
792
|
+
const isReachable = (from, to) => {
|
|
793
|
+
const targetIndex = ownerIndex.get(to);
|
|
794
|
+
if (targetIndex === undefined)
|
|
795
|
+
return false;
|
|
796
|
+
const reached = reachableFrom(from);
|
|
797
|
+
return (reached[targetIndex >>> 5] & (1 << (targetIndex & 31))) !== 0;
|
|
798
|
+
};
|
|
799
|
+
const producers = new Map();
|
|
800
|
+
const consumers = new Map();
|
|
801
|
+
const retainOwnerAccess = (target, access) => {
|
|
802
|
+
const key = stateKey(access);
|
|
803
|
+
const owner = semanticOwnerId(access.ownerId, nodes, parent);
|
|
804
|
+
const values = target.get(key) ?? new Map();
|
|
805
|
+
const previous = values.get(owner);
|
|
806
|
+
if (previous === undefined || compareCodeUnits(access.id, previous.id) < 0) {
|
|
807
|
+
values.set(owner, access);
|
|
808
|
+
}
|
|
809
|
+
target.set(key, values);
|
|
810
|
+
};
|
|
811
|
+
for (const access of graph.stateAccesses) {
|
|
812
|
+
if (access.access === 'write' ||
|
|
813
|
+
access.access === 'read_write' ||
|
|
814
|
+
access.access === 'replace') {
|
|
815
|
+
retainOwnerAccess(producers, access);
|
|
816
|
+
}
|
|
817
|
+
if (access.access === 'read' || access.access === 'read_write') {
|
|
818
|
+
retainOwnerAccess(consumers, access);
|
|
819
|
+
}
|
|
820
|
+
}
|
|
821
|
+
const links = [];
|
|
822
|
+
let candidateCount = 0;
|
|
823
|
+
for (const [, readsByOwner] of [...consumers].sort(([left], [right]) => compareCodeUnits(left, right))) {
|
|
824
|
+
const reads = [...readsByOwner.values()].sort((a, b) => compareCodeUnits(a.id, b.id));
|
|
825
|
+
for (const consumer of reads) {
|
|
826
|
+
const writes = stableUnique(compatibleProducerKeys(consumer).flatMap((producerKey) => [
|
|
827
|
+
...(producers.get(producerKey)?.values() ?? []),
|
|
828
|
+
]));
|
|
829
|
+
const consumerOwner = semanticOwnerId(consumer.ownerId, nodes, parent);
|
|
830
|
+
for (const producer of writes) {
|
|
831
|
+
candidateCount += 1;
|
|
832
|
+
if (candidateCount > EVENT_GRAPH_MAX_STATE_LINK_CANDIDATES) {
|
|
833
|
+
addIssue(graph.issues, {
|
|
834
|
+
code: 'EVENT_STATE_LINK_ANALYSIS_LIMIT',
|
|
835
|
+
classification: 'unresolved_analysis',
|
|
836
|
+
severity: 'blocker',
|
|
837
|
+
message: 'State-flow candidate analysis reached its fixed work ceiling; accesses remain available for bounded subject queries.',
|
|
838
|
+
confidence: 'unresolved',
|
|
839
|
+
location: consumer.location,
|
|
840
|
+
blockers: [
|
|
841
|
+
{
|
|
842
|
+
code: 'STATE_LINK_ANALYSIS_LIMIT',
|
|
843
|
+
message: 'Use stateSubject to inspect candidate producers for this value.',
|
|
844
|
+
location: consumer.location,
|
|
845
|
+
details: { maximum: EVENT_GRAPH_MAX_STATE_LINK_CANDIDATES },
|
|
846
|
+
},
|
|
847
|
+
],
|
|
848
|
+
subjectIds: [consumerOwner],
|
|
849
|
+
});
|
|
850
|
+
return stableUnique(links);
|
|
851
|
+
}
|
|
852
|
+
budget.spend('state_link');
|
|
853
|
+
const producerOwner = semanticOwnerId(producer.ownerId, nodes, parent);
|
|
854
|
+
const pathConfirmed = isReachable(producerOwner, consumerOwner);
|
|
855
|
+
// Keep the shared graph as a proof graph, not a Cartesian inventory.
|
|
856
|
+
// Non-reachable producers remain available in stateAccesses and can be
|
|
857
|
+
// reported as unconfirmed candidates by a bounded subject query.
|
|
858
|
+
if (!pathConfirmed)
|
|
859
|
+
continue;
|
|
860
|
+
if (links.length >= EVENT_GRAPH_MAX_STATE_LINKS) {
|
|
861
|
+
addIssue(graph.issues, {
|
|
862
|
+
code: 'EVENT_STATE_LINK_LIMIT',
|
|
863
|
+
classification: 'unresolved_analysis',
|
|
864
|
+
severity: 'blocker',
|
|
865
|
+
message: 'Confirmed state-flow links reached the fixed materialization ceiling; state accesses remain available for bounded queries.',
|
|
866
|
+
confidence: 'unresolved',
|
|
867
|
+
location: consumer.location,
|
|
868
|
+
blockers: [
|
|
869
|
+
{
|
|
870
|
+
code: 'STATE_LINK_LIMIT',
|
|
871
|
+
message: 'Use a stateSubject filter to inspect omitted producer candidates.',
|
|
872
|
+
location: consumer.location,
|
|
873
|
+
details: { maximum: EVENT_GRAPH_MAX_STATE_LINKS },
|
|
874
|
+
},
|
|
875
|
+
],
|
|
876
|
+
subjectIds: [consumerOwner],
|
|
877
|
+
});
|
|
878
|
+
return stableUnique(links);
|
|
879
|
+
}
|
|
880
|
+
const confidence = hasUnknownSavedScopeStorage(consumer)
|
|
881
|
+
? 'unresolved'
|
|
882
|
+
: worstConfidence(producer.confidence, consumer.confidence);
|
|
883
|
+
links.push({
|
|
884
|
+
id: `state-link:${hashCanonical({ producer: producer.id, consumer: consumer.id }).slice(0, 32)}`,
|
|
885
|
+
stateKind: consumer.kind,
|
|
886
|
+
name: consumer.name,
|
|
887
|
+
producerId: producer.id,
|
|
888
|
+
consumerId: consumer.id,
|
|
889
|
+
confidence,
|
|
890
|
+
pathConfirmed,
|
|
891
|
+
});
|
|
892
|
+
}
|
|
893
|
+
}
|
|
894
|
+
}
|
|
895
|
+
return stableUnique(links);
|
|
896
|
+
}
|
|
897
|
+
function ensureReferencedNodes(graph) {
|
|
898
|
+
const nodes = nodeById(graph.nodes);
|
|
899
|
+
for (const edge of graph.edges) {
|
|
900
|
+
if (nodes.has(edge.to))
|
|
901
|
+
continue;
|
|
902
|
+
const isEvent = edge.to.startsWith('event:');
|
|
903
|
+
const isHelper = edge.to.startsWith('helper:');
|
|
904
|
+
if (!isEvent && !isHelper)
|
|
905
|
+
continue;
|
|
906
|
+
const expression = edge.to.slice(edge.to.indexOf(':') + 1);
|
|
907
|
+
const unresolvedKind = isEvent ? 'missing_event' : 'missing_helper';
|
|
908
|
+
const node = {
|
|
909
|
+
id: edge.to,
|
|
910
|
+
kind: 'unresolved',
|
|
911
|
+
label: `Missing ${isEvent ? 'event' : 'helper'}: ${expression}`,
|
|
912
|
+
...(isEvent ? { eventId: expression, namespace: expression.split('.')[0] } : {}),
|
|
913
|
+
sourcePath: edge.location.path,
|
|
914
|
+
location: edge.location,
|
|
915
|
+
metadata: { expression, unresolvedKind },
|
|
916
|
+
};
|
|
917
|
+
graph.nodes.push(node);
|
|
918
|
+
nodes.set(node.id, node);
|
|
919
|
+
graph.unresolved.push({
|
|
920
|
+
id: `unresolved:${unresolvedKind}:${hashCanonical({
|
|
921
|
+
expression,
|
|
922
|
+
ownerId: edge.from,
|
|
923
|
+
location: locationKey(edge.location),
|
|
924
|
+
}).slice(0, 32)}`,
|
|
925
|
+
kind: unresolvedKind,
|
|
926
|
+
expression,
|
|
927
|
+
ownerId: edge.from,
|
|
928
|
+
location: edge.location,
|
|
929
|
+
confidence: 'confirmed',
|
|
930
|
+
blockers: [
|
|
931
|
+
{
|
|
932
|
+
code: isEvent ? 'MISSING_EVENT' : 'MISSING_HELPER',
|
|
933
|
+
message: `No active ${isEvent ? 'event' : 'scripted-effect'} definition matches the reference.`,
|
|
934
|
+
location: edge.location,
|
|
935
|
+
},
|
|
936
|
+
],
|
|
937
|
+
});
|
|
938
|
+
}
|
|
939
|
+
}
|
|
940
|
+
function duplicateAndReferenceIssues(graph) {
|
|
941
|
+
const nodes = nodeById(graph.nodes);
|
|
942
|
+
const parent = optionParents(graph.edges);
|
|
943
|
+
const eventGroups = new Map();
|
|
944
|
+
for (const node of graph.nodes) {
|
|
945
|
+
if (node.kind !== 'event' || node.eventId === undefined)
|
|
946
|
+
continue;
|
|
947
|
+
const values = eventGroups.get(node.eventId) ?? [];
|
|
948
|
+
values.push(node);
|
|
949
|
+
eventGroups.set(node.eventId, values);
|
|
950
|
+
}
|
|
951
|
+
for (const [eventId, definitions] of [...eventGroups].sort(([left], [right]) => compareCodeUnits(left, right))) {
|
|
952
|
+
if (definitions.length < 2)
|
|
953
|
+
continue;
|
|
954
|
+
definitions.sort((left, right) => compareCodeUnits(locationKey(left.location), locationKey(right.location)) ||
|
|
955
|
+
compareCodeUnits(left.id, right.id));
|
|
956
|
+
addIssue(graph.issues, {
|
|
957
|
+
code: 'EVENT_DUPLICATE_ID',
|
|
958
|
+
classification: 'confirmed_error',
|
|
959
|
+
severity: 'error',
|
|
960
|
+
message: `Multiple active event definitions use ${eventId}.`,
|
|
961
|
+
confidence: 'confirmed',
|
|
962
|
+
location: definitions[0]?.location,
|
|
963
|
+
related: definitions
|
|
964
|
+
.slice(1)
|
|
965
|
+
.flatMap(({ location }) => (location === undefined ? [] : [location])),
|
|
966
|
+
subjectIds: [`event:${eventId}`],
|
|
967
|
+
details: { eventId, definitionCount: definitions.length },
|
|
968
|
+
});
|
|
969
|
+
}
|
|
970
|
+
for (const unresolved of graph.unresolved) {
|
|
971
|
+
if (unresolved.kind !== 'missing_event' && unresolved.kind !== 'missing_helper')
|
|
972
|
+
continue;
|
|
973
|
+
const ownerId = unresolved.ownerId === undefined
|
|
974
|
+
? undefined
|
|
975
|
+
: semanticOwnerId(unresolved.ownerId, nodes, parent);
|
|
976
|
+
addIssue(graph.issues, {
|
|
977
|
+
code: unresolved.kind === 'missing_event' ? 'EVENT_REFERENCE_MISSING' : 'EVENT_HELPER_UNRESOLVED',
|
|
978
|
+
classification: unresolved.kind === 'missing_event' ? 'confirmed_error' : 'unresolved_analysis',
|
|
979
|
+
severity: unresolved.kind === 'missing_event' ? 'error' : 'warning',
|
|
980
|
+
message: unresolved.kind === 'missing_event'
|
|
981
|
+
? `A call references missing event ${unresolved.expression}.`
|
|
982
|
+
: `A call references unresolved scripted effect ${unresolved.expression}.`,
|
|
983
|
+
confidence: unresolved.confidence,
|
|
984
|
+
location: unresolved.location,
|
|
985
|
+
blockers: unresolved.blockers,
|
|
986
|
+
subjectIds: ownerId === undefined ? [] : [ownerId],
|
|
987
|
+
details: { expression: unresolved.expression },
|
|
988
|
+
});
|
|
989
|
+
}
|
|
990
|
+
}
|
|
991
|
+
function structuralCallIssues(graph, budget) {
|
|
992
|
+
const nodes = nodeById(graph.nodes);
|
|
993
|
+
const parent = optionParents(graph.edges);
|
|
994
|
+
const immediate = graph.edges
|
|
995
|
+
.filter(({ derived, reason }) => !derived && reason === 'immediate_event_call')
|
|
996
|
+
.map((edge) => ({
|
|
997
|
+
edge,
|
|
998
|
+
from: semanticOwnerId(edge.from, nodes, parent),
|
|
999
|
+
to: semanticOwnerId(edge.to, nodes, parent),
|
|
1000
|
+
}))
|
|
1001
|
+
.filter(({ from, to }) => nodes.get(from)?.kind === 'event' && nodes.get(to)?.kind === 'event')
|
|
1002
|
+
.sort((left, right) => compareCodeUnits(left.edge.id, right.edge.id));
|
|
1003
|
+
for (const { edge, from, to } of immediate) {
|
|
1004
|
+
if (from !== to)
|
|
1005
|
+
continue;
|
|
1006
|
+
addIssue(graph.issues, {
|
|
1007
|
+
code: 'EVENT_IMMEDIATE_SELF_CALL',
|
|
1008
|
+
classification: 'confirmed_error',
|
|
1009
|
+
severity: 'error',
|
|
1010
|
+
message: 'An event immediately calls itself without a visible delay.',
|
|
1011
|
+
confidence: edge.confidence,
|
|
1012
|
+
location: edge.location,
|
|
1013
|
+
subjectIds: [from],
|
|
1014
|
+
});
|
|
1015
|
+
}
|
|
1016
|
+
const outgoing = new Map();
|
|
1017
|
+
for (const { from, to } of immediate.filter(({ from, to }) => from !== to)) {
|
|
1018
|
+
const values = outgoing.get(from) ?? [];
|
|
1019
|
+
values.push(to);
|
|
1020
|
+
outgoing.set(from, values);
|
|
1021
|
+
}
|
|
1022
|
+
for (const values of outgoing.values())
|
|
1023
|
+
values.sort(compareCodeUnits);
|
|
1024
|
+
const components = iterativeStronglyConnectedComponents(immediate.flatMap(({ from, to }) => [from, to]), outgoing, () => budget.spend('immediate_cycle'));
|
|
1025
|
+
for (const component of components.filter(({ length }) => length > 1)) {
|
|
1026
|
+
const componentSet = new Set(component);
|
|
1027
|
+
const evidence = immediate.find(({ from, to }) => componentSet.has(from) && componentSet.has(to))?.edge;
|
|
1028
|
+
addIssue(graph.issues, {
|
|
1029
|
+
code: 'EVENT_IMMEDIATE_CYCLE',
|
|
1030
|
+
classification: 'probable_defect',
|
|
1031
|
+
severity: 'error',
|
|
1032
|
+
message: 'An immediate event cycle can recurse without a visible scheduling boundary.',
|
|
1033
|
+
confidence: evidence?.confidence ?? 'high',
|
|
1034
|
+
location: evidence?.location,
|
|
1035
|
+
subjectIds: component,
|
|
1036
|
+
});
|
|
1037
|
+
}
|
|
1038
|
+
const collapsed = collapsedFlowEdges(nodes, graph.edges)
|
|
1039
|
+
.filter(({ reason }) => reason !== 'terminal' && reason !== 'option_branch')
|
|
1040
|
+
.map((edge) => ({
|
|
1041
|
+
edge,
|
|
1042
|
+
from: semanticOwnerId(edge.from, nodes, parent),
|
|
1043
|
+
to: semanticOwnerId(edge.to, nodes, parent),
|
|
1044
|
+
}))
|
|
1045
|
+
.filter(({ from, to }) => nodes.get(from)?.kind === 'event' && nodes.get(to)?.kind === 'event')
|
|
1046
|
+
.sort((left, right) => compareCodeUnits(left.edge.id, right.edge.id));
|
|
1047
|
+
const cyclicOutgoing = new Map();
|
|
1048
|
+
for (const { from, to } of collapsed) {
|
|
1049
|
+
const values = cyclicOutgoing.get(from) ?? [];
|
|
1050
|
+
values.push(to);
|
|
1051
|
+
cyclicOutgoing.set(from, values);
|
|
1052
|
+
}
|
|
1053
|
+
for (const values of cyclicOutgoing.values())
|
|
1054
|
+
values.sort(compareCodeUnits);
|
|
1055
|
+
const flowComponents = iterativeStronglyConnectedComponents(collapsed.flatMap(({ from, to }) => [from, to]), cyclicOutgoing, () => budget.spend('event_cycle'));
|
|
1056
|
+
for (const component of flowComponents) {
|
|
1057
|
+
const members = new Set(component);
|
|
1058
|
+
const internal = collapsed.filter(({ from, to }) => members.has(from) && members.has(to));
|
|
1059
|
+
const cyclic = component.length > 1 || internal.some(({ from, to }) => from === to);
|
|
1060
|
+
if (!cyclic)
|
|
1061
|
+
continue;
|
|
1062
|
+
const immediateOnly = internal.every(({ edge }) => edge.reason === 'immediate_event_call' && edge.conditions.length === 0);
|
|
1063
|
+
if (immediateOnly)
|
|
1064
|
+
continue;
|
|
1065
|
+
const gatedOrScheduled = internal.some(({ edge }) => {
|
|
1066
|
+
const scheduled = edge.reason === 'delayed_event_call' ||
|
|
1067
|
+
(edge.timing !== undefined &&
|
|
1068
|
+
edge.timing.mode !== 'immediate' &&
|
|
1069
|
+
edge.timing.mode !== 'unknown');
|
|
1070
|
+
const probabilistic = edge.reason === 'random_event_call' ||
|
|
1071
|
+
edge.reason === 'weighted_event_call' ||
|
|
1072
|
+
edge.weight !== undefined;
|
|
1073
|
+
return scheduled || probabilistic || edge.conditions.length > 0;
|
|
1074
|
+
});
|
|
1075
|
+
if (gatedOrScheduled)
|
|
1076
|
+
continue;
|
|
1077
|
+
const evidence = internal[0]?.edge;
|
|
1078
|
+
addIssue(graph.issues, {
|
|
1079
|
+
code: 'EVENT_CYCLE_GUARD_UNRESOLVED',
|
|
1080
|
+
classification: 'unresolved_analysis',
|
|
1081
|
+
severity: 'warning',
|
|
1082
|
+
message: 'A cyclic event route has no statically visible gate, delay, or random boundary.',
|
|
1083
|
+
confidence: 'low',
|
|
1084
|
+
location: evidence?.location,
|
|
1085
|
+
blockers: [
|
|
1086
|
+
{
|
|
1087
|
+
code: 'CYCLE_GUARD_NOT_PROVEN',
|
|
1088
|
+
message: 'Static analysis cannot establish a scheduling or gating boundary for the cycle.',
|
|
1089
|
+
...(evidence === undefined ? {} : { location: evidence.location }),
|
|
1090
|
+
},
|
|
1091
|
+
],
|
|
1092
|
+
subjectIds: component,
|
|
1093
|
+
});
|
|
1094
|
+
}
|
|
1095
|
+
for (const edge of graph.edges.filter(({ derived, weight }) => !derived && weight?.valid === false)) {
|
|
1096
|
+
addIssue(graph.issues, {
|
|
1097
|
+
code: 'EVENT_RANDOM_WEIGHT_INVALID',
|
|
1098
|
+
classification: 'confirmed_error',
|
|
1099
|
+
severity: 'error',
|
|
1100
|
+
message: 'A random event branch has a missing or invalid weight.',
|
|
1101
|
+
confidence: edge.confidence,
|
|
1102
|
+
location: edge.location,
|
|
1103
|
+
subjectIds: [semanticOwnerId(edge.from, nodes, parent)],
|
|
1104
|
+
details: { edgeId: edge.id, value: edge.weight?.value ?? '' },
|
|
1105
|
+
});
|
|
1106
|
+
}
|
|
1107
|
+
for (const edge of graph.edges.filter(({ derived, scope, to }) => {
|
|
1108
|
+
const destination = nodes.get(to);
|
|
1109
|
+
const eventType = destination?.kind === 'event' && typeof destination.metadata.eventType === 'string'
|
|
1110
|
+
? destination.metadata.eventType
|
|
1111
|
+
: undefined;
|
|
1112
|
+
return (!derived &&
|
|
1113
|
+
scope !== undefined &&
|
|
1114
|
+
scope.source !== 'unknown' &&
|
|
1115
|
+
scope.destination !== 'unknown' &&
|
|
1116
|
+
!callableFrom(eventType, scope));
|
|
1117
|
+
})) {
|
|
1118
|
+
const ownerId = semanticOwnerId(edge.from, nodes, parent);
|
|
1119
|
+
if (nodes.get(ownerId)?.kind === 'entry')
|
|
1120
|
+
continue;
|
|
1121
|
+
addIssue(graph.issues, {
|
|
1122
|
+
code: 'EVENT_SCOPE_MISMATCH',
|
|
1123
|
+
classification: 'probable_defect',
|
|
1124
|
+
severity: 'warning',
|
|
1125
|
+
message: `An event call expects ${edge.scope.destination} scope from ${edge.scope.source} scope without a visible transition.`,
|
|
1126
|
+
confidence: edge.scope.confidence,
|
|
1127
|
+
location: edge.location,
|
|
1128
|
+
subjectIds: [ownerId],
|
|
1129
|
+
details: {
|
|
1130
|
+
edgeId: edge.id,
|
|
1131
|
+
source: edge.scope.source,
|
|
1132
|
+
destination: edge.scope.destination,
|
|
1133
|
+
},
|
|
1134
|
+
});
|
|
1135
|
+
}
|
|
1136
|
+
}
|
|
1137
|
+
function localisationKeys(node) {
|
|
1138
|
+
const candidates = [
|
|
1139
|
+
node.metadata.titleKey,
|
|
1140
|
+
node.metadata.descriptionKey,
|
|
1141
|
+
node.metadata.descKey,
|
|
1142
|
+
node.metadata.nameKey,
|
|
1143
|
+
node.metadata.localisationKey,
|
|
1144
|
+
];
|
|
1145
|
+
const list = node.metadata.localisationKeys;
|
|
1146
|
+
if (Array.isArray(list))
|
|
1147
|
+
candidates.push(...list);
|
|
1148
|
+
return [
|
|
1149
|
+
...new Set(candidates.filter((value) => typeof value === 'string' && /^[A-Za-z0-9_.:@-]+$/u.test(value))),
|
|
1150
|
+
].sort(compareCodeUnits);
|
|
1151
|
+
}
|
|
1152
|
+
function callableFrom(eventType, scope) {
|
|
1153
|
+
if (scope === undefined || eventType === undefined)
|
|
1154
|
+
return true;
|
|
1155
|
+
if (eventType === 'state_event')
|
|
1156
|
+
return scope.source === 'country' || scope.source === 'state';
|
|
1157
|
+
if (eventType === 'unit_leader_event') {
|
|
1158
|
+
return scope.source === 'character' || scope.source === 'unit_leader';
|
|
1159
|
+
}
|
|
1160
|
+
if (eventType === 'operative_leader_event') {
|
|
1161
|
+
return scope.source === 'character' || scope.source === 'operative';
|
|
1162
|
+
}
|
|
1163
|
+
return scope.source === 'country';
|
|
1164
|
+
}
|
|
1165
|
+
function localisationIssues(graph) {
|
|
1166
|
+
const available = new Set(graph.localisation.map(({ key }) => key));
|
|
1167
|
+
const nodes = nodeById(graph.nodes);
|
|
1168
|
+
const parent = optionParents(graph.edges);
|
|
1169
|
+
const missingByOwner = new Map();
|
|
1170
|
+
for (const node of graph.nodes.filter(({ kind }) => kind === 'event' || kind === 'option')) {
|
|
1171
|
+
const owner = semanticOwnerId(node.id, nodes, parent);
|
|
1172
|
+
const ownerNode = nodes.get(owner);
|
|
1173
|
+
if (ownerNode?.kind === 'event' && ownerNode.metadata.hidden === true)
|
|
1174
|
+
continue;
|
|
1175
|
+
const missing = localisationKeys(node).filter((key) => !available.has(key));
|
|
1176
|
+
const missingFields = [];
|
|
1177
|
+
if (node.kind === 'event') {
|
|
1178
|
+
if (node.metadata.hasTitle !== true)
|
|
1179
|
+
missingFields.push('title');
|
|
1180
|
+
if (node.metadata.hasDescription !== true)
|
|
1181
|
+
missingFields.push('description');
|
|
1182
|
+
}
|
|
1183
|
+
else if (node.metadata.hasName !== true) {
|
|
1184
|
+
missingFields.push('option_name');
|
|
1185
|
+
}
|
|
1186
|
+
if (missing.length === 0 && missingFields.length === 0)
|
|
1187
|
+
continue;
|
|
1188
|
+
const record = missingByOwner.get(owner) ?? {
|
|
1189
|
+
keys: new Set(),
|
|
1190
|
+
fields: new Set(),
|
|
1191
|
+
locations: [],
|
|
1192
|
+
};
|
|
1193
|
+
for (const key of missing)
|
|
1194
|
+
record.keys.add(key);
|
|
1195
|
+
for (const field of missingFields)
|
|
1196
|
+
record.fields.add(field);
|
|
1197
|
+
if (node.location !== undefined)
|
|
1198
|
+
record.locations.push(node.location);
|
|
1199
|
+
missingByOwner.set(owner, record);
|
|
1200
|
+
}
|
|
1201
|
+
for (const [owner, record] of [...missingByOwner].sort(([left], [right]) => compareCodeUnits(left, right))) {
|
|
1202
|
+
const locations = record.locations.sort((left, right) => compareCodeUnits(locationKey(left), locationKey(right)));
|
|
1203
|
+
addIssue(graph.issues, {
|
|
1204
|
+
code: 'EVENT_LOCALISATION_MISSING',
|
|
1205
|
+
classification: 'probable_defect',
|
|
1206
|
+
severity: 'warning',
|
|
1207
|
+
message: 'A visible event or option is missing required localisation fields or referenced keys.',
|
|
1208
|
+
confidence: 'high',
|
|
1209
|
+
location: locations[0],
|
|
1210
|
+
related: locations.slice(1),
|
|
1211
|
+
subjectIds: [owner],
|
|
1212
|
+
details: {
|
|
1213
|
+
missingKeys: [...record.keys].sort(compareCodeUnits),
|
|
1214
|
+
missingFields: [...record.fields].sort(compareCodeUnits),
|
|
1215
|
+
},
|
|
1216
|
+
});
|
|
1217
|
+
}
|
|
1218
|
+
}
|
|
1219
|
+
function unresolvedIssues(graph) {
|
|
1220
|
+
const nodes = nodeById(graph.nodes);
|
|
1221
|
+
const parent = optionParents(graph.edges);
|
|
1222
|
+
for (const unresolved of graph.unresolved) {
|
|
1223
|
+
if (unresolved.kind !== 'dynamic_event' && unresolved.kind !== 'dynamic_helper')
|
|
1224
|
+
continue;
|
|
1225
|
+
const owner = unresolved.ownerId === undefined
|
|
1226
|
+
? undefined
|
|
1227
|
+
: semanticOwnerId(unresolved.ownerId, nodes, parent);
|
|
1228
|
+
addIssue(graph.issues, {
|
|
1229
|
+
code: unresolved.kind === 'dynamic_event'
|
|
1230
|
+
? 'EVENT_DYNAMIC_DISPATCH_UNRESOLVED'
|
|
1231
|
+
: 'EVENT_HELPER_UNRESOLVED',
|
|
1232
|
+
classification: 'unresolved_analysis',
|
|
1233
|
+
severity: 'warning',
|
|
1234
|
+
message: unresolved.kind === 'dynamic_event'
|
|
1235
|
+
? 'A dynamic event expression cannot be resolved statically.'
|
|
1236
|
+
: 'A dynamic scripted-effect call cannot be resolved statically.',
|
|
1237
|
+
confidence: 'unresolved',
|
|
1238
|
+
location: unresolved.location,
|
|
1239
|
+
blockers: unresolved.blockers,
|
|
1240
|
+
subjectIds: owner === undefined ? [] : [owner],
|
|
1241
|
+
details: { expression: unresolved.expression },
|
|
1242
|
+
});
|
|
1243
|
+
}
|
|
1244
|
+
}
|
|
1245
|
+
function stateLifecycleIssues(graph, stateLinks, budget) {
|
|
1246
|
+
const nodes = nodeById(graph.nodes);
|
|
1247
|
+
const parent = optionParents(graph.edges);
|
|
1248
|
+
const producers = new Map();
|
|
1249
|
+
const clearers = new Map();
|
|
1250
|
+
const accessesById = new Map(graph.stateAccesses.map((access) => [access.id, access]));
|
|
1251
|
+
for (const access of graph.stateAccesses) {
|
|
1252
|
+
const key = stateKey(access);
|
|
1253
|
+
if (access.access === 'write' ||
|
|
1254
|
+
access.access === 'read_write' ||
|
|
1255
|
+
access.access === 'replace') {
|
|
1256
|
+
const values = producers.get(key) ?? [];
|
|
1257
|
+
values.push(access);
|
|
1258
|
+
producers.set(key, values);
|
|
1259
|
+
}
|
|
1260
|
+
if (access.access === 'clear') {
|
|
1261
|
+
const values = clearers.get(key) ?? [];
|
|
1262
|
+
values.push(access);
|
|
1263
|
+
clearers.set(key, values);
|
|
1264
|
+
}
|
|
1265
|
+
}
|
|
1266
|
+
for (const access of graph.stateAccesses) {
|
|
1267
|
+
if (access.access !== 'read' && access.access !== 'read_write')
|
|
1268
|
+
continue;
|
|
1269
|
+
if (compatibleProducerKeys(access).some((producerKey) => (producers.get(producerKey) ?? []).length > 0))
|
|
1270
|
+
continue;
|
|
1271
|
+
const owner = semanticOwnerId(access.ownerId, nodes, parent);
|
|
1272
|
+
if (access.kind === 'event_target' ||
|
|
1273
|
+
access.kind === 'global_event_target' ||
|
|
1274
|
+
access.kind === 'saved_scope') {
|
|
1275
|
+
addIssue(graph.issues, {
|
|
1276
|
+
code: 'EVENT_TARGET_READ_BEFORE_SAVE',
|
|
1277
|
+
classification: 'probable_defect',
|
|
1278
|
+
severity: 'warning',
|
|
1279
|
+
message: `The ${access.kind.replaceAll('_', ' ')} ${access.name} is read without a visible producer.`,
|
|
1280
|
+
confidence: access.confidence,
|
|
1281
|
+
location: access.location,
|
|
1282
|
+
subjectIds: [owner],
|
|
1283
|
+
details: { stateKind: access.kind, name: access.name, accessId: access.id },
|
|
1284
|
+
});
|
|
1285
|
+
continue;
|
|
1286
|
+
}
|
|
1287
|
+
addIssue(graph.issues, {
|
|
1288
|
+
code: 'EVENT_GATE_WITHOUT_WRITER',
|
|
1289
|
+
classification: 'design_warning',
|
|
1290
|
+
severity: 'warning',
|
|
1291
|
+
message: `The branch reads ${access.kind.replaceAll('_', ' ')} ${access.name} without a visible writer.`,
|
|
1292
|
+
confidence: access.confidence,
|
|
1293
|
+
location: access.location,
|
|
1294
|
+
subjectIds: [owner],
|
|
1295
|
+
details: { stateKind: access.kind, name: access.name, accessId: access.id },
|
|
1296
|
+
});
|
|
1297
|
+
}
|
|
1298
|
+
for (const [key, writes] of [...producers].sort(([left], [right]) => compareCodeUnits(left, right))) {
|
|
1299
|
+
if (!key.startsWith('global_event_target:') ||
|
|
1300
|
+
(clearers.get(key) ?? []).length > 0 ||
|
|
1301
|
+
(clearers.get('global_event_target:*') ?? []).length > 0)
|
|
1302
|
+
continue;
|
|
1303
|
+
const ordered = [...writes].sort((left, right) => compareCodeUnits(left.id, right.id));
|
|
1304
|
+
addIssue(graph.issues, {
|
|
1305
|
+
code: 'EVENT_GLOBAL_TARGET_WITHOUT_CLEANUP',
|
|
1306
|
+
classification: 'design_warning',
|
|
1307
|
+
severity: 'warning',
|
|
1308
|
+
message: 'A global event target is saved without a visible cleanup.',
|
|
1309
|
+
confidence: worstConfidence(...ordered.map(({ confidence }) => confidence)),
|
|
1310
|
+
location: ordered[0]?.location,
|
|
1311
|
+
related: ordered.slice(1).map(({ location }) => location),
|
|
1312
|
+
subjectIds: ordered.map(({ ownerId }) => semanticOwnerId(ownerId, nodes, parent)),
|
|
1313
|
+
details: { name: key.slice('global_event_target:'.length) },
|
|
1314
|
+
});
|
|
1315
|
+
}
|
|
1316
|
+
const flow = collapsedFlowEdges(nodes, graph.edges);
|
|
1317
|
+
const outgoing = new Map();
|
|
1318
|
+
for (const edge of flow) {
|
|
1319
|
+
const from = semanticOwnerId(edge.from, nodes, parent);
|
|
1320
|
+
const to = semanticOwnerId(edge.to, nodes, parent);
|
|
1321
|
+
const values = outgoing.get(from) ?? [];
|
|
1322
|
+
values.push({
|
|
1323
|
+
to,
|
|
1324
|
+
delayed: edge.reason === 'delayed_event_call' ||
|
|
1325
|
+
(edge.timing !== undefined &&
|
|
1326
|
+
edge.timing.mode !== 'immediate' &&
|
|
1327
|
+
edge.timing.mode !== 'unknown'),
|
|
1328
|
+
});
|
|
1329
|
+
outgoing.set(from, values);
|
|
1330
|
+
}
|
|
1331
|
+
for (const values of outgoing.values()) {
|
|
1332
|
+
values.sort((left, right) => compareCodeUnits(left.to, right.to));
|
|
1333
|
+
}
|
|
1334
|
+
const delayedPathCache = new Map();
|
|
1335
|
+
const hasDelayedPath = (from, to) => {
|
|
1336
|
+
const key = `${from}\0${to}`;
|
|
1337
|
+
const cached = delayedPathCache.get(key);
|
|
1338
|
+
if (cached !== undefined)
|
|
1339
|
+
return cached;
|
|
1340
|
+
const queue = [{ id: from, delayed: false }];
|
|
1341
|
+
const visited = new Set([`${from}:false`]);
|
|
1342
|
+
for (const current of queue) {
|
|
1343
|
+
budget.spend('delayed_state_path');
|
|
1344
|
+
for (const edge of outgoing.get(current.id) ?? []) {
|
|
1345
|
+
const delayed = current.delayed || edge.delayed;
|
|
1346
|
+
if (edge.to === to && delayed) {
|
|
1347
|
+
delayedPathCache.set(key, true);
|
|
1348
|
+
return true;
|
|
1349
|
+
}
|
|
1350
|
+
const state = `${edge.to}:${delayed}`;
|
|
1351
|
+
if (visited.has(state))
|
|
1352
|
+
continue;
|
|
1353
|
+
visited.add(state);
|
|
1354
|
+
queue.push({ id: edge.to, delayed });
|
|
1355
|
+
}
|
|
1356
|
+
}
|
|
1357
|
+
delayedPathCache.set(key, false);
|
|
1358
|
+
return false;
|
|
1359
|
+
};
|
|
1360
|
+
for (const link of stateLinks) {
|
|
1361
|
+
if (link.stateKind !== 'event_target' || !link.pathConfirmed)
|
|
1362
|
+
continue;
|
|
1363
|
+
const producer = accessesById.get(link.producerId);
|
|
1364
|
+
const consumer = accessesById.get(link.consumerId);
|
|
1365
|
+
if (producer === undefined || consumer === undefined)
|
|
1366
|
+
continue;
|
|
1367
|
+
const producerOwner = semanticOwnerId(producer.ownerId, nodes, parent);
|
|
1368
|
+
const consumerOwner = semanticOwnerId(consumer.ownerId, nodes, parent);
|
|
1369
|
+
if (!hasDelayedPath(producerOwner, consumerOwner))
|
|
1370
|
+
continue;
|
|
1371
|
+
addIssue(graph.issues, {
|
|
1372
|
+
code: 'EVENT_DELAYED_TRANSIENT_CONTEXT',
|
|
1373
|
+
classification: 'probable_defect',
|
|
1374
|
+
severity: 'warning',
|
|
1375
|
+
message: 'A delayed event route reads a regular event target whose context may not persist.',
|
|
1376
|
+
confidence: worstConfidence(producer.confidence, consumer.confidence),
|
|
1377
|
+
location: consumer.location,
|
|
1378
|
+
related: [producer.location],
|
|
1379
|
+
subjectIds: [producerOwner, consumerOwner],
|
|
1380
|
+
details: { name: link.name, stateLinkId: link.id },
|
|
1381
|
+
});
|
|
1382
|
+
}
|
|
1383
|
+
}
|
|
1384
|
+
function unreachableIssues(graph, budget) {
|
|
1385
|
+
const nodes = nodeById(graph.nodes);
|
|
1386
|
+
const flow = collapsedFlowEdges(nodes, graph.edges);
|
|
1387
|
+
const roots = graph.nodes
|
|
1388
|
+
.filter((node) => node.kind === 'entry' ||
|
|
1389
|
+
(node.kind === 'event' &&
|
|
1390
|
+
(node.metadata.isTriggeredOnly === false || node.metadata.isTriggeredOnly === 'no')))
|
|
1391
|
+
.map(({ id }) => id)
|
|
1392
|
+
.sort(compareCodeUnits);
|
|
1393
|
+
const outgoing = new Map();
|
|
1394
|
+
for (const edge of flow) {
|
|
1395
|
+
const values = outgoing.get(edge.from) ?? [];
|
|
1396
|
+
values.push(edge.to);
|
|
1397
|
+
outgoing.set(edge.from, values);
|
|
1398
|
+
}
|
|
1399
|
+
for (const values of outgoing.values())
|
|
1400
|
+
values.sort(compareCodeUnits);
|
|
1401
|
+
const reachable = new Set(roots);
|
|
1402
|
+
const queue = [...roots];
|
|
1403
|
+
for (const current of queue) {
|
|
1404
|
+
budget.spend('unreachable_analysis');
|
|
1405
|
+
for (const next of outgoing.get(current) ?? []) {
|
|
1406
|
+
if (reachable.has(next))
|
|
1407
|
+
continue;
|
|
1408
|
+
reachable.add(next);
|
|
1409
|
+
queue.push(next);
|
|
1410
|
+
}
|
|
1411
|
+
}
|
|
1412
|
+
for (const node of graph.nodes
|
|
1413
|
+
.filter((candidate) => candidate.kind === 'event' && !reachable.has(candidate.id))
|
|
1414
|
+
.sort((left, right) => compareCodeUnits(left.id, right.id))) {
|
|
1415
|
+
addIssue(graph.issues, {
|
|
1416
|
+
code: 'EVENT_UNREACHABLE_IN_SELECTION',
|
|
1417
|
+
classification: 'design_warning',
|
|
1418
|
+
severity: 'warning',
|
|
1419
|
+
message: 'An event has no statically discovered caller or root entry point.',
|
|
1420
|
+
confidence: 'high',
|
|
1421
|
+
location: node.location,
|
|
1422
|
+
subjectIds: [node.id],
|
|
1423
|
+
});
|
|
1424
|
+
}
|
|
1425
|
+
}
|
|
1426
|
+
function analyzeFragments(snapshot, files, catalog, options, budget) {
|
|
1427
|
+
const fragments = [];
|
|
1428
|
+
for (const file of files) {
|
|
1429
|
+
budget.spend('source_fragment');
|
|
1430
|
+
if (snapshot.index.isSourceSkipped(file.displayPath))
|
|
1431
|
+
continue;
|
|
1432
|
+
const cacheKey = eventSemanticFragmentCacheKey(file, catalog.fingerprint);
|
|
1433
|
+
let fragment = options.cache?.get(cacheKey);
|
|
1434
|
+
if (fragment === undefined) {
|
|
1435
|
+
fragment = analyzeEventSource(file, {
|
|
1436
|
+
...(options.signal === undefined ? {} : { signal: options.signal }),
|
|
1437
|
+
budget,
|
|
1438
|
+
knownEventIds: catalog.eventIds,
|
|
1439
|
+
knownEventTypes: catalog.eventTypes,
|
|
1440
|
+
knownHelperIds: catalog.helperIds,
|
|
1441
|
+
knownDecisionIds: catalog.decisionIds,
|
|
1442
|
+
activeEventPaths: catalog.activeEventPaths,
|
|
1443
|
+
retainedEventPaths: catalog.retainedEventPaths,
|
|
1444
|
+
activeHelperPaths: catalog.activeHelperPaths,
|
|
1445
|
+
activeDecisionPaths: catalog.activeDecisionPaths,
|
|
1446
|
+
inventoryComplete: snapshot.complete,
|
|
1447
|
+
catalogFingerprint: catalog.fingerprint,
|
|
1448
|
+
});
|
|
1449
|
+
options.cache?.set(cacheKey, fragment, file.bytes.length);
|
|
1450
|
+
}
|
|
1451
|
+
fragments.push(fragment);
|
|
1452
|
+
}
|
|
1453
|
+
return fragments.sort((left, right) => compareCodeUnits(left.sourcePath, right.sourcePath) ||
|
|
1454
|
+
compareCodeUnits(left.sourceHash, right.sourceHash));
|
|
1455
|
+
}
|
|
1456
|
+
function partialInventoryEvidence(snapshot) {
|
|
1457
|
+
if (snapshot.complete)
|
|
1458
|
+
return { diagnostics: [], unresolved: [] };
|
|
1459
|
+
const diagnostic = {
|
|
1460
|
+
code: 'EVENT_INVENTORY_PARTIAL',
|
|
1461
|
+
severity: 'blocker',
|
|
1462
|
+
category: 'reference',
|
|
1463
|
+
message: 'Event-chain analysis is partial because the shared source inventory is incomplete.',
|
|
1464
|
+
details: {
|
|
1465
|
+
skippedSourceCount: snapshot.skippedSourceCount,
|
|
1466
|
+
retainedSkippedSources: snapshot.skippedSources.length,
|
|
1467
|
+
skippedSources: snapshot.skippedSources.slice(0, 16).map(({ path, reasonCodes }) => ({
|
|
1468
|
+
path,
|
|
1469
|
+
reasonCodes,
|
|
1470
|
+
})),
|
|
1471
|
+
},
|
|
1472
|
+
};
|
|
1473
|
+
return {
|
|
1474
|
+
diagnostics: [diagnostic],
|
|
1475
|
+
unresolved: [
|
|
1476
|
+
{
|
|
1477
|
+
id: `unresolved:partial-source:${hashCanonical({
|
|
1478
|
+
revision: snapshot.revision,
|
|
1479
|
+
skipped: snapshot.skippedSourceCount,
|
|
1480
|
+
}).slice(0, 32)}`,
|
|
1481
|
+
kind: 'partial_source',
|
|
1482
|
+
expression: `${snapshot.skippedSourceCount} skipped source(s)`,
|
|
1483
|
+
confidence: 'unresolved',
|
|
1484
|
+
blockers: [
|
|
1485
|
+
{
|
|
1486
|
+
code: 'PARTIAL_SOURCE_INVENTORY',
|
|
1487
|
+
message: 'A skipped or over-limit source can contain additional definitions or calls.',
|
|
1488
|
+
details: { skippedSourceCount: snapshot.skippedSourceCount },
|
|
1489
|
+
},
|
|
1490
|
+
],
|
|
1491
|
+
},
|
|
1492
|
+
],
|
|
1493
|
+
};
|
|
1494
|
+
}
|
|
1495
|
+
function sortNodes(values) {
|
|
1496
|
+
return [...values].sort((left, right) => compareCodeUnits(left.id, right.id) ||
|
|
1497
|
+
compareCodeUnits(locationKey(left.location), locationKey(right.location)));
|
|
1498
|
+
}
|
|
1499
|
+
function graphStatistics(nodes, edges, stateAccesses, issues) {
|
|
1500
|
+
const count = (kind) => nodes.filter((node) => node.kind === kind).length;
|
|
1501
|
+
return {
|
|
1502
|
+
eventCount: count('event'),
|
|
1503
|
+
optionCount: count('option'),
|
|
1504
|
+
entryCount: count('entry'),
|
|
1505
|
+
helperCount: count('helper'),
|
|
1506
|
+
unresolvedNodeCount: count('unresolved'),
|
|
1507
|
+
terminalCount: count('terminal'),
|
|
1508
|
+
edgeCount: edges.length,
|
|
1509
|
+
derivedEdgeCount: edges.filter(({ derived }) => derived).length,
|
|
1510
|
+
stateAccessCount: stateAccesses.length,
|
|
1511
|
+
issueCount: issues.length,
|
|
1512
|
+
};
|
|
1513
|
+
}
|
|
1514
|
+
/**
|
|
1515
|
+
* Construct the deterministic event semantic graph from the engine's shared scan snapshot.
|
|
1516
|
+
* Parsing remains file-local; this stage owns cross-file resolution and static projections.
|
|
1517
|
+
*/
|
|
1518
|
+
export function buildEventGraph(snapshot, options = {}) {
|
|
1519
|
+
options.signal?.throwIfAborted();
|
|
1520
|
+
const budget = new EventAnalysisBudget(options.signal);
|
|
1521
|
+
const files = activeFiles(snapshot);
|
|
1522
|
+
const catalog = catalogFor(snapshot);
|
|
1523
|
+
const fragments = analyzeFragments(snapshot, files, catalog, options, budget);
|
|
1524
|
+
const graph = mergeFragments(fragments);
|
|
1525
|
+
const partial = partialInventoryEvidence(snapshot);
|
|
1526
|
+
graph.unresolved.push(...partial.unresolved);
|
|
1527
|
+
assertGraphLimit(graph.nodes.length, EVENT_GRAPH_MAX_NODES, 'EVENT_NODE_LIMIT', 'node count');
|
|
1528
|
+
assertGraphLimit(graph.edges.length, EVENT_GRAPH_MAX_EDGES, 'EVENT_EDGE_LIMIT', 'edge count');
|
|
1529
|
+
assertGraphLimit(graph.stateAccesses.length, EVENT_GRAPH_MAX_STATE_ACCESSES, 'EVENT_STATE_ACCESS_LIMIT', 'state-access count');
|
|
1530
|
+
assertGraphLimit(graph.unresolved.length, EVENT_GRAPH_MAX_UNRESOLVED, 'EVENT_UNRESOLVED_LIMIT', 'unresolved-analysis count');
|
|
1531
|
+
normalizePublicEventNodeIds(graph);
|
|
1532
|
+
normalizeCrossFileEdges(graph);
|
|
1533
|
+
ensureReferencedNodes(graph);
|
|
1534
|
+
graph.edges.push(...helperProjections(graph, budget));
|
|
1535
|
+
graph.stateAccesses.push(...projectHelperStateAccesses(graph, budget));
|
|
1536
|
+
addTerminalNodes(graph);
|
|
1537
|
+
graph.nodes = sortNodes(graph.nodes);
|
|
1538
|
+
graph.edges = stableUnique(graph.edges);
|
|
1539
|
+
graph.stateAccesses = stableUnique(graph.stateAccesses);
|
|
1540
|
+
graph.unresolved = stableUnique(graph.unresolved);
|
|
1541
|
+
const stateLinks = stateLinksFor(graph, budget);
|
|
1542
|
+
duplicateAndReferenceIssues(graph);
|
|
1543
|
+
unresolvedIssues(graph);
|
|
1544
|
+
structuralCallIssues(graph, budget);
|
|
1545
|
+
localisationIssues(graph);
|
|
1546
|
+
stateLifecycleIssues(graph, stateLinks, budget);
|
|
1547
|
+
unreachableIssues(graph, budget);
|
|
1548
|
+
normalizeIssueSubjects(graph);
|
|
1549
|
+
graph.issues = stableIssues(graph.issues);
|
|
1550
|
+
assertGraphLimit(graph.nodes.length, EVENT_GRAPH_MAX_NODES, 'EVENT_NODE_LIMIT', 'node count');
|
|
1551
|
+
assertGraphLimit(graph.edges.length, EVENT_GRAPH_MAX_EDGES, 'EVENT_EDGE_LIMIT', 'edge count');
|
|
1552
|
+
assertGraphLimit(graph.stateAccesses.length, EVENT_GRAPH_MAX_STATE_ACCESSES, 'EVENT_STATE_ACCESS_LIMIT', 'state-access count');
|
|
1553
|
+
assertGraphLimit(stateLinks.length, EVENT_GRAPH_MAX_STATE_LINKS, 'EVENT_STATE_LINK_LIMIT', 'state-link count');
|
|
1554
|
+
assertGraphLimit(graph.issues.length, EVENT_GRAPH_MAX_ISSUES, 'EVENT_ISSUE_LIMIT', 'issue count');
|
|
1555
|
+
assertGraphLimit(graph.unresolved.length, EVENT_GRAPH_MAX_UNRESOLVED, 'EVENT_UNRESOLVED_LIMIT', 'unresolved-analysis count');
|
|
1556
|
+
budget.check();
|
|
1557
|
+
const diagnostics = sortDiagnostics([...snapshot.diagnostics, ...partial.diagnostics]);
|
|
1558
|
+
const complete = snapshot.complete &&
|
|
1559
|
+
graph.unresolved.length === 0 &&
|
|
1560
|
+
!graph.issues.some(({ classification, severity }) => classification === 'unresolved_analysis' || severity === 'blocker');
|
|
1561
|
+
const nodes = sortNodes(graph.nodes);
|
|
1562
|
+
const edges = stableUnique(graph.edges);
|
|
1563
|
+
const stateAccesses = stableUnique(graph.stateAccesses);
|
|
1564
|
+
const issues = stableIssues(graph.issues);
|
|
1565
|
+
const unresolved = stableUnique(graph.unresolved);
|
|
1566
|
+
return {
|
|
1567
|
+
schemaVersion: EVENT_GRAPH_SCHEMA_VERSION,
|
|
1568
|
+
parserVersion: EVENT_GRAPH_PARSER_VERSION,
|
|
1569
|
+
workspaceId: snapshot.workspaceId,
|
|
1570
|
+
workspaceIdentity: options.workspaceIdentity ?? hashCanonical({ workspaceId: snapshot.workspaceId }),
|
|
1571
|
+
revision: snapshot.revision,
|
|
1572
|
+
complete,
|
|
1573
|
+
sourceHashes: sourceHashes(files),
|
|
1574
|
+
filesScanned: files.map(({ displayPath }) => displayPath).sort(compareCodeUnits),
|
|
1575
|
+
skippedSourceCount: snapshot.skippedSourceCount,
|
|
1576
|
+
skippedSources: snapshot.skippedSources,
|
|
1577
|
+
nodes,
|
|
1578
|
+
edges,
|
|
1579
|
+
stateAccesses,
|
|
1580
|
+
stateLinks,
|
|
1581
|
+
issues,
|
|
1582
|
+
diagnostics,
|
|
1583
|
+
unresolved,
|
|
1584
|
+
statistics: graphStatistics(nodes, edges, stateAccesses, issues),
|
|
1585
|
+
};
|
|
1586
|
+
}
|