Skip to content

Commit 9adcd15

Browse files
committed
Reimplement publish-types script with recast
This is far more robust against all the kinds of things our modules do: - It supports having `declare module` and similar in the input source. - It does not do dumb string replacement to remove `declare`, which can easily remove things from comments or the like. - It correctly handles relative imports (`./` and chains of `../`). For this to work correctly, we needed to have more recent versions of the Babel parser, so this freshens the lock file for all `@babel/*` dependencies. It also adds `recast` and drops `magic-string`. This has been tested by enabling more than 50% of the unpublished modules; the resulting output is correct from the point of view of this script but exposes a number of fixes we will need to make for our types as we enable more and more of those.
1 parent 301b76f commit 9adcd15

File tree

4 files changed

+821
-1124
lines changed

4 files changed

+821
-1124
lines changed

package.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -105,6 +105,7 @@
105105
"@types/rsvp": "^4.0.4",
106106
"@typescript-eslint/eslint-plugin": "^5.38.0",
107107
"@typescript-eslint/parser": "^5.38.1",
108+
"ast-types": "^0.14.2",
108109
"auto-dist-tag": "^2.1.1",
109110
"aws-sdk": "^2.1245.0",
110111
"babel-template": "^6.26.0",
@@ -144,13 +145,13 @@
144145
"glob": "^8.0.3",
145146
"html-differ": "^1.4.0",
146147
"lodash.uniq": "^4.5.0",
147-
"magic-string": "^0.26.7",
148148
"mkdirp": "^1.0.4",
149149
"mocha": "^10.1.0",
150150
"npm-run-all": "^4.1.5",
151151
"prettier": "^2.7.1",
152152
"puppeteer": "^13.5.1",
153153
"qunit": "^2.19.1",
154+
"recast": "^0.21.5",
154155
"rollup-plugin-commonjs": "^9.3.4",
155156
"rollup-plugin-node-resolve": "^4.2.4",
156157
"route-recognizer": "^0.3.4",

packages/@ember/-internals/owner/index.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -82,8 +82,8 @@ export interface DIRegistry extends Record<string, Record<string, unknown>> {}
8282
type ResolveFactoryManager<
8383
Type extends string,
8484
Name extends string
85-
> = DIRegistry[Type][Name] extends object
86-
? FactoryManager<DIRegistry[Type][Name]>
85+
> = DIRegistry[Type][Name] extends infer RegistryEntry extends object
86+
? FactoryManager<RegistryEntry>
8787
: FactoryManager<object> | undefined;
8888

8989
/**

types/publish.mjs

Lines changed: 220 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -5,23 +5,27 @@
55
This script is used to publish Ember's type definitions. The basic workflow
66
is:
77
8-
1. Run `tsc` against the Ember packages which make up its public API, with
9-
the output being `/types/stable`.
8+
1. Run `tsc` against the Ember packages which make up its public API, with the
9+
output being `/types/stable`.
1010
11-
2. Wrap each emitted module in a `declare module` statement. While doing so,
12-
keep track of the full list of emitted modules.
11+
2. Wrap each emitted module in a `declare module` statement. This requires
12+
replacing all relative imports with absolute imports and removing all
13+
`declare` statements from the body of the module.
1314
14-
3. Check that each module emitted is included in `types/stable/index.d.ts`,
15-
if and only if it also appears in a list of stable types modules defined
16-
in this script, so that they all "show up" to end users. That list will
15+
While doing so, keep track of the full list of emitted modules for the sake
16+
of step (3).
17+
18+
3. Check that each module emitted is included in `types/stable/index.d.ts`, if
19+
and only if it also appears in a list of stable types modules defined in
20+
this script, so that they all "show up" to end users. That list will
1721
eventually be the list of *all* modules, but this allows us to publish
1822
iteratively as we gain confidence in the stability of the types.
1923
2024
This is *not* an optimal long-term publishing strategy. We would prefer to
2125
generate per-package roll-ups, using a Rollup plugin or some such, but we are
22-
currently blocked on a number of internal circular dependencies as well as
23-
the difficulty of avoiding multiple definitions of the same types reused
24-
across many rollups.
26+
currently blocked on a number of internal circular dependencies as well as the
27+
difficulty of avoiding multiple definitions of the same types reused across
28+
many rollups.
2529
2630
@packageDocumentation
2731
*/
@@ -30,7 +34,15 @@ import glob from 'glob';
3034
import { spawnSync } from 'node:child_process';
3135
import fs from 'node:fs';
3236
import path from 'node:path';
33-
import MagicString from 'magic-string';
37+
import * as parser from 'recast/parsers/babel-ts.js';
38+
import {
39+
isClassDeclaration,
40+
isStringLiteral,
41+
isVariableDeclaration,
42+
isTSDeclareFunction,
43+
} from '@babel/types';
44+
import { builders as b, visit } from 'ast-types';
45+
import { parse, print } from 'recast';
3446

3547
/**
3648
Modules we know we are not ready to expose yet, mostly because they do not
@@ -404,7 +416,7 @@ async function main() {
404416

405417
let status = 'success';
406418
for (let moduleName of moduleNames) {
407-
let result = wrapInDeclareModule(moduleName);
419+
let result = processModule(moduleName);
408420
if (result !== 'success') {
409421
status = result;
410422
}
@@ -416,8 +428,16 @@ async function main() {
416428
// We need to import "package root" types as such, *not* via the actual
417429
// module which provides them, or TS does not see them correctly via the
418430
// side effect imports, so transform them accordingly:
419-
// `@ember/owner/index.d.ts` -> `@ember/owner`
420-
let moduleOrPackagePath = moduleName.replace(/\/index.d.ts$/, '');
431+
//
432+
// `@ember/owner/index.d.ts` -> `@ember/owner`
433+
//
434+
// We also need to replace `.d.ts` entirely:
435+
//
436+
// `@ember/utils/lib/compare.d.ts` -> `@ember/utils/lib/compare`
437+
//
438+
// Otherwise, the modules won't be resolved correctly via the side-effect
439+
// imports.
440+
let moduleOrPackagePath = moduleName.replace(/\/index.d.ts$/, '').replace('.d.ts', '');
421441

422442
// Then create a relative path *to* the path on disk so that the
423443
// side-effect import is e.g. `import './@ember/owner';`, which makes it
@@ -429,14 +449,19 @@ async function main() {
429449
let stableIndexDTsContents = BASE_INDEX_D_TS.replace(MODULES_PLACEHOLDER, sideEffectModules);
430450
fs.writeFileSync(path.join(TYPES_DIR, 'index.d.ts'), stableIndexDTsContents);
431451

452+
// Make the generated types easier to read!
453+
spawnSync('prettier', ['--write', 'types/stable/**/*.ts']);
454+
432455
process.exit(status === 'success' ? 0 : 1);
433456
}
434457

435458
/**
436-
* @param {string} moduleName
437-
* @return {'success' | 'failure'}
459+
Load the module, rewrite it, and write it back to disk.
460+
461+
@param {string} moduleName
462+
@return {'success' | 'failure'}
438463
*/
439-
function wrapInDeclareModule(moduleName) {
464+
function processModule(moduleName) {
440465
let modulePath = path.join(TYPES_DIR, moduleName);
441466

442467
/** @type {string} */
@@ -450,19 +475,16 @@ function wrapInDeclareModule(moduleName) {
450475

451476
let moduleNameForDeclaration = moduleName.replace('/index.d.ts', '');
452477

453-
// This is a horrible nightmare of a hack; in a later PR I'm going to just
454-
// replace all of this by going ahead and using recast or such. As annoying as
455-
// that will be, it will be *way* more reliable.
456-
let string = new MagicString(contents);
457-
string
458-
.replace(/^export declare /gm, 'export ') // g for global, m for multiline
459-
.replace(/^declare /gm, '') // g for global, m for multiline
460-
.indent(' ')
461-
.prepend(`declare module '${moduleNameForDeclaration}' {\n`)
462-
.append('}\n');
478+
let rewrittenModule;
479+
try {
480+
rewrittenModule = rewriteModule(contents, moduleNameForDeclaration);
481+
} catch (e) {
482+
console.error(`Error rewriting ${moduleName}`, e);
483+
return 'failure';
484+
}
463485

464486
try {
465-
fs.writeFileSync(modulePath, string.toString());
487+
fs.writeFileSync(modulePath, rewrittenModule);
466488
} catch (e) {
467489
console.error(`Error writing ${modulePath}: ${e}`);
468490
return 'failure';
@@ -471,5 +493,175 @@ function wrapInDeclareModule(moduleName) {
471493
return 'success';
472494
}
473495

496+
/**
497+
Rewrite a given module declaration:
498+
499+
- Tranform the main body of the module into a new top-level `declare module`
500+
statement.
501+
- Remove all `declare` modifiers from items in the module itself.
502+
- Update all `import` specifiers to be absolute in terms of the package
503+
name (which means )
504+
- Preserve existing `declare module` statements, so that anything using e.g.
505+
declaration merging continues to work correctly.
506+
507+
@param {string} code The initial code to rewrite.
508+
@param {string} moduleName The name of the module to use.
509+
@returns {string}
510+
*/
511+
export function rewriteModule(code, moduleName) {
512+
let ast = parse(code, { parser });
513+
514+
/** @type {Array<import("ast-types/gen/namedTypes").namedTypes.TSModuleDeclaration>} */
515+
let otherModuleDeclarations = [];
516+
517+
visit(ast, {
518+
// We need to preserve existing `declare module { ... }` blocks so that
519+
// things which rely on declaration merging can work, but they need to be
520+
// emitted *outside* the `declare module` we are introducing.
521+
visitTSModuleDeclaration(path) {
522+
otherModuleDeclarations.push(path.node);
523+
path.prune(path.node);
524+
this.traverse(path);
525+
},
526+
527+
// Remove `declare` from `declare (let|const|var)` in the top-level module.
528+
visitVariableDeclaration(path) {
529+
if (isVariableDeclaration(path.node) && !hasParentModuleDeclarationBlock(path)) {
530+
path.node.declare = false;
531+
}
532+
this.traverse(path);
533+
},
534+
535+
// Remove `declare` from `declare class` in the top-level module.
536+
visitClassDeclaration(path) {
537+
if (isClassDeclaration(path.node) && !hasParentModuleDeclarationBlock(path)) {
538+
path.node.declare = false;
539+
}
540+
this.traverse(path);
541+
},
542+
543+
// Remove `declare` from `declare function` in the top-level module.
544+
visitTSDeclareFunction(path) {
545+
if (isTSDeclareFunction(path.node) && !hasParentModuleDeclarationBlock(path)) {
546+
path.node.declare = false;
547+
}
548+
this.traverse(path);
549+
},
550+
551+
// For any relative imports like `import { something } from './somewhere';`,
552+
// rewrite as `import { something } from '@ember/some-package/somewhere';`
553+
// since relative imports are not allowed in `declare module { }` blocks.
554+
visitImportDeclaration(path) {
555+
let source = path.node.source;
556+
if (isStringLiteral(source)) {
557+
source.value = normalizeSpecifier(moduleName, source.value);
558+
}
559+
this.traverse(path);
560+
},
561+
562+
// Do the same for `export ... from './relative-path'`.
563+
visitExportNamedDeclaration(path) {
564+
let source = path.node.source;
565+
if (isStringLiteral(source)) {
566+
if (source.value.startsWith('./') || source.value.startsWith('../')) {
567+
source.value = normalizeSpecifier(moduleName, source.value);
568+
}
569+
}
570+
this.traverse(path);
571+
},
572+
});
573+
574+
let newAST = b.file(
575+
b.program([
576+
b.declareModule(
577+
b.identifier(`'${moduleName.replace('.d.ts', '')}'`),
578+
b.blockStatement(ast.program.body)
579+
),
580+
...otherModuleDeclarations,
581+
])
582+
);
583+
584+
return print(newAST).code;
585+
}
586+
587+
/**
588+
Is this declaration in a `declare module { }` block?
589+
590+
@param {import('ast-types/lib/node-path').NodePath} path
591+
@return boolean
592+
*/
593+
function hasParentModuleDeclarationBlock(path) {
594+
/** @type {import('ast-types/lib/node-path').NodePath} */
595+
let parentPath = path;
596+
while ((parentPath = parentPath.parent)) {
597+
if (parentPath.node.type === 'ModuleDeclaration') {
598+
return true;
599+
}
600+
}
601+
602+
return false;
603+
}
604+
605+
const TERMINAL_MODULE_RE = /\/[\w-_]+\.d\.ts$/;
606+
const NEIGHBOR_PATH_RE = /^(\.)\//;
607+
608+
/**
609+
Given a relative path, `./` or `(../)+`, rewrite it as an absolute path.
610+
611+
@param {string} moduleName The name of the host module we are declaring.
612+
@param {string} specifier The name of the module it is importing.
613+
@return {string}
614+
*/
615+
function normalizeSpecifier(moduleName, specifier) {
616+
if (specifier.startsWith('./')) {
617+
let parentModuleName = moduleName.replace(TERMINAL_MODULE_RE, '');
618+
let sansLeadingDot = specifier.replace(NEIGHBOR_PATH_RE, '');
619+
let newImportName = `${parentModuleName}/${sansLeadingDot}`;
620+
return newImportName;
621+
} else if (specifier.startsWith('../')) {
622+
// Reverse it so we can just `pop` from `parentPathChunks` as we go: walking
623+
// backward through the specifier means as soon as we hit the `..` we can
624+
// start using the chunks from the end of the hosting module.
625+
let reversedSpecifierChunks = specifier.split('/').reverse();
626+
let parentPathChunks = moduleName.split('/');
627+
628+
// To make that logic work, though, we need to drop the last item from the
629+
// chunks comprising host module, because we need to *not* treat the current
630+
// module itself as a parent. If we're not in a "root" module, we need to
631+
// do it an extra time to get rid of the terminal `foo.d.ts` as well.
632+
let terminal = parentPathChunks.pop();
633+
if (terminal?.endsWith('.d.ts')) {
634+
parentPathChunks.pop();
635+
}
636+
637+
// Walk back from the end of the specifier, replacing `..` with chunks from
638+
// the parent paths.
639+
/** @type {string[]} */
640+
let merged = [];
641+
for (let chunk of reversedSpecifierChunks) {
642+
if (chunk === '..') {
643+
let parent = parentPathChunks.pop();
644+
if (!parent) {
645+
throw new Error(
646+
`Could not generate a valid path for relative path specifier ${specifier} in ${moduleName}`
647+
);
648+
}
649+
merged.push(parent);
650+
} else {
651+
merged.push(chunk);
652+
}
653+
}
654+
655+
// Reverse them again so we have the correct ordering.
656+
merged.reverse();
657+
// Then incorporate the rest of the parent path chunks.
658+
merged.unshift(...parentPathChunks);
659+
660+
return merged.join('/');
661+
} else {
662+
return specifier;
663+
}
664+
}
665+
474666
// Run it!
475667
main();

0 commit comments

Comments
 (0)