@tikoci/rosetta 0.6.2 → 0.6.4

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tikoci/rosetta",
3
- "version": "0.6.2",
3
+ "version": "0.6.4",
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
@@ -278,6 +278,10 @@ function renderSearchResults(resp: SearchResponse): string {
278
278
  if (r.url) {
279
279
  out.push(` ${cyan(link(r.url, dim(r.url)))}`);
280
280
  }
281
+ // Show best matching section if available
282
+ if (r.best_section) {
283
+ out.push(` ${dim("§")} ${r.best_section.heading}`);
284
+ }
281
285
  // Show excerpt with highlight markers converted to bold
282
286
  const excerpt = r.excerpt.replace(/>>>/g, `${ESC}[1m`).replace(/<<</g, `${ESC}[0m`);
283
287
  out.push(` ${dim(truncate(excerpt, w - 8))}`);
@@ -0,0 +1,520 @@
1
+ import { describe, expect, test } from 'bun:test';
2
+ import {
3
+ _normalizePath,
4
+ _tokenize,
5
+ canonicalize,
6
+ extractPaths,
7
+ primaryPath,
8
+ } from './canonicalize.ts';
9
+
10
+ // ---------------------------------------------------------------------------
11
+ // Helper: shorthand for asserting a single command
12
+ // ---------------------------------------------------------------------------
13
+ function expectSingle(input: string, expected: { path: string; verb: string; args?: string[] }, cwd = '/') {
14
+ const result = canonicalize(input, cwd);
15
+ expect(result.commands.length).toBeGreaterThanOrEqual(1);
16
+ const cmd = result.commands.find(c => !c.subshell) ?? result.commands[0];
17
+ expect(cmd.path).toBe(expected.path);
18
+ expect(cmd.verb).toBe(expected.verb);
19
+ if (expected.args) {
20
+ expect(cmd.args).toEqual(expected.args);
21
+ }
22
+ }
23
+
24
+ // ===========================================================================
25
+ // Tokenizer
26
+ // ===========================================================================
27
+ describe('tokenizer', () => {
28
+ test('simple path + command', () => {
29
+ const tokens = _tokenize('/ip/address/print');
30
+ const types = tokens.map(t => t.value);
31
+ expect(types).toEqual(['/', 'ip', '/', 'address', '/', 'print']);
32
+ });
33
+
34
+ test('handles comments', () => {
35
+ const tokens = _tokenize('/ip/address/print # list addresses');
36
+ const words = tokens.filter(t => t.value !== '/').map(t => t.value);
37
+ expect(words).toContain('print');
38
+ expect(words).not.toContain('#');
39
+ });
40
+
41
+ test('handles quoted strings', () => {
42
+ const tokens = _tokenize('comment="my comment"');
43
+ expect(tokens.some(t => t.value.includes('my comment'))).toBe(true);
44
+ });
45
+
46
+ test('handles line continuation', () => {
47
+ const tokens = _tokenize('/ip/route/add \\\n gateway=1.1.1.1');
48
+ const words = tokens.map(t => t.value);
49
+ expect(words).toContain('gateway=1.1.1.1');
50
+ });
51
+ });
52
+
53
+ // ===========================================================================
54
+ // Path normalization
55
+ // ===========================================================================
56
+ describe('normalizePath', () => {
57
+ test('adds leading slash', () => {
58
+ expect(_normalizePath('ip/address')).toBe('/ip/address');
59
+ });
60
+
61
+ test('root stays root', () => {
62
+ expect(_normalizePath('/')).toBe('/');
63
+ });
64
+
65
+ test('removes trailing slash', () => {
66
+ expect(_normalizePath('/ip/address/')).toBe('/ip/address');
67
+ });
68
+
69
+ test('collapses double slashes', () => {
70
+ expect(_normalizePath('//ip//address')).toBe('/ip/address');
71
+ });
72
+ });
73
+
74
+ // ===========================================================================
75
+ // Basic absolute paths
76
+ // ===========================================================================
77
+ describe('absolute paths', () => {
78
+ test('/ip/address/print — fully qualified', () => {
79
+ expectSingle('/ip/address/print', { path: '/ip/address', verb: 'print' });
80
+ });
81
+
82
+ test('/ip/address/set — fully qualified', () => {
83
+ expectSingle('/ip/address/set', { path: '/ip/address', verb: 'set' });
84
+ });
85
+
86
+ test('/ip/address/add with args', () => {
87
+ expectSingle('/ip/address/add address=192.168.1.1/24 interface=ether1', {
88
+ path: '/ip/address',
89
+ verb: 'add',
90
+ args: ['address=192.168.1.1/24', 'interface=ether1'],
91
+ });
92
+ });
93
+
94
+ test('/ip/firewall/filter/add — deep path', () => {
95
+ expectSingle('/ip/firewall/filter/add chain=forward', {
96
+ path: '/ip/firewall/filter',
97
+ verb: 'add',
98
+ args: ['chain=forward'],
99
+ });
100
+ });
101
+
102
+ test('/system/reboot — command-only path', () => {
103
+ expectSingle('/system/reboot', { path: '/system', verb: 'reboot' });
104
+ });
105
+ });
106
+
107
+ // ===========================================================================
108
+ // Space-separated paths (both forms valid in RouterOS)
109
+ // ===========================================================================
110
+ describe('space-separated paths', () => {
111
+ test('/ip address print', () => {
112
+ expectSingle('/ip address print', { path: '/ip/address', verb: 'print' });
113
+ });
114
+
115
+ test('/ip route add gateway=1.1.1.1', () => {
116
+ expectSingle('/ip route add gateway=1.1.1.1', {
117
+ path: '/ip/route',
118
+ verb: 'add',
119
+ args: ['gateway=1.1.1.1'],
120
+ });
121
+ });
122
+
123
+ test('/ip firewall filter add chain=forward', () => {
124
+ expectSingle('/ip firewall filter add chain=forward', {
125
+ path: '/ip/firewall/filter',
126
+ verb: 'add',
127
+ args: ['chain=forward'],
128
+ });
129
+ });
130
+ });
131
+
132
+ // ===========================================================================
133
+ // Mixed slash/space paths (explicitly documented in RouterOS Console page)
134
+ // ===========================================================================
135
+ describe('mixed slash/space paths', () => {
136
+ test('/ip firewall/filter/add chain=forward', () => {
137
+ expectSingle('/ip firewall/filter/add chain=forward', {
138
+ path: '/ip/firewall/filter',
139
+ verb: 'add',
140
+ args: ['chain=forward'],
141
+ });
142
+ });
143
+
144
+ test('/ip/firewall filter add chain=forward', () => {
145
+ expectSingle('/ip/firewall filter add chain=forward', {
146
+ path: '/ip/firewall/filter',
147
+ verb: 'add',
148
+ args: ['chain=forward'],
149
+ });
150
+ });
151
+ });
152
+
153
+ // ===========================================================================
154
+ // Relative paths with cwd
155
+ // ===========================================================================
156
+ describe('relative paths', () => {
157
+ test('set disabled=yes from /ip/address cwd', () => {
158
+ expectSingle('set disabled=yes', { path: '/ip/address', verb: 'set', args: ['disabled=yes'] }, '/ip/address');
159
+ });
160
+
161
+ test('print from /ip/route cwd', () => {
162
+ expectSingle('print', { path: '/ip/route', verb: 'print' }, '/ip/route');
163
+ });
164
+
165
+ test('add gateway=1.1.1.1 from /ip/route cwd', () => {
166
+ expectSingle('add gateway=1.1.1.1', {
167
+ path: '/ip/route',
168
+ verb: 'add',
169
+ args: ['gateway=1.1.1.1'],
170
+ }, '/ip/route');
171
+ });
172
+ });
173
+
174
+ // ===========================================================================
175
+ // Missing leading slash (tolerance)
176
+ // ===========================================================================
177
+ describe('missing leading slash', () => {
178
+ test('ip address print — treated as path from root', () => {
179
+ expectSingle('ip address print', { path: '/ip/address', verb: 'print' });
180
+ });
181
+
182
+ test('ip address/set — mixed', () => {
183
+ expectSingle('ip address/set', { path: '/ip/address', verb: 'set' });
184
+ });
185
+ });
186
+
187
+ // ===========================================================================
188
+ // .. (parent) navigation
189
+ // ===========================================================================
190
+ describe('.. navigation', () => {
191
+ test('.. from /ip/address → /ip', () => {
192
+ const result = canonicalize('..', '/ip/address');
193
+ expect(result.finalPath).toBe('/ip');
194
+ });
195
+
196
+ test('../route print from /ip/address', () => {
197
+ expectSingle('../route print', { path: '/ip/route', verb: 'print' }, '/ip/address');
198
+ });
199
+
200
+ test('.. service-port print from /ip/firewall/nat', () => {
201
+ expectSingle('.. service-port print', {
202
+ path: '/ip/firewall/service-port',
203
+ verb: 'print',
204
+ }, '/ip/firewall/nat');
205
+ });
206
+ });
207
+
208
+ // ===========================================================================
209
+ // Subshells [...]
210
+ // ===========================================================================
211
+ describe('subshells [...]', () => {
212
+ test('basic find subshell', () => {
213
+ const result = canonicalize('/ip/address set [find interface=ether1] disabled=yes');
214
+ // Should produce find subshell + set outer command
215
+ const findCmd = result.commands.find(c => c.verb === 'find');
216
+ const setCmd = result.commands.find(c => c.verb === 'set');
217
+ expect(findCmd).toBeDefined();
218
+ expect(findCmd!.subshell).toBe(true);
219
+ expect(findCmd!.path).toBe('/ip/address');
220
+ expect(setCmd).toBeDefined();
221
+ expect(setCmd!.path).toBe('/ip/address');
222
+ expect(setCmd!.verb).toBe('set');
223
+ });
224
+
225
+ test('nested subshells: /ip route get [find gateway=1.1.1.1]', () => {
226
+ const result = canonicalize(':put [/ip route get [find gateway=1.1.1.1]]');
227
+ // Inner find runs in /ip/route context, outer get also in /ip/route
228
+ const findCmd = result.commands.find(c => c.verb === 'find');
229
+ const getCmd = result.commands.find(c => c.verb === 'get');
230
+ expect(findCmd).toBeDefined();
231
+ expect(findCmd!.path).toBe('/ip/route');
232
+ expect(findCmd!.subshell).toBe(true);
233
+ expect(getCmd).toBeDefined();
234
+ expect(getCmd!.path).toBe('/ip/route');
235
+ });
236
+
237
+ test('subshell with absolute path inside', () => {
238
+ const result = canonicalize('/interface set [/ip address find] name=test', '/system');
239
+ const findCmd = result.commands.find(c => c.verb === 'find');
240
+ expect(findCmd).toBeDefined();
241
+ expect(findCmd!.path).toBe('/ip/address');
242
+ expect(findCmd!.subshell).toBe(true);
243
+ });
244
+
245
+ test('complex bridge example with nested subshells', () => {
246
+ // /interface/bridge/set bridge1 pvid=[port/get [find interface=ether1] pvid]
247
+ const result = canonicalize(
248
+ '/interface/bridge/set bridge1 pvid=[port/get [find interface=ether1] pvid]'
249
+ );
250
+ const setCmd = result.commands.find(c => c.verb === 'set' && !c.subshell);
251
+ expect(setCmd).toBeDefined();
252
+ expect(setCmd!.path).toBe('/interface/bridge');
253
+
254
+ // The subshell commands should inherit /interface/bridge context
255
+ const findCmd = result.commands.find(c => c.verb === 'find');
256
+ expect(findCmd).toBeDefined();
257
+ expect(findCmd!.subshell).toBe(true);
258
+ });
259
+ });
260
+
261
+ // ===========================================================================
262
+ // { } blocks — path scoping
263
+ // ===========================================================================
264
+ describe('{ } blocks', () => {
265
+ test('block with path prefix changes cwd within block', () => {
266
+ const result = canonicalize('/ip/address { print }');
267
+ const printCmd = result.commands.find(c => c.verb === 'print');
268
+ expect(printCmd).toBeDefined();
269
+ expect(printCmd!.path).toBe('/ip/address');
270
+ });
271
+
272
+ test('path persists after block exit', () => {
273
+ const result = canonicalize('/ip/address { print }\nprint');
274
+ // The second print should be at /ip/address since path persists after }
275
+ const printCmds = result.commands.filter(c => c.verb === 'print');
276
+ expect(printCmds.length).toBe(2);
277
+ expect(printCmds[1].path).toBe('/ip/address');
278
+ });
279
+
280
+ test('block with local scope', () => {
281
+ const result = canonicalize('{\n :local a 3;\n /ip/address/print\n}');
282
+ const printCmd = result.commands.find(c => c.verb === 'print');
283
+ expect(printCmd).toBeDefined();
284
+ expect(printCmd!.path).toBe('/ip/address');
285
+ });
286
+ });
287
+
288
+ // ===========================================================================
289
+ // ; separator
290
+ // ===========================================================================
291
+ describe('; separator', () => {
292
+ test('multiple commands separated by ;', () => {
293
+ const result = canonicalize('/ip/address/print; /ip/route/print');
294
+ expect(result.commands.length).toBe(2);
295
+ expect(result.commands[0].path).toBe('/ip/address');
296
+ expect(result.commands[0].verb).toBe('print');
297
+ expect(result.commands[1].path).toBe('/ip/route');
298
+ expect(result.commands[1].verb).toBe('print');
299
+ });
300
+
301
+ test('; in scripts — path context carries forward correctly', () => {
302
+ const result = canonicalize('/system/package; print');
303
+ // Second command should inherit /system/package path
304
+ const cmds = result.commands.filter(c => c.verb === 'print');
305
+ expect(cmds.length).toBeGreaterThanOrEqual(1);
306
+ });
307
+ });
308
+
309
+ // ===========================================================================
310
+ // ICE commands (: prefix) — should be skipped/transparent
311
+ // ===========================================================================
312
+ describe('ICE commands', () => {
313
+ test(':put is skipped, path commands within are still extracted', () => {
314
+ const result = canonicalize(':put [/system/resource/get version]');
315
+ const getCmd = result.commands.find(c => c.verb === 'get');
316
+ expect(getCmd).toBeDefined();
317
+ expect(getCmd!.path).toBe('/system/resource');
318
+ });
319
+
320
+ test(':local and :global are skipped', () => {
321
+ const result = canonicalize(':local myVar "test"\n/ip/address/print');
322
+ const printCmd = result.commands.find(c => c.verb === 'print');
323
+ expect(printCmd).toBeDefined();
324
+ expect(printCmd!.path).toBe('/ip/address');
325
+ });
326
+ });
327
+
328
+ // ===========================================================================
329
+ // finalPath tracking (interactive session cwd)
330
+ // ===========================================================================
331
+ describe('finalPath', () => {
332
+ test('absolute navigation updates finalPath', () => {
333
+ const result = canonicalize('/ip/address');
334
+ expect(result.finalPath).toBe('/ip/address');
335
+ });
336
+
337
+ test('/ resets to root', () => {
338
+ const result = canonicalize('/', '/ip/address');
339
+ expect(result.finalPath).toBe('/');
340
+ });
341
+
342
+ test('.. navigates up', () => {
343
+ const result = canonicalize('..', '/ip/address');
344
+ expect(result.finalPath).toBe('/ip');
345
+ });
346
+
347
+ test('command execution preserves path', () => {
348
+ // /ip/address print should leave us at /ip/address
349
+ const result = canonicalize('/ip/address print');
350
+ expect(result.finalPath).toBe('/ip/address');
351
+ });
352
+ });
353
+
354
+ // ===========================================================================
355
+ // Convenience functions
356
+ // ===========================================================================
357
+ describe('extractPaths', () => {
358
+ test('returns unique paths', () => {
359
+ const paths = extractPaths('/ip/address/add address=1.2.3.4/24 interface=ether1; /ip/address/print');
360
+ expect(paths).toContain('/ip/address/add');
361
+ expect(paths).toContain('/ip/address/print');
362
+ });
363
+
364
+ test('includes subshell paths', () => {
365
+ const paths = extractPaths('/ip/address set [find interface=ether1] disabled=yes');
366
+ expect(paths).toContain('/ip/address/find');
367
+ expect(paths).toContain('/ip/address/set');
368
+ });
369
+ });
370
+
371
+ describe('primaryPath', () => {
372
+ test('returns first non-subshell path', () => {
373
+ const path = primaryPath('/ip/address set [find interface=ether1] disabled=yes');
374
+ expect(path).toBe('/ip/address');
375
+ });
376
+
377
+ test('returns null for empty input', () => {
378
+ expect(primaryPath('')).toBeNull();
379
+ });
380
+
381
+ test('returns path for simple command', () => {
382
+ expect(primaryPath('/ip/address/print')).toBe('/ip/address');
383
+ });
384
+ });
385
+
386
+ // ===========================================================================
387
+ // Real-world examples from RouterOS docs
388
+ // ===========================================================================
389
+ describe('real-world examples', () => {
390
+ test('from Console page: /ip firewall/filter/add chain=forward place-before=[find where comment=CommentX]', () => {
391
+ const result = canonicalize(
392
+ '/ip firewall/filter/add chain=forward place-before=[find where comment=CommentX]'
393
+ );
394
+ const addCmd = result.commands.find(c => c.verb === 'add' && !c.subshell);
395
+ expect(addCmd).toBeDefined();
396
+ expect(addCmd!.path).toBe('/ip/firewall/filter');
397
+ expect(addCmd!.args.some(a => a.startsWith('chain='))).toBe(true);
398
+
399
+ const findCmd = result.commands.find(c => c.verb === 'find');
400
+ expect(findCmd).toBeDefined();
401
+ expect(findCmd!.subshell).toBe(true);
402
+ });
403
+
404
+ test('from Console page: /ip/firewall/filter/add chain=forward', () => {
405
+ expectSingle('/ip/firewall/filter/add chain=forward', {
406
+ path: '/ip/firewall/filter',
407
+ verb: 'add',
408
+ args: ['chain=forward'],
409
+ });
410
+ });
411
+
412
+ test('DHCP lease script', () => {
413
+ const result = canonicalize('/ip dhcp-server set myServer lease-script=myLeaseScript');
414
+ const setCmd = result.commands.find(c => c.verb === 'set');
415
+ expect(setCmd).toBeDefined();
416
+ expect(setCmd!.path).toBe('/ip/dhcp-server');
417
+ });
418
+
419
+ test('scripting: :put with nested subshells', () => {
420
+ const result = canonicalize(':put [/ip route get [find gateway=1.1.1.1]]');
421
+ const findCmd = result.commands.find(c => c.verb === 'find');
422
+ const getCmd = result.commands.find(c => c.verb === 'get');
423
+ expect(findCmd).toBeDefined();
424
+ expect(getCmd).toBeDefined();
425
+ expect(findCmd!.path).toBe('/ip/route');
426
+ expect(getCmd!.path).toBe('/ip/route');
427
+ });
428
+
429
+ test('firewall rule with find subshell', () => {
430
+ const result = canonicalize(
431
+ '/ip/address/set [find interface=ether1] address=10.0.0.1/24'
432
+ );
433
+ const setCmd = result.commands.find(c => c.verb === 'set' && !c.subshell);
434
+ const findCmd = result.commands.find(c => c.verb === 'find');
435
+ expect(setCmd).toBeDefined();
436
+ expect(setCmd!.path).toBe('/ip/address');
437
+ expect(findCmd).toBeDefined();
438
+ expect(findCmd!.path).toBe('/ip/address');
439
+ expect(findCmd!.subshell).toBe(true);
440
+ });
441
+
442
+ test('interface set with name', () => {
443
+ expectSingle('/interface set ether1 mtu=1460', {
444
+ path: '/interface',
445
+ verb: 'set',
446
+ args: ['ether1', 'mtu=1460'],
447
+ });
448
+ });
449
+
450
+ test('multiline script with variable scope', () => {
451
+ const result = canonicalize(`{
452
+ :local a 3
453
+ /ip/address/print
454
+ /ip/route/add gateway=1.1.1.1
455
+ }`);
456
+ const printCmd = result.commands.find(c => c.verb === 'print');
457
+ const addCmd = result.commands.find(c => c.verb === 'add');
458
+ expect(printCmd).toBeDefined();
459
+ expect(printCmd!.path).toBe('/ip/address');
460
+ expect(addCmd).toBeDefined();
461
+ expect(addCmd!.path).toBe('/ip/route');
462
+ });
463
+
464
+ test('user example: bridge pvid subshell', () => {
465
+ // /interface/bridge/set bridge1 pvid=[port/get [find interface=ether1] pvid]
466
+ // The outer command is /interface/bridge set
467
+ // The [port/get ...] subshell should resolve relative to /interface/bridge
468
+ // The [find ...] inside that should resolve relative to /interface/bridge/port
469
+ const result = canonicalize(
470
+ '/interface/bridge/set bridge1 pvid=[port/get [find interface=ether1] pvid]'
471
+ );
472
+ const setCmd = result.commands.find(c => c.verb === 'set' && !c.subshell);
473
+ expect(setCmd).toBeDefined();
474
+ expect(setCmd!.path).toBe('/interface/bridge');
475
+ });
476
+ });
477
+
478
+ // ===========================================================================
479
+ // Edge cases
480
+ // ===========================================================================
481
+ describe('edge cases', () => {
482
+ test('empty input', () => {
483
+ const result = canonicalize('');
484
+ expect(result.commands).toEqual([]);
485
+ expect(result.finalPath).toBe('/');
486
+ });
487
+
488
+ test('only whitespace', () => {
489
+ const result = canonicalize(' \t ');
490
+ expect(result.commands).toEqual([]);
491
+ });
492
+
493
+ test('only comment', () => {
494
+ const result = canonicalize('# this is a comment');
495
+ expect(result.commands).toEqual([]);
496
+ });
497
+
498
+ test('root slash only', () => {
499
+ const result = canonicalize('/');
500
+ expect(result.finalPath).toBe('/');
501
+ });
502
+
503
+ test('path with hyphenated segments', () => {
504
+ expectSingle('/ip/dhcp-client/print', { path: '/ip/dhcp-client', verb: 'print' });
505
+ });
506
+
507
+ test('deeply nested path', () => {
508
+ expectSingle('/ip/firewall/filter/add chain=forward action=accept', {
509
+ path: '/ip/firewall/filter',
510
+ verb: 'add',
511
+ });
512
+ });
513
+
514
+ test('path with where clause (not a subshell)', () => {
515
+ const result = canonicalize('/interface/print where name~"ether"');
516
+ const printCmd = result.commands.find(c => c.verb === 'print');
517
+ expect(printCmd).toBeDefined();
518
+ expect(printCmd!.path).toBe('/interface');
519
+ });
520
+ });