Skip to content
Merged
Show file tree
Hide file tree
Changes from 3 commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 13 additions & 4 deletions .github/workflows/ci.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -157,10 +157,11 @@ jobs:
# scripts/corpus-pin.json and verifies its sha256.
#
# What gates here (#260): the FETCH — a hash mismatch is a hard failure, or
# the pin is decorative — and the VALUE census against the committed
# `corpus` block of test/fixtures/explain/values.json, which is the
# reachability this job was built to provide. The genre census still only
# reports: it has no committed fixture to be asserted against.
# the pin is decorative — the VALUE census against the committed `corpus`
# block of test/fixtures/explain/values.json, and the OPERATOR IL-head census
# against the same block of test/fixtures/explain/operators.json (#255). That
# is the reachability this job was built to provide. The genre census still
# only reports: it has no committed fixture to be asserted against.
#
# The other half of #260 needs no corpus and is not here: the README figures
# are a generated projection of that same fixture, gated by
Expand Down Expand Up @@ -211,12 +212,20 @@ jobs:
echo '```'
bun run explain:value-census 2>&1
echo '```'
echo
echo "### Operator IL head census"
echo
echo '```'
bun run explain:operator-census 2>&1
echo '```'
} >> "$GITHUB_STEP_SUMMARY"
# After the summary, so a drifted run still publishes its figures — the
# numbers are what tells you whether the drift is an intended emission
# change or a regression.
- name: Value census matches the committed fixture
run: bun run explain:value-census:check
- name: Operator census matches the committed fixture
run: bun run explain:operator-census:check

chr-smoke:
name: CHR smoke (stable)
Expand Down
5 changes: 5 additions & 0 deletions GLOSSARY.txt
Original file line number Diff line number Diff line change
Expand Up @@ -693,3 +693,8 @@ rbswitch # syscap gate: the RouterBOARD switch-chip menu shape — the other hal
prestera # Marvell Prestera switch chip — named in the `crs_prestera` syscap gate on `/interface/ethernet/switch/qos`
xfrm # `/interface/xfrm` — the IPsec transform interface, and the one published path with no gate anywhere in its ancestry to explain its absence from a CHR tree
btrfs # the Linux B-tree filesystem, a `/disk/btrfs/...` menu on RouterOS 7.20+

# #255 operator-axis vocabulary: the device-grounded operator surface.
arities # plural of arity — the operand counts an operator accepted, which is what separates `(> 1)` (deferred expression) from `(2 > 1)` (comparison)
typeofvalue # RouterOS IL renders `[:typeof $x]` as `/typeofvalue=$x` — the directive and its argument name run together with no separator
juxt # short for juxtaposition — the unnamed ` ( a b)` node RouterOS builds for space-separated paren contents; used in ids like `any-juxt`
151 changes: 151 additions & 0 deletions commands/explain/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -1040,6 +1040,157 @@ for the three issue rows `:put "\q"` / `:put "\x0a"` / `:put "\0a"` vs
failures). The verdict is in `il_text` — plain prose such as
`expected input value (line 7 column 49)` means rejected, IL means accepted.

### The operator surface (#255)

Grounded, not transcribed, for the same reason the escape set above is: the
[manual's operator list](https://manual.mikrotik.com/docs/developer-guides/scripting/index.md#operators)
is a lower bound. `bun run explain:probe:operators` sweeps the manual's list
**plus** plausible non-operators (`not`, `..`, `xor`, `mod`, `is`, `div`, …)
**plus** every IL head the corpus census saw, on CHR 7.23.3 stable and 7.24rc4
testing. Re-cut the fixture with `bun run explain:operator-slice`; the table
below is generated from `src/explain/operators.ts` by
`bun run explain:operator-readme` and gated by
`bun run explain:operator-readme:check`.

**`:parse` IL is the oracle, and `highlight` cannot be.** IL is prefix form with
the operator as its node's head, so it names the operator and shows its
operands. `highlight`'s `syntax-meta` is the device's *residual structure*
class — measured over `test/fixtures/explain/highlight-streams.slice.json` it
covers `=`, `"`, `$`, brackets, braces, parens, `;`, `,`, `/` and whitespace
runs as well as `||`, `&&`, `!=`, `.` and `~` — and adjacent runs are **merged**
(`="`, `($`, `")+` each arrive as one token; the run around `and`, `or` and `in`
swallows the preceding space). An operator boundary is not recoverable from it.
What it does decide is *structure versus word*, which is what rules `not` out:
it comes back `variable-undefined`.

Five things the device says that the manual does not:

1. **`not` is not an operator.** Nor are `xor`, `mod`, `is`, `div`, `band`,
`bor`, `shl`, `shr`, `eq`, `ne`. Inside a paren group a bare word is a
**variable reference**, so `(1 not 2)` parses — as `( 1 $not 2)`, with an
unnamed juxtaposition node. "It parsed" is therefore not evidence of an
operator, which is what the sweep's `(1 zzz 2)` control exists to prove.
2. **`..` is not a range operator** — `(1 .. 2)` is `( (. 1 $.) 2)`, concat
applied to a variable named `.`. Same for `//`. And `<>` is not "not equal":
`(1 <> 2)` is `(< 1 (> 2))`, less-than applied to a *deferred* `2`.
3. **`$`, `[`, `]` are syntax, not operator heads.** `$x` stays an atom in the
IL and `[:tostr 1]` lowers to an `evl` node. Those bytes belong to the
substitution axis.
4. **`any` is an operator** (prefix, arity 1) and is not in the manual's list — a **nil-check**: `:typeof (any x)` is `bool`, `false` only for `nil`/`nothing` (the value of an undefined `:local` and of `[:nothing]`), `true` for everything else including `0`, `""` and `false`. It is the idiom `:if (any $x) ...` to test a variable that may be `nil`; `(true any false)` is not infix at all but juxtaposition `( true (any false))`, and `(1 . any [:nothing])` is concat `1`+`false`. Present since at least 7.20.8 (corpus `any|7.20.8:2`, live on 7.21.5 long-term and both 7.23.3/7.24rc4).
5. **`&&` and `||` are spellings**, lowering to the `and` and `or` nodes.

The `(>…)` and `<%%` forms are in the table on the same footing as `+`.
`(> x)` at arity 1 is the deferred-expression form — `[:typeof (>[:return 1])]`
is `op` — while `(2 > 1)` at arity 2 is the comparison; **arity is the only
thing that separates them**, so a table keyed on spelling alone gets one wrong.
`<%%` applies a deferred expression to an argument array, binding positionals
from `$0`; a `do={…}` function binds the same arguments from `$1`, because
there `$0` is the function's own name.

Both `!` and `any` are prefix-only, so the pair sweep leaves their precedence
unmeasured (`null`). Local unary-vs-binary probes (every `U 1 B 2` and
`1 B U 2`, `U`∈`!`/`any`, `B`∈24 binaries, on 7.23.3 and 7.21.5) show they bind
**tighter than every binary**, including `->` (14) and `<%%` (13) — outer is
always `B`. That is an extra level 15, right-assoc, shared with the unary
arities of `~`, `-`, `>` (binary arities sit at 5, 11, 5); the table's single
`precedence` per spelling is the binary level.

Everything in the sweep is identical on 7.23.3 and 7.24rc4 except one runtime
row: `:put ({2;1} > {1;2;3})` errors on stable and evaluates to `true` on
testing. So the operator table needs no version gate; anything reporting what a
comparison *means* does.

<!-- BEGIN GENERATED operator-table — regenerate with `bun run explain:operator-readme` -->
The device builds a node for **26** spellings, reads **3**
as something else, and refuses the other **33** the sweep asked about.

| operator | arity | precedence | associativity | category |
| -------- | ----- | ---------- | ------------- | -------- |
| `,` | 2 | 1 | variadic | concatenation |
| `or` | 2 | 2 | variadic | logical |
| `and` | 2 | 3 | variadic | logical |
| `in` | 2 | 4 | left | logical |
| `<` | 2 | 5 | left | relational |
| `>` | 1, 2 | 5 | left | relational |
| `=` | 2 | 5 | left | relational |
| `<=` | 2 | 5 | left | relational |
| `>=` | 2 | 5 | left | relational |
| `!=` | 2 | 5 | left | relational |
| `~` | 1, 2 | 5 | left | relational |
| `.` | 2 | 6 | variadic | concatenation |
| `<<` | 2 | 7 | right | bitwise |
| `>>` | 2 | 7 | right | bitwise |
| `\|` | 2 | 8 | variadic | bitwise |
| `^` | 2 | 9 | variadic | bitwise |
| `&` | 2 | 10 | variadic | bitwise |
Comment thread
mobileskyfi marked this conversation as resolved.
| `+` | 2 | 11 | variadic | arithmetic |
| `-` | 1, 2 | 11 | left | arithmetic |
| `*` | 2 | 12 | variadic | arithmetic |
| `/` | 2 | 12 | left | arithmetic |
| `%` | 2 | 12 | left | arithmetic |
| `<%%` | 2 | 13 | right | apply |
| `->` | 2 | 14 | left | access |
| `!` | 1 | not measured | not measured | logical |
| `any` | 1 | not measured | not measured | logical |

Precedence runs 1 (loosest) to 14 (tightest), measured over every
ordered pair rather than transcribed. `variadic` means the device FLATTENS
the operator — `(1 + 2 + 3)` is one node with three children, not two nested
ones. The two prefix-only operators never appear in a pair and so carry no
measured level.

Spellings the device reads as something else:

| spelling | reads as | kind |
| -------- | -------- | ---- |
| `&&` | `(<%% (and 1 2) )` | alias |
| `\|\|` | `(<%% (or 1 2) )` | alias |
| `<>` | `(<%% (< 1 (> 2)) )` | re-lexed |

And the grounded complement — asked, and refused:

| spelling | why not |
| -------- | ------- |
| `not` | reads-as-variable |
| `xor` | reads-as-variable |
| `mod` | reads-as-variable |
| `is` | reads-as-variable |
| `div` | reads-as-variable |
| `band` | reads-as-variable |
| `bor` | reads-as-variable |
| `shl` | reads-as-variable |
| `shr` | reads-as-variable |
| `eq` | reads-as-variable |
| `ne` | reads-as-variable |
| `line` | reads-as-variable |
| `array` | reads-as-variable |
| `as` | reads-as-variable |
| `at` | reads-as-variable |
| `for` | reads-as-variable |
| `none` | reads-as-variable |
| `outside` | reads-as-variable |
| `evl` | reads-as-variable |
| `..` | reads-as-variable |
| `//` | reads-as-variable |
| `**` | rejected |
| `===` | rejected |
| `+=` | rejected |
| `++` | rejected |
| `?` | rejected |
| `?:` | rejected |
| `<%` | rejected |
| `%%` | rejected |
| `;` | rejected |
| `$` | juxtaposition-only |
| `[` | rejected |
| `]` | rejected |
<!-- END GENERATED operator-table -->

None of this emits a span yet. `src/explain/operators.ts` is data plus
accessors; the operator fill for `data.tokens[]` is #264's B2 and the
`centrs → highlight` projection is B4, and both read this table rather than
re-deriving it.

### Designed, not implemented (the CLI surface, #202b)

`src/cli/explain.ts` ships the offline command; implemented flags are generated
Expand Down
8 changes: 7 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -64,7 +64,7 @@
"lint": "bun run lint:biome && bun run lint:typecheck",
"lint:git:commit": "bun run lint:biome",
"lint:git:push": "bun run lint:ci",
"lint:ci": "bun run lint && bun run docs:cli:check && bun run explain:value-census:readme:check && bun run lint:markdownlint && bun run lint:cspell && bun run lint:secretlint",
"lint:ci": "bun run lint && bun run docs:cli:check && bun run explain:value-census:readme:check && bun run explain:operator-readme:check && bun run lint:markdownlint && bun run lint:cspell && bun run lint:secretlint",
"lint:cspell": "cspell lint . --gitignore",
"lint:cspell:all": "cspell lint . --no-gitignore",
"lint:markdownlint": "markdownlint-cli2 \"**/*.md\" \"!.scratch/**\"",
Expand Down Expand Up @@ -97,9 +97,15 @@
"explain:corpus-census": "bun run scripts/explain-corpus-census.ts",
"explain:highlight-slice": "bun run scripts/explain-highlight-slice.ts",
"explain:menus": "bun run scripts/gen-explain-menus.ts",
"explain:operator-census": "bun run scripts/explain-operator-census.ts",
"explain:operator-census:check": "bun run scripts/explain-operator-census.ts --check",
"explain:operator-readme": "bun run scripts/explain-operator-slice.ts --readme",
"explain:operator-readme:check": "bun run scripts/explain-operator-slice.ts --readme --check",
"explain:operator-slice": "bun run scripts/explain-operator-slice.ts",
"explain:probe:brace-slots": "bun run scripts/probes/explain-brace-slot-sweep.ts",
"explain:probe:escapes": "bun run scripts/probes/explain-escape-sweep.ts",
"explain:probe:highlight-recapture": "bun run scripts/probes/explain-highlight-recapture.ts",
"explain:probe:operators": "bun run scripts/probes/explain-operator-sweep.ts",
"explain:probe:symbol-anchors": "bun run scripts/probes/explain-symbol-anchors.ts",
"explain:probe:symbol-arms-score": "bun run scripts/probes/explain-symbol-arms-score.ts",
"explain:probe:symbol-bad-sigil-replay": "bun run scripts/probes/explain-symbol-bad-sigil-replay.ts",
Expand Down
Loading
Loading