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,945 @@
|
|
|
1
|
+
import ts from "typescript";
|
|
2
|
+
import { git } from "../extract/git.js";
|
|
3
|
+
import { isTypeScriptFile } from "../extract/symbols.js";
|
|
4
|
+
import { REPORT_DIR, WORKTREE, } from "../types.js";
|
|
5
|
+
import { makeFact, MAX_EVIDENCE } from "./fact.js";
|
|
6
|
+
/**
|
|
7
|
+
* A repository-relative path followed by a line, or a line range. The path
|
|
8
|
+
* must contain at least one directory separator — see
|
|
9
|
+
* CITATION_GUARD_SEPARATOR in the design's false-positive guards for why a
|
|
10
|
+
* bare `Something.js:14` is not treated as a citation at all.
|
|
11
|
+
*
|
|
12
|
+
* Left to right: a lookbehind rejecting a preceding path character, so a
|
|
13
|
+
* match cannot start in the middle of a longer path; one or more `segment/`
|
|
14
|
+
* groups, which is what makes the separator mandatory; a final segment with
|
|
15
|
+
* a dot and an alphanumeric extension; a colon; a line number with no
|
|
16
|
+
* leading zero; optionally a hyphen and an end line; and a lookahead
|
|
17
|
+
* rejecting a trailing digit, letter, underscore, slash, or hyphen, so a
|
|
18
|
+
* citation is not matched inside a longer line number or a longer path.
|
|
19
|
+
* Capture groups: path, start line, end line or undefined.
|
|
20
|
+
*
|
|
21
|
+
* A trailing period is not rejected, so a citation ending a sentence is
|
|
22
|
+
* extracted like any other — see `test/analyze/citations.test.ts`,
|
|
23
|
+
* "extracts a citation a sentence's closing period touches, in both forms".
|
|
24
|
+
*/
|
|
25
|
+
export const LINE_CITATION = /(?<![A-Za-z0-9_@./-])((?:[A-Za-z0-9_@.-]+\/)+[A-Za-z0-9_@.-]+\.[A-Za-z][A-Za-z0-9]*):([1-9][0-9]*)(?:-([1-9][0-9]*))?(?![0-9A-Za-z_/-])/g;
|
|
26
|
+
/**
|
|
27
|
+
* A backticked repository-relative path, then a quoted phrase — urtext's own
|
|
28
|
+
* comment-contract form, and the most checkable citation form in the
|
|
29
|
+
* repository, because the quoted text either appears in the named file or it
|
|
30
|
+
* does not. The same mandatory-separator path, inside backticks; an optional
|
|
31
|
+
* comma, semicolon, or colon; a short run of whitespace; then a straight or
|
|
32
|
+
* left curly double quote, the phrase, and a straight or right curly close.
|
|
33
|
+
* Capture groups: path, phrase.
|
|
34
|
+
*/
|
|
35
|
+
export const QUOTED_CITATION = /`((?:[A-Za-z0-9_@.-]+\/)+[A-Za-z0-9_@.-]+\.[A-Za-z][A-Za-z0-9]*)`[,;:]?\s{0,3}["“]([^"”]+)["”]/g;
|
|
36
|
+
/**
|
|
37
|
+
* The most code points a quoted phrase, or a stored was/now line, carries.
|
|
38
|
+
* Longer than this is a block quotation rather than a pointer.
|
|
39
|
+
*/
|
|
40
|
+
export const MAX_QUOTE_CHARS = 240;
|
|
41
|
+
/** Appended to a was/now line the cap cut, so no line merely appears to end. */
|
|
42
|
+
export const CITATION_TRUNCATION_MARKER = "… [line truncated]";
|
|
43
|
+
/** Tracked files scanned as raw text, after masking. */
|
|
44
|
+
export const PROSE_EXTENSIONS = [".md", ".markdown", ".txt"];
|
|
45
|
+
/**
|
|
46
|
+
* The pathspecs the candidate-file queries in this file's scan half pass to
|
|
47
|
+
* git. Narrower than `isTypeScriptFile` accepts — it also takes the
|
|
48
|
+
* module-explicit extensions, which no pathspec here names — so a citation
|
|
49
|
+
* written in one of those files is not checked at all. An under-report, the
|
|
50
|
+
* direction every approximation in this feature leans.
|
|
51
|
+
*/
|
|
52
|
+
export const CITATION_PATHSPECS = ["*.md", "*.markdown", "*.txt", "*.ts", "*.tsx"];
|
|
53
|
+
/**
|
|
54
|
+
* Every whitespace run, newlines included, collapsed to a single space, then
|
|
55
|
+
* trimmed. Matching and comparison both run on normalized text, and the
|
|
56
|
+
* cited file is normalized identically before a containment test. Without
|
|
57
|
+
* this, every comment-contract citation in `src/` would fail: they wrap
|
|
58
|
+
* across continuation lines, so the phrase as written carries newlines and
|
|
59
|
+
* asterisks the cited file never had.
|
|
60
|
+
*/
|
|
61
|
+
export function normalizeText(text) {
|
|
62
|
+
return text.replace(/\s+/g, " ").trim();
|
|
63
|
+
}
|
|
64
|
+
/**
|
|
65
|
+
* Replaces every character of a span with a space, keeping newlines. Length
|
|
66
|
+
* is preserved exactly, which is the whole point: every offset computed
|
|
67
|
+
* after a mask still names the same character of the original text, so a
|
|
68
|
+
* masked fence cannot move a later citation's reported line. See
|
|
69
|
+
* `test/analyze/citations.test.ts`, "keeps every offset, so masking never
|
|
70
|
+
* moves a later citation's line".
|
|
71
|
+
*/
|
|
72
|
+
function blankSpan(text) {
|
|
73
|
+
return text.replace(/[^\n]/g, " ");
|
|
74
|
+
}
|
|
75
|
+
const FENCE_LINE = /^(?: {0,3}>[ \t]?)* {0,3}(`{3,}|~{3,})/;
|
|
76
|
+
/**
|
|
77
|
+
* CITATION_GUARD_FENCE. Spans from a line opening a fence through the next
|
|
78
|
+
* line closing that fence — same character, at least as long a run —
|
|
79
|
+
* inclusive, are blanked. A path and line inside a fence is sample output,
|
|
80
|
+
* and treating sample output as an assertion about the repository is the
|
|
81
|
+
* most common false positive available. An unclosed fence blanks to the end
|
|
82
|
+
* of the text: silence about a run of prose costs less than a finding built
|
|
83
|
+
* on a code block nobody closed. Indented blocks are deliberately not
|
|
84
|
+
* masked — they are indistinguishable from list continuations in this
|
|
85
|
+
* repository's prose, and the baseline gate already covers the illustrative
|
|
86
|
+
* ones. A blockquote prefix on the fence line is tolerated: a fence inside a
|
|
87
|
+
* quotation is still a fence, and the indent allowance alone cannot see past
|
|
88
|
+
* the marker. See `test/analyze/citations.test.ts`, "CITATION_GUARD_FENCE: a
|
|
89
|
+
* citation inside a fenced block is not one, and the same text outside it
|
|
90
|
+
* is" and "CITATION_GUARD_FENCE: a fenced block inside a blockquote is masked
|
|
91
|
+
* like any other".
|
|
92
|
+
*/
|
|
93
|
+
export function maskFences(text) {
|
|
94
|
+
const lines = text.split("\n");
|
|
95
|
+
let open;
|
|
96
|
+
for (let i = 0; i < lines.length; i++) {
|
|
97
|
+
const fence = FENCE_LINE.exec(lines[i]);
|
|
98
|
+
if (open === undefined) {
|
|
99
|
+
if (fence) {
|
|
100
|
+
open = fence[1];
|
|
101
|
+
lines[i] = blankSpan(lines[i]);
|
|
102
|
+
}
|
|
103
|
+
continue;
|
|
104
|
+
}
|
|
105
|
+
const closes = fence !== null && fence[1][0] === open[0] && fence[1].length >= open.length;
|
|
106
|
+
lines[i] = blankSpan(lines[i]);
|
|
107
|
+
if (closes)
|
|
108
|
+
open = undefined;
|
|
109
|
+
}
|
|
110
|
+
return lines.join("\n");
|
|
111
|
+
}
|
|
112
|
+
const URL_SPAN = /[A-Za-z][A-Za-z0-9+.-]*:\/\/\S+/g;
|
|
113
|
+
const LINK_DESTINATION = /\]\([^)]*\)/g;
|
|
114
|
+
/**
|
|
115
|
+
* CITATION_GUARD_URL. A link to another host is a link to another host, and
|
|
116
|
+
* its path-and-line tail says nothing about this repository. The regexes'
|
|
117
|
+
* lookbehind is a second line of defense, not a substitute: masking is what
|
|
118
|
+
* makes the intent explicit and testable.
|
|
119
|
+
*/
|
|
120
|
+
export function maskUrls(text) {
|
|
121
|
+
return text.replace(URL_SPAN, blankSpan).replace(LINK_DESTINATION, blankSpan);
|
|
122
|
+
}
|
|
123
|
+
export function isProseFile(path) {
|
|
124
|
+
return PROSE_EXTENSIONS.some((ext) => path.toLowerCase().endsWith(ext));
|
|
125
|
+
}
|
|
126
|
+
/** Absolute offset of the start of every line, for offset-to-line lookup. */
|
|
127
|
+
function lineStartsOf(source) {
|
|
128
|
+
const starts = [0];
|
|
129
|
+
for (let i = 0; i < source.length; i++) {
|
|
130
|
+
if (source[i] === "\n")
|
|
131
|
+
starts.push(i + 1);
|
|
132
|
+
}
|
|
133
|
+
return starts;
|
|
134
|
+
}
|
|
135
|
+
function lineOfOffset(starts, offset) {
|
|
136
|
+
let low = 0;
|
|
137
|
+
let high = starts.length - 1;
|
|
138
|
+
while (low < high) {
|
|
139
|
+
const mid = (low + high + 1) >> 1;
|
|
140
|
+
if (starts[mid] <= offset)
|
|
141
|
+
low = mid;
|
|
142
|
+
else
|
|
143
|
+
high = mid - 1;
|
|
144
|
+
}
|
|
145
|
+
return low + 1;
|
|
146
|
+
}
|
|
147
|
+
function textOfLine(source, starts, line) {
|
|
148
|
+
const start = starts[line - 1];
|
|
149
|
+
const end = starts[line] === undefined ? source.length : starts[line] - 1;
|
|
150
|
+
return source.slice(start, end).replace(/\r$/, "").trim();
|
|
151
|
+
}
|
|
152
|
+
/**
|
|
153
|
+
* Runs both forms over `haystack` and reports each hit against `source`,
|
|
154
|
+
* which is the file as written. `toSource` maps a haystack index back to an
|
|
155
|
+
* absolute source offset: it is the identity for prose, where masking
|
|
156
|
+
* preserves length, and the unwrap's offset map for comments, where it is
|
|
157
|
+
* not.
|
|
158
|
+
*
|
|
159
|
+
* The two post-match rules for Form B live here rather than in the pattern
|
|
160
|
+
* so their reasons stay readable. A parsed line number that is not a safe
|
|
161
|
+
* integer is discarded rather than checked, because `Number` would silently
|
|
162
|
+
* round a numeral too long to be a line into one that looks checkable.
|
|
163
|
+
*/
|
|
164
|
+
function matchCitations(haystack, toSource, source) {
|
|
165
|
+
const starts = lineStartsOf(source);
|
|
166
|
+
const out = [];
|
|
167
|
+
const push = (index, parts) => {
|
|
168
|
+
const citingLine = lineOfOffset(starts, toSource(index));
|
|
169
|
+
out.push({ ...parts, citingLine, citingText: textOfLine(source, starts, citingLine) });
|
|
170
|
+
};
|
|
171
|
+
for (const m of haystack.matchAll(LINE_CITATION)) {
|
|
172
|
+
const line = Number.parseInt(m[2], 10);
|
|
173
|
+
const endLine = m[3] === undefined ? undefined : Number.parseInt(m[3], 10);
|
|
174
|
+
if (!Number.isSafeInteger(line))
|
|
175
|
+
continue;
|
|
176
|
+
if (endLine !== undefined && !Number.isSafeInteger(endLine))
|
|
177
|
+
continue;
|
|
178
|
+
push(m.index, endLine === undefined
|
|
179
|
+
? { form: "line", path: m[1], line }
|
|
180
|
+
: { form: "line", path: m[1], line, endLine });
|
|
181
|
+
}
|
|
182
|
+
for (const m of haystack.matchAll(QUOTED_CITATION)) {
|
|
183
|
+
const quote = normalizeText(m[2]);
|
|
184
|
+
// The phrase must contain whitespace: a single quoted word is prose
|
|
185
|
+
// emphasis far more often than a citation, and one word is too weak a
|
|
186
|
+
// needle to conclude anything from. Counted with the spread for the
|
|
187
|
+
// reason `truncateSignature` in `./surface.ts` documents.
|
|
188
|
+
if (!/\s/.test(quote))
|
|
189
|
+
continue;
|
|
190
|
+
if ([...quote].length > MAX_QUOTE_CHARS)
|
|
191
|
+
continue;
|
|
192
|
+
push(m.index, { form: "quote", path: m[1], quote });
|
|
193
|
+
}
|
|
194
|
+
return out.sort((a, b) => a.citingLine - b.citingLine || a.path.localeCompare(b.path) || a.form.localeCompare(b.form));
|
|
195
|
+
}
|
|
196
|
+
export function citationsInProse(text) {
|
|
197
|
+
return matchCitations(maskUrls(maskFences(text)), (index) => index, text);
|
|
198
|
+
}
|
|
199
|
+
/**
|
|
200
|
+
* Every comment in a file, walked off the parsed AST's leaf tokens.
|
|
201
|
+
*
|
|
202
|
+
* This is a deliberate second copy of the walk `test/comment-contract.test.ts`
|
|
203
|
+
* documents at length, and the design names the duplication so a reviewer
|
|
204
|
+
* does not treat it as an oversight: a plan that wants one copy should hoist
|
|
205
|
+
* it into a shared module and update both call sites in the same change, not
|
|
206
|
+
* import test code into `src/`. The reasons the walk has this shape are
|
|
207
|
+
* recorded there in full; the short version is that a raw scanner loop
|
|
208
|
+
* desynchronizes on the first template interpolation, and this codebase's
|
|
209
|
+
* comments sit beside plenty of those, while a leading-only pass silently
|
|
210
|
+
* misses every comment on the same line as the token before it. Two
|
|
211
|
+
* different leaves can share a position — a zero-width node ends exactly
|
|
212
|
+
* where the next token starts — so hits are deduplicated by range start.
|
|
213
|
+
*/
|
|
214
|
+
function commentSpans(source, fileName) {
|
|
215
|
+
const sourceFile = ts.createSourceFile(fileName, source, ts.ScriptTarget.Latest, true);
|
|
216
|
+
const spans = [];
|
|
217
|
+
const seen = new Set();
|
|
218
|
+
const record = (ranges) => {
|
|
219
|
+
for (const range of ranges ?? []) {
|
|
220
|
+
if (seen.has(range.pos))
|
|
221
|
+
continue;
|
|
222
|
+
seen.add(range.pos);
|
|
223
|
+
spans.push({ start: range.pos, text: source.slice(range.pos, range.end) });
|
|
224
|
+
}
|
|
225
|
+
};
|
|
226
|
+
const visit = (node) => {
|
|
227
|
+
const children = node.getChildren(sourceFile);
|
|
228
|
+
if (children.length === 0) {
|
|
229
|
+
record(ts.getLeadingCommentRanges(source, node.getFullStart()));
|
|
230
|
+
record(ts.getTrailingCommentRanges(source, node.getEnd()));
|
|
231
|
+
return;
|
|
232
|
+
}
|
|
233
|
+
children.forEach(visit);
|
|
234
|
+
};
|
|
235
|
+
visit(sourceFile);
|
|
236
|
+
return spans.sort((a, b) => a.start - b.start);
|
|
237
|
+
}
|
|
238
|
+
/**
|
|
239
|
+
* Strips a comment's opening, closing, and continuation decorations and
|
|
240
|
+
* joins its lines with single spaces, carrying an offset map alongside.
|
|
241
|
+
* The citing line reported in evidence is derived from that map, so a
|
|
242
|
+
* quoted citation whose phrase wraps across several comment lines is
|
|
243
|
+
* reported at the line its path actually sits on — see
|
|
244
|
+
* `test/analyze/citations.test.ts`, "reports the line the path sits on for a
|
|
245
|
+
* quote that wrapped across comment lines". A naive "line of the comment's
|
|
246
|
+
* start" would misreport every wrapped citation in `src/`, which are most of
|
|
247
|
+
* them.
|
|
248
|
+
*/
|
|
249
|
+
function unwrapComment(span) {
|
|
250
|
+
const chars = [];
|
|
251
|
+
const offsets = [];
|
|
252
|
+
let lineStart = span.start;
|
|
253
|
+
span.text.split("\n").forEach((line, index) => {
|
|
254
|
+
let from = 0;
|
|
255
|
+
let to = line.length;
|
|
256
|
+
if (to > from && line[to - 1] === "\r")
|
|
257
|
+
to--;
|
|
258
|
+
while (from < to && (line[from] === " " || line[from] === "\t"))
|
|
259
|
+
from++;
|
|
260
|
+
// The close is stripped before the opens, so a line that is nothing but
|
|
261
|
+
// a closing decoration contributes no stray character.
|
|
262
|
+
if (to - from >= 2 && line.slice(to - 2, to) === "*/")
|
|
263
|
+
to -= 2;
|
|
264
|
+
if (line.startsWith("//", from))
|
|
265
|
+
from += 2;
|
|
266
|
+
else if (line.startsWith("/*", from)) {
|
|
267
|
+
from += 2;
|
|
268
|
+
if (line[from] === "*")
|
|
269
|
+
from++;
|
|
270
|
+
}
|
|
271
|
+
else if (line[from] === "*")
|
|
272
|
+
from++;
|
|
273
|
+
if (index > 0) {
|
|
274
|
+
chars.push(" ");
|
|
275
|
+
offsets.push(lineStart + Math.min(from, to));
|
|
276
|
+
}
|
|
277
|
+
for (let k = from; k < to; k++) {
|
|
278
|
+
chars.push(line[k]);
|
|
279
|
+
offsets.push(lineStart + k);
|
|
280
|
+
}
|
|
281
|
+
lineStart += line.length + 1;
|
|
282
|
+
});
|
|
283
|
+
return { text: chars.join(""), offsets };
|
|
284
|
+
}
|
|
285
|
+
/**
|
|
286
|
+
* Comments only. A path and line inside a string literal is usually a test
|
|
287
|
+
* fixture's expected output, and inside code it is not prose making a claim.
|
|
288
|
+
* Fences are not masked here — a fence is a prose construct — but URLs are,
|
|
289
|
+
* for the same reason they are in prose.
|
|
290
|
+
*/
|
|
291
|
+
export function citationsInComments(source, fileName) {
|
|
292
|
+
const out = [];
|
|
293
|
+
for (const span of commentSpans(source, fileName)) {
|
|
294
|
+
const { text, offsets } = unwrapComment(span);
|
|
295
|
+
out.push(...matchCitations(maskUrls(text), (index) => offsets[index] ?? span.start, source));
|
|
296
|
+
}
|
|
297
|
+
return out.sort((a, b) => a.citingLine - b.citingLine || a.path.localeCompare(b.path));
|
|
298
|
+
}
|
|
299
|
+
/** Every citation in one file, scanned the way that file's kind is scanned. */
|
|
300
|
+
export function citationsIn(path, text) {
|
|
301
|
+
if (isProseFile(path))
|
|
302
|
+
return citationsInProse(text);
|
|
303
|
+
if (isTypeScriptFile(path))
|
|
304
|
+
return citationsInComments(text, path);
|
|
305
|
+
return [];
|
|
306
|
+
}
|
|
307
|
+
/**
|
|
308
|
+
* The most citations one run checks, across both modes. Bounds a sweep of a
|
|
309
|
+
* repository whose prose cites code everywhere; the default mode is already
|
|
310
|
+
* bounded by the change and reaches this only on a very large diff. A cap
|
|
311
|
+
* that bites is disclosed — see `citationsCappedNote`.
|
|
312
|
+
*/
|
|
313
|
+
export const MAX_CITATIONS_CHECKED = 2000;
|
|
314
|
+
/**
|
|
315
|
+
* The most citing files one run opens. Bounds the blame calls, which are the
|
|
316
|
+
* expensive per-file work; the citation cap above bounds the per-citation
|
|
317
|
+
* work. Files are taken in path order so a capped run is deterministic.
|
|
318
|
+
*/
|
|
319
|
+
export const MAX_CITING_FILES = 320;
|
|
320
|
+
/**
|
|
321
|
+
* The most distinct (revision, cited file) pairs one run reads historically.
|
|
322
|
+
* A repository whose prose cites a hundred files from a hundred different
|
|
323
|
+
* baseline commits would otherwise pay a `git show` per pair with no bound
|
|
324
|
+
* at all; every citation past this is checked existence-only and said so.
|
|
325
|
+
*/
|
|
326
|
+
export const MAX_BASELINE_READS = 480;
|
|
327
|
+
/**
|
|
328
|
+
* The most basenames passed to one `git grep` invocation. Terms are chunked
|
|
329
|
+
* at this width and the results unioned, so unlike the caps above this one
|
|
330
|
+
* loses nothing and discloses nothing.
|
|
331
|
+
*/
|
|
332
|
+
export const MAX_GREP_TERMS = 96;
|
|
333
|
+
/**
|
|
334
|
+
* Pluralized inline in the style `review` in `../cli.ts` already uses, and
|
|
335
|
+
* phrased as reasons so they read alongside the existing warnings. They land
|
|
336
|
+
* in `warnings`, which becomes `ReportModel.notes`, which trips the "This
|
|
337
|
+
* review is partial." banner — correctly. A capped run genuinely did not
|
|
338
|
+
* check everything it was asked to.
|
|
339
|
+
*/
|
|
340
|
+
export function citingFilesCappedNote(scanned, found) {
|
|
341
|
+
const left = found - scanned;
|
|
342
|
+
// The leftover count carries its own noun rather than trailing a bare
|
|
343
|
+
// numeral. A leftover of exactly one is reachable — the cap bites the
|
|
344
|
+
// moment one more candidate exists than it scans — and a numeral with
|
|
345
|
+
// nothing to agree with is this sentence saying less than it means to.
|
|
346
|
+
//
|
|
347
|
+
// Where the scan stopped is named because the selection is a prefix, not a
|
|
348
|
+
// sample: `sweepCandidates` sorts by path and this cap takes the front of
|
|
349
|
+
// that list, so a bitten cap cuts mid-directory. Measured on a large
|
|
350
|
+
// repository, the earlier wording reported a fraction that read as though
|
|
351
|
+
// it were spread across the tree, while the run had in fact covered nearly
|
|
352
|
+
// every file under one leading directory and a small minority of the
|
|
353
|
+
// source. A true count that leaves a false impression costs a reader
|
|
354
|
+
// exactly what a false count would, so the sentence says which files it
|
|
355
|
+
// means. See `test/analyze/citations-rot.test.ts`, "discloses a bitten
|
|
356
|
+
// citing-file cap with counts that add up, over the prefix of the path
|
|
357
|
+
// order it really scanned".
|
|
358
|
+
return `citation checking scanned the first ${scanned} of ${found} candidate files in path order, so citations in the other ${left} file${left === 1 ? "" : "s"} were not checked; the scan stops at that point in the path order rather than spreading across this repository`;
|
|
359
|
+
}
|
|
360
|
+
export function citationsCappedNote(checked, found) {
|
|
361
|
+
const left = found - checked;
|
|
362
|
+
// The same prefix the file cap takes, one level down: `pending` is built
|
|
363
|
+
// over the scanned files in path order and, inside each file, in citing-line
|
|
364
|
+
// order, and this cap keeps the front of that list. A bitten cap therefore
|
|
365
|
+
// stops partway along the path order — and may stop partway through a single
|
|
366
|
+
// file — rather than sampling the repository. Said out loud for the reason
|
|
367
|
+
// `citingFilesCappedNote` above says it, and because two cap notes that
|
|
368
|
+
// answer the same question differently are worse than either alone: a reader
|
|
369
|
+
// who learns what the first sentence means reads the second as meaning it
|
|
370
|
+
// too. See `test/analyze/citations-rot.test.ts`, "names the prefix the
|
|
371
|
+
// citation cap took, and enumerates citations in exactly that order".
|
|
372
|
+
return `citation checking stopped after the first ${checked} of ${found} citations in path order, so ${left} further citation${left === 1 ? "" : "s"} in this repository ${left === 1 ? "was" : "were"} not checked; the check stops at that point in the path order rather than spreading across this repository`;
|
|
373
|
+
}
|
|
374
|
+
/**
|
|
375
|
+
* No order clause, unlike the two caps above, and the difference is in what
|
|
376
|
+
* this sentence claims rather than in how its budget is spent. The budget is
|
|
377
|
+
* spent front to back like theirs — a citation is refused a historical read
|
|
378
|
+
* only once the distinct-pair allowance is gone, so the ones that degrade are
|
|
379
|
+
* the later ones in the same path order, minus any whose baseline pair had
|
|
380
|
+
* already been read. But this note states no fraction and no share of the
|
|
381
|
+
* repository: every citation it counts was checked, and the sentence says
|
|
382
|
+
* exactly how far that check went. There is no coverage claim here for an
|
|
383
|
+
* order to qualify, so the clause the caps carry would attach to a sentence
|
|
384
|
+
* that never said it had covered anything.
|
|
385
|
+
*/
|
|
386
|
+
export function baselineReadsCappedNote(unchecked) {
|
|
387
|
+
return `citation checking stopped reading historical file contents, so ${unchecked} citation${unchecked === 1 ? "" : "s"} ${unchecked === 1 ? "was" : "were"} checked only for whether the cited file exists`;
|
|
388
|
+
}
|
|
389
|
+
/**
|
|
390
|
+
* Copy for a shallow repository, where blame answers but cannot be believed.
|
|
391
|
+
* Phrased as a skip rather than a partial check, because that is what it is.
|
|
392
|
+
*/
|
|
393
|
+
export function shallowRepositoryNote() {
|
|
394
|
+
return "citation checking was skipped: this repository is a shallow clone, so the commit that last wrote each citing line cannot be known";
|
|
395
|
+
}
|
|
396
|
+
/** Copy for citations whose history could not be read. */
|
|
397
|
+
export function blameUnavailableNote(count, reason) {
|
|
398
|
+
return `${count} citation${count === 1 ? "" : "s"} could not be dated (git blame failed: ${reason}), so ${count === 1 ? "it was" : "they were"} checked only for whether the cited file exists`;
|
|
399
|
+
}
|
|
400
|
+
/**
|
|
401
|
+
* A blame line's porcelain header: the commit, the line in the original
|
|
402
|
+
* file, the line in the final file, and — only on the first line of a run —
|
|
403
|
+
* how many lines the run covers. The parse keeps one thing, a map from final
|
|
404
|
+
* line to commit; every other header and the tab-prefixed content are
|
|
405
|
+
* skipped. See `test/analyze/citations-rot.test.ts`, "keeps one commit per
|
|
406
|
+
* final line from --line-porcelain output".
|
|
407
|
+
*/
|
|
408
|
+
const BLAME_HEADER = /^([0-9a-f]{40}) \d+ (\d+)(?: \d+)?$/;
|
|
409
|
+
export function parseBlame(out) {
|
|
410
|
+
const map = new Map();
|
|
411
|
+
for (const line of out.split("\n")) {
|
|
412
|
+
const m = BLAME_HEADER.exec(line);
|
|
413
|
+
if (m)
|
|
414
|
+
map.set(Number.parseInt(m[2], 10), m[1]);
|
|
415
|
+
}
|
|
416
|
+
return map;
|
|
417
|
+
}
|
|
418
|
+
/** An uncommitted line blames to the all-zeros commit. */
|
|
419
|
+
const UNCOMMITTED = /^0+$/;
|
|
420
|
+
/**
|
|
421
|
+
* `git grep` exits one when nothing matches. That rejection is an absence,
|
|
422
|
+
* not a failure — it is read as "no candidate files", exactly as `readAt`
|
|
423
|
+
* reads git's absence wording as null — and it is told apart from a real
|
|
424
|
+
* failure by the pair of things git guarantees for it: the exit status, and
|
|
425
|
+
* an empty stderr. Anything else travels the degradation path.
|
|
426
|
+
*/
|
|
427
|
+
function isNoMatch(err) {
|
|
428
|
+
if (!(err instanceof Error))
|
|
429
|
+
return false;
|
|
430
|
+
const code = err.code;
|
|
431
|
+
const stderr = err.stderr;
|
|
432
|
+
return code === 1 && typeof stderr === "string" && stderr.trim() === "";
|
|
433
|
+
}
|
|
434
|
+
/**
|
|
435
|
+
* The reason git printed, not the command that failed. `git()` rejects with
|
|
436
|
+
* execFile's error, whose `message` is a command dump — the whole argument
|
|
437
|
+
* list, revision hashes and all — and whose `stderr` carries what git
|
|
438
|
+
* actually said. This reason is composed into a user-facing partial-review
|
|
439
|
+
* note, so the stderr line wins; the message is the fallback for a rejection
|
|
440
|
+
* that carries no stderr at all.
|
|
441
|
+
*/
|
|
442
|
+
function reasonOf(err) {
|
|
443
|
+
const stderr = err instanceof Error ? err.stderr : undefined;
|
|
444
|
+
const text = typeof stderr === "string" && stderr.trim() !== ""
|
|
445
|
+
? stderr
|
|
446
|
+
: err instanceof Error
|
|
447
|
+
? err.message
|
|
448
|
+
: String(err);
|
|
449
|
+
return text.trim().split("\n")[0].trim();
|
|
450
|
+
}
|
|
451
|
+
/**
|
|
452
|
+
* A revision and a path as one memo key. Joined by a unit separator rather
|
|
453
|
+
* than concatenated, so no pair can spell the same key as a different pair.
|
|
454
|
+
*/
|
|
455
|
+
function revPathKey(rev, path) {
|
|
456
|
+
return `${rev}\u001F${path}`;
|
|
457
|
+
}
|
|
458
|
+
function linesOf(text) {
|
|
459
|
+
const lines = text.split("\n");
|
|
460
|
+
// A file ending in a newline splits to a trailing empty element that is
|
|
461
|
+
// not a line of the file; counting it would put every last-line citation
|
|
462
|
+
// one short of the end.
|
|
463
|
+
if (lines.length > 0 && lines[lines.length - 1] === "")
|
|
464
|
+
lines.pop();
|
|
465
|
+
return lines.map((line) => line.replace(/\r$/, ""));
|
|
466
|
+
}
|
|
467
|
+
/**
|
|
468
|
+
* Both sides of a was/now pair are truncated for storage and rendering, with
|
|
469
|
+
* CITATION_TRUNCATION_MARKER appended when the cut runs, so no line merely
|
|
470
|
+
* appears to end. Counted by code point for the reason `truncateSignature`
|
|
471
|
+
* in `./surface.ts` documents.
|
|
472
|
+
*/
|
|
473
|
+
function truncateLine(text) {
|
|
474
|
+
const points = [...text];
|
|
475
|
+
if (points.length <= MAX_QUOTE_CHARS)
|
|
476
|
+
return text;
|
|
477
|
+
return points.slice(0, MAX_QUOTE_CHARS).join("") + CITATION_TRUNCATION_MARKER;
|
|
478
|
+
}
|
|
479
|
+
function normalizePath(path) {
|
|
480
|
+
const parts = [];
|
|
481
|
+
for (const part of path.split("/")) {
|
|
482
|
+
if (part === "" || part === ".")
|
|
483
|
+
continue;
|
|
484
|
+
if (part === "..")
|
|
485
|
+
parts.pop();
|
|
486
|
+
else
|
|
487
|
+
parts.push(part);
|
|
488
|
+
}
|
|
489
|
+
return parts.join("/");
|
|
490
|
+
}
|
|
491
|
+
function dirOf(path) {
|
|
492
|
+
const slash = path.lastIndexOf("/");
|
|
493
|
+
return slash < 0 ? "" : path.slice(0, slash);
|
|
494
|
+
}
|
|
495
|
+
export async function findCitationRot(changeset, ctx, options = {}) {
|
|
496
|
+
const now = changeset.range.to;
|
|
497
|
+
const cwd = ctx.cwd;
|
|
498
|
+
const note = options.onNote;
|
|
499
|
+
// A shallow clone is the one repository shape where blame is worse than
|
|
500
|
+
// unavailable: `--root` is exactly what suppresses git's boundary marker,
|
|
501
|
+
// so a line older than the graft is attributed to the graft commit rather
|
|
502
|
+
// than reported as unknown. At depth one that makes the baseline and the
|
|
503
|
+
// reviewed revision the same commit, every gate compares a thing to
|
|
504
|
+
// itself, and citation checking becomes a silent no-op; deeper, a finding
|
|
505
|
+
// states a boundary commit as "when this line was last written", which is
|
|
506
|
+
// a historical claim the repository does not carry. A disclosed skip is
|
|
507
|
+
// the only honest answer, and it is the whole check rather than a
|
|
508
|
+
// degradation of it.
|
|
509
|
+
if ((await git(["rev-parse", "--is-shallow-repository"], cwd)).trim() === "true") {
|
|
510
|
+
note?.(shallowRepositoryNote());
|
|
511
|
+
return [];
|
|
512
|
+
}
|
|
513
|
+
// Every path the range touched, both sides, so a rename is covered on
|
|
514
|
+
// both. This is the default mode's whole filter, and it is what bounds
|
|
515
|
+
// cost by the change rather than by the repository.
|
|
516
|
+
const touched = new Set();
|
|
517
|
+
for (const file of changeset.files) {
|
|
518
|
+
touched.add(file.path);
|
|
519
|
+
if (file.previousPath)
|
|
520
|
+
touched.add(file.previousPath);
|
|
521
|
+
}
|
|
522
|
+
// One read per revision-and-path pair. `readAt` is a `git show` per call
|
|
523
|
+
// and is not memoized upstream; memoizing here is what turns many
|
|
524
|
+
// citations into one file at one baseline commit into one read.
|
|
525
|
+
const reads = new Map();
|
|
526
|
+
const historical = new Set();
|
|
527
|
+
let baselineReadsRefused = 0;
|
|
528
|
+
const readAt = (rev, path) => {
|
|
529
|
+
const key = revPathKey(rev, path);
|
|
530
|
+
let read = reads.get(key);
|
|
531
|
+
if (!read) {
|
|
532
|
+
read = ctx.readAt(rev, path);
|
|
533
|
+
read.catch(() => reads.delete(key));
|
|
534
|
+
reads.set(key, read);
|
|
535
|
+
}
|
|
536
|
+
return read;
|
|
537
|
+
};
|
|
538
|
+
/** A historical read, refused once the distinct-pair budget is spent. */
|
|
539
|
+
const readHistorical = (rev, path) => {
|
|
540
|
+
const key = revPathKey(rev, path);
|
|
541
|
+
if (!historical.has(key)) {
|
|
542
|
+
if (historical.size >= MAX_BASELINE_READS)
|
|
543
|
+
return undefined;
|
|
544
|
+
historical.add(key);
|
|
545
|
+
}
|
|
546
|
+
return readAt(rev, path);
|
|
547
|
+
};
|
|
548
|
+
// One blame per citing file, never per citation. A file with forty
|
|
549
|
+
// citations costs one blame. A resolved null is a blame that failed, and
|
|
550
|
+
// its reason travels beside it.
|
|
551
|
+
const blames = new Map();
|
|
552
|
+
let blameReason;
|
|
553
|
+
const blameOf = (path) => {
|
|
554
|
+
let blame = blames.get(path);
|
|
555
|
+
if (!blame) {
|
|
556
|
+
const args = ["blame", "--line-porcelain", "--root"];
|
|
557
|
+
if (now !== WORKTREE)
|
|
558
|
+
args.push(now);
|
|
559
|
+
blame = git([...args, "--", path], cwd)
|
|
560
|
+
.then(parseBlame)
|
|
561
|
+
.catch((err) => {
|
|
562
|
+
blameReason ??= reasonOf(err);
|
|
563
|
+
return null;
|
|
564
|
+
});
|
|
565
|
+
blames.set(path, blame);
|
|
566
|
+
}
|
|
567
|
+
return blame;
|
|
568
|
+
};
|
|
569
|
+
const candidates = options.sweep
|
|
570
|
+
? await sweepCandidates(cwd)
|
|
571
|
+
: await touchedCandidates(cwd, now, touched);
|
|
572
|
+
if (candidates.length === 0)
|
|
573
|
+
return [];
|
|
574
|
+
const scanned = candidates.slice(0, MAX_CITING_FILES);
|
|
575
|
+
if (scanned.length < candidates.length) {
|
|
576
|
+
note?.(citingFilesCappedNote(scanned.length, candidates.length));
|
|
577
|
+
}
|
|
578
|
+
// Extraction first, across every scanned file, because the citation cap's
|
|
579
|
+
// sentence states how many were found as well as how many were checked —
|
|
580
|
+
// and extraction costs no git at all.
|
|
581
|
+
const pending = [];
|
|
582
|
+
for (const file of scanned) {
|
|
583
|
+
const text = await readAt(now, file);
|
|
584
|
+
if (text === null)
|
|
585
|
+
continue;
|
|
586
|
+
for (const citation of citationsIn(file, text))
|
|
587
|
+
pending.push({ file, citation });
|
|
588
|
+
}
|
|
589
|
+
const checking = pending.slice(0, MAX_CITATIONS_CHECKED);
|
|
590
|
+
if (checking.length < pending.length) {
|
|
591
|
+
note?.(citationsCappedNote(checking.length, pending.length));
|
|
592
|
+
}
|
|
593
|
+
const rots = [];
|
|
594
|
+
let undated = 0;
|
|
595
|
+
for (const { file, citation } of checking) {
|
|
596
|
+
const blame = await blameOf(file);
|
|
597
|
+
const baseline = blame?.get(citation.citingLine);
|
|
598
|
+
// An uncommitted citing line is as new as the change under review:
|
|
599
|
+
// there is no earlier state to compare against, all four tests are
|
|
600
|
+
// skipped, and nothing is disclosed, because nothing was lost.
|
|
601
|
+
if (blame !== null && (baseline === undefined || UNCOMMITTED.test(baseline)))
|
|
602
|
+
continue;
|
|
603
|
+
if (blame === null)
|
|
604
|
+
undated++;
|
|
605
|
+
const rot = await checkCitation({
|
|
606
|
+
file,
|
|
607
|
+
citation,
|
|
608
|
+
baseline: blame === null ? undefined : baseline,
|
|
609
|
+
now,
|
|
610
|
+
touched,
|
|
611
|
+
sweep: options.sweep === true,
|
|
612
|
+
readAt,
|
|
613
|
+
readHistorical,
|
|
614
|
+
onRefusedBaseline: () => {
|
|
615
|
+
baselineReadsRefused++;
|
|
616
|
+
},
|
|
617
|
+
});
|
|
618
|
+
if (rot)
|
|
619
|
+
rots.push(rot);
|
|
620
|
+
}
|
|
621
|
+
if (undated > 0)
|
|
622
|
+
note?.(blameUnavailableNote(undated, blameReason ?? "unknown reason"));
|
|
623
|
+
if (baselineReadsRefused > 0)
|
|
624
|
+
note?.(baselineReadsCappedNote(baselineReadsRefused));
|
|
625
|
+
return rots;
|
|
626
|
+
}
|
|
627
|
+
/**
|
|
628
|
+
* Sweep candidates: every tracked file the pathspecs admit, minus
|
|
629
|
+
* REPORT_DIR. urtext's own reports quote source lines by path and line by
|
|
630
|
+
* construction; scanning them would make every review generate citations
|
|
631
|
+
* that the next review reports as rotted. Sorted, so a capped run takes the
|
|
632
|
+
* same files twice.
|
|
633
|
+
*
|
|
634
|
+
* `ls-files` lists the index rather than a revision, which is safe because
|
|
635
|
+
* every candidate is then read through `readAt` at the reviewed revision: a
|
|
636
|
+
* file absent there yields null and is skipped.
|
|
637
|
+
*/
|
|
638
|
+
async function sweepCandidates(cwd) {
|
|
639
|
+
const out = await git(["ls-files", "-z", "--", ...CITATION_PATHSPECS], cwd);
|
|
640
|
+
return out
|
|
641
|
+
.split("\0")
|
|
642
|
+
.filter((path) => path !== "" && !path.startsWith(`${REPORT_DIR}/`))
|
|
643
|
+
.sort();
|
|
644
|
+
}
|
|
645
|
+
/**
|
|
646
|
+
* Default-mode candidates: files git can prove mention one of the changed
|
|
647
|
+
* files' basenames. Basenames rather than full paths, because prose cites a
|
|
648
|
+
* file by many spellings and the resolution rules decide what a path means;
|
|
649
|
+
* the full-path filter is applied after extraction, on resolved paths, where
|
|
650
|
+
* it is exact. Terms are chunked at MAX_GREP_TERMS per invocation and the
|
|
651
|
+
* results unioned — chunking loses nothing, so unlike the caps it needs no
|
|
652
|
+
* disclosure.
|
|
653
|
+
*/
|
|
654
|
+
async function touchedCandidates(cwd, now, touched) {
|
|
655
|
+
const basenames = [...new Set([...touched].map((path) => path.split("/").pop() ?? path))].sort();
|
|
656
|
+
if (basenames.length === 0)
|
|
657
|
+
return [];
|
|
658
|
+
const found = new Set();
|
|
659
|
+
for (let i = 0; i < basenames.length; i += MAX_GREP_TERMS) {
|
|
660
|
+
const terms = basenames.slice(i, i + MAX_GREP_TERMS).flatMap((name) => ["-e", name]);
|
|
661
|
+
const args = ["grep", "-I", "-l", "-F", ...terms];
|
|
662
|
+
if (now !== WORKTREE)
|
|
663
|
+
args.push(now);
|
|
664
|
+
let out;
|
|
665
|
+
try {
|
|
666
|
+
out = await git([...args, "--", ...CITATION_PATHSPECS], cwd);
|
|
667
|
+
}
|
|
668
|
+
catch (err) {
|
|
669
|
+
if (isNoMatch(err))
|
|
670
|
+
continue;
|
|
671
|
+
throw err;
|
|
672
|
+
}
|
|
673
|
+
for (const line of out.split("\n")) {
|
|
674
|
+
// With a revision argument git prefixes each path with `<rev>:`.
|
|
675
|
+
const path = now === WORKTREE ? line : line.slice(line.indexOf(":") + 1);
|
|
676
|
+
if (path !== "" && !path.startsWith(`${REPORT_DIR}/`))
|
|
677
|
+
found.add(path);
|
|
678
|
+
}
|
|
679
|
+
}
|
|
680
|
+
return [...found].sort();
|
|
681
|
+
}
|
|
682
|
+
/**
|
|
683
|
+
* The four tests, in order, first one wins. A missing file has no lines to
|
|
684
|
+
* be out of range and no content to have drifted, so emitting more than one
|
|
685
|
+
* fact for one citation would be one finding said four ways.
|
|
686
|
+
*
|
|
687
|
+
* Which tests a citation is eligible for follows from its form and no other
|
|
688
|
+
* rule: missing_file applies to both forms; line_out_of_range and
|
|
689
|
+
* content_drift to the path-and-line form only, since they need a line
|
|
690
|
+
* number; quote_absent to the quoted form only, since it needs a phrase. A
|
|
691
|
+
* form is never checked by a test it has no input for.
|
|
692
|
+
*
|
|
693
|
+
* With no baseline — blame failed, or the historical-read budget is spent —
|
|
694
|
+
* only the first test runs, ungated, against the reviewed revision. That is
|
|
695
|
+
* the one place a false positive is reachable, and it is disclosed in the
|
|
696
|
+
* same breath by `blameUnavailableNote` or `baselineReadsCappedNote`.
|
|
697
|
+
*/
|
|
698
|
+
async function checkCitation(args) {
|
|
699
|
+
const { file, citation, baseline, now, touched, sweep, readAt, readHistorical } = args;
|
|
700
|
+
const isProse = isProseFile(file);
|
|
701
|
+
// Repository-root-relative first, which is how every path in this codebase
|
|
702
|
+
// is spelled and how `git show <rev>:<path>` resolves; then relative to
|
|
703
|
+
// the citing file's own directory, for prose only. A comment in `src/x.ts`
|
|
704
|
+
// that names `report/model.ts` means the repository path, and resolving it
|
|
705
|
+
// against `src/` would invent a file.
|
|
706
|
+
const spellings = [normalizePath(citation.path)];
|
|
707
|
+
if (isProse && dirOf(file) !== "") {
|
|
708
|
+
spellings.push(normalizePath(`${dirOf(file)}/${citation.path}`));
|
|
709
|
+
}
|
|
710
|
+
const optional = {
|
|
711
|
+
...(citation.line === undefined ? {} : { citedLine: citation.line }),
|
|
712
|
+
...(citation.endLine === undefined ? {} : { citedEndLine: citation.endLine }),
|
|
713
|
+
...(citation.quote === undefined ? {} : { quote: citation.quote }),
|
|
714
|
+
};
|
|
715
|
+
let citedFile;
|
|
716
|
+
let baselineText = null;
|
|
717
|
+
let refused = false;
|
|
718
|
+
if (baseline !== undefined) {
|
|
719
|
+
for (const spelling of spellings) {
|
|
720
|
+
const read = readHistorical(baseline, spelling);
|
|
721
|
+
if (read === undefined) {
|
|
722
|
+
refused = true;
|
|
723
|
+
break;
|
|
724
|
+
}
|
|
725
|
+
const text = await read;
|
|
726
|
+
if (text !== null) {
|
|
727
|
+
citedFile = spelling;
|
|
728
|
+
baselineText = text;
|
|
729
|
+
break;
|
|
730
|
+
}
|
|
731
|
+
}
|
|
732
|
+
}
|
|
733
|
+
if (baseline === undefined || refused) {
|
|
734
|
+
if (refused)
|
|
735
|
+
args.onRefusedBaseline();
|
|
736
|
+
// Existence-only: resolve against the reviewed revision, since there is
|
|
737
|
+
// no baseline to resolve against, and carry no commit, since none was
|
|
738
|
+
// read. The path reported is the root-relative spelling, which is the
|
|
739
|
+
// one a reader will look for.
|
|
740
|
+
for (const spelling of spellings) {
|
|
741
|
+
if ((await readAt(now, spelling)) !== null)
|
|
742
|
+
return undefined;
|
|
743
|
+
}
|
|
744
|
+
const absent = spellings[0];
|
|
745
|
+
const absentTouched = touched.has(absent);
|
|
746
|
+
// The default mode's filter still binds when history does not: a
|
|
747
|
+
// shallow clone is the common case for it, and a review that named
|
|
748
|
+
// files the change never touched would be answering a question nobody
|
|
749
|
+
// asked, under a verified badge.
|
|
750
|
+
if (!sweep && !absentTouched)
|
|
751
|
+
return undefined;
|
|
752
|
+
return {
|
|
753
|
+
rot: "missing_file",
|
|
754
|
+
citingFile: file,
|
|
755
|
+
citingLine: citation.citingLine,
|
|
756
|
+
citingText: citation.citingText,
|
|
757
|
+
citedFile: absent,
|
|
758
|
+
...optional,
|
|
759
|
+
citedTouched: absentTouched,
|
|
760
|
+
};
|
|
761
|
+
}
|
|
762
|
+
// Resolved neither way at the baseline: the citation never resolved, and
|
|
763
|
+
// urtext cannot tell a typo from an illustration from a plan for a file
|
|
764
|
+
// that does not exist yet. It says nothing.
|
|
765
|
+
if (citedFile === undefined || baselineText === null)
|
|
766
|
+
return undefined;
|
|
767
|
+
// The default mode's exact filter, applied on the resolved path.
|
|
768
|
+
const citedTouched = touched.has(citedFile);
|
|
769
|
+
if (!sweep && !citedTouched)
|
|
770
|
+
return undefined;
|
|
771
|
+
const common = {
|
|
772
|
+
citingFile: file,
|
|
773
|
+
citingLine: citation.citingLine,
|
|
774
|
+
citingText: citation.citingText,
|
|
775
|
+
citedFile,
|
|
776
|
+
baseline,
|
|
777
|
+
citedTouched,
|
|
778
|
+
};
|
|
779
|
+
const nowText = await readAt(now, citedFile);
|
|
780
|
+
if (nowText === null) {
|
|
781
|
+
return { rot: "missing_file", ...common, ...optional };
|
|
782
|
+
}
|
|
783
|
+
const baseLines = linesOf(baselineText);
|
|
784
|
+
const nowLines = linesOf(nowText);
|
|
785
|
+
if (citation.line !== undefined) {
|
|
786
|
+
const last = citation.endLine ?? citation.line;
|
|
787
|
+
// Gate: a citation to a line the file never had is a typo, not rot.
|
|
788
|
+
if (citation.line > baseLines.length || last > baseLines.length)
|
|
789
|
+
return undefined;
|
|
790
|
+
if (citation.line > nowLines.length || last > nowLines.length) {
|
|
791
|
+
return {
|
|
792
|
+
rot: "line_out_of_range",
|
|
793
|
+
...common,
|
|
794
|
+
citedLine: citation.line,
|
|
795
|
+
...(citation.endLine === undefined ? {} : { citedEndLine: citation.endLine }),
|
|
796
|
+
lineCount: nowLines.length,
|
|
797
|
+
};
|
|
798
|
+
}
|
|
799
|
+
}
|
|
800
|
+
if (citation.quote !== undefined) {
|
|
801
|
+
// Containment, not equality, and normalization on both sides, so
|
|
802
|
+
// re-wrapping a source comment does not fire this and a genuine
|
|
803
|
+
// rewording does.
|
|
804
|
+
if (!normalizeText(baselineText).includes(citation.quote))
|
|
805
|
+
return undefined;
|
|
806
|
+
if (!normalizeText(nowText).includes(citation.quote)) {
|
|
807
|
+
return { rot: "quote_absent", ...common, quote: citation.quote };
|
|
808
|
+
}
|
|
809
|
+
return undefined;
|
|
810
|
+
}
|
|
811
|
+
if (citation.line !== undefined) {
|
|
812
|
+
const last = citation.endLine ?? citation.line;
|
|
813
|
+
for (let n = citation.line; n <= last; n++) {
|
|
814
|
+
// Compared with leading and trailing whitespace stripped per line: a
|
|
815
|
+
// pure re-indent moves no content, and reporting it would be noise a
|
|
816
|
+
// reader cannot act on. For a range, the first differing line is the
|
|
817
|
+
// one reported.
|
|
818
|
+
const was = baseLines[n - 1] ?? "";
|
|
819
|
+
const current = nowLines[n - 1] ?? "";
|
|
820
|
+
if (was.trim() === current.trim())
|
|
821
|
+
continue;
|
|
822
|
+
return {
|
|
823
|
+
rot: "content_drift",
|
|
824
|
+
...common,
|
|
825
|
+
// The line that differs, not the range's first line. `was`, `now`,
|
|
826
|
+
// and `citedText` are all this line's text, and a fact whose line
|
|
827
|
+
// number and quoted text disagreed would send a reader to a line
|
|
828
|
+
// that reads nothing like the one the finding shows it. See
|
|
829
|
+
// `citedEndLine` for why no range is carried alongside.
|
|
830
|
+
citedLine: n,
|
|
831
|
+
// And the citation as the prose wrote it, whole and unmixed, so the
|
|
832
|
+
// finding can name the string the reader will search for without
|
|
833
|
+
// that string ever being assembled out of two different lines.
|
|
834
|
+
writtenLine: citation.line,
|
|
835
|
+
...(citation.endLine === undefined ? {} : { writtenEndLine: citation.endLine }),
|
|
836
|
+
was: truncateLine(was.trim()),
|
|
837
|
+
now: truncateLine(current.trim()),
|
|
838
|
+
// The cited line as it currently stands, taken from text already in
|
|
839
|
+
// hand so the analyzer composing `evidence[1]` never reads it again.
|
|
840
|
+
citedText: current.trim(),
|
|
841
|
+
};
|
|
842
|
+
}
|
|
843
|
+
}
|
|
844
|
+
return undefined;
|
|
845
|
+
}
|
|
846
|
+
/**
|
|
847
|
+
* Leading characters of a commit object name kept for display. The finding
|
|
848
|
+
* body names this commit to a reader, and a full object name there is noise
|
|
849
|
+
* beside a sentence; git's own short form is the length a reader recognizes.
|
|
850
|
+
*/
|
|
851
|
+
const ABBREVIATED_HASH = 7;
|
|
852
|
+
function abbreviate(hash) {
|
|
853
|
+
return hash.slice(0, ABBREVIATED_HASH);
|
|
854
|
+
}
|
|
855
|
+
/**
|
|
856
|
+
* Returns its analyzer under a name it states outright, and that is
|
|
857
|
+
* load-bearing: `runAnalyzers` reports a failed analyzer by
|
|
858
|
+
* `analyzers[i].name` in a warning a user reads, and an arrow returned
|
|
859
|
+
* directly from a factory has no name at all, so a citation analyzer that
|
|
860
|
+
* threw would be disclosed as a numbered anonymous one.
|
|
861
|
+
*
|
|
862
|
+
* The other analyzers get their names for free, from NamedEvaluation of the
|
|
863
|
+
* variable declaration they are assigned to. That mechanism is not enough
|
|
864
|
+
* here. This binding sits one scope in and shadows the module-level
|
|
865
|
+
* singleton below, and a transform that renames shadowed symbols to keep
|
|
866
|
+
* every binding unique — esbuild, which is what runs this repository's tests
|
|
867
|
+
* — rewrites the binding, and the inferred name goes with it, turning the
|
|
868
|
+
* disclosed name into a near-miss of itself. So the name is written down
|
|
869
|
+
* rather than derived. See `test/analyze/citations-rot.test.ts`, "names
|
|
870
|
+
* itself when it throws, so the disclosure never says analyzer #N".
|
|
871
|
+
*/
|
|
872
|
+
export function makeCitationsAnalyzer(options = {}) {
|
|
873
|
+
const citationsAnalyzer = async (changeset, ctx) => {
|
|
874
|
+
const rots = await findCitationRot(changeset, ctx, options);
|
|
875
|
+
return rots.map((rot) => {
|
|
876
|
+
// evidence[0] is the citing line, so Fact.file/Fact.line land on the
|
|
877
|
+
// prose the reader has to fix. evidence[1], when the cited file and
|
|
878
|
+
// line exist now, is the cited location as it currently stands — the
|
|
879
|
+
// "now" half of a drift, shown rather than asserted. The baseline
|
|
880
|
+
// content is deliberately never an EvidenceRef: `side` distinguishes
|
|
881
|
+
// the before and after sides of the reviewed range, and the baseline
|
|
882
|
+
// is some other commit entirely. It lives in `detail.was` and in the
|
|
883
|
+
// finding body, where the commit that produced it is named beside it.
|
|
884
|
+
const evidence = [
|
|
885
|
+
{
|
|
886
|
+
file: rot.citingFile,
|
|
887
|
+
line: rot.citingLine,
|
|
888
|
+
excerpt: rot.citingText,
|
|
889
|
+
side: "after",
|
|
890
|
+
},
|
|
891
|
+
];
|
|
892
|
+
// A drift onto an empty line has a cited location and nothing to quote
|
|
893
|
+
// at it. The ref is dropped rather than pushed with an empty excerpt:
|
|
894
|
+
// every surface renders a ref as its location followed by its text, so
|
|
895
|
+
// an empty one is a row that shows the reader nothing and reads as a
|
|
896
|
+
// renderer that failed rather than as the blank line it is. The
|
|
897
|
+
// blankness is not lost — `toFinding` states it in words, from
|
|
898
|
+
// `detail.now`, which carries it exactly. See `src/score/index.ts`,
|
|
899
|
+
// "A line urtext read as empty is something it knows".
|
|
900
|
+
if (rot.citedLine !== undefined && rot.citedText !== undefined && rot.citedText !== "") {
|
|
901
|
+
evidence.push({
|
|
902
|
+
file: rot.citedFile,
|
|
903
|
+
line: rot.citedLine,
|
|
904
|
+
excerpt: rot.citedText,
|
|
905
|
+
side: "after",
|
|
906
|
+
});
|
|
907
|
+
}
|
|
908
|
+
return makeFact({
|
|
909
|
+
// The kind and a colon, which is the convention `subjectOf` in
|
|
910
|
+
// `../report/model.ts` recovers the lens from. The citing location
|
|
911
|
+
// plus the rot kind is the identity: one citing line can carry two
|
|
912
|
+
// citations, and both may rot. `qualifiedSymbol` is omitted — a
|
|
913
|
+
// citation is about a file and a line, not a symbol.
|
|
914
|
+
id: `citation_rot:${rot.citingFile}:${rot.citingLine}:${rot.rot}`,
|
|
915
|
+
kind: "citation_rot",
|
|
916
|
+
detail: {
|
|
917
|
+
rot: rot.rot,
|
|
918
|
+
citedFile: rot.citedFile,
|
|
919
|
+
...(rot.citedLine === undefined ? {} : { citedLine: rot.citedLine }),
|
|
920
|
+
...(rot.citedEndLine === undefined ? {} : { citedEndLine: rot.citedEndLine }),
|
|
921
|
+
...(rot.writtenLine === undefined ? {} : { writtenLine: rot.writtenLine }),
|
|
922
|
+
...(rot.writtenEndLine === undefined ? {} : { writtenEndLine: rot.writtenEndLine }),
|
|
923
|
+
...(rot.quote === undefined ? {} : { quote: rot.quote }),
|
|
924
|
+
...(rot.was === undefined ? {} : { was: rot.was }),
|
|
925
|
+
...(rot.now === undefined ? {} : { now: rot.now }),
|
|
926
|
+
...(rot.baseline === undefined ? {} : { baseline: abbreviate(rot.baseline) }),
|
|
927
|
+
...(rot.lineCount === undefined ? {} : { lineCount: rot.lineCount }),
|
|
928
|
+
citedTouched: rot.citedTouched,
|
|
929
|
+
},
|
|
930
|
+
// Shared with the analyzers that sample evidence, so the cap cannot
|
|
931
|
+
// drift between them, though a citation fact never has more than two
|
|
932
|
+
// refs today.
|
|
933
|
+
evidence: evidence.slice(0, MAX_EVIDENCE),
|
|
934
|
+
});
|
|
935
|
+
});
|
|
936
|
+
};
|
|
937
|
+
Object.defineProperty(citationsAnalyzer, "name", { value: "citationsAnalyzer" });
|
|
938
|
+
return citationsAnalyzer;
|
|
939
|
+
}
|
|
940
|
+
/**
|
|
941
|
+
* The default-mode instance, and the member of ANALYZERS. Also the identity
|
|
942
|
+
* `review` matches on when it swaps in a configured instance, so it must stay
|
|
943
|
+
* a single shared value rather than being reconstructed per call.
|
|
944
|
+
*/
|
|
945
|
+
export const citationsAnalyzer = makeCitationsAnalyzer();
|