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
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
|
|
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
|
-
...
|
|
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
|
-
...
|
|
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:
|
|
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
|
-
...
|
|
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
|
},
|
package/src/cycle-rules.ts
CHANGED
|
@@ -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
|
|
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
|
|
271
|
+
Record<`cycle/${CycleRuleName}`, "error">
|
|
272
272
|
> = {
|
|
273
273
|
"cycle/record": "error",
|
|
274
274
|
"cycle/evidence": "error",
|
|
275
|
-
"cycle/stage": "error",
|
|
276
275
|
};
|
package/src/domain/cycle.test.ts
CHANGED
|
@@ -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" } });
|
package/src/domain/cycle.ts
CHANGED
|
@@ -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:
|
|
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 (
|
|
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
|
-
{
|
|
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" },
|
package/src/domain/paths.test.ts
CHANGED
|
@@ -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
|
+
});
|
package/src/domain/paths.ts
CHANGED
|
@@ -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`
|
|
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
|
*
|