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