@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.
- package/CHANGELOG.md +348 -0
- package/GLOSSARY.txt +252 -0
- package/LICENSE +21 -0
- package/README.md +44 -12
- package/commands/AGENTS.md +14 -5
- package/commands/api/AGENTS.md +13 -1
- package/commands/api/README.md +56 -26
- package/commands/api/examples.md +46 -1
- 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 +77 -36
- package/commands/execute/examples.md +120 -10
- package/commands/explain/README.md +2756 -0
- package/commands/explain/examples.md +702 -0
- package/commands/mcp/README.md +7 -4
- package/commands/mcp/examples.md +2 -2
- package/commands/retrieve/README.md +25 -27
- package/commands/retrieve/examples.md +16 -0
- package/commands/terminal/examples.md +7 -1
- package/commands/transfer/README.md +28 -20
- package/commands/transfer/examples.md +29 -0
- package/docs/CLI.md +353 -0
- package/docs/CONSTITUTION.md +138 -30
- package/docs/MATRIX.md +92 -18
- 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/syntax.md +29 -4
- package/docs/errors/validation/unknown-attribute.md +37 -4
- package/package.json +56 -10
- package/src/api-fanout.ts +46 -18
- package/src/api.ts +351 -121
- package/src/cli/api.ts +26 -7
- package/src/cli/btest.ts +19 -3
- 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/terminal.ts +1 -1
- package/src/cli/transfer.ts +23 -6
- package/src/cli.ts +15 -2
- package/src/core/envelope.ts +58 -2
- package/src/core/error-catalog.ts +42 -1
- package/src/core/execute-command.ts +148 -0
- package/src/core/inspect.ts +36 -4
- package/src/core/preflight.ts +306 -0
- package/src/core/routeros-string.ts +24 -0
- package/src/core/version.ts +4 -0
- package/src/core/yaml.ts +86 -0
- package/src/errors.ts +1 -0
- package/src/execute-fanout.ts +47 -18
- package/src/execute.ts +339 -307
- package/src/explain/arg-tokens.ts +151 -0
- package/src/explain/args.ts +1749 -0
- package/src/explain/blocks.ts +175 -0
- package/src/explain/brace-slots.ts +143 -0
- package/src/explain/brace-tokens.ts +39 -0
- package/src/explain/catalog.ts +1343 -0
- package/src/explain/coordinates.ts +488 -0
- package/src/explain/defects.ts +218 -0
- package/src/explain/escape-tokens.ts +77 -0
- package/src/explain/highlight-projection.ts +308 -0
- package/src/explain/is-known-menu.ts +17 -0
- package/src/explain/menus.ts +668 -0
- package/src/explain/operator-tokens.ts +316 -0
- package/src/explain/operators.ts +465 -0
- package/src/explain/path-tokens.ts +150 -0
- package/src/explain/pathresolve.ts +1569 -0
- package/src/explain/quoted-string.ts +331 -0
- package/src/explain/scope-brace.ts +461 -0
- package/src/explain/segment.ts +1165 -0
- package/src/explain/separator.ts +187 -0
- package/src/explain/split-owner.ts +194 -0
- package/src/explain/string-tokens.ts +77 -0
- package/src/explain/symbol-values.ts +556 -0
- package/src/explain/symbols.ts +1617 -0
- package/src/explain/token-ranges.ts +72 -0
- package/src/explain/transport.ts +514 -0
- package/src/explain/value-tokens.ts +83 -0
- package/src/explain/values.ts +357 -0
- package/src/explain/verbs.ts +64 -0
- package/src/explain/verbsplit.ts +955 -0
- package/src/explain/write.ts +856 -0
- package/src/explain.ts +2602 -0
- package/src/index.ts +225 -6
- package/src/mcp/server.ts +2 -1
- package/src/offline-gate.ts +148 -0
- 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 +27 -13
- package/src/retrieve.ts +82 -77
- package/src/settings.ts +2 -1
- package/src/transfer-fanout.ts +43 -26
- package/src/transfer.ts +88 -45
|
@@ -0,0 +1,331 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared RouterOS double-quoted string boundary scanner.
|
|
3
|
+
*
|
|
4
|
+
* Kept below both `segment.ts` and `scope-brace.ts` so statement-boundary
|
|
5
|
+
* lookback can ignore separators inside strings without creating an import
|
|
6
|
+
* cycle or growing a second, drifting quote scanner (#199/#246 review).
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { type Defect, defectAt } from "./defects.ts";
|
|
10
|
+
|
|
11
|
+
/** Analyzer resource bound, not a RouterOS grammar limit. */
|
|
12
|
+
export const MAX_STRING_FRAME_DEPTH = 256;
|
|
13
|
+
|
|
14
|
+
/** Where a double-quoted string ends, and whether it was closed at all. */
|
|
15
|
+
export interface QuotedStringScan {
|
|
16
|
+
/** index just past the closing `"`, or `text.length` when unterminated. */
|
|
17
|
+
end: number;
|
|
18
|
+
closed: boolean;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
type InterpolationEnd = (
|
|
22
|
+
text: string,
|
|
23
|
+
open: number,
|
|
24
|
+
limit: number,
|
|
25
|
+
frameDepth: number,
|
|
26
|
+
) => number;
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Valid single-char escapes after `\` inside a RouterOS string.
|
|
30
|
+
*
|
|
31
|
+
* This is the DEVICE's set, not the manual's table. The manual's
|
|
32
|
+
* "Constant Escape Sequences" list is a lower bound: it omits `\?`, which
|
|
33
|
+
* RouterOS classes `escaped` and evaluates to `?`. Swept byte by byte on CHR
|
|
34
|
+
* 7.23.3 — `:put "\<c>"` for every 0x20–0x7E plus TAB/LF/CRLF, scored on both
|
|
35
|
+
* the `/console/inspect request=highlight` class and the runtime result — and
|
|
36
|
+
* the accepted set is exactly these plus whitespace continuations plus
|
|
37
|
+
* two-digit uppercase hex (#252). Re-derive with
|
|
38
|
+
* `bun run explain:probe:escapes` (#186).
|
|
39
|
+
*/
|
|
40
|
+
const VALID_SINGLE = new Set([
|
|
41
|
+
'"',
|
|
42
|
+
"\\",
|
|
43
|
+
"n",
|
|
44
|
+
"r",
|
|
45
|
+
"t",
|
|
46
|
+
"$",
|
|
47
|
+
"_",
|
|
48
|
+
"a",
|
|
49
|
+
"b",
|
|
50
|
+
"f",
|
|
51
|
+
"v",
|
|
52
|
+
"?",
|
|
53
|
+
]);
|
|
54
|
+
|
|
55
|
+
function isHexUpper(c: string): boolean {
|
|
56
|
+
return (c >= "0" && c <= "9") || (c >= "A" && c <= "F");
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* `\` before whitespace is a line continuation INSIDE a string too, not only in
|
|
61
|
+
* code: the device swallows the pair and emits nothing (`:put "a\ b"` -> `ab`,
|
|
62
|
+
* and the same for TAB/CR/LF/CRLF). The manual states it under "Line joining" —
|
|
63
|
+
* "A backslash does not continue a token except for string". Every multi-line
|
|
64
|
+
* `source=`/`on-event=` script in the wild relies on it.
|
|
65
|
+
*/
|
|
66
|
+
function isContinuationWhitespace(c: string): boolean {
|
|
67
|
+
return c === " " || c === "\t" || c === "\r" || c === "\n";
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Is the escape at `at` (where text[at] === `\`) inside a string valid?
|
|
72
|
+
* Returns true for valid, false for invalid (unknown, lowercase hex, truncated).
|
|
73
|
+
*/
|
|
74
|
+
function isValidStringEscape(text: string, at: number): boolean {
|
|
75
|
+
const next1 = text[at + 1];
|
|
76
|
+
if (next1 === undefined) return false;
|
|
77
|
+
if (VALID_SINGLE.has(next1)) return true;
|
|
78
|
+
if (isContinuationWhitespace(next1)) return true;
|
|
79
|
+
if (isHexUpper(next1)) {
|
|
80
|
+
const next2 = text[at + 2];
|
|
81
|
+
if (next2 !== undefined && isHexUpper(next2)) return true;
|
|
82
|
+
return false;
|
|
83
|
+
}
|
|
84
|
+
return false;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* Bytes to skip past the escape at `at`. Always 2 or 3 so recovery advances
|
|
89
|
+
* even when the escape is malformed (#199). CRLF is ONE continuation, so it is
|
|
90
|
+
* the only non-hex three-byte escape.
|
|
91
|
+
*/
|
|
92
|
+
function escapeLength(text: string, at: number): 2 | 3 {
|
|
93
|
+
const next1 = text[at + 1];
|
|
94
|
+
if (next1 === "\r" && text[at + 2] === "\n") return 3;
|
|
95
|
+
if (
|
|
96
|
+
next1 !== undefined &&
|
|
97
|
+
isHexUpper(next1) &&
|
|
98
|
+
isHexUpper(text[at + 2] ?? "")
|
|
99
|
+
)
|
|
100
|
+
return 3;
|
|
101
|
+
return 2;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
// ---------------------------------------------------------------------------
|
|
105
|
+
// Single source of truth for the frame grammar (#253).
|
|
106
|
+
//
|
|
107
|
+
// Both `scanQuotedString` and `walkStringEscapes` walk the same
|
|
108
|
+
// nesting: a `"` frame is string phase, anything else is code phase where
|
|
109
|
+
// `$[`/`$(` inside a string push a bracket frame and brackets nest until
|
|
110
|
+
// matched. The helpers below own the transitions, the depth guard, and the
|
|
111
|
+
// phase dispatch once — callers differ only in what they do with an escape
|
|
112
|
+
// (skip vs validate) and whether they skip comment spans.
|
|
113
|
+
// ---------------------------------------------------------------------------
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* One step of the shared frame machine. Owns the depth guard, the `top` read,
|
|
117
|
+
* and the phase dispatch (`"` vs code) so callers cannot drift on them.
|
|
118
|
+
* When inside a string at `\`, delegates to `onStringEscape` so callers can
|
|
119
|
+
* validate or just skip. Returns bytes to advance, or a Defect, or 0 when the
|
|
120
|
+
* shared depth cap is hit (caller must break).
|
|
121
|
+
*/
|
|
122
|
+
function stepFrame(
|
|
123
|
+
text: string,
|
|
124
|
+
i: number,
|
|
125
|
+
frames: string[],
|
|
126
|
+
onStringEscape: (at: number) => number | Defect,
|
|
127
|
+
maxFrameDepth: number = MAX_STRING_FRAME_DEPTH,
|
|
128
|
+
): number | Defect {
|
|
129
|
+
if (frames.length > maxFrameDepth) return 0;
|
|
130
|
+
const top = frames[frames.length - 1] as string | undefined;
|
|
131
|
+
const c = text[i] as string;
|
|
132
|
+
if (top === '"') {
|
|
133
|
+
if (c === "\\") return onStringEscape(i);
|
|
134
|
+
if (c === '"') {
|
|
135
|
+
frames.pop();
|
|
136
|
+
return 1;
|
|
137
|
+
}
|
|
138
|
+
if (c === "$" && (text[i + 1] === "[" || text[i + 1] === "(")) {
|
|
139
|
+
frames.push(text[i + 1] as string);
|
|
140
|
+
return 2;
|
|
141
|
+
}
|
|
142
|
+
return 1;
|
|
143
|
+
}
|
|
144
|
+
if (c === '"') {
|
|
145
|
+
frames.push('"');
|
|
146
|
+
return 1;
|
|
147
|
+
}
|
|
148
|
+
if (c === "[" || c === "(" || c === "{") {
|
|
149
|
+
frames.push(c);
|
|
150
|
+
return 1;
|
|
151
|
+
}
|
|
152
|
+
if (c === "]" || c === ")" || c === "}") {
|
|
153
|
+
const want = c === "]" ? "[" : c === ")" ? "(" : "{";
|
|
154
|
+
if (top === want) frames.pop();
|
|
155
|
+
return 1;
|
|
156
|
+
}
|
|
157
|
+
return 1;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/**
|
|
161
|
+
* Boundary recovery must skip the escaped byte even when the escape itself
|
|
162
|
+
* is malformed, otherwise the closing quote is lost and every later comment
|
|
163
|
+
* is swallowed (#199). Validation is separate; here we just advance.
|
|
164
|
+
*/
|
|
165
|
+
function stringEscapeSkip(text: string, at: number): number {
|
|
166
|
+
return escapeLength(text, at);
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
/**
|
|
170
|
+
* Locate defect at the escaped byte the device marks `error`.
|
|
171
|
+
* For `\q` it's `q`; for `\0a` it's `a`.
|
|
172
|
+
* For truncated `\0` before `"` the device marks the closing quote;
|
|
173
|
+
* centrs reports at the backslash instead, because that byte is stable
|
|
174
|
+
* when the string is unterminated (#252).
|
|
175
|
+
*/
|
|
176
|
+
function stringEscapeValidated(text: string, at: number): number | Defect {
|
|
177
|
+
if (!isValidStringEscape(text, at)) {
|
|
178
|
+
const next1 = text[at + 1];
|
|
179
|
+
let defectAtOffset: number;
|
|
180
|
+
if (next1 === undefined) defectAtOffset = at;
|
|
181
|
+
else if (isHexUpper(next1)) {
|
|
182
|
+
const next2 = text[at + 2];
|
|
183
|
+
if (next2 === undefined) defectAtOffset = at;
|
|
184
|
+
else if (!isHexUpper(next2)) defectAtOffset = at + 2;
|
|
185
|
+
else defectAtOffset = at;
|
|
186
|
+
} else defectAtOffset = at + 1;
|
|
187
|
+
return defectAt("bad-string-escape", defectAtOffset);
|
|
188
|
+
}
|
|
189
|
+
return escapeLength(text, at);
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
/**
|
|
193
|
+
* Find the end of the double-quoted string that opens at `open`.
|
|
194
|
+
*
|
|
195
|
+
* A `$[…]` or `$(…)` substitution inside a string is code and may contain
|
|
196
|
+
* nested strings, so the next quote is not necessarily the outer close.
|
|
197
|
+
*
|
|
198
|
+
* `limit` is the end of the region the caller is reading, defaulting to the
|
|
199
|
+
* whole text. A reader working on a RANGE of a larger document must pass it:
|
|
200
|
+
* a string left unterminated inside the range would otherwise be closed by a
|
|
201
|
+
* quote belonging to later text, which is not the answer the same reader gets
|
|
202
|
+
* from the range taken as a string of its own (#322). Lookahead past `limit`
|
|
203
|
+
* cannot change the result — every advance that reads ahead is at least one
|
|
204
|
+
* byte, so it can only overshoot an end that is reported clamped either way.
|
|
205
|
+
*/
|
|
206
|
+
export function scanQuotedString(
|
|
207
|
+
text: string,
|
|
208
|
+
open: number,
|
|
209
|
+
limit: number = text.length,
|
|
210
|
+
interpolationEnd?: InterpolationEnd,
|
|
211
|
+
maxFrameDepth: number = MAX_STRING_FRAME_DEPTH,
|
|
212
|
+
): QuotedStringScan {
|
|
213
|
+
if (maxFrameDepth < 1) return { end: limit, closed: false };
|
|
214
|
+
const frames: string[] = ['"'];
|
|
215
|
+
let i = open + 1;
|
|
216
|
+
while (i < limit) {
|
|
217
|
+
if (
|
|
218
|
+
frames.at(-1) === '"' &&
|
|
219
|
+
text[i] === "$" &&
|
|
220
|
+
text[i + 1] === "[" &&
|
|
221
|
+
interpolationEnd !== undefined
|
|
222
|
+
) {
|
|
223
|
+
const end = interpolationEnd(text, i + 1, limit, frames.length);
|
|
224
|
+
if (end >= limit) return { end: limit, closed: false };
|
|
225
|
+
i = end + 1;
|
|
226
|
+
continue;
|
|
227
|
+
}
|
|
228
|
+
const res = stepFrame(
|
|
229
|
+
text,
|
|
230
|
+
i,
|
|
231
|
+
frames,
|
|
232
|
+
(at) => stringEscapeSkip(text, at),
|
|
233
|
+
maxFrameDepth,
|
|
234
|
+
);
|
|
235
|
+
if (typeof res !== "number") break;
|
|
236
|
+
if (res === 0) break;
|
|
237
|
+
i += res;
|
|
238
|
+
if (frames.length === 0) return { end: i, closed: true };
|
|
239
|
+
}
|
|
240
|
+
return { end: limit, closed: false };
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
/** One string-internal escape: `[start, end)` over the `\` and what it escapes. */
|
|
244
|
+
export interface StringEscapeSpan {
|
|
245
|
+
start: number;
|
|
246
|
+
end: number;
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
/**
|
|
250
|
+
* What one document-wide escape walk found: the valid escapes, and the first
|
|
251
|
+
* invalid one if the walk hit one.
|
|
252
|
+
*/
|
|
253
|
+
export interface StringEscapeWalk {
|
|
254
|
+
/** Valid escapes in document order, up to (not including) the first invalid one. */
|
|
255
|
+
escapes: StringEscapeSpan[];
|
|
256
|
+
/** At most one defect, at the first invalid escape. */
|
|
257
|
+
defects: Defect[];
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
/**
|
|
261
|
+
* Walk every string-internal escape in `text`, once.
|
|
262
|
+
*
|
|
263
|
+
* Same frame model as `scanQuotedString`, validating each `\` inside a string
|
|
264
|
+
* frame against the documented escape table (capital hex, truncated, unknown).
|
|
265
|
+
* Comment spans are skipped: a `"` inside a comment is not a string.
|
|
266
|
+
*
|
|
267
|
+
* The walk yields BOTH answers because they are the same reading (#264): a
|
|
268
|
+
* valid escape becomes an `escaped` token and an invalid one becomes a
|
|
269
|
+
* `bad-string-escape` diagnostic, and deriving them from two walks would let
|
|
270
|
+
* the token stream and the diagnostics disagree about which bytes are an
|
|
271
|
+
* escape. It stops at the first invalid escape, so the escapes list is the
|
|
272
|
+
* prefix the validator actually vouched for — past a malformed escape the
|
|
273
|
+
* string boundaries themselves are in doubt, and painting `escaped` there
|
|
274
|
+
* would be a claim this walk cannot support.
|
|
275
|
+
*
|
|
276
|
+
* `collectEscapes: false` validates without retaining the valid spans. The
|
|
277
|
+
* default `explainCommand` path consumes only `defects`, and materializing a
|
|
278
|
+
* span per escape for a caller that never asked for `data.tokens` is the kind
|
|
279
|
+
* of cost #313/#317/#320 were about — work nobody requested. Validation is identical
|
|
280
|
+
* either way — only the retention differs, so the two callers cannot diverge
|
|
281
|
+
* on what counts as an escape.
|
|
282
|
+
*/
|
|
283
|
+
export function walkStringEscapes(
|
|
284
|
+
text: string,
|
|
285
|
+
comments: readonly { start: number; end: number }[] = [],
|
|
286
|
+
collectEscapes = true,
|
|
287
|
+
): StringEscapeWalk {
|
|
288
|
+
const escapes: StringEscapeSpan[] = [];
|
|
289
|
+
const frames: string[] = [];
|
|
290
|
+
let i = 0;
|
|
291
|
+
let ci = 0;
|
|
292
|
+
while (i < text.length) {
|
|
293
|
+
// Skip comment spans (analyzed-byte offsets, same space as `text`).
|
|
294
|
+
while (
|
|
295
|
+
ci < comments.length &&
|
|
296
|
+
i >= (comments[ci] as { start: number; end: number }).end
|
|
297
|
+
)
|
|
298
|
+
ci++;
|
|
299
|
+
if (ci < comments.length) {
|
|
300
|
+
const c = comments[ci] as { start: number; end: number };
|
|
301
|
+
if (i >= c.start && i < c.end) {
|
|
302
|
+
i = c.end;
|
|
303
|
+
continue;
|
|
304
|
+
}
|
|
305
|
+
}
|
|
306
|
+
const res = stepFrame(text, i, frames, (at) => {
|
|
307
|
+
const step = stringEscapeValidated(text, at);
|
|
308
|
+
if (collectEscapes && typeof step === "number")
|
|
309
|
+
escapes.push({ start: at, end: at + step });
|
|
310
|
+
return step;
|
|
311
|
+
});
|
|
312
|
+
if (typeof res !== "number") return { escapes, defects: [res] };
|
|
313
|
+
if (res === 0) break;
|
|
314
|
+
i += res;
|
|
315
|
+
}
|
|
316
|
+
return { escapes, defects: [] };
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
/**
|
|
320
|
+
* Collect the first invalid string-internal escape in `text`, if any.
|
|
321
|
+
*
|
|
322
|
+
* The defect half of {@link walkStringEscapes}, retaining no escape spans.
|
|
323
|
+
* Callers that also need the valid escapes should call the walk directly
|
|
324
|
+
* rather than walking twice.
|
|
325
|
+
*/
|
|
326
|
+
export function collectStringEscapeDefects(
|
|
327
|
+
text: string,
|
|
328
|
+
comments: readonly { start: number; end: number }[] = [],
|
|
329
|
+
): Defect[] {
|
|
330
|
+
return walkStringEscapes(text, comments, false).defects;
|
|
331
|
+
}
|