hoi4-agent-tools 3.6.0 → 3.8.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 +23 -0
- package/README.md +124 -28
- package/dist/hoi4_agent_tools/core/configuration.js +2 -0
- 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-store.js +19 -40
- package/dist/hoi4_agent_tools/core/job-worker-host.js +84 -19
- package/dist/hoi4_agent_tools/core/job-worker.js +8 -16
- package/dist/hoi4_agent_tools/core/operation-tasks.js +2 -7
- package/dist/hoi4_agent_tools/core/persistent-analysis-cache.js +37 -6
- package/dist/hoi4_agent_tools/core/scanner.js +136 -47
- package/dist/hoi4_agent_tools/core/shared-request-capacity.js +27 -13
- package/dist/hoi4_agent_tools/core/source/cache.js +4 -0
- package/dist/hoi4_agent_tools/core/windows-sharing.js +42 -0
- package/dist/hoi4_agent_tools/core/workspace.js +114 -8
- package/dist/hoi4_agent_tools/event/artifact-validation.js +1 -1
- package/dist/hoi4_agent_tools/event/service.js +1 -1
- package/dist/hoi4_agent_tools/focus/service.js +2 -2
- package/dist/hoi4_agent_tools/gui/studio.js +16 -3
- package/dist/hoi4_agent_tools/map/model.js +81 -2
- package/dist/hoi4_agent_tools/map/service.js +3 -2
- package/dist/hoi4_agent_tools/map/validation.js +60 -4
- package/dist/hoi4_agent_tools/mcp/tools/reference.js +10 -1
- package/dist/hoi4_agent_tools/package-check/service.js +1 -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/ranking.js +309 -0
- package/dist/hoi4_agent_tools/reference/script-validation.js +267 -0
- package/dist/hoi4_agent_tools/reference/service.js +285 -177
- package/dist/hoi4_agent_tools/reference/source-locate.js +156 -0
- package/dist/hoi4_agent_tools/reference/source-lookup.js +125 -18
- package/dist/hoi4_agent_tools/reference/source-navigation.js +130 -0
- package/dist/hoi4_agent_tools/reference/suggestions.js +38 -0
- package/dist/hoi4_agent_tools/schemas/reference.js +135 -2
- package/dist/hoi4_agent_tools/schemas/script-validation.js +69 -0
- package/dist/hoi4_agent_tools/schemas/transaction.js +1 -1
- package/dist/hoi4_agent_tools/technology/service.js +1 -1
- package/dist/hoi4_agent_tools/version.js +1 -1
- package/docs/README.md +1 -0
- package/docs/development.md +6 -0
- package/docs/examples.md +3 -13
- package/docs/gui.md +1 -15
- package/docs/map.md +10 -0
- package/docs/reference.md +47 -4
- package/docs/research/reference-benchmark.md +80 -0
- package/docs/setup.md +15 -0
- package/docs/tools.md +37 -36
- package/npm-shrinkwrap.json +7101 -0
- package/package.json +8 -7
- package/schemas/configuration.schema.json +95 -88
- 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 +144 -15
- package/server.json +2 -2
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Source position lookup: map a file and line to the indexed definitions that contain it and
|
|
3
|
+
* to the exact chain of Clausewitz assignments enclosing it.
|
|
4
|
+
*
|
|
5
|
+
* The returned `keyPath` is relative to the innermost containing definition, so an agent can
|
|
6
|
+
* pass that definition's `id`, `kind`, and `keyPath` to `hoi4.source_lookup` navigation. The
|
|
7
|
+
* chain is derived from parser nodes, preserving repeated keys through zero-based occurrences.
|
|
8
|
+
*/
|
|
9
|
+
import path from 'node:path';
|
|
10
|
+
import { ServiceError } from '../core/result.js';
|
|
11
|
+
import { isWithin } from '../core/workspace.js';
|
|
12
|
+
import { positionAt } from '../core/source/lexer.js';
|
|
13
|
+
import { parseClausewitz } from '../core/source/parser.js';
|
|
14
|
+
const MAX_OWNERS = 8;
|
|
15
|
+
const MAX_CHAIN = 24;
|
|
16
|
+
const MAX_KEY_BYTES = 128;
|
|
17
|
+
const MAX_LINE_TEXT = 300;
|
|
18
|
+
function samePath(left, right) {
|
|
19
|
+
return process.platform === 'win32' ? left.toLowerCase() === right.toLowerCase() : left === right;
|
|
20
|
+
}
|
|
21
|
+
/** Resolve a display, relative, or absolute path to one scanned file, preferring the active layer. */
|
|
22
|
+
function selectFile(snapshot, requested) {
|
|
23
|
+
const normalized = requested.replaceAll('\\', '/').replace(/^\.\//u, '');
|
|
24
|
+
const absolute = path.isAbsolute(requested) ? path.resolve(requested) : undefined;
|
|
25
|
+
const candidates = [...snapshot.index.files.values()].filter((file) => file.displayPath === normalized ||
|
|
26
|
+
file.relativePath.replaceAll('\\', '/') === normalized ||
|
|
27
|
+
(absolute !== undefined && samePath(path.resolve(file.absolutePath), absolute)));
|
|
28
|
+
if (candidates.length === 0)
|
|
29
|
+
throw new ServiceError('SOURCE_FILE_NOT_INDEXED', 'No indexed source file matches the path; use a mod-relative path such as events/example.txt');
|
|
30
|
+
return candidates.find(({ shadowedBy }) => shadowedBy === undefined) ?? candidates[0];
|
|
31
|
+
}
|
|
32
|
+
/**
|
|
33
|
+
* Scan only the requested file's folder family in every layer. Definitions and same-path
|
|
34
|
+
* overrides of a source position live beside it, so the whole workspace index is unnecessary.
|
|
35
|
+
*/
|
|
36
|
+
export function locatePatterns(engine, workspaceId, requested, principal) {
|
|
37
|
+
let relative = requested.replaceAll('\\', '/').replace(/^\.\//u, '');
|
|
38
|
+
if (path.isAbsolute(requested)) {
|
|
39
|
+
const absolute = path.resolve(requested);
|
|
40
|
+
const root = engine.resolver
|
|
41
|
+
.get(workspaceId, principal)
|
|
42
|
+
.roots.find((candidate) => isWithin(candidate.path, absolute));
|
|
43
|
+
if (root === undefined)
|
|
44
|
+
return undefined;
|
|
45
|
+
relative = path.relative(root.path, absolute).replaceAll('\\', '/');
|
|
46
|
+
}
|
|
47
|
+
else {
|
|
48
|
+
const label = /^[A-Za-z0-9_.-]+:(?!\/)(.+)$/u.exec(relative);
|
|
49
|
+
if (label !== null)
|
|
50
|
+
relative = label[1];
|
|
51
|
+
}
|
|
52
|
+
const extension = path.posix.extname(relative);
|
|
53
|
+
const directory = path.posix.dirname(relative);
|
|
54
|
+
if (relative.length === 0 ||
|
|
55
|
+
extension.length === 0 ||
|
|
56
|
+
/[*?[\]{}!]/u.test(relative) ||
|
|
57
|
+
relative.split('/').includes('..'))
|
|
58
|
+
return undefined;
|
|
59
|
+
return [directory === '.' ? `*${extension}` : `${directory}/**/*${extension}`];
|
|
60
|
+
}
|
|
61
|
+
function entryValue(entry) {
|
|
62
|
+
return entry.type === 'assignment' ? entry.value : entry;
|
|
63
|
+
}
|
|
64
|
+
export function locateSource(snapshot, requestedPath, line, requestedColumn) {
|
|
65
|
+
const file = selectFile(snapshot, requestedPath);
|
|
66
|
+
const document = parseClausewitz(file.bytes, file.displayPath);
|
|
67
|
+
const starts = document.lineIndex.lineStarts;
|
|
68
|
+
if (line > starts.length)
|
|
69
|
+
throw new ServiceError('SOURCE_LINE_OUTSIDE_FILE', 'Requested line is beyond the end of the file', {
|
|
70
|
+
lines: starts.length,
|
|
71
|
+
});
|
|
72
|
+
const lineStart = starts[line - 1];
|
|
73
|
+
const lineEnd = line < starts.length ? starts[line] : document.text.length;
|
|
74
|
+
const lineText = document.text.slice(lineStart, lineEnd).replace(/\r?\n$/u, '');
|
|
75
|
+
// Without a column, land on the first non-blank character so an indented line selects its
|
|
76
|
+
// own assignment rather than the enclosing block.
|
|
77
|
+
const column = requestedColumn ?? Math.max(1, lineText.search(/\S/u) + 1);
|
|
78
|
+
if (column > lineText.length + 1)
|
|
79
|
+
throw new ServiceError('SOURCE_COLUMN_OUTSIDE_LINE', 'Requested column is beyond the end of the line');
|
|
80
|
+
const offset = lineStart + column - 1;
|
|
81
|
+
const owners = snapshot.index.symbols
|
|
82
|
+
.filter((symbol) => symbol.path === file.displayPath &&
|
|
83
|
+
symbol.location !== undefined &&
|
|
84
|
+
symbol.location.start.offset <= offset &&
|
|
85
|
+
offset < Math.max(symbol.location.end.offset, symbol.location.start.offset + 1))
|
|
86
|
+
.sort((left, right) => left.location.end.offset -
|
|
87
|
+
left.location.start.offset -
|
|
88
|
+
(right.location.end.offset - right.location.start.offset) ||
|
|
89
|
+
right.location.start.offset - left.location.start.offset);
|
|
90
|
+
const structureAvailable = !document.diagnostics.some(({ severity }) => severity === 'error' || severity === 'blocker');
|
|
91
|
+
const steps = [];
|
|
92
|
+
if (structureAvailable) {
|
|
93
|
+
let block = document.root;
|
|
94
|
+
while (block !== undefined) {
|
|
95
|
+
const index = block.entries.findIndex((entry) => entry.start <= offset && offset < Math.max(entry.end, entry.start + 1));
|
|
96
|
+
if (index < 0)
|
|
97
|
+
break;
|
|
98
|
+
const entry = block.entries[index];
|
|
99
|
+
const key = entry.type === 'assignment' ? entry.key.value : null;
|
|
100
|
+
const occurrence = key === null
|
|
101
|
+
? 0
|
|
102
|
+
: block.entries
|
|
103
|
+
.slice(0, index)
|
|
104
|
+
.filter((candidate) => candidate.type === 'assignment' && candidate.key.value === key)
|
|
105
|
+
.length;
|
|
106
|
+
steps.push({ entry, key, occurrence, index });
|
|
107
|
+
const value = entryValue(entry);
|
|
108
|
+
block = value.type === 'block' && value !== block ? value : undefined;
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
const lineOf = (position) => positionAt(document.lineIndex, position).line;
|
|
112
|
+
const chain = steps.slice(0, MAX_CHAIN).map(({ entry, key, occurrence, index }) => ({
|
|
113
|
+
key: key !== null && Buffer.byteLength(key, 'utf8') <= MAX_KEY_BYTES ? key : null,
|
|
114
|
+
occurrence,
|
|
115
|
+
index,
|
|
116
|
+
startLine: lineOf(entry.start),
|
|
117
|
+
endLine: lineOf(Math.max(entry.start, entry.end - 1)),
|
|
118
|
+
}));
|
|
119
|
+
// The key path continues from the innermost definition that is itself a chain node.
|
|
120
|
+
let keyPath = null;
|
|
121
|
+
if (structureAvailable)
|
|
122
|
+
for (const owner of owners) {
|
|
123
|
+
const anchor = steps.findIndex(({ entry }) => entry.start === owner.location.start.offset && entry.end === owner.location.end.offset);
|
|
124
|
+
if (anchor < 0)
|
|
125
|
+
continue;
|
|
126
|
+
keyPath = steps
|
|
127
|
+
.slice(anchor + 1)
|
|
128
|
+
.map(({ key, occurrence, index }) => key !== null && Buffer.byteLength(key, 'utf8') <= MAX_KEY_BYTES
|
|
129
|
+
? { key, occurrence }
|
|
130
|
+
: { index });
|
|
131
|
+
break;
|
|
132
|
+
}
|
|
133
|
+
let text = lineText.slice(0, MAX_LINE_TEXT);
|
|
134
|
+
if (/[\uD800-\uDBFF]$/u.test(text))
|
|
135
|
+
text = text.slice(0, -1);
|
|
136
|
+
return {
|
|
137
|
+
path: file.displayPath,
|
|
138
|
+
rootKind: file.rootKind,
|
|
139
|
+
loadOrder: file.loadOrder,
|
|
140
|
+
shadowed: file.shadowedBy !== undefined,
|
|
141
|
+
line,
|
|
142
|
+
column,
|
|
143
|
+
text,
|
|
144
|
+
owners: owners.slice(0, MAX_OWNERS).map((owner) => ({
|
|
145
|
+
kind: owner.kind,
|
|
146
|
+
id: owner.id.slice(0, 1024),
|
|
147
|
+
startLine: owner.location.start.line,
|
|
148
|
+
endLine: owner.location.end.line,
|
|
149
|
+
overridden: owner.overridden,
|
|
150
|
+
})),
|
|
151
|
+
chain,
|
|
152
|
+
chainTruncated: steps.length > chain.length,
|
|
153
|
+
keyPath: keyPath === null || keyPath.length > MAX_CHAIN ? null : keyPath,
|
|
154
|
+
structureAvailable,
|
|
155
|
+
};
|
|
156
|
+
}
|
|
@@ -1,21 +1,96 @@
|
|
|
1
1
|
import { ServiceError } from '../core/result.js';
|
|
2
2
|
import { decodeSource } from '../core/source/encoding.js';
|
|
3
|
-
import {
|
|
3
|
+
import { readBoundedJsonLines } from './lines.js';
|
|
4
4
|
import { scanImpactSemanticReferences } from '../core/impact-semantic.js';
|
|
5
|
+
import { parseClausewitz } from '../core/source/parser.js';
|
|
6
|
+
import { navigateSource } from './source-navigation.js';
|
|
7
|
+
import { locatePatterns, locateSource } from './source-locate.js';
|
|
8
|
+
import { nearestNames } from './suggestions.js';
|
|
9
|
+
const semanticCache = new WeakMap();
|
|
5
10
|
/** Exact source definitions and usage over the existing shared Clausewitz index. */
|
|
6
11
|
export async function sourceLookup(engine, workspaceId, input, principal, signal) {
|
|
7
|
-
const
|
|
12
|
+
const narrowPatterns = {
|
|
13
|
+
event: ['events/**/*.txt'],
|
|
14
|
+
scripted_effect: ['common/scripted_effects/**/*.txt'],
|
|
15
|
+
scripted_trigger: ['common/scripted_triggers/**/*.txt'],
|
|
16
|
+
};
|
|
17
|
+
const patterns = input.kind === 'event' && input.includeReferences
|
|
18
|
+
? [
|
|
19
|
+
'events/**/*.txt',
|
|
20
|
+
'common/national_focus/**/*.txt',
|
|
21
|
+
'common/continuous_focus/**/*.txt',
|
|
22
|
+
'common/decisions/**/*.txt',
|
|
23
|
+
'common/ideas/**/*.txt',
|
|
24
|
+
'common/technologies/**/*.txt',
|
|
25
|
+
'common/scripted_effects/**/*.txt',
|
|
26
|
+
'common/scripted_triggers/**/*.txt',
|
|
27
|
+
'common/scripted_guis/**/*.txt',
|
|
28
|
+
'common/on_actions/**/*.txt',
|
|
29
|
+
]
|
|
30
|
+
: input.path !== undefined
|
|
31
|
+
? locatePatterns(engine, workspaceId, input.path, principal)
|
|
32
|
+
: input.includeReferences || input.kind === undefined
|
|
33
|
+
? undefined
|
|
34
|
+
: narrowPatterns[input.kind];
|
|
35
|
+
const snapshot = await engine.scan(workspaceId, patterns === undefined ? {} : { patterns }, principal, signal);
|
|
36
|
+
if (input.path !== undefined && input.line !== undefined)
|
|
37
|
+
return {
|
|
38
|
+
sourceScope: 'source_location',
|
|
39
|
+
revision: snapshot.revision,
|
|
40
|
+
complete: snapshot.complete,
|
|
41
|
+
skippedSourceCount: snapshot.skippedSourceCount,
|
|
42
|
+
definitionCount: 0,
|
|
43
|
+
definitionsTruncated: false,
|
|
44
|
+
referencesIncluded: false,
|
|
45
|
+
referenceCount: 0,
|
|
46
|
+
referencesTruncated: false,
|
|
47
|
+
referencesComplete: false,
|
|
48
|
+
unresolvedReferenceCount: 0,
|
|
49
|
+
location: locateSource(snapshot, input.path, input.line, input.column),
|
|
50
|
+
definitions: [],
|
|
51
|
+
references: [],
|
|
52
|
+
};
|
|
53
|
+
const symbol = input.symbol;
|
|
54
|
+
if (symbol === undefined)
|
|
55
|
+
throw new ServiceError('SOURCE_LOOKUP_TARGET_REQUIRED', 'Provide a symbol, or path and line');
|
|
8
56
|
if (input.expectedRevision !== undefined && input.expectedRevision !== snapshot.revision) {
|
|
9
57
|
throw new ServiceError('SOURCE_REVISION_STALE', 'Indexed source changed; look up the symbol again', { currentRevision: snapshot.revision });
|
|
10
58
|
}
|
|
11
|
-
const
|
|
12
|
-
|
|
13
|
-
.
|
|
14
|
-
.
|
|
59
|
+
const navigating = input.keyPath !== undefined || input.view === 'structure';
|
|
60
|
+
const matchedDefinitions = snapshot.index.symbols
|
|
61
|
+
.filter((entry) => entry.id === symbol && (input.kind === undefined || entry.kind === input.kind))
|
|
62
|
+
.sort((left, right) => Number(left.overridden) - Number(right.overridden) || right.loadOrder - left.loadOrder);
|
|
63
|
+
if (navigating && new Set(matchedDefinitions.map(({ kind }) => kind)).size > 1)
|
|
64
|
+
throw new ServiceError('SOURCE_KIND_AMBIGUOUS', 'Nested navigation requires kind when the identifier has multiple symbol kinds');
|
|
65
|
+
const definitions = matchedDefinitions
|
|
66
|
+
.slice(0, navigating ? 1 : input.maxDefinitions)
|
|
15
67
|
.map((entry, index) => {
|
|
16
68
|
const source = snapshot.index.files.get(entry.path);
|
|
17
69
|
const start = entry.location?.start.line;
|
|
18
70
|
const end = entry.location?.end.line;
|
|
71
|
+
const navigation = navigating
|
|
72
|
+
? (() => {
|
|
73
|
+
if (source === undefined ||
|
|
74
|
+
entry.location === undefined ||
|
|
75
|
+
/\.ya?ml$/iu.test(entry.path))
|
|
76
|
+
throw new ServiceError('SOURCE_STRUCTURE_UNAVAILABLE', 'Nested navigation requires an indexed Clausewitz definition');
|
|
77
|
+
return navigateSource(parseClausewitz(source.bytes, source.displayPath), {
|
|
78
|
+
start: entry.location.start.offset,
|
|
79
|
+
end: entry.location.end.offset,
|
|
80
|
+
}, input.keyPath ?? [], {
|
|
81
|
+
structure: input.view === 'structure',
|
|
82
|
+
maxChildren: input.maxChildren,
|
|
83
|
+
childOffset: input.childOffset,
|
|
84
|
+
maxLines: input.maxLines,
|
|
85
|
+
...(index === 0 && input.fromLine !== undefined
|
|
86
|
+
? { fromLine: input.fromLine }
|
|
87
|
+
: {}),
|
|
88
|
+
...(index === 0 && input.fromColumn !== undefined
|
|
89
|
+
? { fromColumn: input.fromColumn }
|
|
90
|
+
: {}),
|
|
91
|
+
});
|
|
92
|
+
})()
|
|
93
|
+
: undefined;
|
|
19
94
|
const fromLine = index === 0 ? (input.fromLine ?? start) : start;
|
|
20
95
|
if (fromLine !== undefined &&
|
|
21
96
|
start !== undefined &&
|
|
@@ -30,7 +105,7 @@ export async function sourceLookup(engine, workspaceId, input, principal, signal
|
|
|
30
105
|
.slice((start ?? fromLine) - 1, end);
|
|
31
106
|
const selected = fromLine === undefined || lines.length === 0
|
|
32
107
|
? null
|
|
33
|
-
:
|
|
108
|
+
: readBoundedJsonLines(lines, start ?? fromLine, fromLine, index === 0 ? (input.fromColumn ?? 1) : 1, input.maxLines, 2_000, 4_096);
|
|
34
109
|
return {
|
|
35
110
|
kind: entry.kind,
|
|
36
111
|
id: entry.id,
|
|
@@ -41,20 +116,23 @@ export async function sourceLookup(engine, workspaceId, input, principal, signal
|
|
|
41
116
|
sourceShadowed: entry.sourceShadowed,
|
|
42
117
|
startLine: start ?? null,
|
|
43
118
|
endLine: end ?? null,
|
|
44
|
-
fromLine: fromLine ?? null,
|
|
45
|
-
toLine: selected?.endLine ?? null,
|
|
46
|
-
nextLine: selected?.nextLine ?? null,
|
|
47
|
-
nextColumn: selected?.nextColumn ?? null,
|
|
48
|
-
text: selected?.text ?? '',
|
|
119
|
+
fromLine: navigation?.fromLine ?? fromLine ?? null,
|
|
120
|
+
toLine: navigation?.endLine ?? selected?.endLine ?? null,
|
|
121
|
+
nextLine: navigation === undefined ? (selected?.nextLine ?? null) : navigation.nextLine,
|
|
122
|
+
nextColumn: navigation === undefined ? (selected?.nextColumn ?? null) : navigation.nextColumn,
|
|
123
|
+
text: navigation?.text ?? selected?.text ?? '',
|
|
124
|
+
...(navigation === undefined ? {} : { navigation: navigation.navigation }),
|
|
49
125
|
};
|
|
50
126
|
});
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
:
|
|
127
|
+
let semantic = input.includeReferences ? semanticCache.get(snapshot) : undefined;
|
|
128
|
+
if (input.includeReferences && semantic === undefined) {
|
|
129
|
+
semantic = scanImpactSemanticReferences(snapshot, 200_000, { includeOnActions: true });
|
|
130
|
+
semanticCache.set(snapshot, semantic);
|
|
131
|
+
}
|
|
54
132
|
const referenceIdentities = new Set();
|
|
55
133
|
const matchedReferences = input.includeReferences
|
|
56
134
|
? [...snapshot.index.references, ...(semantic?.references ?? [])]
|
|
57
|
-
.filter((entry) => entry.to ===
|
|
135
|
+
.filter((entry) => entry.to === symbol && (input.kind === undefined || entry.toKind === input.kind))
|
|
58
136
|
.filter((entry) => {
|
|
59
137
|
const key = `${entry.path}:${entry.location?.start.offset ?? 0}:${entry.toKind}:${entry.to}:${entry.kind}`;
|
|
60
138
|
if (referenceIdentities.has(key))
|
|
@@ -70,11 +148,26 @@ export async function sourceLookup(engine, workspaceId, input, principal, signal
|
|
|
70
148
|
path: entry.path,
|
|
71
149
|
line: entry.location?.start.line ?? null,
|
|
72
150
|
}));
|
|
73
|
-
|
|
151
|
+
const result = {
|
|
152
|
+
sourceScope: input.kind === 'event'
|
|
153
|
+
? input.includeReferences
|
|
154
|
+
? 'event_consumers'
|
|
155
|
+
: 'event_definitions'
|
|
156
|
+
: patterns === undefined
|
|
157
|
+
? 'workspace_index'
|
|
158
|
+
: 'helper_definitions',
|
|
74
159
|
revision: snapshot.revision,
|
|
75
160
|
complete: snapshot.complete,
|
|
76
161
|
skippedSourceCount: snapshot.skippedSourceCount,
|
|
77
|
-
definitionCount: snapshot.index.symbols.filter((entry) => entry.id ===
|
|
162
|
+
definitionCount: snapshot.index.symbols.filter((entry) => entry.id === symbol && (input.kind === undefined || entry.kind === input.kind)).length,
|
|
163
|
+
definitionsTruncated: matchedDefinitions.length > definitions.length,
|
|
164
|
+
...(matchedDefinitions.length === 0
|
|
165
|
+
? {
|
|
166
|
+
suggestions: nearestNames(symbol, new Set(snapshot.index.symbols
|
|
167
|
+
.filter((entry) => input.kind === undefined || entry.kind === input.kind)
|
|
168
|
+
.map(({ id }) => id))),
|
|
169
|
+
}
|
|
170
|
+
: {}),
|
|
78
171
|
definitions,
|
|
79
172
|
references,
|
|
80
173
|
referencesIncluded: input.includeReferences,
|
|
@@ -87,4 +180,18 @@ export async function sourceLookup(engine, workspaceId, input, principal, signal
|
|
|
87
180
|
matchedReferences.length <= references.length,
|
|
88
181
|
unresolvedReferenceCount: semantic?.unresolved.length ?? 0,
|
|
89
182
|
};
|
|
183
|
+
while (Buffer.byteLength(JSON.stringify(result), 'utf8') > 24_000 &&
|
|
184
|
+
result.references.length > 0) {
|
|
185
|
+
result.references.pop();
|
|
186
|
+
result.referencesTruncated = true;
|
|
187
|
+
result.referencesComplete = false;
|
|
188
|
+
}
|
|
189
|
+
while (Buffer.byteLength(JSON.stringify(result), 'utf8') > 24_000 &&
|
|
190
|
+
result.definitions.length > 1) {
|
|
191
|
+
result.definitions.pop();
|
|
192
|
+
result.definitionsTruncated = true;
|
|
193
|
+
}
|
|
194
|
+
if (Buffer.byteLength(JSON.stringify(result), 'utf8') > 24_000)
|
|
195
|
+
throw new ServiceError('REFERENCE_RESPONSE_TOO_LARGE', 'Selected source metadata exceeds the bounded reply; use entry-index selectors for long nested keys');
|
|
196
|
+
return result;
|
|
90
197
|
}
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
import { ServiceError } from '../core/result.js';
|
|
2
|
+
import { locationFor } from '../core/source/lexer.js';
|
|
3
|
+
import { readBoundedJsonLines } from './lines.js';
|
|
4
|
+
function preview(text, limit) {
|
|
5
|
+
let end = Math.min(text.length, limit);
|
|
6
|
+
if (end > 0 &&
|
|
7
|
+
/[\uD800-\uDBFF]/u.test(text[end - 1]) &&
|
|
8
|
+
/[\uDC00-\uDFFF]/u.test(text[end] ?? ''))
|
|
9
|
+
end--;
|
|
10
|
+
return { text: text.slice(0, end), truncated: end < text.length };
|
|
11
|
+
}
|
|
12
|
+
function findEntry(block, range) {
|
|
13
|
+
for (const entry of block.entries) {
|
|
14
|
+
if (entry.start === range.start && entry.end === range.end)
|
|
15
|
+
return entry;
|
|
16
|
+
const value = entry.type === 'assignment' ? entry.value : entry;
|
|
17
|
+
if (value.type !== 'block' || value.start > range.start || value.end < range.end)
|
|
18
|
+
continue;
|
|
19
|
+
if (value.start === range.start && value.end === range.end)
|
|
20
|
+
return value;
|
|
21
|
+
const found = findEntry(value, range);
|
|
22
|
+
if (found !== undefined)
|
|
23
|
+
return found;
|
|
24
|
+
}
|
|
25
|
+
return undefined;
|
|
26
|
+
}
|
|
27
|
+
/** Navigate actual parser nodes, preserving duplicate keys and exact source boundaries. */
|
|
28
|
+
export function navigateSource(document, range, keyPath, options) {
|
|
29
|
+
if (document.diagnostics.some(({ severity }) => severity === 'error' || severity === 'blocker'))
|
|
30
|
+
throw new ServiceError('SOURCE_STRUCTURE_UNAVAILABLE', 'Cannot navigate a source document with parse errors');
|
|
31
|
+
const definition = findEntry(document.root, range);
|
|
32
|
+
if (definition === undefined)
|
|
33
|
+
throw new ServiceError('SOURCE_STRUCTURE_UNAVAILABLE', 'Indexed definition does not identify an exact parser node');
|
|
34
|
+
let selected = definition;
|
|
35
|
+
const resolvedPath = [];
|
|
36
|
+
for (const step of keyPath) {
|
|
37
|
+
const value = selected.type === 'assignment' ? selected.value : selected;
|
|
38
|
+
if (value.type !== 'block')
|
|
39
|
+
throw new ServiceError('SOURCE_PATH_NOT_BLOCK', 'Cannot navigate inside a scalar source value', { keyPath: resolvedPath });
|
|
40
|
+
if ('index' in step) {
|
|
41
|
+
const child = value.entries[step.index];
|
|
42
|
+
if (child === undefined)
|
|
43
|
+
throw new ServiceError('SOURCE_INDEX_UNKNOWN', 'No source child exists at the requested entry index', { index: step.index });
|
|
44
|
+
selected = child;
|
|
45
|
+
resolvedPath.push({ index: step.index });
|
|
46
|
+
continue;
|
|
47
|
+
}
|
|
48
|
+
const matches = value.entries.filter((entry) => entry.type === 'assignment' && entry.key.value === step.key);
|
|
49
|
+
if (step.occurrence === undefined && matches.length > 1)
|
|
50
|
+
throw new ServiceError('SOURCE_PATH_AMBIGUOUS', 'Repeated key requires an explicit zero-based occurrence', { key: step.key, occurrences: matches.length });
|
|
51
|
+
const occurrence = step.occurrence ?? 0;
|
|
52
|
+
const child = matches[occurrence];
|
|
53
|
+
if (child === undefined)
|
|
54
|
+
throw new ServiceError('SOURCE_PATH_UNKNOWN', 'No source child matches the requested key and occurrence', { key: step.key, occurrence });
|
|
55
|
+
selected = child;
|
|
56
|
+
resolvedPath.push(Buffer.byteLength(step.key, 'utf8') > 128
|
|
57
|
+
? { index: value.entries.indexOf(child) }
|
|
58
|
+
: { key: step.key, occurrence });
|
|
59
|
+
}
|
|
60
|
+
const location = locationFor(document.path, document.lineIndex, selected.start, selected.end);
|
|
61
|
+
const fromLine = options.fromLine ?? location.start.line;
|
|
62
|
+
const fromColumn = options.fromColumn ?? (fromLine === location.start.line ? location.start.column : 1);
|
|
63
|
+
if (fromLine < location.start.line ||
|
|
64
|
+
fromLine > location.end.line ||
|
|
65
|
+
(fromLine === location.start.line && fromColumn < location.start.column) ||
|
|
66
|
+
(fromLine === location.end.line && fromColumn > location.end.column))
|
|
67
|
+
throw new ServiceError('SOURCE_POSITION_OUTSIDE_SELECTION', 'Requested position is outside the selected parser node');
|
|
68
|
+
const lines = document.text.split(/\r?\n/u).slice(location.start.line - 1, location.end.line);
|
|
69
|
+
lines[lines.length - 1] = lines.at(-1).slice(0, location.end.column - 1);
|
|
70
|
+
const read = options.structure
|
|
71
|
+
? { text: '', endLine: location.end.line, nextLine: null, nextColumn: null }
|
|
72
|
+
: readBoundedJsonLines(lines, location.start.line, fromLine, fromColumn, options.maxLines, 2000, 4096);
|
|
73
|
+
const value = selected.type === 'assignment' ? selected.value : selected;
|
|
74
|
+
const entries = options.structure && value.type === 'block' ? value.entries : [];
|
|
75
|
+
const occurrences = new Map();
|
|
76
|
+
if (options.structure && options.childOffset > entries.length)
|
|
77
|
+
throw new ServiceError('SOURCE_CHILD_OFFSET_OUTSIDE_SELECTION', 'Child offset exceeds the selected block');
|
|
78
|
+
for (const entry of entries.slice(0, options.childOffset)) {
|
|
79
|
+
const key = entry.type === 'assignment' ? entry.key.value : null;
|
|
80
|
+
occurrences.set(key, (occurrences.get(key) ?? 0) + 1);
|
|
81
|
+
}
|
|
82
|
+
const candidates = entries
|
|
83
|
+
.slice(options.childOffset, options.childOffset + options.maxChildren)
|
|
84
|
+
.map((entry, index) => {
|
|
85
|
+
const key = entry.type === 'assignment' ? entry.key.value : null;
|
|
86
|
+
const occurrence = occurrences.get(key) ?? 0;
|
|
87
|
+
occurrences.set(key, occurrence + 1);
|
|
88
|
+
const childValue = entry.type === 'assignment' ? entry.value : entry;
|
|
89
|
+
const childLocation = locationFor(document.path, document.lineIndex, entry.start, entry.end);
|
|
90
|
+
const keyPreview = key === null ? null : preview(key, 1024);
|
|
91
|
+
const valuePreview = childValue.type === 'scalar' ? preview(childValue.value, 120) : null;
|
|
92
|
+
return {
|
|
93
|
+
key: keyPreview?.text ?? null,
|
|
94
|
+
index: options.childOffset + index,
|
|
95
|
+
keyTruncated: keyPreview?.truncated ?? false,
|
|
96
|
+
occurrence,
|
|
97
|
+
kind: childValue.type,
|
|
98
|
+
value: valuePreview?.text ?? null,
|
|
99
|
+
valueTruncated: valuePreview?.truncated ?? false,
|
|
100
|
+
startLine: childLocation.start.line,
|
|
101
|
+
endLine: childLocation.end.line,
|
|
102
|
+
childCount: childValue.type === 'block' ? childValue.entries.length : 0,
|
|
103
|
+
};
|
|
104
|
+
});
|
|
105
|
+
const children = [];
|
|
106
|
+
let childBytes = 2;
|
|
107
|
+
for (const child of candidates) {
|
|
108
|
+
const bytes = Buffer.byteLength(JSON.stringify(child), 'utf8') + 1;
|
|
109
|
+
if (childBytes + bytes > 6000)
|
|
110
|
+
break;
|
|
111
|
+
children.push(child);
|
|
112
|
+
childBytes += bytes;
|
|
113
|
+
}
|
|
114
|
+
return {
|
|
115
|
+
...read,
|
|
116
|
+
fromLine,
|
|
117
|
+
navigation: {
|
|
118
|
+
keyPath: resolvedPath,
|
|
119
|
+
startLine: location.start.line,
|
|
120
|
+
startColumn: location.start.column,
|
|
121
|
+
endLine: location.end.line,
|
|
122
|
+
endColumn: location.end.column,
|
|
123
|
+
children,
|
|
124
|
+
omittedChildren: Math.max(0, entries.length - options.childOffset - children.length),
|
|
125
|
+
nextChildOffset: options.childOffset + children.length < entries.length
|
|
126
|
+
? options.childOffset + children.length
|
|
127
|
+
: null,
|
|
128
|
+
},
|
|
129
|
+
};
|
|
130
|
+
}
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
/** Near-miss identifier suggestions shared by source lookup and script checks. */
|
|
2
|
+
const MAX_DISTANCE = 3;
|
|
3
|
+
/** Levenshtein distance, cut off above three edits because larger distances are not suggested. */
|
|
4
|
+
export function editDistance(a, b) {
|
|
5
|
+
let row = Array.from({ length: b.length + 1 }, (_, index) => index);
|
|
6
|
+
for (let i = 0; i < a.length; i++) {
|
|
7
|
+
const next = [i + 1];
|
|
8
|
+
for (let j = 0; j < b.length; j++)
|
|
9
|
+
next.push(Math.min(next[j] + 1, row[j + 1] + 1, row[j] + Number(a[i] !== b[j])));
|
|
10
|
+
if (Math.min(...next) > MAX_DISTANCE)
|
|
11
|
+
return MAX_DISTANCE + 1;
|
|
12
|
+
row = next;
|
|
13
|
+
}
|
|
14
|
+
return row[b.length];
|
|
15
|
+
}
|
|
16
|
+
/**
|
|
17
|
+
* The closest candidates within three edits, nearest first. `budget` bounds the number of
|
|
18
|
+
* distance computations, so a very large index cannot make a missed lookup expensive.
|
|
19
|
+
*/
|
|
20
|
+
export function nearestNames(target, candidates, options = {}) {
|
|
21
|
+
if (target.length > 128)
|
|
22
|
+
return [];
|
|
23
|
+
let budget = options.budget ?? 20_000;
|
|
24
|
+
const scored = [];
|
|
25
|
+
for (const candidate of candidates) {
|
|
26
|
+
if (candidate === target || Math.abs(candidate.length - target.length) > MAX_DISTANCE)
|
|
27
|
+
continue;
|
|
28
|
+
if (budget-- <= 0)
|
|
29
|
+
break;
|
|
30
|
+
const distance = editDistance(target, candidate);
|
|
31
|
+
if (distance <= MAX_DISTANCE)
|
|
32
|
+
scored.push({ candidate, distance });
|
|
33
|
+
}
|
|
34
|
+
return scored
|
|
35
|
+
.sort((a, b) => a.distance - b.distance || a.candidate.localeCompare(b.candidate, 'en'))
|
|
36
|
+
.slice(0, options.limit ?? 5)
|
|
37
|
+
.map(({ candidate }) => candidate);
|
|
38
|
+
}
|