@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.
- package/CHANGELOG.md +39 -0
- package/GLOSSARY.txt +213 -0
- package/LICENSE +21 -0
- package/README.md +36 -8
- package/commands/AGENTS.md +7 -1
- package/commands/api/README.md +19 -18
- package/commands/api/examples.md +15 -0
- package/commands/btest/README.md +21 -32
- package/commands/check/README.md +6 -1
- package/commands/discover/README.md +5 -11
- package/commands/execute/README.md +41 -29
- package/commands/execute/examples.md +41 -3
- package/commands/explain/README.md +1716 -0
- package/commands/explain/examples.md +606 -0
- package/commands/retrieve/README.md +25 -27
- package/commands/retrieve/examples.md +16 -0
- package/commands/transfer/README.md +28 -20
- package/commands/transfer/examples.md +29 -0
- package/docs/CLI.md +353 -0
- package/docs/CONSTITUTION.md +66 -15
- package/docs/MATRIX.md +38 -1
- package/docs/errors/README.md +3 -0
- package/docs/errors/quickchr/machine-not-found.md +22 -0
- package/docs/errors/quickchr/machine-stopped.md +20 -0
- package/docs/errors/quickchr/package-unavailable.md +27 -0
- package/docs/errors/quickchr/unsupported-via.md +35 -0
- package/docs/errors/quickchr/unsupported.md +35 -0
- package/docs/errors/routeros/version-unsupported.md +26 -0
- package/docs/errors/transport/provider-trust.md +29 -0
- package/docs/errors/usage/stdin-ignored.md +28 -0
- package/docs/errors/validation/unknown-attribute.md +37 -4
- package/package.json +45 -10
- package/src/api-fanout.ts +29 -14
- package/src/api.ts +97 -54
- package/src/cli/api.ts +25 -6
- package/src/cli/btest.ts +18 -2
- package/src/cli/execute.ts +25 -6
- package/src/cli/explain.ts +503 -0
- package/src/cli/retrieve.ts +33 -1
- package/src/cli/selection.ts +71 -11
- package/src/cli/settings.ts +2 -2
- package/src/cli/transfer.ts +23 -6
- package/src/cli.ts +15 -2
- package/src/core/envelope.ts +5 -2
- package/src/core/error-catalog.ts +40 -0
- package/src/core/preflight.ts +306 -0
- package/src/core/routeros-string.ts +24 -0
- package/src/errors.ts +1 -0
- package/src/execute-fanout.ts +29 -14
- package/src/execute.ts +114 -63
- package/src/explain/arg-tokens.ts +114 -0
- package/src/explain/args.ts +1403 -0
- package/src/explain/blocks.ts +132 -0
- package/src/explain/brace-slots.ts +143 -0
- package/src/explain/brace-tokens.ts +38 -0
- package/src/explain/catalog.ts +1345 -0
- package/src/explain/coordinates.ts +329 -0
- package/src/explain/defects.ts +217 -0
- package/src/explain/is-known-menu.ts +17 -0
- package/src/explain/menus.ts +664 -0
- package/src/explain/operator-tokens.ts +310 -0
- package/src/explain/operators.ts +458 -0
- package/src/explain/path-tokens.ts +150 -0
- package/src/explain/pathresolve.ts +1324 -0
- package/src/explain/quoted-string.ts +241 -0
- package/src/explain/scope-brace.ts +308 -0
- package/src/explain/segment.ts +757 -0
- package/src/explain/string-tokens.ts +67 -0
- package/src/explain/symbol-values.ts +520 -0
- package/src/explain/symbols.ts +1597 -0
- package/src/explain/token-ranges.ts +72 -0
- package/src/explain/transport.ts +497 -0
- package/src/explain/value-tokens.ts +80 -0
- package/src/explain/values.ts +357 -0
- package/src/explain/verbs.ts +64 -0
- package/src/explain/verbsplit.ts +813 -0
- package/src/explain/write.ts +815 -0
- package/src/explain.ts +2152 -0
- package/src/index.ts +206 -4
- package/src/protocols/mac-telnet-console.ts +61 -17
- package/src/resolver/facts.ts +1 -1
- package/src/resolver/index.ts +23 -4
- package/src/resolver/quickchr-provider.ts +597 -0
- package/src/resolver/selection.ts +99 -12
- package/src/resolver/service-endpoint.ts +155 -0
- package/src/resolver/target.ts +1 -1
- package/src/retrieve-fanout.ts +26 -12
- package/src/retrieve.ts +81 -18
- package/src/transfer-fanout.ts +42 -25
- 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
|
+
}
|