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.
- package/CHANGELOG.md +20 -0
- package/dist/hoi4_agent_tools/core/canonical.js +42 -0
- package/dist/hoi4_agent_tools/core/condition-evaluator.js +12 -8
- package/dist/hoi4_agent_tools/core/dependency-walk.js +21 -2
- package/dist/hoi4_agent_tools/core/impact-graph.js +6 -2
- package/dist/hoi4_agent_tools/core/impact-semantic.js +12 -2
- package/dist/hoi4_agent_tools/core/index.js +1 -0
- package/dist/hoi4_agent_tools/core/job-worker-host.js +12 -2
- package/dist/hoi4_agent_tools/decision/service.js +22 -1
- package/dist/hoi4_agent_tools/event/algorithms.js +3 -2
- package/dist/hoi4_agent_tools/event/graph.js +52 -35
- package/dist/hoi4_agent_tools/event/limits.js +4 -0
- package/dist/hoi4_agent_tools/event/operations.js +11 -0
- package/dist/hoi4_agent_tools/event/queries.js +13 -34
- package/dist/hoi4_agent_tools/event/render.js +4 -2
- package/dist/hoi4_agent_tools/event/service.js +93 -33
- package/dist/hoi4_agent_tools/event/source-analysis.js +94 -4
- package/dist/hoi4_agent_tools/event/state-keys.js +63 -0
- package/dist/hoi4_agent_tools/event/summary.js +130 -0
- package/dist/hoi4_agent_tools/impact/service.js +2 -1
- package/dist/hoi4_agent_tools/mcp/tools/analysis.js +1 -0
- package/dist/hoi4_agent_tools/mcp/tools/event.js +66 -0
- package/dist/hoi4_agent_tools/reference/source-lookup.js +4 -2
- package/dist/hoi4_agent_tools/version.js +1 -1
- package/docs/analysis.md +4 -0
- package/docs/events.md +12 -0
- package/npm-shrinkwrap.json +2 -2
- package/package.json +1 -1
- package/schemas/configuration.schema.json +1 -1
- package/schemas/continuous-focus-palette.schema.json +1 -1
- package/schemas/custom-weighted-pool.schema.json +1 -1
- package/schemas/decision-inspect-input.schema.json +1 -1
- package/schemas/error-log-input.schema.json +1 -1
- package/schemas/event-feature-manifest.schema.json +1 -1
- package/schemas/focus-plan.schema.json +1 -1
- package/schemas/focus-planning-sidecar.schema.json +1 -1
- package/schemas/gui-animation-source.schema.json +1 -1
- package/schemas/gui-helper.schema.json +1 -1
- package/schemas/gui-scenario.schema.json +1 -1
- package/schemas/helper-expansion-request.schema.json +1 -1
- package/schemas/helper-expansion-summary.schema.json +1 -1
- package/schemas/impact-inspect-input.schema.json +1 -1
- package/schemas/map-operation.schema.json +1 -1
- package/schemas/mechanic-test-input.schema.json +1 -1
- package/schemas/mod-index-input.schema.json +1 -1
- package/schemas/operation-result.schema.json +1 -1
- package/schemas/package-check-input.schema.json +1 -1
- package/schemas/package-manifest.schema.json +1 -1
- package/schemas/probability-analysis-result.schema.json +1 -1
- package/schemas/probability-compare-input.schema.json +1 -1
- package/schemas/probability-evaluate-input.schema.json +1 -1
- package/schemas/probability-inspect-input.schema.json +1 -1
- package/schemas/probability-render-input.schema.json +1 -1
- package/schemas/probability-scenario-set.schema.json +1 -1
- package/schemas/probability-sequence-input.schema.json +1 -1
- package/schemas/probability-simulate-input.schema.json +1 -1
- package/schemas/probability-sweep-input.schema.json +1 -1
- package/schemas/reference-context-input.schema.json +1 -1
- package/schemas/reference-read-input.schema.json +1 -1
- package/schemas/reference-search-input.schema.json +1 -1
- package/schemas/scenario-suite.schema.json +1 -1
- package/schemas/scenario-test-input.schema.json +1 -1
- package/schemas/script-validate-input.schema.json +1 -1
- package/schemas/shared-scenario.schema.json +1 -1
- package/schemas/source-lookup-input.schema.json +1 -1
- 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 {
|
|
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
|
|
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
|
|
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
|
-
|
|
856
|
-
|
|
857
|
-
|
|
858
|
-
|
|
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
|
|
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
|
|
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
|
|
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 =
|
|
964
|
-
? await this.engine.artifacts.putChunked(workspace, graphSnapshotName, 'application/json', `${canonicalJson(
|
|
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
|
-
|
|
1005
|
-
|
|
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
|
-
|
|
1008
|
-
|
|
1009
|
-
|
|
1010
|
-
|
|
1011
|
-
|
|
1012
|
-
|
|
1013
|
-
|
|
1014
|
-
|
|
1015
|
-
|
|
1016
|
-
|
|
1017
|
-
|
|
1018
|
-
|
|
1019
|
-
|
|
1020
|
-
|
|
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
|
-
|
|
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)
|
|
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
|
-
|
|
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_
|
|
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),
|
|
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,
|