paperlint 9.0.1 → 10.1.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/CONTRIBUTING.md +16 -4
- package/README.md +68 -128
- package/dist/build.d.ts +1 -1
- package/dist/build.js +1 -1
- package/dist/cli.d.ts +5 -1
- package/dist/cli.d.ts.map +1 -1
- package/dist/cli.js +52 -6
- package/dist/cli.js.map +1 -1
- package/dist/domain/layout-tree.d.ts +19 -0
- package/dist/domain/layout-tree.d.ts.map +1 -0
- package/dist/domain/layout-tree.js +32 -0
- package/dist/domain/layout-tree.js.map +1 -0
- package/dist/domain/lookup-cache.d.ts +2 -2
- package/dist/domain/lookup-cache.js +2 -2
- package/dist/domain/paper-layout.d.ts +81 -0
- package/dist/domain/paper-layout.d.ts.map +1 -0
- package/dist/domain/paper-layout.js +259 -0
- package/dist/domain/paper-layout.js.map +1 -0
- package/dist/domain/repro-layout.d.ts +80 -0
- package/dist/domain/repro-layout.d.ts.map +1 -0
- package/dist/domain/repro-layout.js +247 -0
- package/dist/domain/repro-layout.js.map +1 -0
- package/dist/domain/scaffold-layout.d.ts +28 -0
- package/dist/domain/scaffold-layout.d.ts.map +1 -0
- package/dist/domain/scaffold-layout.js +81 -0
- package/dist/domain/scaffold-layout.js.map +1 -0
- package/dist/eslint-rules/js-needs-reason.d.ts +55 -0
- package/dist/eslint-rules/js-needs-reason.d.ts.map +1 -0
- package/dist/eslint-rules/js-needs-reason.js +35 -0
- package/dist/eslint-rules/js-needs-reason.js.map +1 -0
- package/dist/eslint-rules/prereg-coverage.d.ts +98 -0
- package/dist/eslint-rules/prereg-coverage.d.ts.map +1 -0
- package/dist/eslint-rules/prereg-coverage.js +274 -0
- package/dist/eslint-rules/prereg-coverage.js.map +1 -0
- package/dist/folder-layout-hook.d.ts +21 -0
- package/dist/folder-layout-hook.d.ts.map +1 -0
- package/dist/folder-layout-hook.js +117 -0
- package/dist/folder-layout-hook.js.map +1 -0
- package/dist/folder-layout-rule.d.ts +22 -0
- package/dist/folder-layout-rule.d.ts.map +1 -0
- package/dist/folder-layout-rule.js +107 -0
- package/dist/folder-layout-rule.js.map +1 -0
- package/dist/init.js +1 -1
- package/dist/init.js.map +1 -1
- package/dist/new-paper.d.ts +4 -1
- package/dist/new-paper.d.ts.map +1 -1
- package/dist/new-paper.js +40 -6
- package/dist/new-paper.js.map +1 -1
- package/dist/package-dirs.d.ts +6 -0
- package/dist/package-dirs.d.ts.map +1 -1
- package/dist/package-dirs.js +6 -0
- package/dist/package-dirs.js.map +1 -1
- package/dist/references.d.ts +1 -1
- package/dist/references.d.ts.map +1 -1
- package/dist/references.js +2 -2
- package/dist/references.js.map +1 -1
- package/dist/repro-layout-rule.d.ts +16 -0
- package/dist/repro-layout-rule.d.ts.map +1 -0
- package/dist/repro-layout-rule.js +81 -0
- package/dist/repro-layout-rule.js.map +1 -0
- package/dist/skills/label-agent-runs/scripts/labels-commands.d.ts +13 -0
- package/dist/skills/label-agent-runs/scripts/labels.d.ts +2 -0
- package/dist/skills/run-agent-grid/scripts/grid-guard-commands.d.ts +20 -0
- package/dist/skills/run-agent-grid/scripts/grid-guard.d.ts +2 -0
- package/docs/ci.md +60 -0
- package/docs/configuration.md +2 -0
- package/docs/e2e.md +2 -2
- package/docs/install.md +20 -16
- package/docs/layout.md +154 -0
- package/docs/optional-rules.md +5 -4
- package/docs/references.md +5 -2
- package/docs/rules/paper/allowed-files-only.md +192 -0
- package/docs/rules/repro/allowed-files-only.md +128 -0
- package/docs/rules/study/declare-sample.md +146 -0
- package/docs/rules.md +53 -50
- package/docs/skills.md +8 -3
- package/docs/testing.md +13 -0
- package/docs/venues.md +94 -0
- package/eslint-rules/js-needs-reason.test.ts +141 -0
- package/eslint-rules/js-needs-reason.ts +77 -0
- package/eslint-rules/prereg-coverage.test.ts +448 -0
- package/eslint-rules/prereg-coverage.ts +419 -0
- package/eslint.config.mjs +22 -2
- package/lib/skill-trigger-cases.harness.mjs +51 -5
- package/lib/skill-trigger-cases.mjs +53 -15
- package/lib/skill-trigger-cases.test.ts +211 -0
- package/package.json +8 -4
- package/plugin/hooks/hooks.json +9 -0
- package/scripts/coverage-config.test.ts +5 -3
- package/skills/analyze-sibling-paper/SKILL.md +2 -2
- package/skills/analyze-sibling-paper/SKILL.md.spec.ts +1 -1
- package/skills/build-benchmark/SKILL.md +17 -1
- package/skills/build-benchmark/SKILL.md.spec.ts +16 -0
- package/skills/build-label-page/SKILL.md +120 -0
- package/skills/build-label-page/SKILL.md.spec.ts +131 -0
- package/skills/build-label-page/build-label-page.eval.mjs +15 -0
- package/skills/build-label-page/build-label-page.test.ts +959 -0
- package/skills/build-label-page/references/checklist.md +148 -0
- package/skills/build-label-page/references/example-generator.py +803 -0
- package/skills/find-venue/SKILL.md +5 -3
- package/skills/find-venue/SKILL.md.spec.ts +4 -2
- package/skills/grade-paper-writing/SKILL.md +7 -7
- package/skills/grade-paper-writing/SKILL.md.spec.ts +6 -6
- package/skills/label-agent-runs/SKILL.md +103 -0
- package/skills/label-agent-runs/SKILL.md.spec.ts +106 -0
- package/skills/label-agent-runs/label-agent-runs.eval.ts +55 -0
- package/skills/label-agent-runs/label-agent-runs.harness.ts +123 -0
- package/skills/label-agent-runs/references/formats.md +64 -0
- package/skills/label-agent-runs/scripts/labels-commands.js +13 -0
- package/skills/label-agent-runs/scripts/labels-commands.ts +13 -0
- package/skills/label-agent-runs/scripts/labels.js +396 -0
- package/skills/label-agent-runs/scripts/labels.test.ts +538 -0
- package/skills/label-agent-runs/scripts/labels.ts +501 -0
- package/skills/map-prior-work/SKILL.md +2 -2
- package/skills/map-prior-work/SKILL.md.spec.ts +1 -1
- package/skills/paper-pipeline/SKILL.md +15 -6
- package/skills/paper-pipeline/SKILL.md.spec.ts +14 -5
- package/skills/paper-pipeline/grade-paper-writing-ablation.eval.mjs +19 -9
- package/skills/paper-pipeline/references/acceptance-gate.md +19 -19
- package/skills/paper-pipeline/references/artifact-checklist.md +5 -0
- package/skills/paper-pipeline/references/pipeline-status-template.md +30 -1
- package/skills/paper-pipeline/repro/2026-08-07-fork-check.log +1 -1
- package/skills/paper-pipeline/repro/2026-08-07-fork-check2.log +1 -1
- package/skills/paper-pipeline/repro/2026-08-07-language-eval-pilot.log +1 -1
- package/skills/paper-pipeline/repro/2026-08-07-language-eval-raw.log +1 -1
- package/skills/paper-pipeline/repro/2026-08-08-framing-vs-vocabulary-oracle.log +48 -48
- package/skills/paper-pipeline/repro/2026-08-08-grade-paper-writing-ablation-A6-oracle.log +8 -8
- package/skills/paper-pipeline/repro/2026-08-08-grade-paper-writing-ablation-oracle.log +48 -48
- package/skills/paper-pipeline/repro/2026-08-08-parent-replication-gpw.log +2 -2
- package/skills/paper-pipeline/repro/2026-08-08-parent-replication.mjs +3 -3
- package/skills/paper-pipeline/repro/2026-10-13-grade-paper-writing-ablation-preflight-after.log +81 -0
- package/skills/paper-pipeline/repro/2026-10-13-grade-paper-writing-ablation-preflight-before.log +81 -0
- package/skills/paper-pipeline/repro/2026-10-13-grade-paper-writing-ablation-setupdiff.log +59 -0
- package/skills/paper-pipeline/repro/2026-10-13-grade-paper-writing-trigger.jsonl +1 -0
- package/skills/paper-pipeline/repro/2026-10-13-grade-paper-writing-trigger.log +22 -0
- package/skills/paper-pipeline/repro/README.md +4 -0
- package/skills/paper-pipeline/scripts/extract-ref-facts.harness.mjs +2 -2
- package/skills/paper-pipeline/scripts/extract-ref-facts.mjs +2 -2
- package/skills/paper-pipeline/scripts/extract-ref-facts.test.mjs +4 -2
- package/skills/paper-pipeline/scripts/generated-code.harness.mjs +7 -2
- package/skills/paper-pipeline/scripts/generated-code.mjs +8 -4
- package/skills/paper-pipeline/scripts/generated-code.test.ts +15 -1
- package/skills/paper-status/SKILL.md +2 -2
- package/skills/paper-status/SKILL.md.spec.ts +1 -1
- package/skills/run-agent-grid/SKILL.md +181 -0
- package/skills/run-agent-grid/SKILL.md.spec.ts +184 -0
- package/skills/run-agent-grid/references/failure-catalog.md +36 -0
- package/skills/run-agent-grid/references/registry-and-ledger.md +142 -0
- package/skills/run-agent-grid/run-agent-grid.eval.ts +59 -0
- package/skills/run-agent-grid/run-agent-grid.harness.ts +115 -0
- package/skills/run-agent-grid/scripts/grid-guard-commands.js +18 -0
- package/skills/run-agent-grid/scripts/grid-guard-commands.ts +21 -0
- package/skills/run-agent-grid/scripts/grid-guard.js +700 -0
- package/skills/run-agent-grid/scripts/grid-guard.test.ts +1090 -0
- package/skills/run-agent-grid/scripts/grid-guard.ts +869 -0
- package/skills/study-accepted-papers/SKILL.md +163 -161
- package/skills/study-accepted-papers/SKILL.md.spec.ts +167 -165
- package/skills/study-accepted-papers/references/base-rates.md +69 -0
- package/skills/study-accepted-papers/references/mechanisms.md +181 -0
- package/skills/verify-citations/SKILL.md +2 -2
- package/skills/verify-citations/SKILL.md.spec.ts +1 -1
- package/src/build.ts +1 -1
- package/src/cli.harness.mjs +42 -0
- package/src/cli.test.ts +90 -6
- package/src/cli.ts +78 -5
- package/src/domain/layout-tree.test.ts +32 -0
- package/src/domain/layout-tree.ts +47 -0
- package/src/domain/lookup-cache.ts +2 -2
- package/src/domain/paper-layout.test.ts +264 -0
- package/src/domain/paper-layout.ts +336 -0
- package/src/domain/repro-layout.test.ts +222 -0
- package/src/domain/repro-layout.ts +311 -0
- package/src/domain/scaffold-layout.ts +104 -0
- package/src/folder-layout-hook.test.ts +214 -0
- package/src/folder-layout-hook.ts +141 -0
- package/src/folder-layout-rule.test.ts +235 -0
- package/src/folder-layout-rule.ts +151 -0
- package/src/hooks-settings.harness.ts +9 -8
- package/src/init.ts +1 -1
- package/src/layout-docs.test.ts +52 -0
- package/src/new-paper.harness.mjs +23 -1
- package/src/new-paper.test.ts +299 -6
- package/src/new-paper.ts +51 -5
- package/src/package-dirs.ts +8 -0
- package/src/paper-files.test.ts +9 -6
- package/src/references.test.ts +5 -5
- package/src/references.ts +2 -2
- package/src/repro-layout-rule.test.ts +114 -0
- package/src/repro-layout-rule.ts +121 -0
- package/src/rule-cases.test.ts +98 -0
- package/templates/paper/PIPELINE-STATUS.md +41 -7
- package/tsconfig.base.json +22 -0
- package/tsconfig.json +6 -24
- package/tsconfig.pkg.json +3 -1
- package/tsconfig.skills.json +14 -0
- package/tsconfig.src.json +12 -0
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `local/js-needs-reason`: both halves on planted files, through ESLint's own `Linter`, then the
|
|
3
|
+
* real configuration on a planted new file, then an existing file through the repository's bulk
|
|
4
|
+
* suppressions (`eslint-suppressions.json`), with and without them.
|
|
5
|
+
*/
|
|
6
|
+
import assert from "node:assert/strict";
|
|
7
|
+
import { spawnSync } from "node:child_process";
|
|
8
|
+
import { join } from "node:path";
|
|
9
|
+
import { ESLint, Linter } from "eslint";
|
|
10
|
+
import { test } from "vitest";
|
|
11
|
+
import { z } from "zod";
|
|
12
|
+
import { useTempDir, writeTree } from "../test/support.ts";
|
|
13
|
+
import rules from "./js-needs-reason.ts";
|
|
14
|
+
|
|
15
|
+
const ROOT = join(import.meta.dirname, "..");
|
|
16
|
+
|
|
17
|
+
/** The module as ESLint's `Plugin` type: its rules are typed against `rule-context.ts`, not ESLint's. */
|
|
18
|
+
const isPlugin = (p: unknown): p is ESLint.Plugin =>
|
|
19
|
+
typeof p === "object" && p !== null && "rules" in p;
|
|
20
|
+
const plugin: ESLint.Plugin = isPlugin(rules)
|
|
21
|
+
? rules
|
|
22
|
+
: assert.fail("js-needs-reason.ts does not export a plugin");
|
|
23
|
+
const MESSAGE =
|
|
24
|
+
"New code is TypeScript (#78): write this file as .ts, or open it with " +
|
|
25
|
+
"`// JavaScript, not TypeScript: evidence|fixture|config-loader — <why>` " +
|
|
26
|
+
"(evidence kept exactly as it ran; a fixture checked as JavaScript; a config a loader " +
|
|
27
|
+
"that cannot read TypeScript reads).";
|
|
28
|
+
|
|
29
|
+
/** The rule's messages for `code` as the file `rel`. */
|
|
30
|
+
const lint = (rel: string, code: string) =>
|
|
31
|
+
new Linter({ cwd: ROOT })
|
|
32
|
+
.verify(
|
|
33
|
+
code,
|
|
34
|
+
[
|
|
35
|
+
{
|
|
36
|
+
files: ["**/*.{js,mjs,cjs}"],
|
|
37
|
+
plugins: { local: plugin },
|
|
38
|
+
rules: {
|
|
39
|
+
"local/js-needs-reason": "error",
|
|
40
|
+
},
|
|
41
|
+
},
|
|
42
|
+
],
|
|
43
|
+
{ filename: join(ROOT, rel) },
|
|
44
|
+
)
|
|
45
|
+
.map((m) => ({ rule: m.ruleId, line: m.line, message: m.message }));
|
|
46
|
+
const FIRES = [{ rule: "local/js-needs-reason", line: 1, message: MESSAGE }];
|
|
47
|
+
|
|
48
|
+
test("a new .mjs without a reason fails; .js and .cjs too", () => {
|
|
49
|
+
assert.deepEqual(lint("scripts/new.mjs", "export const x = 1;\n"), FIRES);
|
|
50
|
+
assert.deepEqual(lint("scripts/new.js", "export const x = 1;\n"), FIRES);
|
|
51
|
+
assert.deepEqual(lint("scripts/new.cjs", "module.exports = 1;\n"), FIRES);
|
|
52
|
+
// An empty file has no statement to come before, and still names no reason.
|
|
53
|
+
assert.deepEqual(lint("scripts/empty.mjs", ""), FIRES);
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
test("a new file that names an accepted reason at its top passes", () => {
|
|
57
|
+
for (const reason of ["evidence", "fixture", "config-loader"])
|
|
58
|
+
assert.deepEqual(
|
|
59
|
+
lint(
|
|
60
|
+
"scripts/new.mjs",
|
|
61
|
+
`// JavaScript, not TypeScript: ${reason} — why\nexport const x = 1;\n`,
|
|
62
|
+
),
|
|
63
|
+
[],
|
|
64
|
+
);
|
|
65
|
+
assert.deepEqual(
|
|
66
|
+
lint(
|
|
67
|
+
"bin/new.mjs",
|
|
68
|
+
"#!/usr/bin/env node\n/**\n * JavaScript, not TypeScript: evidence — kept as it ran.\n */\nexport const x = 1;\n",
|
|
69
|
+
),
|
|
70
|
+
[],
|
|
71
|
+
);
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
test("a reason that is not on the list, or that comes after the code, fails", () => {
|
|
75
|
+
assert.deepEqual(
|
|
76
|
+
lint(
|
|
77
|
+
"scripts/new.mjs",
|
|
78
|
+
"// JavaScript, not TypeScript: smaller — fewer bytes\nexport const x = 1;\n",
|
|
79
|
+
),
|
|
80
|
+
FIRES,
|
|
81
|
+
);
|
|
82
|
+
assert.deepEqual(
|
|
83
|
+
lint(
|
|
84
|
+
"scripts/new.mjs",
|
|
85
|
+
"export const x = 1;\n// JavaScript, not TypeScript: fixture — late\n",
|
|
86
|
+
),
|
|
87
|
+
FIRES,
|
|
88
|
+
);
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
/** The repository's own `eslint` CLI on one file: exit code and this rule's findings. */
|
|
92
|
+
function cli(
|
|
93
|
+
file: string,
|
|
94
|
+
extra: readonly string[] = [],
|
|
95
|
+
): { status: number | null; findings: number } {
|
|
96
|
+
const r = spawnSync(
|
|
97
|
+
process.execPath,
|
|
98
|
+
[
|
|
99
|
+
join(ROOT, "node_modules", "eslint", "bin", "eslint.js"),
|
|
100
|
+
"--format",
|
|
101
|
+
"json",
|
|
102
|
+
...extra,
|
|
103
|
+
file,
|
|
104
|
+
],
|
|
105
|
+
{ cwd: ROOT, encoding: "utf8" },
|
|
106
|
+
);
|
|
107
|
+
const findings = z
|
|
108
|
+
.array(
|
|
109
|
+
z.object({
|
|
110
|
+
messages: z.array(z.object({ ruleId: z.string().nullable() })),
|
|
111
|
+
}),
|
|
112
|
+
)
|
|
113
|
+
.parse(JSON.parse(r.stdout))
|
|
114
|
+
.flatMap((f) => f.messages)
|
|
115
|
+
.filter((m) => m.ruleId === "local/js-needs-reason").length;
|
|
116
|
+
return { status: r.status, findings };
|
|
117
|
+
}
|
|
118
|
+
const WORK = useTempDir("js-needs-reason-");
|
|
119
|
+
|
|
120
|
+
test("an existing JavaScript file is suppressed by eslint-suppressions.json, and only by it", () => {
|
|
121
|
+
const old = join(ROOT, "scripts", "rules-see-files.mjs");
|
|
122
|
+
assert.deepEqual(cli(old), { status: 0, findings: 0 });
|
|
123
|
+
const none = writeTree(WORK, { "none.json": "{}\n" });
|
|
124
|
+
assert.deepEqual(
|
|
125
|
+
cli(old, ["--suppressions-location", join(none, "none.json")]),
|
|
126
|
+
{ status: 1, findings: 1 },
|
|
127
|
+
);
|
|
128
|
+
}, 120_000);
|
|
129
|
+
|
|
130
|
+
test("the repository's configuration runs the rule on a new file", async () => {
|
|
131
|
+
const eslint = new ESLint({ cwd: ROOT });
|
|
132
|
+
const found = (
|
|
133
|
+
await eslint.lintText("export const x = 1;\n", {
|
|
134
|
+
filePath: join(ROOT, "scripts", "planted-new-file.mjs"),
|
|
135
|
+
})
|
|
136
|
+
).flatMap((r) => r.messages.map((m) => m.ruleId));
|
|
137
|
+
assert.deepEqual(
|
|
138
|
+
found.filter((id) => id === "local/js-needs-reason"),
|
|
139
|
+
["local/js-needs-reason"],
|
|
140
|
+
);
|
|
141
|
+
}, 120_000);
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `local/js-needs-reason` — a NEW JavaScript file says, at its top, why it is not TypeScript.
|
|
3
|
+
*
|
|
4
|
+
* New code is TypeScript (#78). `TYPESCRIPT_ONLY` in `eslint.config.mjs` holds that for the
|
|
5
|
+
* directories already converted; everywhere else a new `.mjs` arrived whenever its neighbours were
|
|
6
|
+
* `.mjs`, which is not a reason. This rule closes the rest: a `.js`, `.mjs` or `.cjs` file that is
|
|
7
|
+
* not in the grandfathered list must open with a comment naming one of the accepted reasons:
|
|
8
|
+
*
|
|
9
|
+
* // JavaScript, not TypeScript: evidence — <why> kept byte for byte as it ran
|
|
10
|
+
* // JavaScript, not TypeScript: fixture — <why> checked AS JavaScript by a test
|
|
11
|
+
* // JavaScript, not TypeScript: config-loader — <why> read by a loader that cannot read TS
|
|
12
|
+
*
|
|
13
|
+
* "Smaller", "faster" and "the neighbours are .mjs" are not on the list. Code a consumer runs from
|
|
14
|
+
* `node_modules` is TypeScript too, compiled (`tsconfig.skills.json`, `tsconfig.pkg.json`).
|
|
15
|
+
*
|
|
16
|
+
* The rule reports EVERY JavaScript file without a reason; it keeps no list of its own. The files
|
|
17
|
+
* that existed when it arrived are ESLint bulk suppressions in `eslint-suppressions.json`, which
|
|
18
|
+
* only shrinks: a suppression that no longer matches fails `eslint .` until
|
|
19
|
+
* `--prune-suppressions` removes it. Not "added since the base branch": a rule may not run git
|
|
20
|
+
* (`GIT_IN_A_RULE` in the config — a shallow CI checkout has no base).
|
|
21
|
+
*/
|
|
22
|
+
import type { Program } from "estree";
|
|
23
|
+
import type { RuleContext } from "./rule-context.ts";
|
|
24
|
+
|
|
25
|
+
/** The comment that excuses a JavaScript file, and the reasons it may give. */
|
|
26
|
+
const REASON =
|
|
27
|
+
/JavaScript, not TypeScript: (?:evidence|fixture|config-loader)\b/;
|
|
28
|
+
|
|
29
|
+
interface Comment {
|
|
30
|
+
readonly type: string;
|
|
31
|
+
readonly value: string;
|
|
32
|
+
readonly range: readonly [number, number];
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** What this rule reads beyond the shared slice: the comments. */
|
|
36
|
+
type Context = RuleContext & {
|
|
37
|
+
readonly sourceCode: { getAllComments(): readonly Comment[] };
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
/** Does a comment that names an accepted reason come before the first statement? */
|
|
41
|
+
const excused = (comments: readonly Comment[], codeStart: number): boolean =>
|
|
42
|
+
comments.some(
|
|
43
|
+
(c) =>
|
|
44
|
+
c.type !== "Shebang" && c.range[0] < codeStart && REASON.test(c.value),
|
|
45
|
+
);
|
|
46
|
+
|
|
47
|
+
export default {
|
|
48
|
+
rules: {
|
|
49
|
+
"js-needs-reason": {
|
|
50
|
+
meta: {
|
|
51
|
+
type: "problem" as const,
|
|
52
|
+
docs: {
|
|
53
|
+
description:
|
|
54
|
+
"a JavaScript file names why it is not TypeScript at its top",
|
|
55
|
+
},
|
|
56
|
+
schema: [],
|
|
57
|
+
messages: {
|
|
58
|
+
noReason:
|
|
59
|
+
"New code is TypeScript (#78): write this file as .ts, or open it with " +
|
|
60
|
+
"`// JavaScript, not TypeScript: evidence|fixture|config-loader — <why>` " +
|
|
61
|
+
"(evidence kept exactly as it ran; a fixture checked as JavaScript; a config a loader " +
|
|
62
|
+
"that cannot read TypeScript reads).",
|
|
63
|
+
},
|
|
64
|
+
},
|
|
65
|
+
create(context: Context) {
|
|
66
|
+
return {
|
|
67
|
+
Program(node: Readonly<Program>) {
|
|
68
|
+
const starts = node.body.map((s) => Number(s.range?.[0]));
|
|
69
|
+
const codeStart = Math.min(Infinity, ...starts);
|
|
70
|
+
if (excused(context.sourceCode.getAllComments(), codeStart)) return;
|
|
71
|
+
context.report({ node, messageId: "noReason" });
|
|
72
|
+
},
|
|
73
|
+
};
|
|
74
|
+
},
|
|
75
|
+
},
|
|
76
|
+
},
|
|
77
|
+
};
|
|
@@ -0,0 +1,448 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `study/declare-sample`: the declaration a study's `prereg.json` carries — population, strata,
|
|
3
|
+
* screens, coverage, the author's check — judged field by field, then across fields, then through
|
|
4
|
+
* ESLint on a paper's `PIPELINE-STATUS.md` with study folders beside it.
|
|
5
|
+
*/
|
|
6
|
+
import { symlinkSync } from "node:fs";
|
|
7
|
+
import { join } from "node:path";
|
|
8
|
+
import { describe, expect, it } from "vitest";
|
|
9
|
+
import {
|
|
10
|
+
REPRO_DIR as LAYOUT_REPRO_DIR,
|
|
11
|
+
REPRO_DIRS,
|
|
12
|
+
} from "../src/domain/repro-layout.ts";
|
|
13
|
+
import { useTempDir, writeTree } from "../test/support.ts";
|
|
14
|
+
import preregCoverage, {
|
|
15
|
+
REPRO_DIR,
|
|
16
|
+
SHARED_DIRS,
|
|
17
|
+
WHY,
|
|
18
|
+
judgePrereg,
|
|
19
|
+
pathText,
|
|
20
|
+
paperFindings,
|
|
21
|
+
preregFiles,
|
|
22
|
+
preregProblems,
|
|
23
|
+
problemMessage,
|
|
24
|
+
} from "./prereg-coverage.ts";
|
|
25
|
+
|
|
26
|
+
const PAGE =
|
|
27
|
+
"https://github.com/zernie/paperlint/blob/main/docs/rules/study/declare-sample.md";
|
|
28
|
+
|
|
29
|
+
/** A declaration that passes: a sample screened down, saying so, and checked by its author. */
|
|
30
|
+
const GOOD = {
|
|
31
|
+
study: "the author's own keys are not read",
|
|
32
|
+
population: {
|
|
33
|
+
about: "rules written in agent instruction files of public repositories",
|
|
34
|
+
source: "a census of instruction files, crawled 2026-07-28",
|
|
35
|
+
size: 4210,
|
|
36
|
+
},
|
|
37
|
+
strata: { by: "rule type", values: ["crisp", "parameterised", "vague"] },
|
|
38
|
+
screens: [
|
|
39
|
+
{
|
|
40
|
+
name: "judgeable from one code excerpt of at most 40 lines",
|
|
41
|
+
removed: { crisp: 120, parameterised: 410, vague: 1630 },
|
|
42
|
+
},
|
|
43
|
+
],
|
|
44
|
+
coverage: {
|
|
45
|
+
share: 0.31,
|
|
46
|
+
unreachable: ["vague", "rules about process rather than code"],
|
|
47
|
+
},
|
|
48
|
+
author_check: {
|
|
49
|
+
who: "first author",
|
|
50
|
+
when: "2026-10-12",
|
|
51
|
+
units_seen: 24,
|
|
52
|
+
units_drawn: 24,
|
|
53
|
+
verdict: "representative",
|
|
54
|
+
},
|
|
55
|
+
};
|
|
56
|
+
|
|
57
|
+
/** `GOOD` with `patch` merged into one of its object fields. */
|
|
58
|
+
const withField = (
|
|
59
|
+
key: "coverage" | "author_check",
|
|
60
|
+
patch: object,
|
|
61
|
+
): object => ({ ...GOOD, [key]: { ...GOOD[key], ...patch } });
|
|
62
|
+
|
|
63
|
+
/** The problems as `at: text`, the whole list. */
|
|
64
|
+
const judged = (data: unknown): string[] =>
|
|
65
|
+
judgePrereg(data).map((p) => `${p.at}: ${p.text}`);
|
|
66
|
+
|
|
67
|
+
describe("judgePrereg", () => {
|
|
68
|
+
it("a complete declaration, and keys the rule does not read: silent", () => {
|
|
69
|
+
expect(judged(GOOD)).toEqual([]);
|
|
70
|
+
});
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
describe("judgePrereg: the fields, missing", () => {
|
|
74
|
+
it("an empty object: each of the five fields, with what to add", () => {
|
|
75
|
+
expect(judged({})).toEqual([
|
|
76
|
+
"population: is missing — add `population`: what the paper's claim is about (`about`), where its units come from (`source`) and how many there are (`size`)",
|
|
77
|
+
"strata: is missing — add `strata`: the property the screens are counted by (`by`, e.g. rule type) and its values (`values`)",
|
|
78
|
+
"screens: is missing — add `screens`: one entry per screen applied between the population and the sample, each with `name` and `removed` — [] when none was",
|
|
79
|
+
"coverage: is missing — add `coverage`: the share of the population the sample can represent (`share`, 0 to 1, or null with a `reason`) and the strata it cannot reach (`unreachable`)",
|
|
80
|
+
"author_check: is missing — add `author_check`: who looked at the drawn units before the run (`who`), when (`when`), how many of how many (`units_seen`, `units_drawn`), and what they said (`verdict`)",
|
|
81
|
+
]);
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
it("not an object at all: one problem about the file", () => {
|
|
85
|
+
expect(judged([1])).toEqual([
|
|
86
|
+
": is not valid — it must be a JSON object holding `population`, `strata`, `screens`, `coverage` and `author_check`",
|
|
87
|
+
]);
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
it("the fields inside each part, missing", () => {
|
|
91
|
+
expect(
|
|
92
|
+
judged({
|
|
93
|
+
...GOOD,
|
|
94
|
+
population: {},
|
|
95
|
+
strata: {},
|
|
96
|
+
screens: [{}],
|
|
97
|
+
coverage: {},
|
|
98
|
+
author_check: {},
|
|
99
|
+
}),
|
|
100
|
+
).toEqual([
|
|
101
|
+
"population.about: is missing — add what the paper's claim is about, in the paper's own words (e.g. the rules written in agent instruction files of public repositories)",
|
|
102
|
+
"population.source: is missing — add where the population's units come from — the census, crawl or registry, with its date",
|
|
103
|
+
"population.size: is missing — add the number of units in the population, a whole number above 0 (an estimate is fine; say how it was made in `source`)",
|
|
104
|
+
"strata.by: is missing — add the property the units are stratified by, e.g. rule type",
|
|
105
|
+
"strata.values: is missing — add the stratum's values, a list of names",
|
|
106
|
+
"screens[0].name: is missing — add what the screen keeps, as it was applied (e.g. judgeable from one code excerpt of at most 40 lines)",
|
|
107
|
+
"screens[0].removed: is missing — add the number of candidates the screen removed in each declared stratum value, an object {<value>: <count>} with 0 where it removed none",
|
|
108
|
+
"coverage.share: is missing — add the share of the population the sample can represent, a number from 0 to 1, or null with a `reason`",
|
|
109
|
+
"coverage.unreachable: is missing — add the strata (or kinds of units) the sample cannot reach, a list of names — [] only when it reaches them all",
|
|
110
|
+
"author_check.who: is missing — add who looked at the drawn units (a name or a role)",
|
|
111
|
+
"author_check.when: is missing — add the date of the check, YYYY-MM-DD",
|
|
112
|
+
"author_check.units_seen: is missing — add how many of the drawn units — the rules or cases drawn, not the items made from them — the author looked at, a whole number above 0",
|
|
113
|
+
"author_check.units_drawn: is missing — add how many units the sample drew",
|
|
114
|
+
'author_check.verdict: is missing — add the author\'s answer to "is this what you meet in practice?" — one of representative, partly, not-representative',
|
|
115
|
+
]);
|
|
116
|
+
});
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
describe("judgePrereg: the fields, malformed", () => {
|
|
120
|
+
it("the fields inside each part, malformed", () => {
|
|
121
|
+
expect(
|
|
122
|
+
judged({
|
|
123
|
+
...GOOD,
|
|
124
|
+
population: { about: " ", source: 3, size: 0.5 },
|
|
125
|
+
strata: { by: "rule type", values: [] },
|
|
126
|
+
screens: [
|
|
127
|
+
"a screen in a sentence",
|
|
128
|
+
{ name: "short", removed: { crisp: -1 } },
|
|
129
|
+
],
|
|
130
|
+
coverage: { share: 1.5, reason: "", unreachable: "vague" },
|
|
131
|
+
author_check: {
|
|
132
|
+
who: "first author",
|
|
133
|
+
when: "2026-02-30",
|
|
134
|
+
units_seen: 0,
|
|
135
|
+
units_drawn: 10,
|
|
136
|
+
verdict: "fine",
|
|
137
|
+
decision: { proceed: "yes" },
|
|
138
|
+
},
|
|
139
|
+
}),
|
|
140
|
+
).toEqual([
|
|
141
|
+
"population.about: is empty — write what the paper's claim is about, in the paper's own words (e.g. the rules written in agent instruction files of public repositories)",
|
|
142
|
+
"population.source: is not valid — it must be where the population's units come from — the census, crawl or registry, with its date",
|
|
143
|
+
"population.size: is not valid — it must be the number of units in the population, a whole number above 0 (an estimate is fine; say how it was made in `source`)",
|
|
144
|
+
"strata.values: is empty — list the stratum's values, at least one",
|
|
145
|
+
"screens[0]: is not valid — it must be a screen: `name` and `removed`",
|
|
146
|
+
"screens[1].removed.crisp: is not valid — it must be a count, a whole number from 0",
|
|
147
|
+
"coverage.share: is not valid — it must be the share of the population the sample can represent, a number from 0 to 1, or null with a `reason`",
|
|
148
|
+
"coverage.reason: is empty — write why the share cannot be stated",
|
|
149
|
+
"coverage.unreachable: is not valid — it must be the strata (or kinds of units) the sample cannot reach, a list of names — [] only when it reaches them all",
|
|
150
|
+
"author_check.when: is not valid — it must be the date of the check, YYYY-MM-DD",
|
|
151
|
+
"author_check.units_seen: is not valid — it must be how many of the drawn units — the rules or cases drawn, not the items made from them — the author looked at, a whole number above 0",
|
|
152
|
+
'author_check.verdict: is not valid — it must be the author\'s answer to "is this what you meet in practice?" — one of representative, partly, not-representative',
|
|
153
|
+
"author_check.decision.proceed: is not valid — it must be whether the study runs on this sample, true or false",
|
|
154
|
+
"author_check.decision.by: is missing — add who decided",
|
|
155
|
+
"author_check.decision.why: is missing — add why, in a sentence",
|
|
156
|
+
]);
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
it("a decision that is not an object names the three fields it holds", () => {
|
|
160
|
+
expect(
|
|
161
|
+
judged(withField("author_check", { verdict: "partly", decision: "go" })),
|
|
162
|
+
).toEqual([
|
|
163
|
+
"author_check.decision: is not valid — it must be the decision taken after the verdict: `proceed` (true or false), `by` whom, and `why`",
|
|
164
|
+
]);
|
|
165
|
+
});
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
describe("judgePrereg: across fields", () => {
|
|
169
|
+
it("`removed` keyed by other than the declared strata: each missing value and each unknown key", () => {
|
|
170
|
+
expect(
|
|
171
|
+
judged({
|
|
172
|
+
...GOOD,
|
|
173
|
+
screens: [
|
|
174
|
+
{ name: "R1", removed: { crisp: 1, vague: 2, procedural: 3 } },
|
|
175
|
+
{ name: "R2", removed: {} },
|
|
176
|
+
],
|
|
177
|
+
}),
|
|
178
|
+
).toEqual([
|
|
179
|
+
"screens[0].removed.parameterised: is missing — every screen states how many candidates it removed in each declared stratum value (crisp, parameterised, vague), 0 where it removed none",
|
|
180
|
+
"screens[0].removed.procedural: is not a declared stratum value — `strata.values` declares crisp, parameterised, vague; add it there or count it under one of them",
|
|
181
|
+
"screens[1].removed.crisp: is missing — every screen states how many candidates it removed in each declared stratum value (crisp, parameterised, vague), 0 where it removed none",
|
|
182
|
+
"screens[1].removed.parameterised: is missing — every screen states how many candidates it removed in each declared stratum value (crisp, parameterised, vague), 0 where it removed none",
|
|
183
|
+
"screens[1].removed.vague: is missing — every screen states how many candidates it removed in each declared stratum value (crisp, parameterised, vague), 0 where it removed none",
|
|
184
|
+
]);
|
|
185
|
+
});
|
|
186
|
+
|
|
187
|
+
it("no screens at all is a declaration, not an omission: silent", () => {
|
|
188
|
+
expect(judged({ ...GOOD, screens: [] })).toEqual([]);
|
|
189
|
+
});
|
|
190
|
+
|
|
191
|
+
it("🔴 a stratum value that Object.prototype also has must be counted in `removed` itself", () => {
|
|
192
|
+
expect(
|
|
193
|
+
judged({
|
|
194
|
+
...GOOD,
|
|
195
|
+
strata: { by: "kind", values: ["constructor", "toString"] },
|
|
196
|
+
screens: [{ name: "R1", removed: {} }],
|
|
197
|
+
}),
|
|
198
|
+
).toEqual(
|
|
199
|
+
["constructor", "toString"].map(
|
|
200
|
+
(v) =>
|
|
201
|
+
`screens[0].removed.${v}: is missing — every screen states how many candidates it removed in each declared stratum value (constructor, toString), 0 where it removed none`,
|
|
202
|
+
),
|
|
203
|
+
);
|
|
204
|
+
});
|
|
205
|
+
});
|
|
206
|
+
|
|
207
|
+
describe("judgePrereg: the coverage", () => {
|
|
208
|
+
it("a null share needs its reason; with one it is silent", () => {
|
|
209
|
+
expect(judged(withField("coverage", { share: null }))).toEqual([
|
|
210
|
+
"coverage.reason: is missing — `share` is null, so say why the share cannot be stated",
|
|
211
|
+
]);
|
|
212
|
+
expect(
|
|
213
|
+
judged(
|
|
214
|
+
withField("coverage", {
|
|
215
|
+
share: null,
|
|
216
|
+
reason: "the census has no rule-type labels",
|
|
217
|
+
}),
|
|
218
|
+
),
|
|
219
|
+
).toEqual([]);
|
|
220
|
+
});
|
|
221
|
+
|
|
222
|
+
it("a share of 1 with strata it cannot reach contradicts itself; a share below 1 may list them", () => {
|
|
223
|
+
expect(
|
|
224
|
+
judged(withField("coverage", { share: 1, unreachable: ["vague"] })),
|
|
225
|
+
).toEqual([
|
|
226
|
+
"coverage.unreachable: contradicts `share` 1 — a sample that cannot reach «vague» does not represent the whole population: lower `share`, or empty the list",
|
|
227
|
+
]);
|
|
228
|
+
expect(
|
|
229
|
+
judged(withField("coverage", { share: 0.7, unreachable: ["vague"] })),
|
|
230
|
+
).toEqual([]);
|
|
231
|
+
});
|
|
232
|
+
});
|
|
233
|
+
|
|
234
|
+
describe("judgePrereg: within the strata and the check", () => {
|
|
235
|
+
it("a stratum value named twice", () => {
|
|
236
|
+
expect(
|
|
237
|
+
judged({
|
|
238
|
+
...GOOD,
|
|
239
|
+
strata: { by: "rule type", values: ["crisp", "vague", "crisp"] },
|
|
240
|
+
screens: [],
|
|
241
|
+
}),
|
|
242
|
+
).toEqual([
|
|
243
|
+
"strata.values: names «crisp» more than once — each stratum value once",
|
|
244
|
+
]);
|
|
245
|
+
});
|
|
246
|
+
|
|
247
|
+
it("more units seen than drawn", () => {
|
|
248
|
+
expect(judged(withField("author_check", { units_seen: 30 }))).toEqual([
|
|
249
|
+
"author_check.units_seen: is 30, more than the 24 units drawn (`units_drawn`)",
|
|
250
|
+
]);
|
|
251
|
+
});
|
|
252
|
+
|
|
253
|
+
it("a doubtful verdict without a decision is an error; with one, either way, it is silent", () => {
|
|
254
|
+
const missing = (verdict: string): string =>
|
|
255
|
+
`author_check.decision: is missing — the verdict is «${verdict}», and a doubtful verdict with no recorded decision is how a sample the author doubted gets run anyway: add \`proceed\` (true or false), \`by\` whom and \`why\``;
|
|
256
|
+
expect(judged(withField("author_check", { verdict: "partly" }))).toEqual([
|
|
257
|
+
missing("partly"),
|
|
258
|
+
]);
|
|
259
|
+
expect(
|
|
260
|
+
judged(withField("author_check", { verdict: "not-representative" })),
|
|
261
|
+
).toEqual([missing("not-representative")]);
|
|
262
|
+
const decided = (proceed: boolean): string[] =>
|
|
263
|
+
judged(
|
|
264
|
+
withField("author_check", {
|
|
265
|
+
verdict: "not-representative",
|
|
266
|
+
decision: {
|
|
267
|
+
proceed,
|
|
268
|
+
by: "first author",
|
|
269
|
+
why: "redrawn from the census",
|
|
270
|
+
},
|
|
271
|
+
}),
|
|
272
|
+
);
|
|
273
|
+
expect(decided(true)).toEqual([]);
|
|
274
|
+
expect(decided(false)).toEqual([]);
|
|
275
|
+
});
|
|
276
|
+
});
|
|
277
|
+
|
|
278
|
+
describe("preregProblems and the message", () => {
|
|
279
|
+
it("text that is not JSON: one problem, with the parser's reason", () => {
|
|
280
|
+
const problems = preregProblems("{ not json");
|
|
281
|
+
expect(problems.map((p) => p.at)).toEqual([""]);
|
|
282
|
+
expect(problems[0]?.text).toMatch(
|
|
283
|
+
/^does not parse as JSON \(.+\) — there is no declaration to read$/u,
|
|
284
|
+
);
|
|
285
|
+
});
|
|
286
|
+
|
|
287
|
+
it("JSON text is judged", () => {
|
|
288
|
+
expect(preregProblems(JSON.stringify(GOOD))).toEqual([]);
|
|
289
|
+
});
|
|
290
|
+
|
|
291
|
+
it("a path as written in the file", () => {
|
|
292
|
+
expect(pathText(["screens", 0, "removed", "vague"])).toBe(
|
|
293
|
+
"screens[0].removed.vague",
|
|
294
|
+
);
|
|
295
|
+
expect(pathText([])).toBe("");
|
|
296
|
+
});
|
|
297
|
+
|
|
298
|
+
it("a message names the file and the field, then why, then the rule's page", () => {
|
|
299
|
+
expect(
|
|
300
|
+
problemMessage("repro/s-2026-10-12/prereg.json", {
|
|
301
|
+
at: "coverage",
|
|
302
|
+
text: "is missing — add it",
|
|
303
|
+
}),
|
|
304
|
+
).toBe(
|
|
305
|
+
`\`repro/s-2026-10-12/prereg.json\`: \`coverage\` is missing — add it. ${WHY} Rule study/declare-sample: ${PAGE}`,
|
|
306
|
+
);
|
|
307
|
+
expect(
|
|
308
|
+
problemMessage("repro/s/prereg.json", { at: "", text: "is broken" }),
|
|
309
|
+
).toBe(
|
|
310
|
+
`\`repro/s/prereg.json\` is broken. ${WHY} Rule study/declare-sample: ${PAGE}`,
|
|
311
|
+
);
|
|
312
|
+
});
|
|
313
|
+
});
|
|
314
|
+
|
|
315
|
+
const root = useTempDir("prereg-coverage-");
|
|
316
|
+
|
|
317
|
+
describe("which files are judged", () => {
|
|
318
|
+
it("repro/ and its shared folders are the ones repro/allowed-files-only names", () => {
|
|
319
|
+
expect(REPRO_DIR).toBe(LAYOUT_REPRO_DIR);
|
|
320
|
+
expect(SHARED_DIRS).toEqual(REPRO_DIRS);
|
|
321
|
+
});
|
|
322
|
+
|
|
323
|
+
it("every study folder at the top of repro/ that holds a prereg.json, sorted; never tools/ or data/", () => {
|
|
324
|
+
const dir = writeTree(join(root, "files"), {
|
|
325
|
+
"repro/tools/prereg.json": "{}",
|
|
326
|
+
"repro/data/prereg.json": "{}",
|
|
327
|
+
"repro/Misnamed Study/prereg.json": "{}",
|
|
328
|
+
"repro/b-2026-10-12/prereg.json": "{}",
|
|
329
|
+
"repro/a-2026-10/prereg.json": "{}",
|
|
330
|
+
"repro/no-prereg-2026-10-01/README.md": "# s\n",
|
|
331
|
+
"repro/README.md": "# repro\n",
|
|
332
|
+
"repro/a-2026-10/deeper/prereg.json": "{}",
|
|
333
|
+
});
|
|
334
|
+
expect(preregFiles(dir)).toEqual([
|
|
335
|
+
"repro/Misnamed Study/prereg.json",
|
|
336
|
+
"repro/a-2026-10/prereg.json",
|
|
337
|
+
"repro/b-2026-10-12/prereg.json",
|
|
338
|
+
]);
|
|
339
|
+
});
|
|
340
|
+
|
|
341
|
+
it("no repro/, or a file named repro: nothing", () => {
|
|
342
|
+
expect(preregFiles(writeTree(join(root, "none"), { "x.md": "" }))).toEqual(
|
|
343
|
+
[],
|
|
344
|
+
);
|
|
345
|
+
expect(
|
|
346
|
+
preregFiles(writeTree(join(root, "file"), { repro: "not a folder" })),
|
|
347
|
+
).toEqual([]);
|
|
348
|
+
});
|
|
349
|
+
|
|
350
|
+
it("the findings of a paper: each file's problems, each as its message", () => {
|
|
351
|
+
const dir = writeTree(join(root, "findings"), {
|
|
352
|
+
"repro/ok-2026-10-01/prereg.json": JSON.stringify(GOOD),
|
|
353
|
+
"repro/bad-2026-10-02/prereg.json": JSON.stringify(
|
|
354
|
+
withField("coverage", { share: null }),
|
|
355
|
+
),
|
|
356
|
+
});
|
|
357
|
+
expect(paperFindings(dir)).toEqual([
|
|
358
|
+
`\`repro/bad-2026-10-02/prereg.json\`: \`coverage.reason\` is missing — \`share\` is null, so say why the share cannot be stated. ${WHY} Rule study/declare-sample: ${PAGE}`,
|
|
359
|
+
]);
|
|
360
|
+
});
|
|
361
|
+
|
|
362
|
+
it("🔴 a prereg.json that is a folder is a finding, not a crash of the whole lint run", () => {
|
|
363
|
+
const dir = writeTree(join(root, "folder-prereg"), {
|
|
364
|
+
"repro/s-2026-10-01/prereg.json/x.txt": "",
|
|
365
|
+
});
|
|
366
|
+
expect(paperFindings(dir)).toEqual([
|
|
367
|
+
`\`repro/s-2026-10-01/prereg.json\` is not a file — the declaration is one JSON file; move what the folder holds elsewhere. ${WHY} Rule study/declare-sample: ${PAGE}`,
|
|
368
|
+
]);
|
|
369
|
+
});
|
|
370
|
+
});
|
|
371
|
+
|
|
372
|
+
describe("which files are judged: through a link", () => {
|
|
373
|
+
it("🔴 a study folder linked into repro/ is judged like any other: the layout rule follows the link too", () => {
|
|
374
|
+
const dir = writeTree(join(root, "linked"), {
|
|
375
|
+
"elsewhere/s-2026-10-01/prereg.json": "{}",
|
|
376
|
+
"repro/README.md": "# repro\n",
|
|
377
|
+
});
|
|
378
|
+
symlinkSync(
|
|
379
|
+
join(dir, "elsewhere", "s-2026-10-01"),
|
|
380
|
+
join(dir, "repro", "s-2026-10-01"),
|
|
381
|
+
);
|
|
382
|
+
expect(preregFiles(dir)).toEqual(["repro/s-2026-10-01/prereg.json"]);
|
|
383
|
+
});
|
|
384
|
+
|
|
385
|
+
it("a link in repro/ to nothing is passed over, not a crash", () => {
|
|
386
|
+
const dir = writeTree(join(root, "dangling"), {
|
|
387
|
+
"repro/README.md": "# repro\n",
|
|
388
|
+
});
|
|
389
|
+
symlinkSync(join(dir, "gone"), join(dir, "repro", "s-2026-10-02"));
|
|
390
|
+
expect(preregFiles(dir)).toEqual([]);
|
|
391
|
+
});
|
|
392
|
+
});
|
|
393
|
+
|
|
394
|
+
describe("the rule, on the status file of a paper", () => {
|
|
395
|
+
const rule = preregCoverage.rules["declare-sample"];
|
|
396
|
+
/** What the rule reports for `file` in `dir`, each finding with its line and its whole text. */
|
|
397
|
+
const lint = (dir: string, file = "PIPELINE-STATUS.md") => {
|
|
398
|
+
const out: { line: number; messageId: string; message: string }[] = [];
|
|
399
|
+
rule
|
|
400
|
+
.create({
|
|
401
|
+
filename: join(dir, file),
|
|
402
|
+
options: [],
|
|
403
|
+
settings: {},
|
|
404
|
+
sourceCode: {
|
|
405
|
+
text: "# Status\n",
|
|
406
|
+
getLocFromIndex: (i) => ({ line: i + 1, column: 0 }),
|
|
407
|
+
getText: () => "",
|
|
408
|
+
},
|
|
409
|
+
report: (d) => {
|
|
410
|
+
out.push({
|
|
411
|
+
line: d.loc?.start.line ?? 0,
|
|
412
|
+
messageId: d.messageId,
|
|
413
|
+
message: rule.meta.messages.coverage.replace(
|
|
414
|
+
"{{text}}",
|
|
415
|
+
String(d.data?.["text"]),
|
|
416
|
+
),
|
|
417
|
+
});
|
|
418
|
+
},
|
|
419
|
+
})
|
|
420
|
+
.root?.();
|
|
421
|
+
return out;
|
|
422
|
+
};
|
|
423
|
+
|
|
424
|
+
it("reports each problem on the status file's first line", () => {
|
|
425
|
+
const dir = writeTree(join(root, "lint-bad"), {
|
|
426
|
+
"repro/s-2026-10-12/prereg.json": "[]",
|
|
427
|
+
});
|
|
428
|
+
expect(lint(dir)).toEqual([
|
|
429
|
+
{
|
|
430
|
+
line: 1,
|
|
431
|
+
messageId: "coverage",
|
|
432
|
+
message: `\`repro/s-2026-10-12/prereg.json\` is not valid — it must be a JSON object holding \`population\`, \`strata\`, \`screens\`, \`coverage\` and \`author_check\`. ${WHY} Rule study/declare-sample: ${PAGE}`,
|
|
433
|
+
},
|
|
434
|
+
]);
|
|
435
|
+
});
|
|
436
|
+
|
|
437
|
+
it("silent on a complete declaration, and on any file but the status file", () => {
|
|
438
|
+
const dir = writeTree(join(root, "lint-good"), {
|
|
439
|
+
"repro/s-2026-10-12/prereg.json": JSON.stringify(GOOD),
|
|
440
|
+
"repro/t-2026-10-12/prereg.json": "{}",
|
|
441
|
+
});
|
|
442
|
+
expect(lint(dir, "NOTES.md")).toEqual([]);
|
|
443
|
+
const good = writeTree(join(root, "lint-good-only"), {
|
|
444
|
+
"repro/s-2026-10-12/prereg.json": JSON.stringify(GOOD),
|
|
445
|
+
});
|
|
446
|
+
expect(lint(good)).toEqual([]);
|
|
447
|
+
});
|
|
448
|
+
});
|