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,77 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Hunk routing and ranking for a diffninja review run.
|
|
3
|
+
*
|
|
4
|
+
* Everything here is local and deterministic: no model is called, nothing leaves
|
|
5
|
+
* the machine, and the same input always produces the same report. Every input
|
|
6
|
+
* unit comes back as exactly one ReviewItem:
|
|
7
|
+
*
|
|
8
|
+
* - a unit the input parser marked special (binary, rename, mode, symbolic
|
|
9
|
+
* link, submodule) and a unit with no diff text go to manual review;
|
|
10
|
+
* - an exact no-op hunk and a blank-only change to a .md/.txt document pass;
|
|
11
|
+
* - every other hunk gets its local change facts ({@link changeFactsOf}): a
|
|
12
|
+
* formatting- or comment-only change passes, a file type diffninja cannot
|
|
13
|
+
* read is `uncertain` for a human to read, a code or configuration change
|
|
14
|
+
* outside a test file is `attention`, documentation is `attention` when it
|
|
15
|
+
* changes an instruction, a link, or a limit and `low` otherwise, and a
|
|
16
|
+
* test-file change is `attention` only when it changes a limit, discards a
|
|
17
|
+
* failure, or weakens a gate, `low` otherwise.
|
|
18
|
+
*
|
|
19
|
+
* Priority orders hunks within the report: a fixed base, a weight for a change
|
|
20
|
+
* that is not inert, and the heaviest fact of the boundary group (what the
|
|
21
|
+
* change says or bounds), of the failure group (failures, gates,
|
|
22
|
+
* permissions), and of the surface group (public declarations, schema, stored
|
|
23
|
+
* data). Each group contributes its maximum, never a sum.
|
|
24
|
+
*
|
|
25
|
+
* The report order puts manual work first, then the read hunks by priority —
|
|
26
|
+
* those outside test files before those in test files — and the passes last;
|
|
27
|
+
* input order breaks ties. Status is a label to filter on and never reorders it.
|
|
28
|
+
*/
|
|
29
|
+
import type { ReviewItem, ReviewUnit } from "./types.js";
|
|
30
|
+
/** Priority every read hunk starts from. */
|
|
31
|
+
export declare const BASE_PRIORITY = 5;
|
|
32
|
+
/** Weight of a change that is not inert: the code, or the text, really differs. */
|
|
33
|
+
export declare const CHANGED_PRIORITY = 10;
|
|
34
|
+
/** Weight of each fact when it is `yes`; `no` adds nothing. */
|
|
35
|
+
export declare const FACT_PRIORITY: {
|
|
36
|
+
comparisonChanged: number;
|
|
37
|
+
limitChanged: number;
|
|
38
|
+
validationChanged: number;
|
|
39
|
+
failurePropagated: number;
|
|
40
|
+
failureDeferred: number;
|
|
41
|
+
failureDiscarded: number;
|
|
42
|
+
contractChanged: number;
|
|
43
|
+
dataChanged: number;
|
|
44
|
+
queryChanged: number;
|
|
45
|
+
instructionChanged: number;
|
|
46
|
+
referenceChanged: number;
|
|
47
|
+
gateWeakened: number;
|
|
48
|
+
permissionChanged: number;
|
|
49
|
+
pinChanged: number;
|
|
50
|
+
};
|
|
51
|
+
/** Fixed priorities for hunks the facts do not rank. */
|
|
52
|
+
export declare const MANUAL_REVIEW_PRIORITY = 70;
|
|
53
|
+
export declare const TRIVIAL_PRIORITY = 5;
|
|
54
|
+
/**
|
|
55
|
+
* Report position of one item: manual work, then read hunks outside test files,
|
|
56
|
+
* then hunks in test files (read or not), then passes; within a position, higher
|
|
57
|
+
* priority first and, among equal priorities, more changed lines first. Test files come after the code they
|
|
58
|
+
* exercise because a regression test changes as much as its fix does; which file
|
|
59
|
+
* is a test is a path fact. Documentation is not demoted: prose can be normative.
|
|
60
|
+
*/
|
|
61
|
+
export declare const REPORT_PLACEMENT: {
|
|
62
|
+
manual: number;
|
|
63
|
+
read: number;
|
|
64
|
+
readTest: number;
|
|
65
|
+
passed: number;
|
|
66
|
+
};
|
|
67
|
+
/** Reason attached to a read hunk the report lists after the non-test hunks. */
|
|
68
|
+
export declare const TEST_FILE_ORDER_REASON: string;
|
|
69
|
+
export interface ReviewPipelineResult {
|
|
70
|
+
readonly items: ReviewItem[];
|
|
71
|
+
/** Run-level notices for the report; per-hunk detail stays in `reasons`. */
|
|
72
|
+
readonly warnings: string[];
|
|
73
|
+
}
|
|
74
|
+
/** Where an item belongs in the report; see {@link REPORT_PLACEMENT}. */
|
|
75
|
+
export declare function placementOf(item: ReviewItem): number;
|
|
76
|
+
/** Route, read, and order every unit. Deterministic: no network, no model, no randomness. */
|
|
77
|
+
export declare function reviewUnits(units: readonly ReviewUnit[]): ReviewPipelineResult;
|
|
@@ -0,0 +1,227 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Hunk routing and ranking for a diffninja review run.
|
|
3
|
+
*
|
|
4
|
+
* Everything here is local and deterministic: no model is called, nothing leaves
|
|
5
|
+
* the machine, and the same input always produces the same report. Every input
|
|
6
|
+
* unit comes back as exactly one ReviewItem:
|
|
7
|
+
*
|
|
8
|
+
* - a unit the input parser marked special (binary, rename, mode, symbolic
|
|
9
|
+
* link, submodule) and a unit with no diff text go to manual review;
|
|
10
|
+
* - an exact no-op hunk and a blank-only change to a .md/.txt document pass;
|
|
11
|
+
* - every other hunk gets its local change facts ({@link changeFactsOf}): a
|
|
12
|
+
* formatting- or comment-only change passes, a file type diffninja cannot
|
|
13
|
+
* read is `uncertain` for a human to read, a code or configuration change
|
|
14
|
+
* outside a test file is `attention`, documentation is `attention` when it
|
|
15
|
+
* changes an instruction, a link, or a limit and `low` otherwise, and a
|
|
16
|
+
* test-file change is `attention` only when it changes a limit, discards a
|
|
17
|
+
* failure, or weakens a gate, `low` otherwise.
|
|
18
|
+
*
|
|
19
|
+
* Priority orders hunks within the report: a fixed base, a weight for a change
|
|
20
|
+
* that is not inert, and the heaviest fact of the boundary group (what the
|
|
21
|
+
* change says or bounds), of the failure group (failures, gates,
|
|
22
|
+
* permissions), and of the surface group (public declarations, schema, stored
|
|
23
|
+
* data). Each group contributes its maximum, never a sum.
|
|
24
|
+
*
|
|
25
|
+
* The report order puts manual work first, then the read hunks by priority —
|
|
26
|
+
* those outside test files before those in test files — and the passes last;
|
|
27
|
+
* input order breaks ties. Status is a label to filter on and never reorders it.
|
|
28
|
+
*/
|
|
29
|
+
import { CHANGE_FACT_QUESTIONS, changeFactsOf, factQuestionsFor, } from "./change-facts.js";
|
|
30
|
+
import { testLikeFile } from "./file-role.js";
|
|
31
|
+
/** Priority every read hunk starts from. */
|
|
32
|
+
export const BASE_PRIORITY = 5;
|
|
33
|
+
/** Weight of a change that is not inert: the code, or the text, really differs. */
|
|
34
|
+
export const CHANGED_PRIORITY = 10;
|
|
35
|
+
/** Weight of each fact when it is `yes`; `no` adds nothing. */
|
|
36
|
+
export const FACT_PRIORITY = {
|
|
37
|
+
comparisonChanged: 6,
|
|
38
|
+
limitChanged: 15,
|
|
39
|
+
validationChanged: 6,
|
|
40
|
+
failurePropagated: 3,
|
|
41
|
+
failureDeferred: 6,
|
|
42
|
+
failureDiscarded: 15,
|
|
43
|
+
contractChanged: 10,
|
|
44
|
+
dataChanged: 15,
|
|
45
|
+
queryChanged: 10,
|
|
46
|
+
instructionChanged: 10,
|
|
47
|
+
referenceChanged: 6,
|
|
48
|
+
gateWeakened: 15,
|
|
49
|
+
permissionChanged: 15,
|
|
50
|
+
pinChanged: 6,
|
|
51
|
+
};
|
|
52
|
+
/** What the change says or bounds: conditions, limits, checks, instructions, links, pins. */
|
|
53
|
+
const BOUNDARY_FACTS = [
|
|
54
|
+
"comparisonChanged", "limitChanged", "validationChanged", "instructionChanged", "referenceChanged", "pinChanged",
|
|
55
|
+
];
|
|
56
|
+
/** What happens when things fail, or who may do what: failures, CI gates, permissions. */
|
|
57
|
+
const FAILURE_FACTS = [
|
|
58
|
+
"failurePropagated", "failureDeferred", "failureDiscarded", "gateWeakened", "permissionChanged",
|
|
59
|
+
];
|
|
60
|
+
/** What others build on or what is stored: public declarations, schema, and data. */
|
|
61
|
+
const SURFACE_FACTS = ["contractChanged", "dataChanged", "queryChanged"];
|
|
62
|
+
/** Facts strong enough to raise a test-file hunk to attention on their own. */
|
|
63
|
+
const TEST_FILE_ATTENTION_FACTS = ["limitChanged", "failureDiscarded", "gateWeakened"];
|
|
64
|
+
/** Fixed priorities for hunks the facts do not rank. */
|
|
65
|
+
export const MANUAL_REVIEW_PRIORITY = 70;
|
|
66
|
+
export const TRIVIAL_PRIORITY = 5;
|
|
67
|
+
/**
|
|
68
|
+
* Report position of one item: manual work, then read hunks outside test files,
|
|
69
|
+
* then hunks in test files (read or not), then passes; within a position, higher
|
|
70
|
+
* priority first and, among equal priorities, more changed lines first. Test files come after the code they
|
|
71
|
+
* exercise because a regression test changes as much as its fix does; which file
|
|
72
|
+
* is a test is a path fact. Documentation is not demoted: prose can be normative.
|
|
73
|
+
*/
|
|
74
|
+
export const REPORT_PLACEMENT = {
|
|
75
|
+
manual: 0,
|
|
76
|
+
read: 1,
|
|
77
|
+
readTest: 2,
|
|
78
|
+
passed: 3,
|
|
79
|
+
};
|
|
80
|
+
/** Reason attached to a read hunk the report lists after the non-test hunks. */
|
|
81
|
+
export const TEST_FILE_ORDER_REASON = "read after the hunks outside test files: the path looks like a test file (a path convention, " +
|
|
82
|
+
"not coverage), and its priority orders it among the other test hunks";
|
|
83
|
+
const FACT_LABEL = {
|
|
84
|
+
comparisonChanged: "comparison changed",
|
|
85
|
+
limitChanged: "limit changed",
|
|
86
|
+
validationChanged: "input check changed",
|
|
87
|
+
failurePropagated: "failure handed to the caller",
|
|
88
|
+
failureDeferred: "failure deferred or retried",
|
|
89
|
+
failureDiscarded: "failure discarded",
|
|
90
|
+
contractChanged: "public contract or declaration changed",
|
|
91
|
+
dataChanged: "schema or stored data changed",
|
|
92
|
+
queryChanged: "database query changed",
|
|
93
|
+
instructionChanged: "instruction to readers changed",
|
|
94
|
+
referenceChanged: "link or reference changed",
|
|
95
|
+
gateWeakened: "CI gate weakened",
|
|
96
|
+
permissionChanged: "permission or secret access changed",
|
|
97
|
+
pinChanged: "version pin changed",
|
|
98
|
+
};
|
|
99
|
+
const STATUS_REASON = {
|
|
100
|
+
attention: "attention: code or configuration outside a test file changed, documentation changed an instruction, link, or limit, or a test changed a limit, discarded a failure, or weakened a gate",
|
|
101
|
+
uncertain: "uncertain: diffninja does not read this file type, so no facts were established and a person reads it",
|
|
102
|
+
low: "low: a test-file change, an import-only change (read where the names are used), or a documentation change with no instruction, link, or limit change",
|
|
103
|
+
passed: "passed: the text is identical once comments and layout are ignored",
|
|
104
|
+
};
|
|
105
|
+
function clampPriority(priority) {
|
|
106
|
+
return Math.max(0, Math.min(100, Math.round(priority)));
|
|
107
|
+
}
|
|
108
|
+
/** Added and removed lines, and how many of them are not blank. */
|
|
109
|
+
function countChangedLines(diff) {
|
|
110
|
+
let added = 0;
|
|
111
|
+
let removed = 0;
|
|
112
|
+
let nonBlank = 0;
|
|
113
|
+
for (const line of diff.split("\n")) {
|
|
114
|
+
if (line.startsWith("+")) {
|
|
115
|
+
added += 1;
|
|
116
|
+
if (line.slice(1).trim() !== "")
|
|
117
|
+
nonBlank += 1;
|
|
118
|
+
}
|
|
119
|
+
else if (line.startsWith("-")) {
|
|
120
|
+
removed += 1;
|
|
121
|
+
if (line.slice(1).trim() !== "")
|
|
122
|
+
nonBlank += 1;
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
return { added, removed, nonBlank };
|
|
126
|
+
}
|
|
127
|
+
/**
|
|
128
|
+
* Pass rules that need no facts: a hunk with no added or removed lines, and a
|
|
129
|
+
* blank-only change to a text document (.md/.txt).
|
|
130
|
+
*/
|
|
131
|
+
function deterministicPassReason(unit) {
|
|
132
|
+
const changed = countChangedLines(unit.diff);
|
|
133
|
+
if (changed.added === 0 && changed.removed === 0) {
|
|
134
|
+
return "exact no-op: the hunk adds and removes no lines";
|
|
135
|
+
}
|
|
136
|
+
if (changed.nonBlank > 0)
|
|
137
|
+
return null;
|
|
138
|
+
const path = unit.file.toLowerCase();
|
|
139
|
+
if (path.endsWith(".md") || path.endsWith(".txt")) {
|
|
140
|
+
return `blank-only change to a text document (${changed.added} added, ${changed.removed} removed blank line(s))`;
|
|
141
|
+
}
|
|
142
|
+
return null;
|
|
143
|
+
}
|
|
144
|
+
function manualReason(unit) {
|
|
145
|
+
if (unit.special !== undefined && unit.special !== "") {
|
|
146
|
+
return `special unit (${unit.special}): the diff carries no reviewable text, so a person reviews it`;
|
|
147
|
+
}
|
|
148
|
+
if (unit.diff.trim() === "")
|
|
149
|
+
return "no diff text was supplied for this hunk; a person reviews it";
|
|
150
|
+
return null;
|
|
151
|
+
}
|
|
152
|
+
function statusOf(unit, facts) {
|
|
153
|
+
if (facts.language === null)
|
|
154
|
+
return "uncertain";
|
|
155
|
+
if (facts.inert)
|
|
156
|
+
return "passed";
|
|
157
|
+
const yes = (question) => facts.answers[question] === "yes";
|
|
158
|
+
if (testLikeFile(unit.file))
|
|
159
|
+
return TEST_FILE_ATTENTION_FACTS.some(yes) ? "attention" : "low";
|
|
160
|
+
// Prose matters when it tells a reader something new to do, follow, or rely on.
|
|
161
|
+
if (facts.language === "prose")
|
|
162
|
+
return factQuestionsFor("prose").some(yes) ? "attention" : "low";
|
|
163
|
+
// Import wiring is read where the imported names are used.
|
|
164
|
+
if (facts.importsOnly === true)
|
|
165
|
+
return "low";
|
|
166
|
+
return "attention";
|
|
167
|
+
}
|
|
168
|
+
function priorityOf(facts) {
|
|
169
|
+
if (facts.inert || facts.importsOnly === true)
|
|
170
|
+
return TRIVIAL_PRIORITY;
|
|
171
|
+
const heaviest = (group) => Math.max(0, ...group.filter((question) => facts.answers[question] === "yes").map((question) => FACT_PRIORITY[question]));
|
|
172
|
+
return clampPriority(BASE_PRIORITY + CHANGED_PRIORITY + heaviest(BOUNDARY_FACTS) + heaviest(FAILURE_FACTS) + heaviest(SURFACE_FACTS));
|
|
173
|
+
}
|
|
174
|
+
/** One sentence per established fact, citing the changed line it rests on. */
|
|
175
|
+
function reasonsOf(unit, facts, status) {
|
|
176
|
+
const reasons = [];
|
|
177
|
+
for (const question of CHANGE_FACT_QUESTIONS) {
|
|
178
|
+
const evidence = facts.evidence[question];
|
|
179
|
+
if (facts.answers[question] === "yes" && evidence !== undefined) {
|
|
180
|
+
reasons.push(`${FACT_LABEL[question]} — ${evidence.side} line: ${evidence.text}`);
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
reasons.push(STATUS_REASON[status]);
|
|
184
|
+
if (status !== "passed" && facts.language !== null && testLikeFile(unit.file))
|
|
185
|
+
reasons.push(TEST_FILE_ORDER_REASON);
|
|
186
|
+
return reasons;
|
|
187
|
+
}
|
|
188
|
+
function readItem(unit) {
|
|
189
|
+
const facts = changeFactsOf(unit);
|
|
190
|
+
const status = statusOf(unit, facts);
|
|
191
|
+
return { ...unit, status, priority: priorityOf(facts), reasons: reasonsOf(unit, facts, status), facts };
|
|
192
|
+
}
|
|
193
|
+
/** Where an item belongs in the report; see {@link REPORT_PLACEMENT}. */
|
|
194
|
+
export function placementOf(item) {
|
|
195
|
+
if (item.status === "passed")
|
|
196
|
+
return REPORT_PLACEMENT.passed;
|
|
197
|
+
if (item.facts === undefined)
|
|
198
|
+
return REPORT_PLACEMENT.manual;
|
|
199
|
+
// A test file diffninja cannot read (a snapshot, expected compiler output) is
|
|
200
|
+
// still a test file: it belongs with the tests, not ahead of the code.
|
|
201
|
+
return testLikeFile(item.file) ? REPORT_PLACEMENT.readTest : REPORT_PLACEMENT.read;
|
|
202
|
+
}
|
|
203
|
+
function changedLines(item) {
|
|
204
|
+
return item.added + item.removed;
|
|
205
|
+
}
|
|
206
|
+
/** Route, read, and order every unit. Deterministic: no network, no model, no randomness. */
|
|
207
|
+
export function reviewUnits(units) {
|
|
208
|
+
const items = units.map((unit, index) => {
|
|
209
|
+
const manual = manualReason(unit);
|
|
210
|
+
if (manual !== null) {
|
|
211
|
+
return { index, item: { ...unit, status: "uncertain", priority: MANUAL_REVIEW_PRIORITY, reasons: [manual] } };
|
|
212
|
+
}
|
|
213
|
+
const pass = deterministicPassReason(unit);
|
|
214
|
+
if (pass !== null) {
|
|
215
|
+
return { index, item: { ...unit, status: "passed", priority: TRIVIAL_PRIORITY, reasons: [pass] } };
|
|
216
|
+
}
|
|
217
|
+
return { index, item: readItem(unit) };
|
|
218
|
+
});
|
|
219
|
+
items.sort((left, right) => placementOf(left.item) - placementOf(right.item) ||
|
|
220
|
+
right.item.priority - left.item.priority ||
|
|
221
|
+
// Equal priority: the larger change first. On maintainer-reviewed pull
|
|
222
|
+
// requests the hunk they commented on was, among equals, usually the one
|
|
223
|
+
// that changed the most lines, not the first one in path order.
|
|
224
|
+
changedLines(right.item) - changedLines(left.item) ||
|
|
225
|
+
left.index - right.index);
|
|
226
|
+
return { items: items.map((entry) => entry.item), warnings: [] };
|
|
227
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pull request detection shared by the CLI and the MCP server.
|
|
3
|
+
*
|
|
4
|
+
* Inputs are arbitrary text: a bare URL, a sentence with a link in it, or the
|
|
5
|
+
* value half of `--flag=value`. Detection is content based, so no caller has to
|
|
6
|
+
* know which argument carried the link. Only github.com pull requests count,
|
|
7
|
+
* and free text that never claims one is ignored rather than rejected. Text
|
|
8
|
+
* that does claim a github.com pull request and cannot be read is refused
|
|
9
|
+
* loudly: a mistyped target must never silently become a review of something
|
|
10
|
+
* else. The canonical URL returned always passes the strict parser the
|
|
11
|
+
* connected session itself uses.
|
|
12
|
+
*/
|
|
13
|
+
/**
|
|
14
|
+
* The one pull request named by any of the given strings, as
|
|
15
|
+
* `https://github.com/owner/repo/pull/123`. Empty strings are ignored. An
|
|
16
|
+
* unreadable github.com pull request URL, or two different pull requests, is an
|
|
17
|
+
* error: neither may be resolved by guessing which one was meant.
|
|
18
|
+
*/
|
|
19
|
+
export declare function detectPullRequest(inputs: readonly string[]): string | undefined;
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pull request detection shared by the CLI and the MCP server.
|
|
3
|
+
*
|
|
4
|
+
* Inputs are arbitrary text: a bare URL, a sentence with a link in it, or the
|
|
5
|
+
* value half of `--flag=value`. Detection is content based, so no caller has to
|
|
6
|
+
* know which argument carried the link. Only github.com pull requests count,
|
|
7
|
+
* and free text that never claims one is ignored rather than rejected. Text
|
|
8
|
+
* that does claim a github.com pull request and cannot be read is refused
|
|
9
|
+
* loudly: a mistyped target must never silently become a review of something
|
|
10
|
+
* else. The canonical URL returned always passes the strict parser the
|
|
11
|
+
* connected session itself uses.
|
|
12
|
+
*/
|
|
13
|
+
const GITHUB_HOST = "github.com";
|
|
14
|
+
const GITHUB_WWW_HOST = `www.${GITHUB_HOST}`;
|
|
15
|
+
const PR_MARKER = "/pull/";
|
|
16
|
+
const SCHEME = /^([A-Za-z][A-Za-z0-9+.-]*):\/\//;
|
|
17
|
+
/** Owner and repository segments GitHub accepts; also excludes `.` and `..`. */
|
|
18
|
+
const NAME = /^[A-Za-z0-9._-]+$/;
|
|
19
|
+
/** `/owner/repo/pull/123`, then an optional path, query, or fragment. */
|
|
20
|
+
const PR_PATH = /^\/([^/]+)\/([^/]+)\/pull\/(\d+)(?:\.(?:diff|patch))?(?=\/|$)/;
|
|
21
|
+
/**
|
|
22
|
+
* Characters that end the argument-shaped token around a URL: whitespace,
|
|
23
|
+
* quotes — including the typographic ones a paste carries — brackets, markdown
|
|
24
|
+
* and shell punctuation, and the zero-width formatting characters that ride
|
|
25
|
+
* along with copied links. `#` ends a token too: it can only follow a URL,
|
|
26
|
+
* never sit inside the part that names a pull request. `&` ends one because a
|
|
27
|
+
* glued `&x=1` or `&` is not part of the address a reviewer copied.
|
|
28
|
+
*/
|
|
29
|
+
const TOKEN_BREAK = /[\s"'`“”‘’«»<>,;|\\=*()[\]{}【】&…–—#\u200b-\u200f\u2060\ufeff]/u;
|
|
30
|
+
/** Trailing sentence punctuation, which is never part of the URL. */
|
|
31
|
+
const TRAILING = /[\s.,;:!?]+$/u;
|
|
32
|
+
const UNSUPPORTED_PROBLEM = "Only https://github.com pull request URLs are supported.";
|
|
33
|
+
const CREDENTIALS_PROBLEM = "Remove the username or token from the pull request URL.";
|
|
34
|
+
const FORM_PROBLEM = "Enter a pull request URL of the form https://github.com/owner/repo/pull/123.";
|
|
35
|
+
const RANGE_PROBLEM = "That pull request number is out of range.";
|
|
36
|
+
/** Where the last `scheme://` in `head` starts; 0 when it holds no scheme. */
|
|
37
|
+
function schemeStart(head) {
|
|
38
|
+
const scheme = /[A-Za-z][A-Za-z0-9+.-]*:\/\//gu;
|
|
39
|
+
let start = 0;
|
|
40
|
+
for (let match = scheme.exec(head); match !== null; match = scheme.exec(head))
|
|
41
|
+
start = match.index;
|
|
42
|
+
return start;
|
|
43
|
+
}
|
|
44
|
+
/** Every `/pull/`-shaped token in one string; a bare host or repo is not one. */
|
|
45
|
+
function pullTokens(text) {
|
|
46
|
+
const tokens = [];
|
|
47
|
+
for (let index = text.indexOf(PR_MARKER); index !== -1; index = text.indexOf(PR_MARKER, index + PR_MARKER.length)) {
|
|
48
|
+
let start = index;
|
|
49
|
+
while (start > 0 && !TOKEN_BREAK.test(text[start - 1]))
|
|
50
|
+
start--;
|
|
51
|
+
let end = index + PR_MARKER.length;
|
|
52
|
+
while (end < text.length && !TOKEN_BREAK.test(text[end]))
|
|
53
|
+
end++;
|
|
54
|
+
// Text glued to a link keeps its own words in the token (`PR:https://…`,
|
|
55
|
+
// a page whose path holds the link); the link itself starts at the last
|
|
56
|
+
// scheme before the marker, so glue can never hide a real link.
|
|
57
|
+
start += schemeStart(text.slice(start, index + PR_MARKER.length));
|
|
58
|
+
const token = text.slice(start, end).replace(TRAILING, "");
|
|
59
|
+
if (token !== "")
|
|
60
|
+
tokens.push(token);
|
|
61
|
+
}
|
|
62
|
+
return tokens;
|
|
63
|
+
}
|
|
64
|
+
/**
|
|
65
|
+
* The canonical pull request URL one token names, or null when the token is not
|
|
66
|
+
* about github.com at all. A token that is about github.com and is unreadable
|
|
67
|
+
* throws instead, so a mistyped URL is never silently ignored.
|
|
68
|
+
*/
|
|
69
|
+
function canonicalPullUrl(token) {
|
|
70
|
+
let rest = token;
|
|
71
|
+
const scheme = SCHEME.exec(rest);
|
|
72
|
+
if (scheme !== null) {
|
|
73
|
+
if (!/^https?$/iu.test(scheme[1])) {
|
|
74
|
+
if (rest.toLowerCase().includes(GITHUB_HOST))
|
|
75
|
+
throw new Error(UNSUPPORTED_PROBLEM);
|
|
76
|
+
return null;
|
|
77
|
+
}
|
|
78
|
+
rest = rest.slice(scheme[0].length);
|
|
79
|
+
}
|
|
80
|
+
else if (rest.startsWith("//")) {
|
|
81
|
+
rest = rest.slice(2);
|
|
82
|
+
}
|
|
83
|
+
const boundary = rest.search(/[/?#]/u);
|
|
84
|
+
const authority = (boundary === -1 ? rest : rest.slice(0, boundary)).toLowerCase();
|
|
85
|
+
const path = boundary === -1 ? "" : rest.slice(boundary);
|
|
86
|
+
const host = authority.slice(authority.lastIndexOf("@") + 1);
|
|
87
|
+
// Another host entirely: free text, not a claim about a pull request. A host
|
|
88
|
+
// that merely contains github.com (github.com.evil.com, notgithub.com, a
|
|
89
|
+
// port) is a claim that cannot be honored, so it is refused.
|
|
90
|
+
if (!host.includes(GITHUB_HOST))
|
|
91
|
+
return null;
|
|
92
|
+
if (host !== GITHUB_HOST && host !== GITHUB_WWW_HOST)
|
|
93
|
+
throw new Error(UNSUPPORTED_PROBLEM);
|
|
94
|
+
if (authority !== host)
|
|
95
|
+
throw new Error(CREDENTIALS_PROBLEM);
|
|
96
|
+
const match = PR_PATH.exec(path.split(/[?#]/u)[0]);
|
|
97
|
+
if (match === null)
|
|
98
|
+
throw new Error(FORM_PROBLEM);
|
|
99
|
+
const [, owner, repo, digits] = match;
|
|
100
|
+
if (!NAME.test(owner) || !NAME.test(repo) || owner === "." || owner === ".." || repo === "." || repo === "..")
|
|
101
|
+
throw new Error(FORM_PROBLEM);
|
|
102
|
+
const number = Number(digits);
|
|
103
|
+
if (!Number.isSafeInteger(number) || number < 1)
|
|
104
|
+
throw new Error(RANGE_PROBLEM);
|
|
105
|
+
return `https://${GITHUB_HOST}/${owner}/${repo}/pull/${number}`;
|
|
106
|
+
}
|
|
107
|
+
/**
|
|
108
|
+
* The one pull request named by any of the given strings, as
|
|
109
|
+
* `https://github.com/owner/repo/pull/123`. Empty strings are ignored. An
|
|
110
|
+
* unreadable github.com pull request URL, or two different pull requests, is an
|
|
111
|
+
* error: neither may be resolved by guessing which one was meant.
|
|
112
|
+
*/
|
|
113
|
+
export function detectPullRequest(inputs) {
|
|
114
|
+
const found = [];
|
|
115
|
+
for (const input of inputs) {
|
|
116
|
+
for (const token of pullTokens(input)) {
|
|
117
|
+
const url = canonicalPullUrl(token);
|
|
118
|
+
if (url !== null && !found.some(existing => existing.toLowerCase() === url.toLowerCase()))
|
|
119
|
+
found.push(url);
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
if (found.length === 0)
|
|
123
|
+
return undefined;
|
|
124
|
+
if (found.length > 1) {
|
|
125
|
+
const shown = found.slice(0, 3).join(", ");
|
|
126
|
+
const extra = found.length > 3 ? `, and ${found.length - 3} more` : "";
|
|
127
|
+
throw new Error(`Found ${found.length} different pull requests (${shown}${extra}). Pass exactly one.`);
|
|
128
|
+
}
|
|
129
|
+
return found[0];
|
|
130
|
+
}
|
|
@@ -0,0 +1,201 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Questions for the reviewing agent's own model.
|
|
3
|
+
*
|
|
4
|
+
* diffninja calls no model. Where a judgment needs meaning rather than syntax —
|
|
5
|
+
* does this change what callers observe, does a test exercise it, does a test
|
|
6
|
+
* change weaken it, does the documentation match the code, does the hunk serve
|
|
7
|
+
* the stated goal — the report asks the agent that requested it. Questions are
|
|
8
|
+
* fixed templates bound to specific hunks, generated deterministically from the
|
|
9
|
+
* facts, each with a closed set of options that always includes `cannot-tell`.
|
|
10
|
+
*
|
|
11
|
+
* Answers are recorded through the `record_answers` tool, validated against
|
|
12
|
+
* these options, attributed to the answering MCP client, and shown beside the
|
|
13
|
+
* hunk. They never change a status, a priority, or the report order: an answer
|
|
14
|
+
* is another reader's view, not a verdict.
|
|
15
|
+
*/
|
|
16
|
+
import type { PullRequestIntent } from "./evidence-types.js";
|
|
17
|
+
import type { ProjectContext } from "./history.js";
|
|
18
|
+
import type { ReviewItem } from "./types.js";
|
|
19
|
+
/** Most questions one report asks; hunks earlier in the report are asked first. */
|
|
20
|
+
export declare const MAX_REVIEW_QUESTIONS = 36;
|
|
21
|
+
/** Most revert and removed-fix questions one report asks; the rest stay in the project context. */
|
|
22
|
+
export declare const MAX_HISTORY_QUESTIONS = 3;
|
|
23
|
+
export declare const QUESTION_OPTIONS: {
|
|
24
|
+
readonly behaviorChange: readonly ["changes-behavior", "no-behavior-change", "cannot-tell"];
|
|
25
|
+
readonly testCoverage: readonly ["exercised", "not-exercised", "cannot-tell"];
|
|
26
|
+
readonly testWeakened: readonly ["weakens", "does-not-weaken", "cannot-tell"];
|
|
27
|
+
readonly docMatchesCode: readonly ["matches", "contradicts", "cannot-tell"];
|
|
28
|
+
readonly intentFit: readonly ["serves", "supports", "unrelated", "contradicts", "cannot-tell"];
|
|
29
|
+
readonly undoesFix: readonly ["keeps-its-purpose", "undoes-it", "cannot-tell"];
|
|
30
|
+
readonly repeatsRevert: readonly ["reintroduces-it", "different-change", "cannot-tell"];
|
|
31
|
+
readonly followsGuidelines: readonly ["follows", "breaks-a-rule", "not-covered", "cannot-tell"];
|
|
32
|
+
readonly followsConvention: readonly ["should-follow", "differs-for-a-reason", "cannot-tell"];
|
|
33
|
+
};
|
|
34
|
+
export type QuestionKind = keyof typeof QUESTION_OPTIONS;
|
|
35
|
+
/** How a recorded answer reads to the human: `watch` asks for a closer look, `unsure` means the agent could not tell. */
|
|
36
|
+
export type VerdictTone = "ok" | "watch" | "unsure" | "quiet";
|
|
37
|
+
export interface Verdict {
|
|
38
|
+
readonly label: string;
|
|
39
|
+
readonly tone: VerdictTone;
|
|
40
|
+
}
|
|
41
|
+
/** The short label a page shows for each answer, so the question's full wording stays with the agent. */
|
|
42
|
+
export declare const ANSWER_VERDICTS: {
|
|
43
|
+
behaviorChange: {
|
|
44
|
+
"changes-behavior": {
|
|
45
|
+
label: string;
|
|
46
|
+
tone: "quiet";
|
|
47
|
+
};
|
|
48
|
+
"no-behavior-change": {
|
|
49
|
+
label: string;
|
|
50
|
+
tone: "quiet";
|
|
51
|
+
};
|
|
52
|
+
"cannot-tell": {
|
|
53
|
+
label: string;
|
|
54
|
+
tone: "unsure";
|
|
55
|
+
};
|
|
56
|
+
};
|
|
57
|
+
testCoverage: {
|
|
58
|
+
exercised: {
|
|
59
|
+
label: string;
|
|
60
|
+
tone: "ok";
|
|
61
|
+
};
|
|
62
|
+
"not-exercised": {
|
|
63
|
+
label: string;
|
|
64
|
+
tone: "watch";
|
|
65
|
+
};
|
|
66
|
+
"cannot-tell": {
|
|
67
|
+
label: string;
|
|
68
|
+
tone: "unsure";
|
|
69
|
+
};
|
|
70
|
+
};
|
|
71
|
+
testWeakened: {
|
|
72
|
+
weakens: {
|
|
73
|
+
label: string;
|
|
74
|
+
tone: "watch";
|
|
75
|
+
};
|
|
76
|
+
"does-not-weaken": {
|
|
77
|
+
label: string;
|
|
78
|
+
tone: "ok";
|
|
79
|
+
};
|
|
80
|
+
"cannot-tell": {
|
|
81
|
+
label: string;
|
|
82
|
+
tone: "unsure";
|
|
83
|
+
};
|
|
84
|
+
};
|
|
85
|
+
docMatchesCode: {
|
|
86
|
+
matches: {
|
|
87
|
+
label: string;
|
|
88
|
+
tone: "ok";
|
|
89
|
+
};
|
|
90
|
+
contradicts: {
|
|
91
|
+
label: string;
|
|
92
|
+
tone: "watch";
|
|
93
|
+
};
|
|
94
|
+
"cannot-tell": {
|
|
95
|
+
label: string;
|
|
96
|
+
tone: "unsure";
|
|
97
|
+
};
|
|
98
|
+
};
|
|
99
|
+
intentFit: {
|
|
100
|
+
serves: {
|
|
101
|
+
label: string;
|
|
102
|
+
tone: "ok";
|
|
103
|
+
};
|
|
104
|
+
supports: {
|
|
105
|
+
label: string;
|
|
106
|
+
tone: "quiet";
|
|
107
|
+
};
|
|
108
|
+
unrelated: {
|
|
109
|
+
label: string;
|
|
110
|
+
tone: "watch";
|
|
111
|
+
};
|
|
112
|
+
contradicts: {
|
|
113
|
+
label: string;
|
|
114
|
+
tone: "watch";
|
|
115
|
+
};
|
|
116
|
+
"cannot-tell": {
|
|
117
|
+
label: string;
|
|
118
|
+
tone: "unsure";
|
|
119
|
+
};
|
|
120
|
+
};
|
|
121
|
+
undoesFix: {
|
|
122
|
+
"keeps-its-purpose": {
|
|
123
|
+
label: string;
|
|
124
|
+
tone: "ok";
|
|
125
|
+
};
|
|
126
|
+
"undoes-it": {
|
|
127
|
+
label: string;
|
|
128
|
+
tone: "watch";
|
|
129
|
+
};
|
|
130
|
+
"cannot-tell": {
|
|
131
|
+
label: string;
|
|
132
|
+
tone: "unsure";
|
|
133
|
+
};
|
|
134
|
+
};
|
|
135
|
+
repeatsRevert: {
|
|
136
|
+
"reintroduces-it": {
|
|
137
|
+
label: string;
|
|
138
|
+
tone: "watch";
|
|
139
|
+
};
|
|
140
|
+
"different-change": {
|
|
141
|
+
label: string;
|
|
142
|
+
tone: "ok";
|
|
143
|
+
};
|
|
144
|
+
"cannot-tell": {
|
|
145
|
+
label: string;
|
|
146
|
+
tone: "unsure";
|
|
147
|
+
};
|
|
148
|
+
};
|
|
149
|
+
followsGuidelines: {
|
|
150
|
+
follows: {
|
|
151
|
+
label: string;
|
|
152
|
+
tone: "ok";
|
|
153
|
+
};
|
|
154
|
+
"breaks-a-rule": {
|
|
155
|
+
label: string;
|
|
156
|
+
tone: "watch";
|
|
157
|
+
};
|
|
158
|
+
"not-covered": {
|
|
159
|
+
label: string;
|
|
160
|
+
tone: "quiet";
|
|
161
|
+
};
|
|
162
|
+
"cannot-tell": {
|
|
163
|
+
label: string;
|
|
164
|
+
tone: "unsure";
|
|
165
|
+
};
|
|
166
|
+
};
|
|
167
|
+
followsConvention: {
|
|
168
|
+
"should-follow": {
|
|
169
|
+
label: string;
|
|
170
|
+
tone: "watch";
|
|
171
|
+
};
|
|
172
|
+
"differs-for-a-reason": {
|
|
173
|
+
label: string;
|
|
174
|
+
tone: "quiet";
|
|
175
|
+
};
|
|
176
|
+
"cannot-tell": {
|
|
177
|
+
label: string;
|
|
178
|
+
tone: "unsure";
|
|
179
|
+
};
|
|
180
|
+
};
|
|
181
|
+
};
|
|
182
|
+
/** The verdict for a recorded answer, or undefined for an answer the question does not list. */
|
|
183
|
+
export declare function verdictOf(kind: QuestionKind, choice: string): Verdict | undefined;
|
|
184
|
+
export interface QuestionAnswer {
|
|
185
|
+
readonly choice: string;
|
|
186
|
+
/** The MCP client that recorded it, as it named itself; never a model identity claim. */
|
|
187
|
+
readonly answeredBy: string;
|
|
188
|
+
readonly answeredAt: string;
|
|
189
|
+
}
|
|
190
|
+
export interface ReviewQuestion {
|
|
191
|
+
/** Stable within one report: `q1`, `q2`, … in report order. */
|
|
192
|
+
readonly id: string;
|
|
193
|
+
readonly kind: QuestionKind;
|
|
194
|
+
/** The hunks the question is about; the first is the one it is asked beside. */
|
|
195
|
+
readonly unitIds: readonly string[];
|
|
196
|
+
readonly text: string;
|
|
197
|
+
readonly options: readonly string[];
|
|
198
|
+
answer?: QuestionAnswer;
|
|
199
|
+
}
|
|
200
|
+
/** The questions for one report, in report order, at most {@link MAX_REVIEW_QUESTIONS}. */
|
|
201
|
+
export declare function reviewQuestions(items: readonly ReviewItem[], intent?: PullRequestIntent, project?: ProjectContext): ReviewQuestion[];
|