按第三轮复测修复 M3:dry-run 零写入、所有权先行、指纹与门禁补口
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

- 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)
This commit is contained in:
2026-09-28 01:03:40 +08:00
parent 06ffa7b6ac
commit 547fb0dbce
4 changed files with 123 additions and 24 deletions
@@ -536,6 +536,36 @@ function validateExtendedDeclarations() {
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(
@@ -659,10 +659,10 @@ function pluginSourceFingerprint(declaration, plugins, target, features) {
const lines = [];
for (const plugin of plugins) {
for (const subdirectory of declaration.plugins.subdirectories) {
if (subdirectory.origin !== 'source') {
continue;
}
if (!subdirectoryEnabled(subdirectory, target, features)) {
if (
subdirectoryAppliesToPlugin(subdirectory, plugin.name) ||
subdirectoryEnabled(subdirectory, target, features)
) {
continue;
}
const root = path.join(plugin.root, subdirectory.path);
@@ -685,6 +685,44 @@ function pluginSourceFingerprint(declaration, plugins, target, features) {
lines.push(
`${plugin.name}/plugin.json:${info.size}:${Math.round(info.mtimeMs)}`,
);
for (const staging of declaration.plugins.libraryStaging ?? []) {
if (
plugin.name !== staging.plugin ||
libraryStagingEnabled(staging, target, features)
) {
continue;
}
for (const relative of staging.files) {
const file = path.join(
plugin.root,
staging.sourceSubdirectory,
relative,
);
lines.push(
`${plugin.name}/${staging.sourceSubdirectory}/${relative}:${
existsSync(file) ? `sha256:${sha256File(file)}` : 'missing'
}`,
);
}
}
for (const payload of declaration.plugins.nativePayloads ?? []) {
if (
plugin.name !== payload.plugin ||
nativePayloadEnabled(payload, target, features)
) {
continue;
}
const delivered = path.join(
plugin.root,
payload.destinationSubdirectory,
payload.destinationFileName ?? payload.sourceFileName,
);
lines.push(
`${plugin.name}/${payload.destinationSubdirectory}:${
existsSync(delivered) ? `sha256:${sha256File(delivered)}` : 'missing'
}`,
);
}
}
return sha256Text(lines.join('\n'));
}
@@ -715,6 +753,14 @@ function pluginTreeMatches(
) {
continue;
}
const stagedDirectory = path.join(pluginDestination, subdirectory.path);
if (!subdirectoryEnabled(subdirectory, target, features)) {
// 当前目标/feature 下不该出现的子目录:存在即说明是别的构建留下的,必须重建清理。
if (existsSync(stagedDirectory)) {
return false;
}
continue;
}
const sourceRoot = path.join(plugin.root, subdirectory.path);
if (subdirectory.origin !== 'source') {
if (
@@ -1091,6 +1137,26 @@ function preparePlugins({
destinationRoot,
declaration.plugins.destinationDirectory,
);
const fingerprint = pluginSourceFingerprint(
declaration,
plugins,
target,
features,
);
const upToDate =
record.plugins?.fingerprint === fingerprint &&
pluginTreeMatches(declaration, plugins, target, features, destination);
// dry-run 必须零写入:只做只读判断就返回。
if (dryRun) {
return {
summary: upToDate
? `plugins 命中缓存(未写入,${plugins.length} 个插件)`
: `plugins 需要重新生成(dry-run 未写入,${plugins.length} 个插件)`,
record: undefined,
};
}
// 所有权断言先于任何写入(含下面的预缓存交付)。
assertOwnedPluginRoot(destination, plugins);
// payload 交付不能排在缓存短路之后:否则「先默认构建、之后开 injection」会把交付整条跳过。
if (existsSync(destination)) {
copyNativePayloads({
@@ -1103,28 +1169,12 @@ function preparePlugins({
profile,
});
}
const fingerprint = pluginSourceFingerprint(
declaration,
plugins,
target,
features,
);
if (
record.plugins?.fingerprint === fingerprint &&
pluginTreeMatches(declaration, plugins, target, features, destination)
) {
if (upToDate) {
return {
summary: `plugins 命中缓存(未写入,${plugins.length} 个插件)`,
record: undefined,
};
}
if (dryRun) {
return {
summary: `plugins 需要重新生成(dry-run 未写入,${plugins.length} 个插件)`,
record: undefined,
};
}
assertOwnedPluginRoot(destination, plugins);
stageAtomically(destination, (staging) => {
for (const plugin of plugins) {
const pluginDestination = path.join(staging, plugin.name);
@@ -139,6 +139,17 @@ function buildFixture({ targets = [WINDOWS_TARGET], plugins = true } = {}) {
fs.writeFileSync(path.join(pluginRoot, 'target/junk.rs'), 'junk\n');
fs.writeFileSync(path.join(pluginRoot, '.git/HEAD'), 'ref\n');
fs.writeFileSync(path.join(pluginRoot, '.env'), 'secret\n');
const cocosRoot = path.join(repoRoot, 'plugins/agc-cocos-editor');
fs.mkdirSync(path.join(cocosRoot, 'src'), { recursive: true });
fs.writeFileSync(
path.join(cocosRoot, 'plugin.json'),
'{"name":"agc-cocos-editor"}\n',
);
fs.writeFileSync(
path.join(cocosRoot, 'src/entry.mjs'),
'export const cocos = 1;\n',
);
}
writePrepareArtifacts(repoRoot, declaration);
@@ -455,7 +466,14 @@ test('fails closed when the destination is owned by something else', () => {
test('dry run writes nothing', () => {
const fixture = buildFixture();
try {
const summaries = prepare(fixture, { dryRun: true });
const summaries = prepare(fixture, {
dryRun: true,
features: new Set([
'unity-editor-execute',
'godot-editor-execute',
'cocos-editor-injection',
]),
});
assert.match(summaries[0], /需要重新生成(dry-run 未写入)/);
const unit = path.join(fixture.destinationRoot, 'resources/codex/win-x64');
assert.deepEqual(
@@ -497,7 +515,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, 1);
assert.equal(pluginDirectories(declaration, fixture.repoRoot).length, 2);
} finally {
fixture.cleanup();
}
@@ -51,7 +51,8 @@
1. **准备步骤单独跑(不打包)**
- `npm run agc:bundled-resources:prepare -- --target x86_64-pc-windows-msvc`
- 预期:一行汇总日志;`src-tauri/resources/plugins/agc-unity-editor/dotnet/publish/win-x64/Agc.Unity.Attach.exe`、`agc-godot-editor/native/gdextension/` 下的扩展、`agc-cocos-editor/native/payload/cocos-editor-bridge.dll` 全部在位。
- 预期:一行汇总日志;`src-tauri/resources/plugins/agc-unity-editor/dotnet/publish/win-x64/Agc.Unity.Attach.exe`、`agc-godot-editor/native/gdextension/` 下的扩展在位。
- Cocos payload 只在 injection 构建下交付(与迁移前一致):加 `--features=cocos-editor-execute,unity-editor-execute,godot-editor-execute,cocos-editor-injection` 再跑一次,确认 `agc-cocos-editor/native/payload/cocos-editor-bridge.dll` 同时出现在插件工作区与随包目录。
- 再跑一次:预期全部「命中缓存」,且 `resources/**` 的文件时间戳不变。
2. **构建期不再写随包资源**
- 取 `src-tauri/resources` 全量快照(相对路径/大小/mtime/sha256)→ `touch apps/ai-game-creator-shell/src-tauri/build.rs` → 再 `cargo build` → 两次快照必须逐项一致。