paperlint 12.0.0 → 13.0.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/CONTRIBUTING.md +2 -1
- package/dist/adapters/memory/index.d.ts +5 -0
- package/dist/adapters/memory/index.d.ts.map +1 -1
- package/dist/adapters/memory/index.js +16 -0
- package/dist/adapters/memory/index.js.map +1 -1
- package/dist/adapters/node/index.d.ts +1 -0
- package/dist/adapters/node/index.d.ts.map +1 -1
- package/dist/adapters/node/index.js +1 -0
- package/dist/adapters/node/index.js.map +1 -1
- package/dist/adapters/node/move-file.io.d.ts +4 -0
- package/dist/adapters/node/move-file.io.d.ts.map +1 -0
- package/dist/adapters/node/move-file.io.js +12 -0
- package/dist/adapters/node/move-file.io.js.map +1 -0
- package/dist/adapters/yaml/index.d.ts +3 -0
- package/dist/adapters/yaml/index.d.ts.map +1 -0
- package/dist/adapters/yaml/index.js +17 -0
- package/dist/adapters/yaml/index.js.map +1 -0
- package/dist/cli.d.ts.map +1 -1
- package/dist/cli.js +48 -7
- package/dist/cli.js.map +1 -1
- package/dist/cycle-rules.d.ts +3 -3
- package/dist/cycle-rules.d.ts.map +1 -1
- package/dist/cycle-rules.js +1 -2
- package/dist/cycle-rules.js.map +1 -1
- package/dist/domain/cycle.d.ts +16 -2
- package/dist/domain/cycle.d.ts.map +1 -1
- package/dist/domain/cycle.js +21 -42
- package/dist/domain/cycle.js.map +1 -1
- package/dist/domain/paper-layout.d.ts.map +1 -1
- package/dist/domain/paper-layout.js +5 -2
- package/dist/domain/paper-layout.js.map +1 -1
- package/dist/domain/paths.d.ts +12 -0
- package/dist/domain/paths.d.ts.map +1 -1
- package/dist/domain/paths.js +22 -1
- package/dist/domain/paths.js.map +1 -1
- package/dist/domain/scaffold-layout.js +1 -1
- package/dist/domain/versions-migration.d.ts +51 -0
- package/dist/domain/versions-migration.d.ts.map +1 -0
- package/dist/domain/versions-migration.js +252 -0
- package/dist/domain/versions-migration.js.map +1 -0
- package/dist/domain/versions.d.ts +167 -0
- package/dist/domain/versions.d.ts.map +1 -0
- package/dist/domain/versions.js +457 -0
- package/dist/domain/versions.js.map +1 -0
- package/dist/eslint-rules/paper-stages.d.ts +7 -0
- package/dist/eslint-rules/paper-stages.d.ts.map +1 -1
- package/dist/eslint-rules/paper-stages.js +62 -7
- package/dist/eslint-rules/paper-stages.js.map +1 -1
- package/dist/eslint-rules/rule-context.d.ts +2 -0
- package/dist/eslint-rules/rule-context.d.ts.map +1 -1
- package/dist/ports/move-file.d.ts +7 -0
- package/dist/ports/move-file.d.ts.map +1 -0
- package/dist/ports/move-file.js +2 -0
- package/dist/ports/move-file.js.map +1 -0
- package/dist/ports/yaml.d.ts +5 -0
- package/dist/ports/yaml.d.ts.map +1 -0
- package/dist/ports/yaml.js +2 -0
- package/dist/ports/yaml.js.map +1 -0
- package/dist/submission.d.ts +2 -0
- package/dist/submission.d.ts.map +1 -1
- package/dist/submission.js +49 -9
- package/dist/submission.js.map +1 -1
- package/dist/version-rules.d.ts +16 -0
- package/dist/version-rules.d.ts.map +1 -0
- package/dist/version-rules.js +201 -0
- package/dist/version-rules.js.map +1 -0
- package/dist/versions-command.d.ts +37 -0
- package/dist/versions-command.d.ts.map +1 -0
- package/dist/versions-command.js +209 -0
- package/dist/versions-command.js.map +1 -0
- package/dist/versions.d.ts +76 -0
- package/dist/versions.d.ts.map +1 -0
- package/dist/versions.js +164 -0
- package/dist/versions.js.map +1 -0
- package/docs/configuration.md +76 -2
- package/docs/layout.md +5 -5
- package/docs/rules/cycle/camera-ready-source.md +62 -0
- package/docs/rules/cycle/draft-drift.md +47 -0
- package/docs/rules/cycle/port-done.md +2 -2
- package/docs/rules/cycle/record.md +2 -1
- package/docs/rules/cycle/submitted-frozen.md +65 -0
- package/docs/rules/paper/allowed-files-only.md +1 -1
- package/docs/rules/versions/layout.md +88 -0
- package/docs/rules.md +25 -11
- package/docs/submission.md +21 -3
- package/eslint-rules/paper-stages.test.mjs +62 -0
- package/eslint-rules/paper-stages.ts +67 -8
- package/eslint-rules/prereg-coverage.test.ts +1 -0
- package/eslint-rules/rule-context.ts +2 -0
- package/package.json +2 -1
- package/presets/agent.jsonc +75 -0
- package/presets/agent.md +70 -0
- package/presets/agentverify.jsonc +72 -0
- package/presets/agentverify.md +85 -0
- package/presets/ieee-conference.md +1 -1
- package/presets/nlbse.jsonc +76 -0
- package/presets/nlbse.md +85 -0
- package/scripts/coverage-config.test.ts +3 -0
- package/skills/CLAUDE.md +2 -2
- package/skills/CLAUDE.md.spec.ts +1 -1
- package/skills/paper-pipeline/references/pipeline-status-template.md +4 -1
- package/skills/submit-paper/SKILL.md +18 -10
- package/skills/submit-paper/SKILL.md.spec.ts +17 -9
- package/skills/submit-paper/references/publishers/acm.md +5 -3
- package/src/adapters/memory/index.test.ts +22 -0
- package/src/adapters/memory/index.ts +21 -0
- package/src/adapters/node/index.ts +1 -0
- package/src/adapters/node/move-file.io.ts +21 -0
- package/src/adapters/node/move-file.test.ts +51 -0
- package/src/adapters/yaml/index.test.ts +16 -0
- package/src/adapters/yaml/index.ts +17 -0
- package/src/cli.test.ts +43 -0
- package/src/cli.ts +58 -6
- package/src/cycle-rules.ts +4 -5
- package/src/domain/cycle.test.ts +27 -0
- package/src/domain/cycle.ts +38 -6
- package/src/domain/paper-layout.ts +5 -2
- package/src/domain/paths.test.ts +28 -1
- package/src/domain/paths.ts +34 -1
- package/src/domain/scaffold-layout.ts +1 -1
- package/src/domain/versions-migration.test.ts +410 -0
- package/src/domain/versions-migration.ts +429 -0
- package/src/domain/versions.test.ts +564 -0
- package/src/domain/versions.ts +706 -0
- package/src/paper-settings-commands.test.ts +1 -1
- package/src/ports/move-file.ts +8 -0
- package/src/ports/yaml.ts +5 -0
- package/src/preset-card-rule.test.ts +3 -0
- package/src/presets.test.ts +55 -31
- package/src/rule-cases.test.ts +157 -8
- package/src/submission-freeze.test.ts +320 -0
- package/src/submission-new.test.ts +11 -1
- package/src/submission.ts +82 -11
- package/src/tex-venue-rules.test.ts +3 -0
- package/src/venue-rules.test.ts +2 -2
- package/src/version-rules.test.ts +222 -0
- package/src/version-rules.ts +282 -0
- package/src/versions-command.test.ts +594 -0
- package/src/versions-command.ts +360 -0
- package/src/versions.ts +286 -0
- package/dist/eslint-rules/cycle-stage.d.ts +0 -39
- package/dist/eslint-rules/cycle-stage.d.ts.map +0 -1
- package/dist/eslint-rules/cycle-stage.js +0 -119
- package/dist/eslint-rules/cycle-stage.js.map +0 -1
- package/docs/rules/cycle/stage.md +0 -85
- package/eslint-rules/cycle-stage.test.mjs +0 -131
- package/eslint-rules/cycle-stage.ts +0 -141
|
@@ -0,0 +1,706 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* THE FROZEN VERSIONS OF A PAPER, by convention: one folder per attempt at a venue, named by the
|
|
3
|
+
* attempt's id in `paperlint.json` `cycles`, holding a file per stage and nothing else.
|
|
4
|
+
*
|
|
5
|
+
* versions/
|
|
6
|
+
* ├── alpha-2026/ ← a cycle id
|
|
7
|
+
* │ ├── submitted.pdf ← what the venue's reviewers read
|
|
8
|
+
* │ └── submitted.tex ← the source it was built from (a multi-file paper: assembled)
|
|
9
|
+
* ├── beta-2027/
|
|
10
|
+
* │ ├── submitted.pdf
|
|
11
|
+
* │ ├── submitted.tex
|
|
12
|
+
* │ └── camera-ready.pdf ← only after the attempt is accepted
|
|
13
|
+
* └── lock.json ← generated: bytes, sha256 and the time each file was frozen
|
|
14
|
+
*
|
|
15
|
+
* ── WHY NAMES, NOT A RECORD ─────────────────────────────────────────────────────────────
|
|
16
|
+
* The `stages:` list in a paper's scorecard said the same thing as the file names, a second time, by
|
|
17
|
+
* hand, and a rule kept the two in step. Here the names ARE the record: which attempt a file belongs
|
|
18
|
+
* to is its folder, which stage is its name, and what state it is in follows from the attempt's
|
|
19
|
+
* `outcome` — the one value an author changes when a decision arrives. Nothing is typed twice.
|
|
20
|
+
*
|
|
21
|
+
* ── WHY A LOCK ──────────────────────────────────────────────────────────────────────────
|
|
22
|
+
* What a name cannot carry — the bytes a file had when it was frozen, its digest, when it was frozen,
|
|
23
|
+
* and that a source was lost — goes into `lock.json`, which paperlint writes and nobody edits
|
|
24
|
+
* (`package-lock.json`, `schema.rb`). A frozen file is never supposed to change, so a file that does
|
|
25
|
+
* not match its lock entry is the finding.
|
|
26
|
+
*
|
|
27
|
+
* ── NO DATES IN NAMES ───────────────────────────────────────────────────────────────────
|
|
28
|
+
* A second upload to the same attempt replaces the first: the last upload is the one under review.
|
|
29
|
+
* When it was frozen is a fact paperlint records in the lock, not one a person types into a name.
|
|
30
|
+
*
|
|
31
|
+
* Pure: the disk is read by the caller and handed in.
|
|
32
|
+
*/
|
|
33
|
+
import { diffLines } from "diff";
|
|
34
|
+
import * as R from "remeda";
|
|
35
|
+
import {
|
|
36
|
+
CYCLE_ID_PATTERN,
|
|
37
|
+
type Current,
|
|
38
|
+
type Cycle,
|
|
39
|
+
type CycleId,
|
|
40
|
+
} from "./cycle.ts";
|
|
41
|
+
import { isRecord } from "./record.ts";
|
|
42
|
+
import { err, ok, type Result } from "./result.ts";
|
|
43
|
+
|
|
44
|
+
export const VERSIONS_DIR = "versions";
|
|
45
|
+
export const LOCK_FILE = "lock.json";
|
|
46
|
+
|
|
47
|
+
/** The stages a version is frozen at, in the order an attempt reaches them. */
|
|
48
|
+
export const VERSION_STAGES = ["submitted", "camera-ready", "arxiv"] as const;
|
|
49
|
+
export type VersionStage = (typeof VERSION_STAGES)[number];
|
|
50
|
+
export type VersionExt = "pdf" | "tex";
|
|
51
|
+
|
|
52
|
+
const isStage = (s: unknown): s is VersionStage =>
|
|
53
|
+
VERSION_STAGES.some((v) => v === s);
|
|
54
|
+
|
|
55
|
+
/** A frozen file in a cycle's folder: its stage and extension. */
|
|
56
|
+
export interface FrozenName {
|
|
57
|
+
readonly stage: VersionStage;
|
|
58
|
+
readonly ext: VersionExt;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** `submitted.pdf` → its stage and extension; null for a name outside the convention. Pure. */
|
|
62
|
+
export function frozenName(name: string): FrozenName | null {
|
|
63
|
+
const m = /^([a-z-]+)\.(pdf|tex)$/.exec(name);
|
|
64
|
+
const stage = m?.[1];
|
|
65
|
+
const ext = m?.[2];
|
|
66
|
+
return isStage(stage) && (ext === "pdf" || ext === "tex")
|
|
67
|
+
? { stage, ext }
|
|
68
|
+
: null;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** `<cycle>/<stage>.<ext>`: a frozen file's path under `versions/`, and its key in the lock. */
|
|
72
|
+
export const frozenKey = (
|
|
73
|
+
cycle: CycleId,
|
|
74
|
+
stage: VersionStage,
|
|
75
|
+
ext: VersionExt,
|
|
76
|
+
): string => `${cycle}/${stage}.${ext}`;
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* A frozen file in the layout before folders per attempt, `versions/<date>-<stage>.<ext>`: what
|
|
80
|
+
* `paperlint versions migrate` moves into its attempt's folder.
|
|
81
|
+
*/
|
|
82
|
+
export const DATED_NAME = /^(\d{4}-\d{2}-\d{2})-([a-z-]+)\.(pdf|tex)$/;
|
|
83
|
+
|
|
84
|
+
// ── the lock ────────────────────────────────────────────────────────────────────────────
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* One frozen file in the lock: its bytes and digest as frozen, and when (an instant for a file
|
|
88
|
+
* paperlint froze on send or recorded; the day of the stage for one migrated from `stages:`) — or a
|
|
89
|
+
* source recorded as LOST, which a frozen PDF of an earlier layout may have.
|
|
90
|
+
*/
|
|
91
|
+
export type LockEntry =
|
|
92
|
+
| {
|
|
93
|
+
readonly kind: "file";
|
|
94
|
+
readonly bytes: number;
|
|
95
|
+
readonly sha256: string;
|
|
96
|
+
readonly frozen: string;
|
|
97
|
+
}
|
|
98
|
+
| { readonly kind: "lost"; readonly frozen: string | null };
|
|
99
|
+
|
|
100
|
+
/** The lock: an entry per frozen file, by `<cycle>/<stage>.<ext>`. */
|
|
101
|
+
export type Lock = Readonly<Record<string, LockEntry>>;
|
|
102
|
+
|
|
103
|
+
export const LOCK_VERSION = 1;
|
|
104
|
+
|
|
105
|
+
/** What the lock says about itself, so nobody takes it for a file to edit. */
|
|
106
|
+
export const LOCK_NOTE =
|
|
107
|
+
"generated by paperlint — do not edit: `paperlint submission update --save` and `paperlint versions lock` write it";
|
|
108
|
+
|
|
109
|
+
const SHA256 = /^[0-9a-f]{64}$/;
|
|
110
|
+
const WHEN = /^\d{4}-\d{2}-\d{2}(T\d{2}:\d{2}(:\d{2}(\.\d+)?)?Z)?$/;
|
|
111
|
+
/** `<cycle>/<stage>.<ext>`, the cycle part by the same rule `parseCycles` holds an id to. */
|
|
112
|
+
const KEY = new RegExp(`^(${CYCLE_ID_PATTERN})/([a-z-]+\\.(?:pdf|tex))$`);
|
|
113
|
+
|
|
114
|
+
const isWhen = (v: unknown): v is string =>
|
|
115
|
+
typeof v === "string" && WHEN.test(v);
|
|
116
|
+
|
|
117
|
+
/** The cycle and the file name of a lock key; null for a key that is not `<cycle>/<stage>.<ext>`. */
|
|
118
|
+
export function keyParts(
|
|
119
|
+
key: string,
|
|
120
|
+
): { readonly cycle: string; readonly name: FrozenName } | null {
|
|
121
|
+
const m = KEY.exec(key);
|
|
122
|
+
const name = frozenName(m?.[2] ?? "");
|
|
123
|
+
return m?.[1] === undefined || name === null ? null : { cycle: m[1], name };
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
const FILE_KEYS: readonly string[] = ["bytes", "sha256", "frozen"];
|
|
127
|
+
const LOST_KEYS: readonly string[] = ["sourceLost", "frozen"];
|
|
128
|
+
|
|
129
|
+
const whenProblem = (at: string): string =>
|
|
130
|
+
`${at}: "frozen" must be a date or an instant`;
|
|
131
|
+
|
|
132
|
+
/** An entry recording a lost source: `{ "sourceLost": true, "frozen"? }`. */
|
|
133
|
+
function parseLost(
|
|
134
|
+
at: string,
|
|
135
|
+
ext: VersionExt,
|
|
136
|
+
frozen: unknown,
|
|
137
|
+
): Result<LockEntry, string> {
|
|
138
|
+
if (ext !== "tex")
|
|
139
|
+
return err(`${at}: only a source (.tex) can be recorded as lost`);
|
|
140
|
+
return frozen === undefined || isWhen(frozen)
|
|
141
|
+
? ok({ kind: "lost", frozen: frozen ?? null })
|
|
142
|
+
: err(whenProblem(at));
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/** An entry recording a frozen file: `{ "bytes", "sha256", "frozen" }`. */
|
|
146
|
+
function parseFile(
|
|
147
|
+
at: string,
|
|
148
|
+
v: Readonly<Record<string, unknown>>,
|
|
149
|
+
): Result<LockEntry, string> {
|
|
150
|
+
const { bytes, sha256, frozen } = v;
|
|
151
|
+
if (typeof bytes !== "number" || !Number.isInteger(bytes) || bytes < 0)
|
|
152
|
+
return err(`${at}: "bytes" must be a whole number`);
|
|
153
|
+
if (typeof sha256 !== "string" || !SHA256.test(sha256))
|
|
154
|
+
return err(`${at}: "sha256" must be 64 lowercase hex digits`);
|
|
155
|
+
return isWhen(frozen)
|
|
156
|
+
? ok({ kind: "file", bytes, sha256, frozen })
|
|
157
|
+
: err(whenProblem(at));
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
function parseEntry(key: string, v: unknown): Result<LockEntry, string> {
|
|
161
|
+
const at = `"files"["${key}"]`;
|
|
162
|
+
const parts = keyParts(key);
|
|
163
|
+
if (parts === null) return err(`${at}: a key is <cycle>/<stage>.pdf or .tex`);
|
|
164
|
+
if (!isRecord(v)) return err(`${at} must be an object`);
|
|
165
|
+
const lost = v["sourceLost"] === true;
|
|
166
|
+
const unknown = Object.keys(v).find(
|
|
167
|
+
(k) => !(lost ? LOST_KEYS : FILE_KEYS).includes(k),
|
|
168
|
+
);
|
|
169
|
+
if (unknown !== undefined) return err(`${at}: unknown key "${unknown}"`);
|
|
170
|
+
return lost ? parseLost(at, parts.name.ext, v["frozen"]) : parseFile(at, v);
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
/** `lock.json`, parsed → the lock, or one line naming what is wrong. Pure. */
|
|
174
|
+
export function parseLock(json: unknown): Result<Lock, string> {
|
|
175
|
+
if (!isRecord(json) || json["lockfileVersion"] !== LOCK_VERSION)
|
|
176
|
+
return err(
|
|
177
|
+
`not a lock this paperlint writes ("lockfileVersion": ${String(LOCK_VERSION)})`,
|
|
178
|
+
);
|
|
179
|
+
const files = json["files"];
|
|
180
|
+
if (!isRecord(files)) return err(`"files" must be an object`);
|
|
181
|
+
return Object.entries(files).reduce<Result<Lock, string>>((acc, [key, v]) => {
|
|
182
|
+
if (!acc.ok) return acc;
|
|
183
|
+
const e = parseEntry(key, v);
|
|
184
|
+
return e.ok ? ok({ ...acc.value, [key]: e.value }) : e;
|
|
185
|
+
}, ok({}));
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
const entryJson = (e: LockEntry): object =>
|
|
189
|
+
e.kind === "lost"
|
|
190
|
+
? { sourceLost: true, ...(e.frozen === null ? {} : { frozen: e.frozen }) }
|
|
191
|
+
: { bytes: e.bytes, sha256: e.sha256, frozen: e.frozen };
|
|
192
|
+
|
|
193
|
+
/** The lock as paperlint writes it: keys sorted, two spaces, a newline at the end. Pure. */
|
|
194
|
+
export const lockText = (lock: Lock): string =>
|
|
195
|
+
`${JSON.stringify(
|
|
196
|
+
{
|
|
197
|
+
lockfileVersion: LOCK_VERSION,
|
|
198
|
+
note: LOCK_NOTE,
|
|
199
|
+
files: Object.fromEntries(
|
|
200
|
+
R.sortBy(Object.entries(lock), ([k]) => k).map(([k, e]) => [
|
|
201
|
+
k,
|
|
202
|
+
entryJson(e),
|
|
203
|
+
]),
|
|
204
|
+
),
|
|
205
|
+
},
|
|
206
|
+
null,
|
|
207
|
+
2,
|
|
208
|
+
)}\n`;
|
|
209
|
+
|
|
210
|
+
/** What the disk says about a file: its size and digest. */
|
|
211
|
+
export interface FileFacts {
|
|
212
|
+
readonly bytes: number;
|
|
213
|
+
readonly sha256: string;
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
// ── the layout, judged ──────────────────────────────────────────────────────────────────
|
|
217
|
+
|
|
218
|
+
/** One entry of a folder: a file or a folder. */
|
|
219
|
+
export interface Entry {
|
|
220
|
+
readonly name: string;
|
|
221
|
+
readonly kind: "file" | "dir";
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
/** A finding, as the rule reports it: a message id and the data its template names. */
|
|
225
|
+
export interface VersionFinding {
|
|
226
|
+
readonly messageId: string;
|
|
227
|
+
readonly data: Readonly<Record<string, string>>;
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
/** What `versions/layout` judges: the paper's cycles, the folder's entries, and the lock. */
|
|
231
|
+
export interface LayoutInput {
|
|
232
|
+
readonly cycles: readonly Cycle[];
|
|
233
|
+
/** The entries directly in `versions/`. */
|
|
234
|
+
readonly root: readonly Entry[];
|
|
235
|
+
/** The entries of the folder `name` in `versions/`. */
|
|
236
|
+
readonly inFolder: (name: string) => readonly Entry[];
|
|
237
|
+
/** `lock.json`: null when there is none; else parsed, or why it does not parse. */
|
|
238
|
+
readonly lock: Result<Lock, string> | null;
|
|
239
|
+
/** The size and digest of a frozen file on disk, by its key. */
|
|
240
|
+
readonly factsOf: (key: string) => FileFacts;
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
/** A frozen file found on disk: its key, cycle, stage and extension. */
|
|
244
|
+
interface Found extends FrozenName {
|
|
245
|
+
readonly key: string;
|
|
246
|
+
readonly cycle: CycleId;
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
const STAGES_TEXT = VERSION_STAGES.join(", ");
|
|
250
|
+
|
|
251
|
+
const stray = (path: string, hint: string): VersionFinding => ({
|
|
252
|
+
messageId: "stray",
|
|
253
|
+
data: { path, hint },
|
|
254
|
+
});
|
|
255
|
+
|
|
256
|
+
const MIGRATE_HINT =
|
|
257
|
+
"a frozen version in the layout before folders per attempt: `npx paperlint versions migrate <paper>` moves it into versions/<cycle>/";
|
|
258
|
+
const LAYOUT_HINT = `versions/ holds lock.json and one folder per cycle, each with <stage>.pdf and <stage>.tex (${STAGES_TEXT}) — anything else goes to extra/`;
|
|
259
|
+
|
|
260
|
+
/** An entry directly in `versions/`: fine, a cycle's folder, or a finding. */
|
|
261
|
+
function rootFinding(
|
|
262
|
+
e: Entry,
|
|
263
|
+
ids: readonly string[],
|
|
264
|
+
): readonly VersionFinding[] {
|
|
265
|
+
if (e.name.startsWith(".")) return [];
|
|
266
|
+
if (e.kind === "dir")
|
|
267
|
+
return ids.includes(e.name)
|
|
268
|
+
? []
|
|
269
|
+
: [
|
|
270
|
+
{
|
|
271
|
+
messageId: "unknownCycle",
|
|
272
|
+
data: { folder: e.name, known: ids.join(" · ") || "(none)" },
|
|
273
|
+
},
|
|
274
|
+
];
|
|
275
|
+
if (e.name === LOCK_FILE) return [];
|
|
276
|
+
return [stray(e.name, DATED_NAME.test(e.name) ? MIGRATE_HINT : LAYOUT_HINT)];
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
/** The frozen files of the cycles' folders, and every entry there outside the convention. */
|
|
280
|
+
function folderContents(input: LayoutInput): {
|
|
281
|
+
readonly found: readonly Found[];
|
|
282
|
+
readonly strays: readonly VersionFinding[];
|
|
283
|
+
} {
|
|
284
|
+
// Each cycle's folder by the cycle's own id, so every key below is built from a parsed one.
|
|
285
|
+
const folders = input.cycles
|
|
286
|
+
.map((c) => c.id)
|
|
287
|
+
.filter((id) => input.root.some((e) => e.kind === "dir" && e.name === id));
|
|
288
|
+
const listed = folders.flatMap((id) =>
|
|
289
|
+
input.inFolder(id).map((e) => ({ cycle: id, e })),
|
|
290
|
+
);
|
|
291
|
+
const named = listed.map(({ cycle, e }) => ({
|
|
292
|
+
cycle,
|
|
293
|
+
e,
|
|
294
|
+
name: e.kind === "file" ? frozenName(e.name) : null,
|
|
295
|
+
}));
|
|
296
|
+
return {
|
|
297
|
+
found: named.flatMap(({ cycle, name }) =>
|
|
298
|
+
name === null
|
|
299
|
+
? []
|
|
300
|
+
: [{ ...name, cycle, key: frozenKey(cycle, name.stage, name.ext) }],
|
|
301
|
+
),
|
|
302
|
+
strays: named
|
|
303
|
+
.filter(({ e, name }) => name === null && !e.name.startsWith("."))
|
|
304
|
+
.map(({ cycle, e }) => stray(`${cycle}/${e.name}`, LAYOUT_HINT)),
|
|
305
|
+
};
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
const isLost = (lock: Lock, key: string): boolean => lock[key]?.kind === "lost";
|
|
309
|
+
|
|
310
|
+
/** Each frozen PDF without its source beside it, unless the lock records the source as lost. */
|
|
311
|
+
function missingSources(
|
|
312
|
+
found: readonly Found[],
|
|
313
|
+
lock: Lock,
|
|
314
|
+
): readonly VersionFinding[] {
|
|
315
|
+
const keys = found.map((f) => f.key);
|
|
316
|
+
return found
|
|
317
|
+
.filter((f) => f.ext === "pdf")
|
|
318
|
+
.map((f) => ({ f, tex: frozenKey(f.cycle, f.stage, "tex") }))
|
|
319
|
+
.filter(({ tex }) => !keys.includes(tex) && !isLost(lock, tex))
|
|
320
|
+
.map(({ f, tex }) => ({
|
|
321
|
+
messageId: "noSource",
|
|
322
|
+
data: { pdf: f.key, tex },
|
|
323
|
+
}));
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
/** A version sent from a cycle whose source is still declared to be in the previous venue's template. */
|
|
327
|
+
const portingSends = (
|
|
328
|
+
found: readonly Found[],
|
|
329
|
+
cycles: readonly Cycle[],
|
|
330
|
+
): readonly VersionFinding[] =>
|
|
331
|
+
found
|
|
332
|
+
.filter(
|
|
333
|
+
(f) =>
|
|
334
|
+
f.ext === "pdf" &&
|
|
335
|
+
(f.stage === "submitted" || f.stage === "camera-ready") &&
|
|
336
|
+
cycles.find((c) => c.id === f.cycle)?.phase === "porting",
|
|
337
|
+
)
|
|
338
|
+
.map((f) => ({ messageId: "porting", data: { file: f.key, id: f.cycle } }));
|
|
339
|
+
|
|
340
|
+
/** Each frozen file against its lock entry, and each lock entry against the disk. */
|
|
341
|
+
function lockFindings(
|
|
342
|
+
found: readonly Found[],
|
|
343
|
+
lock: Lock,
|
|
344
|
+
factsOf: (key: string) => FileFacts,
|
|
345
|
+
): readonly VersionFinding[] {
|
|
346
|
+
const keys = found.map((f) => f.key);
|
|
347
|
+
const onDisk = found.flatMap((f): readonly VersionFinding[] => {
|
|
348
|
+
const e = lock[f.key];
|
|
349
|
+
if (e === undefined || e.kind === "lost")
|
|
350
|
+
return [{ messageId: "notLocked", data: { file: f.key } }];
|
|
351
|
+
const got = factsOf(f.key);
|
|
352
|
+
return got.bytes === e.bytes && got.sha256 === e.sha256
|
|
353
|
+
? []
|
|
354
|
+
: [
|
|
355
|
+
{
|
|
356
|
+
messageId: "changed",
|
|
357
|
+
data: {
|
|
358
|
+
file: f.key,
|
|
359
|
+
want: String(e.bytes),
|
|
360
|
+
got: String(got.bytes),
|
|
361
|
+
frozen: e.frozen,
|
|
362
|
+
},
|
|
363
|
+
},
|
|
364
|
+
];
|
|
365
|
+
});
|
|
366
|
+
const gone = Object.entries(lock)
|
|
367
|
+
.filter(([k, e]) => e.kind === "file" && !keys.includes(k))
|
|
368
|
+
.map(([k]) => ({ messageId: "lockedGone", data: { file: k } }));
|
|
369
|
+
return [...onDisk, ...gone];
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
/** The lock's findings: none needed, none there, unreadable, or each file against it. */
|
|
373
|
+
function lockOf(
|
|
374
|
+
input: LayoutInput,
|
|
375
|
+
found: readonly Found[],
|
|
376
|
+
): readonly VersionFinding[] {
|
|
377
|
+
if (input.lock === null)
|
|
378
|
+
return found.length === 0 ? [] : [{ messageId: "noLock", data: {} }];
|
|
379
|
+
if (!input.lock.ok)
|
|
380
|
+
return [{ messageId: "badLock", data: { why: input.lock.error } }];
|
|
381
|
+
return lockFindings(found, input.lock.value, input.factsOf);
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
/**
|
|
385
|
+
* `versions/layout`: every entry of `versions/` against the convention — a folder per cycle, a file
|
|
386
|
+
* per stage — the porting declaration, and every frozen file against the lock. Pure.
|
|
387
|
+
*/
|
|
388
|
+
export function judgeVersions(input: LayoutInput): readonly VersionFinding[] {
|
|
389
|
+
const ids = input.cycles.map((c) => c.id);
|
|
390
|
+
const { found, strays } = folderContents(input);
|
|
391
|
+
const lock = input.lock?.ok === true ? input.lock.value : {};
|
|
392
|
+
return [
|
|
393
|
+
...input.root.flatMap((e) => rootFinding(e, ids)),
|
|
394
|
+
...strays,
|
|
395
|
+
...missingSources(found, lock),
|
|
396
|
+
...portingSends(found, input.cycles),
|
|
397
|
+
...lockOf(input, found),
|
|
398
|
+
];
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
/**
|
|
402
|
+
* `cycle/submitted-frozen`: each cycle that has a submission on the portal and no
|
|
403
|
+
* `versions/<id>/submitted.pdf`. Pure over whether a file exists, by its key.
|
|
404
|
+
*/
|
|
405
|
+
export const unfrozenSubmissions = (
|
|
406
|
+
cycles: readonly Cycle[],
|
|
407
|
+
has: (key: string) => boolean,
|
|
408
|
+
): readonly VersionFinding[] =>
|
|
409
|
+
cycles
|
|
410
|
+
.filter(
|
|
411
|
+
(c) => c.submission !== null && !has(frozenKey(c.id, "submitted", "pdf")),
|
|
412
|
+
)
|
|
413
|
+
.map((c) => ({
|
|
414
|
+
messageId: "unfrozen",
|
|
415
|
+
data: {
|
|
416
|
+
id: c.id,
|
|
417
|
+
submission: String(c.submission?.id),
|
|
418
|
+
pdf: frozenKey(c.id, "submitted", "pdf"),
|
|
419
|
+
},
|
|
420
|
+
}));
|
|
421
|
+
|
|
422
|
+
// ── the draft against a frozen source ─────────────────────────────────────────────────
|
|
423
|
+
|
|
424
|
+
/** How far one text has moved from another, in lines: added and removed. */
|
|
425
|
+
export interface Drift {
|
|
426
|
+
readonly added: number;
|
|
427
|
+
readonly removed: number;
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
/** The lines `draft` adds to `frozen` and removes from it (a line diff, as `git diff --numstat`). Pure. */
|
|
431
|
+
export function lineDrift(frozen: string, draft: string): Drift {
|
|
432
|
+
const parts = diffLines(frozen, draft);
|
|
433
|
+
return {
|
|
434
|
+
added: R.sumBy(parts, (p) => (p.added ? p.count : 0)),
|
|
435
|
+
removed: R.sumBy(parts, (p) => (p.removed ? p.count : 0)),
|
|
436
|
+
};
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
/** How much of an accepted source a later version keeps: its lines left unchanged, of all it had. */
|
|
440
|
+
export interface Lineage {
|
|
441
|
+
readonly kept: number;
|
|
442
|
+
readonly total: number;
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
/** The lines of `accepted` that `later` keeps unchanged, counted with the same line diff as the drift. Pure. */
|
|
446
|
+
export function lineage(accepted: string, later: string): Lineage {
|
|
447
|
+
const parts = diffLines(accepted, later);
|
|
448
|
+
const kept = R.sumBy(parts, (p) => (p.added || p.removed ? 0 : p.count));
|
|
449
|
+
const removed = R.sumBy(parts, (p) => (p.removed ? p.count : 0));
|
|
450
|
+
return { kept, total: kept + removed };
|
|
451
|
+
}
|
|
452
|
+
|
|
453
|
+
/**
|
|
454
|
+
* Whether a camera-ready started from the accepted source: it keeps at least half of its lines. A
|
|
455
|
+
* camera-ready is what was accepted plus the changes the reviewers asked for, so it differs — any
|
|
456
|
+
* difference cannot be the test. One that keeps less than half was built from something else.
|
|
457
|
+
*/
|
|
458
|
+
export const derivesFrom = (l: Lineage): boolean => l.kept * 2 >= l.total;
|
|
459
|
+
|
|
460
|
+
export const driftText = (d: Drift): string =>
|
|
461
|
+
`${String(d.added)} line${d.added === 1 ? "" : "s"} added, ${String(d.removed)} removed`;
|
|
462
|
+
|
|
463
|
+
/** The venue of a cycle, as a person names it: the preset's spec, or the named venue. */
|
|
464
|
+
/** A cycle's venue as a person names it: a named venue's name, a preset's name without its package or path. */
|
|
465
|
+
export const venueName = (c: Cycle): string =>
|
|
466
|
+
c.venue.kind === "preset"
|
|
467
|
+
? c.venue.extends
|
|
468
|
+
.replace(/^paperlint:/, "")
|
|
469
|
+
.replace(/^.*\//, "")
|
|
470
|
+
.replace(/\.jsonc?$/, "")
|
|
471
|
+
: c.venue.name;
|
|
472
|
+
|
|
473
|
+
/**
|
|
474
|
+
* The frozen source the draft is measured against: the open cycle's `submitted.tex`, or — once the
|
|
475
|
+
* last cycle is accepted — its newest (`camera-ready.tex`, else `submitted.tex`); null between venues
|
|
476
|
+
* and before the first attempt. Pure over whether a file exists.
|
|
477
|
+
*/
|
|
478
|
+
export function driftBase(
|
|
479
|
+
current: Current,
|
|
480
|
+
has: (key: string) => boolean,
|
|
481
|
+
): { readonly cycle: Cycle; readonly key: string } | null {
|
|
482
|
+
switch (current.kind) {
|
|
483
|
+
case "cycle":
|
|
484
|
+
return {
|
|
485
|
+
cycle: current.cycle,
|
|
486
|
+
key: frozenKey(current.cycle.id, "submitted", "tex"),
|
|
487
|
+
};
|
|
488
|
+
case "accepted": {
|
|
489
|
+
const camera = frozenKey(current.cycle.id, "camera-ready", "tex");
|
|
490
|
+
return {
|
|
491
|
+
cycle: current.cycle,
|
|
492
|
+
key: has(camera)
|
|
493
|
+
? camera
|
|
494
|
+
: frozenKey(current.cycle.id, "submitted", "tex"),
|
|
495
|
+
};
|
|
496
|
+
}
|
|
497
|
+
case "parked":
|
|
498
|
+
case "none":
|
|
499
|
+
return null;
|
|
500
|
+
}
|
|
501
|
+
}
|
|
502
|
+
|
|
503
|
+
// ── each version's state ──────────────────────────────────────────────────────────────
|
|
504
|
+
|
|
505
|
+
/** One row of the version → state table. */
|
|
506
|
+
export interface VersionRow {
|
|
507
|
+
readonly version: string;
|
|
508
|
+
readonly stage: string;
|
|
509
|
+
readonly state: string;
|
|
510
|
+
readonly detail: string;
|
|
511
|
+
}
|
|
512
|
+
|
|
513
|
+
/** What a frozen stage of a cycle IS, from the cycle's outcome. Pure. */
|
|
514
|
+
export function stateOf(c: Cycle, stage: VersionStage): string {
|
|
515
|
+
if (stage === "arxiv") return "arXiv copy";
|
|
516
|
+
switch (c.outcome.kind) {
|
|
517
|
+
case "open":
|
|
518
|
+
return stage === "submitted"
|
|
519
|
+
? `CURRENT (under review at ${venueName(c)})`
|
|
520
|
+
: "camera-ready, cycle still open";
|
|
521
|
+
case "accepted":
|
|
522
|
+
return stage === "submitted" ? "camera-ready base" : "final";
|
|
523
|
+
case "rejected":
|
|
524
|
+
case "withdrawn":
|
|
525
|
+
return `historical (${c.outcome.kind})`;
|
|
526
|
+
}
|
|
527
|
+
}
|
|
528
|
+
|
|
529
|
+
/** `2026-10-23T16:58:01.000Z` → `2026-10-23 16:58 UTC`; a day stays a day. */
|
|
530
|
+
const whenText = (frozen: string): string =>
|
|
531
|
+
frozen.length === 10
|
|
532
|
+
? frozen
|
|
533
|
+
: `${frozen.slice(0, 10)} ${frozen.slice(11, 16)} UTC`;
|
|
534
|
+
|
|
535
|
+
/** A PDF's lock entry as the table shows it (only a source can be recorded lost). */
|
|
536
|
+
const entryDetail = (e: LockEntry | undefined): string =>
|
|
537
|
+
e === undefined || e.kind === "lost"
|
|
538
|
+
? "not locked"
|
|
539
|
+
: `${String(e.bytes)} B frozen ${whenText(e.frozen)}`;
|
|
540
|
+
|
|
541
|
+
/** The stages a cycle has frozen: a PDF, a source, or a source the lock records as lost. */
|
|
542
|
+
const frozenStages = (
|
|
543
|
+
c: Cycle,
|
|
544
|
+
has: (key: string) => boolean,
|
|
545
|
+
lock: Lock,
|
|
546
|
+
): readonly VersionStage[] =>
|
|
547
|
+
VERSION_STAGES.filter(
|
|
548
|
+
(s) =>
|
|
549
|
+
has(frozenKey(c.id, s, "pdf")) ||
|
|
550
|
+
has(frozenKey(c.id, s, "tex")) ||
|
|
551
|
+
isLost(lock, frozenKey(c.id, s, "tex")),
|
|
552
|
+
);
|
|
553
|
+
|
|
554
|
+
/** The row of a cycle that froze nothing: what it is, from its outcome. */
|
|
555
|
+
const emptyRow = (c: Cycle): VersionRow => ({
|
|
556
|
+
version: c.id,
|
|
557
|
+
stage: "—",
|
|
558
|
+
state:
|
|
559
|
+
c.outcome.kind === "open"
|
|
560
|
+
? "open, nothing sent yet"
|
|
561
|
+
: `${c.outcome.kind}, nothing frozen`,
|
|
562
|
+
detail: "",
|
|
563
|
+
});
|
|
564
|
+
|
|
565
|
+
/** A cycle's rows: one per stage frozen in its folder, or one saying what is missing or that nothing is. */
|
|
566
|
+
function cycleRows(
|
|
567
|
+
c: Cycle,
|
|
568
|
+
has: (key: string) => boolean,
|
|
569
|
+
lock: Lock,
|
|
570
|
+
): readonly VersionRow[] {
|
|
571
|
+
const stages = frozenStages(c, has, lock);
|
|
572
|
+
const unfrozen =
|
|
573
|
+
c.submission !== null && !stages.includes("submitted")
|
|
574
|
+
? [
|
|
575
|
+
{
|
|
576
|
+
version: c.id,
|
|
577
|
+
stage: "submitted",
|
|
578
|
+
state: `NOT FROZEN — submission ${String(c.submission.id)} is on the portal`,
|
|
579
|
+
detail: "",
|
|
580
|
+
},
|
|
581
|
+
]
|
|
582
|
+
: [];
|
|
583
|
+
const frozen = stages.map((s) => ({
|
|
584
|
+
version: c.id,
|
|
585
|
+
stage: s,
|
|
586
|
+
state: stateOf(c, s),
|
|
587
|
+
detail: [
|
|
588
|
+
entryDetail(lock[frozenKey(c.id, s, "pdf")]),
|
|
589
|
+
...(isLost(lock, frozenKey(c.id, s, "tex")) ? ["source lost"] : []),
|
|
590
|
+
].join(", "),
|
|
591
|
+
}));
|
|
592
|
+
const rows = [...unfrozen, ...frozen];
|
|
593
|
+
return rows.length > 0 ? rows : [emptyRow(c)];
|
|
594
|
+
}
|
|
595
|
+
|
|
596
|
+
/** What the draft row says about its drift from the base. */
|
|
597
|
+
export type DraftDrift =
|
|
598
|
+
| { readonly kind: "none" }
|
|
599
|
+
| { readonly kind: "no-base"; readonly key: string }
|
|
600
|
+
| { readonly kind: "same"; readonly key: string }
|
|
601
|
+
| { readonly kind: "drift"; readonly key: string; readonly drift: Drift };
|
|
602
|
+
|
|
603
|
+
const draftDetail = (d: DraftDrift): string => {
|
|
604
|
+
switch (d.kind) {
|
|
605
|
+
case "none":
|
|
606
|
+
return "no attempt to compare with";
|
|
607
|
+
case "no-base":
|
|
608
|
+
return `nothing frozen at ${d.key} to compare with`;
|
|
609
|
+
case "same":
|
|
610
|
+
return `same as ${d.key}`;
|
|
611
|
+
case "drift":
|
|
612
|
+
return `differs from ${d.key}: ${driftText(d.drift)}`;
|
|
613
|
+
}
|
|
614
|
+
};
|
|
615
|
+
|
|
616
|
+
/** The table: each cycle, newest first, then the draft. Pure. */
|
|
617
|
+
export function versionRows(input: {
|
|
618
|
+
readonly cycles: readonly Cycle[];
|
|
619
|
+
readonly has: (key: string) => boolean;
|
|
620
|
+
readonly lock: Lock;
|
|
621
|
+
readonly draft: DraftDrift;
|
|
622
|
+
}): readonly VersionRow[] {
|
|
623
|
+
return [
|
|
624
|
+
...[...input.cycles]
|
|
625
|
+
.reverse()
|
|
626
|
+
.flatMap((c) => cycleRows(c, input.has, input.lock)),
|
|
627
|
+
{
|
|
628
|
+
version: "paper.tex",
|
|
629
|
+
stage: "draft",
|
|
630
|
+
state: "target not chosen",
|
|
631
|
+
detail: draftDetail(input.draft),
|
|
632
|
+
},
|
|
633
|
+
];
|
|
634
|
+
}
|
|
635
|
+
|
|
636
|
+
/** The rows as aligned columns, two spaces in front. Pure. */
|
|
637
|
+
export function versionTable(rows: readonly VersionRow[]): readonly string[] {
|
|
638
|
+
const width = (pick: (r: VersionRow) => string): number =>
|
|
639
|
+
Math.max(...rows.map((r) => pick(r).length));
|
|
640
|
+
const w = [
|
|
641
|
+
width((r) => r.version),
|
|
642
|
+
width((r) => r.stage),
|
|
643
|
+
width((r) => r.state),
|
|
644
|
+
] as const;
|
|
645
|
+
return rows.map((r) =>
|
|
646
|
+
` ${r.version.padEnd(w[0])} ${r.stage.padEnd(w[1])} ${r.state.padEnd(w[2])} ${r.detail}`.trimEnd(),
|
|
647
|
+
);
|
|
648
|
+
}
|
|
649
|
+
|
|
650
|
+
// ── recording what is on disk ─────────────────────────────────────────────────────────
|
|
651
|
+
|
|
652
|
+
/** A frozen file found on disk: its key and what it holds. */
|
|
653
|
+
export interface OnDisk {
|
|
654
|
+
readonly key: string;
|
|
655
|
+
readonly facts: FileFacts;
|
|
656
|
+
}
|
|
657
|
+
|
|
658
|
+
/** What one found file does to the lock: kept as recorded, recorded anew, or recorded for the first time. */
|
|
659
|
+
function relocked(
|
|
660
|
+
f: OnDisk,
|
|
661
|
+
old: LockEntry | undefined,
|
|
662
|
+
now: string,
|
|
663
|
+
): { readonly entry: LockEntry; readonly line: string | null } {
|
|
664
|
+
const fresh: LockEntry = { kind: "file", ...f.facts, frozen: now };
|
|
665
|
+
if (old?.kind !== "file")
|
|
666
|
+
return {
|
|
667
|
+
entry: fresh,
|
|
668
|
+
line: `locked versions/${f.key} (${String(f.facts.bytes)} bytes)`,
|
|
669
|
+
};
|
|
670
|
+
return old.sha256 === f.facts.sha256 && old.bytes === f.facts.bytes
|
|
671
|
+
? { entry: old, line: null }
|
|
672
|
+
: {
|
|
673
|
+
entry: fresh,
|
|
674
|
+
line: `re-locked versions/${f.key}: ${String(old.bytes)} → ${String(f.facts.bytes)} bytes — it changed after ${old.frozen}`,
|
|
675
|
+
};
|
|
676
|
+
}
|
|
677
|
+
|
|
678
|
+
/**
|
|
679
|
+
* `paperlint versions lock`: the lock for the files on disk — each kept as recorded, recorded when it
|
|
680
|
+
* is new or changed, an entry whose file is gone dropped, a lost source kept while no file replaces
|
|
681
|
+
* it — and a line per change. Pure.
|
|
682
|
+
*/
|
|
683
|
+
export function relock(
|
|
684
|
+
found: readonly OnDisk[],
|
|
685
|
+
old: Lock,
|
|
686
|
+
now: string,
|
|
687
|
+
): { readonly lock: Lock; readonly lines: readonly string[] } {
|
|
688
|
+
const keys = found.map((f) => f.key);
|
|
689
|
+
const each = found.map((f) => ({
|
|
690
|
+
key: f.key,
|
|
691
|
+
...relocked(f, old[f.key], now),
|
|
692
|
+
}));
|
|
693
|
+
const gone = Object.entries(old).filter(([k]) => !keys.includes(k));
|
|
694
|
+
return {
|
|
695
|
+
lock: {
|
|
696
|
+
...Object.fromEntries(gone.filter(([, e]) => e.kind === "lost")),
|
|
697
|
+
...Object.fromEntries(each.map((e) => [e.key, e.entry])),
|
|
698
|
+
},
|
|
699
|
+
lines: [
|
|
700
|
+
...each.flatMap((e) => (e.line === null ? [] : [e.line])),
|
|
701
|
+
...gone
|
|
702
|
+
.filter(([, e]) => e.kind === "file")
|
|
703
|
+
.map(([k]) => `dropped versions/${k}: the file is gone`),
|
|
704
|
+
],
|
|
705
|
+
};
|
|
706
|
+
}
|