paperlint 10.1.1 → 10.3.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 +2 -0
- package/dist/adapters/study-registry/index.d.ts +357 -0
- package/dist/adapters/study-registry/index.d.ts.map +1 -0
- package/dist/adapters/study-registry/index.js +221 -0
- package/dist/adapters/study-registry/index.js.map +1 -0
- package/dist/cli.d.ts.map +1 -1
- package/dist/cli.js +42 -2
- package/dist/cli.js.map +1 -1
- package/dist/domain/repro-layout.d.ts +12 -4
- package/dist/domain/repro-layout.d.ts.map +1 -1
- package/dist/domain/repro-layout.js +49 -18
- package/dist/domain/repro-layout.js.map +1 -1
- package/dist/domain/scaffold-layout.d.ts +4 -3
- package/dist/domain/scaffold-layout.d.ts.map +1 -1
- package/dist/domain/scaffold-layout.js +7 -4
- package/dist/domain/scaffold-layout.js.map +1 -1
- package/dist/domain/study-registry.d.ts +372 -0
- package/dist/domain/study-registry.d.ts.map +1 -0
- package/dist/domain/study-registry.js +593 -0
- package/dist/domain/study-registry.js.map +1 -0
- package/dist/ports/study-registry-codec.d.ts +15 -0
- package/dist/ports/study-registry-codec.d.ts.map +1 -0
- package/dist/ports/study-registry-codec.js +2 -0
- package/dist/ports/study-registry-codec.js.map +1 -0
- package/dist/repro-layout-rule.d.ts.map +1 -1
- package/dist/repro-layout-rule.js +4 -3
- package/dist/repro-layout-rule.js.map +1 -1
- package/dist/studies.d.ts +27 -0
- package/dist/studies.d.ts.map +1 -0
- package/dist/studies.js +163 -0
- package/dist/studies.js.map +1 -0
- package/dist/study-registry-rule.d.ts +24 -0
- package/dist/study-registry-rule.d.ts.map +1 -0
- package/dist/study-registry-rule.js +145 -0
- package/dist/study-registry-rule.js.map +1 -0
- package/docs/design/study-registry.md +246 -0
- package/docs/layout.md +51 -46
- package/docs/rules/paper/allowed-files-only.md +3 -2
- package/docs/rules/repro/allowed-files-only.md +40 -30
- package/docs/rules/repro/study-plan-name.md +70 -0
- package/docs/rules/repro/study-registered.md +320 -0
- package/docs/rules/study/declare-sample.md +1 -1
- package/docs/rules.md +53 -51
- package/package.json +1 -1
- package/skills/build-benchmark/SKILL.md +39 -8
- package/skills/build-benchmark/SKILL.md.spec.ts +38 -7
- package/skills/build-label-page/SKILL.md +14 -9
- package/skills/build-label-page/SKILL.md.spec.ts +13 -8
- package/skills/paper-pipeline/references/artifact-checklist.md +2 -1
- package/skills/paper-pipeline/references/pipeline-status-template.md +5 -3
- package/skills/run-agent-grid/SKILL.md +13 -1
- package/skills/run-agent-grid/SKILL.md.spec.ts +12 -0
- package/src/adapters/study-registry/index.test.ts +484 -0
- package/src/adapters/study-registry/index.ts +328 -0
- package/src/cli.ts +53 -2
- package/src/domain/paper-layout.test.ts +1 -1
- package/src/domain/repro-layout.test.ts +18 -9
- package/src/domain/repro-layout.ts +58 -18
- package/src/domain/scaffold-layout.ts +7 -4
- package/src/domain/study-measurements.test.ts +629 -0
- package/src/domain/study-registry.test.ts +666 -0
- package/src/domain/study-registry.ts +1044 -0
- package/src/folder-layout-hook.test.ts +10 -0
- package/src/new-paper.test.ts +29 -2
- package/src/ports/study-registry-codec.ts +15 -0
- package/src/repro-layout-rule.ts +4 -3
- package/src/rule-cases.test.ts +68 -0
- package/src/studies-cli.test.ts +113 -0
- package/src/studies.test.ts +359 -0
- package/src/studies.ts +268 -0
- package/src/study-registry-rule.test.ts +456 -0
- package/src/study-registry-rule.ts +239 -0
|
@@ -0,0 +1,1044 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* THE STUDY REGISTRY — `repro/studies.json`: one row per study, each with a stable id, a plain name and
|
|
3
|
+
* its status. The design is docs/design/study-registry.md; the rule is `repro/study-registered`
|
|
4
|
+
* (docs/rules/repro/study-registered.md).
|
|
5
|
+
*
|
|
6
|
+
* A paper's `repro/` once reached 79 study folders named `<slug>-YYYY-MM-DD`, and nobody could tell
|
|
7
|
+
* which was which, which had ended, or which a number in the paper came from. Now every study folder is
|
|
8
|
+
* `<id>-<slug>` (`P1-S03-baseline`), and its row says what it asks, where it stands and what it found.
|
|
9
|
+
*
|
|
10
|
+
* ── JSON, NEVER MARKDOWN ─────────────────────────────────────────────────────────
|
|
11
|
+
* The registry is data: its text is parsed once, where it enters, against the schema in
|
|
12
|
+
* `src/adapters/study-registry/` (`StudyRegistryCodec`). The index in `repro/README.md` is OUTPUT: it is
|
|
13
|
+
* generated from the registry and compared as a string, between two fixed markers. Nothing parses it.
|
|
14
|
+
*
|
|
15
|
+
* Pure: the caller lists `repro/`, reads the registry and the README, and says which result files exist.
|
|
16
|
+
*/
|
|
17
|
+
import { isAbsolute, posix, win32 } from "node:path";
|
|
18
|
+
import { STUDIES_FILE, STUDY_SLUG } from "./repro-layout.ts";
|
|
19
|
+
|
|
20
|
+
/** The registry, at the top of the paper's `repro/`; the layout allows it there. */
|
|
21
|
+
export { STUDIES_FILE };
|
|
22
|
+
|
|
23
|
+
export const STUDY_KINDS = [
|
|
24
|
+
"measurement",
|
|
25
|
+
"spike",
|
|
26
|
+
"tooling",
|
|
27
|
+
"data",
|
|
28
|
+
"artifact",
|
|
29
|
+
"salvage",
|
|
30
|
+
] as const;
|
|
31
|
+
export type StudyKind = (typeof STUDY_KINDS)[number];
|
|
32
|
+
|
|
33
|
+
export const STUDY_STATUSES = [
|
|
34
|
+
"designing",
|
|
35
|
+
"running",
|
|
36
|
+
"done",
|
|
37
|
+
"stopped",
|
|
38
|
+
"superseded",
|
|
39
|
+
"dropped",
|
|
40
|
+
] as const;
|
|
41
|
+
export type StudyStatus = (typeof STUDY_STATUSES)[number];
|
|
42
|
+
|
|
43
|
+
/** `registry`: listed in the registry only, not in the paper's text. */
|
|
44
|
+
export const IN_PAPER = ["yes", "no", "registry"] as const;
|
|
45
|
+
export type InPaper = (typeof IN_PAPER)[number];
|
|
46
|
+
|
|
47
|
+
/** A title is a plain name, not a sentence. */
|
|
48
|
+
export const MAX_TITLE_WORDS = 8;
|
|
49
|
+
|
|
50
|
+
/** A paper's prefix: a capital letter, then capitals or digits (`P1`). */
|
|
51
|
+
export const PREFIX_PATTERN = /^[A-Z][A-Z0-9]*$/u;
|
|
52
|
+
|
|
53
|
+
/** The kind `paperlint studies new` gives a study when none is named. */
|
|
54
|
+
export const DEFAULT_KIND: StudyKind = "measurement";
|
|
55
|
+
|
|
56
|
+
/** The markers around the generated index in `repro/README.md`. Fixed: the rule finds the block by them. */
|
|
57
|
+
export const INDEX_BEGIN =
|
|
58
|
+
"<!-- studies:begin — generated from studies.json by `paperlint studies index`; do not edit -->";
|
|
59
|
+
export const INDEX_END = "<!-- studies:end -->";
|
|
60
|
+
|
|
61
|
+
/** The README written when `repro/` has none. */
|
|
62
|
+
const INDEX_HEADING = "# Reproduction index";
|
|
63
|
+
|
|
64
|
+
/** What a measurement is to the paper: the claim it carries, or what that claim is compared with. */
|
|
65
|
+
export const MEASUREMENT_ROLES = ["headline", "baseline"] as const;
|
|
66
|
+
export type MeasurementRole = (typeof MEASUREMENT_ROLES)[number];
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* The statuses of a study that has run: its plan was due before the first run. A `designing` study
|
|
70
|
+
* has not run yet, and a `dropped` one never will.
|
|
71
|
+
*/
|
|
72
|
+
export const RAN_STATUSES: readonly StudyStatus[] = [
|
|
73
|
+
"running",
|
|
74
|
+
"done",
|
|
75
|
+
"stopped",
|
|
76
|
+
"superseded",
|
|
77
|
+
];
|
|
78
|
+
|
|
79
|
+
/** A requirement of the author's on the paper's measurements, in their words. */
|
|
80
|
+
export interface Requirement {
|
|
81
|
+
readonly id: string;
|
|
82
|
+
/** The author's words, verbatim. */
|
|
83
|
+
readonly text: string;
|
|
84
|
+
readonly date: string;
|
|
85
|
+
/** Where they said it: a chat, a message, a review comment. */
|
|
86
|
+
readonly source: string;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/** Someone's approval, in their words: of a measurement, or of a study's drawn units. */
|
|
90
|
+
export interface Approval {
|
|
91
|
+
readonly by: string;
|
|
92
|
+
readonly date: string;
|
|
93
|
+
readonly quote: string;
|
|
94
|
+
readonly source?: string | undefined;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* One measurement: what the paper measures, made of one or more studies (a pilot, the main run, a
|
|
99
|
+
* replication) whose rows name it in `measurement`. A measurement may carry more fields.
|
|
100
|
+
*/
|
|
101
|
+
export interface Measurement {
|
|
102
|
+
readonly id: string;
|
|
103
|
+
readonly title: string;
|
|
104
|
+
/** The research question it answers. */
|
|
105
|
+
readonly rq?: string | undefined;
|
|
106
|
+
readonly role: MeasurementRole;
|
|
107
|
+
readonly approval?: Approval | undefined;
|
|
108
|
+
/** The ids of the requirements it must meet. */
|
|
109
|
+
readonly requirements?: readonly string[] | undefined;
|
|
110
|
+
readonly [extra: string]: unknown;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* One study. The named fields are the registry's; a row may carry more (a paper merges its own
|
|
115
|
+
* measurement metadata into it), and they are kept as they are.
|
|
116
|
+
*/
|
|
117
|
+
export interface Study {
|
|
118
|
+
readonly id: string;
|
|
119
|
+
readonly folder: string;
|
|
120
|
+
readonly title: string;
|
|
121
|
+
readonly question: string;
|
|
122
|
+
readonly kind: StudyKind;
|
|
123
|
+
readonly status: StudyStatus;
|
|
124
|
+
readonly date: string;
|
|
125
|
+
readonly result?: string | undefined;
|
|
126
|
+
readonly result_file?: string | undefined;
|
|
127
|
+
readonly superseded_by?: string | undefined;
|
|
128
|
+
readonly in_paper: InPaper;
|
|
129
|
+
/** The id of the measurement the study belongs to. */
|
|
130
|
+
readonly measurement?: string | undefined;
|
|
131
|
+
/** The study's plan: a path inside its folder, `plan.json`. */
|
|
132
|
+
readonly plan?: string | undefined;
|
|
133
|
+
readonly [extra: string]: unknown;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
export interface StudyRegistry {
|
|
137
|
+
readonly prefix: string;
|
|
138
|
+
/** The paper's author: the one person whose approval makes a measurement the headline. */
|
|
139
|
+
readonly author?: string | undefined;
|
|
140
|
+
readonly requirements?: readonly Requirement[] | undefined;
|
|
141
|
+
/** When present, every row's `measurement` names one of them. */
|
|
142
|
+
readonly measurements?: readonly Measurement[] | undefined;
|
|
143
|
+
readonly studies: readonly Study[];
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/** The file `paperlint studies new` writes a study's plan to, beside its README.md. */
|
|
147
|
+
export const PLAN_FILE = "plan.json";
|
|
148
|
+
|
|
149
|
+
/** The plan's name before it was a plan: still read as one, and reported by `repro/study-plan-name`. */
|
|
150
|
+
export const LEGACY_PLAN_FILE = "prereg.json";
|
|
151
|
+
|
|
152
|
+
/** The author's check of a study's drawn units, before the runs. */
|
|
153
|
+
export type AuthorCheck = Omit<Approval, "source">;
|
|
154
|
+
|
|
155
|
+
/**
|
|
156
|
+
* A study's plan, fixed before its runs: what it measures, on what population and how it is drawn,
|
|
157
|
+
* its arms, its primary outcome, and the author's check of the drawn units. Closed: a key the paper
|
|
158
|
+
* needs of its own goes in `extra`, so a misspelt key is an error, not a key nobody reads. "Not
|
|
159
|
+
* known" and "not yet" have one spelling each: null.
|
|
160
|
+
*/
|
|
161
|
+
export interface StudyPlan {
|
|
162
|
+
readonly question: string;
|
|
163
|
+
readonly population: {
|
|
164
|
+
/** Where the units come from: the census, crawl or registry, with its date. */
|
|
165
|
+
readonly source: string;
|
|
166
|
+
readonly draw: Draw;
|
|
167
|
+
};
|
|
168
|
+
/** The conditions compared, each name once; none for a census. */
|
|
169
|
+
readonly arms: readonly { readonly name: string }[];
|
|
170
|
+
readonly primary_outcome: string;
|
|
171
|
+
/** null: the author has not checked the drawn units yet. Only a ready plan carries one. */
|
|
172
|
+
readonly author_check: AuthorCheck | null;
|
|
173
|
+
/** The paper's own keys: hypotheses, models, budget … Kept, never read; none is a plan field. */
|
|
174
|
+
readonly extra?: Readonly<Record<string, unknown>> | undefined;
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
/**
|
|
178
|
+
* How the sample is drawn from the population: a seeded draw, whose seed is part of it, or all of
|
|
179
|
+
* the population (a census), which has none. The size of the population is not stored: it is
|
|
180
|
+
* counted from the source, and a stored copy would only contradict it.
|
|
181
|
+
*/
|
|
182
|
+
export type Draw =
|
|
183
|
+
| {
|
|
184
|
+
readonly kind: "seeded";
|
|
185
|
+
/** `random`, `stratified` … */
|
|
186
|
+
readonly method: string;
|
|
187
|
+
readonly seed: string | number;
|
|
188
|
+
}
|
|
189
|
+
| {
|
|
190
|
+
readonly kind: "all";
|
|
191
|
+
/** `census` … */
|
|
192
|
+
readonly method: string;
|
|
193
|
+
};
|
|
194
|
+
|
|
195
|
+
/** The top-level fields of a plan: none of them may be a key of `extra`. */
|
|
196
|
+
export const PLAN_FIELDS = [
|
|
197
|
+
"question",
|
|
198
|
+
"population",
|
|
199
|
+
"arms",
|
|
200
|
+
"primary_outcome",
|
|
201
|
+
"author_check",
|
|
202
|
+
"extra",
|
|
203
|
+
] as const;
|
|
204
|
+
|
|
205
|
+
/** A plan with empty required texts: the author has not approved it. */
|
|
206
|
+
export type DraftPlan = StudyPlan & { readonly author_check: null };
|
|
207
|
+
|
|
208
|
+
/** The texts a plan must fill before the study runs, in the order they are named. */
|
|
209
|
+
export const PLAN_TEXT_FIELDS = [
|
|
210
|
+
"question",
|
|
211
|
+
"population.source",
|
|
212
|
+
"population.draw.method",
|
|
213
|
+
"primary_outcome",
|
|
214
|
+
] as const;
|
|
215
|
+
export type PlanTextField = (typeof PLAN_TEXT_FIELDS)[number];
|
|
216
|
+
|
|
217
|
+
/**
|
|
218
|
+
* A plan's text, parsed: ready (every required text filled), a draft (its empty texts named, and
|
|
219
|
+
* no check of the author's: only a complete plan is approved), or the reasons it is not a plan. Whether a plan is complete is decided here, once, by the parser.
|
|
220
|
+
*/
|
|
221
|
+
export type ParsedPlan =
|
|
222
|
+
| { readonly kind: "ready"; readonly plan: StudyPlan }
|
|
223
|
+
| {
|
|
224
|
+
readonly kind: "draft";
|
|
225
|
+
readonly plan: DraftPlan;
|
|
226
|
+
readonly empty: readonly PlanTextField[];
|
|
227
|
+
}
|
|
228
|
+
| { readonly kind: "unreadable"; readonly why: string };
|
|
229
|
+
|
|
230
|
+
/** What a study's folder holds of the plan its row names: absent, or what its text parsed to. */
|
|
231
|
+
export type PlanState = { readonly kind: "absent" } | ParsedPlan;
|
|
232
|
+
|
|
233
|
+
/**
|
|
234
|
+
* The plan `paperlint studies new` writes: every field present, its value to fill. Its draw is of
|
|
235
|
+
* all the population (a census); a sampled study changes it to `{ "kind": "seeded", "method", "seed" }`.
|
|
236
|
+
*/
|
|
237
|
+
export const planTemplate = (): DraftPlan => ({
|
|
238
|
+
question: "",
|
|
239
|
+
population: { source: "", draw: { kind: "all", method: "" } },
|
|
240
|
+
arms: [],
|
|
241
|
+
primary_outcome: "",
|
|
242
|
+
author_check: null,
|
|
243
|
+
});
|
|
244
|
+
|
|
245
|
+
/** A plan's text as `paperlint studies new` writes it: JSON, two-space indented, a final newline. */
|
|
246
|
+
export const planText = (p: StudyPlan): string =>
|
|
247
|
+
`${JSON.stringify(p, null, 2)}\n`;
|
|
248
|
+
|
|
249
|
+
/** A registry's text, parsed: the registry, or the one reason it is not one. */
|
|
250
|
+
export type ParsedRegistry =
|
|
251
|
+
| { readonly ok: true; readonly registry: StudyRegistry }
|
|
252
|
+
| { readonly ok: false; readonly why: string };
|
|
253
|
+
|
|
254
|
+
/** What the paper's `repro/` holds of a registry. */
|
|
255
|
+
export type RegistryState =
|
|
256
|
+
| { readonly kind: "absent" }
|
|
257
|
+
| { readonly kind: "unreadable"; readonly why: string }
|
|
258
|
+
| { readonly kind: "read"; readonly registry: StudyRegistry };
|
|
259
|
+
|
|
260
|
+
/** What the judge reads. */
|
|
261
|
+
export interface StudyInput {
|
|
262
|
+
/** The study folders at the top of `repro/`: every folder but the shared ones and the byproducts. */
|
|
263
|
+
readonly folders: readonly string[];
|
|
264
|
+
readonly registry: RegistryState;
|
|
265
|
+
/** `repro/README.md`'s text; null when there is none. */
|
|
266
|
+
readonly readme: string | null;
|
|
267
|
+
/** Each `<folder>/<result_file>` of the registry that is a file. */
|
|
268
|
+
readonly existingResults: ReadonlySet<string>;
|
|
269
|
+
/** The plan each row names, by `<folder>/<plan>`; one not listed is absent. */
|
|
270
|
+
readonly plans: ReadonlyMap<string, PlanState>;
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
/** One defect of the registry, of a row, or of the index. */
|
|
274
|
+
export type StudyFinding =
|
|
275
|
+
| { readonly problem: "no-registry"; readonly folders: readonly string[] }
|
|
276
|
+
| { readonly problem: "unreadable"; readonly why: string }
|
|
277
|
+
| {
|
|
278
|
+
readonly problem: "unregistered";
|
|
279
|
+
readonly folder: string;
|
|
280
|
+
readonly next: string;
|
|
281
|
+
}
|
|
282
|
+
| {
|
|
283
|
+
readonly problem: "no-folder";
|
|
284
|
+
readonly id: string;
|
|
285
|
+
readonly folder: string;
|
|
286
|
+
}
|
|
287
|
+
| {
|
|
288
|
+
readonly problem: "two-rows";
|
|
289
|
+
readonly folder: string;
|
|
290
|
+
readonly ids: readonly string[];
|
|
291
|
+
}
|
|
292
|
+
| { readonly problem: "bad-id"; readonly id: string; readonly prefix: string }
|
|
293
|
+
| { readonly problem: "duplicate-id"; readonly id: string }
|
|
294
|
+
| {
|
|
295
|
+
readonly problem: "folder-name";
|
|
296
|
+
readonly id: string;
|
|
297
|
+
readonly folder: string;
|
|
298
|
+
}
|
|
299
|
+
| {
|
|
300
|
+
readonly problem: "long-title";
|
|
301
|
+
readonly id: string;
|
|
302
|
+
readonly words: number;
|
|
303
|
+
}
|
|
304
|
+
| {
|
|
305
|
+
readonly problem: "no-result";
|
|
306
|
+
readonly id: string;
|
|
307
|
+
readonly missing: readonly ("result" | "result_file")[];
|
|
308
|
+
}
|
|
309
|
+
| {
|
|
310
|
+
readonly problem: "result-file";
|
|
311
|
+
readonly id: string;
|
|
312
|
+
readonly path: string;
|
|
313
|
+
readonly why: "missing" | "outside";
|
|
314
|
+
}
|
|
315
|
+
| {
|
|
316
|
+
readonly problem: "no-successor";
|
|
317
|
+
readonly id: string;
|
|
318
|
+
readonly superseded_by: string | null;
|
|
319
|
+
}
|
|
320
|
+
| {
|
|
321
|
+
readonly problem: "stale-index";
|
|
322
|
+
readonly why: "no-readme" | "no-markers" | "differs";
|
|
323
|
+
}
|
|
324
|
+
| {
|
|
325
|
+
readonly problem: "unknown-measurement";
|
|
326
|
+
readonly id: string;
|
|
327
|
+
readonly measurement: string;
|
|
328
|
+
}
|
|
329
|
+
| { readonly problem: "empty-measurement"; readonly measurement: string }
|
|
330
|
+
| {
|
|
331
|
+
readonly problem: "unknown-requirement";
|
|
332
|
+
readonly measurement: string;
|
|
333
|
+
readonly requirement: string;
|
|
334
|
+
}
|
|
335
|
+
| {
|
|
336
|
+
readonly problem: "headline-approval";
|
|
337
|
+
readonly measurement: string;
|
|
338
|
+
readonly why: "missing";
|
|
339
|
+
readonly author: string | null;
|
|
340
|
+
}
|
|
341
|
+
| {
|
|
342
|
+
readonly problem: "headline-approval";
|
|
343
|
+
readonly measurement: string;
|
|
344
|
+
readonly why: "not-author";
|
|
345
|
+
readonly by: string;
|
|
346
|
+
readonly author: string;
|
|
347
|
+
}
|
|
348
|
+
| {
|
|
349
|
+
readonly problem: "bad-plan";
|
|
350
|
+
readonly id: string;
|
|
351
|
+
readonly path: string;
|
|
352
|
+
readonly why: "missing" | "outside";
|
|
353
|
+
}
|
|
354
|
+
| {
|
|
355
|
+
readonly problem: "bad-plan";
|
|
356
|
+
readonly id: string;
|
|
357
|
+
readonly path: string;
|
|
358
|
+
readonly why: "unreadable";
|
|
359
|
+
readonly reasons: string;
|
|
360
|
+
}
|
|
361
|
+
| {
|
|
362
|
+
readonly problem: "bad-plan";
|
|
363
|
+
readonly id: string;
|
|
364
|
+
readonly path: string;
|
|
365
|
+
readonly why: "unfilled";
|
|
366
|
+
readonly fields: readonly PlanTextField[];
|
|
367
|
+
readonly status: StudyStatus;
|
|
368
|
+
}
|
|
369
|
+
| {
|
|
370
|
+
readonly problem: "author-check";
|
|
371
|
+
readonly id: string;
|
|
372
|
+
readonly measurement: string;
|
|
373
|
+
readonly status: StudyStatus;
|
|
374
|
+
readonly why: "no-plan" | "missing";
|
|
375
|
+
}
|
|
376
|
+
| {
|
|
377
|
+
readonly problem: "author-check";
|
|
378
|
+
readonly id: string;
|
|
379
|
+
readonly measurement: string;
|
|
380
|
+
readonly status: StudyStatus;
|
|
381
|
+
readonly why: "not-author";
|
|
382
|
+
readonly by: string;
|
|
383
|
+
readonly author: string;
|
|
384
|
+
}
|
|
385
|
+
| {
|
|
386
|
+
readonly problem: "legacy-plan";
|
|
387
|
+
readonly id: string;
|
|
388
|
+
readonly path: string;
|
|
389
|
+
};
|
|
390
|
+
|
|
391
|
+
/** The findings `repro/study-plan-name` reports, a warning; every other is `repro/study-registered`'s. */
|
|
392
|
+
export const PLAN_NAME_PROBLEMS: readonly StudyFinding["problem"][] = [
|
|
393
|
+
"legacy-plan",
|
|
394
|
+
];
|
|
395
|
+
|
|
396
|
+
/** The number of an id of `prefix` (`P1-S07` → 7); null for an id of another shape. */
|
|
397
|
+
function idNumber(prefix: string, id: string): number | null {
|
|
398
|
+
const m = new RegExp(`^${prefix}-S(\\d{2,})$`, "u").exec(id);
|
|
399
|
+
return m === null ? null : Number(m[1]);
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
/** The id after the highest one in `r`, at least two digits: `P1-S01` in an empty registry. */
|
|
403
|
+
export function nextStudyId(r: StudyRegistry): string {
|
|
404
|
+
const numbers = r.studies
|
|
405
|
+
.map((s) => idNumber(r.prefix, s.id))
|
|
406
|
+
.filter((n): n is number => n !== null);
|
|
407
|
+
const next = Math.max(0, ...numbers) + 1;
|
|
408
|
+
return `${r.prefix}-S${String(next).padStart(2, "0")}`;
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
/** The slug of a title: lowercase Latin letters and digits, words joined by single hyphens; "" when none. */
|
|
412
|
+
export const studySlug = (title: string): string =>
|
|
413
|
+
title
|
|
414
|
+
.normalize("NFKD")
|
|
415
|
+
.replace(/\p{M}/gu, "")
|
|
416
|
+
.toLowerCase()
|
|
417
|
+
.replace(/[^a-z0-9]+/gu, "-")
|
|
418
|
+
.replace(/^-+|-+$/gu, "");
|
|
419
|
+
|
|
420
|
+
/** The number of words in a title. */
|
|
421
|
+
export const titleWords = (title: string): number =>
|
|
422
|
+
title.split(/\s+/u).filter((w) => w !== "").length;
|
|
423
|
+
|
|
424
|
+
/**
|
|
425
|
+
* Whether `path` stays inside the folder it is relative to. A backslash or a Windows absolute path
|
|
426
|
+
* is refused outright: `posix.normalize` does not resolve `..\\`, while the host's `join` may.
|
|
427
|
+
*/
|
|
428
|
+
const isInside = (path: string): boolean => {
|
|
429
|
+
if (path.includes("\\") || isAbsolute(path) || win32.isAbsolute(path))
|
|
430
|
+
return false;
|
|
431
|
+
const n = posix.normalize(path);
|
|
432
|
+
return n !== ".." && !n.startsWith("../");
|
|
433
|
+
};
|
|
434
|
+
|
|
435
|
+
/**
|
|
436
|
+
* The `<folder>/<plan>` of each row whose plan lies inside its folder: the files the judge needs read
|
|
437
|
+
* (`StudyInput.plans`). A plan outside its folder is reported without being read.
|
|
438
|
+
*/
|
|
439
|
+
export const planKeys = (r: StudyRegistry): readonly string[] =>
|
|
440
|
+
r.studies.flatMap((s) =>
|
|
441
|
+
s.plan !== undefined && isInside(s.plan) ? [`${s.folder}/${s.plan}`] : [],
|
|
442
|
+
);
|
|
443
|
+
|
|
444
|
+
/** One row of a registry, with what the judge read beside it. */
|
|
445
|
+
interface Row {
|
|
446
|
+
readonly r: StudyRegistry;
|
|
447
|
+
readonly s: Study;
|
|
448
|
+
readonly i: number;
|
|
449
|
+
readonly input: StudyInput;
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
/** One check of a row: its finding, or null. */
|
|
453
|
+
type RowCheck = (row: Row) => StudyFinding | null;
|
|
454
|
+
|
|
455
|
+
/** Whether the row is the first of the registry with its value of `key`. */
|
|
456
|
+
const isFirst = ({ r, s, i }: Row, key: (o: Study) => string): boolean =>
|
|
457
|
+
r.studies.findIndex((o) => key(o) === key(s)) === i;
|
|
458
|
+
|
|
459
|
+
const badId: RowCheck = ({ r, s }) =>
|
|
460
|
+
idNumber(r.prefix, s.id) === null
|
|
461
|
+
? { problem: "bad-id", id: s.id, prefix: r.prefix }
|
|
462
|
+
: null;
|
|
463
|
+
|
|
464
|
+
/** Reported once, on the first row that holds the id. */
|
|
465
|
+
const duplicateId: RowCheck = (row) =>
|
|
466
|
+
row.r.studies.filter((o) => o.id === row.s.id).length > 1 &&
|
|
467
|
+
isFirst(row, (o) => o.id)
|
|
468
|
+
? { problem: "duplicate-id", id: row.s.id }
|
|
469
|
+
: null;
|
|
470
|
+
|
|
471
|
+
/** Reported once, on the first row that names the folder. */
|
|
472
|
+
const twoRows: RowCheck = (row) => {
|
|
473
|
+
const { r, s } = row;
|
|
474
|
+
const ids = r.studies.filter((o) => o.folder === s.folder).map((o) => o.id);
|
|
475
|
+
return ids.length > 1 && isFirst(row, (o) => o.folder)
|
|
476
|
+
? { problem: "two-rows", folder: s.folder, ids }
|
|
477
|
+
: null;
|
|
478
|
+
};
|
|
479
|
+
|
|
480
|
+
/** A bad id is reported once, as an id: no folder can match it. */
|
|
481
|
+
const folderName: RowCheck = ({ r, s }) =>
|
|
482
|
+
idNumber(r.prefix, s.id) === null ||
|
|
483
|
+
(s.folder.startsWith(`${s.id}-`) &&
|
|
484
|
+
STUDY_SLUG.test(s.folder.slice(s.id.length + 1)))
|
|
485
|
+
? null
|
|
486
|
+
: { problem: "folder-name", id: s.id, folder: s.folder };
|
|
487
|
+
|
|
488
|
+
const noFolder: RowCheck = ({ s, input }) =>
|
|
489
|
+
input.folders.includes(s.folder)
|
|
490
|
+
? null
|
|
491
|
+
: { problem: "no-folder", id: s.id, folder: s.folder };
|
|
492
|
+
|
|
493
|
+
const longTitle: RowCheck = ({ s }) => {
|
|
494
|
+
const words = titleWords(s.title);
|
|
495
|
+
return words > MAX_TITLE_WORDS
|
|
496
|
+
? { problem: "long-title", id: s.id, words }
|
|
497
|
+
: null;
|
|
498
|
+
};
|
|
499
|
+
|
|
500
|
+
const noResult: RowCheck = ({ s }) => {
|
|
501
|
+
const missing = [
|
|
502
|
+
...(s.result === undefined ? (["result"] as const) : []),
|
|
503
|
+
...(s.result_file === undefined ? (["result_file"] as const) : []),
|
|
504
|
+
];
|
|
505
|
+
return s.status === "done" && missing.length > 0
|
|
506
|
+
? { problem: "no-result", id: s.id, missing }
|
|
507
|
+
: null;
|
|
508
|
+
};
|
|
509
|
+
|
|
510
|
+
/** A `result_file` on any status must be a file inside the study's folder. */
|
|
511
|
+
const resultFile: RowCheck = ({ s, input }) => {
|
|
512
|
+
const path = s.result_file;
|
|
513
|
+
if (path === undefined) return null;
|
|
514
|
+
if (!isInside(path))
|
|
515
|
+
return { problem: "result-file", id: s.id, path, why: "outside" };
|
|
516
|
+
return input.existingResults.has(`${s.folder}/${path}`)
|
|
517
|
+
? null
|
|
518
|
+
: { problem: "result-file", id: s.id, path, why: "missing" };
|
|
519
|
+
};
|
|
520
|
+
|
|
521
|
+
const noSuccessor: RowCheck = ({ r, s }) => {
|
|
522
|
+
const by = s.superseded_by ?? null;
|
|
523
|
+
return s.status === "superseded" &&
|
|
524
|
+
(by === null || !r.studies.some((o) => o.id === by))
|
|
525
|
+
? { problem: "no-successor", id: s.id, superseded_by: by }
|
|
526
|
+
: null;
|
|
527
|
+
};
|
|
528
|
+
|
|
529
|
+
/** A row's `measurement`, when the registry declares its measurements, names one of them. */
|
|
530
|
+
const unknownMeasurement: RowCheck = ({ r, s }) =>
|
|
531
|
+
r.measurements === undefined ||
|
|
532
|
+
s.measurement === undefined ||
|
|
533
|
+
r.measurements.some((m) => m.id === s.measurement)
|
|
534
|
+
? null
|
|
535
|
+
: { problem: "unknown-measurement", id: s.id, measurement: s.measurement };
|
|
536
|
+
|
|
537
|
+
/** The state of the plan the row names (absent when outside its folder); null when it names none. */
|
|
538
|
+
const planOf = ({ s, input }: Row): PlanState | null => {
|
|
539
|
+
if (s.plan === undefined) return null;
|
|
540
|
+
return isInside(s.plan)
|
|
541
|
+
? (input.plans.get(`${s.folder}/${s.plan}`) ?? { kind: "absent" })
|
|
542
|
+
: { kind: "absent" };
|
|
543
|
+
};
|
|
544
|
+
|
|
545
|
+
const hasRun = (s: Study): boolean => RAN_STATUSES.includes(s.status);
|
|
546
|
+
|
|
547
|
+
/** The finding for the plan `path` of `s`, in the state its folder holds it; null when it stands. */
|
|
548
|
+
const planFinding = (
|
|
549
|
+
s: Study,
|
|
550
|
+
path: string,
|
|
551
|
+
state: PlanState,
|
|
552
|
+
): StudyFinding | null => {
|
|
553
|
+
const at = { problem: "bad-plan", id: s.id, path } as const;
|
|
554
|
+
switch (state.kind) {
|
|
555
|
+
case "absent":
|
|
556
|
+
return { ...at, why: "missing" };
|
|
557
|
+
case "unreadable":
|
|
558
|
+
return { ...at, why: "unreadable", reasons: state.why };
|
|
559
|
+
case "draft":
|
|
560
|
+
return hasRun(s)
|
|
561
|
+
? { ...at, why: "unfilled", fields: state.empty, status: s.status }
|
|
562
|
+
: null;
|
|
563
|
+
case "ready":
|
|
564
|
+
return null;
|
|
565
|
+
}
|
|
566
|
+
};
|
|
567
|
+
|
|
568
|
+
/** The plan a row names is a file inside its folder, of the schema, and ready once the study has run. */
|
|
569
|
+
const badPlan: RowCheck = ({ s, input }) => {
|
|
570
|
+
const path = s.plan;
|
|
571
|
+
if (path === undefined) return null;
|
|
572
|
+
if (!isInside(path))
|
|
573
|
+
return { problem: "bad-plan", id: s.id, path, why: "outside" };
|
|
574
|
+
return planFinding(
|
|
575
|
+
s,
|
|
576
|
+
path,
|
|
577
|
+
input.plans.get(`${s.folder}/${path}`) ?? { kind: "absent" },
|
|
578
|
+
);
|
|
579
|
+
};
|
|
580
|
+
|
|
581
|
+
/** A plan under its old name: read, and named for renaming. */
|
|
582
|
+
const legacyPlan: RowCheck = ({ s }) =>
|
|
583
|
+
s.plan !== undefined && posix.basename(s.plan) === LEGACY_PLAN_FILE
|
|
584
|
+
? { problem: "legacy-plan", id: s.id, path: s.plan }
|
|
585
|
+
: null;
|
|
586
|
+
|
|
587
|
+
/**
|
|
588
|
+
* A study of a headline measurement that has run carries the author's check of its drawn units in
|
|
589
|
+
* its plan, by the registry's author when it names one. A plan that cannot be read is `badPlan`'s.
|
|
590
|
+
*/
|
|
591
|
+
const authorCheck: RowCheck = (row) => {
|
|
592
|
+
const { r, s } = row;
|
|
593
|
+
const m = r.measurements?.find((o) => o.id === s.measurement);
|
|
594
|
+
if (m?.role !== "headline" || !hasRun(s)) return null;
|
|
595
|
+
const at = {
|
|
596
|
+
problem: "author-check",
|
|
597
|
+
id: s.id,
|
|
598
|
+
measurement: m.id,
|
|
599
|
+
status: s.status,
|
|
600
|
+
} as const;
|
|
601
|
+
const state = planOf(row);
|
|
602
|
+
if (state === null) return { ...at, why: "no-plan" };
|
|
603
|
+
const wrong =
|
|
604
|
+
state.kind === "ready" || state.kind === "draft"
|
|
605
|
+
? checkProblem(r, state.plan)
|
|
606
|
+
: null;
|
|
607
|
+
return wrong === null ? null : { ...at, ...wrong };
|
|
608
|
+
};
|
|
609
|
+
|
|
610
|
+
/** What a read plan's author check lacks: it is not there, or it is not the registry's author's. */
|
|
611
|
+
const checkProblem = (
|
|
612
|
+
r: StudyRegistry,
|
|
613
|
+
plan: StudyPlan,
|
|
614
|
+
):
|
|
615
|
+
| { readonly why: "missing" }
|
|
616
|
+
| { readonly why: "not-author"; readonly by: string; readonly author: string }
|
|
617
|
+
| null => {
|
|
618
|
+
const check = plan.author_check;
|
|
619
|
+
if (check === null) return { why: "missing" };
|
|
620
|
+
return r.author === undefined || check.by === r.author
|
|
621
|
+
? null
|
|
622
|
+
: { why: "not-author", by: check.by, author: r.author };
|
|
623
|
+
};
|
|
624
|
+
|
|
625
|
+
/** The checks of a row, in the order their findings are reported. */
|
|
626
|
+
const ROW_CHECKS: readonly RowCheck[] = [
|
|
627
|
+
badId,
|
|
628
|
+
duplicateId,
|
|
629
|
+
twoRows,
|
|
630
|
+
folderName,
|
|
631
|
+
noFolder,
|
|
632
|
+
longTitle,
|
|
633
|
+
noResult,
|
|
634
|
+
resultFile,
|
|
635
|
+
noSuccessor,
|
|
636
|
+
unknownMeasurement,
|
|
637
|
+
badPlan,
|
|
638
|
+
legacyPlan,
|
|
639
|
+
authorCheck,
|
|
640
|
+
];
|
|
641
|
+
|
|
642
|
+
/** A headline measurement's approval: missing, or not the registry's author's; null when it stands. */
|
|
643
|
+
const headlineApproval = (
|
|
644
|
+
r: StudyRegistry,
|
|
645
|
+
m: Measurement,
|
|
646
|
+
): StudyFinding | null => {
|
|
647
|
+
if (m.role !== "headline") return null;
|
|
648
|
+
const author = r.author ?? null;
|
|
649
|
+
if (m.approval === undefined)
|
|
650
|
+
return {
|
|
651
|
+
problem: "headline-approval",
|
|
652
|
+
measurement: m.id,
|
|
653
|
+
why: "missing",
|
|
654
|
+
author,
|
|
655
|
+
};
|
|
656
|
+
return author === null || m.approval.by === author
|
|
657
|
+
? null
|
|
658
|
+
: {
|
|
659
|
+
problem: "headline-approval",
|
|
660
|
+
measurement: m.id,
|
|
661
|
+
why: "not-author",
|
|
662
|
+
by: m.approval.by,
|
|
663
|
+
author,
|
|
664
|
+
};
|
|
665
|
+
};
|
|
666
|
+
|
|
667
|
+
/** What is wrong with one measurement: no study, a requirement not held, a headline not approved. */
|
|
668
|
+
const judgeMeasurement = (
|
|
669
|
+
r: StudyRegistry,
|
|
670
|
+
m: Measurement,
|
|
671
|
+
): readonly StudyFinding[] => {
|
|
672
|
+
const approval = headlineApproval(r, m);
|
|
673
|
+
return [
|
|
674
|
+
...(r.studies.some((s) => s.measurement === m.id)
|
|
675
|
+
? []
|
|
676
|
+
: [{ problem: "empty-measurement", measurement: m.id } as const]),
|
|
677
|
+
...(m.requirements ?? [])
|
|
678
|
+
.filter((id) => !(r.requirements ?? []).some((q) => q.id === id))
|
|
679
|
+
.map((requirement): StudyFinding => ({
|
|
680
|
+
problem: "unknown-requirement",
|
|
681
|
+
measurement: m.id,
|
|
682
|
+
requirement,
|
|
683
|
+
})),
|
|
684
|
+
...(approval === null ? [] : [approval]),
|
|
685
|
+
];
|
|
686
|
+
};
|
|
687
|
+
|
|
688
|
+
/** What is wrong with a row, in a fixed order. */
|
|
689
|
+
const judgeRow = (row: Row): readonly StudyFinding[] =>
|
|
690
|
+
ROW_CHECKS.map((check) => check(row)).filter(
|
|
691
|
+
(f): f is StudyFinding => f !== null,
|
|
692
|
+
);
|
|
693
|
+
|
|
694
|
+
/** Whether the README's index is the one `r` generates; the finding when not. */
|
|
695
|
+
function judgeIndex(
|
|
696
|
+
r: StudyRegistry,
|
|
697
|
+
readme: string | null,
|
|
698
|
+
): StudyFinding | null {
|
|
699
|
+
if (readme === null) return { problem: "stale-index", why: "no-readme" };
|
|
700
|
+
const block = embeddedIndex(readme);
|
|
701
|
+
if (block === null) return { problem: "stale-index", why: "no-markers" };
|
|
702
|
+
return indexInSync(readme, r)
|
|
703
|
+
? null
|
|
704
|
+
: { problem: "stale-index", why: "differs" };
|
|
705
|
+
}
|
|
706
|
+
|
|
707
|
+
/**
|
|
708
|
+
* Every defect of a paper's study registry, in a fixed order: the registry itself; the unregistered
|
|
709
|
+
* folders; each row's; the index. Pure.
|
|
710
|
+
*/
|
|
711
|
+
export function judgeStudies(input: StudyInput): readonly StudyFinding[] {
|
|
712
|
+
const state = input.registry;
|
|
713
|
+
if (state.kind === "absent")
|
|
714
|
+
return input.folders.length === 0
|
|
715
|
+
? []
|
|
716
|
+
: [{ problem: "no-registry", folders: input.folders }];
|
|
717
|
+
if (state.kind === "unreadable")
|
|
718
|
+
return [{ problem: "unreadable", why: state.why }];
|
|
719
|
+
const r = state.registry;
|
|
720
|
+
const next = nextStudyId(r);
|
|
721
|
+
const index = judgeIndex(r, input.readme);
|
|
722
|
+
return [
|
|
723
|
+
...input.folders
|
|
724
|
+
.filter((f) => !r.studies.some((s) => s.folder === f))
|
|
725
|
+
.map((folder): StudyFinding => ({
|
|
726
|
+
problem: "unregistered",
|
|
727
|
+
folder,
|
|
728
|
+
next,
|
|
729
|
+
})),
|
|
730
|
+
...r.studies.flatMap((s, i) => judgeRow({ r, s, i, input })),
|
|
731
|
+
...(r.measurements ?? []).flatMap((m) => judgeMeasurement(r, m)),
|
|
732
|
+
...(index === null ? [] : [index]),
|
|
733
|
+
];
|
|
734
|
+
}
|
|
735
|
+
|
|
736
|
+
// ── the index ─────────────────────────────────────────────────────────────────────
|
|
737
|
+
|
|
738
|
+
/** A cell's text: one line, a backslash and then a pipe escaped, so `a\\|b` stays one cell. */
|
|
739
|
+
const cell = (s: string): string =>
|
|
740
|
+
s.replace(/\r?\n/gu, " ").replace(/\\/gu, "\\\\").replace(/\|/gu, "\\|");
|
|
741
|
+
|
|
742
|
+
/** The columns of the index: each one's header, and its cell for a study. */
|
|
743
|
+
const COLUMNS: readonly {
|
|
744
|
+
readonly head: string;
|
|
745
|
+
readonly of: (s: Study) => string;
|
|
746
|
+
}[] = [
|
|
747
|
+
{ head: "id", of: (s) => s.id },
|
|
748
|
+
{ head: "title", of: (s) => cell(s.title) },
|
|
749
|
+
{ head: "status", of: (s) => s.status },
|
|
750
|
+
{ head: "result", of: (s) => cell(s.result ?? "—") },
|
|
751
|
+
{ head: "in paper", of: (s) => s.in_paper },
|
|
752
|
+
{ head: "folder", of: (s) => `[${s.folder}/](${s.folder}/)` },
|
|
753
|
+
];
|
|
754
|
+
|
|
755
|
+
/** The width a cell takes: its code points. */
|
|
756
|
+
const widthOf = (s: string): number => Array.from(s).length;
|
|
757
|
+
|
|
758
|
+
/** `t` padded to `width`. */
|
|
759
|
+
const padded = (t: string, width: number): string =>
|
|
760
|
+
t + " ".repeat(width - widthOf(t));
|
|
761
|
+
|
|
762
|
+
/**
|
|
763
|
+
* The index of `r`: a markdown table, a row per study in registry order, each column padded to its
|
|
764
|
+
* widest cell (at least 3), the way prettier writes a table — so formatting the README leaves it as
|
|
765
|
+
* it is.
|
|
766
|
+
*/
|
|
767
|
+
export function studyIndex(r: StudyRegistry): string {
|
|
768
|
+
const sized = COLUMNS.map((c) => ({
|
|
769
|
+
...c,
|
|
770
|
+
width: Math.max(
|
|
771
|
+
3,
|
|
772
|
+
widthOf(c.head),
|
|
773
|
+
...r.studies.map((s) => widthOf(c.of(s))),
|
|
774
|
+
),
|
|
775
|
+
}));
|
|
776
|
+
const line = (cells: readonly string[]): string => `| ${cells.join(" | ")} |`;
|
|
777
|
+
return [
|
|
778
|
+
line(sized.map((c) => padded(c.head, c.width))),
|
|
779
|
+
line(sized.map((c) => "-".repeat(c.width))),
|
|
780
|
+
...r.studies.map((s) => line(sized.map((c) => padded(c.of(s), c.width)))),
|
|
781
|
+
].join("\n");
|
|
782
|
+
}
|
|
783
|
+
|
|
784
|
+
/**
|
|
785
|
+
* What a markdown table says, whatever its padding: its rows, each cell trimmed, a separator cell as
|
|
786
|
+
* `---`. The index is judged on this, because the padding belongs to the formatter: prettier sizes a
|
|
787
|
+
* column by display width, so a wide character pads differently from a code-point count.
|
|
788
|
+
*/
|
|
789
|
+
const cellsOf = (table: string): string =>
|
|
790
|
+
table
|
|
791
|
+
.trim()
|
|
792
|
+
.split("\n")
|
|
793
|
+
.map((line) =>
|
|
794
|
+
line
|
|
795
|
+
.trim()
|
|
796
|
+
.replace(/^\||\|$/gu, "")
|
|
797
|
+
.split(/(?<!\\)\|/u)
|
|
798
|
+
.map((c) => c.trim().replace(/^:?-{3,}:?$/u, "---"))
|
|
799
|
+
.join("|"),
|
|
800
|
+
)
|
|
801
|
+
.join("\n");
|
|
802
|
+
|
|
803
|
+
/** Whether `readme` holds the index of `r`, whatever padding a formatter gave its table. */
|
|
804
|
+
export function indexInSync(readme: string | null, r: StudyRegistry): boolean {
|
|
805
|
+
const block = readme === null ? null : embeddedIndex(readme);
|
|
806
|
+
return block !== null && cellsOf(block) === cellsOf(studyIndex(r));
|
|
807
|
+
}
|
|
808
|
+
|
|
809
|
+
/** The index between its markers, a blank line on each side. */
|
|
810
|
+
export const indexBlock = (r: StudyRegistry): string =>
|
|
811
|
+
`${INDEX_BEGIN}\n\n${studyIndex(r)}\n\n${INDEX_END}`;
|
|
812
|
+
|
|
813
|
+
/**
|
|
814
|
+
* Where `marker` stands alone on a line of `text`, at or after `from`; -1 when it does not. A marker
|
|
815
|
+
* counts only there, where the generator writes it: prose can mention it and a cell can carry it, but
|
|
816
|
+
* neither puts it alone on a line.
|
|
817
|
+
*/
|
|
818
|
+
const markerAt = (text: string, marker: string, from = 0): number => {
|
|
819
|
+
const literal = marker.replace(/[.*+?^${}()|[\]\\]/gu, "\\$&");
|
|
820
|
+
const m = new RegExp(`^${literal}$`, "mu").exec(text.slice(from));
|
|
821
|
+
return m === null ? -1 : from + m.index;
|
|
822
|
+
};
|
|
823
|
+
|
|
824
|
+
/** The text between the markers of `readme`; null when it has no begin marker followed by an end one. */
|
|
825
|
+
export function embeddedIndex(readme: string): string | null {
|
|
826
|
+
const begin = markerAt(readme, INDEX_BEGIN);
|
|
827
|
+
const end = begin === -1 ? -1 : markerAt(readme, INDEX_END, begin);
|
|
828
|
+
return end === -1 ? null : readme.slice(begin + INDEX_BEGIN.length, end);
|
|
829
|
+
}
|
|
830
|
+
|
|
831
|
+
/**
|
|
832
|
+
* `readme` with its index regenerated from `r`: the block replaced in place, appended when the README
|
|
833
|
+
* has none, a README of its own when there is none.
|
|
834
|
+
*/
|
|
835
|
+
export function withIndex(readme: string | null, r: StudyRegistry): string {
|
|
836
|
+
if (readme === null) return `${INDEX_HEADING}\n\n${indexBlock(r)}\n`;
|
|
837
|
+
const block = embeddedIndex(readme);
|
|
838
|
+
if (block === null) return `${readme.trimEnd()}\n\n${indexBlock(r)}\n`;
|
|
839
|
+
const begin = markerAt(readme, INDEX_BEGIN);
|
|
840
|
+
const after = begin + INDEX_BEGIN.length + block.length + INDEX_END.length;
|
|
841
|
+
return `${readme.slice(0, begin)}${indexBlock(r)}${readme.slice(after)}`;
|
|
842
|
+
}
|
|
843
|
+
|
|
844
|
+
/** The registry's text as `paperlint studies new` writes it: JSON, two-space indented, a final newline. */
|
|
845
|
+
export const registryText = (r: StudyRegistry): string =>
|
|
846
|
+
`${JSON.stringify(r, null, 2)}\n`;
|
|
847
|
+
|
|
848
|
+
// ── the messages ──────────────────────────────────────────────────────────────────
|
|
849
|
+
|
|
850
|
+
const REGISTRY = "`repro/studies.json`";
|
|
851
|
+
const NEW_STUDY = '`npx paperlint studies new <paper-dir> "<title>"`';
|
|
852
|
+
const RUN_INDEX =
|
|
853
|
+
"What to do: run `npx paperlint studies index <paper-dir>`; the block is generated, never edited by hand.";
|
|
854
|
+
const RENAME =
|
|
855
|
+
"give each other row the next free id and rename its folder to match";
|
|
856
|
+
|
|
857
|
+
const tick = (s: string): string => `\`${s}\``;
|
|
858
|
+
|
|
859
|
+
/** `a`, `b` and `c`: names in backticks, the last joined by "and". */
|
|
860
|
+
const listed = (names: readonly string[]): string => {
|
|
861
|
+
const t = names.map(tick);
|
|
862
|
+
return [t.slice(0, -1).join(", "), ...t.slice(-1)]
|
|
863
|
+
.filter((part) => part !== "")
|
|
864
|
+
.join(" and ");
|
|
865
|
+
};
|
|
866
|
+
|
|
867
|
+
/** Each finding by its problem: the map the messages are looked up in. */
|
|
868
|
+
type FindingOf = {
|
|
869
|
+
readonly [F in StudyFinding as F["problem"]]: F;
|
|
870
|
+
};
|
|
871
|
+
type Problem = keyof FindingOf;
|
|
872
|
+
|
|
873
|
+
const STALE_HEAD: Readonly<Record<FindingOf["stale-index"]["why"], string>> = {
|
|
874
|
+
differs: `The index in \`repro/README.md\` (between the \`studies:begin\` and \`studies:end\` markers) is not the one ${REGISTRY} generates.`,
|
|
875
|
+
"no-markers":
|
|
876
|
+
"`repro/README.md` has no index of the studies (the `studies:begin` and `studies:end` markers).",
|
|
877
|
+
"no-readme": "`repro/README.md`, the index of the studies, does not exist.",
|
|
878
|
+
};
|
|
879
|
+
|
|
880
|
+
/** The first part of each finding's text: what is wrong, and what to do. */
|
|
881
|
+
const HEADS: { readonly [P in Problem]: (f: FindingOf[P]) => string } = {
|
|
882
|
+
"no-registry": (f) =>
|
|
883
|
+
`\`repro/\` holds study folders (${f.folders.map((d) => tick(`${d}/`)).join(", ")}) but no ${REGISTRY}, the registry that gives every study an id, a plain name and a status. What to do: write ${REGISTRY} with the paper's prefix — \`{ "prefix": "P1", "studies": [] }\` — then give each folder its row and rename it to \`<id>-<slug>\`; a new study is created with ${NEW_STUDY}.`,
|
|
884
|
+
unreadable: (f) =>
|
|
885
|
+
`${REGISTRY} is not a study registry: ${f.why}. Its shape is on the rule's page.`,
|
|
886
|
+
unregistered: (f) =>
|
|
887
|
+
`\`repro/${f.folder}/\` has no row in ${REGISTRY}, and every study folder has exactly one. What to do: add its row with the next free id, ${tick(f.next)}, and rename the folder to \`${f.next}-<slug>\`; a new study is created with ${NEW_STUDY}, which does both.`,
|
|
888
|
+
"no-folder": (f) =>
|
|
889
|
+
`${REGISTRY}: the row ${tick(f.id)} names the folder \`repro/${f.folder}/\`, which does not exist. What to do: rename the study's folder to it, or correct the row's \`folder\`.`,
|
|
890
|
+
"two-rows": (f) =>
|
|
891
|
+
`${REGISTRY}: the rows ${f.ids.map(tick).join(", ")} all name the folder \`repro/${f.folder}/\`, and a study folder has exactly one row. What to do: keep one row for it.`,
|
|
892
|
+
"bad-id": (f) =>
|
|
893
|
+
`${REGISTRY}: the id ${tick(f.id)} is not \`${f.prefix}-S\` followed by two or more digits (\`${f.prefix}-S01\`). What to do: give the row the next free id and rename its folder to match.`,
|
|
894
|
+
"duplicate-id": (f) =>
|
|
895
|
+
`${REGISTRY}: the id ${tick(f.id)} is on more than one row, and an id names one study. What to do: ${RENAME}.`,
|
|
896
|
+
"folder-name": (f) =>
|
|
897
|
+
`${REGISTRY}: the row ${tick(f.id)} names the folder ${tick(f.folder)}, which is not \`${f.id}-<slug>\` — the id, then lowercase letters and digits in words joined by hyphens. What to do: rename the folder and the row's \`folder\` together.`,
|
|
898
|
+
"long-title": (f) =>
|
|
899
|
+
`${REGISTRY}: the title of ${tick(f.id)} has ${String(f.words)} words; a title is a plain name of at most ${String(MAX_TITLE_WORDS)}. What to do: shorten it, and put the detail in \`question\`.`,
|
|
900
|
+
"no-result": (f) =>
|
|
901
|
+
`${REGISTRY}: ${tick(f.id)} is \`done\` without ${f.missing.map(tick).join(" and ")}. What to do: write the one-line result and the file it is read from (\`result\`, \`result_file\`), or set the status it really has.`,
|
|
902
|
+
"result-file": (f) =>
|
|
903
|
+
`${REGISTRY}: the \`result_file\` of ${tick(f.id)}, ${tick(f.path)}, ${f.why === "missing" ? "is not a file in its folder" : "is outside its folder"}. What to do: name a file that exists, relative to the study's folder.`,
|
|
904
|
+
"no-successor": (f) =>
|
|
905
|
+
`${REGISTRY}: ${tick(f.id)} is \`superseded\` ${f.superseded_by === null ? "without `superseded_by`" : `by ${tick(f.superseded_by)}, which is no study in the registry`}. What to do: name the id of the study that replaced it.`,
|
|
906
|
+
"stale-index": (f) => `${STALE_HEAD[f.why]} ${RUN_INDEX}`,
|
|
907
|
+
"unknown-measurement": (f) =>
|
|
908
|
+
`${REGISTRY}: the row ${tick(f.id)} names the measurement ${tick(f.measurement)}, which is not in \`measurements\`. What to do: add the measurement to \`measurements\`, or correct the row's \`measurement\`.`,
|
|
909
|
+
"empty-measurement": (f) =>
|
|
910
|
+
`${REGISTRY}: no row names the measurement ${tick(f.measurement)}, and a measurement is made of studies. What to do: set \`"measurement": "${f.measurement}"\` on the rows of its studies, or remove the measurement.`,
|
|
911
|
+
"unknown-requirement": (f) =>
|
|
912
|
+
`${REGISTRY}: the measurement ${tick(f.measurement)} must meet the requirement ${tick(f.requirement)}, which is not in \`requirements\`. What to do: add the requirement — the author's words verbatim in \`text\`, with its \`date\` and \`source\` — or correct the measurement's \`requirements\`.`,
|
|
913
|
+
"headline-approval": (f) =>
|
|
914
|
+
f.why === "missing"
|
|
915
|
+
? `${REGISTRY}: the measurement ${tick(f.measurement)} is a \`headline\` one and has no \`approval\`. What to do: record ${f.author === null ? "the author's approval" : `the approval of ${f.author}, the registry's \`author\``} — \`by\`, \`date\`, and their words in \`quote\` — or make it a \`baseline\`.`
|
|
916
|
+
: `${REGISTRY}: the measurement ${tick(f.measurement)} is a \`headline\` one, and its \`approval\` is by ${f.by}, not by the registry's \`author\`, ${f.author}. What to do: only the author approves a headline measurement; record their own approval.`,
|
|
917
|
+
"bad-plan": (f) => {
|
|
918
|
+
const of = `the \`plan\` of ${tick(f.id)}, ${tick(f.path)},`;
|
|
919
|
+
const plan = `The plan of ${tick(f.id)}, ${tick(f.path)} in its folder,`;
|
|
920
|
+
switch (f.why) {
|
|
921
|
+
case "missing":
|
|
922
|
+
return `${REGISTRY}: ${of} is not a file in its folder. What to do: write the plan there (its fields are on the rule's page), or correct the row's \`plan\`.`;
|
|
923
|
+
case "outside":
|
|
924
|
+
return `${REGISTRY}: ${of} is outside its folder. What to do: keep the plan in the study's folder, as \`${PLAN_FILE}\`, and name it relative to the folder.`;
|
|
925
|
+
case "unreadable":
|
|
926
|
+
return `${plan} is not a study plan: ${f.reasons}. What to do: give it the fields on the rule's page — \`question\`, \`population\`, \`arms\`, \`primary_outcome\`.`;
|
|
927
|
+
case "unfilled":
|
|
928
|
+
return `${plan} leaves ${listed(f.fields)} empty, and the study is ${tick(f.status)}: a plan is written before the runs. What to do: fill them in, or set the study back to \`designing\`.`;
|
|
929
|
+
}
|
|
930
|
+
},
|
|
931
|
+
"author-check": (f) => {
|
|
932
|
+
const head = `${tick(f.id)} is ${tick(f.status)} in the headline measurement ${tick(f.measurement)}, and`;
|
|
933
|
+
switch (f.why) {
|
|
934
|
+
case "no-plan":
|
|
935
|
+
return `${head} its row names no plan to hold the author's check of the drawn units. What to do: write its plan (\`${PLAN_FILE}\` in its folder, named in the row's \`plan\`) with \`author_check\` — \`by\`, \`date\`, and the author's words in \`quote\`.`;
|
|
936
|
+
case "missing":
|
|
937
|
+
return `${head} its plan has no \`author_check\`. What to do: show the author the drawn units, then record their answer in the plan's \`author_check\` — \`by\`, \`date\`, and their words in \`quote\`.`;
|
|
938
|
+
case "not-author":
|
|
939
|
+
return `${head} the \`author_check\` in its plan is by ${f.by}, not by the registry's \`author\`, ${f.author}. What to do: only the author approves the drawn units of a headline measurement; record their own answer.`;
|
|
940
|
+
}
|
|
941
|
+
},
|
|
942
|
+
"legacy-plan": (f) =>
|
|
943
|
+
`The plan of ${tick(f.id)} is ${tick(f.path)}, its old name; it is read as the plan. What to do: rename it to \`${PLAN_FILE}\` in the study's folder (\`git mv\`) and set the row's \`plan\` to \`${PLAN_FILE}\`.`,
|
|
944
|
+
};
|
|
945
|
+
|
|
946
|
+
/** The head of finding `f` of problem `p`: the lookup, typed by the problem. */
|
|
947
|
+
const headOf = <P extends Problem>(p: P, f: FindingOf[P]): string =>
|
|
948
|
+
HEADS[p](f);
|
|
949
|
+
|
|
950
|
+
/** The rule that reports finding `f`. */
|
|
951
|
+
const ruleOf = (f: StudyFinding): string =>
|
|
952
|
+
PLAN_NAME_PROBLEMS.includes(f.problem)
|
|
953
|
+
? "repro/study-plan-name"
|
|
954
|
+
: "repro/study-registered";
|
|
955
|
+
|
|
956
|
+
/** The one text a finding is reported with: what is wrong, what to do, and the rule's page. */
|
|
957
|
+
export const studyMessage = (f: StudyFinding): string =>
|
|
958
|
+
`${headOf(f.problem, f)}\nRule ${ruleOf(f)}: https://github.com/zernie/paperlint/blob/main/docs/rules/${ruleOf(f)}.md`;
|
|
959
|
+
|
|
960
|
+
// ── a new study ───────────────────────────────────────────────────────────────────
|
|
961
|
+
|
|
962
|
+
/** Whether `s` names a kind of study. */
|
|
963
|
+
export const isStudyKind = (s: string): s is StudyKind =>
|
|
964
|
+
STUDY_KINDS.some((k) => k === s);
|
|
965
|
+
|
|
966
|
+
/** What a new study is made from. */
|
|
967
|
+
export interface NewStudy {
|
|
968
|
+
readonly title: string;
|
|
969
|
+
readonly kind: StudyKind;
|
|
970
|
+
/** The day it is registered, `YYYY-MM-DD`. */
|
|
971
|
+
readonly date: string;
|
|
972
|
+
}
|
|
973
|
+
|
|
974
|
+
/** Why `title` cannot name a study; null when it can. */
|
|
975
|
+
function titleProblem(title: string): string | null {
|
|
976
|
+
const words = titleWords(title);
|
|
977
|
+
if (words === 0) return "the title is empty";
|
|
978
|
+
if (words > MAX_TITLE_WORDS)
|
|
979
|
+
return `the title has ${String(words)} words; a title is a plain name of at most ${String(MAX_TITLE_WORDS)} — the detail goes in the row's \`question\``;
|
|
980
|
+
return studySlug(title) === ""
|
|
981
|
+
? "the title has no Latin letter or digit to name the folder by; give it one (an English title, or a word such as the method's name)"
|
|
982
|
+
: null;
|
|
983
|
+
}
|
|
984
|
+
|
|
985
|
+
/** A registry with a study added, and the study; or why it could not be. */
|
|
986
|
+
export type Added =
|
|
987
|
+
| {
|
|
988
|
+
readonly ok: true;
|
|
989
|
+
readonly registry: StudyRegistry;
|
|
990
|
+
readonly study: Study;
|
|
991
|
+
}
|
|
992
|
+
| { readonly ok: false; readonly why: string };
|
|
993
|
+
|
|
994
|
+
/**
|
|
995
|
+
* `r` with a new study appended: the next id, the folder `<id>-<slug>`, `status: designing`,
|
|
996
|
+
* `in_paper: no`, an empty question and its plan, `plan.json`; or why the title cannot make one.
|
|
997
|
+
*/
|
|
998
|
+
export function addStudy(r: StudyRegistry, n: NewStudy): Added {
|
|
999
|
+
const title = n.title.trim().replace(/\s+/gu, " ");
|
|
1000
|
+
const why = titleProblem(title);
|
|
1001
|
+
if (why !== null) return { ok: false, why };
|
|
1002
|
+
const id = nextStudyId(r);
|
|
1003
|
+
const study: Study = {
|
|
1004
|
+
id,
|
|
1005
|
+
folder: `${id}-${studySlug(title)}`,
|
|
1006
|
+
title,
|
|
1007
|
+
question: "",
|
|
1008
|
+
kind: n.kind,
|
|
1009
|
+
status: "designing",
|
|
1010
|
+
date: n.date,
|
|
1011
|
+
in_paper: "no",
|
|
1012
|
+
plan: PLAN_FILE,
|
|
1013
|
+
};
|
|
1014
|
+
return {
|
|
1015
|
+
ok: true,
|
|
1016
|
+
registry: { ...r, studies: [...r.studies, study] },
|
|
1017
|
+
study,
|
|
1018
|
+
};
|
|
1019
|
+
}
|
|
1020
|
+
|
|
1021
|
+
/** The `README.md` a new study's folder starts with: its id and title in frontmatter, then the sections. */
|
|
1022
|
+
export const studyReadme = (s: Study): string =>
|
|
1023
|
+
[
|
|
1024
|
+
"---",
|
|
1025
|
+
`id: ${s.id}`,
|
|
1026
|
+
`title: ${JSON.stringify(s.title)}`,
|
|
1027
|
+
"---",
|
|
1028
|
+
"",
|
|
1029
|
+
`# ${s.id} — ${s.title}`,
|
|
1030
|
+
"",
|
|
1031
|
+
`Its row in \`../${STUDIES_FILE}\` holds its status. When the study ends, set \`status\`, \`result\`,`,
|
|
1032
|
+
"`result_file` and `in_paper` there, then run `npx paperlint studies index <paper-dir>`.",
|
|
1033
|
+
"",
|
|
1034
|
+
`Its plan is \`${PLAN_FILE}\`: what it measures, the population and the draw, the arms and the primary`,
|
|
1035
|
+
"outcome, filled in before the first run; the author's check of the drawn units goes in its",
|
|
1036
|
+
"`author_check`.",
|
|
1037
|
+
"",
|
|
1038
|
+
"## Question",
|
|
1039
|
+
"",
|
|
1040
|
+
"## How to run",
|
|
1041
|
+
"",
|
|
1042
|
+
"## Result",
|
|
1043
|
+
"",
|
|
1044
|
+
].join("\n");
|