From 56ab9b9359017c14c9cebe949646f346ccb34399 Mon Sep 17 00:00:00 2001 From: sunlei Date: Sat, 25 Jul 2026 11:00:15 +0800 Subject: [PATCH] =?UTF-8?q?fix:=20=E6=94=B6=E7=B4=A7Admin=20JSDoc=E9=97=A8?= =?UTF-8?q?=E7=A6=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- apps/web-antdv-next/src/store/auth.ts | 6 - .../views/_core/authentication/code-login.vue | 4 - internal/commit/husky-fast-check.mjs | 2 +- internal/jsdoc-policy/check-jsdoc.mjs | 615 +++++++++++++++++- internal/jsdoc-policy/check-jsdoc.test.mjs | 341 +++++++++- package.json | 1 + packages/@core/ui-kit/form-ui/src/form-api.ts | 6 - packages/effects/access/src/use-access.ts | 10 - packages/stores/src/modules/timezone.ts | 13 - 9 files changed, 937 insertions(+), 61 deletions(-) diff --git a/apps/web-antdv-next/src/store/auth.ts b/apps/web-antdv-next/src/store/auth.ts index 787f740..2b65908 100644 --- a/apps/web-antdv-next/src/store/auth.ts +++ b/apps/web-antdv-next/src/store/auth.ts @@ -106,12 +106,6 @@ export const useAuthStore = defineStore('auth', () => { await router.push(target); } - /** - * 异步处理登录操作 - * Asynchronously handle the login process - * @param params 登录表单数据 - * @param onSuccess 成功之后的回调函数 - */ async function authLogin( params: Recordable, onSuccess?: () => Promise | void, diff --git a/apps/web-antdv-next/src/views/_core/authentication/code-login.vue b/apps/web-antdv-next/src/views/_core/authentication/code-login.vue index 6b17f5f..d85cf80 100644 --- a/apps/web-antdv-next/src/views/_core/authentication/code-login.vue +++ b/apps/web-antdv-next/src/views/_core/authentication/code-login.vue @@ -87,10 +87,6 @@ const formSchema = computed((): VbenFormSchema[] => { }, ]; }); -/** - * 异步处理登录操作 - * Asynchronously handle the login process - */ async function handleLogin() { loading.value = true; loading.value = false; diff --git a/internal/commit/husky-fast-check.mjs b/internal/commit/husky-fast-check.mjs index d3d22db..c6ffc8a 100644 --- a/internal/commit/husky-fast-check.mjs +++ b/internal/commit/husky-fast-check.mjs @@ -64,7 +64,7 @@ function getStagedFiles() { .filter(Boolean); } -run(getPnpmCommand(), ['run', 'check:jsdoc']); +run(getPnpmCommand(), ['run', 'check:jsdoc:staged']); const files = getStagedFiles().filter((file) => { if (!existsSync(file)) return false; diff --git a/internal/jsdoc-policy/check-jsdoc.mjs b/internal/jsdoc-policy/check-jsdoc.mjs index b485618..d1ebc77 100644 --- a/internal/jsdoc-policy/check-jsdoc.mjs +++ b/internal/jsdoc-policy/check-jsdoc.mjs @@ -11,12 +11,169 @@ import { parse as parseVueSfc } from 'vue/compiler-sfc'; const CODE_FILE_PATTERN = /\.(?:cjs|js|mjs|ts|tsx|vue)$/u; const HAN_CHARACTER_PATTERN = /\p{Script=Han}/u; const JSDOC_PREFIX = '/**'; +const ENGLISH_FUNCTION_WORDS = new Set([ + 'a', + 'after', + 'an', + 'and', + 'are', + 'as', + 'at', + 'be', + 'before', + 'by', + 'for', + 'from', + 'if', + 'in', + 'into', + 'is', + 'of', + 'on', + 'or', + 'otherwise', + 'that', + 'the', + 'these', + 'this', + 'those', + 'to', + 'was', + 'when', + 'whether', + 'with', +]); +const ENGLISH_DESCRIPTION_VERBS = new Set([ + 'build', + 'check', + 'cleanup', + 'convert', + 'create', + 'delete', + 'determine', + 'ensure', + 'fetch', + 'format', + 'get', + 'handle', + 'initialize', + 'load', + 'parse', + 'read', + 'remove', + 'render', + 'resolve', + 'return', + 'save', + 'send', + 'set', + 'transform', + 'update', + 'use', + 'validate', + 'write', +]); +const DESCRIPTION_TAG_NAMES = new Set([ + 'arg', + 'argument', + 'deprecated', + 'description', + 'exception', + 'param', + 'prop', + 'property', + 'remarks', + 'return', + 'returns', + 'see', + 'summary', + 'template', + 'throws', + 'todo', + 'typeparam', + 'yield', + 'yields', +]); +const PARAMETER_TAG_NAMES = new Set([ + 'arg', + 'argument', + 'param', + 'prop', + 'property', + 'template', + 'typeparam', +]); +const RETURN_TAG_NAMES = new Set(['return', 'returns', 'yield', 'yields']); +const MARKER_TAG_NAMES = new Set([ + 'abstract', + 'async', + 'generator', + 'inheritdoc', + 'internal', + 'override', + 'private', + 'protected', + 'public', + 'readonly', + 'static', +]); +const STRUCTURAL_TAG_NAMES = new Set([ + 'alias', + 'author', + 'borrows', + 'callback', + 'constant', + 'constructs', + 'copyright', + 'default', + 'defaultvalue', + 'enum', + 'exports', + 'extends', + 'external', + 'fires', + 'implements', + 'kind', + 'license', + 'link', + 'listens', + 'member', + 'memberof', + 'mixes', + 'module', + 'name', + 'namespace', + 'requires', + 'satisfies', + 'since', + 'this', + 'type', + 'typedef', + 'version', +]); +const TYPE_ONLY_WORDS = new Set([ + 'any', + 'bigint', + 'boolean', + 'never', + 'null', + 'number', + 'object', + 'string', + 'symbol', + 'undefined', + 'unknown', + 'void', +]); const LIFECYCLE_METHOD_NAMES = new Set([ 'activated', 'beforeCreate', 'beforeDestroy', 'beforeMount', + 'beforeRouteEnter', + 'beforeRouteLeave', + 'beforeRouteUpdate', 'beforeUnmount', 'beforeUpdate', 'componentDidCatch', @@ -30,6 +187,8 @@ const LIFECYCLE_METHOD_NAMES = new Set([ 'deactivated', 'destroyed', 'errorCaptured', + 'getDerivedStateFromError', + 'getDerivedStateFromProps', 'getSnapshotBeforeUpdate', 'mounted', 'renderTracked', @@ -46,6 +205,52 @@ function getIsolatedGitEnvironment() { ); } +function isPathInside(parentPath, candidatePath) { + const relativePath = path.relative(parentPath, candidatePath); + return ( + relativePath === '' || + (!relativePath.startsWith('..') && !path.isAbsolute(relativePath)) + ); +} + +function getStagedGitEnvironment(rootDirectory) { + const environment = getIsolatedGitEnvironment(); + const indexFile = process.env.GIT_INDEX_FILE; + + if (!indexFile) { + return environment; + } + + const gitDirectory = execFileSync( + 'git', + ['rev-parse', '--path-format=absolute', '--git-dir'], + { + cwd: rootDirectory, + encoding: 'utf8', + env: environment, + }, + ).trim(); + const commonDirectory = execFileSync( + 'git', + ['rev-parse', '--path-format=absolute', '--git-common-dir'], + { + cwd: rootDirectory, + encoding: 'utf8', + env: environment, + }, + ).trim(); + const absoluteIndexFile = path.resolve(rootDirectory, indexFile); + + if ( + isPathInside(gitDirectory, absoluteIndexFile) || + isPathInside(commonDirectory, absoluteIndexFile) + ) { + environment.GIT_INDEX_FILE = absoluteIndexFile; + } + + return environment; +} + function getLineStarts(source) { const starts = [0]; @@ -84,6 +289,300 @@ function getLineAndColumn(lineStarts, position) { }; } +function stripLeadingTypeExpression(value) { + const trimmedValue = value.trimStart(); + if (!trimmedValue.startsWith('{')) { + return trimmedValue; + } + + let depth = 0; + for (let index = 0; index < trimmedValue.length; index += 1) { + const character = trimmedValue[index]; + if (character === '{') { + depth += 1; + } else if (character === '}') { + depth -= 1; + if (depth === 0) { + return trimmedValue.slice(index + 1).trimStart(); + } + } + } + + return trimmedValue; +} + +function isTypeOnlyDescription(value) { + const normalizedValue = value.trim(); + if (normalizedValue === '') { + return true; + } + + const typeParts = normalizedValue.split(/\s*[|&]\s*/u); + return typeParts.every((part) => { + const arraylessPart = part.replaceAll(/\[\]$/gu, ''); + if (TYPE_ONLY_WORDS.has(arraylessPart)) { + return true; + } + return /^[A-Z_$][\w$]*(?:<.+>)?$/u.test(arraylessPart); + }); +} + +function getTagDescription(tagName, rawValue) { + if (tagName === 'example') { + return null; + } + if (STRUCTURAL_TAG_NAMES.has(tagName)) { + const separatorIndex = rawValue.indexOf(' - '); + return separatorIndex === -1 + ? null + : rawValue.slice(separatorIndex + 3).trim() || null; + } + if (tagName === 'see') { + const separatorIndex = rawValue.indexOf(' - '); + return separatorIndex === -1 + ? null + : rawValue.slice(separatorIndex + 3).trim() || null; + } + if (!DESCRIPTION_TAG_NAMES.has(tagName) && !MARKER_TAG_NAMES.has(tagName)) { + return rawValue.trim() || null; + } + + let value = stripLeadingTypeExpression(rawValue); + if (PARAMETER_TAG_NAMES.has(tagName)) { + value = value + .replace(/^(?:\[[^\]]+\]|[^\s-]+)\s*/u, '') + .replace(/^-\s*/u, ''); + } else if (RETURN_TAG_NAMES.has(tagName) && isTypeOnlyDescription(value)) { + return null; + } else if ( + (tagName === 'throws' || tagName === 'exception') && + isTypeOnlyDescription(value) + ) { + return null; + } + + const description = value.trim(); + return description === '' ? null : description; +} + +function getJsdocDescriptionUnits(rawComment) { + const bodyLines = rawComment + .replace(/^\/\*\*/u, '') + .replace(/\*\/$/u, '') + .split(/\r\n|\r|\n/gu) + .map((line) => line.replace(/^\s*\*\s?/u, '').trimEnd()); + const units = []; + let current = { + context: '主说明', + lines: [], + startLineOffset: 0, + tagName: null, + }; + let insideFence = false; + + function flushCurrent() { + const firstContentLine = current.lines.findIndex( + (line) => line.trim() !== '', + ); + if (firstContentLine === -1) { + return; + } + const rawValue = current.lines.slice(firstContentLine).join('\n').trim(); + if (rawValue === '') { + return; + } + const lineOffset = current.startLineOffset + firstContentLine; + + if (current.tagName === null) { + units.push({ context: current.context, lineOffset, text: rawValue }); + return; + } + + const description = getTagDescription(current.tagName, rawValue); + if (description !== null) { + units.push({ context: current.context, lineOffset, text: description }); + } + } + + for (const [lineOffset, line] of bodyLines.entries()) { + const fenceCount = line.match(/```/gu)?.length ?? 0; + if (insideFence) { + current.lines.push(line); + if (fenceCount % 2 === 1) { + insideFence = false; + } + continue; + } + if (fenceCount > 0) { + current.lines.push(line); + if (fenceCount % 2 === 1) { + insideFence = true; + } + continue; + } + if (current.tagName === null && line.trim() === '') { + flushCurrent(); + current = { + context: '主说明', + lines: [], + startLineOffset: lineOffset + 1, + tagName: null, + }; + continue; + } + + const tagMatch = line.match(/^@([A-Za-z][\w-]*)(?:\s(.*))?$/u); + if (tagMatch) { + if (current.tagName === 'example' && /^[A-Z]/u.test(tagMatch[1])) { + continue; + } + flushCurrent(); + const tagName = tagMatch[1].toLowerCase(); + current = { + context: `@${tagName}`, + lines: tagName === 'example' ? [] : [tagMatch[2] ?? ''], + startLineOffset: lineOffset, + tagName, + }; + continue; + } + + if (current.tagName !== 'example') { + current.lines.push(line); + } + } + flushCurrent(); + + return units; +} + +function maskNeutralDescriptionFragments(value) { + return value + .replaceAll(/```[\s\S]*?```/gu, ' ') + .replaceAll(/`[^`\r\n]*`/gu, ' ') + .replaceAll(/\{@(?:code|link|linkcode|linkplain)\s[^}]+\}/giu, ' ') + .replaceAll(/\[([^\]]*)\]\((?:[^()]|\([^()]*\))*\)/gu, '$1') + .replaceAll(/\bhttps?:\/\/[^\s)]+/giu, ' ') + .replaceAll(/\{[A-Za-z_$][^{}\r\n]*\}/gu, ' '); +} + +function getEnglishWords(value) { + return value.match(/[A-Za-z][\w$-]*/gu) ?? []; +} + +function isStrongTechnicalWord(word) { + return ( + /^[A-Z]{2}[A-Z0-9]*$/u.test(word) || + /[0-9_$]/u.test(word) || + /[a-z][A-Z]/u.test(word) || + /[A-Z].*[A-Z]/u.test(word) + ); +} + +function normalizeDescriptionVerb(word) { + const normalizedWord = word.toLowerCase(); + const withoutIng = normalizedWord.replace(/ing$/u, ''); + const withoutEd = normalizedWord.replace(/ed$/u, ''); + const candidates = [ + normalizedWord, + normalizedWord.replace(/ies$/u, 'y'), + withoutIng, + `${withoutIng}e`, + withoutEd, + `${withoutEd}e`, + normalizedWord.replace(/es$/u, ''), + normalizedWord.replace(/s$/u, ''), + ]; + return candidates.find((candidate) => + ENGLISH_DESCRIPTION_VERBS.has(candidate), + ); +} + +function hasEnglishClause(value) { + const ordinaryWords = getEnglishWords(value).filter( + (word) => !isStrongTechnicalWord(word), + ); + const normalizedWords = ordinaryWords.map((word) => word.toLowerCase()); + + if ( + ordinaryWords.length >= 2 && + normalizedWords.some((word) => ENGLISH_FUNCTION_WORDS.has(word)) + ) { + return true; + } + if ( + ordinaryWords.length >= 2 && + normalizeDescriptionVerb(ordinaryWords[0]) !== undefined + ) { + const verbIndex = value.indexOf(ordinaryWords[0]); + const prefix = verbIndex === -1 ? '' : value.slice(0, verbIndex); + const prefixHanCount = prefix.match(/\p{Script=Han}/gu)?.length ?? 0; + if ( + prefix.trim() === '' || + prefixHanCount <= 2 || + /[,,::]\s*$/u.test(prefix) + ) { + return true; + } + } + + const hanCount = value.match(/\p{Script=Han}/gu)?.length ?? 0; + return ordinaryWords.length >= 4 && ordinaryWords.length > hanCount; +} + +function isPureTechnicalContinuation(value) { + const words = getEnglishWords(value); + if (words.length === 0 || words.length > 3) { + return false; + } + if ( + words.some((word) => ENGLISH_FUNCTION_WORDS.has(word.toLowerCase())) || + normalizeDescriptionVerb(words[0]) !== undefined + ) { + return false; + } + + return words.some( + (word) => isStrongTechnicalWord(word) || /^[A-Z][a-z]+$/u.test(word), + ); +} + +function findNonChineseDescription(rawComment) { + for (const unit of getJsdocDescriptionUnits(rawComment)) { + const maskedText = maskNeutralDescriptionFragments(unit.text); + const unitHasHan = HAN_CHARACTER_PATTERN.test(maskedText); + const lines = maskedText.split(/\r\n|\r|\n/gu); + + for (const [lineOffset, line] of lines.entries()) { + const segments = line.split(/[。!?!?;;]+|(?<=\.)\s+/gu); + for (const segment of segments) { + const normalizedSegment = segment.trim(); + if (!/[\p{L}\p{N}]/u.test(normalizedSegment)) { + continue; + } + const segmentHasHan = HAN_CHARACTER_PATTERN.test(normalizedSegment); + if (!segmentHasHan) { + if (unitHasHan && isPureTechnicalContinuation(normalizedSegment)) { + continue; + } + return { + context: unit.context, + lineOffset: unit.lineOffset + lineOffset, + }; + } + if (hasEnglishClause(normalizedSegment)) { + return { + context: unit.context, + lineOffset: unit.lineOffset + lineOffset, + }; + } + } + } + } + + return null; +} + function getScriptKind(filePath, language) { const normalizedLanguage = language?.toLowerCase(); @@ -166,19 +665,34 @@ function getNodeName(node, sourceFile) { ) { return node.name.text; } + if (ts.isComputedPropertyName(node.name)) { + const expression = node.name.expression; + if (ts.isStringLiteralLike(expression) || ts.isNumericLiteral(expression)) { + return expression.text; + } + } return node.name.getText(sourceFile); } -function isSetupFunctionExpression(node, sourceFile) { - if (getNodeName(node, sourceFile) === 'setup') { - return true; +function getFunctionExpressionRole(node, sourceFile) { + let expression = node; + while ( + (ts.isParenthesizedExpression(expression.parent) || + ts.isAsExpression(expression.parent) || + ts.isTypeAssertionExpression(expression.parent) || + ts.isNonNullExpression(expression.parent) || + ts.isSatisfiesExpression(expression.parent)) && + expression.parent.expression === expression + ) { + expression = expression.parent; } - const parent = node.parent; - return ( - ts.isPropertyAssignment(parent) && - getNodeName(parent, sourceFile) === 'setup' - ); + const parent = expression.parent; + if (ts.isPropertyAssignment(parent) && parent.initializer === expression) { + return getNodeName(parent, sourceFile); + } + + return getNodeName(node, sourceFile); } function classifyTarget(node, sourceFile) { @@ -202,9 +716,13 @@ function classifyTarget(node, sourceFile) { if (!name) { return { allowed: false, target: 'anonymous-function-expression' }; } - if (isSetupFunctionExpression(node, sourceFile)) { + const role = getFunctionExpressionRole(node, sourceFile); + if (role === 'setup') { return { allowed: false, target: 'setup' }; } + if (role && LIFECYCLE_METHOD_NAMES.has(role)) { + return { allowed: false, target: 'lifecycle-entry' }; + } return { allowed: true, target: 'named-function-expression' }; } @@ -385,12 +903,19 @@ function inspectScriptBlock(filePath, block, fullSource, lineStarts) { let rule = null; let message = null; - if (!classification.allowed) { + if (classification.allowed) { + const invalidDescription = findNonChineseDescription(rawComment.text); + if (invalidDescription !== null) { + rule = 'non-chinese-description'; + message = `JSDoc ${invalidDescription.context}的自然语言描述必须使用中文`; + location.line += invalidDescription.lineOffset; + if (invalidDescription.lineOffset > 0) { + location.column = 1; + } + } + } else { rule = 'invalid-target'; message = `JSDoc 不允许用于 ${classification.target}`; - } else if (!HAN_CHARACTER_PATTERN.test(rawComment.text)) { - rule = 'non-chinese-description'; - message = 'JSDoc 自然语言描述必须使用中文'; } return { @@ -457,12 +982,27 @@ function collectSyntaxTokens(source, scriptKind, filePath) { } const text = node.getText(sourceFile); if (text.length > 0) { - tokens.push([node.kind, text]); + tokens.push({ + end: node.end, + kind: node.kind, + start: node.getStart(sourceFile), + text, + }); } } visit(sourceFile); - return tokens; + const sortedTokens = tokens.toSorted( + (left, right) => left.start - right.start || left.end - right.end, + ); + return sortedTokens.map((token, index) => { + const previousToken = sortedTokens[index - 1]; + const hasLineTerminatorBefore = + previousToken === undefined + ? false + : /[\r\n]/u.test(source.slice(previousToken.end, token.start)); + return [token.kind, token.text, hasLineTerminatorBefore]; + }); } export function getCodeTokenSignature(filePath, source) { @@ -591,6 +1131,21 @@ export function getTrackedCodeFiles(rootDirectory = process.cwd()) { .toSorted(); } +export function getStagedCodeFiles(rootDirectory = process.cwd()) { + const output = execFileSync('git', ['ls-files', '--cached', '-z'], { + cwd: rootDirectory, + encoding: 'utf8', + env: getStagedGitEnvironment(rootDirectory), + }); + + return output + .split('\0') + .filter(Boolean) + .map((filePath) => filePath.replaceAll('\\', '/')) + .filter((filePath) => CODE_FILE_PATTERN.test(filePath)) + .toSorted(); +} + function summarizeInspections(inspections) { const summary = { 'invalid-target': 0, @@ -631,6 +1186,24 @@ function inspectTrackedFiles(rootDirectory) { }); } +function inspectStagedFiles(rootDirectory) { + const environment = getStagedGitEnvironment(rootDirectory); + return getStagedCodeFiles(rootDirectory).map((filePath) => { + const source = execFileSync('git', ['show', `:${filePath}`], { + cwd: rootDirectory, + encoding: 'utf8', + env: environment, + maxBuffer: 64 * 1024 * 1024, + }); + return { + absolutePath: null, + filePath, + result: inspectFileSource(filePath, source), + source, + }; + }); +} + function formatViolation(violation) { const blockSuffix = violation.block ? ` ${violation.block}` : ''; return `${violation.filePath}:${violation.line}:${violation.column} [${violation.rule}]${blockSuffix} ${violation.message}`; @@ -650,14 +1223,22 @@ function printSummary(summary, prefix = 'JSDoc 检查') { function runCli() { const args = new Set(process.argv.slice(2)); const fix = args.delete('--fix'); + const staged = args.delete('--staged'); if (args.size > 0) { console.error(`未知参数:${[...args].join(', ')}`); process.exitCode = 2; return; } + if (fix && staged) { + console.error('暂存区检查不支持 --fix'); + process.exitCode = 2; + return; + } const rootDirectory = process.cwd(); - const inspections = inspectTrackedFiles(rootDirectory); + const inspections = staged + ? inspectStagedFiles(rootDirectory) + : inspectTrackedFiles(rootDirectory); const initialSummary = summarizeInspections(inspections); if (!fix) { @@ -666,7 +1247,7 @@ function runCli() { console.error(formatViolation(violation)); } } - printSummary(initialSummary); + printSummary(initialSummary, staged ? 'JSDoc 暂存区检查' : 'JSDoc 检查'); if (initialSummary.violationCount > 0) { process.exitCode = 1; } diff --git a/internal/jsdoc-policy/check-jsdoc.test.mjs b/internal/jsdoc-policy/check-jsdoc.test.mjs index 2be0b58..c53425d 100644 --- a/internal/jsdoc-policy/check-jsdoc.test.mjs +++ b/internal/jsdoc-policy/check-jsdoc.test.mjs @@ -1,8 +1,16 @@ import assert from 'node:assert/strict'; -import { execFileSync } from 'node:child_process'; -import { mkdtempSync, rmSync, writeFileSync } from 'node:fs'; +import { execFileSync, spawnSync } from 'node:child_process'; +import { + copyFileSync, + mkdtempSync, + readFileSync, + rmSync, + writeFileSync, +} from 'node:fs'; import { tmpdir } from 'node:os'; import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { runInNewContext } from 'node:vm'; import { it } from 'vitest'; @@ -13,11 +21,24 @@ import { inspectFileSource, } from './check-jsdoc.mjs'; -function runFixtureGit(repository, args) { +const CHECKER_PATH = fileURLToPath(new URL('check-jsdoc.mjs', import.meta.url)); + +function runFixtureGit(repository, args, extraEnvironment = {}) { const environment = Object.fromEntries( Object.entries(process.env).filter(([name]) => !name.startsWith('GIT_')), ); - execFileSync('git', args, { cwd: repository, env: environment }); + execFileSync('git', args, { + cwd: repository, + env: { ...environment, ...extraEnvironment }, + }); +} + +function runStagedChecker(repository, extraEnvironment = {}) { + return spawnSync(process.execPath, [CHECKER_PATH, '--staged'], { + cwd: repository, + encoding: 'utf8', + env: { ...process.env, ...extraEnvironment }, + }); } it('allows Chinese JSDoc only on explicitly named callable declarations', () => { @@ -55,6 +76,228 @@ it('rejects pure-English JSDoc on an otherwise valid named function', () => { assert.equal(result.violations[0]?.target, 'named-function-declaration'); }); +it('rejects English prose hidden by Chinese text while allowing technical terms', () => { + const source = ` +/** 通过 HTTP 连接 MySQL,并按 userId 返回 DTO。 */ +function loadValidData() {} + +/** 用于获取loading的html模板。 */ +function getLoadingTemplate() {} + +/** + * 按用户标识查询。 + * @param {string} userId - 用户 ID。 + * @returns {Promise} 查询结果。 + * @example + * const 中文变量 = await loadValidData(); + */ +function loadTaggedData() {} + +/** + * 读取二进制内容。 + * @returns RequestResponse + */ +function readBlobResponse() {} + +/** 中文引子。 Determine whether there is permission from the user role. */ +function checkAccess() {} + +/** + * 格式化计数。 + * @param {string} value - The counter value. + * @param fields record + * @returns Counter text such as \`7890\` or \`12.3万\`. + */ +function formatCounter() {} + +/** + * Parse the payload. + * @example + * const 中文变量 = parsePayload(); + */ +function parsePayload() {} + +/** + * 返回载荷。 + * @returns payload + */ +function readPayload() {} +`; + + const result = inspectFileSource('mixed-language.ts', source); + + assert.equal(result.comments.length, 8); + assert.deepEqual( + result.violations.map(({ rule, target }) => [rule, target]), + [ + ['non-chinese-description', 'named-function-declaration'], + ['non-chinese-description', 'named-function-declaration'], + ['non-chinese-description', 'named-function-declaration'], + ['non-chinese-description', 'named-function-declaration'], + ], + ); + assert.deepEqual( + result.violations.map(({ message }) => message), + [ + 'JSDoc 主说明的自然语言描述必须使用中文', + 'JSDoc @param的自然语言描述必须使用中文', + 'JSDoc 主说明的自然语言描述必须使用中文', + 'JSDoc @returns的自然语言描述必须使用中文', + ], + ); + assert.equal( + result.violations[1]?.line, + source.slice(0, source.indexOf('@param {string} value')).split('\n').length, + ); +}); + +it('treats inline code as neutral and validates custom tag descriptions', () => { + const validSource = ` +/** + * 使用 {@code access token} 完成认证。 + * @custom 中文扩展说明。 + * @public + */ +function authenticate() {} +`; + const invalidSource = ` +/** + * 执行扩展检查。 + * @custom Determine whether the extension is enabled. + */ +function checkExtension() {} +`; + const codeMaskedSource = ` +/** + * 执行载荷检查。 + * @custom payload {@code 中文变量} + */ +function checkPayload() {} +`; + + assert.deepEqual( + inspectFileSource('inline-code.ts', validSource).violations, + [], + ); + const invalidResult = inspectFileSource('custom-tag.ts', invalidSource); + assert.equal(invalidResult.violations.length, 1); + assert.equal( + invalidResult.violations[0]?.message, + 'JSDoc @custom的自然语言描述必须使用中文', + ); + assert.equal( + inspectFileSource('custom-code.ts', codeMaskedSource).violations.length, + 1, + ); +}); + +it('validates prose after structural tag payloads', () => { + const validSource = ` +/** + * 创建选项类型。 + * @typedef {object} Options - 选项说明。 + * @extends {Base} - 基类说明。 + */ +function createOptions() {} +`; + const invalidSource = ` +/** + * 创建选项类型。 + * @typedef {object} Options - This is English prose. + */ +function createOptions() {} +`; + + assert.deepEqual( + inspectFileSource('structural-valid.ts', validSource).violations, + [], + ); + const invalidResult = inspectFileSource( + 'structural-invalid.ts', + invalidSource, + ); + assert.equal(invalidResult.violations.length, 1); + assert.equal( + invalidResult.violations[0]?.message, + 'JSDoc @typedef的自然语言描述必须使用中文', + ); +}); + +it('does not parse fenced code or example decorators as JSDoc tags', () => { + const source = ` +/** + * 展示装饰器示例。 + * \`\`\`ts + * @returns payload + * \`\`\` + * @example + * @Component + * class Demo {} + */ +function showDecoratorExample() {} +`; + + assert.deepEqual(inspectFileSource('example.ts', source).violations, []); +}); + +it('does not share a Chinese anchor across main-description paragraphs', () => { + const invalidSource = ` +/** + * 中文说明。 + * + * Cache expires tomorrow. + */ +function readCache() {} +`; + const validSource = ` +/** + * 解析载荷。 + * HTTP payload + * 并返回结果。 + */ +function readPayload() {} +`; + + assert.equal( + inspectFileSource('paragraph-invalid.ts', invalidSource).violations.length, + 1, + ); + assert.deepEqual( + inspectFileSource('paragraph-valid.ts', validSource).violations, + [], + ); +}); + +it('rejects setup and lifecycle property function expressions by property role', () => { + const source = ` +const component = { + mounted: /** 组件挂载入口。 */ function mountedHook() {}, + setup: /** 组件初始化入口。 */ function namedSetup() {}, + ['mounted']: /** 计算属性挂载入口。 */ function computedMountedHook() {}, + ['setup']: /** 计算属性初始化入口。 */ function computedSetup() {}, + updated: (/** 括号包裹的更新入口。 */ function updatedHook() {}), + task: /** 执行普通任务。 */ function namedTask() {}, + ['computedTask']: /** 执行普通计算属性任务。 */ function computedTask() {}, +}; + +/** 读取普通同名函数。 */ +function mounted() {} +`; + + const result = inspectFileSource('lifecycle-expression.ts', source); + + assert.deepEqual( + result.violations.map(({ rule, target }) => [rule, target]), + [ + ['invalid-target', 'lifecycle-entry'], + ['invalid-target', 'setup'], + ['invalid-target', 'lifecycle-entry'], + ['invalid-target', 'setup'], + ['invalid-target', 'lifecycle-entry'], + ], + ); +}); + it('rejects declarations and callables excluded by the policy', () => { const source = ` /** 接口说明。 */ @@ -223,6 +466,32 @@ const named = /** 保留具名函数表达式说明。 */ function namedExpressi assert.equal(getCodeTokenSignature('cleanup.ts', fixed.source), beforeTokens); }); +it('preserves and compares line terminators around restricted productions', () => { + const source = `function readValue() { + return /** Remove this English description. + * It spans multiple lines. + */ 1; +}`; + const unsafeSource = source.replace( + '/** Remove this English description.\n * It spans multiple lines.\n */ ', + '', + ); + + assert.notEqual( + getCodeTokenSignature('restricted.ts', unsafeSource), + getCodeTokenSignature('restricted.ts', source), + ); + + const fixed = applyJsdocPolicyFix('restricted.ts', source); + + assert.equal( + getCodeTokenSignature('restricted.ts', fixed.source), + getCodeTokenSignature('restricted.ts', source), + ); + assert.equal(runInNewContext(`${source}; readValue();`), undefined); + assert.equal(runInNewContext(`${fixed.source}; readValue();`), undefined); +}); + it('tracked-file discovery also includes untracked non-ignored code', () => { const repository = mkdtempSync(path.join(tmpdir(), 'admin-jsdoc-policy-')); @@ -250,3 +519,67 @@ it('tracked-file discovery also includes untracked non-ignored code', () => { rmSync(repository, { force: true, recursive: true }); } }); + +it('staged mode checks the actual index including a valid temporary index', () => { + const repository = mkdtempSync(path.join(tmpdir(), 'admin-jsdoc-staged-')); + const validSource = '/** 读取配置。 */\nfunction readConfig() {}\n'; + const invalidSource = + '/** Read the current configuration. */\nfunction readConfig() {}\n'; + + try { + runFixtureGit(repository, ['init', '--quiet']); + runFixtureGit(repository, ['config', 'user.email', 'fixture@example.com']); + runFixtureGit(repository, ['config', 'user.name', 'Fixture']); + writeFileSync(path.join(repository, 'tracked.ts'), validSource); + writeFileSync(path.join(repository, 'deleted.ts'), validSource); + runFixtureGit(repository, ['add', 'tracked.ts', 'deleted.ts']); + runFixtureGit(repository, ['commit', '--quiet', '-m', 'baseline']); + + writeFileSync(path.join(repository, 'tracked.ts'), invalidSource); + runFixtureGit(repository, ['add', 'tracked.ts']); + writeFileSync(path.join(repository, 'tracked.ts'), validSource); + assert.equal(runStagedChecker(repository).status, 1); + + runFixtureGit(repository, ['add', 'tracked.ts']); + writeFileSync(path.join(repository, 'tracked.ts'), invalidSource); + assert.equal(runStagedChecker(repository).status, 0); + + writeFileSync(path.join(repository, 'added.ts'), invalidSource); + runFixtureGit(repository, ['add', 'added.ts']); + assert.equal(runStagedChecker(repository).status, 1); + runFixtureGit(repository, ['rm', '--cached', '--quiet', 'added.ts']); + assert.equal(runStagedChecker(repository).status, 0); + + rmSync(path.join(repository, 'deleted.ts')); + runFixtureGit(repository, ['add', '--update', 'deleted.ts']); + assert.equal(runStagedChecker(repository).status, 0); + + writeFileSync(path.join(repository, 'tracked.ts'), validSource); + runFixtureGit(repository, ['add', 'tracked.ts']); + const indexPath = path.join(repository, '.git', 'index'); + const temporaryIndexPath = path.join( + repository, + '.git', + 'temporary-commit-index', + ); + copyFileSync(indexPath, temporaryIndexPath); + writeFileSync(path.join(repository, 'tracked.ts'), invalidSource); + runFixtureGit(repository, ['add', 'tracked.ts'], { + GIT_INDEX_FILE: temporaryIndexPath, + }); + writeFileSync(path.join(repository, 'tracked.ts'), validSource); + const indexBefore = readFileSync(indexPath); + const result = runStagedChecker(repository, { + GIT_COMMON_DIR: path.join(repository, 'hostile-common-dir'), + GIT_DIR: path.join(repository, 'hostile-git-dir'), + GIT_INDEX_FILE: temporaryIndexPath, + GIT_PREFIX: 'hostile-prefix/', + }); + + assert.equal(result.status, 1); + assert.match(result.stderr, /tracked\.ts/u); + assert.deepEqual(readFileSync(indexPath), indexBefore); + } finally { + rmSync(repository, { force: true, recursive: true }); + } +}, 15_000); diff --git a/package.json b/package.json index 6ffdb26..5732d2b 100644 --- a/package.json +++ b/package.json @@ -10,6 +10,7 @@ "build:antdv-next": "pnpm run build --filter=@vben/web-antdv-next", "check": "pnpm run check:type", "check:jsdoc": "pnpm run test:jsdoc && node internal/jsdoc-policy/check-jsdoc.mjs", + "check:jsdoc:staged": "pnpm run test:jsdoc && node internal/jsdoc-policy/check-jsdoc.mjs --staged", "check:type": "turbo run typecheck", "clean": "rimraf .turbo apps/*/dist apps/*/dist.zip packages/**/dist packages/**/dist.zip internal/**/dist internal/**/dist.zip", "dev": "pnpm run dev:antdv-next", diff --git a/packages/@core/ui-kit/form-ui/src/form-api.ts b/packages/@core/ui-kit/form-ui/src/form-api.ts index 935aad0..7b34e8e 100644 --- a/packages/@core/ui-kit/form-ui/src/form-api.ts +++ b/packages/@core/ui-kit/form-ui/src/form-api.ts @@ -310,12 +310,6 @@ export class FormApi { } } - /** - * 设置表单值 - * @param fields record - * @param filterFields 过滤不在schema中定义的字段 默认为true - * @param shouldValidate - */ async setValues( fields: Record, filterFields: boolean = true, diff --git a/packages/effects/access/src/use-access.ts b/packages/effects/access/src/use-access.ts index 939cdbe..9ded006 100644 --- a/packages/effects/access/src/use-access.ts +++ b/packages/effects/access/src/use-access.ts @@ -10,22 +10,12 @@ function useAccess() { return preferences.app.accessMode; }); - /** - * 基于角色判断是否有权限 - * @description: Determine whether there is permission,The role is judged by the user's role - * @param roles - */ function hasAccessByRoles(roles: string[]) { const userRoleSet = new Set(userStore.userRoles); const intersection = roles.filter((item) => userRoleSet.has(item)); return intersection.length > 0; } - /** - * 基于权限码判断是否有权限 - * @description: Determine whether there is permission,The permission code is judged by the user's permission code - * @param codes - */ function hasAccessByCodes(codes: string[]) { const userCodesSet = new Set(accessStore.accessCodes); diff --git a/packages/stores/src/modules/timezone.ts b/packages/stores/src/modules/timezone.ts index 53ecc6f..d00d88d 100644 --- a/packages/stores/src/modules/timezone.ts +++ b/packages/stores/src/modules/timezone.ts @@ -51,10 +51,6 @@ const useTimezoneStore = defineStore( () => { const timezoneRef = ref(getCurrentTimezone()); - /** - * 初始化时区 - * Initialize the timezone - */ async function initTimezone() { const timezoneHandler = getTimezoneHandler(); const timezone = await timezoneHandler.getTimezone?.(); @@ -65,11 +61,6 @@ const useTimezoneStore = defineStore( setCurrentTimezone(unref(timezoneRef)); } - /** - * 设置时区 - * Set the timezone - * @param timezone 时区字符串 - */ async function setTimezone(timezone: string) { const timezoneHandler = getTimezoneHandler(); await timezoneHandler.setTimezone?.(timezone); @@ -78,10 +69,6 @@ const useTimezoneStore = defineStore( setCurrentTimezone(timezone); } - /** - * 获取时区选项 - * Get the timezone options - */ async function getTimezoneOptions() { const timezoneHandler = getTimezoneHandler(); return (await timezoneHandler.getTimezoneOptions?.()) || [];