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

[api-extractor] Fix incorrect declaration references for symbols not exported from the package's entry point #3584

Merged
merged 4 commits into from
Sep 2, 2022

Conversation

zelliott
Copy link
Contributor

@zelliott zelliott commented Aug 12, 2022

Summary

This is a bug fix found while continuing to work on #3552. Consider the following situation:

// internal.ts

export class A {}

// index.ts

import { A } from './internal';

/** @public */
export class B extends A {}

In the code above, there are two files, internal.ts and index.ts. index.ts is the main entry point of the package, internal.ts is a local file within the package.

Before this PR, api-extractor's DeclarationReferenceGenerator constructs the following reference for A: my-package!A:class. This reference is incorrect, it should be my-package!~A:class. A is not exported from the entry point, and so it should use a Locals navigation step.

This PR fixes this bug, and in general cleans up the DeclarationReferenceGenerator._getNavigationToSymbol method by removing a ton of code paths that as far as I can tell are never hit.

Details

  • Before this PR, DeclarationReferenceGenerator had dependencies on different parts of the Collector. Now, it has a dependency on the entire Collector itself (because it needs to call a method on the Collector).
  • As mentioned above, this PR removes a ton of logic from the _getNavigationToSymbol method because I could not find scenarios in which they were ever hit. It would be great to run this change against some large actual monorepos before merging it to make sure it doesn't break things.

How it was tested

Test cases existed in api-extractor-scenarios, added one final one, ran rush rebuild, and inspected the .api.json files.

// If its parent symbol is not a source file, then use an Exports navigation. If the parent symbol is
// a source file, but it wasn't exported from the package entry point (in the check above), then the symbol
// is a local, so fall through below.
if (!DeclarationReferenceGenerator._isExternalModuleSymbol(parent)) {
return Navigation.Exports;
}
Copy link
Contributor Author

@zelliott zelliott Aug 12, 2022

Choose a reason for hiding this comment

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

In my testing, I could not find a scenario in which any line from 112 to 157 was hit. I could also not find a situation where Members navigation was ever returned. I'm worried I may be missing an entire class of cases here... the original logic was added in the mega-PR #1337.

Maybe this logic was here because previously this method was used to generate the canonical references for API items in the .api.json, but now these references are generated from the .api.json structure itself.

Copy link
Collaborator

Choose a reason for hiding this comment

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

@rbuckton any idea what kind of type declaration input would cover these these code paths?

Copy link
Contributor Author

@zelliott zelliott Aug 25, 2022

Choose a reason for hiding this comment

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

@octogonz shared some instances where Members navigation was returned, and I'm covering those now. But I still can not find a scenario where any line from 118-157 was hit.

@@ -99,68 +87,48 @@ export class DeclarationReferenceGenerator {
);
}

private static _getNavigationToSymbol(symbol: ts.Symbol): Navigation | 'global' {
private _getNavigationToSymbol(symbol: ts.Symbol): Navigation {
Copy link
Contributor Author

Choose a reason for hiding this comment

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

Here's an interesting canonical reference question: Consider the following example:

// index.ts (entry point of "my package")
import * as n from './internal';
export function someFunction(): n.SomeType { return 5; }

// internal.ts
export type SomeType = number;

What should the canonical reference of SomeType be? Today...

  • DeclarationReferenceGenerator produces the following: my-package!~SomeType:type (after this PR)
  • whereas api-extractor-model produces the following: my-package!~n.SomeType

api-extractor-model produces the latter because the "synthetic" AstNamespaceImport for n makes its way into the .api.json.

I feel like the correct canonical reference in this case is my-package!~SomeType:type, because the namespace n is really an implementation detail of how index.ts imports from internal.ts, and not part of SomeType itself. There could be the following code in internal.ts:

export function someOtherFunction(): SomeType { return 6; }

and it would be odd for someOtherFunction's reference token for SomeType to include the synthetic namespace n. I'd expect for it to be my-package!~SomeType:type.

Regardless, I don't think we should solve this in this PR, just something to think about.

Copy link
Collaborator

Choose a reason for hiding this comment

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

Hmm.... A central principle of API Extractor is that it interprets declarations, imposing a semantics that is not technically present in the source code. This is what justifies rolling up declarations into a different file from where they were declared, discarding some .d.ts files entirely (because they aren't reachable from the entry point), and renaming API items to avoid naming conflicts.

Your question is really about what semantics should be applied to an unexported declaration.

  1. I'm a little sketchy on the grammar, but I think the physical location would be described as my-package/lib/internal!~SomeType:type.

  2. But generally API Extractor's model is that of the .d.ts rollup. Physically in the .d.ts rollup, the location would be my-package!~SomeType:type.

  3. However if there are naming collisions, this could become something arbitrary like my-package!~SomeType_3:type.

  4. Logically, the human intent of import * as n is to move this declaration inside n which would better be described as my-package!n~SomeType:type. The reason it does not get placed there was technical: if they also did import * as n2 then its ambiguous which one is the "real" definition and which one is the alias. With more sophisticated rollup logic, we could solve this problem in the future, and then my-package!n~SomeType:type maybe would be correct.

I guess the choice boils down to requirements:

Is our priority that the canonical reference should help a tool to find the declaration in the .d.ts files? If so then it should probably be a physical location in the .d.ts rollup. If the .d.ts rollup feature is disabled, then maybe all canonical references should actually be physical locations such as (1).

Alternatively, is our priority that the canonical reference should be a stable identifier, i.e. resistant to getting shuffled around by trivial changes? I think this is the design choice that API Extractor has pursued. In this case I guess we would go with (2) or (3).

@@ -54,13 +54,7 @@ export class ApiModelGenerator {
public constructor(collector: Collector) {
Copy link
Collaborator

Choose a reason for hiding this comment

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

I ran your build of API Extractor on some monorepo projects and compared the resulting .api.json files with the baseline output. Here's some differences:

Copy link
Collaborator

Choose a reason for hiding this comment

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

(I've deleted some replies, since I was comparing in the wrong direction 😆 )

Copy link
Collaborator

Choose a reason for hiding this comment

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

1. Interesting case with inline type destructuring

This is NOT good API practices. 😄 But it is an interesting edge case:

forgotten-exports.d.ts

export declare type HeadersInitializer = Record<string, string>;
export interface IOptions {
    headers?: HeadersInitializer;
}

index.d.ts

import { IOptions } from './forgotten-exports';

export declare const example1: ({ headers: addHeaders }: IOptions) => Promise<void>;

Before this PR, the excerpt for example1 includes:
"canonicalReference": "the-package!IOptions#headers"

After this PR:
"canonicalReference": "the-package!IOptions.headers"

Copy link
Collaborator

Choose a reason for hiding this comment

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

2. Case involving "typeof"

// Resolves to [email protected]\node_modules\webpack\types.d.ts
import webpack from 'webpack';

export declare type Example2 = typeof webpack.prototype.inputFileSystem;

Before this PR:

          "excerptTokens": [
            {
              "kind": "Content",
              "text": "export declare type FileSystem = "
            },
            {
              "kind": "Content",
              "text": "typeof "
            },
            {
              "kind": "Reference",
              "text": "webpack.prototype",
              "canonicalReference": "!Function#prototype:member"
            },
            {
              "kind": "Content",
              "text": ".inputFileSystem"
            },
            {
              "kind": "Content",
              "text": ";"
            }
          ],

After this PR:

"canonicalReference": "!Function.prototype:member"

I'm not sure either string is really correct. 😋

Copy link
Contributor Author

Choose a reason for hiding this comment

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

These examples are really useful! I couldn't find any examples of code generating Navigation.Members navigation steps. I'll take a look at these. 👍

Copy link
Collaborator

Choose a reason for hiding this comment

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

3. Case involving preact

This is not a bug really, more of a curiosity:

import { Context } from 'preact';

export declare const Example3: Context<{}>;

Before this PR:

            {
              "kind": "Reference",
              "text": "Context",
              "canonicalReference": "preact!~preact.Context:interface"
            },

After this PR:

            {
              "kind": "Reference",
              "text": "Context",
              "canonicalReference": "preact!preact.Context:interface"
            },

It is an improvement, but I think this canonical reference maybe should not have the extra preact part?

The Context is declared like this:

[email protected]\node_modules\preact\src\index.d.ts

export = preact;
export as namespace preact;

. . .

declare namespace preact {
	. . .
	interface Context<T> {
		Consumer: Consumer<T>;
		Provider: Provider<T>;
		displayName?: string;
	}
}

So it is a member of namespace preact but the intended way of importing is really import { Context } from 'preact';. 🤷‍♂️

Copy link
Collaborator

@octogonz octogonz Aug 24, 2022

Choose a reason for hiding this comment

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

Across >30 libraries, these were the only weird cases. There were a number of canonical references that are fixed by your PR. Otherwise I didn't see any obvious regressions. 👍

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Okay. I think cases 1 and 2 should now have the same behavior as before this PR. Previously, I didn't think it was possible for a members navigation step to be present in an identifier passed to this function, but the cases you shared demonstrated it is possible.

I didn't tackle case 3 because this PR is an improvement, and it seemed like potentially a larger fix. But I agree that the currently behavior isn't necessarily ideal.

# Conflicts:
#	build-tests/install-test-workspace/workspace/common/pnpm-lock.yaml
@octogonz octogonz enabled auto-merge September 2, 2022 07:03
@octogonz octogonz disabled auto-merge September 2, 2022 07:12
@octogonz octogonz merged commit 4de2adf into microsoft:main Sep 2, 2022
@octogonz
Copy link
Collaborator

octogonz commented Sep 2, 2022

🚀 This feature was released with API Extractor 7.30.0

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

Successfully merging this pull request may close these issues.

2 participants