refactor(plugin-sdk): make API baseline module-mergeable (#121842)

This commit is contained in:
Peter Steinberger
2026-08-11 04:45:44 -07:00
committed by GitHub
parent 2cc11aff38
commit 3cfb344f9f
5 changed files with 249 additions and 5069 deletions
+6 -6
View File
@@ -7,7 +7,7 @@ snapshots remain local inspection artifacts.
- `config-baseline.sha256` — hashes of config baseline JSON artifacts.
- `config-baseline.counts.json` — maximum entry counts for each config baseline kind.
- `plugin-sdk-api-baseline.jsonl` — one content-derived record per Plugin SDK module and export.
- `plugin-sdk-api-baseline.jsonl` — one content hash per Plugin SDK module.
- `sqlite-session-transcript-schema-baseline.sha256` — hash of the sessions/transcripts SQLite schema baseline.
**Local only (gitignored):**
@@ -24,11 +24,11 @@ Do not edit any of these files by hand.
- Validate Plugin SDK API contract manifest: `pnpm plugin-sdk:api:check`
The Plugin SDK contract sorts modules by import specifier and exports by kind
then name. Its line-delimited records keep concurrent changes to separate
exports mergeable. Export records carry the normalized `declaration` and an
explicit `closureHash` for surface-reachable repo declarations. Committed
records omit source paths so file moves do not change the contract; origin
`source` fields remain available in the local pretty JSON snapshot.
then name. Each line hashes one module's normalized declarations and the
surface-reachable declaration closure hashes, so changes to different modules
do not rewrite shared aggregate records. Committed records omit source paths so
file moves do not change the contract; origin `source` fields remain available
in the local pretty JSON snapshot.
- Regenerate SQLite sessions/transcripts schema baseline: `pnpm sqlite:sessions-schema:gen`
- Validate SQLite sessions/transcripts schema baseline: `pnpm sqlite:sessions-schema:check`
File diff suppressed because one or more lines are too long
+1 -13
View File
@@ -16,25 +16,13 @@ function describeContractLine(line: string): { identity: string | null; label: s
try {
const record = JSON.parse(line) as {
entrypoint?: unknown;
exportName?: unknown;
recordType?: unknown;
};
if (typeof record.entrypoint === "string" && record.recordType === "module") {
if (typeof record.entrypoint === "string") {
return {
identity: `module\0${record.entrypoint}`,
label: `entrypoint=${record.entrypoint}`,
};
}
if (
typeof record.entrypoint === "string" &&
typeof record.exportName === "string" &&
record.recordType === "export"
) {
return {
identity: `export\0${record.entrypoint}\0${record.exportName}`,
label: `entrypoint=${record.entrypoint} exportName=${record.exportName}`,
};
}
} catch {
// Invalid committed JSONL still appears in the bounded raw-line diff.
}
+67 -4
View File
@@ -1,6 +1,8 @@
/**
* Tests the plugin SDK public API baseline.
*/
import { spawnSync } from "node:child_process";
import fs from "node:fs";
import path from "node:path";
import ts from "typescript";
@@ -307,7 +309,8 @@ describe("Plugin SDK API baseline", () => {
expect(rendered.json).toContain('"source": {');
expect(rendered.jsonl).not.toContain('"sourceLine":');
expect(rendered.jsonl).not.toContain('"sourcePath":');
expect(rendered.jsonl).toContain('"closureHash":"');
expect(rendered.jsonl).toContain('"contentHash":"');
expect(rendered.jsonl).not.toContain('"closureHash":"');
expect(rendered.jsonl).not.toContain("// declaration closure:");
});
@@ -318,7 +321,7 @@ describe("Plugin SDK API baseline", () => {
expect(reverse.jsonl).toBe(rendered.jsonl);
});
it("keeps unrelated JSONL records byte-identical when one export changes", () => {
it("keeps unrelated module hashes byte-identical when one export changes", () => {
const target = rendered.baseline.modules[0];
expect(target?.exports.length).toBeGreaterThan(0);
const changed = renderPluginSdkApiBaselineModules(
@@ -338,8 +341,68 @@ describe("Plugin SDK API baseline", () => {
const before = rendered.jsonl.split("\n");
const after = changed.jsonl.split("\n");
expect(after[1]).not.toBe(before[1]);
expect(after.slice(2)).toEqual(before.slice(2));
expect(after[0]).not.toBe(before[0]);
expect(after.slice(1)).toEqual(before.slice(1));
});
it("writes one line per module and merges disjoint module edits without conflicts", () => {
const modules = rendered.baseline.modules;
const left = modules[0];
const right = modules.at(-1);
expect(left?.exports.length).toBeGreaterThan(0);
expect(right?.exports.length).toBeGreaterThan(0);
expect(left?.entrypoint).not.toBe(right?.entrypoint);
const editModule = (target: typeof left, suffix: string) =>
renderPluginSdkApiBaselineModules(
modules.map((moduleSurface) =>
moduleSurface === target
? {
...moduleSurface,
exports: moduleSurface.exports.map((exportSurface, index) =>
index === 0
? {
...exportSurface,
declaration: `${exportSurface.declaration ?? ""} ${suffix}`,
}
: exportSurface,
),
}
: moduleSurface,
),
);
const ours = editModule(left, "left edit");
const theirs = editModule(right, "right edit");
const lines = rendered.jsonl
.trimEnd()
.split("\n")
.map((line) => JSON.parse(line));
expect(lines).toHaveLength(modules.length);
expect(lines.map((line) => line.importSpecifier)).toEqual(
modules.map((moduleSurface) => moduleSurface.importSpecifier),
);
expect(lines).toEqual(
expect.arrayContaining([
expect.objectContaining({ contentHash: expect.stringMatching(/^[a-f0-9]{64}$/u) }),
]),
);
const mergeDir = tempDirs.make("openclaw-plugin-sdk-api-merge-");
const basePath = path.join(mergeDir, "base.jsonl");
const oursPath = path.join(mergeDir, "ours.jsonl");
const theirsPath = path.join(mergeDir, "theirs.jsonl");
fs.writeFileSync(basePath, rendered.jsonl);
fs.writeFileSync(oursPath, ours.jsonl);
fs.writeFileSync(theirsPath, theirs.jsonl);
const merge = spawnSync("git", ["merge-file", "--stdout", oursPath, basePath, theirsPath], {
encoding: "utf8",
});
expect(merge.status, merge.stderr).toBe(0);
expect(merge.stdout).toContain(ours.jsonl.trimEnd().split("\n")[0]);
expect(merge.stdout).toContain(theirs.jsonl.trimEnd().split("\n").at(-1));
});
it("renders byte-identical JSONL deterministically", async () => {
+23 -28
View File
@@ -1,4 +1,5 @@
// API baseline helpers render public SDK exports for contract drift checks.
import { createHash } from "node:crypto";
import fs from "node:fs/promises";
import path from "node:path";
import { fileURLToPath } from "node:url";
@@ -349,35 +350,29 @@ function buildModuleSurface(params: {
};
}
function sha256(content: string): string {
return createHash("sha256").update(content, "utf8").digest("hex");
}
function buildJsonlLines(baseline: PluginSdkApiBaseline): string[] {
const lines: string[] = [];
for (const moduleSurface of baseline.modules) {
lines.push(
JSON.stringify({
category: moduleSurface.category,
entrypoint: moduleSurface.entrypoint,
importSpecifier: moduleSurface.importSpecifier,
recordType: "module",
}),
);
for (const exportSurface of moduleSurface.exports) {
lines.push(
JSON.stringify({
closureHash: exportSurface.closureHash,
declaration: exportSurface.declaration,
entrypoint: moduleSurface.entrypoint,
exportName: exportSurface.exportName,
importSpecifier: moduleSurface.importSpecifier,
kind: exportSurface.kind,
recordType: "export",
}),
);
}
}
return lines;
return baseline.modules.map((moduleSurface) => {
const contractSurface = {
category: moduleSurface.category,
entrypoint: moduleSurface.entrypoint,
exports: moduleSurface.exports.map((exportSurface) => ({
closureHash: exportSurface.closureHash,
declaration: exportSurface.declaration,
exportName: exportSurface.exportName,
kind: exportSurface.kind,
})),
importSpecifier: moduleSurface.importSpecifier,
};
return JSON.stringify({
contentHash: sha256(JSON.stringify(contractSurface)),
entrypoint: moduleSurface.entrypoint,
importSpecifier: moduleSurface.importSpecifier,
});
});
}
/** Render the current public SDK API baseline without writing generated artifacts. */