diff --git a/packages/loopover-mcp/README.md b/packages/loopover-mcp/README.md index ae46875300..0cb88067e2 100644 --- a/packages/loopover-mcp/README.md +++ b/packages/loopover-mcp/README.md @@ -78,7 +78,7 @@ loopover-mcp --stdio `loopover-mcp version` (aliases `--version` and `-v`) prints the installed package version, the targeted API version, and the Node.js runtime version: ```text -@loopover/mcp/0.5.0 (api 0.1.0, node v22.12.0) +@loopover/mcp/3.0.0 (api 0.1.0, node v22.12.0) ``` Add `--json` for machine-readable output: @@ -86,7 +86,7 @@ Add `--json` for machine-readable output: ```json { "name": "@loopover/mcp", - "version": "0.5.0", + "version": "3.0.0", "apiVersion": "0.1.0", "node": "v22.12.0" } diff --git a/scripts/check-ui-mcp-version-copy.d.mts b/scripts/check-ui-mcp-version-copy.d.mts new file mode 100644 index 0000000000..f628519d9a --- /dev/null +++ b/scripts/check-ui-mcp-version-copy.d.mts @@ -0,0 +1,32 @@ +export declare const SOURCE_LATEST_PATH: string; +export declare const SCAN_TARGETS: string[]; + +export type StaleVersionMatchers = { + floorVersion: string; + minorLabel: string; + visibleVersion: RegExp; + versionRange: RegExp; + floor: RegExp; +}; + +export declare function collectVersionCopyFailures(input: { + label: string; + text: string; + matchers: StaleVersionMatchers; +}): string[]; + +export declare function collectSourceFiles(path: string): string[]; + +export declare function isTextSource(path: string): boolean; + +export declare function isMinimumSupportedContext(line: string): boolean; + +export declare function buildStaleVersionMatchers( + floorVersion: string, +): StaleVersionMatchers; + +export declare function readKnownLatestVersion(path: string): string; + +export declare function readMinimumSupportedVersion(path: string): string; + +export declare function fetchLatestVersion(): Promise; diff --git a/scripts/check-ui-mcp-version-copy.mjs b/scripts/check-ui-mcp-version-copy.mjs index 5219c5d8d5..d02ce0b77e 100644 --- a/scripts/check-ui-mcp-version-copy.mjs +++ b/scripts/check-ui-mcp-version-copy.mjs @@ -2,131 +2,237 @@ import { readdirSync, readFileSync, statSync } from "node:fs"; import { join, relative } from "node:path"; import { get } from "node:https"; +import { pathToFileURL } from "node:url"; -const root = process.cwd(); const packageName = "@loopover/mcp"; const registryUrl = "https://registry.npmjs.org/@loopover%2fmcp"; -const sourceLatestPath = join(root, "apps/loopover-ui/src/lib/mcp-package.ts"); -const targets = [ + +// Single source of truth for the version literals this scan enforces: the same UI module the known-latest +// check already reads. The minimum-supported floor is derived from MCP_MINIMUM_SUPPORTED_VERSION rather than +// hardcoded here, so a floor bump can't silently leave this deterministic drift-detector aimed at a stale +// version (its literals had frozen several majors behind the real floor — #6292). +export const SOURCE_LATEST_PATH = "apps/loopover-ui/src/lib/mcp-package.ts"; +export const SCAN_TARGETS = [ "README.md", "packages/loopover-mcp/README.md", "apps/loopover-ui/src", -].map((target) => join(root, target)); - -// The live npm-registry check is BEST-EFFORT: a transient registry blip must not fail CI, because a red -// required check one-shot-closes a contributor PR. Set LOOPOVER_MCP_LATEST_VERSION to make it fully -// offline/deterministic. The deterministic stale-version-string scan below always runs regardless. -let latest = process.env.LOOPOVER_MCP_LATEST_VERSION ?? null; -let latestSkipReason = null; -if (!latest) { - try { - latest = await fetchLatestVersion(); - } catch (error) { - latestSkipReason = error instanceof Error ? error.message : "unknown error"; - } -} -const sourceLatest = readKnownLatestVersion(sourceLatestPath); -const failures = []; +]; + +async function main() { + const root = process.cwd(); + const sourceLatestPath = join(root, SOURCE_LATEST_PATH); + const targets = SCAN_TARGETS.map((target) => join(root, target)); -if (latest && sourceLatest !== latest) { - failures.push( - `apps/loopover-ui/src/lib/mcp-package.ts: known latest ${sourceLatest} does not match npm dist-tags.latest ${latest}`, + // The live npm-registry check is BEST-EFFORT: a transient registry blip must not fail CI, because a red + // required check one-shot-closes a contributor PR. Set LOOPOVER_MCP_LATEST_VERSION to make it fully + // offline/deterministic. The deterministic stale-version-string scan below always runs regardless. + let latest = process.env.LOOPOVER_MCP_LATEST_VERSION ?? null; + let latestSkipReason = null; + if (!latest) { + try { + latest = await fetchLatestVersion(); + } catch (error) { + latestSkipReason = + error instanceof Error ? error.message : "unknown error"; + } + } + const sourceLatest = readKnownLatestVersion(sourceLatestPath); + const matchers = buildStaleVersionMatchers( + readMinimumSupportedVersion(sourceLatestPath), ); -} else if (!latest) { - console.warn( - `::warning::skipped the npm dist-tag drift check (registry unavailable: ${latestSkipReason}); set LOOPOVER_MCP_LATEST_VERSION to enforce it offline`, + const failures = []; + + if (latest && sourceLatest !== latest) { + failures.push( + `${SOURCE_LATEST_PATH}: known latest ${sourceLatest} does not match npm dist-tags.latest ${latest}`, + ); + } else if (!latest) { + console.warn( + `::warning::skipped the npm dist-tag drift check (registry unavailable: ${latestSkipReason}); set LOOPOVER_MCP_LATEST_VERSION to enforce it offline`, + ); + } + + for (const file of targets.flatMap(collectSourceFiles)) { + const label = relative(root, file); + const text = readFileSync(file, "utf8"); + failures.push(...collectVersionCopyFailures({ label, text, matchers })); + } + + if (failures.length > 0) { + console.error(failures.join("\n")); + process.exit(1); + } + + console.log( + `MCP UI version copy ok: npm latest ${latest ?? "unchecked"}, minimum floor ${matchers.floorVersion}, scanned ${targets.length} target(s)`, ); } -for (const file of targets.flatMap(collectSourceFiles)) { - const label = relative(root, file); - const text = readFileSync(file, "utf8"); +export function collectVersionCopyFailures({ label, text, matchers }) { + const failures = []; const lines = text.split(/\r?\n/); lines.forEach((line, index) => { const lineNumber = index + 1; - if (/\bv0\.2(?:\.0)?\b/.test(line)) { - failures.push(`${label}:${lineNumber}: stale visible v0.2 version text`); + if (matchers.visibleVersion.test(line)) { + failures.push( + `${label}:${lineNumber}: stale visible v${matchers.minorLabel} version text`, + ); } - if (/\b0\.2\.x\b/.test(line)) { - failures.push(`${label}:${lineNumber}: stale 0.2.x package-version range`); + if (matchers.versionRange.test(line)) { + failures.push( + `${label}:${lineNumber}: stale ${matchers.minorLabel}.x package-version range`, + ); } - if (/\b0\.2\.0\b/.test(line) && !isMinimumSupportedContext(line)) { - failures.push(`${label}:${lineNumber}: 0.2.0 is only allowed as an explicit minimum-supported compatibility floor`); + if (matchers.floor.test(line) && !isMinimumSupportedContext(line)) { + failures.push( + `${label}:${lineNumber}: ${matchers.floorVersion} is only allowed as an explicit minimum-supported compatibility floor`, + ); } if (/@loopover\/mcp(?:\s+|@)v?\d+\.\d+\.\d+/.test(line)) { - failures.push(`${label}:${lineNumber}: hardcoded ${packageName} display version`); + failures.push( + `${label}:${lineNumber}: hardcoded ${packageName} display version`, + ); } if (/(?:npm (?:i|install) -g|npx -y)\s+@loopover\/mcp(?!@)/.test(line)) { - failures.push(`${label}:${lineNumber}: install command must use ${packageName}@latest or resolved npm latest`); + failures.push( + `${label}:${lineNumber}: install command must use ${packageName}@latest or resolved npm latest`, + ); } - if (/args\s*=\s*\[.*"@loopover\/mcp"/.test(line) || /"args":\s*\[.*"@loopover\/mcp"/.test(line)) { - failures.push(`${label}:${lineNumber}: MCP client args must use ${packageName}@latest or resolved npm latest`); + if ( + /args\s*=\s*\[.*"@loopover\/mcp"/.test(line) || + /"args":\s*\[.*"@loopover\/mcp"/.test(line) + ) { + failures.push( + `${label}:${lineNumber}: MCP client args must use ${packageName}@latest or resolved npm latest`, + ); } }); -} -if (failures.length > 0) { - console.error(failures.join("\n")); - process.exit(1); + return failures; } -console.log(`MCP UI version copy ok: npm latest ${latest ?? "unchecked"}, scanned ${targets.length} target(s)`); - -function collectSourceFiles(path) { +export function collectSourceFiles(path) { const stat = statSync(path); if (stat.isFile()) return isTextSource(path) ? [path] : []; return readdirSync(path).flatMap((entry) => { const next = join(path, entry); - if (entry === "node_modules" || entry === "dist" || entry === ".vitepress" || entry === "coverage") return []; - if (/routeTree\.gen\.ts$/.test(next) || /public\/openapi\.json$/.test(next)) return []; + if ( + entry === "node_modules" || + entry === "dist" || + entry === ".vitepress" || + entry === "coverage" + ) + return []; + if (/routeTree\.gen\.ts$/.test(next) || /public\/openapi\.json$/.test(next)) + return []; return collectSourceFiles(next); }); } -function isTextSource(path) { +export function isTextSource(path) { return /\.(md|ts|tsx|js|jsx|json)$/.test(path); } -function isMinimumSupportedContext(line) { - return /minimum[_ -]?supported|MCP_MINIMUM_SUPPORTED_VERSION|MINIMUM_SUPPORTED_MCP_VERSION|compatibility floor|API minimum|supportedVersionRange/i.test(line); +export function isMinimumSupportedContext(line) { + return /minimum[_ -]?supported|MCP_MINIMUM_SUPPORTED_VERSION|MINIMUM_SUPPORTED_MCP_VERSION|compatibility floor|API minimum|supportedVersionRange/i.test( + line, + ); +} + +function escapeRegExp(value) { + return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); +} + +// Derives the three stale-version matchers from the current minimum-supported floor. Keeping the shapes in +// one place (visible `vX.Y`, the `X.Y.x` range, and the bare `X.Y.Z` floor allowed only in an explicit +// minimum-supported statement) means the deterministic scan tracks the floor instead of a frozen literal. +export function buildStaleVersionMatchers(floorVersion) { + if (!/^\d+\.\d+\.\d+$/.test(floorVersion)) { + throw new Error( + `Expected a semver minimum-supported floor like 0.5.0, got "${floorVersion}".`, + ); + } + const [major, minor, patch] = floorVersion.split("."); + const minorLabel = `${major}.${minor}`; + const minorPattern = escapeRegExp(minorLabel); + const patchPattern = escapeRegExp(patch); + const floorPattern = escapeRegExp(floorVersion); + return { + floorVersion, + minorLabel, + visibleVersion: new RegExp(`\\bv${minorPattern}(?:\\.${patchPattern})?\\b`), + versionRange: new RegExp(`\\b${minorPattern}\\.x\\b`), + floor: new RegExp(`\\b${floorPattern}\\b`), + }; } -function readKnownLatestVersion(path) { +export function readKnownLatestVersion(path) { const text = readFileSync(path, "utf8"); const match = /MCP_PACKAGE_KNOWN_LATEST_VERSION\s*=\s*"([^"]+)"/.exec(text); - if (!match) throw new Error("Could not find MCP_PACKAGE_KNOWN_LATEST_VERSION."); + if (!match) + throw new Error("Could not find MCP_PACKAGE_KNOWN_LATEST_VERSION."); return match[1]; } -function fetchLatestVersion() { +export function readMinimumSupportedVersion(path) { + const text = readFileSync(path, "utf8"); + const match = /MCP_MINIMUM_SUPPORTED_VERSION\s*=\s*"([^"]+)"/.exec(text); + if (!match) throw new Error("Could not find MCP_MINIMUM_SUPPORTED_VERSION."); + return match[1]; +} + +export function fetchLatestVersion() { return new Promise((resolve, reject) => { - const request = get(registryUrl, { headers: { accept: "application/json" } }, (response) => { - let body = ""; - response.setEncoding("utf8"); - response.on("data", (chunk) => { - body += chunk; - }); - response.on("end", () => { - if (!response.statusCode || response.statusCode < 200 || response.statusCode >= 300) { - reject(new Error(`npm registry returned ${response.statusCode ?? "unknown"}`)); - return; - } - try { - const latest = JSON.parse(body)?.["dist-tags"]?.latest; - if (typeof latest !== "string" || !/^\d+\.\d+\.\d+$/.test(latest)) { - reject(new Error("npm registry did not return a stable latest version")); + const request = get( + registryUrl, + { headers: { accept: "application/json" } }, + (response) => { + let body = ""; + response.setEncoding("utf8"); + response.on("data", (chunk) => { + body += chunk; + }); + response.on("end", () => { + if ( + !response.statusCode || + response.statusCode < 200 || + response.statusCode >= 300 + ) { + reject( + new Error( + `npm registry returned ${response.statusCode ?? "unknown"}`, + ), + ); return; } - resolve(latest); - } catch (error) { - reject(error); - } - }); - }); + try { + const latest = JSON.parse(body)?.["dist-tags"]?.latest; + if (typeof latest !== "string" || !/^\d+\.\d+\.\d+$/.test(latest)) { + reject( + new Error( + "npm registry did not return a stable latest version", + ), + ); + return; + } + resolve(latest); + } catch (error) { + reject(error); + } + }); + }, + ); request.setTimeout(8000, () => { request.destroy(new Error("npm registry timeout")); }); request.on("error", reject); }); } + +if (import.meta.url === pathToFileURL(process.argv[1] ?? "").href) { + main().catch((error) => { + console.error(error instanceof Error ? error.message : error); + process.exit(1); + }); +} diff --git a/test/unit/check-ui-mcp-version-copy-script.test.ts b/test/unit/check-ui-mcp-version-copy-script.test.ts new file mode 100644 index 0000000000..5540e7d4eb --- /dev/null +++ b/test/unit/check-ui-mcp-version-copy-script.test.ts @@ -0,0 +1,122 @@ +import { execFileSync } from "node:child_process"; +import { readFileSync } from "node:fs"; +import { join } from "node:path"; +import { describe, expect, it } from "vitest"; +import { + buildStaleVersionMatchers, + collectVersionCopyFailures, + isMinimumSupportedContext, + readMinimumSupportedVersion, + SOURCE_LATEST_PATH, +} from "../../scripts/check-ui-mcp-version-copy.mjs"; + +const root = process.cwd(); +const SCRIPT_PATH = "scripts/check-ui-mcp-version-copy.mjs"; +const sourceText = readFileSync(join(root, SOURCE_LATEST_PATH), "utf8"); + +function declaredConstant(name: string): string { + const value = new RegExp(`${name}\\s*=\\s*"([^"]+)"`).exec(sourceText)?.[1]; + if (value === undefined) + throw new Error(`Could not find ${name} in ${SOURCE_LATEST_PATH}.`); + return value; +} + +describe("check-ui-mcp-version-copy script (#6292)", () => { + it("derives the minimum-supported floor from the shipped constant, so the scan can't drift", () => { + // The whole point of #6292: the floor must come from the single source of truth the app ships, not a + // hardcoded literal that froze several majors behind reality. + const declared = declaredConstant("MCP_MINIMUM_SUPPORTED_VERSION"); + expect(declared).toMatch(/^\d+\.\d+\.\d+$/); + expect(readMinimumSupportedVersion(join(root, SOURCE_LATEST_PATH))).toBe( + declared, + ); + }); + + it("no longer hardcodes the years-stale 0.2 floor literal it was frozen at", () => { + const scriptText = readFileSync(join(root, SCRIPT_PATH), "utf8"); + expect(scriptText).not.toContain("0.2"); + }); + + describe("buildStaleVersionMatchers", () => { + it("rejects a non-semver floor so a malformed source constant fails loudly", () => { + expect(() => buildStaleVersionMatchers("0.5")).toThrow(/semver/); + }); + + it("targets the floor's own major.minor and exact version", () => { + const matchers = buildStaleVersionMatchers("1.2.3"); + expect(matchers.floorVersion).toBe("1.2.3"); + expect(matchers.minorLabel).toBe("1.2"); + expect(matchers.visibleVersion.test("v1.2")).toBe(true); + expect(matchers.visibleVersion.test("v1.2.3")).toBe(true); + expect(matchers.versionRange.test("1.2.x")).toBe(true); + expect(matchers.floor.test("1.2.3")).toBe(true); + // A neighbouring release must not be mistaken for the floor. + expect(matchers.floor.test("1.2.4")).toBe(false); + }); + }); + + describe("collectVersionCopyFailures", () => { + const matchers = buildStaleVersionMatchers("0.5.0"); + + it("flags a bare floor version used outside a minimum-supported statement", () => { + const failures = collectVersionCopyFailures({ + label: "README.md", + text: "@loopover/mcp/0.5.0 (api 0.1.0, node v22.12.0)", + matchers, + }); + expect(failures).toEqual([ + "README.md:1: 0.5.0 is only allowed as an explicit minimum-supported compatibility floor", + ]); + }); + + it("allows the floor version when the line is an explicit minimum-supported floor", () => { + const failures = collectVersionCopyFailures({ + label: "mcp-package.ts", + text: 'export const MCP_MINIMUM_SUPPORTED_VERSION = "0.5.0";', + matchers, + }); + expect(failures).toEqual([]); + }); + + it("flags visible v-prefixed minor text and the .x range on the same line", () => { + const failures = collectVersionCopyFailures({ + label: "a.md", + text: "use v0.5 or the 0.5.x range", + matchers, + }); + expect(failures).toEqual([ + "a.md:1: stale visible v0.5 version text", + "a.md:1: stale 0.5.x package-version range", + ]); + }); + + it("does not flag the current package version or non-version 0.5 fragments", () => { + const failures = collectVersionCopyFailures({ + label: "b.md", + text: "@loopover/mcp/3.0.0\npy-0.5 gap-0.5\ntransition duration 0.2", + matchers, + }); + expect(failures).toEqual([]); + }); + }); + + it("recognizes minimum-supported context markers", () => { + expect( + isMinimumSupportedContext("the minimum supported version is X"), + ).toBe(true); + expect(isMinimumSupportedContext("supportedVersionRange: >=X")).toBe(true); + expect(isMinimumSupportedContext("just some prose")).toBe(false); + }); + + it("passes cleanly against the real repo docs with the registry check stubbed offline", () => { + const knownLatest = declaredConstant("MCP_PACKAGE_KNOWN_LATEST_VERSION"); + const out = execFileSync(process.execPath, [SCRIPT_PATH], { + encoding: "utf8", + env: { ...process.env, LOOPOVER_MCP_LATEST_VERSION: knownLatest }, + }); + expect(out).toContain("MCP UI version copy ok"); + expect(out).toContain( + `minimum floor ${declaredConstant("MCP_MINIMUM_SUPPORTED_VERSION")}`, + ); + }); +});