@goodbones/typescript 0.1.0-beta.1 → 0.1.0-beta.11

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/extractor.ts CHANGED
@@ -6,277 +6,524 @@ import type {
6
6
  MemberSite,
7
7
  SourceFacts,
8
8
  } from "@goodbones/core";
9
- import ts from "typescript";
9
+ import {
10
+ type BindingIdentifier,
11
+ type BindingPattern,
12
+ type BindingRestElement,
13
+ type CallExpression,
14
+ type ClassElement,
15
+ type Declaration,
16
+ type Directive,
17
+ type ExportDefaultDeclaration,
18
+ type ExportNamedDeclaration,
19
+ type Expression,
20
+ type ImportDeclarationSpecifier,
21
+ type ModuleExportName,
22
+ type Node,
23
+ type ParserOptions,
24
+ parseSync,
25
+ type Program,
26
+ type PropertyKey,
27
+ rawTransferSupported,
28
+ type Statement,
29
+ type TSModuleDeclaration,
30
+ type TSSignature,
31
+ type TSType,
32
+ type TSTypeLiteral,
33
+ Visitor,
34
+ } from "oxc-parser";
10
35
 
11
- // The facts, read out of TypeScript's syntax tree. The plugin reads the same
12
- // facts out of oxlint's; `src/adapters/parity.test.ts` holds the two to one
13
- // answer, so a form added here without the matching visitor there fails a test
14
- // rather than a user.
36
+ // The facts, read out of one ESTree. oxc-parser emits this tree for the CLI
37
+ // and oxlint hands the same tree to a plugin, so one reader serves both hosts:
38
+ // `readProgram` walks a `Program` and returns every fact with the node it came
39
+ // from, the plugin reports at those nodes, and `factsOfText` parses then strips
40
+ // them. The parity suite in `@goodbones/oxlint` pins that the two parsers, at
41
+ // their paired versions, produce the tree this reader expects.
15
42
 
16
- const scriptKindOf = (file: string): ts.ScriptKind =>
17
- file.endsWith(".tsx") ? ts.ScriptKind.TSX : ts.ScriptKind.TS;
43
+ // A fact with the syntax node it was read from — what a host that reports at
44
+ // a position needs, and what the core, which is position-free by design, never
45
+ // sees. `SourceFacts` is this with the nodes stripped.
46
+ export type SyntaxNode = Node;
18
47
 
19
- const nameOf = (node: ts.PropertyName | ts.ModuleExportName | undefined): string | null => {
20
- if (node === undefined) return null;
21
- if (ts.isIdentifier(node)) return node.text;
22
- if (ts.isStringLiteral(node)) return node.text;
23
- return null;
48
+ // The syntactic form an edge was written in. Only an `import` declaration can
49
+ // be rewritten by an `exports` fix; the rest are reported and left as written.
50
+ export type EdgeForm = "import" | "export" | "import-equals" | "import-expression" | "require";
51
+
52
+ // One binding, with the node it was declared at and the local name it was
53
+ // bound to — the name a rewrite of the declaration has to preserve.
54
+ export type ReadBinding = Binding & { readonly node: SyntaxNode; readonly local: string };
55
+
56
+ // One place in the source that names a module. The same specifier written
57
+ // twice is two edges here and one in `SourceFacts.specifiers`.
58
+ export type ReadEdge = {
59
+ readonly specifier: string;
60
+ readonly form: EdgeForm;
61
+ readonly node: SyntaxNode;
62
+ readonly bindings: ReadonlyArray<ReadBinding>;
24
63
  };
25
64
 
26
- const bindingsOfImportClause = (clause: ts.ImportClause | undefined): ReadonlyArray<Binding> => {
27
- if (clause === undefined) return [];
28
- const found: Array<Binding> = [];
29
- if (clause.name !== undefined) found.push({ symbol: "default", kind: "default" });
30
- const bindings = clause.namedBindings;
31
- if (bindings !== undefined) {
32
- if (ts.isNamespaceImport(bindings)) found.push({ symbol: "*", kind: "namespace" });
33
- else {
34
- for (const element of bindings.elements) {
35
- const symbol = nameOf(element.propertyName ?? element.name);
36
- if (symbol !== null) found.push({ symbol, kind: "named" });
37
- }
38
- }
39
- }
40
- return found;
65
+ export type ReadMemberSite = MemberSite & { readonly node: SyntaxNode };
66
+ export type ReadExportSite = ExportSite & { readonly node: SyntaxNode };
67
+
68
+ export type ReadFacts = {
69
+ readonly edges: ReadonlyArray<ReadEdge>;
70
+ readonly memberSites: ReadonlyArray<ReadMemberSite>;
71
+ readonly exportSites: ReadonlyArray<ReadExportSite>;
41
72
  };
42
73
 
43
74
  // The whole module, as one binding. `export * from "m"`, `export * as ns from
44
75
  // "m"`, `import x = require("m")`, `import("m")` and `require("m")` all carry
45
76
  // every export of `m` at once, exactly as `import * as ns` does — and are the
46
77
  // same way around a rule about a name. A side-effect import carries nothing.
47
- const WHOLE_MODULE: ReadonlyArray<Binding> = [{ symbol: "*", kind: "namespace" }];
48
-
49
- // `export { a } from "m"` — `propertyName` is the name in the source module when
50
- // the export is renamed, so it is the one the policy is about.
51
- const bindingsOfExportClause = (
52
- clause: ts.NamedExportBindings | undefined,
53
- ): ReadonlyArray<Binding> => {
54
- if (clause === undefined || ts.isNamespaceExport(clause)) return WHOLE_MODULE;
55
- const found: Array<Binding> = [];
56
- for (const element of clause.elements) {
57
- const symbol = nameOf(element.propertyName ?? element.name);
58
- if (symbol !== null) found.push({ symbol, kind: "named" });
59
- }
60
- return found;
78
+ const wholeModule = (node: SyntaxNode, local: string): ReadonlyArray<ReadBinding> => [
79
+ { symbol: "*", kind: "namespace", node, local },
80
+ ];
81
+
82
+ // The name written at a key or a module export name: an identifier, or a
83
+ // string literal (`import { "a-b" as ab }`, `"c-d": number`). A numeric key,
84
+ // a private `#name` and a computed expression are not names a rule can speak
85
+ // about.
86
+ const nameOf = (node: PropertyKey | ModuleExportName | BindingIdentifier | null): string | null => {
87
+ if (node === null) return null;
88
+ if (node.type === "Identifier") return node.name;
89
+ if (node.type === "Literal" && typeof node.value === "string") return node.value;
90
+ return null;
61
91
  };
62
92
 
63
- // The type literals written in a type, through intersections, unions and
64
- // parentheses: `type Port = Base & ({ a(): void } | { b(): void })` declares `a`
65
- // and `b`. A reference is not followed — `Base`'s members are declared where
66
- // `Base` is, and are reported there under its own name.
67
- const literalsOf = (node: ts.TypeNode): ReadonlyArray<ts.TypeLiteralNode> => {
68
- if (ts.isTypeLiteralNode(node)) return [node];
69
- if (ts.isIntersectionTypeNode(node) || ts.isUnionTypeNode(node)) {
70
- return node.types.flatMap(literalsOf);
93
+ const bindingOf = (specifier: ImportDeclarationSpecifier): ReadBinding | null => {
94
+ const local = specifier.local.name;
95
+ switch (specifier.type) {
96
+ case "ImportSpecifier": {
97
+ const symbol = nameOf(specifier.imported);
98
+ return symbol === null ? null : { symbol, kind: "named", node: specifier, local };
99
+ }
100
+ case "ImportDefaultSpecifier":
101
+ return { symbol: "default", kind: "default", node: specifier, local };
102
+ case "ImportNamespaceSpecifier":
103
+ return { symbol: "*", kind: "namespace", node: specifier, local };
71
104
  }
72
- if (ts.isParenthesizedTypeNode(node)) return literalsOf(node.type);
73
- return [];
74
105
  };
75
106
 
76
- // The member shapes that carry a name a vocabulary rule can speak about: a
77
- // property or method signature in a type, a property, method or accessor in a
78
- // class. Mirrors the plugin's `DECLARED_MEMBER_TYPES`.
79
- const isNamedMember = (
80
- member: ts.TypeElement | ts.ClassElement,
81
- ): member is (ts.TypeElement | ts.ClassElement) & { readonly name: ts.PropertyName } =>
82
- ts.isPropertySignature(member) ||
83
- ts.isMethodSignature(member) ||
84
- ts.isPropertyDeclaration(member) ||
85
- ts.isMethodDeclaration(member) ||
86
- ts.isGetAccessorDeclaration(member) ||
87
- ts.isSetAccessorDeclaration(member);
88
-
89
- const calleeNameOf = (expression: ts.LeftHandSideExpression): string | null => {
90
- if (ts.isIdentifier(expression)) return expression.text;
91
- if (ts.isPropertyAccessExpression(expression) && ts.isIdentifier(expression.name)) {
92
- return expression.name.text;
107
+ // `export { a as b } from "m"` `local` is the name in the source module, so
108
+ // it is the one the policy is about.
109
+ const reexportedBindings = (node: ExportNamedDeclaration): ReadonlyArray<ReadBinding> => {
110
+ const found: Array<ReadBinding> = [];
111
+ for (const specifier of node.specifiers) {
112
+ const symbol = nameOf(specifier.local);
113
+ if (symbol !== null) found.push({ symbol, kind: "named", node: specifier, local: symbol });
93
114
  }
94
- return null;
115
+ return found;
95
116
  };
96
117
 
97
- // The parse alone, for a source that need not be on disk — the CLI reads
98
- // every file through it, a source probe is checked through it at load, and the
99
- // parity suite feeds both adapters the same snippet through it.
100
- export const factsOfText = (file: string, text: string): SourceFacts => {
101
- const parsed = ts.createSourceFile(file, text, ts.ScriptTarget.Latest, true, scriptKindOf(file));
102
-
103
- const specifiers: Array<string> = [];
104
- const bindings = new Map<string, Array<Binding>>();
105
- const memberSites: Array<MemberSite> = [];
118
+ // The type literals written in a type, through intersections and unions:
119
+ // `type Port = Base & ({ a(): void } | { b(): void })` declares `a` and `b`. A
120
+ // reference is not followed `Base`'s members are declared where `Base` is,
121
+ // and are reported there under its own name. The parenthesised form is read
122
+ // too, for a tree parsed with `preserveParens`; oxlint's and `factsOfText`'s
123
+ // carry none.
124
+ const literalsOf = (node: TSType): ReadonlyArray<TSTypeLiteral> => {
125
+ switch (node.type) {
126
+ case "TSTypeLiteral":
127
+ return [node];
128
+ case "TSIntersectionType":
129
+ case "TSUnionType":
130
+ return node.types.flatMap(literalsOf);
131
+ case "TSParenthesizedType":
132
+ return literalsOf(node.typeAnnotation);
133
+ default:
134
+ return [];
135
+ }
136
+ };
106
137
 
107
- const record = (specifier: string, found: ReadonlyArray<Binding>): void => {
108
- if (!bindings.has(specifier)) {
109
- specifiers.push(specifier);
110
- bindings.set(specifier, []);
111
- }
112
- const existing = bindings.get(specifier);
113
- if (existing !== undefined) for (const binding of found) existing.push(binding);
114
- };
138
+ // The key of a member shape that carries a name a vocabulary rule can speak
139
+ // about: a property or method signature in a type; a property, method,
140
+ // accessor or auto-accessor in a class. A computed key is no name, and neither
141
+ // is an index, call or construct signature, a static block, or a constructor.
142
+ const keyOf = (member: TSSignature | ClassElement): PropertyKey | null => {
143
+ switch (member.type) {
144
+ case "TSPropertySignature":
145
+ case "TSMethodSignature":
146
+ case "PropertyDefinition":
147
+ case "TSAbstractPropertyDefinition":
148
+ case "AccessorProperty":
149
+ case "TSAbstractAccessorProperty":
150
+ return member.computed ? null : member.key;
151
+ case "MethodDefinition":
152
+ case "TSAbstractMethodDefinition":
153
+ return member.computed || member.kind === "constructor" ? null : member.key;
154
+ default:
155
+ return null;
156
+ }
157
+ };
115
158
 
116
- // The members written in a declaration, under that declaration's name and
117
- // kind. A computed key is not a name a vocabulary rule can speak about;
118
- // neither is a private `#name`, an index, call or construct signature, or a
119
- // constructor.
120
- const declared = (
121
- declaration: string,
122
- declares: DeclarationKind,
123
- members: ReadonlyArray<ts.TypeElement | ts.ClassElement>,
124
- ): void => {
125
- for (const member of members) {
126
- if (!isNamedMember(member)) continue;
127
- if (ts.isComputedPropertyName(member.name) || ts.isPrivateIdentifier(member.name)) continue;
128
- const name = nameOf(member.name);
129
- if (name !== null) {
130
- memberSites.push({ file, subject: "members", name, in: declaration, declares });
131
- }
132
- }
133
- };
159
+ // The name a call is made by: `f()` and `x.f()` are both `f`. `x[f]()` and
160
+ // `x["f"]()` are not — a computed property is not a name a vocabulary rule can
161
+ // speak about — and neither is a private `x.#f()`.
162
+ const calleeNameOf = (callee: Expression): string | null => {
163
+ if (callee.type === "Identifier") return callee.name;
164
+ if (callee.type === "MemberExpression" && !callee.computed) return nameOf(callee.property);
165
+ return null;
166
+ };
134
167
 
135
- const visit = (node: ts.Node): void => {
136
- if (ts.isImportDeclaration(node) && ts.isStringLiteral(node.moduleSpecifier)) {
137
- // A side-effect import carries no bindings but is still an edge — the
138
- // `import "server-only"` form a regex cannot see.
139
- record(node.moduleSpecifier.text, bindingsOfImportClause(node.importClause));
140
- } else if (
141
- ts.isExportDeclaration(node) &&
142
- node.moduleSpecifier !== undefined &&
143
- ts.isStringLiteral(node.moduleSpecifier)
144
- ) {
145
- record(node.moduleSpecifier.text, bindingsOfExportClause(node.exportClause));
146
- } else if (
147
- ts.isImportEqualsDeclaration(node) &&
148
- ts.isExternalModuleReference(node.moduleReference) &&
149
- ts.isStringLiteral(node.moduleReference.expression)
150
- ) {
151
- record(node.moduleReference.expression.text, WHOLE_MODULE);
152
- } else if (ts.isCallExpression(node)) {
153
- const [first] = node.arguments;
154
- const isModuleCall =
155
- node.expression.kind === ts.SyntaxKind.ImportKeyword ||
156
- (ts.isIdentifier(node.expression) && node.expression.text === "require");
157
- if (isModuleCall && first !== undefined && ts.isStringLiteral(first)) {
158
- record(first.text, WHOLE_MODULE);
159
- }
168
+ const requireSpecifierOf = (node: CallExpression): string | null => {
169
+ if (node.callee.type !== "Identifier" || node.callee.name !== "require") return null;
170
+ const [first] = node.arguments;
171
+ return first?.type === "Literal" && typeof first.value === "string" ? first.value : null;
172
+ };
160
173
 
161
- const callee = calleeNameOf(node.expression);
162
- if (callee !== null) memberSites.push({ file, subject: "calls", name: callee });
163
- } else if (ts.isTypeAliasDeclaration(node)) {
164
- declared(
165
- node.name.text,
166
- "type",
167
- literalsOf(node.type).flatMap((literal) => literal.members),
174
+ // The identifiers a binding pattern introduces.
175
+ const patternNames = (pattern: BindingPattern | BindingRestElement): ReadonlyArray<string> => {
176
+ switch (pattern.type) {
177
+ case "Identifier":
178
+ return [pattern.name];
179
+ case "ObjectPattern":
180
+ return pattern.properties.flatMap((property) =>
181
+ patternNames(property.type === "Property" ? property.value : property.argument),
168
182
  );
169
- } else if (ts.isInterfaceDeclaration(node)) {
170
- declared(node.name.text, "interface", node.members);
171
- } else if (ts.isClassDeclaration(node) && node.name !== undefined) {
172
- // A class body is a declaration with members like any other. An anonymous
173
- // class (`export default class {}`) has no name for `in` and is not read;
174
- // neither is a class expression, which is a value.
175
- declared(node.name.text, "class", node.members);
176
- }
177
-
178
- ts.forEachChild(node, visit);
179
- };
180
-
181
- visit(parsed);
182
-
183
- return { specifiers, bindings, memberSites, exportSites: exportSitesOf(file, parsed) };
183
+ case "ArrayPattern":
184
+ return pattern.elements.flatMap((element) => (element === null ? [] : patternNames(element)));
185
+ case "AssignmentPattern":
186
+ return patternNames(pattern.left);
187
+ case "RestElement":
188
+ return patternNames(pattern.argument);
189
+ }
184
190
  };
185
191
 
186
- const hasModifier = (node: ts.Node, kind: ts.SyntaxKind): boolean =>
187
- ts.canHaveModifiers(node) && (ts.getModifiers(node) ?? []).some((one) => one.kind === kind);
192
+ // `namespace A.B {}` declares `A`; `declare module "m" {}` declares no name a
193
+ // module's surface can carry.
194
+ const moduleNameOf = (node: TSModuleDeclaration): string | null => {
195
+ const id = node.id;
196
+ if (id.type !== "TSQualifiedName") return nameOf(id);
197
+ let left = id.left;
198
+ while (left.type === "TSQualifiedName") left = left.left;
199
+ return left.type === "Identifier" ? left.name : null;
200
+ };
188
201
 
189
202
  // The names a declaration statement introduces, with what it declares them as.
190
203
  // A destructuring pattern introduces names too, but not ones a surface rule
191
204
  // judges by declaration kind; they read as `variable` like the rest.
192
- const declaredNamesOf = (statement: ts.Node): ReadonlyArray<readonly [string, DeclarationKind]> => {
193
- if (ts.isFunctionDeclaration(statement)) {
194
- return statement.name === undefined ? [] : [[statement.name.text, "function"]];
195
- }
196
- if (ts.isClassDeclaration(statement)) {
197
- return statement.name === undefined ? [] : [[statement.name.text, "class"]];
205
+ const declaredNamesOf = (
206
+ declaration: Directive | Statement,
207
+ ): ReadonlyArray<readonly [string, DeclarationKind]> => {
208
+ const named = (
209
+ name: string | null,
210
+ declares: DeclarationKind,
211
+ ): ReadonlyArray<readonly [string, DeclarationKind]> => (name === null ? [] : [[name, declares]]);
212
+ switch (declaration.type) {
213
+ case "VariableDeclaration":
214
+ return declaration.declarations.flatMap((one) =>
215
+ patternNames(one.id).map((name) => [name, "variable"] as const),
216
+ );
217
+ case "FunctionDeclaration":
218
+ case "TSDeclareFunction":
219
+ return named(declaration.id?.name ?? null, "function");
220
+ case "ClassDeclaration":
221
+ return named(declaration.id?.name ?? null, "class");
222
+ case "TSTypeAliasDeclaration":
223
+ return named(declaration.id.name, "type");
224
+ case "TSInterfaceDeclaration":
225
+ return named(declaration.id.name, "interface");
226
+ case "TSEnumDeclaration":
227
+ return named(declaration.id.name, "enum");
228
+ case "TSModuleDeclaration":
229
+ return named(declaration.global ? declaration.id.name : moduleNameOf(declaration), "other");
230
+ default:
231
+ return [];
198
232
  }
199
- if (ts.isVariableStatement(statement)) {
200
- const names: Array<readonly [string, DeclarationKind]> = [];
201
- const collect = (binding: ts.BindingName): void => {
202
- if (ts.isIdentifier(binding)) names.push([binding.text, "variable"]);
203
- else {
204
- for (const element of binding.elements) {
205
- if (ts.isBindingElement(element)) collect(element.name);
206
- }
207
- }
208
- };
209
- for (const declaration of statement.declarationList.declarations) collect(declaration.name);
210
- return names;
233
+ };
234
+
235
+ // The declaration an `export default …` carries, when it is one — a function,
236
+ // class or interface, named or not — rather than an expression.
237
+ const defaultDeclaration = (node: ExportDefaultDeclaration): Declaration | null => {
238
+ const declaration = node.declaration;
239
+ switch (declaration.type) {
240
+ case "FunctionDeclaration":
241
+ case "TSDeclareFunction":
242
+ case "ClassDeclaration":
243
+ case "TSInterfaceDeclaration":
244
+ return declaration;
245
+ default:
246
+ return null;
211
247
  }
212
- if (ts.isTypeAliasDeclaration(statement)) return [[statement.name.text, "type"]];
213
- if (ts.isInterfaceDeclaration(statement)) return [[statement.name.text, "interface"]];
214
- if (ts.isEnumDeclaration(statement)) return [[statement.name.text, "enum"]];
215
- if (ts.isModuleDeclaration(statement) && ts.isIdentifier(statement.name)) {
216
- return [[statement.name.text, "other"]];
248
+ };
249
+
250
+ // What `export default …` declares: a function, class or interface by its
251
+ // shape, named or not; an identifier by the declaration it names in this
252
+ // file; any other expression as one.
253
+ const defaultDeclares = (
254
+ node: ExportDefaultDeclaration,
255
+ locals: ReadonlyMap<string, DeclarationKind>,
256
+ ): DeclarationKind => {
257
+ const declaration = node.declaration;
258
+ switch (declaration.type) {
259
+ case "FunctionDeclaration":
260
+ case "TSDeclareFunction":
261
+ return "function";
262
+ case "ClassDeclaration":
263
+ return "class";
264
+ case "TSInterfaceDeclaration":
265
+ return "interface";
266
+ case "Identifier":
267
+ return locals.get(declaration.name) ?? "expression";
268
+ default:
269
+ return "expression";
217
270
  }
218
- return [];
219
271
  };
220
272
 
221
- // A file's surface: what its top-level statements export, in source order. An
222
- // `export` inside a namespace body is that namespace's, not the module's.
223
- const exportSitesOf = (file: string, parsed: ts.SourceFile): ReadonlyArray<ExportSite> => {
273
+ // A file's surface: what its top-level statements export, in source order,
274
+ // read off the program body rather than by visiting export nodes — an `export`
275
+ // inside a namespace body is that namespace's, not the module's, and reading
276
+ // the top level directly is what says so without a parent pointer. `export =`
277
+ // is a CommonJS surface, not a module's, and is stepped over.
278
+ const exportSitesOf = (
279
+ file: string,
280
+ body: ReadonlyArray<Directive | Statement>,
281
+ ): ReadonlyArray<ReadExportSite> => {
224
282
  const locals = new Map<string, DeclarationKind>();
225
- for (const statement of parsed.statements) {
226
- for (const [name, declares] of declaredNamesOf(statement)) locals.set(name, declares);
283
+ for (const statement of body) {
284
+ const declaration =
285
+ statement.type === "ExportNamedDeclaration"
286
+ ? statement.declaration
287
+ : statement.type === "ExportDefaultDeclaration"
288
+ ? defaultDeclaration(statement)
289
+ : statement;
290
+ if (declaration === null) continue;
291
+ for (const [name, declares] of declaredNamesOf(declaration)) locals.set(name, declares);
227
292
  }
228
293
 
229
- const sites: Array<ExportSite> = [];
294
+ const sites: Array<ReadExportSite> = [];
230
295
  const site = (
296
+ node: SyntaxNode,
231
297
  name: string,
232
298
  kind: ExportSite["kind"],
233
299
  declares: DeclarationKind,
234
300
  reexport: boolean,
235
301
  ): void => {
236
- sites.push({ file, name, kind, declares, reexport });
302
+ sites.push({ file, name, kind, declares, reexport, node });
237
303
  };
238
304
 
239
- for (const statement of parsed.statements) {
240
- if (ts.isExportDeclaration(statement)) {
241
- const reexport = statement.moduleSpecifier !== undefined;
242
- const clause = statement.exportClause;
243
- if (clause === undefined) site("*", "namespace", "other", true);
244
- else if (ts.isNamespaceExport(clause)) site(clause.name.text, "namespace", "other", true);
245
- else {
246
- for (const element of clause.elements) {
247
- const name = nameOf(element.name);
305
+ for (const statement of body) {
306
+ switch (statement.type) {
307
+ case "ExportNamedDeclaration": {
308
+ const declaration = statement.declaration;
309
+ if (declaration !== null) {
310
+ for (const [name, declares] of declaredNamesOf(declaration)) {
311
+ site(declaration, name, "named", declares, false);
312
+ }
313
+ break;
314
+ }
315
+ const reexport = statement.source !== null;
316
+ for (const specifier of statement.specifiers) {
317
+ const name = nameOf(specifier.exported);
248
318
  if (name === null) continue;
249
- const local = nameOf(element.propertyName ?? element.name) ?? name;
319
+ const local = nameOf(specifier.local) ?? name;
250
320
  const declares = reexport ? "other" : (locals.get(local) ?? "other");
251
- site(name, name === "default" ? "default" : "named", declares, reexport);
321
+ site(specifier, name, name === "default" ? "default" : "named", declares, reexport);
252
322
  }
323
+ break;
253
324
  }
254
- } else if (ts.isExportAssignment(statement)) {
255
- // `export = x` is a CommonJS surface, not a module's.
256
- if (statement.isExportEquals === true) continue;
257
- const declares = ts.isIdentifier(statement.expression)
258
- ? (locals.get(statement.expression.text) ?? "expression")
259
- : "expression";
260
- site("default", "default", declares, false);
261
- } else if (hasModifier(statement, ts.SyntaxKind.ExportKeyword)) {
262
- const isDefault = hasModifier(statement, ts.SyntaxKind.DefaultKeyword);
263
- const declared = declaredNamesOf(statement);
264
- if (isDefault) {
265
- // `export default function () {}` has no name to look up.
266
- const declares =
267
- declared[0]?.[1] ??
268
- (ts.isFunctionDeclaration(statement)
269
- ? "function"
270
- : ts.isClassDeclaration(statement)
271
- ? "class"
272
- : "other");
273
- site("default", "default", declares, false);
274
- } else {
275
- for (const [name, declares] of declared) site(name, "named", declares, false);
276
- }
325
+ case "ExportDefaultDeclaration":
326
+ site(statement, "default", "default", defaultDeclares(statement, locals), false);
327
+ break;
328
+ case "ExportAllDeclaration":
329
+ site(statement, nameOf(statement.exported) ?? "*", "namespace", "other", true);
330
+ break;
331
+ default:
332
+ break;
277
333
  }
278
334
  }
279
335
  return sites;
280
336
  };
281
337
 
338
+ // What the reader takes: the statements of one program. oxlint's `Program`
339
+ // and oxc-parser's differ in what else they carry, and the reader reads
340
+ // nothing else.
341
+ export type ProgramBody = Pick<Program, "body">;
342
+
343
+ // The read in progress: where the walk's handlers write. One `Visitor` is
344
+ // compiled once for the module — oxc-parser keeps every compiled visitor's
345
+ // handlers in a cache it never trims, so compiling one per call would retain
346
+ // the handlers and, through them, every tree ever read. `readProgram` is
347
+ // synchronous and no handler re-enters it, so one slot is enough.
348
+ type Reading = {
349
+ readonly file: string;
350
+ readonly edges: Array<ReadEdge>;
351
+ readonly memberSites: Array<ReadMemberSite>;
352
+ };
353
+
354
+ const IDLE: Reading = { file: "", edges: [], memberSites: [] };
355
+ let reading: Reading = IDLE;
356
+
357
+ const edge = (
358
+ node: SyntaxNode,
359
+ specifier: string,
360
+ form: EdgeForm,
361
+ bindings: ReadonlyArray<ReadBinding>,
362
+ ): void => {
363
+ reading.edges.push({ specifier, form, node, bindings });
364
+ };
365
+
366
+ // The members written in a declaration, under that declaration's name and
367
+ // kind, each at its key.
368
+ const declared = (
369
+ declaration: string,
370
+ declares: DeclarationKind,
371
+ members: ReadonlyArray<TSSignature | ClassElement>,
372
+ ): void => {
373
+ for (const member of members) {
374
+ const key = keyOf(member);
375
+ if (key === null) continue;
376
+ const name = nameOf(key);
377
+ if (name !== null) {
378
+ reading.memberSites.push({
379
+ file: reading.file,
380
+ subject: "members",
381
+ name,
382
+ in: declaration,
383
+ declares,
384
+ node: key,
385
+ });
386
+ }
387
+ }
388
+ };
389
+
390
+ // Every form that names a module is an edge, in source order. A computed
391
+ // `import(expr)` or `require(expr)` is not a fact a static policy can speak
392
+ // about.
393
+ const walker = new Visitor({
394
+ ImportDeclaration(node) {
395
+ // A side-effect import carries no bindings but is still an edge — the
396
+ // `import "server-only"` form a regex cannot see.
397
+ const bindings = node.specifiers.flatMap((specifier) => {
398
+ const found = bindingOf(specifier);
399
+ return found === null ? [] : [found];
400
+ });
401
+ edge(node, node.source.value, "import", bindings);
402
+ },
403
+ ExportNamedDeclaration(node) {
404
+ if (node.source !== null) edge(node, node.source.value, "export", reexportedBindings(node));
405
+ },
406
+ ExportAllDeclaration(node) {
407
+ edge(node, node.source.value, "export", wholeModule(node, nameOf(node.exported) ?? ""));
408
+ },
409
+ TSImportEqualsDeclaration(node) {
410
+ const reference = node.moduleReference;
411
+ if (reference.type === "TSExternalModuleReference") {
412
+ edge(node, reference.expression.value, "import-equals", wholeModule(node, node.id.name));
413
+ }
414
+ },
415
+ ImportExpression(node) {
416
+ if (node.source.type === "Literal" && typeof node.source.value === "string") {
417
+ edge(node, node.source.value, "import-expression", wholeModule(node, ""));
418
+ }
419
+ },
420
+ CallExpression(node) {
421
+ const required = requireSpecifierOf(node);
422
+ if (required !== null) edge(node, required, "require", wholeModule(node, ""));
423
+
424
+ const callee = calleeNameOf(node.callee);
425
+ if (callee !== null) {
426
+ reading.memberSites.push({ file: reading.file, subject: "calls", name: callee, node });
427
+ }
428
+ },
429
+ TSTypeAliasDeclaration(node) {
430
+ declared(
431
+ node.id.name,
432
+ "type",
433
+ literalsOf(node.typeAnnotation).flatMap((literal) => literal.members),
434
+ );
435
+ },
436
+ TSInterfaceDeclaration(node) {
437
+ declared(node.id.name, "interface", node.body.body);
438
+ },
439
+ // A named class only: an anonymous default class has no name for `in`, and
440
+ // a class expression is a value. `ClassDeclaration` is the visitor key, so a
441
+ // class expression is never handed in.
442
+ ClassDeclaration(node) {
443
+ if (node.id !== null) declared(node.id.name, "class", node.body.body);
444
+ },
445
+ });
446
+
447
+ // Reads every fact out of one program, with the node each came from. Pure over
448
+ // the tree: oxlint's plugin calls it on the tree oxlint parsed, and
449
+ // `factsOfText` on the one oxc-parser did.
450
+ export const readProgram = (file: string, program: ProgramBody): ReadFacts => {
451
+ const read: Reading = { file, edges: [], memberSites: [] };
452
+ reading = read;
453
+ // The walk starts at a root of this shape and reads only its `body`.
454
+ walker.visit({
455
+ type: "Program",
456
+ body: program.body,
457
+ sourceType: "module",
458
+ hashbang: null,
459
+ start: 0,
460
+ end: 0,
461
+ });
462
+ reading = IDLE;
463
+ return {
464
+ edges: read.edges,
465
+ memberSites: read.memberSites,
466
+ exportSites: exportSitesOf(file, program.body),
467
+ };
468
+ };
469
+
470
+ const langOf = (file: string): "ts" | "tsx" => (file.endsWith(".tsx") ? "tsx" : "ts");
471
+
472
+ // Raw transfer hands the tree over as bytes and builds the nodes here, rather
473
+ // than serialising it to JSON in Rust and parsing that back — about three
474
+ // times the throughput of the JSON path on a source tree of ordinary files,
475
+ // and the difference between this parser and the compiler API being faster
476
+ // or slower. The buffer it reads from is reused across parses and returned
477
+ // before the tree is, so nothing here outlives a parse. The option is not in
478
+ // the parser's declared type yet; it is read all the same. Where the platform
479
+ // cannot do it (32-bit, big-endian, a Node before 22), the JSON path is what
480
+ // runs, at the same answer.
481
+ const RAW_TRANSFER: ParserOptions & { readonly experimentalRawTransfer: boolean } = {
482
+ experimentalRawTransfer: rawTransferSupported(),
483
+ preserveParens: false,
484
+ };
485
+
486
+ const stripNode = <T extends { readonly node: SyntaxNode }>(carrying: T): Omit<T, "node"> => {
487
+ const { node: _node, ...fact } = carrying;
488
+ return fact;
489
+ };
490
+
491
+ // The facts with the nodes stripped: one entry per specifier, in the order
492
+ // first written, carrying every binding pulled across it.
493
+ export const sourceFactsOf = (read: ReadFacts): SourceFacts => {
494
+ const specifiers: Array<string> = [];
495
+ const bindings = new Map<string, Array<Binding>>();
496
+ for (const edge of read.edges) {
497
+ let found = bindings.get(edge.specifier);
498
+ if (found === undefined) {
499
+ found = [];
500
+ bindings.set(edge.specifier, found);
501
+ specifiers.push(edge.specifier);
502
+ }
503
+ // The local name goes with the binding: it is how a campaign's `syntax`
504
+ // term traces an identifier back to its import. A form that binds no name
505
+ // (`import("m")`, `require("m")`) carries none.
506
+ for (const { kind, local, symbol } of edge.bindings) {
507
+ found.push(local === "" ? { symbol, kind } : { symbol, kind, local });
508
+ }
509
+ }
510
+ return {
511
+ specifiers,
512
+ bindings,
513
+ memberSites: read.memberSites.map(stripNode),
514
+ exportSites: read.exportSites.map(stripNode),
515
+ };
516
+ };
517
+
518
+ // The parse alone, for a source that need not be on disk — the CLI reads
519
+ // every file through it, a source probe is checked through it at load, and the
520
+ // parity suite feeds both hosts the same snippet through it. Parentheses are
521
+ // not kept as nodes, which is the tree oxlint hands a plugin. A syntax error
522
+ // does not throw: oxc reports it and returns an empty program, so a file that
523
+ // does not parse contributes no facts — the same file no linter would visit.
524
+ export const factsOfText = (file: string, text: string): SourceFacts => {
525
+ const { program } = parseSync(file, text, { ...RAW_TRANSFER, lang: langOf(file) });
526
+ return sourceFactsOf(readProgram(file, program));
527
+ };
528
+
282
529
  export const makeFactExtractorLive = (): FactExtractor => ({ factsOf: factsOfText });