Skip to content
Merged
Show file tree
Hide file tree
Changes from all 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
117 changes: 108 additions & 9 deletions commands/explain/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -545,7 +545,7 @@ and its phase is named below.
| `structure.statements[].command` | `path` + `verb`, plus **`args` where the argument list was read** (#202c-1); the ordered token list is `statements[].arguments.tokens` |
| `structure.statements[].transport` | complete for commands: `api-candidate`, `execute`, or fail-closed `unknown`, with an equivalent `centrs` invocation and opt-in `curl` for tested REST mappings (#202c-2). The `basis` names the evidence for the route it chose or refused |
| `symbols.occurrences[]` | Q13 name/span/class plus semantic `role`, result-local `bindingIds`, sigil spelling, and an abstention note where needed; **no value shape or type** |
| `values.occurrences[]` | result-local value id/span plus three separate fact homes: offline `shapeHints`; live `observedType` and `schemaType` remain absent until phase 2 |
| `values.occurrences[]` | result-local value id/span plus three separate fact homes: offline `shapeHints`; live `observedType` and `schemaType` remain absent until phase 2. `kind: "element"` rows are array members, carrying `parent` and, for a keyed member, `name` |
| `spans` | comment runs and resolved variable classes only; **no value shape or type** |
| `schema`, `completion` | absent — live evidence, phase 2 |

Expand Down Expand Up @@ -682,14 +682,113 @@ and its phase is named below.
Offline emits only `shapeHints`; phase 2 adds the two live facts into their
existing homes when its safe IL/schema producers land.

The V2 corpus census covers 948 source scripts: all 13,143 value anchors for
which the strict argument lexer also decided have identical half-open byte
spans (0 contradictions), while the prefix-safe hint scan retains 4,161
earlier or structured values across 1,603 statements whose strict REST
reading abstains. The 17,304 emitted occurrences include 420 arrays and 3 MAC
spellings; the corpus contains no source-literal `id` example. All retained
document spans were in bounds. The strict lexer remains all-or-nothing; only
non-authoritative hints use the wider view.
**A located array is read member by member (#225 V1).** Each member of a
`{…}`/`(…)` literal is its own `values.occurrences[]` row with `kind:
"element"`, its own span and hints, a `parent` naming the container's
result-local id, and `name` when the brace form spells a key. The separator is
the delimiter's and not a choice: a brace splits on `;` while a depth-zero `,`
inside one builds a nested array (`{1,2}` is a ONE-member array whose member
is `(, 1 2)`), a paren splits on `,`, and `(1;2)` does not parse. `=` binds a
key in the brace form (`{a=1}`) and COMPARES in the paren form
(`(a=1,b=2)` is two `bool` members), so keys are read in braces only.

**A member is not an argument value, because the device does not parse it as
one.** Inside a literal RouterOS reads an expression, so the member lexicon is
its own: a bare word is a variable reference (`{abc}` lowers to `$abc`), and
so is a full MAC (`{00:11:22:33:44:55}`) and any time literal past the signed
64-bit nanosecond range, whose cliff sits between `15250w` and `15251w`,
`106751d` and `106752d`, `9223372036s` and `9223372037s`. `*1` does not parse
at all there, though `:local x *1` is `id`. Hexadecimal is added — `0x10` is
16, while `0X10` is a variable — and `str`-by-fallback, `mac` and `id` are
dropped. Everything else abstains: expressions, substitutions, `$x`, and the
dotted-decimal-to-seconds fallback described below.

**A `{…}` array literal is legal only in a root scripting directive's value
slot, and only in a slot the device proved.** `/console/inspect` classes the
`{` byte `error` and `:parse` refuses the statement for
`/ip/route/add comment={1;2}`, `/ip/dns/set servers={1.1.1.1;8.8.8.8}` — a
LIST-typed attribute, which is what rules out a schema-shaped reading —
`/interface/print .proplist={name;comment}`,
`/ip/route/print where comment={1;2}`, the relative spelling
`ip route add comment={1;2}`, and `:log info message={1;2}`; while
`:local z {1;2}`, `:put {1;2}`, `:len {1;2}` and `:foreach i in={1;2}` all
parse.

Being a directive is necessary but not sufficient, and the path does not
decide: `:delay {1;2}`, `:beep {1;2}`, `:resolve {1;2}`,
`:if condition={1;2}` and `:local name={1;2}` are syntax errors at a `/` path
too. The gate is keyed on **(verb, slot)** from a device sweep of every root
builtin and every slot it completes — 222 rows in
`test/fixtures/explain/values.json` → `interiorGrounding.braceSlots`, baked
into `src/explain/brace-slots.ts` — and a slot has four outcomes, not two:

| outcome | what the slot does with `{(1,2)}` | example |
| ------- | --------------------------------- | ------- |
| `array` | evaluates it — IL `(, 1 2)` | `:local z`, `:put`, `:foreach in=`, `:for from=` |
| `code` | runs it as a program | `:onerror in=`, `:retry command=`, `:if do=` |
| `text` | keeps it verbatim as a script | `:execute script=`, `:grep script=` |
| `error` | refuses the statement | `:delay`, `:beep`, `:local name=` |

Only `array` slots are read as arrays; the other three and the 30 rows the
sweep could not ask are refused, which costs coverage and never correctness.
A positional is keyed by its index, because position decides — `:local {1;2}`
puts the literal in the NAME slot and does not parse, while `:local z {1;2}`
puts it in the VALUE slot and does. `{1;2}` alone cannot build this table:
`:local z {1;2}` and `:execute script={1;2}` both lower to `…=1;2`, so the
array test has to be a literal whose evaluated and verbatim readings differ. The `(…)` spelling is never rejected at the delimiter in any of those
positions — `.proplist=(name,comment)` still draws `bad parameter .proplist`,
but that is a name-level diagnostic and the plain `name,comment` spelling
draws it too — and a `(…)` or `[…]` around a brace restores the expression
context. The braces a
command argument does take are script bodies (`source=`, `on-event=`), and
those are not arrays either — they are refused earlier as scope blocks. An empty member is a
syntax error (`{;}`, `{;1}`, `{1;;2}`, `(1,)`) and withdraws the enclosing
`array` shape with it; a single trailing separator is legal in the brace form
only (`{1;}` and `{1;2;}` parse, `{1;2,}` and `{2,}` do not).

**A nested member the device rejects withdraws its container too.** A `(…)`
inside a literal is a GROUP as often as an array — `{(1)}` is the one-member
array `1` — so an empty group (`{()}`, `{a=()}`) and an empty comma member
(`{(1,)}`, `{(,)}`, `{1;(2,)}`, `(1,(2,))`) are named as faults rather than
abstained on: `:parse` rejects each of them at every nesting depth, while
`{(1)}`, `{a=(1)}` and `{1;(2)}` parse. `highlight` is not the oracle for this
family — it accepts `{1;2,}`, `{2,}` and `{(1,2),}`, all of which `:parse`
rejects. Member descent stops after eight frames, and the bound WITHDRAWS
rather than keeping an unverified shape: a fault below it still makes the
whole statement a syntax error — `:parse` rejects a `(1,)` buried nine levels
deep exactly as it rejects a shallow one — so a literal nested deeper than
eight is refused instead of called an array. The deepest member in the
948-script corpus sits at depth 6.

**The bare comma spelling is the one place a hint is genuinely plural.**
`=1,2,3` is not a syntax error anywhere, and whether the device SPLITS it is
decided by the argument's type, which offline does not have:
`servers=1.1.1.1,8.8.8.8` lowers to `servers=1.1.1.1;8.8.8.8` and
`dst-port=80,443` to `dst-port=;80;443`, while `comment=a,b` and
`interface=ether1,ether2` keep the whole run as one string. A named attribute
therefore carries `["array", "str"]` — both readings, neither validated. In an
expression position there is no second reading (`:local x 1,2` is a
two-member `array`, and so is `a,b`), so the hint is singular there. No
members are located for a bare comma run: whether it splits at all is the
schema's answer, and a member span would be a guess. The `(1,2)` spelling,
where the delimiters prove it, is anchored and descended into as usual.

The short IPv4 spelling fills the LOW octets and every field is one octet:
`1.255` is `1.0.0.255` but `1.256` is `time` `00:00:01.256`, `1.16777215` is
`time` and not `1.255.255.255`, and `1.1.256` is plain text. Offline abstains
on the seconds fallback rather than encoding a chain that depends on a failed
address attempt.

The corpus census is re-derivable with `bun run explain:value-census` and
covers 948 source scripts. Every one of the 13,168 emitted values in a
statement the strict argument lexer ALSO read has identical half-open byte
spans and decoded text (0 contradictions), while the prefix-safe scan retains
6,540 values across 569 statements whose strict REST reading abstains. Of
19,708 emitted occurrences, 5,636 are array members (529 keyed, 1,147 nested
inside another member) and 813 are arrays; the corpus contains no
source-literal `id` example. All spans were in bounds, every member named a
container that exists, and every member sat strictly inside it. The strict
lexer remains all-or-nothing; only non-authoritative hints use the wider view.
Comment thread
coderabbitai[bot] marked this conversation as resolved.

### Offline comment placement (#245)

Expand Down
108 changes: 107 additions & 1 deletion commands/explain/examples.md
Original file line number Diff line number Diff line change
Expand Up @@ -440,7 +440,9 @@ it does not infer value-flow types, which remain #239 S2 after #225.
centrs explain ':local x 2.2; :set x "2.2"; :local z (1,2,3); :local t 00:00:02; :local i *1; /ip/arp/add mac-address=00:11:22:33:44:55' --json
```

`data.values.occurrences[]` contains six result-local value records. The bare
`data.values.occurrences[]` contains nine result-local value records — six
argument values and the three members of the array literal (example 28 owns the
interior). The bare
`2.2` carries `facts.shapeHints.values: ["ip"]` — RouterOS numbers are integers,
so a dotted decimal is an IPv4 shortcut (`2.0.0.2`) and never `num`; the quoted
`"2.2"` carries `["str"]`. The array literal, colon-form time, internal ID, and
Expand Down Expand Up @@ -495,3 +497,107 @@ A same-line hash after a completed scripting directive is likewise an error:
`:local x 2 #` produces an `explain/canonicalizer/invalid-hash` diagnostic at
the hash byte and stops later semantic claims. Statement-leading comments are
listed with their byte ranges under `comments:` in the default text output.

### 28. An array literal is read member by member, and only where it is one

```bash
centrs explain ':local z {1.1;"abc";1d;{2;3};a=0x10;b=100000w}; /ip/route/add comment={1;2}' --json
```

Members of a located array literal are values in their own right.
`data.values.occurrences[]` carries one record per member, each with its own
document-byte span, its own `shapeHints`, and a `parent` naming the array's
result-local id — `1.1` is `["ip"]`, `"abc"` is `["str"]`, `1d` is `["time"]`,
the nested `{2;3}` is `["array"]` with two `["num"]` members of its own, and the
keyed member carries `name: "a"` with `["num"]` for `0x10`. `kind` is `element`,
so a caller can tell a member from a command argument without re-parsing.

**A member is not an argument value, because RouterOS does not parse it as
one.** Inside a literal the device reads an expression, so `b=100000w` hints
nothing at all: `:parse` lowers it to `$100000w`, a variable reference, because
the literal overflows the signed 64-bit nanosecond range that ends between
`15250w` and `15251w`. The same rule removes three spellings the argument
lexicon does hint — a bare word (`{abc}` is `$abc`), a full MAC
(`{00:11:22:33:44:55}` is a variable, though `:local x 00:11:22:33:44:55` is
`str`), and an internal id (`{*1}` is a hard syntax error, though `:local x *1`
is `id`) — and adds hexadecimal, which is a number here (`0x10` is 16, while
`0X10` is a variable). Expressions and substitutions abstain: `{1+1}`,
`{"a"."b"}`, `{[:parse ":put 1"]}` and `{$x}` produce no member hint.

The second statement produces **no value record at all**. A `{…}` array literal
is legal only in a scripting directive's value slot: `/console/inspect` classes
the `{` byte `error` and `:parse` refuses `/ip/route/add comment={1;2}`,
`/ip/dns/set servers={1.1.1.1;8.8.8.8}` — whose attribute really is list-typed,
which is what rules out a schema-shaped reading — `.proplist={name;comment}`,
the relative spelling `ip route add comment={1;2}`, and even
`:log info message={1;2}`. The `(1,2)` spelling is never rejected at the
delimiter in any of those positions, and a `(…)` or `[…]` around the brace
restores the expression context (`comment=({1;2})` and `comment=[:tostr {1;2}]`
both parse). The braces a
command argument does accept are script bodies (`source=`, `on-event=`), and
those are refused earlier as scope blocks rather than read as arrays.

An empty member is a device syntax error (`{;}`, `{;1}`, `{1;;2}`, `(1,)`), so
the enclosing `array` shape is withdrawn with it; a single trailing separator is
legal (`{1;}` is a one-member array). The verdict stays `pass` throughout —
a shape hint never becomes a diagnostic, and neither does its absence.

### 28c. Which slot the brace sits in decides whether it is an array

```bash
centrs explain ':local z {1;2}; :delay {1;2}; :for i from={1;2} to=2 do={:put 1}; :execute script={1;2}' --json
```

Only the first and third statements produce an array record. Being a scripting
directive is necessary but not sufficient: the gate is keyed on **(verb, slot)**
from a device sweep of every root builtin and every slot it completes, and a
slot has four outcomes. `:local z {…}` and `:for from={…}` EVALUATE the brace
(IL `(, 1 2)` for `{(1,2)}`), so they are arrays. `:delay {1;2}` is a syntax
error — a scalar-typed slot at the same `/` path — and produces nothing.
`:execute script={1;2}` parses but keeps the brace VERBATIM as a script
(`script=(1,2)`, not `(, 1 2)`), so it is not an array either, and neither are
the code slots `:onerror in=`, `:retry command=` and `:if do=`. The same name
can differ by verb: `:foreach i in={1;2}` is an array while
`:onerror e in={1;2}` is a program.

A positional is keyed by its index, because position decides: `:local {1;2}`
puts the literal in the NAME slot and does not parse, while `:local z {1;2}`
puts it in the VALUE slot and does. The verb is matched verbatim for the same
reason: RouterOS accepts only the lower-case spelling, so `:LOCAL z {1;2}` is
`expected command name` and gets no array, while an argument's casing is free
(`:local Z {1;2}` binds `$Z` and does). Everything the sweep could not ask is
refused, so the gate loses coverage rather than inventing a shape.

Two rejections a member walk cannot see from the outside are also refused here.
A nested literal the device rejects withdraws its container — `{(1,)}`,
`{a=(1,)}`, `{1;(2,)}`, `{()}`, `{a=()}`, `{(,)}` and `(1,(2,))` are all
`:parse` syntax errors, even though a `(…)` member is usually a group (`{(1)}`
is the one-member array `1`). And member descent stops after eight frames: past
that the interior is unverified, and a fault below the bound still makes the
whole statement a syntax error, so the literal is refused rather than called an
array on the strength of its delimiters alone.

### 28b. The comma spelling is accepted everywhere, and means two things

```bash
centrs explain '/ip/dns/set servers=1.1.1.1,8.8.8.8; /ip/route/add comment=a,b; :local x 1,2' --json
```

The brace restriction is about the brace, not about lists: `=1,2,3` is a syntax
error nowhere, and list-taking arguments really do take it. What the device does
with it depends on the argument's TYPE, which offline does not have —
`servers=1.1.1.1,8.8.8.8` lowers to `servers=1.1.1.1;8.8.8.8` and
`dst-port=80,443` to `dst-port=;80;443`, while `comment=a,b` and
`interface=ether1,ether2` keep the whole run as a single string.

So the first two records carry **`["array", "str"]`** — the first genuinely
plural hint, which is what `shapeHints.values` is a list for. Neither reading is
validated and neither becomes a diagnostic; the split is a `schemaType` fact and
that home stays empty offline. The third record, in a scripting directive's
value slot, carries `["array"]` alone: `:local x 1,2` is a two-member array on
the device and no text reading exists there.

No members are emitted for a bare comma run, because whether it splits at all is
the schema's answer. The `(1,2)` spelling is different — its delimiters prove
the array — so `servers=(1.1.1.1,8.8.8.8)` yields the array plus two `["ip"]`
members.
6 changes: 3 additions & 3 deletions docs/MATRIX.md
Original file line number Diff line number Diff line change
Expand Up @@ -71,9 +71,9 @@ section short enough that the grid remains the status surface.
design round (#90) and the spec is **ratified** on the phase-0 grounding
lab (#185, ratification PR #187). Phase 0.5 has since promoted the ratified
lab logic into the library — `src/explain/{segment,blocks,coordinates,defects,
pathresolve,verbsplit,write,symbols,menus,verbs,args}.ts` with frozen fixtures
under `test/fixtures/explain/` — and phase 1 has composed them into one
analysis and envelope in `src/explain.ts` (`explainCommand` /
pathresolve,verbsplit,write,symbols,menus,verbs,args,values,brace-slots}.ts`
with frozen fixtures under `test/fixtures/explain/` — and phase 1 has composed
them into one analysis and envelope in `src/explain.ts` (`explainCommand` /
`explainEnvelope`, #202a). **`centrs explain '<input>'` is a real command as
of #202b** (`src/cli/explain.ts`), and #202c adds statement argument reading,
the closed Q8 transport classifier, equivalent centrs invocations, and
Expand Down
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -95,6 +95,7 @@
"explain:corpus-census": "bun run scripts/explain-corpus-census.ts",
"explain:menus": "bun run scripts/gen-explain-menus.ts",
"explain:menus:check": "bun run scripts/gen-explain-menus.ts --check",
"explain:value-census": "bun run scripts/explain-value-census.ts",
"security": "bun run lint:secretlint && bun run lint:markdownlint"
},
"type": "module",
Expand Down
Loading
Loading