diffninja 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 +259 -0
- package/dist/calltree.d.ts +47 -0
- package/dist/calltree.js +296 -0
- package/dist/cli.d.ts +57 -0
- package/dist/cli.js +340 -0
- package/dist/diff.d.ts +7 -0
- package/dist/diff.js +114 -0
- package/dist/extract.d.ts +26 -0
- package/dist/extract.js +152 -0
- package/dist/git.d.ts +40 -0
- package/dist/git.js +288 -0
- package/dist/index.d.ts +9 -0
- package/dist/index.js +8 -0
- package/dist/infer.d.ts +21 -0
- package/dist/infer.js +189 -0
- package/dist/languages/bash.d.ts +2 -0
- package/dist/languages/bash.js +208 -0
- package/dist/languages/c.d.ts +2 -0
- package/dist/languages/c.js +218 -0
- package/dist/languages/call-syntax.d.ts +125 -0
- package/dist/languages/call-syntax.js +997 -0
- package/dist/languages/cpp.d.ts +2 -0
- package/dist/languages/cpp.js +321 -0
- package/dist/languages/csharp.d.ts +2 -0
- package/dist/languages/csharp.js +324 -0
- package/dist/languages/elixir.d.ts +2 -0
- package/dist/languages/elixir.js +331 -0
- package/dist/languages/go.d.ts +2 -0
- package/dist/languages/go.js +299 -0
- package/dist/languages/grammars.d.ts +50 -0
- package/dist/languages/grammars.js +351 -0
- package/dist/languages/haskell.d.ts +2 -0
- package/dist/languages/haskell.js +250 -0
- package/dist/languages/java.d.ts +2 -0
- package/dist/languages/java.js +351 -0
- package/dist/languages/javascript.d.ts +4 -0
- package/dist/languages/javascript.js +648 -0
- package/dist/languages/kotlin.d.ts +2 -0
- package/dist/languages/kotlin.js +368 -0
- package/dist/languages/lua.d.ts +2 -0
- package/dist/languages/lua.js +212 -0
- package/dist/languages/ocaml.d.ts +2 -0
- package/dist/languages/ocaml.js +291 -0
- package/dist/languages/perl.d.ts +2 -0
- package/dist/languages/perl.js +418 -0
- package/dist/languages/php.d.ts +2 -0
- package/dist/languages/php.js +397 -0
- package/dist/languages/python.d.ts +2 -0
- package/dist/languages/python.js +376 -0
- package/dist/languages/registry.d.ts +7 -0
- package/dist/languages/registry.js +69 -0
- package/dist/languages/ruby.d.ts +2 -0
- package/dist/languages/ruby.js +391 -0
- package/dist/languages/rust.d.ts +2 -0
- package/dist/languages/rust.js +261 -0
- package/dist/languages/scala.d.ts +2 -0
- package/dist/languages/scala.js +307 -0
- package/dist/languages/solidity.d.ts +2 -0
- package/dist/languages/solidity.js +240 -0
- package/dist/languages/swift.d.ts +2 -0
- package/dist/languages/swift.js +268 -0
- package/dist/languages/types.d.ts +36 -0
- package/dist/languages/types.js +74 -0
- package/dist/languages/typescript-contracts.d.ts +57 -0
- package/dist/languages/typescript-contracts.js +528 -0
- package/dist/languages/typescript-dispatch.d.ts +68 -0
- package/dist/languages/typescript-dispatch.js +710 -0
- package/dist/languages/typescript.d.ts +4 -0
- package/dist/languages/typescript.js +722 -0
- package/dist/languages/zig.d.ts +2 -0
- package/dist/languages/zig.js +243 -0
- package/dist/loc.d.ts +17 -0
- package/dist/loc.js +34 -0
- package/dist/reach.d.ts +17 -0
- package/dist/reach.js +65 -0
- package/dist/render.d.ts +18 -0
- package/dist/render.js +83 -0
- package/dist/review/brand.d.ts +8 -0
- package/dist/review/brand.js +25 -0
- package/dist/review/call-context.d.ts +27 -0
- package/dist/review/call-context.js +446 -0
- package/dist/review/call-flow-html.d.ts +32 -0
- package/dist/review/call-flow-html.js +1870 -0
- package/dist/review/call-flow-nav.d.ts +151 -0
- package/dist/review/call-flow-nav.js +317 -0
- package/dist/review/call-flow.d.ts +47 -0
- package/dist/review/call-flow.js +229 -0
- package/dist/review/change-facts.d.ts +69 -0
- package/dist/review/change-facts.js +729 -0
- package/dist/review/cli.d.ts +2 -0
- package/dist/review/cli.js +50 -0
- package/dist/review/connected-analysis.d.ts +100 -0
- package/dist/review/connected-analysis.js +163 -0
- package/dist/review/connected-html.d.ts +17 -0
- package/dist/review/connected-html.js +2853 -0
- package/dist/review/connected.d.ts +23 -0
- package/dist/review/connected.js +141 -0
- package/dist/review/escape-html.d.ts +2 -0
- package/dist/review/escape-html.js +9 -0
- package/dist/review/evidence-html.d.ts +21 -0
- package/dist/review/evidence-html.js +521 -0
- package/dist/review/evidence-syntax.d.ts +132 -0
- package/dist/review/evidence-syntax.js +478 -0
- package/dist/review/evidence-types.d.ts +62 -0
- package/dist/review/evidence-types.js +1 -0
- package/dist/review/evidence.d.ts +31 -0
- package/dist/review/evidence.js +1603 -0
- package/dist/review/file-role.d.ts +9 -0
- package/dist/review/file-role.js +29 -0
- package/dist/review/github.d.ts +204 -0
- package/dist/review/github.js +1245 -0
- package/dist/review/history.d.ts +101 -0
- package/dist/review/history.js +412 -0
- package/dist/review/html.d.ts +34 -0
- package/dist/review/html.js +1104 -0
- package/dist/review/input.d.ts +10 -0
- package/dist/review/input.js +113 -0
- package/dist/review/intent.d.ts +4 -0
- package/dist/review/intent.js +75 -0
- package/dist/review/mcp-cli.d.ts +2 -0
- package/dist/review/mcp-cli.js +25 -0
- package/dist/review/mcp.d.ts +12 -0
- package/dist/review/mcp.js +414 -0
- package/dist/review/module-resolution.d.ts +2 -0
- package/dist/review/module-resolution.js +86 -0
- package/dist/review/palette.d.ts +7 -0
- package/dist/review/palette.js +104 -0
- package/dist/review/pipeline.d.ts +77 -0
- package/dist/review/pipeline.js +227 -0
- package/dist/review/pr-input.d.ts +19 -0
- package/dist/review/pr-input.js +130 -0
- package/dist/review/questions.d.ts +201 -0
- package/dist/review/questions.js +174 -0
- package/dist/review/reference-check.d.ts +7 -0
- package/dist/review/reference-check.js +733 -0
- package/dist/review/report-pages.d.ts +109 -0
- package/dist/review/report-pages.js +328 -0
- package/dist/review/service.d.ts +23 -0
- package/dist/review/service.js +198 -0
- package/dist/review/setup.d.ts +112 -0
- package/dist/review/setup.js +549 -0
- package/dist/review/source.d.ts +26 -0
- package/dist/review/source.js +276 -0
- package/dist/review/toml.d.ts +38 -0
- package/dist/review/toml.js +565 -0
- package/dist/review/types.d.ts +179 -0
- package/dist/review/types.js +1 -0
- package/dist/run.d.ts +49 -0
- package/dist/run.js +311 -0
- package/dist/types.d.ts +366 -0
- package/dist/types.js +83 -0
- package/package.json +88 -0
- package/scripts/ensure-native-grammar.mjs +188 -0
|
@@ -0,0 +1,1603 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Deterministic snapshot evidence for the report: the automatic checks with
|
|
3
|
+
* their stated limits, and a reading agenda whose questions come from the
|
|
4
|
+
* change's own structure.
|
|
5
|
+
*
|
|
6
|
+
* Everything here is derived from the immutable snapshots and their indexes:
|
|
7
|
+
* parsed definition bodies, the type-contract relation, the dispatch relation,
|
|
8
|
+
* and the call sites the extractor resolved. Nothing runs a project command and
|
|
9
|
+
* nothing is authored by a model. A check that cannot see its input says so: a
|
|
10
|
+
* missing index, a missing source reader, an unreadable span, and a fragment no
|
|
11
|
+
* grammar parses each end as `not-checked` or as a counted omission instead of
|
|
12
|
+
* a silent pass.
|
|
13
|
+
*
|
|
14
|
+
* Findings state what was read, never what it means: an unread failure field is
|
|
15
|
+
* not a lost failure, and a matching body is not equivalent behavior or
|
|
16
|
+
* unwanted duplication. The agenda then asks fixed review questions on
|
|
17
|
+
* structural triggers, so a question can be raised without a defect having been
|
|
18
|
+
* proved, and every hunk the change touches stays addressable.
|
|
19
|
+
*/
|
|
20
|
+
import { posix } from "node:path";
|
|
21
|
+
import { buildCallSitesFromInfo } from "../calltree.js";
|
|
22
|
+
import { allContextDefinitions } from "../extract.js";
|
|
23
|
+
import { detectLanguage } from "../languages/registry.js";
|
|
24
|
+
import { resolveDispatchContext } from "../languages/typescript-dispatch.js";
|
|
25
|
+
import { resolveTypeContracts } from "../languages/typescript-contracts.js";
|
|
26
|
+
import { formatSourceLoc } from "../loc.js";
|
|
27
|
+
import { collapseWs } from "../languages/types.js";
|
|
28
|
+
import { bodyTokenSignature, callsIn, countReadOf, countReads, declaredValueNames, definitionSyntax, moduleImports, parseFragment, returnedCountRead, returnedResponseType, sameNode, stateWritesAt, contractFields, staticStringValue, typedBindings, walkSyntax, } from "./evidence-syntax.js";
|
|
29
|
+
import { testLikeFile } from "./file-role.js";
|
|
30
|
+
/**
|
|
31
|
+
* Bounds shared by every run. Each one that drops work is stated in the
|
|
32
|
+
* coverage detail, so a reader can tell a clean result from a truncated scan.
|
|
33
|
+
*/
|
|
34
|
+
const MAX_EXCERPT_CHARS = 2_400;
|
|
35
|
+
/**
|
|
36
|
+
* Excerpts that only identify a hunk, not carry its evidence. The report shows
|
|
37
|
+
* every hunk's own diff in full, so a hunk-coverage entry needs the change's
|
|
38
|
+
* shape and enough of it to recognize, not a second copy of the diff.
|
|
39
|
+
*/
|
|
40
|
+
const MAX_HUNK_EXCERPT_CHARS = 600;
|
|
41
|
+
/**
|
|
42
|
+
* Hunks one coverage entry carries as an excerpt. Every remaining hunk is named
|
|
43
|
+
* in the entry's reason with its header and counts, so the entry identifies the
|
|
44
|
+
* file's changes without repeating a card per hunk.
|
|
45
|
+
*/
|
|
46
|
+
const MAX_HUNK_EXCERPTS = 1;
|
|
47
|
+
const MAX_EVIDENCE_PER_ITEM = 5;
|
|
48
|
+
const MAX_FINDINGS_PER_KIND = 4;
|
|
49
|
+
const MAX_COMPARED_DEFINITIONS = 400;
|
|
50
|
+
const MAX_RECEIVER_DEFINITIONS = 200;
|
|
51
|
+
const MAX_CONTEXT_NODES_PER_ENTRY = 3;
|
|
52
|
+
const MAX_FACT_SENTENCES = 2;
|
|
53
|
+
/** Tokens a body needs before two copies of it are worth reporting. */
|
|
54
|
+
const MIN_DUPLICATE_TOKENS = 12;
|
|
55
|
+
/** Field names that report failure in a declared response shape. */
|
|
56
|
+
const ERROR_FIELD_NAMES = /^(?:err|error|errors|exception|exceptions|failure|failures|problem|problems)$/i;
|
|
57
|
+
/** Field names that report how much succeeded. */
|
|
58
|
+
const COUNT_FIELD_NAMES = /(?:count|total|length|size|number|num)$/i;
|
|
59
|
+
/**
|
|
60
|
+
* Leading words of a call name that write state outside this process. A call is
|
|
61
|
+
* a write when its own name starts with one of these at a camel-case boundary,
|
|
62
|
+
* so `updateLease`, `createOrUpdate`, and `saveAll` count while `sendEmail`
|
|
63
|
+
* matches `send` and `publish*` matches `publish`.
|
|
64
|
+
*/
|
|
65
|
+
const WRITE_VERBS = {
|
|
66
|
+
create: true,
|
|
67
|
+
insert: true,
|
|
68
|
+
save: true,
|
|
69
|
+
persist: true,
|
|
70
|
+
upsert: true,
|
|
71
|
+
update: true,
|
|
72
|
+
write: true,
|
|
73
|
+
put: true,
|
|
74
|
+
store: true,
|
|
75
|
+
publish: true,
|
|
76
|
+
emit: true,
|
|
77
|
+
enqueue: true,
|
|
78
|
+
dispatch: true,
|
|
79
|
+
send: true,
|
|
80
|
+
post: true,
|
|
81
|
+
commit: true,
|
|
82
|
+
delete: true,
|
|
83
|
+
remove: true,
|
|
84
|
+
execute: true,
|
|
85
|
+
transaction: true,
|
|
86
|
+
};
|
|
87
|
+
/** Nodes that hold statements; a read's evidence stops at the one enclosing it. */
|
|
88
|
+
const STATEMENT_CONTAINERS = {
|
|
89
|
+
statement_block: true,
|
|
90
|
+
program: true,
|
|
91
|
+
class_body: true,
|
|
92
|
+
switch_body: true,
|
|
93
|
+
interface_body: true,
|
|
94
|
+
declaration_list: true,
|
|
95
|
+
};
|
|
96
|
+
/** Declaration nodes that bind a name, so the binding itself is not a use. */
|
|
97
|
+
const TYPED_DECLARATION_NODES = {
|
|
98
|
+
required_parameter: true,
|
|
99
|
+
optional_parameter: true,
|
|
100
|
+
parameter: true,
|
|
101
|
+
typed_parameter: true,
|
|
102
|
+
variable_declarator: true,
|
|
103
|
+
public_field_definition: true,
|
|
104
|
+
property_declaration: true,
|
|
105
|
+
};
|
|
106
|
+
/** Node types a response value is followed through, so it is still the result. */
|
|
107
|
+
const TRANSPARENT_NODES = {
|
|
108
|
+
await_expression: true,
|
|
109
|
+
parenthesized_expression: true,
|
|
110
|
+
as_expression: true,
|
|
111
|
+
satisfies_expression: true,
|
|
112
|
+
non_null_expression: true,
|
|
113
|
+
type_assertion: true,
|
|
114
|
+
};
|
|
115
|
+
/** Languages whose declared response shapes this check reads. */
|
|
116
|
+
const TYPED_RESPONSE_LANGUAGES = { typescript: true, typescriptreact: true };
|
|
117
|
+
/** Subject of an entry that names no definition of its own. */
|
|
118
|
+
const EMPTY_SUBJECT = { primary: new Set(), contracts: new Set() };
|
|
119
|
+
function location(info) {
|
|
120
|
+
const loc = { file: info.file, line: info.line ?? 1 };
|
|
121
|
+
if (info.line !== undefined && info.endLine !== undefined && info.endLine !== info.line) {
|
|
122
|
+
loc.endLine = info.endLine;
|
|
123
|
+
}
|
|
124
|
+
return loc;
|
|
125
|
+
}
|
|
126
|
+
function byLocation(left, right) {
|
|
127
|
+
return (left.file.localeCompare(right.file) ||
|
|
128
|
+
(left.line ?? 0) - (right.line ?? 0) ||
|
|
129
|
+
left.key.localeCompare(right.key));
|
|
130
|
+
}
|
|
131
|
+
/**
|
|
132
|
+
* Lines the hunk changes on the resulting snapshot, ascending. A deletion has
|
|
133
|
+
* no resulting line of its own, so the boundary it leaves is recorded instead:
|
|
134
|
+
* without it a removal-only hunk would mark nothing changed and the definition
|
|
135
|
+
* that lost those lines would never be inspected.
|
|
136
|
+
*/
|
|
137
|
+
function changedLinesOf(unit) {
|
|
138
|
+
if (unit.special || !unit.header.startsWith("@@"))
|
|
139
|
+
return [];
|
|
140
|
+
const lines = new Set();
|
|
141
|
+
let line = unit.newStart;
|
|
142
|
+
for (const text of unit.diff.split("\n").slice(1)) {
|
|
143
|
+
const prefix = text[0];
|
|
144
|
+
if (prefix === "+")
|
|
145
|
+
lines.add(line++);
|
|
146
|
+
else if (prefix === " ")
|
|
147
|
+
line++;
|
|
148
|
+
else if (prefix === "-")
|
|
149
|
+
lines.add(Math.max(1, line));
|
|
150
|
+
}
|
|
151
|
+
return [...lines].sort((left, right) => left - right);
|
|
152
|
+
}
|
|
153
|
+
function evidenceInput(units, options) {
|
|
154
|
+
return {
|
|
155
|
+
changes: units
|
|
156
|
+
.map(unit => ({ unit, lines: changedLinesOf(unit) }))
|
|
157
|
+
.sort((left, right) => left.unit.file.localeCompare(right.unit.file) ||
|
|
158
|
+
left.unit.newStart - right.unit.newStart ||
|
|
159
|
+
left.unit.id.localeCompare(right.unit.id)),
|
|
160
|
+
after: options.after,
|
|
161
|
+
before: options.before,
|
|
162
|
+
changedFiles: new Set(units.map(unit => unit.file)),
|
|
163
|
+
sources: options.sources ?? {},
|
|
164
|
+
headRef: options.headRef ?? "resulting snapshot",
|
|
165
|
+
baseRef: options.baseRef ?? "prior snapshot",
|
|
166
|
+
definitions: new Map(),
|
|
167
|
+
imports: new Map(),
|
|
168
|
+
returnTypes: new Map(),
|
|
169
|
+
resolveImport: options.resolveImport ?? relativeImportTarget,
|
|
170
|
+
};
|
|
171
|
+
}
|
|
172
|
+
/** Definition source for one side, read and parsed at most once per location. */
|
|
173
|
+
function definitionSource(input, side, info) {
|
|
174
|
+
const loc = location(info);
|
|
175
|
+
const key = `${side}\0${loc.file}\0${loc.line}\0${loc.endLine ?? loc.line}`;
|
|
176
|
+
const cached = input.definitions.get(key);
|
|
177
|
+
if (cached)
|
|
178
|
+
return cached;
|
|
179
|
+
const reader = input.sources[side];
|
|
180
|
+
const text = reader ? reader(loc) : null;
|
|
181
|
+
const fragment = text === null ? null : parseFragment(info.file, text);
|
|
182
|
+
const source = {
|
|
183
|
+
info,
|
|
184
|
+
loc,
|
|
185
|
+
text,
|
|
186
|
+
fragment,
|
|
187
|
+
syntax: fragment ? definitionSyntax(fragment) : null,
|
|
188
|
+
};
|
|
189
|
+
input.definitions.set(key, source);
|
|
190
|
+
return source;
|
|
191
|
+
}
|
|
192
|
+
/** Callable definitions of the resulting snapshot that live in a changed file. */
|
|
193
|
+
function changedDefinitions(input) {
|
|
194
|
+
if (!input.after)
|
|
195
|
+
return [];
|
|
196
|
+
return allContextDefinitions(input.after)
|
|
197
|
+
.filter(info => !info.review?.kind && info.line !== undefined && input.changedFiles.has(info.file))
|
|
198
|
+
.sort(byLocation);
|
|
199
|
+
}
|
|
200
|
+
/** Declared response type of one definition, parsed at most once. */
|
|
201
|
+
function declaredResponseTypes(input, info) {
|
|
202
|
+
const cached = input.returnTypes.get(info);
|
|
203
|
+
if (cached)
|
|
204
|
+
return cached;
|
|
205
|
+
const source = definitionSource(input, "after", info);
|
|
206
|
+
const name = source.syntax ? returnedResponseType(source.syntax.definition) : null;
|
|
207
|
+
const names = name === null ? [] : [name];
|
|
208
|
+
input.returnTypes.set(info, names);
|
|
209
|
+
return names;
|
|
210
|
+
}
|
|
211
|
+
function intersectsChanged(input, info) {
|
|
212
|
+
const start = info.line ?? 0;
|
|
213
|
+
const end = info.endLine ?? start;
|
|
214
|
+
return input.changes.some(change => change.unit.file === info.file &&
|
|
215
|
+
change.lines.some(line => line >= start && line <= end));
|
|
216
|
+
}
|
|
217
|
+
/** Units whose changed lines fall inside one definition's span. */
|
|
218
|
+
function unitsTouching(input, info) {
|
|
219
|
+
const start = info.line ?? 0;
|
|
220
|
+
const end = info.endLine ?? start;
|
|
221
|
+
return input.changes
|
|
222
|
+
.filter(change => change.unit.file === info.file &&
|
|
223
|
+
change.lines.some(line => line >= start && line <= end))
|
|
224
|
+
.map(({ unit }) => unit.id);
|
|
225
|
+
}
|
|
226
|
+
/**
|
|
227
|
+
* Smallest definition containing each changed line of one hunk, so a nested
|
|
228
|
+
* definition is not reported as its enclosing body.
|
|
229
|
+
*/
|
|
230
|
+
function hunkDefinitions(candidates, lines) {
|
|
231
|
+
const selected = new Set();
|
|
232
|
+
const containing = [];
|
|
233
|
+
for (const line of lines) {
|
|
234
|
+
containing.length = 0;
|
|
235
|
+
let width = Infinity;
|
|
236
|
+
for (const info of candidates) {
|
|
237
|
+
const start = info.line ?? 0;
|
|
238
|
+
const end = info.endLine ?? start;
|
|
239
|
+
if (line < start || line > end)
|
|
240
|
+
continue;
|
|
241
|
+
const span = end - start;
|
|
242
|
+
if (span < width) {
|
|
243
|
+
width = span;
|
|
244
|
+
containing.length = 0;
|
|
245
|
+
}
|
|
246
|
+
if (span === width)
|
|
247
|
+
containing.push(info);
|
|
248
|
+
}
|
|
249
|
+
for (const info of containing)
|
|
250
|
+
selected.add(info);
|
|
251
|
+
}
|
|
252
|
+
return [...selected].sort(byLocation);
|
|
253
|
+
}
|
|
254
|
+
/* --------------------------------------------------------------- excerpts */
|
|
255
|
+
/** Text kept for one excerpt; a cut is stated in the text, never silent. */
|
|
256
|
+
function clip(text, limit) {
|
|
257
|
+
return text.length <= limit
|
|
258
|
+
? text
|
|
259
|
+
: `${text.slice(0, limit)}\n… evidence text cut at ${limit} characters`;
|
|
260
|
+
}
|
|
261
|
+
function excerpt(input) {
|
|
262
|
+
const value = {
|
|
263
|
+
id: `${input.role}:${input.file}:${input.line}:${input.label}`,
|
|
264
|
+
label: input.label,
|
|
265
|
+
file: input.file,
|
|
266
|
+
line: Math.max(1, input.line),
|
|
267
|
+
ref: input.ref,
|
|
268
|
+
text: clip(input.text, input.limit ?? MAX_EXCERPT_CHARS),
|
|
269
|
+
role: input.role,
|
|
270
|
+
};
|
|
271
|
+
if (input.endLine !== undefined && input.endLine !== input.line)
|
|
272
|
+
value.endLine = input.endLine;
|
|
273
|
+
return value;
|
|
274
|
+
}
|
|
275
|
+
function definitionExcerpt(input, side, info, role, label) {
|
|
276
|
+
const source = definitionSource(input, side, info);
|
|
277
|
+
if (source.text === null)
|
|
278
|
+
return null;
|
|
279
|
+
return excerpt({
|
|
280
|
+
role,
|
|
281
|
+
label,
|
|
282
|
+
file: source.loc.file,
|
|
283
|
+
line: source.loc.line,
|
|
284
|
+
endLine: source.loc.endLine,
|
|
285
|
+
ref: side === "after" ? input.headRef : input.baseRef,
|
|
286
|
+
text: source.text,
|
|
287
|
+
});
|
|
288
|
+
}
|
|
289
|
+
/** Direct callers within the gathered context, including callers changed in the same hunk. */
|
|
290
|
+
function callerEvidence(input, unitIds, target) {
|
|
291
|
+
if (!input.after)
|
|
292
|
+
return [];
|
|
293
|
+
const wanted = new Set(unitIds);
|
|
294
|
+
const keys = new Set();
|
|
295
|
+
for (const { unit } of input.changes) {
|
|
296
|
+
if (!wanted.has(unit.id))
|
|
297
|
+
continue;
|
|
298
|
+
for (const node of unit.contextNodes ?? [])
|
|
299
|
+
keys.add(node.key);
|
|
300
|
+
}
|
|
301
|
+
const found = [];
|
|
302
|
+
const candidates = allContextDefinitions(input.after)
|
|
303
|
+
.filter(info => info !== target && !info.review?.kind && keys.has(`after:${info.key}`))
|
|
304
|
+
.sort(byLocation);
|
|
305
|
+
for (const info of candidates) {
|
|
306
|
+
const source = definitionSource(input, "after", info);
|
|
307
|
+
const reaches = buildCallSitesFromInfo(info, input.after).some(site => site.definition?.file === target.file && site.definition.line === target.line &&
|
|
308
|
+
producerCallProven(input, source, target, site));
|
|
309
|
+
if (!reaches)
|
|
310
|
+
continue;
|
|
311
|
+
const evidence = definitionExcerpt(input, "after", info, "caller", `direct caller ${info.key}`);
|
|
312
|
+
if (evidence)
|
|
313
|
+
found.push(evidence);
|
|
314
|
+
if (found.length === 2)
|
|
315
|
+
break;
|
|
316
|
+
}
|
|
317
|
+
return found;
|
|
318
|
+
}
|
|
319
|
+
/** Without configuration, only explicit relative TypeScript source modules are followed. */
|
|
320
|
+
function relativeImportTarget(importer, specifier) {
|
|
321
|
+
if (!specifier.startsWith("./") && !specifier.startsWith("../"))
|
|
322
|
+
return undefined;
|
|
323
|
+
const path = posix.normalize(posix.join(posix.dirname(importer), specifier));
|
|
324
|
+
return /\.[cm]?tsx?$/u.test(path) ? path : `${path.replace(/\.[cm]?jsx?$/u, "")}.ts`;
|
|
325
|
+
}
|
|
326
|
+
/** Only lexical bindings, typed receivers, and resolved imports establish a producer. */
|
|
327
|
+
function producerCallProven(input, source, producer, site) {
|
|
328
|
+
const callee = site.context?.callee ?? "";
|
|
329
|
+
const member = producer.key.lastIndexOf(".");
|
|
330
|
+
const sameFile = producer.file === source.info.file;
|
|
331
|
+
if (sameFile && site.context?.target === "lexical")
|
|
332
|
+
return true;
|
|
333
|
+
let importedName = callee;
|
|
334
|
+
let origin = source;
|
|
335
|
+
if (member >= 0) {
|
|
336
|
+
const owner = producer.key.slice(0, member);
|
|
337
|
+
const sourceOwner = source.info.key.slice(0, source.info.key.lastIndexOf("."));
|
|
338
|
+
if (sameFile && sourceOwner === owner && callee === `this.${producer.key.slice(member + 1)}`)
|
|
339
|
+
return true;
|
|
340
|
+
const field = /^this\.([A-Za-z_$][\w$]*)\.[A-Za-z_$][\w$]*$/u.exec(callee)?.[1];
|
|
341
|
+
if (!field || !input.after)
|
|
342
|
+
return false;
|
|
343
|
+
const constructor = allContextDefinitions(input.after).find(info => info.file === source.info.file && info.key === `${sourceOwner}.constructor`);
|
|
344
|
+
if (!constructor)
|
|
345
|
+
return false;
|
|
346
|
+
origin = definitionSource(input, "after", constructor);
|
|
347
|
+
if (!origin.syntax || !typedBindings(origin.syntax.definition, owner).some(binding => binding.name === field))
|
|
348
|
+
return false;
|
|
349
|
+
if (sameFile)
|
|
350
|
+
return true;
|
|
351
|
+
importedName = owner;
|
|
352
|
+
}
|
|
353
|
+
else if (!/^[A-Za-z_$][\w$]*$/u.test(callee)) {
|
|
354
|
+
return false;
|
|
355
|
+
}
|
|
356
|
+
const specifier = importsOf(input, origin)?.get(importedName);
|
|
357
|
+
return specifier !== undefined && input.resolveImport(origin.info.file, specifier) === producer.file;
|
|
358
|
+
}
|
|
359
|
+
function importsOf(input, source) {
|
|
360
|
+
const key = `${source.info.file}:${source.loc.line}`;
|
|
361
|
+
let imports = input.imports.get(key);
|
|
362
|
+
if (imports === undefined) {
|
|
363
|
+
const prefix = input.sources.after?.({ file: source.info.file, line: 1, endLine: source.loc.line });
|
|
364
|
+
imports = prefix === null || prefix === undefined ? null : moduleImports(source.info.file, prefix);
|
|
365
|
+
input.imports.set(key, imports);
|
|
366
|
+
}
|
|
367
|
+
return imports;
|
|
368
|
+
}
|
|
369
|
+
/** Role a context node was selected as, read from its own detail header. */
|
|
370
|
+
function contextRole(node) {
|
|
371
|
+
const role = /role=(changed-definition|caller|callee)\b/.exec(node.detail)?.[1];
|
|
372
|
+
if (role === "changed-definition")
|
|
373
|
+
return "change";
|
|
374
|
+
return role === "caller" ? "caller" : "related";
|
|
375
|
+
}
|
|
376
|
+
/** Identification-sized excerpt of one hunk, for the coverage entries. */
|
|
377
|
+
function unitExcerpt(input, unit) {
|
|
378
|
+
const lead = unit.special
|
|
379
|
+
? `${unit.header}\nspecial: ${unit.special}`
|
|
380
|
+
: `${unit.header} (added ${unit.added}, removed ${unit.removed})`;
|
|
381
|
+
return excerpt({
|
|
382
|
+
role: "change",
|
|
383
|
+
label: unit.special ? `changed file metadata ${unit.file}` : `changed hunk ${unit.file}:${unit.newStart}`,
|
|
384
|
+
file: unit.file,
|
|
385
|
+
line: unit.special ? 1 : unit.newStart,
|
|
386
|
+
ref: input.headRef,
|
|
387
|
+
limit: MAX_HUNK_EXCERPT_CHARS,
|
|
388
|
+
text: `${lead}\n${unit.diff.split("\n").slice(1).join("\n")}`,
|
|
389
|
+
});
|
|
390
|
+
}
|
|
391
|
+
/** Roles of one entry's evidence, in the order a reader needs them. */
|
|
392
|
+
const EVIDENCE_ROLES = ["change", "caller", "test", "related", "contract"];
|
|
393
|
+
/**
|
|
394
|
+
* Unique excerpts for one entry, rotated across the roles so every role present
|
|
395
|
+
* is represented before any role takes a second slot: a run of contract
|
|
396
|
+
* declarations can then never consume the slots the changed code and its
|
|
397
|
+
* callers of the same entry need. Order within a role is the caller's ranking.
|
|
398
|
+
*/
|
|
399
|
+
function selectEvidence(candidates, limit = MAX_EVIDENCE_PER_ITEM) {
|
|
400
|
+
const byRole = new Map();
|
|
401
|
+
const seen = new Set();
|
|
402
|
+
for (const candidate of candidates) {
|
|
403
|
+
if (seen.has(candidate.id))
|
|
404
|
+
continue;
|
|
405
|
+
seen.add(candidate.id);
|
|
406
|
+
const list = byRole.get(candidate.role);
|
|
407
|
+
if (list)
|
|
408
|
+
list.push(candidate);
|
|
409
|
+
else
|
|
410
|
+
byRole.set(candidate.role, [candidate]);
|
|
411
|
+
}
|
|
412
|
+
const kept = [];
|
|
413
|
+
for (let round = 0; kept.length < limit; round += 1) {
|
|
414
|
+
let added = false;
|
|
415
|
+
for (const role of EVIDENCE_ROLES) {
|
|
416
|
+
const candidate = byRole.get(role)?.[round];
|
|
417
|
+
if (!candidate)
|
|
418
|
+
continue;
|
|
419
|
+
kept.push(candidate);
|
|
420
|
+
added = true;
|
|
421
|
+
if (kept.length >= limit)
|
|
422
|
+
break;
|
|
423
|
+
}
|
|
424
|
+
if (!added)
|
|
425
|
+
break;
|
|
426
|
+
}
|
|
427
|
+
return kept;
|
|
428
|
+
}
|
|
429
|
+
/** Ranks one context node by how directly its entry is about it. */
|
|
430
|
+
function contextRank(node, primary, contracts) {
|
|
431
|
+
if (primary.has(node.key))
|
|
432
|
+
return 0;
|
|
433
|
+
const role = contextRole(node);
|
|
434
|
+
if (role === "change")
|
|
435
|
+
return 1;
|
|
436
|
+
// A caller has no other place in the entry; the whole-definition excerpt
|
|
437
|
+
// already carries a callee's body and the declared contract.
|
|
438
|
+
if (role === "caller")
|
|
439
|
+
return 2;
|
|
440
|
+
return contracts.has(node.key) ? 3 : 4;
|
|
441
|
+
}
|
|
442
|
+
/**
|
|
443
|
+
* Context nodes of the entry's units, keyed uniquely and ranked by what the
|
|
444
|
+
* entry is about: the definition it names first, then the changed definition,
|
|
445
|
+
* then its callers, then the declared contract, then farther context. Unit
|
|
446
|
+
* order, then selection order, breaks every tie.
|
|
447
|
+
*/
|
|
448
|
+
function selectContext(input, unitIds, primary, contracts, callers = []) {
|
|
449
|
+
const ranked = [];
|
|
450
|
+
const keys = new Set();
|
|
451
|
+
let order = 0;
|
|
452
|
+
for (const { unit } of input.changes) {
|
|
453
|
+
if (!unitIds.has(unit.id))
|
|
454
|
+
continue;
|
|
455
|
+
for (const node of unit.contextNodes ?? []) {
|
|
456
|
+
if (keys.has(node.key))
|
|
457
|
+
continue;
|
|
458
|
+
if (!primary.has(node.key) && !contracts.has(node.key) &&
|
|
459
|
+
!callers.some(caller => node.key.startsWith("after:") && caller.file === node.file && caller.line === node.line))
|
|
460
|
+
continue;
|
|
461
|
+
keys.add(node.key);
|
|
462
|
+
ranked.push({ node, rank: contextRank(node, primary, contracts), order: order++ });
|
|
463
|
+
}
|
|
464
|
+
}
|
|
465
|
+
return ranked
|
|
466
|
+
.sort((left, right) => left.rank - right.rank || left.order - right.order)
|
|
467
|
+
.slice(0, MAX_CONTEXT_NODES_PER_ENTRY)
|
|
468
|
+
.map(entry => entry.node);
|
|
469
|
+
}
|
|
470
|
+
/** Sentences capped for display; the cut is stated rather than hidden. */
|
|
471
|
+
function factsText(sentences) {
|
|
472
|
+
if (sentences.length <= MAX_FACT_SENTENCES)
|
|
473
|
+
return sentences.join(" ");
|
|
474
|
+
return [
|
|
475
|
+
...sentences.slice(0, MAX_FACT_SENTENCES),
|
|
476
|
+
`…and ${sentences.length - MAX_FACT_SENTENCES} more of the same kind.`,
|
|
477
|
+
].join(" ");
|
|
478
|
+
}
|
|
479
|
+
/* --------------------------------------------------------------- coverage */
|
|
480
|
+
function coverage(kind, status, detail) {
|
|
481
|
+
return { kind, status, detail };
|
|
482
|
+
}
|
|
483
|
+
const PATCH_REASON = "Patch-only input: no repository revision was indexed, so definition bodies, declared shapes, and resolved " +
|
|
484
|
+
"calls are all unavailable.";
|
|
485
|
+
const INDEX_REASON = "No resulting-snapshot index was supplied, so no definition could be read or compared.";
|
|
486
|
+
const SOURCE_REASON = "No snapshot source reader was supplied, so definition bodies and declared shapes could not be read.";
|
|
487
|
+
/**
|
|
488
|
+
* Functions whose bodies are token-identical after comments and formatting are
|
|
489
|
+
* removed. Literals and operators are kept, so `create(user)` and
|
|
490
|
+
* `create(admin)` never match, and only definitions in changed files are
|
|
491
|
+
* compared, so the scan stays bounded by the diff. A group is reported only
|
|
492
|
+
* when it spans two files and at least one copy is code this change touched.
|
|
493
|
+
*/
|
|
494
|
+
function duplicateScan(input) {
|
|
495
|
+
if (!input.after) {
|
|
496
|
+
return { findings: [], check: coverage("duplicate-body", "not-checked", INDEX_REASON) };
|
|
497
|
+
}
|
|
498
|
+
if (!input.sources.after) {
|
|
499
|
+
return { findings: [], check: coverage("duplicate-body", "not-checked", SOURCE_REASON) };
|
|
500
|
+
}
|
|
501
|
+
const candidates = changedDefinitions(input);
|
|
502
|
+
const compared = [];
|
|
503
|
+
let skipped = 0;
|
|
504
|
+
for (const info of candidates.slice(0, MAX_COMPARED_DEFINITIONS)) {
|
|
505
|
+
const source = definitionSource(input, "after", info);
|
|
506
|
+
if (!source.syntax) {
|
|
507
|
+
skipped += 1;
|
|
508
|
+
continue;
|
|
509
|
+
}
|
|
510
|
+
const body = bodyTokenSignature(source.syntax.body);
|
|
511
|
+
if (body.split(" ").length < MIN_DUPLICATE_TOKENS)
|
|
512
|
+
continue;
|
|
513
|
+
compared.push({ info, body, touched: intersectsChanged(input, info) });
|
|
514
|
+
}
|
|
515
|
+
const groups = new Map();
|
|
516
|
+
for (const member of compared) {
|
|
517
|
+
const group = groups.get(member.body);
|
|
518
|
+
if (group)
|
|
519
|
+
group.push(member);
|
|
520
|
+
else
|
|
521
|
+
groups.set(member.body, [member]);
|
|
522
|
+
}
|
|
523
|
+
const findings = [];
|
|
524
|
+
let matchedGroups = 0;
|
|
525
|
+
for (const group of groups.values()) {
|
|
526
|
+
if (group.length < 2)
|
|
527
|
+
continue;
|
|
528
|
+
if (new Set(group.map(member => member.info.file)).size < 2)
|
|
529
|
+
continue;
|
|
530
|
+
if (!group.some(member => member.touched))
|
|
531
|
+
continue;
|
|
532
|
+
matchedGroups += 1;
|
|
533
|
+
if (findings.length >= MAX_FINDINGS_PER_KIND)
|
|
534
|
+
continue;
|
|
535
|
+
const ordered = [...group].sort((left, right) => byLocation(left.info, right.info));
|
|
536
|
+
const first = ordered[0];
|
|
537
|
+
findings.push({
|
|
538
|
+
id: `duplicate-body:${first.info.file}:${first.info.line ?? 1}`,
|
|
539
|
+
kind: "duplicate-body",
|
|
540
|
+
title: `Matching function bodies: ${ordered.map(member => member.info.key).join(" and ")}` +
|
|
541
|
+
(ordered.length > 2 ? ` (${ordered.length} definitions)` : ""),
|
|
542
|
+
scope: `Normalized token comparison of ${ordered.length} definitions whose files this change touches: ` +
|
|
543
|
+
ordered.map(member => `${member.info.key} @ ${formatSourceLoc(location(member.info))}`).join(", ") +
|
|
544
|
+
".",
|
|
545
|
+
limitation: "Matching bodies do not establish equivalent behavior, that this change introduced the duplication, or " +
|
|
546
|
+
"that the definitions should be merged. Only the bodies are compared: declared signatures, parameter and " +
|
|
547
|
+
"return types, and every call site are outside this comparison. Comments and formatting are ignored; " +
|
|
548
|
+
`literals and operators are compared as written. ${compared.length} bodies in changed files were compared, ` +
|
|
549
|
+
`${skipped} were skipped because their source span or fragment was unreadable.`,
|
|
550
|
+
unitIds: [...new Set(ordered.flatMap(member => unitsTouching(input, member.info)))].sort(),
|
|
551
|
+
evidence: selectEvidence(ordered.flatMap((member, index) => {
|
|
552
|
+
const evidence = definitionExcerpt(input, "after", member.info, member.touched ? "change" : "related", `body ${index + 1}/${ordered.length} ${member.info.key}`);
|
|
553
|
+
return evidence ? [evidence] : [];
|
|
554
|
+
})),
|
|
555
|
+
});
|
|
556
|
+
}
|
|
557
|
+
const truncated = candidates.length > MAX_COMPARED_DEFINITIONS;
|
|
558
|
+
return {
|
|
559
|
+
findings,
|
|
560
|
+
check: coverage("duplicate-body", skipped > 0 || truncated ? "partial" : "checked", [
|
|
561
|
+
`Compared ${compared.length} comment-free bodies of callable definitions in the changed files of the ` +
|
|
562
|
+
`resulting snapshot (${candidates.length} candidate definitions, limit ${MAX_COMPARED_DEFINITIONS}${truncated ? ", so the rest were not compared" : ""}).`,
|
|
563
|
+
`${skipped} definitions were skipped: no readable source span, or a fragment the grammar would not parse.`,
|
|
564
|
+
matchedGroups > 0
|
|
565
|
+
? `${matchedGroups} token-identical body group${matchedGroups === 1 ? "" : "s"} crossed two changed files; ${findings.length} ${findings.length === 1 ? "is" : "are"} reported.`
|
|
566
|
+
: "No token-identical body group crossed two changed files, so no duplicate is reported; that is a comparison result, not a claim about design.",
|
|
567
|
+
"Only bodies are compared: signatures, parameter and return types, and call sites are not. Comments and " +
|
|
568
|
+
"formatting are ignored; literals and operators are compared as written.",
|
|
569
|
+
].join(" ")),
|
|
570
|
+
};
|
|
571
|
+
}
|
|
572
|
+
/** Source line of one parse row, on the snapshot both were read from. */
|
|
573
|
+
function absoluteLine(fragment, source, row) {
|
|
574
|
+
return source.loc.line + row - fragment.lineOffset;
|
|
575
|
+
}
|
|
576
|
+
/**
|
|
577
|
+
* Call expression on one line whose callee names the producer: the resolved
|
|
578
|
+
* call site is what selects the line, and the callee segment confirms that the
|
|
579
|
+
* expression belongs to the same target.
|
|
580
|
+
*/
|
|
581
|
+
function callExpressionAt(syntax, fragment, source, line, key) {
|
|
582
|
+
const last = key.slice(key.lastIndexOf(".") + 1);
|
|
583
|
+
let found = null;
|
|
584
|
+
walkSyntax(syntax.definition, node => {
|
|
585
|
+
if (found || node.type !== "call_expression")
|
|
586
|
+
return;
|
|
587
|
+
if (absoluteLine(fragment, source, node.startPosition.row) !== line)
|
|
588
|
+
return;
|
|
589
|
+
const callee = node.childForFieldName("function");
|
|
590
|
+
if (!callee)
|
|
591
|
+
return;
|
|
592
|
+
if (callee.text.slice(callee.text.lastIndexOf(".") + 1) === last)
|
|
593
|
+
found = node;
|
|
594
|
+
});
|
|
595
|
+
return found;
|
|
596
|
+
}
|
|
597
|
+
function responseArrival(call, requiresAwait) {
|
|
598
|
+
let value = call;
|
|
599
|
+
let parent = value.parent;
|
|
600
|
+
let awaited = false;
|
|
601
|
+
while (parent && Object.hasOwn(TRANSPARENT_NODES, parent.type)) {
|
|
602
|
+
if (parent.type === "await_expression")
|
|
603
|
+
awaited = true;
|
|
604
|
+
value = parent;
|
|
605
|
+
parent = parent.parent;
|
|
606
|
+
}
|
|
607
|
+
if (!parent || (requiresAwait && !awaited))
|
|
608
|
+
return null;
|
|
609
|
+
if (parent.type === "variable_declarator" && sameNode(parent.childForFieldName("value"), value)) {
|
|
610
|
+
const name = parent.childForFieldName("name");
|
|
611
|
+
if (!name)
|
|
612
|
+
return null;
|
|
613
|
+
if (name.type === "identifier")
|
|
614
|
+
return { kind: "name", name: name.text, node: name };
|
|
615
|
+
if (name.type !== "object_pattern")
|
|
616
|
+
return { kind: "positional" };
|
|
617
|
+
const fields = [];
|
|
618
|
+
let rest = false;
|
|
619
|
+
for (const entry of name.namedChildren) {
|
|
620
|
+
if (entry.type === "rest_pattern") {
|
|
621
|
+
rest = true;
|
|
622
|
+
continue;
|
|
623
|
+
}
|
|
624
|
+
const keyNode = entry.type === "shorthand_property_identifier_pattern"
|
|
625
|
+
? entry
|
|
626
|
+
: entry.childForFieldName("key") ?? entry.childForFieldName("left");
|
|
627
|
+
if (!keyNode || keyNode.type === "computed_property_name") {
|
|
628
|
+
rest = true;
|
|
629
|
+
continue;
|
|
630
|
+
}
|
|
631
|
+
const key = keyNode.type === "string" ? staticStringValue(keyNode) : keyNode.text;
|
|
632
|
+
if (key === null || key === undefined)
|
|
633
|
+
rest = true;
|
|
634
|
+
else
|
|
635
|
+
fields.push(key);
|
|
636
|
+
}
|
|
637
|
+
return { kind: "pattern", fields, rest, node: name };
|
|
638
|
+
}
|
|
639
|
+
if (parent.type === "assignment_expression" && sameNode(parent.childForFieldName("right"), value)) {
|
|
640
|
+
const left = parent.childForFieldName("left");
|
|
641
|
+
return left?.type === "identifier" ? { kind: "name", name: left.text, node: left } : null;
|
|
642
|
+
}
|
|
643
|
+
return null;
|
|
644
|
+
}
|
|
645
|
+
/** Statement text one read belongs to, so its evidence carries its own context. */
|
|
646
|
+
function enclosingStatement(node) {
|
|
647
|
+
let current = node;
|
|
648
|
+
while (current.parent && !Object.hasOwn(STATEMENT_CONTAINERS, current.parent.type)) {
|
|
649
|
+
current = current.parent;
|
|
650
|
+
}
|
|
651
|
+
return current.text.replace(/\s+/g, " ").trim();
|
|
652
|
+
}
|
|
653
|
+
/**
|
|
654
|
+
* One field read off a held response, as reported evidence. A count-named read
|
|
655
|
+
* of that field is the count itself, so it is marked as counted and its line is
|
|
656
|
+
* the count expression's own line, not the field read's.
|
|
657
|
+
*/
|
|
658
|
+
function reportedField(fragment, source, access, field) {
|
|
659
|
+
const counting = access.parent?.type === "member_expression"
|
|
660
|
+
? countReadOf(access.parent)
|
|
661
|
+
: null;
|
|
662
|
+
const at = counting ? counting.read : access;
|
|
663
|
+
return {
|
|
664
|
+
field,
|
|
665
|
+
line: absoluteLine(fragment, source, at.startPosition.row),
|
|
666
|
+
statement: enclosingStatement(at),
|
|
667
|
+
counted: counting !== null || COUNT_FIELD_NAMES.test(field),
|
|
668
|
+
};
|
|
669
|
+
}
|
|
670
|
+
/** First count usage of one destructured field, with the statement it sits in. */
|
|
671
|
+
function countUsageOf(fragment, source, syntax, field) {
|
|
672
|
+
for (const count of countReads(syntax.definition)) {
|
|
673
|
+
if (count.value !== field)
|
|
674
|
+
continue;
|
|
675
|
+
return {
|
|
676
|
+
line: absoluteLine(fragment, source, count.read.startPosition.row),
|
|
677
|
+
statement: enclosingStatement(count.read),
|
|
678
|
+
};
|
|
679
|
+
}
|
|
680
|
+
return null;
|
|
681
|
+
}
|
|
682
|
+
/**
|
|
683
|
+
* What one receiver does with the response it holds. A field read on the
|
|
684
|
+
* binding is either the failure field itself or a reported value; anything
|
|
685
|
+
* else — the response returned, passed, spread, aliased, rebound, or reached
|
|
686
|
+
* through a computed key — ends as `uncertain`, so no finding rests on it.
|
|
687
|
+
*/
|
|
688
|
+
function observeUses(source, errorField, holds) {
|
|
689
|
+
const syntax = source.syntax;
|
|
690
|
+
const fragment = source.fragment;
|
|
691
|
+
const reported = [];
|
|
692
|
+
if (!syntax || !fragment)
|
|
693
|
+
return { verdict: "uncertain", reported };
|
|
694
|
+
const wanted = new Set(holds.filter(hold => hold.kind === "name").map(hold => hold.name));
|
|
695
|
+
let errorRead = false;
|
|
696
|
+
let uncertain = "";
|
|
697
|
+
walkSyntax(syntax.definition, node => {
|
|
698
|
+
if (errorRead || uncertain !== "" || (node.type !== "identifier" && node.type !== "shorthand_property_identifier"))
|
|
699
|
+
return;
|
|
700
|
+
if (!wanted.has(node.text))
|
|
701
|
+
return;
|
|
702
|
+
const parent = node.parent;
|
|
703
|
+
if (!parent) {
|
|
704
|
+
uncertain = "the binding has no readable parent";
|
|
705
|
+
return;
|
|
706
|
+
}
|
|
707
|
+
if (parent.type === "variable_declarator" && sameNode(parent.childForFieldName("name"), node))
|
|
708
|
+
return;
|
|
709
|
+
if (Object.hasOwn(TYPED_DECLARATION_NODES, parent.type) &&
|
|
710
|
+
sameNode(parent.childForFieldName("pattern") ?? parent.childForFieldName("name"), node)) {
|
|
711
|
+
return;
|
|
712
|
+
}
|
|
713
|
+
if (parent.type === "member_expression") {
|
|
714
|
+
if (!sameNode(parent.childForFieldName("object"), node)) {
|
|
715
|
+
uncertain = "the binding names a property rather than the response";
|
|
716
|
+
return;
|
|
717
|
+
}
|
|
718
|
+
// `result.method()` passes the whole response as the receiver, so the
|
|
719
|
+
// body being read cannot tell what that method does with it.
|
|
720
|
+
if (parent.parent?.type === "call_expression" && sameNode(parent.parent.childForFieldName("function"), parent)) {
|
|
721
|
+
uncertain = `the response is passed whole to ${parent.text}`;
|
|
722
|
+
return;
|
|
723
|
+
}
|
|
724
|
+
const property = parent.childForFieldName("property");
|
|
725
|
+
if (!property || property.type !== "property_identifier") {
|
|
726
|
+
uncertain = "computed property access could read any field";
|
|
727
|
+
return;
|
|
728
|
+
}
|
|
729
|
+
if (property.text === errorField) {
|
|
730
|
+
errorRead = true;
|
|
731
|
+
return;
|
|
732
|
+
}
|
|
733
|
+
reported.push(reportedField(fragment, source, parent, property.text));
|
|
734
|
+
return;
|
|
735
|
+
}
|
|
736
|
+
if (parent.type === "subscript_expression") {
|
|
737
|
+
const index = parent.childForFieldName("index");
|
|
738
|
+
if (index && staticStringValue(index) === errorField) {
|
|
739
|
+
errorRead = true;
|
|
740
|
+
return;
|
|
741
|
+
}
|
|
742
|
+
uncertain = "element access with a non-literal key could read any field";
|
|
743
|
+
return;
|
|
744
|
+
}
|
|
745
|
+
if (parent.type === "variable_declarator" && sameNode(parent.childForFieldName("value"), node)) {
|
|
746
|
+
uncertain = `the response is aliased to ${parent.childForFieldName("name")?.text ?? "another name"}`;
|
|
747
|
+
return;
|
|
748
|
+
}
|
|
749
|
+
if (parent.type === "assignment_expression" && sameNode(parent.childForFieldName("left"), node)) {
|
|
750
|
+
uncertain = "the response is rebound to another name";
|
|
751
|
+
return;
|
|
752
|
+
}
|
|
753
|
+
uncertain = `the response is used as a whole value (${parent.type})`;
|
|
754
|
+
});
|
|
755
|
+
if (errorRead)
|
|
756
|
+
return { verdict: "error-read", reported };
|
|
757
|
+
if (uncertain !== "")
|
|
758
|
+
return { verdict: "uncertain", reported };
|
|
759
|
+
// A destructuring pattern reads exactly the fields it writes: the failure
|
|
760
|
+
// field is either named there, hidden behind a rest element, or not read.
|
|
761
|
+
for (const hold of holds) {
|
|
762
|
+
if (hold.kind !== "pattern")
|
|
763
|
+
continue;
|
|
764
|
+
if (hold.fields.includes(errorField))
|
|
765
|
+
return { verdict: "error-read", reported };
|
|
766
|
+
if (hold.rest)
|
|
767
|
+
return { verdict: "uncertain", reported };
|
|
768
|
+
for (const field of hold.fields) {
|
|
769
|
+
const usage = countUsageOf(fragment, source, syntax, field);
|
|
770
|
+
reported.push(usage
|
|
771
|
+
? { field, line: usage.line, statement: usage.statement, counted: true }
|
|
772
|
+
: {
|
|
773
|
+
field,
|
|
774
|
+
line: hold.line,
|
|
775
|
+
statement: `destructured from the response: { ${hold.fields.join(", ")} }`,
|
|
776
|
+
counted: COUNT_FIELD_NAMES.test(field),
|
|
777
|
+
});
|
|
778
|
+
}
|
|
779
|
+
}
|
|
780
|
+
return { verdict: "error-unread", reported };
|
|
781
|
+
}
|
|
782
|
+
/**
|
|
783
|
+
* Ways one receiver holds the response: the results of calls that resolved to a
|
|
784
|
+
* producer, and parameters, locals, or fields whose declared type names the
|
|
785
|
+
* contract. A result that is returned, passed, spread, aliased, or destructured
|
|
786
|
+
* by position yields no hold and records why in `forwarded`, because the value
|
|
787
|
+
* leaves the code this check can read.
|
|
788
|
+
*/
|
|
789
|
+
function responseHolds(input, source, context, sites, forwarded) {
|
|
790
|
+
const holds = [];
|
|
791
|
+
const syntax = source.syntax;
|
|
792
|
+
const fragment = source.fragment;
|
|
793
|
+
if (!syntax || !fragment)
|
|
794
|
+
return holds;
|
|
795
|
+
for (const producer of context.producers) {
|
|
796
|
+
const target = producer.line;
|
|
797
|
+
if (target === undefined)
|
|
798
|
+
continue;
|
|
799
|
+
const producerSyntax = definitionSource(input, "after", producer).syntax;
|
|
800
|
+
const requiresAwait = producerSyntax !== null && (producerSyntax.definition.children.some(child => child.type === "async") ||
|
|
801
|
+
/\bPromise\s*</u.test(producerSyntax.definition.childForFieldName("return_type")?.text ?? ""));
|
|
802
|
+
// A call whose callee name this receiver declares for itself is a call to
|
|
803
|
+
// that local value, not to the producer whose bare key it shares.
|
|
804
|
+
const shadowed = declaredValueNames(syntax.definition);
|
|
805
|
+
const calleeName = producer.key.slice(producer.key.lastIndexOf(".") + 1);
|
|
806
|
+
if (shadowed.has(calleeName))
|
|
807
|
+
continue;
|
|
808
|
+
// A global name match is not binding evidence; check each actual call site.
|
|
809
|
+
const resolved = sites.filter(node => node.line !== undefined &&
|
|
810
|
+
node.definition !== undefined &&
|
|
811
|
+
node.definition.file === producer.file &&
|
|
812
|
+
node.definition.line === target);
|
|
813
|
+
for (const site of resolved) {
|
|
814
|
+
const siteLine = site.line;
|
|
815
|
+
if (!producerCallProven(input, source, producer, site))
|
|
816
|
+
continue;
|
|
817
|
+
if (siteLine === undefined)
|
|
818
|
+
continue;
|
|
819
|
+
const call = callExpressionAt(syntax, fragment, source, siteLine, producer.key);
|
|
820
|
+
if (!call)
|
|
821
|
+
continue;
|
|
822
|
+
const arrival = responseArrival(call, requiresAwait);
|
|
823
|
+
const via = `resolved call to ${producer.key} at line ${siteLine}`;
|
|
824
|
+
if (arrival === null) {
|
|
825
|
+
forwarded.value = `a call to ${producer.key} at line ${siteLine} hands the response on instead of keeping it`;
|
|
826
|
+
continue;
|
|
827
|
+
}
|
|
828
|
+
if (arrival.kind === "positional") {
|
|
829
|
+
forwarded.value = `a call to ${producer.key} at line ${siteLine} is destructured by position`;
|
|
830
|
+
continue;
|
|
831
|
+
}
|
|
832
|
+
const line = absoluteLine(fragment, source, arrival.node.startPosition.row);
|
|
833
|
+
holds.push(arrival.kind === "name"
|
|
834
|
+
? { kind: "name", name: arrival.name, line, via }
|
|
835
|
+
: { kind: "pattern", fields: arrival.fields, rest: arrival.rest, line, via });
|
|
836
|
+
}
|
|
837
|
+
}
|
|
838
|
+
// A parameter typed by the contract is a response this check can read, but
|
|
839
|
+
// only when the name written there resolves to this very declaration; a
|
|
840
|
+
// same-named declaration elsewhere is a different type, not this response.
|
|
841
|
+
for (const binding of typedBindings(syntax.definition, context.contract.info.key)) {
|
|
842
|
+
if (!context.namers.has(source.info))
|
|
843
|
+
continue;
|
|
844
|
+
holds.push({
|
|
845
|
+
kind: "name",
|
|
846
|
+
name: binding.name,
|
|
847
|
+
line: absoluteLine(fragment, source, binding.row),
|
|
848
|
+
via: `declared as ${context.contract.info.key}`,
|
|
849
|
+
});
|
|
850
|
+
}
|
|
851
|
+
return holds;
|
|
852
|
+
}
|
|
853
|
+
/** Interface/type declarations in changed files, with their declared fields. */
|
|
854
|
+
function changedContracts(input, side) {
|
|
855
|
+
const index = side === "after" ? input.after : input.before;
|
|
856
|
+
if (!index)
|
|
857
|
+
return [];
|
|
858
|
+
const contracts = [];
|
|
859
|
+
for (const info of allContextDefinitions(index)) {
|
|
860
|
+
const kind = info.review?.kind;
|
|
861
|
+
if (kind !== "interface" && kind !== "type")
|
|
862
|
+
continue;
|
|
863
|
+
if (!input.changedFiles.has(info.file))
|
|
864
|
+
continue;
|
|
865
|
+
const fields = readContractFields(input, side, info);
|
|
866
|
+
if (fields)
|
|
867
|
+
contracts.push({ info, fields });
|
|
868
|
+
}
|
|
869
|
+
return contracts.sort((left, right) => byLocation(left.info, right.info));
|
|
870
|
+
}
|
|
871
|
+
function readContractFields(input, side, info) {
|
|
872
|
+
const source = definitionSource(input, side, info);
|
|
873
|
+
return source.fragment ? contractFields(source.fragment, info.key) : null;
|
|
874
|
+
}
|
|
875
|
+
/** Declared fields of a contract that has a failure field and a reported one. */
|
|
876
|
+
function responseContracts(input, infos) {
|
|
877
|
+
const contracts = [];
|
|
878
|
+
let unreadable = 0;
|
|
879
|
+
for (const info of infos) {
|
|
880
|
+
const fields = readContractFields(input, "after", info);
|
|
881
|
+
if (!fields) {
|
|
882
|
+
unreadable += 1;
|
|
883
|
+
continue;
|
|
884
|
+
}
|
|
885
|
+
const errorField = fields.find(field => ERROR_FIELD_NAMES.test(field.name));
|
|
886
|
+
if (!errorField || fields.length < 2)
|
|
887
|
+
continue;
|
|
888
|
+
contracts.push({ info, fields, errorField });
|
|
889
|
+
}
|
|
890
|
+
return { contracts, unreadable };
|
|
891
|
+
}
|
|
892
|
+
/** A test with a call bound to the actual producer, not merely the same member name. */
|
|
893
|
+
function supportingTest(input, candidates, target) {
|
|
894
|
+
if (!input.after)
|
|
895
|
+
return null;
|
|
896
|
+
for (const info of candidates) {
|
|
897
|
+
if (!testLikeFile(info.file))
|
|
898
|
+
continue;
|
|
899
|
+
const sites = buildCallSitesFromInfo(info, input.after);
|
|
900
|
+
if (!sites.some(site => site.definition?.file === target.file && site.definition.line === target.line &&
|
|
901
|
+
producerCallProven(input, definitionSource(input, "after", info), target, site)))
|
|
902
|
+
continue;
|
|
903
|
+
const evidence = definitionExcerpt(input, "after", info, "test", `test reference ${info.key} (static call, not executed coverage)`);
|
|
904
|
+
if (evidence)
|
|
905
|
+
return evidence;
|
|
906
|
+
}
|
|
907
|
+
return null;
|
|
908
|
+
}
|
|
909
|
+
/** The other side of one dispatch site, as the syntactic relation reports it. */
|
|
910
|
+
function dispatchCounterpart(input, info, edges, emit) {
|
|
911
|
+
for (const edge of edges) {
|
|
912
|
+
if (emit ? edge.owner !== info : edge.target !== info)
|
|
913
|
+
continue;
|
|
914
|
+
const other = emit ? edge.target : edge.owner;
|
|
915
|
+
const evidence = definitionExcerpt(input, "after", other, "related", `${emit ? "dispatch handler" : "publisher"} ${other.key} (syntactic relation, not proof of delivery)`);
|
|
916
|
+
if (evidence)
|
|
917
|
+
return evidence;
|
|
918
|
+
}
|
|
919
|
+
return null;
|
|
920
|
+
}
|
|
921
|
+
/**
|
|
922
|
+
* Unused failure results, typed-response scope: a declared response shape has a
|
|
923
|
+
* failure field and at least one reported field, a receiver in code this change
|
|
924
|
+
* touches holds the response, and the failure field is never read from it.
|
|
925
|
+
*/
|
|
926
|
+
function responseScan(input) {
|
|
927
|
+
const after = input.after;
|
|
928
|
+
const facts = factBucket();
|
|
929
|
+
if (!after) {
|
|
930
|
+
return { findings: [], check: coverage("unused-error-result", "not-checked", INDEX_REASON), facts, names: new Map() };
|
|
931
|
+
}
|
|
932
|
+
if (!input.sources.after) {
|
|
933
|
+
return { findings: [], check: coverage("unused-error-result", "not-checked", SOURCE_REASON), facts, names: new Map() };
|
|
934
|
+
}
|
|
935
|
+
const definitions = allContextDefinitions(after);
|
|
936
|
+
const contractEdges = resolveTypeContracts(definitions);
|
|
937
|
+
// A receiver is a changed definition whose readable body this check can walk;
|
|
938
|
+
// its resolved calls are what put a response in the change's scope at all.
|
|
939
|
+
const candidates = changedDefinitions(input);
|
|
940
|
+
// Definitions this change touches come first, so a cap can never push the
|
|
941
|
+
// only changed receiver out behind unchanged predecessors.
|
|
942
|
+
const touchedDefinitions = candidates.filter(info => intersectsChanged(input, info));
|
|
943
|
+
const receivers = [...touchedDefinitions, ...candidates.filter(info => !intersectsChanged(input, info))]
|
|
944
|
+
.slice(0, MAX_RECEIVER_DEFINITIONS);
|
|
945
|
+
const byLocationKey = new Map();
|
|
946
|
+
for (const info of definitions) {
|
|
947
|
+
if (info.line === undefined)
|
|
948
|
+
continue;
|
|
949
|
+
const key = `${info.file}\0${info.line}`;
|
|
950
|
+
if (!byLocationKey.has(key))
|
|
951
|
+
byLocationKey.set(key, info);
|
|
952
|
+
}
|
|
953
|
+
const changedSet = new Set(receivers);
|
|
954
|
+
const scoped = new Set();
|
|
955
|
+
for (const info of receivers) {
|
|
956
|
+
if (!intersectsChanged(input, info))
|
|
957
|
+
continue;
|
|
958
|
+
for (const site of buildCallSitesFromInfo(info, after)) {
|
|
959
|
+
const definition = site.definition;
|
|
960
|
+
if (!definition)
|
|
961
|
+
continue;
|
|
962
|
+
const callee = byLocationKey.get(`${definition.file}\0${definition.line}`);
|
|
963
|
+
if (callee)
|
|
964
|
+
scoped.add(callee);
|
|
965
|
+
}
|
|
966
|
+
}
|
|
967
|
+
// In scope: a shape this change declares, a shape a changed definition writes,
|
|
968
|
+
// and a shape returned by a definition that changed code calls. Nothing else —
|
|
969
|
+
// every contract anybody in the repository references would drown the check.
|
|
970
|
+
const named = new Map();
|
|
971
|
+
for (const info of definitions) {
|
|
972
|
+
const kind = info.review?.kind;
|
|
973
|
+
if (kind !== "interface" && kind !== "type")
|
|
974
|
+
continue;
|
|
975
|
+
const list = named.get(info.key);
|
|
976
|
+
if (list)
|
|
977
|
+
list.push(info);
|
|
978
|
+
else
|
|
979
|
+
named.set(info.key, [info]);
|
|
980
|
+
}
|
|
981
|
+
const relevant = new Map();
|
|
982
|
+
const admit = (info) => {
|
|
983
|
+
if (info.review?.kind === "interface" || info.review?.kind === "type")
|
|
984
|
+
relevant.set(info, true);
|
|
985
|
+
};
|
|
986
|
+
for (const info of definitions)
|
|
987
|
+
if (input.changedFiles.has(info.file))
|
|
988
|
+
admit(info);
|
|
989
|
+
for (const edge of contractEdges)
|
|
990
|
+
if (changedSet.has(edge.owner))
|
|
991
|
+
admit(edge.target);
|
|
992
|
+
for (const callee of scoped) {
|
|
993
|
+
for (const name of declaredResponseTypes(input, callee)) {
|
|
994
|
+
const candidatesNamed = named.get(name);
|
|
995
|
+
if (!candidatesNamed)
|
|
996
|
+
continue;
|
|
997
|
+
admit(candidatesNamed.find(info => info.file === callee.file) ?? candidatesNamed[0]);
|
|
998
|
+
}
|
|
999
|
+
}
|
|
1000
|
+
const inScope = [...relevant.keys()].sort(byLocation);
|
|
1001
|
+
const { contracts, unreadable } = responseContracts(input, inScope);
|
|
1002
|
+
const contexts = contracts.map(contract => ({
|
|
1003
|
+
contract,
|
|
1004
|
+
producers: [
|
|
1005
|
+
...new Set(contractEdges
|
|
1006
|
+
.filter(edge => edge.target === contract.info)
|
|
1007
|
+
.map(edge => edge.owner)
|
|
1008
|
+
.filter(owner => declaredResponseTypes(input, owner).includes(contract.info.key))),
|
|
1009
|
+
].sort(byLocation),
|
|
1010
|
+
namers: new Set(contractEdges.filter(edge => edge.target === contract.info).map(edge => edge.owner)),
|
|
1011
|
+
}));
|
|
1012
|
+
const findings = [];
|
|
1013
|
+
const names = new Map();
|
|
1014
|
+
let examined = 0;
|
|
1015
|
+
let skipped = 0;
|
|
1016
|
+
let forwardedCount = 0;
|
|
1017
|
+
let uncertainCount = 0;
|
|
1018
|
+
for (const info of receivers) {
|
|
1019
|
+
if (!intersectsChanged(input, info))
|
|
1020
|
+
continue;
|
|
1021
|
+
const source = definitionSource(input, "after", info);
|
|
1022
|
+
if (source.text === null || !source.syntax || !source.fragment) {
|
|
1023
|
+
skipped += 1;
|
|
1024
|
+
continue;
|
|
1025
|
+
}
|
|
1026
|
+
examined += 1;
|
|
1027
|
+
const sites = buildCallSitesFromInfo(info, after);
|
|
1028
|
+
for (const context of contexts) {
|
|
1029
|
+
const forwarded = { value: "" };
|
|
1030
|
+
const holds = responseHolds(input, source, context, sites, forwarded);
|
|
1031
|
+
if (forwarded.value !== "") {
|
|
1032
|
+
forwardedCount += 1;
|
|
1033
|
+
continue;
|
|
1034
|
+
}
|
|
1035
|
+
if (holds.length === 0)
|
|
1036
|
+
continue;
|
|
1037
|
+
const observation = observeUses(source, context.contract.errorField.name, holds);
|
|
1038
|
+
if (observation.verdict === "uncertain") {
|
|
1039
|
+
uncertainCount += 1;
|
|
1040
|
+
continue;
|
|
1041
|
+
}
|
|
1042
|
+
if (observation.verdict === "error-read")
|
|
1043
|
+
continue;
|
|
1044
|
+
const contractEvidence = definitionExcerpt(input, "after", context.contract.info, "contract", `declared shape ${context.contract.info.key}`);
|
|
1045
|
+
const receiverEvidence = definitionExcerpt(input, "after", info, "change", `receiver ${info.key}`);
|
|
1046
|
+
// The successful result the receiver did read, then the count it reports:
|
|
1047
|
+
// together with the contract, these are the values a partial-failure
|
|
1048
|
+
// decision has to weigh against the failure field.
|
|
1049
|
+
const successes = observation.reported.filter(entry => !entry.counted).slice(0, 1);
|
|
1050
|
+
const counts = observation.reported.filter(entry => entry.counted).slice(0, 1);
|
|
1051
|
+
// The count handed back to the caller is what the caller receives, so it
|
|
1052
|
+
// leads any count read for a log line.
|
|
1053
|
+
const returned = reportedCount(source);
|
|
1054
|
+
const unitIds = unitsTouching(input, info);
|
|
1055
|
+
const callers = callerEvidence(input, unitIds, info);
|
|
1056
|
+
const reported = [
|
|
1057
|
+
...successes.map(entry => ({ label: `read result field ${entry.field} at line ${entry.line}`, entry })),
|
|
1058
|
+
...(returned
|
|
1059
|
+
? [{
|
|
1060
|
+
label: `${returned.ofReturn ? "returned" : "reported"} count at line ${returned.line}`,
|
|
1061
|
+
entry: returned,
|
|
1062
|
+
}]
|
|
1063
|
+
: counts.map(entry => ({ label: `reported count ${entry.field} at line ${entry.line}`, entry }))),
|
|
1064
|
+
].map(entry => excerpt({
|
|
1065
|
+
role: "related",
|
|
1066
|
+
label: entry.label,
|
|
1067
|
+
file: info.file,
|
|
1068
|
+
line: entry.entry.line,
|
|
1069
|
+
ref: input.headRef,
|
|
1070
|
+
text: entry.entry.statement,
|
|
1071
|
+
}));
|
|
1072
|
+
const finding = {
|
|
1073
|
+
id: `unused-error-result:${info.file}:${info.line ?? 1}:${context.contract.info.key}.${context.contract.errorField.name}`,
|
|
1074
|
+
kind: "unused-error-result",
|
|
1075
|
+
title: `Unread ${context.contract.errorField.name} field: ${info.label} holds ${context.contract.info.key} without reading it`,
|
|
1076
|
+
scope: `${formatSourceLoc(location(info))} in the resulting snapshot holds it as ` +
|
|
1077
|
+
holds
|
|
1078
|
+
.map(hold => hold.kind === "name"
|
|
1079
|
+
? `\`${hold.name}\` (${hold.via})`
|
|
1080
|
+
: `fields { ${hold.fields.join(", ")} } (${hold.via}${hold.rest ? ", with a rest element" : ""})`)
|
|
1081
|
+
.join(", ") +
|
|
1082
|
+
`. The declared shape ${context.contract.info.key} @ ` +
|
|
1083
|
+
`${formatSourceLoc(location(context.contract.info))} contains ${describeFields(context.contract.fields)}.`,
|
|
1084
|
+
limitation: `This establishes that this receiver's readable body never reads ${context.contract.errorField.name} ` +
|
|
1085
|
+
"from that response and never hands the whole response on. It does not establish that any failure " +
|
|
1086
|
+
"occurred, that failures are mishandled, or that another consumer is missing. Receivers that return, " +
|
|
1087
|
+
"pass, spread, alias, or positionally destructure the response are excluded rather than alleged.",
|
|
1088
|
+
unitIds: unitsTouching(input, info),
|
|
1089
|
+
evidence: selectEvidence([
|
|
1090
|
+
...(contractEvidence ? [contractEvidence] : []),
|
|
1091
|
+
...(receiverEvidence ? [receiverEvidence] : []),
|
|
1092
|
+
...callers,
|
|
1093
|
+
...reported,
|
|
1094
|
+
]),
|
|
1095
|
+
};
|
|
1096
|
+
findings.push(finding);
|
|
1097
|
+
names.set(finding.id, {
|
|
1098
|
+
primary: new Set([`after:${info.key}`]),
|
|
1099
|
+
contracts: new Set([`after:${context.contract.info.key}`]),
|
|
1100
|
+
});
|
|
1101
|
+
facts.primary.add(`after:${info.key}`);
|
|
1102
|
+
facts.contracts.add(`after:${context.contract.info.key}`);
|
|
1103
|
+
facts.findingIds.add(finding.id);
|
|
1104
|
+
for (const unitId of finding.unitIds)
|
|
1105
|
+
facts.unitIds.add(unitId);
|
|
1106
|
+
if (contractEvidence)
|
|
1107
|
+
facts.contract.push(contractEvidence);
|
|
1108
|
+
if (receiverEvidence)
|
|
1109
|
+
facts.change.push(receiverEvidence);
|
|
1110
|
+
facts.related.push(...reported);
|
|
1111
|
+
const readFields = observation.reported.length > 0
|
|
1112
|
+
? [...new Set(observation.reported.map(entry => entry.field))].join(", ")
|
|
1113
|
+
: "no field of it";
|
|
1114
|
+
facts.sentences.push(`Changed definition ${info.key} holds ${context.contract.info.key} and reads ${readFields}, ` +
|
|
1115
|
+
`but never ${context.contract.errorField.name}.`);
|
|
1116
|
+
// The receiver's own callers are what a reader needs beside it.
|
|
1117
|
+
facts.caller.push(...callers);
|
|
1118
|
+
}
|
|
1119
|
+
}
|
|
1120
|
+
// Even without a finding, a shape that carries failures into changed code is a
|
|
1121
|
+
// question: the change may add the first caller, or the first success report.
|
|
1122
|
+
for (const context of contexts) {
|
|
1123
|
+
const fields = describeFields(context.contract.fields);
|
|
1124
|
+
const participants = [context.contract.info, ...context.producers];
|
|
1125
|
+
const touched = participants.flatMap(entry => unitsTouching(input, entry).map(unitId => ({ info: entry, unitId })));
|
|
1126
|
+
if (touched.length === 0)
|
|
1127
|
+
continue;
|
|
1128
|
+
for (const { unitId } of touched)
|
|
1129
|
+
facts.unitIds.add(unitId);
|
|
1130
|
+
for (const info of participants) {
|
|
1131
|
+
const role = info === context.contract.info ? "contract" : "change";
|
|
1132
|
+
const evidence = definitionExcerpt(input, "after", info, role, info === context.contract.info ? `declared shape ${info.key}` : `producer ${info.key}`);
|
|
1133
|
+
if (!evidence)
|
|
1134
|
+
continue;
|
|
1135
|
+
if (role === "contract")
|
|
1136
|
+
facts.contract.push(evidence);
|
|
1137
|
+
else
|
|
1138
|
+
facts.change.push(evidence);
|
|
1139
|
+
}
|
|
1140
|
+
facts.sentences.push(`Response shape ${context.contract.info.key} (${fields}) reaches changed code; ` +
|
|
1141
|
+
`${context.producers.length === 0 ? "no changed definition was resolved as its producer" : `it is produced by ${context.producers.map(producer => producer.key).join(", ")}`}.`);
|
|
1142
|
+
}
|
|
1143
|
+
const untyped = [...input.changedFiles].filter(file => {
|
|
1144
|
+
const id = detectLanguage(file)?.id;
|
|
1145
|
+
return id !== undefined && !Object.hasOwn(TYPED_RESPONSE_LANGUAGES, id);
|
|
1146
|
+
}).length;
|
|
1147
|
+
return {
|
|
1148
|
+
findings,
|
|
1149
|
+
facts,
|
|
1150
|
+
names,
|
|
1151
|
+
check: coverage("unused-error-result", skipped > 0 || unreadable > 0 || receivers.length < candidates.length ? "partial" : "checked", [
|
|
1152
|
+
`${contracts.length} declared response shapes with a failure field and another reported field were in scope ` +
|
|
1153
|
+
`for this change (${inScope.length} interface/type declarations: those in changed files, those named by a changed ` +
|
|
1154
|
+
`definition, and those returned by a definition a changed definition calls).`,
|
|
1155
|
+
`${examined} changed definitions were examined as receivers (the ${Math.min(receivers.length, MAX_RECEIVER_DEFINITIONS)} most relevant of ${candidates.length} callable definitions in the changed files are considered, limit ${MAX_RECEIVER_DEFINITIONS}); ${skipped} were skipped because their source span or fragment was unreadable, and ${unreadable} of those declarations could not be read at all.`,
|
|
1156
|
+
`${forwardedCount} receivers hand the whole response on and ${uncertainCount} use it in a way this check cannot follow; both are excluded rather than reported.`,
|
|
1157
|
+
`${findings.length} receivers were found holding the response without reading its failure field (report limit ${MAX_FINDINGS_PER_KIND}).`,
|
|
1158
|
+
`${untyped} changed files are in another language, and files with no parser at all are not counted, ` +
|
|
1159
|
+
"because this check reads only TypeScript interfaces and type aliases. Inside TypeScript, inline object " +
|
|
1160
|
+
"literals and shapes reached only through `extends` are outside it too. Calls need lexical or typed-receiver " +
|
|
1161
|
+
"bindings and resolved local imports. Alias resolution supports standalone JSON tsconfig files with " +
|
|
1162
|
+
"single-target paths; inherited/JSONC configs, package entry points and unresolved bindings remain unproven.",
|
|
1163
|
+
].join(" ")),
|
|
1164
|
+
};
|
|
1165
|
+
}
|
|
1166
|
+
/**
|
|
1167
|
+
* The count a receiver reports to its own caller: the first count-named read
|
|
1168
|
+
* inside a `return`, and only when the body returns no count, the last
|
|
1169
|
+
* count-named read anywhere (a summary log). A returned count is what a caller
|
|
1170
|
+
* receives, so it is the number a partial-failure question weighs against the
|
|
1171
|
+
* failure field; the statement is carried verbatim either way.
|
|
1172
|
+
*/
|
|
1173
|
+
function reportedCount(source) {
|
|
1174
|
+
const syntax = source.syntax;
|
|
1175
|
+
const fragment = source.fragment;
|
|
1176
|
+
if (!syntax || !fragment)
|
|
1177
|
+
return null;
|
|
1178
|
+
const reads = countReads(syntax.definition);
|
|
1179
|
+
const inside = returnedCountRead(syntax.definition);
|
|
1180
|
+
const read = inside ?? reads[reads.length - 1];
|
|
1181
|
+
if (!read)
|
|
1182
|
+
return null;
|
|
1183
|
+
return {
|
|
1184
|
+
line: absoluteLine(fragment, source, read.read.startPosition.row),
|
|
1185
|
+
statement: enclosingStatement(read.read),
|
|
1186
|
+
ofReturn: inside !== null,
|
|
1187
|
+
};
|
|
1188
|
+
}
|
|
1189
|
+
function describeFields(fields) {
|
|
1190
|
+
return fields.map(field => `${field.name}: ${field.type}`).join(", ");
|
|
1191
|
+
}
|
|
1192
|
+
/* ------------------------------------------------------- contract changes */
|
|
1193
|
+
/** One declaration's declared fields, compared across the two snapshots. */
|
|
1194
|
+
function compareContractDeclaration(input, facts, declaration, prior, edges, removed) {
|
|
1195
|
+
const sentence = removed
|
|
1196
|
+
? `Response shape ${declaration.info.key}, declaring ${describeFields(declaration.fields)}, is gone from this change.`
|
|
1197
|
+
: prior
|
|
1198
|
+
? `Response shape ${declaration.info.key} changes its declared fields: ${describeFieldChanges(declaration.fields, prior.fields)}.`
|
|
1199
|
+
: `Response shape ${declaration.info.key} is added by this change, declaring ${describeFields(declaration.fields)}.`;
|
|
1200
|
+
facts.sentences.push(sentence);
|
|
1201
|
+
const role = removed ? "related" : "change";
|
|
1202
|
+
const current = definitionExcerpt(input, removed ? "before" : "after", declaration.info, role, removed ? `removed shape ${declaration.info.key}` : `declared shape ${declaration.info.key}`);
|
|
1203
|
+
if (current)
|
|
1204
|
+
(removed ? facts.related : facts.change).push(current);
|
|
1205
|
+
if (prior && !removed) {
|
|
1206
|
+
const before = definitionExcerpt(input, "before", prior.info, "related", `prior declared shape ${prior.info.key}`);
|
|
1207
|
+
if (before)
|
|
1208
|
+
facts.related.push(before);
|
|
1209
|
+
}
|
|
1210
|
+
// The type-contract relation is a written type reference, not proof that any
|
|
1211
|
+
// value is constructed, assigned, or returned here, so it is reported as a
|
|
1212
|
+
// reference and never as a write.
|
|
1213
|
+
const consumers = [
|
|
1214
|
+
...new Set(edges
|
|
1215
|
+
.filter(edge => edge.target === declaration.info && input.changedFiles.has(edge.owner.file))
|
|
1216
|
+
.map(edge => edge.owner)),
|
|
1217
|
+
].sort(byLocation);
|
|
1218
|
+
for (const unitId of unitsTouching(input, declaration.info))
|
|
1219
|
+
facts.unitIds.add(unitId);
|
|
1220
|
+
if (consumers.length === 0) {
|
|
1221
|
+
if (declaration.info.exported) {
|
|
1222
|
+
facts.sentences.push(`No changed definition names ${declaration.info.key} in its source; consumers outside this change may still do so.`);
|
|
1223
|
+
}
|
|
1224
|
+
return;
|
|
1225
|
+
}
|
|
1226
|
+
facts.sentences.push(`${consumers.length} changed definition${consumers.length === 1 ? "" : "s"} name ${declaration.info.key} ` +
|
|
1227
|
+
`(syntactic type reference, not a runtime write): ` +
|
|
1228
|
+
`${consumers.slice(0, 3).map(consumer => consumer.key).join(", ")}.`);
|
|
1229
|
+
for (const consumer of consumers.slice(0, 2)) {
|
|
1230
|
+
const evidence = definitionExcerpt(input, "after", consumer, "caller", `changed definition ${consumer.key} names ${declaration.info.key}`);
|
|
1231
|
+
if (evidence)
|
|
1232
|
+
facts.caller.push(evidence);
|
|
1233
|
+
for (const unitId of unitsTouching(input, consumer))
|
|
1234
|
+
facts.unitIds.add(unitId);
|
|
1235
|
+
}
|
|
1236
|
+
}
|
|
1237
|
+
function describeFieldChanges(current, prior) {
|
|
1238
|
+
const added = current.filter(field => !prior.some(before => before.name === field.name)).map(field => field.name);
|
|
1239
|
+
const removed = prior.filter(before => !current.some(field => field.name === before.name)).map(before => before.name);
|
|
1240
|
+
return [
|
|
1241
|
+
added.length > 0 ? `added ${added.join(", ")}` : "",
|
|
1242
|
+
removed.length > 0 ? `removed ${removed.join(", ")}` : "",
|
|
1243
|
+
]
|
|
1244
|
+
.filter(Boolean)
|
|
1245
|
+
.join("; ");
|
|
1246
|
+
}
|
|
1247
|
+
/**
|
|
1248
|
+
* Declared contracts this change adds, removes, or edits, with the changed
|
|
1249
|
+
* definitions that write them. A declaration whose fields did not change is not
|
|
1250
|
+
* listed: the question here is what a consumer must now match.
|
|
1251
|
+
*/
|
|
1252
|
+
function contractChangeScan(input) {
|
|
1253
|
+
const facts = factBucket();
|
|
1254
|
+
if (!input.after || !input.before)
|
|
1255
|
+
return facts;
|
|
1256
|
+
const after = changedContracts(input, "after");
|
|
1257
|
+
const before = changedContracts(input, "before");
|
|
1258
|
+
const beforeByKey = new Map(before.map(declaration => [declarationKey(declaration), declaration]));
|
|
1259
|
+
const afterKeys = new Set(after.map(declarationKey));
|
|
1260
|
+
const edges = resolveTypeContracts(allContextDefinitions(input.after));
|
|
1261
|
+
for (const declaration of after) {
|
|
1262
|
+
if (!intersectsChanged(input, declaration.info))
|
|
1263
|
+
continue;
|
|
1264
|
+
const prior = beforeByKey.get(declarationKey(declaration)) ?? null;
|
|
1265
|
+
if (prior && !declaredFieldsDiffer(declaration, prior))
|
|
1266
|
+
continue;
|
|
1267
|
+
compareContractDeclaration(input, facts, declaration, prior, edges, false);
|
|
1268
|
+
}
|
|
1269
|
+
for (const declaration of before) {
|
|
1270
|
+
if (afterKeys.has(declarationKey(declaration)))
|
|
1271
|
+
continue;
|
|
1272
|
+
if (!intersectsChanged(input, declaration.info))
|
|
1273
|
+
continue;
|
|
1274
|
+
compareContractDeclaration(input, facts, declaration, null, edges, true);
|
|
1275
|
+
}
|
|
1276
|
+
return facts;
|
|
1277
|
+
}
|
|
1278
|
+
/** Snapshot-local identity of one declaration: file plus symbol name. */
|
|
1279
|
+
function declarationKey(declaration) {
|
|
1280
|
+
return `${declaration.info.file}\0${declaration.info.key}`;
|
|
1281
|
+
}
|
|
1282
|
+
/** Whether two snapshots' declarations of one symbol list different fields. */
|
|
1283
|
+
function declaredFieldsDiffer(current, prior) {
|
|
1284
|
+
return (current.fields.some(field => !prior.fields.some(before => before.name === field.name)) ||
|
|
1285
|
+
prior.fields.some(before => !current.fields.some(field => field.name === before.name)));
|
|
1286
|
+
}
|
|
1287
|
+
/**
|
|
1288
|
+
* Whether a call name writes state: one of the first two camel-case words of
|
|
1289
|
+
* its last segment is a word from {@link WRITE_VERBS}. So `updateLease`,
|
|
1290
|
+
* `bulkCreate`, `createOrUpdate`, and `upsertResident` write, while
|
|
1291
|
+
* `logger.log`, `queue.add`, and `findByTenantId` do not. The second word is
|
|
1292
|
+
* read because a modifier commonly leads the verb (`bulkCreate`).
|
|
1293
|
+
*/
|
|
1294
|
+
function isWriteCall(name) {
|
|
1295
|
+
const segment = name.slice(name.lastIndexOf(".") + 1).replace(/\(.*$/, "");
|
|
1296
|
+
if (segment === "" || segment === "add")
|
|
1297
|
+
return false;
|
|
1298
|
+
const words = segment.match(/^[a-z][a-z0-9]*|[A-Z]+(?![a-z])|[A-Z][a-z0-9]*/g) ?? [segment];
|
|
1299
|
+
return words.slice(0, 2).some(word => Object.hasOwn(WRITE_VERBS, word.toLowerCase()));
|
|
1300
|
+
}
|
|
1301
|
+
/**
|
|
1302
|
+
* Lifecycle/state values this change writes at an external boundary: an emit
|
|
1303
|
+
* site, a call whose name writes state, or a state-named field written in the
|
|
1304
|
+
* body, together with the constants the write carries. The entry is a question,
|
|
1305
|
+
* so a write whose constants live behind the callee still surfaces as "the
|
|
1306
|
+
* values written here are not visible in this change".
|
|
1307
|
+
*/
|
|
1308
|
+
function lifecycleScan(input) {
|
|
1309
|
+
const facts = factBucket();
|
|
1310
|
+
const after = input.after;
|
|
1311
|
+
if (!after)
|
|
1312
|
+
return facts;
|
|
1313
|
+
const definitions = allContextDefinitions(after);
|
|
1314
|
+
const enums = definitions.filter(info => info.review?.kind === "enum");
|
|
1315
|
+
const dispatch = resolveDispatchContext(definitions).map(edge => ({
|
|
1316
|
+
owner: edge.owner,
|
|
1317
|
+
target: edge.target,
|
|
1318
|
+
}));
|
|
1319
|
+
const tests = new Map();
|
|
1320
|
+
const answered = new Set();
|
|
1321
|
+
for (const { unit, lines } of input.changes) {
|
|
1322
|
+
if (lines.length === 0)
|
|
1323
|
+
continue;
|
|
1324
|
+
const inFile = definitions.filter(info => !info.review?.kind && info.file === unit.file).sort(byLocation);
|
|
1325
|
+
for (const info of hunkDefinitions(inFile, lines)) {
|
|
1326
|
+
// A definition spanning several hunks is one question, asked once.
|
|
1327
|
+
if (answered.has(`${info.file}\0${info.key}\0${info.line}`))
|
|
1328
|
+
continue;
|
|
1329
|
+
answered.add(`${info.file}\0${info.key}\0${info.line}`);
|
|
1330
|
+
const source = definitionSource(input, "after", info);
|
|
1331
|
+
// Without a parsed body there is no site to read state from, so this
|
|
1332
|
+
// definition asks no question rather than a question about nothing.
|
|
1333
|
+
const syntax = source.syntax;
|
|
1334
|
+
const fragment = source.fragment;
|
|
1335
|
+
if (!syntax || !fragment)
|
|
1336
|
+
continue;
|
|
1337
|
+
// State is read from each write call's own argument AST in the definition's
|
|
1338
|
+
// source, not from the capped argument text and not from a routing key: a
|
|
1339
|
+
// queue job name is a routing value even though it is a constant.
|
|
1340
|
+
const writes = callsIn(syntax.definition)
|
|
1341
|
+
.map(call => ({
|
|
1342
|
+
call,
|
|
1343
|
+
callee: call.childForFieldName("function")?.text ?? "",
|
|
1344
|
+
}))
|
|
1345
|
+
.filter(({ callee }) => isWriteCall(callee))
|
|
1346
|
+
.map(({ call, callee }) => ({
|
|
1347
|
+
line: absoluteLine(fragment, source, call.startPosition.row),
|
|
1348
|
+
endLine: absoluteLine(fragment, source, call.endPosition.row),
|
|
1349
|
+
source: call.text,
|
|
1350
|
+
text: collapseWs(callee),
|
|
1351
|
+
tokens: stateWritesAt(call),
|
|
1352
|
+
}));
|
|
1353
|
+
const emits = (info.review?.dispatches ?? []).filter(record => record.direction === "emit");
|
|
1354
|
+
if (writes.length === 0 && emits.length === 0)
|
|
1355
|
+
continue;
|
|
1356
|
+
// A dispatched event is a boundary write too. Its payload properties carry
|
|
1357
|
+
// state the same way a call's arguments do, so the emit's own AST is read.
|
|
1358
|
+
const emitSites = emits.map(record => {
|
|
1359
|
+
const call = callsIn(syntax.definition).find(candidate => absoluteLine(fragment, source, candidate.startPosition.row) === record.line);
|
|
1360
|
+
return {
|
|
1361
|
+
line: record.line,
|
|
1362
|
+
endLine: call ? absoluteLine(fragment, source, call.endPosition.row) : record.line,
|
|
1363
|
+
source: call?.text ?? record.evidence,
|
|
1364
|
+
text: record.evidence,
|
|
1365
|
+
tokens: call ? stateWritesAt(call) : [],
|
|
1366
|
+
};
|
|
1367
|
+
});
|
|
1368
|
+
const sites = [...writes, ...emitSites];
|
|
1369
|
+
const stateSite = sites.find(entry => entry.tokens.length > 0);
|
|
1370
|
+
if (!stateSite)
|
|
1371
|
+
continue;
|
|
1372
|
+
const names = new Set(stateSite.tokens.map(token => /^[\w$]+:\s*([\w$]+)\.[\w$]+$/u.exec(token)?.[1]));
|
|
1373
|
+
const shadowed = declaredValueNames(syntax.definition);
|
|
1374
|
+
const constants = enums.find(candidate => {
|
|
1375
|
+
if (!names.has(candidate.key) || shadowed.has(candidate.key))
|
|
1376
|
+
return false;
|
|
1377
|
+
if (candidate.file === info.file)
|
|
1378
|
+
return true;
|
|
1379
|
+
const specifier = importsOf(input, source)?.get(candidate.key);
|
|
1380
|
+
return specifier !== undefined && input.resolveImport(info.file, specifier) === candidate.file;
|
|
1381
|
+
});
|
|
1382
|
+
// The site that carries state leads: a queue routing key written earlier
|
|
1383
|
+
// must not hide a later status assignment, and its tokens belong to it.
|
|
1384
|
+
const site = stateSite;
|
|
1385
|
+
const tokens = site.tokens;
|
|
1386
|
+
facts.unitIds.add(unit.id);
|
|
1387
|
+
facts.primary.add(`after:${info.key}`);
|
|
1388
|
+
facts.change.push(excerpt({
|
|
1389
|
+
role: "change",
|
|
1390
|
+
label: `state/status call in ${info.key} at line ${site.line}`,
|
|
1391
|
+
file: info.file,
|
|
1392
|
+
line: site.line,
|
|
1393
|
+
endLine: site.endLine,
|
|
1394
|
+
ref: input.headRef,
|
|
1395
|
+
text: site.source,
|
|
1396
|
+
}));
|
|
1397
|
+
if (constants) {
|
|
1398
|
+
const evidence = definitionExcerpt(input, "after", constants, "contract", `lifecycle constants ${constants.key}`);
|
|
1399
|
+
// One constants excerpt per entry; a second enum declaration adds no
|
|
1400
|
+
// value beside the write it belongs to.
|
|
1401
|
+
if (evidence && !facts.contract.some(kept => kept.id === evidence.id))
|
|
1402
|
+
facts.contract.push(evidence);
|
|
1403
|
+
}
|
|
1404
|
+
const counterpart = dispatchCounterpart(input, info, dispatch, emits.length > 0);
|
|
1405
|
+
if (counterpart)
|
|
1406
|
+
facts.related.push(counterpart);
|
|
1407
|
+
facts.caller.push(...callerEvidence(input, [unit.id], info));
|
|
1408
|
+
if (!tests.has(info.key))
|
|
1409
|
+
tests.set(info.key, supportingTest(input, definitions, info));
|
|
1410
|
+
const test = tests.get(info.key);
|
|
1411
|
+
if (test)
|
|
1412
|
+
facts.test.push(test);
|
|
1413
|
+
facts.sentences.push(`Changed definition ${info.key} passes state/status values to a write-shaped call (${site.text}); ` +
|
|
1414
|
+
(tokens.length > 0
|
|
1415
|
+
? `lifecycle/state values written here: ${tokens.join(", ")}.`
|
|
1416
|
+
: `it carries the lifecycle constants of ${constants?.key ?? "a declared enum"}, none of which are written at this site.`));
|
|
1417
|
+
}
|
|
1418
|
+
}
|
|
1419
|
+
return facts;
|
|
1420
|
+
}
|
|
1421
|
+
function factBucket() {
|
|
1422
|
+
return {
|
|
1423
|
+
sentences: [],
|
|
1424
|
+
unitIds: new Set(),
|
|
1425
|
+
findingIds: new Set(),
|
|
1426
|
+
primary: new Set(),
|
|
1427
|
+
contracts: new Set(),
|
|
1428
|
+
contract: [],
|
|
1429
|
+
change: [],
|
|
1430
|
+
related: [],
|
|
1431
|
+
caller: [],
|
|
1432
|
+
test: [],
|
|
1433
|
+
};
|
|
1434
|
+
}
|
|
1435
|
+
function agendaEntry(input, facts, title, key) {
|
|
1436
|
+
if (facts.sentences.length === 0)
|
|
1437
|
+
return null;
|
|
1438
|
+
return {
|
|
1439
|
+
id: `agenda:${key}`,
|
|
1440
|
+
title,
|
|
1441
|
+
reason: factsText(facts.sentences),
|
|
1442
|
+
priority: 0,
|
|
1443
|
+
unitIds: [...facts.unitIds].sort(),
|
|
1444
|
+
findingIds: [...facts.findingIds].sort(),
|
|
1445
|
+
evidence: selectEvidence([
|
|
1446
|
+
...facts.contract,
|
|
1447
|
+
...facts.change,
|
|
1448
|
+
...facts.related,
|
|
1449
|
+
...facts.caller,
|
|
1450
|
+
...facts.test,
|
|
1451
|
+
]),
|
|
1452
|
+
context: selectContext(input, facts.unitIds, facts.primary, facts.contracts, facts.caller),
|
|
1453
|
+
};
|
|
1454
|
+
}
|
|
1455
|
+
/** One finding's agenda entry, with the nodes that finding is about beside it. */
|
|
1456
|
+
function findingEntry(input, finding, subject) {
|
|
1457
|
+
return {
|
|
1458
|
+
id: `agenda:finding:${finding.id}`,
|
|
1459
|
+
title: `Check before concluding: ${finding.title}`,
|
|
1460
|
+
reason: `${finding.scope} ${finding.limitation}`,
|
|
1461
|
+
priority: 0,
|
|
1462
|
+
unitIds: finding.unitIds,
|
|
1463
|
+
findingIds: [finding.id],
|
|
1464
|
+
evidence: selectEvidence(finding.evidence),
|
|
1465
|
+
context: selectContext(input, new Set(finding.unitIds), subject.primary, subject.contracts, finding.evidence.filter(entry => entry.role === "caller")),
|
|
1466
|
+
};
|
|
1467
|
+
}
|
|
1468
|
+
/** A late entry: which automatic checks did not run, and why not. */
|
|
1469
|
+
function coverageEntry(checks) {
|
|
1470
|
+
const open = checks.filter(check => check.status !== "checked");
|
|
1471
|
+
if (open.length === 0)
|
|
1472
|
+
return null;
|
|
1473
|
+
return {
|
|
1474
|
+
id: "agenda:check-scope",
|
|
1475
|
+
title: "Which automatic checks did not run, and what stays unverified?",
|
|
1476
|
+
reason: `${open.map(check => `${check.kind}: ${check.status}`).join("; ")}. Read “Checks and their limits” for exact scope; an unrun check is not a pass.`,
|
|
1477
|
+
priority: 0,
|
|
1478
|
+
unitIds: [],
|
|
1479
|
+
findingIds: [],
|
|
1480
|
+
evidence: [],
|
|
1481
|
+
context: [],
|
|
1482
|
+
};
|
|
1483
|
+
}
|
|
1484
|
+
/**
|
|
1485
|
+
* Hunks no structural question reached, grouped per file: the agenda never
|
|
1486
|
+
* drops a change, so an unevaluated, metadata-only, or unremarkable hunk stays
|
|
1487
|
+
* addressable with its own diff.
|
|
1488
|
+
*/
|
|
1489
|
+
function hunkCoverageEntries(input, covered) {
|
|
1490
|
+
const byFile = new Map();
|
|
1491
|
+
for (const change of input.changes) {
|
|
1492
|
+
if (covered.has(change.unit.id))
|
|
1493
|
+
continue;
|
|
1494
|
+
const existing = byFile.get(change.unit.file);
|
|
1495
|
+
if (existing)
|
|
1496
|
+
existing.push(change);
|
|
1497
|
+
else
|
|
1498
|
+
byFile.set(change.unit.file, [change]);
|
|
1499
|
+
}
|
|
1500
|
+
const entries = [];
|
|
1501
|
+
for (const [file, changes] of byFile) {
|
|
1502
|
+
entries.push({
|
|
1503
|
+
id: `agenda:hunks:${file}`,
|
|
1504
|
+
title: `Read the remaining changes in ${file}`,
|
|
1505
|
+
reason: `${changes.length} changed hunk${changes.length === 1 ? "" : "s"} in this file ` +
|
|
1506
|
+
`${changes.length === 1 ? "is" : "are"} not covered by a structural question above: ` +
|
|
1507
|
+
changes
|
|
1508
|
+
.map(change => change.unit.special
|
|
1509
|
+
? `metadata-only (${change.unit.special})`
|
|
1510
|
+
: `${change.unit.header.split(" @@")[0]} @@ (added ${change.unit.added}, removed ${change.unit.removed})`)
|
|
1511
|
+
.join("; ") +
|
|
1512
|
+
". The report's own diff for these hunks is the full text; the excerpts here identify them.",
|
|
1513
|
+
priority: 0,
|
|
1514
|
+
unitIds: changes.map(change => change.unit.id),
|
|
1515
|
+
findingIds: [],
|
|
1516
|
+
evidence: selectEvidence(changes.map(change => unitExcerpt(input, change.unit)), MAX_HUNK_EXCERPTS),
|
|
1517
|
+
context: selectContext(input, new Set(changes.map(change => change.unit.id)), EMPTY_SUBJECT.primary, EMPTY_SUBJECT.contracts),
|
|
1518
|
+
});
|
|
1519
|
+
}
|
|
1520
|
+
return entries;
|
|
1521
|
+
}
|
|
1522
|
+
/** Whether the partial-failure entry's own units already carry this finding. */
|
|
1523
|
+
function linkedByFailureEntry(scan, finding) {
|
|
1524
|
+
if (finding.kind === "unused-error-result")
|
|
1525
|
+
return true;
|
|
1526
|
+
return scan.lifecycleFacts.findingIds.has(finding.id);
|
|
1527
|
+
}
|
|
1528
|
+
/**
|
|
1529
|
+
* The reading agenda in a fixed order: failure-result accounting first, then
|
|
1530
|
+
* lifecycle/state mapping at external boundaries, then declared-shape changes,
|
|
1531
|
+
* then the automatic findings, then what was not checked, then every hunk no
|
|
1532
|
+
* question reached. Priorities are the resolved ranks, so the order is stable
|
|
1533
|
+
* and does not depend on the order the input arrived in.
|
|
1534
|
+
*/
|
|
1535
|
+
function buildAgenda(input, scan) {
|
|
1536
|
+
const drafted = [
|
|
1537
|
+
agendaEntry(input, scan.failureFacts, "Partial failure: what does this change report when only some items succeed?", "partial-failure"),
|
|
1538
|
+
agendaEntry(input, scan.lifecycleFacts, "Lifecycle state: which state values does this change write at its external boundaries, and do the constants match the mapping that is stored?", "external-write"),
|
|
1539
|
+
agendaEntry(input, scan.contractFacts, "Response shapes: which consumers must change with the shapes this change adds, removes, or edits?", "contract-change"),
|
|
1540
|
+
// A finding the partial-failure entry already links needs no second card:
|
|
1541
|
+
// that entry states its scope, limit, and evidence beside the response it is
|
|
1542
|
+
// about. Findings nothing else links still get their own entry.
|
|
1543
|
+
...scan.findings
|
|
1544
|
+
.filter(finding => !linkedByFailureEntry(scan, finding))
|
|
1545
|
+
.map(finding => findingEntry(input, finding, scan.findingNames.get(finding.id) ?? EMPTY_SUBJECT)),
|
|
1546
|
+
coverageEntry(scan.checks),
|
|
1547
|
+
].filter((entry) => entry !== null);
|
|
1548
|
+
const covered = new Set(drafted.flatMap(entry => entry.unitIds));
|
|
1549
|
+
// Priority is a 0..100 review priority, higher first, in the same direction as
|
|
1550
|
+
// `ReviewItem.priority`: the fixed order above is the ranking, and this field
|
|
1551
|
+
// is that rank expressed on the report's own scale. Array order stays the
|
|
1552
|
+
// authority for reading order, exactly as the renderer treats it.
|
|
1553
|
+
const entries = [...drafted, ...hunkCoverageEntries(input, covered)];
|
|
1554
|
+
entries.forEach((entry, index) => {
|
|
1555
|
+
entry.priority = Math.max(0, 100 - index);
|
|
1556
|
+
});
|
|
1557
|
+
return entries;
|
|
1558
|
+
}
|
|
1559
|
+
/** Why newly broken references are not checked here, in the reader's terms. */
|
|
1560
|
+
function brokenReferenceCheck(input) {
|
|
1561
|
+
if (!input.after)
|
|
1562
|
+
return coverage("broken-reference", "not-checked", PATCH_REASON);
|
|
1563
|
+
if (!input.before) {
|
|
1564
|
+
return coverage("broken-reference", "not-checked", "No prior-snapshot index was supplied, so a newly broken reference could not be separated from a " +
|
|
1565
|
+
"pre-existing one.");
|
|
1566
|
+
}
|
|
1567
|
+
return coverage("broken-reference", "not-checked", "No trusted project-checker result was supplied for the prior and resulting revisions, and a parser plus a " +
|
|
1568
|
+
"syntactic call graph is not a type checker. Newly broken imports and calls are therefore not compared here; " +
|
|
1569
|
+
"that safe before/after diagnostic comparison is a separate step, and until its output is available this check " +
|
|
1570
|
+
"reports neither breakage nor absence of breakage.");
|
|
1571
|
+
}
|
|
1572
|
+
/**
|
|
1573
|
+
* Automatic findings, check coverage, and the review agenda for one diff. Main
|
|
1574
|
+
* calls this inside the index callback with the same snapshots it renders, so
|
|
1575
|
+
* every excerpt names the revision it came from; a call without indexes or
|
|
1576
|
+
* without source readers still returns an agenda over the hunks, with each
|
|
1577
|
+
* check honestly not checked.
|
|
1578
|
+
*/
|
|
1579
|
+
export function buildReviewEvidence(units, options = {}) {
|
|
1580
|
+
const input = evidenceInput(units, options);
|
|
1581
|
+
const scan = {
|
|
1582
|
+
findings: [],
|
|
1583
|
+
checks: [],
|
|
1584
|
+
failureFacts: factBucket(),
|
|
1585
|
+
lifecycleFacts: factBucket(),
|
|
1586
|
+
contractFacts: factBucket(),
|
|
1587
|
+
findingNames: new Map(),
|
|
1588
|
+
};
|
|
1589
|
+
if (!input.after || !input.sources.after) {
|
|
1590
|
+
scan.checks.push(coverage("unused-error-result", "not-checked", input.after ? SOURCE_REASON : PATCH_REASON), coverage("duplicate-body", "not-checked", input.after ? SOURCE_REASON : PATCH_REASON), brokenReferenceCheck(input));
|
|
1591
|
+
return { findings: [], checks: scan.checks, agenda: buildAgenda(input, scan) };
|
|
1592
|
+
}
|
|
1593
|
+
const responses = responseScan(input);
|
|
1594
|
+
const duplicates = duplicateScan(input);
|
|
1595
|
+
scan.findings.push(...responses.findings, ...duplicates.findings);
|
|
1596
|
+
scan.failureFacts = responses.facts;
|
|
1597
|
+
scan.findingNames = responses.names;
|
|
1598
|
+
scan.contractFacts = contractChangeScan(input);
|
|
1599
|
+
scan.lifecycleFacts = lifecycleScan(input);
|
|
1600
|
+
scan.checks.push(responses.check, duplicates.check, brokenReferenceCheck(input));
|
|
1601
|
+
scan.findings.sort((left, right) => left.kind.localeCompare(right.kind) || left.id.localeCompare(right.id));
|
|
1602
|
+
return { findings: scan.findings, checks: scan.checks, agenda: buildAgenda(input, scan) };
|
|
1603
|
+
}
|