hoi4-agent-tools 4.1.0 → 4.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.
Files changed (66) hide show
  1. package/CHANGELOG.md +20 -0
  2. package/dist/hoi4_agent_tools/core/canonical.js +42 -0
  3. package/dist/hoi4_agent_tools/core/condition-evaluator.js +12 -8
  4. package/dist/hoi4_agent_tools/core/dependency-walk.js +21 -2
  5. package/dist/hoi4_agent_tools/core/impact-graph.js +6 -2
  6. package/dist/hoi4_agent_tools/core/impact-semantic.js +12 -2
  7. package/dist/hoi4_agent_tools/core/index.js +1 -0
  8. package/dist/hoi4_agent_tools/core/job-worker-host.js +12 -2
  9. package/dist/hoi4_agent_tools/decision/service.js +22 -1
  10. package/dist/hoi4_agent_tools/event/algorithms.js +3 -2
  11. package/dist/hoi4_agent_tools/event/graph.js +52 -35
  12. package/dist/hoi4_agent_tools/event/limits.js +4 -0
  13. package/dist/hoi4_agent_tools/event/operations.js +11 -0
  14. package/dist/hoi4_agent_tools/event/queries.js +13 -34
  15. package/dist/hoi4_agent_tools/event/render.js +4 -2
  16. package/dist/hoi4_agent_tools/event/service.js +93 -33
  17. package/dist/hoi4_agent_tools/event/source-analysis.js +94 -4
  18. package/dist/hoi4_agent_tools/event/state-keys.js +63 -0
  19. package/dist/hoi4_agent_tools/event/summary.js +130 -0
  20. package/dist/hoi4_agent_tools/impact/service.js +2 -1
  21. package/dist/hoi4_agent_tools/mcp/tools/analysis.js +1 -0
  22. package/dist/hoi4_agent_tools/mcp/tools/event.js +66 -0
  23. package/dist/hoi4_agent_tools/reference/source-lookup.js +4 -2
  24. package/dist/hoi4_agent_tools/version.js +1 -1
  25. package/docs/analysis.md +4 -0
  26. package/docs/events.md +12 -0
  27. package/npm-shrinkwrap.json +2 -2
  28. package/package.json +1 -1
  29. package/schemas/configuration.schema.json +1 -1
  30. package/schemas/continuous-focus-palette.schema.json +1 -1
  31. package/schemas/custom-weighted-pool.schema.json +1 -1
  32. package/schemas/decision-inspect-input.schema.json +1 -1
  33. package/schemas/error-log-input.schema.json +1 -1
  34. package/schemas/event-feature-manifest.schema.json +1 -1
  35. package/schemas/focus-plan.schema.json +1 -1
  36. package/schemas/focus-planning-sidecar.schema.json +1 -1
  37. package/schemas/gui-animation-source.schema.json +1 -1
  38. package/schemas/gui-helper.schema.json +1 -1
  39. package/schemas/gui-scenario.schema.json +1 -1
  40. package/schemas/helper-expansion-request.schema.json +1 -1
  41. package/schemas/helper-expansion-summary.schema.json +1 -1
  42. package/schemas/impact-inspect-input.schema.json +1 -1
  43. package/schemas/map-operation.schema.json +1 -1
  44. package/schemas/mechanic-test-input.schema.json +1 -1
  45. package/schemas/mod-index-input.schema.json +1 -1
  46. package/schemas/operation-result.schema.json +1 -1
  47. package/schemas/package-check-input.schema.json +1 -1
  48. package/schemas/package-manifest.schema.json +1 -1
  49. package/schemas/probability-analysis-result.schema.json +1 -1
  50. package/schemas/probability-compare-input.schema.json +1 -1
  51. package/schemas/probability-evaluate-input.schema.json +1 -1
  52. package/schemas/probability-inspect-input.schema.json +1 -1
  53. package/schemas/probability-render-input.schema.json +1 -1
  54. package/schemas/probability-scenario-set.schema.json +1 -1
  55. package/schemas/probability-sequence-input.schema.json +1 -1
  56. package/schemas/probability-simulate-input.schema.json +1 -1
  57. package/schemas/probability-sweep-input.schema.json +1 -1
  58. package/schemas/reference-context-input.schema.json +1 -1
  59. package/schemas/reference-read-input.schema.json +1 -1
  60. package/schemas/reference-search-input.schema.json +1 -1
  61. package/schemas/scenario-suite.schema.json +1 -1
  62. package/schemas/scenario-test-input.schema.json +1 -1
  63. package/schemas/script-validate-input.schema.json +1 -1
  64. package/schemas/shared-scenario.schema.json +1 -1
  65. package/schemas/source-lookup-input.schema.json +1 -1
  66. package/server.json +2 -2
package/CHANGELOG.md CHANGED
@@ -2,6 +2,26 @@
2
2
 
3
3
  ## Unreleased
4
4
 
5
+ ## 4.2.0 - 2026-10-19
6
+
7
+ - Keep job workers running when a discovered mod folder is removed after server start. Workers now resolve only workspaces whose roots still exist, while a job for the removed workspace still fails with `WORKSPACE_ROOT_MISSING`.
8
+ - Stop `hoi4.event_inspect` `scan` from failing with `RangeError: Invalid string length` on large mods. The graph snapshot is measured before serialization and omitted above 128 MiB with the info diagnostic `EVENT_GRAPH_SNAPSHOT_OMITTED`.
9
+ - Return `data.summary` from event inspection: selection finding counts by classification and code with the ten most severe source-linked findings, trace sizes and event IDs, and root counts.
10
+ - Resolve `event_target:name` reads against `save_global_event_target_as` producers; `global_event_target:name` reads still require a global producer. This removes false `EVENT_TARGET_READ_BEFORE_SAVE` probable defects.
11
+ - Record temporary-variable and temporary-array writes, `round_variable`, `modulo_variable`, `randomize_variable`, `set_variable_to_random` and `resize_array`. Link variable reads to writers inside scripted helpers, scope-prefixed names and `array^index` elements, removing most false `EVENT_GATE_WITHOUT_WRITER` warnings.
12
+ - Stop inferring missing scripted helpers from native effect arguments, condition blocks of decisions, ideas, focuses and raids, trigger iterators and data fields of definition files.
13
+ - Report unresolved references in event analysis only for symbol kinds that analysis indexes; sprite and interface references are left to the full index.
14
+ - Keep distinct event findings in one owner separate instead of collapsing them to the first finding per code.
15
+ - Report impact dynamic references only for symbol kinds the traversal reaches, list affected files in `data.affectedFilePaths`, and leave `changedFiles` empty for this read-only analysis.
16
+ - Treat undeclared `has_variable` and `has_event_target` inputs as unresolved in open decision scenarios, matching flags; `closedFlags: true` keeps them absent.
17
+ - Complete workspace-wide event `lint`, `state_flow`, `impact` and unselected renders on large mods. Helper expansion stops at its fixed work ceiling with an `EVENT_HELPER_PROJECTION_LIMIT` finding instead of failing with `DEPENDENCY_WORK_LIMIT`, and dependency checkpoints are spaced by the size of the previous save so their cost stays linear.
18
+ - Remove quadratic passes from root discovery, event cycle checks, selector-scoped lint and terminal or unresolved renders. Root discovery on Chaos Redux with vanilla falls from minutes to under a second.
19
+ - Report each recursive scripted-helper loop once instead of once per caller, and read `event_target:founder.values` as the target `founder`.
20
+ - Return the overview and the branch renders that fit when an unselected overview spends its raster budget, with `omittedBranchRenders` and `coverage.budgetOmittedBranches`, instead of `RENDER_AGGREGATE_BLOCKED`.
21
+ - Add `data.summary` to `explain_path` (route found, steps, event IDs, failure reason) and event `impact` (reach counts); root summaries list the mod's own callerless events first with counts by layer, and finding summaries rank the mod's files first with `byLayer` counts.
22
+ - Keep decision gate, cost and lifecycle evidence when the installed game version is not verified for AI-weight adapters; the AI part is unresolved with a `PROBABILITY_GAME_VERSION_UNSUPPORTED` warning.
23
+ - Raise the semantic reference ceiling for impact analysis and source lookup to its supported maximum of 1,000,000, so a large mod with the base game is analyzed completely.
24
+
5
25
  ## 4.1.0 - 2026-10-15
6
26
 
7
27
  - Add reference-image `create_world` requests with automatic dimensions, countries, biomes, coastline interpretation, drainage, offline English label recognition, optional aligned layers and reviewed semantic hints.
@@ -40,6 +40,48 @@ export function canonicalize(value) {
40
40
  export function canonicalJson(value) {
41
41
  return JSON.stringify(canonicalize(value));
42
42
  }
43
+ /**
44
+ * UTF-8 byte length of `canonicalJson(value)`, or `undefined` as soon as it would exceed
45
+ * `limit`. The value is measured without building the string, so a caller can decline output
46
+ * that would exceed a JavaScript string or an artifact ceiling before allocating it.
47
+ */
48
+ export function canonicalJsonByteLength(value, limit) {
49
+ let total = 0;
50
+ const add = (bytes) => (total += bytes) <= limit;
51
+ const visit = (current) => {
52
+ if (current === null || typeof current === 'string' || typeof current === 'boolean') {
53
+ return add(Buffer.byteLength(JSON.stringify(current)));
54
+ }
55
+ if (typeof current === 'number') {
56
+ if (!Number.isFinite(current))
57
+ throw new TypeError('Canonical JSON cannot contain non-finite numbers');
58
+ return add(Buffer.byteLength(JSON.stringify(Object.is(current, -0) ? 0 : current)));
59
+ }
60
+ if (Array.isArray(current)) {
61
+ if (!add(2 + Math.max(0, current.length - 1)))
62
+ return false;
63
+ for (let index = 0; index < current.length; index += 1) {
64
+ // A hole stays a hole in the canonical copy, which JSON writes as null.
65
+ if (!(index in current) ? !add(4) : !visit(current[index]))
66
+ return false;
67
+ }
68
+ return true;
69
+ }
70
+ if (typeof current === 'object') {
71
+ const object = current;
72
+ const keys = Object.keys(object).filter((key) => object[key] !== undefined);
73
+ if (!add(2 + Math.max(0, keys.length - 1)))
74
+ return false;
75
+ for (const key of keys) {
76
+ if (!add(Buffer.byteLength(JSON.stringify(key)) + 1) || !visit(object[key]))
77
+ return false;
78
+ }
79
+ return true;
80
+ }
81
+ throw new TypeError(`Canonical JSON cannot contain ${typeof current}`);
82
+ };
83
+ return visit(value) ? total : undefined;
84
+ }
43
85
  export function sha256Bytes(value) {
44
86
  return createHash('sha256').update(value).digest('hex');
45
87
  }
@@ -302,19 +302,23 @@ function evaluateAssignmentCore(assignment, scenario, candidate, definitions, he
302
302
  return unresolved(assignment, candidate, `Scenario does not declare flag ${right}`, `flag.${right}`);
303
303
  return { state: flags?.includes(right) === true ? 'true' : 'false', unresolved: [] };
304
304
  }
305
+ // Like flags, an undeclared variable or event target is unknown in an open scenario and
306
+ // absent only when the scenario closes its world with `closedFlags`.
305
307
  if (key === 'has_event_target') {
306
308
  const eventTargets = scopeContext.binding.eventTargets ?? scenario.eventTargets;
307
- return {
308
- state: Object.hasOwn(eventTargets ?? {}, right) ? 'true' : 'false',
309
- unresolved: [],
310
- };
309
+ if (Object.hasOwn(eventTargets ?? {}, right))
310
+ return { state: 'true', unresolved: [] };
311
+ if (scenario.closedFlags === false)
312
+ return unresolved(assignment, candidate, `Scenario does not declare event target ${right}`, `eventTarget.${right}`);
313
+ return { state: 'false', unresolved: [] };
311
314
  }
312
315
  if (key === 'has_variable') {
313
316
  const state = scopeContext.expression === 'ROOT' ? scenario.state : scopeContext.binding.state;
314
- return {
315
- state: Object.hasOwn(state, right) || Object.hasOwn(state, `variable.${right}`) ? 'true' : 'false',
316
- unresolved: [],
317
- };
317
+ if (Object.hasOwn(state, right) || Object.hasOwn(state, `variable.${right}`))
318
+ return { state: 'true', unresolved: [] };
319
+ if (scenario.closedFlags === false)
320
+ return unresolved(assignment, candidate, `Scenario does not declare variable ${right}`, `variable.${right}`);
321
+ return { state: 'false', unresolved: [] };
318
322
  }
319
323
  if (key === 'tag' || key === 'original_tag') {
320
324
  const actor = scopeContext.expression === 'ROOT'
@@ -24,6 +24,8 @@ const stateSchema = z
24
24
  current: z.object({ path: ordinal, edge: ordinal }).strict().nullable(),
25
25
  started: z.boolean(),
26
26
  complete: z.boolean(),
27
+ /** Set when a `truncate` walk stopped at its work ceiling before visiting every root. */
28
+ truncated: z.boolean().optional(),
27
29
  })
28
30
  .strict();
29
31
  /** A bounded, serializable DFS frontier; paths share prefixes instead of copying source data. */
@@ -82,7 +84,10 @@ export class DependencyWalk {
82
84
  const invalid = (message) => {
83
85
  throw new ServiceError('DEPENDENCY_CHECKPOINT_INVALID', message);
84
86
  };
85
- if (this.state.complete !== (this.state.root === this.input.roots.length) ||
87
+ if ((this.state.truncated === true &&
88
+ (this.input.onWorkLimit !== 'truncate' || !this.state.complete)) ||
89
+ this.state.complete !==
90
+ (this.state.root === this.input.roots.length || this.state.truncated === true) ||
86
91
  (!this.state.started && (this.state.current !== null || this.state.stack.length !== 0)) ||
87
92
  (this.state.complete && this.state.started) ||
88
93
  this.state.paths.length > this.state.steps ||
@@ -152,6 +157,15 @@ export class DependencyWalk {
152
157
  const end = this.state.steps + maximumSteps;
153
158
  while (!this.state.complete && this.state.steps < end) {
154
159
  signal?.throwIfAborted();
160
+ if (this.state.steps >= this.input.maximumSteps && this.input.onWorkLimit === 'truncate') {
161
+ // Records found so far are complete paths; the unfinished frontier is dropped.
162
+ this.state.truncated = true;
163
+ this.state.complete = true;
164
+ this.state.started = false;
165
+ this.state.current = null;
166
+ this.state.stack = [];
167
+ break;
168
+ }
155
169
  if (this.state.steps >= this.input.maximumSteps)
156
170
  throw new ServiceError('DEPENDENCY_WORK_LIMIT', 'Dependency expansion reached its explicit work ceiling', {
157
171
  maximum: this.input.maximumSteps,
@@ -241,9 +255,14 @@ export async function walkDependenciesAsync(input, signal, checkpointSteps = 2_0
241
255
  const restored = await checkpoints?.load(key, input.sourceRevision, stateSchema);
242
256
  const walk = new DependencyWalk(input, restored);
243
257
  let savedAt = walk.state.steps;
258
+ // Each save serializes the whole frontier. Spacing saves by the size written last time
259
+ // doubles the gaps as the frontier grows, so total checkpoint work stays linear.
260
+ let savedSize = 0;
244
261
  while (!walk.state.complete) {
245
262
  const complete = walk.advance(256, signal);
246
- if ((complete && savedAt > 0) || walk.state.steps - savedAt >= checkpointSteps) {
263
+ const interval = Math.max(checkpointSteps, savedSize);
264
+ if ((complete && savedAt > 0) || walk.state.steps - savedAt >= interval) {
265
+ savedSize = walk.state.paths.length + walk.state.records.length;
247
266
  await checkpoints?.save(key, input.sourceRevision, walk.checkpoint(), walk.state.root, input.roots.length);
248
267
  savedAt = walk.state.steps;
249
268
  }
@@ -263,6 +263,10 @@ export function inspectImpactGraph(snapshot, query) {
263
263
  queue.push({ key: consumerKey, depth: depth + 1 });
264
264
  }
265
265
  }
266
+ // A dynamic reference can only reach a symbol of the kind it names, so references to other
267
+ // kinds neither hide consumers of this selection nor make its evidence incomplete.
268
+ const traversedKinds = new Set([...visited].map((key) => key.slice(0, key.indexOf('\0'))));
269
+ const dynamicReferences = semantic.unresolved.filter(({ toKind }) => traversedKinds.has(toKind));
266
270
  const { cycles, omittedCycles } = reachedCycleEdges(visited, [
267
271
  ...directConsumers,
268
272
  ...transitiveConsumers,
@@ -276,14 +280,14 @@ export function inspectImpactGraph(snapshot, query) {
276
280
  omittedCycles === 0 &&
277
281
  !stoppedAtDepth &&
278
282
  unresolved.length === 0 &&
279
- semantic.unresolved.length === 0,
283
+ dynamicReferences.length === 0,
280
284
  definitions,
281
285
  missingSymbols,
282
286
  directConsumers: directConsumers.sort(edgeOrder),
283
287
  transitiveConsumers: transitiveConsumers.sort(edgeOrder),
284
288
  affectedFiles: [...affectedFiles].sort(compareCodeUnits),
285
289
  unresolved,
286
- dynamicReferences: semantic.unresolved,
290
+ dynamicReferences,
287
291
  cycles,
288
292
  coverage: {
289
293
  scannedSymbols: snapshot.index.symbols.length,
@@ -1,5 +1,11 @@
1
1
  import { compareCodeUnits } from './canonical.js';
2
2
  import { assignments, firstScalar, nodeLocation, parseClausewitz, } from './source/index.js';
3
+ /**
4
+ * Semantic references retained per workspace scan. A large mod with the base game holds several
5
+ * hundred thousand (one measured mod with vanilla held about 660,000), so the ceiling is the supported
6
+ * maximum rather than a fraction of it.
7
+ */
8
+ export const IMPACT_SEMANTIC_MAX_REFERENCES = 1_000_000;
3
9
  const eventKeys = new Set([
4
10
  'country_event',
5
11
  'news_event',
@@ -120,8 +126,10 @@ function staticTarget(value) {
120
126
  !/^(?:ROOT|FROM|PREV|THIS)$/iu.test(value));
121
127
  }
122
128
  /** Extracts typed script edges while preserving unknown dynamic references. */
123
- export function scanImpactSemanticReferences(snapshot, maxReferences = 200_000, options = {}) {
124
- if (!Number.isSafeInteger(maxReferences) || maxReferences < 1 || maxReferences > 1_000_000)
129
+ export function scanImpactSemanticReferences(snapshot, maxReferences = IMPACT_SEMANTIC_MAX_REFERENCES, options = {}) {
130
+ if (!Number.isSafeInteger(maxReferences) ||
131
+ maxReferences < 1 ||
132
+ maxReferences > IMPACT_SEMANTIC_MAX_REFERENCES)
125
133
  throw new RangeError('Semantic reference limit is outside the supported range');
126
134
  const references = [];
127
135
  const unresolved = [];
@@ -134,6 +142,7 @@ export function scanImpactSemanticReferences(snapshot, maxReferences = 200_000,
134
142
  path: file.displayPath,
135
143
  location: nodeLocation(document, node, owner.id),
136
144
  reason: `Dynamic ${referenceKind} target ${target} cannot be resolved statically`,
145
+ toKind,
137
146
  });
138
147
  else
139
148
  complete = false;
@@ -192,6 +201,7 @@ export function scanImpactSemanticReferences(snapshot, maxReferences = 200_000,
192
201
  path: file.displayPath,
193
202
  location: nodeLocation(document, assignment, current.id),
194
203
  reason: `Cannot identify ${key} state key`,
204
+ toKind: stateOperation.kind,
195
205
  });
196
206
  else
197
207
  complete = false;
@@ -1440,6 +1440,7 @@ export class SymbolIndex {
1440
1440
  ...(reference.location === undefined ? {} : { location: reference.location }),
1441
1441
  details: {
1442
1442
  referenceKind: reference.kind,
1443
+ toKind: reference.toKind,
1443
1444
  from: reference.from,
1444
1445
  ...(mayResolveFromSkippedSource ? { skippedSourceCount: this.#skippedSourceCount } : {}),
1445
1446
  },
@@ -1,6 +1,6 @@
1
1
  import { spawn } from 'node:child_process';
2
2
  import { closeSync, fstatSync, openSync, readSync, readdirSync, rmSync, statSync } from 'node:fs';
3
- import { mkdir } from 'node:fs/promises';
3
+ import { mkdir, stat } from 'node:fs/promises';
4
4
  import path from 'node:path';
5
5
  import { fileURLToPath } from 'node:url';
6
6
  import { setTimeout as delay } from 'node:timers/promises';
@@ -134,6 +134,16 @@ function retainOrRemoveLog(file, evidence, logRoot) {
134
134
  * Bounded, fixed-entry child execution. Client disconnection never kills a worker; an
135
135
  * overdue or unresponsive cancelled read-only worker is stopped by its supervising host.
136
136
  */
137
+ /**
138
+ * Select the workspaces a job worker resolves again. A mod folder removed after the host
139
+ * discovered it must not stop jobs for every other workspace, so absent roots are left out.
140
+ * The job's own workspace is always kept, and a missing root still fails that job explicitly.
141
+ */
142
+ export async function workerWorkspaces(workspaces, workspaceId) {
143
+ const retained = await Promise.all(workspaces.map(async (workspace) => workspace.id === workspaceId ||
144
+ (await stat(workspace.modRoot).then((metadata) => metadata.isDirectory(), () => false))));
145
+ return workspaces.filter((_workspace, index) => retained[index]);
146
+ }
137
147
  export class JobWorkerHost {
138
148
  engine;
139
149
  jobs;
@@ -234,7 +244,7 @@ export class JobWorkerHost {
234
244
  return;
235
245
  const configuration = this.engine.resolver.config();
236
246
  const registeredWorkspaceIds = new Set(configuration.workspaces.map((workspace) => workspace.id));
237
- const resolvedWorkspaces = this.engine.resolver.list();
247
+ const resolvedWorkspaces = await workerWorkspaces(this.engine.resolver.list(), workspaceId);
238
248
  const discoveredWorkspaceIds = resolvedWorkspaces
239
249
  .map((workspace) => workspace.id)
240
250
  .filter((workspaceId) => !registeredWorkspaceIds.has(workspaceId));
@@ -99,7 +99,20 @@ export class DecisionAnalyzer {
99
99
  }));
100
100
  const ai = input.mode === 'inventory'
101
101
  ? undefined
102
- : await inspectDecisionAi(this.engine, before, input.id, scenarios, overlay, input.principal, input.signal);
102
+ : await inspectDecisionAi(this.engine, before, input.id, scenarios, overlay, input.principal, input.signal).catch((error) => {
103
+ // AI weights depend on adapters verified for one game version. A game patch leaves
104
+ // the AI score unresolved; visibility, availability and cost evidence still stand.
105
+ if (error instanceof ServiceError &&
106
+ (error.code === 'PROBABILITY_GAME_VERSION_UNSUPPORTED' ||
107
+ error.code === 'PROBABILITY_GAME_VERSION_UNVERIFIED'))
108
+ return {
109
+ status: 'unresolved',
110
+ code: error.code,
111
+ message: error.message,
112
+ details: error.details,
113
+ };
114
+ throw error;
115
+ });
103
116
  const sourceComplete = input.mode === 'inventory'
104
117
  ? inventory.complete
105
118
  : input.mode === 'compare'
@@ -180,6 +193,14 @@ export class DecisionAnalyzer {
180
193
  : {}),
181
194
  });
182
195
  result.code = complete ? 'DECISION_ANALYZED' : 'DECISION_ANALYZED_PARTIAL';
196
+ if (ai !== undefined && 'code' in ai)
197
+ result.diagnostics.push({
198
+ category: 'configuration',
199
+ code: ai.code,
200
+ severity: 'warning',
201
+ message: `${ai.message}; AI weights are unresolved, the remaining evidence is unaffected`,
202
+ details: ai.details,
203
+ });
183
204
  result.artifacts = [publicArtifactLink(artifact)];
184
205
  result.proposedFiles = overlay?.changes.map(({ path }) => path).slice(0, 64) ?? [];
185
206
  setInlineFilesScanned(result, before.files.map(({ displayPath }) => displayPath));
@@ -362,10 +362,11 @@ export function eventStronglyConnectedComponents(graph, expandHelpers = false, s
362
362
  const budget = new EventAnalysisBudget(signal);
363
363
  const components = iterativeStronglyConnectedComponents(retainedNodes, adjacency, () => budget.spend('strongly_connected_components'));
364
364
  budget.check();
365
+ // Collect self-loops once: scanning every edge per component is quadratic on large mods.
366
+ const selfLoops = new Set(edges.filter(({ from, to }) => from === to).map(({ from }) => from));
365
367
  return components
366
368
  .map((nodeIds) => {
367
- const members = new Set(nodeIds);
368
- const selfLoop = edges.some(({ from, to }) => from === to && members.has(from));
369
+ const selfLoop = nodeIds.some((nodeId) => selfLoops.has(nodeId));
369
370
  return {
370
371
  id: `scc:${nodeIds.join('|')}`,
371
372
  nodeIds,
@@ -5,6 +5,7 @@ import { beginSemanticAnalysis, semanticFragment, SemanticCatalog, } from '../co
5
5
  import { runDependencyAnalysis, runDependencyAnalysisAsync, } from '../core/dependency-walk.js';
6
6
  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, EVENT_GRAPH_WORK_LIMIT, EventAnalysisBudget, } from './limits.js';
7
7
  import { EVENT_GRAPH_PARSER_VERSION, EVENT_GRAPH_SCHEMA_VERSION, } from './model.js';
8
+ import { compatibleProducerKeys, hasUnknownSavedScopeStorage, stateKey } from './state-keys.js';
8
9
  import { iterativeStronglyConnectedComponents } from './scc.js';
9
10
  import { analyzeEventSource, eventSemanticFragmentCacheKey } from './source-analysis.js';
10
11
  const confidenceRank = {
@@ -117,11 +118,14 @@ function locationKey(location) {
117
118
  return `${location?.path ?? ''}:${location?.start.offset ?? -1}:${location?.end.offset ?? -1}`;
118
119
  }
119
120
  function issueKey(issue) {
120
- const subjectIds = issue.details.subjectIds;
121
+ // One finding per owner and subject: repeated accesses to the same named state collapse,
122
+ // while different events, targets, flags or expressions in one owner stay separate findings.
123
+ const { subjectIds, accessId: _accessId, stateLinkId: _stateLinkId, ...subject } = issue.details;
121
124
  return hashCanonical({
122
125
  code: issue.code,
123
126
  classification: issue.classification,
124
127
  subjectIds: Array.isArray(subjectIds) ? subjectIds : [],
128
+ subject,
125
129
  });
126
130
  }
127
131
  function stableIssues(values) {
@@ -360,6 +364,7 @@ function helperWalkInput(graph, revision) {
360
364
  maximumSteps: EVENT_GRAPH_WORK_LIMIT,
361
365
  visitPolicy: 'node',
362
366
  reverseEdges: true,
367
+ onWorkLimit: 'truncate',
363
368
  };
364
369
  }
365
370
  function helperCallerScope(nodes, parents, start) {
@@ -391,11 +396,17 @@ function helperProjections(graph, budget, walk) {
391
396
  const final = structural.at(-1);
392
397
  const helpers = structural.slice(0, -1).map(({ to }) => nodes.get(to));
393
398
  if (record.kind === 'cycle' || record.kind === 'depth') {
394
- const key = `${record.kind}:${start.id}:${final.to}`;
399
+ const cycle = record.kind === 'cycle';
400
+ // One finding per recursive loop, identified by its member helpers, rather than one per
401
+ // caller that reaches it.
402
+ const chain = helpers.map(({ id }) => id);
403
+ const members = chain.slice(Math.max(0, chain.indexOf(final.to)));
404
+ const key = cycle
405
+ ? `cycle:${[...members].sort(compareCodeUnits).join('\0')}`
406
+ : `depth:${start.id}:${final.to}`;
395
407
  if (reported.has(key))
396
408
  continue;
397
409
  reported.add(key);
398
- const cycle = record.kind === 'cycle';
399
410
  addIssue(graph.issues, {
400
411
  code: cycle ? 'EVENT_HELPER_CYCLE' : 'EVENT_HELPER_DEPTH_LIMIT',
401
412
  classification: 'unresolved_analysis',
@@ -415,9 +426,7 @@ function helperProjections(graph, budget, walk) {
415
426
  ...(cycle ? {} : { details: { maximumDepth: EVENT_GRAPH_MAX_HELPER_DEPTH } }),
416
427
  },
417
428
  ],
418
- subjectIds: cycle
419
- ? [start.from, ...helpers.map(({ id }) => id), final.to]
420
- : [start.from, final.to],
429
+ subjectIds: cycle ? members : [start.from, final.to],
421
430
  });
422
431
  continue;
423
432
  }
@@ -617,32 +626,6 @@ function addTerminalNodes(graph) {
617
626
  });
618
627
  }
619
628
  }
620
- function stateKey(value) {
621
- const scope = value.kind === 'variable' || value.kind === 'array' ? `:${value.scope}` : '';
622
- const storage = value.kind === 'saved_scope'
623
- ? `:${value.metadata.storage === 'global' || value.metadata.storage === 'local'
624
- ? value.metadata.storage
625
- : 'unknown'}`
626
- : '';
627
- return `${value.kind}:${value.name}${scope}${storage}`;
628
- }
629
- function compatibleProducerKeys(value) {
630
- if (value.kind !== 'saved_scope' ||
631
- value.metadata.storage === 'global' ||
632
- value.metadata.storage === 'local') {
633
- return [stateKey(value)];
634
- }
635
- return [
636
- `saved_scope:${value.name}:global`,
637
- `saved_scope:${value.name}:local`,
638
- `saved_scope:${value.name}:unknown`,
639
- ];
640
- }
641
- function hasUnknownSavedScopeStorage(value) {
642
- return (value.kind === 'saved_scope' &&
643
- value.metadata.storage !== 'global' &&
644
- value.metadata.storage !== 'local');
645
- }
646
629
  function collapsedFlowEdges(nodes, edges) {
647
630
  return edges
648
631
  .filter((edge) => {
@@ -999,9 +982,23 @@ function structuralCallIssues(graph, budget) {
999
982
  for (const values of cyclicOutgoing.values())
1000
983
  values.sort(compareCodeUnits);
1001
984
  const flowComponents = iterativeStronglyConnectedComponents(collapsed.flatMap(({ from, to }) => [from, to]), cyclicOutgoing, () => budget.spend('event_cycle'));
1002
- for (const component of flowComponents) {
1003
- const members = new Set(component);
1004
- const internal = collapsed.filter(({ from, to }) => members.has(from) && members.has(to));
985
+ // Group each edge under its component once instead of filtering every edge per component.
986
+ const componentOf = new Map();
987
+ flowComponents.forEach((component, index) => {
988
+ for (const member of component)
989
+ componentOf.set(member, index);
990
+ });
991
+ const internalEdges = new Map();
992
+ for (const edge of collapsed) {
993
+ const component = componentOf.get(edge.from);
994
+ if (component === undefined || componentOf.get(edge.to) !== component)
995
+ continue;
996
+ const values = internalEdges.get(component) ?? [];
997
+ values.push(edge);
998
+ internalEdges.set(component, values);
999
+ }
1000
+ for (const [componentIndex, component] of flowComponents.entries()) {
1001
+ const internal = internalEdges.get(componentIndex) ?? [];
1005
1002
  const cyclic = component.length > 1 || internal.some(({ from, to }) => from === to);
1006
1003
  if (!cyclic)
1007
1004
  continue;
@@ -1487,6 +1484,26 @@ function* eventGraphAnalysis(snapshot, options) {
1487
1484
  ensureReferencedNodes(graph);
1488
1485
  if (options.projectHelpers !== false) {
1489
1486
  const walk = yield helperWalkInput(graph, snapshot.revision);
1487
+ if (walk.state.truncated === true)
1488
+ addIssue(graph.issues, {
1489
+ code: 'EVENT_HELPER_PROJECTION_LIMIT',
1490
+ classification: 'unresolved_analysis',
1491
+ severity: 'blocker',
1492
+ message: 'Helper expansion reached its fixed work ceiling; helper calls from the remaining roots stay structural.',
1493
+ confidence: 'unresolved',
1494
+ blockers: [
1495
+ {
1496
+ code: 'HELPER_PROJECTION_LIMIT',
1497
+ message: 'Select a namespace, event or file, or use helper_expansion mode, for paths beyond the ceiling.',
1498
+ details: {
1499
+ maximum: EVENT_GRAPH_WORK_LIMIT,
1500
+ completedRoots: walk.state.root,
1501
+ totalRoots: walk.input.roots.length,
1502
+ },
1503
+ },
1504
+ ],
1505
+ details: { completedRoots: walk.state.root, totalRoots: walk.input.roots.length },
1506
+ });
1490
1507
  for (const edge of helperProjections(graph, budget, walk))
1491
1508
  graph.edges.push(edge);
1492
1509
  for (const access of projectHelperStateAccesses(graph, budget, walk))
@@ -17,6 +17,10 @@ export const EVENT_GRAPH_WORK_LIMIT = 10_000_000;
17
17
  export const EVENT_FRAGMENT_CACHE_MAX_ENTRIES = 10_000;
18
18
  export const EVENT_FRAGMENT_CACHE_MAX_SOURCE_BYTES = 134_217_728;
19
19
  export const EVENT_PROPOSED_SOURCE_MAX_FILES = 64;
20
+ // The full graph snapshot of a scan is retained only up to this size. Larger graphs, such as a
21
+ // mod with several thousand events and hundreds of thousands of state accesses, would exceed a
22
+ // JavaScript string and the artifact preparation ceiling.
23
+ export const EVENT_GRAPH_SNAPSHOT_MAX_BYTES = 134_217_728;
20
24
  export const EVENT_PROPOSED_SOURCE_MAX_BYTES = 67_108_864;
21
25
  /** One cooperative, deterministic work ceiling shared by extraction and graph construction. */
22
26
  export class EventAnalysisBudget {
@@ -4,6 +4,7 @@ import { publicArtifactLink } from '../core/artifacts.js';
4
4
  import { setInlineFilesScanned } from '../core/operation-result.js';
5
5
  import { emptyServiceResult } from '../core/result.js';
6
6
  import { eventInspectRequestSchema, splitEventInspectRequest, eventRenderRequestSchema, eventCompareRequestSchema, } from '../schemas/event.js';
7
+ import { EVENT_GRAPH_SNAPSHOT_MAX_BYTES } from './limits.js';
7
8
  import { eventDiagnostics, } from './service.js';
8
9
  export function normalizeEventInspectionRequest(input) {
9
10
  // Validated request fields are JSON-only. Match wire semantics by removing optional
@@ -87,6 +88,7 @@ export async function inspectEvents(viewer, input) {
87
88
  ...(inspected.helperExpansion === undefined
88
89
  ? {}
89
90
  : { helperExpansion: inspected.helperExpansion }),
91
+ ...(inspected.summary === undefined ? {} : { summary: inspected.summary }),
90
92
  boundary: {
91
93
  direction: input.direction ?? 'both',
92
94
  maxDepth: inspected.helperExpansion?.maxDepth ?? input.maxDepth ?? 8,
@@ -103,6 +105,14 @@ export async function inspectEvents(viewer, input) {
103
105
  : 'EVENT_INSPECTED_PARTIAL';
104
106
  setInlineFilesScanned(result, inspected.graph.filesScanned);
105
107
  result.artifacts = inspected.artifacts.map(publicArtifactLink);
108
+ if (inspected.graphSnapshotOmitted === true)
109
+ result.diagnostics.push({
110
+ category: 'configuration',
111
+ code: 'EVENT_GRAPH_SNAPSHOT_OMITTED',
112
+ severity: 'info',
113
+ message: 'The workspace graph is too large to retain as a snapshot artifact; the scan report is linked, and comparisons use revisions held by this server',
114
+ details: { maximumBytes: EVENT_GRAPH_SNAPSHOT_MAX_BYTES },
115
+ });
106
116
  result.validation = eventGraphValidation(inspected.graph);
107
117
  if (inspected.helperExpansion !== undefined)
108
118
  result.validation = helperExpansionValidation(inspected.helperExpansion);
@@ -122,6 +132,7 @@ export async function renderEvents(viewer, input) {
122
132
  selectedNodes: rendered.render.selectedNodeIds.length,
123
133
  omittedNodes: rendered.render.omittedNodeCount,
124
134
  branchRenders: rendered.branches.length,
135
+ omittedBranchRenders: rendered.budgetOmittedBranches,
125
136
  },
126
137
  boundary: {
127
138
  direction: input.direction ?? 'both',
@@ -1,5 +1,6 @@
1
1
  import { compareCodeUnits } from '../core/canonical.js';
2
2
  import { eventFlowEdges, eventStronglyConnectedComponents, reachableEventNodeIds, shortestEventPath, traceEventGraph, } from './algorithms.js';
3
+ import { compatibleProducerKeys, hasUnknownSavedScopeStorage, stateKey } from './state-keys.js';
3
4
  function reachableFromRootsWithout(graph, rootIds, removedNodeIds, signal) {
4
5
  signal?.throwIfAborted();
5
6
  const retainedNodeIds = new Set(graph.nodes.filter(({ id }) => !removedNodeIds.has(id)).map(({ id }) => id));
@@ -348,32 +349,6 @@ export function explainEventPath(graph, from, to, boundary, signal) {
348
349
  boundary,
349
350
  };
350
351
  }
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
352
  function compatibleProducers(producers, consumer) {
378
353
  const compatible = new Map();
379
354
  for (const key of compatibleProducerKeys(consumer)) {
@@ -544,20 +519,24 @@ export function lintEventGraph(graph, selector, signal) {
544
519
  if (selector === undefined)
545
520
  return [...graph.issues];
546
521
  const selected = selectedOwnershipIds(graph, selector, signal);
547
- const selectedNodes = graph.nodes.filter(({ id }, index) => {
522
+ // Source ranges of the selected nodes, grouped by file so each issue checks only its own file.
523
+ const selectedRanges = new Map();
524
+ for (const [index, { id, location }] of graph.nodes.entries()) {
548
525
  if ((index & 255) === 0)
549
526
  signal?.throwIfAborted();
550
- return selected.has(id);
551
- });
527
+ if (location === undefined || !selected.has(id))
528
+ continue;
529
+ const ranges = selectedRanges.get(location.path) ?? [];
530
+ ranges.push({ start: location.start.offset, end: location.end.offset });
531
+ selectedRanges.set(location.path, ranges);
532
+ }
552
533
  return graph.issues.filter((issue, index) => {
553
534
  if ((index & 255) === 0)
554
535
  signal?.throwIfAborted();
536
+ const location = issue.location;
555
537
  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)));
538
+ (location !== undefined &&
539
+ (selectedRanges.get(location.path) ?? []).some(({ start, end }) => location.start.offset >= start && location.end.offset <= end)));
561
540
  });
562
541
  }
563
542
  export function issueSubjectIds(issue) {
@@ -103,12 +103,14 @@ function boundedSelection(graph, options) {
103
103
  else if (options.view === 'terminals') {
104
104
  const terminals = withinSelector(graph.nodes.filter(({ kind }) => kind === 'terminal').map(({ id }) => id));
105
105
  add(terminals);
106
- add(graph.edges.filter(({ to }) => terminals.includes(to)).flatMap(({ from, to }) => [from, to]));
106
+ const terminalIds = new Set(terminals);
107
+ add(graph.edges.filter(({ to }) => terminalIds.has(to)).flatMap(({ from, to }) => [from, to]));
107
108
  }
108
109
  else if (options.view === 'unresolved') {
109
110
  const unresolved = withinSelector(graph.nodes.filter(({ kind }) => kind === 'unresolved').map(({ id }) => id));
110
111
  add(unresolved);
111
- add(graph.edges.filter(({ to }) => unresolved.includes(to)).flatMap(({ from, to }) => [from, to]));
112
+ const unresolvedIds = new Set(unresolved);
113
+ add(graph.edges.filter(({ to }) => unresolvedIds.has(to)).flatMap(({ from, to }) => [from, to]));
112
114
  }
113
115
  else if (options.view === 'neighborhood' || options.view === 'reachability') {
114
116
  add(selectorTrace);