@tikoci/rosetta 0.8.9 → 0.8.10
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 +80 -15
- package/src/query.test.ts +146 -3
- package/src/query.ts +252 -14
- package/src/release.test.ts +121 -1
- package/src/setup.test.ts +65 -1
- package/src/setup.ts +277 -106
package/src/mcp.ts
CHANGED
|
@@ -53,8 +53,8 @@ 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.
|
|
@@ -101,14 +101,17 @@ async function ensureDbReady(log: (msg: string) => void): Promise<void> {
|
|
|
101
101
|
p = probe();
|
|
102
102
|
} catch (e) {
|
|
103
103
|
log(`Auto-download failed: ${e instanceof Error ? e.message : e}`);
|
|
104
|
-
log(`
|
|
105
|
-
|
|
104
|
+
log(`Close other rosetta clients and run: bunx @tikoci/rosetta --refresh`);
|
|
105
|
+
throw new Error(`Unable to start rosetta without a usable database at ${dbPath}.`);
|
|
106
106
|
}
|
|
107
107
|
}
|
|
108
108
|
|
|
109
109
|
if (!p) {
|
|
110
|
-
|
|
111
|
-
|
|
110
|
+
throw new Error(`Database probe failed after download: ${dbPath}`);
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
if (p.pages === 0) {
|
|
114
|
+
throw new Error(`Database remained empty after recovery: ${dbPath}`);
|
|
112
115
|
}
|
|
113
116
|
|
|
114
117
|
// Case 2: Schema mismatch → re-download, then re-probe and fail hard if
|
|
@@ -124,9 +127,9 @@ async function ensureDbReady(log: (msg: string) => void): Promise<void> {
|
|
|
124
127
|
log(`✗ Auto-recovery download failed: ${e instanceof Error ? e.message : e}`);
|
|
125
128
|
log(
|
|
126
129
|
` This rosetta build (v${runningVersion}) cannot use the existing DB. ` +
|
|
127
|
-
`
|
|
130
|
+
`Close other rosetta clients, then run \`bun pm cache rm && bunx @tikoci/rosetta --refresh\`.`,
|
|
128
131
|
);
|
|
129
|
-
|
|
132
|
+
throw new Error(`Unable to recover an incompatible database at ${dbPath}.`);
|
|
130
133
|
}
|
|
131
134
|
const p2 = probe();
|
|
132
135
|
if (!p2 || p2.schemaVersion !== SCHEMA_VERSION) {
|
|
@@ -137,7 +140,7 @@ async function ensureDbReady(log: (msg: string) => void): Promise<void> {
|
|
|
137
140
|
` The published database does not match this rosetta build (v${runningVersion}). ` +
|
|
138
141
|
`Run \`bun pm cache rm && bunx @tikoci/rosetta --refresh\` to update both the package and the DB.`,
|
|
139
142
|
);
|
|
140
|
-
|
|
143
|
+
throw new Error(`Database remained incompatible after recovery: ${dbPath}`);
|
|
141
144
|
}
|
|
142
145
|
p = p2;
|
|
143
146
|
}
|
|
@@ -226,6 +229,7 @@ const {
|
|
|
226
229
|
browseCommandsAtVersion,
|
|
227
230
|
checkCommandVersions,
|
|
228
231
|
diffCommandVersions,
|
|
232
|
+
explainCommand,
|
|
229
233
|
exportDevicesCsv,
|
|
230
234
|
exportDeviceTestsCsv,
|
|
231
235
|
fetchCurrentVersions,
|
|
@@ -575,8 +579,8 @@ and FTS in parallel, returning pages plus classifier-informed side queries in a
|
|
|
575
579
|
single response. Consolidates what used to require 3–5 separate tool calls.
|
|
576
580
|
|
|
577
581
|
Response shape:
|
|
578
|
-
- classified: { version, topics, command_path, device, property } — what the
|
|
579
|
-
classifier detected from your input
|
|
582
|
+
- classified: { version, topics, command_path, command_path_confidence, device, property } — what the
|
|
583
|
+
classifier detected from your input; command_path_confidence is high/medium/low
|
|
580
584
|
- pages: top FTS matches (title, path, URL, excerpt, best_section)
|
|
581
585
|
- related: callouts, properties, changelogs, videos, commands, devices, skills,
|
|
582
586
|
glossary — empty sections are omitted. Cap scales with \`limit\`: small limit
|
|
@@ -696,8 +700,10 @@ server.registerTool(
|
|
|
696
700
|
{
|
|
697
701
|
description: `Look up a specific RouterOS configuration property by exact name.
|
|
698
702
|
|
|
699
|
-
Returns type, default value, description,
|
|
703
|
+
Returns type, default value, description, documentation page, and confidence.
|
|
700
704
|
Optionally filter by command path to disambiguate (e.g., "disabled" appears everywhere).
|
|
705
|
+
Confidence is high for exact matches on the page linked from command_path, medium for
|
|
706
|
+
global exact matches without command_path, and low for global fallback with command_path.
|
|
701
707
|
|
|
702
708
|
This requires the **exact property name**. If you don't know the name:
|
|
703
709
|
→ routeros_search: find the documentation page, then routeros_get_page to read properties in context
|
|
@@ -734,6 +740,57 @@ Examples:
|
|
|
734
740
|
},
|
|
735
741
|
);
|
|
736
742
|
|
|
743
|
+
// ---- routeros_explain_command ----
|
|
744
|
+
|
|
745
|
+
server.registerTool(
|
|
746
|
+
"routeros_explain_command",
|
|
747
|
+
{
|
|
748
|
+
description: `Explain a candidate RouterOS CLI command using rosetta's offline docs and command tree.
|
|
749
|
+
|
|
750
|
+
This is a read-only tier-1 helper for write-shaped questions: it canonicalizes
|
|
751
|
+
the command, annotates key=value arguments with documented properties, checks
|
|
752
|
+
tracked RouterOS version presence, and returns compact docs/changelog context.
|
|
753
|
+
It never connects to a router, validates against a live device, or executes anything.
|
|
754
|
+
|
|
755
|
+
Returns:
|
|
756
|
+
- command: original input
|
|
757
|
+
- canonical: { path, verb, args, confidence } for the primary non-subshell command
|
|
758
|
+
- confidence: high/medium/low/none from the CLI canonicalizer
|
|
759
|
+
- args: parsed key=value args with first property match and lookup confidence when found
|
|
760
|
+
- warnings: no-command, low-confidence, unknown-arg, command-not-in-version, or model-context-unused signals
|
|
761
|
+
- pages: compact documentation search hits
|
|
762
|
+
- changelog_hits: compact changelog hits
|
|
763
|
+
- version_check: command version range when a canonical path is available
|
|
764
|
+
|
|
765
|
+
Workflow:
|
|
766
|
+
→ routeros_get_page: read full docs for a returned page
|
|
767
|
+
→ routeros_lookup_property: inspect a specific argument/property in more detail
|
|
768
|
+
→ routeros_command_tree: browse available commands/arguments under the canonical path
|
|
769
|
+
→ routeros_command_version_check / routeros_command_diff: investigate version-specific availability
|
|
770
|
+
|
|
771
|
+
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.`,
|
|
772
|
+
inputSchema: {
|
|
773
|
+
command: z
|
|
774
|
+
.string()
|
|
775
|
+
.describe("RouterOS CLI command to explain (e.g., '/ip/firewall/filter add chain=forward action=drop')"),
|
|
776
|
+
ros_version: z
|
|
777
|
+
.string()
|
|
778
|
+
.optional()
|
|
779
|
+
.describe("Optional RouterOS version to check against tracked command availability (e.g., '7.22')."),
|
|
780
|
+
model: z
|
|
781
|
+
.string()
|
|
782
|
+
.optional()
|
|
783
|
+
.describe("Optional device model context. Accepted for future use; device-specific validation is not implemented."),
|
|
784
|
+
},
|
|
785
|
+
},
|
|
786
|
+
async ({ command, ros_version, model }) => {
|
|
787
|
+
const result = explainCommand(command, ros_version, model);
|
|
788
|
+
return {
|
|
789
|
+
content: [{ type: "text", text: JSON.stringify(result, null, 2) }],
|
|
790
|
+
};
|
|
791
|
+
},
|
|
792
|
+
);
|
|
793
|
+
|
|
737
794
|
// ---- routeros_command_tree ----
|
|
738
795
|
|
|
739
796
|
server.registerTool(
|
|
@@ -811,7 +868,9 @@ Knowledge boundaries:
|
|
|
811
868
|
- No RouterOS v6 data available — v6 syntax and subsystems differ significantly from v7
|
|
812
869
|
- For versions older than 7.9, no command tree data exists
|
|
813
870
|
- 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
|
|
871
|
+
- Absence of a peripheral in docs doesn't mean unsupported — most MBIM modems work
|
|
872
|
+
|
|
873
|
+
→ routeros_search: probe the corpus these stats describe with any RouterOS question`,
|
|
815
874
|
inputSchema: {},
|
|
816
875
|
},
|
|
817
876
|
async () => {
|
|
@@ -1402,7 +1461,9 @@ Key context for version reasoning:
|
|
|
1402
1461
|
- Our command tree data covers 7.9–7.23beta2
|
|
1403
1462
|
- If a user's version is older than the current long-term, recommend upgrading
|
|
1404
1463
|
|
|
1405
|
-
Requires network access to upgrade.mikrotik.com
|
|
1464
|
+
Requires network access to upgrade.mikrotik.com.
|
|
1465
|
+
|
|
1466
|
+
→ routeros_search_changelogs: read what changed *into* the current/target version (use to_version=<latest> and from_version=<user's version>)`,
|
|
1406
1467
|
inputSchema: {},
|
|
1407
1468
|
},
|
|
1408
1469
|
async () => {
|
|
@@ -1558,4 +1619,8 @@ if (useHttp) {
|
|
|
1558
1619
|
await server.connect(transport);
|
|
1559
1620
|
}
|
|
1560
1621
|
|
|
1561
|
-
})()
|
|
1622
|
+
})().catch((err) => {
|
|
1623
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
1624
|
+
process.stderr.write(`${message}\n`);
|
|
1625
|
+
process.exit(1);
|
|
1626
|
+
});
|
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();
|