paperlint 10.3.0 → 10.4.1
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/CLAUDE.md.spec.d.ts.map +1 -1
- package/dist/CLAUDE.md.spec.js +6 -3
- package/dist/CLAUDE.md.spec.js.map +1 -1
- 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/latex/captions.d.ts +9 -0
- package/dist/adapters/latex/captions.d.ts.map +1 -0
- package/dist/adapters/latex/captions.js +40 -0
- package/dist/adapters/latex/captions.js.map +1 -0
- package/dist/adapters/latex/index.d.ts +1 -0
- package/dist/adapters/latex/index.d.ts.map +1 -1
- package/dist/adapters/latex/index.js +1 -0
- package/dist/adapters/latex/index.js.map +1 -1
- package/dist/adapters/latex/parse.d.ts.map +1 -1
- package/dist/adapters/latex/parse.js +6 -0
- package/dist/adapters/latex/parse.js.map +1 -1
- 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 +26 -0
- package/dist/adapters/node/list-entries.io.js.map +1 -0
- package/dist/adapters/study-registry/index.d.ts +70 -10
- package/dist/adapters/study-registry/index.d.ts.map +1 -1
- package/dist/adapters/study-registry/index.js +84 -10
- package/dist/adapters/study-registry/index.js.map +1 -1
- package/dist/cli.d.ts.map +1 -1
- package/dist/cli.js +33 -3
- package/dist/cli.js.map +1 -1
- package/dist/domain/decide-first.d.ts +110 -0
- package/dist/domain/decide-first.d.ts.map +1 -0
- package/dist/domain/decide-first.js +108 -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 +64 -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 +116 -12
- package/dist/domain/study-registry.d.ts.map +1 -1
- package/dist/domain/study-registry.js +164 -44
- 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 +94 -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 +20 -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-deps.d.ts +14 -0
- package/dist/ports/study-deps.d.ts.map +1 -0
- package/dist/ports/study-deps.js +2 -0
- package/dist/ports/study-deps.js.map +1 -0
- package/dist/ports/study-folder.d.ts +43 -0
- package/dist/ports/study-folder.d.ts.map +1 -0
- package/dist/ports/study-folder.js +2 -0
- package/dist/ports/study-folder.js.map +1 -0
- package/dist/ports/study-registry-codec.d.ts +6 -3
- package/dist/ports/study-registry-codec.d.ts.map +1 -1
- package/dist/presets.d.ts +1 -2
- package/dist/presets.d.ts.map +1 -1
- package/dist/presets.js +1 -2
- package/dist/presets.js.map +1 -1
- package/dist/studies.d.ts +4 -0
- package/dist/studies.d.ts.map +1 -1
- package/dist/studies.js +52 -21
- package/dist/studies.js.map +1 -1
- package/dist/study-folder.d.ts +14 -0
- package/dist/study-folder.d.ts.map +1 -0
- package/dist/study-folder.js +123 -0
- package/dist/study-folder.js.map +1 -0
- package/dist/study-gate.d.ts +14 -0
- package/dist/study-gate.d.ts.map +1 -0
- package/dist/study-gate.js +132 -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 +18 -0
- package/dist/study-index-input.js.map +1 -0
- package/dist/study-modes.d.ts +56 -0
- package/dist/study-modes.d.ts.map +1 -0
- package/dist/study-modes.js +59 -0
- package/dist/study-modes.js.map +1 -0
- package/dist/study-registry-rule.d.ts +13 -1
- package/dist/study-registry-rule.d.ts.map +1 -1
- package/dist/study-registry-rule.js +33 -21
- 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 +265 -0
- package/docs/design/study-registry.md +6 -0
- package/docs/e2e.md +8 -4
- package/docs/layout.md +22 -19
- package/docs/rules/repro/outputs-before-decision.md +122 -0
- package/docs/rules/repro/study-registered.md +49 -15
- package/docs/rules/tex/heading-case.md +1 -1
- package/docs/rules.md +54 -53
- package/docs/testing.md +1 -0
- package/eslint.config.mjs +36 -24
- package/package.json +2 -1
- package/scripts/check.ts +6 -0
- 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/grade-paper-writing/SKILL.md +4 -2
- package/skills/grade-paper-writing/SKILL.md.spec.ts +3 -1
- package/skills/grade-paper-writing/__snapshots__/prose-lint.test.ts.snap +16 -3
- package/skills/grade-paper-writing/prose-lint.mjs +127 -95
- package/skills/grade-paper-writing/prose-lint.test.ts +119 -10
- 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/tighten-paper/SKILL.md +5 -1
- package/skills/tighten-paper/SKILL.md.spec.ts +4 -0
- package/src/CLAUDE.md +7 -4
- package/src/CLAUDE.md.spec.ts +6 -3
- 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/latex/captions.test.ts +57 -0
- package/src/adapters/latex/captions.ts +56 -0
- package/src/adapters/latex/index.ts +1 -0
- package/src/adapters/latex/parse.ts +7 -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 +27 -0
- package/src/adapters/node/list-entries.test.ts +61 -0
- package/src/adapters/study-registry/index.test.ts +12 -9
- package/src/adapters/study-registry/index.ts +130 -24
- package/src/adapters/study-registry/mode.test.ts +162 -0
- package/src/cli.ts +47 -3
- package/src/domain/decide-first.test.ts +291 -0
- package/src/domain/decide-first.ts +258 -0
- package/src/domain/index-columns.test.ts +261 -0
- package/src/domain/index-columns.ts +107 -0
- package/src/domain/study-measurements.test.ts +16 -8
- package/src/domain/study-mode.test.ts +55 -0
- package/src/domain/study-mode.ts +78 -0
- package/src/domain/study-registry.test.ts +72 -37
- package/src/domain/study-registry.ts +281 -50
- package/src/new-paper.test.ts +5 -1
- package/src/outputs-before-decision-rule.test.ts +251 -0
- package/src/outputs-before-decision-rule.ts +133 -0
- package/src/ports/git-history.ts +44 -0
- package/src/ports/list-entries.ts +21 -0
- package/src/ports/study-deps.ts +14 -0
- package/src/ports/study-folder.ts +38 -0
- package/src/ports/study-registry-codec.ts +10 -3
- package/src/presets.ts +1 -2
- package/src/rule-cases.test.ts +78 -3
- package/src/studies-index-modes.test.ts +129 -0
- package/src/studies.test.ts +35 -11
- package/src/studies.ts +96 -26
- package/src/study-folder.test.ts +237 -0
- package/src/study-folder.ts +176 -0
- package/src/study-gate.test.ts +467 -0
- package/src/study-gate.ts +234 -0
- package/src/study-index-input.ts +31 -0
- package/src/study-modes.ts +107 -0
- package/src/study-registry-rule.test.ts +285 -11
- package/src/study-registry-rule.ts +64 -35
- 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 };
|
|
@@ -172,6 +182,11 @@ export interface StudyPlan {
|
|
|
172
182
|
readonly author_check: AuthorCheck | null;
|
|
173
183
|
/** The paper's own keys: hypotheses, models, budget … Kept, never read; none is a plan field. */
|
|
174
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;
|
|
175
190
|
}
|
|
176
191
|
|
|
177
192
|
/**
|
|
@@ -200,6 +215,7 @@ export const PLAN_FIELDS = [
|
|
|
200
215
|
"primary_outcome",
|
|
201
216
|
"author_check",
|
|
202
217
|
"extra",
|
|
218
|
+
"mode",
|
|
203
219
|
] as const;
|
|
204
220
|
|
|
205
221
|
/** A plan with empty required texts: the author has not approved it. */
|
|
@@ -211,6 +227,11 @@ export const PLAN_TEXT_FIELDS = [
|
|
|
211
227
|
"population.source",
|
|
212
228
|
"population.draw.method",
|
|
213
229
|
"primary_outcome",
|
|
230
|
+
"mode",
|
|
231
|
+
"mode.outputs",
|
|
232
|
+
"mode.decision.file",
|
|
233
|
+
"mode.decision.note",
|
|
234
|
+
"mode.reason",
|
|
214
235
|
] as const;
|
|
215
236
|
export type PlanTextField = (typeof PLAN_TEXT_FIELDS)[number];
|
|
216
237
|
|
|
@@ -227,19 +248,51 @@ export type ParsedPlan =
|
|
|
227
248
|
}
|
|
228
249
|
| { readonly kind: "unreadable"; readonly why: string };
|
|
229
250
|
|
|
251
|
+
/**
|
|
252
|
+
* What a path inside a study's folder is, found without reading it or following a link: a regular
|
|
253
|
+
* file, nothing, or a path that passes through a symbolic link (`link`: the first one, relative to
|
|
254
|
+
* the folder).
|
|
255
|
+
*/
|
|
256
|
+
export type Located =
|
|
257
|
+
| { readonly kind: "file" }
|
|
258
|
+
| { readonly kind: "absent" }
|
|
259
|
+
| { readonly kind: "linked"; readonly link: string };
|
|
260
|
+
|
|
230
261
|
/** What a study's folder holds of the plan its row names: absent, or what its text parsed to. */
|
|
231
|
-
export type PlanState =
|
|
262
|
+
export type PlanState =
|
|
263
|
+
| { readonly kind: "absent" }
|
|
264
|
+
/** The plan's path passes through a symbolic link, `link` (relative to the folder); never read. */
|
|
265
|
+
| { readonly kind: "linked"; readonly link: string }
|
|
266
|
+
| ParsedPlan;
|
|
267
|
+
|
|
268
|
+
/** The decision file `paperlint studies new` names for a study in `folder`, relative to the paper. */
|
|
269
|
+
export const decisionPath = (folder: string): string =>
|
|
270
|
+
`repro/${folder}/${DECISION_FILE}`;
|
|
271
|
+
|
|
272
|
+
/** The decision file's name, beside the plan. */
|
|
273
|
+
export const DECISION_FILE = "decision.md";
|
|
232
274
|
|
|
233
275
|
/**
|
|
234
|
-
* The plan `paperlint studies new` writes
|
|
235
|
-
* all the population (a census); a sampled study changes it to
|
|
276
|
+
* The plan `paperlint studies new` writes for a study of `kind` in `folder`: every field present, its
|
|
277
|
+
* value to fill. Its draw is of all the population (a census); a sampled study changes it to
|
|
278
|
+
* `{ "kind": "seeded", "method", "seed" }`. A study that must declare its mode gets the default one,
|
|
279
|
+
* decided before the run, its decision file named and its outputs and note to fill.
|
|
236
280
|
*/
|
|
237
|
-
export const planTemplate = (): DraftPlan => ({
|
|
281
|
+
export const planTemplate = (kind: StudyKind, folder: string): DraftPlan => ({
|
|
238
282
|
question: "",
|
|
239
283
|
population: { source: "", draw: { kind: "all", method: "" } },
|
|
240
284
|
arms: [],
|
|
241
285
|
primary_outcome: "",
|
|
242
286
|
author_check: null,
|
|
287
|
+
...(needsMode(kind)
|
|
288
|
+
? {
|
|
289
|
+
mode: {
|
|
290
|
+
kind: "decide_before_run",
|
|
291
|
+
outputs: [],
|
|
292
|
+
decision: { file: decisionPath(folder), note: "" },
|
|
293
|
+
},
|
|
294
|
+
}
|
|
295
|
+
: {}),
|
|
243
296
|
});
|
|
244
297
|
|
|
245
298
|
/** A plan's text as `paperlint studies new` writes it: JSON, two-space indented, a final newline. */
|
|
@@ -264,8 +317,8 @@ export interface StudyInput {
|
|
|
264
317
|
readonly registry: RegistryState;
|
|
265
318
|
/** `repro/README.md`'s text; null when there is none. */
|
|
266
319
|
readonly readme: string | null;
|
|
267
|
-
/**
|
|
268
|
-
readonly
|
|
320
|
+
/** What each `<folder>/<result_file>` of the registry is, as `locate` found it; one not listed is absent. */
|
|
321
|
+
readonly results: ReadonlyMap<string, Located>;
|
|
269
322
|
/** The plan each row names, by `<folder>/<plan>`; one not listed is absent. */
|
|
270
323
|
readonly plans: ReadonlyMap<string, PlanState>;
|
|
271
324
|
}
|
|
@@ -312,6 +365,13 @@ export type StudyFinding =
|
|
|
312
365
|
readonly path: string;
|
|
313
366
|
readonly why: "missing" | "outside";
|
|
314
367
|
}
|
|
368
|
+
| {
|
|
369
|
+
readonly problem: "result-file";
|
|
370
|
+
readonly id: string;
|
|
371
|
+
readonly path: string;
|
|
372
|
+
readonly why: "linked";
|
|
373
|
+
readonly link: string;
|
|
374
|
+
}
|
|
315
375
|
| {
|
|
316
376
|
readonly problem: "no-successor";
|
|
317
377
|
readonly id: string;
|
|
@@ -351,6 +411,13 @@ export type StudyFinding =
|
|
|
351
411
|
readonly path: string;
|
|
352
412
|
readonly why: "missing" | "outside";
|
|
353
413
|
}
|
|
414
|
+
| {
|
|
415
|
+
readonly problem: "bad-plan";
|
|
416
|
+
readonly id: string;
|
|
417
|
+
readonly path: string;
|
|
418
|
+
readonly why: "linked";
|
|
419
|
+
readonly link: string;
|
|
420
|
+
}
|
|
354
421
|
| {
|
|
355
422
|
readonly problem: "bad-plan";
|
|
356
423
|
readonly id: string;
|
|
@@ -425,20 +492,82 @@ export const titleWords = (title: string): number =>
|
|
|
425
492
|
* Whether `path` stays inside the folder it is relative to. A backslash or a Windows absolute path
|
|
426
493
|
* is refused outright: `posix.normalize` does not resolve `..\\`, while the host's `join` may.
|
|
427
494
|
*/
|
|
428
|
-
const isInside = (path: string): boolean => {
|
|
495
|
+
export const isInside = (path: string): boolean => {
|
|
429
496
|
if (path.includes("\\") || isAbsolute(path) || win32.isAbsolute(path))
|
|
430
497
|
return false;
|
|
431
498
|
const n = posix.normalize(path);
|
|
432
499
|
return n !== ".." && !n.startsWith("../");
|
|
433
500
|
};
|
|
434
501
|
|
|
502
|
+
/**
|
|
503
|
+
* Whether `folder` names one folder directly inside `repro/`: not empty, not `.` or `..`, and no
|
|
504
|
+
* separator or NUL. Every command and rule that reads a study's folder reads only such a one: a row
|
|
505
|
+
* naming `../x` would have them read and write outside the paper.
|
|
506
|
+
*/
|
|
507
|
+
export const isFolderName = (folder: string): boolean =>
|
|
508
|
+
folder !== "." && folder !== ".." && /^[^/\\\0]+$/u.test(folder);
|
|
509
|
+
|
|
510
|
+
/**
|
|
511
|
+
* The rows of a registry that carry an id: exactly one, none, or several. The one way to address a
|
|
512
|
+
* study by its id — a caller that wants "the study P1-S01" gets a row only when there is exactly one,
|
|
513
|
+
* so no command can act on the first of two rows by accident. A registry may still hold two rows of
|
|
514
|
+
* one id (`repro/study-registered` reports it); this says so instead of choosing.
|
|
515
|
+
*/
|
|
516
|
+
export type StudyLookup =
|
|
517
|
+
| { readonly kind: "one"; readonly row: Study }
|
|
518
|
+
| { readonly kind: "none" }
|
|
519
|
+
| { readonly kind: "ambiguous"; readonly rows: readonly Study[] };
|
|
520
|
+
|
|
521
|
+
/** The rows of a registry that was read; none when there is no registry, or it does not parse. */
|
|
522
|
+
export const studiesOf = (state: RegistryState): readonly Study[] =>
|
|
523
|
+
state.kind === "read" ? state.registry.studies : [];
|
|
524
|
+
|
|
525
|
+
/** The rows of `studies` whose id is `id`, as a `StudyLookup`. */
|
|
526
|
+
export const studyById = (
|
|
527
|
+
studies: readonly Study[],
|
|
528
|
+
id: string,
|
|
529
|
+
): StudyLookup => {
|
|
530
|
+
const [first, ...rest] = studies.filter((s) => s.id === id);
|
|
531
|
+
if (first === undefined) return { kind: "none" };
|
|
532
|
+
return rest.length === 0
|
|
533
|
+
? { kind: "one", row: first }
|
|
534
|
+
: { kind: "ambiguous", rows: [first, ...rest] };
|
|
535
|
+
};
|
|
536
|
+
|
|
537
|
+
/**
|
|
538
|
+
* The key a path inside a study's folder is held under: `<folder>/<path>`. A plan (`StudyInput.plans`,
|
|
539
|
+
* `IndexInput.plans`), the history of its study (`IndexInput.history`) and a result file
|
|
540
|
+
* (`StudyInput.results`) are all found by it.
|
|
541
|
+
*/
|
|
542
|
+
export const folderKey = (folder: string, path: string): string =>
|
|
543
|
+
`${folder}/${path}`;
|
|
544
|
+
|
|
435
545
|
/**
|
|
436
546
|
* The `<folder>/<plan>` of each row whose plan lies inside its folder: the files the judge needs read
|
|
437
547
|
* (`StudyInput.plans`). A plan outside its folder is reported without being read.
|
|
438
548
|
*/
|
|
439
549
|
export const planKeys = (r: StudyRegistry): readonly string[] =>
|
|
550
|
+
planRefs(r).map((p) => folderKey(p.folder, p.plan));
|
|
551
|
+
|
|
552
|
+
/** A plan a row names: where it is read from, and the kind of the study — what its parser needs. */
|
|
553
|
+
export interface PlanRef {
|
|
554
|
+
readonly folder: string;
|
|
555
|
+
readonly plan: string;
|
|
556
|
+
readonly kind: StudyKind;
|
|
557
|
+
}
|
|
558
|
+
|
|
559
|
+
/** Each plan `planKeys` names. */
|
|
560
|
+
export const planRefs = (r: StudyRegistry): readonly PlanRef[] =>
|
|
440
561
|
r.studies.flatMap((s) =>
|
|
441
|
-
s.plan !== undefined && isInside(s.plan)
|
|
562
|
+
s.plan !== undefined && isInside(s.plan) && isFolderName(s.folder)
|
|
563
|
+
? [
|
|
564
|
+
{
|
|
565
|
+
folder: s.folder,
|
|
566
|
+
plan: s.plan,
|
|
567
|
+
kind: s.kind,
|
|
568
|
+
},
|
|
569
|
+
]
|
|
570
|
+
: [],
|
|
442
571
|
);
|
|
443
572
|
|
|
444
573
|
/** One row of a registry, with what the judge read beside it. */
|
|
@@ -463,7 +592,7 @@ const badId: RowCheck = ({ r, s }) =>
|
|
|
463
592
|
|
|
464
593
|
/** Reported once, on the first row that holds the id. */
|
|
465
594
|
const duplicateId: RowCheck = (row) =>
|
|
466
|
-
row.r.studies
|
|
595
|
+
studyById(row.r.studies, row.s.id).kind === "ambiguous" &&
|
|
467
596
|
isFirst(row, (o) => o.id)
|
|
468
597
|
? { problem: "duplicate-id", id: row.s.id }
|
|
469
598
|
: null;
|
|
@@ -513,9 +642,23 @@ const resultFile: RowCheck = ({ s, input }) => {
|
|
|
513
642
|
if (path === undefined) return null;
|
|
514
643
|
if (!isInside(path))
|
|
515
644
|
return { problem: "result-file", id: s.id, path, why: "outside" };
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
645
|
+
const found = input.results.get(folderKey(s.folder, path)) ?? {
|
|
646
|
+
kind: "absent",
|
|
647
|
+
};
|
|
648
|
+
switch (found.kind) {
|
|
649
|
+
case "file":
|
|
650
|
+
return null;
|
|
651
|
+
case "linked":
|
|
652
|
+
return {
|
|
653
|
+
problem: "result-file",
|
|
654
|
+
id: s.id,
|
|
655
|
+
path,
|
|
656
|
+
why: "linked",
|
|
657
|
+
link: found.link,
|
|
658
|
+
};
|
|
659
|
+
case "absent":
|
|
660
|
+
return { problem: "result-file", id: s.id, path, why: "missing" };
|
|
661
|
+
}
|
|
519
662
|
};
|
|
520
663
|
|
|
521
664
|
const noSuccessor: RowCheck = ({ r, s }) => {
|
|
@@ -534,13 +677,15 @@ const unknownMeasurement: RowCheck = ({ r, s }) =>
|
|
|
534
677
|
? null
|
|
535
678
|
: { problem: "unknown-measurement", id: s.id, measurement: s.measurement };
|
|
536
679
|
|
|
537
|
-
/** The state of the plan the row
|
|
538
|
-
const
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
? (input.plans.get(`${s.folder}/${s.plan}`) ?? { kind: "absent" })
|
|
680
|
+
/** The state of the plan `plan` of the row `s` (absent when outside its folder): the one place a plan is looked up. */
|
|
681
|
+
const planStateOf = (input: StudyInput, s: Study, plan: string): PlanState =>
|
|
682
|
+
isInside(plan)
|
|
683
|
+
? (input.plans.get(folderKey(s.folder, plan)) ?? { kind: "absent" })
|
|
542
684
|
: { kind: "absent" };
|
|
543
|
-
|
|
685
|
+
|
|
686
|
+
/** The state of the plan the row names; null when it names none. */
|
|
687
|
+
const planOf = ({ s, input }: Row): PlanState | null =>
|
|
688
|
+
s.plan === undefined ? null : planStateOf(input, s, s.plan);
|
|
544
689
|
|
|
545
690
|
const hasRun = (s: Study): boolean => RAN_STATUSES.includes(s.status);
|
|
546
691
|
|
|
@@ -554,6 +699,8 @@ const planFinding = (
|
|
|
554
699
|
switch (state.kind) {
|
|
555
700
|
case "absent":
|
|
556
701
|
return { ...at, why: "missing" };
|
|
702
|
+
case "linked":
|
|
703
|
+
return { ...at, why: "linked", link: state.link };
|
|
557
704
|
case "unreadable":
|
|
558
705
|
return { ...at, why: "unreadable", reasons: state.why };
|
|
559
706
|
case "draft":
|
|
@@ -571,11 +718,7 @@ const badPlan: RowCheck = ({ s, input }) => {
|
|
|
571
718
|
if (path === undefined) return null;
|
|
572
719
|
if (!isInside(path))
|
|
573
720
|
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
|
-
);
|
|
721
|
+
return planFinding(s, path, planStateOf(input, s, path));
|
|
579
722
|
};
|
|
580
723
|
|
|
581
724
|
/** A plan under its old name: read, and named for renaming. */
|
|
@@ -695,11 +838,12 @@ const judgeRow = (row: Row): readonly StudyFinding[] =>
|
|
|
695
838
|
function judgeIndex(
|
|
696
839
|
r: StudyRegistry,
|
|
697
840
|
readme: string | null,
|
|
841
|
+
plans: ReadonlyMap<string, PlanState>,
|
|
698
842
|
): StudyFinding | null {
|
|
699
843
|
if (readme === null) return { problem: "stale-index", why: "no-readme" };
|
|
700
844
|
const block = embeddedIndex(readme);
|
|
701
845
|
if (block === null) return { problem: "stale-index", why: "no-markers" };
|
|
702
|
-
return indexInSync(readme, r)
|
|
846
|
+
return indexInSync(readme, r, { plans, history: new Map() })
|
|
703
847
|
? null
|
|
704
848
|
: { problem: "stale-index", why: "differs" };
|
|
705
849
|
}
|
|
@@ -718,7 +862,7 @@ export function judgeStudies(input: StudyInput): readonly StudyFinding[] {
|
|
|
718
862
|
return [{ problem: "unreadable", why: state.why }];
|
|
719
863
|
const r = state.registry;
|
|
720
864
|
const next = nextStudyId(r);
|
|
721
|
-
const index = judgeIndex(r, input.readme);
|
|
865
|
+
const index = judgeIndex(r, input.readme, input.plans);
|
|
722
866
|
return [
|
|
723
867
|
...input.folders
|
|
724
868
|
.filter((f) => !r.studies.some((s) => s.folder === f))
|
|
@@ -739,19 +883,69 @@ export function judgeStudies(input: StudyInput): readonly StudyFinding[] {
|
|
|
739
883
|
const cell = (s: string): string =>
|
|
740
884
|
s.replace(/\r?\n/gu, " ").replace(/\\/gu, "\\\\").replace(/\|/gu, "\\|");
|
|
741
885
|
|
|
742
|
-
/**
|
|
886
|
+
/**
|
|
887
|
+
* What the index is generated from beyond the registry: the plans by `<folder>/<plan>` (as
|
|
888
|
+
* `StudyInput.plans`), and the history of each decide-first study by the same key. A study with no
|
|
889
|
+
* history listed shows `not read` — the lint, which never compares those columns, asks none.
|
|
890
|
+
*/
|
|
891
|
+
export interface IndexInput {
|
|
892
|
+
readonly plans: ReadonlyMap<string, PlanState>;
|
|
893
|
+
readonly history: ReadonlyMap<string, IndexHistory>;
|
|
894
|
+
}
|
|
895
|
+
|
|
896
|
+
/** The mode the plan of `s` declares; null when it names none, or the plan is absent or unreadable. */
|
|
897
|
+
const declaredMode = (
|
|
898
|
+
s: Study,
|
|
899
|
+
plans: ReadonlyMap<string, PlanState>,
|
|
900
|
+
): StudyMode | null => {
|
|
901
|
+
const state =
|
|
902
|
+
s.plan !== undefined && isInside(s.plan)
|
|
903
|
+
? plans.get(folderKey(s.folder, s.plan))
|
|
904
|
+
: undefined;
|
|
905
|
+
return state === undefined ||
|
|
906
|
+
state.kind === "absent" ||
|
|
907
|
+
state.kind === "linked" ||
|
|
908
|
+
state.kind === "unreadable"
|
|
909
|
+
? null
|
|
910
|
+
: (state.plan.mode ?? null);
|
|
911
|
+
};
|
|
912
|
+
|
|
913
|
+
/** What the index shows of `s` beyond its row. */
|
|
914
|
+
const factsOf = (s: Study, at: IndexInput): IndexFacts => ({
|
|
915
|
+
mode: declaredMode(s, at.plans),
|
|
916
|
+
history:
|
|
917
|
+
(s.plan === undefined
|
|
918
|
+
? undefined
|
|
919
|
+
: at.history.get(folderKey(s.folder, s.plan))) ?? null,
|
|
920
|
+
});
|
|
921
|
+
|
|
922
|
+
/**
|
|
923
|
+
* The columns of the index: each one's header, its cell for a study, and whether git's history is
|
|
924
|
+
* where the cell comes from — those two are never compared (src/domain/index-columns.ts).
|
|
925
|
+
*/
|
|
743
926
|
const COLUMNS: readonly {
|
|
744
927
|
readonly head: string;
|
|
745
|
-
readonly of: (s: Study) => string;
|
|
928
|
+
readonly of: (s: Study, f: IndexFacts) => string;
|
|
929
|
+
readonly fromGit: boolean;
|
|
746
930
|
}[] = [
|
|
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: "
|
|
931
|
+
{ head: "id", of: (s) => s.id, fromGit: false },
|
|
932
|
+
{ head: "title", of: (s) => cell(s.title), fromGit: false },
|
|
933
|
+
{ head: "status", of: (s) => s.status, fromGit: false },
|
|
934
|
+
{ head: "result", of: (s) => cell(s.result ?? "—"), fromGit: false },
|
|
935
|
+
{ head: "in paper", of: (s) => s.in_paper, fromGit: false },
|
|
936
|
+
{ head: "mode", of: (_, f) => cell(modeCell(f)), fromGit: false },
|
|
937
|
+
{ head: "decision", of: (_, f) => decisionCell(f), fromGit: true },
|
|
938
|
+
{ head: "first output", of: (_, f) => firstOutputCell(f), fromGit: true },
|
|
939
|
+
{
|
|
940
|
+
head: "folder",
|
|
941
|
+
of: (s) => `[${s.folder}/](${s.folder}/)`,
|
|
942
|
+
fromGit: false,
|
|
943
|
+
},
|
|
753
944
|
];
|
|
754
945
|
|
|
946
|
+
/** The positions of the columns read from git: left out when an index is compared. */
|
|
947
|
+
const FROM_GIT = COLUMNS.flatMap((c, i) => (c.fromGit ? [i] : []));
|
|
948
|
+
|
|
755
949
|
/** The width a cell takes: its code points. */
|
|
756
950
|
const widthOf = (s: string): number => Array.from(s).length;
|
|
757
951
|
|
|
@@ -764,20 +958,23 @@ const padded = (t: string, width: number): string =>
|
|
|
764
958
|
* widest cell (at least 3), the way prettier writes a table — so formatting the README leaves it as
|
|
765
959
|
* it is.
|
|
766
960
|
*/
|
|
767
|
-
export function studyIndex(r: StudyRegistry): string {
|
|
961
|
+
export function studyIndex(r: StudyRegistry, at: IndexInput): string {
|
|
962
|
+
const studies = r.studies.map((s) => ({ s, f: factsOf(s, at) }));
|
|
768
963
|
const sized = COLUMNS.map((c) => ({
|
|
769
964
|
...c,
|
|
770
965
|
width: Math.max(
|
|
771
966
|
3,
|
|
772
967
|
widthOf(c.head),
|
|
773
|
-
...
|
|
968
|
+
...studies.map(({ s, f }) => widthOf(c.of(s, f))),
|
|
774
969
|
),
|
|
775
970
|
}));
|
|
776
971
|
const line = (cells: readonly string[]): string => `| ${cells.join(" | ")} |`;
|
|
777
972
|
return [
|
|
778
973
|
line(sized.map((c) => padded(c.head, c.width))),
|
|
779
974
|
line(sized.map((c) => "-".repeat(c.width))),
|
|
780
|
-
...
|
|
975
|
+
...studies.map(({ s, f }) =>
|
|
976
|
+
line(sized.map((c) => padded(c.of(s, f), c.width))),
|
|
977
|
+
),
|
|
781
978
|
].join("\n");
|
|
782
979
|
}
|
|
783
980
|
|
|
@@ -786,7 +983,7 @@ export function studyIndex(r: StudyRegistry): string {
|
|
|
786
983
|
* `---`. The index is judged on this, because the padding belongs to the formatter: prettier sizes a
|
|
787
984
|
* column by display width, so a wide character pads differently from a code-point count.
|
|
788
985
|
*/
|
|
789
|
-
const cellsOf = (table: string): string =>
|
|
986
|
+
const cellsOf = (table: string, skip: readonly number[]): string =>
|
|
790
987
|
table
|
|
791
988
|
.trim()
|
|
792
989
|
.split("\n")
|
|
@@ -796,19 +993,44 @@ const cellsOf = (table: string): string =>
|
|
|
796
993
|
.replace(/^\||\|$/gu, "")
|
|
797
994
|
.split(/(?<!\\)\|/u)
|
|
798
995
|
.map((c) => c.trim().replace(/^:?-{3,}:?$/u, "---"))
|
|
996
|
+
.filter((_, i) => !skip.includes(i))
|
|
799
997
|
.join("|"),
|
|
800
998
|
)
|
|
801
999
|
.join("\n");
|
|
802
1000
|
|
|
803
|
-
/** Whether `readme` holds the index of `r
|
|
804
|
-
|
|
1001
|
+
/** Whether `readme` holds the index of `r` in every column but those at `skip`, whatever its padding. */
|
|
1002
|
+
const sameCells = (
|
|
1003
|
+
readme: string | null,
|
|
1004
|
+
index: string,
|
|
1005
|
+
skip: readonly number[],
|
|
1006
|
+
): boolean => {
|
|
805
1007
|
const block = readme === null ? null : embeddedIndex(readme);
|
|
806
|
-
return block !== null && cellsOf(block) === cellsOf(
|
|
807
|
-
}
|
|
1008
|
+
return block !== null && cellsOf(block, skip) === cellsOf(index, skip);
|
|
1009
|
+
};
|
|
1010
|
+
|
|
1011
|
+
/**
|
|
1012
|
+
* Whether `readme` holds the index of `r`, whatever padding a formatter gave its table and whatever
|
|
1013
|
+
* its two columns read from git say: what `repro/study-registered` checks.
|
|
1014
|
+
*/
|
|
1015
|
+
export const indexInSync = (
|
|
1016
|
+
readme: string | null,
|
|
1017
|
+
r: StudyRegistry,
|
|
1018
|
+
at: IndexInput,
|
|
1019
|
+
): boolean => sameCells(readme, studyIndex(r, at), FROM_GIT);
|
|
1020
|
+
|
|
1021
|
+
/**
|
|
1022
|
+
* Whether `readme` holds the index of `r` in every column, whatever its padding: when it does,
|
|
1023
|
+
* `studies index` leaves the README as it is, so the padding a formatter gave the table stays.
|
|
1024
|
+
*/
|
|
1025
|
+
export const indexCurrent = (
|
|
1026
|
+
readme: string | null,
|
|
1027
|
+
r: StudyRegistry,
|
|
1028
|
+
at: IndexInput,
|
|
1029
|
+
): boolean => sameCells(readme, studyIndex(r, at), []);
|
|
808
1030
|
|
|
809
1031
|
/** 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}`;
|
|
1032
|
+
export const indexBlock = (r: StudyRegistry, at: IndexInput): string =>
|
|
1033
|
+
`${INDEX_BEGIN}\n\n${studyIndex(r, at)}\n\n${INDEX_END}`;
|
|
812
1034
|
|
|
813
1035
|
/**
|
|
814
1036
|
* Where `marker` stands alone on a line of `text`, at or after `from`; -1 when it does not. A marker
|
|
@@ -832,13 +1054,18 @@ export function embeddedIndex(readme: string): string | null {
|
|
|
832
1054
|
* `readme` with its index regenerated from `r`: the block replaced in place, appended when the README
|
|
833
1055
|
* has none, a README of its own when there is none.
|
|
834
1056
|
*/
|
|
835
|
-
export function withIndex(
|
|
836
|
-
|
|
1057
|
+
export function withIndex(
|
|
1058
|
+
readme: string | null,
|
|
1059
|
+
r: StudyRegistry,
|
|
1060
|
+
at: IndexInput,
|
|
1061
|
+
): string {
|
|
1062
|
+
const fresh = indexBlock(r, at);
|
|
1063
|
+
if (readme === null) return `${INDEX_HEADING}\n\n${fresh}\n`;
|
|
837
1064
|
const block = embeddedIndex(readme);
|
|
838
|
-
if (block === null) return `${readme.trimEnd()}\n\n${
|
|
1065
|
+
if (block === null) return `${readme.trimEnd()}\n\n${fresh}\n`;
|
|
839
1066
|
const begin = markerAt(readme, INDEX_BEGIN);
|
|
840
1067
|
const after = begin + INDEX_BEGIN.length + block.length + INDEX_END.length;
|
|
841
|
-
return `${readme.slice(0, begin)}${
|
|
1068
|
+
return `${readme.slice(0, begin)}${fresh}${readme.slice(after)}`;
|
|
842
1069
|
}
|
|
843
1070
|
|
|
844
1071
|
/** The registry's text as `paperlint studies new` writes it: JSON, two-space indented, a final newline. */
|
|
@@ -900,7 +1127,9 @@ const HEADS: { readonly [P in Problem]: (f: FindingOf[P]) => string } = {
|
|
|
900
1127
|
"no-result": (f) =>
|
|
901
1128
|
`${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
1129
|
"result-file": (f) =>
|
|
903
|
-
|
|
1130
|
+
f.why === "linked"
|
|
1131
|
+
? `${REGISTRY}: the \`result_file\` of ${tick(f.id)}, ${tick(f.path)}, passes through the symbolic link ${tick(f.link)} in its folder; nothing is read through a link. What to do: name a regular file inside the study's folder, or replace the link with the file itself.`
|
|
1132
|
+
: `${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
1133
|
"no-successor": (f) =>
|
|
905
1134
|
`${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
1135
|
"stale-index": (f) => `${STALE_HEAD[f.why]} ${RUN_INDEX}`,
|
|
@@ -920,10 +1149,12 @@ const HEADS: { readonly [P in Problem]: (f: FindingOf[P]) => string } = {
|
|
|
920
1149
|
switch (f.why) {
|
|
921
1150
|
case "missing":
|
|
922
1151
|
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\`.`;
|
|
1152
|
+
case "linked":
|
|
1153
|
+
return `${REGISTRY}: ${of} passes through the symbolic link ${tick(f.link)} in its folder; nothing is read through a link. What to do: replace the link with the plan file itself.`;
|
|
923
1154
|
case "outside":
|
|
924
1155
|
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
1156
|
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\`.`;
|
|
1157
|
+
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\`.`;
|
|
927
1158
|
case "unfilled":
|
|
928
1159
|
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
1160
|
}
|
package/src/new-paper.test.ts
CHANGED
|
@@ -42,9 +42,13 @@ import {
|
|
|
42
42
|
registryText,
|
|
43
43
|
withIndex,
|
|
44
44
|
type StudyRegistry,
|
|
45
|
+
type IndexInput,
|
|
45
46
|
} from "./domain/study-registry.ts";
|
|
46
47
|
import { statSync } from "node:fs";
|
|
47
48
|
|
|
49
|
+
/** No plan and no history: the index with every mode column `—`. */
|
|
50
|
+
const NO_FACTS: IndexInput = { plans: new Map(), history: new Map() };
|
|
51
|
+
|
|
48
52
|
const dirs: string[] = [];
|
|
49
53
|
afterEach(() => {
|
|
50
54
|
for (const d of dirs.splice(0)) rmSync(d, { recursive: true, force: true });
|
|
@@ -813,7 +817,7 @@ describe("paperlint lint — a scaffolded paper has no layout finding", () => {
|
|
|
813
817
|
"talk/.gitkeep": "",
|
|
814
818
|
"sections/.gitkeep": "",
|
|
815
819
|
"repro/studies.json": registryText(SOME_STUDY),
|
|
816
|
-
"repro/README.md": withIndex(REPRO_INDEX_STUB, SOME_STUDY),
|
|
820
|
+
"repro/README.md": withIndex(REPRO_INDEX_STUB, SOME_STUDY, NO_FACTS),
|
|
817
821
|
"repro/P1-S01-some-study/README.md": "# s\n",
|
|
818
822
|
"repro/P1-S01-some-study/.gitkeep": "",
|
|
819
823
|
});
|