paperlint 9.0.1 → 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/README.md +68 -128
- package/dist/build.d.ts +1 -1
- package/dist/build.js +1 -1
- 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/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/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/docs/ci.md +60 -0
- package/docs/configuration.md +2 -0
- package/docs/e2e.md +2 -2
- 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/prereg-coverage.test.ts +448 -0
- package/eslint-rules/prereg-coverage.ts +419 -0
- package/lib/skill-trigger-cases.harness.mjs +51 -5
- package/lib/skill-trigger-cases.mjs +53 -15
- package/package.json +1 -1
- package/plugin/hooks/hooks.json +9 -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/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/build.ts +1 -1
- 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/templates/paper/PIPELINE-STATUS.md +41 -7
package/docs/skills.md
CHANGED
|
@@ -8,6 +8,8 @@ the skill whose description matches starts. `paperlint init` installs them into
|
|
|
8
8
|
`.claude/skills/` ([`install.md`](install.md)). Each one's full instructions are its
|
|
9
9
|
`skills/<name>/SKILL.md`.
|
|
10
10
|
|
|
11
|
+
Where each stage writes, and the check that guards each place: [`layout.md`](layout.md).
|
|
12
|
+
|
|
11
13
|
`test/skills-catalog.test.ts` fails when this list and the shipped `skills/` directory disagree.
|
|
12
14
|
|
|
13
15
|
## Across every stage
|
|
@@ -36,9 +38,10 @@ the skill whose description matches starts. `paperlint init` installs them into
|
|
|
36
38
|
|
|
37
39
|
## 3 · Study
|
|
38
40
|
|
|
39
|
-
| you get | skill
|
|
40
|
-
| ------------------------------------------------------------------------------------------ |
|
|
41
|
-
| a study design with honest statistics, and a reproduction artifact that checks its numbers | `build-benchmark`
|
|
41
|
+
| you get | skill |
|
|
42
|
+
| ------------------------------------------------------------------------------------------ | ------------------ |
|
|
43
|
+
| a study design with honest statistics, and a reproduction artifact that checks its numbers | `build-benchmark` |
|
|
44
|
+
| a page you label items on from a phone, and the answers exported into the study folder | `build-label-page` |
|
|
42
45
|
|
|
43
46
|
## 4 · Draft
|
|
44
47
|
|
package/docs/venues.md
ADDED
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
# Venues: three places, three jobs
|
|
2
|
+
|
|
3
|
+
paperlint keeps three kinds of venue knowledge, each in its own place:
|
|
4
|
+
|
|
5
|
+
| place | holds | written by | read by |
|
|
6
|
+
| -------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------ | -------------------------------------------------------------------- | ------------------------------------------------------------------------------ |
|
|
7
|
+
| `presets/<venue>.jsonc` and `presets/<venue>.md`, in the paperlint package | a shipped **venue preset** (page limits, format, deadlines) and its **card** (the call's requirements in prose, the portal's quirks) | paperlint's maintainers; deadlines are re-read from the portal daily | `paperlint lint` (the preset), `submit-paper` (the card) |
|
|
8
|
+
| `venues/<name>.jsonc` and `venues/<name>.md`, at your project's root | your own preset and card, for a venue paperlint does not ship | you, when `find-venue` picks such a venue (stage 2) | `paperlint lint`, through the paper's `"extends"`; `submit-paper` |
|
|
9
|
+
| `<papers-root>/venues/<venue>/` | the venue's **corpus**: its accepted papers, measured and read | `study-accepted-papers` (stage 2, and again at stage 5) | `study-accepted-papers`, for this paper and every later one aimed at the venue |
|
|
10
|
+
|
|
11
|
+
A preset and a card say what a venue **requires**: a paper that breaks them is sent back. A corpus
|
|
12
|
+
says what a venue **rewards**: what its strongest accepted papers do that its ordinary ones do not.
|
|
13
|
+
|
|
14
|
+
The two `venues/` folders are different places. With the default papers directory they are
|
|
15
|
+
`venues/` (your presets) and `papers/venues/` (the corpora).
|
|
16
|
+
|
|
17
|
+
## The corpus of accepted papers
|
|
18
|
+
|
|
19
|
+
`<papers-root>` is the papers directory: `papersDir` in the project's `paperlint.json`, default
|
|
20
|
+
`papers/`. The corpus lives there, beside the paper folders and not inside any of them, because it
|
|
21
|
+
belongs to the venue: every paper aimed at that venue reads the same one.
|
|
22
|
+
|
|
23
|
+
```text
|
|
24
|
+
<papers-root>/
|
|
25
|
+
├── venues/
|
|
26
|
+
│ └── <venue>/ ← one venue's corpus, shared by every paper aimed at it
|
|
27
|
+
└── <paper>/ ← a paper folder; it holds no corpus of its own
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
### When it is made
|
|
31
|
+
|
|
32
|
+
The skill `study-accepted-papers` makes it, the first time it runs for that venue. It always asks
|
|
33
|
+
which mode to run, and suggests one from what exists:
|
|
34
|
+
|
|
35
|
+
| mode | when | stage | what it adds to the corpus |
|
|
36
|
+
| -------- | ---------------------------------------------- | -------- | ----------------------------------------------------------- |
|
|
37
|
+
| A idea | a target venue and an idea; no `paper.tex` yet | 2 venue | the winner and ordinary lists, their measurements (L0, L1) |
|
|
38
|
+
| B design | a planned claim and measurements; no text yet | 2 venue | the same, if not there yet |
|
|
39
|
+
| C draft | the paper's text exists | 5 review | the deep reads (L2), one file per paper |
|
|
40
|
+
| D final | near-final text, the deadline close | 5 review | nothing new; it compares wording against the closest papers |
|
|
41
|
+
|
|
42
|
+
So the corpus usually appears at stage 2, right after `find-venue` and before the study is
|
|
43
|
+
designed, when what the venue rewards can still shape the study. A paper that first runs the skill
|
|
44
|
+
at stage 5 gets its corpus then. Each later run, by any paper, reuses what is there and adds only
|
|
45
|
+
what is missing.
|
|
46
|
+
|
|
47
|
+
### What is inside
|
|
48
|
+
|
|
49
|
+
```text
|
|
50
|
+
<papers-root>/venues/<venue>/
|
|
51
|
+
├── MANIFEST.md ← the sources, the years, what was capped, the stand-in for "strong accept" and how often it occurs
|
|
52
|
+
├── the winner and ordinary lists
|
|
53
|
+
├── measurements.json ← the codes of L0 and L1, one row per paper
|
|
54
|
+
├── text/ ← the text extracted from each paper
|
|
55
|
+
├── deep-reads/ ← one file per paper read in full (L2), usable by any paper
|
|
56
|
+
├── the scripts that fetched and measured
|
|
57
|
+
└── a labelled subfolder for corpus 2: award papers on the topic at other venues
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
Only `MANIFEST.md`, `measurements.json`, `text/` and `deep-reads/` have fixed names; the lists, the
|
|
61
|
+
scripts and the corpus-2 folder are named by whoever runs the skill. The PDFs are not committed: the
|
|
62
|
+
manifest records each paper's identifier and the command that fetches it.
|
|
63
|
+
|
|
64
|
+
A mechanism the venue adds to the generic ten (M1–M10, kept beside the skill), or one that does not
|
|
65
|
+
hold there, is written in `MANIFEST.md`.
|
|
66
|
+
|
|
67
|
+
### Who reads it
|
|
68
|
+
|
|
69
|
+
- **The paper's strong-accept scorecard.** In mode C, `study-accepted-papers` scores the draft
|
|
70
|
+
against the corpus and writes the result in the section `## Strong-accept scorecard (M1–M10)` of the
|
|
71
|
+
paper's `PIPELINE-STATUS.md`, the only place the verdicts live. Its dated report goes in the
|
|
72
|
+
paper's `reviews/` and names the corpus in its frontmatter (`corpus:`).
|
|
73
|
+
- **Every later paper aimed at the same venue**, and the same paper when it is resubmitted there:
|
|
74
|
+
the corpus is collected once and reused.
|
|
75
|
+
|
|
76
|
+
### What checks it
|
|
77
|
+
|
|
78
|
+
**No rule reads the corpus yet.** `paperlint lint` treats a folder in the papers directory as a paper
|
|
79
|
+
only when it holds `PIPELINE-STATUS.md`, `paper.tex` or `paperlint.json`, and `venues/` holds none of
|
|
80
|
+
them, so nothing in it is linted. The hook that refuses misplaced files does not act there either:
|
|
81
|
+
it acts only inside a paper folder.
|
|
82
|
+
|
|
83
|
+
What is checked is the other side: a corpus started **inside** a paper folder. A `venues/` or
|
|
84
|
+
`venue-corpus/` folder at a paper's root is reported, with this place as where it goes
|
|
85
|
+
([the paper folder's layout](rules/paper/allowed-files-only.md)), and the hook refuses to create one.
|
|
86
|
+
|
|
87
|
+
## Where the venue's other facts go
|
|
88
|
+
|
|
89
|
+
A format fact found while studying the corpus (a page limit, a template, a required section) is
|
|
90
|
+
something the venue **requires**: it goes in the preset and the card, with the quote it comes from,
|
|
91
|
+
not in the corpus. [`rules.md`](rules.md#writing-your-own-venue-preset) shows how to write a preset of
|
|
92
|
+
your own.
|
|
93
|
+
|
|
94
|
+
See also: [the map of every place, and what checks it](layout.md).
|
|
@@ -0,0 +1,448 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `study/declare-sample`: the declaration a study's `prereg.json` carries — population, strata,
|
|
3
|
+
* screens, coverage, the author's check — judged field by field, then across fields, then through
|
|
4
|
+
* ESLint on a paper's `PIPELINE-STATUS.md` with study folders beside it.
|
|
5
|
+
*/
|
|
6
|
+
import { symlinkSync } from "node:fs";
|
|
7
|
+
import { join } from "node:path";
|
|
8
|
+
import { describe, expect, it } from "vitest";
|
|
9
|
+
import {
|
|
10
|
+
REPRO_DIR as LAYOUT_REPRO_DIR,
|
|
11
|
+
REPRO_DIRS,
|
|
12
|
+
} from "../src/domain/repro-layout.ts";
|
|
13
|
+
import { useTempDir, writeTree } from "../test/support.ts";
|
|
14
|
+
import preregCoverage, {
|
|
15
|
+
REPRO_DIR,
|
|
16
|
+
SHARED_DIRS,
|
|
17
|
+
WHY,
|
|
18
|
+
judgePrereg,
|
|
19
|
+
pathText,
|
|
20
|
+
paperFindings,
|
|
21
|
+
preregFiles,
|
|
22
|
+
preregProblems,
|
|
23
|
+
problemMessage,
|
|
24
|
+
} from "./prereg-coverage.ts";
|
|
25
|
+
|
|
26
|
+
const PAGE =
|
|
27
|
+
"https://github.com/zernie/paperlint/blob/main/docs/rules/study/declare-sample.md";
|
|
28
|
+
|
|
29
|
+
/** A declaration that passes: a sample screened down, saying so, and checked by its author. */
|
|
30
|
+
const GOOD = {
|
|
31
|
+
study: "the author's own keys are not read",
|
|
32
|
+
population: {
|
|
33
|
+
about: "rules written in agent instruction files of public repositories",
|
|
34
|
+
source: "a census of instruction files, crawled 2026-07-28",
|
|
35
|
+
size: 4210,
|
|
36
|
+
},
|
|
37
|
+
strata: { by: "rule type", values: ["crisp", "parameterised", "vague"] },
|
|
38
|
+
screens: [
|
|
39
|
+
{
|
|
40
|
+
name: "judgeable from one code excerpt of at most 40 lines",
|
|
41
|
+
removed: { crisp: 120, parameterised: 410, vague: 1630 },
|
|
42
|
+
},
|
|
43
|
+
],
|
|
44
|
+
coverage: {
|
|
45
|
+
share: 0.31,
|
|
46
|
+
unreachable: ["vague", "rules about process rather than code"],
|
|
47
|
+
},
|
|
48
|
+
author_check: {
|
|
49
|
+
who: "first author",
|
|
50
|
+
when: "2026-10-12",
|
|
51
|
+
units_seen: 24,
|
|
52
|
+
units_drawn: 24,
|
|
53
|
+
verdict: "representative",
|
|
54
|
+
},
|
|
55
|
+
};
|
|
56
|
+
|
|
57
|
+
/** `GOOD` with `patch` merged into one of its object fields. */
|
|
58
|
+
const withField = (
|
|
59
|
+
key: "coverage" | "author_check",
|
|
60
|
+
patch: object,
|
|
61
|
+
): object => ({ ...GOOD, [key]: { ...GOOD[key], ...patch } });
|
|
62
|
+
|
|
63
|
+
/** The problems as `at: text`, the whole list. */
|
|
64
|
+
const judged = (data: unknown): string[] =>
|
|
65
|
+
judgePrereg(data).map((p) => `${p.at}: ${p.text}`);
|
|
66
|
+
|
|
67
|
+
describe("judgePrereg", () => {
|
|
68
|
+
it("a complete declaration, and keys the rule does not read: silent", () => {
|
|
69
|
+
expect(judged(GOOD)).toEqual([]);
|
|
70
|
+
});
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
describe("judgePrereg: the fields, missing", () => {
|
|
74
|
+
it("an empty object: each of the five fields, with what to add", () => {
|
|
75
|
+
expect(judged({})).toEqual([
|
|
76
|
+
"population: is missing — add `population`: what the paper's claim is about (`about`), where its units come from (`source`) and how many there are (`size`)",
|
|
77
|
+
"strata: is missing — add `strata`: the property the screens are counted by (`by`, e.g. rule type) and its values (`values`)",
|
|
78
|
+
"screens: is missing — add `screens`: one entry per screen applied between the population and the sample, each with `name` and `removed` — [] when none was",
|
|
79
|
+
"coverage: is missing — add `coverage`: the share of the population the sample can represent (`share`, 0 to 1, or null with a `reason`) and the strata it cannot reach (`unreachable`)",
|
|
80
|
+
"author_check: is missing — add `author_check`: who looked at the drawn units before the run (`who`), when (`when`), how many of how many (`units_seen`, `units_drawn`), and what they said (`verdict`)",
|
|
81
|
+
]);
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
it("not an object at all: one problem about the file", () => {
|
|
85
|
+
expect(judged([1])).toEqual([
|
|
86
|
+
": is not valid — it must be a JSON object holding `population`, `strata`, `screens`, `coverage` and `author_check`",
|
|
87
|
+
]);
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
it("the fields inside each part, missing", () => {
|
|
91
|
+
expect(
|
|
92
|
+
judged({
|
|
93
|
+
...GOOD,
|
|
94
|
+
population: {},
|
|
95
|
+
strata: {},
|
|
96
|
+
screens: [{}],
|
|
97
|
+
coverage: {},
|
|
98
|
+
author_check: {},
|
|
99
|
+
}),
|
|
100
|
+
).toEqual([
|
|
101
|
+
"population.about: is missing — add what the paper's claim is about, in the paper's own words (e.g. the rules written in agent instruction files of public repositories)",
|
|
102
|
+
"population.source: is missing — add where the population's units come from — the census, crawl or registry, with its date",
|
|
103
|
+
"population.size: is missing — add the number of units in the population, a whole number above 0 (an estimate is fine; say how it was made in `source`)",
|
|
104
|
+
"strata.by: is missing — add the property the units are stratified by, e.g. rule type",
|
|
105
|
+
"strata.values: is missing — add the stratum's values, a list of names",
|
|
106
|
+
"screens[0].name: is missing — add what the screen keeps, as it was applied (e.g. judgeable from one code excerpt of at most 40 lines)",
|
|
107
|
+
"screens[0].removed: is missing — add the number of candidates the screen removed in each declared stratum value, an object {<value>: <count>} with 0 where it removed none",
|
|
108
|
+
"coverage.share: is missing — add the share of the population the sample can represent, a number from 0 to 1, or null with a `reason`",
|
|
109
|
+
"coverage.unreachable: is missing — add the strata (or kinds of units) the sample cannot reach, a list of names — [] only when it reaches them all",
|
|
110
|
+
"author_check.who: is missing — add who looked at the drawn units (a name or a role)",
|
|
111
|
+
"author_check.when: is missing — add the date of the check, YYYY-MM-DD",
|
|
112
|
+
"author_check.units_seen: is missing — add how many of the drawn units — the rules or cases drawn, not the items made from them — the author looked at, a whole number above 0",
|
|
113
|
+
"author_check.units_drawn: is missing — add how many units the sample drew",
|
|
114
|
+
'author_check.verdict: is missing — add the author\'s answer to "is this what you meet in practice?" — one of representative, partly, not-representative',
|
|
115
|
+
]);
|
|
116
|
+
});
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
describe("judgePrereg: the fields, malformed", () => {
|
|
120
|
+
it("the fields inside each part, malformed", () => {
|
|
121
|
+
expect(
|
|
122
|
+
judged({
|
|
123
|
+
...GOOD,
|
|
124
|
+
population: { about: " ", source: 3, size: 0.5 },
|
|
125
|
+
strata: { by: "rule type", values: [] },
|
|
126
|
+
screens: [
|
|
127
|
+
"a screen in a sentence",
|
|
128
|
+
{ name: "short", removed: { crisp: -1 } },
|
|
129
|
+
],
|
|
130
|
+
coverage: { share: 1.5, reason: "", unreachable: "vague" },
|
|
131
|
+
author_check: {
|
|
132
|
+
who: "first author",
|
|
133
|
+
when: "2026-02-30",
|
|
134
|
+
units_seen: 0,
|
|
135
|
+
units_drawn: 10,
|
|
136
|
+
verdict: "fine",
|
|
137
|
+
decision: { proceed: "yes" },
|
|
138
|
+
},
|
|
139
|
+
}),
|
|
140
|
+
).toEqual([
|
|
141
|
+
"population.about: is empty — write what the paper's claim is about, in the paper's own words (e.g. the rules written in agent instruction files of public repositories)",
|
|
142
|
+
"population.source: is not valid — it must be where the population's units come from — the census, crawl or registry, with its date",
|
|
143
|
+
"population.size: is not valid — it must be the number of units in the population, a whole number above 0 (an estimate is fine; say how it was made in `source`)",
|
|
144
|
+
"strata.values: is empty — list the stratum's values, at least one",
|
|
145
|
+
"screens[0]: is not valid — it must be a screen: `name` and `removed`",
|
|
146
|
+
"screens[1].removed.crisp: is not valid — it must be a count, a whole number from 0",
|
|
147
|
+
"coverage.share: is not valid — it must be the share of the population the sample can represent, a number from 0 to 1, or null with a `reason`",
|
|
148
|
+
"coverage.reason: is empty — write why the share cannot be stated",
|
|
149
|
+
"coverage.unreachable: is not valid — it must be the strata (or kinds of units) the sample cannot reach, a list of names — [] only when it reaches them all",
|
|
150
|
+
"author_check.when: is not valid — it must be the date of the check, YYYY-MM-DD",
|
|
151
|
+
"author_check.units_seen: is not valid — it must be how many of the drawn units — the rules or cases drawn, not the items made from them — the author looked at, a whole number above 0",
|
|
152
|
+
'author_check.verdict: is not valid — it must be the author\'s answer to "is this what you meet in practice?" — one of representative, partly, not-representative',
|
|
153
|
+
"author_check.decision.proceed: is not valid — it must be whether the study runs on this sample, true or false",
|
|
154
|
+
"author_check.decision.by: is missing — add who decided",
|
|
155
|
+
"author_check.decision.why: is missing — add why, in a sentence",
|
|
156
|
+
]);
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
it("a decision that is not an object names the three fields it holds", () => {
|
|
160
|
+
expect(
|
|
161
|
+
judged(withField("author_check", { verdict: "partly", decision: "go" })),
|
|
162
|
+
).toEqual([
|
|
163
|
+
"author_check.decision: is not valid — it must be the decision taken after the verdict: `proceed` (true or false), `by` whom, and `why`",
|
|
164
|
+
]);
|
|
165
|
+
});
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
describe("judgePrereg: across fields", () => {
|
|
169
|
+
it("`removed` keyed by other than the declared strata: each missing value and each unknown key", () => {
|
|
170
|
+
expect(
|
|
171
|
+
judged({
|
|
172
|
+
...GOOD,
|
|
173
|
+
screens: [
|
|
174
|
+
{ name: "R1", removed: { crisp: 1, vague: 2, procedural: 3 } },
|
|
175
|
+
{ name: "R2", removed: {} },
|
|
176
|
+
],
|
|
177
|
+
}),
|
|
178
|
+
).toEqual([
|
|
179
|
+
"screens[0].removed.parameterised: is missing — every screen states how many candidates it removed in each declared stratum value (crisp, parameterised, vague), 0 where it removed none",
|
|
180
|
+
"screens[0].removed.procedural: is not a declared stratum value — `strata.values` declares crisp, parameterised, vague; add it there or count it under one of them",
|
|
181
|
+
"screens[1].removed.crisp: is missing — every screen states how many candidates it removed in each declared stratum value (crisp, parameterised, vague), 0 where it removed none",
|
|
182
|
+
"screens[1].removed.parameterised: is missing — every screen states how many candidates it removed in each declared stratum value (crisp, parameterised, vague), 0 where it removed none",
|
|
183
|
+
"screens[1].removed.vague: is missing — every screen states how many candidates it removed in each declared stratum value (crisp, parameterised, vague), 0 where it removed none",
|
|
184
|
+
]);
|
|
185
|
+
});
|
|
186
|
+
|
|
187
|
+
it("no screens at all is a declaration, not an omission: silent", () => {
|
|
188
|
+
expect(judged({ ...GOOD, screens: [] })).toEqual([]);
|
|
189
|
+
});
|
|
190
|
+
|
|
191
|
+
it("🔴 a stratum value that Object.prototype also has must be counted in `removed` itself", () => {
|
|
192
|
+
expect(
|
|
193
|
+
judged({
|
|
194
|
+
...GOOD,
|
|
195
|
+
strata: { by: "kind", values: ["constructor", "toString"] },
|
|
196
|
+
screens: [{ name: "R1", removed: {} }],
|
|
197
|
+
}),
|
|
198
|
+
).toEqual(
|
|
199
|
+
["constructor", "toString"].map(
|
|
200
|
+
(v) =>
|
|
201
|
+
`screens[0].removed.${v}: is missing — every screen states how many candidates it removed in each declared stratum value (constructor, toString), 0 where it removed none`,
|
|
202
|
+
),
|
|
203
|
+
);
|
|
204
|
+
});
|
|
205
|
+
});
|
|
206
|
+
|
|
207
|
+
describe("judgePrereg: the coverage", () => {
|
|
208
|
+
it("a null share needs its reason; with one it is silent", () => {
|
|
209
|
+
expect(judged(withField("coverage", { share: null }))).toEqual([
|
|
210
|
+
"coverage.reason: is missing — `share` is null, so say why the share cannot be stated",
|
|
211
|
+
]);
|
|
212
|
+
expect(
|
|
213
|
+
judged(
|
|
214
|
+
withField("coverage", {
|
|
215
|
+
share: null,
|
|
216
|
+
reason: "the census has no rule-type labels",
|
|
217
|
+
}),
|
|
218
|
+
),
|
|
219
|
+
).toEqual([]);
|
|
220
|
+
});
|
|
221
|
+
|
|
222
|
+
it("a share of 1 with strata it cannot reach contradicts itself; a share below 1 may list them", () => {
|
|
223
|
+
expect(
|
|
224
|
+
judged(withField("coverage", { share: 1, unreachable: ["vague"] })),
|
|
225
|
+
).toEqual([
|
|
226
|
+
"coverage.unreachable: contradicts `share` 1 — a sample that cannot reach «vague» does not represent the whole population: lower `share`, or empty the list",
|
|
227
|
+
]);
|
|
228
|
+
expect(
|
|
229
|
+
judged(withField("coverage", { share: 0.7, unreachable: ["vague"] })),
|
|
230
|
+
).toEqual([]);
|
|
231
|
+
});
|
|
232
|
+
});
|
|
233
|
+
|
|
234
|
+
describe("judgePrereg: within the strata and the check", () => {
|
|
235
|
+
it("a stratum value named twice", () => {
|
|
236
|
+
expect(
|
|
237
|
+
judged({
|
|
238
|
+
...GOOD,
|
|
239
|
+
strata: { by: "rule type", values: ["crisp", "vague", "crisp"] },
|
|
240
|
+
screens: [],
|
|
241
|
+
}),
|
|
242
|
+
).toEqual([
|
|
243
|
+
"strata.values: names «crisp» more than once — each stratum value once",
|
|
244
|
+
]);
|
|
245
|
+
});
|
|
246
|
+
|
|
247
|
+
it("more units seen than drawn", () => {
|
|
248
|
+
expect(judged(withField("author_check", { units_seen: 30 }))).toEqual([
|
|
249
|
+
"author_check.units_seen: is 30, more than the 24 units drawn (`units_drawn`)",
|
|
250
|
+
]);
|
|
251
|
+
});
|
|
252
|
+
|
|
253
|
+
it("a doubtful verdict without a decision is an error; with one, either way, it is silent", () => {
|
|
254
|
+
const missing = (verdict: string): string =>
|
|
255
|
+
`author_check.decision: is missing — the verdict is «${verdict}», and a doubtful verdict with no recorded decision is how a sample the author doubted gets run anyway: add \`proceed\` (true or false), \`by\` whom and \`why\``;
|
|
256
|
+
expect(judged(withField("author_check", { verdict: "partly" }))).toEqual([
|
|
257
|
+
missing("partly"),
|
|
258
|
+
]);
|
|
259
|
+
expect(
|
|
260
|
+
judged(withField("author_check", { verdict: "not-representative" })),
|
|
261
|
+
).toEqual([missing("not-representative")]);
|
|
262
|
+
const decided = (proceed: boolean): string[] =>
|
|
263
|
+
judged(
|
|
264
|
+
withField("author_check", {
|
|
265
|
+
verdict: "not-representative",
|
|
266
|
+
decision: {
|
|
267
|
+
proceed,
|
|
268
|
+
by: "first author",
|
|
269
|
+
why: "redrawn from the census",
|
|
270
|
+
},
|
|
271
|
+
}),
|
|
272
|
+
);
|
|
273
|
+
expect(decided(true)).toEqual([]);
|
|
274
|
+
expect(decided(false)).toEqual([]);
|
|
275
|
+
});
|
|
276
|
+
});
|
|
277
|
+
|
|
278
|
+
describe("preregProblems and the message", () => {
|
|
279
|
+
it("text that is not JSON: one problem, with the parser's reason", () => {
|
|
280
|
+
const problems = preregProblems("{ not json");
|
|
281
|
+
expect(problems.map((p) => p.at)).toEqual([""]);
|
|
282
|
+
expect(problems[0]?.text).toMatch(
|
|
283
|
+
/^does not parse as JSON \(.+\) — there is no declaration to read$/u,
|
|
284
|
+
);
|
|
285
|
+
});
|
|
286
|
+
|
|
287
|
+
it("JSON text is judged", () => {
|
|
288
|
+
expect(preregProblems(JSON.stringify(GOOD))).toEqual([]);
|
|
289
|
+
});
|
|
290
|
+
|
|
291
|
+
it("a path as written in the file", () => {
|
|
292
|
+
expect(pathText(["screens", 0, "removed", "vague"])).toBe(
|
|
293
|
+
"screens[0].removed.vague",
|
|
294
|
+
);
|
|
295
|
+
expect(pathText([])).toBe("");
|
|
296
|
+
});
|
|
297
|
+
|
|
298
|
+
it("a message names the file and the field, then why, then the rule's page", () => {
|
|
299
|
+
expect(
|
|
300
|
+
problemMessage("repro/s-2026-10-12/prereg.json", {
|
|
301
|
+
at: "coverage",
|
|
302
|
+
text: "is missing — add it",
|
|
303
|
+
}),
|
|
304
|
+
).toBe(
|
|
305
|
+
`\`repro/s-2026-10-12/prereg.json\`: \`coverage\` is missing — add it. ${WHY} Rule study/declare-sample: ${PAGE}`,
|
|
306
|
+
);
|
|
307
|
+
expect(
|
|
308
|
+
problemMessage("repro/s/prereg.json", { at: "", text: "is broken" }),
|
|
309
|
+
).toBe(
|
|
310
|
+
`\`repro/s/prereg.json\` is broken. ${WHY} Rule study/declare-sample: ${PAGE}`,
|
|
311
|
+
);
|
|
312
|
+
});
|
|
313
|
+
});
|
|
314
|
+
|
|
315
|
+
const root = useTempDir("prereg-coverage-");
|
|
316
|
+
|
|
317
|
+
describe("which files are judged", () => {
|
|
318
|
+
it("repro/ and its shared folders are the ones repro/allowed-files-only names", () => {
|
|
319
|
+
expect(REPRO_DIR).toBe(LAYOUT_REPRO_DIR);
|
|
320
|
+
expect(SHARED_DIRS).toEqual(REPRO_DIRS);
|
|
321
|
+
});
|
|
322
|
+
|
|
323
|
+
it("every study folder at the top of repro/ that holds a prereg.json, sorted; never tools/ or data/", () => {
|
|
324
|
+
const dir = writeTree(join(root, "files"), {
|
|
325
|
+
"repro/tools/prereg.json": "{}",
|
|
326
|
+
"repro/data/prereg.json": "{}",
|
|
327
|
+
"repro/Misnamed Study/prereg.json": "{}",
|
|
328
|
+
"repro/b-2026-10-12/prereg.json": "{}",
|
|
329
|
+
"repro/a-2026-10/prereg.json": "{}",
|
|
330
|
+
"repro/no-prereg-2026-10-01/README.md": "# s\n",
|
|
331
|
+
"repro/README.md": "# repro\n",
|
|
332
|
+
"repro/a-2026-10/deeper/prereg.json": "{}",
|
|
333
|
+
});
|
|
334
|
+
expect(preregFiles(dir)).toEqual([
|
|
335
|
+
"repro/Misnamed Study/prereg.json",
|
|
336
|
+
"repro/a-2026-10/prereg.json",
|
|
337
|
+
"repro/b-2026-10-12/prereg.json",
|
|
338
|
+
]);
|
|
339
|
+
});
|
|
340
|
+
|
|
341
|
+
it("no repro/, or a file named repro: nothing", () => {
|
|
342
|
+
expect(preregFiles(writeTree(join(root, "none"), { "x.md": "" }))).toEqual(
|
|
343
|
+
[],
|
|
344
|
+
);
|
|
345
|
+
expect(
|
|
346
|
+
preregFiles(writeTree(join(root, "file"), { repro: "not a folder" })),
|
|
347
|
+
).toEqual([]);
|
|
348
|
+
});
|
|
349
|
+
|
|
350
|
+
it("the findings of a paper: each file's problems, each as its message", () => {
|
|
351
|
+
const dir = writeTree(join(root, "findings"), {
|
|
352
|
+
"repro/ok-2026-10-01/prereg.json": JSON.stringify(GOOD),
|
|
353
|
+
"repro/bad-2026-10-02/prereg.json": JSON.stringify(
|
|
354
|
+
withField("coverage", { share: null }),
|
|
355
|
+
),
|
|
356
|
+
});
|
|
357
|
+
expect(paperFindings(dir)).toEqual([
|
|
358
|
+
`\`repro/bad-2026-10-02/prereg.json\`: \`coverage.reason\` is missing — \`share\` is null, so say why the share cannot be stated. ${WHY} Rule study/declare-sample: ${PAGE}`,
|
|
359
|
+
]);
|
|
360
|
+
});
|
|
361
|
+
|
|
362
|
+
it("🔴 a prereg.json that is a folder is a finding, not a crash of the whole lint run", () => {
|
|
363
|
+
const dir = writeTree(join(root, "folder-prereg"), {
|
|
364
|
+
"repro/s-2026-10-01/prereg.json/x.txt": "",
|
|
365
|
+
});
|
|
366
|
+
expect(paperFindings(dir)).toEqual([
|
|
367
|
+
`\`repro/s-2026-10-01/prereg.json\` is not a file — the declaration is one JSON file; move what the folder holds elsewhere. ${WHY} Rule study/declare-sample: ${PAGE}`,
|
|
368
|
+
]);
|
|
369
|
+
});
|
|
370
|
+
});
|
|
371
|
+
|
|
372
|
+
describe("which files are judged: through a link", () => {
|
|
373
|
+
it("🔴 a study folder linked into repro/ is judged like any other: the layout rule follows the link too", () => {
|
|
374
|
+
const dir = writeTree(join(root, "linked"), {
|
|
375
|
+
"elsewhere/s-2026-10-01/prereg.json": "{}",
|
|
376
|
+
"repro/README.md": "# repro\n",
|
|
377
|
+
});
|
|
378
|
+
symlinkSync(
|
|
379
|
+
join(dir, "elsewhere", "s-2026-10-01"),
|
|
380
|
+
join(dir, "repro", "s-2026-10-01"),
|
|
381
|
+
);
|
|
382
|
+
expect(preregFiles(dir)).toEqual(["repro/s-2026-10-01/prereg.json"]);
|
|
383
|
+
});
|
|
384
|
+
|
|
385
|
+
it("a link in repro/ to nothing is passed over, not a crash", () => {
|
|
386
|
+
const dir = writeTree(join(root, "dangling"), {
|
|
387
|
+
"repro/README.md": "# repro\n",
|
|
388
|
+
});
|
|
389
|
+
symlinkSync(join(dir, "gone"), join(dir, "repro", "s-2026-10-02"));
|
|
390
|
+
expect(preregFiles(dir)).toEqual([]);
|
|
391
|
+
});
|
|
392
|
+
});
|
|
393
|
+
|
|
394
|
+
describe("the rule, on the status file of a paper", () => {
|
|
395
|
+
const rule = preregCoverage.rules["declare-sample"];
|
|
396
|
+
/** What the rule reports for `file` in `dir`, each finding with its line and its whole text. */
|
|
397
|
+
const lint = (dir: string, file = "PIPELINE-STATUS.md") => {
|
|
398
|
+
const out: { line: number; messageId: string; message: string }[] = [];
|
|
399
|
+
rule
|
|
400
|
+
.create({
|
|
401
|
+
filename: join(dir, file),
|
|
402
|
+
options: [],
|
|
403
|
+
settings: {},
|
|
404
|
+
sourceCode: {
|
|
405
|
+
text: "# Status\n",
|
|
406
|
+
getLocFromIndex: (i) => ({ line: i + 1, column: 0 }),
|
|
407
|
+
getText: () => "",
|
|
408
|
+
},
|
|
409
|
+
report: (d) => {
|
|
410
|
+
out.push({
|
|
411
|
+
line: d.loc?.start.line ?? 0,
|
|
412
|
+
messageId: d.messageId,
|
|
413
|
+
message: rule.meta.messages.coverage.replace(
|
|
414
|
+
"{{text}}",
|
|
415
|
+
String(d.data?.["text"]),
|
|
416
|
+
),
|
|
417
|
+
});
|
|
418
|
+
},
|
|
419
|
+
})
|
|
420
|
+
.root?.();
|
|
421
|
+
return out;
|
|
422
|
+
};
|
|
423
|
+
|
|
424
|
+
it("reports each problem on the status file's first line", () => {
|
|
425
|
+
const dir = writeTree(join(root, "lint-bad"), {
|
|
426
|
+
"repro/s-2026-10-12/prereg.json": "[]",
|
|
427
|
+
});
|
|
428
|
+
expect(lint(dir)).toEqual([
|
|
429
|
+
{
|
|
430
|
+
line: 1,
|
|
431
|
+
messageId: "coverage",
|
|
432
|
+
message: `\`repro/s-2026-10-12/prereg.json\` is not valid — it must be a JSON object holding \`population\`, \`strata\`, \`screens\`, \`coverage\` and \`author_check\`. ${WHY} Rule study/declare-sample: ${PAGE}`,
|
|
433
|
+
},
|
|
434
|
+
]);
|
|
435
|
+
});
|
|
436
|
+
|
|
437
|
+
it("silent on a complete declaration, and on any file but the status file", () => {
|
|
438
|
+
const dir = writeTree(join(root, "lint-good"), {
|
|
439
|
+
"repro/s-2026-10-12/prereg.json": JSON.stringify(GOOD),
|
|
440
|
+
"repro/t-2026-10-12/prereg.json": "{}",
|
|
441
|
+
});
|
|
442
|
+
expect(lint(dir, "NOTES.md")).toEqual([]);
|
|
443
|
+
const good = writeTree(join(root, "lint-good-only"), {
|
|
444
|
+
"repro/s-2026-10-12/prereg.json": JSON.stringify(GOOD),
|
|
445
|
+
});
|
|
446
|
+
expect(lint(good)).toEqual([]);
|
|
447
|
+
});
|
|
448
|
+
});
|