@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 +3 -2
- package/package.json +1 -1
- package/src/browse.ts +12 -2
- package/src/canonicalize-resolver.ts +59 -0
- package/src/canonicalize.fuzz.test.ts +371 -0
- package/src/canonicalize.test.ts +68 -0
- package/src/canonicalize.ts +240 -20
- package/src/classify.test.ts +16 -3
- package/src/classify.ts +36 -8
- package/src/extract-videos.ts +46 -4
- package/src/gc-versions.test.ts +201 -0
- package/src/gc-versions.ts +230 -0
- package/src/mcp-contract.test.ts +7 -10
- package/src/mcp-http.test.ts +6 -5
- package/src/mcp.ts +91 -49
- package/src/query.test.ts +146 -3
- package/src/query.ts +252 -14
- package/src/release.test.ts +127 -1
- package/src/setup.test.ts +109 -2
- package/src/setup.ts +359 -120
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
|
|
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
|
|
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
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
|
|
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
|
+
});
|
package/src/canonicalize.test.ts
CHANGED
|
@@ -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
|
+
});
|