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,734 @@
1
+ import { budgetFrom, callLeavesUnknown, displayName, excessCapabilities, excessEffects, formatBudget, formatCapability, KNOWN_EFFECTS, } from "../core/index.js";
2
+ import { capabilityUnknownWitnessChain, capabilityWitnessChain, chainToVia, unknownWitnessChain, witnessChain, } from "./propagate.js";
3
+ /**
4
+ * Compare declared vs. observed effects for every declared function and
5
+ * produce diagnostics (DESIGN.md §5.1). Undeclared functions
6
+ * (`declared.kind === "none"`) are not diagnosed here — see §4.2/§4.3:
7
+ * undeclared is a coverage concern, not a propagation input.
8
+ *
9
+ * `engine` identifies the backend that produced `state` (DESIGN.md §3.4) and
10
+ * is attached to every diagnostic emitted.
11
+ */
12
+ export function diagnose(state, engine) {
13
+ const diagnostics = [];
14
+ for (const propagated of state.values()) {
15
+ diagnostics.push(...diagnoseEffects(propagated, state, engine));
16
+ diagnostics.push(...diagnoseCapabilities(propagated, state, engine));
17
+ diagnostics.push(...diagnoseBoundary(propagated, engine));
18
+ diagnostics.push(...diagnoseBudget(propagated, engine));
19
+ diagnostics.push(...diagnoseEntrypoint(propagated, engine));
20
+ }
21
+ return diagnostics;
22
+ }
23
+ /**
24
+ * The one fix candidate AMB-E001 can generate: widen the `@effects` tag to
25
+ * cover what was observed.
26
+ *
27
+ * There is deliberately no second, contract-preserving candidate. Restoring a
28
+ * declaration means restructuring the code — moving the effectful call to a
29
+ * caller that is allowed to make it — and Ambit cannot generate that patch
30
+ * safely. DESIGN.md §5.3 forbids inventing one for the sake of ranking:
31
+ * "Candidates that cannot be generated are not fabricated for the sake of
32
+ * ranking". So this fix is always
33
+ * `consistentWithContract: false`, and `impact` says what widening costs.
34
+ */
35
+ function buildWidenFix(propagated, declared, excess, state) {
36
+ const { summary } = propagated;
37
+ const tagLocation = summary.tagLocations.get("effects");
38
+ // No recorded tag position means no applicable patch. §5.3: never emit a
39
+ // summary-only candidate as if it were one.
40
+ if (!tagLocation)
41
+ return [];
42
+ const widened = KNOWN_EFFECTS.filter((effect) => declared.has(effect) || excess.has(effect));
43
+ if (widened.length === 0)
44
+ return [];
45
+ const edit = {
46
+ file: tagLocation.file,
47
+ range: toEditRange(tagLocation),
48
+ replacement: `@effects ${widened.join(", ")}`,
49
+ };
50
+ const callers = callersOf(summary.id, state);
51
+ const pureCallersBroken = callers.filter((caller) => {
52
+ const callerDeclared = caller.summary.declared;
53
+ if (callerDeclared.kind !== "declared")
54
+ return false;
55
+ return [...excess].some((effect) => !callerDeclared.effects.effects.has(effect));
56
+ }).length;
57
+ return [
58
+ {
59
+ rank: 1,
60
+ kind: "widen",
61
+ summary: `Allow ${[...excess].join(", ")} in ${displayName(summary.id)}`,
62
+ // Not a probability. The patch is mechanical and always applies; what
63
+ // is uncertain is whether widening is what the author wants, which is
64
+ // exactly what `consistentWithContract: false` is for.
65
+ confidence: 1,
66
+ consistentWithContract: false,
67
+ edits: [edit],
68
+ impact: {
69
+ callersAffected: callers.map((caller) => caller.summary.id),
70
+ pureCallersBroken,
71
+ },
72
+ },
73
+ ];
74
+ }
75
+ /**
76
+ * DESIGN.md §5.3: `location` is 1-based, an edit range 0-based and
77
+ * end-exclusive. `SourceLocation.endLine`/`endCol` are already exclusive, so
78
+ * only the origin shifts.
79
+ */
80
+ function toEditRange(location) {
81
+ return [
82
+ [location.line - 1, location.col - 1],
83
+ [location.endLine - 1, location.endCol - 1],
84
+ ];
85
+ }
86
+ /** Every analyzed function with a resolved call to `id` — who a widened contract newly affects. */
87
+ function callersOf(id, state) {
88
+ return [...state.values()].filter((candidate) => candidate.summary.calls.some((call) => call.kind === "resolved" && call.callee === id));
89
+ }
90
+ function diagnoseEffects(propagated, state, engine) {
91
+ const { summary } = propagated;
92
+ if (summary.declared.kind === "invalid") {
93
+ return [buildInvalidEffectsDiagnostic(propagated, summary.declared.raw, engine)];
94
+ }
95
+ if (summary.declared.kind !== "declared")
96
+ return [];
97
+ const diagnostics = [];
98
+ const declaredEffects = summary.declared.effects;
99
+ const excess = excessEffects(declaredEffects, propagated.observed);
100
+ if (excess.size > 0) {
101
+ diagnostics.push(buildExcessDiagnostic(propagated, declaredEffects.effects, excess, state, engine));
102
+ }
103
+ if (propagated.observed.unknown) {
104
+ diagnostics.push(buildUnknownDiagnostic(propagated, declaredEffects.effects, state, engine));
105
+ }
106
+ return diagnostics;
107
+ }
108
+ /**
109
+ * DESIGN.md §4.4's narrowing rule: a callee may not require a capability its
110
+ * caller
111
+ * does not grant. A function's own declaration is the grant; what its body
112
+ * reaches is the requirement.
113
+ */
114
+ function diagnoseCapabilities(propagated, state, engine) {
115
+ const { summary } = propagated;
116
+ if (summary.capabilities.kind === "invalid") {
117
+ return [
118
+ {
119
+ id: "AMB-E004",
120
+ severity: "error",
121
+ category: "capabilities",
122
+ message: `${displayName(summary.id)} declares @capabilities "${summary.capabilities.raw}", which is not a comma-separated list of <resource>:<action>:<target>`,
123
+ location: summary.location,
124
+ fixes: [],
125
+ docs: "docs/diagnostics/README.md#amb-e004",
126
+ engine,
127
+ },
128
+ ];
129
+ }
130
+ if (summary.capabilities.kind !== "declared")
131
+ return [];
132
+ const granted = summary.capabilities.capabilities.capabilities;
133
+ const excess = excessCapabilities(granted, propagated.required.capabilities);
134
+ const diagnostics = [];
135
+ // DESIGN.md §4.4's static half: a target this function's own body fixes in
136
+ // the source — a literal URL's host — is checked against the grant here, at
137
+ // the call site, rather than folded into the declaration-to-declaration
138
+ // escalation below. The two are different findings: one names a line to
139
+ // change, the other names a callee whose contract is too wide.
140
+ const literalViolations = summary.calls.filter((call) => call.kind === "stub" &&
141
+ call.requiredCapability !== undefined &&
142
+ excessCapabilities(granted, [call.requiredCapability]).length > 0);
143
+ for (const call of literalViolations) {
144
+ if (call.kind !== "stub" || !call.requiredCapability)
145
+ continue;
146
+ diagnostics.push(buildLiteralTargetViolation(propagated, granted, call, engine));
147
+ }
148
+ const reportedLiterally = new Set(literalViolations.map((call) => call.kind === "stub" && call.requiredCapability
149
+ ? formatCapability(call.requiredCapability)
150
+ : ""));
151
+ // A capability reported at a literal call site is dropped from the
152
+ // escalation *only* when nothing else requires it: otherwise the same
153
+ // function's own `fetch("https://elsewhere.example/…")` would hide a callee
154
+ // that declares the very same capability, and fixing the URL would reveal
155
+ // an escalation that was there all along. `capabilityWitness` names that
156
+ // callee, and is empty when the requirement is this body's alone.
157
+ const inheritedExcess = excess.filter((capability) => {
158
+ const text = formatCapability(capability);
159
+ if (!reportedLiterally.has(text))
160
+ return true;
161
+ return capabilityWitnessChain(summary.id, text, state).length > 0;
162
+ });
163
+ if (inheritedExcess.length > 0) {
164
+ diagnostics.push(buildCapabilityEscalation(propagated, granted, inheritedExcess, state, engine));
165
+ }
166
+ if (propagated.required.unknown) {
167
+ const chain = capabilityUnknownWitnessChain(summary.id, state);
168
+ const witness = chain[chain.length - 1];
169
+ const witnessSummary = witness ? state.get(witness)?.summary : undefined;
170
+ // Three different causes reach the same unknown, and saying the wrong one
171
+ // sends a reader hunting for a call that resolved perfectly well.
172
+ const cause = capabilityUnknownCause(propagated, witnessSummary);
173
+ diagnostics.push({
174
+ id: "AMB-W003",
175
+ severity: "warning",
176
+ category: "capabilities",
177
+ message: `${displayName(summary.id)} declares @capabilities but ${cause}, so its capability requirement is not fully known`,
178
+ location: summary.location,
179
+ contract: {
180
+ declared: granted.map(formatCapability),
181
+ required: propagated.required.capabilities.map(formatCapability),
182
+ excess: [],
183
+ via: chainToVia(chain, state),
184
+ },
185
+ fixes: [],
186
+ docs: "docs/diagnostics/README.md#amb-w003",
187
+ engine,
188
+ });
189
+ }
190
+ return diagnostics;
191
+ }
192
+ /**
193
+ * Why a declared capability set is not fully known. A `@boundary` callee that
194
+ * declared nothing, an operation whose target the source does not fix, and a
195
+ * call that could not be resolved are three different situations with three
196
+ * different fixes; naming the wrong one wastes the reader's time.
197
+ *
198
+ * A direct unresolved call wins over a dynamic target: it is the wider hole
199
+ * (an unresolved callee could require anything), and the witness chain is
200
+ * empty for both, so nothing else would say it.
201
+ */
202
+ function capabilityUnknownCause(propagated, witnessSummary) {
203
+ if (witnessSummary?.boundary.kind === "declared") {
204
+ return `calls ${displayName(witnessSummary.id)}, a @boundary that declares no @capabilities`;
205
+ }
206
+ const { calls } = propagated.summary;
207
+ if (!calls.some(callLeavesUnknown)) {
208
+ const dynamic = calls.find((call) => call.kind === "stub" && call.capabilityTargetUnknown);
209
+ if (dynamic?.kind === "stub") {
210
+ return `calls ${dynamic.qualifiedName} with a target that is not a literal in the source, which only the runtime can match (DESIGN.md §4.4)`;
211
+ }
212
+ }
213
+ return "reaches a call that could not be resolved";
214
+ }
215
+ /**
216
+ * DESIGN.md §4.4's static half: an operation whose target the source fixes —
217
+ * a literal URL — reaching outside what the function was granted.
218
+ *
219
+ * Reported at the call site rather than at the declaration, because that is
220
+ * the line to change, and separately from `AMB-E005`, because the finding is
221
+ * different: nothing declared this requirement, the code performs it directly.
222
+ *
223
+ * No fix candidate. The two possible patches are widening the grant and
224
+ * changing the URL, and Ambit cannot tell which the author meant; §5.3 forbids
225
+ * inventing one for the sake of ranking, and widening a capability silently is
226
+ * exactly the expansion of authority the tag exists to catch.
227
+ */
228
+ function buildLiteralTargetViolation(propagated, granted, call, engine) {
229
+ const { summary } = propagated;
230
+ const required = call.requiredCapability;
231
+ const requiredText = required ? formatCapability(required) : "";
232
+ const grantedList = granted.map(formatCapability);
233
+ return {
234
+ id: "AMB-E009",
235
+ severity: "error",
236
+ category: "capabilities",
237
+ message: `${displayName(summary.id)} grants [${grantedList.join(", ")}] but ${call.qualifiedName} here targets ${requiredText}`,
238
+ location: call.location,
239
+ contract: {
240
+ declared: grantedList,
241
+ required: propagated.required.capabilities.map(formatCapability),
242
+ excess: [requiredText],
243
+ via: [],
244
+ },
245
+ fixes: [],
246
+ docs: "docs/diagnostics/README.md#amb-e009",
247
+ engine,
248
+ };
249
+ }
250
+ function buildCapabilityEscalation(propagated, granted, excess, state, engine) {
251
+ const { summary } = propagated;
252
+ // Deterministic choice of which excess capability's path to show, so the
253
+ // same code always produces the same diagnostic.
254
+ const primary = [...excess].sort((a, b) => formatCapability(a).localeCompare(formatCapability(b)))[0];
255
+ const chain = primary ? capabilityWitnessChain(summary.id, formatCapability(primary), state) : [];
256
+ const via = chainToVia(chain, state);
257
+ const grantedList = granted.map(formatCapability);
258
+ const excessList = excess.map(formatCapability);
259
+ const message = via.length > 0
260
+ ? `${displayName(summary.id)} grants [${grantedList.join(", ")}] but calls ${displayName(via[via.length - 1]?.symbol ?? summary.id)} which requires [${excessList.join(", ")}]`
261
+ : `${displayName(summary.id)} grants [${grantedList.join(", ")}] but requires [${excessList.join(", ")}]`;
262
+ return {
263
+ id: "AMB-E005",
264
+ severity: "error",
265
+ category: "capabilities",
266
+ message,
267
+ location: summary.location,
268
+ contract: {
269
+ declared: grantedList,
270
+ required: propagated.required.capabilities.map(formatCapability),
271
+ excess: excessList,
272
+ via,
273
+ },
274
+ fixes: [],
275
+ docs: "docs/diagnostics/README.md#amb-e005",
276
+ engine,
277
+ };
278
+ }
279
+ /**
280
+ * `@boundary` is an explicit trust declaration (DESIGN.md §4.6). Two ways to
281
+ * write one that does nothing, both reported rather than accepted:
282
+ * a missing `reason`, and no contract to trust in its place.
283
+ */
284
+ function diagnoseBoundary(propagated, engine) {
285
+ const { summary } = propagated;
286
+ if (summary.boundary.kind === "invalid") {
287
+ return [
288
+ {
289
+ id: "AMB-E006",
290
+ severity: "error",
291
+ category: "boundary",
292
+ message: `${displayName(summary.id)} declares @boundary "${summary.boundary.raw}", which does not give the required reason= (DESIGN.md §4.6)`,
293
+ location: summary.location,
294
+ fixes: [],
295
+ docs: "docs/diagnostics/README.md#amb-e006",
296
+ engine,
297
+ },
298
+ ];
299
+ }
300
+ if (summary.boundary.kind !== "declared")
301
+ return [];
302
+ // An `@effects` that failed to parse is already AMB-E002's business; saying
303
+ // "no @effects" on top of it would name the wrong problem.
304
+ if (summary.declared.kind !== "none")
305
+ return [];
306
+ return [
307
+ {
308
+ id: "AMB-E007",
309
+ severity: "error",
310
+ category: "boundary",
311
+ message: `${displayName(summary.id)} declares @boundary but no @effects, so its body is not checked and nothing was declared in its place`,
312
+ location: summary.location,
313
+ fixes: [],
314
+ docs: "docs/diagnostics/README.md#amb-e007",
315
+ engine,
316
+ },
317
+ ];
318
+ }
319
+ function diagnoseBudget(propagated, engine) {
320
+ const { summary } = propagated;
321
+ if (summary.budget.kind !== "invalid")
322
+ return [];
323
+ return [
324
+ {
325
+ id: "AMB-E008",
326
+ severity: "error",
327
+ category: "budget",
328
+ message: `${displayName(summary.id)} declares @budget "${summary.budget.raw}", which is not a space-separated list of timeMs/costUsd/llmCalls limits with an optional onExceed=throw|warn|abort`,
329
+ location: summary.location,
330
+ fixes: [],
331
+ docs: "docs/diagnostics/README.md#amb-e008",
332
+ engine,
333
+ },
334
+ ];
335
+ }
336
+ /**
337
+ * DESIGN.md §4.4: "An entry point states `@entrypoint` and `@capabilities`
338
+ * explicitly … Leaving them unspecified is warned about as equivalent to
339
+ * `unknown`". An entrypoint is where the
340
+ * runtime establishes a capability context; one with no capability set
341
+ * establishes nothing to check against.
342
+ */
343
+ function diagnoseEntrypoint(propagated, engine) {
344
+ const { summary } = propagated;
345
+ if (!summary.entrypoint)
346
+ return [];
347
+ if (summary.capabilities.kind !== "none")
348
+ return [];
349
+ return [
350
+ {
351
+ id: "AMB-W002",
352
+ severity: "warning",
353
+ category: "capabilities",
354
+ message: `${displayName(summary.id)} is an @entrypoint with no @capabilities, so the runtime has no capability set to establish for it`,
355
+ location: summary.location,
356
+ fixes: [],
357
+ docs: "docs/diagnostics/README.md#amb-w002",
358
+ engine,
359
+ },
360
+ ];
361
+ }
362
+ function buildExcessDiagnostic(propagated, declared, excess, state, engine) {
363
+ const { summary } = propagated;
364
+ // Deterministic choice of which excess effect's path to show in `via`:
365
+ // KNOWN_EFFECTS declaration order, not Set iteration/insertion order.
366
+ const primaryEffect = KNOWN_EFFECTS.find((effect) => excess.has(effect));
367
+ const chain = primaryEffect ? witnessChain(summary.id, primaryEffect, state) : [];
368
+ const via = chainToVia(chain, state);
369
+ const operation = primaryEffect
370
+ ? operationSite(primaryEffect, chain[chain.length - 1] ?? summary.id, state)
371
+ : undefined;
372
+ const fnName = displayName(summary.id);
373
+ const excessList = [...excess].filter((e) => KNOWN_EFFECTS.includes(e));
374
+ const declaredList = declaredContractList(declared).join(", ");
375
+ const message = via.length > 0
376
+ ? `${fnName} declares ${declaredList} but calls ${displayName(via[via.length - 1]?.symbol ?? summary.id)} which has effects [${excessList.join(", ")}]`
377
+ : `${fnName} declares ${declaredList} but performs [${excessList.join(", ")}] directly`;
378
+ return {
379
+ id: "AMB-E001",
380
+ severity: "error",
381
+ category: "effects",
382
+ message,
383
+ location: summary.location,
384
+ contract: {
385
+ declared: declaredContractList(declared),
386
+ observed: [...propagated.observed.effects],
387
+ via,
388
+ operation,
389
+ },
390
+ fixes: buildWidenFix(propagated, declared, excess, state),
391
+ docs: "docs/diagnostics/README.md#amb-e001",
392
+ engine,
393
+ };
394
+ }
395
+ function buildUnknownDiagnostic(propagated, declared, state, engine) {
396
+ const { summary } = propagated;
397
+ const chain = unknownWitnessChain(summary.id, state);
398
+ const via = chainToVia(chain, state);
399
+ const fnName = displayName(summary.id);
400
+ const declaredList = declaredContractList(declared).join(", ");
401
+ const message = via.length > 0
402
+ ? `${fnName} declares ${declaredList} but calls ${displayName(via[via.length - 1]?.symbol ?? summary.id)} which could not be resolved`
403
+ : `${fnName} declares ${declaredList} but calls something that could not be resolved`;
404
+ return {
405
+ id: "AMB-W001",
406
+ severity: "warning",
407
+ category: "effects",
408
+ message,
409
+ location: summary.location,
410
+ contract: {
411
+ declared: declaredContractList(declared),
412
+ observed: [...propagated.observed.effects],
413
+ via,
414
+ },
415
+ fixes: [],
416
+ docs: "docs/diagnostics/README.md#amb-w001",
417
+ engine,
418
+ };
419
+ }
420
+ /**
421
+ * A `@effects` tag that failed to parse (`summarize.ts`'s `parseEffectsTag`
422
+ * returned `undefined` — a token that is neither `pure` nor a known effect).
423
+ * Reported instead of the excess/unknown diagnostics above, never alongside
424
+ * them: `summarizeExtractedFiles` already treats this function as
425
+ * undeclared for propagation, so it cannot also carry an observed-vs-declared
426
+ * mismatch.
427
+ */
428
+ function buildInvalidEffectsDiagnostic(propagated, raw, engine) {
429
+ const { summary } = propagated;
430
+ const fnName = displayName(summary.id);
431
+ return {
432
+ id: "AMB-E002",
433
+ severity: "error",
434
+ category: "effects",
435
+ message: `${fnName} declares @effects "${raw}", which is not "pure" or a known effect name`,
436
+ location: summary.location,
437
+ fixes: [],
438
+ docs: "docs/diagnostics/README.md#amb-e002",
439
+ engine,
440
+ };
441
+ }
442
+ /** Why the node in question cannot carry a contract, for AMB-E003's message. */
443
+ const UNCARRIED_REASON = {
444
+ "class-declaration": "a class declaration — the contract belongs on its constructor",
445
+ "getter-setter": "a getter/setter",
446
+ "object-literal-method": "an object-literal member with no stable declaration path",
447
+ "anonymous-default-export": "an anonymous default export",
448
+ "callback-argument": "a callback passed inline as an argument",
449
+ "nested-function": "a function declared inside another function",
450
+ "bodyless-declaration": "a declaration with no body (an overload signature, an abstract member, or an ambient declare) — the contract belongs on the implementation that runs",
451
+ other: "a node the analysis does not extract",
452
+ };
453
+ /**
454
+ * A contract tag written on a function-like node the backend does not extract
455
+ * (DESIGN.md §4.1 permits `@effects` on any function or method, but only an
456
+ * extracted node has a `SymbolId` to hang one on). Reported rather than
457
+ * dropped, on the same principle as AMB-E002: a declaration that silently does
458
+ * nothing looks like a guarantee and is not one.
459
+ */
460
+ export function diagnoseUncarriedContracts(uncarried, engine) {
461
+ return uncarried.map((contract) => ({
462
+ id: "AMB-E003",
463
+ severity: "error",
464
+ category: "effects",
465
+ message: `@${contract.tag} is declared on ${UNCARRIED_REASON[contract.kind]}, which cannot carry a contract — the declaration has no effect${contract.configKey
466
+ ? `; declare it in ambit.config.ts under "${contract.configKey}" instead`
467
+ : ""}`,
468
+ location: contract.location,
469
+ fixes: [],
470
+ docs: "docs/diagnostics/README.md#amb-e003",
471
+ engine,
472
+ }));
473
+ }
474
+ /**
475
+ * JSDoc and `ambit.config.ts` declare the same tag for one symbol and the two
476
+ * do not agree (DESIGN.md §4.1: "If a symbol has both JSDoc and config,
477
+ * JSDoc wins and the difference is warned about").
478
+ *
479
+ * A warning, not an error: JSDoc winning is the specified behaviour, so the
480
+ * run is doing the right thing — but a config entry that is being ignored is
481
+ * a declaration the author believes is in force and is not, which is the same
482
+ * failure AMB-E003 exists to prevent. `--strict` does not promote it: the
483
+ * disagreement is between two declarations, not an unverified path.
484
+ *
485
+ * No fix is offered. Which side is wrong is the author's decision — deleting
486
+ * the config entry and rewriting the JSDoc are opposite intentions, and §5.3
487
+ * forbids inventing a candidate to fill the slot.
488
+ */
489
+ export function diagnoseContractDivergence(state, configPath, engine) {
490
+ const diagnostics = [];
491
+ for (const { summary } of state.values()) {
492
+ for (const divergence of summary.divergences ?? []) {
493
+ diagnostics.push({
494
+ id: "AMB-W005",
495
+ severity: "warning",
496
+ category: "effects",
497
+ message: `${displayName(summary.id)} declares @${divergence.tag} as [${divergence.jsDoc}] in JSDoc and [${divergence.config}] in ${configPath}; the JSDoc declaration is the one in force`,
498
+ location: summary.location,
499
+ fixes: [],
500
+ docs: "docs/diagnostics/README.md#amb-w005",
501
+ engine,
502
+ });
503
+ }
504
+ }
505
+ return diagnostics;
506
+ }
507
+ /**
508
+ * An exact `contracts` key that named no extracted symbol (DESIGN.md §4.1).
509
+ *
510
+ * Same principle as AMB-E003: a declaration that silently applies to nothing
511
+ * reads as a guarantee and is not one. Only *exact* keys are reported — a
512
+ * glob covering a directory this run did not check matches nothing for a
513
+ * reason that is not a mistake, and reporting it would make `ambit check
514
+ * src/domain` noisy in proportion to how much of the project it skipped.
515
+ *
516
+ * A warning rather than an error, and not promoted by `--strict`: the key may
517
+ * name a file outside the directory being checked, which is a normal thing
518
+ * for one config to do.
519
+ */
520
+ export function diagnoseUnmatchedConfigKeys(keys, configPath, configSource, engine) {
521
+ return keys.map((key) => ({
522
+ id: "AMB-W006",
523
+ severity: "warning",
524
+ category: "effects",
525
+ message: `contracts key "${key}" matches no analyzed declaration; the contract it declares is not in force`,
526
+ location: configKeyLocation(configPath, configSource, key),
527
+ fixes: [],
528
+ docs: "docs/diagnostics/README.md#amb-w006",
529
+ engine,
530
+ }));
531
+ }
532
+ /**
533
+ * Where a `contracts` key is written in the config file's own text.
534
+ *
535
+ * Found textually rather than by parsing: the config was imported as a
536
+ * module, not parsed into an AST, and a key's line is all a reader needs to
537
+ * find it. Falls back to the file's first character when the key cannot be
538
+ * located (an object built by an expression rather than written literally),
539
+ * which is honest — the key is real, its line is not knowable here.
540
+ */
541
+ function configKeyLocation(configPath, configSource, key) {
542
+ const lines = configSource.split("\n");
543
+ for (const [index, line] of lines.entries()) {
544
+ const col = line.indexOf(key);
545
+ if (col < 0)
546
+ continue;
547
+ return {
548
+ file: configPath,
549
+ line: index + 1,
550
+ col: col + 1,
551
+ endLine: index + 1,
552
+ endCol: col + 1 + key.length,
553
+ };
554
+ }
555
+ return { file: configPath, line: 1, col: 1, endLine: 1, endCol: 1 };
556
+ }
557
+ /** How a wrapper whose handler could not be reached at all is described. */
558
+ const HANDLER_NOT_IN_THIS_FILE = "its handler is not a declaration in this file, so there is no JSDoc contract beside it to compare";
559
+ /**
560
+ * DESIGN.md §4.4: a literal spec on a `withAmbit(spec, handler)` or
561
+ * `ambitHandler(spec, handler, decode)` naming a handler in the same file *is*
562
+ * that handler's `@capabilities` / `@budget` (`summarize.ts`'s
563
+ * `specContracts`). Writing the tag too stays legal, and this is what stops it
564
+ * from being free: an agent adding `db:write:users` to one of them — or
565
+ * widening `timeMs` in one of them — expanded authority silently.
566
+ *
567
+ * Where the spec is *not* the declaration — a list built at runtime, a budget
568
+ * that is not an object literal, a handler from another module — there is
569
+ * nothing to compare and nothing was declared by the spec either. That is
570
+ * `AMB-W004`, and its message says so: the handler's own JSDoc is then the
571
+ * only declaration there is.
572
+ *
573
+ * This compares the two **as source**, half by half: the capability set
574
+ * (`AMB-E010`) and the budget (`AMB-E011`) are fixed by the source
575
+ * independently, so one may be comparable when the other is not. §12's
576
+ * "Mapping contracts to handlers" — matching a contract to a handler after a
577
+ * build
578
+ * strips the comments, or after a bundler moves it — stays open, and a half
579
+ * this comparison cannot reach is reported (`AMB-W004`) rather than passed
580
+ * over.
581
+ */
582
+ export function diagnoseRuntimeWrappers(wrappers, state, engine) {
583
+ const diagnostics = [];
584
+ for (const wrapper of wrappers) {
585
+ const handler = wrapper.handler ? state.get(wrapper.handler)?.summary : undefined;
586
+ if (!handler) {
587
+ diagnostics.push(uncomparedWrapper(wrapper, wrapper.unmatchedReason === "handler-not-in-this-file"
588
+ ? HANDLER_NOT_IN_THIS_FILE
589
+ : "its handler could not be matched to an analyzed declaration", engine));
590
+ continue;
591
+ }
592
+ diagnostics.push(...diagnoseWrapperCapabilities(wrapper, handler, engine));
593
+ diagnostics.push(...diagnoseWrapperBudget(wrapper, handler, engine));
594
+ }
595
+ return diagnostics;
596
+ }
597
+ /** The capability half of §4.4's agreement check. */
598
+ function diagnoseWrapperCapabilities(wrapper, handler, engine) {
599
+ // A `@capabilities` that did not parse is AMB-E004's business; comparing
600
+ // against a tag Ambit already rejected would name the wrong problem.
601
+ if (handler.capabilities.kind === "invalid")
602
+ return [];
603
+ if (wrapper.capabilities === undefined) {
604
+ return [
605
+ uncomparedWrapper(wrapper, "its capability list is not a literal array of strings in the source", engine),
606
+ ];
607
+ }
608
+ if (!handler.entrypoint && handler.capabilities.kind === "none") {
609
+ return [
610
+ uncomparedWrapper(wrapper, `${displayName(handler.id)} declares neither @entrypoint nor @capabilities, so there is nothing to compare the spec against`, engine),
611
+ ];
612
+ }
613
+ const declared = handler.capabilities.kind === "declared"
614
+ ? handler.capabilities.capabilities.capabilities.map(formatCapability)
615
+ : [];
616
+ const wrapped = [...wrapper.capabilities];
617
+ if (sameCapabilityText(declared, wrapped))
618
+ return [];
619
+ return [
620
+ {
621
+ id: "AMB-E010",
622
+ severity: "error",
623
+ category: "capabilities",
624
+ message: `${wrapper.wrapper} grants [${wrapped.join(", ")}] but ${displayName(handler.id)} declares @capabilities [${declared.join(", ")}]`,
625
+ location: wrapper.location,
626
+ contract: {
627
+ declared,
628
+ required: wrapped,
629
+ excess: wrapped.filter((capability) => !declared.includes(capability)),
630
+ via: [],
631
+ },
632
+ fixes: [],
633
+ docs: "docs/diagnostics/README.md#amb-e010",
634
+ engine,
635
+ },
636
+ ];
637
+ }
638
+ /**
639
+ * The budget half of §4.4's agreement check.
640
+ *
641
+ * Its own id rather than AMB-E010's: that diagnostic's `contract` field is
642
+ * capability text (`declared` / `required` / `excess`), and a budget
643
+ * disagreement has nothing honest to put in it.
644
+ */
645
+ function diagnoseWrapperBudget(wrapper, handler, engine) {
646
+ // A `@budget` that did not parse is AMB-E008's business, for the reason an
647
+ // invalid `@capabilities` is AMB-E004's.
648
+ if (handler.budget.kind === "invalid")
649
+ return [];
650
+ if (wrapper.budget === undefined) {
651
+ return [
652
+ uncomparedWrapper(wrapper, "its budget is not an object literal of literal limits in the source", engine),
653
+ ];
654
+ }
655
+ const declared = handler.budget.kind === "declared" ? handler.budget.budget : undefined;
656
+ const wrapped = wrapper.budget.kind === "literal" ? budgetFrom(wrapper.budget) : undefined;
657
+ if (sameBudget(declared, wrapped))
658
+ return [];
659
+ return [
660
+ {
661
+ id: "AMB-E011",
662
+ severity: "error",
663
+ category: "budget",
664
+ message: `${wrapper.wrapper} sets ${describeBudget(wrapped, "budget")} but ${displayName(handler.id)} declares ${describeBudget(declared, "@budget")}`,
665
+ location: wrapper.location,
666
+ fixes: [],
667
+ docs: "docs/diagnostics/README.md#amb-e011",
668
+ engine,
669
+ },
670
+ ];
671
+ }
672
+ /** Field-by-field equality; a limit present on one side only is a disagreement. */
673
+ function sameBudget(a, b) {
674
+ if (a === undefined || b === undefined)
675
+ return a === b;
676
+ return (a.timeMs === b.timeMs &&
677
+ a.costUsd === b.costUsd &&
678
+ a.llmCalls === b.llmCalls &&
679
+ a.onExceed === b.onExceed);
680
+ }
681
+ function describeBudget(budget, label) {
682
+ return budget === undefined ? `no ${label}` : `${label} ${formatBudget(budget)}`;
683
+ }
684
+ function uncomparedWrapper(wrapper, reason, engine) {
685
+ return {
686
+ id: "AMB-W004",
687
+ severity: "warning",
688
+ category: "capabilities",
689
+ message: `${wrapper.wrapper} here was not compared with a declared contract: ${reason}. A spec Ambit cannot read declares nothing, so the handler's own @capabilities / @budget is the only declaration here. The check is on the source only (DESIGN.md §4.4)`,
690
+ location: wrapper.location,
691
+ fixes: [],
692
+ docs: "docs/diagnostics/README.md#amb-w004",
693
+ engine,
694
+ };
695
+ }
696
+ /** Set equality over the capability text as written; order is not part of the contract. */
697
+ function sameCapabilityText(a, b) {
698
+ if (a.length !== b.length)
699
+ return false;
700
+ const left = [...a].sort();
701
+ const right = [...b].sort();
702
+ return left.every((value, index) => value === right[index]);
703
+ }
704
+ /**
705
+ * The call site inside `ownerId` that performs `effect` — the `fetch(...)` line
706
+ * rather than the enclosing function's declaration line.
707
+ *
708
+ * Only a stub call answers: it is the layer that knows an operation's name and
709
+ * its effects. A `state_write` that came from an assignment or a mutating
710
+ * method has no operation to name, and an effect that reached `ownerId`
711
+ * through its own `@effects` declaration alone has no site inside it — both
712
+ * return `undefined` rather than a guess (DESIGN.md §5.2).
713
+ *
714
+ * The first matching site in source order is reported when a function performs
715
+ * the same effect more than once: one site is enough to send the reader to the
716
+ * right place, and picking the first is stable across re-analysis.
717
+ */
718
+ export function operationSite(effect, ownerId, state) {
719
+ const owner = state.get(ownerId);
720
+ if (!owner)
721
+ return undefined;
722
+ const site = owner.summary.calls.find((call) => call.kind === "stub" && call.effects.includes(effect));
723
+ if (!site)
724
+ return undefined;
725
+ return {
726
+ qualifiedName: site.qualifiedName,
727
+ file: site.location.file,
728
+ line: site.location.line,
729
+ };
730
+ }
731
+ /** `["pure"]` for the declared empty set, matching DESIGN.md §5.1's example; the known effects otherwise. */
732
+ function declaredContractList(declared) {
733
+ return declared.size === 0 ? ["pure"] : [...declared];
734
+ }