hoi4-agent-tools 3.6.0 → 3.7.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 +11 -0
- package/README.md +122 -28
- package/dist/hoi4_agent_tools/core/engine.js +7 -7
- package/dist/hoi4_agent_tools/core/file-glob.js +164 -0
- package/dist/hoi4_agent_tools/core/index-segments.js +4 -0
- package/dist/hoi4_agent_tools/core/job-operation-registration.js +31 -0
- package/dist/hoi4_agent_tools/core/job-worker-host.js +27 -11
- package/dist/hoi4_agent_tools/core/job-worker.js +8 -16
- package/dist/hoi4_agent_tools/core/persistent-analysis-cache.js +37 -6
- package/dist/hoi4_agent_tools/core/scanner.js +133 -47
- package/dist/hoi4_agent_tools/core/source/cache.js +4 -0
- package/dist/hoi4_agent_tools/map/service.js +2 -2
- package/dist/hoi4_agent_tools/mcp/tools/reference.js +10 -1
- package/dist/hoi4_agent_tools/reference/lines.js +12 -0
- package/dist/hoi4_agent_tools/reference/operations.js +45 -2
- package/dist/hoi4_agent_tools/reference/script-validation.js +277 -0
- package/dist/hoi4_agent_tools/reference/service.js +139 -67
- package/dist/hoi4_agent_tools/reference/source-lookup.js +91 -15
- package/dist/hoi4_agent_tools/reference/source-navigation.js +130 -0
- package/dist/hoi4_agent_tools/schemas/reference.js +55 -0
- package/dist/hoi4_agent_tools/schemas/script-validation.js +69 -0
- package/dist/hoi4_agent_tools/version.js +1 -1
- package/docs/README.md +1 -0
- package/docs/examples.md +1 -11
- package/docs/gui.md +1 -15
- package/docs/reference.md +43 -2
- package/docs/research/reference-benchmark.md +41 -0
- package/docs/tools.md +37 -36
- package/npm-shrinkwrap.json +7088 -0
- package/package.json +7 -6
- 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/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/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 +9 -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 +92 -0
- package/schemas/shared-scenario.schema.json +1 -1
- package/schemas/source-lookup-input.schema.json +68 -2
- package/server.json +2 -2
|
@@ -0,0 +1,277 @@
|
|
|
1
|
+
import path from 'node:path';
|
|
2
|
+
import { hashCanonical } from '../core/canonical.js';
|
|
3
|
+
import { ServiceError } from '../core/result.js';
|
|
4
|
+
import { locationFor, parseClausewitz, } from '../core/source/index.js';
|
|
5
|
+
const MAX_COMMANDS = 256;
|
|
6
|
+
const booleanCommands = new Set(['AND', 'OR', 'NOT', 'XOR', 'NAND', 'NOR']);
|
|
7
|
+
const containers = new Set([
|
|
8
|
+
'and',
|
|
9
|
+
'or',
|
|
10
|
+
'not',
|
|
11
|
+
'xor',
|
|
12
|
+
'nand',
|
|
13
|
+
'nor',
|
|
14
|
+
'hidden_trigger',
|
|
15
|
+
'hidden_effect',
|
|
16
|
+
]);
|
|
17
|
+
const conditionals = new Set(['if', 'else_if', 'else']);
|
|
18
|
+
const iteratorParameters = new Set([
|
|
19
|
+
'tooltip',
|
|
20
|
+
'random_select_amount',
|
|
21
|
+
'display_individual_scopes',
|
|
22
|
+
'count',
|
|
23
|
+
]);
|
|
24
|
+
function editDistance(a, b) {
|
|
25
|
+
let row = Array.from({ length: b.length + 1 }, (_, index) => index);
|
|
26
|
+
for (let i = 0; i < a.length; i++) {
|
|
27
|
+
const next = [i + 1];
|
|
28
|
+
for (let j = 0; j < b.length; j++)
|
|
29
|
+
next.push(Math.min(next[j] + 1, row[j + 1] + 1, row[j] + Number(a[i] !== b[j])));
|
|
30
|
+
if (Math.min(...next) > 3)
|
|
31
|
+
return 4;
|
|
32
|
+
row = next;
|
|
33
|
+
}
|
|
34
|
+
return row[b.length];
|
|
35
|
+
}
|
|
36
|
+
/** Check only rules explicitly available from one selected documentation authority. */
|
|
37
|
+
export async function validateScript(references, workspace, input, signal) {
|
|
38
|
+
if (Buffer.byteLength(input.source, 'utf8') > 64_000)
|
|
39
|
+
throw new ServiceError('SCRIPT_SOURCE_TOO_LARGE', 'Snippet exceeds 64000 UTF-8 bytes');
|
|
40
|
+
const inventory = await references.inventory(workspace, signal, [input.documentation]);
|
|
41
|
+
const catalogs = { effect: new Map(), trigger: new Map() };
|
|
42
|
+
const sources = new Map();
|
|
43
|
+
for (const section of inventory.sections) {
|
|
44
|
+
const filename = path.basename(section.path).toLowerCase();
|
|
45
|
+
const match = /^(effects?|triggers?)_(?:documentation|docs)\.(?:md|log)$/u.exec(filename);
|
|
46
|
+
if (match === null)
|
|
47
|
+
continue;
|
|
48
|
+
sources.set(section.path, section.revision);
|
|
49
|
+
if (!/^[a-z_][a-z0-9_]{0,255}$/u.test(section.heading))
|
|
50
|
+
continue;
|
|
51
|
+
const scopeLine = /^\s*(?:\*\s*)?Supported Scopes:\s*(.+?)\s*$/imu.exec(section.lines.join('\n'));
|
|
52
|
+
if (scopeLine === null || !/^[a-z_,\s]+$/iu.test(scopeLine[1]))
|
|
53
|
+
continue;
|
|
54
|
+
const scopes = [
|
|
55
|
+
...new Set(scopeLine[1]
|
|
56
|
+
.toLowerCase()
|
|
57
|
+
.split(/[,\s]+/u)
|
|
58
|
+
.filter(Boolean)),
|
|
59
|
+
].sort();
|
|
60
|
+
const kind = match[1].startsWith('effect') ? 'effect' : 'trigger';
|
|
61
|
+
const previous = catalogs[kind].get(section.heading);
|
|
62
|
+
const { lines: _lines, ...reference } = section;
|
|
63
|
+
if (previous !== undefined) {
|
|
64
|
+
previous.conflict ||= previous.scopes.join(',') !== scopes.join(',');
|
|
65
|
+
}
|
|
66
|
+
else
|
|
67
|
+
catalogs[kind].set(section.heading, { reference, scopes, conflict: false });
|
|
68
|
+
}
|
|
69
|
+
const document = parseClausewitz(Buffer.from(input.source, 'utf8'), 'snippet');
|
|
70
|
+
const findings = [];
|
|
71
|
+
let visited = 0;
|
|
72
|
+
let checkedCommands = 0;
|
|
73
|
+
let argumentBlocksUnchecked = 0;
|
|
74
|
+
let suggestionBudget = 4000;
|
|
75
|
+
const bindings = new Map();
|
|
76
|
+
for (const binding of input.bindings) {
|
|
77
|
+
if (bindings.has(binding.name))
|
|
78
|
+
throw new ServiceError('SCRIPT_BINDING_DUPLICATE', 'Scope bindings must have unique names');
|
|
79
|
+
if (binding.name === 'ROOT' || binding.name === 'THIS' || binding.name === 'PREV')
|
|
80
|
+
throw new ServiceError('SCRIPT_BINDING_RESERVED', 'ROOT, THIS and PREV follow the supplied scope and traversal stack');
|
|
81
|
+
bindings.set(binding.name, binding.scope);
|
|
82
|
+
}
|
|
83
|
+
const report = (entry, kind, scope, details) => {
|
|
84
|
+
const position = locationFor('snippet', document.lineIndex, entry.start, entry.end).start;
|
|
85
|
+
findings.push({
|
|
86
|
+
...details,
|
|
87
|
+
command: entry.key.value.slice(0, 256),
|
|
88
|
+
kind,
|
|
89
|
+
scope,
|
|
90
|
+
line: position.line,
|
|
91
|
+
column: position.column,
|
|
92
|
+
});
|
|
93
|
+
};
|
|
94
|
+
for (const diagnostic of document.diagnostics)
|
|
95
|
+
findings.push({
|
|
96
|
+
code: diagnostic.code,
|
|
97
|
+
status: 'error',
|
|
98
|
+
command: '',
|
|
99
|
+
kind: input.kind,
|
|
100
|
+
scope: input.scope,
|
|
101
|
+
line: diagnostic.location?.start.line ?? 1,
|
|
102
|
+
column: diagnostic.location?.start.column ?? 1,
|
|
103
|
+
message: diagnostic.message.slice(0, 500),
|
|
104
|
+
suggestions: [],
|
|
105
|
+
});
|
|
106
|
+
const walk = (block, kind, scope, previous) => {
|
|
107
|
+
for (const entry of block.entries) {
|
|
108
|
+
signal?.throwIfAborted();
|
|
109
|
+
if (++visited > MAX_COMMANDS) {
|
|
110
|
+
return;
|
|
111
|
+
}
|
|
112
|
+
if (entry.type !== 'assignment') {
|
|
113
|
+
const position = locationFor('snippet', document.lineIndex, entry.start, entry.end).start;
|
|
114
|
+
findings.push({
|
|
115
|
+
code: 'SCRIPT_EXPECTED_COMMAND',
|
|
116
|
+
status: 'error',
|
|
117
|
+
command: '',
|
|
118
|
+
kind,
|
|
119
|
+
scope,
|
|
120
|
+
line: position.line,
|
|
121
|
+
column: position.column,
|
|
122
|
+
message: 'Expected a command assignment in this effect or trigger body',
|
|
123
|
+
suggestions: [],
|
|
124
|
+
});
|
|
125
|
+
continue;
|
|
126
|
+
}
|
|
127
|
+
const raw = entry.key.value;
|
|
128
|
+
if (raw.startsWith('@'))
|
|
129
|
+
continue;
|
|
130
|
+
const name = booleanCommands.has(raw) ? raw.toLowerCase() : raw;
|
|
131
|
+
const explicitScope = raw === 'ROOT'
|
|
132
|
+
? input.scope
|
|
133
|
+
: raw === 'THIS'
|
|
134
|
+
? scope
|
|
135
|
+
: raw === 'PREV'
|
|
136
|
+
? previous
|
|
137
|
+
: bindings.get(raw);
|
|
138
|
+
const fixedScope = /^[A-Z][A-Z0-9]{2}$/u.test(raw) &&
|
|
139
|
+
!['ROOT', 'THIS', 'PREV', 'FROM', 'AND', 'NOT', 'XOR', 'NOR'].includes(raw)
|
|
140
|
+
? 'country'
|
|
141
|
+
: /^\d+$/u.test(raw)
|
|
142
|
+
? 'state'
|
|
143
|
+
: undefined;
|
|
144
|
+
if (entry.value.type === 'block' &&
|
|
145
|
+
(explicitScope !== undefined || fixedScope !== undefined)) {
|
|
146
|
+
walk(entry.value, kind, explicitScope ?? fixedScope, scope);
|
|
147
|
+
continue;
|
|
148
|
+
}
|
|
149
|
+
const signature = catalogs[kind].get(name);
|
|
150
|
+
if (signature === undefined) {
|
|
151
|
+
const other = catalogs[kind === 'effect' ? 'trigger' : 'effect'].get(name);
|
|
152
|
+
const suggestions = name.length > 128
|
|
153
|
+
? []
|
|
154
|
+
: [...catalogs[kind].keys()]
|
|
155
|
+
.filter((candidate) => Math.abs(candidate.length - name.length) <= 3)
|
|
156
|
+
.filter(() => suggestionBudget-- > 0)
|
|
157
|
+
.map((candidate) => ({ candidate, distance: editDistance(name, candidate) }))
|
|
158
|
+
.filter(({ distance }) => distance <= 3)
|
|
159
|
+
.sort((a, b) => a.distance - b.distance || a.candidate.localeCompare(b.candidate, 'en'))
|
|
160
|
+
.slice(0, 3)
|
|
161
|
+
.map(({ candidate }) => candidate);
|
|
162
|
+
report(entry, kind, scope, {
|
|
163
|
+
code: other === undefined ? 'SCRIPT_COMMAND_UNRESOLVED' : 'SCRIPT_COMMAND_WRONG_KIND',
|
|
164
|
+
status: other === undefined ? 'unresolved' : 'error',
|
|
165
|
+
message: other === undefined
|
|
166
|
+
? 'Not documented as a native command in the selected source; inspect scripted helpers or declare the scope binding'
|
|
167
|
+
: `Documented as a ${kind === 'effect' ? 'trigger' : 'effect'}, not a ${kind}`,
|
|
168
|
+
suggestions,
|
|
169
|
+
...(other === undefined ? {} : { reference: other.reference }),
|
|
170
|
+
});
|
|
171
|
+
continue;
|
|
172
|
+
}
|
|
173
|
+
checkedCommands++;
|
|
174
|
+
const wrongScope = !signature.scopes.includes('any') && !signature.scopes.includes(scope);
|
|
175
|
+
report(entry, kind, scope, {
|
|
176
|
+
code: signature.conflict
|
|
177
|
+
? 'SCRIPT_DOCUMENTATION_CONFLICT'
|
|
178
|
+
: wrongScope
|
|
179
|
+
? 'SCRIPT_COMMAND_WRONG_SCOPE'
|
|
180
|
+
: 'SCRIPT_COMMAND_SUPPORTED',
|
|
181
|
+
status: signature.conflict ? 'unresolved' : wrongScope ? 'error' : 'supported',
|
|
182
|
+
suggestions: [],
|
|
183
|
+
reference: signature.reference,
|
|
184
|
+
message: signature.conflict
|
|
185
|
+
? 'Conflicting supported scopes in the selected documentation'
|
|
186
|
+
: wrongScope
|
|
187
|
+
? `Supported scopes: ${signature.scopes.join(', ')}`
|
|
188
|
+
: 'Command kind and declared scope match the selected documentation',
|
|
189
|
+
});
|
|
190
|
+
const iterator = /^(?:every|random|any|all)_(?:(?:owned|controlled|neighbor)_)?(state|country)$/u.exec(name);
|
|
191
|
+
if (entry.value.type !== 'block') {
|
|
192
|
+
if (containers.has(name) || conditionals.has(name) || iterator !== null)
|
|
193
|
+
report(entry, kind, scope, {
|
|
194
|
+
code: 'SCRIPT_EXPECTED_BLOCK',
|
|
195
|
+
status: 'error',
|
|
196
|
+
message: 'This control command requires a block',
|
|
197
|
+
suggestions: [],
|
|
198
|
+
reference: signature.reference,
|
|
199
|
+
});
|
|
200
|
+
continue;
|
|
201
|
+
}
|
|
202
|
+
if (containers.has(name)) {
|
|
203
|
+
walk(entry.value, kind, scope, previous);
|
|
204
|
+
continue;
|
|
205
|
+
}
|
|
206
|
+
if (conditionals.has(name) || iterator !== null) {
|
|
207
|
+
const childScope = iterator?.[1] ?? scope;
|
|
208
|
+
for (const child of entry.value.entries) {
|
|
209
|
+
if (child.type === 'assignment' && child.key.value === 'limit') {
|
|
210
|
+
if (child.value.type === 'block')
|
|
211
|
+
walk(child.value, 'trigger', childScope, iterator === null ? previous : scope);
|
|
212
|
+
else
|
|
213
|
+
report(child, 'trigger', childScope, {
|
|
214
|
+
code: 'SCRIPT_EXPECTED_BLOCK',
|
|
215
|
+
status: 'error',
|
|
216
|
+
message: 'limit requires a trigger block',
|
|
217
|
+
suggestions: [],
|
|
218
|
+
});
|
|
219
|
+
}
|
|
220
|
+
else if (iterator !== null &&
|
|
221
|
+
child.type === 'assignment' &&
|
|
222
|
+
iteratorParameters.has(child.key.value)) {
|
|
223
|
+
argumentBlocksUnchecked++;
|
|
224
|
+
}
|
|
225
|
+
else
|
|
226
|
+
walk({ type: 'block', entries: [child], start: child.start, end: child.end }, kind, childScope, iterator === null ? previous : scope);
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
else {
|
|
230
|
+
argumentBlocksUnchecked++;
|
|
231
|
+
report(entry, kind, scope, {
|
|
232
|
+
code: 'SCRIPT_ARGUMENT_BLOCK_UNCHECKED',
|
|
233
|
+
status: 'unresolved',
|
|
234
|
+
message: 'Native argument block is not validated from prose documentation; read the cited syntax before use',
|
|
235
|
+
suggestions: [],
|
|
236
|
+
reference: signature.reference,
|
|
237
|
+
});
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
};
|
|
241
|
+
if (document.diagnostics.every(({ severity }) => severity !== 'error' && severity !== 'blocker'))
|
|
242
|
+
walk(document.root, input.kind, input.scope);
|
|
243
|
+
const unresolvedCount = findings.filter(({ status }) => status === 'unresolved').length;
|
|
244
|
+
const invalid = findings.some(({ status }) => status === 'error');
|
|
245
|
+
const truncated = visited > MAX_COMMANDS;
|
|
246
|
+
const incomplete = unresolvedCount > 0 || truncated || inventory.skipped > 0 || catalogs[input.kind].size === 0;
|
|
247
|
+
const order = { error: 0, unresolved: 1, supported: 2 };
|
|
248
|
+
findings.sort((a, b) => order[a.status] - order[b.status] ||
|
|
249
|
+
a.line - b.line ||
|
|
250
|
+
a.column - b.column ||
|
|
251
|
+
a.code.localeCompare(b.code, 'en'));
|
|
252
|
+
const result = {
|
|
253
|
+
valid: invalid ? false : incomplete ? null : true,
|
|
254
|
+
checksPerformed: ['syntax', 'command_kind', 'declared_scope'],
|
|
255
|
+
parametersChecked: false,
|
|
256
|
+
documentation: {
|
|
257
|
+
source: input.documentation,
|
|
258
|
+
revision: hashCanonical([...sources].sort()),
|
|
259
|
+
effectCount: catalogs.effect.size,
|
|
260
|
+
triggerCount: catalogs.trigger.size,
|
|
261
|
+
skippedSources: inventory.skipped,
|
|
262
|
+
},
|
|
263
|
+
findings: findings.slice(0, input.limit),
|
|
264
|
+
totalFindings: findings.length,
|
|
265
|
+
omittedFindings: Math.max(0, findings.length - input.limit),
|
|
266
|
+
checkedCommands,
|
|
267
|
+
unresolvedCount,
|
|
268
|
+
truncated,
|
|
269
|
+
argumentBlocksUnchecked,
|
|
270
|
+
};
|
|
271
|
+
while (Buffer.byteLength(JSON.stringify(result), 'utf8') > 16_000 && result.findings.length > 1)
|
|
272
|
+
result.findings.pop();
|
|
273
|
+
result.omittedFindings = result.totalFindings - result.findings.length;
|
|
274
|
+
if (Buffer.byteLength(JSON.stringify(result), 'utf8') > 16_000)
|
|
275
|
+
throw new ServiceError('REFERENCE_RESPONSE_TOO_LARGE', 'Command documentation metadata exceeds the bounded reply');
|
|
276
|
+
return result;
|
|
277
|
+
}
|
|
@@ -1,15 +1,29 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { realpath } from 'node:fs/promises';
|
|
2
2
|
import path from 'node:path';
|
|
3
|
-
import
|
|
3
|
+
import { globFiles } from '../core/file-glob.js';
|
|
4
4
|
import { compareCodeUnits, sha256Bytes } from '../core/canonical.js';
|
|
5
5
|
import { ServiceError } from '../core/result.js';
|
|
6
6
|
import { isWithin } from '../core/workspace.js';
|
|
7
|
-
import {
|
|
8
|
-
import {
|
|
7
|
+
import { prepareSource, verifySource, readBoundedFile, } from '../core/scanner.js';
|
|
8
|
+
import { decodeSource } from '../core/source/encoding.js';
|
|
9
|
+
import { readBoundedJsonLines } from './lines.js';
|
|
9
10
|
const MAX_FILES = 512;
|
|
10
11
|
const MAX_FILE_BYTES = 2_000_000;
|
|
11
12
|
const MAX_TOTAL_BYTES = 32_000_000;
|
|
12
13
|
const MAX_SECTIONS = 20_000;
|
|
14
|
+
const MAX_REFERENCE_METADATA_BYTES = 24_000;
|
|
15
|
+
function referenceText(bytes) {
|
|
16
|
+
if (bytes.length >= 2 &&
|
|
17
|
+
((bytes[0] === 0xff && bytes[1] === 0xfe) || (bytes[0] === 0xfe && bytes[1] === 0xff))) {
|
|
18
|
+
const payload = Buffer.from(bytes.subarray(2));
|
|
19
|
+
if (payload.length % 2 !== 0)
|
|
20
|
+
throw new ServiceError('REFERENCE_ENCODING_INVALID', 'UTF-16 reference text has an incomplete code unit');
|
|
21
|
+
if (bytes[0] === 0xfe)
|
|
22
|
+
payload.swap16();
|
|
23
|
+
return payload.toString('utf16le');
|
|
24
|
+
}
|
|
25
|
+
return decodeSource(bytes).text;
|
|
26
|
+
}
|
|
13
27
|
const searchStopWords = new Set([
|
|
14
28
|
'a',
|
|
15
29
|
'an',
|
|
@@ -78,7 +92,7 @@ function sectionize(root, filePath, content, revision) {
|
|
|
78
92
|
const relative = path.relative(root.root, filePath).replaceAll('\\', '/');
|
|
79
93
|
const title = path
|
|
80
94
|
.basename(filePath)
|
|
81
|
-
.replace(/\.(md|log)$/
|
|
95
|
+
.replace(/\.(md|log)$/iu, '')
|
|
82
96
|
.replaceAll('_', ' ')
|
|
83
97
|
.replace(/ - Hearts of Iron 4 Wiki$/u, '');
|
|
84
98
|
const headings = [];
|
|
@@ -95,7 +109,7 @@ function sectionize(root, filePath, content, revision) {
|
|
|
95
109
|
if (match)
|
|
96
110
|
headings.push({ heading: match[2].replace(/<a\b[^>]*><\/a>/gu, '').trim(), start: index });
|
|
97
111
|
else if (root.kind === 'script_doc' &&
|
|
98
|
-
filePath.endsWith('.log') &&
|
|
112
|
+
filePath.toLowerCase().endsWith('.log') &&
|
|
99
113
|
/^[A-Za-z_][A-Za-z0-9_.:-]*$/u.test(line.trim()) &&
|
|
100
114
|
!/^\s/u.test(line) &&
|
|
101
115
|
/^\s+\S/u.test(lines[index + 1] ?? '')) {
|
|
@@ -222,16 +236,33 @@ export class ReferenceService {
|
|
|
222
236
|
this.#cacheBytes -= oldest[1].bytes;
|
|
223
237
|
}
|
|
224
238
|
}
|
|
225
|
-
async inventory(workspace, signal) {
|
|
239
|
+
async inventory(workspace, signal, sources) {
|
|
226
240
|
const sections = [];
|
|
227
241
|
const coverage = {
|
|
228
|
-
game_doc: {
|
|
229
|
-
|
|
230
|
-
|
|
242
|
+
game_doc: {
|
|
243
|
+
files: 0,
|
|
244
|
+
sections: 0,
|
|
245
|
+
unavailable: true,
|
|
246
|
+
included: sources === undefined || sources.includes('game_doc'),
|
|
247
|
+
},
|
|
248
|
+
wiki: {
|
|
249
|
+
files: 0,
|
|
250
|
+
sections: 0,
|
|
251
|
+
unavailable: true,
|
|
252
|
+
included: sources === undefined || sources.includes('wiki'),
|
|
253
|
+
},
|
|
254
|
+
script_doc: {
|
|
255
|
+
files: 0,
|
|
256
|
+
sections: 0,
|
|
257
|
+
unavailable: true,
|
|
258
|
+
included: sources === undefined || sources.includes('script_doc'),
|
|
259
|
+
},
|
|
231
260
|
};
|
|
232
261
|
let skipped = 0;
|
|
233
262
|
let bytes = 0;
|
|
234
263
|
for (const root of sourceRoots(workspace)) {
|
|
264
|
+
if (sources !== undefined && !sources.includes(root.kind))
|
|
265
|
+
continue;
|
|
235
266
|
signal?.throwIfAborted();
|
|
236
267
|
let canonicalRoot;
|
|
237
268
|
try {
|
|
@@ -250,80 +281,104 @@ export class ReferenceService {
|
|
|
250
281
|
throw new ServiceError('REFERENCE_ROOT_ESCAPE', 'Reference root resolves outside its configured workspace');
|
|
251
282
|
}
|
|
252
283
|
coverage[root.kind].unavailable = false;
|
|
253
|
-
const names = await
|
|
284
|
+
const names = await globFiles(root.patterns, {
|
|
254
285
|
cwd: canonicalRoot,
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
dot: false,
|
|
286
|
+
caseSensitive: false,
|
|
287
|
+
...(signal === undefined ? {} : { signal }),
|
|
258
288
|
});
|
|
259
289
|
names.sort(compareCodeUnits);
|
|
260
|
-
for (
|
|
290
|
+
for (let offset = 0; offset < names.length; offset += 8) {
|
|
261
291
|
signal?.throwIfAborted();
|
|
262
292
|
if (coverage[root.kind].files >= MAX_FILES || sections.length >= MAX_SECTIONS) {
|
|
263
|
-
skipped
|
|
264
|
-
|
|
265
|
-
}
|
|
266
|
-
const candidate = path.join(canonicalRoot, name);
|
|
267
|
-
const canonical = await realpath(candidate);
|
|
268
|
-
if (!isWithin(canonicalRoot, canonical)) {
|
|
269
|
-
skipped++;
|
|
270
|
-
continue;
|
|
271
|
-
}
|
|
272
|
-
const metadata = await stat(canonical);
|
|
273
|
-
if (metadata.size > MAX_FILE_BYTES || bytes + metadata.size > MAX_TOTAL_BYTES) {
|
|
274
|
-
skipped++;
|
|
275
|
-
continue;
|
|
293
|
+
skipped += names.length - offset;
|
|
294
|
+
break;
|
|
276
295
|
}
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
const
|
|
280
|
-
const handle = await open(canonical, 'r');
|
|
281
|
-
let content;
|
|
296
|
+
const readRoot = { path: canonicalRoot };
|
|
297
|
+
const prepared = await Promise.allSettled(names.slice(offset, offset + 8).map((name) => prepareSource(readRoot, name, signal)));
|
|
298
|
+
const opened = prepared.flatMap((entry) => entry.status === 'fulfilled' ? [entry.value] : []);
|
|
282
299
|
try {
|
|
283
|
-
|
|
284
|
-
const
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
300
|
+
signal?.throwIfAborted();
|
|
301
|
+
for (const entry of prepared) {
|
|
302
|
+
if (entry.status !== 'rejected')
|
|
303
|
+
continue;
|
|
304
|
+
if (entry.reason instanceof ServiceError && entry.reason.code === 'SCAN_ROOT_ESCAPE') {
|
|
305
|
+
skipped++;
|
|
306
|
+
continue;
|
|
307
|
+
}
|
|
290
308
|
throw new ServiceError('REFERENCE_SOURCE_CHANGED', 'Reference path changed while opening it; retry the request');
|
|
291
309
|
}
|
|
292
|
-
|
|
310
|
+
const admittedSources = [];
|
|
311
|
+
for (const source of opened) {
|
|
312
|
+
if (!source.metadata.isFile() ||
|
|
313
|
+
source.metadata.size > MAX_FILE_BYTES ||
|
|
314
|
+
bytes + source.metadata.size > MAX_TOTAL_BYTES ||
|
|
315
|
+
coverage[root.kind].files >= MAX_FILES) {
|
|
316
|
+
skipped++;
|
|
317
|
+
continue;
|
|
318
|
+
}
|
|
319
|
+
bytes += source.metadata.size;
|
|
320
|
+
coverage[root.kind].files++;
|
|
321
|
+
admittedSources.push(source);
|
|
322
|
+
}
|
|
323
|
+
const reads = await Promise.allSettled(admittedSources.map(async (source) => {
|
|
324
|
+
const content = await readBoundedFile(source.handle, source.metadata.size, signal);
|
|
325
|
+
await verifySource(readRoot, source);
|
|
326
|
+
if (content.length !== source.metadata.size)
|
|
327
|
+
throw new ServiceError('REFERENCE_SOURCE_CHANGED', 'Reference changed while it was being read; retry the request');
|
|
328
|
+
return content;
|
|
329
|
+
}));
|
|
330
|
+
signal?.throwIfAborted();
|
|
331
|
+
const failed = reads.find((entry) => entry.status === 'rejected');
|
|
332
|
+
if (failed?.status === 'rejected')
|
|
333
|
+
throw new ServiceError('REFERENCE_SOURCE_CHANGED', 'Reference changed while it was being read; retry the request');
|
|
334
|
+
for (const [index, read] of reads.entries()) {
|
|
335
|
+
if (read.status !== 'fulfilled')
|
|
336
|
+
continue;
|
|
337
|
+
const canonical = admittedSources[index].canonicalPath;
|
|
338
|
+
const content = read.value;
|
|
339
|
+
const key = `${root.kind}:${canonicalRoot}:${canonical}`;
|
|
340
|
+
const revision = sha256Bytes(content);
|
|
341
|
+
let cached = this.#cache.get(key);
|
|
342
|
+
if (cached?.revision !== revision)
|
|
343
|
+
cached = {
|
|
344
|
+
revision,
|
|
345
|
+
bytes: content.length,
|
|
346
|
+
sections: sectionize(root, canonical, referenceText(content), revision),
|
|
347
|
+
};
|
|
348
|
+
this.retain(key, cached);
|
|
349
|
+
const admitted = cached.sections.slice(0, MAX_SECTIONS - sections.length);
|
|
350
|
+
if (admitted.length < cached.sections.length)
|
|
351
|
+
skipped++;
|
|
352
|
+
sections.push(...admitted);
|
|
353
|
+
coverage[root.kind].sections += admitted.length;
|
|
354
|
+
}
|
|
293
355
|
}
|
|
294
356
|
finally {
|
|
295
|
-
await handle.close();
|
|
357
|
+
await Promise.all(opened.map(({ handle }) => handle.close()));
|
|
296
358
|
}
|
|
297
|
-
bytes += content.length - metadata.size;
|
|
298
|
-
const revision = sha256Bytes(content);
|
|
299
|
-
let cached = this.#cache.get(key);
|
|
300
|
-
if (cached?.revision !== revision) {
|
|
301
|
-
cached = {
|
|
302
|
-
revision,
|
|
303
|
-
bytes: content.length,
|
|
304
|
-
sections: sectionize(root, canonical, content.toString('utf8'), revision),
|
|
305
|
-
};
|
|
306
|
-
}
|
|
307
|
-
this.retain(key, cached);
|
|
308
|
-
const admitted = cached.sections.slice(0, MAX_SECTIONS - sections.length);
|
|
309
|
-
if (admitted.length < cached.sections.length)
|
|
310
|
-
skipped++;
|
|
311
|
-
sections.push(...admitted);
|
|
312
|
-
coverage[root.kind].sections += admitted.length;
|
|
313
359
|
}
|
|
314
360
|
}
|
|
315
361
|
return { sections, coverage, skipped };
|
|
316
362
|
}
|
|
317
363
|
async search(workspace, input, signal) {
|
|
318
|
-
const inventory = await this.inventory(workspace, signal);
|
|
364
|
+
const inventory = await this.inventory(workspace, signal, input.sources);
|
|
319
365
|
const matches = this.rank(inventory.sections, input.query, input.sources);
|
|
366
|
+
const results = matches.slice(0, input.limit).map(({ section, score, excerpt }) => ({
|
|
367
|
+
...this.publicSection(section),
|
|
368
|
+
score,
|
|
369
|
+
excerpt,
|
|
370
|
+
}));
|
|
371
|
+
const requested = results.length;
|
|
372
|
+
while (Buffer.byteLength(JSON.stringify(results), 'utf8') > MAX_REFERENCE_METADATA_BYTES &&
|
|
373
|
+
results.length > 1)
|
|
374
|
+
results.pop();
|
|
375
|
+
if (Buffer.byteLength(JSON.stringify(results), 'utf8') > MAX_REFERENCE_METADATA_BYTES)
|
|
376
|
+
throw new ServiceError('REFERENCE_RESPONSE_TOO_LARGE', 'Reference metadata exceeds the bounded reply');
|
|
320
377
|
return {
|
|
321
|
-
results
|
|
322
|
-
...this.publicSection(section),
|
|
323
|
-
score,
|
|
324
|
-
excerpt,
|
|
325
|
-
})),
|
|
378
|
+
results,
|
|
326
379
|
total: matches.length,
|
|
380
|
+
omitted: matches.length - results.length,
|
|
381
|
+
limitedByBytes: results.length < requested,
|
|
327
382
|
coverage: inventory.coverage,
|
|
328
383
|
skipped: inventory.skipped,
|
|
329
384
|
};
|
|
@@ -364,7 +419,17 @@ export class ReferenceService {
|
|
|
364
419
|
left.section.startLine - right.section.startLine);
|
|
365
420
|
}
|
|
366
421
|
async read(workspace, input, signal) {
|
|
367
|
-
|
|
422
|
+
let source = input.source;
|
|
423
|
+
if (source === undefined) {
|
|
424
|
+
for (const cached of this.#cache.values()) {
|
|
425
|
+
const section = cached.sections.find(({ id }) => id === input.id);
|
|
426
|
+
if (section !== undefined) {
|
|
427
|
+
source = section.source;
|
|
428
|
+
break;
|
|
429
|
+
}
|
|
430
|
+
}
|
|
431
|
+
}
|
|
432
|
+
const inventory = await this.inventory(workspace, signal, source === undefined ? undefined : [source]);
|
|
368
433
|
const section = inventory.sections.find(({ id }) => id === input.id);
|
|
369
434
|
if (section === undefined)
|
|
370
435
|
throw new ServiceError('REFERENCE_SECTION_UNKNOWN', 'Reference section is not in this workspace');
|
|
@@ -373,7 +438,7 @@ export class ReferenceService {
|
|
|
373
438
|
currentRevision: section.revision,
|
|
374
439
|
});
|
|
375
440
|
const startLine = input.startLine ?? section.startLine;
|
|
376
|
-
const selected =
|
|
441
|
+
const selected = readBoundedJsonLines(section.lines, section.startLine, startLine, input.startColumn ?? 1, input.maxLines, 8_000, 8_192);
|
|
377
442
|
return {
|
|
378
443
|
...this.publicSection(section),
|
|
379
444
|
sectionStartLine: section.startLine,
|
|
@@ -422,13 +487,20 @@ export class ReferenceService {
|
|
|
422
487
|
return true;
|
|
423
488
|
});
|
|
424
489
|
const selected = prioritized.slice(0, input.limit);
|
|
490
|
+
const requestedCount = selected.length;
|
|
491
|
+
while (Buffer.byteLength(JSON.stringify(selected.map((section) => this.publicSection(section))), 'utf8') > MAX_REFERENCE_METADATA_BYTES &&
|
|
492
|
+
selected.length > 1)
|
|
493
|
+
selected.pop();
|
|
494
|
+
if (Buffer.byteLength(JSON.stringify(selected.map((section) => this.publicSection(section))), 'utf8') > MAX_REFERENCE_METADATA_BYTES)
|
|
495
|
+
throw new ServiceError('REFERENCE_RESPONSE_TOO_LARGE', 'Reference metadata exceeds the bounded reply');
|
|
425
496
|
return {
|
|
426
497
|
surface: input.surface,
|
|
427
498
|
sections: selected.map((section) => ({
|
|
428
499
|
...this.publicSection(section),
|
|
429
500
|
excerpt: ranked.find((entry) => entry.section.id === section.id)?.excerpt ?? section.excerpt,
|
|
430
501
|
})),
|
|
431
|
-
omitted: Math.max(0, prioritized.length -
|
|
502
|
+
omitted: Math.max(0, prioritized.length - selected.length),
|
|
503
|
+
limitedByBytes: selected.length < requestedCount,
|
|
432
504
|
omittedSources: required
|
|
433
505
|
.filter(({ section }) => !selected.some((entry) => entry.path === section.path && entry.source === section.source))
|
|
434
506
|
.map(({ name }) => name),
|