#!/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 = /]*)>([\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; } }