@tikoci/centrs 0.1.9-next.23 → 0.1.9-next.24

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 (53) hide show
  1. package/CHANGELOG.md +106 -0
  2. package/GLOSSARY.txt +14 -0
  3. package/README.md +2 -2
  4. package/commands/api/AGENTS.md +18 -1
  5. package/commands/api/README.md +10 -6
  6. package/commands/api/examples.md +1 -1
  7. package/commands/execute/README.md +26 -6
  8. package/commands/execute/examples.md +24 -5
  9. package/commands/explain/README.md +452 -61
  10. package/commands/explain/examples.md +191 -41
  11. package/commands/retrieve/README.md +13 -2
  12. package/commands/retrieve/examples.md +30 -1
  13. package/commands/transfer/README.md +8 -4
  14. package/docs/CLI.md +19 -5
  15. package/docs/CONSTITUTION.md +14 -9
  16. package/docs/MATRIX.md +34 -21
  17. package/docs/errors/cmr/caller-policy-not-enforced.md +5 -3
  18. package/docs/errors/routeros/permission-denied.md +24 -0
  19. package/docs/errors/transport/edge-auth-failed.md +22 -0
  20. package/docs/errors/transport/write-outcome-unknown.md +3 -0
  21. package/docs/errors/usage/invalid-cursor.md +20 -0
  22. package/docs/errors/validation/unknown-path.md +9 -0
  23. package/package.json +2 -2
  24. package/src/api.ts +34 -39
  25. package/src/cli/explain.ts +206 -47
  26. package/src/core/envelope.ts +10 -3
  27. package/src/core/error-catalog.ts +15 -0
  28. package/src/core/execute-command.ts +53 -0
  29. package/src/core/fanout.ts +12 -2
  30. package/src/core/inspect.ts +4 -5
  31. package/src/core/routeros-errors.ts +25 -0
  32. package/src/execute-cmr.ts +2 -8
  33. package/src/execute.ts +48 -20
  34. package/src/explain/args.ts +115 -10
  35. package/src/explain/group-faults.ts +162 -29
  36. package/src/explain/operators.ts +2 -1
  37. package/src/explain/scope-brace.ts +226 -11
  38. package/src/explain/segment.ts +136 -1
  39. package/src/explain/symbols.ts +146 -6
  40. package/src/explain/values.ts +55 -4
  41. package/src/explain-live-facets.ts +739 -0
  42. package/src/explain-live-types.ts +490 -0
  43. package/src/explain-live.ts +593 -0
  44. package/src/explain.ts +395 -43
  45. package/src/index.ts +6 -0
  46. package/src/protocols/adapter.ts +88 -10
  47. package/src/protocols/mac-telnet.ts +38 -10
  48. package/src/protocols/native-api.ts +13 -12
  49. package/src/retrieve-follow.ts +2 -1
  50. package/src/retrieve-sample.ts +2 -1
  51. package/src/retrieve-wait.ts +10 -6
  52. package/src/retrieve.ts +77 -27
  53. package/src/transfer.ts +38 -13
@@ -0,0 +1,490 @@
1
+ /**
2
+ * Live value types for explain (#225): `observedType` from a visible `:parse`
3
+ * conversion and `schemaType` from argument-level `syntax`. Read-only; nothing
4
+ * the user wrote is evaluated, and no `:typeof` is sent.
5
+ *
6
+ * ## observedType: only a conversion is evidence
7
+ *
8
+ * `:parse` prints a string unquoted, so `:local a 123` and `:local b "123"`
9
+ * both render `value=123`, and `"2.2"` renders `2.2`. A rendering therefore
10
+ * names a type only when the device CHANGED the literal: `2.2` → `2.0.0.2`
11
+ * (ip), `0x10` → `16` and `0123` → `123` (num), `yes` → `true` (bool),
12
+ * `200ms` → `00:00:00.200` and `1:2` → `01:02:00` (time), `1:0:0::1` →
13
+ * `1::1` (ip6). An unchanged rendering (`123`, `1.1.1.1`, `true`, `abc`,
14
+ * `100000w`) is an abstention, as is a rendering no type explains (`$abc`, a
15
+ * member read as a variable; `(- 1)`, a member read as an expression).
16
+ *
17
+ * The literal is not located in the IL of the user's input: strings print raw,
18
+ * so a quoted `"x;y=1"` is indistinguishable from two arguments there. Each
19
+ * bare literal is instead re-parsed alone, in a probe centrs builds from a
20
+ * restricted byte set: `:local vN <lit>` for a scripting operand and
21
+ * `:local vN {<lit>}` for an array member, joined by `;` into one escaped
22
+ * `:put [:parse "…"]`. `:put <lit>` converts identically to `:local`. One
23
+ * hard-error member (`{*1}`) rejects its whole batch, so members and operands
24
+ * are separate batches and a rejected batch abstains with its reason.
25
+ *
26
+ * A scripting directive's named argument converts exactly as an operand
27
+ * (`:for i from=0x10` → `from=16`, `:put message=yes` → `message=true`), so it
28
+ * joins the operand batch. Its NAME slots never convert (see
29
+ * {@link isVariableName}).
30
+ *
31
+ * Menu-command arguments are excluded. Their values are converted by the
32
+ * argument's own parser, which prints a per-argument encoding
33
+ * (`src-address=!1.1.1.1` → `true;1.1.1.1`, `address=2.2` → `2.0.0.2;`), and a
34
+ * quoted value converts only at run time (`address="2.2"` stays `2.2`). The
35
+ * slot is `schemaType`'s.
36
+ *
37
+ * Grounded on CHR 7.24.5 and 7.23.8 over REST and native API, identical on
38
+ * both builds; the raw matrix is in `test/fixtures/explain/live-types.*.json`
39
+ * and its reading is pinned in `test/unit/explain-live-types.test.ts`.
40
+ *
41
+ * ## schemaType: the argument's declaration, normalized
42
+ *
43
+ * One `syntax` probe per distinct (command, argument) pair, at the ARGUMENT
44
+ * path only (a command path hangs REST on 7.12.2 and 7.20.8; see
45
+ * `explain-live-facets.ts`). The argument name is resolved against the
46
+ * command's live `child` names, unique prefixes included, as RouterOS does.
47
+ * The rows are a small grammar (`[!]Range`, `Address[%Interface][@VRF]`); the
48
+ * projection maps each leaf to {@link SCHEMA_VALUE_TYPES} and abstains when a
49
+ * leaf has no mapping (`see documentation`) or the declaration is blank, as
50
+ * every flag and boolean is. A listed word set is `enum` even when the words
51
+ * are the current configuration names (`ether1 | lo`): the device prints both
52
+ * the same way, so the live projection never emits `name`. Keyword descriptions (`tcp` → `transmission
53
+ * control protocol`) are help for a parent keyword, not leaves.
54
+ */
55
+ import { isIP } from "node:net";
56
+ import { pathTokens } from "./core/inspect.ts";
57
+ import { routerOsStringLiteral } from "./core/routeros-string.ts";
58
+ import type { ObservedValueType, SchemaValueType } from "./explain/values.ts";
59
+ import type {
60
+ ExplainSchemaTypeFact,
61
+ ExplainSpanRange,
62
+ ExplainStatement,
63
+ ExplainSubcommand,
64
+ ExplainSyntaxRow,
65
+ ExplainValueOccurrence,
66
+ ExplainValueTypeFact,
67
+ } from "./explain.ts";
68
+ import type { LiveProbeContext } from "./explain-live.ts";
69
+
70
+ /** Hard bounds. Work past them is skipped with a reason, never truncated silently. */
71
+ export const EXPLAIN_TYPE_LIMITS = {
72
+ /** Literals per `:parse` batch. */
73
+ literals: 256,
74
+ /** Argument-level `syntax` probes per call. */
75
+ syntaxProbes: 32,
76
+ } as const;
77
+
78
+ /**
79
+ * The bytes a probe literal may hold. No quote, `$`, bracket, brace, paren,
80
+ * `;`, `=` or space, so the literal cannot leave its slot; a leading `/` or
81
+ * `:` would read as a path or a command.
82
+ */
83
+ const PROBE_LITERAL = /^-?[A-Za-z0-9][A-Za-z0-9._:/-]*$/;
84
+
85
+ type Reading = ExplainStatement | ExplainSubcommand;
86
+ export type TypeProbeContext = "operand" | "member";
87
+ type Context = TypeProbeContext;
88
+
89
+ export async function collectValueTypes(ctx: LiveProbeContext): Promise<void> {
90
+ const occurrences = ctx.data.values.occurrences;
91
+ if (occurrences.length === 0) return;
92
+ const batches: Record<Context, ExplainValueOccurrence[]> = {
93
+ operand: [],
94
+ member: [],
95
+ };
96
+ const statementIds: Record<Context, Set<string>> = {
97
+ operand: new Set(),
98
+ member: new Set(),
99
+ };
100
+ const named: { occurrence: ExplainValueOccurrence; reading: Reading }[] = [];
101
+ for (const occurrence of occurrences) {
102
+ const reading = readingOf(ctx, occurrence.tokenSpan);
103
+ if (reading?.kind !== "command" || reading.resolution !== "resolved")
104
+ continue;
105
+ const directive = reading.command.path === "/";
106
+ if (directive) {
107
+ const context = observedContext(ctx, occurrence);
108
+ if (context) {
109
+ batches[context].push(occurrence);
110
+ statementIds[context].add(
111
+ "owner" in reading ? reading.owner : reading.id,
112
+ );
113
+ }
114
+ } else if (
115
+ occurrence.kind === "attribute" &&
116
+ occurrence.name !== undefined &&
117
+ "id" in reading
118
+ )
119
+ named.push({ occurrence, reading });
120
+ }
121
+ for (const context of ["operand", "member"] as const)
122
+ await observe(ctx, context, batches[context], [...statementIds[context]]);
123
+ await declare(ctx, named);
124
+ }
125
+
126
+ // ---------------------------------------------------------------- observed
127
+
128
+ function observedContext(
129
+ ctx: LiveProbeContext,
130
+ occurrence: ExplainValueOccurrence,
131
+ ): Context | undefined {
132
+ if (isVariableName(ctx, occurrence)) return undefined;
133
+ // The span keeps a quoted value's quotes, so the byte set refuses it too.
134
+ const literal = ctx.analyzed.slice(
135
+ occurrence.span.start,
136
+ occurrence.span.end,
137
+ );
138
+ if (!PROBE_LITERAL.test(literal)) return undefined;
139
+ return occurrence.kind === "element" ? "member" : "operand";
140
+ }
141
+
142
+ /**
143
+ * A directive's NAME slot is a variable, never a value: the device renders
144
+ * `:local yes 2` as `name=$yes`, `:for yes from=1` as `counter=$yes` and
145
+ * `:local name=no` as `name=$no` (CHR 7.24.5). Probing `yes` there would
146
+ * publish `bool` for a variable name. The positional spelling is a span the
147
+ * symbol view claims; the named one is the `name=`/`counter=` argument, which
148
+ * the symbol view does not resolve to the declared variable.
149
+ */
150
+ function isVariableName(
151
+ ctx: LiveProbeContext,
152
+ occurrence: ExplainValueOccurrence,
153
+ ): boolean {
154
+ if (
155
+ occurrence.kind === "attribute" &&
156
+ (occurrence.name === "name" || occurrence.name === "counter")
157
+ )
158
+ return true;
159
+ const { start, end } = occurrence.span;
160
+ return ctx.data.symbols.occurrences.some(
161
+ (symbol) => symbol.span.start < end && start < symbol.span.end,
162
+ );
163
+ }
164
+
165
+ async function observe(
166
+ ctx: LiveProbeContext,
167
+ context: Context,
168
+ batch: ExplainValueOccurrence[],
169
+ statementIds: string[],
170
+ ): Promise<void> {
171
+ if (batch.length === 0) return;
172
+ const literals = batch.map((o) =>
173
+ ctx.analyzed.slice(o.span.start, o.span.end),
174
+ );
175
+ const script = typeProbeScript(context, literals);
176
+ const ev = ctx.evidence(":parse", {
177
+ facet: "types",
178
+ basis: "derived",
179
+ statementIds,
180
+ inputSha256: ctx.sha256(script),
181
+ normalization: "original-utf8",
182
+ });
183
+ if (batch.length > EXPLAIN_TYPE_LIMITS.literals) {
184
+ ev.outcome = "skipped";
185
+ ev.reason = `${batch.length} ${context} literals exceed the ${EXPLAIN_TYPE_LIMITS.literals}-literal type budget.`;
186
+ return;
187
+ }
188
+ const result = await ctx.run(ev, () =>
189
+ ctx.backend.execute({
190
+ path: "",
191
+ command: "",
192
+ script: `:put [:parse ${routerOsStringLiteral(script)}]`,
193
+ }),
194
+ );
195
+ if (!result) return;
196
+ const output = typeof result.ret === "string" ? result.ret.trim() : "";
197
+ if (output === "") {
198
+ ev.outcome = "empty";
199
+ return;
200
+ }
201
+ const rendered = splitRenderings(output, batch.length);
202
+ if (!rendered) {
203
+ ev.outcome = "invalid-response";
204
+ ev.reason = output.startsWith("(evl ")
205
+ ? "The type probe's parse output does not split into one rendering per literal."
206
+ : `The device rejected the ${context} type probe: ${output}`;
207
+ return;
208
+ }
209
+ batch.forEach((occurrence, i) => {
210
+ const fact = observedTypeOf(literals[i] ?? "", rendered[i] ?? "", ev.id);
211
+ if (fact) occurrence.facts.observedType = fact;
212
+ });
213
+ }
214
+
215
+ /** The probe text before quoting: one `:local` per literal, `;`-joined. */
216
+ export function typeProbeScript(
217
+ context: Context,
218
+ literals: readonly string[],
219
+ ): string {
220
+ return literals
221
+ .map((literal, i) =>
222
+ context === "member"
223
+ ? `:local v${i} {${literal}}`
224
+ : `:local v${i} ${literal}`,
225
+ )
226
+ .join(";");
227
+ }
228
+
229
+ /**
230
+ * Each probe statement renders `(evl /localname=$vN;value=<rendering>)`, joined
231
+ * by `;`. The markers are centrs's own, and a probe literal holds no `(`, so a
232
+ * marker cannot appear inside a rendering.
233
+ */
234
+ export function splitRenderings(
235
+ output: string,
236
+ count: number,
237
+ ): string[] | undefined {
238
+ const out: string[] = [];
239
+ let at = 0;
240
+ for (let i = 0; i < count; i++) {
241
+ const marker = `(evl /localname=$v${i};value=`;
242
+ if (!output.startsWith(marker, at)) return undefined;
243
+ const start = at + marker.length;
244
+ const next = i + 1 < count ? `);(evl /localname=$v${i + 1};value=` : ")";
245
+ const end = i + 1 < count ? output.indexOf(next, start) : output.length - 1;
246
+ if (end < start || (i + 1 === count && !output.endsWith(")")))
247
+ return undefined;
248
+ out.push(output.slice(start, end));
249
+ at = end + 2;
250
+ }
251
+ return out;
252
+ }
253
+
254
+ const TIME_RENDERING = /^(?:\d+w)?(?:\d+d)?\d{2}:\d{2}:\d{2}(?:\.\d+)?$/;
255
+
256
+ /** The type a changed rendering names, or nothing. */
257
+ export function observedTypeOf(
258
+ literal: string,
259
+ rendered: string,
260
+ ev: string,
261
+ ): ExplainValueTypeFact | undefined {
262
+ if (rendered === literal) return undefined;
263
+ const value = renderedType(rendered);
264
+ return value ? { value, rendered, ev } : undefined;
265
+ }
266
+
267
+ function renderedType(rendered: string): ObservedValueType | undefined {
268
+ if (/^-?\d+$/.test(rendered)) return "num";
269
+ if (rendered === "true" || rendered === "false") return "bool";
270
+ const slash = rendered.indexOf("/");
271
+ const [address, length] =
272
+ slash === -1
273
+ ? [rendered, undefined]
274
+ : [rendered.slice(0, slash), rendered.slice(slash + 1)];
275
+ if (length !== undefined && !/^\d+$/.test(length)) return undefined;
276
+ const family = isIP(address);
277
+ if (family === 4) return length === undefined ? "ip" : "ip-prefix";
278
+ if (family === 6) return length === undefined ? "ip6" : "ip6-prefix";
279
+ if (length === undefined && TIME_RENDERING.test(rendered)) return "time";
280
+ return undefined;
281
+ }
282
+
283
+ // ---------------------------------------------------------------- declared
284
+
285
+ async function declare(
286
+ ctx: LiveProbeContext,
287
+ named: { occurrence: ExplainValueOccurrence; reading: Reading }[],
288
+ ): Promise<void> {
289
+ const probes = new Map<
290
+ string,
291
+ {
292
+ tokens: string[];
293
+ statementIds: Set<string>;
294
+ occurrences: ExplainValueOccurrence[];
295
+ }
296
+ >();
297
+ for (const { occurrence, reading } of named) {
298
+ const statementId = (reading as ExplainStatement).id;
299
+ const discovered = ctx.data.live?.arguments.find((entry) =>
300
+ entry.statementIds.includes(statementId),
301
+ );
302
+ if (!discovered || reading.kind !== "command") continue;
303
+ const name = resolveArgumentName(occurrence.name ?? "", discovered.names);
304
+ if (!name) continue;
305
+ const tokens = [...pathTokens(discovered.path), name];
306
+ const key = tokens.join(",");
307
+ const probe = probes.get(key) ?? {
308
+ tokens,
309
+ statementIds: new Set<string>(),
310
+ occurrences: [],
311
+ };
312
+ probe.statementIds.add(statementId);
313
+ probe.occurrences.push(occurrence);
314
+ probes.set(key, probe);
315
+ }
316
+ let sent = 0;
317
+ for (const probe of probes.values()) {
318
+ const ev = ctx.evidence("syntax", {
319
+ facet: "types",
320
+ basis: "derived",
321
+ path: `/${probe.tokens.join("/")}`,
322
+ statementIds: [...probe.statementIds],
323
+ });
324
+ if (++sent > EXPLAIN_TYPE_LIMITS.syntaxProbes) {
325
+ ev.outcome = "skipped";
326
+ ev.reason = `Argument-type budget of ${EXPLAIN_TYPE_LIMITS.syntaxProbes} syntax probes exhausted.`;
327
+ continue;
328
+ }
329
+ // Argument paths only: a command path hangs REST on 7.12.2 and 7.20.8.
330
+ const rows = await ctx.run(ev, () =>
331
+ ctx.backend.inspect("syntax", probe.tokens.join(",")),
332
+ );
333
+ if (rows === undefined) continue;
334
+ if (
335
+ !Array.isArray(rows) ||
336
+ rows.some((row) => row === null || typeof row !== "object")
337
+ ) {
338
+ ev.outcome = "invalid-response";
339
+ ev.reason = "Syntax response is not an array of rows.";
340
+ continue;
341
+ }
342
+ if (rows.length === 0) {
343
+ ev.outcome = "empty";
344
+ continue;
345
+ }
346
+ const fact = schemaTypeOf(
347
+ (rows as Record<string, unknown>[]).map((row) => ({
348
+ symbol: String(row["symbol"] ?? ""),
349
+ kind: String(row["symbol-type"] ?? ""),
350
+ text: String(row["text"] ?? ""),
351
+ depth: Number(row["nested"] ?? 0),
352
+ })),
353
+ ev.id,
354
+ );
355
+ if (fact)
356
+ for (const occurrence of probe.occurrences)
357
+ occurrence.facts.schemaType = fact;
358
+ }
359
+ }
360
+
361
+ /** An exact name, else a unique prefix, as RouterOS resolves an argument. */
362
+ export function resolveArgumentName(
363
+ name: string,
364
+ names: readonly string[],
365
+ ): string | undefined {
366
+ if (names.includes(name)) return name;
367
+ const matches = names.filter((candidate) => candidate.startsWith(name));
368
+ return name !== "" && matches.length === 1 ? matches[0] : undefined;
369
+ }
370
+
371
+ /** Leaf text annotations the device appends, in parentheses. */
372
+ const ANNOTATIONS: Record<string, SchemaValueType> = {
373
+ "integer number": "num",
374
+ "decimal number": "num",
375
+ "time interval": "time",
376
+ "IP address": "ip",
377
+ "IP prefix": "ip-prefix",
378
+ "IP address range": "ip-range",
379
+ "IPv6 prefix": "ip6-prefix",
380
+ "MAC address": "mac",
381
+ };
382
+
383
+ /** Whole-leaf texts the device uses without an annotation. */
384
+ const LEAF_TEXTS: Record<string, SchemaValueType> = {
385
+ "string value": "str",
386
+ "hexadecimal string value": "str",
387
+ script: "str",
388
+ filename: "str",
389
+ "integer value": "num",
390
+ "time interval": "time",
391
+ "IPv4 address": "ip",
392
+ "IPv6 address": "ip6",
393
+ "A.B.C.D": "ip",
394
+ };
395
+
396
+ const REPETITION = /\[,[A-Za-z]|\(max \d+ times\)/;
397
+ const KEYWORD = /^[A-Za-z0-9][\w.-]*$/;
398
+
399
+ /**
400
+ * Normalize argument `syntax` rows, or abstain. Exported for the fixture
401
+ * replay and the crawl that grounded the tables above.
402
+ */
403
+ export function schemaTypeOf(
404
+ rows: readonly ExplainSyntaxRow[],
405
+ ev: string,
406
+ ): ExplainSchemaTypeFact | undefined {
407
+ const kept = rows.filter(
408
+ (row) =>
409
+ !(
410
+ row.kind === "explanation" &&
411
+ row.depth > 0 &&
412
+ /^[a-z0-9]/.test(row.symbol)
413
+ ),
414
+ );
415
+ const root = kept[0];
416
+ if (!root || kept.every((row) => row.text.trim() === "")) return undefined;
417
+ const values: SchemaValueType[] = [];
418
+ for (const [i, row] of kept.entries()) {
419
+ const next = kept[i + 1];
420
+ // A blank leaf is a placeholder (`"" | string value`); it says nothing.
421
+ if (row.text.trim() === "") continue;
422
+ if (next !== undefined && next.depth > row.depth) continue;
423
+ const terms = leafTypes(row.text);
424
+ if (!terms) return undefined;
425
+ for (const term of terms) if (!values.includes(term)) values.push(term);
426
+ }
427
+ if (values.length === 0) return undefined;
428
+ return {
429
+ values,
430
+ ...(kept.some((row) => REPETITION.test(row.text))
431
+ ? { list: true as const }
432
+ : {}),
433
+ declared: root.text.trim() === "" ? root.symbol : root.text.trim(),
434
+ ev,
435
+ };
436
+ }
437
+
438
+ function leafTypes(raw: string): SchemaValueType[] | undefined {
439
+ let text = raw.replace(/\s+/g, " ").trim();
440
+ const terms: SchemaValueType[] = [];
441
+ for (;;) {
442
+ const annotation = /\s*\(([^()]*)\)$/.exec(text);
443
+ if (!annotation) break;
444
+ text = text.slice(0, annotation.index).trim();
445
+ const label = annotation[1] ?? "";
446
+ if (/^max \d+ times$/.test(label)) continue;
447
+ const term = ANNOTATIONS[label];
448
+ if (!term) return undefined;
449
+ terms.push(term);
450
+ }
451
+ if (terms.length > 0) return terms;
452
+ const whole =
453
+ LEAF_TEXTS[text] ??
454
+ LEAF_TEXTS[text.replace(/, (?:min|max) length \d+/g, "")];
455
+ if (whole) return [whole];
456
+ if (/^-?[0-9A-F]+\.\.[0-9A-F]+$/.test(text)) return ["num"];
457
+ const alternatives = text
458
+ .replace(/\[,[A-Za-z]\w*\*\]$/, "")
459
+ .split("|")
460
+ .map((part) => part.trim())
461
+ .filter((part) => part !== "");
462
+ // A listed word set is `enum` whether it holds keywords or the current
463
+ // configuration names (`ether1 | lo`); `...` only marks a long list cut
464
+ // short, and keyword lists are cut too (`ftp | h323 | … | ...`).
465
+ const open = alternatives.at(-1) === "...";
466
+ const words = open ? alternatives.slice(0, -1) : alternatives;
467
+ if (!words.every((word) => KEYWORD.test(word))) return undefined;
468
+ return words.length > 0 || open ? ["enum"] : undefined;
469
+ }
470
+
471
+ // ---------------------------------------------------------------- readings
472
+
473
+ /** The innermost statement or `[…]` substitution containing the span. */
474
+ function readingOf(
475
+ ctx: LiveProbeContext,
476
+ span: ExplainSpanRange,
477
+ ): Reading | undefined {
478
+ let found: Reading | undefined;
479
+ const { statements, subcommands } = ctx.data.structure;
480
+ for (const reading of [...statements, ...subcommands]) {
481
+ if (reading.span.start > span.start || reading.span.end < span.end)
482
+ continue;
483
+ if (
484
+ !found ||
485
+ reading.span.end - reading.span.start < found.span.end - found.span.start
486
+ )
487
+ found = reading;
488
+ }
489
+ return found;
490
+ }