archstrict 0.0.0 → 0.1.0

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