Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Override isExternalLibraryImport as needed; re-add realpath #970

Merged
merged 27 commits into from
Aug 21, 2020

Conversation

cspotcode
Copy link
Collaborator

@cspotcode cspotcode commented Mar 2, 2020

The various realpath issues we've seen don't strictly relate to symlinks.

They're caused by TypeScript considering a file to be an "external library import" when it's nested within a node_modules directory, and refusing to emit .js for it. Sometimes a symlink's path does not include node_modules and its resolved path does, or vice versa, so enabling or disabling realpath causes or fixes this bug.

The "external library import" determination is handled by the module resolver. If we implement our own in the LanguageServiceHost, we can tell the compiler to consider all rootFileNames to be "internal" no matter what.

This lets us re-enable realpath.

Related / should fix:
fix #876
fix #797
fix #956
fix #819
fix #990
fix #1093


Notes for code review

It's been a while since I started this PR, and I've since learned more about the compiler.

rootFiles are always emitted, but we want to avoid modifying rootFiles as much as possible, because it throws away a bunch of compiler state. How much, precisely, I'm not sure, but modify rootFiles is way slower than leaving it as-is rootFiles.

This PR hooks into TypeScript's resolver and does 2x things:
(a) forces some modules to be classified internal using our own logic, overriding TypeScript's logic
(b) keeps track of modules that have already been classified as internal

(a) enables compiling files nested within ./node_modules
(b) allows us to avoid modifying rootFiles unnecessary

TS files in node_modules are always forced to be internal, because I can't see any downsides to this.
JS files in node_modules are only forced internal on-demand (when required), because the "internal/external" distinction affects maxNodeModuleJsDepth, which affects typechecking.


Old, irrelevant notes

I hate deleting old research in case I need it later, but stuff below this line is not relevant anymore.

Gotchas / notes

(this is not true anymore) Implementation overrides isExternalLibraryImport according to our ignored() logic, which is the "nuclear option" described here:
microsoft/TypeScript#28432 (comment)
There's another option: node_modules nested within the rootDir are excluded, but if rootDir has node_modules in its own path, it is still compiled.

Implement resolveModuleNames that forces modules to be considered internal (not from an external library) when we need them to be emitted
@coveralls
Copy link

coveralls commented Mar 2, 2020

Coverage Status

Coverage increased (+1.05%) to 86.95% when pulling cc279f8 on ab/realpath-and-emit-node_modules-files into 836d1f2 on master.

@arcanis
Copy link

arcanis commented Apr 19, 2020

Of note, TS has itself a bug with workspaces being marked as external sources (cf microsoft/TypeScript#37270). Maybe the isExternalLibraryImport override here could workaround that as well?

…ll require()d files to

force them to be included in compilation without modifying the rootFiles array
@codecov
Copy link

codecov bot commented May 20, 2020

Codecov Report

Merging #970 into master will decrease coverage by 0.31%.
The diff coverage is 82.25%.

Impacted file tree graph

@@            Coverage Diff             @@
##           master     #970      +/-   ##
==========================================
- Coverage   79.63%   79.32%   -0.32%     
==========================================
  Files           7        7              
  Lines         658      711      +53     
  Branches      147      158      +11     
==========================================
+ Hits          524      564      +40     
- Misses         84       90       +6     
- Partials       50       57       +7     
Flag Coverage Δ
#node_10 76.28% <82.25%> (-0.09%) ⬇️
#node_12_15 76.63% <82.25%> (-0.12%) ⬇️
#node_12_16 76.63% <82.25%> (-0.12%) ⬇️
#node_13 79.04% <82.25%> (-0.29%) ⬇️
#node_14 79.04% <82.25%> (-0.29%) ⬇️
#typescript_2_7 78.90% <82.25%> (-0.28%) ⬇️
#typescript_latest 78.19% <82.25%> (-0.22%) ⬇️
#typescript_next 78.05% <82.25%> (-0.21%) ⬇️
#ubuntu 79.04% <82.25%> (-0.29%) ⬇️
#windows 79.18% <82.25%> (-0.30%) ⬇️

Flags with carried forward coverage won't be shown. Click here to find out more.

Impacted Files Coverage Δ
src/index.ts 79.06% <82.25%> (-0.54%) ⬇️

Continue to review full report at Codecov.

Legend - Click here to learn more
Δ = absolute <relative> (impact), ø = not affected, ? = missing data
Powered by Codecov. Last update 836d1f2...cc279f8. Read the comment docs.

@cspotcode cspotcode force-pushed the ab/realpath-and-emit-node_modules-files branch from 83518b8 to 32d7c37 Compare July 29, 2020 01:40
@cspotcode
Copy link
Collaborator Author

cspotcode commented Jul 30, 2020

https://github.com/TypeStrong/ts-node/blob/master/src/index.ts#L688-L710

I noticed some weirdness in the compilerHost codepath which I'll need to fix. Don't have time now, so I'm jotting it down.

The bug is: rootFileNames are updated and the program rebuilt only when the builder program doesn't already have a sourcefile for the require()d module.

The problem is that a sourcefile might exist, but it might be considered "external." When this happens, typescript refuses to emit .js for it. We can force TS to emit .js by adding the file to rootFileNames. Unfortunately this is slow because it triggers a program rebuild that should not otherwise be necessary.

If we mark all non-ignored files as "internal", this problem goes away. Alternatively, we keep track of which files are classified "external" and we add them to rootFileNames only when we must force a reclassification "tointernal."

@cspotcode
Copy link
Collaborator Author

Importantly, we must ensure that tests properly trigger all possible patterns for a file being classified external, being omitted from rootFileNames, being unknown to the compiler, etc.

  • require() outside the typechecker
  • import from node_modules
  • something in node_modules import something else in node_modules (the latter is classified external b/c is not in rootFileNames)

@@ -0,0 +1,63 @@
## How we override isExternalLibraryImport
Copy link
Collaborator Author

@cspotcode cspotcode Aug 11, 2020

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This file is notes to myself and any other ts-node developers. Given the audience, it's not as polished as the rest of ts-node, and I can remove it if needed.

@cspotcode cspotcode marked this pull request as ready for review August 11, 2020 17:55
@cspotcode
Copy link
Collaborator Author

@blakeembrey this is finally(!!) done, tested, and ready for a review. I added my own review comments to hopefully make the changes clearer.

@cspotcode
Copy link
Collaborator Author

@blakeembrey Despite the red X, tests are passing. The only failure is coverage, and I think that's only because compiler docs ask us to implement a function that the compiler never actually calls.

If you think you'll be busy for the next week, and if this sounds ok to you, I was thinking of asking for a code review on the TypeScript Discord. Compiler experts hang out there, so I think I'd be able to find a reviewer with the right skillset.

@@ -515,6 +527,102 @@ export function create (rawOptions: CreateOptions = {}): Register {
let getOutput: (code: string, fileName: string) => SourceOutput
let getTypeInfo: (_code: string, _fileName: string, _position: number) => TypeInfo

const getCanonicalFileName = (ts as unknown as TSInternal).createGetCanonicalFileName(ts.sys.useCaseSensitiveFileNames)
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This seems dangerous, shouldn't case sensitivity be determined based on the OS, or potentially the forceConsistentCasingInFileNames option?

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fair question. ts.sys.useCaseSensitiveFileNames is OS-dependent: true on Posix, false on Windows. As far as I can tell, createGetCanonicalFileName is how the compiler internally converts filenames to cache keys.

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

As far as I know, forceConsistentCasingInFileNames generates additional semantic diagnostics but doesn't affect resolver behavior. Even when forceConsistentCasingInFileNames is turned off, FOO.ts and foo.ts still resolve to the same file on Windows, right?

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ah, that makes sense. I was reading useCaseSensitiveFileNames as if there was another useCaseInsensitiveFileNames option, which it makes sense that there isn't. This should be fine then.

@blakeembrey
Copy link
Member

If you think you'll be busy for the next week, and if this sounds ok to you, I was thinking of asking for a code review on the TypeScript Discord.

Absolutely feel free to do this anyway!

Copy link
Member

@blakeembrey blakeembrey left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

To be honest, I'll just trust you on this release 😄

This work is really great, it'll probably take me a while to verify the investigation you've already done!

import sourceMapSupport = require('source-map-support')
import * as ynModule from 'yn'
import { BaseError } from 'make-error'
import * as util from 'util'
import { fileURLToPath } from 'url'
import * as _ts from 'typescript'
import type * as _ts from 'typescript'
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🙏

src/index.ts Outdated

// Get bucket for a source filename. Bucket is the containing `./node_modules/*/` directory
// For '/project/node_modules/foo/node_modules/bar/lib/index.js' bucket is '/project/node_modules/foo/node_modules/bar/'
const moduleBucketRe = /.*\/node_modules\/[^\/]+\//
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What about namespaced @ node modules?

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ooh, good catch. I'll add logic for this. I should also add a test with 2x peer modules, @org/foo and @org/bar, and make sure one bucket doesn't affect the other.

@cspotcode cspotcode force-pushed the ab/realpath-and-emit-node_modules-files branch from bbfe788 to 1d80028 Compare August 21, 2020 04:08
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment