diff --git a/.planning/phases/09-minimum-browser-runnable/09-22-SUMMARY.md b/.planning/phases/09-minimum-browser-runnable/09-22-SUMMARY.md new file mode 100644 index 0000000..eea4b07 --- /dev/null +++ b/.planning/phases/09-minimum-browser-runnable/09-22-SUMMARY.md @@ -0,0 +1,186 @@ +--- +phase: 09-minimum-browser-runnable +plan: 22 +subsystem: build +tags: [vite, rollup, typescript-ast, decorators, jsonc, content-filter, build-pipeline] + +# Dependency graph +requires: + - phase: 09-minimum-browser-runnable + provides: 09-21 的构建重构(`dataIsolationPlugin`、content 镜像、`buildGame` 编排) +provides: + - "script/build-plugin.ts:`removeReplayDecorators` / `replayDecoratorPlugin`(AST 切片删除录像装饰器)" + - "script/build-game.ts:`buildClient` 与 `buildData` 双端注入录像装饰器删除插件" + - "script/build-resource.ts:按 `.list.jsonc` 过滤 content 素材(根目录与 `defaults/` 豁免)" + - "script/__test__/:插件与 content 行为测试(旧 `script/build-plugin.test.ts` 已移动)" +affects: [09-minimum-browser-runnable, build:game 产物布局, dist/content 素材集合] + +# Actuals +actuals: + tokens: 7900 + tasks: 2 + commits: 0 + plan_head_before: 9d291a6f80764a995e376aee6e8f583874433ee1 + +# Tech tracking +tech-stack: + added: [] + patterns: + - "装饰器删除:TypeScript AST(`ts.canHaveDecorators` / `ts.getDecorators`)取节点范围后按起点降序切片" + - "content 清单过滤:`minifyJSONC` 去整行注释 + `JSON.parse` + 递归收集全部字符串值" + - "测试:真实 `vite.build()` 的带插件 / 不带插件对照,防止 `not.toContain` 空断言" + +key-files: + created: + - script/__test__/build-plugin.test.ts + - script/__test__/build-resource.test.ts + modified: + - script/build-plugin.ts + - script/build-game.ts + - script/build-resource.ts + deleted: + - script/build-plugin.test.ts(移动为 `script/__test__/build-plugin.test.ts`) + +key-decisions: + - "装饰器删除用 TypeScript AST 精确切片,不用正则扫描;字符串与注释中的同名文本不受影响" + - "录像装饰器在数据端与渲染端都删除(`buildData` + `buildClient` 各注入一次),不手动删 import" + - "插件固定 `enforce: 'pre'` + `apply: 'build'`,开发服务(`pnpm dev`)行为不变" + - "content:根目录经 `isRoot` 显式跳过过滤,`defaults/` 全量,其余子文件夹按各自 `.list.jsonc` 取舍" + - "清单只按「完整文件名」或「去扩展名主文件名」两种精确方式匹配;`.list.jsonc` 自身始终压缩输出" + - "测试文件统一放到 `script/__test__/`,旧 `script/build-plugin.test.ts` 不保留" + +patterns-established: + - "构建脚本行为用临时 fixture + 真实 `vite.build()` 验证,结构门禁只断言值标记与导出形态" + - "既有 jsDoc 零丢失门禁与仓库外 Task 0 基线比对,不依赖 git 提交时序" + +requirements-completed: [] +duration: 12min +completed: 2026-10-21 +status: complete +--- + +# Phase 09 Plan 22: 任务 11 后续修复 —— content 清单过滤与录像装饰器删除 Summary + +**content 构建改为按 `.list.jsonc` 只保留被引用素材(根目录与 `defaults/` 始终全量、清单自身压缩输出),并在数据端与渲染端构建时用 TypeScript AST 精确删除全部 `@shouldReplay` / `@ignoreReplay` 装饰器应用(不手删 import、不影响 `pnpm dev`);两套测试共 20 个用例通过,结构门禁、注释基线门禁、真实清单覆盖率与 eslint 全绿** + +## Performance + +- **Duration:** 约 12 分钟 +- **Completed:** 2026-10-21 +- **Tasks:** 2(Task 0 汇报关卡已由用户「可以执行」闭环,不在本次执行内重复) +- **Files:** 新增 2、修改 3、移动 1(旧路径删除) + +## 任务执行结果 + +| Task | 内容 | 结果 | +| ---- | ---- | ---- | +| Task 1 | `removeReplayDecorators` / `replayDecoratorPlugin` + `build-game` 双端注入 + 插件测试移动并扩展 | 完成,11/11 用例通过 | +| Task 2 | content 清单过滤 + `build-resource` 行为测试 + 本 SUMMARY | 完成,9/9 用例通过与全部门禁通过 | + +## What Was Built + +### 录像装饰器删除插件(`script/build-plugin.ts`) + +- 新增不导出常量:`REPLAY_DECORATORS`(`['shouldReplay', 'ignoreReplay']`)、`SCRIPT_EXTENSIONS`(`['.ts', '.tsx']`)、`NODE_MODULES_SEGMENT`(`/[/\\]node_modules[/\\]/`)。 +- `removeReplayDecorators(code, fileName)`:用 `ts.createSourceFile` 建 AST,以显式栈遍历全部节点;对 `ts.canHaveDecorators(node)` 的节点取 `ts.getDecorators(node)`,仅当装饰器表达式是目标标识符或「callee 为目标标识符的调用表达式」时记录 `[getStart, getEnd]`,按起点降序切片删除;无命中时原样返回输入文本。 +- `replayDecoratorPlugin()`:`name: 'mota-replay-decorator'`、`enforce: 'pre'`、`apply: 'build'`;`transform` 先剥离 `?` 查询串,命中 `node_modules` 或非 `.ts` / `.tsx` 返回 `null`,代码未变化也返回 `null`。 +- 既有 `dataIsolationPlugin` / `SPECIFIER_BANS` / `PATH_BANS` / `findBan` 行为与注释一字未动。 + +### 双端注入(`script/build-game.ts`) + +- 导入改为 `{ dataIsolationPlugin, replayDecoratorPlugin }`。 +- `buildClient`:`plugins: [replayDecoratorPlugin(), legacy({ ... })]`(新插件在 `legacy` 之前)。 +- `buildData`:`plugins: [dataIsolationPlugin(), replayDecoratorPlugin()]`(隔离插件保持首位)。 +- 构建顺序(准备 → 渲染端 → 数据端 → content → LICENSE)、产物落点与其余配置均未变。 + +### content 清单过滤(`script/build-resource.ts`) + +- 新增不导出常量 `LIST_FILE_NAME = '.list.jsonc'`、`DEFAULTS_DIRECTORY = 'defaults'`。 +- 新增内部函数:`readListNames`(读文件 → `minifyJSONC` 去注释 → `JSON.parse`)、`collectListedNames`(递归收集全部字符串值)、`isListedFile`(完整文件名或去扩展名主文件名的精确匹配)。 +- `processDirectory` 增加第五个参数 `isRoot = false`: + - `buildContent` 的根调用传 `true`,根目录**永不**按清单取舍(即使将来出现根 `.list.jsonc`); + - `defaults/` 通过 `DEFAULTS_DIRECTORY` 显式豁免,全量输出并压缩; + - 其余子目录若存在 `.list.jsonc`,只保留被引用文件,未引用文件既不拷贝也不计入统计; + - `.list.jsonc` 自身先于过滤判断,始终走 `.jsonc` 压缩分支; + - 无清单的子文件夹与递归子目录保持全量。 +- `minifyJSONC` / `buildContent` / 接口名称与签名、字体分支与告警文案均未改;jsonc 仍只剥离整行 `//` 注释(含系统标记行),不改解析与序列化方式,不分块、不 zip。 + +### 测试(`script/__test__/`) + +- `script/__test__/build-plugin.test.ts`:由 `script/build-plugin.test.ts` 移动而来(导入改 `../build-plugin`,旧路径已删除),保留既有 `dataIsolationPlugin` 四个用例;新增纯函数用例(删 `@shouldReplay` / `@ignoreReplay`、参数含双引号与嵌套括号、字符串不误删、注释不误删)与插件跳过用例,以及**对照式双装饰器**真实 `vite.build()`:同一 fixture 内本地定义且不导出 `shouldReplay` / `ignoreReplay` 工厂,导出一个类、两个方法分别带一种装饰器并各返回 `mota_replay_probe` / `mota_ignore_probe`。对照组(`plugins: []`)产物含两个工厂名、两个唯一字面量与两个方法名;实验组不含 `shouldReplay` / `ignoreReplay`,但两个唯一字面量与两个方法名仍在。 +- `script/__test__/build-resource.test.ts`:9 个 fixture 行为用例——根 jsonc 压缩、根目录存在 `.list.jsonc` 仍全量、`defaults/` 存在 `.list.jsonc` 仍全量、maps 清单(原文带 `//` 注释)保留/剔除、texture 对象清单 + 完整文件名按字节保留、font 清单、无清单子文件夹全量、`item.ts` 原样、统计值一致。 +- 每个 `it` 前均有一行中文说明注释(AGENTS.md)。 + +## Files Created/Modified + +- `script/build-plugin.ts`(修改)— 新增录像装饰器删除纯函数与构建插件 +- `script/build-game.ts`(修改)— 双端注入新插件 +- `script/build-resource.ts`(修改)— content 清单过滤 +- `script/__test__/build-plugin.test.ts`(新增;源 `script/build-plugin.test.ts` 已删除) +- `script/__test__/build-resource.test.ts`(新增) +- `.planning/phases/09-minimum-browser-runnable/09-22-SUMMARY.md`(本文件) + +**未改动(已核对):** `script/build-lib.ts`、`package.json`、`index.html`、`dev.md`、`src/**`、`public/**`、`packages-user/**`、`packages/**`、`vite.config.ts`、`pnpm-lock.yaml`、`script/dev.ts`、`script/check-data-circular.test.ts`;未手动删除任何 import。 + +## 命名获批情形 + +- OI-1:`removeReplayDecorators` / `replayDecoratorPlugin` / `name: 'mota-replay-decorator'`,以及内部常量 `REPLAY_DECORATORS` / `SCRIPT_EXTENSIONS` / `NODE_MODULES_SEGMENT`。 +- OI-2:`LIST_FILE_NAME` / `DEFAULTS_DIRECTORY` / `readListNames` / `collectListedNames` / `isListedFile`,以及 `processDirectory` 的 `isRoot` 参数。 +- OI-3:`script/__test__/build-plugin.test.ts`(移动)与 `script/__test__/build-resource.test.ts`(新增)。 +- 均按计划提议命名采用(Task 0 已报备并由用户批准执行)。 + +## Gates Run + +| 门禁 | 命令 / 内容 | 结果 | +| ---- | ----------- | ---- | +| 插件单测 | `cmd /c "pnpm exec vitest run script/__test__/build-plugin.test.ts"` | **PASS**(11/11) | +| content 单测 | `cmd /c "pnpm exec vitest run script/__test__/build-resource.test.ts"` | **PASS**(9/9) | +| 合并重跑 | 两个测试文件同时运行 | **PASS**(20/20) | +| 插件结构门禁 | 计划 Task 1 ``(AST 标记、`enforce` / `apply`、导出形态、`build-game` 按函数切片、测试位置与正负向断言) | **PASS**(`OK replay plugin: AST strip + dual-sided wiring`) | +| 插件注释基线门禁 | 与 `gsd-09-22-jsdoc-baseline.json` 比对 | **PASS**(`build-plugin.ts` 既有注释 9 行唯一值全部保留) | +| content 结构门禁 | 计划 Task 2 ``(值标记、导出与 `chars: string`、基线注释零丢失、SUMMARY 关键字与基线 SHA) | **PASS** | +| 真实清单覆盖率 | 5 个清单目录内现存素材全部被清单命中 | **PASS** | +| eslint | `cmd /c "pnpm exec eslint <5 个改动文件>"` | **PASS**(零报告) | + +### 注释基线 + +- 文件:`gsd-09-22-jsdoc-baseline.json`(由 Task 0 写入仓库外 `os.tmpdir()`,本次执行复用、未覆盖)。 +- 捕获时 `head`:`539c6516720b4aa9f6a0bbbf912ad447f95fc85c`。 +- `script/build-plugin.ts` 与 `script/build-resource.ts` 的既有注释行均与基线逐行一致(去重后分别为 9 行与 24 行),门禁与 git 提交无关、可重复执行。 + +## Open Items 处置 + +- OI-1 / OI-2 / OI-3:按计划提议命名采用,未改名。 +- 已知边界见下节,均不在本计划范围。 + +## Known Boundaries + +- **未端到端运行全量 `build:game`:** 渲染端仍有用户 WIP 的 stale import(`src/client/initialize.ts`、`src/client/plugin/shop/shop.tsx`,09-21 已记录)。本次验证只覆盖两个测试文件、结构门禁、注释基线、真实清单覆盖率与 eslint;**不得**表述为「全量构建已通过」。 +- **生产构建不再有录像安全检查:** `@shouldReplay` / `@ignoreReplay` 应用在构建期被删除,运行期不再产生对应的录像收集信息,属用户裁定与 D-96 的预期结果。 +- **`index.html` 仍引用 `/styles.css`:** 本计划不产出、不改该文件(09-21 已记录)。 +- **`animation` / `audio` / `font` 当前无对应素材:** 其清单过滤规则仅由测试覆盖,不影响现有产物。 +- **估算未校准:** 计划 `estimate.tokens=46000` 是 `confidence: low` 的原始估算,尚无历史执行样本,本次 `actuals.tokens≈7900` 为按实际改动字符数(chars/4)测得,二者差异不构成校准结论(`uncalibrated`)。 + +## Deviations from Plan + +无功能性偏差。执行细节说明: + +1. **换行符:** 3 个脚本经 `prettier --write` 保持仓库既有 CRLF;2 个新测试文件同样以 `.prettierrc` 的 `endOfLine: crlf` 格式化;本 SUMMARY 沿用 `.planning` 既有的 LF。 +2. **测试辅助函数:** 插件测试把探测构建收敛为一个接收 `{ root, entry, plugins }` 的辅助函数,以便对照组写出字面量 `plugins: []`(结构门禁要求的对照构建),行为与计划一致。 + +## Issues Encountered + +无。两套测试首轮即通过。 + +## User Setup Required + +None。 + +## Commits + +**未提交任何内容(`git add` / `git commit` 均未执行,按本次执行指令保留在工作树)。** `actuals.commits` 因此记录为 0。 + +--- + +*Phase: 09-minimum-browser-runnable* +*Completed: 2026-10-21* diff --git a/script/__test__/build-plugin.test.ts b/script/__test__/build-plugin.test.ts new file mode 100644 index 0000000..19a4132 --- /dev/null +++ b/script/__test__/build-plugin.test.ts @@ -0,0 +1,348 @@ +import { build, Plugin } from 'vite'; +import { RollupOutput } from 'rollup'; +import { mkdtemp, mkdir, rm, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { dirname, join } from 'node:path'; +import { afterEach, describe, expect, it } from 'vitest'; +import { + dataIsolationPlugin, + removeReplayDecorators, + replayDecoratorPlugin +} from '../build-plugin'; + +const tempDirs: string[] = []; + +/** 创建独立的临时构建根目录,并登记以便用例结束后统一清理 */ +async function createRoot() { + const root = await mkdtemp(join(tmpdir(), 'mota-data-isolation-')); + tempDirs.push(root); + return root; +} + +/** 在临时根目录下写入文件,必要时创建父目录,返回写入后的绝对路径 */ +async function writeRootFile(root: string, name: string, content: string) { + const file = join(root, name); + await mkdir(dirname(file), { recursive: true }); + await writeFile(file, content, 'utf8'); + return file; +} + +/** 探测构建的配置 */ +interface IProbeBuildOptions { + /** 临时构建根目录 */ + readonly root: string; + /** 入口文件绝对路径 */ + readonly entry: string; + /** 参与构建的插件列表 */ + readonly plugins: Plugin[]; +} + +/** 以库模式 ESM 执行一次真实的 vite 构建,返回全部 chunk 代码 */ +async function buildProbe(options: IProbeBuildOptions) { + const result = (await build({ + configFile: false, + root: options.root, + logLevel: 'silent', + build: { + write: false, + minify: false, + copyPublicDir: false, + lib: { + entry: options.entry, + formats: ['es'], + fileName: 'probe' + } + }, + plugins: options.plugins + })) as RollupOutput | RollupOutput[]; + const outputs = Array.isArray(result) ? result : [result]; + return outputs + .flatMap(v => v.output) + .filter(v => v.type === 'chunk') + .map(v => v.code) + .join('\n'); +} + +type TransformHook = (code: string, id: string) => { code: string } | null; + +/** 取出插件 transform 钩子的可调用形式,兼容对象式钩子 */ +function getTransform(plugin: Plugin): TransformHook { + const hook: unknown = plugin.transform; + if (typeof hook === 'function') { + return hook as TransformHook; + } + return (hook as { handler: TransformHook }).handler; +} + +/** 以数据端库模式 ESM 配置执行一次真实的 vite 构建 */ +function buildFixture(root: string, entry: string) { + return build({ + configFile: false, + root, + logLevel: 'silent', + build: { + write: false, + minify: false, + copyPublicDir: false, + lib: { + entry, + formats: ['es'], + fileName: 'data' + } + }, + plugins: [dataIsolationPlugin()] + }); +} + +/** 执行构建并返回错误信息,构建成功时返回空字符串 */ +async function getBuildError(root: string, entry: string) { + try { + await buildFixture(root, entry); + } catch (e) { + return String(e); + } + return ''; +} + +/** 录像装饰器探测源码:工厂函数本地定义且不导出,两个方法各带一种装饰器并返回唯一字面量 */ +const PROBE_SOURCE = ` +function shouldReplay(message: string) { + return function (method: unknown) { + return method; + }; +} +function ignoreReplay(_msg: string) { + return function (method: unknown) { + return method; + }; +} +export class Probe { + @shouldReplay('replay probe') + replayProbe(): string { + return 'mota_replay_probe'; + } + @ignoreReplay('ignore probe') + ignoreProbe(): string { + return 'mota_ignore_probe'; + } +} +`; + +afterEach(async () => { + const dirs = tempDirs.splice(0); + await Promise.all( + dirs.map(dir => rm(dir, { recursive: true, force: true })) + ); +}); + +describe('dataIsolationPlugin', () => { + // 直接引入 vue 说明符的数据端模块必须被插件拒绝 + it('rejects a render-side specifier', async () => { + const root = await createRoot(); + const entry = await writeRootFile(root, 'entry.ts', "import 'vue';\n"); + + const message = await getBuildError(root, entry); + + expect(message).toContain('[mota-data-isolation]'); + expect(message).toContain('matched: vue'); + expect(message).toContain( + 'data-side build must not depend on render-side' + ); + }); + + // 相对引入的 src/client 渲染目录必须按解析路径被 moduleParsed 拒绝 + it('rejects a resolved src/client module', async () => { + const root = await createRoot(); + await writeRootFile( + root, + 'src/client/ui/fake.ts', + 'export const fake = 1;\n' + ); + const entry = await writeRootFile( + root, + 'entry.ts', + "import './src/client/ui/fake';\n" + ); + + const message = await getBuildError(root, entry); + + expect(message).toContain('[mota-data-isolation]'); + expect(message).toContain('matched: src/client'); + }); + + // 相对引入的 packages-user/client-* 必须按解析路径被 moduleParsed 拒绝 + it('rejects a resolved packages-user client package', async () => { + const root = await createRoot(); + await writeRootFile( + root, + 'packages-user/client-modules/fake.ts', + 'export const fake = 1;\n' + ); + const entry = await writeRootFile( + root, + 'entry.ts', + "import './packages-user/client-modules/fake';\n" + ); + + const message = await getBuildError(root, entry); + + expect(message).toContain('[mota-data-isolation]'); + expect(message).toContain('matched: packages-user/client-'); + }); + + // 纯数据端依赖必须通过构建并产出 ESM 代码 + it('passes a pure data-side graph and emits ESM', async () => { + const root = await createRoot(); + await writeRootFile( + root, + 'packages-user/data-common/fake.ts', + 'export const value = 1;\n' + ); + const entry = await writeRootFile( + root, + 'entry.ts', + "export { value } from './packages-user/data-common/fake';\n" + ); + + const result = (await buildFixture(root, entry)) as + | RollupOutput + | RollupOutput[]; + const outputs = Array.isArray(result) ? result : [result]; + const code = outputs + .flatMap(v => v.output) + .filter(v => v.type === 'chunk') + .map(v => v.code) + .join('\n'); + + expect(code).toContain('export'); + }); +}); + +describe('removeReplayDecorators', () => { + // 方法上方的 @shouldReplay 调用装饰器必须被整段删除,类与方法保持原样 + it('removes a shouldReplay decorator', () => { + const code = [ + 'export class Sample {', + " @shouldReplay('should be replayed')", + ' run(): void {}', + '}' + ].join('\n'); + + const output = removeReplayDecorators(code, 'sample.ts'); + + expect(output).not.toContain('@shouldReplay'); + expect(output).toContain('run(): void {}'); + expect(output).toContain('export class Sample'); + }); + + // 方法上方的 @ignoreReplay 调用装饰器同样必须被整段删除 + it('removes an ignoreReplay decorator', () => { + const code = [ + 'export class Sample {', + " @ignoreReplay('async content')", + ' run(): void {}', + '}' + ].join('\n'); + + const output = removeReplayDecorators(code, 'sample.ts'); + + expect(output).not.toContain('@ignoreReplay'); + expect(output).toContain('run(): void {}'); + }); + + // 参数含双引号、逗号与嵌套括号时也必须整段删除,不残留括号或字符串碎片 + it('removes a decorator with quoted and nested arguments', () => { + const code = [ + 'export class Sample {', + ' @shouldReplay("a (nested) call, with \'quotes\'")', + ' run(): void {}', + '}' + ].join('\n'); + + const output = removeReplayDecorators(code, 'sample.ts'); + + expect(output).not.toContain('@shouldReplay'); + expect(output).not.toContain('nested'); + expect(output).not.toContain('quotes'); + expect(output).toContain('run(): void {}'); + }); + + // 字符串字面量中出现的同名文本不得被删除 + it('keeps the same text inside string literals', () => { + const code = [ + 'export const message = "@shouldReplay inside a string";', + 'export class Sample {', + ' run(): void {}', + '}' + ].join('\n'); + + const output = removeReplayDecorators(code, 'sample.ts'); + + expect(output).toBe(code); + expect(output).toContain('@shouldReplay inside a string'); + }); + + // 注释中出现的同名文本不得被删除 + it('keeps the same text inside comments', () => { + const code = [ + '// @shouldReplay is applied here in the real source', + 'export class Sample {', + ' run(): void {}', + '}' + ].join('\n'); + + const output = removeReplayDecorators(code, 'sample.ts'); + + expect(output).toBe(code); + expect(output).toContain('@shouldReplay is applied'); + }); +}); + +describe('replayDecoratorPlugin', () => { + // 插件必须跳过 node_modules 与非 TypeScript 文件,只改写项目自身的源码 + it('skips node_modules and non-TypeScript files', async () => { + const root = await createRoot(); + const transform = getTransform(replayDecoratorPlugin()); + const code = [ + 'export class Sample {', + " @shouldReplay('should be replayed')", + ' run(): void {}', + '}' + ].join('\n'); + + expect( + transform(code, join(root, 'node_modules', 'dep', 'a.ts')) + ).toBeNull(); + expect(transform(code, join(root, 'src', 'a.css'))).toBeNull(); + + const result = transform(code, join(root, 'src', 'a.ts')); + expect(result).not.toBeNull(); + expect(result!.code).not.toContain('@shouldReplay'); + }); + + // 对照构建证明 token 在未处理时确实存在,实验构建证明只删装饰器而未删类与方法 + it('strips decorators in a real build without dropping class members', async () => { + const root = await createRoot(); + const entry = await writeRootFile(root, 'probe.ts', PROBE_SOURCE); + + const control = await buildProbe({ root, entry, plugins: [] }); + expect(control).toContain('shouldReplay'); + expect(control).toContain('ignoreReplay'); + expect(control).toContain('mota_replay_probe'); + expect(control).toContain('mota_ignore_probe'); + expect(control).toContain('replayProbe'); + expect(control).toContain('ignoreProbe'); + + const stripped = await buildProbe({ + root, + entry, + plugins: [replayDecoratorPlugin()] + }); + expect(stripped).not.toContain('shouldReplay'); + expect(stripped).not.toContain('ignoreReplay'); + expect(stripped).toContain('mota_replay_probe'); + expect(stripped).toContain('mota_ignore_probe'); + expect(stripped).toContain('replayProbe'); + expect(stripped).toContain('ignoreProbe'); + }); +}); diff --git a/script/__test__/build-resource.test.ts b/script/__test__/build-resource.test.ts new file mode 100644 index 0000000..3fa2843 --- /dev/null +++ b/script/__test__/build-resource.test.ts @@ -0,0 +1,217 @@ +import { + mkdtemp, + mkdir, + readFile, + rm, + stat, + writeFile +} from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { dirname, join } from 'node:path'; +import { afterEach, describe, expect, it } from 'vitest'; +import { buildContent } from '../build-resource'; + +const tempDirs: string[] = []; + +/** 创建独立的临时 fixture 根目录,并登记以便用例结束后统一清理 */ +async function createRoot() { + const root = await mkdtemp(join(tmpdir(), 'mota-content-filter-')); + tempDirs.push(root); + return root; +} + +/** 在 fixture 的源目录下写入文件,必要时创建父目录 */ +async function writeSource( + root: string, + name: string, + content: string | Buffer +) { + const file = join(root, 'src', name); + await mkdir(dirname(file), { recursive: true }); + await writeFile(file, content); + return file; +} + +/** 以空字符集执行一次 content 构建 */ +function buildFixture(root: string) { + return buildContent({ + sourceDir: join(root, 'src'), + outputDir: join(root, 'out'), + chars: '' + }); +} + +/** 读取输出目录中的文本文件 */ +function readOutput(root: string, name: string) { + return readFile(join(root, 'out', name), 'utf-8'); +} + +/** 判断输出目录中是否存在指定文件 */ +async function outputExists(root: string, name: string) { + try { + await stat(join(root, 'out', name)); + return true; + } catch { + return false; + } +} + +afterEach(async () => { + const dirs = tempDirs.splice(0); + await Promise.all( + dirs.map(dir => rm(dir, { recursive: true, force: true })) + ); +}); + +describe('buildContent', () => { + // 根目录的 jsonc 必须去掉整行注释并压成纯 JSON,后缀保持不变 + it('minifies a root jsonc file', async () => { + const root = await createRoot(); + await writeSource( + root, + 'core.jsonc', + '// Type: IGameCore\n// --- SYSTEM PREFIX END --- //\n{ "floorId": "MT0" }\n' + ); + + const stats = await buildFixture(root); + const output = await readOutput(root, 'core.jsonc'); + + expect(JSON.parse(output)).toEqual({ floorId: 'MT0' }); + expect(output).not.toContain('//'); + expect(stats.jsonc).toBe(1); + }); + + // 根目录即使出现 .list.jsonc 也必须全量输出,根调用显式跳过清单过滤 + it('keeps every root file even when a root list exists', async () => { + const root = await createRoot(); + await writeSource(root, '.list.jsonc', '["core"]\n'); + await writeSource(root, 'core.jsonc', '{ "value": 1 }\n'); + await writeSource(root, 'other.jsonc', '{ "value": 2 }\n'); + + await buildFixture(root); + + expect(await outputExists(root, 'core.jsonc')).toBe(true); + expect(await outputExists(root, 'other.jsonc')).toBe(true); + expect(await outputExists(root, '.list.jsonc')).toBe(true); + }); + + // defaults 目录即使存在 .list.jsonc 也必须全量输出并压缩 + it('keeps every defaults file even when a defaults list exists', async () => { + const root = await createRoot(); + await writeSource(root, 'defaults/.list.jsonc', '["env"]\n'); + await writeSource( + root, + 'defaults/env.jsonc', + '// Type: env\n{ "value": 1 }\n' + ); + await writeSource(root, 'defaults/other.jsonc', '{ "value": 2 }\n'); + + await buildFixture(root); + + expect(await outputExists(root, 'defaults/env.jsonc')).toBe(true); + expect(await outputExists(root, 'defaults/other.jsonc')).toBe(true); + expect(await outputExists(root, 'defaults/.list.jsonc')).toBe(true); + expect(await readOutput(root, 'defaults/other.jsonc')).not.toContain( + '//' + ); + expect( + JSON.parse(await readOutput(root, 'defaults/.list.jsonc')) + ).toEqual(['env']); + }); + + // maps 清单带注释时先剥离注释,再按清单保留文件并剔除未引用文件 + it('filters a maps directory by its list', async () => { + const root = await createRoot(); + await writeSource( + root, + 'maps/.list.jsonc', + '// Type: string[], without .jsonc suffix\n// --- SYSTEM PREFIX END --- //\n["keep"]\n' + ); + await writeSource(root, 'maps/keep.jsonc', '{ "value": "keep" }\n'); + await writeSource(root, 'maps/drop.jsonc', '{ "value": "drop" }\n'); + + await buildFixture(root); + + expect(await outputExists(root, 'maps/keep.jsonc')).toBe(true); + expect(await outputExists(root, 'maps/drop.jsonc')).toBe(false); + expect(await outputExists(root, 'maps/.list.jsonc')).toBe(true); + const list = await readOutput(root, 'maps/.list.jsonc'); + expect(list).not.toContain('//'); + expect(JSON.parse(list)).toEqual(['keep']); + }); + + // texture 清单是对象形态且使用完整文件名,命中文件按字节原样拷贝 + it('filters a texture directory by object list and full file name', async () => { + const root = await createRoot(); + await writeSource( + root, + 'texture/.list.jsonc', + '{"textures":[{"type":"grid","source":"a.png"}]}\n' + ); + const bytes = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x01, 0x02]); + await writeSource(root, 'texture/a.png', bytes); + await writeSource(root, 'texture/b.png', Buffer.from([0x03])); + + await buildFixture(root); + + expect(await outputExists(root, 'texture/a.png')).toBe(true); + expect(await outputExists(root, 'texture/b.png')).toBe(false); + const output = await readFile(join(root, 'out', 'texture', 'a.png')); + expect(Buffer.compare(output, bytes)).toBe(0); + }); + + // font 清单按完整文件名保留,未引用文件被剔除 + it('filters a font directory by its list', async () => { + const root = await createRoot(); + await writeSource(root, 'font/.list.jsonc', '["ok.fake"]\n'); + await writeSource(root, 'font/ok.fake', 'ok'); + await writeSource(root, 'font/skip.fake', 'skip'); + + await buildFixture(root); + + expect(await outputExists(root, 'font/ok.fake')).toBe(true); + expect(await outputExists(root, 'font/skip.fake')).toBe(false); + }); + + // 没有清单的非 defaults 子文件夹必须全量输出 + it('keeps every file in a sub directory without a list', async () => { + const root = await createRoot(); + await writeSource( + root, + 'extra/free.jsonc', + '// Type: free\n{ "value": 1 }\n' + ); + await writeSource(root, 'extra/free.txt', 'free'); + + await buildFixture(root); + + expect(await outputExists(root, 'extra/free.txt')).toBe(true); + expect(await readOutput(root, 'extra/free.jsonc')).not.toContain('//'); + }); + + // 根目录的非 jsonc 文件按原样拷贝 + it('copies a root non-jsonc file verbatim', async () => { + const root = await createRoot(); + const source = 'export const item = 1;\n'; + await writeSource(root, 'item.ts', source); + + await buildFixture(root); + + expect(await readOutput(root, 'item.ts')).toBe(source); + }); + + // 统计结果必须与实际输出文件数一致 + it('reports stats matching the emitted files', async () => { + const root = await createRoot(); + await writeSource(root, 'core.jsonc', '{ "value": 1 }\n'); + await writeSource(root, 'maps/.list.jsonc', '["keep"]\n'); + await writeSource(root, 'maps/keep.jsonc', '{ "value": 2 }\n'); + await writeSource(root, 'maps/drop.jsonc', '{ "value": 3 }\n'); + + const stats = await buildFixture(root); + + expect(stats.files).toBe(3); + expect(stats.jsonc).toBe(3); + expect(stats.fonts).toBe(0); + }); +}); diff --git a/script/build-game.ts b/script/build-game.ts index c698e95..954ed5a 100644 --- a/script/build-game.ts +++ b/script/build-game.ts @@ -3,7 +3,7 @@ import legacy from '@vitejs/plugin-legacy'; import { resolve } from 'path'; import { copy, emptyDir, ensureDir } from 'fs-extra'; import { readdir, readFile, stat } from 'fs/promises'; -import { dataIsolationPlugin } from './build-plugin'; +import { dataIsolationPlugin, replayDecoratorPlugin } from './build-plugin'; import { buildContent } from './build-resource'; import { formatSize } from './utils'; @@ -75,6 +75,7 @@ async function buildClient(outDir: string) { ); const resolved = mergeConfig(config?.config ?? {}, { plugins: [ + replayDecoratorPlugin(), legacy({ targets: [ 'Chrome >= 56', @@ -131,7 +132,7 @@ async function buildData(outDir: string, entry: string) { configFile ); const resolved = mergeConfig(config?.config ?? {}, { - plugins: [dataIsolationPlugin()], + plugins: [dataIsolationPlugin(), replayDecoratorPlugin()], build: { outDir, copyPublicDir: false, diff --git a/script/build-plugin.test.ts b/script/build-plugin.test.ts deleted file mode 100644 index 60ba25e..0000000 --- a/script/build-plugin.test.ts +++ /dev/null @@ -1,144 +0,0 @@ -import { build } from 'vite'; -import { RollupOutput } from 'rollup'; -import { mkdtemp, mkdir, rm, writeFile } from 'node:fs/promises'; -import { tmpdir } from 'node:os'; -import { dirname, join } from 'node:path'; -import { afterEach, describe, expect, it } from 'vitest'; -import { dataIsolationPlugin } from './build-plugin'; - -const tempDirs: string[] = []; - -/** 创建独立的临时构建根目录,并登记以便用例结束后统一清理 */ -async function createRoot() { - const root = await mkdtemp(join(tmpdir(), 'mota-data-isolation-')); - tempDirs.push(root); - return root; -} - -/** 在临时根目录下写入文件,必要时创建父目录,返回写入后的绝对路径 */ -async function writeRootFile(root: string, name: string, content: string) { - const file = join(root, name); - await mkdir(dirname(file), { recursive: true }); - await writeFile(file, content, 'utf8'); - return file; -} - -/** 以数据端库模式 ESM 配置执行一次真实的 vite 构建 */ -function buildFixture(root: string, entry: string) { - return build({ - configFile: false, - root, - logLevel: 'silent', - build: { - write: false, - minify: false, - copyPublicDir: false, - lib: { - entry, - formats: ['es'], - fileName: 'data' - } - }, - plugins: [dataIsolationPlugin()] - }); -} - -/** 执行构建并返回错误信息,构建成功时返回空字符串 */ -async function getBuildError(root: string, entry: string) { - try { - await buildFixture(root, entry); - } catch (e) { - return String(e); - } - return ''; -} - -afterEach(async () => { - const dirs = tempDirs.splice(0); - await Promise.all( - dirs.map(dir => rm(dir, { recursive: true, force: true })) - ); -}); - -describe('dataIsolationPlugin', () => { - // 直接引入 vue 说明符的数据端模块必须被插件拒绝 - it('rejects a render-side specifier', async () => { - const root = await createRoot(); - const entry = await writeRootFile(root, 'entry.ts', "import 'vue';\n"); - - const message = await getBuildError(root, entry); - - expect(message).toContain('[mota-data-isolation]'); - expect(message).toContain('matched: vue'); - expect(message).toContain( - 'data-side build must not depend on render-side' - ); - }); - - // 相对引入的 src/client 渲染目录必须按解析路径被 moduleParsed 拒绝 - it('rejects a resolved src/client module', async () => { - const root = await createRoot(); - await writeRootFile( - root, - 'src/client/ui/fake.ts', - 'export const fake = 1;\n' - ); - const entry = await writeRootFile( - root, - 'entry.ts', - "import './src/client/ui/fake';\n" - ); - - const message = await getBuildError(root, entry); - - expect(message).toContain('[mota-data-isolation]'); - expect(message).toContain('matched: src/client'); - }); - - // 相对引入的 packages-user/client-* 必须按解析路径被 moduleParsed 拒绝 - it('rejects a resolved packages-user client package', async () => { - const root = await createRoot(); - await writeRootFile( - root, - 'packages-user/client-modules/fake.ts', - 'export const fake = 1;\n' - ); - const entry = await writeRootFile( - root, - 'entry.ts', - "import './packages-user/client-modules/fake';\n" - ); - - const message = await getBuildError(root, entry); - - expect(message).toContain('[mota-data-isolation]'); - expect(message).toContain('matched: packages-user/client-'); - }); - - // 纯数据端依赖必须通过构建并产出 ESM 代码 - it('passes a pure data-side graph and emits ESM', async () => { - const root = await createRoot(); - await writeRootFile( - root, - 'packages-user/data-common/fake.ts', - 'export const value = 1;\n' - ); - const entry = await writeRootFile( - root, - 'entry.ts', - "export { value } from './packages-user/data-common/fake';\n" - ); - - const result = (await buildFixture(root, entry)) as - | RollupOutput - | RollupOutput[]; - const outputs = Array.isArray(result) ? result : [result]; - const code = outputs - .flatMap(v => v.output) - .filter(v => v.type === 'chunk') - .map(v => v.code) - .join('\n'); - - expect(code).toContain('export'); - }); -}); diff --git a/script/build-plugin.ts b/script/build-plugin.ts index fbde89a..a3ad2a6 100644 --- a/script/build-plugin.ts +++ b/script/build-plugin.ts @@ -1,5 +1,6 @@ import { Plugin } from 'vite'; import { ModuleInfo } from 'rollup'; +import ts from 'typescript'; /** 数据端构建中禁止出现的渲染端模块说明符,第一项为匹配规则,第二项为报错时的命中标记 */ const SPECIFIER_BANS: ReadonlyArray = [ @@ -76,3 +77,91 @@ export function dataIsolationPlugin(): Plugin { } }; } + +/** 构建时需要删除的录像装饰器名称 */ +const REPLAY_DECORATORS: ReadonlyArray = [ + 'shouldReplay', + 'ignoreReplay' +]; +/** 需要处理录像装饰器的源文件后缀 */ +const SCRIPT_EXTENSIONS: ReadonlyArray = ['.ts', '.tsx']; +/** 判定第三方依赖路径的 node_modules 片段 */ +const NODE_MODULES_SEGMENT = /[/\\]node_modules[/\\]/; + +/** + * 删除源码中所有录像装饰器的应用,只按装饰器节点的文本范围切片,不会误删字符串与注释。 + * @param code 源码文本 + * @param fileName 源码文件名,用于确定脚本种类 + */ +export function removeReplayDecorators(code: string, fileName: string): string { + const kind = fileName.endsWith('.tsx') + ? ts.ScriptKind.TSX + : ts.ScriptKind.TS; + const sourceFile = ts.createSourceFile( + fileName, + code, + ts.ScriptTarget.Latest, + true, + kind + ); + const ranges: Array<[number, number]> = []; + const stack: ts.Node[] = [sourceFile]; + + while (stack.length > 0) { + const node = stack.pop()!; + if (ts.canHaveDecorators(node)) { + const decorators = ts.getDecorators(node); + if (decorators) { + for (const decorator of decorators) { + const expression = decorator.expression; + const target = ts.isCallExpression(expression) + ? expression.expression + : expression; + if ( + ts.isIdentifier(target) && + REPLAY_DECORATORS.includes(target.text) + ) { + ranges.push([ + decorator.getStart(sourceFile), + decorator.getEnd() + ]); + } + } + } + } + ts.forEachChild(node, child => { + stack.push(child); + }); + } + + if (ranges.length === 0) return code; + + let result = code; + ranges.sort((a, b) => b[0] - a[0]); + for (const [start, end] of ranges) { + result = result.slice(0, start) + result.slice(end); + } + return result; +} + +/** + * 创建删除录像装饰器的构建插件,仅处理项目自身的 TypeScript 源码,跳过第三方依赖。 + */ +export function replayDecoratorPlugin(): Plugin { + return { + name: 'mota-replay-decorator', + enforce: 'pre', + apply: 'build', + transform(code, id) { + const fileName = id.split('?')[0]; + if (NODE_MODULES_SEGMENT.test(fileName)) return null; + const matched = SCRIPT_EXTENSIONS.some(extension => + fileName.endsWith(extension) + ); + if (!matched) return null; + const result = removeReplayDecorators(code, fileName); + if (result === code) return null; + return { code: result, map: null }; + } + }; +} diff --git a/script/build-resource.ts b/script/build-resource.ts index 175fa90..a7555d0 100644 --- a/script/build-resource.ts +++ b/script/build-resource.ts @@ -1,11 +1,17 @@ import Fontmin from 'fontmin'; import { copy } from 'fs-extra'; import { mkdir, readdir, readFile, writeFile } from 'fs/promises'; -import { dirname, extname, join } from 'path'; +import { basename, dirname, extname, join } from 'path'; /** 需要经 fontmin 做字形子集化的字体扩展名 */ const FONT_EXTENSIONS = ['.ttf', '.otf', '.woff', '.woff2']; +/** 子目录中声明保留素材的清单文件名,清单自身始终会压缩输出 */ +const LIST_FILE_NAME = '.list.jsonc'; + +/** 永远全量输出、不参与清单过滤的素材目录名 */ +const DEFAULTS_DIRECTORY = 'defaults'; + export interface IContentBuildOptions { /** content 素材的源目录 */ readonly sourceDir: string; @@ -42,6 +48,48 @@ export function minifyJSONC(text: string): string { return JSON.stringify(JSON.parse(body)); } +/** + * 读取清单文件并收集其中声明的全部文件标识,清单中的注释会先被去除。 + * @param listFile 清单文件路径 + */ +async function readListNames(listFile: string): Promise> { + const text = await readFile(listFile, 'utf-8'); + const names = new Set(); + collectListedNames(JSON.parse(minifyJSONC(text)), names); + return names; +} + +/** + * 递归收集清单值中的全部字符串标识,数字、布尔与空值会被忽略。 + * @param value 清单中的任意值 + * @param output 收集结果 + */ +function collectListedNames(value: unknown, output: Set): void { + if (typeof value === 'string') { + output.add(value); + } else if (Array.isArray(value)) { + for (const item of value) { + collectListedNames(item, output); + } + } else if (value && typeof value === 'object') { + for (const item of Object.values(value)) { + collectListedNames(item, output); + } + } +} + +/** + * 判断文件名是否被清单保留,只支持完整文件名与去扩展名主文件名两种精确匹配。 + * @param name 待判断的文件名 + * @param names 清单声明的名称集合 + */ +function isListedFile(name: string, names: Set): boolean { + if (names.has(name)) return true; + const extension = extname(name); + const stem = name.slice(0, name.length - extension.length); + return names.has(stem); +} + /** * 将单个字体文件按给定字符集做字形子集化,输出到目标目录并保留原文件名。 * @param source 字体源文件 @@ -65,17 +113,34 @@ async function processDirectory( sourceDir: string, outputDir: string, chars: string, - counter: IContentBuildCounter + counter: IContentBuildCounter, + isRoot: boolean = false ) { await mkdir(outputDir, { recursive: true }); const entries = await readdir(sourceDir, { withFileTypes: true }); + const isDefaults = basename(sourceDir) === DEFAULTS_DIRECTORY; + const filterEnabled = !isRoot && !isDefaults; + const listEntry = filterEnabled + ? entries.find(entry => entry.isFile() && entry.name === LIST_FILE_NAME) + : undefined; + const names = listEntry + ? await readListNames(join(sourceDir, listEntry.name)) + : null; for (const entry of entries) { const source = join(sourceDir, entry.name); const target = join(outputDir, entry.name); if (entry.isDirectory()) { - await processDirectory(source, target, chars, counter); + await processDirectory(source, target, chars, counter, false); + continue; + } + + if ( + names && + entry.name !== LIST_FILE_NAME && + !isListedFile(entry.name, names) + ) { continue; } @@ -106,7 +171,8 @@ export async function buildContent( options.sourceDir, options.outputDir, options.chars, - counter + counter, + true ); if (counter.fonts === 0) {