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
@@ -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
  };
@@ -86,6 +86,21 @@ const STATE_RULES = {
86
86
  multiply_variable: { kind: 'variable', access: 'read_write' },
87
87
  divide_variable: { kind: 'variable', access: 'read_write' },
88
88
  clamp_variable: { kind: 'variable', access: 'read_write' },
89
+ round_variable: { kind: 'variable', access: 'read_write' },
90
+ modulo_variable: { kind: 'variable', access: 'read_write' },
91
+ randomize_variable: { kind: 'variable', access: 'read_write' },
92
+ set_variable_to_random: { kind: 'variable', access: 'write' },
93
+ // `check_variable` and `has_variable` read a temporary variable of the same name as well.
94
+ set_temp_variable: { kind: 'variable', access: 'write', temporary: true },
95
+ set_temp_variable_to_random: { kind: 'variable', access: 'write', temporary: true },
96
+ add_to_temp_variable: { kind: 'variable', access: 'read_write', temporary: true },
97
+ subtract_from_temp_variable: { kind: 'variable', access: 'read_write', temporary: true },
98
+ multiply_temp_variable: { kind: 'variable', access: 'read_write', temporary: true },
99
+ divide_temp_variable: { kind: 'variable', access: 'read_write', temporary: true },
100
+ clamp_temp_variable: { kind: 'variable', access: 'read_write', temporary: true },
101
+ round_temp_variable: { kind: 'variable', access: 'read_write', temporary: true },
102
+ modulo_temp_variable: { kind: 'variable', access: 'read_write', temporary: true },
103
+ randomize_temp_variable: { kind: 'variable', access: 'read_write', temporary: true },
89
104
  check_variable: { kind: 'variable', access: 'read' },
90
105
  has_variable: { kind: 'variable', access: 'read' },
91
106
  is_variable_equal: { kind: 'variable', access: 'read' },
@@ -101,6 +116,16 @@ const STATE_RULES = {
101
116
  remove_from_array: { kind: 'array', access: 'read_write', nameField: 'array' },
102
117
  is_in_array: { kind: 'array', access: 'read', nameField: 'array' },
103
118
  clear_array: { kind: 'array', access: 'clear' },
119
+ resize_array: { kind: 'array', access: 'write', nameField: 'array' },
120
+ add_to_temp_array: { kind: 'array', access: 'write', nameField: 'array', temporary: true },
121
+ remove_from_temp_array: {
122
+ kind: 'array',
123
+ access: 'read_write',
124
+ nameField: 'array',
125
+ temporary: true,
126
+ },
127
+ resize_temp_array: { kind: 'array', access: 'write', nameField: 'array', temporary: true },
128
+ clear_temp_array: { kind: 'array', access: 'clear', temporary: true },
104
129
  save_event_target_as: { kind: 'event_target', access: 'replace' },
105
130
  has_event_target: { kind: 'event_target', access: 'read' },
106
131
  save_global_event_target_as: { kind: 'global_event_target', access: 'replace' },
@@ -153,7 +178,59 @@ const NON_EXECUTING_BLOCKS = new Set([
153
178
  'mean_time_to_happen',
154
179
  'effect_tooltip',
155
180
  'custom_effect_tooltip',
181
+ // Condition blocks of decisions, missions, ideas, focuses, raids and characters.
182
+ 'visible',
183
+ 'available',
184
+ 'allowed',
185
+ 'allowed_civil_war',
186
+ 'allowed_to_remove',
187
+ 'cancel_trigger',
188
+ 'remove_trigger',
189
+ 'target_trigger',
190
+ 'target_root_trigger',
191
+ 'activation',
192
+ 'ai_will_do',
193
+ 'bypass',
194
+ 'allow_branch',
195
+ 'hidden_trigger',
196
+ 'custom_trigger_tooltip',
197
+ 'and',
198
+ 'or',
199
+ 'not',
200
+ 'nor',
201
+ 'nand',
156
202
  ]);
203
+ /** Native effects whose body holds further effects rather than named arguments. */
204
+ const NATIVE_EFFECT_CONTAINERS = new Set([
205
+ 'create_dynamic_country',
206
+ 'custom_override_tooltip',
207
+ 'effect_tooltip',
208
+ 'for_each_loop',
209
+ 'for_each_scope_loop',
210
+ 'for_loop_effect',
211
+ 'hidden_effect',
212
+ 'if',
213
+ 'random',
214
+ 'random_list',
215
+ 'random_scope_in_array',
216
+ 'while_loop_effect',
217
+ ]);
218
+ /** Inside a native effect such as `add_building_construction`, `key = yes` is an argument. */
219
+ function isNativeArgumentBlock(normalizedKey) {
220
+ return (nativeEffectKeys.has(normalizedKey) &&
221
+ !NATIVE_EFFECT_CONTAINERS.has(normalizedKey) &&
222
+ !CONTROL_BLOCKS.has(normalizedKey) &&
223
+ SCOPE_BLOCKS[normalizedKey] === undefined &&
224
+ !/^(?:every|random|global_every)_/u.test(normalizedKey));
225
+ }
226
+ /** Effect blocks of definition files by naming convention: `on_add`, `complete_effect`, `effects`. */
227
+ function isConventionalEffectBlock(normalizedKey) {
228
+ return /^on_[a-z0-9_]+$|(?:^|_)effects?$/u.test(normalizedKey);
229
+ }
230
+ /** Trigger iterators such as `any_owned_state` hold conditions, never effects. */
231
+ function isTriggerIterator(normalizedKey) {
232
+ return /^(?:any|all)_/u.test(normalizedKey);
233
+ }
157
234
  const TOOLTIP_ONLY_BLOCKS = new Set(['effect_tooltip', 'custom_effect_tooltip']);
158
235
  const COUNTRY_ON_ACTIONS = new Set([
159
236
  'on_startup',
@@ -836,6 +913,7 @@ class FragmentAnalyzer {
836
913
  helperStack: [],
837
914
  semanticPath: ['other'],
838
915
  effectContext: true,
916
+ inferMissingHelpers: false,
839
917
  });
840
918
  if (this.edges.length + this.stateAccesses.length > before) {
841
919
  this.addNode({
@@ -913,6 +991,8 @@ class FragmentAnalyzer {
913
991
  return;
914
992
  }
915
993
  if (this.context.inventoryComplete !== false &&
994
+ context.nativeArguments !== true &&
995
+ context.inferMissingHelpers !== false &&
916
996
  missingHelperCandidate(key, entry.value, effectContext)) {
917
997
  const target = this.unresolvedNode('missing_helper', key, entry, context.ownerId, semanticPath);
918
998
  this.addEdge(context.ownerId, target, 'scripted_effect_call', entry, context.conditions, {
@@ -1039,7 +1119,13 @@ class FragmentAnalyzer {
1039
1119
  conditions,
1040
1120
  sourceScope: scope,
1041
1121
  phase,
1042
- effectContext: NON_EXECUTING_BLOCKS.has(normalizedKey) ? false : effectContext,
1122
+ effectContext: NON_EXECUTING_BLOCKS.has(normalizedKey) || isTriggerIterator(normalizedKey)
1123
+ ? false
1124
+ : effectContext,
1125
+ nativeArguments: context.nativeArguments === true || isNativeArgumentBlock(normalizedKey),
1126
+ ...(context.inferMissingHelpers === false && isConventionalEffectBlock(normalizedKey)
1127
+ ? { inferMissingHelpers: true }
1128
+ : {}),
1043
1129
  suppressSemantics: context.suppressSemantics === true || TOOLTIP_ONLY_BLOCKS.has(normalizedKey),
1044
1130
  semanticPath,
1045
1131
  ...(scopeExpression === undefined ? {} : { scopeExpression }),
@@ -1296,7 +1382,8 @@ class FragmentAnalyzer {
1296
1382
  return id;
1297
1383
  }
1298
1384
  recordState(assignment, context, semanticPath) {
1299
- const keyedTarget = /^(event_target|global_event_target|scope):([A-Za-z0-9_.-]+)$/u.exec(assignment.key.value);
1385
+ // `event_target:founder.districts` names the target `founder`; the rest is a value in its scope.
1386
+ const keyedTarget = /^(event_target|global_event_target|scope):([A-Za-z0-9_-]+)(?:\.[A-Za-z0-9_.-]+)?$/u.exec(assignment.key.value);
1300
1387
  if (keyedTarget !== null) {
1301
1388
  this.addState(context, keyedTarget[1] === 'global_event_target'
1302
1389
  ? 'global_event_target'
@@ -1307,7 +1394,7 @@ class FragmentAnalyzer {
1307
1394
  const rule = STATE_RULES[assignment.key.value];
1308
1395
  if (rule === undefined) {
1309
1396
  const raw = valueText(this.document, assignment.value);
1310
- for (const match of raw.matchAll(/\b(event_target|global_event_target):([A-Za-z0-9_.-]+)/gu)) {
1397
+ for (const match of raw.matchAll(/\b(event_target|global_event_target):([A-Za-z0-9_-]+)/gu)) {
1311
1398
  this.addState(context, match[1] === 'global_event_target' ? 'global_event_target' : 'event_target', match[2], 'read', assignment, semanticPath, false);
1312
1399
  }
1313
1400
  return;
@@ -1336,7 +1423,10 @@ class FragmentAnalyzer {
1336
1423
  else if (access === 'write') {
1337
1424
  this.#seenStateWriters.add(writerKey);
1338
1425
  }
1339
- this.addState(context, kind, name, access, assignment, semanticPath, isDynamic(name), rule.storage === undefined ? {} : { storage: rule.storage });
1426
+ this.addState(context, kind, name, access, assignment, semanticPath, isDynamic(name), {
1427
+ ...(rule.storage === undefined ? {} : { storage: rule.storage }),
1428
+ ...(rule.temporary === true ? { temporary: true } : {}),
1429
+ });
1340
1430
  }
1341
1431
  addState(context, kind, name, access, node, _semanticPath, dynamic, metadata = {}) {
1342
1432
  const semantic = {
@@ -0,0 +1,63 @@
1
+ const SCOPE_KINDS = [
2
+ 'country',
3
+ 'state',
4
+ 'province',
5
+ 'unit_leader',
6
+ 'operative',
7
+ 'character',
8
+ 'global',
9
+ 'unknown',
10
+ ];
11
+ /** Identity of one stored value: variables and arrays live per scope, saved scopes per storage. */
12
+ export function stateKey(value) {
13
+ const scope = value.kind === 'variable' || value.kind === 'array' ? `:${value.scope}` : '';
14
+ const storage = value.kind === 'saved_scope'
15
+ ? `:${value.metadata.storage === 'global' || value.metadata.storage === 'local'
16
+ ? value.metadata.storage
17
+ : 'unknown'}`
18
+ : '';
19
+ return `${value.kind}:${value.name}${scope}${storage}`;
20
+ }
21
+ /** Keys of every write that a read of this value can observe. */
22
+ export function compatibleProducerKeys(value) {
23
+ // `event_target:name` reads local and global targets alike, whereas
24
+ // `global_event_target:name` reads only global ones.
25
+ if (value.kind === 'event_target') {
26
+ return [stateKey(value), `global_event_target:${value.name}`];
27
+ }
28
+ // A helper body runs in whatever scope calls it, so its scope is unknown. A write there can
29
+ // reach a read in any scope, and a read there can observe a write in any scope.
30
+ if (value.kind === 'variable' || value.kind === 'array') {
31
+ const scopes = value.scope === 'unknown' ? SCOPE_KINDS : [value.scope, 'unknown'];
32
+ const keys = scopes.map((scope) => `${value.kind}:${value.name}:${scope}`);
33
+ // `entries^num` and `entries^3` read the array `entries`; `GER.value` and `ROOT.value` read
34
+ // `value` in another scope, which may be written under its bare name in any scope.
35
+ const element = /^([^^]+)\^/u.exec(value.name)?.[1];
36
+ if (element !== undefined)
37
+ keys.push(...SCOPE_KINDS.map((scope) => `array:${unscopedName(element)}:${scope}`));
38
+ const bare = unscopedName(value.name);
39
+ if (bare !== value.name)
40
+ keys.push(...SCOPE_KINDS.map((scope) => `${value.kind}:${bare}:${scope}`));
41
+ return [...new Set(keys)];
42
+ }
43
+ if (value.kind !== 'saved_scope' ||
44
+ value.metadata.storage === 'global' ||
45
+ value.metadata.storage === 'local') {
46
+ return [stateKey(value)];
47
+ }
48
+ return [
49
+ `saved_scope:${value.name}:global`,
50
+ `saved_scope:${value.name}:local`,
51
+ `saved_scope:${value.name}:unknown`,
52
+ ];
53
+ }
54
+ /** `GER.value`, `ROOT.FROM.value` or `var:target.value` without its scope prefix. */
55
+ function unscopedName(name) {
56
+ const scoped = /^(?:(?:[A-Z][A-Z0-9]{2}|ROOT|FROM|PREV|THIS|OWNER|CONTROLLER|CAPITAL)\.|(?:var|event_target):[A-Za-z0-9_]+\.)+(.+)$/u.exec(name);
57
+ return scoped?.[1] ?? name;
58
+ }
59
+ export function hasUnknownSavedScopeStorage(value) {
60
+ return (value.kind === 'saved_scope' &&
61
+ value.metadata.storage !== 'global' &&
62
+ value.metadata.storage !== 'local');
63
+ }
@@ -0,0 +1,130 @@
1
+ import { compareCodeUnits } from '../core/canonical.js';
2
+ /** Findings returned inline, before an agent opens the linked report. */
3
+ export const EVENT_INLINE_FINDINGS = 10;
4
+ const EVENT_INLINE_CODES = 10;
5
+ const EVENT_INLINE_EVENT_IDS = 20;
6
+ const EVENT_INLINE_MESSAGE_LENGTH = 300;
7
+ const CLASSIFICATION_ORDER = {
8
+ confirmed_error: 0,
9
+ probable_defect: 1,
10
+ design_warning: 2,
11
+ unresolved_analysis: 3,
12
+ };
13
+ const CONFIDENCE_ORDER = {
14
+ confirmed: 0,
15
+ high: 1,
16
+ medium: 2,
17
+ low: 3,
18
+ unresolved: 4,
19
+ };
20
+ function countBy(values, key) {
21
+ const counts = new Map();
22
+ for (const value of values)
23
+ counts.set(key(value), (counts.get(key(value)) ?? 0) + 1);
24
+ return Object.fromEntries([...counts].sort(([left], [right]) => compareCodeUnits(left, right)));
25
+ }
26
+ function layer(issue) {
27
+ const path = issue.location?.path;
28
+ return path === undefined ? 'unknown' : (/^([a-z]+):/u.exec(path)?.[1] ?? 'unknown');
29
+ }
30
+ /** The mod's own files first, then most severe: classification, confidence, source order. */
31
+ function issueOrder(left, right) {
32
+ const leftMod = layer(left) === 'mod' ? 0 : 1;
33
+ const rightMod = layer(right) === 'mod' ? 0 : 1;
34
+ return (leftMod - rightMod ||
35
+ CLASSIFICATION_ORDER[left.classification] - CLASSIFICATION_ORDER[right.classification] ||
36
+ CONFIDENCE_ORDER[left.confidence] - CONFIDENCE_ORDER[right.confidence] ||
37
+ compareCodeUnits(left.location?.path ?? '', right.location?.path ?? '') ||
38
+ (left.location?.start.line ?? 0) - (right.location?.start.line ?? 0) ||
39
+ compareCodeUnits(left.code, right.code) ||
40
+ compareCodeUnits(left.message, right.message));
41
+ }
42
+ /** Counts and the most severe findings of a selection, small enough to return inline. */
43
+ export function summarizeEventIssues(issues) {
44
+ const byCode = Object.entries(countBy(issues, ({ code }) => code))
45
+ .sort(([leftCode, left], [rightCode, right]) => right - left || compareCodeUnits(leftCode, rightCode))
46
+ .slice(0, EVENT_INLINE_CODES);
47
+ return {
48
+ issues: issues.length,
49
+ byClassification: countBy(issues, ({ classification }) => classification),
50
+ // `mod` findings are the workspace's own; `game` and `dependency` ones are context.
51
+ byLayer: countBy(issues, layer),
52
+ topCodes: Object.fromEntries(byCode),
53
+ findings: [...issues]
54
+ .sort(issueOrder)
55
+ .slice(0, EVENT_INLINE_FINDINGS)
56
+ .map((issue) => ({
57
+ code: issue.code,
58
+ classification: issue.classification,
59
+ confidence: issue.confidence,
60
+ message: issue.message.length > EVENT_INLINE_MESSAGE_LENGTH
61
+ ? `${issue.message.slice(0, EVENT_INLINE_MESSAGE_LENGTH - 1)}…`
62
+ : issue.message,
63
+ ...(issue.location === undefined
64
+ ? {}
65
+ : { path: issue.location.path, line: issue.location.start.line }),
66
+ ...(typeof issue.details.name === 'string' ? { subject: issue.details.name } : {}),
67
+ })),
68
+ };
69
+ }
70
+ /** Size of a traced chain and the events it reaches. */
71
+ export function summarizeEventTrace(trace) {
72
+ const events = trace.nodes
73
+ .flatMap(({ kind, eventId }) => (kind === 'event' && eventId !== undefined ? [eventId] : []))
74
+ .sort(compareCodeUnits);
75
+ return {
76
+ nodes: trace.nodes.length,
77
+ edges: trace.edges.length,
78
+ events: events.length,
79
+ eventIds: events.slice(0, EVENT_INLINE_EVENT_IDS),
80
+ boundaryNodes: trace.boundaryNodeIds.length,
81
+ truncated: trace.truncated,
82
+ };
83
+ }
84
+ /** How many chains start where, in a form an agent can read without the report. */
85
+ export function summarizeEventRoots(roots) {
86
+ const nodeLayer = ({ sourcePath }) => sourcePath === undefined ? 'unknown' : (/^([a-z]+):/u.exec(sourcePath)?.[1] ?? 'unknown');
87
+ // The mod's own events first: vanilla callerless events are context, not the mod's defects.
88
+ const ids = (nodes) => [...nodes]
89
+ .sort((left, right) => (nodeLayer(left) === 'mod' ? 0 : 1) - (nodeLayer(right) === 'mod' ? 0 : 1) ||
90
+ compareCodeUnits(left.eventId ?? '', right.eventId ?? ''))
91
+ .flatMap(({ eventId }) => (eventId === undefined ? [] : [eventId]))
92
+ .slice(0, EVENT_INLINE_EVENT_IDS);
93
+ return {
94
+ entryPoints: roots.entryPoints.length,
95
+ entryKinds: countBy(roots.entryPoints, ({ metadata }) => typeof metadata.entryKind === 'string' ? metadata.entryKind : 'unknown'),
96
+ automaticEvents: roots.automaticEvents.length,
97
+ callerlessTriggeredEvents: roots.callerlessTriggeredEvents.length,
98
+ callerlessTriggeredByLayer: countBy(roots.callerlessTriggeredEvents, nodeLayer),
99
+ callerlessTriggeredEventIds: ids(roots.callerlessTriggeredEvents),
100
+ cycles: roots.stronglyConnectedComponents.filter(({ cyclic }) => cyclic).length,
101
+ };
102
+ }
103
+ /** Whether a route exists and, if so, the events it passes through in order. */
104
+ export function summarizeEventPath(path) {
105
+ return {
106
+ found: path.found,
107
+ steps: path.steps.length,
108
+ eventIds: path.steps
109
+ .flatMap(({ node }) => node.kind === 'event' && node.eventId !== undefined ? [node.eventId] : [])
110
+ .slice(0, EVENT_INLINE_EVENT_IDS),
111
+ ...(path.failureReason === undefined ? {} : { failureReason: path.failureReason }),
112
+ ...(path.failureMessage === undefined ? {} : { failureMessage: path.failureMessage }),
113
+ unresolvedAssumptions: path.unresolvedAssumptions.length,
114
+ };
115
+ }
116
+ /** How far a change to the subject reaches, by count. */
117
+ export function summarizeEventImpact(impact) {
118
+ return {
119
+ direct: impact.directNodeIds.length,
120
+ upstream: impact.upstreamNodeIds.length,
121
+ downstream: impact.downstreamNodeIds.length,
122
+ stateAccesses: impact.stateAccessIds.length,
123
+ affectedRoots: impact.affectedRootIds.length,
124
+ affectedTerminals: impact.affectedTerminalIds.length,
125
+ wouldDisconnect: impact.wouldDisconnectNodeIds.length,
126
+ wouldDisconnectTerminals: impact.wouldDisconnectTerminalIds.length,
127
+ unresolved: impact.unresolvedNodeIds.length,
128
+ sampleDownstream: impact.downstreamNodeIds.slice(0, EVENT_INLINE_EVENT_IDS),
129
+ };
130
+ }
@@ -94,6 +94,8 @@ export class ImpactAnalyzer {
94
94
  directConsumers: graph.directConsumers.length,
95
95
  transitiveConsumers: graph.transitiveConsumers.length,
96
96
  affectedFiles: graph.affectedFiles.length,
97
+ // Affected files are evidence, not writes: this read-only analysis never changes a file.
98
+ affectedFilePaths: graph.affectedFiles.slice(0, 64),
97
99
  cycles: graph.cycles.length,
98
100
  scenarioSuites: scenarioSuites.scannedSuites,
99
101
  affectedCases: scenarioSuites.matches.length,
@@ -108,7 +110,6 @@ export class ImpactAnalyzer {
108
110
  result.code = complete ? 'IMPACT_ANALYZED' : 'IMPACT_ANALYZED_PARTIAL';
109
111
  result.artifacts = [publicArtifactLink(artifact)];
110
112
  result.proposedFiles = overlay?.changes.map(({ path }) => path).slice(0, 64) ?? [];
111
- result.changedFiles = graph.affectedFiles.slice(0, 64);
112
113
  setInlineFilesScanned(result, before.files.map(({ displayPath }) => displayPath));
113
114
  result.validation = {
114
115
  passed: complete,
@@ -16,6 +16,7 @@ const impactOutputSchema = strictOperationResultSchema(z
16
16
  directConsumers: nonNegativeIntegerSchema,
17
17
  transitiveConsumers: nonNegativeIntegerSchema,
18
18
  affectedFiles: nonNegativeIntegerSchema,
19
+ affectedFilePaths: z.array(z.string().max(1024)).max(64).optional(),
19
20
  cycles: nonNegativeIntegerSchema,
20
21
  scenarioSuites: nonNegativeIntegerSchema,
21
22
  affectedCases: nonNegativeIntegerSchema,