archstrict 0.0.0 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (67) hide show
  1. package/.agents/hooks/hooks.json +29 -0
  2. package/.agents/hooks/post-tool-use.mjs +107 -0
  3. package/.agents/hooks/pre-tool-use.mjs +182 -0
  4. package/.agents/mcp/server.mjs +71 -0
  5. package/.agents/plugin.json +19 -0
  6. package/AGENTS.md +81 -0
  7. package/CHANGELOG.md +77 -0
  8. package/README.ja.md +142 -0
  9. package/README.md +143 -2
  10. package/dist/augmentation-cache.js +65 -0
  11. package/dist/check-options.js +40 -0
  12. package/dist/classify.js +148 -0
  13. package/dist/cli.js +243 -0
  14. package/dist/config-pointer.js +251 -0
  15. package/dist/config.js +194 -0
  16. package/dist/edge-cache.js +530 -0
  17. package/dist/gitignore.js +271 -0
  18. package/dist/mcp-server.js +111 -0
  19. package/dist/module-candidates.js +125 -0
  20. package/dist/module-graph.js +2179 -0
  21. package/dist/project-path.js +59 -0
  22. package/dist/report-error.js +13 -0
  23. package/dist/rules/config-meaning.js +143 -0
  24. package/dist/rules/constraints.js +419 -0
  25. package/dist/rules/cycles.js +285 -0
  26. package/dist/rules/deprecated.js +67 -0
  27. package/dist/rules/empty-rule.js +101 -0
  28. package/dist/rules/moves.js +79 -0
  29. package/dist/rules/must-be-empty.js +52 -0
  30. package/dist/rules/public-surface.js +100 -0
  31. package/dist/rules/uncovered.js +75 -0
  32. package/dist/todo-migration.js +112 -0
  33. package/dist/todo-store.js +434 -0
  34. package/dist/type-closure.js +959 -0
  35. package/dist/type-leak.js +590 -0
  36. package/dist/verbs/agents.js +116 -0
  37. package/dist/verbs/check.js +1011 -0
  38. package/dist/verbs/fix.js +170 -0
  39. package/dist/verbs/hotspots.js +261 -0
  40. package/dist/verbs/init.js +538 -0
  41. package/dist/verbs/map-shape.js +78 -0
  42. package/dist/verbs/recommend.js +863 -0
  43. package/dist/verbs/rules.js +188 -0
  44. package/dist/verbs/search.js +109 -0
  45. package/dist/verbs/simulate.js +220 -0
  46. package/dist/verbs/todo.js +180 -0
  47. package/dist/warm-graph.js +82 -0
  48. package/docs/boundary-patterns.md +374 -0
  49. package/docs/calibrated-rules-design.md +124 -0
  50. package/docs/init-singleton-modules.md +133 -0
  51. package/docs/maintenance.md +109 -0
  52. package/docs/releasing.md +58 -0
  53. package/docs/rules-edge-cache.md +50 -0
  54. package/docs/todo-single-file-migration.md +58 -0
  55. package/llms.txt +25 -0
  56. package/package.json +61 -4
  57. package/skills/archstrict/SKILL.md +54 -0
  58. package/skills/archstrict/references/agents-verb.md +39 -0
  59. package/skills/archstrict/references/config.md +116 -0
  60. package/skills/archstrict/references/hook.md +57 -0
  61. package/skills/archstrict/references/path-rules.md +57 -0
  62. package/skills/archstrict/references/patterns.md +915 -0
  63. package/skills/archstrict/references/prove-rules.md +58 -0
  64. package/skills/archstrict/references/rearchitect.md +66 -0
  65. package/skills/archstrict/references/recommend.md +98 -0
  66. package/skills/archstrict/references/rules.md +149 -0
  67. package/skills/archstrict/references/simulate.md +109 -0
@@ -0,0 +1,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
+ }