@tikoci/centrs 0.1.9-next.23 → 0.1.9-next.27

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 (91) hide show
  1. package/CHANGELOG.md +163 -0
  2. package/GLOSSARY.txt +23 -1
  3. package/README.md +30 -18
  4. package/commands/api/AGENTS.md +21 -3
  5. package/commands/api/README.md +18 -7
  6. package/commands/api/examples.md +1 -1
  7. package/commands/btest/README.md +9 -0
  8. package/commands/btest/examples.md +7 -0
  9. package/commands/devices/README.md +72 -3
  10. package/commands/devices/examples.md +56 -1
  11. package/commands/execute/README.md +37 -6
  12. package/commands/execute/examples.md +24 -5
  13. package/commands/explain/README.md +675 -64
  14. package/commands/explain/examples.md +199 -42
  15. package/commands/retrieve/README.md +35 -3
  16. package/commands/retrieve/examples.md +114 -1
  17. package/commands/transfer/README.md +33 -4
  18. package/commands/transfer/examples.md +46 -0
  19. package/docs/CLI.md +30 -8
  20. package/docs/CONSTITUTION.md +14 -9
  21. package/docs/MATRIX.md +37 -22
  22. package/docs/errors/cdb/encrypt-failed.md +10 -0
  23. package/docs/errors/cdb/password-required.md +5 -4
  24. package/docs/errors/cdb/plaintext-backups.md +11 -0
  25. package/docs/errors/cmr/caller-policy-not-enforced.md +5 -3
  26. package/docs/errors/input/invalid-routeros-path.md +25 -3
  27. package/docs/errors/routeros/permission-denied.md +24 -0
  28. package/docs/errors/transport/edge-auth-failed.md +22 -0
  29. package/docs/errors/transport/write-outcome-unknown.md +3 -0
  30. package/docs/errors/usage/invalid-cursor.md +20 -0
  31. package/docs/errors/validation/unknown-path.md +9 -0
  32. package/package.json +9 -5
  33. package/src/api-fanout.ts +2 -0
  34. package/src/api.ts +46 -39
  35. package/src/btest.ts +7 -0
  36. package/src/cli/api.ts +18 -1
  37. package/src/cli/btest.ts +9 -0
  38. package/src/cli/devices.ts +76 -8
  39. package/src/cli/execute.ts +9 -0
  40. package/src/cli/explain.ts +206 -47
  41. package/src/cli/retrieve.ts +9 -0
  42. package/src/cli/terminal.ts +9 -0
  43. package/src/cli/transfer.ts +10 -1
  44. package/src/core/envelope.ts +10 -3
  45. package/src/core/error-catalog.ts +28 -2
  46. package/src/core/execute-command.ts +53 -0
  47. package/src/core/fanout.ts +12 -2
  48. package/src/core/inspect.ts +4 -5
  49. package/src/core/menu-path.ts +89 -0
  50. package/src/core/routeros-errors.ts +25 -0
  51. package/src/devices.ts +154 -9
  52. package/src/execute-cmr.ts +2 -8
  53. package/src/execute-fanout.ts +2 -0
  54. package/src/execute.ts +55 -20
  55. package/src/explain/args.ts +115 -10
  56. package/src/explain/blocks.ts +2 -2
  57. package/src/explain/catalog.ts +22 -12
  58. package/src/explain/group-faults.ts +162 -29
  59. package/src/explain/operators.ts +2 -1
  60. package/src/explain/pasted-prompt.ts +198 -0
  61. package/src/explain/pathresolve.ts +2 -2
  62. package/src/explain/scope-brace.ts +226 -11
  63. package/src/explain/segment.ts +138 -3
  64. package/src/explain/symbols.ts +148 -8
  65. package/src/explain/values.ts +55 -4
  66. package/src/explain/verbsplit.ts +2 -2
  67. package/src/explain/write.ts +2 -2
  68. package/src/explain-live-facets.ts +739 -0
  69. package/src/explain-live-types.ts +490 -0
  70. package/src/explain-live.ts +593 -0
  71. package/src/explain.ts +397 -43
  72. package/src/index.ts +10 -0
  73. package/src/protocols/adapter.ts +90 -10
  74. package/src/protocols/btest-session.ts +10 -30
  75. package/src/protocols/ec-srp5.ts +19 -10
  76. package/src/protocols/mac-telnet-console.ts +2 -2
  77. package/src/protocols/mac-telnet.ts +38 -10
  78. package/src/protocols/native-api.ts +13 -12
  79. package/src/resolver/cdb.ts +58 -2
  80. package/src/resolver/index.ts +1 -0
  81. package/src/resolver/quickchr-provider.ts +5 -0
  82. package/src/resolver/selection.ts +11 -0
  83. package/src/retrieve-fanout.ts +2 -0
  84. package/src/retrieve-follow.ts +2 -1
  85. package/src/retrieve-sample.ts +2 -1
  86. package/src/retrieve-wait.ts +10 -6
  87. package/src/retrieve.ts +88 -27
  88. package/src/settings.ts +1 -0
  89. package/src/terminal.ts +7 -0
  90. package/src/transfer-fanout.ts +2 -0
  91. package/src/transfer.ts +117 -27
@@ -78,7 +78,12 @@
78
78
 
79
79
  import { isScopeBrace, isScopeBraceIn } from "./blocks.ts";
80
80
  import { braceSlotAcceptsBrace, braceSlotTakesArray } from "./brace-slots.ts";
81
- import { braceStartsStatements } from "./scope-brace.ts";
81
+ import {
82
+ braceBodyIsScriptLike,
83
+ braceStartsStatements,
84
+ isScriptBodyName,
85
+ scopeNameFromMasked,
86
+ } from "./scope-brace.ts";
82
87
  import {
83
88
  type MaskedRange,
84
89
  maskComments,
@@ -353,6 +358,34 @@ function braceValueOwner(
353
358
  return ARGUMENT_NAME.test(name) ? name.toLowerCase() : undefined;
354
359
  }
355
360
 
361
+ /**
362
+ * The `name` of a `name= {…}` brace: the token before the gap ends in a glued
363
+ * `name=` run (#554).
364
+ *
365
+ * {@link braceValueOwner} only sees the `=` glued to the brace (`down={`). A
366
+ * space after the `=` starts a new token, so the brace reads as positional
367
+ * there — but on CHR 7.25 `down= { :beep }` lowers to `down-script=` exactly
368
+ * like the glued spelling, while `comment= {1;2}` is a `syntax error` at the
369
+ * `{`. Only spaces and tabs span the gap: a newline there is a statement
370
+ * boundary, not a gap, and the name run must start its token, so `=down= {`
371
+ * and `down = {` own nothing.
372
+ */
373
+ function spacedBraceOwner(text: string, at: number): string | undefined {
374
+ let j = at - 1;
375
+ let gap = 0;
376
+ while (text[j] === " " || text[j] === "\t") {
377
+ j--;
378
+ gap++;
379
+ }
380
+ if (gap === 0 || text[j] !== "=") return undefined;
381
+ const nameEnd = j;
382
+ j--;
383
+ while (j >= 0 && /[A-Za-z0-9.-]/.test(text[j] as string)) j--;
384
+ if (j >= 0 && !/[\s([]/.test(text[j] as string)) return undefined;
385
+ const name = text.slice(j + 1, nameEnd);
386
+ return ARGUMENT_NAME.test(name) ? name.toLowerCase() : undefined;
387
+ }
388
+
356
389
  /**
357
390
  * Whether a `{` at `at` may open an array literal in this statement.
358
391
  *
@@ -1715,6 +1748,22 @@ export function comparesNamedValue(
1715
1748
  return /^[A-Za-z.]/.test(name) && /[\s(]/.test(text[j] as string);
1716
1749
  }
1717
1750
 
1751
+ /** One top-level `{` in command arguments that is not a proven body. */
1752
+ export interface CommandBraceFinding {
1753
+ /** Offset of the `{` in the text passed to the lexer. */
1754
+ at: number;
1755
+ /**
1756
+ * True when `name={` with a script-like body on a non-root command outside
1757
+ * `where`, where `name` is not a proven script-body slot (#525). The brace
1758
+ * may abbreviate one (`down=` for `down-script=`), but telling it from
1759
+ * `comment=` needs the command's argument list, which is schema — so the
1760
+ * caller warns instead of rejecting, and the device decides.
1761
+ */
1762
+ ambiguous: boolean;
1763
+ /** The `name` of `name=`, lower-cased, when `ambiguous` is true. */
1764
+ name?: string;
1765
+ }
1766
+
1718
1767
  /**
1719
1768
  * Locate top-level `{...}` values that RouterOS rejects in command arguments.
1720
1769
  *
@@ -1724,15 +1773,21 @@ export function comparesNamedValue(
1724
1773
  * excluded: their brace is a script body, not an array. The caller applies
1725
1774
  * this only to non-root commands; root scripting directives use the
1726
1775
  * `(verb, slot)` table instead.
1776
+ *
1777
+ * A `name={` with a script-like body that is not a proven slot is returned
1778
+ * with `ambiguous: true` rather than as an error (#525), and so is the spaced
1779
+ * `name= {…}` spelling (`down= { :beep }` lowers to `down-script=` on CHR
1780
+ * 7.25). Array-content braces (`comment={1;2}`), positional braces, `where`
1781
+ * comparisons and root slots keep their rejection.
1727
1782
  */
1728
1783
  export function invalidCommandBraceOffsets(
1729
1784
  text: string,
1730
1785
  from: number,
1731
1786
  directiveVerb?: string,
1732
1787
  range?: MaskedRange,
1733
- ): number[] {
1788
+ ): CommandBraceFinding[] {
1734
1789
  const structural = maskOf(range) ?? maskComments(text);
1735
- const offsets: number[] = [];
1790
+ const offsets: CommandBraceFinding[] = [];
1736
1791
  const stack: string[] = [];
1737
1792
  let i = Math.max(0, from);
1738
1793
  let tokenStart: number | null = null;
@@ -1783,7 +1838,54 @@ export function invalidCommandBraceOffsets(
1783
1838
  tokenStart === null
1784
1839
  ? undefined
1785
1840
  : braceValueOwner(structural, tokenStart, i);
1786
- const slot = owner === null ? `#${positionals}` : owner;
1841
+ // A space after the `=` owns the brace the same way the glued `=`
1842
+ // does (#554), so the slot attributes the spaced owner before
1843
+ // falling back to positional: `:put x= { :beep }` reads slot `x`,
1844
+ // not `#0` (which `put` accepts, swallowing the error).
1845
+ const spacedOwner =
1846
+ typeof owner === "string" ? undefined : spacedBraceOwner(structural, i);
1847
+ const slot = owner === null ? (spacedOwner ?? `#${positionals}`) : owner;
1848
+ // A `where` named comparison reads the brace with the property's own
1849
+ // parser and rejects it (`expected <name> value`), so the #525 body
1850
+ // guess must not shield it below — `gateway={ :beep }` included.
1851
+ const inWhereComparison =
1852
+ directiveVerb === undefined &&
1853
+ whereAt !== null &&
1854
+ comparesNamedValue(structural, i, whereAt);
1855
+ // The ambiguity check comes first: `braceStartsStatements` reads an
1856
+ // unproven `name={` with a script-like body as a body too (#525), so
1857
+ // reaching that skip would swallow the warning that marks the guess.
1858
+ // Control scopes (`do={` on a monitor, `:retry … command={`) are not
1859
+ // guesses — the scope table already proves them — so they stay out.
1860
+ // A space after the `=` owns the brace the same way the glued `=`
1861
+ // does (`down= { :beep }` lowers to `down-script=` on CHR 7.25), so
1862
+ // the spaced owner warns under the same gates.
1863
+ const scopeName =
1864
+ directiveVerb === undefined && whereAt === null
1865
+ ? range === undefined
1866
+ ? scopeNameFromMasked(structural, i)
1867
+ : scopeNameFromMasked(range.masked, range.start + i, range.start)
1868
+ : null;
1869
+ const ambiguousOwner = typeof owner === "string" ? owner : spacedOwner;
1870
+ if (
1871
+ directiveVerb === undefined &&
1872
+ whereAt === null &&
1873
+ scopeName === null &&
1874
+ typeof ambiguousOwner === "string" &&
1875
+ !isScriptBodyName(ambiguousOwner) &&
1876
+ braceBodyIsScriptLike(structural, i, end ?? structural.length)
1877
+ ) {
1878
+ offsets.push({ at: i, ambiguous: true, name: ambiguousOwner });
1879
+ i = end ?? i + 1;
1880
+ continue;
1881
+ }
1882
+ // A root directive's slots and a `where` named comparison never take
1883
+ // a script body through an unproven name: `:put x={ :beep }` and
1884
+ // `where gateway={ :beep }` are device rejections, so the brace reads
1885
+ // as a VALUE for the body check and the error below stands (#554).
1886
+ // Proven slots still skip — the gate is only the unproven disjunct.
1887
+ const unprovenName =
1888
+ directiveVerb !== undefined || inWhereComparison ? "value" : "body";
1787
1889
  if (
1788
1890
  // Asked of the DOCUMENT when the caller anchored this statement, so the
1789
1891
  // statement index behind it is the document's one index rather than a
@@ -1791,19 +1893,22 @@ export function invalidCommandBraceOffsets(
1791
1893
  // subtree, and so was rebuilt once per enclosing level (#322). The floor
1792
1894
  // makes the two readings the same answer (`scope-brace.ts`).
1793
1895
  (range === undefined
1794
- ? braceStartsStatements(structural, i)
1795
- : braceStartsStatements(range.masked, range.start + i, range.start)) ||
1896
+ ? braceStartsStatements(structural, i, 0, unprovenName)
1897
+ : braceStartsStatements(
1898
+ range.masked,
1899
+ range.start + i,
1900
+ range.start,
1901
+ unprovenName,
1902
+ )) ||
1796
1903
  (directiveVerb !== undefined &&
1797
1904
  slot !== undefined &&
1798
1905
  braceSlotAcceptsBrace(directiveVerb, slot)) ||
1799
- (directiveVerb === undefined &&
1800
- whereAt !== null &&
1801
- !comparesNamedValue(structural, i, whereAt))
1906
+ (directiveVerb === undefined && whereAt !== null && !inWhereComparison)
1802
1907
  ) {
1803
1908
  i = end ?? structural.length;
1804
1909
  continue;
1805
1910
  }
1806
- offsets.push(i);
1911
+ offsets.push({ at: i, ambiguous: false });
1807
1912
  i = end ?? i + 1;
1808
1913
  }
1809
1914
  return offsets;
@@ -1,8 +1,8 @@
1
1
  /**
2
2
  * Block / scope-brace classification for `explain` (centrs canonicalizer).
3
3
  *
4
- * Ratified by the phase-0 lab, question Q2 (#185) and promoted from the
5
- * throwaway probe `.scratch/explain-lab-blocktree.ts`. Q2 asks: can an offline,
4
+ * Ratified by the phase-0 lab, question Q2 (#185); the lab probe it was
5
+ * promoted from is history (see #185), not a runnable method. Q2 asks: can an offline,
6
6
  * schema-free brace tracker tell a `{…}` SCOPE (`do=`/`else=`/`on-error=`, which
7
7
  * IL lowers to `name=;(evl …)`) from a `name={…}` LITERAL value (braces
8
8
  * stripped, body kept as RAW text, never lowered)? Descending into a literal
@@ -9,7 +9,7 @@
9
9
  * Union of two first-order sources (#228):
10
10
  *
11
11
  * 1. MikroTik's published CLI Reference (`https://manual.mikrotik.com/docs/cli-reference/`),
12
- * 1,099 pages, 1,106 entries — first-order about the definition
12
+ * 1,109 pages, 1,116 entries — first-order about the definition
13
13
  * structs and their build-time gates. Since #285 every page is a leaf whose
14
14
  * slug is the CLI path, so no spelling has to be rewritten to be looked up.
15
15
  * 2. 8 pinned restraml `/console/inspect` trees
@@ -28,9 +28,9 @@
28
28
  *
29
29
  * | Kind | Total | `both` | `inspect` | `published` |
30
30
  * | ---- | ----- | ------ | --------- | ----------- |
31
- * | `menu` | 568 | 457 | 67 | 44 |
32
- * | `command` | 477 | 436 | 0 | 41 |
33
- * | `settings` | 121 | 112 | 0 | 9 |
31
+ * | `menu` | 571 | 457 | 67 | 47 |
32
+ * | `command` | 483 | 436 | 0 | 47 |
33
+ * | `settings` | 122 | 112 | 0 | 10 |
34
34
  *
35
35
  * Zero navigation-vs-command contradictions between the two sources. Generation
36
36
  * aborts if that ever stops holding, rather than picking a winner. Where a path
@@ -49,9 +49,9 @@
49
49
  *
50
50
  * **Gates conjoin down a path, so read them with {@link effectiveGates}, not
51
51
  * row by row.** A row states only what the publication stated at that entry.
52
- * Read row-wise, 1 published-only path looks ungated; read with
52
+ * Read row-wise, 7 published-only paths look ungated; read with
53
53
  * ancestry, the residue carrying no published explanation for its absence at
54
- * all is 1.
54
+ * all is 7.
55
55
  *
56
56
  * Tree COMMANDS are not enumerated — those are the generic CRUD leaves
57
57
  * `verbs.ts` already owns. The command rows here are the published,
@@ -91,7 +91,7 @@ export interface CatalogEntry {
91
91
  * `path|kind|provenance|package|conditions|syscap` — with trailing empty
92
92
  * columns trimmed. Lower-cased, slash-led and sorted by path.
93
93
  *
94
- * Text rather than ~1,166 object literals so that one path is one line in a
94
+ * Text rather than ~1,176 object literals so that one path is one line in a
95
95
  * review diff, and so the formatter has nothing to re-wrap.
96
96
  */
97
97
  const ROWS = `
@@ -101,6 +101,7 @@ const ROWS = `
101
101
  /app/remove|command|both|container||app
102
102
  /app/restart|command|both|container||app
103
103
  /app/settings|settings|both|container||app
104
+ /app/settings/remove-certificate|command|published|container||app
104
105
  /app/setup|command|both|container||app
105
106
  /app/update|command|both|container||app
106
107
  /beep|command|both
@@ -160,9 +161,9 @@ const ROWS = `
160
161
  /cmr/alert|menu|both|cmr
161
162
  /cmr/alert/show-devices|command|both|cmr
162
163
  /cmr/alert/test|command|both|cmr
163
- /cmr/client|settings|both||!smips, !powerpc
164
- /cmr/client/forget|command|both||!smips, !powerpc
165
- /cmr/client/pair|command|both||!smips, !powerpc
164
+ /cmr/client|settings|both||!smips
165
+ /cmr/client/forget|command|both||!smips
166
+ /cmr/client/pair|command|both||!smips
166
167
  /cmr/client/push-button|command|both
167
168
  /cmr/device|menu|both|cmr
168
169
  /cmr/device/apptraffic|command|both|cmr
@@ -485,6 +486,12 @@ const ROWS = `
485
486
  /interface/wifi/dashboard/show-logs|command|both
486
487
  /interface/wifi/datapath|menu|both
487
488
  /interface/wifi/devel|command|both
489
+ /interface/wifi/easymesh|settings|published
490
+ /interface/wifi/easymesh/bss|menu|published
491
+ /interface/wifi/easymesh/dpp-trigger|command|published
492
+ /interface/wifi/easymesh/sta|menu|published
493
+ /interface/wifi/easymesh/topology|menu|published
494
+ /interface/wifi/easymesh/wps-push-button|command|published
488
495
  /interface/wifi/flat-snoop|command|both
489
496
  /interface/wifi/frequency-scan|command|both
490
497
  /interface/wifi/interworking|menu|both
@@ -1095,6 +1102,9 @@ const ROWS = `
1095
1102
  /system/resource/cpu|menu|both
1096
1103
  /system/resource/hardware|menu|both||!powerpc, !smips
1097
1104
  /system/resource/hardware/authorize|command|both||!powerpc, !smips
1105
+ /system/resource/hardware/power-cycle|command|published||!powerpc, !smips|usb-hub
1106
+ /system/resource/hardware/power-reset|command|published||!powerpc, !smips, !i386
1107
+ /system/resource/hardware/reset-overcurrent|command|published||!powerpc, !smips|usb-hub
1098
1108
  /system/resource/hardware/usb-power-reset|command|both||!powerpc, !smips, i386
1099
1109
  /system/resource/hardware/usb-settings|settings|both||!powerpc, !smips
1100
1110
  /system/resource/irq|menu|both
@@ -1373,8 +1383,8 @@ export interface CatalogGate {
1373
1383
  * parent's `syscap` applies even though the child entry states none: the gates
1374
1384
  * up a path CONJOIN, they do not override.
1375
1385
  *
1376
- * Read row-wise, 1 published-only path looks ungated; read with
1377
- * ancestry the residue is 1. The gap between the two is the
1386
+ * Read row-wise, 7 published-only paths look ungated; read with
1387
+ * ancestry the residue is 7. The gap between the two is the
1378
1388
  * conjunction, and it is what a caller has to reproduce: a child's silence about
1379
1389
  * a gate is not the absence of one. #228's finding — that a published-only path
1380
1390
  * almost always explains its own absence — is about the ancestry-aware number.
@@ -29,8 +29,8 @@
29
29
  * after `<name>=` with the property's own parser: `where (comment=+)`,
30
30
  * `(comment=a-)`, `(comment=@)` and `(.id=*1)` parse, though each is an
31
31
  * expression error elsewhere. So a group in a query gets only the structural
32
- * checks (empty, `;`, newline) and #509's brace rule, never the operand or
33
- * `@` rules.
32
+ * checks (empty, `;`, newline), #509's brace rule and the unquoted-byte
33
+ * rule below, never the operand or `@` rules.
34
34
  * - **Arrays (#511).** An array that is the whole value of a statement or an
35
35
  * argument must have an element: `:local z {}`, `:put {}`, `:return {}`,
36
36
  * `[:len {}]` and `in={}` are errors, while `{1;{}}`, `{a={}}`, `({})`,
@@ -53,6 +53,21 @@
53
53
  * declared name that is not letters and digits. `gluedAt`, `wordFaults` and
54
54
  * `nameFault` carry the device rules. One token fault is reported per
55
55
  * statement, and the head word is never read.
56
+ * - **Unquoted bytes (#358, #371).** A `?`, a backtick and a non-ASCII byte
57
+ * are never part of a bare word: the device rejects each at its byte in a
58
+ * head word, a name, a `$name`, a value, a query, an array and an
59
+ * expression (`/systemé`, `print?`, `$café`, `url=http://x/a?b`,
60
+ * `where comment=café`, `{a`b}`, `(café)`), but a menu command's argument
61
+ * name at the name's first byte (`addré=1`, `a?b=1`). A second `=` in a
62
+ * menu command's argument is rejected too (`name=a=b`, a bare base64 key
63
+ * ending in `=`). In a query and in an array it is an operator and a key
64
+ * (`where comment=a=b`, `{a=b}` parse), and a query group takes the byte
65
+ * rule but no other (`where (comment=a?b)` and `in {a?b}` are rejected at
66
+ * the `?`, `where (comment=a=b)` parses); a directive's `a=b` is a named
67
+ * argument on 7.24.5 and a syntax error on 7.23.8, so it is left alone. No
68
+ * property parser was found that takes these bytes (`regexp=a?b`,
69
+ * `public-key=…=`, `secret=…==`, `on-event=a=b` are all rejected). Strings
70
+ * and comments carry any byte (`"café"`, `$"café"`, `:local "café"`).
56
71
  *
57
72
  * Deliberately not here: a `$( … )` group (`:put $({})` is rejected and is
58
73
  * read as `({})`), the `@` in a later head word (`/ip address@ print`), and
@@ -72,7 +87,9 @@ export type GroupFaultCode =
72
87
  | "named-value-brace"
73
88
  | "glued-token"
74
89
  | "invalid-variable"
75
- | "invalid-name";
90
+ | "invalid-name"
91
+ | "unquoted-non-ascii"
92
+ | "unquoted-reserved-byte";
76
93
 
77
94
  export interface GroupFault {
78
95
  code: GroupFaultCode;
@@ -84,6 +101,12 @@ export interface GroupFault {
84
101
  detail?: string;
85
102
  /** The group the fault sits in, for a message that must say which. */
86
103
  group?: "(" | "{";
104
+ /**
105
+ * Where an unquoted-byte fault sits when it is not in a value: a menu
106
+ * command's argument name, or a statement's head word (path, command,
107
+ * `$name`). The remedy differs, since neither has a value to quote.
108
+ */
109
+ site?: "argument-name" | "head-word";
87
110
  }
88
111
 
89
112
  interface StatementFrame {
@@ -95,6 +118,8 @@ interface StatementFrame {
95
118
  whereEnd: number | null;
96
119
  /** The head word without its colon, the key into `brace-slots.ts`. */
97
120
  verb: string | null;
121
+ /** The head word is a `$` call, whose arguments no menu reads (`$f a=1`). */
122
+ call: boolean;
98
123
  /** Positional operands read after the head. */
99
124
  positionals: number;
100
125
  /** No token of the current statement read yet. */
@@ -116,6 +141,8 @@ interface StatementToken {
116
141
  closedAt: number;
117
142
  /** Just past the token's last bare-word run, or -1. */
118
143
  wordEnd: number;
144
+ /** The token's first bare `=`, or -1. */
145
+ eqAt: number;
119
146
  }
120
147
 
121
148
  interface ArrayFrame {
@@ -201,6 +228,31 @@ const STRICT_NAME_DIRECTIVES = new Set(["local", "global", "foreach"]);
201
228
  const ALNUM = /[A-Za-z0-9]/;
202
229
  const SUB = "\x1a";
203
230
 
231
+ /** A byte no bare word holds: `?`, a backtick, a non-ASCII stand-in (#358, #371). */
232
+ function isUnquotedFaultByte(c: string | undefined): boolean {
233
+ return c === "?" || c === "`" || c === SUB;
234
+ }
235
+
236
+ /** The first such byte in `[from, end)`, or -1. */
237
+ function unquotedFaultByteAt(masked: string, from: number, end: number) {
238
+ for (let j = from; j < end; j++) if (isUnquotedFaultByte(masked[j])) return j;
239
+ return -1;
240
+ }
241
+
242
+ /** The fault for the byte at `at`; a non-ASCII run is reported whole. */
243
+ function unquotedByteFault(masked: string, at: number): GroupFault {
244
+ if (masked[at] !== SUB)
245
+ return {
246
+ code: "unquoted-reserved-byte",
247
+ start: at,
248
+ end: at + 1,
249
+ detail: masked[at],
250
+ };
251
+ let end = at;
252
+ while (masked[end] === SUB) end++;
253
+ return { code: "unquoted-non-ascii", start: at, end };
254
+ }
255
+
204
256
  function statementFrame(char: StatementFrame["char"]): StatementFrame {
205
257
  return {
206
258
  kind: "statement",
@@ -208,6 +260,7 @@ function statementFrame(char: StatementFrame["char"]): StatementFrame {
208
260
  query: false,
209
261
  whereEnd: null,
210
262
  verb: null,
263
+ call: false,
211
264
  positionals: 0,
212
265
  lead: true,
213
266
  token: null,
@@ -245,6 +298,7 @@ function openToken(frame: StatementFrame, at: number) {
245
298
  equals: false,
246
299
  closedAt: -1,
247
300
  wordEnd: -1,
301
+ eqAt: -1,
248
302
  };
249
303
  frame.lead = false;
250
304
  return frame.token;
@@ -287,12 +341,10 @@ function gluedAt(
287
341
  (frame.verb !== null || ALNUM.test(before ?? "")));
288
342
  if (!glued) return null;
289
343
  const c = masked[at];
290
- // A stray `\` is `symbols.ts`'s, and a non-ASCII stand-in is #371's.
291
- if (c === "\\" || c === SUB) {
292
- frame.settled = true;
293
- return null;
294
- }
295
344
  frame.settled = true;
345
+ // A stray `\` is `symbols.ts`'s; a non-ASCII byte is its own fault (`"x"é`).
346
+ if (c === "\\") return null;
347
+ if (c === SUB) return unquotedByteFault(masked, at);
296
348
  return { code: "glued-token", start: at, end: at + 1 };
297
349
  }
298
350
 
@@ -343,7 +395,21 @@ function wordFaults(
343
395
  if (named !== undefined) return named;
344
396
  }
345
397
  while (j < end) {
346
- if (masked[j] !== "$") {
398
+ const c = masked[j];
399
+ if (isUnquotedFaultByte(c)) {
400
+ frame.settled = true;
401
+ return (
402
+ argumentNameFault(masked, frame, j, end) ?? unquotedByteFault(masked, j)
403
+ );
404
+ }
405
+ if (c === "=") {
406
+ if (token.eqAt < 0) token.eqAt = j;
407
+ else if (frame.verb === null && !frame.call) {
408
+ frame.settled = true;
409
+ return unquotedByteFault(masked, j);
410
+ }
411
+ }
412
+ if (c !== "$") {
347
413
  j++;
348
414
  continue;
349
415
  }
@@ -367,14 +433,12 @@ function wordFaults(
367
433
  token.wordEnd = -1;
368
434
  return null;
369
435
  }
436
+ if (next === SUB) {
437
+ frame.settled = true;
438
+ return unquotedByteFault(masked, j + 1);
439
+ }
370
440
  // `$$v` is rejected after the name, not at the second `$`.
371
- if (
372
- next === "(" ||
373
- next === "[" ||
374
- next === "{" ||
375
- next === "$" ||
376
- next === SUB
377
- ) {
441
+ if (next === "(" || next === "[" || next === "{" || next === "$") {
378
442
  frame.settled = true;
379
443
  return null;
380
444
  }
@@ -390,6 +454,26 @@ function wordFaults(
390
454
  return null;
391
455
  }
392
456
 
457
+ /**
458
+ * A bad byte at `at` inside a menu command's argument name: the device
459
+ * reports it at the name's first byte (`/ip/address/add addré=1` and
460
+ * `a?b=1` at the `a`), and the span is the name. A directive (`:put café=1`)
461
+ * and a `$` call (`$f café=1`) report at the byte, so this is `null` there.
462
+ */
463
+ function argumentNameFault(
464
+ masked: string,
465
+ frame: StatementFrame & { token: StatementToken },
466
+ at: number,
467
+ end: number,
468
+ ): GroupFault | null {
469
+ const { token } = frame;
470
+ if (frame.verb !== null || frame.call || token.eqAt >= 0) return null;
471
+ const eq = masked.indexOf("=", at);
472
+ if (eq < 0 || eq >= end) return null;
473
+ const fault = unquotedByteFault(masked, at);
474
+ return { ...fault, start: token.start, end: eq, site: "argument-name" };
475
+ }
476
+
393
477
  /**
394
478
  * The declared-name fault in the word at `at`, `null` when the name is
395
479
  * well formed, or `undefined` when the word is not a bare name at all (a
@@ -473,9 +557,15 @@ const CLOSER = { "(": ")", "[": "]", "{": "}" } as const;
473
557
 
474
558
  /**
475
559
  * Find every group fault in an analyzed document. `masked` is its comment
476
- * mask; `text` is read only to tell a masked comment line from spaces.
560
+ * mask; `text` is read only to tell a masked comment line from spaces. The
561
+ * walk starts at `from`, past a leading BOM, which is a positional fact and
562
+ * not a byte of the first word.
477
563
  */
478
- export function findGroupFaults(text: string, masked: string): GroupFault[] {
564
+ export function findGroupFaults(
565
+ text: string,
566
+ masked: string,
567
+ from = 0,
568
+ ): GroupFault[] {
479
569
  const faults: GroupFault[] = [];
480
570
  const frames: Frame[] = [statementFrame("")];
481
571
  const top = (): Frame => frames[frames.length - 1] as Frame;
@@ -502,7 +592,7 @@ export function findGroupFaults(text: string, masked: string): GroupFault[] {
502
592
  // `(1 +\⏎⏎2)`, and reject `(1 +\⏎# c⏎⏎2)` at the blank line.
503
593
  let cont: "none" | "escape" | "comment" = "none";
504
594
  let contLineStart = false;
505
- let i = 0;
595
+ let i = from;
506
596
  while (i < masked.length) {
507
597
  const c = masked[i] as string;
508
598
  const frame = top();
@@ -597,20 +687,27 @@ export function findGroupFaults(text: string, masked: string): GroupFault[] {
597
687
  (frame.kind === "statement" && frame.char === "[")
598
688
  ? whereEnd
599
689
  : null;
690
+ // A `where` named comparison never takes a script body through an
691
+ // unproven name — `[find where comment={ :beep }]` is `expected
692
+ // comment value` on CHR 7.25 — so the brace reads as a VALUE for
693
+ // the role below and the fault stands (#554). Proven slots still
694
+ // read as bodies; the gate is only the unproven disjunct.
695
+ const inWhereComparison =
696
+ c === "{" && floor !== null && comparesNamedValue(masked, i, floor);
600
697
  const role =
601
698
  c === "{" && frame.kind === "statement"
602
- ? braceStartsStatements(masked, i)
699
+ ? braceStartsStatements(
700
+ masked,
701
+ i,
702
+ 0,
703
+ inWhereComparison ? "value" : "body",
704
+ )
603
705
  ? "program"
604
706
  : directiveSlotRole(masked, i, frame)
605
707
  : null;
606
708
  // A body (`do={`) is never a comparison, though `where` is still open:
607
709
  // corpus `eworm/hotspot-to-wpa.template.rsc` interleaves two `:if`s.
608
- if (
609
- c === "{" &&
610
- role !== "program" &&
611
- floor !== null &&
612
- comparesNamedValue(masked, i, floor)
613
- ) {
710
+ if (c === "{" && role !== "program" && inWhereComparison) {
614
711
  // The device stops at this brace, so neither its contents nor the
615
712
  // rest of the enclosing group are read.
616
713
  faults.push({ code: "named-value-brace", start: i, end: i + 1 });
@@ -738,10 +835,26 @@ export function findGroupFaults(text: string, masked: string): GroupFault[] {
738
835
  : BARE_DECLARATIONS.has(word)
739
836
  ? word
740
837
  : null;
838
+ frame.call = word.startsWith("$");
839
+ // `[/ip/address/find comment=a=b]` is a query, as `[find …]` is.
840
+ if (frame.verb === null && /(^|\/)find$/i.test(word))
841
+ frame.query = true;
842
+ const bad = unquotedFaultByteAt(masked, i, end);
843
+ if (bad >= 0 && (at < 0 || i + at > bad)) {
844
+ frame.settled = true;
845
+ faults.push({ ...unquotedByteFault(masked, bad), site: "head-word" });
846
+ }
741
847
  }
742
848
  if (readsTokens(frame)) {
743
849
  const fault = wordFaults(masked, frame, i, end);
744
850
  if (fault !== null) faults.push(fault);
851
+ } else if (frame.query && !frame.settled && !token.head) {
852
+ // A query keeps `=` as an operator, but not these bytes.
853
+ const bad = unquotedFaultByteAt(masked, i, end);
854
+ if (bad >= 0) {
855
+ frame.settled = true;
856
+ faults.push(unquotedByteFault(masked, bad));
857
+ }
745
858
  }
746
859
  if (word.includes("=")) token.equals = true;
747
860
  const lower = word.toLowerCase();
@@ -760,6 +873,12 @@ export function findGroupFaults(text: string, masked: string): GroupFault[] {
760
873
  }
761
874
 
762
875
  if (frame.kind === "opaque") {
876
+ // A query's `in {…}` is read as opaque, so `where … in {}` keeps
877
+ // parsing; its bytes are still rejected (`in {a?b}` at the `?`).
878
+ if (frame.query && !frame.muted && isUnquotedFaultByte(c)) {
879
+ faults.push(unquotedByteFault(masked, i));
880
+ frame.muted = true;
881
+ }
763
882
  i++;
764
883
  continue;
765
884
  }
@@ -824,6 +943,12 @@ export function findGroupFaults(text: string, masked: string): GroupFault[] {
824
943
  i++;
825
944
  continue;
826
945
  }
946
+ // Unlike `@`, these bytes are rejected in a query too (`in {a?b}`).
947
+ if (isUnquotedFaultByte(c)) {
948
+ report(unquotedByteFault(masked, i), frame);
949
+ i++;
950
+ continue;
951
+ }
827
952
  frame.element = true;
828
953
  frame.last = "element";
829
954
  i++;
@@ -849,8 +974,16 @@ export function findGroupFaults(text: string, masked: string): GroupFault[] {
849
974
  i++;
850
975
  continue;
851
976
  }
977
+ // A query group keeps its operators, but not these bytes:
978
+ // `where (comment=a?b)` is a `syntax error` at the `?`.
979
+ if (isUnquotedFaultByte(c)) {
980
+ report(unquotedByteFault(masked, i), frame);
981
+ i++;
982
+ continue;
983
+ }
852
984
  if (frame.query) {
853
- // Structural checks only (module header): any other byte is content.
985
+ // Structural and byte checks only (module header): any other byte is
986
+ // content.
854
987
  frame.empty = false;
855
988
  frame.wantOperand = false;
856
989
  i++;
@@ -918,8 +1051,8 @@ export function findGroupFaults(text: string, masked: string): GroupFault[] {
918
1051
  i += spelling.length;
919
1052
  continue;
920
1053
  }
921
- // `#`, `?`, a backtick, `'`, a stray `\`, a non-ASCII placeholder: another
922
- // rule's byte, and nothing here can say what the group means after it.
1054
+ // `#`, `'`, a stray `\`: another rule's byte, and nothing here can say
1055
+ // what the group means after it.
923
1056
  frame.done = true;
924
1057
  i++;
925
1058
  }
@@ -143,7 +143,8 @@ const OPERATORS: readonly RouterosOperator[] = [
143
143
  // different operator, and arity is the only thing that separates them.
144
144
  // Offline value-shape hints abstain on every such form: identical
145
145
  // `(> …)` syntax can be `op` or `array` at runtime and `:parse` IL cannot
146
- // distinguish them, so `op` lives only as a live `observedType` (#288).
146
+ // distinguish them; only `:typeof` can, and live explain never sends it,
147
+ // so `op` has no producer at all (#288, #225).
147
148
  op(">", [1, 2], 5, "left", "relational", ">"),
148
149
  op("=", [2], 5, "left", "relational", "="),
149
150
  op("<=", [2], 5, "left", "relational", "<="),