From 1edb5e90bd515f179c7b76c540a39a5d7eb5255e Mon Sep 17 00:00:00 2001
From: SkyFi Geek <45924209+mobileskyfi@users.noreply.github.com>
Date: Wed, 12 Aug 2026 08:13:58 -0700
Subject: [PATCH 1/2] fix(catalog): discover CLI Reference from both
inventories (#285)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
MikroTik reshaped the CLI Reference from module pages into per-command leaf
pages whose slug is the CLI path. Fixed upstream in tikoci/rosetta#138; this is
the centrs half.
Discovery was the sitemap alone, which serves a branching menu as a
trailing-slash category URL with no .md of its own. That menu's own entry IS
published, at
/.md, and listed only in llms.txt. Grounded live
before touching code:
sitemap 814 U derived / 256 = 1,070 = llms.txt (1,071 - index)
Discovery is now the union, with a BLOCKING gate that every sitemap category dir
contributes its leaf — the same assertion rosetta ships as V-cliref-discovery,
so the next reshape fails loudly instead of quietly shrinking the table. A page
only one inventory lists is reported rather than refused: the union already
carries it, and --check fails on the new row anyway. Corpus now reconciles with
rosetta exactly at 1,070 pages / 1,077 entries.
The alias allowlist is empty, and that is the correct state. All 20 source
spellings are gone from the publication and all 20 targets are published
directly. R0/R1/R2 stay asserted so the next entry is refused rather than
absorbed; the R1 test moved to a synthetic allowlist since no live entry exists.
Two paths publish twice as containers under different hardware gates
(/system/health as !i386 and health, /interface/ethernet/switch as musicswitch
and rbswitch). Both halves say the path is navigation, so the row records what
they agree on, exactly as a disagreed gate is dropped rather than picked. Only a
navigation-vs-command split still aborts. Anchored on a new frozen fixture of
the real page; "one entry per page" is explicitly not an invariant.
Catalog: 1,119 -> 1,124 paths, 439 -> 450 commands, and the ancestry-aware
unexplained residue 7 -> 1 (/interface/xfrm).
Corpus differential over the 948-script corpus, old table vs new, harness
verified by reproducing the #249 figures exactly on the old table:
stmts 18,648 -> 18,648
argCandidates 14,329 -> 14,336
argReadable 7,385 -> 7,392
abstentions 6,944 -> 6,944 (48.4612% -> 48.4375%)
context-lost 69 -> 83
Six scripts move and every statement in them is a correction. Seven newly
resolve and read; five read the same with the right verb (/ip hotspot user
reset-counters took `hotspot` as the verb, now takes `reset-counters`, which
also reclassifies it as a write). No document changes containsWrite verdict.
context-lost rising is the resolver withdrawing a certainty claim it should
never have had: an unreadable statement might have been an absolute navigation
and so restored context; a known command never navigated.
Live --check is 1m34s cold, inside the QA job's 10-minute budget.
Closes #285
Co-Authored-By: Claude Opus 5
---
GLOSSARY.txt | 8 +
commands/explain/README.md | 65 +-
scripts/explain-catalog-data.ts | 269 +++++--
scripts/gen-explain-catalog.ts | 17 +-
src/explain/catalog.ts | 702 +++++++++---------
test/fixtures/explain/cliref/README.md | 26 +-
.../explain/cliref/system__health__health.md | 90 +++
test/unit/explain-catalog.test.ts | 218 +++++-
test/unit/explain-write.test.ts | 20 +-
9 files changed, 932 insertions(+), 483 deletions(-)
create mode 100644 test/fixtures/explain/cliref/system__health__health.md
diff --git a/GLOSSARY.txt b/GLOSSARY.txt
index 099a8cc..3aee809 100644
--- a/GLOSSARY.txt
+++ b/GLOSSARY.txt
@@ -684,4 +684,12 @@ unioned # of the two sources: merged with per-entry provenance rather than flatt
unioning # the act of the above
greppable # of a generated table: one entry per line, so `grep` finds a path
apos # the `'` XML entity, decoded by the CLI-Reference parser
+
+# #285 CLI-Reference reshape vocabulary: the second published inventory, and the
+# gate values on the paths that publish twice.
+llms # `llms.txt` — MikroTik's published page inventory; the only one listing the `/` leaf a branching menu's own entry lives on, so discovery is the union of it and the sitemap
+musicswitch # syscap gate: the Marvell-era switch-chip menu shape — one half of the `/interface/ethernet/switch` pair published under two gates
+rbswitch # syscap gate: the RouterBOARD switch-chip menu shape — the other half of that pair
+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+
diff --git a/commands/explain/README.md b/commands/explain/README.md
index d7b63a9..2b3806b 100644
--- a/commands/explain/README.md
+++ b/commands/explain/README.md
@@ -173,20 +173,31 @@ today's path/verb/args split and script-vs-structured gate:
The *command* half is what #228 addressed, by adding a second first-order
source rather than a schema. MikroTik's CLI Reference is generated from the
definition structs, and measured against the same four pinned trees it has
- **zero kind contradictions** across 906 exactly-matching paths, three RouterOS
- versions and two architectures; 105 of the 112 published paths absent from
- every tree carry a `package`/`conditions`/`syscap` gate that predicts the
- absence. `src/explain/catalog.ts` unions the two with per-entry provenance,
- which is what keeps `menus.ts`'s device-confirmed floor intact rather than
- diluting it. The publication is not device truth: it uses the
- definition-module spelling, so `caps-man/acl/access-list` is published and
- unreachable on any device, and the hand-audited alias allowlist that recovers
- the CLI spelling is guarded by two generation-time assertions. So
+ **zero navigation-vs-command contradictions** across 968 exactly-matching
+ paths, three RouterOS versions and two architectures; all but one of the 102
+ published paths absent from every tree carry a `package`/`conditions`/`syscap`
+ gate — their own or an ancestor's — that predicts the absence.
+ `src/explain/catalog.ts` unions the two with per-entry provenance, which is
+ what keeps `menus.ts`'s device-confirmed floor intact rather than diluting it.
+ The publication is still not device truth, but as of #285 it is at least
+ spelled like the CLI: MikroTik reshaped it from module pages into per-command
+ leaf pages whose slug *is* the CLI path, so the definition-module spellings
+ (`caps-man/acl/access-list`) are gone and the alias allowlist that recovered
+ them is empty — kept, and kept asserted, to refuse the next one loudly. So
`/system/reboot` now resolves as a `command` at `/system`, and the floor is
again unmoved — the residual is a path in *neither* table, such as
`/disk/format-drive`, which 7.23.2 spells `/disk format` and MikroTik does not
publish at all.
+ Discovery is the union of MikroTik's two published inventories, not the
+ sitemap alone. A *branching* menu is served as a trailing-slash category URL
+ with no `.md` of its own, while that menu's own entry is published at
+ `/.md` and listed only in `llms.txt` — so sitemap-only
+ discovery dropped 256 of 1,070 pages, silently and field-heavily. Generation
+ asserts that every category dir still contributes its leaf, so the next
+ inventory reshape fails loudly instead of shrinking the table (#285,
+ tikoci/rosetta#137).
+
There are real unknowns in how far offline parsing can go (expression
grammar, scope fidelity vs `:parse`, `[]`-nesting corner cases). Grounding
those unknowns was **phase 0** (#185); its findings are folded in throughout
@@ -899,13 +910,14 @@ The 948-script corpus contains no such continuation-comment argument case. The s
the corpus re-measurement below shows no readable/abstention movement — no
`do=`/`else=`/`command=`/`script=` brace sits inside an array in the 948
scripts, so the fix closes a device-grounded false `pass` with zero corpus
-blast radius. The readable set stays exactly 7,385 statements. Recognizing a bracket-leading hash
+blast radius. The readable set stayed exactly 7,385 statements. Recognizing a bracket-leading hash
as a comment conservatively folds two previously separate abstentions inside
one foreign kernel-panic transcript (`[#1]` / `[#2]`) into its already-unknown
outer statement, leaving 14,329 argument-bearing candidates and 6,944
abstentions (48.4612%). That two-statement movement is a blast-radius
measurement, not the grounding evidence; the two CHR versions above are the
-oracle.
+oracle. (Those are the #249-era figures; the #285 catalog correction has since
+moved the readable set to 7,392 — see the re-measurement below.)
The same corpus run finds five new `invalid-hash` diagnostics, all in pasted
non-RouterOS material: one NGINX server block, one JavaScript highlighter, one
@@ -928,6 +940,37 @@ Corpus re-measurement for #249 — `bun -e 'explainCommand'` over the 948-script
expected outcome; the grounding remains the 15-input CHR probe below
(CHR 7.23.3 `highlight` at the `#` byte, 0/15 mismatches), not a corpus delta.
+The #285 catalog correction moved those figures, and only upward. Same harness,
+old table vs new:
+
+| | #249 | #285 |
+| --- | ---: | ---: |
+| `stmts` | 18,648 | 18,648 |
+| `argCandidates` | 14,329 | **14,336** |
+| `argReadable` | 7,385 | **7,392** |
+| `abstentions` | 6,944 | 6,944 (**48.4375%**) |
+| `ambiguous-statement` | 91 | **85** |
+| `unresolved-statement` | 3,911 | **3,910** |
+| `context-lost` | 69 | **83** |
+
+Six scripts move, and every statement in them is a correction. Seven statements
+that read as `ambiguous`/`unknown` now resolve and their arguments read —
+`/interface/monitor-traffic`, `/system/package/apply-changes`,
+`/system/package/update/{check-for-updates,download,install}` — which is the
+whole of the `argCandidates`/`argReadable` gain, with `abstentions` flat. Five
+more read the same but with the right verb: `/ip hotspot user reset-counters
+[find …]` took `hotspot` as the verb and now takes `reset-counters`, which also
+reclassifies it as a write. No document changes `containsWrite` verdict.
+
+`context-lost` rising is the resolver **withdrawing** a certainty claim, not
+losing ground. It is `info` severity and marks a statement that resolved
+correctly while the document context was already unknown. All 14 are in one
+script — a bare list of command paths, one per line, with no navigation
+anywhere. Previously `/interface/monitor-traffic` was unreadable and so *might*
+have been an absolute navigation, which R4 has REPLACE the context; certainty
+was restored on that maybe. Knowing it is a command, the resolver knows it never
+navigated, so the context stays as unknown as it already was.
+
- **Severity is fixed here, because it drives `--fail-on`.** Three buckets, and
the split is not "structural vs not":
- `error` — `unclosed`, `unbalanced-close`, `unterminated-string`,
diff --git a/scripts/explain-catalog-data.ts b/scripts/explain-catalog-data.ts
index 879d834..4fe5cd4 100644
--- a/scripts/explain-catalog-data.ts
+++ b/scripts/explain-catalog-data.ts
@@ -23,21 +23,33 @@
* other first-order source. It is generated from the definition structs — the
* `typ=` strings are a dumped internal type grammar with `:0` ordinals, and
* `Package` / `Conditions` / `Syscap` are build-time gates no documentation
- * author writes by hand. But it publishes the DEFINITION-MODULE spelling, not
- * the CLI spelling, which is why `caps-man/acl/access-list` exists there and is
+ * author writes by hand.
+ *
+ * It USED to publish the DEFINITION-MODULE spelling rather than the CLI
+ * spelling, which is why `caps-man/acl/access-list` was documented and yet
* unreachable on a device over `/console/inspect`, REST and the native API
- * alike (#228, five independent oracles).
+ * alike (#228, five independent oracles). MikroTik has since reshaped the
+ * publication from module pages into per-command leaf pages whose slug IS the
+ * CLI path, and those spellings are gone (#285). {@link ALIASES} is empty as a
+ * result — still asserted, still able to refuse the next one.
*
* So neither source is a superset and neither is "more accurate" outright.
* inspect is first-order about the **CLI surface**; CLI Reference is first-order
* about the **definition structs and their gates**. This table unions them and
* records, per entry, which one carried it.
*
- * Measured across 906 exactly-matching paths, three RouterOS versions and two
+ * Measured across 968 exactly-matching paths, three RouterOS versions and two
* architectures, the two sources have **zero kind contradictions** — they have
* never disagreed about whether something is a menu or a command. That is the
* assertion this generator aborts on, and the one worth watching.
*
+ * The publication does disagree with ITSELF on a narrower axis: seven paths are
+ * published twice on one page under different hardware gates, and two of those
+ * pairs split `Directory` against `Settings Directory` (`/system/health`,
+ * `/interface/ethernet/switch`). Both halves say the path is navigation, so the
+ * row records that and drops the refinement, exactly as a disagreed gate is
+ * dropped rather than picked. Only a navigation-vs-command split aborts.
+ *
* ## What is NOT here
*
* Arguments, flags, types, enums and descriptions. For a Directory the
@@ -79,66 +91,51 @@ import { CONTAINER_TYPES } from "./restraml-trees.ts";
export const MANUAL_BASE = "https://manual.mikrotik.com";
export const SITEMAP_URL = `${MANUAL_BASE}/sitemap.xml`;
+export const LLMS_TXT_URL = `${MANUAL_BASE}/llms.txt`;
export const CLI_PREFIX = "/docs/cli-reference/";
const CLI_SLUG = /^[a-z0-9-]+(?:\/[a-z0-9-]+)*$/;
+/**
+ * The section landing page: the argument-type glossary prose. It is listed in
+ * `llms.txt` like any other `.md`, but publishes no `**Type:**` entry, so it is
+ * not a CLI path and the loud-fail parser would (correctly) reject it.
+ */
+const SECTION_INDEX_SLUG = "index";
/**
* Published doc spelling -> real CLI spelling. HAND-AUDITED; the generator
* never rewrites a path on its own.
*
- * The naive rule ("drop an interior segment until something matches a tree") is
- * unsafe, and dangerously so: it maps `/interface/ethernet/poe/monitor` onto
- * `/interface/ethernet/monitor`, a different command with a disjoint field set.
- * `poe`, `qos`, `acl`, `controller` and `route` are real CLI menu segments, and
- * an unscoped allowlist of segment names to drop is exactly the defect filed as
- * tikoci/rosetta#136.
+ * **Empty, and correct as empty (#285).** MikroTik reshaped the CLI Reference
+ * from module pages — one page carrying many command headings, in the
+ * DEFINITION-MODULE spelling — into per-command leaf pages whose slug IS the
+ * CLI path. All twenty entries this list used to carry (fifteen `caps-man`
+ * definition modules plus `easymesh`, `serial-interface`, `ddns`, `ifaces`,
+ * `queues`) are gone from the source, and each one's target is now published
+ * directly under its real path. Re-audited against the corrected 1,070-page
+ * inventory: zero of the twenty sources survive, twenty of twenty targets are
+ * published outright.
*
- * Two assertions below keep this list honest, and generation ABORTS on either:
+ * The machinery stays, and stays asserted, because an allowlist that expects
+ * zero hits is exactly what should refuse the next one loudly. Generation
+ * ABORTS on any of these, and every abort wants a hand decision, not an entry:
*
+ * R0 stale source — an alias whose `from` is no longer published. That is what
+ * caught this reshape (`/caps-man/acl/access-list` vanished) rather than
+ * the table quietly keeping a dead rewrite.
* R1 prefix scoping — never drop segment `S` when the prefix ENDING at `S` is
- * itself a published entry. This alone rejects all nine unsafe candidates,
- * including the three rosetta gets wrong.
+ * itself a published entry. The naive rule ("drop an interior segment until
+ * something matches a tree") maps `/interface/ethernet/poe/monitor` onto
+ * `/interface/ethernet/monitor`, a different command with a disjoint field
+ * set; `poe`, `qos`, `acl`, `controller` and `route` are real CLI menu
+ * segments. An unscoped allowlist of segment names is the defect filed as
+ * tikoci/rosetta#136 and fixed in tikoci/rosetta#138.
* R2 field overlap — where both sides publish argument names, they must
- * overlap. This is the offline proof that the two spellings are the same
+ * overlap. This is the offline proof that two spellings are the same
* command rather than a coincidental name collision.
*
- * Every entry here was produced by running both rules over the naive candidate
- * set and keeping the survivors, then reading them. Fifteen are the `caps-man`
- * definition modules (`acl`, `cfg`, `chancfg`, `dpathcfg`, `ifaceactual`,
- * `ratescfg`, `remoteap`, `seccfg`, `sta`, `rule`, `controller`); the rest are
- * the same shape elsewhere.
+ * A new hit is a signal to read the source, not to normalize it away.
*/
-export const ALIASES: ReadonlyMap = new Map([
- ["/caps-man/acl/access-list", "/caps-man/access-list"],
- ["/caps-man/cfg/configuration", "/caps-man/configuration"],
- ["/caps-man/chancfg/channel", "/caps-man/channel"],
- ["/caps-man/controller/manager", "/caps-man/manager"],
- ["/caps-man/controller/manager/interface", "/caps-man/manager/interface"],
- ["/caps-man/dpathcfg/datapath", "/caps-man/datapath"],
- [
- "/caps-man/ifaceactual/actual-interface-configuration",
- "/caps-man/actual-interface-configuration",
- ],
- ["/caps-man/ratescfg/rates", "/caps-man/rates"],
- ["/caps-man/remoteap/remote-cap", "/caps-man/remote-cap"],
- ["/caps-man/remoteap/remote-cap/provision", "/caps-man/remote-cap/provision"],
- [
- "/caps-man/remoteap/remote-cap/set-identity",
- "/caps-man/remote-cap/set-identity",
- ],
- ["/caps-man/remoteap/remote-cap/upgrade", "/caps-man/remote-cap/upgrade"],
- ["/caps-man/rule/provisioning", "/caps-man/provisioning"],
- ["/caps-man/seccfg/security", "/caps-man/security"],
- ["/caps-man/sta/registration-table", "/caps-man/registration-table"],
- [
- "/interface/wifi/easymesh/wps-push-button",
- "/interface/wifi/wps-push-button",
- ],
- ["/system/serial-interface/serial-terminal", "/system/serial-terminal"],
- ["/tool/ddns/dns-update", "/tool/dns-update"],
- ["/tool/graphing/ifaces/interface", "/tool/graphing/interface"],
- ["/tool/graphing/queues/queue", "/tool/graphing/queue"],
-]);
+export const ALIASES: ReadonlyMap = new Map();
// ---------------------------------------------------------------------------
// CLI-Reference ETL (vendored; see the header)
@@ -175,24 +172,122 @@ function decodeXml(text: string): string {
.replace(/&/g, "&");
}
-/** Every CLI-Reference slug the sitemap lists, sorted and de-duplicated. */
-export function cliRefSlugs(sitemapXml: string): string[] {
+/** The CLI-Reference path a URL names, or `null` when it names something else. */
+function cliRefPath(location: string): string | null {
+ let path: string;
+ try {
+ path = new URL(location).pathname;
+ } catch {
+ return null;
+ }
+ return path.startsWith(CLI_PREFIX) ? path : null;
+}
+
+/** The slug a CLI-Reference page path names, or `null` when it is not a page. */
+function pageSlug(path: string): string | null {
+ if (path.endsWith("/")) return null;
+ const slug = path.slice(CLI_PREFIX.length).replace(/\.md$/, "");
+ if (slug === "" || slug === SECTION_INDEX_SLUG || !CLI_SLUG.test(slug))
+ return null;
+ return slug;
+}
+
+/** Every directly-addressable CLI-Reference page the sitemap lists. */
+export function sitemapSlugs(sitemapXml: string): string[] {
const slugs = new Set();
for (const match of sitemapXml.matchAll(/\s*([\s\S]*?)\s*<\/loc>/g)) {
- const location = decodeXml(match[1] ?? "");
- let path: string;
- try {
- path = new URL(location).pathname;
- } catch {
- continue;
- }
- if (!path.startsWith(CLI_PREFIX) || path.endsWith("/")) continue;
- const slug = path.slice(CLI_PREFIX.length);
- if (slug !== "" && CLI_SLUG.test(slug)) slugs.add(slug);
+ const path = cliRefPath(decodeXml(match[1] ?? ""));
+ const slug = path === null ? null : pageSlug(path);
+ if (slug !== null) slugs.add(slug);
+ }
+ return [...slugs].sort();
+}
+
+/**
+ * Every BRANCHING menu the sitemap serves as a trailing-slash category URL, as
+ * the slug of the page that carries its own entry: `app/` -> `app/app`.
+ *
+ * The section root itself (`/docs/cli-reference/`) is not a menu and is skipped.
+ */
+export function categoryLeafSlugs(sitemapXml: string): string[] {
+ const slugs = new Set();
+ for (const match of sitemapXml.matchAll(/\s*([\s\S]*?)\s*<\/loc>/g)) {
+ const path = cliRefPath(decodeXml(match[1] ?? ""));
+ if (path === null || !path.endsWith("/")) continue;
+ const dir = path.slice(CLI_PREFIX.length).replace(/\/$/, "");
+ if (dir === "" || !CLI_SLUG.test(dir)) continue;
+ slugs.add(`${dir}/${dir.split("/").pop()}`);
}
return [...slugs].sort();
}
+/** Every CLI-Reference page `llms.txt` lists. */
+export function llmsSlugs(llmsTxt: string): string[] {
+ const slugs = new Set();
+ for (const match of llmsTxt.matchAll(/^-\s*\[[^\]]*\]\(([^)\s]+)\)/gm)) {
+ const path = cliRefPath(match[1] ?? "");
+ const slug = path === null ? null : pageSlug(path);
+ if (slug !== null) slugs.add(slug);
+ }
+ return [...slugs].sort();
+}
+
+/**
+ * The page inventory: the union of the two published inventories, or abort.
+ *
+ * The sitemap alone is NOT the inventory, and its shortfall is silent. A menu
+ * that BRANCHES is served as a trailing-slash category URL with no `.md` of its
+ * own — but the menu's own `Directory` entry is published, at
+ * `/.md` (`app/` -> `app/app.md`), and listed only in
+ * `llms.txt`. Sitemap-only discovery therefore dropped 256 of 1,070 pages, and
+ * the field-heavy quarter of them: `app/app` carries 35 argument rows,
+ * `caps-man/interface/interface` 79 (tikoci/rosetta#137, centrs #285).
+ *
+ * `.md`, `/index.md` and `/.md` all 404, which is what made this
+ * read as a navigation stub for months. A 404 on a probe is evidence about the
+ * probe, not about the page.
+ *
+ * The two inventories reconcile exactly, and this asserts it rather than
+ * trusting either one:
+ *
+ * sitemap pages 814 ∪ derived / 256 = 1,070 = llms.txt pages
+ *
+ * The category-leaf reconciliation is the ABORT, because a category dir whose
+ * leaf no inventory carries is a menu entry being dropped — the #285 defect
+ * itself, and invisible in the output. A page only one inventory lists is
+ * reported instead of refused: the union already carries it, so nothing is
+ * dropped, and `--check` fails on the new row anyway.
+ */
+export function discoverSlugs(
+ sitemapXml: string,
+ llmsTxt: string,
+ report: (message: string) => void = console.warn,
+): string[] {
+ const sitemap = new Set(sitemapSlugs(sitemapXml));
+ const llms = new Set(llmsSlugs(llmsTxt));
+ const inventory = new Set([...sitemap, ...llms]);
+
+ const orphans = categoryLeafSlugs(sitemapXml).filter(
+ (slug) => !inventory.has(slug),
+ );
+ if (orphans.length > 0)
+ throw new Error(
+ `${orphans.length} sitemap category dir(s) contribute no / leaf to the\n` +
+ "discovered inventory (sitemap ∪ llms.txt), so those menus' own entries would be\n" +
+ "dropped silently — the #285 defect. Re-verify the inventory by hand:\n " +
+ orphans.join("\n "),
+ );
+
+ const sitemapOnly = [...sitemap].filter((slug) => !llms.has(slug));
+ if (sitemapOnly.length > 0)
+ report(
+ `${sitemapOnly.length} page(s) are in the sitemap but not in llms.txt — the two ` +
+ `published inventories no longer agree in shape: ${sitemapOnly.join(", ")}`,
+ );
+
+ return [...inventory].sort();
+}
+
/**
* Drop the Docusaurus preamble: the H1 title and the `import {…}` lines. The
* page body starts after the last import, which is what makes every remaining
@@ -430,10 +525,13 @@ export function droppedSegments(from: string, to: string): number[] {
* Fold the published entries onto their CLI paths, applying the alias allowlist
* and asserting it.
*
- * Four paths are published twice on the same page (`/interface/ethernet/switch`
- * variants). Same kind is required; a gate is kept only where every occurrence
- * agrees, because one ungated occurrence means the path is not uniformly gated
- * and claiming a gate would over-state the publication.
+ * Seven paths are published twice, each on one page, under different hardware
+ * gates — `/interface/ethernet/switch` and its four children as `musicswitch`
+ * and `rbswitch`, `/system/health` as `!i386` and `health`. One entry per page
+ * is therefore NOT an invariant, and a key on path would reject real source. A
+ * gate is kept only where every occurrence states it, because one ungated
+ * occurrence means the path is not uniformly gated and claiming the gate would
+ * over-state the publication.
*/
function foldPublished(
entries: readonly PublishedEntry[],
@@ -559,13 +657,21 @@ export function build(
const kinds = new Set(
occurrences.map((entry) => PUBLISHED_KINDS[entry.kind]),
);
- if (kinds.size > 1) {
+ // Occurrences that disagree about navigation-vs-executable are a real
+ // contradiction. Occurrences that disagree only about `menu` vs `settings`
+ // are not: both say the path is navigation, and they differ on whether THIS
+ // hardware's menu holds a single record — `/system/health` is a settings
+ // menu on `!i386` and a directory under the `health` syscap, on one page.
+ // Record what they agree on, exactly as a disagreed gate is dropped rather
+ // than picked. Nothing downstream distinguishes the two kinds anyway
+ // (`isKnownMenu` accepts either), so the weaker claim costs nothing.
+ if (kinds.size > 1 && kinds.has("command")) {
contradictions.push(
`${path}: published as ${[...kinds].sort().join(" and ")} on ${occurrences.map((e) => e.slug).join(", ")}`,
);
continue;
}
- const kind = [...kinds][0] ?? "menu";
+ const kind: PathKind = kinds.size > 1 ? "menu" : ([...kinds][0] ?? "menu");
const treeType = treeTypes.get(path);
if (treeType !== undefined && !agrees(kind, treeType)) {
contradictions.push(
@@ -706,9 +812,9 @@ export function render(rows: readonly CatalogRow[], counts: Counts): string {
* Union of two first-order sources (#228):
*
* 1. MikroTik's published CLI Reference (\`${MANUAL_BASE}${CLI_PREFIX}\`),
- * ${counts.pages} pages, ${counts.publishedEntries.toLocaleString("en-US")} entries — first-order about the definition
- * structs and their build-time gates, but published in the definition-module
- * spelling rather than the CLI spelling.
+ * ${counts.pages.toLocaleString("en-US")} pages, ${counts.publishedEntries.toLocaleString("en-US")} entries — first-order about the definition
+ * structs and their build-time gates. Since #285 every page is a leaf whose
+ * slug is the CLI path, so no spelling has to be rewritten to be looked up.
* 2. Four pinned restraml \`/console/inspect\` trees
* (\`https://tikoci.github.io/restraml/\`) — first-order about the CLI surface:
*
@@ -722,8 +828,11 @@ ${byKind("menu")}
${byKind("command")}
${byKind("settings")}
*
- * Zero kind contradictions between the two sources. Generation aborts if that
- * ever stops holding, rather than picking a winner.
+ * Zero navigation-vs-command contradictions between the two sources. Generation
+ * aborts if that ever stops holding, rather than picking a winner. Where a path
+ * is published twice as a container under different hardware gates — the seven
+ * \`/interface/ethernet/switch\` and \`/system/health\` variants — the row records
+ * what the occurrences agree on.
*
* **This table is not a schema.** It says what a path IS — navigation, an
* executable command, or a settings menu — and never what a command accepts.
@@ -736,8 +845,9 @@ ${byKind("settings")}
*
* **Gates conjoin down a path, so read them with {@link effectiveGates}, not
* row by row.** A row states only what the publication stated at that entry.
- * Row-wise, ${ungatedRows} published-only paths look ungated; ancestry-aware,
- * only ${residueRows} carry no published explanation for their absence at all.
+ * Read row-wise, ${ungatedRows} published-only paths look ungated; read with
+ * ancestry, the residue carrying no published explanation for its absence at
+ * all is ${residueRows}.
*
* Tree COMMANDS are not enumerated — those are the generic CRUD leaves
* \`verbs.ts\` already owns. The command rows here are the published,
@@ -887,10 +997,11 @@ export interface CatalogGate {
* parent's \`syscap\` applies even though the child entry states none: the gates
* up a path CONJOIN, they do not override.
*
- * Read row-wise instead, ${ungatedRows} published-only paths look ungated, and
- * #228's finding — that a published-only path almost always explains its own
- * absence — would read as false. Ancestry-aware the residue is ${residueRows},
- * and those are the only published paths carrying no explanation at all.
+ * Read row-wise, ${ungatedRows} published-only paths look ungated; read with
+ * ancestry the residue is ${residueRows}. The gap between the two is the
+ * conjunction, and it is what a caller has to reproduce: a child's silence about
+ * a gate is not the absence of one. #228's finding — that a published-only path
+ * almost always explains its own absence — is about the ancestry-aware number.
*
* Still not a claim about any router. This says what MikroTik published about
* applicability; only a live device knows what it has.
diff --git a/scripts/gen-explain-catalog.ts b/scripts/gen-explain-catalog.ts
index bb5f714..1c8e180 100644
--- a/scripts/gen-explain-catalog.ts
+++ b/scripts/gen-explain-catalog.ts
@@ -17,8 +17,8 @@
* bun run explain:catalog --cache=DIR # reuse/populate a local page cache
*
* `--check` needs network (CLI Reference plus the pinned trees), so it hangs off
- * the QA workflow rather than the offline `lint:ci` gate. A full run is ~230
- * small page fetches plus 15 MB of trees, about 15 seconds.
+ * the QA workflow rather than the offline `lint:ci` gate. A full run is ~1,070
+ * small page fetches plus 15 MB of trees, about a minute.
*/
import { mkdirSync, readFileSync, writeFileSync } from "node:fs";
@@ -26,9 +26,10 @@ import { isAbsolute, join, relative, resolve } from "node:path";
import {
build,
CLI_PREFIX,
- cliRefSlugs,
countRowMarkers,
countTypeMarkers,
+ discoverSlugs,
+ LLMS_TXT_URL,
MANUAL_BASE,
parsePage,
render,
@@ -110,8 +111,14 @@ async function mapConcurrent(
// Main
// ---------------------------------------------------------------------------
-const sitemapXml = await fetchTextWithRetry(SITEMAP_URL);
-const slugs = cliRefSlugs(sitemapXml);
+// Both inventories, because neither alone is the page set: the sitemap serves a
+// branching menu as a category URL with no `.md`, and only `llms.txt` lists the
+// leaf that carries that menu's own entry (#285).
+const [sitemapXml, llmsTxt] = await Promise.all([
+ fetchTextWithRetry(SITEMAP_URL),
+ fetchTextWithRetry(LLMS_TXT_URL),
+]);
+const slugs = discoverSlugs(sitemapXml, llmsTxt);
console.log(`cli-reference: ${slugs.length} pages`);
let fetched = 0;
diff --git a/src/explain/catalog.ts b/src/explain/catalog.ts
index ae9a02c..23c4e92 100644
--- a/src/explain/catalog.ts
+++ b/src/explain/catalog.ts
@@ -9,9 +9,9 @@
* Union of two first-order sources (#228):
*
* 1. MikroTik's published CLI Reference (`https://manual.mikrotik.com/docs/cli-reference/`),
- * 228 pages, 1,051 entries — first-order about the definition
- * structs and their build-time gates, but published in the definition-module
- * spelling rather than the CLI spelling.
+ * 1,070 pages, 1,077 entries — first-order about the definition
+ * structs and their build-time gates. Since #285 every page is a leaf whose
+ * slug is the CLI path, so no spelling has to be rewritten to be looked up.
* 2. Four pinned restraml `/console/inspect` trees
* (`https://tikoci.github.io/restraml/`) — first-order about the CLI surface:
*
@@ -24,12 +24,15 @@
*
* | Kind | Total | `both` | `inspect` | `published` |
* | ---- | ----- | ------ | --------- | ----------- |
- * | `menu` | 563 | 436 | 73 | 54 |
- * | `command` | 439 | 383 | 0 | 56 |
- * | `settings` | 117 | 106 | 0 | 11 |
+ * | `menu` | 558 | 454 | 54 | 50 |
+ * | `command` | 450 | 407 | 0 | 43 |
+ * | `settings` | 116 | 107 | 0 | 9 |
*
- * Zero kind contradictions between the two sources. Generation aborts if that
- * ever stops holding, rather than picking a winner.
+ * Zero navigation-vs-command contradictions between the two sources. Generation
+ * aborts if that ever stops holding, rather than picking a winner. Where a path
+ * is published twice as a container under different hardware gates — the seven
+ * `/interface/ethernet/switch` and `/system/health` variants — the row records
+ * what the occurrences agree on.
*
* **This table is not a schema.** It says what a path IS — navigation, an
* executable command, or a settings menu — and never what a command accepts.
@@ -42,8 +45,9 @@
*
* **Gates conjoin down a path, so read them with {@link effectiveGates}, not
* row by row.** A row states only what the publication stated at that entry.
- * Row-wise, 46 published-only paths look ungated; ancestry-aware,
- * only 7 carry no published explanation for their absence at all.
+ * Read row-wise, 2 published-only paths look ungated; read with
+ * ancestry, the residue carrying no published explanation for its absence at
+ * all is 1.
*
* Tree COMMANDS are not enumerated — those are the generic CRUD leaves
* `verbs.ts` already owns. The command rows here are the published,
@@ -83,18 +87,18 @@ export interface CatalogEntry {
* `path|kind|provenance|package|conditions|syscap` — with trailing empty
* columns trimmed. Lower-cased, slash-led and sorted by path.
*
- * Text rather than ~1,119 object literals so that one path is one line in a
+ * Text rather than ~1,124 object literals so that one path is one line in a
* review diff, and so the formatter has nothing to re-wrap.
*/
const ROWS = `
/app|menu|both|container||app
-/app/cleanup|command|both|container
-/app/network|menu|both|container
-/app/remove|command|both|container
-/app/restart|command|both|container
-/app/settings|settings|both|container
-/app/setup|command|both|container
-/app/update|command|both|container
+/app/cleanup|command|both|container||app
+/app/network|menu|both|container||app
+/app/remove|command|both|container||app
+/app/restart|command|both|container||app
+/app/settings|settings|both|container||app
+/app/setup|command|both|container||app
+/app/update|command|both|container||app
/beep|command|both
/blink|command|both||!i386
/caps-man|menu|inspect
@@ -123,6 +127,7 @@ const ROWS = `
/caps-man/remote-cap/upgrade|command|both|wireless-rep
/caps-man/security|menu|both|wireless-rep
/certificate|menu|both
+/certificate/acme-renew|command|both
/certificate/add-acme|command|both
/certificate/add-scep|command|both
/certificate/builtin|menu|both
@@ -147,8 +152,6 @@ const ROWS = `
/certificate/settings|settings|both
/certificate/sign|command|both
/certificate/sign-certificate-request|command|both
-/colon/root|menu|published||CONSOLE_DEBUG
-/colon/root/terminal|menu|published
/console|menu|both
/console/inspect|command|both
/console/settings|settings|both
@@ -161,6 +164,7 @@ const ROWS = `
/container/mounts|menu|both|container
/container/repull|command|both|container
/container/restart|command|both|container
+/container/save|command|both|container
/container/start|command|both|container
/container/stop|command|both|container
/container/update|command|both|container
@@ -168,15 +172,15 @@ const ROWS = `
/disk/blink|command|both||!smips
/disk/btrfs|menu|both||!smips|storage
/disk/btrfs/filesystem|menu|both||!smips|storage
-/disk/btrfs/filesystem/add-device|command|both||!smips
-/disk/btrfs/filesystem/balance-cancel|command|both||!smips
-/disk/btrfs/filesystem/balance-start|command|both||!smips
-/disk/btrfs/filesystem/remove-device|command|both||!smips
-/disk/btrfs/filesystem/replace-cancel|command|both||!smips
-/disk/btrfs/filesystem/replace-device|command|both||!smips
-/disk/btrfs/filesystem/reset-counters|command|both||!smips
-/disk/btrfs/filesystem/scrub-cancel|command|both||!smips
-/disk/btrfs/filesystem/scrub-start|command|both||!smips
+/disk/btrfs/filesystem/add-device|command|both||!smips|storage
+/disk/btrfs/filesystem/balance-cancel|command|both||!smips|storage
+/disk/btrfs/filesystem/balance-start|command|both||!smips|storage
+/disk/btrfs/filesystem/remove-device|command|both||!smips|storage
+/disk/btrfs/filesystem/replace-cancel|command|both||!smips|storage
+/disk/btrfs/filesystem/replace-device|command|both||!smips|storage
+/disk/btrfs/filesystem/reset-counters|command|both||!smips|storage
+/disk/btrfs/filesystem/scrub-cancel|command|both||!smips|storage
+/disk/btrfs/filesystem/scrub-start|command|both||!smips|storage
/disk/btrfs/subvolume|menu|both||!smips|storage
/disk/btrfs/transfer|menu|both||!smips|storage
/disk/check|command|both||!smips|storage
@@ -220,13 +224,18 @@ const ROWS = `
/dude/settings|settings|both|dude
/dude/vacuum-db|command|both|dude
/environment|menu|both
-/file|menu|inspect
+/file|menu|both
+/file/copy|command|both
+/file/head|command|both
+/file/read|command|both
/file/rsync-daemon|settings|both|rose-storage
/file/sync|menu|both|rose-storage
/file/sync/monitor|command|both|rose-storage
+/file/tail|command|both
/import|command|both
-/interface|menu|inspect
-/interface/6to4|menu|both|ipv6
+/interface|menu|both
+/interface/6to4|menu|both
+/interface/blink|command|both
/interface/bonding|menu|both
/interface/bonding/monitor|command|both
/interface/bonding/monitor-slaves|command|both
@@ -270,76 +279,73 @@ const ROWS = `
/interface/dot1x/server/active|menu|both||!smips
/interface/dot1x/server/state|menu|both||!smips
/interface/eoip|menu|both
-/interface/eoipv6|menu|both|ipv6
+/interface/eoipv6|menu|both
/interface/ethernet|menu|both||i386
/interface/ethernet/blink|command|both
/interface/ethernet/cable-test|command|both
/interface/ethernet/monitor|command|both||i386
/interface/ethernet/poe|menu|published|||(poe or poe-in)
-/interface/ethernet/poe/monitor|command|published
-/interface/ethernet/poe/power-cycle|command|published|||poe
-/interface/ethernet/poe/settings|settings|published|||poesettings
+/interface/ethernet/poe/monitor|command|published|||(poe or poe-in)
+/interface/ethernet/poe/power-cycle|command|published|||(poe or poe-in)
+/interface/ethernet/poe/settings|settings|published|||(poe or poe-in) and poesettings
/interface/ethernet/reset-counters|command|both
/interface/ethernet/reset-mac-address|command|both
-/interface/ethernet/switch|menu|both||!smips|multiswitch
-/interface/ethernet/switch/acl|menu|published
-/interface/ethernet/switch/acl/policer|menu|published
-/interface/ethernet/switch/dscp-qos-map|menu|published
-/interface/ethernet/switch/dscp-to-dscp|menu|published
-/interface/ethernet/switch/egress-vlan-tag|menu|published
-/interface/ethernet/switch/egress-vlan-translation|menu|published
-/interface/ethernet/switch/host|menu|both|||oldswitch
-/interface/ethernet/switch/ingress-port-policer|menu|published
-/interface/ethernet/switch/ingress-vlan-translation|menu|published
-/interface/ethernet/switch/l3hw-settings|settings|published|||crs_prestera
-/interface/ethernet/switch/l3hw-settings/advanced|settings|published|||crs_prestera
-/interface/ethernet/switch/l3hw-settings/advanced/monitor|command|published
-/interface/ethernet/switch/l3hw-settings/monitor|command|published
-/interface/ethernet/switch/mac-based-vlan|menu|published
-/interface/ethernet/switch/multicast-fdb|menu|published
-/interface/ethernet/switch/one2one-vlan-switching|menu|published
-/interface/ethernet/switch/policer-qos-map|menu|published
+/interface/ethernet/switch|menu|both
+/interface/ethernet/switch/acl|menu|published|||musicswitch
+/interface/ethernet/switch/acl/policer|menu|published|||musicswitch
+/interface/ethernet/switch/dscp-qos-map|menu|published|||musicswitch
+/interface/ethernet/switch/dscp-to-dscp|menu|published|||musicswitch
+/interface/ethernet/switch/egress-vlan-tag|menu|published|||musicswitch
+/interface/ethernet/switch/egress-vlan-translation|menu|published|||musicswitch
+/interface/ethernet/switch/host|menu|both|||rbswitch and oldswitch
+/interface/ethernet/switch/ingress-port-policer|menu|published|||musicswitch
+/interface/ethernet/switch/ingress-vlan-translation|menu|published|||musicswitch
+/interface/ethernet/switch/l3hw-settings|settings|published|||rbswitch and crs_prestera
+/interface/ethernet/switch/l3hw-settings/advanced|settings|published|||rbswitch and crs_prestera
+/interface/ethernet/switch/l3hw-settings/advanced/monitor|command|published|||rbswitch and crs_prestera
+/interface/ethernet/switch/l3hw-settings/monitor|command|published|||rbswitch and crs_prestera
+/interface/ethernet/switch/mac-based-vlan|menu|published|||musicswitch
+/interface/ethernet/switch/multicast-fdb|menu|published|||musicswitch
+/interface/ethernet/switch/one2one-vlan-switching|menu|published|||musicswitch
+/interface/ethernet/switch/policer-qos-map|menu|published|||musicswitch
/interface/ethernet/switch/port|menu|both
/interface/ethernet/switch/port-isolation|menu|both
-/interface/ethernet/switch/port-leakage|menu|published
+/interface/ethernet/switch/port-leakage|menu|published|||musicswitch
/interface/ethernet/switch/port/reset-counters|command|both
-/interface/ethernet/switch/prbs/reset-prbs|command|published
-/interface/ethernet/switch/prbs/start-prbs|command|published
-/interface/ethernet/switch/prbs/stop-prbs|command|published
-/interface/ethernet/switch/protocol-based-vlan|menu|published
-/interface/ethernet/switch/qos|menu|published|||crs_prestera
-/interface/ethernet/switch/qos-group|menu|published
-/interface/ethernet/switch/qos/map|menu|published|||crs_prestera
-/interface/ethernet/switch/qos/map/ip|menu|published|||crs_prestera
-/interface/ethernet/switch/qos/map/vlan|menu|published|||crs_prestera
-/interface/ethernet/switch/qos/monitor|command|published
-/interface/ethernet/switch/qos/port|menu|published
-/interface/ethernet/switch/qos/port/reset-counters|command|published
-/interface/ethernet/switch/qos/priority-flow-control|menu|published|||!prestera-ac3
-/interface/ethernet/switch/qos/profile|menu|published|||crs_prestera
-/interface/ethernet/switch/qos/settings|settings|published
-/interface/ethernet/switch/qos/tx-manager|menu|published|||crs_prestera
-/interface/ethernet/switch/qos/tx-manager/queue|menu|published|||crs_prestera
-/interface/ethernet/switch/reserved-fdb|menu|published
-/interface/ethernet/switch/reset-counters|command|both||!smips
-/interface/ethernet/switch/rule|menu|both
-/interface/ethernet/switch/shaper|menu|published
-/interface/ethernet/switch/stats|settings|published||!smips
-/interface/ethernet/switch/trunk|menu|published
-/interface/ethernet/switch/unicast-fdb|menu|published
-/interface/ethernet/switch/unicast-fdb/flush|command|published
+/interface/ethernet/switch/protocol-based-vlan|menu|published|||musicswitch
+/interface/ethernet/switch/qos|menu|published|||rbswitch and crs_prestera
+/interface/ethernet/switch/qos-group|menu|published|||musicswitch
+/interface/ethernet/switch/qos/map|menu|published|||rbswitch and crs_prestera
+/interface/ethernet/switch/qos/map/ip|menu|published|||rbswitch and crs_prestera
+/interface/ethernet/switch/qos/map/vlan|menu|published|||rbswitch and crs_prestera
+/interface/ethernet/switch/qos/monitor|command|published|||rbswitch and crs_prestera
+/interface/ethernet/switch/qos/port|menu|published|||rbswitch and crs_prestera
+/interface/ethernet/switch/qos/port/reset-counters|command|published|||rbswitch and crs_prestera
+/interface/ethernet/switch/qos/priority-flow-control|menu|published|||rbswitch and crs_prestera and !prestera-ac3
+/interface/ethernet/switch/qos/profile|menu|published|||rbswitch and crs_prestera
+/interface/ethernet/switch/qos/settings|settings|published|||rbswitch and crs_prestera
+/interface/ethernet/switch/qos/tx-manager|menu|published|||rbswitch and crs_prestera
+/interface/ethernet/switch/qos/tx-manager/queue|menu|published|||rbswitch and crs_prestera
+/interface/ethernet/switch/reserved-fdb|menu|published|||musicswitch
+/interface/ethernet/switch/reset-counters|command|both
+/interface/ethernet/switch/rule|menu|both|||rbswitch
+/interface/ethernet/switch/shaper|menu|published|||musicswitch
+/interface/ethernet/switch/stats|settings|published||!smips|musicswitch
+/interface/ethernet/switch/trunk|menu|published|||musicswitch
+/interface/ethernet/switch/unicast-fdb|menu|published|||musicswitch
+/interface/ethernet/switch/unicast-fdb/flush|command|published|||musicswitch
/interface/ethernet/switch/vlan|menu|both
/interface/gre|menu|both
-/interface/gre6|menu|both|ipv6
+/interface/gre6|menu|both
/interface/ipip|menu|both
-/interface/ipipv6|menu|both|ipv6
-/interface/l2tp-client|menu|both|ppp
-/interface/l2tp-client/monitor|command|both|ppp
-/interface/l2tp-ether|menu|both|ppp
-/interface/l2tp-ether/monitor|command|both|ppp
-/interface/l2tp-server|menu|both|ppp
-/interface/l2tp-server/monitor|command|both|ppp
-/interface/l2tp-server/server|settings|both|ppp
+/interface/ipipv6|menu|both
+/interface/l2tp-client|menu|both
+/interface/l2tp-client/monitor|command|both
+/interface/l2tp-ether|menu|both
+/interface/l2tp-ether/monitor|command|both
+/interface/l2tp-server|menu|both
+/interface/l2tp-server/monitor|command|both
+/interface/l2tp-server/server|settings|both
/interface/list|menu|both
/interface/list/member|menu|both
/interface/lte|menu|both||!smips
@@ -357,6 +363,7 @@ const ROWS = `
/interface/lte/esim/set-nickname|command|both||!smips
/interface/lte/firmware-upgrade|command|both||!smips
/interface/lte/monitor|command|both||!smips
+/interface/lte/run-modem-update|command|published||!smips
/interface/lte/scan|command|both||!smips
/interface/lte/settings|settings|both||!smips, !i386, !mips, !powerpc
/interface/lte/show-capabilities|command|both||!smips
@@ -368,46 +375,48 @@ const ROWS = `
/interface/mesh/fdb|menu|both
/interface/mesh/port|menu|both
/interface/mesh/traceroute|command|both
-/interface/ovpn-client|menu|both|ppp
-/interface/ovpn-client/import-ovpn-configuration|command|both|ppp
-/interface/ovpn-client/monitor|command|both|ppp
-/interface/ovpn-server|menu|both|ppp
-/interface/ovpn-server/monitor|command|both|ppp
-/interface/ovpn-server/server|menu|both|ppp
-/interface/ovpn-server/server/export-client-configuration|command|both|ppp
-/interface/ppp-client|menu|both|ppp
-/interface/ppp-client/at-chat|command|both|ppp
-/interface/ppp-client/firmware-upgrade|command|both|ppp
-/interface/ppp-client/info|command|both|ppp
-/interface/ppp-client/monitor|command|both|ppp
-/interface/ppp-client/scan|command|both|ppp
-/interface/ppp-server|menu|both|ppp
-/interface/ppp-server/monitor|command|both|ppp
-/interface/pppoe-client|menu|both|ppp
-/interface/pppoe-client/monitor|command|both|ppp
-/interface/pppoe-client/scan|command|both|ppp
-/interface/pppoe-server|menu|both|ppp
-/interface/pppoe-server/monitor|command|both|ppp
-/interface/pppoe-server/server|menu|both|ppp
-/interface/pptp-client|menu|both|ppp
-/interface/pptp-client/monitor|command|both|ppp
-/interface/pptp-server|menu|both|ppp
-/interface/pptp-server/monitor|command|both|ppp
-/interface/pptp-server/server|settings|both|ppp
-/interface/pwr-link/pwr-line|menu|published|||pwrlink
-/interface/pwr-link/pwr-line/blink|command|published
-/interface/pwr-link/pwr-line/configure|command|published
-/interface/pwr-link/pwr-line/join|command|published
-/interface/pwr-link/pwr-line/leave|command|published
-/interface/pwr-link/pwr-line/monitor|command|published
-/interface/pwr-link/pwr-line/reset-counters|command|published
-/interface/pwr-link/pwr-line/reset-mac-address|command|published
-/interface/pwr-link/pwr-line/upgrade-firmware|command|published
-/interface/sstp-client|menu|both|ppp
-/interface/sstp-client/monitor|command|both|ppp
-/interface/sstp-server|menu|both|ppp
-/interface/sstp-server/monitor|command|both|ppp
-/interface/sstp-server/server|settings|both|ppp
+/interface/monitor-traffic|command|both
+/interface/ovpn-client|menu|both
+/interface/ovpn-client/import-ovpn-configuration|command|both
+/interface/ovpn-client/monitor|command|both
+/interface/ovpn-server|menu|both
+/interface/ovpn-server/monitor|command|both
+/interface/ovpn-server/server|menu|both
+/interface/ovpn-server/server/export-client-configuration|command|both
+/interface/ppp-client|menu|both
+/interface/ppp-client/at-chat|command|both
+/interface/ppp-client/firmware-upgrade|command|both
+/interface/ppp-client/info|command|both
+/interface/ppp-client/monitor|command|both
+/interface/ppp-client/scan|command|both
+/interface/ppp-server|menu|both
+/interface/ppp-server/monitor|command|both
+/interface/pppoe-client|menu|both
+/interface/pppoe-client/monitor|command|both
+/interface/pppoe-client/scan|command|both
+/interface/pppoe-server|menu|both
+/interface/pppoe-server/monitor|command|both
+/interface/pppoe-server/server|menu|both
+/interface/pptp-client|menu|both
+/interface/pptp-client/monitor|command|both
+/interface/pptp-server|menu|both
+/interface/pptp-server/monitor|command|both
+/interface/pptp-server/server|settings|both
+/interface/pwr-line|menu|published|||pwrlink
+/interface/pwr-line/blink|command|published|||pwrlink
+/interface/pwr-line/configure|command|published|||pwrlink
+/interface/pwr-line/join|command|published|||pwrlink
+/interface/pwr-line/leave|command|published|||pwrlink
+/interface/pwr-line/monitor|command|published|||pwrlink
+/interface/pwr-line/reset-counters|command|published|||pwrlink
+/interface/pwr-line/reset-mac-address|command|published|||pwrlink
+/interface/pwr-line/upgrade-firmware|command|published|||pwrlink
+/interface/reset-counters|command|both
+/interface/sstp-client|menu|both
+/interface/sstp-client/monitor|command|both
+/interface/sstp-server|menu|both
+/interface/sstp-server/monitor|command|both
+/interface/sstp-server/server|settings|both
/interface/veth|menu|both||!smips|container
/interface/vlan|menu|both||!smips
/interface/vpls|menu|both||!smips
@@ -417,59 +426,50 @@ const ROWS = `
/interface/vxlan/fdb|menu|both
/interface/vxlan/vteps|menu|both
/interface/w60g|menu|published|wireless-rep||60ghz
-/interface/w60g/align|command|published|wireless-rep
-/interface/w60g/monitor|command|published|wireless-rep
-/interface/w60g/reset-configuration|command|published|wireless-rep
-/interface/w60g/scan|command|published|wireless-rep
-/interface/w60g/station|menu|published|wireless-rep
-/interface/w60g/station/monitor|command|published|wireless-rep
-/interface/wifi|menu|both|wireless-qca
-/interface/wifi/aaa|menu|both|wireless-qca
-/interface/wifi/access-list|menu|both|wireless-qca
-/interface/wifi/cap|settings|both|wireless-qca
-/interface/wifi/capsman|settings|both|wireless-qca
-/interface/wifi/capsman/remote-cap|menu|both|wireless-qca
-/interface/wifi/capsman/remote-cap/provision|command|both|wireless-qca
-/interface/wifi/capsman/remote-cap/set-identity|command|both|wireless-qca
-/interface/wifi/capsman/remote-cap/upgrade|command|both|wireless-qca
-/interface/wifi/channel|menu|both|wireless-qca
-/interface/wifi/configuration|menu|both|wireless-qca
-/interface/wifi/dashboard/settings|settings|published|wireless-qca
-/interface/wifi/dashboard/show|command|published|wireless-qca
-/interface/wifi/dashboard/show-clients|command|published|wireless-qca
-/interface/wifi/dashboard/show-logs|command|published|wireless-qca
-/interface/wifi/datapath|menu|both|wireless-qca
-/interface/wifi/devel|command|both|wireless-qca
-/interface/wifi/dpp-bootstrap-info/gen-qr-code|command|published|wireless-qca
-/interface/wifi/easymesh/bss|menu|published|wireless-qca
-/interface/wifi/easymesh/dpp-trigger|command|published|wireless-qca
-/interface/wifi/easymesh/sta|menu|published|wireless-qca
-/interface/wifi/easymesh/topology|menu|published|wireless-qca
+/interface/w60g/align|command|published|wireless-rep||60ghz
+/interface/w60g/monitor|command|published|wireless-rep||60ghz
+/interface/w60g/reset-configuration|command|published|wireless-rep||60ghz
+/interface/w60g/scan|command|published|wireless-rep||60ghz
+/interface/w60g/station|menu|published|wireless-rep||60ghz
+/interface/w60g/station/monitor|command|published|wireless-rep||60ghz
+/interface/wifi|menu|both
+/interface/wifi/aaa|menu|both
+/interface/wifi/access-list|menu|both
+/interface/wifi/cap|settings|both
+/interface/wifi/capsman|settings|both
+/interface/wifi/capsman/remote-cap|menu|both
+/interface/wifi/capsman/remote-cap/provision|command|both
+/interface/wifi/capsman/remote-cap/set-identity|command|both
+/interface/wifi/capsman/remote-cap/upgrade|command|both
+/interface/wifi/channel|menu|both
+/interface/wifi/configuration|menu|both
+/interface/wifi/datapath|menu|both
+/interface/wifi/devel|command|both
/interface/wifi/flat-snoop|command|both
-/interface/wifi/frequency-scan|command|both|wireless-qca
-/interface/wifi/interworking|menu|both|wireless-qca
-/interface/wifi/liberate|command|both|wireless-qca
-/interface/wifi/monitor|command|both|wireless-qca
-/interface/wifi/network|menu|both|wireless-qca
-/interface/wifi/network/radio|menu|both|wireless-qca
-/interface/wifi/provisioning|menu|both|wireless-qca
-/interface/wifi/radio|menu|both|wireless-qca
-/interface/wifi/radio/provision|command|both|wireless-qca
-/interface/wifi/radio/reg-info|command|both|wireless-qca
-/interface/wifi/radio/settings|settings|both|wireless-qca
-/interface/wifi/registration-table|menu|both|wireless-qca
-/interface/wifi/reset-mac-address|command|both|wireless-qca
-/interface/wifi/roam|command|both|wireless-qca
-/interface/wifi/scan|command|both|wireless-qca
-/interface/wifi/security|menu|both|wireless-qca
-/interface/wifi/security/multi-passphrase|menu|both|wireless-qca
-/interface/wifi/sniffer|command|both|wireless-qca
-/interface/wifi/spectral-scan|command|both|wireless-qca
-/interface/wifi/steering|menu|both|wireless-qca
-/interface/wifi/steering/neighbor-group|menu|both|wireless-qca
-/interface/wifi/trigger-radar|command|published|wireless-qca||dfstest
-/interface/wifi/wps-client|command|both|wireless-qca
-/interface/wifi/wps-push-button|command|both|wireless-qca
+/interface/wifi/frequency-scan|command|both
+/interface/wifi/interworking|menu|both
+/interface/wifi/liberate|command|both
+/interface/wifi/monitor|command|both
+/interface/wifi/network|menu|both
+/interface/wifi/network/radio|menu|both
+/interface/wifi/provisioning|menu|both
+/interface/wifi/radio|menu|both
+/interface/wifi/radio/provision|command|both
+/interface/wifi/radio/reg-info|command|both
+/interface/wifi/radio/settings|settings|both
+/interface/wifi/registration-table|menu|both
+/interface/wifi/reset-mac-address|command|both
+/interface/wifi/roam|command|both
+/interface/wifi/scan|command|both
+/interface/wifi/security|menu|both
+/interface/wifi/security/multi-passphrase|menu|both
+/interface/wifi/sniffer|command|both
+/interface/wifi/spectral-scan|command|both
+/interface/wifi/steering|menu|both
+/interface/wifi/steering/neighbor-group|menu|both
+/interface/wifi/trigger-radar|command|published|||dfstest
+/interface/wifi/wps-client|command|both
+/interface/wifi/wps-push-button|command|both
/interface/wifiwave2|menu|inspect
/interface/wifiwave2/aaa|menu|inspect
/interface/wifiwave2/access-list|menu|inspect
@@ -531,6 +531,7 @@ const ROWS = `
/interface/wireless/wds/monitor|command|both|wireless-rep
/interface/wireless/wps-client|command|both|wireless-rep
/interface/wireless/wps-push-button|command|both|wireless-rep
+/interface/xfrm|menu|published
/iot|menu|inspect
/iot/bluetooth|menu|both|iot
/iot/bluetooth/advertisers|menu|both|iot
@@ -554,8 +555,8 @@ const ROWS = `
/iot/bluetooth/scanners/advertisements/clear|command|both|iot
/iot/bluetooth/whitelist|menu|both|iot
/iot/gpio|menu|published|iot||gpio
-/iot/gpio/analog|menu|published|iot
-/iot/gpio/digital|menu|published|iot
+/iot/gpio/analog|menu|published|iot||gpio
+/iot/gpio/digital|menu|published|iot||gpio
/iot/lora|menu|both|iot
/iot/lora/channels|menu|both|iot
/iot/lora/joineui|menu|both|iot
@@ -599,32 +600,32 @@ const ROWS = `
/ip/arp|menu|both
/ip/cloud|settings|both
/ip/cloud/advanced|settings|both
-/ip/cloud/app/update|command|published
/ip/cloud/back-to-home-file|menu|both|||cloud-vpn
-/ip/cloud/back-to-home-file/settings|settings|both
+/ip/cloud/back-to-home-file/settings|settings|both|||cloud-vpn
+/ip/cloud/back-to-home-file/settings/remove-certificate|command|published|||cloud-vpn
/ip/cloud/back-to-home-user|menu|both|||cloud-vpn
-/ip/cloud/back-to-home-user/show-client-config|command|both
+/ip/cloud/back-to-home-user/show-client-config|command|both|||cloud-vpn
/ip/cloud/force-update|command|both
-/ip/dhcp-client|menu|both|dhcp
-/ip/dhcp-client/option|menu|both|dhcp
-/ip/dhcp-client/release|command|both|dhcp
-/ip/dhcp-client/renew|command|both|dhcp
-/ip/dhcp-relay|menu|both|dhcp
-/ip/dhcp-relay/monitor|command|both|dhcp
-/ip/dhcp-relay/reset-counters|command|both|dhcp
-/ip/dhcp-server|menu|both|dhcp
-/ip/dhcp-server/alert|menu|both|dhcp
-/ip/dhcp-server/alert/reset-alert|command|both|dhcp
-/ip/dhcp-server/config|settings|both|dhcp
-/ip/dhcp-server/lease|menu|both|dhcp
-/ip/dhcp-server/lease/check-status|command|both|dhcp
-/ip/dhcp-server/lease/make-static|command|both|dhcp
-/ip/dhcp-server/lease/send-reconfigure|command|both|dhcp
-/ip/dhcp-server/matcher|menu|both|dhcp
-/ip/dhcp-server/network|menu|both|dhcp
-/ip/dhcp-server/option|menu|both|dhcp
-/ip/dhcp-server/option/sets|menu|both|dhcp
-/ip/dhcp-server/setup|command|both|dhcp
+/ip/dhcp-client|menu|both
+/ip/dhcp-client/option|menu|both
+/ip/dhcp-client/release|command|both
+/ip/dhcp-client/renew|command|both
+/ip/dhcp-relay|menu|both
+/ip/dhcp-relay/monitor|command|both
+/ip/dhcp-relay/reset-counters|command|both
+/ip/dhcp-server|menu|both
+/ip/dhcp-server/alert|menu|both
+/ip/dhcp-server/alert/reset-alert|command|both
+/ip/dhcp-server/config|settings|both
+/ip/dhcp-server/lease|menu|both
+/ip/dhcp-server/lease/check-status|command|both
+/ip/dhcp-server/lease/make-static|command|both
+/ip/dhcp-server/lease/send-reconfigure|command|both
+/ip/dhcp-server/matcher|menu|both
+/ip/dhcp-server/network|menu|both
+/ip/dhcp-server/option|menu|both
+/ip/dhcp-server/option/sets|menu|both
+/ip/dhcp-server/setup|command|both
/ip/dns|settings|both
/ip/dns/adlist|menu|both
/ip/dns/adlist/pause|command|both
@@ -655,44 +656,46 @@ const ROWS = `
/ip/firewall/raw/reset-counters|command|both
/ip/firewall/raw/reset-counters-all|command|both
/ip/firewall/service-port|menu|both
-/ip/hotspot|menu|inspect
-/ip/hotspot/active|menu|inspect
-/ip/hotspot/cookie|menu|inspect
-/ip/hotspot/host|menu|inspect
-/ip/hotspot/ip-binding|menu|inspect
-/ip/hotspot/profile|menu|inspect
-/ip/hotspot/service-port|menu|inspect
-/ip/hotspot/setup|command|both|hotspot
-/ip/hotspot/user|menu|inspect
-/ip/hotspot/user/profile|menu|inspect
-/ip/hotspot/walled-garden|menu|inspect
-/ip/hotspot/walled-garden/ip|menu|inspect
-/ip/ipsec|menu|both|security
-/ip/ipsec/active-peers|menu|both|security
-/ip/ipsec/active-peers/kill-connections|command|both|security
-/ip/ipsec/identity|menu|both|security
-/ip/ipsec/installed-sa|menu|both|security
-/ip/ipsec/installed-sa/flush|command|both|security
-/ip/ipsec/key|menu|both|security
-/ip/ipsec/key/psk|menu|both|security
-/ip/ipsec/key/psk/generate|command|both|security
-/ip/ipsec/key/qkd|settings|both|security
-/ip/ipsec/key/qkd/get-key|command|both|security
-/ip/ipsec/key/qkd/get-key-cached|command|both|security
-/ip/ipsec/key/qkd/get-key-with-ids|command|both|security
-/ip/ipsec/key/qkd/get-status|command|both|security
-/ip/ipsec/key/rsa|menu|both|security
-/ip/ipsec/key/rsa/export-pub-key|command|both|security
-/ip/ipsec/key/rsa/generate-key|command|both|security
-/ip/ipsec/key/rsa/import|command|both|security
-/ip/ipsec/mode-config|menu|both|security
-/ip/ipsec/peer|menu|both|security
-/ip/ipsec/policy|menu|both|security
-/ip/ipsec/policy/group|menu|both|security
-/ip/ipsec/profile|menu|both|security
-/ip/ipsec/proposal|menu|both|security|IKE2_DEV
-/ip/ipsec/settings|settings|both|security
-/ip/ipsec/statistics|settings|both|security
+/ip/hotspot|menu|both
+/ip/hotspot/active|menu|both
+/ip/hotspot/active/login|command|both
+/ip/hotspot/cookie|menu|both
+/ip/hotspot/host|menu|both
+/ip/hotspot/host/make-binding|command|both
+/ip/hotspot/ip-binding|menu|both
+/ip/hotspot/profile|menu|both
+/ip/hotspot/reset-html|command|both
+/ip/hotspot/service-port|menu|both
+/ip/hotspot/setup|command|both
+/ip/hotspot/user|menu|both
+/ip/hotspot/user/profile|menu|both
+/ip/hotspot/user/reset-counters|command|both
+/ip/hotspot/walled-garden|menu|both
+/ip/hotspot/walled-garden/ip|menu|both
+/ip/hotspot/walled-garden/reset-counters|command|both
+/ip/hotspot/walled-garden/reset-counters-all|command|both
+/ip/ipsec|menu|both
+/ip/ipsec/active-peers|menu|both
+/ip/ipsec/active-peers/kill-connections|command|both
+/ip/ipsec/identity|menu|both
+/ip/ipsec/installed-sa|menu|both
+/ip/ipsec/installed-sa/flush|command|both
+/ip/ipsec/key|menu|both
+/ip/ipsec/key/psk|menu|both
+/ip/ipsec/key/psk/generate|command|both
+/ip/ipsec/key/qkd|menu|inspect
+/ip/ipsec/key/rsa|menu|both
+/ip/ipsec/key/rsa/export-pub-key|command|both
+/ip/ipsec/key/rsa/generate-key|command|both
+/ip/ipsec/key/rsa/import|command|both
+/ip/ipsec/mode-config|menu|both
+/ip/ipsec/peer|menu|both
+/ip/ipsec/policy|menu|both
+/ip/ipsec/policy/group|menu|both
+/ip/ipsec/profile|menu|both
+/ip/ipsec/proposal|menu|both
+/ip/ipsec/settings|settings|both
+/ip/ipsec/statistics|settings|both
/ip/kid-control|menu|both
/ip/kid-control/device|menu|both
/ip/kid-control/device/reset-counters|command|both
@@ -740,10 +743,10 @@ const ROWS = `
/ip/socks/connections|menu|both
/ip/socks/users|menu|both
/ip/socksify|menu|both
-/ip/ssh|settings|both|security
-/ip/ssh/export-host-key|command|both|security
-/ip/ssh/import-host-key|command|both|security
-/ip/ssh/regenerate-host-key|command|both|security
+/ip/ssh|settings|both
+/ip/ssh/export-host-key|command|both
+/ip/ssh/import-host-key|command|both
+/ip/ssh/regenerate-host-key|command|both
/ip/tftp|menu|both
/ip/tftp/settings|settings|both
/ip/traffic-flow|settings|both
@@ -754,58 +757,58 @@ const ROWS = `
/ip/upnp/interfaces|menu|both
/ip/vrf|menu|both
/ipv6|menu|inspect
-/ipv6/address|menu|both|ipv6
-/ipv6/dhcp-client|menu|both|dhcp
-/ipv6/dhcp-client/option|menu|both|dhcp
-/ipv6/dhcp-client/release|command|both|dhcp
-/ipv6/dhcp-client/renew|command|both|dhcp
-/ipv6/dhcp-relay|menu|both|dhcp
-/ipv6/dhcp-relay/monitor|command|both|dhcp
-/ipv6/dhcp-relay/option|menu|both|dhcp
-/ipv6/dhcp-relay/reset-counters|command|both|dhcp
-/ipv6/dhcp-relay/routes|menu|both|dhcp
-/ipv6/dhcp-server|menu|both|dhcp
-/ipv6/dhcp-server/binding|menu|both|dhcp
-/ipv6/dhcp-server/binding/make-static|command|both|dhcp
-/ipv6/dhcp-server/binding/send-reconfigure|command|both|dhcp
-/ipv6/dhcp-server/option|menu|both|dhcp
-/ipv6/dhcp-server/option/sets|menu|both|dhcp
+/ipv6/address|menu|both
+/ipv6/dhcp-client|menu|both
+/ipv6/dhcp-client/option|menu|both
+/ipv6/dhcp-client/release|command|both
+/ipv6/dhcp-client/renew|command|both
+/ipv6/dhcp-relay|menu|both
+/ipv6/dhcp-relay/monitor|command|both
+/ipv6/dhcp-relay/option|menu|both
+/ipv6/dhcp-relay/reset-counters|command|both
+/ipv6/dhcp-relay/routes|menu|both
+/ipv6/dhcp-server|menu|both
+/ipv6/dhcp-server/binding|menu|both
+/ipv6/dhcp-server/binding/make-static|command|both
+/ipv6/dhcp-server/binding/send-reconfigure|command|both
+/ipv6/dhcp-server/option|menu|both
+/ipv6/dhcp-server/option/sets|menu|both
/ipv6/firewall|menu|inspect
-/ipv6/firewall/address-list|menu|both|ipv6
-/ipv6/firewall/connection|menu|both|ipv6
-/ipv6/firewall/filter|menu|both|ipv6
+/ipv6/firewall/address-list|menu|both
+/ipv6/firewall/connection|menu|both
+/ipv6/firewall/filter|menu|both
/ipv6/firewall/filter/reset-counters|command|both
/ipv6/firewall/filter/reset-counters-all|command|both
-/ipv6/firewall/mangle|menu|both|ipv6
+/ipv6/firewall/mangle|menu|both
/ipv6/firewall/mangle/reset-counters|command|both
/ipv6/firewall/mangle/reset-counters-all|command|both
-/ipv6/firewall/nat|menu|both|ipv6
+/ipv6/firewall/nat|menu|both
/ipv6/firewall/nat/reset-counters|command|both
/ipv6/firewall/nat/reset-counters-all|command|both
-/ipv6/firewall/raw|menu|both|ipv6
+/ipv6/firewall/raw|menu|both
/ipv6/firewall/raw/reset-counters|command|both
/ipv6/firewall/raw/reset-counters-all|command|both
-/ipv6/nd|menu|inspect
-/ipv6/nd/prefix|menu|both|ipv6
-/ipv6/nd/prefix/default|settings|both|ipv6
-/ipv6/nd/proxy|menu|both|ipv6
-/ipv6/nd/settings|settings|both|ipv6
-/ipv6/neighbor|menu|both|ipv6
-/ipv6/pool|menu|both|ipv6
-/ipv6/pool/used|menu|both|ipv6
-/ipv6/route|menu|both|ipv6
-/ipv6/settings|settings|both|ipv6
+/ipv6/nd|menu|both
+/ipv6/nd/prefix|menu|both
+/ipv6/nd/prefix/default|settings|both
+/ipv6/nd/proxy|menu|both
+/ipv6/nd/settings|settings|both
+/ipv6/neighbor|menu|both
+/ipv6/pool|menu|both
+/ipv6/pool/used|menu|both
+/ipv6/route|menu|both
+/ipv6/settings|settings|both
/lcd|settings|published||!smips|lcd
-/lcd/backlight|command|published||!smips
-/lcd/interface|menu|published||!smips
-/lcd/interface/default-wireless|command|published||!smips
-/lcd/interface/display|command|published||!smips
-/lcd/interface/pages|menu|published||!smips
-/lcd/pin|settings|published||!smips
-/lcd/recalibrate|command|published||!smips
-/lcd/screen|menu|published||!smips
-/lcd/show|command|published||!smips
-/lcd/take-screenshot|command|published||!smips
+/lcd/backlight|command|published||!smips|lcd
+/lcd/interface|menu|published||!smips|lcd
+/lcd/interface/default-wireless|command|published||!smips|lcd
+/lcd/interface/display|command|published||!smips|lcd
+/lcd/interface/pages|menu|published||!smips|lcd
+/lcd/pin|settings|published||!smips|lcd
+/lcd/recalibrate|command|published||!smips|lcd
+/lcd/screen|menu|published||!smips|lcd
+/lcd/show|command|published||!smips|lcd
+/lcd/take-screenshot|command|published||!smips|lcd
/log|menu|both
/lora|menu|inspect
/lora/channels|menu|inspect
@@ -839,20 +842,20 @@ const ROWS = `
/openflow/meter|menu|both|openflow
/openflow/port|menu|both|openflow
/partitions|menu|published||!i386, !smips, !mmips|partitions
-/partitions/activate|command|published||!i386, !smips, !mmips
-/partitions/copy-to|command|published||!i386, !smips, !mmips
-/partitions/repartition|command|published||!i386, !smips, !mmips
-/partitions/restore-config-from|command|published||!i386, !smips, !mmips
-/partitions/save-config-to|command|published||!i386, !smips, !mmips
+/partitions/activate|command|published||!i386, !smips, !mmips|partitions
+/partitions/copy-to|command|published||!i386, !smips, !mmips|partitions
+/partitions/repartition|command|published||!i386, !smips, !mmips|partitions
+/partitions/restore-config-from|command|published||!i386, !smips, !mmips|partitions
+/partitions/save-config-to|command|published||!i386, !smips, !mmips|partitions
/password|command|both
/port|menu|both
/port/remote-access|menu|both
-/ppp|menu|both|ppp
-/ppp/aaa|settings|both|ppp
-/ppp/active|menu|both|ppp
-/ppp/l2tp-secret|menu|both|ppp
-/ppp/profile|menu|both|ppp
-/ppp/secret|menu|both|ppp
+/ppp|menu|both
+/ppp/aaa|settings|both
+/ppp/active|menu|both
+/ppp/l2tp-secret|menu|both
+/ppp/profile|menu|both
+/ppp/secret|menu|both
/queue|menu|inspect
/queue/interface|menu|both
/queue/monitor|command|both
@@ -871,6 +874,8 @@ const ROWS = `
/radius/monitor|command|both
/radius/reset-counters|command|both
/redo|command|both
+/root|menu|published||CONSOLE_DEBUG
+/root/terminal|menu|published
/routing|menu|inspect
/routing/bfd|menu|both||BFD_AUTHENTICATION
/routing/bfd/authentication|menu|published||BFD_AUTHENTICATION
@@ -946,7 +951,7 @@ const ROWS = `
/routing/rip/neighbor|menu|both
/routing/rip/static-neighbor|menu|both
/routing/route|menu|both
-/routing/route/rule|menu|both
+/routing/route/rule|menu|inspect
/routing/rpki|menu|both
/routing/rpki/rpki-check|command|both
/routing/rpki/rpki-query|command|both
@@ -979,10 +984,8 @@ const ROWS = `
/system/check-installation|command|both
/system/clock|settings|both
/system/clock/manual|settings|both
-/system/console|menu|inspect
+/system/console|menu|both
/system/console/screen|settings|both||i386
-/system/dashboard/settings|settings|published
-/system/dashboard/show|command|published
/system/default-configuration|settings|both
/system/default-configuration/caps-mode-script|settings|both
/system/default-configuration/custom-script|settings|both
@@ -993,12 +996,15 @@ const ROWS = `
/system/gps|settings|both|gps|mmips
/system/gps/monitor|command|both|gps
/system/hardware|menu|inspect
-/system/health|menu|both||!i386|health
-/system/health/settings|settings|both||!i386, tile|health-settings
-/system/health/settings/detect-fans|command|both||!i386
+/system/health|menu|both||!i386
+/system/health/settings|settings|both||!i386, tile|health and health-settings
+/system/health/settings/detect-fans|command|both||!i386|health and health-settings
/system/history|menu|both
/system/identity|settings|both
-/system/keymat-provider|menu|inspect
+/system/keymat-provider|menu|both
+/system/keymat-provider/qkd-get-key|command|both
+/system/keymat-provider/qkd-get-key-with-ids|command|both
+/system/keymat-provider/qkd-get-status|command|both
/system/leds|menu|both
/system/leds/settings|settings|both
/system/license|settings|both
@@ -1016,7 +1022,11 @@ const ROWS = `
/system/ntp/key|menu|both
/system/ntp/monitor-peers|command|both
/system/ntp/server|settings|both
-/system/package|menu|inspect
+/system/package|menu|both
+/system/package/apply-changes|command|both
+/system/package/disable|command|both
+/system/package/downgrade|command|both
+/system/package/enable|command|both
/system/package/local-update|menu|both
/system/package/local-update/download|command|both
/system/package/local-update/download-all|command|both
@@ -1024,11 +1034,17 @@ const ROWS = `
/system/package/local-update/mirror/force-check|command|both
/system/package/local-update/refresh|command|both
/system/package/local-update/update-package-source|menu|both
-/system/package/update|menu|inspect
+/system/package/uninstall|command|both
+/system/package/unschedule|command|both
+/system/package/update|settings|both
+/system/package/update/cancel|command|both
+/system/package/update/check-for-updates|command|both
+/system/package/update/download|command|both
+/system/package/update/install|command|both
/system/ptp|menu|published||!smips|ptp
-/system/ptp/monitor|command|published||!smips
-/system/ptp/port|menu|published||!smips
-/system/ptp/status|menu|published||!smips
+/system/ptp/monitor|command|published||!smips|ptp
+/system/ptp/port|menu|published||!smips|ptp
+/system/ptp/status|menu|published||!smips|ptp
/system/reboot|command|both
/system/regulatory|settings|both
/system/reset-configuration|command|both
@@ -1057,24 +1073,20 @@ const ROWS = `
/system/rtrace/start|command|both
/system/rtrace/stop|command|both
/system/scheduler|menu|both
-/system/script|menu|inspect
-/system/script/environment|menu|inspect
+/system/script|menu|both
+/system/script/environment|menu|both
/system/script/job|menu|both
-/system/serial-interface/read|command|published
-/system/serial-interface/start|command|published
-/system/serial-interface/stop|command|published
-/system/serial-interface/write|command|published
/system/serial-terminal|command|both
/system/shutdown|command|both
-/system/ssh|command|both|security
-/system/ssh-exec|command|both|security
+/system/ssh|command|both
+/system/ssh-exec|command|both
/system/sup-output|command|both
/system/swos|settings|published||!i386, !mmips, !powerpc, !tile, !smips|swos
-/system/swos/load-config|command|published||!i386, !mmips, !powerpc, !tile, !smips
-/system/swos/password|command|published||!i386, !mmips, !powerpc, !tile, !smips
-/system/swos/reset-config|command|published||!i386, !mmips, !powerpc, !tile, !smips
-/system/swos/save-config|command|published||!i386, !mmips, !powerpc, !tile, !smips
-/system/swos/upgrade|command|published||!i386, !mmips, !powerpc, !tile, !smips
+/system/swos/load-config|command|published||!i386, !mmips, !powerpc, !tile, !smips|swos
+/system/swos/password|command|published||!i386, !mmips, !powerpc, !tile, !smips|swos
+/system/swos/reset-config|command|published||!i386, !mmips, !powerpc, !tile, !smips|swos
+/system/swos/save-config|command|published||!i386, !mmips, !powerpc, !tile, !smips|swos
+/system/swos/upgrade|command|published||!i386, !mmips, !powerpc, !tile, !smips|swos
/system/telnet|command|both
/system/upgrade|menu|inspect
/system/upgrade/mirror|menu|inspect
@@ -1091,24 +1103,21 @@ const ROWS = `
/task/terminate|command|both
/terminal|menu|inspect
/tool|menu|inspect
-/tool/apptraffic/stats|menu|published||!mmips, !powerpc, !smips, !mipsel
-/tool/apptraffic/stats/categories|menu|published||!mmips, !powerpc, !smips, !mipsel
-/tool/apptraffic/stats/clear|command|published||!mmips, !powerpc, !smips, !mipsel
/tool/bandwidth-server|settings|both
/tool/bandwidth-server/session|menu|both
/tool/bandwidth-test|command|both
/tool/calea|menu|both|calea
-/tool/dns-update|command|both|advanced-tools
+/tool/dns-update|command|both
/tool/e-mail|settings|both
/tool/e-mail/send|command|both
/tool/fetch|command|both||arm64
-/tool/flood-ping|command|both|advanced-tools|!smips
-/tool/graphing|menu|inspect
+/tool/flood-ping|command|both||!smips
+/tool/graphing|settings|both
/tool/graphing/interface|menu|both
/tool/graphing/queue|menu|both
/tool/graphing/resource|menu|both
-/tool/ip-scan|command|both|advanced-tools|!smips
-/tool/mac-scan|command|both|advanced-tools|!smips
+/tool/ip-scan|command|both||!smips
+/tool/mac-scan|command|both||!smips
/tool/mac-server|settings|both
/tool/mac-server/mac-winbox|settings|both
/tool/mac-server/ping|settings|both
@@ -1118,9 +1127,9 @@ const ROWS = `
/tool/netinstall/cache|menu|inspect
/tool/netinstall/devices|menu|inspect
/tool/netinstall/settings|menu|inspect
-/tool/netwatch|menu|both|advanced-tools
+/tool/netwatch|menu|both
/tool/ping|command|both
-/tool/ping-speed|command|both|advanced-tools|!smips
+/tool/ping-speed|command|both||!smips
/tool/profile|command|both
/tool/romon|settings|both
/tool/romon/discover|command|both
@@ -1160,7 +1169,7 @@ const ROWS = `
/tool/traffic-generator/stop|command|both
/tool/traffic-generator/stream|menu|both
/tool/traffic-monitor|menu|both
-/tool/wol|command|both|advanced-tools
+/tool/wol|command|both
/tr069-client|settings|both|tr069-client
/tr069-client/reset-tr069-config|command|both|tr069-client
/undo|command|both
@@ -1196,10 +1205,10 @@ const ROWS = `
/user/expire-password|command|both
/user/group|menu|both
/user/settings|settings|both
-/user/ssh-keys|menu|both|security
-/user/ssh-keys/import|command|both|security
-/user/ssh-keys/private|menu|both|security
-/user/ssh-keys/private/import|command|both|security
+/user/ssh-keys|menu|both
+/user/ssh-keys/import|command|both
+/user/ssh-keys/private|menu|both
+/user/ssh-keys/private/import|command|both
/zerotier|menu|both|zerotier
/zerotier/controller|menu|both|zerotier
/zerotier/controller/member|menu|both|zerotier
@@ -1311,10 +1320,11 @@ export interface CatalogGate {
* parent's `syscap` applies even though the child entry states none: the gates
* up a path CONJOIN, they do not override.
*
- * Read row-wise instead, 46 published-only paths look ungated, and
- * #228's finding — that a published-only path almost always explains its own
- * absence — would read as false. Ancestry-aware the residue is 7,
- * and those are the only published paths carrying no explanation at all.
+ * Read row-wise, 2 published-only paths look ungated; read with
+ * ancestry the residue is 1. The gap between the two is the
+ * conjunction, and it is what a caller has to reproduce: a child's silence about
+ * a gate is not the absence of one. #228's finding — that a published-only path
+ * almost always explains its own absence — is about the ancestry-aware number.
*
* Still not a claim about any router. This says what MikroTik published about
* applicability; only a live device knows what it has.
diff --git a/test/fixtures/explain/cliref/README.md b/test/fixtures/explain/cliref/README.md
index 87743f0..7d73977 100644
--- a/test/fixtures/explain/cliref/README.md
+++ b/test/fixtures/explain/cliref/README.md
@@ -1,19 +1,27 @@
# Frozen CLI-Reference pages
-Two pages of MikroTik's published CLI Reference, captured verbatim on
-**2026-08-06** and frozen so the catalog parser
+Pages of MikroTik's published CLI Reference, frozen so the catalog parser
(`scripts/explain-catalog-data.ts`, #228) can be tested without a network.
-| File | Source | Why this page |
-| ---- | ------ | ------------- |
-| `partitions.md` | | Every shape in one small page: a body-level `#` heading that repeats the title, `Conditions` + `Syscap` gates, a `Flag` table, `Argument` and `Read-only Argument` tables, one Directory and five Commands. It is also one of the hardware-gated paths (`syscap: partitions`) that #228 is about — absent from every CHR-derived tree, and self-documented as to why. |
-| `tool__mac-server.md` | | The `/tool mac-server mac-winbox` case #207 was filed on, and the only page here with `Settings Directory` entries. Filename mirrors the generator's `--cache` naming (`/` → `__`). |
+| File | Source | Captured | Why this page |
+| ---- | ------ | -------- | ------------- |
+| `partitions.md` | | 2026-08-06 | Every shape in one small page: a body-level `#` heading that repeats the title, `Conditions` + `Syscap` gates, a `Flag` table, `Argument` and `Read-only Argument` tables, one Directory and five Commands. It is also one of the hardware-gated paths (`syscap: partitions`) that #228 is about — absent from every CHR-derived tree, and self-documented as to why. |
+| `tool__mac-server.md` | | 2026-08-06 | The `/tool mac-server mac-winbox` case #207 was filed on, and the only page here with `Settings Directory` entries. Filename mirrors the generator's `--cache` naming (`/` → `__`). |
+| `system__health__health.md` | | 2026-08-12 | Both #285 shapes on one page: a `/` leaf — the kind sitemap-only discovery dropped — publishing its path TWICE, as a `Settings Directory` (`!i386`) and a `Directory` (`syscap: health`). That pair is why "one entry per page" is not an invariant and why occurrences that differ only in container kind are folded rather than refused. |
+
+**The first two pages no longer exist at those URLs.** MikroTik reshaped the CLI
+Reference from module pages into per-command leaf pages (#285,
+tikoci/rosetta#137), so `partitions.md` is now six leaves under `partitions/`
+and `tool/mac-server.md` is four under `tool/mac-server/`. They are kept
+deliberately, in their captured module-page shape: multi-heading pages with
+intra-page nesting are what prove the parser still reads that shape, and no
+current page nests. Do not "refresh" them — re-capturing would silently delete
+that coverage. `system__health__health.md` is the current shape.
These are documentation excerpts, retained under MikroTik's manual terms for
test purposes only; they are not redistributed as a product artifact. They are
**not** device truth — only a live router establishes what a given RouterOS
build actually accepts.
-Refresh by re-fetching the two URLs above. A parser change that alters what
-these produce is a deliberate change, and the tests in
-`test/unit/explain-catalog.test.ts` say what the current reading is.
+A parser change that alters what these produce is a deliberate change, and the
+tests in `test/unit/explain-catalog.test.ts` say what the current reading is.
diff --git a/test/fixtures/explain/cliref/system__health__health.md b/test/fixtures/explain/cliref/system__health__health.md
new file mode 100644
index 0000000..b941fc9
--- /dev/null
+++ b/test/fixtures/explain/cliref/system__health__health.md
@@ -0,0 +1,90 @@
+# Health
+
+> -----------
+
+import {ArgTableRow} from '@site/src/components/common';
+import {ArgTable} from '@site/src/components/common';
+
+-----------
+
+## system/health
+**Conditions:** !i386
+**Type:** Settings Directory
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+## system/health
+**Conditions:** !i386
+**Syscap:** health
+**Type:** Directory
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/test/unit/explain-catalog.test.ts b/test/unit/explain-catalog.test.ts
index 2bd2c4c..f319999 100644
--- a/test/unit/explain-catalog.test.ts
+++ b/test/unit/explain-catalog.test.ts
@@ -29,11 +29,15 @@ import {
ALIASES,
build,
type CatalogRow,
+ categoryLeafSlugs,
countRowMarkers,
countTypeMarkers,
+ discoverSlugs,
droppedSegments,
+ llmsSlugs,
type PublishedEntry,
parsePage,
+ sitemapSlugs,
} from "../../scripts/explain-catalog-data.ts";
import {
type CatalogEntry,
@@ -144,9 +148,15 @@ describe("explain/catalog — the generated table", () => {
});
/**
- * The alias layer, read from the product side: the doc spelling must NOT be in
- * the table, and the CLI spelling must be — with device provenance, since
- * every alias target was proven against a tree at generation time.
+ * The definition-module spellings, read from the product side: the doc
+ * spelling must NOT be in the table and the CLI spelling must be, with device
+ * provenance.
+ *
+ * This used to be the alias layer's job. Since #285 the source publishes the
+ * CLI spelling directly and the doc spellings are gone, so the same two
+ * assertions now hold for a different reason — which is exactly why they stay:
+ * a regression in either direction is visible here whichever mechanism is
+ * responsible.
*/
test("resolves the caps-man definition-module spellings", () => {
expect(PATH_CATALOG.has("/caps-man/acl/access-list")).toBe(false);
@@ -175,6 +185,25 @@ describe("explain/catalog — the generated table", () => {
);
});
+ /**
+ * The shipped-table half of the #285 discovery fix. `interface/ethernet/
+ * switch/qos/` is a trailing-slash category URL — the sitemap carries its
+ * CHILDREN but never the menu itself, whose entry is published only at
+ * `qos/qos.md` and listed only in `llms.txt`. It is published-only, so
+ * sitemap-only discovery does not merely lose its gate: the row disappears,
+ * and with it the explanation for why no CHR tree has it.
+ *
+ * This is also the R1 path from tikoci/rosetta#136 — `qos` being a real,
+ * published menu segment is what makes dropping it unsafe, and it can only be
+ * seen to be one once the leaf is recovered.
+ */
+ test("carries a branching menu the sitemap alone never lists", () => {
+ const qos = PATH_CATALOG.get("/interface/ethernet/switch/qos");
+ expect(qos?.kind).toBe("menu");
+ expect(qos?.provenance).toBe("published");
+ expect(qos?.syscap).toBe("rbswitch and crs_prestera");
+ });
+
test("entries are lower-case, slash-led and sorted", () => {
const paths = [...PATH_CATALOG.keys()];
expect(paths.filter((path) => path !== path.toLowerCase())).toEqual([]);
@@ -253,18 +282,18 @@ describe("explain/catalog — commandVerbIndex", () => {
describe("explain/catalog — effectiveGates", () => {
/**
* A row states only what the publication stated AT that entry, so a gate must
- * be read down the whole path. `/interface/ethernet/poe/monitor` publishes no
- * gate of its own, but reaching it requires `/interface/ethernet/poe`.
+ * be read down the whole path. `/disk/smb-share` publishes no gate of its own,
+ * but reaching it requires `/disk`, which is built `!smips`.
*/
test("inherits an ancestor's gate", () => {
- const own = lookupPath(["interface", "ethernet", "poe", "monitor"]);
+ const own = lookupPath(["disk", "smb-share"]);
+ expect(own?.conditions).toBeUndefined();
+ expect(own?.package).toBeUndefined();
expect(own?.syscap).toBeUndefined();
- const gates = effectiveGates(["interface", "ethernet", "poe", "monitor"]);
- expect(gates.map((gate) => gate.path)).toContain("/interface/ethernet/poe");
- expect(
- gates.find((gate) => gate.path === "/interface/ethernet/poe")?.syscap,
- ).toBe("(poe or poe-in)");
+ const gates = effectiveGates(["disk", "smb-share"]);
+ expect(gates.map((gate) => gate.path)).toEqual(["/disk"]);
+ expect(gates[0]?.conditions).toBe("!smips");
});
test("carries a path's own gate, root-first", () => {
@@ -283,27 +312,24 @@ describe("explain/catalog — effectiveGates", () => {
/**
* The #228 headline, re-derived from the shipped table rather than quoted:
* a published-only path almost always explains its own absence from a
- * CHR-derived tree, and the unexplained residue is a small fixed set. Read
- * row-wise the residue looks like 46; ancestry-aware it is the seven #228
- * named. Those seven are the ONLY evidence that the publication can carry a
- * path with no explanation at all, so a growing set would be a real event.
+ * CHR-derived tree, and the unexplained residue is a small fixed set.
+ *
+ * The corrected inventory (#285) sharpened this rather than moving it. The
+ * seven paths #228 named were an artifact of the module-page corpus:
+ * `serial-interface` and `dashboard` are now published as their own gated
+ * menus, and the recovered `/` leaves carry the gates their
+ * children inherit. Row-wise the residue is 2; ancestry-aware it is
+ * `/interface/xfrm` alone — the one published path that explains its absence
+ * from a CHR tree in no way at all. A growing set would be a real event.
*/
- test("the unexplained residue is the seven paths #228 named", () => {
+ test("the unexplained residue is one path", () => {
const residue = [...PATH_CATALOG]
.filter(([path, entry]) => {
if (entry.provenance !== "published") return false;
return effectiveGates(path.split("/").filter(Boolean)).length === 0;
})
.map(([path]) => path);
- expect(residue).toEqual([
- "/ip/cloud/app/update",
- "/system/dashboard/settings",
- "/system/dashboard/show",
- "/system/serial-interface/read",
- "/system/serial-interface/start",
- "/system/serial-interface/stop",
- "/system/serial-interface/write",
- ]);
+ expect(residue).toEqual(["/interface/xfrm"]);
});
});
@@ -351,6 +377,38 @@ describe("explain/catalog — parsing published pages", () => {
]);
});
+ /**
+ * A `/` leaf, the shape sitemap-only discovery dropped (#285),
+ * publishing ONE path as two entries under different hardware gates. Read
+ * end-to-end, because each half was decided from this page: the parser keeps
+ * both occurrences, the fold records the kind they agree on, and the gate
+ * survives only where both state it — `!i386` does, `syscap: health` does not.
+ */
+ test("reads a path published twice under different gates", async () => {
+ const entries = parsePage(
+ "system/health/health",
+ await readFixture("system__health__health.md"),
+ );
+ expect(
+ entries.map((entry) => `${entry.path} ${entry.kind} ${entry.syscap}`),
+ ).toEqual([
+ "/system/health Settings Directory null",
+ "/system/health Directory health",
+ ]);
+
+ const rows = buildWith(entries, { "/system/health": "dir" });
+ expect(rows).toEqual([
+ {
+ conditions: "!i386",
+ kind: "menu",
+ package: undefined,
+ path: "/system/health",
+ provenance: "both",
+ syscap: undefined,
+ },
+ ]);
+ });
+
/** Flag rows name print flags, not arguments; only R2 consumes this set. */
test("collects argument names but not print flags", async () => {
const entries = parsePage("partitions", await readFixture("partitions.md"));
@@ -465,6 +523,83 @@ describe("explain/catalog — parsing published pages", () => {
});
});
+// ---------------------------------------------------------------------------
+// Discovery
+// ---------------------------------------------------------------------------
+
+/**
+ * The sitemap is not the inventory (#285). A branching menu is served as a
+ * trailing-slash category URL with no `.md`, while the menu's own entry is
+ * published at `/.md` and listed only in `llms.txt`. These
+ * hold the shape of that pair, because getting it wrong shrinks the catalog by
+ * a quarter without a single error.
+ */
+describe("explain/catalog — discovery", () => {
+ const loc = (path: string): string =>
+ `https://manual.mikrotik.com${path}`;
+ const sitemap = [
+ loc("/docs/cli-reference/"),
+ loc("/docs/cli-reference/app/"),
+ loc("/docs/cli-reference/app/cleanup"),
+ loc("/docs/cli-reference/beep"),
+ loc("/docs/other/thing"),
+ ].join("\n");
+ const llms = [
+ "- [App](https://manual.mikrotik.com/docs/cli-reference/app/app.md)",
+ "- [Cleanup](https://manual.mikrotik.com/docs/cli-reference/app/cleanup.md)",
+ "- [Beep](https://manual.mikrotik.com/docs/cli-reference/beep.md)",
+ "- [Index](https://manual.mikrotik.com/docs/cli-reference/index.md)",
+ "- [Other](https://manual.mikrotik.com/docs/other/thing.md)",
+ ].join("\n");
+
+ test("the sitemap lists only the directly-addressable pages", () => {
+ expect(sitemapSlugs(sitemap)).toEqual(["app/cleanup", "beep"]);
+ });
+
+ test("a category URL names the leaf that carries the menu's own entry", () => {
+ expect(categoryLeafSlugs(sitemap)).toEqual(["app/app"]);
+ });
+
+ /** `index` is the argument-type glossary prose, not a CLI path. */
+ test("llms.txt lists every page except the section landing page", () => {
+ expect(llmsSlugs(llms)).toEqual(["app/app", "app/cleanup", "beep"]);
+ });
+
+ test("the inventory is the union, and the category leaf survives it", () => {
+ expect(discoverSlugs(sitemap, llms)).toEqual([
+ "app/app",
+ "app/cleanup",
+ "beep",
+ ]);
+ });
+
+ /**
+ * The #285 defect itself, refused. A category dir whose leaf no inventory
+ * carries means that menu's entry is being dropped — invisibly, since the
+ * generated table would simply be shorter.
+ */
+ test("aborts when a category dir contributes no leaf", () => {
+ expect(() =>
+ discoverSlugs(sitemap, llms.split("\n").slice(1).join("\n")),
+ ).toThrow(/contribute no \/ leaf/);
+ });
+
+ /**
+ * A page only the sitemap lists is reported, not refused: the union already
+ * carries it, so nothing is dropped, and `--check` fails on the new row.
+ */
+ test("reports a sitemap page llms.txt does not list", () => {
+ const reported: string[] = [];
+ const slugs = discoverSlugs(
+ `${sitemap}\n${loc("/docs/cli-reference/new-page")}`,
+ llms,
+ (message) => reported.push(message),
+ );
+ expect(slugs).toContain("new-page");
+ expect(reported.join("\n")).toMatch(/not in llms\.txt.*new-page/s);
+ });
+});
+
// ---------------------------------------------------------------------------
// The generator's refusals
// ---------------------------------------------------------------------------
@@ -528,15 +663,32 @@ describe("explain/catalog — what the generator refuses", () => {
).toThrow(/disagree about what these paths ARE/);
});
+ /**
+ * ...but a `Directory` / `Settings Directory` pair is NOT that disagreement.
+ * Both say the path is navigation and differ only on whether this hardware's
+ * menu holds a single record — `/system/health` is published both ways on one
+ * page, `!i386` and `health`. Record what they agree on, as a disagreed gate
+ * is dropped rather than picked.
+ */
+ test("records the agreed kind when two occurrences differ only in container kind", () => {
+ const rows = buildWith(
+ [entry("/x/y", "Settings Directory"), entry("/x/y", "Directory")],
+ {},
+ );
+ expect(rows.map((row) => `${row.path} ${row.kind}`)).toEqual(["/x/y menu"]);
+ });
+
/**
* R1, stated directly. `poe` is a real CLI menu segment, so dropping it is
- * never allowed — this is the rule tikoci/rosetta#136 is missing, and the
- * reason nine of the naive candidates are not in {@link ALIASES}.
+ * never allowed — this is the rule tikoci/rosetta#136 was missing.
+ *
+ * Exercised against a synthetic allowlist because the shipped one is empty
+ * (#285): the rule guards the next entry someone adds, so it must be provable
+ * without one.
*/
test("R1: refuses to drop a segment that is itself a published entry", () => {
const from = "/caps-man/acl/access-list";
const to = "/caps-man/access-list";
- expect(ALIASES.get(from)).toBe(to);
expect(() =>
buildWith(
[
@@ -614,8 +766,14 @@ describe("explain/catalog — what the generator refuses", () => {
).toThrow(/is in no pinned tree/);
});
- /** Every alias must be a pure segment drop, or R1 is not defined on it. */
- test("every allowlist entry is a segment drop", () => {
+ /**
+ * The allowlist is empty and that is the correct state (#285): MikroTik now
+ * publishes every path in its CLI spelling, so nothing needs rewriting. Pinned
+ * because an entry appearing again is a source change to read, not a routine
+ * regeneration — and because R0/R1/R2 above only ever fire on an entry.
+ */
+ test("the allowlist is empty, and any entry would be a segment drop", () => {
+ expect([...ALIASES]).toEqual([]);
for (const [from, to] of ALIASES)
expect(droppedSegments(from, to).length).toBeGreaterThan(0);
expect(() => droppedSegments("/a/b/c", "/a/x")).toThrow(
diff --git a/test/unit/explain-write.test.ts b/test/unit/explain-write.test.ts
index b00a101..e79674d 100644
--- a/test/unit/explain-write.test.ts
+++ b/test/unit/explain-write.test.ts
@@ -238,11 +238,20 @@ describe("explain/write — finding 2: fail closed where input is discarded", ()
* unconfirmed-nav fallback and instead arrives with V4 ambiguity. Clearing it
* would be a false negative on a curated write verb, so it abstains — Q14's
* rule (b) enforced at the rollup.
+ *
+ * This read `/interface\nreset-counters` until #285. The corrected
+ * CLI-Reference inventory publishes `/interface/reset-counters` as a Command,
+ * and the pinned trees carry it too, so the catalog now RESOLVES that head and
+ * the document is a confirmed write — the abstention there was a false
+ * negative the larger table fixed. `reset` is the same shape with the path
+ * still in neither table, which is the case under test.
*/
test("a bare-word head abstains rather than clearing the document", () => {
- const got = containsWrite("/interface\nreset-counters");
+ const got = containsWrite("/interface\nreset");
expect(got.verdict).toBe("unknown");
expect(got.blockers.map((b) => b.klass)).toEqual(["ambiguous"]);
+ // The catalog decides it once the path IS published.
+ expect(containsWrite("/interface\nreset-counters").verdict).toBe("true");
});
/**
@@ -396,9 +405,14 @@ describe("explain/write — the published command axis (#228)", () => {
expect(python.occurrences.map((o) => o.verb)).toEqual([null, null]);
});
- /** Both tables are floors: absence from them is still not evidence. */
+ /**
+ * Both tables are floors: absence from them is still not evidence.
+ *
+ * `/interface/reset` is the anchor since #285 — `/interface/reset-counters`,
+ * which this used to use, is now published and therefore decided.
+ */
test("a path in neither table is unchanged", () => {
- const got = containsWrite("/interface\nreset-counters");
+ const got = containsWrite("/interface\nreset");
expect(got.verdict).toBe("unknown");
expect(got.blockers.map((b) => b.klass)).toEqual(["ambiguous"]);
});
From 4d281c160d86afd977112bba97a4419791185387 Mon Sep 17 00:00:00 2001
From: SkyFi Geek <45924209+mobileskyfi@users.noreply.github.com>
Date: Wed, 12 Aug 2026 08:34:07 -0700
Subject: [PATCH 2/2] review: resolve inventory-drift and test-anchor findings
(#286)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Three review findings, all fixed.
Copilot — `discoverSlugs` reported the sitemap-only direction but never the
inverse, while the docstring claimed both. The two directions are NOT symmetric,
and that asymmetry is the shape: every sitemap page is also in llms.txt, but
llms.txt legitimately carries 256 pages the sitemap lacks — those ARE the
category leaves, so reporting them would bury the signal. The residue is what
matters, and is now reported: an llms.txt page that is neither in the sitemap nor
a category leaf has appeared from nowhere.
CodeRabbit — `cliRefPath` used `new URL(link)`, which throws on a root-relative
or document-relative link and so returned null, indistinguishable from "not a
CLI page". Both inventories publish absolute URLs today (verified live), so this
was latent rather than live, but a silent inventory shrink is exactly the #285
defect class. Links now resolve against the document they were read from.
CodeRabbit — split the combined bare-word-head test. The abstention anchor
(`/interface reset`) and the catalog-resolution anchor (`/interface
reset-counters`) are separate mechanisms and now separate tests, so a catalog
change is legible as one.
Both new guards mutation-tested: reverting either fix turns its test red.
`explain:catalog:check` still matches live — the catalog itself is unmoved.
Co-Authored-By: Claude Opus 5
---
scripts/explain-catalog-data.ts | 42 ++++++++++++++++++++++-------
test/unit/explain-catalog.test.ts | 44 +++++++++++++++++++++++++++++++
test/unit/explain-write.test.ts | 24 +++++++++++------
3 files changed, 93 insertions(+), 17 deletions(-)
diff --git a/scripts/explain-catalog-data.ts b/scripts/explain-catalog-data.ts
index 4fe5cd4..b4a5905 100644
--- a/scripts/explain-catalog-data.ts
+++ b/scripts/explain-catalog-data.ts
@@ -172,11 +172,19 @@ function decodeXml(text: string): string {
.replace(/&/g, "&");
}
-/** The CLI-Reference path a URL names, or `null` when it names something else. */
-function cliRefPath(location: string): string | null {
+/**
+ * The CLI-Reference path a link names, or `null` when it names something else.
+ *
+ * `base` is the document the link was read FROM, so a relative link resolves
+ * the way a browser would. Both inventories publish absolute URLs today, but a
+ * relative one must not vanish: `new URL(link)` alone throws on `/docs/…` and
+ * on `./app/app.md`, and this returning `null` is indistinguishable from "not a
+ * CLI page". Resolving against the base is what keeps the two cases apart.
+ */
+function cliRefPath(location: string, base: string): string | null {
let path: string;
try {
- path = new URL(location).pathname;
+ path = new URL(location, base).pathname;
} catch {
return null;
}
@@ -196,7 +204,7 @@ function pageSlug(path: string): string | null {
export function sitemapSlugs(sitemapXml: string): string[] {
const slugs = new Set();
for (const match of sitemapXml.matchAll(/\s*([\s\S]*?)\s*<\/loc>/g)) {
- const path = cliRefPath(decodeXml(match[1] ?? ""));
+ const path = cliRefPath(decodeXml(match[1] ?? ""), SITEMAP_URL);
const slug = path === null ? null : pageSlug(path);
if (slug !== null) slugs.add(slug);
}
@@ -212,7 +220,7 @@ export function sitemapSlugs(sitemapXml: string): string[] {
export function categoryLeafSlugs(sitemapXml: string): string[] {
const slugs = new Set();
for (const match of sitemapXml.matchAll(/\s*([\s\S]*?)\s*<\/loc>/g)) {
- const path = cliRefPath(decodeXml(match[1] ?? ""));
+ const path = cliRefPath(decodeXml(match[1] ?? ""), SITEMAP_URL);
if (path === null || !path.endsWith("/")) continue;
const dir = path.slice(CLI_PREFIX.length).replace(/\/$/, "");
if (dir === "" || !CLI_SLUG.test(dir)) continue;
@@ -225,7 +233,7 @@ export function categoryLeafSlugs(sitemapXml: string): string[] {
export function llmsSlugs(llmsTxt: string): string[] {
const slugs = new Set();
for (const match of llmsTxt.matchAll(/^-\s*\[[^\]]*\]\(([^)\s]+)\)/gm)) {
- const path = cliRefPath(match[1] ?? "");
+ const path = cliRefPath(match[1] ?? "", LLMS_TXT_URL);
const slug = path === null ? null : pageSlug(path);
if (slug !== null) slugs.add(slug);
}
@@ -257,6 +265,14 @@ export function llmsSlugs(llmsTxt: string): string[] {
* itself, and invisible in the output. A page only one inventory lists is
* reported instead of refused: the union already carries it, so nothing is
* dropped, and `--check` fails on the new row anyway.
+ *
+ * BOTH one-sided directions are reported, but they are not symmetric, and the
+ * asymmetry is the shape itself. Every sitemap page is also in `llms.txt`, so a
+ * sitemap-only page is bare drift. `llms.txt` has 256 pages the sitemap lacks —
+ * that is not drift, it IS the category leaves, so reporting them all would be
+ * noise that buries the signal. What is reported is the residue: an `llms.txt`
+ * page that is neither in the sitemap nor a category leaf, i.e. one that
+ * appears from nowhere.
*/
export function discoverSlugs(
sitemapXml: string,
@@ -265,11 +281,10 @@ export function discoverSlugs(
): string[] {
const sitemap = new Set(sitemapSlugs(sitemapXml));
const llms = new Set(llmsSlugs(llmsTxt));
+ const leaves = new Set(categoryLeafSlugs(sitemapXml));
const inventory = new Set([...sitemap, ...llms]);
- const orphans = categoryLeafSlugs(sitemapXml).filter(
- (slug) => !inventory.has(slug),
- );
+ const orphans = [...leaves].filter((slug) => !inventory.has(slug));
if (orphans.length > 0)
throw new Error(
`${orphans.length} sitemap category dir(s) contribute no / leaf to the\n` +
@@ -285,6 +300,15 @@ export function discoverSlugs(
`published inventories no longer agree in shape: ${sitemapOnly.join(", ")}`,
);
+ const unaccounted = [...llms].filter(
+ (slug) => !sitemap.has(slug) && !leaves.has(slug),
+ );
+ if (unaccounted.length > 0)
+ report(
+ `${unaccounted.length} page(s) are in llms.txt but are neither in the sitemap nor a ` +
+ `category leaf, so the sitemap no longer accounts for them: ${unaccounted.join(", ")}`,
+ );
+
return [...inventory].sort();
}
diff --git a/test/unit/explain-catalog.test.ts b/test/unit/explain-catalog.test.ts
index f319999..ac32126 100644
--- a/test/unit/explain-catalog.test.ts
+++ b/test/unit/explain-catalog.test.ts
@@ -565,6 +565,25 @@ describe("explain/catalog — discovery", () => {
expect(llmsSlugs(llms)).toEqual(["app/app", "app/cleanup", "beep"]);
});
+ /**
+ * Both inventories publish absolute URLs today, so a relative link must be
+ * resolved rather than dropped: `new URL(link)` alone throws on it, and
+ * returning nothing is indistinguishable from "not a CLI page" — which is how
+ * an inventory silently shrinks. Root-relative and document-relative both
+ * resolve against `llms.txt`'s own URL, the way a browser reads them.
+ */
+ test("a relative llms.txt link resolves against the document, not away", () => {
+ expect(
+ llmsSlugs(
+ [
+ "- [Root-relative](/docs/cli-reference/beep.md)",
+ "- [Document-relative](./docs/cli-reference/app/app.md)",
+ "- [Elsewhere](/docs/other/thing.md)",
+ ].join("\n"),
+ ),
+ ).toEqual(["app/app", "beep"]);
+ });
+
test("the inventory is the union, and the category leaf survives it", () => {
expect(discoverSlugs(sitemap, llms)).toEqual([
"app/app",
@@ -598,6 +617,31 @@ describe("explain/catalog — discovery", () => {
expect(slugs).toContain("new-page");
expect(reported.join("\n")).toMatch(/not in llms\.txt.*new-page/s);
});
+
+ /**
+ * And the inverse, which is NOT symmetric. `llms.txt` legitimately carries
+ * every category leaf the sitemap lacks — 256 of them — so reporting all
+ * llms-only pages would bury the signal in the shape. The residue is what
+ * matters: a page in neither the sitemap nor the category leaves has appeared
+ * from nowhere and the sitemap no longer accounts for it.
+ */
+ test("reports an llms.txt page the sitemap cannot account for", () => {
+ const reported: string[] = [];
+ const slugs = discoverSlugs(
+ sitemap,
+ `${llms}\n- [Nowhere](https://manual.mikrotik.com/docs/cli-reference/nowhere.md)`,
+ (message) => reported.push(message),
+ );
+ expect(slugs).toContain("nowhere");
+ expect(reported.join("\n")).toMatch(/neither in the sitemap.*nowhere/s);
+ });
+
+ /** ...and the category leaves themselves are never reported as that. */
+ test("stays silent when llms.txt differs only by its category leaves", () => {
+ const reported: string[] = [];
+ discoverSlugs(sitemap, llms, (message) => reported.push(message));
+ expect(reported).toEqual([]);
+ });
});
// ---------------------------------------------------------------------------
diff --git a/test/unit/explain-write.test.ts b/test/unit/explain-write.test.ts
index e79674d..86739e6 100644
--- a/test/unit/explain-write.test.ts
+++ b/test/unit/explain-write.test.ts
@@ -239,19 +239,27 @@ describe("explain/write — finding 2: fail closed where input is discarded", ()
* would be a false negative on a curated write verb, so it abstains — Q14's
* rule (b) enforced at the rollup.
*
- * This read `/interface\nreset-counters` until #285. The corrected
- * CLI-Reference inventory publishes `/interface/reset-counters` as a Command,
- * and the pinned trees carry it too, so the catalog now RESOLVES that head and
- * the document is a confirmed write — the abstention there was a false
- * negative the larger table fixed. `reset` is the same shape with the path
- * still in neither table, which is the case under test.
+ * This read `/interface\nreset-counters` until #285; `reset` is the same shape
+ * with the path still in neither table, which is what this test is about. The
+ * head that MOVED has its own test below.
*/
test("a bare-word head abstains rather than clearing the document", () => {
const got = containsWrite("/interface\nreset");
expect(got.verdict).toBe("unknown");
expect(got.blockers.map((b) => b.klass)).toEqual(["ambiguous"]);
- // The catalog decides it once the path IS published.
- expect(containsWrite("/interface\nreset-counters").verdict).toBe("true");
+ });
+
+ /**
+ * The other side of that boundary, on its own anchor so a catalog change is
+ * legible as one. The corrected CLI-Reference inventory (#285) publishes
+ * `/interface/reset-counters` as a Command and the pinned trees carry it too,
+ * so the head RESOLVES and the document is a confirmed write — the abstention
+ * that used to stand here was a false negative the larger table fixed.
+ */
+ test("a bare-word head the catalog knows resolves instead", () => {
+ const got = containsWrite("/interface\nreset-counters");
+ expect(got.verdict).toBe("true");
+ expect(got.blockers).toEqual([]);
});
/**