Add API baseline checks for Obsidian and Codex

This commit is contained in:
murashit 2026-05-17 21:18:49 +09:00
parent e6966995a6
commit 6e5c99d0c8
6 changed files with 269 additions and 7 deletions

View file

@ -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

View file

@ -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

View file

@ -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.

2
package-lock.json generated
View file

@ -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"

View file

@ -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"

View file

@ -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);