Skip to content

Commit 99610e1

Browse files
authored
fix: static selective model import (#38)
2 parents 1e23124 + f0b39c3 commit 99610e1

File tree

4 files changed

+205
-11
lines changed

4 files changed

+205
-11
lines changed

packages/cli/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@
1717
"fs-extra": "^11.3.0",
1818
"lodash": "^4.17.21",
1919
"tsx": "^4.19.4",
20+
"typescript": "^5.8.2",
2021
"uuid": "^11.1.0",
2122
"yaml": "^2.7.0"
2223
},

packages/cli/src/commands/gen-manifests/index.ts

Lines changed: 33 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,8 @@ import fs from "fs-extra"
1111
import { v4 as uuidv4 } from "uuid"
1212
import YAML from "yaml"
1313

14+
import { analyzeModelImports, toVirtualEntryImports } from "../../libs/import-analysis.ts"
15+
1416
// Create a logger for this module
1517
const moduleLogger = createLogger("gen-manifests")
1618

@@ -170,27 +172,47 @@ async function bundleTypeScript(
170172
// Get original file size for logging
171173
const originalSize = fs.statSync(filePath).size
172174

173-
// Check if models/index.ts exists and prepare to auto-import it
175+
// Determine package root and function dir
174176
const packageRoot = process.cwd()
175-
const modelsIndexPath = path.join(packageRoot, "models", "index.ts")
176-
const shouldAutoImportModels = fs.existsSync(modelsIndexPath)
177+
const functionDir = path.dirname(filePath)
177178

178-
// Create virtual entry content that imports models and re-exports the function
179+
// Determine tsconfig path for import analysis
180+
const analysisFunctionTsConfigPath = path.join(functionDir, "tsconfig.json")
181+
const analysisCwdTsConfigPath = path.join(packageRoot, "tsconfig.json")
182+
const chosenTsConfigPath = fs.existsSync(analysisFunctionTsConfigPath)
183+
? analysisFunctionTsConfigPath
184+
: fs.existsSync(analysisCwdTsConfigPath)
185+
? analysisCwdTsConfigPath
186+
: undefined
187+
188+
// Analyze imports to detect used models under models/
189+
const modelsDir = path.join(packageRoot, "models")
190+
let modelImports: string[] = []
191+
if (fs.existsSync(modelsDir)) {
192+
try {
193+
const matches = await analyzeModelImports(filePath, modelsDir, chosenTsConfigPath)
194+
modelImports = toVirtualEntryImports(matches, packageRoot)
195+
if (modelImports.length) {
196+
moduleLogger.debug(`Auto-importing specific models:\n${modelImports.join("\n")}`)
197+
} else {
198+
moduleLogger.debug("No model imports detected under models/")
199+
}
200+
} catch (e) {
201+
moduleLogger.warn(`Model import analysis failed, continuing without auto-imports: ${e}`)
202+
}
203+
}
204+
205+
// Create virtual entry content that imports detected models and re-exports the function
179206
const relativeFunctionPath = path.relative(packageRoot, filePath).replace(/\\/g, "/")
180207
let virtualEntryContent = ""
181-
182-
if (shouldAutoImportModels) {
183-
virtualEntryContent += `import './models';\n`
184-
moduleLogger.debug(`Auto-importing models from: ${modelsIndexPath}`)
208+
if (modelImports.length) {
209+
virtualEntryContent += modelImports.join("\n") + "\n"
185210
}
186-
187-
// Import and re-export the function as default
188211
virtualEntryContent += `export { default } from './${relativeFunctionPath}';\n`
189212

190213
moduleLogger.debug(`Virtual entry content:\n${virtualEntryContent}`)
191214

192215
// Load TypeScript configuration and extract aliases
193-
const functionDir = path.dirname(filePath)
194216
let tsConfig: TypeScriptConfig | null = null
195217
let esbuildAlias: Record<string, string> | undefined = undefined
196218

Lines changed: 170 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,170 @@
1+
import path from "path"
2+
3+
import fs from "fs-extra"
4+
import * as ts from "typescript"
5+
6+
/**
7+
* Read and parse tsconfig, honoring "extends". Returns compiler options usable for module resolution.
8+
*/
9+
export function readTsCompilerOptions(tsconfigPath?: string) {
10+
if (!tsconfigPath) {
11+
const options = ts.getDefaultCompilerOptions()
12+
if (!options.moduleResolution) options.moduleResolution = ts.ModuleResolutionKind.NodeJs
13+
return { options, files: [] as string[] }
14+
}
15+
16+
const configFile = ts.readJsonConfigFile(tsconfigPath, f => fs.readFileSync(f, "utf8"))
17+
const { options, fileNames, errors } = ts.parseJsonSourceFileConfigFileContent(
18+
configFile,
19+
ts.sys,
20+
path.dirname(tsconfigPath)
21+
)
22+
23+
if (!options.moduleResolution) options.moduleResolution = ts.ModuleResolutionKind.NodeJs
24+
25+
if (errors.length) {
26+
// Keep this utility logger-agnostic. Consumers can log if needed.
27+
}
28+
29+
return { options, files: fileNames }
30+
}
31+
32+
/**
33+
* Create a CompilerHost with normalized canonical file names (helps with path comparisons).
34+
*/
35+
export function createCompilerHost(options: ts.CompilerOptions) {
36+
const host = ts.createCompilerHost(options)
37+
const origGetCanonicalFileName = host.getCanonicalFileName.bind(host)
38+
host.getCanonicalFileName = f => path.normalize(origGetCanonicalFileName(f))
39+
return host
40+
}
41+
42+
function isStringLiteralLike(node: ts.Node): node is ts.StringLiteralLike {
43+
return ts.isStringLiteral(node) || ts.isNoSubstitutionTemplateLiteral(node)
44+
}
45+
46+
/**
47+
* Collect all module specifiers from a source file:
48+
* - import ... from 'x'
49+
* - export ... from 'x'
50+
* - require('x')
51+
* - import('x')
52+
*/
53+
function collectModuleSpecifiers(sf: ts.SourceFile): string[] {
54+
const specs: string[] = []
55+
56+
function visit(node: ts.Node) {
57+
// import ... from 'x'
58+
if (
59+
ts.isImportDeclaration(node) &&
60+
node.moduleSpecifier &&
61+
isStringLiteralLike(node.moduleSpecifier)
62+
) {
63+
specs.push(node.moduleSpecifier.text)
64+
}
65+
66+
// export ... from 'x'
67+
if (
68+
ts.isExportDeclaration(node) &&
69+
node.moduleSpecifier &&
70+
isStringLiteralLike(node.moduleSpecifier)
71+
) {
72+
specs.push(node.moduleSpecifier.text)
73+
}
74+
75+
// require('x')
76+
if (
77+
ts.isCallExpression(node) &&
78+
ts.isIdentifier(node.expression) &&
79+
node.expression.text === "require" &&
80+
node.arguments.length === 1 &&
81+
isStringLiteralLike(node.arguments[0])
82+
) {
83+
specs.push(node.arguments[0].text)
84+
}
85+
86+
// import('x')
87+
if (
88+
ts.isCallExpression(node) &&
89+
node.expression.kind === ts.SyntaxKind.ImportKeyword &&
90+
node.arguments.length === 1 &&
91+
isStringLiteralLike(node.arguments[0])
92+
) {
93+
specs.push(node.arguments[0].text)
94+
}
95+
96+
ts.forEachChild(node, visit)
97+
}
98+
99+
visit(sf)
100+
return specs
101+
}
102+
103+
/**
104+
* Resolve a module specifier to an absolute file path using TypeScript's module resolution.
105+
*/
106+
function resolveModule(
107+
specifier: string,
108+
containingFile: string,
109+
options: ts.CompilerOptions,
110+
host: ts.ModuleResolutionHost
111+
): string | undefined {
112+
const resolved = ts.resolveModuleName(specifier, containingFile, options, host)
113+
const primary = resolved.resolvedModule?.resolvedFileName
114+
if (!primary) return undefined
115+
return path.normalize(primary)
116+
}
117+
118+
/**
119+
* Analyze a given source file for imports that resolve under the given target directory.
120+
* - Skips .d.ts files.
121+
* - Honors baseUrl/paths from tsconfig.
122+
*/
123+
export async function analyzeModelImports(
124+
sourceFile: string,
125+
targetDir: string,
126+
tsconfigPath?: string
127+
): Promise<string[]> {
128+
const { options } = readTsCompilerOptions(tsconfigPath)
129+
const host = createCompilerHost(options)
130+
131+
const program = ts.createProgram([sourceFile], options, host)
132+
const sf = program.getSourceFile(sourceFile)
133+
if (!sf) {
134+
return []
135+
}
136+
137+
const specs = collectModuleSpecifiers(sf)
138+
139+
const normalizedTarget = path.normalize(targetDir)
140+
const hits = new Set<string>()
141+
for (const spec of specs) {
142+
const resolved = resolveModule(spec, sourceFile, options, host)
143+
if (!resolved) continue
144+
if (resolved.endsWith(".d.ts")) continue // skip .d.ts as requested
145+
if (resolved.toLowerCase().startsWith(normalizedTarget.toLowerCase() + path.sep)) {
146+
hits.add(resolved)
147+
}
148+
}
149+
return Array.from(hits)
150+
}
151+
152+
/**
153+
* Convert absolute file paths to side-effect import statements relative to a package root.
154+
* - For index.(ts|tsx|js), imports the folder.
155+
* - For other files, strips the extension.
156+
*/
157+
export function toVirtualEntryImports(resolvedFiles: string[], packageRoot: string): string[] {
158+
const uniq = new Set<string>()
159+
for (const abs of resolvedFiles) {
160+
let rel = path.relative(packageRoot, abs).replace(/\\/g, "/")
161+
if (/(^|\/)index\.(ts|tsx|js)$/.test(rel)) {
162+
rel = rel.replace(/\/index\.(ts|tsx|js)$/, "")
163+
} else {
164+
rel = rel.replace(/\.(ts|tsx|js)$/, "")
165+
}
166+
const spec = `./${rel}`
167+
uniq.add(`import '${spec}';`)
168+
}
169+
return Array.from(uniq)
170+
}

yarn.lock

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -277,6 +277,7 @@ __metadata:
277277
fs-extra: "npm:^11.3.0"
278278
lodash: "npm:^4.17.21"
279279
tsx: "npm:^4.19.4"
280+
typescript: "npm:^5.8.2"
280281
uuid: "npm:^11.1.0"
281282
yaml: "npm:^2.7.0"
282283
bin:

0 commit comments

Comments
 (0)