paperlint 10.3.0 → 10.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.github/workflows/ci.yml +15 -4
- package/README.md +1 -0
- package/dist/adapters/git/index.d.ts +10 -0
- package/dist/adapters/git/index.d.ts.map +1 -0
- package/dist/adapters/git/index.js +151 -0
- package/dist/adapters/git/index.js.map +1 -0
- package/dist/adapters/memory/index.d.ts +6 -0
- package/dist/adapters/memory/index.d.ts.map +1 -1
- package/dist/adapters/memory/index.js +8 -0
- package/dist/adapters/memory/index.js.map +1 -1
- package/dist/adapters/node/index.d.ts +1 -0
- package/dist/adapters/node/index.d.ts.map +1 -1
- package/dist/adapters/node/index.js +1 -0
- package/dist/adapters/node/index.js.map +1 -1
- package/dist/adapters/node/list-entries.io.d.ts +3 -0
- package/dist/adapters/node/list-entries.io.d.ts.map +1 -0
- package/dist/adapters/node/list-entries.io.js +24 -0
- package/dist/adapters/node/list-entries.io.js.map +1 -0
- package/dist/adapters/study-registry/index.d.ts +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 +100 -0
- package/dist/domain/decide-first.d.ts.map +1 -0
- package/dist/domain/decide-first.js +102 -0
- package/dist/domain/decide-first.js.map +1 -0
- package/dist/domain/index-columns.d.ts +45 -0
- package/dist/domain/index-columns.d.ts.map +1 -0
- package/dist/domain/index-columns.js +62 -0
- package/dist/domain/index-columns.js.map +1 -0
- package/dist/domain/study-mode.d.ts +37 -0
- package/dist/domain/study-mode.d.ts.map +1 -0
- package/dist/domain/study-mode.js +45 -0
- package/dist/domain/study-mode.js.map +1 -0
- package/dist/domain/study-registry.d.ts +57 -10
- package/dist/domain/study-registry.d.ts.map +1 -1
- package/dist/domain/study-registry.js +106 -30
- package/dist/domain/study-registry.js.map +1 -1
- package/dist/outputs-before-decision-rule.d.ts +17 -0
- package/dist/outputs-before-decision-rule.d.ts.map +1 -0
- package/dist/outputs-before-decision-rule.js +90 -0
- package/dist/outputs-before-decision-rule.js.map +1 -0
- package/dist/ports/git-history.d.ts +44 -0
- package/dist/ports/git-history.d.ts.map +1 -0
- package/dist/ports/git-history.js +2 -0
- package/dist/ports/git-history.js.map +1 -0
- package/dist/ports/list-entries.d.ts +17 -0
- package/dist/ports/list-entries.d.ts.map +1 -0
- package/dist/ports/list-entries.js +2 -0
- package/dist/ports/list-entries.js.map +1 -0
- package/dist/ports/study-registry-codec.d.ts +6 -3
- package/dist/ports/study-registry-codec.d.ts.map +1 -1
- package/dist/studies.d.ts +4 -0
- package/dist/studies.d.ts.map +1 -1
- package/dist/studies.js +50 -22
- package/dist/studies.js.map +1 -1
- package/dist/study-gate.d.ts +14 -0
- package/dist/study-gate.d.ts.map +1 -0
- package/dist/study-gate.js +95 -0
- package/dist/study-gate.js.map +1 -0
- package/dist/study-index-input.d.ts +5 -0
- package/dist/study-index-input.d.ts.map +1 -0
- package/dist/study-index-input.js +13 -0
- package/dist/study-index-input.js.map +1 -0
- package/dist/study-modes.d.ts +45 -0
- package/dist/study-modes.d.ts.map +1 -0
- package/dist/study-modes.js +70 -0
- package/dist/study-modes.js.map +1 -0
- package/dist/study-registry-rule.d.ts +17 -1
- package/dist/study-registry-rule.d.ts.map +1 -1
- package/dist/study-registry-rule.js +34 -13
- package/dist/study-registry-rule.js.map +1 -1
- package/dist/study-run.d.ts +19 -0
- package/dist/study-run.d.ts.map +1 -0
- package/dist/study-run.js +9 -0
- package/dist/study-run.js.map +1 -0
- package/docs/design/study-modes.md +247 -0
- package/docs/design/study-registry.md +6 -0
- package/docs/e2e.md +8 -4
- package/docs/layout.md +22 -19
- package/docs/rules/repro/outputs-before-decision.md +119 -0
- package/docs/rules/repro/study-registered.md +47 -14
- package/docs/rules.md +54 -53
- package/docs/testing.md +1 -0
- 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/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/adapters/git/history.test.ts +121 -0
- package/src/adapters/git/index.test.ts +268 -0
- package/src/adapters/git/index.ts +227 -0
- package/src/adapters/memory/index.test.ts +15 -1
- package/src/adapters/memory/index.ts +13 -0
- package/src/adapters/node/index.ts +1 -0
- package/src/adapters/node/list-entries.io.ts +26 -0
- package/src/adapters/node/list-entries.test.ts +43 -0
- package/src/adapters/study-registry/index.test.ts +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 +258 -0
- package/src/domain/decide-first.ts +243 -0
- package/src/domain/index-columns.test.ts +216 -0
- package/src/domain/index-columns.ts +105 -0
- package/src/domain/study-measurements.test.ts +9 -5
- package/src/domain/study-mode.test.ts +55 -0
- package/src/domain/study-mode.ts +78 -0
- package/src/domain/study-registry.test.ts +38 -31
- package/src/domain/study-registry.ts +170 -31
- package/src/new-paper.test.ts +5 -1
- package/src/outputs-before-decision-rule.test.ts +206 -0
- package/src/outputs-before-decision-rule.ts +124 -0
- package/src/ports/git-history.ts +44 -0
- package/src/ports/list-entries.ts +18 -0
- package/src/ports/study-registry-codec.ts +10 -3
- 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 +88 -27
- package/src/study-gate.test.ts +316 -0
- package/src/study-gate.ts +161 -0
- package/src/study-index-input.ts +23 -0
- package/src/study-modes.ts +120 -0
- package/src/study-registry-rule.test.ts +198 -11
- package/src/study-registry-rule.ts +51 -14
- package/src/study-run.ts +21 -0
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A STUDY'S MODE — how its outcomes are kept from the decisions about its inputs. A measurement study
|
|
3
|
+
* declares exactly one (docs/design/study-modes.md):
|
|
4
|
+
*
|
|
5
|
+
* decide_before_run the default: the author's decisions on the inputs (exclusions, veto, the
|
|
6
|
+
* sample's approval) are committed BEFORE any output exists. No key, no extra
|
|
7
|
+
* tool: git's history is the proof of order, and
|
|
8
|
+
* `repro/outputs-before-decision` re-checks it on every lint.
|
|
9
|
+
* exploratory no gate: the study is exploratory and is reported as such, with its reason.
|
|
10
|
+
*
|
|
11
|
+
* "None" and "two at once" cannot be written: the plan holds one `mode`, a union on its `kind`, and a
|
|
12
|
+
* measurement study's plan is not ready without it (the parser, `src/adapters/study-registry/`).
|
|
13
|
+
*
|
|
14
|
+
* Paths: `outputs` are globs relative to the study's folder (the study's results live there); the
|
|
15
|
+
* decision's `file` is relative to the PAPER's folder, so a decision may live beside the study or
|
|
16
|
+
* anywhere in the paper (`research/decisions.md`). Pure.
|
|
17
|
+
*/
|
|
18
|
+
import { posix } from "node:path";
|
|
19
|
+
import { REPRO_DIR } from "./repro-layout.ts";
|
|
20
|
+
import type { StudyKind } from "./study-registry.ts";
|
|
21
|
+
|
|
22
|
+
/** A decision recorded in a file: where, and what it decides. */
|
|
23
|
+
export interface Decision {
|
|
24
|
+
/** Relative to the paper's folder. */
|
|
25
|
+
readonly file: string;
|
|
26
|
+
readonly note: string;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** Decide first: the decision on the inputs is committed before any output exists. */
|
|
30
|
+
export interface DecideFirst {
|
|
31
|
+
readonly kind: "decide_before_run";
|
|
32
|
+
/** Globs relative to the study's folder: the files the run produces. */
|
|
33
|
+
readonly outputs: readonly string[];
|
|
34
|
+
readonly decision: Decision;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** Exploratory: no gate, and reported as exploratory. */
|
|
38
|
+
export interface Exploratory {
|
|
39
|
+
readonly kind: "exploratory";
|
|
40
|
+
readonly reason: string;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export type StudyMode = DecideFirst | Exploratory;
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* The kinds of study whose plan must declare a mode: the ones that measure. A spike, a tool, a data
|
|
47
|
+
* set, an artifact or a salvage may omit it.
|
|
48
|
+
*/
|
|
49
|
+
export const MODE_REQUIRED: readonly StudyKind[] = ["measurement"];
|
|
50
|
+
|
|
51
|
+
/** Whether a study of `kind` must declare its mode. */
|
|
52
|
+
export const needsMode = (kind: StudyKind): boolean =>
|
|
53
|
+
MODE_REQUIRED.includes(kind);
|
|
54
|
+
|
|
55
|
+
/** `paperPath` relative to the study folder `folder`, when it lies inside it; else null. */
|
|
56
|
+
export const inStudy = (folder: string, paperPath: string): string | null => {
|
|
57
|
+
const prefix = `${REPRO_DIR}/${folder}/`;
|
|
58
|
+
const n = posix.normalize(paperPath);
|
|
59
|
+
return n.startsWith(prefix) ? n.slice(prefix.length) : null;
|
|
60
|
+
};
|
|
61
|
+
|
|
62
|
+
/** Whether the file at `path` (relative to the study's folder) is one of `outputs`. */
|
|
63
|
+
export const isOutput = (outputs: readonly string[], path: string): boolean =>
|
|
64
|
+
outputs.some((g) => posix.matchesGlob(path, g));
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* The output glob that matches the study's own decision file, when the file lies in the study's
|
|
68
|
+
* folder: the decision would be "produced" by the run it gates.
|
|
69
|
+
*/
|
|
70
|
+
export const decisionAsOutput = (
|
|
71
|
+
folder: string,
|
|
72
|
+
m: DecideFirst,
|
|
73
|
+
): string | null => {
|
|
74
|
+
const rel = inStudy(folder, m.decision.file);
|
|
75
|
+
return rel === null
|
|
76
|
+
? null
|
|
77
|
+
: (m.outputs.find((g) => posix.matchesGlob(rel, g)) ?? null);
|
|
78
|
+
};
|
|
@@ -25,8 +25,12 @@ import {
|
|
|
25
25
|
type Study,
|
|
26
26
|
type StudyInput,
|
|
27
27
|
type StudyRegistry,
|
|
28
|
+
type IndexInput,
|
|
28
29
|
} from "./study-registry.ts";
|
|
29
30
|
|
|
31
|
+
/** No plan and no history: the index with every mode column `—`. */
|
|
32
|
+
const NO_FACTS: IndexInput = { plans: new Map(), history: new Map() };
|
|
33
|
+
|
|
30
34
|
const study = (over: Partial<Study> = {}): Study => ({
|
|
31
35
|
id: "P1-S01",
|
|
32
36
|
folder: "P1-S01-baseline",
|
|
@@ -56,7 +60,7 @@ const input = (
|
|
|
56
60
|
): StudyInput => ({
|
|
57
61
|
folders: r.studies.map((s) => s.folder),
|
|
58
62
|
registry: read(r),
|
|
59
|
-
readme: withIndex("# Reproduction index\n", r),
|
|
63
|
+
readme: withIndex("# Reproduction index\n", r, NO_FACTS),
|
|
60
64
|
existingResults: new Set(),
|
|
61
65
|
plans: new Map(),
|
|
62
66
|
...over,
|
|
@@ -310,16 +314,16 @@ describe("judgeStudies — the index", () => {
|
|
|
310
314
|
|
|
311
315
|
it("a table prettier padded by display width, with wide characters, is in sync", async () => {
|
|
312
316
|
const wide = reg(study({ title: "測定 A" }));
|
|
313
|
-
const readme = await format(withIndex("# index\n", wide), {
|
|
317
|
+
const readme = await format(withIndex("# index\n", wide, NO_FACTS), {
|
|
314
318
|
parser: "markdown",
|
|
315
319
|
});
|
|
316
|
-
expect(readme).not.toBe(withIndex("# index\n", wide));
|
|
320
|
+
expect(readme).not.toBe(withIndex("# index\n", wide, NO_FACTS));
|
|
317
321
|
expect(judgeStudies(input(wide, { readme }))).toEqual([]);
|
|
318
322
|
});
|
|
319
323
|
|
|
320
324
|
it("a title with a backslash before a pipe stays one cell through prettier", async () => {
|
|
321
325
|
const bs = reg(study({ title: "foo\\|bar" }));
|
|
322
|
-
const readme = await format(withIndex("# index\n", bs), {
|
|
326
|
+
const readme = await format(withIndex("# index\n", bs, NO_FACTS), {
|
|
323
327
|
parser: "markdown",
|
|
324
328
|
});
|
|
325
329
|
expect(judgeStudies(input(bs, { readme }))).toEqual([]);
|
|
@@ -328,22 +332,25 @@ describe("judgeStudies — the index", () => {
|
|
|
328
332
|
|
|
329
333
|
it("a title carrying the end marker's text: in sync, and regenerating changes nothing", () => {
|
|
330
334
|
const m = reg(study({ title: `x ${INDEX_END} y` }));
|
|
331
|
-
const readme = withIndex("# index\n", m);
|
|
335
|
+
const readme = withIndex("# index\n", m, NO_FACTS);
|
|
332
336
|
expect(judgeStudies(input(m, { readme }))).toEqual([]);
|
|
333
|
-
expect(withIndex(readme, m)).toBe(readme);
|
|
337
|
+
expect(withIndex(readme, m, NO_FACTS)).toBe(readme);
|
|
334
338
|
});
|
|
335
339
|
|
|
336
340
|
it("prose that mentions the begin marker is kept, and the real block is the one judged", () => {
|
|
337
341
|
const r1 = reg(study());
|
|
338
342
|
const prose = `# index\n\nThe table between \`${INDEX_BEGIN}\` and its end marker is generated.\n`;
|
|
339
|
-
const readme = withIndex(prose, r1);
|
|
343
|
+
const readme = withIndex(prose, r1, NO_FACTS);
|
|
340
344
|
expect(judgeStudies(input(r1, { readme }))).toEqual([]);
|
|
341
|
-
expect(withIndex(readme, r1)).toBe(readme);
|
|
345
|
+
expect(withIndex(readme, r1, NO_FACTS)).toBe(readme);
|
|
342
346
|
expect(readme.startsWith(prose.trimEnd())).toBe(true);
|
|
343
347
|
});
|
|
344
348
|
|
|
345
349
|
it("a block that differs from the registry by one character", () => {
|
|
346
|
-
const readme = withIndex("# index\n", r).replace(
|
|
350
|
+
const readme = withIndex("# index\n", r, NO_FACTS).replace(
|
|
351
|
+
"designing",
|
|
352
|
+
"running",
|
|
353
|
+
);
|
|
347
354
|
expect(judgeStudies(input(r, { readme }))).toEqual([
|
|
348
355
|
{ problem: "stale-index", why: "differs" },
|
|
349
356
|
]);
|
|
@@ -363,12 +370,12 @@ describe("the index", () => {
|
|
|
363
370
|
in_paper: "yes",
|
|
364
371
|
}),
|
|
365
372
|
);
|
|
366
|
-
expect(studyIndex(r)).toBe(
|
|
373
|
+
expect(studyIndex(r, NO_FACTS)).toBe(
|
|
367
374
|
[
|
|
368
|
-
"| id | title | status | result | in paper | folder |",
|
|
369
|
-
"| ------ | ------------------- | --------- | -------- | -------- | ------------------------------------ |",
|
|
370
|
-
"| P1-S01 | Baseline pass rate | designing | — | no | [P1-S01-baseline/](P1-S01-baseline/) |",
|
|
371
|
-
"| P1-S02 | Pipes \\| and breaks | done | 41 of 60 | yes | [P1-S02-a-b/](P1-S02-a-b/) |",
|
|
375
|
+
"| id | title | status | result | in paper | mode | decision | first output | folder |",
|
|
376
|
+
"| ------ | ------------------- | --------- | -------- | -------- | ---- | -------- | ------------ | ------------------------------------ |",
|
|
377
|
+
"| P1-S01 | Baseline pass rate | designing | — | no | — | — | — | [P1-S01-baseline/](P1-S01-baseline/) |",
|
|
378
|
+
"| P1-S02 | Pipes \\| and breaks | done | 41 of 60 | yes | — | — | — | [P1-S02-a-b/](P1-S02-a-b/) |",
|
|
372
379
|
].join("\n"),
|
|
373
380
|
);
|
|
374
381
|
});
|
|
@@ -390,30 +397,30 @@ describe("the index", () => {
|
|
|
390
397
|
}),
|
|
391
398
|
].slice(0, n),
|
|
392
399
|
);
|
|
393
|
-
const md = withIndex("# Reproduction index\n\nSome text.\n", r);
|
|
400
|
+
const md = withIndex("# Reproduction index\n\nSome text.\n", r, NO_FACTS);
|
|
394
401
|
expect(await format(md, { parser: "markdown" })).toBe(md);
|
|
395
402
|
});
|
|
396
403
|
});
|
|
397
404
|
|
|
398
405
|
describe("the index of an empty registry, and its markers", () => {
|
|
399
406
|
it("of an empty registry is the header alone", () => {
|
|
400
|
-
expect(studyIndex(reg())).toBe(
|
|
407
|
+
expect(studyIndex(reg(), NO_FACTS)).toBe(
|
|
401
408
|
[
|
|
402
|
-
"| id | title | status | result | in paper | folder |",
|
|
403
|
-
"| --- | ----- | ------ | ------ | -------- | ------ |",
|
|
409
|
+
"| id | title | status | result | in paper | mode | decision | first output | folder |",
|
|
410
|
+
"| --- | ----- | ------ | ------ | -------- | ---- | -------- | ------------ | ------ |",
|
|
404
411
|
].join("\n"),
|
|
405
412
|
);
|
|
406
413
|
});
|
|
407
414
|
|
|
408
415
|
it("sits between the two markers, a blank line on each side", () => {
|
|
409
|
-
expect(indexBlock(reg())).toBe(
|
|
410
|
-
`${INDEX_BEGIN}\n\n${studyIndex(reg())}\n\n${INDEX_END}`,
|
|
416
|
+
expect(indexBlock(reg(), NO_FACTS)).toBe(
|
|
417
|
+
`${INDEX_BEGIN}\n\n${studyIndex(reg(), NO_FACTS)}\n\n${INDEX_END}`,
|
|
411
418
|
);
|
|
412
419
|
});
|
|
413
420
|
|
|
414
421
|
it("is read back from a README as the text between the markers, or null", () => {
|
|
415
|
-
expect(embeddedIndex(`# x\n\n${indexBlock(reg())}\n`)).toBe(
|
|
416
|
-
`\n\n${studyIndex(reg())}\n\n`,
|
|
422
|
+
expect(embeddedIndex(`# x\n\n${indexBlock(reg(), NO_FACTS)}\n`)).toBe(
|
|
423
|
+
`\n\n${studyIndex(reg(), NO_FACTS)}\n\n`,
|
|
417
424
|
);
|
|
418
425
|
expect(embeddedIndex("# x\n")).toBeNull();
|
|
419
426
|
expect(embeddedIndex(`${INDEX_END}\n${INDEX_BEGIN}\n`)).toBeNull();
|
|
@@ -424,27 +431,27 @@ describe("withIndex", () => {
|
|
|
424
431
|
const r = reg(study());
|
|
425
432
|
|
|
426
433
|
it("replaces the block in place and keeps the rest", () => {
|
|
427
|
-
const before = `# Index\n\nintro\n\n${indexBlock(reg())}\n\ntail\n`;
|
|
428
|
-
expect(withIndex(before, r)).toBe(
|
|
429
|
-
`# Index\n\nintro\n\n${indexBlock(r)}\n\ntail\n`,
|
|
434
|
+
const before = `# Index\n\nintro\n\n${indexBlock(reg(), NO_FACTS)}\n\ntail\n`;
|
|
435
|
+
expect(withIndex(before, r, NO_FACTS)).toBe(
|
|
436
|
+
`# Index\n\nintro\n\n${indexBlock(r, NO_FACTS)}\n\ntail\n`,
|
|
430
437
|
);
|
|
431
438
|
});
|
|
432
439
|
|
|
433
440
|
it("appends the block to a README without markers", () => {
|
|
434
|
-
expect(withIndex("# Index\n\nintro", r)).toBe(
|
|
435
|
-
`# Index\n\nintro\n\n${indexBlock(r)}\n`,
|
|
441
|
+
expect(withIndex("# Index\n\nintro", r, NO_FACTS)).toBe(
|
|
442
|
+
`# Index\n\nintro\n\n${indexBlock(r, NO_FACTS)}\n`,
|
|
436
443
|
);
|
|
437
444
|
});
|
|
438
445
|
|
|
439
446
|
it("writes a README when there is none", () => {
|
|
440
|
-
expect(withIndex(null, r)).toBe(
|
|
441
|
-
`# Reproduction index\n\n${indexBlock(r)}\n`,
|
|
447
|
+
expect(withIndex(null, r, NO_FACTS)).toBe(
|
|
448
|
+
`# Reproduction index\n\n${indexBlock(r, NO_FACTS)}\n`,
|
|
442
449
|
);
|
|
443
450
|
});
|
|
444
451
|
|
|
445
452
|
it("is a fixed point: a second run changes nothing", () => {
|
|
446
|
-
const once = withIndex("# Index\n", r);
|
|
447
|
-
expect(withIndex(once, r)).toBe(once);
|
|
453
|
+
const once = withIndex("# Index\n", r, NO_FACTS);
|
|
454
|
+
expect(withIndex(once, r, NO_FACTS)).toBe(once);
|
|
448
455
|
});
|
|
449
456
|
});
|
|
450
457
|
|
|
@@ -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
|
|
|
@@ -230,16 +251,34 @@ export type ParsedPlan =
|
|
|
230
251
|
/** What a study's folder holds of the plan its row names: absent, or what its text parsed to. */
|
|
231
252
|
export type PlanState = { readonly kind: "absent" } | ParsedPlan;
|
|
232
253
|
|
|
254
|
+
/** The decision file `paperlint studies new` names for a study in `folder`, relative to the paper. */
|
|
255
|
+
export const decisionPath = (folder: string): string =>
|
|
256
|
+
`repro/${folder}/${DECISION_FILE}`;
|
|
257
|
+
|
|
258
|
+
/** The decision file's name, beside the plan. */
|
|
259
|
+
export const DECISION_FILE = "decision.md";
|
|
260
|
+
|
|
233
261
|
/**
|
|
234
|
-
* The plan `paperlint studies new` writes
|
|
235
|
-
* all the population (a census); a sampled study changes it to
|
|
262
|
+
* The plan `paperlint studies new` writes for a study of `kind` in `folder`: every field present, its
|
|
263
|
+
* value to fill. Its draw is of all the population (a census); a sampled study changes it to
|
|
264
|
+
* `{ "kind": "seeded", "method", "seed" }`. A study that must declare its mode gets the default one,
|
|
265
|
+
* decided before the run, its decision file named and its outputs and note to fill.
|
|
236
266
|
*/
|
|
237
|
-
export const planTemplate = (): DraftPlan => ({
|
|
267
|
+
export const planTemplate = (kind: StudyKind, folder: string): DraftPlan => ({
|
|
238
268
|
question: "",
|
|
239
269
|
population: { source: "", draw: { kind: "all", method: "" } },
|
|
240
270
|
arms: [],
|
|
241
271
|
primary_outcome: "",
|
|
242
272
|
author_check: null,
|
|
273
|
+
...(needsMode(kind)
|
|
274
|
+
? {
|
|
275
|
+
mode: {
|
|
276
|
+
kind: "decide_before_run",
|
|
277
|
+
outputs: [],
|
|
278
|
+
decision: { file: decisionPath(folder), note: "" },
|
|
279
|
+
},
|
|
280
|
+
}
|
|
281
|
+
: {}),
|
|
243
282
|
});
|
|
244
283
|
|
|
245
284
|
/** A plan's text as `paperlint studies new` writes it: JSON, two-space indented, a final newline. */
|
|
@@ -425,20 +464,40 @@ export const titleWords = (title: string): number =>
|
|
|
425
464
|
* Whether `path` stays inside the folder it is relative to. A backslash or a Windows absolute path
|
|
426
465
|
* is refused outright: `posix.normalize` does not resolve `..\\`, while the host's `join` may.
|
|
427
466
|
*/
|
|
428
|
-
const isInside = (path: string): boolean => {
|
|
467
|
+
export const isInside = (path: string): boolean => {
|
|
429
468
|
if (path.includes("\\") || isAbsolute(path) || win32.isAbsolute(path))
|
|
430
469
|
return false;
|
|
431
470
|
const n = posix.normalize(path);
|
|
432
471
|
return n !== ".." && !n.startsWith("../");
|
|
433
472
|
};
|
|
434
473
|
|
|
474
|
+
/**
|
|
475
|
+
* Whether `folder` names one folder directly inside `repro/`: not empty, not `.` or `..`, and no
|
|
476
|
+
* separator or NUL. Every command and rule that reads a study's folder reads only such a one: a row
|
|
477
|
+
* naming `../x` would have them read and write outside the paper.
|
|
478
|
+
*/
|
|
479
|
+
export const isFolderName = (folder: string): boolean =>
|
|
480
|
+
folder !== "." && folder !== ".." && /^[^/\\\0]+$/u.test(folder);
|
|
481
|
+
|
|
435
482
|
/**
|
|
436
483
|
* The `<folder>/<plan>` of each row whose plan lies inside its folder: the files the judge needs read
|
|
437
484
|
* (`StudyInput.plans`). A plan outside its folder is reported without being read.
|
|
438
485
|
*/
|
|
439
486
|
export const planKeys = (r: StudyRegistry): readonly string[] =>
|
|
487
|
+
planRefs(r).map((p) => p.key);
|
|
488
|
+
|
|
489
|
+
/** Each plan `planKeys` names, with the kind of the study whose row names it: what its parser needs. */
|
|
490
|
+
export const planRefs = (
|
|
491
|
+
r: StudyRegistry,
|
|
492
|
+
): readonly {
|
|
493
|
+
readonly key: string;
|
|
494
|
+
readonly folder: string;
|
|
495
|
+
readonly kind: StudyKind;
|
|
496
|
+
}[] =>
|
|
440
497
|
r.studies.flatMap((s) =>
|
|
441
|
-
s.plan !== undefined && isInside(s.plan)
|
|
498
|
+
s.plan !== undefined && isInside(s.plan) && isFolderName(s.folder)
|
|
499
|
+
? [{ key: `${s.folder}/${s.plan}`, folder: s.folder, kind: s.kind }]
|
|
500
|
+
: [],
|
|
442
501
|
);
|
|
443
502
|
|
|
444
503
|
/** One row of a registry, with what the judge read beside it. */
|
|
@@ -695,11 +754,12 @@ const judgeRow = (row: Row): readonly StudyFinding[] =>
|
|
|
695
754
|
function judgeIndex(
|
|
696
755
|
r: StudyRegistry,
|
|
697
756
|
readme: string | null,
|
|
757
|
+
plans: ReadonlyMap<string, PlanState>,
|
|
698
758
|
): StudyFinding | null {
|
|
699
759
|
if (readme === null) return { problem: "stale-index", why: "no-readme" };
|
|
700
760
|
const block = embeddedIndex(readme);
|
|
701
761
|
if (block === null) return { problem: "stale-index", why: "no-markers" };
|
|
702
|
-
return indexInSync(readme, r)
|
|
762
|
+
return indexInSync(readme, r, { plans, history: new Map() })
|
|
703
763
|
? null
|
|
704
764
|
: { problem: "stale-index", why: "differs" };
|
|
705
765
|
}
|
|
@@ -718,7 +778,7 @@ export function judgeStudies(input: StudyInput): readonly StudyFinding[] {
|
|
|
718
778
|
return [{ problem: "unreadable", why: state.why }];
|
|
719
779
|
const r = state.registry;
|
|
720
780
|
const next = nextStudyId(r);
|
|
721
|
-
const index = judgeIndex(r, input.readme);
|
|
781
|
+
const index = judgeIndex(r, input.readme, input.plans);
|
|
722
782
|
return [
|
|
723
783
|
...input.folders
|
|
724
784
|
.filter((f) => !r.studies.some((s) => s.folder === f))
|
|
@@ -739,19 +799,65 @@ export function judgeStudies(input: StudyInput): readonly StudyFinding[] {
|
|
|
739
799
|
const cell = (s: string): string =>
|
|
740
800
|
s.replace(/\r?\n/gu, " ").replace(/\\/gu, "\\\\").replace(/\|/gu, "\\|");
|
|
741
801
|
|
|
742
|
-
/**
|
|
802
|
+
/**
|
|
803
|
+
* What the index is generated from beyond the registry: the plans by `<folder>/<plan>` (as
|
|
804
|
+
* `StudyInput.plans`), and the history of each decide-first study by id. A study with no history
|
|
805
|
+
* listed shows `not read` — the lint, which never compares those columns, asks none.
|
|
806
|
+
*/
|
|
807
|
+
export interface IndexInput {
|
|
808
|
+
readonly plans: ReadonlyMap<string, PlanState>;
|
|
809
|
+
readonly history: ReadonlyMap<string, IndexHistory>;
|
|
810
|
+
}
|
|
811
|
+
|
|
812
|
+
/** The mode the plan of `s` declares; null when it names none, or the plan is absent or unreadable. */
|
|
813
|
+
const declaredMode = (
|
|
814
|
+
s: Study,
|
|
815
|
+
plans: ReadonlyMap<string, PlanState>,
|
|
816
|
+
): StudyMode | null => {
|
|
817
|
+
const state =
|
|
818
|
+
s.plan !== undefined && isInside(s.plan)
|
|
819
|
+
? plans.get(`${s.folder}/${s.plan}`)
|
|
820
|
+
: undefined;
|
|
821
|
+
return state === undefined ||
|
|
822
|
+
state.kind === "absent" ||
|
|
823
|
+
state.kind === "unreadable"
|
|
824
|
+
? null
|
|
825
|
+
: (state.plan.mode ?? null);
|
|
826
|
+
};
|
|
827
|
+
|
|
828
|
+
/** What the index shows of `s` beyond its row. */
|
|
829
|
+
const factsOf = (s: Study, at: IndexInput): IndexFacts => ({
|
|
830
|
+
mode: declaredMode(s, at.plans),
|
|
831
|
+
history: at.history.get(s.id) ?? null,
|
|
832
|
+
});
|
|
833
|
+
|
|
834
|
+
/**
|
|
835
|
+
* The columns of the index: each one's header, its cell for a study, and whether git's history is
|
|
836
|
+
* where the cell comes from — those two are never compared (src/domain/index-columns.ts).
|
|
837
|
+
*/
|
|
743
838
|
const COLUMNS: readonly {
|
|
744
839
|
readonly head: string;
|
|
745
|
-
readonly of: (s: Study) => string;
|
|
840
|
+
readonly of: (s: Study, f: IndexFacts) => string;
|
|
841
|
+
readonly fromGit: boolean;
|
|
746
842
|
}[] = [
|
|
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: "
|
|
843
|
+
{ head: "id", of: (s) => s.id, fromGit: false },
|
|
844
|
+
{ head: "title", of: (s) => cell(s.title), fromGit: false },
|
|
845
|
+
{ head: "status", of: (s) => s.status, fromGit: false },
|
|
846
|
+
{ head: "result", of: (s) => cell(s.result ?? "—"), fromGit: false },
|
|
847
|
+
{ head: "in paper", of: (s) => s.in_paper, fromGit: false },
|
|
848
|
+
{ head: "mode", of: (_, f) => cell(modeCell(f)), fromGit: false },
|
|
849
|
+
{ head: "decision", of: (_, f) => decisionCell(f), fromGit: true },
|
|
850
|
+
{ head: "first output", of: (_, f) => firstOutputCell(f), fromGit: true },
|
|
851
|
+
{
|
|
852
|
+
head: "folder",
|
|
853
|
+
of: (s) => `[${s.folder}/](${s.folder}/)`,
|
|
854
|
+
fromGit: false,
|
|
855
|
+
},
|
|
753
856
|
];
|
|
754
857
|
|
|
858
|
+
/** The positions of the columns read from git: left out when an index is compared. */
|
|
859
|
+
const FROM_GIT = COLUMNS.flatMap((c, i) => (c.fromGit ? [i] : []));
|
|
860
|
+
|
|
755
861
|
/** The width a cell takes: its code points. */
|
|
756
862
|
const widthOf = (s: string): number => Array.from(s).length;
|
|
757
863
|
|
|
@@ -764,20 +870,23 @@ const padded = (t: string, width: number): string =>
|
|
|
764
870
|
* widest cell (at least 3), the way prettier writes a table — so formatting the README leaves it as
|
|
765
871
|
* it is.
|
|
766
872
|
*/
|
|
767
|
-
export function studyIndex(r: StudyRegistry): string {
|
|
873
|
+
export function studyIndex(r: StudyRegistry, at: IndexInput): string {
|
|
874
|
+
const studies = r.studies.map((s) => ({ s, f: factsOf(s, at) }));
|
|
768
875
|
const sized = COLUMNS.map((c) => ({
|
|
769
876
|
...c,
|
|
770
877
|
width: Math.max(
|
|
771
878
|
3,
|
|
772
879
|
widthOf(c.head),
|
|
773
|
-
...
|
|
880
|
+
...studies.map(({ s, f }) => widthOf(c.of(s, f))),
|
|
774
881
|
),
|
|
775
882
|
}));
|
|
776
883
|
const line = (cells: readonly string[]): string => `| ${cells.join(" | ")} |`;
|
|
777
884
|
return [
|
|
778
885
|
line(sized.map((c) => padded(c.head, c.width))),
|
|
779
886
|
line(sized.map((c) => "-".repeat(c.width))),
|
|
780
|
-
...
|
|
887
|
+
...studies.map(({ s, f }) =>
|
|
888
|
+
line(sized.map((c) => padded(c.of(s, f), c.width))),
|
|
889
|
+
),
|
|
781
890
|
].join("\n");
|
|
782
891
|
}
|
|
783
892
|
|
|
@@ -786,7 +895,7 @@ export function studyIndex(r: StudyRegistry): string {
|
|
|
786
895
|
* `---`. The index is judged on this, because the padding belongs to the formatter: prettier sizes a
|
|
787
896
|
* column by display width, so a wide character pads differently from a code-point count.
|
|
788
897
|
*/
|
|
789
|
-
const cellsOf = (table: string): string =>
|
|
898
|
+
const cellsOf = (table: string, skip: readonly number[]): string =>
|
|
790
899
|
table
|
|
791
900
|
.trim()
|
|
792
901
|
.split("\n")
|
|
@@ -796,19 +905,44 @@ const cellsOf = (table: string): string =>
|
|
|
796
905
|
.replace(/^\||\|$/gu, "")
|
|
797
906
|
.split(/(?<!\\)\|/u)
|
|
798
907
|
.map((c) => c.trim().replace(/^:?-{3,}:?$/u, "---"))
|
|
908
|
+
.filter((_, i) => !skip.includes(i))
|
|
799
909
|
.join("|"),
|
|
800
910
|
)
|
|
801
911
|
.join("\n");
|
|
802
912
|
|
|
803
|
-
/** Whether `readme` holds the index of `r
|
|
804
|
-
|
|
913
|
+
/** Whether `readme` holds the index of `r` in every column but those at `skip`, whatever its padding. */
|
|
914
|
+
const sameCells = (
|
|
915
|
+
readme: string | null,
|
|
916
|
+
index: string,
|
|
917
|
+
skip: readonly number[],
|
|
918
|
+
): boolean => {
|
|
805
919
|
const block = readme === null ? null : embeddedIndex(readme);
|
|
806
|
-
return block !== null && cellsOf(block) === cellsOf(
|
|
807
|
-
}
|
|
920
|
+
return block !== null && cellsOf(block, skip) === cellsOf(index, skip);
|
|
921
|
+
};
|
|
922
|
+
|
|
923
|
+
/**
|
|
924
|
+
* Whether `readme` holds the index of `r`, whatever padding a formatter gave its table and whatever
|
|
925
|
+
* its two columns read from git say: what `repro/study-registered` checks.
|
|
926
|
+
*/
|
|
927
|
+
export const indexInSync = (
|
|
928
|
+
readme: string | null,
|
|
929
|
+
r: StudyRegistry,
|
|
930
|
+
at: IndexInput,
|
|
931
|
+
): boolean => sameCells(readme, studyIndex(r, at), FROM_GIT);
|
|
932
|
+
|
|
933
|
+
/**
|
|
934
|
+
* Whether `readme` holds the index of `r` in every column, whatever its padding: when it does,
|
|
935
|
+
* `studies index` leaves the README as it is, so the padding a formatter gave the table stays.
|
|
936
|
+
*/
|
|
937
|
+
export const indexCurrent = (
|
|
938
|
+
readme: string | null,
|
|
939
|
+
r: StudyRegistry,
|
|
940
|
+
at: IndexInput,
|
|
941
|
+
): boolean => sameCells(readme, studyIndex(r, at), []);
|
|
808
942
|
|
|
809
943
|
/** 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}`;
|
|
944
|
+
export const indexBlock = (r: StudyRegistry, at: IndexInput): string =>
|
|
945
|
+
`${INDEX_BEGIN}\n\n${studyIndex(r, at)}\n\n${INDEX_END}`;
|
|
812
946
|
|
|
813
947
|
/**
|
|
814
948
|
* Where `marker` stands alone on a line of `text`, at or after `from`; -1 when it does not. A marker
|
|
@@ -832,13 +966,18 @@ export function embeddedIndex(readme: string): string | null {
|
|
|
832
966
|
* `readme` with its index regenerated from `r`: the block replaced in place, appended when the README
|
|
833
967
|
* has none, a README of its own when there is none.
|
|
834
968
|
*/
|
|
835
|
-
export function withIndex(
|
|
836
|
-
|
|
969
|
+
export function withIndex(
|
|
970
|
+
readme: string | null,
|
|
971
|
+
r: StudyRegistry,
|
|
972
|
+
at: IndexInput,
|
|
973
|
+
): string {
|
|
974
|
+
const fresh = indexBlock(r, at);
|
|
975
|
+
if (readme === null) return `${INDEX_HEADING}\n\n${fresh}\n`;
|
|
837
976
|
const block = embeddedIndex(readme);
|
|
838
|
-
if (block === null) return `${readme.trimEnd()}\n\n${
|
|
977
|
+
if (block === null) return `${readme.trimEnd()}\n\n${fresh}\n`;
|
|
839
978
|
const begin = markerAt(readme, INDEX_BEGIN);
|
|
840
979
|
const after = begin + INDEX_BEGIN.length + block.length + INDEX_END.length;
|
|
841
|
-
return `${readme.slice(0, begin)}${
|
|
980
|
+
return `${readme.slice(0, begin)}${fresh}${readme.slice(after)}`;
|
|
842
981
|
}
|
|
843
982
|
|
|
844
983
|
/** The registry's text as `paperlint studies new` writes it: JSON, two-space indented, a final newline. */
|
|
@@ -923,7 +1062,7 @@ const HEADS: { readonly [P in Problem]: (f: FindingOf[P]) => string } = {
|
|
|
923
1062
|
case "outside":
|
|
924
1063
|
return `${REGISTRY}: ${of} is outside its folder. What to do: keep the plan in the study's folder, as \`${PLAN_FILE}\`, and name it relative to the folder.`;
|
|
925
1064
|
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\`.`;
|
|
1065
|
+
return `${plan} is not a study plan: ${f.reasons}. What to do: give it the fields on the rule's page — \`question\`, \`population\`, \`arms\`, \`primary_outcome\`, and for a measurement its \`mode\`.`;
|
|
927
1066
|
case "unfilled":
|
|
928
1067
|
return `${plan} leaves ${listed(f.fields)} empty, and the study is ${tick(f.status)}: a plan is written before the runs. What to do: fill them in, or set the study back to \`designing\`.`;
|
|
929
1068
|
}
|
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
|
});
|