@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/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, return server
57
- * will start but tool calls will surface the underlying SQL error.
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
- /** Probe an existing DB. Returns null if the file is missing or unreadable.
71
- * Do NOT pass { readonly: true } — freshly written SQLite WAL-mode files fail
72
- * to open readonly on macOS until a read-write connection initialises the WAL
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 = probe();
73
+ let p = probeDb(dbPath);
94
74
 
95
75
  // Case 1: DB missing or empty → first-time download.
96
- if (!p || p.pages === 0) {
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(`Run: bunx @tikoci/rosetta --refresh`);
105
- return;
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
- log(`Database probe failed after download.`);
111
- return;
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
- `Run \`bun pm cache rm\` to clear the bunx cache and relaunch.`,
109
+ `Close other rosetta clients and run: bunx @tikoci/rosetta@latest --refresh`,
128
110
  );
129
- process.exit(1);
111
+ throw new Error(`Unable to recover an incompatible database at ${dbPath}.`);
130
112
  }
131
- const p2 = probe();
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=${p2?.schemaVersion ?? "unreadable"}, expected=${SCHEMA_VERSION}).`,
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 \`bun pm cache rm && bunx @tikoci/rosetta --refresh\` to update both the package and the DB.`,
119
+ `Run: bunx @tikoci/rosetta@latest --refresh`,
139
120
  );
140
- process.exit(1);
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, and documentation page.
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("returns empty when command path has no linked page", () => {
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("version filter returns only that version", () => {
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).toContain("Bold 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();