@tikoci/rosetta 0.8.6 → 0.8.8

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.
@@ -0,0 +1,263 @@
1
+ /**
2
+ * mcp-contract.test.ts — MCP tool surface contract tests (Phase 2).
3
+ *
4
+ * Guards against silent breaking changes to the MCP tool registry:
5
+ * - Block A: Frozen 13-tool list + workflow-arrow (→) convention in descriptions
6
+ * - Block B: Token-budget guardrails for 10 canonical queries (rough chars/4)
7
+ * - Block C: Shape snapshots — fingerprint the *contract* (keys, counts,
8
+ * classifier output), NOT the corpus. Deliberately omits page IDs
9
+ * and titles so DB refreshes don't churn snapshots.
10
+ *
11
+ * These are fast, deterministic, CI-runnable structural tests. No LLM calls,
12
+ * no network. Use the real local DB (ros-help.db) for stable FTS results.
13
+ *
14
+ * When adding/removing/renaming a tool: update EXPECTED_TOOLS below AND add
15
+ * a CHANGELOG entry under [Unreleased] → Added/Changed/Removed. The test is
16
+ * designed to force an explicit decision.
17
+ */
18
+ import { describe, expect, test } from "bun:test";
19
+ import { readFileSync } from "node:fs";
20
+ import path from "node:path";
21
+
22
+ const ROOT = path.resolve(import.meta.dirname, "..");
23
+
24
+ // Block A (static file parse) runs unconditionally. Blocks B and C need the
25
+ // real populated DB singleton. When another test file (extract-videos,
26
+ // query, etc.) has already pinned the db.ts singleton to :memory:, we skip
27
+ // the DB-dependent blocks — running `bun test src/mcp-contract.test.ts`
28
+ // alone exercises them, and CI's `bun test` still gets Block A coverage.
29
+ const { searchAll } = await import("./query.ts");
30
+ const { DB_PATH, getDbStats } = await import("./db.ts");
31
+
32
+ // getDbStats() throws if tables don't exist (clean checkout before any DB build).
33
+ // Guard defensively: any failure → treat as "DB not usable" and skip B/C.
34
+ function dbPagesOrZero(): number {
35
+ try {
36
+ return getDbStats().pages;
37
+ } catch {
38
+ return 0;
39
+ }
40
+ }
41
+
42
+ const dbPages = dbPagesOrZero();
43
+ const dbIsReal = DB_PATH !== ":memory:" && dbPages > 100;
44
+ const skipReason = dbIsReal
45
+ ? ""
46
+ : `DB singleton is "${DB_PATH}" (pages=${dbPages}); run \`bun test src/mcp-contract.test.ts\` solo against a populated DB for Blocks B/C.`;
47
+
48
+ // ---------------------------------------------------------------------------
49
+ // Block A: Frozen tool registry
50
+ // ---------------------------------------------------------------------------
51
+
52
+ describe("Frozen tool registry", () => {
53
+ const EXPECTED_TOOLS = [
54
+ "routeros_search",
55
+ "routeros_get_page",
56
+ "routeros_lookup_property",
57
+ "routeros_command_tree",
58
+ "routeros_stats",
59
+ "routeros_search_changelogs",
60
+ "routeros_dude_search",
61
+ "routeros_dude_get_page",
62
+ "routeros_command_version_check",
63
+ "routeros_command_diff",
64
+ "routeros_device_lookup",
65
+ "routeros_search_tests",
66
+ "routeros_current_versions",
67
+ ];
68
+
69
+ test("exactly 13 tools registered", () => {
70
+ const mcpSrc = readFileSync(path.join(ROOT, "src/mcp.ts"), "utf-8");
71
+ // Extract tool names from server.registerTool("<name>", patterns
72
+ const toolMatches = mcpSrc.matchAll(/server\.registerTool\(\s*["']([^"']+)["']/g);
73
+ const foundTools = Array.from(toolMatches, (m) => m[1]);
74
+
75
+ expect(foundTools.length).toBe(13);
76
+ expect(foundTools.sort()).toEqual(EXPECTED_TOOLS.sort());
77
+ });
78
+
79
+ test("all tools have workflow arrow (→) in description", () => {
80
+ const mcpSrc = readFileSync(path.join(ROOT, "src/mcp.ts"), "utf-8");
81
+
82
+ // Terminal/informational tools that don't have natural follow-ups
83
+ // (identified during Phase 2 implementation as missing workflow arrows)
84
+ const KNOWN_EXCEPTIONS = ["routeros_stats", "routeros_current_versions"];
85
+
86
+ // Extract each complete registerTool block (tool name to closing paren before next registerTool)
87
+ // Split on registerTool calls, then extract name + description from each block
88
+ const toolBlocks = mcpSrc.split(/(?=server\.registerTool\()/);
89
+
90
+ const toolsWithoutArrow: string[] = [];
91
+
92
+ for (const block of toolBlocks) {
93
+ // Extract tool name
94
+ const nameMatch = block.match(/server\.registerTool\(\s*["']([^"']+)["']/);
95
+ if (!nameMatch) continue;
96
+
97
+ const toolName = nameMatch[1];
98
+
99
+ // Extract description (from `description:` to the closing `inputSchema:`)
100
+ // This captures the full description including embedded backticks
101
+ const descMatch = block.match(/description:\s*`([\s\S]*?)`\s*,\s*inputSchema:/);
102
+ if (!descMatch) {
103
+ // Some tools might not have inputSchema, try alternate pattern
104
+ const altMatch = block.match(/description:\s*`([\s\S]*?)`\s*,?\s*\}/);
105
+ if (!altMatch) continue;
106
+
107
+ const description = altMatch[1];
108
+ if (!description.includes("→") && !KNOWN_EXCEPTIONS.includes(toolName)) {
109
+ toolsWithoutArrow.push(toolName);
110
+ }
111
+ continue;
112
+ }
113
+
114
+ const description = descMatch[1];
115
+ if (!description.includes("→") && !KNOWN_EXCEPTIONS.includes(toolName)) {
116
+ toolsWithoutArrow.push(toolName);
117
+ }
118
+ }
119
+
120
+ if (toolsWithoutArrow.length > 0) {
121
+ throw new Error(
122
+ `Tools lacking workflow arrow (→) convention: ${toolsWithoutArrow.join(", ")}`,
123
+ );
124
+ }
125
+
126
+ // Ensure we found tool descriptions (sanity check for regex)
127
+ const totalFound = Array.from(
128
+ mcpSrc.matchAll(/server\.registerTool\(\s*["']([^"']+)["']/g),
129
+ ).length;
130
+ expect(totalFound).toBe(13);
131
+ });
132
+ });
133
+
134
+ // ---------------------------------------------------------------------------
135
+ // Block B: Token-budget guardrails
136
+ // ---------------------------------------------------------------------------
137
+
138
+ describe.skipIf(!dbIsReal)(`Token-budget guardrails${dbIsReal ? "" : ` [skipped: ${skipReason}]`}`, () => {
139
+ /**
140
+ * Rough token estimator: 1 token ≈ 4 chars for JSON.
141
+ * This is a guardrail to catch 10x regressions, not precise billing.
142
+ */
143
+ function estimateTokens(obj: unknown): number {
144
+ return Math.ceil(JSON.stringify(obj).length / 4);
145
+ }
146
+
147
+ const QUERIES: Array<{ query: string; limit: number; budget: number }> = [
148
+ { query: "dhcp server", limit: 8, budget: 8000 },
149
+ { query: "/ip/firewall/filter", limit: 8, budget: 8000 },
150
+ { query: "bridge vlan", limit: 8, budget: 8000 },
151
+ { query: "hAP ax3", limit: 8, budget: 6000 },
152
+ { query: "what changed in 7.22.1", limit: 8, budget: 8000 },
153
+ { query: "BGP", limit: 8, budget: 8000 },
154
+ { query: "container setup", limit: 8, budget: 8000 },
155
+ { query: "disabled property", limit: 8, budget: 6000 },
156
+ { query: "CAPsMAN", limit: 20, budget: 16000 }, // hunger knob test
157
+ { query: "firewall filter chain", limit: 8, budget: 8000 },
158
+ ];
159
+
160
+ for (const { query, limit, budget } of QUERIES) {
161
+ test(`"${query}" (limit=${limit}) ≤ ${budget} tokens`, () => {
162
+ const result = searchAll(query, limit);
163
+ const tokens = estimateTokens(result);
164
+
165
+ if (tokens > budget) {
166
+ throw new Error(
167
+ `Token budget exceeded: "${query}" | actual=${tokens} tokens | budget=${budget}`,
168
+ );
169
+ }
170
+
171
+ // Log for the record (visible on test run)
172
+ console.log(` ✓ "${query}" (limit=${limit}): ${tokens} tokens`);
173
+ });
174
+ }
175
+ });
176
+
177
+ // ---------------------------------------------------------------------------
178
+ // Block C: Response-shape invariants
179
+ // ---------------------------------------------------------------------------
180
+ //
181
+ // This block asserts shape contracts that hold on any populated DB — no
182
+ // file-based snapshots (they coupled to the local dev DB's extraction state
183
+ // and drifted against the full CI-built DB's richer `related_buckets`). The
184
+ // invariants here protect against silent breakage of the searchAll return
185
+ // shape while staying portable across DBs of varying richness.
186
+ //
187
+ // Corpus-linked expectations ("this page must rank for this query") live in
188
+ // fixtures/eval/queries.json (Phase 0) — that's the right surface for them.
189
+
190
+ describe.skipIf(!dbIsReal)(`Response-shape invariants${dbIsReal ? "" : ` [skipped: ${skipReason}]`}`, () => {
191
+ type Invariant = {
192
+ query: string;
193
+ limit: number;
194
+ classifier_expected: Record<string, unknown>;
195
+ };
196
+
197
+ const INVARIANTS: Invariant[] = [
198
+ { query: "dhcp server", limit: 8, classifier_expected: { topics: ["dhcp"] } },
199
+ {
200
+ query: "bridge vlan",
201
+ limit: 8,
202
+ classifier_expected: { command_path: "/bridge/vlan", topics: ["bridge", "vlan"] },
203
+ },
204
+ { query: "hAP ax3", limit: 8, classifier_expected: { device: "hAP" } },
205
+ {
206
+ query: "what changed in 7.22.1",
207
+ limit: 8,
208
+ classifier_expected: { version: "7.22.1" },
209
+ },
210
+ {
211
+ query: "/ip/firewall/filter",
212
+ limit: 8,
213
+ classifier_expected: {
214
+ command_path: "/ip/firewall/filter",
215
+ topics: ["ip", "firewall", "filter"],
216
+ },
217
+ },
218
+ ];
219
+
220
+ for (const { query, limit, classifier_expected } of INVARIANTS) {
221
+ test(`shape: "${query}"`, () => {
222
+ const result = searchAll(query, limit);
223
+
224
+ // Top-level keys
225
+ expect(result).toHaveProperty("query", query);
226
+ expect(result).toHaveProperty("classified");
227
+ expect(result).toHaveProperty("pages");
228
+ expect(result).toHaveProperty("related");
229
+ expect(result).toHaveProperty("next_steps");
230
+ expect(result).toHaveProperty("total_pages");
231
+
232
+ // Classifier output is DB-independent (pure regex) — assert exact subset
233
+ for (const [k, v] of Object.entries(classifier_expected)) {
234
+ expect(result.classified).toHaveProperty(k, v);
235
+ }
236
+
237
+ // Pages: at least one hit on a populated DB, bounded by limit
238
+ expect(Array.isArray(result.pages)).toBe(true);
239
+ expect(result.pages.length).toBeGreaterThan(0);
240
+ expect(result.pages.length).toBeLessThanOrEqual(limit);
241
+ expect(result.total_pages).toBeGreaterThanOrEqual(result.pages.length);
242
+ for (const page of result.pages) {
243
+ expect(page).toHaveProperty("id");
244
+ expect(page).toHaveProperty("title");
245
+ }
246
+
247
+ // Related block: always an object; buckets that are present are arrays
248
+ expect(typeof result.related).toBe("object");
249
+ for (const [bucket, entries] of Object.entries(result.related)) {
250
+ if (Array.isArray(entries)) {
251
+ expect(entries.length).toBeGreaterThan(0);
252
+ } else {
253
+ // command_node is a single object when present
254
+ expect(entries).toBeTruthy();
255
+ }
256
+ expect(bucket.length).toBeGreaterThan(0);
257
+ }
258
+
259
+ // next_steps: array of hint strings
260
+ expect(Array.isArray(result.next_steps)).toBe(true);
261
+ });
262
+ }
263
+ });
package/src/query.test.ts CHANGED
@@ -43,6 +43,7 @@ const {
43
43
  searchDeviceTests,
44
44
  getTestResultMeta,
45
45
  normalizeDeviceQuery,
46
+ truncateDeviceTestResultsPrefer512,
46
47
  searchVideos,
47
48
  searchDude,
48
49
  getDudePage,
@@ -1426,6 +1427,48 @@ describe("dataset CSV exports", () => {
1426
1427
  });
1427
1428
  });
1428
1429
 
1430
+ describe("truncateDeviceTestResultsPrefer512", () => {
1431
+ test("keeps all 512B rows when truncating", () => {
1432
+ const rows = [
1433
+ { packet_size: 1518, id: "a" },
1434
+ { packet_size: 512, id: "b" },
1435
+ { packet_size: 64, id: "c" },
1436
+ { packet_size: 512, id: "d" },
1437
+ ];
1438
+ const res = truncateDeviceTestResultsPrefer512(rows, 2);
1439
+
1440
+ expect(res.rows.map((r) => r.id)).toEqual(["b", "d"]);
1441
+ expect(res.omitted).toBe(2);
1442
+ });
1443
+
1444
+ test("fills remaining slots with non-512 rows", () => {
1445
+ const rows = [
1446
+ { packet_size: 1518, id: "a" },
1447
+ { packet_size: 512, id: "b" },
1448
+ { packet_size: 64, id: "c" },
1449
+ { packet_size: 512, id: "d" },
1450
+ ];
1451
+ const res = truncateDeviceTestResultsPrefer512(rows, 3);
1452
+
1453
+ expect(res.rows.map((r) => r.id)).toEqual(["b", "d", "a"]);
1454
+ expect(res.omitted).toBe(1);
1455
+ });
1456
+
1457
+ test("returns all 512B rows even if they exceed maxRows", () => {
1458
+ const rows = [
1459
+ { packet_size: 512, id: "a" },
1460
+ { packet_size: 512, id: "b" },
1461
+ { packet_size: 1518, id: "c" },
1462
+ { packet_size: 512, id: "d" },
1463
+ { packet_size: 64, id: "e" },
1464
+ ];
1465
+ const res = truncateDeviceTestResultsPrefer512(rows, 2);
1466
+
1467
+ expect(res.rows.map((r) => r.id)).toEqual(["a", "b", "d"]);
1468
+ expect(res.omitted).toBe(2);
1469
+ });
1470
+ });
1471
+
1429
1472
  describe("getTestResultMeta", () => {
1430
1473
  test("returns distinct values", () => {
1431
1474
  const meta = getTestResultMeta();
package/src/query.ts CHANGED
@@ -298,8 +298,8 @@ export function searchAll(query: string, limit = DEFAULT_LIMIT): SearchAllRespon
298
298
  path: node.path,
299
299
  type: node.type,
300
300
  description: node.description,
301
- ...(node.page_id
302
- ? { linked_page: { id: node.page_id, title: node.page_title!, url: node.page_url! } }
301
+ ...(node.page_id && node.page_title && node.page_url
302
+ ? { linked_page: { id: node.page_id, title: node.page_title, url: node.page_url } }
303
303
  : {}),
304
304
  };
305
305
  }
@@ -1330,6 +1330,43 @@ export type DeviceTestResult = {
1330
1330
  throughput_mbps: number | null;
1331
1331
  };
1332
1332
 
1333
+ /**
1334
+ * Truncate benchmark rows for display while always keeping all 512-byte rows.
1335
+ *
1336
+ * Used by both TUI and MCP-facing adapters when they need a compact view.
1337
+ * If the 512B bucket itself exceeds maxRows, return all 512B rows anyway.
1338
+ */
1339
+ export function truncateDeviceTestResultsPrefer512<T extends { packet_size: number }>(
1340
+ rows: T[],
1341
+ maxRows: number,
1342
+ ): { rows: T[]; omitted: number } {
1343
+ if (rows.length === 0) return { rows: [], omitted: 0 };
1344
+
1345
+ const safeMax = Math.max(0, Math.floor(maxRows));
1346
+ const rows512 = rows.filter((r) => r.packet_size === 512);
1347
+ const non512 = rows.filter((r) => r.packet_size !== 512);
1348
+
1349
+ if (safeMax === 0) {
1350
+ return { rows: rows512, omitted: rows.length - rows512.length };
1351
+ }
1352
+
1353
+ if (rows.length <= safeMax) {
1354
+ return { rows, omitted: 0 };
1355
+ }
1356
+
1357
+ if (rows512.length === 0) {
1358
+ return { rows: rows.slice(0, safeMax), omitted: rows.length - safeMax };
1359
+ }
1360
+
1361
+ if (rows512.length >= safeMax) {
1362
+ return { rows: rows512, omitted: rows.length - rows512.length };
1363
+ }
1364
+
1365
+ const takeNon512 = safeMax - rows512.length;
1366
+ const selected = [...rows512, ...non512.slice(0, takeNon512)];
1367
+ return { rows: selected, omitted: rows.length - selected.length };
1368
+ }
1369
+
1333
1370
  /** Map Unicode superscript digits to ASCII equivalents for product name matching.
1334
1371
  * MikroTik uses ² and ³ in product names (hAP ax³, hAP ac²), but users type ASCII. */
1335
1372
  const SUPERSCRIPT_TO_ASCII: [string, string][] = [
@@ -390,9 +390,20 @@ describe("release.yml", () => {
390
390
  const src = readText(".github/workflows/release.yml");
391
391
  expect(src).toContain("bun run typecheck");
392
392
  expect(src).toContain("bun test");
393
+ expect(src).toContain("bun test src/mcp-contract.test.ts");
393
394
  expect(src).toContain("bun run lint");
394
395
  });
395
396
 
397
+ test("runs MCP contract tests against the real built DB before eval/release", () => {
398
+ const src = readText(".github/workflows/release.yml");
399
+ const contractIdx = src.indexOf("bun test src/mcp-contract.test.ts");
400
+ const evalIdx = src.indexOf("MCP retrieval eval (Phase 0, non-blocking)");
401
+ const buildIdx = src.indexOf("Build release artifacts");
402
+ expect(contractIdx).toBeGreaterThan(0);
403
+ expect(contractIdx).toBeLessThan(evalIdx);
404
+ expect(contractIdx).toBeLessThan(buildIdx);
405
+ });
406
+
396
407
  test("creates GitHub Release", () => {
397
408
  const src = readText(".github/workflows/release.yml");
398
409
  expect(src).toContain("gh release create");