papilot 0.0.0-stage → 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.
Files changed (99) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +126 -2
  3. package/README.zh-CN.md +127 -0
  4. package/bin/papilot.mjs +113 -0
  5. package/dist/apple-touch-icon.png +0 -0
  6. package/dist/assets/Inconsolatazi4-Regular-Dbtq7t_r.otf +0 -0
  7. package/dist/assets/TeXGyrePagellaX-Bold-CEtYdOXj.otf +0 -0
  8. package/dist/assets/TeXGyrePagellaX-Italic-CSuYIstn.otf +0 -0
  9. package/dist/assets/TeXGyrePagellaX-Regular-DQJjZE_L.otf +0 -0
  10. package/dist/assets/cursor-editorTextHighlight-kG3uUOwL.svg +8 -0
  11. package/dist/assets/index-BhQFCMTx.js +131 -0
  12. package/dist/assets/index-CAV3-87U.css +1 -0
  13. package/dist/assets/pdf-view-DJo0X38w.js +598 -0
  14. package/dist/assets/pdf.worker.min-CjEcRF4W.mjs +28 -0
  15. package/dist/favicon.png +0 -0
  16. package/dist/index.html +4 -0
  17. package/examples/papilot-paper/.latex-review.json +4 -0
  18. package/examples/papilot-paper/appendix/proofs.tex +60 -0
  19. package/examples/papilot-paper/appendix/reference.tex +54 -0
  20. package/examples/papilot-paper/main.tex +57 -0
  21. package/examples/papilot-paper/preamble.tex +53 -0
  22. package/examples/papilot-paper/references.bib +158 -0
  23. package/examples/papilot-paper/sections/01-introduction.tex +66 -0
  24. package/examples/papilot-paper/sections/02-using.tex +134 -0
  25. package/examples/papilot-paper/sections/03-background.tex +97 -0
  26. package/examples/papilot-paper/sections/04-model.tex +185 -0
  27. package/examples/papilot-paper/sections/05-conflicts.tex +99 -0
  28. package/examples/papilot-paper/sections/06-building.tex +70 -0
  29. package/examples/papilot-paper/sections/07-geometry.tex +193 -0
  30. package/examples/papilot-paper/sections/08-notes.tex +104 -0
  31. package/examples/papilot-paper/sections/09-agents.tex +106 -0
  32. package/examples/papilot-paper/sections/10-usage.tex +56 -0
  33. package/examples/papilot-paper/sections/11-git.tex +62 -0
  34. package/examples/papilot-paper/sections/12-security.tex +41 -0
  35. package/examples/papilot-paper/sections/13-evaluation.tex +106 -0
  36. package/examples/papilot-paper/sections/14-related.tex +38 -0
  37. package/examples/papilot-paper/sections/15-conclusion.tex +27 -0
  38. package/package.json +85 -4
  39. package/scripts/demo.ts +21 -0
  40. package/scripts/service.ts +35 -0
  41. package/src/notes.ts +40 -0
  42. package/src/server/agents.ts +343 -0
  43. package/src/server/codex.ts +63 -0
  44. package/src/server/frontend.ts +15 -0
  45. package/src/server/main.ts +202 -0
  46. package/src/server/manuscript.ts +337 -0
  47. package/src/server/paths.ts +10 -0
  48. package/src/server/pdf.ts +27 -0
  49. package/src/server/review.ts +53 -0
  50. package/src/server/security.ts +15 -0
  51. package/src/server/store.ts +60 -0
  52. package/src/server/workspace.ts +529 -0
  53. package/src/shared.ts +28 -0
  54. package/vendor/latex-review/LICENSE +21 -0
  55. package/vendor/latex-review/dist/web/assets/TeXGyrePagellaX-Bold-CEtYdOXj.otf +0 -0
  56. package/vendor/latex-review/dist/web/assets/TeXGyrePagellaX-Regular-DQJjZE_L.otf +0 -0
  57. package/vendor/latex-review/dist/web/assets/index-DyzYnIGk.css +1 -0
  58. package/vendor/latex-review/dist/web/assets/index-HxBK-dls.js +19 -0
  59. package/vendor/latex-review/dist/web/index.html +18 -0
  60. package/vendor/latex-review/package-lock.json +3114 -0
  61. package/vendor/latex-review/package.json +49 -0
  62. package/vendor/latex-review/src/cli.ts +494 -0
  63. package/vendor/latex-review/src/client.ts +198 -0
  64. package/vendor/latex-review/src/core/build.ts +296 -0
  65. package/vendor/latex-review/src/core/changes.ts +126 -0
  66. package/vendor/latex-review/src/core/config.ts +270 -0
  67. package/vendor/latex-review/src/core/diff.ts +54 -0
  68. package/vendor/latex-review/src/core/files.ts +96 -0
  69. package/vendor/latex-review/src/core/git.ts +317 -0
  70. package/vendor/latex-review/src/core/outline.ts +314 -0
  71. package/vendor/latex-review/src/core/process.ts +111 -0
  72. package/vendor/latex-review/src/core/publish.ts +339 -0
  73. package/vendor/latex-review/src/core/review.ts +734 -0
  74. package/vendor/latex-review/src/core/store.ts +66 -0
  75. package/vendor/latex-review/src/core/tasks.ts +237 -0
  76. package/vendor/latex-review/src/render/inline.ts +805 -0
  77. package/vendor/latex-review/src/render/locate.ts +481 -0
  78. package/vendor/latex-review/src/render/pdf.ts +281 -0
  79. package/vendor/latex-review/src/render/pdfdiff.ts +272 -0
  80. package/vendor/latex-review/src/render/round.ts +236 -0
  81. package/vendor/latex-review/src/render/worker.ts +10 -0
  82. package/vendor/latex-review/src/runtime.ts +23 -0
  83. package/vendor/latex-review/src/server.ts +401 -0
  84. package/vendor/latex-review/src/types.ts +283 -0
  85. package/vendor/latex-review/src/web/ChangeMap.tsx +212 -0
  86. package/vendor/latex-review/src/web/PdfStrip.tsx +77 -0
  87. package/vendor/latex-review/src/web/fonts/GUST-FONT-LICENSE.txt +29 -0
  88. package/vendor/latex-review/src/web/fonts/LPPL.txt +415 -0
  89. package/vendor/latex-review/src/web/fonts/MANIFEST-newpx.txt +20 -0
  90. package/vendor/latex-review/src/web/fonts/README-newpx.txt +473 -0
  91. package/vendor/latex-review/src/web/fonts/README.md +12 -0
  92. package/vendor/latex-review/src/web/fonts/TeXGyrePagellaX-Bold.otf +0 -0
  93. package/vendor/latex-review/src/web/fonts/TeXGyrePagellaX-Regular.otf +0 -0
  94. package/vendor/latex-review/src/web/index.html +17 -0
  95. package/vendor/latex-review/src/web/main.tsx +10 -0
  96. package/vendor/latex-review/src/web/model.ts +153 -0
  97. package/vendor/latex-review/src/web/overview.tsx +146 -0
  98. package/vendor/latex-review/src/web/review.tsx +991 -0
  99. package/vendor/latex-review/src/web/style.css +1249 -0
@@ -0,0 +1,198 @@
1
+ import fs from "node:fs";
2
+ import path from "node:path";
3
+ import { spawn } from "node:child_process";
4
+ import { ROOT, codeVersion } from "./runtime.ts";
5
+ import { Repo } from "./core/git.ts";
6
+ import { readJson, sleep, withLock } from "./core/files.ts";
7
+ export interface Connection {
8
+ url: string;
9
+ port: number;
10
+ pid: number;
11
+ root?: string;
12
+ version?: string;
13
+ }
14
+ export interface ServerInfo {
15
+ root: string;
16
+ version: string;
17
+ pid: number;
18
+ busy: boolean;
19
+ csrf: string;
20
+ build_workers: number;
21
+ runtime?: string;
22
+ }
23
+ export class ServerError extends Error {}
24
+ export async function call<T>(
25
+ url: string,
26
+ route: string,
27
+ data?: unknown,
28
+ token = "",
29
+ timeout = 30000,
30
+ ): Promise<T> {
31
+ const response = await fetch(url + route, {
32
+ method: data === undefined ? "GET" : "POST",
33
+ headers: { "Content-Type": "application/json", "X-Review-Token": token },
34
+ ...(data === undefined ? {} : { body: JSON.stringify(data) }),
35
+ signal: AbortSignal.timeout(timeout),
36
+ redirect: "error",
37
+ });
38
+ const value = (await response.json()) as T & { error?: string };
39
+ if (!response.ok)
40
+ throw new ServerError(value.error || `HTTP ${response.status}`);
41
+ return value;
42
+ }
43
+ export class Client {
44
+ constructor(
45
+ public url: string,
46
+ public token: string,
47
+ ) {}
48
+ get<T>(route: string, timeout = 30000) {
49
+ return call<T>(this.url, route, undefined, "", timeout);
50
+ }
51
+ post<T>(route: string, data: unknown = {}, timeout = 120000) {
52
+ return call<T>(this.url, route, data, this.token, timeout);
53
+ }
54
+ }
55
+ export const serverHome = (root: string) =>
56
+ path.join(new Repo(root).gitDir(), "latex-review/server");
57
+ export async function probe(root: string, info: Partial<Connection> | null) {
58
+ if (!info?.url) return null;
59
+ const address = new URL(info.url);
60
+ if (address.protocol !== "http:" || address.hostname !== "127.0.0.1")
61
+ return null;
62
+ try {
63
+ const answer = await call<ServerInfo>(
64
+ info.url,
65
+ "/api/info",
66
+ undefined,
67
+ "",
68
+ 5000,
69
+ );
70
+ return path.resolve(answer.root) === path.resolve(root) ? answer : null;
71
+ } catch {
72
+ return null;
73
+ }
74
+ }
75
+ async function waitForExit(url: string, pid: number) {
76
+ const deadline = Date.now() + 25000;
77
+ while (Date.now() < deadline) {
78
+ try {
79
+ const info = await call<ServerInfo>(
80
+ url,
81
+ "/api/info",
82
+ undefined,
83
+ "",
84
+ 1000,
85
+ );
86
+ if (info.pid !== pid) return;
87
+ } catch {
88
+ return;
89
+ }
90
+ await sleep(100);
91
+ }
92
+ throw new ServerError(
93
+ "The review server has not stopped yet. Retry after it finishes.",
94
+ );
95
+ }
96
+ async function launch(root: string, port: number): Promise<Client> {
97
+ const home = serverHome(root);
98
+ fs.mkdirSync(home, { recursive: true });
99
+ const log = path.join(home, "server.log");
100
+ if (fs.existsSync(log) && fs.statSync(log).size > 2000000)
101
+ fs.renameSync(log, log + ".1");
102
+ const fd = fs.openSync(log, "a"),
103
+ child = spawn(
104
+ process.execPath,
105
+ [
106
+ "--import",
107
+ "tsx",
108
+ path.join(ROOT, "src/cli.ts"),
109
+ "serve",
110
+ "--root",
111
+ root,
112
+ "--port",
113
+ String(port),
114
+ ],
115
+ {
116
+ cwd: ROOT,
117
+ detached: true,
118
+ stdio: ["ignore", fd, fd],
119
+ env: process.env,
120
+ },
121
+ );
122
+ fs.closeSync(fd);
123
+ let launchError: Error | undefined;
124
+ child.on("error", (error) => {
125
+ launchError = error;
126
+ });
127
+ child.unref();
128
+ const deadline = Date.now() + 60000;
129
+ while (Date.now() < deadline) {
130
+ if (launchError) throw new ServerError(launchError.message);
131
+ const info = readJson<Connection | null>(
132
+ path.join(home, "connection.json"),
133
+ null,
134
+ );
135
+ if (info && info.pid === child.pid) {
136
+ const answer = await probe(root, info);
137
+ if (answer) return new Client(info.url, answer.csrf);
138
+ }
139
+ if (child.exitCode !== null) break;
140
+ await sleep(100);
141
+ }
142
+ throw new ServerError(
143
+ "The review server failed to start:\n" +
144
+ fs.readFileSync(log, "utf8").slice(-1500),
145
+ );
146
+ }
147
+ export async function connect(
148
+ root: string,
149
+ start: false,
150
+ ): Promise<Client | null>;
151
+ export async function connect(root: string, start?: true): Promise<Client>;
152
+ export async function connect(
153
+ root: string,
154
+ start = true,
155
+ ): Promise<Client | null> {
156
+ root = path.resolve(root);
157
+ const home = serverHome(root);
158
+ return withLock(path.join(home, "start.lock"), async () => {
159
+ const last = readJson<{ port: number } | null>(
160
+ path.join(home, "last.json"),
161
+ null,
162
+ );
163
+ let info = readJson<Connection | null>(
164
+ path.join(home, "connection.json"),
165
+ null,
166
+ );
167
+ if (!info && last)
168
+ info = { url: `http://127.0.0.1:${last.port}`, port: last.port, pid: 0 };
169
+ let answer = await probe(root, info);
170
+ if (answer && (!start || answer.version === codeVersion()))
171
+ return new Client(info!.url, answer.csrf);
172
+ if (!start) return null;
173
+ if (answer && info) {
174
+ const deadline = Date.now() + 900000;
175
+ while (answer?.busy && Date.now() < deadline) {
176
+ await sleep(1000);
177
+ answer = await probe(root, info);
178
+ }
179
+ if (answer?.busy)
180
+ throw new ServerError(
181
+ "A round is still building. Wait for it before upgrading the server.",
182
+ );
183
+ if (answer) {
184
+ await call(info.url, "/api/shutdown", {}, answer.csrf, 5000);
185
+ await waitForExit(info.url, answer.pid);
186
+ }
187
+ }
188
+ return launch(root, info?.port ?? last?.port ?? 0);
189
+ });
190
+ }
191
+ export async function stop(root: string, force = false) {
192
+ const client = await connect(root, false);
193
+ if (!client) return null;
194
+ const answer = await client.get<ServerInfo>("/api/info");
195
+ await client.post("/api/shutdown", { force });
196
+ await waitForExit(client.url, answer.pid);
197
+ return client.url;
198
+ }
@@ -0,0 +1,296 @@
1
+ import fs from "node:fs";
2
+ import path from "node:path";
3
+ import { createHash } from "node:crypto";
4
+ import { fileURLToPath } from "node:url";
5
+ import { Repo } from "./git.ts";
6
+ import { listFiles, readJson, writeJson, withLock, now } from "./files.ts";
7
+ import { stripComments } from "./outline.ts";
8
+ import { signature, type Project } from "./config.ts";
9
+ import { run, runAsync } from "./process.ts";
10
+ import type { BuildInfo } from "../types.ts";
11
+ export class BuildError extends Error {
12
+ constructor(
13
+ message: string,
14
+ public details: string[] = [],
15
+ public log?: string,
16
+ ) {
17
+ super(message);
18
+ }
19
+ }
20
+ const AUXILIARY = new Set([
21
+ ".aux",
22
+ ".bbl",
23
+ ".brf",
24
+ ".toc",
25
+ ".lof",
26
+ ".lot",
27
+ ".loe",
28
+ ".out",
29
+ ".thm",
30
+ ]);
31
+ const hash = (value: string | Buffer) =>
32
+ createHash("sha256").update(value).digest("hex");
33
+ export const includes = (text: string) =>
34
+ [...stripComments(text).matchAll(/^\s*\\include\{([^}]+)\}/gm)].map((m) =>
35
+ m[1].trim(),
36
+ );
37
+ export function affectedTargets(
38
+ texts: Record<string, string>,
39
+ changed: string[],
40
+ targets: string[],
41
+ ) {
42
+ const owners: Record<string, Set<string>> = {};
43
+ function visit(file: string, target: string, seen: Set<string>) {
44
+ if (!path.posix.extname(file)) file += ".tex";
45
+ if (seen.has(file) || !(file in texts)) return;
46
+ seen.add(file);
47
+ (owners[file] ??= new Set()).add(target);
48
+ for (const m of stripComments(texts[file]).matchAll(
49
+ /\\(?:input|include|subfile)\{([^}]+)\}/g,
50
+ ))
51
+ visit(path.posix.normalize(m[1].trim()), target, seen);
52
+ }
53
+ targets.forEach((t) => visit(t, t, new Set()));
54
+ const selected = new Set<string>();
55
+ for (const file of changed) {
56
+ if (!owners[file]) return ["full"];
57
+ for (const owner of owners[file]) selected.add(owner);
58
+ }
59
+ return [...selected].sort();
60
+ }
61
+ export function errors(text: string) {
62
+ const lines = text.split("\n"),
63
+ result: string[] = [];
64
+ for (const [i, line] of lines.entries())
65
+ if (/^(\S[^:\n]*):(\d+): (.+)$/.test(line) || line.startsWith("! ")) {
66
+ const context = lines
67
+ .slice(i + 1, i + 8)
68
+ .find((l) => /^l\.\d+ /.test(l))
69
+ ?.trim();
70
+ result.push(
71
+ line.replace(/^! |^\.\//, "") + (context ? ` [${context}]` : ""),
72
+ );
73
+ if (result.length === 8) break;
74
+ }
75
+ return result;
76
+ }
77
+ export function warnings(text: string) {
78
+ const patterns: [RegExp, string][] = [
79
+ [/LaTeX Warning: Reference `([^']+)' on page/g, "Undefined reference: "],
80
+ [/Warning: Citation [`']([^']+)' on page/g, "Undefined citation: "],
81
+ [
82
+ /LaTeX Warning: Label `([^']+)' multiply defined/g,
83
+ "Label defined twice: ",
84
+ ],
85
+ [/Missing character: There is no (\S+)/g, "Missing glyph: "],
86
+ ];
87
+ return [
88
+ ...new Set(
89
+ patterns.flatMap(([re, label]) =>
90
+ [...text.matchAll(re)].map((m) => label + m[1]),
91
+ ),
92
+ ),
93
+ ].sort();
94
+ }
95
+ export class Builder {
96
+ cache: string;
97
+ constructor(
98
+ public project: Project,
99
+ public repo: Repo,
100
+ ) {
101
+ this.cache = path.join(project.state, "cache/builds");
102
+ }
103
+ documentTexts(rev: string) {
104
+ const files = this.repo
105
+ .files(rev, this.project.doc_dir)
106
+ .filter((f) => f.endsWith(".tex")),
107
+ blobs = this.repo.blobs(rev, files);
108
+ return Object.fromEntries(
109
+ files.map((f) => [
110
+ path.posix.relative(this.project.doc_dir || ".", f),
111
+ blobs[f]?.toString() ?? "",
112
+ ]),
113
+ );
114
+ }
115
+ plan(before: string, after: string, changed: string[]) {
116
+ const p = this.project,
117
+ old = this.documentTexts(before),
118
+ next = this.documentTexts(after),
119
+ main = path.posix.basename(p.main),
120
+ targets = includes(next[main] ?? "");
121
+ if (
122
+ !p.partial_builds ||
123
+ targets.length < 2 ||
124
+ JSON.stringify(targets) !== JSON.stringify(includes(old[main] ?? ""))
125
+ )
126
+ return ["full"];
127
+ if (p.doc_dir && changed.some((f) => !f.startsWith(p.doc_dir + "/")))
128
+ return ["full"];
129
+ const local = changed.map((f) => path.posix.relative(p.doc_dir || ".", f)),
130
+ selected = new Set([
131
+ ...affectedTargets(old, local, targets),
132
+ ...affectedTargets(next, local, targets),
133
+ ]);
134
+ if (selected.has("full") || !selected.size) return ["full"];
135
+ for (const name of [...selected])
136
+ for (const dependency of p.dependencies[name] ?? [])
137
+ selected.add(dependency);
138
+ return targets.filter((t) => selected.has(t));
139
+ }
140
+ async build(
141
+ rev: string,
142
+ targets = ["full"],
143
+ seed: BuildInfo | null = null,
144
+ ): Promise<BuildInfo> {
145
+ const p = this.project,
146
+ partial = JSON.stringify(targets) !== '["full"]';
147
+ if (partial && !seed)
148
+ throw new BuildError(
149
+ "A partial build needs a completed full build to start from.",
150
+ );
151
+ let version: string;
152
+ try {
153
+ version = run(p.engine, ["--version"]).stdout.toString().split("\n")[0];
154
+ } catch {
155
+ throw new BuildError(
156
+ `${p.engine} was not found. Install TeX Live (or MacTeX) with latexmk.`,
157
+ );
158
+ }
159
+ const tree = this.repo.treeId(rev, p.source_dir),
160
+ key = hash(
161
+ JSON.stringify([
162
+ tree,
163
+ targets,
164
+ seed?.key ?? null,
165
+ signature(p),
166
+ version,
167
+ hash(fs.readFileSync(fileURLToPath(import.meta.url))),
168
+ ]),
169
+ ).slice(0, 32),
170
+ folder = path.join(this.cache, key);
171
+ return withLock(path.join(this.cache, key + ".lock"), async () => {
172
+ const cached = readJson<BuildInfo | null>(
173
+ path.join(folder, "result.json"),
174
+ null,
175
+ );
176
+ if (cached && fs.existsSync(cached.pdf)) {
177
+ fs.utimesSync(folder, new Date(), new Date());
178
+ return cached;
179
+ }
180
+ fs.rmSync(folder, { recursive: true, force: true });
181
+ const source = path.join(folder, "src");
182
+ this.repo.export(rev, p.source_dir, source);
183
+ const cwd = path.join(
184
+ source,
185
+ path.posix.relative(p.source_dir || ".", p.doc_dir || "."),
186
+ ),
187
+ main = path.posix.basename(p.main),
188
+ job = path.posix.basename(main, ".tex"),
189
+ out = path.join(cwd, "build");
190
+ fs.mkdirSync(out, { recursive: true });
191
+ for (const target of includes(
192
+ fs.readFileSync(path.join(cwd, main), "utf8"),
193
+ ))
194
+ fs.mkdirSync(path.join(out, path.dirname(target)), { recursive: true });
195
+ const args = [
196
+ "-norc",
197
+ ...(p.latexmkrc
198
+ ? ["-r", path.posix.relative(p.doc_dir || ".", p.latexmkrc)]
199
+ : []),
200
+ { pdflatex: "-pdf", xelatex: "-xelatex", lualatex: "-lualatex" }[
201
+ p.engine
202
+ ],
203
+ "-outdir=build",
204
+ `-jobname=${job}`,
205
+ "-synctex=1",
206
+ "-interaction=nonstopmode",
207
+ "-halt-on-error",
208
+ "-file-line-error",
209
+ ];
210
+ if (partial) {
211
+ const seedOut = path.dirname(seed!.pdf);
212
+ for (const file of listFiles(seedOut))
213
+ if (AUXILIARY.has(path.extname(file))) {
214
+ const target = path.join(out, path.relative(seedOut, file));
215
+ fs.mkdirSync(path.dirname(target), { recursive: true });
216
+ if (!fs.existsSync(target)) fs.copyFileSync(file, target);
217
+ }
218
+ const preload = Object.entries(p.preload)
219
+ .filter(
220
+ ([name, file]) =>
221
+ !targets.includes(name) && fs.existsSync(path.join(cwd, file)),
222
+ )
223
+ .map(([, file]) => `\\input{${file}}`)
224
+ .join("");
225
+ args.push(
226
+ `-usepretex=\\includeonly{${targets.join(",")}}` +
227
+ (preload ? `\\AtBeginDocument{${preload}}` : ""),
228
+ );
229
+ }
230
+ args.push(main);
231
+ const transcript = path.join(folder, "latexmk.log"),
232
+ fd = fs.openSync(transcript, "w"),
233
+ started = now();
234
+ let failure: Error | null = null;
235
+ try {
236
+ await runAsync("latexmk", args, {
237
+ cwd,
238
+ timeout: 1800000,
239
+ output: (chunk) => fs.writeSync(fd, chunk),
240
+ });
241
+ } catch (error) {
242
+ failure = error as Error;
243
+ } finally {
244
+ fs.closeSync(fd);
245
+ }
246
+ const pdf = path.join(out, job + ".pdf"),
247
+ log = path.join(out, job + ".log"),
248
+ logText = fs.existsSync(log) ? fs.readFileSync(log, "utf8") : "";
249
+ if (failure || !fs.existsSync(pdf)) {
250
+ const details = errors(logText),
251
+ kept = path.join(p.state, "logs", key + ".log");
252
+ fs.mkdirSync(path.dirname(kept), { recursive: true });
253
+ fs.copyFileSync(fs.existsSync(log) ? log : transcript, kept);
254
+ const tail = fs.readFileSync(transcript, "utf8").split("\n").slice(-25);
255
+ fs.rmSync(folder, { recursive: true, force: true });
256
+ throw new BuildError(
257
+ failure?.message.includes("ENOENT")
258
+ ? "latexmk was not found. Install TeX Live or MacTeX."
259
+ : "LaTeX failed to compile.",
260
+ details.length ? details : tail,
261
+ kept,
262
+ );
263
+ }
264
+ const result: BuildInfo = {
265
+ key,
266
+ tree,
267
+ targets,
268
+ root: source,
269
+ cwd,
270
+ pdf,
271
+ seconds: Math.round((now() - started) * 10) / 10,
272
+ warnings: warnings(logText),
273
+ };
274
+ writeJson(path.join(folder, "result.json"), result);
275
+ return result;
276
+ });
277
+ }
278
+ async prune(keep: Set<string>, maxAge = 3 * 86400) {
279
+ if (!fs.existsSync(this.cache)) return;
280
+ for (const entry of fs.readdirSync(this.cache, { withFileTypes: true })) {
281
+ const folder = path.join(this.cache, entry.name);
282
+ if (
283
+ entry.isDirectory() &&
284
+ !entry.name.endsWith(".lock") &&
285
+ !keep.has(entry.name) &&
286
+ now() - fs.statSync(folder).mtimeMs / 1000 > maxAge
287
+ )
288
+ await withLock(
289
+ path.join(this.cache, entry.name + ".lock"),
290
+ async () => {
291
+ fs.rmSync(folder, { recursive: true, force: true });
292
+ },
293
+ );
294
+ }
295
+ }
296
+ }
@@ -0,0 +1,126 @@
1
+ import { Repo, within } from "./git.ts";
2
+ import { groups, opcodes } from "./diff.ts";
3
+ import type { Scope, Unit, Op } from "../types.ts";
4
+ export const EXCERPT = 6000;
5
+ export function textOf(data: Buffer | null): string | null {
6
+ if (data === null) return "";
7
+ if (data.includes(0)) return null;
8
+ try {
9
+ return new TextDecoder("utf-8", { fatal: true }).decode(data);
10
+ } catch {
11
+ return null;
12
+ }
13
+ }
14
+ export const splitLines = (text: string) =>
15
+ text.match(/[^\n]*\n|[^\n]+$/g) ?? [];
16
+ export function diffUnits(
17
+ repo: Repo,
18
+ before: string,
19
+ after: string,
20
+ isFigure: (p: string) => boolean = () => false,
21
+ ): Unit[] {
22
+ const files = repo.changedFiles(before, after),
23
+ oldBlobs = repo.blobs(before, files),
24
+ newBlobs = repo.blobs(after, files),
25
+ units: Unit[] = [];
26
+ for (const file of files) {
27
+ const old = oldBlobs[file],
28
+ next = newBlobs[file],
29
+ a = textOf(old),
30
+ b = textOf(next);
31
+ const status =
32
+ old === null ? "added" : next === null ? "deleted" : "modified",
33
+ binary = a === null || b === null;
34
+ const left = binary ? [] : splitLines(a!),
35
+ right = binary ? [] : splitLines(b!);
36
+ let hunks = binary ? [] : groups(opcodes(left, right));
37
+ const whole =
38
+ binary || status !== "modified" || isFigure(file) || !hunks.length;
39
+ if (whole) hunks = [[["replace", 0, left.length, 0, right.length]]];
40
+ for (const edits of hunks) {
41
+ const [, i, , x] = edits[0],
42
+ [, , j, , y] = edits.at(-1)!;
43
+ const oldText = left.slice(i, j).join(""),
44
+ newText = right.slice(x, y).join("");
45
+ units.push({
46
+ id: `c${String(units.length + 1).padStart(3, "0")}`,
47
+ file,
48
+ status,
49
+ kind: binary ? "binary" : isFigure(file) ? "figure" : "text",
50
+ whole_file: whole,
51
+ ops: whole ? [] : edits,
52
+ old_start: i + 1,
53
+ old_end: Math.max(i + 1, j),
54
+ new_start: x + 1,
55
+ new_end: Math.max(x + 1, y),
56
+ old_text: binary ? "" : oldText.slice(0, EXCERPT),
57
+ new_text: binary ? "" : newText.slice(0, EXCERPT),
58
+ truncated: oldText.length > EXCERPT || newText.length > EXCERPT,
59
+ });
60
+ }
61
+ }
62
+ return units;
63
+ }
64
+ function edits(units: Unit[], index: 1 | 3): Op[] {
65
+ return units.flatMap((u) => u.ops).sort((a, b) => b[index] - a[index]);
66
+ }
67
+ export function applyUnits(
68
+ old: Buffer | null,
69
+ next: Buffer | null,
70
+ units: Unit[],
71
+ ) {
72
+ if (units.some((u) => u.whole_file)) return next;
73
+ const a = splitLines(old!.toString()),
74
+ b = splitLines(next!.toString());
75
+ for (const [, i, j, x, y] of edits(units, 1))
76
+ a.splice(i, j - i, ...b.slice(x, y));
77
+ return Buffer.from(a.join(""));
78
+ }
79
+ export function revertUnits(
80
+ old: Buffer | null,
81
+ next: Buffer | null,
82
+ units: Unit[],
83
+ ) {
84
+ if (units.some((u) => u.whole_file)) return old;
85
+ const a = splitLines(old!.toString()),
86
+ b = splitLines(next!.toString());
87
+ for (const [, i, j, x, y] of edits(units, 3))
88
+ b.splice(x, y - x, ...a.slice(i, j));
89
+ return Buffer.from(b.join(""));
90
+ }
91
+ export function scopeViolations(
92
+ repo: Repo,
93
+ base: string,
94
+ revision: string,
95
+ scope: Scope,
96
+ ) {
97
+ const files = repo.changedFiles(base, revision);
98
+ if (scope.ranges === null)
99
+ return files.filter((f) => !within(f, scope.paths));
100
+ const old = repo.blobs(base, files),
101
+ next = repo.blobs(revision, files),
102
+ violations: string[] = [];
103
+ for (const file of files) {
104
+ const a = textOf(old[file]),
105
+ b = textOf(next[file]),
106
+ allowed = scope.ranges[file];
107
+ if (!allowed || old[file] === null || a === null || b === null) {
108
+ violations.push(file);
109
+ continue;
110
+ }
111
+ for (const [tag, i, j] of opcodes(splitLines(a), splitLines(b))) {
112
+ if (tag === "equal") continue;
113
+ const permitted =
114
+ i === j
115
+ ? allowed.some(([s, e]) => s - 1 <= i && i <= e)
116
+ : Array.from({ length: j - i }, (_, k) => i + k + 1).every((n) =>
117
+ allowed.some(([s, e]) => s <= n && n <= e),
118
+ );
119
+ if (!permitted) {
120
+ violations.push(`${file}:${i + 1}`);
121
+ break;
122
+ }
123
+ }
124
+ }
125
+ return violations;
126
+ }