archstrict 0.0.0 → 0.1.0

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.
Files changed (65) hide show
  1. package/.agents/hooks/hooks.json +29 -0
  2. package/.agents/hooks/post-tool-use.mjs +107 -0
  3. package/.agents/hooks/pre-tool-use.mjs +182 -0
  4. package/.agents/mcp/server.mjs +71 -0
  5. package/.agents/plugin.json +19 -0
  6. package/AGENTS.md +69 -0
  7. package/CHANGELOG.md +38 -0
  8. package/README.ja.md +62 -0
  9. package/README.md +63 -2
  10. package/dist/augmentation-cache.js +65 -0
  11. package/dist/check-options.js +40 -0
  12. package/dist/classify.js +148 -0
  13. package/dist/cli.js +239 -0
  14. package/dist/config-pointer.js +251 -0
  15. package/dist/config.js +186 -0
  16. package/dist/edge-cache.js +530 -0
  17. package/dist/mcp-server.js +111 -0
  18. package/dist/module-candidates.js +118 -0
  19. package/dist/module-graph.js +2072 -0
  20. package/dist/project-path.js +59 -0
  21. package/dist/report-error.js +13 -0
  22. package/dist/rules/config-meaning.js +143 -0
  23. package/dist/rules/constraints.js +417 -0
  24. package/dist/rules/cycles.js +257 -0
  25. package/dist/rules/deprecated.js +67 -0
  26. package/dist/rules/empty-rule.js +101 -0
  27. package/dist/rules/moves.js +79 -0
  28. package/dist/rules/must-be-empty.js +52 -0
  29. package/dist/rules/public-surface.js +100 -0
  30. package/dist/rules/type-leak.js +562 -0
  31. package/dist/rules/uncovered.js +75 -0
  32. package/dist/todo-migration.js +112 -0
  33. package/dist/todo-store.js +434 -0
  34. package/dist/type-closure.js +959 -0
  35. package/dist/verbs/agents.js +116 -0
  36. package/dist/verbs/check.js +957 -0
  37. package/dist/verbs/fix.js +170 -0
  38. package/dist/verbs/hotspots.js +261 -0
  39. package/dist/verbs/init.js +522 -0
  40. package/dist/verbs/recommend.js +800 -0
  41. package/dist/verbs/rules.js +188 -0
  42. package/dist/verbs/search.js +109 -0
  43. package/dist/verbs/simulate.js +220 -0
  44. package/dist/verbs/todo.js +163 -0
  45. package/dist/warm-graph.js +82 -0
  46. package/docs/boundary-patterns.md +374 -0
  47. package/docs/calibrated-rules-design.md +124 -0
  48. package/docs/init-singleton-modules.md +128 -0
  49. package/docs/maintenance.md +82 -0
  50. package/docs/releasing.md +55 -0
  51. package/docs/rules-edge-cache.md +50 -0
  52. package/docs/todo-single-file-migration.md +58 -0
  53. package/llms.txt +19 -0
  54. package/package.json +57 -4
  55. package/skills/archstrict/SKILL.md +42 -0
  56. package/skills/archstrict/references/agents-verb.md +39 -0
  57. package/skills/archstrict/references/config.md +107 -0
  58. package/skills/archstrict/references/hook.md +57 -0
  59. package/skills/archstrict/references/path-rules.md +57 -0
  60. package/skills/archstrict/references/patterns.md +883 -0
  61. package/skills/archstrict/references/prove-rules.md +58 -0
  62. package/skills/archstrict/references/rearchitect.md +35 -0
  63. package/skills/archstrict/references/recommend.md +80 -0
  64. package/skills/archstrict/references/rules.md +146 -0
  65. package/skills/archstrict/references/simulate.md +109 -0
@@ -0,0 +1,959 @@
1
+ // Responsibility: compute the file set rule 6's own checker Program must
2
+ // load to answer every real type-leak question - not every analyzed file,
3
+ // but every file reachable from a module surface by following an export
4
+ // chain, a type position, or an unannotated declaration's own inferred
5
+ // type, plus every file whose own top-level names are ambient (a script,
6
+ // or a `declare global`/`declare module "..."` body) and so bind
7
+ // regardless of who imports it. On a large project this closure is a small
8
+ // fraction of the analyzed set - the whole reason this module exists is
9
+ // that the checker's own bound SourceFile trees, not the edges archstrict
10
+ // otherwise builds, dominate memory on a codebase of tens of thousands of
11
+ // files.
12
+ //
13
+ // Boundary: syntax only. Each file is parsed once, reduced immediately to a small per-declaration
14
+ // summary (which names it exports, which specifiers and identifiers each
15
+ // declaration references), and the AST is dropped. Specifier resolution
16
+ // never happens here - `resolvedSpecifiers` is the graph's own
17
+ // fromFile -> resolutionKey(specifier, mode) -> resolvedFile edges, already built once by
18
+ // module-graph.ts's own per-file walk; asking `ts.resolveModuleName`
19
+ // again here would be a second resolution pass over the same specifiers.
20
+ // No rule logic and no checker: this module only names the files rule 6's
21
+ // own Program needs to see. Its public-name walk also reports every file
22
+ // it visits, because an augmentation matters only when that walk depends
23
+ // on the augmented file. It never decides whether a closure is complete -
24
+ // module-graph.ts's own safety net makes that call, from rule
25
+ // 6's own real resolution failures on the Program this module's output
26
+ // became, not from anything this module reports about itself.
27
+ //
28
+ // An incomplete closure gets rule 6 wrong in two different directions.
29
+ // Within the syntax archstrict analyzes (ESM import/export; `import x =
30
+ // require(...)` is counted as unsupported and never followed), the
31
+ // rules below guarantee the first: a missing file that holds a
32
+ // declaration a reached export or an internal type structurally reaches
33
+ // drops a real finding, since the checker would see no declaration at
34
+ // all where that file's own export should have resolved. module-graph.ts's
35
+ // own safety net guards the second, separately: a missing file that
36
+ // holds a hop INSIDE a re-export chain (or a generic constraint's own
37
+ // reference) turns a surface's own public name into an error type, so
38
+ // rule 6's own "does a consumer already have a name for this" lookup
39
+ // reads it as absent, and an internal declaration that already had a
40
+ // real public name through that chain reads as newly, wrongly leaked -
41
+ // the net recovers this because the chain's own alias resolves to the
42
+ // checker's own unknown symbol, a fact rule 6 reports and module-graph.ts
43
+ // resolves through the edge records. A lost `export *` target is the
44
+ // rules below own responsibility, not the net's: R2 (export chains)
45
+ // already follows every file `export * from` can reach, so a missing
46
+ // target there is this module's own bug, not a case the net exists to
47
+ // paper over.
48
+ import ts from "typescript";
49
+ import { resolutionKey } from "./edge-cache.js";
50
+ // TypeScript hands back a real resolvedFileName for a target the
51
+ // checker can bind (a real .ts/.tsx/.mts/.cts source, or a hand-authored
52
+ // .d.ts/.d.mts/.d.cts) - anything else (a resolved .js with no
53
+ // declaration file, a JSON import, ...) is not something a Program root
54
+ // could type, the same filter module-graph.ts's own resolution loop
55
+ // already keeps in mind when deciding whether a target is real project
56
+ // source.
57
+ function isProgramSource(file) {
58
+ return /\.(?:d\.)?[mc]?tsx?$/.test(file);
59
+ }
60
+ function segmentsOf(node) {
61
+ if (ts.isIdentifier(node))
62
+ return [node.text];
63
+ if (ts.isQualifiedName(node)) {
64
+ const left = segmentsOf(node.left);
65
+ return left === undefined ? undefined : [...left, node.right.text];
66
+ }
67
+ if (ts.isPropertyAccessExpression(node) && ts.isIdentifier(node.name)) {
68
+ const left = segmentsOf(node.expression);
69
+ return left === undefined ? undefined : [...left, node.name.text];
70
+ }
71
+ return undefined;
72
+ }
73
+ function isFunctionLikeWithBody(node) {
74
+ return ts.isFunctionDeclaration(node) || ts.isMethodDeclaration(node) ||
75
+ ts.isArrowFunction(node) || ts.isFunctionExpression(node) || ts.isGetAccessorDeclaration(node);
76
+ }
77
+ function hasExportModifier(node) {
78
+ return (ts.canHaveModifiers(node) ? ts.getModifiers(node) : undefined)
79
+ ?.some((m) => m.kind === ts.SyntaxKind.ExportKeyword) ?? false;
80
+ }
81
+ function hasDefaultModifier(node) {
82
+ return (ts.canHaveModifiers(node) ? ts.getModifiers(node) : undefined)
83
+ ?.some((m) => m.kind === ts.SyntaxKind.DefaultKeyword) ?? false;
84
+ }
85
+ // Each bound name, paired with the node the checker itself reports as its
86
+ // own declaration - a plain identifier binding (`const x = 1`) resolves to
87
+ // the VariableDeclaration itself; a destructured one (`const { a } = x`,
88
+ // `const [a] = x`) resolves to that ONE binding element, not the
89
+ // declaration as a whole (measured directly against a real checker: two
90
+ // names destructured from the same declarator get two different
91
+ // positions) - `contextNode` starts as the declaration and becomes each
92
+ // binding element in turn as the pattern nests.
93
+ function bindingElements(name, contextNode, out) {
94
+ if (ts.isIdentifier(name)) {
95
+ out.push({ name: name.text, node: contextNode });
96
+ return out;
97
+ }
98
+ for (const element of name.elements) {
99
+ if (!ts.isOmittedExpression(element))
100
+ bindingElements(element.name, element, out);
101
+ }
102
+ return out;
103
+ }
104
+ function newInfo() {
105
+ return { refs: [], imports: [] };
106
+ }
107
+ // The same (file, line, column) `declarationKey` (below) turns into one
108
+ // string - kept as a plain position, not a node reference, so a
109
+ // declaration reached through a syntax-only parse (no bound Program, no
110
+ // live symbol) can still be compared against one the checker itself
111
+ // returned for the identical source text.
112
+ function positionOf(node, sf, file) {
113
+ const start = node.getStart(sf);
114
+ const { line, character } = sf.getLineAndCharacterOfPosition(start);
115
+ return { file, line: line + 1, column: character + 1 };
116
+ }
117
+ // A dynamic `import(...)` call's own string-literal argument, or
118
+ // undefined for anything else - the one specifier-bearing node shape
119
+ // that is a plain CallExpression rather than its own dedicated node kind.
120
+ function dynamicImportSpecifier(node) {
121
+ if (!ts.isCallExpression(node) || node.expression.kind !== ts.SyntaxKind.ImportKeyword)
122
+ return undefined;
123
+ const argument = node.arguments[0];
124
+ return argument !== undefined && ts.isStringLiteral(argument) ? argument : undefined;
125
+ }
126
+ // Walks one declaration's own subtree, filling `info`. `inferring` is
127
+ // true once a value position with no type annotation has been entered -
128
+ // only then does a bare identifier/property-access count as a reference
129
+ // at all (an annotated declaration's own body/initializer is never
130
+ // walked: the annotation already says everything the checker needs).
131
+ function collect(node, info, inferring, sf, compilerOptions) {
132
+ const resolvedSpecifier = (literal) => ({
133
+ specifier: literal.text,
134
+ key: resolutionKey({ specifier: literal.text, mode: ts.getModeForUsageLocation(sf, literal, compilerOptions) }),
135
+ });
136
+ const visit = (n, inferHere) => {
137
+ const importTypeSpecifier = ts.isImportTypeNode(n) && ts.isLiteralTypeNode(n.argument) && ts.isStringLiteral(n.argument.literal)
138
+ ? n.argument.literal : undefined;
139
+ const dynamicSpecifier = dynamicImportSpecifier(n);
140
+ // A dynamic `import(...)` reached while inferring (an unannotated
141
+ // declaration's own value walk) can resolve to any type its target
142
+ // module exports - the same reasoning the inference rule below uses
143
+ // for a static reference, applied to the one import shape a value
144
+ // expression can hold. Always reaches the target whole: this closure
145
+ // decides only which FILE needs loading, never which member of it a
146
+ // caller happens to reach.
147
+ if (dynamicSpecifier !== undefined && inferHere)
148
+ info.imports.push(resolvedSpecifier(dynamicSpecifier));
149
+ if (ts.isTypeReferenceNode(n)) {
150
+ const seg = segmentsOf(n.typeName);
151
+ if (seg !== undefined)
152
+ info.refs.push(seg);
153
+ n.typeArguments?.forEach((a) => visit(a, inferHere));
154
+ return;
155
+ }
156
+ if (ts.isExpressionWithTypeArguments(n)) {
157
+ const seg = segmentsOf(n.expression);
158
+ if (seg !== undefined)
159
+ info.refs.push(seg);
160
+ else
161
+ visit(n.expression, true);
162
+ n.typeArguments?.forEach((a) => visit(a, inferHere));
163
+ return;
164
+ }
165
+ if (ts.isTypeQueryNode(n)) {
166
+ const seg = segmentsOf(n.exprName);
167
+ if (seg !== undefined)
168
+ info.refs.push(seg);
169
+ n.typeArguments?.forEach((a) => visit(a, inferHere));
170
+ return;
171
+ }
172
+ if (ts.isImportTypeNode(n)) {
173
+ if (importTypeSpecifier !== undefined) {
174
+ info.imports.push({ ...resolvedSpecifier(importTypeSpecifier), qualifier: n.qualifier ? segmentsOf(n.qualifier) : undefined });
175
+ }
176
+ n.typeArguments?.forEach((a) => visit(a, inferHere));
177
+ return;
178
+ }
179
+ if (ts.isComputedPropertyName(n)) {
180
+ visit(n.expression, true);
181
+ return;
182
+ }
183
+ // An export specifier inside a namespace or ambient-module body
184
+ // (`export { X }`, `export type { X }`) references X the same way a
185
+ // value expression references an identifier - nothing else visits a
186
+ // nested ExportDeclaration, since the top-level walk only reads
187
+ // export declarations that are direct children of a source file.
188
+ if (ts.isExportDeclaration(n)) {
189
+ const spec = n.moduleSpecifier !== undefined && ts.isStringLiteral(n.moduleSpecifier) ? n.moduleSpecifier : undefined;
190
+ if (n.exportClause !== undefined && ts.isNamedExports(n.exportClause)) {
191
+ for (const element of n.exportClause.elements) {
192
+ const local = (element.propertyName ?? element.name).text;
193
+ if (spec !== undefined)
194
+ info.imports.push({ ...resolvedSpecifier(spec), qualifier: [local] });
195
+ else
196
+ info.refs.push([local]);
197
+ }
198
+ }
199
+ else if (spec !== undefined) {
200
+ info.imports.push(resolvedSpecifier(spec));
201
+ }
202
+ return;
203
+ }
204
+ if (inferHere) {
205
+ const seg = (ts.isPropertyAccessExpression(n) || ts.isIdentifier(n)) ? segmentsOf(n) : undefined;
206
+ if (seg !== undefined) {
207
+ info.refs.push(seg);
208
+ return;
209
+ }
210
+ }
211
+ // An annotated function-like/constructor/setter contributes only its
212
+ // signature (parameters, type parameters, return type) - its body is
213
+ // never inferred, since the annotation already fixes what the
214
+ // checker needs. An unannotated one has its body walked in inferring
215
+ // mode instead (the inference rule, below).
216
+ if (isFunctionLikeWithBody(n) || ts.isConstructorDeclaration(n) || ts.isSetAccessorDeclaration(n)) {
217
+ n.typeParameters?.forEach((p) => visit(p, inferHere));
218
+ n.parameters.forEach((p) => visit(p, inferHere));
219
+ if (ts.isMethodDeclaration(n) && n.name !== undefined && ts.isComputedPropertyName(n.name))
220
+ visit(n.name, inferHere);
221
+ if (n.type !== undefined)
222
+ visit(n.type, inferHere);
223
+ else if (n.body !== undefined && isFunctionLikeWithBody(n))
224
+ visit(n.body, true);
225
+ return;
226
+ }
227
+ if (ts.isBindingElement(n)) {
228
+ if (n.name !== undefined && ts.isComputedPropertyName(n.name))
229
+ visit(n.name, inferHere);
230
+ else if (!ts.isIdentifier(n.name))
231
+ visit(n.name, inferHere);
232
+ if (inferHere && n.propertyName !== undefined)
233
+ visit(n.propertyName, inferHere);
234
+ return;
235
+ }
236
+ if (ts.isVariableDeclaration(n) || ts.isPropertyDeclaration(n) || ts.isParameter(n)) {
237
+ if (n.name !== undefined && ts.isComputedPropertyName(n.name))
238
+ visit(n.name, inferHere);
239
+ else if (n.name !== undefined && !ts.isIdentifier(n.name))
240
+ visit(n.name, inferHere);
241
+ if (n.type !== undefined)
242
+ visit(n.type, inferHere);
243
+ else if (n.initializer !== undefined)
244
+ visit(n.initializer, true);
245
+ return;
246
+ }
247
+ ts.forEachChild(n, (child) => visit(child, inferHere));
248
+ };
249
+ visit(node, inferring);
250
+ }
251
+ function summarize(inputs, summaries, file) {
252
+ const cached = summaries.get(file);
253
+ if (cached !== undefined)
254
+ return cached;
255
+ const text = inputs.readFile(file);
256
+ if (text === undefined) {
257
+ const empty = {
258
+ missing: true, isScript: false, imports: new Map(), exportsLocal: new Map(), reexports: new Map(),
259
+ stars: [], decls: new Map(), defaultInfo: undefined, defaultAlias: undefined, exportEquals: false,
260
+ exportEqualsInfo: undefined, ambient: [],
261
+ };
262
+ summaries.set(file, empty);
263
+ return empty;
264
+ }
265
+ const sourceFileOptions = inputs.sourceFileOptionsFor(file);
266
+ const sf = ts.createSourceFile(file, text, {
267
+ languageVersion: inputs.languageVersion,
268
+ impliedNodeFormat: sourceFileOptions.impliedNodeFormat,
269
+ }, true, inputs.scriptKindFor(file));
270
+ const resolvedSpecifier = (literal) => ({
271
+ specifier: literal.text,
272
+ key: resolutionKey({
273
+ specifier: literal.text,
274
+ mode: ts.getModeForUsageLocation(sf, literal, sourceFileOptions.compilerOptions),
275
+ }),
276
+ });
277
+ const summary = {
278
+ missing: false, isScript: !ts.isExternalModule(sf), imports: new Map(), exportsLocal: new Map(),
279
+ reexports: new Map(), stars: [], decls: new Map(), defaultInfo: undefined, defaultAlias: undefined,
280
+ exportEquals: false, exportEqualsInfo: undefined, ambient: [],
281
+ };
282
+ const addDecl = (name, info) => {
283
+ const existing = summary.decls.get(name);
284
+ if (existing === undefined)
285
+ summary.decls.set(name, [info]);
286
+ else
287
+ existing.push(info);
288
+ };
289
+ for (const statement of sf.statements) {
290
+ if (ts.isImportDeclaration(statement)) {
291
+ const spec = ts.isStringLiteral(statement.moduleSpecifier) ? statement.moduleSpecifier : undefined;
292
+ const clause = statement.importClause;
293
+ if (clause === undefined || spec === undefined)
294
+ continue;
295
+ if (clause.name !== undefined)
296
+ summary.imports.set(clause.name.text, { ...resolvedSpecifier(spec), importedName: "default" });
297
+ const bindings = clause.namedBindings;
298
+ if (bindings !== undefined && ts.isNamespaceImport(bindings)) {
299
+ summary.imports.set(bindings.name.text, { ...resolvedSpecifier(spec), importedName: "*" });
300
+ }
301
+ else if (bindings !== undefined) {
302
+ for (const element of bindings.elements) {
303
+ summary.imports.set(element.name.text, { ...resolvedSpecifier(spec), importedName: (element.propertyName ?? element.name).text });
304
+ }
305
+ }
306
+ continue;
307
+ }
308
+ if (ts.isImportEqualsDeclaration(statement)) {
309
+ // `import x = require("./y")` is out of scope for analysis
310
+ // entirely (module-graph.ts's own walk counts it as unsupported
311
+ // syntax and never resolves it) - `import x = SomeNamespace.Y`
312
+ // (the other, in-scope form) is a real local reference, walked
313
+ // the same way any other declaration is.
314
+ if (!ts.isExternalModuleReference(statement.moduleReference)) {
315
+ const info = newInfo();
316
+ const seg = segmentsOf(statement.moduleReference);
317
+ if (seg !== undefined)
318
+ info.refs.push(seg);
319
+ info.position = positionOf(statement, sf, file);
320
+ info.isImportEquals = true;
321
+ addDecl(statement.name.text, info);
322
+ if (hasExportModifier(statement))
323
+ summary.exportsLocal.set(statement.name.text, statement.name.text);
324
+ }
325
+ continue;
326
+ }
327
+ if (ts.isExportDeclaration(statement)) {
328
+ const spec = statement.moduleSpecifier !== undefined && ts.isStringLiteral(statement.moduleSpecifier) ? statement.moduleSpecifier : undefined;
329
+ if (statement.exportClause === undefined) {
330
+ if (spec !== undefined)
331
+ summary.stars.push(resolvedSpecifier(spec));
332
+ continue;
333
+ }
334
+ if (ts.isNamespaceExport(statement.exportClause)) {
335
+ if (spec !== undefined)
336
+ summary.reexports.set(statement.exportClause.name.text, { ...resolvedSpecifier(spec), importedName: "*" });
337
+ continue;
338
+ }
339
+ for (const element of statement.exportClause.elements) {
340
+ const exported = element.name.text;
341
+ const local = (element.propertyName ?? element.name).text;
342
+ if (spec !== undefined)
343
+ summary.reexports.set(exported, { ...resolvedSpecifier(spec), importedName: local });
344
+ else
345
+ summary.exportsLocal.set(exported, local);
346
+ }
347
+ continue;
348
+ }
349
+ if (ts.isExportAssignment(statement)) {
350
+ const info = newInfo();
351
+ const seg = segmentsOf(statement.expression);
352
+ if (seg !== undefined)
353
+ info.refs.push(seg);
354
+ else
355
+ collect(statement.expression, info, true, sf, sourceFileOptions.compilerOptions);
356
+ // `export default <expr>` (an anonymous expression, no separate
357
+ // named declaration of its own): the checker's own declaration for
358
+ // it is this ExportAssignment statement itself - EXCEPT when the
359
+ // expression is a single identifier (`export default Foo;`),
360
+ // which is an alias to Foo's own declaration (FileSummary's own
361
+ // `defaultAlias` comment; a qualified name has no local name to
362
+ // resolve, so it is left for the resolver to report unresolvable).
363
+ info.position = positionOf(statement, sf, file);
364
+ if (statement.isExportEquals === true) {
365
+ summary.exportEquals = true;
366
+ summary.exportEqualsInfo = info;
367
+ }
368
+ else {
369
+ summary.defaultInfo = info;
370
+ summary.defaultAlias = seg === undefined ? undefined : seg.length === 1 ? { name: seg[0] } : { qualified: true };
371
+ }
372
+ continue;
373
+ }
374
+ // `declare global` (GlobalAugmentation) and `declare module "literal
375
+ // name"` (a StringLiteral name) bind ambient names no import ever
376
+ // names - a plain `namespace X {}`/`declare namespace X {}` (an
377
+ // Identifier name, handled below with every other ordinary
378
+ // declaration) is reachable the normal way and must not double up
379
+ // here.
380
+ if (ts.isModuleDeclaration(statement) &&
381
+ (statement.name.kind === ts.SyntaxKind.StringLiteral || (statement.flags & ts.NodeFlags.GlobalAugmentation) !== 0)) {
382
+ const info = newInfo();
383
+ collect(statement, info, false, sf, sourceFileOptions.compilerOptions);
384
+ summary.ambient.push(info);
385
+ continue;
386
+ }
387
+ if (ts.isVariableStatement(statement)) {
388
+ for (const declaration of statement.declarationList.declarations) {
389
+ const info = newInfo();
390
+ collect(declaration, info, false, sf, sourceFileOptions.compilerOptions);
391
+ // Each bound name gets its OWN position (bindingElements' own
392
+ // comment) - a shared `info` object would give every name
393
+ // destructured from the same declarator the same position,
394
+ // which the checker itself never does.
395
+ for (const { name, node } of bindingElements(declaration.name, declaration, [])) {
396
+ addDecl(name, { refs: info.refs, imports: info.imports, position: positionOf(node, sf, file) });
397
+ if (hasExportModifier(statement))
398
+ summary.exportsLocal.set(name, name);
399
+ }
400
+ }
401
+ continue;
402
+ }
403
+ if (ts.isInterfaceDeclaration(statement) || ts.isTypeAliasDeclaration(statement) || ts.isClassDeclaration(statement) ||
404
+ ts.isFunctionDeclaration(statement) || ts.isEnumDeclaration(statement) || ts.isModuleDeclaration(statement)) {
405
+ const name = statement.name !== undefined && ts.isIdentifier(statement.name) ? statement.name.text : undefined;
406
+ const info = newInfo();
407
+ collect(statement, info, false, sf, sourceFileOptions.compilerOptions);
408
+ info.position = positionOf(statement, sf, file);
409
+ const local = name ?? "default";
410
+ addDecl(local, info);
411
+ if (hasExportModifier(statement)) {
412
+ if (hasDefaultModifier(statement))
413
+ summary.exportsLocal.set("default", local);
414
+ else
415
+ summary.exportsLocal.set(local, local);
416
+ }
417
+ continue;
418
+ }
419
+ // An expression statement, a bare block, ... - not a declaration and
420
+ // not a form any rule above needs; a script file's own effect (R6,
421
+ // below) never depends on what a statement here says.
422
+ }
423
+ summaries.set(file, summary);
424
+ return summary;
425
+ }
426
+ // A resolved edge that isn't real Program source (module-graph.ts's own
427
+ // resolvedSpecifiers can carry one - a resolved .js with no declaration
428
+ // file, a JSON import, ...) is not a target either builder below can ever
429
+ // load - lifted to module scope (not a closure over one `inputs`) so both
430
+ // `buildTypeClosure` and `computeSyntacticNamedDeclarations` share the identical
431
+ // answer for the identical edge, never two independently-written copies
432
+ // that could drift.
433
+ function resolveTarget(inputs, file, specifier) {
434
+ const target = inputs.resolvedSpecifiers.get(file)?.get(specifier.key);
435
+ return target !== undefined && isProgramSource(target) ? target : undefined;
436
+ }
437
+ // Whether `file` exports `name` at all, following `export *` (a cyclic
438
+ // chain answers false past its own start - the same reasoning
439
+ // `reachExport`'s own `done` guard applies, restated per-call here since
440
+ // this can run inside more than one root's own walk). This function
441
+ // alone does NOT enforce real ESM's own rule that `export *` never
442
+ // carries a "default" - it still answers true for a star target with a
443
+ // direct default of its own (the `name === "default" &&
444
+ // summary.defaultInfo !== undefined` disjunct below matches on THAT
445
+ // target file directly, regardless of how it was reached). `reachExport`
446
+ // above tolerates the resulting over-inclusion (a star-only re-export of
447
+ // "default" it can never really satisfy still marks the target file
448
+ // reached) since a bigger-than-needed closure is still a correct one;
449
+ // `computeSyntacticNamedDeclarations`'s own resolveNamed cannot tolerate
450
+ // that for a NAMED answer, so it never lets `name === "default"` reach
451
+ // this function's own stars branch at all (`resolveNamed`'s own comment
452
+ // on `export *` and "default" has the caller-side guard).
453
+ function hasExport(inputs, summaries, file, name, seen = new Set(), visitedFiles) {
454
+ if (seen.has(file))
455
+ return false;
456
+ seen.add(file);
457
+ visitedFiles?.add(file);
458
+ const summary = summarize(inputs, summaries, file);
459
+ if (summary.exportEquals)
460
+ return true;
461
+ if (summary.exportsLocal.has(name) || summary.reexports.has(name) || (name === "default" && summary.defaultInfo !== undefined))
462
+ return true;
463
+ if (name === "default")
464
+ return false;
465
+ return summary.stars.some((spec) => {
466
+ const target = resolveTarget(inputs, file, spec);
467
+ return target !== undefined && hasExport(inputs, summaries, target, name, seen, visitedFiles);
468
+ });
469
+ }
470
+ export function buildTypeClosure(inputs) {
471
+ const summaries = new Map();
472
+ const closure = new Set();
473
+ const done = new Set();
474
+ function mark(file) { closure.add(file); }
475
+ function resolve(file, specifier) {
476
+ return resolveTarget(inputs, file, specifier);
477
+ }
478
+ // A declaration actually reached: every rule-followed reference/import
479
+ // is dispatched. module-graph.ts's own safety net (a resolution failure
480
+ // rule 6 itself reports while walking the closure Program this
481
+ // function's own output becomes a root list for - see
482
+ // rules/type-leak.ts's own ReportUnresolvedReference) is a separate
483
+ // pass over the real Program, not this syntactic walk; a net built from
484
+ // the same rules it is meant to catch a gap in could never fire.
485
+ //
486
+ // The inference rule reaches only the identifiers the declaration's
487
+ // initializer or body references, resolved the same way every other
488
+ // reference is - `collect` (above) already recorded every one of them
489
+ // into `info.refs` while walking an unannotated declaration's own
490
+ // value position, so the loop below needs no separate step for it.
491
+ // Reaching every import binding of the file instead doubles rule 6's
492
+ // own Program on a 23,000-file codebase, with no change in findings:
493
+ // every identifier that can shape an inferred type already appears in
494
+ // the expression itself.
495
+ function processInfo(file, info) {
496
+ for (const ref of info.refs)
497
+ reachRef(file, ref);
498
+ for (const use of info.imports) {
499
+ const target = resolve(file, use);
500
+ if (target === undefined)
501
+ continue;
502
+ if (use.qualifier !== undefined)
503
+ reachExport(target, use.qualifier[0], use.qualifier.slice(1));
504
+ else
505
+ reachWhole(target);
506
+ }
507
+ }
508
+ function reachRef(file, segments) {
509
+ const summary = summarize(inputs, summaries, file);
510
+ const [head, ...rest] = segments;
511
+ if (head === undefined)
512
+ return;
513
+ if (summary.decls.has(head))
514
+ reachLocal(file, head);
515
+ const binding = summary.imports.get(head);
516
+ if (binding !== undefined) {
517
+ const target = resolve(file, binding);
518
+ if (target === undefined)
519
+ return;
520
+ if (binding.importedName === "*") {
521
+ if (rest.length > 0)
522
+ reachExport(target, rest[0], rest.slice(1));
523
+ else
524
+ reachWhole(target);
525
+ }
526
+ else {
527
+ reachExport(target, binding.importedName, rest);
528
+ }
529
+ }
530
+ }
531
+ function reachLocal(file, name) {
532
+ const key = `${file}\0L\0${name}`;
533
+ if (done.has(key))
534
+ return;
535
+ done.add(key);
536
+ mark(file);
537
+ const summary = summarize(inputs, summaries, file);
538
+ for (const info of summary.decls.get(name) ?? [])
539
+ processInfo(file, info);
540
+ // A declaration and an import can share one local name only when the
541
+ // import is itself the declaration (there is no local decls entry
542
+ // for a plain re-bound import name) - checked regardless, since a
543
+ // name that is only ever an import binding never appears in `decls`
544
+ // at all, and this call would otherwise never follow it.
545
+ if (summary.imports.has(name))
546
+ reachRef(file, [name]);
547
+ }
548
+ function reachExport(file, name, rest = []) {
549
+ const key = `${file}\0E\0${name}\0${rest.join(".")}`;
550
+ if (done.has(key))
551
+ return;
552
+ done.add(key);
553
+ const summary = summarize(inputs, summaries, file);
554
+ if (summary.missing)
555
+ return;
556
+ if (summary.exportEquals) {
557
+ mark(file);
558
+ if (summary.exportEqualsInfo !== undefined)
559
+ processInfo(file, summary.exportEqualsInfo);
560
+ reachWhole(file);
561
+ return;
562
+ }
563
+ const local = summary.exportsLocal.get(name);
564
+ if (local !== undefined) {
565
+ mark(file);
566
+ // `export { ns }` where `ns` is itself a namespace import: the
567
+ // export chain's own target is the imported module, qualified by
568
+ // whatever the caller still needs past this hop.
569
+ if (rest.length > 0 && summary.imports.get(local)?.importedName === "*")
570
+ reachRef(file, [local, ...rest]);
571
+ else
572
+ reachLocal(file, local);
573
+ return;
574
+ }
575
+ if (name === "default" && summary.defaultInfo !== undefined) {
576
+ mark(file);
577
+ processInfo(file, summary.defaultInfo);
578
+ return;
579
+ }
580
+ const reexport = summary.reexports.get(name);
581
+ if (reexport !== undefined) {
582
+ mark(file);
583
+ const target = resolve(file, reexport);
584
+ if (target === undefined)
585
+ return;
586
+ if (reexport.importedName === "*") {
587
+ if (rest.length > 0)
588
+ reachExport(target, rest[0], rest.slice(1));
589
+ else
590
+ reachAllExports(target);
591
+ }
592
+ else {
593
+ reachExport(target, reexport.importedName, rest);
594
+ }
595
+ return;
596
+ }
597
+ for (const spec of summary.stars) {
598
+ const target = resolve(file, spec);
599
+ if (target !== undefined && hasExport(inputs, summaries, target, name)) {
600
+ mark(file);
601
+ reachExport(target, name, rest);
602
+ }
603
+ }
604
+ }
605
+ function reachAllExports(file) {
606
+ const key = `${file}\0AE`;
607
+ if (done.has(key))
608
+ return;
609
+ done.add(key);
610
+ const summary = summarize(inputs, summaries, file);
611
+ if (summary.missing)
612
+ return;
613
+ mark(file);
614
+ for (const name of summary.exportsLocal.keys())
615
+ reachExport(file, name);
616
+ for (const name of summary.reexports.keys())
617
+ reachExport(file, name);
618
+ if (summary.defaultInfo !== undefined)
619
+ reachExport(file, "default");
620
+ if (summary.exportEquals)
621
+ reachExport(file, "=");
622
+ for (const spec of summary.stars) {
623
+ const target = resolve(file, spec);
624
+ if (target !== undefined)
625
+ reachAllExports(target);
626
+ }
627
+ }
628
+ function reachWhole(file) {
629
+ const key = `${file}\0W`;
630
+ if (done.has(key))
631
+ return;
632
+ done.add(key);
633
+ const summary = summarize(inputs, summaries, file);
634
+ if (summary.missing)
635
+ return;
636
+ mark(file);
637
+ reachAllExports(file);
638
+ for (const name of summary.decls.keys())
639
+ reachLocal(file, name);
640
+ for (const info of summary.ambient)
641
+ processInfo(file, info);
642
+ }
643
+ for (const file of inputs.surfaceFiles)
644
+ reachAllExports(file);
645
+ for (const file of inputs.extraRoots ?? [])
646
+ reachWhole(file);
647
+ // Ambient roots: a script file (no import, no export at all) or a file
648
+ // holding `declare global`/`declare module "..."` binds names no
649
+ // import statement ever names, so nothing above can discover it by
650
+ // following an edge - `inputs.ambientFiles` already names every one
651
+ // (module-graph.ts's own per-file walk flagged each from real syntax,
652
+ // during the pass every build already makes over every analyzed file);
653
+ // this loop parses exactly these, never the rest.
654
+ //
655
+ // reachAmbientRoot reaches only the ambient content itself
656
+ // (`summary.ambient`, every `declare global`/`declare module "..."`
657
+ // body) and, for an actual script (no import/export at all - the
658
+ // checker treats it as a global scope, not a module), every one of its
659
+ // top-level declarations - never `reachAllExports` and never every
660
+ // `decls` key unconditionally: an ordinary MODULE that also augments the
661
+ // global scope (`export {}; declare global { ... }`) binds nothing
662
+ // ambient beyond that block, so its own unrelated exports and locals
663
+ // stay reachable the normal way, by whoever actually imports them - not
664
+ // forced in as a root just because the same file also happens to hold a
665
+ // `declare global`. `processInfo` on each reached declaration already
666
+ // follows every type position, inferred reference, and import it
667
+ // actually uses, the same as any other reached declaration - a plain
668
+ // import at the top of an ambient file joins the closure only when one
669
+ // of these declarations references it, never unconditionally.
670
+ function reachAmbientRoot(file) {
671
+ const key = `${file}\0AR`;
672
+ if (done.has(key))
673
+ return;
674
+ done.add(key);
675
+ const summary = summarize(inputs, summaries, file);
676
+ if (summary.missing)
677
+ return;
678
+ mark(file);
679
+ for (const info of summary.ambient)
680
+ processInfo(file, info);
681
+ if (summary.isScript) {
682
+ for (const name of summary.decls.keys())
683
+ reachLocal(file, name);
684
+ }
685
+ }
686
+ for (const file of inputs.ambientFiles)
687
+ reachAmbientRoot(file);
688
+ return { files: [...closure].sort() };
689
+ }
690
+ // The one place a checker-derived declaration (type-leak.ts's own
691
+ // `collectNamedDeclarations`) and a syntactically-parsed one (this
692
+ // module's own `computeSyntacticNamedDeclarations`, below) are turned into
693
+ // the same string, so the two can be compared - or merged into one set -
694
+ // by value, never by object identity (a syntactic parse has no bound
695
+ // Program, so it can never share a node reference with the checker's
696
+ // own). Position alone (file, 1-based line, 1-based column of the
697
+ // declaration's own start) is enough: two different declarations can
698
+ // never share one file's one offset, so a declared NAME is never part of
699
+ // the key at all.
700
+ export function declarationKey(file, line, column) {
701
+ return `${file.replaceAll("\\", "/")}\0${line}\0${column}`;
702
+ }
703
+ // A SourceFile and its first declaration can start at the same offset.
704
+ // Reusing a position key is refused because it can make that declaration public.
705
+ export function sourceFileKey(file) {
706
+ return `${file.replaceAll("\\", "/")}\0<sourcefile>`;
707
+ }
708
+ // The syntactic twin of type-leak.ts's own `collectNamedDeclarations`:
709
+ // every declaration a consumer can already reach under some public name,
710
+ // computed from syntax alone (export chains, `export *`, aliases - the
711
+ // same rules `reachExport`/`hasExport` above already follow for file
712
+ // reachability), instead of a bound checker walking a real Program. Exists
713
+ // so rule 6 can be scoped to one module's own surface files - a closure
714
+ // Program that never loads every OTHER surface's own files still needs to
715
+ // know every name those other surfaces expose, the one thing scoping the
716
+ // Program cannot also scope away (a type a consumer can already import
717
+ // from a module outside the closure is still not a leak).
718
+ //
719
+ // `visitedFiles` contains each file whose export syntax can affect the
720
+ // answer. Returning this set is required because an augmentation can add
721
+ // a name there. Treating every analyzed augmentation as relevant is
722
+ // refused because ambient roots already apply unrelated augmentations.
723
+ //
724
+ // `unresolvable`: true the moment this walk crosses a shape it cannot
725
+ // answer as confidently as the checker would (an `export =` target, an
726
+ // `import x = SomeNamespace.Y` export, or a specifier reached from a
727
+ // non-analyzed project file whose edges were not recorded - DeclInfo's own
728
+ // `isImportEquals` comment covers the second case). The caller falls back to
729
+ // treating every surface as a closure root and the checker's own named set
730
+ // when this is true, the same as if scoping had never been requested. Silently
731
+ // omitting a name here instead would read an already-named declaration as
732
+ // unnamed on its next surface - a false leak - so an unresolvable shape stops
733
+ // the whole computation rather than under-reporting one name and continuing.
734
+ export function computeSyntacticNamedDeclarations(inputs, surfaceFiles) {
735
+ const summaries = new Map();
736
+ const keys = new Set();
737
+ const visitedFiles = new Set();
738
+ let unresolvable = false;
739
+ const resolvedAllExportsOf = new Set();
740
+ const resolvedNames = new Set();
741
+ // The graph records specifier resolutions only for analyzed files. A
742
+ // non-analyzed project file reached through one of those edges can therefore
743
+ // have real declarations beyond a missing record. Guessing that it exports
744
+ // nothing would create false leaks. An analyzed file's missing record is a
745
+ // real unresolved or non-program target, and an external package file can
746
+ // safely keep the checker's own no-declaration answer.
747
+ function resolveWalkTarget(file, specifier) {
748
+ const target = resolveTarget(inputs, file, specifier);
749
+ if (target === undefined && !inputs.analyzedFiles.has(file) &&
750
+ !file.split(/[\\/]/).includes("node_modules"))
751
+ unresolvable = true;
752
+ return target;
753
+ }
754
+ function addDeclInfo(info) {
755
+ // Every DeclInfo this walk can reach `summarize` sets a position on -
756
+ // `isImportEquals` is the one shape known in advance to need the
757
+ // fallback instead (DeclInfo's own comment); a missing position on
758
+ // any other shape would be this module's own bug, not a real project
759
+ // input, so it takes the same safe path rather than silently
760
+ // dropping the name.
761
+ if (info.isImportEquals === true || info.position === undefined) {
762
+ unresolvable = true;
763
+ return;
764
+ }
765
+ keys.add(declarationKey(info.position.file, info.position.line, info.position.column));
766
+ }
767
+ // A namespace-shaped resolution names the target SourceFile. A position
768
+ // key is refused because the first declaration can start at the same offset.
769
+ function addSourceFileKey(target) {
770
+ visitedFiles.add(target);
771
+ keys.add(sourceFileKey(target));
772
+ }
773
+ // `export default <expr>` where `expr` is a single identifier
774
+ // (`export default Foo;`) is an alias - resolved exactly like a named
775
+ // export of that local name (FileSummary's own `defaultAlias`
776
+ // comment), never at the ExportAssignment's own position. Every other
777
+ // shape (a class/function expression, a literal, ...) keeps the
778
+ // ExportAssignment's own position, already on `defaultInfo`.
779
+ function resolveDefault(file, summary) {
780
+ const alias = summary.defaultAlias;
781
+ if (alias === undefined) {
782
+ addDeclInfo(summary.defaultInfo);
783
+ return;
784
+ }
785
+ if ("qualified" in alias) {
786
+ unresolvable = true;
787
+ return;
788
+ }
789
+ const decls = summary.decls.get(alias.name);
790
+ if (decls !== undefined) {
791
+ for (const info of decls)
792
+ addDeclInfo(info);
793
+ return;
794
+ }
795
+ const binding = summary.imports.get(alias.name);
796
+ if (binding !== undefined) {
797
+ const target = resolveWalkTarget(file, binding);
798
+ if (target === undefined)
799
+ return;
800
+ if (binding.importedName === "*") {
801
+ addSourceFileKey(target);
802
+ return;
803
+ }
804
+ resolveNamed(target, binding.importedName);
805
+ return;
806
+ }
807
+ unresolvable = true;
808
+ }
809
+ // Resolves `file`'s own export named `name` to every real declaration
810
+ // it names - the same priority order `reachExport` follows above (a
811
+ // local export, then `default`, then a named re-export, then `export
812
+ // *`, first match in declared order wins), which is what makes this
813
+ // agree with a real checker on which of two `export *` sources naming
814
+ // the same identifier wins, and on a local export that shadows one.
815
+ function resolveNamed(file, name) {
816
+ const key = `${file}\0${name}`;
817
+ if (resolvedNames.has(key))
818
+ return;
819
+ resolvedNames.add(key);
820
+ visitedFiles.add(file);
821
+ const summary = summarize(inputs, summaries, file);
822
+ if (summary.missing)
823
+ return; // an edge with no real target: the checker's own symbol there has no declarations either
824
+ if (summary.exportEquals) {
825
+ unresolvable = true;
826
+ return;
827
+ }
828
+ const local = summary.exportsLocal.get(name);
829
+ if (local !== undefined) {
830
+ const decls = summary.decls.get(local);
831
+ if (decls !== undefined) {
832
+ for (const info of decls)
833
+ addDeclInfo(info);
834
+ return;
835
+ }
836
+ const binding = summary.imports.get(local);
837
+ if (binding !== undefined) {
838
+ const target = resolveWalkTarget(file, binding);
839
+ if (target === undefined)
840
+ return;
841
+ // A re-exported namespace import (`import * as NS from "./x";
842
+ // export { NS };`): the checker's own declaration for it is the
843
+ // target's own SourceFile, the same as `export * as ns` below.
844
+ if (binding.importedName === "*") {
845
+ addSourceFileKey(target);
846
+ return;
847
+ }
848
+ resolveNamed(target, binding.importedName);
849
+ return;
850
+ }
851
+ // `exportsLocal` named a local that is neither a real declaration
852
+ // nor an import binding - not a shape this walk expects to exist;
853
+ // treated as unresolvable rather than guessed at.
854
+ unresolvable = true;
855
+ return;
856
+ }
857
+ if (name === "default" && summary.defaultInfo !== undefined) {
858
+ resolveDefault(file, summary);
859
+ return;
860
+ }
861
+ const reexport = summary.reexports.get(name);
862
+ if (reexport !== undefined) {
863
+ const target = resolveWalkTarget(file, reexport);
864
+ if (target === undefined)
865
+ return;
866
+ // `export * as ns from "./x"`: the checker's own declaration for
867
+ // `ns` is `./x`'s own SourceFile (measured directly against a real
868
+ // checker), not any one declaration inside it.
869
+ if (reexport.importedName === "*") {
870
+ addSourceFileKey(target);
871
+ return;
872
+ }
873
+ resolveNamed(target, reexport.importedName);
874
+ return;
875
+ }
876
+ // `export *` never carries a "default" of its own - hasExport's own
877
+ // header - so a plain `export {default as X} from` a star-only
878
+ // source resolves to nothing here too, matching a real checker
879
+ // exactly (measured directly: `getExportsOfModule` gives that name no
880
+ // declarations at all in that shape).
881
+ if (name === "default")
882
+ return;
883
+ for (const spec of summary.stars) {
884
+ const target = resolveWalkTarget(file, spec);
885
+ if (target !== undefined && hasExport(inputs, summaries, target, name, new Set(), visitedFiles)) {
886
+ resolveNamed(target, name);
887
+ return;
888
+ }
889
+ }
890
+ }
891
+ // Every name reachable through `file`'s own `export *` chain (not
892
+ // `file`'s own direct names - callers already have those from
893
+ // `exportsLocal`/`reexports`/`defaultInfo` directly) - never "default"
894
+ // (hasExport's own header: `export *` carries no default). `seen`
895
+ // guards a cyclic chain the same way `hasExport`'s own does.
896
+ function collectStarNames(file, seen = new Set()) {
897
+ const names = new Set();
898
+ if (seen.has(file))
899
+ return names;
900
+ seen.add(file);
901
+ visitedFiles.add(file);
902
+ const summary = summarize(inputs, summaries, file);
903
+ if (summary.missing)
904
+ return names;
905
+ for (const name of summary.exportsLocal.keys())
906
+ names.add(name);
907
+ for (const name of summary.reexports.keys())
908
+ names.add(name);
909
+ for (const spec of summary.stars) {
910
+ const target = resolveWalkTarget(file, spec);
911
+ if (target !== undefined)
912
+ for (const name of collectStarNames(target, seen))
913
+ names.add(name);
914
+ }
915
+ return names;
916
+ }
917
+ // Every name `file` itself claims to export, including one it only
918
+ // gets through `export *` - the syntactic mirror of `reachAllExports`
919
+ // above, over export NAMES instead of files to load. Every name -
920
+ // direct or star-inherited alike - resolves through `resolveNamed(file,
921
+ // name)`, never `resolveNamed(target, name)` on a star's own target
922
+ // directly: `resolveNamed`'s own stars loop is what decides which of
923
+ // several `export *` sources naming the same identifier wins (the
924
+ // first, in declared order) - calling straight into a star's own
925
+ // target here instead would add every one of them, the same
926
+ // over-inclusion `reachAllExports` above tolerates for file
927
+ // reachability (see hasExport's own header) but a NAMED answer cannot:
928
+ // the checker names exactly one declaration for a colliding name, not
929
+ // every source that happens to offer one.
930
+ function resolveAllExportsOf(file) {
931
+ if (resolvedAllExportsOf.has(file))
932
+ return;
933
+ resolvedAllExportsOf.add(file);
934
+ visitedFiles.add(file);
935
+ const summary = summarize(inputs, summaries, file);
936
+ if (summary.missing)
937
+ return;
938
+ if (summary.exportEquals) {
939
+ unresolvable = true;
940
+ return;
941
+ }
942
+ for (const name of summary.exportsLocal.keys())
943
+ resolveNamed(file, name);
944
+ for (const name of summary.reexports.keys())
945
+ resolveNamed(file, name);
946
+ if (summary.defaultInfo !== undefined)
947
+ resolveNamed(file, "default");
948
+ for (const spec of summary.stars) {
949
+ const target = resolveWalkTarget(file, spec);
950
+ if (target === undefined)
951
+ continue;
952
+ for (const name of collectStarNames(target))
953
+ resolveNamed(file, name);
954
+ }
955
+ }
956
+ for (const file of surfaceFiles)
957
+ resolveAllExportsOf(file);
958
+ return { keys, visitedFiles, unresolvable };
959
+ }