fix: 收紧Admin JSDoc门禁
This commit is contained in:
parent
523f96c3b2
commit
56ab9b9359
@ -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<any>,
|
||||
onSuccess?: () => Promise<void> | void,
|
||||
|
||||
@ -87,10 +87,6 @@ const formSchema = computed((): VbenFormSchema[] => {
|
||||
},
|
||||
];
|
||||
});
|
||||
/**
|
||||
* 异步处理登录操作
|
||||
* Asynchronously handle the login process
|
||||
*/
|
||||
async function handleLogin() {
|
||||
loading.value = true;
|
||||
loading.value = false;
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
@ -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<Result>} 查询结果。
|
||||
* @example
|
||||
* const 中文变量 = await loadValidData();
|
||||
*/
|
||||
function loadTaggedData() {}
|
||||
|
||||
/**
|
||||
* 读取二进制内容。
|
||||
* @returns RequestResponse<Blob>
|
||||
*/
|
||||
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);
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -310,12 +310,6 @@ export class FormApi {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 设置表单值
|
||||
* @param fields record
|
||||
* @param filterFields 过滤不在schema中定义的字段 默认为true
|
||||
* @param shouldValidate
|
||||
*/
|
||||
async setValues(
|
||||
fields: Record<string, any>,
|
||||
filterFields: boolean = true,
|
||||
|
||||
@ -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);
|
||||
|
||||
|
||||
@ -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?.()) || [];
|
||||
|
||||
Loading…
Reference in New Issue
Block a user