@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
@@ -91,6 +91,7 @@ import {
91
91
  hashStartsHardError,
92
92
  isIndexedStatementStart,
93
93
  matchingBraceEnd,
94
+ menuBlockPath,
94
95
  scopeNameFromMasked,
95
96
  startsArrayCommentLine,
96
97
  } from "./scope-brace.ts";
@@ -624,6 +625,137 @@ export function segmentScopeBody(
624
625
  );
625
626
  }
626
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
+
627
759
  function segmentStatementsInternal(
628
760
  original: string,
629
761
  range: MaskedRange | undefined,
@@ -1073,7 +1205,10 @@ function scanAscii(
1073
1205
  if (
1074
1206
  c === "{" &&
1075
1207
  atContainerLevel() &&
1076
- 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))
1077
1212
  ) {
1078
1213
  if (frames.length - 1 < MAX_CONTAINER_DEPTH) {
1079
1214
  openContainer(i);
@@ -419,7 +419,10 @@ interface BracketFrame {
419
419
  pendingSetTarget: boolean;
420
420
  pendingErrVar: boolean;
421
421
  pendingDecl: SymbolClass | null;
422
+ pendingDeclNameValue: boolean;
422
423
  pendingLoopVars: boolean;
424
+ loopCounterOpen: boolean;
425
+ pendingLoopCounterValue: boolean;
423
426
  declaredHere: boolean;
424
427
  headSigil: ":" | "/" | "";
425
428
  cont: Continuation;
@@ -511,6 +514,20 @@ export function resolveSymbols(original: string): SymbolAnalysis {
511
514
  char: "{" | "[" | "(" | '"';
512
515
  at: number;
513
516
  statements: boolean;
517
+ /**
518
+ * #533 reorder — a `{…}` resets the statement it interrupts, but a
519
+ * `:for`/`:foreach` head stays open for `counter=` past it (notably a
520
+ * `:foreach … in={…}` array), so the flag rides the frame and the
521
+ * matching `}` restores it. Only `}` restores: `[` has the
522
+ * `BracketFrame`, quotes and `(` never reset.
523
+ */
524
+ loopCounterOpen: boolean;
525
+ /**
526
+ * #533 reorder — likewise a `:local`/`:global` head stays open for a
527
+ * later `name=` past an array VALUE (`:local value={1;2} name=foo`).
528
+ * Saved only on a `{` that is an argument value; `}` restores it.
529
+ */
530
+ pendingDecl?: SymbolClass | null;
514
531
  }[] = [];
515
532
  /** bracket/brace nesting, strings excluded (the S8 filter region uses it). */
516
533
  let depth = 0;
@@ -525,7 +542,23 @@ export function resolveSymbols(original: string): SymbolAnalysis {
525
542
  /** depth at which a filter word opened its region, or null. */
526
543
  let filterDepth: number | null = null;
527
544
  let pendingDecl: SymbolClass | null = null;
545
+ /**
546
+ * #533 — the pending `:local`/`:global` declaration waits for the VALUE of
547
+ * a `name=` argument (`:local name=foo` declares `foo`, not `name`). Set
548
+ * when a `name=` arg name is read, cleared after its value declares or is
549
+ * skipped. Values of other args (`value=`) never declare.
550
+ */
551
+ let pendingDeclNameValue = false;
528
552
  let pendingLoopVars = false;
553
+ /**
554
+ * #533 reorder — a `:for`/`:foreach` head is still open for a named
555
+ * `counter=`: unlike `pendingLoopVars` (positional vars, ended by
556
+ * `in=`/`from=`/any `arg=`), this survives the whole head — CHR accepts
557
+ * `:for from=1 counter=i to=2` — and only `do` (the body) closes it.
558
+ */
559
+ let loopCounterOpen = false;
560
+ /** The next value word is a `counter=` loop var. */
561
+ let pendingLoopCounterValue = false;
529
562
  /** inside a space-separated menu path run, where bare words are `dir` (S16). */
530
563
  let inMenuPath = false;
531
564
  /**
@@ -593,7 +626,10 @@ export function resolveSymbols(original: string): SymbolAnalysis {
593
626
  pendingSetTarget = false;
594
627
  pendingErrVar = false;
595
628
  pendingDecl = null;
629
+ pendingDeclNameValue = false;
596
630
  pendingLoopVars = false;
631
+ loopCounterOpen = false;
632
+ pendingLoopCounterValue = false;
597
633
  declaredHere = false;
598
634
  headSigil = "";
599
635
  cont = "none";
@@ -666,7 +702,10 @@ export function resolveSymbols(original: string): SymbolAnalysis {
666
702
  pendingSetTarget,
667
703
  pendingErrVar,
668
704
  pendingDecl,
705
+ pendingDeclNameValue,
669
706
  pendingLoopVars,
707
+ loopCounterOpen,
708
+ pendingLoopCounterValue,
670
709
  declaredHere,
671
710
  headSigil,
672
711
  cont,
@@ -719,7 +758,10 @@ export function resolveSymbols(original: string): SymbolAnalysis {
719
758
  pendingSetTarget = frame.pendingSetTarget;
720
759
  pendingErrVar = frame.pendingErrVar;
721
760
  pendingDecl = frame.pendingDecl;
761
+ pendingDeclNameValue = frame.pendingDeclNameValue;
722
762
  pendingLoopVars = frame.pendingLoopVars;
763
+ loopCounterOpen = frame.loopCounterOpen;
764
+ pendingLoopCounterValue = frame.pendingLoopCounterValue;
723
765
  declaredHere = frame.declaredHere;
724
766
  headSigil = frame.headSigil;
725
767
  cont = frame.cont;
@@ -807,7 +849,12 @@ export function resolveSymbols(original: string): SymbolAnalysis {
807
849
  // string. A scan that swallowed this as string bytes would flip
808
850
  // its quote phase on the first nested string and lose every
809
851
  // binding after it.
810
- frames.push({ char: next, at: i + 1, statements: next === "[" });
852
+ frames.push({
853
+ char: next,
854
+ at: i + 1,
855
+ statements: next === "[",
856
+ loopCounterOpen,
857
+ });
811
858
  depth++;
812
859
  if (next === "[") {
813
860
  // F6 — and it is a statement context like any other `[`. A `$[`
@@ -990,7 +1037,28 @@ export function resolveSymbols(original: string): SymbolAnalysis {
990
1037
  // `set-dns`, and the console carries the class across the quotes, so the
991
1038
  // span includes them. Without this the string is skipped and the next
992
1039
  // bare word (`do`) is declared instead.
993
- if (pendingDecl !== null || pendingLoopVars || pendingErrVar) {
1040
+ if (
1041
+ pendingDecl !== null ||
1042
+ pendingLoopVars ||
1043
+ pendingErrVar ||
1044
+ pendingLoopCounterValue
1045
+ ) {
1046
+ // #533 — a quoted VALUE of a named arg declares only for `name=`.
1047
+ // `:local name="foo"` declares `foo`; `:local value="foo"` declares
1048
+ // nothing and stays a string (so `$` inside still classifies, S9).
1049
+ // Positional quotes (`:local "foo"`) declare.
1050
+ if (pendingDecl !== null) {
1051
+ const prev = prevNonSpace(text, i);
1052
+ if ((prev === "=" || prev === ",") && !pendingDeclNameValue) {
1053
+ frames.push({
1054
+ char: '"',
1055
+ at: i,
1056
+ statements: false,
1057
+ loopCounterOpen,
1058
+ });
1059
+ continue;
1060
+ }
1061
+ }
994
1062
  const close = text.indexOf('"', i + 1);
995
1063
  if (close > i) {
996
1064
  const name = text.slice(i + 1, close);
@@ -1014,6 +1082,7 @@ export function resolveSymbols(original: string): SymbolAnalysis {
1014
1082
  const binding = bind(name, cls, i + 1);
1015
1083
  if (binding !== null) bindingIds.push(binding.id);
1016
1084
  pendingDecl = null;
1085
+ pendingDeclNameValue = false;
1017
1086
  declaredHere = true;
1018
1087
  } else {
1019
1088
  // F7 — statement scope, and for `:onerror` the enclosing claim
@@ -1039,7 +1108,12 @@ export function resolveSymbols(original: string): SymbolAnalysis {
1039
1108
  continue;
1040
1109
  }
1041
1110
  }
1042
- frames.push({ char: '"', at: i, statements: false });
1111
+ frames.push({
1112
+ char: '"',
1113
+ at: i,
1114
+ statements: false,
1115
+ loopCounterOpen,
1116
+ });
1043
1117
  continue;
1044
1118
  }
1045
1119
 
@@ -1052,6 +1126,9 @@ export function resolveSymbols(original: string): SymbolAnalysis {
1052
1126
  (c === "{" &&
1053
1127
  (frames.at(-1)?.statements ?? true) &&
1054
1128
  braceStartsStatements(text, i)),
1129
+ loopCounterOpen,
1130
+ pendingDecl:
1131
+ c === "{" && prevNonSpace(text, i) === "=" ? pendingDecl : null,
1055
1132
  });
1056
1133
  depth++;
1057
1134
  if (c === "[") openBracket(leadBefore, i);
@@ -1087,7 +1164,7 @@ export function resolveSymbols(original: string): SymbolAnalysis {
1087
1164
  defects.push(defectAt("unbalanced-close", i, c));
1088
1165
  continue;
1089
1166
  }
1090
- frames.pop();
1167
+ const popped = frames.pop();
1091
1168
  if (filterDepth !== null && depth <= filterDepth) filterDepth = null;
1092
1169
  // F6 — restore the statement the `[` interrupted, and drop the bracket's
1093
1170
  // scope. Ordered after the `filterDepth` clear on purpose: the saved
@@ -1098,6 +1175,11 @@ export function resolveSymbols(original: string): SymbolAnalysis {
1098
1175
  if (suppressedScopes > 0) suppressedScopes--;
1099
1176
  else if (scopes.length > 1) scopes.pop();
1100
1177
  resetStatement();
1178
+ // #533 reorder — the head outlives its own array braces.
1179
+ if (popped !== undefined) {
1180
+ loopCounterOpen = popped.loopCounterOpen;
1181
+ pendingDecl = popped.pendingDecl ?? null;
1182
+ }
1101
1183
  }
1102
1184
  if (depth > 0) depth--;
1103
1185
  continue;
@@ -1220,8 +1302,10 @@ export function resolveSymbols(original: string): SymbolAnalysis {
1220
1302
  pathOpen = true;
1221
1303
  }
1222
1304
  if (DECL[lower] !== undefined) pendingDecl = DECL[lower] as SymbolClass;
1223
- else if (LOOP_HEADS.has(lower)) pendingLoopVars = true;
1224
- else if (lower === "set") pendingSetTarget = true;
1305
+ else if (LOOP_HEADS.has(lower)) {
1306
+ pendingLoopVars = true;
1307
+ loopCounterOpen = true;
1308
+ } else if (lower === "set") pendingSetTarget = true;
1225
1309
  else if (ERRVAR_HEADS.has(lower)) pendingErrVar = true;
1226
1310
  // S8 — `find`/`where` open a filter region from HEAD position too.
1227
1311
  // `[find comment=$tag]` is the commonest shape of the whole corpus and
@@ -1315,6 +1399,17 @@ export function resolveSymbols(original: string): SymbolAnalysis {
1315
1399
 
1316
1400
  // S1/S2 — this word is the declared name.
1317
1401
  if (pendingDecl !== null) {
1402
+ // #533 — named spelling (`:local name=foo value=1`): an argument
1403
+ // NAME (`word=`) is never the declaration. Only `name=` carries
1404
+ // it; its value declares. Other values (`value=foo`) are skipped,
1405
+ // and the pending declaration survives for a later `name=`
1406
+ // (`value=1 name=foo`). A positional word (`:local foo`, `:local
1407
+ // name`) declares as before.
1408
+ if (nextNonSpace(text, j) === "=") {
1409
+ pendingDeclNameValue = lower === "name";
1410
+ continue;
1411
+ }
1412
+ if (isValue && !pendingDeclNameValue) continue;
1318
1413
  const binding = bind(word, pendingDecl, wordStart);
1319
1414
  record(
1320
1415
  wordStart,
@@ -1327,6 +1422,7 @@ export function resolveSymbols(original: string): SymbolAnalysis {
1327
1422
  binding === null ? [] : [binding.id],
1328
1423
  );
1329
1424
  pendingDecl = null;
1425
+ pendingDeclNameValue = false;
1330
1426
  declaredHere = true;
1331
1427
  continue;
1332
1428
  }
@@ -1334,6 +1430,13 @@ export function resolveSymbols(original: string): SymbolAnalysis {
1334
1430
  // S3 — `:foreach k,v in=…`: every word before `in=`/`from=` is a loop
1335
1431
  // variable.
1336
1432
  if (pendingLoopVars) {
1433
+ // #533 — named spelling (`:for counter=i …`): `counter=` carries
1434
+ // the loop var(s); the word `counter` itself is never one. Any
1435
+ // other `arg=` ends the var list, like `in=`/`from=` already did.
1436
+ if (nextNonSpace(text, j) === "=") {
1437
+ if (lower !== "counter") pendingLoopVars = false;
1438
+ continue;
1439
+ }
1337
1440
  if (lower === "in" || lower === "from") {
1338
1441
  pendingLoopVars = false;
1339
1442
  continue;
@@ -1355,6 +1458,43 @@ export function resolveSymbols(original: string): SymbolAnalysis {
1355
1458
  continue;
1356
1459
  }
1357
1460
 
1461
+ // #533 reorder — a named `counter=` past `in=`/`from=` or other
1462
+ // args still carries the loop var: CHR accepts
1463
+ // `:for from=1 counter=i to=2`, and the value binds exactly like
1464
+ // a positional. Only `do` (the body) closes the head; a value of
1465
+ // another arg, or a further arg name, just ends this `counter=`.
1466
+ if (!isValue && lower === "do") {
1467
+ loopCounterOpen = false;
1468
+ pendingLoopCounterValue = false;
1469
+ }
1470
+ if (
1471
+ loopCounterOpen &&
1472
+ lower === "counter" &&
1473
+ nextNonSpace(text, j) === "="
1474
+ ) {
1475
+ pendingLoopCounterValue = true;
1476
+ continue;
1477
+ }
1478
+ if (pendingLoopCounterValue) {
1479
+ if (nextNonSpace(text, j) === "=" || !isValue) {
1480
+ pendingLoopCounterValue = false;
1481
+ } else {
1482
+ openStatementScope(wordStart);
1483
+ const counterBinding = bind(word, "auto", wordStart);
1484
+ record(
1485
+ wordStart,
1486
+ j,
1487
+ word,
1488
+ "auto",
1489
+ true,
1490
+ undefined,
1491
+ "binding",
1492
+ counterBinding === null ? [] : [counterBinding.id],
1493
+ );
1494
+ continue;
1495
+ }
1496
+ }
1497
+
1358
1498
  if (FILTER_WORDS.has(lower)) {
1359
1499
  filterDepth = depth;
1360
1500
  continue;
@@ -17,8 +17,9 @@
17
17
  * them; only `:typeof` can. Grounded on CHR 7.21.5, 7.23.3 and 7.24rc4 in
18
18
  * `test/fixtures/explain/operators.json` → `sweep.runtime` rows
19
19
  * `typeof-deferred` / `deferred-array-typeof` and `sweep.opAxis` `defer-*`.
20
- * Offline abstains on every such form; a live `observedType` reports the
21
- * runtime type.
20
+ * Offline abstains on every such form, and so does the live `observedType`
21
+ * (#225): it reads only visible `:parse` conversions and never sends
22
+ * `:typeof`.
22
23
  *
23
24
  * ## The two contexts are different lexicons, not one lexicon plus a fallback
24
25
  *
@@ -51,8 +52,8 @@ import { isIP } from "node:net";
51
52
  * tests spellings, so reordering the members here must not change any result.
52
53
  * `array` is emitted by the structured anchor reader rather than
53
54
  * {@link valueShapeHints}, which receives decoded scalar values only.
54
- * `op` is not a member: a deferred `(> …)` form abstains offline and is
55
- * reported only as a live `observedType` (#288).
55
+ * `op` is not a member: a deferred `(> …)` form abstains offline and live
56
+ * (#288, #225).
56
57
  */
57
58
  export const VALUE_SHAPES = [
58
59
  "num",
@@ -70,6 +71,56 @@ export const VALUE_SHAPES = [
70
71
 
71
72
  export type ValueShape = (typeof VALUE_SHAPES)[number];
72
73
 
74
+ /**
75
+ * The types a visible `:parse` conversion can name (#225 `observedType`).
76
+ *
77
+ * A subset of {@link VALUE_SHAPES}: these are exactly the scalar types whose
78
+ * device rendering differs from some spelling of them (`2.2` → `2.0.0.2`,
79
+ * `0x10` → `16`, `yes` → `true`, `200ms` → `00:00:00.200`, `1:0:0::1` →
80
+ * `1::1`). `str`, `id` and `mac` never convert, so they are never observed.
81
+ */
82
+ export const OBSERVED_VALUE_TYPES = [
83
+ "num",
84
+ "bool",
85
+ "ip",
86
+ "ip-prefix",
87
+ "ip6",
88
+ "ip6-prefix",
89
+ "time",
90
+ ] as const satisfies readonly ValueShape[];
91
+
92
+ export type ObservedValueType = (typeof OBSERVED_VALUE_TYPES)[number];
93
+
94
+ /**
95
+ * The normalized terms a schema declaration maps to (#225 `schemaType`).
96
+ *
97
+ * The scalar names are shared with {@link VALUE_SHAPES} so a schema fact and a
98
+ * shape hint compare directly. The schema-only terms name declarations no
99
+ * literal spelling has: `ip-range` (`A.B.C.D[-A.B.C.D|…] (IP address range)`),
100
+ * `enum` (a listed word set such as `input | forward | output`, never closed:
101
+ * the device also lists current configuration names this way) and `name` (a
102
+ * reference to a configuration object). `bool` and `name` are CLI Reference
103
+ * spellings (`bool`, `iface_enum`) the live projection never emits:
104
+ * `/console/inspect` declares a boolean with a blank row, and prints the
105
+ * current interface names exactly like keywords.
106
+ */
107
+ export const SCHEMA_VALUE_TYPES = [
108
+ "num",
109
+ "bool",
110
+ "ip",
111
+ "ip-prefix",
112
+ "ip-range",
113
+ "ip6",
114
+ "ip6-prefix",
115
+ "time",
116
+ "mac",
117
+ "str",
118
+ "enum",
119
+ "name",
120
+ ] as const;
121
+
122
+ export type SchemaValueType = (typeof SCHEMA_VALUE_TYPES)[number];
123
+
73
124
  /**
74
125
  * Where the value sits, because RouterOS reads the same bytes differently.
75
126
  *