@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/src/mcp.ts
CHANGED
|
@@ -53,62 +53,44 @@ function link(url: string, display?: string): string {
|
|
|
53
53
|
* on fresh installs.
|
|
54
54
|
*
|
|
55
55
|
* Failure modes are explicit:
|
|
56
|
-
* - Missing or empty DB → download once. If download fails,
|
|
57
|
-
*
|
|
56
|
+
* - Missing or empty DB → download once. If download fails, abort startup —
|
|
57
|
+
* package-mode startup must not continue into db.ts and create a schema-only DB.
|
|
58
58
|
* - Schema mismatch → re-download once, then re-probe. If still mismatched,
|
|
59
59
|
* fail hard with an actionable message rather than silently using a DB
|
|
60
60
|
* that the running code can't query correctly.
|
|
61
61
|
*/
|
|
62
62
|
async function ensureDbReady(log: (msg: string) => void): Promise<void> {
|
|
63
63
|
const { resolveDbPath, SCHEMA_VERSION, resolveVersion } = await import("./paths.ts");
|
|
64
|
-
const { downloadDb } = await import("./setup.ts");
|
|
65
|
-
const { default: sqlite } = await import("bun:sqlite");
|
|
64
|
+
const { downloadDb, hasMinimumDbContent, probeDb, cleanupStaleTempArtifacts } = await import("./setup.ts");
|
|
66
65
|
|
|
67
66
|
const dbPath = resolveDbPath(import.meta.dirname);
|
|
68
67
|
const runningVersion = resolveVersion(import.meta.dirname);
|
|
69
68
|
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
* shared-memory file. Same gotcha as setup.ts::probeDb. */
|
|
74
|
-
function probe(): { pages: number; schemaVersion: number; releaseTag: string | null } | null {
|
|
75
|
-
try {
|
|
76
|
-
const check = new sqlite(dbPath);
|
|
77
|
-
const pages = (check.prepare("SELECT COUNT(*) AS c FROM pages").get() as { c: number }).c;
|
|
78
|
-
const ver = (check.prepare("PRAGMA user_version").get() as { user_version: number }).user_version;
|
|
79
|
-
let releaseTag: string | null = null;
|
|
80
|
-
try {
|
|
81
|
-
const meta = check.prepare("SELECT value FROM db_meta WHERE key = 'release_tag'").get() as { value: string } | null;
|
|
82
|
-
releaseTag = meta?.value ?? null;
|
|
83
|
-
} catch {
|
|
84
|
-
// db_meta missing on pre-v5 DBs — leave null
|
|
85
|
-
}
|
|
86
|
-
check.close();
|
|
87
|
-
return { pages, schemaVersion: ver, releaseTag };
|
|
88
|
-
} catch {
|
|
89
|
-
return null;
|
|
90
|
-
}
|
|
91
|
-
}
|
|
69
|
+
// Always clean stale .tmp.* artifacts from previous failed runs, regardless of
|
|
70
|
+
// whether a download is needed this time.
|
|
71
|
+
cleanupStaleTempArtifacts(dbPath);
|
|
92
72
|
|
|
93
|
-
let p =
|
|
73
|
+
let p = probeDb(dbPath);
|
|
94
74
|
|
|
95
75
|
// Case 1: DB missing or empty → first-time download.
|
|
96
|
-
if (!p
|
|
76
|
+
if (!hasMinimumDbContent(p)) {
|
|
97
77
|
log(`No usable database at ${dbPath} — downloading...`);
|
|
98
78
|
try {
|
|
99
|
-
await downloadDb(dbPath, log);
|
|
79
|
+
p = await downloadDb(dbPath, log);
|
|
100
80
|
log("Database downloaded successfully.");
|
|
101
|
-
p = probe();
|
|
102
81
|
} catch (e) {
|
|
103
82
|
log(`Auto-download failed: ${e instanceof Error ? e.message : e}`);
|
|
104
|
-
log(`
|
|
105
|
-
|
|
83
|
+
log(`Close other rosetta clients and run: bunx @tikoci/rosetta --refresh`);
|
|
84
|
+
throw new Error(`Unable to start rosetta without a usable database at ${dbPath}.`);
|
|
106
85
|
}
|
|
107
86
|
}
|
|
108
87
|
|
|
109
88
|
if (!p) {
|
|
110
|
-
|
|
111
|
-
|
|
89
|
+
throw new Error(`Database probe failed after download: ${dbPath}`);
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
if (!hasMinimumDbContent(p)) {
|
|
93
|
+
throw new Error(`Database remained incomplete after recovery: ${dbPath}`);
|
|
112
94
|
}
|
|
113
95
|
|
|
114
96
|
// Case 2: Schema mismatch → re-download, then re-probe and fail hard if
|
|
@@ -119,27 +101,25 @@ async function ensureDbReady(log: (msg: string) => void): Promise<void> {
|
|
|
119
101
|
`Re-downloading database...`,
|
|
120
102
|
);
|
|
121
103
|
try {
|
|
122
|
-
await downloadDb(dbPath, log);
|
|
104
|
+
p = await downloadDb(dbPath, log);
|
|
123
105
|
} catch (e) {
|
|
124
106
|
log(`✗ Auto-recovery download failed: ${e instanceof Error ? e.message : e}`);
|
|
125
107
|
log(
|
|
126
108
|
` This rosetta build (v${runningVersion}) cannot use the existing DB. ` +
|
|
127
|
-
`
|
|
109
|
+
`Close other rosetta clients and run: bunx @tikoci/rosetta@latest --refresh`,
|
|
128
110
|
);
|
|
129
|
-
|
|
111
|
+
throw new Error(`Unable to recover an incompatible database at ${dbPath}.`);
|
|
130
112
|
}
|
|
131
|
-
|
|
132
|
-
if (!p2 || p2.schemaVersion !== SCHEMA_VERSION) {
|
|
113
|
+
if (!p || !hasMinimumDbContent(p) || p.schemaVersion !== SCHEMA_VERSION) {
|
|
133
114
|
log(
|
|
134
|
-
`✗ Still incompatible after re-download (DB=${
|
|
115
|
+
`✗ Still incompatible after re-download (DB=${p?.schemaVersion ?? "unreadable"}, expected=${SCHEMA_VERSION}).`,
|
|
135
116
|
);
|
|
136
117
|
log(
|
|
137
118
|
` The published database does not match this rosetta build (v${runningVersion}). ` +
|
|
138
|
-
`Run
|
|
119
|
+
`Run: bunx @tikoci/rosetta@latest --refresh`,
|
|
139
120
|
);
|
|
140
|
-
|
|
121
|
+
throw new Error(`Database remained incompatible after recovery: ${dbPath}`);
|
|
141
122
|
}
|
|
142
|
-
p = p2;
|
|
143
123
|
}
|
|
144
124
|
|
|
145
125
|
// Quietly emit a one-line provenance banner so MCP-client logs show what's loaded.
|
|
@@ -226,6 +206,7 @@ const {
|
|
|
226
206
|
browseCommandsAtVersion,
|
|
227
207
|
checkCommandVersions,
|
|
228
208
|
diffCommandVersions,
|
|
209
|
+
explainCommand,
|
|
229
210
|
exportDevicesCsv,
|
|
230
211
|
exportDeviceTestsCsv,
|
|
231
212
|
fetchCurrentVersions,
|
|
@@ -575,8 +556,8 @@ and FTS in parallel, returning pages plus classifier-informed side queries in a
|
|
|
575
556
|
single response. Consolidates what used to require 3–5 separate tool calls.
|
|
576
557
|
|
|
577
558
|
Response shape:
|
|
578
|
-
- classified: { version, topics, command_path, device, property } — what the
|
|
579
|
-
classifier detected from your input
|
|
559
|
+
- classified: { version, topics, command_path, command_path_confidence, device, property } — what the
|
|
560
|
+
classifier detected from your input; command_path_confidence is high/medium/low
|
|
580
561
|
- pages: top FTS matches (title, path, URL, excerpt, best_section)
|
|
581
562
|
- related: callouts, properties, changelogs, videos, commands, devices, skills,
|
|
582
563
|
glossary — empty sections are omitted. Cap scales with \`limit\`: small limit
|
|
@@ -696,8 +677,10 @@ server.registerTool(
|
|
|
696
677
|
{
|
|
697
678
|
description: `Look up a specific RouterOS configuration property by exact name.
|
|
698
679
|
|
|
699
|
-
Returns type, default value, description,
|
|
680
|
+
Returns type, default value, description, documentation page, and confidence.
|
|
700
681
|
Optionally filter by command path to disambiguate (e.g., "disabled" appears everywhere).
|
|
682
|
+
Confidence is high for exact matches on the page linked from command_path, medium for
|
|
683
|
+
global exact matches without command_path, and low for global fallback with command_path.
|
|
701
684
|
|
|
702
685
|
This requires the **exact property name**. If you don't know the name:
|
|
703
686
|
→ routeros_search: find the documentation page, then routeros_get_page to read properties in context
|
|
@@ -734,6 +717,57 @@ Examples:
|
|
|
734
717
|
},
|
|
735
718
|
);
|
|
736
719
|
|
|
720
|
+
// ---- routeros_explain_command ----
|
|
721
|
+
|
|
722
|
+
server.registerTool(
|
|
723
|
+
"routeros_explain_command",
|
|
724
|
+
{
|
|
725
|
+
description: `Explain a candidate RouterOS CLI command using rosetta's offline docs and command tree.
|
|
726
|
+
|
|
727
|
+
This is a read-only tier-1 helper for write-shaped questions: it canonicalizes
|
|
728
|
+
the command, annotates key=value arguments with documented properties, checks
|
|
729
|
+
tracked RouterOS version presence, and returns compact docs/changelog context.
|
|
730
|
+
It never connects to a router, validates against a live device, or executes anything.
|
|
731
|
+
|
|
732
|
+
Returns:
|
|
733
|
+
- command: original input
|
|
734
|
+
- canonical: { path, verb, args, confidence } for the primary non-subshell command
|
|
735
|
+
- confidence: high/medium/low/none from the CLI canonicalizer
|
|
736
|
+
- args: parsed key=value args with first property match and lookup confidence when found
|
|
737
|
+
- warnings: no-command, low-confidence, unknown-arg, command-not-in-version, or model-context-unused signals
|
|
738
|
+
- pages: compact documentation search hits
|
|
739
|
+
- changelog_hits: compact changelog hits
|
|
740
|
+
- version_check: command version range when a canonical path is available
|
|
741
|
+
|
|
742
|
+
Workflow:
|
|
743
|
+
→ routeros_get_page: read full docs for a returned page
|
|
744
|
+
→ routeros_lookup_property: inspect a specific argument/property in more detail
|
|
745
|
+
→ routeros_command_tree: browse available commands/arguments under the canonical path
|
|
746
|
+
→ routeros_command_version_check / routeros_command_diff: investigate version-specific availability
|
|
747
|
+
|
|
748
|
+
Boundaries: Documentation covers RouterOS v7, aligned with long-term ~7.22; command data covers 7.9–7.23beta2. This tool is explanatory only — use a separate validator/runner before touching a router.`,
|
|
749
|
+
inputSchema: {
|
|
750
|
+
command: z
|
|
751
|
+
.string()
|
|
752
|
+
.describe("RouterOS CLI command to explain (e.g., '/ip/firewall/filter add chain=forward action=drop')"),
|
|
753
|
+
ros_version: z
|
|
754
|
+
.string()
|
|
755
|
+
.optional()
|
|
756
|
+
.describe("Optional RouterOS version to check against tracked command availability (e.g., '7.22')."),
|
|
757
|
+
model: z
|
|
758
|
+
.string()
|
|
759
|
+
.optional()
|
|
760
|
+
.describe("Optional device model context. Accepted for future use; device-specific validation is not implemented."),
|
|
761
|
+
},
|
|
762
|
+
},
|
|
763
|
+
async ({ command, ros_version, model }) => {
|
|
764
|
+
const result = explainCommand(command, ros_version, model);
|
|
765
|
+
return {
|
|
766
|
+
content: [{ type: "text", text: JSON.stringify(result, null, 2) }],
|
|
767
|
+
};
|
|
768
|
+
},
|
|
769
|
+
);
|
|
770
|
+
|
|
737
771
|
// ---- routeros_command_tree ----
|
|
738
772
|
|
|
739
773
|
server.registerTool(
|
|
@@ -811,7 +845,9 @@ Knowledge boundaries:
|
|
|
811
845
|
- No RouterOS v6 data available — v6 syntax and subsystems differ significantly from v7
|
|
812
846
|
- For versions older than 7.9, no command tree data exists
|
|
813
847
|
- Versions older than current long-term are unpatched by MikroTik
|
|
814
|
-
- Absence of a peripheral in docs doesn't mean unsupported — most MBIM modems work
|
|
848
|
+
- Absence of a peripheral in docs doesn't mean unsupported — most MBIM modems work
|
|
849
|
+
|
|
850
|
+
→ routeros_search: probe the corpus these stats describe with any RouterOS question`,
|
|
815
851
|
inputSchema: {},
|
|
816
852
|
},
|
|
817
853
|
async () => {
|
|
@@ -1402,7 +1438,9 @@ Key context for version reasoning:
|
|
|
1402
1438
|
- Our command tree data covers 7.9–7.23beta2
|
|
1403
1439
|
- If a user's version is older than the current long-term, recommend upgrading
|
|
1404
1440
|
|
|
1405
|
-
Requires network access to upgrade.mikrotik.com
|
|
1441
|
+
Requires network access to upgrade.mikrotik.com.
|
|
1442
|
+
|
|
1443
|
+
→ routeros_search_changelogs: read what changed *into* the current/target version (use to_version=<latest> and from_version=<user's version>)`,
|
|
1406
1444
|
inputSchema: {},
|
|
1407
1445
|
},
|
|
1408
1446
|
async () => {
|
|
@@ -1558,4 +1596,8 @@ if (useHttp) {
|
|
|
1558
1596
|
await server.connect(transport);
|
|
1559
1597
|
}
|
|
1560
1598
|
|
|
1561
|
-
})()
|
|
1599
|
+
})().catch((err) => {
|
|
1600
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
1601
|
+
process.stderr.write(`${message}\n`);
|
|
1602
|
+
process.exit(1);
|
|
1603
|
+
});
|
package/src/query.test.ts
CHANGED
|
@@ -54,6 +54,7 @@ const {
|
|
|
54
54
|
listGlossary,
|
|
55
55
|
KNOWN_TOPICS,
|
|
56
56
|
searchAll,
|
|
57
|
+
explainCommand,
|
|
57
58
|
} = await import("./query.ts");
|
|
58
59
|
const { parseChangelog } = await import("./extract-changelogs.ts");
|
|
59
60
|
const { parseVtt, segmentTranscript } = await import("./extract-videos.ts");
|
|
@@ -122,6 +123,14 @@ beforeAll(() => {
|
|
|
122
123
|
(id, page_id, name, type, default_val, description, section, sort_order)
|
|
123
124
|
VALUES (2, 1, 'address-pool', 'string', '', 'Name of the address pool to use', NULL, 1)`);
|
|
124
125
|
|
|
126
|
+
db.run(`INSERT INTO properties
|
|
127
|
+
(id, page_id, name, type, default_val, description, section, sort_order)
|
|
128
|
+
VALUES (3, 2, 'chain', 'string', '', 'Firewall chain to match or create', NULL, 0)`);
|
|
129
|
+
|
|
130
|
+
db.run(`INSERT INTO properties
|
|
131
|
+
(id, page_id, name, type, default_val, description, section, sort_order)
|
|
132
|
+
VALUES (4, 2, 'action', 'string', 'accept', 'Action to take when a packet matches the rule', NULL, 1)`);
|
|
133
|
+
|
|
125
134
|
db.run(`INSERT INTO ros_versions (version, arch, channel, extra_packages, extracted_at)
|
|
126
135
|
VALUES ('7.22', 'x86', 'stable', 0, '2024-01-01T00:00:00Z')`);
|
|
127
136
|
db.run(`INSERT INTO ros_versions (version, arch, channel, extra_packages, extracted_at)
|
|
@@ -137,6 +146,14 @@ beforeAll(() => {
|
|
|
137
146
|
(id, path, name, type, parent_path, page_id, description, ros_version)
|
|
138
147
|
VALUES (2, '/ip/dhcp-server', 'dhcp-server', 'dir', '/ip', 1, 'DHCP Server configuration', '7.22')`);
|
|
139
148
|
|
|
149
|
+
db.run(`INSERT INTO commands
|
|
150
|
+
(id, path, name, type, parent_path, page_id, description, ros_version)
|
|
151
|
+
VALUES (3, '/ip/firewall', 'firewall', 'dir', '/ip', 2, 'Firewall configuration', '7.22')`);
|
|
152
|
+
|
|
153
|
+
db.run(`INSERT INTO commands
|
|
154
|
+
(id, path, name, type, parent_path, page_id, description, ros_version)
|
|
155
|
+
VALUES (4, '/ip/firewall/filter', 'filter', 'dir', '/ip/firewall', 2, 'Firewall filter rules', '7.22')`);
|
|
156
|
+
|
|
140
157
|
// schema_nodes for arch-filter tests: shared + x86-only child of /ip
|
|
141
158
|
db.run(`INSERT INTO schema_nodes (path, name, type, parent_path, dir_role, _arch)
|
|
142
159
|
VALUES ('/ip', 'ip', 'dir', NULL, 'namespace', NULL)`);
|
|
@@ -153,6 +170,8 @@ beforeAll(() => {
|
|
|
153
170
|
VALUES ('/ip/dhcp-server', '7.22')`);
|
|
154
171
|
db.run(`INSERT INTO command_versions (command_path, ros_version)
|
|
155
172
|
VALUES ('/ip/dhcp-server', '7.9')`);
|
|
173
|
+
db.run(`INSERT INTO command_versions (command_path, ros_version)
|
|
174
|
+
VALUES ('/ip/firewall/filter', '7.22')`);
|
|
156
175
|
|
|
157
176
|
// Extra command_versions entries to support diffCommandVersions tests
|
|
158
177
|
// /ip/dhcp-server/lease only in 7.22 (added)
|
|
@@ -383,8 +402,12 @@ beforeAll(() => {
|
|
|
383
402
|
VALUES ('7.22', '2026-Mar-09 10:38', 'bgp', 0, 'added BGP unnumbered support', 1)`);
|
|
384
403
|
db.run(`INSERT INTO changelogs (version, released, category, is_breaking, description, sort_order)
|
|
385
404
|
VALUES ('7.22', '2026-Mar-09 10:38', 'bridge', 0, 'added local and static MAC synchronization for MLAG', 2)`);
|
|
405
|
+
db.run(`INSERT INTO changelogs (version, released, category, is_breaking, description, sort_order)
|
|
406
|
+
VALUES ('7.22', '2026-Mar-09 10:38', 'firewall', 0, 'improved firewall filter rule handling', 3)`);
|
|
386
407
|
db.run(`INSERT INTO changelogs (version, released, category, is_breaking, description, sort_order)
|
|
387
408
|
VALUES ('7.22.1', '2026-Apr-01 09:00', 'wifi', 0, 'fixed channel switching for MediaTek access points', 0)`);
|
|
409
|
+
db.run(`INSERT INTO changelogs (version, released, category, is_breaking, description, sort_order)
|
|
410
|
+
VALUES ('7.23.1', '2026-Apr-15 09:00', 'routing', 0, 'fixed route cache after upgrade', 0)`);
|
|
388
411
|
|
|
389
412
|
// Video and transcript fixtures for searchVideos tests
|
|
390
413
|
db.run(`INSERT INTO videos
|
|
@@ -460,6 +483,10 @@ describe("extractTerms", () => {
|
|
|
460
483
|
expect(extractTerms("how and the with without")).toEqual([]);
|
|
461
484
|
});
|
|
462
485
|
|
|
486
|
+
test("drops switch as context for bridge VLAN filtering queries", () => {
|
|
487
|
+
expect(extractTerms("bridge vlan filtering on a switch")).toEqual(["bridge", "vlan", "filtering"]);
|
|
488
|
+
});
|
|
489
|
+
|
|
463
490
|
test("filters terms shorter than 2 characters", () => {
|
|
464
491
|
expect(extractTerms("a x y")).toEqual([]);
|
|
465
492
|
});
|
|
@@ -777,11 +804,13 @@ describe("lookupProperty", () => {
|
|
|
777
804
|
expect(rows.length).toBeGreaterThan(0);
|
|
778
805
|
expect(rows[0].name).toBe("lease-time");
|
|
779
806
|
expect(rows[0].page_title).toBe("DHCP Server");
|
|
807
|
+
expect(rows[0].confidence).toBe("medium");
|
|
780
808
|
});
|
|
781
809
|
|
|
782
810
|
test("case-insensitive name lookup", () => {
|
|
783
811
|
const rows = lookupProperty("LEASE-TIME");
|
|
784
812
|
expect(rows.length).toBeGreaterThan(0);
|
|
813
|
+
expect(rows.every((row) => row.confidence === "medium")).toBe(true);
|
|
785
814
|
});
|
|
786
815
|
|
|
787
816
|
test("returns empty for unknown property", () => {
|
|
@@ -792,12 +821,74 @@ describe("lookupProperty", () => {
|
|
|
792
821
|
const rows = lookupProperty("lease-time", "/ip/dhcp-server");
|
|
793
822
|
expect(rows.length).toBeGreaterThan(0);
|
|
794
823
|
expect(rows[0].name).toBe("lease-time");
|
|
824
|
+
expect(rows[0].confidence).toBe("high");
|
|
795
825
|
});
|
|
796
826
|
|
|
797
|
-
test("
|
|
827
|
+
test("marks global fallback low when command path has no linked page", () => {
|
|
798
828
|
const rows = lookupProperty("lease-time", "/ip/unlinked");
|
|
799
829
|
// /ip/unlinked has no page_id → falls through to global search
|
|
800
830
|
expect(Array.isArray(rows)).toBe(true);
|
|
831
|
+
expect(rows.length).toBeGreaterThan(0);
|
|
832
|
+
expect(rows.every((row) => row.confidence === "low")).toBe(true);
|
|
833
|
+
});
|
|
834
|
+
});
|
|
835
|
+
|
|
836
|
+
// ---------------------------------------------------------------------------
|
|
837
|
+
// DB integration: explainCommand
|
|
838
|
+
// ---------------------------------------------------------------------------
|
|
839
|
+
|
|
840
|
+
describe("explainCommand", () => {
|
|
841
|
+
test("returns canonical command, known arg property, pages, changelog hits, and version check", () => {
|
|
842
|
+
const result = explainCommand("/ip firewall filter add chain=forward action=drop", "7.22");
|
|
843
|
+
|
|
844
|
+
expect(result.command).toBe("/ip firewall filter add chain=forward action=drop");
|
|
845
|
+
expect(result.canonical).toEqual({
|
|
846
|
+
path: "/ip/firewall/filter",
|
|
847
|
+
verb: "add",
|
|
848
|
+
args: ["chain=forward", "action=drop"],
|
|
849
|
+
confidence: "high",
|
|
850
|
+
});
|
|
851
|
+
expect(result.confidence).toBe("high");
|
|
852
|
+
expect(result.args.map((arg) => arg.name)).toEqual(["chain", "action"]);
|
|
853
|
+
expect(result.args[0].property?.name).toBe("chain");
|
|
854
|
+
expect(result.args[0].property?.confidence).toBe("high");
|
|
855
|
+
expect(result.warnings).toEqual([]);
|
|
856
|
+
expect(result.pages.some((page) => page.title === "Firewall Filter")).toBe(true);
|
|
857
|
+
expect(result.changelog_hits.some((hit) => hit.category === "firewall")).toBe(true);
|
|
858
|
+
expect(result.version_check?.versions).toContain("7.22");
|
|
859
|
+
});
|
|
860
|
+
|
|
861
|
+
test("warns for unknown args, absent target versions, and unused model context", () => {
|
|
862
|
+
const result = explainCommand("/ip firewall filter add frobnicate=yes", "7.9", "hAP ax3");
|
|
863
|
+
|
|
864
|
+
expect(result.canonical?.path).toBe("/ip/firewall/filter");
|
|
865
|
+
expect(result.confidence).toBe("high");
|
|
866
|
+
expect(result.args[0]).toMatchObject({ name: "frobnicate", value: "yes" });
|
|
867
|
+
expect(result.args[0].property).toBeUndefined();
|
|
868
|
+
expect(result.warnings.map((warning) => warning.kind)).toEqual(
|
|
869
|
+
expect.arrayContaining([
|
|
870
|
+
"unknown-arg",
|
|
871
|
+
"command-not-in-version",
|
|
872
|
+
"model-context-unused",
|
|
873
|
+
]),
|
|
874
|
+
);
|
|
875
|
+
});
|
|
876
|
+
|
|
877
|
+
test("warns on low-confidence canonicalization", () => {
|
|
878
|
+
const result = explainCommand("print");
|
|
879
|
+
|
|
880
|
+
expect(result.canonical).toMatchObject({ path: "/", verb: "print", confidence: "low" });
|
|
881
|
+
expect(result.confidence).toBe("low");
|
|
882
|
+
expect(result.warnings.some((warning) => warning.kind === "low-confidence")).toBe(true);
|
|
883
|
+
});
|
|
884
|
+
|
|
885
|
+
test("warns when no primary command can be extracted", () => {
|
|
886
|
+
const result = explainCommand("not a routeros command");
|
|
887
|
+
|
|
888
|
+
expect(result.canonical).toBeNull();
|
|
889
|
+
expect(result.confidence).toBe("none");
|
|
890
|
+
expect(result.args).toEqual([]);
|
|
891
|
+
expect(result.warnings.some((warning) => warning.kind === "no-command")).toBe(true);
|
|
801
892
|
});
|
|
802
893
|
});
|
|
803
894
|
|
|
@@ -1563,12 +1654,33 @@ describe("searchChangelogs", () => {
|
|
|
1563
1654
|
expect(results.some((r) => r.category === "bgp")).toBe(true);
|
|
1564
1655
|
});
|
|
1565
1656
|
|
|
1566
|
-
test("
|
|
1657
|
+
test("exact patch version stays exact", () => {
|
|
1658
|
+
const results = searchChangelogs("", { version: "7.22.1" });
|
|
1659
|
+
expect(results.length).toBeGreaterThan(0);
|
|
1660
|
+
for (const r of results) {
|
|
1661
|
+
expect(r.version).toBe("7.22.1");
|
|
1662
|
+
}
|
|
1663
|
+
});
|
|
1664
|
+
|
|
1665
|
+
test("major.minor version filter preserves exact rows before patch fallback", () => {
|
|
1567
1666
|
const results = searchChangelogs("", { version: "7.22" });
|
|
1568
1667
|
expect(results.length).toBeGreaterThan(0);
|
|
1569
1668
|
for (const r of results) {
|
|
1570
1669
|
expect(r.version).toBe("7.22");
|
|
1571
1670
|
}
|
|
1671
|
+
expect(results.some((r) => r.version === "7.22.1")).toBe(false);
|
|
1672
|
+
});
|
|
1673
|
+
|
|
1674
|
+
test("major.minor version filter falls back to patch rows when exact rows are absent", () => {
|
|
1675
|
+
const results = searchChangelogs("", { version: "7.23" });
|
|
1676
|
+
expect(results.length).toBeGreaterThan(0);
|
|
1677
|
+
expect(results.every((r) => r.version === "7.23.1")).toBe(true);
|
|
1678
|
+
});
|
|
1679
|
+
|
|
1680
|
+
test("generic major.minor version questions browse fallback patch rows", () => {
|
|
1681
|
+
const results = searchChangelogs("what changed in 7.23", { version: "7.23" });
|
|
1682
|
+
expect(results.length).toBeGreaterThan(0);
|
|
1683
|
+
expect(results.every((r) => r.version === "7.23.1")).toBe(true);
|
|
1572
1684
|
});
|
|
1573
1685
|
|
|
1574
1686
|
test("version range filter works (from_version exclusive, to_version inclusive)", () => {
|
|
@@ -1885,7 +1997,19 @@ Today we will cover trunking. Let us begin.
|
|
|
1885
1997
|
const vtt = `WEBVTT\n\n00:00:01.000 --> 00:00:03.000\n<b>Bold text</b> and <i>italic</i>.\n`;
|
|
1886
1998
|
const cues = parseVtt(vtt);
|
|
1887
1999
|
expect(cues[0].text).not.toContain("<b>");
|
|
1888
|
-
expect(cues[0].text).
|
|
2000
|
+
expect(cues[0].text).toBe("Bold text and italic.");
|
|
2001
|
+
});
|
|
2002
|
+
|
|
2003
|
+
test("drops malformed cue markup without leaking tag fragments", () => {
|
|
2004
|
+
const vtt = `WEBVTT\n\n00:00:01.000 --> 00:00:03.000\n<scr<script>ipt>alert</scr<script>ipt> safe text.\n`;
|
|
2005
|
+
const cues = parseVtt(vtt);
|
|
2006
|
+
expect(cues[0].text).toBe("safe text.");
|
|
2007
|
+
});
|
|
2008
|
+
|
|
2009
|
+
test("recovers plain text after malformed cue markup", () => {
|
|
2010
|
+
const vtt = `WEBVTT\n\n00:00:01.000 --> 00:00:03.000\nClick here<a h<a href='x'>ref='evil'>safe text.\n`;
|
|
2011
|
+
const cues = parseVtt(vtt);
|
|
2012
|
+
expect(cues[0].text).toBe("Click heresafe text.");
|
|
1889
2013
|
});
|
|
1890
2014
|
});
|
|
1891
2015
|
|
|
@@ -2169,6 +2293,25 @@ describe("listGlossary", () => {
|
|
|
2169
2293
|
// ---------------------------------------------------------------------------
|
|
2170
2294
|
|
|
2171
2295
|
describe("searchAll related block", () => {
|
|
2296
|
+
test("exposes command path confidence in classified output", () => {
|
|
2297
|
+
const res = searchAll("/ip/dhcp-server");
|
|
2298
|
+
expect(res.classified.command_path).toBe("/ip/dhcp-server");
|
|
2299
|
+
expect(res.classified.command_path_confidence).toBe("medium");
|
|
2300
|
+
});
|
|
2301
|
+
|
|
2302
|
+
test("includes property lookup confidence in related properties", () => {
|
|
2303
|
+
const res = searchAll("lease-time");
|
|
2304
|
+
expect(res.related.properties?.length).toBeGreaterThan(0);
|
|
2305
|
+
expect(res.related.properties?.[0].confidence).toBe("medium");
|
|
2306
|
+
});
|
|
2307
|
+
|
|
2308
|
+
test("includes changelogs for major.minor version questions backed only by patch rows", () => {
|
|
2309
|
+
const res = searchAll("what changed in 7.23");
|
|
2310
|
+
expect(res.classified.version).toBe("7.23");
|
|
2311
|
+
expect(res.related.changelogs?.length).toBeGreaterThan(0);
|
|
2312
|
+
expect(res.related.changelogs?.every((row) => row.version === "7.23.1")).toBe(true);
|
|
2313
|
+
});
|
|
2314
|
+
|
|
2172
2315
|
test("includes glossary when input matches a glossary term", () => {
|
|
2173
2316
|
const res = searchAll("chr");
|
|
2174
2317
|
expect(res.related).toBeDefined();
|