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.
Files changed (96) hide show
  1. package/CHANGELOG.md +64 -0
  2. package/LICENSE +21 -0
  3. package/README.md +403 -0
  4. package/dist/checker/authority.d.ts +13 -0
  5. package/dist/checker/authority.js +87 -0
  6. package/dist/checker/backend/legacy-ts.d.ts +26 -0
  7. package/dist/checker/backend/legacy-ts.js +1936 -0
  8. package/dist/checker/config.d.ts +84 -0
  9. package/dist/checker/config.js +391 -0
  10. package/dist/checker/coverage.d.ts +78 -0
  11. package/dist/checker/coverage.js +84 -0
  12. package/dist/checker/diagnose.d.ts +89 -0
  13. package/dist/checker/diagnose.js +734 -0
  14. package/dist/checker/index.d.ts +8 -0
  15. package/dist/checker/index.js +8 -0
  16. package/dist/checker/init.d.ts +38 -0
  17. package/dist/checker/init.js +205 -0
  18. package/dist/checker/propagate.d.ts +69 -0
  19. package/dist/checker/propagate.js +259 -0
  20. package/dist/checker/summarize.d.ts +27 -0
  21. package/dist/checker/summarize.js +411 -0
  22. package/dist/cli/analyze.d.ts +33 -0
  23. package/dist/cli/analyze.js +98 -0
  24. package/dist/cli/approvals.d.ts +28 -0
  25. package/dist/cli/approvals.js +55 -0
  26. package/dist/cli/diff.d.ts +66 -0
  27. package/dist/cli/diff.js +235 -0
  28. package/dist/cli/github.d.ts +33 -0
  29. package/dist/cli/github.js +41 -0
  30. package/dist/cli/main.d.ts +8 -0
  31. package/dist/cli/main.js +385 -0
  32. package/dist/cli/worktree.d.ts +75 -0
  33. package/dist/cli/worktree.js +154 -0
  34. package/dist/config.d.ts +12 -0
  35. package/dist/config.js +10 -0
  36. package/dist/core/approvals.d.ts +82 -0
  37. package/dist/core/approvals.js +0 -0
  38. package/dist/core/authority-diff.d.ts +98 -0
  39. package/dist/core/authority-diff.js +209 -0
  40. package/dist/core/authority.d.ts +109 -0
  41. package/dist/core/authority.js +50 -0
  42. package/dist/core/backend.d.ts +355 -0
  43. package/dist/core/backend.js +1 -0
  44. package/dist/core/budget.d.ts +61 -0
  45. package/dist/core/budget.js +95 -0
  46. package/dist/core/capability.d.ts +53 -0
  47. package/dist/core/capability.js +117 -0
  48. package/dist/core/config.d.ts +59 -0
  49. package/dist/core/config.js +10 -0
  50. package/dist/core/diagnostic.d.ts +126 -0
  51. package/dist/core/diagnostic.js +13 -0
  52. package/dist/core/effects.d.ts +39 -0
  53. package/dist/core/effects.js +72 -0
  54. package/dist/core/index.d.ts +13 -0
  55. package/dist/core/index.js +13 -0
  56. package/dist/core/location.d.ts +15 -0
  57. package/dist/core/location.js +1 -0
  58. package/dist/core/sql.d.ts +22 -0
  59. package/dist/core/sql.js +38 -0
  60. package/dist/core/summary.d.ts +240 -0
  61. package/dist/core/summary.js +8 -0
  62. package/dist/core/symbol-id.d.ts +25 -0
  63. package/dist/core/symbol-id.js +23 -0
  64. package/dist/index.d.ts +14 -0
  65. package/dist/index.js +14 -0
  66. package/dist/runtime/child-process.d.ts +29 -0
  67. package/dist/runtime/child-process.js +124 -0
  68. package/dist/runtime/context.d.ts +37 -0
  69. package/dist/runtime/context.js +8 -0
  70. package/dist/runtime/enforce.d.ts +52 -0
  71. package/dist/runtime/enforce.js +95 -0
  72. package/dist/runtime/fs.d.ts +46 -0
  73. package/dist/runtime/fs.js +188 -0
  74. package/dist/runtime/hono.d.ts +55 -0
  75. package/dist/runtime/hono.js +68 -0
  76. package/dist/runtime/index.d.ts +71 -0
  77. package/dist/runtime/index.js +126 -0
  78. package/dist/runtime/next.d.ts +95 -0
  79. package/dist/runtime/next.js +60 -0
  80. package/dist/runtime/pg.d.ts +48 -0
  81. package/dist/runtime/pg.js +122 -0
  82. package/dist/stubs/constructors.d.ts +34 -0
  83. package/dist/stubs/constructors.js +111 -0
  84. package/dist/stubs/data-clients.d.ts +9 -0
  85. package/dist/stubs/data-clients.js +109 -0
  86. package/dist/stubs/http-capabilities.d.ts +15 -0
  87. package/dist/stubs/http-capabilities.js +70 -0
  88. package/dist/stubs/mutating-builtins.d.ts +1 -0
  89. package/dist/stubs/mutating-builtins.js +48 -0
  90. package/dist/stubs/node-builtins.d.ts +2 -0
  91. package/dist/stubs/node-builtins.js +77 -0
  92. package/dist/stubs/pure-builtins.d.ts +1 -0
  93. package/dist/stubs/pure-builtins.js +89 -0
  94. package/docs/diagnostics/README.md +519 -0
  95. package/docs/limitations.md +712 -0
  96. 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
+ }