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,729 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Local, deterministic facts about the added and removed lines of one hunk.
|
|
3
|
+
*
|
|
4
|
+
* Without any model and without the text leaving the machine, it answers
|
|
5
|
+
* existence questions that depend on what the file is. Code: did a condition or
|
|
6
|
+
* comparison change, a limit, an input check, and did the lines hand an error to
|
|
7
|
+
* the caller, defer it, or discard it. Documentation: did an instruction to
|
|
8
|
+
* readers, a link target, or a numeric limit change. Configuration: was a CI gate
|
|
9
|
+
* weakened, did permissions or secret access change, a version pin, a limit. Each
|
|
10
|
+
* `yes` carries the line that produced it, so a reviewer sees what it is about.
|
|
11
|
+
*
|
|
12
|
+
* The analysis is lexical, not a parse: in code, strings become a placeholder and
|
|
13
|
+
* comments are removed; in configuration only comments are; and patterns are
|
|
14
|
+
* matched on what remains, on the changed lines of
|
|
15
|
+
* each side and on the lines around them that the hunk shows. That keeps it usable
|
|
16
|
+
* on a patch with no repository, and it keeps its limits plain:
|
|
17
|
+
*
|
|
18
|
+
* - only file types listed here are read; any other file answers no question
|
|
19
|
+
* at all, never `no`;
|
|
20
|
+
* - `no` means the changed lines the hunk shows contain no such pattern, never
|
|
21
|
+
* that the property is absent from the file or the program;
|
|
22
|
+
* - a line moved without change cancels out, because every question compares
|
|
23
|
+
* what the removed lines had with what the added lines have.
|
|
24
|
+
*/
|
|
25
|
+
import { testLikeFile } from "./file-role.js";
|
|
26
|
+
/** Questions asked of source code, in the order the report shows them. */
|
|
27
|
+
export const CODE_FACT_QUESTIONS = [
|
|
28
|
+
"comparisonChanged",
|
|
29
|
+
"limitChanged",
|
|
30
|
+
"validationChanged",
|
|
31
|
+
"failurePropagated",
|
|
32
|
+
"failureDeferred",
|
|
33
|
+
"failureDiscarded",
|
|
34
|
+
"contractChanged",
|
|
35
|
+
"dataChanged",
|
|
36
|
+
"queryChanged",
|
|
37
|
+
];
|
|
38
|
+
/** Questions asked of SQL files: schema and data changes. */
|
|
39
|
+
export const SQL_FACT_QUESTIONS = ["dataChanged"];
|
|
40
|
+
/** Questions asked of prose (documentation): what a reader is told to do or rely on. */
|
|
41
|
+
export const PROSE_FACT_QUESTIONS = ["instructionChanged", "referenceChanged", "limitChanged"];
|
|
42
|
+
/** Questions asked of configuration: CI gates, permissions, pins, and bounds. */
|
|
43
|
+
export const CONFIG_FACT_QUESTIONS = ["gateWeakened", "permissionChanged", "pinChanged", "limitChanged"];
|
|
44
|
+
/** Every question, once, in report order. */
|
|
45
|
+
export const CHANGE_FACT_QUESTIONS = [
|
|
46
|
+
...CODE_FACT_QUESTIONS,
|
|
47
|
+
"instructionChanged",
|
|
48
|
+
"referenceChanged",
|
|
49
|
+
"gateWeakened",
|
|
50
|
+
"permissionChanged",
|
|
51
|
+
"pinChanged",
|
|
52
|
+
];
|
|
53
|
+
const C_LIKE_FILE = /\.(?:[cm]?[jt]sx?|java|kts?|cs|go|rs|c|h|cc|cpp|cxx|hpp|hh|swift|php|scala|dart|groovy)$/i;
|
|
54
|
+
const PYTHON_FILE = /\.pyi?$/i;
|
|
55
|
+
const RUBY_FILE = /\.rb$/i;
|
|
56
|
+
const SQL_FILE = /\.sql$/i;
|
|
57
|
+
const PROSE_FILE = /\.(?:md|mdx|markdown|rst|txt|adoc|asciidoc)$/i;
|
|
58
|
+
const CONFIG_FILE = /\.(?:ya?ml|json|jsonc|json5|toml|ini|cfg|conf|properties)$|(?:^|\/)(?:\.env(?:\.[\w.-]+)?|Dockerfile(?:\.[\w.-]+)?|[\w.-]+\.dockerfile)$/i;
|
|
59
|
+
const JSON_FILE = /\.json[c5]?$/i;
|
|
60
|
+
/** Evidence text is the source line, bounded so one long line cannot dominate a report. */
|
|
61
|
+
const EVIDENCE_TEXT_LIMIT = 160;
|
|
62
|
+
export function changeFactLanguageOf(file) {
|
|
63
|
+
if (C_LIKE_FILE.test(file))
|
|
64
|
+
return "c-like";
|
|
65
|
+
if (PYTHON_FILE.test(file))
|
|
66
|
+
return "python";
|
|
67
|
+
if (RUBY_FILE.test(file))
|
|
68
|
+
return "ruby";
|
|
69
|
+
if (SQL_FILE.test(file))
|
|
70
|
+
return "sql";
|
|
71
|
+
if (PROSE_FILE.test(file))
|
|
72
|
+
return "prose";
|
|
73
|
+
if (CONFIG_FILE.test(file))
|
|
74
|
+
return "config";
|
|
75
|
+
return null;
|
|
76
|
+
}
|
|
77
|
+
/** The questions a file of this kind is asked; none for an unread type. */
|
|
78
|
+
export function factQuestionsFor(language) {
|
|
79
|
+
if (language === null)
|
|
80
|
+
return [];
|
|
81
|
+
if (language === "prose")
|
|
82
|
+
return PROSE_FACT_QUESTIONS;
|
|
83
|
+
if (language === "config")
|
|
84
|
+
return CONFIG_FACT_QUESTIONS;
|
|
85
|
+
if (language === "sql")
|
|
86
|
+
return SQL_FACT_QUESTIONS;
|
|
87
|
+
return CODE_FACT_QUESTIONS;
|
|
88
|
+
}
|
|
89
|
+
/**
|
|
90
|
+
* One line with strings replaced by `S` and comments removed. `state` carries a
|
|
91
|
+
* block comment or multi-line string into the next line of the same side.
|
|
92
|
+
*/
|
|
93
|
+
function scanLine(line, language, state, keepStrings = false) {
|
|
94
|
+
let out = "";
|
|
95
|
+
// A string's text, or its placeholder: facts ignore string content, while the
|
|
96
|
+
// formatting-only check must see it, since changing a literal changes behavior.
|
|
97
|
+
const literal = (from, to) => (keepStrings ? line.slice(from, to) : "S");
|
|
98
|
+
let index = 0;
|
|
99
|
+
while (index < line.length) {
|
|
100
|
+
if (state.open === "comment") {
|
|
101
|
+
const end = line.indexOf("*/", index);
|
|
102
|
+
if (end < 0)
|
|
103
|
+
return out;
|
|
104
|
+
index = end + 2;
|
|
105
|
+
state.open = null;
|
|
106
|
+
continue;
|
|
107
|
+
}
|
|
108
|
+
if (state.open !== null) {
|
|
109
|
+
const end = closingIndex(line, index, state.open);
|
|
110
|
+
if (end < 0)
|
|
111
|
+
return keepStrings ? out + line.slice(index) : out;
|
|
112
|
+
if (keepStrings)
|
|
113
|
+
out += line.slice(index, end + state.open.length);
|
|
114
|
+
index = end + state.open.length;
|
|
115
|
+
state.open = null;
|
|
116
|
+
continue;
|
|
117
|
+
}
|
|
118
|
+
const rest = line.slice(index);
|
|
119
|
+
if (language === "c-like" && rest.startsWith("//"))
|
|
120
|
+
break;
|
|
121
|
+
// A hunk can start inside a block comment it never shows opening: a line
|
|
122
|
+
// that begins `* ` or `*/` is that comment's continuation, not code.
|
|
123
|
+
if (language === "c-like" && out.trim() === "" && /^\*(?:\s|\/|$)/.test(rest)) {
|
|
124
|
+
if (rest.includes("*/")) {
|
|
125
|
+
index = line.indexOf("*/", index) + 2;
|
|
126
|
+
continue;
|
|
127
|
+
}
|
|
128
|
+
break;
|
|
129
|
+
}
|
|
130
|
+
if (language === "c-like" && rest.startsWith("/*")) {
|
|
131
|
+
state.open = "comment";
|
|
132
|
+
index += 2;
|
|
133
|
+
continue;
|
|
134
|
+
}
|
|
135
|
+
if (language !== "c-like" && rest.startsWith("#"))
|
|
136
|
+
break;
|
|
137
|
+
if (language === "python" && (rest.startsWith('"""') || rest.startsWith("'''"))) {
|
|
138
|
+
const delimiter = rest.startsWith('"""') ? '"""' : "'''";
|
|
139
|
+
const end = closingIndex(line, index + 3, delimiter);
|
|
140
|
+
if (end < 0) {
|
|
141
|
+
state.open = delimiter;
|
|
142
|
+
return out + literal(index, line.length);
|
|
143
|
+
}
|
|
144
|
+
out += literal(index, end + 3);
|
|
145
|
+
index = end + 3;
|
|
146
|
+
continue;
|
|
147
|
+
}
|
|
148
|
+
const char = line[index];
|
|
149
|
+
if (char === "`" && language === "c-like") {
|
|
150
|
+
const end = closingIndex(line, index + 1, "`");
|
|
151
|
+
if (end < 0) {
|
|
152
|
+
state.open = "`";
|
|
153
|
+
return out + literal(index, line.length);
|
|
154
|
+
}
|
|
155
|
+
out += literal(index, end + 1);
|
|
156
|
+
index = end + 1;
|
|
157
|
+
continue;
|
|
158
|
+
}
|
|
159
|
+
if (char === '"' || char === "'") {
|
|
160
|
+
const end = closingIndex(line, index + 1, char);
|
|
161
|
+
if (end < 0)
|
|
162
|
+
return out + literal(index, line.length);
|
|
163
|
+
out += literal(index, end + 1);
|
|
164
|
+
index = end + 1;
|
|
165
|
+
continue;
|
|
166
|
+
}
|
|
167
|
+
out += char;
|
|
168
|
+
index += 1;
|
|
169
|
+
}
|
|
170
|
+
return out;
|
|
171
|
+
}
|
|
172
|
+
/**
|
|
173
|
+
* One configuration line without its `#` comment. Quoted values are kept: in
|
|
174
|
+
* configuration the value is the point, and `"warn"` must stay readable. JSON
|
|
175
|
+
* has no comments, so its lines are kept whole.
|
|
176
|
+
*/
|
|
177
|
+
function scanConfigLine(line, json) {
|
|
178
|
+
if (json)
|
|
179
|
+
return line;
|
|
180
|
+
let quote = null;
|
|
181
|
+
for (let index = 0; index < line.length; index++) {
|
|
182
|
+
const char = line[index];
|
|
183
|
+
if (quote !== null) {
|
|
184
|
+
if (char === "\\")
|
|
185
|
+
index += 1;
|
|
186
|
+
else if (char === quote)
|
|
187
|
+
quote = null;
|
|
188
|
+
continue;
|
|
189
|
+
}
|
|
190
|
+
if (char === '"' || char === "'")
|
|
191
|
+
quote = char;
|
|
192
|
+
else if (char === "#" && (index === 0 || /\s/.test(line[index - 1])))
|
|
193
|
+
return line.slice(0, index);
|
|
194
|
+
}
|
|
195
|
+
return line;
|
|
196
|
+
}
|
|
197
|
+
/** Index of an unescaped `delimiter` at or after `from`, or -1. */
|
|
198
|
+
function closingIndex(line, from, delimiter) {
|
|
199
|
+
for (let index = from; index < line.length; index++) {
|
|
200
|
+
if (line[index] === "\\") {
|
|
201
|
+
index += 1;
|
|
202
|
+
continue;
|
|
203
|
+
}
|
|
204
|
+
if (line.startsWith(delimiter, index))
|
|
205
|
+
return index;
|
|
206
|
+
}
|
|
207
|
+
return -1;
|
|
208
|
+
}
|
|
209
|
+
/** Code text with layout ignored: single spaces, and none next to punctuation. */
|
|
210
|
+
function compact(code) {
|
|
211
|
+
return code
|
|
212
|
+
.replace(/\s+/g, " ")
|
|
213
|
+
.trim()
|
|
214
|
+
.replace(/ (?=[^\w$])|(?<=[^\w$]) /g, "");
|
|
215
|
+
}
|
|
216
|
+
function scannerFor(language, file) {
|
|
217
|
+
if (language === "prose")
|
|
218
|
+
return { code: (line) => line, literal: (line) => line };
|
|
219
|
+
if (language === "sql") {
|
|
220
|
+
// `--` comments go; string text stays, it is what a statement writes.
|
|
221
|
+
const scan = (line) => line.replace(/--.*$/, "");
|
|
222
|
+
return { code: scan, literal: scan };
|
|
223
|
+
}
|
|
224
|
+
if (language === "config") {
|
|
225
|
+
const json = JSON_FILE.test(file);
|
|
226
|
+
const scan = (line) => scanConfigLine(line, json);
|
|
227
|
+
return { code: scan, literal: scan };
|
|
228
|
+
}
|
|
229
|
+
return {
|
|
230
|
+
code: (line, state) => scanLine(line, language, state),
|
|
231
|
+
literal: (line, state) => scanLine(line, language, state, true),
|
|
232
|
+
};
|
|
233
|
+
}
|
|
234
|
+
function sidesOf(diff, scanners) {
|
|
235
|
+
const before = [];
|
|
236
|
+
const after = [];
|
|
237
|
+
const beforeState = { open: null };
|
|
238
|
+
const afterState = { open: null };
|
|
239
|
+
const beforeLiteral = { open: null };
|
|
240
|
+
const afterLiteral = { open: null };
|
|
241
|
+
let inHunk = false;
|
|
242
|
+
for (const line of diff.split("\n")) {
|
|
243
|
+
if (line.startsWith("@@")) {
|
|
244
|
+
inHunk = true;
|
|
245
|
+
continue;
|
|
246
|
+
}
|
|
247
|
+
// File headers precede the first hunk; inside one, `---x` is a removed `--x`.
|
|
248
|
+
if (!inHunk || line.startsWith("\\"))
|
|
249
|
+
continue;
|
|
250
|
+
const marker = line[0];
|
|
251
|
+
const raw = line.slice(1);
|
|
252
|
+
const entry = (state, literalState, changed) => ({
|
|
253
|
+
raw,
|
|
254
|
+
code: scanners.code(raw, state).replace(/\s+/g, " ").trim(),
|
|
255
|
+
literal: scanners.literal(raw, literalState).replace(/\s+/g, " ").trim(),
|
|
256
|
+
changed,
|
|
257
|
+
indent: raw.length - raw.trimStart().length,
|
|
258
|
+
});
|
|
259
|
+
if (marker === "-")
|
|
260
|
+
before.push(entry(beforeState, beforeLiteral, true));
|
|
261
|
+
else if (marker === "+")
|
|
262
|
+
after.push(entry(afterState, afterLiteral, true));
|
|
263
|
+
else if (marker === " " || line === "") {
|
|
264
|
+
before.push(entry(beforeState, beforeLiteral, false));
|
|
265
|
+
after.push(entry(afterState, afterLiteral, false));
|
|
266
|
+
}
|
|
267
|
+
}
|
|
268
|
+
return { before, after };
|
|
269
|
+
}
|
|
270
|
+
/* --------------------------------------------------------------- patterns */
|
|
271
|
+
const COMPARISON_OPERATOR = /(===|!==|==|!=|(?<![<=])<=|(?<![>=])>=|(?<=\s)<(?=\s)|(?<=\s)>(?=\s))/g;
|
|
272
|
+
const PYTHON_COMPARISON_OPERATOR = /(\bis not\b|\bnot in\b|\bis\b)/g;
|
|
273
|
+
const CONDITION_KEYWORD = /\b(if|elif|while|unless|until|switch|when)\b/;
|
|
274
|
+
// `??` supplies a default and is not a condition a reviewer checks.
|
|
275
|
+
const LOGICAL_OPERATOR = /(&&|\|\||\band\b|\bor\b)/;
|
|
276
|
+
/** Where a logical operator is a condition: continuing one, or a returned boolean. */
|
|
277
|
+
const CONDITION_LINE = /^(?:return\b|&&|\|\||!|\()|(?:&&|\|\||\()\s*$/;
|
|
278
|
+
const TERNARY = /\s\?\s[^:]*\s:\s/;
|
|
279
|
+
const LIMIT_WORD = /\b\w*(?:timeout|limit|max|min|size|length|len|offset|index|idx|count|capacity|retries|attempts|ttl|threshold|page|batch|delay|interval|slice|substring|substr|take|skip|range|depth|width|height|bound)\w*/i;
|
|
280
|
+
const NUMBER = /(?<![\w$.])-?\d[\d_]*(?:\.\d+)?(?:e-?\d+)?\b/gi;
|
|
281
|
+
/** Non-global twin of {@link NUMBER} for `test`, which a global pattern makes stateful. */
|
|
282
|
+
const HAS_NUMBER = new RegExp(NUMBER.source, "i");
|
|
283
|
+
const VALIDATION =
|
|
284
|
+
// `typeof` only as a runtime check, compared with a value: never in a type
|
|
285
|
+
// position (`ConstructorParameters<typeof X>`) or inside an assertion.
|
|
286
|
+
/\btypeof\s+[\w$.?[\]]+\s*[!=]==?|[!=]==?\s*typeof\b|\binstanceof\s+(?!\w*(?:Error|Exception)\b)|\b(?:isinstance|issubclass)\b|\bArray\.isArray\b|\bNumber\.is(?:Integer|Finite|NaN|SafeInteger)\b|\bis_a\?|\bkind_of\?|\binvariant\(|\b(?:validate|ensure)\w*\(|\bz\.\w+\(|\bJoi\.|\byup\.|\.safeParse\(/;
|
|
287
|
+
const PROPAGATION = /\bthrow\b|\braise\b|\breject\(|\bPromise\.reject\b|\breturn\s+(?:nil\s*,\s*)?err\b|\bErr\(|\bpanic!?\(|\bnext\(\s*(?:err|error)\b|\b(?:cb|callback|done)\(\s*(?:err|error)\b|\bfmt\.Errorf\(|\berrors\.New\(/;
|
|
288
|
+
/** A test assertion: its expected numbers are expectations, never bounds. */
|
|
289
|
+
const ASSERTION = /\b(?:expect|assert\w*|should)\s*[.(]|\bt\.\w+\(|\.to(?:Be|Equal|StrictEqual|HaveLength|Match|Contain)\w*\(/;
|
|
290
|
+
const DEFERRAL = /\b(?:retry|retries|retrying|retried|backoff|requeue|reschedul\w*|dead_?letter|dlq)\w*/i;
|
|
291
|
+
/** Values a handler returns instead of the error: a default, not a failure. */
|
|
292
|
+
const DEFAULT_VALUE = String.raw `(?:null|undefined|nil|None|false|False|0|-1|S|\[\s*\]|\{\s*\}|\(\s*\))`;
|
|
293
|
+
const INLINE_DISCARD = [
|
|
294
|
+
new RegExp(String.raw `\.catch\(\s*(?:\(\s*\w*\s*\)|\w+)\s*=>\s*(?:\{\s*\}|${DEFAULT_VALUE})\s*\)`),
|
|
295
|
+
/\.catch\(\s*(?:noop|_\.noop|\(\)\s*=>\s*void\s+0)\s*\)/,
|
|
296
|
+
/\bcatch\s*(?:\([^)]*\))?\s*\{\s*\}/,
|
|
297
|
+
new RegExp(String.raw `\bcatch\s*(?:\([^)]*\))?\s*\{\s*(?:return(?:\s+${DEFAULT_VALUE})?|continue|break)\s*;?\s*\}`),
|
|
298
|
+
/\bexcept\b[^:]*:\s*(?:pass|continue)\s*$/,
|
|
299
|
+
/\brescue\s+nil\b/,
|
|
300
|
+
/^_\s*=\s*err\b/,
|
|
301
|
+
];
|
|
302
|
+
const BLOCK_HANDLER = {
|
|
303
|
+
"c-like": /(?:\bcatch\s*(?:\([^)]*\))?|\bif\s*\(?\s*err\s*!=\s*nil\s*\)?)\s*\{\s*$/,
|
|
304
|
+
python: /^except\b[^:]*:\s*$/,
|
|
305
|
+
ruby: /^rescue\b/,
|
|
306
|
+
};
|
|
307
|
+
const DISCARDING_BODY = new RegExp(String.raw `^(?:pass|continue|break;?|return(?:\s+${DEFAULT_VALUE}(?:\s*,\s*nil)?)?\s*;?|nil)$`);
|
|
308
|
+
/** Removed hits with no identical added hit, and the other way round. */
|
|
309
|
+
function difference(before, after) {
|
|
310
|
+
const remaining = new Map();
|
|
311
|
+
for (const hit of after)
|
|
312
|
+
remaining.set(hit.key, (remaining.get(hit.key) ?? 0) + 1);
|
|
313
|
+
const removed = [];
|
|
314
|
+
for (const hit of before) {
|
|
315
|
+
const count = remaining.get(hit.key) ?? 0;
|
|
316
|
+
if (count > 0)
|
|
317
|
+
remaining.set(hit.key, count - 1);
|
|
318
|
+
else
|
|
319
|
+
removed.push(hit);
|
|
320
|
+
}
|
|
321
|
+
const consumed = new Map();
|
|
322
|
+
for (const hit of before)
|
|
323
|
+
consumed.set(hit.key, (consumed.get(hit.key) ?? 0) + 1);
|
|
324
|
+
const added = [];
|
|
325
|
+
for (const hit of after) {
|
|
326
|
+
const count = consumed.get(hit.key) ?? 0;
|
|
327
|
+
if (count > 0)
|
|
328
|
+
consumed.set(hit.key, count - 1);
|
|
329
|
+
else
|
|
330
|
+
added.push(hit);
|
|
331
|
+
}
|
|
332
|
+
return { removed, added };
|
|
333
|
+
}
|
|
334
|
+
function comparisonAtoms(code, language) {
|
|
335
|
+
const atoms = [];
|
|
336
|
+
const patterns = language === "python" ? [COMPARISON_OPERATOR, PYTHON_COMPARISON_OPERATOR] : [COMPARISON_OPERATOR];
|
|
337
|
+
for (const pattern of patterns) {
|
|
338
|
+
for (const match of code.matchAll(pattern)) {
|
|
339
|
+
const at = match.index ?? 0;
|
|
340
|
+
const left = /[\w$.[\]]+$/.exec(code.slice(0, at).trimEnd())?.[0] ?? "";
|
|
341
|
+
const right = /^-?[\w$.[\]]+/.exec(code.slice(at + match[0].length).trimStart())?.[0] ?? "";
|
|
342
|
+
atoms.push({ left, operator: match[0].trim(), right });
|
|
343
|
+
}
|
|
344
|
+
}
|
|
345
|
+
return atoms;
|
|
346
|
+
}
|
|
347
|
+
/** The condition a line tests: a keyword's guarded expression, or the whole logical line. */
|
|
348
|
+
function conditionOf(code) {
|
|
349
|
+
const keyword = CONDITION_KEYWORD.exec(code);
|
|
350
|
+
if (keyword) {
|
|
351
|
+
let rest = code.slice(keyword.index + keyword[0].length).trimStart();
|
|
352
|
+
if (rest.startsWith("(")) {
|
|
353
|
+
let depth = 0;
|
|
354
|
+
for (let index = 0; index < rest.length; index++) {
|
|
355
|
+
if (rest[index] === "(")
|
|
356
|
+
depth += 1;
|
|
357
|
+
else if (rest[index] === ")" && --depth === 0)
|
|
358
|
+
return compact(`${keyword[0]} ${rest.slice(0, index + 1)}`);
|
|
359
|
+
}
|
|
360
|
+
return compact(`${keyword[0]} ${rest}`);
|
|
361
|
+
}
|
|
362
|
+
rest = rest.replace(/\s*[:{]\s*$/, "");
|
|
363
|
+
return compact(`${keyword[0]} ${rest}`);
|
|
364
|
+
}
|
|
365
|
+
// A line of a multi-line condition, or a returned boolean; an assignment such
|
|
366
|
+
// as `const x = a || b` combines values and is not a condition by itself.
|
|
367
|
+
if ((LOGICAL_OPERATOR.test(code) && CONDITION_LINE.test(code)) || TERNARY.test(code))
|
|
368
|
+
return compact(code);
|
|
369
|
+
return null;
|
|
370
|
+
}
|
|
371
|
+
function conditionHits(lines, language) {
|
|
372
|
+
const hits = [];
|
|
373
|
+
for (const line of lines) {
|
|
374
|
+
if (!line.changed || line.code === "")
|
|
375
|
+
continue;
|
|
376
|
+
const condition = conditionOf(line.code);
|
|
377
|
+
if (condition !== null)
|
|
378
|
+
hits.push({ key: `cond:${condition}`, line });
|
|
379
|
+
for (const atom of comparisonAtoms(line.code, language)) {
|
|
380
|
+
hits.push({ key: `cmp:${atom.left}${atom.operator}${atom.right}`, line });
|
|
381
|
+
}
|
|
382
|
+
}
|
|
383
|
+
return hits;
|
|
384
|
+
}
|
|
385
|
+
/** Changed lines whose code matches `pattern`, keyed by their layout-free code. */
|
|
386
|
+
function lineHits(lines, pattern) {
|
|
387
|
+
return lines
|
|
388
|
+
.filter((line) => line.changed && line.code !== "" && pattern.test(line.code))
|
|
389
|
+
.map((line) => ({ key: compact(line.code), line }));
|
|
390
|
+
}
|
|
391
|
+
/** Each bound operator and its strict or non-strict twin. */
|
|
392
|
+
const STRICTNESS = new Map([["<", "<="], ["<=", "<"], [">", ">="], [">=", ">"]]);
|
|
393
|
+
const isNumber = (token) => /^-?\d[\d_]*(?:\.\d+)?(?:e-?\d+)?$/i.test(token);
|
|
394
|
+
/**
|
|
395
|
+
* A bound whose admitted range changed: the same comparison with a strict and a
|
|
396
|
+
* non-strict operator swapped, or a numeric side changed; or a line naming a limit
|
|
397
|
+
* whose only difference is a number.
|
|
398
|
+
*/
|
|
399
|
+
function limitHit(removed, added, language) {
|
|
400
|
+
const removedAtoms = removed.flatMap((hit) => comparisonAtoms(hit.line.code, language).map((atom) => ({ atom, hit })));
|
|
401
|
+
const addedAtoms = added.flatMap((hit) => comparisonAtoms(hit.line.code, language).map((atom) => ({ atom, hit })));
|
|
402
|
+
for (const { atom: before } of removedAtoms) {
|
|
403
|
+
for (const { atom: after, hit } of addedAtoms) {
|
|
404
|
+
const sameOperands = before.left === after.left && before.right === after.right;
|
|
405
|
+
if (sameOperands && STRICTNESS.get(before.operator) === after.operator)
|
|
406
|
+
return hit;
|
|
407
|
+
const sameDirection = before.operator === after.operator || STRICTNESS.get(before.operator) === after.operator;
|
|
408
|
+
if (!sameDirection)
|
|
409
|
+
continue;
|
|
410
|
+
if (before.left === after.left && isNumber(before.right) && isNumber(after.right) && before.right !== after.right) {
|
|
411
|
+
return hit;
|
|
412
|
+
}
|
|
413
|
+
if (before.right === after.right && isNumber(before.left) && isNumber(after.left) && before.left !== after.left) {
|
|
414
|
+
return hit;
|
|
415
|
+
}
|
|
416
|
+
}
|
|
417
|
+
}
|
|
418
|
+
return null;
|
|
419
|
+
}
|
|
420
|
+
function numericLimitHit(before, after) {
|
|
421
|
+
const limitLines = (lines) => lines
|
|
422
|
+
.filter((line) => line.changed && line.code !== "" && LIMIT_WORD.test(line.code) && HAS_NUMBER.test(line.code) && !ASSERTION.test(line.code))
|
|
423
|
+
.map((line) => ({ key: compact(line.code), numberless: compact(line.code.replace(NUMBER, "N")), line }));
|
|
424
|
+
const removed = limitLines(before);
|
|
425
|
+
const added = limitLines(after);
|
|
426
|
+
for (const old of removed) {
|
|
427
|
+
for (const candidate of added) {
|
|
428
|
+
if (old.numberless === candidate.numberless && old.key !== candidate.key)
|
|
429
|
+
return { key: candidate.key, line: candidate.line };
|
|
430
|
+
}
|
|
431
|
+
}
|
|
432
|
+
return null;
|
|
433
|
+
}
|
|
434
|
+
/**
|
|
435
|
+
* A changed condition that guards a raise. It is an input check, whatever it tests,
|
|
436
|
+
* and the raise it controls is a failure handed to the caller on a path the
|
|
437
|
+
* changed line controls, even when the raise itself is unchanged context.
|
|
438
|
+
*/
|
|
439
|
+
function guardHits(lines, changedConditions) {
|
|
440
|
+
const hits = [];
|
|
441
|
+
lines.forEach((line, index) => {
|
|
442
|
+
if (!changedConditions.has(line))
|
|
443
|
+
return;
|
|
444
|
+
const next = lines.slice(index + 1).find((candidate) => candidate.code !== "");
|
|
445
|
+
if (PROPAGATION.test(line.code) || (next !== undefined && PROPAGATION.test(next.code))) {
|
|
446
|
+
hits.push({ key: `guard:${compact(line.code)}`, line });
|
|
447
|
+
}
|
|
448
|
+
});
|
|
449
|
+
return hits;
|
|
450
|
+
}
|
|
451
|
+
/** Handlers that swallow the error, anchored on a changed line of this side. */
|
|
452
|
+
function discardHits(lines, language) {
|
|
453
|
+
const hits = [];
|
|
454
|
+
lines.forEach((line, index) => {
|
|
455
|
+
if (line.code === "")
|
|
456
|
+
return;
|
|
457
|
+
if (INLINE_DISCARD.some((pattern) => pattern.test(line.code))) {
|
|
458
|
+
if (line.changed)
|
|
459
|
+
hits.push({ key: `discard:${compact(line.code)}`, line });
|
|
460
|
+
return;
|
|
461
|
+
}
|
|
462
|
+
if (!BLOCK_HANDLER[language].test(line.code))
|
|
463
|
+
return;
|
|
464
|
+
const body = [];
|
|
465
|
+
let closed = false;
|
|
466
|
+
for (const candidate of lines.slice(index + 1)) {
|
|
467
|
+
if (candidate.code === "")
|
|
468
|
+
continue;
|
|
469
|
+
if (language === "python" && candidate.indent <= line.indent) {
|
|
470
|
+
closed = true;
|
|
471
|
+
break;
|
|
472
|
+
}
|
|
473
|
+
if (language !== "python" && candidate.code.startsWith("}")) {
|
|
474
|
+
body.push(candidate);
|
|
475
|
+
closed = true;
|
|
476
|
+
break;
|
|
477
|
+
}
|
|
478
|
+
if (language === "ruby" && /^end\b/.test(candidate.code)) {
|
|
479
|
+
body.push(candidate);
|
|
480
|
+
closed = true;
|
|
481
|
+
break;
|
|
482
|
+
}
|
|
483
|
+
body.push(candidate);
|
|
484
|
+
if (body.length > 2)
|
|
485
|
+
break;
|
|
486
|
+
}
|
|
487
|
+
const statements = body.filter((candidate) => !/^\}|^end\b/.test(candidate.code));
|
|
488
|
+
const swallows = (closed || language === "python") &&
|
|
489
|
+
statements.length <= 1 &&
|
|
490
|
+
statements.every((candidate) => DISCARDING_BODY.test(candidate.code));
|
|
491
|
+
const involved = [line, ...body];
|
|
492
|
+
if (swallows && involved.some((candidate) => candidate.changed)) {
|
|
493
|
+
hits.push({ key: `discard:${involved.map((candidate) => compact(candidate.code)).join("|")}`, line });
|
|
494
|
+
}
|
|
495
|
+
});
|
|
496
|
+
return hits;
|
|
497
|
+
}
|
|
498
|
+
function evidenceOf(hit, side) {
|
|
499
|
+
const text = hit.line.raw.trim();
|
|
500
|
+
return { side, text: text.length > EVIDENCE_TEXT_LIMIT ? `${text.slice(0, EVIDENCE_TEXT_LIMIT - 1)}…` : text };
|
|
501
|
+
}
|
|
502
|
+
/** First surviving hit, added side first: the new code is what a reviewer reads. */
|
|
503
|
+
function firstEvidence(diff) {
|
|
504
|
+
if (diff.added.length > 0)
|
|
505
|
+
return evidenceOf(diff.added[0], "added");
|
|
506
|
+
if (diff.removed.length > 0)
|
|
507
|
+
return evidenceOf(diff.removed[0], "removed");
|
|
508
|
+
return null;
|
|
509
|
+
}
|
|
510
|
+
function isInert(sides, language) {
|
|
511
|
+
const key = (line) => (language === "python" ? `${line.indent}:${compact(line.literal)}` : compact(line.literal));
|
|
512
|
+
const code = (lines) => lines.filter((line) => line.changed && line.literal !== "").map(key);
|
|
513
|
+
const before = code(sides.before);
|
|
514
|
+
const after = code(sides.after);
|
|
515
|
+
return before.length === after.length && before.every((value, index) => value === after[index]);
|
|
516
|
+
}
|
|
517
|
+
/* ----------------------------------------------------- prose and config */
|
|
518
|
+
/** Words that tell a reader what they must, may, or may not do or rely on. */
|
|
519
|
+
const NORMATIVE = /\b(?:must|shall|should|required|requires|never|always|only|cannot|can't|do not|don't|deprecated|breaking|at most|at least|not supported|unsupported|recommended)\b/i;
|
|
520
|
+
/** A link target or bare URL. */
|
|
521
|
+
const REFERENCE = /\]\(\s*<?([^)\s>]+)|(https?:\/\/[^\s)>"'\]]+)/g;
|
|
522
|
+
/** Settings that turn a failing CI check into a passing or advisory one. */
|
|
523
|
+
const GATE_WEAKENING = /continue-on-error:\s*true|allow_failure:\s*true|\|\|\s*true\b|\bset\s+\+e\b|--no-verify\b|\bif:\s*false\b|\bskip\b|\bwarn(?:ing)?\b|\bignore\b|fail_?[oO]n_?[eE]rror\W+false|--passWithNoTests|\bexit\s+0\b|--force\b/i;
|
|
524
|
+
/** A step that runs a check; fewer of them after the change is a weaker gate. */
|
|
525
|
+
const CHECK_STEP = /\b(?:run|script|command)\b.*\b(?:test|tests|lint|check|audit|verify|typecheck|tsc|vitest|jest|pytest|mypy|eslint|oxlint)\b|\buses:.*\b(?:codeql|lint|test|scan)/i;
|
|
526
|
+
const PERMISSION = /\bpermissions\b|:\s*write(?:-all)?\b|\bwrite-all\b|\bpull_request_target\b|\bsecrets\.|\bid-token\b|\bGITHUB_TOKEN\b|\bprivileged:\s*true|\ballowPrivilegeEscalation\b|\brunAsUser:\s*0\b|^USER\s+root\b|\bsudo\b/i;
|
|
527
|
+
const PIN = /\buses:\s*\S+@|\bimage:\s*\S+|^FROM\s|\bversion\b|"[@\w./-]+"\s*:\s*"\s*[\^~<>=*]?\s*(?:v?\d|latest|\*)/i;
|
|
528
|
+
/** Changed lines of one side whose text carries each link target they name. */
|
|
529
|
+
function referenceHits(lines) {
|
|
530
|
+
const hits = [];
|
|
531
|
+
for (const line of lines) {
|
|
532
|
+
if (!line.changed)
|
|
533
|
+
continue;
|
|
534
|
+
for (const match of line.raw.matchAll(REFERENCE))
|
|
535
|
+
hits.push({ key: match[1] ?? match[2], line });
|
|
536
|
+
}
|
|
537
|
+
return hits;
|
|
538
|
+
}
|
|
539
|
+
/** Changed lines matching `pattern`, keyed by their text with case and spacing ignored. */
|
|
540
|
+
function textHits(lines, pattern) {
|
|
541
|
+
return lines
|
|
542
|
+
.filter((line) => line.changed && line.code !== "" && pattern.test(line.code))
|
|
543
|
+
.map((line) => ({ key: line.code.toLowerCase(), line }));
|
|
544
|
+
}
|
|
545
|
+
/** The same words in the same order on both sides: reflowed or respaced prose. */
|
|
546
|
+
function proseInert(sides) {
|
|
547
|
+
const words = (lines) => lines.filter((line) => line.changed).map((line) => line.code).join(" ").split(/\s+/).filter((word) => word !== "");
|
|
548
|
+
const before = words(sides.before);
|
|
549
|
+
const after = words(sides.after);
|
|
550
|
+
return before.length === after.length && before.every((word, index) => word === after[index]);
|
|
551
|
+
}
|
|
552
|
+
/**
|
|
553
|
+
* A declaration others build on: an exported symbol, an HTTP route, a DTO or
|
|
554
|
+
* entity field, or a public method or function signature. Read on code with
|
|
555
|
+
* strings set aside, so a string that looks like a declaration never counts.
|
|
556
|
+
*/
|
|
557
|
+
const CONTRACT = [
|
|
558
|
+
/^export\s+(?:default\s+)?(?:declare\s+)?(?:async\s+)?(?:function|class|interface|type|enum|const|let|abstract)\b/,
|
|
559
|
+
/^@(?:Get|Post|Put|Patch|Delete|All|Controller|Resolver|Query|Mutation|Column|PrimaryColumn|PrimaryGeneratedColumn|Entity|ManyToOne|OneToMany|OneToOne|ManyToMany|JoinColumn|Index|Unique|Is[A-Z]\w*|Min|Max|Length|ValidateNested|Type|Transform|Api(?:Property|ResponseProperty)\w*|Field|Prop|Schema)\b/,
|
|
560
|
+
/^(?:public\s+|static\s+|async\s+|override\s+|readonly\s+)*(?!(?:if|for|while|switch|catch|return|function|await|new|else|do|try)\b)[A-Za-z_$][\w$]*\s*(?:<[^>]*>)?\s*\([^)]*\)\s*(?::\s*[^={;]+)?\s*\{$/,
|
|
561
|
+
/^(?:public|protected)\s+(?:static\s+|abstract\s+|final\s+|async\s+|override\s+)*[\w<>[\],.? ]+\s+\w+\s*\(/,
|
|
562
|
+
/^def\s+[A-Za-z]\w*\s*\(/,
|
|
563
|
+
/^class\s+[A-Z]\w*/,
|
|
564
|
+
/^func\s+(?:\([^)]*\)\s*)?[A-Z]\w*\s*\(/,
|
|
565
|
+
/^pub(?:\([^)]*\))?\s+(?:async\s+)?(?:fn|struct|enum|trait|type|const)\b/,
|
|
566
|
+
];
|
|
567
|
+
/** Schema changes and writes to stored data, in SQL text or a migration builder call. */
|
|
568
|
+
const DATA_CHANGE = /\b(?:ALTER\s+(?:TABLE|TYPE|INDEX|VIEW|SEQUENCE)|ADD\s+VALUE|CREATE\s+(?:UNIQUE\s+)?(?:TABLE|INDEX|TYPE|VIEW|MATERIALIZED\s+VIEW)|DROP\s+(?:TABLE|COLUMN|INDEX|TYPE|VIEW|CONSTRAINT|SCHEMA)|ADD\s+(?:COLUMN|CONSTRAINT)|RENAME\s+(?:COLUMN|TO)|TRUNCATE|DELETE\s+FROM|UPDATE\s+[\w."]+\s+SET|INSERT\s+INTO)\b|\b(?:addColumn|dropColumn|renameColumn|changeColumn|createTable|dropTable|alterTable|renameTable|createIndex|dropIndex|createForeignKey|dropForeignKey|removeColumn|addIndex|removeIndex|addConstraint|removeConstraint|bulkInsert|bulkUpdate|bulkDelete)\s*\(/i;
|
|
569
|
+
/** A migration directory: whatever changes there changes the schema or stored data. */
|
|
570
|
+
const MIGRATION_PATH = /(?:^|\/)migrations?\//i;
|
|
571
|
+
/** Changed lines whose literal text (comments removed, strings kept) matches. */
|
|
572
|
+
function literalHits(lines, pattern) {
|
|
573
|
+
return lines
|
|
574
|
+
.filter((line) => line.changed && line.literal !== "" && pattern.test(line.literal))
|
|
575
|
+
.map((line) => ({ key: compact(line.literal), line }));
|
|
576
|
+
}
|
|
577
|
+
/**
|
|
578
|
+
* SQL written inside the code's strings: upper-case clause keywords that appear
|
|
579
|
+
* in a line's string text but not in its code, so identifiers and prose do not count.
|
|
580
|
+
*/
|
|
581
|
+
const SQL_QUERY = /\b(?:SELECT|FROM|WHERE|JOIN|GROUP BY|ORDER BY|HAVING|LIMIT|OFFSET|UNION|CASE WHEN|RETURNING|ON CONFLICT|WITH [a-z_]+ AS)\b/;
|
|
582
|
+
function queryHits(lines) {
|
|
583
|
+
return lines
|
|
584
|
+
.filter((line) => line.changed && SQL_QUERY.test(line.literal) && !SQL_QUERY.test(line.code))
|
|
585
|
+
.map((line) => ({ key: `sql:${compact(line.literal)}`, line }));
|
|
586
|
+
}
|
|
587
|
+
function contractHits(lines) {
|
|
588
|
+
return lines
|
|
589
|
+
.filter((line) => line.changed && line.code !== "" && CONTRACT.some((pattern) => pattern.test(line.code)))
|
|
590
|
+
.map((line) => ({ key: compact(line.code), line }));
|
|
591
|
+
}
|
|
592
|
+
function proseFacts(sides, record) {
|
|
593
|
+
record("instructionChanged", firstEvidence(difference(textHits(sides.before, NORMATIVE), textHits(sides.after, NORMATIVE))));
|
|
594
|
+
record("referenceChanged", firstEvidence(difference(referenceHits(sides.before), referenceHits(sides.after))));
|
|
595
|
+
const limit = numericLimitHit(sides.before, sides.after);
|
|
596
|
+
record("limitChanged", limit === null ? null : evidenceOf(limit, "added"));
|
|
597
|
+
}
|
|
598
|
+
/** Bookkeeping files whose numbers record state, not bounds. */
|
|
599
|
+
const BOOKKEEPING_FILE = /(?:suppressions?|baseline|snapshot|lock)[\w.-]*\.(?:json|ya?ml|toml)$/i;
|
|
600
|
+
function configFacts(sides, record, file) {
|
|
601
|
+
const weakening = difference(textHits(sides.before, GATE_WEAKENING), textHits(sides.after, GATE_WEAKENING));
|
|
602
|
+
const checksBefore = textHits(sides.before, CHECK_STEP);
|
|
603
|
+
const checksAfter = textHits(sides.after, CHECK_STEP);
|
|
604
|
+
const removedCheck = difference(checksBefore, checksAfter).removed;
|
|
605
|
+
// Added weakening settings, or fewer check steps than before; removing a
|
|
606
|
+
// weakening setting strengthens the gate and is not reported here.
|
|
607
|
+
const gate = weakening.added.length > 0
|
|
608
|
+
? evidenceOf(weakening.added[0], "added")
|
|
609
|
+
: checksBefore.length > checksAfter.length && removedCheck.length > 0
|
|
610
|
+
? evidenceOf(removedCheck[0], "removed")
|
|
611
|
+
: null;
|
|
612
|
+
record("gateWeakened", gate);
|
|
613
|
+
record("permissionChanged", firstEvidence(difference(textHits(sides.before, PERMISSION), textHits(sides.after, PERMISSION))));
|
|
614
|
+
record("pinChanged", firstEvidence(difference(textHits(sides.before, PIN), textHits(sides.after, PIN))));
|
|
615
|
+
const limit = BOOKKEEPING_FILE.test(file) ? null : numericLimitHit(sides.before, sides.after);
|
|
616
|
+
record("limitChanged", limit === null ? null : evidenceOf(limit, "added"));
|
|
617
|
+
}
|
|
618
|
+
function codeFacts(sides, language, record, file) {
|
|
619
|
+
const conditions = difference(conditionHits(sides.before, language), conditionHits(sides.after, language));
|
|
620
|
+
record("comparisonChanged", firstEvidence(conditions));
|
|
621
|
+
const limit = limitHit(conditions.removed, conditions.added, language) ?? numericLimitHit(sides.before, sides.after);
|
|
622
|
+
record("limitChanged", limit === null ? null : evidenceOf(limit, "added"));
|
|
623
|
+
const changedConditionLines = new Set([...conditions.removed, ...conditions.added].map((hit) => hit.line));
|
|
624
|
+
const validation = difference([...lineHits(sides.before, VALIDATION).filter((hit) => !ASSERTION.test(hit.line.code)), ...guardHits(sides.before, changedConditionLines)], [...lineHits(sides.after, VALIDATION).filter((hit) => !ASSERTION.test(hit.line.code)), ...guardHits(sides.after, changedConditionLines)]);
|
|
625
|
+
// A test's checks and mocks are not input validation of the code under review.
|
|
626
|
+
record("validationChanged", testLikeFile(file) ? null : firstEvidence(validation));
|
|
627
|
+
record("failurePropagated", firstEvidence(difference([...lineHits(sides.before, PROPAGATION), ...guardHits(sides.before, changedConditionLines)], [...lineHits(sides.after, PROPAGATION), ...guardHits(sides.after, changedConditionLines)])));
|
|
628
|
+
record("failureDeferred", firstEvidence(difference(lineHits(sides.before, DEFERRAL), lineHits(sides.after, DEFERRAL))));
|
|
629
|
+
record("failureDiscarded", firstEvidence(difference(discardHits(sides.before, language), discardHits(sides.after, language))));
|
|
630
|
+
// A migration's up/down is not a contract others build on; its data fact covers it.
|
|
631
|
+
if (!MIGRATION_PATH.test(file)) {
|
|
632
|
+
record("contractChanged", firstEvidence(difference(contractHits(sides.before), contractHits(sides.after))));
|
|
633
|
+
}
|
|
634
|
+
record("queryChanged", firstEvidence(difference(queryHits(sides.before), queryHits(sides.after))));
|
|
635
|
+
const data = firstEvidence(difference(literalHits(sides.before, DATA_CHANGE), literalHits(sides.after, DATA_CHANGE)));
|
|
636
|
+
record("dataChanged", data ?? (MIGRATION_PATH.test(file) && !testLikeFile(file) ? firstChangedLine(sides) : null));
|
|
637
|
+
}
|
|
638
|
+
/** Lines that say nothing about what a migration does. */
|
|
639
|
+
const TRIVIAL_LINE = /^(?:S;?|import\b|export\s*\{|(?:const|let|var)\s+[\w{}\s,:]+=\s*require\(|[{}()[\];,]+$|module\.exports\s*=\s*\{$)/;
|
|
640
|
+
/** The first telling changed line, added side first: what a migration hunk does. */
|
|
641
|
+
function firstChangedLine(sides) {
|
|
642
|
+
const telling = (line) => line.changed && line.code !== "" && !TRIVIAL_LINE.test(line.code);
|
|
643
|
+
const added = sides.after.find(telling);
|
|
644
|
+
if (added !== undefined)
|
|
645
|
+
return evidenceOf({ key: "", line: added }, "added");
|
|
646
|
+
const removed = sides.before.find(telling);
|
|
647
|
+
return removed === undefined ? null : evidenceOf({ key: "", line: removed }, "removed");
|
|
648
|
+
}
|
|
649
|
+
/** The facts for one hunk, each `yes` with the changed line it rests on. */
|
|
650
|
+
export function changeFactsOf(unit) {
|
|
651
|
+
const language = changeFactLanguageOf(unit.file);
|
|
652
|
+
if (language === null)
|
|
653
|
+
return { language, inert: null, answers: {}, evidence: {} };
|
|
654
|
+
const sides = sidesOf(unit.diff, scannerFor(language, unit.file));
|
|
655
|
+
const answers = {};
|
|
656
|
+
for (const question of factQuestionsFor(language))
|
|
657
|
+
answers[question] = "no";
|
|
658
|
+
const evidence = {};
|
|
659
|
+
const record = (question, found) => {
|
|
660
|
+
if (found === null)
|
|
661
|
+
return;
|
|
662
|
+
answers[question] = "yes";
|
|
663
|
+
evidence[question] = found;
|
|
664
|
+
};
|
|
665
|
+
if (language === "prose") {
|
|
666
|
+
proseFacts(sides, record);
|
|
667
|
+
return { language, inert: proseInert(sides), answers, evidence };
|
|
668
|
+
}
|
|
669
|
+
if (language === "config") {
|
|
670
|
+
configFacts(sides, record, unit.file);
|
|
671
|
+
return { language, inert: isInert(sides, language), answers, evidence };
|
|
672
|
+
}
|
|
673
|
+
if (language === "sql") {
|
|
674
|
+
record("dataChanged", firstEvidence(difference(literalHits(sides.before, DATA_CHANGE), literalHits(sides.after, DATA_CHANGE))));
|
|
675
|
+
return { language, inert: isInert(sides, "config"), answers, evidence };
|
|
676
|
+
}
|
|
677
|
+
codeFacts(sides, language, record, unit.file);
|
|
678
|
+
return { language, inert: isInert(sides, language), importsOnly: importsOnly(sides), answers, evidence };
|
|
679
|
+
}
|
|
680
|
+
const IMPORT_START = /^(?:import\b|export\s+(?:\*|\{[^}]*\})\s+from\b|from\s+[\w.]+\s+import\b|using\s+[\w.]+\s*;|(?:const|let|var)\s+[\w{}\s,:]+=\s*require\()/;
|
|
681
|
+
const IMPORT_LIST_OPEN = /^(?:import\b[^;]*\{[^}]*$|import\s*\($|from\s+[\w.]+\s+import\s*\($|export\s*\{[^}]*$)/;
|
|
682
|
+
const IMPORT_LIST_CLOSE = /[})]/;
|
|
683
|
+
const IMPORT_LIST_ITEM = /^(?:type\s+)?[\w$]+(?:\s+as\s+[\w$]+)?,?$/;
|
|
684
|
+
const IMPORT_LIST_END = /^\}\s*from\b/;
|
|
685
|
+
/** Whether each changed line of a side sits in an import statement or list. */
|
|
686
|
+
function importLines(lines) {
|
|
687
|
+
// A hunk can start inside an import list whose `import {` it never shows: names
|
|
688
|
+
// that run down to a `} from '…'` line belong to that list.
|
|
689
|
+
const tail = Array.from({ length: lines.length }, () => false);
|
|
690
|
+
let inTail = false;
|
|
691
|
+
for (let index = lines.length - 1; index >= 0; index--) {
|
|
692
|
+
const code = lines[index].code.trim();
|
|
693
|
+
if (IMPORT_LIST_END.test(code))
|
|
694
|
+
inTail = true;
|
|
695
|
+
else if (inTail && !(IMPORT_LIST_ITEM.test(code) || code === ""))
|
|
696
|
+
inTail = false;
|
|
697
|
+
tail[index] = inTail;
|
|
698
|
+
}
|
|
699
|
+
let inList = false;
|
|
700
|
+
return lines.map((line, index) => {
|
|
701
|
+
if (tail[index])
|
|
702
|
+
return true;
|
|
703
|
+
const code = line.code.trim();
|
|
704
|
+
if (inList) {
|
|
705
|
+
if (IMPORT_LIST_CLOSE.test(code))
|
|
706
|
+
inList = false;
|
|
707
|
+
return true;
|
|
708
|
+
}
|
|
709
|
+
if (IMPORT_LIST_OPEN.test(code)) {
|
|
710
|
+
inList = true;
|
|
711
|
+
return true;
|
|
712
|
+
}
|
|
713
|
+
return IMPORT_START.test(code);
|
|
714
|
+
});
|
|
715
|
+
}
|
|
716
|
+
function importsOnly(sides) {
|
|
717
|
+
let changed = 0;
|
|
718
|
+
for (const lines of [sides.before, sides.after]) {
|
|
719
|
+
const imports = importLines(lines);
|
|
720
|
+
for (const [index, line] of lines.entries()) {
|
|
721
|
+
if (!line.changed || line.code === "")
|
|
722
|
+
continue;
|
|
723
|
+
changed += 1;
|
|
724
|
+
if (!imports[index])
|
|
725
|
+
return false;
|
|
726
|
+
}
|
|
727
|
+
}
|
|
728
|
+
return changed > 0;
|
|
729
|
+
}
|