#!/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'; 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', 'componentDidMount', 'componentDidUpdate', 'componentWillMount', 'componentWillReceiveProps', 'componentWillUnmount', 'componentWillUpdate', 'created', 'deactivated', 'destroyed', 'errorCaptured', 'getDerivedStateFromError', 'getDerivedStateFromProps', 'getSnapshotBeforeUpdate', 'mounted', 'renderTracked', 'renderTriggered', 'serverPrefetch', 'shouldComponentUpdate', 'unmounted', 'updated', ]); function getIsolatedGitEnvironment() { return Object.fromEntries( Object.entries(process.env).filter(([name]) => !name.startsWith('GIT_')), ); } 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]; for (let index = 0; index < source.length; index += 1) { const character = source.codePointAt(index); if (character === 13) { if (source.codePointAt(index + 1) === 10) { index += 1; } starts.push(index + 1); } else if (character === 10) { starts.push(index + 1); } } return starts; } function getLineAndColumn(lineStarts, position) { let low = 0; let high = lineStarts.length - 1; while (low <= high) { const middle = Math.floor((low + high) / 2); if (lineStarts[middle] <= position) { low = middle + 1; } else { high = middle - 1; } } const lineIndex = Math.max(0, high); return { column: position - lineStarts[lineIndex] + 1, line: lineIndex + 1, }; } 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(); if (normalizedLanguage === 'tsx' || filePath.endsWith('.tsx')) { return ts.ScriptKind.TSX; } if (normalizedLanguage === 'jsx') { return ts.ScriptKind.JSX; } if ( normalizedLanguage === 'ts' || filePath.endsWith('.ts') || filePath.endsWith('.mts') || filePath.endsWith('.cts') ) { return ts.ScriptKind.TS; } return ts.ScriptKind.JS; } function getVueScriptBlocks(filePath, source) { const { descriptor, errors } = parseVueSfc(source, { filename: filePath, sourceMap: false, }); if (errors.length > 0) { const message = errors .map((error) => (error instanceof Error ? error.message : String(error))) .join('; '); throw new Error(`Vue SFC 解析失败:${message}`); } return [ descriptor.script ? { content: descriptor.script.content, label: '