@tikoci/centrs 0.1.3 → 0.1.4

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 (90) hide show
  1. package/CHANGELOG.md +39 -0
  2. package/GLOSSARY.txt +213 -0
  3. package/LICENSE +21 -0
  4. package/README.md +36 -8
  5. package/commands/AGENTS.md +7 -1
  6. package/commands/api/README.md +19 -18
  7. package/commands/api/examples.md +15 -0
  8. package/commands/btest/README.md +21 -32
  9. package/commands/check/README.md +6 -1
  10. package/commands/discover/README.md +5 -11
  11. package/commands/execute/README.md +41 -29
  12. package/commands/execute/examples.md +41 -3
  13. package/commands/explain/README.md +1716 -0
  14. package/commands/explain/examples.md +606 -0
  15. package/commands/retrieve/README.md +25 -27
  16. package/commands/retrieve/examples.md +16 -0
  17. package/commands/transfer/README.md +28 -20
  18. package/commands/transfer/examples.md +29 -0
  19. package/docs/CLI.md +353 -0
  20. package/docs/CONSTITUTION.md +66 -15
  21. package/docs/MATRIX.md +38 -1
  22. package/docs/errors/README.md +3 -0
  23. package/docs/errors/quickchr/machine-not-found.md +22 -0
  24. package/docs/errors/quickchr/machine-stopped.md +20 -0
  25. package/docs/errors/quickchr/package-unavailable.md +27 -0
  26. package/docs/errors/quickchr/unsupported-via.md +35 -0
  27. package/docs/errors/quickchr/unsupported.md +35 -0
  28. package/docs/errors/routeros/version-unsupported.md +26 -0
  29. package/docs/errors/transport/provider-trust.md +29 -0
  30. package/docs/errors/usage/stdin-ignored.md +28 -0
  31. package/docs/errors/validation/unknown-attribute.md +37 -4
  32. package/package.json +45 -10
  33. package/src/api-fanout.ts +29 -14
  34. package/src/api.ts +97 -54
  35. package/src/cli/api.ts +25 -6
  36. package/src/cli/btest.ts +18 -2
  37. package/src/cli/execute.ts +25 -6
  38. package/src/cli/explain.ts +503 -0
  39. package/src/cli/retrieve.ts +33 -1
  40. package/src/cli/selection.ts +71 -11
  41. package/src/cli/settings.ts +2 -2
  42. package/src/cli/transfer.ts +23 -6
  43. package/src/cli.ts +15 -2
  44. package/src/core/envelope.ts +5 -2
  45. package/src/core/error-catalog.ts +40 -0
  46. package/src/core/preflight.ts +306 -0
  47. package/src/core/routeros-string.ts +24 -0
  48. package/src/errors.ts +1 -0
  49. package/src/execute-fanout.ts +29 -14
  50. package/src/execute.ts +114 -63
  51. package/src/explain/arg-tokens.ts +114 -0
  52. package/src/explain/args.ts +1403 -0
  53. package/src/explain/blocks.ts +132 -0
  54. package/src/explain/brace-slots.ts +143 -0
  55. package/src/explain/brace-tokens.ts +38 -0
  56. package/src/explain/catalog.ts +1345 -0
  57. package/src/explain/coordinates.ts +329 -0
  58. package/src/explain/defects.ts +217 -0
  59. package/src/explain/is-known-menu.ts +17 -0
  60. package/src/explain/menus.ts +664 -0
  61. package/src/explain/operator-tokens.ts +310 -0
  62. package/src/explain/operators.ts +458 -0
  63. package/src/explain/path-tokens.ts +150 -0
  64. package/src/explain/pathresolve.ts +1324 -0
  65. package/src/explain/quoted-string.ts +241 -0
  66. package/src/explain/scope-brace.ts +308 -0
  67. package/src/explain/segment.ts +757 -0
  68. package/src/explain/string-tokens.ts +67 -0
  69. package/src/explain/symbol-values.ts +520 -0
  70. package/src/explain/symbols.ts +1597 -0
  71. package/src/explain/token-ranges.ts +72 -0
  72. package/src/explain/transport.ts +497 -0
  73. package/src/explain/value-tokens.ts +80 -0
  74. package/src/explain/values.ts +357 -0
  75. package/src/explain/verbs.ts +64 -0
  76. package/src/explain/verbsplit.ts +813 -0
  77. package/src/explain/write.ts +815 -0
  78. package/src/explain.ts +2152 -0
  79. package/src/index.ts +206 -4
  80. package/src/protocols/mac-telnet-console.ts +61 -17
  81. package/src/resolver/facts.ts +1 -1
  82. package/src/resolver/index.ts +23 -4
  83. package/src/resolver/quickchr-provider.ts +597 -0
  84. package/src/resolver/selection.ts +99 -12
  85. package/src/resolver/service-endpoint.ts +155 -0
  86. package/src/resolver/target.ts +1 -1
  87. package/src/retrieve-fanout.ts +26 -12
  88. package/src/retrieve.ts +81 -18
  89. package/src/transfer-fanout.ts +42 -25
  90. package/src/transfer.ts +87 -44
@@ -0,0 +1,72 @@
1
+ import type { ExplainToken, ExplainTokenClass } from "../explain.ts";
2
+
3
+ export interface TokenRange {
4
+ start: number;
5
+ end: number;
6
+ }
7
+
8
+ export function clipToResidual(
9
+ start: number,
10
+ end: number,
11
+ residual: readonly TokenRange[],
12
+ ): TokenRange[] {
13
+ if (start >= end || residual.length === 0) return [];
14
+ let lo = 0;
15
+ let hi = residual.length - 1;
16
+ let first = residual.length;
17
+ while (lo <= hi) {
18
+ const mid = (lo + hi) >> 1;
19
+ const range = residual[mid] as TokenRange;
20
+ if (range.end <= start) lo = mid + 1;
21
+ else {
22
+ first = mid;
23
+ hi = mid - 1;
24
+ }
25
+ }
26
+ const out: TokenRange[] = [];
27
+ for (let i = first; i < residual.length; i++) {
28
+ const range = residual[i] as TokenRange;
29
+ if (range.start >= end) break;
30
+ const clippedStart = Math.max(start, range.start);
31
+ const clippedEnd = Math.min(end, range.end);
32
+ if (clippedStart < clippedEnd)
33
+ out.push({ start: clippedStart, end: clippedEnd });
34
+ }
35
+ return out;
36
+ }
37
+
38
+ export function mergeAdjacentTokens(
39
+ tokens: readonly ExplainToken[],
40
+ ): ExplainToken[] {
41
+ const sorted = [...tokens].sort((a, b) => a.start - b.start || a.end - b.end);
42
+ const out: ExplainToken[] = [];
43
+ for (const token of sorted) {
44
+ const last = out.at(-1);
45
+ if (
46
+ last !== undefined &&
47
+ last.end === token.start &&
48
+ last.class === token.class &&
49
+ last.ev === token.ev
50
+ ) {
51
+ last.end = token.end;
52
+ continue;
53
+ }
54
+ out.push({ ...token });
55
+ }
56
+ return out;
57
+ }
58
+
59
+ export function tokensOnResidual(
60
+ ranges: readonly TokenRange[],
61
+ residual: readonly TokenRange[],
62
+ class_: ExplainTokenClass,
63
+ ev: string,
64
+ ): ExplainToken[] {
65
+ return ranges.flatMap((range) =>
66
+ clipToResidual(range.start, range.end, residual).map((clipped) => ({
67
+ ...clipped,
68
+ class: class_,
69
+ ev,
70
+ })),
71
+ );
72
+ }
@@ -0,0 +1,497 @@
1
+ /**
2
+ * Fail-closed offline transport classification for `explain` (#202c-2).
3
+ *
4
+ * RouterOS REST has a broad mechanical convention (CRUD verbs plus command
5
+ * POSTs), and `explain` emits a runnable request only where the Q8 probe
6
+ * exercised that convention on CHR 7.23.2 and 7.24rc2
7
+ * (`test/fixtures/explain/transport-rest-q8.v*.json`, promoted in #186).
8
+ * The rules below are that evidence.
9
+ * A verb or operand merely looking REST-shaped never widens them.
10
+ *
11
+ * ## The four CRUD rules are literal; the action rule is a family
12
+ *
13
+ * `add`/`get`/`set`/`remove` were each exercised as themselves, so they are a
14
+ * closed table keyed on the verb. The fifth row was exercised as
15
+ * `/ip/dns/cache flush` → `POST /rest/ip/dns/cache/flush` with body `{}`, and
16
+ * the probe recorded it as the RULE `run(action) → POST /rest/<path>/<command>`
17
+ * — a statement about how the REST adapter builds a URL for a menu action, not
18
+ * about the one verb that demonstrated it (maintainer decision, #241 review:
19
+ * most non-`get`/`set`/`print` operations are that POST, `monitor` included).
20
+ * So any verb outside the four CRUD rules, `print`, and `find` rides it.
21
+ *
22
+ * That rule deliberately does NOT claim the verb exists. Whether a menu has a
23
+ * `run` is a schema question, and offline has no schema: the `catalog.ts`
24
+ * command rows confirm a verb where they carry one, but "a MISS says nothing"
25
+ * is that table's own contract, so a miss strengthens no refusal. Existence is
26
+ * the phase-2 `/console/inspect` probe's answer, not this pass's.
27
+ *
28
+ * What DOES fail closed on the action rule is a positional operand: RouterOS
29
+ * names it from per-menu schema (`/file set 0` → `numbers=0`), decision 3 keeps
30
+ * no offline schema snapshot, and a POST body that omits the target would run
31
+ * against the wrong rows. `/ip/address enable *A` is therefore `unknown`, and
32
+ * the reason names the live probe that would lift it.
33
+ */
34
+
35
+ import type { ExplainArguments } from "../explain.ts";
36
+ import { lookupPath } from "./catalog.ts";
37
+
38
+ export type ExplainTransportClassification =
39
+ | "api-candidate"
40
+ | "execute"
41
+ | "unknown";
42
+
43
+ export type ExplainRestMethod = "GET" | "PUT" | "PATCH" | "DELETE" | "POST";
44
+
45
+ export interface ExplainRestRequest {
46
+ method: ExplainRestMethod;
47
+ path: string;
48
+ body?: Record<string, unknown>;
49
+ }
50
+
51
+ interface ExplainTransportBase {
52
+ classification: ExplainTransportClassification;
53
+ /** Why this route was selected or refused. */
54
+ basis: string;
55
+ /** Evidence id assigned by the composing explain pass. */
56
+ ev: string;
57
+ }
58
+
59
+ export interface ExplainApiCandidateTransport extends ExplainTransportBase {
60
+ classification: "api-candidate";
61
+ rest: ExplainRestRequest;
62
+ /** Equivalent structured centrs invocation, using an offline target placeholder. */
63
+ centrs: string;
64
+ /** Present only when the caller requested curl rendering. */
65
+ curl?: string;
66
+ }
67
+
68
+ export interface ExplainExecuteTransport extends ExplainTransportBase {
69
+ classification: "execute";
70
+ /** Equivalent raw-CLI centrs invocation, using an offline target placeholder. */
71
+ centrs: string;
72
+ rest?: undefined;
73
+ curl?: undefined;
74
+ }
75
+
76
+ export interface ExplainUnknownTransport extends ExplainTransportBase {
77
+ classification: "unknown";
78
+ rest?: undefined;
79
+ centrs?: undefined;
80
+ curl?: undefined;
81
+ }
82
+
83
+ export type ExplainTransport =
84
+ | ExplainApiCandidateTransport
85
+ | ExplainExecuteTransport
86
+ | ExplainUnknownTransport;
87
+
88
+ /** The reading this pass consumes, once the lexer has decided every token. */
89
+ type ReadArguments = Extract<ExplainArguments, { read: true }>;
90
+
91
+ export interface ExplainTransportInput {
92
+ command: {
93
+ path: string;
94
+ verb: string;
95
+ args?: Record<string, string>;
96
+ };
97
+ /**
98
+ * The envelope's own argument reading, imported rather than restated so the
99
+ * transport contract cannot drift from what `src/explain.ts` publishes.
100
+ */
101
+ arguments: ExplainArguments;
102
+ /** The statement exactly as written, for the execute invocation. */
103
+ source: string;
104
+ }
105
+
106
+ export interface ExplainTransportOptions {
107
+ renderCurl?: boolean;
108
+ evidenceId: string;
109
+ }
110
+
111
+ type EndpointShape = "menu" | "id";
112
+ type BodyShape = "none" | "attributes";
113
+
114
+ interface TestedRestRule {
115
+ method: ExplainRestMethod;
116
+ endpoint: EndpointShape;
117
+ body: BodyShape;
118
+ requiresBody?: boolean;
119
+ }
120
+
121
+ /**
122
+ * The Q8 rules keyed on a literal verb. `print` has three separately exercised
123
+ * shapes and is handled below (bare GET, `.proplist` POST, `.query` POST), and
124
+ * every other verb rides the action rule.
125
+ */
126
+ const TESTED_REST_RULES: Readonly<Record<string, TestedRestRule>> = {
127
+ add: {
128
+ method: "PUT",
129
+ endpoint: "menu",
130
+ body: "attributes",
131
+ requiresBody: true,
132
+ },
133
+ get: { method: "GET", endpoint: "id", body: "none" },
134
+ set: {
135
+ method: "PATCH",
136
+ endpoint: "id",
137
+ body: "attributes",
138
+ requiresBody: true,
139
+ },
140
+ remove: { method: "DELETE", endpoint: "id", body: "none" },
141
+ };
142
+
143
+ const ROUTEROS_ID = /^\*[0-9A-F]+$/i;
144
+ /**
145
+ * A `.query` word's property name. Q8 exercised exactly one spelling —
146
+ * `interface=ether1` — so this matches the NAME half and the value is required
147
+ * separately. A bare property, an empty value, an infix comparison
148
+ * (`address>1.1.1.1`), and a second `where` all fail that pair and fail closed;
149
+ * REST's own `<name=value` / `>name=value` comparison words are not a `where`
150
+ * spelling any device exercised here.
151
+ */
152
+ const QUERY_NAME = /^[A-Za-z][A-Za-z0-9._-]*$/;
153
+
154
+ function restPath(path: string): string {
155
+ // A single trailing slash, never a run: `path` is a resolved menu path from
156
+ // `pathresolve.ts`, whose segments are non-empty by construction, so `//`
157
+ // cannot reach here and one strip is total.
158
+ return `/rest${path === "/" ? "" : path.replace(/\/$/, "")}`;
159
+ }
160
+
161
+ function shellQuote(value: string): string {
162
+ // End the single-quoted run, emit the apostrophe from a double-quoted run,
163
+ // then reopen it. This is portable POSIX shell syntax and keeps every other
164
+ // byte literal.
165
+ return `'${value.replaceAll("'", `'"'"'`)}'`;
166
+ }
167
+
168
+ function bodyOf(
169
+ rule: TestedRestRule,
170
+ args: Record<string, string>,
171
+ ): Record<string, unknown> | undefined {
172
+ if (rule.body === "none" || Object.keys(args).length === 0) return undefined;
173
+ return { ...args };
174
+ }
175
+
176
+ function endpointOf(
177
+ path: string,
178
+ rule: TestedRestRule,
179
+ id: string | undefined,
180
+ ): string {
181
+ const base = restPath(path);
182
+ return rule.endpoint === "id" ? `${base}/${id}` : base;
183
+ }
184
+
185
+ function renderCentrsApi(rest: ExplainRestRequest): string {
186
+ const printFacet =
187
+ rest.method === "POST" &&
188
+ rest.path.endsWith("/print") &&
189
+ (rest.body?.[".proplist"] !== undefined ||
190
+ rest.body?.[".query"] !== undefined);
191
+ const renderedPath = printFacet
192
+ ? rest.path.slice(0, -"/print".length)
193
+ : rest.path;
194
+ const endpoint = renderedPath.replace(/^\/rest/, "") || "/";
195
+ const parts = ["centrs", "api", shellQuote("<router>"), shellQuote(endpoint)];
196
+ // `centrs api` models print as GET even when query/projection makes its REST
197
+ // adapter send POST `/print`; giving the user `-X POST` would select `run` and
198
+ // silently drop the print facets.
199
+ if (rest.method !== "GET" && !printFacet) parts.push("-X", rest.method);
200
+ const body = rest.body ?? {};
201
+ const proplist = body[".proplist"];
202
+ if (Array.isArray(proplist))
203
+ parts.push("--proplist", shellQuote(proplist.join(",")));
204
+ const query = body[".query"];
205
+ if (Array.isArray(query)) {
206
+ for (const word of query)
207
+ if (typeof word === "string") parts.push("--raw-query", shellQuote(word));
208
+ }
209
+ for (const [name, value] of Object.entries(body)) {
210
+ if (name === ".proplist" || name === ".query") continue;
211
+ // Keep source order: the argument lexer builds `args` in token order, which
212
+ // makes the suggested command read like the input while remaining stable.
213
+ parts.push(
214
+ "-f",
215
+ shellQuote(
216
+ `${name}=${typeof value === "string" ? value : JSON.stringify(value)}`,
217
+ ),
218
+ );
219
+ }
220
+ return parts.join(" ");
221
+ }
222
+
223
+ function renderCurl(rest: ExplainRestRequest): string {
224
+ const parts = [
225
+ "curl",
226
+ "--user",
227
+ shellQuote("<username>:<password>"),
228
+ "--request",
229
+ rest.method,
230
+ ];
231
+ // An EMPTY body still ships as `{}`: that is the byte-for-byte request the Q8
232
+ // action probe sent (`POST /rest/ip/dns/cache/flush {}` → 200), and a POST
233
+ // with no entity is a different request.
234
+ if (rest.body !== undefined) {
235
+ parts.push(
236
+ "--header",
237
+ shellQuote("Content-Type: application/json"),
238
+ "--data",
239
+ shellQuote(JSON.stringify(rest.body)),
240
+ );
241
+ }
242
+ parts.push(shellQuote(`https://<router>${rest.path}`));
243
+ return parts.join(" ");
244
+ }
245
+
246
+ function apiCandidate(
247
+ rest: ExplainRestRequest,
248
+ basis: string,
249
+ options: ExplainTransportOptions,
250
+ ): ExplainApiCandidateTransport {
251
+ return {
252
+ classification: "api-candidate",
253
+ basis,
254
+ rest,
255
+ centrs: renderCentrsApi(rest),
256
+ ...(options.renderCurl ? { curl: renderCurl(rest) } : {}),
257
+ ev: options.evidenceId,
258
+ };
259
+ }
260
+
261
+ function execute(
262
+ source: string,
263
+ basis: string,
264
+ evidenceId: string,
265
+ ): ExplainExecuteTransport {
266
+ return {
267
+ classification: "execute",
268
+ basis,
269
+ centrs: `centrs execute ${shellQuote("<router>")} ${shellQuote(source)}`,
270
+ ev: evidenceId,
271
+ };
272
+ }
273
+
274
+ function unknown(basis: string, evidenceId: string): ExplainUnknownTransport {
275
+ return { classification: "unknown", basis, ev: evidenceId };
276
+ }
277
+
278
+ /**
279
+ * The one literal `.id` an id-bearing rule needs, or nothing.
280
+ *
281
+ * Only `tokens[0]` is examined, and only when it is the sole positional. The
282
+ * rejection is deliberately two-stage: `get *A comment=x` gets an id here and is
283
+ * then refused by the `body === "none"` guard in the caller, because "there is
284
+ * an id" and "the operands fit the tested shape" are separate questions and
285
+ * folding them would hide which one failed from the basis string.
286
+ */
287
+ function literalId(arguments_: ReadArguments): string | undefined {
288
+ if (arguments_.tokens.length === 0) return undefined;
289
+ const [first] = arguments_.tokens;
290
+ if (
291
+ arguments_.positional.length !== 1 ||
292
+ first?.kind !== "positional" ||
293
+ first.value === undefined ||
294
+ !ROUTEROS_ID.test(first.value)
295
+ )
296
+ return undefined;
297
+ return first.value;
298
+ }
299
+
300
+ function printRequest(
301
+ arguments_: ReadArguments,
302
+ path: string,
303
+ ): { rest: ExplainRestRequest; basis: string } | string {
304
+ if (arguments_.queries.length > 0)
305
+ return "only the tested print where shape can produce a REST .query";
306
+ const query: string[] = [];
307
+ let proplist: string[] | undefined;
308
+ let where = false;
309
+
310
+ for (const token of arguments_.tokens) {
311
+ if (token.kind === "query") continue;
312
+ if (token.kind === "attribute" && token.name === "proplist") {
313
+ if (where)
314
+ return "combined print projection and query were not runtime-exercised";
315
+ if (token.value === undefined) return "the proplist value is not literal";
316
+ proplist = token.value
317
+ .split(",")
318
+ .map((part) => part.trim())
319
+ .filter(Boolean);
320
+ continue;
321
+ }
322
+ if (token.kind === "positional" && token.value === "where" && !where) {
323
+ where = true;
324
+ continue;
325
+ }
326
+ if (!where)
327
+ return `print operand ${JSON.stringify(token.text)} is outside the tested no-argument/proplist/query rules`;
328
+ if (
329
+ token.kind !== "attribute" ||
330
+ token.name === undefined ||
331
+ !QUERY_NAME.test(token.name) ||
332
+ token.value === undefined ||
333
+ token.value === ""
334
+ )
335
+ return `where expression ${JSON.stringify(token.text)} has no tested .query translation`;
336
+ // The DECODED value, never `text`: `comment="a b"` must reach the device as
337
+ // `comment=a b`, and shipping the quote bytes would filter on a value no
338
+ // row holds.
339
+ query.push(`${token.name}=${token.value}`);
340
+ }
341
+
342
+ if (where && query.length === 0)
343
+ return "print where has no query expression to send";
344
+ if (where && proplist !== undefined)
345
+ return "combined print projection and query were not runtime-exercised";
346
+ if (proplist !== undefined && proplist.length === 0)
347
+ return "the tested print projection requires at least one property";
348
+
349
+ const base = restPath(path);
350
+ if (query.length === 0 && proplist === undefined)
351
+ return {
352
+ rest: { method: "GET", path: base },
353
+ basis: "Q8 tested bare print as GET on CHR 7.23.2 and 7.24rc2",
354
+ };
355
+ const body: Record<string, unknown> = {};
356
+ if (query.length > 0) body[".query"] = query;
357
+ if (proplist !== undefined) body[".proplist"] = proplist;
358
+ return {
359
+ rest: { method: "POST", path: `${base}/print`, body },
360
+ basis:
361
+ "Q8 tested print projection/query as POST to the print endpoint on CHR 7.23.2 and 7.24rc2",
362
+ };
363
+ }
364
+
365
+ /**
366
+ * The Q8 action rule: a menu action POSTs to its own command endpoint.
367
+ *
368
+ * `find` is excluded and not because it is untested — it is a SELECTOR. A bare
369
+ * `find` yields ids for another statement to consume, there is no
370
+ * `/rest/<path>/find` for it to become, and `[find …]` already left through the
371
+ * execute branch above.
372
+ */
373
+ function actionRequest(
374
+ input: ExplainTransportInput,
375
+ lower: string,
376
+ arguments_: ReadArguments,
377
+ options: ExplainTransportOptions,
378
+ ): ExplainTransport {
379
+ // The endpoint carries the verb AS WRITTEN. RouterOS paths are
380
+ // case-sensitive, so the normalized spelling is for LOOKUPS only — building
381
+ // `/rest/ip/address/tostring` out of `toString` would be a different URL.
382
+ const { path, verb, args = {} } = input.command;
383
+ if (lower === "find")
384
+ return unknown(
385
+ "find selects rows for another statement to consume and has no REST endpoint of its own",
386
+ options.evidenceId,
387
+ );
388
+ if (arguments_.positional.length > 0)
389
+ return unknown(
390
+ `offline cannot name the operand ${JSON.stringify(arguments_.positional[0])} the tested action POST would carry — RouterOS names it from per-menu schema, which is live evidence`,
391
+ options.evidenceId,
392
+ );
393
+ if (arguments_.queries.length > 0)
394
+ return unknown(
395
+ "the tested action POST carries attributes, and a ?query has no place in its body",
396
+ options.evidenceId,
397
+ );
398
+
399
+ // A catalog hit is decisive about what the segment IS; a miss says nothing
400
+ // (`catalog.ts`), so it narrows the basis rather than the classification.
401
+ const named =
402
+ lookupPath([...path.split("/").filter(Boolean), lower])?.kind === "command";
403
+ return apiCandidate(
404
+ {
405
+ method: "POST",
406
+ path: `${restPath(path)}/${verb}`,
407
+ // `{}`, not omitted: the Q8 action probe sent an empty object.
408
+ body: { ...args },
409
+ },
410
+ `Q8 tested a menu action as POST to its command endpoint on CHR 7.23.2 and 7.24rc2; ${
411
+ named
412
+ ? `the CLI Reference catalog names ${path}/${verb} a command`
413
+ : `no offline source can confirm ${JSON.stringify(verb)} is a command under ${path}`
414
+ }`,
415
+ options,
416
+ );
417
+ }
418
+
419
+ /** Classify one resolved statement and optionally render its REST curl. */
420
+ export function classifyExplainTransport(
421
+ input: ExplainTransportInput,
422
+ options: ExplainTransportOptions,
423
+ ): ExplainTransport {
424
+ const { path, verb, args = {} } = input.command;
425
+ const lower = verb.toLowerCase();
426
+
427
+ // Root scripting directives have no RouterOS REST path. They always ride the
428
+ // raw execute surface, regardless of whether their operands are literal.
429
+ if (path === "/")
430
+ return execute(
431
+ input.source,
432
+ "a RouterOS scripting directive has no structured REST endpoint",
433
+ options.evidenceId,
434
+ );
435
+
436
+ // Once an operand requires RouterOS evaluation, the exact CLI statement is
437
+ // the only semantics-preserving rendering. This check deliberately precedes
438
+ // the REST verb table: selector actions such as `enable [find ...]` are
439
+ // execute-shaped even though literal `enable 0` has no tested REST rule.
440
+ if (!input.arguments.read)
441
+ return execute(
442
+ input.source,
443
+ `the argument list requires RouterOS evaluation (${input.arguments.why})`,
444
+ options.evidenceId,
445
+ );
446
+
447
+ const arguments_ = input.arguments;
448
+
449
+ if (lower === "print") {
450
+ const planned = printRequest(arguments_, path);
451
+ return typeof planned === "string"
452
+ ? unknown(planned, options.evidenceId)
453
+ : apiCandidate(planned.rest, planned.basis, options);
454
+ }
455
+
456
+ // `Object.hasOwn`, not a truthiness test: a plain object literal inherits
457
+ // `Object.prototype`, so `TESTED_REST_RULES["constructor"]` is a function
458
+ // that passes an `=== undefined` guard and then reads `undefined` out of
459
+ // every shape field. The table is only closed when the lookup is.
460
+ const rule = Object.hasOwn(TESTED_REST_RULES, lower)
461
+ ? TESTED_REST_RULES[lower]
462
+ : undefined;
463
+ if (rule === undefined)
464
+ return actionRequest(input, lower, arguments_, options);
465
+
466
+ const id = rule.endpoint === "id" ? literalId(arguments_) : undefined;
467
+ if (rule.endpoint === "id" && id === undefined) {
468
+ const setBoundary =
469
+ lower === "set" && arguments_.positional.length === 0
470
+ ? "offline cannot distinguish a singleton menu from an id-bearing table"
471
+ : `the tested ${lower} mapping requires exactly one literal RouterOS .id`;
472
+ return unknown(setBoundary, options.evidenceId);
473
+ }
474
+
475
+ if (
476
+ (rule.endpoint !== "id" && arguments_.positional.length > 0) ||
477
+ arguments_.queries.length > 0 ||
478
+ (rule.body === "none" && Object.keys(args).length > 0) ||
479
+ (rule.requiresBody === true && Object.keys(args).length === 0)
480
+ )
481
+ return unknown(
482
+ `the operands are outside the tested ${lower} REST shape`,
483
+ options.evidenceId,
484
+ );
485
+
486
+ const body = bodyOf(rule, args);
487
+ const rest: ExplainRestRequest = {
488
+ method: rule.method,
489
+ path: endpointOf(path, rule, id),
490
+ ...(body === undefined ? {} : { body }),
491
+ };
492
+ return apiCandidate(
493
+ rest,
494
+ `Q8 tested ${lower} as ${rule.method} on CHR 7.23.2 and 7.24rc2`,
495
+ options,
496
+ );
497
+ }
@@ -0,0 +1,80 @@
1
+ /**
2
+ * B2 value fill — claims argument value bytes and array-literal interiors on the residual.
3
+ *
4
+ * This is the third B2 fill of #264, after the argument `name=` fill. It runs
5
+ * **after** proof-only `spans` (comment + variable-*) and the `arg` fill and
6
+ * **before** the operator fill, so the fill order is structural:
7
+ * `spans → arg → value → operator`. That ordering retires the glued-dot residue
8
+ * already noted in `operator-tokens.ts`: once value bytes are claimed a `.`
9
+ * inside a value is no longer read as concatenation.
10
+ *
11
+ * Source is `data.values.occurrences` (already rebased into document analyzed-byte
12
+ * space by `src/explain.ts`), not `Argument.valueSpan`. That is the load-bearing
13
+ * scoping fact (#295): `lexValueAnchors` is prefix-safe and already carries array
14
+ * interiors (`kind: \"element\"`, `parent`), so a container/value that the strict
15
+ * `lexArguments` refused wholesale still yields leaves.
16
+ *
17
+ * A container contains its members (`{1;2;3}` container `[9,16)` contains
18
+ * `[10,11)`, `[12,13)`, `[14,15)`). Emitting both would be a self-overlap that
19
+ * `buildTokens` throws on, so this fill **claims leaves only**: an occurrence
20
+ * that is nobody's `parent`. Container detection is `Set` of `parent` ids — no
21
+ * tree walk, members follow their container in the same flat list.
22
+ *
23
+ * `span` is quotes-INCLUDED by contract (deliberate; the quotes/escapes fill is
24
+ * a separate staging row and must not double-claim them).
25
+ *
26
+ * Vocabulary is provisional until #264 B5: one `value` class for every leaf span,
27
+ * regardless of shape/observed/schema type — the three #225 facts stay where
28
+ * they already are, in `data.values[]`, joined by offset. Do not read
29
+ * `ExplainValueOccurrence.facts` to pick a class. Whether `value` later splits
30
+ * per shape is B5 and does not move byte coverage.
31
+ *
32
+ * ~1,720 leaf bytes are already held by `variable-*` spans (a variable inside
33
+ * a quoted value). Reuses the clipping shape from `arg-tokens.ts`: offer only
34
+ * residual and keep `buildTokens`'s overlap throw as the safety net.
35
+ */
36
+
37
+ import type { ExplainToken, ExplainValueOccurrence } from "../explain.ts";
38
+ import { clipToResidual } from "./token-ranges.ts";
39
+
40
+ /**
41
+ * Leaf value + array-member spans on the residual.
42
+ *
43
+ * `analyzed` is the ASCII-normalized document text (length checks only);
44
+ * `residual` is the gap set left by earlier fills (sorted, no overlaps);
45
+ * `occurrences` are the already-rebased `ExplainValueOccurrence` rows from
46
+ * `data.values.occurrences`. Every emitted span's bytes are fully inside
47
+ * `residual`, sorted by `start`, non-overlapping, and carry
48
+ * `class: \"value\"` + `ev: \"e9\"`.
49
+ */
50
+ export function valueSpans(
51
+ analyzed: string,
52
+ residual: readonly { start: number; end: number }[],
53
+ occurrences: readonly ExplainValueOccurrence[],
54
+ ): ExplainToken[] {
55
+ const len = analyzed.length;
56
+ if (len === 0 || residual.length === 0 || occurrences.length === 0) return [];
57
+
58
+ const parentIds = new Set<string>();
59
+ for (const occurrence of occurrences)
60
+ if (occurrence.parent !== undefined) parentIds.add(occurrence.parent);
61
+
62
+ const out: ExplainToken[] = [];
63
+ for (const occurrence of occurrences) {
64
+ if (parentIds.has(occurrence.id)) continue;
65
+ const { start, end } = occurrence.span;
66
+ if (!Number.isInteger(start) || !Number.isInteger(end)) continue;
67
+ if (start < 0 || end <= start || end > len) continue;
68
+ for (const r of clipToResidual(start, end, residual)) {
69
+ out.push({
70
+ start: r.start,
71
+ end: r.end,
72
+ class: "value" as const,
73
+ ev: "e9",
74
+ });
75
+ }
76
+ }
77
+
78
+ out.sort((a, b) => a.start - b.start || a.end - b.end);
79
+ return out;
80
+ }