From cabee3cc01a2769e61fffc2a1242c28d26009a4e Mon Sep 17 00:00:00 2001 From: unanmed <1319491857@qq.com> Date: Sat, 12 Sep 2026 13:24:06 +0800 Subject: [PATCH] style(03-19): normalize touched replay JSDoc to multiline - Convert replay command, helper, and registry JSDoc into multiline form - Add declaration-aware script/check-touched-jsdoc.ts scanner that inventories constructors as explicit exemptions and fails any other touched top-level function or class method without multiline JSDoc --- .../data-state/src/replay/commands.ts | 53 +++++- script/check-touched-jsdoc.ts | 179 ++++++++++++++++++ 2 files changed, 230 insertions(+), 2 deletions(-) create mode 100644 script/check-touched-jsdoc.ts diff --git a/packages-user/data-state/src/replay/commands.ts b/packages-user/data-state/src/replay/commands.ts index 5758821..f93637c 100644 --- a/packages-user/data-state/src/replay/commands.ts +++ b/packages-user/data-state/src/replay/commands.ts @@ -13,22 +13,37 @@ import { REPLAY_COMMAND_ORDER } from './types'; +/** + * 判断未知值是否为有限数值 + */ function isNumber(value: unknown): value is number { return typeof value === 'number' && Number.isFinite(value); } +/** + * 判断未知值是否为可用的道具编号或道具 id + */ function isItem(value: unknown): value is number | string { return isNumber(value) || typeof value === 'string'; } +/** + * 判断未知值是否为布尔值 + */ function isBoolean(value: unknown): value is boolean { return typeof value === 'boolean'; } +/** + * 判断未知值是否为可用的装备槽位编号或槽位 id + */ function isSlot(value: unknown): value is number | string { return isNumber(value) || typeof value === 'string'; } +/** + * 将槽位编号或槽位 id 解析为装备槽位索引 + */ function resolveSlot( state: IReplayCommandState, slot: number | string @@ -46,6 +61,9 @@ class ReplayDirectionCommand implements IReplayCommand { private readonly direction: FaceDirection ) {} + /** + * 按构造方向启动一次勇士移动并等待移动结束 + */ private async moveHero(): Promise { try { const mover = this.state.hero.location.mover; @@ -60,6 +78,9 @@ class ReplayDirectionCommand implements IReplayCommand { } } + /** + * 校验录像步参数并执行一次方向移动 + */ execute(step: IReplayStepHandler): Promise { if (step.params.length !== 0) return Promise.resolve(false); return this.moveHero(); @@ -69,6 +90,9 @@ class ReplayDirectionCommand implements IReplayCommand { class ReplayAutoPathfindCommand implements IReplayCommand { constructor(private readonly state: IReplayCommandState) {} + /** + * 自动寻路到目标坐标并等待寻路结束 + */ private async moveToPoint(x: number, y: number): Promise { try { const result = this.state.pathfinding.moveTo({ x, y }); @@ -80,6 +104,9 @@ class ReplayAutoPathfindCommand implements IReplayCommand { } } + /** + * 校验录像步参数并执行一次自动寻路 + */ execute(step: IReplayStepHandler): Promise { if (step.params.length !== 2) return Promise.resolve(false); const x = step.params[0]; @@ -92,10 +119,16 @@ class ReplayAutoPathfindCommand implements IReplayCommand { class ReplayUseItemCommand implements IReplayCommand { constructor(private readonly state: IReplayCommandState) {} + /** + * 使用指定道具并返回现有状态接口的结果 + */ private useItem(item: number | string): boolean { return this.state.hero.items.useItem(item); } + /** + * 校验录像步参数并执行一次道具使用 + */ execute(step: IReplayStepHandler): Promise { if (step.params.length !== 1) return Promise.resolve(false); const item = step.params[0]; @@ -107,6 +140,9 @@ class ReplayUseItemCommand implements IReplayCommand { class ReplayEquipCommand implements IReplayCommand { constructor(private readonly state: IReplayCommandState) {} + /** + * 将指定装备穿到目标槽位并返回是否穿装成功 + */ private equip( uid: number, slot: number | string, @@ -123,6 +159,9 @@ class ReplayEquipCommand implements IReplayCommand { return equipment.getEquipped(slotIndex) === uid; } + /** + * 校验录像步参数并执行一次装备穿装 + */ execute(step: IReplayStepHandler): Promise { if (step.params.length < 2 || step.params.length > 3) { return Promise.resolve(false); @@ -145,6 +184,9 @@ class ReplayEquipCommand implements IReplayCommand { class ReplayUnequipCommand implements IReplayCommand { constructor(private readonly state: IReplayCommandState) {} + /** + * 卸下指定槽位的装备并返回是否卸下成功 + */ private unequip(slot: number): boolean { const equipment = this.state.hero.equip; if (equipment.getEquipped(slot) === undefined) return false; @@ -152,6 +194,9 @@ class ReplayUnequipCommand implements IReplayCommand { return equipment.getEquipped(slot) === undefined; } + /** + * 校验录像步参数并执行一次装备卸下 + */ execute(step: IReplayStepHandler): Promise { if (step.params.length !== 1) return Promise.resolve(false); const slot = step.params[0]; @@ -162,7 +207,9 @@ class ReplayUnequipCommand implements IReplayCommand { } } -/** 创建按稳定 enum 顺序排列的默认 replay command items */ +/** + * 创建按稳定 enum 顺序排列的默认 replay command items + */ export function createReplayCommandItems( state: IReplayCommandState ): ReadonlyArray { @@ -202,7 +249,9 @@ export function createReplayCommandItems( ]; } -/** 按 top-level stable code 注册 command,并在注册前拒绝重复项 */ +/** + * 按 top-level stable code 注册 command,并在注册前拒绝重复项 + */ export function registerReplayCommandItems( replay: IReplaySystem | IReplayCommandRegistry, items: ReadonlyArray diff --git a/script/check-touched-jsdoc.ts b/script/check-touched-jsdoc.ts new file mode 100644 index 0000000..287991a --- /dev/null +++ b/script/check-touched-jsdoc.ts @@ -0,0 +1,179 @@ +import { readFileSync } from 'node:fs'; +import { relative, resolve } from 'node:path'; +import ts from 'typescript'; + +type DeclarationKind = 'constructor' | 'function' | 'method'; + +interface IInventoryEntry { + readonly file: string; + readonly symbol: string; + readonly kind: DeclarationKind; + readonly line: number; + readonly exempt: boolean; + readonly hasMultilineJsDoc: boolean; +} + +function toDisplayPath(file: string): string { + return relative(process.cwd(), resolve(file)).replaceAll('\\', '/'); +} + +function hasMultilineJsDoc(node: ts.Node, source: ts.SourceFile): boolean { + const ranges = ts.getLeadingCommentRanges(source.text, node.getFullStart()); + if (!ranges || ranges.length === 0) return false; + const range = ranges[ranges.length - 1]; + const comment = source.text.slice(range.pos, range.end); + if (!comment.startsWith('/**')) return false; + if (!/^\/\*\*\r?\n/.test(comment)) return false; + return /\r?\n\s*\*\/$/.test(comment); +} + +function createEntry( + file: string, + symbol: string, + kind: DeclarationKind, + node: ts.Node, + source: ts.SourceFile, + exempt: boolean +): IInventoryEntry { + const position = source.getLineAndCharacterOfPosition( + node.getStart(source) + ); + return { + file, + symbol, + kind, + line: position.line + 1, + exempt, + hasMultilineJsDoc: hasMultilineJsDoc(node, source) + }; +} + +function collectEntries( + file: string, + source: ts.SourceFile +): IInventoryEntry[] { + const entries: IInventoryEntry[] = []; + for (const statement of source.statements) { + if (ts.isFunctionDeclaration(statement) && statement.name) { + entries.push( + createEntry( + file, + statement.name.text, + 'function', + statement, + source, + false + ) + ); + continue; + } + if (!ts.isClassDeclaration(statement) || !statement.name) continue; + const owner = statement.name.text; + for (const member of statement.members) { + if (ts.isConstructorDeclaration(member)) { + entries.push( + createEntry( + file, + `${owner}.constructor`, + 'constructor', + member, + source, + true + ) + ); + continue; + } + if ( + ts.isMethodDeclaration(member) || + ts.isGetAccessorDeclaration(member) || + ts.isSetAccessorDeclaration(member) + ) { + entries.push( + createEntry( + file, + `${owner}.${member.name.getText(source)}`, + 'method', + member, + source, + false + ) + ); + } + } + } + return entries; +} + +function readEntries(file: string): IInventoryEntry[] { + const text = readFileSync(file, 'utf8'); + const source = ts.createSourceFile( + file, + text, + ts.ScriptTarget.Latest, + true, + ts.ScriptKind.TS + ); + return collectEntries(toDisplayPath(file), source); +} + +function report(entries: readonly IInventoryEntry[]): number { + console.log(`Touched JSDoc inventory: ${entries.length} declarations`); + for (const entry of entries) { + const status = entry.exempt + ? 'EXEMPT' + : entry.hasMultilineJsDoc + ? 'MULTILINE' + : 'MISSING'; + console.log( + `INVENTORY ${entry.file}:${entry.line} ${entry.symbol} [${entry.kind}] ${status}` + ); + } + + const constructors = entries.filter(entry => entry.exempt); + if (constructors.length > 0) { + console.log( + `Constructors explicitly exempt from JSDoc: ${constructors + .map(entry => entry.symbol) + .join(', ')}` + ); + } + + const violations = entries.filter( + entry => !entry.exempt && !entry.hasMultilineJsDoc + ); + if (violations.length > 0) { + console.error( + 'check-touched-jsdoc failed: multiline JSDoc missing for' + ); + for (const violation of violations) { + console.error( + ` ${violation.file}:${violation.line} ${violation.symbol} [${violation.kind}]` + ); + } + return 1; + } + + console.log( + 'check-touched-jsdoc passed: every non-constructor declaration has multiline JSDoc' + ); + return 0; +} + +function main(): void { + const files = process.argv.slice(2); + if (files.length === 0) { + console.error( + 'Usage: pnpm exec tsx script/check-touched-jsdoc.ts ' + ); + process.exit(1); + } + const entries = files.flatMap(readEntries); + if (report(entries) !== 0) process.exit(1); +} + +try { + main(); +} catch (error) { + console.error(error instanceof Error ? error.message : error); + process.exit(2); +}