kt-template-online-api/scripts/jsdoc/check-jsdoc-policy.mjs

740 lines
18 KiB
JavaScript

#!/usr/bin/env node
import { execFileSync } from 'node:child_process';
import { existsSync, 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 VUE_SCRIPT_PATTERN = /<script\b([^>]*)>([\s\S]*?)<\/script\s*>/giu;
const LIFECYCLE_ENTRY_NAMES = new Set([
'activated',
'afterInit',
'afterInsert',
'afterLoad',
'afterQuery',
'afterRecover',
'afterRemove',
'afterSoftRemove',
'afterTransactionCommit',
'afterTransactionRollback',
'afterTransactionStart',
'afterUpdate',
'beforeApplicationShutdown',
'beforeCreate',
'beforeDestroy',
'beforeInsert',
'beforeMount',
'beforeModuleDestroy',
'beforeQuery',
'beforeRecover',
'beforeRemove',
'beforeSoftRemove',
'beforeTransactionCommit',
'beforeTransactionRollback',
'beforeTransactionStart',
'beforeUnmount',
'beforeUpdate',
'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;
}
return node.name.getText(sourceFile);
}
function classifyOwner(node, sourceFile) {
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);
if (ownerName === 'setup' || LIFECYCLE_ENTRY_NAMES.has(ownerName)) {
return {
code: 'forbidden-entry',
ownerKind: ts.SyntaxKind[node.kind],
ownerName,
reason: `${ownerName} 是禁止使用 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 issues = [];
const removals = [];
let retainedCount = 0;
for (const rawDoc of rawDocs) {
const start = rawDoc.start;
const end = rawDoc.end;
const text = block.sourceText.slice(start, end);
const owner = ownedDocs.get(`${start}:${end}`)?.owner;
const classification = classifyOwner(owner, sourceFile);
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;
}
if (!CHINESE_CHARACTER_PATTERN.test(text)) {
issues.push({
...common,
code: 'missing-chinese-description',
reason: 'JSDoc 描述必须整体以中文为主,不能是纯英文或仅标签',
});
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 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,
start: lineStart,
};
}
const horizontalWhitespace = before.match(/[^\S\r\n]+$/u)?.[0];
if (horizontalWhitespace) {
return {
end: removal.end,
start: removal.start - horizontalWhitespace.length,
};
}
return removal;
}
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);
} 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) +
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,
);
return getLeafTokens(sourceFile)
.filter((node) => node.kind !== ts.SyntaxKind.EndOfFileToken)
.map((node) => `${node.kind}:${node.getText(sourceFile)}`);
}
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 listCurrentSourceFiles(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 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,
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) {
const absolutePath = path.resolve(repoRoot, filePath);
if (!currentFileSet.has(filePath) || !existsSync(absolutePath)) {
mismatches.push({
filePath,
reason: '基线文件在当前工作树中缺失',
});
continue;
}
const beforeSource = execFileSync('git', ['show', `${ref}:${filePath}`], {
cwd: repoRoot,
encoding: 'utf8',
maxBuffer: 64 * 1024 * 1024,
});
const afterSource = readFileSync(absolutePath, 'utf8');
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,
}) {
const files = listCurrentSourceFiles(repoRoot);
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 absolutePath = path.resolve(repoRoot, filePath);
const sourceText = readFileSync(absolutePath, 'utf8');
if (fix) {
const result = fixSourceText(filePath, sourceText);
addStats(totals, result.stats);
removedCount += result.removedCount;
if (result.sourceText !== sourceText) {
writeFileSync(absolutePath, 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, allowedTokenChanges)
: undefined;
const summary = {
changedFiles,
comparison,
filesScanned: files.length,
issues: issues.length,
mode: fix ? 'fix' : 'check',
removedCount,
...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,
});
}
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;
}
}