@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,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
+ }