ambit-ts 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +64 -0
- package/LICENSE +21 -0
- package/README.md +403 -0
- package/dist/checker/authority.d.ts +13 -0
- package/dist/checker/authority.js +87 -0
- package/dist/checker/backend/legacy-ts.d.ts +26 -0
- package/dist/checker/backend/legacy-ts.js +1936 -0
- package/dist/checker/config.d.ts +84 -0
- package/dist/checker/config.js +391 -0
- package/dist/checker/coverage.d.ts +78 -0
- package/dist/checker/coverage.js +84 -0
- package/dist/checker/diagnose.d.ts +89 -0
- package/dist/checker/diagnose.js +734 -0
- package/dist/checker/index.d.ts +8 -0
- package/dist/checker/index.js +8 -0
- package/dist/checker/init.d.ts +38 -0
- package/dist/checker/init.js +205 -0
- package/dist/checker/propagate.d.ts +69 -0
- package/dist/checker/propagate.js +259 -0
- package/dist/checker/summarize.d.ts +27 -0
- package/dist/checker/summarize.js +411 -0
- package/dist/cli/analyze.d.ts +33 -0
- package/dist/cli/analyze.js +98 -0
- package/dist/cli/approvals.d.ts +28 -0
- package/dist/cli/approvals.js +55 -0
- package/dist/cli/diff.d.ts +66 -0
- package/dist/cli/diff.js +235 -0
- package/dist/cli/github.d.ts +33 -0
- package/dist/cli/github.js +41 -0
- package/dist/cli/main.d.ts +8 -0
- package/dist/cli/main.js +385 -0
- package/dist/cli/worktree.d.ts +75 -0
- package/dist/cli/worktree.js +154 -0
- package/dist/config.d.ts +12 -0
- package/dist/config.js +10 -0
- package/dist/core/approvals.d.ts +82 -0
- package/dist/core/approvals.js +0 -0
- package/dist/core/authority-diff.d.ts +98 -0
- package/dist/core/authority-diff.js +209 -0
- package/dist/core/authority.d.ts +109 -0
- package/dist/core/authority.js +50 -0
- package/dist/core/backend.d.ts +355 -0
- package/dist/core/backend.js +1 -0
- package/dist/core/budget.d.ts +61 -0
- package/dist/core/budget.js +95 -0
- package/dist/core/capability.d.ts +53 -0
- package/dist/core/capability.js +117 -0
- package/dist/core/config.d.ts +59 -0
- package/dist/core/config.js +10 -0
- package/dist/core/diagnostic.d.ts +126 -0
- package/dist/core/diagnostic.js +13 -0
- package/dist/core/effects.d.ts +39 -0
- package/dist/core/effects.js +72 -0
- package/dist/core/index.d.ts +13 -0
- package/dist/core/index.js +13 -0
- package/dist/core/location.d.ts +15 -0
- package/dist/core/location.js +1 -0
- package/dist/core/sql.d.ts +22 -0
- package/dist/core/sql.js +38 -0
- package/dist/core/summary.d.ts +240 -0
- package/dist/core/summary.js +8 -0
- package/dist/core/symbol-id.d.ts +25 -0
- package/dist/core/symbol-id.js +23 -0
- package/dist/index.d.ts +14 -0
- package/dist/index.js +14 -0
- package/dist/runtime/child-process.d.ts +29 -0
- package/dist/runtime/child-process.js +124 -0
- package/dist/runtime/context.d.ts +37 -0
- package/dist/runtime/context.js +8 -0
- package/dist/runtime/enforce.d.ts +52 -0
- package/dist/runtime/enforce.js +95 -0
- package/dist/runtime/fs.d.ts +46 -0
- package/dist/runtime/fs.js +188 -0
- package/dist/runtime/hono.d.ts +55 -0
- package/dist/runtime/hono.js +68 -0
- package/dist/runtime/index.d.ts +71 -0
- package/dist/runtime/index.js +126 -0
- package/dist/runtime/next.d.ts +95 -0
- package/dist/runtime/next.js +60 -0
- package/dist/runtime/pg.d.ts +48 -0
- package/dist/runtime/pg.js +122 -0
- package/dist/stubs/constructors.d.ts +34 -0
- package/dist/stubs/constructors.js +111 -0
- package/dist/stubs/data-clients.d.ts +9 -0
- package/dist/stubs/data-clients.js +109 -0
- package/dist/stubs/http-capabilities.d.ts +15 -0
- package/dist/stubs/http-capabilities.js +70 -0
- package/dist/stubs/mutating-builtins.d.ts +1 -0
- package/dist/stubs/mutating-builtins.js +48 -0
- package/dist/stubs/node-builtins.d.ts +2 -0
- package/dist/stubs/node-builtins.js +77 -0
- package/dist/stubs/pure-builtins.d.ts +1 -0
- package/dist/stubs/pure-builtins.js +89 -0
- package/docs/diagnostics/README.md +519 -0
- package/docs/limitations.md +712 -0
- package/package.json +89 -0
|
@@ -0,0 +1,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
|
+
}
|