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,411 @@
1
+ import { budgetFrom, capabilitySetsEqual, DEFAULT_ON_EXCEED, effectSetOf, effectSetsEqual, emptyEffectSet, formatBudget, formatCapability, isKnownEffect, KNOWN_EFFECTS, parseBudgetTag, parseCapabilitiesTag, parseCapability, } from "../core/index.js";
2
+ import { isConstructorKey, isKnownPureConstructor, lookupConstructorEffect, } from "../stubs/constructors.js";
3
+ import { lookupClientEffects } from "../stubs/data-clients.js";
4
+ import { lookupHttpCapability } from "../stubs/http-capabilities.js";
5
+ import { lookupStubEffect } from "../stubs/node-builtins.js";
6
+ import { isKnownPureBuiltin } from "../stubs/pure-builtins.js";
7
+ /**
8
+ * Turn a backend's raw extraction into Ambit's own analysis representation
9
+ * (DESIGN.md §3.4 layer 2), independent of which backend produced it.
10
+ */
11
+ export function summarizeExtractedFiles(files, config) {
12
+ const aliases = config?.effectAliases;
13
+ const summaries = [];
14
+ for (const file of files) {
15
+ const specs = specContracts(file.runtimeWrappers);
16
+ for (const fn of file.functions) {
17
+ const jsDoc = {
18
+ declared: parseDeclaredEffects(fn.jsDoc, aliases),
19
+ capabilities: parseDeclaredCapabilities(fn.jsDoc),
20
+ budget: parseDeclaredBudget(fn.jsDoc),
21
+ boundary: parseDeclaredBoundary(fn.jsDoc),
22
+ entrypoint: fn.jsDoc?.tags.has("entrypoint") ?? false,
23
+ };
24
+ const declaredContract = config?.contractFor(fn.id);
25
+ const merged = mergeContract(jsDoc, declaredContract, specs.get(fn.id), aliases);
26
+ summaries.push({
27
+ id: fn.id,
28
+ location: fn.location,
29
+ tagLocations: fn.jsDoc?.tagLocations ?? new Map(),
30
+ declarationStart: fn.declarationStart,
31
+ ...(fn.jsDocRange ? { jsDocRange: fn.jsDocRange } : {}),
32
+ ...(fn.implicitConstructor ? { implicitConstructor: true } : {}),
33
+ ...(fn.configOnly ? { configOnly: true } : {}),
34
+ ...merged,
35
+ calls: fn.calls.map(toCall),
36
+ });
37
+ }
38
+ }
39
+ return summaries;
40
+ }
41
+ /**
42
+ * The declarations the registrations in one file make about the handlers they
43
+ * name (DESIGN.md §4.4).
44
+ *
45
+ * Only a registration whose `handler` is a declaration in this same file says
46
+ * anything here — one naming a handler from elsewhere has no summary to attach
47
+ * to, and stays `AMB-W004`'s business. A half the source does not fix as a
48
+ * literal is left out, so it is `AMB-W004` too rather than a declaration
49
+ * invented from a value nobody can read.
50
+ *
51
+ * The first registration naming a handler is the one that declares for it.
52
+ * A second one is not merged: whatever it says is compared against this one's
53
+ * declaration like any other spec, so a pair that disagrees is `AMB-E010` /
54
+ * `AMB-E011` rather than a silent last-write-wins.
55
+ */
56
+ function specContracts(wrappers) {
57
+ const contracts = new Map();
58
+ for (const wrapper of wrappers) {
59
+ const handler = wrapper.handler;
60
+ if (handler === undefined || contracts.has(handler))
61
+ continue;
62
+ const capabilities = wrapper.capabilities ? capabilitySetOf(wrapper.capabilities) : undefined;
63
+ const budget = wrapper.budget?.kind === "literal" ? budgetFrom(wrapper.budget) : undefined;
64
+ contracts.set(handler, {
65
+ ...(capabilities ? { capabilities } : {}),
66
+ ...(budget ? { budget } : {}),
67
+ });
68
+ }
69
+ return contracts;
70
+ }
71
+ /**
72
+ * Combine a JSDoc contract with the one `ambit.config.ts` declares for the
73
+ * same symbol and the one the registration beside it fixes (DESIGN.md §4.1,
74
+ * §4.4).
75
+ *
76
+ * JSDoc wins per tag — a tag JSDoc declares is the one that propagates, and the
77
+ * other sides fill only the tags JSDoc left out, so `@effects` in the code plus
78
+ * `capabilities` in the spec is one complete contract rather than a conflict.
79
+ * Where JSDoc and config declare the *same* tag and the two parse to different
80
+ * values, the difference is recorded as a {@link ContractDivergence} and
81
+ * reported (`AMB-W005`) rather than resolved silently.
82
+ *
83
+ * A spec is the last side consulted, and needs no divergence record of its own:
84
+ * `diagnoseRuntimeWrappers` compares every literal spec against whatever this
85
+ * merge settled on, so a spec disagreeing with a JSDoc *or* a config
86
+ * declaration is still `AMB-E010` / `AMB-E011` — an error, not a warning. Only
87
+ * the case where no other side declared anything is silent, and there the two
88
+ * being compared are the same declaration.
89
+ *
90
+ * A JSDoc tag that failed to parse still counts as "JSDoc declared this":
91
+ * neither config nor a spec must quietly stand in for a tag the author wrote
92
+ * and misspelled, or `AMB-E002` would be reported against a contract that is
93
+ * not the one in force.
94
+ */
95
+ function mergeContract(jsDoc, config, spec, aliases) {
96
+ const divergences = [];
97
+ const configEffects = config?.effects ? expandConfigEffects(config.effects, aliases) : undefined;
98
+ const configCapabilities = config?.capabilities
99
+ ? capabilitySetOf(config.capabilities)
100
+ : undefined;
101
+ const configBudget = config?.budget
102
+ ? { ...config.budget, onExceed: config.budget.onExceed ?? DEFAULT_ON_EXCEED }
103
+ : undefined;
104
+ let declared = jsDoc.declared;
105
+ let effectsBy = jsDoc.declared.kind === "declared" ? "jsdoc" : undefined;
106
+ if (configEffects) {
107
+ if (jsDoc.declared.kind === "none") {
108
+ declared = { kind: "declared", effects: configEffects };
109
+ effectsBy = "config";
110
+ }
111
+ else if (jsDoc.declared.kind === "declared" &&
112
+ !effectSetsEqual(jsDoc.declared.effects, configEffects)) {
113
+ divergences.push({
114
+ tag: "effects",
115
+ jsDoc: formatEffects(jsDoc.declared.effects.effects),
116
+ config: formatEffects(configEffects.effects),
117
+ });
118
+ }
119
+ }
120
+ let capabilities = jsDoc.capabilities;
121
+ let capabilitiesBy = jsDoc.capabilities.kind === "declared" ? "jsdoc" : undefined;
122
+ if (configCapabilities) {
123
+ if (jsDoc.capabilities.kind === "none") {
124
+ capabilities = { kind: "declared", capabilities: configCapabilities };
125
+ capabilitiesBy = "config";
126
+ }
127
+ else if (jsDoc.capabilities.kind === "declared" &&
128
+ !capabilitySetsEqual(jsDoc.capabilities.capabilities, configCapabilities)) {
129
+ divergences.push({
130
+ tag: "capabilities",
131
+ jsDoc: formatCapabilities(jsDoc.capabilities.capabilities),
132
+ config: formatCapabilities(configCapabilities),
133
+ });
134
+ }
135
+ }
136
+ if (spec?.capabilities && capabilities.kind === "none") {
137
+ capabilities = { kind: "declared", capabilities: spec.capabilities };
138
+ capabilitiesBy = "spec";
139
+ }
140
+ let budget = jsDoc.budget;
141
+ let budgetBy = jsDoc.budget.kind === "declared" ? "jsdoc" : undefined;
142
+ if (configBudget) {
143
+ if (jsDoc.budget.kind === "none") {
144
+ budget = { kind: "declared", budget: configBudget };
145
+ budgetBy = "config";
146
+ }
147
+ else if (jsDoc.budget.kind === "declared" &&
148
+ formatBudget(jsDoc.budget.budget) !== formatBudget(configBudget)) {
149
+ divergences.push({
150
+ tag: "budget",
151
+ jsDoc: formatBudget(jsDoc.budget.budget),
152
+ config: formatBudget(configBudget),
153
+ });
154
+ }
155
+ }
156
+ if (spec?.budget && budget.kind === "none") {
157
+ budget = { kind: "declared", budget: spec.budget };
158
+ budgetBy = "spec";
159
+ }
160
+ const declaredBy = {
161
+ ...(effectsBy ? { effects: effectsBy } : {}),
162
+ ...(capabilitiesBy ? { capabilities: capabilitiesBy } : {}),
163
+ ...(budgetBy ? { budget: budgetBy } : {}),
164
+ };
165
+ let boundary = jsDoc.boundary;
166
+ if (config?.boundary !== undefined) {
167
+ if (jsDoc.boundary.kind === "none") {
168
+ boundary = { kind: "declared", reason: config.boundary };
169
+ }
170
+ else if (jsDoc.boundary.kind === "declared" && jsDoc.boundary.reason !== config.boundary) {
171
+ divergences.push({
172
+ tag: "boundary",
173
+ jsDoc: jsDoc.boundary.reason,
174
+ config: config.boundary,
175
+ });
176
+ }
177
+ }
178
+ // No `@entrypoint` is not a declaration that the function is *not* one, so
179
+ // only an explicit `entrypoint: false` beside the tag is a disagreement.
180
+ let entrypoint = jsDoc.entrypoint;
181
+ if (config?.entrypoint !== undefined) {
182
+ if (!jsDoc.entrypoint && config.entrypoint)
183
+ entrypoint = true;
184
+ else if (jsDoc.entrypoint && !config.entrypoint) {
185
+ divergences.push({ tag: "entrypoint", jsDoc: "true", config: "false" });
186
+ }
187
+ }
188
+ return {
189
+ declared,
190
+ capabilities,
191
+ budget,
192
+ boundary,
193
+ entrypoint,
194
+ ...(Object.keys(declaredBy).length > 0 ? { declaredBy } : {}),
195
+ ...(divergences.length > 0 ? { divergences } : {}),
196
+ };
197
+ }
198
+ /**
199
+ * A config `effects` list turned into an {@link EffectSet}. Every name is
200
+ * either a standard effect or a user-defined one, which `validateConfig`
201
+ * already guaranteed — a user-defined name expands to the standard effects it
202
+ * stands for, and nothing but standard effects ever leaves this function
203
+ * (DESIGN.md §4.1 (d)).
204
+ */
205
+ function expandConfigEffects(names, aliases) {
206
+ const effects = [];
207
+ for (const name of names) {
208
+ if (isKnownEffect(name))
209
+ effects.push(name);
210
+ else
211
+ effects.push(...(aliases?.get(name) ?? []));
212
+ }
213
+ return effectSetOf(...effects);
214
+ }
215
+ /**
216
+ * A list of capability strings — a config `capabilities` list, or a spec's
217
+ * literal array — turned into a {@link CapabilitySet}.
218
+ *
219
+ * A token that does not parse is dropped rather than guessed at. It is not
220
+ * lost: `validateConfig` rejects a malformed config token, and a malformed
221
+ * spec token leaves the set narrower than the text the spec wrote, which
222
+ * `diagnoseRuntimeWrappers` then reports as `AMB-E010` against that text.
223
+ */
224
+ function capabilitySetOf(tokens) {
225
+ const capabilities = tokens
226
+ .map(parseCapability)
227
+ .filter((capability) => capability !== undefined);
228
+ return { capabilities, unknown: false };
229
+ }
230
+ function formatEffects(effects) {
231
+ const ordered = KNOWN_EFFECTS.filter((effect) => effects.has(effect));
232
+ return ordered.length === 0 ? "pure" : ordered.join(", ");
233
+ }
234
+ function formatCapabilities(set) {
235
+ return set.capabilities.map(formatCapability).join(", ");
236
+ }
237
+ function parseDeclaredEffects(jsDoc, aliases) {
238
+ const tagText = jsDoc?.tags.get("effects");
239
+ if (tagText === undefined)
240
+ return { kind: "none" };
241
+ const effects = parseEffectsTag(tagText, aliases);
242
+ if (effects === undefined)
243
+ return { kind: "invalid", raw: tagText };
244
+ return { kind: "declared", effects };
245
+ }
246
+ function parseDeclaredCapabilities(jsDoc) {
247
+ const tagText = jsDoc?.tags.get("capabilities");
248
+ if (tagText === undefined)
249
+ return { kind: "none" };
250
+ const capabilities = parseCapabilitiesTag(tagText);
251
+ if (capabilities === undefined)
252
+ return { kind: "invalid", raw: tagText };
253
+ return { kind: "declared", capabilities };
254
+ }
255
+ function parseDeclaredBudget(jsDoc) {
256
+ const tagText = jsDoc?.tags.get("budget");
257
+ if (tagText === undefined)
258
+ return { kind: "none" };
259
+ const budget = parseBudgetTag(tagText);
260
+ if (budget === undefined)
261
+ return { kind: "invalid", raw: tagText };
262
+ return { kind: "declared", budget };
263
+ }
264
+ /**
265
+ * `@boundary reason="..."`. DESIGN.md §4.6 makes `reason` mandatory, so a tag
266
+ * without one does not declare a boundary — it declares an unexplained hole,
267
+ * which is exactly what the tag exists to prevent. Both quoted and bare
268
+ * `reason=` forms are accepted; anything else is `"invalid"`.
269
+ */
270
+ export function parseBoundaryTag(text) {
271
+ const match = /^reason\s*=\s*(?:"([^"]*)"|'([^']*)'|(\S+))\s*$/.exec(text.trim());
272
+ const reason = match?.[1] ?? match?.[2] ?? match?.[3];
273
+ return reason !== undefined && reason.trim().length > 0 ? reason.trim() : undefined;
274
+ }
275
+ function parseDeclaredBoundary(jsDoc) {
276
+ const tagText = jsDoc?.tags.get("boundary");
277
+ if (tagText === undefined)
278
+ return { kind: "none" };
279
+ const reason = parseBoundaryTag(tagText);
280
+ if (reason === undefined)
281
+ return { kind: "invalid", raw: tagText };
282
+ return { kind: "declared", reason };
283
+ }
284
+ /**
285
+ * `pure` is the literal spelling for the empty set (DESIGN.md §4.2 rule 2).
286
+ * Returns `undefined` when a token is neither `pure` nor a known effect (a
287
+ * typo, e.g. `@effects netwrok`) — such a declaration must not silently
288
+ * collapse to an empty (`pure`) contract. The caller reports this as
289
+ * `AMB-E002` (`diagnose.ts`) instead of the tag's real, but broken, contract.
290
+ */
291
+ export function parseEffectsTag(text, aliases) {
292
+ const trimmed = text.trim();
293
+ if (trimmed === "pure")
294
+ return emptyEffectSet();
295
+ const tokens = trimmed.split(",").map((token) => token.trim());
296
+ const effects = [];
297
+ for (const token of tokens) {
298
+ if (isKnownEffect(token)) {
299
+ effects.push(token);
300
+ continue;
301
+ }
302
+ // A user-defined name is usable from `@effects` too, not only from
303
+ // config (DESIGN.md §4.2: "User-defined effects can be declared in
304
+ // `ambit.config.ts` as combinations of standard effects"). It expands here, so
305
+ // nothing downstream ever sees a name that is not a standard effect
306
+ // (§4.1 (d)).
307
+ const expansion = aliases?.get(token);
308
+ if (expansion === undefined)
309
+ return undefined;
310
+ effects.push(...expansion);
311
+ }
312
+ return effectSetOf(...effects);
313
+ }
314
+ function toCall(site) {
315
+ if (site.mutation) {
316
+ return {
317
+ kind: "mutation",
318
+ location: site.location,
319
+ escaping: site.mutation.escaping,
320
+ ...(site.mutation.qualifiedName ? { qualifiedName: site.mutation.qualifiedName } : {}),
321
+ ...(site.mutation.unknownCallback ? { unknownCallback: true } : {}),
322
+ };
323
+ }
324
+ if (site.resolvedCallee) {
325
+ return { kind: "resolved", location: site.location, callee: site.resolvedCallee };
326
+ }
327
+ if (site.calleeQualifiedName) {
328
+ // A construction is keyed in its own namespace (`src/stubs/
329
+ // constructors.ts`) — `new URL(...)` and `URL(...)` are different
330
+ // operations and must never share a table entry.
331
+ if (isConstructorKey(site.calleeQualifiedName)) {
332
+ return toConstructorCall(site, site.calleeQualifiedName);
333
+ }
334
+ const effects = stubEffectsFor(site.calleeQualifiedName, site.literalArguments);
335
+ if (effects) {
336
+ const required = lookupHttpCapability(site.calleeQualifiedName, site.literalArguments);
337
+ return {
338
+ kind: "stub",
339
+ location: site.location,
340
+ effects,
341
+ qualifiedName: site.calleeQualifiedName,
342
+ ...(required?.capability ? { requiredCapability: required.capability } : {}),
343
+ ...(required?.targetUnknown ? { capabilityTargetUnknown: true } : {}),
344
+ };
345
+ }
346
+ // A named call that didn't resolve to a project function and doesn't
347
+ // match a known stub (e.g. a third-party library call): unresolved, not
348
+ // "no effect" (DESIGN.md §3.4 — never turn an unanalyzed call into
349
+ // "violation-free"). The connector layer may already know a more
350
+ // specific reason than the residual "unresolved-symbol" (see
351
+ // `CallSite.unresolvedReason`'s doc comment).
352
+ return {
353
+ kind: "unresolved",
354
+ location: site.location,
355
+ reason: site.unresolvedReason ?? "unresolved-symbol",
356
+ qualifiedName: site.calleeQualifiedName,
357
+ };
358
+ }
359
+ if (site.pureBuiltinName) {
360
+ // A callback passed by reference is never walked, so it can't be
361
+ // trusted as pure even when the method name itself is allowlisted
362
+ // (CallSite.callbackByReference's doc comment).
363
+ if (isKnownPureBuiltin(site.pureBuiltinName) && !site.callbackByReference) {
364
+ return { kind: "known-pure", location: site.location, qualifiedName: site.pureBuiltinName };
365
+ }
366
+ return {
367
+ kind: "unresolved",
368
+ location: site.location,
369
+ reason: site.unresolvedReason ?? "unresolved-symbol",
370
+ qualifiedName: site.pureBuiltinName,
371
+ };
372
+ }
373
+ return {
374
+ kind: "unresolved",
375
+ location: site.location,
376
+ reason: site.unresolvedReason ?? "unresolved-symbol",
377
+ };
378
+ }
379
+ /**
380
+ * The bundled effect tables, in one lookup: the fixed table for globals and
381
+ * Node.js builtins (`src/stubs/node-builtins.ts`), then the database/LLM
382
+ * client table (`src/stubs/data-clients.ts`), whose answer can depend on the
383
+ * call's own literal arguments. The two key spaces do not overlap — a builtin
384
+ * key never has a client class in it — so the order only decides which lookup
385
+ * runs first, not which answer wins.
386
+ */
387
+ function stubEffectsFor(qualifiedName, literalArguments) {
388
+ const builtin = lookupStubEffect(qualifiedName);
389
+ if (builtin)
390
+ return [builtin];
391
+ return lookupClientEffects(qualifiedName, literalArguments);
392
+ }
393
+ function toConstructorCall(site, qualifiedName) {
394
+ const withoutArguments = site.constructedWithoutArguments === true;
395
+ const effect = lookupConstructorEffect(qualifiedName, withoutArguments);
396
+ if (effect) {
397
+ return { kind: "stub", location: site.location, effects: [effect], qualifiedName };
398
+ }
399
+ // A callback passed by reference is never walked, so an allowlisted
400
+ // constructor that runs one (`new Promise(namedExecutor)`) cannot be
401
+ // trusted as effect-free (DESIGN.md §4.2 rule 4).
402
+ if (isKnownPureConstructor(qualifiedName, withoutArguments) && !site.callbackByReference) {
403
+ return { kind: "known-pure", location: site.location, qualifiedName };
404
+ }
405
+ return {
406
+ kind: "unresolved",
407
+ location: site.location,
408
+ reason: site.unresolvedReason ?? "unresolved-symbol",
409
+ qualifiedName,
410
+ };
411
+ }
@@ -0,0 +1,33 @@
1
+ import type { CoverageReport } from "../checker/coverage.ts";
2
+ import type { AuthorityRecord, Diagnostic } from "../core/index.ts";
3
+ /** One run of the analysis over one directory. */
4
+ export interface Analysis {
5
+ readonly diagnostics: readonly Diagnostic[];
6
+ /** Per-function authority (DESIGN.md §5.1), the input `ambit diff` compares. */
7
+ readonly authority: readonly AuthorityRecord[];
8
+ readonly coverage: CoverageReport;
9
+ }
10
+ export interface AnalyzeOptions {
11
+ /** `init`: report contract proposals instead of violations. */
12
+ readonly propose?: boolean;
13
+ /** `init --config`: also propose `ambit.config.ts` entries (DESIGN.md §4.1 (a)). */
14
+ readonly proposeConfig?: boolean;
15
+ /** `--strict`: promote the `unknown` warnings to errors (DESIGN.md §4.2 rule 3). */
16
+ readonly strict?: boolean;
17
+ }
18
+ /**
19
+ * Load the config, extract, summarize, propagate, diagnose and count — the
20
+ * whole single-directory pipeline, in one place.
21
+ *
22
+ * Extracted from `main` because `ambit diff` runs it twice, once per tree,
23
+ * and the two runs must be the same run: a base side that analyzed a
24
+ * directory even slightly differently would report the difference as a change
25
+ * in authority.
26
+ *
27
+ * Throws on any failure. A caller turns that into exit 2 — an analysis that
28
+ * could not run must never be reported as "checked, nothing wrong"
29
+ * (DESIGN.md §3.4).
30
+ *
31
+ * @effects fs_read
32
+ */
33
+ export declare function analyze(dir: string, options?: AnalyzeOptions): Promise<Analysis>;
@@ -0,0 +1,98 @@
1
+ import { buildAuthorityRecords, computeCoverage, diagnose, diagnoseContractDivergence, diagnoseRuntimeWrappers, diagnoseUncarriedContracts, diagnoseUnmatchedConfigKeys, legacyTsBackend, loadConfig, propagate, proposeContracts, resolveConfig, summarizeExtractedFiles, } from "../checker/index.js";
2
+ /**
3
+ * Load the config, extract, summarize, propagate, diagnose and count — the
4
+ * whole single-directory pipeline, in one place.
5
+ *
6
+ * Extracted from `main` because `ambit diff` runs it twice, once per tree,
7
+ * and the two runs must be the same run: a base side that analyzed a
8
+ * directory even slightly differently would report the difference as a change
9
+ * in authority.
10
+ *
11
+ * Throws on any failure. A caller turns that into exit 2 — an analysis that
12
+ * could not run must never be reported as "checked, nothing wrong"
13
+ * (DESIGN.md §3.4).
14
+ *
15
+ * @effects fs_read
16
+ */
17
+ export async function analyze(dir, options = {}) {
18
+ // Loaded before extraction so a broken config stops the run before any
19
+ // work is reported (DESIGN.md §3.4): a config that could not be read must
20
+ // never come out as "checked, no violations".
21
+ const loaded = await loadConfig(dir);
22
+ const config = loaded ? resolveConfig(loaded, dir) : undefined;
23
+ const project = await legacyTsBackend.extractProject(dir);
24
+ // No extracted function anywhere means "nothing analyzable was found"
25
+ // (zero .ts files, or every function-like node was skipped) — that must
26
+ // not read the same as "checked, no violations" (DESIGN.md §3.4). Counted
27
+ // over functions rather than over `files`, because a file can now be
28
+ // pushed for its `withAmbit` wrappers alone.
29
+ const functionsFound = project.files.reduce((total, file) => total + file.functions.length, 0);
30
+ if (functionsFound === 0) {
31
+ throw new Error(`no analyzable functions found under ${dir}`);
32
+ }
33
+ const summaries = summarizeExtractedFiles(project.files, config);
34
+ const state = propagate(summaries);
35
+ const engine = { name: legacyTsBackend.name, version: legacyTsBackend.version };
36
+ const diagnostics = options.propose
37
+ ? proposeContracts(state, engine, options.proposeConfig ? configTarget(config, dir) : undefined)
38
+ : applyStrict([
39
+ ...diagnose(state, engine),
40
+ ...diagnoseUncarriedContracts(project.uncarriedContracts, engine),
41
+ ...diagnoseRuntimeWrappers(project.files.flatMap((file) => file.runtimeWrappers), state, engine),
42
+ ...(config ? diagnoseContractDivergence(state, config.displayPath, engine) : []),
43
+ ...(config
44
+ ? diagnoseUnmatchedConfigKeys(config.unmatchedExactKeys(), config.displayPath, config.sourceText, engine)
45
+ : []),
46
+ ], options.strict ?? false, config);
47
+ return {
48
+ diagnostics,
49
+ authority: buildAuthorityRecords(state),
50
+ coverage: computeCoverage({
51
+ filesAnalyzed: project.files.length,
52
+ skippedFunctions: project.skippedFunctions,
53
+ summaries,
54
+ state,
55
+ }),
56
+ };
57
+ }
58
+ /**
59
+ * The diagnostics `--strict` promotes to errors: the two that say "analysis
60
+ * reached something it could not resolve" (DESIGN.md §4.2 rule 3 — "Ambit's
61
+ * `strict: true` can promote it to an error"). Deliberately not every warning:
62
+ * `--strict` means "an unverified path is not acceptable here", which is a
63
+ * different claim from promoting, say, an entrypoint's missing capability set.
64
+ */
65
+ const STRICT_PROMOTED_IDS = new Set(["AMB-W001", "AMB-W003"]);
66
+ /**
67
+ * `--strict` promotes everywhere; `strict` in `ambit.config.ts` promotes only
68
+ * inside the globs it lists (DESIGN.md §4.3: "`strict` can be set per
69
+ * directory in `ambit.config.ts`. Tighten new code while leaving legacy code
70
+ * at warnings"). The two are a union, so `--strict` on the command line is never
71
+ * narrowed by a config that lists fewer directories.
72
+ *
73
+ * Matched on the diagnostic's own file, which is why the config-level
74
+ * diagnostics (AMB-W005/W006) are unaffected in practice: theirs is the
75
+ * config file, which no `strict` glob names.
76
+ */
77
+ function applyStrict(diagnostics, strict, config) {
78
+ if (!strict && config === undefined)
79
+ return diagnostics;
80
+ return diagnostics.map((diagnostic) => STRICT_PROMOTED_IDS.has(diagnostic.id) &&
81
+ (strict || config?.isStrictFile(diagnostic.location.file) === true)
82
+ ? { ...diagnostic, severity: "error" }
83
+ : diagnostic);
84
+ }
85
+ /**
86
+ * Where `ambit init --config` should write, and what is already there — the
87
+ * config file's path and text.
88
+ *
89
+ * `undefined` when no config file was found: §4.1's patch is an *append* to a
90
+ * `contracts` block, and inventing a whole file (with a `defineConfig` import
91
+ * whose specifier depends on how the consumer installed Ambit) is not a patch
92
+ * this command can generate safely (§5.3).
93
+ */
94
+ function configTarget(config, rootDir) {
95
+ if (!config)
96
+ return undefined;
97
+ return { path: config.displayPath, source: config.sourceText, rootDir };
98
+ }
@@ -0,0 +1,28 @@
1
+ import { type ParsedApprovals } from "../core/index.ts";
2
+ /** The ledger that governs `dir`, and what it said. */
3
+ export interface LoadedApprovals {
4
+ /** Absolute path to the ledger, or `undefined` when there is none. */
5
+ readonly filePath?: string;
6
+ readonly parsed: ParsedApprovals;
7
+ }
8
+ /**
9
+ * Walk up from `startDir` looking for the ledger, stopping after the first
10
+ * directory that holds a `package.json` or `.git`.
11
+ *
12
+ * The same walk `ambit.config.ts` uses (DESIGN.md §4.1 (c)), so the two files
13
+ * agree on where the project starts — a ledger picked up from outside the
14
+ * project would approve increases in a tree nobody reviewed.
15
+ */
16
+ export declare function findApprovalsFile(startDir: string): string | undefined;
17
+ /**
18
+ * Load the ledger governing `dir`. No file is not an error: a repository with
19
+ * no increases to approve never needs one, and the base side of the very
20
+ * comparison that introduces the mechanism has none by definition.
21
+ *
22
+ * A file that exists and cannot be read *is* an error. Treating it as an empty
23
+ * ledger would silently withdraw every approval in it, which reads as "these
24
+ * increases were never approved" (DESIGN.md §3.4).
25
+ *
26
+ * @effects fs_read
27
+ */
28
+ export declare function loadApprovals(dir: string): LoadedApprovals;
@@ -0,0 +1,55 @@
1
+ /**
2
+ * Finding and reading the approval ledger `ambit.approvals.md`
3
+ * (DESIGN.md §6.3).
4
+ *
5
+ * The reading of it — what the lines mean, and which of them are in force —
6
+ * is `src/core/approvals.ts`, which touches no file. This module is only the
7
+ * two lines of filesystem that stand between them, and it runs once per side
8
+ * of a `diff`.
9
+ */
10
+ import fs from "node:fs";
11
+ import path from "node:path";
12
+ import { APPROVALS_FILENAME, parseApprovals } from "../core/index.js";
13
+ const EMPTY = { approvals: [], malformed: [] };
14
+ /**
15
+ * Walk up from `startDir` looking for the ledger, stopping after the first
16
+ * directory that holds a `package.json` or `.git`.
17
+ *
18
+ * The same walk `ambit.config.ts` uses (DESIGN.md §4.1 (c)), so the two files
19
+ * agree on where the project starts — a ledger picked up from outside the
20
+ * project would approve increases in a tree nobody reviewed.
21
+ */
22
+ export function findApprovalsFile(startDir) {
23
+ let dir = path.resolve(startDir);
24
+ for (;;) {
25
+ const candidate = path.join(dir, APPROVALS_FILENAME);
26
+ if (fs.existsSync(candidate) && fs.statSync(candidate).isFile())
27
+ return candidate;
28
+ if (isProjectBoundary(dir))
29
+ return undefined;
30
+ const parent = path.dirname(dir);
31
+ if (parent === dir)
32
+ return undefined;
33
+ dir = parent;
34
+ }
35
+ }
36
+ function isProjectBoundary(dir) {
37
+ return fs.existsSync(path.join(dir, "package.json")) || fs.existsSync(path.join(dir, ".git"));
38
+ }
39
+ /**
40
+ * Load the ledger governing `dir`. No file is not an error: a repository with
41
+ * no increases to approve never needs one, and the base side of the very
42
+ * comparison that introduces the mechanism has none by definition.
43
+ *
44
+ * A file that exists and cannot be read *is* an error. Treating it as an empty
45
+ * ledger would silently withdraw every approval in it, which reads as "these
46
+ * increases were never approved" (DESIGN.md §3.4).
47
+ *
48
+ * @effects fs_read
49
+ */
50
+ export function loadApprovals(dir) {
51
+ const filePath = findApprovalsFile(dir);
52
+ if (filePath === undefined)
53
+ return { parsed: EMPTY };
54
+ return { filePath, parsed: parseApprovals(fs.readFileSync(filePath, "utf8")) };
55
+ }