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.
Files changed (77) hide show
  1. package/README.md +2 -0
  2. package/dist/behavior-map-schema.d.ts +107 -0
  3. package/dist/behavior-map-schema.js +232 -0
  4. package/dist/cli.d.ts +5 -0
  5. package/dist/cli.js +77 -2
  6. package/dist/commands/guidance.d.ts +1 -1
  7. package/dist/commands/guidance.js +6 -0
  8. package/dist/commands/judge.d.ts +271 -0
  9. package/dist/commands/judge.js +1170 -0
  10. package/dist/commands/map.d.ts +120 -0
  11. package/dist/commands/map.js +601 -0
  12. package/dist/commands/mcp.d.ts +6 -0
  13. package/dist/commands/mcp.js +53 -0
  14. package/dist/commands/offers.d.ts +265 -0
  15. package/dist/commands/offers.js +801 -0
  16. package/dist/commands/risk.d.ts +29 -0
  17. package/dist/commands/risk.js +133 -0
  18. package/dist/copy.d.ts +24 -1
  19. package/dist/copy.js +91 -0
  20. package/dist/guidance-hook.mjs +230 -74
  21. package/dist/guidance.d.ts +2 -0
  22. package/dist/guidance.js +7 -1
  23. package/dist/headless-agent.d.ts +166 -0
  24. package/dist/headless-agent.js +416 -0
  25. package/dist/hook-trust.d.ts +31 -0
  26. package/dist/hook-trust.js +63 -0
  27. package/dist/judge-brief.d.ts +36 -0
  28. package/dist/judge-brief.js +89 -0
  29. package/dist/judge-hook.d.ts +84 -0
  30. package/dist/judge-hook.js +420 -0
  31. package/dist/map-brief.d.ts +16 -0
  32. package/dist/map-brief.js +41 -0
  33. package/dist/offer-brief.d.ts +95 -0
  34. package/dist/offer-brief.js +217 -0
  35. package/dist/owned-process.d.ts +65 -0
  36. package/dist/owned-process.js +146 -0
  37. package/dist/promise-meaning.d.ts +126 -0
  38. package/dist/promise-meaning.js +292 -0
  39. package/dist/risk/contract.d.ts +145 -0
  40. package/dist/risk/contract.js +74 -0
  41. package/dist/risk/describe.d.ts +7 -0
  42. package/dist/risk/describe.js +45 -0
  43. package/dist/risk/diff.d.ts +26 -0
  44. package/dist/risk/diff.js +174 -0
  45. package/dist/risk/extract.d.ts +43 -0
  46. package/dist/risk/extract.js +336 -0
  47. package/dist/risk/git.d.ts +30 -0
  48. package/dist/risk/git.js +118 -0
  49. package/dist/risk/import-graph.d.ts +41 -0
  50. package/dist/risk/import-graph.js +487 -0
  51. package/dist/risk/index.d.ts +20 -0
  52. package/dist/risk/index.js +20 -0
  53. package/dist/risk/paths.d.ts +16 -0
  54. package/dist/risk/paths.js +73 -0
  55. package/dist/risk/pipeline.d.ts +47 -0
  56. package/dist/risk/pipeline.js +121 -0
  57. package/dist/risk/priors.d.ts +18 -0
  58. package/dist/risk/priors.js +86 -0
  59. package/dist/risk/resources.d.ts +56 -0
  60. package/dist/risk/resources.js +374 -0
  61. package/dist/risk/score.d.ts +85 -0
  62. package/dist/risk/score.js +543 -0
  63. package/dist/risk/symbols.d.ts +35 -0
  64. package/dist/risk/symbols.js +348 -0
  65. package/dist/risk/text.d.ts +43 -0
  66. package/dist/risk/text.js +277 -0
  67. package/dist/risk/validate.d.ts +19 -0
  68. package/dist/risk/validate.js +154 -0
  69. package/dist/scratch-worktree.d.ts +51 -0
  70. package/dist/scratch-worktree.js +153 -0
  71. package/dist/self-update.d.ts +29 -2
  72. package/dist/self-update.js +96 -11
  73. package/dist/user-scope.d.ts +2 -0
  74. package/dist/user-scope.js +26 -2
  75. package/dist/wire.d.ts +55 -2
  76. package/dist/wire.js +5 -1
  77. package/package.json +1 -1
@@ -0,0 +1,543 @@
1
+ import { mapFiles } from "../behavior-map-schema.js";
2
+ import { forwardClosure, reverseClosure } from "./import-graph.js";
3
+ import { parseResourceRef, resourceKey, resourcesMatch } from "./resources.js";
4
+ import { RISK_REPORT_SCHEMA_VERSION, } from "./contract.js";
5
+ import { buildIdf, cosine, REVERSAL_CUE, words } from "./text.js";
6
+ /**
7
+ * How much each signal counts, in the contract's order: a changed linked test
8
+ * and a direct hit on a mapped symbol highest, then new reach to a sink, a
9
+ * shared resource, forward import distance, intent clash, delta similarity,
10
+ * reverse import distance, and the two priors as small additions.
11
+ *
12
+ * One exported table so the evaluation can sweep it. `scoreChange` accepts a
13
+ * replacement.
14
+ */
15
+ export const WEIGHTS = {
16
+ linked_test_changed: 0.9,
17
+ symbol_hit: 0.9,
18
+ sink_reach: 0.75,
19
+ resource: 0.65,
20
+ graph_forward: 0.55,
21
+ intent_clash: 0.5,
22
+ delta_similarity: 0.45,
23
+ graph_reverse: 0.3,
24
+ prior_break: 0.08,
25
+ churn: 0.04,
26
+ };
27
+ /** Forward distance d counts 1/(1+d): the mapped file itself 1, a direct import one half. */
28
+ export const forwardDecay = (distance) => 1 / (1 + distance);
29
+ /** Reverse distance decays faster, and a mapped file is not its own dependent. */
30
+ export const reverseDecay = (distance) => distance < 1 ? 0 : 1 / ((1 + distance) * (1 + distance));
31
+ export const BAND_THRESHOLDS = { high: 0.6, medium: 0.3 };
32
+ export function bandFor(score) {
33
+ if (score >= BAND_THRESHOLDS.high)
34
+ return "high";
35
+ if (score >= BAND_THRESHOLDS.medium)
36
+ return "medium";
37
+ if (score > 0)
38
+ return "low";
39
+ return "none";
40
+ }
41
+ const evidenceOf = (fields) => ({
42
+ file: fields.file ?? "",
43
+ symbol: fields.symbol ?? "",
44
+ resource: fields.resource ?? "",
45
+ delta: fields.delta ?? "",
46
+ ...(fields.caseId === undefined ? {} : { caseId: fields.caseId }),
47
+ ...(fields.detail === undefined ? {} : { detail: fields.detail }),
48
+ });
49
+ function offer(place, signal, value, evidence) {
50
+ if (!(value > 0))
51
+ return;
52
+ if ((place.values[signal] ?? 0) >= value)
53
+ return;
54
+ place.values[signal] = Math.min(1, value);
55
+ place.evidence[signal] = evidenceOf(evidence);
56
+ }
57
+ /**
58
+ * How directly a change reaches a promise, read off the features the report already carries.
59
+ *
60
+ * 3: the change edits the promise's own code or its tests: a mapped symbol changed, a linked test
61
+ * changed, or a mapped file itself is among the changed files.
62
+ * 2: the change reaches what the promise depends on without editing its code: a sink, a resource, a
63
+ * changed line elsewhere naming one of its symbols, or a stated intent that reads as its reversal.
64
+ * 1: anything weaker: a neighbour in the import graph, or words that resemble a failing case.
65
+ * 0: nothing.
66
+ */
67
+ export function directness(features) {
68
+ if (features.symbol_hit >= 0.75 ||
69
+ features.linked_test_changed > 0 ||
70
+ features.graph_forward_distance === 0)
71
+ return 3;
72
+ if (features.sink_reach > 0 ||
73
+ features.resource_overlap > 0 ||
74
+ features.symbol_hit > 0 ||
75
+ features.intent_clash >= 0.5)
76
+ return 2;
77
+ if (features.graph_forward_distance !== null ||
78
+ features.graph_reverse_distance !== null ||
79
+ features.delta_similarity > 0 ||
80
+ features.intent_clash > 0)
81
+ return 1;
82
+ return 0;
83
+ }
84
+ /**
85
+ * The order promises are listed in: how directly the change reaches each, then the score, then the
86
+ * total evidence, then the id.
87
+ *
88
+ * The score alone is not an order. Signals combine so that none can push past 1, so strong
89
+ * evidence saturates: in the first live evaluation (29 September 2026, 17 real regressions) four
90
+ * promises whose code one change had edited all scored exactly 1, and the alphabet decided which
91
+ * was listed first. The total evidence behind each now decides that. The same arithmetic lets
92
+ * several weak signals reach 0.99 as easily as one strong one; that was not seen live, where every
93
+ * promise listed above the broken one had its own code edited by the same change, but a change
94
+ * that edits a promise's code is a different kind of evidence from one that resembles it, so it
95
+ * is a tier and not a larger number. Scores and bands are unchanged.
96
+ */
97
+ export function byRisk(a, b) {
98
+ const evidence = (p) => p.reasons.reduce((sum, reason) => sum + reason.weight, 0);
99
+ return (directness(b.features) - directness(a.features) ||
100
+ b.score - a.score ||
101
+ evidence(b) - evidence(a) ||
102
+ a.promiseId.localeCompare(b.promiseId));
103
+ }
104
+ /** Independent signals combined so that none can push past 1 and more never lowers it. */
105
+ function combine(place, weights) {
106
+ let miss = 1;
107
+ for (const [signal, value] of Object.entries(place.values)) {
108
+ miss *= 1 - Math.min(1, weights[signal] * value);
109
+ }
110
+ return 1 - miss;
111
+ }
112
+ /** The files the promise's behavior runs through, where the import closures start. Tests excluded. */
113
+ export function mapSeeds(map) {
114
+ return [
115
+ ...new Set([
116
+ ...map.entryPoints.map((entry) => entry.file),
117
+ ...map.symbols.map((symbol) => symbol.file),
118
+ ]),
119
+ ];
120
+ }
121
+ function symbolNamesMatch(mapped, changed) {
122
+ return mapped === changed || changed.endsWith(`.${mapped}`) || mapped.endsWith(`.${changed}`);
123
+ }
124
+ function promiseText(map) {
125
+ const failingText = new Map();
126
+ for (const example of map.meaning?.failingExamples ?? [])
127
+ failingText.set(example.id, example.text);
128
+ for (const sink of map.sinks)
129
+ failingText.set(sink.caseId, `${failingText.get(sink.caseId) ?? ""} ${sink.effect}`.trim());
130
+ return {
131
+ failing: [...failingText].map(([caseId, text]) => ({ caseId, words: words(text) })),
132
+ nonGoals: (map.meaning?.nonGoals ?? []).map((text) => ({ text, words: words(text) })),
133
+ outcome: words(`${map.meaning?.title ?? ""} ${map.meaning?.observableOutcome ?? ""}`),
134
+ };
135
+ }
136
+ function corpusDocument(text) {
137
+ const all = new Set(text.outcome);
138
+ for (const failing of text.failing)
139
+ for (const word of failing.words)
140
+ all.add(word);
141
+ for (const nonGoal of text.nonGoals)
142
+ for (const word of nonGoal.words)
143
+ all.add(word);
144
+ return all;
145
+ }
146
+ /** Lexical signals for one place: sources against failing cases and non-goals. */
147
+ function offerText(place, sources, text, idf, file) {
148
+ for (const source of sources) {
149
+ if (source.words.size === 0)
150
+ continue;
151
+ for (const failing of text.failing) {
152
+ offer(place, "delta_similarity", cosine(source.words, failing.words, idf), {
153
+ file,
154
+ delta: source.text.slice(0, 300),
155
+ caseId: failing.caseId,
156
+ });
157
+ }
158
+ // Only stated intent can clash: identifiers state nothing.
159
+ if (!source.stated)
160
+ continue;
161
+ for (const nonGoal of text.nonGoals) {
162
+ offer(place, "intent_clash", cosine(source.words, nonGoal.words, idf), {
163
+ file,
164
+ delta: source.text.slice(0, 300),
165
+ detail: `non_goal: ${nonGoal.text.slice(0, 200)}`,
166
+ });
167
+ }
168
+ if (REVERSAL_CUE.test(source.text)) {
169
+ offer(place, "intent_clash", cosine(source.words, text.outcome, idf), {
170
+ file,
171
+ delta: source.text.slice(0, 300),
172
+ detail: "reverses_outcome",
173
+ });
174
+ }
175
+ }
176
+ }
177
+ function intentLines(summary) {
178
+ return summary
179
+ .split("\n")
180
+ .map((line) => line.replace(/^\s*(?:[-*+]|\d+[.)])\s+/, "").trim())
181
+ .filter((line) => line.length > 0);
182
+ }
183
+ /** A seeded shuffle, so the random baseline is the same list every time for the same change. */
184
+ export function seededShuffle(items, seed) {
185
+ let hash = 0x811c9dc5;
186
+ for (const char of seed) {
187
+ hash ^= char.charCodeAt(0);
188
+ hash = Math.imul(hash, 0x01000193) >>> 0;
189
+ }
190
+ let state = hash || 1;
191
+ const next = () => {
192
+ state = (state + 0x6d2b79f5) >>> 0;
193
+ let t = state;
194
+ t = Math.imul(t ^ (t >>> 15), t | 1);
195
+ t ^= t + Math.imul(t ^ (t >>> 7), t | 61);
196
+ return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
197
+ };
198
+ const shuffled = [...items];
199
+ for (let i = shuffled.length - 1; i > 0; i -= 1) {
200
+ const j = Math.floor(next() * (i + 1));
201
+ [shuffled[i], shuffled[j]] = [shuffled[j], shuffled[i]];
202
+ }
203
+ return shuffled;
204
+ }
205
+ function surfaceMatches(surface, path) {
206
+ const clean = surface.replace(/^\.\//, "").replace(/^\/+/, "");
207
+ if (clean.length === 0)
208
+ return true;
209
+ if (clean.includes("*")) {
210
+ const pattern = clean
211
+ .split("**")
212
+ .map((part) => part
213
+ .split("*")
214
+ .map((piece) => piece.replace(/[.+?^${}()|[\]\\]/g, "\\$&"))
215
+ .join("[^/]*"))
216
+ .join(".*");
217
+ return new RegExp(`^${pattern}`).test(path);
218
+ }
219
+ if (clean.endsWith("/"))
220
+ return path.startsWith(clean);
221
+ return path === clean || path.startsWith(`${clean}/`);
222
+ }
223
+ const round = (value) => Math.round(value * 10_000) / 10_000;
224
+ const SIGNAL_ORDER = [
225
+ "linked_test_changed",
226
+ "symbol_hit",
227
+ "sink_reach",
228
+ "resource",
229
+ "graph_forward",
230
+ "intent_clash",
231
+ "delta_similarity",
232
+ "graph_reverse",
233
+ "prior_break",
234
+ "churn",
235
+ ];
236
+ function emptyFeatures() {
237
+ return {
238
+ symbol_hit: 0,
239
+ graph_forward_distance: null,
240
+ graph_reverse_distance: null,
241
+ resource_overlap: 0,
242
+ linked_test_changed: 0,
243
+ sink_reach: 0,
244
+ delta_similarity: 0,
245
+ intent_clash: 0,
246
+ prior_break: 0,
247
+ churn: 0,
248
+ };
249
+ }
250
+ /**
251
+ * Scores one change against every behavior map.
252
+ *
253
+ * Every signal is computed per place: each changed file, and the change's
254
+ * stated intent as one more place. A place's signals combine as independent
255
+ * chances; the promise's score is the highest place's, never a sum over the
256
+ * change, so a fifty-file change is not riskier for being wide. Priors are then
257
+ * added, small, to a promise that already has some signal.
258
+ *
259
+ * `features` in the report are each signal's own maximum across places
260
+ * (distances their minimum), for reading. The score is not recomputable from
261
+ * them, because two signals from different files are never combined.
262
+ */
263
+ export function scoreChange(options) {
264
+ const weights = options.weights ?? WEIGHTS;
265
+ const { extraction, graph } = options;
266
+ const maxDistance = options.maxDistance ?? 8;
267
+ const maps = [...new Map(options.maps.map((map) => [map.promiseId, map])).values()];
268
+ const texts = new Map(maps.map((map) => [map.promiseId, promiseText(map)]));
269
+ const idf = buildIdf([...texts.values()].map(corpusDocument));
270
+ const deltas = extraction.deltas ?? [];
271
+ const changedPaths = new Set();
272
+ for (const file of extraction.files) {
273
+ changedPaths.add(file.path);
274
+ if (file.oldPath !== null)
275
+ changedPaths.add(file.oldPath);
276
+ }
277
+ const deltaFor = (file) => deltas.filter((delta) => delta.files.some((path) => path === file.path || (file.oldPath !== null && path === file.oldPath)));
278
+ const unattributed = deltas.filter((delta) => !delta.files.some((path) => changedPaths.has(path)));
279
+ const intentSources = [
280
+ ...intentLines(extraction.intent?.summary ?? "").map((line) => ({
281
+ text: line,
282
+ words: words(line),
283
+ stated: true,
284
+ })),
285
+ ...unattributed.map((delta) => ({ text: delta.text, words: words(delta.text), stated: true })),
286
+ ];
287
+ const scored = [];
288
+ const importClosure = [];
289
+ const scopeOnly = [];
290
+ for (const map of maps) {
291
+ const text = texts.get(map.promiseId);
292
+ const seeds = mapSeeds(map);
293
+ const forward = graph === undefined
294
+ ? new Map(seeds.map((seed) => [seed, 0]))
295
+ : forwardClosure(graph, seeds, maxDistance);
296
+ const reverse = graph === undefined
297
+ ? new Map(seeds.map((seed) => [seed, 0]))
298
+ : reverseClosure(graph, seeds, maxDistance);
299
+ const mappedSymbols = [
300
+ ...map.symbols.map((symbol) => ({ file: symbol.file, name: symbol.name, role: symbol.role })),
301
+ ...map.entryPoints.map((entry) => ({
302
+ file: entry.file,
303
+ name: entry.symbol,
304
+ role: "core",
305
+ })),
306
+ ...map.sinks.flatMap((sink) => sink.symbols.flatMap((reference) => {
307
+ const hash = reference.lastIndexOf("#");
308
+ return hash > 0
309
+ ? [
310
+ {
311
+ file: reference.slice(0, hash),
312
+ name: reference.slice(hash + 1),
313
+ role: "core",
314
+ },
315
+ ]
316
+ : [];
317
+ })),
318
+ ];
319
+ // Each signal has one source: the path's resources feed `resource`, and the
320
+ // failing cases' sinks feed `sink_reach` alone, so neither counts the other.
321
+ const promiseResources = map.resources.map((resource) => ({
322
+ kind: resource.kind,
323
+ name: resource.name,
324
+ }));
325
+ const sinkResources = map.sinks.flatMap((sink) => sink.resources.flatMap((ref) => {
326
+ const parsed = parseResourceRef(ref);
327
+ return parsed === undefined ? [] : [{ caseId: sink.caseId, ref: parsed }];
328
+ }));
329
+ const places = [];
330
+ const features = emptyFeatures();
331
+ let closestForward;
332
+ for (const file of extraction.files) {
333
+ const place = { values: {}, evidence: {} };
334
+ const paths = file.oldPath === null ? [file.path] : [file.path, file.oldPath];
335
+ const here = (path) => paths.includes(path);
336
+ // A mapped symbol changed, or a changed line names one.
337
+ const named = new Set([...file.identifiersAdded, ...file.identifiersRemoved]);
338
+ const linkedTestHere = map.linkedTests.some((test) => here(test.file));
339
+ for (const mapped of mappedSymbols) {
340
+ if (here(mapped.file)) {
341
+ for (const changed of file.symbolsChanged) {
342
+ if (!symbolNamesMatch(mapped.name, changed.name))
343
+ continue;
344
+ offer(place, "symbol_hit", mapped.role === "core" ? 1 : 0.75, {
345
+ file: file.path,
346
+ symbol: changed.name,
347
+ detail: changed.change,
348
+ });
349
+ }
350
+ }
351
+ else if (!linkedTestHere) {
352
+ // A line naming the symbol elsewhere is a call site. The promise's own
353
+ // linked tests call it by construction, and have their own signal.
354
+ const bare = mapped.name.slice(mapped.name.lastIndexOf(".") + 1);
355
+ if (bare.length >= 5 && named.has(bare)) {
356
+ offer(place, "symbol_hit", 0.5, { file: file.path, symbol: bare, detail: "call_site" });
357
+ }
358
+ }
359
+ }
360
+ // A linked test deleted, stripped of an assertion, or edited.
361
+ for (const test of map.linkedTests) {
362
+ if (!here(test.file))
363
+ continue;
364
+ const [value, detail] = file.status === "deleted"
365
+ ? [1, "deleted"]
366
+ : file.guardDeletions.length > 0
367
+ ? [1, "assertion_removed"]
368
+ : file.hunks.length === 0
369
+ ? [0.3, "renamed"]
370
+ : [0.6, "modified"];
371
+ offer(place, "linked_test_changed", value, {
372
+ file: file.path,
373
+ symbol: test.name,
374
+ detail,
375
+ });
376
+ }
377
+ // New reach to a sink: the file names a sink resource it did not name before.
378
+ for (const sink of sinkResources) {
379
+ for (const changed of file.resources) {
380
+ if (changed.change !== "added" || !resourcesMatch(sink.ref, changed))
381
+ continue;
382
+ offer(place, "sink_reach", 1, {
383
+ file: file.path,
384
+ resource: resourceKey(sink.ref),
385
+ caseId: sink.caseId,
386
+ detail: "new_reach",
387
+ });
388
+ }
389
+ }
390
+ // A resource the promise depends on, named by the change.
391
+ for (const mapped of promiseResources) {
392
+ for (const changed of file.resources) {
393
+ if (!resourcesMatch(mapped, changed))
394
+ continue;
395
+ offer(place, "resource", 1, {
396
+ file: file.path,
397
+ resource: resourceKey(mapped),
398
+ detail: changed.change,
399
+ });
400
+ }
401
+ }
402
+ // Import distance, both ways.
403
+ const forwardDistance = Math.min(...paths.map((path) => forward.get(path) ?? Infinity));
404
+ if (Number.isFinite(forwardDistance)) {
405
+ offer(place, "graph_forward", forwardDecay(forwardDistance), {
406
+ file: file.path,
407
+ detail: `distance:${forwardDistance}`,
408
+ });
409
+ closestForward = Math.min(closestForward ?? Infinity, forwardDistance);
410
+ features.graph_forward_distance = Math.min(features.graph_forward_distance ?? Infinity, forwardDistance);
411
+ }
412
+ const reverseDistance = Math.min(...paths.map((path) => reverse.get(path) ?? Infinity));
413
+ if (Number.isFinite(reverseDistance)) {
414
+ offer(place, "graph_reverse", reverseDecay(reverseDistance), {
415
+ file: file.path,
416
+ detail: `distance:${reverseDistance}`,
417
+ });
418
+ features.graph_reverse_distance = Math.min(features.graph_reverse_distance ?? Infinity, reverseDistance);
419
+ }
420
+ // What the change says it does, against the failing cases and non-goals.
421
+ const fileDeltas = deltaFor(file);
422
+ const sources = deltas.length > 0
423
+ ? fileDeltas.map((delta) => ({
424
+ text: delta.text,
425
+ words: words(delta.text),
426
+ stated: true,
427
+ }))
428
+ : [
429
+ {
430
+ text: [...named].slice(0, 12).join(" "),
431
+ words: words([...named].join(" ")),
432
+ stated: false,
433
+ },
434
+ ];
435
+ offerText(place, sources, text, idf, file.path);
436
+ places.push(place);
437
+ }
438
+ const intentPlace = { values: {}, evidence: {} };
439
+ offerText(intentPlace, intentSources, text, idf, "");
440
+ places.push(intentPlace);
441
+ // The highest place, never a sum.
442
+ let best = 0;
443
+ for (const place of places)
444
+ best = Math.max(best, combine(place, weights));
445
+ // Features and reasons: each signal's strongest place.
446
+ const strongest = new Map();
447
+ for (const place of places) {
448
+ for (const [signal, value] of Object.entries(place.values)) {
449
+ const contribution = weights[signal] * value;
450
+ const current = strongest.get(signal);
451
+ if (current !== undefined && current.contribution >= contribution)
452
+ continue;
453
+ strongest.set(signal, {
454
+ contribution,
455
+ value,
456
+ evidence: place.evidence[signal],
457
+ });
458
+ }
459
+ }
460
+ features.symbol_hit = round(strongest.get("symbol_hit")?.value ?? 0);
461
+ features.resource_overlap = round(strongest.get("resource")?.value ?? 0);
462
+ features.linked_test_changed = round(strongest.get("linked_test_changed")?.value ?? 0);
463
+ features.sink_reach = round(strongest.get("sink_reach")?.value ?? 0);
464
+ features.delta_similarity = round(strongest.get("delta_similarity")?.value ?? 0);
465
+ features.intent_clash = round(strongest.get("intent_clash")?.value ?? 0);
466
+ const prior = options.priors?.[map.promiseId];
467
+ features.prior_break = Math.max(0, prior?.priorBreak ?? 0);
468
+ features.churn = round(Math.min(1, Math.max(0, prior?.churn ?? 0)));
469
+ let score = best;
470
+ if (best > 0) {
471
+ const breakValue = Math.min(1, features.prior_break);
472
+ if (breakValue > 0) {
473
+ score += weights.prior_break * breakValue;
474
+ strongest.set("prior_break", {
475
+ contribution: weights.prior_break * breakValue,
476
+ value: breakValue,
477
+ evidence: evidenceOf({ detail: `broken_before:${features.prior_break}` }),
478
+ });
479
+ }
480
+ if (features.churn > 0) {
481
+ score += weights.churn * features.churn;
482
+ strongest.set("churn", {
483
+ contribution: weights.churn * features.churn,
484
+ value: features.churn,
485
+ evidence: evidenceOf({ detail: `churn:${features.churn}` }),
486
+ });
487
+ }
488
+ }
489
+ score = round(Math.min(1, score));
490
+ const reasons = [...strongest.entries()]
491
+ .filter(([, entry]) => entry.contribution > 0)
492
+ .sort((a, b) => b[1].contribution - a[1].contribution ||
493
+ SIGNAL_ORDER.indexOf(a[0]) - SIGNAL_ORDER.indexOf(b[0]))
494
+ .map(([signal, entry]) => ({
495
+ signal,
496
+ weight: round(Math.min(1, entry.contribution)),
497
+ evidence: entry.evidence,
498
+ }));
499
+ scored.push({
500
+ promiseId: map.promiseId,
501
+ ...(map.meaning?.title === undefined ? {} : { title: map.meaning.title }),
502
+ score,
503
+ band: bandFor(score),
504
+ reasons,
505
+ features,
506
+ });
507
+ if (closestForward !== undefined)
508
+ importClosure.push({ promiseId: map.promiseId, distance: closestForward });
509
+ const surfaces = map.meaning?.surfaces ?? [];
510
+ const inScope = surfaces.length > 0
511
+ ? [...changedPaths].some((path) => surfaces.some((surface) => surfaceMatches(surface, path)))
512
+ : mapFiles(map).some((path) => changedPaths.has(path));
513
+ if (inScope)
514
+ scopeOnly.push(map.promiseId);
515
+ }
516
+ const mappedIds = new Set(maps.map((map) => map.promiseId));
517
+ const unmapped = [...new Set(options.promiseIds ?? [])]
518
+ .filter((id) => !mappedIds.has(id))
519
+ .sort()
520
+ .map((promiseId) => ({
521
+ promiseId,
522
+ score: 0,
523
+ band: "unmapped",
524
+ reasons: [],
525
+ features: emptyFeatures(),
526
+ }));
527
+ const ordered = [...scored.sort(byRisk), ...unmapped];
528
+ const all = [...mappedIds].sort();
529
+ return {
530
+ schemaVersion: RISK_REPORT_SCHEMA_VERSION,
531
+ base: extraction.base,
532
+ head: extraction.head,
533
+ promises: ordered,
534
+ baselines: {
535
+ scope_only: scopeOnly.sort(),
536
+ import_closure: importClosure
537
+ .sort((a, b) => a.distance - b.distance || a.promiseId.localeCompare(b.promiseId))
538
+ .map((entry) => entry.promiseId),
539
+ all,
540
+ random: seededShuffle(all, options.seed ?? extraction.head),
541
+ },
542
+ };
543
+ }
@@ -0,0 +1,35 @@
1
+ import type ts from "typescript";
2
+ import type { SymbolExtractor, SymbolKind } from "./contract.js";
3
+ /**
4
+ * Where each named thing in a file starts and ends, by line.
5
+ *
6
+ * TypeScript and JavaScript are parsed with the repository's own TypeScript
7
+ * compiler when it has one, one file at a time with no program, so it is a
8
+ * syntax read and never a type check.
9
+ * Python is read by indentation after `def` and `class`. Every other language
10
+ * gets a declaration regex and a symbol runs until the next declaration.
11
+ */
12
+ export type DeclaredSymbol = Readonly<{
13
+ name: string;
14
+ kind: SymbolKind;
15
+ /** 1-based, inclusive. */
16
+ startLine: number;
17
+ endLine: number;
18
+ }>;
19
+ type TypeScriptModule = typeof ts;
20
+ /**
21
+ * The TypeScript compiler of the repository being read, resolved from its root
22
+ * the way its own tools resolve it. The published command ships no compiler and
23
+ * never imports one: a TypeScript repository nearly always has its own, and one
24
+ * without it has its script files read by the declaration pattern instead.
25
+ * Looked up once per repository.
26
+ */
27
+ export declare function loadTypeScript(root: string | undefined): TypeScriptModule | undefined;
28
+ /** Every symbol declared in a file, with its line range, and which reader found them. */
29
+ export declare function declaredSymbols(path: string, text: string, root?: string): {
30
+ symbols: DeclaredSymbol[];
31
+ extractor: SymbolExtractor;
32
+ };
33
+ /** The symbols whose range contains any of the given lines. */
34
+ export declare function symbolsAtLines(symbols: readonly DeclaredSymbol[], lines: ReadonlySet<number>): DeclaredSymbol[];
35
+ export {};