ambit-ts 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (96) hide show
  1. package/CHANGELOG.md +64 -0
  2. package/LICENSE +21 -0
  3. package/README.md +403 -0
  4. package/dist/checker/authority.d.ts +13 -0
  5. package/dist/checker/authority.js +87 -0
  6. package/dist/checker/backend/legacy-ts.d.ts +26 -0
  7. package/dist/checker/backend/legacy-ts.js +1936 -0
  8. package/dist/checker/config.d.ts +84 -0
  9. package/dist/checker/config.js +391 -0
  10. package/dist/checker/coverage.d.ts +78 -0
  11. package/dist/checker/coverage.js +84 -0
  12. package/dist/checker/diagnose.d.ts +89 -0
  13. package/dist/checker/diagnose.js +734 -0
  14. package/dist/checker/index.d.ts +8 -0
  15. package/dist/checker/index.js +8 -0
  16. package/dist/checker/init.d.ts +38 -0
  17. package/dist/checker/init.js +205 -0
  18. package/dist/checker/propagate.d.ts +69 -0
  19. package/dist/checker/propagate.js +259 -0
  20. package/dist/checker/summarize.d.ts +27 -0
  21. package/dist/checker/summarize.js +411 -0
  22. package/dist/cli/analyze.d.ts +33 -0
  23. package/dist/cli/analyze.js +98 -0
  24. package/dist/cli/approvals.d.ts +28 -0
  25. package/dist/cli/approvals.js +55 -0
  26. package/dist/cli/diff.d.ts +66 -0
  27. package/dist/cli/diff.js +235 -0
  28. package/dist/cli/github.d.ts +33 -0
  29. package/dist/cli/github.js +41 -0
  30. package/dist/cli/main.d.ts +8 -0
  31. package/dist/cli/main.js +385 -0
  32. package/dist/cli/worktree.d.ts +75 -0
  33. package/dist/cli/worktree.js +154 -0
  34. package/dist/config.d.ts +12 -0
  35. package/dist/config.js +10 -0
  36. package/dist/core/approvals.d.ts +82 -0
  37. package/dist/core/approvals.js +0 -0
  38. package/dist/core/authority-diff.d.ts +98 -0
  39. package/dist/core/authority-diff.js +209 -0
  40. package/dist/core/authority.d.ts +109 -0
  41. package/dist/core/authority.js +50 -0
  42. package/dist/core/backend.d.ts +355 -0
  43. package/dist/core/backend.js +1 -0
  44. package/dist/core/budget.d.ts +61 -0
  45. package/dist/core/budget.js +95 -0
  46. package/dist/core/capability.d.ts +53 -0
  47. package/dist/core/capability.js +117 -0
  48. package/dist/core/config.d.ts +59 -0
  49. package/dist/core/config.js +10 -0
  50. package/dist/core/diagnostic.d.ts +126 -0
  51. package/dist/core/diagnostic.js +13 -0
  52. package/dist/core/effects.d.ts +39 -0
  53. package/dist/core/effects.js +72 -0
  54. package/dist/core/index.d.ts +13 -0
  55. package/dist/core/index.js +13 -0
  56. package/dist/core/location.d.ts +15 -0
  57. package/dist/core/location.js +1 -0
  58. package/dist/core/sql.d.ts +22 -0
  59. package/dist/core/sql.js +38 -0
  60. package/dist/core/summary.d.ts +240 -0
  61. package/dist/core/summary.js +8 -0
  62. package/dist/core/symbol-id.d.ts +25 -0
  63. package/dist/core/symbol-id.js +23 -0
  64. package/dist/index.d.ts +14 -0
  65. package/dist/index.js +14 -0
  66. package/dist/runtime/child-process.d.ts +29 -0
  67. package/dist/runtime/child-process.js +124 -0
  68. package/dist/runtime/context.d.ts +37 -0
  69. package/dist/runtime/context.js +8 -0
  70. package/dist/runtime/enforce.d.ts +52 -0
  71. package/dist/runtime/enforce.js +95 -0
  72. package/dist/runtime/fs.d.ts +46 -0
  73. package/dist/runtime/fs.js +188 -0
  74. package/dist/runtime/hono.d.ts +55 -0
  75. package/dist/runtime/hono.js +68 -0
  76. package/dist/runtime/index.d.ts +71 -0
  77. package/dist/runtime/index.js +126 -0
  78. package/dist/runtime/next.d.ts +95 -0
  79. package/dist/runtime/next.js +60 -0
  80. package/dist/runtime/pg.d.ts +48 -0
  81. package/dist/runtime/pg.js +122 -0
  82. package/dist/stubs/constructors.d.ts +34 -0
  83. package/dist/stubs/constructors.js +111 -0
  84. package/dist/stubs/data-clients.d.ts +9 -0
  85. package/dist/stubs/data-clients.js +109 -0
  86. package/dist/stubs/http-capabilities.d.ts +15 -0
  87. package/dist/stubs/http-capabilities.js +70 -0
  88. package/dist/stubs/mutating-builtins.d.ts +1 -0
  89. package/dist/stubs/mutating-builtins.js +48 -0
  90. package/dist/stubs/node-builtins.d.ts +2 -0
  91. package/dist/stubs/node-builtins.js +77 -0
  92. package/dist/stubs/pure-builtins.d.ts +1 -0
  93. package/dist/stubs/pure-builtins.js +89 -0
  94. package/docs/diagnostics/README.md +519 -0
  95. package/docs/limitations.md +712 -0
  96. package/package.json +89 -0
@@ -0,0 +1,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;