ambit-ts 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +64 -0
- package/LICENSE +21 -0
- package/README.md +403 -0
- package/dist/checker/authority.d.ts +13 -0
- package/dist/checker/authority.js +87 -0
- package/dist/checker/backend/legacy-ts.d.ts +26 -0
- package/dist/checker/backend/legacy-ts.js +1936 -0
- package/dist/checker/config.d.ts +84 -0
- package/dist/checker/config.js +391 -0
- package/dist/checker/coverage.d.ts +78 -0
- package/dist/checker/coverage.js +84 -0
- package/dist/checker/diagnose.d.ts +89 -0
- package/dist/checker/diagnose.js +734 -0
- package/dist/checker/index.d.ts +8 -0
- package/dist/checker/index.js +8 -0
- package/dist/checker/init.d.ts +38 -0
- package/dist/checker/init.js +205 -0
- package/dist/checker/propagate.d.ts +69 -0
- package/dist/checker/propagate.js +259 -0
- package/dist/checker/summarize.d.ts +27 -0
- package/dist/checker/summarize.js +411 -0
- package/dist/cli/analyze.d.ts +33 -0
- package/dist/cli/analyze.js +98 -0
- package/dist/cli/approvals.d.ts +28 -0
- package/dist/cli/approvals.js +55 -0
- package/dist/cli/diff.d.ts +66 -0
- package/dist/cli/diff.js +235 -0
- package/dist/cli/github.d.ts +33 -0
- package/dist/cli/github.js +41 -0
- package/dist/cli/main.d.ts +8 -0
- package/dist/cli/main.js +385 -0
- package/dist/cli/worktree.d.ts +75 -0
- package/dist/cli/worktree.js +154 -0
- package/dist/config.d.ts +12 -0
- package/dist/config.js +10 -0
- package/dist/core/approvals.d.ts +82 -0
- package/dist/core/approvals.js +0 -0
- package/dist/core/authority-diff.d.ts +98 -0
- package/dist/core/authority-diff.js +209 -0
- package/dist/core/authority.d.ts +109 -0
- package/dist/core/authority.js +50 -0
- package/dist/core/backend.d.ts +355 -0
- package/dist/core/backend.js +1 -0
- package/dist/core/budget.d.ts +61 -0
- package/dist/core/budget.js +95 -0
- package/dist/core/capability.d.ts +53 -0
- package/dist/core/capability.js +117 -0
- package/dist/core/config.d.ts +59 -0
- package/dist/core/config.js +10 -0
- package/dist/core/diagnostic.d.ts +126 -0
- package/dist/core/diagnostic.js +13 -0
- package/dist/core/effects.d.ts +39 -0
- package/dist/core/effects.js +72 -0
- package/dist/core/index.d.ts +13 -0
- package/dist/core/index.js +13 -0
- package/dist/core/location.d.ts +15 -0
- package/dist/core/location.js +1 -0
- package/dist/core/sql.d.ts +22 -0
- package/dist/core/sql.js +38 -0
- package/dist/core/summary.d.ts +240 -0
- package/dist/core/summary.js +8 -0
- package/dist/core/symbol-id.d.ts +25 -0
- package/dist/core/symbol-id.js +23 -0
- package/dist/index.d.ts +14 -0
- package/dist/index.js +14 -0
- package/dist/runtime/child-process.d.ts +29 -0
- package/dist/runtime/child-process.js +124 -0
- package/dist/runtime/context.d.ts +37 -0
- package/dist/runtime/context.js +8 -0
- package/dist/runtime/enforce.d.ts +52 -0
- package/dist/runtime/enforce.js +95 -0
- package/dist/runtime/fs.d.ts +46 -0
- package/dist/runtime/fs.js +188 -0
- package/dist/runtime/hono.d.ts +55 -0
- package/dist/runtime/hono.js +68 -0
- package/dist/runtime/index.d.ts +71 -0
- package/dist/runtime/index.js +126 -0
- package/dist/runtime/next.d.ts +95 -0
- package/dist/runtime/next.js +60 -0
- package/dist/runtime/pg.d.ts +48 -0
- package/dist/runtime/pg.js +122 -0
- package/dist/stubs/constructors.d.ts +34 -0
- package/dist/stubs/constructors.js +111 -0
- package/dist/stubs/data-clients.d.ts +9 -0
- package/dist/stubs/data-clients.js +109 -0
- package/dist/stubs/http-capabilities.d.ts +15 -0
- package/dist/stubs/http-capabilities.js +70 -0
- package/dist/stubs/mutating-builtins.d.ts +1 -0
- package/dist/stubs/mutating-builtins.js +48 -0
- package/dist/stubs/node-builtins.d.ts +2 -0
- package/dist/stubs/node-builtins.js +77 -0
- package/dist/stubs/pure-builtins.d.ts +1 -0
- package/dist/stubs/pure-builtins.js +89 -0
- package/docs/diagnostics/README.md +519 -0
- package/docs/limitations.md +712 -0
- package/package.json +89 -0
|
@@ -0,0 +1,1936 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import ts from "typescript";
|
|
4
|
+
import { isOnExceed, symbolId } from "../../core/index.js";
|
|
5
|
+
import { constructorStubKey } from "../../stubs/constructors.js";
|
|
6
|
+
import { isMutatingBuiltin } from "../../stubs/mutating-builtins.js";
|
|
7
|
+
/**
|
|
8
|
+
* `TsBackend` implementation on the TypeScript Compiler API (DESIGN.md §3.4).
|
|
9
|
+
*
|
|
10
|
+
* **This is the adopted backend**, not a placeholder. M0.5's comparison ran and
|
|
11
|
+
* chose it — DESIGN.md §3.5 and `docs/adr/0001-analysis-backend.md`, with the measurements
|
|
12
|
+
* in `docs/status.md`. The native TypeScript 7 engine (Go) was faster on every
|
|
13
|
+
* corpus and was still not adopted: its API is published entirely under
|
|
14
|
+
* `unstable/`, it answers from a stale snapshot unless told which files
|
|
15
|
+
* changed, and none of its speed was needed to meet a threshold. §3.5 also
|
|
16
|
+
* records what would reopen the decision; changing the default now requires an
|
|
17
|
+
* RFC (§9).
|
|
18
|
+
*
|
|
19
|
+
* The name `typescript-legacy` is the engine id in diagnostics and predates
|
|
20
|
+
* that decision. It distinguishes the JavaScript implementation from the Go
|
|
21
|
+
* one; it does not mean unmaintained. The version tracks the JS line's newest
|
|
22
|
+
* stable release (6.0.3), by the rule in AGENTS.md.
|
|
23
|
+
*
|
|
24
|
+
* The separation this file sits behind is unchanged and still the point: this
|
|
25
|
+
* is the ONLY file allowed to import `typescript`, and no `ts.Node`,
|
|
26
|
+
* `ts.Symbol`, or `ts.Type` may be returned from `extractProject` — see
|
|
27
|
+
* `src/core/backend.ts`. Adoption makes the boundary more useful, not less: it
|
|
28
|
+
* is what will let §3.5's review happen without touching the contract layer.
|
|
29
|
+
*/
|
|
30
|
+
export const legacyTsBackend = {
|
|
31
|
+
name: "typescript-legacy",
|
|
32
|
+
version: ts.version,
|
|
33
|
+
extractProject,
|
|
34
|
+
};
|
|
35
|
+
/** @effects fs_read */
|
|
36
|
+
async function extractProject(rootDir) {
|
|
37
|
+
const absoluteRoot = path.resolve(rootDir);
|
|
38
|
+
// A missing/non-directory target must fail loudly, not silently produce
|
|
39
|
+
// zero files (DESIGN.md §3.4: "Do not convert a failure to start, an
|
|
40
|
+
// unsupported setting, or an analysis failure into 'no violations'").
|
|
41
|
+
// Without this check, ts.findConfigFile still
|
|
42
|
+
// walks upward from a nonexistent path and can find an unrelated ancestor
|
|
43
|
+
// tsconfig.json, silently analyzing the wrong (or no) files.
|
|
44
|
+
if (!fs.existsSync(absoluteRoot) || !fs.statSync(absoluteRoot).isDirectory()) {
|
|
45
|
+
throw new Error(`project root not found or not a directory: ${absoluteRoot}`);
|
|
46
|
+
}
|
|
47
|
+
const { rootNames, options } = loadProjectConfig(absoluteRoot);
|
|
48
|
+
const program = ts.createProgram({ rootNames, options });
|
|
49
|
+
const checker = program.getTypeChecker();
|
|
50
|
+
// Pass 1: find every function/method declaration under the project root
|
|
51
|
+
// and assign it a stable SymbolId, so pass 2 can resolve calls between
|
|
52
|
+
// them regardless of which file declares which. Each file's declarations
|
|
53
|
+
// are kept (not just indexed into declaredNodeToId) so pass 2 can reuse
|
|
54
|
+
// them instead of walking the file a second time.
|
|
55
|
+
const declaredNodeToId = new Map();
|
|
56
|
+
const sourceFiles = program
|
|
57
|
+
.getSourceFiles()
|
|
58
|
+
.filter((sf) => !sf.isDeclarationFile && isUnderRoot(sf.fileName, absoluteRoot));
|
|
59
|
+
const declarationsByFile = new Map();
|
|
60
|
+
for (const sourceFile of sourceFiles) {
|
|
61
|
+
const declarations = collectFunctionLikeDeclarations(sourceFile);
|
|
62
|
+
declarationsByFile.set(sourceFile, declarations);
|
|
63
|
+
for (const [node, declPath] of declarations) {
|
|
64
|
+
declaredNodeToId.set(node, symbolId(relativePath(absoluteRoot, sourceFile), declPath));
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
// Pass 2: extract each function's JSDoc and calls, resolving callees
|
|
68
|
+
// against the map built in pass 1; and tally every function-like node this
|
|
69
|
+
// slice saw but did not extract (`skippedFunctions` — DESIGN.md §4.3).
|
|
70
|
+
// Reuses pass 1's declarationsByFile instead of re-walking each file.
|
|
71
|
+
const files = [];
|
|
72
|
+
const skippedFunctions = new Map();
|
|
73
|
+
const uncarriedContracts = [];
|
|
74
|
+
for (const [sourceFile, declarations] of declarationsByFile) {
|
|
75
|
+
const functions = [];
|
|
76
|
+
for (const [node, declPath] of declarations) {
|
|
77
|
+
const id = symbolId(relativePath(absoluteRoot, sourceFile), declPath);
|
|
78
|
+
const location = locationOf(absoluteRoot, sourceFile, nameOrNode(node));
|
|
79
|
+
// An accessor / anonymous default export propagates like any other
|
|
80
|
+
// function but does not adopt a contract comment (DESIGN.md §4.1 (a)).
|
|
81
|
+
// Its JSDoc is deliberately not read — and a contract written there is
|
|
82
|
+
// reported as AMB-E003 rather than dropped, exactly as it was before
|
|
83
|
+
// the declaration became indexable.
|
|
84
|
+
const configOnly = configOnlyPath(declPath);
|
|
85
|
+
if (configOnly) {
|
|
86
|
+
const jsDoc = extractJsDoc(node, absoluteRoot);
|
|
87
|
+
for (const tag of CONTRACT_TAGS) {
|
|
88
|
+
const raw = jsDoc?.tags.get(tag);
|
|
89
|
+
if (raw === undefined)
|
|
90
|
+
continue;
|
|
91
|
+
uncarriedContracts.push({
|
|
92
|
+
location,
|
|
93
|
+
kind: ts.isGetAccessor(node) || ts.isSetAccessor(node)
|
|
94
|
+
? "getter-setter"
|
|
95
|
+
: "anonymous-default-export",
|
|
96
|
+
tag,
|
|
97
|
+
raw,
|
|
98
|
+
configKey: id,
|
|
99
|
+
});
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
functions.push({
|
|
103
|
+
id,
|
|
104
|
+
location,
|
|
105
|
+
declarationStart: declarationStartOf(absoluteRoot, sourceFile, node),
|
|
106
|
+
...jsDocRangeOf(absoluteRoot, sourceFile, node),
|
|
107
|
+
...(ts.isClassDeclaration(node) ? { implicitConstructor: true } : {}),
|
|
108
|
+
...(configOnly ? { configOnly: true } : {}),
|
|
109
|
+
jsDoc: configOnly ? undefined : extractJsDoc(node, absoluteRoot),
|
|
110
|
+
calls: collectCalls(node, sourceFile, program, checker, declaredNodeToId, absoluteRoot),
|
|
111
|
+
});
|
|
112
|
+
}
|
|
113
|
+
const runtimeWrappers = collectRuntimeWrappers(sourceFile, checker, declaredNodeToId, absoluteRoot);
|
|
114
|
+
if (functions.length > 0 || runtimeWrappers.length > 0) {
|
|
115
|
+
files.push({
|
|
116
|
+
filePath: relativePath(absoluteRoot, sourceFile),
|
|
117
|
+
functions,
|
|
118
|
+
runtimeWrappers,
|
|
119
|
+
});
|
|
120
|
+
}
|
|
121
|
+
const skipped = collectSkippedFunctions(sourceFile, declaredNodeToId, absoluteRoot);
|
|
122
|
+
for (const kind of skipped.kinds) {
|
|
123
|
+
skippedFunctions.set(kind, (skippedFunctions.get(kind) ?? 0) + 1);
|
|
124
|
+
}
|
|
125
|
+
uncarriedContracts.push(...skipped.uncarried);
|
|
126
|
+
}
|
|
127
|
+
return { files, skippedFunctions, uncarriedContracts };
|
|
128
|
+
}
|
|
129
|
+
// ---- project loading --------------------------------------------------
|
|
130
|
+
/** @effects fs_read */
|
|
131
|
+
function loadProjectConfig(absoluteRoot) {
|
|
132
|
+
const configPath = ts.findConfigFile(absoluteRoot, ts.sys.fileExists, "tsconfig.json");
|
|
133
|
+
if (configPath) {
|
|
134
|
+
const configFile = ts.readConfigFile(configPath, ts.sys.readFile);
|
|
135
|
+
// A malformed tsconfig.json (unparseable JSON) must fail loudly, not
|
|
136
|
+
// silently fall back to an empty `config` object — that would produce
|
|
137
|
+
// 0 root files and read as "checked, no violations" (DESIGN.md §3.4).
|
|
138
|
+
if (configFile.error) {
|
|
139
|
+
throw new Error(`failed to read ${configPath}: ${ts.flattenDiagnosticMessageText(configFile.error.messageText, "\n")}`);
|
|
140
|
+
}
|
|
141
|
+
const parsed = ts.parseJsonConfigFileContent(configFile.config, ts.sys, path.dirname(configPath));
|
|
142
|
+
const errors = parsed.errors.filter((d) => d.category === ts.DiagnosticCategory.Error);
|
|
143
|
+
if (errors.length > 0) {
|
|
144
|
+
throw new Error(`invalid ${configPath}: ${errors
|
|
145
|
+
.map((d) => ts.flattenDiagnosticMessageText(d.messageText, "\n"))
|
|
146
|
+
.join("; ")}`);
|
|
147
|
+
}
|
|
148
|
+
return { rootNames: parsed.fileNames, options: parsed.options };
|
|
149
|
+
}
|
|
150
|
+
// No tsconfig.json found: fall back to every .ts file under the root with
|
|
151
|
+
// a reasonable default (DESIGN.md §3.4 — analysis must not silently
|
|
152
|
+
// degrade to "no violations" just because a config is missing).
|
|
153
|
+
const rootNames = collectTsFiles(absoluteRoot);
|
|
154
|
+
const options = {
|
|
155
|
+
target: ts.ScriptTarget.ES2023,
|
|
156
|
+
module: ts.ModuleKind.NodeNext,
|
|
157
|
+
moduleResolution: ts.ModuleResolutionKind.NodeNext,
|
|
158
|
+
strict: true,
|
|
159
|
+
skipLibCheck: true,
|
|
160
|
+
};
|
|
161
|
+
return { rootNames, options };
|
|
162
|
+
}
|
|
163
|
+
/** @effects fs_read */
|
|
164
|
+
function collectTsFiles(dir) {
|
|
165
|
+
const results = [];
|
|
166
|
+
for (const entry of ts.sys.readDirectory(dir, [".ts", ".tsx"], ["node_modules"])) {
|
|
167
|
+
results.push(entry);
|
|
168
|
+
}
|
|
169
|
+
return results;
|
|
170
|
+
}
|
|
171
|
+
function isUnderRoot(fileName, absoluteRoot) {
|
|
172
|
+
const rel = path.relative(absoluteRoot, fileName);
|
|
173
|
+
return !rel.startsWith("..") && !path.isAbsolute(rel) && !fileName.includes("node_modules");
|
|
174
|
+
}
|
|
175
|
+
function relativePath(absoluteRoot, sourceFile) {
|
|
176
|
+
return path.relative(absoluteRoot, sourceFile.fileName);
|
|
177
|
+
}
|
|
178
|
+
/**
|
|
179
|
+
* Walk a source file collecting function declarations, class methods,
|
|
180
|
+
* variable-declared function/arrow expressions, and the identifier-named
|
|
181
|
+
* members of a module-scope `const` object literal — each paired with its
|
|
182
|
+
* "."-joined declaration path (DESIGN.md §5.3: "A symbol ID's declaration
|
|
183
|
+
* path is joined with `"."`"). Anonymous functions and functions nested
|
|
184
|
+
* inside another function's body are not extracted — nested closures' calls
|
|
185
|
+
* are still walked and attributed to their enclosing named declaration.
|
|
186
|
+
*/
|
|
187
|
+
function collectFunctionLikeDeclarations(sourceFile) {
|
|
188
|
+
const results = [];
|
|
189
|
+
function visitTop(node, containerPath) {
|
|
190
|
+
// `export default function () {}` / `export default () => {}`: no name,
|
|
191
|
+
// but exactly one such declaration can exist per file, so `#default` is
|
|
192
|
+
// as stable a path as any identifier (DESIGN.md §4.1 (a)). Indexed at the
|
|
193
|
+
// top level only — a default export is not nestable.
|
|
194
|
+
if (containerPath.length === 0) {
|
|
195
|
+
const anonymousDefault = anonymousDefaultExport(node);
|
|
196
|
+
if (anonymousDefault) {
|
|
197
|
+
results.push([anonymousDefault, [DEFAULT_EXPORT_PATH_SEGMENT]]);
|
|
198
|
+
return;
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
// A function declaration with no body declares a signature, not code: an
|
|
202
|
+
// overload signature, or an ambient `declare function` written in a `.ts`
|
|
203
|
+
// file. Indexing it would give the overload set's several declarations one
|
|
204
|
+
// shared declaration path — and `ExtractedFile.functions` requires ids to
|
|
205
|
+
// be unique, because `propagate`'s fixed point does not terminate without
|
|
206
|
+
// it. It is counted as `bodyless-declaration` instead, and a contract
|
|
207
|
+
// written on it is reported (AMB-E003) rather than silently attributed to
|
|
208
|
+
// a declaration Ambit does not model. Same rule the constructor branch
|
|
209
|
+
// below already applies.
|
|
210
|
+
if (ts.isFunctionDeclaration(node) && node.name) {
|
|
211
|
+
if (!node.body)
|
|
212
|
+
return;
|
|
213
|
+
const declPath = [...containerPath, node.name.text];
|
|
214
|
+
results.push([node, declPath]);
|
|
215
|
+
return; // do not descend into nested function declarations separately
|
|
216
|
+
}
|
|
217
|
+
if (ts.isClassDeclaration(node) && node.name) {
|
|
218
|
+
const classPath = [...containerPath, node.name.text];
|
|
219
|
+
for (const member of node.members) {
|
|
220
|
+
// `member.body` for the same reason the function-declaration branch
|
|
221
|
+
// above requires it: a method's overload signatures and an `abstract`
|
|
222
|
+
// member declare a signature and no code.
|
|
223
|
+
if (ts.isMethodDeclaration(member) &&
|
|
224
|
+
member.body &&
|
|
225
|
+
member.name &&
|
|
226
|
+
ts.isIdentifier(member.name)) {
|
|
227
|
+
results.push([member, [...classPath, member.name.text]]);
|
|
228
|
+
}
|
|
229
|
+
// `handle = async (req) => { … }` is a method written as a property.
|
|
230
|
+
// It must be indexed in its own right, or its body would be
|
|
231
|
+
// attributed to the constructor — construction creates the closure,
|
|
232
|
+
// it does not run it, and a class of arrow-shaped request handlers
|
|
233
|
+
// would make every `new Controller()` look like it hit the network.
|
|
234
|
+
if (isFunctionValuedProperty(member)) {
|
|
235
|
+
results.push([member, [...classPath, member.name.text]]);
|
|
236
|
+
}
|
|
237
|
+
// An accessor's body runs like any other method's, so it propagates
|
|
238
|
+
// like one; it is indexed under `get x` / `set x` because `get` and
|
|
239
|
+
// `set` share a name and a plain `x` could not tell them apart
|
|
240
|
+
// (DESIGN.md §4.1 (a)). A JSDoc tag written on it is still inert —
|
|
241
|
+
// see `configOnlyPath`.
|
|
242
|
+
if ((ts.isGetAccessor(member) || ts.isSetAccessor(member)) &&
|
|
243
|
+
member.name &&
|
|
244
|
+
ts.isIdentifier(member.name)) {
|
|
245
|
+
results.push([member, [...classPath, accessorSegment(member, member.name.text)]]);
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
// `new C(...)` has to have somewhere to propagate *from*, or a
|
|
249
|
+
// constructor that opens a socket is invisible rather than `unknown`
|
|
250
|
+
// (DESIGN.md §3.4). The explicit constructor is indexed when the class
|
|
251
|
+
// writes one; otherwise the class node stands in for the implicit one,
|
|
252
|
+
// which still runs property initializers and the base constructor.
|
|
253
|
+
// Overload signatures carry no body, so the implementation is the one
|
|
254
|
+
// indexed.
|
|
255
|
+
const explicitConstructor = node.members.find((member) => ts.isConstructorDeclaration(member) && member.body !== undefined);
|
|
256
|
+
results.push([explicitConstructor ?? node, [...classPath, CONSTRUCTOR_PATH_SEGMENT]]);
|
|
257
|
+
return;
|
|
258
|
+
}
|
|
259
|
+
if (ts.isVariableStatement(node) ||
|
|
260
|
+
ts.isModuleBlock(node) ||
|
|
261
|
+
ts.isModuleDeclaration(node) ||
|
|
262
|
+
node === sourceFile) {
|
|
263
|
+
ts.forEachChild(node, (child) => visitTop(child, containerPath));
|
|
264
|
+
return;
|
|
265
|
+
}
|
|
266
|
+
if (ts.isVariableDeclarationList(node)) {
|
|
267
|
+
for (const decl of node.declarations)
|
|
268
|
+
visitTop(decl, containerPath);
|
|
269
|
+
return;
|
|
270
|
+
}
|
|
271
|
+
if (ts.isVariableDeclaration(node) &&
|
|
272
|
+
ts.isIdentifier(node.name) &&
|
|
273
|
+
node.initializer &&
|
|
274
|
+
(ts.isFunctionExpression(node.initializer) || ts.isArrowFunction(node.initializer))) {
|
|
275
|
+
results.push([node, [...containerPath, node.name.text]]);
|
|
276
|
+
return;
|
|
277
|
+
}
|
|
278
|
+
// `const handlers = { read() {…} }` / `{ read: () => {…} }`: the member's
|
|
279
|
+
// body lives in the literal, so it gets its own id under the existing
|
|
280
|
+
// declaration-path notation (`file.ts#handlers.read`), exactly as a class
|
|
281
|
+
// method does. The container `handlers` is never itself indexed — its
|
|
282
|
+
// initializer is an object literal, not a function — so no extracted
|
|
283
|
+
// function's body contains these members and nothing is walked twice.
|
|
284
|
+
if (ts.isVariableDeclaration(node) && ts.isIdentifier(node.name)) {
|
|
285
|
+
const literal = indexableObjectLiteral(node);
|
|
286
|
+
if (!literal)
|
|
287
|
+
return;
|
|
288
|
+
const objectPath = [...containerPath, node.name.text];
|
|
289
|
+
for (const member of literal.properties) {
|
|
290
|
+
// Identifier names only, mirroring the class-method rule above. A
|
|
291
|
+
// computed, string, or numeric name has no spelling that survives
|
|
292
|
+
// `symbolId`'s "."-join — `{ "a.b": … }` would be indistinguishable
|
|
293
|
+
// from nesting — and DESIGN.md §5.3 requires a stable path that does
|
|
294
|
+
// not lean on anything compiler-internal to disambiguate. Those stay
|
|
295
|
+
// counted as `object-literal-method`.
|
|
296
|
+
if (!member.name || !ts.isIdentifier(member.name))
|
|
297
|
+
continue;
|
|
298
|
+
if (ts.isGetAccessor(member) || ts.isSetAccessor(member)) {
|
|
299
|
+
results.push([member, [...objectPath, accessorSegment(member, member.name.text)]]);
|
|
300
|
+
}
|
|
301
|
+
else if (ts.isMethodDeclaration(member)) {
|
|
302
|
+
results.push([member, [...objectPath, member.name.text]]);
|
|
303
|
+
}
|
|
304
|
+
else if (ts.isPropertyAssignment(member) &&
|
|
305
|
+
(ts.isFunctionExpression(member.initializer) || ts.isArrowFunction(member.initializer))) {
|
|
306
|
+
results.push([member, [...objectPath, member.name.text]]);
|
|
307
|
+
}
|
|
308
|
+
}
|
|
309
|
+
return;
|
|
310
|
+
}
|
|
311
|
+
}
|
|
312
|
+
ts.forEachChild(sourceFile, (child) => visitTop(child, []));
|
|
313
|
+
return results;
|
|
314
|
+
}
|
|
315
|
+
/**
|
|
316
|
+
* The object literal a `const` binds, when its members are safe to treat as
|
|
317
|
+
* the call targets they name. `undefined` for every other binding.
|
|
318
|
+
*
|
|
319
|
+
* Two conditions make the syntactic match a fact rather than a convenience:
|
|
320
|
+
*
|
|
321
|
+
* - **`const` only.** A `let`/`var` binding may hold a different object by the
|
|
322
|
+
* time the call runs, so the members written here would not be the ones
|
|
323
|
+
* called.
|
|
324
|
+
* - **No spread.** A spread can carry members this walk cannot enumerate, so
|
|
325
|
+
* any spread rejects the whole literal rather than trusting the members
|
|
326
|
+
* written beside it.
|
|
327
|
+
*
|
|
328
|
+
* This does not make resolution sound: `const` freezes the binding, not the
|
|
329
|
+
* properties, so `handlers.read = other` still defeats it. Resolving a class
|
|
330
|
+
* instance method already rests on the same assumption; this adds no new one.
|
|
331
|
+
*
|
|
332
|
+
* `satisfies` and `as const` wrap the literal without changing which object
|
|
333
|
+
* its members belong to, so they are unwrapped rather than rejected.
|
|
334
|
+
*/
|
|
335
|
+
function indexableObjectLiteral(declaration) {
|
|
336
|
+
if (!declaration.initializer)
|
|
337
|
+
return undefined;
|
|
338
|
+
if ((declaration.parent.flags & ts.NodeFlags.Const) === 0)
|
|
339
|
+
return undefined;
|
|
340
|
+
const literal = unwrapTypeOnlyExpression(declaration.initializer);
|
|
341
|
+
if (!ts.isObjectLiteralExpression(literal))
|
|
342
|
+
return undefined;
|
|
343
|
+
if (literal.properties.some(ts.isSpreadAssignment))
|
|
344
|
+
return undefined;
|
|
345
|
+
return literal;
|
|
346
|
+
}
|
|
347
|
+
/** Strips wrappers that assert a type without changing the runtime value. */
|
|
348
|
+
function unwrapTypeOnlyExpression(expression) {
|
|
349
|
+
let current = expression;
|
|
350
|
+
while (ts.isSatisfiesExpression(current) ||
|
|
351
|
+
ts.isAsExpression(current) ||
|
|
352
|
+
ts.isParenthesizedExpression(current)) {
|
|
353
|
+
current = current.expression;
|
|
354
|
+
}
|
|
355
|
+
return current;
|
|
356
|
+
}
|
|
357
|
+
/**
|
|
358
|
+
* True if `member` belongs to a literal `indexableObjectLiteral` accepts. The
|
|
359
|
+
* same rule has to gate resolution as gates indexing: TypeScript resolves
|
|
360
|
+
* `handlers.read` to the member's own declaration whether or not `handlers` is
|
|
361
|
+
* a `const`, so without this check the guards above would apply only to the
|
|
362
|
+
* receiver path and be bypassed by the direct one.
|
|
363
|
+
*/
|
|
364
|
+
function isInIndexableObjectLiteral(member) {
|
|
365
|
+
const literal = member.parent;
|
|
366
|
+
if (literal === undefined || !ts.isObjectLiteralExpression(literal))
|
|
367
|
+
return false;
|
|
368
|
+
let container = literal.parent;
|
|
369
|
+
while (ts.isSatisfiesExpression(container) ||
|
|
370
|
+
ts.isAsExpression(container) ||
|
|
371
|
+
ts.isParenthesizedExpression(container)) {
|
|
372
|
+
container = container.parent;
|
|
373
|
+
}
|
|
374
|
+
return ts.isVariableDeclaration(container) && indexableObjectLiteral(container) === literal;
|
|
375
|
+
}
|
|
376
|
+
/**
|
|
377
|
+
* `ts.isFunctionLikeDeclaration` (a real function-like node with a body, as
|
|
378
|
+
* opposed to a signature-only form like `MethodSignature` or
|
|
379
|
+
* `FunctionTypeNode`, which `ts.isFunctionLike` also matches) exists at
|
|
380
|
+
* runtime but is not declared in the public `typescript` .d.ts, so it's
|
|
381
|
+
* redefined locally against `ts.FunctionLikeDeclaration`'s public union.
|
|
382
|
+
*/
|
|
383
|
+
function isFunctionLikeNode(node) {
|
|
384
|
+
return (ts.isFunctionDeclaration(node) ||
|
|
385
|
+
ts.isFunctionExpression(node) ||
|
|
386
|
+
ts.isArrowFunction(node) ||
|
|
387
|
+
ts.isMethodDeclaration(node) ||
|
|
388
|
+
ts.isGetAccessor(node) ||
|
|
389
|
+
ts.isSetAccessor(node) ||
|
|
390
|
+
ts.isConstructorDeclaration(node));
|
|
391
|
+
}
|
|
392
|
+
/**
|
|
393
|
+
* Every function-like node in `sourceFile` that `collectFunctionLikeDeclarations`
|
|
394
|
+
* did not index, classified by kind (`SkippedFunctionKind`), plus any contract
|
|
395
|
+
* written on one of them. The count turns "silent skip" into a visible number
|
|
396
|
+
* (`ambit check --coverage`, DESIGN.md §4.3); the contracts turn a silently
|
|
397
|
+
* dropped declaration into `AMB-E003`.
|
|
398
|
+
*/
|
|
399
|
+
function collectSkippedFunctions(sourceFile, indexed, absoluteRoot) {
|
|
400
|
+
const kinds = [];
|
|
401
|
+
const uncarried = [];
|
|
402
|
+
function visit(node) {
|
|
403
|
+
if (isFunctionLikeNode(node) && !indexed.has(node) && !isIndexedInitializer(node, indexed)) {
|
|
404
|
+
const kind = classifySkipped(node);
|
|
405
|
+
kinds.push(kind);
|
|
406
|
+
// `isFunctionLikeNode` has already narrowed to a real function-like
|
|
407
|
+
// node; the local alias only widens it to the shape the JSDoc and
|
|
408
|
+
// location helpers take.
|
|
409
|
+
const decl = node;
|
|
410
|
+
const jsDoc = extractJsDoc(decl, absoluteRoot);
|
|
411
|
+
for (const tag of CONTRACT_TAGS) {
|
|
412
|
+
const raw = jsDoc?.tags.get(tag);
|
|
413
|
+
if (raw === undefined)
|
|
414
|
+
continue;
|
|
415
|
+
uncarried.push({
|
|
416
|
+
location: locationOf(absoluteRoot, sourceFile, nameOrNode(decl)),
|
|
417
|
+
kind,
|
|
418
|
+
tag,
|
|
419
|
+
raw,
|
|
420
|
+
});
|
|
421
|
+
}
|
|
422
|
+
}
|
|
423
|
+
// A contract written on a `class` is inert: the class's construction is
|
|
424
|
+
// indexed, but `extractJsDoc` refuses to read a class's own comment as
|
|
425
|
+
// its implicit constructor's contract. Reported for the same reason
|
|
426
|
+
// AMB-E003 reports every other inert declaration, and not counted in
|
|
427
|
+
// `skippedFunctions`, which counts function-like nodes.
|
|
428
|
+
if (ts.isClassDeclaration(node)) {
|
|
429
|
+
const tags = ts.getJSDocTags(node);
|
|
430
|
+
for (const tag of tags) {
|
|
431
|
+
if (!CONTRACT_TAGS.includes(tag.tagName.text))
|
|
432
|
+
continue;
|
|
433
|
+
uncarried.push({
|
|
434
|
+
location: locationOf(absoluteRoot, sourceFile, node.name ?? node),
|
|
435
|
+
kind: "class-declaration",
|
|
436
|
+
tag: tag.tagName.text,
|
|
437
|
+
raw: jsDocTagText(tag),
|
|
438
|
+
});
|
|
439
|
+
}
|
|
440
|
+
}
|
|
441
|
+
ts.forEachChild(node, visit);
|
|
442
|
+
}
|
|
443
|
+
ts.forEachChild(sourceFile, visit);
|
|
444
|
+
return { kinds, uncarried };
|
|
445
|
+
}
|
|
446
|
+
/**
|
|
447
|
+
* The JSDoc tags that declare a contract (DESIGN.md §4.1). Only `@effects` is
|
|
448
|
+
* enforced today, but a contract tag on a node that cannot carry one is dead
|
|
449
|
+
* whichever tag it is, so all four are reported.
|
|
450
|
+
*/
|
|
451
|
+
const CONTRACT_TAGS = ["effects", "capabilities", "budget", "entrypoint", "boundary"];
|
|
452
|
+
/**
|
|
453
|
+
* The declaration-path segment a class's construction is indexed under
|
|
454
|
+
* (`src/db.ts#Client.constructor`). `constructor` cannot collide with a
|
|
455
|
+
* method of the same name: `constructor(){}` in a class body *is* the
|
|
456
|
+
* constructor, and a method named `constructor` is not expressible.
|
|
457
|
+
*/
|
|
458
|
+
const CONSTRUCTOR_PATH_SEGMENT = "constructor";
|
|
459
|
+
/** The declaration-path segment an anonymous `export default` is indexed under (DESIGN.md §4.1 (a)). */
|
|
460
|
+
const DEFAULT_EXPORT_PATH_SEGMENT = "default";
|
|
461
|
+
/** `get total` / `set total` — the accessor's kind is part of the segment (DESIGN.md §4.1 (a)). */
|
|
462
|
+
function accessorSegment(node, name) {
|
|
463
|
+
return `${ts.isGetAccessor(node) ? "get" : "set"} ${name}`;
|
|
464
|
+
}
|
|
465
|
+
/**
|
|
466
|
+
* The function an `export default` with no name introduces, or `undefined`.
|
|
467
|
+
*
|
|
468
|
+
* Covers both spellings: `export default function () {}` (a nameless
|
|
469
|
+
* `FunctionDeclaration`) and `export default () => {}` / `export default
|
|
470
|
+
* function () {}` as an expression (an `ExportAssignment`). A *named* default
|
|
471
|
+
* export is not this case — it already has an identifier path.
|
|
472
|
+
*/
|
|
473
|
+
function anonymousDefaultExport(node) {
|
|
474
|
+
if (ts.isFunctionDeclaration(node) && !node.name && isDefaultExport(node))
|
|
475
|
+
return node;
|
|
476
|
+
if (ts.isExportAssignment(node) && !node.isExportEquals) {
|
|
477
|
+
const expression = node.expression;
|
|
478
|
+
if (ts.isArrowFunction(expression) || ts.isFunctionExpression(expression)) {
|
|
479
|
+
if (!ts.isFunctionExpression(expression) || !expression.name)
|
|
480
|
+
return expression;
|
|
481
|
+
}
|
|
482
|
+
}
|
|
483
|
+
return undefined;
|
|
484
|
+
}
|
|
485
|
+
/**
|
|
486
|
+
* True for the declaration paths only `ambit.config.ts` can name: an accessor
|
|
487
|
+
* and an anonymous default export (DESIGN.md §4.1 (a)).
|
|
488
|
+
*
|
|
489
|
+
* These nodes propagate like any other function, but a contract *comment* on
|
|
490
|
+
* them is not adopted — §4.1 (a) keeps the config namespace a superset of the
|
|
491
|
+
* JSDoc one, and §12 records the asymmetry that leaves. Derived from the path
|
|
492
|
+
* rather than tracked in a side table so that the rule has exactly one
|
|
493
|
+
* spelling.
|
|
494
|
+
*/
|
|
495
|
+
function configOnlyPath(declPath) {
|
|
496
|
+
if (declPath.length === 1 && declPath[0] === DEFAULT_EXPORT_PATH_SEGMENT)
|
|
497
|
+
return true;
|
|
498
|
+
const last = declPath[declPath.length - 1] ?? "";
|
|
499
|
+
return last.startsWith("get ") || last.startsWith("set ");
|
|
500
|
+
}
|
|
501
|
+
/**
|
|
502
|
+
* True if `node` is the function expression that *is* an indexed declaration's
|
|
503
|
+
* body. `const f = () => {}` and `{ read: () => {} }` index the enclosing
|
|
504
|
+
* `VariableDeclaration` / `PropertyAssignment`, not the arrow itself, so the
|
|
505
|
+
* arrow would otherwise be counted as skipped as well as extracted — a
|
|
506
|
+
* double-count that makes `--coverage` overstate what the analysis missed.
|
|
507
|
+
*/
|
|
508
|
+
function isIndexedInitializer(node, indexed) {
|
|
509
|
+
const parent = node.parent;
|
|
510
|
+
return (parent !== undefined &&
|
|
511
|
+
(ts.isVariableDeclaration(parent) ||
|
|
512
|
+
ts.isPropertyAssignment(parent) ||
|
|
513
|
+
ts.isPropertyDeclaration(parent)) &&
|
|
514
|
+
parent.initializer === node &&
|
|
515
|
+
indexed.has(parent));
|
|
516
|
+
}
|
|
517
|
+
function classifySkipped(node) {
|
|
518
|
+
// Checked before the shape-based kinds below: an overload signature is
|
|
519
|
+
// syntactically a plain function or method declaration, so nothing else
|
|
520
|
+
// here would distinguish it from one that was skipped for a different
|
|
521
|
+
// reason.
|
|
522
|
+
if ((ts.isFunctionDeclaration(node) ||
|
|
523
|
+
ts.isMethodDeclaration(node) ||
|
|
524
|
+
ts.isConstructorDeclaration(node)) &&
|
|
525
|
+
!node.body) {
|
|
526
|
+
return "bodyless-declaration";
|
|
527
|
+
}
|
|
528
|
+
if (ts.isGetAccessor(node) || ts.isSetAccessor(node))
|
|
529
|
+
return "getter-setter";
|
|
530
|
+
if (isObjectLiteralMethod(node))
|
|
531
|
+
return "object-literal-method";
|
|
532
|
+
if (isDefaultExport(node))
|
|
533
|
+
return "anonymous-default-export";
|
|
534
|
+
if (isCallbackArgument(node))
|
|
535
|
+
return "callback-argument";
|
|
536
|
+
if (isNestedInAnotherFunction(node))
|
|
537
|
+
return "nested-function";
|
|
538
|
+
return "other";
|
|
539
|
+
}
|
|
540
|
+
/**
|
|
541
|
+
* `{ foo() {} }` (a MethodDeclaration whose parent is the object literal
|
|
542
|
+
* directly) and `{ foo: () => 1 }` (an arrow/function expression assigned via
|
|
543
|
+
* a PropertyAssignment, whose parent is the assignment, not the object
|
|
544
|
+
* literal itself) are both object-literal methods in spirit; both must be
|
|
545
|
+
* recognized so the two forms are never classified differently.
|
|
546
|
+
*
|
|
547
|
+
* This runs only on members `collectFunctionLikeDeclarations` did not index,
|
|
548
|
+
* so the kind it feeds is deliberately narrower than its name: what reaches it
|
|
549
|
+
* are the members `indexableObjectLiteral` rules out.
|
|
550
|
+
*/
|
|
551
|
+
function isObjectLiteralMethod(node) {
|
|
552
|
+
if (node.parent && ts.isObjectLiteralExpression(node.parent))
|
|
553
|
+
return true;
|
|
554
|
+
const parent = node.parent;
|
|
555
|
+
return (parent !== undefined &&
|
|
556
|
+
ts.isPropertyAssignment(parent) &&
|
|
557
|
+
parent.initializer === node &&
|
|
558
|
+
ts.isObjectLiteralExpression(parent.parent));
|
|
559
|
+
}
|
|
560
|
+
function isDefaultExport(node) {
|
|
561
|
+
if (ts.isExportAssignment(node.parent) && !node.parent.isExportEquals)
|
|
562
|
+
return true;
|
|
563
|
+
if (ts.canHaveModifiers(node)) {
|
|
564
|
+
return (ts.getModifiers(node) ?? []).some((m) => m.kind === ts.SyntaxKind.DefaultKeyword);
|
|
565
|
+
}
|
|
566
|
+
return false;
|
|
567
|
+
}
|
|
568
|
+
function isCallbackArgument(node) {
|
|
569
|
+
const parent = node.parent;
|
|
570
|
+
if (!parent || !(ts.isCallExpression(parent) || ts.isNewExpression(parent)))
|
|
571
|
+
return false;
|
|
572
|
+
return parent.arguments?.includes(node) ?? false;
|
|
573
|
+
}
|
|
574
|
+
/** Walks up from `node` to the source file, stopping at the first enclosing function-like ancestor. */
|
|
575
|
+
function isNestedInAnotherFunction(node) {
|
|
576
|
+
let current = node.parent;
|
|
577
|
+
while (current && !ts.isSourceFile(current)) {
|
|
578
|
+
if (isFunctionLikeNode(current))
|
|
579
|
+
return true;
|
|
580
|
+
current = current.parent;
|
|
581
|
+
}
|
|
582
|
+
return false;
|
|
583
|
+
}
|
|
584
|
+
/**
|
|
585
|
+
* Where the declaration's own text starts, excluding leading JSDoc and other
|
|
586
|
+
* trivia — the insertion point for a new contract comment.
|
|
587
|
+
*
|
|
588
|
+
* A `const f = () => {}` indexes the `VariableDeclaration`, whose start is
|
|
589
|
+
* after the `const`; the statement is what a comment goes above.
|
|
590
|
+
*/
|
|
591
|
+
function declarationStartOf(absoluteRoot, sourceFile, decl) {
|
|
592
|
+
const node = ts.isVariableDeclaration(decl) ? (decl.parent.parent ?? decl) : decl;
|
|
593
|
+
const start = node.getStart(sourceFile, /* includeJsDocComment */ false);
|
|
594
|
+
const position = sourceFile.getLineAndCharacterOfPosition(start);
|
|
595
|
+
return {
|
|
596
|
+
file: relativePath(absoluteRoot, sourceFile),
|
|
597
|
+
line: position.line + 1,
|
|
598
|
+
col: position.character + 1,
|
|
599
|
+
endLine: position.line + 1,
|
|
600
|
+
endCol: position.character + 1,
|
|
601
|
+
};
|
|
602
|
+
}
|
|
603
|
+
/**
|
|
604
|
+
* The one JSDoc block attached to `decl`, if there is exactly one. Read from
|
|
605
|
+
* the comment nodes rather than from the tags, because the block a fix needs
|
|
606
|
+
* to add a tag to is usually one that has no tags yet.
|
|
607
|
+
*/
|
|
608
|
+
function jsDocRangeOf(absoluteRoot, sourceFile, decl) {
|
|
609
|
+
const target = ts.isVariableDeclaration(decl) ? (decl.parent.parent ?? decl) : decl;
|
|
610
|
+
const blocks = ts.getJSDocCommentsAndTags(target).filter(ts.isJSDoc);
|
|
611
|
+
// More than one block above the same declaration has no single right place
|
|
612
|
+
// to add to; the fix falls back to a new block of its own.
|
|
613
|
+
if (blocks.length !== 1)
|
|
614
|
+
return {};
|
|
615
|
+
const block = blocks[0];
|
|
616
|
+
if (!block || block.getSourceFile() !== sourceFile)
|
|
617
|
+
return {};
|
|
618
|
+
return { jsDocRange: locationOf(absoluteRoot, sourceFile, block) };
|
|
619
|
+
}
|
|
620
|
+
function nameOrNode(decl) {
|
|
621
|
+
if (ts.isVariableDeclaration(decl) || ts.isPropertyDeclaration(decl))
|
|
622
|
+
return decl.name;
|
|
623
|
+
// An arrow function has no name node at all; an anonymous default export is
|
|
624
|
+
// reported at the expression itself.
|
|
625
|
+
if (ts.isArrowFunction(decl))
|
|
626
|
+
return decl;
|
|
627
|
+
return decl.name ?? decl;
|
|
628
|
+
}
|
|
629
|
+
/**
|
|
630
|
+
* The node(s) whose calls belong to `decl`.
|
|
631
|
+
*
|
|
632
|
+
* More than one for a class's construction: an explicit constructor's body
|
|
633
|
+
* runs *alongside* the class's property initializers and its parameter
|
|
634
|
+
* defaults, and all three are effects of the same `new C(...)`. Attributing
|
|
635
|
+
* them to one entry (`Class.constructor`) is what lets a caller propagate
|
|
636
|
+
* from a single symbol.
|
|
637
|
+
*/
|
|
638
|
+
function bodiesOf(decl) {
|
|
639
|
+
if (ts.isVariableDeclaration(decl) ||
|
|
640
|
+
ts.isPropertyAssignment(decl) ||
|
|
641
|
+
ts.isPropertyDeclaration(decl)) {
|
|
642
|
+
return decl.initializer ? [decl.initializer] : [];
|
|
643
|
+
}
|
|
644
|
+
if (ts.isClassDeclaration(decl))
|
|
645
|
+
return propertyInitializersOf(decl);
|
|
646
|
+
if (ts.isConstructorDeclaration(decl)) {
|
|
647
|
+
const classBody = ts.isClassLike(decl.parent) ? propertyInitializersOf(decl.parent) : [];
|
|
648
|
+
const parameterDefaults = decl.parameters
|
|
649
|
+
.map((parameter) => parameter.initializer)
|
|
650
|
+
.filter((initializer) => initializer !== undefined);
|
|
651
|
+
return [...(decl.body ? [decl.body] : []), ...parameterDefaults, ...classBody];
|
|
652
|
+
}
|
|
653
|
+
return decl.body ? [decl.body] : [];
|
|
654
|
+
}
|
|
655
|
+
/**
|
|
656
|
+
* The property initializers that actually run when the class is constructed.
|
|
657
|
+
*
|
|
658
|
+
* A property holding a function *value* is excluded: constructing the class
|
|
659
|
+
* creates the closure, it does not call it. Those bodies belong to the
|
|
660
|
+
* property's own entry (`isFunctionValuedProperty`), or — when the name has no
|
|
661
|
+
* stable declaration path — to nothing, where `collectSkippedFunctions`
|
|
662
|
+
* counts them, as it did before constructions were indexed at all.
|
|
663
|
+
*/
|
|
664
|
+
function propertyInitializersOf(node) {
|
|
665
|
+
return node.members
|
|
666
|
+
.filter(ts.isPropertyDeclaration)
|
|
667
|
+
.map((member) => member.initializer)
|
|
668
|
+
.filter((initializer) => initializer !== undefined)
|
|
669
|
+
.filter((initializer) => !ts.isArrowFunction(initializer) && !ts.isFunctionExpression(initializer));
|
|
670
|
+
}
|
|
671
|
+
/** `name = () => {…}` / `name = function () {…}` on a class: a method written as a property. */
|
|
672
|
+
function isFunctionValuedProperty(member) {
|
|
673
|
+
return (ts.isPropertyDeclaration(member) &&
|
|
674
|
+
ts.isIdentifier(member.name) &&
|
|
675
|
+
member.initializer !== undefined &&
|
|
676
|
+
(ts.isArrowFunction(member.initializer) || ts.isFunctionExpression(member.initializer)));
|
|
677
|
+
}
|
|
678
|
+
// ---- runtime wrappers ---------------------------------------------------
|
|
679
|
+
/**
|
|
680
|
+
* The calls that establish an entrypoint context. Matched by module specifier
|
|
681
|
+
* and exported name, the same way the stub tables match everything else — a
|
|
682
|
+
* local `as` alias or a re-export chain does not hide one, and a `withAmbit`
|
|
683
|
+
* of one's own from somewhere else is not mistaken for it.
|
|
684
|
+
*
|
|
685
|
+
* The framework adapters are here because DESIGN.md §4.4 chose explicit
|
|
686
|
+
* registration: a literal `spec` beside a same-file handler *is* that
|
|
687
|
+
* handler's `@capabilities` and `@budget` (§4.4, "Removing the double
|
|
688
|
+
* declaration"), so a registration this pass cannot see would take the
|
|
689
|
+
* declaration with it — and where a project does write the JSDoc tag as well,
|
|
690
|
+
* the agreement check (`AMB-E010` / `AMB-E011`) has to reach the registration
|
|
691
|
+
* or the duplication would go uncompared. All three take `(spec, handler, …)`
|
|
692
|
+
* in the same two positions, which is what makes one extraction serve them
|
|
693
|
+
* all; an adapter that reordered them would silently stop being read.
|
|
694
|
+
*/
|
|
695
|
+
const RUNTIME_WRAPPER_NAMES = new Map([
|
|
696
|
+
["ambit-ts/runtime.withAmbit", "withAmbit"],
|
|
697
|
+
["ambit-ts/runtime/hono.ambitHandler", "ambitHandler"],
|
|
698
|
+
["ambit-ts/runtime/next.ambitRoute", "ambitRoute"],
|
|
699
|
+
]);
|
|
700
|
+
/**
|
|
701
|
+
* Every `withAmbit(spec, handler)` in the file, with what the source fixes
|
|
702
|
+
* about it (see {@link RuntimeWrapper}).
|
|
703
|
+
*
|
|
704
|
+
* A wrapper this pass cannot compare gets an `unmatchedReason` rather than
|
|
705
|
+
* being left out: a wrapper that silently produced no record would read as
|
|
706
|
+
* "checked and agreed".
|
|
707
|
+
*/
|
|
708
|
+
function collectRuntimeWrappers(sourceFile, checker, declaredNodeToId, absoluteRoot) {
|
|
709
|
+
const wrappers = [];
|
|
710
|
+
function visit(node) {
|
|
711
|
+
if (ts.isCallExpression(node)) {
|
|
712
|
+
const wrapper = runtimeWrapperNameOf(checker, node.expression);
|
|
713
|
+
if (wrapper !== undefined) {
|
|
714
|
+
wrappers.push(runtimeWrapperOf(node, wrapper, sourceFile, checker, declaredNodeToId, absoluteRoot));
|
|
715
|
+
}
|
|
716
|
+
}
|
|
717
|
+
ts.forEachChild(node, visit);
|
|
718
|
+
}
|
|
719
|
+
ts.forEachChild(sourceFile, visit);
|
|
720
|
+
return wrappers;
|
|
721
|
+
}
|
|
722
|
+
function runtimeWrapperNameOf(checker, callee) {
|
|
723
|
+
const qualified = importedQualifiedNameOf(checker, callee);
|
|
724
|
+
return qualified === undefined ? undefined : RUNTIME_WRAPPER_NAMES.get(qualified);
|
|
725
|
+
}
|
|
726
|
+
function runtimeWrapperOf(node, wrapper, sourceFile, checker, declaredNodeToId, absoluteRoot) {
|
|
727
|
+
const location = locationOf(absoluteRoot, sourceFile, node);
|
|
728
|
+
const capabilities = literalCapabilityListOf(node.arguments[0]);
|
|
729
|
+
const budget = literalBudgetOf(node.arguments[0]);
|
|
730
|
+
const handler = sameFileHandlerOf(node.arguments[1], sourceFile, checker, declaredNodeToId);
|
|
731
|
+
// Both halves are carried whatever either one turned out to be. A spec that
|
|
732
|
+
// builds its capability list at runtime but writes its budget as a literal
|
|
733
|
+
// still has a budget worth comparing, and vice versa: collapsing the wrapper
|
|
734
|
+
// to one "not compared" the moment either half is dynamic would drop a check
|
|
735
|
+
// the source does support.
|
|
736
|
+
const halves = {
|
|
737
|
+
...(capabilities === undefined ? {} : { capabilities }),
|
|
738
|
+
...(budget === undefined ? {} : { budget }),
|
|
739
|
+
};
|
|
740
|
+
if (handler === undefined) {
|
|
741
|
+
return { location, wrapper, ...halves, unmatchedReason: "handler-not-in-this-file" };
|
|
742
|
+
}
|
|
743
|
+
return { location, wrapper, ...halves, handler };
|
|
744
|
+
}
|
|
745
|
+
/**
|
|
746
|
+
* The spec's `capabilities` as written, or `undefined` when the source does not
|
|
747
|
+
* fix it — the spec is not an object literal, the array is not a literal, or an
|
|
748
|
+
* element is not a string literal.
|
|
749
|
+
*
|
|
750
|
+
* A spec with no `capabilities` key yields `[]`: that is a grant of nothing,
|
|
751
|
+
* which the handler's JSDoc can agree or disagree with, not an absence of
|
|
752
|
+
* information.
|
|
753
|
+
*/
|
|
754
|
+
function literalCapabilityListOf(spec) {
|
|
755
|
+
if (!spec)
|
|
756
|
+
return undefined;
|
|
757
|
+
const literal = unwrapTypeOnlyExpression(spec);
|
|
758
|
+
if (!ts.isObjectLiteralExpression(literal))
|
|
759
|
+
return undefined;
|
|
760
|
+
if (literal.properties.some(ts.isSpreadAssignment))
|
|
761
|
+
return undefined;
|
|
762
|
+
const property = literal.properties.find((member) => ts.isPropertyAssignment(member) &&
|
|
763
|
+
ts.isIdentifier(member.name) &&
|
|
764
|
+
member.name.text === "capabilities");
|
|
765
|
+
if (!property)
|
|
766
|
+
return [];
|
|
767
|
+
const array = unwrapTypeOnlyExpression(property.initializer);
|
|
768
|
+
if (!ts.isArrayLiteralExpression(array))
|
|
769
|
+
return undefined;
|
|
770
|
+
const capabilities = [];
|
|
771
|
+
for (const element of array.elements) {
|
|
772
|
+
const value = unwrapTypeOnlyExpression(element);
|
|
773
|
+
if (!ts.isStringLiteral(value) && !ts.isNoSubstitutionTemplateLiteral(value))
|
|
774
|
+
return undefined;
|
|
775
|
+
capabilities.push(value.text);
|
|
776
|
+
}
|
|
777
|
+
return capabilities;
|
|
778
|
+
}
|
|
779
|
+
/**
|
|
780
|
+
* The spec's `budget` as written, or `undefined` when the source does not fix
|
|
781
|
+
* it — the spec is not an object literal, the `budget` value is not an object
|
|
782
|
+
* literal, a key is not one of §4.5's four, or a value is not a literal.
|
|
783
|
+
*
|
|
784
|
+
* A spec with no `budget` key yields `{ kind: "absent" }`, on the same
|
|
785
|
+
* reasoning as an absent `capabilities` key: writing no budget is a statement
|
|
786
|
+
* the handler's JSDoc can contradict, not an absence of information.
|
|
787
|
+
*/
|
|
788
|
+
function literalBudgetOf(spec) {
|
|
789
|
+
if (!spec)
|
|
790
|
+
return undefined;
|
|
791
|
+
const literal = unwrapTypeOnlyExpression(spec);
|
|
792
|
+
if (!ts.isObjectLiteralExpression(literal))
|
|
793
|
+
return undefined;
|
|
794
|
+
if (literal.properties.some(ts.isSpreadAssignment))
|
|
795
|
+
return undefined;
|
|
796
|
+
const property = literal.properties.find((member) => ts.isPropertyAssignment(member) &&
|
|
797
|
+
ts.isIdentifier(member.name) &&
|
|
798
|
+
member.name.text === "budget");
|
|
799
|
+
if (!property)
|
|
800
|
+
return { kind: "absent" };
|
|
801
|
+
const object = unwrapTypeOnlyExpression(property.initializer);
|
|
802
|
+
if (!ts.isObjectLiteralExpression(object))
|
|
803
|
+
return undefined;
|
|
804
|
+
if (object.properties.some(ts.isSpreadAssignment))
|
|
805
|
+
return undefined;
|
|
806
|
+
let timeMs;
|
|
807
|
+
let costUsd;
|
|
808
|
+
let llmCalls;
|
|
809
|
+
let onExceed;
|
|
810
|
+
for (const member of object.properties) {
|
|
811
|
+
if (!ts.isPropertyAssignment(member) || !ts.isIdentifier(member.name))
|
|
812
|
+
return undefined;
|
|
813
|
+
const value = unwrapTypeOnlyExpression(member.initializer);
|
|
814
|
+
if (member.name.text === "onExceed") {
|
|
815
|
+
if (!ts.isStringLiteral(value) && !ts.isNoSubstitutionTemplateLiteral(value))
|
|
816
|
+
return undefined;
|
|
817
|
+
if (!isOnExceed(value.text))
|
|
818
|
+
return undefined;
|
|
819
|
+
onExceed = value.text;
|
|
820
|
+
continue;
|
|
821
|
+
}
|
|
822
|
+
const numeric = numericLiteralOf(value);
|
|
823
|
+
if (numeric === undefined)
|
|
824
|
+
return undefined;
|
|
825
|
+
if (member.name.text === "timeMs")
|
|
826
|
+
timeMs = numeric;
|
|
827
|
+
else if (member.name.text === "costUsd")
|
|
828
|
+
costUsd = numeric;
|
|
829
|
+
else if (member.name.text === "llmCalls")
|
|
830
|
+
llmCalls = numeric;
|
|
831
|
+
// A key outside §4.5's four is not a budget this comparison understands.
|
|
832
|
+
else
|
|
833
|
+
return undefined;
|
|
834
|
+
}
|
|
835
|
+
return {
|
|
836
|
+
kind: "literal",
|
|
837
|
+
...(timeMs === undefined ? {} : { timeMs }),
|
|
838
|
+
...(costUsd === undefined ? {} : { costUsd }),
|
|
839
|
+
...(llmCalls === undefined ? {} : { llmCalls }),
|
|
840
|
+
...(onExceed === undefined ? {} : { onExceed }),
|
|
841
|
+
};
|
|
842
|
+
}
|
|
843
|
+
/** A numeric literal, including a negated one — `-1` is a prefix expression, not a literal. */
|
|
844
|
+
function numericLiteralOf(node) {
|
|
845
|
+
if (ts.isNumericLiteral(node))
|
|
846
|
+
return Number(node.text);
|
|
847
|
+
if (ts.isPrefixUnaryExpression(node) && node.operator === ts.SyntaxKind.MinusToken) {
|
|
848
|
+
const operand = unwrapTypeOnlyExpression(node.operand);
|
|
849
|
+
if (ts.isNumericLiteral(operand))
|
|
850
|
+
return -Number(operand.text);
|
|
851
|
+
}
|
|
852
|
+
return undefined;
|
|
853
|
+
}
|
|
854
|
+
/**
|
|
855
|
+
* The handler's `SymbolId`, when it is an identifier naming a declaration this
|
|
856
|
+
* file also declares and the analysis extracted.
|
|
857
|
+
*
|
|
858
|
+
* Same file on purpose. The comparison this feeds is between two statements a
|
|
859
|
+
* reader sees together — the JSDoc above the handler and the spec beside it.
|
|
860
|
+
* A handler declared elsewhere is reported as uncompared (`AMB-W004`), not
|
|
861
|
+
* silently accepted; whether the same equality is the right test across files
|
|
862
|
+
* is part of §12's "Mapping contracts to handlers", which this does not
|
|
863
|
+
* settle.
|
|
864
|
+
*/
|
|
865
|
+
function sameFileHandlerOf(handler, sourceFile, checker, declaredNodeToId) {
|
|
866
|
+
if (!handler || !ts.isIdentifier(handler))
|
|
867
|
+
return undefined;
|
|
868
|
+
const symbol = checker.getSymbolAtLocation(handler);
|
|
869
|
+
if (!symbol)
|
|
870
|
+
return undefined;
|
|
871
|
+
const resolved = (symbol.flags & ts.SymbolFlags.Alias) !== 0 ? checker.getAliasedSymbol(symbol) : symbol;
|
|
872
|
+
const declaration = resolved.declarations?.[0];
|
|
873
|
+
if (!declaration || declaration.getSourceFile() !== sourceFile)
|
|
874
|
+
return undefined;
|
|
875
|
+
return declaredNodeToId.get(declaration);
|
|
876
|
+
}
|
|
877
|
+
// ---- JSDoc extraction ---------------------------------------------------
|
|
878
|
+
function extractJsDoc(decl, absoluteRoot) {
|
|
879
|
+
// A `ClassDeclaration` is only ever indexed as a stand-in for an *implicit*
|
|
880
|
+
// constructor (`collectFunctionLikeDeclarations`). That constructor has no
|
|
881
|
+
// declaration site, so the class's own JSDoc must not be read as its
|
|
882
|
+
// contract: `/** @effects pure */ class C {}` documents the class, and
|
|
883
|
+
// treating it as a verified constructor contract would manufacture a
|
|
884
|
+
// guarantee out of a comment about something else.
|
|
885
|
+
if (ts.isClassDeclaration(decl))
|
|
886
|
+
return undefined;
|
|
887
|
+
const target = ts.isVariableDeclaration(decl) ? (decl.parent.parent ?? decl) : decl;
|
|
888
|
+
const tags = ts.getJSDocTags(target);
|
|
889
|
+
if (tags.length === 0)
|
|
890
|
+
return undefined;
|
|
891
|
+
const map = new Map();
|
|
892
|
+
const locations = new Map();
|
|
893
|
+
const sourceFile = target.getSourceFile();
|
|
894
|
+
for (const tag of tags) {
|
|
895
|
+
map.set(tag.tagName.text, jsDocTagText(tag));
|
|
896
|
+
locations.set(tag.tagName.text, jsDocTagLocation(absoluteRoot, sourceFile, tag));
|
|
897
|
+
}
|
|
898
|
+
return { tags: map, tagLocations: locations };
|
|
899
|
+
}
|
|
900
|
+
/**
|
|
901
|
+
* A JSDoc tag's own span, with trailing trivia trimmed.
|
|
902
|
+
*
|
|
903
|
+
* `tag.getEnd()` runs to where the next tag or the closing `*/ ` begins, so it
|
|
904
|
+
* swallows the whitespace after the tag text. A fix that replaced that span
|
|
905
|
+
* would produce `; /** @effects network*/`. The patch has to be one a person
|
|
906
|
+
* would have written (DESIGN.md §5.3), so the range stops at the last
|
|
907
|
+
* non-whitespace character.
|
|
908
|
+
*/
|
|
909
|
+
function jsDocTagLocation(absoluteRoot, sourceFile, tag) {
|
|
910
|
+
const start = tag.getStart(sourceFile);
|
|
911
|
+
const text = sourceFile.text;
|
|
912
|
+
let end = tag.getEnd();
|
|
913
|
+
while (end > start && /\s/.test(text[end - 1] ?? ""))
|
|
914
|
+
end--;
|
|
915
|
+
const startPosition = sourceFile.getLineAndCharacterOfPosition(start);
|
|
916
|
+
const endPosition = sourceFile.getLineAndCharacterOfPosition(end);
|
|
917
|
+
return {
|
|
918
|
+
file: relativePath(absoluteRoot, sourceFile),
|
|
919
|
+
line: startPosition.line + 1,
|
|
920
|
+
col: startPosition.character + 1,
|
|
921
|
+
endLine: endPosition.line + 1,
|
|
922
|
+
endCol: endPosition.character + 1,
|
|
923
|
+
};
|
|
924
|
+
}
|
|
925
|
+
function jsDocTagText(tag) {
|
|
926
|
+
const { comment } = tag;
|
|
927
|
+
if (typeof comment === "string")
|
|
928
|
+
return comment.trim();
|
|
929
|
+
if (!comment)
|
|
930
|
+
return "";
|
|
931
|
+
return comment
|
|
932
|
+
.map((part) => part.kind === ts.SyntaxKind.JSDocText ? part.text : part.getText())
|
|
933
|
+
.join("")
|
|
934
|
+
.trim();
|
|
935
|
+
}
|
|
936
|
+
// ---- call extraction ----------------------------------------------------
|
|
937
|
+
function collectCalls(decl, sourceFile, program, checker, declaredNodeToId, absoluteRoot) {
|
|
938
|
+
const bodies = bodiesOf(decl);
|
|
939
|
+
if (bodies.length === 0 && !ts.isClassDeclaration(decl))
|
|
940
|
+
return [];
|
|
941
|
+
const calls = [];
|
|
942
|
+
function visit(node) {
|
|
943
|
+
if (ts.isCallExpression(node)) {
|
|
944
|
+
calls.push(classifyCall(node, sourceFile, program, checker, declaredNodeToId, absoluteRoot, decl));
|
|
945
|
+
}
|
|
946
|
+
else if (ts.isNewExpression(node)) {
|
|
947
|
+
calls.push(classifyNewExpression(node, sourceFile, program, checker, declaredNodeToId, absoluteRoot));
|
|
948
|
+
}
|
|
949
|
+
else {
|
|
950
|
+
// Assignments are not calls, but they mutate exactly the same way a
|
|
951
|
+
// mutating builtin method does (DESIGN.md §4.2, "Local mutation and
|
|
952
|
+
// `pure`"), so they enter the same array.
|
|
953
|
+
const mutation = classifyAssignment(node, sourceFile, checker, absoluteRoot, decl);
|
|
954
|
+
if (mutation)
|
|
955
|
+
calls.push(mutation);
|
|
956
|
+
}
|
|
957
|
+
ts.forEachChild(node, visit);
|
|
958
|
+
}
|
|
959
|
+
for (const body of bodies) {
|
|
960
|
+
// A property initializer / parameter default *is itself* an expression
|
|
961
|
+
// that may be a call, unlike a block body — visit it, don't only descend.
|
|
962
|
+
if (ts.isBlock(body))
|
|
963
|
+
ts.forEachChild(body, visit);
|
|
964
|
+
else
|
|
965
|
+
visit(body);
|
|
966
|
+
}
|
|
967
|
+
// An implicit constructor still calls its base constructor. There is no
|
|
968
|
+
// `super(...)` node to classify, so the heritage clause stands in for it;
|
|
969
|
+
// without this a `class Derived extends Effectful {}` would report no calls
|
|
970
|
+
// at all (DESIGN.md §3.4).
|
|
971
|
+
if (ts.isClassDeclaration(decl)) {
|
|
972
|
+
const base = baseTypeExpressionOf(decl);
|
|
973
|
+
if (base) {
|
|
974
|
+
calls.push(classifyConstruct(base, base, sourceFile, program, checker, declaredNodeToId, absoluteRoot));
|
|
975
|
+
}
|
|
976
|
+
}
|
|
977
|
+
return calls;
|
|
978
|
+
}
|
|
979
|
+
function enclosingClassOf(node) {
|
|
980
|
+
let current = node.parent;
|
|
981
|
+
while (current && !ts.isSourceFile(current)) {
|
|
982
|
+
if (ts.isClassLike(current))
|
|
983
|
+
return current;
|
|
984
|
+
current = current.parent;
|
|
985
|
+
}
|
|
986
|
+
return undefined;
|
|
987
|
+
}
|
|
988
|
+
function baseTypeExpressionOf(node) {
|
|
989
|
+
for (const clause of node.heritageClauses ?? []) {
|
|
990
|
+
if (clause.token !== ts.SyntaxKind.ExtendsKeyword)
|
|
991
|
+
continue;
|
|
992
|
+
return clause.types[0]?.expression;
|
|
993
|
+
}
|
|
994
|
+
return undefined;
|
|
995
|
+
}
|
|
996
|
+
/**
|
|
997
|
+
* `new X(...)`. Before this existed the expression was dropped unless it was
|
|
998
|
+
* `new Function`, so a `pure` function that did `new PrismaClient()` reported
|
|
999
|
+
* no call at all — not even `unknown`. DESIGN.md §3.4 forbids exactly that:
|
|
1000
|
+
* an unanalyzed path must stay visible, never collapse into "no violation".
|
|
1001
|
+
*/
|
|
1002
|
+
function classifyNewExpression(node, sourceFile, program, checker, declaredNodeToId, absoluteRoot) {
|
|
1003
|
+
if (ts.isIdentifier(node.expression) && node.expression.text === "Function") {
|
|
1004
|
+
return {
|
|
1005
|
+
location: locationOf(absoluteRoot, sourceFile, node),
|
|
1006
|
+
unresolvedReason: "new-function",
|
|
1007
|
+
};
|
|
1008
|
+
}
|
|
1009
|
+
return classifyConstruct(node.expression, node, sourceFile, program, checker, declaredNodeToId, absoluteRoot);
|
|
1010
|
+
}
|
|
1011
|
+
/**
|
|
1012
|
+
* Resolve a construction — `new X(...)`, `super(...)`, or the implicit base
|
|
1013
|
+
* call of a derived class — to the `Class.constructor` entry it runs, or to a
|
|
1014
|
+
* named-but-external constructor the stub tables may know
|
|
1015
|
+
* (`src/stubs/constructors.ts`).
|
|
1016
|
+
*
|
|
1017
|
+
* `classExpression` is the expression naming the class; `site` is the node the
|
|
1018
|
+
* diagnostic should point at (the whole `new` expression, or the heritage
|
|
1019
|
+
* clause for an implicit base call).
|
|
1020
|
+
*/
|
|
1021
|
+
function classifyConstruct(classExpression, site, sourceFile, program, checker, declaredNodeToId, absoluteRoot) {
|
|
1022
|
+
const location = locationOf(absoluteRoot, sourceFile, site);
|
|
1023
|
+
const symbol = checker.getSymbolAtLocation(classExpression);
|
|
1024
|
+
const isAlias = symbol !== undefined && (symbol.flags & ts.SymbolFlags.Alias) !== 0;
|
|
1025
|
+
const resolvedSymbol = isAlias ? checker.getAliasedSymbol(symbol) : symbol;
|
|
1026
|
+
const declaration = resolvedSymbol?.declarations?.find(ts.isClassLike);
|
|
1027
|
+
if (declaration) {
|
|
1028
|
+
const resolved = constructorTarget(declaration, declaredNodeToId);
|
|
1029
|
+
if (resolved)
|
|
1030
|
+
return { location, resolvedCallee: resolved };
|
|
1031
|
+
}
|
|
1032
|
+
const declarationSourceFile = declaration?.getSourceFile();
|
|
1033
|
+
const ambientReason = declarationSourceFile?.isDeclarationFile
|
|
1034
|
+
? ambientUnresolvedReason(declarationSourceFile, program)
|
|
1035
|
+
: undefined;
|
|
1036
|
+
const importBindingReason = isAlias && !resolvedSymbol?.declarations ? "import-binding" : undefined;
|
|
1037
|
+
const name = qualifiedNameOf(checker, classExpression, importBindingReason === undefined);
|
|
1038
|
+
if (name) {
|
|
1039
|
+
return {
|
|
1040
|
+
location,
|
|
1041
|
+
calleeQualifiedName: constructorStubKey(name),
|
|
1042
|
+
unresolvedReason: importBindingReason ?? ambientReason,
|
|
1043
|
+
// `new Promise(namedExecutor)` runs `namedExecutor` immediately; the
|
|
1044
|
+
// pure-constructor allowlist must not cover a body this walk never
|
|
1045
|
+
// visited (DESIGN.md §4.2 rule 4).
|
|
1046
|
+
callbackByReference: (ts.isNewExpression(site) && hasOpaqueCallableArgument(site, checker)) || undefined,
|
|
1047
|
+
// `new Date()` reads the clock; `new Date(2020, 0, 1)` does not
|
|
1048
|
+
// (DESIGN.md §4.2 lists the clock under `env`).
|
|
1049
|
+
constructedWithoutArguments: (ts.isNewExpression(site) ? (site.arguments?.length ?? 0) === 0 : true) || undefined,
|
|
1050
|
+
};
|
|
1051
|
+
}
|
|
1052
|
+
return {
|
|
1053
|
+
location,
|
|
1054
|
+
unresolvedReason: importBindingReason ?? ambientReason ?? "unresolved-symbol",
|
|
1055
|
+
};
|
|
1056
|
+
}
|
|
1057
|
+
/** The indexed `Class.constructor` entry for a class: its explicit constructor, or the class node standing in for the implicit one. */
|
|
1058
|
+
function constructorTarget(declaration, declaredNodeToId) {
|
|
1059
|
+
for (const member of declaration.members) {
|
|
1060
|
+
if (!ts.isConstructorDeclaration(member) || !member.body)
|
|
1061
|
+
continue;
|
|
1062
|
+
const id = declaredNodeToId.get(member);
|
|
1063
|
+
if (id)
|
|
1064
|
+
return id;
|
|
1065
|
+
}
|
|
1066
|
+
return declaredNodeToId.get(declaration);
|
|
1067
|
+
}
|
|
1068
|
+
/**
|
|
1069
|
+
* `f!` → `f`, through any number of assertions and the parentheses that may
|
|
1070
|
+
* wrap them. A non-null assertion is a type-level statement with no runtime
|
|
1071
|
+
* meaning and no effect on which declaration the callee names.
|
|
1072
|
+
*/
|
|
1073
|
+
function unwrapNonNullAssertions(expression) {
|
|
1074
|
+
let current = expression;
|
|
1075
|
+
while (ts.isNonNullExpression(current) || ts.isParenthesizedExpression(current)) {
|
|
1076
|
+
current = current.expression;
|
|
1077
|
+
}
|
|
1078
|
+
return current;
|
|
1079
|
+
}
|
|
1080
|
+
/**
|
|
1081
|
+
* The declaration of `symbol` that carries code, or its first declaration when
|
|
1082
|
+
* none does.
|
|
1083
|
+
*
|
|
1084
|
+
* An overloaded function is one symbol with several declarations: the
|
|
1085
|
+
* signatures, then the implementation. Only the implementation runs, and only
|
|
1086
|
+
* it is extracted (`collectFunctionLikeDeclarations`), so a call that stopped
|
|
1087
|
+
* at `declarations[0]` would reach a node with no id and no body — and a
|
|
1088
|
+
* body-less node infers an empty effect set, which reads as `pure` however the
|
|
1089
|
+
* implementation behaves. DESIGN.md §3.2 names this case: "With overloads
|
|
1090
|
+
* the selected declaration may have no body".
|
|
1091
|
+
*
|
|
1092
|
+
* Returning the first declaration when nothing has a body is deliberate: the
|
|
1093
|
+
* caller needs a node to classify (ambient vs. project, parameter vs.
|
|
1094
|
+
* function), and an ambient overload set legitimately has no implementation.
|
|
1095
|
+
*/
|
|
1096
|
+
function implementationDeclarationOf(symbol) {
|
|
1097
|
+
const declarations = symbol?.declarations;
|
|
1098
|
+
if (!declarations || declarations.length === 0)
|
|
1099
|
+
return undefined;
|
|
1100
|
+
if (declarations.length === 1)
|
|
1101
|
+
return declarations[0];
|
|
1102
|
+
for (const declaration of declarations) {
|
|
1103
|
+
if ((ts.isFunctionDeclaration(declaration) || ts.isMethodDeclaration(declaration)) &&
|
|
1104
|
+
declaration.body) {
|
|
1105
|
+
return declaration;
|
|
1106
|
+
}
|
|
1107
|
+
}
|
|
1108
|
+
return declarations[0];
|
|
1109
|
+
}
|
|
1110
|
+
function classifyCall(node, sourceFile, program, checker, declaredNodeToId, absoluteRoot, enclosing) {
|
|
1111
|
+
const location = locationOf(absoluteRoot, sourceFile, node);
|
|
1112
|
+
// Dynamic import: import(...)
|
|
1113
|
+
if (node.expression.kind === ts.SyntaxKind.ImportKeyword) {
|
|
1114
|
+
return { location, unresolvedReason: "dynamic-import" };
|
|
1115
|
+
}
|
|
1116
|
+
// `f!()` is a call to `f`. The non-null assertion narrows the *type* and
|
|
1117
|
+
// leaves the declaration exactly where it was, so it must not cost the call
|
|
1118
|
+
// its resolution — DESIGN.md §12 requires `as any` and `!` to be told apart,
|
|
1119
|
+
// and they differ in precisely this: a cast to `any` destroys the
|
|
1120
|
+
// declaration, an assertion keeps it. Only the assertion is unwrapped here;
|
|
1121
|
+
// `(f as any)()` continues to fall through to `any-typed`.
|
|
1122
|
+
const callee = unwrapNonNullAssertions(node.expression);
|
|
1123
|
+
// eval(...)
|
|
1124
|
+
if (ts.isIdentifier(callee) && callee.text === "eval") {
|
|
1125
|
+
return { location, unresolvedReason: "eval" };
|
|
1126
|
+
}
|
|
1127
|
+
// `super(...)` runs the base class's constructor. `getSymbolAtLocation` on
|
|
1128
|
+
// the `super` keyword does not name it, so the base is taken from the
|
|
1129
|
+
// enclosing class's heritage clause instead.
|
|
1130
|
+
if (callee.kind === ts.SyntaxKind.SuperKeyword) {
|
|
1131
|
+
const enclosingClass = enclosingClassOf(node);
|
|
1132
|
+
const base = enclosingClass ? baseTypeExpressionOf(enclosingClass) : undefined;
|
|
1133
|
+
if (!base)
|
|
1134
|
+
return { location, unresolvedReason: "unresolved-symbol" };
|
|
1135
|
+
return classifyConstruct(base, node, sourceFile, program, checker, declaredNodeToId, absoluteRoot);
|
|
1136
|
+
}
|
|
1137
|
+
const symbol = checker.getSymbolAtLocation(callee);
|
|
1138
|
+
// A call to an imported identifier (`import { helper } from "./b.ts";
|
|
1139
|
+
// helper()`) resolves via getSymbolAtLocation to the `ImportSpecifier`/
|
|
1140
|
+
// `ImportClause` itself, not the declaration behind it — that binding is
|
|
1141
|
+
// an alias (`SymbolFlags.Alias`), and getAliasedSymbol() follows it
|
|
1142
|
+
// (through an entire re-export chain, e.g. a barrel `index.ts`) to the
|
|
1143
|
+
// real declaration, whether that's a project function or an ambient one
|
|
1144
|
+
// (e.g. `node:fs`'s `readFileSync` in `@types/node`). Calling
|
|
1145
|
+
// getAliasedSymbol() on a non-alias symbol asserts, so it's guarded.
|
|
1146
|
+
const isAlias = symbol !== undefined && (symbol.flags & ts.SymbolFlags.Alias) !== 0;
|
|
1147
|
+
const resolvedSymbol = isAlias ? checker.getAliasedSymbol(symbol) : symbol;
|
|
1148
|
+
// An overload set is several declarations under one symbol, and only the
|
|
1149
|
+
// implementation has code. `declarations[0]` is the first *signature*, whose
|
|
1150
|
+
// empty body would infer an empty effect set — so the implementation is
|
|
1151
|
+
// preferred when there is one, and `declarations[0]` remains the answer when
|
|
1152
|
+
// there is not (an ambient overload set, which falls to
|
|
1153
|
+
// `overload-without-body` below). Which declaration this is decides both the
|
|
1154
|
+
// resolved target and every classification derived from its source file.
|
|
1155
|
+
const declaration = implementationDeclarationOf(resolvedSymbol);
|
|
1156
|
+
// Resolves to a project-local function/method we indexed in pass 1
|
|
1157
|
+
// (directly, or via the alias resolution above).
|
|
1158
|
+
if (declaration) {
|
|
1159
|
+
const resolvedId = declaredNodeToId.get(declaration);
|
|
1160
|
+
if (resolvedId)
|
|
1161
|
+
return { location, resolvedCallee: resolvedId };
|
|
1162
|
+
// The callee is an object-literal member: either indexed in its own right,
|
|
1163
|
+
// or holding an already-indexed function by reference.
|
|
1164
|
+
const memberId = objectLiteralMemberTarget(declaration, checker, declaredNodeToId);
|
|
1165
|
+
if (memberId)
|
|
1166
|
+
return { location, resolvedCallee: memberId };
|
|
1167
|
+
}
|
|
1168
|
+
// A property access on a module-scope `const` bound to an object literal is
|
|
1169
|
+
// resolvable from the value even when the literal carries a type annotation
|
|
1170
|
+
// and `getSymbolAtLocation` therefore lands on the annotation's member
|
|
1171
|
+
// signature instead of the literal's own member — the shape of Ambit's own
|
|
1172
|
+
// `legacyTsBackend: TsBackend = { extractProject }`. Genuine dynamic
|
|
1173
|
+
// dispatch is untouched: a receiver with no single literal behind it fails
|
|
1174
|
+
// the guards in `objectLiteralReceiverTarget` and stays unresolved.
|
|
1175
|
+
const receiverMemberId = objectLiteralReceiverTarget(callee, checker, declaredNodeToId);
|
|
1176
|
+
if (receiverMemberId)
|
|
1177
|
+
return { location, resolvedCallee: receiverMemberId };
|
|
1178
|
+
// An import binding whose alias couldn't be followed to any declaration at
|
|
1179
|
+
// all (e.g. the module specifier doesn't resolve, or the named export
|
|
1180
|
+
// doesn't exist) — `getAliasedSymbol()` returns TypeScript's `unknownSymbol`
|
|
1181
|
+
// in that case, whose `declarations` is `undefined`. Recorded as a
|
|
1182
|
+
// fallback reason rather than an early return, so a stub match is still
|
|
1183
|
+
// attempted below: an *unresolvable* `import { fetch } from "undici"`
|
|
1184
|
+
// falls back to the bare identifier text (`qualifiedNameOf`, below), which
|
|
1185
|
+
// still matches the stub table's bare `"fetch"` entry. A *resolvable* one
|
|
1186
|
+
// is qualified as `"undici.fetch"` instead and needs its own stub row
|
|
1187
|
+
// (see `src/stubs/node-builtins.ts`) — otherwise it downgrades from a
|
|
1188
|
+
// known `network` effect to `unknown`.
|
|
1189
|
+
const importBindingReason = isAlias && !declaration ? "import-binding" : undefined;
|
|
1190
|
+
// Ambient declarations (globals and stdlib types from .d.ts files, e.g.
|
|
1191
|
+
// `declare function fetch(...)`) are never project overloads or callback
|
|
1192
|
+
// parameters — they just describe how a builtin's type looks. Only real
|
|
1193
|
+
// project source is checked against the two rules below, so a stub match
|
|
1194
|
+
// is attempted for anything ambient instead of being misclassified.
|
|
1195
|
+
const isAmbientDeclaration = declaration?.getSourceFile().isDeclarationFile ?? false;
|
|
1196
|
+
if (declaration && !isAmbientDeclaration) {
|
|
1197
|
+
// A parameter (higher-order function calling its own callback argument):
|
|
1198
|
+
// out of scope for this slice's propagation (DESIGN.md §4.2 rule 4).
|
|
1199
|
+
if (ts.isParameter(declaration)) {
|
|
1200
|
+
return { location, unresolvedReason: "callback-parameter" };
|
|
1201
|
+
}
|
|
1202
|
+
// A resolved-but-bodyless overload signature.
|
|
1203
|
+
if ((ts.isFunctionDeclaration(declaration) || ts.isMethodDeclaration(declaration)) &&
|
|
1204
|
+
!declaration.body) {
|
|
1205
|
+
return { location, unresolvedReason: "overload-without-body" };
|
|
1206
|
+
}
|
|
1207
|
+
}
|
|
1208
|
+
// If this call ends up unresolved (no calleeQualifiedName, or one that
|
|
1209
|
+
// doesn't match a stub — decided later by `summarize.ts`), a more specific
|
|
1210
|
+
// reason than "unresolved-symbol" is already knowable from the ambient
|
|
1211
|
+
// declaration's own source file: TypeScript's default lib (a builtin
|
|
1212
|
+
// method reached through a value the connector layer can't name, e.g.
|
|
1213
|
+
// `set.has(...)`) vs. a third-party package's `.d.ts` (DESIGN.md §12).
|
|
1214
|
+
const ambientReason = isAmbientDeclaration && declaration
|
|
1215
|
+
? ambientUnresolvedReason(declaration.getSourceFile(), program)
|
|
1216
|
+
: undefined;
|
|
1217
|
+
// `importBindingReason` and `ambientReason` are mutually exclusive (the
|
|
1218
|
+
// former only applies when `declaration` is undefined, the latter only
|
|
1219
|
+
// when it is defined), so combining them loses nothing.
|
|
1220
|
+
const fallbackReason = importBindingReason ?? ambientReason;
|
|
1221
|
+
const calleeType = checker.getTypeAtLocation(callee);
|
|
1222
|
+
const isAnyTyped = (calleeType.flags & ts.TypeFlags.Any) !== 0;
|
|
1223
|
+
const qualifiedName = qualifiedNameOf(checker, callee, importBindingReason === undefined);
|
|
1224
|
+
if (qualifiedName) {
|
|
1225
|
+
return {
|
|
1226
|
+
location,
|
|
1227
|
+
calleeQualifiedName: qualifiedName,
|
|
1228
|
+
literalArguments: literalArgumentsOf(node),
|
|
1229
|
+
unresolvedReason: fallbackReason,
|
|
1230
|
+
};
|
|
1231
|
+
}
|
|
1232
|
+
// qualifiedNameOf only names a bare identifier or a property access on an
|
|
1233
|
+
// import binding — a builtin method reached through a local value
|
|
1234
|
+
// (`set.has(...)`) has neither, so it falls through to here with no
|
|
1235
|
+
// textual name. The checker can still name the symbol directly; that name
|
|
1236
|
+
// is checked against src/stubs/pure-builtins.ts's allowlist (a separate
|
|
1237
|
+
// namespace — see CallSite.pureBuiltinName), not against calleeQualifiedName.
|
|
1238
|
+
if (ambientReason === "builtin-method" && resolvedSymbol) {
|
|
1239
|
+
const builtinName = checker.getFullyQualifiedName(resolvedSymbol);
|
|
1240
|
+
if (builtinName) {
|
|
1241
|
+
const callbackByReference = hasOpaqueCallableArgument(node, checker) || undefined;
|
|
1242
|
+
if (isMutatingBuiltin(builtinName) && ts.isPropertyAccessExpression(callee)) {
|
|
1243
|
+
const escaping = !isLocallyOwnedMutationTarget(callee.expression, enclosing, checker);
|
|
1244
|
+
// A local mutation carries no effect, so a callback the walk never
|
|
1245
|
+
// enters is the only thing left that could — and that is plain
|
|
1246
|
+
// `unknown`, not a mutation site (DESIGN.md §4.2 rule 4).
|
|
1247
|
+
if (!escaping && callbackByReference) {
|
|
1248
|
+
return {
|
|
1249
|
+
location,
|
|
1250
|
+
pureBuiltinName: builtinName,
|
|
1251
|
+
unresolvedReason: ambientReason,
|
|
1252
|
+
callbackByReference,
|
|
1253
|
+
};
|
|
1254
|
+
}
|
|
1255
|
+
return {
|
|
1256
|
+
location,
|
|
1257
|
+
mutation: {
|
|
1258
|
+
escaping,
|
|
1259
|
+
qualifiedName: builtinName,
|
|
1260
|
+
...(escaping && callbackByReference ? { unknownCallback: true } : {}),
|
|
1261
|
+
},
|
|
1262
|
+
};
|
|
1263
|
+
}
|
|
1264
|
+
return {
|
|
1265
|
+
location,
|
|
1266
|
+
pureBuiltinName: builtinName,
|
|
1267
|
+
unresolvedReason: ambientReason,
|
|
1268
|
+
callbackByReference,
|
|
1269
|
+
};
|
|
1270
|
+
}
|
|
1271
|
+
}
|
|
1272
|
+
if (isAnyTyped) {
|
|
1273
|
+
return { location, unresolvedReason: "any-typed" };
|
|
1274
|
+
}
|
|
1275
|
+
return { location, unresolvedReason: fallbackReason ?? "unresolved-symbol" };
|
|
1276
|
+
}
|
|
1277
|
+
/**
|
|
1278
|
+
* The `SymbolId` a call through an object-literal member resolves to. Three
|
|
1279
|
+
* shapes, one hop each:
|
|
1280
|
+
*
|
|
1281
|
+
* - `{ read() {} }` / `{ read: () => {} }` — the member is indexed by
|
|
1282
|
+
* `collectFunctionLikeDeclarations`, so it has an id of its own.
|
|
1283
|
+
* - `{ read: readIt }` — the member holds an already-indexed function by
|
|
1284
|
+
* reference. The call resolves to *that* function's existing id; no second
|
|
1285
|
+
* id is minted for the same body.
|
|
1286
|
+
* - `{ readIt }` — the same, reached via `getShorthandAssignmentValueSymbol`.
|
|
1287
|
+
*
|
|
1288
|
+
* The referenced function may itself be an import binding, so the value symbol
|
|
1289
|
+
* is de-aliased the way `classifyCall` de-aliases a callee. Exactly one hop: a
|
|
1290
|
+
* member holding another member, or a `const b = a` re-binding, is not
|
|
1291
|
+
* followed. Each further hop is another place the analysis could be wrong
|
|
1292
|
+
* without saying so, and one hop covers every shape this resolves.
|
|
1293
|
+
*/
|
|
1294
|
+
function objectLiteralMemberTarget(member, checker, declaredNodeToId) {
|
|
1295
|
+
const own = declaredNodeToId.get(member);
|
|
1296
|
+
if (own)
|
|
1297
|
+
return own;
|
|
1298
|
+
if (!isInIndexableObjectLiteral(member))
|
|
1299
|
+
return undefined;
|
|
1300
|
+
let valueSymbol;
|
|
1301
|
+
if (ts.isShorthandPropertyAssignment(member)) {
|
|
1302
|
+
valueSymbol = checker.getShorthandAssignmentValueSymbol(member);
|
|
1303
|
+
}
|
|
1304
|
+
else if (ts.isPropertyAssignment(member) && ts.isIdentifier(member.initializer)) {
|
|
1305
|
+
valueSymbol = checker.getSymbolAtLocation(member.initializer);
|
|
1306
|
+
}
|
|
1307
|
+
else {
|
|
1308
|
+
return undefined;
|
|
1309
|
+
}
|
|
1310
|
+
if (!valueSymbol)
|
|
1311
|
+
return undefined;
|
|
1312
|
+
const resolved = (valueSymbol.flags & ts.SymbolFlags.Alias) !== 0
|
|
1313
|
+
? checker.getAliasedSymbol(valueSymbol)
|
|
1314
|
+
: valueSymbol;
|
|
1315
|
+
const target = resolved.declarations?.[0];
|
|
1316
|
+
return target ? declaredNodeToId.get(target) : undefined;
|
|
1317
|
+
}
|
|
1318
|
+
/**
|
|
1319
|
+
* `X.p(...)` where `X` is a module-scope `const` bound to an object literal:
|
|
1320
|
+
* the member is found by name in the literal itself, so a type annotation on
|
|
1321
|
+
* `X` — which makes `getSymbolAtLocation` return the annotation's member
|
|
1322
|
+
* signature rather than the literal's member — does not hide the target.
|
|
1323
|
+
*
|
|
1324
|
+
* Which literals qualify — and why — is `indexableObjectLiteral`.
|
|
1325
|
+
*/
|
|
1326
|
+
function objectLiteralReceiverTarget(callee, checker, declaredNodeToId) {
|
|
1327
|
+
if (!ts.isPropertyAccessExpression(callee) || !ts.isIdentifier(callee.expression)) {
|
|
1328
|
+
return undefined;
|
|
1329
|
+
}
|
|
1330
|
+
const receiverSymbol = checker.getSymbolAtLocation(callee.expression);
|
|
1331
|
+
if (!receiverSymbol)
|
|
1332
|
+
return undefined;
|
|
1333
|
+
const resolved = (receiverSymbol.flags & ts.SymbolFlags.Alias) !== 0
|
|
1334
|
+
? checker.getAliasedSymbol(receiverSymbol)
|
|
1335
|
+
: receiverSymbol;
|
|
1336
|
+
const declaration = resolved.declarations?.[0];
|
|
1337
|
+
if (!declaration || !ts.isVariableDeclaration(declaration))
|
|
1338
|
+
return undefined;
|
|
1339
|
+
const literal = indexableObjectLiteral(declaration);
|
|
1340
|
+
if (!literal)
|
|
1341
|
+
return undefined;
|
|
1342
|
+
for (const member of literal.properties) {
|
|
1343
|
+
if (!member.name || !ts.isIdentifier(member.name))
|
|
1344
|
+
continue;
|
|
1345
|
+
if (member.name.text !== callee.name.text)
|
|
1346
|
+
continue;
|
|
1347
|
+
return objectLiteralMemberTarget(member, checker, declaredNodeToId);
|
|
1348
|
+
}
|
|
1349
|
+
return undefined;
|
|
1350
|
+
}
|
|
1351
|
+
/**
|
|
1352
|
+
* The assignment-shaped mutation at `node`, if any (DESIGN.md §4.2, "Local
|
|
1353
|
+
* mutation and `pure`"): `a.b = 1`, `a.b += 1`, `a.b++`, `delete a.b`, and a
|
|
1354
|
+
* write
|
|
1355
|
+
* to a binding declared outside `enclosing`.
|
|
1356
|
+
*
|
|
1357
|
+
* Reassigning a variable the function itself declared (`let i = 0; i++`) is
|
|
1358
|
+
* not a mutation of anything: nothing outside can observe it, and it is not
|
|
1359
|
+
* recorded as a site at all.
|
|
1360
|
+
*/
|
|
1361
|
+
function classifyAssignment(node, sourceFile, checker, absoluteRoot, enclosing) {
|
|
1362
|
+
const target = assignmentTargetOf(node);
|
|
1363
|
+
if (!target)
|
|
1364
|
+
return undefined;
|
|
1365
|
+
// A destructuring assignment writes to several places at once; one escaping
|
|
1366
|
+
// leaf makes the whole statement a `state_write` (DESIGN.md §4.2,
|
|
1367
|
+
// "The rule for deciding locality").
|
|
1368
|
+
const escaping = assignmentLeavesOf(target).some((leaf) => !isLocalAssignmentLeaf(leaf, enclosing, checker));
|
|
1369
|
+
if (!escaping)
|
|
1370
|
+
return undefined;
|
|
1371
|
+
return { location: locationOf(absoluteRoot, sourceFile, node), mutation: { escaping: true } };
|
|
1372
|
+
}
|
|
1373
|
+
/**
|
|
1374
|
+
* Whether writing to one destructuring leaf stays inside `enclosing`. A bare
|
|
1375
|
+
* identifier the function declared is its own local; anything else is decided
|
|
1376
|
+
* by {@link isLocallyOwnedMutationTarget}.
|
|
1377
|
+
*/
|
|
1378
|
+
function isLocalAssignmentLeaf(leaf, enclosing, checker) {
|
|
1379
|
+
if (ts.isIdentifier(leaf)) {
|
|
1380
|
+
const declaration = checker.getSymbolAtLocation(leaf)?.valueDeclaration;
|
|
1381
|
+
return declaration !== undefined && isLexicallyInside(declaration, enclosing);
|
|
1382
|
+
}
|
|
1383
|
+
if (!ts.isPropertyAccessExpression(leaf) && !ts.isElementAccessExpression(leaf)) {
|
|
1384
|
+
// Not a shape this analysis can place — over-approximate to escaping.
|
|
1385
|
+
return false;
|
|
1386
|
+
}
|
|
1387
|
+
return isLocallyOwnedMutationTarget(leaf, enclosing, checker);
|
|
1388
|
+
}
|
|
1389
|
+
/**
|
|
1390
|
+
* The individual places an assignment target writes to. A plain target is
|
|
1391
|
+
* itself; a destructuring pattern (`[a.x, b] = xs`, `({ y: o.z } = v)`) is
|
|
1392
|
+
* flattened to its leaves, so no write goes unexamined.
|
|
1393
|
+
*/
|
|
1394
|
+
function assignmentLeavesOf(target) {
|
|
1395
|
+
if (ts.isArrayLiteralExpression(target)) {
|
|
1396
|
+
return target.elements.flatMap((element) => ts.isOmittedExpression(element) ? [] : assignmentLeavesOf(stripAssignmentDefault(element)));
|
|
1397
|
+
}
|
|
1398
|
+
if (ts.isObjectLiteralExpression(target)) {
|
|
1399
|
+
return target.properties.flatMap((property) => {
|
|
1400
|
+
if (ts.isPropertyAssignment(property)) {
|
|
1401
|
+
return assignmentLeavesOf(stripAssignmentDefault(property.initializer));
|
|
1402
|
+
}
|
|
1403
|
+
if (ts.isShorthandPropertyAssignment(property))
|
|
1404
|
+
return [property.name];
|
|
1405
|
+
// A spread target (`{...rest} = v`) writes to whatever follows it.
|
|
1406
|
+
if (ts.isSpreadAssignment(property))
|
|
1407
|
+
return assignmentLeavesOf(property.expression);
|
|
1408
|
+
return [];
|
|
1409
|
+
});
|
|
1410
|
+
}
|
|
1411
|
+
if (ts.isSpreadElement(target))
|
|
1412
|
+
return assignmentLeavesOf(target.expression);
|
|
1413
|
+
return [target];
|
|
1414
|
+
}
|
|
1415
|
+
/** `a.x = 1` in `[a.x = 1] = xs`: the default value is not part of the target. */
|
|
1416
|
+
function stripAssignmentDefault(node) {
|
|
1417
|
+
return ts.isBinaryExpression(node) && node.operatorToken.kind === ts.SyntaxKind.EqualsToken
|
|
1418
|
+
? node.left
|
|
1419
|
+
: node;
|
|
1420
|
+
}
|
|
1421
|
+
/** The expression a mutating statement writes through, or `undefined` if `node` is not one. */
|
|
1422
|
+
function assignmentTargetOf(node) {
|
|
1423
|
+
if (ts.isBinaryExpression(node) && isAssignmentOperator(node.operatorToken.kind)) {
|
|
1424
|
+
return node.left;
|
|
1425
|
+
}
|
|
1426
|
+
if ((ts.isPostfixUnaryExpression(node) || ts.isPrefixUnaryExpression(node)) &&
|
|
1427
|
+
(node.operator === ts.SyntaxKind.PlusPlusToken ||
|
|
1428
|
+
node.operator === ts.SyntaxKind.MinusMinusToken)) {
|
|
1429
|
+
return node.operand;
|
|
1430
|
+
}
|
|
1431
|
+
if (ts.isDeleteExpression(node))
|
|
1432
|
+
return node.expression;
|
|
1433
|
+
return undefined;
|
|
1434
|
+
}
|
|
1435
|
+
function isAssignmentOperator(kind) {
|
|
1436
|
+
return (kind >= ts.SyntaxKind.FirstAssignment &&
|
|
1437
|
+
kind <= ts.SyntaxKind.LastAssignment &&
|
|
1438
|
+
kind !== ts.SyntaxKind.EqualsGreaterThanToken);
|
|
1439
|
+
}
|
|
1440
|
+
/**
|
|
1441
|
+
* Whether the value `target` writes through was allocated inside `enclosing`
|
|
1442
|
+
* — the locality rule of DESIGN.md §4.2, "The rule for deciding locality",
|
|
1443
|
+
* deliberately
|
|
1444
|
+
* as narrow as §4.2 rule 7 and, like it, not a soundness claim: a fresh value
|
|
1445
|
+
* handed to something else before being mutated still reads as local, because
|
|
1446
|
+
* Ambit does no alias analysis.
|
|
1447
|
+
*
|
|
1448
|
+
* Local iff the root of the access chain is a fresh allocation itself, or an
|
|
1449
|
+
* identifier bound by `const` inside `enclosing` to a fresh allocation.
|
|
1450
|
+
* Everything else — a parameter (its declaration is lexically inside the
|
|
1451
|
+
* function but the value is the caller's), `this`, a module-scope or outer
|
|
1452
|
+
* binding, `let`/`var`, an unresolvable root — is escaping, over-approximated
|
|
1453
|
+
* on purpose.
|
|
1454
|
+
*/
|
|
1455
|
+
function isLocallyOwnedMutationTarget(target, enclosing, checker) {
|
|
1456
|
+
const root = mutationRootOf(target);
|
|
1457
|
+
if (isFreshAllocation(root))
|
|
1458
|
+
return true;
|
|
1459
|
+
if (root.kind === ts.SyntaxKind.ThisKeyword)
|
|
1460
|
+
return isThisOfNewOperand(root);
|
|
1461
|
+
if (!ts.isIdentifier(root))
|
|
1462
|
+
return false;
|
|
1463
|
+
const declaration = checker.getSymbolAtLocation(root)?.valueDeclaration;
|
|
1464
|
+
if (!declaration || !ts.isVariableDeclaration(declaration))
|
|
1465
|
+
return false;
|
|
1466
|
+
if ((ts.getCombinedNodeFlags(declaration) & ts.NodeFlags.Const) === 0)
|
|
1467
|
+
return false;
|
|
1468
|
+
if (!declaration.initializer || !isFreshAllocation(declaration.initializer))
|
|
1469
|
+
return false;
|
|
1470
|
+
return isLexicallyInside(declaration, enclosing);
|
|
1471
|
+
}
|
|
1472
|
+
/**
|
|
1473
|
+
* Whether `this` denotes an object nothing else holds yet: the function it
|
|
1474
|
+
* binds to is the direct operand of a `NewExpression` (`new function () {
|
|
1475
|
+
* this.x = 1 }`), or it is the constructor of a class with no `extends`
|
|
1476
|
+
* clause. Any other `this` (a method's, a callback's, a derived
|
|
1477
|
+
* constructor's, one the analysis cannot place) is escaping.
|
|
1478
|
+
*
|
|
1479
|
+
* The constructor case is not a convenience: with `erasableSyntaxOnly` there
|
|
1480
|
+
* are no parameter properties, so `this.x = x` in a constructor is the only
|
|
1481
|
+
* way to write a field, and calling it `state_write` would make `pure`
|
|
1482
|
+
* unusable on every constructor in the language subset Ambit targets.
|
|
1483
|
+
*/
|
|
1484
|
+
function isThisOfNewOperand(node) {
|
|
1485
|
+
for (let current = node; current; current = current.parent) {
|
|
1486
|
+
// Arrow functions do not bind `this`; keep walking out through them.
|
|
1487
|
+
if (ts.isFunctionDeclaration(current) || ts.isFunctionExpression(current)) {
|
|
1488
|
+
return current.parent !== undefined && ts.isNewExpression(current.parent);
|
|
1489
|
+
}
|
|
1490
|
+
// A base class's constructor allocated the object it is writing to, and
|
|
1491
|
+
// the only way out is its own return. A derived one cannot claim that:
|
|
1492
|
+
// `super(...)` ran first and may have handed `this` to something else.
|
|
1493
|
+
if (ts.isConstructorDeclaration(current)) {
|
|
1494
|
+
return ts.isClassLike(current.parent) && baseTypeExpressionOf(current.parent) === undefined;
|
|
1495
|
+
}
|
|
1496
|
+
if (ts.isClassLike(current) || ts.isSourceFile(current))
|
|
1497
|
+
return false;
|
|
1498
|
+
}
|
|
1499
|
+
return false;
|
|
1500
|
+
}
|
|
1501
|
+
/** The base of a property/element access chain: `a` in `a.b[0].c`. */
|
|
1502
|
+
function mutationRootOf(target) {
|
|
1503
|
+
let current = target;
|
|
1504
|
+
for (;;) {
|
|
1505
|
+
if (ts.isPropertyAccessExpression(current) ||
|
|
1506
|
+
ts.isElementAccessExpression(current) ||
|
|
1507
|
+
ts.isNonNullExpression(current) ||
|
|
1508
|
+
ts.isParenthesizedExpression(current) ||
|
|
1509
|
+
ts.isAsExpression(current)) {
|
|
1510
|
+
current = current.expression;
|
|
1511
|
+
continue;
|
|
1512
|
+
}
|
|
1513
|
+
return current;
|
|
1514
|
+
}
|
|
1515
|
+
}
|
|
1516
|
+
/** An expression that necessarily produces a value no one else holds yet. */
|
|
1517
|
+
function isFreshAllocation(node) {
|
|
1518
|
+
return (ts.isArrayLiteralExpression(node) ||
|
|
1519
|
+
ts.isObjectLiteralExpression(node) ||
|
|
1520
|
+
ts.isNewExpression(node));
|
|
1521
|
+
}
|
|
1522
|
+
function isLexicallyInside(node, ancestor) {
|
|
1523
|
+
for (let current = node; current; current = current.parent) {
|
|
1524
|
+
if (current === ancestor)
|
|
1525
|
+
return true;
|
|
1526
|
+
}
|
|
1527
|
+
return false;
|
|
1528
|
+
}
|
|
1529
|
+
/**
|
|
1530
|
+
* True if any argument is a callable passed by reference (an identifier,
|
|
1531
|
+
* property access, or other expression with call signatures) rather than
|
|
1532
|
+
* written inline as `x => ...` / `function (...) {...}`. `collectCalls`
|
|
1533
|
+
* only walks into an inline callback's body; a callback passed by reference
|
|
1534
|
+
* is invisible to it, so a method taking one (`forEach`, `map`, `some`, ...)
|
|
1535
|
+
* cannot be trusted as pure even if its own name is allowlisted.
|
|
1536
|
+
*
|
|
1537
|
+
* An `any`/`unknown`-typed argument has no call signatures of its own
|
|
1538
|
+
* (`getCallSignatures()` returns `[]`), so it must be treated as opaque
|
|
1539
|
+
* rather than as "not callable" — otherwise `arr.map(fnFromAnyRecord)`
|
|
1540
|
+
* would slip past this guard the same way `classifyCall`'s own
|
|
1541
|
+
* `any-typed` callee case treats `any` as unresolved, not as safe.
|
|
1542
|
+
*
|
|
1543
|
+
* Only argument positions whose *declared* parameter type can itself be
|
|
1544
|
+
* called are scanned (`acceptsCallableArgument`) — otherwise a
|
|
1545
|
+
* non-callback argument that merely happens to be a callable value (e.g.
|
|
1546
|
+
* `Array.prototype.reduce`'s `initialValue`, when the accumulator type is a
|
|
1547
|
+
* function type) would make the whole call look opaque even though its
|
|
1548
|
+
* actual callback is written inline and already walked by `collectCalls`.
|
|
1549
|
+
* Every branch that can't determine whether a position accepts a callable
|
|
1550
|
+
* (`getResolvedSignature` returns nothing, a JSDoc-only signature, an
|
|
1551
|
+
* out-of-range or rest parameter) falls back to scanning that argument
|
|
1552
|
+
* rather than skipping it, so this narrowing can only add opacity checks
|
|
1553
|
+
* back in, never silently drop the `any`/`unknown` fail-open guard above.
|
|
1554
|
+
*/
|
|
1555
|
+
function hasOpaqueCallableArgument(node, checker) {
|
|
1556
|
+
const signature = checker.getResolvedSignature(node);
|
|
1557
|
+
return (node.arguments ?? []).some((arg, index) => {
|
|
1558
|
+
if (ts.isArrowFunction(arg) || ts.isFunctionExpression(arg))
|
|
1559
|
+
return false;
|
|
1560
|
+
if (signature && !acceptsCallableArgument(signature, index, checker))
|
|
1561
|
+
return false;
|
|
1562
|
+
const type = checker.getTypeAtLocation(arg);
|
|
1563
|
+
if (type.flags & (ts.TypeFlags.Any | ts.TypeFlags.Unknown))
|
|
1564
|
+
return true;
|
|
1565
|
+
return type.getCallSignatures().length > 0;
|
|
1566
|
+
});
|
|
1567
|
+
}
|
|
1568
|
+
/**
|
|
1569
|
+
* True if `signature`'s declared (not instantiated) parameter type at
|
|
1570
|
+
* `index` has call signatures — i.e. this argument position is a callback
|
|
1571
|
+
* slot. `checker.getTypeAtLocation` is called on the *parameter
|
|
1572
|
+
* declaration node*, not the argument: the declaration site carries the
|
|
1573
|
+
* generic, uninstantiated type (e.g. `reduce`'s `initialValue: U`), while
|
|
1574
|
+
* `getTypeAtLocation` on the argument itself would return the type
|
|
1575
|
+
* *instantiated* for this call (e.g. `() => number` when `U` is inferred
|
|
1576
|
+
* as a function type) and defeat the narrowing this function exists for.
|
|
1577
|
+
*/
|
|
1578
|
+
function acceptsCallableArgument(signature, index, checker) {
|
|
1579
|
+
const declaration = signature.declaration;
|
|
1580
|
+
// No declaration (e.g. a synthetic signature) or a JSDoc-only signature
|
|
1581
|
+
// (`JSDocSignature` has no `parameters` in the same shape) can't be
|
|
1582
|
+
// inspected — treat the slot as callable so the caller still scans it.
|
|
1583
|
+
if (!declaration || ts.isJSDocSignature(declaration))
|
|
1584
|
+
return true;
|
|
1585
|
+
const parameter = declaration.parameters[index];
|
|
1586
|
+
// An argument beyond the declared parameter list, or a rest parameter
|
|
1587
|
+
// (whose declared type is the array type, not the element type), can't
|
|
1588
|
+
// be classified from the declaration either — stay conservative.
|
|
1589
|
+
if (!parameter || parameter.dotDotDotToken)
|
|
1590
|
+
return true;
|
|
1591
|
+
return isCallableParameterType(checker.getTypeAtLocation(parameter));
|
|
1592
|
+
}
|
|
1593
|
+
function isCallableParameterType(type) {
|
|
1594
|
+
if (type.flags & (ts.TypeFlags.Any | ts.TypeFlags.Unknown))
|
|
1595
|
+
return true;
|
|
1596
|
+
if (type.isUnion())
|
|
1597
|
+
return type.types.some(isCallableParameterType);
|
|
1598
|
+
return type.getCallSignatures().length > 0;
|
|
1599
|
+
}
|
|
1600
|
+
/**
|
|
1601
|
+
* Which flavour of ambient declaration a callee resolved to, so an unresolved
|
|
1602
|
+
* call says what would fix it: the compiler's own lib (`builtin-method`), an
|
|
1603
|
+
* installed package's types (`external-module`), or a `.d.ts` written by the
|
|
1604
|
+
* project itself (`ambient-declaration` — a hand-written `declare module`,
|
|
1605
|
+
* common in a project that types a dependency locally).
|
|
1606
|
+
*/
|
|
1607
|
+
function ambientUnresolvedReason(declarationSourceFile, program) {
|
|
1608
|
+
if (program.isSourceFileDefaultLibrary(declarationSourceFile))
|
|
1609
|
+
return "builtin-method";
|
|
1610
|
+
if (program.isSourceFileFromExternalLibrary(declarationSourceFile))
|
|
1611
|
+
return "external-module";
|
|
1612
|
+
return "ambient-declaration";
|
|
1613
|
+
}
|
|
1614
|
+
/**
|
|
1615
|
+
* Best-effort textual name for a call target, for stub matching
|
|
1616
|
+
* (`src/stubs/`). A property access on an identifier bound to a namespace
|
|
1617
|
+
* or default import yields `"<module specifier>.<property>"` (e.g.
|
|
1618
|
+
* `"node:fs".readFileSync` for `import * as fs from "node:fs";
|
|
1619
|
+
* fs.readFileSync(...)`, reported as `"node:fs.readFileSync"`; likewise for
|
|
1620
|
+
* `import fs from "node:fs"; fs.readFileSync(...)`).
|
|
1621
|
+
*
|
|
1622
|
+
* A bare identifier normally yields its own text (`"fetch"`) — the best
|
|
1623
|
+
* available name for stub matching regardless of whether it resolves to a
|
|
1624
|
+
* lib.dom.d.ts symbol. But when it's bound by a *resolved* named import
|
|
1625
|
+
* (`aliasResolved` — the alias was followed to a real declaration, see
|
|
1626
|
+
* `classifyCall`'s `importBindingReason`), the module specifier is known, so
|
|
1627
|
+
* the name is qualified the same way as a property access: `import {
|
|
1628
|
+
* readFileSync } from "node:fs"; readFileSync(...)` is reported as
|
|
1629
|
+
* `"node:fs.readFileSync"` (using the imported name, not a local `as`
|
|
1630
|
+
* alias). An *unresolved* named import (module doesn't resolve, or the
|
|
1631
|
+
* named export doesn't exist) still falls back to the bare identifier text
|
|
1632
|
+
* — an unresolvable `import { fetch } from "undici"` is still recognized as
|
|
1633
|
+
* `fetch` for stub matching, not silently downgraded to no name at all. Once
|
|
1634
|
+
* the same import *resolves*, though, it is qualified as `"undici.fetch"`
|
|
1635
|
+
* instead, which only matches the stub table if that qualified name has its
|
|
1636
|
+
* own row — a bare `"fetch"` row does not cover it.
|
|
1637
|
+
*
|
|
1638
|
+
* This does not resolve re-exported bindings several hops away — see the
|
|
1639
|
+
* limitation noted in `src/stubs/node-builtins.ts`.
|
|
1640
|
+
*/
|
|
1641
|
+
function qualifiedNameOf(checker, expr, aliasResolved) {
|
|
1642
|
+
if (ts.isIdentifier(expr)) {
|
|
1643
|
+
if (aliasResolved) {
|
|
1644
|
+
const imported = importedQualifiedNameOf(checker, expr);
|
|
1645
|
+
if (imported)
|
|
1646
|
+
return imported;
|
|
1647
|
+
}
|
|
1648
|
+
return expr.text;
|
|
1649
|
+
}
|
|
1650
|
+
if (ts.isPropertyAccessExpression(expr)) {
|
|
1651
|
+
return memberChainQualifiedNameOf(checker, expr);
|
|
1652
|
+
}
|
|
1653
|
+
return undefined;
|
|
1654
|
+
}
|
|
1655
|
+
/**
|
|
1656
|
+
* `<origin>.<property path>` for a property-access expression, where
|
|
1657
|
+
* `<origin>` says where the receiver came from and the path is what the source
|
|
1658
|
+
* literally wrote after it.
|
|
1659
|
+
*
|
|
1660
|
+
* Two origins are recognized, both facts about the project's own source rather
|
|
1661
|
+
* than about any package's `.d.ts`:
|
|
1662
|
+
*
|
|
1663
|
+
* - a namespace or default import — `node:fs` for `import * as fs from
|
|
1664
|
+
* "node:fs"`, giving `node:fs.readFileSync` (and now `node:fs.promises.readFile`
|
|
1665
|
+
* for a deeper path, which previously had no name at all);
|
|
1666
|
+
* - the class a `const` was constructed from — `pg.Pool` for `const pool = new
|
|
1667
|
+
* Pool(...)` with `Pool` imported from `"pg"`, giving `pg.Pool.query` and
|
|
1668
|
+
* `@prisma/client.PrismaClient.user.findMany`.
|
|
1669
|
+
*
|
|
1670
|
+
* Anything else yields `undefined`: a receiver whose origin is a parameter, a
|
|
1671
|
+
* `let`, a project-local class, or a call result has no module-qualified name
|
|
1672
|
+
* that a stub table could honestly key on, and inventing one from the local
|
|
1673
|
+
* variable's spelling would make the table match by coincidence.
|
|
1674
|
+
*/
|
|
1675
|
+
function memberChainQualifiedNameOf(checker, expr) {
|
|
1676
|
+
const path = [];
|
|
1677
|
+
let current = expr;
|
|
1678
|
+
while (ts.isPropertyAccessExpression(current)) {
|
|
1679
|
+
path.unshift(current.name.text);
|
|
1680
|
+
current = current.expression;
|
|
1681
|
+
}
|
|
1682
|
+
if (!ts.isIdentifier(current))
|
|
1683
|
+
return undefined;
|
|
1684
|
+
const origin = moduleSpecifierOf(checker, current) ?? constructedClassQualifiedNameOf(checker, current);
|
|
1685
|
+
if (!origin)
|
|
1686
|
+
return undefined;
|
|
1687
|
+
return [origin, ...path].join(".");
|
|
1688
|
+
}
|
|
1689
|
+
/**
|
|
1690
|
+
* The module-qualified class name a receiver was constructed from, following
|
|
1691
|
+
* the binding through imports and re-exports — `"pg.Pool"` for a `const pool =
|
|
1692
|
+
* new Pool(...)` declared in another module and re-exported by a barrel.
|
|
1693
|
+
*
|
|
1694
|
+
* `undefined` unless the binding is a `const` whose initializer is a `new`
|
|
1695
|
+
* expression naming an *imported* class. `const` because a `let` may hold a
|
|
1696
|
+
* different object by the time the call runs; imported because a locally
|
|
1697
|
+
* declared class has no module-qualified name. This rests on exactly the
|
|
1698
|
+
* assumption DESIGN.md §4.2 rule 7 already states for object literals —
|
|
1699
|
+
* `const` fixes the binding, not the object's properties — and adds no other.
|
|
1700
|
+
*/
|
|
1701
|
+
function constructedClassQualifiedNameOf(checker, receiver) {
|
|
1702
|
+
const symbol = checker.getSymbolAtLocation(receiver);
|
|
1703
|
+
if (!symbol)
|
|
1704
|
+
return undefined;
|
|
1705
|
+
const resolved = (symbol.flags & ts.SymbolFlags.Alias) !== 0 ? checker.getAliasedSymbol(symbol) : symbol;
|
|
1706
|
+
const declaration = resolved.declarations?.[0];
|
|
1707
|
+
if (!declaration || !ts.isVariableDeclaration(declaration))
|
|
1708
|
+
return undefined;
|
|
1709
|
+
if ((declaration.parent.flags & ts.NodeFlags.Const) === 0)
|
|
1710
|
+
return undefined;
|
|
1711
|
+
if (!declaration.initializer)
|
|
1712
|
+
return undefined;
|
|
1713
|
+
const initializer = unwrapTypeOnlyExpression(declaration.initializer);
|
|
1714
|
+
if (!ts.isNewExpression(initializer))
|
|
1715
|
+
return undefined;
|
|
1716
|
+
return importedQualifiedNameOf(checker, initializer.expression);
|
|
1717
|
+
}
|
|
1718
|
+
/**
|
|
1719
|
+
* `"<module specifier>.<exported name>"` for an expression that names an
|
|
1720
|
+
* import, and `undefined` for anything else — including a bare local
|
|
1721
|
+
* identifier, which `qualifiedNameOf` falls back to separately.
|
|
1722
|
+
*
|
|
1723
|
+
* The distinction matters wherever the name is used as a *prefix* rather than
|
|
1724
|
+
* as a whole key ({@link constructedClassQualifiedNameOf}): a prefix built from
|
|
1725
|
+
* a local spelling would collide across projects, so only module-derived names
|
|
1726
|
+
* qualify.
|
|
1727
|
+
*/
|
|
1728
|
+
function importedQualifiedNameOf(checker, expr) {
|
|
1729
|
+
if (ts.isIdentifier(expr)) {
|
|
1730
|
+
const named = namedImportQualifiedNameOf(checker, expr);
|
|
1731
|
+
if (named)
|
|
1732
|
+
return named;
|
|
1733
|
+
const defaultSpecifier = defaultImportSpecifierOf(checker, expr);
|
|
1734
|
+
// The module's default export has no name of its own to borrow — the local
|
|
1735
|
+
// binding's spelling is the importer's choice, not the module's.
|
|
1736
|
+
return defaultSpecifier === undefined ? undefined : `${defaultSpecifier}.default`;
|
|
1737
|
+
}
|
|
1738
|
+
if (ts.isPropertyAccessExpression(expr)) {
|
|
1739
|
+
const moduleSpecifier = moduleSpecifierOf(checker, expr.expression);
|
|
1740
|
+
return moduleSpecifier === undefined ? undefined : `${moduleSpecifier}.${expr.name.text}`;
|
|
1741
|
+
}
|
|
1742
|
+
return undefined;
|
|
1743
|
+
}
|
|
1744
|
+
/** The module specifier of a default import (`import OpenAI from "openai"`), and nothing else. */
|
|
1745
|
+
function defaultImportSpecifierOf(checker, expr) {
|
|
1746
|
+
const decl = checker.getSymbolAtLocation(expr)?.declarations?.[0];
|
|
1747
|
+
if (!decl || !ts.isImportClause(decl))
|
|
1748
|
+
return undefined;
|
|
1749
|
+
const importDecl = decl.parent;
|
|
1750
|
+
if (!ts.isImportDeclaration(importDecl) || !ts.isStringLiteral(importDecl.moduleSpecifier)) {
|
|
1751
|
+
return undefined;
|
|
1752
|
+
}
|
|
1753
|
+
return importDecl.moduleSpecifier.text;
|
|
1754
|
+
}
|
|
1755
|
+
/**
|
|
1756
|
+
* What each argument of `node` says statically (see {@link LiteralArgument}).
|
|
1757
|
+
* `undefined` when nothing at all could be read from any argument, so a call
|
|
1758
|
+
* whose arguments are all opaque carries no field rather than an array of
|
|
1759
|
+
* holes.
|
|
1760
|
+
*/
|
|
1761
|
+
function literalArgumentsOf(node) {
|
|
1762
|
+
const args = node.arguments;
|
|
1763
|
+
if (args.length === 0)
|
|
1764
|
+
return undefined;
|
|
1765
|
+
const read = args.map(literalArgumentOf);
|
|
1766
|
+
return read.some((argument) => argument !== undefined) ? read : undefined;
|
|
1767
|
+
}
|
|
1768
|
+
function literalArgumentOf(argument) {
|
|
1769
|
+
const expr = unwrapTypeOnlyExpression(argument);
|
|
1770
|
+
if (ts.isStringLiteral(expr) || ts.isNoSubstitutionTemplateLiteral(expr)) {
|
|
1771
|
+
return { text: expr.text, complete: true };
|
|
1772
|
+
}
|
|
1773
|
+
// A template literal's static head is the part the source fixes; everything
|
|
1774
|
+
// after the first substitution is the caller's to decide at runtime.
|
|
1775
|
+
if (ts.isTemplateExpression(expr)) {
|
|
1776
|
+
return { text: expr.head.text, complete: false };
|
|
1777
|
+
}
|
|
1778
|
+
if (ts.isObjectLiteralExpression(expr)) {
|
|
1779
|
+
const properties = new Map();
|
|
1780
|
+
for (const property of expr.properties) {
|
|
1781
|
+
if (!ts.isPropertyAssignment(property))
|
|
1782
|
+
continue;
|
|
1783
|
+
if (!ts.isIdentifier(property.name))
|
|
1784
|
+
continue;
|
|
1785
|
+
const value = unwrapTypeOnlyExpression(property.initializer);
|
|
1786
|
+
if (ts.isStringLiteral(value) || ts.isNoSubstitutionTemplateLiteral(value)) {
|
|
1787
|
+
properties.set(property.name.text, value.text);
|
|
1788
|
+
}
|
|
1789
|
+
}
|
|
1790
|
+
return properties.size === 0 ? undefined : { properties };
|
|
1791
|
+
}
|
|
1792
|
+
return undefined;
|
|
1793
|
+
}
|
|
1794
|
+
/**
|
|
1795
|
+
* `"<module specifier>.<exported name>"` for an identifier bound by a named
|
|
1796
|
+
* import, following the re-export chain to the module that actually owns the
|
|
1797
|
+
* binding.
|
|
1798
|
+
*
|
|
1799
|
+
* The imported name is used rather than a local `as` alias (`import {
|
|
1800
|
+
* readFileSync as rf } ...` still yields `"node:fs.readFileSync"` — the stub
|
|
1801
|
+
* table is keyed on the module's own export names).
|
|
1802
|
+
*
|
|
1803
|
+
* The chain matters for a barrel file: `import { readFileSync } from
|
|
1804
|
+
* "./lib/index.ts"` with the barrel re-exporting `"node:fs"` has to name
|
|
1805
|
+
* `node:fs.readFileSync`, not `./lib/index.ts.readFileSync`, or the bundled
|
|
1806
|
+
* effect table misses it. {@link deepestPackageHop} explains which hop wins,
|
|
1807
|
+
* and why the deepest one is not always right.
|
|
1808
|
+
*
|
|
1809
|
+
* `undefined` for anything that isn't a named import of a string-literal
|
|
1810
|
+
* module specifier (namespace/default imports are handled by
|
|
1811
|
+
* `moduleSpecifierOf` and `defaultImportSpecifierOf`).
|
|
1812
|
+
*/
|
|
1813
|
+
function namedImportQualifiedNameOf(checker, expr) {
|
|
1814
|
+
const symbol = checker.getSymbolAtLocation(expr);
|
|
1815
|
+
if (!symbol)
|
|
1816
|
+
return undefined;
|
|
1817
|
+
const hops = reExportHopsOf(checker, symbol);
|
|
1818
|
+
const hop = deepestPackageHop(hops);
|
|
1819
|
+
return hop === undefined ? undefined : `${hop.specifier}.${hop.name}`;
|
|
1820
|
+
}
|
|
1821
|
+
/**
|
|
1822
|
+
* Every `from "<specifier>"` between an identifier's binding and the
|
|
1823
|
+
* declaration behind it, outermost first. Stops at the first declaration that
|
|
1824
|
+
* is not an import/export specifier — that is where the binding is really
|
|
1825
|
+
* declared — and on a cycle, which a malformed re-export can produce.
|
|
1826
|
+
*/
|
|
1827
|
+
function reExportHopsOf(checker, symbol) {
|
|
1828
|
+
const hops = [];
|
|
1829
|
+
const seen = new Set();
|
|
1830
|
+
let current = symbol;
|
|
1831
|
+
while (current && !seen.has(current)) {
|
|
1832
|
+
seen.add(current);
|
|
1833
|
+
const hop = reExportHopOf(current.declarations?.[0]);
|
|
1834
|
+
if (hop)
|
|
1835
|
+
hops.push(hop);
|
|
1836
|
+
// Only an alias has an immediate target; asking a non-alias asserts.
|
|
1837
|
+
if ((current.flags & ts.SymbolFlags.Alias) === 0)
|
|
1838
|
+
break;
|
|
1839
|
+
current = checker.getImmediateAliasedSymbol(current);
|
|
1840
|
+
}
|
|
1841
|
+
return hops;
|
|
1842
|
+
}
|
|
1843
|
+
function reExportHopOf(decl) {
|
|
1844
|
+
if (!decl)
|
|
1845
|
+
return undefined;
|
|
1846
|
+
if (ts.isImportSpecifier(decl)) {
|
|
1847
|
+
const importDecl = decl.parent.parent.parent;
|
|
1848
|
+
if (!ts.isImportDeclaration(importDecl) || !ts.isStringLiteral(importDecl.moduleSpecifier)) {
|
|
1849
|
+
return undefined;
|
|
1850
|
+
}
|
|
1851
|
+
return {
|
|
1852
|
+
specifier: importDecl.moduleSpecifier.text,
|
|
1853
|
+
name: decl.propertyName?.text ?? decl.name.text,
|
|
1854
|
+
};
|
|
1855
|
+
}
|
|
1856
|
+
// `export { x } from "m"`. A local re-export (`export { x }`, no `from`) has
|
|
1857
|
+
// no specifier of its own and contributes no hop — the chain walks past it
|
|
1858
|
+
// to whatever declares `x`.
|
|
1859
|
+
if (ts.isExportSpecifier(decl)) {
|
|
1860
|
+
const exportDecl = decl.parent.parent;
|
|
1861
|
+
if (!ts.isExportDeclaration(exportDecl) ||
|
|
1862
|
+
!exportDecl.moduleSpecifier ||
|
|
1863
|
+
!ts.isStringLiteral(exportDecl.moduleSpecifier)) {
|
|
1864
|
+
return undefined;
|
|
1865
|
+
}
|
|
1866
|
+
return {
|
|
1867
|
+
specifier: exportDecl.moduleSpecifier.text,
|
|
1868
|
+
name: decl.propertyName?.text ?? decl.name.text,
|
|
1869
|
+
};
|
|
1870
|
+
}
|
|
1871
|
+
return undefined;
|
|
1872
|
+
}
|
|
1873
|
+
/**
|
|
1874
|
+
* Which hop names the binding for stub-matching purposes: the deepest one with
|
|
1875
|
+
* a *bare* specifier, and otherwise the first.
|
|
1876
|
+
*
|
|
1877
|
+
* Deepest is not simply right. A package's own types re-export internally
|
|
1878
|
+
* (`export { helper } from "./internal.js"` inside `node_modules/pkg`), and the
|
|
1879
|
+
* deepest hop there is a path inside the package, which means nothing outside
|
|
1880
|
+
* it — `pkg.helper` is the name. A bare specifier, by contrast, always names a
|
|
1881
|
+
* package or a Node.js builtin, which is exactly what the bundled tables are
|
|
1882
|
+
* keyed on, so the deepest bare specifier is the one that crossed the last
|
|
1883
|
+
* real boundary.
|
|
1884
|
+
*/
|
|
1885
|
+
function deepestPackageHop(hops) {
|
|
1886
|
+
for (let i = hops.length - 1; i >= 0; i--) {
|
|
1887
|
+
const hop = hops[i];
|
|
1888
|
+
if (hop && !hop.specifier.startsWith("."))
|
|
1889
|
+
return hop;
|
|
1890
|
+
}
|
|
1891
|
+
return hops[0];
|
|
1892
|
+
}
|
|
1893
|
+
function moduleSpecifierOf(checker, expr) {
|
|
1894
|
+
if (!ts.isIdentifier(expr))
|
|
1895
|
+
return undefined;
|
|
1896
|
+
const symbol = checker.getSymbolAtLocation(expr);
|
|
1897
|
+
const decl = symbol?.declarations?.[0];
|
|
1898
|
+
if (!decl)
|
|
1899
|
+
return undefined;
|
|
1900
|
+
if (ts.isNamespaceImport(decl)) {
|
|
1901
|
+
const importDecl = decl.parent.parent;
|
|
1902
|
+
if (ts.isImportDeclaration(importDecl) && ts.isStringLiteral(importDecl.moduleSpecifier)) {
|
|
1903
|
+
return importDecl.moduleSpecifier.text;
|
|
1904
|
+
}
|
|
1905
|
+
}
|
|
1906
|
+
// A default import: `import fs from "node:fs"` — `decl` here is the
|
|
1907
|
+
// ImportClause itself (its `name` is the default binding), unlike a named
|
|
1908
|
+
// import where `decl` is an ImportSpecifier under the clause's
|
|
1909
|
+
// NamedImports. `ts.isImportClause(decl.parent)` would never match a
|
|
1910
|
+
// value import (NamespaceImport is caught above; ImportSpecifier's parent
|
|
1911
|
+
// is NamedImports, not ImportClause).
|
|
1912
|
+
if (ts.isImportClause(decl)) {
|
|
1913
|
+
const importDecl = decl.parent;
|
|
1914
|
+
if (ts.isImportDeclaration(importDecl) && ts.isStringLiteral(importDecl.moduleSpecifier)) {
|
|
1915
|
+
return importDecl.moduleSpecifier.text;
|
|
1916
|
+
}
|
|
1917
|
+
}
|
|
1918
|
+
return undefined;
|
|
1919
|
+
}
|
|
1920
|
+
// ---- positions ------------------------------------------------------------
|
|
1921
|
+
function locationOf(absoluteRoot, sourceFile, node) {
|
|
1922
|
+
const start = sourceFile.getLineAndCharacterOfPosition(node.getStart(sourceFile));
|
|
1923
|
+
const end = sourceFile.getLineAndCharacterOfPosition(node.getEnd());
|
|
1924
|
+
return {
|
|
1925
|
+
// Relative to the project root passed to `ambit check` (core/location.ts's
|
|
1926
|
+
// contract) — never absolute: it would leak the local filesystem layout
|
|
1927
|
+
// into NDJSON output and make `via[].file`/`location.file` inconsistent
|
|
1928
|
+
// with the already-relative `via[].symbol` (DESIGN.md §5.1 example uses
|
|
1929
|
+
// "src/tax.ts", not an absolute path).
|
|
1930
|
+
file: relativePath(absoluteRoot, sourceFile),
|
|
1931
|
+
line: start.line + 1,
|
|
1932
|
+
col: start.character + 1,
|
|
1933
|
+
endLine: end.line + 1,
|
|
1934
|
+
endCol: end.character + 1,
|
|
1935
|
+
};
|
|
1936
|
+
}
|