@tikoci/rosetta 0.8.8 → 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/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
|
-
|
|
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
|
-
|
|
808
|
-
|
|
809
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
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 =
|
|
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 =
|
|
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);
|
package/src/release.test.ts
CHANGED
|
@@ -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,
|
|
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,
|
|
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
|
// ---------------------------------------------------------------------------
|