@tikoci/centrs 0.1.9-next.22 → 0.1.9-next.24

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 (68) hide show
  1. package/CHANGELOG.md +106 -0
  2. package/GLOSSARY.txt +38 -0
  3. package/README.md +2 -2
  4. package/commands/api/AGENTS.md +18 -1
  5. package/commands/api/README.md +10 -6
  6. package/commands/api/examples.md +1 -1
  7. package/commands/execute/README.md +26 -6
  8. package/commands/execute/examples.md +43 -5
  9. package/commands/explain/README.md +873 -182
  10. package/commands/explain/examples.md +447 -38
  11. package/commands/retrieve/README.md +13 -2
  12. package/commands/retrieve/examples.md +30 -1
  13. package/commands/transfer/README.md +8 -4
  14. package/docs/CLI.md +19 -5
  15. package/docs/CONSTITUTION.md +18 -9
  16. package/docs/MATRIX.md +51 -26
  17. package/docs/errors/cmr/caller-policy-not-enforced.md +5 -3
  18. package/docs/errors/routeros/permission-denied.md +24 -0
  19. package/docs/errors/transport/edge-auth-failed.md +22 -0
  20. package/docs/errors/transport/write-outcome-unknown.md +3 -0
  21. package/docs/errors/usage/invalid-cursor.md +20 -0
  22. package/docs/errors/validation/unknown-path.md +9 -0
  23. package/package.json +5 -2
  24. package/src/api.ts +34 -39
  25. package/src/cli/explain.ts +206 -47
  26. package/src/core/envelope.ts +10 -3
  27. package/src/core/error-catalog.ts +15 -0
  28. package/src/core/execute-command.ts +53 -0
  29. package/src/core/fanout.ts +12 -2
  30. package/src/core/inspect.ts +4 -5
  31. package/src/core/routeros-errors.ts +25 -0
  32. package/src/execute-cmr.ts +2 -8
  33. package/src/execute.ts +48 -20
  34. package/src/explain/args.ts +181 -15
  35. package/src/explain/blocks.ts +3 -4
  36. package/src/explain/brace-slots.ts +5 -0
  37. package/src/explain/catalog.ts +13 -6
  38. package/src/explain/defects.ts +1 -0
  39. package/src/explain/group-faults.ts +1060 -0
  40. package/src/explain/highlight-projection.ts +10 -8
  41. package/src/explain/operators.ts +22 -20
  42. package/src/explain/pathresolve.ts +102 -20
  43. package/src/explain/quoted-string.ts +41 -6
  44. package/src/explain/run-tokens.ts +223 -0
  45. package/src/explain/scope-brace.ts +319 -7
  46. package/src/explain/segment.ts +177 -4
  47. package/src/explain/spaced-args.ts +116 -0
  48. package/src/explain/symbols.ts +163 -7
  49. package/src/explain/transport.ts +13 -0
  50. package/src/explain/values.ts +55 -4
  51. package/src/explain/verbsplit.ts +14 -188
  52. package/src/explain/write.ts +8 -1
  53. package/src/explain-live-facets.ts +739 -0
  54. package/src/explain-live-types.ts +490 -0
  55. package/src/explain-live.ts +593 -0
  56. package/src/explain.ts +772 -60
  57. package/src/index.ts +6 -0
  58. package/src/offline-gate.ts +30 -9
  59. package/src/protocols/adapter.ts +88 -10
  60. package/src/protocols/btest-session.ts +30 -10
  61. package/src/protocols/mac-telnet-console.ts +16 -0
  62. package/src/protocols/mac-telnet.ts +38 -10
  63. package/src/protocols/native-api.ts +13 -12
  64. package/src/retrieve-follow.ts +2 -1
  65. package/src/retrieve-sample.ts +2 -1
  66. package/src/retrieve-wait.ts +10 -6
  67. package/src/retrieve.ts +77 -27
  68. package/src/transfer.ts +38 -13
@@ -84,13 +84,16 @@ import {
84
84
  MAX_STRING_FRAME_DEPTH,
85
85
  scanQuotedString as scanQuotedStringShared,
86
86
  } from "./quoted-string.ts";
87
+ import { runNamesCommand } from "./run-tokens.ts";
87
88
  import {
88
89
  braceStartsStatements,
89
90
  canRetainStatementIndex,
90
91
  hashStartsHardError,
91
92
  isIndexedStatementStart,
92
93
  matchingBraceEnd,
94
+ menuBlockPath,
93
95
  scopeNameFromMasked,
96
+ startsArrayCommentLine,
94
97
  } from "./scope-brace.ts";
95
98
 
96
99
  /** One top-level statement located in analyzed-byte space. */
@@ -130,6 +133,15 @@ export interface SegmentResult {
130
133
  * a throw. Includes the two coordinate-pass classes (`bom`, `non-ascii`).
131
134
  */
132
135
  defects: Defect[];
136
+ /**
137
+ * The first `}` or `]` that closes nothing at the top level, where RouterOS
138
+ * stops reading (#504). The device accepts the script and silently discards
139
+ * that byte and everything after it: `:put 1};:put 2` lowers to
140
+ * `(evl /putmessage=1)` and `}⏎:put 1` to `/`, on CHR 7.24.5 and 7.23.8.
141
+ * A `)` there, or any stray close inside an open group, is a rejection
142
+ * instead and is only an `unbalanced-close` defect. Absent when there is none.
143
+ */
144
+ discardFrom?: number;
133
145
  }
134
146
 
135
147
  const isSpace = (c: string): boolean => c === " " || c === "\t" || c === "\r";
@@ -510,7 +522,12 @@ function maskUncached(
510
522
  // indented `#` is a comment when the statement is still empty (`do={\` +
511
523
  // newline + ` # c`) and content when it is not (`:put a\` + newline +
512
524
  // ` # x`, which the device classes `error`) — exactly what `atLead` says.
513
- if (c === "#" && (atLead || contComment)) {
525
+ if (
526
+ c === "#" &&
527
+ (atLead ||
528
+ contComment ||
529
+ startsArrayCommentLine(original, i, contexts.at(-1)))
530
+ ) {
514
531
  const commentStart = i;
515
532
  while (i < original.length && original[i] !== "\n") i++;
516
533
  if (pieces === undefined) pieces = [];
@@ -608,6 +625,137 @@ export function segmentScopeBody(
608
625
  );
609
626
  }
610
627
 
628
+ /** Nesting cap for dispatch-statement counting (#549): the 16× guard (#322). */
629
+ const MAX_DISPATCH_STATEMENT_DEPTH = 16;
630
+
631
+ /**
632
+ * Executable statements in `text`, at any nesting depth (#549).
633
+ *
634
+ * A dispatched script can carry statements inside executed control scopes
635
+ * (`:if … do={…}`, `:for`/`:foreach`/`:while`, `:do`, `:retry`, `:onerror`,
636
+ * `once do={…}`), and those run before a later refusal exactly like
637
+ * top-level ones do — `:if (true) do={ /system/note/set …; /user/add … }`
638
+ * applies the note before the refused add on CHR 7.25. Stored bodies
639
+ * (`source={…}`, `on-event={…}`, any other `name={`) never execute at
640
+ * dispatch, so only `scopeNameFromMasked` scopes recurse — except a `do={…}`
641
+ * under a `:global`/`:local`/`:set` head, which stores a function value.
642
+ * Command substitutions (`[…]`) run at dispatch too — `:put [:put "p5-first";
643
+ * /system/identity/set …]` prints before the refused set on CHR 7.25 — so
644
+ * they recurse unless they sit inside a stored or array `{…}`. An `else={…}`
645
+ * paired with its `do={…}` counts the larger side, not the sum, because the
646
+ * branches exclude each other.
647
+ *
648
+ * A segment with executed blocks or substitutions counts their inner
649
+ * statements, not itself (a control head is not an effect); a `/menu { … }`
650
+ * prefix counts nothing (navigation is not an effect); any other segment
651
+ * counts one. So the commonest write shape, `/… remove [find …]`, stays one
652
+ * statement — at the cost of a lone substitution that itself writes before a
653
+ * refused enclosing statement, which also counts one. Past the depth cap the
654
+ * inner text counts its top-level siblings only. Unbalanced input counts what
655
+ * precedes the break and never throws.
656
+ */
657
+ export function countExecutableStatements(text: string): number {
658
+ return countExecutableStatementsWithin(text, 0);
659
+ }
660
+
661
+ function countExecutableStatementsWithin(text: string, depth: number): number {
662
+ const { segments, ascii } = segmentStatements(text);
663
+ if (depth >= MAX_DISPATCH_STATEMENT_DEPTH) return segments.length;
664
+ let count = 0;
665
+ for (const segment of segments) {
666
+ // A `/menu { … }` prefix only navigates; it is not an effect. The
667
+ // `ascii` guard is load-bearing: segment offsets are analyzed-byte
668
+ // offsets, which are string indexes only for pure-ASCII input.
669
+ if (ascii && segment.menuOnly && /^\s*\{/.test(text.slice(segment.end)))
670
+ continue;
671
+ const inner = countExecutedBlockStatements(segment.text, depth);
672
+ count += inner > 0 ? inner : 1;
673
+ }
674
+ return count;
675
+ }
676
+
677
+ /** Executable statements in the segment's executed `{…}` scopes and `[…]`. */
678
+ function countExecutedBlockStatements(
679
+ segmentText: string,
680
+ depth: number,
681
+ ): number {
682
+ const masked = maskComments(segmentText);
683
+ const storedDoHead = /^:(?:global|local|set)\b/i.test(masked);
684
+ let total = 0;
685
+ let i = 0;
686
+ const stack: string[] = [];
687
+ // The last executed scope closed at this level, for `do`/`else` pairing.
688
+ let lastScope: { name: string; count: number; end: number } | null = null;
689
+ // The outermost open `[` that executes at dispatch, and its stack depth.
690
+ let substitution: { start: number; depth: number } | null = null;
691
+ while (i < masked.length) {
692
+ const c = masked[i];
693
+ if (c === '"') {
694
+ i = scanQuotedString(masked, i).end;
695
+ continue;
696
+ }
697
+ if (c === "[" || c === "(") {
698
+ if (c === "[" && !stack.includes("[") && !stack.includes("{")) {
699
+ substitution = { start: i, depth: stack.length };
700
+ }
701
+ stack.push(c);
702
+ i++;
703
+ continue;
704
+ }
705
+ if (c === "]" || c === ")") {
706
+ const want = c === "]" ? "[" : "(";
707
+ if (stack.at(-1) === want) {
708
+ stack.pop();
709
+ if (c === "]" && stack.length === substitution?.depth) {
710
+ total += countExecutableStatementsWithin(
711
+ segmentText.slice(substitution.start + 1, i),
712
+ depth + 1,
713
+ );
714
+ substitution = null;
715
+ }
716
+ }
717
+ i++;
718
+ continue;
719
+ }
720
+ if (c === "{") {
721
+ const scope = stack.length === 0 ? scopeNameFromMasked(masked, i) : null;
722
+ if (
723
+ scope !== null &&
724
+ !(storedDoHead && (scope === "do" || scope === "command"))
725
+ ) {
726
+ const end = matchingBraceEnd(masked, i);
727
+ if (end === undefined) return total;
728
+ const count = countExecutableStatementsWithin(
729
+ segmentText.slice(i + 1, end),
730
+ depth + 1,
731
+ );
732
+ if (
733
+ scope === "else" &&
734
+ lastScope?.name === "do" &&
735
+ /^\s*else\s*=?\s*$/.test(masked.slice(lastScope.end + 1, i))
736
+ ) {
737
+ total += Math.max(lastScope.count, count) - lastScope.count;
738
+ } else {
739
+ total += count;
740
+ }
741
+ lastScope = { name: scope, count, end };
742
+ i = end + 1;
743
+ continue;
744
+ }
745
+ stack.push("{");
746
+ i++;
747
+ continue;
748
+ }
749
+ if (c === "}") {
750
+ if (stack.at(-1) === "{") stack.pop();
751
+ i++;
752
+ continue;
753
+ }
754
+ i++;
755
+ }
756
+ return total;
757
+ }
758
+
611
759
  function segmentStatementsInternal(
612
760
  original: string,
613
761
  range: MaskedRange | undefined,
@@ -650,6 +798,7 @@ function segmentStatementsInternal(
650
798
  analysis === undefined ? [] : coordinateDefects(analysis),
651
799
  raw.defects,
652
800
  ),
801
+ ...(raw.discardFrom === undefined ? {} : { discardFrom: raw.discardFrom }),
653
802
  };
654
803
  }
655
804
 
@@ -699,6 +848,8 @@ interface Frame {
699
848
  /** Incremental equivalent of trimming and checking the H7 prefix. */
700
849
  prefixSawNonSpace: boolean;
701
850
  prefixValid: boolean;
851
+ /** The prefix's run has been checked for a command verb (#496). */
852
+ prefixVerbChecked: boolean;
702
853
  /** statements collected here; the flattened output for a committed container. */
703
854
  buffer: RawSegment[];
704
855
  /** true for a container frame; false for the synthetic top level. */
@@ -726,6 +877,7 @@ function scanAscii(
726
877
  segments: RawSegment[];
727
878
  comments: { start: number; end: number }[];
728
879
  defects: Defect[];
880
+ discardFrom?: number;
729
881
  } {
730
882
  // Local indices remain local in the result; only index-backed questions use
731
883
  // document coordinates. The floor prevents an enclosing head from becoming
@@ -736,6 +888,7 @@ function scanAscii(
736
888
  const comments: { start: number; end: number }[] = [];
737
889
  const defects: Defect[] = [];
738
890
  const overDepth: number[] = [];
891
+ let discardFrom: number | undefined;
739
892
  let hardHashSeen = false;
740
893
  let hardApostropheSeen = false;
741
894
  // H2 — every open bracket, for balance. Each frame carries WHERE it opened so
@@ -752,6 +905,7 @@ function scanAscii(
752
905
  prefixScanEnd: -1,
753
906
  prefixSawNonSpace: false,
754
907
  prefixValid: true,
908
+ prefixVerbChecked: false,
755
909
  buffer: [],
756
910
  container: false,
757
911
  openIndex: -1,
@@ -777,6 +931,7 @@ function scanAscii(
777
931
  f.prefixScanEnd = -1;
778
932
  f.prefixSawNonSpace = false;
779
933
  f.prefixValid = true;
934
+ f.prefixVerbChecked = false;
780
935
  };
781
936
 
782
937
  const makeSegment = (
@@ -814,7 +969,8 @@ function scanAscii(
814
969
  };
815
970
 
816
971
  // A container prefix is empty or a bare `/`-menu path with no `=[($"` — the
817
- // same test the old `containerOpen` applied to the group's prefix.
972
+ // same test the old `containerOpen` applied to the group's prefix — whose
973
+ // run names no command (#496).
818
974
  const validPrefix = (f: Frame, start: number, end: number): boolean => {
819
975
  if (f.prefixScanStart !== start || f.prefixScanEnd > end) {
820
976
  resetPrefixScan(f);
@@ -841,6 +997,12 @@ function scanAscii(
841
997
  }
842
998
  }
843
999
  f.prefixScanEnd = end;
1000
+ // Checked once per statement: a later `{` sits behind this one, which
1001
+ // already ended the run, so the answer cannot change.
1002
+ if (f.prefixValid && !f.prefixVerbChecked) {
1003
+ f.prefixVerbChecked = true;
1004
+ if (runNamesCommand(ascii.slice(start, end))) f.prefixValid = false;
1005
+ }
844
1006
  return f.prefixValid;
845
1007
  };
846
1008
 
@@ -860,6 +1022,7 @@ function scanAscii(
860
1022
  prefixScanEnd: -1,
861
1023
  prefixSawNonSpace: false,
862
1024
  prefixValid: true,
1025
+ prefixVerbChecked: false,
863
1026
  buffer: [],
864
1027
  container: true,
865
1028
  openIndex: i,
@@ -920,7 +1083,12 @@ function scanAscii(
920
1083
  // mid-continuation stays content.
921
1084
  const inCont = f.cont !== "none";
922
1085
  const contComment = c === "#" && inCont && f.contLineStart;
923
- if (c === "#" && (f.atLead || contComment)) {
1086
+ if (
1087
+ c === "#" &&
1088
+ (f.atLead ||
1089
+ contComment ||
1090
+ startsArrayCommentLine(ascii, i, delimStack.at(-1)))
1091
+ ) {
924
1092
  const nl = ascii.indexOf("\n", i);
925
1093
  const end = nl === -1 ? ascii.length : nl;
926
1094
  comments.push({ start: i, end });
@@ -1037,7 +1205,10 @@ function scanAscii(
1037
1205
  if (
1038
1206
  c === "{" &&
1039
1207
  atContainerLevel() &&
1040
- validPrefix(f, f.stmtStart >= 0 ? f.stmtStart : i, i)
1208
+ (validPrefix(f, f.stmtStart >= 0 ? f.stmtStart : i, i) ||
1209
+ // A relative submenu inside a container is one too (#351).
1210
+ (frames.length > 1 &&
1211
+ menuBlockPath(indexed, floor + i, floor) !== null))
1041
1212
  ) {
1042
1213
  if (frames.length - 1 < MAX_CONTAINER_DEPTH) {
1043
1214
  openContainer(i);
@@ -1091,6 +1262,7 @@ function scanAscii(
1091
1262
  f.atLead = false;
1092
1263
  }
1093
1264
  } else {
1265
+ if (delimStack.length === 0 && c !== ")") discardFrom ??= i;
1094
1266
  defects.push(defectAt("unbalanced-close", i, c));
1095
1267
  ensureStmt(f, i);
1096
1268
  f.atLead = false;
@@ -1148,6 +1320,7 @@ function scanAscii(
1148
1320
  // event fail to de-duplicate across analyzers.
1149
1321
  ...overDepth.map((offset) => defectAt("over-depth", offset)),
1150
1322
  ],
1323
+ ...(discardFrom === undefined ? {} : { discardFrom }),
1151
1324
  };
1152
1325
  }
1153
1326
 
@@ -0,0 +1,116 @@
1
+ /**
2
+ * The space-for-`=` argument spelling (#350).
3
+ *
4
+ * RouterOS lets a space stand in for the `=` of an ordinary command argument:
5
+ * `/ip/firewall/layer7-protocol add comment "something" regexp 1.1` lowers to
6
+ * `(evl /ip/firewall/layer7-protocol/addcomment=something;regexp=1.1)`, byte for
7
+ * byte the IL of the `=` spelling, and the spellings mix freely. That is not a
8
+ * schema-free rule. The device pairs `comment` with `"something"` because it
9
+ * knows `/ip/firewall/layer7-protocol/add` has a `comment` argument:
10
+ * `/system/script add zzznotanarg beep` is `bad parameter zzznotanarg` on
11
+ * 7.24.5 and `expected end of command` on 7.22.1.
12
+ *
13
+ * ## What the device actually does (CHR 7.22.1 and 7.24.5, `:parse` + `highlight`)
14
+ *
15
+ * Identical on both builds:
16
+ *
17
+ * | input | IL | `highlight` on the gap |
18
+ * | ----- | -- | ---------------------- |
19
+ * | `/interface set ether1 comment "x"` | `setcomment=x;numbers=ether1` | `syntax-obsolete` |
20
+ * | `/interface set ether1 name x` | `setname=x;numbers=ether1` | `syntax-obsolete` |
21
+ * | `/interface set ether1 disabled yes` | `setdisabled=true;numbers=ether1` | `syntax-obsolete` |
22
+ * | `/user group set full policy "read,write"` | `setnumbers=full;policy=read,write` | `syntax-obsolete` |
23
+ * | `/interface print detail without-paging` | `printdetail=true;without-paging=true` | none |
24
+ *
25
+ * Two facts come out of that table, and they are why this module has two
26
+ * outputs rather than one.
27
+ *
28
+ * - **Two adjacent bare operands have no schema-free reading.** `name x` is an
29
+ * argument and its value; `detail without-paging` is two value-less flags;
30
+ * `ether1 name` is an unnamed `numbers` operand and then an argument name. The
31
+ * bytes are the same shape in all three. So the strict reading ABSTAINS on any
32
+ * such pair: it never publishes `args: {}` plus a positional list, which
33
+ * claims the list was read and holds no arguments.
34
+ * - **The obsolete marker follows the value, not the name.** The device marks
35
+ * the gap `syntax-obsolete` exactly when it paired the words, and a flag pair
36
+ * gets no marker. Offline can see one half of that: a second operand that is
37
+ * not itself name-shaped (`"x"`, `1.1`, `10s`, `$v`) cannot be a flag, so the
38
+ * pair is very probably the spaced spelling and gets a warning. A bare-word
39
+ * second operand (`name x`, `disabled yes`) is also obsolete on device, but
40
+ * offline cannot tell it from `detail without-paging`, so it abstains with no
41
+ * warning rather than flagging the most common `print` spelling there is. The
42
+ * warning says "possible"; a live probe (#491) is what makes it definite.
43
+ *
44
+ * ## What this deliberately does not decide
45
+ *
46
+ * Root statements are out of scope. A scripting directive's operands are
47
+ * positional by grammar, not spelling: `:pick abc 0 1` lowers to
48
+ * `pickbegin=0;counter=abc;end=1`, and `:local t 200ms` to `name=t;value=200ms`.
49
+ * Pairing those would fabricate argument names the device never uses. `/ping
50
+ * 1.1.1.1 count 1` is spaced (`count` is `syntax-obsolete`) and resolves to the
51
+ * root, so it stays read here; telling a root command's positional grammar from a
52
+ * spaced argument is per-command schema and belongs to the live probe.
53
+ *
54
+ * `where` ends the scan. What follows it is a query expression, and the word
55
+ * before it is never paired with it: `print count-only where …` lowers to
56
+ * `count-only=true;where=…`.
57
+ *
58
+ * Like the #311 separator rule, this reads only what the argument lexer
59
+ * already decided (`lexArgumentTokens`, the skip-tolerant reading), so a
60
+ * `[…]` substitution or a quoted run is one token by construction.
61
+ */
62
+
63
+ import { ARGUMENT_NAME, type Argument } from "./args.ts";
64
+
65
+ /** Two adjacent operands that may be the spaced spelling of `name=value`. */
66
+ export interface SpacedPair {
67
+ /** First byte of the would-be name through the end of the would-be value. */
68
+ span: { start: number; end: number };
69
+ /** The would-be argument name, verbatim. */
70
+ name: string;
71
+ /** The would-be value, verbatim (quotes kept). */
72
+ value: string;
73
+ /**
74
+ * The second operand cannot be a flag, so the device very probably reads the
75
+ * pair as `name=value` and marks the gap `syntax-obsolete`.
76
+ */
77
+ valueShaped: boolean;
78
+ }
79
+
80
+ /**
81
+ * An argument-name-shaped bare word. This is the lexer's own grammar, so a word
82
+ * the lexer would read as `name=` in the `=` spelling is a name here too.
83
+ */
84
+ const NAME_SHAPED = ARGUMENT_NAME;
85
+
86
+ function isWhere(token: Argument): boolean {
87
+ return token.kind === "positional" && token.text.toLowerCase() === "where";
88
+ }
89
+
90
+ /**
91
+ * Every adjacent `name operand` pair in one statement's argument tokens.
92
+ *
93
+ * Pairs overlap on purpose. `set ether1 comment "x"` yields `ether1 comment`
94
+ * and `comment "x"`: the first only abstains, the second also warns, and
95
+ * consuming the first would hide the second.
96
+ *
97
+ * Coordinate-space agnostic, like `findMissingSeparators`: spans come back in
98
+ * whatever space `tokens` were lexed in.
99
+ */
100
+ export function findSpacedPairs(tokens: readonly Argument[]): SpacedPair[] {
101
+ const out: SpacedPair[] = [];
102
+ for (let i = 0; i + 1 < tokens.length; i++) {
103
+ const name = tokens[i] as Argument;
104
+ const value = tokens[i + 1] as Argument;
105
+ if (isWhere(name)) break;
106
+ if (name.kind !== "positional" || value.kind !== "positional") continue;
107
+ if (!NAME_SHAPED.test(name.text) || isWhere(value)) continue;
108
+ out.push({
109
+ span: { start: name.span.start, end: value.span.end },
110
+ name: name.text,
111
+ value: value.text,
112
+ valueShaped: !NAME_SHAPED.test(value.text),
113
+ });
114
+ }
115
+ return out;
116
+ }