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,360 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `paperlint versions [show|lock|migrate] [paper]` — a paper's frozen versions, one folder per
|
|
3
|
+
* attempt (src/domain/versions.ts):
|
|
4
|
+
*
|
|
5
|
+
* show (the default) each attempt's frozen versions and what they are, newest first, then the
|
|
6
|
+
* draft and how far it has moved from the version it is measured against
|
|
7
|
+
* lock record the files of versions/ in versions/lock.json: what `submission --save` did not
|
|
8
|
+
* freeze — a file put there by hand, an arXiv copy, a recovered upload
|
|
9
|
+
* migrate from the `stages:` list and dated files to folders per attempt, once
|
|
10
|
+
*
|
|
11
|
+
* ── WHY ONE NOUN, THREE VERBS ───────────────────────────────────────────────────────────────
|
|
12
|
+
* docs/prior-art/multi-mode-tools.md: tools that stay small add verbs to a noun the project already
|
|
13
|
+
* has, not nouns. The table is also printed by `paperlint submission show`, beside the portal's copy;
|
|
14
|
+
* `versions` prints it without a token or a network call. `lock` is the `npm install` slot — make the
|
|
15
|
+
* generated file match the disk — and `migrate` runs once per paper.
|
|
16
|
+
*/
|
|
17
|
+
import { join, relative, resolve } from "node:path";
|
|
18
|
+
import * as R from "remeda";
|
|
19
|
+
import { callerPath } from "./caller-path.ts";
|
|
20
|
+
import type { Cycles } from "./domain/cycle.ts";
|
|
21
|
+
import { fieldOf, isRecord } from "./domain/record.ts";
|
|
22
|
+
import { err, ok, type Result } from "./domain/result.ts";
|
|
23
|
+
import { sha256Hex } from "./domain/sha256.ts";
|
|
24
|
+
import {
|
|
25
|
+
frontmatterOf,
|
|
26
|
+
planMigration,
|
|
27
|
+
rehearse,
|
|
28
|
+
withoutStages,
|
|
29
|
+
type MigrationPlan,
|
|
30
|
+
} from "./domain/versions-migration.ts";
|
|
31
|
+
import {
|
|
32
|
+
frozenKey,
|
|
33
|
+
frozenName,
|
|
34
|
+
LOCK_FILE,
|
|
35
|
+
lockText,
|
|
36
|
+
relock,
|
|
37
|
+
type Lock,
|
|
38
|
+
type OnDisk,
|
|
39
|
+
} from "./domain/versions.ts";
|
|
40
|
+
import type { CopyFile, MoveFile } from "./ports/move-file.ts";
|
|
41
|
+
import type { ParseYaml } from "./ports/yaml.ts";
|
|
42
|
+
import type { ProseDeps } from "./tex-paper.ts";
|
|
43
|
+
import {
|
|
44
|
+
cyclesIn,
|
|
45
|
+
entriesIn,
|
|
46
|
+
factsOfFrozen,
|
|
47
|
+
readLock,
|
|
48
|
+
versionLines,
|
|
49
|
+
versionsDir,
|
|
50
|
+
type VersionsDeps,
|
|
51
|
+
} from "./versions.ts";
|
|
52
|
+
|
|
53
|
+
const SUBS = ["show", "lock", "migrate"] as const;
|
|
54
|
+
type Sub = (typeof SUBS)[number];
|
|
55
|
+
const isSub = (s: unknown): s is Sub => SUBS.some((x) => x === s);
|
|
56
|
+
|
|
57
|
+
const STATUS_FILE = "PIPELINE-STATUS.md";
|
|
58
|
+
|
|
59
|
+
export interface VersionsCommandDeps extends ProseDeps, VersionsDeps {
|
|
60
|
+
readonly move: MoveFile;
|
|
61
|
+
readonly copy: CopyFile;
|
|
62
|
+
readonly yaml: ParseYaml;
|
|
63
|
+
/** The instant a lock entry records, ISO 8601. */
|
|
64
|
+
readonly now: () => string;
|
|
65
|
+
readonly cwd: string;
|
|
66
|
+
readonly log: (line: string) => void;
|
|
67
|
+
readonly err: (line: string) => void;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/** The words after `versions` → the subcommand, the paper folder, and what is left over. Pure. */
|
|
71
|
+
export function versionsArgs(words: readonly string[]): {
|
|
72
|
+
readonly sub: Sub;
|
|
73
|
+
readonly paper: string;
|
|
74
|
+
readonly extra: readonly string[];
|
|
75
|
+
} {
|
|
76
|
+
const [first, ...rest] = words;
|
|
77
|
+
const named = isSub(first);
|
|
78
|
+
const after = named ? rest : words;
|
|
79
|
+
return {
|
|
80
|
+
sub: named ? first : "show",
|
|
81
|
+
paper: after[0] ?? ".",
|
|
82
|
+
extra: after.slice(1),
|
|
83
|
+
};
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/** The paper as a command line names it, from where the command runs. */
|
|
87
|
+
const shown = (cwd: string, p: string): string => relative(cwd, p) || ".";
|
|
88
|
+
|
|
89
|
+
const logAll = (lines: readonly string[], deps: VersionsCommandDeps): void => {
|
|
90
|
+
lines.forEach((l) => {
|
|
91
|
+
deps.log(l);
|
|
92
|
+
});
|
|
93
|
+
};
|
|
94
|
+
|
|
95
|
+
// ── lock ────────────────────────────────────────────────────────────────────────────────
|
|
96
|
+
|
|
97
|
+
/** Every frozen file in the cycles' folders, with what it holds. */
|
|
98
|
+
function onDisk(
|
|
99
|
+
paperDir: string,
|
|
100
|
+
cycles: Cycles,
|
|
101
|
+
deps: VersionsCommandDeps,
|
|
102
|
+
): readonly OnDisk[] {
|
|
103
|
+
const root = versionsDir(paperDir);
|
|
104
|
+
const facts = factsOfFrozen(paperDir, deps.files);
|
|
105
|
+
return cycles.list.flatMap((c) =>
|
|
106
|
+
entriesIn(join(root, c.id), deps).flatMap((e) => {
|
|
107
|
+
const n = e.kind === "file" ? frozenName(e.name) : null;
|
|
108
|
+
return n === null
|
|
109
|
+
? []
|
|
110
|
+
: [
|
|
111
|
+
{
|
|
112
|
+
key: frozenKey(c.id, n.stage, n.ext),
|
|
113
|
+
facts: facts(frozenKey(c.id, n.stage, n.ext)),
|
|
114
|
+
},
|
|
115
|
+
];
|
|
116
|
+
}),
|
|
117
|
+
);
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
const writeLock = (
|
|
121
|
+
paperDir: string,
|
|
122
|
+
lock: Lock,
|
|
123
|
+
deps: VersionsCommandDeps,
|
|
124
|
+
): void => {
|
|
125
|
+
deps.files.writeAtomic(
|
|
126
|
+
callerPath(join(versionsDir(paperDir), LOCK_FILE)),
|
|
127
|
+
new TextEncoder().encode(lockText(lock)),
|
|
128
|
+
);
|
|
129
|
+
};
|
|
130
|
+
|
|
131
|
+
function runLock(
|
|
132
|
+
paperDir: string,
|
|
133
|
+
cycles: Cycles,
|
|
134
|
+
deps: VersionsCommandDeps,
|
|
135
|
+
): number {
|
|
136
|
+
const found = onDisk(paperDir, cycles, deps);
|
|
137
|
+
const old = readLock(paperDir, deps.files);
|
|
138
|
+
if (found.length === 0 && old === null)
|
|
139
|
+
return (deps.log("nothing frozen in versions/ — no lock to write"), 0);
|
|
140
|
+
const r = relock(found, old?.ok === true ? old.value : {}, deps.now());
|
|
141
|
+
writeLock(paperDir, r.lock, deps);
|
|
142
|
+
logAll(
|
|
143
|
+
[
|
|
144
|
+
...(old?.ok === false
|
|
145
|
+
? [
|
|
146
|
+
`versions/lock.json did not read (${old.error}): written anew from the files`,
|
|
147
|
+
]
|
|
148
|
+
: []),
|
|
149
|
+
...r.lines,
|
|
150
|
+
],
|
|
151
|
+
deps,
|
|
152
|
+
);
|
|
153
|
+
if (old?.ok !== false && r.lines.length === 0)
|
|
154
|
+
deps.log("versions/lock.json records every frozen file as it stands");
|
|
155
|
+
return 0;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
// ── migrate ─────────────────────────────────────────────────────────────────────────────
|
|
159
|
+
|
|
160
|
+
/** What the scorecard holds for the migration: its `stages:` list, and its text without it (null: none to take out). */
|
|
161
|
+
export interface Scorecard {
|
|
162
|
+
readonly stages: readonly unknown[];
|
|
163
|
+
readonly without: string | null;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
/** The scorecard's text, "" when there is none. */
|
|
167
|
+
const statusText = (paperDir: string, deps: VersionsCommandDeps): string =>
|
|
168
|
+
new TextDecoder().decode(
|
|
169
|
+
deps.files.readBytes(callerPath(join(paperDir, STATUS_FILE))) ??
|
|
170
|
+
new Uint8Array(),
|
|
171
|
+
);
|
|
172
|
+
|
|
173
|
+
/** Whether the scorecard without its list says everything else it said: parsed, both, and compared. */
|
|
174
|
+
function keepsTheRest(
|
|
175
|
+
before: Readonly<Record<string, unknown>>,
|
|
176
|
+
without: string,
|
|
177
|
+
yaml: ParseYaml,
|
|
178
|
+
): boolean {
|
|
179
|
+
const after = yaml(frontmatterOf(without) ?? "");
|
|
180
|
+
return (
|
|
181
|
+
after.ok && R.isDeepEqual(R.omit(before, ["stages"]), after.value ?? {})
|
|
182
|
+
);
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
/** The scorecard read and its list taken out — checked by parsing both, so a cut that misreads is refused. */
|
|
186
|
+
function scorecard(
|
|
187
|
+
paperDir: string,
|
|
188
|
+
deps: VersionsCommandDeps,
|
|
189
|
+
): Result<Scorecard, string> {
|
|
190
|
+
const text = statusText(paperDir, deps);
|
|
191
|
+
const before = deps.yaml(frontmatterOf(text) ?? "");
|
|
192
|
+
if (!before.ok)
|
|
193
|
+
return err(
|
|
194
|
+
`${STATUS_FILE}: the frontmatter does not parse (${before.error}) — fix it, then migrate`,
|
|
195
|
+
);
|
|
196
|
+
// A frontmatter that is no mapping (none, or a bare value) has no `stages:` key to take out.
|
|
197
|
+
const fields = isRecord(before.value) ? before.value : {};
|
|
198
|
+
const list = fieldOf(fields, "stages");
|
|
199
|
+
const stages: readonly unknown[] = Array.isArray(list) ? list : [];
|
|
200
|
+
const without = withoutStages(text);
|
|
201
|
+
return without === null || keepsTheRest(fields, without, deps.yaml)
|
|
202
|
+
? ok({ stages, without })
|
|
203
|
+
: err(
|
|
204
|
+
`${STATUS_FILE}: taking the stages: list out would change the rest of its frontmatter — take it out by hand, then migrate`,
|
|
205
|
+
);
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
/** A file of the paper, by its path relative to the paper: its size and digest, or null. */
|
|
209
|
+
const paperFacts =
|
|
210
|
+
(paperDir: string, deps: VersionsCommandDeps) => (rel: string) => {
|
|
211
|
+
const b = deps.files.readBytes(callerPath(resolve(paperDir, rel)));
|
|
212
|
+
return b === null ? null : { bytes: b.length, sha256: sha256Hex(b) };
|
|
213
|
+
};
|
|
214
|
+
|
|
215
|
+
/** Each copy, then each move: a shared source is copied before the move takes it away. */
|
|
216
|
+
function carry(
|
|
217
|
+
paperDir: string,
|
|
218
|
+
plan: MigrationPlan,
|
|
219
|
+
deps: VersionsCommandDeps,
|
|
220
|
+
): void {
|
|
221
|
+
const at = (rel: string) => callerPath(resolve(paperDir, rel));
|
|
222
|
+
plan.copies.forEach((m) => {
|
|
223
|
+
deps.copy(at(m.from), at(m.to));
|
|
224
|
+
deps.log(`copied ${m.from} → ${m.to}`);
|
|
225
|
+
});
|
|
226
|
+
plan.moves.forEach((m) => {
|
|
227
|
+
deps.move(at(m.from), at(m.to));
|
|
228
|
+
deps.log(`moved ${m.from} → ${m.to}`);
|
|
229
|
+
});
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
/** The plan carried out: the files, the lock, the scorecard without its list. */
|
|
233
|
+
function migrate(
|
|
234
|
+
paperDir: string,
|
|
235
|
+
plan: MigrationPlan,
|
|
236
|
+
card: Scorecard,
|
|
237
|
+
deps: VersionsCommandDeps,
|
|
238
|
+
): number {
|
|
239
|
+
carry(paperDir, plan, deps);
|
|
240
|
+
const entries = Object.keys(plan.lock).length;
|
|
241
|
+
if (entries > 0) {
|
|
242
|
+
writeLock(paperDir, plan.lock, deps);
|
|
243
|
+
deps.log(`recorded versions/lock.json, ${String(entries)} entries`);
|
|
244
|
+
}
|
|
245
|
+
if (card.without !== null) {
|
|
246
|
+
deps.files.writeAtomic(
|
|
247
|
+
callerPath(join(paperDir, STATUS_FILE)),
|
|
248
|
+
new TextEncoder().encode(card.without),
|
|
249
|
+
);
|
|
250
|
+
deps.log(
|
|
251
|
+
`removed the stages: list from ${STATUS_FILE} — the folders and the lock are the record now`,
|
|
252
|
+
);
|
|
253
|
+
}
|
|
254
|
+
return 0;
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
/**
|
|
258
|
+
* A plan carried out only once its rehearsal on the paths it touches went through (src/domain/
|
|
259
|
+
* versions-migration.ts, `rehearse`): a plan that could stop half-way is a refusal, nothing written.
|
|
260
|
+
*/
|
|
261
|
+
export function carryOut(
|
|
262
|
+
paperDir: string,
|
|
263
|
+
plan: MigrationPlan,
|
|
264
|
+
card: Scorecard,
|
|
265
|
+
deps: VersionsCommandDeps,
|
|
266
|
+
): number {
|
|
267
|
+
const ran = rehearse(plan, (p) =>
|
|
268
|
+
deps.files.isFile(callerPath(resolve(paperDir, p))),
|
|
269
|
+
);
|
|
270
|
+
return ran.ok
|
|
271
|
+
? migrate(paperDir, plan, card, deps)
|
|
272
|
+
: refused([...ran.error, "nothing was moved"], deps);
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
/** The lock already there, or the line saying it must be written first. */
|
|
276
|
+
function lockBefore(
|
|
277
|
+
paperDir: string,
|
|
278
|
+
deps: VersionsCommandDeps,
|
|
279
|
+
): Result<Lock, string> {
|
|
280
|
+
const lock = readLock(paperDir, deps.files) ?? ok({});
|
|
281
|
+
return lock.ok
|
|
282
|
+
? lock
|
|
283
|
+
: err(
|
|
284
|
+
`versions/lock.json: ${lock.error} — \`npx paperlint versions lock ${shown(deps.cwd, paperDir)}\` writes it anew; then migrate`,
|
|
285
|
+
);
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
function runMigrate(
|
|
289
|
+
paperDir: string,
|
|
290
|
+
cycles: Cycles,
|
|
291
|
+
deps: VersionsCommandDeps,
|
|
292
|
+
): number {
|
|
293
|
+
const card = scorecard(paperDir, deps);
|
|
294
|
+
if (!card.ok) return (deps.err(card.error), 2);
|
|
295
|
+
const lock = lockBefore(paperDir, deps);
|
|
296
|
+
if (!lock.ok) return (deps.err(lock.error), 2);
|
|
297
|
+
const plan = planMigration({
|
|
298
|
+
cycleIds: cycles.list.map((c) => c.id),
|
|
299
|
+
stages: card.value.stages,
|
|
300
|
+
root: entriesIn(versionsDir(paperDir), deps),
|
|
301
|
+
factsOf: paperFacts(paperDir, deps),
|
|
302
|
+
lock: lock.value,
|
|
303
|
+
exists: (rel) => deps.files.isFile(callerPath(resolve(paperDir, rel))),
|
|
304
|
+
});
|
|
305
|
+
if (!plan.ok) return refused([...plan.error, "nothing was moved"], deps);
|
|
306
|
+
// A copy always has its move: the last destination of a shared source is moved, not copied.
|
|
307
|
+
if (plan.value.moves.length === 0 && card.value.without === null)
|
|
308
|
+
return (
|
|
309
|
+
deps.log(
|
|
310
|
+
`nothing to migrate: ${STATUS_FILE} has no stages: list, and no file of the old layout is left in versions/`,
|
|
311
|
+
),
|
|
312
|
+
0
|
|
313
|
+
);
|
|
314
|
+
return carryOut(paperDir, plan.value, card.value, deps);
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
const refused = (
|
|
318
|
+
lines: readonly string[],
|
|
319
|
+
deps: VersionsCommandDeps,
|
|
320
|
+
): number => {
|
|
321
|
+
lines.forEach((l) => {
|
|
322
|
+
deps.err(l);
|
|
323
|
+
});
|
|
324
|
+
return 2;
|
|
325
|
+
};
|
|
326
|
+
|
|
327
|
+
// ── the command ─────────────────────────────────────────────────────────────────────────
|
|
328
|
+
|
|
329
|
+
const NO_CYCLES_LOCK =
|
|
330
|
+
'the paper keeps no "cycles" in paperlint.json: there is no versions/<cycle>/ to lock (docs/configuration.md, "The paper\'s cycles")';
|
|
331
|
+
|
|
332
|
+
const NO_CYCLES_MIGRATE =
|
|
333
|
+
'the paper keeps no "cycles" in paperlint.json: its stages: list stays its record — folders per attempt are named by cycle ids (docs/configuration.md, "The paper\'s cycles")';
|
|
334
|
+
|
|
335
|
+
/** `paperlint versions …`: exit 0, or 2 for a command line or a paper it cannot run on. */
|
|
336
|
+
export function runVersions(
|
|
337
|
+
words: readonly string[],
|
|
338
|
+
deps: VersionsCommandDeps,
|
|
339
|
+
): number {
|
|
340
|
+
const a = versionsArgs(words);
|
|
341
|
+
if (a.extra.length > 0)
|
|
342
|
+
return (
|
|
343
|
+
deps.err(
|
|
344
|
+
`\`versions ${a.sub}\` takes one paper folder — got also \`${a.extra.join(" ")}\``,
|
|
345
|
+
),
|
|
346
|
+
2
|
|
347
|
+
);
|
|
348
|
+
const paperDir = resolve(deps.cwd, a.paper);
|
|
349
|
+
if (a.sub === "show") {
|
|
350
|
+
const lines = versionLines(paperDir, deps);
|
|
351
|
+
if (!lines.ok) return (deps.err(lines.error), 2);
|
|
352
|
+
return (logAll(lines.value, deps), 0);
|
|
353
|
+
}
|
|
354
|
+
const cycles = cyclesIn(paperDir, deps.files);
|
|
355
|
+
if (cycles === null)
|
|
356
|
+
return (deps.err(a.sub === "lock" ? NO_CYCLES_LOCK : NO_CYCLES_MIGRATE), 2);
|
|
357
|
+
return a.sub === "lock"
|
|
358
|
+
? runLock(paperDir, cycles, deps)
|
|
359
|
+
: runMigrate(paperDir, cycles, deps);
|
|
360
|
+
}
|
package/src/versions.ts
ADDED
|
@@ -0,0 +1,286 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A PAPER'S FROZEN VERSIONS, read from the disk: the folder `versions/`, its lock, and the draft
|
|
3
|
+
* measured against the frozen source it left (`src/domain/versions.ts` holds the convention and every
|
|
4
|
+
* decision; this file reads and writes through the ports).
|
|
5
|
+
*
|
|
6
|
+
* readVersions the cycles, the lock and the files of `versions/`, as the rules and the table read them
|
|
7
|
+
* draftDrift paper.tex, assembled as the build read it, against the frozen source of the attempt
|
|
8
|
+
* versionLines the version → state table (`paperlint versions`, `paperlint submission show`)
|
|
9
|
+
* freezePlan before a `submission --save` sends: what it will freeze, or why it cannot
|
|
10
|
+
* freezeSent after the portal took it: the PDF sent and its source, written and locked
|
|
11
|
+
*/
|
|
12
|
+
import { join } from "node:path";
|
|
13
|
+
import { callerPath } from "./caller-path.ts";
|
|
14
|
+
import { venueCycleOf, type CycleId, type Cycles } from "./domain/cycle.ts";
|
|
15
|
+
import { sha256Hex } from "./domain/sha256.ts";
|
|
16
|
+
import { messageOf } from "./domain/text.ts";
|
|
17
|
+
import { err, ok, type Result } from "./domain/result.ts";
|
|
18
|
+
import {
|
|
19
|
+
driftBase,
|
|
20
|
+
frozenKey,
|
|
21
|
+
lineDrift,
|
|
22
|
+
lockText,
|
|
23
|
+
LOCK_FILE,
|
|
24
|
+
parseLock,
|
|
25
|
+
versionRows,
|
|
26
|
+
versionTable,
|
|
27
|
+
VERSIONS_DIR,
|
|
28
|
+
type DraftDrift,
|
|
29
|
+
type Entry,
|
|
30
|
+
type FileFacts,
|
|
31
|
+
type Lock,
|
|
32
|
+
type LockEntry,
|
|
33
|
+
} from "./domain/versions.ts";
|
|
34
|
+
import type { Files } from "./ports/files.ts";
|
|
35
|
+
import type { ListDir } from "./ports/talk-media.ts";
|
|
36
|
+
import { readCycles } from "./cycle-rules.ts";
|
|
37
|
+
import { readPaper, decoded, type ProseDeps } from "./tex-paper.ts";
|
|
38
|
+
import { MAIN } from "./build.ts";
|
|
39
|
+
import { paperRecord } from "./paper-record.ts";
|
|
40
|
+
|
|
41
|
+
/** The paper's `versions/` folder. */
|
|
42
|
+
export const versionsDir = (paperDir: string): string =>
|
|
43
|
+
join(paperDir, VERSIONS_DIR);
|
|
44
|
+
|
|
45
|
+
/** A frozen file's path, by its key. */
|
|
46
|
+
export const frozenPath = (paperDir: string, key: string): string =>
|
|
47
|
+
join(paperDir, VERSIONS_DIR, key);
|
|
48
|
+
|
|
49
|
+
/** Whether a frozen file is on disk, by its key. */
|
|
50
|
+
export const hasFrozen =
|
|
51
|
+
(paperDir: string, files: Files) =>
|
|
52
|
+
(key: string): boolean =>
|
|
53
|
+
files.isFile(callerPath(frozenPath(paperDir, key)));
|
|
54
|
+
|
|
55
|
+
/** A frozen file's text, by its key; null when it is not there. */
|
|
56
|
+
export const frozenText = (
|
|
57
|
+
paperDir: string,
|
|
58
|
+
key: string,
|
|
59
|
+
files: Files,
|
|
60
|
+
): string | null =>
|
|
61
|
+
decoded(files.readBytes(callerPath(frozenPath(paperDir, key))));
|
|
62
|
+
|
|
63
|
+
/** The size and digest of a frozen file on disk, by its key: zero bytes of nothing when it is gone. */
|
|
64
|
+
export const factsOfFrozen =
|
|
65
|
+
(paperDir: string, files: Files) =>
|
|
66
|
+
(key: string): FileFacts => {
|
|
67
|
+
const b =
|
|
68
|
+
files.readBytes(callerPath(frozenPath(paperDir, key))) ??
|
|
69
|
+
new Uint8Array();
|
|
70
|
+
return { bytes: b.length, sha256: sha256Hex(b) };
|
|
71
|
+
};
|
|
72
|
+
|
|
73
|
+
/** `versions/lock.json`: null when there is none; else parsed, or why it does not parse. */
|
|
74
|
+
export function readLock(
|
|
75
|
+
paperDir: string,
|
|
76
|
+
files: Files,
|
|
77
|
+
): Result<Lock, string> | null {
|
|
78
|
+
const b = files.readBytes(callerPath(join(versionsDir(paperDir), LOCK_FILE)));
|
|
79
|
+
if (b === null) return null;
|
|
80
|
+
try {
|
|
81
|
+
return parseLock(JSON.parse(new TextDecoder().decode(b)));
|
|
82
|
+
} catch (e) {
|
|
83
|
+
return err(`not JSON: ${messageOf(e)}`);
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/** What reading the folder needs: the disk and its listings. */
|
|
88
|
+
export interface VersionsDeps {
|
|
89
|
+
readonly files: Files;
|
|
90
|
+
readonly listDir: ListDir;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/** The entries of a folder, each a file or a folder, by name. */
|
|
94
|
+
export const entriesIn = (dir: string, deps: VersionsDeps): readonly Entry[] =>
|
|
95
|
+
[...deps.listDir(callerPath(dir))].sort().map((name) => ({
|
|
96
|
+
name,
|
|
97
|
+
kind: deps.files.isFile(callerPath(join(dir, name))) ? "file" : "dir",
|
|
98
|
+
}));
|
|
99
|
+
|
|
100
|
+
/** The paper's cycles, when it keeps them and they read; null for the flat form or a broken record. */
|
|
101
|
+
export function cyclesIn(paperDir: string, files: Files): Cycles | null {
|
|
102
|
+
const r = readCycles(paperDir, { files });
|
|
103
|
+
return r.ok ? r.value : null;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/** paper.tex, assembled as the last build read it (its includes spliced in); null without one. */
|
|
107
|
+
export function draftText(paperDir: string, deps: ProseDeps): string | null {
|
|
108
|
+
const main = join(paperDir, MAIN);
|
|
109
|
+
const src = decoded(deps.files.readBytes(callerPath(main)));
|
|
110
|
+
return src === null ? null : readPaper(main, src, deps).text;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/** The draft against the frozen source of the paper's attempt: none, nothing frozen, the same, or drifted. */
|
|
114
|
+
export function draftDrift(
|
|
115
|
+
paperDir: string,
|
|
116
|
+
cycles: Cycles,
|
|
117
|
+
draft: string,
|
|
118
|
+
files: Files,
|
|
119
|
+
): DraftDrift {
|
|
120
|
+
const base = driftBase(cycles.current, hasFrozen(paperDir, files));
|
|
121
|
+
if (base === null) return { kind: "none" };
|
|
122
|
+
const frozen = frozenText(paperDir, base.key, files);
|
|
123
|
+
if (frozen === null) return { kind: "no-base", key: base.key };
|
|
124
|
+
const drift = lineDrift(frozen, draft);
|
|
125
|
+
return drift.added === 0 && drift.removed === 0
|
|
126
|
+
? { kind: "same", key: base.key }
|
|
127
|
+
: { kind: "drift", key: base.key, drift };
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/**
|
|
131
|
+
* The version → state table of the paper in `paperDir`: each attempt's frozen files and what they
|
|
132
|
+
* are, then the draft. Or why there is none: the paper keeps no `cycles`.
|
|
133
|
+
*/
|
|
134
|
+
export function versionLines(
|
|
135
|
+
paperDir: string,
|
|
136
|
+
deps: ProseDeps,
|
|
137
|
+
): Result<readonly string[], string> {
|
|
138
|
+
const cycles = cyclesIn(paperDir, deps.files);
|
|
139
|
+
if (cycles === null)
|
|
140
|
+
return err(
|
|
141
|
+
'the paper keeps no "cycles" in paperlint.json (or they do not read — `paperlint lint` says why): versions are kept per attempt, versions/<cycle>/<stage>.pdf',
|
|
142
|
+
);
|
|
143
|
+
const lock = readLock(paperDir, deps.files);
|
|
144
|
+
const draft = draftText(paperDir, deps);
|
|
145
|
+
return ok(
|
|
146
|
+
versionTable(
|
|
147
|
+
versionRows({
|
|
148
|
+
cycles: cycles.list,
|
|
149
|
+
has: hasFrozen(paperDir, deps.files),
|
|
150
|
+
lock: lock?.ok === true ? lock.value : {},
|
|
151
|
+
draft:
|
|
152
|
+
draft === null
|
|
153
|
+
? { kind: "none" }
|
|
154
|
+
: draftDrift(paperDir, cycles, draft, deps.files),
|
|
155
|
+
}),
|
|
156
|
+
),
|
|
157
|
+
);
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
// ── freezing what a submission sends ────────────────────────────────────────────────────
|
|
161
|
+
|
|
162
|
+
/** What a `--save` will freeze once the portal takes the PDF: nothing for the flat form, else where. */
|
|
163
|
+
export type FreezePlan =
|
|
164
|
+
| { readonly kind: "flat" }
|
|
165
|
+
| {
|
|
166
|
+
readonly kind: "freeze";
|
|
167
|
+
readonly cycle: CycleId;
|
|
168
|
+
readonly stage: "submitted" | "camera-ready";
|
|
169
|
+
/** paper.tex as the build read it, includes spliced in: the source the PDF was built from. */
|
|
170
|
+
readonly source: string;
|
|
171
|
+
};
|
|
172
|
+
|
|
173
|
+
const refusal = (why: string, cycle: string): string =>
|
|
174
|
+
`refused: ${why}, and the two could not be frozen together in versions/${cycle}/ — rebuild (\`paperlint build\`), run the dry run again, then --save; nothing was sent`;
|
|
175
|
+
|
|
176
|
+
/** Why the source on disk cannot be frozen as the PDF's; null when the build's record says it can. */
|
|
177
|
+
function sourceProblem(
|
|
178
|
+
paperDir: string,
|
|
179
|
+
deps: ProseDeps,
|
|
180
|
+
): Result<string, string> {
|
|
181
|
+
const record = paperRecord(paperDir, deps);
|
|
182
|
+
if (record.kind === "none")
|
|
183
|
+
return err(
|
|
184
|
+
"no record of the build (_build/sources.json), so which source the PDF was built from is not known",
|
|
185
|
+
);
|
|
186
|
+
if (record.kind === "stale")
|
|
187
|
+
return err(
|
|
188
|
+
`${record.changed.map((x) => x.path).join(", ")} changed since the build, so the PDF is not what the source builds now`,
|
|
189
|
+
);
|
|
190
|
+
const source = draftText(paperDir, deps);
|
|
191
|
+
return source === null
|
|
192
|
+
? err(`no ${MAIN} to freeze beside the PDF`)
|
|
193
|
+
: ok(source);
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
/**
|
|
197
|
+
* Before a `--save` sends: what it will freeze — the open cycle's `submitted`, or the accepted one's
|
|
198
|
+
* `camera-ready` — and the source to freeze beside the PDF. Refused when the build's record does not
|
|
199
|
+
* say the source on disk is the one the PDF was built from: a PDF frozen with another source is a
|
|
200
|
+
* pair that lies.
|
|
201
|
+
*/
|
|
202
|
+
export function freezePlan(
|
|
203
|
+
paperDir: string,
|
|
204
|
+
cycles: Cycles | null,
|
|
205
|
+
deps: ProseDeps,
|
|
206
|
+
): Result<FreezePlan, string> {
|
|
207
|
+
const c = cycles === null ? null : venueCycleOf(cycles.current);
|
|
208
|
+
if (cycles === null || c === null) return ok({ kind: "flat" });
|
|
209
|
+
const source = sourceProblem(paperDir, deps);
|
|
210
|
+
return source.ok
|
|
211
|
+
? ok({
|
|
212
|
+
kind: "freeze",
|
|
213
|
+
cycle: c.id,
|
|
214
|
+
stage:
|
|
215
|
+
cycles.current.kind === "accepted" ? "camera-ready" : "submitted",
|
|
216
|
+
source: source.value,
|
|
217
|
+
})
|
|
218
|
+
: err(refusal(source.error, c.id));
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
/** What freezing writes with: the disk, and the instant the lock records (ISO 8601). */
|
|
222
|
+
export interface FreezeDeps {
|
|
223
|
+
readonly files: Files;
|
|
224
|
+
readonly now: () => string;
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
const lockEntry = (bytes: Uint8Array, frozen: string): LockEntry => ({
|
|
228
|
+
kind: "file",
|
|
229
|
+
bytes: bytes.length,
|
|
230
|
+
sha256: sha256Hex(bytes),
|
|
231
|
+
frozen,
|
|
232
|
+
});
|
|
233
|
+
|
|
234
|
+
/** What was sent: the paper, its plan, the PDF's bytes, and the paper as the command names it. */
|
|
235
|
+
export interface Sent {
|
|
236
|
+
readonly paperDir: string;
|
|
237
|
+
readonly plan: Extract<FreezePlan, { readonly kind: "freeze" }>;
|
|
238
|
+
readonly pdf: Uint8Array;
|
|
239
|
+
readonly shownPaper: string;
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
/** The lock with the two frozen files recorded, written; or why the lock on disk does not read. */
|
|
243
|
+
function lockSent(
|
|
244
|
+
s: Sent,
|
|
245
|
+
entries: Lock,
|
|
246
|
+
deps: FreezeDeps,
|
|
247
|
+
): Result<null, string> {
|
|
248
|
+
const lock = readLock(s.paperDir, deps.files) ?? ok({});
|
|
249
|
+
if (!lock.ok) return lock;
|
|
250
|
+
deps.files.writeAtomic(
|
|
251
|
+
callerPath(join(versionsDir(s.paperDir), LOCK_FILE)),
|
|
252
|
+
new TextEncoder().encode(lockText({ ...lock.value, ...entries })),
|
|
253
|
+
);
|
|
254
|
+
return ok(null);
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
/**
|
|
258
|
+
* After the portal took the PDF: the PDF and its source written to `versions/<cycle>/<stage>.*`,
|
|
259
|
+
* replacing an earlier upload's, and recorded in the lock. The line to print; an error line when the
|
|
260
|
+
* lock does not read (the files are frozen, the lock left as it is).
|
|
261
|
+
*/
|
|
262
|
+
export function freezeSent(s: Sent, deps: FreezeDeps): Result<string, string> {
|
|
263
|
+
const { cycle, stage } = s.plan;
|
|
264
|
+
const source = new TextEncoder().encode(s.plan.source);
|
|
265
|
+
const pdfKey = frozenKey(cycle, stage, "pdf");
|
|
266
|
+
const texKey = frozenKey(cycle, stage, "tex");
|
|
267
|
+
deps.files.writeAtomic(callerPath(frozenPath(s.paperDir, pdfKey)), s.pdf);
|
|
268
|
+
deps.files.writeAtomic(callerPath(frozenPath(s.paperDir, texKey)), source);
|
|
269
|
+
const frozen = deps.now();
|
|
270
|
+
const locked = lockSent(
|
|
271
|
+
s,
|
|
272
|
+
{ [pdfKey]: lockEntry(s.pdf, frozen), [texKey]: lockEntry(source, frozen) },
|
|
273
|
+
deps,
|
|
274
|
+
);
|
|
275
|
+
return locked.ok
|
|
276
|
+
? ok(
|
|
277
|
+
`frozen versions/${pdfKey} (${String(s.pdf.length)} bytes) and ${stage}.tex (${String(source.length)} bytes), recorded in versions/lock.json`,
|
|
278
|
+
)
|
|
279
|
+
: err(
|
|
280
|
+
`versions/lock.json does not read (${locked.error}): versions/${pdfKey} and ${stage}.tex are frozen, not recorded — \`npx paperlint versions lock ${s.shownPaper}\` records them`,
|
|
281
|
+
);
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
/** What a send in the flat form says instead of a freeze. */
|
|
285
|
+
export const NOT_FROZEN =
|
|
286
|
+
'not frozen the paper keeps no "cycles" in paperlint.json: versions are frozen per attempt, in versions/<cycle>/ (docs/configuration.md, "The paper\'s cycles")';
|
|
@@ -1,39 +0,0 @@
|
|
|
1
|
-
import type { RuleContext } from "./rule-context.ts";
|
|
2
|
-
interface Head {
|
|
3
|
-
readonly id: string;
|
|
4
|
-
readonly phase: string;
|
|
5
|
-
}
|
|
6
|
-
interface Finding {
|
|
7
|
-
readonly messageId: "noCycle" | "unknownCycle" | "porting";
|
|
8
|
-
readonly data: Readonly<Record<string, string>>;
|
|
9
|
-
}
|
|
10
|
-
/** Each stage against the declared cycles. Pure. */
|
|
11
|
-
export declare function judgeStages(stages: readonly unknown[], cycles: readonly Head[]): readonly Finding[];
|
|
12
|
-
declare const _default: {
|
|
13
|
-
rules: {
|
|
14
|
-
stage: {
|
|
15
|
-
meta: {
|
|
16
|
-
type: "problem";
|
|
17
|
-
docs: {
|
|
18
|
-
description: string;
|
|
19
|
-
url: string;
|
|
20
|
-
};
|
|
21
|
-
schema: never[];
|
|
22
|
-
messages: {
|
|
23
|
-
noCycle: string;
|
|
24
|
-
unknownCycle: string;
|
|
25
|
-
porting: string;
|
|
26
|
-
};
|
|
27
|
-
};
|
|
28
|
-
create(context: RuleContext): {
|
|
29
|
-
yaml?: never;
|
|
30
|
-
} | {
|
|
31
|
-
yaml(node: {
|
|
32
|
-
readonly value: string;
|
|
33
|
-
}): void;
|
|
34
|
-
};
|
|
35
|
-
};
|
|
36
|
-
};
|
|
37
|
-
};
|
|
38
|
-
export default _default;
|
|
39
|
-
//# sourceMappingURL=cycle-stage.d.ts.map
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
{"version":3,"file":"cycle-stage.d.ts","sourceRoot":"","sources":["../../eslint-rules/cycle-stage.ts"],"names":[],"mappings":"AAqBA,OAAO,KAAK,EAAE,WAAW,EAAE,MAAM,mBAAmB,CAAC;AAwBrD,UAAU,IAAI;IACZ,QAAQ,CAAC,EAAE,EAAE,MAAM,CAAC;IACpB,QAAQ,CAAC,KAAK,EAAE,MAAM,CAAC;CACxB;AAuBD,UAAU,OAAO;IACf,QAAQ,CAAC,SAAS,EAAE,SAAS,GAAG,cAAc,GAAG,SAAS,CAAC;IAC3D,QAAQ,CAAC,IAAI,EAAE,QAAQ,CAAC,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC,CAAC;CACjD;AAED,oDAAoD;AACpD,wBAAgB,WAAW,CACzB,MAAM,EAAE,SAAS,OAAO,EAAE,EAC1B,MAAM,EAAE,SAAS,IAAI,EAAE,GACtB,SAAS,OAAO,EAAE,CAgBpB;;;;;;;;;;;;;;;;;4BAsBqB,WAAW;;;2BAIZ;oBAAE,QAAQ,CAAC,KAAK,EAAE,MAAM,CAAA;iBAAE;;;;;AAxB/C,wBA0CE"}
|