hoi4-agent-tools 3.1.0 → 3.3.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 (74) hide show
  1. package/CHANGELOG.md +16 -0
  2. package/README.md +10 -2
  3. package/dist/hoi4_agent_tools/core/analysis-evidence.js +9 -0
  4. package/dist/hoi4_agent_tools/core/analysis-job-operations.js +48 -0
  5. package/dist/hoi4_agent_tools/core/condition-evaluator.js +21 -1
  6. package/dist/hoi4_agent_tools/core/impact-comparison.js +49 -0
  7. package/dist/hoi4_agent_tools/core/impact-graph.js +301 -0
  8. package/dist/hoi4_agent_tools/core/impact-semantic.js +256 -0
  9. package/dist/hoi4_agent_tools/core/job-worker-host.js +5 -0
  10. package/dist/hoi4_agent_tools/core/job-worker.js +2 -0
  11. package/dist/hoi4_agent_tools/core/operation-tasks.js +12 -0
  12. package/dist/hoi4_agent_tools/core/scenario-model.js +85 -0
  13. package/dist/hoi4_agent_tools/core/source-overlay.js +125 -0
  14. package/dist/hoi4_agent_tools/decision/ai.js +104 -0
  15. package/dist/hoi4_agent_tools/decision/comparison.js +106 -0
  16. package/dist/hoi4_agent_tools/decision/cost.js +177 -0
  17. package/dist/hoi4_agent_tools/decision/evaluation.js +256 -0
  18. package/dist/hoi4_agent_tools/decision/lifecycle.js +114 -0
  19. package/dist/hoi4_agent_tools/decision/service.js +172 -0
  20. package/dist/hoi4_agent_tools/decision/source-inventory.js +182 -0
  21. package/dist/hoi4_agent_tools/gui/scenario-conditions.js +2 -23
  22. package/dist/hoi4_agent_tools/impact/scenario-suites.js +74 -0
  23. package/dist/hoi4_agent_tools/impact/service.js +127 -0
  24. package/dist/hoi4_agent_tools/mcp/server/create.js +2 -0
  25. package/dist/hoi4_agent_tools/mcp/server/instructions.js +1 -1
  26. package/dist/hoi4_agent_tools/mcp/server/request-lifecycle.js +5 -0
  27. package/dist/hoi4_agent_tools/mcp/server/task-tool-catalog.js +2 -0
  28. package/dist/hoi4_agent_tools/mcp/tools/analysis.js +155 -0
  29. package/dist/hoi4_agent_tools/mechanic/assertions.js +112 -0
  30. package/dist/hoi4_agent_tools/mechanic/interpreter.js +619 -0
  31. package/dist/hoi4_agent_tools/mechanic/service.js +145 -0
  32. package/dist/hoi4_agent_tools/mechanic/source.js +89 -0
  33. package/dist/hoi4_agent_tools/package-check/service.js +231 -0
  34. package/dist/hoi4_agent_tools/probability/evaluation.js +3 -1
  35. package/dist/hoi4_agent_tools/scenario-suite/service.js +341 -0
  36. package/dist/hoi4_agent_tools/schemas/analysis.js +228 -0
  37. package/dist/hoi4_agent_tools/schemas/scenarios.js +264 -0
  38. package/dist/hoi4_agent_tools/version.js +1 -1
  39. package/docs/README.md +2 -0
  40. package/docs/analysis.md +100 -0
  41. package/docs/jobs.md +2 -1
  42. package/docs/mechanics.md +70 -0
  43. package/package.json +6 -2
  44. package/schemas/configuration.schema.json +1 -1
  45. package/schemas/continuous-focus-palette.schema.json +1 -1
  46. package/schemas/custom-weighted-pool.schema.json +1 -1
  47. package/schemas/decision-inspect-input.schema.json +290 -0
  48. package/schemas/event-feature-manifest.schema.json +1 -1
  49. package/schemas/focus-plan.schema.json +1 -1
  50. package/schemas/focus-planning-sidecar.schema.json +1 -1
  51. package/schemas/gui-animation-source.schema.json +1 -1
  52. package/schemas/gui-helper.schema.json +1 -1
  53. package/schemas/gui-scenario.schema.json +1 -1
  54. package/schemas/helper-expansion-request.schema.json +1 -1
  55. package/schemas/helper-expansion-summary.schema.json +1 -1
  56. package/schemas/impact-inspect-input.schema.json +203 -0
  57. package/schemas/map-operation.schema.json +1 -1
  58. package/schemas/mechanic-test-input.schema.json +650 -0
  59. package/schemas/operation-result.schema.json +1 -1
  60. package/schemas/package-check-input.schema.json +164 -0
  61. package/schemas/package-manifest.schema.json +142 -0
  62. package/schemas/probability-analysis-result.schema.json +1 -1
  63. package/schemas/probability-compare-input.schema.json +1 -1
  64. package/schemas/probability-evaluate-input.schema.json +1 -1
  65. package/schemas/probability-inspect-input.schema.json +1 -1
  66. package/schemas/probability-render-input.schema.json +1 -1
  67. package/schemas/probability-scenario-set.schema.json +1 -1
  68. package/schemas/probability-sequence-input.schema.json +1 -1
  69. package/schemas/probability-simulate-input.schema.json +1 -1
  70. package/schemas/probability-sweep-input.schema.json +1 -1
  71. package/schemas/scenario-suite.schema.json +1004 -0
  72. package/schemas/scenario-test-input.schema.json +1057 -0
  73. package/schemas/shared-scenario.schema.json +237 -0
  74. package/server.json +3 -3
package/CHANGELOG.md CHANGED
@@ -2,6 +2,22 @@
2
2
 
3
3
  ## Unreleased
4
4
 
5
+ ## 3.3.0 - 2026-09-20
6
+
7
+ - Add `hoi4.mechanic_test` with copied versioned scenario state, bounded source effects, explicit time advancement, traces, and typed gameplay assertions.
8
+ - Add `hoi4.package_check` for declarative package definitions, calls, registrations, localisation, assets, and required suite-case references.
9
+ - Add `hoi4.scenario_test` for named inline or workspace-owned suites through fixed read-only operations, case artifacts, and authenticated revision-bound continuation.
10
+ - Share declared condition state across GUI and probability adapters while preserving their public inputs and specialized evaluation semantics.
11
+
12
+ ## 3.2.0 - 2026-09-20
13
+
14
+ - Add read-only `hoi4.impact_inspect` with active and overridden definitions, direct and transitive source consumers, state-key access roles, cycle and coverage evidence, and in-memory source comparisons.
15
+ - Connect event, focus, decision, idea, technology, scripted-helper, localisation, scripted-GUI, GUI, sprite, and texture references where typed static source evidence supports an edge; leave dynamic and unsupported references unresolved.
16
+ - Identify affected declared scenario-suite cases from bounded workspace-owned JSON references without executing them.
17
+ - Add read-only `hoi4.decision_inspect` for decision and mission inventory, declared actor and target gates, costs and direct payments, lifecycle paths, and scenario-by-scenario source comparisons.
18
+ - Reuse the probability adapters for decision and mission AI scores and score comparisons, retaining linked traces and explicit proposal limits.
19
+ - Serve both tools through ordinary and persistent native MCP tasks over legacy and modern stdio and HTTP routes, with revision-bound linked reports and source-preserving overlays.
20
+
5
21
  ## 3.1.0 - 2026-09-20
6
22
 
7
23
  - Reuse authenticated content-addressed parsed documents and file-local index segments across authorized local processes while revalidating current source bytes, load order, shadowing, workspace topology, and principal scope.
package/README.md CHANGED
@@ -1,6 +1,6 @@
1
1
  # HOI4 Agent Tools
2
2
 
3
- HOI4 Agent Tools is an MCP server for coding agents to understand Hearts of Iron IV event chains, technology systems, AI weights, and MTTH timing and to inspect, create, or clean up focus trees, scripted GUIs, and maps. It produces source-linked structural and visual evidence that agents can use inside a larger modding workflow.
3
+ HOI4 Agent Tools is an MCP server for coding agents to understand Hearts of Iron IV event chains, decisions, cross-system dependencies, technology systems, AI weights, and MTTH timing and to inspect, create, or clean up focus trees, scripted GUIs, and maps. It produces source-linked structural and visual evidence that agents can use inside a larger modding workflow.
4
4
 
5
5
  ## What it does
6
6
 
@@ -8,6 +8,7 @@ HOI4 Agent Tools is an MCP server for coding agents to understand Hearts of Iron
8
8
  - Scripted GUIs: trace GUI, GFX, scripted-GUI, and localisation links; render value-driven variants, dynamic country flags, inline text icons, states, resolutions, hierarchy, and click regions; diagnose alignment, visibility, clipping, panel containment, and button-label centering; create or repair interface source.
9
9
  - Maps: navigate the complete rendered map by ID or localised name; inspect provinces, states, regions, adjacency, supply, railways, and positions; create states and provinces; change IDs; and repair connected map data.
10
10
  - Event chains: scan definitions and call sites, trace routes and state flow, lint references, render graphs, and compare revisions without editing event source.
11
+ - Cross-system impact and decisions: trace definitions and consumers across source systems, evaluate declared actor and target scenarios, inspect costs and mission paths, and compare in-memory proposals.
11
12
  - Technology trees: reconstruct technology and doctrine paths, folder layouts, unlocks, bonuses, grants, metadata, assets, and structural changes.
12
13
  - AI and MTTH: evaluate weighted choices and timing across explicit scenarios, bind special scope chains, enumerate dynamic target pools, sweep uncertain inputs, simulate distributions, compare patches, and analyze declared stateful pools.
13
14
 
@@ -71,6 +72,11 @@ On non-Windows systems, use `hoi4-agent-tools` as the command. Agentic HOI4 repo
71
72
  | `hoi4.event_inspect` | Scan, trace, explain, lint, or assess event chains and their state flow. |
72
73
  | `hoi4.event_render` | Render source-linked event routes, options, timing, state, scope, and unresolved edges. |
73
74
  | `hoi4.event_compare` | Compare event-chain topology and diagnostics between revisions. |
75
+ | `hoi4.impact_inspect` | Trace symbol and changed-file consumers across source systems and compare proposals. |
76
+ | `hoi4.decision_inspect` | Inventory and evaluate decisions or missions under declared scenarios and source changes. |
77
+ | `hoi4.mechanic_test` | Execute bounded source effects on a copied declared scenario and check assertions. |
78
+ | `hoi4.package_check` | Check declarative package definitions, calls, registrations, assets, and case links. |
79
+ | `hoi4.scenario_test` | Run named source and domain cases in resumable, revision-bound batches. |
74
80
  | `hoi4.job_inspect` | Inspect durable background work or retrieve its completed tool result. |
75
81
  | `hoi4.job_cancel` | Durably request cancellation of authorized background work. |
76
82
  | `hoi4.tech_inspect` | Scan, trace, explain, lint, and assess technology and doctrine systems. |
@@ -94,7 +100,7 @@ Long-running domain calls advertise optional MCP task support. A task survives c
94
100
 
95
101
  HOI4 Agent Tools provides HOI4 domain operations without replacing repository instructions such as `AGENTS.md`, skills, plans, or subagents. Its optional weighted-logic prompt helps an agent scope one probability analysis and then returns control to the normal workflow.
96
102
 
97
- Connecting and listing tools does not scan mod source. Compact tool schemas and linked resources keep large diagnostics, renders, and diffs out of the agent's working context until needed. Event, technology, and probability tools analyze source without editing it; only `hoi4.*_rewrite` calls edit mod source.
103
+ Connecting and listing tools does not scan mod source. Compact tool schemas and linked resources keep large diagnostics, renders, and diffs out of the agent's working context until needed. Event, impact, decision, technology, and probability tools analyze source without editing it; only `hoi4.*_rewrite` calls edit mod source.
98
104
 
99
105
  ## Create or clean content
100
106
 
@@ -104,6 +110,8 @@ Ask your agent in normal task language. A typical workflow is inspect, render, r
104
110
  - Scripted GUIs: "Create a scripted GUI for this mechanic," or "Render every value-driven version of this window and fix hidden controls, off-center button text, background alignment, clipping, and click-region conflicts." See [Scripted GUIs](docs/gui.md).
105
111
  - Maps: "Render the whole map and find this state by name," "Create a state from these provinces," "Create a province inside this exact rectangle," or "Swap these state IDs and update connected references." See [Maps](docs/map.md).
106
112
  - Event chains: "Trace every route from this event and explain where its flags and variables change," or "Compare the workspace event graph with its previous revision and render the affected routes." See [Event chains](docs/events.md).
113
+ - Cross-system impact and decisions: "Find every direct and transitive consumer of this idea," or "Compare who can take this decision, what they pay, and its mission end paths for these actor and target scenarios." See [Cross-system impact and decisions](docs/analysis.md).
114
+ - Mechanics and packages: "Test this transfer's conservation and payment," or "Check every declared package link and resume its named scenario suite." See [Mechanic tests and scenario suites](docs/mechanics.md).
107
115
  - Technology trees: "Explain everything this technology requires and unlocks," or "Compare this technology patch and render every affected folder and doctrine branch." See [Technology trees](docs/technology.md).
108
116
  - AI and MTTH: "Compare these focus weights across peace, defensive-war, and low-stability scenarios," or "Show when this MTTH event becomes likely and which unknown inputs control the result." See [AI and MTTH analysis](docs/probability.md).
109
117
 
@@ -0,0 +1,9 @@
1
+ import { boundedSourceHashEvidence } from './artifacts.js';
2
+ /** Bind a linked analysis report to every source in both compared revisions. */
3
+ export function analysisSourceEvidence(before, after, additionalSources = {}) {
4
+ return boundedSourceHashEvidence(Object.fromEntries([
5
+ ...before.files.map((file) => [`before:${file.displayPath}`, file.sha256]),
6
+ ...(after?.files.map((file) => [`after:${file.displayPath}`, file.sha256]) ?? []),
7
+ ...Object.entries(additionalSources),
8
+ ]));
9
+ }
@@ -0,0 +1,48 @@
1
+ import { z } from 'zod/v4';
2
+ import { DecisionAnalyzer } from '../decision/service.js';
3
+ import { ImpactAnalyzer } from '../impact/service.js';
4
+ import { MechanicAnalyzer } from '../mechanic/service.js';
5
+ import { PackageAnalyzer } from '../package-check/service.js';
6
+ import { ScenarioAnalyzer } from '../scenario-suite/service.js';
7
+ import { decisionInspectRequestSchema, impactInspectRequestSchema } from '../schemas/analysis.js';
8
+ import { mechanicTestRequestSchema, packageCheckRequestSchema, scenarioTestRequestSchema, } from '../schemas/scenarios.js';
9
+ import { errorResult, toolResult } from './operation-result.js';
10
+ import { ServiceError } from './result.js';
11
+ const wireResult = z.record(z.string(), z.json());
12
+ /** Registers read-only analysis services for durable ordinary and native tasks. */
13
+ export function registerAnalysisJobs(operations, engine) {
14
+ const impact = new ImpactAnalyzer(engine);
15
+ const decision = new DecisionAnalyzer(engine);
16
+ const mechanic = new MechanicAnalyzer(engine);
17
+ const packages = new PackageAnalyzer(engine);
18
+ const scenarios = new ScenarioAnalyzer(engine);
19
+ const register = (name, schema, execute, progressMessage) => {
20
+ operations.registerRead(name, schema, async (input, context) => {
21
+ try {
22
+ if (input.workspaceId !== 'current' && input.workspaceId !== context.workspaceId)
23
+ throw new ServiceError('JOB_ARGUMENT_SCOPE_MISMATCH', 'The request workspace does not match the authorized job workspace');
24
+ await context.progress({ completed: 0, total: 2, message: progressMessage });
25
+ const result = await execute({
26
+ ...input,
27
+ workspaceId: context.workspaceId,
28
+ ...(context.principal === undefined ? {} : { principal: context.principal }),
29
+ signal: context.signal,
30
+ });
31
+ await context.progress({ completed: 2, total: 2, message: 'Analysis complete' });
32
+ const wire = wireResult.parse(JSON.parse(JSON.stringify(toolResult(result))));
33
+ await context.stageResult(wire, { sourceRevision: result.data.sourceRevision });
34
+ return wire;
35
+ }
36
+ catch (error) {
37
+ if (context.signal.aborted)
38
+ throw error;
39
+ return wireResult.parse(JSON.parse(JSON.stringify(errorResult(error, context.workspaceId))));
40
+ }
41
+ });
42
+ };
43
+ register('hoi4.impact_inspect', impactInspectRequestSchema, (input) => impact.inspect(input), 'Tracing cross-system impact');
44
+ register('hoi4.decision_inspect', decisionInspectRequestSchema, (input) => decision.inspect(input), 'Analyzing decisions and missions');
45
+ register('hoi4.mechanic_test', mechanicTestRequestSchema, (input) => mechanic.test(input), 'Testing declared mechanic steps');
46
+ register('hoi4.package_check', packageCheckRequestSchema, (input) => packages.check(input), 'Checking package connections');
47
+ register('hoi4.scenario_test', scenarioTestRequestSchema, (input) => scenarios.test(input), 'Running a bounded scenario suite');
48
+ }
@@ -257,7 +257,11 @@ function evaluateAssignmentCore(assignment, scenario, candidate, definitions, he
257
257
  const explicit = bool(scopeStateValue(scenario, `flag.${right}`, scopeContext));
258
258
  if (explicit !== undefined)
259
259
  return { state: explicit ? 'true' : 'false', unresolved: [] };
260
- const flags = scopeContext.expression === 'ROOT' ? scenario.flags : scopeContext.binding.flags;
260
+ const flags = key === 'has_global_flag' && scenario.globalFlags !== undefined
261
+ ? scenario.globalFlags
262
+ : scopeContext.expression === 'ROOT'
263
+ ? scenario.flags
264
+ : scopeContext.binding.flags;
261
265
  if (scenario.closedFlags === false && flags?.includes(right) !== true)
262
266
  return unresolved(assignment, candidate, `Scenario does not declare flag ${right}`);
263
267
  return { state: flags?.includes(right) === true ? 'true' : 'false', unresolved: [] };
@@ -344,6 +348,20 @@ function evaluateAssignmentCore(assignment, scenario, candidate, definitions, he
344
348
  state: compare(leftNumber, rightNumber, assignment.operator.text) ? 'true' : 'false',
345
349
  unresolved: [],
346
350
  };
351
+ if (/^(?:ROOT|THIS|PREV|FROM)$/iu.test(right)) {
352
+ const comparisonScope = resolveScopeContext(scenario, right, scopeContext);
353
+ if (comparisonScope === undefined ||
354
+ (comparisonScope.expression === 'ROOT' && scenario.actor === undefined))
355
+ return unresolved(assignment, candidate, `Scenario does not bind scope ${right}`);
356
+ if (typeof declared !== 'string')
357
+ return unresolved(assignment, candidate, `Trigger ${key} cannot compare its declared value with scope ${right}`);
358
+ return {
359
+ state: compare(declared, scopeIdentity(comparisonScope), assignment.operator.text)
360
+ ? 'true'
361
+ : 'false',
362
+ unresolved: [],
363
+ };
364
+ }
347
365
  if (typeof declared === 'string')
348
366
  return {
349
367
  state: compare(declared, right, assignment.operator.text) ? 'true' : 'false',
@@ -397,3 +415,5 @@ export function evaluateTriggerBlock(block, scenario, candidate, definitions, he
397
415
  trace: children.flatMap(({ trace }) => trace ?? []),
398
416
  };
399
417
  }
418
+ /** Resolve a declared numeric expression with the same constants and scope rules as triggers. */
419
+ export { numberValue as resolveConditionNumber };
@@ -0,0 +1,49 @@
1
+ import { compareCodeUnits, hashCanonical } from './canonical.js';
2
+ import { inspectImpactGraph, } from './impact-graph.js';
3
+ function edgeIdentity(edge) {
4
+ const targetDefinition = edge.targetDefinition;
5
+ return hashCanonical({
6
+ source: edge.source,
7
+ target: edge.target,
8
+ targetDefinition: targetDefinition === undefined
9
+ ? null
10
+ : {
11
+ path: targetDefinition.path,
12
+ rootKind: targetDefinition.rootKind,
13
+ loadOrder: targetDefinition.loadOrder,
14
+ },
15
+ referenceKind: edge.referenceKind,
16
+ accessRole: edge.accessRole,
17
+ });
18
+ }
19
+ function consumers(result) {
20
+ return [...result.directConsumers, ...result.transitiveConsumers];
21
+ }
22
+ /** Compare typed dependency consumers under the same selection and bounds. */
23
+ export function compareImpactGraphs(beforeSnapshot, afterSnapshot, query) {
24
+ if (beforeSnapshot.workspaceId !== afterSnapshot.workspaceId)
25
+ throw new Error('Impact comparison crosses workspace boundaries');
26
+ const before = inspectImpactGraph(beforeSnapshot, query);
27
+ const after = inspectImpactGraph(afterSnapshot, query);
28
+ const beforeEdges = consumers(before);
29
+ const afterEdges = consumers(after);
30
+ const beforeKeys = new Set(beforeEdges.map(edgeIdentity));
31
+ const afterKeys = new Set(afterEdges.map(edgeIdentity));
32
+ const beforeFiles = new Set(before.affectedFiles);
33
+ const afterFiles = new Set(after.affectedFiles);
34
+ return {
35
+ beforeRevision: before.sourceRevision,
36
+ afterRevision: after.sourceRevision,
37
+ complete: before.complete && after.complete,
38
+ before,
39
+ after,
40
+ addedConsumers: afterEdges.filter((edge) => !beforeKeys.has(edgeIdentity(edge))),
41
+ removedConsumers: beforeEdges.filter((edge) => !afterKeys.has(edgeIdentity(edge))),
42
+ newlyAffectedFiles: after.affectedFiles
43
+ .filter((file) => !beforeFiles.has(file))
44
+ .sort(compareCodeUnits),
45
+ noLongerAffectedFiles: before.affectedFiles
46
+ .filter((file) => !afterFiles.has(file))
47
+ .sort(compareCodeUnits),
48
+ };
49
+ }
@@ -0,0 +1,301 @@
1
+ import { compareCodeUnits } from './canonical.js';
2
+ import { scanImpactSemanticReferences } from './impact-semantic.js';
3
+ function symbolKey(kind, id) {
4
+ return `${kind}\0${id}`;
5
+ }
6
+ function sourceKey(path, id) {
7
+ return `${path}\0${id}`;
8
+ }
9
+ function edgeOrder(left, right) {
10
+ return (compareCodeUnits(left.target.kind, right.target.kind) ||
11
+ compareCodeUnits(left.target.id, right.target.id) ||
12
+ compareCodeUnits(left.source.kind, right.source.kind) ||
13
+ compareCodeUnits(left.source.id, right.source.id) ||
14
+ compareCodeUnits(left.path, right.path) ||
15
+ compareCodeUnits(left.referenceKind, right.referenceKind));
16
+ }
17
+ function bounded(value, fallback, maximum) {
18
+ if (value === undefined)
19
+ return fallback;
20
+ if (!Number.isSafeInteger(value) || value < 1 || value > maximum)
21
+ throw new RangeError(`Impact limit must be an integer between 1 and ${maximum}`);
22
+ return value;
23
+ }
24
+ /** Finds every reached edge inside a strongly connected component without recursive call stacks. */
25
+ function reachedCycleEdges(reached, edges) {
26
+ const outgoing = new Map();
27
+ const incoming = new Map();
28
+ for (const edge of edges) {
29
+ const from = symbolKey(edge.target.kind, edge.target.id);
30
+ const to = symbolKey(edge.source.kind, edge.source.id);
31
+ if (!reached.has(from) || !reached.has(to))
32
+ continue;
33
+ const targets = outgoing.get(from) ?? [];
34
+ targets.push(to);
35
+ outgoing.set(from, targets);
36
+ const sources = incoming.get(to) ?? [];
37
+ sources.push(from);
38
+ incoming.set(to, sources);
39
+ }
40
+ const visited = new Set();
41
+ const order = [];
42
+ for (const root of [...reached].sort(compareCodeUnits)) {
43
+ if (visited.has(root))
44
+ continue;
45
+ visited.add(root);
46
+ const stack = [{ key: root, cursor: 0 }];
47
+ while (stack.length > 0) {
48
+ const frame = stack.at(-1);
49
+ const next = outgoing.get(frame.key)?.[frame.cursor];
50
+ if (next === undefined) {
51
+ order.push(frame.key);
52
+ stack.pop();
53
+ continue;
54
+ }
55
+ frame.cursor += 1;
56
+ if (!visited.has(next)) {
57
+ visited.add(next);
58
+ stack.push({ key: next, cursor: 0 });
59
+ }
60
+ }
61
+ }
62
+ const componentByKey = new Map();
63
+ const componentSizes = [];
64
+ for (const root of order.reverse()) {
65
+ if (componentByKey.has(root))
66
+ continue;
67
+ const component = componentSizes.length;
68
+ let size = 0;
69
+ const stack = [root];
70
+ componentByKey.set(root, component);
71
+ while (stack.length > 0) {
72
+ const key = stack.pop();
73
+ size += 1;
74
+ for (const previous of incoming.get(key) ?? []) {
75
+ if (componentByKey.has(previous))
76
+ continue;
77
+ componentByKey.set(previous, component);
78
+ stack.push(previous);
79
+ }
80
+ }
81
+ componentSizes.push(size);
82
+ }
83
+ const cycles = [];
84
+ const recorded = new Set();
85
+ let omittedCycles = 0;
86
+ for (const edge of [...edges].sort(edgeOrder)) {
87
+ const from = symbolKey(edge.target.kind, edge.target.id);
88
+ const to = symbolKey(edge.source.kind, edge.source.id);
89
+ const component = componentByKey.get(from);
90
+ if (component === undefined ||
91
+ component !== componentByKey.get(to) ||
92
+ (componentSizes[component] === 1 && from !== to))
93
+ continue;
94
+ const identity = `${from}\0${to}\0${edge.source.path}`;
95
+ if (recorded.has(identity))
96
+ continue;
97
+ recorded.add(identity);
98
+ if (cycles.length < 1_000)
99
+ cycles.push({
100
+ from: edge.target,
101
+ to: { kind: edge.source.kind, id: edge.source.id },
102
+ path: edge.source.path,
103
+ });
104
+ else
105
+ omittedCycles += 1;
106
+ }
107
+ return { cycles, omittedCycles };
108
+ }
109
+ /** Revision-pinned reverse consumer walk over the shared typed symbol index. */
110
+ export function inspectImpactGraph(snapshot, query) {
111
+ const maxNodes = bounded(query.maxNodes, 5_000, 50_000);
112
+ const maxEdges = bounded(query.maxEdges, 10_000, 100_000);
113
+ const maxDepth = bounded(query.maxDepth, 12, 64);
114
+ const semantic = scanImpactSemanticReferences(snapshot);
115
+ const activeBySource = new Map();
116
+ const activeByKey = new Map();
117
+ const activeByPath = new Map();
118
+ for (const symbol of snapshot.index.symbols) {
119
+ if (symbol.overridden)
120
+ continue;
121
+ const source = sourceKey(symbol.path, symbol.id);
122
+ const sourceGroup = activeBySource.get(source) ?? [];
123
+ sourceGroup.push(symbol);
124
+ activeBySource.set(source, sourceGroup);
125
+ activeByKey.set(symbolKey(symbol.kind, symbol.id), symbol);
126
+ const pathGroup = activeByPath.get(symbol.path) ?? [];
127
+ pathGroup.push(symbol);
128
+ activeByPath.set(symbol.path, pathGroup);
129
+ }
130
+ const reverse = new Map();
131
+ const unresolvedByTarget = new Map();
132
+ const assetReferences = snapshot.index.symbols.flatMap((symbol) => symbol.kind === 'sprite' && !symbol.overridden && typeof symbol.metadata.texture === 'string'
133
+ ? [
134
+ {
135
+ kind: 'sprite_texture',
136
+ from: symbol.id,
137
+ toKind: 'texture',
138
+ to: symbol.metadata.texture,
139
+ path: symbol.path,
140
+ ...(symbol.location === undefined ? {} : { location: symbol.location }),
141
+ },
142
+ ]
143
+ : []);
144
+ for (const reference of [
145
+ ...snapshot.index.references,
146
+ ...semantic.references,
147
+ ...assetReferences,
148
+ ]) {
149
+ const sources = activeBySource.get(sourceKey(reference.path, reference.from)) ?? [];
150
+ const targetKey = symbolKey(reference.toKind, reference.to);
151
+ if (sources.length !== 1) {
152
+ const list = unresolvedByTarget.get(targetKey) ?? [];
153
+ list.push({
154
+ reference,
155
+ reason: sources.length === 0
156
+ ? 'Reference has no active enclosing symbol in the shared index'
157
+ : 'Reference has more than one active enclosing symbol kind',
158
+ });
159
+ unresolvedByTarget.set(targetKey, list);
160
+ continue;
161
+ }
162
+ const target = activeByKey.get(symbolKey(reference.toKind, reference.to));
163
+ const edge = {
164
+ source: {
165
+ kind: sources[0].kind,
166
+ id: sources[0].id,
167
+ path: sources[0].path,
168
+ rootKind: sources[0].rootKind,
169
+ loadOrder: sources[0].loadOrder,
170
+ },
171
+ target: { kind: reference.toKind, id: reference.to },
172
+ ...(target === undefined
173
+ ? {}
174
+ : {
175
+ targetDefinition: {
176
+ path: target.path,
177
+ rootKind: target.rootKind,
178
+ loadOrder: target.loadOrder,
179
+ ...(target.location === undefined ? {} : { location: target.location }),
180
+ },
181
+ }),
182
+ referenceKind: reference.kind,
183
+ accessRole: reference.kind.endsWith('_read')
184
+ ? 'read'
185
+ : reference.kind.endsWith('_write')
186
+ ? 'write'
187
+ : 'reference',
188
+ path: reference.path,
189
+ ...(reference.location === undefined ? {} : { location: reference.location }),
190
+ targetStatus: ['variable', 'flag', 'event_target'].includes(reference.toKind)
191
+ ? 'state_key'
192
+ : target === undefined
193
+ ? snapshot.index.hasSkippedSourceForKind(reference.toKind)
194
+ ? 'partial'
195
+ : 'missing'
196
+ : 'active',
197
+ };
198
+ const list = reverse.get(targetKey) ?? [];
199
+ list.push(edge);
200
+ reverse.set(targetKey, list);
201
+ }
202
+ for (const edges of reverse.values())
203
+ edges.sort(edgeOrder);
204
+ const selectors = query.symbols ?? [];
205
+ const selectorKeys = new Set(selectors.map(({ kind, id }) => symbolKey(kind, id)));
206
+ const changedFiles = [...new Set(query.changedFiles ?? [])].sort(compareCodeUnits);
207
+ const seeds = new Set(selectorKeys);
208
+ for (const changedFile of changedFiles)
209
+ for (const symbol of activeByPath.get(changedFile) ?? [])
210
+ seeds.add(symbolKey(symbol.kind, symbol.id));
211
+ const definitions = snapshot.index.symbols
212
+ .filter((symbol) => selectorKeys.has(symbolKey(symbol.kind, symbol.id)))
213
+ .sort((left, right) => compareCodeUnits(left.kind, right.kind) ||
214
+ compareCodeUnits(left.id, right.id) ||
215
+ right.loadOrder - left.loadOrder ||
216
+ compareCodeUnits(left.path, right.path));
217
+ const missingSymbols = selectors.filter(({ kind, id }) => !activeByKey.has(symbolKey(kind, id)) &&
218
+ !(['variable', 'flag', 'event_target'].includes(kind) && reverse.has(symbolKey(kind, id))));
219
+ const seedKeys = [...seeds].sort(compareCodeUnits);
220
+ const visited = new Set(seedKeys.slice(0, maxNodes));
221
+ const queue = [...visited].map((key) => ({ key, depth: 0 }));
222
+ const directConsumers = [];
223
+ const transitiveConsumers = [];
224
+ const affectedFiles = new Set(changedFiles);
225
+ for (const key of seeds) {
226
+ const source = activeByKey.get(key);
227
+ if (source !== undefined)
228
+ affectedFiles.add(source.path);
229
+ }
230
+ const unresolved = [];
231
+ let reachedEdges = 0;
232
+ let omittedEdges = 0;
233
+ let omittedNodes = seedKeys.length - visited.size;
234
+ let stoppedAtDepth = false;
235
+ for (const { key, depth } of queue) {
236
+ unresolved.push(...(unresolvedByTarget.get(key) ?? []));
237
+ const consumers = reverse.get(key) ?? [];
238
+ if (depth >= maxDepth) {
239
+ if (consumers.length > 0)
240
+ stoppedAtDepth = true;
241
+ omittedEdges += consumers.length;
242
+ continue;
243
+ }
244
+ for (const edge of consumers) {
245
+ if (reachedEdges >= maxEdges) {
246
+ omittedEdges += 1;
247
+ continue;
248
+ }
249
+ reachedEdges += 1;
250
+ affectedFiles.add(edge.source.path);
251
+ if (depth === 0)
252
+ directConsumers.push(edge);
253
+ else
254
+ transitiveConsumers.push(edge);
255
+ const consumerKey = symbolKey(edge.source.kind, edge.source.id);
256
+ if (visited.has(consumerKey))
257
+ continue;
258
+ if (visited.size >= maxNodes) {
259
+ omittedNodes += 1;
260
+ continue;
261
+ }
262
+ visited.add(consumerKey);
263
+ queue.push({ key: consumerKey, depth: depth + 1 });
264
+ }
265
+ }
266
+ const { cycles, omittedCycles } = reachedCycleEdges(visited, [
267
+ ...directConsumers,
268
+ ...transitiveConsumers,
269
+ ]);
270
+ return {
271
+ sourceRevision: snapshot.revision,
272
+ complete: snapshot.complete &&
273
+ semantic.complete &&
274
+ omittedNodes === 0 &&
275
+ omittedEdges === 0 &&
276
+ omittedCycles === 0 &&
277
+ !stoppedAtDepth &&
278
+ unresolved.length === 0 &&
279
+ semantic.unresolved.length === 0,
280
+ definitions,
281
+ missingSymbols,
282
+ directConsumers: directConsumers.sort(edgeOrder),
283
+ transitiveConsumers: transitiveConsumers.sort(edgeOrder),
284
+ affectedFiles: [...affectedFiles].sort(compareCodeUnits),
285
+ unresolved,
286
+ dynamicReferences: semantic.unresolved,
287
+ cycles,
288
+ coverage: {
289
+ scannedSymbols: snapshot.index.symbols.length,
290
+ scannedReferences: snapshot.index.references.length + semantic.references.length + assetReferences.length,
291
+ reachedNodes: visited.size,
292
+ reachedEdges,
293
+ omittedNodes,
294
+ omittedEdges,
295
+ omittedCycles,
296
+ stoppedAtDepth,
297
+ sourceComplete: snapshot.complete,
298
+ skippedSourceCount: snapshot.skippedSourceCount,
299
+ },
300
+ };
301
+ }