fix: avoid false namespace export conflicts

This commit is contained in:
DESKTOP-I3JPKHK\wy 2026-04-21 20:06:37 +08:00
parent 89f43483ae
commit 57a2492408
4 changed files with 179 additions and 83 deletions

View File

@ -352,8 +352,8 @@ import { getCurriculumListApi } from '@/api/aixue/generated'
- 如果不同模块里恰好生成了同名函数,例如都生成了 `getListApi`
- 那么 `generated/index.js` 里的 `export * from './module'` 会产生冲突
- 如果某个扁平函数名刚好和模块命名空间导出名撞上,例如模块命名空间是 `rankingApi`,同时模块里也生成了 `rankingApi`
- 这类情况也会被当成冲突处理
- 如果某个接口的默认函数名刚好会和模块命名空间导出名撞上,例如 `/api/Values` 同时会推导出模块命名空间 `valuesApi`
- 工具会在生成阶段自动把函数名调整成不冲突的形式,例如 `getValuesApi`
- 工具会先列出冲突函数、对应模块名,以及能识别到的完整接口 URL
- 在交互终端里会给你两个选择:
- `1` 在函数名后自动追加模块名,例如 `getListEnglishWordApi`
@ -502,12 +502,12 @@ npx yx-generate-api generate Curriculum
### 6. 为什么生成时报“Duplicate API export names detected”
说明 `generated/index.js` 里出现了重复导出名。
说明不同模块之间出现了重复的扁平函数导出名。
常见场景有两种:
- 两个或多个模块里生成了同名函数,例如都导出了 `getListApi`
- 某个扁平函数名和模块命名空间导出名撞上,例如模块命名空间是 `rankingApi`,同时模块里也生成了 `rankingApi`
- 你只生成了部分模块,但保留目录里已有模块,导致新旧模块之间出现了同名导出
因为 `generated/index.js` 会使用:
@ -516,7 +516,11 @@ export * from './module-a'
export * from './module-b'
```
这种模式下,重复导出名不能直接共存,命名空间导出也不能把同名扁平导出安全暴露出来。
这种模式下,重复的扁平导出名不能直接共存。
`/api/Values` 这类“函数默认名刚好和模块命名空间名相同”的情况,工具会在生成阶段自动规避,不会再单独报这个冲突。
如果你是从旧版本升级过来,而 `generated` 目录里还保留着旧规则生成的模块文件,也可能在部分生成时触发这类校验。遇到这种情况,直接做一次全量生成,或者把冲突模块一起重新生成即可。
所以工具会先把冲突明细列出来,内容会尽量包含:

View File

@ -130,12 +130,7 @@ const createDuplicateExportResolver = () => {
console.log(`- 冲突函数:${conflict.exportName}`)
for (const occurrence of conflict.occurrences) {
const locationText =
occurrence.sourceType === 'namespace'
? '类型: 模块命名空间导出'
: occurrence.path
? `URL: ${occurrence.path}`
: 'URL: 当前保留的已生成模块'
const locationText = occurrence.path ? `URL: ${occurrence.path}` : 'URL: 当前保留的已生成模块'
console.log(
` 模块: ${occurrence.moduleName} | 文件: ${occurrence.fileName} | ${locationText}`,
)

View File

@ -77,6 +77,27 @@ export const generateApiFiles = async ({
skipFileNames: selectedModuleFileNames,
skipRemovableAutoGeneratedFiles: shouldRemoveStaleGeneratedFiles,
})
const reservedNamespaceFunctionNames = new Set(
[...selectedModules.map((moduleInfo) => moduleInfo.fileName), ...retainedModuleExports.map((item) => item.fileName)].map(
(fileName) => toCamelCase(fileName),
),
)
for (const moduleInfo of selectedModules) {
assignFunctionNames(moduleInfo.operations, reservedNamespaceFunctionNames)
}
const namespaceConflicts = findNamespaceExportConflicts([
...buildPlannedModuleExports(selectedModules),
...retainedModuleExports,
])
if (namespaceConflicts.length) {
throw createDuplicateModuleExportsError(namespaceConflicts, {
reason: 'namespace-conflict',
})
}
const duplicateConflicts = findDuplicateModuleExports([
...buildPlannedModuleExports(selectedModules),
...retainedModuleExports,
@ -89,14 +110,19 @@ export const generateApiFiles = async ({
if (resolution?.action === 'rename') {
const renameEntries = applyDuplicateExportRenameStrategy(selectedModules, duplicateConflicts)
const remainingNamespaceConflicts = findNamespaceExportConflicts([
...buildPlannedModuleExports(selectedModules),
...retainedModuleExports,
])
const remainingConflicts = findDuplicateModuleExports([
...buildPlannedModuleExports(selectedModules),
...retainedModuleExports,
])
if (remainingConflicts.length) {
if (remainingNamespaceConflicts.length || remainingConflicts.length) {
throw createDuplicateModuleExportsError(remainingConflicts, {
reason: 'rename-failed',
supplementalConflicts: remainingNamespaceConflicts,
})
}
@ -280,22 +306,15 @@ const readRetainedModuleExports = async ({
fileName,
isSelected: false,
moduleName,
occurrences: [
buildNamespaceExportOccurrence({
occurrences: extractGeneratedModuleExportNames(content).map((exportName) =>
buildFlatExportOccurrence({
exportName,
fileName,
isSelected: false,
moduleName,
path: null,
}),
...extractGeneratedModuleExportNames(content).map((exportName) =>
buildFlatExportOccurrence({
exportName,
fileName,
isSelected: false,
moduleName,
path: null,
}),
),
],
),
}
}),
).then((items) => items.filter(Boolean))
@ -314,22 +333,15 @@ const buildPlannedModuleExports = (selectedModules) => {
fileName: moduleInfo.fileName,
isSelected: true,
moduleName: moduleInfo.moduleName,
occurrences: [
buildNamespaceExportOccurrence({
occurrences: moduleInfo.operations.map((operationItem) =>
buildFlatExportOccurrence({
exportName: `${operationItem.functionName}Api`,
fileName: moduleInfo.fileName,
isSelected: false,
isSelected: true,
moduleName: moduleInfo.moduleName,
path: operationItem.path,
}),
...moduleInfo.operations.map((operationItem) =>
buildFlatExportOccurrence({
exportName: `${operationItem.functionName}Api`,
fileName: moduleInfo.fileName,
isSelected: true,
moduleName: moduleInfo.moduleName,
path: operationItem.path,
}),
),
],
),
}
})
}
@ -374,7 +386,6 @@ const buildModuleMap = (swagger) => {
for (const moduleInfo of moduleMap.values()) {
moduleInfo.operations.sort(compareOperations)
assignFunctionNames(moduleInfo.operations)
}
return moduleMap
@ -576,11 +587,11 @@ const readGeneratedModuleName = (content) => {
return match?.[1]?.trim() || ''
}
const buildNamespaceExportOccurrence = ({ fileName, isSelected, moduleName }) => {
const buildNamespaceExportOccurrence = ({ fileName, moduleName }) => {
return {
exportName: buildModuleNamespaceExportName(fileName),
fileName,
isSelected,
isSelected: false,
moduleName,
path: null,
sourceType: 'namespace',
@ -620,12 +631,52 @@ const findDuplicateModuleExports = (moduleExports) => {
.sort((left, right) => left.exportName.localeCompare(right.exportName))
}
const findNamespaceExportConflicts = (moduleExports) => {
const namespaceOccurrences = moduleExports.map((moduleExport) =>
buildNamespaceExportOccurrence({
fileName: moduleExport.fileName,
moduleName: moduleExport.moduleName,
}),
)
const namespaceMap = new Map(
namespaceOccurrences.map((occurrence) => [occurrence.exportName, occurrence]),
)
const conflicts = new Map()
for (const moduleExport of moduleExports) {
for (const occurrence of moduleExport.occurrences) {
const namespaceOccurrence = namespaceMap.get(occurrence.exportName)
if (!namespaceOccurrence) {
continue
}
if (!conflicts.has(occurrence.exportName)) {
conflicts.set(occurrence.exportName, {
exportName: occurrence.exportName,
occurrences: [namespaceOccurrence],
})
}
conflicts.get(occurrence.exportName).occurrences.push(occurrence)
}
}
return [...conflicts.values()]
.map((conflict) => ({
...conflict,
occurrences: conflict.occurrences.sort(compareDuplicateOccurrences),
}))
.filter((conflict) => conflict.occurrences.length > 1)
.sort((left, right) => left.exportName.localeCompare(right.exportName))
}
const applyDuplicateExportRenameStrategy = (selectedModules, conflicts) => {
const renameEntries = []
for (const conflict of conflicts) {
for (const occurrence of conflict.occurrences) {
if (!occurrence.isSelected || occurrence.sourceType !== 'flat') {
if (!occurrence.isSelected) {
continue
}
@ -683,45 +734,45 @@ const compareDuplicateOccurrences = (left, right) => {
}
const assertNoDuplicateModuleExports = (moduleExports) => {
const duplicateConflicts = findDuplicateModuleExports(
moduleExports.map((moduleExport) => ({
fileName: moduleExport.fileName,
isSelected: false,
moduleName: moduleExport.fileName,
occurrences: [
buildNamespaceExportOccurrence({
fileName: moduleExport.fileName,
isSelected: false,
moduleName: moduleExport.fileName,
}),
...moduleExport.exportNames.map((exportName) =>
buildFlatExportOccurrence({
exportName,
fileName: moduleExport.fileName,
isSelected: false,
moduleName: moduleExport.fileName,
path: null,
}),
),
],
})),
)
const normalizedModuleExports = moduleExports.map((moduleExport) => ({
fileName: moduleExport.fileName,
isSelected: false,
moduleName: moduleExport.fileName,
occurrences: moduleExport.exportNames.map((exportName) =>
buildFlatExportOccurrence({
exportName,
fileName: moduleExport.fileName,
isSelected: false,
moduleName: moduleExport.fileName,
path: null,
}),
),
}))
const namespaceConflicts = findNamespaceExportConflicts(normalizedModuleExports)
const duplicateConflicts = findDuplicateModuleExports(normalizedModuleExports)
if (!duplicateConflicts.length) {
if (!namespaceConflicts.length && !duplicateConflicts.length) {
return
}
throw createDuplicateModuleExportsError(duplicateConflicts, {
reason: 'unresolved',
reason: namespaceConflicts.length ? 'namespace-conflict' : 'unresolved',
supplementalConflicts: namespaceConflicts,
})
}
const createDuplicateModuleExportsError = (conflicts, { reason = 'unresolved' } = {}) => {
const createDuplicateModuleExportsError = (
conflicts,
{ reason = 'unresolved', supplementalConflicts = [] } = {},
) => {
const allConflicts = [...supplementalConflicts, ...conflicts]
const reasonMessage =
reason === 'user-aborted'
? 'Generation cancelled because duplicate exports were not resolved.'
: reason === 'rename-failed'
? 'Automatic rename still leaves duplicate exports, so generation has been aborted.'
: reason === 'namespace-conflict'
? 'A flat API export still conflicts with a module namespace export. Regenerate the affected modules or run a full generate to repair stale generated files.'
: 'Current index export mode uses "export * from \'./module\'", so duplicate names are not allowed. Run this command in an interactive terminal to choose rename or exit.'
return new Error(
@ -729,7 +780,7 @@ const createDuplicateModuleExportsError = (conflicts, { reason = 'unresolved' }
'Duplicate API export names detected across generated modules.',
reasonMessage,
'',
...formatDuplicateConflictLines(conflicts),
...formatDuplicateConflictLines(allConflicts),
].join('\n'),
)
}
@ -1214,8 +1265,8 @@ const compareOperations = (left, right) => {
return HTTP_METHOD_ORDER.indexOf(left.method) - HTTP_METHOD_ORDER.indexOf(right.method)
}
const assignFunctionNames = (operations) => {
const usedNames = new Set()
const assignFunctionNames = (operations, reservedNames = new Set()) => {
const usedNames = new Set(reservedNames)
for (const item of operations) {
const baseName = ensureIdentifier(toCamelCase(getEndpointName(item.path)))

View File

@ -276,7 +276,7 @@ test('generation can rename duplicate exports by appending module names', async
}
})
test('generation renames flat exports that conflict with module namespace exports', async () => {
test('generation automatically avoids collisions with module namespace export names', async () => {
const tempDir = await createTempDir()
try {
@ -286,9 +286,9 @@ test('generation renames flat exports that conflict with module namespace export
await writeJson(swaggerPath, {
openapi: '3.0.0',
paths: {
'/api/v1/Ranking': {
'/api/Values': {
get: {
tags: ['Ranking'],
tags: ['Values'],
responses: {
200: { description: 'OK' },
},
@ -306,19 +306,65 @@ test('generation renames flat exports that conflict with module namespace export
paramStyle: 'object',
modules: [],
cleanOutput: true,
resolveDuplicateExports: async ({ conflicts }) => {
assert.equal(conflicts.length, 1)
assert.equal(conflicts[0].exportName, 'rankingApi')
return { action: 'rename' }
})
const valuesContent = await readFile(path.join(outputDir, 'values.js'))
const indexContent = await readFile(path.join(outputDir, 'index.js'))
assert.match(valuesContent, /const getValuesApi =/)
assert.match(indexContent, /export \* as valuesApi from ["']\.\/values["']/)
assert.match(indexContent, /export \* from ["']\.\/values["']/)
} finally {
await fs.rm(tempDir, { recursive: true, force: true })
}
})
test('partial generation fails when retained generated files still collide with namespace exports', async () => {
const tempDir = await createTempDir()
try {
const swaggerPath = path.join(tempDir, 'swagger.json')
const outputDir = path.join(tempDir, 'generated')
await fs.mkdir(outputDir, { recursive: true })
await fs.writeFile(
path.join(outputDir, 'values.js'),
`// Auto-generated. Do not edit manually.
// Module: Values
const valuesApi = () => null
export { valuesApi }
`,
'utf8',
)
await writeJson(swaggerPath, {
openapi: '3.0.0',
paths: {
'/api/v1/Alpha/GetThing': {
get: {
tags: ['Alpha'],
responses: {
200: { description: 'OK' },
},
},
},
},
})
const rankingContent = await readFile(path.join(outputDir, 'ranking.js'))
const indexContent = await readFile(path.join(outputDir, 'index.js'))
assert.match(rankingContent, /const rankingRankingApi =/)
assert.match(indexContent, /export \* as rankingApi from ["']\.\/ranking["']/)
assert.match(indexContent, /export \* from ["']\.\/ranking["']/)
await assert.rejects(
generateApiFiles({
projectRoot: tempDir,
swaggerUrl: swaggerPath,
swaggerTimeoutMs: 1000,
outputDir,
requestImport: '../request',
paramStyle: 'object',
modules: ['Alpha'],
cleanOutput: true,
}),
/namespace export/,
)
} finally {
await fs.rm(tempDir, { recursive: true, force: true })
}