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.
Files changed (64) hide show
  1. package/README.md +65 -11
  2. package/dist/executables.d.ts +18 -0
  3. package/dist/executables.js +32 -0
  4. package/dist/git.d.ts +26 -1
  5. package/dist/git.js +56 -4
  6. package/dist/languages/child-env.d.ts +11 -0
  7. package/dist/languages/child-env.js +60 -0
  8. package/dist/languages/grammar-lock.d.ts +569 -0
  9. package/dist/languages/grammar-lock.js +574 -0
  10. package/dist/languages/grammars.d.ts +69 -9
  11. package/dist/languages/grammars.js +186 -119
  12. package/dist/review/call-flow-html.d.ts +3 -1
  13. package/dist/review/call-flow-html.js +13 -11
  14. package/dist/review/change-facts.d.ts +21 -1
  15. package/dist/review/change-facts.js +271 -49
  16. package/dist/review/cli.js +13 -1
  17. package/dist/review/connected-analysis.d.ts +4 -1
  18. package/dist/review/connected-analysis.js +4 -2
  19. package/dist/review/connected-html.d.ts +15 -4
  20. package/dist/review/connected-html.js +342 -35
  21. package/dist/review/connected.js +47 -14
  22. package/dist/review/escape-html.d.ts +5 -1
  23. package/dist/review/escape-html.js +7 -2
  24. package/dist/review/explanation.d.ts +4 -0
  25. package/dist/review/explanation.js +6 -1
  26. package/dist/review/github.d.ts +56 -0
  27. package/dist/review/github.js +234 -33
  28. package/dist/review/grammars-command.d.ts +12 -0
  29. package/dist/review/grammars-command.js +60 -0
  30. package/dist/review/hidden-characters.d.ts +31 -0
  31. package/dist/review/hidden-characters.js +113 -0
  32. package/dist/review/history.js +7 -3
  33. package/dist/review/html.d.ts +3 -2
  34. package/dist/review/html.js +19 -18
  35. package/dist/review/input.js +5 -2
  36. package/dist/review/intent.d.ts +7 -0
  37. package/dist/review/intent.js +25 -3
  38. package/dist/review/markdown.js +11 -0
  39. package/dist/review/mcp-cli.js +4 -1
  40. package/dist/review/mcp.d.ts +7 -1
  41. package/dist/review/mcp.js +145 -59
  42. package/dist/review/pipeline.d.ts +2 -1
  43. package/dist/review/pipeline.js +4 -3
  44. package/dist/review/pr-input.d.ts +7 -0
  45. package/dist/review/pr-input.js +25 -3
  46. package/dist/review/process-html.d.ts +1 -5
  47. package/dist/review/process-html.js +3 -12
  48. package/dist/review/questions.js +11 -3
  49. package/dist/review/reference-check.d.ts +5 -1
  50. package/dist/review/reference-check.js +40 -16
  51. package/dist/review/report-pages.d.ts +24 -9
  52. package/dist/review/report-pages.js +111 -28
  53. package/dist/review/result-budget.d.ts +28 -0
  54. package/dist/review/result-budget.js +136 -0
  55. package/dist/review/service.js +26 -2
  56. package/dist/review/setup.d.ts +1 -1
  57. package/dist/review/setup.js +9 -4
  58. package/dist/review/types.d.ts +19 -5
  59. package/dist/review/types.js +2 -1
  60. package/dist/review/update-check.d.ts +35 -0
  61. package/dist/review/update-check.js +76 -0
  62. package/dist/run.js +11 -5
  63. package/npm-shrinkwrap.json +3483 -0
  64. package/package.json +3 -2
@@ -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("Content-Security-Policy", `default-src 'none'; script-src 'nonce-${csrf}'; style-src 'nonce-${csrf}'; connect-src 'self'; frame-src 'self'; base-uri 'none'; form-action 'none'; frame-ancestors 'none'`);
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
- if (req.method === "GET" && req.url === "/") {
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" && req.url === "/api/state") {
115
+ if (req.method === "GET" && route === "api/state") {
86
116
  json(200, review.getState());
87
117
  return;
88
118
  }
89
- if (req.method === "GET" && req.url === "/api/analysis") {
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" && (req.url ?? "").startsWith("/flow?")) {
99
- const query = new URL(req.url ?? "", origin).searchParams;
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 = ["/api/load", "/api/preview", "/api/submit", "/api/reconcile"];
122
- if (req.method !== "POST" || !routes.includes(req.url ?? "")) {
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 (req.url) {
135
- case "/api/load":
164
+ switch (route) {
165
+ case "api/load":
136
166
  result = await review.load(loadSchema.parse(JSON.parse(text)).url);
137
167
  break;
138
- case "/api/preview":
168
+ case "api/preview":
139
169
  result = await review.preview(reviewSchema.parse(JSON.parse(text)));
140
170
  break;
141
- case "/api/submit":
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
- /** Escape untrusted text for HTML element and attribute contexts. */
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
- /** Escape untrusted text for HTML element and attribute contexts. */
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("&", "&amp;")
5
10
  .replaceAll("<", "&lt;")
6
11
  .replaceAll(">", "&gt;")
@@ -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) {
@@ -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