paperlint 2.1.0 → 3.0.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/CLAUDE.md +6 -1
- package/README.md +233 -224
- package/action.yml +4 -6
- package/dist/adapters/references/index.d.ts +13 -0
- package/dist/adapters/references/index.d.ts.map +1 -0
- package/dist/adapters/references/index.js +55 -0
- package/dist/adapters/references/index.js.map +1 -0
- package/dist/adapters/references/reach.io.d.ts +3 -0
- package/dist/adapters/references/reach.io.d.ts.map +1 -0
- package/dist/adapters/references/reach.io.js +14 -0
- package/dist/adapters/references/reach.io.js.map +1 -0
- package/dist/build.d.ts +15 -0
- package/dist/build.d.ts.map +1 -1
- package/dist/build.js +38 -4
- package/dist/build.js.map +1 -1
- package/dist/cli.d.ts +28 -25
- package/dist/cli.d.ts.map +1 -1
- package/dist/cli.js +277 -265
- package/dist/cli.js.map +1 -1
- package/dist/doctor.d.ts.map +1 -1
- package/dist/doctor.js +40 -72
- package/dist/doctor.js.map +1 -1
- package/dist/facts-file.d.ts +2 -2
- package/dist/facts-file.js +2 -2
- package/dist/hooks-settings.d.ts +0 -21
- package/dist/hooks-settings.d.ts.map +1 -1
- package/dist/hooks-settings.js +17 -135
- package/dist/hooks-settings.js.map +1 -1
- package/dist/init.d.ts +39 -29
- package/dist/init.d.ts.map +1 -1
- package/dist/init.js +143 -172
- package/dist/init.js.map +1 -1
- package/dist/link-skills.d.ts +1 -1
- package/dist/link-skills.d.ts.map +1 -1
- package/dist/link-skills.js +11 -29
- package/dist/link-skills.js.map +1 -1
- package/dist/new-paper.d.ts.map +1 -1
- package/dist/new-paper.js +2 -13
- package/dist/new-paper.js.map +1 -1
- package/dist/paper-files.d.ts +63 -0
- package/dist/paper-files.d.ts.map +1 -0
- package/dist/paper-files.js +69 -0
- package/dist/paper-files.js.map +1 -0
- package/dist/paper-settings.d.ts +13 -41
- package/dist/paper-settings.d.ts.map +1 -1
- package/dist/paper-settings.js +71 -71
- package/dist/paper-settings.js.map +1 -1
- package/dist/ports/check-references.d.ts +31 -0
- package/dist/ports/check-references.d.ts.map +1 -0
- package/dist/ports/check-references.js +7 -0
- package/dist/ports/check-references.js.map +1 -0
- package/dist/presets.d.ts +1 -1
- package/dist/presets.d.ts.map +1 -1
- package/dist/presets.js +4 -6
- package/dist/presets.js.map +1 -1
- package/dist/reference-rules.d.ts +47 -0
- package/dist/reference-rules.d.ts.map +1 -0
- package/dist/reference-rules.js +156 -0
- package/dist/reference-rules.js.map +1 -0
- package/dist/references.d.ts +36 -0
- package/dist/references.d.ts.map +1 -0
- package/dist/references.js +91 -0
- package/dist/references.js.map +1 -0
- package/dist/rules-config.d.ts +5 -3
- package/dist/rules-config.d.ts.map +1 -1
- package/dist/rules-config.js +15 -6
- package/dist/rules-config.js.map +1 -1
- package/dist/structure.js +3 -3
- package/dist/structure.js.map +1 -1
- package/dist/tex-requirements.js +2 -2
- package/dist/tex-requirements.js.map +1 -1
- package/dist/types.d.ts +20 -18
- package/dist/types.d.ts.map +1 -1
- package/dist/venue-rules.d.ts.map +1 -1
- package/dist/venue-rules.js +11 -9
- package/dist/venue-rules.js.map +1 -1
- package/docs/configuration.md +119 -104
- package/docs/e2e.md +1 -1
- package/docs/install.md +25 -40
- package/docs/optional-rules.md +5 -4
- package/docs/rules.md +60 -23
- package/eslint-rules/bib-reachable-entry.mjs +75 -0
- package/eslint-rules/latex-language.mjs +29 -1
- package/eslint-rules/paper-stages.harness.mjs +1 -117
- package/eslint-rules/paper-stages.mjs +0 -120
- package/eslint-rules/paper-stages.mutations.mjs +0 -76
- package/eslint-rules/paper-typography.mjs +267 -192
- package/eslint-rules/paper-typography.test.mjs +297 -0
- package/eslint-rules/papers.harness.mjs +8 -12
- package/eslint-rules/papers.mjs +17 -31
- package/eslint-rules/pdf-last-page-balance.mjs +4 -7
- package/eslint-rules/review-frontmatter.mjs +118 -0
- package/eslint-rules/review-frontmatter.schema.json +36 -0
- package/eslint-rules/review-frontmatter.test.mjs +118 -0
- package/eslint-rules/sibling-frontmatter.mjs +19 -0
- package/eslint-rules/sibling-frontmatter.schema.json +14 -0
- package/eslint.config.mjs +0 -15
- package/fixtures/real-markdown-paper/baseline.json +6 -7
- package/hooks/hooks.harness.mjs +39 -98
- package/hooks/hooks.mutations.mjs +6 -6
- package/hooks/paper-edit-guard.hook.d.mts +2 -3
- package/hooks/paper-edit-guard.hook.mjs +31 -42
- package/hooks/paper-skills-nudge.hook.mjs +21 -19
- package/hooks/paper-status-gates.hook.mjs +21 -19
- package/hooks/paper-status-gates.sh +8 -13
- package/lib/paper-config.d.mts +15 -25
- package/lib/paper-config.harness.mjs +18 -35
- package/lib/paper-config.mjs +83 -105
- package/lib/paper-config.mutations.mjs +2 -2
- package/lib/skill-checks.mjs +1 -1
- package/lib/skill-trigger-cases.harness.mjs +9 -8
- package/lib/skill-trigger-cases.mjs +7 -15
- package/package.json +1 -1
- package/scripts/eslint-report-guard.mjs +1 -1
- package/scripts/harness-api.frozen.json +0 -3
- package/scripts/mutation-batteries.frozen.json +4 -13
- package/skills/analyze-sibling-paper/SKILL.md +16 -12
- package/skills/analyze-sibling-paper/SKILL.md.spec.ts +15 -11
- package/skills/cold-read-diff/SKILL.md +20 -1
- package/skills/cold-read-diff/SKILL.md.spec.ts +19 -0
- package/skills/grade-paper-writing/SKILL.md +20 -1
- package/skills/grade-paper-writing/SKILL.md.spec.ts +19 -0
- package/skills/paper-pipeline/scripts/consumer.d.mts +0 -1
- package/skills/paper-pipeline/scripts/consumer.harness.mjs +8 -12
- package/skills/paper-pipeline/scripts/consumer.mjs +19 -33
- package/skills/paper-pipeline/scripts/extract-ref-facts.harness.mjs +6 -4
- package/skills/paper-pipeline/scripts/run-mechanical.mjs +2 -11
- package/skills/plan-paper-timeline/SKILL.md +2 -2
- package/skills/plan-paper-timeline/SKILL.md.spec.ts +1 -1
- package/skills/plan-paper-timeline/plan-paper-timeline.effects.harness.mjs +1 -1
- package/skills/plan-paper-timeline/plan-paper-timeline.effects.mutations.mjs +2 -2
- package/skills/render-paper/check-render.sh +2 -3
- package/skills/render-paper/render-paper.harness.mjs +2 -2
- package/skills/verify-citations/SKILL.md +10 -1
- package/skills/verify-citations/SKILL.md.spec.ts +9 -0
- package/skills/verify-citations/scripts/bib-authors.mjs +48 -26
- package/src/adapters/references/index.ts +102 -0
- package/src/adapters/references/reach.io.ts +12 -0
- package/src/build.harness.mjs +8 -4
- package/src/build.ts +62 -6
- package/src/cli.harness.mjs +147 -292
- package/src/cli.mutations.mjs +35 -44
- package/src/cli.ts +344 -304
- package/src/doctor.harness.mjs +41 -84
- package/src/doctor.mutations.mjs +9 -9
- package/src/doctor.ts +44 -89
- package/src/facts-file.test.ts +0 -7
- package/src/facts-file.ts +2 -2
- package/src/hooks-settings.harness.mjs +7 -92
- package/src/hooks-settings.mutations.mjs +2 -17
- package/src/hooks-settings.ts +18 -162
- package/src/init.test.ts +206 -0
- package/src/init.ts +186 -230
- package/src/link-skills.harness.mjs +1 -38
- package/src/link-skills.mutations.mjs +9 -5
- package/src/link-skills.ts +10 -34
- package/src/lint-fix.test.ts +120 -0
- package/src/new-paper.test.ts +1 -13
- package/src/new-paper.ts +2 -17
- package/src/paper-files.test.ts +290 -0
- package/src/paper-files.ts +144 -0
- package/src/paper-settings-commands.test.ts +84 -121
- package/src/paper-settings.test.ts +93 -64
- package/src/paper-settings.ts +99 -97
- package/src/ports/check-references.ts +28 -0
- package/src/presets.ts +5 -15
- package/src/reference-rules.ts +229 -0
- package/src/references.test.ts +209 -0
- package/src/references.ts +138 -0
- package/src/rules-config.harness.mjs +24 -8
- package/src/rules-config.ts +19 -7
- package/src/structure.harness.mjs +3 -1
- package/src/structure.ts +3 -3
- package/src/tex-requirements.ts +2 -2
- package/src/types.ts +30 -18
- package/src/venue-rules.test.ts +20 -11
- package/src/venue-rules.ts +11 -13
- package/eslint-rules/doc-fields.harness.mjs +0 -336
- package/eslint-rules/doc-fields.mjs +0 -186
- package/eslint-rules/doc-fields.mutations.mjs +0 -96
- package/eslint-rules/paper-typography.harness.mjs +0 -291
- package/eslint-rules/paper-typography.mutations.mjs +0 -131
- package/eslint-rules/review-findings-cause.harness.mjs +0 -228
- package/eslint-rules/review-findings-cause.mjs +0 -135
- package/eslint-rules/review-findings-cause.mutations.mjs +0 -72
- package/fixtures/paper-stages/authors-ran/PIPELINE-STATUS.md +0 -16
- package/fixtures/paper-stages/marker-in-prose/PIPELINE-STATUS.md +0 -17
- package/fixtures/review-findings-cause/clean.md +0 -17
- package/fixtures/review-findings-cause/defect.md +0 -14
- package/fixtures/review-findings-cause/old-debt.md +0 -14
- package/fixtures/review-findings-cause/quiet-in-fence.md +0 -16
|
@@ -1,291 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Both halves for `paper/typography`, plus the halves the RATCHET needs — which are the ones a
|
|
3
|
-
* naive test forgets. A rule that only ever gets asserted "fires on the defective fixture" says
|
|
4
|
-
* nothing about the mechanism that actually decides whether a human sees it.
|
|
5
|
-
*
|
|
6
|
-
* Four sub-checks, and every one of them has a documented way of being silently wrong:
|
|
7
|
-
* sectionSign counted only the literal glyph once and returned ZERO on the paper whose
|
|
8
|
-
* reviewer raised it — the source writes the MACRO form
|
|
9
|
-
* bareDecimal must not eat an arXiv id, where the dot follows a digit
|
|
10
|
-
* figMixed fires only when BOTH spellings appear; either alone is a style, not a defect
|
|
11
|
-
* unreachable must accept url and arXiv id, not only doi — three venues issue no doi at all
|
|
12
|
-
* So each gets a fixture line that would flip it, and the clean fixture carries the near-misses.
|
|
13
|
-
*/
|
|
14
|
-
import assert from "node:assert/strict";
|
|
15
|
-
import { join, dirname } from "node:path";
|
|
16
|
-
import { fileURLToPath } from "node:url";
|
|
17
|
-
import { ESLint } from "eslint";
|
|
18
|
-
import markdown from "@eslint/markdown";
|
|
19
|
-
|
|
20
|
-
const HERE = dirname(fileURLToPath(import.meta.url));
|
|
21
|
-
const ROOT = join(HERE, "..");
|
|
22
|
-
const FIX = join(ROOT, "fixtures", "paper-typography");
|
|
23
|
-
|
|
24
|
-
const { texLanguage } = await import(join(HERE, "latex-language.mjs"));
|
|
25
|
-
const typographyPlugin = (await import(join(HERE, "paper-typography.mjs")))
|
|
26
|
-
.default;
|
|
27
|
-
|
|
28
|
-
let n = 0;
|
|
29
|
-
const check = (label, cond) => {
|
|
30
|
-
assert.ok(cond, label);
|
|
31
|
-
n++;
|
|
32
|
-
};
|
|
33
|
-
|
|
34
|
-
// A new rule in this module with no declared severity must not slip through unnoticed.
|
|
35
|
-
assert.deepEqual(
|
|
36
|
-
Object.keys(typographyPlugin.rules).sort(),
|
|
37
|
-
["typography"],
|
|
38
|
-
"the module's rule set changed — update the config and this harness",
|
|
39
|
-
);
|
|
40
|
-
|
|
41
|
-
const lint = async (file, debt) => {
|
|
42
|
-
const eslint = new ESLint({
|
|
43
|
-
cwd: FIX,
|
|
44
|
-
overrideConfigFile: true,
|
|
45
|
-
overrideConfig: [
|
|
46
|
-
{
|
|
47
|
-
files: ["**/*.tex"],
|
|
48
|
-
plugins: {
|
|
49
|
-
tex: { languages: { latex: texLanguage } },
|
|
50
|
-
paper: typographyPlugin,
|
|
51
|
-
},
|
|
52
|
-
language: "tex/latex",
|
|
53
|
-
rules: { "paper/typography": ["warn", { debt }] },
|
|
54
|
-
},
|
|
55
|
-
],
|
|
56
|
-
});
|
|
57
|
-
const [res] = await eslint.lintFiles([file]);
|
|
58
|
-
assert.deepEqual(
|
|
59
|
-
res.messages.filter((m) => m.fatal || !m.ruleId),
|
|
60
|
-
[],
|
|
61
|
-
`${file}: the language failed to parse or the rule threw — a linter crash, not a finding`,
|
|
62
|
-
);
|
|
63
|
-
return res.messages.map((m) => m.message);
|
|
64
|
-
};
|
|
65
|
-
|
|
66
|
-
const CLEAN = join(FIX, "clean-paper", "paper.tex");
|
|
67
|
-
const MESSY = join(FIX, "messy-paper", "paper.tex");
|
|
68
|
-
|
|
69
|
-
// ── half one: it FIRES, and on each of the four independently ──────────────────────────────
|
|
70
|
-
const fresh = await lint(MESSY, {});
|
|
71
|
-
check(
|
|
72
|
-
"fires on the section sign",
|
|
73
|
-
fresh.some((m) => m.includes("§")),
|
|
74
|
-
);
|
|
75
|
-
check(
|
|
76
|
-
"fires on the bare decimal",
|
|
77
|
-
fresh.some((m) => m.includes("leading zero")),
|
|
78
|
-
);
|
|
79
|
-
check(
|
|
80
|
-
"fires on mixed Fig./Figure",
|
|
81
|
-
fresh.some((m) => m.includes("mixed in one document")),
|
|
82
|
-
);
|
|
83
|
-
check(
|
|
84
|
-
"fires on unreachable bibliography entries",
|
|
85
|
-
fresh.some((m) => m.includes("bibliography entries")),
|
|
86
|
-
);
|
|
87
|
-
check("four sub-checks, four findings", fresh.length === 4);
|
|
88
|
-
|
|
89
|
-
// 🔴 The MACRO form is counted, not just the glyph. This is the half that was missing when the
|
|
90
|
-
// check first shipped: it returned zero on the very paper the reviewer wrote about.
|
|
91
|
-
const signs = fresh.find((m) => m.includes("§"));
|
|
92
|
-
check(
|
|
93
|
-
"the section sign count includes the macro form, not only the glyph",
|
|
94
|
-
/^3 ×/.test(signs),
|
|
95
|
-
);
|
|
96
|
-
|
|
97
|
-
// ── half two: it STAYS QUIET on clean input, including the near-misses ─────────────────────
|
|
98
|
-
const clean = await lint(CLEAN, {});
|
|
99
|
-
check("silent on a clean paper", clean.length === 0);
|
|
100
|
-
// Each near-miss is in the clean fixture on purpose; naming them keeps the reason alive.
|
|
101
|
-
check(
|
|
102
|
-
"an arXiv id is not a bare decimal",
|
|
103
|
-
!clean.some((m) => m.includes("leading zero")),
|
|
104
|
-
);
|
|
105
|
-
check(
|
|
106
|
-
"`Figure` used consistently is not a defect",
|
|
107
|
-
!clean.some((m) => m.includes("mixed in one document")),
|
|
108
|
-
);
|
|
109
|
-
check(
|
|
110
|
-
"url and arXiv id count as reachable, not only doi",
|
|
111
|
-
!clean.some((m) => m.includes("bibliography entries")),
|
|
112
|
-
);
|
|
113
|
-
|
|
114
|
-
// ── bareDecimal counts what the READER sees, and nothing else ─────────────────────────────
|
|
115
|
-
// The count used to be a regex over the raw source. On a real corpus it found 22 decimals and
|
|
116
|
-
// none was a defect: every one sat in markup (an option, a column spec, a comment, a listing, a
|
|
117
|
-
// tikz coordinate). The cases below pin both directions with EXACT counts — "some finding" would
|
|
118
|
-
// pass a counter that reads markup AND prose, which is precisely the old defect.
|
|
119
|
-
const INLINE = join(FIX, "inline-paper");
|
|
120
|
-
const lintSource = async (text, file) => {
|
|
121
|
-
const eslint = new ESLint({
|
|
122
|
-
cwd: FIX,
|
|
123
|
-
overrideConfigFile: true,
|
|
124
|
-
overrideConfig: [
|
|
125
|
-
{
|
|
126
|
-
files: ["**/*.tex"],
|
|
127
|
-
plugins: {
|
|
128
|
-
tex: { languages: { latex: texLanguage } },
|
|
129
|
-
paper: typographyPlugin,
|
|
130
|
-
},
|
|
131
|
-
language: "tex/latex",
|
|
132
|
-
rules: { "paper/typography": ["warn", { debt: {} }] },
|
|
133
|
-
},
|
|
134
|
-
{
|
|
135
|
-
files: ["**/*.md"],
|
|
136
|
-
plugins: { markdown, paper: typographyPlugin },
|
|
137
|
-
language: "markdown/gfm",
|
|
138
|
-
languageOptions: { frontmatter: "yaml" },
|
|
139
|
-
rules: { "paper/typography": ["warn", { debt: {} }] },
|
|
140
|
-
},
|
|
141
|
-
],
|
|
142
|
-
});
|
|
143
|
-
const [res] = await eslint.lintText(text, { filePath: join(INLINE, file) });
|
|
144
|
-
assert.deepEqual(
|
|
145
|
-
res.messages.filter((m) => m.fatal || !m.ruleId),
|
|
146
|
-
[],
|
|
147
|
-
`${file}: the language failed to parse or the rule threw on ${JSON.stringify(text)}`,
|
|
148
|
-
);
|
|
149
|
-
return res.messages.map((m) => m.message);
|
|
150
|
-
};
|
|
151
|
-
/** The bare-decimal count for one source, 0 when the rule is silent about it. */
|
|
152
|
-
const bare = async (text, file = "paper.tex") => {
|
|
153
|
-
const hit = (await lintSource(text, file)).find((m) =>
|
|
154
|
-
m.includes("leading zero"),
|
|
155
|
-
);
|
|
156
|
-
return hit ? Number(/^(\d+) ×/.exec(hit)[1]) : 0;
|
|
157
|
-
};
|
|
158
|
-
const doc = (body) =>
|
|
159
|
-
`\\documentclass{acmart}\n\\begin{document}\n${body}\n\\end{document}\n`;
|
|
160
|
-
|
|
161
|
-
// CAUGHT — prose, inline math (where p-values live) and a table cell. One each, exactly.
|
|
162
|
-
const caughtTex = [
|
|
163
|
-
["a p-value in inline math", "We call an effect significant at $p < .05$."],
|
|
164
|
-
["a p-value after other math", "(bugfix $-31\\%$, $p=.002$)"],
|
|
165
|
-
[
|
|
166
|
-
"a table cell",
|
|
167
|
-
"\\begin{tabular}{ll}\ntask & p \\\\\nbugfix & .037 \\\\\n\\end{tabular}",
|
|
168
|
-
],
|
|
169
|
-
["plain prose", "We use a threshold of .05 throughout."],
|
|
170
|
-
];
|
|
171
|
-
for (const [what, body] of caughtTex)
|
|
172
|
-
check(`LaTeX CAUGHT, exactly one: ${what}`, (await bare(doc(body))) === 1);
|
|
173
|
-
check(
|
|
174
|
-
"LaTeX CAUGHT: all four in one document count four",
|
|
175
|
-
(await bare(doc(caughtTex.map(([, b]) => b).join("\n\n")))) === 4,
|
|
176
|
-
);
|
|
177
|
-
|
|
178
|
-
// SILENT — markup the reader never sees as a number. Each alone must count ZERO.
|
|
179
|
-
const silentTex = [
|
|
180
|
-
["a figure width option", "\\includegraphics[width=.48\\columnwidth]{f}"],
|
|
181
|
-
[
|
|
182
|
-
"a tabular column spec",
|
|
183
|
-
"\\begin{tabular}{p{.25\\linewidth}l}\na & b \\\\\n\\end{tabular}",
|
|
184
|
-
],
|
|
185
|
-
["a comment", "Text.\n% p<.01 in the old draft\nMore text."],
|
|
186
|
-
["a listing", "\\begin{lstlisting}\nx = .25\n\\end{lstlisting}"],
|
|
187
|
-
["inline listing", "Call \\lstinline{.25} here."],
|
|
188
|
-
["a length argument", "A\\hspace{.3em}B\\hspace{.35em}C"],
|
|
189
|
-
["a macro definition body", "\\def\\x{.85}"],
|
|
190
|
-
[
|
|
191
|
-
"tikz coordinates",
|
|
192
|
-
"\\begin{tikzpicture}\\draw (.35,.65);\\end{tikzpicture}",
|
|
193
|
-
],
|
|
194
|
-
["an arXiv id", "See arXiv 2310.05736 for details."],
|
|
195
|
-
// unified-latex has no signature for these, so their arguments arrive as sibling nodes.
|
|
196
|
-
[
|
|
197
|
-
"an unsigned environment's width",
|
|
198
|
-
"\\begin{subfigure}[b]{.48\\textwidth}\nPanel.\n\\end{subfigure}",
|
|
199
|
-
],
|
|
200
|
-
[
|
|
201
|
-
"an unsigned table's column spec",
|
|
202
|
-
"\\begin{longtable}{p{.25\\linewidth}l}\na & b \\\\\n\\end{longtable}",
|
|
203
|
-
],
|
|
204
|
-
[
|
|
205
|
-
"an unknown macro's options",
|
|
206
|
-
"\\adjustbox{width=.48\\linewidth}{x} \\foo[scale=.75]{y}",
|
|
207
|
-
],
|
|
208
|
-
];
|
|
209
|
-
for (const [what, body] of silentTex)
|
|
210
|
-
check(`LaTeX SILENT: ${what}`, (await bare(doc(body))) === 0);
|
|
211
|
-
check(
|
|
212
|
-
"LaTeX SILENT: the preamble and the inline bibliography are not the reader's text",
|
|
213
|
-
(await bare(
|
|
214
|
-
"\\documentclass{acmart}\n\\renewcommand{\\arraystretch}{.85}\n" +
|
|
215
|
-
"\\begin{filecontents*}{refs.bib}\n@misc{k, note = {p = .05}, url = {https://x.org}}\n" +
|
|
216
|
-
"\\end{filecontents*}\n\\begin{document}\nNothing here.\n\\end{document}\n",
|
|
217
|
-
)) === 0,
|
|
218
|
-
);
|
|
219
|
-
check(
|
|
220
|
-
"LaTeX: every silent case plus one real p-value counts exactly one",
|
|
221
|
-
(await bare(
|
|
222
|
-
doc(
|
|
223
|
-
silentTex.map(([, b]) => b).join("\n\n") +
|
|
224
|
-
"\n\nThe effect held at $p=.002$.",
|
|
225
|
-
),
|
|
226
|
-
)) === 1,
|
|
227
|
-
);
|
|
228
|
-
|
|
229
|
-
check(
|
|
230
|
-
"LaTeX CAUGHT: the body of an unsigned environment is still read",
|
|
231
|
-
(await bare(
|
|
232
|
-
doc(
|
|
233
|
-
"\\begin{subfigure}{.48\\textwidth}\nHeld at $p=.05$.\n\\end{subfigure}",
|
|
234
|
-
),
|
|
235
|
-
)) === 1,
|
|
236
|
-
);
|
|
237
|
-
|
|
238
|
-
// Markdown, on the markdown AST: text nodes including table cells; code, html and front matter
|
|
239
|
-
// are not prose.
|
|
240
|
-
check(
|
|
241
|
-
"Markdown CAUGHT: a p-value in prose and one in a table cell count two",
|
|
242
|
-
(await bare(
|
|
243
|
-
"We call it significant at p<.05.\n\n| task | p |\n| --- | --- |\n| bugfix | .002 |\n",
|
|
244
|
-
"paper.md",
|
|
245
|
-
)) === 2,
|
|
246
|
-
);
|
|
247
|
-
check(
|
|
248
|
-
"Markdown SILENT: fenced code, inline code, html and front matter count zero",
|
|
249
|
-
(await bare(
|
|
250
|
-
"---\nthreshold: .05\n---\n\nRun `.25` here.\n\n```py\nx = .25\n```\n\n<!-- p<.01 -->\n",
|
|
251
|
-
"paper.md",
|
|
252
|
-
)) === 0,
|
|
253
|
-
);
|
|
254
|
-
|
|
255
|
-
// ── the RATCHET: three halves, because this is what decides if a human ever sees it ────────
|
|
256
|
-
const KEY = "messy-paper";
|
|
257
|
-
|
|
258
|
-
// (a) debt equal to the count → silence. Old sin, already recorded.
|
|
259
|
-
const paid = await lint(MESSY, {
|
|
260
|
-
[KEY]: { sectionSign: 3, bareDecimal: 2, figMixed: 1, unreachable: 2 },
|
|
261
|
-
});
|
|
262
|
-
check("known debt, unchanged, is silent", paid.length === 0);
|
|
263
|
-
|
|
264
|
-
// (b) debt ABOVE the count → still silent, and it does not re-baseline noisily. Lowering is free.
|
|
265
|
-
const lowered = await lint(MESSY, {
|
|
266
|
-
[KEY]: { sectionSign: 99, bareDecimal: 99, figMixed: 99, unreachable: 99 },
|
|
267
|
-
});
|
|
268
|
-
check("paying debt down is silent", lowered.length === 0);
|
|
269
|
-
|
|
270
|
-
// (c) debt BELOW the count → speaks, and says both numbers. A bare "3 ×" would not tell a
|
|
271
|
-
// reader whether anything changed, which is the entire point of the ratchet.
|
|
272
|
-
const grew = await lint(MESSY, {
|
|
273
|
-
[KEY]: { sectionSign: 1, bareDecimal: 99, figMixed: 99, unreachable: 99 },
|
|
274
|
-
});
|
|
275
|
-
check("growth over known debt is reported", grew.length === 1);
|
|
276
|
-
check("growth names the before and after", grew[0].includes("was 1, now 3"));
|
|
277
|
-
|
|
278
|
-
// (d) debt for ANOTHER paper must not silence this one — the key is the paper's own directory.
|
|
279
|
-
const wrongKey = await lint(MESSY, {
|
|
280
|
-
"clean-paper": {
|
|
281
|
-
sectionSign: 99,
|
|
282
|
-
bareDecimal: 99,
|
|
283
|
-
figMixed: 99,
|
|
284
|
-
unreachable: 99,
|
|
285
|
-
},
|
|
286
|
-
});
|
|
287
|
-
check("debt is keyed per paper, not shared", wrongKey.length === 4);
|
|
288
|
-
|
|
289
|
-
console.log(
|
|
290
|
-
`✓ ${String(n)} assertions passed — paper/typography, both halves and the ratchet`,
|
|
291
|
-
);
|
|
@@ -1,131 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Battery for `paper/typography` — mutations over DIFFERENT properties: the `§` count, the
|
|
3
|
-
* ratchet and its direction, and what the bare-decimal count is allowed to read.
|
|
4
|
-
*
|
|
5
|
-
* 🔴 Why the ratchet needs TWO mutations, not one. It has two halves, and they're opposite:
|
|
6
|
-
* declared debt must STAY SILENT (otherwise a legacy paper drowns out a new finding and the
|
|
7
|
-
* rule gets turned off), while growth must SPEAK (otherwise debt turns into a permit). A
|
|
8
|
-
* mutation removing the first leaves the second green and vice versa — so both are needed, and
|
|
9
|
-
* they must die on different asserts.
|
|
10
|
-
*
|
|
11
|
-
* ⚠️ What the battery does NOT check, and why: the accuracy of the counting regexes
|
|
12
|
-
* themselves. The harness's own fixtures hold those (an arXiv identifier is not a decimal
|
|
13
|
-
* fraction, consecutive `Figure`s are not a defect), and a mutation here would prove the
|
|
14
|
-
* fixture exists, not that the count is correct.
|
|
15
|
-
*
|
|
16
|
-
* The bare-decimal count is the exception, because what it gets wrong is not the lexeme but the
|
|
17
|
-
* INPUT: paperlint#44 was a correct regex run over the wrong text. So two cases below change what it
|
|
18
|
-
* reads — back to the raw source (the SILENT half must die), and with math dropped from the walk
|
|
19
|
-
* (the CAUGHT half must die, since math is where p-values live).
|
|
20
|
-
*/
|
|
21
|
-
import { fileURLToPath } from "node:url";
|
|
22
|
-
import { dirname, join, resolve } from "node:path";
|
|
23
|
-
import { runMutations } from "../lib/mutation-driver.mjs";
|
|
24
|
-
|
|
25
|
-
const HERE = dirname(fileURLToPath(import.meta.url));
|
|
26
|
-
const ROOT = resolve(HERE, "..");
|
|
27
|
-
const RULE = join(HERE, "paper-typography.mjs");
|
|
28
|
-
const HARNESS = join(HERE, "paper-typography.harness.mjs");
|
|
29
|
-
|
|
30
|
-
process.exit(
|
|
31
|
-
runMutations({
|
|
32
|
-
root: ROOT,
|
|
33
|
-
runner: "node",
|
|
34
|
-
cases: [
|
|
35
|
-
{
|
|
36
|
-
name: "the ratchet stops LETTING declared debt THROUGH",
|
|
37
|
-
harness: HARNESS,
|
|
38
|
-
expect: "known debt, unchanged, is silent",
|
|
39
|
-
disables:
|
|
40
|
-
"the 'debt stays silent' half — a legacy paper with 246 § characters starts going " +
|
|
41
|
-
"red on every run, and the rule gets turned off within a day, taking new findings with it",
|
|
42
|
-
edits: [
|
|
43
|
-
[
|
|
44
|
-
RULE,
|
|
45
|
-
"if (n <= before) continue; // known debt, unchanged or paid down",
|
|
46
|
-
"if (false) continue;",
|
|
47
|
-
],
|
|
48
|
-
],
|
|
49
|
-
},
|
|
50
|
-
{
|
|
51
|
-
name: "the ratchet stops CATCHING growth",
|
|
52
|
-
harness: HARNESS,
|
|
53
|
-
expect: "growth over known debt is reported",
|
|
54
|
-
disables:
|
|
55
|
-
"the second half — the one debt is declared for in the first place: without it a " +
|
|
56
|
-
"record in the debt file turns into a permanent permit rather than a mark of today's state",
|
|
57
|
-
// 🔴 NOT an unconditional `continue;`: that kills the rule entirely, and the harness dies
|
|
58
|
-
// on the very first "fires on §" assert — that is, a finding about the MUTATION, not
|
|
59
|
-
// about the protection. `before > 0` keeps the rule alive where there is no debt, and
|
|
60
|
-
// mutes EXACTLY growth over declared debt.
|
|
61
|
-
edits: [
|
|
62
|
-
[
|
|
63
|
-
RULE,
|
|
64
|
-
"if (n <= before) continue; // known debt, unchanged or paid down",
|
|
65
|
-
"if (before > 0) continue;",
|
|
66
|
-
],
|
|
67
|
-
],
|
|
68
|
-
},
|
|
69
|
-
{
|
|
70
|
-
name: "the `§` counter stops seeing the macro form",
|
|
71
|
-
harness: HARNESS,
|
|
72
|
-
expect:
|
|
73
|
-
"the section sign count includes the macro form, not only the glyph",
|
|
74
|
-
disables:
|
|
75
|
-
"exactly the case the counter was written for: in LaTeX the section sign is typeset " +
|
|
76
|
-
"as `\\S\\ref{…}`, not as the glyph, and a check that only counts the glyph reports " +
|
|
77
|
-
"clean on the very defect it was written to catch",
|
|
78
|
-
edits: [
|
|
79
|
-
[
|
|
80
|
-
RULE,
|
|
81
|
-
"(body.match(/§/g) || []).length +\n (body.match(/\\\\S(?=\\s*\\\\ref|~\\\\ref|\\d)/g) || []).length",
|
|
82
|
-
"(body.match(/§/g) || []).length",
|
|
83
|
-
],
|
|
84
|
-
],
|
|
85
|
-
},
|
|
86
|
-
{
|
|
87
|
-
name: "bareDecimal reads the RAW source again (the #44 defect)",
|
|
88
|
-
harness: HARNESS,
|
|
89
|
-
expect: "LaTeX SILENT: a figure width option",
|
|
90
|
-
disables:
|
|
91
|
-
"the reason the count walks the tree: over raw source the regex fires on option values, " +
|
|
92
|
-
"column specs, comments, listings and tikz coordinates — 22 findings, 0 real, on the " +
|
|
93
|
-
"corpus that prompted #44",
|
|
94
|
-
edits: [[RULE, "visibleRuns(context.sourceCode),", "[raw],"]],
|
|
95
|
-
},
|
|
96
|
-
{
|
|
97
|
-
name: "arguments unified-latex did not attach are read as prose again",
|
|
98
|
-
harness: HARNESS,
|
|
99
|
-
expect: "LaTeX SILENT: a macro definition body",
|
|
100
|
-
disables:
|
|
101
|
-
"the second path to the #44 defect: for `\\def`, an author's macro, `subfigure` or " +
|
|
102
|
-
"`longtable` the parser returns the arguments as sibling nodes, and a tree walk that " +
|
|
103
|
-
"trusts the tree reads `{.48\\textwidth}` as prose",
|
|
104
|
-
edits: [
|
|
105
|
-
[
|
|
106
|
-
RULE,
|
|
107
|
-
' while (nodes[j]?.type === "group") j++;\n return j - 1;',
|
|
108
|
-
" return i;",
|
|
109
|
-
],
|
|
110
|
-
],
|
|
111
|
-
},
|
|
112
|
-
{
|
|
113
|
-
name: "the tree walk stops entering math",
|
|
114
|
-
harness: HARNESS,
|
|
115
|
-
// The messy fixture's two decimals are both in math, so the first assertion to die is the
|
|
116
|
-
// plain "fires" one — which is the point: without math there is no bare decimal left.
|
|
117
|
-
expect: "fires on the bare decimal",
|
|
118
|
-
disables:
|
|
119
|
-
"the half the projection could never give: math is blanked there, and math is where " +
|
|
120
|
-
"`$p < .05$` lives — the exact form the reviewer flagged",
|
|
121
|
-
edits: [
|
|
122
|
-
[
|
|
123
|
-
RULE,
|
|
124
|
-
'n.type === "mathenv"\n )\n texRuns(n.content, true, out);',
|
|
125
|
-
'n.type === "mathenv"\n )\n void 0;',
|
|
126
|
-
],
|
|
127
|
-
],
|
|
128
|
-
},
|
|
129
|
-
],
|
|
130
|
-
}),
|
|
131
|
-
);
|
|
@@ -1,228 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* `review/findings-cause` — both halves on real ESLint, plus a case where the old
|
|
3
|
-
* implementation LIED.
|
|
4
|
-
*
|
|
5
|
-
* The rule came over from the consumer (`checkReviewFindingsCause` in `paper-lint.mjs`),
|
|
6
|
-
* where findings were counted by regexes over the text. The third fixture here is not
|
|
7
|
-
* decoration: a table inside a ``` fence is an EXAMPLE of the format, not a report, and
|
|
8
|
-
* the text-based counter recorded it as a finding. In the AST it's a `code` node, and
|
|
9
|
-
* `tableRow` does not exist inside it. That is, the move removed a whole class of bugs,
|
|
10
|
-
* and this fixture pins it down.
|
|
11
|
-
*/
|
|
12
|
-
import assert from "node:assert/strict";
|
|
13
|
-
import { dirname, join } from "node:path";
|
|
14
|
-
import { fileURLToPath } from "node:url";
|
|
15
|
-
import { ESLint } from "eslint";
|
|
16
|
-
import markdown from "@eslint/markdown";
|
|
17
|
-
import { recordCheck } from "vigiles";
|
|
18
|
-
import reviewRules from "./review-findings-cause.mjs";
|
|
19
|
-
|
|
20
|
-
const HERE = dirname(fileURLToPath(import.meta.url));
|
|
21
|
-
const FIX = join(HERE, "..", "fixtures", "review-findings-cause");
|
|
22
|
-
|
|
23
|
-
/** Same language as the consumer: the rule judges markdown, not lines of text. */
|
|
24
|
-
const eslint = new ESLint({
|
|
25
|
-
overrideConfigFile: true,
|
|
26
|
-
overrideConfig: [
|
|
27
|
-
{
|
|
28
|
-
files: ["**/*.md"],
|
|
29
|
-
plugins: { markdown, review: reviewRules },
|
|
30
|
-
language: "markdown/gfm",
|
|
31
|
-
languageOptions: { frontmatter: "yaml" },
|
|
32
|
-
rules: { "review/findings-cause": "error" },
|
|
33
|
-
},
|
|
34
|
-
],
|
|
35
|
-
});
|
|
36
|
-
|
|
37
|
-
const on = async (file) =>
|
|
38
|
-
(await eslint.lintFiles([join(FIX, file)]))[0].messages;
|
|
39
|
-
const cases = [];
|
|
40
|
-
|
|
41
|
-
// ── 1. FIRES: three findings, no cause analysis.
|
|
42
|
-
{
|
|
43
|
-
const m = await on("defect.md");
|
|
44
|
-
assert.equal(
|
|
45
|
-
m.length,
|
|
46
|
-
1,
|
|
47
|
-
`one finding was expected, got ${m.length}: ${JSON.stringify(m)}`,
|
|
48
|
-
);
|
|
49
|
-
assert.equal(m[0].ruleId, "review/findings-cause");
|
|
50
|
-
assert.match(
|
|
51
|
-
m[0].message,
|
|
52
|
-
/3 findings/,
|
|
53
|
-
"the message must name the NUMBER of findings",
|
|
54
|
-
);
|
|
55
|
-
assert.match(
|
|
56
|
-
m[0].message,
|
|
57
|
-
/PIPELINE/,
|
|
58
|
-
"and say the tool needs fixing, not the paragraph",
|
|
59
|
-
);
|
|
60
|
-
assert.equal(
|
|
61
|
-
m[0].line,
|
|
62
|
-
1,
|
|
63
|
-
"the finding is about the FILE, so the position is the start of the document",
|
|
64
|
-
);
|
|
65
|
-
cases.push(
|
|
66
|
-
"a report with findings and no cause analysis → a finding, the count is named",
|
|
67
|
-
);
|
|
68
|
-
}
|
|
69
|
-
|
|
70
|
-
// ── 2. STAYS SILENT on a report with an analysis. Without this half the rule is
|
|
71
|
-
// indistinguishable from one that always screams.
|
|
72
|
-
{
|
|
73
|
-
const m = await on("clean.md");
|
|
74
|
-
assert.deepEqual(
|
|
75
|
-
m,
|
|
76
|
-
[],
|
|
77
|
-
`on a report with "Cause:" the rule must stay silent, got: ${JSON.stringify(m)}`,
|
|
78
|
-
);
|
|
79
|
-
cases.push("the same report with a cause analysis → silence");
|
|
80
|
-
}
|
|
81
|
-
|
|
82
|
-
// ── 3. 🔴 STAYS SILENT on a TABLE INSIDE A FENCE — the case the old text-based version
|
|
83
|
-
// counted as findings. This is the move's payoff, shown as a test.
|
|
84
|
-
{
|
|
85
|
-
const m = await on("quiet-in-fence.md");
|
|
86
|
-
assert.deepEqual(
|
|
87
|
-
m,
|
|
88
|
-
[],
|
|
89
|
-
`a table inside a \`\`\` fence is an EXAMPLE of the format, not a report; the text-based ` +
|
|
90
|
-
`counter counted it, the AST must not. Got: ${JSON.stringify(m)}`,
|
|
91
|
-
);
|
|
92
|
-
cases.push(
|
|
93
|
-
"a table inside a fence → silence (the text-based version got this wrong)",
|
|
94
|
-
);
|
|
95
|
-
}
|
|
96
|
-
|
|
97
|
-
// ── 4. THRESHOLD — a consumer option, not a mechanism constant.
|
|
98
|
-
{
|
|
99
|
-
const strict = new ESLint({
|
|
100
|
-
overrideConfigFile: true,
|
|
101
|
-
overrideConfig: [
|
|
102
|
-
{
|
|
103
|
-
files: ["**/*.md"],
|
|
104
|
-
plugins: { markdown, review: reviewRules },
|
|
105
|
-
language: "markdown/gfm",
|
|
106
|
-
languageOptions: { frontmatter: "yaml" },
|
|
107
|
-
rules: { "review/findings-cause": ["error", { minFindings: 99 }] },
|
|
108
|
-
},
|
|
109
|
-
],
|
|
110
|
-
});
|
|
111
|
-
const m = (await strict.lintFiles([join(FIX, "defect.md")]))[0].messages;
|
|
112
|
-
assert.deepEqual(
|
|
113
|
-
m,
|
|
114
|
-
[],
|
|
115
|
-
"with a threshold above the finding count the rule must stay silent — the threshold is data",
|
|
116
|
-
);
|
|
117
|
-
cases.push(
|
|
118
|
-
"the threshold is passed as an option → with minFindings: 99, silence on the same file",
|
|
119
|
-
);
|
|
120
|
-
}
|
|
121
|
-
|
|
122
|
-
// ── 5. 🔴 "RULE FROM A DATE": an old report is known debt, not a finding. Without this
|
|
123
|
-
// option the rule would have opened at the first consumer with FORTY-NINE findings
|
|
124
|
-
// (measured: 84 reports, 0 new, 49 old), and a check that opens with a wall gets muted.
|
|
125
|
-
{
|
|
126
|
-
const dated = new ESLint({
|
|
127
|
-
overrideConfigFile: true,
|
|
128
|
-
overrideConfig: [
|
|
129
|
-
{
|
|
130
|
-
files: ["**/*.md"],
|
|
131
|
-
plugins: { markdown, review: reviewRules },
|
|
132
|
-
language: "markdown/gfm",
|
|
133
|
-
languageOptions: { frontmatter: "yaml" },
|
|
134
|
-
rules: {
|
|
135
|
-
"review/findings-cause": ["error", { sinceCreated: "2026-08-23" }],
|
|
136
|
-
},
|
|
137
|
-
},
|
|
138
|
-
],
|
|
139
|
-
});
|
|
140
|
-
const old = (await dated.lintFiles([join(FIX, "old-debt.md")]))[0].messages;
|
|
141
|
-
assert.deepEqual(
|
|
142
|
-
old,
|
|
143
|
-
[],
|
|
144
|
-
`a report older than the rule's date — debt, not a finding; got: ${JSON.stringify(old)}`,
|
|
145
|
-
);
|
|
146
|
-
|
|
147
|
-
// AND THE SECOND HALF OF THE OPTION: on a fresh report it must NOT exempt anything, or
|
|
148
|
-
// "rule from a date" turns into an off switch.
|
|
149
|
-
const fresh = (await dated.lintFiles([join(FIX, "defect.md")]))[0].messages;
|
|
150
|
-
assert.equal(
|
|
151
|
-
fresh.length,
|
|
152
|
-
1,
|
|
153
|
-
`a report AFTER the rule's date must be caught; got: ${JSON.stringify(fresh)}`,
|
|
154
|
-
);
|
|
155
|
-
cases.push(
|
|
156
|
-
"a report older than the rule's date → silence; a fresh one of the same shape → a finding",
|
|
157
|
-
);
|
|
158
|
-
}
|
|
159
|
-
|
|
160
|
-
recordCheck(cases.length);
|
|
161
|
-
console.log(`review/findings-cause: ${cases.length} cases:`);
|
|
162
|
-
for (const c of cases) console.log(` ok ${c}`);
|
|
163
|
-
|
|
164
|
-
// ── THE MARKER COMES FROM AN OPTION, IT IS NOT HARDCODED ──────────────────────────────────────
|
|
165
|
-
//
|
|
166
|
-
// The default has been English since 2026-09-17. Before that it was the Russian word
|
|
167
|
-
// "Причина:", and there was no way to change it: `causeMarker` was not threaded through the
|
|
168
|
-
// CLI, so an English-speaking user could not satisfy an `error`-level rule at all. Both halves:
|
|
169
|
-
// a) with a foreign marker in the options, a report using IT passes;
|
|
170
|
-
// b) the same report without the option — a finding. Otherwise "option accepted" is
|
|
171
|
-
// indistinguishable from "the rule stays silent".
|
|
172
|
-
//
|
|
173
|
-
// ⚠️ The fixture's shape is load-bearing: findings are counted by NUMBERED table rows (and
|
|
174
|
-
// bold list items), not by any list. The first draft of this block used a plain `- finding
|
|
175
|
-
// one`, the rule never fired, and BOTH halves passed vacuously.
|
|
176
|
-
{
|
|
177
|
-
const body = [
|
|
178
|
-
"---",
|
|
179
|
-
"created: 2026-09-01",
|
|
180
|
-
"---",
|
|
181
|
-
"",
|
|
182
|
-
"| # | what | where |",
|
|
183
|
-
"|---|---|---|",
|
|
184
|
-
"| 1 | a | §1 |",
|
|
185
|
-
"| 2 | b | §2 |",
|
|
186
|
-
"| 3 | c | §3 |",
|
|
187
|
-
"",
|
|
188
|
-
"Причина: the pipeline step that let them through.",
|
|
189
|
-
"",
|
|
190
|
-
].join("\n");
|
|
191
|
-
|
|
192
|
-
const lintWith = async (options) => {
|
|
193
|
-
const e = new ESLint({
|
|
194
|
-
overrideConfigFile: true,
|
|
195
|
-
overrideConfig: [
|
|
196
|
-
{
|
|
197
|
-
files: ["**/*.md"],
|
|
198
|
-
plugins: { markdown, review: reviewRules },
|
|
199
|
-
language: "markdown/gfm",
|
|
200
|
-
languageOptions: { frontmatter: "yaml" },
|
|
201
|
-
rules: { "review/findings-cause": ["error", options] },
|
|
202
|
-
},
|
|
203
|
-
],
|
|
204
|
-
});
|
|
205
|
-
return (
|
|
206
|
-
await e.lintText(body, { filePath: join(FIX, "option-probe.md") })
|
|
207
|
-
)[0].messages;
|
|
208
|
-
};
|
|
209
|
-
|
|
210
|
-
const withOpt = await lintWith({ minFindings: 3, causeMarker: "Причина:" });
|
|
211
|
-
assert.deepEqual(
|
|
212
|
-
withOpt,
|
|
213
|
-
[],
|
|
214
|
-
`the marker from the options must be accepted, got: ${JSON.stringify(withOpt)}`,
|
|
215
|
-
);
|
|
216
|
-
|
|
217
|
-
const withoutOpt = await lintWith({ minFindings: 3 });
|
|
218
|
-
assert.equal(
|
|
219
|
-
withoutOpt.length,
|
|
220
|
-
1,
|
|
221
|
-
"without the option the same marker is NOT counted as a cause — otherwise the option decides nothing",
|
|
222
|
-
);
|
|
223
|
-
assert.match(
|
|
224
|
-
withoutOpt[0].message,
|
|
225
|
-
/Cause:/,
|
|
226
|
-
"and the finding's text carries the ENGLISH default, not something absent from the file",
|
|
227
|
-
);
|
|
228
|
-
}
|