From 6e5c99d0c81b0468c057807b15dd1e265003c621 Mon Sep 17 00:00:00 2001 From: murashit Date: Sun, 17 May 2026 21:18:49 +0900 Subject: [PATCH] Add API baseline checks for Obsidian and Codex --- .../codex-panel-app-server-update/SKILL.md | 6 +- README.md | 7 +- docs/development.md | 13 + package-lock.json | 2 +- package.json | 4 +- scripts/report-api-baseline.mjs | 244 ++++++++++++++++++ 6 files changed, 269 insertions(+), 7 deletions(-) create mode 100644 scripts/report-api-baseline.mjs diff --git a/.agents/skills/codex-panel-app-server-update/SKILL.md b/.agents/skills/codex-panel-app-server-update/SKILL.md index 06c36e74..82034cc8 100644 --- a/.agents/skills/codex-panel-app-server-update/SKILL.md +++ b/.agents/skills/codex-panel-app-server-update/SKILL.md @@ -1,6 +1,6 @@ --- name: codex-panel-app-server-update -description: Use when updating Codex Panel for a new Codex CLI or app-server API version, regenerating app-server TypeScript bindings, handling generated binding diffs, adjusting app-server compatibility, or updating the README tested Codex CLI version. +description: Use when updating Codex Panel for a new Codex CLI or app-server API version, regenerating app-server TypeScript bindings, handling generated binding diffs, adjusting app-server compatibility, or updating the README Compatibility table. --- # Codex Panel App-Server Update @@ -17,12 +17,12 @@ Use this skill when Codex Panel needs to follow Codex CLI or experimental `codex ## Procedure 1. Read the README Compatibility and Development sections, `package.json`, and app-server-related source around requests, notifications, threads, approvals, Plan mode, hooks, and model listing. -2. Compare the README tested Codex CLI version with the target `codex --version`. +2. Compare the README Compatibility table's `codex.testedCliVersion` with the target `codex --version`. 3. Check official Codex CLI or app-server release information when the target version is newer or behavior is uncertain. 4. Regenerate bindings with `npm run generate:app-server-types`. 5. Review generated diffs for protocol changes that affect runtime behavior. If mechanical normalization is needed, update `scripts/normalize-generated-types.mjs` and regenerate instead of patching generated files by hand. 6. Implement only the compatibility changes needed for the target Codex CLI version. -7. Update the README tested Codex CLI version only after validating against that version. +7. Update the README Compatibility table's `codex.testedCliVersion` only after validating against that version. ## Verification diff --git a/README.md b/README.md index 8af9dd7c..6a7d9206 100644 --- a/README.md +++ b/README.md @@ -82,9 +82,12 @@ The plugin does not store API keys. ## Compatibility -Codex Panel depends on the experimental `codex app-server` API. Later Codex CLI releases may require regenerated app-server bindings or compatibility fixes. +| Key | Version | Policy | +| ------------------------ | --------- | --------------------------------------------------------------------------------------------------- | +| `obsidian.minAppVersion` | `1.12.0` | Track the latest patch in this minor; raise this baseline when adopting a newer Obsidian API minor. | +| `codex.testedCliVersion` | `0.130.0` | Manage app-server compatibility by Codex CLI minor version. | -The current release is developed and tested with Codex CLI 0.130.0. +Codex Panel depends on the experimental `codex app-server` API. Later Codex CLI releases may require regenerated app-server bindings or compatibility fixes. ## Project Docs diff --git a/docs/development.md b/docs/development.md index 66ab7b25..e02d1dfd 100644 --- a/docs/development.md +++ b/docs/development.md @@ -37,3 +37,16 @@ npm run check ``` The generation script uses `codex app-server generate-ts --experimental` because the panel depends on experimental app-server fields such as collaboration mode and generated v2 types. + +## API Baselines + +Use the local API baseline report when checking whether the development environment matches the supported API policy: + +```sh +npm run api:baseline +npm run api:baseline:check +``` + +Obsidian API compatibility follows semver within the guaranteed minor. `manifest.json` and `versions.json` define the minimum supported Obsidian app minor with a `.0` patch version, while the `obsidian` npm dev dependency tracks the latest patch in that same minor using a `~` range. When the Obsidian API minor is raised, update `manifest.json`, `versions.json`, README requirements, and the `obsidian` dev dependency together. + +Codex app-server compatibility is managed by Codex CLI minor version. README records the tested Codex CLI patch version, and the baseline check verifies that the local `codex --version` is in the same minor before app-server binding or compatibility work. diff --git a/package-lock.json b/package-lock.json index 6b4ee2bb..7157b964 100644 --- a/package-lock.json +++ b/package-lock.json @@ -16,7 +16,7 @@ "eslint": "^10.3.0", "eslint-plugin-obsidianmd": "^0.3.0", "jsdom": "^29.1.1", - "obsidian": "^1.12.3", + "obsidian": "~1.12.3", "prettier": "^3.8.3", "typescript": "^6.0.3", "vitest": "^4.1.5" diff --git a/package.json b/package.json index 92fca5c3..1f977eea 100644 --- a/package.json +++ b/package.json @@ -19,6 +19,8 @@ "dev": "node esbuild.config.mjs --watch", "format": "prettier --write \"src/**/*.{ts,tsx,js,mjs,json,css,md}\" \"tests/**/*.{ts,tsx,js,mjs,json,css,md}\" \"scripts/**/*.{js,mjs}\" \"*.{json,mjs,md,css}\" \"!src/generated/**\"", "format:check": "prettier --check \"src/**/*.{ts,tsx,js,mjs,json,css,md}\" \"tests/**/*.{ts,tsx,js,mjs,json,css,md}\" \"scripts/**/*.{js,mjs}\" \"*.{json,mjs,md,css}\" \"!src/generated/**\"", + "api:baseline": "node scripts/report-api-baseline.mjs", + "api:baseline:check": "node scripts/report-api-baseline.mjs --check", "generate:app-server-types": "codex app-server generate-ts --experimental --out src/generated/app-server && node scripts/normalize-generated-types.mjs", "lint": "eslint src tests --max-warnings=0", "release:check": "node scripts/check-release.mjs", @@ -36,7 +38,7 @@ "eslint": "^10.3.0", "eslint-plugin-obsidianmd": "^0.3.0", "jsdom": "^29.1.1", - "obsidian": "^1.12.3", + "obsidian": "~1.12.3", "prettier": "^3.8.3", "typescript": "^6.0.3", "vitest": "^4.1.5" diff --git a/scripts/report-api-baseline.mjs b/scripts/report-api-baseline.mjs new file mode 100644 index 00000000..d6b7d7c3 --- /dev/null +++ b/scripts/report-api-baseline.mjs @@ -0,0 +1,244 @@ +import { readFile } from "node:fs/promises"; +import { spawnSync } from "node:child_process"; + +const args = new Set(process.argv.slice(2)); +const shouldCheck = args.has("--check"); +const asJson = args.has("--json"); + +function fail(message) { + failures.push(message); +} + +function warn(message) { + warnings.push(message); +} + +async function readJson(path) { + return JSON.parse(await readFile(path, "utf8")); +} + +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; +} + +function readCompatibilityBaselines(readme) { + const section = markdownSection(readme, "Compatibility"); + const table = readMarkdownTableValues(section); + return { + codexTestedCliVersion: table.get("codex.testedCliVersion") ?? null, + obsidianMinAppVersion: table.get("obsidian.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]); + if (!separator || !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)"; +} + +const failures = []; +const warnings = []; + +const packageJson = await readJson("package.json"); +const packageLockJson = await readJson("package-lock.json"); +const manifestJson = await readJson("manifest.json"); +const versionsJson = await readJson("versions.json"); +const readme = await readFile("README.md", "utf8"); +const clientSource = await readFile("src/app-server/client.ts", "utf8"); +const readmeBaselines = readCompatibilityBaselines(readme); + +const codexReadmeVersion = readmeBaselines.codexTestedCliVersion; +const codexLocalVersion = readCodexVersion(); +const codexReadmeSemver = parseSemver(codexReadmeVersion); +const codexLocalSemver = parseSemver(codexLocalVersion); + +const obsidianMinVersion = manifestJson.minAppVersion; +const obsidianReadmeMinVersion = readmeBaselines.obsidianMinAppVersion; +const obsidianVersionEntry = versionsJson[packageJson.version] ?? null; +const obsidianSpec = packageJson.devDependencies?.obsidian ?? null; +const obsidianLockVersion = packageLockJson.packages?.["node_modules/obsidian"]?.version ?? null; +const obsidianSpecSemver = parseSemver(obsidianSpec); +const obsidianLockSemver = parseSemver(obsidianLockVersion); +const obsidianMinSemver = parseSemver(obsidianMinVersion); + +const appServerGenerateScript = packageJson.scripts?.["generate:app-server-types"] ?? ""; +const appServerGenerationExperimental = + appServerGenerateScript.includes("codex app-server generate-ts") && appServerGenerateScript.includes("--experimental"); +const initializeExperimentalApi = /experimentalApi:\s*true/.test(clientSource); + +if (!codexReadmeSemver) { + fail("README.md Compatibility table must define `codex.testedCliVersion` as X.Y.Z."); +} +if (!codexLocalSemver) { + const message = "local codex --version could not be read."; + if (shouldCheck) fail(message); + else warn(message); +} +if (codexReadmeSemver && codexLocalSemver && minorKey(codexReadmeSemver) !== minorKey(codexLocalSemver)) { + fail(`local Codex CLI minor ${minorKey(codexLocalSemver)} does not match compatibility table minor ${minorKey(codexReadmeSemver)}.`); +} +if (!appServerGenerationExperimental) fail("generate:app-server-types must use codex app-server generate-ts --experimental."); +if (!initializeExperimentalApi) fail("app-server initialize must declare experimentalApi: true."); + +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 `obsidian.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 ${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)}.`); +} + +const report = { + codex: { + policy: "managed by minor version", + readmeTestedCliVersion: codexReadmeVersion, + readmeTestedMinor: minorKey(codexReadmeSemver), + localCliVersion: codexLocalVersion, + localCliMinor: minorKey(codexLocalSemver), + localCliMatchesTestedMinor: codexReadmeSemver && codexLocalSemver ? minorKey(codexReadmeSemver) === minorKey(codexLocalSemver) : null, + appServerGenerationExperimental, + initializeExperimentalApi, + }, + obsidian: { + policy: "track latest patch within the guaranteed minor; raise minAppVersion when the minor changes", + minAppVersion: obsidianMinVersion, + minAppVersionMinor: minorKey(obsidianMinSemver), + readmeMinAppVersion: obsidianReadmeMinVersion, + versionsJsonCurrentMinAppVersion: obsidianVersionEntry, + packageDependency: obsidianSpec, + packageDependencyRange: packageRangeKind(obsidianSpec), + packageDependencyMinor: minorKey(obsidianSpecSemver), + lockedPackageVersion: obsidianLockVersion, + lockedPackageMinor: minorKey(obsidianLockSemver), + }, + warnings, + failures, +}; + +if (asJson) { + console.log(JSON.stringify(report, null, 2)); +} else { + 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(` 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(` generate-ts --experimental: ${report.codex.appServerGenerationExperimental ? "yes" : "no"}`); + console.log(` initialize experimentalApi: ${report.codex.initializeExperimentalApi ? "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)}`); + 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 (warnings.length > 0) { + console.log(""); + console.log("Warnings"); + for (const message of warnings) console.log(` - ${message}`); + } + if (failures.length > 0) { + console.log(""); + console.log("Failures"); + for (const message of failures) console.log(` - ${message}`); + } +} + +if (shouldCheck && failures.length > 0) process.exit(1);