Files
Genarrative/apps/ai-game-creator-shell/scripts/check-package-layout.mjs
T
suzmii d2d78fa3d4
Project CI / AI game creator shell Rust crates (pull_request) Successful in 1m34s
Project CI / Backend tests (pull_request) Failing after 16s
Project CI / AI game creator shell Rust lane 1/2 (pull_request) Failing after 2m3s
Project CI / AI game creator shell Rust smoke (pull_request) Successful in 2m7s
Project CI / Repository checks (pull_request) Failing after 25s
Project CI / Native shell tests (pull_request) Failing after 1m44s
Project CI / Frontend tests (pull_request) Successful in 2m12s
Project CI / AI game creator shell web tests (pull_request) Successful in 2m3s
Project CI / AI game creator shell Rust lane 2/2 (pull_request) Successful in 7m30s
修复AGC随包资源staging边界
修复 --no-bundle 构建前的资源准备接线。

拒绝插件随包目录中的未声明文件并同步Rust校验。

增加staging替换失败后的旧资源恢复与回归测试。
2026-09-30 19:12:30 +08:00

918 lines
30 KiB
JavaScript
Executable File
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/usr/bin/env node
// 随包资源声明门禁:把 build_support/package-layout.json(唯一人工声明)渲染成
// build_support/package-layout.generated.rs(Rust 编译期常量),并校验声明结构与不变量。
//
// 用法:
// node scripts/check-package-layout.mjs 校验生成结果是否与声明一致(不一致 exit 1)
// node scripts/check-package-layout.mjs --write 重新生成
//
// 设计约束:Rust 侧不解析 JSON(避免运行期解析与生命周期妥协),只使用本脚本产出的常量;
// Node 侧准备步骤直接读同一份 JSON。因此本门禁是“单一声明”的机械保障。
import { existsSync, readFileSync, writeFileSync } from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
const SCRIPT_DIR = path.dirname(fileURLToPath(import.meta.url));
const APP_ROOT = path.resolve(SCRIPT_DIR, '..');
const SRC_TAURI = path.join(APP_ROOT, 'src-tauri');
const DECLARATION_PATH = path.join(
SRC_TAURI,
'build_support/package-layout.json',
);
const GENERATED_PATH = path.join(
SRC_TAURI,
'build_support/package-layout.generated.rs',
);
const EXPECTED_SCHEMA = 'agc-package-layout.v1';
class DeclarationError extends Error {}
function fail(message) {
throw new DeclarationError(message);
}
function expectObject(value, at) {
if (value === null || typeof value !== 'object' || Array.isArray(value)) {
fail(`${at} 必须是对象`);
}
return value;
}
function expectArray(value, at) {
if (!Array.isArray(value)) {
fail(`${at} 必须是数组`);
}
return value;
}
function expectString(value, at) {
if (typeof value !== 'string' || value.length === 0) {
fail(`${at} 必须是非空字符串`);
}
return value;
}
function expectBoolean(value, at) {
if (typeof value !== 'boolean') {
fail(`${at} 必须是布尔值`);
}
return value;
}
function expectStringArray(value, at) {
return expectArray(value, at).map((item, index) =>
expectString(item, `${at}[${index}]`),
);
}
function optionalStringArray(source, key, at) {
if (source[key] === undefined) {
return [];
}
return expectStringArray(source[key], `${at}.${key}`);
}
function expectInteger(value, at) {
if (!Number.isInteger(value) || value < 0) {
fail(`${at} 必须是非负整数`);
}
return value;
}
// 随包子目录来源:`source` 直接来自仓库源码(准备步骤复制),
// `prepared` 需要先由准备步骤运行声明的构建命令产出,再复制进随包目录。
function expectOrigin(value, at) {
if (value !== 'source' && value !== 'prepared') {
fail(`${at} 必须是 source 或 prepared(实际 ${String(value)})`);
}
return value;
}
// JSON 字符串字面量与 Rust 字符串字面量几乎一致,唯一差异是控制字符的 \uXXXX 与 \u{XX}。
function rustString(value) {
return JSON.stringify(value).replace(/\\u([0-9a-fA-F]{4})/g, '\\u{$1}');
}
function rustStrings(values) {
return `&[${values.map(rustString).join(', ')}]`;
}
function indent(level) {
return ' '.repeat(level);
}
function renderStruct(name, fields, level = 0) {
const body = fields
.map(([key, rendered]) => `${indent(level + 1)}${key}: ${rendered},`)
.join('\n');
return `${name} {\n${body}\n${indent(level)}}`;
}
function parsePackageMetadata(value) {
const metadata = expectObject(value, 'codex.packageMetadata');
return {
layoutVersion: expectInteger(
metadata.layoutVersion,
'codex.packageMetadata.layoutVersion',
),
resourcesDir: expectString(
metadata.resourcesDir,
'codex.packageMetadata.resourcesDir',
),
pathDir: expectString(metadata.pathDir, 'codex.packageMetadata.pathDir'),
};
}
function parseDeclaration(raw) {
const root = expectObject(JSON.parse(raw), 'root');
if (root.schema !== EXPECTED_SCHEMA) {
fail(
`不支持的声明 schema:${String(root.schema)}(期望 ${EXPECTED_SCHEMA})`,
);
}
const layoutVersion = expectInteger(root.layoutVersion, 'layoutVersion');
const codex = expectObject(root.codex, 'codex');
const targets = expectArray(codex.targets, 'codex.targets').map(
(entry, index) => {
const at = `codex.targets[${index}]`;
const parsed = expectObject(entry, at);
const files = expectStringArray(parsed.files, `${at}.files`);
if (files.length === 0) {
fail(`${at}.files 不能为空`);
}
const executable = expectString(parsed.executable, `${at}.executable`);
if (!files.includes(executable)) {
fail(`${at}.executable 必须属于组件白名单:${executable}`);
}
return {
target: expectString(parsed.target, `${at}.target`),
platform: expectString(parsed.platform, `${at}.platform`),
directory: expectString(parsed.directory, `${at}.directory`),
executable,
files,
};
},
);
const seenTargets = new Set();
for (const entry of targets) {
if (seenTargets.has(entry.target)) {
fail(`codex.targets 重复声明目标 ${entry.target}`);
}
seenTargets.add(entry.target);
}
const noticeSources = expectArray(
codex.noticeSources,
'codex.noticeSources',
).map((entry, index) => {
const at = `codex.noticeSources[${index}]`;
const parsed = expectObject(entry, at);
return {
targets: expectStringArray(parsed.targets, `${at}.targets`),
source: expectString(parsed.source, `${at}.source`),
preserve: expectBoolean(parsed.preserve, `${at}.preserve`),
};
});
for (const entry of targets) {
const covered = noticeSources.filter((notice) =>
notice.targets.includes(entry.target),
);
if (covered.length !== 1) {
fail(
`目标 ${entry.target} 必须且只能有一条第三方声明来源(实际 ${covered.length} 条)`,
);
}
}
const universalGroups = expectArray(
codex.universalGroups,
'codex.universalGroups',
).map((entry, index) => {
const at = `codex.universalGroups[${index}]`;
const parsed = expectObject(entry, at);
const groupTargets = expectStringArray(parsed.targets, `${at}.targets`);
for (const target of groupTargets) {
if (!seenTargets.has(target)) {
fail(`${at}.targets 含未声明目标 ${target}`);
}
}
return {
name: expectString(parsed.name, `${at}.name`),
directory: expectString(parsed.directory, `${at}.directory`),
targets: groupTargets,
};
});
const plugins = expectObject(root.plugins, 'plugins');
const subdirectories = expectArray(
plugins.subdirectories,
'plugins.subdirectories',
).map((entry, index) => {
const at = `plugins.subdirectories[${index}]`;
const parsed = expectObject(entry, at);
return {
path: expectString(parsed.path, `${at}.path`),
origin: expectOrigin(parsed.origin, `${at}.origin`),
plugin:
parsed.plugin === undefined
? ''
: expectString(parsed.plugin, `${at}.plugin`),
targetContains: optionalStringArray(parsed, 'targetContains', at),
targets: optionalStringArray(parsed, 'targets', at),
features: optionalStringArray(parsed, 'features', at),
};
});
const libraryStaging = expectArray(
plugins.libraryStaging,
'plugins.libraryStaging',
).map((entry, index) => {
const at = `plugins.libraryStaging[${index}]`;
const parsed = expectObject(entry, at);
const files = expectStringArray(parsed.files, `${at}.files`);
if (files.length === 0) {
fail(`${at}.files 不能为空`);
}
return {
plugin: expectString(parsed.plugin, `${at}.plugin`),
sourceSubdirectory: expectString(
parsed.sourceSubdirectory,
`${at}.sourceSubdirectory`,
),
prepare: expectString(parsed.prepare, `${at}.prepare`),
targets: expectStringArray(parsed.targets, `${at}.targets`),
features: expectStringArray(parsed.features, `${at}.features`),
layout: expectString(parsed.layout, `${at}.layout`),
files,
};
});
const claudeAgent = expectObject(root.claudeAgent, 'claudeAgent');
const claudeAgentTargets = expectArray(
claudeAgent.targets,
'claudeAgent.targets',
).map((entry, index) => {
const at = `claudeAgent.targets[${index}]`;
const parsed = expectObject(entry, at);
return {
target: expectString(parsed.target, `${at}.target`),
runtimePackage: expectString(
parsed.runtimePackage,
`${at}.runtimePackage`,
),
runtimeFileName: expectString(
parsed.runtimeFileName,
`${at}.runtimeFileName`,
),
};
});
const seenClaudeAgentTargets = new Set();
for (const entry of claudeAgentTargets) {
if (seenClaudeAgentTargets.has(entry.target)) {
fail(`claudeAgent.targets 重复声明目标 ${entry.target}`);
}
seenClaudeAgentTargets.add(entry.target);
}
const sdkPackageName = expectString(
claudeAgent.sdkPackageName,
'claudeAgent.sdkPackageName',
);
if (!/^@[^/]+\/[^/]+$/u.test(sdkPackageName)) {
fail(`claudeAgent.sdkPackageName 必须是带 scope 的包名:${sdkPackageName}`);
}
for (const entry of claudeAgentTargets) {
// 原生运行时包与 SDK 同处一个 scope 目录下,包名必须是裸名。
if (entry.runtimePackage.includes('/')) {
fail(
`claudeAgent.targets 的 runtimePackage 必须是裸包名:${entry.runtimePackage}`,
);
}
}
return {
layoutVersion,
codex: {
version: expectString(codex.version, 'codex.version'),
cliVersionPrefix: expectString(
codex.cliVersionPrefix,
'codex.cliVersionPrefix',
),
manifestSchema: expectString(
codex.manifestSchema,
'codex.manifestSchema',
),
packageMetadata: parsePackageMetadata(codex.packageMetadata),
resourceDirectory: expectString(
codex.resourceDirectory,
'codex.resourceDirectory',
),
manifestFileName: expectString(
codex.manifestFileName,
'codex.manifestFileName',
),
packageMetadataFileName: expectString(
codex.packageMetadataFileName,
'codex.packageMetadataFileName',
),
noticeFileName: expectString(
codex.noticeFileName,
'codex.noticeFileName',
),
sourceRoots: expectStringArray(codex.sourceRoots, 'codex.sourceRoots'),
sourceRelativePaths: expectStringArray(
codex.sourceRelativePaths,
'codex.sourceRelativePaths',
),
noticeSources,
universalGroups,
targets,
},
claudeAgent: {
version: expectString(claudeAgent.version, 'claudeAgent.version'),
resourceDirectory: assertRelativePath(
expectString(
claudeAgent.resourceDirectory,
'claudeAgent.resourceDirectory',
),
'claudeAgent.resourceDirectory',
),
entryRelativePath: assertRelativePath(
expectString(
claudeAgent.entryRelativePath,
'claudeAgent.entryRelativePath',
),
'claudeAgent.entryRelativePath',
),
entryFileName: expectString(
claudeAgent.entryFileName,
'claudeAgent.entryFileName',
),
nodeModulesDirectory: expectString(
claudeAgent.nodeModulesDirectory,
'claudeAgent.nodeModulesDirectory',
),
sdkPackageName,
sdkEntryFileName: expectString(
claudeAgent.sdkEntryFileName,
'claudeAgent.sdkEntryFileName',
),
sdkPackageMetadataFileName: expectString(
claudeAgent.sdkPackageMetadataFileName,
'claudeAgent.sdkPackageMetadataFileName',
),
sourceRoots: expectStringArray(
claudeAgent.sourceRoots,
'claudeAgent.sourceRoots',
),
skipDirectoryNames: expectStringArray(
claudeAgent.skipDirectoryNames,
'claudeAgent.skipDirectoryNames',
),
skipDirectoryNamePrefixes: expectStringArray(
claudeAgent.skipDirectoryNamePrefixes,
'claudeAgent.skipDirectoryNamePrefixes',
),
skipFileNamePrefixes: expectStringArray(
claudeAgent.skipFileNamePrefixes,
'claudeAgent.skipFileNamePrefixes',
),
skipFileNameFragments: expectStringArray(
claudeAgent.skipFileNameFragments,
'claudeAgent.skipFileNameFragments',
),
targets: claudeAgentTargets,
},
plugins: {
sourceDirectory: expectString(
plugins.sourceDirectory,
'plugins.sourceDirectory',
),
destinationDirectory: expectString(
plugins.destinationDirectory,
'plugins.destinationDirectory',
),
manifestFileName: expectString(
plugins.manifestFileName,
'plugins.manifestFileName',
),
targetContainsAny: expectStringArray(
plugins.targetContainsAny,
'plugins.targetContainsAny',
),
subdirectories,
libraryStaging,
skipDirectoryNames: expectStringArray(
plugins.skipDirectoryNames,
'plugins.skipDirectoryNames',
),
skipDirectoryNamePrefixes: expectStringArray(
plugins.skipDirectoryNamePrefixes,
'plugins.skipDirectoryNamePrefixes',
),
skipFileNamePrefixes: expectStringArray(
plugins.skipFileNamePrefixes,
'plugins.skipFileNamePrefixes',
),
skipFileNameFragments: expectStringArray(
plugins.skipFileNameFragments,
'plugins.skipFileNameFragments',
),
},
};
}
function renderCodexTarget(entry) {
return renderStruct(
'CodexTarget',
[
['target', rustString(entry.target)],
['platform', rustString(entry.platform)],
['directory', rustString(entry.directory)],
['executable', rustString(entry.executable)],
['files', rustStrings(entry.files)],
],
1,
);
}
function renderNoticeSource(entry) {
return renderStruct(
'NoticeSource',
[
['targets', rustStrings(entry.targets)],
['source', rustString(entry.source)],
['preserve', entry.preserve ? 'true' : 'false'],
],
1,
);
}
function renderUniversalGroup(entry) {
return renderStruct(
'UniversalGroup',
[
['name', rustString(entry.name)],
['directory', rustString(entry.directory)],
['targets', rustStrings(entry.targets)],
],
1,
);
}
function renderSubdirectory(entry) {
return renderStruct(
'Subdirectory',
[
['path', rustString(entry.path)],
['plugin', rustString(entry.plugin ?? '')],
['origin', rustString(entry.origin)],
['target_contains', rustStrings(entry.targetContains)],
['targets', rustStrings(entry.targets)],
['features', rustStrings(entry.features)],
],
1,
);
}
function renderLibraryStaging(entry) {
return renderStruct(
'LibraryStaging',
[
['plugin', rustString(entry.plugin)],
['source_subdirectory', rustString(entry.sourceSubdirectory)],
['targets', rustStrings(entry.targets)],
['features', rustStrings(entry.features)],
['layout', rustString(entry.layout)],
['files', rustStrings(entry.files)],
],
1,
);
}
function renderClaudeAgentTarget(entry) {
return renderStruct(
'ClaudeAgentTarget',
[
['target', rustString(entry.target)],
['runtime_package', rustString(entry.runtimePackage)],
['runtime_file_name', rustString(entry.runtimeFileName)],
],
1,
);
}
function renderGenerated(declaration) {
const codex = declaration.codex;
const godotStaging = declaration.plugins.libraryStaging.find(
(entry) => entry.layout === 'godot-bundle',
);
if (!godotStaging || godotStaging.files.length === 0) {
fail('声明缺少 godot-bundle 随包文件清单,拒绝生成空清单');
}
const godotFiles = godotStaging.files;
const plugins = declaration.plugins;
const codexStruct = renderStruct('Codex', [
[
'package_metadata',
renderStruct('PackageMetadata', [
['layout_version', String(codex.packageMetadata.layoutVersion)],
['resources_dir', rustString(codex.packageMetadata.resourcesDir)],
['path_dir', rustString(codex.packageMetadata.pathDir)],
]),
],
['resource_directory', rustString(codex.resourceDirectory)],
['manifest_file_name', rustString(codex.manifestFileName)],
['package_metadata_file_name', rustString(codex.packageMetadataFileName)],
['notice_file_name', rustString(codex.noticeFileName)],
['source_roots', rustStrings(codex.sourceRoots)],
['source_relative_paths', rustStrings(codex.sourceRelativePaths)],
[
'notice_sources',
`&[\n${codex.noticeSources.map(renderNoticeSource).join(',\n')}\n]`,
],
[
'universal_groups',
`&[\n${codex.universalGroups.map(renderUniversalGroup).join(',\n')}\n]`,
],
['targets', `&[\n${codex.targets.map(renderCodexTarget).join(',\n')}\n]`],
]);
const claudeAgent = declaration.claudeAgent;
const claudeAgentStruct = renderStruct('ClaudeAgent', [
['version', rustString(claudeAgent.version)],
['resource_directory', rustString(claudeAgent.resourceDirectory)],
['entry_relative_path', rustString(claudeAgent.entryRelativePath)],
['entry_file_name', rustString(claudeAgent.entryFileName)],
['node_modules_directory', rustString(claudeAgent.nodeModulesDirectory)],
['sdk_package_name', rustString(claudeAgent.sdkPackageName)],
['sdk_entry_file_name', rustString(claudeAgent.sdkEntryFileName)],
[
'sdk_package_metadata_file_name',
rustString(claudeAgent.sdkPackageMetadataFileName),
],
[
'targets',
`&[\n${claudeAgent.targets.map(renderClaudeAgentTarget).join(',\n')}\n]`,
],
]);
const pluginsStruct = renderStruct('Plugins', [
['source_directory', rustString(plugins.sourceDirectory)],
['destination_directory', rustString(plugins.destinationDirectory)],
['manifest_file_name', rustString(plugins.manifestFileName)],
['target_contains_any', rustStrings(plugins.targetContainsAny)],
[
'subdirectories',
`&[\n${plugins.subdirectories.map(renderSubdirectory).join(',\n')}\n]`,
],
[
'library_staging',
`&[\n${plugins.libraryStaging.map(renderLibraryStaging).join(',\n')}\n]`,
],
['skip_directory_names', rustStrings(plugins.skipDirectoryNames)],
[
'skip_directory_name_prefixes',
rustStrings(plugins.skipDirectoryNamePrefixes),
],
['skip_file_name_prefixes', rustStrings(plugins.skipFileNamePrefixes)],
['skip_file_name_fragments', rustStrings(plugins.skipFileNameFragments)],
]);
return `// @generated by apps/ai-game-creator-shell/scripts/check-package-layout.mjs
// 来源:build_support/package-layout.json。不要手工编辑本文件。
// 修改随包资源布局请编辑声明文件,然后运行
// npm run agc:bundled-resources:sync(在仓库根目录)
// 门禁会校验两者一致(npm run agc:typecheck 链内含 check-package-layout.mjs)。
pub const DECLARATION_SCHEMA: &str = ${rustString(EXPECTED_SCHEMA)};
pub const LAYOUT_VERSION: u64 = ${declaration.layoutVersion};
pub const CODEX_VERSION: &str = ${rustString(declaration.codex.version)};
pub const CODEX_CLI_VERSION: &str = ${rustString(declaration.codex.cliVersionPrefix + declaration.codex.version)};
pub const CODEX_MANIFEST_SCHEMA: &str = ${rustString(declaration.codex.manifestSchema)};
pub const CLAUDE_AGENT_SDK_VERSION: &str = ${rustString(declaration.claudeAgent.version)};
pub const GODOT_BUNDLE_FILES: &[&str] = ${rustStrings(godotFiles)};
pub const CODEX: Codex = ${codexStruct};
pub const CLAUDE_AGENT: ClaudeAgent = ${claudeAgentStruct};
pub const PLUGINS: Plugins = ${pluginsStruct};
`;
}
function appLockedCodexVersion() {
const appPackage = expectObject(
JSON.parse(readFileSync(path.join(APP_ROOT, 'package.json'), 'utf8')),
'apps/ai-game-creator-shell/package.json',
);
const declared =
appPackage.dependencies?.['@openai/codex'] ??
appPackage.devDependencies?.['@openai/codex'];
if (typeof declared !== 'string' || declared.length === 0) {
fail(
'应用 package.json 未声明 @openai/codex,无法校验声明的 codex.version',
);
}
return declared.replace(/^[\^~]/, '');
}
/// Claude Agent SDK 有两个锁点:客户端依赖与 sidecar 自己的依赖。两处都必须与声明一致,
/// 否则随包的 SDK 与运行它的 sidecar 会悄悄分叉。
function lockedClaudeAgentVersions() {
const manifests = [
[
'apps/ai-game-creator-shell/package.json',
path.join(APP_ROOT, 'package.json'),
],
[
'apps/ai-game-creator-shell/agent-sidecar/package.json',
path.join(APP_ROOT, 'agent-sidecar', 'package.json'),
],
];
return manifests.map(([label, file]) => {
const manifest = expectObject(
JSON.parse(readFileSync(file, 'utf8')),
label,
);
const declared = manifest.dependencies?.['@anthropic-ai/claude-agent-sdk'];
if (typeof declared !== 'string' || declared.length === 0) {
fail(
`${label} 未声明 @anthropic-ai/claude-agent-sdk,无法校验声明的 claudeAgent.version`,
);
}
return declared.replace(/^[\^~]/, '');
});
}
const REPO_ROOT = path.resolve(APP_ROOT, '..', '..');
const PLUGINS_ROOT = path.join(REPO_ROOT, 'plugins');
const SHELL_MANIFEST_PATH = path.join(SRC_TAURI, 'Cargo.toml');
/// 声明里的路径必须是仓库内相对路径:绝对路径或含 `..` 会写到工作区之外。
function assertRelativePath(value, at) {
if (
path.isAbsolute(value) ||
value.startsWith('/') ||
value.split('/').includes('..')
) {
fail(`${at} 必须是仓库内相对路径(不允许绝对路径或 ..):${value}`);
}
return value;
}
/// 声明引用的插件必须是真实存在的插件目录(拼错插件的后果是静默不交付)。
function assertKnownPlugin(name, at) {
const directory = path.join(PLUGINS_ROOT, name);
if (!existsSync(path.join(directory, 'plugin.json'))) {
fail(`${at} 指向的插件不存在或缺少 plugin.json:${name}`);
}
return name;
}
/// shell crate 的 feature 表(拼错 feature 的后果同样是静默不交付)。
function shellCrateFeatures() {
const manifest = readFileSync(SHELL_MANIFEST_PATH, 'utf8');
const section = /\[features\]([\s\S]*?)(?:\n\[|$)/u.exec(manifest);
if (!section) {
fail('无法从 src-tauri/Cargo.toml 读取 [features] 段');
}
return new Set(
section[1]
.split('\n')
.map((line) => /^([A-Za-z0-9_-]+)\s*=/u.exec(line.trim())?.[1])
.filter(Boolean),
);
}
/// 新 section(prepareSteps / nativePayloads / prepared 来源)不参与 Rust 生成物,
/// 必须在门禁里单独把关,避免「声明了却没人用」或引用到不存在的准备步骤。
function validateExtendedDeclarations() {
const root = expectObject(
JSON.parse(readFileSync(DECLARATION_PATH, 'utf8')),
'root',
);
const plugins = expectObject(root.plugins, 'plugins');
const steps = expectArray(plugins.prepareSteps ?? [], 'plugins.prepareSteps');
const names = new Set();
for (const [index, raw] of steps.entries()) {
const at = `plugins.prepareSteps[${index}]`;
const step = expectObject(raw, at);
const name = expectString(step.name, `${at}.name`);
if (names.has(name)) {
fail(`${at}.name 重复:${name}`);
}
names.add(name);
const kind = expectString(step.kind, `${at}.kind`);
if (kind !== 'powershell' && kind !== 'cargo') {
fail(`${at}.kind 只能是 powershell 或 cargo`);
}
if (kind === 'powershell') {
expectString(step.workingDirectory, `${at}.workingDirectory`);
expectString(step.scriptFileName, `${at}.scriptFileName`);
} else {
expectString(step.packageDirectory, `${at}.packageDirectory`);
}
expectStringArray(step.requiredOutputs ?? [], `${at}.requiredOutputs`);
}
const payloads = expectArray(
plugins.nativePayloads ?? [],
'plugins.nativePayloads',
);
for (const [index, raw] of payloads.entries()) {
const at = `plugins.nativePayloads[${index}]`;
const payload = expectObject(raw, at);
expectString(payload.plugin, `${at}.plugin`);
expectString(payload.prepare, `${at}.prepare`);
expectString(payload.sourceFileName, `${at}.sourceFileName`);
expectString(payload.destinationFileName, `${at}.destinationFileName`);
expectString(
payload.destinationSubdirectory,
`${at}.destinationSubdirectory`,
);
if (!names.has(payload.prepare)) {
fail(`${at}.prepare 引用了未声明的准备步骤:${payload.prepare}`);
}
}
const preparedByPlugin = new Map();
for (const entry of expectArray(
plugins.subdirectories ?? [],
'plugins.subdirectories',
)) {
const subdirectory = expectObject(entry, 'subdirectory');
if (subdirectory.origin !== 'prepared') {
continue;
}
const owner = expectString(
subdirectory.plugin ?? '',
'subdirectory.plugin',
);
if (!owner) {
fail(`origin=prepared 的子目录必须声明 plugin:${subdirectory.path}`);
}
preparedByPlugin.set(owner, [
...(preparedByPlugin.get(owner) ?? []),
subdirectory.path,
]);
}
for (const payload of payloads) {
const candidates = preparedByPlugin.get(payload.plugin) ?? [];
if (!candidates.includes(payload.destinationSubdirectory)) {
fail(
`nativePayloads 的 destinationSubdirectory(${payload.destinationSubdirectory})必须是该插件 origin=prepared 的子目录之一(现有:${candidates.join('、') || '无'})`,
);
}
}
const knownFeatures = shellCrateFeatures();
const checkFeatures = (values, at) => {
for (const name of expectStringArray(values ?? [], at)) {
if (!knownFeatures.has(name)) {
fail(`${at} 引用了 Cargo.toml 里不存在的 feature:${name}`);
}
}
};
for (const [index, raw] of expectArray(
plugins.subdirectories ?? [],
'plugins.subdirectories',
).entries()) {
const at = `plugins.subdirectories[${index}]`;
const subdirectory = expectObject(raw, at);
assertRelativePath(
expectString(subdirectory.path, `${at}.path`),
`${at}.path`,
);
if (subdirectory.plugin) {
assertKnownPlugin(subdirectory.plugin, `${at}.plugin`);
}
checkFeatures(subdirectory.features, `${at}.features`);
}
for (const [index, raw] of expectArray(
plugins.libraryStaging ?? [],
'plugins.libraryStaging',
).entries()) {
const at = `plugins.libraryStaging[${index}]`;
const staging = expectObject(raw, at);
assertKnownPlugin(
expectString(staging.plugin, `${at}.plugin`),
`${at}.plugin`,
);
assertRelativePath(
expectString(staging.sourceSubdirectory, `${at}.sourceSubdirectory`),
`${at}.sourceSubdirectory`,
);
for (const relative of expectStringArray(
staging.files ?? [],
`${at}.files`,
)) {
assertRelativePath(relative, `${at}.files`);
}
checkFeatures(staging.features, `${at}.features`);
}
for (const payload of payloads) {
assertRelativePath(
payload.destinationSubdirectory,
`nativePayloads.${payload.plugin}.destinationSubdirectory`,
);
checkFeatures(
payload.features,
`nativePayloads.${payload.plugin}.features`,
);
}
for (const [index, raw] of steps.entries()) {
const at = `plugins.prepareSteps[${index}]`;
const step = expectObject(raw, at);
for (const key of ['workingDirectory', 'packageDirectory']) {
if (step[key]) {
assertRelativePath(
expectString(step[key], `${at}.${key}`),
`${at}.${key}`,
);
}
}
for (const relative of expectStringArray(
step.requiredOutputs ?? [],
`${at}.requiredOutputs`,
)) {
assertRelativePath(relative, `${at}.requiredOutputs`);
}
}
const referenced = [
...expectArray(plugins.subdirectories ?? [], 'plugins.subdirectories')
.filter(
(entry) => expectObject(entry, 'subdirectory').origin === 'prepared',
)
.map((entry) => [entry.path, entry.prepare]),
...expectArray(plugins.libraryStaging ?? [], 'plugins.libraryStaging').map(
(entry) => [entry.sourceSubdirectory, entry.prepare],
),
...payloads.map((entry) => [
`${entry.plugin}/${entry.destinationSubdirectory}`,
entry.prepare,
]),
];
for (const [label, prepare] of referenced) {
if (!prepare) {
fail(`prepared 来源的条目缺少 prepare 声明:${label}`);
}
if (!names.has(prepare)) {
fail(`${label} 引用了未声明的准备步骤:${prepare}`);
}
}
}
function main() {
const declaration = parseDeclaration(readFileSync(DECLARATION_PATH, 'utf8'));
validateExtendedDeclarations();
const lockedVersion = appLockedCodexVersion();
if (lockedVersion !== declaration.codex.version) {
fail(
`声明 codex.version(${declaration.codex.version})与应用锁定的 @openai/codex(${lockedVersion})不一致;请同步更新 build_support/package-layout.json`,
);
}
for (const lockedClaudeVersion of lockedClaudeAgentVersions()) {
if (lockedClaudeVersion !== declaration.claudeAgent.version) {
fail(
`声明 claudeAgent.version(${declaration.claudeAgent.version})与锁定的 @anthropic-ai/claude-agent-sdk(${lockedClaudeVersion})不一致;请同步更新 build_support/package-layout.json`,
);
}
}
const rendered = renderGenerated(declaration);
const write = process.argv.includes('--write');
if (write) {
writeFileSync(GENERATED_PATH, rendered);
console.log(
`随包资源声明已生成:${path.relative(process.cwd(), GENERATED_PATH)}(layoutVersion ${declaration.layoutVersion})`,
);
return;
}
const current = readFileSync(GENERATED_PATH, 'utf8');
if (current !== rendered) {
console.error(
[
`随包资源声明与 Rust 常量不一致:`,
` 声明:${path.relative(process.cwd(), DECLARATION_PATH)}`,
` 生成:${path.relative(process.cwd(), GENERATED_PATH)}`,
'请运行:npm run agc:bundled-resources:sync',
].join('\n'),
);
process.exit(1);
}
console.log(
`随包资源声明一致(layoutVersion ${declaration.layoutVersion},codex ${declaration.codex.version},目标 ${declaration.codex.targets.length},插件子目录 ${declaration.plugins.subdirectories.length})`,
);
}
try {
main();
} catch (error) {
if (error instanceof DeclarationError) {
console.error(`随包资源声明无效:${error.message}`);
process.exit(1);
}
throw error;
}