diffninja 0.3.1 → 0.4.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/README.md +65 -11
- package/dist/executables.d.ts +18 -0
- package/dist/executables.js +32 -0
- package/dist/git.d.ts +26 -1
- package/dist/git.js +56 -4
- package/dist/languages/child-env.d.ts +11 -0
- package/dist/languages/child-env.js +60 -0
- package/dist/languages/grammar-lock.d.ts +569 -0
- package/dist/languages/grammar-lock.js +574 -0
- package/dist/languages/grammars.d.ts +69 -9
- package/dist/languages/grammars.js +186 -119
- package/dist/review/call-flow-html.d.ts +3 -1
- package/dist/review/call-flow-html.js +13 -11
- package/dist/review/change-facts.d.ts +21 -1
- package/dist/review/change-facts.js +271 -49
- package/dist/review/cli.js +13 -1
- package/dist/review/connected-analysis.d.ts +4 -1
- package/dist/review/connected-analysis.js +4 -2
- package/dist/review/connected-html.d.ts +15 -4
- package/dist/review/connected-html.js +342 -35
- package/dist/review/connected.js +47 -14
- package/dist/review/escape-html.d.ts +5 -1
- package/dist/review/escape-html.js +7 -2
- package/dist/review/explanation.d.ts +4 -0
- package/dist/review/explanation.js +6 -1
- package/dist/review/github.d.ts +56 -0
- package/dist/review/github.js +234 -33
- package/dist/review/grammars-command.d.ts +12 -0
- package/dist/review/grammars-command.js +60 -0
- package/dist/review/hidden-characters.d.ts +31 -0
- package/dist/review/hidden-characters.js +113 -0
- package/dist/review/history.js +7 -3
- package/dist/review/html.d.ts +3 -2
- package/dist/review/html.js +19 -18
- package/dist/review/input.js +5 -2
- package/dist/review/intent.d.ts +7 -0
- package/dist/review/intent.js +25 -3
- package/dist/review/markdown.js +11 -0
- package/dist/review/mcp-cli.js +4 -1
- package/dist/review/mcp.d.ts +7 -1
- package/dist/review/mcp.js +145 -59
- package/dist/review/pipeline.d.ts +2 -1
- package/dist/review/pipeline.js +4 -3
- package/dist/review/pr-input.d.ts +7 -0
- package/dist/review/pr-input.js +25 -3
- package/dist/review/process-html.d.ts +1 -5
- package/dist/review/process-html.js +3 -12
- package/dist/review/questions.js +11 -3
- package/dist/review/reference-check.d.ts +5 -1
- package/dist/review/reference-check.js +40 -16
- package/dist/review/report-pages.d.ts +24 -9
- package/dist/review/report-pages.js +111 -28
- package/dist/review/result-budget.d.ts +28 -0
- package/dist/review/result-budget.js +136 -0
- package/dist/review/service.js +26 -2
- package/dist/review/setup.d.ts +1 -1
- package/dist/review/setup.js +9 -4
- package/dist/review/types.d.ts +19 -5
- package/dist/review/types.js +2 -1
- package/dist/review/update-check.d.ts +35 -0
- package/dist/review/update-check.js +76 -0
- package/dist/run.js +11 -5
- package/npm-shrinkwrap.json +3483 -0
- package/package.json +3 -2
package/dist/review/connected.js
CHANGED
|
@@ -11,6 +11,7 @@ const reviewSchema = z.object({
|
|
|
11
11
|
snapshotId: z.string(), event: z.enum(["COMMENT", "APPROVE", "REQUEST_CHANGES"]), body: z.string(),
|
|
12
12
|
comments: z.array(z.object({ path: z.string(), line: z.number().int().positive(), side: z.enum(["LEFT", "RIGHT"]), body: z.string() }).strict()),
|
|
13
13
|
}).strict();
|
|
14
|
+
const viewedSchema = z.object({ snapshotId: z.string(), path: z.string(), viewed: z.boolean() }).strict();
|
|
14
15
|
const emptySchema = z.object({}).strict();
|
|
15
16
|
const tokenSchema = z.string().regex(/^[a-f0-9]{64}$/);
|
|
16
17
|
function described(state) {
|
|
@@ -62,31 +63,60 @@ function requestIsTrusted(req, origin) {
|
|
|
62
63
|
const site = req.headers["sec-fetch-site"];
|
|
63
64
|
return !site || ["same-origin", "none"].includes(String(site));
|
|
64
65
|
}
|
|
66
|
+
/**
|
|
67
|
+
* The part of a request path after this session's secret prefix, or undefined
|
|
68
|
+
* when the path does not start with it. Every route of the session lives under
|
|
69
|
+
* `/<256-bit secret>/`: a process that can reach the loopback port but was never
|
|
70
|
+
* handed the link (another local user, a script the reviewed code runs) finds
|
|
71
|
+
* nothing to read and nothing to post. Host, Origin and the CSRF token below
|
|
72
|
+
* only defend against browsers; this is what defends against the rest.
|
|
73
|
+
*/
|
|
74
|
+
function routeOf(url, secret) {
|
|
75
|
+
if (url === undefined || url.length < secret.length + 2 || url[0] !== "/" || url[secret.length + 1] !== "/")
|
|
76
|
+
return undefined;
|
|
77
|
+
const presented = Buffer.from(url.slice(1, secret.length + 1));
|
|
78
|
+
const expected = Buffer.from(secret);
|
|
79
|
+
if (presented.length !== expected.length || !timingSafeEqual(presented, expected))
|
|
80
|
+
return undefined;
|
|
81
|
+
return url.slice(secret.length + 2);
|
|
82
|
+
}
|
|
65
83
|
/** A single ephemeral session; never exposes a general GitHub API proxy. */
|
|
66
84
|
export async function serveConnected(review = new ConnectedReview(), options = {}) {
|
|
67
85
|
const csrf = randomBytes(32).toString("hex");
|
|
86
|
+
const secret = randomBytes(32).toString("hex");
|
|
87
|
+
const base = `/${secret}/`;
|
|
68
88
|
let origin = "";
|
|
69
89
|
const server = createServer(async (req, res) => {
|
|
70
90
|
res.setHeader("Cache-Control", "no-store");
|
|
71
91
|
res.setHeader("X-Content-Type-Options", "nosniff");
|
|
72
92
|
res.setHeader("X-Frame-Options", "DENY");
|
|
73
93
|
res.setHeader("Referrer-Policy", "no-referrer");
|
|
74
|
-
res.setHeader("
|
|
94
|
+
res.setHeader("Cross-Origin-Resource-Policy", "same-origin");
|
|
95
|
+
res.setHeader("Cross-Origin-Opener-Policy", "same-origin");
|
|
96
|
+
res.setHeader("Content-Security-Policy", "default-src 'none'; frame-ancestors 'none'");
|
|
75
97
|
const json = (code, value) => { res.writeHead(code, { "Content-Type": "application/json" }); res.end(JSON.stringify(withDescription(value))); };
|
|
76
98
|
if (!requestIsTrusted(req, origin)) {
|
|
77
99
|
json(403, { error: "Untrusted Host or Origin." });
|
|
78
100
|
return;
|
|
79
101
|
}
|
|
80
|
-
|
|
102
|
+
const route = routeOf(req.url, secret);
|
|
103
|
+
if (route === undefined) {
|
|
104
|
+
json(404, { error: "Not found." });
|
|
105
|
+
return;
|
|
106
|
+
}
|
|
107
|
+
if (req.method === "GET" && route === "") {
|
|
108
|
+
// A fresh nonce per response, never the CSRF token the script carries.
|
|
109
|
+
const nonce = randomBytes(16).toString("base64url");
|
|
110
|
+
res.setHeader("Content-Security-Policy", `default-src 'none'; script-src 'nonce-${nonce}'; style-src 'nonce-${nonce}'; connect-src 'self'; frame-src 'self'; base-uri 'none'; form-action 'none'; frame-ancestors 'none'`);
|
|
81
111
|
res.writeHead(200, { "Content-Type": "text/html; charset=utf-8" });
|
|
82
|
-
res.end(renderConnectedPage(csrf));
|
|
112
|
+
res.end(renderConnectedPage({ csrf, nonce, base }));
|
|
83
113
|
return;
|
|
84
114
|
}
|
|
85
|
-
if (req.method === "GET" &&
|
|
115
|
+
if (req.method === "GET" && route === "api/state") {
|
|
86
116
|
json(200, review.getState());
|
|
87
117
|
return;
|
|
88
118
|
}
|
|
89
|
-
if (req.method === "GET" &&
|
|
119
|
+
if (req.method === "GET" && route === "api/analysis") {
|
|
90
120
|
try {
|
|
91
121
|
json(200, options.analysis === undefined ? NO_ANALYSIS : await options.analysis());
|
|
92
122
|
}
|
|
@@ -95,8 +125,8 @@ export async function serveConnected(review = new ConnectedReview(), options = {
|
|
|
95
125
|
}
|
|
96
126
|
return;
|
|
97
127
|
}
|
|
98
|
-
if (req.method === "GET" &&
|
|
99
|
-
const query = new URL(
|
|
128
|
+
if (req.method === "GET" && route.startsWith("flow?")) {
|
|
129
|
+
const query = new URL(route, origin).searchParams;
|
|
100
130
|
const snapshotId = query.get("snapshot") ?? "";
|
|
101
131
|
const file = query.get("file") ?? undefined;
|
|
102
132
|
const view = query.get("view") === "business" ? "business" : "flow";
|
|
@@ -118,8 +148,8 @@ export async function serveConnected(review = new ConnectedReview(), options = {
|
|
|
118
148
|
res.end(html);
|
|
119
149
|
return;
|
|
120
150
|
}
|
|
121
|
-
const routes = ["
|
|
122
|
-
if (req.method !== "POST" || !routes.includes(
|
|
151
|
+
const routes = ["api/load", "api/preview", "api/submit", "api/reconcile", "api/viewed"];
|
|
152
|
+
if (req.method !== "POST" || !routes.includes(route)) {
|
|
123
153
|
json(404, { error: "Not found." });
|
|
124
154
|
return;
|
|
125
155
|
}
|
|
@@ -131,16 +161,19 @@ export async function serveConnected(review = new ConnectedReview(), options = {
|
|
|
131
161
|
try {
|
|
132
162
|
const text = await readBody(req);
|
|
133
163
|
let result;
|
|
134
|
-
switch (
|
|
135
|
-
case "
|
|
164
|
+
switch (route) {
|
|
165
|
+
case "api/load":
|
|
136
166
|
result = await review.load(loadSchema.parse(JSON.parse(text)).url);
|
|
137
167
|
break;
|
|
138
|
-
case "
|
|
168
|
+
case "api/preview":
|
|
139
169
|
result = await review.preview(reviewSchema.parse(JSON.parse(text)));
|
|
140
170
|
break;
|
|
141
|
-
case "
|
|
171
|
+
case "api/submit":
|
|
142
172
|
result = await review.submit(reviewSchema.parse(JSON.parse(text)));
|
|
143
173
|
break;
|
|
174
|
+
case "api/viewed":
|
|
175
|
+
result = await review.setViewed(viewedSchema.parse(JSON.parse(text)));
|
|
176
|
+
break;
|
|
144
177
|
default:
|
|
145
178
|
emptySchema.parse(JSON.parse(text));
|
|
146
179
|
result = await review.reconcile();
|
|
@@ -158,5 +191,5 @@ export async function serveConnected(review = new ConnectedReview(), options = {
|
|
|
158
191
|
await new Promise((resolve, reject) => { server.once("error", reject); server.listen(0, "127.0.0.1", () => { server.removeListener("error", reject); resolve(); }); });
|
|
159
192
|
const address = z.object({ port: z.number().int().positive() }).parse(server.address());
|
|
160
193
|
origin = `http://127.0.0.1:${address.port}`;
|
|
161
|
-
return { server, url: origin +
|
|
194
|
+
return { server, url: origin + base };
|
|
162
195
|
}
|
|
@@ -1,2 +1,6 @@
|
|
|
1
|
-
/**
|
|
1
|
+
/**
|
|
2
|
+
* Escape untrusted text for HTML element and attribute contexts. Bidirectional
|
|
3
|
+
* and other invisible control characters are shown as visible markers, so a page
|
|
4
|
+
* never lets untrusted text reorder or hide what a reviewer reads.
|
|
5
|
+
*/
|
|
2
6
|
export declare function escapeHtml(text: string): string;
|
|
@@ -1,6 +1,11 @@
|
|
|
1
|
-
|
|
1
|
+
import { visibleControls } from "./hidden-characters.js";
|
|
2
|
+
/**
|
|
3
|
+
* Escape untrusted text for HTML element and attribute contexts. Bidirectional
|
|
4
|
+
* and other invisible control characters are shown as visible markers, so a page
|
|
5
|
+
* never lets untrusted text reorder or hide what a reviewer reads.
|
|
6
|
+
*/
|
|
2
7
|
export function escapeHtml(text) {
|
|
3
|
-
return text
|
|
8
|
+
return visibleControls(text)
|
|
4
9
|
.replaceAll("&", "&")
|
|
5
10
|
.replaceAll("<", "<")
|
|
6
11
|
.replaceAll(">", ">")
|
|
@@ -105,6 +105,10 @@ export interface ExplanationCounts {
|
|
|
105
105
|
readonly steps: number;
|
|
106
106
|
readonly rules: number;
|
|
107
107
|
}
|
|
108
|
+
/**
|
|
109
|
+
* The id is minted the way the agent is shown it, hidden characters as
|
|
110
|
+
* ⟦U+XXXX⟧ markers, so the id it sends back names the same function.
|
|
111
|
+
*/
|
|
108
112
|
export declare function functionId(file: string, name: string): string;
|
|
109
113
|
/** The function id a call-flow node's resolved definition has, or undefined when it resolved none. */
|
|
110
114
|
export declare function nodeFunctionId(node: CallFlowNode): string | undefined;
|
|
@@ -14,6 +14,7 @@
|
|
|
14
14
|
* attributed to the client that wrote it, as its reading.
|
|
15
15
|
*/
|
|
16
16
|
import { testLikeFile } from "./file-role.js";
|
|
17
|
+
import { visibleControls } from "./hidden-characters.js";
|
|
17
18
|
/** Most functions one review asks the agent to explain; changed code first. */
|
|
18
19
|
export const MAX_EXPLAINED_FUNCTIONS = 40;
|
|
19
20
|
/** Most processes one explanation draws: the flows a pull request actually touches. */
|
|
@@ -39,8 +40,12 @@ function shortName(name) {
|
|
|
39
40
|
const segments = bare.split(/[.:#]+/).filter((segment) => segment !== "");
|
|
40
41
|
return segments.length === 0 ? bare : segments[segments.length - 1];
|
|
41
42
|
}
|
|
43
|
+
/**
|
|
44
|
+
* The id is minted the way the agent is shown it, hidden characters as
|
|
45
|
+
* ⟦U+XXXX⟧ markers, so the id it sends back names the same function.
|
|
46
|
+
*/
|
|
42
47
|
export function functionId(file, name) {
|
|
43
|
-
return `${file}#${shortName(name)}
|
|
48
|
+
return visibleControls(`${file}#${shortName(name)}`);
|
|
44
49
|
}
|
|
45
50
|
/** The function id a call-flow node's resolved definition has, or undefined when it resolved none. */
|
|
46
51
|
export function nodeFunctionId(node) {
|
package/dist/review/github.d.ts
CHANGED
|
@@ -82,12 +82,41 @@ export interface ConnectedReceipt {
|
|
|
82
82
|
* reconciliation may resolve it.
|
|
83
83
|
*/
|
|
84
84
|
export type ConnectedStatus = "empty" | "ready" | "submitting" | "unknown" | "submitted";
|
|
85
|
+
/** GitHub's Viewed mark for one changed file, as the signed-in account has it. */
|
|
86
|
+
export interface ViewedFile {
|
|
87
|
+
path: string;
|
|
88
|
+
viewed: boolean;
|
|
89
|
+
}
|
|
90
|
+
/**
|
|
91
|
+
* The signed-in account's Viewed marks on the loaded pull request, one entry
|
|
92
|
+
* per changed file. Unavailable when GitHub did not offer them, with a short
|
|
93
|
+
* fixed reason and never GitHub's own words.
|
|
94
|
+
*/
|
|
95
|
+
export type ViewedState = {
|
|
96
|
+
available: true;
|
|
97
|
+
files: ViewedFile[];
|
|
98
|
+
} | {
|
|
99
|
+
available: false;
|
|
100
|
+
reason: string;
|
|
101
|
+
};
|
|
102
|
+
/** What the page sends to mark a file Viewed or not Viewed; it owns nothing else about the write. */
|
|
103
|
+
export interface ViewedInput {
|
|
104
|
+
snapshotId: string;
|
|
105
|
+
path: string;
|
|
106
|
+
viewed: boolean;
|
|
107
|
+
}
|
|
108
|
+
export interface ViewedReceipt {
|
|
109
|
+
path: string;
|
|
110
|
+
viewed: boolean;
|
|
111
|
+
}
|
|
85
112
|
export interface ConnectedState {
|
|
86
113
|
identity?: ConnectedIdentity;
|
|
87
114
|
snapshot?: ConnectedSnapshot;
|
|
88
115
|
status: ConnectedStatus;
|
|
89
116
|
receipt?: ConnectedReceipt;
|
|
90
117
|
message?: string;
|
|
118
|
+
/** Present for a reviewable snapshot; a load reads it, a successful mark updates it. */
|
|
119
|
+
viewed?: ViewedState;
|
|
91
120
|
}
|
|
92
121
|
/** Lowest `gh` release whose `pr view --json` fields and `api --input -` this code needs. */
|
|
93
122
|
export declare const MIN_GH_VERSION = "2.45.0";
|
|
@@ -118,6 +147,12 @@ export interface ConnectedReviewDeps {
|
|
|
118
147
|
runner?: GhRunner;
|
|
119
148
|
timeoutMs?: number;
|
|
120
149
|
}
|
|
150
|
+
/**
|
|
151
|
+
* No prompt, no pager, no update notice, no usage telemetry (gh reports each
|
|
152
|
+
* command, its flag names, a device id and which agent runs it to GitHub unless
|
|
153
|
+
* told not to), and never a host other than github.com.
|
|
154
|
+
*/
|
|
155
|
+
export declare function ghEnvironment(): NodeJS.ProcessEnv;
|
|
121
156
|
/** Runs the real `gh` with an explicit argv (never a shell) and a hard deadline. */
|
|
122
157
|
export declare function ghCliRunner(): GhRunner;
|
|
123
158
|
/** The JSON data model, used to validate each `gh` payload field by field. */
|
|
@@ -125,6 +160,14 @@ export type GhJson = string | number | boolean | null | GhJson[] | GhObject;
|
|
|
125
160
|
export interface GhObject {
|
|
126
161
|
readonly [key: string]: GhJson;
|
|
127
162
|
}
|
|
163
|
+
/**
|
|
164
|
+
* The only GraphQL documents diffninja sends, fixed text. A file path, a cursor
|
|
165
|
+
* and the pull request's node id travel as separate `-f` variables and are never
|
|
166
|
+
* placed in a document.
|
|
167
|
+
*/
|
|
168
|
+
export declare const VIEWED_FILES_QUERY = "query($pullRequestId: ID!, $after: String) { node(id: $pullRequestId) { ... on PullRequest { files(first: 100, after: $after) { nodes { path viewerViewedState } pageInfo { hasNextPage endCursor } } } } }";
|
|
169
|
+
export declare const MARK_VIEWED_MUTATION = "mutation($pullRequestId: ID!, $path: String!) { markFileAsViewed(input: { pullRequestId: $pullRequestId, path: $path }) { clientMutationId } }";
|
|
170
|
+
export declare const UNMARK_VIEWED_MUTATION = "mutation($pullRequestId: ID!, $path: String!) { unmarkFileAsViewed(input: { pullRequestId: $pullRequestId, path: $path }) { clientMutationId } }";
|
|
128
171
|
/**
|
|
129
172
|
* One connected review session. Every method serializes behind the previous
|
|
130
173
|
* one, so an in-flight submission can never be double-fired.
|
|
@@ -144,6 +187,7 @@ export declare class ConnectedReview {
|
|
|
144
187
|
/** Review ids that already existed before this session's write attempt. */
|
|
145
188
|
private preexistingReviewIds;
|
|
146
189
|
private queue;
|
|
190
|
+
private viewedSync;
|
|
147
191
|
constructor(deps?: ConnectedReviewDeps);
|
|
148
192
|
getState(): ConnectedState;
|
|
149
193
|
/** Canonical patch from the last successfully bound snapshot, for static export. */
|
|
@@ -152,6 +196,8 @@ export declare class ConnectedReview {
|
|
|
152
196
|
preview(input: ReviewInput): Promise<ReviewPayload>;
|
|
153
197
|
submit(input: ReviewInput): Promise<ConnectedState>;
|
|
154
198
|
reconcile(): Promise<ConnectedState>;
|
|
199
|
+
/** Mark one changed file of the loaded snapshot Viewed, or not, on GitHub. */
|
|
200
|
+
setViewed(input: ViewedInput): Promise<ViewedReceipt>;
|
|
155
201
|
/** Run `work` after everything already queued; failures never break the chain. */
|
|
156
202
|
private serialize;
|
|
157
203
|
private call;
|
|
@@ -181,6 +227,16 @@ export declare class ConnectedReview {
|
|
|
181
227
|
private runLoad;
|
|
182
228
|
private runPreview;
|
|
183
229
|
private runSubmit;
|
|
230
|
+
/** One `gh api graphql` run: a fixed document plus its variables, each a separate raw `-f` argument. */
|
|
231
|
+
private graphql;
|
|
232
|
+
/** The Viewed marks for the files of a fresh snapshot; a read that fails costs the sync, never the load. */
|
|
233
|
+
private readViewed;
|
|
234
|
+
/** Path to viewed, over every page of the pull request's files; VIEWED counts, UNVIEWED and DISMISSED do not. */
|
|
235
|
+
private readViewedMarks;
|
|
236
|
+
private runSetViewed;
|
|
237
|
+
/** A write that may or may not have landed: the marks stay unread until the pull request is loaded again. */
|
|
238
|
+
private unfinishedMark;
|
|
239
|
+
private viewedState;
|
|
184
240
|
private postReview;
|
|
185
241
|
/**
|
|
186
242
|
* Resolve an ambiguous write. This never retries and never assumes: it looks
|