@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,175 @@
1
+ /**
2
+ * Block / scope-brace classification for `explain` (centrs canonicalizer).
3
+ *
4
+ * Ratified by the phase-0 lab, question Q2 (#185) and promoted from the
5
+ * throwaway probe `.scratch/explain-lab-blocktree.ts`. Q2 asks: can an offline,
6
+ * schema-free brace tracker tell a `{…}` SCOPE (`do=`/`else=`/`on-error=`, which
7
+ * IL lowers to `name=;(evl …)`) from a `name={…}` LITERAL value (braces
8
+ * stripped, body kept as RAW text, never lowered)? Descending into a literal
9
+ * body as if it held statements is the prior-art bug (canonicalize-audit #8/H5).
10
+ *
11
+ * The source-visible scope set is a small CLOSED set harvested across the
12
+ * 913-script corpus, so the distinction is schema-free — it reads the argument
13
+ * name (and, for the one head-dependent case, the leading directive) off the
14
+ * statement itself.
15
+ *
16
+ * This module ships only the (single-pass, non-recursive) classification
17
+ * primitives the path resolver needs. The recursive block *tree* / topology
18
+ * surface stays in the lab until it can be promoted with a bounded, index-based
19
+ * traversal of its own.
20
+ *
21
+ * Every string skip here goes through `segment.ts`'s `scanQuotedString` rather
22
+ * than a local quote-to-quote loop, so this module and the segmenter cannot
23
+ * drift on where a string ends: a `}` inside the nested string of a `$[…]`
24
+ * substitution is not a brace, and reading it as one truncated the scope body
25
+ * (#199).
26
+ */
27
+
28
+ import {
29
+ DIRECTIVE_BODY,
30
+ HEAD_SCOPED_ARG_NAMES,
31
+ matchingBraceEnd,
32
+ SCOPE_ARG_NAMES,
33
+ scopeNameFromMasked,
34
+ } from "./scope-brace.ts";
35
+ import {
36
+ documentRange,
37
+ type MaskedRange,
38
+ maskComments,
39
+ scanQuotedString,
40
+ } from "./segment.ts";
41
+
42
+ export { DIRECTIVE_BODY, HEAD_SCOPED_ARG_NAMES, SCOPE_ARG_NAMES };
43
+ /** One depth-0 scope block found in a statement: its name and raw body text. */
44
+ export interface ScopeBlock {
45
+ name: string;
46
+ body: string;
47
+ /**
48
+ * Offset of `body`'s first character within the text `scopeBlocks` was given
49
+ * — i.e. one past the opening `{`.
50
+ *
51
+ * Without it a caller that re-segments `body` gets BODY-RELATIVE offsets and
52
+ * no way to rebase them, so a span or region found inside a `do={…}` cannot
53
+ * be reported in document space. `pathresolve` is that caller.
54
+ */
55
+ start: number;
56
+ }
57
+
58
+ /**
59
+ * The scope name for the `{` at `open`, or null when the brace is a value.
60
+ * Two shapes qualify: `<name>={` with name in the closed set (or head-scoped
61
+ * under the right leading directive), and a brace body directly following a
62
+ * body-taking directive (`:do {`, `:retry {`, `:onerror Err {`).
63
+ */
64
+ export function scopeNameAt(text: string, open: number): string | null {
65
+ return scopeNameFromMasked(maskComments(text), open);
66
+ }
67
+
68
+ /** True when the `{` at `open` opens a scope rather than a literal value. */
69
+ export function isScopeBrace(text: string, open: number): boolean {
70
+ return scopeNameAt(text, open) !== null;
71
+ }
72
+
73
+ /**
74
+ * {@link isScopeBrace} for a reader that already holds the document's mask,
75
+ * with `open` relative to `range` rather than to the whole document.
76
+ *
77
+ * The string form has to mask the text it is given, and a statement's text
78
+ * carries its whole nested `do={…}` subtree — so asking it this question once
79
+ * per brace re-derived a mask over that subtree once per enclosing level
80
+ * (#322). Same answer: the floor is what makes a ranged reading identical to
81
+ * the region read on its own (`scope-brace.ts` → `scopeNameFromMasked`).
82
+ */
83
+ export function isScopeBraceIn(range: MaskedRange, open: number): boolean {
84
+ return (
85
+ scopeNameFromMasked(range.masked, range.start + open, range.start) !== null
86
+ );
87
+ }
88
+
89
+ /**
90
+ * The depth-0 scope `{…}` in a statement, with names and raw body text. A
91
+ * single left-to-right pass (no recursion): each scope's body is returned raw
92
+ * for the caller to segment/recurse under its own depth budget. Literal `{…}`
93
+ * values are skipped, not descended.
94
+ */
95
+ export function scopeBlocks(text: string): ScopeBlock[] {
96
+ return scopeBlocksIn(documentRange(text));
97
+ }
98
+
99
+ /**
100
+ * The same scan over a RANGE of a document, reading the document's own mask.
101
+ *
102
+ * `ScopeBlock.start` stays relative to the range, which is what its contract
103
+ * already promises ("within the text `scopeBlocks` was given") and what a
104
+ * caller rebasing through `Loc` needs. Every other offset here is absolute.
105
+ */
106
+ export function scopeBlocksIn(range: MaskedRange): ScopeBlock[] {
107
+ // Scan a comment-masked copy for structure so a `#`-comment `}`/`[` cannot
108
+ // truncate a body or shift depth; slice the ORIGINAL for the body text.
109
+ const { text, masked, start: lo, end: hi } = range;
110
+ const blocks: ScopeBlock[] = [];
111
+ let depth = 0;
112
+ for (let i = lo; i < hi; i++) {
113
+ const c = masked[i];
114
+ if (c === '"') {
115
+ i = scanQuotedString(masked, i, hi).end - 1;
116
+ continue;
117
+ }
118
+ if (c === "[" || c === "(") depth++;
119
+ else if (c === "]" || c === ")") {
120
+ if (depth > 0) depth--;
121
+ } else if (c === "{") {
122
+ const indexedEnd = range.coordinateIdentity
123
+ ? matchingBraceEnd(masked, i)
124
+ : undefined;
125
+ const end =
126
+ indexedEnd !== undefined && indexedEnd < hi
127
+ ? indexedEnd
128
+ : matchBraceInMasked(masked, i, hi);
129
+ if (depth === 0) {
130
+ const name = scopeNameFromMasked(masked, i, lo);
131
+ if (name !== null)
132
+ blocks.push({
133
+ name,
134
+ body: text.slice(i + 1, end),
135
+ start: i + 1 - lo,
136
+ });
137
+ }
138
+ i = end;
139
+ } else if (c === "}") {
140
+ if (depth > 0) depth--;
141
+ }
142
+ }
143
+ return blocks;
144
+ }
145
+
146
+ /** Bodies of the depth-0 scope `{…}` in a statement (for block recursion). */
147
+ export function scopeBodies(text: string): string[] {
148
+ return scopeBlocks(text).map((b) => b.body);
149
+ }
150
+
151
+ function matchBraceInMasked(
152
+ masked: string,
153
+ open: number,
154
+ limit: number = masked.length,
155
+ ): number {
156
+ let depth = 0;
157
+ for (let i = open; i < limit; i++) {
158
+ const c = masked[i];
159
+ if (c === '"') {
160
+ i = scanQuotedString(masked, i, limit).end - 1;
161
+ continue;
162
+ }
163
+ if (c === "{") depth++;
164
+ else if (c === "}") {
165
+ depth--;
166
+ if (depth === 0) return i;
167
+ }
168
+ }
169
+ return limit;
170
+ }
171
+
172
+ /** Index of the `}` matching the `{` at `open`, honoring strings and comments. */
173
+ export function matchBrace(text: string, open: number): number {
174
+ return matchBraceInMasked(maskComments(text), open);
175
+ }
@@ -0,0 +1,143 @@
1
+ /**
2
+ * Which root-directive value slots read a `{…}` as an ARRAY (#225, #257).
3
+ *
4
+ * `explain` used to gate brace arrays on the statement's resolved path: any
5
+ * `/` path allowed one, positionally or under `in=`. A device sweep showed that
6
+ * is wrong in both directions on CHR 7.23.3 — `:delay {1;2}`, `:beep {1;2}`,
7
+ * `:resolve {1;2}`, `:if condition={1;2}` and `:local name={1;2}` are syntax
8
+ * errors the path rule called arrays, while `:for from=`, `:execute script=`,
9
+ * `:local value=` and 50-odd more are accepted braces it refused.
10
+ *
11
+ * The real key is the SLOT, and a slot has four outcomes, not two:
12
+ *
13
+ * - **array** — the content is EVALUATED: `:foreach i in={(1,2)} do={}`
14
+ * lowers to `in=(, 1 2)`. Only these 72 belong here.
15
+ * - **code** — the braces are a program. `:onerror e in={1;2} do={}` lowers
16
+ * to `in=;(evl (<%% bad command name 1 …))`, so the SAME NAME is an array
17
+ * on `:foreach` and a block on `:onerror`.
18
+ * - **text** — the brace is accepted and its content taken VERBATIM as a
19
+ * script: `:execute script={(1,2)}` lowers to `script=(1,2)`, not
20
+ * `(, 1 2)`. This outcome is why `{1;2}` alone is not a sufficient probe —
21
+ * `:local z {1;2}` and `:execute script={1;2}` both lower to `…=1;2`, and
22
+ * reading only that put `:execute script=` in the array set, one step from
23
+ * hinting `array` on a script body.
24
+ * - **error** — `:parse` refuses the statement.
25
+ *
26
+ * A slot the sweep could not ask (30 of 222, mostly ones whose scalar control
27
+ * also fails) is absent from this table, which is the fail-closed direction:
28
+ * absent means the walk refuses to read the value, and a refusal costs nothing
29
+ * but coverage. Every brace array in the 948-script corpus lands on a slot
30
+ * listed here except one `:execute {…}` script body, which is `text`.
31
+ *
32
+ * A positional slot is keyed by its zero-based index among positionals, because
33
+ * position decides: `:local {1;2}` puts the literal in the NAME slot (a syntax
34
+ * error) while `:local z {1;2}` puts it in the VALUE slot.
35
+ *
36
+ * **The verb is matched VERBATIM, and that is the device's rule, not laziness.**
37
+ * `verbsplit.ts` keeps a run token's source casing, and RouterOS accepts only
38
+ * the lower-case spelling: `:LOCAL z {1;2}`, `:Local z {1;2}`, `:PUT {1;2}` and
39
+ * even `/IP/DNS/set servers=1.1.1.1` all draw `expected command name (line 1
40
+ * column 2)` on CHR 7.23.3, while an ARGUMENT's casing is free (`:local Z {1;2}`
41
+ * parses and binds `$Z`). Normalizing the verb here would open the gate on a
42
+ * statement the device refuses to parse — the exact fabrication this table
43
+ * exists to remove — so a differently-cased directive misses every entry and is
44
+ * refused, which is the same answer the device gives.
45
+ *
46
+ * The evidence is `test/fixtures/explain/values.json` →
47
+ * `interiorGrounding.braceSlots` (222 rows, CHR 7.23.3, `:parse` IL — NOT
48
+ * `highlight`, which accepts `{1;2,}` and `(1,)` that `:parse` rejects), and
49
+ * `explain-values.test.ts` asserts this table still equals the `array` rows of
50
+ * that block. Re-derive with `bun run explain:probe:brace-slots` (#186).
51
+ */
52
+ const BRACE_ARRAY_SLOTS: ReadonlyMap<string, ReadonlySet<string>> = new Map(
53
+ (
54
+ [
55
+ [
56
+ "convert",
57
+ ["#0", "from-scheme", "to-scheme", "transform-scheme", "value"],
58
+ ],
59
+ ["deserialize", ["#0", "value"]],
60
+ ["error", ["#0", "message"]],
61
+ ["find", ["#0", "#1", "from", "in", "key"]],
62
+ ["for", ["from", "step", "to"]],
63
+ ["foreach", ["in"]],
64
+ ["global", ["#1", "value"]],
65
+ ["len", ["#0", "value"]],
66
+ ["local", ["#1", "value"]],
67
+ ["parse", ["#0", "value"]],
68
+ ["pick", ["#0", "#1", "begin", "counter", "end"]],
69
+ ["put", ["#0", "message"]],
70
+ ["range", ["#0", "#1", "from", "to"]],
71
+ ["return", ["#0", "value"]],
72
+ ["rndnum", ["#0", "#1", "from", "to"]],
73
+ ["rndstr", ["#0", "#1", "from", "length"]],
74
+ ["serialize", ["#0", "order", "value"]],
75
+ ["set", ["#1", "value"]],
76
+ ["toarray", ["#0", "value"]],
77
+ ["tobool", ["#0", "value"]],
78
+ ["toid", ["#0", "value"]],
79
+ ["toip", ["#0", "value"]],
80
+ ["toip6", ["#0", "value"]],
81
+ ["tonsec", ["#0", "value"]],
82
+ ["tonum", ["#0", "value"]],
83
+ ["tostr", ["#0", "value"]],
84
+ ["totime", ["#0", "value"]],
85
+ ["typeof", ["#0", "value"]],
86
+ ] as const
87
+ ).map(([verb, slots]) => [verb, new Set<string>(slots)]),
88
+ );
89
+
90
+ /** Slots that accept a brace as code or verbatim script text, not as an array. */
91
+ const BRACE_PROGRAM_SLOTS: ReadonlyMap<string, ReadonlySet<string>> = new Map(
92
+ (
93
+ [
94
+ ["set", ["do"]],
95
+ ["do", ["#0", "#1", "command", "on-error"]],
96
+ ["execute", ["#0", "script"]],
97
+ ["for", ["do", "on-error"]],
98
+ ["foreach", ["do", "on-error"]],
99
+ ["global", ["do"]],
100
+ ["grep", ["#0", "script"]],
101
+ ["if", ["do", "else"]],
102
+ ["local", ["do"]],
103
+ ["onerror", ["#1", "do", "in"]],
104
+ ["retry", ["#0", "#1", "command", "on-error"]],
105
+ ["time", ["#0", "#1", "command"]],
106
+ ["while", ["do"]],
107
+ ] as const
108
+ ).map(([verb, slots]) => [verb, new Set<string>(slots)]),
109
+ );
110
+
111
+ /** The table as flat `verb slot` pairs, for the fixture-drift assertion. */
112
+ export function braceArraySlotPairs(): string[] {
113
+ const pairs: string[] = [];
114
+ for (const [verb, slots] of BRACE_ARRAY_SLOTS)
115
+ for (const slot of slots) pairs.push(`${verb} ${slot}`);
116
+ return pairs.sort();
117
+ }
118
+
119
+ /** The code/text table as flat pairs, for the same fixture-drift assertion. */
120
+ export function braceProgramSlotPairs(): string[] {
121
+ const pairs: string[] = [];
122
+ for (const [verb, slots] of BRACE_PROGRAM_SLOTS)
123
+ for (const slot of slots) pairs.push(`${verb} ${slot}`);
124
+ return pairs.sort();
125
+ }
126
+
127
+ /**
128
+ * Whether a `{…}` in this slot of this root directive is an array literal.
129
+ *
130
+ * `verb` is the resolved directive name without its colon; `slot` is the
131
+ * lowercase argument name, or `#<index>` for a positional.
132
+ */
133
+ export function braceSlotTakesArray(verb: string, slot: string): boolean {
134
+ return BRACE_ARRAY_SLOTS.get(verb)?.has(slot) === true;
135
+ }
136
+
137
+ /** Whether the device sweep proved this root-directive slot accepts `{...}`. */
138
+ export function braceSlotAcceptsBrace(verb: string, slot: string): boolean {
139
+ return (
140
+ braceSlotTakesArray(verb, slot) ||
141
+ BRACE_PROGRAM_SLOTS.get(verb)?.has(slot) === true
142
+ );
143
+ }
@@ -0,0 +1,39 @@
1
+ /**
2
+ * B3 brace fill — claims brace delimiters on the residual.
3
+ *
4
+ * Value-anchored array literals are claimed as `value` leaves, but their outer
5
+ * delimiters remain unclassified (container excluded to avoid overlap). Scope
6
+ * braces (`do={...}` etc.) are never array values. Both families therefore
7
+ * appear on the residual after the `value` and `string` fills, so this fill
8
+ * claims every remaining `{` / `}` byte there as `brace`. #264 B5 kept scope
9
+ * and array delimiters in one class: `structure.blocks[]` already locates every
10
+ * scope block, and an array container is the `data.values.occurrences[]` entry
11
+ * that is some other occurrence's `parent`, so the distinction is a join away.
12
+ */
13
+
14
+ import type { ExplainToken } from "../explain.ts";
15
+ import { clipToResidual } from "./token-ranges.ts";
16
+
17
+ /** Brace delimiter spans on the residual. */
18
+ export function braceSpans(
19
+ analyzed: string,
20
+ residual: readonly { start: number; end: number }[],
21
+ ): ExplainToken[] {
22
+ const len = analyzed.length;
23
+ if (len === 0 || residual.length === 0) return [];
24
+ const out: ExplainToken[] = [];
25
+ for (let i = 0; i < analyzed.length; i++) {
26
+ const c = analyzed[i] as string;
27
+ if (c !== "{" && c !== "}") continue;
28
+ for (const r of clipToResidual(i, i + 1, residual)) {
29
+ out.push({
30
+ start: r.start,
31
+ end: r.end,
32
+ class: "brace" as const,
33
+ ev: "e14",
34
+ });
35
+ }
36
+ }
37
+ out.sort((a, b) => a.start - b.start || a.end - b.end);
38
+ return out;
39
+ }