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,84 @@
|
|
|
1
|
+
import type { AmbitConfig, ConfigContract, KnownEffect, SymbolId } from "../core/index.ts";
|
|
2
|
+
/** Thrown for every config problem. `main` turns it into exit 2 (§3.4). */
|
|
3
|
+
export declare class ConfigError extends Error {
|
|
4
|
+
}
|
|
5
|
+
export interface LoadedConfig {
|
|
6
|
+
/** Absolute path to the config file. Keys are resolved relative to its directory. */
|
|
7
|
+
readonly configPath: string;
|
|
8
|
+
readonly config: AmbitConfig;
|
|
9
|
+
/** The file's own text, so a diagnostic about a key can point at its line. */
|
|
10
|
+
readonly sourceText: string;
|
|
11
|
+
}
|
|
12
|
+
/**
|
|
13
|
+
* Walk up from `startDir` looking for a config file, stopping after the first
|
|
14
|
+
* directory that holds a `package.json` or `.git` (§4.1 (c)) — a config
|
|
15
|
+
* outside the project must never be picked up silently.
|
|
16
|
+
*/
|
|
17
|
+
export declare function findConfigFile(startDir: string): string | undefined;
|
|
18
|
+
/**
|
|
19
|
+
* Load the config that governs `startDir`, or `undefined` when there is none.
|
|
20
|
+
*
|
|
21
|
+
* The file is imported, not parsed: it is TypeScript or JavaScript, and Node
|
|
22
|
+
* strips types for any file outside `node_modules` — which a consumer's
|
|
23
|
+
* config always is, including when the CLI itself is running from `dist/`.
|
|
24
|
+
* A file that throws on import (a syntax error, a bad import) becomes a
|
|
25
|
+
* {@link ConfigError}: a config that could not be read must stop the run, not
|
|
26
|
+
* be treated as "no config" (§3.4).
|
|
27
|
+
*/
|
|
28
|
+
export declare function loadConfig(startDir: string): Promise<LoadedConfig | undefined>;
|
|
29
|
+
/**
|
|
30
|
+
* Check the imported value against {@link AmbitConfig} by hand.
|
|
31
|
+
*
|
|
32
|
+
* An unknown key is rejected rather than ignored, for the reason a misspelled
|
|
33
|
+
* effect name is (AMB-E002): a `contarcts:` block that is silently dropped
|
|
34
|
+
* reads as a set of declarations and is not one.
|
|
35
|
+
*/
|
|
36
|
+
export declare function validateConfig(value: unknown, where: string): AmbitConfig;
|
|
37
|
+
/**
|
|
38
|
+
* A config resolved against one analysis root: contract lookup by symbol id,
|
|
39
|
+
* per-directory `strict`, and the user-defined effect table.
|
|
40
|
+
*/
|
|
41
|
+
export interface ResolvedConfig {
|
|
42
|
+
readonly configPath: string;
|
|
43
|
+
/**
|
|
44
|
+
* The config file as diagnostics and fixes should name it: relative to the
|
|
45
|
+
* analysis root when it sits inside it, absolute when it does not.
|
|
46
|
+
*
|
|
47
|
+
* Every other `location.file` and `edits[].file` in the output is
|
|
48
|
+
* root-relative, and a consumer that resolves them against the checked
|
|
49
|
+
* directory (`test/e2e.realistic.test.ts` does exactly that) would break on
|
|
50
|
+
* one absolute path among them.
|
|
51
|
+
*/
|
|
52
|
+
readonly displayPath: string;
|
|
53
|
+
readonly sourceText: string;
|
|
54
|
+
/** User-defined effect names → the standard effects they stand for (§4.1 (d)). */
|
|
55
|
+
readonly effectAliases: ReadonlyMap<string, readonly KnownEffect[]>;
|
|
56
|
+
/** The contract declared for `id`, or `undefined`. Records the match for {@link unmatchedExactKeys}. */
|
|
57
|
+
contractFor(id: SymbolId): ConfigContract | undefined;
|
|
58
|
+
/** Whether `relativeFile`'s diagnostics get `--strict`'s promotion (§4.3). */
|
|
59
|
+
isStrictFile(relativeFile: string): boolean;
|
|
60
|
+
/**
|
|
61
|
+
* Exact keys that named no extracted symbol, after every lookup has run.
|
|
62
|
+
* Glob keys are excluded on purpose: a glob matching nothing under the
|
|
63
|
+
* directory being checked is normal, an exact key naming nothing is a typo.
|
|
64
|
+
*/
|
|
65
|
+
unmatchedExactKeys(): readonly string[];
|
|
66
|
+
}
|
|
67
|
+
/**
|
|
68
|
+
* Bind a loaded config to the directory being analyzed.
|
|
69
|
+
*
|
|
70
|
+
* Keys are written relative to the config file (§4.1 (c)) and symbol ids are
|
|
71
|
+
* relative to the analysis root, so every key is rebased once, here, and
|
|
72
|
+
* nothing downstream has to remember which base it is holding.
|
|
73
|
+
*/
|
|
74
|
+
export declare function resolveConfig(loaded: LoadedConfig, rootDir: string): ResolvedConfig;
|
|
75
|
+
/**
|
|
76
|
+
* §4.1 (b)'s glob: `*` matches within one path segment, `**` crosses
|
|
77
|
+
* directories. Hand-rolled rather than delegated to `path.matchesGlob`, whose
|
|
78
|
+
* semantics are the shell's and are not the two lines the spec commits to.
|
|
79
|
+
*
|
|
80
|
+
* A pattern that escapes the analysis root (`../…` after rebasing) still
|
|
81
|
+
* compiles; it simply matches no root-relative file, which is the honest
|
|
82
|
+
* answer for a key naming something outside the run.
|
|
83
|
+
*/
|
|
84
|
+
export declare function globToRegExp(pattern: string): RegExp;
|
|
@@ -0,0 +1,391 @@
|
|
|
1
|
+
var __rewriteRelativeImportExtension = (this && this.__rewriteRelativeImportExtension) || function (path, preserveJsx) {
|
|
2
|
+
if (typeof path === "string" && /^\.\.?\//.test(path)) {
|
|
3
|
+
return path.replace(/\.(tsx)$|((?:\.d)?)((?:\.[^./]+?)?)\.([cm]?)ts$/i, function (m, tsx, d, ext, cm) {
|
|
4
|
+
return tsx ? preserveJsx ? ".jsx" : ".js" : d && (!ext || !cm) ? m : (d + ext + "." + cm.toLowerCase() + "js");
|
|
5
|
+
});
|
|
6
|
+
}
|
|
7
|
+
return path;
|
|
8
|
+
};
|
|
9
|
+
/**
|
|
10
|
+
* Finding, loading, validating and resolving `ambit.config.ts`
|
|
11
|
+
* (DESIGN.md §4.1, "Out-of-code declarations").
|
|
12
|
+
*
|
|
13
|
+
* Imports no compiler. A config file is plain data about symbols the backend
|
|
14
|
+
* already produced ids for, so nothing here needs to know what a
|
|
15
|
+
* `ts.Node` is (§3.4).
|
|
16
|
+
*/
|
|
17
|
+
import fs from "node:fs";
|
|
18
|
+
import path from "node:path";
|
|
19
|
+
import { pathToFileURL } from "node:url";
|
|
20
|
+
import { isKnownEffect, isOnExceed, KNOWN_EFFECTS, parseCapability } from "../core/index.js";
|
|
21
|
+
/**
|
|
22
|
+
* The file names looked for, in order. §4.1 (c): the first one found in a
|
|
23
|
+
* directory is the one used — a second file in the same directory is never
|
|
24
|
+
* read, so a stale `ambit.config.js` beside a `.ts` cannot silently win.
|
|
25
|
+
*/
|
|
26
|
+
const CONFIG_FILENAMES = [
|
|
27
|
+
"ambit.config.ts",
|
|
28
|
+
"ambit.config.mts",
|
|
29
|
+
"ambit.config.js",
|
|
30
|
+
"ambit.config.mjs",
|
|
31
|
+
];
|
|
32
|
+
/** Thrown for every config problem. `main` turns it into exit 2 (§3.4). */
|
|
33
|
+
export class ConfigError extends Error {
|
|
34
|
+
}
|
|
35
|
+
/**
|
|
36
|
+
* Walk up from `startDir` looking for a config file, stopping after the first
|
|
37
|
+
* directory that holds a `package.json` or `.git` (§4.1 (c)) — a config
|
|
38
|
+
* outside the project must never be picked up silently.
|
|
39
|
+
*/
|
|
40
|
+
export function findConfigFile(startDir) {
|
|
41
|
+
let dir = path.resolve(startDir);
|
|
42
|
+
for (;;) {
|
|
43
|
+
for (const name of CONFIG_FILENAMES) {
|
|
44
|
+
const candidate = path.join(dir, name);
|
|
45
|
+
if (fs.existsSync(candidate) && fs.statSync(candidate).isFile())
|
|
46
|
+
return candidate;
|
|
47
|
+
}
|
|
48
|
+
if (isProjectBoundary(dir))
|
|
49
|
+
return undefined;
|
|
50
|
+
const parent = path.dirname(dir);
|
|
51
|
+
if (parent === dir)
|
|
52
|
+
return undefined;
|
|
53
|
+
dir = parent;
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
function isProjectBoundary(dir) {
|
|
57
|
+
return fs.existsSync(path.join(dir, "package.json")) || fs.existsSync(path.join(dir, ".git"));
|
|
58
|
+
}
|
|
59
|
+
/**
|
|
60
|
+
* Load the config that governs `startDir`, or `undefined` when there is none.
|
|
61
|
+
*
|
|
62
|
+
* The file is imported, not parsed: it is TypeScript or JavaScript, and Node
|
|
63
|
+
* strips types for any file outside `node_modules` — which a consumer's
|
|
64
|
+
* config always is, including when the CLI itself is running from `dist/`.
|
|
65
|
+
* A file that throws on import (a syntax error, a bad import) becomes a
|
|
66
|
+
* {@link ConfigError}: a config that could not be read must stop the run, not
|
|
67
|
+
* be treated as "no config" (§3.4).
|
|
68
|
+
*/
|
|
69
|
+
export async function loadConfig(startDir) {
|
|
70
|
+
const configPath = findConfigFile(startDir);
|
|
71
|
+
if (configPath === undefined)
|
|
72
|
+
return undefined;
|
|
73
|
+
let sourceText;
|
|
74
|
+
try {
|
|
75
|
+
sourceText = fs.readFileSync(configPath, "utf8");
|
|
76
|
+
}
|
|
77
|
+
catch (error) {
|
|
78
|
+
throw new ConfigError(`cannot read ${configPath}: ${messageOf(error)}`);
|
|
79
|
+
}
|
|
80
|
+
let module;
|
|
81
|
+
try {
|
|
82
|
+
module = (await import(__rewriteRelativeImportExtension(pathToFileURL(configPath).href)));
|
|
83
|
+
}
|
|
84
|
+
catch (error) {
|
|
85
|
+
throw new ConfigError(`cannot load ${configPath}: ${messageOf(error)}`);
|
|
86
|
+
}
|
|
87
|
+
if (module.default === undefined) {
|
|
88
|
+
throw new ConfigError(`${configPath} has no default export`);
|
|
89
|
+
}
|
|
90
|
+
return { configPath, config: validateConfig(module.default, configPath), sourceText };
|
|
91
|
+
}
|
|
92
|
+
const TOP_LEVEL_KEYS = ["effects", "contracts", "strict"];
|
|
93
|
+
const CONTRACT_KEYS = ["effects", "capabilities", "budget", "entrypoint", "boundary"];
|
|
94
|
+
const BUDGET_KEYS = ["timeMs", "costUsd", "llmCalls", "onExceed"];
|
|
95
|
+
/**
|
|
96
|
+
* Check the imported value against {@link AmbitConfig} by hand.
|
|
97
|
+
*
|
|
98
|
+
* An unknown key is rejected rather than ignored, for the reason a misspelled
|
|
99
|
+
* effect name is (AMB-E002): a `contarcts:` block that is silently dropped
|
|
100
|
+
* reads as a set of declarations and is not one.
|
|
101
|
+
*/
|
|
102
|
+
export function validateConfig(value, where) {
|
|
103
|
+
const root = asObject(value, where, "default export");
|
|
104
|
+
rejectUnknownKeys(root, TOP_LEVEL_KEYS, where, "");
|
|
105
|
+
const effects = root.effects === undefined ? undefined : validateEffectAliases(root.effects, where);
|
|
106
|
+
const strict = root.strict === undefined ? undefined : validateStringArray(root.strict, where, "strict");
|
|
107
|
+
const contracts = root.contracts === undefined
|
|
108
|
+
? undefined
|
|
109
|
+
: validateContracts(root.contracts, where, new Set(Object.keys(effects ?? {})));
|
|
110
|
+
return {
|
|
111
|
+
...(effects ? { effects } : {}),
|
|
112
|
+
...(contracts ? { contracts } : {}),
|
|
113
|
+
...(strict ? { strict } : {}),
|
|
114
|
+
};
|
|
115
|
+
}
|
|
116
|
+
function validateEffectAliases(value, where) {
|
|
117
|
+
const raw = asObject(value, where, "effects");
|
|
118
|
+
const out = {};
|
|
119
|
+
for (const [name, members] of Object.entries(raw)) {
|
|
120
|
+
if (name.trim().length === 0)
|
|
121
|
+
throw new ConfigError(`${where}: effects has an empty name`);
|
|
122
|
+
// A definition that shadows a standard effect would make `@effects env`
|
|
123
|
+
// mean something different in two files (§4.1 (d)).
|
|
124
|
+
if (isKnownEffect(name)) {
|
|
125
|
+
throw new ConfigError(`${where}: effects.${name} redefines the standard effect "${name}"; user-defined names must not collide with ${KNOWN_EFFECTS.join(", ")}`);
|
|
126
|
+
}
|
|
127
|
+
const list = validateStringArray(members, where, `effects.${name}`);
|
|
128
|
+
for (const member of list) {
|
|
129
|
+
// §4.1 (d): definitions do not expand into other definitions. Allowing
|
|
130
|
+
// it would need a cycle check and would buy nothing a flat list cannot
|
|
131
|
+
// express.
|
|
132
|
+
if (!isKnownEffect(member)) {
|
|
133
|
+
throw new ConfigError(`${where}: effects.${name} contains "${member}", which is not a standard effect (${KNOWN_EFFECTS.join(", ")})`);
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
if (list.length === 0)
|
|
137
|
+
throw new ConfigError(`${where}: effects.${name} is empty`);
|
|
138
|
+
out[name] = list;
|
|
139
|
+
}
|
|
140
|
+
return out;
|
|
141
|
+
}
|
|
142
|
+
function validateContracts(value, where, aliasNames) {
|
|
143
|
+
const raw = asObject(value, where, "contracts");
|
|
144
|
+
const out = {};
|
|
145
|
+
for (const [key, contract] of Object.entries(raw)) {
|
|
146
|
+
if (!key.includes("#")) {
|
|
147
|
+
throw new ConfigError(`${where}: contracts key ${JSON.stringify(key)} is not "<file>#<symbol>"`);
|
|
148
|
+
}
|
|
149
|
+
const [file = "", symbol = ""] = splitKey(key);
|
|
150
|
+
if (file.length === 0 || symbol.length === 0) {
|
|
151
|
+
throw new ConfigError(`${where}: contracts key ${JSON.stringify(key)} is not "<file>#<symbol>"`);
|
|
152
|
+
}
|
|
153
|
+
if (symbol.includes("*")) {
|
|
154
|
+
// §4.1 (b): the symbol half does not glob. A `#*` that quietly matched
|
|
155
|
+
// everything in a file would be a contract nobody wrote.
|
|
156
|
+
throw new ConfigError(`${where}: contracts key ${JSON.stringify(key)} globs the symbol half; only the file half may use * or **`);
|
|
157
|
+
}
|
|
158
|
+
out[key] = validateContract(contract, where, key, aliasNames);
|
|
159
|
+
}
|
|
160
|
+
return out;
|
|
161
|
+
}
|
|
162
|
+
function validateContract(value, where, key, aliasNames) {
|
|
163
|
+
const raw = asObject(value, where, `contracts[${JSON.stringify(key)}]`);
|
|
164
|
+
rejectUnknownKeys(raw, CONTRACT_KEYS, where, `contracts[${JSON.stringify(key)}].`);
|
|
165
|
+
const at = `contracts[${JSON.stringify(key)}]`;
|
|
166
|
+
const effects = raw.effects === undefined
|
|
167
|
+
? undefined
|
|
168
|
+
: validateStringArray(raw.effects, where, `${at}.effects`);
|
|
169
|
+
const capabilities = raw.capabilities === undefined
|
|
170
|
+
? undefined
|
|
171
|
+
: validateStringArray(raw.capabilities, where, `${at}.capabilities`);
|
|
172
|
+
if (raw.entrypoint !== undefined && typeof raw.entrypoint !== "boolean") {
|
|
173
|
+
throw new ConfigError(`${where}: ${at}.entrypoint must be a boolean`);
|
|
174
|
+
}
|
|
175
|
+
if (raw.boundary !== undefined && typeof raw.boundary !== "string") {
|
|
176
|
+
throw new ConfigError(`${where}: ${at}.boundary must be a string (the reason §4.6 requires)`);
|
|
177
|
+
}
|
|
178
|
+
if (raw.boundary !== undefined && raw.boundary.trim().length === 0) {
|
|
179
|
+
throw new ConfigError(`${where}: ${at}.boundary must give a non-empty reason (§4.6)`);
|
|
180
|
+
}
|
|
181
|
+
const budget = raw.budget === undefined ? undefined : validateBudget(raw.budget, where, at);
|
|
182
|
+
// A misspelled effect or a malformed capability is rejected here rather
|
|
183
|
+
// than turned into an "invalid" contract downstream: a JSDoc typo has a tag
|
|
184
|
+
// location a diagnostic can point at, a config typo has a file the run has
|
|
185
|
+
// already decided to trust, and §3.4 says a declaration that does not mean
|
|
186
|
+
// what it says must stop the run rather than narrow silently.
|
|
187
|
+
for (const effect of effects ?? []) {
|
|
188
|
+
if (!isKnownEffect(effect) && !aliasNames.has(effect)) {
|
|
189
|
+
throw new ConfigError(`${where}: ${at}.effects contains "${effect}", which is neither a standard effect nor defined under effects`);
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
for (const capability of capabilities ?? []) {
|
|
193
|
+
if (parseCapability(capability) === undefined) {
|
|
194
|
+
throw new ConfigError(`${where}: ${at}.capabilities contains "${capability}", which is not <resource>:<action>:<target>`);
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
if (effects === undefined &&
|
|
198
|
+
capabilities === undefined &&
|
|
199
|
+
budget === undefined &&
|
|
200
|
+
raw.entrypoint === undefined &&
|
|
201
|
+
raw.boundary === undefined) {
|
|
202
|
+
throw new ConfigError(`${where}: ${at} declares nothing`);
|
|
203
|
+
}
|
|
204
|
+
return {
|
|
205
|
+
...(effects ? { effects } : {}),
|
|
206
|
+
...(capabilities ? { capabilities } : {}),
|
|
207
|
+
...(budget ? { budget } : {}),
|
|
208
|
+
...(raw.entrypoint === undefined ? {} : { entrypoint: raw.entrypoint }),
|
|
209
|
+
...(raw.boundary === undefined ? {} : { boundary: raw.boundary.trim() }),
|
|
210
|
+
};
|
|
211
|
+
}
|
|
212
|
+
function validateBudget(value, where, at) {
|
|
213
|
+
const raw = asObject(value, where, `${at}.budget`);
|
|
214
|
+
rejectUnknownKeys(raw, BUDGET_KEYS, where, `${at}.budget.`);
|
|
215
|
+
const out = {};
|
|
216
|
+
for (const key of ["timeMs", "costUsd", "llmCalls"]) {
|
|
217
|
+
const limit = raw[key];
|
|
218
|
+
if (limit === undefined)
|
|
219
|
+
continue;
|
|
220
|
+
if (typeof limit !== "number" || !Number.isFinite(limit) || limit < 0) {
|
|
221
|
+
throw new ConfigError(`${where}: ${at}.budget.${key} must be a non-negative number`);
|
|
222
|
+
}
|
|
223
|
+
if (key === "llmCalls" && !Number.isInteger(limit)) {
|
|
224
|
+
throw new ConfigError(`${where}: ${at}.budget.llmCalls must be an integer`);
|
|
225
|
+
}
|
|
226
|
+
out[key] = limit;
|
|
227
|
+
}
|
|
228
|
+
if (raw.onExceed !== undefined) {
|
|
229
|
+
if (typeof raw.onExceed !== "string" || !isOnExceed(raw.onExceed)) {
|
|
230
|
+
throw new ConfigError(`${where}: ${at}.budget.onExceed must be throw, warn or abort`);
|
|
231
|
+
}
|
|
232
|
+
out.onExceed = raw.onExceed;
|
|
233
|
+
}
|
|
234
|
+
// Same rule as `parseBudgetTag`: a policy with nothing to exceed is not a
|
|
235
|
+
// budget.
|
|
236
|
+
if (out.timeMs === undefined && out.costUsd === undefined && out.llmCalls === undefined) {
|
|
237
|
+
throw new ConfigError(`${where}: ${at}.budget declares no limit`);
|
|
238
|
+
}
|
|
239
|
+
return out;
|
|
240
|
+
}
|
|
241
|
+
function asObject(value, where, at) {
|
|
242
|
+
if (typeof value !== "object" || value === null || Array.isArray(value)) {
|
|
243
|
+
throw new ConfigError(`${where}: ${at} must be an object`);
|
|
244
|
+
}
|
|
245
|
+
return value;
|
|
246
|
+
}
|
|
247
|
+
function rejectUnknownKeys(raw, known, where, prefix) {
|
|
248
|
+
for (const key of Object.keys(raw)) {
|
|
249
|
+
if (!known.includes(key)) {
|
|
250
|
+
throw new ConfigError(`${where}: unknown key ${prefix}${key} (known keys: ${known.join(", ")})`);
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
function validateStringArray(value, where, at) {
|
|
255
|
+
if (!Array.isArray(value) || value.some((item) => typeof item !== "string")) {
|
|
256
|
+
throw new ConfigError(`${where}: ${at} must be an array of strings`);
|
|
257
|
+
}
|
|
258
|
+
return value;
|
|
259
|
+
}
|
|
260
|
+
function messageOf(error) {
|
|
261
|
+
return error instanceof Error ? error.message : String(error);
|
|
262
|
+
}
|
|
263
|
+
/** `"a/b.ts#Foo.bar"` → `["a/b.ts", "Foo.bar"]`, splitting on the first `#` only. */
|
|
264
|
+
function splitKey(key) {
|
|
265
|
+
const hash = key.indexOf("#");
|
|
266
|
+
return [key.slice(0, hash), key.slice(hash + 1)];
|
|
267
|
+
}
|
|
268
|
+
/**
|
|
269
|
+
* Bind a loaded config to the directory being analyzed.
|
|
270
|
+
*
|
|
271
|
+
* Keys are written relative to the config file (§4.1 (c)) and symbol ids are
|
|
272
|
+
* relative to the analysis root, so every key is rebased once, here, and
|
|
273
|
+
* nothing downstream has to remember which base it is holding.
|
|
274
|
+
*/
|
|
275
|
+
export function resolveConfig(loaded, rootDir) {
|
|
276
|
+
const configDir = path.dirname(loaded.configPath);
|
|
277
|
+
const absoluteRoot = path.resolve(rootDir);
|
|
278
|
+
const entries = [];
|
|
279
|
+
for (const [key, contract] of Object.entries(loaded.config.contracts ?? {})) {
|
|
280
|
+
const hash = key.indexOf("#");
|
|
281
|
+
const filePattern = key.slice(0, hash);
|
|
282
|
+
const symbol = key.slice(hash + 1);
|
|
283
|
+
const rebased = rebase(filePattern, configDir, absoluteRoot);
|
|
284
|
+
entries.push({
|
|
285
|
+
key,
|
|
286
|
+
symbol,
|
|
287
|
+
matcher: globToRegExp(rebased),
|
|
288
|
+
exact: !filePattern.includes("*"),
|
|
289
|
+
file: rebased,
|
|
290
|
+
contract,
|
|
291
|
+
});
|
|
292
|
+
}
|
|
293
|
+
const strictMatchers = (loaded.config.strict ?? []).map((pattern) => globToRegExp(rebase(pattern, configDir, absoluteRoot)));
|
|
294
|
+
const effectAliases = new Map();
|
|
295
|
+
for (const [name, members] of Object.entries(loaded.config.effects ?? {})) {
|
|
296
|
+
effectAliases.set(name, members.filter(isKnownEffect));
|
|
297
|
+
}
|
|
298
|
+
const matchedKeys = new Set();
|
|
299
|
+
const relative = path.relative(absoluteRoot, loaded.configPath);
|
|
300
|
+
const displayPath = relative.startsWith("..") || path.isAbsolute(relative)
|
|
301
|
+
? loaded.configPath
|
|
302
|
+
: relative.split(path.sep).join("/");
|
|
303
|
+
return {
|
|
304
|
+
configPath: loaded.configPath,
|
|
305
|
+
displayPath,
|
|
306
|
+
sourceText: loaded.sourceText,
|
|
307
|
+
effectAliases,
|
|
308
|
+
contractFor(id) {
|
|
309
|
+
const hash = id.indexOf("#");
|
|
310
|
+
if (hash < 0)
|
|
311
|
+
return undefined;
|
|
312
|
+
const file = id.slice(0, hash);
|
|
313
|
+
const symbol = id.slice(hash + 1);
|
|
314
|
+
const candidates = entries.filter((entry) => entry.symbol === symbol && entry.matcher.test(file));
|
|
315
|
+
if (candidates.length === 0)
|
|
316
|
+
return undefined;
|
|
317
|
+
const exact = candidates.filter((entry) => entry.exact);
|
|
318
|
+
// §4.1 (b): an exact key always wins, and two globs on one symbol is an
|
|
319
|
+
// ambiguity the config author has to resolve — never a silent pick.
|
|
320
|
+
if (exact.length > 0) {
|
|
321
|
+
for (const entry of exact)
|
|
322
|
+
matchedKeys.add(entry.key);
|
|
323
|
+
if (exact.length > 1) {
|
|
324
|
+
throw new ConfigError(`${loaded.configPath}: ${exact.map((e) => JSON.stringify(e.key)).join(" and ")} both name ${id}`);
|
|
325
|
+
}
|
|
326
|
+
return exact[0]?.contract;
|
|
327
|
+
}
|
|
328
|
+
if (candidates.length > 1) {
|
|
329
|
+
throw new ConfigError(`${loaded.configPath}: ${candidates
|
|
330
|
+
.map((e) => JSON.stringify(e.key))
|
|
331
|
+
.join(" and ")} both match ${id}; an exact key is needed to say which contract applies`);
|
|
332
|
+
}
|
|
333
|
+
for (const entry of candidates)
|
|
334
|
+
matchedKeys.add(entry.key);
|
|
335
|
+
return candidates[0]?.contract;
|
|
336
|
+
},
|
|
337
|
+
isStrictFile(relativeFile) {
|
|
338
|
+
return strictMatchers.some((matcher) => matcher.test(relativeFile));
|
|
339
|
+
},
|
|
340
|
+
unmatchedExactKeys() {
|
|
341
|
+
return entries
|
|
342
|
+
.filter((entry) => entry.exact && !matchedKeys.has(entry.key))
|
|
343
|
+
.map((entry) => entry.key);
|
|
344
|
+
},
|
|
345
|
+
};
|
|
346
|
+
}
|
|
347
|
+
/**
|
|
348
|
+
* A config-relative pattern rewritten relative to the analysis root, using
|
|
349
|
+
* `/` throughout (a {@link SymbolId}'s file half always does, on every
|
|
350
|
+
* platform).
|
|
351
|
+
*/
|
|
352
|
+
function rebase(pattern, configDir, absoluteRoot) {
|
|
353
|
+
const absolute = path.resolve(configDir, pattern);
|
|
354
|
+
return path.relative(absoluteRoot, absolute).split(path.sep).join("/");
|
|
355
|
+
}
|
|
356
|
+
/**
|
|
357
|
+
* §4.1 (b)'s glob: `*` matches within one path segment, `**` crosses
|
|
358
|
+
* directories. Hand-rolled rather than delegated to `path.matchesGlob`, whose
|
|
359
|
+
* semantics are the shell's and are not the two lines the spec commits to.
|
|
360
|
+
*
|
|
361
|
+
* A pattern that escapes the analysis root (`../…` after rebasing) still
|
|
362
|
+
* compiles; it simply matches no root-relative file, which is the honest
|
|
363
|
+
* answer for a key naming something outside the run.
|
|
364
|
+
*/
|
|
365
|
+
export function globToRegExp(pattern) {
|
|
366
|
+
let out = "";
|
|
367
|
+
for (let i = 0; i < pattern.length; i++) {
|
|
368
|
+
const char = pattern[i];
|
|
369
|
+
if (char !== "*") {
|
|
370
|
+
out += escapeRegExp(char ?? "");
|
|
371
|
+
continue;
|
|
372
|
+
}
|
|
373
|
+
if (pattern[i + 1] === "*") {
|
|
374
|
+
// `**/` spans zero or more directories, so `src/**/a.ts` matches
|
|
375
|
+
// `src/a.ts` as well as `src/x/y/a.ts`.
|
|
376
|
+
if (pattern[i + 2] === "/") {
|
|
377
|
+
out += "(?:[^/]*(?:/|$))*";
|
|
378
|
+
i += 2;
|
|
379
|
+
continue;
|
|
380
|
+
}
|
|
381
|
+
out += "[\\s\\S]*";
|
|
382
|
+
i += 1;
|
|
383
|
+
continue;
|
|
384
|
+
}
|
|
385
|
+
out += "[^/]*";
|
|
386
|
+
}
|
|
387
|
+
return new RegExp(`^${out}$`);
|
|
388
|
+
}
|
|
389
|
+
function escapeRegExp(text) {
|
|
390
|
+
return text.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
391
|
+
}
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
import type { FunctionSummary, SkippedFunctionKind, SymbolId, UnresolvedReason } from "../core/index.ts";
|
|
2
|
+
import type { PropagatedFunction } from "./propagate.ts";
|
|
3
|
+
export interface CoverageInput {
|
|
4
|
+
readonly filesAnalyzed: number;
|
|
5
|
+
readonly skippedFunctions: ReadonlyMap<SkippedFunctionKind, number>;
|
|
6
|
+
readonly summaries: readonly FunctionSummary[];
|
|
7
|
+
readonly state: ReadonlyMap<SymbolId, PropagatedFunction>;
|
|
8
|
+
}
|
|
9
|
+
export interface CoverageReport {
|
|
10
|
+
readonly filesAnalyzed: number;
|
|
11
|
+
readonly functionsExtracted: number;
|
|
12
|
+
readonly functionsDeclared: number;
|
|
13
|
+
/**
|
|
14
|
+
* How {@link functionsDeclared} splits by where the declaration was written
|
|
15
|
+
* (DESIGN.md §4.1, "Out-of-code declarations"). Reported apart because the
|
|
16
|
+
* two are not
|
|
17
|
+
* interchangeable evidence: a JSDoc contract travels with the code and
|
|
18
|
+
* survives the package being removed (P5), while a config contract is a
|
|
19
|
+
* statement *about* code that was not touched — often third-party or
|
|
20
|
+
* generated. A coverage figure that merged them would hide how much of the
|
|
21
|
+
* declared surface lives outside the source it describes.
|
|
22
|
+
*/
|
|
23
|
+
readonly functionsDeclaredByJsDoc: number;
|
|
24
|
+
readonly functionsDeclaredByConfig: number;
|
|
25
|
+
/**
|
|
26
|
+
* Functions whose body is excluded from static analysis by `@boundary`
|
|
27
|
+
* (DESIGN.md §4.6). Counted apart from everything else because §4.3
|
|
28
|
+
* requires it: "Moving something to a boundary is tallied separately from
|
|
29
|
+
* succeeding at analysis". A boundary is a
|
|
30
|
+
* declared hole, and a coverage figure that folded it into the resolved
|
|
31
|
+
* count would report the hole as progress.
|
|
32
|
+
*/
|
|
33
|
+
readonly functionsBoundary: number;
|
|
34
|
+
/** Functions marked `@entrypoint`, and how many of those declare no `@capabilities`. */
|
|
35
|
+
readonly functionsEntrypoint: number;
|
|
36
|
+
readonly entrypointsWithoutCapabilities: number;
|
|
37
|
+
readonly functionsSkipped: number;
|
|
38
|
+
readonly skippedByKind: ReadonlyMap<SkippedFunctionKind, number>;
|
|
39
|
+
/**
|
|
40
|
+
* The primary KPI (DESIGN.md §4.3: "`ambit check --coverage` outputs the
|
|
41
|
+
* proportion of the codebase that depends on `unknown` and where it occurs.
|
|
42
|
+
* It is treated as a primary KPI"): the fraction of *all* extracted functions
|
|
43
|
+
* — declared or not — whose propagated effect set carries `unknown`. This
|
|
44
|
+
* is what a user actually cares about ("can Ambit say anything definite
|
|
45
|
+
* about this function?"), not the raw call-site resolution rate below,
|
|
46
|
+
* which is an internal diagnostic, not the target itself.
|
|
47
|
+
*/
|
|
48
|
+
readonly functionUnknownRate: number;
|
|
49
|
+
/**
|
|
50
|
+
* Boundary functions as a fraction of extracted functions, reported next to
|
|
51
|
+
* {@link functionUnknownRate} because tagging a function `@boundary` moves
|
|
52
|
+
* it out of the unknown numerator while leaving it in the denominator.
|
|
53
|
+
* Without this figure beside it, declaring boundaries would read as an
|
|
54
|
+
* improving KPI (DESIGN.md §4.3: "Moving something to a boundary is tallied
|
|
55
|
+
* separately from succeeding at analysis").
|
|
56
|
+
* The two rates together are the fraction of functions whose contract is
|
|
57
|
+
* not backed by a verified body.
|
|
58
|
+
*/
|
|
59
|
+
readonly functionBoundaryRate: number;
|
|
60
|
+
readonly callSitesTotal: number;
|
|
61
|
+
readonly callSitesResolved: number;
|
|
62
|
+
readonly callSitesStub: number;
|
|
63
|
+
readonly callSitesPure: number;
|
|
64
|
+
/**
|
|
65
|
+
* In-place mutation sites (DESIGN.md §4.2, "Local mutation and `pure`").
|
|
66
|
+
* Counted apart from `callSitesPure` and `callSitesStub`: a local mutation
|
|
67
|
+
* carries no effect but is not the same evidence as a call proven pure, and
|
|
68
|
+
* an escaping one is a `state_write` that no stub table produced.
|
|
69
|
+
*/
|
|
70
|
+
readonly callSitesMutation: number;
|
|
71
|
+
readonly callSitesUnresolved: number;
|
|
72
|
+
readonly unresolvedByReason: ReadonlyMap<UnresolvedReason, number>;
|
|
73
|
+
readonly topUnresolvedNames: readonly {
|
|
74
|
+
readonly name: string;
|
|
75
|
+
readonly count: number;
|
|
76
|
+
}[];
|
|
77
|
+
}
|
|
78
|
+
export declare function computeCoverage(input: CoverageInput): CoverageReport;
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
/** How many top unresolved-call names to surface — the "what to stub next" signal. */
|
|
2
|
+
const TOP_UNRESOLVED_NAMES = 10;
|
|
3
|
+
export function computeCoverage(input) {
|
|
4
|
+
const { filesAnalyzed, skippedFunctions, summaries, state } = input;
|
|
5
|
+
const functionsExtracted = summaries.length;
|
|
6
|
+
const declaredSummaries = summaries.filter((s) => s.declared.kind === "declared");
|
|
7
|
+
const functionsDeclared = declaredSummaries.length;
|
|
8
|
+
const functionsDeclaredByConfig = declaredSummaries.filter((s) => s.declaredBy?.effects === "config").length;
|
|
9
|
+
const functionsDeclaredByJsDoc = functionsDeclared - functionsDeclaredByConfig;
|
|
10
|
+
const functionsBoundary = summaries.filter((s) => s.boundary.kind === "declared").length;
|
|
11
|
+
const entrypoints = summaries.filter((s) => s.entrypoint);
|
|
12
|
+
const entrypointsWithoutCapabilities = entrypoints.filter((s) => s.capabilities.kind !== "declared").length;
|
|
13
|
+
let unknownCount = 0;
|
|
14
|
+
for (const summary of summaries) {
|
|
15
|
+
if (state.get(summary.id)?.observed.unknown)
|
|
16
|
+
unknownCount++;
|
|
17
|
+
}
|
|
18
|
+
const functionUnknownRate = functionsExtracted === 0 ? 0 : unknownCount / functionsExtracted;
|
|
19
|
+
const functionBoundaryRate = functionsExtracted === 0 ? 0 : functionsBoundary / functionsExtracted;
|
|
20
|
+
let callSitesResolved = 0;
|
|
21
|
+
let callSitesStub = 0;
|
|
22
|
+
let callSitesPure = 0;
|
|
23
|
+
let callSitesMutation = 0;
|
|
24
|
+
let callSitesUnresolved = 0;
|
|
25
|
+
const unresolvedByReason = new Map();
|
|
26
|
+
const nameFrequency = new Map();
|
|
27
|
+
for (const summary of summaries) {
|
|
28
|
+
// A boundary's body is excluded from propagation, so its call sites say
|
|
29
|
+
// nothing about how well the analysis resolves things. Counting them
|
|
30
|
+
// would make a declared hole look like unresolved analysis (and pad
|
|
31
|
+
// `topUnresolvedNames`, the "what to stub next" signal, with names no
|
|
32
|
+
// stub would help).
|
|
33
|
+
if (summary.boundary.kind === "declared")
|
|
34
|
+
continue;
|
|
35
|
+
for (const call of summary.calls) {
|
|
36
|
+
if (call.kind === "resolved") {
|
|
37
|
+
callSitesResolved++;
|
|
38
|
+
}
|
|
39
|
+
else if (call.kind === "stub") {
|
|
40
|
+
callSitesStub++;
|
|
41
|
+
}
|
|
42
|
+
else if (call.kind === "known-pure") {
|
|
43
|
+
callSitesPure++;
|
|
44
|
+
}
|
|
45
|
+
else if (call.kind === "mutation") {
|
|
46
|
+
callSitesMutation++;
|
|
47
|
+
}
|
|
48
|
+
else {
|
|
49
|
+
callSitesUnresolved++;
|
|
50
|
+
unresolvedByReason.set(call.reason, (unresolvedByReason.get(call.reason) ?? 0) + 1);
|
|
51
|
+
if (call.qualifiedName) {
|
|
52
|
+
nameFrequency.set(call.qualifiedName, (nameFrequency.get(call.qualifiedName) ?? 0) + 1);
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
const topUnresolvedNames = [...nameFrequency.entries()]
|
|
58
|
+
.sort(([nameA, countA], [nameB, countB]) => countB - countA || nameA.localeCompare(nameB))
|
|
59
|
+
.slice(0, TOP_UNRESOLVED_NAMES)
|
|
60
|
+
.map(([name, count]) => ({ name, count }));
|
|
61
|
+
const functionsSkipped = [...skippedFunctions.values()].reduce((total, n) => total + n, 0);
|
|
62
|
+
return {
|
|
63
|
+
filesAnalyzed,
|
|
64
|
+
functionsExtracted,
|
|
65
|
+
functionsDeclared,
|
|
66
|
+
functionsDeclaredByJsDoc,
|
|
67
|
+
functionsDeclaredByConfig,
|
|
68
|
+
functionsBoundary,
|
|
69
|
+
functionsEntrypoint: entrypoints.length,
|
|
70
|
+
entrypointsWithoutCapabilities,
|
|
71
|
+
functionsSkipped,
|
|
72
|
+
skippedByKind: skippedFunctions,
|
|
73
|
+
functionUnknownRate,
|
|
74
|
+
functionBoundaryRate,
|
|
75
|
+
callSitesTotal: callSitesResolved + callSitesStub + callSitesPure + callSitesMutation + callSitesUnresolved,
|
|
76
|
+
callSitesResolved,
|
|
77
|
+
callSitesStub,
|
|
78
|
+
callSitesPure,
|
|
79
|
+
callSitesMutation,
|
|
80
|
+
callSitesUnresolved,
|
|
81
|
+
unresolvedByReason,
|
|
82
|
+
topUnresolvedNames,
|
|
83
|
+
};
|
|
84
|
+
}
|