@tikoci/centrs 0.1.4 → 0.1.7

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 (85) hide show
  1. package/CHANGELOG.md +417 -1
  2. package/GLOSSARY.txt +40 -1
  3. package/README.md +16 -8
  4. package/commands/AGENTS.md +7 -4
  5. package/commands/api/AGENTS.md +13 -1
  6. package/commands/api/README.md +50 -10
  7. package/commands/api/examples.md +100 -2
  8. package/commands/devices/README.md +10 -1
  9. package/commands/devices/examples.md +33 -2
  10. package/commands/execute/README.md +68 -25
  11. package/commands/execute/examples.md +168 -7
  12. package/commands/explain/README.md +1206 -155
  13. package/commands/explain/examples.md +99 -3
  14. package/commands/mcp/README.md +9 -5
  15. package/commands/mcp/examples.md +2 -2
  16. package/commands/retrieve/examples.md +20 -0
  17. package/commands/terminal/examples.md +7 -1
  18. package/docs/CLI.md +3 -3
  19. package/docs/CONSTITUTION.md +83 -21
  20. package/docs/MATRIX.md +89 -52
  21. package/docs/errors/cdb/create-failed.md +20 -0
  22. package/docs/errors/cdb/file-exists.md +23 -0
  23. package/docs/errors/input/invalid-target.md +25 -3
  24. package/docs/errors/routeros/invalid-value.md +3 -2
  25. package/docs/errors/transport/cancel-unacknowledged.md +24 -0
  26. package/docs/errors/validation/syntax.md +29 -4
  27. package/package.json +13 -2
  28. package/src/api-fanout.ts +17 -4
  29. package/src/api.ts +346 -75
  30. package/src/cli/api.ts +1 -1
  31. package/src/cli/btest.ts +1 -1
  32. package/src/cli/devices.ts +16 -3
  33. package/src/cli/explain.ts +1 -1
  34. package/src/cli/terminal.ts +1 -1
  35. package/src/core/envelope.ts +53 -0
  36. package/src/core/error-catalog.ts +16 -1
  37. package/src/core/execute-command.ts +455 -0
  38. package/src/core/inspect.ts +58 -26
  39. package/src/core/routeros-errors.ts +66 -0
  40. package/src/core/version.ts +4 -0
  41. package/src/core/yaml.ts +86 -0
  42. package/src/data/winbox-cdb-write.ts +22 -8
  43. package/src/devices.ts +142 -28
  44. package/src/execute-fanout.ts +19 -5
  45. package/src/execute.ts +247 -295
  46. package/src/explain/arg-tokens.ts +62 -25
  47. package/src/explain/args.ts +398 -52
  48. package/src/explain/blocks.ts +54 -11
  49. package/src/explain/brace-tokens.ts +4 -3
  50. package/src/explain/catalog.ts +42 -44
  51. package/src/explain/coordinates.ts +159 -0
  52. package/src/explain/defects.ts +2 -1
  53. package/src/explain/escape-tokens.ts +77 -0
  54. package/src/explain/highlight-projection.ts +308 -0
  55. package/src/explain/menus.ts +9 -5
  56. package/src/explain/operator-tokens.ts +13 -7
  57. package/src/explain/operators.ts +27 -20
  58. package/src/explain/path-tokens.ts +1 -1
  59. package/src/explain/pathresolve.ts +358 -113
  60. package/src/explain/quoted-string.ts +110 -20
  61. package/src/explain/scope-brace.ts +175 -22
  62. package/src/explain/segment.ts +429 -21
  63. package/src/explain/separator.ts +187 -0
  64. package/src/explain/split-owner.ts +194 -0
  65. package/src/explain/string-tokens.ts +14 -4
  66. package/src/explain/symbol-values.ts +209 -173
  67. package/src/explain/symbols.ts +21 -1
  68. package/src/explain/transport.ts +17 -0
  69. package/src/explain/value-tokens.ts +15 -12
  70. package/src/explain/verbsplit.ts +165 -23
  71. package/src/explain/write.ts +47 -6
  72. package/src/explain.ts +522 -72
  73. package/src/index.ts +21 -2
  74. package/src/mcp/server.ts +2 -1
  75. package/src/mcp/tools.ts +1 -1
  76. package/src/offline-gate.ts +148 -0
  77. package/src/protocols/adapter.ts +12 -1
  78. package/src/protocols/mac-telnet-console.ts +6 -4
  79. package/src/protocols/native-api.ts +29 -3
  80. package/src/resolver/target.ts +32 -12
  81. package/src/retrieve-fanout.ts +1 -1
  82. package/src/retrieve.ts +18 -63
  83. package/src/settings.ts +2 -1
  84. package/src/transfer-fanout.ts +1 -1
  85. package/src/transfer.ts +1 -1
@@ -0,0 +1,187 @@
1
+ /**
2
+ * The missing statement separator between two command-shaped runs (#311).
3
+ *
4
+ * RouterOS accepts slash-shaped operands after a verb — `/file remove
5
+ * /flash/skins/foo.html` lowers cleanly to `numbers=/flash/skins/foo.html` — so
6
+ * "a `/` after the verb" is NOT the signal, and a rule built on it would reject
7
+ * valid input. What the device rejects is narrower: a known **menu** path in
8
+ * operand position, followed by a console **verb**.
9
+ *
10
+ * ## What the device actually does (CHR 7.24.2, `:parse` + `highlight`)
11
+ *
12
+ * #311 reported the error byte as the `=` of the trailing attribute
13
+ * (`expected end of command (line 1 column 55)` for `/ip/address add
14
+ * interface=ether1 /ip/route add gateway=192.168.88.1`). Probing the shape
15
+ * around that row shows the `=` is an ARTEFACT of that particular head:
16
+ * `add` abbreviates `/ip/address/add`'s own `address=` argument, so the console
17
+ * reads `add gateway` as `address=gateway` and only complains at the `=` after
18
+ * it. Vary the head so the second verb abbreviates nothing and the byte moves —
19
+ * `/system/note set note=hello /ip/route add gateway=1.2.3.4` is not a hard
20
+ * reject at all; `:parse` returns IL carrying `bad parameter /ip/route
21
+ * (line 1 column 38)`, naming the second PATH. The same input also proves the
22
+ * head's own argument list is not safe to keep: `… /ip/route add placeholder` lowers
23
+ * to `address=placeholder;;interface=ether1`, a phantom attribute the source never
24
+ * wrote, and whether that happens depends on per-menu argument names — schema
25
+ * knowledge offline does not ship.
26
+ *
27
+ * So the grounded invariant is: **RouterOS never accepts a known menu path
28
+ * followed by a verb in operand position.** Across every probed row it answers
29
+ * either `bad parameter <that path>` or `expected end of command`; none is
30
+ * clean. It is also the shape with an obvious fix, which is what makes it worth
31
+ * a diagnostic rather than an abstention.
32
+ *
33
+ * ## What this deliberately does not decide
34
+ *
35
+ * A bare operand the head does not accept (`… placeholder`, `/file remove a b`) also
36
+ * draws `bad parameter`, and so does a menu path with no verb after it
37
+ * (`… /ip/route`). Those are the generic "this menu takes no such positional"
38
+ * complaint — deciding them needs the per-menu positional list, which
39
+ * `catalog.ts` deliberately is not, and a separator is not necessarily the fix.
40
+ * They abstain.
41
+ *
42
+ * The rule reads only what the argument lexer already DECIDED. It never
43
+ * re-scans bytes, so a `[…]` substitution (a located token the lexer declined
44
+ * to decode, hence no `value` and no operand), a `{…}` body (a separate
45
+ * flattened statement), a quoted operand and a `/` inside a VALUE are all
46
+ * outside its input by construction rather than by a guard it could get wrong.
47
+ * `bareOperand` is that construction: `value` absent is the lexer saying it
48
+ * decoded nothing, whatever the token's bytes spell.
49
+ *
50
+ * ## The reach it has, stated
51
+ *
52
+ * The input is `lexArgumentTokens`, the skip-tolerant reading (#316), never the
53
+ * strict `lexArguments`. The strict lexer discards every token it decoded as
54
+ * soon as it declines one, so a statement holding a variable — `… gateway=$g` —
55
+ * used to carry no token list for this rule at all and the run in it was missed
56
+ * while its literal twin was found. The tolerant reading locates the
57
+ * undecodable token and keeps walking, so the run is found on either side of
58
+ * it; that the strict reading still refuses the statement is correct and
59
+ * separate, since nothing here makes `$g` renderable.
60
+ *
61
+ * What remains out of reach is an unterminated string and an unbalanced
62
+ * delimiter, where no walk can find a boundary to resume from. That is still a
63
+ * reach limit rather than a judgement: nothing here decides those shapes are
64
+ * acceptable.
65
+ */
66
+
67
+ import type { Argument } from "./args.ts";
68
+ import { isKnownMenuPath } from "./is-known-menu.ts";
69
+ import { VERBS } from "./verbs.ts";
70
+
71
+ /** A second command-shaped run where a `;` or newline should have been. */
72
+ export interface MissingSeparator {
73
+ /** The whole second run — first byte of its path through the end of its verb. */
74
+ span: { start: number; end: number };
75
+ /** Where a `;` or newline belongs: the first byte of the second path. */
76
+ insertAt: number;
77
+ /** The second run's menu path, slash-spelled and leading-slashed. */
78
+ path: string;
79
+ /** The verb token that closed the second run. */
80
+ verb: string;
81
+ }
82
+
83
+ // Both patterns below admit a segment that STARTS WITH A DIGIT. `/interface/6to4`
84
+ // is the shipped tables' one such name — a menu in both `MENU_PATHS` and
85
+ // `PATH_CATALOG` — and requiring a leading letter made every spelling of it miss
86
+ // the rule. These are only a cheap shape filter; presence in the structure table
87
+ // is the actual gate, so a wider first character cannot admit a path the table
88
+ // does not already list.
89
+
90
+ /** A bare path segment run, `route` or `firewall/filter`, with no leading slash. */
91
+ const RELATIVE_SEGMENTS =
92
+ /^[A-Za-z0-9][A-Za-z0-9-]*(?:\/[A-Za-z0-9][A-Za-z0-9-]*)*$/;
93
+
94
+ /** An absolute menu path spelled with slashes, `/ip` or `/ip/firewall/filter`. */
95
+ const ABSOLUTE_SEGMENTS =
96
+ /^\/[A-Za-z0-9][A-Za-z0-9-]*(?:\/[A-Za-z0-9][A-Za-z0-9-]*)*$/;
97
+
98
+ /**
99
+ * A positional whose source text IS its value — so it carries no quotes, no
100
+ * substitution and no escape. `Argument.value` is already quote-stripped, which
101
+ * makes the comparison the whole quoting check; `value` absent means the lexer
102
+ * declined to decode the token and there is nothing to match against.
103
+ */
104
+ function bareOperand(token: Argument | undefined): string | undefined {
105
+ if (token === undefined || token.kind !== "positional") return undefined;
106
+ if (token.value === undefined || token.value !== token.text) return undefined;
107
+ return token.value;
108
+ }
109
+
110
+ /**
111
+ * Every complete second command-shaped run in one statement's argument tokens.
112
+ *
113
+ * Coordinate-space agnostic: the spans come back in whatever space `tokens`
114
+ * were lexed in, so the caller passes document-rebased tokens and gets document
115
+ * offsets.
116
+ */
117
+ export function findMissingSeparators(
118
+ tokens: readonly Argument[],
119
+ ): MissingSeparator[] {
120
+ const out: MissingSeparator[] = [];
121
+ let i = 0;
122
+ while (i < tokens.length) {
123
+ const run = separatorAt(tokens, i);
124
+ if (run === null) {
125
+ i++;
126
+ continue;
127
+ }
128
+ out.push(run.separator);
129
+ i = run.next;
130
+ }
131
+ return out;
132
+ }
133
+
134
+ /** One run starting at `i`, or `null` when the tokens there are not that shape. */
135
+ function separatorAt(
136
+ tokens: readonly Argument[],
137
+ i: number,
138
+ ): { separator: MissingSeparator; next: number } | null {
139
+ const head = bareOperand(tokens[i]);
140
+ if (head === undefined || !ABSOLUTE_SEGMENTS.test(head)) return null;
141
+ const run = longestKnownMenuRun(tokens, i, head);
142
+ if (run === null) return null;
143
+ const verb = bareOperand(tokens[run.last + 1]);
144
+ if (verb === undefined || !VERBS.has(verb)) return null;
145
+ const pathStart = tokens[i]?.span.start;
146
+ const verbEnd = tokens[run.last + 1]?.span.end;
147
+ if (pathStart === undefined || verbEnd === undefined) return null;
148
+ return {
149
+ separator: {
150
+ span: { start: pathStart, end: verbEnd },
151
+ insertAt: pathStart,
152
+ path: `/${run.segments.join("/")}`,
153
+ verb,
154
+ },
155
+ next: run.last + 2,
156
+ };
157
+ }
158
+
159
+ /**
160
+ * The longest run of operands from `i` that spells a known menu path.
161
+ *
162
+ * A menu path may be written with slashes, spaces, or a mix (Q3 rule R4), so
163
+ * `/ip/route`, `/ip route` and `/ip route` split across operands are the same
164
+ * path and all of them have to be reachable — the device rejects the spaced
165
+ * spelling identically. Longest wins: `/ip route` is a menu and so is `/ip`, and
166
+ * only the longer one leaves `add` in verb position. `null` when no prefix is a
167
+ * known menu — absence in the structure table abstains and never asserts a
168
+ * command (`is-known-menu.ts`).
169
+ */
170
+ function longestKnownMenuRun(
171
+ tokens: readonly Argument[],
172
+ i: number,
173
+ head: string,
174
+ ): { last: number; segments: string[] } | null {
175
+ const segments = head.slice(1).split("/");
176
+ const runs: { last: number; segments: string[] }[] = [];
177
+ if (isKnownMenuPath(segments))
178
+ runs.push({ last: i, segments: [...segments] });
179
+ for (let j = i + 1; j < tokens.length; j++) {
180
+ const next = bareOperand(tokens[j]);
181
+ if (next === undefined || !RELATIVE_SEGMENTS.test(next)) break;
182
+ segments.push(...next.split("/"));
183
+ if (isKnownMenuPath(segments))
184
+ runs.push({ last: j, segments: [...segments] });
185
+ }
186
+ return runs.at(-1) ?? null;
187
+ }
@@ -0,0 +1,194 @@
1
+ /**
2
+ * "Which statement owns this span?" as an index rather than a scan (#317).
3
+ *
4
+ * `symbol-values.ts` asks this question once per symbol, once per value and
5
+ * once per scope brace. Answering it by scanning every statement split makes
6
+ * the product quadratic: on 62 KiB of `:local v<i> <i>; :put $v<i>;` the scan
7
+ * ran 6,144 queries over 4,096 splits — 25.2 M visits, 42% of total analysis
8
+ * time — and the visit count quadrupled for every doubling of the input.
9
+ *
10
+ * ## The answer that has to be preserved, exactly
11
+ *
12
+ * The **shortest ADDRESSABLE split span containing `[start, end)`**, and on a
13
+ * length tie the **earliest split index**. Addressable means the span slices
14
+ * back to the split's own text: `pathresolve.ts` can hand back a *widened
15
+ * fallback* span, and a span that is not this statement's own bytes cannot
16
+ * prove statement ownership (the same gate `valuesOf`/`argumentsOf` apply to
17
+ * their own reads).
18
+ * Shortest — not first, not outermost — is what keeps `:local f do={:put 1}`
19
+ * from claiming the body's `1` for `f`.
20
+ *
21
+ * ## Why a parent chain is enough
22
+ *
23
+ * Statement spans NEST: a parent statement's span contains its scope bodies'
24
+ * statements. If the addressable spans are a laminar family — every pair
25
+ * either nested or disjoint, never crossing — then the spans containing a
26
+ * given point form a chain, so the answer is reachable by walking up from the
27
+ * deepest span that starts at or before `start`.
28
+ *
29
+ * Laminarity is **measured, not assumed**. Over the pinned 948-script corpus
30
+ * (`corpus-pin.json`), all 18,487 addressable spans of 18,648 splits are
31
+ * laminar: zero crossings, zero duplicate `(start, end)` pairs, maximum
32
+ * nesting depth 11. That is one genre of input (see #203 on the corpus bias),
33
+ * so it is a measurement, not a proof — {@link buildSplitOwnerIndex} therefore
34
+ * DETECTS a crossing while it builds and falls back to the exhaustive scan for
35
+ * that document. The fallback is the old code, so a crossing costs speed and
36
+ * never correctness, and `test/unit/explain-split-owner.test.ts` drives both
37
+ * paths against a brute-force oracle on randomly generated span families.
38
+ */
39
+
40
+ import type { DocumentVerbSplit } from "./verbsplit.ts";
41
+
42
+ /** A prepared owner lookup over one document's splits. */
43
+ export interface SplitOwnerIndex {
44
+ /**
45
+ * The owning split index for `[start, end)`, or `undefined` when no
46
+ * addressable span contains it.
47
+ */
48
+ ownerOf(start: number, end: number): number | undefined;
49
+ /**
50
+ * Were the addressable spans laminar? `false` means this index answers by
51
+ * exhaustive scan. Exposed so the unit test can prove it drove both paths,
52
+ * not for callers to branch on.
53
+ */
54
+ readonly laminar: boolean;
55
+ }
56
+
57
+ interface Entry {
58
+ start: number;
59
+ end: number;
60
+ /** Index into the original `splits` array. */
61
+ split: number;
62
+ }
63
+
64
+ /** The addressable spans, in split order. */
65
+ function addressableEntries(
66
+ analyzed: string,
67
+ splits: readonly DocumentVerbSplit[],
68
+ ): Entry[] {
69
+ const entries: Entry[] = [];
70
+ for (let i = 0; i < splits.length; i++) {
71
+ const split = splits[i] as DocumentVerbSplit;
72
+ const { start, end } = split.span;
73
+ // A widened fallback span cannot prove statement ownership: it is not
74
+ // this statement's own bytes.
75
+ if (analyzed.slice(start, end) !== split.text) continue;
76
+ entries.push({ start, end, split: i });
77
+ }
78
+ return entries;
79
+ }
80
+
81
+ /**
82
+ * The exhaustive scan, over the addressable entries rather than over `splits`.
83
+ * Same answer as the pre-#317 loop: the filter it applied per candidate is the
84
+ * one `addressableEntries` applied once, and `length < ownerLength` is strict,
85
+ * so ascending split order still resolves a length tie to the earliest index.
86
+ */
87
+ function scanOwner(
88
+ entries: readonly Entry[],
89
+ start: number,
90
+ end: number,
91
+ ): number | undefined {
92
+ let owner: number | undefined;
93
+ let ownerLength = Number.POSITIVE_INFINITY;
94
+ for (const entry of entries) {
95
+ if (entry.start > start || end > entry.end) continue;
96
+ const length = entry.end - entry.start;
97
+ if (length < ownerLength) {
98
+ owner = entry.split;
99
+ ownerLength = length;
100
+ }
101
+ }
102
+ return owner;
103
+ }
104
+
105
+ /**
106
+ * Prepare the owner lookup for one document. Build once per analysis and reuse
107
+ * it for every query: building it per query would be the scan it replaces.
108
+ */
109
+ export function buildSplitOwnerIndex(
110
+ analyzed: string,
111
+ splits: readonly DocumentVerbSplit[],
112
+ ): SplitOwnerIndex {
113
+ const entries = addressableEntries(analyzed, splits);
114
+ // Parents first: ascending start, and on equal starts the longer span
115
+ // (the container) before the shorter one. The last entry with
116
+ // `start <= query.start` is then the deepest candidate, which is what the
117
+ // binary search below relies on.
118
+ const ordered = [...entries].sort(
119
+ (a, b) => a.start - b.start || b.end - a.end || a.split - b.split,
120
+ );
121
+ // Two splits can claim exactly the same span; the sort has made them
122
+ // adjacent, and dropping all but the first keeps the earliest index. It
123
+ // also leaves the nesting STRICT, which is what lets the walk stop at the
124
+ // first long-enough span: after this, two distinct entries of equal length
125
+ // cannot both contain one query, because in a laminar family nested plus
126
+ // equal length means identical.
127
+ const sorted = ordered.filter((entry, i) => {
128
+ const previous = ordered[i - 1];
129
+ return (
130
+ previous === undefined ||
131
+ previous.start !== entry.start ||
132
+ previous.end !== entry.end
133
+ );
134
+ });
135
+ const parent = new Int32Array(sorted.length).fill(-1);
136
+ const stack: number[] = [];
137
+ let laminar = true;
138
+ for (let i = 0; i < sorted.length; i++) {
139
+ const cur = sorted[i] as Entry;
140
+ while (stack.length > 0) {
141
+ const top = sorted[stack[stack.length - 1] as number] as Entry;
142
+ if (top.end <= cur.start) stack.pop();
143
+ else break;
144
+ }
145
+ const topIndex = stack[stack.length - 1];
146
+ if (topIndex !== undefined) {
147
+ const top = sorted[topIndex] as Entry;
148
+ // Overlapping but not containing: the family is not laminar, so the
149
+ // containing spans of a point are not a chain and the walk below
150
+ // could miss the real owner.
151
+ if (top.end < cur.end) laminar = false;
152
+ parent[i] = topIndex;
153
+ }
154
+ stack.push(i);
155
+ }
156
+
157
+ if (!laminar)
158
+ return {
159
+ laminar,
160
+ ownerOf: (start, end) => scanOwner(entries, start, end),
161
+ };
162
+
163
+ return {
164
+ laminar,
165
+ ownerOf(start, end) {
166
+ // A ZERO-WIDTH query is outside the nesting model and takes the scan.
167
+ // Containment of `[p, p)` is satisfied by a span that merely TOUCHES
168
+ // `p`, so `[a, p)` and `[p, b)` both "contain" it while being disjoint
169
+ // — the containing spans stop being a chain and the walk below can
170
+ // reach only one of them. No real query is zero-width (a symbol, a
171
+ // value anchor and a scope brace all span at least one byte; measured
172
+ // 0 of 19,292 symbol occurrences over the pinned corpus), so this
173
+ // costs nothing in practice and keeps the answer exact if that ever
174
+ // stops being true.
175
+ if (start >= end) return scanOwner(entries, start, end);
176
+ // Last entry whose span starts at or before `start`.
177
+ let lo = 0;
178
+ let hi = sorted.length;
179
+ while (lo < hi) {
180
+ const mid = (lo + hi) >> 1;
181
+ if ((sorted[mid] as Entry).start <= start) lo = mid + 1;
182
+ else hi = mid;
183
+ }
184
+ // Ends grow as the walk rises, so the first span long enough to
185
+ // cover `end` is the shortest one that does. Entries that stop
186
+ // before `start` are simply skipped by the same test.
187
+ for (let i = lo - 1; i >= 0; i = parent[i] as number) {
188
+ const entry = sorted[i] as Entry;
189
+ if (entry.end >= end) return entry.split;
190
+ }
191
+ return undefined;
192
+ },
193
+ };
194
+ }
@@ -12,10 +12,20 @@
12
12
  * `src/explain/quoted-string.ts` (`scanQuotedString`), the same primitive the
13
13
  * segmenter and symbol resolver use, so boundaries cannot drift.
14
14
  *
15
- * Vocabulary is provisional until #264 B5: one `string` class for every quoted
16
- * run (delimiters included), regardless of whether the string is a literal
17
- * value, a name, or code. Whether escapes or interpolations later deserve a
18
- * distinct class is B5 and does not move byte coverage.
15
+ * One `string` class per quoted run, delimiters included, regardless of whether
16
+ * the string is a literal value, a name, or code. The delimiters are part of
17
+ * the run because they are its own first and last byte, so nothing is gained by
18
+ * naming them separately (#264 B5).
19
+ *
20
+ * A run is **not** necessarily one token, and escapes are why. The `escaped`
21
+ * fill (`src/explain/escape-tokens.ts`) claims valid escapes two stages earlier,
22
+ * so what reaches here is the run minus its escapes and this fill emits a
23
+ * fragment either side of each one — `"a\nb"` is `string("a) escaped(\n)
24
+ * string(b")`. Splitting the run that way is not new machinery: `clipToResidual`
25
+ * already fragmented these runs around `variable-*` spans. #264 made that split
26
+ * because a valid escape is recoverable from no other published surface, where a
27
+ * delimiter is recoverable from the run's own first and last byte — which is why
28
+ * the escapes are named and the delimiters are not.
19
29
  */
20
30
 
21
31
  import type { ExplainToken } from "../explain.ts";