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,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
|
+
}
|