paperlint 12.1.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.
Files changed (131) hide show
  1. package/dist/adapters/memory/index.d.ts +5 -0
  2. package/dist/adapters/memory/index.d.ts.map +1 -1
  3. package/dist/adapters/memory/index.js +16 -0
  4. package/dist/adapters/memory/index.js.map +1 -1
  5. package/dist/adapters/node/index.d.ts +1 -0
  6. package/dist/adapters/node/index.d.ts.map +1 -1
  7. package/dist/adapters/node/index.js +1 -0
  8. package/dist/adapters/node/index.js.map +1 -1
  9. package/dist/adapters/node/move-file.io.d.ts +4 -0
  10. package/dist/adapters/node/move-file.io.d.ts.map +1 -0
  11. package/dist/adapters/node/move-file.io.js +12 -0
  12. package/dist/adapters/node/move-file.io.js.map +1 -0
  13. package/dist/adapters/yaml/index.d.ts +3 -0
  14. package/dist/adapters/yaml/index.d.ts.map +1 -0
  15. package/dist/adapters/yaml/index.js +17 -0
  16. package/dist/adapters/yaml/index.js.map +1 -0
  17. package/dist/cli.d.ts.map +1 -1
  18. package/dist/cli.js +48 -7
  19. package/dist/cli.js.map +1 -1
  20. package/dist/cycle-rules.d.ts +3 -3
  21. package/dist/cycle-rules.d.ts.map +1 -1
  22. package/dist/cycle-rules.js +1 -2
  23. package/dist/cycle-rules.js.map +1 -1
  24. package/dist/domain/cycle.d.ts +16 -2
  25. package/dist/domain/cycle.d.ts.map +1 -1
  26. package/dist/domain/cycle.js +21 -42
  27. package/dist/domain/cycle.js.map +1 -1
  28. package/dist/domain/paper-layout.d.ts.map +1 -1
  29. package/dist/domain/paper-layout.js +5 -2
  30. package/dist/domain/paper-layout.js.map +1 -1
  31. package/dist/domain/paths.d.ts +12 -0
  32. package/dist/domain/paths.d.ts.map +1 -1
  33. package/dist/domain/paths.js +22 -1
  34. package/dist/domain/paths.js.map +1 -1
  35. package/dist/domain/scaffold-layout.js +1 -1
  36. package/dist/domain/versions-migration.d.ts +51 -0
  37. package/dist/domain/versions-migration.d.ts.map +1 -0
  38. package/dist/domain/versions-migration.js +252 -0
  39. package/dist/domain/versions-migration.js.map +1 -0
  40. package/dist/domain/versions.d.ts +167 -0
  41. package/dist/domain/versions.d.ts.map +1 -0
  42. package/dist/domain/versions.js +457 -0
  43. package/dist/domain/versions.js.map +1 -0
  44. package/dist/eslint-rules/paper-stages.d.ts +7 -0
  45. package/dist/eslint-rules/paper-stages.d.ts.map +1 -1
  46. package/dist/eslint-rules/paper-stages.js +62 -7
  47. package/dist/eslint-rules/paper-stages.js.map +1 -1
  48. package/dist/eslint-rules/rule-context.d.ts +2 -0
  49. package/dist/eslint-rules/rule-context.d.ts.map +1 -1
  50. package/dist/ports/move-file.d.ts +7 -0
  51. package/dist/ports/move-file.d.ts.map +1 -0
  52. package/dist/ports/move-file.js +2 -0
  53. package/dist/ports/move-file.js.map +1 -0
  54. package/dist/ports/yaml.d.ts +5 -0
  55. package/dist/ports/yaml.d.ts.map +1 -0
  56. package/dist/ports/yaml.js +2 -0
  57. package/dist/ports/yaml.js.map +1 -0
  58. package/dist/submission.d.ts +2 -0
  59. package/dist/submission.d.ts.map +1 -1
  60. package/dist/submission.js +49 -9
  61. package/dist/submission.js.map +1 -1
  62. package/dist/version-rules.d.ts +16 -0
  63. package/dist/version-rules.d.ts.map +1 -0
  64. package/dist/version-rules.js +201 -0
  65. package/dist/version-rules.js.map +1 -0
  66. package/dist/versions-command.d.ts +37 -0
  67. package/dist/versions-command.d.ts.map +1 -0
  68. package/dist/versions-command.js +209 -0
  69. package/dist/versions-command.js.map +1 -0
  70. package/dist/versions.d.ts +76 -0
  71. package/dist/versions.d.ts.map +1 -0
  72. package/dist/versions.js +164 -0
  73. package/dist/versions.js.map +1 -0
  74. package/docs/configuration.md +76 -2
  75. package/docs/layout.md +5 -5
  76. package/docs/rules/cycle/camera-ready-source.md +62 -0
  77. package/docs/rules/cycle/draft-drift.md +47 -0
  78. package/docs/rules/cycle/port-done.md +2 -2
  79. package/docs/rules/cycle/record.md +2 -1
  80. package/docs/rules/cycle/submitted-frozen.md +65 -0
  81. package/docs/rules/paper/allowed-files-only.md +1 -1
  82. package/docs/rules/versions/layout.md +88 -0
  83. package/docs/rules.md +25 -11
  84. package/docs/submission.md +21 -3
  85. package/eslint-rules/paper-stages.test.mjs +62 -0
  86. package/eslint-rules/paper-stages.ts +67 -8
  87. package/eslint-rules/prereg-coverage.test.ts +1 -0
  88. package/eslint-rules/rule-context.ts +2 -0
  89. package/package.json +2 -1
  90. package/skills/paper-pipeline/references/pipeline-status-template.md +4 -1
  91. package/skills/submit-paper/SKILL.md +18 -10
  92. package/skills/submit-paper/SKILL.md.spec.ts +17 -9
  93. package/skills/submit-paper/references/publishers/acm.md +5 -3
  94. package/src/adapters/memory/index.test.ts +22 -0
  95. package/src/adapters/memory/index.ts +21 -0
  96. package/src/adapters/node/index.ts +1 -0
  97. package/src/adapters/node/move-file.io.ts +21 -0
  98. package/src/adapters/node/move-file.test.ts +51 -0
  99. package/src/adapters/yaml/index.test.ts +16 -0
  100. package/src/adapters/yaml/index.ts +17 -0
  101. package/src/cli.test.ts +43 -0
  102. package/src/cli.ts +58 -6
  103. package/src/cycle-rules.ts +4 -5
  104. package/src/domain/cycle.test.ts +27 -0
  105. package/src/domain/cycle.ts +38 -6
  106. package/src/domain/paper-layout.ts +5 -2
  107. package/src/domain/paths.test.ts +28 -1
  108. package/src/domain/paths.ts +34 -1
  109. package/src/domain/scaffold-layout.ts +1 -1
  110. package/src/domain/versions-migration.test.ts +410 -0
  111. package/src/domain/versions-migration.ts +429 -0
  112. package/src/domain/versions.test.ts +564 -0
  113. package/src/domain/versions.ts +706 -0
  114. package/src/ports/move-file.ts +8 -0
  115. package/src/ports/yaml.ts +5 -0
  116. package/src/rule-cases.test.ts +157 -8
  117. package/src/submission-freeze.test.ts +320 -0
  118. package/src/submission-new.test.ts +11 -1
  119. package/src/submission.ts +82 -11
  120. package/src/version-rules.test.ts +222 -0
  121. package/src/version-rules.ts +282 -0
  122. package/src/versions-command.test.ts +594 -0
  123. package/src/versions-command.ts +360 -0
  124. package/src/versions.ts +286 -0
  125. package/dist/eslint-rules/cycle-stage.d.ts +0 -39
  126. package/dist/eslint-rules/cycle-stage.d.ts.map +0 -1
  127. package/dist/eslint-rules/cycle-stage.js +0 -119
  128. package/dist/eslint-rules/cycle-stage.js.map +0 -1
  129. package/docs/rules/cycle/stage.md +0 -85
  130. package/eslint-rules/cycle-stage.test.mjs +0 -131
  131. package/eslint-rules/cycle-stage.ts +0 -141
@@ -0,0 +1,16 @@
1
+ /** `parseYaml`: the value, a bare date as a `Date`, or the parser's own message. */
2
+ import { expect, test } from "vitest";
3
+ import { parseYaml } from "./index.ts";
4
+
5
+ test("parses a mapping, a date without quotes as a Date; refuses what is not YAML with the reason", () => {
6
+ expect(parseYaml("a: 1\nd: 2026-07-22\n")).toStrictEqual({
7
+ ok: true,
8
+ value: { a: 1, d: new Date("2026-07-22T00:00:00.000Z") },
9
+ });
10
+ const bad = parseYaml("a: [");
11
+ expect(bad.ok).toBe(false);
12
+ expect(bad).toStrictEqual({
13
+ ok: false,
14
+ error: "unexpected end of the stream within a flow collection (2:1)",
15
+ });
16
+ });
@@ -0,0 +1,17 @@
1
+ /**
2
+ * `ParseYaml` over js-yaml: the one YAML reader of `src/`. A date without quotes reads as a `Date`.
3
+ * A refusal is the parser's first line — the reason and its line:column — without the excerpt it
4
+ * prints under it, so it fits in a one-line message.
5
+ */
6
+ import { load } from "js-yaml";
7
+ import { messageOf } from "../../domain/text.ts";
8
+ import { err, ok } from "../../domain/result.ts";
9
+ import type { ParseYaml } from "../../ports/yaml.ts";
10
+
11
+ export const parseYaml: ParseYaml = (text) => {
12
+ try {
13
+ return ok(load(text));
14
+ } catch (e) {
15
+ return err(messageOf(e).split("\n", 1).join(""));
16
+ }
17
+ };
package/src/cli.test.ts CHANGED
@@ -912,3 +912,46 @@ test("submission show: through the HotCRP adapter to a local portal, the token i
912
912
  });
913
913
  }
914
914
  });
915
+
916
+ test("versions: migrate moves an old-layout paper on disk into folders per attempt; show then prints its state", async () => {
917
+ const dir = join(root, "versions-migrate");
918
+ writeTree(dir, {
919
+ "package.json": "{}",
920
+ "papers/p/paperlint.json": JSON.stringify({
921
+ cycles: [
922
+ {
923
+ id: "open-2027",
924
+ venue: { kind: "preset", extends: "paperlint:acm-sigconf" },
925
+ opened: "2026-09-09",
926
+ submission: { id: 7 },
927
+ },
928
+ ],
929
+ }),
930
+ "papers/p/paper.tex": "A.\nB.\n",
931
+ "papers/p/PIPELINE-STATUS.md":
932
+ "---\nstages:\n - stage: submitted\n cycle: open-2027\n date: 2026-10-23\n pdf: versions/2026-10-23-submitted.pdf\n bytes: 4\n source: versions/2026-10-23-submitted.tex\n---\n# Status\n",
933
+ "papers/p/versions/2026-10-23-submitted.pdf": "%PDF",
934
+ "papers/p/versions/2026-10-23-submitted.tex": "A.\n",
935
+ });
936
+ const m = await cli(["versions", "migrate", "papers/p"], dir);
937
+ assert.equal(m.code, 0, m.err);
938
+ assert.ok(existsSync(join(dir, "papers/p/versions/open-2027/submitted.pdf")));
939
+ assert.ok(
940
+ !existsSync(join(dir, "papers/p/versions/2026-10-23-submitted.pdf")),
941
+ );
942
+ const s = await cli(["versions", "papers/p"], dir);
943
+ assert.equal(s.code, 0, s.err);
944
+ assert.equal(
945
+ s.out,
946
+ [
947
+ " open-2027 submitted CURRENT (under review at acm-sigconf) 4 B frozen 2026-10-23",
948
+ " paper.tex draft target not chosen differs from open-2027/submitted.tex: 1 line added, 0 removed",
949
+ ].join("\n"),
950
+ );
951
+ const l = await cli(["versions", "lock", "papers/p"], dir);
952
+ assert.equal(l.code, 0, l.err);
953
+ assert.equal(
954
+ l.out,
955
+ "versions/lock.json records every frozen file as it stands",
956
+ );
957
+ });
package/src/cli.ts CHANGED
@@ -58,6 +58,8 @@ import {
58
58
  nodeAdapters,
59
59
  nodeFiles,
60
60
  nodeListDir,
61
+ nodeCopyFile,
62
+ nodeMoveFile,
61
63
  } from "./adapters/node/index.ts";
62
64
  import { probeVideo } from "./adapters/mp4box/index.ts";
63
65
  import { imageSize } from "./adapters/png/index.ts";
@@ -173,8 +175,16 @@ export { nextSteps } from "./init.ts";
173
175
 
174
176
  import paperStages from "#eslint-rules/paper-stages";
175
177
  import preregCoverage from "#eslint-rules/prereg-coverage";
176
- import cycleStage from "#eslint-rules/cycle-stage";
177
178
  import { CYCLE_RULE_LEVELS, cycleRules } from "./cycle-rules.ts";
179
+ import { runVersions } from "./versions-command.ts";
180
+ import { parseYaml } from "./adapters/yaml/index.ts";
181
+ import {
182
+ DRAFT_RULE_LEVELS,
183
+ draftRules,
184
+ submittedFrozenRules,
185
+ VERSION_STATUS_RULE_LEVELS,
186
+ versionLayoutRules,
187
+ } from "./version-rules.ts";
178
188
  import {
179
189
  FOLDER_VENUE_RULE_LEVELS,
180
190
  folderVenueRules,
@@ -260,6 +270,14 @@ const USAGE = `paperlint — machine-checkable gates for a paper kept in git
260
270
  npx paperlint submission topics [paper]
261
271
  the topics the venue's portal offers, against the ones its
262
272
  preset records; exit 1 when they differ
273
+ npx paperlint versions [show|lock|migrate] [paper]
274
+ the paper's frozen versions, one folder per attempt:
275
+ versions/<cycle>/<stage>.{pdf,tex}. show (the default): each
276
+ version and its state — under review, historical, camera-ready
277
+ base, final — and how far paper.tex has moved. lock: record the
278
+ files in versions/lock.json (submission --save does it for what
279
+ it sends). migrate: move the stages: list's dated files into
280
+ their cycles' folders, write the lock, drop the list
263
281
  npx paperlint studies new <paper-dir> "<title>" [--kind <kind>]
264
282
  register a study: the next id in repro/studies.json, the folder
265
283
  repro/<id>-<slug>/ with its README.md and plan.json (a template),
@@ -402,7 +420,7 @@ const REGISTER_AND_CASE_RULES = {
402
420
 
403
421
  /**
404
422
  * The paper's attempts (`cycles` in paperlint.json), judged on `PIPELINE-STATUS.md`: the record itself
405
- * and its evidence (src/cycle-rules.ts), and the stages that name a cycle (eslint-rules/cycle-stage.ts).
423
+ * and its evidence (src/cycle-rules.ts), and that each version sent was frozen (src/version-rules.ts).
406
424
  * The `cycle` plugin's other rule, `port-done`, reads `paper.tex` (src/tex-venue-rules.ts).
407
425
  */
408
426
  /** The markdown language with YAML frontmatter: what every markdown block of the config lints as. */
@@ -411,9 +429,27 @@ const MARKDOWN_WITH_FRONTMATTER = {
411
429
  languageOptions: { frontmatter: "yaml" },
412
430
  };
413
431
 
432
+ /** The instant a freeze or a lock records, ISO 8601 (versions/lock.json). */
433
+ const isoNow = (): string => new Date().toISOString();
434
+
414
435
  const CYCLE_STATUS_RULES = {
415
436
  ...cycleRules({ files: nodeFiles, venuesDir: presetsDir() }),
416
- ...cycleStage.rules,
437
+ ...submittedFrozenRules({ files: nodeFiles, listDir: nodeListDir }),
438
+ };
439
+
440
+ /** The `cycle` plugin's rules on `paper.tex`: the port declared done, the draft against its frozen source. */
441
+ const CYCLE_PAPER_RULES = {
442
+ ...cyclePortRules(TEX_RULE_DEPS),
443
+ ...draftRules(TEX_RULE_DEPS),
444
+ };
445
+ const CYCLE_PAPER_RULE_LEVELS = {
446
+ ...CYCLE_PORT_RULE_LEVELS,
447
+ ...DRAFT_RULE_LEVELS,
448
+ };
449
+ /** The levels of the `cycle` and `versions` rules on `PIPELINE-STATUS.md`. */
450
+ const STATUS_CYCLE_RULE_LEVELS = {
451
+ ...CYCLE_RULE_LEVELS,
452
+ ...VERSION_STATUS_RULE_LEVELS,
417
453
  };
418
454
 
419
455
  /** The rules of the files `paper.tex` includes, each read on its own as a fragment (#144). */
@@ -539,6 +575,7 @@ export function buildConfig(
539
575
  // Registered, on for no paper: `"study/declare-sample": "error"` in paperlint.json turns it on.
540
576
  study: { rules: preregCoverage.rules },
541
577
  cycle: { rules: CYCLE_STATUS_RULES },
578
+ versions: { rules: versionLayoutRules(LAYOUT_RULE_DEPS) },
542
579
  },
543
580
  ...md,
544
581
  rules: {
@@ -547,7 +584,7 @@ export function buildConfig(
547
584
  ...FOLDER_VENUE_RULE_LEVELS,
548
585
  ...FOLDER_LAYOUT_RULE_LEVELS,
549
586
  ...REPRO_RULE_LEVELS,
550
- ...CYCLE_RULE_LEVELS,
587
+ ...STATUS_CYCLE_RULE_LEVELS,
551
588
  },
552
589
  },
553
590
  {
@@ -594,7 +631,7 @@ export function buildConfig(
594
631
  ...pageLimitRules({ files: nodeFiles, venuesDir: presetsDir() }),
595
632
  },
596
633
  },
597
- cycle: { rules: cyclePortRules(TEX_RULE_DEPS) },
634
+ cycle: { rules: CYCLE_PAPER_RULES },
598
635
  },
599
636
  language: "tex/latex",
600
637
  rules: {
@@ -618,7 +655,7 @@ export function buildConfig(
618
655
  ...TEX_VENUE_RULE_LEVELS,
619
656
  ...FORMAT_RULE_LEVELS,
620
657
  ...PAGE_LIMIT_RULE_LEVELS,
621
- ...CYCLE_PORT_RULE_LEVELS,
658
+ ...CYCLE_PAPER_RULE_LEVELS,
622
659
  // Silent for a paper that declares no `talk` (src/talk-rules.ts), but talk/undeclared.
623
660
  ...TALK_RULE_LEVELS,
624
661
  },
@@ -1803,6 +1840,20 @@ const SIMPLE: Readonly<
1803
1840
  err,
1804
1841
  },
1805
1842
  ),
1843
+ versions: (a, { log, err, cwd }) =>
1844
+ runVersions(a.paths, {
1845
+ files: nodeFiles,
1846
+ listDir: nodeListDir,
1847
+ move: nodeMoveFile,
1848
+ copy: nodeCopyFile,
1849
+ yaml: parseYaml,
1850
+ latex: latexReader,
1851
+ codec: sourcesCodec,
1852
+ now: isoNow,
1853
+ cwd,
1854
+ log,
1855
+ err,
1856
+ }),
1806
1857
  submission: (a, { log, err, cwd }) =>
1807
1858
  runSubmission(
1808
1859
  {
@@ -1823,6 +1874,7 @@ const SIMPLE: Readonly<
1823
1874
  latex: latexReader,
1824
1875
  codec: sourcesCodec,
1825
1876
  lintText,
1877
+ now: isoNow,
1826
1878
  log,
1827
1879
  err,
1828
1880
  },
@@ -62,7 +62,7 @@ const STATUS_FILE = "PIPELINE-STATUS.md";
62
62
  export type CycleRuleName = "record" | "evidence";
63
63
 
64
64
  /** The `cycles` value of the paper's settings file; undefined when there is no file, no JSON, or no key. */
65
- function cyclesValue(dir: string, deps: VenueRuleDeps): unknown {
65
+ function cyclesValue(dir: string, deps: Pick<VenueRuleDeps, "files">): unknown {
66
66
  const bytes = deps.files.readBytes(callerPath(join(dir, CONFIG_FILE)));
67
67
  if (bytes === null) return undefined;
68
68
  try {
@@ -75,7 +75,7 @@ function cyclesValue(dir: string, deps: VenueRuleDeps): unknown {
75
75
  /** The paper's cycles, parsed and consistent, or the one line that says what is wrong. */
76
76
  export function readCycles(
77
77
  dir: string,
78
- deps: VenueRuleDeps,
78
+ deps: Pick<VenueRuleDeps, "files">,
79
79
  ): Result<Cycles | null, string> {
80
80
  const list = parseCycles(cyclesValue(dir, deps));
81
81
  if (!list.ok) return list;
@@ -266,11 +266,10 @@ export function cycleRules(
266
266
  return { record: rule("record", deps), evidence: rule("evidence", deps) };
267
267
  }
268
268
 
269
- /** The level each `cycle/*` rule on PIPELINE-STATUS.md is on at in paperlint's own config — `stage` too (eslint-rules/cycle-stage.ts). */
269
+ /** The level each `cycle/*` rule of this file is on at in paperlint's own config. */
270
270
  export const CYCLE_RULE_LEVELS: Readonly<
271
- Record<`cycle/${CycleRuleName | "stage"}`, "error">
271
+ Record<`cycle/${CycleRuleName}`, "error">
272
272
  > = {
273
273
  "cycle/record": "error",
274
274
  "cycle/evidence": "error",
275
- "cycle/stage": "error",
276
275
  };
@@ -7,6 +7,7 @@ import { present } from "../../test/support.ts";
7
7
  import {
8
8
  currentCycle,
9
9
  currentDeadlines,
10
+ cycleId,
10
11
  cycleProblemText,
11
12
  effectiveBlind,
12
13
  parseCycles,
@@ -321,6 +322,25 @@ describe("parseCycles refuses a value of the wrong shape, naming the entry and t
321
322
  [{ ...OPEN, kind: 5 }],
322
323
  /"kind" must be a string or null/,
323
324
  ],
325
+ [
326
+ "an id with a slash: it names a folder under versions/, one path segment",
327
+ [{ ...OPEN, id: "conf/2026" }],
328
+ /^cycles\[0\]: "id" «conf\/2026» is not one folder name — an id is lowercase letters, digits and hyphens, starting with a letter or a digit \(conf-2026\): it names the attempt's folder, versions\/<id>\/$/,
329
+ ],
330
+ [
331
+ "an id that climbs out of versions/",
332
+ [{ ...OPEN, id: ".." }],
333
+ /^cycles\[0\]: "id" «\.\.» is not one folder name/,
334
+ ],
335
+ [
336
+ "an id with a capital, a backslash or a dot",
337
+ [
338
+ { ...OPEN, id: "Conf-2026" },
339
+ { ...OPEN, id: "a\\b" },
340
+ { ...OPEN, id: "." },
341
+ ],
342
+ /^cycles\[0\]: "id" «Conf-2026» is not one folder name/,
343
+ ],
324
344
  [
325
345
  "two broken cycles: the first is named, the second never read",
326
346
  [{}, {}],
@@ -329,6 +349,13 @@ describe("parseCycles refuses a value of the wrong shape, naming the entry and t
329
349
  ])("refuses %s", refuses);
330
350
  });
331
351
 
352
+ describe("cycleId", () => {
353
+ it("mints an id that is one folder name, and throws on one that is not", () => {
354
+ expect(cycleId("msr-2027")).toBe("msr-2027");
355
+ expect(() => cycleId("conf/2026")).toThrow("not a cycle id: conf/2026");
356
+ });
357
+ });
358
+
332
359
  describe("currentCycle", () => {
333
360
  it("no cycles: none; the last cycle open: that cycle; the last cycle closed: parked on it", () => {
334
361
  expect(currentCycle([])).toEqual({ ok: true, value: { kind: "none" } });
@@ -37,6 +37,7 @@
37
37
  *
38
38
  * Parsed by hand: the domain imports no schema library (see `src/CLAUDE.md`).
39
39
  */
40
+ import type { Opaque } from "ts-essentials";
40
41
  import { err, ok, type Result } from "./result.ts";
41
42
  import { fieldOf, isRecord } from "./record.ts";
42
43
  import {
@@ -81,9 +82,30 @@ export const OUTCOME_KINDS: readonly Outcome["kind"][] = [
81
82
 
82
83
  export type Phase = "porting" | "prepared";
83
84
 
85
+ /**
86
+ * A cycle's id: the name of the attempt's folder, `versions/<id>/`, and the first part of its keys in
87
+ * `versions/lock.json`. Minted only by `isCycleId`, so every freeze, migration, lock and layout check
88
+ * holds an id that is one path segment: no `/` or `\`, never `.` or `..`, never empty. The charset is
89
+ * kept small on purpose — lowercase letters, digits and hyphens, starting with a letter or a digit —
90
+ * so the name means the same folder on every filesystem, case-insensitive ones included.
91
+ */
92
+ export type CycleId = Opaque<string, "CycleId">;
93
+
94
+ /** The id's one owner: `parseLock` builds its key pattern from this too. */
95
+ export const CYCLE_ID_PATTERN = "[a-z0-9][a-z0-9-]*";
96
+ const CYCLE_ID = new RegExp(`^${CYCLE_ID_PATTERN}$`);
97
+
98
+ export const isCycleId = (s: string): s is CycleId => CYCLE_ID.test(s);
99
+
100
+ /** An id the caller holds as text (a test, a literal), checked where it becomes one; a bad one throws. */
101
+ export function cycleId(s: string): CycleId {
102
+ if (!isCycleId(s)) throw new Error(`not a cycle id: ${s}`);
103
+ return s;
104
+ }
105
+
84
106
  export interface Cycle {
85
- /** What a stage refers to (`stages[].cycle`): unique in the list. */
86
- readonly id: string;
107
+ /** What a stage refers to (`stages[].cycle`), and the attempt's folder: unique in the list. */
108
+ readonly id: CycleId;
87
109
  readonly venue: CycleVenue;
88
110
  /** A key of the preset's `format.kinds`; null when the venue has one kind or none is chosen. */
89
111
  readonly kind: string | null;
@@ -262,13 +284,23 @@ const kindOf = (v: unknown): Result<string | null, string> =>
262
284
  ? ok(v)
263
285
  : err('"kind" must be a string or null');
264
286
 
287
+ /** A cycle's id, checked as the folder name it becomes. */
288
+ function parseId(id: unknown, at: string): Result<CycleId, string> {
289
+ if (typeof id !== "string" || id === "")
290
+ return err(`${at}: "id" must be a non-empty string (what stages refer to)`);
291
+ return isCycleId(id)
292
+ ? ok(id)
293
+ : err(
294
+ `${at}: "id" «${id}» is not one folder name — an id is lowercase letters, digits and hyphens, starting with a letter or a digit (conf-2026): it names the attempt's folder, versions/<id>/`,
295
+ );
296
+ }
297
+
265
298
  function parseHead(
266
299
  v: Readonly<Record<string, unknown>>,
267
300
  at: string,
268
301
  ): Result<Head, string> {
269
- const id = v["id"];
270
- if (typeof id !== "string" || id === "")
271
- return err(`${at}: "id" must be a non-empty string (what stages refer to)`);
302
+ const id = parseId(v["id"], at);
303
+ if (!id.ok) return id;
272
304
  const venue = parseVenue(v["venue"], at);
273
305
  if (!venue.ok) return venue;
274
306
  const kind = kindOf(v["kind"]);
@@ -282,7 +314,7 @@ function parseHead(
282
314
  if (!isPhase(phase))
283
315
  return err(`${at}: "phase" must be "porting" or "prepared"`);
284
316
  return ok({
285
- id,
317
+ id: id.value,
286
318
  venue: venue.value,
287
319
  kind: kind.value,
288
320
  opened,
@@ -14,7 +14,7 @@
14
14
  * - what `paperlint new` scaffolds: `PIPELINE-STATUS.md`, `paper.tex`, `paperlint.json`;
15
15
  * - what the build writes: `paper.pdf` and `_build/`, and TeX's own byproducts beside `paper.tex`
16
16
  * (pdflatex runs in the paper folder without `-output-directory`), which are never reported;
17
- * - what the rules read: `versions/` (`paper/stages`), `reviews/` (`review/frontmatter`),
17
+ * - what the rules read: `versions/` (`versions/layout`, `paper/stages`), `reviews/` (`review/frontmatter`),
18
18
  * `siblings/` (`sibling/frontmatter`), `talk/` (the `talk/` rules' default folder);
19
19
  * - what the skills read or write at the root: `README.md`, `CLAUDE.md`, `CLAIMS.md`, `NOTES.md`,
20
20
  * `SUBMIT-CHECKLIST.md`, `research/`, `repro/`, `figures/`, `submission/`;
@@ -93,7 +93,10 @@ export const ROOT_FILES: readonly string[] = ROOT_FILE_NOTES.map((f) => f.name);
93
93
  /** The folders the root may hold, by exact name, each with what it holds. */
94
94
  export const ROOT_DIR_NOTES: readonly Noted[] = [
95
95
  { name: "_build", note: "the facts `paperlint build` writes" },
96
- { name: "versions", note: "each stage's frozen PDF and source" },
96
+ {
97
+ name: "versions",
98
+ note: "each stage's frozen PDF and source — per attempt, versions/<cycle>/, with lock.json",
99
+ },
97
100
  { name: "reviews", note: "dated reviews and pass reports" },
98
101
  { name: "siblings", note: "the cards of competing papers" },
99
102
  { name: "research", note: "dated research and decision records" },
@@ -1,6 +1,6 @@
1
1
  import assert from "node:assert/strict";
2
2
  import { test } from "vitest";
3
- import { absolutePath, joinPath } from "./paths.ts";
3
+ import { absolutePath, asPaperPath, joinPath, paperPath } from "./paths.ts";
4
4
 
5
5
  test("absolutePath keeps an absolute path as it is", () => {
6
6
  assert.equal(absolutePath("/a/b"), "/a/b");
@@ -13,3 +13,30 @@ test("absolutePath refuses a relative path instead of letting the process's cwd
13
13
  test("joinPath stays under its absolute base", () => {
14
14
  assert.equal(joinPath(absolutePath("/a"), "b", "c"), "/a/b/c");
15
15
  });
16
+
17
+ test("paperPath: one spelling per file of the paper, and nothing outside it", () => {
18
+ assert.deepEqual(paperPath("./versions/a.tex"), {
19
+ ok: true,
20
+ value: "versions/a.tex",
21
+ });
22
+ assert.deepEqual(paperPath("versions//b/../a.tex"), {
23
+ ok: true,
24
+ value: "versions/a.tex",
25
+ });
26
+ for (const bad of ["../x.tex", "versions/../../x", "/abs/x", "", ".", "a\\b"])
27
+ assert.deepEqual(paperPath(bad), {
28
+ ok: false,
29
+ error: `\`${bad}\` is not a path inside the paper folder`,
30
+ });
31
+ });
32
+
33
+ test("asPaperPath keeps a composed path, and throws on one that is not one spelling inside the paper", () => {
34
+ assert.equal(
35
+ asPaperPath("versions/c/submitted.pdf"),
36
+ "versions/c/submitted.pdf",
37
+ );
38
+ assert.throws(
39
+ () => asPaperPath("versions/./x"),
40
+ /not a path inside the paper folder/,
41
+ );
42
+ });
@@ -1,6 +1,7 @@
1
1
  /** Paths as the domain holds them. */
2
- import { isAbsolute, join } from "node:path";
2
+ import { isAbsolute, join, posix } from "node:path";
3
3
  import type { Opaque } from "ts-essentials";
4
+ import { err, ok, type Result } from "./result.ts";
4
5
 
5
6
  /**
6
7
  * An absolute path. Minted by `absolutePath` (a checked string) or `joinPath` (an absolute base and
@@ -22,3 +23,35 @@ export const joinPath = (
22
23
  base: AbsolutePath,
23
24
  ...segments: string[]
24
25
  ): AbsolutePath => absolutePath(join(base, ...segments));
26
+
27
+ /**
28
+ * A file of a paper, by its path relative to the paper folder, in one spelling: posix-normalized, no
29
+ * leading `./`, never absolute, never out of the folder (`..`), never the folder itself. Two
30
+ * spellings of one file (`./versions/a.tex`, `versions/a.tex`) are then one value, so comparing,
31
+ * grouping and checking paths is comparing strings.
32
+ */
33
+ export type PaperPath = Opaque<string, "PaperPath">;
34
+
35
+ // The brand's one owner, as for AbsolutePath: the predicate is the check.
36
+ const isPaperPath = (p: string): p is PaperPath =>
37
+ p !== "." &&
38
+ p !== ".." &&
39
+ !p.startsWith("../") &&
40
+ !isAbsolute(p) &&
41
+ !p.includes("\\") &&
42
+ posix.normalize(p) === p;
43
+
44
+ /** A path as a paper's record spells it, normalized; or why it names no file inside the paper. */
45
+ export function paperPath(raw: string): Result<PaperPath, string> {
46
+ const p = posix.normalize(raw);
47
+ return isPaperPath(p)
48
+ ? ok(p)
49
+ : err(`\`${raw}\` is not a path inside the paper folder`);
50
+ }
51
+
52
+ /** A path the code composes from parts it already holds (`versions/<key>`); one that is not throws. */
53
+ export function asPaperPath(p: string): PaperPath {
54
+ if (!isPaperPath(p))
55
+ throw new Error(`not a path inside the paper folder: ${p}`);
56
+ return p;
57
+ }
@@ -16,7 +16,7 @@
16
16
  * Git keeps files, not folders: an empty folder is not in the first commit, which is the point of
17
17
  * scaffolding. A folder that holds nothing else gets a `.gitkeep`; a folder that holds a real file
18
18
  * (`repro/` with its README.md) gets none. `.gitkeep` is the convention, not a paperlint file, and no
19
- * rule reads it: `paper/stages` looks for `*.pdf` in `versions/`, the review and sibling rules lint
19
+ * rule reads it: `versions/layout` and `paper/stages` look for `*.pdf` in `versions/`, the review and sibling rules lint
20
20
  * `*.md`, `repro/allowed-files-only` judges the top of `repro/` only, and neither `tools/` nor `data/` is
21
21
  * listed by any rule.
22
22
  *