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
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,26 @@
|
|
|
2
2
|
|
|
3
3
|
## Unreleased
|
|
4
4
|
|
|
5
|
+
## 4.2.0 - 2026-10-19
|
|
6
|
+
|
|
7
|
+
- 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`.
|
|
8
|
+
- 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`.
|
|
9
|
+
- 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.
|
|
10
|
+
- 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.
|
|
11
|
+
- 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.
|
|
12
|
+
- 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.
|
|
13
|
+
- Report unresolved references in event analysis only for symbol kinds that analysis indexes; sprite and interface references are left to the full index.
|
|
14
|
+
- Keep distinct event findings in one owner separate instead of collapsing them to the first finding per code.
|
|
15
|
+
- 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.
|
|
16
|
+
- Treat undeclared `has_variable` and `has_event_target` inputs as unresolved in open decision scenarios, matching flags; `closedFlags: true` keeps them absent.
|
|
17
|
+
- 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.
|
|
18
|
+
- 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.
|
|
19
|
+
- Report each recursive scripted-helper loop once instead of once per caller, and read `event_target:founder.values` as the target `founder`.
|
|
20
|
+
- 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`.
|
|
21
|
+
- 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.
|
|
22
|
+
- 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.
|
|
23
|
+
- 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.
|
|
24
|
+
|
|
5
25
|
## 4.1.0 - 2026-10-15
|
|
6
26
|
|
|
7
27
|
- 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
|
}
|
|
@@ -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));
|
|
@@ -99,7 +99,20 @@ export class DecisionAnalyzer {
|
|
|
99
99
|
}));
|
|
100
100
|
const ai = input.mode === 'inventory'
|
|
101
101
|
? undefined
|
|
102
|
-
: await inspectDecisionAi(this.engine, before, input.id, scenarios, overlay, input.principal, input.signal)
|
|
102
|
+
: await inspectDecisionAi(this.engine, before, input.id, scenarios, overlay, input.principal, input.signal).catch((error) => {
|
|
103
|
+
// AI weights depend on adapters verified for one game version. A game patch leaves
|
|
104
|
+
// the AI score unresolved; visibility, availability and cost evidence still stand.
|
|
105
|
+
if (error instanceof ServiceError &&
|
|
106
|
+
(error.code === 'PROBABILITY_GAME_VERSION_UNSUPPORTED' ||
|
|
107
|
+
error.code === 'PROBABILITY_GAME_VERSION_UNVERIFIED'))
|
|
108
|
+
return {
|
|
109
|
+
status: 'unresolved',
|
|
110
|
+
code: error.code,
|
|
111
|
+
message: error.message,
|
|
112
|
+
details: error.details,
|
|
113
|
+
};
|
|
114
|
+
throw error;
|
|
115
|
+
});
|
|
103
116
|
const sourceComplete = input.mode === 'inventory'
|
|
104
117
|
? inventory.complete
|
|
105
118
|
: input.mode === 'compare'
|
|
@@ -180,6 +193,14 @@ export class DecisionAnalyzer {
|
|
|
180
193
|
: {}),
|
|
181
194
|
});
|
|
182
195
|
result.code = complete ? 'DECISION_ANALYZED' : 'DECISION_ANALYZED_PARTIAL';
|
|
196
|
+
if (ai !== undefined && 'code' in ai)
|
|
197
|
+
result.diagnostics.push({
|
|
198
|
+
category: 'configuration',
|
|
199
|
+
code: ai.code,
|
|
200
|
+
severity: 'warning',
|
|
201
|
+
message: `${ai.message}; AI weights are unresolved, the remaining evidence is unaffected`,
|
|
202
|
+
details: ai.details,
|
|
203
|
+
});
|
|
183
204
|
result.artifacts = [publicArtifactLink(artifact)];
|
|
184
205
|
result.proposedFiles = overlay?.changes.map(({ path }) => path).slice(0, 64) ?? [];
|
|
185
206
|
setInlineFilesScanned(result, before.files.map(({ displayPath }) => displayPath));
|
|
@@ -362,10 +362,11 @@ export function eventStronglyConnectedComponents(graph, expandHelpers = false, s
|
|
|
362
362
|
const budget = new EventAnalysisBudget(signal);
|
|
363
363
|
const components = iterativeStronglyConnectedComponents(retainedNodes, adjacency, () => budget.spend('strongly_connected_components'));
|
|
364
364
|
budget.check();
|
|
365
|
+
// Collect self-loops once: scanning every edge per component is quadratic on large mods.
|
|
366
|
+
const selfLoops = new Set(edges.filter(({ from, to }) => from === to).map(({ from }) => from));
|
|
365
367
|
return components
|
|
366
368
|
.map((nodeIds) => {
|
|
367
|
-
const
|
|
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;
|
|
@@ -1487,6 +1484,26 @@ function* eventGraphAnalysis(snapshot, options) {
|
|
|
1487
1484
|
ensureReferencedNodes(graph);
|
|
1488
1485
|
if (options.projectHelpers !== false) {
|
|
1489
1486
|
const walk = yield helperWalkInput(graph, snapshot.revision);
|
|
1487
|
+
if (walk.state.truncated === true)
|
|
1488
|
+
addIssue(graph.issues, {
|
|
1489
|
+
code: 'EVENT_HELPER_PROJECTION_LIMIT',
|
|
1490
|
+
classification: 'unresolved_analysis',
|
|
1491
|
+
severity: 'blocker',
|
|
1492
|
+
message: 'Helper expansion reached its fixed work ceiling; helper calls from the remaining roots stay structural.',
|
|
1493
|
+
confidence: 'unresolved',
|
|
1494
|
+
blockers: [
|
|
1495
|
+
{
|
|
1496
|
+
code: 'HELPER_PROJECTION_LIMIT',
|
|
1497
|
+
message: 'Select a namespace, event or file, or use helper_expansion mode, for paths beyond the ceiling.',
|
|
1498
|
+
details: {
|
|
1499
|
+
maximum: EVENT_GRAPH_WORK_LIMIT,
|
|
1500
|
+
completedRoots: walk.state.root,
|
|
1501
|
+
totalRoots: walk.input.roots.length,
|
|
1502
|
+
},
|
|
1503
|
+
},
|
|
1504
|
+
],
|
|
1505
|
+
details: { completedRoots: walk.state.root, totalRoots: walk.input.roots.length },
|
|
1506
|
+
});
|
|
1490
1507
|
for (const edge of helperProjections(graph, budget, walk))
|
|
1491
1508
|
graph.edges.push(edge);
|
|
1492
1509
|
for (const access of projectHelperStateAccesses(graph, budget, walk))
|
|
@@ -17,6 +17,10 @@ export const EVENT_GRAPH_WORK_LIMIT = 10_000_000;
|
|
|
17
17
|
export const EVENT_FRAGMENT_CACHE_MAX_ENTRIES = 10_000;
|
|
18
18
|
export const EVENT_FRAGMENT_CACHE_MAX_SOURCE_BYTES = 134_217_728;
|
|
19
19
|
export const EVENT_PROPOSED_SOURCE_MAX_FILES = 64;
|
|
20
|
+
// The full graph snapshot of a scan is retained only up to this size. Larger graphs, such as a
|
|
21
|
+
// mod with several thousand events and hundreds of thousands of state accesses, would exceed a
|
|
22
|
+
// JavaScript string and the artifact preparation ceiling.
|
|
23
|
+
export const EVENT_GRAPH_SNAPSHOT_MAX_BYTES = 134_217_728;
|
|
20
24
|
export const EVENT_PROPOSED_SOURCE_MAX_BYTES = 67_108_864;
|
|
21
25
|
/** One cooperative, deterministic work ceiling shared by extraction and graph construction. */
|
|
22
26
|
export class EventAnalysisBudget {
|
|
@@ -4,6 +4,7 @@ import { publicArtifactLink } from '../core/artifacts.js';
|
|
|
4
4
|
import { setInlineFilesScanned } from '../core/operation-result.js';
|
|
5
5
|
import { emptyServiceResult } from '../core/result.js';
|
|
6
6
|
import { eventInspectRequestSchema, splitEventInspectRequest, eventRenderRequestSchema, eventCompareRequestSchema, } from '../schemas/event.js';
|
|
7
|
+
import { EVENT_GRAPH_SNAPSHOT_MAX_BYTES } from './limits.js';
|
|
7
8
|
import { eventDiagnostics, } from './service.js';
|
|
8
9
|
export function normalizeEventInspectionRequest(input) {
|
|
9
10
|
// Validated request fields are JSON-only. Match wire semantics by removing optional
|
|
@@ -87,6 +88,7 @@ export async function inspectEvents(viewer, input) {
|
|
|
87
88
|
...(inspected.helperExpansion === undefined
|
|
88
89
|
? {}
|
|
89
90
|
: { helperExpansion: inspected.helperExpansion }),
|
|
91
|
+
...(inspected.summary === undefined ? {} : { summary: inspected.summary }),
|
|
90
92
|
boundary: {
|
|
91
93
|
direction: input.direction ?? 'both',
|
|
92
94
|
maxDepth: inspected.helperExpansion?.maxDepth ?? input.maxDepth ?? 8,
|
|
@@ -103,6 +105,14 @@ export async function inspectEvents(viewer, input) {
|
|
|
103
105
|
: 'EVENT_INSPECTED_PARTIAL';
|
|
104
106
|
setInlineFilesScanned(result, inspected.graph.filesScanned);
|
|
105
107
|
result.artifacts = inspected.artifacts.map(publicArtifactLink);
|
|
108
|
+
if (inspected.graphSnapshotOmitted === true)
|
|
109
|
+
result.diagnostics.push({
|
|
110
|
+
category: 'configuration',
|
|
111
|
+
code: 'EVENT_GRAPH_SNAPSHOT_OMITTED',
|
|
112
|
+
severity: 'info',
|
|
113
|
+
message: 'The workspace graph is too large to retain as a snapshot artifact; the scan report is linked, and comparisons use revisions held by this server',
|
|
114
|
+
details: { maximumBytes: EVENT_GRAPH_SNAPSHOT_MAX_BYTES },
|
|
115
|
+
});
|
|
106
116
|
result.validation = eventGraphValidation(inspected.graph);
|
|
107
117
|
if (inspected.helperExpansion !== undefined)
|
|
108
118
|
result.validation = helperExpansionValidation(inspected.helperExpansion);
|
|
@@ -122,6 +132,7 @@ export async function renderEvents(viewer, input) {
|
|
|
122
132
|
selectedNodes: rendered.render.selectedNodeIds.length,
|
|
123
133
|
omittedNodes: rendered.render.omittedNodeCount,
|
|
124
134
|
branchRenders: rendered.branches.length,
|
|
135
|
+
omittedBranchRenders: rendered.budgetOmittedBranches,
|
|
125
136
|
},
|
|
126
137
|
boundary: {
|
|
127
138
|
direction: input.direction ?? 'both',
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { compareCodeUnits } from '../core/canonical.js';
|
|
2
2
|
import { eventFlowEdges, eventStronglyConnectedComponents, reachableEventNodeIds, shortestEventPath, traceEventGraph, } from './algorithms.js';
|
|
3
|
+
import { compatibleProducerKeys, hasUnknownSavedScopeStorage, stateKey } from './state-keys.js';
|
|
3
4
|
function reachableFromRootsWithout(graph, rootIds, removedNodeIds, signal) {
|
|
4
5
|
signal?.throwIfAborted();
|
|
5
6
|
const retainedNodeIds = new Set(graph.nodes.filter(({ id }) => !removedNodeIds.has(id)).map(({ id }) => id));
|
|
@@ -348,32 +349,6 @@ export function explainEventPath(graph, from, to, boundary, signal) {
|
|
|
348
349
|
boundary,
|
|
349
350
|
};
|
|
350
351
|
}
|
|
351
|
-
function stateKey(access) {
|
|
352
|
-
const scope = access.kind === 'variable' || access.kind === 'array' ? `:${access.scope}` : '';
|
|
353
|
-
const storage = access.kind === 'saved_scope'
|
|
354
|
-
? `:${access.metadata.storage === 'global' || access.metadata.storage === 'local'
|
|
355
|
-
? access.metadata.storage
|
|
356
|
-
: 'unknown'}`
|
|
357
|
-
: '';
|
|
358
|
-
return `${access.kind}:${access.name}${scope}${storage}`;
|
|
359
|
-
}
|
|
360
|
-
function compatibleProducerKeys(access) {
|
|
361
|
-
if (access.kind !== 'saved_scope' ||
|
|
362
|
-
access.metadata.storage === 'global' ||
|
|
363
|
-
access.metadata.storage === 'local') {
|
|
364
|
-
return [stateKey(access)];
|
|
365
|
-
}
|
|
366
|
-
return [
|
|
367
|
-
`saved_scope:${access.name}:global`,
|
|
368
|
-
`saved_scope:${access.name}:local`,
|
|
369
|
-
`saved_scope:${access.name}:unknown`,
|
|
370
|
-
];
|
|
371
|
-
}
|
|
372
|
-
function hasUnknownSavedScopeStorage(access) {
|
|
373
|
-
return (access.kind === 'saved_scope' &&
|
|
374
|
-
access.metadata.storage !== 'global' &&
|
|
375
|
-
access.metadata.storage !== 'local');
|
|
376
|
-
}
|
|
377
352
|
function compatibleProducers(producers, consumer) {
|
|
378
353
|
const compatible = new Map();
|
|
379
354
|
for (const key of compatibleProducerKeys(consumer)) {
|
|
@@ -544,20 +519,24 @@ export function lintEventGraph(graph, selector, signal) {
|
|
|
544
519
|
if (selector === undefined)
|
|
545
520
|
return [...graph.issues];
|
|
546
521
|
const selected = selectedOwnershipIds(graph, selector, signal);
|
|
547
|
-
|
|
522
|
+
// Source ranges of the selected nodes, grouped by file so each issue checks only its own file.
|
|
523
|
+
const selectedRanges = new Map();
|
|
524
|
+
for (const [index, { id, location }] of graph.nodes.entries()) {
|
|
548
525
|
if ((index & 255) === 0)
|
|
549
526
|
signal?.throwIfAborted();
|
|
550
|
-
|
|
551
|
-
|
|
527
|
+
if (location === undefined || !selected.has(id))
|
|
528
|
+
continue;
|
|
529
|
+
const ranges = selectedRanges.get(location.path) ?? [];
|
|
530
|
+
ranges.push({ start: location.start.offset, end: location.end.offset });
|
|
531
|
+
selectedRanges.set(location.path, ranges);
|
|
532
|
+
}
|
|
552
533
|
return graph.issues.filter((issue, index) => {
|
|
553
534
|
if ((index & 255) === 0)
|
|
554
535
|
signal?.throwIfAborted();
|
|
536
|
+
const location = issue.location;
|
|
555
537
|
return (issueSubjectIds(issue).some((id) => selected.has(id)) ||
|
|
556
|
-
(
|
|
557
|
-
|
|
558
|
-
location.path === issue.location?.path &&
|
|
559
|
-
issue.location.start.offset >= location.start.offset &&
|
|
560
|
-
issue.location.end.offset <= location.end.offset)));
|
|
538
|
+
(location !== undefined &&
|
|
539
|
+
(selectedRanges.get(location.path) ?? []).some(({ start, end }) => location.start.offset >= start && location.end.offset <= end)));
|
|
561
540
|
});
|
|
562
541
|
}
|
|
563
542
|
export function issueSubjectIds(issue) {
|
|
@@ -103,12 +103,14 @@ function boundedSelection(graph, options) {
|
|
|
103
103
|
else if (options.view === 'terminals') {
|
|
104
104
|
const terminals = withinSelector(graph.nodes.filter(({ kind }) => kind === 'terminal').map(({ id }) => id));
|
|
105
105
|
add(terminals);
|
|
106
|
-
|
|
106
|
+
const terminalIds = new Set(terminals);
|
|
107
|
+
add(graph.edges.filter(({ to }) => terminalIds.has(to)).flatMap(({ from, to }) => [from, to]));
|
|
107
108
|
}
|
|
108
109
|
else if (options.view === 'unresolved') {
|
|
109
110
|
const unresolved = withinSelector(graph.nodes.filter(({ kind }) => kind === 'unresolved').map(({ id }) => id));
|
|
110
111
|
add(unresolved);
|
|
111
|
-
|
|
112
|
+
const unresolvedIds = new Set(unresolved);
|
|
113
|
+
add(graph.edges.filter(({ to }) => unresolvedIds.has(to)).flatMap(({ from, to }) => [from, to]));
|
|
112
114
|
}
|
|
113
115
|
else if (options.view === 'neighborhood' || options.view === 'reachability') {
|
|
114
116
|
add(selectorTrace);
|