diffninja 0.3.2 → 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.js +1 -0
- 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 +338 -43
- 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.js +11 -3
- 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 -56
- 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/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 -9
- package/dist/review/types.js +2 -2
- 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/github.js
CHANGED
|
@@ -19,6 +19,7 @@
|
|
|
19
19
|
* - One review per session. An ambiguous write marks the session `unknown`,
|
|
20
20
|
* never retried; only an exact match found on GitHub resolves it.
|
|
21
21
|
*/
|
|
22
|
+
import { resolveExecutable } from "../executables.js";
|
|
22
23
|
import { execFile } from "node:child_process";
|
|
23
24
|
import { createHash } from "node:crypto";
|
|
24
25
|
/* ---------------------------------------------------------------- gh process */
|
|
@@ -42,9 +43,13 @@ export class GhCommandError extends Error {
|
|
|
42
43
|
this.stderr = stderr;
|
|
43
44
|
}
|
|
44
45
|
}
|
|
45
|
-
/**
|
|
46
|
-
|
|
47
|
-
|
|
46
|
+
/**
|
|
47
|
+
* No prompt, no pager, no update notice, no usage telemetry (gh reports each
|
|
48
|
+
* command, its flag names, a device id and which agent runs it to GitHub unless
|
|
49
|
+
* told not to), and never a host other than github.com.
|
|
50
|
+
*/
|
|
51
|
+
export function ghEnvironment() {
|
|
52
|
+
return { ...process.env, GH_HOST: GITHUB_HOST, GH_PROMPT_DISABLED: "1", GH_NO_UPDATE_NOTIFIER: "1", GH_PAGER: "cat", NO_COLOR: "1", GH_TELEMETRY: "false", DO_NOT_TRACK: "1" };
|
|
48
53
|
}
|
|
49
54
|
/** Runs the real `gh` with an explicit argv (never a shell) and a hard deadline. */
|
|
50
55
|
export function ghCliRunner() {
|
|
@@ -70,7 +75,15 @@ export function ghCliRunner() {
|
|
|
70
75
|
}
|
|
71
76
|
reject(new GhCommandError(firstLine(stderr) || firstLine(stdout) || "gh failed", stdout, stderr));
|
|
72
77
|
};
|
|
73
|
-
|
|
78
|
+
let ghPath;
|
|
79
|
+
try {
|
|
80
|
+
ghPath = resolveExecutable("gh");
|
|
81
|
+
}
|
|
82
|
+
catch {
|
|
83
|
+
reject(new GhCommandError("gh was not found", "", ""));
|
|
84
|
+
return;
|
|
85
|
+
}
|
|
86
|
+
const child = execFile(ghPath, [...invocation.args], {
|
|
74
87
|
timeout: invocation.timeoutMs,
|
|
75
88
|
killSignal: "SIGKILL",
|
|
76
89
|
maxBuffer: GH_OUTPUT_LIMIT,
|
|
@@ -143,6 +156,39 @@ function githubBodyError(message) {
|
|
|
143
156
|
return new Error(AUTHORIZATION_FAILURE);
|
|
144
157
|
return new Error(`GitHub rejected the request: ${cleaned}`);
|
|
145
158
|
}
|
|
159
|
+
function failureKind(error) {
|
|
160
|
+
const detail = error.message;
|
|
161
|
+
const status = httpStatus(error);
|
|
162
|
+
if (status === 408 || /timed out|timeout/i.test(detail))
|
|
163
|
+
return "timeout";
|
|
164
|
+
if (/was not found/i.test(detail) && status === null)
|
|
165
|
+
return "gh-missing";
|
|
166
|
+
if (/unknown json field|unknown flag|unknown shorthand|unknown command/i.test(detail))
|
|
167
|
+
return "gh-outdated";
|
|
168
|
+
if (status === 401 || /bad credentials|not logged in|gh auth login|authentication required/i.test(detail))
|
|
169
|
+
return "signed-out";
|
|
170
|
+
if (status === 403 || /resource not accessible|forbidden|must have push|not authorized|insufficient/i.test(detail))
|
|
171
|
+
return "forbidden";
|
|
172
|
+
if (status === 404 || /could not resolve to a (?:node|pull ?request)|pull request was not found/i.test(detail))
|
|
173
|
+
return "not-found";
|
|
174
|
+
if (status !== null && status >= 400 && status < 500)
|
|
175
|
+
return "rejected";
|
|
176
|
+
// A GraphQL refusal is an HTTP 200 whose body carries `errors`, so gh names no status.
|
|
177
|
+
if (status === null && graphqlErrorsIn(error.stdout))
|
|
178
|
+
return "refused";
|
|
179
|
+
if (githubErrorText(error.stdout) !== null || (status !== null && status >= 500))
|
|
180
|
+
return "server";
|
|
181
|
+
if (/connection refused|no such host|name resolution|network is unreachable|unexpected EOF|TLS handshake|proxyconnect/i.test(detail))
|
|
182
|
+
return "unreachable";
|
|
183
|
+
return "other";
|
|
184
|
+
}
|
|
185
|
+
function graphqlErrorsIn(stdout) {
|
|
186
|
+
const decoded = tryParseJson(stdout);
|
|
187
|
+
if (!isGhObject(decoded))
|
|
188
|
+
return false;
|
|
189
|
+
const errors = decoded["errors"];
|
|
190
|
+
return Array.isArray(errors) && errors.length > 0;
|
|
191
|
+
}
|
|
146
192
|
/**
|
|
147
193
|
* Map a raw `gh` failure to a safe, user-facing error. Read failures stay
|
|
148
194
|
* neutral — no failure text claims the pull request was untouched, because when
|
|
@@ -153,37 +199,29 @@ function githubBodyError(message) {
|
|
|
153
199
|
function classifyGhFailure(rawError, subject) {
|
|
154
200
|
const error = asGhCommandError(rawError);
|
|
155
201
|
const detail = error.message;
|
|
156
|
-
const status = httpStatus(error);
|
|
157
202
|
const github = githubErrorText(error.stdout);
|
|
158
|
-
|
|
159
|
-
return new Error("gh did not answer in time; the request did not complete. Check the network, then try again.");
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
return new Error(
|
|
163
|
-
if (/unknown json field|unknown flag|unknown shorthand|unknown command/i.test(detail)) {
|
|
164
|
-
return new Error(`The installed gh does not support the flags diffninja needs. Update gh to ${MIN_GH_VERSION} or newer.`);
|
|
165
|
-
}
|
|
166
|
-
if (status === 401 || /bad credentials|not logged in|gh auth login|authentication required/i.test(detail))
|
|
167
|
-
return new Error(AUTH_FAILURE);
|
|
168
|
-
if (status === 403 || /resource not accessible|forbidden|must have push|not authorized|insufficient/i.test(detail)) {
|
|
203
|
+
switch (failureKind(error)) {
|
|
204
|
+
case "timeout": return new Error("gh did not answer in time; the request did not complete. Check the network, then try again.");
|
|
205
|
+
case "gh-missing": return new Error(`The GitHub CLI (gh) was not found on PATH. Install gh ${MIN_GH_VERSION} or newer from https://cli.github.com, then run \`gh auth login --hostname github.com\` and try again.`);
|
|
206
|
+
case "gh-outdated": return new Error(`The installed gh does not support the flags diffninja needs. Update gh to ${MIN_GH_VERSION} or newer.`);
|
|
207
|
+
case "signed-out": return new Error(AUTH_FAILURE);
|
|
169
208
|
// A 403 has many causes; GitHub's own text is what tells them apart.
|
|
170
|
-
return new Error(github === null ? AUTHORIZATION_FAILURE : `${AUTHORIZATION_FAILURE} GitHub said: ${github}.`);
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
return new Error(`GitHub
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
return new Error("
|
|
209
|
+
case "forbidden": return new Error(github === null ? AUTHORIZATION_FAILURE : `${AUTHORIZATION_FAILURE} GitHub said: ${github}.`);
|
|
210
|
+
case "not-found": return new Error(NOT_FOUND_FAILURE);
|
|
211
|
+
case "rejected": {
|
|
212
|
+
const what = subject === "review" ? "GitHub rejected the review" : subject === "viewed" ? "GitHub rejected the Viewed mark" : "The request was rejected";
|
|
213
|
+
// The Viewed mark shows GitHub's named message or nothing of its own: never a line of gh's stderr.
|
|
214
|
+
if (github === null)
|
|
215
|
+
return new Error(subject === "viewed" ? `${what}.` : `${what}: ${sanitizeText(detail)}`);
|
|
216
|
+
return new Error(`${what}: ${github}`);
|
|
217
|
+
}
|
|
218
|
+
case "refused": return new Error(`GitHub refused the ${subject === "viewed" ? "Viewed mark" : "request"}: ${github ?? "no reason given"}.`);
|
|
219
|
+
case "server": return new Error(github === null
|
|
220
|
+
? "GitHub returned a server error; the request did not complete. Try again in a moment."
|
|
221
|
+
: `GitHub returned a server error; the request did not complete: ${github}. Try again in a moment.`);
|
|
222
|
+
case "unreachable": return new Error("diffninja could not reach GitHub; the request did not complete. Check the network and try again.");
|
|
223
|
+
case "other": return new Error("The gh command failed; the request did not complete.");
|
|
185
224
|
}
|
|
186
|
-
return new Error("The gh command failed; the request did not complete.");
|
|
187
225
|
}
|
|
188
226
|
function isGhObject(value) {
|
|
189
227
|
// JSON.parse only produces plain objects, arrays, and primitives, so plain
|
|
@@ -647,6 +685,40 @@ const VIEW_FIELDS = [
|
|
|
647
685
|
"url", "id", "number", "state", "title", "body", "baseRefOid", "headRefOid", "isCrossRepository",
|
|
648
686
|
"headRepository", "headRepositoryOwner", "baseRefName", "headRefName",
|
|
649
687
|
].join(",");
|
|
688
|
+
/**
|
|
689
|
+
* The only GraphQL documents diffninja sends, fixed text. A file path, a cursor
|
|
690
|
+
* and the pull request's node id travel as separate `-f` variables and are never
|
|
691
|
+
* placed in a document.
|
|
692
|
+
*/
|
|
693
|
+
export 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 } } } } }";
|
|
694
|
+
export const MARK_VIEWED_MUTATION = "mutation($pullRequestId: ID!, $path: String!) { markFileAsViewed(input: { pullRequestId: $pullRequestId, path: $path }) { clientMutationId } }";
|
|
695
|
+
export const UNMARK_VIEWED_MUTATION = "mutation($pullRequestId: ID!, $path: String!) { unmarkFileAsViewed(input: { pullRequestId: $pullRequestId, path: $path }) { clientMutationId } }";
|
|
696
|
+
const VIEWED_MUTATIONS = {
|
|
697
|
+
mark: { query: MARK_VIEWED_MUTATION, field: "markFileAsViewed" },
|
|
698
|
+
unmark: { query: UNMARK_VIEWED_MUTATION, field: "unmarkFileAsViewed" },
|
|
699
|
+
};
|
|
700
|
+
/** GitHub lists at most 3,000 files of a pull request; at 100 a page this is far past that. */
|
|
701
|
+
const MAX_VIEWED_PAGES = 40;
|
|
702
|
+
const UNFINISHED_MARK = "The Viewed mark did not finish, so GitHub may or may not have recorded it. Load the pull request again to read GitHub's marks.";
|
|
703
|
+
/** A failure of these kinds means GitHub answered the write with a refusal, or gh never ran it. */
|
|
704
|
+
const DEFINITE_FAILURES = new Set(["gh-missing", "gh-outdated", "signed-out", "forbidden", "not-found", "rejected", "refused"]);
|
|
705
|
+
/** Fixed reasons a read of the Viewed marks failed. GitHub's own text is never among them. */
|
|
706
|
+
const VIEWED_READ_REASON = {
|
|
707
|
+
"timeout": "gh did not answer in time",
|
|
708
|
+
"gh-missing": "the GitHub CLI was not found",
|
|
709
|
+
"gh-outdated": "the installed gh is too old",
|
|
710
|
+
"signed-out": "gh is not signed in to GitHub",
|
|
711
|
+
"forbidden": "the gh account may not read viewed marks",
|
|
712
|
+
"not-found": "GitHub did not find the pull request",
|
|
713
|
+
"rejected": "GitHub rejected the request",
|
|
714
|
+
"refused": "GitHub refused the request",
|
|
715
|
+
"server": "GitHub returned a server error",
|
|
716
|
+
"unreachable": "GitHub could not be reached",
|
|
717
|
+
"other": "gh failed",
|
|
718
|
+
};
|
|
719
|
+
/** A read of the Viewed marks that cannot be trusted; its message is a fixed sentence fragment. */
|
|
720
|
+
class ViewedUnreadable extends Error {
|
|
721
|
+
}
|
|
650
722
|
const MAX_REVIEW_CHARS = 65_536;
|
|
651
723
|
const MAX_COMMENT_CHARS = 65_536;
|
|
652
724
|
const MAX_COMMENTS = 100;
|
|
@@ -691,6 +763,7 @@ export class ConnectedReview {
|
|
|
691
763
|
/** Review ids that already existed before this session's write attempt. */
|
|
692
764
|
preexistingReviewIds = new Set();
|
|
693
765
|
queue = Promise.resolve();
|
|
766
|
+
viewedSync;
|
|
694
767
|
constructor(deps = {}) {
|
|
695
768
|
this.runner = deps.runner ?? ghCliRunner();
|
|
696
769
|
this.timeoutMs = deps.timeoutMs ?? GH_TIMEOUT_MS;
|
|
@@ -702,6 +775,7 @@ export class ConnectedReview {
|
|
|
702
775
|
status: this.status,
|
|
703
776
|
receipt: this.receipt,
|
|
704
777
|
message: this.message,
|
|
778
|
+
viewed: this.viewedState(),
|
|
705
779
|
};
|
|
706
780
|
}
|
|
707
781
|
/** Canonical patch from the last successfully bound snapshot, for static export. */
|
|
@@ -722,6 +796,10 @@ export class ConnectedReview {
|
|
|
722
796
|
async reconcile() {
|
|
723
797
|
return this.serialize(() => this.runReconcile());
|
|
724
798
|
}
|
|
799
|
+
/** Mark one changed file of the loaded snapshot Viewed, or not, on GitHub. */
|
|
800
|
+
async setViewed(input) {
|
|
801
|
+
return this.serialize(() => this.runSetViewed(input));
|
|
802
|
+
}
|
|
725
803
|
/** Run `work` after everything already queued; failures never break the chain. */
|
|
726
804
|
serialize(work) {
|
|
727
805
|
const result = this.queue.then(work, work);
|
|
@@ -794,7 +872,9 @@ export class ConnectedReview {
|
|
|
794
872
|
const canonical = parsePullUrl(url);
|
|
795
873
|
if (canonical.number !== number)
|
|
796
874
|
throw new Error("GitHub's pull request metadata is inconsistent; refusing to review it.");
|
|
875
|
+
const nodeId = textField(payload, "id");
|
|
797
876
|
return {
|
|
877
|
+
nodeId: nodeId === null || nodeId === "" ? null : nodeId,
|
|
798
878
|
url: canonical.url,
|
|
799
879
|
owner: canonical.owner,
|
|
800
880
|
repo: canonical.repo,
|
|
@@ -871,7 +951,8 @@ export class ConnectedReview {
|
|
|
871
951
|
const reason = canonical.problem ?? reviewabilityProblem(meta);
|
|
872
952
|
if (reason !== null)
|
|
873
953
|
snapshot.unavailableReason = reason;
|
|
874
|
-
|
|
954
|
+
const filePaths = (listedFiles(listing) ?? []).map((file) => file.filename);
|
|
955
|
+
return { snapshot, diff: rawDiff, anchors: canonical.anchors, note: reason === null ? forkNote(meta) : null, nodeId: meta.nodeId, filePaths };
|
|
875
956
|
}
|
|
876
957
|
/**
|
|
877
958
|
* One PR and one account per session: the first pull request that loads at all
|
|
@@ -900,10 +981,12 @@ export class ConnectedReview {
|
|
|
900
981
|
await this.ensureGhVersion();
|
|
901
982
|
const identity = await this.readIdentity();
|
|
902
983
|
const read = await this.readSnapshot(target);
|
|
984
|
+
const viewed = read.snapshot.unavailableReason === undefined ? await this.readViewed(read) : undefined;
|
|
903
985
|
this.identity = identity;
|
|
904
986
|
this.snapshot = read.snapshot;
|
|
905
987
|
this.diff = read.diff;
|
|
906
988
|
this.anchors = read.anchors;
|
|
989
|
+
this.viewedSync = viewed;
|
|
907
990
|
this.previewed = undefined;
|
|
908
991
|
this.message = read.snapshot.unavailableReason ?? read.note ?? undefined;
|
|
909
992
|
this.status = read.snapshot.unavailableReason === undefined ? "ready" : "empty";
|
|
@@ -950,6 +1033,89 @@ export class ConnectedReview {
|
|
|
950
1033
|
await this.postReview(snapshot, payload);
|
|
951
1034
|
return this.getState();
|
|
952
1035
|
}
|
|
1036
|
+
/** One `gh api graphql` run: a fixed document plus its variables, each a separate raw `-f` argument. */
|
|
1037
|
+
graphql(query, variables) {
|
|
1038
|
+
const args = ["api", "--hostname", GITHUB_HOST, "graphql", "-f", `query=${query}`];
|
|
1039
|
+
for (const [name, value] of Object.entries(variables))
|
|
1040
|
+
args.push("-f", `${name}=${value}`);
|
|
1041
|
+
return this.runner.run({ args, timeoutMs: this.timeoutMs });
|
|
1042
|
+
}
|
|
1043
|
+
/** The Viewed marks for the files of a fresh snapshot; a read that fails costs the sync, never the load. */
|
|
1044
|
+
async readViewed(read) {
|
|
1045
|
+
const pullRequestId = read.nodeId;
|
|
1046
|
+
if (pullRequestId === null)
|
|
1047
|
+
return { available: false, reason: "gh did not report the pull request's GitHub id" };
|
|
1048
|
+
try {
|
|
1049
|
+
const marks = await this.readViewedMarks(pullRequestId);
|
|
1050
|
+
return { available: true, pullRequestId, files: new Map(read.filePaths.map((path) => [path, marks.get(path) === true])) };
|
|
1051
|
+
}
|
|
1052
|
+
catch (error) {
|
|
1053
|
+
if (error instanceof ViewedUnreadable)
|
|
1054
|
+
return { available: false, reason: error.message };
|
|
1055
|
+
return { available: false, reason: VIEWED_READ_REASON[failureKind(asGhCommandError(error instanceof Error ? error : new Error("gh failed")))] };
|
|
1056
|
+
}
|
|
1057
|
+
}
|
|
1058
|
+
/** Path to viewed, over every page of the pull request's files; VIEWED counts, UNVIEWED and DISMISSED do not. */
|
|
1059
|
+
async readViewedMarks(pullRequestId) {
|
|
1060
|
+
const marks = new Map();
|
|
1061
|
+
let after;
|
|
1062
|
+
for (let page = 0; page < MAX_VIEWED_PAGES; page++) {
|
|
1063
|
+
const result = await this.graphql(VIEWED_FILES_QUERY, after === undefined ? { pullRequestId } : { pullRequestId, after });
|
|
1064
|
+
const next = readViewedPage(result.stdout, marks);
|
|
1065
|
+
if (next === null)
|
|
1066
|
+
return marks;
|
|
1067
|
+
after = next;
|
|
1068
|
+
}
|
|
1069
|
+
throw new ViewedUnreadable("the pull request has more files than diffninja reads");
|
|
1070
|
+
}
|
|
1071
|
+
async runSetViewed(input) {
|
|
1072
|
+
const snapshot = this.snapshot;
|
|
1073
|
+
if (snapshot === undefined)
|
|
1074
|
+
throw new Error("Load a pull request before marking a file viewed.");
|
|
1075
|
+
if (input.snapshotId !== snapshot.id) {
|
|
1076
|
+
throw new Error("This mark belongs to a different version of the pull request. Reload it and try again.");
|
|
1077
|
+
}
|
|
1078
|
+
const sync = this.viewedSync;
|
|
1079
|
+
if (sync === undefined || !sync.available) {
|
|
1080
|
+
throw new Error(`Viewed marks are not synced with GitHub for this pull request${sync === undefined ? "" : ` (${sync.reason})`}.`);
|
|
1081
|
+
}
|
|
1082
|
+
if (!sync.files.has(input.path))
|
|
1083
|
+
throw new Error("That file is not one of this pull request's changed files.");
|
|
1084
|
+
// A mark has no commit of its own: GitHub applies it to the file as it is now. A push since the load
|
|
1085
|
+
// would turn "every hunk I read" into "the file", so the head is read again first, as a submit does.
|
|
1086
|
+
const meta = await this.readMetadata({ owner: snapshot.owner, repo: snapshot.repo, number: snapshot.number, url: snapshot.url });
|
|
1087
|
+
if (meta.baseSha !== snapshot.baseSha || meta.headSha !== snapshot.headSha) {
|
|
1088
|
+
throw new Error("The pull request changed since it was loaded. Load it again, then mark the file.");
|
|
1089
|
+
}
|
|
1090
|
+
const mutation = input.viewed ? VIEWED_MUTATIONS.mark : VIEWED_MUTATIONS.unmark;
|
|
1091
|
+
let stdout;
|
|
1092
|
+
try {
|
|
1093
|
+
({ stdout } = await this.graphql(mutation.query, { pullRequestId: sync.pullRequestId, path: input.path }));
|
|
1094
|
+
}
|
|
1095
|
+
catch (error) {
|
|
1096
|
+
const failure = asGhCommandError(error instanceof Error ? error : new Error("gh failed"));
|
|
1097
|
+
if (DEFINITE_FAILURES.has(failureKind(failure)))
|
|
1098
|
+
throw classifyGhFailure(failure, "viewed");
|
|
1099
|
+
throw this.unfinishedMark();
|
|
1100
|
+
}
|
|
1101
|
+
if (!confirmsMutation(stdout, mutation.field))
|
|
1102
|
+
throw this.unfinishedMark();
|
|
1103
|
+
sync.files.set(input.path, input.viewed);
|
|
1104
|
+
return { path: input.path, viewed: input.viewed };
|
|
1105
|
+
}
|
|
1106
|
+
/** A write that may or may not have landed: the marks stay unread until the pull request is loaded again. */
|
|
1107
|
+
unfinishedMark() {
|
|
1108
|
+
this.viewedSync = { available: false, reason: "the last Viewed mark did not finish" };
|
|
1109
|
+
return new Error(UNFINISHED_MARK);
|
|
1110
|
+
}
|
|
1111
|
+
viewedState() {
|
|
1112
|
+
const sync = this.viewedSync;
|
|
1113
|
+
if (sync === undefined)
|
|
1114
|
+
return undefined;
|
|
1115
|
+
if (!sync.available)
|
|
1116
|
+
return { available: false, reason: sync.reason };
|
|
1117
|
+
return { available: true, files: [...sync.files].map(([path, viewed]) => ({ path, viewed })) };
|
|
1118
|
+
}
|
|
953
1119
|
async postReview(snapshot, payload) {
|
|
954
1120
|
this.status = "submitting";
|
|
955
1121
|
this.message = "Submitting the review to GitHub.";
|
|
@@ -1142,6 +1308,41 @@ export class ConnectedReview {
|
|
|
1142
1308
|
throw new Error(`The comment on ${path}:${line} (${side}) does not match a line of this pull request's diff. Comment on an added line on the right, a deleted line on the left, or a context line.`);
|
|
1143
1309
|
}
|
|
1144
1310
|
}
|
|
1311
|
+
/**
|
|
1312
|
+
* Add one page of `files { nodes { path viewerViewedState } }` to `marks` and
|
|
1313
|
+
* return the cursor of the next page, or null after the last. Anything that is
|
|
1314
|
+
* not that shape rejects the whole read: a mark cannot be guessed.
|
|
1315
|
+
*/
|
|
1316
|
+
function readViewedPage(stdout, marks) {
|
|
1317
|
+
const unreadable = new ViewedUnreadable("GitHub's answer about viewed files was not readable");
|
|
1318
|
+
const decoded = tryParseJson(stdout);
|
|
1319
|
+
const files = objectField(objectField(objectField(isGhObject(decoded) ? decoded : null, "data"), "node"), "files");
|
|
1320
|
+
const nodes = files === null ? null : files["nodes"];
|
|
1321
|
+
const pageInfo = objectField(files, "pageInfo");
|
|
1322
|
+
const more = booleanField(pageInfo, "hasNextPage");
|
|
1323
|
+
if (!Array.isArray(nodes) || more === null)
|
|
1324
|
+
throw unreadable;
|
|
1325
|
+
for (const node of nodes) {
|
|
1326
|
+
const path = isGhObject(node) ? textField(node, "path") : null;
|
|
1327
|
+
const state = isGhObject(node) ? textField(node, "viewerViewedState") : null;
|
|
1328
|
+
if (path === null || state === null)
|
|
1329
|
+
throw unreadable;
|
|
1330
|
+
marks.set(path, state === "VIEWED");
|
|
1331
|
+
}
|
|
1332
|
+
if (!more)
|
|
1333
|
+
return null;
|
|
1334
|
+
const cursor = textField(pageInfo, "endCursor");
|
|
1335
|
+
if (cursor === null || cursor === "")
|
|
1336
|
+
throw unreadable;
|
|
1337
|
+
return cursor;
|
|
1338
|
+
}
|
|
1339
|
+
/** A mutation is confirmed only by its own field in `data` and no `errors`. */
|
|
1340
|
+
function confirmsMutation(stdout, field) {
|
|
1341
|
+
const decoded = tryParseJson(stdout);
|
|
1342
|
+
if (!isGhObject(decoded) || graphqlErrorsIn(stdout))
|
|
1343
|
+
return false;
|
|
1344
|
+
return objectField(objectField(decoded, "data"), field) !== null;
|
|
1345
|
+
}
|
|
1145
1346
|
function compareVersions(left, right) {
|
|
1146
1347
|
const a = left.split(".").map(Number);
|
|
1147
1348
|
const b = right.split(".").map(Number);
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import { type GrammarStatus, type InstalledGrammars } from "../languages/grammars.js";
|
|
2
|
+
export declare const grammarsHelp = "diffninja grammars. Downloads grammar code through npm, and only when you run it.\n\n diffninja grammars install [--build] [--dry-run]\n Install the tree-sitter grammars call flows need for languages other than\n JavaScript and TypeScript (Python, Go, Java, Rust, C, C++, C#, Ruby, PHP,\n Swift, Scala, Bash, Lua, Zig, Elixir, Haskell, OCaml, Solidity). Exact\n versions, checked against the sha512 of every tarball in the lock that ships\n with this diffninja, and no install script of any package is run.\n --build also compiles the Kotlin and Perl grammars, which ship no prebuilt\n binary: that runs their install script (node-gyp) and needs Python and a C/C++\n compiler.\n diffninja grammars status\n Show what is installed and where.\n\nReviews never download or build anything: without these grammars, files in those\nlanguages are skipped by call flows and the review says so.\n";
|
|
3
|
+
export interface GrammarsDependencies {
|
|
4
|
+
install: (options: {
|
|
5
|
+
cacheDir?: string;
|
|
6
|
+
build?: boolean;
|
|
7
|
+
}) => InstalledGrammars;
|
|
8
|
+
status: () => GrammarStatus;
|
|
9
|
+
log: (line: string) => void;
|
|
10
|
+
}
|
|
11
|
+
/** `diffninja grammars ...`; throws a message-only Error for bad usage. */
|
|
12
|
+
export declare function runGrammars(args: readonly string[], deps?: GrammarsDependencies): void;
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import { parseArgs } from "node:util";
|
|
2
|
+
import { GRAMMAR_PINS } from "../languages/grammar-lock.js";
|
|
3
|
+
import { grammarCacheDir, grammarStatus, installPinnedGrammars } from "../languages/grammars.js";
|
|
4
|
+
export const grammarsHelp = `diffninja grammars. Downloads grammar code through npm, and only when you run it.
|
|
5
|
+
|
|
6
|
+
diffninja grammars install [--build] [--dry-run]
|
|
7
|
+
Install the tree-sitter grammars call flows need for languages other than
|
|
8
|
+
JavaScript and TypeScript (Python, Go, Java, Rust, C, C++, C#, Ruby, PHP,
|
|
9
|
+
Swift, Scala, Bash, Lua, Zig, Elixir, Haskell, OCaml, Solidity). Exact
|
|
10
|
+
versions, checked against the sha512 of every tarball in the lock that ships
|
|
11
|
+
with this diffninja, and no install script of any package is run.
|
|
12
|
+
--build also compiles the Kotlin and Perl grammars, which ship no prebuilt
|
|
13
|
+
binary: that runs their install script (node-gyp) and needs Python and a C/C++
|
|
14
|
+
compiler.
|
|
15
|
+
diffninja grammars status
|
|
16
|
+
Show what is installed and where.
|
|
17
|
+
|
|
18
|
+
Reviews never download or build anything: without these grammars, files in those
|
|
19
|
+
languages are skipped by call flows and the review says so.
|
|
20
|
+
`;
|
|
21
|
+
const real = { install: installPinnedGrammars, status: () => grammarStatus(), log: (line) => console.log(line) };
|
|
22
|
+
/** `diffninja grammars ...`; throws a message-only Error for bad usage. */
|
|
23
|
+
export function runGrammars(args, deps = real) {
|
|
24
|
+
const [action, ...rest] = args;
|
|
25
|
+
if (action === undefined || action === "--help" || action === "-h" || action === "help") {
|
|
26
|
+
deps.log(grammarsHelp);
|
|
27
|
+
return;
|
|
28
|
+
}
|
|
29
|
+
const { values, positionals } = parseArgs({ args: rest, options: { "dry-run": { type: "boolean" }, build: { type: "boolean" }, help: { type: "boolean" } }, strict: true, allowPositionals: true });
|
|
30
|
+
if (values.help) {
|
|
31
|
+
deps.log(grammarsHelp);
|
|
32
|
+
return;
|
|
33
|
+
}
|
|
34
|
+
if (positionals.length > 0)
|
|
35
|
+
throw new Error(`grammars ${action} takes no arguments. Use --help.`);
|
|
36
|
+
if (action === "status") {
|
|
37
|
+
const status = deps.status();
|
|
38
|
+
deps.log(`Cache: ${status.cacheDir}${status.trusted ? "" : " (nothing installed by this diffninja yet)"}`);
|
|
39
|
+
for (const entry of status.packages)
|
|
40
|
+
deps.log(` ${entry.installed ? "installed" : "missing "} ${entry.name}@${entry.version}${entry.installed || !entry.needsBuild ? "" : " (needs --build)"}`);
|
|
41
|
+
if (status.packages.some((entry) => !entry.installed))
|
|
42
|
+
deps.log("Run `diffninja grammars install` (add --build for the ones marked) to add the missing ones.");
|
|
43
|
+
return;
|
|
44
|
+
}
|
|
45
|
+
if (action !== "install")
|
|
46
|
+
throw new Error(`Unknown grammars command "${action}". Use install or status.`);
|
|
47
|
+
const names = Object.entries(GRAMMAR_PINS).map(([name, version]) => `${name}@${version}`);
|
|
48
|
+
const build = values.build === true;
|
|
49
|
+
if (values["dry-run"]) {
|
|
50
|
+
deps.log(`Would install into ${grammarCacheDir()} (npm ci from the lock shipped with diffninja, install scripts off${build ? ", then a source build of the Kotlin and Perl grammars" : ""}):`);
|
|
51
|
+
for (const name of names)
|
|
52
|
+
deps.log(` ${name}`);
|
|
53
|
+
return;
|
|
54
|
+
}
|
|
55
|
+
deps.log(`Installing ${names.length} pinned grammars into ${grammarCacheDir()} (integrity-checked, install scripts off${build ? "; compiling Kotlin and Perl" : ""})...`);
|
|
56
|
+
const installed = deps.install({ build });
|
|
57
|
+
for (const entry of installed.packages)
|
|
58
|
+
deps.log(` ${entry.name}@${entry.version}`);
|
|
59
|
+
deps.log("Done. The next review uses them; nothing needs restarting.");
|
|
60
|
+
}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
/** Matches one hidden character at a time, so each match is one code point to mark. */
|
|
2
|
+
export declare const HIDDEN_CHARACTER_SOURCE = "[\\u061C\\u200E\\u200F\\u202A-\\u202E\\u2066-\\u206F\\u{E0000}-\\u{E0FFF}]|(?<!(?![\\x00-\\x7F\\u061C\\u200E\\u200F\\u202A-\\u202E\\u2066-\\u206F\\u{E0000}-\\u{E0FFF}\\u00AD\\u034F\\u115F\\u1160\\u17B4\\u17B5\\u180B-\\u180F\\u200B-\\u200D\\u2060-\\u2065\\u2800\\u3164\\uFE00-\\uFE0F\\uFFA0\\uFFF0-\\uFFF8\\u{1BCA0}-\\u{1BCA3}\\u{1D173}-\\u{1D17A}\\uFEFF]|\\p{Default_Ignorable_Code_Point})[\\p{L}\\p{M}\\p{N}\\p{S}\\p{P}])[\\u00AD\\u034F\\u115F\\u1160\\u17B4\\u17B5\\u180B-\\u180F\\u200B-\\u200D\\u2060-\\u2065\\u2800\\u3164\\uFE00-\\uFE0F\\uFFA0\\uFFF0-\\uFFF8\\u{1BCA0}-\\u{1BCA3}\\u{1D173}-\\u{1D17A}](?!(?![\\x00-\\x7F\\u061C\\u200E\\u200F\\u202A-\\u202E\\u2066-\\u206F\\u{E0000}-\\u{E0FFF}\\u00AD\\u034F\\u115F\\u1160\\u17B4\\u17B5\\u180B-\\u180F\\u200B-\\u200D\\u2060-\\u2065\\u2800\\u3164\\uFE00-\\uFE0F\\uFFA0\\uFFF0-\\uFFF8\\u{1BCA0}-\\u{1BCA3}\\u{1D173}-\\u{1D17A}\\uFEFF]|\\p{Default_Ignorable_Code_Point})[\\p{L}\\p{M}\\p{N}\\p{S}\\p{P}])|(?<!(?![\\x00-\\x7F\\u061C\\u200E\\u200F\\u202A-\\u202E\\u2066-\\u206F\\u{E0000}-\\u{E0FFF}\\u00AD\\u034F\\u115F\\u1160\\u17B4\\u17B5\\u180B-\\u180F\\u200B-\\u200D\\u2060-\\u2065\\u2800\\u3164\\uFE00-\\uFE0F\\uFFA0\\uFFF0-\\uFFF8\\u{1BCA0}-\\u{1BCA3}\\u{1D173}-\\u{1D17A}\\uFEFF]|\\p{Default_Ignorable_Code_Point})[\\p{L}\\p{M}\\p{N}\\p{S}\\p{P}]|(?:^|\\n)[+\\- ])\\uFEFF(?!(?![\\x00-\\x7F\\u061C\\u200E\\u200F\\u202A-\\u202E\\u2066-\\u206F\\u{E0000}-\\u{E0FFF}\\u00AD\\u034F\\u115F\\u1160\\u17B4\\u17B5\\u180B-\\u180F\\u200B-\\u200D\\u2060-\\u2065\\u2800\\u3164\\uFE00-\\uFE0F\\uFFA0\\uFFF0-\\uFFF8\\u{1BCA0}-\\u{1BCA3}\\u{1D173}-\\u{1D17A}\\uFEFF]|\\p{Default_Ignorable_Code_Point})[\\p{L}\\p{M}\\p{N}\\p{S}\\p{P}])";
|
|
3
|
+
/**
|
|
4
|
+
* Every character of both tiers, with no lookaround, for a browser too old to compile
|
|
5
|
+
* {@link HIDDEN_CHARACTER_SOURCE} (lookbehind arrived in Safari 16.4). It marks the
|
|
6
|
+
* joiners of emoji and Persian text too, which reads worse but shows everything.
|
|
7
|
+
*/
|
|
8
|
+
export declare const HIDDEN_CHARACTER_FALLBACK_SOURCE = "[\\u061C\\u200E\\u200F\\u202A-\\u202E\\u2066-\\u206F\\u{E0000}-\\u{E0FFF}\\u00AD\\u034F\\u115F\\u1160\\u17B4\\u17B5\\u180B-\\u180F\\u200B-\\u200D\\u2060-\\u2065\\u2800\\u3164\\uFE00-\\uFE0F\\uFFA0\\uFFF0-\\uFFF8\\u{1BCA0}-\\u{1BCA3}\\u{1D173}-\\u{1D17A}\\uFEFF]";
|
|
9
|
+
/** The text with each hidden character replaced by a visible `⟦U+XXXX⟧` marker. */
|
|
10
|
+
export declare function visibleControls(text: string): string;
|
|
11
|
+
/** The distinct hidden characters in the text, as sorted code points. */
|
|
12
|
+
export declare function hiddenControlsIn(text: string): string[];
|
|
13
|
+
/**
|
|
14
|
+
* A copy of a JSON value with every string in it, keys included, passed
|
|
15
|
+
* through {@link visibleControls}. Every review result goes through it on its
|
|
16
|
+
* way to the agent: the pull request's words are written by other people, and
|
|
17
|
+
* a hidden character would carry text or reorder it without the reader seeing
|
|
18
|
+
* it. Each string is marked on its own, not the JSON text as a whole, so the
|
|
19
|
+
* start of a string and its line breaks read the way they do in the string
|
|
20
|
+
* itself. The literals are found by scanning for quotes, not by a regular
|
|
21
|
+
* expression, which overflows its stack on a string of a few million
|
|
22
|
+
* characters.
|
|
23
|
+
*/
|
|
24
|
+
export declare function withVisibleControls<Payload>(payload: Payload): Payload;
|
|
25
|
+
interface ChangedText {
|
|
26
|
+
readonly file: string;
|
|
27
|
+
readonly diff: string;
|
|
28
|
+
}
|
|
29
|
+
/** For each file, the distinct hidden characters on lines the change adds or removes (not on context it merely shows). */
|
|
30
|
+
export declare function hiddenControlsByFile(units: readonly ChangedText[]): Map<string, string[]>;
|
|
31
|
+
export {};
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Characters that change how text is drawn or that draw nothing at all. In code
|
|
3
|
+
* they let what a reviewer reads differ from what a compiler reads (Trojan
|
|
4
|
+
* Source, CVE-2021-42574), and in prose they hide instructions. A review tool
|
|
5
|
+
* shows them as visible markers instead of letting the browser act on them.
|
|
6
|
+
*
|
|
7
|
+
* Every Unicode bidirectional control (the left-to-right and right-to-left marks,
|
|
8
|
+
* embeddings, overrides and isolates, and the Arabic letter mark) and the whole
|
|
9
|
+
* plane-14 block (the tag characters, the supplementary variation selectors, and
|
|
10
|
+
* the unassigned code points between them) are always shown. The
|
|
11
|
+
* other invisible characters (joiners, zero-width spaces, fillers, variation
|
|
12
|
+
* selectors, marks) are also how emoji, Persian, Indic, Mongolian, and Hangul
|
|
13
|
+
* text is written, so one is shown only where no such script surrounds it:
|
|
14
|
+
* neither neighbour is a visible character of a script, so a run of them
|
|
15
|
+
* between ASCII letters is shown whole. A byte order mark right after a diff
|
|
16
|
+
* line's `+`, `-`, or space is left alone; anywhere else, a path or an id
|
|
17
|
+
* included, it is shown.
|
|
18
|
+
*/
|
|
19
|
+
const ALWAYS = "\\u061C\\u200E\\u200F\\u202A-\\u202E\\u2066-\\u206F\\u{E0000}-\\u{E0FFF}";
|
|
20
|
+
const BESIDE_ASCII = "\\u00AD\\u034F\\u115F\\u1160\\u17B4\\u17B5\\u180B-\\u180F\\u200B-\\u200D\\u2060-\\u2065\\u2800\\u3164\\uFE00-\\uFE0F\\uFFA0\\uFFF0-\\uFFF8\\u{1BCA0}-\\u{1BCA3}\\u{1D173}-\\u{1D17A}";
|
|
21
|
+
const BYTE_ORDER_MARK = "\\uFEFF";
|
|
22
|
+
/**
|
|
23
|
+
* One visible character of a script: a non-ASCII letter, mark, digit, symbol,
|
|
24
|
+
* or punctuation that is not itself invisible. A space such as U+00A0 is not
|
|
25
|
+
* one, so it cannot shield a hidden character in ASCII code.
|
|
26
|
+
*/
|
|
27
|
+
const SCRIPT = `(?![\\x00-\\x7F${ALWAYS}${BESIDE_ASCII}${BYTE_ORDER_MARK}]|\\p{Default_Ignorable_Code_Point})[\\p{L}\\p{M}\\p{N}\\p{S}\\p{P}]`;
|
|
28
|
+
/** Matches one hidden character at a time, so each match is one code point to mark. */
|
|
29
|
+
export const HIDDEN_CHARACTER_SOURCE = `[${ALWAYS}]|(?<!${SCRIPT})[${BESIDE_ASCII}](?!${SCRIPT})|(?<!${SCRIPT}|(?:^|\\n)[+\\- ])${BYTE_ORDER_MARK}(?!${SCRIPT})`;
|
|
30
|
+
/**
|
|
31
|
+
* Every character of both tiers, with no lookaround, for a browser too old to compile
|
|
32
|
+
* {@link HIDDEN_CHARACTER_SOURCE} (lookbehind arrived in Safari 16.4). It marks the
|
|
33
|
+
* joiners of emoji and Persian text too, which reads worse but shows everything.
|
|
34
|
+
*/
|
|
35
|
+
export const HIDDEN_CHARACTER_FALLBACK_SOURCE = `[${ALWAYS}${BESIDE_ASCII}${BYTE_ORDER_MARK}]`;
|
|
36
|
+
const hidden = new RegExp(HIDDEN_CHARACTER_SOURCE, "gu");
|
|
37
|
+
const anyHidden = new RegExp(HIDDEN_CHARACTER_SOURCE, "u");
|
|
38
|
+
/** `U+202E`, at least four hex digits. */
|
|
39
|
+
function codePoint(character) {
|
|
40
|
+
return `U+${(character.codePointAt(0) ?? 0).toString(16).toUpperCase().padStart(4, "0")}`;
|
|
41
|
+
}
|
|
42
|
+
/** The text with each hidden character replaced by a visible `⟦U+XXXX⟧` marker. */
|
|
43
|
+
export function visibleControls(text) {
|
|
44
|
+
return text.replace(hidden, (character) => `⟦${codePoint(character)}⟧`);
|
|
45
|
+
}
|
|
46
|
+
/** Whether the text holds a character {@link visibleControls} would mark. */
|
|
47
|
+
function hasHiddenControls(text) {
|
|
48
|
+
return anyHidden.test(text);
|
|
49
|
+
}
|
|
50
|
+
/** The distinct hidden characters in the text, as sorted code points. */
|
|
51
|
+
export function hiddenControlsIn(text) {
|
|
52
|
+
const points = new Set();
|
|
53
|
+
for (const match of text.matchAll(hidden))
|
|
54
|
+
points.add(codePoint(match[0]));
|
|
55
|
+
return [...points].sort();
|
|
56
|
+
}
|
|
57
|
+
/** Whether the quote at `index` of a JSON text is escaped: an odd run of backslashes precedes it. */
|
|
58
|
+
function escapedAt(json, index) {
|
|
59
|
+
let backslashes = 0;
|
|
60
|
+
while (json[index - 1 - backslashes] === "\\")
|
|
61
|
+
backslashes += 1;
|
|
62
|
+
return backslashes % 2 === 1;
|
|
63
|
+
}
|
|
64
|
+
/**
|
|
65
|
+
* A copy of a JSON value with every string in it, keys included, passed
|
|
66
|
+
* through {@link visibleControls}. Every review result goes through it on its
|
|
67
|
+
* way to the agent: the pull request's words are written by other people, and
|
|
68
|
+
* a hidden character would carry text or reorder it without the reader seeing
|
|
69
|
+
* it. Each string is marked on its own, not the JSON text as a whole, so the
|
|
70
|
+
* start of a string and its line breaks read the way they do in the string
|
|
71
|
+
* itself. The literals are found by scanning for quotes, not by a regular
|
|
72
|
+
* expression, which overflows its stack on a string of a few million
|
|
73
|
+
* characters.
|
|
74
|
+
*/
|
|
75
|
+
export function withVisibleControls(payload) {
|
|
76
|
+
const json = JSON.stringify(payload);
|
|
77
|
+
// Whatever one string of it would mark, the JSON text marks too: escapes are ASCII.
|
|
78
|
+
if (!hasHiddenControls(json))
|
|
79
|
+
return payload;
|
|
80
|
+
const parts = [];
|
|
81
|
+
let copied = 0;
|
|
82
|
+
for (let open = json.indexOf('"'); open >= 0;) {
|
|
83
|
+
let close = json.indexOf('"', open + 1);
|
|
84
|
+
while (escapedAt(json, close))
|
|
85
|
+
close = json.indexOf('"', close + 1);
|
|
86
|
+
const literal = json.slice(open, close + 1);
|
|
87
|
+
if (hasHiddenControls(literal)) {
|
|
88
|
+
// SAFETY: a JSON string literal parses to a string.
|
|
89
|
+
parts.push(json.slice(copied, open), JSON.stringify(visibleControls(JSON.parse(literal))));
|
|
90
|
+
copied = close + 1;
|
|
91
|
+
}
|
|
92
|
+
open = json.indexOf('"', close + 1);
|
|
93
|
+
}
|
|
94
|
+
parts.push(json.slice(copied));
|
|
95
|
+
// SAFETY: only the contents of JSON strings changed, so the text parses to the same shape.
|
|
96
|
+
return JSON.parse(parts.join(""));
|
|
97
|
+
}
|
|
98
|
+
/** For each file, the distinct hidden characters on lines the change adds or removes (not on context it merely shows). */
|
|
99
|
+
export function hiddenControlsByFile(units) {
|
|
100
|
+
const found = new Map();
|
|
101
|
+
for (const unit of units) {
|
|
102
|
+
for (const line of unit.diff.split("\n")) {
|
|
103
|
+
if (!line.startsWith("+") && !line.startsWith("-"))
|
|
104
|
+
continue;
|
|
105
|
+
for (const point of hiddenControlsIn(line)) {
|
|
106
|
+
const points = found.get(unit.file) ?? new Set();
|
|
107
|
+
points.add(point);
|
|
108
|
+
found.set(unit.file, points);
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
return new Map([...found].map(([file, points]) => [file, [...points].sort()]));
|
|
113
|
+
}
|
package/dist/review/history.js
CHANGED
|
@@ -13,6 +13,7 @@
|
|
|
13
13
|
* context. A shallow clone cuts history, and lines whose origin lies past the cut
|
|
14
14
|
* are counted as unknown rather than attributed to the boundary commit.
|
|
15
15
|
*/
|
|
16
|
+
import { resolveExecutable } from "../executables.js";
|
|
16
17
|
import { execFileSync } from "node:child_process";
|
|
17
18
|
import { posix } from "node:path";
|
|
18
19
|
import { testLikeFile } from "./file-role.js";
|
|
@@ -52,7 +53,7 @@ const STOP_WORDS = new Set([
|
|
|
52
53
|
"before", "after", "while", "without", "because", "through", "over", "under", "about", "again", "back", "other",
|
|
53
54
|
]);
|
|
54
55
|
function git(cwd, args, input) {
|
|
55
|
-
return execFileSync("git", ["--no-replace-objects", "--no-pager", ...args], {
|
|
56
|
+
return execFileSync(resolveExecutable("git"), ["--no-replace-objects", "--no-pager", ...args], {
|
|
56
57
|
cwd, input, encoding: "utf8", maxBuffer: 64 * 1024 * 1024, timeout: GIT_TIMEOUT_MS, stdio: ["pipe", "pipe", "pipe"],
|
|
57
58
|
});
|
|
58
59
|
}
|
|
@@ -130,7 +131,10 @@ function hunkHistories(cwd, base, units) {
|
|
|
130
131
|
let blame;
|
|
131
132
|
try {
|
|
132
133
|
const args = ranges(hunks.flatMap((hunk) => hunk.lines)).flatMap(([a, b]) => ["-L", `${a},${b}`]);
|
|
133
|
-
|
|
134
|
+
// --no-textconv: like `git diff` in input.ts, blame would otherwise run a textconv helper
|
|
135
|
+
// that the repository's config or attributes select. A git too old to know the option
|
|
136
|
+
// fails here and the file simply has no line history.
|
|
137
|
+
blame = parseBlame(git(cwd, ["blame", "--no-textconv", "--porcelain", ...args, base, "--", file]));
|
|
134
138
|
}
|
|
135
139
|
catch {
|
|
136
140
|
// A file absent at the base (renamed or copied) has no line history here.
|
|
@@ -284,7 +288,7 @@ function readBlobs(cwd, rev, paths) {
|
|
|
284
288
|
const out = new Map();
|
|
285
289
|
if (paths.length === 0)
|
|
286
290
|
return out;
|
|
287
|
-
const raw = execFileSync("git", ["--no-replace-objects", "cat-file", "--batch"], {
|
|
291
|
+
const raw = execFileSync(resolveExecutable("git"), ["--no-replace-objects", "cat-file", "--batch"], {
|
|
288
292
|
cwd, input: paths.map((path) => `${rev}:${path}`).join("\n") + "\n", maxBuffer: 256 * 1024 * 1024, timeout: GIT_TIMEOUT_MS,
|
|
289
293
|
});
|
|
290
294
|
let offset = 0;
|