Skip to content

Commit c2a2048

Browse files
authored
Override isExternalLibraryImport as needed; re-add realpath (#970)
* Re-add "realpath" to LanguageServiceHost Implement resolveModuleNames that forces modules to be considered internal (not from an external library) when we need them to be emitted * fix linter failures * fix failure on old ts version * Add $$ts-node-root.ts synthetic root file, which `/// <reference`'s all require()d files to force them to be included in compilation without modifying the rootFiles array * Preserve path casing in /// <references * fix * Revert $$ts-node-root changes, limiting only to the isExternal* changes * Add new resolver behavior to the CompilerHost codepath * WIP * add tests; plus fixes * Fix tests * Code-reviewing myself * fix tests * add missing test files * fix tests * fix tests * fix linter * fix tests on windows * remove comma from the diff * Update package.json * adding handling of @Scoped modules in bucketing logic; adds tests
1 parent 836d1f2 commit c2a2048

File tree

28 files changed

+356
-29
lines changed

28 files changed

+356
-29
lines changed
Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,63 @@
1+
## How we override isExternalLibraryImport
2+
3+
`isExternalLibraryImport` is a boolean returned by node's module resolver that is `true`
4+
if the target module is inside a `node_modules` directory.
5+
6+
This has 2x effects inside the compiler:
7+
a) compiler refuses to emit JS for external modules
8+
b) increments node_module depth +1, which affects `maxNodeModulesJsDepth`
9+
10+
If someone `require()`s a file inside `node_modules`, we need to override this flag to overcome (a).
11+
12+
### ts-node's behavior
13+
14+
- If TS's normal resolution deems a file is external, we might override this flag.
15+
- Is file's containing module directory marked as "must be internal"?
16+
- if yes, override as "internal"
17+
- if no, track this flag, and leave it as "external"
18+
19+
When you try to `require()` a file that's previously been deemed "external", we mark the entire module's
20+
directory as "must be internal" and add the file to `rootFiles` to trigger a re-resolve.
21+
22+
When you try to `require()` a file that's totally unknown to the compiler, we have to add it to `rootFiles`
23+
to trigger a recompile. This is a separate issue.
24+
25+
### Implementation notes
26+
27+
In `updateMemoryCache`:
28+
- If file is not in rootFiles and is not known internal (either was never resolved or was resolved external)
29+
- mark module directory as "must be internal"
30+
- add file to rootFiles to either pull file into compilation or trigger re-resolve (will do both)
31+
32+
TODO: WHAT IF WE MUST MARK FILEA INTERNAL; WILL FILEB AUTOMATICALLY GET THE SAME TREATMENT?
33+
34+
TODO if `noResolve`, force adding to `rootFileNames`?
35+
36+
TODO if `noResolve` are the resolvers called anyway?
37+
38+
TODO eagerly classify .ts as internal, only use the "bucket" behavior for .js?
39+
- b/c externalModule and maxNodeModulesJsDepth only seems to affect typechecking of .js, not .ts
40+
41+
### Tests
42+
43+
require() .ts file where TS didn't know about it before
44+
require() .js file where TS didn't know about it before, w/allowJs
45+
import {} ./node_modules/*/.ts
46+
import {} ./node_modules/*/.js w/allowJs (initially external; will be switched to internal)
47+
import {} ./node_modules/*/.ts from another file within node_modules
48+
import {} ./node_modules/*/.js from another file within node_modules
49+
require() from ./node_modules when it is ignored; ensure is not forced internal and maxNodeModulesJsDepth is respected (type info does not change)
50+
51+
### Keywords for searching TypeScript's source code
52+
53+
These may jog my memory the next time I need to read TypeScript's source and remember how this works.
54+
55+
currentNodeModulesDepth
56+
sourceFilesFoundSearchingNodeModules
57+
58+
isExternalLibraryImport is used to increment currentNodeModulesDepth
59+
currentNodeModulesDepth is used to put things into sourceFilesFoundSearchingNodeModules
60+
61+
https://github.com/microsoft/TypeScript/blob/ec338146166935069124572135119b57a3d2cd22/src/compiler/program.ts#L2384-L2398
62+
63+
getSourceFilesToEmit / sourceFileMayBeEmitted obeys internal "external" state, is responsible for preventing emit of external modules

scripts/create-merged-schema.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -54,7 +54,7 @@ async function main() {
5454
);
5555
}
5656

57-
async function getSchemastoreSchema() {
57+
export async function getSchemastoreSchema() {
5858
const {data: schemastoreSchema} = await axios.get(
5959
'https://schemastore.azurewebsites.net/schemas/json/tsconfig.json',
6060
{ responseType: "json" }

src/index.spec.ts

Lines changed: 39 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -594,24 +594,52 @@ describe('ts-node', function () {
594594
return done()
595595
})
596596
})
597-
598-
it('should give ts error for invalid node_modules', function (done) {
599-
exec(`${cmd} --compiler-host --skip-ignore tests/from-node-modules/from-node-modules`, function (err, stdout) {
600-
if (err === null) return done('Expected an error')
601-
602-
expect(err.message).to.contain('Unable to compile file from external library')
603-
604-
return done()
605-
})
606-
})
607597
})
608598

609599
it('should transpile files inside a node_modules directory when not ignored', function (done) {
610-
exec(`${cmd} --skip-ignore tests/from-node-modules/from-node-modules`, function (err, stdout, stderr) {
600+
exec(`${cmdNoProject} --script-mode tests/from-node-modules/from-node-modules`, function (err, stdout, stderr) {
611601
if (err) return done(`Unexpected error: ${err}\nstdout:\n${stdout}\nstderr:\n${stderr}`)
602+
expect(JSON.parse(stdout)).to.deep.equal({
603+
external: {
604+
tsmri: { name: 'typescript-module-required-internally' },
605+
jsmri: { name: 'javascript-module-required-internally' },
606+
tsmii: { name: 'typescript-module-imported-internally' },
607+
jsmii: { name: 'javascript-module-imported-internally' }
608+
},
609+
tsmie: { name: 'typescript-module-imported-externally' },
610+
jsmie: { name: 'javascript-module-imported-externally' },
611+
tsmre: { name: 'typescript-module-required-externally' },
612+
jsmre: { name: 'javascript-module-required-externally' }
613+
})
612614
done()
613615
})
614616
})
617+
618+
describe('should respect maxNodeModulesJsDepth', function () {
619+
it('for unscoped modules', function (done) {
620+
exec(`${cmdNoProject} --script-mode tests/maxnodemodulesjsdepth`, function (err, stdout, stderr) {
621+
expect(err).to.not.equal(null)
622+
expect(stderr.replace(/\r\n/g, '\n')).to.contain(
623+
'TSError: ⨯ Unable to compile TypeScript:\n' +
624+
"other.ts(4,7): error TS2322: Type 'string' is not assignable to type 'boolean'.\n" +
625+
'\n'
626+
)
627+
done()
628+
})
629+
})
630+
631+
it('for @scoped modules', function (done) {
632+
exec(`${cmdNoProject} --script-mode tests/maxnodemodulesjsdepth-scoped`, function (err, stdout, stderr) {
633+
expect(err).to.not.equal(null)
634+
expect(stderr.replace(/\r\n/g, '\n')).to.contain(
635+
'TSError: ⨯ Unable to compile TypeScript:\n' +
636+
"other.ts(7,7): error TS2322: Type 'string' is not assignable to type 'boolean'.\n" +
637+
'\n'
638+
)
639+
done()
640+
})
641+
})
642+
})
615643
})
616644

617645
describe('register', function () {

src/index.ts

Lines changed: 139 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,10 @@
1-
import { relative, basename, extname, resolve, dirname, join } from 'path'
1+
import { relative, basename, extname, resolve, dirname, join, isAbsolute } from 'path'
22
import sourceMapSupport = require('source-map-support')
33
import * as ynModule from 'yn'
44
import { BaseError } from 'make-error'
55
import * as util from 'util'
66
import { fileURLToPath } from 'url'
7-
import * as _ts from 'typescript'
7+
import type * as _ts from 'typescript'
88
import * as Module from 'module'
99

1010
/**
@@ -95,6 +95,18 @@ export interface TSCommon {
9595
formatDiagnosticsWithColorAndContext: typeof _ts.formatDiagnosticsWithColorAndContext
9696
}
9797

98+
/**
99+
* Compiler APIs we use that are marked internal and not included in TypeScript's public API declarations
100+
*/
101+
interface TSInternal {
102+
// https://github.com/microsoft/TypeScript/blob/4a34294908bed6701dcba2456ca7ac5eafe0ddff/src/compiler/core.ts#L1906-L1909
103+
createGetCanonicalFileName (useCaseSensitiveFileNames: boolean): TSInternal.GetCanonicalFileName
104+
}
105+
namespace TSInternal {
106+
// https://github.com/microsoft/TypeScript/blob/4a34294908bed6701dcba2456ca7ac5eafe0ddff/src/compiler/core.ts#L1906
107+
export type GetCanonicalFileName = (fileName: string) => string
108+
}
109+
98110
/**
99111
* Export the current version.
100112
*/
@@ -515,6 +527,103 @@ export function create (rawOptions: CreateOptions = {}): Register {
515527
let getOutput: (code: string, fileName: string) => SourceOutput
516528
let getTypeInfo: (_code: string, _fileName: string, _position: number) => TypeInfo
517529

530+
const getCanonicalFileName = (ts as unknown as TSInternal).createGetCanonicalFileName(ts.sys.useCaseSensitiveFileNames)
531+
532+
// In a factory because these are shared across both CompilerHost and LanguageService codepaths
533+
function createResolverFunctions (serviceHost: _ts.ModuleResolutionHost) {
534+
const moduleResolutionCache = ts.createModuleResolutionCache(cwd, getCanonicalFileName, config.options)
535+
const knownInternalFilenames = new Set<string>()
536+
/** "Buckets" (module directories) whose contents should be marked "internal" */
537+
const internalBuckets = new Set<string>()
538+
539+
// Get bucket for a source filename. Bucket is the containing `./node_modules/*/` directory
540+
// For '/project/node_modules/foo/node_modules/bar/lib/index.js' bucket is '/project/node_modules/foo/node_modules/bar/'
541+
// For '/project/node_modules/foo/node_modules/@scope/bar/lib/index.js' bucket is '/project/node_modules/foo/node_modules/@scope/bar/'
542+
const moduleBucketRe = /.*\/node_modules\/(?:@[^\/]+\/)?[^\/]+\//
543+
function getModuleBucket (filename: string) {
544+
const find = moduleBucketRe.exec(filename)
545+
if (find) return find[0]
546+
return ''
547+
}
548+
549+
// Mark that this file and all siblings in its bucket should be "internal"
550+
function markBucketOfFilenameInternal (filename: string) {
551+
internalBuckets.add(getModuleBucket(filename))
552+
}
553+
554+
function isFileInInternalBucket (filename: string) {
555+
return internalBuckets.has(getModuleBucket(filename))
556+
}
557+
558+
function isFileKnownToBeInternal (filename: string) {
559+
return knownInternalFilenames.has(filename)
560+
}
561+
562+
/**
563+
* If we need to emit JS for a file, force TS to consider it non-external
564+
*/
565+
const fixupResolvedModule = (resolvedModule: _ts.ResolvedModule | _ts.ResolvedTypeReferenceDirective) => {
566+
const { resolvedFileName } = resolvedModule
567+
if (resolvedFileName === undefined) return
568+
// .ts is always switched to internal
569+
// .js is switched on-demand
570+
if (
571+
resolvedModule.isExternalLibraryImport && (
572+
(resolvedFileName.endsWith('.ts') && !resolvedFileName.endsWith('.d.ts')) ||
573+
isFileKnownToBeInternal(resolvedFileName) ||
574+
isFileInInternalBucket(resolvedFileName)
575+
)
576+
) {
577+
resolvedModule.isExternalLibraryImport = false
578+
}
579+
if (!resolvedModule.isExternalLibraryImport) {
580+
knownInternalFilenames.add(resolvedFileName)
581+
}
582+
}
583+
/*
584+
* NOTE:
585+
* Older ts versions do not pass `redirectedReference` nor `options`.
586+
* We must pass `redirectedReference` to newer ts versions, but cannot rely on `options`, hence the weird argument name
587+
*/
588+
const resolveModuleNames: _ts.LanguageServiceHost['resolveModuleNames'] = (moduleNames: string[], containingFile: string, reusedNames: string[] | undefined, redirectedReference: _ts.ResolvedProjectReference | undefined, optionsOnlyWithNewerTsVersions: _ts.CompilerOptions): (_ts.ResolvedModule | undefined)[] => {
589+
return moduleNames.map(moduleName => {
590+
const { resolvedModule } = ts.resolveModuleName(moduleName, containingFile, config.options, serviceHost, moduleResolutionCache, redirectedReference)
591+
if (resolvedModule) {
592+
fixupResolvedModule(resolvedModule)
593+
}
594+
return resolvedModule
595+
})
596+
}
597+
598+
// language service never calls this, but TS docs recommend that we implement it
599+
const getResolvedModuleWithFailedLookupLocationsFromCache: _ts.LanguageServiceHost['getResolvedModuleWithFailedLookupLocationsFromCache'] = (moduleName, containingFile): _ts.ResolvedModuleWithFailedLookupLocations | undefined => {
600+
const ret = ts.resolveModuleNameFromCache(moduleName, containingFile, moduleResolutionCache)
601+
if (ret && ret.resolvedModule) {
602+
fixupResolvedModule(ret.resolvedModule)
603+
}
604+
return ret
605+
}
606+
607+
const resolveTypeReferenceDirectives: _ts.LanguageServiceHost['resolveTypeReferenceDirectives'] = (typeDirectiveNames: string[], containingFile: string, redirectedReference: _ts.ResolvedProjectReference | undefined, options: _ts.CompilerOptions): (_ts.ResolvedTypeReferenceDirective | undefined)[] => {
608+
// Note: seems to be called with empty typeDirectiveNames array for all files.
609+
return typeDirectiveNames.map(typeDirectiveName => {
610+
const { resolvedTypeReferenceDirective } = ts.resolveTypeReferenceDirective(typeDirectiveName, containingFile, config.options, serviceHost, redirectedReference)
611+
if (resolvedTypeReferenceDirective) {
612+
fixupResolvedModule(resolvedTypeReferenceDirective)
613+
}
614+
return resolvedTypeReferenceDirective
615+
})
616+
}
617+
618+
return {
619+
resolveModuleNames,
620+
getResolvedModuleWithFailedLookupLocationsFromCache,
621+
resolveTypeReferenceDirectives,
622+
isFileKnownToBeInternal,
623+
markBucketOfFilenameInternal
624+
}
625+
}
626+
518627
// Use full language services when the fast option is disabled.
519628
if (!transpileOnly) {
520629
const fileContents = new Map<string, string>()
@@ -536,14 +645,15 @@ export function create (rawOptions: CreateOptions = {}): Register {
536645
}
537646

538647
// Create the compiler host for type checking.
539-
const serviceHost: _ts.LanguageServiceHost = {
648+
const serviceHost: _ts.LanguageServiceHost & Required<Pick<_ts.LanguageServiceHost, 'fileExists' | 'readFile'>> = {
540649
getProjectVersion: () => String(projectVersion),
541650
getScriptFileNames: () => Array.from(rootFileNames),
542651
getScriptVersion: (fileName: string) => {
543652
const version = fileVersions.get(fileName)
544653
return version ? version.toString() : ''
545654
},
546655
getScriptSnapshot (fileName: string) {
656+
// TODO ordering of this with getScriptVersion? Should they sync up?
547657
let contents = fileContents.get(fileName)
548658

549659
// Read contents into TypeScript memory cache.
@@ -563,21 +673,27 @@ export function create (rawOptions: CreateOptions = {}): Register {
563673
getDirectories: cachedLookup(debugFn('getDirectories', ts.sys.getDirectories)),
564674
fileExists: cachedLookup(debugFn('fileExists', fileExists)),
565675
directoryExists: cachedLookup(debugFn('directoryExists', ts.sys.directoryExists)),
676+
realpath: ts.sys.realpath ? cachedLookup(debugFn('realpath', ts.sys.realpath)) : undefined,
566677
getNewLine: () => ts.sys.newLine,
567678
useCaseSensitiveFileNames: () => ts.sys.useCaseSensitiveFileNames,
568679
getCurrentDirectory: () => cwd,
569680
getCompilationSettings: () => config.options,
570681
getDefaultLibFileName: () => ts.getDefaultLibFilePath(config.options),
571682
getCustomTransformers: getCustomTransformers
572683
}
684+
const { resolveModuleNames, getResolvedModuleWithFailedLookupLocationsFromCache, resolveTypeReferenceDirectives, isFileKnownToBeInternal, markBucketOfFilenameInternal } = createResolverFunctions(serviceHost)
685+
serviceHost.resolveModuleNames = resolveModuleNames
686+
serviceHost.getResolvedModuleWithFailedLookupLocationsFromCache = getResolvedModuleWithFailedLookupLocationsFromCache
687+
serviceHost.resolveTypeReferenceDirectives = resolveTypeReferenceDirectives
573688

574689
const registry = ts.createDocumentRegistry(ts.sys.useCaseSensitiveFileNames, cwd)
575690
const service = ts.createLanguageService(serviceHost, registry)
576691

577692
const updateMemoryCache = (contents: string, fileName: string) => {
578-
// Add to `rootFiles` if not already there
579-
// This is necessary to force TS to emit output
580-
if (!rootFileNames.has(fileName)) {
693+
// Add to `rootFiles` as necessary, either to make TS include a file it has not seen,
694+
// or to trigger a re-classification of files from external to internal.
695+
if (!rootFileNames.has(fileName) && !isFileKnownToBeInternal(fileName)) {
696+
markBucketOfFilenameInternal(fileName)
581697
rootFileNames.add(fileName)
582698
// Increment project version for every change to rootFileNames.
583699
projectVersion++
@@ -649,13 +765,15 @@ export function create (rawOptions: CreateOptions = {}): Register {
649765
return { name, comment }
650766
}
651767
} else {
652-
const sys = {
768+
const sys: _ts.System & _ts.FormatDiagnosticsHost = {
653769
...ts.sys,
654770
...diagnosticHost,
655771
readFile: (fileName: string) => {
656772
const cacheContents = fileContents.get(fileName)
657773
if (cacheContents !== undefined) return cacheContents
658-
return cachedReadFile(fileName)
774+
const contents = cachedReadFile(fileName)
775+
if (contents) fileContents.set(fileName, contents)
776+
return contents
659777
},
660778
readDirectory: ts.sys.readDirectory,
661779
getDirectories: cachedLookup(debugFn('getDirectories', ts.sys.getDirectories)),
@@ -678,6 +796,9 @@ export function create (rawOptions: CreateOptions = {}): Register {
678796
getDefaultLibFileName: () => normalizeSlashes(join(dirname(compiler), ts.getDefaultLibFileName(config.options))),
679797
useCaseSensitiveFileNames: () => sys.useCaseSensitiveFileNames
680798
}
799+
const { resolveModuleNames, resolveTypeReferenceDirectives, isFileKnownToBeInternal, markBucketOfFilenameInternal } = createResolverFunctions(host)
800+
host.resolveModuleNames = resolveModuleNames
801+
host.resolveTypeReferenceDirectives = resolveTypeReferenceDirectives
681802

682803
// Fallback for older TypeScript releases without incremental API.
683804
let builderProgram = ts.createIncrementalProgram
@@ -704,17 +825,22 @@ export function create (rawOptions: CreateOptions = {}): Register {
704825

705826
// Set the file contents into cache manually.
706827
const updateMemoryCache = (contents: string, fileName: string) => {
707-
const sourceFile = builderProgram.getSourceFile(fileName)
708-
709-
fileContents.set(fileName, contents)
828+
const previousContents = fileContents.get(fileName)
829+
const contentsChanged = previousContents !== contents
830+
if (contentsChanged) {
831+
fileContents.set(fileName, contents)
832+
}
710833

711834
// Add to `rootFiles` when discovered by compiler for the first time.
712-
if (sourceFile === undefined) {
835+
let addedToRootFileNames = false
836+
if (!rootFileNames.has(fileName) && !isFileKnownToBeInternal(fileName)) {
837+
markBucketOfFilenameInternal(fileName)
713838
rootFileNames.add(fileName)
839+
addedToRootFileNames = true
714840
}
715841

716842
// Update program when file changes.
717-
if (sourceFile === undefined || sourceFile.text !== contents) {
843+
if (addedToRootFileNames || contentsChanged) {
718844
builderProgram = ts.createEmitAndSemanticDiagnosticsBuilderProgram(
719845
Array.from(rootFileNames),
720846
config.options,
Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1 +1,10 @@
1-
import 'test'
1+
// These files are resolved by the typechecker
2+
import * as tsmie from 'external/typescript-module-imported-externally'
3+
import * as jsmie from 'external/javascript-module-imported-externally'
4+
// These files are unknown to the compiler until required.
5+
const tsmre = require('external/typescript-module-required-externally')
6+
const jsmre = require('external/javascript-module-required-externally')
7+
8+
import * as external from 'external'
9+
10+
console.log(JSON.stringify({external, tsmie, jsmie, tsmre, jsmre}, null, 2))

tests/from-node-modules/node_modules/external/index.ts

Lines changed: 8 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

0 commit comments

Comments
 (0)