f6dad1950f
Project CI / AI game creator shell Rust smoke (pull_request) Has been cancelled
Project CI / Backend tests (pull_request) Has been cancelled
Project CI / AI game creator shell Rust crates (pull_request) Has been cancelled
Project CI / Native shell tests (pull_request) Has been cancelled
Project CI / Frontend tests (pull_request) Has been cancelled
Project CI / Repository checks (pull_request) Has been cancelled
Project CI / AI game creator shell web tests (pull_request) Has been cancelled
Project CI / AI game creator shell Rust lane 1/2 (pull_request) Has been cancelled
Project CI / AI game creator shell Rust lane 2/2 (pull_request) Has been cancelled
- start-tauri-dev.mjs 在前端与配套后端就绪后、spawn Tauri CLI 之前调用准备步骤,并保留依赖注入供入口测试断言顺序 - build-release.mjs 的 runTauriBuild 与既有 stageRuntime 并列调用 stageBundledResources,tauri build --no-bundle 仍不强制 staging - 声明新增 origin 字段:source 由准备步骤写,build 由构建脚本在产物生成后写;构建脚本删除 codex 与插件白名单的写入分支及 stage_plugin_file/copy_plugin_tree/copy_plugin_file,改为 stage_build_generated_plugin_payloads - 插件随包工作区改为与仓库源码逐文件比对(清单 + 逐文件 sha256 + 整树符号链接),实现移入 build_support/package_layout.rs 复用单测 - 上游原生包元数据改由准备步骤按声明校验,build_support/codex_package_metadata.rs 因失去调用方删除 - 准备步骤改为同步实现,dev 与发布入口可直接调用而无需子进程 - 测试:build-release 39 passed(新增 stage、bundled、build 顺序与 no-bundle 不 staging)、dev 入口 12 passed(新增准备步骤先于 CLI 启动)、准备步骤 9 passed、package_layout 36 passed - 文档:技术方案 §4.9 记录构建期写入边界,M1 里程碑标记完成,运维文档补入口接线与 resources/plugins 所有权,决策日志与排障经验同步
513 lines
16 KiB
JavaScript
Executable File
513 lines
16 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 { 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` 由声明与仓库源码就能生成(准备步骤负责),
|
||
// `build` 由构建期工具链产出(构建脚本在产物生成后负责)。
|
||
function expectOrigin(value, at) {
|
||
if (value !== 'source' && value !== 'build') {
|
||
fail(`${at} 必须是 source 或 build(实际 ${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);
|
||
return {
|
||
plugin: expectString(parsed.plugin, `${at}.plugin`),
|
||
sourceSubdirectory: expectString(
|
||
parsed.sourceSubdirectory,
|
||
`${at}.sourceSubdirectory`,
|
||
),
|
||
targets: expectStringArray(parsed.targets, `${at}.targets`),
|
||
features: expectStringArray(parsed.features, `${at}.features`),
|
||
layout: expectString(parsed.layout, `${at}.layout`),
|
||
};
|
||
});
|
||
|
||
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 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;
|
||
}
|