balladeer 1.0.14 → 1.0.16
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/README.md +2 -0
- package/dist/behavior-map-schema.d.ts +107 -0
- package/dist/behavior-map-schema.js +232 -0
- package/dist/cli.d.ts +5 -0
- package/dist/cli.js +77 -2
- package/dist/commands/guidance.d.ts +1 -1
- package/dist/commands/guidance.js +6 -0
- package/dist/commands/judge.d.ts +271 -0
- package/dist/commands/judge.js +1170 -0
- package/dist/commands/map.d.ts +120 -0
- package/dist/commands/map.js +601 -0
- package/dist/commands/mcp.d.ts +6 -0
- package/dist/commands/mcp.js +53 -0
- package/dist/commands/offers.d.ts +265 -0
- package/dist/commands/offers.js +801 -0
- package/dist/commands/risk.d.ts +29 -0
- package/dist/commands/risk.js +133 -0
- package/dist/copy.d.ts +24 -1
- package/dist/copy.js +91 -0
- package/dist/guidance-hook.mjs +230 -74
- package/dist/guidance.d.ts +2 -0
- package/dist/guidance.js +7 -1
- package/dist/headless-agent.d.ts +166 -0
- package/dist/headless-agent.js +416 -0
- package/dist/hook-trust.d.ts +31 -0
- package/dist/hook-trust.js +63 -0
- package/dist/judge-brief.d.ts +36 -0
- package/dist/judge-brief.js +89 -0
- package/dist/judge-hook.d.ts +84 -0
- package/dist/judge-hook.js +420 -0
- package/dist/map-brief.d.ts +16 -0
- package/dist/map-brief.js +41 -0
- package/dist/offer-brief.d.ts +95 -0
- package/dist/offer-brief.js +217 -0
- package/dist/owned-process.d.ts +65 -0
- package/dist/owned-process.js +146 -0
- package/dist/promise-meaning.d.ts +126 -0
- package/dist/promise-meaning.js +292 -0
- package/dist/risk/contract.d.ts +145 -0
- package/dist/risk/contract.js +74 -0
- package/dist/risk/describe.d.ts +7 -0
- package/dist/risk/describe.js +45 -0
- package/dist/risk/diff.d.ts +26 -0
- package/dist/risk/diff.js +174 -0
- package/dist/risk/extract.d.ts +43 -0
- package/dist/risk/extract.js +336 -0
- package/dist/risk/git.d.ts +30 -0
- package/dist/risk/git.js +118 -0
- package/dist/risk/import-graph.d.ts +41 -0
- package/dist/risk/import-graph.js +487 -0
- package/dist/risk/index.d.ts +20 -0
- package/dist/risk/index.js +20 -0
- package/dist/risk/paths.d.ts +16 -0
- package/dist/risk/paths.js +73 -0
- package/dist/risk/pipeline.d.ts +47 -0
- package/dist/risk/pipeline.js +121 -0
- package/dist/risk/priors.d.ts +18 -0
- package/dist/risk/priors.js +86 -0
- package/dist/risk/resources.d.ts +56 -0
- package/dist/risk/resources.js +374 -0
- package/dist/risk/score.d.ts +85 -0
- package/dist/risk/score.js +543 -0
- package/dist/risk/symbols.d.ts +35 -0
- package/dist/risk/symbols.js +348 -0
- package/dist/risk/text.d.ts +43 -0
- package/dist/risk/text.js +277 -0
- package/dist/risk/validate.d.ts +19 -0
- package/dist/risk/validate.js +154 -0
- package/dist/scratch-worktree.d.ts +51 -0
- package/dist/scratch-worktree.js +153 -0
- package/dist/self-update.d.ts +29 -2
- package/dist/self-update.js +96 -11
- package/dist/user-scope.d.ts +2 -0
- package/dist/user-scope.js +26 -2
- package/dist/wire.d.ts +55 -2
- package/dist/wire.js +5 -1
- package/package.json +1 -1
|
@@ -0,0 +1,374 @@
|
|
|
1
|
+
import { basenameOf, extensionOf } from "./paths.js";
|
|
2
|
+
export function resourceKey(ref) {
|
|
3
|
+
return `${ref.kind}:${ref.name}`;
|
|
4
|
+
}
|
|
5
|
+
/** `outbound:stripe.charges.create` → { kind: outbound, name: stripe.charges.create }. */
|
|
6
|
+
export function parseResourceRef(text) {
|
|
7
|
+
const colon = text.indexOf(":");
|
|
8
|
+
if (colon <= 0)
|
|
9
|
+
return undefined;
|
|
10
|
+
const kind = text.slice(0, colon);
|
|
11
|
+
const name = text.slice(colon + 1).trim();
|
|
12
|
+
if (!RESOURCE_KIND_SET.has(kind) || name.length === 0)
|
|
13
|
+
return undefined;
|
|
14
|
+
return { kind, name };
|
|
15
|
+
}
|
|
16
|
+
const RESOURCE_KIND_SET = new Set([
|
|
17
|
+
"table",
|
|
18
|
+
"column",
|
|
19
|
+
"route",
|
|
20
|
+
"flag",
|
|
21
|
+
"env",
|
|
22
|
+
"queue",
|
|
23
|
+
"event",
|
|
24
|
+
"outbound",
|
|
25
|
+
"file",
|
|
26
|
+
"config",
|
|
27
|
+
]);
|
|
28
|
+
const SQL_WORDS = new Set([
|
|
29
|
+
"select",
|
|
30
|
+
"where",
|
|
31
|
+
"set",
|
|
32
|
+
"values",
|
|
33
|
+
"the",
|
|
34
|
+
"a",
|
|
35
|
+
"an",
|
|
36
|
+
"and",
|
|
37
|
+
"or",
|
|
38
|
+
"not",
|
|
39
|
+
"exists",
|
|
40
|
+
"if",
|
|
41
|
+
"on",
|
|
42
|
+
"as",
|
|
43
|
+
"only",
|
|
44
|
+
"lateral",
|
|
45
|
+
"unnest",
|
|
46
|
+
"table",
|
|
47
|
+
"into",
|
|
48
|
+
"from",
|
|
49
|
+
"join",
|
|
50
|
+
"update",
|
|
51
|
+
"using",
|
|
52
|
+
"with",
|
|
53
|
+
"public",
|
|
54
|
+
"returning",
|
|
55
|
+
"default",
|
|
56
|
+
]);
|
|
57
|
+
const FILE_EXTENSIONS = "json|jsonl|ya?ml|toml|ini|env|txt|md|csv|tsv|db|sqlite|sqlite3|lock|log|conf|cfg|xml|properties|pem|key|plist";
|
|
58
|
+
/** Whether the file is SQL, where keywords are read in any case. */
|
|
59
|
+
export function isSqlPath(path) {
|
|
60
|
+
return extensionOf(path) === "sql";
|
|
61
|
+
}
|
|
62
|
+
function cleanTableName(raw) {
|
|
63
|
+
const name = raw.replace(/["`[\]]/g, "").replace(/;$/, "");
|
|
64
|
+
if (name.length < 2)
|
|
65
|
+
return undefined;
|
|
66
|
+
if (!/^[A-Za-z_][\w.]*$/.test(name))
|
|
67
|
+
return undefined;
|
|
68
|
+
const last = name.slice(name.lastIndexOf(".") + 1).toLowerCase();
|
|
69
|
+
if (SQL_WORDS.has(last))
|
|
70
|
+
return undefined;
|
|
71
|
+
return name.toLowerCase();
|
|
72
|
+
}
|
|
73
|
+
/** Normalizes a route so `/orders/:id`, `/orders/{id}` and `/orders/[id]` meet. */
|
|
74
|
+
export function normalizeRoute(route) {
|
|
75
|
+
const path = route.split(/[?#]/)[0] ?? route;
|
|
76
|
+
const normalized = path
|
|
77
|
+
.split("/")
|
|
78
|
+
.map((segment) => /^(?::\w+|\{[^}]*\}|\[[^\]]*\]|<[^>]*>|\$\{[^}]*\})$/.test(segment) ? ":param" : segment)
|
|
79
|
+
.join("/")
|
|
80
|
+
.replace(/\/+$/, "");
|
|
81
|
+
return (normalized.startsWith("/") ? normalized : `/${normalized}`).toLowerCase();
|
|
82
|
+
}
|
|
83
|
+
function looksLikeRoute(literal) {
|
|
84
|
+
if (!/^\/[A-Za-z0-9_:{[<$-]/.test(literal))
|
|
85
|
+
return false;
|
|
86
|
+
if (/\s/.test(literal))
|
|
87
|
+
return false;
|
|
88
|
+
// A file on disk rather than a route: an extension at the end.
|
|
89
|
+
if (new RegExp(`\\.(?:${FILE_EXTENSIONS}|js|ts|mjs|cjs|py|html|css|png|svg|sh)$`, "i").test(literal))
|
|
90
|
+
return false;
|
|
91
|
+
if (/^\/(?:usr|etc|tmp|var|bin|dev|proc|home|Users|opt)\b/.test(literal))
|
|
92
|
+
return false;
|
|
93
|
+
return true;
|
|
94
|
+
}
|
|
95
|
+
function looksLikeFile(literal) {
|
|
96
|
+
if (/\s/.test(literal) || literal.length > 300)
|
|
97
|
+
return false;
|
|
98
|
+
if (/^[a-z]+:\/\//i.test(literal))
|
|
99
|
+
return false;
|
|
100
|
+
return new RegExp(`(?:^|/)[\\w.@-]+\\.(?:${FILE_EXTENSIONS})$`, "i").test(literal);
|
|
101
|
+
}
|
|
102
|
+
const STRING_LITERAL = /(["'`])((?:\\.|(?!\1)[^\\\n])*)\1/g;
|
|
103
|
+
/** Every resource this one line names. */
|
|
104
|
+
export function resourcesInLine(line, context) {
|
|
105
|
+
const found = new Map();
|
|
106
|
+
const add = (kind, name) => {
|
|
107
|
+
if (name === undefined)
|
|
108
|
+
return;
|
|
109
|
+
const trimmed = name.trim();
|
|
110
|
+
if (trimmed.length === 0 || trimmed.length > 600)
|
|
111
|
+
return;
|
|
112
|
+
found.set(`${kind}:${trimmed}`, { kind, name: trimmed });
|
|
113
|
+
};
|
|
114
|
+
const sql = isSqlPath(context.path);
|
|
115
|
+
const trimmed = line.trim();
|
|
116
|
+
const isImportLine = /^(?:import|export)\b.*\bfrom\b/.test(trimmed) ||
|
|
117
|
+
/^from\s+[\w.]+\s+import\b/.test(trimmed) ||
|
|
118
|
+
/^import\s+[\w.]+/.test(trimmed);
|
|
119
|
+
// SQL tables. In SQL files any case; elsewhere only upper-case keywords, which
|
|
120
|
+
// is how SQL embedded in code is conventionally written and what keeps "from
|
|
121
|
+
// the queue" in a comment from naming a table called "the".
|
|
122
|
+
if (!isImportLine) {
|
|
123
|
+
const keyword = sql
|
|
124
|
+
? /\b(?:from|join|into|update|references|(?:create|alter|drop|truncate)\s+table(?:\s+if\s+(?:not\s+)?exists)?)\s+(?:only\s+)?([`"[]?[A-Za-z_][\w.]*[`"\]]?)/gi
|
|
125
|
+
: /\b(?:FROM|JOIN|INTO|UPDATE|REFERENCES|(?:CREATE|ALTER|DROP|TRUNCATE)\s+TABLE(?:\s+IF\s+(?:NOT\s+)?EXISTS)?)\s+(?:ONLY\s+)?([`"[]?[A-Za-z_][\w.]*[`"\]]?)/g;
|
|
126
|
+
for (const match of line.matchAll(keyword))
|
|
127
|
+
add("table", cleanTableName(match[1] ?? ""));
|
|
128
|
+
// Query builders: supabase `.from("orders")`, knex `.table("orders")`, `.into("orders")`.
|
|
129
|
+
for (const match of line.matchAll(/\.(?:from|table|into|insertInto|selectFrom|updateTable|deleteFrom)\(\s*["'`]([A-Za-z_][\w.]*)["'`]/g))
|
|
130
|
+
add("table", cleanTableName(match[1] ?? ""));
|
|
131
|
+
}
|
|
132
|
+
if (sql) {
|
|
133
|
+
const columnPatterns = [
|
|
134
|
+
/\b(?:add|drop|alter|rename)\s+column\s+(?:if\s+(?:not\s+)?exists\s+)?["`]?([A-Za-z_]\w*)["`]?/gi,
|
|
135
|
+
/\brename\s+column\s+["`]?[A-Za-z_]\w*["`]?\s+to\s+["`]?([A-Za-z_]\w*)["`]?/gi,
|
|
136
|
+
/\badd\s+(?!column\b|constraint\b|index\b|primary\b|foreign\b|unique\b|check\b)["`]?([A-Za-z_]\w*)["`]?\s+[A-Za-z]/gi,
|
|
137
|
+
];
|
|
138
|
+
for (const pattern of columnPatterns) {
|
|
139
|
+
for (const match of line.matchAll(pattern)) {
|
|
140
|
+
const column = (match[1] ?? "").toLowerCase();
|
|
141
|
+
if (column.length === 0)
|
|
142
|
+
continue;
|
|
143
|
+
add("column", context.currentTable ? `${context.currentTable}.${column}` : column);
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
// `ALTER TABLE orders ADD COLUMN x` on one line names its own table.
|
|
147
|
+
const inline = /\balter\s+table\s+(?:if\s+exists\s+)?(?:only\s+)?([`"]?[A-Za-z_][\w.]*[`"]?)/i.exec(line);
|
|
148
|
+
if (inline) {
|
|
149
|
+
const table = cleanTableName(inline[1] ?? "");
|
|
150
|
+
for (const pattern of columnPatterns) {
|
|
151
|
+
for (const match of line.matchAll(pattern)) {
|
|
152
|
+
const column = (match[1] ?? "").toLowerCase();
|
|
153
|
+
if (table && column.length > 0)
|
|
154
|
+
add("column", `${table}.${column}`);
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
if (context.inCreateTable) {
|
|
159
|
+
const column = /^\s*["`]?([A-Za-z_]\w*)["`]?\s+[A-Za-z]/.exec(line);
|
|
160
|
+
const name = column?.[1]?.toLowerCase();
|
|
161
|
+
if (name !== undefined &&
|
|
162
|
+
!/^(?:constraint|primary|foreign|unique|check|create|like|exclude)$/.test(name))
|
|
163
|
+
add("column", context.currentTable ? `${context.currentTable}.${name}` : name);
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
// Migrations written in code: alembic `op.drop_column("orders", "status")`,
|
|
167
|
+
// Rails `remove_column :orders, :status`, knex `table.dropColumn("status")`.
|
|
168
|
+
for (const match of line.matchAll(/\bop\.(?:add|drop|alter)_column\(\s*["'](\w+)["']\s*,\s*(?:sa\.Column\(\s*)?["'](\w+)["']/g)) {
|
|
169
|
+
add("table", cleanTableName(match[1] ?? ""));
|
|
170
|
+
add("column", `${(match[1] ?? "").toLowerCase()}.${(match[2] ?? "").toLowerCase()}`);
|
|
171
|
+
}
|
|
172
|
+
for (const match of line.matchAll(/\b(?:add|remove|rename|change)_column\s*\(?\s*:(\w+)\s*,\s*:(\w+)/g)) {
|
|
173
|
+
add("table", cleanTableName(match[1] ?? ""));
|
|
174
|
+
add("column", `${(match[1] ?? "").toLowerCase()}.${(match[2] ?? "").toLowerCase()}`);
|
|
175
|
+
}
|
|
176
|
+
for (const match of line.matchAll(/\.(?:dropColumn|renameColumn)\(\s*["'](\w+)["']/g))
|
|
177
|
+
add("column", (match[1] ?? "").toLowerCase());
|
|
178
|
+
// Environment keys.
|
|
179
|
+
for (const pattern of [
|
|
180
|
+
/\bprocess\.env\.([A-Za-z_]\w*)/g,
|
|
181
|
+
/\bprocess\.env\[\s*["'`]([A-Za-z_]\w*)["'`]\s*\]/g,
|
|
182
|
+
/\bimport\.meta\.env\.([A-Za-z_]\w*)/g,
|
|
183
|
+
/\bos\.environ\[\s*["']([A-Za-z_]\w*)["']\s*\]/g,
|
|
184
|
+
/\bos\.environ\.get\(\s*["']([A-Za-z_]\w*)["']/g,
|
|
185
|
+
/\bos\.getenv\(\s*["']([A-Za-z_]\w*)["']/g,
|
|
186
|
+
/\bos\.Getenv\(\s*"([A-Za-z_]\w*)"/g,
|
|
187
|
+
/\bENV\[\s*["']([A-Za-z_]\w*)["']\s*\]/g,
|
|
188
|
+
/\bENV\.fetch\(\s*["']([A-Za-z_]\w*)["']/g,
|
|
189
|
+
/\bSystem\.getenv\(\s*"([A-Za-z_]\w*)"/g,
|
|
190
|
+
/\benv::var\(\s*"([A-Za-z_]\w*)"/g,
|
|
191
|
+
/\bDeno\.env\.get\(\s*["']([A-Za-z_]\w*)["']/g,
|
|
192
|
+
]) {
|
|
193
|
+
for (const match of line.matchAll(pattern))
|
|
194
|
+
add("env", match[1]);
|
|
195
|
+
}
|
|
196
|
+
// Feature flags.
|
|
197
|
+
for (const match of line.matchAll(/\b(?:flag|isEnabled|isFeatureEnabled|isFeatureFlagEnabled|featureEnabled|feature_enabled|is_enabled|useFlag|getFlag|useFeatureFlag|getFeatureFlag|variation|boolVariation|isOn|flipper)\(\s*["'`]([\w.:/-]+)["'`]/g))
|
|
198
|
+
add("flag", match[1]);
|
|
199
|
+
for (const match of line.matchAll(/\b(?:features|featureFlags|feature_flags|flags)\.([A-Za-z_]\w*)/g))
|
|
200
|
+
add("flag", match[1]);
|
|
201
|
+
// Queues and events.
|
|
202
|
+
for (const match of line.matchAll(/\b(?:emit|on|once|off|addEventListener|removeEventListener|trigger|dispatchEvent|listen)\(\s*["'`]([\w.:/-]+)["'`]/g))
|
|
203
|
+
add("event", match[1]);
|
|
204
|
+
for (const match of line.matchAll(/\b(?:publish|subscribe|enqueue|send_task|sendToQueue|assertQueue|consume|Queue|queue|topic|channel)\(\s*["'`]([\w.:/-]+)["'`]/g))
|
|
205
|
+
add("queue", match[1]);
|
|
206
|
+
// Configuration keys.
|
|
207
|
+
for (const match of line.matchAll(/\b(?:config|conf|cfg|settings|options)\s*(?:\.get\(\s*|\[\s*)["'`]([\w.:-]+)["'`]/g))
|
|
208
|
+
add("config", match[1]);
|
|
209
|
+
for (const match of line.matchAll(/\b(?:getConfig|readConfig|configValue)\(\s*["'`]([\w.:-]+)["'`]/g))
|
|
210
|
+
add("config", match[1]);
|
|
211
|
+
// Route decorators without a leading slash: NestJS `@Get("orders")`.
|
|
212
|
+
for (const match of line.matchAll(/@(?:Get|Post|Put|Patch|Delete|All|Head|Options)\(\s*["'`]([^"'`]*)["'`]/g))
|
|
213
|
+
add("route", normalizeRoute(match[1] ?? ""));
|
|
214
|
+
// String literals: routes, outbound hosts, files.
|
|
215
|
+
for (const match of line.matchAll(STRING_LITERAL)) {
|
|
216
|
+
const literal = match[2] ?? "";
|
|
217
|
+
for (const url of literal.matchAll(/\b[a-z][a-z0-9+.-]*:\/\/([A-Za-z0-9.-]+)(?::\d+)?(\/[^\s"'`]*)?/gi)) {
|
|
218
|
+
const host = (url[1] ?? "").toLowerCase();
|
|
219
|
+
if (host.length > 0 && !/^(?:localhost|127\.0\.0\.1|0\.0\.0\.0)$/.test(host))
|
|
220
|
+
add("outbound", host);
|
|
221
|
+
}
|
|
222
|
+
if (looksLikeRoute(literal))
|
|
223
|
+
add("route", normalizeRoute(literal));
|
|
224
|
+
if (looksLikeFile(literal))
|
|
225
|
+
add("file", literal.replace(/^\.\//, ""));
|
|
226
|
+
}
|
|
227
|
+
return [...found.values()];
|
|
228
|
+
}
|
|
229
|
+
/** Next.js and similar file-system routers: the route a file serves, from its path. */
|
|
230
|
+
export function routeFromPath(path) {
|
|
231
|
+
const app = /(?:^|\/)app\/(.*)\/route\.[jt]sx?$/.exec(path);
|
|
232
|
+
if (app)
|
|
233
|
+
return normalizeRoute(`/${(app[1] ?? "").replace(/\([^)]*\)\/?/g, "")}`);
|
|
234
|
+
const pages = /(?:^|\/)pages\/(api\/.*?)(?:\/index)?\.[jt]sx?$/.exec(path);
|
|
235
|
+
if (pages)
|
|
236
|
+
return normalizeRoute(`/${pages[1] ?? ""}`);
|
|
237
|
+
return undefined;
|
|
238
|
+
}
|
|
239
|
+
function escapeRegExp(text) {
|
|
240
|
+
return text.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
241
|
+
}
|
|
242
|
+
const compiled = new Map();
|
|
243
|
+
/** The needle as a whole word, compiled once per needle. */
|
|
244
|
+
function wordPattern(needle, flags) {
|
|
245
|
+
const key = `${flags}\u0000${needle}`;
|
|
246
|
+
const cached = compiled.get(key);
|
|
247
|
+
if (cached !== undefined)
|
|
248
|
+
return cached;
|
|
249
|
+
const before = /^\w/.test(needle) ? "(?<![\\w$])" : "";
|
|
250
|
+
const after = /\w$/.test(needle) ? "(?![\\w$])" : "";
|
|
251
|
+
const pattern = new RegExp(`${before}${escapeRegExp(needle)}${after}`, flags);
|
|
252
|
+
if (compiled.size > 10_000)
|
|
253
|
+
compiled.clear();
|
|
254
|
+
compiled.set(key, pattern);
|
|
255
|
+
return pattern;
|
|
256
|
+
}
|
|
257
|
+
/** The contents of every string literal on a line. */
|
|
258
|
+
export function stringLiterals(line) {
|
|
259
|
+
return [...line.matchAll(STRING_LITERAL)].map((match) => match[2] ?? "");
|
|
260
|
+
}
|
|
261
|
+
const lastSegment = (name) => name.slice(name.lastIndexOf(".") + 1);
|
|
262
|
+
/**
|
|
263
|
+
* Whether one line names a resource a behavior map lists, read directly.
|
|
264
|
+
*
|
|
265
|
+
* The extraction runs this for every resource the maps list, so a resource the
|
|
266
|
+
* expressions above cannot recognize (a call chain such as
|
|
267
|
+
* `stripe.charges.create`, a configuration key) is still found where it is
|
|
268
|
+
* written. Tables, columns and files are looked for inside string literals
|
|
269
|
+
* only, outside SQL files, because their names are ordinary words in code.
|
|
270
|
+
* Routes are left to the route literals above, which normalize parameters.
|
|
271
|
+
* Names shorter than four characters are too common to look for.
|
|
272
|
+
*/
|
|
273
|
+
export function mentionsResource(line, ref, context) {
|
|
274
|
+
const sql = isSqlPath(context.path);
|
|
275
|
+
let needle;
|
|
276
|
+
switch (ref.kind) {
|
|
277
|
+
case "route":
|
|
278
|
+
return false;
|
|
279
|
+
case "file":
|
|
280
|
+
needle = basenameOf(ref.name);
|
|
281
|
+
break;
|
|
282
|
+
case "table":
|
|
283
|
+
case "column":
|
|
284
|
+
needle = lastSegment(ref.name);
|
|
285
|
+
break;
|
|
286
|
+
case "config": {
|
|
287
|
+
// `settings.json#hooks.PreToolUse` names the key after the file, and code
|
|
288
|
+
// spells a nested key one segment at a time, so the last segment is what
|
|
289
|
+
// a line carries.
|
|
290
|
+
const key = ref.name.includes("#") ? ref.name.slice(ref.name.indexOf("#") + 1) : ref.name;
|
|
291
|
+
needle = lastSegment(key);
|
|
292
|
+
break;
|
|
293
|
+
}
|
|
294
|
+
default:
|
|
295
|
+
needle = ref.name;
|
|
296
|
+
}
|
|
297
|
+
if (needle.length < 4)
|
|
298
|
+
return false;
|
|
299
|
+
const caseless = ref.kind === "table" || ref.kind === "column";
|
|
300
|
+
const pattern = wordPattern(needle, caseless ? "i" : "");
|
|
301
|
+
const literalOnly = (ref.kind === "table" || ref.kind === "column" || ref.kind === "file") && !sql;
|
|
302
|
+
const places = literalOnly ? stringLiterals(line) : [line];
|
|
303
|
+
for (const place of places) {
|
|
304
|
+
if (!pattern.test(place))
|
|
305
|
+
continue;
|
|
306
|
+
if (ref.kind === "column" && ref.name.includes(".")) {
|
|
307
|
+
// A bare column name is an ordinary word; it counts when its table is in sight.
|
|
308
|
+
const table = lastSegment(ref.name.slice(0, ref.name.lastIndexOf(".")));
|
|
309
|
+
const tableNamed = wordPattern(table, "i").test(place);
|
|
310
|
+
const tableInContext = context.currentTable !== undefined && lastSegment(context.currentTable) === table;
|
|
311
|
+
if (!tableNamed && !tableInContext)
|
|
312
|
+
continue;
|
|
313
|
+
}
|
|
314
|
+
return true;
|
|
315
|
+
}
|
|
316
|
+
return false;
|
|
317
|
+
}
|
|
318
|
+
/**
|
|
319
|
+
* Whether a resource the change names is the resource a map names.
|
|
320
|
+
*
|
|
321
|
+
* Names meet case-insensitively. Tables meet on their last segment as well
|
|
322
|
+
* (`public.orders` and `orders`), columns with or without their table, routes
|
|
323
|
+
* by prefix after parameters are normalized, files by base name, hosts by
|
|
324
|
+
* suffix, and configuration keys by their final segments.
|
|
325
|
+
*/
|
|
326
|
+
export function resourcesMatch(mapped, changed) {
|
|
327
|
+
const a = mapped.name.toLowerCase();
|
|
328
|
+
const b = changed.name.toLowerCase();
|
|
329
|
+
const last = (name) => name.slice(name.lastIndexOf(".") + 1);
|
|
330
|
+
switch (mapped.kind) {
|
|
331
|
+
case "table":
|
|
332
|
+
if (changed.kind === "table")
|
|
333
|
+
return a === b || last(a) === last(b);
|
|
334
|
+
if (changed.kind === "column")
|
|
335
|
+
return b.startsWith(`${a}.`) || b.startsWith(`${last(a)}.`);
|
|
336
|
+
return false;
|
|
337
|
+
case "column": {
|
|
338
|
+
if (changed.kind !== "column")
|
|
339
|
+
return false;
|
|
340
|
+
if (a === b)
|
|
341
|
+
return true;
|
|
342
|
+
if (!a.includes(".") || !b.includes("."))
|
|
343
|
+
return last(a) === last(b);
|
|
344
|
+
const [tableA, columnA] = [a.slice(0, a.lastIndexOf(".")), last(a)];
|
|
345
|
+
const [tableB, columnB] = [b.slice(0, b.lastIndexOf(".")), last(b)];
|
|
346
|
+
return columnA === columnB && last(tableA) === last(tableB);
|
|
347
|
+
}
|
|
348
|
+
case "route": {
|
|
349
|
+
if (changed.kind !== "route")
|
|
350
|
+
return false;
|
|
351
|
+
const x = normalizeRoute(a);
|
|
352
|
+
const y = normalizeRoute(b);
|
|
353
|
+
return x === y || y.startsWith(`${x}/`) || x.startsWith(`${y}/`);
|
|
354
|
+
}
|
|
355
|
+
case "file":
|
|
356
|
+
if (changed.kind !== "file")
|
|
357
|
+
return false;
|
|
358
|
+
return (a === b || a.endsWith(`/${b}`) || b.endsWith(`/${a}`) || basenameOf(a) === basenameOf(b));
|
|
359
|
+
case "outbound":
|
|
360
|
+
if (changed.kind !== "outbound")
|
|
361
|
+
return false;
|
|
362
|
+
return a === b || a.endsWith(`.${b}`) || b.endsWith(`.${a}`) || a.startsWith(`${b}/`);
|
|
363
|
+
case "config": {
|
|
364
|
+
if (changed.kind !== "config")
|
|
365
|
+
return false;
|
|
366
|
+
const keyOf = (name) => name.includes("#") ? name.slice(name.indexOf("#") + 1) : name;
|
|
367
|
+
const x = keyOf(a);
|
|
368
|
+
const y = keyOf(b);
|
|
369
|
+
return x === y || x.endsWith(`.${y}`) || y.endsWith(`.${x}`);
|
|
370
|
+
}
|
|
371
|
+
default:
|
|
372
|
+
return mapped.kind === changed.kind && a === b;
|
|
373
|
+
}
|
|
374
|
+
}
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
import { type ImportGraph } from "./import-graph.js";
|
|
2
|
+
import { type Band, type BehaviorMap, type ChangeExtraction, type PromiseRisk, type RiskReport, type Signal } from "./contract.js";
|
|
3
|
+
/**
|
|
4
|
+
* How much each signal counts, in the contract's order: a changed linked test
|
|
5
|
+
* and a direct hit on a mapped symbol highest, then new reach to a sink, a
|
|
6
|
+
* shared resource, forward import distance, intent clash, delta similarity,
|
|
7
|
+
* reverse import distance, and the two priors as small additions.
|
|
8
|
+
*
|
|
9
|
+
* One exported table so the evaluation can sweep it. `scoreChange` accepts a
|
|
10
|
+
* replacement.
|
|
11
|
+
*/
|
|
12
|
+
export declare const WEIGHTS: Readonly<Record<Signal, number>>;
|
|
13
|
+
export type Weights = Readonly<Record<Signal, number>>;
|
|
14
|
+
/** Forward distance d counts 1/(1+d): the mapped file itself 1, a direct import one half. */
|
|
15
|
+
export declare const forwardDecay: (distance: number) => number;
|
|
16
|
+
/** Reverse distance decays faster, and a mapped file is not its own dependent. */
|
|
17
|
+
export declare const reverseDecay: (distance: number) => number;
|
|
18
|
+
export declare const BAND_THRESHOLDS: {
|
|
19
|
+
readonly high: 0.6;
|
|
20
|
+
readonly medium: 0.3;
|
|
21
|
+
};
|
|
22
|
+
export declare function bandFor(score: number): Exclude<Band, "unmapped">;
|
|
23
|
+
export type Prior = Readonly<{
|
|
24
|
+
/** Verified breaks of this promise recorded before; any positive count counts fully. */
|
|
25
|
+
priorBreak?: number;
|
|
26
|
+
/** Share of recent commits that touched the promise's mapped files, in [0, 1]. */
|
|
27
|
+
churn?: number;
|
|
28
|
+
}>;
|
|
29
|
+
export type ScoreOptions = Readonly<{
|
|
30
|
+
extraction: ChangeExtraction;
|
|
31
|
+
maps: readonly BehaviorMap[];
|
|
32
|
+
/** The import graph at head. Without it the graph signals and the closure baseline see only the mapped files themselves. */
|
|
33
|
+
graph?: ImportGraph;
|
|
34
|
+
priors?: Readonly<Record<string, Prior>>;
|
|
35
|
+
/** Every promise that should appear. Those without a map are listed as `unmapped`. */
|
|
36
|
+
promiseIds?: readonly string[];
|
|
37
|
+
weights?: Weights;
|
|
38
|
+
/** Seed for the random baseline; the head sha when absent. */
|
|
39
|
+
seed?: string;
|
|
40
|
+
/** How far the import closures reach. */
|
|
41
|
+
maxDistance?: number;
|
|
42
|
+
}>;
|
|
43
|
+
/**
|
|
44
|
+
* How directly a change reaches a promise, read off the features the report already carries.
|
|
45
|
+
*
|
|
46
|
+
* 3: the change edits the promise's own code or its tests: a mapped symbol changed, a linked test
|
|
47
|
+
* changed, or a mapped file itself is among the changed files.
|
|
48
|
+
* 2: the change reaches what the promise depends on without editing its code: a sink, a resource, a
|
|
49
|
+
* changed line elsewhere naming one of its symbols, or a stated intent that reads as its reversal.
|
|
50
|
+
* 1: anything weaker: a neighbour in the import graph, or words that resemble a failing case.
|
|
51
|
+
* 0: nothing.
|
|
52
|
+
*/
|
|
53
|
+
export declare function directness(features: PromiseRisk["features"]): 0 | 1 | 2 | 3;
|
|
54
|
+
/**
|
|
55
|
+
* The order promises are listed in: how directly the change reaches each, then the score, then the
|
|
56
|
+
* total evidence, then the id.
|
|
57
|
+
*
|
|
58
|
+
* The score alone is not an order. Signals combine so that none can push past 1, so strong
|
|
59
|
+
* evidence saturates: in the first live evaluation (29 September 2026, 17 real regressions) four
|
|
60
|
+
* promises whose code one change had edited all scored exactly 1, and the alphabet decided which
|
|
61
|
+
* was listed first. The total evidence behind each now decides that. The same arithmetic lets
|
|
62
|
+
* several weak signals reach 0.99 as easily as one strong one; that was not seen live, where every
|
|
63
|
+
* promise listed above the broken one had its own code edited by the same change, but a change
|
|
64
|
+
* that edits a promise's code is a different kind of evidence from one that resembles it, so it
|
|
65
|
+
* is a tier and not a larger number. Scores and bands are unchanged.
|
|
66
|
+
*/
|
|
67
|
+
export declare function byRisk(a: PromiseRisk, b: PromiseRisk): number;
|
|
68
|
+
/** The files the promise's behavior runs through, where the import closures start. Tests excluded. */
|
|
69
|
+
export declare function mapSeeds(map: BehaviorMap): string[];
|
|
70
|
+
/** A seeded shuffle, so the random baseline is the same list every time for the same change. */
|
|
71
|
+
export declare function seededShuffle<T>(items: readonly T[], seed: string): T[];
|
|
72
|
+
/**
|
|
73
|
+
* Scores one change against every behavior map.
|
|
74
|
+
*
|
|
75
|
+
* Every signal is computed per place: each changed file, and the change's
|
|
76
|
+
* stated intent as one more place. A place's signals combine as independent
|
|
77
|
+
* chances; the promise's score is the highest place's, never a sum over the
|
|
78
|
+
* change, so a fifty-file change is not riskier for being wide. Priors are then
|
|
79
|
+
* added, small, to a promise that already has some signal.
|
|
80
|
+
*
|
|
81
|
+
* `features` in the report are each signal's own maximum across places
|
|
82
|
+
* (distances their minimum), for reading. The score is not recomputable from
|
|
83
|
+
* them, because two signals from different files are never combined.
|
|
84
|
+
*/
|
|
85
|
+
export declare function scoreChange(options: ScoreOptions): RiskReport;
|