@tikoci/rosetta 0.10.0 → 0.11.0-alpha.87

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 CHANGED
@@ -121,6 +121,21 @@ Need to force a database reload later? Use:
121
121
  bunx @tikoci/rosetta@latest --refresh
122
122
  ```
123
123
 
124
+ ### Prerelease channels (optional)
125
+
126
+ New corpus builds sometimes ship first under a non-default npm dist-tag so testers can opt in without moving what everyone else gets by default:
127
+
128
+ ```sh
129
+ bunx @tikoci/rosetta@next # newest prerelease of any stage (alpha/beta/rc)
130
+ bunx @tikoci/rosetta@alpha # pinned to the alpha stage's latest
131
+ bunx @tikoci/rosetta@beta # pinned to the beta stage's latest
132
+ bunx @tikoci/rosetta@rc # pinned to the rc stage's latest
133
+ ```
134
+
135
+ `bunx @tikoci/rosetta` (no tag) and `bunx @tikoci/rosetta@latest` always resolve to the default, non-prerelease channel — publishing a prerelease never moves `latest`.
136
+
137
+ > **Dist-tags, not semver ranges.** A version range like `^0.11.0-alpha` is **not** equivalent to a dist-tag. npm's prerelease range matching only spans the exact `[major,minor,patch]` tuple written in the range, so it stops tracking new prereleases the moment a patch/minor bump happens. `@next`/`@alpha`/`@beta`/`@rc` are the actual "follow forever" mechanism — use those, not a range, to stay on a moving prerelease channel.
138
+
124
139
  ### Configure your MCP client
125
140
 
126
141
  <details>
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tikoci/rosetta",
3
- "version": "0.10.0",
3
+ "version": "0.11.0-alpha.87",
4
4
  "description": "RouterOS documentation as SQLite FTS5 — RAG search + command glossary via MCP",
5
5
  "type": "module",
6
6
  "license": "MIT",
package/src/db.ts CHANGED
@@ -81,6 +81,16 @@ export function initDb() {
81
81
  html_file TEXT NOT NULL
82
82
  );`);
83
83
 
84
+ // Migration: add rosetta_id (H7 Option 2 — see briefings/B-0012, "Identity / rosetta-id
85
+ // design"). NULL for legacy Confluence-sourced rows (they keep their numeric `id` as the
86
+ // only identifier); populated for Docusaurus-sourced rows extracted by extract-docusaurus.ts.
87
+ // A UNIQUE index (not a column constraint) so multiple NULLs are allowed side by side.
88
+ const pageCols = db.prepare("PRAGMA table_info(pages)").all() as Array<{ name: string }>;
89
+ if (!pageCols.some((c) => c.name === "rosetta_id")) {
90
+ db.run("ALTER TABLE pages ADD COLUMN rosetta_id TEXT;");
91
+ }
92
+ db.run("CREATE UNIQUE INDEX IF NOT EXISTS idx_pages_rosetta_id ON pages(rosetta_id) WHERE rosetta_id IS NOT NULL;");
93
+
84
94
  db.run(`CREATE VIRTUAL TABLE IF NOT EXISTS pages_fts USING fts5(
85
95
  title, path, text, code,
86
96
  content=pages,
@@ -25,6 +25,7 @@
25
25
  import { existsSync, readFileSync, writeFileSync } from "node:fs";
26
26
  import { join } from "node:path";
27
27
  import { searchAll } from "../query.ts";
28
+ import { deriveRosettaId } from "../rosetta-id.ts";
28
29
 
29
30
  // ── Types ──────────────────────────────────────────────────────────────────
30
31
 
@@ -49,7 +50,10 @@ type GoldenQuery = {
49
50
  id: string;
50
51
  query: string;
51
52
  shape: Shape;
52
- expected_pages?: number[];
53
+ /** Stable identity for Docusaurus-sourced pages — see src/rosetta-id.ts. `pages.id` is NOT
54
+ * stable across extraction runs (extract-docusaurus.ts re-mints it as a fresh rowid every
55
+ * time), so golden-set expectations must pin to rosetta_id, not the numeric id. */
56
+ expected_rosetta_ids?: string[];
53
57
  /** "any" (default): top-k contains ≥1 expected page → recall=1. "all": classical subset recall. */
54
58
  match_mode?: MatchMode;
55
59
  expected_classified?: ExpectedClassified;
@@ -85,7 +89,7 @@ type QueryResult = {
85
89
  topics_ok: boolean;
86
90
  skipped: boolean;
87
91
  skip_reason?: string;
88
- top_pages: { id: number; title: string }[];
92
+ top_pages: { id: number; title: string; rosetta_id: string }[];
89
93
  classified_actual: Record<string, unknown>;
90
94
  notes: string[];
91
95
  };
@@ -152,15 +156,15 @@ function evalQuery(q: GoldenQuery, commandsCount: number, k = 5): QueryResult {
152
156
  }
153
157
 
154
158
  const resp = searchAll(q.query, k * 2);
155
- const topIds = resp.pages.slice(0, k).map((p) => p.id);
156
- const top3Ids = resp.pages.slice(0, 3).map((p) => p.id);
159
+ const topRosettaIds = resp.pages.slice(0, k).map((p) => deriveRosettaId(p.url));
160
+ const top3RosettaIds = resp.pages.slice(0, 3).map((p) => deriveRosettaId(p.url));
157
161
 
158
162
  // Recall semantics — default "any" (≥1 expected page in top-k counts as full recall).
159
163
  // For QA-style retrieval we usually only need ONE good answer; classical subset recall
160
164
  // ("all" mode) is opt-in for cases where coverage actually matters.
161
- const expected = q.expected_pages ?? [];
165
+ const expected = q.expected_rosetta_ids ?? [];
162
166
  const mode: MatchMode = q.match_mode ?? "any";
163
- const recallFor = (ids: number[]): number => {
167
+ const recallFor = (ids: string[]): number => {
164
168
  if (expected.length === 0) return 1;
165
169
  if (mode === "any") {
166
170
  return expected.some((id) => ids.includes(id)) ? 1 : 0;
@@ -168,14 +172,14 @@ function evalQuery(q: GoldenQuery, commandsCount: number, k = 5): QueryResult {
168
172
  // "all" mode: classical subset recall
169
173
  return expected.filter((id) => ids.includes(id)).length / expected.length;
170
174
  };
171
- const recall_at_5 = recallFor(topIds);
172
- const recall_at_3 = recallFor(top3Ids);
175
+ const recall_at_5 = recallFor(topRosettaIds);
176
+ const recall_at_3 = recallFor(top3RosettaIds);
173
177
 
174
178
  // Reciprocal rank: 1/rank of first expected page in top-k. 0 if none found.
175
179
  let rr = 0;
176
180
  if (expected.length > 0) {
177
- for (let i = 0; i < topIds.length; i++) {
178
- if (expected.includes(topIds[i] as number)) {
181
+ for (let i = 0; i < topRosettaIds.length; i++) {
182
+ if (expected.includes(topRosettaIds[i] as string)) {
179
183
  rr = 1 / (i + 1);
180
184
  break;
181
185
  }
@@ -220,7 +224,7 @@ function evalQuery(q: GoldenQuery, commandsCount: number, k = 5): QueryResult {
220
224
  }
221
225
 
222
226
  if (expected.length > 0 && recall_at_5 === 0) {
223
- notes.push(`top-${k} pages: ${topIds.join(", ")} (none of expected ${expected.join(", ")})`);
227
+ notes.push(`top-${k} pages: ${topRosettaIds.join(", ")} (none of expected ${expected.join(", ")})`);
224
228
  }
225
229
 
226
230
  return {
@@ -234,7 +238,7 @@ function evalQuery(q: GoldenQuery, commandsCount: number, k = 5): QueryResult {
234
238
  related_ok,
235
239
  topics_ok,
236
240
  skipped: false,
237
- top_pages: resp.pages.slice(0, 5).map((p) => ({ id: p.id, title: p.title })),
241
+ top_pages: resp.pages.slice(0, 5).map((p) => ({ id: p.id, title: p.title, rosetta_id: deriveRosettaId(p.url) })),
238
242
  classified_actual: { ...resp.classified },
239
243
  notes,
240
244
  };
@@ -0,0 +1,251 @@
1
+ // Force in-memory DB before importing extract-docusaurus.ts (which transitively imports
2
+ // db.ts) — same guard as extract-dude.test.ts. See BACKLOG.md "Test DB-leak guards".
3
+ process.env.DB_PATH = ":memory:";
4
+
5
+ import { describe, expect, test } from "bun:test";
6
+ import { readFileSync } from "node:fs";
7
+ import { join } from "node:path";
8
+
9
+ const {
10
+ isInScopeDocsUrl,
11
+ parseProperties,
12
+ parseCallouts,
13
+ parseSections,
14
+ parsePage,
15
+ resolveDescriptionLinks,
16
+ extractCodeBlocks,
17
+ slugify,
18
+ parseLlmsTxtInScopeCount,
19
+ markdownUrlFor,
20
+ } = await import("./extract-docusaurus.ts");
21
+
22
+ const FIXTURES_DIR = join(import.meta.dirname, "..", "fixtures", "docusaurus");
23
+ const read = (name: string) => readFileSync(join(FIXTURES_DIR, name), "utf-8");
24
+
25
+ const dhcpMd = read("dhcp.md");
26
+ const smsMd = read("sms.md");
27
+ const dot1xMd = read("dot1x.md");
28
+ const addressListsMd = read("address-lists.md");
29
+
30
+ const DHCP_URL = "https://manual.mikrotik.com/docs/network-management/dhcp";
31
+ const SMS_URL = "https://manual.mikrotik.com/docs/mobile-networking/sms";
32
+ const ADDRESS_LISTS_URL = "https://manual.mikrotik.com/docs/firewall-and-quality-of-service/firewall/address-lists";
33
+
34
+ describe("isInScopeDocsUrl", () => {
35
+ test("accepts ordinary /docs prose pages", () => {
36
+ expect(isInScopeDocsUrl(DHCP_URL)).toBeTrue();
37
+ expect(isInScopeDocsUrl("/docs/network-management/dhcp")).toBeTrue();
38
+ });
39
+
40
+ test("rejects CLI Reference pages", () => {
41
+ expect(isInScopeDocsUrl("https://manual.mikrotik.com/docs/cli-reference/ip/address")).toBeFalse();
42
+ });
43
+
44
+ test("rejects tag-index pages, including the bare tags root (no trailing slash — real 404 live)", () => {
45
+ expect(isInScopeDocsUrl("https://manual.mikrotik.com/docs/tags/dhcp")).toBeFalse();
46
+ expect(isInScopeDocsUrl("https://manual.mikrotik.com/docs/tags")).toBeFalse();
47
+ });
48
+
49
+ test("rejects non-/docs sections (hardware, changelog, blog)", () => {
50
+ expect(isInScopeDocsUrl("https://manual.mikrotik.com/hardware/rb5009")).toBeFalse();
51
+ expect(isInScopeDocsUrl("https://manual.mikrotik.com/changelog/changelog-2026-05-25")).toBeFalse();
52
+ expect(isInScopeDocsUrl("https://manual.mikrotik.com/blog/news130")).toBeFalse();
53
+ });
54
+ });
55
+
56
+ describe("markdownUrlFor", () => {
57
+ test("appends .md to an ordinary leaf page URL", () => {
58
+ expect(markdownUrlFor(DHCP_URL)).toBe(`${DHCP_URL}.md`);
59
+ });
60
+
61
+ test("appends index.md (not .md) to a category/index page URL — real 404 otherwise", () => {
62
+ // Confirmed live 2026-07-07: .../accounting.md 404s, .../accounting/index.md is 200.
63
+ const categoryUrl = "https://manual.mikrotik.com/docs/authentication-authorization-accounting/";
64
+ expect(markdownUrlFor(categoryUrl)).toBe(`${categoryUrl}index.md`);
65
+ });
66
+ });
67
+
68
+ describe("slugify", () => {
69
+ test("produces a github-slugger-style anchor", () => {
70
+ expect(slugify("DHCP Client")).toBe("dhcp-client");
71
+ expect(slugify("Read-only properties")).toBe("read-only-properties");
72
+ });
73
+ });
74
+
75
+ describe("parseProperties — dhcp.md (real malformed-emphasis case, B-0012 H4)", () => {
76
+ const properties = parseProperties(dhcpMd);
77
+
78
+ test("finds a realistic number of properties across the whole page", () => {
79
+ // dhcp.md has multiple "### Properties"/"#### Read-only properties" tables
80
+ // (client + server + DHCPv6). Anchor on a floor, not an exact count, so a future
81
+ // MikroTik doc edit doesn't spuriously fail this test the way an exact-count
82
+ // assertion would — matching the "durable, trend toward catching real drift" goal.
83
+ expect(properties.length).toBeGreaterThan(50);
84
+ });
85
+
86
+ test("flags check-gateway's malformed bold/italic collision, not just any property", () => {
87
+ const checkGateway = properties.find((p) => p.name === "check-gateway");
88
+ expect(checkGateway).toBeDefined();
89
+ expect(checkGateway?.malformedEmphasis).toBeTrue();
90
+ expect(checkGateway?.defaultVal).toBe("none");
91
+ });
92
+
93
+ test("parses a well-formed property cleanly (not flagged malformed)", () => {
94
+ const disabled = properties.find((p) => p.name === "disabled" && p.section === "Properties");
95
+ expect(disabled).toBeDefined();
96
+ expect(disabled?.malformedEmphasis).toBeFalse();
97
+ expect(disabled?.defaultVal).toBe("yes");
98
+ expect(disabled?.rawType).toContain("yes");
99
+ });
100
+
101
+ test("attributes properties to the nearest preceding heading as section", () => {
102
+ const readOnly = properties.find((p) => p.section === "Read-only properties");
103
+ expect(readOnly).toBeDefined();
104
+ });
105
+
106
+ test("leaves the relative link target as-is at the parseProperties layer (resolution happens in parsePage)", () => {
107
+ const useDns = properties.find((p) => p.name === "use-peer-dns");
108
+ expect(useDns?.description).toContain("./dhcp.md#dhcp-server");
109
+ });
110
+ });
111
+
112
+ describe("parseProperties — sms.md (real 'Parameter' header spelling, not 'Property')", () => {
113
+ test("recognizes the 'Parameter' header spelling used on sms.md", () => {
114
+ const properties = parseProperties(smsMd);
115
+ expect(properties.length).toBeGreaterThan(5);
116
+ expect(properties.some((p) => p.name === "phone-number")).toBeTrue();
117
+ });
118
+
119
+ test("handles an empty Default value without crashing or eating the next cell", () => {
120
+ const properties = parseProperties(smsMd);
121
+ const smsc = properties.find((p) => p.name === "smsc");
122
+ expect(smsc).toBeDefined();
123
+ expect(smsc?.defaultVal).toBeNull();
124
+ });
125
+ });
126
+
127
+ describe("parseProperties — address-lists.md (no property table at all)", () => {
128
+ test("returns an empty array rather than a false-positive match", () => {
129
+ expect(parseProperties(addressListsMd)).toEqual([]);
130
+ });
131
+ });
132
+
133
+ describe("parseCallouts — dot1x.md (real live :::: fenced admonitions)", () => {
134
+ const callouts = parseCallouts(dot1xMd);
135
+
136
+ test("finds every :::: warning block on the page", () => {
137
+ expect(callouts.length).toBe(4);
138
+ expect(callouts.every((c) => c.type === "warning")).toBeTrue();
139
+ });
140
+
141
+ test("captures real callout content, not an empty fence", () => {
142
+ expect(callouts[0].content).toContain("not supported on SMIPS devices");
143
+ });
144
+ });
145
+
146
+ describe("parseCallouts — address-lists.md (no admonitions)", () => {
147
+ test("returns an empty array", () => {
148
+ expect(parseCallouts(addressListsMd)).toEqual([]);
149
+ });
150
+ });
151
+
152
+ describe("parseSections", () => {
153
+ test("skips the duplicated title H1 (real quirk: raw .md repeats '# Title' after the summary blockquote)", () => {
154
+ const sections = parseSections(dot1xMd, "Dot1X");
155
+ const topLevel = sections.filter((s) => s.level === 1);
156
+ expect(topLevel.length).toBe(0); // both H1 occurrences are the title; neither should mint a section
157
+ });
158
+
159
+ test("splits on h1-h3 but folds deeper headings into the enclosing section's text", () => {
160
+ const sections = parseSections(dhcpMd, "DHCP");
161
+ const summary = sections.find((s) => s.heading === "Summary");
162
+ expect(summary).toBeDefined();
163
+ expect(summary?.level).toBe(3);
164
+ });
165
+
166
+ test("de-duplicates colliding anchor ids within a page", () => {
167
+ // dhcp.md has more than one "Example"-style heading across its DHCP Client /
168
+ // DHCPv6 Client / DHCP Server subsections at different heading levels.
169
+ const sections = parseSections(dhcpMd, "DHCP");
170
+ const anchors = sections.map((s) => s.anchorId);
171
+ expect(new Set(anchors).size).toBe(anchors.length);
172
+ });
173
+
174
+ test("a page whose only headings are the duplicated title yields zero sections (content stays in page text)", () => {
175
+ // address-lists.md has no h2/h3 at all — both its headings are the "Address-lists"
176
+ // title duplicate, so both get dropped. Matches extract-html.ts's own behavior for
177
+ // Confluence pages with no id-bearing headings: sections == [], full text elsewhere.
178
+ const sections = parseSections(addressListsMd, "Address-lists");
179
+ expect(sections).toEqual([]);
180
+ });
181
+ });
182
+
183
+ describe("extractCodeBlocks", () => {
184
+ test("extracts fenced ```ros blocks and records the language", () => {
185
+ const { code, codeLang } = extractCodeBlocks(addressListsMd);
186
+ expect(code).toContain("/ip/firewall/address-list/add");
187
+ expect(codeLang).toBe("ros");
188
+ });
189
+ });
190
+
191
+ describe("resolveDescriptionLinks", () => {
192
+ test("leaves absolute links and same-page anchors untouched", () => {
193
+ const desc = "See [external](https://example.com/x) and [here](#section).";
194
+ expect(resolveDescriptionLinks(desc, DHCP_URL)).toBe(desc);
195
+ });
196
+
197
+ test("rewrites a relative .md link to a live manual.mikrotik.com URL", () => {
198
+ const desc = "See [gateway reachability](../user-guides/routing-and-networking-protocols/routing-decision.md).";
199
+ const resolved = resolveDescriptionLinks(desc, DHCP_URL);
200
+ expect(resolved).toBe(
201
+ "See [gateway reachability](https://manual.mikrotik.com/docs/user-guides/routing-and-networking-protocols/routing-decision).",
202
+ );
203
+ });
204
+ });
205
+
206
+ describe("parsePage — end-to-end shape", () => {
207
+ test("derives rosetta_id, slug, path, and depth from the page URL", () => {
208
+ const page = parsePage(dhcpMd, DHCP_URL);
209
+ expect(page.rosettaId).toBe("docs/network-management/dhcp");
210
+ expect(page.slug).toBe("dhcp");
211
+ expect(page.path).toBe("docs > network-management > dhcp");
212
+ expect(page.depth).toBe(3);
213
+ });
214
+
215
+ test("every property on a real page round-trips through parsePage with resolved links", () => {
216
+ const page = parsePage(dhcpMd, DHCP_URL);
217
+ expect(page.properties.length).toBeGreaterThan(50);
218
+ expect(page.properties.filter((p) => p.malformedEmphasis).length).toBeGreaterThanOrEqual(1);
219
+
220
+ const useDns = page.properties.find((p) => p.name === "use-peer-dns");
221
+ expect(useDns?.description).toContain("https://manual.mikrotik.com/docs/network-management/dhcp#dhcp-server");
222
+ expect(useDns?.description).not.toContain(".md");
223
+ });
224
+
225
+ test("a short page with no properties/callouts still produces a valid page shape", () => {
226
+ const page = parsePage(addressListsMd, ADDRESS_LISTS_URL);
227
+ expect(page.title).toBe("Address-lists");
228
+ expect(page.properties).toEqual([]);
229
+ expect(page.callouts).toEqual([]);
230
+ expect(page.sections).toEqual([]); // see parseSections' dedicated test for why
231
+ expect(page.wordCount).toBeGreaterThan(0);
232
+ expect(page.text).toContain("dynamic address list");
233
+ });
234
+
235
+ test("sms.md's 'Parameter'-spelled table still surfaces via parsePage", () => {
236
+ const page = parsePage(smsMd, SMS_URL);
237
+ expect(page.properties.some((p) => p.name === "phone-number")).toBeTrue();
238
+ });
239
+ });
240
+
241
+ describe("parseLlmsTxtInScopeCount (B-0012 H8, V-docusaurus-docs-count)", () => {
242
+ test("counts only in-scope /docs entries, excluding CLI Reference and tags", () => {
243
+ const llmsTxt = [
244
+ "- [DHCP](https://manual.mikrotik.com/docs/network-management/dhcp.md): desc",
245
+ "- [ip/address](https://manual.mikrotik.com/docs/cli-reference/ip/address.md): desc",
246
+ "- [tag](https://manual.mikrotik.com/docs/tags/dhcp.md): desc",
247
+ "- [Dot1X](https://manual.mikrotik.com/docs/authentication-authorization-accounting/dot1x.md): desc",
248
+ ].join("\n");
249
+ expect(parseLlmsTxtInScopeCount(llmsTxt)).toBe(2);
250
+ });
251
+ });