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.
Files changed (87) hide show
  1. package/CHANGELOG.md +23 -0
  2. package/README.md +124 -28
  3. package/dist/hoi4_agent_tools/core/configuration.js +2 -0
  4. package/dist/hoi4_agent_tools/core/engine.js +7 -7
  5. package/dist/hoi4_agent_tools/core/file-glob.js +164 -0
  6. package/dist/hoi4_agent_tools/core/index-segments.js +4 -0
  7. package/dist/hoi4_agent_tools/core/job-operation-registration.js +31 -0
  8. package/dist/hoi4_agent_tools/core/job-store.js +19 -40
  9. package/dist/hoi4_agent_tools/core/job-worker-host.js +84 -19
  10. package/dist/hoi4_agent_tools/core/job-worker.js +8 -16
  11. package/dist/hoi4_agent_tools/core/operation-tasks.js +2 -7
  12. package/dist/hoi4_agent_tools/core/persistent-analysis-cache.js +37 -6
  13. package/dist/hoi4_agent_tools/core/scanner.js +136 -47
  14. package/dist/hoi4_agent_tools/core/shared-request-capacity.js +27 -13
  15. package/dist/hoi4_agent_tools/core/source/cache.js +4 -0
  16. package/dist/hoi4_agent_tools/core/windows-sharing.js +42 -0
  17. package/dist/hoi4_agent_tools/core/workspace.js +114 -8
  18. package/dist/hoi4_agent_tools/event/artifact-validation.js +1 -1
  19. package/dist/hoi4_agent_tools/event/service.js +1 -1
  20. package/dist/hoi4_agent_tools/focus/service.js +2 -2
  21. package/dist/hoi4_agent_tools/gui/studio.js +16 -3
  22. package/dist/hoi4_agent_tools/map/model.js +81 -2
  23. package/dist/hoi4_agent_tools/map/service.js +3 -2
  24. package/dist/hoi4_agent_tools/map/validation.js +60 -4
  25. package/dist/hoi4_agent_tools/mcp/tools/reference.js +10 -1
  26. package/dist/hoi4_agent_tools/package-check/service.js +1 -1
  27. package/dist/hoi4_agent_tools/reference/lines.js +12 -0
  28. package/dist/hoi4_agent_tools/reference/operations.js +45 -2
  29. package/dist/hoi4_agent_tools/reference/ranking.js +309 -0
  30. package/dist/hoi4_agent_tools/reference/script-validation.js +267 -0
  31. package/dist/hoi4_agent_tools/reference/service.js +285 -177
  32. package/dist/hoi4_agent_tools/reference/source-locate.js +156 -0
  33. package/dist/hoi4_agent_tools/reference/source-lookup.js +125 -18
  34. package/dist/hoi4_agent_tools/reference/source-navigation.js +130 -0
  35. package/dist/hoi4_agent_tools/reference/suggestions.js +38 -0
  36. package/dist/hoi4_agent_tools/schemas/reference.js +135 -2
  37. package/dist/hoi4_agent_tools/schemas/script-validation.js +69 -0
  38. package/dist/hoi4_agent_tools/schemas/transaction.js +1 -1
  39. package/dist/hoi4_agent_tools/technology/service.js +1 -1
  40. package/dist/hoi4_agent_tools/version.js +1 -1
  41. package/docs/README.md +1 -0
  42. package/docs/development.md +6 -0
  43. package/docs/examples.md +3 -13
  44. package/docs/gui.md +1 -15
  45. package/docs/map.md +10 -0
  46. package/docs/reference.md +47 -4
  47. package/docs/research/reference-benchmark.md +80 -0
  48. package/docs/setup.md +15 -0
  49. package/docs/tools.md +37 -36
  50. package/npm-shrinkwrap.json +7101 -0
  51. package/package.json +8 -7
  52. package/schemas/configuration.schema.json +95 -88
  53. package/schemas/continuous-focus-palette.schema.json +1 -1
  54. package/schemas/custom-weighted-pool.schema.json +1 -1
  55. package/schemas/decision-inspect-input.schema.json +1 -1
  56. package/schemas/event-feature-manifest.schema.json +1 -1
  57. package/schemas/focus-plan.schema.json +1 -1
  58. package/schemas/focus-planning-sidecar.schema.json +1 -1
  59. package/schemas/gui-animation-source.schema.json +1 -1
  60. package/schemas/gui-helper.schema.json +1 -1
  61. package/schemas/gui-scenario.schema.json +1 -1
  62. package/schemas/helper-expansion-request.schema.json +1 -1
  63. package/schemas/helper-expansion-summary.schema.json +1 -1
  64. package/schemas/impact-inspect-input.schema.json +1 -1
  65. package/schemas/map-operation.schema.json +1 -1
  66. package/schemas/mechanic-test-input.schema.json +1 -1
  67. package/schemas/operation-result.schema.json +1 -1
  68. package/schemas/package-check-input.schema.json +1 -1
  69. package/schemas/package-manifest.schema.json +1 -1
  70. package/schemas/probability-analysis-result.schema.json +1 -1
  71. package/schemas/probability-compare-input.schema.json +1 -1
  72. package/schemas/probability-evaluate-input.schema.json +1 -1
  73. package/schemas/probability-inspect-input.schema.json +1 -1
  74. package/schemas/probability-render-input.schema.json +1 -1
  75. package/schemas/probability-scenario-set.schema.json +1 -1
  76. package/schemas/probability-sequence-input.schema.json +1 -1
  77. package/schemas/probability-simulate-input.schema.json +1 -1
  78. package/schemas/probability-sweep-input.schema.json +1 -1
  79. package/schemas/reference-context-input.schema.json +1 -1
  80. package/schemas/reference-read-input.schema.json +9 -1
  81. package/schemas/reference-search-input.schema.json +1 -1
  82. package/schemas/scenario-suite.schema.json +1 -1
  83. package/schemas/scenario-test-input.schema.json +1 -1
  84. package/schemas/script-validate-input.schema.json +92 -0
  85. package/schemas/shared-scenario.schema.json +1 -1
  86. package/schemas/source-lookup-input.schema.json +144 -15
  87. 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 { readBoundedLines } from './lines.js';
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 snapshot = await engine.scan(workspaceId, {}, principal, signal);
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 definitions = snapshot.index.symbols
12
- .filter((entry) => entry.id === input.symbol && (input.kind === undefined || entry.kind === input.kind))
13
- .sort((left, right) => Number(left.overridden) - Number(right.overridden) || right.loadOrder - left.loadOrder)
14
- .slice(0, input.maxDefinitions)
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
- : readBoundedLines(lines, start ?? fromLine, fromLine, index === 0 ? (input.fromColumn ?? 1) : 1, input.maxLines, 2_000);
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
- const semantic = input.includeReferences
52
- ? scanImpactSemanticReferences(snapshot, 200_000, { includeOnActions: true })
53
- : undefined;
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 === input.symbol && (input.kind === undefined || entry.toKind === input.kind))
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
- return {
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 === input.symbol && (input.kind === undefined || entry.kind === input.kind)).length,
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
+ }