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

Check nearest package.json dependencies for possible package names for specifier candidates #58176

Merged

Conversation

weswigham
Copy link
Member

@weswigham weswigham commented Apr 12, 2024

Implements this comment.

I won't say this "fixes" #42873, but it does prevent it from occurring for people in workspaces with appropriately set-up explicit dependencies in their package files, even when they haven't explicitly referenced all of those dependencies in their program. IMO, that's the upper limit of how much we can or should fix that issue - anything further would imply unsafe/unportable specifiers, which I don't want us to generate. I say that, but this could be improved upon: While this leverages the fact that we already lookup all package.json files to make this cheap, this does not look through those package.json dependencies to try and make a deep import, so we'll only discover top-level package imports this way right now. That's a potential improvement that could be built on this.

@typescript-bot typescript-bot added Author: Team For Uncommitted Bug PR for untriaged, rejected, closed or missing bug labels Apr 12, 2024
if (resolvedPath === moduleFileName) {
return name;
}
if (host.realpath && host.realpath(resolvedPath) === host.realpath(moduleFileName)) {
Copy link
Member Author

Choose a reason for hiding this comment

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

I didn't want to have to realpath here, but it seems to be the only way we can check if the two paths actually refer to the same thing through symlinks.

@RyanCavanaugh
Copy link
Member

I've been reading the comments on #42873 and I'd estimate anywhere from 30% to 80% are legitimate misconfigurations, or at least configurations that we can't plausibly disambiguate from incorrect ones. It's hard to tell.

This looks like a good fix but I'll also write up a FAQ entry on this which covers the basics. What's the most-plausible way someone would legitimately hit this error in a way that is also "fixable" without some restructuring of their package folder layout?

@weswigham
Copy link
Member Author

weswigham commented Apr 12, 2024

This looks like a good fix but I'll also write up a FAQ entry on this which covers the basics. What's the most-plausible way someone would legitimately hit this error in a way that is also "fixable" without some restructuring of their package folder layout?

The test case in this PR is a good example - a workspace (yarn, npm, pnpm, take your pick) with explicit deps between workspace members with everything (sym)linked together from the workspace (some package managers may also link into the global cache first (or make similar structures with symlinks within the cache), but extra layers of link indirection shouldn't affect this), the only thing missing (for it to already work today) is an explicit in-code import from one package to one of its' dependents to make us recognize the valid specifier up-front (and this PR basically opportunistically uses the nearest package.json dependencies list as another source of known-valid nonrelative import specifiers).

@RyanCavanaugh
Copy link
Member

RyanCavanaugh commented Apr 12, 2024

draft faq

Let's say you use a package manager with strict dependencies:

|-/my_package
|-- /node_modules
|---- /other_package <- direct dependency of my_package
|------ index.d.ts
|------ /node_modules
|--------- /sub_dep <- symlink!
|----------- index.d.ts
|-- /src
|---- tsconfig.json  <- project root
|---- foo.ts         <- a source file that imports other_package

Where foo.ts looks like this:

import { getMakeSubDep } from "other_package";

// The inferred type of p refers to a type defined
// inside node_modules/other_package/node_modules/sub_dep/index.d.ts
export const p = getMakeSubDep();

When TypeScript needs to emit foo.d.ts, it needs to write out a type for p:

export const p: ???

What should go in the ??? ?

One option would be to use a relative path:

import { subdep } from "../node_modules/other_package/node_modules/sub_dep";
export const p: subdep

This is obviously wrong: It's implausible that a consumer of foo.d.ts would have a folder layout that matches what we happened to have here.

Another option would be to use a subpath:

import { subdep } from "other_package/node_modules/sub_dep";
export const p: subdep

This is also obviously wrong: The semantics of other_package are not that it exposes node_modules/sub_dep as as sub-path. This probably won't work at runtime, and even if it did, it's not what you want

Another option would be to use a module name:

import { subdep } from "sub_dep";
export const p: subdep

Is this correct?

If other_package has a dependency on sub_dep@2.0.0 and your package has a dependency on sub_dep@3.0.0, then these aren't the same type, and it's wrong.

If you don't have a declared dependency on sub_dep at all, then this code would also fail to load sub_dep when ingested in a downstream project.

These situations - the non-working ones - are the "non-portable identifiers" that TypeScript is complaining about. TS was put into a position where it had to reference the name of a module, but it couldn't find a name for that module that appeared to work. This is why this error occurs.

Now, if you do have a declared dependency on sub_dep, and it resolves to the "same" target as the one other_package did, then this is fine. The way this works (roughly) is that TS keeps a reverse map of the files it has loaded and what module specifiers it used to find them. So if you have import { subdep } from "sub_dep", and it resolved to a correct .d.ts file, then TS will have it in the lookup table and can use sub_dep to refer to the same file that other_package did even though these two module specifiers may have referred to different files (but they didn't).

But! If you never referred to sub_dep in your compilation, then TypeScript has never "seen" sub_dep be referred to from the module resolution context that foo.d.ts will have, so it doesn't know whether or not "sub_dep" is a legal way to refer to other_package/node_modules/sub_dep, and ends up issuing this error.

The correct way to address this is generally to import the type that the .d.ts needs to refer to:

// in foo.ts
import { subdep } from "sub_dep";
import { getMakeSubDep } from "other_package";

// No additional type annotation needed
export const p = getMakeSubDep();

If you can't do this, then TypeScript can't either, and the only real alternative is to use a broader type annotation so that TS doesn't need to refer to types which are impossible to name:

// in foo.ts
import { getMakeSubDep } from "other_package";

// Annotate to anonymous version of whatever subdep is
export const p: { isSubDep: boolean } = getMakeSubDep();

Or restructure the input file in such a way that the referenced type is not exposed in the .d.ts.

@weswigham
Copy link
Member Author

Yep, excellent summary of the current state of affairs. That's basically all the productive bits of the years of discussion in #42873 summed up.

@robpalme
Copy link

For the FAQ, another option to offer up is to extract the type from the original import to avoid adding additional (fragile?) routes to the type.

export const p: ReturnType<getMakeSubDep> = getMakeSubDep();

(Even better would be if this could be auto-inferred during DTS emit to avoid the user needing to explicitly perform the extraction in the TS file.)

@eps1lon
Copy link
Contributor

eps1lon commented Apr 13, 2024

Is there an option to enforce that whatever getMakeSubDep returns, is also exported from other_package? Then TypeScript could reference those exports instead. It should already do that today if you explicitly export the return type. But TypeScript favors import('sub_dep').Options over import('other_package').Options even if other_package explicitly exports Options.

If other_package has a dependency on sub_dep@2.0.0 and your package has a dependency on sub_dep@3.0.0, then these aren't the same type, and it's wrong.

@RyanCavanaugh I don't see how this is wrong. other_package would use sub_dep@2.0.0 at runtime and your package would use sub_dep@3.0.0 at runtime so the types would correctly reflect that.

For the FAQ, another option to offer up is to extract the type from the original import to avoid adding additional (fragile?) routes to the type.

@robpalme ReturnType works in this simple scenario but I'm not aware how to refine it to a specific overload that way. For generics, TypeScript could emit instantiation expressions at least.

@fatcerberus
Copy link

fatcerberus commented Apr 13, 2024

I don't see how this is wrong. other_package would use sub_dep@2.0.0 at runtime and your package would use sub_dep@3.0.0 at runtime so the types would correctly reflect that.

The point of this is that getMakeSubDep() might, at runtime, return a type subdep from 2.0.0 that isn't compatible with the same type subdep as exported from 3.0.0. It would be incorrect for TS to synthesize an import for the return type that refers to the latter.

@eps1lon
Copy link
Contributor

eps1lon commented Apr 13, 2024

Right, I misread the package name.

What about

Is there an option to enforce that whatever getMakeSubDep returns, is also exported from other_package? Then TypeScript could reference those exports instead. It should already do that today if you explicitly export the return type. But TypeScript favors import('sub_dep').Options over import('other_package').Options even if other_package explicitly exports Options.

then?

@RyanCavanaugh
Copy link
Member

Is there an option to enforce that whatever getMakeSubDep returns, is also exported from other_package?

I don't understand the proposal. If the package fully conceals its dependencies, as it should, then you won't have a problem. If it doesn't, you may or may not have a problem depending on how things go. If you turn on this rule, you'll always have a problem. Having more problems doesn't seem like a solution?

@fz6m
Copy link

fz6m commented Apr 14, 2024

I use an automated script as a workaround, help TypeScript find the type for non-flattened dependency directory structures ( like pnpm ), first locate the real position of the indirect dependencies:

  const otherDepPath = path.dirname(require.resolve('other_package/package.json'))
  const subDepPath = path.dirname(
    require.resolve(`sub_dep/package.json`, { paths: [otherDepPath] })
  )

then add /// <reference /> to make the types work:

/// <reference types="${subDepPath}" />

if the /// <reference /> doesn't work, generating it in tsconfig.json as an alias, and the types work as expected:

// tsconfig.json

{
  "compilerOptions": {
    "paths": {
      "sub_dep": ["/Users/path/to/pkg/dir"] // `${subDepPath}`
    }
  }
}

Copy link
Member

@andrewbranch andrewbranch left a comment

Choose a reason for hiding this comment

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

This currently only works for the entrypoint resolved by the bare package name itself—how much work would it take to make it work for subpath exports?

src/compiler/moduleSpecifiers.ts Outdated Show resolved Hide resolved
@weswigham
Copy link
Member Author

how much work would it take to make it work for subpath exports?

Hm, maybe not too bad? If we use a not-direct-match result that has the same affecting package.json as the target file (which should be cached, so cheapish to check - though equality may necessitate some realpath or psuedo-realpathing), we can probably use that to hint to the existing lookup-from-known-packages logic to use that package. Maybe I can get away with just adding another entry into the symlink cache when the realpaths we do here discover one? Basically inject the newly found symlink into the symlink cache, and restart back from forEachFileNameOfModule? I'll have to look into it. It should be doable, at least, so long as we're interested.

@typescript-bot
Copy link
Collaborator

Looks like you're introducing a change to the public API surface area. If this includes breaking changes, please document them on our wiki's API Breaking Changes page.

Also, please make sure @DanielRosenwasser and @RyanCavanaugh are aware of the changes, just as a heads up.

@weswigham
Copy link
Member Author

@typescript-bot test it

@andrewbranch slightly new approach here - now, rather than looking for a package.json and its' dependencies on-demand, I do it up-front when resolution is first requested within a file. 99% of the time, this is going to just find cached and/or pre-create cached resolutions we were going to use anyway (since who doesn't reference their deps), but that remaining 1% is what fixes this issue and allows us to discover the symlink for the unused dependency, where it goes right into the resolution cache (which we now directly crawl to build the symlink cache, rather than only indirectly via the resolved modules).

@typescript-bot
Copy link
Collaborator

typescript-bot commented Apr 17, 2024

Starting jobs; this comment will be updated as builds start and complete.

Command Status Results
test top400 ✅ Started ✅ Results
user test this ✅ Started ✅ Results
run dt ✅ Started ✅ Results
perf test this faster ✅ Started 👀 Results

@typescript-bot
Copy link
Collaborator

Hey @weswigham, the results of running the DT tests are ready.

Everything looks the same!

You can check the log here.

@weswigham
Copy link
Member Author

@typescript-bot test it

@typescript-bot
Copy link
Collaborator

typescript-bot commented Apr 18, 2024

Starting jobs; this comment will be updated as builds start and complete.

Command Status Results
test top400 ✅ Started ✅ Results
user test this ✅ Started ✅ Results
run dt ✅ Started ✅ Results
perf test this faster ✅ Started 👀 Results

@typescript-bot
Copy link
Collaborator

Hey @weswigham, the results of running the DT tests are ready.

Everything looks the same!

You can check the log here.

@typescript-bot
Copy link
Collaborator

@weswigham
The results of the perf run you requested are in!

Here they are:

tsc

Comparison Report - baseline..pr
Metric baseline pr Delta Best Worst p-value
Angular - node (v18.15.0, x64)
Memory used 296,993k (± 0.00%) 297,001k (± 0.00%) ~ 296,981k 297,013k p=0.230 n=6
Parse Time 2.69s (± 0.59%) 2.69s (± 0.51%) ~ 2.67s 2.70s p=0.564 n=6
Bind Time 0.82s (± 0.00%) 0.82s (± 0.50%) ~ 0.82s 0.83s p=0.405 n=6
Check Time 8.33s (± 0.42%) 8.33s (± 0.15%) ~ 8.32s 8.35s p=0.932 n=6
Emit Time 7.07s (± 0.42%) 7.06s (± 0.31%) ~ 7.04s 7.09s p=0.935 n=6
Total Time 18.91s (± 0.16%) 18.89s (± 0.19%) ~ 18.85s 18.96s p=0.569 n=6
Compiler-Unions - node (v18.15.0, x64)
Memory used 192,404k (± 0.74%) 192,414k (± 0.76%) ~ 191,807k 195,392k p=0.689 n=6
Parse Time 1.35s (± 0.94%) 1.34s (± 2.58%) ~ 1.29s 1.39s p=0.513 n=6
Bind Time 0.72s (± 0.00%) 0.72s (± 0.00%) ~ 0.72s 0.72s p=1.000 n=6
Check Time 9.55s (± 0.26%) 9.57s (± 0.56%) ~ 9.49s 9.65s p=0.520 n=6
Emit Time 2.62s (± 0.29%) 2.63s (± 0.56%) ~ 2.61s 2.65s p=0.241 n=6
Total Time 14.24s (± 0.18%) 14.25s (± 0.38%) ~ 14.19s 14.34s p=0.806 n=6
mui-docs - node (v18.15.0, x64)
Memory used 1,750,942k (± 0.00%) 1,750,923k (± 0.00%) ~ 1,750,846k 1,750,983k p=0.378 n=6
Parse Time 9.92s (± 0.49%) 9.93s (± 0.43%) ~ 9.86s 9.98s p=0.683 n=6
Bind Time 3.33s (± 0.45%) 3.35s (± 0.72%) ~ 3.32s 3.39s p=0.413 n=6
Check Time 81.97s (± 0.48%) 82.14s (± 0.29%) ~ 81.79s 82.50s p=0.378 n=6
Emit Time 0.20s (± 2.02%) 0.20s (± 4.01%) ~ 0.20s 0.22s p=1.000 n=6
Total Time 95.43s (± 0.44%) 95.62s (± 0.24%) ~ 95.26s 95.92s p=0.471 n=6
self-build-src - node (v18.15.0, x64)
Memory used 2,307,429k (± 0.03%) 2,308,676k (± 0.02%) +1,246k (+ 0.05%) 2,308,302k 2,309,563k p=0.005 n=6
Parse Time 5.03s (± 0.83%) 5.00s (± 1.34%) ~ 4.91s 5.10s p=0.471 n=6
Bind Time 1.86s (± 1.54%) 1.86s (± 1.42%) ~ 1.83s 1.90s p=0.872 n=6
Check Time 33.84s (± 0.32%) 33.76s (± 0.50%) ~ 33.51s 33.97s p=0.297 n=6
Emit Time 2.66s (± 1.31%) 2.67s (± 2.39%) ~ 2.60s 2.78s p=0.688 n=6
Total Time 43.42s (± 0.27%) 43.32s (± 0.44%) ~ 43.00s 43.54s p=0.261 n=6
self-build-src-public-api - node (v18.15.0, x64)
Memory used 2,381,488k (± 0.03%) 2,382,902k (± 0.05%) ~ 2,381,855k 2,385,007k p=0.066 n=6
Parse Time 6.19s (± 0.57%) 6.18s (± 1.10%) ~ 6.10s 6.26s p=0.747 n=6
Bind Time 2.04s (± 1.34%) 2.11s (± 4.49%) ~ 2.04s 2.24s p=0.126 n=6
Check Time 40.17s (± 0.24%) 40.07s (± 0.61%) ~ 39.74s 40.43s p=0.575 n=6
Emit Time 3.14s (± 2.06%) 3.10s (± 2.46%) ~ 3.01s 3.21s p=0.471 n=6
Total Time 51.56s (± 0.23%) 51.48s (± 0.37%) ~ 51.21s 51.69s p=0.689 n=6
self-compiler - node (v18.15.0, x64)
Memory used 418,972k (± 0.01%) 419,179k (± 0.01%) +207k (+ 0.05%) 419,155k 419,224k p=0.005 n=6
Parse Time 2.80s (± 0.52%) 2.78s (± 1.67%) ~ 2.69s 2.82s p=0.463 n=6
Bind Time 1.10s (± 1.37%) 1.11s (± 4.65%) ~ 1.07s 1.21s p=0.744 n=6
Check Time 15.27s (± 0.26%) 15.29s (± 0.35%) ~ 15.20s 15.34s p=0.290 n=6
Emit Time 1.15s (± 1.28%) 1.15s (± 2.13%) ~ 1.13s 1.19s p=0.744 n=6
Total Time 20.32s (± 0.26%) 20.32s (± 0.28%) ~ 20.23s 20.38s p=0.809 n=6
ts-pre-modules - node (v18.15.0, x64)
Memory used 368,961k (± 0.01%) 368,993k (± 0.01%) ~ 368,968k 369,084k p=0.092 n=6
Parse Time 3.65s (± 0.64%) 3.66s (± 0.81%) ~ 3.61s 3.70s p=0.332 n=6
Bind Time 1.91s (± 1.13%) 1.92s (± 1.72%) ~ 1.88s 1.96s p=0.744 n=6
Check Time 19.35s (± 0.42%) 19.37s (± 0.31%) ~ 19.29s 19.47s p=0.872 n=6
Emit Time 0.00s (± 0.00%) 0.00s (± 0.00%) ~ 0.00s 0.00s p=1.000 n=6
Total Time 24.91s (± 0.39%) 24.95s (± 0.24%) ~ 24.90s 25.05s p=0.688 n=6
vscode - node (v18.15.0, x64)
Memory used 2,917,849k (± 0.01%) 2,917,822k (± 0.01%) ~ 2,917,260k 2,918,030k p=0.810 n=6
Parse Time 13.45s (± 0.19%) 13.40s (± 0.31%) ~ 13.35s 13.45s p=0.065 n=6
Bind Time 4.08s (± 0.20%) 4.14s (± 2.35%) ~ 4.07s 4.28s p=0.135 n=6
Check Time 72.56s (± 0.37%) 72.92s (± 0.33%) +0.36s (+ 0.49%) 72.68s 73.28s p=0.045 n=6
Emit Time 20.32s (± 5.76%) 19.86s (± 3.85%) ~ 19.42s 21.41s p=0.423 n=6
Total Time 110.39s (± 1.01%) 110.31s (± 0.62%) ~ 109.82s 111.63s p=0.689 n=6
webpack - node (v18.15.0, x64)
Memory used 409,451k (± 0.02%) 409,481k (± 0.02%) ~ 409,426k 409,563k p=0.378 n=6
Parse Time 3.94s (± 0.65%) 3.94s (± 0.93%) ~ 3.87s 3.97s p=0.934 n=6
Bind Time 1.65s (± 0.49%) 1.65s (± 1.06%) ~ 1.62s 1.67s p=0.605 n=6
Check Time 16.98s (± 0.40%) 16.96s (± 0.22%) ~ 16.92s 17.01s p=0.688 n=6
Emit Time 0.00s (± 0.00%) 0.00s (± 0.00%) ~ 0.00s 0.00s p=1.000 n=6
Total Time 22.57s (± 0.19%) 22.55s (± 0.26%) ~ 22.45s 22.61s p=0.688 n=6
xstate-main - node (v18.15.0, x64)
Memory used 458,937k (± 0.01%) 458,977k (± 0.02%) ~ 458,888k 459,112k p=0.521 n=6
Parse Time 3.24s (± 0.73%) 3.24s (± 0.45%) ~ 3.22s 3.26s p=1.000 n=6
Bind Time 1.17s (± 0.71%) 1.18s (± 0.44%) ~ 1.17s 1.18s p=0.533 n=6
Check Time 18.11s (± 0.27%) 18.15s (± 0.11%) ~ 18.12s 18.17s p=0.126 n=6
Emit Time 0.00s (± 0.00%) 0.00s (± 0.00%) ~ 0.00s 0.00s p=1.000 n=6
Total Time 22.52s (± 0.25%) 22.56s (± 0.08%) ~ 22.54s 22.58s p=0.228 n=6
System info unknown
Hosts
  • node (v18.15.0, x64)
Scenarios
  • Angular - node (v18.15.0, x64)
  • Compiler-Unions - node (v18.15.0, x64)
  • mui-docs - node (v18.15.0, x64)
  • self-build-src - node (v18.15.0, x64)
  • self-build-src-public-api - node (v18.15.0, x64)
  • self-compiler - node (v18.15.0, x64)
  • ts-pre-modules - node (v18.15.0, x64)
  • vscode - node (v18.15.0, x64)
  • webpack - node (v18.15.0, x64)
  • xstate-main - node (v18.15.0, x64)
Benchmark Name Iterations
Current pr 6
Baseline baseline 6

Developer Information:

Download Benchmarks

@typescript-bot
Copy link
Collaborator

@weswigham Here are the results of running the user tests comparing main and refs/pull/58176/merge:

Everything looks good!

@weswigham
Copy link
Member Author

@sheetalkamat extended tests are still good, and I think I've addressed all your concerns.

@typescript-bot
Copy link
Collaborator

@weswigham Here are the results of running the top 400 repos comparing main and refs/pull/58176/merge:

Everything looks good!

for (const field of runtimeDependencyFields) {
const deps = packageJson[field];
if (deps && typeof deps === "object") {
result = concatenate(result, getOwnKeys(deps));
Copy link
Member

Choose a reason for hiding this comment

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

You can probably just use append here, given getOwnKeys is going to give a fresh copy anyway.

Copy link
Member

Choose a reason for hiding this comment

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

Eh, wrong argument type. Nevermind.

Copy link
Member Author

Choose a reason for hiding this comment

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

You'd want addRange, not append, since append doesn't handle an array for input or take a spread list of arguments. But even that's not perfect - concatenate unconditionally allocates a new output array if both inputs are present (which is actually going to be rare here, usually it'll just be dependencies) - meanwhile addRange unconditionally copies from if to is missing (so it guarantees returning either to to a new array). That means it'd copy the first array needlessly every time. We actually don't have a "use either array mutably if they're present, it's fine"-type array-merging helper.

Honestly, it probably doesn't matter, though. Resizing one temporary array to merge in another one is probably going to trigger the similar allocations in the VM that making a new array would anyway (if they're both reasonably large). It's all short-lived objects.

@weswigham weswigham merged commit 4887016 into microsoft:main Apr 19, 2024
25 checks passed
@weswigham weswigham deleted the check-package-json-for-possible-package-names branch April 19, 2024 00:43
@andrewbranch andrewbranch mentioned this pull request Jun 13, 2024
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
Author: Team For Uncommitted Bug PR for untriaged, rejected, closed or missing bug
Projects
None yet
Development

Successfully merging this pull request may close these issues.