acf021de2a
Project CI / AI game creator shell Rust crates (pull_request) Successful in 1m19s
Project CI / AI game creator shell Rust smoke (pull_request) Successful in 1m51s
Project CI / Native shell tests (pull_request) Failing after 1m10s
Project CI / Frontend tests (pull_request) Failing after 43s
Project CI / Backend tests (pull_request) Successful in 3m26s
Project CI / Repository checks (pull_request) Successful in 1m51s
Project CI / AI game creator shell web tests (pull_request) Failing after 1m29s
Project CI / AI game creator shell Rust lane 2/2 (pull_request) Successful in 8m33s
Project CI / AI game creator shell Rust lane 1/2 (pull_request) Successful in 8m34s
- 门禁新增交叉校验:plugin 必须指向真实插件目录、features 必须存在于 shell crate 的 [features]、所有声明路径必须是仓库内相对路径(拒绝绝对路径与 ..) - 准备步骤 CLI:--target/--destination/--features 缺值直接失败(不再静默回退宿主目标),--features 空值也拒绝 - 用例:fixture 补齐 agc-unity-editor/agc-godot-editor/agc-cocos-editor,新增「编辑器分支产物按 prepared 与 library staging 交付」用例;godot_bundle 补 validate 负例单测 - 运维文档:dev 构建下客户端读 target/debug/plugins/**,改资源要重跑准备步骤 - 未做(已按评估结论记录):P2-2 运行时常量锁定需要先把 nativePayloads 也 emit 进 Rust 声明,留作后续 - 验证:准备步骤 14/14、发布入口 39/39、dev 入口 12/12、godot_bundle 19 通过、声明门禁一致、cargo check 通过
751 lines
24 KiB
JavaScript
Executable File
751 lines
24 KiB
JavaScript
Executable File
#!/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,
|
||
};
|
||
});
|
||
|
||
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,
|
||
},
|
||
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)],
|
||
],
|
||
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 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 GODOT_BUNDLE_FILES: &[&str] = ${rustStrings(godotFiles)};
|
||
|
||
pub const CODEX: Codex = ${codexStruct};
|
||
|
||
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(/^[\^~]/, '');
|
||
}
|
||
|
||
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`,
|
||
);
|
||
}
|
||
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;
|
||
}
|