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
|
@@ -6,6 +6,7 @@ import { absolutePath } from "../../domain/paths.ts";
|
|
|
6
6
|
import type { Download } from "../../ports/download.ts";
|
|
7
7
|
import type { Files } from "../../ports/files.ts";
|
|
8
8
|
import type { ListDir } from "../../ports/talk-media.ts";
|
|
9
|
+
import type { ListEntries } from "../../ports/list-entries.ts";
|
|
9
10
|
import type { Command, ProcessExit, RunProcess } from "../../ports/process.ts";
|
|
10
11
|
import type { NotAPromise, Scratch, Workspace } from "../../ports/workspace.ts";
|
|
11
12
|
import { err, ok } from "../../domain/result.ts";
|
|
@@ -72,6 +73,18 @@ export const memoryListDir =
|
|
|
72
73
|
),
|
|
73
74
|
];
|
|
74
75
|
|
|
76
|
+
/**
|
|
77
|
+
* `ListEntries` over `memoryFiles`: each name directly under a directory, a file when it is one, else a
|
|
78
|
+
* directory. Memory holds no links: the walk's refusal to follow one is the node adapter's to show.
|
|
79
|
+
*/
|
|
80
|
+
export const memoryListEntries =
|
|
81
|
+
(files: MemoryFiles): ListEntries =>
|
|
82
|
+
(dir) =>
|
|
83
|
+
memoryListDir(files)(dir).map((name) => ({
|
|
84
|
+
name,
|
|
85
|
+
kind: files.isFile(absolutePath(`${dir}/${name}`)) ? "file" : "dir",
|
|
86
|
+
}));
|
|
87
|
+
|
|
75
88
|
export interface MemoryWorkspace extends Workspace {
|
|
76
89
|
/** Every file written into a scratch directory, in order. */
|
|
77
90
|
readonly written: {
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
/** `ListEntries` over `node:fs`: `readdir` with its file types, which `lstat` each entry, never following a link. */
|
|
2
|
+
import { readdirSync, type Dirent } from "node:fs";
|
|
3
|
+
import type { AbsolutePath } from "../../domain/paths.ts";
|
|
4
|
+
import { codeOf } from "../../domain/text.ts";
|
|
5
|
+
import type { DirEntry, ListEntries } from "../../ports/list-entries.ts";
|
|
6
|
+
|
|
7
|
+
/** What a directory entry is, its link never followed. */
|
|
8
|
+
const kindOf = (d: Readonly<Dirent>): DirEntry["kind"] => {
|
|
9
|
+
if (d.isSymbolicLink()) return "symlink";
|
|
10
|
+
if (d.isDirectory()) return "dir";
|
|
11
|
+
return d.isFile() ? "file" : "other";
|
|
12
|
+
};
|
|
13
|
+
|
|
14
|
+
export const nodeListEntries: ListEntries = (
|
|
15
|
+
dir: AbsolutePath,
|
|
16
|
+
): readonly DirEntry[] => {
|
|
17
|
+
try {
|
|
18
|
+
return readdirSync(dir, { withFileTypes: true }).map((d) => ({
|
|
19
|
+
name: d.name,
|
|
20
|
+
kind: kindOf(d),
|
|
21
|
+
}));
|
|
22
|
+
} catch (e) {
|
|
23
|
+
const code = codeOf(e);
|
|
24
|
+
if (code === "ENOENT" || code === "ENOTDIR") return [];
|
|
25
|
+
throw e;
|
|
26
|
+
}
|
|
27
|
+
};
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `nodeListEntries`: a directory's entries with their kinds, a symbolic link reported as one and never
|
|
3
|
+
* followed; none for a missing directory or a file; other errors thrown.
|
|
4
|
+
*/
|
|
5
|
+
import { spawnSync } from "node:child_process";
|
|
6
|
+
import { mkdirSync, symlinkSync, writeFileSync } from "node:fs";
|
|
7
|
+
import { join } from "node:path";
|
|
8
|
+
import { describe, expect, it } from "vitest";
|
|
9
|
+
import { absolutePath } from "../../domain/paths.ts";
|
|
10
|
+
import { useTempDir } from "../../../test/support.ts";
|
|
11
|
+
import { nodeListEntries } from "./list-entries.io.ts";
|
|
12
|
+
|
|
13
|
+
const root = useTempDir("paperlint-list-entries-");
|
|
14
|
+
|
|
15
|
+
const sorted = (dir: string) =>
|
|
16
|
+
[...nodeListEntries(absolutePath(dir))].sort((a, b) =>
|
|
17
|
+
a.name.localeCompare(b.name),
|
|
18
|
+
);
|
|
19
|
+
|
|
20
|
+
describe("nodeListEntries", () => {
|
|
21
|
+
it("names each entry's kind, a link to a directory as a link", () => {
|
|
22
|
+
const d = join(root, "study");
|
|
23
|
+
mkdirSync(join(d, "out"), { recursive: true });
|
|
24
|
+
writeFileSync(join(d, "a.md"), "");
|
|
25
|
+
symlinkSync(".", join(d, "loop"));
|
|
26
|
+
expect(sorted(d)).toEqual([
|
|
27
|
+
{ name: "a.md", kind: "file" },
|
|
28
|
+
{ name: "loop", kind: "symlink" },
|
|
29
|
+
{ name: "out", kind: "dir" },
|
|
30
|
+
]);
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
// A FIFO needs `mkfifo`, which Windows does not have; the reason is said, not hidden.
|
|
34
|
+
it.skipIf(process.platform === "win32")(
|
|
35
|
+
"names a FIFO as neither a file nor a directory: other",
|
|
36
|
+
() => {
|
|
37
|
+
const d = join(root, "fifo");
|
|
38
|
+
mkdirSync(d, { recursive: true });
|
|
39
|
+
const made = spawnSync("mkfifo", [join(d, "pipe")]);
|
|
40
|
+
expect(made.status).toBe(0);
|
|
41
|
+
writeFileSync(join(d, "a.md"), "");
|
|
42
|
+
// Guards: it was reported as a file, so a reader would open it and block.
|
|
43
|
+
expect(sorted(d)).toEqual([
|
|
44
|
+
{ name: "a.md", kind: "file" },
|
|
45
|
+
{ name: "pipe", kind: "other" },
|
|
46
|
+
]);
|
|
47
|
+
},
|
|
48
|
+
);
|
|
49
|
+
|
|
50
|
+
it("is empty for a directory that does not exist, and for a file", () => {
|
|
51
|
+
writeFileSync(join(root, "f"), "");
|
|
52
|
+
expect([
|
|
53
|
+
nodeListEntries(absolutePath(join(root, "none"))),
|
|
54
|
+
nodeListEntries(absolutePath(join(root, "f"))),
|
|
55
|
+
]).toEqual([[], []]);
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
it("throws any other error", () => {
|
|
59
|
+
expect(() => nodeListEntries(absolutePath(`${root}/a\0b`))).toThrow();
|
|
60
|
+
});
|
|
61
|
+
});
|
|
@@ -240,7 +240,7 @@ const planWith = (over: Readonly<Record<string, unknown>>): string =>
|
|
|
240
240
|
|
|
241
241
|
describe("parsing a study's plan: ready or draft", () => {
|
|
242
242
|
it("a filled plan is ready, its `extra` kept as it is", () => {
|
|
243
|
-
expect(parsePlan(JSON.stringify(PLAN))).toEqual({
|
|
243
|
+
expect(parsePlan(JSON.stringify(PLAN), "spike")).toEqual({
|
|
244
244
|
kind: "ready",
|
|
245
245
|
plan: PLAN,
|
|
246
246
|
});
|
|
@@ -255,17 +255,20 @@ describe("parsing a study's plan: ready or draft", () => {
|
|
|
255
255
|
},
|
|
256
256
|
arms: [],
|
|
257
257
|
};
|
|
258
|
-
expect(parsePlan(JSON.stringify(census))).toEqual({
|
|
258
|
+
expect(parsePlan(JSON.stringify(census), "spike")).toEqual({
|
|
259
259
|
kind: "ready",
|
|
260
260
|
plan: census,
|
|
261
261
|
});
|
|
262
262
|
});
|
|
263
263
|
|
|
264
264
|
it("the template studies new writes is a draft of a census, naming every empty text", () => {
|
|
265
|
-
expect(planTemplate().population.draw).toEqual({
|
|
266
|
-
|
|
265
|
+
expect(planTemplate("spike", "x").population.draw).toEqual({
|
|
266
|
+
kind: "all",
|
|
267
|
+
method: "",
|
|
268
|
+
});
|
|
269
|
+
expect(parsePlan(planText(planTemplate("spike", "x")), "spike")).toEqual({
|
|
267
270
|
kind: "draft",
|
|
268
|
-
plan: planTemplate(),
|
|
271
|
+
plan: planTemplate("spike", "x"),
|
|
269
272
|
empty: [
|
|
270
273
|
"question",
|
|
271
274
|
"population.source",
|
|
@@ -277,7 +280,7 @@ describe("parsing a study's plan: ready or draft", () => {
|
|
|
277
280
|
|
|
278
281
|
it("a text of white space only is empty", () => {
|
|
279
282
|
const plan = { ...PLAN, primary_outcome: " ", author_check: null };
|
|
280
|
-
expect(parsePlan(JSON.stringify(plan))).toEqual({
|
|
283
|
+
expect(parsePlan(JSON.stringify(plan), "spike")).toEqual({
|
|
281
284
|
kind: "draft",
|
|
282
285
|
plan,
|
|
283
286
|
empty: ["primary_outcome"],
|
|
@@ -330,7 +333,7 @@ describe("parsing a study's plan: no contradictory state", () => {
|
|
|
330
333
|
"population.draw.kind: Invalid discriminator value. Expected 'seeded' | 'all'",
|
|
331
334
|
],
|
|
332
335
|
])("refuses %s", (_why, t, why) => {
|
|
333
|
-
expect(parsePlan(t)).toEqual({ kind: "unreadable", why });
|
|
336
|
+
expect(parsePlan(t, "spike")).toEqual({ kind: "unreadable", why });
|
|
334
337
|
});
|
|
335
338
|
});
|
|
336
339
|
|
|
@@ -352,7 +355,7 @@ describe("parsing a study's plan: no name or fact said twice", () => {
|
|
|
352
355
|
'population: Unrecognized key: "size"',
|
|
353
356
|
],
|
|
354
357
|
])("refuses %s", (_why, t, why) => {
|
|
355
|
-
expect(parsePlan(t)).toEqual({ kind: "unreadable", why });
|
|
358
|
+
expect(parsePlan(t, "spike")).toEqual({ kind: "unreadable", why });
|
|
356
359
|
});
|
|
357
360
|
});
|
|
358
361
|
|
|
@@ -395,7 +398,7 @@ describe("parsing a study's plan: strict at every level", () => {
|
|
|
395
398
|
'arms.0: Unrecognized key: "model"',
|
|
396
399
|
],
|
|
397
400
|
])("refuses %s", (_why, t, why) => {
|
|
398
|
-
expect(parsePlan(t)).toEqual({ kind: "unreadable", why });
|
|
401
|
+
expect(parsePlan(t, "spike")).toEqual({ kind: "unreadable", why });
|
|
399
402
|
});
|
|
400
403
|
});
|
|
401
404
|
|
|
@@ -12,8 +12,13 @@
|
|
|
12
12
|
* Its texts may be empty — the template's are — and the parser says which: a plan comes out ready or
|
|
13
13
|
* a draft naming its empty texts, so nothing downstream looks at a text again. A plan still named
|
|
14
14
|
* `prereg.json` has a loose schema of its own.
|
|
15
|
+
*
|
|
16
|
+
* A plan's `mode` (docs/design/study-modes.md) is parsed here too, strictly, a union on its `kind`:
|
|
17
|
+
* one mode at most, a glob that leaves the study's folder or a decision outside the paper refused,
|
|
18
|
+
* and a measurement's plan ready only with a mode.
|
|
15
19
|
*/
|
|
16
20
|
import { z } from "zod";
|
|
21
|
+
import { README } from "../../domain/repro-layout.ts";
|
|
17
22
|
import {
|
|
18
23
|
IN_PAPER,
|
|
19
24
|
MEASUREMENT_ROLES,
|
|
@@ -29,9 +34,16 @@ import {
|
|
|
29
34
|
type ParsedRegistry,
|
|
30
35
|
type Requirement,
|
|
31
36
|
type Study,
|
|
37
|
+
type PlanTextField,
|
|
38
|
+
type StudyKind,
|
|
32
39
|
type StudyPlan,
|
|
33
40
|
type StudyRegistry,
|
|
34
41
|
} from "../../domain/study-registry.ts";
|
|
42
|
+
import {
|
|
43
|
+
needsMode,
|
|
44
|
+
type Decision,
|
|
45
|
+
type StudyMode,
|
|
46
|
+
} from "../../domain/study-mode.ts";
|
|
35
47
|
import type { StudyRegistryCodec } from "../../ports/study-registry-codec.ts";
|
|
36
48
|
|
|
37
49
|
const study = z.looseObject({
|
|
@@ -128,6 +140,66 @@ const extra = z.record(z.string(), z.unknown()).superRefine((e, ctx) => {
|
|
|
128
140
|
});
|
|
129
141
|
});
|
|
130
142
|
|
|
143
|
+
/** Every value of `values` once: a repeat is an error naming it. */
|
|
144
|
+
const once =
|
|
145
|
+
(what: string) =>
|
|
146
|
+
(values: readonly string[], ctx: Readonly<z.core.$RefinementCtx>): void => {
|
|
147
|
+
repeated(values).forEach((v) => {
|
|
148
|
+
ctx.addIssue({
|
|
149
|
+
code: "custom",
|
|
150
|
+
message: `${what} «${v}» is listed more than once`,
|
|
151
|
+
});
|
|
152
|
+
});
|
|
153
|
+
};
|
|
154
|
+
|
|
155
|
+
/** Whether `p` is a relative path that stays where it starts: no backslash, not absolute, no `..`. */
|
|
156
|
+
const staysInside = (p: string): boolean =>
|
|
157
|
+
!p.includes("\\") && !p.startsWith("/") && !p.split("/").includes("..");
|
|
158
|
+
|
|
159
|
+
/** An output glob, relative to the study's folder: inside it, and never the study's README. */
|
|
160
|
+
const inFolder = z
|
|
161
|
+
.string()
|
|
162
|
+
.min(1)
|
|
163
|
+
.refine(
|
|
164
|
+
(p) => staysInside(p) && p !== README,
|
|
165
|
+
`is not a path inside the study's folder, or names its ${README}`,
|
|
166
|
+
);
|
|
167
|
+
|
|
168
|
+
/**
|
|
169
|
+
* Whether `p` names a file by one or more names joined by `/`, none empty, `.` or `..`: never the
|
|
170
|
+
* paper's folder itself (`.`) nor a path ending in `/`. Whether a name is a directory on disk is the
|
|
171
|
+
* gate's and the order rule's to see.
|
|
172
|
+
*/
|
|
173
|
+
const namesAFile = (p: string): boolean =>
|
|
174
|
+
p.split("/").every((n) => n !== "" && n !== "." && n !== "..");
|
|
175
|
+
|
|
176
|
+
/**
|
|
177
|
+
* A decision file, relative to the PAPER's folder: inside it, and a file's path. Empty in a draft (the
|
|
178
|
+
* parser names it).
|
|
179
|
+
*/
|
|
180
|
+
const inPaper = z
|
|
181
|
+
.string()
|
|
182
|
+
.refine(staysInside, "is not a path inside the paper's folder")
|
|
183
|
+
.refine(
|
|
184
|
+
(p) => p === "" || !staysInside(p) || namesAFile(p),
|
|
185
|
+
"is not a path to a file inside the paper's folder: one or more names joined by /, none empty, . or ..",
|
|
186
|
+
);
|
|
187
|
+
|
|
188
|
+
const decisionShape = z.strictObject({ file: inPaper, note: z.string() });
|
|
189
|
+
|
|
190
|
+
/** The output globs: each once; none yet in a draft. */
|
|
191
|
+
const outputs = z.array(inFolder).superRefine(once("the output"));
|
|
192
|
+
|
|
193
|
+
/** How the outcomes are kept from the decisions: exactly one mode, by its `kind`. */
|
|
194
|
+
const mode = z.discriminatedUnion("kind", [
|
|
195
|
+
z.strictObject({
|
|
196
|
+
kind: z.literal("decide_before_run"),
|
|
197
|
+
outputs,
|
|
198
|
+
decision: decisionShape,
|
|
199
|
+
}),
|
|
200
|
+
z.strictObject({ kind: z.literal("exploratory"), reason: z.string() }),
|
|
201
|
+
]);
|
|
202
|
+
|
|
131
203
|
/** A plan's shape: closed at every level; the paper's own keys go in `extra`. Null is the one "none". */
|
|
132
204
|
const planShape = z.strictObject({
|
|
133
205
|
question: z.string(),
|
|
@@ -136,6 +208,7 @@ const planShape = z.strictObject({
|
|
|
136
208
|
primary_outcome: z.string(),
|
|
137
209
|
author_check: authorCheck.nullable(),
|
|
138
210
|
extra: extra.optional(),
|
|
211
|
+
mode: mode.optional(),
|
|
139
212
|
});
|
|
140
213
|
|
|
141
214
|
/**
|
|
@@ -196,6 +269,28 @@ const readyTexts = z.object({
|
|
|
196
269
|
primary_outcome: filled,
|
|
197
270
|
});
|
|
198
271
|
|
|
272
|
+
/** Whether `t` says something. */
|
|
273
|
+
const says = (t: string): boolean => /\S/u.test(t);
|
|
274
|
+
|
|
275
|
+
/**
|
|
276
|
+
* The mode's texts that are still empty, by their field: the mode itself when a study of `kind` must
|
|
277
|
+
* declare one and the plan does not, a decide-first mode's outputs and decision, an exploratory reason.
|
|
278
|
+
*/
|
|
279
|
+
const modeEmpties = (
|
|
280
|
+
p: StudyPlan,
|
|
281
|
+
kind: StudyKind,
|
|
282
|
+
): readonly PlanTextField[] => {
|
|
283
|
+
const m = p.mode;
|
|
284
|
+
if (m === undefined) return needsMode(kind) ? ["mode"] : [];
|
|
285
|
+
if (m.kind === "exploratory") return says(m.reason) ? [] : ["mode.reason"];
|
|
286
|
+
return PLAN_TEXT_FIELDS.filter(
|
|
287
|
+
(f) =>
|
|
288
|
+
(f === "mode.outputs" && m.outputs.length === 0) ||
|
|
289
|
+
(f === "mode.decision.file" && !says(m.decision.file)) ||
|
|
290
|
+
(f === "mode.decision.note" && !says(m.decision.note)),
|
|
291
|
+
);
|
|
292
|
+
};
|
|
293
|
+
|
|
199
294
|
/** A plan that parsed: ready, or a draft. */
|
|
200
295
|
type ReadyOrDraft = Exclude<ParsedPlan, { readonly kind: "unreadable" }>;
|
|
201
296
|
|
|
@@ -203,29 +298,37 @@ type ReadyOrDraft = Exclude<ParsedPlan, { readonly kind: "unreadable" }>;
|
|
|
203
298
|
* A parsed plan, ready when every required text is filled, else a draft naming the empty ones. A
|
|
204
299
|
* draft that carries the author's check is no plan: the author approved one with empty texts.
|
|
205
300
|
*/
|
|
206
|
-
const readyOrDraft =
|
|
207
|
-
|
|
208
|
-
ctx: Readonly<z.core.$RefinementCtx
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
301
|
+
const readyOrDraft =
|
|
302
|
+
(kind: StudyKind) =>
|
|
303
|
+
(p: StudyPlan, ctx: Readonly<z.core.$RefinementCtx>): ReadyOrDraft => {
|
|
304
|
+
const checked = readyTexts.safeParse(p);
|
|
305
|
+
const paths = checked.success
|
|
306
|
+
? []
|
|
307
|
+
: checked.error.issues.map((i) => i.path.join("."));
|
|
308
|
+
const empty = [
|
|
309
|
+
...PLAN_TEXT_FIELDS.filter((f) => paths.includes(f)),
|
|
310
|
+
...modeEmpties(p, kind),
|
|
311
|
+
];
|
|
312
|
+
if (empty.length === 0) return { kind: "ready", plan: p };
|
|
313
|
+
if (p.author_check === null)
|
|
314
|
+
return { kind: "draft", plan: { ...p, author_check: null }, empty };
|
|
315
|
+
ctx.addIssue({
|
|
316
|
+
code: "custom",
|
|
317
|
+
path: ["author_check"],
|
|
318
|
+
message: `is set, and the plan leaves ${empty.map((f) => `\`${f}\``).join(", ")} empty — the author approves a complete plan: fill them, or set author_check to null`,
|
|
319
|
+
});
|
|
320
|
+
return z.NEVER;
|
|
321
|
+
};
|
|
223
322
|
|
|
224
|
-
/** A `plan.json`: strict, then ready or draft. */
|
|
225
|
-
const
|
|
323
|
+
/** A `plan.json` of a study of `kind`: strict, then ready or draft — a measurement's ready only with its mode. */
|
|
324
|
+
const planFor = (kind: StudyKind) => planShape.transform(readyOrDraft(kind));
|
|
226
325
|
|
|
227
|
-
/**
|
|
228
|
-
|
|
326
|
+
/**
|
|
327
|
+
* A `prereg.json`: loose, mapped onto the plan, then ready or draft. Written before modes existed, it
|
|
328
|
+
* is never asked for one; `repro/study-plan-name` already asks to move it to `plan.json`, where the
|
|
329
|
+
* strict parser does.
|
|
330
|
+
*/
|
|
331
|
+
const legacyPlan = legacyAsPlan.transform(readyOrDraft("spike"));
|
|
229
332
|
|
|
230
333
|
// ── the schemas ARE the domain's types ─────────────────────────────────────────
|
|
231
334
|
// Each schema's output and its hand-written domain type are the same type, both ways, once both are
|
|
@@ -261,8 +364,10 @@ export type SchemasAreTheDomain = [
|
|
|
261
364
|
Same<z.output<typeof authorCheck>, AuthorCheck>,
|
|
262
365
|
Same<z.output<typeof planShape>, StudyPlan>,
|
|
263
366
|
Same<z.output<typeof legacyAsPlan>, StudyPlan>,
|
|
264
|
-
Same<z.output<typeof
|
|
367
|
+
Same<z.output<ReturnType<typeof planFor>>, ReadyOrDraft>,
|
|
265
368
|
Same<z.output<typeof legacyPlan>, ReadyOrDraft>,
|
|
369
|
+
Same<z.output<typeof mode>, StudyMode>,
|
|
370
|
+
Same<z.output<typeof decisionShape>, Decision>,
|
|
266
371
|
] extends true[]
|
|
267
372
|
? true
|
|
268
373
|
: never;
|
|
@@ -315,8 +420,9 @@ const planParser =
|
|
|
315
420
|
: { kind: "unreadable", why: reasons(parsed.error.issues) };
|
|
316
421
|
};
|
|
317
422
|
|
|
318
|
-
/** A `plan.json`: strict. */
|
|
319
|
-
export const parseStudyPlan =
|
|
423
|
+
/** A `plan.json` of a study of `kind`: strict. */
|
|
424
|
+
export const parseStudyPlan = (text: string, kind: StudyKind): ParsedPlan =>
|
|
425
|
+
planParser(planFor(kind))(text);
|
|
320
426
|
|
|
321
427
|
/** A plan still named `prereg.json`: loose, read only when a row names it. */
|
|
322
428
|
export const parseLegacyPlan = planParser(legacyPlan);
|
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The schema of a study's mode (`mode` in its plan). Both halves: what `studies new` writes parses back
|
|
3
|
+
* to itself; a measurement's plan is ready only with exactly one mode; every contradiction is refused
|
|
4
|
+
* with its reason.
|
|
5
|
+
*/
|
|
6
|
+
import { expect, it } from "vitest";
|
|
7
|
+
import type { StudyMode } from "../../domain/study-mode.ts";
|
|
8
|
+
import {
|
|
9
|
+
planTemplate,
|
|
10
|
+
planText,
|
|
11
|
+
type StudyKind,
|
|
12
|
+
type StudyPlan,
|
|
13
|
+
} from "../../domain/study-registry.ts";
|
|
14
|
+
import { studyRegistryCodec } from "./index.ts";
|
|
15
|
+
|
|
16
|
+
const DECIDED: StudyMode = {
|
|
17
|
+
kind: "decide_before_run",
|
|
18
|
+
outputs: ["data/outcome/**", "RESULTS.md"],
|
|
19
|
+
decision: {
|
|
20
|
+
file: "repro/P1-S01-x/decision.md",
|
|
21
|
+
note: "exclusions and the sample approved",
|
|
22
|
+
},
|
|
23
|
+
};
|
|
24
|
+
const EXPLORATORY: StudyMode = {
|
|
25
|
+
kind: "exploratory",
|
|
26
|
+
reason: "a first look at the logs, to find the question",
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
const PLAN: StudyPlan = {
|
|
30
|
+
question: "Does the hint help?",
|
|
31
|
+
population: {
|
|
32
|
+
source: "tasks, 2026-10-01",
|
|
33
|
+
draw: { kind: "all", method: "census" },
|
|
34
|
+
},
|
|
35
|
+
arms: [{ name: "hint" }, { name: "none" }],
|
|
36
|
+
primary_outcome: "pass rate",
|
|
37
|
+
author_check: null,
|
|
38
|
+
mode: DECIDED,
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
const parse = (text: string, kind: StudyKind = "measurement") =>
|
|
42
|
+
studyRegistryCodec.parsePlan(text, kind);
|
|
43
|
+
const withMode = (mode: unknown): string => JSON.stringify({ ...PLAN, mode });
|
|
44
|
+
const why = (text: string): string => {
|
|
45
|
+
const r = parse(text);
|
|
46
|
+
return r.kind === "unreadable" ? r.why : r.kind;
|
|
47
|
+
};
|
|
48
|
+
const empty = (text: string, kind: StudyKind = "measurement") => {
|
|
49
|
+
const r = parse(text, kind);
|
|
50
|
+
return r.kind === "draft" ? r.empty : r.kind;
|
|
51
|
+
};
|
|
52
|
+
|
|
53
|
+
it("each mode parses to itself, ready", () => {
|
|
54
|
+
// Guards: the three modes round-trip through the text planText writes.
|
|
55
|
+
expect(
|
|
56
|
+
[DECIDED, EXPLORATORY].map((mode) => parse(planText({ ...PLAN, mode }))),
|
|
57
|
+
).toEqual(
|
|
58
|
+
[DECIDED, EXPLORATORY].map((mode) => ({
|
|
59
|
+
kind: "ready",
|
|
60
|
+
plan: { ...PLAN, mode },
|
|
61
|
+
})),
|
|
62
|
+
);
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
it("a measurement's plan without a mode is a draft naming `mode`; a spike's is ready", () => {
|
|
66
|
+
const none: StudyPlan = { ...PLAN, mode: undefined };
|
|
67
|
+
// Guards: "none" is not a ready state for a measurement — and a spike may omit it.
|
|
68
|
+
expect(empty(planText(none))).toEqual(["mode"]);
|
|
69
|
+
expect(empty(planText(none), "spike")).toBe("ready");
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
it("the template of a measurement is a draft decided before its run, its decision file named", () => {
|
|
73
|
+
const t = planTemplate("measurement", "P1-S02-x");
|
|
74
|
+
// Guards: `studies new` scaffolds the default mode; what is left to fill is named.
|
|
75
|
+
expect(t.mode).toEqual({
|
|
76
|
+
kind: "decide_before_run",
|
|
77
|
+
outputs: [],
|
|
78
|
+
decision: { file: "repro/P1-S02-x/decision.md", note: "" },
|
|
79
|
+
});
|
|
80
|
+
expect(empty(planText(t))).toEqual([
|
|
81
|
+
"question",
|
|
82
|
+
"population.source",
|
|
83
|
+
"population.draw.method",
|
|
84
|
+
"primary_outcome",
|
|
85
|
+
"mode.outputs",
|
|
86
|
+
"mode.decision.note",
|
|
87
|
+
]);
|
|
88
|
+
expect(planTemplate("spike", "P1-S03-y").mode).toBeUndefined();
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
it("each mode's empty texts are named: the decision's, an exploratory reason", () => {
|
|
92
|
+
// Guards: a mode left half-written is a draft, so a study that has run on it is reported.
|
|
93
|
+
expect(
|
|
94
|
+
empty(
|
|
95
|
+
withMode({ ...DECIDED, outputs: [], decision: { file: " ", note: "" } }),
|
|
96
|
+
),
|
|
97
|
+
).toEqual(["mode.outputs", "mode.decision.file", "mode.decision.note"]);
|
|
98
|
+
expect(empty(withMode({ ...EXPLORATORY, reason: "" }))).toEqual([
|
|
99
|
+
"mode.reason",
|
|
100
|
+
]);
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
it("two modes at once, or a mode that is neither, cannot be written", () => {
|
|
104
|
+
// Guards: exactly one — a second mode's key is an unknown key, an unknown kind is refused.
|
|
105
|
+
expect(why(withMode({ ...DECIDED, reason: "r" }))).toBe(
|
|
106
|
+
'mode: Unrecognized key: "reason"',
|
|
107
|
+
);
|
|
108
|
+
expect(why(withMode({ kind: "preregistered" }))).toBe(
|
|
109
|
+
"mode.kind: Invalid discriminator value. Expected 'decide_before_run' | 'exploratory'",
|
|
110
|
+
);
|
|
111
|
+
expect(why(withMode({ kind: "blind" }))).toBe(
|
|
112
|
+
"mode.kind: Invalid discriminator value. Expected 'decide_before_run' | 'exploratory'",
|
|
113
|
+
);
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
it("refuses an output outside the folder or naming the study's README, and a decision outside the paper", () => {
|
|
117
|
+
const outside =
|
|
118
|
+
"is not a path inside the study's folder, or names its README.md";
|
|
119
|
+
// Guards: a glob cannot reach past the study's folder; a decision cannot leave the paper.
|
|
120
|
+
expect(
|
|
121
|
+
["../x", "/abs", "a\\b", "README.md"].map((o) =>
|
|
122
|
+
why(withMode({ ...DECIDED, outputs: [o] })),
|
|
123
|
+
),
|
|
124
|
+
).toEqual(Array.from({ length: 4 }, () => `mode.outputs.0: ${outside}`));
|
|
125
|
+
expect(why(withMode({ ...DECIDED, outputs: ["a", "a"] }))).toBe(
|
|
126
|
+
"mode.outputs: the output «a» is listed more than once",
|
|
127
|
+
);
|
|
128
|
+
expect(
|
|
129
|
+
["../d.md", "/d.md", "a\\d.md"].map((file) =>
|
|
130
|
+
why(withMode({ ...DECIDED, decision: { file, note: "n" } })),
|
|
131
|
+
),
|
|
132
|
+
).toEqual(
|
|
133
|
+
Array.from(
|
|
134
|
+
{ length: 3 },
|
|
135
|
+
() => "mode.decision.file: is not a path inside the paper's folder",
|
|
136
|
+
),
|
|
137
|
+
);
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
it("refuses a decision path that names the paper's folder or a directory, and keeps an empty one a draft", () => {
|
|
141
|
+
const notFile =
|
|
142
|
+
"mode.decision.file: is not a path to a file inside the paper's folder: one or more names joined by /, none empty, . or ..";
|
|
143
|
+
// Guards: a decision of `.` would make the gate ask git about the whole paper, and the order rule
|
|
144
|
+
// read the paper's first commit as the decision; such a path cannot be written down.
|
|
145
|
+
expect(
|
|
146
|
+
[".", "./", "repro/", "a//d.md", "a/./d.md", "./d.md"].map((file) =>
|
|
147
|
+
why(withMode({ ...DECIDED, decision: { file, note: "n" } })),
|
|
148
|
+
),
|
|
149
|
+
).toEqual(Array(6).fill(notFile));
|
|
150
|
+
expect(
|
|
151
|
+
empty(withMode({ ...DECIDED, decision: { file: "", note: "n" } })),
|
|
152
|
+
).toEqual(["mode.decision.file"]);
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
it("mode is a field of the plan, so extra cannot carry it", () => {
|
|
156
|
+
// Guards: one place says how a study is gated.
|
|
157
|
+
expect(
|
|
158
|
+
why(JSON.stringify({ ...PLAN, mode: undefined, extra: { mode: {} } })),
|
|
159
|
+
).toBe(
|
|
160
|
+
"extra.mode: is a field of the plan itself — set it there, not in extra",
|
|
161
|
+
);
|
|
162
|
+
});
|
package/src/cli.ts
CHANGED
|
@@ -190,6 +190,12 @@ import {
|
|
|
190
190
|
studyRegistryRules,
|
|
191
191
|
} from "./study-registry-rule.ts";
|
|
192
192
|
import { studyRegistryCodec } from "./adapters/study-registry/index.ts";
|
|
193
|
+
import { nodeListEntries, spawnProcess } from "./adapters/node/index.ts";
|
|
194
|
+
import { gitHistory } from "./adapters/git/index.ts";
|
|
195
|
+
import {
|
|
196
|
+
ORDER_RULE_LEVELS,
|
|
197
|
+
outputsBeforeDecisionRules,
|
|
198
|
+
} from "./outputs-before-decision-rule.ts";
|
|
193
199
|
import { runStudies } from "./studies.ts";
|
|
194
200
|
import typography from "#eslint-rules/paper-typography";
|
|
195
201
|
import texBuild from "#eslint-rules/tex-build";
|
|
@@ -243,6 +249,11 @@ const USAGE = `paperlint — machine-checkable gates for a paper kept in git
|
|
|
243
249
|
index in repro/README.md regenerated
|
|
244
250
|
npx paperlint studies index <paper-dir>
|
|
245
251
|
regenerate the index in repro/README.md from repro/studies.json
|
|
252
|
+
npx paperlint studies gate <paper-dir> <id>
|
|
253
|
+
may the study's run start? For a study decided before its run
|
|
254
|
+
(the default mode), exit 0 only once its decision file is
|
|
255
|
+
committed as it stands, printing the commit and date; else exit
|
|
256
|
+
1 saying why. A runner calls it first: gate … && ./run.sh
|
|
246
257
|
npx paperlint hook <name> run an editor hook (.claude/settings.json calls this)
|
|
247
258
|
npx paperlint --help
|
|
248
259
|
|
|
@@ -267,7 +278,10 @@ studies:
|
|
|
267
278
|
--kind <kind> new: measurement (the default), spike, tooling, data, artifact or salvage.
|
|
268
279
|
A study's status, result and in_paper are edited in repro/studies.json, its
|
|
269
280
|
plan in its plan.json; the rule repro/study-registered checks them
|
|
270
|
-
(docs/rules/repro/study-registered.md)
|
|
281
|
+
(docs/rules/repro/study-registered.md). A measurement's plan declares its
|
|
282
|
+
mode: decide_before_run (written by new) or exploratory;
|
|
283
|
+
repro/outputs-before-decision checks the order in git
|
|
284
|
+
(docs/design/study-modes.md)
|
|
271
285
|
|
|
272
286
|
submission:
|
|
273
287
|
The venue preset declares the portal — "portal": { "kind": "hotcrp", "url": "<site>" } — and the
|
|
@@ -383,20 +397,43 @@ const FRAGMENT_RULES = {
|
|
|
383
397
|
"heading-case": REGISTER_AND_CASE_RULES["heading-case"],
|
|
384
398
|
};
|
|
385
399
|
|
|
400
|
+
/** `names` of this process's environment, and nothing else of it. */
|
|
401
|
+
const envOf =
|
|
402
|
+
(names: readonly string[]) =>
|
|
403
|
+
(env: Readonly<NodeJS.ProcessEnv>): Readonly<Record<string, string>> =>
|
|
404
|
+
Object.fromEntries(
|
|
405
|
+
Object.entries(env).filter(
|
|
406
|
+
(e): e is [string, string] =>
|
|
407
|
+
names.includes(e[0]) && e[1] !== undefined,
|
|
408
|
+
),
|
|
409
|
+
);
|
|
410
|
+
|
|
411
|
+
/** The environment git runs in: PATH, and HOME for its configuration. */
|
|
412
|
+
const gitEnv = envOf(["PATH", "HOME"]);
|
|
413
|
+
|
|
414
|
+
/** git's history, as the decide-first rule and the gate read it. */
|
|
415
|
+
const GIT = gitHistory(spawnProcess(), gitEnv(process.env));
|
|
416
|
+
|
|
386
417
|
/** What the two layout rules read with: the disk and its directory listings. */
|
|
387
418
|
const LAYOUT_RULE_DEPS = { files: nodeFiles, listDir: nodeListDir };
|
|
388
419
|
|
|
389
420
|
/** What `repro/study-registered` and `paperlint studies` read with: the layout's, and the registry's parser. */
|
|
390
|
-
const STUDY_RULE_DEPS = {
|
|
421
|
+
const STUDY_RULE_DEPS = {
|
|
422
|
+
...LAYOUT_RULE_DEPS,
|
|
423
|
+
entries: nodeListEntries,
|
|
424
|
+
codec: studyRegistryCodec,
|
|
425
|
+
};
|
|
391
426
|
|
|
392
427
|
/** The `repro` plugin's rules, on every `PIPELINE-STATUS.md`: the layout of `repro/`, and its study registry. */
|
|
393
428
|
const REPRO_RULES = {
|
|
394
429
|
...reproLayoutRules(LAYOUT_RULE_DEPS),
|
|
395
430
|
...studyRegistryRules(STUDY_RULE_DEPS),
|
|
431
|
+
...outputsBeforeDecisionRules({ ...STUDY_RULE_DEPS, git: GIT }),
|
|
396
432
|
};
|
|
397
433
|
const REPRO_RULE_LEVELS = {
|
|
398
434
|
...REPRO_LAYOUT_RULE_LEVELS,
|
|
399
435
|
...STUDY_REGISTRY_RULE_LEVELS,
|
|
436
|
+
...ORDER_RULE_LEVELS,
|
|
400
437
|
};
|
|
401
438
|
|
|
402
439
|
/** Today in this machine's time zone, `YYYY-MM-DD`: the date `paperlint studies new` registers a study on. */
|
|
@@ -1675,7 +1712,14 @@ const SIMPLE: Readonly<
|
|
|
1675
1712
|
extra: a.paths.slice(3),
|
|
1676
1713
|
kind: a.kind,
|
|
1677
1714
|
},
|
|
1678
|
-
{
|
|
1715
|
+
{
|
|
1716
|
+
...STUDY_RULE_DEPS,
|
|
1717
|
+
git: GIT,
|
|
1718
|
+
cwd,
|
|
1719
|
+
today: today(),
|
|
1720
|
+
log,
|
|
1721
|
+
err,
|
|
1722
|
+
},
|
|
1679
1723
|
),
|
|
1680
1724
|
submission: (a, { log, err, cwd }) =>
|
|
1681
1725
|
runSubmission(
|