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.
- 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 +81 -0
- package/CHANGELOG.md +77 -0
- package/README.ja.md +142 -0
- package/README.md +143 -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 +243 -0
- package/dist/config-pointer.js +251 -0
- package/dist/config.js +194 -0
- package/dist/edge-cache.js +530 -0
- package/dist/gitignore.js +271 -0
- package/dist/mcp-server.js +111 -0
- package/dist/module-candidates.js +125 -0
- package/dist/module-graph.js +2179 -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 +419 -0
- package/dist/rules/cycles.js +285 -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/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/type-leak.js +590 -0
- package/dist/verbs/agents.js +116 -0
- package/dist/verbs/check.js +1011 -0
- package/dist/verbs/fix.js +170 -0
- package/dist/verbs/hotspots.js +261 -0
- package/dist/verbs/init.js +538 -0
- package/dist/verbs/map-shape.js +78 -0
- package/dist/verbs/recommend.js +863 -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 +180 -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 +133 -0
- package/docs/maintenance.md +109 -0
- package/docs/releasing.md +58 -0
- package/docs/rules-edge-cache.md +50 -0
- package/docs/todo-single-file-migration.md +58 -0
- package/llms.txt +25 -0
- package/package.json +61 -4
- package/skills/archstrict/SKILL.md +54 -0
- package/skills/archstrict/references/agents-verb.md +39 -0
- package/skills/archstrict/references/config.md +116 -0
- package/skills/archstrict/references/hook.md +57 -0
- package/skills/archstrict/references/path-rules.md +57 -0
- package/skills/archstrict/references/patterns.md +915 -0
- package/skills/archstrict/references/prove-rules.md +58 -0
- package/skills/archstrict/references/rearchitect.md +66 -0
- package/skills/archstrict/references/recommend.md +98 -0
- package/skills/archstrict/references/rules.md +149 -0
- package/skills/archstrict/references/simulate.md +109 -0
|
@@ -0,0 +1,2179 @@
|
|
|
1
|
+
// Responsibility: build every declared module's own file membership and
|
|
2
|
+
// resolve every import/export/dynamic-import/import-type edge to its target module.
|
|
3
|
+
// This is shared infrastructure: every rule (public-surface bypass,
|
|
4
|
+
// cycles, uncovered modules, deprecated edges) and every verb reads the
|
|
5
|
+
// same graph rather than each re-walking the source.
|
|
6
|
+
// Boundary: no rule logic here. A rule is a predicate over this graph's
|
|
7
|
+
// edges and modules; this module only builds the graph and says what it
|
|
8
|
+
// could not analyze (unresolved specifiers, unsupported syntax, files
|
|
9
|
+
// outside the modules glob) as counts, never as silence.
|
|
10
|
+
// Two deliberate exceptions support rule 6. The Program builders call
|
|
11
|
+
// `checkTypeLeaks` because duplicate alias logic can omit required roots.
|
|
12
|
+
// The focused builder computes absent public names because loading every
|
|
13
|
+
// surface would remove the performance benefit of its smaller Program.
|
|
14
|
+
// It also scans resolvable TypeScript files outside analysis when a focused
|
|
15
|
+
// result can depend on them. Eager scanning is refused because other graph
|
|
16
|
+
// consumers do not use module-augmentation syntax from those files.
|
|
17
|
+
// Rule 6's closure Program resolves imports with each file's nearest tsconfig
|
|
18
|
+
// (`compilerOptionsForFile`, the same one the edge walk uses), not the
|
|
19
|
+
// project root's compiler options for every file alike, so a nested
|
|
20
|
+
// tsconfig's own `paths` resolves there the same way the edge walk
|
|
21
|
+
// resolves it.
|
|
22
|
+
//
|
|
23
|
+
// Edges never require a whole-project ts.Program. A per-file
|
|
24
|
+
// ts.createSourceFile (parsed, walked for its own imports/exports, then
|
|
25
|
+
// dropped) does the same work a Program's own getSourceFiles() walk did,
|
|
26
|
+
// at a fraction of the memory: a Program's own parsed SourceFile/Node
|
|
27
|
+
// trees are what dominate memory on a codebase of tens of thousands of
|
|
28
|
+
// files, and archstrict's own edge records are a rounding error beside
|
|
29
|
+
// them (measured directly: dropping the Program after the edge walk on a
|
|
30
|
+
// 21,000-file tree returned the heap to a few tens of megabytes). A
|
|
31
|
+
// ts.Program is still built - lazily, only when a rule that needs real
|
|
32
|
+
// type information (rule 6, type-leak; search; fix; simulate) actually
|
|
33
|
+
// asks for `program` or `checker` - and can be released again once that
|
|
34
|
+
// rule is done with it (`releaseProgram`), rather than held for the rest
|
|
35
|
+
// of a run that no longer needs it.
|
|
36
|
+
//
|
|
37
|
+
// Every edge is tagged `isTypeOnly`. Decisions a downstream rule must not
|
|
38
|
+
// reopen: rule 1 (public-surface bypass) counts a type-only edge the same
|
|
39
|
+
// as a value edge — reaching an internal file for its types alone is still
|
|
40
|
+
// reaching past the public surface. Rule 2 (cycles) does NOT count a
|
|
41
|
+
// type-only edge — a type-only cycle has no runtime consequence, and TS
|
|
42
|
+
// itself allows it; counting it would produce violations nobody can act on.
|
|
43
|
+
//
|
|
44
|
+
// `unsupportedSyntaxCount` covers `require(...)` calls and
|
|
45
|
+
// `import x = require(...)`; under `verbatimModuleSyntax` (this project's
|
|
46
|
+
// own tsconfig, and the convention it targets) TS itself already rejects
|
|
47
|
+
// the latter as a syntax error, so in practice this count is driven by the
|
|
48
|
+
// former.
|
|
49
|
+
import ts from "typescript";
|
|
50
|
+
import { createHash } from "node:crypto";
|
|
51
|
+
import { readEdgeCache, writeEdgeCache, resolutionKey } from "./edge-cache.js";
|
|
52
|
+
import { readAugmentationCache, writeAugmentationCache } from "./augmentation-cache.js";
|
|
53
|
+
import { existsSync, readFileSync, readdirSync, realpathSync, statSync } from "node:fs";
|
|
54
|
+
import { dirname, join, relative, sep } from "node:path";
|
|
55
|
+
import { builtinModules } from "node:module";
|
|
56
|
+
import { compileGlob, mostSpecificMatch } from "./classify.js";
|
|
57
|
+
import { ReportError } from "./report-error.js";
|
|
58
|
+
import { buildTypeClosure, computeSyntacticNamedDeclarations } from "./type-closure.js";
|
|
59
|
+
import { checkTypeLeaks } from "./type-leak.js";
|
|
60
|
+
import { makeProjectRelativePosix } from "./project-path.js";
|
|
61
|
+
import { forcedBasesOf, gitignoreStackAbove, isPathGitignored, nextIgnoreState, withGitignoreFile } from "./gitignore.js";
|
|
62
|
+
// A node builtin (`fs`, `node:fs`, ...) never has a real resolvedModule:
|
|
63
|
+
// ts.resolveModuleName looks for an actual file, but @types/node's ambient
|
|
64
|
+
// `declare module "node:fs"` is resolved by the checker's own ambient-module
|
|
65
|
+
// lookup, a different mechanism entirely - resolveModuleName returns
|
|
66
|
+
// undefined for a builtin even with `types: ["node"]` set (measured
|
|
67
|
+
// directly, not assumed). Treating that as "unresolved" would flag nearly
|
|
68
|
+
// every backend project's own node:fs/node:path imports as unanalyzable.
|
|
69
|
+
// Detected once here, not resolved: a builtin is synthesized as its own
|
|
70
|
+
// external edge instead.
|
|
71
|
+
const BUILTIN_MODULE_NAMES = new Set(builtinModules);
|
|
72
|
+
function builtinModuleName(specifier) {
|
|
73
|
+
const bare = specifier.replace(/^node:/, "");
|
|
74
|
+
return BUILTIN_MODULE_NAMES.has(bare) ? bare : undefined;
|
|
75
|
+
}
|
|
76
|
+
function resolutionPositionKey(file, position) {
|
|
77
|
+
return `${file}\0${position.line}\0${position.column}`;
|
|
78
|
+
}
|
|
79
|
+
// Every TypeScript source extension archstrict analyzes - .tsx and
|
|
80
|
+
// .mts/.cts included, since limiting the walk to plain .ts silently
|
|
81
|
+
// dropped a whole React codebase's own UI code (a real, measured survey:
|
|
82
|
+
// 5 of 50 popular TypeScript repos have more .tsx than .ts). A hand-
|
|
83
|
+
// authored .d.ts/.d.mts/.d.cts stays excluded by default regardless (see
|
|
84
|
+
// isEligibleSourceFile's own comment) - this list is source extensions
|
|
85
|
+
// only, not every extension ts.sys.readDirectory could be asked for.
|
|
86
|
+
export const ANALYZED_EXTENSIONS = [".ts", ".tsx", ".mts", ".cts"];
|
|
87
|
+
// A relative or bare specifier resolves through every one of these
|
|
88
|
+
// extensions, analyzed or not: a hand-authored .d.ts/.d.mts/.d.cts
|
|
89
|
+
// (declarations with no source counterpart), a plain .js/.mjs/.cjs/.jsx
|
|
90
|
+
// (an already-built or hand-written non-TypeScript sibling), a bare
|
|
91
|
+
// .json (an `import data.json` under `resolveJsonModule`), or one of the
|
|
92
|
+
// four ANALYZED_EXTENSIONS themselves under an exclude glob or outside
|
|
93
|
+
// every declared module's own glob - excluded from analysis, but not
|
|
94
|
+
// from what a specifier can resolve to. Files outside analysis never get
|
|
95
|
+
// an import walk. TypeScript-shaped files receive only an augmentation
|
|
96
|
+
// scan, because a full Program can reach one through an omitted surface.
|
|
97
|
+
const RESOLVABLE_EXTENSIONS = [".ts", ".tsx", ".mts", ".cts", ".d.ts", ".d.mts", ".d.cts", ".js", ".mjs", ".cjs", ".jsx", ".json"];
|
|
98
|
+
export function isResolvableFile(file) {
|
|
99
|
+
return RESOLVABLE_EXTENSIONS.some((extension) => file.endsWith(extension));
|
|
100
|
+
}
|
|
101
|
+
// The default public surface now names one file per analyzed source
|
|
102
|
+
// extension (an array, not a single string) - a directory module whose
|
|
103
|
+
// real entry point is index.tsx (a React project) or index.mts/index.cts
|
|
104
|
+
// must be found by the SAME default a plain index.ts project already
|
|
105
|
+
// gets, with no per-project config needed just to declare that.
|
|
106
|
+
export const DEFAULT_SURFACE = ["index.ts", "index.tsx", "index.mts", "index.cts"];
|
|
107
|
+
// True for a hand-authored declaration file of ANY analyzed source
|
|
108
|
+
// extension (.d.ts, .d.mts, .d.cts) - the single pattern every
|
|
109
|
+
// declaration-file check below shares, so widening the analyzed source
|
|
110
|
+
// extensions never has to widen this check in more than one place.
|
|
111
|
+
function isDeclarationFile(file) {
|
|
112
|
+
return /\.d\.(?:ts|mts|cts)$/.test(file);
|
|
113
|
+
}
|
|
114
|
+
// The literal directory prefix a glob names before its first wildcard,
|
|
115
|
+
// trailing slash stripped - "packages/x/**" -> "packages/x". `surface` is
|
|
116
|
+
// resolved relative to this.
|
|
117
|
+
// Exported: init's own fresh-run walk and a re-run's anchor computation
|
|
118
|
+
// both need the same literal-prefix rule a declared module's glob already
|
|
119
|
+
// follows, so a directory group's glob (e.g. "src/extra/**") and a
|
|
120
|
+
// project's own existing declaredModules entries agree on what "the
|
|
121
|
+
// module's own directory" means.
|
|
122
|
+
export function moduleGlobBaseDir(glob) {
|
|
123
|
+
const firstWildcard = glob.search(/\*/);
|
|
124
|
+
const prefix = firstWildcard === -1 ? glob : glob.slice(0, firstWildcard);
|
|
125
|
+
return prefix.replace(/\/+$/, "");
|
|
126
|
+
}
|
|
127
|
+
export function moduleGlobList(glob) {
|
|
128
|
+
return typeof glob === "string" ? [glob] : glob;
|
|
129
|
+
}
|
|
130
|
+
// The directory a surface or friend path resolves against. A directory
|
|
131
|
+
// glob's own base (`src/build/**` -> `src/build`). A file glob's parent
|
|
132
|
+
// (`src/build/plan.ts` -> `src/build`), so several files in one flat
|
|
133
|
+
// directory share it. Syntactic: it does not look at the disk, because a
|
|
134
|
+
// file the change set has not written yet still belongs to that directory.
|
|
135
|
+
export function globResolutionDir(glob) {
|
|
136
|
+
const base = moduleGlobBaseDir(glob);
|
|
137
|
+
if (glob.includes("*"))
|
|
138
|
+
return base;
|
|
139
|
+
const slash = base.lastIndexOf("/");
|
|
140
|
+
return slash === -1 ? "" : base.slice(0, slash);
|
|
141
|
+
}
|
|
142
|
+
// The shared resolution directory, or undefined when the list is empty or
|
|
143
|
+
// the globs name more than one directory. One directory per entry is the
|
|
144
|
+
// rule: a module that spans directories is several entries.
|
|
145
|
+
export function sharedGlobResolutionDir(glob) {
|
|
146
|
+
const globs = moduleGlobList(glob);
|
|
147
|
+
if (globs.length === 0)
|
|
148
|
+
return undefined;
|
|
149
|
+
const dir = globResolutionDir(globs[0]);
|
|
150
|
+
for (const entry of globs) {
|
|
151
|
+
if (globResolutionDir(entry) !== dir)
|
|
152
|
+
return undefined;
|
|
153
|
+
}
|
|
154
|
+
return dir;
|
|
155
|
+
}
|
|
156
|
+
export function declaredModuleMembership(declaredModules) {
|
|
157
|
+
return declaredModules.flatMap((dm) => moduleGlobList(dm.glob).map((glob) => ({ glob, value: dm.name })));
|
|
158
|
+
}
|
|
159
|
+
function joinPosix(dir, relativePath) {
|
|
160
|
+
const joined = dir === "" ? relativePath : `${dir}/${relativePath}`;
|
|
161
|
+
return joined.replace(/\/{2,}/g, "/").replace(/^\//, "");
|
|
162
|
+
}
|
|
163
|
+
// A single glob keeps today's file-or-directory root. Several globs must
|
|
164
|
+
// share one resolution directory; the caller uses that directory for
|
|
165
|
+
// surface and friends, and each glob's own base as a type-leak root.
|
|
166
|
+
function moduleGlobShape(dm) {
|
|
167
|
+
const globs = moduleGlobList(dm.glob);
|
|
168
|
+
if (globs.length === 0) {
|
|
169
|
+
throw new ReportError(`declared module '${dm.name}' has an empty glob list`, `give '${dm.name}' a glob string or a non-empty array of paths in one directory, in archstrict.config.ts, then run archstrict check`);
|
|
170
|
+
}
|
|
171
|
+
if (globs.length === 1)
|
|
172
|
+
return { kind: "single", glob: globs[0] };
|
|
173
|
+
const dir = sharedGlobResolutionDir(dm.glob);
|
|
174
|
+
if (dir === undefined) {
|
|
175
|
+
throw new ReportError(`declared module '${dm.name}' lists globs that do not share one directory`, `list only files in one directory, or use one declaredModules entry per directory, in archstrict.config.ts, then run archstrict check`);
|
|
176
|
+
}
|
|
177
|
+
return { kind: "many", dir, globs };
|
|
178
|
+
}
|
|
179
|
+
function pathIsFile(path) {
|
|
180
|
+
try {
|
|
181
|
+
return statSync(path).isFile();
|
|
182
|
+
}
|
|
183
|
+
catch {
|
|
184
|
+
return false;
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
// Directory a module-relative path (surface, friends) resolves against.
|
|
188
|
+
// A glob with no wildcard that names an existing file has no directory of
|
|
189
|
+
// its own. Those paths resolve against the file's parent, so
|
|
190
|
+
// `{ glob: "src/index.ts", surface: "index.ts" }` names `src/index.ts`
|
|
191
|
+
// and not `src/index.ts/index.ts`. The parent is computed with string
|
|
192
|
+
// ops, not `path.dirname`: globs are project-relative posix even on
|
|
193
|
+
// Windows, and `path.dirname` would follow the platform separator.
|
|
194
|
+
// `fileExists` defaults to a real disk check (every caller but simulate's
|
|
195
|
+
// own overlay build); simulate passes one backed by the change set too, so
|
|
196
|
+
// a file the change set creates - not yet written to disk - still counts
|
|
197
|
+
// as existing here. Without that, a single-file module's glob resolves as
|
|
198
|
+
// though its own file were a directory the moment simulate proposes
|
|
199
|
+
// creating it, and every real import into it misreads as reaching past a
|
|
200
|
+
// surface that was never computed at all.
|
|
201
|
+
function moduleRelativeDir(projectRoot, glob, fileExists = pathIsFile) {
|
|
202
|
+
const base = moduleGlobBaseDir(glob);
|
|
203
|
+
if (!fileExists(join(projectRoot, base)))
|
|
204
|
+
return base;
|
|
205
|
+
const slash = base.lastIndexOf("/");
|
|
206
|
+
return slash === -1 ? "" : base.slice(0, slash);
|
|
207
|
+
}
|
|
208
|
+
function moduleRelativeGlob(projectRoot, glob, relativePath, fileExists = pathIsFile) {
|
|
209
|
+
const base = moduleRelativeDir(projectRoot, glob, fileExists);
|
|
210
|
+
const joined = base === "" ? relativePath : `${base}/${relativePath}`;
|
|
211
|
+
return joined.replace(/\/{2,}/g, "/").replace(/^\//, "");
|
|
212
|
+
}
|
|
213
|
+
export function toProjectRelativePosix(filePath, projectRoot) {
|
|
214
|
+
return relative(projectRoot, filePath).split(sep).join("/");
|
|
215
|
+
}
|
|
216
|
+
// Recursively lists every .ts file under `projectRoot`, excluding
|
|
217
|
+
// node_modules, dist, and every config.exclude glob - the candidate set
|
|
218
|
+
// declared-module membership and surface matching both filter from.
|
|
219
|
+
// Declared modules can live anywhere under the project, so there is no
|
|
220
|
+
// narrower directory to start from than the project root itself.
|
|
221
|
+
// True when a `resolvedFileName` TS itself flagged `isExternalLibraryImport`
|
|
222
|
+
// (per that field's own contract: "comes from node_modules") is actually a
|
|
223
|
+
// workspace's own sibling package - a package manager symlinks a sibling
|
|
224
|
+
// package into node_modules exactly like a real dependency, but following
|
|
225
|
+
// that symlink lands back on a real file this project owns, outside
|
|
226
|
+
// node_modules entirely (measured directly: a real workspace-symlink
|
|
227
|
+
// resolution's own `resolvedFileName` already comes back as the real,
|
|
228
|
+
// symlink-followed path, e.g. `<root>/packages/b/src/index.ts`, not
|
|
229
|
+
// `<root>/node_modules/<pkg>/src/index.ts`). A genuine external dependency
|
|
230
|
+
// resolves to a real file that, however it's laid out (a plain copy, or a
|
|
231
|
+
// pnpm content-addressed store under its own `node_modules/.pnpm/...`),
|
|
232
|
+
// never escapes SOME `node_modules` directory - `isExternalLibraryImport`
|
|
233
|
+
// itself guarantees the file came from one. So: still under a node_modules
|
|
234
|
+
// segment after resolution -> genuinely external; escaped every
|
|
235
|
+
// node_modules segment and lands inside this project's own root -> a
|
|
236
|
+
// workspace sibling, not an external target.
|
|
237
|
+
function isWorkspaceSiblingResolution(resolvedFile, rootDir) {
|
|
238
|
+
if (resolvedFile.split(sep).includes("node_modules"))
|
|
239
|
+
return false;
|
|
240
|
+
const rel = relative(rootDir, resolvedFile);
|
|
241
|
+
return !(rel.startsWith("..") || rel === resolvedFile);
|
|
242
|
+
}
|
|
243
|
+
// A hand-authored `.d.ts` is excluded from analysis by default - most are
|
|
244
|
+
// either a third-party ambient declaration with no real source in this
|
|
245
|
+
// project, or a generated twin of a real `.ts` file, neither one "module
|
|
246
|
+
// content" this tool should walk as its own file. But a project whose
|
|
247
|
+
// real, intentional public-surface convention IS a hand-authored `.d.ts`
|
|
248
|
+
// (a webpack-built package publishing `"types": "./types.d.ts"` with no
|
|
249
|
+
// `index.ts` at all, a real, measured case) can never be modeled at all
|
|
250
|
+
// otherwise - a `.d.ts` a declaredModules entry's own `surface` glob
|
|
251
|
+
// explicitly names is the one, narrow exception: an explicit config
|
|
252
|
+
// choice, not a blanket re-inclusion of every declaration file.
|
|
253
|
+
// `dm`'s own effective surface (hand-set, derived from a real package.json
|
|
254
|
+
// exports map, or the project's own global default - effectiveSurface's
|
|
255
|
+
// own precedence) relative to its module's own base directory
|
|
256
|
+
// (moduleGlobBaseDir of `dm.glob`), one project-relative glob per surface
|
|
257
|
+
// entry - a single string normalizes to one entry, an array to one per
|
|
258
|
+
// element.
|
|
259
|
+
export function surfaceGlobsFor(dm, projectRoot, globalDefaultSurface,
|
|
260
|
+
// See moduleRelativeDir's own comment - simulate's overlay build passes
|
|
261
|
+
// one here so a change set's own new file classifies correctly.
|
|
262
|
+
fileExists = pathIsFile) {
|
|
263
|
+
const shape = moduleGlobShape(dm);
|
|
264
|
+
// A file list does not derive a surface from a package.json sitting in
|
|
265
|
+
// the shared directory. That derivation belongs to a directory module,
|
|
266
|
+
// whose glob is one directory. A seam lists its surface by hand, or
|
|
267
|
+
// takes the project default (index.ts in that directory).
|
|
268
|
+
if (shape.kind === "many") {
|
|
269
|
+
const surface = dm.surface ?? globalDefaultSurface;
|
|
270
|
+
const entries = Array.isArray(surface) ? surface : [surface];
|
|
271
|
+
return entries.map((s) => joinPosix(shape.dir, s));
|
|
272
|
+
}
|
|
273
|
+
const moduleDir = join(projectRoot, moduleGlobBaseDir(shape.glob));
|
|
274
|
+
const surface = effectiveSurface(dm, moduleDir, globalDefaultSurface);
|
|
275
|
+
const entries = Array.isArray(surface) ? surface : [surface];
|
|
276
|
+
return entries.map((s) => moduleRelativeGlob(projectRoot, shape.glob, s, fileExists));
|
|
277
|
+
}
|
|
278
|
+
function surfaceGlobsAllowingDts(declaredModules, projectRoot, globalDefaultSurface) {
|
|
279
|
+
return declaredModules
|
|
280
|
+
.flatMap((dm) => surfaceGlobsFor(dm, projectRoot, globalDefaultSurface))
|
|
281
|
+
.filter((g) => isDeclarationFile(g));
|
|
282
|
+
}
|
|
283
|
+
// A build-output path's own extension, swapped for the real source
|
|
284
|
+
// extension(s) every one of these ships from - never guessed beyond this
|
|
285
|
+
// fixed, small set (a project using some other build layout entirely
|
|
286
|
+
// simply isn't derivable, and falls back to the tool's own default
|
|
287
|
+
// instead of a wrong guess). More than one candidate per built extension
|
|
288
|
+
// (e.g. ".js" -> both ".ts" and ".tsx") - a React package's own built
|
|
289
|
+
// "./dist/index.js" ships from "index.tsx", not "index.ts", and the first
|
|
290
|
+
// existing guess wins (existsSync in the caller's own loop).
|
|
291
|
+
const BUILT_TO_SOURCE_EXTENSIONS = [
|
|
292
|
+
[".d.mts", [".mts", ".ts"]],
|
|
293
|
+
[".d.cts", [".cts", ".ts"]],
|
|
294
|
+
[".d.ts", [".ts", ".tsx"]],
|
|
295
|
+
[".mjs", [".mts", ".ts"]],
|
|
296
|
+
[".cjs", [".cts", ".ts"]],
|
|
297
|
+
[".js", [".ts", ".tsx"]],
|
|
298
|
+
];
|
|
299
|
+
// One export subpath's own value (a bare string, or a conditions object)
|
|
300
|
+
// resolved to the one real, existing source file it names - or undefined
|
|
301
|
+
// when nothing in it can be confidently resolved. A source-pointing
|
|
302
|
+
// condition (a project-specific key ending in "-source", the real
|
|
303
|
+
// convention this was measured against) is preferred when present, since
|
|
304
|
+
// it already names the real source path directly, with no built-output
|
|
305
|
+
// heuristic needed at all. Otherwise, tries "types"/"import"/"require"/
|
|
306
|
+
// "default" in that order, applying the fixed built-to-source extension
|
|
307
|
+
// swap and a single "dist/" prefix strip, then confirms the guess is a
|
|
308
|
+
// real file - a project whose own build output lives somewhere other
|
|
309
|
+
// than a literal "dist/" directory, or under some other convention
|
|
310
|
+
// entirely, is simply not derivable this way, not guessed wrong.
|
|
311
|
+
function resolveExportsEntry(value, moduleDir) {
|
|
312
|
+
const candidates = [];
|
|
313
|
+
if (typeof value === "string") {
|
|
314
|
+
candidates.push(value);
|
|
315
|
+
}
|
|
316
|
+
else if (typeof value === "object" && value !== null) {
|
|
317
|
+
const conditions = value;
|
|
318
|
+
const sourceKey = Object.keys(conditions).find((k) => k.endsWith("-source"));
|
|
319
|
+
for (const key of [sourceKey, "types", "import", "require", "default"]) {
|
|
320
|
+
if (key === undefined)
|
|
321
|
+
continue;
|
|
322
|
+
const v = conditions[key];
|
|
323
|
+
if (typeof v === "string")
|
|
324
|
+
candidates.push(v);
|
|
325
|
+
}
|
|
326
|
+
}
|
|
327
|
+
for (const raw of candidates) {
|
|
328
|
+
const stripped = raw.replace(/^\.\//, "");
|
|
329
|
+
// A declaration output must still pass through the built-to-source
|
|
330
|
+
// conversion below; a bare .ts/.tsx/.mts/.cts is already real source.
|
|
331
|
+
const isDeclaration = isDeclarationFile(stripped);
|
|
332
|
+
const asSource = !isDeclaration && ANALYZED_EXTENSIONS.some((ext) => stripped.endsWith(ext)) ? stripped : undefined;
|
|
333
|
+
const guesses = asSource !== undefined
|
|
334
|
+
? [asSource]
|
|
335
|
+
: BUILT_TO_SOURCE_EXTENSIONS.filter(([ext]) => stripped.endsWith(ext)).flatMap(([ext, replacements]) => replacements.map((replacement) => stripped.replace(/^dist\//, "").slice(0, -ext.length) + replacement));
|
|
336
|
+
for (const guess of guesses) {
|
|
337
|
+
if (existsSync(join(moduleDir, guess)))
|
|
338
|
+
return guess;
|
|
339
|
+
}
|
|
340
|
+
}
|
|
341
|
+
return undefined;
|
|
342
|
+
}
|
|
343
|
+
// Every real, sanctioned entry point a package.json's own `exports` map
|
|
344
|
+
// names, resolved back to its own real source file - or undefined when
|
|
345
|
+
// the map is absent, empty of real subpaths, or even one entry can't be
|
|
346
|
+
// confidently resolved (whole-module fallback to the tool's own default,
|
|
347
|
+
// never a partial or guessed-wrong surface array).
|
|
348
|
+
// Deliberately NOT cached across calls: buildModuleGraphForRules rebuilds
|
|
349
|
+
// the whole graph on a package.json exports edit, and a cache keyed only
|
|
350
|
+
// by moduleDir would return the stale, pre-edit surface for that same
|
|
351
|
+
// rebuild (measured directly - a test writing a new exports map to the
|
|
352
|
+
// same package.json between two builds got the first build's answer
|
|
353
|
+
// back). buildDeclaredModules and listAnalyzedFiles each reach this a few
|
|
354
|
+
// times per module per scan (surfaceName, surfaceGlobsFor's own dts
|
|
355
|
+
// check), not once per candidate file, so leaving it uncached costs a few
|
|
356
|
+
// reads per module per build, independent of file count.
|
|
357
|
+
function deriveSurfaceFromExports(moduleDir) {
|
|
358
|
+
const pkgPath = join(moduleDir, "package.json");
|
|
359
|
+
if (!existsSync(pkgPath))
|
|
360
|
+
return undefined;
|
|
361
|
+
let pkg;
|
|
362
|
+
try {
|
|
363
|
+
pkg = JSON.parse(readFileSync(pkgPath, "utf8"));
|
|
364
|
+
}
|
|
365
|
+
catch {
|
|
366
|
+
return undefined;
|
|
367
|
+
}
|
|
368
|
+
if (typeof pkg !== "object" || pkg === null)
|
|
369
|
+
return undefined;
|
|
370
|
+
const exportsField = pkg.exports;
|
|
371
|
+
if (exportsField === undefined)
|
|
372
|
+
return undefined;
|
|
373
|
+
// A single string, or a bare conditions object (keys like "types"/
|
|
374
|
+
// "import" that don't start with "."), names only the package's own
|
|
375
|
+
// default "." entry - not a subpath map at all.
|
|
376
|
+
const isSubpathMap = typeof exportsField === "object" &&
|
|
377
|
+
exportsField !== null &&
|
|
378
|
+
Object.keys(exportsField).every((k) => k.startsWith("."));
|
|
379
|
+
const subpaths = isSubpathMap
|
|
380
|
+
? exportsField
|
|
381
|
+
: { ".": exportsField };
|
|
382
|
+
const resolved = [];
|
|
383
|
+
for (const [key, value] of Object.entries(subpaths)) {
|
|
384
|
+
if (key === "./package.json")
|
|
385
|
+
continue; // a real file, but never TypeScript source
|
|
386
|
+
if (value === null)
|
|
387
|
+
continue; // explicitly blocked by the package's own author - not a leak candidate
|
|
388
|
+
const source = resolveExportsEntry(value, moduleDir);
|
|
389
|
+
if (source === undefined)
|
|
390
|
+
return undefined; // one unresolvable entry fails the whole derivation
|
|
391
|
+
if (!resolved.includes(source))
|
|
392
|
+
resolved.push(source);
|
|
393
|
+
}
|
|
394
|
+
return resolved.length > 0 ? resolved : undefined;
|
|
395
|
+
}
|
|
396
|
+
// A declared module's own effective surface: its own hand-set surface if
|
|
397
|
+
// present (wins unconditionally), else a real package.json's own exports
|
|
398
|
+
// map derived back to source (every real, sanctioned entry point at
|
|
399
|
+
// once), else the project's own global default - never a mix of derived
|
|
400
|
+
// and hand-set for the same module.
|
|
401
|
+
function effectiveSurface(dm, moduleDir, globalDefaultSurface) {
|
|
402
|
+
if (dm.surface !== undefined)
|
|
403
|
+
return dm.surface;
|
|
404
|
+
return deriveSurfaceFromExports(moduleDir) ?? globalDefaultSurface;
|
|
405
|
+
}
|
|
406
|
+
// A named import/export clause is type-only either as a whole
|
|
407
|
+
// (`import type { X } from "..."`) or per specifier
|
|
408
|
+
// (`import { type X } from "..."`, the modifier on one named binding
|
|
409
|
+
// rather than the whole declaration) - TypeScript allows both forms, and
|
|
410
|
+
// only the first was ever checked here (measured directly: a minimal
|
|
411
|
+
// two-module fixture whose only edge is `import { type X } from "../b"`
|
|
412
|
+
// reported `isTypeOnly: false`, producing a false-positive cycle with a
|
|
413
|
+
// real value edge the other way). A default import binding
|
|
414
|
+
// (`import Foo, { type X } from "..."`) can never be per-specifier
|
|
415
|
+
// type-only itself, so its presence always makes the whole import a real
|
|
416
|
+
// value reference regardless of any named specifier's own modifier; same
|
|
417
|
+
// for a namespace import (`import * as X`), which has no per-specifier
|
|
418
|
+
// form at all. So: type-only only when the whole declaration says so, OR
|
|
419
|
+
// every named specifier does and neither a default nor a namespace
|
|
420
|
+
// binding exists on the same declaration.
|
|
421
|
+
function isEffectivelyTypeOnlyImport(importClause) {
|
|
422
|
+
if (importClause === undefined)
|
|
423
|
+
return false; // a side-effect-only `import "./x"` is a real reference
|
|
424
|
+
if (importClause.isTypeOnly)
|
|
425
|
+
return true;
|
|
426
|
+
if (importClause.name !== undefined)
|
|
427
|
+
return false;
|
|
428
|
+
const bindings = importClause.namedBindings;
|
|
429
|
+
if (bindings === undefined || ts.isNamespaceImport(bindings))
|
|
430
|
+
return false;
|
|
431
|
+
return bindings.elements.length > 0 && bindings.elements.every((el) => el.isTypeOnly);
|
|
432
|
+
}
|
|
433
|
+
// The export-side twin of isEffectivelyTypeOnlyImport - `export { type X }
|
|
434
|
+
// from "..."` has the identical per-specifier-vs-whole-declaration
|
|
435
|
+
// distinction `export type { X } from "..."` already gets right.
|
|
436
|
+
function isEffectivelyTypeOnlyExport(node) {
|
|
437
|
+
if (node.isTypeOnly)
|
|
438
|
+
return true;
|
|
439
|
+
const clause = node.exportClause;
|
|
440
|
+
if (clause === undefined || !ts.isNamedExports(clause))
|
|
441
|
+
return false;
|
|
442
|
+
return clause.elements.length > 0 && clause.elements.every((el) => el.isTypeOnly);
|
|
443
|
+
}
|
|
444
|
+
// Exported (not just used internally by prepareGraph) so init's own fresh-
|
|
445
|
+
// run walk sees exactly the file set check will analyze - a second,
|
|
446
|
+
// hand-rolled scan here would drift from isEligibleSourceFile's own rules
|
|
447
|
+
// (node_modules/dist segments, .d.ts, exclude globs) the moment either one
|
|
448
|
+
// changed without the other.
|
|
449
|
+
const NON_TS_SOURCE_EXTENSIONS = [".js", ".mjs", ".cjs"];
|
|
450
|
+
// One directory's own real identity, following any symlink - `undefined`
|
|
451
|
+
// for a broken symlink, or a directory this process cannot stat at all
|
|
452
|
+
// (invisible to this walk, the same as an unreadable file is).
|
|
453
|
+
function realDirOf(path) {
|
|
454
|
+
try {
|
|
455
|
+
return realpathSync(path);
|
|
456
|
+
}
|
|
457
|
+
catch {
|
|
458
|
+
return undefined;
|
|
459
|
+
}
|
|
460
|
+
}
|
|
461
|
+
// Ordinal, case-sensitive comparison - plain `<`/`>` on the raw strings,
|
|
462
|
+
// never `localeCompare` (which is locale-sensitive and can reorder
|
|
463
|
+
// mixed-case or `_`-prefixed names differently across machines). Matches
|
|
464
|
+
// TypeScript's own `matchFiles`, whose real output this walk replaces
|
|
465
|
+
// (walk-parity tests compare directly against it) - a caller comparing
|
|
466
|
+
// this walk's own order against a fresh `ts.sys.readDirectory` call must
|
|
467
|
+
// see the identical order, not merely the identical file set.
|
|
468
|
+
function ordinalCompare(a, b) {
|
|
469
|
+
return a < b ? -1 : a > b ? 1 : 0;
|
|
470
|
+
}
|
|
471
|
+
// A directory's own children, split into real files and real
|
|
472
|
+
// directories (a symlink resolved through statSync either way - a
|
|
473
|
+
// Dirent never resolves one on its own: isDirectory()/isFile() both
|
|
474
|
+
// read false for a symlink regardless of what it points at). A broken
|
|
475
|
+
// symlink, or an entry this process cannot stat at all, is invisible -
|
|
476
|
+
// the same as a file this walk can't read is everywhere else in this
|
|
477
|
+
// project. Each group comes back sorted with `ordinalCompare`, matching
|
|
478
|
+
// `matchFiles`' own order.
|
|
479
|
+
function readDirEntries(dir) {
|
|
480
|
+
let entries;
|
|
481
|
+
try {
|
|
482
|
+
entries = readdirSync(dir, { withFileTypes: true });
|
|
483
|
+
}
|
|
484
|
+
catch {
|
|
485
|
+
return { files: [], dirs: [] };
|
|
486
|
+
}
|
|
487
|
+
const files = [];
|
|
488
|
+
const dirs = [];
|
|
489
|
+
for (const entry of entries) {
|
|
490
|
+
const full = join(dir, entry.name);
|
|
491
|
+
let isDir = entry.isDirectory();
|
|
492
|
+
let isFile = entry.isFile();
|
|
493
|
+
if (entry.isSymbolicLink()) {
|
|
494
|
+
try {
|
|
495
|
+
const target = statSync(full);
|
|
496
|
+
isDir = target.isDirectory();
|
|
497
|
+
isFile = target.isFile();
|
|
498
|
+
}
|
|
499
|
+
catch {
|
|
500
|
+
continue;
|
|
501
|
+
}
|
|
502
|
+
}
|
|
503
|
+
if (isDir) {
|
|
504
|
+
const real = realDirOf(full);
|
|
505
|
+
if (real !== undefined)
|
|
506
|
+
dirs.push({ entry, real });
|
|
507
|
+
continue;
|
|
508
|
+
}
|
|
509
|
+
if (isFile)
|
|
510
|
+
files.push(entry);
|
|
511
|
+
}
|
|
512
|
+
files.sort((a, b) => ordinalCompare(a.name, b.name));
|
|
513
|
+
dirs.sort((a, b) => ordinalCompare(a.entry.name, b.entry.name));
|
|
514
|
+
return { files, dirs };
|
|
515
|
+
}
|
|
516
|
+
// One recursive descent of the project tree, in place of four separate
|
|
517
|
+
// directory walks: the analyzed file list, the non-TS source count, the
|
|
518
|
+
// resolvable-file set, and the outside-node_modules package.json list.
|
|
519
|
+
// node_modules is recorded but never descended into - the package names
|
|
520
|
+
// and package.json mtimes a resolution fingerprint needs from it come
|
|
521
|
+
// from listNodeModulesPackages instead, reading only that one directory's
|
|
522
|
+
// own top level. config.exclude applies to the analyzed list and the
|
|
523
|
+
// non-TS count only - never to the resolvable set or the package.json
|
|
524
|
+
// list, which describe what a specifier can reach, not what gets
|
|
525
|
+
// analyzed. A later pass reads TypeScript-shaped members of the
|
|
526
|
+
// resolvable set for the narrow augmentation scan only.
|
|
527
|
+
//
|
|
528
|
+
// dist/ is NEVER entered by this pass, unconditionally - not only under
|
|
529
|
+
// `analysisOnly` (see below). A real, measured case this fixes: a build
|
|
530
|
+
// tool symlinking a source directory straight into dist/ (`dist/shared
|
|
531
|
+
// -> ../src/shared`, a real pattern some bundlers use) reaches
|
|
532
|
+
// `src/shared`'s own real identity while walking dist/ alphabetically
|
|
533
|
+
// before src/ - if this pass's own visited set were shared with dist's
|
|
534
|
+
// own descent, `src/shared` would already be marked visited by the time
|
|
535
|
+
// this pass reaches it for real, and every file under it would silently
|
|
536
|
+
// vanish from the analyzed list. Every one of dist/'s own top-level
|
|
537
|
+
// directories this pass meets (never descended into) is instead handed
|
|
538
|
+
// to a second, wholly separate pass below - own visited set, never
|
|
539
|
+
// touching this pass's own analyzed output at all.
|
|
540
|
+
//
|
|
541
|
+
// `analysisOnly` (listAnalyzedFiles' own use, and every other caller that
|
|
542
|
+
// wants only the analyzed list) additionally skips collecting the
|
|
543
|
+
// resolvable set, the package.json list, and the node_modules directory
|
|
544
|
+
// list for the rest of the tree too (and skips the second, dist-only
|
|
545
|
+
// pass entirely) - each is real, avoidable work a caller that never
|
|
546
|
+
// reads those fields would otherwise pay for nothing.
|
|
547
|
+
// buildModuleGraphForRules' own resolutionInputs is the one caller that
|
|
548
|
+
// needs the fuller walk (`analysisOnly` false, prepareGraph's own
|
|
549
|
+
// default).
|
|
550
|
+
//
|
|
551
|
+
// Every directory's own real identity (following any symlink) is
|
|
552
|
+
// visited at most once per pass, first visit wins - the same rule
|
|
553
|
+
// TypeScript's own `matchFiles` follows. Without it, a symlink cycle (a
|
|
554
|
+
// directory symlinked back to one of its own ancestors) recurses forever
|
|
555
|
+
// in practice (bounded only by the filesystem's own path-length limit),
|
|
556
|
+
// and a directory reached twice through two different symlinks (or a
|
|
557
|
+
// symlink and its own real target) is listed twice over. Each pass's
|
|
558
|
+
// root (this walk's own `projectRoot` for the first; each dist/
|
|
559
|
+
// directory, independently, for the second) is seeded into that pass's
|
|
560
|
+
// own visited set before it starts, so a later symlink back to it (or to
|
|
561
|
+
// any directory already reached within that same pass) is caught the
|
|
562
|
+
// same way an ordinary cycle is.
|
|
563
|
+
//
|
|
564
|
+
// Measured directly on nukadoko-archstrict-adopt's own real tree (1,342
|
|
565
|
+
// files outside node_modules): the four separate ts.sys.readDirectory
|
|
566
|
+
// calls this replaces took about 41 ms; this one recursive descent takes
|
|
567
|
+
// about 23 ms - roughly 1.8x faster, from walking every directory once
|
|
568
|
+
// instead of four times.
|
|
569
|
+
//
|
|
570
|
+
// A gitignored path is not project source (scratch corpora, build output,
|
|
571
|
+
// local caches), so it leaves the analyzed list and the non-TS count, the
|
|
572
|
+
// same two outputs config.exclude governs. It stays in the resolvable set
|
|
573
|
+
// and the package.json list: a checked-in file can import gitignored
|
|
574
|
+
// codegen output, and the resolution fingerprint must still see it appear
|
|
575
|
+
// or vanish. Under `analysisOnly` nothing else is collected, so an ignored
|
|
576
|
+
// directory is not entered at all - unless a declared module's base lies
|
|
577
|
+
// inside it, since a declared module is the explicit request to analyze
|
|
578
|
+
// an ignored path (see gitignore.ts's nextIgnoreState).
|
|
579
|
+
function walkProjectTree(projectRoot, excludeGlobs, dtsSurfaceGlobs, analysisOnly = false, relativePath = makeProjectRelativePosix(projectRoot), declaredBases = []) {
|
|
580
|
+
const forced = forcedBasesOf(declaredBases);
|
|
581
|
+
const analyzedFiles = [];
|
|
582
|
+
let nonTsSourceFileCount = 0;
|
|
583
|
+
const resolvableFiles = [];
|
|
584
|
+
const packageJsonFiles = [];
|
|
585
|
+
const nodeModulesDirs = [];
|
|
586
|
+
const distDirs = [];
|
|
587
|
+
const visited = new Set();
|
|
588
|
+
function visit(dir, dirState, inherited) {
|
|
589
|
+
const { files, dirs } = readDirEntries(dir);
|
|
590
|
+
let stack = inherited;
|
|
591
|
+
if (dirState === "kept" && files.some((entry) => entry.name === ".gitignore")) {
|
|
592
|
+
try {
|
|
593
|
+
stack = withGitignoreFile(stack, relativePath(dir), readFileSync(join(dir, ".gitignore"), "utf8"));
|
|
594
|
+
}
|
|
595
|
+
catch {
|
|
596
|
+
// An unreadable .gitignore ignores nothing, like any unreadable file here.
|
|
597
|
+
}
|
|
598
|
+
}
|
|
599
|
+
for (const entry of files) {
|
|
600
|
+
const full = join(dir, entry.name);
|
|
601
|
+
if (entry.name === "package.json" && !analysisOnly)
|
|
602
|
+
packageJsonFiles.push(full);
|
|
603
|
+
if (!analysisOnly && isResolvableFile(entry.name))
|
|
604
|
+
resolvableFiles.push(full);
|
|
605
|
+
const rel = relativePath(full);
|
|
606
|
+
if (nextIgnoreState(dirState, stack, rel, false, forced.bases) === "ignored")
|
|
607
|
+
continue;
|
|
608
|
+
if (excludeGlobs.some((glob) => compileGlob(glob).test(rel)))
|
|
609
|
+
continue;
|
|
610
|
+
if (NON_TS_SOURCE_EXTENSIONS.some((ext) => entry.name.endsWith(ext))) {
|
|
611
|
+
nonTsSourceFileCount++;
|
|
612
|
+
}
|
|
613
|
+
else if (ANALYZED_EXTENSIONS.some((ext) => entry.name.endsWith(ext)) &&
|
|
614
|
+
(!isDeclarationFile(full) || dtsSurfaceGlobs.some((glob) => compileGlob(glob).test(rel)))) {
|
|
615
|
+
analyzedFiles.push(full);
|
|
616
|
+
}
|
|
617
|
+
}
|
|
618
|
+
for (const { entry, real } of dirs) {
|
|
619
|
+
const full = join(dir, entry.name);
|
|
620
|
+
if (entry.name === "node_modules") {
|
|
621
|
+
if (!analysisOnly)
|
|
622
|
+
nodeModulesDirs.push(full);
|
|
623
|
+
continue;
|
|
624
|
+
}
|
|
625
|
+
// An exact, case-sensitive match on every platform: "Dist" or "DIST" stays analyzed even on
|
|
626
|
+
// a case-insensitive file system, so one project gives the same analyzed list on macOS,
|
|
627
|
+
// Windows and Linux.
|
|
628
|
+
if (entry.name === "dist") {
|
|
629
|
+
if (!analysisOnly)
|
|
630
|
+
distDirs.push(full);
|
|
631
|
+
continue; // never entered by this pass, unconditionally
|
|
632
|
+
}
|
|
633
|
+
const rel = relativePath(full);
|
|
634
|
+
const state = nextIgnoreState(dirState, stack, rel, true, forced.bases);
|
|
635
|
+
if (state === "ignored" && analysisOnly && !forced.ancestors.has(rel))
|
|
636
|
+
continue;
|
|
637
|
+
if (visited.has(real))
|
|
638
|
+
continue;
|
|
639
|
+
visited.add(real);
|
|
640
|
+
visit(full, state, stack);
|
|
641
|
+
}
|
|
642
|
+
}
|
|
643
|
+
const rootReal = realDirOf(projectRoot);
|
|
644
|
+
if (rootReal !== undefined)
|
|
645
|
+
visited.add(rootReal);
|
|
646
|
+
// The project root itself is never tested against a pattern: a root the
|
|
647
|
+
// caller pointed at is analyzed even when it sits inside an ignored path.
|
|
648
|
+
visit(projectRoot, "kept", gitignoreStackAbove(projectRoot));
|
|
649
|
+
// The second pass: every dist/ directory the first pass met, walked
|
|
650
|
+
// separately for the resolvable set, the package.json list, and the
|
|
651
|
+
// node_modules directory list only - never the analyzed list or the
|
|
652
|
+
// non-TS count, and never sharing the first pass's own visited set.
|
|
653
|
+
if (!analysisOnly) {
|
|
654
|
+
const distVisited = new Set();
|
|
655
|
+
function visitDist(dir) {
|
|
656
|
+
const { files, dirs } = readDirEntries(dir);
|
|
657
|
+
for (const entry of files) {
|
|
658
|
+
const full = join(dir, entry.name);
|
|
659
|
+
if (entry.name === "package.json")
|
|
660
|
+
packageJsonFiles.push(full);
|
|
661
|
+
if (isResolvableFile(entry.name))
|
|
662
|
+
resolvableFiles.push(full);
|
|
663
|
+
}
|
|
664
|
+
for (const { entry, real } of dirs) {
|
|
665
|
+
const full = join(dir, entry.name);
|
|
666
|
+
if (entry.name === "node_modules") {
|
|
667
|
+
nodeModulesDirs.push(full);
|
|
668
|
+
continue;
|
|
669
|
+
}
|
|
670
|
+
if (distVisited.has(real))
|
|
671
|
+
continue;
|
|
672
|
+
distVisited.add(real);
|
|
673
|
+
visitDist(full);
|
|
674
|
+
}
|
|
675
|
+
}
|
|
676
|
+
for (const dir of distDirs) {
|
|
677
|
+
const real = realDirOf(dir);
|
|
678
|
+
if (real === undefined || distVisited.has(real))
|
|
679
|
+
continue;
|
|
680
|
+
distVisited.add(real);
|
|
681
|
+
visitDist(dir);
|
|
682
|
+
}
|
|
683
|
+
}
|
|
684
|
+
return { analyzedFiles, nonTsSourceFileCount, resolvableFiles, packageJsonFiles, nodeModulesDirs };
|
|
685
|
+
}
|
|
686
|
+
// Each declared module's literal base (see moduleGlobBaseDir): the walk
|
|
687
|
+
// keeps a gitignored path at or under one, since declaring a module there
|
|
688
|
+
// is the project's explicit request to analyze it.
|
|
689
|
+
function declaredBasesOf(declaredModules) {
|
|
690
|
+
return declaredModules.flatMap((dm) => moduleGlobList(dm.glob).map(moduleGlobBaseDir));
|
|
691
|
+
}
|
|
692
|
+
export function listAnalyzedFiles(projectRoot, excludeGlobs, declaredModules = [], globalDefaultSurface = DEFAULT_SURFACE) {
|
|
693
|
+
// Computed once for the whole scan, not once per .d.ts candidate file:
|
|
694
|
+
// surfaceGlobsAllowingDts itself derives every module's own surface from
|
|
695
|
+
// its package.json (a file read plus a JSON.parse per module), and a
|
|
696
|
+
// project can have thousands of .d.ts candidates in one walk - the
|
|
697
|
+
// exported, per-file isEligibleSourceFile still recomputes this per
|
|
698
|
+
// call (safe there: callers of that form check a handful of files, not
|
|
699
|
+
// the whole tree). `analysisOnly: true` - this function's only output
|
|
700
|
+
// is the analyzed list, so dist/ is never entered and the other three
|
|
701
|
+
// categories are never collected at all. `init` calls this 4-5 times,
|
|
702
|
+
// each with its own exclude list (noise directories, colocated tests,
|
|
703
|
+
// ...) - pruning each individual call, rather than sharing one fuller
|
|
704
|
+
// walk across all of them, is the simpler of the two fixes for that:
|
|
705
|
+
// `init` needs no change at all, and every other analysis-only caller
|
|
706
|
+
// gets the same win for free. Measured directly on
|
|
707
|
+
// nukadoko-archstrict-adopt (which has no dist/ of its own): `init`
|
|
708
|
+
// took about 61 ms pruned and about 61 ms unpruned - indistinguishable
|
|
709
|
+
// there, since this checkout has nothing under dist/ to skip; the
|
|
710
|
+
// pruning still removes real work (a full descent into a real dist/
|
|
711
|
+
// tree, plus the resolvable/package.json/node_modules collection) on
|
|
712
|
+
// any project that has one.
|
|
713
|
+
const dtsSurfaceGlobs = surfaceGlobsAllowingDts(declaredModules, projectRoot, globalDefaultSurface);
|
|
714
|
+
return walkProjectTree(projectRoot, excludeGlobs, dtsSurfaceGlobs, true, undefined, declaredBasesOf(declaredModules)).analyzedFiles;
|
|
715
|
+
}
|
|
716
|
+
// A proposed new path has never passed through walkProjectTree.
|
|
717
|
+
// Export the eligibility predicate so callers can ask whether that path
|
|
718
|
+
// would qualify, using the same rules as the real scan. Keeping these
|
|
719
|
+
// rules separate from directory traversal lets both paths agree before
|
|
720
|
+
// the proposed file exists on disk.
|
|
721
|
+
export function isEligibleSourceFile(file, projectRoot, excludeGlobs, declaredModules, globalDefaultSurface) {
|
|
722
|
+
return isEligibleSourceFileWithDtsGlobs(file, projectRoot, excludeGlobs, surfaceGlobsAllowingDts(declaredModules, projectRoot, globalDefaultSurface)) && !isPathGitignored(projectRoot, toProjectRelativePosix(file, projectRoot), forcedBasesOf(declaredBasesOf(declaredModules)).bases);
|
|
723
|
+
}
|
|
724
|
+
// Shared core: takes the already-derived .d.ts-allowing surface globs
|
|
725
|
+
// rather than declaredModules directly, so a caller scanning many files at
|
|
726
|
+
// once (listAnalyzedFiles) can derive them exactly once for the whole
|
|
727
|
+
// scan instead of once per candidate file.
|
|
728
|
+
function isEligibleSourceFileWithDtsGlobs(file, projectRoot, excludeGlobs, dtsSurfaceGlobs) {
|
|
729
|
+
const rel = toProjectRelativePosix(file, projectRoot);
|
|
730
|
+
if (!ANALYZED_EXTENSIONS.some((ext) => file.endsWith(ext)) ||
|
|
731
|
+
rel.split("/").some((part) => part === "node_modules" || part === "dist")) {
|
|
732
|
+
return false;
|
|
733
|
+
}
|
|
734
|
+
if (excludeGlobs.some((glob) => compileGlob(glob).test(rel)))
|
|
735
|
+
return false;
|
|
736
|
+
return !isDeclarationFile(file) || dtsSurfaceGlobs.some((glob) => compileGlob(glob).test(rel));
|
|
737
|
+
}
|
|
738
|
+
function buildDeclaredModules(projectRoot, declaredModules, allFiles, globalDefaultSurface = DEFAULT_SURFACE, relativePath = makeProjectRelativePosix(projectRoot)) {
|
|
739
|
+
// `allFiles` is already overlay-aware by the time this runs - simulate's
|
|
740
|
+
// own fileListOverride adds a change set's new file to it before this
|
|
741
|
+
// call. A single-file module's glob names a file that may not exist on
|
|
742
|
+
// disk yet in that case; counting it as existing here (not just via a
|
|
743
|
+
// real disk stat) keeps its surface/rootIsFile classification correct
|
|
744
|
+
// for a proposed file the same way it already is for one that exists.
|
|
745
|
+
const allFilesSet = new Set(allFiles);
|
|
746
|
+
const existsForClassification = (path) => allFilesSet.has(path) || pathIsFile(path);
|
|
747
|
+
const membership = declaredModuleMembership(declaredModules);
|
|
748
|
+
const modules = new Map(declaredModules.map((dm) => {
|
|
749
|
+
const shape = moduleGlobShape(dm);
|
|
750
|
+
const dir = join(projectRoot, shape.kind === "single" ? moduleGlobBaseDir(shape.glob) : shape.dir);
|
|
751
|
+
const boundaryRoots = shape.kind === "single"
|
|
752
|
+
? [dir]
|
|
753
|
+
: shape.globs.map((glob) => join(projectRoot, moduleGlobBaseDir(glob)));
|
|
754
|
+
return [
|
|
755
|
+
dm.name,
|
|
756
|
+
{
|
|
757
|
+
name: dm.name,
|
|
758
|
+
dir,
|
|
759
|
+
rootIsFile: shape.kind === "single" && existsForClassification(dir),
|
|
760
|
+
boundaryRoots,
|
|
761
|
+
files: [],
|
|
762
|
+
surfaceFiles: [],
|
|
763
|
+
surfaceName: shape.kind === "single"
|
|
764
|
+
? effectiveSurface(dm, dir, globalDefaultSurface)
|
|
765
|
+
: (dm.surface ?? globalDefaultSurface),
|
|
766
|
+
friends: (dm.friends ?? []).map((f) => ({
|
|
767
|
+
fileGlob: shape.kind === "single"
|
|
768
|
+
? moduleRelativeGlob(projectRoot, shape.glob, f.file, existsForClassification)
|
|
769
|
+
: joinPosix(shape.dir, f.file),
|
|
770
|
+
from: f.from,
|
|
771
|
+
because: f.because,
|
|
772
|
+
})),
|
|
773
|
+
},
|
|
774
|
+
];
|
|
775
|
+
}));
|
|
776
|
+
const surfaceGlobs = new Map(declaredModules.map((dm) => [
|
|
777
|
+
dm.name,
|
|
778
|
+
surfaceGlobsFor(dm, projectRoot, globalDefaultSurface, existsForClassification).map((g) => compileGlob(g)),
|
|
779
|
+
]));
|
|
780
|
+
for (const file of allFiles) {
|
|
781
|
+
const rel = relativePath(file);
|
|
782
|
+
const name = mostSpecificMatch(rel, membership, (a, b) => a === b);
|
|
783
|
+
if (name === undefined)
|
|
784
|
+
continue;
|
|
785
|
+
// Only surfaceFiles is populated here - `files` (every file, not just
|
|
786
|
+
// the surface) is populated once, in buildModuleGraph's shared walk
|
|
787
|
+
// loop - not duplicated here.
|
|
788
|
+
// surfaceFiles is the UNION of every configured surface glob's own
|
|
789
|
+
// matches - a real package can publish more than one real, equally
|
|
790
|
+
// public entry point at once.
|
|
791
|
+
const globs = surfaceGlobs.get(name);
|
|
792
|
+
if (globs.some((g) => g.test(rel))) {
|
|
793
|
+
modules.get(name).surfaceFiles.push(file);
|
|
794
|
+
}
|
|
795
|
+
}
|
|
796
|
+
for (const module of modules.values()) {
|
|
797
|
+
module.surfaceFiles.sort();
|
|
798
|
+
}
|
|
799
|
+
return modules;
|
|
800
|
+
}
|
|
801
|
+
export function moduleForDeclaredFile(filePath, projectRoot, declaredModules) {
|
|
802
|
+
const rel = toProjectRelativePosix(filePath, projectRoot);
|
|
803
|
+
return mostSpecificMatch(rel, declaredModuleMembership(declaredModules), (a, b) => a === b);
|
|
804
|
+
}
|
|
805
|
+
// parseJsonConfigFileContent's real job (`include`/`exclude` -> a file
|
|
806
|
+
// list) is work this function throws away: it returns only `.options`.
|
|
807
|
+
// Given plain `ts.sys`, it still walks the whole subtree under `include`
|
|
808
|
+
// to build that discarded list - measured on a 23,000-file project at 234
|
|
809
|
+
// ms across the two calls loadCompilerOptions and makeCompilerOptionsForFile
|
|
810
|
+
// make per run (a root tsconfig.json's own `include` covering most of the
|
|
811
|
+
// tree, and a leaf one). `readDirectory: () => []` stops that walk: parsing
|
|
812
|
+
// still needs a real directory-read call, but "no entries" makes every
|
|
813
|
+
// glob match nothing, so the file list comes back empty rather than
|
|
814
|
+
// walking the tree to build one. `paths`/`baseUrl`/`extends` never expand
|
|
815
|
+
// `include`/`exclude` at all, so they resolve identically either way - a
|
|
816
|
+
// leaf tsconfig's own `paths` alias still resolves against ITS OWN
|
|
817
|
+
// directory (this function's own basePath, unaffected by the host).
|
|
818
|
+
// `fileExists`/`readFile` stay real: `extends` resolves another tsconfig
|
|
819
|
+
// file through them, and a stubbed one would silently fail to find it.
|
|
820
|
+
// An empty file list also makes parseJsonConfigFileContent add a "no
|
|
821
|
+
// inputs were found" diagnostic (TS18003) to its own `.errors` array -
|
|
822
|
+
// this function already discards `.errors`, keeping only `.options`, so
|
|
823
|
+
// that diagnostic never reaches a caller either way.
|
|
824
|
+
const noExpandParseConfigHost = {
|
|
825
|
+
useCaseSensitiveFileNames: ts.sys.useCaseSensitiveFileNames,
|
|
826
|
+
readDirectory: () => [],
|
|
827
|
+
fileExists: (p) => ts.sys.fileExists(p),
|
|
828
|
+
readFile: (p) => ts.sys.readFile(p),
|
|
829
|
+
};
|
|
830
|
+
function readCompilerOptions(configPath) {
|
|
831
|
+
const { config } = ts.readConfigFile(configPath, (p) => readFileSync(p, "utf8"));
|
|
832
|
+
// basePath = the config's own directory - a leaf tsconfig's own `paths`
|
|
833
|
+
// (a per-package alias, e.g. "@/*": ["./src/*"]) resolves relative to
|
|
834
|
+
// THIS, not the project root; parseJsonConfigFileContent computes
|
|
835
|
+
// `pathsBasePath` from it. Hand-merging option objects instead of
|
|
836
|
+
// reusing this real TypeScript call would resolve `paths` against the
|
|
837
|
+
// wrong root and produce a different wrong answer, not a correct one.
|
|
838
|
+
return ts.parseJsonConfigFileContent(config, noExpandParseConfigHost, dirname(configPath)).options;
|
|
839
|
+
}
|
|
840
|
+
function loadCompilerOptions(startDir) {
|
|
841
|
+
const configPath = ts.findConfigFile(startDir, ts.sys.fileExists.bind(ts.sys));
|
|
842
|
+
if (configPath === undefined) {
|
|
843
|
+
return { configPath: undefined, options: { target: ts.ScriptTarget.ESNext, module: ts.ModuleKind.NodeNext } };
|
|
844
|
+
}
|
|
845
|
+
return { configPath, options: readCompilerOptions(configPath) };
|
|
846
|
+
}
|
|
847
|
+
// Module resolution needs each file's OWN nearest tsconfig.json, not just
|
|
848
|
+
// the one at the project root - a real TypeScript monorepo convention
|
|
849
|
+
// (findConfigFile walking up from the importing file's own directory),
|
|
850
|
+
// and the one this project's own resolver measurably missed: a leaf
|
|
851
|
+
// package's own `paths` alias (or a jsx/moduleResolution override) was
|
|
852
|
+
// invisible when every file resolved under the same, single root config,
|
|
853
|
+
// inflating unresolvedSpecifierCount for every aliased import in that
|
|
854
|
+
// package. Cached by the config file's own path (a monorepo has one
|
|
855
|
+
// config per package, not one per file) - a directory with no nearer
|
|
856
|
+
// config than the project root's own reuses the already-parsed root
|
|
857
|
+
// options rather than re-parsing the same file per directory.
|
|
858
|
+
//
|
|
859
|
+
// This function serves two callers with the same per-file need. The edge
|
|
860
|
+
// walk uses it to resolve each specifier through its own file's nearest
|
|
861
|
+
// tsconfig. Rule 6's own closure Program (`ensureProgram`, via
|
|
862
|
+
// `resolveModuleNameLiterals`) uses it too, by design: a leaf package's
|
|
863
|
+
// own aliased import (a monorepo path alias a nested tsconfig's own
|
|
864
|
+
// `paths` defines, differently from the root) resolves there too; a
|
|
865
|
+
// plain `ts.createProgram` call under the root options alone cannot see
|
|
866
|
+
// a nested tsconfig at all. One real,
|
|
867
|
+
// intentional difference this leaves standing: `target`/`jsx` still
|
|
868
|
+
// come from the root options for the whole Program (mixing genuinely
|
|
869
|
+
// incompatible per-file compilation targets into one shared Program is a
|
|
870
|
+
// separate architectural question, not attempted here) - only module
|
|
871
|
+
// resolution is per-file. The same holds for each file's ESM/CJS format
|
|
872
|
+
// inside that Program: TypeScript derives it from the root options, while
|
|
873
|
+
// the edge walk derives it from the file's own nearest tsconfig, so a
|
|
874
|
+
// nested tsconfig that overrides `module`/`moduleResolution` can make the
|
|
875
|
+
// two disagree on which export condition applies.
|
|
876
|
+
function makeCompilerOptionsForFile(rootOptions, rootConfigPath) {
|
|
877
|
+
const optionsByConfigPath = new Map();
|
|
878
|
+
if (rootConfigPath !== undefined)
|
|
879
|
+
optionsByConfigPath.set(rootConfigPath, rootOptions);
|
|
880
|
+
// The cache above already stops readCompilerOptions from re-parsing the
|
|
881
|
+
// same tsconfig.json twice, but ts.findConfigFile itself still does one
|
|
882
|
+
// fileExists check per directory level between a file and its nearest
|
|
883
|
+
// config - that walk ran again for every file in the same directory,
|
|
884
|
+
// and buildPreparedGraph's edge walk calls this once per import
|
|
885
|
+
// specifier (not once per file), so a file with several imports
|
|
886
|
+
// repeated its own directory's walk several times over.
|
|
887
|
+
// graphBuildFingerprint also calls this once per root file, through the
|
|
888
|
+
// same closure, when buildModuleGraphForRules checks its cache before
|
|
889
|
+
// buildPreparedGraph's own edge walk runs - repeating every directory's
|
|
890
|
+
// walk a second time. Caching by the starting directory turns each
|
|
891
|
+
// directory's own walk into one lookup after its first caller.
|
|
892
|
+
const configPathByDir = new Map();
|
|
893
|
+
return (filePath) => {
|
|
894
|
+
const dir = dirname(filePath);
|
|
895
|
+
let configPath = configPathByDir.get(dir);
|
|
896
|
+
if (configPath === undefined && !configPathByDir.has(dir)) {
|
|
897
|
+
configPath = ts.findConfigFile(dir, ts.sys.fileExists.bind(ts.sys));
|
|
898
|
+
configPathByDir.set(dir, configPath);
|
|
899
|
+
}
|
|
900
|
+
if (configPath === undefined)
|
|
901
|
+
return rootOptions;
|
|
902
|
+
const cached = optionsByConfigPath.get(configPath);
|
|
903
|
+
if (cached !== undefined)
|
|
904
|
+
return cached;
|
|
905
|
+
const options = readCompilerOptions(configPath);
|
|
906
|
+
optionsByConfigPath.set(configPath, options);
|
|
907
|
+
return options;
|
|
908
|
+
};
|
|
909
|
+
}
|
|
910
|
+
export function prepareGraph(options) {
|
|
911
|
+
// Realpath'd up front, not just at whichever comparison happens to need
|
|
912
|
+
// it: TypeScript's own resolver already returns a symlink-resolved
|
|
913
|
+
// `resolvedFileName` for any import that passes through one (measured
|
|
914
|
+
// directly - a workspace package symlinked into node_modules, and
|
|
915
|
+
// separately, a platform's own tmp-directory symlink like macOS's
|
|
916
|
+
// /tmp -> /private/tmp), so a non-realpath'd `projectRoot` would make
|
|
917
|
+
// every relative-path computation downstream (toProjectRelativePosix,
|
|
918
|
+
// declaredModules glob matching, exclude glob matching) silently
|
|
919
|
+
// disagree with the paths TypeScript itself already resolved to.
|
|
920
|
+
const { declaredModules, surface = DEFAULT_SURFACE, exclude = [] } = options;
|
|
921
|
+
const projectRoot = realpathSync(options.projectRoot);
|
|
922
|
+
const relativePath = makeProjectRelativePosix(projectRoot);
|
|
923
|
+
const { configPath: rootConfigPath, options: compilerOptions } = loadCompilerOptions(projectRoot);
|
|
924
|
+
const compilerOptionsForFile = makeCompilerOptionsForFile(compilerOptions, rootConfigPath);
|
|
925
|
+
const rootDir = projectRoot;
|
|
926
|
+
// One walk produces the analyzed file list and the non-TS source count
|
|
927
|
+
// every caller needs, plus the resolvable-file set, the package.json
|
|
928
|
+
// list, and the node_modules directories found by descent - the three
|
|
929
|
+
// extra ones only buildModuleGraphForRules' own resolutionInputs reads,
|
|
930
|
+
// at no extra walk cost to a caller (simulate, fix, search) that never
|
|
931
|
+
// touches them.
|
|
932
|
+
const dtsSurfaceGlobs = surfaceGlobsAllowingDts(declaredModules, projectRoot, surface);
|
|
933
|
+
const tree = walkProjectTree(projectRoot, exclude, dtsSurfaceGlobs, false, relativePath, declaredBasesOf(declaredModules));
|
|
934
|
+
let rootNames = tree.analyzedFiles;
|
|
935
|
+
if (options.fileListOverride)
|
|
936
|
+
rootNames = options.fileListOverride(rootNames);
|
|
937
|
+
let resolvableFiles = tree.resolvableFiles;
|
|
938
|
+
if (options.resolvableFileListOverride)
|
|
939
|
+
resolvableFiles = options.resolvableFileListOverride(resolvableFiles);
|
|
940
|
+
const modules = buildDeclaredModules(projectRoot, declaredModules, rootNames, surface, relativePath);
|
|
941
|
+
// Cached by absolute file path: buildPreparedGraph calls this once per
|
|
942
|
+
// source file AND once per edge's resolvedFile, and a widely-imported
|
|
943
|
+
// file (a shared utils module, a design-system entry point) is a common
|
|
944
|
+
// edge target hundreds of times over in a real codebase - each repeat
|
|
945
|
+
// was a fresh O(declaredModules) glob-match walk over the exact same
|
|
946
|
+
// answer. Safe for the lifetime of one prepareGraph call: projectRoot
|
|
947
|
+
// and declaredModules are both fixed for that call.
|
|
948
|
+
const moduleForFileCache = new Map();
|
|
949
|
+
const membership = declaredModuleMembership(declaredModules);
|
|
950
|
+
const resolveModuleForFile = (filePath) => {
|
|
951
|
+
if (moduleForFileCache.has(filePath))
|
|
952
|
+
return moduleForFileCache.get(filePath);
|
|
953
|
+
const result = mostSpecificMatch(relativePath(filePath), membership, (a, b) => a === b);
|
|
954
|
+
moduleForFileCache.set(filePath, result);
|
|
955
|
+
return result;
|
|
956
|
+
};
|
|
957
|
+
return { projectRoot, surface, rootDir, rootNames, modules, relativePath, resolveModuleForFile, compilerOptions, compilerOptionsForFile,
|
|
958
|
+
nonTsSourceFileCount: tree.nonTsSourceFileCount, resolvableFiles,
|
|
959
|
+
packageJsonFiles: tree.packageJsonFiles, nodeModulesDirs: tree.nodeModulesDirs };
|
|
960
|
+
}
|
|
961
|
+
export function buildModuleGraph(options) {
|
|
962
|
+
return buildPreparedGraph(prepareGraph(options));
|
|
963
|
+
}
|
|
964
|
+
// A top-level string-named module declaration carries the only syntax the
|
|
965
|
+
// scoped safety guard needs. Keeping this extraction separate is required
|
|
966
|
+
// because non-analyzed files must not pay for the full import walk.
|
|
967
|
+
function moduleAugmentationSpecifiers(sf, compilerOptions, includeScripts) {
|
|
968
|
+
// A script declaration defines an ambient module. The analyzed-file walk
|
|
969
|
+
// refuses to label it as an augmentation because the script is a root.
|
|
970
|
+
if (!includeScripts && !ts.isExternalModule(sf))
|
|
971
|
+
return [];
|
|
972
|
+
const result = [];
|
|
973
|
+
for (const statement of sf.statements) {
|
|
974
|
+
if (!ts.isModuleDeclaration(statement) || !ts.isStringLiteral(statement.name))
|
|
975
|
+
continue;
|
|
976
|
+
result.push({
|
|
977
|
+
specifier: statement.name.text,
|
|
978
|
+
mode: ts.getModeForUsageLocation(sf, statement.name, compilerOptions),
|
|
979
|
+
});
|
|
980
|
+
}
|
|
981
|
+
return result;
|
|
982
|
+
}
|
|
983
|
+
// TypeScript's own default (ensureScriptKind, applied when a caller of
|
|
984
|
+
// ts.createSourceFile omits scriptKind) already maps every analyzed
|
|
985
|
+
// extension this way - .tsx to TSX, everything else (.ts/.mts/.cts) to
|
|
986
|
+
// plain TS, since ts.ScriptKind itself has no separate Mts/Cts member.
|
|
987
|
+
// Made explicit here rather than left to that implicit default: this
|
|
988
|
+
// project's per-file parse is deliberate about which of TypeScript's own
|
|
989
|
+
// two source dialects (JSX-capable or not) it invokes, not a place that
|
|
990
|
+
// should silently follow whatever TypeScript's own default happens to be
|
|
991
|
+
// this version. Exported: warm-graph.ts's own per-file cache parses a
|
|
992
|
+
// file the same way, outside this module's own buildPreparedGraph.
|
|
993
|
+
export function scriptKindForFile(fileName) {
|
|
994
|
+
return fileName.endsWith(".tsx") ? ts.ScriptKind.TSX : ts.ScriptKind.TS;
|
|
995
|
+
}
|
|
996
|
+
// The per-file half of the edge walk: every import/export/dynamic-import/
|
|
997
|
+
// import-type specifier syntax recognizes, plus a count of syntax it doesn't
|
|
998
|
+
// (require(), import x = require(...)) - no resolution, no Program, no
|
|
999
|
+
// module graph. Kept separate from buildPreparedGraph's own resolution
|
|
1000
|
+
// loop below so warm-graph.ts can memoize exactly this part.
|
|
1001
|
+
// `compilerOptions` is this file's own effective options (compilerOptionsForFile,
|
|
1002
|
+
// not necessarily the project root's) - passed through unchanged to
|
|
1003
|
+
// ts.getModeForUsageLocation for every specifier, so the mode recorded
|
|
1004
|
+
// here is the same one the resolver (buildPreparedGraph's own
|
|
1005
|
+
// resolveModule) will resolve that same specifier under. `sf` must have
|
|
1006
|
+
// been parsed with `setParentNodes: true`: getModeForUsageLocation reads
|
|
1007
|
+
// `usage.parent` (and, for `import type ... with { "resolution-mode" }`,
|
|
1008
|
+
// `usage.parent.parent`) - measured directly, a parent-less literal makes
|
|
1009
|
+
// it throw rather than return undefined.
|
|
1010
|
+
export function walkFileImports(sf, compilerOptions) {
|
|
1011
|
+
const imports = [];
|
|
1012
|
+
let unsupportedSyntaxCount = 0;
|
|
1013
|
+
ts.forEachChild(sf, function walk(node) {
|
|
1014
|
+
let specifier;
|
|
1015
|
+
let isTypeOnly = false;
|
|
1016
|
+
let isDynamic = false;
|
|
1017
|
+
if (ts.isImportDeclaration(node)) {
|
|
1018
|
+
specifier = node.moduleSpecifier;
|
|
1019
|
+
isTypeOnly = isEffectivelyTypeOnlyImport(node.importClause);
|
|
1020
|
+
}
|
|
1021
|
+
else if (ts.isExportDeclaration(node) && node.moduleSpecifier !== undefined) {
|
|
1022
|
+
specifier = node.moduleSpecifier;
|
|
1023
|
+
isTypeOnly = isEffectivelyTypeOnlyExport(node);
|
|
1024
|
+
}
|
|
1025
|
+
else if (ts.isCallExpression(node) &&
|
|
1026
|
+
node.expression.kind === ts.SyntaxKind.ImportKeyword &&
|
|
1027
|
+
node.arguments[0] !== undefined &&
|
|
1028
|
+
ts.isStringLiteral(node.arguments[0])) {
|
|
1029
|
+
specifier = node.arguments[0];
|
|
1030
|
+
isDynamic = true;
|
|
1031
|
+
}
|
|
1032
|
+
else if (ts.isImportTypeNode(node) &&
|
|
1033
|
+
ts.isLiteralTypeNode(node.argument) &&
|
|
1034
|
+
ts.isStringLiteral(node.argument.literal)) {
|
|
1035
|
+
// `import("./x").Y` in a type position: a real type dependency on
|
|
1036
|
+
// the target file, not syntax that merely mentions a module name.
|
|
1037
|
+
// Recorded as type-only (never dynamic - "dynamic" here means the
|
|
1038
|
+
// runtime `import()` expression, which this is not) so rule 1
|
|
1039
|
+
// (public-surface bypass) and the edge constraints see it; without
|
|
1040
|
+
// this, `import("../b/internal.js").T` reached past a surface unseen.
|
|
1041
|
+
specifier = node.argument.literal;
|
|
1042
|
+
isTypeOnly = true;
|
|
1043
|
+
}
|
|
1044
|
+
else if (ts.isImportEqualsDeclaration(node) &&
|
|
1045
|
+
ts.isExternalModuleReference(node.moduleReference)) {
|
|
1046
|
+
// `import x = require("./y")`: a CommonJS-only form, outside the
|
|
1047
|
+
// ESM scope this project analyzes.
|
|
1048
|
+
unsupportedSyntaxCount++;
|
|
1049
|
+
}
|
|
1050
|
+
else if (ts.isCallExpression(node) &&
|
|
1051
|
+
ts.isIdentifier(node.expression) &&
|
|
1052
|
+
node.expression.text === "require") {
|
|
1053
|
+
unsupportedSyntaxCount++;
|
|
1054
|
+
}
|
|
1055
|
+
if (specifier !== undefined && ts.isStringLiteral(specifier)) {
|
|
1056
|
+
const start = specifier.getStart(sf);
|
|
1057
|
+
const { line, character } = sf.getLineAndCharacterOfPosition(start);
|
|
1058
|
+
imports.push({
|
|
1059
|
+
specifier: specifier.text,
|
|
1060
|
+
fromPosition: { line: line + 1, column: character + 1 },
|
|
1061
|
+
isTypeOnly,
|
|
1062
|
+
isDynamic,
|
|
1063
|
+
mode: ts.getModeForUsageLocation(sf, specifier, compilerOptions),
|
|
1064
|
+
});
|
|
1065
|
+
}
|
|
1066
|
+
ts.forEachChild(node, walk);
|
|
1067
|
+
});
|
|
1068
|
+
const isScript = !ts.isExternalModule(sf);
|
|
1069
|
+
const hasAmbientDeclarations = sf.statements.some((statement) => isGlobalAugmentationOrAmbientModule(statement) || (isScript && isTopLevelDeclaration(statement)));
|
|
1070
|
+
const augmentations = moduleAugmentationSpecifiers(sf, compilerOptions, false);
|
|
1071
|
+
return {
|
|
1072
|
+
imports, unsupportedSyntaxCount, isScript, hasAmbientDeclarations,
|
|
1073
|
+
hasModuleAugmentation: augmentations.length > 0,
|
|
1074
|
+
moduleAugmentationSpecifiers: augmentations,
|
|
1075
|
+
};
|
|
1076
|
+
}
|
|
1077
|
+
// Parses one file and walks it for imports, in one place both real parse
|
|
1078
|
+
// paths (buildPreparedGraph's own default walk, and warm-graph.ts's own
|
|
1079
|
+
// cached one) call, so both compute the same resolution mode the same
|
|
1080
|
+
// way. `impliedNodeFormat` (needed before the mode of any specifier
|
|
1081
|
+
// inside can be known - see walkFileImports' own header) depends on the
|
|
1082
|
+
// nearest package.json's own "type" field for a .ts/.tsx/.js/.jsx file
|
|
1083
|
+
// (fixed by extension alone for .mts/.cts/.mjs/.cjs); `host` supplies the
|
|
1084
|
+
// fileExists/readFile that lookup needs, and `packageJsonInfoCache`
|
|
1085
|
+
// (a ts.ModuleResolutionCache's own getPackageJsonInfoCache(), or
|
|
1086
|
+
// undefined) lets a caller that already has one avoid re-reading the
|
|
1087
|
+
// same package.json for every ambiguous file - undefined costs an extra
|
|
1088
|
+
// read per such file, never a wrong answer. `setExternalModuleIndicator`
|
|
1089
|
+
// is deliberately NOT set here (unlike a real ts.Program, which sets it
|
|
1090
|
+
// via getSetExternalModuleIndicator): that indicator, not
|
|
1091
|
+
// impliedNodeFormat, decides `isScript` (via ts.isExternalModule) for a
|
|
1092
|
+
// file with no import/export syntax of its own, and setting it would
|
|
1093
|
+
// reclassify an import-less "type": "module" file as a module in a way
|
|
1094
|
+
// this fix's own scope (resolution mode only - see this module's header)
|
|
1095
|
+
// must not touch.
|
|
1096
|
+
export function parseFileForImports(fileName, text, languageVersion, host, compilerOptions, packageJsonInfoCache) {
|
|
1097
|
+
const impliedNodeFormat = ts.getImpliedNodeFormatForFile(fileName, packageJsonInfoCache, host, compilerOptions);
|
|
1098
|
+
const sf = ts.createSourceFile(fileName, text, { languageVersion, impliedNodeFormat }, true, scriptKindForFile(fileName));
|
|
1099
|
+
return walkFileImports(sf, compilerOptions);
|
|
1100
|
+
}
|
|
1101
|
+
// `declare global { ... }` (GlobalAugmentation) or `declare module "literal
|
|
1102
|
+
// name"` (a StringLiteral name) - binds names no import ever names,
|
|
1103
|
+
// unlike a plain `namespace X {}`/`declare namespace X {}` (an Identifier
|
|
1104
|
+
// name), which is an ordinary, reachable local declaration.
|
|
1105
|
+
function isGlobalAugmentationOrAmbientModule(statement) {
|
|
1106
|
+
return ts.isModuleDeclaration(statement) &&
|
|
1107
|
+
(statement.name.kind === ts.SyntaxKind.StringLiteral || (statement.flags & ts.NodeFlags.GlobalAugmentation) !== 0);
|
|
1108
|
+
}
|
|
1109
|
+
// Every top-level statement shape type-closure.ts's own per-file summary
|
|
1110
|
+
// treats as a named declaration - mirrored here only to decide whether a
|
|
1111
|
+
// script file actually binds anything into the global scope, not to
|
|
1112
|
+
// summarize its own references (that stays type-closure.ts's own job).
|
|
1113
|
+
function isTopLevelDeclaration(statement) {
|
|
1114
|
+
return ts.isInterfaceDeclaration(statement) || ts.isTypeAliasDeclaration(statement) || ts.isClassDeclaration(statement) ||
|
|
1115
|
+
ts.isFunctionDeclaration(statement) || ts.isEnumDeclaration(statement) || ts.isModuleDeclaration(statement) ||
|
|
1116
|
+
ts.isVariableStatement(statement) ||
|
|
1117
|
+
(ts.isImportEqualsDeclaration(statement) && !ts.isExternalModuleReference(statement.moduleReference));
|
|
1118
|
+
}
|
|
1119
|
+
function makeGraphCommons(prepared, overrides) {
|
|
1120
|
+
const { rootNames, compilerOptions, compilerOptionsForFile } = prepared;
|
|
1121
|
+
const host = overrides.host ?? ts.createCompilerHost(compilerOptions);
|
|
1122
|
+
const languageVersion = compilerOptions.target ?? ts.ScriptTarget.ESNext;
|
|
1123
|
+
// One ts.ModuleResolutionCache per distinct compiler-options object
|
|
1124
|
+
// (a monorepo can have many, one per leaf tsconfig - see
|
|
1125
|
+
// compilerOptionsForFile's own comment), unless the caller supplies one
|
|
1126
|
+
// cache to use for every file regardless of its own options
|
|
1127
|
+
// (overrides.resolutionCache - simulate.ts's own single-cache-per-run
|
|
1128
|
+
// convention, kept as-is here). Always returns a real cache (never
|
|
1129
|
+
// undefined) - every branch below produces one - so a caller needing
|
|
1130
|
+
// its own getPackageJsonInfoCache() (parseFileForImports' own
|
|
1131
|
+
// impliedNodeFormat lookup) can call it directly, with no extra
|
|
1132
|
+
// plumbing for a case that cannot happen.
|
|
1133
|
+
const resolutionCaches = new Map();
|
|
1134
|
+
const resolutionCacheFor = (options) => {
|
|
1135
|
+
if (overrides.resolutionCache !== undefined)
|
|
1136
|
+
return overrides.resolutionCache;
|
|
1137
|
+
let cache = resolutionCaches.get(options);
|
|
1138
|
+
if (cache === undefined) {
|
|
1139
|
+
cache = ts.createModuleResolutionCache(host.getCurrentDirectory(), host.getCanonicalFileName, options);
|
|
1140
|
+
resolutionCaches.set(options, cache);
|
|
1141
|
+
}
|
|
1142
|
+
return cache;
|
|
1143
|
+
};
|
|
1144
|
+
const defaultFileWalk = (fileName) => {
|
|
1145
|
+
const text = host.readFile(fileName);
|
|
1146
|
+
if (text === undefined)
|
|
1147
|
+
return undefined;
|
|
1148
|
+
const options = compilerOptionsForFile(fileName);
|
|
1149
|
+
return parseFileForImports(fileName, text, languageVersion, host, options, resolutionCacheFor(options).getPackageJsonInfoCache());
|
|
1150
|
+
};
|
|
1151
|
+
// Most non-analyzed project files contain no augmentation. Reading their
|
|
1152
|
+
// text is cheaper than parsing them all, so only a matching file gets an AST.
|
|
1153
|
+
// The prefilter keys on `module` followed by a quote, with only whitespace
|
|
1154
|
+
// or comments between them. Requiring `declare` is refused: a declaration
|
|
1155
|
+
// file applies `module "x" {}` without it, and a comment can sit between
|
|
1156
|
+
// the keywords. A false match costs one parse; a missed one hides a leak.
|
|
1157
|
+
const scanModuleAugmentations = (fileName) => {
|
|
1158
|
+
const text = host.readFile(fileName);
|
|
1159
|
+
if (text === undefined)
|
|
1160
|
+
return undefined;
|
|
1161
|
+
if (!/\bmodule(?:\s|\/\*[\s\S]*?\*\/|\/\/[^\n]*\n)*["']/.test(text))
|
|
1162
|
+
return [];
|
|
1163
|
+
const options = compilerOptionsForFile(fileName);
|
|
1164
|
+
const packageJsonInfoCache = resolutionCacheFor(options).getPackageJsonInfoCache();
|
|
1165
|
+
const impliedNodeFormat = ts.getImpliedNodeFormatForFile(fileName, packageJsonInfoCache, host, options);
|
|
1166
|
+
const sf = ts.createSourceFile(fileName, text, { languageVersion, impliedNodeFormat }, true, scriptKindForFile(fileName));
|
|
1167
|
+
return moduleAugmentationSpecifiers(sf, options, true);
|
|
1168
|
+
};
|
|
1169
|
+
// A cached syntax answer is valid only under the parse options and module
|
|
1170
|
+
// format that produce its usage modes. Reusing by file metadata alone is
|
|
1171
|
+
// refused because a config or package type change can change those modes.
|
|
1172
|
+
// Files under one tsconfig share one options object. Hashing per file is
|
|
1173
|
+
// refused because a warm surface check visits every candidate, and on a
|
|
1174
|
+
// 23,000-file project that hash was a fifth of the whole run.
|
|
1175
|
+
const optionsHashes = new WeakMap();
|
|
1176
|
+
const augmentationScanMetadata = (fileName) => {
|
|
1177
|
+
const options = compilerOptionsForFile(fileName);
|
|
1178
|
+
const packageJsonInfoCache = resolutionCacheFor(options).getPackageJsonInfoCache();
|
|
1179
|
+
let optionsHash = optionsHashes.get(options);
|
|
1180
|
+
if (optionsHash === undefined) {
|
|
1181
|
+
optionsHash = createHash("sha256").update(JSON.stringify(options)).digest("hex");
|
|
1182
|
+
optionsHashes.set(options, optionsHash);
|
|
1183
|
+
}
|
|
1184
|
+
return {
|
|
1185
|
+
optionsHash,
|
|
1186
|
+
impliedNodeFormat: ts.getImpliedNodeFormatForFile(fileName, packageJsonInfoCache, host, options),
|
|
1187
|
+
};
|
|
1188
|
+
};
|
|
1189
|
+
// Every specifier resolution in this build goes through this one
|
|
1190
|
+
// function - the edge walk below, and (via optionsForContainingFile)
|
|
1191
|
+
// both branches of ensureProgram's own closureHost. `mode` decides
|
|
1192
|
+
// which of a dual package's own "import"/"require" export condition
|
|
1193
|
+
// (or a condition-scoped "types") applies under node16/nodenext; the
|
|
1194
|
+
// caller supplies it because only the caller has the real usage site
|
|
1195
|
+
// (an ImportRecord already carrying its own mode, or a live AST literal
|
|
1196
|
+
// node) getModeForUsageLocation needs to compute it - see
|
|
1197
|
+
// walkFileImports' own header for where that happens for an analyzed
|
|
1198
|
+
// file's own specifier. A scanned non-analyzed file resolves its
|
|
1199
|
+
// augmentation target here too, under the same options the full Program
|
|
1200
|
+
// gives it. Its nearest tsconfig is refused because the full Program never
|
|
1201
|
+
// reads that tsconfig, so the scan would guard a target the Program misses.
|
|
1202
|
+
const analyzedSet = new Set(rootNames);
|
|
1203
|
+
const optionsForContainingFile = (containingFile, redirectedReference) => analyzedSet.has(containingFile) ? compilerOptionsForFile(containingFile) : (redirectedReference?.commandLine.options ?? compilerOptions);
|
|
1204
|
+
const sourceFileOptionsFor = (fileName) => {
|
|
1205
|
+
const options = optionsForContainingFile(fileName);
|
|
1206
|
+
return {
|
|
1207
|
+
compilerOptions: options,
|
|
1208
|
+
impliedNodeFormat: ts.getImpliedNodeFormatForFile(fileName, resolutionCacheFor(options).getPackageJsonInfoCache(), host, options),
|
|
1209
|
+
};
|
|
1210
|
+
};
|
|
1211
|
+
const resolveModule = (specifier, containingFile, mode, redirectedReference) => {
|
|
1212
|
+
const options = optionsForContainingFile(containingFile, redirectedReference);
|
|
1213
|
+
return ts.resolveModuleName(specifier, containingFile, options, host, resolutionCacheFor(options), redirectedReference, mode);
|
|
1214
|
+
};
|
|
1215
|
+
return { host, resolutionCacheFor, optionsForContainingFile, sourceFileOptionsFor, resolveModule, analyzedSet,
|
|
1216
|
+
defaultFileWalk, scanModuleAugmentations, augmentationScanMetadata };
|
|
1217
|
+
}
|
|
1218
|
+
// One import's own edge (or the reason it has none yet) - shared by the
|
|
1219
|
+
// cold, always-resolve walk below and buildModuleGraphForRules' own
|
|
1220
|
+
// disk-cache reconciliation, so a builtin and an external-package edge
|
|
1221
|
+
// are built identically whichever path produced the underlying
|
|
1222
|
+
// resolution. `resolution` is `undefined` for a builtin (no real
|
|
1223
|
+
// resolvedFile - the specifier itself, "node:"-stripped, stands in for
|
|
1224
|
+
// one) and the caller-supplied resolved outcome (or "unresolved")
|
|
1225
|
+
// otherwise.
|
|
1226
|
+
function edgeFor(fileName, fromModule, imp, resolution, resolveModuleForFile, projectRoot) {
|
|
1227
|
+
const builtin = builtinModuleName(imp.specifier);
|
|
1228
|
+
if (builtin !== undefined) {
|
|
1229
|
+
return { edge: {
|
|
1230
|
+
fromFile: fileName, fromModule, fromPosition: imp.fromPosition, specifier: imp.specifier,
|
|
1231
|
+
mode: imp.mode, isTypeOnly: imp.isTypeOnly, isDynamic: imp.isDynamic,
|
|
1232
|
+
resolvedFile: `node:${builtin}`, toModule: undefined, externalPackage: builtin,
|
|
1233
|
+
} };
|
|
1234
|
+
}
|
|
1235
|
+
if (resolution === undefined || resolution === "unresolved")
|
|
1236
|
+
return { unresolvedSpecifier: imp.specifier };
|
|
1237
|
+
const { resolvedFile } = resolution;
|
|
1238
|
+
const toModule = resolveModuleForFile(resolvedFile);
|
|
1239
|
+
const externalPackage = resolution.isExternalLibraryImport && !isWorkspaceSiblingResolution(resolvedFile, projectRoot)
|
|
1240
|
+
? (resolution.packageName ?? imp.specifier.replace(/^node:/, "")) : undefined;
|
|
1241
|
+
return { edge: {
|
|
1242
|
+
fromFile: fileName, fromModule, fromPosition: imp.fromPosition, specifier: imp.specifier,
|
|
1243
|
+
mode: imp.mode, isTypeOnly: imp.isTypeOnly, isDynamic: imp.isDynamic, resolvedFile, toModule, externalPackage,
|
|
1244
|
+
} };
|
|
1245
|
+
}
|
|
1246
|
+
// Only TypeScript-shaped files can contain a declaration that changes the
|
|
1247
|
+
// checker. Scanning JavaScript and JSON is refused because they cannot hold it.
|
|
1248
|
+
function nonAnalyzedAugmentationCandidates(resolvableFiles, analyzedSet) {
|
|
1249
|
+
return resolvableFiles.filter((file) => !analyzedSet.has(file) && ANALYZED_EXTENSIONS.some((extension) => file.endsWith(extension)));
|
|
1250
|
+
}
|
|
1251
|
+
// Only a focused rule-6 call consumes this scan. Adding it to the graph walk
|
|
1252
|
+
// is refused because full checks and non-surface checks cannot use the result.
|
|
1253
|
+
function scanNonAnalyzedModuleAugmentations(prepared, commons, overrides) {
|
|
1254
|
+
const path = join(prepared.projectRoot, "node_modules/.cache/archstrict/augmentations.json");
|
|
1255
|
+
const cached = readAugmentationCache(path, ARCHSTRICT_VERSION);
|
|
1256
|
+
const candidates = nonAnalyzedAugmentationCandidates(prepared.resolvableFiles, commons.analyzedSet);
|
|
1257
|
+
const files = {};
|
|
1258
|
+
const result = new Map();
|
|
1259
|
+
let dirty = false;
|
|
1260
|
+
for (const file of candidates) {
|
|
1261
|
+
let before;
|
|
1262
|
+
if (overrides.dirtyFiles?.has(file) && commons.host.fileExists(file)) {
|
|
1263
|
+
before = { mtimeMs: -1, size: Buffer.byteLength(commons.host.readFile(file) ?? "") };
|
|
1264
|
+
}
|
|
1265
|
+
else
|
|
1266
|
+
try {
|
|
1267
|
+
const value = statSync(file);
|
|
1268
|
+
before = { mtimeMs: value.mtimeMs, size: value.size };
|
|
1269
|
+
}
|
|
1270
|
+
catch {
|
|
1271
|
+
if (cached?.files[file] !== undefined)
|
|
1272
|
+
dirty = true;
|
|
1273
|
+
continue;
|
|
1274
|
+
}
|
|
1275
|
+
const metadata = commons.augmentationScanMetadata(file);
|
|
1276
|
+
const old = cached?.files[file];
|
|
1277
|
+
let specifiers;
|
|
1278
|
+
let scanned = false;
|
|
1279
|
+
if (!overrides.dirtyFiles?.has(file) && old !== undefined && old.mtimeMs === before.mtimeMs && old.size === before.size &&
|
|
1280
|
+
old.optionsHash === metadata.optionsHash && old.impliedNodeFormat === metadata.impliedNodeFormat) {
|
|
1281
|
+
specifiers = old.specifiers;
|
|
1282
|
+
}
|
|
1283
|
+
else {
|
|
1284
|
+
overrides.onAugmentationCandidateReadForTests?.(file);
|
|
1285
|
+
specifiers = commons.scanModuleAugmentations(file);
|
|
1286
|
+
scanned = true;
|
|
1287
|
+
}
|
|
1288
|
+
// An unreadable file must be retried. Caching an empty answer is refused
|
|
1289
|
+
// because a later call can read the same unchanged file successfully.
|
|
1290
|
+
if (specifiers === undefined) {
|
|
1291
|
+
if (old !== undefined)
|
|
1292
|
+
dirty = true;
|
|
1293
|
+
continue;
|
|
1294
|
+
}
|
|
1295
|
+
let stable = overrides.dirtyFiles?.has(file) === true;
|
|
1296
|
+
try {
|
|
1297
|
+
if (!stable) {
|
|
1298
|
+
const after = statSync(file);
|
|
1299
|
+
stable = after.mtimeMs === before.mtimeMs && after.size === before.size;
|
|
1300
|
+
}
|
|
1301
|
+
}
|
|
1302
|
+
catch { /* The next call retries a file that disappeared during the scan. */ }
|
|
1303
|
+
if (stable) {
|
|
1304
|
+
files[file] = { ...before, ...metadata, specifiers };
|
|
1305
|
+
if (scanned)
|
|
1306
|
+
dirty = true;
|
|
1307
|
+
}
|
|
1308
|
+
else if (old !== undefined)
|
|
1309
|
+
dirty = true;
|
|
1310
|
+
if (specifiers.length > 0)
|
|
1311
|
+
result.set(file, specifiers);
|
|
1312
|
+
}
|
|
1313
|
+
if (cached !== undefined) {
|
|
1314
|
+
const oldPaths = Object.keys(cached.files);
|
|
1315
|
+
if (oldPaths.length !== Object.keys(files).length || oldPaths.some((file) => files[file] === undefined))
|
|
1316
|
+
dirty = true;
|
|
1317
|
+
}
|
|
1318
|
+
if (dirty && overrides.persistCache !== false) {
|
|
1319
|
+
try {
|
|
1320
|
+
writeAugmentationCache(path, ARCHSTRICT_VERSION, files);
|
|
1321
|
+
}
|
|
1322
|
+
catch {
|
|
1323
|
+
// The scan is authoritative for this call. Failing the check for an
|
|
1324
|
+
// optional cache write is refused because the next call can scan again.
|
|
1325
|
+
}
|
|
1326
|
+
}
|
|
1327
|
+
return result;
|
|
1328
|
+
}
|
|
1329
|
+
// The cold, always-resolve walk: every rootName is parsed (via `fileWalk`)
|
|
1330
|
+
// and every one of its specifiers is resolved through `commons.resolveModule`,
|
|
1331
|
+
// with no cache of any kind consulted. buildModuleGraphForRules' own
|
|
1332
|
+
// disk-cache reconciliation walks the identical rootNames list but skips
|
|
1333
|
+
// this function entirely for a file whose parse and resolutions are both
|
|
1334
|
+
// still valid.
|
|
1335
|
+
function walkAllFiles(prepared, commons, fileWalk) {
|
|
1336
|
+
const { rootNames, modules, resolveModuleForFile, projectRoot } = prepared;
|
|
1337
|
+
const outsideFiles = [];
|
|
1338
|
+
const edges = [];
|
|
1339
|
+
let unsupportedSyntaxCount = 0;
|
|
1340
|
+
let unresolvedSpecifierCount = 0;
|
|
1341
|
+
const unresolvedSpecifiers = [];
|
|
1342
|
+
// Walked in rootNames order (listAnalyzedFiles' own directory-scan
|
|
1343
|
+
// order), not program.getSourceFiles()'s dependency order - there is no
|
|
1344
|
+
// Program to walk here. Every edges/modules-membership/outsideFiles
|
|
1345
|
+
// consumer that cares about a stable order sorts at its own site rather
|
|
1346
|
+
// than leaning on this order (see each rule's own comment where that
|
|
1347
|
+
// applies); this loop makes no ordering promise beyond "rootNames order".
|
|
1348
|
+
const fileFlags = new Map();
|
|
1349
|
+
for (const fileName of rootNames) {
|
|
1350
|
+
const walked = fileWalk(fileName);
|
|
1351
|
+
if (walked === undefined)
|
|
1352
|
+
continue; // unreadable: invisible, matching a Program that never got a SourceFile for it either
|
|
1353
|
+
fileFlags.set(fileName, {
|
|
1354
|
+
isScript: walked.isScript,
|
|
1355
|
+
hasAmbientDeclarations: walked.hasAmbientDeclarations,
|
|
1356
|
+
hasModuleAugmentation: walked.hasModuleAugmentation,
|
|
1357
|
+
moduleAugmentationSpecifiers: walked.moduleAugmentationSpecifiers,
|
|
1358
|
+
});
|
|
1359
|
+
const fromModule = resolveModuleForFile(fileName);
|
|
1360
|
+
if (fromModule === undefined) {
|
|
1361
|
+
outsideFiles.push(fileName);
|
|
1362
|
+
continue;
|
|
1363
|
+
}
|
|
1364
|
+
modules.get(fromModule)?.files.push(fileName);
|
|
1365
|
+
unsupportedSyntaxCount += walked.unsupportedSyntaxCount;
|
|
1366
|
+
for (const imp of walked.imports) {
|
|
1367
|
+
// Resolved regardless of a leading "." - a bare specifier
|
|
1368
|
+
// (`@internal/a`, `lodash`) is resolved the same way a relative
|
|
1369
|
+
// one is; TS's own resolver already follows a workspace
|
|
1370
|
+
// package's package.json `exports` under nodenext, so the only
|
|
1371
|
+
// thing gating that path before was this project's own code,
|
|
1372
|
+
// not TypeScript.
|
|
1373
|
+
const builtin = builtinModuleName(imp.specifier);
|
|
1374
|
+
const resolution = builtin !== undefined ? undefined : (() => {
|
|
1375
|
+
const resolved = commons.resolveModule(imp.specifier, fileName, imp.mode);
|
|
1376
|
+
const rm = resolved.resolvedModule;
|
|
1377
|
+
return rm === undefined ? "unresolved" : {
|
|
1378
|
+
resolvedFile: rm.resolvedFileName,
|
|
1379
|
+
...(rm.isExternalLibraryImport ? { isExternalLibraryImport: true } : {}),
|
|
1380
|
+
...(rm.packageId?.name !== undefined ? { packageName: rm.packageId.name } : {}),
|
|
1381
|
+
};
|
|
1382
|
+
})();
|
|
1383
|
+
const outcome = edgeFor(fileName, fromModule, imp, resolution, resolveModuleForFile, projectRoot);
|
|
1384
|
+
if (outcome !== undefined && "edge" in outcome)
|
|
1385
|
+
edges.push(outcome.edge);
|
|
1386
|
+
else if (outcome !== undefined) {
|
|
1387
|
+
unresolvedSpecifierCount++;
|
|
1388
|
+
unresolvedSpecifiers.push(outcome.unresolvedSpecifier);
|
|
1389
|
+
}
|
|
1390
|
+
}
|
|
1391
|
+
}
|
|
1392
|
+
return { edges, outsideFiles, fileFlags, unsupportedSyntaxCount, unresolvedSpecifierCount, unresolvedSpecifiers };
|
|
1393
|
+
}
|
|
1394
|
+
// The rest of a graph build - crossModuleEdges and the lazy Program/rule-6
|
|
1395
|
+
// closure - shared verbatim by the cold walk (buildPreparedGraph) and the
|
|
1396
|
+
// disk-cache reconciliation (buildModuleGraphForRules): both hand this the
|
|
1397
|
+
// same shape (edges + fileFlags + counts), so ensureProgram's own closure
|
|
1398
|
+
// never needs to know whether its input came from a fresh parse or a
|
|
1399
|
+
// cache hit. Separate assemblers are refused because they can give rule 6
|
|
1400
|
+
// different closure facts for cached and uncached graphs.
|
|
1401
|
+
function assembleGraph(prepared, commons, walked, overrides) {
|
|
1402
|
+
const { modules, surface, rootDir, rootNames, compilerOptions, relativePath } = prepared;
|
|
1403
|
+
const { edges, outsideFiles, fileFlags, unsupportedSyntaxCount, unresolvedSpecifierCount, unresolvedSpecifiers } = walked;
|
|
1404
|
+
const { host, analyzedSet, optionsForContainingFile, sourceFileOptionsFor, resolveModule, resolutionCacheFor } = commons;
|
|
1405
|
+
const crossModuleEdges = edges.filter((e) => e.toModule !== undefined && e.toModule !== e.fromModule);
|
|
1406
|
+
// Bounded at 3 rounds. A later round only ever happens when the closure
|
|
1407
|
+
// built in round 0 (surfaces, export chains, type positions, inference,
|
|
1408
|
+
// ambient roots, and a dynamic `import(...)` reached while inferring)
|
|
1409
|
+
// still leaves rule 6 unable to resolve some alias it needs - a
|
|
1410
|
+
// specifier syntax type-closure.ts's own rules do not yet recognize,
|
|
1411
|
+
// not an ordinary project's own re-export depth (every rule already
|
|
1412
|
+
// follows a whole chain in its first pass). Each later round adds
|
|
1413
|
+
// exactly the files rule 6 just reported missing and tries again.
|
|
1414
|
+
// Three rounds leaves room for one such gap to itself reference one
|
|
1415
|
+
// more before the closure stabilizes, while keeping the fallback path
|
|
1416
|
+
// fast to reach when it doesn't stabilize at all.
|
|
1417
|
+
const MAX_CLOSURE_ROUNDS = 3;
|
|
1418
|
+
// Built only on first access to `program`/`checker`, and dropped again
|
|
1419
|
+
// by `releaseProgram` - see this module's own header and the
|
|
1420
|
+
// `ModuleGraph.program`/`releaseProgram` field comments for why.
|
|
1421
|
+
let program;
|
|
1422
|
+
let notes = [];
|
|
1423
|
+
// A reused graph can next serve an unscoped caller without a release.
|
|
1424
|
+
// Sharing `notes` is refused because scoped notes would leak across calls.
|
|
1425
|
+
let scopedNotes = [];
|
|
1426
|
+
let cachedTypeLeaks;
|
|
1427
|
+
// Both Program paths need identical edge resolutions. A second resolver map
|
|
1428
|
+
// is refused because separate answers could diverge from the graph's edges.
|
|
1429
|
+
const resolvedSpecifiers = new Map();
|
|
1430
|
+
const resolutionKeyByPosition = new Map();
|
|
1431
|
+
for (const edge of edges) {
|
|
1432
|
+
let perFile = resolvedSpecifiers.get(edge.fromFile);
|
|
1433
|
+
if (perFile === undefined) {
|
|
1434
|
+
perFile = new Map();
|
|
1435
|
+
resolvedSpecifiers.set(edge.fromFile, perFile);
|
|
1436
|
+
}
|
|
1437
|
+
perFile.set(resolutionKey(edge), edge.resolvedFile);
|
|
1438
|
+
resolutionKeyByPosition.set(resolutionPositionKey(edge.fromFile, edge.fromPosition), resolutionKey(edge));
|
|
1439
|
+
}
|
|
1440
|
+
const readFile = (file) => host.readFile(file);
|
|
1441
|
+
const languageVersion = compilerOptions.target ?? ts.ScriptTarget.ESNext;
|
|
1442
|
+
const baseHost = overrides.host ?? host;
|
|
1443
|
+
const ambientFiles = [...fileFlags].filter(([, f]) => f.isScript || f.hasAmbientDeclarations).map(([file]) => file);
|
|
1444
|
+
// Both Program paths require the same closure facts. A duplicated input
|
|
1445
|
+
// assembly is refused because ambient roots and resolutions must stay equal.
|
|
1446
|
+
const closureInputs = (surfaceFiles, extraRoots = []) => ({
|
|
1447
|
+
readFile, languageVersion, scriptKindFor: scriptKindForFile, sourceFileOptionsFor, ambientFiles,
|
|
1448
|
+
surfaceFiles, resolvedSpecifiers, analyzedFiles: analyzedSet, extraRoots,
|
|
1449
|
+
});
|
|
1450
|
+
// Both Program paths need the same bounded safety loop. Separate loops are
|
|
1451
|
+
// refused because a missed alias must trigger the same fallback in each path.
|
|
1452
|
+
const runProgramRounds = (surfaceFiles, focusModuleName, extraNamedDeclarationKeys) => {
|
|
1453
|
+
// The Program's own module-resolution host, not `noResolve`: `noResolve`
|
|
1454
|
+
// also stops TypeScript from following node_modules/@types imports and
|
|
1455
|
+
// triple-slash references, so an external dependency's own generic type
|
|
1456
|
+
// (Promise<Internal>, an npm package's own EventEmitter<Internal>, ...)
|
|
1457
|
+
// would resolve to an error type there, and a real finding through it
|
|
1458
|
+
// would silently disappear. `resolveModuleNameLiterals` instead resolves
|
|
1459
|
+
// every specifier exactly the way this module's own edge walk already
|
|
1460
|
+
// does (the file's own nearest tsconfig, the same per-options
|
|
1461
|
+
// resolution cache), then restricts only ONE case: a containing file
|
|
1462
|
+
// this project analyzes, resolving to ANOTHER file this project
|
|
1463
|
+
// analyzes that sits outside the closure, reads back as unresolved -
|
|
1464
|
+
// the checker sees exactly what it would see if that file did not
|
|
1465
|
+
// exist, which is the closure's whole premise. Every other case
|
|
1466
|
+
// (an external dependency's own file resolving its own further
|
|
1467
|
+
// imports, a project file resolving into node_modules/@types/lib) gets
|
|
1468
|
+
// the real result unfiltered, so that whole external graph loads the
|
|
1469
|
+
// same way it would in a whole-project Program - triple-slash
|
|
1470
|
+
// references and automatic type-directive inclusion are untouched,
|
|
1471
|
+
// TypeScript's own defaults for both.
|
|
1472
|
+
function closureHost(closureSet) {
|
|
1473
|
+
const delegate = Object.create(baseHost);
|
|
1474
|
+
const withFileFormat = (fileName, languageVersionOrOptions) => {
|
|
1475
|
+
const supplied = typeof languageVersionOrOptions === "number"
|
|
1476
|
+
? { languageVersion: languageVersionOrOptions }
|
|
1477
|
+
: languageVersionOrOptions;
|
|
1478
|
+
return { ...supplied, impliedNodeFormat: sourceFileOptionsFor(fileName).impliedNodeFormat };
|
|
1479
|
+
};
|
|
1480
|
+
// TypeScript derives this value from the Program's root options before
|
|
1481
|
+
// it calls a host. The closure contains files owned by nested configs,
|
|
1482
|
+
// so the host replaces only that value with the edge walk's answer.
|
|
1483
|
+
delegate.getSourceFile = (fileName, languageVersionOrOptions, onError, shouldCreateNewSourceFile) => baseHost.getSourceFile(fileName, withFileFormat(fileName, languageVersionOrOptions), onError, shouldCreateNewSourceFile);
|
|
1484
|
+
if (baseHost.getSourceFileByPath !== undefined) {
|
|
1485
|
+
delegate.getSourceFileByPath = (fileName, path, languageVersionOrOptions, onError, shouldCreateNewSourceFile) => baseHost.getSourceFileByPath(fileName, path, withFileFormat(fileName, languageVersionOrOptions), onError, shouldCreateNewSourceFile);
|
|
1486
|
+
}
|
|
1487
|
+
// The 4th positional param (TS's per-call options, accounting for a
|
|
1488
|
+
// redirected project reference) is not used here. The containing-file
|
|
1489
|
+
// lookup derives the equivalent value, and this project has no project
|
|
1490
|
+
// references for the two answers to differ over.
|
|
1491
|
+
delegate.resolveModuleNameLiterals = (moduleLiterals, containingFile, redirectedReference, _options, containingSourceFile) => moduleLiterals.map((literal) => {
|
|
1492
|
+
const options = optionsForContainingFile(containingFile, redirectedReference);
|
|
1493
|
+
const mode = ts.getModeForUsageLocation(containingSourceFile, literal, options);
|
|
1494
|
+
const resolved = resolveModule(literal.text, containingFile, mode, redirectedReference);
|
|
1495
|
+
if (!analyzedSet.has(containingFile))
|
|
1496
|
+
return resolved;
|
|
1497
|
+
const resolvedFile = resolved.resolvedModule?.resolvedFileName;
|
|
1498
|
+
if (resolvedFile !== undefined && analyzedSet.has(resolvedFile) && !closureSet.has(resolvedFile)) {
|
|
1499
|
+
return { ...resolved, resolvedModule: undefined };
|
|
1500
|
+
}
|
|
1501
|
+
return resolved;
|
|
1502
|
+
});
|
|
1503
|
+
// TypeScript still asks for one cache for non-analyzed dependencies and
|
|
1504
|
+
// type directives. Without one, a 23,000-file project with a 145 KB root
|
|
1505
|
+
// package.json used about 550 MB more memory. Analyzed files get their
|
|
1506
|
+
// per-options package cache through sourceFileOptionsFor above.
|
|
1507
|
+
delegate.getModuleResolutionCache = () => resolutionCacheFor(compilerOptions);
|
|
1508
|
+
return delegate;
|
|
1509
|
+
}
|
|
1510
|
+
let extraRoots = [];
|
|
1511
|
+
for (let round = 0;; round++) {
|
|
1512
|
+
const closure = buildTypeClosure(closureInputs(surfaceFiles, extraRoots));
|
|
1513
|
+
// Test-only: see GraphBuildOverrides' own comment. Round 0 only -
|
|
1514
|
+
// a later round's own `extraRoots` (added for real, by the safety
|
|
1515
|
+
// net below) must stick.
|
|
1516
|
+
const dropped = round === 0 ? new Set(overrides.dropFromClosureForTests ?? []) : undefined;
|
|
1517
|
+
const closureFiles = dropped === undefined ? closure.files : closure.files.filter((f) => !dropped.has(f));
|
|
1518
|
+
overrides.onClosureRoundForTests?.(round, closureFiles);
|
|
1519
|
+
const closureSet = new Set(closureFiles);
|
|
1520
|
+
const candidate = ts.createProgram({
|
|
1521
|
+
rootNames: closureFiles, options: compilerOptions,
|
|
1522
|
+
host: closureHost(closureSet), oldProgram: overrides.oldProgram,
|
|
1523
|
+
});
|
|
1524
|
+
// The safety net: rule 6 itself is the only code that already
|
|
1525
|
+
// walks every alias and every structural type a surface (or an
|
|
1526
|
+
// internal declaration) depends on, so its own resolution failures
|
|
1527
|
+
// are read back here instead of this module re-deriving them - a
|
|
1528
|
+
// real, deliberate exception to this module's own "no rule logic
|
|
1529
|
+
// here" boundary (see the header). An unresolved alias names the
|
|
1530
|
+
// specifier it failed on - resolved from the same edge records the
|
|
1531
|
+
// closure itself used, no re-resolution.
|
|
1532
|
+
//
|
|
1533
|
+
// The unscoped caller caches this return value as `cachedTypeLeaks`.
|
|
1534
|
+
// The focused caller returns it directly. Neither caller walks the
|
|
1535
|
+
// identical final Program a second time after a successful round.
|
|
1536
|
+
const missing = new Set();
|
|
1537
|
+
const roundViolations = checkTypeLeaks({ modules, program: candidate, checker: candidate.getTypeChecker(), rootDir }, {
|
|
1538
|
+
focusModuleName,
|
|
1539
|
+
extraNamedDeclarationKeys,
|
|
1540
|
+
report: ({ file, fromPosition }) => {
|
|
1541
|
+
const key = resolutionKeyByPosition.get(resolutionPositionKey(file, fromPosition));
|
|
1542
|
+
const resolved = key === undefined ? undefined : resolvedSpecifiers.get(file)?.get(key);
|
|
1543
|
+
if (resolved !== undefined && analyzedSet.has(resolved) && !closureSet.has(resolved))
|
|
1544
|
+
missing.add(resolved);
|
|
1545
|
+
},
|
|
1546
|
+
});
|
|
1547
|
+
// Test-only: see GraphBuildOverrides' own comment. The value added
|
|
1548
|
+
// is never a real file - only `missing.size` past this point
|
|
1549
|
+
// matters, not what it names.
|
|
1550
|
+
if (overrides.forceClosureFallbackForTests === true)
|
|
1551
|
+
missing.add("\0forced-missing-for-tests");
|
|
1552
|
+
// A complete alias walk makes the candidate safe. Another round is
|
|
1553
|
+
// refused because another round adds work without a missing root.
|
|
1554
|
+
if (missing.size === 0) {
|
|
1555
|
+
return { program: candidate, violations: roundViolations, notes: [], closureFiles: closureSet };
|
|
1556
|
+
}
|
|
1557
|
+
if (round >= MAX_CLOSURE_ROUNDS) {
|
|
1558
|
+
// The bound prevents an unending recovery loop. A partial result is
|
|
1559
|
+
// refused because an omitted public name creates a false leak.
|
|
1560
|
+
return {
|
|
1561
|
+
notes: [`rule 6's type closure could not resolve every referenced import after ${MAX_CLOSURE_ROUNDS} rounds; fell back to the whole-project program for this check`],
|
|
1562
|
+
program: ts.createProgram({ rootNames, options: compilerOptions, host: baseHost, oldProgram: overrides.oldProgram }),
|
|
1563
|
+
};
|
|
1564
|
+
}
|
|
1565
|
+
extraRoots = [...extraRoots, ...missing];
|
|
1566
|
+
}
|
|
1567
|
+
};
|
|
1568
|
+
// Unscoped callers share one Program and cache. Reusing a focused Program is
|
|
1569
|
+
// refused because an MCP caller can request an unscoped answer on the graph.
|
|
1570
|
+
const ensureProgram = () => {
|
|
1571
|
+
// Unscoped callers share the memoized Program. Rebuilding the Program is refused
|
|
1572
|
+
// because it repeats binding work without changing the requested scope.
|
|
1573
|
+
if (program !== undefined)
|
|
1574
|
+
return program;
|
|
1575
|
+
const built = runProgramRounds([...modules.values()].flatMap((m) => m.surfaceFiles));
|
|
1576
|
+
program = built.program;
|
|
1577
|
+
notes = built.notes;
|
|
1578
|
+
cachedTypeLeaks = built.violations;
|
|
1579
|
+
return program;
|
|
1580
|
+
};
|
|
1581
|
+
// An augmentation can hide a name only when the syntax-only public-name
|
|
1582
|
+
// walk depends on its target. Falling back for every analyzed target is
|
|
1583
|
+
// refused because each augmenting file is already an ambient closure root.
|
|
1584
|
+
function hasAugmentationOf(visitedFiles) {
|
|
1585
|
+
for (const [file, flags] of fileFlags) {
|
|
1586
|
+
for (const augmentation of flags.moduleAugmentationSpecifiers) {
|
|
1587
|
+
const target = resolveModule(augmentation.specifier, file, augmentation.mode).resolvedModule?.resolvedFileName;
|
|
1588
|
+
if (target !== undefined && visitedFiles.has(target))
|
|
1589
|
+
return true;
|
|
1590
|
+
}
|
|
1591
|
+
}
|
|
1592
|
+
return false;
|
|
1593
|
+
}
|
|
1594
|
+
// A non-analyzed augmentation matters only when its project-file target
|
|
1595
|
+
// contributes to this answer and the focused Program omits the augmenting
|
|
1596
|
+
// file. Falling back for every project target is refused because unrelated
|
|
1597
|
+
// targets and augmentations already loaded by this Program cannot change
|
|
1598
|
+
// the answer.
|
|
1599
|
+
function hasNonAnalyzedProjectAugmentation(focusedProgram, closureFiles, visitedFiles) {
|
|
1600
|
+
for (const [file, augmentations] of scanNonAnalyzedModuleAugmentations(prepared, commons, overrides)) {
|
|
1601
|
+
if (focusedProgram.getSourceFile(file) !== undefined)
|
|
1602
|
+
continue;
|
|
1603
|
+
for (const augmentation of augmentations) {
|
|
1604
|
+
const target = resolveModule(augmentation.specifier, file, augmentation.mode).resolvedModule?.resolvedFileName;
|
|
1605
|
+
if (target !== undefined && isWorkspaceSiblingResolution(target, rootDir) &&
|
|
1606
|
+
(visitedFiles.has(target) || closureFiles.has(target)))
|
|
1607
|
+
return true;
|
|
1608
|
+
}
|
|
1609
|
+
}
|
|
1610
|
+
return false;
|
|
1611
|
+
}
|
|
1612
|
+
const graph = {
|
|
1613
|
+
modules,
|
|
1614
|
+
edges,
|
|
1615
|
+
crossModuleEdges,
|
|
1616
|
+
outsideFiles,
|
|
1617
|
+
nonTsSourceFileCount: prepared.nonTsSourceFileCount,
|
|
1618
|
+
unsupportedSyntaxCount,
|
|
1619
|
+
unresolvedSpecifierCount,
|
|
1620
|
+
unresolvedSpecifiers,
|
|
1621
|
+
surface,
|
|
1622
|
+
rootDir,
|
|
1623
|
+
relativePath,
|
|
1624
|
+
get program() {
|
|
1625
|
+
return ensureProgram();
|
|
1626
|
+
},
|
|
1627
|
+
get checker() {
|
|
1628
|
+
return ensureProgram().getTypeChecker();
|
|
1629
|
+
},
|
|
1630
|
+
releaseProgram() {
|
|
1631
|
+
program = undefined;
|
|
1632
|
+
notes = [];
|
|
1633
|
+
// A release ends both result lifetimes. Retaining scoped notes is refused
|
|
1634
|
+
// because a later focused call can have a different fallback reason.
|
|
1635
|
+
scopedNotes = [];
|
|
1636
|
+
cachedTypeLeaks = undefined;
|
|
1637
|
+
},
|
|
1638
|
+
get programNotes() {
|
|
1639
|
+
return notes;
|
|
1640
|
+
},
|
|
1641
|
+
get focusedTypeLeakNotes() {
|
|
1642
|
+
// The getter exposes notes assembled for the latest focused call.
|
|
1643
|
+
// Returning `notes` directly is refused because it lacks scoped reasons.
|
|
1644
|
+
return scopedNotes;
|
|
1645
|
+
},
|
|
1646
|
+
get cachedTypeLeaks() {
|
|
1647
|
+
return cachedTypeLeaks;
|
|
1648
|
+
},
|
|
1649
|
+
typeLeaksForFocus(moduleName) {
|
|
1650
|
+
const module = modules.get(moduleName);
|
|
1651
|
+
// A missing module has no surface roots. Building an unscoped Program is
|
|
1652
|
+
// refused because it cannot produce a finding owned by the missing module.
|
|
1653
|
+
if (module === undefined)
|
|
1654
|
+
return [];
|
|
1655
|
+
// Other surfaces contribute public names, but their closures are omitted.
|
|
1656
|
+
// Loading the other closures is refused because full loading recreates the measured cost.
|
|
1657
|
+
const otherSurfaceFiles = [...modules.values()]
|
|
1658
|
+
.filter((candidate) => candidate.name !== moduleName)
|
|
1659
|
+
.flatMap((candidate) => candidate.surfaceFiles);
|
|
1660
|
+
const named = computeSyntacticNamedDeclarations(closureInputs(otherSurfaceFiles), otherSurfaceFiles);
|
|
1661
|
+
// An unresolved export chain can hide a public name. Continuing with a
|
|
1662
|
+
// partial key set is refused because the omitted name creates a false leak.
|
|
1663
|
+
const fallbackReason = named.unresolvable
|
|
1664
|
+
? "the syntactic public-name resolver could not resolve every declaration"
|
|
1665
|
+
// An augmentation of a visited target can add a name absent from syntax.
|
|
1666
|
+
// Continuing with syntactic keys is refused because that name stays invisible.
|
|
1667
|
+
: hasAugmentationOf(named.visitedFiles)
|
|
1668
|
+
? "a module augmentation can add public names to another surface's export chain"
|
|
1669
|
+
: undefined;
|
|
1670
|
+
if (fallbackReason !== undefined) {
|
|
1671
|
+
// The unscoped checker supplies every name and the note exposes the cost.
|
|
1672
|
+
// Silent scoped evaluation is refused because it can report a false leak.
|
|
1673
|
+
const focusedNote = `rule 6 could not safely scope this surface because ${fallbackReason}; fell back to the whole-project type closure for this check`;
|
|
1674
|
+
const violations = checkTypeLeaks(graph).filter((violation) => violation.todoModule === moduleName);
|
|
1675
|
+
// The whole-project builder can add its own fallback note. Dropping it is
|
|
1676
|
+
// refused because the focused result must explain every fallback it used.
|
|
1677
|
+
scopedNotes = [focusedNote, ...notes];
|
|
1678
|
+
return violations;
|
|
1679
|
+
}
|
|
1680
|
+
const built = runProgramRounds(module.surfaceFiles, moduleName, named.keys);
|
|
1681
|
+
if (built.closureFiles !== undefined && hasNonAnalyzedProjectAugmentation(built.program, built.closureFiles, named.visitedFiles)) {
|
|
1682
|
+
// The unscoped checker supplies augmentation effects that this focused
|
|
1683
|
+
// Program omits. Keeping its partial result is refused because it can
|
|
1684
|
+
// miss a leak from an added member.
|
|
1685
|
+
const focusedNote = "rule 6 could not safely scope this surface because a non-analyzed project file can apply a module augmentation to a project file on another surface's export chain; fell back to the whole-project type closure for this check";
|
|
1686
|
+
const violations = checkTypeLeaks(graph).filter((violation) => violation.todoModule === moduleName);
|
|
1687
|
+
scopedNotes = [focusedNote, ...notes];
|
|
1688
|
+
return violations;
|
|
1689
|
+
}
|
|
1690
|
+
scopedNotes = built.notes;
|
|
1691
|
+
// A successful round already returns its findings. Rewalking is refused
|
|
1692
|
+
// unless the bounded fallback returns only a whole-project Program.
|
|
1693
|
+
const violations = built.violations ?? checkTypeLeaks({
|
|
1694
|
+
modules, program: built.program, checker: built.program.getTypeChecker(), rootDir,
|
|
1695
|
+
}, { focusModuleName: moduleName, extraNamedDeclarationKeys: named.keys });
|
|
1696
|
+
return violations;
|
|
1697
|
+
},
|
|
1698
|
+
fileFlags,
|
|
1699
|
+
};
|
|
1700
|
+
return graph;
|
|
1701
|
+
}
|
|
1702
|
+
export function buildPreparedGraph(prepared, overrides = {}) {
|
|
1703
|
+
const commons = makeGraphCommons(prepared, overrides);
|
|
1704
|
+
const walked = walkAllFiles(prepared, commons, overrides.fileWalk ?? commons.defaultFileWalk);
|
|
1705
|
+
return assembleGraph(prepared, commons, walked, overrides);
|
|
1706
|
+
}
|
|
1707
|
+
function hash(value) {
|
|
1708
|
+
return createHash("sha256").update(JSON.stringify(value)).digest("hex");
|
|
1709
|
+
}
|
|
1710
|
+
const ARCHSTRICT_VERSION = JSON.parse(readFileSync(new URL("../package.json", import.meta.url), "utf8")).version;
|
|
1711
|
+
// A hash of the two built files whose own code produces a cache entry
|
|
1712
|
+
// (this file and edge-cache.ts, read from beside `import.meta.url` - the
|
|
1713
|
+
// same directory a build writes both to), computed once per process. A
|
|
1714
|
+
// package version bump is not the only way this project's own walker or
|
|
1715
|
+
// resolver logic changes: a local build after an uncommitted edit to
|
|
1716
|
+
// either file changes neither ARCHSTRICT_VERSION nor the package.json
|
|
1717
|
+
// this process reads, but does change what a cache entry means - reading
|
|
1718
|
+
// an old entry back under new code would replay an answer the new code
|
|
1719
|
+
// never produced. Read once, not per build: neither file's own content
|
|
1720
|
+
// changes while one process is running.
|
|
1721
|
+
const CODE_VERSION_HASH = (() => {
|
|
1722
|
+
try {
|
|
1723
|
+
const sources = ["module-graph.js", "edge-cache.js"].map((name) => readFileSync(new URL(name, import.meta.url), "utf8"));
|
|
1724
|
+
return createHash("sha256").update(sources.join("\u0000")).digest("hex");
|
|
1725
|
+
}
|
|
1726
|
+
catch {
|
|
1727
|
+
// A test importing this module from its own .ts source (never built
|
|
1728
|
+
// to module-graph.js/edge-cache.js beside it) has no built files to
|
|
1729
|
+
// hash - a fixed placeholder, not a crash, since this only ever
|
|
1730
|
+
// gates a cache write/read this same process makes and reads back.
|
|
1731
|
+
return "unbuilt";
|
|
1732
|
+
}
|
|
1733
|
+
})();
|
|
1734
|
+
// The exact typescript this process resolved, next to CODE_VERSION_HASH:
|
|
1735
|
+
// a different installed typescript version can resolve or parse the same
|
|
1736
|
+
// project differently (a resolver bug fix, a new export-condition rule)
|
|
1737
|
+
// with neither this project's own code nor its package.json version
|
|
1738
|
+
// having changed at all.
|
|
1739
|
+
const TYPESCRIPT_VERSION = ts.version;
|
|
1740
|
+
function cacheMetadata(projectRoot, options) {
|
|
1741
|
+
const packages = [join(projectRoot, "package.json"), ...options.declaredModules
|
|
1742
|
+
.flatMap((dm) => moduleGlobList(dm.glob).map((glob) => join(projectRoot, moduleGlobBaseDir(glob), "package.json")))];
|
|
1743
|
+
const lock = ["package-lock.json", "pnpm-lock.yaml", "yarn.lock", "bun.lock"]
|
|
1744
|
+
.map((name) => join(projectRoot, name)).find((path) => existsSync(path));
|
|
1745
|
+
if (lock !== undefined)
|
|
1746
|
+
packages.push(lock);
|
|
1747
|
+
return Object.fromEntries([...new Set(packages)].sort().map((path) => [path, existsSync(path) ? statSync(path).mtimeMs : null]));
|
|
1748
|
+
}
|
|
1749
|
+
// Stringifies one distinct effective-options OBJECT at most once, keyed
|
|
1750
|
+
// by reference identity - compilerOptionsForFile memoizes per directory
|
|
1751
|
+
// (not per file), so the same object recurs across many files. Keying
|
|
1752
|
+
// solely by the JSON string would require producing that string first,
|
|
1753
|
+
// which needs one stringify per file regardless of how many end up
|
|
1754
|
+
// sharing a key. A project with one tsconfig then stringifies once, not
|
|
1755
|
+
// once per file - the 14 KB-per-file churn a 23,000-file project would
|
|
1756
|
+
// otherwise pay twice over (once here, once in buildModuleGraphForRules
|
|
1757
|
+
// below).
|
|
1758
|
+
function optionsJsonMemo() {
|
|
1759
|
+
const jsonByIdentity = new Map();
|
|
1760
|
+
return (opts) => {
|
|
1761
|
+
let json = jsonByIdentity.get(opts);
|
|
1762
|
+
if (json === undefined) {
|
|
1763
|
+
json = JSON.stringify(opts);
|
|
1764
|
+
jsonByIdentity.set(opts, json);
|
|
1765
|
+
}
|
|
1766
|
+
return json;
|
|
1767
|
+
};
|
|
1768
|
+
}
|
|
1769
|
+
// warm-graph.ts's own in-memory, single-process fingerprint - unrelated to
|
|
1770
|
+
// the persistent disk cache below (see buildModuleGraphForRules' own
|
|
1771
|
+
// header for that one's own, broader inputs). Kept as one opaque string
|
|
1772
|
+
// per distinct effective options object, not one 14 KB options object
|
|
1773
|
+
// repeated per file: a project with thousands of files but a handful of
|
|
1774
|
+
// distinct tsconfigs hashes a handful of objects, not one per file - the
|
|
1775
|
+
// same dedup technique the disk cache uses (below).
|
|
1776
|
+
export function graphBuildFingerprint(options, prepared) {
|
|
1777
|
+
const { projectRoot, rootNames, compilerOptions, compilerOptionsForFile } = prepared;
|
|
1778
|
+
const optionsJson = optionsJsonMemo();
|
|
1779
|
+
const optionsIndexByJson = new Map();
|
|
1780
|
+
const optionsTable = [];
|
|
1781
|
+
const fileOptionsIndex = rootNames.map((file) => {
|
|
1782
|
+
const json = optionsJson(compilerOptionsForFile(file));
|
|
1783
|
+
let idx = optionsIndexByJson.get(json);
|
|
1784
|
+
if (idx === undefined) {
|
|
1785
|
+
idx = optionsTable.length;
|
|
1786
|
+
optionsTable.push(json);
|
|
1787
|
+
optionsIndexByJson.set(json, idx);
|
|
1788
|
+
}
|
|
1789
|
+
return idx;
|
|
1790
|
+
});
|
|
1791
|
+
const tsconfigHash = hash({ root: compilerOptions, optionsTable, fileOptionsIndex });
|
|
1792
|
+
const buildOptionsHash = hash({ declaredModules: options.declaredModules,
|
|
1793
|
+
exclude: options.exclude, surface: prepared.surface });
|
|
1794
|
+
const metadata = cacheMetadata(projectRoot, options);
|
|
1795
|
+
return { tsconfigHash, buildOptionsHash, metadata, archstrictVersion: ARCHSTRICT_VERSION };
|
|
1796
|
+
}
|
|
1797
|
+
const LOCKFILE_NAMES = ["package-lock.json", "pnpm-lock.yaml", "yarn.lock", "bun.lock", "bun.lockb"];
|
|
1798
|
+
// The nearest lockfile above `startDir`, checked at `startDir` itself and
|
|
1799
|
+
// then each ancestor up to the filesystem root - a monorepo's own
|
|
1800
|
+
// lockfile commonly sits at the workspace root, one or more directories
|
|
1801
|
+
// above any one package's own project root.
|
|
1802
|
+
function findNearestLockfile(startDir) {
|
|
1803
|
+
let dir = startDir;
|
|
1804
|
+
for (;;) {
|
|
1805
|
+
for (const name of LOCKFILE_NAMES) {
|
|
1806
|
+
const candidate = join(dir, name);
|
|
1807
|
+
if (existsSync(candidate))
|
|
1808
|
+
return candidate;
|
|
1809
|
+
}
|
|
1810
|
+
const parent = dirname(dir);
|
|
1811
|
+
if (parent === dir)
|
|
1812
|
+
return undefined;
|
|
1813
|
+
dir = parent;
|
|
1814
|
+
}
|
|
1815
|
+
}
|
|
1816
|
+
// Top-level package names (scoped names included, one entry per
|
|
1817
|
+
// "@scope/name") directly under one node_modules directory, each paired
|
|
1818
|
+
// with its own package.json's own mtime - read after following any
|
|
1819
|
+
// symlink (`npm link`, or a workspace's own symlinked sibling package),
|
|
1820
|
+
// since the real file such a symlink points at is what actually changes
|
|
1821
|
+
// when that package's own `exports`/`imports` map is edited, not the
|
|
1822
|
+
// symlink itself, whose own mtime a package manager does not always
|
|
1823
|
+
// touch for that edit. Returns an empty object for a directory that does
|
|
1824
|
+
// not exist (a project with no dependencies at all, or above the
|
|
1825
|
+
// filesystem root's own node_modules that never exists).
|
|
1826
|
+
function listNodeModulesPackages(nodeModulesDir) {
|
|
1827
|
+
let entries;
|
|
1828
|
+
try {
|
|
1829
|
+
entries = readdirSync(nodeModulesDir, { withFileTypes: true });
|
|
1830
|
+
}
|
|
1831
|
+
catch {
|
|
1832
|
+
return {};
|
|
1833
|
+
}
|
|
1834
|
+
const names = [];
|
|
1835
|
+
for (const entry of entries) {
|
|
1836
|
+
// Never a real package: ".bin" (npm's own executable-symlink
|
|
1837
|
+
// directory), and every other dot-prefixed entry a package manager
|
|
1838
|
+
// or another tool creates for its own bookkeeping right inside
|
|
1839
|
+
// node_modules (".cache", ".vite", ".vitest", pnpm's own ".pnpm"
|
|
1840
|
+
// content-addressed store - a real package under it is reached
|
|
1841
|
+
// through a top-level symlink instead, counted there). Skipping
|
|
1842
|
+
// these keeps this project's own persistent cache from moving its
|
|
1843
|
+
// own fingerprint the moment it creates node_modules/.cache/archstrict.
|
|
1844
|
+
if (entry.name.startsWith(".") || !(entry.isDirectory() || entry.isSymbolicLink()))
|
|
1845
|
+
continue;
|
|
1846
|
+
if (entry.name.startsWith("@")) {
|
|
1847
|
+
let scoped;
|
|
1848
|
+
try {
|
|
1849
|
+
scoped = readdirSync(join(nodeModulesDir, entry.name), { withFileTypes: true });
|
|
1850
|
+
}
|
|
1851
|
+
catch {
|
|
1852
|
+
continue;
|
|
1853
|
+
}
|
|
1854
|
+
for (const s of scoped) {
|
|
1855
|
+
if (s.isDirectory() || s.isSymbolicLink())
|
|
1856
|
+
names.push(`${entry.name}/${s.name}`);
|
|
1857
|
+
}
|
|
1858
|
+
}
|
|
1859
|
+
else {
|
|
1860
|
+
names.push(entry.name);
|
|
1861
|
+
}
|
|
1862
|
+
}
|
|
1863
|
+
return Object.fromEntries(names.sort().map((name) => {
|
|
1864
|
+
const packageJson = join(nodeModulesDir, name, "package.json");
|
|
1865
|
+
let mtime = null;
|
|
1866
|
+
try {
|
|
1867
|
+
mtime = statSync(realpathSync(packageJson)).mtimeMs;
|
|
1868
|
+
}
|
|
1869
|
+
catch {
|
|
1870
|
+
// A package directory with no package.json, or a broken symlink -
|
|
1871
|
+
// its own presence in `names` still moves the fingerprint.
|
|
1872
|
+
mtime = null;
|
|
1873
|
+
}
|
|
1874
|
+
return [name, mtime];
|
|
1875
|
+
}));
|
|
1876
|
+
}
|
|
1877
|
+
// Every node_modules directory this project's own root, or an ancestor
|
|
1878
|
+
// of it, has - up to the filesystem root, always, because that is how
|
|
1879
|
+
// far TypeScript's own resolver walks for a bare specifier (measured
|
|
1880
|
+
// directly: a package installed only into an ancestor directory's own
|
|
1881
|
+
// node_modules, above any lockfile the project has, still resolves for
|
|
1882
|
+
// real - a chain that stopped at the nearest lockfile's own directory
|
|
1883
|
+
// missed exactly this). Covers a package installed or removed with no
|
|
1884
|
+
// lockfile edit at all (no package.json under the project root moves
|
|
1885
|
+
// either, and no lockfile exists to record it), and a symlinked
|
|
1886
|
+
// workspace package's own `exports` edit. A node_modules directory
|
|
1887
|
+
// nested INSIDE the project (a workspace member's own, e.g.
|
|
1888
|
+
// packages/app/node_modules) is not an ancestor of the project root, so
|
|
1889
|
+
// it is covered separately, by walkProjectTree's own descent - merged
|
|
1890
|
+
// in here by the caller.
|
|
1891
|
+
//
|
|
1892
|
+
// Remaining limit, stated here and in this cache's own module header: an
|
|
1893
|
+
// edit inside an already-installed package's own file (not its
|
|
1894
|
+
// package.json) is invisible to every input this function reads - this
|
|
1895
|
+
// cache has no way to notice it short of deleting
|
|
1896
|
+
// node_modules/.cache/archstrict itself.
|
|
1897
|
+
function ancestorNodeModulesDirs(projectRoot) {
|
|
1898
|
+
const dirs = [];
|
|
1899
|
+
let dir = projectRoot;
|
|
1900
|
+
for (;;) {
|
|
1901
|
+
dirs.push(join(dir, "node_modules"));
|
|
1902
|
+
const parent = dirname(dir);
|
|
1903
|
+
if (parent === dir)
|
|
1904
|
+
break;
|
|
1905
|
+
dir = parent;
|
|
1906
|
+
}
|
|
1907
|
+
return dirs;
|
|
1908
|
+
}
|
|
1909
|
+
// Every input a resolution answer (not a parse) depends on that this
|
|
1910
|
+
// module cannot read off one file alone - see edge-cache.ts's own header
|
|
1911
|
+
// for the full contract this feeds. `resolvableFiles`, `packageJsonFiles`,
|
|
1912
|
+
// and `descendantNodeModulesDirs` all come from the one project-tree walk
|
|
1913
|
+
// prepareGraph already did (walkProjectTree) - this function adds no
|
|
1914
|
+
// directory walk of its own beyond the ancestor node_modules chain and
|
|
1915
|
+
// each node_modules directory's own top level.
|
|
1916
|
+
function resolutionInputs(projectRoot, rootNames, resolvableFiles, packageJsonFiles, descendantNodeModulesDirs) {
|
|
1917
|
+
const packages = Object.fromEntries([...packageJsonFiles].sort().map((path) => [path, statSync(path).mtimeMs]));
|
|
1918
|
+
const lockPath = findNearestLockfile(projectRoot);
|
|
1919
|
+
const lockMtime = lockPath === undefined ? null : statSync(lockPath).mtimeMs;
|
|
1920
|
+
// Hashed once, not embedded file-by-file: an added, deleted, or renamed
|
|
1921
|
+
// file changes this one hash, not a per-file field every other file's
|
|
1922
|
+
// own entry would otherwise have to repeat.
|
|
1923
|
+
const filesHash = hash([...rootNames].sort());
|
|
1924
|
+
// Existence only, never mtime: a resolvable file's own content never
|
|
1925
|
+
// changes what it resolves to (it is never parsed or read for that
|
|
1926
|
+
// purpose) - only whether it exists at all does.
|
|
1927
|
+
const resolvableFilesHash = hash([...resolvableFiles].sort());
|
|
1928
|
+
const nodeModuleDirs = new Set([...ancestorNodeModulesDirs(projectRoot), ...descendantNodeModulesDirs]);
|
|
1929
|
+
const nodeModules = Object.fromEntries([...nodeModuleDirs].sort().map((dir) => [dir, listNodeModulesPackages(dir)]));
|
|
1930
|
+
return { packages, lockPath: lockPath ?? null, lockMtime, filesHash, resolvableFilesHash, nodeModules };
|
|
1931
|
+
}
|
|
1932
|
+
// One analyzed file's own reparse gate: true while this file's own text
|
|
1933
|
+
// (mtime+size), its own nearest tsconfig's own effective options, and its
|
|
1934
|
+
// own nearest package.json "type" (impliedNodeFormat - see edge-cache.ts's
|
|
1935
|
+
// own header on why this is separate from the tsconfig check) all still
|
|
1936
|
+
// match what was cached for it. False for either a brand-new file (no old
|
|
1937
|
+
// entry) or one whose own inputs moved - the only two cases that force a
|
|
1938
|
+
// reparse of this ONE file, never the rest of the project.
|
|
1939
|
+
function fileParseValid(oldEntry, stat, optionsJson, oldOptionsTable, impliedNodeFormat) {
|
|
1940
|
+
return oldEntry !== undefined && oldEntry.unreadable !== true && stat !== undefined &&
|
|
1941
|
+
oldEntry.mtimeMs === stat.mtimeMs && oldEntry.size === stat.size &&
|
|
1942
|
+
oldEntry.optionsIndex < oldOptionsTable.length && oldOptionsTable[oldEntry.optionsIndex] === optionsJson &&
|
|
1943
|
+
oldEntry.impliedNodeFormat === impliedNodeFormat;
|
|
1944
|
+
}
|
|
1945
|
+
// THE one graph-build path every verb that needs a real analysis reads
|
|
1946
|
+
// and writes (check, check <file>, todo, rules, recommend, fix's own
|
|
1947
|
+
// baseline, search - see each verb's own call site). Persists to
|
|
1948
|
+
// node_modules/.cache/archstrict/edges.json (a header) plus its own
|
|
1949
|
+
// edges/*.json shards, per file, keyed by absolute path once decoded -
|
|
1950
|
+
// see edge-cache.ts's own header for the full correctness contract
|
|
1951
|
+
// (which input invalidates which stored fact, and where) and for why the
|
|
1952
|
+
// cache is sharded at all. Simulation reads this cache through an overlay,
|
|
1953
|
+
// marks changed paths dirty, and disables writes for both graph sides.
|
|
1954
|
+
export function buildModuleGraphForRules(options, overrides = {}) {
|
|
1955
|
+
const prepared = prepareGraph(options);
|
|
1956
|
+
const commons = makeGraphCommons(prepared, overrides);
|
|
1957
|
+
const { projectRoot, rootNames, modules, resolveModuleForFile, compilerOptionsForFile, resolvableFiles, packageJsonFiles, nodeModulesDirs } = prepared;
|
|
1958
|
+
const path = join(projectRoot, "node_modules/.cache/archstrict/edges.json");
|
|
1959
|
+
const cached = readEdgeCache(path, projectRoot);
|
|
1960
|
+
// A package version or code-version mismatch drops the whole cache -
|
|
1961
|
+
// modeled here as "no old entry for any file", which the per-file logic
|
|
1962
|
+
// below already treats as a full reparse+resolve of that file.
|
|
1963
|
+
const versionOk = cached !== undefined && cached.archstrictVersion === ARCHSTRICT_VERSION &&
|
|
1964
|
+
cached.codeVersionHash === CODE_VERSION_HASH && cached.typescriptVersion === TYPESCRIPT_VERSION;
|
|
1965
|
+
const oldOptionsTable = versionOk ? cached.optionsTable : [];
|
|
1966
|
+
// Every file's own effective options and implied module format - cheap
|
|
1967
|
+
// even on a large tree: compilerOptionsForFile memoizes per directory
|
|
1968
|
+
// (not per file), and ts.getImpliedNodeFormatForFile reads only the
|
|
1969
|
+
// nearest package.json, cached the same way. optionsJson is keyed by
|
|
1970
|
+
// the options OBJECT's own identity (see optionsJsonMemo's own header) -
|
|
1971
|
+
// never restringified for two files sharing one directory's tsconfig.
|
|
1972
|
+
const optionsJson = optionsJsonMemo();
|
|
1973
|
+
const optionsIndexByJson = new Map();
|
|
1974
|
+
const optionsTable = [];
|
|
1975
|
+
const optionsJsonByFile = new Map();
|
|
1976
|
+
const optionsIndexByFile = new Map();
|
|
1977
|
+
const impliedFormatByFile = new Map();
|
|
1978
|
+
for (const file of rootNames) {
|
|
1979
|
+
const opts = compilerOptionsForFile(file);
|
|
1980
|
+
const json = optionsJson(opts);
|
|
1981
|
+
optionsJsonByFile.set(file, json);
|
|
1982
|
+
let idx = optionsIndexByJson.get(json);
|
|
1983
|
+
if (idx === undefined) {
|
|
1984
|
+
idx = optionsTable.length;
|
|
1985
|
+
optionsTable.push(json);
|
|
1986
|
+
optionsIndexByJson.set(json, idx);
|
|
1987
|
+
}
|
|
1988
|
+
optionsIndexByFile.set(file, idx);
|
|
1989
|
+
const packageJsonInfoCache = commons.resolutionCacheFor(opts).getPackageJsonInfoCache();
|
|
1990
|
+
impliedFormatByFile.set(file, ts.getImpliedNodeFormatForFile(file, packageJsonInfoCache, commons.host, opts));
|
|
1991
|
+
}
|
|
1992
|
+
const inputs = resolutionInputs(projectRoot, rootNames, resolvableFiles, packageJsonFiles, nodeModulesDirs);
|
|
1993
|
+
const fingerprint = hash({ ...inputs, optionsTable });
|
|
1994
|
+
// Whether every file's own already-cached resolutions can be reused
|
|
1995
|
+
// outright, with no ts.resolveModuleName call at all - false forces a
|
|
1996
|
+
// fresh resolve of every specifier (from each file's own, possibly still
|
|
1997
|
+
// cached, `imports`), never a reparse of every file.
|
|
1998
|
+
const resolutionsValid = versionOk && cached.resolutionFingerprint === fingerprint;
|
|
1999
|
+
const stats = new Map();
|
|
2000
|
+
for (const file of rootNames) {
|
|
2001
|
+
if (overrides.dirtyFiles?.has(file) && commons.host.fileExists(file)) {
|
|
2002
|
+
stats.set(file, { mtimeMs: -1, size: Buffer.byteLength(commons.host.readFile(file) ?? "") });
|
|
2003
|
+
continue;
|
|
2004
|
+
}
|
|
2005
|
+
try {
|
|
2006
|
+
const st = statSync(file);
|
|
2007
|
+
stats.set(file, { mtimeMs: st.mtimeMs, size: st.size });
|
|
2008
|
+
}
|
|
2009
|
+
catch {
|
|
2010
|
+
stats.set(file, undefined);
|
|
2011
|
+
}
|
|
2012
|
+
}
|
|
2013
|
+
const newFiles = {};
|
|
2014
|
+
const edges = [];
|
|
2015
|
+
const outsideFiles = [];
|
|
2016
|
+
const fileFlags = new Map();
|
|
2017
|
+
let unsupportedSyntaxCount = 0;
|
|
2018
|
+
let unresolvedSpecifierCount = 0;
|
|
2019
|
+
const unresolvedSpecifiers = [];
|
|
2020
|
+
// True once any file's own parse was not reused - a full hit (every
|
|
2021
|
+
// file's own parse AND the global resolution fingerprint both still
|
|
2022
|
+
// valid) needs no rewrite at all: the new cache would be byte-identical
|
|
2023
|
+
// to the one already on disk, and skipping the write leaves that file's
|
|
2024
|
+
// own mtime alone, so a caller comparing two back-to-back no-op builds
|
|
2025
|
+
// (or a snapshot of the project tree around one) sees no change either.
|
|
2026
|
+
let dirty = !versionOk || !resolutionsValid;
|
|
2027
|
+
// Every file whose own entry this build actually reparsed - the exact
|
|
2028
|
+
// set whose own shard (edge-cache.ts's own `shardIndexForRelativePath`)
|
|
2029
|
+
// must be rewritten when `forceAll` (below) is false. A file that only
|
|
2030
|
+
// had its resolutions refreshed (mustResolve true, parseValid true)
|
|
2031
|
+
// does not add itself here on purpose: `forceAll` already covers that
|
|
2032
|
+
// case for every file at once, the moment `resolutionsValid` is false.
|
|
2033
|
+
const dirtyPaths = new Set();
|
|
2034
|
+
for (const file of rootNames) {
|
|
2035
|
+
const stat = stats.get(file);
|
|
2036
|
+
const oldEntry = versionOk ? cached.files[file] : undefined;
|
|
2037
|
+
const optionsIndex = optionsIndexByFile.get(file);
|
|
2038
|
+
const impliedNodeFormat = impliedFormatByFile.get(file);
|
|
2039
|
+
const parseValid = !overrides.dirtyFiles?.has(file) &&
|
|
2040
|
+
fileParseValid(oldEntry, stat, optionsJsonByFile.get(file), oldOptionsTable, impliedNodeFormat);
|
|
2041
|
+
if (!parseValid) {
|
|
2042
|
+
dirty = true;
|
|
2043
|
+
dirtyPaths.add(file);
|
|
2044
|
+
}
|
|
2045
|
+
let imports;
|
|
2046
|
+
let unsupportedForFile;
|
|
2047
|
+
let isScript;
|
|
2048
|
+
let hasAmbientDeclarations;
|
|
2049
|
+
let hasModuleAugmentation;
|
|
2050
|
+
let moduleAugmentationSpecifiers;
|
|
2051
|
+
let unreadable;
|
|
2052
|
+
if (parseValid) {
|
|
2053
|
+
({ imports, unsupportedSyntaxCount: unsupportedForFile, isScript, hasAmbientDeclarations,
|
|
2054
|
+
hasModuleAugmentation, moduleAugmentationSpecifiers, unreadable } = oldEntry);
|
|
2055
|
+
}
|
|
2056
|
+
else if (stat === undefined) {
|
|
2057
|
+
// Listed by the scan, gone (or unstattable) by the time this build
|
|
2058
|
+
// reached it - a race, not a real file to analyze this build.
|
|
2059
|
+
continue;
|
|
2060
|
+
}
|
|
2061
|
+
else {
|
|
2062
|
+
const walked = commons.defaultFileWalk(file);
|
|
2063
|
+
if (walked === undefined) {
|
|
2064
|
+
imports = [];
|
|
2065
|
+
unsupportedForFile = 0;
|
|
2066
|
+
isScript = false;
|
|
2067
|
+
hasAmbientDeclarations = false;
|
|
2068
|
+
hasModuleAugmentation = false;
|
|
2069
|
+
moduleAugmentationSpecifiers = [];
|
|
2070
|
+
unreadable = true;
|
|
2071
|
+
}
|
|
2072
|
+
else {
|
|
2073
|
+
({ imports, unsupportedSyntaxCount: unsupportedForFile, isScript, hasAmbientDeclarations,
|
|
2074
|
+
hasModuleAugmentation, moduleAugmentationSpecifiers } = walked);
|
|
2075
|
+
}
|
|
2076
|
+
}
|
|
2077
|
+
if (unreadable) {
|
|
2078
|
+
// Permission changes do not alter mtime or size. Omitting this entry
|
|
2079
|
+
// makes the next build retry the read instead of preserving an empty
|
|
2080
|
+
// answer after the file becomes readable.
|
|
2081
|
+
continue;
|
|
2082
|
+
}
|
|
2083
|
+
fileFlags.set(file, { isScript, hasAmbientDeclarations, hasModuleAugmentation, moduleAugmentationSpecifiers });
|
|
2084
|
+
const fromModule = resolveModuleForFile(file);
|
|
2085
|
+
if (fromModule === undefined)
|
|
2086
|
+
outsideFiles.push(file);
|
|
2087
|
+
else {
|
|
2088
|
+
modules.get(fromModule)?.files.push(file);
|
|
2089
|
+
unsupportedSyntaxCount += unsupportedForFile;
|
|
2090
|
+
}
|
|
2091
|
+
// Every walked file's own specifiers are resolved here, whether or
|
|
2092
|
+
// not it currently belongs to a declared module - a file outside
|
|
2093
|
+
// every module today can belong to one after a `declaredModules`
|
|
2094
|
+
// edit alone, with its own mtime, size, and resolution fingerprint
|
|
2095
|
+
// all unchanged; a resolution recorded only for module-owned files
|
|
2096
|
+
// would leave that file with no record at all, and reading a missing
|
|
2097
|
+
// key as "unresolved" below would misreport it as unresolved forever
|
|
2098
|
+
// instead of resolving it once, right here.
|
|
2099
|
+
//
|
|
2100
|
+
// A changed file's own specifiers are always re-resolved (mustResolve
|
|
2101
|
+
// is true whenever parseValid is false); otherwise, reused outright
|
|
2102
|
+
// while resolutionsValid, or freshly resolved (project-wide, but from
|
|
2103
|
+
// each file's own already-cached `imports`, never a reparse) the
|
|
2104
|
+
// moment any covered input moved. Either way, a specifier with no
|
|
2105
|
+
// prior record (this file's own membership changed, or any other
|
|
2106
|
+
// reason a key could be missing) is resolved here rather than assumed
|
|
2107
|
+
// unresolved - a cache entry records exactly the specifiers it
|
|
2108
|
+
// actually resolved, never a gap silently read back as a negative
|
|
2109
|
+
// answer.
|
|
2110
|
+
const mustResolve = !parseValid || !resolutionsValid;
|
|
2111
|
+
const priorResolutions = parseValid ? oldEntry.resolutions : {};
|
|
2112
|
+
const resolutions = {};
|
|
2113
|
+
for (const imp of imports) {
|
|
2114
|
+
const key = resolutionKey(imp);
|
|
2115
|
+
const builtin = builtinModuleName(imp.specifier);
|
|
2116
|
+
let resolution;
|
|
2117
|
+
if (builtin === undefined) {
|
|
2118
|
+
if (!mustResolve && Object.hasOwn(priorResolutions, key)) {
|
|
2119
|
+
resolution = priorResolutions[key]; // Object.hasOwn just confirmed this key is present
|
|
2120
|
+
}
|
|
2121
|
+
else {
|
|
2122
|
+
const resolved = commons.resolveModule(imp.specifier, file, imp.mode);
|
|
2123
|
+
const rm = resolved.resolvedModule;
|
|
2124
|
+
resolution = rm === undefined ? "unresolved" : {
|
|
2125
|
+
resolvedFile: rm.resolvedFileName,
|
|
2126
|
+
...(rm.isExternalLibraryImport ? { isExternalLibraryImport: true } : {}),
|
|
2127
|
+
...(rm.packageId?.name !== undefined ? { packageName: rm.packageId.name } : {}),
|
|
2128
|
+
};
|
|
2129
|
+
}
|
|
2130
|
+
resolutions[key] = resolution;
|
|
2131
|
+
}
|
|
2132
|
+
if (fromModule !== undefined) {
|
|
2133
|
+
const outcome = edgeFor(file, fromModule, imp, resolution, resolveModuleForFile, projectRoot);
|
|
2134
|
+
if (outcome !== undefined && "edge" in outcome)
|
|
2135
|
+
edges.push(outcome.edge);
|
|
2136
|
+
else if (outcome !== undefined) {
|
|
2137
|
+
unresolvedSpecifierCount++;
|
|
2138
|
+
unresolvedSpecifiers.push(outcome.unresolvedSpecifier);
|
|
2139
|
+
}
|
|
2140
|
+
}
|
|
2141
|
+
}
|
|
2142
|
+
// `stat` is defined here regardless of branch: `parseValid` requires
|
|
2143
|
+
// it (fileParseValid), and the reparse branch above already `continue`s
|
|
2144
|
+
// when it's undefined.
|
|
2145
|
+
newFiles[file] = { mtimeMs: stat.mtimeMs, size: stat.size, optionsIndex,
|
|
2146
|
+
...(impliedNodeFormat !== undefined ? { impliedNodeFormat } : {}),
|
|
2147
|
+
imports, unsupportedSyntaxCount: unsupportedForFile, isScript, hasAmbientDeclarations,
|
|
2148
|
+
hasModuleAugmentation, moduleAugmentationSpecifiers, resolutions };
|
|
2149
|
+
}
|
|
2150
|
+
// Do not label an analysis with mtimes/sizes from a concurrent edit.
|
|
2151
|
+
const stillStable = overrides.persistCache !== false && rootNames.every((file) => {
|
|
2152
|
+
const before = stats.get(file);
|
|
2153
|
+
if (before === undefined)
|
|
2154
|
+
return false;
|
|
2155
|
+
try {
|
|
2156
|
+
const now = statSync(file);
|
|
2157
|
+
return now.mtimeMs === before.mtimeMs && now.size === before.size;
|
|
2158
|
+
}
|
|
2159
|
+
catch {
|
|
2160
|
+
return false;
|
|
2161
|
+
}
|
|
2162
|
+
});
|
|
2163
|
+
if (dirty && stillStable) {
|
|
2164
|
+
// Every file this build's own cache no longer has an entry for, but
|
|
2165
|
+
// the OLD cache did (deleted, renamed away from, or dropped by the
|
|
2166
|
+
// stat-race `continue` above) - its own shard must be rewritten too,
|
|
2167
|
+
// to drop that now-stale entry, even though nothing marked it dirty
|
|
2168
|
+
// above (there is no new entry to reparse).
|
|
2169
|
+
const deletedPaths = new Set();
|
|
2170
|
+
if (versionOk)
|
|
2171
|
+
for (const oldPath of Object.keys(cached.files))
|
|
2172
|
+
if (!Object.hasOwn(newFiles, oldPath))
|
|
2173
|
+
deletedPaths.add(oldPath);
|
|
2174
|
+
writeEdgeCache(path, projectRoot, { archstrictVersion: ARCHSTRICT_VERSION, codeVersionHash: CODE_VERSION_HASH, typescriptVersion: TYPESCRIPT_VERSION, optionsTable, resolutionFingerprint: fingerprint, files: newFiles }, versionOk ? cached.shards : undefined, dirtyPaths, deletedPaths, !versionOk || !resolutionsValid);
|
|
2175
|
+
}
|
|
2176
|
+
const walked = { edges, outsideFiles, fileFlags,
|
|
2177
|
+
unsupportedSyntaxCount, unresolvedSpecifierCount, unresolvedSpecifiers };
|
|
2178
|
+
return assembleGraph(prepared, commons, walked, overrides);
|
|
2179
|
+
}
|