Files
Genarrative/apps/ai-game-creator-shell/scripts/check-package-layout.mjs
T
suzmii 547fb0dbce
Project CI / AI game creator shell Rust crates (pull_request) Successful in 1m23s
Project CI / AI game creator shell Rust smoke (pull_request) Successful in 2m1s
Project CI / Backend tests (pull_request) Successful in 3m46s
Project CI / Frontend tests (pull_request) Successful in 1m53s
Project CI / Native shell tests (pull_request) Successful in 5m46s
Project CI / AI game creator shell Rust lane 2/2 (pull_request) Successful in 8m51s
Project CI / Repository checks (pull_request) Successful in 1m57s
Project CI / AI game creator shell web tests (pull_request) Successful in 1m26s
Project CI / AI game creator shell Rust lane 1/2 (pull_request) Successful in 9m39s
按第三轮复测修复 M3:dry-run 零写入、所有权先行、指纹与门禁补口
- P2:preparePlugins 重排——dry-run 只做只读判断即返回,预缓存交付不再排在 dry-run 之前;所有权断言提到任何写入之前
- P3:pluginSourceFingerprint 放开 origin=source 过滤(prepared 内容哈希分支可达),库文件与 payload 交付态纳入指纹,pluginTreeMatches 增加「不该存在却存在 → 需要重建」判定
- P3:门禁交叉校验 nativePayloads[].destinationSubdirectory 必须是同插件 origin=prepared 的子目录之一
- P2:里程碑验收清单注明 Cocos payload 只在 injection 构建交付并给出对应命令;dry-run 用例补 injection feature 覆盖
- 夹具补齐 agc-cocos-editor 插件(所有权检查要求目标插件真实存在)
- 验证:工具用例 13/13、声明门禁通过
- 已知缺口:injection 构建后切回默认 feature,随包目录里陈旧 payload 的清理尚未生效(已记在 PR)
2026-09-28 01:03:40 +08:00

637 lines
21 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`),
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: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(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(/^[\^~]/, '');
}
/// 新 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 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: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;
}