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.
- package/LICENSE +21 -0
- package/README.md +229 -0
- package/dist/analyze/blast-radius.d.ts +28 -0
- package/dist/analyze/blast-radius.js +163 -0
- package/dist/analyze/canonical.d.ts +27 -0
- package/dist/analyze/canonical.js +74 -0
- package/dist/analyze/citations.d.ts +256 -0
- package/dist/analyze/citations.js +945 -0
- package/dist/analyze/effects.d.ts +15 -0
- package/dist/analyze/effects.js +255 -0
- package/dist/analyze/fact.d.ts +42 -0
- package/dist/analyze/fact.js +46 -0
- package/dist/analyze/guards.d.ts +70 -0
- package/dist/analyze/guards.js +211 -0
- package/dist/analyze/index.d.ts +26 -0
- package/dist/analyze/index.js +52 -0
- package/dist/analyze/program.d.ts +15 -0
- package/dist/analyze/program.js +229 -0
- package/dist/analyze/surface.d.ts +48 -0
- package/dist/analyze/surface.js +396 -0
- package/dist/bin.d.ts +2 -0
- package/dist/bin.js +12 -0
- package/dist/cli.d.ts +110 -0
- package/dist/cli.js +502 -0
- package/dist/extract/diff.d.ts +35 -0
- package/dist/extract/diff.js +116 -0
- package/dist/extract/git.d.ts +12 -0
- package/dist/extract/git.js +247 -0
- package/dist/extract/index.d.ts +4 -0
- package/dist/extract/index.js +57 -0
- package/dist/extract/intent.d.ts +64 -0
- package/dist/extract/intent.js +238 -0
- package/dist/extract/scope.d.ts +160 -0
- package/dist/extract/scope.js +284 -0
- package/dist/extract/symbols.d.ts +24 -0
- package/dist/extract/symbols.js +230 -0
- package/dist/interpret/client.d.ts +27 -0
- package/dist/interpret/client.js +80 -0
- package/dist/interpret/index.d.ts +41 -0
- package/dist/interpret/index.js +86 -0
- package/dist/interpret/prompt.d.ts +23 -0
- package/dist/interpret/prompt.js +128 -0
- package/dist/interpret/schema.d.ts +74 -0
- package/dist/interpret/schema.js +103 -0
- package/dist/report/conceal.d.ts +63 -0
- package/dist/report/conceal.js +129 -0
- package/dist/report/coverage.d.ts +43 -0
- package/dist/report/coverage.js +56 -0
- package/dist/report/html.d.ts +4 -0
- package/dist/report/html.js +634 -0
- package/dist/report/markdown.d.ts +2 -0
- package/dist/report/markdown.js +168 -0
- package/dist/report/model.d.ts +303 -0
- package/dist/report/model.js +289 -0
- package/dist/report/pdf.d.ts +2 -0
- package/dist/report/pdf.js +217 -0
- package/dist/report/terminal.d.ts +2 -0
- package/dist/report/terminal.js +206 -0
- package/dist/report/write.d.ts +105 -0
- package/dist/report/write.js +160 -0
- package/dist/score/index.d.ts +94 -0
- package/dist/score/index.js +572 -0
- package/dist/score/reach.d.ts +126 -0
- package/dist/score/reach.js +320 -0
- package/dist/score/reconcile.d.ts +52 -0
- package/dist/score/reconcile.js +208 -0
- package/dist/types.d.ts +221 -0
- package/dist/types.js +10 -0
- package/fonts/DejaVuSans-Bold.ttf +0 -0
- package/fonts/DejaVuSans-Oblique.ttf +0 -0
- package/fonts/DejaVuSans.ttf +0 -0
- package/fonts/DejaVuSansMono.ttf +0 -0
- package/fonts/LICENSE +187 -0
- 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[];
|