hoi4-agent-tools 4.0.5 → 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 (104) hide show
  1. package/CHANGELOG.md +32 -0
  2. package/README.md +2 -0
  3. package/dist/hoi4_agent_tools/core/bounded-source.js +210 -0
  4. package/dist/hoi4_agent_tools/core/canonical.js +42 -0
  5. package/dist/hoi4_agent_tools/core/condition-evaluator.js +12 -8
  6. package/dist/hoi4_agent_tools/core/dependency-walk.js +21 -2
  7. package/dist/hoi4_agent_tools/core/diagnostic-rollup.js +91 -0
  8. package/dist/hoi4_agent_tools/core/domain-validation.js +12 -0
  9. package/dist/hoi4_agent_tools/core/engine.js +2 -1
  10. package/dist/hoi4_agent_tools/core/impact-graph.js +6 -2
  11. package/dist/hoi4_agent_tools/core/impact-semantic.js +12 -2
  12. package/dist/hoi4_agent_tools/core/index.js +1 -0
  13. package/dist/hoi4_agent_tools/core/job-executor.js +9 -0
  14. package/dist/hoi4_agent_tools/core/job-worker-host.js +12 -2
  15. package/dist/hoi4_agent_tools/core/managed-outputs.js +72 -0
  16. package/dist/hoi4_agent_tools/core/operation-result.js +28 -2
  17. package/dist/hoi4_agent_tools/core/scanner.js +17 -0
  18. package/dist/hoi4_agent_tools/core/server-state.js +9 -1
  19. package/dist/hoi4_agent_tools/core/temporary-directories.js +5 -1
  20. package/dist/hoi4_agent_tools/core/transaction-canonical.js +43 -0
  21. package/dist/hoi4_agent_tools/core/transaction-limits.js +7 -2
  22. package/dist/hoi4_agent_tools/core/transactions.js +355 -111
  23. package/dist/hoi4_agent_tools/decision/service.js +22 -1
  24. package/dist/hoi4_agent_tools/event/algorithms.js +3 -2
  25. package/dist/hoi4_agent_tools/event/graph.js +52 -35
  26. package/dist/hoi4_agent_tools/event/limits.js +4 -0
  27. package/dist/hoi4_agent_tools/event/operations.js +11 -0
  28. package/dist/hoi4_agent_tools/event/queries.js +13 -34
  29. package/dist/hoi4_agent_tools/event/render.js +4 -2
  30. package/dist/hoi4_agent_tools/event/service.js +93 -33
  31. package/dist/hoi4_agent_tools/event/source-analysis.js +94 -4
  32. package/dist/hoi4_agent_tools/event/state-keys.js +63 -0
  33. package/dist/hoi4_agent_tools/event/summary.js +130 -0
  34. package/dist/hoi4_agent_tools/impact/service.js +2 -1
  35. package/dist/hoi4_agent_tools/map/derived.js +21 -6
  36. package/dist/hoi4_agent_tools/map/job-operations.js +9 -3
  37. package/dist/hoi4_agent_tools/map/operations.js +72 -21
  38. package/dist/hoi4_agent_tools/map/reference-labels.js +238 -0
  39. package/dist/hoi4_agent_tools/map/reference-preparation.js +247 -0
  40. package/dist/hoi4_agent_tools/map/reference-world.js +1097 -0
  41. package/dist/hoi4_agent_tools/map/render.js +9 -1
  42. package/dist/hoi4_agent_tools/map/service.js +222 -18
  43. package/dist/hoi4_agent_tools/map/tool-operations.js +71 -2
  44. package/dist/hoi4_agent_tools/map/world-context.js +148 -0
  45. package/dist/hoi4_agent_tools/map/world-drainage.js +273 -0
  46. package/dist/hoi4_agent_tools/map/world-outputs.js +55 -0
  47. package/dist/hoi4_agent_tools/map/world-preview-style.js +157 -0
  48. package/dist/hoi4_agent_tools/map/world-preview.js +230 -0
  49. package/dist/hoi4_agent_tools/map/world-provinces.js +524 -0
  50. package/dist/hoi4_agent_tools/map/world.js +374 -253
  51. package/dist/hoi4_agent_tools/mcp/tools/analysis.js +1 -0
  52. package/dist/hoi4_agent_tools/mcp/tools/event.js +66 -0
  53. package/dist/hoi4_agent_tools/mcp/tools/map.js +15 -2
  54. package/dist/hoi4_agent_tools/reference/source-lookup.js +4 -2
  55. package/dist/hoi4_agent_tools/schemas/map-requests.js +4 -0
  56. package/dist/hoi4_agent_tools/schemas/map.js +103 -10
  57. package/dist/hoi4_agent_tools/schemas/transaction.js +20 -3
  58. package/dist/hoi4_agent_tools/version.js +2 -2
  59. package/docs/analysis.md +4 -0
  60. package/docs/events.md +12 -0
  61. package/docs/images/reference-maps/political.png +0 -0
  62. package/docs/images/reference-maps/sketch.png +0 -0
  63. package/docs/map.md +45 -0
  64. package/docs/third-party-notices.md +6 -0
  65. package/npm-shrinkwrap.json +124 -2
  66. package/package.json +4 -1
  67. package/schemas/configuration.schema.json +1 -1
  68. package/schemas/continuous-focus-palette.schema.json +1 -1
  69. package/schemas/custom-weighted-pool.schema.json +1 -1
  70. package/schemas/decision-inspect-input.schema.json +1 -1
  71. package/schemas/error-log-input.schema.json +1 -1
  72. package/schemas/event-feature-manifest.schema.json +1 -1
  73. package/schemas/focus-plan.schema.json +1 -1
  74. package/schemas/focus-planning-sidecar.schema.json +1 -1
  75. package/schemas/gui-animation-source.schema.json +1 -1
  76. package/schemas/gui-helper.schema.json +1 -1
  77. package/schemas/gui-scenario.schema.json +1 -1
  78. package/schemas/helper-expansion-request.schema.json +1 -1
  79. package/schemas/helper-expansion-summary.schema.json +1 -1
  80. package/schemas/impact-inspect-input.schema.json +1 -1
  81. package/schemas/map-operation.schema.json +373 -70
  82. package/schemas/mechanic-test-input.schema.json +1 -1
  83. package/schemas/mod-index-input.schema.json +1 -1
  84. package/schemas/operation-result.schema.json +1 -1
  85. package/schemas/package-check-input.schema.json +1 -1
  86. package/schemas/package-manifest.schema.json +1 -1
  87. package/schemas/probability-analysis-result.schema.json +1 -1
  88. package/schemas/probability-compare-input.schema.json +1 -1
  89. package/schemas/probability-evaluate-input.schema.json +1 -1
  90. package/schemas/probability-inspect-input.schema.json +1 -1
  91. package/schemas/probability-render-input.schema.json +1 -1
  92. package/schemas/probability-scenario-set.schema.json +1 -1
  93. package/schemas/probability-sequence-input.schema.json +1 -1
  94. package/schemas/probability-simulate-input.schema.json +1 -1
  95. package/schemas/probability-sweep-input.schema.json +1 -1
  96. package/schemas/reference-context-input.schema.json +1 -1
  97. package/schemas/reference-read-input.schema.json +1 -1
  98. package/schemas/reference-search-input.schema.json +1 -1
  99. package/schemas/scenario-suite.schema.json +1 -1
  100. package/schemas/scenario-test-input.schema.json +1 -1
  101. package/schemas/script-validate-input.schema.json +1 -1
  102. package/schemas/shared-scenario.schema.json +1 -1
  103. package/schemas/source-lookup-input.schema.json +1 -1
  104. package/server.json +2 -2
@@ -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);
@@ -2,7 +2,7 @@ import path from 'node:path';
2
2
  import { inspectHelperExpansion } from '../core/helper-expansion.js';
3
3
  import { eventHelperInventory } from './helper-expansion.js';
4
4
  import { clearSemanticDependencies } from '../core/semantic-dependencies.js';
5
- import { canonicalJson, compareCodeUnits, hashCanonical, sha256Bytes } from '../core/canonical.js';
5
+ import { canonicalJson, canonicalJsonByteLength, compareCodeUnits, hashCanonical, sha256Bytes, } from '../core/canonical.js';
6
6
  import { boundedSourceHashEvidence, publicArtifactLink, } from '../core/artifacts.js';
7
7
  import { ServiceError } from '../core/result.js';
8
8
  import { RenderBudget } from '../core/render-budget.js';
@@ -10,7 +10,8 @@ import { SOURCE_MAX_BYTES } from '../core/source/index.js';
10
10
  import { isPortablePathSegment } from '../core/workspace.js';
11
11
  import { PACKAGE_VERSION } from '../version.js';
12
12
  import { buildEventGraph, buildEventGraphAsync } from './graph.js';
13
- import { EVENT_FRAGMENT_CACHE_MAX_ENTRIES, EVENT_FRAGMENT_CACHE_MAX_SOURCE_BYTES, EVENT_PROPOSED_SOURCE_MAX_BYTES, EVENT_PROPOSED_SOURCE_MAX_FILES, } from './limits.js';
13
+ import { summarizeEventImpact, summarizeEventIssues, summarizeEventPath, summarizeEventRoots, summarizeEventTrace, } from './summary.js';
14
+ import { EVENT_FRAGMENT_CACHE_MAX_ENTRIES, EVENT_FRAGMENT_CACHE_MAX_SOURCE_BYTES, EVENT_GRAPH_SNAPSHOT_MAX_BYTES, EVENT_PROPOSED_SOURCE_MAX_BYTES, EVENT_PROPOSED_SOURCE_MAX_FILES, } from './limits.js';
14
15
  import { analyzeEventImpact, discoverEventRoots, explainEventPath, inspectEventStateFlow, lintEventGraph, traceSelectedEvents, } from './queries.js';
15
16
  import { compareEventGraphs, eventGraphHash } from './compare.js';
16
17
  import { selectEventComparison } from './comparison-selection.js';
@@ -210,9 +211,23 @@ async function scanEventSources(engine, workspaceId, workspace, focused = false,
210
211
  complete: index.complete,
211
212
  skippedSourceCount: index.skippedSourceCount,
212
213
  skippedSources: index.skippedSources,
213
- diagnostics: index.diagnostics,
214
+ diagnostics: diagnosticsWithinScannedKinds(index),
214
215
  };
215
216
  }
217
+ /**
218
+ * Event analysis indexes only event-relevant folders, so it holds no sprite, interface or
219
+ * scripted-trigger definitions. A reference to a kind with no definition in this file set says
220
+ * nothing about the workspace and is left to the full index, which scans those folders.
221
+ */
222
+ function diagnosticsWithinScannedKinds(index) {
223
+ const indexedKinds = new Set(index.symbols.map(({ kind }) => kind));
224
+ return index.diagnostics.filter(({ code, details }) => {
225
+ if (code !== 'INDEX_UNRESOLVED_REFERENCE' && code !== 'INDEX_UNRESOLVED_REFERENCE_PARTIAL')
226
+ return true;
227
+ const toKind = details?.toKind;
228
+ return typeof toKind !== 'string' || indexedKinds.has(toKind);
229
+ });
230
+ }
216
231
  function rememberGraph(state, workspaceId, graph) {
217
232
  const history = state.history.get(workspaceId) ?? new Map();
218
233
  history.delete(graph.revision);
@@ -808,17 +823,21 @@ export class EventChainViewer {
808
823
  refresh: input.refresh ?? input.mode === 'scan',
809
824
  };
810
825
  let report;
826
+ let summary;
811
827
  switch (input.mode) {
812
828
  case 'scan':
813
829
  report = eventScanReport(graph);
830
+ summary = summarizeEventIssues(graph.issues);
814
831
  break;
815
832
  case 'roots':
816
833
  {
817
834
  const unresolved = boundedUnresolved(graph);
835
+ const roots = discoverEventRoots(graph, input.signal);
836
+ summary = summarizeEventRoots(roots);
818
837
  report = {
819
838
  graphRevision: graph.revision,
820
839
  sourceHashes: graph.sourceHashes,
821
- roots: discoverEventRoots(graph, input.signal),
840
+ roots,
822
841
  unresolved: unresolved.values,
823
842
  ...(unresolved.inventory === undefined
824
843
  ? {}
@@ -838,6 +857,10 @@ export class EventChainViewer {
838
857
  expandHelpers: input.expandHelpers ?? false,
839
858
  }, input.signal);
840
859
  const unresolved = boundedUnresolved(graph, new Set(trace.nodes.map(({ id }) => id)));
860
+ summary = {
861
+ trace: summarizeEventTrace(trace),
862
+ ...summarizeEventIssues(lintEventGraph(graph, input.selector, input.signal)),
863
+ };
841
864
  report = {
842
865
  graphRevision: graph.revision,
843
866
  sourceHashes: graph.sourceHashes,
@@ -852,16 +875,22 @@ export class EventChainViewer {
852
875
  case 'explain_path':
853
876
  if (input.from === undefined || input.to === undefined)
854
877
  throw new ServiceError('EVENT_PATH_SELECTORS_REQUIRED', 'Path explanation requires from and to selectors');
855
- report = {
856
- graphRevision: graph.revision,
857
- sourceHashes: graph.sourceHashes,
858
- explanation: explainEventPath(graph, input.from, input.to, { maxDepth, maxNodes, expandHelpers: input.expandHelpers ?? true }, input.signal),
859
- };
878
+ {
879
+ const explanation = explainEventPath(graph, input.from, input.to, { maxDepth, maxNodes, expandHelpers: input.expandHelpers ?? true }, input.signal);
880
+ summary = summarizeEventPath(explanation);
881
+ report = {
882
+ graphRevision: graph.revision,
883
+ sourceHashes: graph.sourceHashes,
884
+ explanation,
885
+ };
886
+ }
860
887
  break;
861
888
  case 'state_flow':
862
889
  {
863
890
  const unresolved = boundedUnresolved(graph);
864
- const issues = boundedEvidence(lintEventGraph(graph, input.selector, input.signal).filter(({ code }) => /(?:STATE|FLAG|VARIABLE|ARRAY|TARGET|SCOPE)/u.test(code)));
891
+ const stateIssues = lintEventGraph(graph, input.selector, input.signal).filter(({ code }) => /(?:STATE|FLAG|VARIABLE|ARRAY|TARGET|SCOPE|WRITER)/u.test(code));
892
+ summary = summarizeEventIssues(stateIssues);
893
+ const issues = boundedEvidence(stateIssues);
865
894
  report = {
866
895
  graphRevision: graph.revision,
867
896
  sourceHashes: graph.sourceHashes,
@@ -877,7 +906,9 @@ export class EventChainViewer {
877
906
  break;
878
907
  case 'lint':
879
908
  {
880
- const issues = boundedEvidence(lintEventGraph(graph, input.selector, input.signal));
909
+ const selectedIssues = lintEventGraph(graph, input.selector, input.signal);
910
+ summary = summarizeEventIssues(selectedIssues);
911
+ const issues = boundedEvidence(selectedIssues);
881
912
  const unresolved = boundedUnresolved(graph);
882
913
  report = {
883
914
  graphRevision: graph.revision,
@@ -897,10 +928,12 @@ export class EventChainViewer {
897
928
  throw new ServiceError('EVENT_IMPACT_SUBJECT_REQUIRED', 'Impact analysis requires a subject');
898
929
  {
899
930
  const unresolved = boundedUnresolved(graph);
931
+ const impact = analyzeEventImpact(graph, input.impactSubject, input.signal);
932
+ summary = summarizeEventImpact(impact);
900
933
  report = {
901
934
  graphRevision: graph.revision,
902
935
  sourceHashes: graph.sourceHashes,
903
- impact: analyzeEventImpact(graph, input.impactSubject, input.signal),
936
+ impact,
904
937
  unresolved: unresolved.values,
905
938
  ...(unresolved.inventory === undefined
906
939
  ? {}
@@ -912,6 +945,13 @@ export class EventChainViewer {
912
945
  const graphHash = cachedEventGraphHash(graph, input.signal);
913
946
  const name = `${safeSlug(`event-${input.mode}`)}-${graph.revision.slice(0, 12)}.json`;
914
947
  const graphSnapshotName = `event-graph-${graph.revision}.json`;
948
+ // A large workspace graph cannot be held as one JSON string or artifact. Measure it first and
949
+ // retain it only when it fits; comparisons then use the in-memory revision history.
950
+ const graphSnapshot = input.mode === 'scan' ? { schemaVersion: 'event-graph-snapshot.v1', graph } : undefined;
951
+ const graphSnapshotBytes = graphSnapshot === undefined
952
+ ? undefined
953
+ : canonicalJsonByteLength(graphSnapshot, EVENT_GRAPH_SNAPSHOT_MAX_BYTES);
954
+ const snapshotOmitted = graphSnapshot !== undefined && graphSnapshotBytes === undefined;
915
955
  const projectedScan = input.mode === 'scan' &&
916
956
  typeof report === 'object' &&
917
957
  report !== null &&
@@ -949,10 +989,13 @@ export class EventChainViewer {
949
989
  : {}),
950
990
  resources: [
951
991
  { name, mimeType: 'application/json' },
952
- ...(input.mode === 'scan'
992
+ ...(input.mode === 'scan' && !snapshotOmitted
953
993
  ? [{ name: graphSnapshotName, mimeType: 'application/json' }]
954
994
  : []),
955
995
  ],
996
+ ...(snapshotOmitted
997
+ ? { graphSnapshotOmitted: { maximumBytes: EVENT_GRAPH_SNAPSHOT_MAX_BYTES } }
998
+ : {}),
956
999
  report,
957
1000
  })}\n`;
958
1001
  const workspace = this.engine.resolver.get(input.workspaceId, input.principal);
@@ -960,8 +1003,8 @@ export class EventChainViewer {
960
1003
  mode: input.mode,
961
1004
  ...(input.selector === undefined ? {} : { selector: input.selector }),
962
1005
  }, input.signal), `Authoritative Event Chain Viewer ${input.mode.replaceAll('_', ' ')} report`, input.signal);
963
- const graphArtifact = input.mode === 'scan'
964
- ? await this.engine.artifacts.putChunked(workspace, graphSnapshotName, 'application/json', `${canonicalJson({ schemaVersion: 'event-graph-snapshot.v1', graph })}\n`, eventProvenance(graph, 'event-graph-snapshot', { revision: graph.revision, graphHash }, input.signal), 'Revision-addressed Event Chain Viewer graph snapshot', input.signal)
1006
+ const graphArtifact = graphSnapshot !== undefined && !snapshotOmitted
1007
+ ? await this.engine.artifacts.putChunked(workspace, graphSnapshotName, 'application/json', `${canonicalJson(graphSnapshot)}\n`, eventProvenance(graph, 'event-graph-snapshot', { revision: graph.revision, graphHash }, input.signal), 'Revision-addressed Event Chain Viewer graph snapshot', input.signal)
965
1008
  : undefined;
966
1009
  return {
967
1010
  graph,
@@ -970,6 +1013,8 @@ export class EventChainViewer {
970
1013
  report,
971
1014
  reportJson,
972
1015
  artifacts: [artifact, ...(graphArtifact === undefined ? [] : [graphArtifact])],
1016
+ ...(snapshotOmitted ? { graphSnapshotOmitted: true } : {}),
1017
+ summary,
973
1018
  };
974
1019
  }
975
1020
  async renderAndStore(input) {
@@ -1001,25 +1046,38 @@ export class EventChainViewer {
1001
1046
  graph.nodes.length > render.selectedNodeIds.length
1002
1047
  ? coherentBranchPartitions(graph, branchNodeLimit, 64, input.signal)
1003
1048
  : { groups: [], omittedNodeIds: [] };
1004
- const branchGroups = partitions.groups;
1005
- for (const [branchIndex, nodeIds] of branchGroups.entries()) {
1049
+ // Branch images share the request's raster budget with the overview. When it is spent, the
1050
+ // remaining branches are listed as omitted instead of failing the whole render.
1051
+ let budgetOmittedBranches = 0;
1052
+ for (const [branchIndex, nodeIds] of partitions.groups.entries()) {
1006
1053
  input.signal?.throwIfAborted();
1007
- branches.push(await renderEventGraph(graph, {
1008
- view: 'neighborhood',
1009
- selector: {
1010
- kind: 'manifest',
1011
- manifest: { id: `overview-branch-${branchIndex + 1}`, nodeIds },
1012
- },
1013
- direction: 'both',
1014
- maxDepth: 0,
1015
- maxNodes: branchNodeLimit,
1016
- expandHelpers: input.expandHelpers ?? false,
1017
- includeHtml: false,
1018
- compactLayout: true,
1019
- budget,
1020
- ...(input.signal === undefined ? {} : { signal: input.signal }),
1021
- }));
1054
+ let branch;
1055
+ try {
1056
+ branch = await renderEventGraph(graph, {
1057
+ view: 'neighborhood',
1058
+ selector: {
1059
+ kind: 'manifest',
1060
+ manifest: { id: `overview-branch-${branchIndex + 1}`, nodeIds },
1061
+ },
1062
+ direction: 'both',
1063
+ maxDepth: 0,
1064
+ maxNodes: branchNodeLimit,
1065
+ expandHelpers: input.expandHelpers ?? false,
1066
+ includeHtml: false,
1067
+ compactLayout: true,
1068
+ budget,
1069
+ ...(input.signal === undefined ? {} : { signal: input.signal }),
1070
+ });
1071
+ }
1072
+ catch (error) {
1073
+ if (!(error instanceof ServiceError) || error.code !== 'RENDER_AGGREGATE_BLOCKED')
1074
+ throw error;
1075
+ budgetOmittedBranches = partitions.groups.length - branchIndex;
1076
+ break;
1077
+ }
1078
+ branches.push(branch);
1022
1079
  }
1080
+ const branchGroups = partitions.groups.slice(0, branches.length);
1023
1081
  const prefix = safeSlug(`event-${input.view}-${graph.revision.slice(0, 12)}`);
1024
1082
  const manifestName = `${prefix}-manifest.json`;
1025
1083
  const resourceNames = {
@@ -1165,7 +1223,8 @@ export class EventChainViewer {
1165
1223
  omittedNodeCount: partitions.omittedNodeIds.length,
1166
1224
  omittedNodeIds: partitions.omittedNodeIds,
1167
1225
  omittedNodeHash: hashCanonical(partitions.omittedNodeIds),
1168
- truncated: partitions.omittedNodeIds.length > 0,
1226
+ budgetOmittedBranches,
1227
+ truncated: partitions.omittedNodeIds.length > 0 || budgetOmittedBranches > 0,
1169
1228
  },
1170
1229
  })}\n`;
1171
1230
  const manifestArtifact = await this.engine.artifacts.putChunked(workspace, manifestName, 'application/json', manifestJson, provenance, `Event-chain ${input.view} render and bounded branch manifest`, input.signal);
@@ -1175,6 +1234,7 @@ export class EventChainViewer {
1175
1234
  graphHash: cachedEventGraphHash(graph, input.signal),
1176
1235
  render,
1177
1236
  branches,
1237
+ budgetOmittedBranches,
1178
1238
  manifestJson,
1179
1239
  artifacts,
1180
1240
  };