@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.
- package/LICENSE +202 -0
- package/README.md +262 -0
- package/cli.mjs +2792 -0
- package/index.mjs +85 -0
- package/lsp.mjs +81 -0
- package/nx.mjs +24 -0
- package/package.json +81 -0
- package/presets/clean-architecture.json +78 -0
- package/presets/ddd-bounded-contexts.json +88 -0
- package/presets/hexagonal.json +68 -0
- package/presets/layered.json +92 -0
- package/presets/modular-monolith.json +85 -0
- package/presets/vertical-slice.json +68 -0
- package/src/analysis/analyze.mjs +218 -0
- package/src/analysis/contract.md +259 -0
- package/src/analysis/go.mjs +414 -0
- package/src/analysis/manifest-util.mjs +68 -0
- package/src/analysis/python.mjs +1266 -0
- package/src/analysis/registry.mjs +74 -0
- package/src/analysis/rust.mjs +674 -0
- package/src/analysis/source-util.mjs +230 -0
- package/src/analysis/typescript.mjs +1034 -0
- package/src/analysis/vue.mjs +156 -0
- package/src/architecture-intent/intent-fingerprint.mjs +29 -0
- package/src/architecture-intent/judge.mjs +539 -0
- package/src/architecture-intent/model.mjs +703 -0
- package/src/architecture-intent/selectors.mjs +170 -0
- package/src/canonical.mjs +48 -0
- package/src/commands/README.md +266 -0
- package/src/commands/adr.mjs +248 -0
- package/src/commands/check.mjs +989 -0
- package/src/commands/context-command.mjs +212 -0
- package/src/commands/context.mjs +790 -0
- package/src/commands/custom-rules.mjs +428 -0
- package/src/commands/debt.mjs +218 -0
- package/src/commands/diff.mjs +523 -0
- package/src/commands/discover.mjs +159 -0
- package/src/commands/drift.mjs +473 -0
- package/src/commands/edge-constraints.mjs +355 -0
- package/src/commands/explain.mjs +359 -0
- package/src/commands/fitness.mjs +226 -0
- package/src/commands/graph.mjs +297 -0
- package/src/commands/health.mjs +213 -0
- package/src/commands/history.mjs +614 -0
- package/src/commands/impact.mjs +226 -0
- package/src/commands/plan-context-command.mjs +496 -0
- package/src/commands/policy.mjs +138 -0
- package/src/commands/provenance-command.mjs +352 -0
- package/src/commands/provenance.mjs +159 -0
- package/src/commands/reconcile.mjs +219 -0
- package/src/commands/report.mjs +553 -0
- package/src/commands/snapshot-meta.mjs +107 -0
- package/src/commands/waivers.mjs +240 -0
- package/src/config.mjs +1308 -0
- package/src/containment.mjs +234 -0
- package/src/custom-rules/evidence.mjs +340 -0
- package/src/custom-rules/host.mjs +1023 -0
- package/src/custom-rules/values.mjs +43 -0
- package/src/entry-point.mjs +55 -0
- package/src/errors.mjs +36 -0
- package/src/eslint-config.mjs +542 -0
- package/src/go-work.mjs +394 -0
- package/src/governance/adr-registry.mjs +539 -0
- package/src/governance/clock.mjs +69 -0
- package/src/governance/debt-ledger.mjs +274 -0
- package/src/governance/discovery-proposal.mjs +423 -0
- package/src/governance/fitness-registry.mjs +504 -0
- package/src/governance/fitness-rules.mjs +668 -0
- package/src/governance/metrics.mjs +392 -0
- package/src/governance/preset-fingerprints.json +16 -0
- package/src/governance/profile-registry.mjs +366 -0
- package/src/governance/provenance-record.mjs +177 -0
- package/src/governance/reconcile-candidates.mjs +301 -0
- package/src/governance/reconcile-score.mjs +503 -0
- package/src/governance/row-schema.mjs +208 -0
- package/src/governance/verdict.mjs +127 -0
- package/src/governance/waiver.mjs +105 -0
- package/src/graph/create-dependencies.mjs +96 -0
- package/src/intent/intent-manifest.json +347 -0
- package/src/intent/mask-non-code.mjs +640 -0
- package/src/lsp/boundary-config.mjs +225 -0
- package/src/lsp/diagnose.mjs +202 -0
- package/src/lsp/diagnostics.mjs +241 -0
- package/src/lsp/protocol.mjs +215 -0
- package/src/lsp/server.mjs +922 -0
- package/src/lsp/workspace-index.mjs +891 -0
- package/src/nx-json.mjs +95 -0
- package/src/options.mjs +611 -0
- package/src/process.mjs +91 -0
- package/src/providers/moon.mjs +733 -0
- package/src/providers/native/README.md +204 -0
- package/src/providers/native/coverage.mjs +74 -0
- package/src/providers/native/differential.fixtures.mjs +1277 -0
- package/src/providers/native/discover.mjs +431 -0
- package/src/providers/native/graph.mjs +234 -0
- package/src/providers/native/index.mjs +152 -0
- package/src/providers/native/model.mjs +755 -0
- package/src/providers/nx.mjs +178 -0
- package/src/report/README.md +89 -0
- package/src/report/adr-text.mjs +129 -0
- package/src/report/context-text.mjs +109 -0
- package/src/report/debt-text.mjs +105 -0
- package/src/report/diff-text.mjs +219 -0
- package/src/report/discover-text.mjs +186 -0
- package/src/report/drift-text.mjs +194 -0
- package/src/report/envelope-shape.mjs +161 -0
- package/src/report/evidence.mjs +157 -0
- package/src/report/explain-text.mjs +159 -0
- package/src/report/graph-text.mjs +116 -0
- package/src/report/health-text.mjs +123 -0
- package/src/report/history-text.mjs +204 -0
- package/src/report/impact-text.mjs +128 -0
- package/src/report/json.mjs +173 -0
- package/src/report/plan-context-text.mjs +159 -0
- package/src/report/provenance-text.mjs +78 -0
- package/src/report/reconcile-text.mjs +159 -0
- package/src/report/report-text.mjs +264 -0
- package/src/report/sarif.mjs +953 -0
- package/src/report/text.mjs +823 -0
- package/src/report/waivers-text.mjs +100 -0
- package/src/rules/README.md +123 -0
- package/src/rules/index.mjs +962 -0
- package/src/rules/match.mjs +1708 -0
- package/src/rules/messages.mjs +73 -0
- package/src/rules/reachability.mjs +224 -0
- package/src/rules/specifiers.mjs +300 -0
- package/src/rules/tags.mjs +238 -0
- package/src/rules/topology.mjs +333 -0
- package/src/tsconfig-paths.mjs +237 -0
- package/src/verdict.mjs +145 -0
- 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
|
+
}
|