urtext 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 (74) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +229 -0
  3. package/dist/analyze/blast-radius.d.ts +28 -0
  4. package/dist/analyze/blast-radius.js +163 -0
  5. package/dist/analyze/canonical.d.ts +27 -0
  6. package/dist/analyze/canonical.js +74 -0
  7. package/dist/analyze/citations.d.ts +256 -0
  8. package/dist/analyze/citations.js +945 -0
  9. package/dist/analyze/effects.d.ts +15 -0
  10. package/dist/analyze/effects.js +255 -0
  11. package/dist/analyze/fact.d.ts +42 -0
  12. package/dist/analyze/fact.js +46 -0
  13. package/dist/analyze/guards.d.ts +70 -0
  14. package/dist/analyze/guards.js +211 -0
  15. package/dist/analyze/index.d.ts +26 -0
  16. package/dist/analyze/index.js +52 -0
  17. package/dist/analyze/program.d.ts +15 -0
  18. package/dist/analyze/program.js +229 -0
  19. package/dist/analyze/surface.d.ts +48 -0
  20. package/dist/analyze/surface.js +396 -0
  21. package/dist/bin.d.ts +2 -0
  22. package/dist/bin.js +12 -0
  23. package/dist/cli.d.ts +110 -0
  24. package/dist/cli.js +502 -0
  25. package/dist/extract/diff.d.ts +35 -0
  26. package/dist/extract/diff.js +116 -0
  27. package/dist/extract/git.d.ts +12 -0
  28. package/dist/extract/git.js +247 -0
  29. package/dist/extract/index.d.ts +4 -0
  30. package/dist/extract/index.js +57 -0
  31. package/dist/extract/intent.d.ts +64 -0
  32. package/dist/extract/intent.js +238 -0
  33. package/dist/extract/scope.d.ts +160 -0
  34. package/dist/extract/scope.js +284 -0
  35. package/dist/extract/symbols.d.ts +24 -0
  36. package/dist/extract/symbols.js +230 -0
  37. package/dist/interpret/client.d.ts +27 -0
  38. package/dist/interpret/client.js +80 -0
  39. package/dist/interpret/index.d.ts +41 -0
  40. package/dist/interpret/index.js +86 -0
  41. package/dist/interpret/prompt.d.ts +23 -0
  42. package/dist/interpret/prompt.js +128 -0
  43. package/dist/interpret/schema.d.ts +74 -0
  44. package/dist/interpret/schema.js +103 -0
  45. package/dist/report/conceal.d.ts +63 -0
  46. package/dist/report/conceal.js +129 -0
  47. package/dist/report/coverage.d.ts +43 -0
  48. package/dist/report/coverage.js +56 -0
  49. package/dist/report/html.d.ts +4 -0
  50. package/dist/report/html.js +634 -0
  51. package/dist/report/markdown.d.ts +2 -0
  52. package/dist/report/markdown.js +168 -0
  53. package/dist/report/model.d.ts +303 -0
  54. package/dist/report/model.js +289 -0
  55. package/dist/report/pdf.d.ts +2 -0
  56. package/dist/report/pdf.js +217 -0
  57. package/dist/report/terminal.d.ts +2 -0
  58. package/dist/report/terminal.js +206 -0
  59. package/dist/report/write.d.ts +105 -0
  60. package/dist/report/write.js +160 -0
  61. package/dist/score/index.d.ts +94 -0
  62. package/dist/score/index.js +572 -0
  63. package/dist/score/reach.d.ts +126 -0
  64. package/dist/score/reach.js +320 -0
  65. package/dist/score/reconcile.d.ts +52 -0
  66. package/dist/score/reconcile.js +208 -0
  67. package/dist/types.d.ts +221 -0
  68. package/dist/types.js +10 -0
  69. package/fonts/DejaVuSans-Bold.ttf +0 -0
  70. package/fonts/DejaVuSans-Oblique.ttf +0 -0
  71. package/fonts/DejaVuSans.ttf +0 -0
  72. package/fonts/DejaVuSansMono.ttf +0 -0
  73. package/fonts/LICENSE +187 -0
  74. package/package.json +44 -0
@@ -0,0 +1,126 @@
1
+ import type { EvidenceRef, Fact, Finding } from "../types.js";
2
+ export interface Reach {
3
+ references: number;
4
+ /**
5
+ * The referencing sites, excluding the declaration — capped at the
6
+ * analyzer's `MAX_EVIDENCE`, unlike `references`, which is the exact,
7
+ * uncapped count. A `Reach` with `references: 300` and a handful of
8
+ * `sites` is correct, not truncated evidence: this list is a sample, not
9
+ * the complete set of referencing sites.
10
+ */
11
+ sites: EvidenceRef[];
12
+ }
13
+ /**
14
+ * The identity reach is recorded and looked up under. `qualifiedSymbol`, not
15
+ * a bare name: this key is the one place facts from two different analyzers
16
+ * are matched against each other, so a caller that passed an unqualified name
17
+ * would hand one symbol's reference count to a different symbol's finding.
18
+ * `Fact.qualifiedSymbol` is the only thing either call site passes, and its
19
+ * doc comment states the same rule from the other end.
20
+ */
21
+ export declare function reachKey(file: string, qualifiedSymbol: string): string;
22
+ /**
23
+ * A blast_radius fact's reference count, as the prose states it: the exact
24
+ * counted quantity when the analyzer recorded one, else the evidence list
25
+ * minus the declaration entry that leads it. One derivation, shared by the
26
+ * reach entry `foldReach` writes and the standalone-row suppression in
27
+ * `./reconcile.js` (`MIN_STANDALONE_REFERENCES`) — two hand-copies of this
28
+ * rule could disagree about which findings sit under that threshold.
29
+ */
30
+ export declare function referenceCount(fact: Fact): number;
31
+ /**
32
+ * Strips `blast_radius` facts out of the fact list and returns their content
33
+ * as reach, keyed by the symbol they describe.
34
+ *
35
+ * Reach is not a defect. "This export is referenced in 34 places" names no
36
+ * problem on its own — it says how much a problem named by some *other*
37
+ * finding would cost. The spec models it as a scoring input for exactly that
38
+ * reason, and shipping it as a standalone finding made roughly 40% of a real
39
+ * report say nothing actionable. Folding it in turns two adjacent entries
40
+ * into one sentence: "`findByEmail` changed signature; 34 places call it."
41
+ *
42
+ * A blast_radius fact with no sibling finding for the same symbol is kept as
43
+ * a fact, so reach is never silently discarded — it just stops being the
44
+ * headline when something better is available. `absorbedBy` records, for
45
+ * every folded fact, which sibling fact's finding absorbed it: the id a
46
+ * model claim naming the folded fact must be redirected to, since the
47
+ * folded fact no longer produces a finding of its own for that claim to
48
+ * attach to.
49
+ *
50
+ * A symbol can have more than one sibling — a removed guard and a changed
51
+ * signature on the same function both carry that function's path as
52
+ * `qualifiedSymbol`. Which one a claim lands on is not incidental: `factWeight`
53
+ * ranks the candidates (the caller passes `WEIGHTS.factKind`; this module
54
+ * cannot import it directly without importing `./index.js`, which already
55
+ * imports this one), and the heaviest sibling wins, ties broken by fact id
56
+ * so the choice is total. This only decides `absorbedBy`'s target — every
57
+ * sibling is still amplified identically by `rank`, keyed on
58
+ * (file, qualifiedSymbol) rather than on which one absorbed the claim.
59
+ */
60
+ export declare function foldReach(facts: Fact[], factWeight: (fact: Fact) => number): {
61
+ facts: Fact[];
62
+ reach: Map<string, Reach>;
63
+ absorbedBy: Map<string, string>;
64
+ };
65
+ /**
66
+ * Collapses a file's added-export findings into one entry once there are
67
+ * enough of them to be noise rather than news. A new module legitimately
68
+ * exports a dozen symbols; listing each as its own finding buries everything
69
+ * that names a problem. `absorbedBy` records, for every collapsed finding,
70
+ * the id of the group finding that replaced it — the id a model claim
71
+ * naming that export must be redirected to.
72
+ */
73
+ export declare function groupAddedExports(findings: Finding[], threshold?: number): {
74
+ findings: Finding[];
75
+ absorbedBy: Map<string, string>;
76
+ };
77
+ /**
78
+ * The hedge appended to a signature_changed body when an after side
79
+ * rendered exactly as `any` and its before side did not. The checker
80
+ * prints `any` both for a genuine widening and for a type it could not
81
+ * resolve at the reviewed revision — a repository whose dependencies are
82
+ * not installed at that revision resolves every import from them this
83
+ * way — and the surface analyzer records only the rendered text, so the
84
+ * sentence hedges instead of asserting either reading. Keyed on that text
85
+ * rather than on a resolution-failure signal from the checker: no public,
86
+ * cheap signal survives to `exportedSignatures`' string output, and deeper
87
+ * detection was deliberately ruled out.
88
+ *
89
+ * Takes the affected export names so the hedge points at the member(s) it
90
+ * is about — an unnamed hedge on a many-member group left the reader
91
+ * guessing which change it hedged. Lives here rather than beside the
92
+ * signature prose in `./index.js` because `groupSignatureChanges` below
93
+ * needs it too, and that module already imports this one.
94
+ */
95
+ export declare function typeUnresolvedNoteFor(names: string[]): string;
96
+ /**
97
+ * What `groupSignatureChanges` needs to know about a member beyond its
98
+ * `Finding`: the export's name, the was→now sentence its listing line
99
+ * shows, and whether its new type rendered as unresolvable (see
100
+ * `typeUnresolvedNoteFor`). Composed by the caller — `rankWithAbsorption`,
101
+ * which still has the facts in hand — because a `Finding` carries prose,
102
+ * not the name and `before`/`after` detail these are built from.
103
+ */
104
+ export interface SignatureChangeDetail {
105
+ name: string;
106
+ sentence: string;
107
+ typeUnresolved: boolean;
108
+ }
109
+ /**
110
+ * Collapses a file's signature_changed findings into one entry once there
111
+ * are enough of them to be one story rather than several. The first real
112
+ * dogfood run listed ten near-identical rows for one file's exported
113
+ * consts — one shared cause, ten findings burying everything else. Mirrors
114
+ * `groupAddedExports`, above; the same `absorbedBy` contract records, for
115
+ * every collapsed finding, the id of the group finding that replaced it —
116
+ * the id a model claim naming that member must be redirected to.
117
+ *
118
+ * A signature_changed finding with no `details` entry is left ungrouped: a
119
+ * listing line cannot be invented for it, and in production the entry
120
+ * always exists because `rankWithAbsorption` derives the map from the same
121
+ * facts it built the findings from.
122
+ */
123
+ export declare function groupSignatureChanges(findings: Finding[], details: Map<string, SignatureChangeDetail>, threshold?: number): {
124
+ findings: Finding[];
125
+ absorbedBy: Map<string, string>;
126
+ };
@@ -0,0 +1,320 @@
1
+ /**
2
+ * The identity reach is recorded and looked up under. `qualifiedSymbol`, not
3
+ * a bare name: this key is the one place facts from two different analyzers
4
+ * are matched against each other, so a caller that passed an unqualified name
5
+ * would hand one symbol's reference count to a different symbol's finding.
6
+ * `Fact.qualifiedSymbol` is the only thing either call site passes, and its
7
+ * doc comment states the same rule from the other end.
8
+ */
9
+ export function reachKey(file, qualifiedSymbol) {
10
+ return `${file} ${qualifiedSymbol}`;
11
+ }
12
+ /**
13
+ * A blast_radius fact's reference count, as the prose states it: the exact
14
+ * counted quantity when the analyzer recorded one, else the evidence list
15
+ * minus the declaration entry that leads it. One derivation, shared by the
16
+ * reach entry `foldReach` writes and the standalone-row suppression in
17
+ * `./reconcile.js` (`MIN_STANDALONE_REFERENCES`) — two hand-copies of this
18
+ * rule could disagree about which findings sit under that threshold.
19
+ */
20
+ export function referenceCount(fact) {
21
+ return typeof fact.detail.references === "number"
22
+ ? fact.detail.references
23
+ : fact.evidence.length - 1;
24
+ }
25
+ /**
26
+ * Strips `blast_radius` facts out of the fact list and returns their content
27
+ * as reach, keyed by the symbol they describe.
28
+ *
29
+ * Reach is not a defect. "This export is referenced in 34 places" names no
30
+ * problem on its own — it says how much a problem named by some *other*
31
+ * finding would cost. The spec models it as a scoring input for exactly that
32
+ * reason, and shipping it as a standalone finding made roughly 40% of a real
33
+ * report say nothing actionable. Folding it in turns two adjacent entries
34
+ * into one sentence: "`findByEmail` changed signature; 34 places call it."
35
+ *
36
+ * A blast_radius fact with no sibling finding for the same symbol is kept as
37
+ * a fact, so reach is never silently discarded — it just stops being the
38
+ * headline when something better is available. `absorbedBy` records, for
39
+ * every folded fact, which sibling fact's finding absorbed it: the id a
40
+ * model claim naming the folded fact must be redirected to, since the
41
+ * folded fact no longer produces a finding of its own for that claim to
42
+ * attach to.
43
+ *
44
+ * A symbol can have more than one sibling — a removed guard and a changed
45
+ * signature on the same function both carry that function's path as
46
+ * `qualifiedSymbol`. Which one a claim lands on is not incidental: `factWeight`
47
+ * ranks the candidates (the caller passes `WEIGHTS.factKind`; this module
48
+ * cannot import it directly without importing `./index.js`, which already
49
+ * imports this one), and the heaviest sibling wins, ties broken by fact id
50
+ * so the choice is total. This only decides `absorbedBy`'s target — every
51
+ * sibling is still amplified identically by `rank`, keyed on
52
+ * (file, qualifiedSymbol) rather than on which one absorbed the claim.
53
+ */
54
+ export function foldReach(facts, factWeight) {
55
+ const reach = new Map();
56
+ const absorbedBy = new Map();
57
+ const others = facts.filter((f) => f.kind !== "blast_radius");
58
+ const radius = facts.filter((f) => f.kind === "blast_radius");
59
+ const findSibling = (f) => {
60
+ const candidates = others.filter((o) => o.file === f.file &&
61
+ o.qualifiedSymbol !== undefined &&
62
+ o.qualifiedSymbol === f.qualifiedSymbol);
63
+ return candidates.reduce((best, candidate) => {
64
+ if (!best)
65
+ return candidate;
66
+ if (factWeight(candidate) > factWeight(best))
67
+ return candidate;
68
+ if (factWeight(candidate) === factWeight(best) && candidate.id < best.id)
69
+ return candidate;
70
+ return best;
71
+ }, undefined);
72
+ };
73
+ const kept = [];
74
+ for (const f of radius) {
75
+ // Reach is recorded for every blast_radius fact with a symbol,
76
+ // unconditionally — one loop, no extra branch for whether a sibling
77
+ // exists. For a lonely fact (no sibling below) this entry is written
78
+ // but never read: `rank` deliberately does not apply a fact's own
79
+ // reach to itself, and nothing else shares that fact's
80
+ // (file, qualifiedSymbol) key by definition of "lonely". That is a
81
+ // harmless unused map entry, not a feature — do not read anything into
82
+ // it being there.
83
+ if (f.qualifiedSymbol) {
84
+ reach.set(reachKey(f.file, f.qualifiedSymbol), {
85
+ references: referenceCount(f),
86
+ // evidence[0] is the declaration itself; the rest are call sites.
87
+ sites: f.evidence.slice(1),
88
+ });
89
+ }
90
+ const sibling = f.qualifiedSymbol ? findSibling(f) : undefined;
91
+ if (sibling) {
92
+ absorbedBy.set(f.id, sibling.id);
93
+ }
94
+ else {
95
+ // Every lonely fact is kept here, whatever its reference count —
96
+ // deliberately not the place low-reference rows are filtered. That
97
+ // filter (`MIN_STANDALONE_REFERENCES`, in `./reconcile.js`) runs
98
+ // after model claims attach, because a claim citing this fact can
99
+ // only find it if its finding still exists to attach to; filtering
100
+ // here silently dropped that claim.
101
+ kept.push(f);
102
+ }
103
+ }
104
+ return { facts: [...others, ...kept], reach, absorbedBy };
105
+ }
106
+ /**
107
+ * The members' reach merged into one entry, with duplicated sites removed.
108
+ * Per-member reference counts are exact, but two members of one group can
109
+ * share a referencing line — a single consumer line naming three of the
110
+ * grouped exports — and a plain sum then reports one place as three
111
+ * places, listing the same site once per member. Every duplicate observed
112
+ * among the collected sites is exactly one such over-count, so the merged
113
+ * count is the sum minus the duplicates seen. Sites the analyzer's
114
+ * evidence cap left uncollected cannot be deduplicated, so in a capped
115
+ * group the count errs toward the raw sum rather than undercounting real
116
+ * references.
117
+ */
118
+ function mergedReach(withReach) {
119
+ if (withReach.length === 0)
120
+ return undefined;
121
+ const seen = new Set();
122
+ const sites = [];
123
+ let duplicates = 0;
124
+ for (const f of withReach) {
125
+ for (const site of f.reach.sites) {
126
+ const key = `${site.file}:${site.line}`;
127
+ if (seen.has(key)) {
128
+ duplicates++;
129
+ }
130
+ else {
131
+ seen.add(key);
132
+ sites.push(site);
133
+ }
134
+ }
135
+ }
136
+ const summed = withReach.reduce((sum, f) => sum + f.reach.references, 0);
137
+ return { references: summed - duplicates, sites };
138
+ }
139
+ const ADDED_EXPORT_THRESHOLD = 3;
140
+ /**
141
+ * Collapses a file's added-export findings into one entry once there are
142
+ * enough of them to be noise rather than news. A new module legitimately
143
+ * exports a dozen symbols; listing each as its own finding buries everything
144
+ * that names a problem. `absorbedBy` records, for every collapsed finding,
145
+ * the id of the group finding that replaced it — the id a model claim
146
+ * naming that export must be redirected to.
147
+ */
148
+ export function groupAddedExports(findings, threshold = ADDED_EXPORT_THRESHOLD) {
149
+ const byFile = new Map();
150
+ const rest = [];
151
+ for (const f of findings) {
152
+ if (!f.id.startsWith("export_added:")) {
153
+ rest.push(f);
154
+ continue;
155
+ }
156
+ const group = byFile.get(f.file) ?? [];
157
+ group.push(f);
158
+ byFile.set(f.file, group);
159
+ }
160
+ const out = [...rest];
161
+ const absorbedBy = new Map();
162
+ for (const [file, group] of byFile) {
163
+ if (group.length < threshold) {
164
+ out.push(...group);
165
+ continue;
166
+ }
167
+ const names = group
168
+ .map((f) => f.title.replace(/ is newly exported$/, ""))
169
+ .sort();
170
+ // A grouped export can still carry reach (an added export referenced
171
+ // elsewhere in the same change). Merging keeps that fact from vanishing
172
+ // into the group the way it would if `reach` were simply dropped along
173
+ // with the individual findings that carried it. `references` on a
174
+ // blast_radius-derived `Reach` is always at least one (the analyzer
175
+ // never emits a fact for zero references), so the merged total here is
176
+ // too — `mergedReach` subtracts only duplicates it saw among the sites,
177
+ // never below the distinct count.
178
+ const reach = mergedReach(group.filter((f) => f.reach !== undefined));
179
+ const places = reach && (reach.references === 1 ? "One place" : `${reach.references} places`);
180
+ // Agrees with `places`'s own number, the same way `toFinding`'s
181
+ // blast_radius branch in `../score/index.ts` does for its "references
182
+ // it" sentence: "one place" takes "references", "N places" takes
183
+ // "reference".
184
+ const verb = reach && reach.references === 1 ? "references" : "reference";
185
+ const reachSentence = reach ? ` ${places} in this repository ${verb} them.` : "";
186
+ const groupId = `export_added_group:${file}`;
187
+ for (const f of group)
188
+ absorbedBy.set(f.id, groupId);
189
+ out.push({
190
+ id: groupId,
191
+ tier: "verified",
192
+ file,
193
+ line: group[0].line,
194
+ // No file prefix — the renderer already prints `file:line — ` ahead
195
+ // of every title (see the identical fix in `toFinding`'s comment on
196
+ // effect findings).
197
+ title: `exports ${group.length} new symbols`,
198
+ body: `New public surface: ${names.join(", ")}. New exports cannot break an existing caller, but they are what future code will depend on.${reachSentence}`,
199
+ score: Math.max(...group.map((f) => f.score)),
200
+ evidence: group.map((f) => f.evidence[0]),
201
+ ...(reach ? { reach } : {}),
202
+ });
203
+ }
204
+ return { findings: out, absorbedBy };
205
+ }
206
+ const SIGNATURE_CHANGE_GROUP_THRESHOLD = 3;
207
+ /**
208
+ * The hedge appended to a signature_changed body when an after side
209
+ * rendered exactly as `any` and its before side did not. The checker
210
+ * prints `any` both for a genuine widening and for a type it could not
211
+ * resolve at the reviewed revision — a repository whose dependencies are
212
+ * not installed at that revision resolves every import from them this
213
+ * way — and the surface analyzer records only the rendered text, so the
214
+ * sentence hedges instead of asserting either reading. Keyed on that text
215
+ * rather than on a resolution-failure signal from the checker: no public,
216
+ * cheap signal survives to `exportedSignatures`' string output, and deeper
217
+ * detection was deliberately ruled out.
218
+ *
219
+ * Takes the affected export names so the hedge points at the member(s) it
220
+ * is about — an unnamed hedge on a many-member group left the reader
221
+ * guessing which change it hedged. Lives here rather than beside the
222
+ * signature prose in `./index.js` because `groupSignatureChanges` below
223
+ * needs it too, and that module already imports this one.
224
+ */
225
+ export function typeUnresolvedNoteFor(names) {
226
+ if (names.length === 1) {
227
+ return `If ${names[0]}'s new type reads as any because it could not be resolved at this revision — often missing dependencies at that commit — the change may be narrower than it looks.`;
228
+ }
229
+ return `If the new types of ${names.join(", ")} read as any because they could not be resolved at this revision — often missing dependencies at that commit — those changes may be narrower than they look.`;
230
+ }
231
+ /**
232
+ * Collapses a file's signature_changed findings into one entry once there
233
+ * are enough of them to be one story rather than several. The first real
234
+ * dogfood run listed ten near-identical rows for one file's exported
235
+ * consts — one shared cause, ten findings burying everything else. Mirrors
236
+ * `groupAddedExports`, above; the same `absorbedBy` contract records, for
237
+ * every collapsed finding, the id of the group finding that replaced it —
238
+ * the id a model claim naming that member must be redirected to.
239
+ *
240
+ * A signature_changed finding with no `details` entry is left ungrouped: a
241
+ * listing line cannot be invented for it, and in production the entry
242
+ * always exists because `rankWithAbsorption` derives the map from the same
243
+ * facts it built the findings from.
244
+ */
245
+ export function groupSignatureChanges(findings, details, threshold = SIGNATURE_CHANGE_GROUP_THRESHOLD) {
246
+ const byFile = new Map();
247
+ const rest = [];
248
+ for (const f of findings) {
249
+ if (!f.id.startsWith("signature_changed:") || !details.has(f.id)) {
250
+ rest.push(f);
251
+ continue;
252
+ }
253
+ const group = byFile.get(f.file) ?? [];
254
+ group.push(f);
255
+ byFile.set(f.file, group);
256
+ }
257
+ const out = [...rest];
258
+ const absorbedBy = new Map();
259
+ for (const [file, group] of byFile) {
260
+ if (group.length < threshold) {
261
+ out.push(...group);
262
+ continue;
263
+ }
264
+ // Score order, highest first, whatever order the members arrived in:
265
+ // the group scores as its highest member and sums its members' reach,
266
+ // so the member that *drives* those numbers must lead the listing, the
267
+ // evidence, and the group's own anchor — a line-ordered listing let the
268
+ // driver hide behind its file position while the finding asserted its
269
+ // score and reach collectively. Ties fall back to file order, then id,
270
+ // keeping the sort total.
271
+ const members = [...group].sort((a, b) => b.score - a.score || a.line - b.line || a.id.localeCompare(b.id));
272
+ // Every member, uncapped — matching `groupAddedExports`, which lists
273
+ // every name and keeps every anchor. A member that contributes to the
274
+ // group's score or its reach sentence must never be invisible in the
275
+ // finding that speaks for it.
276
+ const listing = members.map((f) => details.get(f.id).sentence).join(" ");
277
+ // One hedge for the whole group, not one per member — the unresolved
278
+ // rendering has one cause when it appears here at all, which is the
279
+ // very premise of grouping — but it names exactly the members it is
280
+ // about, so a reader of a many-member group is not left guessing which
281
+ // change it hedges.
282
+ const unresolvedNames = members
283
+ .filter((f) => details.get(f.id).typeUnresolved)
284
+ .map((f) => details.get(f.id).name);
285
+ // Same shape as `groupAddedExports`' reach handling, for the same
286
+ // reason: a member's folded-in reach must not vanish into the group,
287
+ // and a site two members share must not count as two places.
288
+ const reach = mergedReach(members.filter((f) => f.reach !== undefined));
289
+ const places = reach && (reach.references === 1 ? "One place" : `${reach.references} places`);
290
+ const verb = reach && reach.references === 1 ? "references" : "reference";
291
+ const reachSentence = reach ? ` ${places} in this repository ${verb} them.` : "";
292
+ const groupId = `signature_changed_group:${file}`;
293
+ for (const f of members)
294
+ absorbedBy.set(f.id, groupId);
295
+ out.push({
296
+ id: groupId,
297
+ tier: "verified",
298
+ file,
299
+ // The driver's declaration line, since members lead with the highest
300
+ // score: the place the headline sends the reader is the member that
301
+ // earned the group its rank.
302
+ line: members[0].line,
303
+ // Unlike every single-fact title in this module, the file is named in
304
+ // the title even though the renderer already prefixes `file:line — `:
305
+ // a bare "N exports changed their signature" reads as a claim about
306
+ // the whole range, and scoping the headline to its file is worth the
307
+ // repetition.
308
+ title: `${members.length} exports in ${file} changed their signature`,
309
+ body: `${listing} A changed contract can break callers without breaking the build at this file, so check the call sites.${unresolvedNames.length > 0 ? ` ${typeUnresolvedNoteFor(unresolvedNames)}` : ""}${reachSentence}`,
310
+ // The group scores as its highest-scoring member. Grouping is
311
+ // presentation, not amplification: ten same-cause rows folded into
312
+ // one must rank exactly where the most serious of them would have
313
+ // ranked alone, never higher for having company.
314
+ score: Math.max(...members.map((f) => f.score)),
315
+ evidence: members.map((f) => f.evidence[0]),
316
+ ...(reach ? { reach } : {}),
317
+ });
318
+ }
319
+ return { findings: out, absorbedBy };
320
+ }
@@ -0,0 +1,52 @@
1
+ import type { Claim, Fact, Finding } from "../types.js";
2
+ /**
3
+ * Strictly below `minPossibleAnalyzerScore()` (currently 6, an
4
+ * `effect_removed` timing effect) — never the raw `WEIGHTS.factKind`
5
+ * minimum, which ignores the effect multiplier `scoreFact` applies and put
6
+ * the old hardcoded ceiling (14) 8 points above a score an analyzer can
7
+ * actually produce. Derived, not hand-copied, so a future weight change can
8
+ * only move this number, never leave it stale above a fact it is supposed
9
+ * to sit under.
10
+ */
11
+ export declare const MODEL_CEILING: number;
12
+ /**
13
+ * The fewest references at which an unabsorbed blast_radius finding that no
14
+ * claim explains still earns a standalone row. "X changed and is referenced
15
+ * in one place" names no problem and barely any cost — a sixth of the first
16
+ * real dogfood report was exactly that row.
17
+ *
18
+ * Enforced here, after model claims attach, and not in `foldReach` where
19
+ * the fact-level fold happens: a claim citing the fact can only attach to a
20
+ * finding that still exists, so filtering earlier silently dropped the
21
+ * claim — the one kind of loss this pipeline is built to refuse. A finding
22
+ * a claim did attach to survives at its normal `inferred` tier, because
23
+ * model context is exactly what promotes the row out of "filler". Only the
24
+ * claim-free standalone row disappears: absorption into siblings happened
25
+ * back in `foldReach`, before any of this, so amplified findings are
26
+ * untouched, and the reach entry itself is recorded regardless.
27
+ * `test/score/reconcile.test.ts` pins the survival edge and both sides of
28
+ * the numeric line, so moving this number either way fails a test.
29
+ */
30
+ export declare const MIN_STANDALONE_REFERENCES = 2;
31
+ /**
32
+ * Merges what the analyzers found with what the model said.
33
+ *
34
+ * The asymmetry is the point: a claim can only ever annotate a fact or
35
+ * stand alone, and a fact survives as a finding whether or not the model
36
+ * mentions it — with exactly one scoped exception, a claim-free lonely
37
+ * blast_radius row under MIN_STANDALONE_REFERENCES, filtered after claims
38
+ * attach and disclosed through `onSuppressed` rather than dropped in
39
+ * silence. The rule is pinned by "keeps every fact except a claim-free
40
+ * sub-threshold reach row as a finding even when the model says nothing"
41
+ * and the exception by "suppresses a claim-free lonely one-reference reach
42
+ * finding", both in test/score/reconcile.test.ts.
43
+ *
44
+ * A claim never edits a fact's file, line, or evidence — if the model
45
+ * asserts a location, it is ignored in favour of the analyzer's, because
46
+ * the analyzer's came from the code.
47
+ *
48
+ * The marker on a claim travels to the finding it lands on and nothing else:
49
+ * `test/score/reconcile.test.ts`, "changes no score and no ordering, with the
50
+ * marker or without it".
51
+ */
52
+ export declare function reconcile(facts: Fact[], claims: Claim[], onDroppedClaims?: (count: number) => void, onSuppressed?: (count: number) => void): Finding[];