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,572 @@
|
|
|
1
|
+
import { ANONYMOUS_OWNER, GETTER_FRAME_PREFIX, LOCAL_SCOPE, MODULE_OWNER, SETTER_FRAME_PREFIX, } from "../extract/scope.js";
|
|
2
|
+
import { SIGNATURE_TRUNCATION_MARKER } from "../analyze/surface.js";
|
|
3
|
+
import { typeUnresolvedNoteFor, foldReach, groupAddedExports, groupSignatureChanges, reachKey, } from "./reach.js";
|
|
4
|
+
/**
|
|
5
|
+
* Tunable in one place on purpose: these weights will need adjusting once
|
|
6
|
+
* they have been run against real diffs.
|
|
7
|
+
*/
|
|
8
|
+
export const WEIGHTS = {
|
|
9
|
+
factKind: {
|
|
10
|
+
// A vanished check is the best mechanical proxy for a correctness or
|
|
11
|
+
// security regression, so it outranks everything else.
|
|
12
|
+
guard_removed: 90,
|
|
13
|
+
signature_changed: 75,
|
|
14
|
+
export_removed: 70,
|
|
15
|
+
effect_added: 60,
|
|
16
|
+
// Deliberately far below the ceiling the log curve is clamped to (see
|
|
17
|
+
// `effect_added`, above): this is the *base* the log-scaled formula in
|
|
18
|
+
// `scoreFact` multiplies, not the score a blast-radius fact typically
|
|
19
|
+
// gets. At a base of 40 the curve saturated at three references, so 4
|
|
20
|
+
// references and 24 scored identically and every blast-radius finding
|
|
21
|
+
// in a real run tied at the ceiling and sorted by file path — the
|
|
22
|
+
// ranking carried no information at all. The base was lowered to the
|
|
23
|
+
// value below so the curve instead spans one reference up to the
|
|
24
|
+
// ceiling at roughly a thousand, which covers every count a real
|
|
25
|
+
// repository produces. See `scoreFact`.
|
|
26
|
+
blast_radius: 15,
|
|
27
|
+
export_added: 25,
|
|
28
|
+
effect_removed: 15,
|
|
29
|
+
// A rotted citation is a defect in the repository's account of itself, not
|
|
30
|
+
// in its behavior: nothing a reader merges is broken by it. So it sits
|
|
31
|
+
// above the kinds that report cost rather than a problem, and below the
|
|
32
|
+
// kinds that report new public surface or a regression. See
|
|
33
|
+
// `test/score/index.test.ts`.
|
|
34
|
+
citation_rot: 18,
|
|
35
|
+
},
|
|
36
|
+
effect: {
|
|
37
|
+
network: 1.0,
|
|
38
|
+
database: 1.0,
|
|
39
|
+
process: 0.9,
|
|
40
|
+
filesystem: 0.8,
|
|
41
|
+
env: 0.6,
|
|
42
|
+
timing: 0.4,
|
|
43
|
+
},
|
|
44
|
+
};
|
|
45
|
+
function effectOf(fact) {
|
|
46
|
+
const e = fact.detail.effect;
|
|
47
|
+
return typeof e === "string" && Object.hasOwn(WEIGHTS.effect, e)
|
|
48
|
+
? e
|
|
49
|
+
: "timing";
|
|
50
|
+
}
|
|
51
|
+
export function scoreFact(fact) {
|
|
52
|
+
const base = WEIGHTS.factKind[fact.kind];
|
|
53
|
+
if (fact.kind === "effect_added" || fact.kind === "effect_removed") {
|
|
54
|
+
return base * WEIGHTS.effect[effectOf(fact)];
|
|
55
|
+
}
|
|
56
|
+
if (fact.kind === "blast_radius") {
|
|
57
|
+
// Log-scaled: three callers and forty are meaningfully different, forty
|
|
58
|
+
// and eighty are not. The base must stay low enough that the curve does
|
|
59
|
+
// not hit the ceiling below the reference counts real repositories
|
|
60
|
+
// produce — a saturated curve ranks nothing (see `WEIGHTS.factKind.blast_radius`).
|
|
61
|
+
const refs = typeof fact.detail.references === "number" ? fact.detail.references : 1;
|
|
62
|
+
const logScaled = base * (1 + Math.log10(Math.max(refs, 1)));
|
|
63
|
+
// A blast-radius fact reports reach, not a defect: "this changed and a
|
|
64
|
+
// lot of code uses it" names no problem by itself, so no reference count
|
|
65
|
+
// may push it above a fact that does. `effect_added` is a deliberately
|
|
66
|
+
// chosen ceiling, set below guard_removed / signature_changed /
|
|
67
|
+
// export_removed — the kinds that most directly report a regression —
|
|
68
|
+
// so a widely-used export can never bury one of those under sheer reach.
|
|
69
|
+
// Do not raise this to "fix" a large-repo score; raise it only by
|
|
70
|
+
// deciding blast radius should outrank a removed guard, which it should
|
|
71
|
+
// not.
|
|
72
|
+
return Math.min(logScaled, WEIGHTS.factKind.effect_added);
|
|
73
|
+
}
|
|
74
|
+
return base;
|
|
75
|
+
}
|
|
76
|
+
const FACT_KINDS = Object.keys(WEIGHTS.factKind);
|
|
77
|
+
const EFFECT_KINDS = Object.keys(WEIGHTS.effect);
|
|
78
|
+
function syntheticFact(kind, detail) {
|
|
79
|
+
return { id: "synthetic", kind, file: "synthetic.ts", line: 1, detail, evidence: [] };
|
|
80
|
+
}
|
|
81
|
+
/**
|
|
82
|
+
* The lowest score any real analyzer fact can produce, across every fact
|
|
83
|
+
* kind and — for the two effect kinds, whose score also depends on which
|
|
84
|
+
* effect fired — every effect. Computed by actually calling `scoreFact` on
|
|
85
|
+
* a synthetic fact of each shape, not by reading `WEIGHTS.factKind` and
|
|
86
|
+
* taking its minimum directly: that minimum (shared by `blast_radius` and
|
|
87
|
+
* `effect_removed`) is a real, producible score — `effect_removed` ×
|
|
88
|
+
* `network` or `database` reaches it exactly, and so does `blast_radius` at
|
|
89
|
+
* one reference — but it is not the *lowest* producible score. `scoreFact`
|
|
90
|
+
* also multiplies `effect_removed`'s (and `effect_added`'s) base by an
|
|
91
|
+
* effect weight that can sit below `network`/`database`'s (`timing` is the
|
|
92
|
+
* lowest), which takes `effect_removed` down further still — a score
|
|
93
|
+
* `WEIGHTS.factKind` has no entry for,
|
|
94
|
+
* because it only lists each kind's base, not what the formula built on top
|
|
95
|
+
* of that base can still produce. Calling `scoreFact` directly means this
|
|
96
|
+
* tracks the real formula (the effect multiplier, the blast_radius log
|
|
97
|
+
* curve) instead of a hand-copied table that cannot see below its own
|
|
98
|
+
* bases. `reconcile.ts` derives `MODEL_CEILING` from this, so a claim can
|
|
99
|
+
* never be scored above the weakest thing an analyzer can find.
|
|
100
|
+
*/
|
|
101
|
+
export function minPossibleAnalyzerScore() {
|
|
102
|
+
const scores = FACT_KINDS.flatMap((kind) => {
|
|
103
|
+
if (kind === "effect_added" || kind === "effect_removed") {
|
|
104
|
+
return EFFECT_KINDS.map((effect) => scoreFact(syntheticFact(kind, { effect, sites: 1 })));
|
|
105
|
+
}
|
|
106
|
+
if (kind === "blast_radius") {
|
|
107
|
+
// references: one is the floor — the analyzer that emits blast_radius
|
|
108
|
+
// facts never emits one for zero references (see the early return in
|
|
109
|
+
// src/analyze/blast-radius.ts, `if (refs.length === 0) continue;`),
|
|
110
|
+
// so nothing weaker exists.
|
|
111
|
+
return [scoreFact(syntheticFact(kind, { references: 1 }))];
|
|
112
|
+
}
|
|
113
|
+
return [scoreFact(syntheticFact(kind, {}))];
|
|
114
|
+
});
|
|
115
|
+
return Math.min(...scores);
|
|
116
|
+
}
|
|
117
|
+
/**
|
|
118
|
+
* The evidence tier for a finding, from what produced it.
|
|
119
|
+
*
|
|
120
|
+
* - `verified` — an analyzer found it and can point at the code.
|
|
121
|
+
* - `inferred` — the model explained something an analyzer found. The fact
|
|
122
|
+
* is still true; the *explanation* is the model's, so the finding is only
|
|
123
|
+
* as good as that explanation.
|
|
124
|
+
* - `model` — the model alone. Nothing mechanical corroborates it.
|
|
125
|
+
*
|
|
126
|
+
* A fact always beats a claim: if there is a fact, the tier can never be
|
|
127
|
+
* `model`, because something machine-checked is underneath it.
|
|
128
|
+
*/
|
|
129
|
+
export function tierFor(fact, claim) {
|
|
130
|
+
if (fact && claim?.correspondsTo === fact.id)
|
|
131
|
+
return "inferred";
|
|
132
|
+
if (fact)
|
|
133
|
+
return "verified";
|
|
134
|
+
return "model";
|
|
135
|
+
}
|
|
136
|
+
/** "a network" but "an env" — the first words a user reads. */
|
|
137
|
+
function article(word) {
|
|
138
|
+
return /^[aeiou]/i.test(word) ? "an" : "a";
|
|
139
|
+
}
|
|
140
|
+
function str(v, fallback) {
|
|
141
|
+
return typeof v === "string" && v.length > 0 ? v : fallback;
|
|
142
|
+
}
|
|
143
|
+
function num(v, fallback) {
|
|
144
|
+
return typeof v === "number" ? v : fallback;
|
|
145
|
+
}
|
|
146
|
+
function isNonEmptyString(v) {
|
|
147
|
+
return typeof v === "string" && v.length > 0;
|
|
148
|
+
}
|
|
149
|
+
/**
|
|
150
|
+
* Longest signature text, in code points, that a finding body renders
|
|
151
|
+
* verbatim on either side of a was→now sentence. Deliberately far below
|
|
152
|
+
* `MAX_SIGNATURE_LENGTH` (the storage cap in `../analyze/surface.ts`): that
|
|
153
|
+
* one bounds what a fact *carries*, this one bounds what a sentence *shows*.
|
|
154
|
+
* The first dogfood run printed a JWT-sized string literal verbatim into a
|
|
155
|
+
* body, which both drowned the sentence and republished a secret-looking
|
|
156
|
+
* literal in a report — the middle-truncated rendering is readability plus
|
|
157
|
+
* soft redaction. Nothing is hidden from a reader who wants the full text:
|
|
158
|
+
* it remains in the diff itself, at the declaration every such finding
|
|
159
|
+
* anchors its evidence to. `test/comment-contract.test.ts` derives part of
|
|
160
|
+
* its forbidden set from this, so comments name it rather than restating
|
|
161
|
+
* its value.
|
|
162
|
+
*/
|
|
163
|
+
export const MAX_RENDERED_SIGNATURE = 120;
|
|
164
|
+
/**
|
|
165
|
+
* How much of the tail survives the middle cut. A tail is kept at all —
|
|
166
|
+
* rather than cutting at the end the way `truncateSignature` in
|
|
167
|
+
* `../analyze/surface.ts` does — because the end of a signature is often
|
|
168
|
+
* the discriminating part (a return type, a literal's final characters),
|
|
169
|
+
* and a head-only cut of two long literals sharing a prefix would render
|
|
170
|
+
* identically.
|
|
171
|
+
*/
|
|
172
|
+
const RENDERED_SIGNATURE_TAIL = 12;
|
|
173
|
+
/**
|
|
174
|
+
* Middle-truncates a signature that would overflow `MAX_RENDERED_SIGNATURE`,
|
|
175
|
+
* stating the original length so the cut is visible rather than mistakable
|
|
176
|
+
* for the whole text. Counted and cut by code point, not UTF-16 unit, for
|
|
177
|
+
* the same reason `excerpt` in `../report/terminal.ts` is: `String#slice`
|
|
178
|
+
* counts units, so an astral character straddling either cut would leave a
|
|
179
|
+
* lone surrogate that renders as U+FFFD. Applied here, in the one place
|
|
180
|
+
* was→now sentences are composed, so the terminal and HTML renderers cannot
|
|
181
|
+
* disagree about what they show.
|
|
182
|
+
*
|
|
183
|
+
* `trueChars` is the length stated in the marker when given: the length of
|
|
184
|
+
* the signature before any upstream cap cut it, not of whatever text
|
|
185
|
+
* survived to this layer — a marker measuring the surviving text asserted
|
|
186
|
+
* a false size whenever the storage cap had already run.
|
|
187
|
+
*/
|
|
188
|
+
function truncateRenderedSignature(text, trueChars) {
|
|
189
|
+
const points = [...text];
|
|
190
|
+
if (points.length <= MAX_RENDERED_SIGNATURE)
|
|
191
|
+
return text;
|
|
192
|
+
const head = points.slice(0, MAX_RENDERED_SIGNATURE - RENDERED_SIGNATURE_TAIL - 1).join("");
|
|
193
|
+
const tail = points.slice(points.length - RENDERED_SIGNATURE_TAIL).join("");
|
|
194
|
+
return `${head}…${tail} (${trueChars ?? points.length} chars)`;
|
|
195
|
+
}
|
|
196
|
+
/**
|
|
197
|
+
* One side of a was→now sentence, from what the fact stored. `trueChars`
|
|
198
|
+
* is the fact's `beforeChars`/`afterChars` detail — the code-point length
|
|
199
|
+
* the surface analyzer measured before `MAX_SIGNATURE_LENGTH` capped the
|
|
200
|
+
* stored text. When it is present, a trailing storage marker is dropped
|
|
201
|
+
* ahead of the middle cut: the render marker states the true length now,
|
|
202
|
+
* and keeping both left the storage marker's tail fragment sitting inside
|
|
203
|
+
* the rendered tail. When it is absent — an older fact, or one built by
|
|
204
|
+
* hand — the stored text renders exactly as before, marker included, with
|
|
205
|
+
* the marker length falling back to the stored text's own count.
|
|
206
|
+
*/
|
|
207
|
+
function renderStoredSignature(stored, trueChars) {
|
|
208
|
+
const chars = typeof trueChars === "number" ? trueChars : undefined;
|
|
209
|
+
const bare = chars === undefined ? stored : stored.replace(SIGNATURE_TRUNCATION_MARKER, "");
|
|
210
|
+
return truncateRenderedSignature(bare, chars);
|
|
211
|
+
}
|
|
212
|
+
/**
|
|
213
|
+
* The signature_changed prose, shared between `toFinding` (one fact, one
|
|
214
|
+
* finding) and the member details `rankWithAbsorption` hands to
|
|
215
|
+
* `groupSignatureChanges` — composed once so the grouped and ungrouped
|
|
216
|
+
* renderings of the same fact cannot drift apart.
|
|
217
|
+
*
|
|
218
|
+
* On narrowing: `hasName` (an aliased condition, a stored boolean) carries
|
|
219
|
+
* a type-guard's narrowing only for a const variable, not for a property
|
|
220
|
+
* access — narrowing `fact.detail.export` through it would not compile —
|
|
221
|
+
* and the local is reused for `leadingName`, which capitalises only the
|
|
222
|
+
* fallback so a real export keeps its own casing (see `capitalize`'s doc
|
|
223
|
+
* comment).
|
|
224
|
+
*/
|
|
225
|
+
function describeSignatureChange(fact) {
|
|
226
|
+
const rawName = fact.detail.export;
|
|
227
|
+
const hasName = isNonEmptyString(rawName);
|
|
228
|
+
const name = hasName ? rawName : "an export";
|
|
229
|
+
const rawBefore = str(fact.detail.before, "its previous type");
|
|
230
|
+
const rawAfter = str(fact.detail.after, "a new type");
|
|
231
|
+
// Detected on the raw strings, ahead of truncation — though `any` is
|
|
232
|
+
// short enough that the order could never matter — and asymmetric on
|
|
233
|
+
// purpose: an export that was already `any` and stayed `any` never
|
|
234
|
+
// emits a fact, and one whose *before* side reads `any` genuinely
|
|
235
|
+
// narrowed, which needs no hedge. The hedge itself is
|
|
236
|
+
// `typeUnresolvedNoteFor`, whose doc comment (in `./reach.js`) argues why
|
|
237
|
+
// it keys on the rendered text.
|
|
238
|
+
const typeUnresolved = rawAfter === "any" && rawBefore !== "any";
|
|
239
|
+
const leadingName = hasName ? name : capitalize(name);
|
|
240
|
+
const sentence = `${leadingName} was ${renderStoredSignature(rawBefore, fact.detail.beforeChars)} and is now ${renderStoredSignature(rawAfter, fact.detail.afterChars)}.`;
|
|
241
|
+
return { name, sentence, typeUnresolved };
|
|
242
|
+
}
|
|
243
|
+
/**
|
|
244
|
+
* Bodies are sentences and must start capitalised — unlike titles, which are
|
|
245
|
+
* deliberately lowercase-led because the renderer prefixes them with
|
|
246
|
+
* `file:line — `. Only ever applied to a fallback string (see call sites): a
|
|
247
|
+
* real symbol name keeps whatever casing the source code gave it, since
|
|
248
|
+
* forcing that would misspell the identifier the user is reading about.
|
|
249
|
+
*/
|
|
250
|
+
function capitalize(s) {
|
|
251
|
+
return s.length > 0 ? `${s[0].toUpperCase()}${s.slice(1)}` : s;
|
|
252
|
+
}
|
|
253
|
+
/**
|
|
254
|
+
* Plain English for every scope sentinel a qualified owner path can contain,
|
|
255
|
+
* keyed on the constants themselves. `guardOwnerLabel` translates what is in
|
|
256
|
+
* this table plus the accessor frames (`accessorSegmentLabel`, below) and
|
|
257
|
+
* nothing else, so a sentinel is either listed here or printed raw to a
|
|
258
|
+
* reader. `SCOPE_SENTINELS` is the list this has to cover, and
|
|
259
|
+
* `test/score/index.test.ts` walks that list rather than this table — so a new
|
|
260
|
+
* sentinel with no entry here fails a test instead of reaching a report.
|
|
261
|
+
*/
|
|
262
|
+
const SEGMENT_LABEL = {
|
|
263
|
+
[MODULE_OWNER]: "the top level of this file",
|
|
264
|
+
[ANONYMOUS_OWNER]: "an anonymous function",
|
|
265
|
+
[LOCAL_SCOPE]: "an unnamed block",
|
|
266
|
+
};
|
|
267
|
+
/**
|
|
268
|
+
* "the value getter" for an accessor's `get value` frame — the second family
|
|
269
|
+
* of path segments that is not source text a reader can search for, beside
|
|
270
|
+
* the sentinels above. Parameterised where the sentinels are fixed, so it is
|
|
271
|
+
* a function rather than more table rows; `test/score/index.test.ts` pins
|
|
272
|
+
* both accessor kinds at every path position the sentinel walk covers.
|
|
273
|
+
*/
|
|
274
|
+
function accessorSegmentLabel(segment) {
|
|
275
|
+
if (segment.startsWith(GETTER_FRAME_PREFIX)) {
|
|
276
|
+
return `the ${segment.slice(GETTER_FRAME_PREFIX.length)} getter`;
|
|
277
|
+
}
|
|
278
|
+
if (segment.startsWith(SETTER_FRAME_PREFIX)) {
|
|
279
|
+
return `the ${segment.slice(SETTER_FRAME_PREFIX.length)} setter`;
|
|
280
|
+
}
|
|
281
|
+
return undefined;
|
|
282
|
+
}
|
|
283
|
+
/**
|
|
284
|
+
* `guard_removed`'s `symbol` is the qualified owner path `collectGuards`
|
|
285
|
+
* attributed the guard to — `Worker.run`, which reads fine as it stands. What
|
|
286
|
+
* does not read fine are the sentinels that path can contain.
|
|
287
|
+
*
|
|
288
|
+
* A sentinel can sit at *any* position, and handling only "the whole path" and
|
|
289
|
+
* "the last segment" printed a raw one to the reader — `<anonymous>.inner` for a
|
|
290
|
+
* method of an object literal passed straight to a call, `<local>.run` for an
|
|
291
|
+
* arrow declared in a top-level block, `Registry.<local>.helper` for a local in
|
|
292
|
+
* a static initializer block. So the path is read as segments: each run of
|
|
293
|
+
* real names stays dotted, each sentinel becomes its phrase, and the pieces are
|
|
294
|
+
* joined innermost first — "inner in an anonymous function", "an anonymous
|
|
295
|
+
* function in Worker.run", "run in an unnamed block". A path of real names comes
|
|
296
|
+
* back exactly as it went in, and a lone `MODULE_OWNER` comes back as the one
|
|
297
|
+
* phrase it has always had.
|
|
298
|
+
*/
|
|
299
|
+
function guardOwnerLabel(v) {
|
|
300
|
+
const s = str(v, "this code");
|
|
301
|
+
const parts = [];
|
|
302
|
+
let named = [];
|
|
303
|
+
const flush = () => {
|
|
304
|
+
if (named.length > 0)
|
|
305
|
+
parts.push(named.join("."));
|
|
306
|
+
named = [];
|
|
307
|
+
};
|
|
308
|
+
for (const segment of s.split(".")) {
|
|
309
|
+
const label = SEGMENT_LABEL[segment] ?? accessorSegmentLabel(segment);
|
|
310
|
+
if (label) {
|
|
311
|
+
flush();
|
|
312
|
+
parts.push(label);
|
|
313
|
+
}
|
|
314
|
+
else {
|
|
315
|
+
named.push(segment);
|
|
316
|
+
}
|
|
317
|
+
}
|
|
318
|
+
flush();
|
|
319
|
+
return parts.reverse().join(" in ");
|
|
320
|
+
}
|
|
321
|
+
export function toFinding(fact) {
|
|
322
|
+
let title;
|
|
323
|
+
let body;
|
|
324
|
+
switch (fact.kind) {
|
|
325
|
+
case "effect_added":
|
|
326
|
+
case "effect_removed": {
|
|
327
|
+
// No file path here: the renderer prefixes every finding with
|
|
328
|
+
// `file:line — `, and the JSON carries `file` as its own field. Naming
|
|
329
|
+
// it again produced "svc.ts:2 — svc.ts introduces a network effect".
|
|
330
|
+
const effect = effectOf(fact);
|
|
331
|
+
const sites = num(fact.detail.sites, 1);
|
|
332
|
+
const an = article(effect);
|
|
333
|
+
const where = sites === 1 ? "at one site" : `at ${sites} sites`;
|
|
334
|
+
title =
|
|
335
|
+
fact.kind === "effect_added"
|
|
336
|
+
? `introduces ${an} ${effect} effect`
|
|
337
|
+
: `no longer has ${an} ${effect} effect`;
|
|
338
|
+
body =
|
|
339
|
+
fact.kind === "effect_added"
|
|
340
|
+
? `This file previously had no ${effect} effect. It now does, ${where}.`
|
|
341
|
+
: `This file previously had ${an} ${effect} effect ${where}. It no longer does.`;
|
|
342
|
+
break;
|
|
343
|
+
}
|
|
344
|
+
case "guard_removed": {
|
|
345
|
+
const owner = guardOwnerLabel(fact.detail.symbol);
|
|
346
|
+
const guard = str(fact.detail.guard, "check");
|
|
347
|
+
const an = article(guard);
|
|
348
|
+
title = `${an} ${guard} guard was removed from ${owner}`;
|
|
349
|
+
body = `${an[0].toUpperCase()}${an.slice(1)} ${guard} guard that previously ran in ${owner} is no longer present. Removed checks are where correctness and security regressions usually hide, so confirm the condition is genuinely unreachable now.`;
|
|
350
|
+
break;
|
|
351
|
+
}
|
|
352
|
+
case "export_added": {
|
|
353
|
+
const name = str(fact.detail.export, "an export");
|
|
354
|
+
title = `${name} is newly exported`;
|
|
355
|
+
body = `This file did not export ${name} before. New public surface is worth a look, but it cannot break an existing caller.`;
|
|
356
|
+
break;
|
|
357
|
+
}
|
|
358
|
+
case "export_removed": {
|
|
359
|
+
const name = str(fact.detail.export, "an export");
|
|
360
|
+
title = `${name} is no longer exported`;
|
|
361
|
+
body = `This file previously exported ${name}. Anything importing it will fail to resolve.`;
|
|
362
|
+
break;
|
|
363
|
+
}
|
|
364
|
+
case "signature_changed": {
|
|
365
|
+
const d = describeSignatureChange(fact);
|
|
366
|
+
title = `${d.name} changed its signature`;
|
|
367
|
+
body = `${d.sentence} A changed contract can break callers without breaking the build at this file, so check the call sites.${d.typeUnresolved ? ` ${typeUnresolvedNoteFor([d.name])}` : ""}`;
|
|
368
|
+
break;
|
|
369
|
+
}
|
|
370
|
+
case "blast_radius": {
|
|
371
|
+
const rawSymbol = fact.detail.symbol;
|
|
372
|
+
const hasSymbol = isNonEmptyString(rawSymbol);
|
|
373
|
+
const symbol = hasSymbol ? rawSymbol : "this export";
|
|
374
|
+
const refs = num(fact.detail.references, 0);
|
|
375
|
+
const places = refs === 1 ? "one place" : `${refs} places`;
|
|
376
|
+
const verb = refs === 1 ? "references" : "reference";
|
|
377
|
+
title = `${symbol} changed and is referenced in ${places}`;
|
|
378
|
+
const leadingSymbol = hasSymbol ? symbol : capitalize(symbol);
|
|
379
|
+
body = `${leadingSymbol} was modified, and ${places} in this repository ${verb} it. The wider the reach, the more a subtle change costs.`;
|
|
380
|
+
break;
|
|
381
|
+
}
|
|
382
|
+
case "citation_rot": {
|
|
383
|
+
const cited = str(fact.detail.citedFile, "the cited file");
|
|
384
|
+
const start = num(fact.detail.citedLine, 0);
|
|
385
|
+
const end = num(fact.detail.citedEndLine, 0);
|
|
386
|
+
// The citation as the prose wrote it: a line, a range, or — for the
|
|
387
|
+
// quoted form — no line at all.
|
|
388
|
+
const at = start === 0 ? "" : end === 0 ? `:${start}` : `:${start}-${end}`;
|
|
389
|
+
// True only when the citation reaches more than one line. A citation
|
|
390
|
+
// the prose wrote as a degenerate range — `X:2-2` — stays a range in
|
|
391
|
+
// the title, which echoes what was written, and is one line everywhere
|
|
392
|
+
// a sentence has to agree with it.
|
|
393
|
+
const spanned = end !== 0 && end !== start;
|
|
394
|
+
const hash = fact.detail.baseline;
|
|
395
|
+
const dated = isNonEmptyString(hash);
|
|
396
|
+
const when = dated ? `when this line was last written (${hash})` : "";
|
|
397
|
+
// Only `missing_file` has a branch for the undated case, and that is
|
|
398
|
+
// not an oversight: the analyzer runs the other three tests solely
|
|
399
|
+
// against a baseline file it has already read, so a fact of those
|
|
400
|
+
// kinds carrying no commit cannot be produced. See
|
|
401
|
+
// `src/analyze/citations.ts`, "With no baseline — blame failed, or the
|
|
402
|
+
// historical-read budget is spent — only the first test runs, ungated,
|
|
403
|
+
// against the reviewed revision."
|
|
404
|
+
switch (fact.detail.rot) {
|
|
405
|
+
case "missing_file":
|
|
406
|
+
if (!dated) {
|
|
407
|
+
// No baseline means no commit to name and no proof the path was
|
|
408
|
+
// ever there, so this branch claims neither.
|
|
409
|
+
title = `cites \`${cited}\`, which is not in this repository at this revision`;
|
|
410
|
+
body = `This line cites \`${cited}${at}\`. That path is not present at this revision. urtext could not read this line's history here, so it does not know whether the citation ever resolved.`;
|
|
411
|
+
break;
|
|
412
|
+
}
|
|
413
|
+
title = `cites \`${cited}\`, which is not in this repository any more`;
|
|
414
|
+
body = `This line cites \`${cited}${at}\`. That file existed ${when} and is not present at this revision, so the citation does not resolve. What it was meant to point at is not something urtext can recover.`;
|
|
415
|
+
break;
|
|
416
|
+
case "line_out_of_range": {
|
|
417
|
+
const count = num(fact.detail.lineCount, 0);
|
|
418
|
+
title = `cites \`${cited}${at}\`, which is past the end of that file`;
|
|
419
|
+
body = `\`${cited}\` has ${count} line${count === 1 ? "" : "s"} at this revision, so ${spanned ? `lines ${start}-${end} are not all in it` : `line ${start} is not in it`}. The citation resolved ${when}; it resolves to nothing now.`;
|
|
420
|
+
break;
|
|
421
|
+
}
|
|
422
|
+
case "quote_absent": {
|
|
423
|
+
const phrase = str(fact.detail.quote, "the quoted phrase");
|
|
424
|
+
title = `cites \`${cited}\` for a quoted phrase that is not in it`;
|
|
425
|
+
body = `This line cites \`${cited}\` and quotes “${phrase}”. That text was in \`${cited}\` ${when} and is not in it at this revision. urtext compares the quoted text against the file's contents; it does not know whether the text moved, was reworded, or was deliberately dropped.`;
|
|
426
|
+
break;
|
|
427
|
+
}
|
|
428
|
+
default: {
|
|
429
|
+
// A line urtext read as empty is something it knows, not something
|
|
430
|
+
// it is missing. `str`'s fallback exists for a detail that is absent
|
|
431
|
+
// or is not a string; an empty line is neither, and routing it
|
|
432
|
+
// through the fallback printed "something else" — a phrase whose
|
|
433
|
+
// whole meaning is "urtext cannot say" — in place of a fact urtext
|
|
434
|
+
// could state exactly, under a verified badge. Both sides are said
|
|
435
|
+
// as blankness instead, in the same voice as the reading they
|
|
436
|
+
// replace. A detail genuinely absent still takes the fallback, which
|
|
437
|
+
// is the case that phrase was written for.
|
|
438
|
+
const isBlank = (v) => typeof v === "string" && v.trim() === "";
|
|
439
|
+
const read = isBlank(fact.detail.was)
|
|
440
|
+
? "was blank"
|
|
441
|
+
: `read \`${str(fact.detail.was, "something else")}\``;
|
|
442
|
+
const readsNow = isBlank(fact.detail.now)
|
|
443
|
+
? "It is now blank."
|
|
444
|
+
: `It now reads \`${str(fact.detail.now, "something else")}\`.`;
|
|
445
|
+
// On a drift `citedLine` is the line whose content differs, which is
|
|
446
|
+
// where the evidence points — not what the prose wrote. The citation
|
|
447
|
+
// as written is carried separately and is what these sentences name:
|
|
448
|
+
// it is the string a reader searches their own document for and the
|
|
449
|
+
// text they will edit, and a title naming the differing line alone
|
|
450
|
+
// would be a string they could not find. The two are read from
|
|
451
|
+
// separate fields and never combined, so no span the prose did not
|
|
452
|
+
// write can be composed here.
|
|
453
|
+
const wroteStart = num(fact.detail.writtenLine, start);
|
|
454
|
+
const wroteEnd = num(fact.detail.writtenEndLine, 0);
|
|
455
|
+
const wrote = wroteStart === 0 ? "" : wroteEnd === 0 ? `:${wroteStart}` : `:${wroteStart}-${wroteEnd}`;
|
|
456
|
+
// Which line inside the citation moved. Said only where the citation
|
|
457
|
+
// reaches more than one line: on a single-line citation it would
|
|
458
|
+
// restate the line just named.
|
|
459
|
+
const inside = wroteEnd !== 0 && wroteEnd !== wroteStart && start !== 0 ? `line ${start} of ` : "";
|
|
460
|
+
title = `cites \`${cited}${wrote}\`, which no longer reads the same`;
|
|
461
|
+
// Membership of the cited path in the changed set is proven, so the
|
|
462
|
+
// body states exactly that and nothing more. Attributing the
|
|
463
|
+
// movement to the reviewed change would be a causal claim under a
|
|
464
|
+
// verified badge that the evidence does not carry: the baseline is
|
|
465
|
+
// the commit that last wrote the CITING line, which can predate the
|
|
466
|
+
// reviewed range by any number of commits.
|
|
467
|
+
const touched = fact.detail.citedTouched === true ? ` This change touched \`${cited}\`.` : "";
|
|
468
|
+
body = `When this line was last written (${hash}), ${inside}\`${cited}${wrote}\` ${read}. ${readsNow} The citation still resolves to a line; it no longer resolves to the same content. urtext does not know whether the new line is what this sentence meant.${touched}`;
|
|
469
|
+
}
|
|
470
|
+
}
|
|
471
|
+
break;
|
|
472
|
+
}
|
|
473
|
+
}
|
|
474
|
+
return {
|
|
475
|
+
id: fact.id,
|
|
476
|
+
tier: tierFor(fact, undefined),
|
|
477
|
+
file: fact.file,
|
|
478
|
+
line: fact.line,
|
|
479
|
+
title,
|
|
480
|
+
body,
|
|
481
|
+
score: scoreFact(fact),
|
|
482
|
+
evidence: fact.evidence,
|
|
483
|
+
};
|
|
484
|
+
}
|
|
485
|
+
/**
|
|
486
|
+
* `rank` plus the map a model claim needs to find a fact that no longer has
|
|
487
|
+
* a finding of its own: `absorbedBy` maps a folded/grouped fact's id to the
|
|
488
|
+
* id of the finding that now speaks for it (see `foldReach`,
|
|
489
|
+
* `groupAddedExports`, and `groupSignatureChanges` — the three places facts
|
|
490
|
+
* disappear this way). Chained here because a fact can be absorbed twice in
|
|
491
|
+
* a row — a blast_radius fact folded into an `export_added` or
|
|
492
|
+
* `signature_changed` sibling whose own finding is then itself collapsed
|
|
493
|
+
* into its file's group — and only this function, which runs the fold and
|
|
494
|
+
* both grouping passes itself, sees every step. `reconcile.ts` is the only
|
|
495
|
+
* caller that needs this; everything else calls `rank`, the one-line
|
|
496
|
+
* delegate below, which just discards it.
|
|
497
|
+
*/
|
|
498
|
+
export function rankWithAbsorption(facts) {
|
|
499
|
+
const { facts: kept, reach, absorbedBy: radiusAbsorbedBy } = foldReach(facts, (fact) => WEIGHTS.factKind[fact.kind]);
|
|
500
|
+
const findings = kept.map((fact) => {
|
|
501
|
+
const finding = toFinding(fact);
|
|
502
|
+
// A blast_radius fact never looks up its own reach entry: that entry
|
|
503
|
+
// is *its own* reference count, so without this guard a lonely
|
|
504
|
+
// blast_radius fact (one with no sibling, kept above) amplifies and
|
|
505
|
+
// restates itself. `toFinding` already wrote the reference count into
|
|
506
|
+
// this finding's body; there is nothing here to add.
|
|
507
|
+
const r = fact.kind !== "blast_radius" && fact.qualifiedSymbol
|
|
508
|
+
? reach.get(reachKey(fact.file, fact.qualifiedSymbol))
|
|
509
|
+
: undefined;
|
|
510
|
+
if (!r)
|
|
511
|
+
return finding;
|
|
512
|
+
// Computed once and reused below, matching the pattern `groupAddedExports`
|
|
513
|
+
// (`../score/reach.ts`) and `toFinding`'s blast_radius branch (above in
|
|
514
|
+
// this file) both use: the subject is `places`, not the symbol, so the
|
|
515
|
+
// verb has to agree with it — "One place ... references"; "N places ...
|
|
516
|
+
// reference" — rather than being re-derived (or, as this site used to,
|
|
517
|
+
// fixed to the plural form regardless of count).
|
|
518
|
+
const places = r.references === 1 ? "One place" : `${r.references} places`;
|
|
519
|
+
const verb = r.references === 1 ? "references" : "reference";
|
|
520
|
+
return {
|
|
521
|
+
...finding,
|
|
522
|
+
reach: r,
|
|
523
|
+
// A bounded multiplier, at most 1.5x, applied on top of `scoreFact`'s
|
|
524
|
+
// own ceiling — not a separate score. That means an amplified finding
|
|
525
|
+
// *can* end up above an unamplified higher-weight one (a
|
|
526
|
+
// signature_changed finding reaching 300 references is multiplied by
|
|
527
|
+
// 1.5x its base score, which can put it above an unamplified
|
|
528
|
+
// guard_removed finding) — correctly: a contract change with 300 call
|
|
529
|
+
// sites can matter more than one removed guard.
|
|
530
|
+
// Any guard_removed with the same reach amplifies by the same
|
|
531
|
+
// factor, so comparable findings keep their relative order. What
|
|
532
|
+
// this never does is let a fact amplify *itself*: see the guard on
|
|
533
|
+
// `r`, above.
|
|
534
|
+
score: finding.score * (1 + Math.min(Math.log10(Math.max(r.references, 1)), 1) * 0.5),
|
|
535
|
+
body: `${finding.body} ${places} in this repository ${verb} it.`,
|
|
536
|
+
};
|
|
537
|
+
});
|
|
538
|
+
// Derived from the same facts the findings were built from, so the group
|
|
539
|
+
// listing's sentences and the ungrouped bodies come out of one composer
|
|
540
|
+
// (`describeSignatureChange`) and cannot drift apart. Keyed by fact id,
|
|
541
|
+
// which is also the finding id for an ungrouped fact.
|
|
542
|
+
const signatureDetails = new Map();
|
|
543
|
+
for (const fact of kept) {
|
|
544
|
+
if (fact.kind !== "signature_changed")
|
|
545
|
+
continue;
|
|
546
|
+
const d = describeSignatureChange(fact);
|
|
547
|
+
signatureDetails.set(fact.id, { name: d.name, sentence: d.sentence, typeUnresolved: d.typeUnresolved });
|
|
548
|
+
}
|
|
549
|
+
const { findings: signatureGrouped, absorbedBy: signatureAbsorbedBy } = groupSignatureChanges(findings, signatureDetails);
|
|
550
|
+
const { findings: grouped, absorbedBy: exportAbsorbedBy } = groupAddedExports(signatureGrouped);
|
|
551
|
+
// The two grouping passes touch disjoint id prefixes, so one merged map
|
|
552
|
+
// can answer for both when the chain below resolves a sibling.
|
|
553
|
+
const groupAbsorbedBy = new Map([...signatureAbsorbedBy, ...exportAbsorbedBy]);
|
|
554
|
+
// A blast_radius fact's sibling can itself have been grouped away, so its
|
|
555
|
+
// id no longer names a finding either — resolve through the grouping
|
|
556
|
+
// absorption maps too, falling back to the sibling's own id when it was
|
|
557
|
+
// not grouped (an ungrouped sibling's finding id is just its fact id).
|
|
558
|
+
const absorbedBy = new Map();
|
|
559
|
+
for (const [factId, siblingId] of radiusAbsorbedBy) {
|
|
560
|
+
absorbedBy.set(factId, groupAbsorbedBy.get(siblingId) ?? siblingId);
|
|
561
|
+
}
|
|
562
|
+
for (const [factId, groupId] of groupAbsorbedBy) {
|
|
563
|
+
absorbedBy.set(factId, groupId);
|
|
564
|
+
}
|
|
565
|
+
return {
|
|
566
|
+
findings: grouped.sort((a, b) => b.score - a.score || a.file.localeCompare(b.file) || a.line - b.line),
|
|
567
|
+
absorbedBy,
|
|
568
|
+
};
|
|
569
|
+
}
|
|
570
|
+
export function rank(facts) {
|
|
571
|
+
return rankWithAbsorption(facts).findings;
|
|
572
|
+
}
|