murashit_codex-panel/scripts/api-baseline.mjs

311 lines
14 KiB
JavaScript
Raw Permalink Normal View History

import { spawnSync } from "node:child_process";
2026-06-24 05:44:16 +00:00
import { readFile } from "node:fs/promises";
2026-06-26 02:39:55 +00:00
import path from "node:path";
import { fileURLToPath } from "node:url";
const validArgs = new Set(["--json"]);
2026-06-26 02:39:55 +00:00
if (isMain()) {
const args = new Set(process.argv.slice(2));
const asJson = args.has("--json");
for (const arg of args) {
if (!validArgs.has(arg)) {
console.error("Usage: node scripts/api-baseline.mjs [--json]");
2026-06-26 02:39:55 +00:00
process.exit(1);
}
}
const report = await createApiBaselineReport();
2026-06-26 02:39:55 +00:00
if (asJson) {
console.log(JSON.stringify(report, null, 2));
} else {
printReport(report);
2026-06-19 23:18:19 +00:00
}
2026-06-26 02:39:55 +00:00
if (report.failures.length > 0) process.exit(1);
}
function parseSemver(value) {
const match = String(value ?? "").match(/\b(\d+)\.(\d+)\.(\d+)\b/);
if (!match) return null;
return {
version: match[0],
major: Number(match[1]),
minor: Number(match[2]),
patch: Number(match[3]),
};
}
function minorKey(version) {
return version ? `${version.major}.${version.minor}` : null;
}
function readCodexVersion() {
const result = spawnSync("codex", ["--version"], {
encoding: "utf8",
stdio: "pipe",
shell: false,
});
if (result.error || result.status !== 0) return null;
return parseSemver(`${result.stdout}\n${result.stderr}`)?.version ?? null;
}
2026-06-26 02:39:55 +00:00
export async function createApiBaselineReport(options = {}) {
const failures = [];
const fail = (message) => {
failures.push(message);
};
const inputs = await readBaselineInputs(options.cwd ?? process.cwd());
const readmeBaselines = readCompatibilityBaselines(inputs.readme);
const codexReadmeVersion = readmeBaselines.codexTestedCliVersion;
const codexRecordedVersion = inputs.appServerCompatibilityJson.codexAppServer?.testedCliVersion ?? null;
const codexLocalVersion = options.readCodexVersion ? await options.readCodexVersion() : readCodexVersion();
2026-06-26 02:39:55 +00:00
const codexReadmeSemver = parseSemver(codexReadmeVersion);
const codexRecordedSemver = parseSemver(codexRecordedVersion);
2026-06-26 02:39:55 +00:00
const codexLocalSemver = parseSemver(codexLocalVersion);
const obsidianMinVersion = inputs.manifestJson.minAppVersion;
const obsidianReadmeApiTypesVersion = readmeBaselines.obsidianApiTypesVersion;
const obsidianReadmeMinVersion = readmeBaselines.obsidianMinAppVersion;
const obsidianVersionEntry = inputs.versionsJson[inputs.packageJson.version] ?? null;
const obsidianSpec = inputs.packageJson.devDependencies?.obsidian ?? null;
const obsidianLockVersion = inputs.packageLockJson.packages?.["node_modules/obsidian"]?.version ?? null;
const obsidianSpecSemver = parseSemver(obsidianSpec);
const obsidianLockSemver = parseSemver(obsidianLockVersion);
const obsidianMinSemver = parseSemver(obsidianMinVersion);
const generationArguments = inputs.appServerCompatibilityJson.codexAppServer?.typeGeneration?.arguments ?? null;
const expectedGenerationArguments = ["app-server", "generate-ts", "--experimental"];
const appServerGenerationArgumentsDeclared =
Array.isArray(generationArguments) && generationArguments.every((argument) => typeof argument === "string");
const appServerGenerationArgumentsSupported =
appServerGenerationArgumentsDeclared && arraysEqual(generationArguments, expectedGenerationArguments);
const initializeCapabilities = inputs.appServerCompatibilityJson.codexAppServer?.initialize?.capabilities ?? {};
const initializeExperimentalApi = initializeCapabilities.experimentalApi === true;
const initializeRequestAttestationDisabled = initializeCapabilities.requestAttestation === false;
2026-06-26 02:39:55 +00:00
if (!codexReadmeSemver) {
fail("README.md Compatibility table must define `codexAppServer.testedCliVersion` as X.Y.Z.");
2026-06-26 02:39:55 +00:00
}
if (!codexRecordedSemver || codexRecordedSemver.version !== codexRecordedVersion) {
fail("src/app-server/connection/compatibility.json must declare codexAppServer.testedCliVersion as X.Y.Z.");
}
if (!codexLocalSemver) {
2026-06-26 02:39:55 +00:00
fail("local codex --version could not be read.");
}
if (codexReadmeVersion && codexRecordedVersion && codexReadmeVersion !== codexRecordedVersion) {
fail(`README Codex CLI ${codexReadmeVersion} does not match recorded tested CLI ${codexRecordedVersion}.`);
2026-06-26 02:39:55 +00:00
}
if (codexLocalSemver && codexRecordedSemver && codexLocalSemver.version !== codexRecordedSemver.version) {
fail(`local Codex CLI ${codexLocalSemver.version} does not match recorded tested CLI ${codexRecordedSemver.version}.`);
}
if (!appServerGenerationArgumentsDeclared) {
fail("src/app-server/connection/compatibility.json must declare codexAppServer.typeGeneration.arguments as a string array.");
} else if (!appServerGenerationArgumentsSupported) {
fail(`app-server type generation arguments must be ${expectedGenerationArguments.join(" ")}.`);
}
if (!initializeExperimentalApi) {
fail("src/app-server/connection/compatibility.json must declare codexAppServer.initialize.capabilities.experimentalApi: true.");
}
if (!initializeRequestAttestationDisabled) {
fail("src/app-server/connection/compatibility.json must declare codexAppServer.initialize.capabilities.requestAttestation: false.");
}
2026-06-26 02:39:55 +00:00
if (!obsidianMinSemver) fail("manifest.json minAppVersion must be X.Y.Z.");
if (obsidianMinSemver && obsidianMinSemver.patch !== 0) {
fail(`manifest.json minAppVersion should be the minor baseline patch 0, got ${obsidianMinVersion}.`);
}
if (!parseSemver(obsidianReadmeMinVersion)) {
fail("README.md Compatibility table must define `manifest.minAppVersion` as X.Y.Z.");
} else if (obsidianReadmeMinVersion !== obsidianMinVersion) {
fail(`README Obsidian baseline ${displayValue(obsidianReadmeMinVersion)} does not match manifest ${obsidianMinVersion}.`);
}
if (obsidianVersionEntry !== obsidianMinVersion) {
fail(`versions.json must map ${inputs.packageJson.version} to manifest minAppVersion ${obsidianMinVersion}.`);
}
if (!obsidianSpecSemver) fail("package.json devDependency obsidian must include an X.Y.Z version.");
if (!obsidianLockSemver) fail("package-lock.json must lock node_modules/obsidian to X.Y.Z.");
if (packageRangeKind(obsidianSpec) !== "patch") {
fail(`package.json devDependency obsidian should use a patch-only '~' range, got ${displayValue(obsidianSpec)}.`);
}
if (obsidianMinSemver && obsidianSpecSemver && minorKey(obsidianSpecSemver) !== minorKey(obsidianMinSemver)) {
fail(`obsidian devDependency minor ${minorKey(obsidianSpecSemver)} does not match minAppVersion minor ${minorKey(obsidianMinSemver)}.`);
}
if (obsidianMinSemver && obsidianLockSemver && minorKey(obsidianLockSemver) !== minorKey(obsidianMinSemver)) {
fail(`locked obsidian minor ${minorKey(obsidianLockSemver)} does not match minAppVersion minor ${minorKey(obsidianMinSemver)}.`);
}
if (!parseSemver(obsidianReadmeApiTypesVersion)) {
fail("README.md Compatibility table must define `obsidian` API types as X.Y.Z.");
} else if (obsidianReadmeApiTypesVersion !== obsidianLockVersion) {
fail(
`README Obsidian API types ${displayValue(obsidianReadmeApiTypesVersion)} does not match package-lock obsidian ${displayValue(obsidianLockVersion)}.`,
);
}
return {
codex: {
policy: "compatibility is managed by minor; generated bindings are proven against an exact CLI patch",
recordedTestedCliVersion: codexRecordedVersion,
2026-06-26 02:39:55 +00:00
readmeTestedCliVersion: codexReadmeVersion,
readmeTestedMinor: minorKey(codexReadmeSemver),
localCliVersion: codexLocalVersion,
localCliMinor: minorKey(codexLocalSemver),
readmeMatchesRecordedVersion: codexReadmeVersion && codexRecordedVersion ? codexReadmeVersion === codexRecordedVersion : false,
localCliMatchesRecordedVersion:
codexLocalSemver && codexRecordedSemver ? codexLocalSemver.version === codexRecordedSemver.version : null,
generationArguments,
appServerGenerationArgumentsSupported,
2026-06-26 02:39:55 +00:00
initializeExperimentalApi,
initializeRequestAttestationDisabled,
},
obsidian: {
policy: "manifest declares the minimum app version; obsidian npm provides compile-time API types in the same minor",
minAppVersion: obsidianMinVersion,
minAppVersionMinor: minorKey(obsidianMinSemver),
readmeMinAppVersion: obsidianReadmeMinVersion,
versionsJsonCurrentMinAppVersion: obsidianVersionEntry,
readmeApiTypesVersion: obsidianReadmeApiTypesVersion,
packageDependency: obsidianSpec,
packageDependencyRange: packageRangeKind(obsidianSpec),
packageDependencyMinor: minorKey(obsidianSpecSemver),
lockedPackageVersion: obsidianLockVersion,
lockedPackageMinor: minorKey(obsidianLockSemver),
},
failures,
};
}
function readCompatibilityBaselines(readme) {
const section = markdownSection(readme, "Compatibility");
const table = readMarkdownTableValues(section);
return {
codexTestedCliVersion: table.get("codexAppServer.testedCliVersion") ?? null,
2026-06-19 11:11:51 +00:00
obsidianApiTypesVersion: table.get("obsidian") ?? null,
obsidianMinAppVersion: table.get("manifest.minAppVersion") ?? null,
};
}
function markdownSection(markdown, heading) {
const lines = markdown.replace(/\r\n/g, "\n").split("\n");
const headingLine = `## ${heading}`;
const start = lines.findIndex((line) => line.trim() === headingLine);
if (start === -1) return "";
const end = lines.findIndex((line, index) => index > start && /^##\s+\S/.test(line));
return lines.slice(start + 1, end === -1 ? undefined : end).join("\n");
}
function readMarkdownTableValues(markdown) {
const values = new Map();
const lines = markdown.split("\n").map((line) => line.trim());
for (let index = 0; index < lines.length; index += 1) {
const header = splitMarkdownTableRow(lines[index]);
if (!header) continue;
const separator = splitMarkdownTableRow(lines[index + 1]);
2026-06-24 07:09:29 +00:00
if (!separator?.every((cell) => /^:?-{3,}:?$/.test(cell))) continue;
const keyColumn = header.findIndex((cell) => normalizeTableHeader(cell) === "key");
const versionColumn = header.findIndex((cell) => normalizeTableHeader(cell) === "version");
if (keyColumn === -1 || versionColumn === -1) continue;
for (index += 2; index < lines.length; index += 1) {
const row = splitMarkdownTableRow(lines[index]);
if (!row) break;
const key = inlineCodeValue(row[keyColumn]);
const version = inlineCodeValue(row[versionColumn]);
if (key && version) values.set(key, version);
}
}
return values;
}
function splitMarkdownTableRow(line) {
if (!line?.startsWith("|") || !line.endsWith("|")) return null;
return line
.slice(1, -1)
.split("|")
.map((cell) => cell.trim());
}
function normalizeTableHeader(value) {
return value.trim().toLowerCase();
}
function inlineCodeValue(value) {
return value?.match(/`([^`]+)`/)?.[1] ?? null;
}
function packageRangeKind(spec) {
const value = String(spec ?? "");
if (value.startsWith("~")) return "patch";
if (/^\d+\.\d+\.\d+$/.test(value)) return "exact";
if (value.startsWith("^")) return "minor";
return "other";
}
function displayValue(value) {
return value ?? "(missing)";
}
function arraysEqual(left, right) {
return left.length === right.length && left.every((value, index) => value === right[index]);
}
2026-06-26 02:39:55 +00:00
async function readBaselineInputs(cwd) {
const [packageJson, packageLockJson, manifestJson, versionsJson, readme, appServerCompatibilityJson] = await Promise.all([
readJson(cwd, "package.json"),
readJson(cwd, "package-lock.json"),
readJson(cwd, "manifest.json"),
readJson(cwd, "versions.json"),
readFile(path.join(cwd, "README.md"), "utf8"),
readJson(cwd, "src/app-server/connection/compatibility.json"),
]);
return {
appServerCompatibilityJson,
manifestJson,
packageJson,
packageLockJson,
readme,
versionsJson,
};
}
2026-06-26 02:39:55 +00:00
async function readJson(cwd, file) {
return JSON.parse(await readFile(path.join(cwd, file), "utf8"));
}
function isMain() {
return process.argv[1] ? path.resolve(process.argv[1]) === fileURLToPath(import.meta.url) : false;
}
function printReport(report) {
console.log("API baseline");
console.log("");
console.log("Codex app-server");
console.log(` policy: ${report.codex.policy}`);
console.log(` compatibility table CLI: ${displayValue(report.codex.readmeTestedCliVersion)}`);
console.log(` recorded tested CLI: ${displayValue(report.codex.recordedTestedCliVersion)}`);
console.log(` compatibility table minor: ${displayValue(report.codex.readmeTestedMinor)}`);
console.log(` local codex CLI: ${displayValue(report.codex.localCliVersion)}`);
console.log(` local codex minor: ${displayValue(report.codex.localCliMinor)}`);
console.log(` generation arguments: ${report.codex.generationArguments?.join(" ") ?? "(missing)"}`);
console.log(` initialize experimentalApi: ${report.codex.initializeExperimentalApi ? "yes" : "no"}`);
console.log(` initialize requestAttestation disabled: ${report.codex.initializeRequestAttestationDisabled ? "yes" : "no"}`);
console.log("");
console.log("Obsidian API");
console.log(` policy: ${report.obsidian.policy}`);
console.log(` manifest minAppVersion: ${displayValue(report.obsidian.minAppVersion)}`);
console.log(` compatibility table minAppVersion: ${displayValue(report.obsidian.readmeMinAppVersion)}`);
console.log(` versions.json current minAppVersion: ${displayValue(report.obsidian.versionsJsonCurrentMinAppVersion)}`);
2026-06-19 11:11:51 +00:00
console.log(` compatibility table API types: ${displayValue(report.obsidian.readmeApiTypesVersion)}`);
console.log(` package obsidian: ${displayValue(report.obsidian.packageDependency)}`);
console.log(` package range: ${report.obsidian.packageDependencyRange}`);
console.log(` package-lock obsidian: ${displayValue(report.obsidian.lockedPackageVersion)}`);
if (report.failures.length > 0) {
console.log("");
console.log("Failures");
for (const message of report.failures) console.log(` - ${message}`);
}
}