@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/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
|
@@ -0,0 +1,201 @@
|
|
|
1
|
+
import { Database } from "bun:sqlite";
|
|
2
|
+
import { describe, expect, test } from "bun:test";
|
|
3
|
+
import {
|
|
4
|
+
compareRouterOsVersions,
|
|
5
|
+
computeActiveChannelHeads,
|
|
6
|
+
gcSchemaNodePresence,
|
|
7
|
+
} from "./gc-versions.ts";
|
|
8
|
+
|
|
9
|
+
function createTestDb(): Database {
|
|
10
|
+
const db = new Database(":memory:");
|
|
11
|
+
db.run("PRAGMA foreign_keys=ON;");
|
|
12
|
+
|
|
13
|
+
db.run(`CREATE TABLE ros_versions (
|
|
14
|
+
version TEXT NOT NULL,
|
|
15
|
+
arch TEXT NOT NULL DEFAULT 'x86',
|
|
16
|
+
channel TEXT,
|
|
17
|
+
extra_packages INTEGER NOT NULL DEFAULT 0,
|
|
18
|
+
extracted_at TEXT NOT NULL,
|
|
19
|
+
PRIMARY KEY (version, arch)
|
|
20
|
+
);`);
|
|
21
|
+
|
|
22
|
+
db.run(`CREATE TABLE schema_nodes (
|
|
23
|
+
id INTEGER PRIMARY KEY,
|
|
24
|
+
path TEXT NOT NULL,
|
|
25
|
+
name TEXT NOT NULL,
|
|
26
|
+
type TEXT NOT NULL,
|
|
27
|
+
UNIQUE(path, type)
|
|
28
|
+
);`);
|
|
29
|
+
|
|
30
|
+
db.run(`CREATE TABLE schema_node_presence (
|
|
31
|
+
node_id INTEGER NOT NULL REFERENCES schema_nodes(id),
|
|
32
|
+
version TEXT NOT NULL,
|
|
33
|
+
PRIMARY KEY (node_id, version)
|
|
34
|
+
);`);
|
|
35
|
+
|
|
36
|
+
db.run(`CREATE TABLE command_versions (
|
|
37
|
+
command_path TEXT NOT NULL,
|
|
38
|
+
ros_version TEXT NOT NULL,
|
|
39
|
+
PRIMARY KEY (command_path, ros_version)
|
|
40
|
+
);`);
|
|
41
|
+
|
|
42
|
+
for (let i = 1; i <= 3; i++) {
|
|
43
|
+
db.run("INSERT INTO schema_nodes (id, path, name, type) VALUES (?, ?, ?, 'cmd')", [
|
|
44
|
+
i,
|
|
45
|
+
`/node-${i}`,
|
|
46
|
+
`node-${i}`,
|
|
47
|
+
]);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
return db;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function insertVersion(db: Database, version: string, channel: string | null, arch = "x86"): void {
|
|
54
|
+
db.run(
|
|
55
|
+
`INSERT INTO ros_versions (version, arch, channel, extra_packages, extracted_at)
|
|
56
|
+
VALUES (?, ?, ?, 1, '2026-04-26T00:00:00Z')`,
|
|
57
|
+
[version, arch, channel],
|
|
58
|
+
);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function insertPresence(db: Database, version: string, nodeIds = [1, 2]): void {
|
|
62
|
+
for (const nodeId of nodeIds) {
|
|
63
|
+
db.run("INSERT INTO schema_node_presence (node_id, version) VALUES (?, ?)", [nodeId, version]);
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function insertCommandVersion(db: Database, version: string): void {
|
|
68
|
+
db.run("INSERT INTO command_versions (command_path, ros_version) VALUES (?, ?)", ["/ip/address/add", version]);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function count(db: Database, sql: string): number {
|
|
72
|
+
return (db.prepare(sql).get() as { c: number }).c;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
describe("RouterOS semantic version sorting", () => {
|
|
76
|
+
test("sorts numeric releases and prereleases semantically", () => {
|
|
77
|
+
const versions = ["7.23", "7.9", "7.23rc1", "7.10", "7.23beta2", "7.23beta10", "7.22.1"];
|
|
78
|
+
|
|
79
|
+
expect(versions.sort(compareRouterOsVersions)).toEqual([
|
|
80
|
+
"7.9",
|
|
81
|
+
"7.10",
|
|
82
|
+
"7.22.1",
|
|
83
|
+
"7.23beta2",
|
|
84
|
+
"7.23beta10",
|
|
85
|
+
"7.23rc1",
|
|
86
|
+
"7.23",
|
|
87
|
+
]);
|
|
88
|
+
});
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
describe("computeActiveChannelHeads", () => {
|
|
92
|
+
test("keeps the newest version per recognized active channel", () => {
|
|
93
|
+
const heads = computeActiveChannelHeads([
|
|
94
|
+
{ version: "7.9", channel: "stable" },
|
|
95
|
+
{ version: "7.10", channel: "stable" },
|
|
96
|
+
{ version: "7.22", channel: "long-term" },
|
|
97
|
+
{ version: "7.23beta2", channel: "testing" },
|
|
98
|
+
{ version: "7.23rc1", channel: "testing" },
|
|
99
|
+
{ version: "7.24beta1", channel: "development" },
|
|
100
|
+
{ version: "9.99", channel: "legacy" },
|
|
101
|
+
{ version: "10.0", channel: null },
|
|
102
|
+
]);
|
|
103
|
+
|
|
104
|
+
expect(heads).toEqual({
|
|
105
|
+
stable: "7.10",
|
|
106
|
+
"long-term": "7.22",
|
|
107
|
+
testing: "7.23rc1",
|
|
108
|
+
development: "7.24beta1",
|
|
109
|
+
});
|
|
110
|
+
});
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
describe("gcSchemaNodePresence", () => {
|
|
114
|
+
test("dry-run reports candidates without mutating schema_node_presence", () => {
|
|
115
|
+
const db = createTestDb();
|
|
116
|
+
try {
|
|
117
|
+
insertVersion(db, "7.9", "stable");
|
|
118
|
+
insertVersion(db, "7.10", "stable");
|
|
119
|
+
insertPresence(db, "7.9");
|
|
120
|
+
insertPresence(db, "7.10");
|
|
121
|
+
|
|
122
|
+
const stats = gcSchemaNodePresence(db, { dryRun: true });
|
|
123
|
+
|
|
124
|
+
expect(stats.dry_run).toBe(true);
|
|
125
|
+
expect(stats.before_count).toBe(4);
|
|
126
|
+
expect(stats.after_count).toBe(4);
|
|
127
|
+
expect(stats.deleted_rows).toBe(0);
|
|
128
|
+
expect(stats.would_delete_rows).toBe(2);
|
|
129
|
+
expect(stats.kept_versions).toEqual(["7.10"]);
|
|
130
|
+
expect(stats.pruned_versions).toEqual(["7.9"]);
|
|
131
|
+
expect(count(db, "SELECT COUNT(*) AS c FROM schema_node_presence")).toBe(4);
|
|
132
|
+
} finally {
|
|
133
|
+
db.close();
|
|
134
|
+
}
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
test("de-duplicates ros_versions by version when arches differ", () => {
|
|
138
|
+
const db = createTestDb();
|
|
139
|
+
try {
|
|
140
|
+
insertVersion(db, "7.21", "stable", "x86");
|
|
141
|
+
insertVersion(db, "7.22", "stable", "x86");
|
|
142
|
+
insertVersion(db, "7.22", "stable", "arm64");
|
|
143
|
+
insertPresence(db, "7.21");
|
|
144
|
+
insertPresence(db, "7.22");
|
|
145
|
+
|
|
146
|
+
const stats = gcSchemaNodePresence(db);
|
|
147
|
+
|
|
148
|
+
expect(stats.kept_versions).toEqual(["7.22"]);
|
|
149
|
+
expect(stats.pruned_versions).toEqual(["7.21"]);
|
|
150
|
+
expect(stats.deleted_rows).toBe(2);
|
|
151
|
+
expect(count(db, "SELECT COUNT(*) AS c FROM schema_node_presence WHERE version = '7.22'")).toBe(2);
|
|
152
|
+
expect(count(db, "SELECT COUNT(*) AS c FROM schema_node_presence WHERE version = '7.21'")).toBe(0);
|
|
153
|
+
} finally {
|
|
154
|
+
db.close();
|
|
155
|
+
}
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
test("skips safely when no recognized channel heads can be computed", () => {
|
|
159
|
+
const db = createTestDb();
|
|
160
|
+
try {
|
|
161
|
+
insertVersion(db, "7.9", "legacy");
|
|
162
|
+
insertVersion(db, "7.10", null);
|
|
163
|
+
insertPresence(db, "7.9");
|
|
164
|
+
insertPresence(db, "7.10");
|
|
165
|
+
|
|
166
|
+
const stats = gcSchemaNodePresence(db);
|
|
167
|
+
|
|
168
|
+
expect(stats.skipped).toBe(true);
|
|
169
|
+
expect(stats.deleted_rows).toBe(0);
|
|
170
|
+
expect(stats.kept_versions).toEqual([]);
|
|
171
|
+
expect(stats.note).toContain("skipped");
|
|
172
|
+
expect(count(db, "SELECT COUNT(*) AS c FROM schema_node_presence")).toBe(4);
|
|
173
|
+
} finally {
|
|
174
|
+
db.close();
|
|
175
|
+
}
|
|
176
|
+
});
|
|
177
|
+
|
|
178
|
+
test("prunes only schema_node_presence and leaves command_versions untouched", () => {
|
|
179
|
+
const db = createTestDb();
|
|
180
|
+
try {
|
|
181
|
+
insertVersion(db, "7.20", "stable");
|
|
182
|
+
insertVersion(db, "7.22", "stable");
|
|
183
|
+
insertVersion(db, "7.23beta1", "development");
|
|
184
|
+
for (const version of ["7.20", "7.22", "7.23beta1"]) {
|
|
185
|
+
insertPresence(db, version);
|
|
186
|
+
insertCommandVersion(db, version);
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
const stats = gcSchemaNodePresence(db);
|
|
190
|
+
|
|
191
|
+
expect(stats.kept_versions).toEqual(["7.22", "7.23beta1"]);
|
|
192
|
+
expect(stats.pruned_versions).toEqual(["7.20"]);
|
|
193
|
+
expect(stats.deleted_rows).toBe(2);
|
|
194
|
+
expect(count(db, "SELECT COUNT(*) AS c FROM schema_node_presence")).toBe(4);
|
|
195
|
+
expect(count(db, "SELECT COUNT(*) AS c FROM command_versions")).toBe(3);
|
|
196
|
+
expect(count(db, "SELECT COUNT(*) AS c FROM command_versions WHERE ros_version = '7.20'")).toBe(1);
|
|
197
|
+
} finally {
|
|
198
|
+
db.close();
|
|
199
|
+
}
|
|
200
|
+
});
|
|
201
|
+
});
|
|
@@ -0,0 +1,230 @@
|
|
|
1
|
+
#!/usr/bin/env bun
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* gc-versions.ts — prune schema_node_presence to active RouterOS channel heads.
|
|
5
|
+
*
|
|
6
|
+
* The GC is intentionally narrow: it deletes only schema_node_presence rows.
|
|
7
|
+
* command_versions, changelogs, schema_nodes, commands, and ros_versions keep
|
|
8
|
+
* their full extracted history.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import type { SQLQueryBindings } from "bun:sqlite";
|
|
12
|
+
import { Database } from "bun:sqlite";
|
|
13
|
+
import { resolveDbPath } from "./paths.ts";
|
|
14
|
+
|
|
15
|
+
export const ACTIVE_CHANNELS = ["stable", "long-term", "testing", "development"] as const;
|
|
16
|
+
export type ActiveChannel = (typeof ACTIVE_CHANNELS)[number];
|
|
17
|
+
|
|
18
|
+
export interface VersionChannelRow {
|
|
19
|
+
version: string;
|
|
20
|
+
channel: string | null;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export interface SchemaPresenceGcOptions {
|
|
24
|
+
dryRun?: boolean;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export interface SchemaPresenceGcStats {
|
|
28
|
+
before_count: number;
|
|
29
|
+
after_count: number;
|
|
30
|
+
deleted_rows: number;
|
|
31
|
+
would_delete_rows: number;
|
|
32
|
+
kept_versions: string[];
|
|
33
|
+
pruned_versions: string[];
|
|
34
|
+
channel_heads: Partial<Record<ActiveChannel, string>>;
|
|
35
|
+
dry_run: boolean;
|
|
36
|
+
skipped: boolean;
|
|
37
|
+
note?: string;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
interface ParsedVersion {
|
|
41
|
+
valid: boolean;
|
|
42
|
+
major: number;
|
|
43
|
+
minor: number;
|
|
44
|
+
patch: number;
|
|
45
|
+
phase: number;
|
|
46
|
+
phaseNumber: number;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function normalizeChannel(channel: string | null): ActiveChannel | null {
|
|
50
|
+
if (!channel) return null;
|
|
51
|
+
const normalized = channel.trim().toLowerCase();
|
|
52
|
+
return ACTIVE_CHANNELS.includes(normalized as ActiveChannel) ? (normalized as ActiveChannel) : null;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function parseRouterOsVersion(version: string): ParsedVersion {
|
|
56
|
+
const match = version.match(/^(\d+)\.(\d+)(?:\.(\d+))?(?:(beta|rc)(\d+))?$/);
|
|
57
|
+
if (!match) {
|
|
58
|
+
return { valid: false, major: 0, minor: 0, patch: 0, phase: -3, phaseNumber: 0 };
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
const phase = match[4] === "beta" ? -2 : match[4] === "rc" ? -1 : 0;
|
|
62
|
+
return {
|
|
63
|
+
valid: true,
|
|
64
|
+
major: Number(match[1]),
|
|
65
|
+
minor: Number(match[2]),
|
|
66
|
+
patch: Number(match[3] ?? 0),
|
|
67
|
+
phase,
|
|
68
|
+
phaseNumber: Number(match[5] ?? 0),
|
|
69
|
+
};
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/** Compare RouterOS versions semantically: 7.9 < 7.10 < 7.23beta2 < 7.23rc1 < 7.23. */
|
|
73
|
+
export function compareRouterOsVersions(a: string, b: string): number {
|
|
74
|
+
const av = parseRouterOsVersion(a);
|
|
75
|
+
const bv = parseRouterOsVersion(b);
|
|
76
|
+
|
|
77
|
+
if (!av.valid || !bv.valid) {
|
|
78
|
+
if (av.valid !== bv.valid) return av.valid ? 1 : -1;
|
|
79
|
+
return a.localeCompare(b, undefined, { numeric: true });
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
for (const key of ["major", "minor", "patch", "phase", "phaseNumber"] as const) {
|
|
83
|
+
const diff = av[key] - bv[key];
|
|
84
|
+
if (diff !== 0) return diff;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
return 0;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
export function computeActiveChannelHeads(rows: VersionChannelRow[]): Partial<Record<ActiveChannel, string>> {
|
|
91
|
+
const heads: Partial<Record<ActiveChannel, string>> = {};
|
|
92
|
+
|
|
93
|
+
for (const row of rows) {
|
|
94
|
+
const channel = normalizeChannel(row.channel);
|
|
95
|
+
if (!channel) continue;
|
|
96
|
+
|
|
97
|
+
const current = heads[channel];
|
|
98
|
+
if (!current || compareRouterOsVersions(row.version, current) > 0) {
|
|
99
|
+
heads[channel] = row.version;
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
return heads;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
function countRows(db: Database, sql: string, params: SQLQueryBindings[] = []): number {
|
|
107
|
+
return (db.prepare(sql).get(...params) as { c: number }).c;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
function distinctVersions(db: Database, sql: string): string[] {
|
|
111
|
+
const rows = db.prepare(sql).all() as Array<{ version: string }>;
|
|
112
|
+
return rows.map((row) => row.version).sort(compareRouterOsVersions);
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
function countPresenceVersions(db: Database, versions: string[]): number {
|
|
116
|
+
if (versions.length === 0) return 0;
|
|
117
|
+
const placeholders = versions.map(() => "?").join(", ");
|
|
118
|
+
return countRows(db, `SELECT COUNT(*) AS c FROM schema_node_presence WHERE version IN (${placeholders})`, versions);
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
export function gcSchemaNodePresence(
|
|
122
|
+
db: Database,
|
|
123
|
+
options: SchemaPresenceGcOptions = {},
|
|
124
|
+
): SchemaPresenceGcStats {
|
|
125
|
+
const dryRun = options.dryRun ?? false;
|
|
126
|
+
const beforeCount = countRows(db, "SELECT COUNT(*) AS c FROM schema_node_presence");
|
|
127
|
+
const versionRows = db.prepare("SELECT DISTINCT version, channel FROM ros_versions").all() as VersionChannelRow[];
|
|
128
|
+
const channelHeads = computeActiveChannelHeads(versionRows);
|
|
129
|
+
const keptVersions = Array.from(new Set(Object.values(channelHeads))).sort(compareRouterOsVersions);
|
|
130
|
+
|
|
131
|
+
if (keptVersions.length === 0) {
|
|
132
|
+
return {
|
|
133
|
+
before_count: beforeCount,
|
|
134
|
+
after_count: beforeCount,
|
|
135
|
+
deleted_rows: 0,
|
|
136
|
+
would_delete_rows: 0,
|
|
137
|
+
kept_versions: [],
|
|
138
|
+
pruned_versions: [],
|
|
139
|
+
channel_heads: channelHeads,
|
|
140
|
+
dry_run: dryRun,
|
|
141
|
+
skipped: true,
|
|
142
|
+
note: "No recognized ros_versions channel heads found; schema_node_presence GC skipped.",
|
|
143
|
+
};
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
const keptSet = new Set(keptVersions);
|
|
147
|
+
const presenceVersions = distinctVersions(db, "SELECT DISTINCT version FROM schema_node_presence");
|
|
148
|
+
const prunedVersions = presenceVersions.filter((version) => !keptSet.has(version));
|
|
149
|
+
const wouldDeleteRows = countPresenceVersions(db, prunedVersions);
|
|
150
|
+
|
|
151
|
+
if (!dryRun && prunedVersions.length > 0) {
|
|
152
|
+
const placeholders = prunedVersions.map(() => "?").join(", ");
|
|
153
|
+
db.run(`DELETE FROM schema_node_presence WHERE version IN (${placeholders})`, prunedVersions);
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
const afterCount = countRows(db, "SELECT COUNT(*) AS c FROM schema_node_presence");
|
|
157
|
+
const deletedRows = dryRun ? 0 : beforeCount - afterCount;
|
|
158
|
+
|
|
159
|
+
return {
|
|
160
|
+
before_count: beforeCount,
|
|
161
|
+
after_count: afterCount,
|
|
162
|
+
deleted_rows: deletedRows,
|
|
163
|
+
would_delete_rows: wouldDeleteRows,
|
|
164
|
+
kept_versions: keptVersions,
|
|
165
|
+
pruned_versions: prunedVersions,
|
|
166
|
+
channel_heads: channelHeads,
|
|
167
|
+
dry_run: dryRun,
|
|
168
|
+
skipped: false,
|
|
169
|
+
note: dryRun ? "Dry run only; schema_node_presence was not modified." : undefined,
|
|
170
|
+
};
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
function getArgValue(name: string): string | undefined {
|
|
174
|
+
const eq = process.argv.find((arg) => arg.startsWith(`${name}=`));
|
|
175
|
+
if (eq) return eq.slice(name.length + 1);
|
|
176
|
+
|
|
177
|
+
const idx = process.argv.indexOf(name);
|
|
178
|
+
return idx !== -1 && idx + 1 < process.argv.length ? process.argv[idx + 1] : undefined;
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
function usage(): string {
|
|
182
|
+
return [
|
|
183
|
+
"Usage: bun run src/gc-versions.ts [--dry-run] [--verbose] [--db <path>]",
|
|
184
|
+
"",
|
|
185
|
+
"Prunes only schema_node_presence to active RouterOS channel heads.",
|
|
186
|
+
"Conservative fallback: if no stable/long-term/testing/development heads exist, nothing is deleted.",
|
|
187
|
+
].join("\n");
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
function printStats(stats: SchemaPresenceGcStats, verbose: boolean): void {
|
|
191
|
+
const action = stats.dry_run ? "would delete" : "deleted";
|
|
192
|
+
console.log(
|
|
193
|
+
`schema_node_presence GC: before=${stats.before_count} after=${stats.after_count} ${action}=${stats.dry_run ? stats.would_delete_rows : stats.deleted_rows}`,
|
|
194
|
+
);
|
|
195
|
+
console.log(`kept_versions=${stats.kept_versions.length ? stats.kept_versions.join(",") : "(none)"}`);
|
|
196
|
+
|
|
197
|
+
if (stats.note) console.log(`note=${stats.note}`);
|
|
198
|
+
|
|
199
|
+
if (verbose) {
|
|
200
|
+
console.log(`pruned_versions=${stats.pruned_versions.length ? stats.pruned_versions.join(",") : "(none)"}`);
|
|
201
|
+
console.log(`channel_heads=${JSON.stringify(stats.channel_heads)}`);
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
async function main(): Promise<void> {
|
|
206
|
+
if (process.argv.includes("--help") || process.argv.includes("-h")) {
|
|
207
|
+
console.log(usage());
|
|
208
|
+
return;
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
const dryRun = process.argv.includes("--dry-run");
|
|
212
|
+
const verbose = process.argv.includes("--verbose");
|
|
213
|
+
const dbPath = getArgValue("--db") ?? resolveDbPath(import.meta.dirname);
|
|
214
|
+
const db = new Database(dbPath);
|
|
215
|
+
|
|
216
|
+
try {
|
|
217
|
+
db.run("PRAGMA foreign_keys=ON;");
|
|
218
|
+
const stats = gcSchemaNodePresence(db, { dryRun });
|
|
219
|
+
printStats(stats, verbose);
|
|
220
|
+
} finally {
|
|
221
|
+
db.close();
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
if (import.meta.main) {
|
|
226
|
+
main().catch((error: unknown) => {
|
|
227
|
+
console.error(error instanceof Error ? error.message : String(error));
|
|
228
|
+
process.exit(1);
|
|
229
|
+
});
|
|
230
|
+
}
|
package/src/mcp-contract.test.ts
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
* mcp-contract.test.ts — MCP tool surface contract tests (Phase 2).
|
|
3
3
|
*
|
|
4
4
|
* Guards against silent breaking changes to the MCP tool registry:
|
|
5
|
-
* - Block A: Frozen
|
|
5
|
+
* - Block A: Frozen 14-tool list + workflow-arrow (→) convention in descriptions
|
|
6
6
|
* - Block B: Token-budget guardrails for 10 canonical queries (rough chars/4)
|
|
7
7
|
* - Block C: Shape snapshots — fingerprint the *contract* (keys, counts,
|
|
8
8
|
* classifier output), NOT the corpus. Deliberately omits page IDs
|
|
@@ -54,6 +54,7 @@ describe("Frozen tool registry", () => {
|
|
|
54
54
|
"routeros_search",
|
|
55
55
|
"routeros_get_page",
|
|
56
56
|
"routeros_lookup_property",
|
|
57
|
+
"routeros_explain_command",
|
|
57
58
|
"routeros_command_tree",
|
|
58
59
|
"routeros_stats",
|
|
59
60
|
"routeros_search_changelogs",
|
|
@@ -66,23 +67,19 @@ describe("Frozen tool registry", () => {
|
|
|
66
67
|
"routeros_current_versions",
|
|
67
68
|
];
|
|
68
69
|
|
|
69
|
-
test("exactly
|
|
70
|
+
test("exactly 14 tools registered", () => {
|
|
70
71
|
const mcpSrc = readFileSync(path.join(ROOT, "src/mcp.ts"), "utf-8");
|
|
71
72
|
// Extract tool names from server.registerTool("<name>", patterns
|
|
72
73
|
const toolMatches = mcpSrc.matchAll(/server\.registerTool\(\s*["']([^"']+)["']/g);
|
|
73
74
|
const foundTools = Array.from(toolMatches, (m) => m[1]);
|
|
74
75
|
|
|
75
|
-
expect(foundTools.length).toBe(
|
|
76
|
+
expect(foundTools.length).toBe(14);
|
|
76
77
|
expect(foundTools.sort()).toEqual(EXPECTED_TOOLS.sort());
|
|
77
78
|
});
|
|
78
79
|
|
|
79
80
|
test("all tools have workflow arrow (→) in description", () => {
|
|
80
81
|
const mcpSrc = readFileSync(path.join(ROOT, "src/mcp.ts"), "utf-8");
|
|
81
82
|
|
|
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
83
|
// Extract each complete registerTool block (tool name to closing paren before next registerTool)
|
|
87
84
|
// Split on registerTool calls, then extract name + description from each block
|
|
88
85
|
const toolBlocks = mcpSrc.split(/(?=server\.registerTool\()/);
|
|
@@ -105,14 +102,14 @@ describe("Frozen tool registry", () => {
|
|
|
105
102
|
if (!altMatch) continue;
|
|
106
103
|
|
|
107
104
|
const description = altMatch[1];
|
|
108
|
-
if (!description.includes("→")
|
|
105
|
+
if (!description.includes("→")) {
|
|
109
106
|
toolsWithoutArrow.push(toolName);
|
|
110
107
|
}
|
|
111
108
|
continue;
|
|
112
109
|
}
|
|
113
110
|
|
|
114
111
|
const description = descMatch[1];
|
|
115
|
-
if (!description.includes("→")
|
|
112
|
+
if (!description.includes("→")) {
|
|
116
113
|
toolsWithoutArrow.push(toolName);
|
|
117
114
|
}
|
|
118
115
|
}
|
|
@@ -127,7 +124,7 @@ describe("Frozen tool registry", () => {
|
|
|
127
124
|
const totalFound = Array.from(
|
|
128
125
|
mcpSrc.matchAll(/server\.registerTool\(\s*["']([^"']+)["']/g),
|
|
129
126
|
).length;
|
|
130
|
-
expect(totalFound).toBe(
|
|
127
|
+
expect(totalFound).toBe(14);
|
|
131
128
|
});
|
|
132
129
|
});
|
|
133
130
|
|
package/src/mcp-http.test.ts
CHANGED
|
@@ -322,7 +322,7 @@ describe("HTTP transport: session lifecycle", () => {
|
|
|
322
322
|
expect(serverInfo.name).toBe("rosetta");
|
|
323
323
|
});
|
|
324
324
|
|
|
325
|
-
test("tools/list returns all
|
|
325
|
+
test("tools/list returns all 14 tools after initialization", async () => {
|
|
326
326
|
const { sessionId } = await mcpInitialize(server.url);
|
|
327
327
|
|
|
328
328
|
// Send initialized notification first (required by protocol)
|
|
@@ -333,12 +333,13 @@ describe("HTTP transport: session lifecycle", () => {
|
|
|
333
333
|
|
|
334
334
|
const result = (messages[0] as Record<string, unknown>).result as Record<string, unknown>;
|
|
335
335
|
const tools = result.tools as Array<{ name: string }>;
|
|
336
|
-
expect(tools.length).toBe(
|
|
336
|
+
expect(tools.length).toBe(14);
|
|
337
337
|
|
|
338
338
|
const toolNames = tools.map((t) => t.name).sort();
|
|
339
339
|
expect(toolNames).toContain("routeros_search");
|
|
340
340
|
expect(toolNames).toContain("routeros_get_page");
|
|
341
341
|
expect(toolNames).toContain("routeros_lookup_property");
|
|
342
|
+
expect(toolNames).toContain("routeros_explain_command");
|
|
342
343
|
expect(toolNames).toContain("routeros_command_tree");
|
|
343
344
|
expect(toolNames).toContain("routeros_search_changelogs");
|
|
344
345
|
expect(toolNames).toContain("routeros_command_version_check");
|
|
@@ -555,8 +556,8 @@ describe("HTTP transport: multi-session", () => {
|
|
|
555
556
|
const tools1 = ((msgs1[0] as Record<string, unknown>).result as Record<string, unknown>).tools as unknown[];
|
|
556
557
|
const tools2 = ((msgs2[0] as Record<string, unknown>).result as Record<string, unknown>).tools as unknown[];
|
|
557
558
|
|
|
558
|
-
expect(tools1.length).toBe(
|
|
559
|
-
expect(tools2.length).toBe(
|
|
559
|
+
expect(tools1.length).toBe(14);
|
|
560
|
+
expect(tools2.length).toBe(14);
|
|
560
561
|
});
|
|
561
562
|
|
|
562
563
|
test("deleting one session does not affect another", async () => {
|
|
@@ -578,7 +579,7 @@ describe("HTTP transport: multi-session", () => {
|
|
|
578
579
|
// Client2 still works
|
|
579
580
|
const msgs = await mcpRequest(server.url, client2.sessionId, "tools/list", 2);
|
|
580
581
|
const tools = ((msgs[0] as Record<string, unknown>).result as Record<string, unknown>).tools as unknown[];
|
|
581
|
-
expect(tools.length).toBe(
|
|
582
|
+
expect(tools.length).toBe(14);
|
|
582
583
|
|
|
583
584
|
// Client1 is gone
|
|
584
585
|
const resp = await fetch(server.url, {
|