Files
Genarrative/apps/ai-game-creator-shell/scripts/check-package-layout.mjs
T
suzmii 3b4f9b2e10
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 1m55s
Project CI / Native shell tests (pull_request) Failing after 1m26s
Project CI / Frontend tests (pull_request) Successful in 1m36s
Project CI / Backend tests (pull_request) Successful in 3m37s
Project CI / AI game creator shell web tests (pull_request) Failing after 34s
Project CI / Repository checks (pull_request) Successful in 1m49s
Project CI / AI game creator shell Rust lane 2/2 (pull_request) Successful in 7m37s
Project CI / AI game creator shell Rust lane 1/2 (pull_request) Successful in 8m19s
按系统性审计修复 M3:指纹反判断、守卫死代码、fail-open 与卫生项
- P1-1:pluginSourceFingerprint 的判断取反(prepared 内容与交付态真正进入指纹);pluginTreeMatches 的「当前目标/feature 下不该存在却存在 → 重建」从死代码修活
- P1-7:package_layout 的 `sha256_file(x).ok() != ...` 改为 map_err 传播,两侧读失败不再被判为「相等」
- P3-2:copyNativePayloads 的 'plugins' 字面量改由声明 sourceDirectory 派生
- P3-5:payload 交付内容一致时跳过写入(命中缓存保持零写入、时间戳稳定)
- P3-7:门禁提示里的脚本名改为 agc:bundled-resources:sync
- P3-8:删除已无写入方的 resources/cocos-editor-bridge 占位目录
- P2-4:root 包装脚本补结尾 `--`,文档里的 prepare 命令可直接复制执行
- 验证:工具用例全绿、声明门禁通过
2026-09-28 10:20:09 +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: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(/^[\^~]/, '');
}
/// 新 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: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;
}