@tikoci/centrs 0.1.9-next.23 → 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 (53) hide show
  1. package/CHANGELOG.md +106 -0
  2. package/GLOSSARY.txt +14 -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 +24 -5
  9. package/commands/explain/README.md +452 -61
  10. package/commands/explain/examples.md +191 -41
  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 +14 -9
  16. package/docs/MATRIX.md +34 -21
  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 +2 -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 +115 -10
  35. package/src/explain/group-faults.ts +162 -29
  36. package/src/explain/operators.ts +2 -1
  37. package/src/explain/scope-brace.ts +226 -11
  38. package/src/explain/segment.ts +136 -1
  39. package/src/explain/symbols.ts +146 -6
  40. package/src/explain/values.ts +55 -4
  41. package/src/explain-live-facets.ts +739 -0
  42. package/src/explain-live-types.ts +490 -0
  43. package/src/explain-live.ts +593 -0
  44. package/src/explain.ts +395 -43
  45. package/src/index.ts +6 -0
  46. package/src/protocols/adapter.ts +88 -10
  47. package/src/protocols/mac-telnet.ts +38 -10
  48. package/src/protocols/native-api.ts +13 -12
  49. package/src/retrieve-follow.ts +2 -1
  50. package/src/retrieve-sample.ts +2 -1
  51. package/src/retrieve-wait.ts +10 -6
  52. package/src/retrieve.ts +77 -27
  53. package/src/transfer.ts +38 -13
package/src/explain.ts CHANGED
@@ -70,9 +70,9 @@
70
70
  * separate optional homes but no offline producer. Each fact has its own
71
71
  * provenance; no hint validates a value or becomes a diagnostic. The
72
72
  * decision and the probe matrix are #225; `op` is deliberately not a shape
73
- * hint — `(> …)` abstains offline and is reported only as a live
74
- * `observedType` because identical syntax can be `op` or `array` and
75
- * `:parse` IL cannot distinguish them (#288). This module is deliberately
73
+ * hint — `(> …)` abstains offline AND live, because identical syntax can
74
+ * be `op` or `array` and only `:typeof`, which live explain never sends,
75
+ * distinguishes them (#288, #225). This module is deliberately
76
76
  * type-blind, which is why a verdict that looks wrong around a value is a
77
77
  * type-axis question before it is a lexical one.
78
78
  * - **`runtimeAcceptance` is always `"not-proven"`**, offline and live alike.
@@ -101,7 +101,12 @@ import type {
101
101
  CommonSettingsMeta,
102
102
  EnvelopeMeta,
103
103
  } from "./core/envelope.ts";
104
- import { buildTip, type Tip, type Warning } from "./core/envelope.ts";
104
+ import {
105
+ buildTip,
106
+ type EnvelopeValidationMeta,
107
+ type Tip,
108
+ type Warning,
109
+ } from "./core/envelope.ts";
105
110
  import {
106
111
  type CanonicalExecuteCommand,
107
112
  canonicalizeExecuteCommand,
@@ -172,7 +177,12 @@ import {
172
177
  type ExplainTransport,
173
178
  } from "./explain/transport.ts";
174
179
  import { valueSpans } from "./explain/value-tokens.ts";
175
- import { type ValueShape, valueShapeHints } from "./explain/values.ts";
180
+ import {
181
+ type ObservedValueType,
182
+ type SchemaValueType,
183
+ type ValueShape,
184
+ valueShapeHints,
185
+ } from "./explain/values.ts";
176
186
  import {
177
187
  type DocumentVerbSplit,
178
188
  resolveVerb,
@@ -460,15 +470,21 @@ export interface ExplainStructure {
460
470
  *
461
471
  * The vocabulary is centrs-owned (the ratified decision): consumers depend on
462
472
  * it rather than on raw RouterOS `highlight` classes, so cross-version drift in
463
- * those classes is centrs's problem to absorb. For these five the two names
464
- * currently coincide, which is a convenience, not the contract.
473
+ * those classes is centrs's problem to absorb. Name-coincident classes keep
474
+ * their spelling; live classes outside this vocabulary remain unclassified
475
+ * here and are preserved in the raw highlight observation.
465
476
  */
466
477
  export type ExplainSpanClass =
467
478
  | "comment"
468
479
  | "variable-local"
469
480
  | "variable-global"
470
481
  | "variable-auto"
471
- | "variable-parameter";
482
+ | "variable-parameter"
483
+ | "dir"
484
+ | "cmd"
485
+ | "arg"
486
+ | "escaped"
487
+ | "unclassified";
472
488
 
473
489
  export interface ExplainSpan extends ExplainSpanRange {
474
490
  class: ExplainSpanClass;
@@ -620,8 +636,34 @@ export interface ExplainValueShapeFact {
620
636
  ev: string;
621
637
  }
622
638
 
639
+ /**
640
+ * A type the device's parser visibly converted a bare literal to (#225).
641
+ *
642
+ * Only a conversion is evidence: `:parse` prints a string unquoted, so an
643
+ * unchanged rendering (`123`, `1.1.1.1`, `"2.2"`) proves nothing and the fact
644
+ * is absent. See `src/explain-live-types.ts`.
645
+ */
623
646
  export interface ExplainValueTypeFact {
624
- value: string;
647
+ value: ObservedValueType;
648
+ /** The device's own rendering of the literal (`2.2` → `2.0.0.2`). */
649
+ rendered: string;
650
+ ev: string;
651
+ }
652
+
653
+ /**
654
+ * An argument's declared type, normalized from a schema source (#225).
655
+ *
656
+ * `values` are the leaf types the declaration mentions, not alternatives of
657
+ * one value: `gateway` (`Address[%Interface][@VRF]`) lists `ip`, `ip6` and
658
+ * `enum`, because inspect prints the interface names like keywords. `declared` is the source's own top-level term, verbatim,
659
+ * so a consumer can tell a live `/console/inspect` declaration from a CLI
660
+ * Reference `Type` cell by `ev` and still read the original.
661
+ */
662
+ export interface ExplainSchemaTypeFact {
663
+ values: SchemaValueType[];
664
+ /** Present only when the declaration spells a repetition; absence is not a claim. */
665
+ list?: true;
666
+ declared: string;
625
667
  ev: string;
626
668
  }
627
669
 
@@ -629,7 +671,7 @@ export interface ExplainValueTypeFact {
629
671
  export interface ExplainValueFacts {
630
672
  shapeHints?: ExplainValueShapeFact;
631
673
  observedType?: ExplainValueTypeFact;
632
- schemaType?: ExplainValueTypeFact;
674
+ schemaType?: ExplainSchemaTypeFact;
633
675
  }
634
676
 
635
677
  export interface ExplainValueOccurrence {
@@ -670,18 +712,14 @@ export interface ExplainDiagnostic {
670
712
  }
671
713
 
672
714
  /**
673
- * Where a derived fact came from — the OFFLINE SUBSET of the contract.
674
- *
675
- * The spec's evidence entry also carries `source: "live-inspect"` and a
676
- * RouterOS version stamp per probe, neither of which phase 1 can produce: no
677
- * probe runs and there is no device to stamp. The type says only what offline
678
- * emits rather than pre-declaring variants with no producer, so a consumer
679
- * cannot narrow on a `source` that never appears; phase 2 widens the union and
680
- * adds the stamp, which is an ADDITION to the shape a caller reads today.
715
+ * Where an analysis fact came from.
716
+ *
717
+ * Live probes add version, observation time, input identity and coverage. Their
718
+ * outcome describes acquisition, never runtime acceptance.
681
719
  */
682
720
  export interface ExplainEvidence {
683
721
  id: string;
684
- source: "canonicalizer";
722
+ source: "canonicalizer" | "live-inspect";
685
723
  /** The analyzer that produced the facts citing this entry. */
686
724
  probe: string;
687
725
  /**
@@ -692,7 +730,157 @@ export interface ExplainEvidence {
692
730
  * drawn from live responses.
693
731
  */
694
732
  basis: "direct" | "heuristic" | "derived";
695
- outcome: "ok" | "empty" | "timeout" | "transport-error";
733
+ outcome:
734
+ | "ok"
735
+ | "empty"
736
+ | "timeout"
737
+ | "transport-error"
738
+ | "auth-error"
739
+ | "unsupported"
740
+ | "skipped"
741
+ | "interrupted"
742
+ | "invalid-response";
743
+ /** Live observation provenance; absent on canonicalizer evidence. */
744
+ version?: string;
745
+ observedAt?: string;
746
+ statementIds?: string[];
747
+ path?: string;
748
+ coverage?: ExplainSpanRange;
749
+ inputSha256?: string;
750
+ normalization?: "original-utf8" | "sub-per-non-ascii-byte";
751
+ reason?: string;
752
+ errorCode?: CentrsError["code"];
753
+ /**
754
+ * The facet a live probe served; an `empty` facet answer is not a warning.
755
+ * `types` is the value-type probes (#225), which run on every live call.
756
+ */
757
+ facet?: "schema" | "complete" | "types";
758
+ }
759
+
760
+ /** One `request=syntax` row, verbatim: the device's declared help for one argument. */
761
+ export interface ExplainSyntaxRow {
762
+ symbol: string;
763
+ /** RouterOS `symbol-type` (`definition`, `explanation`, …). */
764
+ kind: string;
765
+ text: string;
766
+ /** RouterOS `nested` depth. */
767
+ depth: number;
768
+ }
769
+
770
+ /** A menu or verb under a described menu, with counts when it was expanded. */
771
+ export interface ExplainSchemaChild {
772
+ name: string;
773
+ verbs?: number;
774
+ menus?: number;
775
+ arguments?: number;
776
+ ev?: string;
777
+ }
778
+
779
+ export interface ExplainSchemaArgument {
780
+ name: string;
781
+ /** Argument-level `syntax` rows, present when within the probe budget. */
782
+ syntax?: ExplainSyntaxRow[];
783
+ ev?: string;
784
+ }
785
+
786
+ /**
787
+ * `--schema`: what the device declares at one path (#492). `child` decides
788
+ * the node; a menu lists verbs and submenus, a command its arguments, `print`
789
+ * also its `.proplist`. Declared facts only — never print rows or state.
790
+ */
791
+ export interface ExplainSchema {
792
+ /** The `child` probe on the described path. */
793
+ ev: string;
794
+ path: string;
795
+ statementId?: string;
796
+ node: "menu" | "command" | "argument" | "unknown";
797
+ verbs?: ExplainSchemaChild[];
798
+ menus?: ExplainSchemaChild[];
799
+ arguments?: ExplainSchemaArgument[];
800
+ /** Output-field names a `print` can show; not print rows. */
801
+ proplist?: {
802
+ ev: string;
803
+ source: "print-proplist" | "get-value-name";
804
+ names: string[];
805
+ };
806
+ /** Argument node only: its `syntax` rows. */
807
+ syntax?: ExplainSyntaxRow[];
808
+ /** Full sizes as the device reported them, before any list was cut. */
809
+ counts: {
810
+ verbs?: number;
811
+ menus?: number;
812
+ arguments?: number;
813
+ proplist?: number;
814
+ };
815
+ /** A list was cut or children were left unexpanded. */
816
+ truncated: boolean;
817
+ /** Narrower inputs that expand what this result left out. */
818
+ next: string[];
819
+ reason?: string;
820
+ }
821
+
822
+ export interface ExplainCompletionCandidate {
823
+ text: string;
824
+ /** `variable`: a name in a `where` clause, which RouterOS scopes as a variable. */
825
+ kind: "menu" | "verb" | "argument" | "variable" | "value";
826
+ /** `exact` when the typed prefix already spells the whole candidate. */
827
+ match: "partial" | "exact";
828
+ /** The input-completion probe that offered it. */
829
+ ev: string;
830
+ }
831
+
832
+ /**
833
+ * `--complete`: what may follow at the cursor (#492). Every candidate is an
834
+ * observation: the device's input completion, cited by `ev`. It is never a
835
+ * closed set, and a value may reflect configuration state (interface names).
836
+ * Declared facts (`child`, argument `syntax`) live in {@link ExplainSchema}.
837
+ */
838
+ export interface ExplainCompletion {
839
+ /** Analyzed byte offset; equals the device's UTF-8 byte offset. */
840
+ cursor: number;
841
+ /** The cursor in the original document: 0-based line, UTF-16 character. */
842
+ at: { line: number; character: number };
843
+ statementId?: string;
844
+ context: "path" | "argument-name" | "variable" | "value" | "unsupported";
845
+ /** Bytes a chosen candidate replaces; `end` is the cursor. */
846
+ replace: ExplainSpanRange;
847
+ prefix: string;
848
+ /** The command at the cursor, when known (argument-name and value contexts). */
849
+ path?: string;
850
+ /** Value context: the argument (or `where` property) whose value is being completed. */
851
+ argument?: string;
852
+ /** Exact argument name: whether the device offers `=` at the cursor. */
853
+ takesValue?: boolean;
854
+ candidates: ExplainCompletionCandidate[];
855
+ /** Argument value context: argument-level `syntax` rows (type information). */
856
+ syntax?: ExplainSyntaxRow[];
857
+ count: number;
858
+ truncated: boolean;
859
+ reason?: string;
860
+ }
861
+
862
+ export interface ExplainLiveData {
863
+ environment?: {
864
+ ev: string;
865
+ version: string;
866
+ architecture?: string;
867
+ board?: string;
868
+ };
869
+ /** Exact parse output; absent when empty, skipped or unavailable. */
870
+ parse?: {
871
+ ev: string;
872
+ output: string;
873
+ rejected: boolean;
874
+ position?: { line: number; column: number };
875
+ };
876
+ /** Raw device classes, retained separately from the centrs span vocabulary. */
877
+ highlight?: { ev: string; classes: string[] };
878
+ arguments: {
879
+ ev: string;
880
+ path: string;
881
+ statementIds: string[];
882
+ names: string[];
883
+ }[];
696
884
  }
697
885
 
698
886
  export interface ExplainData {
@@ -703,6 +891,12 @@ export interface ExplainData {
703
891
  symbols: ExplainSymbols;
704
892
  values: ExplainValues;
705
893
  spans: ExplainSpan[];
894
+ /** Read-only device observations, present only on live calls. */
895
+ live?: ExplainLiveData;
896
+ /** Live `--schema` only. */
897
+ schema?: ExplainSchema;
898
+ /** Live `--complete` only. */
899
+ completion?: ExplainCompletion;
706
900
  /**
707
901
  * Total, gapless token partition of `[0, input.bytes)`.
708
902
  *
@@ -725,8 +919,8 @@ export interface ExplainData {
725
919
 
726
920
  export interface ExplainOperationMeta {
727
921
  command: "explain";
728
- /** `offline` is the only mode phase 1 has; live evidence is phase 2. */
729
- mode: "offline";
922
+ /** Whether a target was consulted for read-only evidence. */
923
+ mode: "offline" | "live";
730
924
  statementCount: number;
731
925
  verdict: ExplainVerdict;
732
926
  }
@@ -1271,11 +1465,24 @@ export function buildTokens(
1271
1465
  return out;
1272
1466
  }
1273
1467
 
1468
+ /** Source order, with the most severe finding first at the same byte. */
1469
+ export function compareExplainDiagnostics(
1470
+ a: ExplainDiagnostic,
1471
+ b: ExplainDiagnostic,
1472
+ ): number {
1473
+ return (
1474
+ a.span.start - b.span.start ||
1475
+ severityRank(b.severity) - severityRank(a.severity)
1476
+ );
1477
+ }
1478
+
1274
1479
  function severityRank(s: ExplainSeverity): number {
1275
1480
  return s === "error" ? 2 : s === "warning" ? 1 : 0;
1276
1481
  }
1277
1482
 
1278
- function verdictOf(diagnostics: readonly ExplainDiagnostic[]): ExplainVerdict {
1483
+ export function verdictOf(
1484
+ diagnostics: readonly ExplainDiagnostic[],
1485
+ ): ExplainVerdict {
1279
1486
  let worst = 0;
1280
1487
  for (const d of diagnostics)
1281
1488
  worst = Math.max(worst, severityRank(d.severity));
@@ -1479,7 +1686,11 @@ export function explainCommand(
1479
1686
  }),
1480
1687
  ...braceDiagnostics,
1481
1688
  ...groupFaultDiagnostics(
1482
- findGroupFaults(analyzed, analyzedRange.masked),
1689
+ findGroupFaults(
1690
+ analyzed,
1691
+ analyzedRange.masked,
1692
+ defects.find(({ defect }) => defect.code === "bom")?.defect.end,
1693
+ ),
1483
1694
  braceDiagnostics,
1484
1695
  ),
1485
1696
  ...separators.flatMap((runs) => runs.map(missingSeparatorDiagnostic)),
@@ -1487,11 +1698,7 @@ export function explainCommand(
1487
1698
  pairs.filter((pair) => pair.valueShaped).map(spacedArgumentDiagnostic),
1488
1699
  ),
1489
1700
  ...statements.flatMap((s) => diagnosticsForStatement(s)),
1490
- ].sort(
1491
- (a, b) =>
1492
- a.span.start - b.span.start ||
1493
- severityRank(b.severity) - severityRank(a.severity),
1494
- );
1701
+ ].sort(compareExplainDiagnostics);
1495
1702
 
1496
1703
  const valueFacts = valuesOf(
1497
1704
  verbs.splits,
@@ -2134,6 +2341,18 @@ function spacedArgumentDiagnostic(pair: SpacedPair): ExplainDiagnostic {
2134
2341
  const INVALID_COMMAND_BRACE =
2135
2342
  "brace arrays are not valid in command arguments — use a parenthesized comma list such as `(1,2)`, or a bare comma list where the argument schema accepts one";
2136
2343
 
2344
+ /**
2345
+ * A `name={` with a script-like body that is not a proven script slot (#525).
2346
+ *
2347
+ * `warning`, not `error`: `down={ :beep }` lowers to `down-script=` on CHR
2348
+ * 7.24.5 and 7.23.8, while `comment={ :beep }` is a `syntax error` there, and
2349
+ * telling them apart needs the command's argument list, which is schema. The
2350
+ * brace is read as a body; the device decides the slot.
2351
+ */
2352
+ function possibleScriptBodyMessage(name: string): string {
2353
+ return `possible script-body abbreviation: \`${name}=\` with a \`{…}\` body may be a script-valued argument or abbreviate one; offline has no per-command argument list to confirm the slot, so the brace is read as a script body — device validation decides`;
2354
+ }
2355
+
2137
2356
  /**
2138
2357
  * The group faults as diagnostics (#505, #509, #511, #512).
2139
2358
  *
@@ -2198,9 +2417,35 @@ function groupFaultMessage(f: GroupFault): string {
2198
2417
  return '`$` must be followed by a variable name (letters and digits) or `"name"`, and RouterOS rejects it here (`syntax error`) — quote a literal dollar (`"\\$"`)';
2199
2418
  case "invalid-name":
2200
2419
  return 'a variable name is letters and digits only, and RouterOS rejects any other byte in it (`expected end of command`) — quote the name (`:local "a_b"`) to use other characters';
2420
+ case "unquoted-non-ascii":
2421
+ case "unquoted-reserved-byte":
2422
+ return unquotedByteMessage(f);
2201
2423
  }
2202
2424
  }
2203
2425
 
2426
+ /**
2427
+ * The unquoted-byte faults (#358, #371). The remedy follows the site: a value
2428
+ * can be quoted, an argument name must be corrected, and a path, command or
2429
+ * `$name` must lose the byte (a variable name may be quoted, `$"café"`).
2430
+ */
2431
+ function unquotedByteMessage(f: GroupFault): string {
2432
+ const what =
2433
+ f.code === "unquoted-non-ascii"
2434
+ ? "a non-ASCII character outside a string"
2435
+ : f.detail === "="
2436
+ ? "a second `=` in a bare argument"
2437
+ : `an unquoted \`${f.detail}\``;
2438
+ if (f.site === "argument-name")
2439
+ return `${what} in an argument name: RouterOS rejects the name at its first byte (\`expected end of command\`) — correct the argument name; no RouterOS argument name holds this byte`;
2440
+ if (f.site === "head-word")
2441
+ return `${what} in a path or command word: RouterOS rejects it (\`expected command name\` or \`expected end of command\`) — remove or retype the byte (a \`?\` is the console's help key, and a pasted non-breaking space or curly quote only looks like ASCII); a variable name may be quoted (\`$"café"\`)`;
2442
+ if (f.code === "unquoted-non-ascii")
2443
+ return `${what}: RouterOS reads only ASCII in a bare word, name or value, and rejects it (\`expected end of command\`) — quote it (\`name="café"\`, \`$"café"\`), or retype a pasted non-breaking space, curly quote or ellipsis`;
2444
+ return f.detail === "="
2445
+ ? `${what} value: RouterOS rejects it (\`expected end of command\`) — quote the value (\`comment="a=b"\`), as a base64 key ending in \`=\` needs`
2446
+ : `${what} is never part of a bare word, and RouterOS rejects it (\`expected end of command\`) — quote the value (\`url="http://x/a?b"\`)`;
2447
+ }
2448
+
2204
2449
  function invalidCommandBraceDiagnostics(
2205
2450
  splits: readonly DocumentVerbSplit[],
2206
2451
  analyzed: string,
@@ -2212,12 +2457,15 @@ function invalidCommandBraceDiagnostics(
2212
2457
  const { start, end } = split.span;
2213
2458
  const text = analyzed.slice(start, end);
2214
2459
  if (text !== split.text) continue;
2215
- // The verb splitter cannot yet flatten every relative nested menu block.
2216
- // Do not reinterpret a known submenu's leading `{` as an argument value,
2217
- // but retain diagnostics for rejected braces later in the same split.
2218
- // Require evidence that the brace actually opens a menu container
2219
- // (body starts with a submenu name), so `/ip/route/print {1;2}` and
2220
- // `/ip { firewall/filter/print {1;2} }` still diagnose the `{1;2}` array.
2460
+ // The segmenter flattens a relative submenu block only where the source
2461
+ // alone names the menu (#351, `menuBlockPath`): not after a navigation
2462
+ // statement (`/ip⏎address { … }`), and not for an abbreviated or
2463
+ // digit-led menu word. Those still reach here as one split. Do not
2464
+ // reinterpret a known submenu's leading `{` as an argument value, but
2465
+ // retain diagnostics for rejected braces later in the same split.
2466
+ // Require evidence that the brace actually opens a menu container, so
2467
+ // `/ip/route/print {1;2}` and `/ip { firewall/filter/print {1;2} }` still
2468
+ // diagnose the `{1;2}` array.
2221
2469
  //
2222
2470
  // A verb the vocabulary or the catalog names is no menu, so its leading
2223
2471
  // brace is a positional operand: `/system/script add { :beep }` and
@@ -2284,18 +2532,28 @@ function invalidCommandBraceDiagnostics(
2284
2532
  }
2285
2533
  }
2286
2534
  }
2287
- for (const at of invalidCommandBraceOffsets(
2535
+ for (const finding of invalidCommandBraceOffsets(
2288
2536
  text,
2289
2537
  split.argsAt,
2290
2538
  split.path === "/" ? split.verb : undefined,
2291
2539
  nestedRange(analyzedRange, start, end),
2292
2540
  )) {
2293
- if (at === menuBraceAt) continue;
2541
+ if (finding.at === menuBraceAt) continue;
2542
+ if (finding.ambiguous) {
2543
+ out.push({
2544
+ code: "explain/canonicalizer/possible-script-body-abbreviation",
2545
+ severity: "warning",
2546
+ message: possibleScriptBodyMessage(finding.name ?? "name"),
2547
+ span: { start: start + finding.at, end: start + finding.at + 1 },
2548
+ ev: EV.values,
2549
+ });
2550
+ continue;
2551
+ }
2294
2552
  out.push({
2295
2553
  code: "explain/canonicalizer/invalid-command-brace",
2296
2554
  severity: "error",
2297
2555
  message: INVALID_COMMAND_BRACE,
2298
- span: { start: start + at, end: start + at + 1 },
2556
+ span: { start: start + finding.at, end: start + finding.at + 1 },
2299
2557
  ev: EV.values,
2300
2558
  });
2301
2559
  }
@@ -2611,7 +2869,7 @@ export function explainEnvelope(
2611
2869
  buildTip(
2612
2870
  "tip/explain-offline-only",
2613
2871
  "No router was given, so this is the canonicalizer's reading alone.",
2614
- "Completion, schema, and `:parse` evidence come from a live target (`centrs explain <router> '<input>'`), which is phase 2 and not accepted yet.",
2872
+ "Add a router (`centrs explain <router> '<input>'`) for read-only parse, highlight and argument-name evidence; `--schema` and `--complete` then describe paths and complete at the cursor.",
2615
2873
  ),
2616
2874
  ...(options.tips ?? []),
2617
2875
  ];
@@ -2620,7 +2878,7 @@ export function explainEnvelope(
2620
2878
  target: {},
2621
2879
  via: null,
2622
2880
  settings: settingsMeta(options.format),
2623
- validation: { enabled: false, result: "skipped" },
2881
+ validation: explainValidationMeta(),
2624
2882
  operation: {
2625
2883
  command: "explain",
2626
2884
  mode: "offline",
@@ -2645,7 +2903,7 @@ export function buildExplainFacetTip(facets: readonly string[]): Tip {
2645
2903
  return buildTip(
2646
2904
  "tip/explain-live-facets",
2647
2905
  `${named} enumerate what a device accepts, and no router was given — so nothing is enumerated here.`,
2648
- "Name a router once live evidence lands (phase 2); until then use `rosetta` for documented RouterOS schema facts.",
2906
+ "Name a router (`centrs explain <router> '<input>' --schema`) to ask the device; use `rosetta` for documented RouterOS schema facts.",
2649
2907
  );
2650
2908
  }
2651
2909
 
@@ -2779,10 +3037,23 @@ export function buildExplainErrorEnvelope(
2779
3037
  error: serializeCentrsError(centrs),
2780
3038
  warnings: [],
2781
3039
  tips,
2782
- meta: { target: {}, via: null, settings: settingsMeta(format) },
3040
+ meta: {
3041
+ target: {},
3042
+ via: null,
3043
+ settings: settingsMeta(format),
3044
+ validation: explainValidationMeta(),
3045
+ },
2783
3046
  };
2784
3047
  }
2785
3048
 
3049
+ /**
3050
+ * explain is not a gate: it never runs the input, so there is no gate verdict
3051
+ * to report, offline or live. Its verdict is `data.verdict` (#535).
3052
+ */
3053
+ export function explainValidationMeta(): EnvelopeValidationMeta {
3054
+ return { enabled: false };
3055
+ }
3056
+
2786
3057
  export function renderExplainEnvelope(
2787
3058
  envelope: ExplainEnvelope | ExplainErrorEnvelope,
2788
3059
  format: ExplainOutputFormat,
@@ -2844,7 +3115,85 @@ function renderValue(occurrence: ExplainValueOccurrence): string {
2844
3115
  const shapes = occurrence.facts.shapeHints?.values.join("|") ?? "unknown";
2845
3116
  const parent =
2846
3117
  occurrence.parent === undefined ? "" : ` in=${occurrence.parent}`;
2847
- return `${occurrence.kind.padEnd(10)}${name}${parent} shapes=${shapes}`;
3118
+ const observed = occurrence.facts.observedType;
3119
+ const schema = occurrence.facts.schemaType;
3120
+ const live = `${observed ? ` observed=${observed.value}(${observed.rendered})` : ""}${schema ? ` schema=${schema.values.join("|")}${schema.list ? "[list]" : ""}` : ""}`;
3121
+ return `${occurrence.kind.padEnd(10)}${name}${parent} shapes=${shapes}${live}`;
3122
+ }
3123
+
3124
+ function renderLiveSummary(live: ExplainLiveData): string {
3125
+ const device = live.environment
3126
+ ? `${live.environment.version}${live.environment.architecture ? ` ${live.environment.architecture}` : ""}`
3127
+ : "device unavailable";
3128
+ const parse = live.parse
3129
+ ? `parse ${live.parse.rejected ? "rejected" : "accepted"}${live.parse.position ? ` at ${live.parse.position.line}:${live.parse.position.column}` : ""}`
3130
+ : "parse unavailable";
3131
+ const highlight = live.highlight
3132
+ ? `highlight ${live.highlight.classes.length} byte(s)`
3133
+ : "highlight unavailable";
3134
+ const names = live.arguments.reduce(
3135
+ (total, entry) => total + entry.names.length,
3136
+ 0,
3137
+ );
3138
+ return `live: ${device} · ${parse} · ${highlight} · arguments ${live.arguments.length} path(s), ${names} name(s)`;
3139
+ }
3140
+
3141
+ /** Names only, at most a dozen per line; `--json` keeps every fact. */
3142
+ function renderSchemaSummary(schema: ExplainSchema): string[] {
3143
+ const list = (label: string, names: readonly string[], total?: number) =>
3144
+ ` ${label} (${total ?? names.length}): ${names.slice(0, 12).join(", ")}${(total ?? names.length) > Math.min(names.length, 12) ? ", …" : ""}`;
3145
+ const lines = [
3146
+ `schema: ${schema.path} is ${schema.node === "unknown" ? "unknown to the device" : `a ${schema.node}`}${schema.truncated ? " (truncated)" : ""}${schema.reason ? ` — ${schema.reason}` : ""}`,
3147
+ ];
3148
+ if (schema.verbs)
3149
+ lines.push(
3150
+ list(
3151
+ "verbs",
3152
+ schema.verbs.map((v) => v.name),
3153
+ schema.counts.verbs,
3154
+ ),
3155
+ );
3156
+ if (schema.menus?.length)
3157
+ lines.push(
3158
+ list(
3159
+ "menus",
3160
+ schema.menus.map((m) => m.name),
3161
+ schema.counts.menus,
3162
+ ),
3163
+ );
3164
+ if (schema.arguments)
3165
+ lines.push(
3166
+ list(
3167
+ "arguments",
3168
+ schema.arguments.map((a) => a.name),
3169
+ schema.counts.arguments,
3170
+ ),
3171
+ );
3172
+ if (schema.proplist)
3173
+ lines.push(list("proplist", schema.proplist.names, schema.counts.proplist));
3174
+ if (schema.syntax)
3175
+ for (const row of schema.syntax)
3176
+ lines.push(` ${row.symbol ? `${row.symbol}: ` : ""}${row.text}`);
3177
+ return lines;
3178
+ }
3179
+
3180
+ function renderCompletionSummary(completion: ExplainCompletion): string {
3181
+ const head = `complete @${completion.cursor}: ${completion.context}${completion.argument ? ` ${completion.argument}=` : ""}${completion.prefix ? ` "${completion.prefix}"` : ""}`;
3182
+ if (completion.reason && completion.candidates.length === 0)
3183
+ return `${head} — ${completion.reason}`;
3184
+ const shown = completion.candidates
3185
+ .slice(0, 12)
3186
+ .map((c) => (c.match === "exact" ? `${c.text}*` : c.text));
3187
+ const value =
3188
+ completion.takesValue === undefined
3189
+ ? ""
3190
+ : completion.takesValue
3191
+ ? " (takes a value: `=`)"
3192
+ : " (flag, no value)";
3193
+ const syntax = completion.syntax?.length
3194
+ ? ` [${completion.syntax.map((row) => row.text || row.symbol).join("; ")}]`
3195
+ : "";
3196
+ return `${head} → ${completion.count === 0 ? "no candidates" : `${shown.join(", ")}${completion.count > shown.length ? ", …" : ""}`}${value}${syntax}`;
2848
3197
  }
2849
3198
 
2850
3199
  /**
@@ -2874,6 +3223,9 @@ function renderExplainText(
2874
3223
  `gate: ${canonical.mode}${canonical.path ? ` ${canonical.path}` : ""}${canonical.verb ? ` ${canonical.verb}` : ""} · ${argCount} arg(s)${canonical.writeShaped ? " · write-shaped" : ""}`,
2875
3224
  );
2876
3225
  lines.push(`write: ${structure.containsWrite}`);
3226
+ if (data.live) lines.push(renderLiveSummary(data.live));
3227
+ if (data.schema) lines.push(...renderSchemaSummary(data.schema));
3228
+ if (data.completion) lines.push(renderCompletionSummary(data.completion));
2877
3229
  if (data.input.normalized)
2878
3230
  lines.push(
2879
3231
  `input: ${data.input.bytes} byte(s), normalized (non-ASCII stood in for one-for-one)`,
package/src/index.ts CHANGED
@@ -480,6 +480,7 @@ export {
480
480
  type ExplainEvidence,
481
481
  type ExplainFailOn,
482
482
  type ExplainInput,
483
+ type ExplainLiveData,
483
484
  type ExplainMenuReading,
484
485
  type ExplainOperationMeta,
485
486
  type ExplainOutputFormat,
@@ -514,6 +515,11 @@ export {
514
515
  explainOutputFormats,
515
516
  renderExplainEnvelope,
516
517
  } from "./explain.ts";
518
+ export {
519
+ EXPLAIN_LIVE_LIMITS,
520
+ type ExplainLiveRequest,
521
+ explainLiveEnvelope,
522
+ } from "./explain-live.ts";
517
523
  export {
518
524
  assertOfflineSyntax,
519
525
  isOfflineGateRejection,