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.
- package/.agents/hooks/hooks.json +29 -0
- package/.agents/hooks/post-tool-use.mjs +107 -0
- package/.agents/hooks/pre-tool-use.mjs +182 -0
- package/.agents/mcp/server.mjs +71 -0
- package/.agents/plugin.json +19 -0
- package/AGENTS.md +69 -0
- package/CHANGELOG.md +38 -0
- package/README.ja.md +62 -0
- package/README.md +63 -2
- package/dist/augmentation-cache.js +65 -0
- package/dist/check-options.js +40 -0
- package/dist/classify.js +148 -0
- package/dist/cli.js +239 -0
- package/dist/config-pointer.js +251 -0
- package/dist/config.js +186 -0
- package/dist/edge-cache.js +530 -0
- package/dist/mcp-server.js +111 -0
- package/dist/module-candidates.js +118 -0
- package/dist/module-graph.js +2072 -0
- package/dist/project-path.js +59 -0
- package/dist/report-error.js +13 -0
- package/dist/rules/config-meaning.js +143 -0
- package/dist/rules/constraints.js +417 -0
- package/dist/rules/cycles.js +257 -0
- package/dist/rules/deprecated.js +67 -0
- package/dist/rules/empty-rule.js +101 -0
- package/dist/rules/moves.js +79 -0
- package/dist/rules/must-be-empty.js +52 -0
- package/dist/rules/public-surface.js +100 -0
- package/dist/rules/type-leak.js +562 -0
- package/dist/rules/uncovered.js +75 -0
- package/dist/todo-migration.js +112 -0
- package/dist/todo-store.js +434 -0
- package/dist/type-closure.js +959 -0
- package/dist/verbs/agents.js +116 -0
- package/dist/verbs/check.js +957 -0
- package/dist/verbs/fix.js +170 -0
- package/dist/verbs/hotspots.js +261 -0
- package/dist/verbs/init.js +522 -0
- package/dist/verbs/recommend.js +800 -0
- package/dist/verbs/rules.js +188 -0
- package/dist/verbs/search.js +109 -0
- package/dist/verbs/simulate.js +220 -0
- package/dist/verbs/todo.js +163 -0
- package/dist/warm-graph.js +82 -0
- package/docs/boundary-patterns.md +374 -0
- package/docs/calibrated-rules-design.md +124 -0
- package/docs/init-singleton-modules.md +128 -0
- package/docs/maintenance.md +82 -0
- package/docs/releasing.md +55 -0
- package/docs/rules-edge-cache.md +50 -0
- package/docs/todo-single-file-migration.md +58 -0
- package/llms.txt +19 -0
- package/package.json +57 -4
- package/skills/archstrict/SKILL.md +42 -0
- package/skills/archstrict/references/agents-verb.md +39 -0
- package/skills/archstrict/references/config.md +107 -0
- package/skills/archstrict/references/hook.md +57 -0
- package/skills/archstrict/references/path-rules.md +57 -0
- package/skills/archstrict/references/patterns.md +883 -0
- package/skills/archstrict/references/prove-rules.md +58 -0
- package/skills/archstrict/references/rearchitect.md +35 -0
- package/skills/archstrict/references/recommend.md +80 -0
- package/skills/archstrict/references/rules.md +146 -0
- 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
|
+
}
|