Files
Genarrative/apps/ai-game-creator-shell/scripts/check-package-layout.mjs
T
suzmii 44c5a69185
Project CI / AI game creator shell Rust crates (pull_request) Successful in 1m29s
Project CI / AI game creator shell Rust smoke (pull_request) Successful in 2m5s
Project CI / Backend tests (pull_request) Successful in 3m49s
Project CI / Frontend tests (pull_request) Successful in 1m46s
Project CI / Native shell tests (pull_request) Successful in 6m1s
Project CI / Repository checks (pull_request) Successful in 1m57s
Project CI / AI game creator shell Rust lane 2/2 (pull_request) Successful in 9m11s
Project CI / AI game creator shell web tests (pull_request) Successful in 1m27s
Project CI / AI game creator shell Rust lane 1/2 (pull_request) Successful in 9m53s
AGC 编辑器分支产物归位:构建脚本彻底退出写入
- 声明扩展:subdirectories 新增 origin=prepared;libraryStaging 增加 prepare/files;新增 nativePayloads 与 plugins.prepareSteps(程序类型、工作目录、指纹、必需产物)
- Godot 随包文件清单改由声明提供,godot_bundle::BUNDLE_FILES 从生成的编译期常量取值,不再各写一份
- 准备步骤按声明运行 powershell.exe -File build.ps1(Unity/Godot,Godot 移除 PSModulePath)与 cargo build -p cocos-editor-bridge --target … --features windows-injection,内容指纹命中且必需产物齐全时零写入,命令执行器可注入以便在 macOS 上覆盖调度与失败关闭
- build.rs 删除 prepare_unity_editor_helper、prepare_godot_editor_extension、stage_cocos_editor_payload、stage_build_generated_plugin_payloads 及辅助函数(415 → 193 行),只留只读校验,并新增已准备产物存在性与 Godot 随包库深度校验;AGC_SKIP_RESOURCE_STAGING 开关随写入分支删除
- 删除两份只含 staging 条目的 .taurignore;发布入口传 profile=release 以定位 Cocos 产物
- 文档:技术方案 §4.9/§8、M3 里程碑(含 Windows 验收清单)、运维文档、决策日志与排障经验同步
- 验证:准备步骤 13 条用例通过(三类准备步骤调度、指纹跳过、缺产物失败关闭、幂等);npm run agc:bundled-resources:check 通过;cargo check --no-default-features 通过且第二次 0.62 秒 fresh
- 未验证:Windows 真机(powershell/cargo 路径、产物归位、包内容一致性与客户端加载),按 M3 里程碑验收清单在 Windows 上确认
2026-09-27 23:33:48 +08:00

523 lines
16 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 { 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`),
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)],
['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 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:package-layout: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(
declaration.plugins.libraryStaging[0]?.files ?? [],
)};
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(/^[\^~]/, '');
}
function main() {
const declaration = parseDeclaration(readFileSync(DECLARATION_PATH, 'utf8'));
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:package-layout: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;
}