paperlint 9.0.0 → 10.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/.github/CLAUDE.md +22 -0
- package/.github/CLAUDE.md.spec.ts +20 -0
- package/CLAUDE.md +59 -515
- package/CONTRIBUTING.md +2 -2
- package/README.md +68 -128
- package/dist/CLAUDE.md.spec.d.ts +3 -0
- package/dist/CLAUDE.md.spec.d.ts.map +1 -0
- package/dist/CLAUDE.md.spec.js +62 -0
- package/dist/CLAUDE.md.spec.js.map +1 -0
- package/dist/build.d.ts +1 -1
- package/dist/build.js +2 -2
- package/dist/cli.d.ts +5 -1
- package/dist/cli.d.ts.map +1 -1
- package/dist/cli.js +52 -6
- package/dist/cli.js.map +1 -1
- package/dist/domain/layout-tree.d.ts +19 -0
- package/dist/domain/layout-tree.d.ts.map +1 -0
- package/dist/domain/layout-tree.js +32 -0
- package/dist/domain/layout-tree.js.map +1 -0
- package/dist/domain/lookup-cache.d.ts +2 -2
- package/dist/domain/lookup-cache.js +2 -2
- package/dist/domain/paper-layout.d.ts +81 -0
- package/dist/domain/paper-layout.d.ts.map +1 -0
- package/dist/domain/paper-layout.js +259 -0
- package/dist/domain/paper-layout.js.map +1 -0
- package/dist/domain/repro-layout.d.ts +80 -0
- package/dist/domain/repro-layout.d.ts.map +1 -0
- package/dist/domain/repro-layout.js +247 -0
- package/dist/domain/repro-layout.js.map +1 -0
- package/dist/domain/scaffold-layout.d.ts +28 -0
- package/dist/domain/scaffold-layout.d.ts.map +1 -0
- package/dist/domain/scaffold-layout.js +81 -0
- package/dist/domain/scaffold-layout.js.map +1 -0
- package/dist/eslint-rules/CLAUDE.md.spec.d.ts +3 -0
- package/dist/eslint-rules/CLAUDE.md.spec.d.ts.map +1 -0
- package/dist/eslint-rules/CLAUDE.md.spec.js +39 -0
- package/dist/eslint-rules/CLAUDE.md.spec.js.map +1 -0
- package/dist/eslint-rules/install-path-literals.d.ts +1 -1
- package/dist/eslint-rules/install-path-literals.js +4 -4
- package/dist/eslint-rules/install-path-literals.js.map +1 -1
- package/dist/eslint-rules/pdf-last-page-balance.js +1 -1
- package/dist/eslint-rules/prereg-coverage.d.ts +98 -0
- package/dist/eslint-rules/prereg-coverage.d.ts.map +1 -0
- package/dist/eslint-rules/prereg-coverage.js +274 -0
- package/dist/eslint-rules/prereg-coverage.js.map +1 -0
- package/dist/folder-layout-hook.d.ts +21 -0
- package/dist/folder-layout-hook.d.ts.map +1 -0
- package/dist/folder-layout-hook.js +117 -0
- package/dist/folder-layout-hook.js.map +1 -0
- package/dist/folder-layout-rule.d.ts +22 -0
- package/dist/folder-layout-rule.d.ts.map +1 -0
- package/dist/folder-layout-rule.js +107 -0
- package/dist/folder-layout-rule.js.map +1 -0
- package/dist/init.js +1 -1
- package/dist/init.js.map +1 -1
- package/dist/lib/agent-cli-version.js +1 -1
- package/dist/lib/agent-cli-version.js.map +1 -1
- package/dist/new-paper.d.ts +4 -1
- package/dist/new-paper.d.ts.map +1 -1
- package/dist/new-paper.js +40 -6
- package/dist/new-paper.js.map +1 -1
- package/dist/references.d.ts +1 -1
- package/dist/references.d.ts.map +1 -1
- package/dist/references.js +2 -2
- package/dist/references.js.map +1 -1
- package/dist/repro-layout-rule.d.ts +16 -0
- package/dist/repro-layout-rule.d.ts.map +1 -0
- package/dist/repro-layout-rule.js +81 -0
- package/dist/repro-layout-rule.js.map +1 -0
- package/dist/tex-requirements.js +1 -1
- package/dist/toolchain.js +1 -1
- package/docs/ci.md +60 -0
- package/docs/configuration.md +2 -0
- package/docs/e2e.md +3 -3
- package/docs/incidents.md +5 -5
- package/docs/install.md +20 -16
- package/docs/layout.md +154 -0
- package/docs/optional-rules.md +5 -4
- package/docs/references.md +5 -2
- package/docs/rules/paper/allowed-files-only.md +192 -0
- package/docs/rules/repro/allowed-files-only.md +128 -0
- package/docs/rules/study/declare-sample.md +146 -0
- package/docs/rules.md +53 -50
- package/docs/skills.md +6 -3
- package/docs/venues.md +94 -0
- package/eslint-rules/CLAUDE.md +44 -0
- package/eslint-rules/CLAUDE.md.spec.ts +43 -0
- package/eslint-rules/install-path-literals.harness.mjs +1 -1
- package/eslint-rules/install-path-literals.ts +4 -4
- package/eslint-rules/pdf-last-page-balance.ts +1 -1
- package/eslint-rules/prereg-coverage.test.ts +448 -0
- package/eslint-rules/prereg-coverage.ts +419 -0
- package/eslint.config.mjs +6 -3
- package/hooks/CLAUDE.md +34 -0
- package/hooks/CLAUDE.md.spec.ts +30 -0
- package/lib/agent-cli-version.ts +1 -1
- package/lib/skill-trigger-cases.harness.mjs +51 -5
- package/lib/skill-trigger-cases.mjs +53 -15
- package/package.json +2 -2
- package/plugin/CLAUDE.md +22 -0
- package/plugin/CLAUDE.md.spec.ts +22 -0
- package/plugin/hooks/hooks.json +9 -0
- package/scripts/coverage-config.test.ts +2 -1
- package/skills/CLAUDE.md +19 -0
- package/skills/CLAUDE.md.spec.ts +18 -0
- package/skills/analyze-sibling-paper/SKILL.md +2 -2
- package/skills/analyze-sibling-paper/SKILL.md.spec.ts +1 -1
- package/skills/build-benchmark/SKILL.md +14 -1
- package/skills/build-benchmark/SKILL.md.spec.ts +13 -0
- package/skills/build-label-page/SKILL.md +118 -0
- package/skills/build-label-page/SKILL.md.spec.ts +129 -0
- package/skills/build-label-page/build-label-page.eval.mjs +15 -0
- package/skills/build-label-page/build-label-page.test.ts +959 -0
- package/skills/build-label-page/references/checklist.md +148 -0
- package/skills/build-label-page/references/example-generator.py +803 -0
- package/skills/find-venue/SKILL.md +5 -3
- package/skills/find-venue/SKILL.md.spec.ts +4 -2
- package/skills/grade-paper-writing/SKILL.md +7 -7
- package/skills/grade-paper-writing/SKILL.md.spec.ts +6 -6
- package/skills/map-prior-work/SKILL.md +2 -2
- package/skills/map-prior-work/SKILL.md.spec.ts +1 -1
- package/skills/paper-pipeline/SKILL.md +8 -6
- package/skills/paper-pipeline/SKILL.md.spec.ts +7 -5
- package/skills/paper-pipeline/grade-paper-writing-ablation.eval.mjs +19 -9
- package/skills/paper-pipeline/references/acceptance-gate.md +19 -19
- package/skills/paper-pipeline/references/artifact-checklist.md +5 -0
- package/skills/paper-pipeline/references/pipeline-status-template.md +30 -1
- package/skills/paper-pipeline/repro/2026-08-07-fork-check.log +1 -1
- package/skills/paper-pipeline/repro/2026-08-07-fork-check2.log +1 -1
- package/skills/paper-pipeline/repro/2026-08-07-language-eval-pilot.log +1 -1
- package/skills/paper-pipeline/repro/2026-08-07-language-eval-raw.log +1 -1
- package/skills/paper-pipeline/repro/2026-08-08-framing-vs-vocabulary-oracle.log +48 -48
- package/skills/paper-pipeline/repro/2026-08-08-grade-paper-writing-ablation-A6-oracle.log +8 -8
- package/skills/paper-pipeline/repro/2026-08-08-grade-paper-writing-ablation-oracle.log +48 -48
- package/skills/paper-pipeline/repro/2026-08-08-parent-replication-gpw.log +2 -2
- package/skills/paper-pipeline/repro/2026-08-08-parent-replication.mjs +3 -3
- package/skills/paper-pipeline/repro/2026-10-13-grade-paper-writing-ablation-preflight-after.log +81 -0
- package/skills/paper-pipeline/repro/2026-10-13-grade-paper-writing-ablation-preflight-before.log +81 -0
- package/skills/paper-pipeline/repro/2026-10-13-grade-paper-writing-ablation-setupdiff.log +59 -0
- package/skills/paper-pipeline/repro/2026-10-13-grade-paper-writing-trigger.jsonl +1 -0
- package/skills/paper-pipeline/repro/2026-10-13-grade-paper-writing-trigger.log +22 -0
- package/skills/paper-pipeline/repro/README.md +4 -0
- package/skills/paper-pipeline/scripts/extract-ref-facts.harness.mjs +2 -2
- package/skills/paper-pipeline/scripts/extract-ref-facts.mjs +2 -2
- package/skills/paper-pipeline/scripts/extract-ref-facts.test.mjs +4 -2
- package/skills/paper-pipeline/scripts/generated-code.harness.mjs +7 -2
- package/skills/paper-pipeline/scripts/generated-code.mjs +8 -4
- package/skills/paper-pipeline/scripts/generated-code.test.ts +15 -1
- package/skills/paper-status/SKILL.md +2 -2
- package/skills/paper-status/SKILL.md.spec.ts +1 -1
- package/skills/plan-paper-timeline/plan-paper-timeline.effects.harness.mjs +21 -102
- package/skills/study-accepted-papers/SKILL.md +163 -161
- package/skills/study-accepted-papers/SKILL.md.spec.ts +167 -165
- package/skills/study-accepted-papers/references/base-rates.md +69 -0
- package/skills/study-accepted-papers/references/mechanisms.md +181 -0
- package/skills/verify-citations/SKILL.md +2 -2
- package/skills/verify-citations/SKILL.md.spec.ts +1 -1
- package/src/CLAUDE.md +23 -1
- package/src/CLAUDE.md.spec.ts +72 -0
- package/src/build.ts +2 -2
- package/src/cli.harness.mjs +42 -0
- package/src/cli.test.ts +90 -6
- package/src/cli.ts +78 -5
- package/src/domain/layout-tree.test.ts +32 -0
- package/src/domain/layout-tree.ts +47 -0
- package/src/domain/lookup-cache.ts +2 -2
- package/src/domain/paper-layout.test.ts +264 -0
- package/src/domain/paper-layout.ts +336 -0
- package/src/domain/repro-layout.test.ts +222 -0
- package/src/domain/repro-layout.ts +311 -0
- package/src/domain/scaffold-layout.ts +104 -0
- package/src/folder-layout-hook.test.ts +214 -0
- package/src/folder-layout-hook.ts +141 -0
- package/src/folder-layout-rule.test.ts +235 -0
- package/src/folder-layout-rule.ts +151 -0
- package/src/hooks-settings.harness.ts +9 -8
- package/src/init.ts +1 -1
- package/src/layout-docs.test.ts +52 -0
- package/src/new-paper.harness.mjs +23 -1
- package/src/new-paper.test.ts +299 -6
- package/src/new-paper.ts +51 -5
- package/src/paper-files.test.ts +9 -6
- package/src/references.test.ts +5 -5
- package/src/references.ts +2 -2
- package/src/repro-layout-rule.test.ts +114 -0
- package/src/repro-layout-rule.ts +121 -0
- package/src/rule-cases.test.ts +98 -0
- package/src/tex-requirements.ts +1 -1
- package/src/toolchain.ts +1 -1
- package/templates/paper/PIPELINE-STATUS.md +41 -7
|
@@ -0,0 +1,336 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* THE PAPER FOLDER'S LAYOUT — what may stand at the root of a paper folder, and nothing else.
|
|
3
|
+
*
|
|
4
|
+
* A paper folder collects files the way a desk does: plans, mails, three drafts of the PDF, a corpus,
|
|
5
|
+
* an old attempt's checklist. Each was put there for a reason, and after a few months nobody can say
|
|
6
|
+
* which file is current, which folder holds the evidence, or where a new thing goes. A strict root is
|
|
7
|
+
* the answer every build tool gives (Cargo's `src/`, a Maven layout): the root holds a small, known
|
|
8
|
+
* set, every known kind of material has one folder, and ONE escape hatch, `extra/`, takes the rest
|
|
9
|
+
* without judging it.
|
|
10
|
+
*
|
|
11
|
+
* ── WHERE THE SET COMES FROM ─────────────────────────────────────────────────────
|
|
12
|
+
* Each entry is a file or folder paperlint itself writes or reads, or one its skills name at the
|
|
13
|
+
* paper root (each carries its note below, which the drawing of the layout shows):
|
|
14
|
+
* - what `paperlint new` scaffolds: `PIPELINE-STATUS.md`, `paper.tex`, `paperlint.json`;
|
|
15
|
+
* - what the build writes: `paper.pdf` and `_build/`, and TeX's own byproducts beside `paper.tex`
|
|
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`),
|
|
18
|
+
* `siblings/` (`sibling/frontmatter`), `talk/` (the `talk/` rules' default folder);
|
|
19
|
+
* - what the skills read or write at the root: `README.md`, `CLAUDE.md`, `CLAIMS.md`, `NOTES.md`,
|
|
20
|
+
* `SUBMIT-CHECKLIST.md`, `research/`, `repro/`, `figures/`, `submission/`;
|
|
21
|
+
* - where the paper's own body lives when it is split: `sections/` (README's `\input{sections/intro}`);
|
|
22
|
+
* - the files TeX and bibtex look up in the folder they run in: `*.tex`, `*.bib`, `*.cls`, `*.sty`,
|
|
23
|
+
* `*.bst`.
|
|
24
|
+
* A venue's corpus of accepted papers is NOT here: it is shared by every paper for that venue and
|
|
25
|
+
* lives outside paper folders, in `<papers>/venues/<venue>/`.
|
|
26
|
+
*
|
|
27
|
+
* ── WHAT THE PAPER DECLARES ──────────────────────────────────────────────────────
|
|
28
|
+
* A paper's settings may move three of these: `pdf` (the built PDF under another name), `talk.dir`
|
|
29
|
+
* (another talk folder) and a preset file of its own named by `extends`. What the settings name at the
|
|
30
|
+
* root is allowed beside the fixed set (`Declared.roots`); this module does not read settings.
|
|
31
|
+
*/
|
|
32
|
+
import { basename, extname } from "node:path";
|
|
33
|
+
import { leaf, renderTree, type TreeNode } from "./layout-tree.ts";
|
|
34
|
+
import { REPRO_DIR, REPRO_NODES } from "./repro-layout.ts";
|
|
35
|
+
|
|
36
|
+
/** One entry at the paper folder's root: its name, and whether it is a folder. */
|
|
37
|
+
export interface LayoutEntry {
|
|
38
|
+
readonly name: string;
|
|
39
|
+
readonly kind: "file" | "dir";
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** What the paper's own settings name at its root (`pdf`, `talk.dir`, a preset of its own). */
|
|
43
|
+
export interface Declared {
|
|
44
|
+
readonly roots: readonly string[];
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export const NOTHING_DECLARED: Declared = { roots: [] };
|
|
48
|
+
|
|
49
|
+
/** An entry that is not part of the layout, with where such material belongs. */
|
|
50
|
+
export interface Stray {
|
|
51
|
+
/** The entry's name; a folder's ends in a slash. */
|
|
52
|
+
readonly name: string;
|
|
53
|
+
readonly kind: "file" | "dir";
|
|
54
|
+
/** Where it goes: one destination for a known name, else the choice by what it is. */
|
|
55
|
+
readonly cure: string;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** The one folder whose content is not judged. */
|
|
59
|
+
export const ESCAPE_HATCH = "extra";
|
|
60
|
+
|
|
61
|
+
/** An allowed entry's name, with what it is for — the note the layout's drawing shows beside it. */
|
|
62
|
+
export interface Noted {
|
|
63
|
+
readonly name: string;
|
|
64
|
+
readonly note: string;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/** The files the root may hold, by exact name, each with what it is for. */
|
|
68
|
+
export const ROOT_FILE_NOTES: readonly Noted[] = [
|
|
69
|
+
{
|
|
70
|
+
name: "PIPELINE-STATUS.md",
|
|
71
|
+
note: "the paper's one live-state file: stages, plan, scorecard",
|
|
72
|
+
},
|
|
73
|
+
{ name: "paper.tex", note: "the source; `paperlint build` compiles it" },
|
|
74
|
+
{ name: "paperlint.json", note: "the paper's settings" },
|
|
75
|
+
{ name: "README.md", note: "what the paper is" },
|
|
76
|
+
{ name: "CLAUDE.md", note: "the paper's agent rules" },
|
|
77
|
+
{ name: "CLAIMS.md", note: "the claims ledger" },
|
|
78
|
+
{
|
|
79
|
+
name: "NOTES.md",
|
|
80
|
+
note: "each number in the paper → the data it comes from",
|
|
81
|
+
},
|
|
82
|
+
{
|
|
83
|
+
name: "SUBMIT-CHECKLIST.md",
|
|
84
|
+
note: "the venue's format and submission mechanics",
|
|
85
|
+
},
|
|
86
|
+
{ name: ".gitignore", note: "keeps the build's byproducts out of git" },
|
|
87
|
+
{ name: "paper.pdf", note: "the built PDF" },
|
|
88
|
+
];
|
|
89
|
+
|
|
90
|
+
/** The files the root may hold, by exact name. */
|
|
91
|
+
export const ROOT_FILES: readonly string[] = ROOT_FILE_NOTES.map((f) => f.name);
|
|
92
|
+
|
|
93
|
+
/** The folders the root may hold, by exact name, each with what it holds. */
|
|
94
|
+
export const ROOT_DIR_NOTES: readonly Noted[] = [
|
|
95
|
+
{ name: "_build", note: "the facts `paperlint build` writes" },
|
|
96
|
+
{ name: "versions", note: "each stage's frozen PDF and source" },
|
|
97
|
+
{ name: "reviews", note: "dated reviews and pass reports" },
|
|
98
|
+
{ name: "siblings", note: "the cards of competing papers" },
|
|
99
|
+
{ name: "research", note: "dated research and decision records" },
|
|
100
|
+
{ name: REPRO_DIR, note: "the reproduction artifact" },
|
|
101
|
+
{ name: "figures", note: "graphics and their sources" },
|
|
102
|
+
{ name: "sections", note: "the body, when it is split" },
|
|
103
|
+
{ name: "talk", note: "the talk's files" },
|
|
104
|
+
{
|
|
105
|
+
name: "submission",
|
|
106
|
+
note: "forms, portal records, mail with the venue, the anonymized upload",
|
|
107
|
+
},
|
|
108
|
+
{ name: ESCAPE_HATCH, note: "anything; never judged" },
|
|
109
|
+
];
|
|
110
|
+
|
|
111
|
+
/** The folders the root may hold, by exact name. */
|
|
112
|
+
export const ROOT_DIRS: readonly string[] = ROOT_DIR_NOTES.map((d) => d.name);
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* The files TeX and bibtex look up in the folder they run in, which is the paper's root: a fragment
|
|
116
|
+
* `\input` names, a database, a class, a package, a bibliography style.
|
|
117
|
+
*/
|
|
118
|
+
export const TEX_INPUT_EXTENSIONS: readonly string[] = [
|
|
119
|
+
".tex",
|
|
120
|
+
".bib",
|
|
121
|
+
".cls",
|
|
122
|
+
".sty",
|
|
123
|
+
".bst",
|
|
124
|
+
];
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* The extensions of what a TeX run writes next to its main file, named after the job. The list is
|
|
128
|
+
* the union of latexmk's clean lists (`@generated_exts`, `$clean_ext` and `$clean_full_ext` in the
|
|
129
|
+
* latexmk documentation, the files `latexmk -c` and `-C` delete) and the outputs of the tools
|
|
130
|
+
* `paperlint build` and a hand run use on the same job: pdflatex (`aux log out toc lof lot lol fls
|
|
131
|
+
* xdv dvi`, `synctex.gz` with `-synctex`), the algorithm packages' `\listofalgorithms` (`loa`),
|
|
132
|
+
* bibtex (`bbl blg`), makeindex (`idx ind ilg`), glossaries and nomencl (`glo gls glg acn acr alg ist
|
|
133
|
+
* nlo nls`), biber and biblatex (`bcf run.xml`), beamer (`nav snm vrb`), and latexmk's own
|
|
134
|
+
* `fdb_latexmk`. A missing extension is added here, not
|
|
135
|
+
* special-cased where the rule is judged.
|
|
136
|
+
*/
|
|
137
|
+
const BYPRODUCT_EXTENSIONS: readonly string[] = [
|
|
138
|
+
"aux",
|
|
139
|
+
"log",
|
|
140
|
+
"out",
|
|
141
|
+
"toc",
|
|
142
|
+
"lof",
|
|
143
|
+
"lot",
|
|
144
|
+
"lol",
|
|
145
|
+
"loa",
|
|
146
|
+
"fls",
|
|
147
|
+
"xdv",
|
|
148
|
+
"dvi",
|
|
149
|
+
"synctex.gz",
|
|
150
|
+
"bbl",
|
|
151
|
+
"blg",
|
|
152
|
+
"idx",
|
|
153
|
+
"ind",
|
|
154
|
+
"ilg",
|
|
155
|
+
"glo",
|
|
156
|
+
"gls",
|
|
157
|
+
"glg",
|
|
158
|
+
"acn",
|
|
159
|
+
"acr",
|
|
160
|
+
"alg",
|
|
161
|
+
"ist",
|
|
162
|
+
"nlo",
|
|
163
|
+
"nls",
|
|
164
|
+
"bcf",
|
|
165
|
+
"run.xml",
|
|
166
|
+
"nav",
|
|
167
|
+
"snm",
|
|
168
|
+
"vrb",
|
|
169
|
+
"fdb_latexmk",
|
|
170
|
+
];
|
|
171
|
+
|
|
172
|
+
/**
|
|
173
|
+
* What a `paperlint build` leaves beside `paper.tex`: pdflatex and bibtex run in the paper folder and
|
|
174
|
+
* name every output after the job, `paper`, so only `paper.<extension>` is the build's. Never
|
|
175
|
+
* reported — they are the build's, not the author's. Another job name (`draft.aux`) is the author's.
|
|
176
|
+
*/
|
|
177
|
+
export const BUILD_BYPRODUCTS: readonly string[] = BYPRODUCT_EXTENSIONS.map(
|
|
178
|
+
(ext) => `paper.${ext}`,
|
|
179
|
+
);
|
|
180
|
+
|
|
181
|
+
/** Files the operating system writes into any folder it shows. Never reported. */
|
|
182
|
+
export const OS_FILES: readonly string[] = [".DS_Store", "Thumbs.db"];
|
|
183
|
+
|
|
184
|
+
/** The live state of a paper — its stages, its plan, its scorecard — is one file. */
|
|
185
|
+
const STATUS_SECTIONS =
|
|
186
|
+
'a section of PIPELINE-STATUS.md — the paper\'s one live-state file holds its stages, its plan ("Plan / now") and its scorecard; move what is still live there and delete the file';
|
|
187
|
+
|
|
188
|
+
/** The shared corpus of a venue's accepted papers. */
|
|
189
|
+
const VENUE_CORPUS =
|
|
190
|
+
"<papers-root>/venues/<venue>/, outside the paper folder: one corpus per venue, shared by every paper that targets it";
|
|
191
|
+
|
|
192
|
+
/**
|
|
193
|
+
* Names met in real paper folders, each with its one destination. Matched ignoring case, a file's
|
|
194
|
+
* name against files and a folder's against folders.
|
|
195
|
+
*/
|
|
196
|
+
export const KNOWN_CURES: readonly {
|
|
197
|
+
readonly name: string;
|
|
198
|
+
readonly kind: "file" | "dir";
|
|
199
|
+
readonly cure: string;
|
|
200
|
+
}[] = [
|
|
201
|
+
{ name: "PLAN.md", kind: "file", cure: STATUS_SECTIONS },
|
|
202
|
+
{ name: "plans", kind: "dir", cure: STATUS_SECTIONS },
|
|
203
|
+
{ name: "DECISION-LOG.md", kind: "file", cure: "research/ (a dated record)" },
|
|
204
|
+
{
|
|
205
|
+
name: "EMAILS.md",
|
|
206
|
+
kind: "file",
|
|
207
|
+
cure: "submission/ (mail with the venue is about submitting)",
|
|
208
|
+
},
|
|
209
|
+
{
|
|
210
|
+
name: "data",
|
|
211
|
+
kind: "dir",
|
|
212
|
+
cure: "repro/data/ (the data behind the numbers)",
|
|
213
|
+
},
|
|
214
|
+
{
|
|
215
|
+
name: "artifact",
|
|
216
|
+
kind: "dir",
|
|
217
|
+
cure: "repro/ — its scripts in repro/tools/, its data in repro/data/; the anonymized copy uploaded for review is about submitting and goes in submission/",
|
|
218
|
+
},
|
|
219
|
+
{
|
|
220
|
+
name: "archive",
|
|
221
|
+
kind: "dir",
|
|
222
|
+
cure: "versions/ for frozen PDFs, submission/ for an earlier attempt's forms, mails and checklists, extra/ for the rest",
|
|
223
|
+
},
|
|
224
|
+
{ name: "venue-corpus", kind: "dir", cure: VENUE_CORPUS },
|
|
225
|
+
{ name: "venues", kind: "dir", cure: VENUE_CORPUS },
|
|
226
|
+
];
|
|
227
|
+
|
|
228
|
+
/** For a name nobody listed: the choice by what the material is. */
|
|
229
|
+
export const CURE_BY_KIND =
|
|
230
|
+
"decide by what it is — a record or research → research/; about submitting (forms, mails, checklists, portal records) → submission/; live state of the paper (plan, status) → a section of PIPELINE-STATUS.md; reproduction (scripts, data, the artifact) → repro/; a frozen PDF of a stage → versions/; unclear → extra/, which is never judged";
|
|
231
|
+
|
|
232
|
+
const isAllowedFile = (name: string, declared: Declared): boolean =>
|
|
233
|
+
ROOT_FILES.includes(name) ||
|
|
234
|
+
TEX_INPUT_EXTENSIONS.includes(extname(name)) ||
|
|
235
|
+
BUILD_BYPRODUCTS.includes(name) ||
|
|
236
|
+
OS_FILES.includes(name) ||
|
|
237
|
+
declared.roots.includes(name);
|
|
238
|
+
|
|
239
|
+
const isAllowed = (e: LayoutEntry, declared: Declared): boolean =>
|
|
240
|
+
e.kind === "dir"
|
|
241
|
+
? ROOT_DIRS.includes(e.name) || declared.roots.includes(e.name)
|
|
242
|
+
: isAllowedFile(e.name, declared);
|
|
243
|
+
|
|
244
|
+
/** Where material like `e` belongs: its listed destination, else the choice by what it is. */
|
|
245
|
+
function cureFor(e: LayoutEntry): string {
|
|
246
|
+
const known = KNOWN_CURES.find(
|
|
247
|
+
(c) => c.kind === e.kind && c.name.toLowerCase() === e.name.toLowerCase(),
|
|
248
|
+
);
|
|
249
|
+
return known === undefined ? CURE_BY_KIND : known.cure;
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
/** The root entries that are not part of the layout, in the order given. Pure. */
|
|
253
|
+
export function judgeLayout(
|
|
254
|
+
entries: readonly LayoutEntry[],
|
|
255
|
+
declared: Declared,
|
|
256
|
+
): readonly Stray[] {
|
|
257
|
+
// `\include{chapter}` makes pdflatex write chapter.aux beside chapter.tex, under that file's own
|
|
258
|
+
// name, not the job's: the build's for every source at the root, the author's without one.
|
|
259
|
+
const includeAux = entries
|
|
260
|
+
.filter((e) => e.kind === "file" && extname(e.name) === ".tex")
|
|
261
|
+
.map((e) => `${basename(e.name, ".tex")}.aux`);
|
|
262
|
+
return entries
|
|
263
|
+
.filter(
|
|
264
|
+
(e) =>
|
|
265
|
+
!isAllowed(e, declared) &&
|
|
266
|
+
!(e.kind === "file" && includeAux.includes(e.name)),
|
|
267
|
+
)
|
|
268
|
+
.map((e) => ({
|
|
269
|
+
name: e.kind === "dir" ? `${e.name}/` : e.name,
|
|
270
|
+
kind: e.kind,
|
|
271
|
+
cure: cureFor(e),
|
|
272
|
+
}));
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
/** The paper folder's layout, entry by entry, `repro/` drawn with its own layout inside. */
|
|
276
|
+
export const PAPER_NODES: readonly TreeNode[] = [
|
|
277
|
+
...ROOT_FILE_NOTES.map((f) => leaf(f.name, f.note)),
|
|
278
|
+
leaf(
|
|
279
|
+
TEX_INPUT_EXTENSIONS.map((e) => `*${e}`).join(" "),
|
|
280
|
+
"what TeX and bibtex look up beside paper.tex",
|
|
281
|
+
),
|
|
282
|
+
...ROOT_DIR_NOTES.map((d) => ({
|
|
283
|
+
name: `${d.name}/`,
|
|
284
|
+
note: d.note,
|
|
285
|
+
children: d.name === REPRO_DIR ? REPRO_NODES : [],
|
|
286
|
+
})),
|
|
287
|
+
];
|
|
288
|
+
|
|
289
|
+
/** The drawing of the paper folder's layout: the rule's message, the hook's refusal, the rule's page. */
|
|
290
|
+
export const PAPER_LAYOUT_TREE: string = renderTree("<paper>/", PAPER_NODES);
|
|
291
|
+
|
|
292
|
+
/** The folder beside the paper folders that holds one corpus of accepted papers per venue. */
|
|
293
|
+
export const VENUES_DIR = "venues";
|
|
294
|
+
|
|
295
|
+
/**
|
|
296
|
+
* The papers directory as the README and `docs/layout.md` draw it: the venues' corpora beside the
|
|
297
|
+
* paper folders, and each paper folder with its layout. Only the paper folder's part is enforced (by
|
|
298
|
+
* the two layout rules); `venues/` is drawn so a reader sees where a corpus goes, and no rule reads it.
|
|
299
|
+
*/
|
|
300
|
+
export const PAPERS_NODES: readonly TreeNode[] = [
|
|
301
|
+
{
|
|
302
|
+
name: `${VENUES_DIR}/`,
|
|
303
|
+
note: "outside every paper folder: one folder per venue",
|
|
304
|
+
children: [
|
|
305
|
+
leaf(
|
|
306
|
+
"<venue>/",
|
|
307
|
+
"that venue's accepted papers, shared by every paper aimed at it",
|
|
308
|
+
),
|
|
309
|
+
],
|
|
310
|
+
},
|
|
311
|
+
{
|
|
312
|
+
name: "<paper>/",
|
|
313
|
+
note: "one folder per paper, named after the work; its root holds only what is below",
|
|
314
|
+
children: PAPER_NODES,
|
|
315
|
+
},
|
|
316
|
+
];
|
|
317
|
+
|
|
318
|
+
/** The drawing of the papers directory: the README's and `docs/layout.md`'s map of where things live. */
|
|
319
|
+
export const PAPERS_LAYOUT_TREE: string = renderTree(
|
|
320
|
+
"<papers-root>/",
|
|
321
|
+
PAPERS_NODES,
|
|
322
|
+
);
|
|
323
|
+
|
|
324
|
+
/**
|
|
325
|
+
* The one text a stray entry is reported with, by the rule `paper/allowed-files-only` and by the hook that
|
|
326
|
+
* refuses to create such an entry. It stands alone: where the entry goes, and the whole layout drawn,
|
|
327
|
+
* for a reader who knows nothing of paperlint.
|
|
328
|
+
*/
|
|
329
|
+
export function strayMessage(s: Stray): string {
|
|
330
|
+
return [
|
|
331
|
+
`\`${s.name}\` is at the root of a paper folder (the folder holding PIPELINE-STATUS.md), and that root holds only paperlint's layout. Where it goes: ${s.cure}.`,
|
|
332
|
+
`The layout (${ESCAPE_HATCH}/ takes anything and is never judged; the build's paper.* byproducts are never reported):`,
|
|
333
|
+
PAPER_LAYOUT_TREE,
|
|
334
|
+
`Rule paper/allowed-files-only: https://github.com/zernie/paperlint/blob/main/docs/rules/paper/allowed-files-only.md`,
|
|
335
|
+
].join("\n");
|
|
336
|
+
}
|
|
@@ -0,0 +1,222 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The layout of a paper's `repro/` (`src/domain/repro-layout.ts`), judged on its top entries alone.
|
|
3
|
+
* Both halves: the layout itself, the byproducts and dated study folders with a README.md are clean;
|
|
4
|
+
* every other entry is reported once, with where it goes by kind, and a study folder without its
|
|
5
|
+
* README.md is reported once.
|
|
6
|
+
*/
|
|
7
|
+
import { describe, expect, it } from "vitest";
|
|
8
|
+
import {
|
|
9
|
+
DATA_CURE,
|
|
10
|
+
FILE_CURE_BY_KIND,
|
|
11
|
+
MOVED_FILES,
|
|
12
|
+
NOTE_CURE,
|
|
13
|
+
SCRIPT_CURE,
|
|
14
|
+
isStudyName,
|
|
15
|
+
judgeRepro,
|
|
16
|
+
reproMessage,
|
|
17
|
+
suggestedStudyName,
|
|
18
|
+
type ReproEntry,
|
|
19
|
+
} from "./repro-layout.ts";
|
|
20
|
+
|
|
21
|
+
const file = (name: string): ReproEntry => ({
|
|
22
|
+
name,
|
|
23
|
+
kind: "file",
|
|
24
|
+
hasReadme: false,
|
|
25
|
+
});
|
|
26
|
+
const dir = (name: string, hasReadme = true): ReproEntry => ({
|
|
27
|
+
name,
|
|
28
|
+
kind: "dir",
|
|
29
|
+
hasReadme,
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
describe("isStudyName", () => {
|
|
33
|
+
it.each([
|
|
34
|
+
"context-reading-2026-10",
|
|
35
|
+
"human-labels-2026-10",
|
|
36
|
+
"ablation-2026-10-12",
|
|
37
|
+
"a1-b2-2026-01-31",
|
|
38
|
+
"leap-2024-02-29",
|
|
39
|
+
"leap-2000-02-29",
|
|
40
|
+
"april-2026-04-30",
|
|
41
|
+
])("%s is a study's name", (name) => {
|
|
42
|
+
expect(isStudyName(name)).toBe(true);
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
it.each([
|
|
46
|
+
["no date", "ablation"],
|
|
47
|
+
["no slug", "2026-10-12"],
|
|
48
|
+
["a capital", "Ablation-2026-10-12"],
|
|
49
|
+
["an underscore", "human_labels-2026-10"],
|
|
50
|
+
["a doubled hyphen", "a--b-2026-10"],
|
|
51
|
+
["month 13", "x-2026-13"],
|
|
52
|
+
["month 00", "x-2026-00-01"],
|
|
53
|
+
["day 00", "x-2026-10-00"],
|
|
54
|
+
["31 April", "x-2026-04-31"],
|
|
55
|
+
["29 February of a common year", "x-2025-02-29"],
|
|
56
|
+
["29 February 1900", "x-1900-02-29"],
|
|
57
|
+
["32 October", "x-2026-10-32"],
|
|
58
|
+
["a two-digit year", "x-26-10-12"],
|
|
59
|
+
])("not with %s: %s", (_why, name) => {
|
|
60
|
+
expect(isStudyName(name)).toBe(false);
|
|
61
|
+
});
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
describe("suggestedStudyName", () => {
|
|
65
|
+
it("a name that is a study's once lowercased and hyphenated, as it is", () => {
|
|
66
|
+
expect(suggestedStudyName("Human_Labels_2026_10")).toBe(
|
|
67
|
+
"human-labels-2026-10",
|
|
68
|
+
);
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
it("a name with no date: its slug, and the date to fill in", () => {
|
|
72
|
+
expect(suggestedStudyName("Old Baseline")).toBe("old-baseline-YYYY-MM-DD");
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
it("a name with a date that is not one: the slug without it", () => {
|
|
76
|
+
expect(suggestedStudyName("results-2026-13-01")).toBe("results-YYYY-MM-DD");
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
it("a name with no slug at all: a placeholder for one", () => {
|
|
80
|
+
expect(suggestedStudyName("2026-10")).toBe("<slug>-YYYY-MM-DD");
|
|
81
|
+
expect(suggestedStudyName("__")).toBe("<slug>-YYYY-MM-DD");
|
|
82
|
+
});
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
describe("judgeRepro — silent", () => {
|
|
86
|
+
it("on the layout: the index, .gitignore, tools/, data/ and dated studies with a README.md", () => {
|
|
87
|
+
expect(
|
|
88
|
+
judgeRepro([
|
|
89
|
+
file("README.md"),
|
|
90
|
+
file(".gitignore"),
|
|
91
|
+
dir("tools", false),
|
|
92
|
+
dir("data", false),
|
|
93
|
+
dir("ablation-2026-10-12"),
|
|
94
|
+
dir("human-labels-2026-10"),
|
|
95
|
+
]),
|
|
96
|
+
).toEqual([]);
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
it("on what interpreters, package managers and the system leave, file or folder", () => {
|
|
100
|
+
expect(
|
|
101
|
+
judgeRepro([
|
|
102
|
+
dir("__pycache__", false),
|
|
103
|
+
dir(".pytest_cache", false),
|
|
104
|
+
dir(".mypy_cache", false),
|
|
105
|
+
dir(".ruff_cache", false),
|
|
106
|
+
dir(".ipynb_checkpoints", false),
|
|
107
|
+
dir("node_modules", false),
|
|
108
|
+
dir(".venv", false),
|
|
109
|
+
file(".DS_Store"),
|
|
110
|
+
file("Thumbs.db"),
|
|
111
|
+
]),
|
|
112
|
+
).toEqual([]);
|
|
113
|
+
});
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
describe("judgeRepro — reports, one finding per entry", () => {
|
|
117
|
+
it("a loose file, by kind: script, tool manifest, data, note, anything else", () => {
|
|
118
|
+
expect(
|
|
119
|
+
judgeRepro([
|
|
120
|
+
file("paper_numbers.py"),
|
|
121
|
+
file("check-anon.sh"),
|
|
122
|
+
file("harness.MJS"),
|
|
123
|
+
file("requirements.txt"),
|
|
124
|
+
file("Makefile"),
|
|
125
|
+
file("numbers.tsv"),
|
|
126
|
+
file("runs.jsonl"),
|
|
127
|
+
file("notes.md"),
|
|
128
|
+
file("figure.pdf"),
|
|
129
|
+
file("LICENSE"),
|
|
130
|
+
]).map((f) => [f.path, f.problem, f.cure]),
|
|
131
|
+
).toEqual([
|
|
132
|
+
["repro/paper_numbers.py", "stray", SCRIPT_CURE],
|
|
133
|
+
["repro/check-anon.sh", "stray", SCRIPT_CURE],
|
|
134
|
+
["repro/harness.MJS", "stray", SCRIPT_CURE],
|
|
135
|
+
["repro/requirements.txt", "stray", SCRIPT_CURE],
|
|
136
|
+
["repro/Makefile", "stray", SCRIPT_CURE],
|
|
137
|
+
["repro/numbers.tsv", "stray", DATA_CURE],
|
|
138
|
+
["repro/runs.jsonl", "stray", DATA_CURE],
|
|
139
|
+
["repro/notes.md", "stray", NOTE_CURE],
|
|
140
|
+
["repro/figure.pdf", "stray", FILE_CURE_BY_KIND],
|
|
141
|
+
["repro/LICENSE", "stray", FILE_CURE_BY_KIND],
|
|
142
|
+
]);
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
it("a file paperlint once kept at the top: where it is read now, with the command", () => {
|
|
146
|
+
expect(
|
|
147
|
+
judgeRepro(MOVED_FILES.map((m) => file(m.name))).map((f) => f.cure),
|
|
148
|
+
).toEqual(MOVED_FILES.map((m) => m.cure));
|
|
149
|
+
expect(MOVED_FILES[0]?.cure).toContain(
|
|
150
|
+
"git mv repro/references-cache.json repro/data/",
|
|
151
|
+
);
|
|
152
|
+
});
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
describe("judgeRepro — reports a folder, one finding each", () => {
|
|
156
|
+
it("an undated folder: rename it as a study, with its first commit's date as the default", () => {
|
|
157
|
+
expect(judgeRepro([dir("Old_Baseline")])).toEqual([
|
|
158
|
+
{
|
|
159
|
+
path: "repro/Old_Baseline/",
|
|
160
|
+
problem: "stray",
|
|
161
|
+
cure: "rename it to `old-baseline-YYYY-MM-DD`, one folder per study or spike, named for what it is and the date it began — the date of its first commit is a fine default (`git log --reverse --format=%as -- repro/Old_Baseline | head -1`, run in the paper folder) — and give it a README.md; if it holds scripts or data shared across the paper instead, move them into repro/tools/ or repro/data/",
|
|
162
|
+
},
|
|
163
|
+
]);
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
it("a study folder without its README.md", () => {
|
|
167
|
+
expect(judgeRepro([dir("ablation-2026-10-12", false)])).toEqual([
|
|
168
|
+
{
|
|
169
|
+
path: "repro/ablation-2026-10-12/",
|
|
170
|
+
problem: "no-readme",
|
|
171
|
+
cure: "add repro/ablation-2026-10-12/README.md — what the study asks, how to run it, what it found — and a line for it in repro/README.md",
|
|
172
|
+
},
|
|
173
|
+
]);
|
|
174
|
+
});
|
|
175
|
+
|
|
176
|
+
it("a name allowed for a folder, as a file, and the other way round", () => {
|
|
177
|
+
expect(
|
|
178
|
+
judgeRepro([
|
|
179
|
+
file("tools"),
|
|
180
|
+
file("ablation-2026-10-12"),
|
|
181
|
+
dir("README.md"),
|
|
182
|
+
]).map((f) => f.path),
|
|
183
|
+
).toEqual(["repro/tools", "repro/ablation-2026-10-12", "repro/README.md/"]);
|
|
184
|
+
});
|
|
185
|
+
});
|
|
186
|
+
|
|
187
|
+
describe("reproMessage — stands alone", () => {
|
|
188
|
+
it("a stray entry: what it is, where it goes, what repro/ holds, the rule's page", () => {
|
|
189
|
+
expect(
|
|
190
|
+
reproMessage({
|
|
191
|
+
path: "repro/run.py",
|
|
192
|
+
problem: "stray",
|
|
193
|
+
cure: SCRIPT_CURE,
|
|
194
|
+
}),
|
|
195
|
+
).toBe(
|
|
196
|
+
[
|
|
197
|
+
`\`repro/run.py\` is loose at the top of the paper's repro/ folder, which holds only an index, the shared tools and data, and one dated folder per study. Where it goes: ${SCRIPT_CURE}.`,
|
|
198
|
+
"What repro/ holds (what interpreters and package managers leave there, like __pycache__/ and node_modules/, is never reported):",
|
|
199
|
+
"repro/",
|
|
200
|
+
"├── README.md ← the index: one line per study",
|
|
201
|
+
"├── .gitignore ← keeps the scripts' byproducts out of git",
|
|
202
|
+
"├── tools/ ← scripts used across the paper (numbers, figures, anonymity check, harnesses)",
|
|
203
|
+
"├── data/ ← shared data (tables, caches)",
|
|
204
|
+
"└── <slug>-YYYY-MM-DD/ ← one folder per study or spike (<slug>-YYYY-MM for one that ran over weeks)",
|
|
205
|
+
" └── README.md ← required: what it asks, how to run it, what it found",
|
|
206
|
+
"Rule repro/allowed-files-only: https://github.com/zernie/paperlint/blob/main/docs/rules/repro/allowed-files-only.md",
|
|
207
|
+
].join("\n"),
|
|
208
|
+
);
|
|
209
|
+
});
|
|
210
|
+
|
|
211
|
+
it("a study without its README.md: what to add", () => {
|
|
212
|
+
expect(
|
|
213
|
+
reproMessage({
|
|
214
|
+
path: "repro/x-2026-10/",
|
|
215
|
+
problem: "no-readme",
|
|
216
|
+
cure: "add it",
|
|
217
|
+
}),
|
|
218
|
+
).toMatch(
|
|
219
|
+
/^`repro\/x-2026-10\/` is a study folder without a README\.md, and every study in repro\/ has one\. What to do: add it\.\nWhat repro\/ holds/,
|
|
220
|
+
);
|
|
221
|
+
});
|
|
222
|
+
});
|