archstrict 0.0.0 → 0.2.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 (67) 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 +81 -0
  7. package/CHANGELOG.md +77 -0
  8. package/README.ja.md +142 -0
  9. package/README.md +143 -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 +243 -0
  14. package/dist/config-pointer.js +251 -0
  15. package/dist/config.js +194 -0
  16. package/dist/edge-cache.js +530 -0
  17. package/dist/gitignore.js +271 -0
  18. package/dist/mcp-server.js +111 -0
  19. package/dist/module-candidates.js +125 -0
  20. package/dist/module-graph.js +2179 -0
  21. package/dist/project-path.js +59 -0
  22. package/dist/report-error.js +13 -0
  23. package/dist/rules/config-meaning.js +143 -0
  24. package/dist/rules/constraints.js +419 -0
  25. package/dist/rules/cycles.js +285 -0
  26. package/dist/rules/deprecated.js +67 -0
  27. package/dist/rules/empty-rule.js +101 -0
  28. package/dist/rules/moves.js +79 -0
  29. package/dist/rules/must-be-empty.js +52 -0
  30. package/dist/rules/public-surface.js +100 -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/type-leak.js +590 -0
  36. package/dist/verbs/agents.js +116 -0
  37. package/dist/verbs/check.js +1011 -0
  38. package/dist/verbs/fix.js +170 -0
  39. package/dist/verbs/hotspots.js +261 -0
  40. package/dist/verbs/init.js +538 -0
  41. package/dist/verbs/map-shape.js +78 -0
  42. package/dist/verbs/recommend.js +863 -0
  43. package/dist/verbs/rules.js +188 -0
  44. package/dist/verbs/search.js +109 -0
  45. package/dist/verbs/simulate.js +220 -0
  46. package/dist/verbs/todo.js +180 -0
  47. package/dist/warm-graph.js +82 -0
  48. package/docs/boundary-patterns.md +374 -0
  49. package/docs/calibrated-rules-design.md +124 -0
  50. package/docs/init-singleton-modules.md +133 -0
  51. package/docs/maintenance.md +109 -0
  52. package/docs/releasing.md +58 -0
  53. package/docs/rules-edge-cache.md +50 -0
  54. package/docs/todo-single-file-migration.md +58 -0
  55. package/llms.txt +25 -0
  56. package/package.json +61 -4
  57. package/skills/archstrict/SKILL.md +54 -0
  58. package/skills/archstrict/references/agents-verb.md +39 -0
  59. package/skills/archstrict/references/config.md +116 -0
  60. package/skills/archstrict/references/hook.md +57 -0
  61. package/skills/archstrict/references/path-rules.md +57 -0
  62. package/skills/archstrict/references/patterns.md +915 -0
  63. package/skills/archstrict/references/prove-rules.md +58 -0
  64. package/skills/archstrict/references/rearchitect.md +66 -0
  65. package/skills/archstrict/references/recommend.md +98 -0
  66. package/skills/archstrict/references/rules.md +149 -0
  67. package/skills/archstrict/references/simulate.md +109 -0
@@ -0,0 +1,590 @@
1
+ // Responsibility: rule 6, type leak. A module's public surface re-exports
2
+ // or otherwise exposes an internal declaration - one that lives outside
3
+ // the surface file, inside this project's own checked source - without the
4
+ // consumer having a name for it. "A name" means any name a declared
5
+ // module's own surface assigns it, resolved through an aliased re-export
6
+ // (`export { X as Y }`) and through a re-export chain, from that same
7
+ // module's surface or from any OTHER declared module's surface (a
8
+ // consumer that can already `import { Y } from "b"` has a name for the
9
+ // type, whichever module's surface structurally reaches it) - a
10
+ // declaration reached through a `node_modules` segment relative to that
11
+ // boundary is never internal either, whatever a module's glob base
12
+ // happens to contain (the consumer names it from the package, not from
13
+ // this project's own boundary - see isInternal's own boundary-check
14
+ // comment for why this is scoped to the matched boundary root, not the
15
+ // declaration's bare absolute path). Promoted from a spike
16
+ // once its definition settled (three candidates converged on one general
17
+ // case: a structural leak, which subsumes an inferred-return-type leak and
18
+ // a generic-parameter leak as named subsets, kept as their own `via` tag
19
+ // rather than a separate rule each).
20
+ // Boundary: pure predicate over a ModuleGraph (its shared program and
21
+ // checker) and a boundary root. No I/O, no output formatting.
22
+ // Lives next to the graph builder, not under src/rules: module-graph.ts
23
+ // calls checkTypeLeaks while it builds the closure. A rules-module home
24
+ // would make that call a cycle with every other rule that reads the graph.
25
+ import ts from "typescript";
26
+ import { relative } from "node:path";
27
+ import { declarationKey, sourceFileKey } from "./type-closure.js";
28
+ function declaredIn(symbol) {
29
+ return symbol.getDeclarations()?.[0]?.getSourceFile().fileName;
30
+ }
31
+ // TypeScript's own sentinel for "this alias could not be resolved to a
32
+ // real symbol" (its internal `unknownSymbol`) - not a project symbol at
33
+ // all: name "unknown", no declarations, reached whenever
34
+ // `getAliasedSymbol` runs out of a real target to point to (measured
35
+ // directly against a broken import). Detected by shape, since the public
36
+ // API exposes no dedicated flag for it.
37
+ function isUnresolvedAliasTarget(symbol) {
38
+ return symbol.name === "unknown" && symbol.getDeclarations() === undefined;
39
+ }
40
+ // A module specifier, its position, and the file it was written in, recovered by
41
+ // walking up from an unresolved alias's own declaration (an
42
+ // ImportSpecifier, a NamespaceImport, or similar) to its nearest
43
+ // import/export declaration - the same values module-graph.ts uses to find
44
+ // an edge, so a caller can map this straight to a mode-aware
45
+ // resolved file without resolving the specifier itself again.
46
+ function specifierOf(symbol) {
47
+ let node = symbol.getDeclarations()?.[0];
48
+ const declaration = node;
49
+ if (declaration === undefined)
50
+ return undefined;
51
+ while (node !== undefined && !ts.isImportDeclaration(node) && !ts.isExportDeclaration(node))
52
+ node = node.parent;
53
+ if (node === undefined || node.moduleSpecifier === undefined || !ts.isStringLiteral(node.moduleSpecifier))
54
+ return undefined;
55
+ const sourceFile = declaration.getSourceFile();
56
+ const start = node.moduleSpecifier.getStart(sourceFile);
57
+ const { line, character } = sourceFile.getLineAndCharacterOfPosition(start);
58
+ return {
59
+ file: sourceFile.fileName,
60
+ specifier: node.moduleSpecifier.text,
61
+ fromPosition: { line: line + 1, column: character + 1 },
62
+ };
63
+ }
64
+ function reportIfUnresolved(symbol, target, report) {
65
+ if (report === undefined || !isUnresolvedAliasTarget(target))
66
+ return;
67
+ const ref = specifierOf(symbol);
68
+ if (ref !== undefined)
69
+ report(ref);
70
+ }
71
+ // A re-export's own alias (`export { X as Y } from "./z.js"`, and a chain
72
+ // of those across several files - `verbs/index.ts` re-exporting a type
73
+ // `rules/index.ts` itself re-exported) is unwrapped one hop at a time by
74
+ // `getAliasedSymbol` - looping here follows the chain all the way to the
75
+ // symbol whose own declarations are the real, original ones, which is
76
+ // what `isInternal` below needs to compare against.
77
+ function resolveAlias(checker, symbol, report) {
78
+ let current = symbol;
79
+ while (current.flags & ts.SymbolFlags.Alias) {
80
+ const next = checker.getAliasedSymbol(current);
81
+ if (next === current)
82
+ break; // defensive: an unresolvable alias must not loop forever
83
+ reportIfUnresolved(current, next, report);
84
+ current = next;
85
+ }
86
+ return current;
87
+ }
88
+ // Every declaration a consumer can already reach under SOME public name -
89
+ // gathered once per `checkTypeLeaks` run, over every declared module's own
90
+ // surface files, not just the leaking module's own: a type a consumer can
91
+ // already `import` from module B is not a leak in module A's surface
92
+ // either (the decision behind this: rule 6 exists so a consumer has a name
93
+ // for every type it receives, and a name from ANY declared module's own
94
+ // surface satisfies that, not only the exposing module's own). Keyed by
95
+ // declaration NODE, not by symbol object or by name - `resolveAlias`'s own
96
+ // target symbol is what a plain name-based check (the old
97
+ // `exportedNames.has(symbol.name)`) missed for `export { X as Y }`: Y's own
98
+ // exported symbol resolves to X's real declaration, and that declaration
99
+ // is what `isInternal` looks up its own candidate symbol's declarations
100
+ // against.
101
+ function collectNamedDeclarations(program, checker, surfaceFiles, report) {
102
+ const declarations = new Set();
103
+ for (const path of surfaceFiles) {
104
+ const sf = program.getSourceFile(path);
105
+ if (sf === undefined)
106
+ continue;
107
+ const moduleSymbol = checker.getSymbolAtLocation(sf);
108
+ if (moduleSymbol === undefined)
109
+ continue;
110
+ for (const exp of checker.getExportsOfModule(moduleSymbol)) {
111
+ const resolved = resolveAlias(checker, exp, report);
112
+ for (const decl of resolved.getDeclarations() ?? [])
113
+ declarations.add(decl);
114
+ }
115
+ }
116
+ return declarations;
117
+ }
118
+ // Namespace exports resolve to a SourceFile, which can share the first
119
+ // declaration's offset. A common position key is refused because it aliases them.
120
+ function namedDeclarationKey(node) {
121
+ if (ts.isSourceFile(node))
122
+ return sourceFileKey(node.fileName);
123
+ const sf = node.getSourceFile();
124
+ const start = node.getStart(sf);
125
+ const { line, character } = sf.getLineAndCharacterOfPosition(start);
126
+ return declarationKey(sf.fileName, line + 1, character + 1);
127
+ }
128
+ // A generic type reference (Promise<Internal>, Map<K, Internal>,
129
+ // Array<Internal>) is an Object type carrying the Reference object flag -
130
+ // a plain object literal type carries Object but not Reference.
131
+ function isTypeReference(type) {
132
+ return (type.flags & ts.TypeFlags.Object) !== 0 && (type.objectFlags & ts.ObjectFlags.Reference) !== 0;
133
+ }
134
+ // Detects every structural type leak reachable from `entrySf`'s own
135
+ // exports - the core algorithm, independent of archstrict's module
136
+ // concept, so it can run both per-module (checkTypeLeaks below) and
137
+ // directly against an arbitrary entry file (nukadoko's own src/index.ts,
138
+ // in test/type-leak.nukadoko.test.ts, matching this rule's own
139
+ // promoted-from-spike history).
140
+ export function detectTypeLeaks(checker, entrySf, boundaryRoot, siblingSurfaceFiles = [],
141
+ // Declarations exported by SOME OTHER declared module's own surface
142
+ // (checkTypeLeaks passes these in, gathered once per check with
143
+ // collectNamedDeclarations; a standalone caller such as the nukadoko
144
+ // harness passes none, so only this one file's own exports, folded in
145
+ // below, apply there).
146
+ externallyNamedDeclarations = new Set(),
147
+ // Reports every alias this walk cannot resolve (module-graph.ts's own
148
+ // closure Program safety net) - see UnresolvedReference's own comment
149
+ // for the seam this is. Absent for a standalone caller (the nukadoko
150
+ // harness): a real, whole-project Program never has this problem.
151
+ report,
152
+ // Public names from other surfaces arrive without checker nodes because
153
+ // loading their closures removes the scope benefit. Object identity is
154
+ // refused, so declarations match by stable file, line, and column keys.
155
+ externallyNamedDeclarationKeys = new Set()) {
156
+ const boundaryRoots = typeof boundaryRoot === "string" ? [boundaryRoot] : boundaryRoot;
157
+ const moduleSymbol = checker.getSymbolAtLocation(entrySf);
158
+ if (moduleSymbol === undefined)
159
+ return [];
160
+ const exports = checker.getExportsOfModule(moduleSymbol);
161
+ // This surface's own exports (resolved through an aliased or chained
162
+ // re-export, e.g. `export { type Violation as AViolation }`)
163
+ // union the caller's cross-module set. A candidate symbol with a
164
+ // declaration in this combined set already has a public name somewhere,
165
+ // under some name - not necessarily its own declared name.
166
+ const namedDeclarations = new Set(externallyNamedDeclarations);
167
+ for (const exp of exports) {
168
+ const resolved = resolveAlias(checker, exp, report);
169
+ for (const decl of resolved.getDeclarations() ?? [])
170
+ namedDeclarations.add(decl);
171
+ }
172
+ const leaks = [];
173
+ // Only a genuine, importable named type declaration is a leak candidate
174
+ // at all - a type alias, interface, class, or enum. A method or function
175
+ // property's own "type" resolves to a symbol too (its call signature),
176
+ // with a real declaration site and a real name (the method's own name,
177
+ // e.g. "run"), but that name was never a *type* a consumer could import
178
+ // in the first place; it is structurally the same case an anonymous
179
+ // type literal already is, just with a name borrowed from the property
180
+ // rather than none at all. Measured directly against nukadoko's real
181
+ // `StepDefinitionInput.run` (an inline method signature written in its
182
+ // own interface body): without this check, `run`'s own declaration site
183
+ // (a different file than the surface) read as a leak of a type named
184
+ // "run", which nothing could ever "export by name" in any meaningful
185
+ // sense - a method is not re-exportable independent of its own type.
186
+ const NAMED_TYPE_DECLARATION = ts.SymbolFlags.TypeAlias | ts.SymbolFlags.Interface | ts.SymbolFlags.Class | ts.SymbolFlags.Enum;
187
+ function isInternal(symbol) {
188
+ // Also excludes a generic type parameter (a variable substituted at
189
+ // the call site, not a declaration - its own declaration site,
190
+ // wherever the generic function or type was written, is meaningless
191
+ // here) and an anonymous type literal (TS names it "__type",
192
+ // "__object", ... - it has no name a consumer could fail to import,
193
+ // its shape already fully expanded wherever it appears): TypeParameter
194
+ // and TypeLiteral are never among the flags above on the same symbol.
195
+ if (!(symbol.flags & NAMED_TYPE_DECLARATION))
196
+ return undefined;
197
+ const file = declaredIn(symbol);
198
+ if (file === undefined)
199
+ return undefined;
200
+ // A sibling surface also gives consumers a public path to the declaration.
201
+ if (file === entrySf.fileName || siblingSurfaceFiles.includes(file))
202
+ return undefined;
203
+ // Has a public name somewhere - this surface's own re-export (any
204
+ // name, any alias depth) or another declared module's surface.
205
+ const declarations = symbol.getDeclarations();
206
+ if (declarations?.some((d) => {
207
+ // Checker-owned names keep their exact node identity. Converting all
208
+ // names to strings is refused because the unscoped path already has nodes.
209
+ if (namedDeclarations.has(d))
210
+ return true;
211
+ // Other surfaces have no nodes in this Program. Loading them is refused
212
+ // because it rebuilds the all-surface closure that focus avoids.
213
+ return externallyNamedDeclarationKeys.has(namedDeclarationKey(d));
214
+ }))
215
+ return undefined;
216
+ // TS file names are always forward-slash; boundaryRoot comes from
217
+ // node:path's own join/dirname, which uses the platform separator on
218
+ // Windows - a plain startsWith would then read every declaration as
219
+ // outside the boundary there (the same class of bug module-graph.ts's
220
+ // own isWorkspaceSiblingResolution already hit and fixed with the same
221
+ // relative() check). This also closes a prefix hole a plain startsWith
222
+ // has even on one platform: "src-other" starts with "src" as a string.
223
+ //
224
+ // Under declared modules, "internal" means inside SOME declared
225
+ // module's own directory - not the whole project root. A single,
226
+ // broad rootDir boundary was measured wrong: under declared mode
227
+ // rootDir is the project root, so a root-level file's own type
228
+ // declarations (archstrict.config.ts, a test helper, ...) would
229
+ // incorrectly count as "this project's own checked source, must be
230
+ // exported by name" for every module's surface. A cross-module leak
231
+ // (module A's surface exposing a type declared in module B) still
232
+ // counts - that's still real, still worth a name - checked against
233
+ // every declared module's own boundary, not just the leaking
234
+ // module's own.
235
+ //
236
+ // A root-based glob ("**") makes a module's
237
+ // own directory the project root itself, which contains the project's
238
+ // real node_modules - a dependency's own type would then read as
239
+ // "inside the boundary" by the plain prefix test above, when the
240
+ // consumer already has a name for it from the package it imported it
241
+ // from, not from this project's own boundary to keep. Checked as a
242
+ // node_modules SEGMENT in the path relative to the matched boundary
243
+ // root specifically (not the declaration's absolute path): the
244
+ // standalone nukadoko harness below intentionally passes a boundary
245
+ // root that itself sits under node_modules (a real npm-installed
246
+ // package's own source, used as a convenient real-world fixture, not a
247
+ // dependency of the thing being analyzed) - a declaration inside THAT
248
+ // root is still internal to it, since nothing in the path AFTER the
249
+ // root names a nested node_modules of its own.
250
+ const isInsideAnyBoundary = boundaryRoots.some((root) => {
251
+ const rel = relative(root, file);
252
+ if (rel.startsWith("..") || rel === file)
253
+ return false; // rel === file: outside entirely (relative() returns the input unchanged across drives on Windows)
254
+ return !rel.split(/[\\/]/).includes("node_modules");
255
+ });
256
+ if (!isInsideAnyBoundary)
257
+ return undefined;
258
+ return { file };
259
+ }
260
+ // A property typed with a named alias whose target is a mapped/utility
261
+ // type resolves `getSymbol()` to that utility type's own anonymous
262
+ // shape, not the alias a consumer actually sees on hover - checking
263
+ // `aliasSymbol` first matches what a consumer's own experience of the
264
+ // type is.
265
+ //
266
+ // `seen` (shared with the walkStructural call that follows each
267
+ // checkType call, at every call site) is an optimization only, skipping
268
+ // a type object already flagged or already walked within this exported
269
+ // symbol's own recursion - it does not by itself guarantee no duplicate
270
+ // leak in the output, since TS does not always hand back the identical
271
+ // `ts.Type` object for what is conceptually the same declaration reached
272
+ // two structural ways (measured directly: an array's own type argument
273
+ // and its index signature's value type). `dedupe()` below, over the
274
+ // fully collected `leaks`, is what actually guarantees that.
275
+ function checkType(type, exportedAs, via, position, seen) {
276
+ if (seen.has(type))
277
+ return;
278
+ const sym = type.aliasSymbol ?? type.getSymbol();
279
+ if (sym === undefined)
280
+ return;
281
+ const internal = isInternal(sym);
282
+ if (internal !== undefined) {
283
+ leaks.push({ exportedAs, via, internalType: sym.name, internalFile: internal.file, ...position });
284
+ }
285
+ }
286
+ // Walks every type reachable from `type`'s own shape: its base types, its properties,
287
+ // its index signatures' value types, its own type arguments if it's a
288
+ // generic type reference (Promise<Internal>, Map<K, Internal>,
289
+ // Array<Internal> - the wrapper's own properties don't structurally
290
+ // contain the argument type the way a plain property does, so this is
291
+ // its own walk target, not covered by the properties loop below), and
292
+ // (for a union) every constituent - "any property, anywhere in an
293
+ // exported type's shape" requires all of these, not just direct
294
+ // properties one level down. `seen` guards against a type that
295
+ // references itself, directly or through a cycle of aliases.
296
+ function walkStructural(type, exportedAs, via, position, depth, seen) {
297
+ if (depth <= 0 || seen.has(type))
298
+ return;
299
+ seen.add(type);
300
+ if (type.isUnion()) {
301
+ for (const member of type.types) {
302
+ checkType(member, exportedAs, via, position, seen);
303
+ walkStructural(member, exportedAs, via, position, depth - 1, seen);
304
+ }
305
+ return;
306
+ }
307
+ // No early return here, unlike the union branch above: a union's own
308
+ // getPropertiesOfType is already the intersection of its members'
309
+ // properties, so walking it too would just re-walk what the member
310
+ // loop already covered. A type reference's own type arguments and its
311
+ // own properties are each real, independent parts of its shape - a
312
+ // user-defined generic like `Box<T> { value: T; other: Internal }`
313
+ // needs both walked, not just one.
314
+ if (isTypeReference(type)) {
315
+ for (const typeArg of checker.getTypeArguments(type)) {
316
+ checkType(typeArg, exportedAs, via, position, seen);
317
+ walkStructural(typeArg, exportedAs, via, position, depth - 1, seen);
318
+ }
319
+ }
320
+ if ((type.flags & ts.TypeFlags.Object) !== 0 &&
321
+ (type.objectFlags & ts.ObjectFlags.ClassOrInterface) !== 0) {
322
+ for (const baseType of checker.getBaseTypes(type) ?? []) {
323
+ checkType(baseType, exportedAs, via, position, seen);
324
+ walkStructural(baseType, exportedAs, via, position, depth - 1, seen);
325
+ }
326
+ }
327
+ for (const prop of checker.getPropertiesOfType(type)) {
328
+ const decl = prop.valueDeclaration ?? prop.getDeclarations()?.[0];
329
+ if (decl === undefined)
330
+ continue;
331
+ const propType = checker.getTypeOfSymbolAtLocation(prop, decl);
332
+ checkType(propType, exportedAs, via, position, seen);
333
+ walkStructural(propType, exportedAs, via, position, depth - 1, seen);
334
+ }
335
+ for (const indexInfo of checker.getIndexInfosOfType(type)) {
336
+ checkType(indexInfo.type, exportedAs, via, position, seen);
337
+ walkStructural(indexInfo.type, exportedAs, via, position, depth - 1, seen);
338
+ }
339
+ }
340
+ function walkSignatures(type, exportedAs, position) {
341
+ const signatures = [...type.getCallSignatures(), ...type.getConstructSignatures()];
342
+ for (const sig of signatures) {
343
+ const sigDecl = sig.getDeclaration();
344
+ const hasExplicitReturnType = sigDecl !== undefined && ts.isFunctionLike(sigDecl) && sigDecl.type !== undefined;
345
+ const returnType = checker.getReturnTypeOfSignature(sig);
346
+ const via = hasExplicitReturnType ? "structural" : "inferred-return";
347
+ // The return type itself may be internal, while the structural walk
348
+ // only inspects the return type's components.
349
+ const returnSeen = new Set();
350
+ checkType(returnType, exportedAs, via, position, returnSeen);
351
+ walkStructural(returnType, exportedAs, via, position, 2, returnSeen);
352
+ }
353
+ }
354
+ for (const symbol of exports) {
355
+ const decl = symbol.getDeclarations()?.[0];
356
+ if (decl === undefined)
357
+ continue;
358
+ // Position stays on the ORIGINAL symbol's own declaration (an
359
+ // ExportSpecifier, for `export type { Foo } from "./x.js"`) - that's
360
+ // the line in the surface file itself, matching `path: surfacePath`.
361
+ const start = decl.getStart(decl.getSourceFile());
362
+ const { line, character } = decl.getSourceFile().getLineAndCharacterOfPosition(start);
363
+ const position = { line: line + 1, column: character + 1 };
364
+ // A re-export (`export type { Foo } from "./internal.js"`, or
365
+ // `export { foo } from "./internal.js"`) is an Alias symbol whose own
366
+ // "declaration" is the ExportSpecifier, never a
367
+ // TypeAliasDeclaration/InterfaceDeclaration or a function/class - so
368
+ // without resolving through the alias, every re-exported symbol falls
369
+ // through to the function branch below, finds no call signatures, and
370
+ // is silently never walked at all. Resolving to the real target
371
+ // symbol (and ITS declaration) is what lets Foo's own shape - which
372
+ // may still structurally reach an internal type nothing re-exports -
373
+ // get walked, exactly as if it had been declared locally.
374
+ const resolvedSymbol = symbol.flags & ts.SymbolFlags.Alias ? checker.getAliasedSymbol(symbol) : symbol;
375
+ if (symbol.flags & ts.SymbolFlags.Alias)
376
+ reportIfUnresolved(symbol, resolvedSymbol, report);
377
+ const resolvedDecl = resolvedSymbol.getDeclarations()?.[0];
378
+ if (resolvedDecl === undefined)
379
+ continue;
380
+ if (ts.isTypeAliasDeclaration(resolvedDecl) || ts.isInterfaceDeclaration(resolvedDecl)) {
381
+ const type = checker.getDeclaredTypeOfSymbol(resolvedSymbol);
382
+ walkStructural(type, symbol.name, "structural", position, 3, new Set());
383
+ // Both declaration kinds carry `typeParameters`; gating on the
384
+ // alias kind alone would silently skip every interface's own
385
+ // generics.
386
+ if (resolvedDecl.typeParameters !== undefined) {
387
+ for (const tp of resolvedDecl.typeParameters) {
388
+ const constraintNode = tp.constraint ?? tp.default;
389
+ if (constraintNode === undefined)
390
+ continue;
391
+ const constraintType = checker.getTypeAtLocation(constraintNode);
392
+ const constraintSeen = new Set();
393
+ checkType(constraintType, symbol.name, "generic-parameter", position, constraintSeen);
394
+ walkStructural(constraintType, symbol.name, "generic-parameter", position, 2, constraintSeen);
395
+ }
396
+ }
397
+ continue;
398
+ }
399
+ // A plain exported binding and `export default <expression>` expose
400
+ // their value type directly. They have no signature for the branch below.
401
+ if (ts.isVariableDeclaration(resolvedDecl) || ts.isBindingElement(resolvedDecl) ||
402
+ ts.isExportAssignment(resolvedDecl)) {
403
+ const valueType = checker.getTypeOfSymbolAtLocation(resolvedSymbol, resolvedDecl);
404
+ const valueSeen = new Set();
405
+ checkType(valueType, symbol.name, "structural", position, valueSeen);
406
+ walkStructural(valueType, symbol.name, "structural", position, 3, valueSeen);
407
+ // A function-valued binding exposes its signature through the value type.
408
+ walkSignatures(valueType, symbol.name, position);
409
+ continue;
410
+ }
411
+ // Functions and classes: inspect call/construct signatures' return types.
412
+ const symbolType = checker.getTypeOfSymbolAtLocation(resolvedSymbol, resolvedDecl);
413
+ walkSignatures(symbolType, symbol.name, position);
414
+ }
415
+ return dedupe(leaks);
416
+ }
417
+ // The `seen` guard inside walkStructural/checkType avoids re-flagging the
418
+ // exact same `ts.Type` OBJECT twice within one exported symbol's own walk,
419
+ // but TS does not always hand back that same object for what is
420
+ // conceptually the same type reached two structural ways - measured
421
+ // directly on nukadoko's own `used?: UsedEntryWithResult[]`: an optional
422
+ // array property's own type argument and its index signature's value type
423
+ // resolve to two distinct `ts.Type` instances for the identical
424
+ // declaration, so object identity alone under-deduplicates. This same
425
+ // under-deduplication already existed before this file ever called
426
+ // `getTypeArguments` at all - the version of `checkType` before this
427
+ // change had no `seen` check whatsoever, so two different structural
428
+ // paths to the same declaration (measured: two union members, each
429
+ // independently reaching the same inherited property) each pushed their
430
+ // own leak. A content key (which export, which via, which internal
431
+ // declaration) is what a reader actually means by "the same finding"
432
+ // regardless of which internal TS object or which structural path
433
+ // produced it, and collapsing to that key also keeps a todo fingerprint
434
+ // (rule+path+evidence) from ever getting two entries for what reads as
435
+ // one violation. The key deliberately omits line/column: position is
436
+ // always the exported symbol's own declaration site, identical for every
437
+ // leak naming that `exportedAs`, so it adds nothing to distinguish by
438
+ // today - but if `evidence` ever grows a property-path breadcrumb (rule
439
+ // 6's own known gap: two distinct properties leaking the same internal
440
+ // type today read identically), this key would need one too, or it would
441
+ // start collapsing genuinely different findings instead of duplicates.
442
+ function dedupe(leaks) {
443
+ const seen = new Set();
444
+ const result = [];
445
+ for (const leak of leaks) {
446
+ const key = `${leak.exportedAs}\n${leak.via}\n${leak.internalType}\n${leak.internalFile}`;
447
+ if (seen.has(key))
448
+ continue;
449
+ seen.add(key);
450
+ result.push(leak);
451
+ }
452
+ return result;
453
+ }
454
+ const BECAUSE = "a consumer needs a name for every type it receives from a public surface, not just the type doing the exposing";
455
+ // The number of distinct exported symbols named in one violation's own
456
+ // evidence before it switches to "and N more" - a real, measured case (a
457
+ // heavily-generic library's own client package) had a single internal
458
+ // type referenced by 51 different exported symbols; naming all 51 in one
459
+ // evidence line stops being readable long before that.
460
+ const MAX_NAMED_EXPORTS = 10;
461
+ // The marker splitting evidence's own stable identity (which internal
462
+ // type, declared where, leaked from which module) from its mutable,
463
+ // informational suffix (which exports currently reach it - can grow or
464
+ // shrink as the source changes without the leak itself being new or
465
+ // gone). todo-store.ts's own fingerprintOf reads this to keep a frozen
466
+ // entry from reopening every time one more caller of an already-known
467
+ // leak appears - exported so the split lives in one place, not
468
+ // duplicated as a second copy of this exact string.
469
+ export const REFERENCED_BY_MARKER = " - referenced by ";
470
+ function groupByInternalType(findings, surfacePath) {
471
+ const groups = new Map();
472
+ for (const finding of findings) {
473
+ const key = `${finding.internalFile}\n${finding.internalType}`;
474
+ const existing = groups.get(key);
475
+ if (existing === undefined) {
476
+ groups.set(key, {
477
+ file: finding.internalFile,
478
+ type: finding.internalType,
479
+ path: surfacePath,
480
+ line: finding.line,
481
+ column: finding.column,
482
+ exportedAs: [finding.exportedAs],
483
+ });
484
+ continue;
485
+ }
486
+ if (!existing.exportedAs.includes(finding.exportedAs))
487
+ existing.exportedAs.push(finding.exportedAs);
488
+ // Earliest position wins, for a deterministic, stable anchor
489
+ // regardless of which surface file or which exported symbol the walk
490
+ // happened to visit first.
491
+ if (finding.line < existing.line || (finding.line === existing.line && finding.column < existing.column)) {
492
+ existing.line = finding.line;
493
+ existing.column = finding.column;
494
+ existing.path = surfacePath;
495
+ }
496
+ }
497
+ return groups;
498
+ }
499
+ // Two leaks look alike in evidence but need different fixes. A type this
500
+ // module owns needs only a name on this surface. A type owned by a module
501
+ // with no surface cannot get a public name from this surface at all: that
502
+ // module needs a surface, or this surface must stop exposing the type.
503
+ // Any other owner (a module with a surface that does not name the type,
504
+ // or a file no module declares) keeps the general three-way advice.
505
+ function leakDo(moduleName, group, relativeInternalFile, owner, modules, exportsShown) {
506
+ if (owner === moduleName) {
507
+ return `'${group.type}' belongs to module '${moduleName}': export '${group.type}' by name from ${group.path} (it's declared in ${relativeInternalFile})`;
508
+ }
509
+ if (owner !== undefined && (modules.get(owner)?.surfaceFiles.length ?? 0) === 0) {
510
+ return `${exportsShown} reaches '${group.type}', owned by module '${owner}', which has no surface: give '${owner}' a surface that exports '${group.type}', or drop ${exportsShown} from this surface`;
511
+ }
512
+ return `export '${group.type}' by name from ${group.path} (it's declared in ${relativeInternalFile}), change the referencing exports to not expose it, or add ${relativeInternalFile} to this module's own surface`;
513
+ }
514
+ export function checkTypeLeaks(graph, options = {}) {
515
+ // Forces graph.program/graph.checker first (as this always did): on a
516
+ // graph whose Program was not built yet, that is what populates
517
+ // `cachedTypeLeaks` as a side effect, in time for the check right after.
518
+ void graph.program;
519
+ void graph.checker;
520
+ if (options.report === undefined && graph.cachedTypeLeaks !== undefined)
521
+ return graph.cachedTypeLeaks;
522
+ const violations = [];
523
+ // Every declared module's own directory, not the whole project root -
524
+ // see detectTypeLeaks' own comment on why a single, broad boundary was
525
+ // measured wrong.
526
+ // boundaryRoots, when present, is the multi-file seam's own files.
527
+ // Falling back to dir keeps a caller that built a module the old way
528
+ // (one root, the directory or the single file) on the same answer.
529
+ const moduleBoundaries = [...graph.modules.values()].flatMap((m) => m.boundaryRoots ?? [m.dir]);
530
+ // Every declaration named by ANY declared module's own surface, computed
531
+ // once for the whole check (not per module): a type a consumer can
532
+ // already import from module B is not a leak in module A's surface
533
+ // either - see collectNamedDeclarations' own header comment.
534
+ const allSurfaceFiles = [...graph.modules.values()].flatMap((m) => m.surfaceFiles);
535
+ const namedDeclarations = collectNamedDeclarations(graph.program, graph.checker, allSurfaceFiles, options.report);
536
+ // Which declared module owns each analyzed file, for the do: line: a
537
+ // type owned by this module needs only a name on this surface, while a
538
+ // type owned by a module with no surface cannot get one from here.
539
+ const ownerOf = new Map();
540
+ for (const [name, module] of graph.modules)
541
+ for (const file of module.files ?? [])
542
+ ownerOf.set(file, name);
543
+ for (const [name, module] of graph.modules) {
544
+ // The focused Program owns only one module's surface closure. Walking other
545
+ // modules is refused because their absent source files cannot give safe results.
546
+ if (options.focusModuleName !== undefined && name !== options.focusModuleName)
547
+ continue;
548
+ // A module's surface can be more than one file (a glob, not a single
549
+ // name) - each is walked independently, but grouped together below:
550
+ // the same internal type leaking through two different surface files
551
+ // of the same module is still one fact about that module, not two.
552
+ const groups = new Map();
553
+ for (const surfacePath of module.surfaceFiles) {
554
+ const sf = graph.program.getSourceFile(surfacePath);
555
+ if (sf === undefined)
556
+ continue;
557
+ const findings = detectTypeLeaks(graph.checker, sf, moduleBoundaries, module.surfaceFiles.filter(p => p !== surfacePath), namedDeclarations, options.report, options.extraNamedDeclarationKeys);
558
+ for (const [key, group] of groupByInternalType(findings, surfacePath)) {
559
+ const existing = groups.get(key);
560
+ if (existing === undefined) {
561
+ groups.set(key, group);
562
+ }
563
+ else {
564
+ for (const exportedAs of group.exportedAs) {
565
+ if (!existing.exportedAs.includes(exportedAs))
566
+ existing.exportedAs.push(exportedAs);
567
+ }
568
+ }
569
+ }
570
+ }
571
+ for (const group of groups.values()) {
572
+ const relativeInternalFile = relative(graph.rootDir, group.file);
573
+ const names = [...group.exportedAs].sort();
574
+ const shown = names.slice(0, MAX_NAMED_EXPORTS).map((n) => `'${n}'`).join(", ");
575
+ const more = names.length > MAX_NAMED_EXPORTS ? ` (and ${names.length - MAX_NAMED_EXPORTS} more)` : "";
576
+ violations.push({
577
+ rule: "type-leak",
578
+ path: group.path,
579
+ line: group.line,
580
+ column: group.column,
581
+ evidence: `'${group.type}', declared in '${relativeInternalFile}', is never exported by name from module '${name}'${REFERENCED_BY_MARKER}${shown}${more}`,
582
+ because: BECAUSE,
583
+ do: leakDo(name, group, relativeInternalFile, ownerOf.get(group.file), graph.modules, shown + more),
584
+ todoModule: name,
585
+ leak: { internalType: group.type, internalFile: group.file, exportedAs: names },
586
+ });
587
+ }
588
+ }
589
+ return violations;
590
+ }