@tikoci/centrs 0.1.3 → 0.1.6

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