类型判定改为内容证据优先:消除 kind 与内容证据同权的脆弱点

**口径更正(后加的)**:本条提交**不是**"修复真机那 8 张卡"。经用真源码复算真机 manifest 确认,当前 HEAD 上这 8 张 `.json` 本就正确落 `document`、角标「文档」,该现象不复现——用户看到的是更早构建的行为。本条的真实价值是**消除 `kind` 与内容证据同权的脆弱点,并把「派发决策由内容证据决定」钉成不变量**,见下。

- 背景:真机「待归类」栏目曾出现 8 张卡——角标写「图片」、卡面却只有占位图标。该现象在当前 HEAD 上不复现(见上),但它对应一个**真实存在且此前只是碰巧没被踩中的脆弱点**:`projectedResourceKind` 里 `kind` 与内容证据**同权**,而 `artKind` 含 `ui`;一旦界面规格类资产丢了 `.json` 扩展名,预览派发就会翻车。原先能通过**完全依赖 `.json` 恰好在那张扩展名表里**,属巧合而非设计。
- 机制(旧行为):`kind:"UI"` 被 `artKind` 命中 → 判定为 `art`;预览调度再按 `art` 走图像分支,又因 `mediaType:"application/json"` 不是图像而兜底成 `placeholder`,而 `requestPreview` 对 `placeholder` 直接返回——**一次 IPC 都不发**,卡片既不报错也没有图。
- `resourceProjectionModel.ts` 的 `projectedResourceKind` 改为两趟判定:第一趟只看 `mediaType` 与路径扩展名(文件内容的可验证证据),第二趟才看 `kind`(登记标签,可能过时或宽泛)。`kind` 不再与内容证据同权,因此 `kind:"ui"` 无法再把 `application/json` 改写成 `art`。
- 不改 `artKind` 的取值(`ui` 仍在表内),只调整判定顺序:最小改动面,`kind:"ui"` + `image/png` 这类正常资产行为不变(仍由 `mediaType` 命中)。
- 界面规格 JSON 现在落 `document` 分支,走 `read_local_project_text_preview`(Rust 侧 `.json` 与 `application/json` 本就在支持范围内且这些条目已登记),卡面渲染 JSON 文本摘要,角标随 `projectResourceTypeLabel` 变为「文档」。预览链条数、缓存、并发与门禁一律未动。
- `tests/resourceCardPreviewRealManifest.test.ts` 中记录旧行为的用例同步跟上新合同:`placeholder: 8` 改为 `document: 8`,用例名与注释改写为「52 张 PNG 走图片分支,8 条 UI 规格走文档分支,无一落占位」,并新增「角标与预览分支必须说同一件事」的逐条断言。旧断言记录的是缺陷本身,按「四不写」直接更新为当前权威行为,不做兼容保留;其余既有断言一条未放宽。
- 验证:`npm run test -- apps/ai-game-creator-shell/tests` 1202 passed / 4 skipped / 0 failed;`src/components/image-editor` 1385 passed;typecheck exit 0;check:encoding 通过;`git diff --check` 干净。
This commit is contained in:
2026-09-11 13:26:30 +08:00
parent 5a37ccfd2b
commit 083df0aa2c
2 changed files with 54 additions and 15 deletions
@@ -95,10 +95,23 @@ function fileName(path: string) {
export type ProjectedResourceKind = 'audio' | 'art' | 'code' | 'document';
/**
* 资源类型判定:按扩展名、mediaType 与 manifest `kind` 判定资源类型。
* 资源类型判定:按 mediaType、扩展名与 manifest `kind` 判定资源类型。
*
* 只有能判定类型的资源才进入资源画布:`.zip` / `.exe` 等无法识别的二进制产物与附件返回
* `null`,卡片显示类型复用同一口径。画布分区口径见 `projectResourceCanvasCategory`。
*
* 判定分两趟,**顺序是这条函数的核心合同**:
*
* 1. 第一趟只看 `mediaType` 与路径扩展名——它们是文件内容的可验证证据;
* 2. 第二趟才看 `kind`——它是登记时的标签,可能过时、可能宽泛。
*
* 为什么不能把 `kind` 混进第一趟:`artKind` 含 `ui`,而 `ui` 是极常见的登记标签
* (旧版本把界面素材统一写成 `kind:"ui"`)。一旦 `kind` 与 mediaType 同权,
* `kind:"ui"` + `mediaType:"application/json"` 的界面规格就会被判成 `art`,
* 于是卡面角标按 `art` 派生显示「图片」,而预览调度按 `art` 走图像分支、
* 又因 mediaType 不是图像而兜底成 `placeholder` 且**永不发起读取** ——
* 出现「标着图片、却是占位、没有任何报错」的分叉。分趟后 JSON/HTML 这类
* 有明确文档 / 代码 mediaType 的资产不会再被 `kind` 顶掉。
*/
export function projectedResourceKind(input: {
path: string;
@@ -109,18 +122,17 @@ export function projectedResourceKind(input: {
const normalizedMediaType = input.mediaType.trim().toLowerCase();
const normalizedKind = input.kind?.trim().toLowerCase() ?? '';
// 第一趟:mediaType 与扩展名(内容证据,权威)。
if (
normalizedMediaType.startsWith('audio/') ||
audioExtension.test(normalizedPath) ||
audioKind.test(normalizedKind)
audioExtension.test(normalizedPath)
) {
return 'audio';
}
if (
normalizedMediaType.startsWith('image/') ||
normalizedMediaType.startsWith('video/') ||
artExtension.test(normalizedPath) ||
artKind.test(normalizedKind)
artExtension.test(normalizedPath)
) {
return 'art';
}
@@ -129,8 +141,7 @@ export function projectedResourceKind(input: {
normalizedMediaType === 'text/css' ||
normalizedMediaType.includes('javascript') ||
normalizedMediaType.includes('typescript') ||
gameCodeExtension.test(normalizedPath) ||
gameCodeKind.test(normalizedKind)
gameCodeExtension.test(normalizedPath)
) {
return 'code';
}
@@ -142,6 +153,18 @@ export function projectedResourceKind(input: {
) {
return 'document';
}
// 第二趟:登记标签兜底。只在 mediaType 与扩展名都给不出结论时生效,
// 保证「有明确 mediaType 的资产」永远不被宽泛的 `kind` 改写类型。
if (audioKind.test(normalizedKind)) {
return 'audio';
}
if (gameCodeKind.test(normalizedKind)) {
return 'code';
}
if (artKind.test(normalizedKind)) {
return 'art';
}
return null;
}
@@ -192,7 +192,7 @@ describe('真机 manifest 取证:占位卡片计数与栏目分布', () => {
).toEqual(['game-entry']);
});
test('按预览分支计数:只有 JSON 规格会落占位,52 张 PNG 走图片分支', () => {
test('按预览分支计数:52 张 PNG 走图片分支,8 条 UI 规格走文档分支,无一落占位', () => {
const resources = projectResourcesFromReadModels(realManifest(), [], []);
const previewKindByResource = resources.map((resource) => ({
resource,
@@ -202,20 +202,36 @@ describe('真机 manifest 取证:占位卡片计数与栏目分布', () => {
}));
const byPreviewKind = countBy(previewKindByResource, (e) => e.previewKind);
// 这里曾经是 `placeholder: 8`8 条 `ui/application/json` 因为 `kind:"UI"` 命中
// `artKind`(其中含 `ui`)而被判成 art,于是角标显示「图片」,而预览调度按 art 走
// 图像分支、又因 mediaType 不是图像而兜底成 placeholder —— 卡片永不发起读取,
// 表现为「标着图片却只有占位图标」。类型判定改为 mediaType/扩展名优先、kind 只做
// 兜底后,它们正确落文档分支,卡面渲染 JSON 文本摘要。
expect(byPreviewKind).toEqual({
'raster-image': 52,
placeholder: 8,
document: 8,
code: 1,
});
// 占位卡片的构成:8 条 ui/application/json。它们不是"图片读不出来"
// 而是被归成文档以外的未知分支后又没有可预览内容(game-entry 走 code 分支渲染文本)
const placeholders = previewKindByResource.filter(
(e) => e.previewKind === 'placeholder',
// 占位分支在真机上必须为空 —— 上面那条 `toEqual` 已经钉住了唯一取值集合
// 这里不再另加"不存在"式断言,避免写成恒真守卫
const documentAssets = previewKindByResource.filter(
(e) => e.previewKind === 'document',
);
expect(countBy(placeholders, (e) => e.resource.mediaType)).toEqual({
expect(countBy(documentAssets, (e) => e.resource.mediaType)).toEqual({
'application/json': 8,
});
expect(countBy(placeholders, (e) => e.resource.subtype)).toEqual({ ui: 8 });
expect(countBy(documentAssets, (e) => e.resource.subtype)).toEqual({
ui: 8,
});
// 角标与预览分支必须是同一套口径:标「图片」的必须真的走图片分支。
for (const entry of previewKindByResource) {
if (entry.typeLabel === '图片') {
expect(entry.previewKind).toBe('raster-image');
}
if (entry.typeLabel === '文档') {
expect(entry.previewKind).toBe('document');
}
}
});
test('按画布栏目计数:待归类从 58 降到 1,UI 交互升到 59', () => {