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,429 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* FROM `stages:` TO FOLDERS PER ATTEMPT — what `paperlint versions migrate` decides before it touches
|
|
3
|
+
* anything: each entry of the scorecard's `stages:` list becomes a move of its PDF and source into
|
|
4
|
+
* `versions/<cycle>/<stage>.{pdf,tex}` and an entry of `versions/lock.json`, and the list is dropped.
|
|
5
|
+
*
|
|
6
|
+
* The entry's `cycle:` names the folder, its `date:` becomes the day the lock says it was frozen, and
|
|
7
|
+
* its `bytes:` / `sourceBytes:` are checked against the files once more: a file that is no longer the
|
|
8
|
+
* one declared is not moved under a lock that would vouch for it. `sourceLost: true` becomes a lock
|
|
9
|
+
* entry that records the loss. A frozen file of the old layout that no entry names, and an entry that
|
|
10
|
+
* names no cycle, stop the whole migration with what to do — nothing is half-moved.
|
|
11
|
+
*
|
|
12
|
+
* Pure: the caller reads the disk and hands in what it found.
|
|
13
|
+
*/
|
|
14
|
+
import * as R from "remeda";
|
|
15
|
+
import type { CycleId } from "./cycle.ts";
|
|
16
|
+
import { asPaperPath, paperPath, type PaperPath } from "./paths.ts";
|
|
17
|
+
import { isRecord } from "./record.ts";
|
|
18
|
+
import { err, ok, type Result } from "./result.ts";
|
|
19
|
+
import {
|
|
20
|
+
DATED_NAME,
|
|
21
|
+
frozenKey,
|
|
22
|
+
LOCK_FILE,
|
|
23
|
+
VERSION_STAGES,
|
|
24
|
+
VERSIONS_DIR,
|
|
25
|
+
type Entry,
|
|
26
|
+
type FileFacts,
|
|
27
|
+
type Lock,
|
|
28
|
+
type LockEntry,
|
|
29
|
+
type VersionExt,
|
|
30
|
+
type VersionStage,
|
|
31
|
+
} from "./versions.ts";
|
|
32
|
+
|
|
33
|
+
/** A file moved (or copied), both paths parsed: one spelling per file, inside the paper folder. */
|
|
34
|
+
export interface Move {
|
|
35
|
+
readonly from: PaperPath;
|
|
36
|
+
readonly to: PaperPath;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export interface MigrationPlan {
|
|
40
|
+
/**
|
|
41
|
+
* A source two stages were built from, copied to every destination but its last, which the move
|
|
42
|
+
* takes: done before the moves, so each copy reads the file before it leaves.
|
|
43
|
+
*/
|
|
44
|
+
readonly copies: readonly Move[];
|
|
45
|
+
readonly moves: readonly Move[];
|
|
46
|
+
/** The lock after the migration: what was there, and an entry per file moved or source lost. */
|
|
47
|
+
readonly lock: Lock;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export interface MigrationInput {
|
|
51
|
+
readonly cycleIds: readonly CycleId[];
|
|
52
|
+
/** The scorecard's `stages:` list, as its YAML parsed. */
|
|
53
|
+
readonly stages: readonly unknown[];
|
|
54
|
+
/** The entries directly in `versions/`. */
|
|
55
|
+
readonly root: readonly Entry[];
|
|
56
|
+
/** The size and digest of a file of the paper, by its path relative to the paper; null when absent. */
|
|
57
|
+
readonly factsOf: (rel: PaperPath) => FileFacts | null;
|
|
58
|
+
/** The lock already there (versions frozen on send since), or none. */
|
|
59
|
+
readonly lock: Lock;
|
|
60
|
+
/** Whether a file of the paper is there, by its path relative to the paper: a destination taken. */
|
|
61
|
+
readonly exists: (rel: PaperPath) => boolean;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** One entry's part of the plan: moves and lock entries, or what stops it. */
|
|
65
|
+
interface Part {
|
|
66
|
+
readonly moves: readonly Move[];
|
|
67
|
+
readonly lock: Lock;
|
|
68
|
+
readonly problems: readonly string[];
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
const NONE: Part = { moves: [], lock: {}, problems: [] };
|
|
72
|
+
const problem = (why: string): Part => ({ ...NONE, problems: [why] });
|
|
73
|
+
|
|
74
|
+
const isStage = (s: unknown): s is VersionStage =>
|
|
75
|
+
VERSION_STAGES.some((v) => v === s);
|
|
76
|
+
|
|
77
|
+
const text = (v: unknown): string =>
|
|
78
|
+
typeof v === "string" ? v : v === undefined ? "" : JSON.stringify(v);
|
|
79
|
+
|
|
80
|
+
/** A YAML date without quotes is a `Date`; either way, the day it names, or "" when it names none. */
|
|
81
|
+
function isoDate(v: unknown): string {
|
|
82
|
+
if (v instanceof Date) return v.toISOString().slice(0, 10);
|
|
83
|
+
return typeof v === "string"
|
|
84
|
+
? (/^\d{4}-\d{2}-\d{2}$/.exec(v)?.[0] ?? "")
|
|
85
|
+
: "";
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/** Where an entry's file goes, checked against what the entry declares of it. */
|
|
89
|
+
interface Target {
|
|
90
|
+
readonly at: string;
|
|
91
|
+
readonly cycle: CycleId;
|
|
92
|
+
readonly stage: VersionStage;
|
|
93
|
+
readonly date: string;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* A path a stage declares, parsed once where the plan reads the list: absent (not a string), or the
|
|
98
|
+
* file it names in one spelling, or why it names no file of the paper.
|
|
99
|
+
*/
|
|
100
|
+
type DeclaredPath = Result<PaperPath, string> | null;
|
|
101
|
+
|
|
102
|
+
/** One `stages:` entry as the plan reads it: the record, and its two paths parsed. */
|
|
103
|
+
interface Declaration {
|
|
104
|
+
readonly rec: Readonly<Record<string, unknown>>;
|
|
105
|
+
readonly pdf: DeclaredPath;
|
|
106
|
+
readonly source: DeclaredPath;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
const declaredPath = (v: unknown): DeclaredPath =>
|
|
110
|
+
typeof v === "string" ? paperPath(v) : null;
|
|
111
|
+
|
|
112
|
+
const declaration = (e: unknown): Declaration => {
|
|
113
|
+
const rec = isRecord(e) ? e : {};
|
|
114
|
+
return {
|
|
115
|
+
rec,
|
|
116
|
+
pdf: declaredPath(rec["pdf"]),
|
|
117
|
+
source: declaredPath(rec["source"]),
|
|
118
|
+
};
|
|
119
|
+
};
|
|
120
|
+
|
|
121
|
+
/** A declared path that names no file of the paper: what stops the entry. */
|
|
122
|
+
const outside = (t: Target, error: string): Part =>
|
|
123
|
+
problem(
|
|
124
|
+
`${t.at}: ${error} — a stage names a file of this paper, relative to it`,
|
|
125
|
+
);
|
|
126
|
+
|
|
127
|
+
/** One declared file → its move and lock entry, or why it cannot move. */
|
|
128
|
+
function filePart(
|
|
129
|
+
t: Target,
|
|
130
|
+
f: {
|
|
131
|
+
readonly path: PaperPath;
|
|
132
|
+
readonly declared: unknown;
|
|
133
|
+
readonly ext: VersionExt;
|
|
134
|
+
},
|
|
135
|
+
factsOf: MigrationInput["factsOf"],
|
|
136
|
+
): Part {
|
|
137
|
+
const { path, declared, ext } = f;
|
|
138
|
+
const facts = factsOf(path);
|
|
139
|
+
if (facts === null) return problem(`${t.at}: \`${path}\` is not on disk`);
|
|
140
|
+
if (typeof declared === "number" && declared !== facts.bytes)
|
|
141
|
+
return problem(
|
|
142
|
+
`${t.at}: \`${path}\` has ${String(facts.bytes)} bytes, the stage declares ${String(declared)} — it is not the file that was frozen: restore it from git first`,
|
|
143
|
+
);
|
|
144
|
+
const key = frozenKey(t.cycle, t.stage, ext);
|
|
145
|
+
const entry: LockEntry = { kind: "file", ...facts, frozen: t.date };
|
|
146
|
+
return {
|
|
147
|
+
moves: [{ from: path, to: asPaperPath(`${VERSIONS_DIR}/${key}`) }],
|
|
148
|
+
lock: { [key]: entry },
|
|
149
|
+
problems: [],
|
|
150
|
+
};
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
const merge = (parts: readonly Part[]): Part => ({
|
|
154
|
+
moves: parts.flatMap((p) => p.moves),
|
|
155
|
+
lock: parts.reduce<Lock>((acc, p) => ({ ...acc, ...p.lock }), {}),
|
|
156
|
+
problems: parts.flatMap((p) => p.problems),
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
/** The source of an entry: lost, a file, or none (versions/layout then says the PDF has none). */
|
|
160
|
+
function sourcePart(
|
|
161
|
+
t: Target,
|
|
162
|
+
d: Declaration,
|
|
163
|
+
factsOf: MigrationInput["factsOf"],
|
|
164
|
+
): Part {
|
|
165
|
+
if (d.rec["sourceLost"] === true)
|
|
166
|
+
return {
|
|
167
|
+
...NONE,
|
|
168
|
+
lock: {
|
|
169
|
+
[frozenKey(t.cycle, t.stage, "tex")]: { kind: "lost", frozen: t.date },
|
|
170
|
+
},
|
|
171
|
+
};
|
|
172
|
+
if (d.source === null) return NONE;
|
|
173
|
+
return d.source.ok
|
|
174
|
+
? filePart(
|
|
175
|
+
t,
|
|
176
|
+
{ path: d.source.value, declared: d.rec["sourceBytes"], ext: "tex" },
|
|
177
|
+
factsOf,
|
|
178
|
+
)
|
|
179
|
+
: outside(t, d.source.error);
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
/** Where one `stages:` entry goes, or why it has no place. */
|
|
183
|
+
function targetOf(
|
|
184
|
+
rec: Readonly<Record<string, unknown>>,
|
|
185
|
+
i: number,
|
|
186
|
+
ids: readonly CycleId[],
|
|
187
|
+
): Result<Target, string> {
|
|
188
|
+
const at = `stages[${String(i)}]`;
|
|
189
|
+
const stage = rec["stage"];
|
|
190
|
+
if (!isStage(stage))
|
|
191
|
+
return err(
|
|
192
|
+
`${at}: the stage «${text(stage)}» has no place in versions/<cycle>/ — the stages are ${VERSION_STAGES.join(", ")}`,
|
|
193
|
+
);
|
|
194
|
+
const cycle = ids.find((id) => id === rec["cycle"]);
|
|
195
|
+
if (cycle === undefined)
|
|
196
|
+
return err(
|
|
197
|
+
`${at} («${stage}»): names no cycle of paperlint.json — add \`cycle: <id>\` (${ids.join(" · ") || "none declared"}), the attempt the PDF was sent in, then migrate`,
|
|
198
|
+
);
|
|
199
|
+
const date = isoDate(rec["date"]);
|
|
200
|
+
return date === ""
|
|
201
|
+
? err(`${at} («${stage}»): "date" is not YYYY-MM-DD`)
|
|
202
|
+
: ok({ at, cycle, stage, date });
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
/** One `stages:` entry → its moves and lock entries. */
|
|
206
|
+
function entryPart(d: Declaration, i: number, input: MigrationInput): Part {
|
|
207
|
+
const t = targetOf(d.rec, i, input.cycleIds);
|
|
208
|
+
if (!t.ok) return problem(t.error);
|
|
209
|
+
return merge([
|
|
210
|
+
pdfPart(t.value, d, input.factsOf),
|
|
211
|
+
sourcePart(t.value, d, input.factsOf),
|
|
212
|
+
]);
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
/** The PDF of an entry: a move, or why there is none. */
|
|
216
|
+
function pdfPart(
|
|
217
|
+
t: Target,
|
|
218
|
+
d: Declaration,
|
|
219
|
+
factsOf: MigrationInput["factsOf"],
|
|
220
|
+
): Part {
|
|
221
|
+
if (d.pdf === null) return problem(`${t.at} («${t.stage}»): no "pdf"`);
|
|
222
|
+
return d.pdf.ok
|
|
223
|
+
? filePart(
|
|
224
|
+
t,
|
|
225
|
+
{ path: d.pdf.value, declared: d.rec["bytes"], ext: "pdf" },
|
|
226
|
+
factsOf,
|
|
227
|
+
)
|
|
228
|
+
: outside(t, d.pdf.error);
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
/** Every file the entries name, moved or not, in its one spelling. */
|
|
232
|
+
const named = (decls: readonly Declaration[]): readonly PaperPath[] =>
|
|
233
|
+
decls
|
|
234
|
+
.flatMap((d) => [d.pdf, d.source])
|
|
235
|
+
.flatMap((p) => (p?.ok === true ? [p.value] : []));
|
|
236
|
+
|
|
237
|
+
/**
|
|
238
|
+
* Files no entry names, at the top of versions/: a dated one of the old layout stops it — it may be a
|
|
239
|
+
* stage nobody declared — and any other (a withdrawn `STALE` copy, a note on the old convention) goes to
|
|
240
|
+
* extra/versions/, since versions/layout allows nothing loose beside lock.json.
|
|
241
|
+
*/
|
|
242
|
+
function leftovers(input: MigrationInput, moved: readonly PaperPath[]): Part {
|
|
243
|
+
const left = input.root.filter(
|
|
244
|
+
(e) => e.kind === "file" && !e.name.startsWith(".") && e.name !== LOCK_FILE,
|
|
245
|
+
);
|
|
246
|
+
return merge(
|
|
247
|
+
left.map((e): Part => {
|
|
248
|
+
const from = paperPath(`${VERSIONS_DIR}/${e.name}`);
|
|
249
|
+
if (!from.ok)
|
|
250
|
+
return problem(
|
|
251
|
+
`versions/${e.name}: not a file name migrate can move — rename it, then migrate`,
|
|
252
|
+
);
|
|
253
|
+
if (moved.includes(from.value)) return NONE;
|
|
254
|
+
return DATED_NAME.test(e.name)
|
|
255
|
+
? problem(
|
|
256
|
+
`versions/${e.name}: no stage in \`stages:\` names it — declare it with its cycle, or move it to extra/, then migrate`,
|
|
257
|
+
)
|
|
258
|
+
: {
|
|
259
|
+
...NONE,
|
|
260
|
+
moves: [
|
|
261
|
+
{ from: from.value, to: asPaperPath(`extra/versions/${e.name}`) },
|
|
262
|
+
],
|
|
263
|
+
};
|
|
264
|
+
}),
|
|
265
|
+
);
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
/** Two entries frozen into one place: an attempt keeps one upload per stage. */
|
|
269
|
+
const collisions = (moves: readonly Move[]): readonly string[] =>
|
|
270
|
+
Object.entries(R.groupBy(moves, (m) => m.to))
|
|
271
|
+
.filter(([, ms]) => ms.length > 1)
|
|
272
|
+
.map(
|
|
273
|
+
([to, ms]) =>
|
|
274
|
+
`${to}: ${ms.map((m) => `\`${m.from}\``).join(" and ")} would both go there — an attempt keeps its last upload per stage: move the older to extra/, drop its entry from \`stages:\`, then migrate`,
|
|
275
|
+
);
|
|
276
|
+
|
|
277
|
+
/**
|
|
278
|
+
* One source named by several stages (the old layout let a `submitted` and an `arxiv` share the
|
|
279
|
+
* source they were built from): copied to each destination but the last, and moved to that one.
|
|
280
|
+
* Renaming it twice would fail on the second rename, after the first files had moved.
|
|
281
|
+
*/
|
|
282
|
+
function shared(moves: readonly Move[]): {
|
|
283
|
+
readonly copies: readonly Move[];
|
|
284
|
+
readonly moves: readonly Move[];
|
|
285
|
+
} {
|
|
286
|
+
const last = (m: Move, i: number) =>
|
|
287
|
+
!moves.slice(i + 1).some((n) => n.from === m.from);
|
|
288
|
+
return {
|
|
289
|
+
copies: moves.filter((m, i) => !last(m, i)),
|
|
290
|
+
moves: moves.filter(last),
|
|
291
|
+
};
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
/** Destinations a file is already at — a version frozen on send since, or an earlier copy in extra/. */
|
|
295
|
+
const taken = (
|
|
296
|
+
moves: readonly Move[],
|
|
297
|
+
exists: MigrationInput["exists"],
|
|
298
|
+
): readonly string[] =>
|
|
299
|
+
moves
|
|
300
|
+
.filter((m) => exists(m.to))
|
|
301
|
+
.map(
|
|
302
|
+
(m) =>
|
|
303
|
+
`${m.to} is already there, and \`${m.from}\` would replace it — compare the two, keep one, move the other out of versions/, then migrate`,
|
|
304
|
+
);
|
|
305
|
+
|
|
306
|
+
/** The whole migration, or every reason it cannot run: nothing is planned that could stop half-way. Pure. */
|
|
307
|
+
export function planMigration(
|
|
308
|
+
input: MigrationInput,
|
|
309
|
+
): Result<MigrationPlan, readonly string[]> {
|
|
310
|
+
// Every declared path parsed once, here: everything below compares one spelling per file.
|
|
311
|
+
const decls = input.stages.map(declaration);
|
|
312
|
+
const entries = merge(decls.map((d, i) => entryPart(d, i, input)));
|
|
313
|
+
const rest = leftovers(input, named(decls));
|
|
314
|
+
const all = [...entries.moves, ...rest.moves].filter((m) => m.from !== m.to);
|
|
315
|
+
const problems = [
|
|
316
|
+
...entries.problems,
|
|
317
|
+
...rest.problems,
|
|
318
|
+
...collisions(all),
|
|
319
|
+
...taken(all, input.exists),
|
|
320
|
+
];
|
|
321
|
+
return problems.length > 0
|
|
322
|
+
? err(problems)
|
|
323
|
+
: ok({ ...shared(all), lock: { ...input.lock, ...entries.lock } });
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
// ── the rehearsal ────────────────────────────────────────────────────────────────────────
|
|
327
|
+
|
|
328
|
+
/** One step of a plan, as the rehearsal replays it. */
|
|
329
|
+
interface Step {
|
|
330
|
+
readonly verb: "copy" | "move";
|
|
331
|
+
readonly from: PaperPath;
|
|
332
|
+
readonly to: PaperPath;
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
/** The files there after one step, and what stopped it. */
|
|
336
|
+
interface Stage {
|
|
337
|
+
readonly there: readonly PaperPath[];
|
|
338
|
+
readonly problems: readonly string[];
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
/** One step on the files there: it needs its source and a free destination. */
|
|
342
|
+
function replay(state: Stage, s: Step): Stage {
|
|
343
|
+
const at = `${s.verb} ${s.from} → ${s.to}`;
|
|
344
|
+
if (!state.there.includes(s.from))
|
|
345
|
+
return {
|
|
346
|
+
...state,
|
|
347
|
+
problems: [
|
|
348
|
+
...state.problems,
|
|
349
|
+
`${at}: ${s.from} is not there at that step`,
|
|
350
|
+
],
|
|
351
|
+
};
|
|
352
|
+
if (state.there.includes(s.to))
|
|
353
|
+
return {
|
|
354
|
+
...state,
|
|
355
|
+
problems: [
|
|
356
|
+
...state.problems,
|
|
357
|
+
`${at}: ${s.to} is already there at that step`,
|
|
358
|
+
],
|
|
359
|
+
};
|
|
360
|
+
const kept =
|
|
361
|
+
s.verb === "move" ? state.there.filter((p) => p !== s.from) : state.there;
|
|
362
|
+
return { ...state, there: [...kept, s.to] };
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
/**
|
|
366
|
+
* The plan replayed on the paths it touches, before the disk is: each copy and each move needs its
|
|
367
|
+
* source and a free destination at its step, and every file the new lock vouches for is there at the
|
|
368
|
+
* end. The planner's own checks give the reasons a person can act on; this is the backstop that turns
|
|
369
|
+
* any plan they let through by mistake into a refusal, never a paper moved half-way. Pure.
|
|
370
|
+
*/
|
|
371
|
+
export function rehearse(
|
|
372
|
+
plan: MigrationPlan,
|
|
373
|
+
exists: (p: PaperPath) => boolean,
|
|
374
|
+
): Result<null, readonly string[]> {
|
|
375
|
+
const steps: readonly Step[] = [
|
|
376
|
+
...plan.copies.map((m) => ({ verb: "copy" as const, ...m })),
|
|
377
|
+
...plan.moves.map((m) => ({ verb: "move" as const, ...m })),
|
|
378
|
+
];
|
|
379
|
+
const vouched = Object.entries(plan.lock)
|
|
380
|
+
.filter(([, e]) => e.kind === "file")
|
|
381
|
+
.map(([key]) => asPaperPath(`${VERSIONS_DIR}/${key}`));
|
|
382
|
+
const touched = R.unique([
|
|
383
|
+
...steps.flatMap((s) => [s.from, s.to]),
|
|
384
|
+
...vouched,
|
|
385
|
+
]);
|
|
386
|
+
const end = steps.reduce(replay, {
|
|
387
|
+
there: touched.filter(exists),
|
|
388
|
+
problems: [],
|
|
389
|
+
});
|
|
390
|
+
const missing = vouched
|
|
391
|
+
.filter((p) => !end.there.includes(p))
|
|
392
|
+
.map(
|
|
393
|
+
(p) =>
|
|
394
|
+
`versions/lock.json would vouch for ${p}, which would not be there after the migration`,
|
|
395
|
+
);
|
|
396
|
+
const problems = [...end.problems, ...missing];
|
|
397
|
+
return problems.length > 0 ? err(problems) : ok(null);
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
// ── the scorecard's frontmatter ──────────────────────────────────────────────────────────
|
|
401
|
+
|
|
402
|
+
const FRONTMATTER = /^---\n([\s\S]*?\n)?---(?:\n|$)/;
|
|
403
|
+
|
|
404
|
+
/** The YAML between the scorecard's `---` lines; null when it has none. Pure. */
|
|
405
|
+
export function frontmatterOf(markdown: string): string | null {
|
|
406
|
+
const m = FRONTMATTER.exec(markdown);
|
|
407
|
+
return m === null ? null : (m[1] ?? "");
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
/**
|
|
411
|
+
* The scorecard with its `stages:` key — the line and every indented or list line under it — taken
|
|
412
|
+
* out of the frontmatter; the frontmatter itself dropped when nothing is left in it. Null when there
|
|
413
|
+
* is no `stages:` key at the top of a frontmatter. Pure: the caller checks the result with a YAML
|
|
414
|
+
* parser, so a layout this misreads is refused, not written.
|
|
415
|
+
*/
|
|
416
|
+
export function withoutStages(markdown: string): string | null {
|
|
417
|
+
const m = FRONTMATTER.exec(markdown);
|
|
418
|
+
const lines = (m?.[1] ?? "").split("\n");
|
|
419
|
+
const start = lines.findIndex((l) => /^stages\s*:/.test(l));
|
|
420
|
+
if (m === null || start < 0) return null;
|
|
421
|
+
const after = lines.slice(start + 1);
|
|
422
|
+
const length = after.findIndex((l) => /^[^\s#-]/.test(l));
|
|
423
|
+
const kept = [
|
|
424
|
+
...lines.slice(0, start),
|
|
425
|
+
...(length < 0 ? [] : after.slice(length)),
|
|
426
|
+
].join("\n");
|
|
427
|
+
const body = markdown.slice(m[0].length);
|
|
428
|
+
return kept.trim() === "" ? body : `---\n${kept.trimEnd()}\n---\n${body}`;
|
|
429
|
+
}
|