@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
@@ -60,6 +60,18 @@
60
60
  * its precedence over the punctuation guess, and the two places it deliberately
61
61
  * does not apply. `splitRun` is again untouched — both tables are consumed here.
62
62
  *
63
+ * **The other direction closes too (#324).** R9 kept `pathresolve` from
64
+ * claiming navigation where a verb was decided; the symmetric rule keeps
65
+ * `resolveVerb` from claiming a verb where the whole run names a known MENU
66
+ * and the segment it would promote is not in `VERBS`. `splitRun`'s punctuation
67
+ * fallback used to read `/ip/firewall/address-list name=ytkids` as verb
68
+ * `address-list` at `/ip/firewall` — a verb assembled from a path segment, on
69
+ * a statement whose verb is simply missing. It degrades to `ambiguous`. Priced
70
+ * on the frozen partition: 10 of 17,212 statements flip, every one of them a
71
+ * fabrication withdrawn (`commands/explain/README.md` → The menu path with
72
+ * arguments). Both tables stay floors — presence narrows, absence decides
73
+ * nothing.
74
+ *
63
75
  * Like `blocks.ts`, this ships only the boundary primitives. Argument parsing
64
76
  * (`k=v` / `?query` after the verb) belongs to the phase-1 canonical assembly,
65
77
  * not to the Q6 question. `resolveVerb(text, context)` takes the enclosing menu
@@ -67,7 +79,7 @@
67
79
  * resolver's context-certainty contract (#192).
68
80
  */
69
81
 
70
- import { scopeBodies } from "./blocks.ts";
82
+ import { scopeBlocksIn } from "./blocks.ts";
71
83
  import { commandVerbIndex } from "./catalog.ts";
72
84
  import type { Defect } from "./defects.ts";
73
85
  import { isKnownMenuPath } from "./is-known-menu.ts";
@@ -76,6 +88,12 @@ import {
76
88
  type Span,
77
89
  type StatementAnalysis,
78
90
  } from "./pathresolve.ts";
91
+ import {
92
+ documentRange,
93
+ type MaskedRange,
94
+ nestedRange,
95
+ rangeAt,
96
+ } from "./segment.ts";
79
97
  import { SUBMENU_DIRECTIVES, VERBS } from "./verbs.ts";
80
98
 
81
99
  const BARE_WORD = /^[A-Za-z][A-Za-z0-9._-]*$/;
@@ -135,8 +153,14 @@ interface Word {
135
153
  * offset from a name is therefore wrong on continued statements, and
136
154
  * {@link describeStatement} only ever publishes the run's END.
137
155
  */
138
- function asciiWordSpans(text: string): Word[] {
139
- const out: Word[] = [];
156
+ function* asciiWordSpans(text: string): Generator<Word> {
157
+ // Yielded ONE WORD AT A TIME rather than returned as a list. Both readers
158
+ // stop at the first word the leading run cannot take, and a statement's text
159
+ // carries its whole nested `do={…}` subtree — so building a word (and a span
160
+ // per character of it) for the rest of that subtree was work no caller ever
161
+ // looked at, once per enclosing level (#322). A word is complete when the
162
+ // next one starts, which is why the yield happens on the following boundary
163
+ // rather than at the point the word is opened.
140
164
  let current: Word | null = null;
141
165
  let i = 0;
142
166
  while (i < text.length) {
@@ -152,6 +176,7 @@ function asciiWordSpans(text: string): Word[] {
152
176
  continue;
153
177
  }
154
178
  if (isAsciiWhitespace(c)) {
179
+ if (current !== null) yield current;
155
180
  current = null;
156
181
  i++;
157
182
  continue;
@@ -163,7 +188,6 @@ function asciiWordSpans(text: string): Word[] {
163
188
  end: i + 1,
164
189
  chars: [{ start: i, end: i + 1 }],
165
190
  };
166
- out.push(current);
167
191
  } else {
168
192
  current.name += c;
169
193
  current.end = i + 1;
@@ -171,7 +195,7 @@ function asciiWordSpans(text: string): Word[] {
171
195
  }
172
196
  i++;
173
197
  }
174
- return out;
198
+ if (current !== null) yield current;
175
199
  }
176
200
 
177
201
  /**
@@ -284,14 +308,25 @@ export function runTokens(text: string): RunToken[] {
284
308
  }
285
309
 
286
310
  /** True when the statement is a scripting directive (`:x`, or a bare `x do={…}`). */
287
- export function isDirective(text: string): boolean {
311
+ export function isDirective(text: string, range?: MaskedRange): boolean {
288
312
  const t = trimAscii(text);
289
313
  if (t.startsWith(":")) return true;
290
- return !t.startsWith("/") && scopeBodies(t).length > 0;
314
+ if (t.startsWith("/")) return false;
315
+ const start = text.length - trimAsciiStart(text).length;
316
+ return (
317
+ scopeBlocksIn(
318
+ range === undefined
319
+ ? documentRange(t)
320
+ : nestedRange(range, start, start + t.length),
321
+ ).length > 0
322
+ );
291
323
  }
292
324
 
293
325
  /** Everything the boundary rule needs from one statement's text. */
294
- export function describeStatement(text: string): {
326
+ export function describeStatement(
327
+ text: string,
328
+ range?: MaskedRange,
329
+ ): {
295
330
  run: RunToken[];
296
331
  directive: boolean;
297
332
  whole: boolean;
@@ -331,20 +366,30 @@ export function describeStatement(text: string): {
331
366
  // V4 — did the run consume every whitespace-separated word? A slash-joined
332
367
  // word expands to several run tokens, so compare against the SOURCE words the
333
368
  // run covers, not the run length.
334
- const words = asciiWordSpans(body);
335
369
  let covered = 0;
370
+ let words = 0;
336
371
  let index = 0;
372
+ // A word the run cannot take is proof there is one more word than the run
373
+ // covered, which is the whole of V4's question — so the walk stops there
374
+ // instead of counting the rest of the statement.
375
+ let everyWordCovered = true;
337
376
  const runEnds: (number | null)[] = run.map(() => null);
338
- for (const word of words) {
377
+ for (const word of asciiWordSpans(body)) {
378
+ words++;
339
379
  const parts = word.name.split("/").filter((p) => p.length > 0);
340
380
  if (parts.length === 0) continue;
341
- if (index + parts.length > run.length) break;
381
+ if (index + parts.length > run.length) {
382
+ everyWordCovered = false;
383
+ break;
384
+ }
342
385
  if (
343
386
  !parts.every(
344
387
  (p, i) => (run[index + i] as RunToken | undefined)?.name === p,
345
388
  )
346
- )
389
+ ) {
390
+ everyWordCovered = false;
347
391
  break;
392
+ }
348
393
  // Only the word's LAST part ends where the word does; an earlier part is
349
394
  // followed by more path inside the same word.
350
395
  runEnds[index + parts.length - 1] = base + word.end;
@@ -353,8 +398,8 @@ export function describeStatement(text: string): {
353
398
  }
354
399
  return {
355
400
  run,
356
- directive: isDirective(text),
357
- whole: covered === words.length && run.length > 0,
401
+ directive: isDirective(text, range),
402
+ whole: everyWordCovered && covered === words && run.length > 0,
358
403
  runEnds,
359
404
  };
360
405
  }
@@ -586,6 +631,25 @@ function unknownSplit(why: string): VerbSplitRefusal {
586
631
  };
587
632
  }
588
633
 
634
+ /**
635
+ * What follows the leading run: an operand stream, a brace body, or unknown.
636
+ *
637
+ * Only the tail's FIRST byte is read, and only three answers come out of it, so
638
+ * this stays a shape test rather than a second parser. `"body"` is the `{` of a
639
+ * menu container or a scope block; `"argument"` is anything else that is there;
640
+ * `"none"` is a run with nothing after it (V4's bare path) or a tail whose
641
+ * offset `describeStatement` could not establish.
642
+ */
643
+ function operandTail(
644
+ text: string,
645
+ runEnd: number | null,
646
+ ): "argument" | "body" | "none" {
647
+ if (runEnd === null) return "none";
648
+ const tail = trimAsciiStart(text.slice(runEnd));
649
+ if (tail.length === 0) return "none";
650
+ return tail.startsWith("{") ? "body" : "argument";
651
+ }
652
+
589
653
  /**
590
654
  * Resolve one statement's verb/menu boundary, applying the enclosing menu
591
655
  * `context`. Mirrors `pathresolve`'s base rule: a `:`/bare directive and an
@@ -593,12 +657,16 @@ function unknownSplit(why: string): VerbSplitRefusal {
593
657
  * `context`. Substitution-headed and variable-segment statements are `unknown`
594
658
  * — offline refuses rather than guessing (the Q3/Q4/Q14 fail-closed floor).
595
659
  */
596
- export function resolveVerb(text: string, context: string): VerbSplit {
660
+ export function resolveVerb(
661
+ text: string,
662
+ context: string,
663
+ range?: MaskedRange,
664
+ ): VerbSplit {
597
665
  const t = trimAscii(text);
598
666
  if (t.startsWith("$") || t.startsWith("[") || t.startsWith("("))
599
667
  return unknownSplit("dynamic or substitution-headed statement");
600
668
 
601
- const { run, directive, whole, runEnds } = describeStatement(text);
669
+ const { run, directive, whole, runEnds } = describeStatement(text, range);
602
670
  if (run.length === 0) return unknownSplit("no leading path token");
603
671
 
604
672
  // `directive` already folds in the bare-directive case (`isDirective`), so it
@@ -657,6 +725,58 @@ export function resolveVerb(text: string, context: string): VerbSplit {
657
725
  ambiguous: false,
658
726
  why: `published command \`${candidates[catalogAt]}\``,
659
727
  };
728
+ // #324 — the symmetric half of R9. R9 made `pathresolve`'s `menuNavPath`
729
+ // consult `VERBS` so it could not claim navigation where a verb was decided;
730
+ // this stops the punctuation rule claiming a verb where the run names a
731
+ // known MENU and the token it would promote is not a console verb.
732
+ // `/ip/firewall/address-list name=ytkids` has no verb in it: the punctuation
733
+ // rule's "no space token, arguments follow — last segment" fallback promotes
734
+ // `address-list`, and the spaced spelling `/ip firewall address-list name=x`
735
+ // promotes `firewall`, one level higher still. Both premises are available
736
+ // offline and both say no, so the reading is a fabrication rather than a
737
+ // guess with a basis.
738
+ //
739
+ // Only the fail-closed direction: PRESENCE in the tables decides, absence
740
+ // decides nothing, so an unlisted menu spelled `…/address-list` keeps
741
+ // today's reading. The vocabulary hit is excluded by the `VERBS` test itself
742
+ // (a vocabulary split promotes a `VERBS` member by construction), the
743
+ // published-command axis by `catalogAt` — first-order evidence about that
744
+ // exact path outranks a container listing of its prefix — and directives by
745
+ // R10, which is measured.
746
+ //
747
+ // This abstains, it does not reject: offline knows it cannot name a verb
748
+ // here, which needs no device evidence. Whether RouterOS refuses the shape
749
+ // outright is a device claim and is deliberately not made (contrast #311's
750
+ // `error`, which is CHR-grounded).
751
+ //
752
+ // One shape is excluded by the tail rather than by a table: a `{` after the
753
+ // run is a menu CONTAINER body (`/system {identity print}`, H7), where the
754
+ // run legitimately names a menu and no verb is missing at all.
755
+ // `invalidCommandBraceDiagnostics` reads that statement's `argsAt` to find
756
+ // the brace, so abstaining here would drop a real `error` on the statement
757
+ // after it. A tail this cannot see (`runEnds` null) is not classified, and
758
+ // the guard stands down there too.
759
+ if (
760
+ !directive &&
761
+ catalogAt === null &&
762
+ !split.ambiguous &&
763
+ split.verbAt !== null &&
764
+ !VERBS.has((run[split.verbAt] as RunToken).name) &&
765
+ operandTail(text, runEnds[run.length - 1] ?? null) === "argument"
766
+ ) {
767
+ const full = candidates[candidates.length - 1] as string;
768
+ if (isKnownMenuPath(full.split("/").filter(Boolean)))
769
+ return {
770
+ resolution: "ambiguous",
771
+ kind: null,
772
+ path: null,
773
+ verb: null,
774
+ verbAt: null,
775
+ argsAt: null,
776
+ candidates,
777
+ why: `\`${full}\` is a known RouterOS menu and \`${(run[split.verbAt] as RunToken).name}\` is not a console verb: this run names a menu and the verb is missing, so offline will not promote a path segment to one`,
778
+ };
779
+ }
660
780
  if (split.ambiguous) {
661
781
  // V4's bare path, the one case `proposed` refuses — and the one the baked
662
782
  // container table can answer (#210). The lookup reads the LAST candidate,
@@ -791,19 +911,41 @@ export interface VerbAnalysis {
791
911
  * pair this array with `segmentStatements` by index.
792
912
  */
793
913
  export function resolveVerbs(text: string): VerbAnalysis {
794
- return resolveVerbsFromStatements(resolveStatements(text));
914
+ const analysis = resolveStatements(text);
915
+ return resolveVerbsFromStatements(
916
+ analysis,
917
+ analysis.defects.some((d) => d.code === "bom" || d.code === "non-ascii")
918
+ ? undefined
919
+ : documentRange(text, true),
920
+ );
795
921
  }
796
922
 
797
- /** Reuse an existing Q4 walk when a composed analysis already performed it. */
798
- export function resolveVerbsFromStatements({
799
- statements,
800
- defects,
801
- }: StatementAnalysis): VerbAnalysis {
923
+ /**
924
+ * Reuse an existing Q4 walk when a composed analysis already performed it.
925
+ * `range`, if supplied, belongs to the same ASCII document as the analysis;
926
+ * statement spans are absolute document offsets, even within a partial range.
927
+ */
928
+ export function resolveVerbsFromStatements(
929
+ { statements, defects }: StatementAnalysis,
930
+ range?: MaskedRange,
931
+ ): VerbAnalysis {
802
932
  return {
803
933
  splits: statements.map((s) => ({
804
934
  ...(s.unresolved
805
935
  ? unknownSplit(s.unresolved)
806
- : resolveVerb(s.text, s.context)),
936
+ : resolveVerb(
937
+ s.text,
938
+ s.context,
939
+ // The caller supplies a range only for ASCII input; widened
940
+ // fallback spans must still match the statement's own text.
941
+ range !== undefined &&
942
+ s.span.start >= range.start &&
943
+ s.span.end <= range.end &&
944
+ s.text.length === s.span.end - s.span.start &&
945
+ range.text.slice(s.span.start, s.span.end) === s.text
946
+ ? rangeAt(range, s.span.start, s.span.end)
947
+ : undefined,
948
+ )),
807
949
  span: s.span,
808
950
  text: s.text,
809
951
  contextCertain: s.contextCertain,
@@ -93,11 +93,13 @@ import { type Defect, hasStructuralDefect, mergeDefects } from "./defects.ts";
93
93
  import { isMenuPath } from "./menus.ts";
94
94
  import {
95
95
  type DocumentAnalysis,
96
+ rangeForResolution,
96
97
  resolveDocument,
97
98
  resolveStatements,
98
99
  type StatementAnalysis,
99
100
  type StatementResolution,
100
101
  } from "./pathresolve.ts";
102
+ import { documentRange, type MaskedRange, rangeAt } from "./segment.ts";
101
103
  import {
102
104
  catalogVerbAt,
103
105
  describeStatement,
@@ -603,12 +605,29 @@ function catalogVerbOf(
603
605
  * consumes this only as a rollup, so it cannot repeat that failure.
604
606
  */
605
607
  export function occurrences(text: string): Occurrence[] {
606
- return collect(resolveStatements(text), resolveDocument(text));
608
+ const statements = resolveStatements(text);
609
+ return collect(
610
+ statements,
611
+ resolveDocument(text),
612
+ sourceRange(text, statements),
613
+ );
614
+ }
615
+
616
+ function sourceRange(
617
+ text: string,
618
+ analysis: StatementAnalysis,
619
+ ): MaskedRange | undefined {
620
+ return analysis.defects.some(
621
+ (d) => d.code === "bom" || d.code === "non-ascii",
622
+ )
623
+ ? undefined
624
+ : documentRange(text, true);
607
625
  }
608
626
 
609
627
  function collect(
610
628
  statements: StatementAnalysis,
611
629
  brackets: DocumentAnalysis,
630
+ range?: MaskedRange,
612
631
  ): Occurrence[] {
613
632
  const out: Occurrence[] = [];
614
633
  const list = statements.statements;
@@ -627,7 +646,21 @@ function collect(
627
646
  if (t.length === 0) continue;
628
647
  // One parse per statement, threaded through every rule below: the Q6
629
648
  // boundary reads the same run three or four times otherwise.
630
- const described = describeStatement(t);
649
+ const trimStart = statement.text.length - statement.text.trimStart().length;
650
+ const described = describeStatement(
651
+ t,
652
+ range !== undefined &&
653
+ statement.span.start >= range.start &&
654
+ statement.span.end <= range.end &&
655
+ range.text.slice(statement.span.start, statement.span.end) ===
656
+ statement.text
657
+ ? rangeAt(
658
+ range,
659
+ statement.span.start + trimStart,
660
+ statement.span.start + trimStart + t.length,
661
+ )
662
+ : undefined,
663
+ );
631
664
  const catalogAt = catalogVerbOf(t, described, statement.context);
632
665
  if (isDynamicForm(t, described)) {
633
666
  out.push({
@@ -676,7 +709,8 @@ function collect(
676
709
  for (const bracket of brackets.resolutions) {
677
710
  const inner = bracket.inner.trim();
678
711
  if (inner.length === 0) continue;
679
- const described = describeStatement(inner);
712
+ const bracketRange = rangeForResolution(bracket) ?? documentRange(inner);
713
+ const described = describeStatement(inner, bracketRange);
680
714
  if (isDynamicForm(inner, described)) {
681
715
  out.push({
682
716
  kind: "bracket",
@@ -774,18 +808,25 @@ export function containsWrite(text: string): WriteAnalysis {
774
808
  // One resolution pass each for the statement walk (Q4) and the bracket walk
775
809
  // (Q3); both are re-entrant over the same segmentation and neither is cheap
776
810
  // on adversarial input, so they are not re-run for the defects.
811
+ const statements = resolveStatements(text);
777
812
  return containsWriteFromAnalyses(
778
- resolveStatements(text),
813
+ statements,
779
814
  resolveDocument(text),
815
+ sourceRange(text, statements),
780
816
  );
781
817
  }
782
818
 
783
- /** Reuse the Q3/Q4 walks when a composed analysis already performed them. */
819
+ /**
820
+ * Reuse the Q3/Q4 walks when a composed analysis already performed them.
821
+ * `range`, if supplied, belongs to the same ASCII document as the analyses;
822
+ * statements outside that range keep their standalone reading.
823
+ */
784
824
  export function containsWriteFromAnalyses(
785
825
  statements: StatementAnalysis,
786
826
  brackets: DocumentAnalysis,
827
+ range?: MaskedRange,
787
828
  ): WriteAnalysis {
788
- const found = collect(statements, brackets);
829
+ const found = collect(statements, brackets, range);
789
830
  const defects = mergeDefects(statements.defects, brackets.defects);
790
831
  const writes = found.filter((o) => o.klass === "write").length;
791
832
  const blockers = found.filter((o) => BLOCKING.has(o.klass));