Files
openclaw/scripts/check-protocol-registry.mjs
Peter Steinberger d5e46646f2 refactor(gateway-protocol): derive schema registries from owner fragments (#114817)
* refactor(protocol): share schema owner barrel

* refactor(protocol): extract ordered schema fragments

* refactor(protocol): compose public schema registry

* docs(protocol): document schema fragments

* ci(protocol): guard schema registry structure

* style(protocol): satisfy registry guard lint
2026-07-27 21:08:20 -04:00

155 lines
5.7 KiB
JavaScript

import fs from "node:fs";
import path from "node:path";
import { fileURLToPath, pathToFileURL } from "node:url";
const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
const schemaDir = path.join(repoRoot, "packages/gateway-protocol/src/schema");
const failures = [];
const read = (relativePath) => fs.readFileSync(path.join(repoRoot, relativePath), "utf8");
const check = (condition, message) => {
if (!condition) {
failures.push(message);
}
};
const registryPath = "packages/gateway-protocol/src/schema/protocol-schemas.ts";
const registrySource = read(registryPath);
const fragmentImports = [
...registrySource.matchAll(
/^import \{ ([A-Za-z0-9_]+) \} from "(\.\/protocol-schema-fragment-[^"]+\.js)";$/gmu,
),
].map((match) => ({ binding: match[1], specifier: match[2] }));
const importSpecifiers = [...registrySource.matchAll(/^import .* from "([^"]+)";$/gmu)].map(
(match) => match[1],
);
check(
importSpecifiers.every(
(specifier) =>
specifier === "./protocol-schema-composer.js" ||
specifier.startsWith("./protocol-schema-fragment-"),
),
`${registryPath} may import only the composer and schema fragments`,
);
check(
!/\b[A-Z][A-Za-z0-9]*Schema\b/u.test(registrySource),
`${registryPath} contains a direct *Schema inventory`,
);
const composition = registrySource.match(
/export const ProtocolSchemas = composeProtocolSchemaFragments\(\[([\s\S]*?)\]\s+as const\);/u,
);
const composedBindings = (composition?.[1] ?? "")
.split("\n")
.map((line) => line.trim().replace(/,$/u, ""))
.filter(Boolean);
const importedBindings = fragmentImports.map(({ binding }) => binding);
check(Boolean(composition), `${registryPath} must explicitly compose an ordered fragment array`);
check(
composedBindings.length === importedBindings.length &&
new Set(composedBindings).size === composedBindings.length &&
importedBindings.every((binding) => composedBindings.includes(binding)),
`${registryPath} must compose every imported fragment exactly once`,
);
const fragmentFiles = fs
.readdirSync(schemaDir)
.filter((name) => /^protocol-schema-fragment-.+\.ts$/u.test(name));
const importedFiles = fragmentImports.map(({ specifier }) => `${specifier.slice(2, -3)}.ts`);
check(
fragmentFiles.length === importedFiles.length &&
fragmentFiles.every((name) => importedFiles.includes(name)),
`${registryPath} must explicitly import every protocol schema fragment`,
);
const importsByBinding = new Map(
fragmentImports.map((fragmentImport) => [fragmentImport.binding, fragmentImport]),
);
const seenKeys = new Set();
const orderedKeys = [];
for (const binding of composedBindings) {
const { specifier } = importsByBinding.get(binding) ?? {};
if (!specifier) {
continue;
}
const moduleUrl = new URL(specifier.replace(/\.js$/u, ".ts"), pathToFileURL(registryPath));
const fragment = (await import(moduleUrl.href))[binding];
check(fragment && typeof fragment === "object", `${specifier} must export object ${binding}`);
if (!fragment || typeof fragment !== "object") {
continue;
}
for (const key of Object.keys(fragment)) {
check(!seenKeys.has(key), `duplicate protocol schema key ${key}`);
seenKeys.add(key);
orderedKeys.push(key);
}
}
const { ProtocolSchemas } = await import(
pathToFileURL(path.join(schemaDir, "protocol-schemas.ts"))
);
check(
JSON.stringify(Object.keys(ProtocolSchemas)) === JSON.stringify(orderedKeys),
"ProtocolSchemas must preserve explicit fragment/key order",
);
const composerSource = read("packages/gateway-protocol/src/schema/protocol-schema-composer.ts");
check(!/\.(?:sort|toSorted)\s*\(/u.test(composerSource), "schema composer must not sort");
check(
composerSource.includes("Object.hasOwn(registry, key)"),
"schema composer must reject duplicate fragment keys",
);
const withoutComments = (source) =>
source
.replace(/\r\n?/gu, "\n")
.replace(/\/\*[\s\S]*?\*\//gu, "")
.replace(/^\s*\/\/.*$/gmu, "")
.trim();
const schemaModulesSource = withoutComments(
read("packages/gateway-protocol/src/schema-modules.ts"),
);
const ownerModules = [
...schemaModulesSource.matchAll(/^export \* from "\.\/schema\/([^"]+)\.js";$/gmu),
].map((match) => match[1]);
check(
ownerModules.length === 51 && new Set(ownerModules).size === ownerModules.length,
"schema-modules.ts must contain one unique 51-module owner list",
);
check(
schemaModulesSource.split("\n").filter(Boolean).length === ownerModules.length,
"schema-modules.ts may contain only owner-module exports",
);
check(
withoutComments(read("packages/gateway-protocol/src/schema.ts")) ===
'export * from "./schema-modules.js";\nexport * from "./schema/protocol-schemas.js";',
"schema.ts must remain a schema-modules/protocol-schemas wrapper",
);
check(
withoutComments(read("packages/gateway-protocol/src/schema-types.ts")) ===
'export type * from "./schema-modules.js";',
"schema-types.ts must remain a registry-free schema-modules wrapper",
);
for (const relativePath of [
"packages/gateway-protocol/src/index.ts",
"packages/gateway-protocol/src/schema-export-registry.ts",
"packages/gateway-protocol/src/validator-registry.ts",
]) {
check(
!read(relativePath).includes('from "./schema.js"'),
`${relativePath} must not cross the registry through schema.ts`,
);
}
const pluginSdkGuard = read("scripts/check-plugin-sdk-exports.mjs");
check(
pluginSdkGuard.includes("FORBIDDEN_PUBLIC_PROTOCOL_REGISTRY_RE") &&
pluginSdkGuard.includes("FORBIDDEN PUBLIC DTS REGISTRY"),
"plugin SDK declaration checks must reject leaked ProtocolSchemas declarations",
);
if (failures.length) {
throw new Error(
failures.map((failure) => `protocol registry check failed: ${failure}`).join("\n"),
);
}
console.log("protocol registry check passed");