hoi4-agent-tools 3.1.0 → 3.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +16 -0
- package/README.md +10 -2
- package/dist/hoi4_agent_tools/core/analysis-evidence.js +9 -0
- package/dist/hoi4_agent_tools/core/analysis-job-operations.js +48 -0
- package/dist/hoi4_agent_tools/core/condition-evaluator.js +21 -1
- package/dist/hoi4_agent_tools/core/impact-comparison.js +49 -0
- package/dist/hoi4_agent_tools/core/impact-graph.js +301 -0
- package/dist/hoi4_agent_tools/core/impact-semantic.js +256 -0
- package/dist/hoi4_agent_tools/core/job-worker-host.js +5 -0
- package/dist/hoi4_agent_tools/core/job-worker.js +2 -0
- package/dist/hoi4_agent_tools/core/operation-tasks.js +12 -0
- package/dist/hoi4_agent_tools/core/scenario-model.js +85 -0
- package/dist/hoi4_agent_tools/core/source-overlay.js +125 -0
- package/dist/hoi4_agent_tools/decision/ai.js +104 -0
- package/dist/hoi4_agent_tools/decision/comparison.js +106 -0
- package/dist/hoi4_agent_tools/decision/cost.js +177 -0
- package/dist/hoi4_agent_tools/decision/evaluation.js +256 -0
- package/dist/hoi4_agent_tools/decision/lifecycle.js +114 -0
- package/dist/hoi4_agent_tools/decision/service.js +172 -0
- package/dist/hoi4_agent_tools/decision/source-inventory.js +182 -0
- package/dist/hoi4_agent_tools/gui/scenario-conditions.js +2 -23
- package/dist/hoi4_agent_tools/impact/scenario-suites.js +74 -0
- package/dist/hoi4_agent_tools/impact/service.js +127 -0
- package/dist/hoi4_agent_tools/mcp/server/create.js +2 -0
- package/dist/hoi4_agent_tools/mcp/server/instructions.js +1 -1
- package/dist/hoi4_agent_tools/mcp/server/request-lifecycle.js +5 -0
- package/dist/hoi4_agent_tools/mcp/server/task-tool-catalog.js +2 -0
- package/dist/hoi4_agent_tools/mcp/tools/analysis.js +155 -0
- package/dist/hoi4_agent_tools/mechanic/assertions.js +112 -0
- package/dist/hoi4_agent_tools/mechanic/interpreter.js +619 -0
- package/dist/hoi4_agent_tools/mechanic/service.js +145 -0
- package/dist/hoi4_agent_tools/mechanic/source.js +89 -0
- package/dist/hoi4_agent_tools/package-check/service.js +231 -0
- package/dist/hoi4_agent_tools/probability/evaluation.js +3 -1
- package/dist/hoi4_agent_tools/scenario-suite/service.js +341 -0
- package/dist/hoi4_agent_tools/schemas/analysis.js +228 -0
- package/dist/hoi4_agent_tools/schemas/scenarios.js +264 -0
- package/dist/hoi4_agent_tools/version.js +1 -1
- package/docs/README.md +2 -0
- package/docs/analysis.md +100 -0
- package/docs/jobs.md +2 -1
- package/docs/mechanics.md +70 -0
- package/package.json +6 -2
- 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 +290 -0
- 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 +203 -0
- package/schemas/map-operation.schema.json +1 -1
- package/schemas/mechanic-test-input.schema.json +650 -0
- package/schemas/operation-result.schema.json +1 -1
- package/schemas/package-check-input.schema.json +164 -0
- package/schemas/package-manifest.schema.json +142 -0
- 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/scenario-suite.schema.json +1004 -0
- package/schemas/scenario-test-input.schema.json +1057 -0
- package/schemas/shared-scenario.schema.json +237 -0
- package/server.json +3 -3
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
import { analysisSourceEvidence } from '../core/analysis-evidence.js';
|
|
2
|
+
import { publicArtifactLink } from '../core/artifacts.js';
|
|
3
|
+
import { canonicalJson, hashCanonical } from '../core/canonical.js';
|
|
4
|
+
import { ClausewitzEvaluationDefinitions } from '../core/clausewitz-evaluation.js';
|
|
5
|
+
import { setInlineFilesScanned } from '../core/operation-result.js';
|
|
6
|
+
import { emptyServiceResult, ServiceError } from '../core/result.js';
|
|
7
|
+
import { copyScenario } from '../core/scenario-model.js';
|
|
8
|
+
import { inspectDecisionCost } from '../decision/cost.js';
|
|
9
|
+
import { decisionSourceInventory } from '../decision/source-inventory.js';
|
|
10
|
+
import { PACKAGE_VERSION } from '../version.js';
|
|
11
|
+
import { evaluateMechanicAssertions } from './assertions.js';
|
|
12
|
+
import { MechanicInterpreter } from './interpreter.js';
|
|
13
|
+
import { scriptedEffectSources, selectMechanicSource } from './source.js';
|
|
14
|
+
export function runMechanicCase(snapshot, test) {
|
|
15
|
+
// Zod's optional output permits explicit undefined; JSON cloning drops those
|
|
16
|
+
// keys before the stricter internal scenario interface is used.
|
|
17
|
+
const scenario = JSON.parse(JSON.stringify(test.scenario));
|
|
18
|
+
const definitions = ClausewitzEvaluationDefinitions.build(snapshot);
|
|
19
|
+
const interpreter = new MechanicInterpreter(scenario, definitions, scriptedEffectSources(snapshot));
|
|
20
|
+
const before = copyScenario(scenario);
|
|
21
|
+
const checkpoints = [copyScenario(scenario)];
|
|
22
|
+
const decisions = decisionSourceInventory(snapshot).decisions;
|
|
23
|
+
for (const step of test.steps) {
|
|
24
|
+
if (step.kind === 'advance_days')
|
|
25
|
+
interpreter.advanceDays(step.days);
|
|
26
|
+
else {
|
|
27
|
+
const source = selectMechanicSource(snapshot, step.source);
|
|
28
|
+
for (let iteration = 0; iteration < step.repeat; iteration += 1) {
|
|
29
|
+
if (step.source.kind === 'decision') {
|
|
30
|
+
const decision = decisions.find(({ id }) => id === step.source.id);
|
|
31
|
+
if (decision === undefined)
|
|
32
|
+
throw new ServiceError('MECHANIC_SOURCE_MISSING', 'Selected decision is absent', step.source);
|
|
33
|
+
const cost = inspectDecisionCost(snapshot, decision, interpreter.result().state.scenario);
|
|
34
|
+
if (cost.kind === 'invalid' ||
|
|
35
|
+
cost.affordable === 'unresolved' ||
|
|
36
|
+
cost.unresolved.length > 0) {
|
|
37
|
+
interpreter.unresolvedDecision(decision.path, 'Decision cost or eligibility is unresolved');
|
|
38
|
+
continue;
|
|
39
|
+
}
|
|
40
|
+
if (cost.affordable === 'false') {
|
|
41
|
+
interpreter.skipDecision(decision.path, 'Decision is unaffordable');
|
|
42
|
+
continue;
|
|
43
|
+
}
|
|
44
|
+
if (cost.kind === 'engine_political_power') {
|
|
45
|
+
if (cost.amount === null) {
|
|
46
|
+
interpreter.unresolvedDecision(decision.path, 'Engine cost amount is unresolved');
|
|
47
|
+
continue;
|
|
48
|
+
}
|
|
49
|
+
interpreter.payDecisionCost(cost.amount, decision.path);
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
interpreter.execute(source);
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
const current = interpreter.result().state;
|
|
56
|
+
checkpoints.push({
|
|
57
|
+
scenario: structuredClone(current.scenario),
|
|
58
|
+
unknown: new Set(current.unknown),
|
|
59
|
+
day: current.day,
|
|
60
|
+
});
|
|
61
|
+
}
|
|
62
|
+
const execution = interpreter.result();
|
|
63
|
+
const assertions = evaluateMechanicAssertions(test.assertions, before, execution.state, checkpoints, execution.trace);
|
|
64
|
+
return {
|
|
65
|
+
id: test.id,
|
|
66
|
+
sourceRevision: snapshot.revision,
|
|
67
|
+
status: assertions.some(({ status }) => status === 'failed')
|
|
68
|
+
? 'failed'
|
|
69
|
+
: assertions.some(({ status }) => status === 'unresolved') || !execution.complete
|
|
70
|
+
? 'unresolved'
|
|
71
|
+
: 'passed',
|
|
72
|
+
assertions,
|
|
73
|
+
trace: execution.trace,
|
|
74
|
+
unresolved: execution.unresolved,
|
|
75
|
+
finalState: execution.state.scenario,
|
|
76
|
+
unknown: [...execution.state.unknown].sort(),
|
|
77
|
+
day: execution.state.day,
|
|
78
|
+
};
|
|
79
|
+
}
|
|
80
|
+
/** Source-backed, read-only declared mechanic scenario tests. */
|
|
81
|
+
export class MechanicAnalyzer {
|
|
82
|
+
engine;
|
|
83
|
+
constructor(engine) {
|
|
84
|
+
this.engine = engine;
|
|
85
|
+
}
|
|
86
|
+
async test(input) {
|
|
87
|
+
const workspace = this.engine.resolver.get(input.workspaceId, input.principal);
|
|
88
|
+
if (input.refresh)
|
|
89
|
+
this.engine.invalidate(input.workspaceId);
|
|
90
|
+
const snapshot = await this.engine.scan(input.workspaceId, {}, input.principal, input.signal);
|
|
91
|
+
if (input.expectedRevision !== undefined && input.expectedRevision !== snapshot.revision)
|
|
92
|
+
throw new ServiceError('MECHANIC_SOURCE_STALE', 'Mechanic source revision changed', {
|
|
93
|
+
expectedRevision: input.expectedRevision,
|
|
94
|
+
observedRevision: snapshot.revision,
|
|
95
|
+
});
|
|
96
|
+
const report = {
|
|
97
|
+
schemaVersion: 'mechanic-test.v1',
|
|
98
|
+
...runMechanicCase(snapshot, input.test),
|
|
99
|
+
};
|
|
100
|
+
const reportHash = hashCanonical(report);
|
|
101
|
+
const evidence = analysisSourceEvidence(snapshot);
|
|
102
|
+
const artifact = await this.engine.artifacts.putChunked(workspace, `mechanic-${reportHash.slice(0, 24)}.json`, 'application/json', `${canonicalJson(report)}\n`, {
|
|
103
|
+
kind: 'mechanic-test',
|
|
104
|
+
toolVersion: PACKAGE_VERSION,
|
|
105
|
+
schemaVersion: 'mechanic-test.v1',
|
|
106
|
+
sourceHashes: evidence.sourceHashes,
|
|
107
|
+
metadata: {
|
|
108
|
+
beforeRevision: snapshot.revision,
|
|
109
|
+
sourceInventory: evidence.inventory,
|
|
110
|
+
},
|
|
111
|
+
}, 'Revision-pinned mechanic trace and assertion evidence', input.signal);
|
|
112
|
+
const result = emptyServiceResult(input.workspaceId, {
|
|
113
|
+
sourceRevision: snapshot.revision,
|
|
114
|
+
reportHash,
|
|
115
|
+
status: report.status,
|
|
116
|
+
assertions: report.assertions.length,
|
|
117
|
+
passed: report.assertions.filter(({ status }) => status === 'passed').length,
|
|
118
|
+
failed: report.assertions.filter(({ status }) => status === 'failed').length,
|
|
119
|
+
unresolved: report.assertions.filter(({ status }) => status === 'unresolved').length +
|
|
120
|
+
report.unresolved.length,
|
|
121
|
+
steps: input.test.steps.length,
|
|
122
|
+
});
|
|
123
|
+
result.code =
|
|
124
|
+
report.status === 'passed'
|
|
125
|
+
? 'MECHANIC_TEST_PASSED'
|
|
126
|
+
: report.status === 'failed'
|
|
127
|
+
? 'MECHANIC_TEST_FAILED'
|
|
128
|
+
: 'MECHANIC_TEST_UNRESOLVED';
|
|
129
|
+
result.artifacts = [publicArtifactLink(artifact)];
|
|
130
|
+
setInlineFilesScanned(result, snapshot.files.map(({ displayPath }) => displayPath));
|
|
131
|
+
result.validation = {
|
|
132
|
+
passed: report.status === 'passed',
|
|
133
|
+
checks: [
|
|
134
|
+
{
|
|
135
|
+
id: 'mechanic-assertions',
|
|
136
|
+
passed: report.status === 'passed',
|
|
137
|
+
message: report.status === 'passed'
|
|
138
|
+
? 'All declared mechanic assertions passed'
|
|
139
|
+
: 'Inspect the linked trace for failed or unresolved assertions',
|
|
140
|
+
},
|
|
141
|
+
],
|
|
142
|
+
};
|
|
143
|
+
return result;
|
|
144
|
+
}
|
|
145
|
+
}
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
import { assignments, firstScalar, parseClausewitz } from '../core/source/index.js';
|
|
2
|
+
import { ServiceError } from '../core/result.js';
|
|
3
|
+
import { decisionSourceInventory } from '../decision/source-inventory.js';
|
|
4
|
+
const eventKinds = new Set([
|
|
5
|
+
'country_event',
|
|
6
|
+
'state_event',
|
|
7
|
+
'news_event',
|
|
8
|
+
'unit_leader_event',
|
|
9
|
+
'operative_leader_event',
|
|
10
|
+
]);
|
|
11
|
+
const optionMetadata = new Set([
|
|
12
|
+
'name',
|
|
13
|
+
'ai_chance',
|
|
14
|
+
'trigger',
|
|
15
|
+
'highlighted',
|
|
16
|
+
'original_tag_only',
|
|
17
|
+
'show_but_dont_execute',
|
|
18
|
+
]);
|
|
19
|
+
function activeDocument(snapshot, path) {
|
|
20
|
+
const file = snapshot.files.find((candidate) => candidate.displayPath === path && candidate.shadowedBy === undefined);
|
|
21
|
+
return file === undefined ? undefined : parseClausewitz(file.bytes, file.displayPath);
|
|
22
|
+
}
|
|
23
|
+
function source(id, path, block) {
|
|
24
|
+
if (block === undefined)
|
|
25
|
+
throw new ServiceError('MECHANIC_SOURCE_MISSING', 'Selected source has no effect block', {
|
|
26
|
+
id,
|
|
27
|
+
path,
|
|
28
|
+
});
|
|
29
|
+
return { id, path, block };
|
|
30
|
+
}
|
|
31
|
+
export function selectMechanicSource(snapshot, selector) {
|
|
32
|
+
if (selector.kind === 'decision') {
|
|
33
|
+
const decision = decisionSourceInventory(snapshot).decisions.find(({ id }) => id === selector.id);
|
|
34
|
+
return source(selector.id, decision?.path ?? '', decision?.fields.complete_effect[0]?.value.type === 'block'
|
|
35
|
+
? decision.fields.complete_effect[0].value
|
|
36
|
+
: undefined);
|
|
37
|
+
}
|
|
38
|
+
const indexedKind = selector.kind === 'focus_reward'
|
|
39
|
+
? 'focus'
|
|
40
|
+
: selector.kind === 'event_option'
|
|
41
|
+
? 'event'
|
|
42
|
+
: 'scripted_effect';
|
|
43
|
+
const symbol = snapshot.index.find(indexedKind, selector.id);
|
|
44
|
+
const document = symbol === undefined ? undefined : activeDocument(snapshot, symbol.path);
|
|
45
|
+
if (document === undefined)
|
|
46
|
+
throw new ServiceError('MECHANIC_SOURCE_MISSING', 'Selected source definition is absent', selector);
|
|
47
|
+
if (selector.kind === 'scripted_effect') {
|
|
48
|
+
const block = assignments(document.root, selector.id)[0]?.value;
|
|
49
|
+
return source(selector.id, symbol.path, block?.type === 'block' ? block : undefined);
|
|
50
|
+
}
|
|
51
|
+
if (selector.kind === 'event_option') {
|
|
52
|
+
if (selector.option === undefined)
|
|
53
|
+
throw new ServiceError('MECHANIC_OPTION_REQUIRED', 'Event option selector requires option');
|
|
54
|
+
const event = assignments(document.root).find((candidate) => eventKinds.has(candidate.key.value) &&
|
|
55
|
+
candidate.value.type === 'block' &&
|
|
56
|
+
firstScalar(candidate.value, 'id')?.value === selector.id);
|
|
57
|
+
const option = event?.value.type === 'block'
|
|
58
|
+
? assignments(event.value, 'option').find((candidate) => candidate.value.type === 'block' &&
|
|
59
|
+
firstScalar(candidate.value, 'name')?.value === selector.option)
|
|
60
|
+
: undefined;
|
|
61
|
+
if (option?.value.type !== 'block')
|
|
62
|
+
throw new ServiceError('MECHANIC_OPTION_MISSING', 'Selected event option is absent', selector);
|
|
63
|
+
return source(selector.id + ':' + selector.option, symbol.path, {
|
|
64
|
+
...option.value,
|
|
65
|
+
entries: option.value.entries.filter((entry) => entry.type !== 'assignment' || !optionMetadata.has(entry.key.value)),
|
|
66
|
+
});
|
|
67
|
+
}
|
|
68
|
+
const focusTree = assignments(document.root, 'focus_tree');
|
|
69
|
+
const focus = focusTree
|
|
70
|
+
.flatMap((tree) => (tree.value.type === 'block' ? assignments(tree.value, 'focus') : []))
|
|
71
|
+
.find((candidate) => candidate.value.type === 'block' &&
|
|
72
|
+
firstScalar(candidate.value, 'id')?.value === selector.id);
|
|
73
|
+
const reward = focus?.value.type === 'block'
|
|
74
|
+
? assignments(focus.value, 'completion_reward')[0]?.value
|
|
75
|
+
: undefined;
|
|
76
|
+
return source(selector.id, symbol.path, reward?.type === 'block' ? reward : undefined);
|
|
77
|
+
}
|
|
78
|
+
export function scriptedEffectSources(snapshot) {
|
|
79
|
+
const result = new Map();
|
|
80
|
+
for (const symbol of snapshot.index.symbols) {
|
|
81
|
+
if (symbol.kind !== 'scripted_effect' || symbol.overridden || symbol.sourceShadowed)
|
|
82
|
+
continue;
|
|
83
|
+
const document = activeDocument(snapshot, symbol.path);
|
|
84
|
+
const block = document === undefined ? undefined : assignments(document.root, symbol.id)[0]?.value;
|
|
85
|
+
if (block?.type === 'block')
|
|
86
|
+
result.set(symbol.id, { id: symbol.id, path: symbol.path, block });
|
|
87
|
+
}
|
|
88
|
+
return result;
|
|
89
|
+
}
|
|
@@ -0,0 +1,231 @@
|
|
|
1
|
+
import { readFile, stat } from 'node:fs/promises';
|
|
2
|
+
import { analysisSourceEvidence } from '../core/analysis-evidence.js';
|
|
3
|
+
import { publicArtifactLink } from '../core/artifacts.js';
|
|
4
|
+
import { canonicalJson, hashCanonical, sha256Bytes } from '../core/canonical.js';
|
|
5
|
+
import { scanImpactSemanticReferences } from '../core/impact-semantic.js';
|
|
6
|
+
import { setInlineFilesScanned } from '../core/operation-result.js';
|
|
7
|
+
import { emptyServiceResult, ServiceError } from '../core/result.js';
|
|
8
|
+
import { parseClausewitz } from '../core/source/index.js';
|
|
9
|
+
import { scenarioSuiteSchema } from '../schemas/scenarios.js';
|
|
10
|
+
import { PACKAGE_VERSION } from '../version.js';
|
|
11
|
+
const MAX_FILE_BYTES = 64 * 1024 * 1024;
|
|
12
|
+
function registrationPresent(bytes, path, token) {
|
|
13
|
+
if (!/\.(?:txt|gfx|gui|asset)$/iu.test(path))
|
|
14
|
+
return undefined;
|
|
15
|
+
const document = parseClausewitz(bytes, path);
|
|
16
|
+
if (document.diagnostics.some(({ severity }) => severity === 'error' || severity === 'blocker'))
|
|
17
|
+
return undefined;
|
|
18
|
+
const visit = (entry) => {
|
|
19
|
+
if (entry.type === 'scalar')
|
|
20
|
+
return entry.value === token;
|
|
21
|
+
if (entry.type === 'block')
|
|
22
|
+
return entry.entries.some(visit);
|
|
23
|
+
return entry.key.value === token || visit(entry.value);
|
|
24
|
+
};
|
|
25
|
+
return visit(document.root);
|
|
26
|
+
}
|
|
27
|
+
function indexedChecks(snapshot, manifest) {
|
|
28
|
+
const results = [];
|
|
29
|
+
const semantic = manifest.calls.length > 0 ? scanImpactSemanticReferences(snapshot) : undefined;
|
|
30
|
+
const references = semantic === undefined
|
|
31
|
+
? snapshot.index.references
|
|
32
|
+
: [...snapshot.index.references, ...semantic.references];
|
|
33
|
+
for (const definition of manifest.definitions) {
|
|
34
|
+
const active = snapshot.index.find(definition.kind, definition.id);
|
|
35
|
+
const all = snapshot.index.findAll(definition.kind, definition.id);
|
|
36
|
+
const file = snapshot.files.find(({ displayPath }) => displayPath === active?.path);
|
|
37
|
+
results.push({
|
|
38
|
+
category: 'definition',
|
|
39
|
+
id: `${definition.kind}:${definition.id}`,
|
|
40
|
+
status: active === undefined
|
|
41
|
+
? all.length > 0
|
|
42
|
+
? 'shadowed'
|
|
43
|
+
: snapshot.complete
|
|
44
|
+
? 'absent'
|
|
45
|
+
: 'unresolved'
|
|
46
|
+
: 'present',
|
|
47
|
+
...(active?.path === undefined ? {} : { path: active.path }),
|
|
48
|
+
...(file?.sha256 === undefined ? {} : { sha256: file.sha256 }),
|
|
49
|
+
});
|
|
50
|
+
}
|
|
51
|
+
for (const call of manifest.calls) {
|
|
52
|
+
const from = snapshot.index.find(call.from.kind, call.from.id);
|
|
53
|
+
const to = snapshot.index.find(call.to.kind, call.to.id);
|
|
54
|
+
const reference = references.find((candidate) => candidate.from === call.from.id &&
|
|
55
|
+
candidate.toKind === call.to.kind &&
|
|
56
|
+
candidate.to === call.to.id &&
|
|
57
|
+
candidate.path === from?.path);
|
|
58
|
+
results.push({
|
|
59
|
+
category: 'call',
|
|
60
|
+
id: `${call.from.kind}:${call.from.id}->${call.to.kind}:${call.to.id}`,
|
|
61
|
+
status: from === undefined || to === undefined
|
|
62
|
+
? snapshot.complete
|
|
63
|
+
? 'absent'
|
|
64
|
+
: 'unresolved'
|
|
65
|
+
: reference === undefined
|
|
66
|
+
? snapshot.complete && (semantic?.complete ?? true)
|
|
67
|
+
? 'absent'
|
|
68
|
+
: 'unresolved'
|
|
69
|
+
: 'present',
|
|
70
|
+
...(reference?.path === undefined ? {} : { path: reference.path }),
|
|
71
|
+
});
|
|
72
|
+
}
|
|
73
|
+
for (const id of manifest.localisation) {
|
|
74
|
+
const symbol = snapshot.index.find('localisation', id) ??
|
|
75
|
+
snapshot.index.find('localisation', `l_english:${id}`);
|
|
76
|
+
results.push({
|
|
77
|
+
category: 'localisation',
|
|
78
|
+
id,
|
|
79
|
+
status: symbol === undefined ? (snapshot.complete ? 'absent' : 'unresolved') : 'present',
|
|
80
|
+
...(symbol?.path === undefined ? {} : { path: symbol.path }),
|
|
81
|
+
});
|
|
82
|
+
}
|
|
83
|
+
return results;
|
|
84
|
+
}
|
|
85
|
+
/** Declarative package inventory; no manifest field is interpreted as code. */
|
|
86
|
+
export class PackageAnalyzer {
|
|
87
|
+
engine;
|
|
88
|
+
constructor(engine) {
|
|
89
|
+
this.engine = engine;
|
|
90
|
+
}
|
|
91
|
+
async check(input) {
|
|
92
|
+
const workspace = this.engine.resolver.get(input.workspaceId, input.principal);
|
|
93
|
+
if (input.refresh)
|
|
94
|
+
this.engine.invalidate(input.workspaceId);
|
|
95
|
+
const snapshot = await this.engine.scan(input.workspaceId, {}, input.principal, input.signal);
|
|
96
|
+
if (input.expectedRevision !== undefined && input.expectedRevision !== snapshot.revision)
|
|
97
|
+
throw new ServiceError('PACKAGE_SOURCE_STALE', 'Package source revision changed', {
|
|
98
|
+
expectedRevision: input.expectedRevision,
|
|
99
|
+
observedRevision: snapshot.revision,
|
|
100
|
+
});
|
|
101
|
+
const results = indexedChecks(snapshot, input.manifest);
|
|
102
|
+
const read = async (relativePath) => {
|
|
103
|
+
const resolved = await this.engine.resolver.resolvePath(input.workspaceId, relativePath, 'read', ['mod', 'game', 'dependency', 'fixture'], input.principal);
|
|
104
|
+
const metadata = await stat(resolved.path);
|
|
105
|
+
if (!metadata.isFile() || metadata.size > MAX_FILE_BYTES)
|
|
106
|
+
throw new ServiceError('PACKAGE_FILE_LIMIT', 'Package file exceeds the bounded reader');
|
|
107
|
+
return { bytes: await readFile(resolved.path), path: resolved.path };
|
|
108
|
+
};
|
|
109
|
+
for (const registration of input.manifest.registrations) {
|
|
110
|
+
input.signal?.throwIfAborted();
|
|
111
|
+
try {
|
|
112
|
+
const file = await read(registration.path);
|
|
113
|
+
const present = registrationPresent(file.bytes, registration.path, registration.token);
|
|
114
|
+
results.push({
|
|
115
|
+
category: 'registration',
|
|
116
|
+
id: `${registration.path}:${registration.token}`,
|
|
117
|
+
status: present === undefined ? 'unresolved' : present ? 'present' : 'absent',
|
|
118
|
+
path: registration.path,
|
|
119
|
+
sha256: sha256Bytes(file.bytes),
|
|
120
|
+
...(present === undefined ? { reason: 'PACKAGE_REGISTRATION_UNSUPPORTED_SOURCE' } : {}),
|
|
121
|
+
});
|
|
122
|
+
}
|
|
123
|
+
catch (error) {
|
|
124
|
+
if (error.name === 'AbortError')
|
|
125
|
+
throw error;
|
|
126
|
+
results.push({
|
|
127
|
+
category: 'registration',
|
|
128
|
+
id: `${registration.path}:${registration.token}`,
|
|
129
|
+
status: error instanceof ServiceError && error.code === 'PATH_NOT_FOUND_IN_ROOTS'
|
|
130
|
+
? 'absent'
|
|
131
|
+
: 'unresolved',
|
|
132
|
+
reason: error instanceof ServiceError ? error.code : 'PACKAGE_FILE_READ',
|
|
133
|
+
});
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
for (const asset of input.manifest.assets) {
|
|
137
|
+
input.signal?.throwIfAborted();
|
|
138
|
+
try {
|
|
139
|
+
const file = await read(asset);
|
|
140
|
+
results.push({
|
|
141
|
+
category: 'asset',
|
|
142
|
+
id: asset,
|
|
143
|
+
status: 'present',
|
|
144
|
+
path: asset,
|
|
145
|
+
sha256: sha256Bytes(file.bytes),
|
|
146
|
+
});
|
|
147
|
+
}
|
|
148
|
+
catch (error) {
|
|
149
|
+
if (error.name === 'AbortError')
|
|
150
|
+
throw error;
|
|
151
|
+
results.push({
|
|
152
|
+
category: 'asset',
|
|
153
|
+
id: asset,
|
|
154
|
+
status: error instanceof ServiceError && error.code === 'PATH_NOT_FOUND_IN_ROOTS'
|
|
155
|
+
? 'absent'
|
|
156
|
+
: 'unresolved',
|
|
157
|
+
reason: error instanceof ServiceError ? error.code : 'PACKAGE_FILE_READ',
|
|
158
|
+
});
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
for (const required of input.manifest.requiredCases) {
|
|
162
|
+
input.signal?.throwIfAborted();
|
|
163
|
+
try {
|
|
164
|
+
const file = await read(required.suite);
|
|
165
|
+
const suite = scenarioSuiteSchema.parse(JSON.parse(file.bytes.toString('utf8')));
|
|
166
|
+
results.push({
|
|
167
|
+
category: 'required_case',
|
|
168
|
+
id: `${required.suite}:${required.caseId}`,
|
|
169
|
+
status: suite.cases.some(({ id }) => id === required.caseId) ? 'present' : 'absent',
|
|
170
|
+
path: required.suite,
|
|
171
|
+
sha256: sha256Bytes(file.bytes),
|
|
172
|
+
});
|
|
173
|
+
}
|
|
174
|
+
catch (error) {
|
|
175
|
+
if (error.name === 'AbortError')
|
|
176
|
+
throw error;
|
|
177
|
+
results.push({
|
|
178
|
+
category: 'required_case',
|
|
179
|
+
id: `${required.suite}:${required.caseId}`,
|
|
180
|
+
status: error instanceof ServiceError && error.code === 'PATH_NOT_FOUND_IN_ROOTS'
|
|
181
|
+
? 'absent'
|
|
182
|
+
: 'unresolved',
|
|
183
|
+
reason: error instanceof ServiceError ? error.code : 'PACKAGE_SUITE_INVALID',
|
|
184
|
+
});
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
const report = {
|
|
188
|
+
schemaVersion: 'package-check.v1',
|
|
189
|
+
packageId: input.manifest.id,
|
|
190
|
+
sourceRevision: snapshot.revision,
|
|
191
|
+
manifestHash: hashCanonical(input.manifest),
|
|
192
|
+
items: results,
|
|
193
|
+
complete: snapshot.complete && results.every(({ status }) => status === 'present'),
|
|
194
|
+
};
|
|
195
|
+
const reportHash = hashCanonical(report);
|
|
196
|
+
const evidence = analysisSourceEvidence(snapshot);
|
|
197
|
+
const artifact = await this.engine.artifacts.putChunked(workspace, `package-${reportHash.slice(0, 24)}.json`, 'application/json', `${canonicalJson(report)}\n`, {
|
|
198
|
+
kind: 'package-check',
|
|
199
|
+
toolVersion: PACKAGE_VERSION,
|
|
200
|
+
schemaVersion: 'package-check.v1',
|
|
201
|
+
sourceHashes: evidence.sourceHashes,
|
|
202
|
+
metadata: { beforeRevision: snapshot.revision, sourceInventory: evidence.inventory },
|
|
203
|
+
}, 'Revision-pinned package connection inventory', input.signal);
|
|
204
|
+
const result = emptyServiceResult(input.workspaceId, {
|
|
205
|
+
sourceRevision: snapshot.revision,
|
|
206
|
+
reportHash,
|
|
207
|
+
complete: report.complete,
|
|
208
|
+
items: results.length,
|
|
209
|
+
present: results.filter(({ status }) => status === 'present').length,
|
|
210
|
+
absent: results.filter(({ status }) => status === 'absent').length,
|
|
211
|
+
shadowed: results.filter(({ status }) => status === 'shadowed').length,
|
|
212
|
+
unresolved: results.filter(({ status }) => status === 'unresolved').length,
|
|
213
|
+
});
|
|
214
|
+
result.code = report.complete ? 'PACKAGE_CHECK_PASSED' : 'PACKAGE_CHECK_INCOMPLETE';
|
|
215
|
+
result.artifacts = [publicArtifactLink(artifact)];
|
|
216
|
+
setInlineFilesScanned(result, snapshot.files.map(({ displayPath }) => displayPath));
|
|
217
|
+
result.validation = {
|
|
218
|
+
passed: report.complete,
|
|
219
|
+
checks: [
|
|
220
|
+
{
|
|
221
|
+
id: 'package-connections',
|
|
222
|
+
passed: report.complete,
|
|
223
|
+
message: report.complete
|
|
224
|
+
? 'Every declared package connection is present'
|
|
225
|
+
: 'A declared package connection is absent, shadowed, or unresolved',
|
|
226
|
+
},
|
|
227
|
+
],
|
|
228
|
+
};
|
|
229
|
+
return result;
|
|
230
|
+
}
|
|
231
|
+
}
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { compareCodeUnits } from '../core/canonical.js';
|
|
2
|
+
import { scenarioFromProbability } from '../core/scenario-model.js';
|
|
2
3
|
import { assignments } from '../core/source/index.js';
|
|
3
4
|
import { Rational, sumRationals, uniformRaceProbabilities } from './rational.js';
|
|
4
5
|
import { withScenarioPathValue } from './scenario-state.js';
|
|
@@ -84,8 +85,9 @@ function externalFactor(surface, candidate, scenario) {
|
|
|
84
85
|
return { factor, trace, unresolved };
|
|
85
86
|
}
|
|
86
87
|
export function evaluateExactCandidates(surface, scenario, definitions) {
|
|
88
|
+
const declaredConditions = scenarioFromProbability(scenario);
|
|
87
89
|
const candidates = surface.candidates.map((candidate) => {
|
|
88
|
-
const eligibility = evaluateTriggerBlock(candidate.eligibilityBlock,
|
|
90
|
+
const eligibility = evaluateTriggerBlock(candidate.eligibilityBlock, declaredConditions, candidate, definitions);
|
|
89
91
|
const value = evaluateCandidateValue(candidate, scenario, definitions);
|
|
90
92
|
const external = externalFactor(surface, candidate, scenario);
|
|
91
93
|
const exactValue = eligibility.state === 'false'
|