@tikoci/rosetta 0.8.9 → 0.8.11

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -255,7 +255,7 @@ Type a search query to find documentation pages, then select a numbered result t
255
255
 
256
256
  Type `help` for the full command list. URLs are clickable in terminals that support OSC 8 hyperlinks (iTerm2, Windows Terminal, GNOME Terminal, etc.).
257
257
 
258
- The browser is also useful as a test harness — it interacts with the data the same way an AI agent would through MCP, so gaps or rough edges visible here often point to MCP tool improvements too.
258
+ The browser is also useful as an audit surface — it shares core query functions with MCP and exposes every MCP tool as a raw dot-command, so gaps or rough edges visible here often point to agent-facing improvements too.
259
259
 
260
260
  > **From a router:** If rosetta is installed as a `/app`, access the browser via `/container/shell app-rosetta` then `/app/rosetta browse`.
261
261
 
@@ -275,13 +275,14 @@ Ask your AI assistant questions like:
275
275
 
276
276
  ## MCP Tools
277
277
 
278
- The server exposes 13 tools designed to work together — agents start with `routeros_search` and drill into specific data as needed:
278
+ The server exposes 14 tools designed to work together — agents start with `routeros_search` and drill into specific data as needed:
279
279
 
280
280
  | Tool | What it does |
281
281
  |------|-------------|
282
282
  | `routeros_search` | **Start here.** Unified search with input classifier — returns pages + related callouts, videos, properties, changelogs, devices, skills |
283
283
  | `routeros_get_page` | Full page content by ID or title, section-aware for large pages |
284
284
  | `routeros_lookup_property` | Property by exact name — type, default, description |
285
+ | `routeros_explain_command` | Read-only explanation for a CLI command — canonical path/verb, args, warnings, docs, changelogs |
285
286
  | `routeros_command_tree` | Browse the command hierarchy (`/ip/firewall/filter` style) |
286
287
  | `routeros_search_changelogs` | Changelogs filtered by version range, category, breaking flag |
287
288
  | `routeros_command_version_check` | Which RouterOS versions include a command path |
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tikoci/rosetta",
3
- "version": "0.8.9",
3
+ "version": "0.8.11",
4
4
  "description": "RouterOS documentation as SQLite FTS5 — RAG search + command glossary via MCP",
5
5
  "type": "module",
6
6
  "license": "MIT",
package/src/browse.ts CHANGED
@@ -33,6 +33,7 @@ import {
33
33
  checkCommandVersions,
34
34
  compareVersions,
35
35
  diffCommandVersions,
36
+ explainCommand,
36
37
  fetchCurrentVersions,
37
38
  getDudePage,
38
39
  getPage,
@@ -1105,7 +1106,7 @@ function renderHelp(): string {
1105
1106
 
1106
1107
  out.push("");
1107
1108
  out.push(` ${bold("MCP probe (dot-commands)")} ${dim("— see exactly what an agent sees")}`);
1108
- out.push(` ${cyan(pad(".help", 38))} ${dim("Full list of all 13 tool dot-commands + meta")}`);
1109
+ out.push(` ${cyan(pad(".help", 38))} ${dim("Full list of all 14 tool dot-commands + meta")}`);
1109
1110
  out.push(` ${cyan(pad(".instructions", 38))} ${dim("MCP server instructions string (sent on init)")}`);
1110
1111
  out.push(` ${cyan(pad(".resources", 38))} ${dim("Registered MCP resources (rosetta:// URIs)")}`);
1111
1112
  out.push(` ${cyan(pad(".routeros_search <q> [limit=N]", 38))} ${dim("Raw JSON output, same query path as MCP")}`);
@@ -1118,7 +1119,7 @@ function renderHelp(): string {
1118
1119
  out.push(` ${bold("CLI flags")}`);
1119
1120
  out.push(` ${cyan(pad("--db <path>", 26))} ${dim("")} Use a specific database file`);
1120
1121
  out.push(` ${cyan(pad("--once", 26))} ${dim("")} Execute any command once and exit (for piping)`);
1121
- out.push(` ${cyan(pad("browse <cmd> [args]", 26))} ${dim("")} Pass any TUI command directly from the shell:`)
1122
+ out.push(` ${cyan(pad("browse <cmd> [args]", 26))} ${dim("")} Pass any TUI command directly from the shell:`);
1122
1123
  out.push(` ${cyan(pad("", 26))} ${dim("")} ${dim("browse changelog 7.20..7.22")}`);
1123
1124
  out.push(` ${cyan(pad("", 26))} ${dim("")} ${dim("browse cmd /ip/firewall")}`);
1124
1125
  out.push(` ${cyan(pad("", 26))} ${dim("")} ${dim("browse .routeros_search vrrp")}`);
@@ -1207,6 +1208,15 @@ const dotTools: Record<string, DotTool> = {
1207
1208
  desc: "Property by exact name (args: name=, command_path=)",
1208
1209
  run: (a) => lookupProperty(String(a.name ?? ""), a.command_path ? String(a.command_path) : undefined),
1209
1210
  },
1211
+ routeros_explain_command: {
1212
+ primary: "command",
1213
+ desc: "Explain CLI command: canonical path/verb, args, warnings, docs, changelogs (args: command=, ros_version=, model=)",
1214
+ run: (a) => explainCommand(
1215
+ String(a.command ?? ""),
1216
+ a.ros_version ? String(a.ros_version) : undefined,
1217
+ a.model ? String(a.model) : undefined,
1218
+ ),
1219
+ },
1210
1220
  routeros_command_tree: {
1211
1221
  primary: "path",
1212
1222
  tui: "cmd [path]",
@@ -0,0 +1,59 @@
1
+ /**
2
+ * DB-backed verb resolver for {@link canonicalize}.
3
+ *
4
+ * `canonicalize.ts` is intentionally pure — it knows nothing about the DB.
5
+ * This module is the rosetta-side adapter: given a `Database`, it returns
6
+ * an `isVerb(token, parentPath)` function that consults the `commands`
7
+ * table to decide whether a token at a given menu is a path-specific verb
8
+ * (`type='cmd'`). The pure canonicalizer still keeps its universal verb
9
+ * fallback active for helpers that RouterOS introspection does not enumerate
10
+ * everywhere (for example `find`).
11
+ *
12
+ * Why path-aware lookup matters:
13
+ * /interface/wifi-qcom/info ← `info` is a cmd at /interface/wifi-qcom
14
+ * /interface/wireless/info ← `info` is a dir at /interface/wireless
15
+ * The pure-module universal-verb-set heuristic can't tell these apart;
16
+ * the DB can.
17
+ *
18
+ * The resolver caches per-call results in-memory — most realistic inputs
19
+ * touch a small number of (token, parentPath) pairs and the cache turns
20
+ * a sub-millisecond SQL query into a hash lookup.
21
+ *
22
+ * Cross-ref: rosetta issue #5 (H4), DESIGN.md "canonicalize: vendoring
23
+ * intent and DB-backed resolver".
24
+ */
25
+
26
+ import type { Database } from "bun:sqlite";
27
+
28
+ /**
29
+ * Build an `isVerb` resolver bound to a rosetta DB. Returns a synchronous
30
+ * function suitable for {@link CanonicalizeOptions.isVerb}.
31
+ *
32
+ * The resolver is path-aware: `(token, parentPath) => boolean`. A token
33
+ * counts as a path-specific verb if there exists a `commands` row with
34
+ * `name=token`, `parent_path=parentPath`, `type='cmd'`. Returning `false`
35
+ * means "no DB-specific hit"; the canonicalizer may still accept the token
36
+ * via its built-in universal verb set.
37
+ */
38
+ export function makeDbVerbResolver(
39
+ db: Database,
40
+ ): (token: string, parentPath: string) => boolean {
41
+ const stmt = db.prepare(
42
+ "SELECT 1 FROM commands WHERE name = ? AND parent_path = ? AND type = 'cmd' LIMIT 1",
43
+ );
44
+ // Per-resolver cache. Lifetime = lifetime of the resolver, which is the
45
+ // lifetime of the process for searchAll's call site. A long-running TUI
46
+ // session benefits; a one-shot CLI invocation pays a single SQL query
47
+ // per unique (token, parentPath) pair regardless.
48
+ const cache = new Map<string, boolean>();
49
+
50
+ return (token: string, parentPath: string): boolean => {
51
+ const key = `${parentPath}\x00${token}`;
52
+ const cached = cache.get(key);
53
+ if (cached !== undefined) return cached;
54
+ const row = stmt.get(token, parentPath);
55
+ const result = row !== null && row !== undefined;
56
+ cache.set(key, result);
57
+ return result;
58
+ };
59
+ }
@@ -0,0 +1,371 @@
1
+ /**
2
+ * Fuzz / robustness tests for canonicalize.ts.
3
+ *
4
+ * These probe the parser against pathological inputs — prose, markdown,
5
+ * malformed scripts, RouterOS scripting constructs — to document where it
6
+ * is robust, where it is wrong-but-quiet, and where the behaviour is
7
+ * underspecified.
8
+ *
9
+ * Inputs originated from `lsp-routeros-ts/docs/canonicalize-audit.md`
10
+ * (2026-04-25 audit) and were ported here so future fixes have a
11
+ * regression surface.
12
+ *
13
+ * Conventions:
14
+ * - `test(...)` — current behaviour (anchor test). Update if a fix
15
+ * intentionally changes it; add a comment noting the audit finding number.
16
+ * - `test.todo(...)` — known-bad behaviour the audit recommended fixing.
17
+ * Promote to `test(...)` once the fix lands.
18
+ *
19
+ * Cross-ref:
20
+ * - `lsp-routeros-ts/docs/canonicalize-audit.md` (findings #1–#12, hardenings H1–H8)
21
+ */
22
+ import { describe, expect, test } from 'bun:test';
23
+ import { canonicalize, extractMentions, extractPaths } from './canonicalize.ts';
24
+
25
+ // ---------------------------------------------------------------------------
26
+ // Helpers
27
+ // ---------------------------------------------------------------------------
28
+
29
+ function paths(input: string, cwd = '/'): string[] {
30
+ return canonicalize(input, cwd).commands.map(c =>
31
+ c.verb ? `${c.path}/${c.verb}` : c.path,
32
+ );
33
+ }
34
+
35
+ // ===========================================================================
36
+ // Crash-resistance — none of these may throw
37
+ // ===========================================================================
38
+ describe('does not crash on malformed input', () => {
39
+ const torture = [
40
+ '',
41
+ ' \t ',
42
+ '# comment only',
43
+ '/',
44
+ '\n\n\n',
45
+ '/system/script/add source="never ends', // unclosed quote
46
+ '/ip/address/set [find interface=ether1', // unclosed [
47
+ '/ip/address { print', // unclosed {
48
+ '/ip/address/print ]', // stray ]
49
+ '/ip/address/print }', // stray }
50
+ ';;;;',
51
+ '\\',
52
+ '$$$$',
53
+ ':::::',
54
+ '////',
55
+ '[]{}();',
56
+ ];
57
+ for (const input of torture) {
58
+ test(`no throw: ${JSON.stringify(input).slice(0, 60)}`, () => {
59
+ expect(() => canonicalize(input)).not.toThrow();
60
+ });
61
+ }
62
+ });
63
+
64
+ // ===========================================================================
65
+ // H7 — BOM and zero-width characters (FIXED 2026-04-25 in tokenizer)
66
+ // ===========================================================================
67
+ describe('H7: BOM and zero-width characters', () => {
68
+ test('BOM-prefixed input is stripped before parsing', () => {
69
+ // U+FEFF byte-order mark prepended
70
+ const result = canonicalize('/ip/address/print');
71
+ expect(result.commands.length).toBe(1);
72
+ expect(result.commands[0].path).toBe('/ip/address');
73
+ expect(result.commands[0].verb).toBe('print');
74
+ });
75
+
76
+ test('zero-width space is treated as whitespace', () => {
77
+ // U+200B zero-width space between `ip` and `/`
78
+ const result = canonicalize('/ip​/address/print');
79
+ expect(result.commands.length).toBe(1);
80
+ expect(result.commands[0].path).toBe('/ip/address');
81
+ expect(result.commands[0].verb).toBe('print');
82
+ });
83
+ });
84
+
85
+ // ===========================================================================
86
+ // Markdown backticks (FIXED 2026-04-25 — backticks now whitespace)
87
+ // ===========================================================================
88
+ describe('markdown backticks treated as whitespace', () => {
89
+ test('inline backtick-wrapped path extracts cleanly', () => {
90
+ const result = canonicalize('Use `/ip/address/print` to list');
91
+ // "Use" still becomes a phantom (finding #1, see H1 lenient mode).
92
+ // But the backticks no longer pollute the path.
93
+ const cmd = result.commands.find(c => c.verb === 'print');
94
+ expect(cmd).toBeDefined();
95
+ expect(cmd?.path).toContain('/ip/address');
96
+ });
97
+
98
+ test('markdown fence wrapper does not break extraction', () => {
99
+ const result = canonicalize('```routeros\n/ip/address/print\n```');
100
+ const cmd = result.commands.find(c => c.verb === 'print');
101
+ expect(cmd?.path).toBe('/ip/address');
102
+ });
103
+ });
104
+
105
+ // ===========================================================================
106
+ // Universal verbs added 2026-04-25 — clear, unset, reset-counters,
107
+ // reset-counters-all (verified universal in rosetta DB; safe to add)
108
+ // ===========================================================================
109
+ describe('expanded universal verbs', () => {
110
+ test('unset is recognised', () => {
111
+ const r = canonicalize('/ip/address/unset disabled');
112
+ expect(r.commands[0].path).toBe('/ip/address');
113
+ expect(r.commands[0].verb).toBe('unset');
114
+ });
115
+
116
+ test('clear is recognised', () => {
117
+ const r = canonicalize('/system/logging/action/clear name=test');
118
+ const c = r.commands.find(x => x.verb === 'clear');
119
+ expect(c?.path).toBe('/system/logging/action');
120
+ });
121
+
122
+ test('reset-counters is recognised', () => {
123
+ const r = canonicalize('/interface/reset-counters ether1');
124
+ expect(r.commands[0].verb).toBe('reset-counters');
125
+ expect(r.commands[0].path).toBe('/interface');
126
+ });
127
+
128
+ test('reset-counters-all is recognised', () => {
129
+ const r = canonicalize('/ip/firewall/filter/reset-counters-all');
130
+ expect(r.commands[0].verb).toBe('reset-counters-all');
131
+ expect(r.commands[0].path).toBe('/ip/firewall/filter');
132
+ });
133
+ });
134
+
135
+ // ===========================================================================
136
+ // Variables: today they tolerate as-is in args; not mistaken for paths
137
+ // when AFTER a verb (because then verb is set and they fall into the
138
+ // args branch). This is the safe case.
139
+ // ===========================================================================
140
+ describe('variables in args (safe positions)', () => {
141
+ test('$var after verb stays in args', () => {
142
+ const r = canonicalize('/ip/address/remove $myAddr');
143
+ expect(r.commands[0].args).toEqual(['$myAddr']);
144
+ });
145
+
146
+ test('$var inside subshell after verb', () => {
147
+ const r = canonicalize('/ip/address set [find interface=$iface] disabled=yes');
148
+ const find = r.commands.find(c => c.verb === 'find');
149
+ expect(find?.args.some(a => a.includes('$iface'))).toBe(true);
150
+ });
151
+ });
152
+
153
+ // ===========================================================================
154
+ // Common scripting constructs that work today
155
+ // ===========================================================================
156
+ describe(':foreach extracts inner commands', () => {
157
+ test('extracts find subshell + remove command', () => {
158
+ const r = canonicalize(':foreach i in=[/ip address find] do={ /ip address remove $i }');
159
+ const find = r.commands.find(c => c.verb === 'find' && c.subshell);
160
+ const remove = r.commands.find(c => c.verb === 'remove');
161
+ expect(find?.path).toBe('/ip/address');
162
+ expect(remove?.path).toBe('/ip/address');
163
+ });
164
+ });
165
+
166
+ describe(':do { } while=', () => {
167
+ test('extracts inner run command', () => {
168
+ const r = canonicalize(':do { /system/script/run myscript } while=($i < 5)');
169
+ const run = r.commands.find(c => c.verb === 'run');
170
+ expect(run?.path).toBe('/system/script');
171
+ });
172
+ });
173
+
174
+ describe('scheduler one-liner', () => {
175
+ test('three semicolon-separated commands extract cleanly', () => {
176
+ const ps = paths('/ip/firewall/filter/print; /ip/address/print; /system/identity/print');
177
+ expect(ps).toEqual([
178
+ '/ip/firewall/filter/print',
179
+ '/ip/address/print',
180
+ '/system/identity/print',
181
+ ]);
182
+ });
183
+ });
184
+
185
+ // ===========================================================================
186
+ // Anchor tests for known-bad behaviour (audit findings)
187
+ // These DOCUMENT current behaviour. When a hardening lands, flip them.
188
+ // ===========================================================================
189
+ describe('finding #1 — mid-line slash does not restart path (anchor)', () => {
190
+ test('leading prose word becomes phantom path segment', () => {
191
+ // Today: "Run" gets jammed into the path.
192
+ // After H1 (lenient mode): would be dropped.
193
+ const r = canonicalize('Run /ip/address/print');
194
+ expect(r.commands[0].path).toBe('/Run/ip/address');
195
+ expect(r.commands[0].verb).toBe('print');
196
+ });
197
+
198
+ test('two paths joined by " and " merge into one command', () => {
199
+ // Today: only the first command is recognised; the second path
200
+ // becomes positional args.
201
+ // After H1 (lenient mode): both should extract independently.
202
+ const r = canonicalize('/ip/address/print and /ip/route/print');
203
+ expect(r.commands.length).toBe(1);
204
+ expect(r.commands[0].path).toBe('/ip/address');
205
+ expect(r.commands[0].args).toContain('and');
206
+ });
207
+ });
208
+
209
+ describe('finding #4 — menu-specific verbs', () => {
210
+ // Today (no resolver): zero commands. /interface/wifi-qcom/info is not in
211
+ // the universal verb set so the parser treats it as a path without a verb.
212
+ // H4 fixed this for callers that wire a resolver.
213
+ test('/interface/wifi-qcom/info without resolver produces no command (anchor)', () => {
214
+ const r = canonicalize('/interface/wifi-qcom/info');
215
+ expect(r.commands.length).toBe(0);
216
+ });
217
+
218
+ // H4 — supplying a path-aware resolver makes /interface/wifi-qcom/info
219
+ // classify correctly. The resolver returns true for `info` at
220
+ // /interface/wifi-qcom only, mirroring a DB/live-router path-specific hit.
221
+ const wifiQcomResolver = (token: string, parentPath: string) =>
222
+ parentPath === '/interface/wifi-qcom' && token === 'info';
223
+
224
+ test('H4: /interface/wifi-qcom/info with resolver classifies as cmd', () => {
225
+ const r = canonicalize('/interface/wifi-qcom/info', '/', { isVerb: wifiQcomResolver });
226
+ expect(r.commands.length).toBe(1);
227
+ expect(r.commands[0].path).toBe('/interface/wifi-qcom');
228
+ expect(r.commands[0].verb).toBe('info');
229
+ });
230
+
231
+ test('H4: /interface/wireless/info with same resolver stays a path (info is a dir there)', () => {
232
+ // Resolver only returns true for `info` at /interface/wifi-qcom, not /interface/wireless.
233
+ // The token `info` at /interface/wireless is therefore navigation, not
234
+ // a verb — exactly the disambiguation H4 enables.
235
+ const r = canonicalize('/interface/wireless/info', '/', { isVerb: wifiQcomResolver });
236
+ expect(r.commands.length).toBe(0);
237
+ expect(r.finalPath).toBe('/interface/wireless/info');
238
+ });
239
+
240
+ test('H4: universal-verb-set still works when resolver is supplied', () => {
241
+ // The DB resolver supplements path-specific verbs, but it is not allowed
242
+ // to disable universal helpers. RouterOS command data does not enumerate
243
+ // all helper verbs (notably `find`) under every parent path.
244
+ const r = canonicalize('/ip/address/print', '/', {
245
+ isVerb: () => false,
246
+ });
247
+ expect(r.commands[0]?.path).toBe('/ip/address');
248
+ expect(r.commands[0]?.verb).toBe('print');
249
+ });
250
+ });
251
+
252
+ describe('finding #3 — paren expression scope needs re-audit (anchor)', () => {
253
+ test(':if with paren expression preserves recognized inner command', () => {
254
+ // The original audit used /log/info here, but the zero-command result was
255
+ // confounded by finding #4 (menu-specific verb recognition). With a
256
+ // recognized command in the body, current code does preserve the block.
257
+ // H3 stays as a re-audit item for expression-scope correctness.
258
+ const r = canonicalize(':if ($x = 1) do={ /ip/address/print }');
259
+ expect(r.commands[0]?.path).toBe('/ip/address');
260
+ expect(r.commands[0]?.verb).toBe('print');
261
+ });
262
+ });
263
+
264
+ describe('finding #7 — pure path mention not in extractPaths (anchor)', () => {
265
+ test('bare path mention returns empty extractPaths', () => {
266
+ // extractPaths only includes commands that have a verb. Use
267
+ // extractMentions for navigation-only references — see H6 below.
268
+ const ps = extractPaths('/ip/firewall/filter');
269
+ expect(ps).toEqual([]);
270
+ });
271
+ });
272
+
273
+ // ===========================================================================
274
+ // H6 — extractMentions surfaces bare path mentions
275
+ // ===========================================================================
276
+ describe('H6: extractMentions for navigation-only references', () => {
277
+ test('bare /ip/firewall/filter is reported as a mention', () => {
278
+ const m = extractMentions('/ip/firewall/filter');
279
+ expect(m).toEqual(['/ip/firewall/filter']);
280
+ });
281
+
282
+ test('two semicolon-separated bare paths surface both', () => {
283
+ // Note: prose like "See /ip/firewall/filter and /ip/firewall/nat" is
284
+ // still mishandled by the mid-line-slash issue (finding #1, H1). Use
285
+ // explicit separators here — that's what extractMentions can support
286
+ // before lenient mode lands.
287
+ const m = extractMentions('/ip/firewall/filter ; /ip/firewall/nat');
288
+ expect(m).toContain('/ip/firewall/filter');
289
+ expect(m).toContain('/ip/firewall/nat');
290
+ });
291
+
292
+ test('command path also surfaces as mention (dir + dir/verb)', () => {
293
+ const m = extractMentions('/ip/address/print');
294
+ // Contains both the verbed path and the dir.
295
+ expect(m).toContain('/ip/address/print');
296
+ expect(m).toContain('/ip/address');
297
+ });
298
+
299
+ test('mentions are deduped in order of first appearance', () => {
300
+ const m = extractMentions('/ip/address ; /ip/address/print');
301
+ // /ip/address shows up twice (nav + dir-of-cmd) but is only emitted once.
302
+ expect(m.filter(p => p === '/ip/address').length).toBe(1);
303
+ });
304
+ });
305
+
306
+ describe('finding #8 — source={…} block-as-value misread (anchor)', () => {
307
+ test('outer add command is dropped when source is a block', () => {
308
+ // Today: extracts only the inner /ip/address/print and DROPS the
309
+ // outer /system/script/add.
310
+ // After H5: { after key= should be a quoted block value, not
311
+ // a scope to recurse into.
312
+ const r = canonicalize('/system/script/add name=foo source={ /ip/address/print }');
313
+ const inner = r.commands.find(c => c.verb === 'print');
314
+ const outer = r.commands.find(c => c.verb === 'add');
315
+ expect(inner).toBeDefined(); // current behaviour
316
+ expect(outer).toBeUndefined(); // ← this is the bug
317
+ });
318
+ });
319
+
320
+ // ===========================================================================
321
+ // Hardenings not yet shipped — todo() so they show in the runner output
322
+ // ===========================================================================
323
+ // ===========================================================================
324
+ // H8 — confidence flag on each CanonicalCommand
325
+ // ===========================================================================
326
+ describe('H8: confidence flag', () => {
327
+ test('high: absolute path with explicit verb', () => {
328
+ const r = canonicalize('/ip/address/print');
329
+ expect(r.commands[0].confidence).toBe('high');
330
+ });
331
+
332
+ test('medium: relative path with cwd', () => {
333
+ const r = canonicalize('print', '/ip/address');
334
+ expect(r.commands[0].confidence).toBe('medium');
335
+ });
336
+
337
+ test('low: verb inferred at flush time when no path context exists', () => {
338
+ // Bare word `print` with cwd='/' gates out of the explicit-verb check
339
+ // (the parser only treats a word as a verb if it has path context).
340
+ // It falls through to the path-segment branch and flushCommand's
341
+ // trailing-segment-as-verb fallback promotes it. That is exactly the
342
+ // looser/prose-shaped path H8 flags as 'low'.
343
+ const r = canonicalize('print');
344
+ expect(r.commands[0]?.verb).toBe('print');
345
+ expect(r.commands[0]?.confidence).toBe('low');
346
+ });
347
+
348
+ test('subshell command keeps confidence on the inner command', () => {
349
+ const r = canonicalize('/ip/address set [find interface=ether1] disabled=yes');
350
+ const find = r.commands.find(c => c.subshell);
351
+ const set = r.commands.find(c => c.verb === 'set');
352
+ expect(find?.confidence).toBeDefined();
353
+ expect(set?.confidence).toBe('high');
354
+ });
355
+ });
356
+
357
+ // ===========================================================================
358
+ // Hardenings not yet shipped — todo() so they show in the runner output
359
+ // ===========================================================================
360
+ describe('hardenings not yet shipped', () => {
361
+ test.todo('H1: lenient mode drops leading prose word', () => {
362
+ // const r = canonicalize('Run /ip/address/print', '/', { mode: 'lenient' });
363
+ // expect(r.commands).toEqual([
364
+ // { path: '/ip/address', verb: 'print', args: [], confidence: 'low' },
365
+ // ]);
366
+ });
367
+ test.todo('H1: lenient mode splits "/a/b/c and /d/e/f" into two commands', () => {});
368
+ test.todo('H2: $variable becomes Tok.Var, never a path segment', () => {});
369
+ test.todo('H3: re-audit paren expression scope independent of menu-specific verbs', () => {});
370
+ test.todo('H5: source={ … } in /system/script/add is a value, not a scope', () => {});
371
+ });
@@ -3,6 +3,7 @@ import {
3
3
  _normalizePath,
4
4
  _tokenize,
5
5
  canonicalize,
6
+ extractMentions,
6
7
  extractPaths,
7
8
  primaryPath,
8
9
  } from './canonicalize.ts';
@@ -518,3 +519,70 @@ describe('edge cases', () => {
518
519
  expect(printCmd?.path).toBe('/interface');
519
520
  });
520
521
  });
522
+
523
+ // ===========================================================================
524
+ // CanonicalizeOptions — pluggable isVerb resolver (H4)
525
+ // ===========================================================================
526
+ describe('CanonicalizeOptions.isVerb', () => {
527
+ test('resolver receives non-universal token and parent path', () => {
528
+ const calls: Array<{ token: string; parentPath: string }> = [];
529
+ canonicalize('/interface/wifi-qcom/info', '/', {
530
+ isVerb: (token, parentPath) => {
531
+ calls.push({ token, parentPath });
532
+ return token === 'info' && parentPath === '/interface/wifi-qcom';
533
+ },
534
+ });
535
+ expect(calls.some(c => c.token === 'info' && c.parentPath === '/interface/wifi-qcom')).toBe(true);
536
+ });
537
+
538
+ test('resolver call site is path-aware: same token, different parent', () => {
539
+ // Call with a resolver that ONLY recognizes `info` at /interface/wifi-qcom.
540
+ const isVerb = (token: string, parentPath: string) =>
541
+ token === 'info' && parentPath === '/interface/wifi-qcom';
542
+
543
+ const atWifiQcom = canonicalize('/interface/wifi-qcom/info', '/', { isVerb });
544
+ expect(atWifiQcom.commands[0]?.verb).toBe('info');
545
+ expect(atWifiQcom.commands[0]?.path).toBe('/interface/wifi-qcom');
546
+
547
+ // Same token at a different path → not a verb, treated as navigation.
548
+ const atWifi = canonicalize('/interface/wireless/info', '/', { isVerb });
549
+ expect(atWifi.commands.length).toBe(0);
550
+ expect(atWifi.finalPath).toBe('/interface/wireless/info');
551
+ });
552
+
553
+ test('universal verbs remain active when a resolver is supplied', () => {
554
+ const result = canonicalize('/ip/address set [find interface=ether1] disabled=yes', '/', {
555
+ isVerb: () => false,
556
+ });
557
+ const setCmd = result.commands.find(c => c.verb === 'set' && !c.subshell);
558
+ const findCmd = result.commands.find(c => c.verb === 'find' && c.subshell);
559
+ expect(setCmd?.path).toBe('/ip/address');
560
+ expect(findCmd?.path).toBe('/ip/address');
561
+ });
562
+
563
+ test('omitting options preserves backward-compatible behaviour', () => {
564
+ const r1 = canonicalize('/ip/address/print');
565
+ const r2 = canonicalize('/ip/address/print', '/');
566
+ expect(r1.commands[0]?.verb).toBe('print');
567
+ expect(r2.commands[0]?.verb).toBe('print');
568
+ });
569
+ });
570
+
571
+ // ===========================================================================
572
+ // extractMentions (H6) — sanity unit tests
573
+ // ===========================================================================
574
+ describe('extractMentions', () => {
575
+ test('verbed command surfaces both dir and dir/verb', () => {
576
+ const m = extractMentions('/ip/address/print');
577
+ expect(m).toContain('/ip/address');
578
+ expect(m).toContain('/ip/address/print');
579
+ });
580
+
581
+ test('bare path surfaces as a single mention', () => {
582
+ expect(extractMentions('/ip/firewall/filter')).toEqual(['/ip/firewall/filter']);
583
+ });
584
+
585
+ test('empty input → empty mentions', () => {
586
+ expect(extractMentions('')).toEqual([]);
587
+ });
588
+ });