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