1355 lines
33 KiB
JavaScript
1355 lines
33 KiB
JavaScript
#!/usr/bin/env node
|
|
|
|
import { execFileSync } from 'node:child_process';
|
|
import { readFileSync, writeFileSync } from 'node:fs';
|
|
import path from 'node:path';
|
|
import { pathToFileURL } from 'node:url';
|
|
|
|
import ts from 'typescript';
|
|
|
|
const SOURCE_EXTENSIONS = new Set([
|
|
'.cjs',
|
|
'.js',
|
|
'.mjs',
|
|
'.ts',
|
|
'.tsx',
|
|
'.vue',
|
|
]);
|
|
const SOURCE_PATHSPECS = ['*.cjs', '*.js', '*.mjs', '*.ts', '*.tsx', '*.vue'];
|
|
const CHINESE_CHARACTER_PATTERN = /\p{Script=Han}/u;
|
|
const LINE_TERMINATOR_PATTERN = /\r\n|\r|\n/u;
|
|
const VUE_SCRIPT_PATTERN = /<script\b([^>]*)>([\s\S]*?)<\/script\s*>/giu;
|
|
const TYPEORM_ENTITY_LISTENER_DECORATORS = new Set([
|
|
'AfterInsert',
|
|
'AfterLoad',
|
|
'AfterRecover',
|
|
'AfterRemove',
|
|
'AfterSoftRemove',
|
|
'AfterUpdate',
|
|
'BeforeInsert',
|
|
'BeforeRecover',
|
|
'BeforeRemove',
|
|
'BeforeSoftRemove',
|
|
'BeforeUpdate',
|
|
]);
|
|
const CODE_ONLY_TAGS = new Set(['example']);
|
|
const STRUCTURAL_TAGS = 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 BARE_TYPE_TAGS = new Set([
|
|
'exception',
|
|
'return',
|
|
'returns',
|
|
'throws',
|
|
'yield',
|
|
'yields',
|
|
]);
|
|
const PARAMETER_TAGS = new Set([
|
|
'arg',
|
|
'argument',
|
|
'param',
|
|
'prop',
|
|
'property',
|
|
'template',
|
|
'typeparam',
|
|
]);
|
|
const BUILTIN_TYPE_NAMES = new Set([
|
|
'any',
|
|
'bigint',
|
|
'boolean',
|
|
'never',
|
|
'null',
|
|
'number',
|
|
'object',
|
|
'string',
|
|
'symbol',
|
|
'undefined',
|
|
'unknown',
|
|
'void',
|
|
]);
|
|
const ENGLISH_FUNCTION_WORDS = new Set([
|
|
'a',
|
|
'after',
|
|
'an',
|
|
'and',
|
|
'are',
|
|
'as',
|
|
'at',
|
|
'before',
|
|
'by',
|
|
'for',
|
|
'from',
|
|
'if',
|
|
'in',
|
|
'into',
|
|
'is',
|
|
'it',
|
|
'its',
|
|
'of',
|
|
'on',
|
|
'or',
|
|
'otherwise',
|
|
'that',
|
|
'the',
|
|
'these',
|
|
'this',
|
|
'those',
|
|
'to',
|
|
'was',
|
|
'were',
|
|
'when',
|
|
'whether',
|
|
'with',
|
|
]);
|
|
const LEADING_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 LIFECYCLE_ENTRY_NAMES = new Set([
|
|
'activated',
|
|
'afterInit',
|
|
'afterQuery',
|
|
'afterTransactionCommit',
|
|
'afterTransactionRollback',
|
|
'afterTransactionStart',
|
|
'beforeApplicationShutdown',
|
|
'beforeCreate',
|
|
'beforeDestroy',
|
|
'beforeMount',
|
|
'beforeModuleDestroy',
|
|
'beforeQuery',
|
|
'beforeTransactionCommit',
|
|
'beforeTransactionRollback',
|
|
'beforeTransactionStart',
|
|
'beforeUnmount',
|
|
'componentDidCatch',
|
|
'componentDidMount',
|
|
'componentDidUpdate',
|
|
'componentWillMount',
|
|
'componentWillReceiveProps',
|
|
'componentWillUnmount',
|
|
'componentWillUpdate',
|
|
'created',
|
|
'deactivated',
|
|
'destroyed',
|
|
'errorCaptured',
|
|
'getDerivedStateFromError',
|
|
'getSnapshotBeforeUpdate',
|
|
'handleConnection',
|
|
'handleDisconnect',
|
|
'mounted',
|
|
'onApplicationBootstrap',
|
|
'onApplicationShutdown',
|
|
'onModuleDestroy',
|
|
'onModuleInit',
|
|
'renderTracked',
|
|
'renderTriggered',
|
|
'serverPrefetch',
|
|
'shouldComponentUpdate',
|
|
'unmounted',
|
|
'updated',
|
|
]);
|
|
|
|
function getScriptKind(filePath, attributes = '') {
|
|
const extension = path.extname(filePath).toLowerCase();
|
|
|
|
if (extension === '.tsx') return ts.ScriptKind.TSX;
|
|
if (extension === '.js' || extension === '.mjs' || extension === '.cjs') {
|
|
return ts.ScriptKind.JS;
|
|
}
|
|
if (extension === '.vue') {
|
|
if (/\blang\s*=\s*["']tsx["']/iu.test(attributes)) {
|
|
return ts.ScriptKind.TSX;
|
|
}
|
|
if (/\blang\s*=\s*["'](?:js|jsx)["']/iu.test(attributes)) {
|
|
return ts.ScriptKind.JS;
|
|
}
|
|
}
|
|
|
|
return ts.ScriptKind.TS;
|
|
}
|
|
|
|
function getScriptBlocks(filePath, sourceText) {
|
|
if (path.extname(filePath).toLowerCase() !== '.vue') {
|
|
return [
|
|
{
|
|
contentOffset: 0,
|
|
filePath,
|
|
lineOffset: 0,
|
|
scriptKind: getScriptKind(filePath),
|
|
sourceText,
|
|
},
|
|
];
|
|
}
|
|
|
|
const blocks = [];
|
|
let match;
|
|
|
|
VUE_SCRIPT_PATTERN.lastIndex = 0;
|
|
while ((match = VUE_SCRIPT_PATTERN.exec(sourceText)) !== null) {
|
|
const openingTagEnd = match[0].indexOf('>') + 1;
|
|
const contentOffset = match.index + openingTagEnd;
|
|
const lineOffset = (
|
|
sourceText.slice(0, contentOffset).match(/\r\n|\r|\n/gu) || []
|
|
).length;
|
|
const scriptKind = getScriptKind(filePath, match[1]);
|
|
const extension =
|
|
scriptKind === ts.ScriptKind.TSX
|
|
? 'tsx'
|
|
: scriptKind === ts.ScriptKind.JS
|
|
? 'js'
|
|
: 'ts';
|
|
|
|
blocks.push({
|
|
contentOffset,
|
|
filePath: `${filePath}#script-${blocks.length + 1}.${extension}`,
|
|
lineOffset,
|
|
scriptKind,
|
|
sourceText: match[2],
|
|
});
|
|
}
|
|
|
|
return blocks;
|
|
}
|
|
|
|
function getNodeName(node, sourceFile) {
|
|
if (!node.name) return undefined;
|
|
|
|
if (
|
|
ts.isIdentifier(node.name) ||
|
|
ts.isPrivateIdentifier(node.name) ||
|
|
ts.isStringLiteral(node.name) ||
|
|
ts.isNumericLiteral(node.name)
|
|
) {
|
|
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 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 = expression.parent;
|
|
if (
|
|
(ts.isPropertyAssignment(parent) || ts.isPropertyDeclaration(parent)) &&
|
|
parent.initializer === expression
|
|
) {
|
|
return getNodeName(parent, sourceFile);
|
|
}
|
|
|
|
return undefined;
|
|
}
|
|
|
|
function collectTypeOrmListenerBindings(sourceFile) {
|
|
const importedNames = new Set();
|
|
const namespaceNames = new Set();
|
|
|
|
for (const statement of sourceFile.statements) {
|
|
if (
|
|
!ts.isImportDeclaration(statement) ||
|
|
!ts.isStringLiteral(statement.moduleSpecifier) ||
|
|
statement.moduleSpecifier.text !== 'typeorm' ||
|
|
statement.importClause?.isTypeOnly
|
|
) {
|
|
continue;
|
|
}
|
|
|
|
const namedBindings = statement.importClause?.namedBindings;
|
|
if (ts.isNamespaceImport(namedBindings)) {
|
|
namespaceNames.add(namedBindings.name.text);
|
|
continue;
|
|
}
|
|
if (!ts.isNamedImports(namedBindings)) continue;
|
|
|
|
for (const element of namedBindings.elements) {
|
|
const importedName = element.propertyName?.text || element.name.text;
|
|
if (
|
|
!element.isTypeOnly &&
|
|
TYPEORM_ENTITY_LISTENER_DECORATORS.has(importedName)
|
|
) {
|
|
importedNames.add(element.name.text);
|
|
}
|
|
}
|
|
}
|
|
|
|
return {
|
|
importedNames,
|
|
namespaceNames,
|
|
};
|
|
}
|
|
|
|
function getTypeOrmListenerDecoratorName(node, bindings) {
|
|
if (!ts.canHaveDecorators(node)) return undefined;
|
|
|
|
for (const decorator of ts.getDecorators(node) || []) {
|
|
const expression = ts.isCallExpression(decorator.expression)
|
|
? decorator.expression.expression
|
|
: decorator.expression;
|
|
|
|
if (
|
|
ts.isIdentifier(expression) &&
|
|
bindings.importedNames.has(expression.text)
|
|
) {
|
|
return expression.text;
|
|
}
|
|
|
|
if (
|
|
ts.isPropertyAccessExpression(expression) &&
|
|
ts.isIdentifier(expression.expression) &&
|
|
bindings.namespaceNames.has(expression.expression.text) &&
|
|
TYPEORM_ENTITY_LISTENER_DECORATORS.has(expression.name.text)
|
|
) {
|
|
return expression.name.text;
|
|
}
|
|
}
|
|
|
|
return undefined;
|
|
}
|
|
|
|
function maskNonNaturalLanguage(text) {
|
|
return text
|
|
.replace(/```[\s\S]*?```/gu, (code) => code.replace(/[^\r\n]/gu, '\0'))
|
|
.replace(/`[^`\r\n]*`/gu, '\0')
|
|
.replace(/\{@(?:code|link|linkcode|linkplain)\s+[^}]+\}/giu, '\0')
|
|
.replace(/\b(?:https?|ftp):\/\/[^\s<>{}|\\^`[\]]+/giu, '\0')
|
|
.replace(/\b[\w.+-]+@[\w.-]+\.[A-Za-z]{2,}\b/gu, '\0')
|
|
.replace(/\bv?\d+(?:\.\d+){1,}\b/gu, '\0')
|
|
.replace(/<[^>\r\n]+>/gu, '\0')
|
|
.replace(/^[\s*-]+/u, '')
|
|
.trim();
|
|
}
|
|
|
|
function splitDescriptionSentences(text) {
|
|
return text
|
|
.split(/(?:[。!?;]+|[.!?;]+(?=\s|$))/u)
|
|
.map((sentence) => sentence.trim())
|
|
.filter((sentence) => sentence.replaceAll('\0', '').trim());
|
|
}
|
|
|
|
function isStrongTechnicalWord(word) {
|
|
if (/\d/u.test(word)) return true;
|
|
return (
|
|
word === word.toUpperCase() ||
|
|
/[_$]/u.test(word) ||
|
|
/[A-Z]/u.test(word.slice(1))
|
|
);
|
|
}
|
|
|
|
function isTitleCaseWord(word) {
|
|
return /^[A-Z][a-z]+(?:-[A-Z]?[a-z]+)*$/u.test(word);
|
|
}
|
|
|
|
function isLeadingDescriptionVerb(word) {
|
|
const lower = word.toLowerCase();
|
|
const candidates = new Set([lower]);
|
|
|
|
if (lower.endsWith('ies')) {
|
|
candidates.add(`${lower.slice(0, -3)}y`);
|
|
}
|
|
if (lower.endsWith('ing')) {
|
|
const stem = lower.slice(0, -3);
|
|
candidates.add(stem);
|
|
candidates.add(`${stem}e`);
|
|
if (stem.at(-1) === stem.at(-2)) {
|
|
candidates.add(stem.slice(0, -1));
|
|
}
|
|
}
|
|
if (lower.endsWith('ed')) {
|
|
const stem = lower.slice(0, -2);
|
|
candidates.add(stem);
|
|
candidates.add(`${stem}e`);
|
|
}
|
|
if (lower.endsWith('es')) {
|
|
candidates.add(lower.slice(0, -2));
|
|
candidates.add(lower.slice(0, -1));
|
|
} else if (lower.endsWith('s')) {
|
|
candidates.add(lower.slice(0, -1));
|
|
}
|
|
|
|
return [...candidates].some((candidate) =>
|
|
LEADING_DESCRIPTION_VERBS.has(candidate),
|
|
);
|
|
}
|
|
|
|
function containsEnglishClause(sentence) {
|
|
const chineseCount = [...sentence].filter((character) =>
|
|
CHINESE_CHARACTER_PATTERN.test(character),
|
|
).length;
|
|
|
|
for (const region of sentence.split(/[\p{Script=Han}\0]+/u)) {
|
|
const words = [
|
|
...region.matchAll(/[A-Za-z][A-Za-z0-9]*(?:[-'][A-Za-z0-9]+)*/gu),
|
|
].map((match) => match[0]);
|
|
const ordinaryWords = words.filter((word) => !isStrongTechnicalWord(word));
|
|
if (ordinaryWords.length === 0) continue;
|
|
|
|
if (
|
|
ordinaryWords.length >= 2 &&
|
|
isLeadingDescriptionVerb(ordinaryWords[0])
|
|
) {
|
|
return true;
|
|
}
|
|
|
|
const isTitleCaseTechnicalPhrase = words.every(
|
|
(word) => isStrongTechnicalWord(word) || isTitleCaseWord(word),
|
|
);
|
|
if (isTitleCaseTechnicalPhrase) continue;
|
|
|
|
const hasFunctionWord = ordinaryWords.some((word) =>
|
|
ENGLISH_FUNCTION_WORDS.has(word.toLowerCase()),
|
|
);
|
|
|
|
if (
|
|
(ordinaryWords.length >= 2 && hasFunctionWord) ||
|
|
(ordinaryWords.length >= 4 && ordinaryWords.length > chineseCount)
|
|
) {
|
|
return true;
|
|
}
|
|
}
|
|
|
|
return false;
|
|
}
|
|
|
|
function isBareTypePayload(text) {
|
|
const payload = text.trim();
|
|
if (!payload || payload.startsWith('-')) return false;
|
|
|
|
const rootName = payload.match(/^[A-Za-z_$][A-Za-z0-9_$]*/u)?.[0];
|
|
if (
|
|
!rootName ||
|
|
(!BUILTIN_TYPE_NAMES.has(rootName) && !/^[A-Z]/u.test(rootName))
|
|
) {
|
|
return false;
|
|
}
|
|
|
|
const sourceFile = ts.createSourceFile(
|
|
'jsdoc-type.ts',
|
|
`type JSDocPayload = ${payload};`,
|
|
ts.ScriptTarget.Latest,
|
|
true,
|
|
ts.ScriptKind.TS,
|
|
);
|
|
return sourceFile.parseDiagnostics.length === 0;
|
|
}
|
|
|
|
function stripLeadingTypeExpression(text) {
|
|
const trimmedText = text.trimStart();
|
|
if (!trimmedText.startsWith('{')) return trimmedText;
|
|
|
|
let depth = 0;
|
|
for (let index = 0; index < trimmedText.length; index += 1) {
|
|
const character = trimmedText[index];
|
|
if (character === '{') {
|
|
depth += 1;
|
|
} else if (character === '}') {
|
|
depth -= 1;
|
|
if (depth === 0) {
|
|
return trimmedText.slice(index + 1).trimStart();
|
|
}
|
|
}
|
|
}
|
|
|
|
return trimmedText;
|
|
}
|
|
|
|
function getTagDescription(tagName, rawValue) {
|
|
if (CODE_ONLY_TAGS.has(tagName)) return undefined;
|
|
|
|
if (STRUCTURAL_TAGS.has(tagName) || tagName === 'see') {
|
|
const separatorIndex = rawValue.indexOf(' - ');
|
|
return separatorIndex === -1
|
|
? undefined
|
|
: rawValue.slice(separatorIndex + 3).trim() || undefined;
|
|
}
|
|
|
|
let value = stripLeadingTypeExpression(rawValue);
|
|
if (PARAMETER_TAGS.has(tagName)) {
|
|
value = value
|
|
.replace(/^(?:\[[^\]]+\]|[^\s-]+)\s*/u, '')
|
|
.replace(/^-\s*/u, '');
|
|
} else if (BARE_TYPE_TAGS.has(tagName) && isBareTypePayload(value)) {
|
|
return undefined;
|
|
}
|
|
|
|
return value.trim() || undefined;
|
|
}
|
|
|
|
function collectDescriptionUnits(rawComment) {
|
|
const units = [];
|
|
const bodyLines = rawComment
|
|
.replace(/^\/\*\*/u, '')
|
|
.replace(/\*\/$/u, '')
|
|
.split(/\r\n|\r|\n/u)
|
|
.map((line) => line.replace(/^\s*\*\s?/u, '').trimEnd());
|
|
let current = {
|
|
label: '主说明',
|
|
lines: [],
|
|
startLineOffset: 0,
|
|
tagName: undefined,
|
|
};
|
|
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) {
|
|
units.push({
|
|
label: current.label,
|
|
lineOffset,
|
|
text: rawValue,
|
|
});
|
|
return;
|
|
}
|
|
|
|
const description = getTagDescription(current.tagName, rawValue);
|
|
if (description) {
|
|
units.push({
|
|
label: current.label,
|
|
lineOffset,
|
|
text: description,
|
|
});
|
|
}
|
|
}
|
|
|
|
for (let lineOffset = 0; lineOffset < bodyLines.length; lineOffset += 1) {
|
|
const line = bodyLines[lineOffset];
|
|
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 && !line.trim()) {
|
|
flushCurrent();
|
|
current = {
|
|
label: '主说明',
|
|
lines: [],
|
|
startLineOffset: lineOffset + 1,
|
|
tagName: undefined,
|
|
};
|
|
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 = {
|
|
label: `@${tagName}`,
|
|
lines: CODE_ONLY_TAGS.has(tagName) ? [] : [tagMatch[2] || ''],
|
|
startLineOffset: lineOffset,
|
|
tagName,
|
|
};
|
|
continue;
|
|
}
|
|
|
|
if (!CODE_ONLY_TAGS.has(current.tagName)) {
|
|
current.lines.push(line);
|
|
}
|
|
}
|
|
flushCurrent();
|
|
|
|
return units;
|
|
}
|
|
|
|
function findDescriptionIssue(rawComment) {
|
|
const units = collectDescriptionUnits(rawComment);
|
|
|
|
for (const unit of units) {
|
|
const maskedText = maskNonNaturalLanguage(unit.text);
|
|
const unitHasChinese = CHINESE_CHARACTER_PATTERN.test(maskedText);
|
|
const lines = maskedText.split(/\r\n|\r|\n/u);
|
|
|
|
for (let lineOffset = 0; lineOffset < lines.length; lineOffset += 1) {
|
|
const sentences = splitDescriptionSentences(lines[lineOffset]);
|
|
|
|
for (const sentence of sentences) {
|
|
const hasChinese = CHINESE_CHARACTER_PATTERN.test(sentence);
|
|
const hasEnglishClause = containsEnglishClause(sentence);
|
|
|
|
if (!hasChinese) {
|
|
if (unitHasChinese && !hasEnglishClause) continue;
|
|
return {
|
|
label: unit.label,
|
|
lineOffset: unit.lineOffset + lineOffset,
|
|
reason: `${unit.label} 描述必须使用中文`,
|
|
};
|
|
}
|
|
|
|
if (hasEnglishClause) {
|
|
return {
|
|
label: unit.label,
|
|
lineOffset: unit.lineOffset + lineOffset,
|
|
reason: `${unit.label} 描述包含完整英文说明,必须改为中文`,
|
|
};
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
return undefined;
|
|
}
|
|
|
|
function classifyOwner(node, sourceFile, typeOrmBindings) {
|
|
if (!node) {
|
|
return {
|
|
code: 'invalid-target',
|
|
ownerKind: 'UnattachedJSDoc',
|
|
reason: 'JSDoc 未归属于允许的具名函数或方法',
|
|
};
|
|
}
|
|
|
|
const isNamedFunction =
|
|
(ts.isFunctionDeclaration(node) || ts.isFunctionExpression(node)) &&
|
|
Boolean(node.name);
|
|
const isNamedMethod =
|
|
ts.isMethodDeclaration(node) ||
|
|
ts.isGetAccessorDeclaration(node) ||
|
|
ts.isSetAccessorDeclaration(node);
|
|
|
|
if (!isNamedFunction && !isNamedMethod) {
|
|
return {
|
|
code: 'invalid-target',
|
|
ownerKind: ts.SyntaxKind[node.kind],
|
|
reason: 'JSDoc 只能用于具名函数声明、显式具名函数表达式或具名方法',
|
|
};
|
|
}
|
|
|
|
const ownerName = getNodeName(node, sourceFile);
|
|
const typeOrmListenerName = getTypeOrmListenerDecoratorName(
|
|
node,
|
|
typeOrmBindings,
|
|
);
|
|
const roleName = ts.isFunctionExpression(node)
|
|
? getFunctionExpressionRole(node, sourceFile)
|
|
: ts.isFunctionDeclaration(node)
|
|
? undefined
|
|
: ownerName;
|
|
const isSetupEntry = roleName === 'setup';
|
|
const lifecycleEntryName =
|
|
roleName && LIFECYCLE_ENTRY_NAMES.has(roleName) ? roleName : undefined;
|
|
|
|
if (isSetupEntry || lifecycleEntryName || typeOrmListenerName) {
|
|
const entryName = typeOrmListenerName
|
|
? `@${typeOrmListenerName}`
|
|
: lifecycleEntryName || roleName;
|
|
return {
|
|
code: 'forbidden-entry',
|
|
ownerKind: ts.SyntaxKind[node.kind],
|
|
ownerName,
|
|
reason: `${entryName} 是禁止使用 JSDoc 的 setup 或生命周期入口`,
|
|
};
|
|
}
|
|
|
|
return {
|
|
ownerKind: ts.SyntaxKind[node.kind],
|
|
ownerName,
|
|
};
|
|
}
|
|
|
|
function collectOwnedJsDocs(sourceFile) {
|
|
const ownedDocs = new Map();
|
|
|
|
function visit(node) {
|
|
for (const jsDoc of node.jsDoc || []) {
|
|
ownedDocs.set(`${jsDoc.getStart(sourceFile)}:${jsDoc.end}`, {
|
|
jsDoc,
|
|
owner: node,
|
|
});
|
|
}
|
|
ts.forEachChild(node, visit);
|
|
}
|
|
|
|
visit(sourceFile);
|
|
return ownedDocs;
|
|
}
|
|
|
|
function getLeafTokens(sourceFile) {
|
|
const tokens = [];
|
|
|
|
function visit(node) {
|
|
if (
|
|
node.kind >= ts.SyntaxKind.FirstJSDocNode &&
|
|
node.kind <= ts.SyntaxKind.LastJSDocNode
|
|
) {
|
|
return;
|
|
}
|
|
|
|
if (
|
|
node.kind >= ts.SyntaxKind.FirstToken &&
|
|
node.kind <= ts.SyntaxKind.LastToken
|
|
) {
|
|
tokens.push(node);
|
|
return;
|
|
}
|
|
|
|
for (const child of node.getChildren(sourceFile)) {
|
|
visit(child);
|
|
}
|
|
}
|
|
|
|
visit(sourceFile);
|
|
return tokens.sort(
|
|
(left, right) => left.getStart(sourceFile) - right.getStart(sourceFile),
|
|
);
|
|
}
|
|
|
|
function scanTriviaGap(sourceText, start, end) {
|
|
if (start >= end) return [];
|
|
|
|
const scanner = ts.createScanner(
|
|
ts.ScriptTarget.Latest,
|
|
false,
|
|
ts.LanguageVariant.Standard,
|
|
sourceText.slice(start, end),
|
|
);
|
|
const comments = [];
|
|
let token;
|
|
|
|
do {
|
|
token = scanner.scan();
|
|
if (
|
|
token === ts.SyntaxKind.MultiLineCommentTrivia &&
|
|
scanner.getTokenText().startsWith('/**')
|
|
) {
|
|
comments.push({
|
|
end: start + scanner.getTextPos(),
|
|
start: start + scanner.getTokenPos(),
|
|
});
|
|
}
|
|
} while (token !== ts.SyntaxKind.EndOfFileToken);
|
|
|
|
return comments;
|
|
}
|
|
|
|
function collectRawJsDocs(sourceFile) {
|
|
const sourceText = sourceFile.text;
|
|
const comments = [];
|
|
let cursor = 0;
|
|
|
|
for (const token of getLeafTokens(sourceFile)) {
|
|
const tokenStart = token.getStart(sourceFile);
|
|
comments.push(...scanTriviaGap(sourceText, cursor, tokenStart));
|
|
cursor = Math.max(cursor, token.end);
|
|
}
|
|
comments.push(...scanTriviaGap(sourceText, cursor, sourceText.length));
|
|
|
|
return comments;
|
|
}
|
|
|
|
function analyzeScriptBlock(block) {
|
|
const sourceFile = ts.createSourceFile(
|
|
block.filePath,
|
|
block.sourceText,
|
|
ts.ScriptTarget.Latest,
|
|
true,
|
|
block.scriptKind,
|
|
);
|
|
const ownedDocs = collectOwnedJsDocs(sourceFile);
|
|
const rawDocs = collectRawJsDocs(sourceFile);
|
|
const typeOrmBindings = collectTypeOrmListenerBindings(sourceFile);
|
|
const issues = [];
|
|
const removals = [];
|
|
let retainedCount = 0;
|
|
|
|
for (const rawDoc of rawDocs) {
|
|
const start = rawDoc.start;
|
|
const end = rawDoc.end;
|
|
const rawComment = block.sourceText.slice(start, end);
|
|
const ownedDoc = ownedDocs.get(`${start}:${end}`);
|
|
const owner = ownedDoc?.owner;
|
|
const classification = classifyOwner(owner, sourceFile, typeOrmBindings);
|
|
const position = sourceFile.getLineAndCharacterOfPosition(start);
|
|
const common = {
|
|
column: position.character + 1,
|
|
end: block.contentOffset + end,
|
|
filePath: block.filePath.replace(/#script-\d+\.(?:js|ts|tsx)$/u, ''),
|
|
line: block.lineOffset + position.line + 1,
|
|
ownerKind: classification.ownerKind,
|
|
ownerName: classification.ownerName,
|
|
start: block.contentOffset + start,
|
|
};
|
|
|
|
if (classification.code) {
|
|
issues.push({
|
|
...common,
|
|
code: classification.code,
|
|
reason: classification.reason,
|
|
});
|
|
removals.push({
|
|
end: common.end,
|
|
start: common.start,
|
|
});
|
|
continue;
|
|
}
|
|
|
|
const descriptionIssue = findDescriptionIssue(rawComment);
|
|
if (descriptionIssue) {
|
|
issues.push({
|
|
...common,
|
|
code: 'missing-chinese-description',
|
|
descriptionLineOffset: descriptionIssue.lineOffset,
|
|
descriptionUnit: descriptionIssue.label,
|
|
reason: descriptionIssue.reason,
|
|
});
|
|
removals.push({
|
|
end: common.end,
|
|
start: common.start,
|
|
});
|
|
continue;
|
|
}
|
|
|
|
retainedCount += 1;
|
|
}
|
|
|
|
return {
|
|
issues,
|
|
removals,
|
|
retainedCount,
|
|
totalCount: rawDocs.length,
|
|
};
|
|
}
|
|
|
|
function analyzeSourceText(filePath, sourceText) {
|
|
const results = getScriptBlocks(filePath, sourceText).map(analyzeScriptBlock);
|
|
const issues = results.flatMap((result) => result.issues);
|
|
|
|
return {
|
|
issues,
|
|
removals: results.flatMap((result) => result.removals),
|
|
stats: {
|
|
forbiddenEntryCount: issues.filter(
|
|
(issue) => issue.code === 'forbidden-entry',
|
|
).length,
|
|
invalidTargetCount: issues.filter(
|
|
(issue) => issue.code === 'invalid-target',
|
|
).length,
|
|
missingChineseCount: issues.filter(
|
|
(issue) => issue.code === 'missing-chinese-description',
|
|
).length,
|
|
retainedCount: results.reduce(
|
|
(sum, result) => sum + result.retainedCount,
|
|
0,
|
|
),
|
|
totalCount: results.reduce((sum, result) => sum + result.totalCount, 0),
|
|
},
|
|
};
|
|
}
|
|
|
|
export function validateSourceText(filePath, sourceText) {
|
|
const analysis = analyzeSourceText(filePath, sourceText);
|
|
return {
|
|
issues: analysis.issues,
|
|
stats: analysis.stats,
|
|
};
|
|
}
|
|
|
|
function expandStandaloneRemoval(sourceText, removal) {
|
|
const lineTerminator =
|
|
sourceText
|
|
.slice(removal.start, removal.end)
|
|
.match(LINE_TERMINATOR_PATTERN)?.[0] || '';
|
|
const lineStart = sourceText.lastIndexOf('\n', removal.start - 1) + 1;
|
|
const nextLineBreak = sourceText.indexOf('\n', removal.end);
|
|
const lineEnd = nextLineBreak === -1 ? sourceText.length : nextLineBreak + 1;
|
|
const before = sourceText.slice(lineStart, removal.start);
|
|
const after = sourceText.slice(
|
|
removal.end,
|
|
nextLineBreak === -1 ? sourceText.length : nextLineBreak,
|
|
);
|
|
|
|
if (before.trim() === '' && after.trim() === '') {
|
|
return {
|
|
end: lineEnd,
|
|
replacement: '',
|
|
start: lineStart,
|
|
};
|
|
}
|
|
|
|
const horizontalWhitespace = before.match(/[^\S\r\n]+$/u)?.[0];
|
|
if (horizontalWhitespace) {
|
|
return {
|
|
end: removal.end,
|
|
replacement: lineTerminator,
|
|
start: removal.start - horizontalWhitespace.length,
|
|
};
|
|
}
|
|
|
|
return {
|
|
...removal,
|
|
replacement: lineTerminator,
|
|
};
|
|
}
|
|
|
|
function mergeRemovals(removals) {
|
|
const sorted = [...removals].sort((left, right) => left.start - right.start);
|
|
const merged = [];
|
|
|
|
for (const removal of sorted) {
|
|
const previous = merged.at(-1);
|
|
if (previous && removal.start <= previous.end) {
|
|
previous.end = Math.max(previous.end, removal.end);
|
|
previous.replacement ||= removal.replacement;
|
|
} else {
|
|
merged.push({ ...removal });
|
|
}
|
|
}
|
|
|
|
return merged;
|
|
}
|
|
|
|
export function fixSourceText(filePath, sourceText) {
|
|
const analysis = analyzeSourceText(filePath, sourceText);
|
|
const removals = mergeRemovals(
|
|
analysis.removals.map((removal) =>
|
|
expandStandaloneRemoval(sourceText, removal),
|
|
),
|
|
);
|
|
let fixedSourceText = sourceText;
|
|
|
|
for (const removal of removals.toReversed()) {
|
|
fixedSourceText =
|
|
fixedSourceText.slice(0, removal.start) +
|
|
removal.replacement +
|
|
fixedSourceText.slice(removal.end);
|
|
}
|
|
|
|
return {
|
|
removedCount: analysis.removals.length,
|
|
sourceText: fixedSourceText,
|
|
stats: analysis.stats,
|
|
};
|
|
}
|
|
|
|
function tokenizeScript(filePath, sourceText, scriptKind) {
|
|
const sourceFile = ts.createSourceFile(
|
|
filePath,
|
|
sourceText,
|
|
ts.ScriptTarget.Latest,
|
|
true,
|
|
scriptKind,
|
|
);
|
|
const tokens = getLeafTokens(sourceFile).filter(
|
|
(node) => node.kind !== ts.SyntaxKind.EndOfFileToken,
|
|
);
|
|
|
|
return tokens.map((node, index) => {
|
|
const previous = tokens[index - 1];
|
|
const hasLineTerminator =
|
|
previous &&
|
|
LINE_TERMINATOR_PATTERN.test(
|
|
sourceText.slice(previous.end, node.getStart(sourceFile)),
|
|
);
|
|
const tokenText = node
|
|
.getText(sourceFile)
|
|
.replaceAll('\r\n', '\n')
|
|
.replaceAll('\r', '\n');
|
|
return `${hasLineTerminator ? 'line-break' : 'same-line'}:${node.kind}:${tokenText}`;
|
|
});
|
|
}
|
|
|
|
export function getTokenSignature(filePath, sourceText) {
|
|
return getScriptBlocks(filePath, sourceText).flatMap((block, index) => [
|
|
`script-block:${index}:${block.scriptKind}`,
|
|
...tokenizeScript(block.filePath, block.sourceText, block.scriptKind),
|
|
]);
|
|
}
|
|
|
|
function getRepositoryRoot(cwd) {
|
|
return execFileSync('git', ['rev-parse', '--show-toplevel'], {
|
|
cwd,
|
|
encoding: 'utf8',
|
|
}).trim();
|
|
}
|
|
|
|
function isSupportedSourceFile(filePath) {
|
|
return SOURCE_EXTENSIONS.has(path.extname(filePath).toLowerCase());
|
|
}
|
|
|
|
function splitNullDelimited(output) {
|
|
return output.toString('utf8').split('\0').filter(Boolean);
|
|
}
|
|
|
|
function listWorkingTreeSourceFiles(repoRoot) {
|
|
const output = execFileSync(
|
|
'git',
|
|
[
|
|
'ls-files',
|
|
'--cached',
|
|
'--others',
|
|
'--exclude-standard',
|
|
'-z',
|
|
'--',
|
|
...SOURCE_PATHSPECS,
|
|
],
|
|
{
|
|
cwd: repoRoot,
|
|
encoding: 'buffer',
|
|
},
|
|
);
|
|
|
|
return splitNullDelimited(output).filter(isSupportedSourceFile).sort();
|
|
}
|
|
|
|
function listIndexSourceFiles(repoRoot) {
|
|
const output = execFileSync(
|
|
'git',
|
|
['ls-files', '--cached', '-z', '--', ...SOURCE_PATHSPECS],
|
|
{
|
|
cwd: repoRoot,
|
|
encoding: 'buffer',
|
|
},
|
|
);
|
|
|
|
return splitNullDelimited(output).filter(isSupportedSourceFile).sort();
|
|
}
|
|
|
|
function readIndexSourceFile(repoRoot, filePath) {
|
|
return execFileSync('git', ['show', `:${filePath}`], {
|
|
cwd: repoRoot,
|
|
encoding: 'utf8',
|
|
maxBuffer: 64 * 1024 * 1024,
|
|
});
|
|
}
|
|
|
|
function listRefSourceFiles(repoRoot, ref) {
|
|
const output = execFileSync(
|
|
'git',
|
|
['ls-tree', '-r', '--name-only', '-z', ref],
|
|
{
|
|
cwd: repoRoot,
|
|
encoding: 'buffer',
|
|
},
|
|
);
|
|
|
|
return splitNullDelimited(output).filter(isSupportedSourceFile).sort();
|
|
}
|
|
|
|
function findFirstTokenDifference(beforeTokens, afterTokens) {
|
|
const length = Math.max(beforeTokens.length, afterTokens.length);
|
|
|
|
for (let index = 0; index < length; index += 1) {
|
|
if (beforeTokens[index] !== afterTokens[index]) {
|
|
return {
|
|
after: afterTokens[index],
|
|
before: beforeTokens[index],
|
|
index,
|
|
};
|
|
}
|
|
}
|
|
|
|
return undefined;
|
|
}
|
|
|
|
function compareRepositoryTokens(
|
|
repoRoot,
|
|
ref,
|
|
currentFiles,
|
|
readCurrentSource,
|
|
allowedTokenChanges,
|
|
) {
|
|
const currentFileSet = new Set(currentFiles);
|
|
const refFiles = listRefSourceFiles(repoRoot, ref);
|
|
const refFileSet = new Set(refFiles);
|
|
const allowedMismatches = [];
|
|
const mismatches = [];
|
|
let comparedFiles = 0;
|
|
let comparedTokens = 0;
|
|
|
|
for (const filePath of refFiles) {
|
|
if (!currentFileSet.has(filePath)) {
|
|
mismatches.push({
|
|
filePath,
|
|
reason: '基线文件在当前代码快照中缺失',
|
|
});
|
|
continue;
|
|
}
|
|
|
|
const beforeSource = execFileSync('git', ['show', `${ref}:${filePath}`], {
|
|
cwd: repoRoot,
|
|
encoding: 'utf8',
|
|
maxBuffer: 64 * 1024 * 1024,
|
|
});
|
|
const afterSource = readCurrentSource(filePath);
|
|
const beforeTokens = getTokenSignature(filePath, beforeSource);
|
|
const afterTokens = getTokenSignature(filePath, afterSource);
|
|
const difference = findFirstTokenDifference(beforeTokens, afterTokens);
|
|
|
|
if (difference) {
|
|
const mismatch = {
|
|
difference,
|
|
filePath,
|
|
reason: '非注释 token 与基线不一致',
|
|
};
|
|
if (allowedTokenChanges.has(filePath)) {
|
|
allowedMismatches.push(mismatch);
|
|
} else {
|
|
mismatches.push(mismatch);
|
|
}
|
|
continue;
|
|
}
|
|
|
|
comparedFiles += 1;
|
|
comparedTokens += beforeTokens.length;
|
|
}
|
|
|
|
return {
|
|
allowedMismatches,
|
|
comparedFiles,
|
|
comparedTokens,
|
|
mismatches,
|
|
newFilesSkipped: currentFiles.filter(
|
|
(filePath) => !refFileSet.has(filePath),
|
|
),
|
|
ref,
|
|
};
|
|
}
|
|
|
|
function addStats(target, stats) {
|
|
for (const [key, value] of Object.entries(stats)) {
|
|
target[key] += value;
|
|
}
|
|
}
|
|
|
|
function parseOption(args, name) {
|
|
const inline = args.find((arg) => arg.startsWith(`${name}=`));
|
|
if (inline) return inline.slice(name.length + 1);
|
|
|
|
const index = args.indexOf(name);
|
|
return index >= 0 ? args[index + 1] : undefined;
|
|
}
|
|
|
|
function parseOptionList(args, name) {
|
|
const values = [];
|
|
|
|
for (let index = 0; index < args.length; index += 1) {
|
|
const arg = args[index];
|
|
if (arg.startsWith(`${name}=`)) {
|
|
values.push(arg.slice(name.length + 1));
|
|
} else if (arg === name && args[index + 1]) {
|
|
values.push(args[index + 1]);
|
|
}
|
|
}
|
|
|
|
return new Set(
|
|
values
|
|
.flatMap((value) => value.split(','))
|
|
.map((value) => value.trim().replaceAll('\\', '/'))
|
|
.filter(Boolean),
|
|
);
|
|
}
|
|
|
|
function printIssues(issues) {
|
|
for (const issue of issues) {
|
|
const owner = issue.ownerName
|
|
? `${issue.ownerKind} ${issue.ownerName}`
|
|
: issue.ownerKind;
|
|
console.error(
|
|
`${issue.filePath}:${issue.line}:${issue.column} ${issue.code} ${owner}: ${issue.reason}`,
|
|
);
|
|
}
|
|
}
|
|
|
|
function runRepositoryCheck({
|
|
allowedTokenChanges,
|
|
compareRef,
|
|
fix,
|
|
repoRoot,
|
|
staged,
|
|
}) {
|
|
if (fix && staged) {
|
|
throw new Error('--fix 不能与 --staged 同时使用');
|
|
}
|
|
|
|
const files = staged
|
|
? listIndexSourceFiles(repoRoot)
|
|
: listWorkingTreeSourceFiles(repoRoot);
|
|
const readCurrentSource = staged
|
|
? (filePath) => readIndexSourceFile(repoRoot, filePath)
|
|
: (filePath) => readFileSync(path.resolve(repoRoot, filePath), 'utf8');
|
|
const issues = [];
|
|
const totals = {
|
|
forbiddenEntryCount: 0,
|
|
invalidTargetCount: 0,
|
|
missingChineseCount: 0,
|
|
retainedCount: 0,
|
|
totalCount: 0,
|
|
};
|
|
let changedFiles = 0;
|
|
let removedCount = 0;
|
|
|
|
for (const filePath of files) {
|
|
const sourceText = readCurrentSource(filePath);
|
|
|
|
if (fix) {
|
|
const result = fixSourceText(filePath, sourceText);
|
|
addStats(totals, result.stats);
|
|
removedCount += result.removedCount;
|
|
if (result.sourceText !== sourceText) {
|
|
writeFileSync(path.resolve(repoRoot, filePath), result.sourceText);
|
|
changedFiles += 1;
|
|
}
|
|
continue;
|
|
}
|
|
|
|
const result = validateSourceText(filePath, sourceText);
|
|
addStats(totals, result.stats);
|
|
issues.push(...result.issues);
|
|
}
|
|
|
|
const comparison = compareRef
|
|
? compareRepositoryTokens(
|
|
repoRoot,
|
|
compareRef,
|
|
files,
|
|
readCurrentSource,
|
|
allowedTokenChanges,
|
|
)
|
|
: undefined;
|
|
const summary = {
|
|
changedFiles,
|
|
comparison,
|
|
filesScanned: files.length,
|
|
issues: issues.length,
|
|
mode: fix ? 'fix' : 'check',
|
|
removedCount,
|
|
sourceMode: staged ? 'index' : 'worktree',
|
|
...totals,
|
|
};
|
|
|
|
if (issues.length > 0) {
|
|
printIssues(issues);
|
|
}
|
|
if (comparison?.mismatches.length) {
|
|
for (const mismatch of comparison.mismatches) {
|
|
console.error(`${mismatch.filePath}: ${mismatch.reason}`);
|
|
}
|
|
}
|
|
|
|
console.log(JSON.stringify(summary, null, 2));
|
|
|
|
if (issues.length > 0 || comparison?.mismatches.length) {
|
|
process.exitCode = 1;
|
|
}
|
|
}
|
|
|
|
function main() {
|
|
const args = process.argv.slice(2);
|
|
const repoRoot = getRepositoryRoot(
|
|
path.resolve(parseOption(args, '--repo-root') || process.cwd()),
|
|
);
|
|
|
|
runRepositoryCheck({
|
|
allowedTokenChanges: parseOptionList(args, '--allow-token-change'),
|
|
compareRef: parseOption(args, '--compare-ref'),
|
|
fix: args.includes('--fix'),
|
|
repoRoot,
|
|
staged: args.includes('--staged'),
|
|
});
|
|
}
|
|
|
|
const invokedUrl = process.argv[1]
|
|
? pathToFileURL(path.resolve(process.argv[1])).href
|
|
: undefined;
|
|
|
|
if (invokedUrl === import.meta.url) {
|
|
try {
|
|
main();
|
|
} catch (error) {
|
|
console.error(error instanceof Error ? error.message : error);
|
|
process.exitCode = 1;
|
|
}
|
|
}
|