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.
Files changed (187) hide show
  1. package/.github/workflows/ci.yml +15 -4
  2. package/README.md +1 -0
  3. package/dist/CLAUDE.md.spec.d.ts.map +1 -1
  4. package/dist/CLAUDE.md.spec.js +6 -3
  5. package/dist/CLAUDE.md.spec.js.map +1 -1
  6. package/dist/adapters/git/index.d.ts +10 -0
  7. package/dist/adapters/git/index.d.ts.map +1 -0
  8. package/dist/adapters/git/index.js +151 -0
  9. package/dist/adapters/git/index.js.map +1 -0
  10. package/dist/adapters/latex/captions.d.ts +9 -0
  11. package/dist/adapters/latex/captions.d.ts.map +1 -0
  12. package/dist/adapters/latex/captions.js +40 -0
  13. package/dist/adapters/latex/captions.js.map +1 -0
  14. package/dist/adapters/latex/index.d.ts +1 -0
  15. package/dist/adapters/latex/index.d.ts.map +1 -1
  16. package/dist/adapters/latex/index.js +1 -0
  17. package/dist/adapters/latex/index.js.map +1 -1
  18. package/dist/adapters/latex/parse.d.ts.map +1 -1
  19. package/dist/adapters/latex/parse.js +6 -0
  20. package/dist/adapters/latex/parse.js.map +1 -1
  21. package/dist/adapters/memory/index.d.ts +6 -0
  22. package/dist/adapters/memory/index.d.ts.map +1 -1
  23. package/dist/adapters/memory/index.js +8 -0
  24. package/dist/adapters/memory/index.js.map +1 -1
  25. package/dist/adapters/node/index.d.ts +1 -0
  26. package/dist/adapters/node/index.d.ts.map +1 -1
  27. package/dist/adapters/node/index.js +1 -0
  28. package/dist/adapters/node/index.js.map +1 -1
  29. package/dist/adapters/node/list-entries.io.d.ts +3 -0
  30. package/dist/adapters/node/list-entries.io.d.ts.map +1 -0
  31. package/dist/adapters/node/list-entries.io.js +26 -0
  32. package/dist/adapters/node/list-entries.io.js.map +1 -0
  33. package/dist/adapters/study-registry/index.d.ts +70 -10
  34. package/dist/adapters/study-registry/index.d.ts.map +1 -1
  35. package/dist/adapters/study-registry/index.js +84 -10
  36. package/dist/adapters/study-registry/index.js.map +1 -1
  37. package/dist/cli.d.ts.map +1 -1
  38. package/dist/cli.js +33 -3
  39. package/dist/cli.js.map +1 -1
  40. package/dist/domain/decide-first.d.ts +110 -0
  41. package/dist/domain/decide-first.d.ts.map +1 -0
  42. package/dist/domain/decide-first.js +108 -0
  43. package/dist/domain/decide-first.js.map +1 -0
  44. package/dist/domain/index-columns.d.ts +45 -0
  45. package/dist/domain/index-columns.d.ts.map +1 -0
  46. package/dist/domain/index-columns.js +64 -0
  47. package/dist/domain/index-columns.js.map +1 -0
  48. package/dist/domain/study-mode.d.ts +37 -0
  49. package/dist/domain/study-mode.d.ts.map +1 -0
  50. package/dist/domain/study-mode.js +45 -0
  51. package/dist/domain/study-mode.js.map +1 -0
  52. package/dist/domain/study-registry.d.ts +116 -12
  53. package/dist/domain/study-registry.d.ts.map +1 -1
  54. package/dist/domain/study-registry.js +164 -44
  55. package/dist/domain/study-registry.js.map +1 -1
  56. package/dist/outputs-before-decision-rule.d.ts +17 -0
  57. package/dist/outputs-before-decision-rule.d.ts.map +1 -0
  58. package/dist/outputs-before-decision-rule.js +94 -0
  59. package/dist/outputs-before-decision-rule.js.map +1 -0
  60. package/dist/ports/git-history.d.ts +44 -0
  61. package/dist/ports/git-history.d.ts.map +1 -0
  62. package/dist/ports/git-history.js +2 -0
  63. package/dist/ports/git-history.js.map +1 -0
  64. package/dist/ports/list-entries.d.ts +20 -0
  65. package/dist/ports/list-entries.d.ts.map +1 -0
  66. package/dist/ports/list-entries.js +2 -0
  67. package/dist/ports/list-entries.js.map +1 -0
  68. package/dist/ports/study-deps.d.ts +14 -0
  69. package/dist/ports/study-deps.d.ts.map +1 -0
  70. package/dist/ports/study-deps.js +2 -0
  71. package/dist/ports/study-deps.js.map +1 -0
  72. package/dist/ports/study-folder.d.ts +43 -0
  73. package/dist/ports/study-folder.d.ts.map +1 -0
  74. package/dist/ports/study-folder.js +2 -0
  75. package/dist/ports/study-folder.js.map +1 -0
  76. package/dist/ports/study-registry-codec.d.ts +6 -3
  77. package/dist/ports/study-registry-codec.d.ts.map +1 -1
  78. package/dist/presets.d.ts +1 -2
  79. package/dist/presets.d.ts.map +1 -1
  80. package/dist/presets.js +1 -2
  81. package/dist/presets.js.map +1 -1
  82. package/dist/studies.d.ts +4 -0
  83. package/dist/studies.d.ts.map +1 -1
  84. package/dist/studies.js +52 -21
  85. package/dist/studies.js.map +1 -1
  86. package/dist/study-folder.d.ts +14 -0
  87. package/dist/study-folder.d.ts.map +1 -0
  88. package/dist/study-folder.js +123 -0
  89. package/dist/study-folder.js.map +1 -0
  90. package/dist/study-gate.d.ts +14 -0
  91. package/dist/study-gate.d.ts.map +1 -0
  92. package/dist/study-gate.js +132 -0
  93. package/dist/study-gate.js.map +1 -0
  94. package/dist/study-index-input.d.ts +5 -0
  95. package/dist/study-index-input.d.ts.map +1 -0
  96. package/dist/study-index-input.js +18 -0
  97. package/dist/study-index-input.js.map +1 -0
  98. package/dist/study-modes.d.ts +56 -0
  99. package/dist/study-modes.d.ts.map +1 -0
  100. package/dist/study-modes.js +59 -0
  101. package/dist/study-modes.js.map +1 -0
  102. package/dist/study-registry-rule.d.ts +13 -1
  103. package/dist/study-registry-rule.d.ts.map +1 -1
  104. package/dist/study-registry-rule.js +33 -21
  105. package/dist/study-registry-rule.js.map +1 -1
  106. package/dist/study-run.d.ts +19 -0
  107. package/dist/study-run.d.ts.map +1 -0
  108. package/dist/study-run.js +9 -0
  109. package/dist/study-run.js.map +1 -0
  110. package/docs/design/study-modes.md +265 -0
  111. package/docs/design/study-registry.md +6 -0
  112. package/docs/e2e.md +8 -4
  113. package/docs/layout.md +22 -19
  114. package/docs/rules/repro/outputs-before-decision.md +122 -0
  115. package/docs/rules/repro/study-registered.md +49 -15
  116. package/docs/rules/tex/heading-case.md +1 -1
  117. package/docs/rules.md +54 -53
  118. package/docs/testing.md +1 -0
  119. package/eslint.config.mjs +36 -24
  120. package/package.json +2 -1
  121. package/scripts/check.ts +6 -0
  122. package/skills/camera-ready/SKILL.md +6 -1
  123. package/skills/camera-ready/SKILL.md.spec.ts +5 -0
  124. package/skills/draft-paper/SKILL.md +11 -3
  125. package/skills/draft-paper/SKILL.md.spec.ts +10 -2
  126. package/skills/grade-paper-writing/SKILL.md +4 -2
  127. package/skills/grade-paper-writing/SKILL.md.spec.ts +3 -1
  128. package/skills/grade-paper-writing/__snapshots__/prose-lint.test.ts.snap +16 -3
  129. package/skills/grade-paper-writing/prose-lint.mjs +127 -95
  130. package/skills/grade-paper-writing/prose-lint.test.ts +119 -10
  131. package/skills/harden-paper/SKILL.md +12 -2
  132. package/skills/harden-paper/SKILL.md.spec.ts +11 -1
  133. package/skills/osf-artifact-upload/SKILL.md +7 -1
  134. package/skills/osf-artifact-upload/SKILL.md.spec.ts +6 -0
  135. package/skills/pc-panel-review/SKILL.md +10 -3
  136. package/skills/pc-panel-review/SKILL.md.spec.ts +9 -2
  137. package/skills/tighten-paper/SKILL.md +5 -1
  138. package/skills/tighten-paper/SKILL.md.spec.ts +4 -0
  139. package/src/CLAUDE.md +7 -4
  140. package/src/CLAUDE.md.spec.ts +6 -3
  141. package/src/adapters/git/history.test.ts +121 -0
  142. package/src/adapters/git/index.test.ts +268 -0
  143. package/src/adapters/git/index.ts +227 -0
  144. package/src/adapters/latex/captions.test.ts +57 -0
  145. package/src/adapters/latex/captions.ts +56 -0
  146. package/src/adapters/latex/index.ts +1 -0
  147. package/src/adapters/latex/parse.ts +7 -0
  148. package/src/adapters/memory/index.test.ts +15 -1
  149. package/src/adapters/memory/index.ts +13 -0
  150. package/src/adapters/node/index.ts +1 -0
  151. package/src/adapters/node/list-entries.io.ts +27 -0
  152. package/src/adapters/node/list-entries.test.ts +61 -0
  153. package/src/adapters/study-registry/index.test.ts +12 -9
  154. package/src/adapters/study-registry/index.ts +130 -24
  155. package/src/adapters/study-registry/mode.test.ts +162 -0
  156. package/src/cli.ts +47 -3
  157. package/src/domain/decide-first.test.ts +291 -0
  158. package/src/domain/decide-first.ts +258 -0
  159. package/src/domain/index-columns.test.ts +261 -0
  160. package/src/domain/index-columns.ts +107 -0
  161. package/src/domain/study-measurements.test.ts +16 -8
  162. package/src/domain/study-mode.test.ts +55 -0
  163. package/src/domain/study-mode.ts +78 -0
  164. package/src/domain/study-registry.test.ts +72 -37
  165. package/src/domain/study-registry.ts +281 -50
  166. package/src/new-paper.test.ts +5 -1
  167. package/src/outputs-before-decision-rule.test.ts +251 -0
  168. package/src/outputs-before-decision-rule.ts +133 -0
  169. package/src/ports/git-history.ts +44 -0
  170. package/src/ports/list-entries.ts +21 -0
  171. package/src/ports/study-deps.ts +14 -0
  172. package/src/ports/study-folder.ts +38 -0
  173. package/src/ports/study-registry-codec.ts +10 -3
  174. package/src/presets.ts +1 -2
  175. package/src/rule-cases.test.ts +78 -3
  176. package/src/studies-index-modes.test.ts +129 -0
  177. package/src/studies.test.ts +35 -11
  178. package/src/studies.ts +96 -26
  179. package/src/study-folder.test.ts +237 -0
  180. package/src/study-folder.ts +176 -0
  181. package/src/study-gate.test.ts +467 -0
  182. package/src/study-gate.ts +234 -0
  183. package/src/study-index-input.ts +31 -0
  184. package/src/study-modes.ts +107 -0
  185. package/src/study-registry-rule.test.ts +285 -11
  186. package/src/study-registry-rule.ts +64 -35
  187. 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 and compared as a string, between two fixed markers. Nothing parses it.
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 = { readonly kind: "absent" } | ParsedPlan;
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: 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" }`.
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
- /** Each `<folder>/<result_file>` of the registry that is a file. */
268
- readonly existingResults: ReadonlySet<string>;
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) ? [`${s.folder}/${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.filter((o) => o.id === row.s.id).length > 1 &&
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
- return input.existingResults.has(`${s.folder}/${path}`)
517
- ? null
518
- : { problem: "result-file", id: s.id, path, why: "missing" };
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 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" })
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
- /** The columns of the index: each one's header, and its cell for a study. */
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: "folder", of: (s) => `[${s.folder}/](${s.folder}/)` },
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
- ...r.studies.map((s) => widthOf(c.of(s))),
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
- ...r.studies.map((s) => line(sized.map((c) => padded(c.of(s), c.width)))),
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`, whatever padding a formatter gave its table. */
804
- export function indexInSync(readme: string | null, r: StudyRegistry): boolean {
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(studyIndex(r));
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(readme: string | null, r: StudyRegistry): string {
836
- if (readme === null) return `${INDEX_HEADING}\n\n${indexBlock(r)}\n`;
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${indexBlock(r)}\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)}${indexBlock(r)}${readme.slice(after)}`;
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
- `${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.`,
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
  }
@@ -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
  });