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,8 @@
|
|
|
1
|
+
export * from "./authority.ts";
|
|
2
|
+
export * from "./backend/legacy-ts.ts";
|
|
3
|
+
export * from "./config.ts";
|
|
4
|
+
export * from "./coverage.ts";
|
|
5
|
+
export * from "./diagnose.ts";
|
|
6
|
+
export * from "./init.ts";
|
|
7
|
+
export * from "./propagate.ts";
|
|
8
|
+
export * from "./summarize.ts";
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
export * from "./authority.js";
|
|
2
|
+
export * from "./backend/legacy-ts.js";
|
|
3
|
+
export * from "./config.js";
|
|
4
|
+
export * from "./coverage.js";
|
|
5
|
+
export * from "./diagnose.js";
|
|
6
|
+
export * from "./init.js";
|
|
7
|
+
export * from "./propagate.js";
|
|
8
|
+
export * from "./summarize.js";
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import type { Diagnostic, DiagnosticEngine, SymbolId } from "../core/index.ts";
|
|
2
|
+
import type { PropagatedFunction } from "./propagate.ts";
|
|
3
|
+
/**
|
|
4
|
+
* Where `ambit init --config` writes: the config file `ambit check` would
|
|
5
|
+
* load, its current text, and the analysis root every {@link SymbolId}'s file
|
|
6
|
+
* half is relative to.
|
|
7
|
+
*
|
|
8
|
+
* The root is needed because the two are written against different bases —
|
|
9
|
+
* a symbol id is relative to `check <dir>`, a `contracts` key is relative to
|
|
10
|
+
* the config file (DESIGN.md §4.1 (c)) — and a patch that ignored the
|
|
11
|
+
* difference would propose a key that names nothing.
|
|
12
|
+
*/
|
|
13
|
+
export interface ConfigTarget {
|
|
14
|
+
/** The config file as the output names it — root-relative when it is under the root. */
|
|
15
|
+
readonly path: string;
|
|
16
|
+
readonly source: string;
|
|
17
|
+
readonly rootDir: string;
|
|
18
|
+
}
|
|
19
|
+
/**
|
|
20
|
+
* `ambit init`'s analysis half (DESIGN.md §4.1: "`ambit init` infers the
|
|
21
|
+
* effects of existing code from the evidence in 4.2 and emits JSDoc additions
|
|
22
|
+
* as fix candidates in diagnostics (chapter 5, `fixes[].edits`)").
|
|
23
|
+
*
|
|
24
|
+
* For every function that has no `@effects` tag and whose propagated effect
|
|
25
|
+
* set is fully known, emit an `AMB-I001` info diagnostic carrying one
|
|
26
|
+
* applicable patch that adds the tag.
|
|
27
|
+
*
|
|
28
|
+
* The `unknown` guard is the whole point. A function whose effects could not
|
|
29
|
+
* be resolved gets **no** proposal: writing `@effects pure` on it would turn
|
|
30
|
+
* "we could not tell" into a declared guarantee, which is precisely what §4.3
|
|
31
|
+
* says `unknown` exists to prevent. Those functions stay undeclared and keep
|
|
32
|
+
* showing up in `--coverage`, exactly as §4.1 says they should.
|
|
33
|
+
*
|
|
34
|
+
* Nothing here writes to disk. The proposals are diagnostics, so the same
|
|
35
|
+
* NDJSON an agent already consumes carries them, and applying them is the
|
|
36
|
+
* caller's decision.
|
|
37
|
+
*/
|
|
38
|
+
export declare function proposeContracts(state: ReadonlyMap<SymbolId, PropagatedFunction>, engine: DiagnosticEngine, configTarget?: ConfigTarget): readonly Diagnostic[];
|
|
@@ -0,0 +1,205 @@
|
|
|
1
|
+
import path from "node:path";
|
|
2
|
+
import { KNOWN_EFFECTS } from "../core/index.js";
|
|
3
|
+
/**
|
|
4
|
+
* `ambit init`'s analysis half (DESIGN.md §4.1: "`ambit init` infers the
|
|
5
|
+
* effects of existing code from the evidence in 4.2 and emits JSDoc additions
|
|
6
|
+
* as fix candidates in diagnostics (chapter 5, `fixes[].edits`)").
|
|
7
|
+
*
|
|
8
|
+
* For every function that has no `@effects` tag and whose propagated effect
|
|
9
|
+
* set is fully known, emit an `AMB-I001` info diagnostic carrying one
|
|
10
|
+
* applicable patch that adds the tag.
|
|
11
|
+
*
|
|
12
|
+
* The `unknown` guard is the whole point. A function whose effects could not
|
|
13
|
+
* be resolved gets **no** proposal: writing `@effects pure` on it would turn
|
|
14
|
+
* "we could not tell" into a declared guarantee, which is precisely what §4.3
|
|
15
|
+
* says `unknown` exists to prevent. Those functions stay undeclared and keep
|
|
16
|
+
* showing up in `--coverage`, exactly as §4.1 says they should.
|
|
17
|
+
*
|
|
18
|
+
* Nothing here writes to disk. The proposals are diagnostics, so the same
|
|
19
|
+
* NDJSON an agent already consumes carries them, and applying them is the
|
|
20
|
+
* caller's decision.
|
|
21
|
+
*/
|
|
22
|
+
export function proposeContracts(state, engine, configTarget) {
|
|
23
|
+
const proposals = [];
|
|
24
|
+
for (const propagated of state.values()) {
|
|
25
|
+
const { summary } = propagated;
|
|
26
|
+
if (summary.declared.kind !== "none")
|
|
27
|
+
continue;
|
|
28
|
+
// A boundary's body was never analyzed, so there is nothing inferred to
|
|
29
|
+
// propose — and proposing the empty set would be the worst possible
|
|
30
|
+
// suggestion for a function that exists to hide something.
|
|
31
|
+
if (summary.boundary.kind === "declared")
|
|
32
|
+
continue;
|
|
33
|
+
if (propagated.observed.unknown)
|
|
34
|
+
continue;
|
|
35
|
+
const effects = KNOWN_EFFECTS.filter((effect) => propagated.observed.effects.has(effect));
|
|
36
|
+
const tag = `@effects ${effects.length === 0 ? "pure" : effects.join(", ")}`;
|
|
37
|
+
// Declarations no JSDoc comment can carry: an accessor or an anonymous
|
|
38
|
+
// default export (DESIGN.md §4.1 (a)), and a class that writes no
|
|
39
|
+
// constructor (its construction has a declaration path and no declaration
|
|
40
|
+
// site at all). All three have a stable symbol id, so `ambit.config.ts`
|
|
41
|
+
// can name them — which is what `--config` proposes.
|
|
42
|
+
if (summary.configOnly || summary.implicitConstructor) {
|
|
43
|
+
const edit = configTarget ? configEdit(configTarget, summary.id, effects) : undefined;
|
|
44
|
+
proposals.push(proposal(propagated, effects, configProposalMessage(summary, effects, tag, configTarget, edit !== undefined), edit
|
|
45
|
+
? [
|
|
46
|
+
{
|
|
47
|
+
rank: 1,
|
|
48
|
+
kind: "narrow",
|
|
49
|
+
summary: `Declare [${effects.join(", ") || "no effects"}] for ${displayName(summary.id)} in ${configTarget?.path ?? "ambit.config.ts"}`,
|
|
50
|
+
confidence: 1,
|
|
51
|
+
consistentWithContract: true,
|
|
52
|
+
edits: [edit],
|
|
53
|
+
},
|
|
54
|
+
]
|
|
55
|
+
: []));
|
|
56
|
+
continue;
|
|
57
|
+
}
|
|
58
|
+
const edit = contractEdit(propagated, tag);
|
|
59
|
+
if (!edit)
|
|
60
|
+
continue;
|
|
61
|
+
proposals.push(proposal(propagated, effects, `${displayName(summary.id)} has no @effects; its observed effects are [${effects.join(", ") || "none"}]`, [
|
|
62
|
+
{
|
|
63
|
+
rank: 1,
|
|
64
|
+
kind: "narrow",
|
|
65
|
+
summary: `Declare ${tag} on ${displayName(summary.id)}`,
|
|
66
|
+
confidence: 1,
|
|
67
|
+
// Adding a declaration where there was none cannot contradict a
|
|
68
|
+
// contract that does not exist, and the set proposed is exactly
|
|
69
|
+
// what was observed — so this neither loosens nor tightens.
|
|
70
|
+
consistentWithContract: true,
|
|
71
|
+
edits: [edit],
|
|
72
|
+
},
|
|
73
|
+
]));
|
|
74
|
+
}
|
|
75
|
+
return proposals;
|
|
76
|
+
function proposal(propagated, effects, message, fixes) {
|
|
77
|
+
return {
|
|
78
|
+
id: "AMB-I001",
|
|
79
|
+
severity: "info",
|
|
80
|
+
category: "effects",
|
|
81
|
+
message,
|
|
82
|
+
location: propagated.summary.location,
|
|
83
|
+
contract: { declared: [], observed: effects, via: [] },
|
|
84
|
+
fixes,
|
|
85
|
+
docs: "docs/diagnostics/README.md#amb-i001",
|
|
86
|
+
engine,
|
|
87
|
+
};
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
/**
|
|
91
|
+
* The patch that adds `tag`: a new line inside the existing JSDoc block when
|
|
92
|
+
* there is one, or a fresh single-line block above the declaration when there
|
|
93
|
+
* is not. Both are inserts (a zero-width range), so nothing existing is
|
|
94
|
+
* overwritten.
|
|
95
|
+
*/
|
|
96
|
+
function contractEdit(propagated, tag) {
|
|
97
|
+
const { summary } = propagated;
|
|
98
|
+
const indent = " ".repeat(Math.max(0, summary.declarationStart.col - 1));
|
|
99
|
+
const comment = summary.jsDocRange;
|
|
100
|
+
if (comment) {
|
|
101
|
+
// Insert the tag on its own line just before the block's closing `*/`,
|
|
102
|
+
// which is the last two characters of the block.
|
|
103
|
+
const closingCol = comment.endCol - 2;
|
|
104
|
+
if (closingCol < 1)
|
|
105
|
+
return undefined;
|
|
106
|
+
// A one-line block (`/** text */`) has to become multi-line to hold a
|
|
107
|
+
// tag on its own line; a multi-line one is already sitting at the start
|
|
108
|
+
// of a `*`-prefixed line, so it only needs the line itself.
|
|
109
|
+
const singleLine = comment.line === comment.endLine;
|
|
110
|
+
const replacement = singleLine ? `\n${indent} * ${tag}\n${indent} ` : `* ${tag}\n${indent} `;
|
|
111
|
+
return {
|
|
112
|
+
file: comment.file,
|
|
113
|
+
range: [
|
|
114
|
+
[comment.endLine - 1, closingCol - 1],
|
|
115
|
+
[comment.endLine - 1, closingCol - 1],
|
|
116
|
+
],
|
|
117
|
+
replacement,
|
|
118
|
+
};
|
|
119
|
+
}
|
|
120
|
+
return {
|
|
121
|
+
file: summary.declarationStart.file,
|
|
122
|
+
range: [
|
|
123
|
+
[summary.declarationStart.line - 1, summary.declarationStart.col - 1],
|
|
124
|
+
[summary.declarationStart.line - 1, summary.declarationStart.col - 1],
|
|
125
|
+
],
|
|
126
|
+
replacement: `/** ${tag} */\n${indent}`,
|
|
127
|
+
};
|
|
128
|
+
}
|
|
129
|
+
/**
|
|
130
|
+
* What a config-only proposal says, which depends on how far the command can
|
|
131
|
+
* actually get: `--config` with a config file present produces a patch; every
|
|
132
|
+
* other combination produces the reason there is none. Never silence — the
|
|
133
|
+
* effects are real, and P4 forbids hiding what cannot be declared.
|
|
134
|
+
*/
|
|
135
|
+
function configProposalMessage(summary, effects, tag, configTarget, hasEdit) {
|
|
136
|
+
const what = `${displayName(summary.id)} runs [${effects.join(", ") || "no effects"}]`;
|
|
137
|
+
if (hasEdit)
|
|
138
|
+
return `${what} and has no declaration; add it to ${configTarget?.path}`;
|
|
139
|
+
if (summary.implicitConstructor && configTarget === undefined) {
|
|
140
|
+
return `${what} but has no constructor to declare them on; write an explicit constructor to carry ${tag}, or declare "${summary.id}" in ambit.config.ts (ambit init --config)`;
|
|
141
|
+
}
|
|
142
|
+
if (configTarget === undefined) {
|
|
143
|
+
return `${what} and cannot carry a JSDoc contract; declare "${summary.id}" in ambit.config.ts (ambit init --config)`;
|
|
144
|
+
}
|
|
145
|
+
return `${what} and cannot carry a JSDoc contract; ${configTarget.path} has no \`contracts\` block to append "${summary.id}" to`;
|
|
146
|
+
}
|
|
147
|
+
/**
|
|
148
|
+
* The patch that appends one `contracts` entry to an existing config file.
|
|
149
|
+
*
|
|
150
|
+
* Inserted at the end of the `contracts: {` line, so the edit is a single
|
|
151
|
+
* line's insertion into text this command did not parse — the config was
|
|
152
|
+
* loaded by importing it, not by building an AST, and guessing where the
|
|
153
|
+
* block *ends* would mean matching braces in a file that may contain any
|
|
154
|
+
* expression. `undefined` when there is no such line: §5.3 forbids emitting a
|
|
155
|
+
* candidate that does not apply.
|
|
156
|
+
*
|
|
157
|
+
* The key is rebased from the analysis root to the config file's directory
|
|
158
|
+
* (§4.1 (c)).
|
|
159
|
+
*/
|
|
160
|
+
function configEdit(target, id, effects) {
|
|
161
|
+
const lines = target.source.split("\n");
|
|
162
|
+
const pattern = /(?:^|[^A-Za-z0-9_$])contracts\s*:\s*\{/;
|
|
163
|
+
const index = lines.findIndex((line) => pattern.test(line));
|
|
164
|
+
const line = lines[index];
|
|
165
|
+
if (index < 0 || line === undefined)
|
|
166
|
+
return undefined;
|
|
167
|
+
const opening = pattern.exec(line);
|
|
168
|
+
if (!opening)
|
|
169
|
+
return undefined;
|
|
170
|
+
// Inserted immediately after the `{`, not at the end of the line: a
|
|
171
|
+
// formatter collapses an empty block to `contracts: {},` and appending
|
|
172
|
+
// after that line's text would put the entry outside the object.
|
|
173
|
+
const column = opening.index + opening[0].length;
|
|
174
|
+
const indent = " ".repeat(line.length - line.trimStart().length + 2);
|
|
175
|
+
const key = configKeyFor(target, id);
|
|
176
|
+
const value = `{ effects: [${effects.map((effect) => `"${effect}"`).join(", ")}] }`;
|
|
177
|
+
return {
|
|
178
|
+
file: target.path,
|
|
179
|
+
range: [
|
|
180
|
+
[index, column],
|
|
181
|
+
[index, column],
|
|
182
|
+
],
|
|
183
|
+
replacement: `\n${indent}"${key}": ${value},`,
|
|
184
|
+
};
|
|
185
|
+
}
|
|
186
|
+
/** A symbol id rebased onto the config file's directory (DESIGN.md §4.1 (c)). */
|
|
187
|
+
function configKeyFor(target, id) {
|
|
188
|
+
const hash = id.indexOf("#");
|
|
189
|
+
if (hash < 0)
|
|
190
|
+
return id;
|
|
191
|
+
const absolute = path.resolve(target.rootDir, id.slice(0, hash));
|
|
192
|
+
const configDir = path.dirname(path.resolve(target.rootDir, target.path));
|
|
193
|
+
const relative = path.relative(configDir, absolute).split(path.sep).join("/");
|
|
194
|
+
return `${relative}#${id.slice(hash + 1)}`;
|
|
195
|
+
}
|
|
196
|
+
function displayName(id) {
|
|
197
|
+
const afterHash = id.split("#")[1] ?? id;
|
|
198
|
+
const parts = afterHash.split(".");
|
|
199
|
+
const last = parts[parts.length - 1];
|
|
200
|
+
if (last === undefined)
|
|
201
|
+
return id;
|
|
202
|
+
if (last === "constructor" && parts.length >= 2)
|
|
203
|
+
return `${parts[parts.length - 2]}.${last}`;
|
|
204
|
+
return last;
|
|
205
|
+
}
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
import type { CapabilitySet, ContractViaEntry, EffectSet, FunctionSummary, KnownEffect, SymbolId } from "../core/index.ts";
|
|
2
|
+
/** One function's propagated (transitive) effects, plus provenance for diagnostics. */
|
|
3
|
+
export interface PropagatedFunction {
|
|
4
|
+
readonly summary: FunctionSummary;
|
|
5
|
+
readonly observed: EffectSet;
|
|
6
|
+
/**
|
|
7
|
+
* For an effect in `observed` that this function does not produce
|
|
8
|
+
* directly, the immediate callee it was inherited through. Absent for an
|
|
9
|
+
* effect this function produces itself (a direct stub call).
|
|
10
|
+
*/
|
|
11
|
+
readonly effectWitness: ReadonlyMap<KnownEffect, SymbolId>;
|
|
12
|
+
/** The immediate callee `unknown` was inherited through, if not direct. */
|
|
13
|
+
readonly unknownWitness?: SymbolId;
|
|
14
|
+
/**
|
|
15
|
+
* The capabilities this function's body needs (DESIGN.md §4.4). A second
|
|
16
|
+
* lattice over the same call graph, with the same trust rule as effects: a
|
|
17
|
+
* callee that declares `@capabilities` contributes what it declared, and an
|
|
18
|
+
* undeclared one contributes what its own body was inferred to need — so a
|
|
19
|
+
* grant checks through an undeclared middle function.
|
|
20
|
+
*
|
|
21
|
+
* A requirement arises in two ways. It is inherited from a callee's
|
|
22
|
+
* declaration — the narrowing rule between a caller's grant and a callee's
|
|
23
|
+
* declaration — or it is produced directly, by a bundled operation whose
|
|
24
|
+
* target the source fixes: a literal URL's host (§4.4's static half,
|
|
25
|
+
* `src/stubs/http-capabilities.ts`). The same operation with a target the
|
|
26
|
+
* source does not fix contributes `unknown` instead, never nothing.
|
|
27
|
+
*/
|
|
28
|
+
readonly required: CapabilitySet;
|
|
29
|
+
/** For a required capability this function does not declare itself, the immediate callee it came through. */
|
|
30
|
+
readonly capabilityWitness: ReadonlyMap<string, SymbolId>;
|
|
31
|
+
/**
|
|
32
|
+
* The immediate callee an *unknown capability requirement* came through, if
|
|
33
|
+
* not direct. Tracked apart from {@link unknownWitness} because the two have
|
|
34
|
+
* different causes: effects go unknown at an unresolved call, while
|
|
35
|
+
* capabilities also go unknown at a perfectly resolved `@boundary` callee
|
|
36
|
+
* that declared no `@capabilities`. Reporting the second as "reaches a call
|
|
37
|
+
* that could not be resolved" would send a reader hunting for an unresolved
|
|
38
|
+
* call that does not exist.
|
|
39
|
+
*/
|
|
40
|
+
readonly capabilityUnknownWitness?: SymbolId;
|
|
41
|
+
}
|
|
42
|
+
/**
|
|
43
|
+
* Compute each function's transitive effect set by a worklist fixed point
|
|
44
|
+
* over the call graph (DESIGN.md §4.2: "Cycles in the call graph are
|
|
45
|
+
* propagated to a fixed point using strongly connected components or the
|
|
46
|
+
* like"). Handles cycles by iterating to a stable state — no
|
|
47
|
+
* strongly-connected-component precomputation, since `EffectSet` only grows
|
|
48
|
+
* (union is monotonic) and the id space is finite, so this always
|
|
49
|
+
* terminates.
|
|
50
|
+
*/
|
|
51
|
+
export declare function propagate(summaries: readonly FunctionSummary[]): ReadonlyMap<SymbolId, PropagatedFunction>;
|
|
52
|
+
/** Walk `effectWitness` chains from `start` to the function that produces `effect` directly. */
|
|
53
|
+
export declare function witnessChain(start: SymbolId, effect: KnownEffect, state: ReadonlyMap<SymbolId, PropagatedFunction>): readonly SymbolId[];
|
|
54
|
+
/** Walk `capabilityUnknownWitness` from `start` to the function whose capability requirement first went unknown. */
|
|
55
|
+
export declare function capabilityUnknownWitnessChain(start: SymbolId, state: ReadonlyMap<SymbolId, PropagatedFunction>): readonly SymbolId[];
|
|
56
|
+
/** Walk `capabilityWitness` chains from `start` to the function that declares `capability`. */
|
|
57
|
+
export declare function capabilityWitnessChain(start: SymbolId, capability: string, state: ReadonlyMap<SymbolId, PropagatedFunction>): readonly SymbolId[];
|
|
58
|
+
/** Walk `unknownWitness` chain from `start` to the function with the direct unresolved call. */
|
|
59
|
+
export declare function unknownWitnessChain(start: SymbolId, state: ReadonlyMap<SymbolId, PropagatedFunction>): readonly SymbolId[];
|
|
60
|
+
/**
|
|
61
|
+
* A witness chain rendered as `contract.via` entries: each hop named by its
|
|
62
|
+
* symbol id and located at its own declaration (DESIGN.md §5.1 — "`via` is a
|
|
63
|
+
* sequence of functions, and each element's position is that function's
|
|
64
|
+
* declaration position").
|
|
65
|
+
*
|
|
66
|
+
* Lives beside the chain walkers rather than beside either consumer, because
|
|
67
|
+
* a diagnostic's path and an authority record's path must be the same path.
|
|
68
|
+
*/
|
|
69
|
+
export declare function chainToVia(chain: readonly SymbolId[], state: ReadonlyMap<SymbolId, PropagatedFunction>): readonly ContractViaEntry[];
|
|
@@ -0,0 +1,259 @@
|
|
|
1
|
+
import { callLeavesUnknown, capabilitySetsEqual, effectSetOf, effectSetsEqual, emptyCapabilitySet, formatCapability, unionCapabilitySets, unionEffectSets, unknownCapabilitySet, unknownEffectSet, } from "../core/index.js";
|
|
2
|
+
/**
|
|
3
|
+
* Compute each function's transitive effect set by a worklist fixed point
|
|
4
|
+
* over the call graph (DESIGN.md §4.2: "Cycles in the call graph are
|
|
5
|
+
* propagated to a fixed point using strongly connected components or the
|
|
6
|
+
* like"). Handles cycles by iterating to a stable state — no
|
|
7
|
+
* strongly-connected-component precomputation, since `EffectSet` only grows
|
|
8
|
+
* (union is monotonic) and the id space is finite, so this always
|
|
9
|
+
* terminates.
|
|
10
|
+
*/
|
|
11
|
+
export function propagate(summaries) {
|
|
12
|
+
const byId = new Map(summaries.map((s) => [s.id, s]));
|
|
13
|
+
const state = new Map();
|
|
14
|
+
for (const summary of summaries) {
|
|
15
|
+
state.set(summary.id, {
|
|
16
|
+
summary,
|
|
17
|
+
observed: directEffects(summary),
|
|
18
|
+
effectWitness: new Map(),
|
|
19
|
+
required: emptyCapabilitySet(),
|
|
20
|
+
capabilityWitness: new Map(),
|
|
21
|
+
});
|
|
22
|
+
}
|
|
23
|
+
let changed = true;
|
|
24
|
+
while (changed) {
|
|
25
|
+
changed = false;
|
|
26
|
+
for (const summary of summaries) {
|
|
27
|
+
const next = deriveState(summary, byId, state);
|
|
28
|
+
const previous = state.get(summary.id);
|
|
29
|
+
if (!previous ||
|
|
30
|
+
!effectSetsEqual(previous.observed, next.observed) ||
|
|
31
|
+
!capabilitySetsEqual(previous.required, next.required)) {
|
|
32
|
+
changed = true;
|
|
33
|
+
}
|
|
34
|
+
state.set(summary.id, next);
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
return state;
|
|
38
|
+
}
|
|
39
|
+
/**
|
|
40
|
+
* The capabilities a function's own body requires before anything is
|
|
41
|
+
* propagated into it: what its bundled-operation call sites fix statically,
|
|
42
|
+
* plus `unknown` when one of them has a target the source does not fix, or
|
|
43
|
+
* when a call could not be resolved at all.
|
|
44
|
+
*/
|
|
45
|
+
function directCapabilities(summary) {
|
|
46
|
+
let set = emptyCapabilitySet();
|
|
47
|
+
let unknown = false;
|
|
48
|
+
for (const call of summary.calls) {
|
|
49
|
+
if (callLeavesUnknown(call)) {
|
|
50
|
+
unknown = true;
|
|
51
|
+
continue;
|
|
52
|
+
}
|
|
53
|
+
if (call.kind !== "stub")
|
|
54
|
+
continue;
|
|
55
|
+
if (call.requiredCapability) {
|
|
56
|
+
set = unionCapabilitySets(set, {
|
|
57
|
+
capabilities: [call.requiredCapability],
|
|
58
|
+
unknown: false,
|
|
59
|
+
});
|
|
60
|
+
}
|
|
61
|
+
if (call.capabilityTargetUnknown)
|
|
62
|
+
unknown = true;
|
|
63
|
+
}
|
|
64
|
+
return unknown ? { capabilities: set.capabilities, unknown: true } : set;
|
|
65
|
+
}
|
|
66
|
+
function directEffects(summary) {
|
|
67
|
+
const stubEffects = summary.calls
|
|
68
|
+
.filter((call) => call.kind === "stub")
|
|
69
|
+
.flatMap((call) => call.effects);
|
|
70
|
+
let set = effectSetOf(...stubEffects);
|
|
71
|
+
// A mutation whose receiver is reachable from outside the function is a
|
|
72
|
+
// direct `state_write` (DESIGN.md §4.2, "Local mutation and `pure`"); a local
|
|
73
|
+
// one is recorded as a site but contributes nothing.
|
|
74
|
+
if (summary.calls.some((call) => call.kind === "mutation" && call.escaping)) {
|
|
75
|
+
set = unionEffectSets(set, effectSetOf("state_write"));
|
|
76
|
+
}
|
|
77
|
+
if (summary.calls.some(callLeavesUnknown)) {
|
|
78
|
+
set = unionEffectSets(set, unknownEffectSet());
|
|
79
|
+
}
|
|
80
|
+
return set;
|
|
81
|
+
}
|
|
82
|
+
/**
|
|
83
|
+
* What a caller inherits from a resolved call to `calleeSummary`.
|
|
84
|
+
*
|
|
85
|
+
* A *declared* callee is a trust boundary: its own body is checked against
|
|
86
|
+
* its own declaration by `diagnose.ts` (independently, on that function's
|
|
87
|
+
* own record), and callers propagate the declared set rather than the
|
|
88
|
+
* callee's inferred one. This is the usual modular-typing shape (a
|
|
89
|
+
* function's signature, not its body, is what callers see) and is also
|
|
90
|
+
* what makes "undeclared = unknown" workable as a coverage concept rather
|
|
91
|
+
* than a propagation rule — DESIGN.md §4.2 states it directly: "Undeclared"
|
|
92
|
+
* and "`unknown`" are not the same thing.
|
|
93
|
+
* A declared tag never carries `unknown` itself, so trusting it means a
|
|
94
|
+
* callee's own undeclared internal `unknown` does not leak to callers; it
|
|
95
|
+
* stays that callee's own AMB-W001, not its callers'.
|
|
96
|
+
*
|
|
97
|
+
* An *undeclared* callee has no signature to trust, so its own
|
|
98
|
+
* recursively-inferred `observed` set is used instead — this is what lets
|
|
99
|
+
* inference cross undeclared code (§4.3, incremental adoption).
|
|
100
|
+
*/
|
|
101
|
+
function contributionOf(calleeSummary, calleeState) {
|
|
102
|
+
if (calleeSummary.declared.kind === "declared")
|
|
103
|
+
return calleeSummary.declared.effects;
|
|
104
|
+
return calleeState.observed;
|
|
105
|
+
}
|
|
106
|
+
/** The capability requirement a caller inherits from a resolved call. Same trust rule as {@link contributionOf}. */
|
|
107
|
+
function capabilityContributionOf(calleeSummary, calleeState) {
|
|
108
|
+
if (calleeSummary.capabilities.kind === "declared")
|
|
109
|
+
return calleeSummary.capabilities.capabilities;
|
|
110
|
+
return calleeState.required;
|
|
111
|
+
}
|
|
112
|
+
/**
|
|
113
|
+
* A `@boundary` function's contract is taken as written and its body is not
|
|
114
|
+
* propagated through (DESIGN.md §4.6: "do not statically check inside this
|
|
115
|
+
* function; trust the effects and capabilities it declares outward").
|
|
116
|
+
*
|
|
117
|
+
* What it declares is what it contributes. What it does *not* declare is
|
|
118
|
+
* `unknown`, not empty: the body was excluded from analysis, so an
|
|
119
|
+
* undeclared dimension is unexamined, and calling it "requires nothing"
|
|
120
|
+
* would turn an explicit hole into a guarantee (P4).
|
|
121
|
+
*/
|
|
122
|
+
function boundaryState(summary) {
|
|
123
|
+
return {
|
|
124
|
+
summary,
|
|
125
|
+
observed: summary.declared.kind === "declared" ? summary.declared.effects : unknownEffectSet(),
|
|
126
|
+
effectWitness: new Map(),
|
|
127
|
+
required: summary.capabilities.kind === "declared"
|
|
128
|
+
? summary.capabilities.capabilities
|
|
129
|
+
: unknownCapabilitySet(),
|
|
130
|
+
capabilityWitness: new Map(),
|
|
131
|
+
};
|
|
132
|
+
}
|
|
133
|
+
function deriveState(summary, byId, state) {
|
|
134
|
+
if (summary.boundary.kind === "declared")
|
|
135
|
+
return boundaryState(summary);
|
|
136
|
+
const direct = directEffects(summary);
|
|
137
|
+
const hasDirectUnresolved = summary.calls.some(callLeavesUnknown);
|
|
138
|
+
let merged = direct;
|
|
139
|
+
const directRequired = directCapabilities(summary);
|
|
140
|
+
// A capability requirement can go unknown right here — an unresolved call,
|
|
141
|
+
// or a bundled operation whose target the source does not fix — in which
|
|
142
|
+
// case there is no callee to name as the witness.
|
|
143
|
+
const hasDirectCapabilityUnknown = directRequired.unknown;
|
|
144
|
+
let required = directRequired;
|
|
145
|
+
const effectWitness = new Map();
|
|
146
|
+
const capabilityWitness = new Map();
|
|
147
|
+
let unknownWitness;
|
|
148
|
+
let capabilityUnknownWitness;
|
|
149
|
+
for (const call of summary.calls) {
|
|
150
|
+
if (call.kind !== "resolved")
|
|
151
|
+
continue;
|
|
152
|
+
const calleeSummary = byId.get(call.callee);
|
|
153
|
+
const calleeState = state.get(call.callee);
|
|
154
|
+
if (!calleeSummary || !calleeState)
|
|
155
|
+
continue; // defensive: should always resolve within the same summary set
|
|
156
|
+
const contribution = contributionOf(calleeSummary, calleeState);
|
|
157
|
+
merged = unionEffectSets(merged, contribution);
|
|
158
|
+
for (const effect of contribution.effects) {
|
|
159
|
+
if (!direct.effects.has(effect) && !effectWitness.has(effect)) {
|
|
160
|
+
effectWitness.set(effect, call.callee);
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
if (contribution.unknown && !hasDirectUnresolved && unknownWitness === undefined) {
|
|
164
|
+
unknownWitness = call.callee;
|
|
165
|
+
}
|
|
166
|
+
const capabilityContribution = capabilityContributionOf(calleeSummary, calleeState);
|
|
167
|
+
required = unionCapabilitySets(required, capabilityContribution);
|
|
168
|
+
for (const capability of capabilityContribution.capabilities) {
|
|
169
|
+
const key = formatCapability(capability);
|
|
170
|
+
if (!capabilityWitness.has(key))
|
|
171
|
+
capabilityWitness.set(key, call.callee);
|
|
172
|
+
}
|
|
173
|
+
if (capabilityContribution.unknown &&
|
|
174
|
+
!hasDirectCapabilityUnknown &&
|
|
175
|
+
capabilityUnknownWitness === undefined) {
|
|
176
|
+
capabilityUnknownWitness = call.callee;
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
return {
|
|
180
|
+
summary,
|
|
181
|
+
observed: merged,
|
|
182
|
+
effectWitness,
|
|
183
|
+
unknownWitness: hasDirectUnresolved ? undefined : unknownWitness,
|
|
184
|
+
required,
|
|
185
|
+
capabilityWitness,
|
|
186
|
+
capabilityUnknownWitness: hasDirectCapabilityUnknown ? undefined : capabilityUnknownWitness,
|
|
187
|
+
};
|
|
188
|
+
}
|
|
189
|
+
/** Walk `effectWitness` chains from `start` to the function that produces `effect` directly. */
|
|
190
|
+
export function witnessChain(start, effect, state) {
|
|
191
|
+
const chain = [];
|
|
192
|
+
let current = start;
|
|
193
|
+
const visited = new Set([current]);
|
|
194
|
+
for (;;) {
|
|
195
|
+
const next = state.get(current)?.effectWitness.get(effect);
|
|
196
|
+
if (next === undefined || visited.has(next))
|
|
197
|
+
return chain;
|
|
198
|
+
chain.push(next);
|
|
199
|
+
visited.add(next);
|
|
200
|
+
current = next;
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
/** Walk `capabilityUnknownWitness` from `start` to the function whose capability requirement first went unknown. */
|
|
204
|
+
export function capabilityUnknownWitnessChain(start, state) {
|
|
205
|
+
const chain = [];
|
|
206
|
+
let current = start;
|
|
207
|
+
const visited = new Set([current]);
|
|
208
|
+
for (;;) {
|
|
209
|
+
const next = state.get(current)?.capabilityUnknownWitness;
|
|
210
|
+
if (next === undefined || visited.has(next))
|
|
211
|
+
return chain;
|
|
212
|
+
chain.push(next);
|
|
213
|
+
visited.add(next);
|
|
214
|
+
current = next;
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
/** Walk `capabilityWitness` chains from `start` to the function that declares `capability`. */
|
|
218
|
+
export function capabilityWitnessChain(start, capability, state) {
|
|
219
|
+
const chain = [];
|
|
220
|
+
let current = start;
|
|
221
|
+
const visited = new Set([current]);
|
|
222
|
+
for (;;) {
|
|
223
|
+
const next = state.get(current)?.capabilityWitness.get(capability);
|
|
224
|
+
if (next === undefined || visited.has(next))
|
|
225
|
+
return chain;
|
|
226
|
+
chain.push(next);
|
|
227
|
+
visited.add(next);
|
|
228
|
+
current = next;
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
/** Walk `unknownWitness` chain from `start` to the function with the direct unresolved call. */
|
|
232
|
+
export function unknownWitnessChain(start, state) {
|
|
233
|
+
const chain = [];
|
|
234
|
+
let current = start;
|
|
235
|
+
const visited = new Set([current]);
|
|
236
|
+
for (;;) {
|
|
237
|
+
const next = state.get(current)?.unknownWitness;
|
|
238
|
+
if (next === undefined || visited.has(next))
|
|
239
|
+
return chain;
|
|
240
|
+
chain.push(next);
|
|
241
|
+
visited.add(next);
|
|
242
|
+
current = next;
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
/**
|
|
246
|
+
* A witness chain rendered as `contract.via` entries: each hop named by its
|
|
247
|
+
* symbol id and located at its own declaration (DESIGN.md §5.1 — "`via` is a
|
|
248
|
+
* sequence of functions, and each element's position is that function's
|
|
249
|
+
* declaration position").
|
|
250
|
+
*
|
|
251
|
+
* Lives beside the chain walkers rather than beside either consumer, because
|
|
252
|
+
* a diagnostic's path and an authority record's path must be the same path.
|
|
253
|
+
*/
|
|
254
|
+
export function chainToVia(chain, state) {
|
|
255
|
+
return chain.map((id) => {
|
|
256
|
+
const location = state.get(id)?.summary.location;
|
|
257
|
+
return { symbol: id, file: location?.file ?? "", line: location?.line ?? 0 };
|
|
258
|
+
});
|
|
259
|
+
}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import type { EffectSet, ExtractedFile, FunctionSummary, KnownEffect } from "../core/index.ts";
|
|
2
|
+
import type { ResolvedConfig } from "./config.ts";
|
|
3
|
+
/**
|
|
4
|
+
* Turn a backend's raw extraction into Ambit's own analysis representation
|
|
5
|
+
* (DESIGN.md §3.4 layer 2), independent of which backend produced it.
|
|
6
|
+
*/
|
|
7
|
+
export declare function summarizeExtractedFiles(files: readonly ExtractedFile[], config?: ResolvedConfig): readonly FunctionSummary[];
|
|
8
|
+
/**
|
|
9
|
+
* `@boundary reason="..."`. DESIGN.md §4.6 makes `reason` mandatory, so a tag
|
|
10
|
+
* without one does not declare a boundary — it declares an unexplained hole,
|
|
11
|
+
* which is exactly what the tag exists to prevent. Both quoted and bare
|
|
12
|
+
* `reason=` forms are accepted; anything else is `"invalid"`.
|
|
13
|
+
*/
|
|
14
|
+
export declare function parseBoundaryTag(text: string): string | undefined;
|
|
15
|
+
/**
|
|
16
|
+
* `pure` is the literal spelling for the empty set (DESIGN.md §4.2 rule 2).
|
|
17
|
+
* Returns `undefined` when a token is neither `pure` nor a known effect (a
|
|
18
|
+
* typo, e.g. `@effects netwrok`) — such a declaration must not silently
|
|
19
|
+
* collapse to an empty (`pure`) contract. The caller reports this as
|
|
20
|
+
* `AMB-E002` (`diagnose.ts`) instead of the tag's real, but broken, contract.
|
|
21
|
+
*/
|
|
22
|
+
export declare function parseEffectsTag(text: string, aliases?: EffectAliases): EffectSet | undefined;
|
|
23
|
+
/**
|
|
24
|
+
* User-defined effect names and the standard effects they stand for. Absent
|
|
25
|
+
* when no config was loaded, in which case every name has to be standard.
|
|
26
|
+
*/
|
|
27
|
+
export type EffectAliases = ReadonlyMap<string, readonly KnownEffect[]> | undefined;
|