按评估结论落地:门禁交叉校验、CLI 缺值校验、编辑器分支覆盖与文档
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 通过
This commit is contained in:
2026-09-28 11:36:34 +08:00
parent 3fba76fd6e
commit acf021de2a
5 changed files with 201 additions and 5 deletions
@@ -9,7 +9,7 @@
// 设计约束:Rust 侧不解析 JSON(避免运行期解析与生命周期妥协),只使用本脚本产出的常量;
// Node 侧准备步骤直接读同一份 JSON。因此本门禁是“单一声明”的机械保障。
import { readFileSync, writeFileSync } from 'node:fs';
import { existsSync, readFileSync, writeFileSync } from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
@@ -487,6 +487,46 @@ function appLockedCodexVersion() {
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() {
@@ -566,6 +606,80 @@ function validateExtendedDeclarations() {
}
}
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(
@@ -1312,15 +1312,25 @@ export function prepareBundledResources({
return summaries;
}
function requireFlagValue(argv, index, flag) {
const value = argv[index + 1];
if (value === undefined || String(value).startsWith('--')) {
fail(
`${flag} 缺少取值(例如 ${flag} <value>);拒绝回退到宿主默认值,以免准备错目标`,
);
}
return String(value);
}
function parseArguments(argv) {
const args = { target: undefined, destinationRoot: undefined, dryRun: false };
for (let index = 0; index < argv.length; index += 1) {
const value = argv[index];
if (value === '--target') {
args.target = argv[index + 1];
args.target = requireFlagValue(argv, index, '--target');
index += 1;
} else if (value === '--destination') {
args.destinationRoot = argv[index + 1];
args.destinationRoot = requireFlagValue(argv, index, '--destination');
index += 1;
} else if (value === '--dry-run') {
args.dryRun = true;
@@ -140,6 +140,21 @@ function buildFixture({ targets = [WINDOWS_TARGET], plugins = true } = {}) {
fs.writeFileSync(path.join(pluginRoot, '.git/HEAD'), 'ref\n');
fs.writeFileSync(path.join(pluginRoot, '.env'), 'secret\n');
for (const name of [
'agc-cocos-editor',
'agc-unity-editor',
'agc-godot-editor',
]) {
const root = path.join(repoRoot, 'plugins', name);
fs.mkdirSync(path.join(root, 'src'), {
recursive: true,
});
fs.writeFileSync(path.join(root, 'plugin.json'), `{"name":"${name}"}\n`);
fs.writeFileSync(
path.join(root, 'src/entry.mjs'),
`export const ${name} = 1;\n`,
);
}
const cocosRoot = path.join(repoRoot, 'plugins/agc-cocos-editor');
fs.mkdirSync(path.join(cocosRoot, 'src'), { recursive: true });
fs.writeFileSync(
@@ -515,7 +530,7 @@ test('declaration drives source lookup and staging units', () => {
source,
/codex-win32-x64[\\/]vendor[\\/]x86_64-pc-windows-msvc$/u,
);
assert.equal(pluginDirectories(declaration, fixture.repoRoot).length, 2);
assert.equal(pluginDirectories(declaration, fixture.repoRoot).length, 4);
} finally {
fixture.cleanup();
}
@@ -605,3 +620,42 @@ test('fails closed when a prepare step does not produce its declared outputs', (
fixture.cleanup();
}
});
test('delivers editor branch artifacts declared as prepared or library staging', () => {
const fixture = buildFixture();
try {
const features = new Set([
'unity-editor-execute',
'godot-editor-execute',
'cocos-editor-injection',
]);
prepare(fixture, { features });
const staged = (relative) =>
path.join(fixture.destinationRoot, 'resources/plugins', relative);
// Unity:prepared 子目录整体复制
assert.ok(
fs.existsSync(
staged('agc-unity-editor/dotnet/publish/win-x64/Agc.Unity.Attach.exe'),
),
'Unity helper 必须随包',
);
// Godot:libraryStaging 声明文件逐个复制
assert.ok(
fs.existsSync(
staged(
'agc-godot-editor/native/gdextension/bin/win-x64/agc_godot_editor.dll',
),
),
'Godot 扩展必须随包',
);
assert.ok(
fs.existsSync(
staged('agc-godot-editor/native/gdextension/vendor/provenance.json'),
),
'Godot 随包清单文件必须随包',
);
} finally {
fixture.cleanup();
}
});
@@ -99,4 +99,22 @@ mod tests {
)
.unwrap();
}
#[test]
fn validate_rejects_incomplete_bundle() {
let temp = tempfile::tempdir().expect("tempdir");
let error = validate(temp.path()).expect_err("空目录必须被拒绝");
assert!(!error.is_empty(), "失败原因不能为空");
// 只有文件名、内容不合法的「像样」目录也必须被拒绝。
for relative in BUNDLE_FILES {
let file = temp.path().join(relative);
fs::create_dir_all(file.parent().expect("parent")).expect("create dir");
fs::write(&file, b"not a real artifact").expect("write file");
}
assert!(
validate(temp.path()).is_err(),
"内容不合法的随包库必须被拒绝",
);
}
}
@@ -88,7 +88,7 @@ Windows 本地 `npm run dev` / `npm run dev:api-server` / `npm run dev:bgfilter-
AGC 随包资源(内置 Codex CLI、插件工作区)的布局与组件白名单只有一份人工声明:`apps/ai-game-creator-shell/src-tauri/build_support/package-layout.json`。Node 侧准备步骤直接读它,Rust 侧读由 `node scripts/check-package-layout.mjs --write`(仓库根 `npm run agc:bundled-resources:sync`)生成的 `build_support/package-layout.generated.rs`;门禁 `npm run agc:bundled-resources:check` 已进 `agc:typecheck` 链,两者不一致直接失败。改布局只能改声明文件再同步生成物,不要手改生成文件,也不要另写第二份白名单。准备步骤是 `node apps/ai-game-creator-shell/scripts/prepare-bundled-resources.mjs`:写临时目录后原子替换、命中缓存不写任何文件、只替换本工具产物、失败即退出并给出可执行提示;其用例为 `npm run agc:bundled-resources:test`。内置 Codex CLI 的上游平台包来自仓库根 `npm ci`,缺失时工具会直接提示重新安装。构建脚本对既有随包产物做只读校验(不再有写入分支,因此也没有跳过写入的开关)。
随包资源由准备步骤在 Tauri 之前生成:`npm run agc` 在 `apps/ai-game-creator-shell/scripts/start-tauri-dev.mjs` 里、spawn Tauri CLI 之前调用(日志以 `[ai-game-creator-shell]` 前缀给出命中缓存或重新生成);发布链在 `build-release.mjs` 的 `runTauriBuild` 内与 Node 运行时 staging 并列调用,并传 `profile: 'release'`,`tauri build --no-bundle` 不强制 staging。**构建脚本完全不生成随包资源**(源码派生内容逐文件比对、已准备产物查存在性、Godot 随包库跑既有深度校验),源码不变时 `cargo build` 稳定 fresh。需要外部工具链或同一次 cargo 构建才能产出的内容(Unity publish 目录、Godot gdextension、Cocos bridge dll)也由准备步骤按 `build_support/package-layout.json` 的 `prepareSteps` 先运行 `powershell.exe -File build.ps1` 或 `cargo build -p … --target …` 再复制;这些步骤按内容指纹跳过未变化的情况。`resources/plugins` 由准备步骤拥有:不要手工往里放东西,准备步骤会按仓库 `plugins/` 与声明重建;`.taurignore` 的 staging 条目已删除(构建期不再写该目录)。
随包资源由准备步骤在 Tauri 之前生成:`npm run agc` 在 `apps/ai-game-creator-shell/scripts/start-tauri-dev.mjs` 里、spawn Tauri CLI 之前调用(日志以 `[ai-game-creator-shell]` 前缀给出命中缓存或重新生成);发布链在 `build-release.mjs` 的 `runTauriBuild` 内与 Node 运行时 staging 并列调用,并传 `profile: 'release'`,`tauri build --no-bundle` 不强制 staging。**构建脚本完全不生成随包资源**(源码派生内容逐文件比对、已准备产物查存在性、Godot 随包库跑既有深度校验),源码不变时 `cargo build` 稳定 fresh。需要外部工具链或同一次 cargo 构建才能产出的内容(Unity publish 目录、Godot gdextension、Cocos bridge dll)也由准备步骤按 `build_support/package-layout.json` 的 `prepareSteps` 先运行 `powershell.exe -File build.ps1` 或 `cargo build -p … --target …` 再复制;这些步骤按内容指纹跳过未变化的情况。`resources/plugins` 由准备步骤拥有:不要手工往里放东西,准备步骤会按仓库 `plugins/` 与声明重建;dev 构建下客户端读的是 `target/debug/plugins/**`(Tauri 在 debug 配置下把随包资源拷到那里),所以改完资源要重跑准备步骤而不是手动改 `resources/`;`.taurignore` 的 staging 条目已删除(构建期不再写该目录)。
### 本地 Rust 构建缓存与磁盘上限