@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/src/query.ts CHANGED
@@ -5,9 +5,22 @@
5
5
  * no author/date/engagement signals — just text search with BM25 ranking.
6
6
  */
7
7
 
8
+ import { type CanonicalCommand, canonicalize } from "./canonicalize.ts";
9
+ import { makeDbVerbResolver } from "./canonicalize-resolver.ts";
8
10
  import { classifyQuery, type QueryClassification } from "./classify.ts";
9
11
  import { db } from "./db.ts";
10
12
 
13
+ /**
14
+ * Lazy DB-backed verb resolver for the classifier. Built on first use so
15
+ * tests that import query.ts but never call searchAll don't pay the cost,
16
+ * and it inherits the same `db` singleton everything else uses.
17
+ */
18
+ let _verbResolver: ReturnType<typeof makeDbVerbResolver> | null = null;
19
+ function getVerbResolver() {
20
+ if (!_verbResolver) _verbResolver = makeDbVerbResolver(db);
21
+ return _verbResolver;
22
+ }
23
+
11
24
  export type SearchResult = {
12
25
  id: number;
13
26
  title: string;
@@ -43,6 +56,18 @@ const STOP_WORDS = new Set([
43
56
  "what", "when", "where", "which", "why", "with", "without",
44
57
  ]);
45
58
 
59
+ const CHANGELOG_GENERIC_TERMS = new Set([
60
+ "change",
61
+ "changed",
62
+ "changes",
63
+ "changelog",
64
+ "changelogs",
65
+ "new",
66
+ "release",
67
+ "released",
68
+ "version",
69
+ ]);
70
+
46
71
  const COMPOUND_TERMS: [string, string][] = [
47
72
  ["firewall", "filter"],
48
73
  ["firewall", "mangle"],
@@ -94,6 +119,13 @@ const COMPOUND_TERMS: [string, string][] = [
94
119
  // from here for backward compatibility. See classify.ts for the canonical definition.
95
120
  export { KNOWN_TOPICS } from "./classify.ts";
96
121
 
122
+ function applyContextualTermFilters(terms: string[]): string[] {
123
+ if (terms.includes("bridge") && terms.includes("vlan") && terms.includes("filtering")) {
124
+ return terms.filter((term) => term !== "switch");
125
+ }
126
+ return terms;
127
+ }
128
+
97
129
  function getSpecialSearchHint(question: string): string | undefined {
98
130
  const normalized = question.toLowerCase();
99
131
  if (/(?:^|\b)(?:the\s+)?dude(?:\b|$)/.test(normalized)) {
@@ -118,12 +150,13 @@ function rowsToCsv<T extends Record<string, CsvScalar>>(
118
150
  }
119
151
 
120
152
  export function extractTerms(question: string): string[] {
121
- return question
153
+ const terms = question
122
154
  .toLowerCase()
123
155
  .replace(/[^\w\s-]/g, " ")
124
156
  .split(/\s+/)
125
157
  .filter((t) => t.length >= MIN_TERM_LENGTH && !STOP_WORDS.has(t))
126
158
  .slice(0, MAX_TERMS);
159
+ return applyContextualTermFilters(terms);
127
160
  }
128
161
 
129
162
  export function buildFtsQuery(terms: string[], mode: "AND" | "OR"): string {
@@ -203,6 +236,7 @@ type RelatedProperty = {
203
236
  page_title: string;
204
237
  page_url: string;
205
238
  page_id: number;
239
+ confidence: PropertyLookupConfidence;
206
240
  };
207
241
 
208
242
  type RelatedDevice = {
@@ -273,7 +307,7 @@ function relatedCaps(limit: number): { cap: number; videoCap: number } {
273
307
  }
274
308
 
275
309
  export function searchAll(query: string, limit = DEFAULT_LIMIT): SearchAllResponse {
276
- const classified = classifyQuery(query);
310
+ const classified = classifyQuery(query, { isVerb: getVerbResolver() });
277
311
  const pagesResp = searchPages(query, limit);
278
312
  const { cap: RELATED_CAP, videoCap: RELATED_VIDEO_CAP } = relatedCaps(limit);
279
313
 
@@ -330,6 +364,7 @@ export function searchAll(query: string, limit = DEFAULT_LIMIT): SearchAllRespon
330
364
  page_title: p.page_title,
331
365
  page_url: p.page_url,
332
366
  page_id: p.page_id,
367
+ confidence: p.confidence,
333
368
  }));
334
369
  }
335
370
  }
@@ -500,6 +535,177 @@ function buildSearchAllNote(
500
535
  return notes.length > 0 ? notes.join(" ") : undefined;
501
536
  }
502
537
 
538
+ type ExplainCommandConfidence = CanonicalCommand["confidence"] | "none";
539
+
540
+ type ExplainCommandCanonical = {
541
+ path: string;
542
+ verb: string;
543
+ args: string[];
544
+ confidence: CanonicalCommand["confidence"];
545
+ };
546
+
547
+ type ExplainCommandArg = {
548
+ raw: string;
549
+ name: string;
550
+ value: string;
551
+ property?: {
552
+ name: string;
553
+ type: string | null;
554
+ default_val: string | null;
555
+ description: string;
556
+ page_title: string;
557
+ page_url: string;
558
+ page_id: number;
559
+ confidence: PropertyLookupConfidence;
560
+ };
561
+ };
562
+
563
+ type ExplainCommandWarning = {
564
+ kind: "no-command" | "low-confidence" | "unknown-arg" | "command-not-in-version" | "model-context-unused";
565
+ message: string;
566
+ arg?: string;
567
+ suggestion?: string;
568
+ };
569
+
570
+ export type ExplainCommandResult = {
571
+ command: string;
572
+ canonical: ExplainCommandCanonical | null;
573
+ confidence: ExplainCommandConfidence;
574
+ args: ExplainCommandArg[];
575
+ warnings: ExplainCommandWarning[];
576
+ pages: SearchResult[];
577
+ changelog_hits: ChangelogResult[];
578
+ version_check?: ReturnType<typeof checkCommandVersions>;
579
+ };
580
+
581
+ function parseKeyValueArg(raw: string): { name: string; value: string } | null {
582
+ const eq = raw.indexOf("=");
583
+ if (eq <= 0) return null;
584
+ return {
585
+ name: raw.slice(0, eq),
586
+ value: raw.slice(eq + 1),
587
+ };
588
+ }
589
+
590
+ function explainCommandSearchText(command: string, canonical: ExplainCommandCanonical | null, args: ExplainCommandArg[]): string {
591
+ if (!canonical) return command;
592
+ const argNames = args.map((arg) => arg.name).join(" ");
593
+ return `${canonical.path} ${canonical.verb} ${argNames}`.trim();
594
+ }
595
+
596
+ /**
597
+ * Explain a candidate RouterOS CLI command using only the local rosetta DB.
598
+ *
599
+ * This is read-only: it canonicalizes the command, annotates key=value args
600
+ * with documentation-property matches, checks version presence, and surfaces
601
+ * compact docs/changelog context. It never connects to or modifies a router.
602
+ */
603
+ export function explainCommand(command: string, rosVersion?: string, model?: string): ExplainCommandResult {
604
+ const parsed = canonicalize(command, "/", { isVerb: getVerbResolver() });
605
+ const primary = parsed.commands.find((cmd) => !cmd.subshell) ?? parsed.commands[0] ?? null;
606
+ const warnings: ExplainCommandWarning[] = [];
607
+
608
+ if (!primary?.verb) {
609
+ warnings.push({
610
+ kind: "no-command",
611
+ message: "No primary RouterOS command verb could be extracted from the input.",
612
+ suggestion: "Pass a full CLI command such as '/ip/firewall/filter add chain=forward action=drop'.",
613
+ });
614
+ }
615
+
616
+ const canonical = primary
617
+ ? {
618
+ path: primary.path,
619
+ verb: primary.verb,
620
+ args: primary.args,
621
+ confidence: primary.confidence,
622
+ }
623
+ : null;
624
+ const confidence: ExplainCommandConfidence = canonical?.confidence ?? "none";
625
+
626
+ if (confidence === "low") {
627
+ warnings.push({
628
+ kind: "low-confidence",
629
+ message: "The command path/verb was inferred from a loose or prose-shaped input.",
630
+ suggestion: "Use a full absolute RouterOS CLI form such as '/ip/firewall/filter add ...'.",
631
+ });
632
+ }
633
+
634
+ if (model) {
635
+ warnings.push({
636
+ kind: "model-context-unused",
637
+ message: `Model context "${model}" was accepted but device-specific command validation is not implemented yet.`,
638
+ suggestion: "Use routeros_device_lookup separately for hardware specs; this tool stays read-only and documentation-backed.",
639
+ });
640
+ }
641
+
642
+ const args: ExplainCommandArg[] = [];
643
+ if (canonical) {
644
+ for (const raw of canonical.args) {
645
+ const parsedArg = parseKeyValueArg(raw);
646
+ if (!parsedArg) continue;
647
+ const match = lookupProperty(parsedArg.name, canonical.path)[0];
648
+ const explainedArg: ExplainCommandArg = {
649
+ raw,
650
+ name: parsedArg.name,
651
+ value: parsedArg.value,
652
+ };
653
+ if (match) {
654
+ explainedArg.property = {
655
+ name: match.name,
656
+ type: match.type,
657
+ default_val: match.default_val,
658
+ description: match.description,
659
+ page_title: match.page_title,
660
+ page_url: match.page_url,
661
+ page_id: match.page_id,
662
+ confidence: match.confidence,
663
+ };
664
+ } else {
665
+ warnings.push({
666
+ kind: "unknown-arg",
667
+ arg: parsedArg.name,
668
+ message: `No documented property named "${parsedArg.name}" was found for ${canonical.path}.`,
669
+ suggestion: `Use routeros_command_tree path="${canonical.path}" or routeros_get_page for the linked documentation to confirm available arguments.`,
670
+ });
671
+ }
672
+ args.push(explainedArg);
673
+ }
674
+ }
675
+
676
+ let version_check: ReturnType<typeof checkCommandVersions> | undefined;
677
+ if (canonical) {
678
+ version_check = checkCommandVersions(canonical.path);
679
+ if (rosVersion && !version_check.versions.includes(rosVersion)) {
680
+ warnings.push({
681
+ kind: "command-not-in-version",
682
+ message: `${canonical.path} is not present in tracked command data for RouterOS ${rosVersion}.`,
683
+ suggestion: version_check.versions.length > 0
684
+ ? `Tracked range for this path: ${version_check.first_seen}–${version_check.last_seen}. Use routeros_command_diff for upgrade changes.`
685
+ : "Use routeros_command_tree/routeros_search to confirm the path, or check routeros_stats for tracked version coverage.",
686
+ });
687
+ }
688
+ }
689
+
690
+ const contextQuery = explainCommandSearchText(command, canonical, args);
691
+ const pages = searchPages(contextQuery || command, 3).results;
692
+ const changelog_hits = searchChangelogs(contextQuery || command, {
693
+ ...(rosVersion ? { version: rosVersion } : {}),
694
+ limit: 3,
695
+ });
696
+
697
+ return {
698
+ command,
699
+ canonical,
700
+ confidence,
701
+ args,
702
+ warnings,
703
+ pages,
704
+ changelog_hits,
705
+ ...(version_check ? { version_check } : {}),
706
+ };
707
+ }
708
+
503
709
  function runFtsQuery(ftsQuery: string, limit: number): SearchResult[] {
504
710
  if (!ftsQuery) return [];
505
711
  try {
@@ -804,11 +1010,9 @@ function getPageToc(pageId: number, pageUrl: string): SectionTocEntry[] {
804
1010
  }));
805
1011
  }
806
1012
 
807
- /** Lookup property by name, optionally filtered by command path. */
808
- export function lookupProperty(
809
- name: string,
810
- commandPath?: string,
811
- ): Array<{
1013
+ export type PropertyLookupConfidence = "high" | "medium" | "low";
1014
+
1015
+ export type PropertyLookupRow = {
812
1016
  name: string;
813
1017
  type: string | null;
814
1018
  default_val: string | null;
@@ -817,7 +1021,13 @@ export function lookupProperty(
817
1021
  page_title: string;
818
1022
  page_url: string;
819
1023
  page_id: number;
820
- }> {
1024
+ confidence: PropertyLookupConfidence;
1025
+ };
1026
+
1027
+ type PropertyLookupRowWithoutConfidence = Omit<PropertyLookupRow, "confidence">;
1028
+
1029
+ /** Lookup property by name, optionally filtered by command path. */
1030
+ export function lookupProperty(name: string, commandPath?: string): PropertyLookupRow[] {
821
1031
  if (commandPath) {
822
1032
  // Find the page linked to this command path, then search properties there
823
1033
  const linked = db
@@ -828,7 +1038,7 @@ export function lookupProperty(
828
1038
  .get(commandPath) as { page_id: number } | null;
829
1039
 
830
1040
  if (linked) {
831
- return db
1041
+ const scopedRows = db
832
1042
  .prepare(
833
1043
  `SELECT p.name, p.type, p.default_val, p.description, p.section,
834
1044
  pg.title as page_title, pg.url as page_url, pg.id as page_id
@@ -837,12 +1047,15 @@ export function lookupProperty(
837
1047
  WHERE p.page_id = ? AND p.name = ? COLLATE NOCASE
838
1048
  ORDER BY p.sort_order`,
839
1049
  )
840
- .all(linked.page_id, name) as typeof lookupProperty extends (...a: unknown[]) => infer R ? R : never;
1050
+ .all(linked.page_id, name) as PropertyLookupRowWithoutConfidence[];
1051
+ if (scopedRows.length > 0) {
1052
+ return scopedRows.map((row) => ({ ...row, confidence: "high" }));
1053
+ }
841
1054
  }
842
1055
  }
843
1056
 
844
1057
  // Fallback: search by property name across all pages
845
- return db
1058
+ const globalRows = db
846
1059
  .prepare(
847
1060
  `SELECT p.name, p.type, p.default_val, p.description, p.section,
848
1061
  pg.title as page_title, pg.url as page_url, pg.id as page_id
@@ -851,7 +1064,9 @@ export function lookupProperty(
851
1064
  WHERE p.name = ? COLLATE NOCASE
852
1065
  ORDER BY pg.title, p.sort_order`,
853
1066
  )
854
- .all(name) as typeof lookupProperty extends (...a: unknown[]) => infer R ? R : never;
1067
+ .all(name) as PropertyLookupRowWithoutConfidence[];
1068
+ const confidence: PropertyLookupConfidence = commandPath ? "low" : "medium";
1069
+ return globalRows.map((row) => ({ ...row, confidence }));
855
1070
  }
856
1071
 
857
1072
  /** Parse _attrs JSON and extract completion, stripping _attrs from output. */
@@ -1931,6 +2146,29 @@ function filterVersionRange(
1931
2146
  });
1932
2147
  }
1933
2148
 
2149
+ function isMajorMinorVersion(version: string): boolean {
2150
+ return /^\d+\.\d+$/.test(version);
2151
+ }
2152
+
2153
+ function resolveChangelogVersionList(version: string): string[] {
2154
+ const all = getChangelogVersions();
2155
+ if (all.includes(version)) return [version];
2156
+ if (isMajorMinorVersion(version)) {
2157
+ const patchPrefix = `${version}.`;
2158
+ const patchVersions = all.filter((v) => v.startsWith(patchPrefix));
2159
+ if (patchVersions.length > 0) return patchVersions;
2160
+ }
2161
+ return [version];
2162
+ }
2163
+
2164
+ function extractChangelogTerms(query: string, version?: string): string[] {
2165
+ const terms = extractTerms(query);
2166
+ if (!version) return terms;
2167
+
2168
+ const versionParts = new Set(version.match(/\d+/g) ?? []);
2169
+ return terms.filter((term) => !CHANGELOG_GENERIC_TERMS.has(term) && !versionParts.has(term));
2170
+ }
2171
+
1934
2172
  /** Search changelogs with FTS, version range, category, and breaking-only filters. */
1935
2173
  export function searchChangelogs(
1936
2174
  query: string,
@@ -1946,12 +2184,12 @@ export function searchChangelogs(
1946
2184
  } = {},
1947
2185
  ): ChangelogResult[] {
1948
2186
  const limit = options.limit ?? 20;
1949
- const terms = extractTerms(query);
2187
+ const terms = extractChangelogTerms(query, options.version);
1950
2188
 
1951
2189
  // Build version filter
1952
2190
  let versionList: string[] | null = null;
1953
2191
  if (options.version) {
1954
- versionList = [options.version];
2192
+ versionList = resolveChangelogVersionList(options.version);
1955
2193
  } else if (options.fromVersion || options.toVersion) {
1956
2194
  const all = getChangelogVersions();
1957
2195
  versionList = filterVersionRange(all, options.fromVersion, options.toVersion, options.inclusiveFrom);
@@ -15,6 +15,12 @@ function readText(relPath: string): string {
15
15
  return readFileSync(path.join(ROOT, relPath), "utf-8");
16
16
  }
17
17
 
18
+ function mustIndex(haystack: string, needle: string): number {
19
+ const idx = haystack.indexOf(needle);
20
+ expect(idx).toBeGreaterThanOrEqual(0);
21
+ return idx;
22
+ }
23
+
18
24
  // ---------------------------------------------------------------------------
19
25
  // package.json health
20
26
  // ---------------------------------------------------------------------------
@@ -196,11 +202,24 @@ describe("setup.ts", () => {
196
202
  expect(mcp).toContain("refreshDb");
197
203
  });
198
204
 
205
+ test("serializes package DB preparation with a sidecar lock", () => {
206
+ const src = readText("src/setup.ts");
207
+ expect(src).toContain(".lock");
208
+ expect(src).toContain("tryAcquireDownloadLock");
209
+ expect(src).toContain("waitForUsableDb");
210
+ });
211
+
199
212
  test("mcp.ts fails hard on persistent schema mismatch (no silent fall-through)", () => {
200
213
  const src = readText("src/mcp.ts");
201
214
  // Must call process.exit on the unrecoverable schema-mismatch path
202
215
  expect(src).toContain("Still incompatible after re-download");
203
- expect(src).toMatch(/Still incompatible[\s\S]{0,400}process\.exit\(1\)/);
216
+ expect(src).toMatch(/Still incompatible[\s\S]{0,500}(throw new Error|process\.exit\(1\))/);
217
+ });
218
+
219
+ test("mcp.ts aborts startup instead of continuing with an empty DB", () => {
220
+ const src = readText("src/mcp.ts");
221
+ expect(src).toContain("Unable to start rosetta without a usable database");
222
+ expect(src).toContain("Database remained empty after recovery");
204
223
  });
205
224
 
206
225
  test("no DB open uses { readonly: true } (WAL-shm init trap on macOS)", () => {
@@ -299,6 +318,18 @@ describe("Makefile", () => {
299
318
  expect(phonyBlock).toContain("extract-skills");
300
319
  });
301
320
 
321
+ test("has gc-versions target with EXTRA_FLAGS passthrough", () => {
322
+ expect(makefile).toContain("gc-versions:");
323
+ expect(makefile).toContain("src/gc-versions.ts $(EXTRA_FLAGS)");
324
+ });
325
+
326
+ test("gc-versions is in PHONY", () => {
327
+ const phonyStart = makefile.indexOf(".PHONY:");
328
+ const phonyEnd = makefile.indexOf("\n\n", phonyStart);
329
+ const phonyBlock = makefile.slice(phonyStart, phonyEnd);
330
+ expect(phonyBlock).toContain("gc-versions");
331
+ });
332
+
302
333
  test("extract-dude is in PHONY", () => {
303
334
  const phonyStart = makefile.indexOf(".PHONY:");
304
335
  const phonyEnd = makefile.indexOf("\n\n", phonyStart);
@@ -356,6 +387,8 @@ describe("release.yml", () => {
356
387
  const src = readText(".github/workflows/release.yml");
357
388
  expect(src).toContain("html_url:");
358
389
  expect(src).toContain("version:");
390
+ expect(src).toContain("republish_assets:");
391
+ expect(src).not.toContain("inputs.force");
359
392
  });
360
393
 
361
394
  test("tolerates Confluence zip with absolute path root entry", () => {
@@ -394,6 +427,44 @@ describe("release.yml", () => {
394
427
  expect(src).toContain("bun run lint");
395
428
  });
396
429
 
430
+ test("runs early quality gate before downloading HTML export", () => {
431
+ const src = readText(".github/workflows/release.yml");
432
+ const installIdx = mustIndex(src, "bun install");
433
+ const typecheckIdx = mustIndex(src, "Type check (fast-fail)");
434
+ const lintIdx = mustIndex(src, "Lint (fast-fail)");
435
+ const earlyTestIdx = mustIndex(src, "Run tests (fast-fail)");
436
+ const downloadIdx = mustIndex(src, "Download HTML export");
437
+
438
+ expect(installIdx).toBeLessThan(typecheckIdx);
439
+ expect(typecheckIdx).toBeLessThan(lintIdx);
440
+ expect(lintIdx).toBeLessThan(earlyTestIdx);
441
+ expect(earlyTestIdx).toBeLessThan(downloadIdx);
442
+ });
443
+
444
+ test("keeps post-extraction DB-wipe guard after extraction", () => {
445
+ const src = readText(".github/workflows/release.yml");
446
+ const linkIdx = mustIndex(src, "Link commands to pages");
447
+ const guardIdx = mustIndex(src, "Run tests (DB-wipe guard)");
448
+ const buildIdx = mustIndex(src, "Build release artifacts");
449
+
450
+ expect(guardIdx).toBeGreaterThan(linkIdx);
451
+ expect(guardIdx).toBeLessThan(buildIdx);
452
+ expect(src.slice(guardIdx, buildIdx)).toContain("bun test");
453
+ });
454
+
455
+ test("runs schema_node_presence GC after link and before stats/build", () => {
456
+ const src = readText(".github/workflows/release.yml");
457
+ const linkIdx = mustIndex(src, "Link commands to pages");
458
+ const gcIdx = mustIndex(src, "GC schema node presence versions");
459
+ const statsIdx = mustIndex(src, "Collect DB stats");
460
+ const buildIdx = mustIndex(src, "Build release artifacts");
461
+
462
+ expect(gcIdx).toBeGreaterThan(linkIdx);
463
+ expect(gcIdx).toBeLessThan(statsIdx);
464
+ expect(gcIdx).toBeLessThan(buildIdx);
465
+ expect(src.slice(gcIdx, statsIdx)).toContain("make gc-versions EXTRA_FLAGS=--verbose");
466
+ });
467
+
397
468
  test("runs MCP contract tests against the real built DB before eval/release", () => {
398
469
  const src = readText(".github/workflows/release.yml");
399
470
  const contractIdx = src.indexOf("bun test src/mcp-contract.test.ts");
@@ -409,6 +480,50 @@ describe("release.yml", () => {
409
480
  expect(src).toContain("gh release create");
410
481
  });
411
482
 
483
+ test("republish_assets controls immutable npm skips and release clobbering", () => {
484
+ const src = readText(".github/workflows/release.yml");
485
+ expect(src).toContain("republish_assets:");
486
+ expect(src).toContain("Does NOT re-publish npm");
487
+ expect(src).not.toContain("inputs.force");
488
+ expect(src).not.toContain("force=true");
489
+
490
+ const republishBranchIdx = src.search(
491
+ /if \[ "\$\{\{ inputs\.republish_assets \}\}" = "true" \]; then/,
492
+ );
493
+ expect(republishBranchIdx).toBeGreaterThanOrEqual(0);
494
+ const clobberIdx = mustIndex(src, "gh release upload");
495
+ expect(src.slice(clobberIdx, clobberIdx + 120)).toContain("--clobber");
496
+ expect(republishBranchIdx).toBeLessThan(clobberIdx);
497
+
498
+ expect(src).toContain("if: inputs.republish_assets != true");
499
+ expect(src).toContain("if: inputs.republish_assets == true");
500
+ expect(src).toMatch(
501
+ /Publish to npm[\s\S]{0,120}if: inputs\.republish_assets != true/,
502
+ );
503
+ expect(src).toMatch(
504
+ /bunx-smoke:[\s\S]{0,120}if: inputs\.republish_assets != true/,
505
+ );
506
+ expect(src).toMatch(
507
+ /bump-version:[\s\S]{0,120}if: inputs\.republish_assets != true/,
508
+ );
509
+ });
510
+
511
+ test("bump-version fetches and rebases before retrying push", () => {
512
+ const src = readText(".github/workflows/release.yml");
513
+ const bumpIdx = mustIndex(src, "bump-version:");
514
+ const bumpBlock = src.slice(bumpIdx);
515
+ const loopIdx = mustIndex(bumpBlock, "for attempt in 1 2 3; do");
516
+ const fetchIdx = mustIndex(bumpBlock, "git fetch origin main");
517
+ const rebaseIdx = mustIndex(bumpBlock, "git rebase origin/main");
518
+ const pushIdx = mustIndex(bumpBlock, "git push origin HEAD:main");
519
+ const retryIdx = mustIndex(bumpBlock, "Push rejected on attempt");
520
+
521
+ expect(loopIdx).toBeLessThan(fetchIdx);
522
+ expect(fetchIdx).toBeLessThan(rebaseIdx);
523
+ expect(rebaseIdx).toBeLessThan(pushIdx);
524
+ expect(pushIdx).toBeLessThan(retryIdx);
525
+ });
526
+
412
527
  test("publishes to npm", () => {
413
528
  const src = readText(".github/workflows/release.yml");
414
529
  expect(src).toContain("npm publish");
@@ -463,6 +578,11 @@ describe("CLI flags", () => {
463
578
  expect(src).toContain('if (args[0] === "browse")');
464
579
  expect(src).toContain("await ensureDbReady");
465
580
  });
581
+
582
+ test("top-level CLI bootstrap has a catch that exits non-zero on startup errors", () => {
583
+ expect(src).toContain("})().catch((err) => {");
584
+ expect(src).toContain("process.exit(1)");
585
+ });
466
586
  });
467
587
 
468
588
  // ---------------------------------------------------------------------------
package/src/setup.test.ts CHANGED
@@ -20,9 +20,26 @@ afterAll(() => {
20
20
  } catch {}
21
21
  });
22
22
 
23
- const { probeDb, dbDownloadUrls } = await import("./setup.ts");
23
+ const { dbDownloadUrls, probeDb, releaseDownloadLock, tryAcquireDownloadLock, waitForUsableDb } = await import("./setup.ts");
24
24
  const { SCHEMA_VERSION } = await import("./paths.ts");
25
25
 
26
+ function writeUsableDb(dbFile: string, releaseTag = "v0.0.0-test"): void {
27
+ const db = new sqlite(dbFile);
28
+ db.run(`PRAGMA user_version = ${SCHEMA_VERSION};`);
29
+ db.run("CREATE TABLE pages (id INTEGER PRIMARY KEY, title TEXT);");
30
+ db.run("CREATE TABLE commands (id INTEGER PRIMARY KEY, path TEXT);");
31
+ db.run("CREATE TABLE db_meta (key TEXT PRIMARY KEY, value TEXT NOT NULL);");
32
+
33
+ const insertPage = db.prepare("INSERT INTO pages (title) VALUES (?)");
34
+ for (let i = 0; i < 100; i++) insertPage.run(`page-${i}`);
35
+
36
+ const insertCmd = db.prepare("INSERT INTO commands (path) VALUES (?)");
37
+ for (let i = 0; i < 1000; i++) insertCmd.run(`/cmd/${i}`);
38
+
39
+ db.run("INSERT INTO db_meta (key, value) VALUES ('release_tag', ?);", [releaseTag]);
40
+ db.close();
41
+ }
42
+
26
43
  // ---------------------------------------------------------------------------
27
44
  // dbDownloadUrls — version pinning + latest fallback
28
45
  // ---------------------------------------------------------------------------
@@ -53,6 +70,53 @@ describe("dbDownloadUrls", () => {
53
70
  });
54
71
  });
55
72
 
73
+ // ---------------------------------------------------------------------------
74
+ // download lock helpers — serialize package-mode DB preparation
75
+ // ---------------------------------------------------------------------------
76
+
77
+ describe("download lock helpers", () => {
78
+ test("lock is exclusive until released", () => {
79
+ const dbFile = path.join(tmp, "locked.db");
80
+ const first = tryAcquireDownloadLock(dbFile);
81
+ expect(first).not.toBeNull();
82
+ const second = tryAcquireDownloadLock(dbFile);
83
+ expect(second).toBeNull();
84
+
85
+ releaseDownloadLock(first);
86
+
87
+ const third = tryAcquireDownloadLock(dbFile);
88
+ expect(third).not.toBeNull();
89
+ releaseDownloadLock(third);
90
+ });
91
+
92
+ test("waitForUsableDb returns false when lock goes away without a healthy DB", async () => {
93
+ const dbFile = path.join(tmp, "wait-false.db");
94
+ const lock = tryAcquireDownloadLock(dbFile);
95
+ expect(lock).not.toBeNull();
96
+
97
+ const waiter = waitForUsableDb(dbFile, () => {}, 2_000);
98
+ setTimeout(() => releaseDownloadLock(lock), 100);
99
+
100
+ expect(await waiter).toBe(false);
101
+ });
102
+
103
+ test("waitForUsableDb returns true when another process finishes the DB", async () => {
104
+ const dbFile = path.join(tmp, "wait-true.db");
105
+ const lock = tryAcquireDownloadLock(dbFile);
106
+ expect(lock).not.toBeNull();
107
+
108
+ writeUsableDb(dbFile, "v0.0.0-wait");
109
+ const waiter = waitForUsableDb(dbFile, () => {}, 2_000);
110
+ setTimeout(() => releaseDownloadLock(lock), 100);
111
+
112
+ expect(await waiter).toBe(true);
113
+ const probe = probeDb(dbFile);
114
+ expect(probe?.releaseTag).toBe("v0.0.0-wait");
115
+ expect(probe?.pages).toBe(100);
116
+ expect(probe?.commands).toBe(1000);
117
+ });
118
+ });
119
+
56
120
  // ---------------------------------------------------------------------------
57
121
  // probeDb — schema / pages / commands / release_tag
58
122
  // ---------------------------------------------------------------------------