@ecoma-io/archkeep 0.13.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 (131) hide show
  1. package/LICENSE +202 -0
  2. package/README.md +262 -0
  3. package/cli.mjs +2792 -0
  4. package/index.mjs +85 -0
  5. package/lsp.mjs +81 -0
  6. package/nx.mjs +24 -0
  7. package/package.json +81 -0
  8. package/presets/clean-architecture.json +78 -0
  9. package/presets/ddd-bounded-contexts.json +88 -0
  10. package/presets/hexagonal.json +68 -0
  11. package/presets/layered.json +92 -0
  12. package/presets/modular-monolith.json +85 -0
  13. package/presets/vertical-slice.json +68 -0
  14. package/src/analysis/analyze.mjs +218 -0
  15. package/src/analysis/contract.md +259 -0
  16. package/src/analysis/go.mjs +414 -0
  17. package/src/analysis/manifest-util.mjs +68 -0
  18. package/src/analysis/python.mjs +1266 -0
  19. package/src/analysis/registry.mjs +74 -0
  20. package/src/analysis/rust.mjs +674 -0
  21. package/src/analysis/source-util.mjs +230 -0
  22. package/src/analysis/typescript.mjs +1034 -0
  23. package/src/analysis/vue.mjs +156 -0
  24. package/src/architecture-intent/intent-fingerprint.mjs +29 -0
  25. package/src/architecture-intent/judge.mjs +539 -0
  26. package/src/architecture-intent/model.mjs +703 -0
  27. package/src/architecture-intent/selectors.mjs +170 -0
  28. package/src/canonical.mjs +48 -0
  29. package/src/commands/README.md +266 -0
  30. package/src/commands/adr.mjs +248 -0
  31. package/src/commands/check.mjs +989 -0
  32. package/src/commands/context-command.mjs +212 -0
  33. package/src/commands/context.mjs +790 -0
  34. package/src/commands/custom-rules.mjs +428 -0
  35. package/src/commands/debt.mjs +218 -0
  36. package/src/commands/diff.mjs +523 -0
  37. package/src/commands/discover.mjs +159 -0
  38. package/src/commands/drift.mjs +473 -0
  39. package/src/commands/edge-constraints.mjs +355 -0
  40. package/src/commands/explain.mjs +359 -0
  41. package/src/commands/fitness.mjs +226 -0
  42. package/src/commands/graph.mjs +297 -0
  43. package/src/commands/health.mjs +213 -0
  44. package/src/commands/history.mjs +614 -0
  45. package/src/commands/impact.mjs +226 -0
  46. package/src/commands/plan-context-command.mjs +496 -0
  47. package/src/commands/policy.mjs +138 -0
  48. package/src/commands/provenance-command.mjs +352 -0
  49. package/src/commands/provenance.mjs +159 -0
  50. package/src/commands/reconcile.mjs +219 -0
  51. package/src/commands/report.mjs +553 -0
  52. package/src/commands/snapshot-meta.mjs +107 -0
  53. package/src/commands/waivers.mjs +240 -0
  54. package/src/config.mjs +1308 -0
  55. package/src/containment.mjs +234 -0
  56. package/src/custom-rules/evidence.mjs +340 -0
  57. package/src/custom-rules/host.mjs +1023 -0
  58. package/src/custom-rules/values.mjs +43 -0
  59. package/src/entry-point.mjs +55 -0
  60. package/src/errors.mjs +36 -0
  61. package/src/eslint-config.mjs +542 -0
  62. package/src/go-work.mjs +394 -0
  63. package/src/governance/adr-registry.mjs +539 -0
  64. package/src/governance/clock.mjs +69 -0
  65. package/src/governance/debt-ledger.mjs +274 -0
  66. package/src/governance/discovery-proposal.mjs +423 -0
  67. package/src/governance/fitness-registry.mjs +504 -0
  68. package/src/governance/fitness-rules.mjs +668 -0
  69. package/src/governance/metrics.mjs +392 -0
  70. package/src/governance/preset-fingerprints.json +16 -0
  71. package/src/governance/profile-registry.mjs +366 -0
  72. package/src/governance/provenance-record.mjs +177 -0
  73. package/src/governance/reconcile-candidates.mjs +301 -0
  74. package/src/governance/reconcile-score.mjs +503 -0
  75. package/src/governance/row-schema.mjs +208 -0
  76. package/src/governance/verdict.mjs +127 -0
  77. package/src/governance/waiver.mjs +105 -0
  78. package/src/graph/create-dependencies.mjs +96 -0
  79. package/src/intent/intent-manifest.json +347 -0
  80. package/src/intent/mask-non-code.mjs +640 -0
  81. package/src/lsp/boundary-config.mjs +225 -0
  82. package/src/lsp/diagnose.mjs +202 -0
  83. package/src/lsp/diagnostics.mjs +241 -0
  84. package/src/lsp/protocol.mjs +215 -0
  85. package/src/lsp/server.mjs +922 -0
  86. package/src/lsp/workspace-index.mjs +891 -0
  87. package/src/nx-json.mjs +95 -0
  88. package/src/options.mjs +611 -0
  89. package/src/process.mjs +91 -0
  90. package/src/providers/moon.mjs +733 -0
  91. package/src/providers/native/README.md +204 -0
  92. package/src/providers/native/coverage.mjs +74 -0
  93. package/src/providers/native/differential.fixtures.mjs +1277 -0
  94. package/src/providers/native/discover.mjs +431 -0
  95. package/src/providers/native/graph.mjs +234 -0
  96. package/src/providers/native/index.mjs +152 -0
  97. package/src/providers/native/model.mjs +755 -0
  98. package/src/providers/nx.mjs +178 -0
  99. package/src/report/README.md +89 -0
  100. package/src/report/adr-text.mjs +129 -0
  101. package/src/report/context-text.mjs +109 -0
  102. package/src/report/debt-text.mjs +105 -0
  103. package/src/report/diff-text.mjs +219 -0
  104. package/src/report/discover-text.mjs +186 -0
  105. package/src/report/drift-text.mjs +194 -0
  106. package/src/report/envelope-shape.mjs +161 -0
  107. package/src/report/evidence.mjs +157 -0
  108. package/src/report/explain-text.mjs +159 -0
  109. package/src/report/graph-text.mjs +116 -0
  110. package/src/report/health-text.mjs +123 -0
  111. package/src/report/history-text.mjs +204 -0
  112. package/src/report/impact-text.mjs +128 -0
  113. package/src/report/json.mjs +173 -0
  114. package/src/report/plan-context-text.mjs +159 -0
  115. package/src/report/provenance-text.mjs +78 -0
  116. package/src/report/reconcile-text.mjs +159 -0
  117. package/src/report/report-text.mjs +264 -0
  118. package/src/report/sarif.mjs +953 -0
  119. package/src/report/text.mjs +823 -0
  120. package/src/report/waivers-text.mjs +100 -0
  121. package/src/rules/README.md +123 -0
  122. package/src/rules/index.mjs +962 -0
  123. package/src/rules/match.mjs +1708 -0
  124. package/src/rules/messages.mjs +73 -0
  125. package/src/rules/reachability.mjs +224 -0
  126. package/src/rules/specifiers.mjs +300 -0
  127. package/src/rules/tags.mjs +238 -0
  128. package/src/rules/topology.mjs +333 -0
  129. package/src/tsconfig-paths.mjs +237 -0
  130. package/src/verdict.mjs +145 -0
  131. package/src/workspace.mjs +580 -0
@@ -0,0 +1,1034 @@
1
+ /**
2
+ * TypeScript/JavaScript analyzer — TypeScript's own parser and TypeScript's
3
+ * own resolver, wired to the injected workspace. Nothing here reimplements
4
+ * either.
5
+ *
6
+ * `ts.resolveModuleName` is a public API and already answers this question
7
+ * correctly for an Nx workspace: `tsconfig.base.json` `paths`, secondary
8
+ * entries, `exports` conditions, extension probing, and the
9
+ * `isExternalLibraryImport` flag all come out of one call. Reimplementing any
10
+ * of it would be a second answer to a question TypeScript already answers, and
11
+ * the two would disagree exactly where a boundary rule needs them not to
12
+ * (`contract.md`, "Resolution is delegated, never reimplemented").
13
+ *
14
+ * Known limits, deliberate and pinned by tests:
15
+ *
16
+ * - **`fileExists` is `readFile() !== null`.** `Workspace` exposes exactly one
17
+ * filesystem verb, and inventing a second would break the injected shape a
18
+ * test drives from memory, so an existence probe reads the file. Every read
19
+ * is memoised per workspace, which matters because TypeScript probes many
20
+ * candidate paths per specifier and hits the same ones over and over.
21
+ * - **No `realpath`.** A package linked into `node_modules` — pnpm's workspace
22
+ * protocol — resolves to its link path, so it reports `external` instead of
23
+ * naming the project behind the link. This workspace wires its libs through
24
+ * `tsconfig.base.json` `paths` rather than workspace links, so the case does
25
+ * not arise here; a consumer workspace that used links would see the missing
26
+ * edge as an unattributed external, never as a wrong project.
27
+ * - **`require(x)` and `require.resolve(x)` are read as imports** even when
28
+ * `require` is a local function of that name. Worst case is a spurious
29
+ * record naming a specifier the file really does contain — never a missed
30
+ * one. Those two callee forms are exactly the ones upstream's
31
+ * `getImportFromRequireCall` admits, and `isRequireCallee` below matches its
32
+ * shape node kind for node kind rather than widening it.
33
+ * - **`import("typescript")` in a type position (`ImportTypeNode`) is read as a
34
+ * `type-only` import.** The form `type X = import("typescript").Foo` and
35
+ * `const x: typeof import("typescript") = ...` both introduce a dependency on
36
+ * `typescript` that is erased at runtime, so they carry `kind: "type-only"` —
37
+ * the same classification `import type { X } from "typescript"` receives. The
38
+ * argument is the `LiteralType` child of the `ImportTypeNode`; a
39
+ * non-string-literal argument (a type reference, a template type) is
40
+ * recorded as unresolvable rather than dropped, following the same loud/skip
41
+ * discipline dynamic `import()` already applies. `typeof import("typescript")`
42
+ * wraps the same `ImportTypeNode` inside a `TypeQueryNode`; the walk visits
43
+ * the `ImportTypeNode` once and produces exactly one site, not a duplicate.
44
+ *
45
+ * - **A LITERAL specifier that names a DECLARED project and fails to resolve
46
+ * is a whole-file failure.** `import { x } from "@acme/ui"` where the native
47
+ * workspace declares a project named `@acme/ui` asks the resolver a concrete
48
+ * question about a workspace-internal dependency and the resolver answers
49
+ * "no such module" — the edge that import would have carried (or the
50
+ * violation it would have avoided) is simply missing, so the file could not
51
+ * be fully judged. That is the same "could not look" shape an unreadable
52
+ * file produces (`fileFailure`, `line: null`), and it rides `check`'s
53
+ * `unchecked` count to exit 3 (`cli.mjs`) rather than a silent pass.
54
+ * - **A literal package import that names NO declared project** (`vitest`,
55
+ * `@nx/eslint-plugin`, an uninstalled third-party package) is NOT a hole: a
56
+ * workspace with packages is a normal state, and failing the whole run on
57
+ * every unresolved package import would block merges over dependencies
58
+ * nobody crossed. Those stay positioned site failures — the "blind spot"
59
+ * the contract documents as legitimately permanent.
60
+ * - **A NON-LITERAL argument** — `import(url)` with a computed argument, a
61
+ * brace-group `use` the Rust analyzer records the same way — is genuinely
62
+ * not statically knowable and stays a positioned site failure, because the
63
+ * rest of the file's imports WERE judged. The two classes are deliberately
64
+ * different directions of the same "did not resolve" fact (`contract.md`).
65
+ *
66
+ * ## One `kind` per import site, and how a mixed statement is judged
67
+ *
68
+ * The record carries one `kind` (`contract.md`), so a statement that is two
69
+ * things at once has to be judged. Two rulings, both because `type-only` is a
70
+ * statement about **runtime erasure** and rules turn constraints off on it:
71
+ *
72
+ * - `import { type A, B } from "x"` is `static`, not `type-only`. `B` survives
73
+ * to runtime, so the dependency is real. Only a statement where nothing
74
+ * survives — `import type`, or a named list whose every element carries
75
+ * `type` — is erased, and only then is it `type-only`.
76
+ * - `export type { A } from "x"` is `type-only`, not `re-export`. It is both,
77
+ * and erasure is the property that decides which constraints apply at all; a
78
+ * barrel of types creates no runtime edge. Calling it `re-export` would
79
+ * apply runtime constraints to a statement that leaves no runtime behind.
80
+ */
81
+ import { isBuiltin } from "node:module";
82
+
83
+ import ts from "typescript";
84
+
85
+ import { DEFAULT_OPTIONS } from "../options.mjs";
86
+ import { normalizePath } from "./manifest-util.mjs";
87
+ import { emptyResult, fileFailure, perWorkspace, projectOwning } from "./source-util.mjs";
88
+
89
+ /** The directory a workspace-relative path sits in; `""` at the root. */
90
+ const directoryOf = (path) => (path.includes("/") ? path.slice(0, path.lastIndexOf("/")) : "");
91
+
92
+ /**
93
+ * The Nx workspace's shared compiler options live in a file whose name is an Nx
94
+ * CONVENTION, not a contract — `tsconfig.base.json` in nearly every workspace,
95
+ * and whatever the plugin's `tsConfig` option names in the rest. So the name
96
+ * arrives on the `workspace` rather than as a constant here (`../options.mjs`
97
+ * says why the two conventions are options and the language manifests are not):
98
+ * a workspace that renamed it would otherwise silently resolve no path alias at
99
+ * all, and every aliased import would read as external.
100
+ *
101
+ * It travels on the workspace and not as a fourth analyzer argument for a
102
+ * reason `perWorkspace` makes concrete: that cache is a `WeakMap` keyed on the
103
+ * workspace object's identity, so a filename passed alongside it could differ
104
+ * between two calls sharing one key and the second call would silently get the
105
+ * first one's parsed context. Carried ON the key, the two cannot come apart.
106
+ * The default below is the same one `../options.mjs` states, imported from
107
+ * there rather than respelled, and exists for the callers that build a
108
+ * workspace by hand — the resolvers next door and the analyzer tests — rather
109
+ * than as a second declaration of the convention.
110
+ */
111
+ const tsConfigOf = (workspace) => workspace.tsConfig ?? DEFAULT_OPTIONS.tsConfig;
112
+
113
+ /**
114
+ * TypeScript's "No inputs were found in config file" diagnostic.
115
+ *
116
+ * The one hardcoded literal in this module, and the justification is the
117
+ * rubric's: it is a fixed external contract (a TypeScript diagnostic code),
118
+ * there is no source to derive it from, and it appears exactly once. It is
119
+ * ignored because this module deliberately does not expand `include`/`files`
120
+ * globs — it wants `compilerOptions` and nothing else, so the config host's
121
+ * `readDirectory` returns nothing and TypeScript correctly observes that the
122
+ * config matched no inputs. Every OTHER config diagnostic is reported.
123
+ */
124
+ const NO_INPUTS_FOUND = 18003;
125
+
126
+ /**
127
+ * The TypeScript API this module delegates to, and the reason the manifest's
128
+ * peer range has an upper bound rather than the open `>=5` it carried first.
129
+ *
130
+ * TypeScript 7 is the native port, and its entry point exports `version` and
131
+ * `versionMajorMinor` and nothing else — `createSourceFile`, `resolveModuleName`
132
+ * and `sys` all moved behind `typescript/unstable/*` subpaths with a different
133
+ * shape. `>=5` was therefore a promise this package could not keep, and npm's
134
+ * `latest` tag now points at 7, so a consumer running `pnpm add typescript`
135
+ * today gets the version that breaks it. `<7` is measured: every name below
136
+ * resolves on 6.0.3 and none of them on 7.0.2.
137
+ *
138
+ * The check runs at module load because the alternative is what it replaces —
139
+ * a `TypeError: Cannot read properties of undefined (reading 'TS')` pointing at
140
+ * a frozen object literal, which names a property rather than a cause and sends
141
+ * the reader into this file instead of into their own manifest. A peer range is
142
+ * only advisory (npm warns, pnpm warns, neither refuses), so the range alone
143
+ * would let an unusable install reach exactly this crash.
144
+ */
145
+ const REQUIRED_TS_API = Object.freeze([
146
+ "createSourceFile",
147
+ "resolveModuleName",
148
+ "createModuleResolutionCache",
149
+ "parseJsonConfigFileContent",
150
+ "forEachChild",
151
+ "Extension",
152
+ "ScriptKind",
153
+ "ScriptTarget",
154
+ "SyntaxKind",
155
+ ]);
156
+
157
+ const missingTsApi = REQUIRED_TS_API.filter((name) => ts[name] === undefined);
158
+ if (missingTsApi.length > 0) {
159
+ throw new Error(
160
+ `archkeep needs the TypeScript compiler API, and the installed ` +
161
+ `typescript@${ts.version ?? "unknown"} does not expose ${missingTsApi.join(", ")}. ` +
162
+ `TypeScript 7 is the native port and moved that API behind typescript/unstable/*, ` +
163
+ `so this package declares typescript ">=5 <7". Install a 5.x or 6.x in the workspace ` +
164
+ `this plugin runs in.`,
165
+ );
166
+ }
167
+
168
+ /**
169
+ * Every extension TypeScript's own module resolver may load a module from,
170
+ * read off `ts.Extension` rather than kept by hand here. `.tsbuildinfo` is
171
+ * dropped because it is an output artefact and never a resolution target.
172
+ *
173
+ * The list is a fact about the installed compiler, so deriving it is what keeps
174
+ * `declinedSiblingOf` below honest across TypeScript versions — a hand-kept
175
+ * copy would be wrong the release a new extension lands, and wrong silently.
176
+ *
177
+ * MAY, not will: membership here is necessary and not sufficient, because one
178
+ * member is switched by a compiler option — see `ordinaryPassLoadsJson`.
179
+ */
180
+ const TS_RESOLVABLE_EXTENSIONS = Object.freeze(
181
+ Object.values(ts.Extension).filter((extension) => extension !== ts.Extension.TsBuildInfo),
182
+ );
183
+
184
+ /**
185
+ * Whether the ORDINARY resolution pass would itself load a `.json` target under
186
+ * these compiler options — asked of TypeScript rather than modelled here.
187
+ *
188
+ * `.json` is the one member of `TS_RESOLVABLE_EXTENSIONS` that an option turns
189
+ * off, and it is also the only member a probe remainder can carry in practice:
190
+ * for an extension it does not recognise TypeScript APPENDS (`data.json` is
191
+ * probed as `data.json.ts`), while for one it does it SUBSTITUTES (`legacy.js`
192
+ * is probed as `legacy.ts`), and a substitution leaves a remainder with no
193
+ * extension at all. So this predicate decides the whole of the guard below.
194
+ *
195
+ * Measured on typescript 5.9.3, where the `.json` is supplied by the SPECIFIER
196
+ * — a wildcard alias `@ui/*` → `pkg/src/*` reached by `@ui/data.json`, or a
197
+ * bare `baseUrl` mapping — with `module` written beside `moduleResolution` the
198
+ * way a real `tsconfig` writes them:
199
+ *
200
+ * | `resolveJsonModule` | Classic | Node10 | Node16 | NodeNext | Bundler |
201
+ * | ------------------- | ------- | ------ | ------ | -------- | ------- |
202
+ * | `true` | loads | loads | loads | loads | loads |
203
+ * | `false` | null | null | null | null | null |
204
+ * | unset | null | null | null | loads | loads |
205
+ *
206
+ * The prose this replaces claimed the ordinary pass loads `.json` "even with
207
+ * `resolveJsonModule` off", which is true in NONE of those five cells. In every
208
+ * "null" cell a project's `.json` source reached by an alias resolved to
209
+ * nothing, and the guard built on that claim then refused the
210
+ * declined-extension pass its answer too — a boundary crossing invisible from
211
+ * both passes, in exactly the workspaces that pass exists to serve.
212
+ *
213
+ * The SHAPE matters, and is why the probe below is a relative specifier. A
214
+ * `paths` TARGET that itself names the `.json` — `{"@ui/data":
215
+ * ["pkg/src/data.json"]}`, or a `*.json` target template — loads in all fifteen
216
+ * cells, because TypeScript takes a mapped target carrying an extension as the
217
+ * file to load. That case never reaches the widened pass at all, the ordinary
218
+ * one having already resolved it, so this returning `false` there costs
219
+ * nothing. The relationship that must hold does hold, in every one of the sixty
220
+ * combinations measured: wherever this returns `true` the ordinary pass really
221
+ * does load, so the guard can never refuse the widened pass a question the
222
+ * ordinary pass had abandoned.
223
+ *
224
+ * The answer is measured rather than derived because every derivation tried was
225
+ * wrong. `resolveJsonModule ?? moduleResolution === Bundler` reads like the
226
+ * table and contradicts the compiler in three measured places: `module:
227
+ * "preserve"` and `module: "nodenext"` with `moduleResolution` UNSET both load
228
+ * while naming no Bundler, and the unset row above moves under NodeNext
229
+ * depending on whether `module` was written beside `moduleResolution` — one
230
+ * `moduleResolution`, two answers. A `ts.resolveModuleName` against a two-entry
231
+ * host cannot drift from the resolver the rest of this module drives, and costs
232
+ * one call per workspace: `contextFor` is memoised per workspace and asks once.
233
+ *
234
+ * @param {import("typescript").CompilerOptions} options
235
+ * @returns {boolean}
236
+ */
237
+ function ordinaryPassLoadsJson(options) {
238
+ // A directory the workspace host never sees, so the probe cannot be answered
239
+ // by a real file and cannot answer for one: this asks about the OPTIONS.
240
+ const directory = "/__archkeep_json_probe__";
241
+ const probe = `${directory}/probe.json`;
242
+ const host = {
243
+ fileExists: (path) => path === probe,
244
+ readFile: (path) => (path === probe ? "{}" : undefined),
245
+ };
246
+ const resolution = ts.resolveModuleName("./probe.json", `${directory}/index.ts`, options, host);
247
+ return resolution.resolvedModule?.resolvedFileName === probe;
248
+ }
249
+
250
+ /**
251
+ * Whether `path` ends in an extension the ordinary pass would load it from.
252
+ *
253
+ * `jsonIsResolvable` is `ordinaryPassLoadsJson`'s answer for the workspace's
254
+ * own compiler options; every other member of the list is unconditional.
255
+ */
256
+ const ordinaryPassLoads = (path, jsonIsResolvable) =>
257
+ TS_RESOLVABLE_EXTENSIONS.some(
258
+ (extension) =>
259
+ (jsonIsResolvable || extension !== ts.Extension.Json) && path.endsWith(extension),
260
+ );
261
+
262
+ /** Whether `path`'s own basename names an extension at all. */
263
+ const namesAnExtension = (path) => path.slice(path.lastIndexOf("/") + 1).includes(".");
264
+
265
+ /** Which TypeScript dialect a file extension is written in. */
266
+ const SCRIPT_KIND_BY_EXTENSION = Object.freeze({
267
+ ".ts": ts.ScriptKind.TS,
268
+ ".mts": ts.ScriptKind.TS,
269
+ ".cts": ts.ScriptKind.TS,
270
+ ".tsx": ts.ScriptKind.TSX,
271
+ ".js": ts.ScriptKind.JS,
272
+ ".mjs": ts.ScriptKind.JS,
273
+ ".cjs": ts.ScriptKind.JS,
274
+ ".jsx": ts.ScriptKind.JSX,
275
+ });
276
+
277
+ /**
278
+ * Which dialect a `lang` attribute names. The Vue analyzer hands a `<script>`
279
+ * block's `lang` here because a `.vue` file's extension cannot say which of
280
+ * the four its script is written in.
281
+ */
282
+ const SCRIPT_KIND_BY_LANG = Object.freeze({
283
+ ts: ts.ScriptKind.TS,
284
+ tsx: ts.ScriptKind.TSX,
285
+ js: ts.ScriptKind.JS,
286
+ jsx: ts.ScriptKind.JSX,
287
+ });
288
+
289
+ /**
290
+ * How a JavaScript-family specifier is SPELLED — the per-language half of the
291
+ * analysis record (`contract.md`, "How the specifier is spelled").
292
+ *
293
+ * This is the family where the two bits coincide, which is exactly why the rule
294
+ * engine used to derive them itself and got Rust and Python wrong: here a
295
+ * relative reference IS a filesystem path, so one predicate answered both
296
+ * questions and looked general. It is not — a Rust `super::x` and a Python
297
+ * `..pkg` are relative and are not paths.
298
+ *
299
+ * `relative` is upstream's `isRelativePath` (nx `fileutils`), which accepts a
300
+ * bare `.` and `..` where its two-character neighbour `isRelative`
301
+ * (`../rules/specifiers.mjs`, still used for upstream's path arithmetic) does
302
+ * not. The two must keep disagreeing about those two inputs, so they are stated
303
+ * once each rather than derived from one another.
304
+ *
305
+ * A non-literal `import()` argument arrives here as its own source text
306
+ * (`` `./${dir}/x` ``), which starts with a backtick or a quote and so is
307
+ * neither — the honest answer for a specifier nobody can read.
308
+ *
309
+ * @param {string} specifier The raw string as written.
310
+ * @returns {{ path: boolean, relative: boolean }}
311
+ */
312
+ export function specifierSpelling(specifier) {
313
+ const relative =
314
+ specifier === "." ||
315
+ specifier === ".." ||
316
+ specifier.startsWith("./") ||
317
+ specifier.startsWith("../");
318
+ return { path: relative || specifier.startsWith("/"), relative };
319
+ }
320
+
321
+ /**
322
+ * The package a bare specifier names, or `null` when the specifier is a path
323
+ * rather than a package.
324
+ *
325
+ * A scoped package keeps both segments (`@tauri-apps/api` for
326
+ * `@tauri-apps/api/window`) and never the deep path, because that is what a
327
+ * `bannedExternalImports` glob is written against (`contract.md`). The deep
328
+ * path stays in the record's `specifier`, so a rule can match either without
329
+ * re-parsing.
330
+ *
331
+ * @param {string} specifier
332
+ * @returns {string|null}
333
+ */
334
+ export function packageNameOf(specifier) {
335
+ if (specifier === "" || specifier.startsWith(".") || specifier.startsWith("/")) return null;
336
+ const segments = specifier.split("/");
337
+ if (specifier.startsWith("@"))
338
+ return segments.length >= 2 ? `${segments[0]}/${segments[1]}` : null;
339
+ return segments[0];
340
+ }
341
+
342
+ /**
343
+ * Whether an unresolvable literal specifier names a project this workspace
344
+ * DECLARES — the discriminator between a missing workspace edge and a
345
+ * legitimate (perhaps uninstalled) package dependency.
346
+ *
347
+ * Both the full specifier and its package name (`packageNameOf`) are matched
348
+ * against the declared project names. A project is imported by its own name in
349
+ * a native `archkeep.json` workspace; a scoped specifier `@billing/api` names a
350
+ * project whose declared name IS `@billing/api` (names are non-empty strings,
351
+ * and the model performs no normalisation — `providers/native/model.mjs`
352
+ * accepts the scope verbatim), and a deep path into such a project
353
+ * (`@billing/api/models`) still matches its package name. A specifier that
354
+ * reduces to no package name — a relative path, an empty string — is a path
355
+ * spelling judged by the path rules, never a workspace-internal package
356
+ * dependency, so it stays a blind spot.
357
+ *
358
+ * @param {string} specifier The raw specifier as written.
359
+ * @param {{projects: {name: string}[]}} workspace
360
+ * @returns {boolean}
361
+ */
362
+ function namesDeclaredProject(specifier, workspace) {
363
+ const name = packageNameOf(specifier);
364
+ if (name === null) return false;
365
+ return workspace.projects.some((project) => project.name === name || project.name === specifier);
366
+ }
367
+
368
+ /** The dialect to parse `sourceFile` as; `lang` wins when the caller knows it. */
369
+ function scriptKindFor(sourceFile, lang) {
370
+ if (lang) return SCRIPT_KIND_BY_LANG[lang] ?? ts.ScriptKind.TS;
371
+ const dot = sourceFile.lastIndexOf(".");
372
+ return SCRIPT_KIND_BY_EXTENSION[sourceFile.slice(dot)] ?? ts.ScriptKind.TS;
373
+ }
374
+
375
+ /**
376
+ * A `ts.ModuleResolutionHost` over the injected workspace, plus the memo that
377
+ * makes it affordable. Every path TypeScript hands back is absolute, so the
378
+ * translation to the workspace-relative reader happens here and once.
379
+ */
380
+ function resolutionHostFor(workspace) {
381
+ const root = workspace.root.replace(/\/+$/, "");
382
+ const prefix = `${root}/`;
383
+ const contents = new Map();
384
+ const read = (absolute) => {
385
+ if (contents.has(absolute)) return contents.get(absolute);
386
+ const text = absolute.startsWith(prefix)
387
+ ? workspace.readFile(absolute.slice(prefix.length))
388
+ : null;
389
+ contents.set(absolute, text ?? null);
390
+ return text ?? null;
391
+ };
392
+ return {
393
+ root,
394
+ fileExists: (path) => read(path) !== null,
395
+ readFile: (path) => read(path) ?? undefined,
396
+ };
397
+ }
398
+
399
+ /**
400
+ * The real file a probe path is the TypeScript sibling of, or `null`.
401
+ *
402
+ * TypeScript resolves a module by forming candidate paths and asking the host
403
+ * whether each exists — for the candidate `…/PageHeader.vue` it probes
404
+ * `…/PageHeader.vue.ts`, `…/PageHeader.vue.tsx`, `…/PageHeader.vue.d.ts` and so
405
+ * on (measured on typescript 5.9.3, in every `moduleResolution` mode). This
406
+ * reverses one of those probes: strip a TypeScript extension off the end and
407
+ * see whether what remains is a file the workspace really has.
408
+ *
409
+ * Two guards keep it to the case it exists for, and both are scope statements
410
+ * rather than repairs of an observed failure — they are what says in code, not
411
+ * in prose, that this pass answers exactly one question. The remainder must
412
+ * itself name an extension (`namesAnExtension`), so an `index` or
413
+ * extension-substitution probe for a specifier that named no file at all can
414
+ * never be answered here: `./widgets` reaching `./widgets/index.vue`, and
415
+ * `../Button` reaching `../Button.vue`, both stay refused, which is the
416
+ * narrowness the relative branch in `resolveSpecifier` states outright. And
417
+ * the remainder must not be one the ordinary pass would itself have loaded
418
+ * (`ordinaryPassLoads`) — a remainder that pass can load is one it already had
419
+ * its chance at, so answering for it would let a widened `fileExists` decide a
420
+ * question that pass owns. Measured on typescript 5.9.3: a `.js` alias target
421
+ * resolves in the ordinary pass with `allowJs` on AND off, a `.d.ts` one always
422
+ * does, and a `.json` one does only under the options `ordinaryPassLoadsJson`
423
+ * measures — which is why that half of the guard is an argument rather than a
424
+ * constant.
425
+ *
426
+ * The extensions are tested in `ts.Extension` order and the first one whose
427
+ * remainder EXISTS wins, not the first one that merely matches: `…/x.vue.d.ts`
428
+ * ends in both `.ts` and `.d.ts`, and stopping at `.ts` would test `…/x.vue.d`
429
+ * and answer no.
430
+ *
431
+ * @param {string} probe An absolute path TypeScript asked about.
432
+ * @param {(path: string) => boolean} exists
433
+ * @param {boolean} jsonIsResolvable `ordinaryPassLoadsJson` for these options.
434
+ * @returns {string|null}
435
+ */
436
+ function declinedSiblingOf(probe, exists, jsonIsResolvable) {
437
+ for (const extension of TS_RESOLVABLE_EXTENSIONS) {
438
+ if (!probe.endsWith(extension)) continue;
439
+ const candidate = probe.slice(0, -extension.length);
440
+ if (!namesAnExtension(candidate) || ordinaryPassLoads(candidate, jsonIsResolvable)) continue;
441
+ if (exists(candidate)) return candidate;
442
+ }
443
+ return null;
444
+ }
445
+
446
+ /**
447
+ * The same `ts.ModuleResolutionHost`, plus one answer: a file whose extension
448
+ * TypeScript declines is reported to exist under the name TypeScript is looking
449
+ * for it by.
450
+ *
451
+ * This is what lets a `paths` alias pointing at `packages/blocks/page-header/src/PageHeader.vue`
452
+ * be resolved by `ts.resolveModuleName` itself instead of by a table read here
453
+ * — the fix for a `.vue` (or `.css`, or `.svg`) alias target resolving to
454
+ * NOTHING, which skipped every `depConstraints` row for the edge. The mechanism
455
+ * is traced at `resolveSpecifier`'s declined-extension branch, which is where
456
+ * the record that carries it is built; the one-line version is that no target
457
+ * was named at all, not that a wrong one was.
458
+ *
459
+ * **It is not the second resolver `AGENTS.md` forbids, and the distinction is
460
+ * mechanical rather than a matter of degree.** Nothing here matches a `paths`
461
+ * pattern, substitutes a `*`, applies `baseUrl`, or walks `node_modules`;
462
+ * `ts.resolveModuleName` still does all of it, from the same parsed
463
+ * `compilerOptions`. The only thing added is an answer to a `fileExists`
464
+ * question TypeScript was already asking, and the answer is read off the
465
+ * workspace rather than computed. A specifier TypeScript would decline for any
466
+ * other reason — no matching alias, a target that is not there — is declined
467
+ * here too, which is what the resolver's own negative answers below are pinned
468
+ * on.
469
+ *
470
+ * The widened host gets its own resolution cache in `contextFor`, never the
471
+ * ordinary one: a cache shared between the two would let a directory-level
472
+ * entry recorded under the widened answers decide an ordinary resolution.
473
+ *
474
+ * @param {{ root: string, fileExists: (path: string) => boolean, readFile: (path: string) => string|undefined }} host
475
+ * @param {boolean} jsonIsResolvable `ordinaryPassLoadsJson` for these options.
476
+ */
477
+ function declinedExtensionHostFor(host, jsonIsResolvable) {
478
+ return {
479
+ root: host.root,
480
+ fileExists: (path) =>
481
+ host.fileExists(path) || declinedSiblingOf(path, host.fileExists, jsonIsResolvable) !== null,
482
+ readFile: host.readFile,
483
+ };
484
+ }
485
+
486
+ /**
487
+ * Everything TypeScript needs that is per-workspace rather than per-file:
488
+ * parsed compiler options, the resolution host, and TypeScript's own
489
+ * directory-level resolution cache.
490
+ *
491
+ * A **missing** `tsconfig.base.json` is not a failure — a workspace need not
492
+ * have one, and without `paths` an alias simply fails to resolve, which is
493
+ * already reported at the import site that used it. A **malformed** one is a
494
+ * failure, and a loud one: it silently drops every path alias, which would
495
+ * turn a whole workspace's aliased imports into "unresolvable" with no
496
+ * indication that the cause was one broken file.
497
+ */
498
+ const contextFor = perWorkspace((workspace) => {
499
+ const host = resolutionHostFor(workspace);
500
+ const flatten = (message) => ts.flattenDiagnosticMessageText(message, " ");
501
+ const tsConfig = tsConfigOf(workspace);
502
+
503
+ // One shape for all three exits below, so the widened host and its OWN cache
504
+ // (`declinedExtensionHostFor` says why they may not be shared with the
505
+ // ordinary ones) cannot come to exist on some paths and not others — a
506
+ // workspace with no tsconfig still resolves relative and `node_modules`
507
+ // specifiers, and a `.vue` sibling of one of those is the same question.
508
+ const context = (options, configFailure) => {
509
+ // Asked once per workspace, and carried on the context so the widened
510
+ // host's `fileExists` and the sibling lookup that reads its answer back
511
+ // (`resolveSpecifier`) cannot come to hold different opinions of it: a host
512
+ // that reported `…/data.json.ts` to exist while the lookup refused to map
513
+ // it back would resolve the specifier and then discard the answer.
514
+ const jsonIsResolvable = ordinaryPassLoadsJson(options);
515
+ return {
516
+ host,
517
+ options,
518
+ cache: ts.createModuleResolutionCache(host.root, (x) => x, options),
519
+ declinedHost: declinedExtensionHostFor(host, jsonIsResolvable),
520
+ declinedCache: ts.createModuleResolutionCache(host.root, (x) => x, options),
521
+ jsonIsResolvable,
522
+ configFailure,
523
+ };
524
+ };
525
+
526
+ const text = workspace.readFile(tsConfig);
527
+ if (text === null) {
528
+ return context(/** @type {import("typescript").CompilerOptions} */ ({}), null);
529
+ }
530
+
531
+ const json = ts.parseConfigFileTextToJson(tsConfig, text);
532
+ if (json.error) {
533
+ return context(
534
+ /** @type {import("typescript").CompilerOptions} */ ({}),
535
+ `${tsConfig} is not valid JSON, so no path alias resolves: ${flatten(json.error.messageText)}`,
536
+ );
537
+ }
538
+ const configHost = {
539
+ useCaseSensitiveFileNames: true,
540
+ readDirectory: () => [],
541
+ fileExists: host.fileExists,
542
+ readFile: host.readFile,
543
+ };
544
+ const parsed = ts.parseJsonConfigFileContent(
545
+ json.config,
546
+ configHost,
547
+ host.root,
548
+ undefined,
549
+ `${host.root}/${tsConfig}`,
550
+ );
551
+ const errors = parsed.errors.filter((error) => error.code !== NO_INPUTS_FOUND);
552
+ return context(
553
+ parsed.options,
554
+ errors.length === 0
555
+ ? null
556
+ : `${tsConfig} is malformed, so path aliases may not resolve: ${errors.map((e) => flatten(e.messageText)).join("; ")}`,
557
+ );
558
+ });
559
+
560
+ /**
561
+ * The alias table exactly as this analyzer's resolver will use it — for the
562
+ * paths hygiene check in `../tsconfig-paths.mjs`. Reading `contextFor`'s own
563
+ * memoised context (same file via `tsConfigOf`, same parse, same `extends`
564
+ * handling) is what makes "the check judges the table the resolver reads" a
565
+ * construction rather than a promise: there is no second load that could
566
+ * disagree. `paths` is `undefined` when the workspace has no tsconfig or the
567
+ * tsconfig declares none — the caller's silent case — while a config that
568
+ * failed to load reports through `configFailure`, never as an absent table.
569
+ * `base` is what `paths` targets resolve against: `baseUrl` when set, else
570
+ * TypeScript's own `pathsBasePath` (the declaring config's directory), else
571
+ * the workspace root — the same precedence `ts.resolveModuleName` applies.
572
+ *
573
+ * @param {import("./analyze.mjs").Workspace} workspace
574
+ * @returns {{ tsConfig: string, configFailure: string|null,
575
+ * paths: Record<string, unknown>|undefined, base: string }}
576
+ */
577
+ export function tsconfigPathsFacts(workspace) {
578
+ const context = contextFor(workspace);
579
+ return {
580
+ tsConfig: tsConfigOf(workspace),
581
+ configFailure: context.configFailure,
582
+ paths: context.options.paths,
583
+ base: context.options.baseUrl ?? context.options.pathsBasePath ?? context.host.root,
584
+ };
585
+ }
586
+
587
+ /** `static` unless nothing in the import clause survives to runtime. */
588
+ function importDeclarationKind(node) {
589
+ const clause = node.importClause;
590
+ if (!clause) return "static"; // `import "./side-effect"`
591
+ if (clause.isTypeOnly) return "type-only";
592
+ if (clause.name) return "static"; // a default binding is a value
593
+ const bindings = clause.namedBindings;
594
+ if (bindings && ts.isNamedImports(bindings) && bindings.elements.length > 0) {
595
+ return bindings.elements.every((element) => element.isTypeOnly) ? "type-only" : "static";
596
+ }
597
+ return "static"; // namespace import, or an empty `{}`
598
+ }
599
+
600
+ /** `type-only` when the re-export is erased, `re-export` otherwise. */
601
+ function exportDeclarationKind(node) {
602
+ if (node.isTypeOnly) return "type-only";
603
+ const clause = node.exportClause;
604
+ if (clause && ts.isNamedExports(clause) && clause.elements.length > 0) {
605
+ return clause.elements.every((element) => element.isTypeOnly) ? "type-only" : "re-export";
606
+ }
607
+ return "re-export"; // `export * from`, or a namespace re-export
608
+ }
609
+
610
+ /**
611
+ * Whether a call's callee names a CommonJS module lookup — upstream's
612
+ * `getImportFromRequireCall` test, in TypeScript's AST instead of ESTree's.
613
+ *
614
+ * Upstream admits exactly two shapes and refuses everything else: a bare
615
+ * `require` identifier, and a `MemberExpression` whose object is the identifier
616
+ * `require` and whose property is the identifier `resolve`. Mapped node kind
617
+ * for node kind, ESTree's non-computed `MemberExpression` is TypeScript's
618
+ * `PropertyAccessExpression` — so `require["resolve"](x)` is refused by both,
619
+ * upstream because its property is a `Literal` rather than an `Identifier` and
620
+ * here because it parses as an `ElementAccessExpression`. A `PrivateIdentifier`
621
+ * name keeps its `#` in `.text` and so can never equal `"resolve"`.
622
+ *
623
+ * Matching upstream's shape rather than a wider one is the point: a callee
624
+ * upstream ignores would make this engine report where ESLint stays silent, and
625
+ * every divergence in either direction has to be a decision someone wrote down
626
+ * (`../conformance/README.md`).
627
+ */
628
+ function isRequireCallee(callee) {
629
+ if (ts.isIdentifier(callee)) return callee.text === "require";
630
+ return (
631
+ ts.isPropertyAccessExpression(callee) &&
632
+ ts.isIdentifier(callee.expression) &&
633
+ callee.expression.text === "require" &&
634
+ callee.name.text === "resolve"
635
+ );
636
+ }
637
+
638
+ /**
639
+ * Every import site in a parsed source, in source order.
640
+ *
641
+ * Order is established by sorting on offset rather than by trusting the walk:
642
+ * a depth-first walk happens to visit these node kinds in source order today,
643
+ * and `contract.md` promises source order as a property of the output, not as
644
+ * a side effect of the traversal.
645
+ *
646
+ * `callee` is the call form a site was written with (`import`, `require`,
647
+ * `require.resolve`) and is absent for a declaration; it names the construct in
648
+ * the failure a non-literal argument produces, so the report quotes what the
649
+ * file actually says.
650
+ *
651
+ * @returns {{ offset: number, specifier: string, kind: string, literal: boolean, callee?: string }[]}
652
+ */
653
+ function importSitesIn(sourceFile) {
654
+ const sites = [];
655
+ const at = (node) => node.getStart(sourceFile);
656
+
657
+ const push = (node, kind, callee) => {
658
+ if (ts.isStringLiteralLike(node)) {
659
+ sites.push({ offset: at(node), specifier: node.text, kind, literal: true, callee });
660
+ return;
661
+ }
662
+ // A non-literal argument: the site is real and the target is not knowable
663
+ // statically. The record keeps the argument's source text as `specifier`
664
+ // so a report can show what was written (`contract.md`).
665
+ sites.push({
666
+ offset: at(node),
667
+ specifier: sourceFile.text.slice(at(node), node.end).trim(),
668
+ kind,
669
+ literal: false,
670
+ callee,
671
+ });
672
+ };
673
+
674
+ const pushCallArgument = (call, kind, callee) => {
675
+ if (call.arguments.length === 0) {
676
+ sites.push({ offset: at(call), specifier: "", kind, literal: false, callee });
677
+ return;
678
+ }
679
+ push(call.arguments[0], kind, callee);
680
+ };
681
+
682
+ const visit = (node) => {
683
+ if (ts.isImportDeclaration(node)) {
684
+ push(node.moduleSpecifier, importDeclarationKind(node));
685
+ } else if (ts.isExportDeclaration(node) && node.moduleSpecifier) {
686
+ push(node.moduleSpecifier, exportDeclarationKind(node));
687
+ } else if (
688
+ ts.isImportEqualsDeclaration(node) &&
689
+ ts.isExternalModuleReference(node.moduleReference)
690
+ ) {
691
+ push(node.moduleReference.expression, node.isTypeOnly ? "type-only" : "static");
692
+ } else if (ts.isCallExpression(node)) {
693
+ if (node.expression.kind === ts.SyntaxKind.ImportKeyword) {
694
+ pushCallArgument(node, "dynamic", "import");
695
+ } else if (isRequireCallee(node.expression)) {
696
+ // `static` for `require(...)` and `require.resolve(...)` alike.
697
+ // `contract.md`'s kinds separate sites by what survives to runtime and
698
+ // by when the target is fetched — `type-only` is erased, `dynamic` is
699
+ // deferred — and `require.resolve("x")` is neither: the specifier is
700
+ // written literally, survives to runtime, and is read where the
701
+ // statement stands. That the call yields a PATH rather than the
702
+ // module's exports is a fact about its VALUE, and no rule reads the
703
+ // value; all fifteen read the specifier, on which the two calls are the
704
+ // same static declaration of a dependency on another project.
705
+ //
706
+ // Upstream agrees by construction — both forms enter its one
707
+ // `run(imp, node)` with nothing distinguishing them — so any other kind
708
+ // here would move a verdict away from ESLint on a site the two
709
+ // otherwise agree about (`kind === "static"` is what gates the
710
+ // lazy-loaded check).
711
+ //
712
+ // A non-literal argument is unresolvable, not dynamic, either way.
713
+ pushCallArgument(
714
+ node,
715
+ "static",
716
+ ts.isIdentifier(node.expression) ? "require" : "require.resolve",
717
+ );
718
+ }
719
+ } else if (ts.isImportTypeNode(node)) {
720
+ // ImportType is always type-only — it is a type query, erased at runtime.
721
+ // `typeof import("typescript")` wraps the same ImportTypeNode inside a
722
+ // TypeQueryNode; `ts.forEachChild` descends into the TypeQueryNode and
723
+ // reaches this branch once, producing exactly one site, not a duplicate.
724
+ // The argument is a LiteralType wrapping a StringLiteral for string-literal
725
+ // arguments. Non-literal arguments (type references, template types) are
726
+ // recorded as unresolvable following the same loud/skip discipline dynamic
727
+ // import() already applies.
728
+ const arg = node.argument;
729
+ if (ts.isLiteralTypeNode(arg) && ts.isStringLiteralLike(arg.literal)) {
730
+ push(arg.literal, "type-only");
731
+ } else {
732
+ // Non-literal argument: the site is real, the target is not knowable.
733
+ push(arg, "type-only", "import");
734
+ }
735
+ }
736
+ ts.forEachChild(node, visit);
737
+ };
738
+
739
+ visit(sourceFile);
740
+ return sites.sort((a, b) => a.offset - b.offset);
741
+ }
742
+
743
+ /**
744
+ * TypeScript's recorded syntax errors for a parsed file.
745
+ *
746
+ * `parseDiagnostics` is TypeScript's own field on a `SourceFile` and the only
747
+ * way to see parse errors without building a `Program` — which would need the
748
+ * whole compilation, on a machine that may have none of it. It is read
749
+ * defensively: if a TypeScript upgrade ever removes it, this degrades to "no
750
+ * parse failure reported" and never to a throw, because `createSourceFile`
751
+ * itself recovers from malformed input and returns whatever it could parse.
752
+ */
753
+ function parseFailures(sourceFile, workspaceRelativePath) {
754
+ const diagnostics = sourceFile.parseDiagnostics;
755
+ if (!Array.isArray(diagnostics)) return [];
756
+ return diagnostics.map((diagnostic) => {
757
+ const { line, character } = sourceFile.getLineAndCharacterOfPosition(diagnostic.start ?? 0);
758
+ return {
759
+ sourceFile: workspaceRelativePath,
760
+ line: line + 1,
761
+ column: character + 1,
762
+ reason: `parse error: ${ts.flattenDiagnosticMessageText(diagnostic.messageText, " ")}`,
763
+ };
764
+ });
765
+ }
766
+
767
+ /**
768
+ * Where a specifier points, through `ts.resolveModuleName`.
769
+ *
770
+ * `external` is decided by `isExternalLibraryImport` first and **project
771
+ * ownership of the resolved file** second, rather than by ownership alone.
772
+ * Ownership is `contract.md`'s definition ("resolves outside every project")
773
+ * and gets a workspace file belonging to no project right — an alias pointed at
774
+ * a loose root-level file is external, and `packageName` stays `null` because a
775
+ * relative or aliased path names no package. But ownership alone is wrong for a
776
+ * nested `node_modules/`, which every package that declares its own
777
+ * `dependencies` has; the branch below says why, with the measurement.
778
+ *
779
+ * @returns {{ resolved: object|null, reason: string|null }}
780
+ */
781
+ function resolveSpecifier(specifier, sourceFile, workspace) {
782
+ const context = contextFor(workspace);
783
+ const containingFile = `${context.host.root}/${sourceFile}`;
784
+ const resolution = ts.resolveModuleName(
785
+ specifier,
786
+ containingFile,
787
+ context.options,
788
+ context.host,
789
+ context.cache,
790
+ );
791
+ let resolvedFileName = resolution.resolvedModule?.resolvedFileName ?? null;
792
+ let isExternalLibraryImport = resolution.resolvedModule?.isExternalLibraryImport ?? false;
793
+ if (resolvedFileName === null) {
794
+ // A Node built-in resolves for real at runtime, and TypeScript's resolver
795
+ // structurally cannot say so: `node:fs` and `fs` have no package to find,
796
+ // they are wired into the runtime, and a Program reaches them through
797
+ // `types`/`lib` rather than through module resolution. Reporting them as
798
+ // unresolvable would be a false statement about the world AND would bury
799
+ // every genuine failure — measured on this workspace, 546 of 548 failures
800
+ // were `node:*` and stdlib specifiers before this branch existed.
801
+ //
802
+ // The list comes from `node:module`'s own `isBuiltin`, never from a copy
803
+ // of it here: the set changes with the Node version this runs on, and a
804
+ // hand-kept list would be wrong the release after it was written.
805
+ //
806
+ // It is checked AFTER TypeScript, not before, so a workspace that really
807
+ // does contain a package named `fs` still resolves to it.
808
+ if (isBuiltin(specifier)) {
809
+ return {
810
+ resolved: {
811
+ target: null,
812
+ file: null,
813
+ external: true,
814
+ packageName: packageNameOf(specifier),
815
+ },
816
+ reason: null,
817
+ };
818
+ }
819
+ // A relative specifier IS a path — there is no resolution left to
820
+ // delegate, only a normalisation and an existence check. TypeScript
821
+ // declines these because the extension is not one it compiles (`.vue`,
822
+ // `.css`, `.svg`), and on this workspace that is a real hole rather than a
823
+ // cosmetic one: `import Button from "../Button.vue"` can cross a project
824
+ // boundary, and dropping it would make that crossing invisible.
825
+ //
826
+ // Deliberately narrow. No extension probing, no `index` lookup, no `paths`
827
+ // mapping — the exact named file must exist. Anything more would be the
828
+ // second resolver `AGENTS.md` forbids.
829
+ if (specifier.startsWith("./") || specifier.startsWith("../")) {
830
+ // Bundler query and fragment suffixes (`?raw`, `?url#hash`) name the
831
+ // same file with a load-time transform; the raw form stays in the
832
+ // record's `specifier`, so a rule that cares can still see it.
833
+ const path = normalizePath(directoryOf(sourceFile), specifier.split(/[?#]/)[0]);
834
+ if (workspace.readFile(path) !== null) {
835
+ const owner = projectOwning(workspace.projects, path);
836
+ return {
837
+ resolved: {
838
+ target: owner?.name ?? null,
839
+ file: path,
840
+ external: owner === null,
841
+ packageName: null,
842
+ },
843
+ reason: null,
844
+ };
845
+ }
846
+ }
847
+ // The NON-relative half of the same hole, and the one that was silent.
848
+ // A `paths` alias — or a `baseUrl` mapping, or a deep path into an
849
+ // installed package — can land on a file whose extension TypeScript
850
+ // declines just as a relative specifier can, and in a Vue workspace it
851
+ // routinely does: `@acme/blocks/page-header` mapped to
852
+ // `…/src/PageHeader.vue` is a project's SOURCE, not an asset.
853
+ //
854
+ // Declined, the site named NO target at all: this branch fell through to
855
+ // the `resolved: null` return below, `../rules/index.mjs`'s
856
+ // `resolveTargetNode` answers `undefined` for a record that did not
857
+ // resolve, and the site landed in the `!targetProject` branch — which is
858
+ // `../../../../docs/reference/violations.md`, "The order matters", step 4
859
+ // (unresolvable target), not step 6. It never reached the external
860
+ // classification at all, because `externalNodeFor` sits past a resolution
861
+ // that never happened. The consequence is the same either way, since steps
862
+ // 4 and 6 share rule 10: every `depConstraints` row was skipped, an
863
+ // aliased specifier is no path so `noRelativeOrAbsoluteExternals` did not
864
+ // apply, and under `banTransitiveDependencies: false` — the option's own
865
+ // default — nothing was reported at all, so a primitive reaching into a
866
+ // block's `.vue` source scored a clean run and exit 0.
867
+ //
868
+ // The answer still comes from `ts.resolveModuleName`. `declinedExtensionHostFor`
869
+ // argues at length why widening one `fileExists` answer is not the second
870
+ // resolver this package must not grow; the short version is that every
871
+ // mapping rule stays TypeScript's, including the `*` substitution and the
872
+ // `node_modules` walk, and a specifier TypeScript declines for any reason
873
+ // other than the target's extension is still declined here.
874
+ //
875
+ // The relative branch above keeps its narrower rules and runs FIRST: it
876
+ // strips bundler query suffixes and refuses extension probing outright,
877
+ // which this pass has no way to offer, so a relative specifier it settles
878
+ // never reaches here.
879
+ const declined = ts.resolveModuleName(
880
+ specifier,
881
+ containingFile,
882
+ context.options,
883
+ context.declinedHost,
884
+ context.declinedCache,
885
+ );
886
+ const sibling = declined.resolvedModule
887
+ ? declinedSiblingOf(
888
+ declined.resolvedModule.resolvedFileName,
889
+ context.host.fileExists,
890
+ context.jsonIsResolvable,
891
+ )
892
+ : null;
893
+ // `sibling === null` when the widened pass resolved to a real file rather
894
+ // than to a widened answer. That file was reachable by the ordinary pass
895
+ // too, which declined it, so the widened pass has learned nothing and its
896
+ // result is dropped rather than preferred.
897
+ if (sibling === null) {
898
+ return {
899
+ resolved: null,
900
+ reason: `TypeScript cannot resolve '${specifier}' from '${sourceFile}'`,
901
+ };
902
+ }
903
+ resolvedFileName = sibling;
904
+ isExternalLibraryImport = declined.resolvedModule.isExternalLibraryImport ?? false;
905
+ }
906
+ const prefix = `${context.host.root}/`;
907
+ const file = resolvedFileName.startsWith(prefix) ? resolvedFileName.slice(prefix.length) : null;
908
+ // An installed package resolving INSIDE a project's own directory is still an
909
+ // installed package. `isExternalLibraryImport` is checked before ownership
910
+ // because the two disagree in exactly one shape, and it is the shape every
911
+ // publishable package in a workspace has: a package that declares its own
912
+ // `dependencies` gets its own `node_modules/` — pnpm puts
913
+ // `packages/<pkg>/node_modules/smol-toml` there — which sits under the
914
+ // project root, so ownership by longest-root-prefix attributes a vendored
915
+ // file to the project and the import reads as the project importing itself.
916
+ //
917
+ // Measured on this repository the day its own package declared `smol-toml`:
918
+ // three `noSelfCircularDependencies` violations telling the tool to rewrite
919
+ // `import "typescript"` as a relative path. The verdict was false, and worse
920
+ // than false — the rule that fired is the one that cannot be switched off by
921
+ // a `depConstraints` row, so a consumer hitting it would have no way to
922
+ // configure their way out.
923
+ //
924
+ // Ownership still decides everything else, which is why this is a narrow
925
+ // pre-empt rather than a replacement: an alias pointed at a loose root-level
926
+ // file has no `isExternalLibraryImport` flag and must still read as external,
927
+ // and a resolution into a sibling project must still name that project.
928
+ //
929
+ // The declined-extension pass above reaches here with the same two facts and
930
+ // is judged by the same two branches, deliberately: `pkg/styles.css` inside
931
+ // `node_modules` carries `isExternalLibraryImport` and stays an external
932
+ // import of `pkg`, while an alias landing on a project's `.vue` source
933
+ // carries neither and names the project that owns it.
934
+ if (isExternalLibraryImport) {
935
+ return {
936
+ resolved: { target: null, file, external: true, packageName: packageNameOf(specifier) },
937
+ reason: null,
938
+ };
939
+ }
940
+ const owner = file === null ? null : projectOwning(workspace.projects, file);
941
+ if (owner) {
942
+ return {
943
+ resolved: { target: owner.name, file, external: false, packageName: null },
944
+ reason: null,
945
+ };
946
+ }
947
+ return {
948
+ resolved: { target: null, file, external: true, packageName: packageNameOf(specifier) },
949
+ reason: null,
950
+ };
951
+ }
952
+
953
+ /**
954
+ * Analyzes one TypeScript/JavaScript source.
955
+ *
956
+ * Never throws: a malformed file yields what TypeScript could parse plus a
957
+ * failure per syntax error, and an unexpected error anywhere yields a
958
+ * file-level failure. One bad file must not blank a run (`contract.md`).
959
+ *
960
+ * @param {{ sourceFile: string, text: string, workspace: object, lang?: string }} request
961
+ * `lang` is the `<script lang>` of a Vue block; omitted for a real file,
962
+ * whose extension decides.
963
+ * @returns {{ imports: object[], failures: object[] }}
964
+ */
965
+ export function analyzeTypeScript({ sourceFile, text, workspace, lang }) {
966
+ const result = emptyResult();
967
+ try {
968
+ const context = contextFor(workspace);
969
+ if (context.configFailure) result.failures.push(fileFailure(sourceFile, context.configFailure));
970
+
971
+ const parsed = ts.createSourceFile(
972
+ `${workspace.root}/${sourceFile}`,
973
+ text,
974
+ ts.ScriptTarget.Latest,
975
+ false,
976
+ scriptKindFor(sourceFile, lang),
977
+ );
978
+ result.failures.push(...parseFailures(parsed, sourceFile));
979
+
980
+ for (const site of importSitesIn(parsed)) {
981
+ const { line, character } = parsed.getLineAndCharacterOfPosition(site.offset);
982
+ // Every call site carries its own `callee`. A declaration reaches the
983
+ // fallback only under parser recovery — a non-literal module specifier is
984
+ // a grammar error there — and `import x = require(y)` is the form that
985
+ // makes `require` the right word for it.
986
+ const callee = site.callee ?? (site.kind === "dynamic" ? "import" : "require");
987
+ const { resolved, reason } = site.literal
988
+ ? resolveSpecifier(site.specifier, sourceFile, workspace)
989
+ : {
990
+ resolved: null,
991
+ reason:
992
+ `'${callee}(${site.specifier})' has a non-literal argument, ` +
993
+ `so its target is not knowable statically`,
994
+ };
995
+ result.imports.push({
996
+ sourceFile,
997
+ line: line + 1,
998
+ column: character + 1,
999
+ specifier: site.specifier,
1000
+ kind: site.kind,
1001
+ spelling: specifierSpelling(site.specifier),
1002
+ resolved,
1003
+ });
1004
+ // A LITERAL specifier that failed to resolve is a hole — but only when it
1005
+ // names a project this workspace DECLARES. The resolver was asked a
1006
+ // concrete question about a workspace-internal dependency and could not
1007
+ // answer, so the edge that import would have carried (or the violation it
1008
+ // would have avoided) is missing and the file's boundary verdict is
1009
+ // incomplete — the same "could not look" shape an unreadable file takes
1010
+ // (`unchecked`, exit 3). A literal package import that names NO declared
1011
+ // project (`vitest`, `@nx/eslint-plugin`, an uninstalled third-party
1012
+ // package) is legitimate: a workspace with packages is a normal state,
1013
+ // and failing the whole run on every unresolved package import would
1014
+ // block merges over dependencies nobody crossed. Those stay positioned
1015
+ // site failures — the "blind spot" the contract documents as legitimately
1016
+ // permanent (`report/text.mjs`'s `formatFailures` is where the report
1017
+ // explains the two, and `cli.mjs` counts `unchecked` by
1018
+ // `failure.line === null`). A NON-LITERAL argument keeps its line/column
1019
+ // for the same reason: it is genuinely not statically knowable.
1020
+ if (reason) {
1021
+ result.failures.push(
1022
+ site.literal && namesDeclaredProject(site.specifier, workspace)
1023
+ ? fileFailure(sourceFile, reason)
1024
+ : { sourceFile, line: line + 1, column: character + 1, reason },
1025
+ );
1026
+ }
1027
+ }
1028
+ } catch (cause) {
1029
+ result.failures.push(
1030
+ fileFailure(sourceFile, `TypeScript analysis failed: ${cause?.message ?? cause}`),
1031
+ );
1032
+ }
1033
+ return result;
1034
+ }