paperlint 10.2.0 → 10.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.github/workflows/ci.yml +15 -4
- package/README.md +1 -0
- package/dist/adapters/git/index.d.ts +10 -0
- package/dist/adapters/git/index.d.ts.map +1 -0
- package/dist/adapters/git/index.js +151 -0
- package/dist/adapters/git/index.js.map +1 -0
- package/dist/adapters/memory/index.d.ts +6 -0
- package/dist/adapters/memory/index.d.ts.map +1 -1
- package/dist/adapters/memory/index.js +8 -0
- package/dist/adapters/memory/index.js.map +1 -1
- package/dist/adapters/node/index.d.ts +1 -0
- package/dist/adapters/node/index.d.ts.map +1 -1
- package/dist/adapters/node/index.js +1 -0
- package/dist/adapters/node/index.js.map +1 -1
- package/dist/adapters/node/list-entries.io.d.ts +3 -0
- package/dist/adapters/node/list-entries.io.d.ts.map +1 -0
- package/dist/adapters/node/list-entries.io.js +24 -0
- package/dist/adapters/node/list-entries.io.js.map +1 -0
- package/dist/adapters/study-registry/index.d.ts +412 -1
- package/dist/adapters/study-registry/index.d.ts.map +1 -1
- package/dist/adapters/study-registry/index.js +245 -8
- package/dist/adapters/study-registry/index.js.map +1 -1
- package/dist/cli.d.ts.map +1 -1
- package/dist/cli.js +38 -6
- package/dist/cli.js.map +1 -1
- package/dist/domain/decide-first.d.ts +100 -0
- package/dist/domain/decide-first.d.ts.map +1 -0
- package/dist/domain/decide-first.js +102 -0
- package/dist/domain/decide-first.js.map +1 -0
- package/dist/domain/index-columns.d.ts +45 -0
- package/dist/domain/index-columns.d.ts.map +1 -0
- package/dist/domain/index-columns.js +62 -0
- package/dist/domain/index-columns.js.map +1 -0
- package/dist/domain/study-mode.d.ts +37 -0
- package/dist/domain/study-mode.d.ts.map +1 -0
- package/dist/domain/study-mode.js +45 -0
- package/dist/domain/study-mode.js.map +1 -0
- package/dist/domain/study-registry.d.ts +253 -10
- package/dist/domain/study-registry.d.ts.map +1 -1
- package/dist/domain/study-registry.js +325 -27
- package/dist/domain/study-registry.js.map +1 -1
- package/dist/outputs-before-decision-rule.d.ts +17 -0
- package/dist/outputs-before-decision-rule.d.ts.map +1 -0
- package/dist/outputs-before-decision-rule.js +90 -0
- package/dist/outputs-before-decision-rule.js.map +1 -0
- package/dist/ports/git-history.d.ts +44 -0
- package/dist/ports/git-history.d.ts.map +1 -0
- package/dist/ports/git-history.js +2 -0
- package/dist/ports/git-history.js.map +1 -0
- package/dist/ports/list-entries.d.ts +17 -0
- package/dist/ports/list-entries.d.ts.map +1 -0
- package/dist/ports/list-entries.js +2 -0
- package/dist/ports/list-entries.js.map +1 -0
- package/dist/ports/study-registry-codec.d.ts +9 -2
- package/dist/ports/study-registry-codec.d.ts.map +1 -1
- package/dist/studies.d.ts +4 -0
- package/dist/studies.d.ts.map +1 -1
- package/dist/studies.js +55 -24
- package/dist/studies.js.map +1 -1
- package/dist/study-gate.d.ts +14 -0
- package/dist/study-gate.d.ts.map +1 -0
- package/dist/study-gate.js +95 -0
- package/dist/study-gate.js.map +1 -0
- package/dist/study-index-input.d.ts +5 -0
- package/dist/study-index-input.d.ts.map +1 -0
- package/dist/study-index-input.js +13 -0
- package/dist/study-index-input.js.map +1 -0
- package/dist/study-modes.d.ts +45 -0
- package/dist/study-modes.d.ts.map +1 -0
- package/dist/study-modes.js +70 -0
- package/dist/study-modes.js.map +1 -0
- package/dist/study-registry-rule.d.ts +29 -6
- package/dist/study-registry-rule.d.ts.map +1 -1
- package/dist/study-registry-rule.js +91 -40
- package/dist/study-registry-rule.js.map +1 -1
- package/dist/study-run.d.ts +19 -0
- package/dist/study-run.d.ts.map +1 -0
- package/dist/study-run.js +9 -0
- package/dist/study-run.js.map +1 -0
- package/docs/design/study-modes.md +247 -0
- package/docs/design/study-registry.md +137 -15
- package/docs/e2e.md +8 -4
- package/docs/layout.md +26 -21
- package/docs/rules/repro/outputs-before-decision.md +119 -0
- package/docs/rules/repro/study-plan-name.md +70 -0
- package/docs/rules/repro/study-registered.md +206 -15
- package/docs/rules.md +54 -52
- package/docs/testing.md +1 -0
- package/package.json +2 -1
- package/scripts/check.ts +6 -0
- package/skills/build-benchmark/SKILL.md +24 -12
- package/skills/build-benchmark/SKILL.md.spec.ts +23 -11
- package/skills/build-label-page/SKILL.md +9 -7
- package/skills/build-label-page/SKILL.md.spec.ts +8 -6
- package/skills/camera-ready/SKILL.md +6 -1
- package/skills/camera-ready/SKILL.md.spec.ts +5 -0
- package/skills/draft-paper/SKILL.md +11 -3
- package/skills/draft-paper/SKILL.md.spec.ts +10 -2
- package/skills/harden-paper/SKILL.md +12 -2
- package/skills/harden-paper/SKILL.md.spec.ts +11 -1
- package/skills/osf-artifact-upload/SKILL.md +7 -1
- package/skills/osf-artifact-upload/SKILL.md.spec.ts +6 -0
- package/skills/pc-panel-review/SKILL.md +10 -3
- package/skills/pc-panel-review/SKILL.md.spec.ts +9 -2
- package/skills/run-agent-grid/SKILL.md +10 -5
- package/skills/run-agent-grid/SKILL.md.spec.ts +9 -4
- package/skills/tighten-paper/SKILL.md +5 -1
- package/skills/tighten-paper/SKILL.md.spec.ts +4 -0
- package/src/adapters/git/history.test.ts +121 -0
- package/src/adapters/git/index.test.ts +268 -0
- package/src/adapters/git/index.ts +227 -0
- package/src/adapters/memory/index.test.ts +15 -1
- package/src/adapters/memory/index.ts +13 -0
- package/src/adapters/node/index.ts +1 -0
- package/src/adapters/node/list-entries.io.ts +26 -0
- package/src/adapters/node/list-entries.test.ts +43 -0
- package/src/adapters/study-registry/index.test.ts +343 -0
- package/src/adapters/study-registry/index.ts +374 -10
- package/src/adapters/study-registry/mode.test.ts +162 -0
- package/src/cli.ts +52 -6
- package/src/domain/decide-first.test.ts +258 -0
- package/src/domain/decide-first.ts +243 -0
- package/src/domain/index-columns.test.ts +216 -0
- package/src/domain/index-columns.ts +105 -0
- package/src/domain/study-measurements.test.ts +633 -0
- package/src/domain/study-mode.test.ts +55 -0
- package/src/domain/study-mode.ts +78 -0
- package/src/domain/study-registry.test.ts +48 -32
- package/src/domain/study-registry.ts +621 -36
- package/src/new-paper.test.ts +6 -1
- package/src/outputs-before-decision-rule.test.ts +206 -0
- package/src/outputs-before-decision-rule.ts +124 -0
- package/src/ports/git-history.ts +44 -0
- package/src/ports/list-entries.ts +18 -0
- package/src/ports/study-registry-codec.ts +13 -2
- package/src/rule-cases.test.ts +103 -1
- package/src/studies-cli.test.ts +2 -2
- package/src/studies-index-modes.test.ts +129 -0
- package/src/studies.test.ts +39 -9
- package/src/studies.ts +99 -29
- package/src/study-gate.test.ts +316 -0
- package/src/study-gate.ts +161 -0
- package/src/study-index-input.ts +23 -0
- package/src/study-modes.ts +120 -0
- package/src/study-registry-rule.test.ts +333 -7
- package/src/study-registry-rule.ts +145 -41
- package/src/study-run.ts +21 -0
|
@@ -10,12 +10,22 @@
|
|
|
10
10
|
* ── JSON, NEVER MARKDOWN ─────────────────────────────────────────────────────────
|
|
11
11
|
* The registry is data: its text is parsed once, where it enters, against the schema in
|
|
12
12
|
* `src/adapters/study-registry/` (`StudyRegistryCodec`). The index in `repro/README.md` is OUTPUT: it is
|
|
13
|
-
* generated from the registry
|
|
13
|
+
* generated from the registry, the plans' modes and git's history (src/domain/index-columns.ts), and
|
|
14
|
+
* compared cell by cell between two fixed markers, the two columns read from git left out. Nothing
|
|
15
|
+
* else parses it.
|
|
14
16
|
*
|
|
15
17
|
* Pure: the caller lists `repro/`, reads the registry and the README, and says which result files exist.
|
|
16
18
|
*/
|
|
17
19
|
import { isAbsolute, posix, win32 } from "node:path";
|
|
18
20
|
import { STUDIES_FILE, STUDY_SLUG } from "./repro-layout.ts";
|
|
21
|
+
import { needsMode, type StudyMode } from "./study-mode.ts";
|
|
22
|
+
import {
|
|
23
|
+
decisionCell,
|
|
24
|
+
firstOutputCell,
|
|
25
|
+
modeCell,
|
|
26
|
+
type IndexFacts,
|
|
27
|
+
type IndexHistory,
|
|
28
|
+
} from "./index-columns.ts";
|
|
19
29
|
|
|
20
30
|
/** The registry, at the top of the paper's `repro/`; the layout allows it there. */
|
|
21
31
|
export { STUDIES_FILE };
|
|
@@ -61,6 +71,55 @@ export const INDEX_END = "<!-- studies:end -->";
|
|
|
61
71
|
/** The README written when `repro/` has none. */
|
|
62
72
|
const INDEX_HEADING = "# Reproduction index";
|
|
63
73
|
|
|
74
|
+
/** What a measurement is to the paper: the claim it carries, or what that claim is compared with. */
|
|
75
|
+
export const MEASUREMENT_ROLES = ["headline", "baseline"] as const;
|
|
76
|
+
export type MeasurementRole = (typeof MEASUREMENT_ROLES)[number];
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* The statuses of a study that has run: its plan was due before the first run. A `designing` study
|
|
80
|
+
* has not run yet, and a `dropped` one never will.
|
|
81
|
+
*/
|
|
82
|
+
export const RAN_STATUSES: readonly StudyStatus[] = [
|
|
83
|
+
"running",
|
|
84
|
+
"done",
|
|
85
|
+
"stopped",
|
|
86
|
+
"superseded",
|
|
87
|
+
];
|
|
88
|
+
|
|
89
|
+
/** A requirement of the author's on the paper's measurements, in their words. */
|
|
90
|
+
export interface Requirement {
|
|
91
|
+
readonly id: string;
|
|
92
|
+
/** The author's words, verbatim. */
|
|
93
|
+
readonly text: string;
|
|
94
|
+
readonly date: string;
|
|
95
|
+
/** Where they said it: a chat, a message, a review comment. */
|
|
96
|
+
readonly source: string;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/** Someone's approval, in their words: of a measurement, or of a study's drawn units. */
|
|
100
|
+
export interface Approval {
|
|
101
|
+
readonly by: string;
|
|
102
|
+
readonly date: string;
|
|
103
|
+
readonly quote: string;
|
|
104
|
+
readonly source?: string | undefined;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* One measurement: what the paper measures, made of one or more studies (a pilot, the main run, a
|
|
109
|
+
* replication) whose rows name it in `measurement`. A measurement may carry more fields.
|
|
110
|
+
*/
|
|
111
|
+
export interface Measurement {
|
|
112
|
+
readonly id: string;
|
|
113
|
+
readonly title: string;
|
|
114
|
+
/** The research question it answers. */
|
|
115
|
+
readonly rq?: string | undefined;
|
|
116
|
+
readonly role: MeasurementRole;
|
|
117
|
+
readonly approval?: Approval | undefined;
|
|
118
|
+
/** The ids of the requirements it must meet. */
|
|
119
|
+
readonly requirements?: readonly string[] | undefined;
|
|
120
|
+
readonly [extra: string]: unknown;
|
|
121
|
+
}
|
|
122
|
+
|
|
64
123
|
/**
|
|
65
124
|
* One study. The named fields are the registry's; a row may carry more (a paper merges its own
|
|
66
125
|
* measurement metadata into it), and they are kept as they are.
|
|
@@ -77,14 +136,155 @@ export interface Study {
|
|
|
77
136
|
readonly result_file?: string | undefined;
|
|
78
137
|
readonly superseded_by?: string | undefined;
|
|
79
138
|
readonly in_paper: InPaper;
|
|
139
|
+
/** The id of the measurement the study belongs to. */
|
|
140
|
+
readonly measurement?: string | undefined;
|
|
141
|
+
/** The study's plan: a path inside its folder, `plan.json`. */
|
|
142
|
+
readonly plan?: string | undefined;
|
|
80
143
|
readonly [extra: string]: unknown;
|
|
81
144
|
}
|
|
82
145
|
|
|
83
146
|
export interface StudyRegistry {
|
|
84
147
|
readonly prefix: string;
|
|
148
|
+
/** The paper's author: the one person whose approval makes a measurement the headline. */
|
|
149
|
+
readonly author?: string | undefined;
|
|
150
|
+
readonly requirements?: readonly Requirement[] | undefined;
|
|
151
|
+
/** When present, every row's `measurement` names one of them. */
|
|
152
|
+
readonly measurements?: readonly Measurement[] | undefined;
|
|
85
153
|
readonly studies: readonly Study[];
|
|
86
154
|
}
|
|
87
155
|
|
|
156
|
+
/** The file `paperlint studies new` writes a study's plan to, beside its README.md. */
|
|
157
|
+
export const PLAN_FILE = "plan.json";
|
|
158
|
+
|
|
159
|
+
/** The plan's name before it was a plan: still read as one, and reported by `repro/study-plan-name`. */
|
|
160
|
+
export const LEGACY_PLAN_FILE = "prereg.json";
|
|
161
|
+
|
|
162
|
+
/** The author's check of a study's drawn units, before the runs. */
|
|
163
|
+
export type AuthorCheck = Omit<Approval, "source">;
|
|
164
|
+
|
|
165
|
+
/**
|
|
166
|
+
* A study's plan, fixed before its runs: what it measures, on what population and how it is drawn,
|
|
167
|
+
* its arms, its primary outcome, and the author's check of the drawn units. Closed: a key the paper
|
|
168
|
+
* needs of its own goes in `extra`, so a misspelt key is an error, not a key nobody reads. "Not
|
|
169
|
+
* known" and "not yet" have one spelling each: null.
|
|
170
|
+
*/
|
|
171
|
+
export interface StudyPlan {
|
|
172
|
+
readonly question: string;
|
|
173
|
+
readonly population: {
|
|
174
|
+
/** Where the units come from: the census, crawl or registry, with its date. */
|
|
175
|
+
readonly source: string;
|
|
176
|
+
readonly draw: Draw;
|
|
177
|
+
};
|
|
178
|
+
/** The conditions compared, each name once; none for a census. */
|
|
179
|
+
readonly arms: readonly { readonly name: string }[];
|
|
180
|
+
readonly primary_outcome: string;
|
|
181
|
+
/** null: the author has not checked the drawn units yet. Only a ready plan carries one. */
|
|
182
|
+
readonly author_check: AuthorCheck | null;
|
|
183
|
+
/** The paper's own keys: hypotheses, models, budget … Kept, never read; none is a plan field. */
|
|
184
|
+
readonly extra?: Readonly<Record<string, unknown>> | undefined;
|
|
185
|
+
/**
|
|
186
|
+
* How the outcomes are kept from the decisions on the inputs: decided before the run, or
|
|
187
|
+
* exploratory (src/domain/study-mode.ts). A measurement study's plan is not ready without it.
|
|
188
|
+
*/
|
|
189
|
+
readonly mode?: StudyMode | undefined;
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
/**
|
|
193
|
+
* How the sample is drawn from the population: a seeded draw, whose seed is part of it, or all of
|
|
194
|
+
* the population (a census), which has none. The size of the population is not stored: it is
|
|
195
|
+
* counted from the source, and a stored copy would only contradict it.
|
|
196
|
+
*/
|
|
197
|
+
export type Draw =
|
|
198
|
+
| {
|
|
199
|
+
readonly kind: "seeded";
|
|
200
|
+
/** `random`, `stratified` … */
|
|
201
|
+
readonly method: string;
|
|
202
|
+
readonly seed: string | number;
|
|
203
|
+
}
|
|
204
|
+
| {
|
|
205
|
+
readonly kind: "all";
|
|
206
|
+
/** `census` … */
|
|
207
|
+
readonly method: string;
|
|
208
|
+
};
|
|
209
|
+
|
|
210
|
+
/** The top-level fields of a plan: none of them may be a key of `extra`. */
|
|
211
|
+
export const PLAN_FIELDS = [
|
|
212
|
+
"question",
|
|
213
|
+
"population",
|
|
214
|
+
"arms",
|
|
215
|
+
"primary_outcome",
|
|
216
|
+
"author_check",
|
|
217
|
+
"extra",
|
|
218
|
+
"mode",
|
|
219
|
+
] as const;
|
|
220
|
+
|
|
221
|
+
/** A plan with empty required texts: the author has not approved it. */
|
|
222
|
+
export type DraftPlan = StudyPlan & { readonly author_check: null };
|
|
223
|
+
|
|
224
|
+
/** The texts a plan must fill before the study runs, in the order they are named. */
|
|
225
|
+
export const PLAN_TEXT_FIELDS = [
|
|
226
|
+
"question",
|
|
227
|
+
"population.source",
|
|
228
|
+
"population.draw.method",
|
|
229
|
+
"primary_outcome",
|
|
230
|
+
"mode",
|
|
231
|
+
"mode.outputs",
|
|
232
|
+
"mode.decision.file",
|
|
233
|
+
"mode.decision.note",
|
|
234
|
+
"mode.reason",
|
|
235
|
+
] as const;
|
|
236
|
+
export type PlanTextField = (typeof PLAN_TEXT_FIELDS)[number];
|
|
237
|
+
|
|
238
|
+
/**
|
|
239
|
+
* A plan's text, parsed: ready (every required text filled), a draft (its empty texts named, and
|
|
240
|
+
* 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.
|
|
241
|
+
*/
|
|
242
|
+
export type ParsedPlan =
|
|
243
|
+
| { readonly kind: "ready"; readonly plan: StudyPlan }
|
|
244
|
+
| {
|
|
245
|
+
readonly kind: "draft";
|
|
246
|
+
readonly plan: DraftPlan;
|
|
247
|
+
readonly empty: readonly PlanTextField[];
|
|
248
|
+
}
|
|
249
|
+
| { readonly kind: "unreadable"; readonly why: string };
|
|
250
|
+
|
|
251
|
+
/** What a study's folder holds of the plan its row names: absent, or what its text parsed to. */
|
|
252
|
+
export type PlanState = { readonly kind: "absent" } | ParsedPlan;
|
|
253
|
+
|
|
254
|
+
/** The decision file `paperlint studies new` names for a study in `folder`, relative to the paper. */
|
|
255
|
+
export const decisionPath = (folder: string): string =>
|
|
256
|
+
`repro/${folder}/${DECISION_FILE}`;
|
|
257
|
+
|
|
258
|
+
/** The decision file's name, beside the plan. */
|
|
259
|
+
export const DECISION_FILE = "decision.md";
|
|
260
|
+
|
|
261
|
+
/**
|
|
262
|
+
* The plan `paperlint studies new` writes for a study of `kind` in `folder`: every field present, its
|
|
263
|
+
* value to fill. Its draw is of all the population (a census); a sampled study changes it to
|
|
264
|
+
* `{ "kind": "seeded", "method", "seed" }`. A study that must declare its mode gets the default one,
|
|
265
|
+
* decided before the run, its decision file named and its outputs and note to fill.
|
|
266
|
+
*/
|
|
267
|
+
export const planTemplate = (kind: StudyKind, folder: string): DraftPlan => ({
|
|
268
|
+
question: "",
|
|
269
|
+
population: { source: "", draw: { kind: "all", method: "" } },
|
|
270
|
+
arms: [],
|
|
271
|
+
primary_outcome: "",
|
|
272
|
+
author_check: null,
|
|
273
|
+
...(needsMode(kind)
|
|
274
|
+
? {
|
|
275
|
+
mode: {
|
|
276
|
+
kind: "decide_before_run",
|
|
277
|
+
outputs: [],
|
|
278
|
+
decision: { file: decisionPath(folder), note: "" },
|
|
279
|
+
},
|
|
280
|
+
}
|
|
281
|
+
: {}),
|
|
282
|
+
});
|
|
283
|
+
|
|
284
|
+
/** A plan's text as `paperlint studies new` writes it: JSON, two-space indented, a final newline. */
|
|
285
|
+
export const planText = (p: StudyPlan): string =>
|
|
286
|
+
`${JSON.stringify(p, null, 2)}\n`;
|
|
287
|
+
|
|
88
288
|
/** A registry's text, parsed: the registry, or the one reason it is not one. */
|
|
89
289
|
export type ParsedRegistry =
|
|
90
290
|
| { readonly ok: true; readonly registry: StudyRegistry }
|
|
@@ -105,6 +305,8 @@ export interface StudyInput {
|
|
|
105
305
|
readonly readme: string | null;
|
|
106
306
|
/** Each `<folder>/<result_file>` of the registry that is a file. */
|
|
107
307
|
readonly existingResults: ReadonlySet<string>;
|
|
308
|
+
/** The plan each row names, by `<folder>/<plan>`; one not listed is absent. */
|
|
309
|
+
readonly plans: ReadonlyMap<string, PlanState>;
|
|
108
310
|
}
|
|
109
311
|
|
|
110
312
|
/** One defect of the registry, of a row, or of the index. */
|
|
@@ -157,8 +359,79 @@ export type StudyFinding =
|
|
|
157
359
|
| {
|
|
158
360
|
readonly problem: "stale-index";
|
|
159
361
|
readonly why: "no-readme" | "no-markers" | "differs";
|
|
362
|
+
}
|
|
363
|
+
| {
|
|
364
|
+
readonly problem: "unknown-measurement";
|
|
365
|
+
readonly id: string;
|
|
366
|
+
readonly measurement: string;
|
|
367
|
+
}
|
|
368
|
+
| { readonly problem: "empty-measurement"; readonly measurement: string }
|
|
369
|
+
| {
|
|
370
|
+
readonly problem: "unknown-requirement";
|
|
371
|
+
readonly measurement: string;
|
|
372
|
+
readonly requirement: string;
|
|
373
|
+
}
|
|
374
|
+
| {
|
|
375
|
+
readonly problem: "headline-approval";
|
|
376
|
+
readonly measurement: string;
|
|
377
|
+
readonly why: "missing";
|
|
378
|
+
readonly author: string | null;
|
|
379
|
+
}
|
|
380
|
+
| {
|
|
381
|
+
readonly problem: "headline-approval";
|
|
382
|
+
readonly measurement: string;
|
|
383
|
+
readonly why: "not-author";
|
|
384
|
+
readonly by: string;
|
|
385
|
+
readonly author: string;
|
|
386
|
+
}
|
|
387
|
+
| {
|
|
388
|
+
readonly problem: "bad-plan";
|
|
389
|
+
readonly id: string;
|
|
390
|
+
readonly path: string;
|
|
391
|
+
readonly why: "missing" | "outside";
|
|
392
|
+
}
|
|
393
|
+
| {
|
|
394
|
+
readonly problem: "bad-plan";
|
|
395
|
+
readonly id: string;
|
|
396
|
+
readonly path: string;
|
|
397
|
+
readonly why: "unreadable";
|
|
398
|
+
readonly reasons: string;
|
|
399
|
+
}
|
|
400
|
+
| {
|
|
401
|
+
readonly problem: "bad-plan";
|
|
402
|
+
readonly id: string;
|
|
403
|
+
readonly path: string;
|
|
404
|
+
readonly why: "unfilled";
|
|
405
|
+
readonly fields: readonly PlanTextField[];
|
|
406
|
+
readonly status: StudyStatus;
|
|
407
|
+
}
|
|
408
|
+
| {
|
|
409
|
+
readonly problem: "author-check";
|
|
410
|
+
readonly id: string;
|
|
411
|
+
readonly measurement: string;
|
|
412
|
+
readonly status: StudyStatus;
|
|
413
|
+
readonly why: "no-plan" | "missing";
|
|
414
|
+
}
|
|
415
|
+
| {
|
|
416
|
+
readonly problem: "author-check";
|
|
417
|
+
readonly id: string;
|
|
418
|
+
readonly measurement: string;
|
|
419
|
+
readonly status: StudyStatus;
|
|
420
|
+
readonly why: "not-author";
|
|
421
|
+
readonly by: string;
|
|
422
|
+
readonly author: string;
|
|
423
|
+
}
|
|
424
|
+
| {
|
|
425
|
+
readonly problem: "legacy-plan";
|
|
426
|
+
readonly id: string;
|
|
427
|
+
readonly path: string;
|
|
160
428
|
};
|
|
161
429
|
|
|
430
|
+
/** The findings `repro/study-plan-name` reports, a warning; every other is `repro/study-registered`'s. */
|
|
431
|
+
export const PLAN_NAME_PROBLEMS: readonly StudyFinding["problem"][] = [
|
|
432
|
+
"legacy-plan",
|
|
433
|
+
];
|
|
434
|
+
|
|
162
435
|
/** The number of an id of `prefix` (`P1-S07` → 7); null for an id of another shape. */
|
|
163
436
|
function idNumber(prefix: string, id: string): number | null {
|
|
164
437
|
const m = new RegExp(`^${prefix}-S(\\d{2,})$`, "u").exec(id);
|
|
@@ -191,13 +464,42 @@ export const titleWords = (title: string): number =>
|
|
|
191
464
|
* Whether `path` stays inside the folder it is relative to. A backslash or a Windows absolute path
|
|
192
465
|
* is refused outright: `posix.normalize` does not resolve `..\\`, while the host's `join` may.
|
|
193
466
|
*/
|
|
194
|
-
const isInside = (path: string): boolean => {
|
|
467
|
+
export const isInside = (path: string): boolean => {
|
|
195
468
|
if (path.includes("\\") || isAbsolute(path) || win32.isAbsolute(path))
|
|
196
469
|
return false;
|
|
197
470
|
const n = posix.normalize(path);
|
|
198
471
|
return n !== ".." && !n.startsWith("../");
|
|
199
472
|
};
|
|
200
473
|
|
|
474
|
+
/**
|
|
475
|
+
* Whether `folder` names one folder directly inside `repro/`: not empty, not `.` or `..`, and no
|
|
476
|
+
* separator or NUL. Every command and rule that reads a study's folder reads only such a one: a row
|
|
477
|
+
* naming `../x` would have them read and write outside the paper.
|
|
478
|
+
*/
|
|
479
|
+
export const isFolderName = (folder: string): boolean =>
|
|
480
|
+
folder !== "." && folder !== ".." && /^[^/\\\0]+$/u.test(folder);
|
|
481
|
+
|
|
482
|
+
/**
|
|
483
|
+
* The `<folder>/<plan>` of each row whose plan lies inside its folder: the files the judge needs read
|
|
484
|
+
* (`StudyInput.plans`). A plan outside its folder is reported without being read.
|
|
485
|
+
*/
|
|
486
|
+
export const planKeys = (r: StudyRegistry): readonly string[] =>
|
|
487
|
+
planRefs(r).map((p) => p.key);
|
|
488
|
+
|
|
489
|
+
/** Each plan `planKeys` names, with the kind of the study whose row names it: what its parser needs. */
|
|
490
|
+
export const planRefs = (
|
|
491
|
+
r: StudyRegistry,
|
|
492
|
+
): readonly {
|
|
493
|
+
readonly key: string;
|
|
494
|
+
readonly folder: string;
|
|
495
|
+
readonly kind: StudyKind;
|
|
496
|
+
}[] =>
|
|
497
|
+
r.studies.flatMap((s) =>
|
|
498
|
+
s.plan !== undefined && isInside(s.plan) && isFolderName(s.folder)
|
|
499
|
+
? [{ key: `${s.folder}/${s.plan}`, folder: s.folder, kind: s.kind }]
|
|
500
|
+
: [],
|
|
501
|
+
);
|
|
502
|
+
|
|
201
503
|
/** One row of a registry, with what the judge read beside it. */
|
|
202
504
|
interface Row {
|
|
203
505
|
readonly r: StudyRegistry;
|
|
@@ -283,6 +585,102 @@ const noSuccessor: RowCheck = ({ r, s }) => {
|
|
|
283
585
|
: null;
|
|
284
586
|
};
|
|
285
587
|
|
|
588
|
+
/** A row's `measurement`, when the registry declares its measurements, names one of them. */
|
|
589
|
+
const unknownMeasurement: RowCheck = ({ r, s }) =>
|
|
590
|
+
r.measurements === undefined ||
|
|
591
|
+
s.measurement === undefined ||
|
|
592
|
+
r.measurements.some((m) => m.id === s.measurement)
|
|
593
|
+
? null
|
|
594
|
+
: { problem: "unknown-measurement", id: s.id, measurement: s.measurement };
|
|
595
|
+
|
|
596
|
+
/** The state of the plan the row names (absent when outside its folder); null when it names none. */
|
|
597
|
+
const planOf = ({ s, input }: Row): PlanState | null => {
|
|
598
|
+
if (s.plan === undefined) return null;
|
|
599
|
+
return isInside(s.plan)
|
|
600
|
+
? (input.plans.get(`${s.folder}/${s.plan}`) ?? { kind: "absent" })
|
|
601
|
+
: { kind: "absent" };
|
|
602
|
+
};
|
|
603
|
+
|
|
604
|
+
const hasRun = (s: Study): boolean => RAN_STATUSES.includes(s.status);
|
|
605
|
+
|
|
606
|
+
/** The finding for the plan `path` of `s`, in the state its folder holds it; null when it stands. */
|
|
607
|
+
const planFinding = (
|
|
608
|
+
s: Study,
|
|
609
|
+
path: string,
|
|
610
|
+
state: PlanState,
|
|
611
|
+
): StudyFinding | null => {
|
|
612
|
+
const at = { problem: "bad-plan", id: s.id, path } as const;
|
|
613
|
+
switch (state.kind) {
|
|
614
|
+
case "absent":
|
|
615
|
+
return { ...at, why: "missing" };
|
|
616
|
+
case "unreadable":
|
|
617
|
+
return { ...at, why: "unreadable", reasons: state.why };
|
|
618
|
+
case "draft":
|
|
619
|
+
return hasRun(s)
|
|
620
|
+
? { ...at, why: "unfilled", fields: state.empty, status: s.status }
|
|
621
|
+
: null;
|
|
622
|
+
case "ready":
|
|
623
|
+
return null;
|
|
624
|
+
}
|
|
625
|
+
};
|
|
626
|
+
|
|
627
|
+
/** The plan a row names is a file inside its folder, of the schema, and ready once the study has run. */
|
|
628
|
+
const badPlan: RowCheck = ({ s, input }) => {
|
|
629
|
+
const path = s.plan;
|
|
630
|
+
if (path === undefined) return null;
|
|
631
|
+
if (!isInside(path))
|
|
632
|
+
return { problem: "bad-plan", id: s.id, path, why: "outside" };
|
|
633
|
+
return planFinding(
|
|
634
|
+
s,
|
|
635
|
+
path,
|
|
636
|
+
input.plans.get(`${s.folder}/${path}`) ?? { kind: "absent" },
|
|
637
|
+
);
|
|
638
|
+
};
|
|
639
|
+
|
|
640
|
+
/** A plan under its old name: read, and named for renaming. */
|
|
641
|
+
const legacyPlan: RowCheck = ({ s }) =>
|
|
642
|
+
s.plan !== undefined && posix.basename(s.plan) === LEGACY_PLAN_FILE
|
|
643
|
+
? { problem: "legacy-plan", id: s.id, path: s.plan }
|
|
644
|
+
: null;
|
|
645
|
+
|
|
646
|
+
/**
|
|
647
|
+
* A study of a headline measurement that has run carries the author's check of its drawn units in
|
|
648
|
+
* its plan, by the registry's author when it names one. A plan that cannot be read is `badPlan`'s.
|
|
649
|
+
*/
|
|
650
|
+
const authorCheck: RowCheck = (row) => {
|
|
651
|
+
const { r, s } = row;
|
|
652
|
+
const m = r.measurements?.find((o) => o.id === s.measurement);
|
|
653
|
+
if (m?.role !== "headline" || !hasRun(s)) return null;
|
|
654
|
+
const at = {
|
|
655
|
+
problem: "author-check",
|
|
656
|
+
id: s.id,
|
|
657
|
+
measurement: m.id,
|
|
658
|
+
status: s.status,
|
|
659
|
+
} as const;
|
|
660
|
+
const state = planOf(row);
|
|
661
|
+
if (state === null) return { ...at, why: "no-plan" };
|
|
662
|
+
const wrong =
|
|
663
|
+
state.kind === "ready" || state.kind === "draft"
|
|
664
|
+
? checkProblem(r, state.plan)
|
|
665
|
+
: null;
|
|
666
|
+
return wrong === null ? null : { ...at, ...wrong };
|
|
667
|
+
};
|
|
668
|
+
|
|
669
|
+
/** What a read plan's author check lacks: it is not there, or it is not the registry's author's. */
|
|
670
|
+
const checkProblem = (
|
|
671
|
+
r: StudyRegistry,
|
|
672
|
+
plan: StudyPlan,
|
|
673
|
+
):
|
|
674
|
+
| { readonly why: "missing" }
|
|
675
|
+
| { readonly why: "not-author"; readonly by: string; readonly author: string }
|
|
676
|
+
| null => {
|
|
677
|
+
const check = plan.author_check;
|
|
678
|
+
if (check === null) return { why: "missing" };
|
|
679
|
+
return r.author === undefined || check.by === r.author
|
|
680
|
+
? null
|
|
681
|
+
: { why: "not-author", by: check.by, author: r.author };
|
|
682
|
+
};
|
|
683
|
+
|
|
286
684
|
/** The checks of a row, in the order their findings are reported. */
|
|
287
685
|
const ROW_CHECKS: readonly RowCheck[] = [
|
|
288
686
|
badId,
|
|
@@ -294,8 +692,58 @@ const ROW_CHECKS: readonly RowCheck[] = [
|
|
|
294
692
|
noResult,
|
|
295
693
|
resultFile,
|
|
296
694
|
noSuccessor,
|
|
695
|
+
unknownMeasurement,
|
|
696
|
+
badPlan,
|
|
697
|
+
legacyPlan,
|
|
698
|
+
authorCheck,
|
|
297
699
|
];
|
|
298
700
|
|
|
701
|
+
/** A headline measurement's approval: missing, or not the registry's author's; null when it stands. */
|
|
702
|
+
const headlineApproval = (
|
|
703
|
+
r: StudyRegistry,
|
|
704
|
+
m: Measurement,
|
|
705
|
+
): StudyFinding | null => {
|
|
706
|
+
if (m.role !== "headline") return null;
|
|
707
|
+
const author = r.author ?? null;
|
|
708
|
+
if (m.approval === undefined)
|
|
709
|
+
return {
|
|
710
|
+
problem: "headline-approval",
|
|
711
|
+
measurement: m.id,
|
|
712
|
+
why: "missing",
|
|
713
|
+
author,
|
|
714
|
+
};
|
|
715
|
+
return author === null || m.approval.by === author
|
|
716
|
+
? null
|
|
717
|
+
: {
|
|
718
|
+
problem: "headline-approval",
|
|
719
|
+
measurement: m.id,
|
|
720
|
+
why: "not-author",
|
|
721
|
+
by: m.approval.by,
|
|
722
|
+
author,
|
|
723
|
+
};
|
|
724
|
+
};
|
|
725
|
+
|
|
726
|
+
/** What is wrong with one measurement: no study, a requirement not held, a headline not approved. */
|
|
727
|
+
const judgeMeasurement = (
|
|
728
|
+
r: StudyRegistry,
|
|
729
|
+
m: Measurement,
|
|
730
|
+
): readonly StudyFinding[] => {
|
|
731
|
+
const approval = headlineApproval(r, m);
|
|
732
|
+
return [
|
|
733
|
+
...(r.studies.some((s) => s.measurement === m.id)
|
|
734
|
+
? []
|
|
735
|
+
: [{ problem: "empty-measurement", measurement: m.id } as const]),
|
|
736
|
+
...(m.requirements ?? [])
|
|
737
|
+
.filter((id) => !(r.requirements ?? []).some((q) => q.id === id))
|
|
738
|
+
.map((requirement): StudyFinding => ({
|
|
739
|
+
problem: "unknown-requirement",
|
|
740
|
+
measurement: m.id,
|
|
741
|
+
requirement,
|
|
742
|
+
})),
|
|
743
|
+
...(approval === null ? [] : [approval]),
|
|
744
|
+
];
|
|
745
|
+
};
|
|
746
|
+
|
|
299
747
|
/** What is wrong with a row, in a fixed order. */
|
|
300
748
|
const judgeRow = (row: Row): readonly StudyFinding[] =>
|
|
301
749
|
ROW_CHECKS.map((check) => check(row)).filter(
|
|
@@ -306,11 +754,12 @@ const judgeRow = (row: Row): readonly StudyFinding[] =>
|
|
|
306
754
|
function judgeIndex(
|
|
307
755
|
r: StudyRegistry,
|
|
308
756
|
readme: string | null,
|
|
757
|
+
plans: ReadonlyMap<string, PlanState>,
|
|
309
758
|
): StudyFinding | null {
|
|
310
759
|
if (readme === null) return { problem: "stale-index", why: "no-readme" };
|
|
311
760
|
const block = embeddedIndex(readme);
|
|
312
761
|
if (block === null) return { problem: "stale-index", why: "no-markers" };
|
|
313
|
-
return indexInSync(readme, r)
|
|
762
|
+
return indexInSync(readme, r, { plans, history: new Map() })
|
|
314
763
|
? null
|
|
315
764
|
: { problem: "stale-index", why: "differs" };
|
|
316
765
|
}
|
|
@@ -329,7 +778,7 @@ export function judgeStudies(input: StudyInput): readonly StudyFinding[] {
|
|
|
329
778
|
return [{ problem: "unreadable", why: state.why }];
|
|
330
779
|
const r = state.registry;
|
|
331
780
|
const next = nextStudyId(r);
|
|
332
|
-
const index = judgeIndex(r, input.readme);
|
|
781
|
+
const index = judgeIndex(r, input.readme, input.plans);
|
|
333
782
|
return [
|
|
334
783
|
...input.folders
|
|
335
784
|
.filter((f) => !r.studies.some((s) => s.folder === f))
|
|
@@ -339,6 +788,7 @@ export function judgeStudies(input: StudyInput): readonly StudyFinding[] {
|
|
|
339
788
|
next,
|
|
340
789
|
})),
|
|
341
790
|
...r.studies.flatMap((s, i) => judgeRow({ r, s, i, input })),
|
|
791
|
+
...(r.measurements ?? []).flatMap((m) => judgeMeasurement(r, m)),
|
|
342
792
|
...(index === null ? [] : [index]),
|
|
343
793
|
];
|
|
344
794
|
}
|
|
@@ -349,19 +799,65 @@ export function judgeStudies(input: StudyInput): readonly StudyFinding[] {
|
|
|
349
799
|
const cell = (s: string): string =>
|
|
350
800
|
s.replace(/\r?\n/gu, " ").replace(/\\/gu, "\\\\").replace(/\|/gu, "\\|");
|
|
351
801
|
|
|
352
|
-
/**
|
|
802
|
+
/**
|
|
803
|
+
* What the index is generated from beyond the registry: the plans by `<folder>/<plan>` (as
|
|
804
|
+
* `StudyInput.plans`), and the history of each decide-first study by id. A study with no history
|
|
805
|
+
* listed shows `not read` — the lint, which never compares those columns, asks none.
|
|
806
|
+
*/
|
|
807
|
+
export interface IndexInput {
|
|
808
|
+
readonly plans: ReadonlyMap<string, PlanState>;
|
|
809
|
+
readonly history: ReadonlyMap<string, IndexHistory>;
|
|
810
|
+
}
|
|
811
|
+
|
|
812
|
+
/** The mode the plan of `s` declares; null when it names none, or the plan is absent or unreadable. */
|
|
813
|
+
const declaredMode = (
|
|
814
|
+
s: Study,
|
|
815
|
+
plans: ReadonlyMap<string, PlanState>,
|
|
816
|
+
): StudyMode | null => {
|
|
817
|
+
const state =
|
|
818
|
+
s.plan !== undefined && isInside(s.plan)
|
|
819
|
+
? plans.get(`${s.folder}/${s.plan}`)
|
|
820
|
+
: undefined;
|
|
821
|
+
return state === undefined ||
|
|
822
|
+
state.kind === "absent" ||
|
|
823
|
+
state.kind === "unreadable"
|
|
824
|
+
? null
|
|
825
|
+
: (state.plan.mode ?? null);
|
|
826
|
+
};
|
|
827
|
+
|
|
828
|
+
/** What the index shows of `s` beyond its row. */
|
|
829
|
+
const factsOf = (s: Study, at: IndexInput): IndexFacts => ({
|
|
830
|
+
mode: declaredMode(s, at.plans),
|
|
831
|
+
history: at.history.get(s.id) ?? null,
|
|
832
|
+
});
|
|
833
|
+
|
|
834
|
+
/**
|
|
835
|
+
* The columns of the index: each one's header, its cell for a study, and whether git's history is
|
|
836
|
+
* where the cell comes from — those two are never compared (src/domain/index-columns.ts).
|
|
837
|
+
*/
|
|
353
838
|
const COLUMNS: readonly {
|
|
354
839
|
readonly head: string;
|
|
355
|
-
readonly of: (s: Study) => string;
|
|
840
|
+
readonly of: (s: Study, f: IndexFacts) => string;
|
|
841
|
+
readonly fromGit: boolean;
|
|
356
842
|
}[] = [
|
|
357
|
-
{ head: "id", of: (s) => s.id },
|
|
358
|
-
{ head: "title", of: (s) => cell(s.title) },
|
|
359
|
-
{ head: "status", of: (s) => s.status },
|
|
360
|
-
{ head: "result", of: (s) => cell(s.result ?? "—") },
|
|
361
|
-
{ head: "in paper", of: (s) => s.in_paper },
|
|
362
|
-
{ head: "
|
|
843
|
+
{ head: "id", of: (s) => s.id, fromGit: false },
|
|
844
|
+
{ head: "title", of: (s) => cell(s.title), fromGit: false },
|
|
845
|
+
{ head: "status", of: (s) => s.status, fromGit: false },
|
|
846
|
+
{ head: "result", of: (s) => cell(s.result ?? "—"), fromGit: false },
|
|
847
|
+
{ head: "in paper", of: (s) => s.in_paper, fromGit: false },
|
|
848
|
+
{ head: "mode", of: (_, f) => cell(modeCell(f)), fromGit: false },
|
|
849
|
+
{ head: "decision", of: (_, f) => decisionCell(f), fromGit: true },
|
|
850
|
+
{ head: "first output", of: (_, f) => firstOutputCell(f), fromGit: true },
|
|
851
|
+
{
|
|
852
|
+
head: "folder",
|
|
853
|
+
of: (s) => `[${s.folder}/](${s.folder}/)`,
|
|
854
|
+
fromGit: false,
|
|
855
|
+
},
|
|
363
856
|
];
|
|
364
857
|
|
|
858
|
+
/** The positions of the columns read from git: left out when an index is compared. */
|
|
859
|
+
const FROM_GIT = COLUMNS.flatMap((c, i) => (c.fromGit ? [i] : []));
|
|
860
|
+
|
|
365
861
|
/** The width a cell takes: its code points. */
|
|
366
862
|
const widthOf = (s: string): number => Array.from(s).length;
|
|
367
863
|
|
|
@@ -374,20 +870,23 @@ const padded = (t: string, width: number): string =>
|
|
|
374
870
|
* widest cell (at least 3), the way prettier writes a table — so formatting the README leaves it as
|
|
375
871
|
* it is.
|
|
376
872
|
*/
|
|
377
|
-
export function studyIndex(r: StudyRegistry): string {
|
|
873
|
+
export function studyIndex(r: StudyRegistry, at: IndexInput): string {
|
|
874
|
+
const studies = r.studies.map((s) => ({ s, f: factsOf(s, at) }));
|
|
378
875
|
const sized = COLUMNS.map((c) => ({
|
|
379
876
|
...c,
|
|
380
877
|
width: Math.max(
|
|
381
878
|
3,
|
|
382
879
|
widthOf(c.head),
|
|
383
|
-
...
|
|
880
|
+
...studies.map(({ s, f }) => widthOf(c.of(s, f))),
|
|
384
881
|
),
|
|
385
882
|
}));
|
|
386
883
|
const line = (cells: readonly string[]): string => `| ${cells.join(" | ")} |`;
|
|
387
884
|
return [
|
|
388
885
|
line(sized.map((c) => padded(c.head, c.width))),
|
|
389
886
|
line(sized.map((c) => "-".repeat(c.width))),
|
|
390
|
-
...
|
|
887
|
+
...studies.map(({ s, f }) =>
|
|
888
|
+
line(sized.map((c) => padded(c.of(s, f), c.width))),
|
|
889
|
+
),
|
|
391
890
|
].join("\n");
|
|
392
891
|
}
|
|
393
892
|
|
|
@@ -396,7 +895,7 @@ export function studyIndex(r: StudyRegistry): string {
|
|
|
396
895
|
* `---`. The index is judged on this, because the padding belongs to the formatter: prettier sizes a
|
|
397
896
|
* column by display width, so a wide character pads differently from a code-point count.
|
|
398
897
|
*/
|
|
399
|
-
const cellsOf = (table: string): string =>
|
|
898
|
+
const cellsOf = (table: string, skip: readonly number[]): string =>
|
|
400
899
|
table
|
|
401
900
|
.trim()
|
|
402
901
|
.split("\n")
|
|
@@ -406,19 +905,44 @@ const cellsOf = (table: string): string =>
|
|
|
406
905
|
.replace(/^\||\|$/gu, "")
|
|
407
906
|
.split(/(?<!\\)\|/u)
|
|
408
907
|
.map((c) => c.trim().replace(/^:?-{3,}:?$/u, "---"))
|
|
908
|
+
.filter((_, i) => !skip.includes(i))
|
|
409
909
|
.join("|"),
|
|
410
910
|
)
|
|
411
911
|
.join("\n");
|
|
412
912
|
|
|
413
|
-
/** Whether `readme` holds the index of `r
|
|
414
|
-
|
|
913
|
+
/** Whether `readme` holds the index of `r` in every column but those at `skip`, whatever its padding. */
|
|
914
|
+
const sameCells = (
|
|
915
|
+
readme: string | null,
|
|
916
|
+
index: string,
|
|
917
|
+
skip: readonly number[],
|
|
918
|
+
): boolean => {
|
|
415
919
|
const block = readme === null ? null : embeddedIndex(readme);
|
|
416
|
-
return block !== null && cellsOf(block) === cellsOf(
|
|
417
|
-
}
|
|
920
|
+
return block !== null && cellsOf(block, skip) === cellsOf(index, skip);
|
|
921
|
+
};
|
|
922
|
+
|
|
923
|
+
/**
|
|
924
|
+
* Whether `readme` holds the index of `r`, whatever padding a formatter gave its table and whatever
|
|
925
|
+
* its two columns read from git say: what `repro/study-registered` checks.
|
|
926
|
+
*/
|
|
927
|
+
export const indexInSync = (
|
|
928
|
+
readme: string | null,
|
|
929
|
+
r: StudyRegistry,
|
|
930
|
+
at: IndexInput,
|
|
931
|
+
): boolean => sameCells(readme, studyIndex(r, at), FROM_GIT);
|
|
932
|
+
|
|
933
|
+
/**
|
|
934
|
+
* Whether `readme` holds the index of `r` in every column, whatever its padding: when it does,
|
|
935
|
+
* `studies index` leaves the README as it is, so the padding a formatter gave the table stays.
|
|
936
|
+
*/
|
|
937
|
+
export const indexCurrent = (
|
|
938
|
+
readme: string | null,
|
|
939
|
+
r: StudyRegistry,
|
|
940
|
+
at: IndexInput,
|
|
941
|
+
): boolean => sameCells(readme, studyIndex(r, at), []);
|
|
418
942
|
|
|
419
943
|
/** The index between its markers, a blank line on each side. */
|
|
420
|
-
export const indexBlock = (r: StudyRegistry): string =>
|
|
421
|
-
`${INDEX_BEGIN}\n\n${studyIndex(r)}\n\n${INDEX_END}`;
|
|
944
|
+
export const indexBlock = (r: StudyRegistry, at: IndexInput): string =>
|
|
945
|
+
`${INDEX_BEGIN}\n\n${studyIndex(r, at)}\n\n${INDEX_END}`;
|
|
422
946
|
|
|
423
947
|
/**
|
|
424
948
|
* Where `marker` stands alone on a line of `text`, at or after `from`; -1 when it does not. A marker
|
|
@@ -442,13 +966,18 @@ export function embeddedIndex(readme: string): string | null {
|
|
|
442
966
|
* `readme` with its index regenerated from `r`: the block replaced in place, appended when the README
|
|
443
967
|
* has none, a README of its own when there is none.
|
|
444
968
|
*/
|
|
445
|
-
export function withIndex(
|
|
446
|
-
|
|
969
|
+
export function withIndex(
|
|
970
|
+
readme: string | null,
|
|
971
|
+
r: StudyRegistry,
|
|
972
|
+
at: IndexInput,
|
|
973
|
+
): string {
|
|
974
|
+
const fresh = indexBlock(r, at);
|
|
975
|
+
if (readme === null) return `${INDEX_HEADING}\n\n${fresh}\n`;
|
|
447
976
|
const block = embeddedIndex(readme);
|
|
448
|
-
if (block === null) return `${readme.trimEnd()}\n\n${
|
|
977
|
+
if (block === null) return `${readme.trimEnd()}\n\n${fresh}\n`;
|
|
449
978
|
const begin = markerAt(readme, INDEX_BEGIN);
|
|
450
979
|
const after = begin + INDEX_BEGIN.length + block.length + INDEX_END.length;
|
|
451
|
-
return `${readme.slice(0, begin)}${
|
|
980
|
+
return `${readme.slice(0, begin)}${fresh}${readme.slice(after)}`;
|
|
452
981
|
}
|
|
453
982
|
|
|
454
983
|
/** The registry's text as `paperlint studies new` writes it: JSON, two-space indented, a final newline. */
|
|
@@ -466,6 +995,14 @@ const RENAME =
|
|
|
466
995
|
|
|
467
996
|
const tick = (s: string): string => `\`${s}\``;
|
|
468
997
|
|
|
998
|
+
/** `a`, `b` and `c`: names in backticks, the last joined by "and". */
|
|
999
|
+
const listed = (names: readonly string[]): string => {
|
|
1000
|
+
const t = names.map(tick);
|
|
1001
|
+
return [t.slice(0, -1).join(", "), ...t.slice(-1)]
|
|
1002
|
+
.filter((part) => part !== "")
|
|
1003
|
+
.join(" and ");
|
|
1004
|
+
};
|
|
1005
|
+
|
|
469
1006
|
/** Each finding by its problem: the map the messages are looked up in. */
|
|
470
1007
|
type FindingOf = {
|
|
471
1008
|
readonly [F in StudyFinding as F["problem"]]: F;
|
|
@@ -506,15 +1043,58 @@ const HEADS: { readonly [P in Problem]: (f: FindingOf[P]) => string } = {
|
|
|
506
1043
|
"no-successor": (f) =>
|
|
507
1044
|
`${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.`,
|
|
508
1045
|
"stale-index": (f) => `${STALE_HEAD[f.why]} ${RUN_INDEX}`,
|
|
1046
|
+
"unknown-measurement": (f) =>
|
|
1047
|
+
`${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\`.`,
|
|
1048
|
+
"empty-measurement": (f) =>
|
|
1049
|
+
`${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.`,
|
|
1050
|
+
"unknown-requirement": (f) =>
|
|
1051
|
+
`${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\`.`,
|
|
1052
|
+
"headline-approval": (f) =>
|
|
1053
|
+
f.why === "missing"
|
|
1054
|
+
? `${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\`.`
|
|
1055
|
+
: `${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.`,
|
|
1056
|
+
"bad-plan": (f) => {
|
|
1057
|
+
const of = `the \`plan\` of ${tick(f.id)}, ${tick(f.path)},`;
|
|
1058
|
+
const plan = `The plan of ${tick(f.id)}, ${tick(f.path)} in its folder,`;
|
|
1059
|
+
switch (f.why) {
|
|
1060
|
+
case "missing":
|
|
1061
|
+
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\`.`;
|
|
1062
|
+
case "outside":
|
|
1063
|
+
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.`;
|
|
1064
|
+
case "unreadable":
|
|
1065
|
+
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\`, and for a measurement its \`mode\`.`;
|
|
1066
|
+
case "unfilled":
|
|
1067
|
+
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\`.`;
|
|
1068
|
+
}
|
|
1069
|
+
},
|
|
1070
|
+
"author-check": (f) => {
|
|
1071
|
+
const head = `${tick(f.id)} is ${tick(f.status)} in the headline measurement ${tick(f.measurement)}, and`;
|
|
1072
|
+
switch (f.why) {
|
|
1073
|
+
case "no-plan":
|
|
1074
|
+
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\`.`;
|
|
1075
|
+
case "missing":
|
|
1076
|
+
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\`.`;
|
|
1077
|
+
case "not-author":
|
|
1078
|
+
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.`;
|
|
1079
|
+
}
|
|
1080
|
+
},
|
|
1081
|
+
"legacy-plan": (f) =>
|
|
1082
|
+
`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}\`.`,
|
|
509
1083
|
};
|
|
510
1084
|
|
|
511
1085
|
/** The head of finding `f` of problem `p`: the lookup, typed by the problem. */
|
|
512
1086
|
const headOf = <P extends Problem>(p: P, f: FindingOf[P]): string =>
|
|
513
1087
|
HEADS[p](f);
|
|
514
1088
|
|
|
1089
|
+
/** The rule that reports finding `f`. */
|
|
1090
|
+
const ruleOf = (f: StudyFinding): string =>
|
|
1091
|
+
PLAN_NAME_PROBLEMS.includes(f.problem)
|
|
1092
|
+
? "repro/study-plan-name"
|
|
1093
|
+
: "repro/study-registered";
|
|
1094
|
+
|
|
515
1095
|
/** The one text a finding is reported with: what is wrong, what to do, and the rule's page. */
|
|
516
1096
|
export const studyMessage = (f: StudyFinding): string =>
|
|
517
|
-
`${headOf(f.problem, f)}\nRule
|
|
1097
|
+
`${headOf(f.problem, f)}\nRule ${ruleOf(f)}: https://github.com/zernie/paperlint/blob/main/docs/rules/${ruleOf(f)}.md`;
|
|
518
1098
|
|
|
519
1099
|
// ── a new study ───────────────────────────────────────────────────────────────────
|
|
520
1100
|
|
|
@@ -541,20 +1121,20 @@ function titleProblem(title: string): string | null {
|
|
|
541
1121
|
: null;
|
|
542
1122
|
}
|
|
543
1123
|
|
|
544
|
-
/**
|
|
545
|
-
|
|
546
|
-
* `in_paper: no` and an empty question; or why the title cannot make one.
|
|
547
|
-
*/
|
|
548
|
-
export function addStudy(
|
|
549
|
-
r: StudyRegistry,
|
|
550
|
-
n: NewStudy,
|
|
551
|
-
):
|
|
1124
|
+
/** A registry with a study added, and the study; or why it could not be. */
|
|
1125
|
+
export type Added =
|
|
552
1126
|
| {
|
|
553
1127
|
readonly ok: true;
|
|
554
1128
|
readonly registry: StudyRegistry;
|
|
555
1129
|
readonly study: Study;
|
|
556
1130
|
}
|
|
557
|
-
| { readonly ok: false; readonly why: string }
|
|
1131
|
+
| { readonly ok: false; readonly why: string };
|
|
1132
|
+
|
|
1133
|
+
/**
|
|
1134
|
+
* `r` with a new study appended: the next id, the folder `<id>-<slug>`, `status: designing`,
|
|
1135
|
+
* `in_paper: no`, an empty question and its plan, `plan.json`; or why the title cannot make one.
|
|
1136
|
+
*/
|
|
1137
|
+
export function addStudy(r: StudyRegistry, n: NewStudy): Added {
|
|
558
1138
|
const title = n.title.trim().replace(/\s+/gu, " ");
|
|
559
1139
|
const why = titleProblem(title);
|
|
560
1140
|
if (why !== null) return { ok: false, why };
|
|
@@ -568,6 +1148,7 @@ export function addStudy(
|
|
|
568
1148
|
status: "designing",
|
|
569
1149
|
date: n.date,
|
|
570
1150
|
in_paper: "no",
|
|
1151
|
+
plan: PLAN_FILE,
|
|
571
1152
|
};
|
|
572
1153
|
return {
|
|
573
1154
|
ok: true,
|
|
@@ -589,6 +1170,10 @@ export const studyReadme = (s: Study): string =>
|
|
|
589
1170
|
`Its row in \`../${STUDIES_FILE}\` holds its status. When the study ends, set \`status\`, \`result\`,`,
|
|
590
1171
|
"`result_file` and `in_paper` there, then run `npx paperlint studies index <paper-dir>`.",
|
|
591
1172
|
"",
|
|
1173
|
+
`Its plan is \`${PLAN_FILE}\`: what it measures, the population and the draw, the arms and the primary`,
|
|
1174
|
+
"outcome, filled in before the first run; the author's check of the drawn units goes in its",
|
|
1175
|
+
"`author_check`.",
|
|
1176
|
+
"",
|
|
592
1177
|
"## Question",
|
|
593
1178
|
"",
|
|
594
1179
|
"## How to run",
|