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
package/src/cli.ts
CHANGED
|
@@ -13,10 +13,8 @@
|
|
|
13
13
|
* uses: zernie/paperlint@<sha> ← action.yml
|
|
14
14
|
*
|
|
15
15
|
* ⚠️ THE BOUNDARY THIS UTILITY HAS NO RIGHT TO ERASE: the consumer's data stays with the consumer.
|
|
16
|
-
*
|
|
17
|
-
*
|
|
18
|
-
* `.claude/skills/verify-citations/...` into a rule's message. So they live in the consumer's
|
|
19
|
-
* `package.json`, under the `paperlint` key.
|
|
16
|
+
* Where the papers are and the project's own rule blocks — that is about ONE corpus, so it lives in
|
|
17
|
+
* the consumer's own `paperlint.json`, at the project root and in each paper.
|
|
20
18
|
*
|
|
21
19
|
* 🔴 WHY THE COMMAND IS CALLED `lint` AND NOT `check`. It does exactly what everyone else calls by
|
|
22
20
|
* that word: reads files, changes nothing, prints findings, exits non-zero. `check` is taken in the
|
|
@@ -26,7 +24,7 @@
|
|
|
26
24
|
* replaced it: silently breaking someone else's workflow is worse than asking them to fix a line.
|
|
27
25
|
*/
|
|
28
26
|
import { ESLint, type Linter } from "eslint";
|
|
29
|
-
import { readFileSync, existsSync } from "node:fs";
|
|
27
|
+
import { readFileSync, existsSync, statSync } from "node:fs";
|
|
30
28
|
import { createRequire } from "node:module";
|
|
31
29
|
import { spawnSync } from "node:child_process";
|
|
32
30
|
import { fileURLToPath } from "node:url";
|
|
@@ -46,12 +44,24 @@ import {
|
|
|
46
44
|
} from "./structure.ts";
|
|
47
45
|
import { buildPapers, papersIn, anyFailed, remedyFor, MAIN } from "./build.ts";
|
|
48
46
|
import { prepareEngine } from "./build-engine.ts";
|
|
49
|
-
import { runToolchain } from "./toolchain.ts";
|
|
47
|
+
import { cacheRoot, cachedTree, runToolchain } from "./toolchain.ts";
|
|
50
48
|
import { banalInstaller, parseBanalSettings } from "./adapters/banal/index.ts";
|
|
51
49
|
import { curlDownload } from "./adapters/curl/index.ts";
|
|
52
50
|
import { hostDirs, nodeAdapters, nodeFiles } from "./adapters/node/index.ts";
|
|
53
51
|
import { VENUE_RULE_LEVELS, venueRules } from "./venue-rules.ts";
|
|
54
|
-
import {
|
|
52
|
+
import {
|
|
53
|
+
paperRules,
|
|
54
|
+
stringFields,
|
|
55
|
+
type PaperSettings,
|
|
56
|
+
} from "./paper-settings.ts";
|
|
57
|
+
import { referenceRules, REFERENCE_RULE_LEVELS } from "./reference-rules.ts";
|
|
58
|
+
import { onlineReferences } from "./adapters/references/index.ts";
|
|
59
|
+
import {
|
|
60
|
+
narrowToOwners,
|
|
61
|
+
ownedPatterns,
|
|
62
|
+
ruleOwners,
|
|
63
|
+
scopeToOwned,
|
|
64
|
+
} from "./paper-files.ts";
|
|
55
65
|
import {
|
|
56
66
|
paperPreset,
|
|
57
67
|
paperPresetProblem,
|
|
@@ -78,13 +88,11 @@ import {
|
|
|
78
88
|
// The one source for the consumer's config key lives in the .mjs half of the package (the ESLint
|
|
79
89
|
// rules and the skill scripts import it too); its types are in lib/paper-config.d.mts.
|
|
80
90
|
import {
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
LEGACY_KEY_MESSAGE,
|
|
91
|
+
CONFIG_FILE,
|
|
92
|
+
DEFAULT_PAPERS_ROOT,
|
|
84
93
|
PAPERS_DIR_FIELD,
|
|
85
94
|
SETTINGS_KEYS,
|
|
86
|
-
|
|
87
|
-
renamedFieldMessage,
|
|
95
|
+
findProjectRoot,
|
|
88
96
|
} from "../lib/paper-config.mjs";
|
|
89
97
|
import {
|
|
90
98
|
parseRuleBlocks,
|
|
@@ -93,6 +101,7 @@ import {
|
|
|
93
101
|
unknownKeys,
|
|
94
102
|
type Parsed,
|
|
95
103
|
type RuleBlock,
|
|
104
|
+
type RuleEntry,
|
|
96
105
|
} from "./rules-config.ts";
|
|
97
106
|
export { init };
|
|
98
107
|
export { nextSteps } from "./init.ts";
|
|
@@ -106,9 +115,11 @@ import typography from "../eslint-rules/paper-typography.mjs";
|
|
|
106
115
|
// @ts-expect-error — an ESLint rule in .mjs, it has no types
|
|
107
116
|
import texBuild from "../eslint-rules/tex-build.mjs";
|
|
108
117
|
// @ts-expect-error — an ESLint rule in .mjs, it has no types
|
|
109
|
-
import
|
|
118
|
+
import bibReachable from "../eslint-rules/bib-reachable-entry.mjs";
|
|
119
|
+
// @ts-expect-error — an ESLint rule in .mjs, it has no types
|
|
120
|
+
import reviewFrontmatter from "../eslint-rules/review-frontmatter.mjs";
|
|
110
121
|
// @ts-expect-error — an ESLint rule in .mjs, it has no types
|
|
111
|
-
import
|
|
122
|
+
import siblingFrontmatter from "../eslint-rules/sibling-frontmatter.mjs";
|
|
112
123
|
// @ts-expect-error — an ESLint rule in .mjs, it has no types
|
|
113
124
|
import pdfRules from "../eslint-rules/pdf-last-page-balance.mjs";
|
|
114
125
|
|
|
@@ -127,7 +138,10 @@ const USAGE = `paperlint — machine-checkable gates for a paper kept in git
|
|
|
127
138
|
paper directory is ignored (--dry-run: print the plan only).
|
|
128
139
|
Compiles with paperlint's TeX Live, else one on PATH that has every
|
|
129
140
|
package the venue declares; on a terminal it offers to install
|
|
130
|
-
one, without a terminal it stops and names \`npx paperlint toolchain
|
|
141
|
+
one, without a terminal it stops and names \`npx paperlint toolchain\`.
|
|
142
|
+
Then checks the references online (the cited works exist, titles
|
|
143
|
+
and authors match) into _build/references.json — never failing
|
|
144
|
+
the build: without network it records "not checked"
|
|
131
145
|
npx paperlint toolchain [--check] install TeX Live with every package the venue presets declare
|
|
132
146
|
into ~/.cache/paperlint/texlive (PAPERLINT_TEXLIVE_DIR overrides); a second
|
|
133
147
|
run does nothing. --check: report what is missing, change nothing
|
|
@@ -143,48 +157,46 @@ init:
|
|
|
143
157
|
--format tex|md the new paper's source format; default tex
|
|
144
158
|
|
|
145
159
|
lint:
|
|
146
|
-
npx paperlint lint [paths…] [--config <file.json>] [--json]
|
|
160
|
+
npx paperlint lint [paths…] [--fix] [--config <file.json>] [--json]
|
|
147
161
|
|
|
148
162
|
<paths…> where your papers live, e.g. papers. Optional ONLY because the declaration
|
|
149
163
|
names it — one of the two must name the scope. There is no default
|
|
150
164
|
of ".": linting whatever happens to be in the checkout is how a green
|
|
151
165
|
report over a scope nobody chose gets produced.
|
|
166
|
+
--fix write every fix the rules offer (section signs, leading zeros, figure
|
|
167
|
+
references), then report what is left
|
|
152
168
|
--config <file> read the settings from this file instead of the discovered one
|
|
153
169
|
--json machine-readable findings on stdout, nothing else on it
|
|
154
170
|
--max-warnings <n> fail when warnings exceed n. Default -1: warnings never fail, because
|
|
155
171
|
most findings here are advisory and a gate that fails on advice gets muted
|
|
156
172
|
|
|
157
|
-
settings —
|
|
158
|
-
current directory, the way every other tool in the stack finds its config. \`--config\` names
|
|
159
|
-
another file of the same shape. \`papersDir\` is required; the rest is optional:
|
|
160
|
-
|
|
161
|
-
"paperlint": {
|
|
162
|
-
"papersDir": "papers",
|
|
163
|
-
"authorListCommand": "node scripts/bib-authors.mjs",
|
|
164
|
-
"typographyDebt": { "papers/my-paper": { "sectionSign": 12 } },
|
|
165
|
-
"docFields": { "read": { "values": ["full", "abstract", "none"] } },
|
|
166
|
-
"reviewSince": "2026-08-23",
|
|
167
|
-
"minFindings": 3,
|
|
168
|
-
"causeMarker": "Cause:",
|
|
169
|
-
"rules": [ { "files": ["papers/my-paper/**"],
|
|
170
|
-
"rules": { "pdf/last-page-balance": "error" } } ]
|
|
171
|
-
}
|
|
173
|
+
settings — paperlint.json, at two levels, one schema. Both are optional.
|
|
172
174
|
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
175
|
+
paperlint.json (the project root, beside package.json) — found by walking up from the
|
|
176
|
+
current directory. \`--config\` names another file of the same shape.
|
|
177
|
+
|
|
178
|
+
{
|
|
179
|
+
"papersDir": "papers",
|
|
180
|
+
"rules": [ { "files": ["papers/my-paper/**"],
|
|
181
|
+
"rules": { "pdf/last-page-balance": "error" } } ]
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
"papersDir" defaults to "papers". "extends", "kind" and "pdf" here are defaults for every
|
|
185
|
+
paper. "papersDir", "structure" and the skills' keys are allowed only here.
|
|
178
186
|
|
|
179
|
-
|
|
187
|
+
<papersDir>/<paper>/paperlint.json — one paper, merged over the root file:
|
|
180
188
|
|
|
181
|
-
|
|
189
|
+
{ "extends": "paperlint:aisec", "kind": "research", "rules": { "pdf/last-page-balance": "error" } }
|
|
182
190
|
|
|
183
191
|
"extends" names a venue preset: paperlint:<name> (shipped: acm-sigconf, agenticdev, aisec, realm)
|
|
184
192
|
or ./path.jsonc, relative to the paperlint.json. npm presets are not supported yet.
|
|
185
193
|
|
|
186
|
-
"rules"
|
|
187
|
-
|
|
194
|
+
"rules" is { "<rule>": "<severity>" } for every paper file in scope, or ESLint flat-config
|
|
195
|
+
blocks (files, ignores, rules) with globs relative to that file. Order, later wins: paperlint's
|
|
196
|
+
own, the venue preset's, the root file's, the paper's. Optional rules (off unless turned on):
|
|
197
|
+
pdf/last-page-balance. The venue rules (pdf/fresh, pdf/profile, pdf/fonts, pdf/geometry,
|
|
198
|
+
pdf/limits, pdf/body-size, pdf/measured) are on for every paper with a venue preset; set one to
|
|
199
|
+
"off" to skip it. An unknown key, in either file, is an error.
|
|
188
200
|
`;
|
|
189
201
|
|
|
190
202
|
/** The config the user would otherwise write by hand. The data comes from `opts`, the mechanism is here. */
|
|
@@ -193,7 +205,17 @@ export function buildConfig(
|
|
|
193
205
|
texLanguage: unknown,
|
|
194
206
|
): unknown[] {
|
|
195
207
|
const paperRules = { ...researchQuestion.rules, ...typography.rules };
|
|
196
|
-
|
|
208
|
+
// The reference rules judge `_build/references.json`, and only on `paper.tex`.
|
|
209
|
+
const texPaperRules = {
|
|
210
|
+
...paperRules,
|
|
211
|
+
...referenceRules({ files: nodeFiles }),
|
|
212
|
+
};
|
|
213
|
+
// Each typography rule reports every occurrence where it is, and fixes it (`--fix`).
|
|
214
|
+
const prose = {
|
|
215
|
+
"paper/research-question": "warn",
|
|
216
|
+
"paper/section-word": "warn",
|
|
217
|
+
"paper/leading-zero": "warn",
|
|
218
|
+
};
|
|
197
219
|
const md = {
|
|
198
220
|
language: "markdown/gfm",
|
|
199
221
|
languageOptions: { frontmatter: "yaml" },
|
|
@@ -228,51 +250,30 @@ export function buildConfig(
|
|
|
228
250
|
rules: {
|
|
229
251
|
"paper/stages": "error",
|
|
230
252
|
"paper/source": "error",
|
|
231
|
-
"paper/author-list": [
|
|
232
|
-
"warn",
|
|
233
|
-
opts.authorListCommand ? { command: opts.authorListCommand } : {},
|
|
234
|
-
],
|
|
235
253
|
},
|
|
236
254
|
},
|
|
237
255
|
{
|
|
238
256
|
files: ["**/paper.md", "**/draft.md"],
|
|
239
257
|
plugins: { markdown, paper: { rules: paperRules } },
|
|
240
258
|
...md,
|
|
241
|
-
rules:
|
|
242
|
-
"paper/research-question": "warn",
|
|
243
|
-
"paper/typography": typographyOpt,
|
|
244
|
-
},
|
|
259
|
+
rules: prose,
|
|
245
260
|
},
|
|
246
261
|
{
|
|
247
262
|
files: ["**/reviews/*.md"],
|
|
248
|
-
plugins: {
|
|
249
|
-
markdown,
|
|
250
|
-
review: { rules: { ...findingsCause.rules } },
|
|
251
|
-
doc: docFields,
|
|
252
|
-
},
|
|
263
|
+
plugins: { markdown, review: reviewFrontmatter },
|
|
253
264
|
...md,
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
{
|
|
267
|
-
minFindings: opts.minFindings ?? 3,
|
|
268
|
-
...(opts.causeMarker ? { causeMarker: opts.causeMarker } : {}),
|
|
269
|
-
...(opts.reviewSince ? { sinceCreated: opts.reviewSince } : {}),
|
|
270
|
-
},
|
|
271
|
-
],
|
|
272
|
-
...(opts.docFields
|
|
273
|
-
? { "doc/fields": ["warn", { fields: opts.docFields }] }
|
|
274
|
-
: {}),
|
|
275
|
-
},
|
|
265
|
+
// A review's frontmatter is a record, validated by paperlint's JSON Schema
|
|
266
|
+
// (eslint-rules/review-frontmatter.mjs).
|
|
267
|
+
rules: { "review/frontmatter": "error" },
|
|
268
|
+
},
|
|
269
|
+
{
|
|
270
|
+
// A sibling card says how much of the competing paper was read (`read:`). The cards are
|
|
271
|
+
// written by this package's analyze-sibling-paper skill; its README is an index, not a card.
|
|
272
|
+
files: ["**/siblings/*.md"],
|
|
273
|
+
ignores: ["**/siblings/README.md"],
|
|
274
|
+
plugins: { markdown, sibling: siblingFrontmatter },
|
|
275
|
+
...md,
|
|
276
|
+
rules: { "sibling/frontmatter": "warn" },
|
|
276
277
|
},
|
|
277
278
|
];
|
|
278
279
|
|
|
@@ -283,21 +284,32 @@ export function buildConfig(
|
|
|
283
284
|
files: ["**/paper.tex"],
|
|
284
285
|
plugins: {
|
|
285
286
|
tex: { languages: { latex: texLanguage }, rules: texBuild },
|
|
286
|
-
paper: { rules:
|
|
287
|
+
paper: { rules: texPaperRules },
|
|
288
|
+
bib: bibReachable,
|
|
287
289
|
},
|
|
288
290
|
language: "tex/latex",
|
|
289
291
|
rules: {
|
|
290
|
-
|
|
291
|
-
|
|
292
|
+
...prose,
|
|
293
|
+
...REFERENCE_RULE_LEVELS,
|
|
294
|
+
"paper/figure-ref-style": "warn",
|
|
295
|
+
"bib/reachable-entry": "warn",
|
|
292
296
|
"tex/future-promise": "warn",
|
|
293
297
|
"tex/acm-frontmatter-override": "error",
|
|
294
298
|
// Silent for a paper whose paperlint.json names no venue (src/venue-rules.ts).
|
|
295
299
|
...VENUE_RULE_LEVELS,
|
|
296
300
|
},
|
|
297
301
|
});
|
|
302
|
+
// 🔴 ONLY THE FILES THESE BLOCKS CLAIM ARE LINTED (src/paper-files.ts). Without this block ESLint's
|
|
303
|
+
// built-in defaults lint every .js/.mjs/.cjs under the papers directory — a paper's vendored
|
|
304
|
+
// `repro/` code failed the run with 74 parse errors and not one finding on a paper file. It goes
|
|
305
|
+
// FIRST: the `.template/` ignore below must come after its directory un-ignore to win.
|
|
306
|
+
const owners = ruleOwners(cfg);
|
|
307
|
+
cfg.unshift(scopeToOwned(ownedPatterns(cfg)));
|
|
298
308
|
// The consumer's own blocks, LAST, so a later block wins — ESLint's rule. Parsed by
|
|
299
|
-
// `readConfig`; each carries the settings file's directory as its `basePath`.
|
|
300
|
-
|
|
309
|
+
// `readConfig`; each carries the settings file's directory as its `basePath`. Each is split so a
|
|
310
|
+
// rule reaches only the files its plugin is registered for: `paper` is a different plugin beside
|
|
311
|
+
// PIPELINE-STATUS.md than beside paper.tex, and ESLint throws on a rule its plugin lacks.
|
|
312
|
+
cfg.push(...(opts.rules ?? []).flatMap((b) => narrowToOwners(b, owners)));
|
|
301
313
|
return cfg;
|
|
302
314
|
}
|
|
303
315
|
|
|
@@ -359,34 +371,42 @@ export async function silentOptionalRules(
|
|
|
359
371
|
}
|
|
360
372
|
|
|
361
373
|
/**
|
|
362
|
-
* Every linted paper's
|
|
363
|
-
*
|
|
364
|
-
*
|
|
365
|
-
*
|
|
374
|
+
* Every linted paper's rules, as ESLint blocks scoped to that paper, in two groups: what its venue
|
|
375
|
+
* preset turns on, and what its own `paperlint.json` says. The caller puts the root's blocks between
|
|
376
|
+
* them. A file that does not parse, or names a rule paperlint does not ship, stops the run with one
|
|
377
|
+
* line naming the file — the same strictness as the root's own `rules`.
|
|
366
378
|
*/
|
|
367
|
-
export function paperRuleBlocks(
|
|
368
|
-
|
|
369
|
-
|
|
379
|
+
export function paperRuleBlocks(
|
|
380
|
+
paths: readonly string[],
|
|
381
|
+
): Parsed<{ preset: RuleBlock[]; own: RuleBlock[] }> {
|
|
382
|
+
// A FILE named on the command line belongs to the paper it sits in: that paper's settings apply.
|
|
383
|
+
const dirs = paths.map((p) =>
|
|
384
|
+
existsSync(p) && statSync(p).isFile() ? dirname(p) : p,
|
|
385
|
+
);
|
|
386
|
+
const papers = [...new Set(dirs.flatMap((p) => [p, ...papersIn(p)]))];
|
|
387
|
+
const out = { preset: [] as RuleBlock[], own: [] as RuleBlock[] };
|
|
370
388
|
for (const dir of papers) {
|
|
371
389
|
const p = paperPreset(dir, PRESET_DEPS);
|
|
372
390
|
if (p.kind === "settings-problem" && p.problem.kind === "broken")
|
|
373
391
|
return { ok: false, error: paperPresetProblem(dir, p) ?? dir };
|
|
374
|
-
const
|
|
375
|
-
if (!
|
|
376
|
-
|
|
392
|
+
const blocks = rulesOfPaper(dir, p);
|
|
393
|
+
if (!blocks.ok) return blocks;
|
|
394
|
+
out.preset.push(...blocks.value.preset);
|
|
395
|
+
out.own.push(...blocks.value.own);
|
|
377
396
|
}
|
|
378
397
|
return { ok: true, value: out };
|
|
379
398
|
}
|
|
380
399
|
|
|
381
400
|
/**
|
|
382
|
-
* One paper's
|
|
383
|
-
*
|
|
401
|
+
* One paper's blocks: its preset chain's `rules`, and its own — either `{ id: severity }` over the
|
|
402
|
+
* paper's files, or ESLint blocks with globs relative to the paper. A preset that does not resolve
|
|
403
|
+
* contributes nothing here; `pdf/profile` reports it on the paper.
|
|
384
404
|
*/
|
|
385
|
-
function rulesOfPaper(
|
|
405
|
+
function rulesOfPaper(
|
|
406
|
+
dir: string,
|
|
407
|
+
p: PaperPreset,
|
|
408
|
+
): Parsed<{ preset: RuleBlock[]; own: RuleBlock[] }> {
|
|
386
409
|
const settings = "settings" in p ? p.settings : null;
|
|
387
|
-
if (settings === null) return { ok: true, value: null };
|
|
388
|
-
const own = paperRules(dir, settings, SHIPPED_RULES);
|
|
389
|
-
if (!own.ok) return own;
|
|
390
410
|
const fromPreset =
|
|
391
411
|
p.kind === "resolved"
|
|
392
412
|
? parseRuleEntries(
|
|
@@ -396,12 +416,40 @@ function rulesOfPaper(dir: string, p: PaperPreset): Parsed<RuleBlock | null> {
|
|
|
396
416
|
)
|
|
397
417
|
: { ok: true as const, value: {} };
|
|
398
418
|
if (!fromPreset.ok) return fromPreset;
|
|
399
|
-
const
|
|
419
|
+
const own =
|
|
420
|
+
settings === null
|
|
421
|
+
? { ok: true as const, value: [] }
|
|
422
|
+
: Array.isArray(settings.rules)
|
|
423
|
+
? parseRuleBlocks(
|
|
424
|
+
settings.rules,
|
|
425
|
+
join(dir, CONFIG_FILE),
|
|
426
|
+
SHIPPED_RULES,
|
|
427
|
+
dir,
|
|
428
|
+
)
|
|
429
|
+
: ownRules(dir, settings);
|
|
430
|
+
if (!own.ok) return own;
|
|
431
|
+
const scoped = (rules: Record<string, RuleEntry>): RuleBlock[] =>
|
|
432
|
+
Object.keys(rules).length
|
|
433
|
+
? [{ basePath: dir, files: PAPER_FILE_PATTERNS, rules }]
|
|
434
|
+
: [];
|
|
400
435
|
return {
|
|
401
436
|
ok: true,
|
|
402
|
-
value:
|
|
403
|
-
|
|
404
|
-
|
|
437
|
+
value: { preset: scoped(fromPreset.value), own: [...own.value] },
|
|
438
|
+
};
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
/** A paper's `{ id: severity }` rules, as one block over the paper's files. */
|
|
442
|
+
function ownRules(
|
|
443
|
+
dir: string,
|
|
444
|
+
settings: PaperSettings,
|
|
445
|
+
): Parsed<readonly RuleBlock[]> {
|
|
446
|
+
const own = paperRules(dir, settings, SHIPPED_RULES);
|
|
447
|
+
if (!own.ok) return own;
|
|
448
|
+
return {
|
|
449
|
+
ok: true,
|
|
450
|
+
value: own.value
|
|
451
|
+
? [{ basePath: dir, files: PAPER_FILE_PATTERNS, rules: own.value }]
|
|
452
|
+
: [],
|
|
405
453
|
};
|
|
406
454
|
}
|
|
407
455
|
|
|
@@ -410,24 +458,24 @@ function rulesOfPaper(dir: string, p: PaperPreset): Parsed<RuleBlock | null> {
|
|
|
410
458
|
* config. A wider glob (everything under the paper) would make ESLint lint files no block gives a
|
|
411
459
|
* language — `paperlint.json` itself would be parsed as JavaScript.
|
|
412
460
|
*/
|
|
413
|
-
const PAPER_FILE_PATTERNS: string[] =
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
(b) => (b as { files?: string[] }).files ?? [],
|
|
417
|
-
),
|
|
418
|
-
),
|
|
419
|
-
];
|
|
461
|
+
const PAPER_FILE_PATTERNS: string[] = ownedPatterns(
|
|
462
|
+
buildConfig({}, { sentinel: "tex language" }),
|
|
463
|
+
);
|
|
420
464
|
|
|
421
465
|
/**
|
|
422
|
-
* The
|
|
423
|
-
*
|
|
466
|
+
* The root `paperlint.json` after the boundary: an unknown key is refused by name, the paper
|
|
467
|
+
* defaults (`extends`, `kind`, `pdf`) are checked the way a paper's own are, and `rules` becomes
|
|
468
|
+
* parsed config blocks. Nothing after this sees the raw object.
|
|
424
469
|
*/
|
|
425
470
|
export function parseSettings(
|
|
426
|
-
|
|
471
|
+
json: unknown,
|
|
427
472
|
where: string,
|
|
428
473
|
baseDir: string,
|
|
429
474
|
): Parsed<PaperlintConfig> {
|
|
430
|
-
|
|
475
|
+
if (typeof json !== "object" || json === null || Array.isArray(json))
|
|
476
|
+
return { ok: false, error: `${where}: must be a JSON object` };
|
|
477
|
+
const raw = json as Record<string, unknown>;
|
|
478
|
+
const opts = raw as PaperlintConfig;
|
|
431
479
|
const unknown = unknownKeys(raw);
|
|
432
480
|
if (unknown.length > 0)
|
|
433
481
|
return {
|
|
@@ -436,15 +484,17 @@ export function parseSettings(
|
|
|
436
484
|
`${where}: unknown key${unknown.length > 1 ? "s" : ""} ${unknown.map((k) => `"${k}"`).join(", ")} — ` +
|
|
437
485
|
`a typo would otherwise read as "not set". Known keys: ${Object.keys(SETTINGS_KEYS).join(", ")}`,
|
|
438
486
|
};
|
|
487
|
+
const defaults = stringFields(raw);
|
|
488
|
+
if (!defaults.ok) return { ok: false, error: `${where}: ${defaults.error}` };
|
|
439
489
|
const rules = parseRuleBlocks(raw["rules"], where, SHIPPED_RULES, baseDir);
|
|
440
490
|
if (!rules.ok) return rules;
|
|
441
491
|
return { ok: true, value: { ...opts, rules: rules.value } };
|
|
442
492
|
}
|
|
443
493
|
|
|
444
494
|
/**
|
|
445
|
-
* The directory ESLint runs from. ESLint ignores every file outside it (#48), and
|
|
446
|
-
* `
|
|
447
|
-
*
|
|
495
|
+
* The directory ESLint runs from. ESLint ignores every file outside it (#48), and the consumer's
|
|
496
|
+
* `rules` globs are written relative to the config's directory. So: the deepest directory holding
|
|
497
|
+
* the config's directory and every path. With the
|
|
448
498
|
* papers inside the config's directory, that is the config's directory itself.
|
|
449
499
|
*/
|
|
450
500
|
const lintRoot = (home: string, paths: readonly string[]): string =>
|
|
@@ -467,6 +517,7 @@ export function parseArgs(argv: readonly string[]): Args {
|
|
|
467
517
|
paths: [],
|
|
468
518
|
config: null,
|
|
469
519
|
json: false,
|
|
520
|
+
fix: false,
|
|
470
521
|
all: false,
|
|
471
522
|
dryRun: false,
|
|
472
523
|
check: false,
|
|
@@ -498,6 +549,7 @@ export function parseArgs(argv: readonly string[]): Args {
|
|
|
498
549
|
const a = rest[i];
|
|
499
550
|
if (a === undefined) continue;
|
|
500
551
|
if (a === "--json") out.json = true;
|
|
552
|
+
else if (a === "--fix") out.fix = true;
|
|
501
553
|
else if (a === "--all") out.all = true;
|
|
502
554
|
else if (a === "--dry-run") out.dryRun = true;
|
|
503
555
|
else if (a === "--check") out.check = true;
|
|
@@ -521,8 +573,6 @@ export function parseArgs(argv: readonly string[]): Args {
|
|
|
521
573
|
return out;
|
|
522
574
|
}
|
|
523
575
|
|
|
524
|
-
export const PKG_NAME = "package.json";
|
|
525
|
-
|
|
526
576
|
/**
|
|
527
577
|
* This package's own version, from the `package.json` beside `src/` and `dist/` alike. `init` pins
|
|
528
578
|
* the CI action to its release tag; an unreadable manifest yields `undefined`, and init then keeps
|
|
@@ -539,46 +589,17 @@ function ownVersion(): string | undefined {
|
|
|
539
589
|
}
|
|
540
590
|
}
|
|
541
591
|
|
|
542
|
-
/**
|
|
543
|
-
* 🔴 THE CLI READS `package.json`, THE ONE DECLARATION. `paperlint init` writes the settings under
|
|
544
|
-
* the `package.json` key that the three hooks and `eslint-rules` already read; the CLI reads the
|
|
545
|
-
* same file, so the install and the check cannot look at different files (issue #33,
|
|
546
|
-
* `docs/install.md`).
|
|
547
|
-
*
|
|
548
|
-
* The walk goes up to the filesystem root, the way eslint, prettier and tsc find theirs, so a run
|
|
549
|
-
* from inside one paper sees the same settings as a run from the repository root.
|
|
550
|
-
*/
|
|
551
|
-
export function findConfig(startDir: string): string | null {
|
|
552
|
-
let dir = resolve(startDir);
|
|
553
|
-
for (;;) {
|
|
554
|
-
const pkg = join(dir, PKG_NAME);
|
|
555
|
-
if (existsSync(pkg) && declaresSettings(pkg)) return pkg;
|
|
556
|
-
const up = dirname(dir);
|
|
557
|
-
if (up === dir) return null;
|
|
558
|
-
dir = up;
|
|
559
|
-
}
|
|
560
|
-
}
|
|
561
|
-
|
|
562
|
-
/**
|
|
563
|
-
* A `package.json` WITHOUT the key is not a declaration and must not stop the walk — every
|
|
564
|
-
* project on the way up has one, so stopping there would make the search find nothing, always.
|
|
565
|
-
* An unparsable one is treated the same way here; `paperlint doctor` is the command that reports it.
|
|
566
|
-
*/
|
|
567
|
-
const declaresSettings = (pkgPath: string): boolean => {
|
|
568
|
-
try {
|
|
569
|
-
const d = declaredSettings(JSON.parse(readFileSync(pkgPath, "utf8")));
|
|
570
|
-
return d.settings !== undefined || d.conflict !== null;
|
|
571
|
-
} catch {
|
|
572
|
-
return false;
|
|
573
|
-
}
|
|
574
|
-
};
|
|
575
|
-
|
|
576
592
|
/**
|
|
577
593
|
* Reading the config, ONE reader for all commands. Pulled out of `run()` the moment a second
|
|
578
594
|
* command needed the same config (`build`): two copies of this block would have drifted apart on
|
|
579
595
|
* the very first edit — exactly the class that already cost us the empty-set guard in two places.
|
|
580
596
|
*
|
|
581
|
-
*
|
|
597
|
+
* 🔴 THE CONFIG FINDS ITSELF: the project root is the nearest directory up from `cwd` with a root
|
|
598
|
+
* `paperlint.json`, else with a `package.json`, else `cwd` (`findProjectRoot`). The file is
|
|
599
|
+
* optional — without one every setting has its default, and papers are in `papers/`. An explicit
|
|
600
|
+
* `--config` beats the discovered one: it was named out loud, and a substitution is never silent.
|
|
601
|
+
*
|
|
602
|
+
* @returns `{ opts, configPath, root }` on success, or `{ code }` — and then the caller exits with it.
|
|
582
603
|
*/
|
|
583
604
|
export function readConfig(
|
|
584
605
|
a: Args,
|
|
@@ -592,85 +613,60 @@ export function readConfig(
|
|
|
592
613
|
cwd?: string;
|
|
593
614
|
} = {},
|
|
594
615
|
): ConfigRead {
|
|
595
|
-
|
|
596
|
-
// loud, and a substitution is never silent. Either way the file has the shape of a
|
|
597
|
-
// `package.json`: the settings sit under the key.
|
|
598
|
-
const configPath = a.config ?? findConfig(cwd);
|
|
599
|
-
if (a.config && !existsSync(a.config)) {
|
|
616
|
+
if (a.config && !existsSync(resolve(cwd, a.config))) {
|
|
600
617
|
err(`config file not found: ${a.config}`);
|
|
601
618
|
return { code: 2 };
|
|
602
619
|
}
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
}
|
|
615
|
-
const d = declaredSettings(parsed);
|
|
616
|
-
if (d.conflict !== null) {
|
|
617
|
-
err(d.conflict);
|
|
618
|
-
return { code: 2 };
|
|
619
|
-
}
|
|
620
|
-
opts = (d.settings ?? {}) as PaperlintConfig;
|
|
621
|
-
legacyKey = d.legacy;
|
|
622
|
-
// The discovered config is NAMED out loud. Otherwise a run from someone else's directory picks
|
|
623
|
-
// up someone else's file and does not say so — and a typography-debt mismatch looks like a finding.
|
|
624
|
-
//
|
|
625
|
-
// 🔴 IN `--json` MODE — TO stderr. Machine output must be ONE parsable document: a line before
|
|
626
|
-
// the array breaks any `| jq`, and it breaks it for the consumer, not for us. Caught not by a
|
|
627
|
-
// test but by an attempt to wire our own action to this output; in the harness I first WORKED
|
|
628
|
-
// AROUND this line (stripped the first line before JSON.parse) — that is, the workaround hid
|
|
629
|
-
// the defect exactly where it should have been shouting.
|
|
630
|
-
(a.json ? err : log)(`config: ${relative(cwd, configPath) || PKG_NAME}`);
|
|
631
|
-
if (legacyKey) (a.json ? err : log)(` ⚠ ${LEGACY_KEY_MESSAGE}`);
|
|
632
|
-
}
|
|
633
|
-
|
|
634
|
-
// The old field name is refused before anything else is read from the settings: falling back
|
|
635
|
-
// to it would keep it working forever, and this package has no released users to migrate.
|
|
636
|
-
const where = `${configPath ? basename(configPath) : PKG_NAME} → "${legacyKey ? LEGACY_CONFIG_KEY : CONFIG_KEY}"`;
|
|
637
|
-
const renamed = configPath ? renamedFieldMessage(opts, where) : null;
|
|
638
|
-
if (renamed) {
|
|
639
|
-
err(renamed);
|
|
620
|
+
const root = a.config
|
|
621
|
+
? dirname(resolve(cwd, a.config))
|
|
622
|
+
: findProjectRoot(cwd);
|
|
623
|
+
const found = a.config ? resolve(cwd, a.config) : join(root, CONFIG_FILE);
|
|
624
|
+
const configPath = existsSync(found) ? found : null;
|
|
625
|
+
if (configPath === null) return { opts: {}, configPath, root };
|
|
626
|
+
|
|
627
|
+
let parsed: unknown;
|
|
628
|
+
try {
|
|
629
|
+
parsed = JSON.parse(readFileSync(configPath, "utf8"));
|
|
630
|
+
} catch (e) {
|
|
631
|
+
err(`${configPath} is not valid JSON: ${(e as Error).message}`);
|
|
640
632
|
return { code: 2 };
|
|
641
633
|
}
|
|
642
|
-
|
|
643
|
-
|
|
644
|
-
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
|
|
650
|
-
|
|
651
|
-
|
|
652
|
-
|
|
653
|
-
|
|
654
|
-
|
|
655
|
-
// 🔴 THE PAPERS DIRECTORY IS A REQUIRED FIELD. The papers directory is the one thing without which the tool
|
|
656
|
-
// does not know what it works on, and the one thing that cannot be guessed: a default of "." runs
|
|
657
|
-
// the rules over the whole checkout and exits green over a scope nobody chose.
|
|
658
|
-
if (configPath && !hasPapers(opts)) {
|
|
659
|
-
err(
|
|
660
|
-
`${configPath} must declare \`${PAPERS_DIR_FIELD}\` — the directory your papers live in, e.g.\n` +
|
|
661
|
-
` { "${CONFIG_KEY}": { "${PAPERS_DIR_FIELD}": "papers" } }\n` +
|
|
662
|
-
`It is the one thing this tool cannot guess. \`npx paperlint init\` writes it for you.`,
|
|
663
|
-
);
|
|
634
|
+
// The discovered config is NAMED out loud. Otherwise a run from someone else's directory picks
|
|
635
|
+
// up someone else's file and does not say so — and its settings then look like findings.
|
|
636
|
+
//
|
|
637
|
+
// 🔴 IN `--json` MODE — TO stderr. Machine output must be ONE parsable document: a line before
|
|
638
|
+
// the array breaks any `| jq`, and it breaks it for the consumer, not for us.
|
|
639
|
+
(a.json ? err : log)(`config: ${relative(cwd, configPath) || CONFIG_FILE}`);
|
|
640
|
+
const settings = parseSettings(
|
|
641
|
+
parsed,
|
|
642
|
+
relative(cwd, configPath) || CONFIG_FILE,
|
|
643
|
+
root,
|
|
644
|
+
);
|
|
645
|
+
if (!settings.ok) {
|
|
646
|
+
err(settings.error);
|
|
664
647
|
return { code: 2 };
|
|
665
648
|
}
|
|
666
|
-
return { opts, configPath };
|
|
649
|
+
return { opts: settings.value, configPath, root };
|
|
667
650
|
}
|
|
668
651
|
|
|
669
|
-
/** The papers directory field of the settings, read by its one declared name. */
|
|
652
|
+
/** The papers directory field of the settings, read by its one declared name; the default when absent. */
|
|
670
653
|
export function papersDirOf(opts: PaperlintConfig): unknown {
|
|
671
|
-
|
|
654
|
+
const declared = (opts as Record<string, unknown>)[PAPERS_DIR_FIELD];
|
|
655
|
+
return declared === undefined ? DEFAULT_PAPERS_ROOT : declared;
|
|
672
656
|
}
|
|
673
657
|
|
|
658
|
+
/** The error for a papers directory that is missing or holds no paper. */
|
|
659
|
+
export const noPapersMessage = (dir: string): string =>
|
|
660
|
+
`no papers in ${dir}/ — create one with \`npx paperlint new <name>\`, or set "${PAPERS_DIR_FIELD}" ` +
|
|
661
|
+
`in ${CONFIG_FILE} if your papers live elsewhere`;
|
|
662
|
+
|
|
663
|
+
/** The papers directories of a read config, absolute: each relative to the project root. */
|
|
664
|
+
export const papersRoots = (cfg: {
|
|
665
|
+
opts: PaperlintConfig;
|
|
666
|
+
root: string;
|
|
667
|
+
}): string[] =>
|
|
668
|
+
toPaths(papersDirOf(cfg.opts)).map((rel) => resolve(cfg.root, rel));
|
|
669
|
+
|
|
674
670
|
/** The papers directory may be one directory or several; both spellings normalise to a list. */
|
|
675
671
|
export function toPaths(papers: unknown): string[] {
|
|
676
672
|
if (typeof papers === "string") return papers.trim() ? [papers.trim()] : [];
|
|
@@ -679,9 +675,6 @@ export function toPaths(papers: unknown): string[] {
|
|
|
679
675
|
return [];
|
|
680
676
|
}
|
|
681
677
|
|
|
682
|
-
const hasPapers = (opts: PaperlintConfig): boolean =>
|
|
683
|
-
toPaths(papersDirOf(opts)).length > 0;
|
|
684
|
-
|
|
685
678
|
/**
|
|
686
679
|
* `paperlint hook <name>` — run an editor hook. It exists for ONE thing: so that the wiring does not
|
|
687
680
|
* address the runtime from the project root.
|
|
@@ -828,14 +821,11 @@ async function runNew(
|
|
|
828
821
|
}
|
|
829
822
|
const cfg = readConfig({ ...a, json: false }, { log: () => {}, err, cwd });
|
|
830
823
|
if (cfg.code !== undefined) return cfg.code;
|
|
831
|
-
const roots =
|
|
832
|
-
resolve(dirname(cfg.configPath ?? cwd), rel),
|
|
833
|
-
);
|
|
824
|
+
const roots = papersRoots(cfg);
|
|
834
825
|
const papersRoot = roots[0];
|
|
835
|
-
if (
|
|
826
|
+
if (papersRoot === undefined) {
|
|
836
827
|
err(
|
|
837
|
-
`no
|
|
838
|
-
`Run \`npx paperlint init\` first — it declares the directory in package.json.`,
|
|
828
|
+
`"${PAPERS_DIR_FIELD}" names no directory, so there is nowhere to put \`${name}\`.`,
|
|
839
829
|
);
|
|
840
830
|
return 2;
|
|
841
831
|
}
|
|
@@ -863,16 +853,7 @@ async function runBuild(
|
|
|
863
853
|
): Promise<number> {
|
|
864
854
|
const cfg = readConfig(a, { log, err, cwd });
|
|
865
855
|
if (cfg.code !== undefined) return cfg.code;
|
|
866
|
-
const
|
|
867
|
-
// The key that used to name the scripts to run. It is read by nothing now; saying so beats a
|
|
868
|
-
// setting that silently stopped doing anything.
|
|
869
|
-
if (opts.buildScripts !== undefined)
|
|
870
|
-
log(
|
|
871
|
-
`note: "buildScripts" in ${relative(cwd, configPath ?? "") || "the settings"} is ignored — paperlint builds the paper itself`,
|
|
872
|
-
);
|
|
873
|
-
const roots = toPaths(papersDirOf(opts)).map((rel) =>
|
|
874
|
-
resolve(configPath ? dirname(configPath) : cwd, rel),
|
|
875
|
-
);
|
|
856
|
+
const roots = papersRoots(cfg);
|
|
876
857
|
|
|
877
858
|
let targets;
|
|
878
859
|
if (a.all) {
|
|
@@ -900,6 +881,7 @@ async function runBuild(
|
|
|
900
881
|
cwd,
|
|
901
882
|
dryRun: a.dryRun,
|
|
902
883
|
log,
|
|
884
|
+
checkReferences: onlineReferences,
|
|
903
885
|
engine: () => engineEnv(targets, a, { log, err }),
|
|
904
886
|
});
|
|
905
887
|
if (out.kind === "no-engine") return 1;
|
|
@@ -931,12 +913,7 @@ export function toolchainTex(cwd: string): TexRequirements {
|
|
|
931
913
|
err: () => {},
|
|
932
914
|
cwd,
|
|
933
915
|
});
|
|
934
|
-
const roots =
|
|
935
|
-
cfg.code === undefined
|
|
936
|
-
? toPaths(papersDirOf(cfg.opts)).map((rel) =>
|
|
937
|
-
resolve(dirname(cfg.configPath ?? cwd), rel),
|
|
938
|
-
)
|
|
939
|
-
: [];
|
|
916
|
+
const roots = cfg.code === undefined ? papersRoots(cfg) : [];
|
|
940
917
|
const chains = roots
|
|
941
918
|
.flatMap((r) => papersIn(r))
|
|
942
919
|
.map((dir) => paperPreset(dir, PRESET_DEPS))
|
|
@@ -1005,6 +982,61 @@ function hostBanalInstaller(): ToolInstaller {
|
|
|
1005
982
|
return banalInstaller({ ...ports, download }, s);
|
|
1006
983
|
}
|
|
1007
984
|
|
|
985
|
+
/** `paperlint init`: its flags checked here, the install itself in `init.ts`. */
|
|
986
|
+
async function runInit(
|
|
987
|
+
a: Args,
|
|
988
|
+
{
|
|
989
|
+
log,
|
|
990
|
+
err,
|
|
991
|
+
cwd,
|
|
992
|
+
}: { log: typeof console.log; err: typeof console.error; cwd: string },
|
|
993
|
+
): Promise<number> {
|
|
994
|
+
// Deferred, not implemented: named and refused, rather than read as the directory argument.
|
|
995
|
+
if (a.hooksMode !== null) {
|
|
996
|
+
err(
|
|
997
|
+
`--hooks=${a.hooksMode} is not implemented. init writes the hooks into .claude/settings.json ` +
|
|
998
|
+
`(shared, committed) or, with --no-hooks, nowhere.`,
|
|
999
|
+
);
|
|
1000
|
+
return 2;
|
|
1001
|
+
}
|
|
1002
|
+
if (a.format !== null && !isFormat(a.format)) {
|
|
1003
|
+
err(`--format must be one of ${FORMATS.join(", ")} — got \`${a.format}\``);
|
|
1004
|
+
return 2;
|
|
1005
|
+
}
|
|
1006
|
+
return await init(a.paths[0] ?? ".", {
|
|
1007
|
+
log,
|
|
1008
|
+
err,
|
|
1009
|
+
cwd,
|
|
1010
|
+
version: ownVersion(),
|
|
1011
|
+
yes: a.yes,
|
|
1012
|
+
hooks: !a.noHooks,
|
|
1013
|
+
paper: a.paper,
|
|
1014
|
+
format: isFormat(a.format) ? a.format : null,
|
|
1015
|
+
createPaper: (papersRoot, name, format) =>
|
|
1016
|
+
createPaperAt(papersRoot, name, format, { log, err, cwd }),
|
|
1017
|
+
tex: {
|
|
1018
|
+
installed: () => cachedTree(cacheRoot(process.env)) !== null,
|
|
1019
|
+
install: () =>
|
|
1020
|
+
runToolchain({
|
|
1021
|
+
check: false,
|
|
1022
|
+
log,
|
|
1023
|
+
err,
|
|
1024
|
+
banal: hostBanalInstaller(),
|
|
1025
|
+
tex: toolchainTex(resolve(cwd, a.paths[0] ?? ".")),
|
|
1026
|
+
}),
|
|
1027
|
+
},
|
|
1028
|
+
resolveCliPapers: (root: string): string | null => {
|
|
1029
|
+
const read = readConfig(
|
|
1030
|
+
{ ...a, config: null },
|
|
1031
|
+
{ log: () => {}, err: () => {}, cwd: root },
|
|
1032
|
+
);
|
|
1033
|
+
return read.code === undefined
|
|
1034
|
+
? (toPaths(papersDirOf(read.opts))[0] ?? null)
|
|
1035
|
+
: null;
|
|
1036
|
+
},
|
|
1037
|
+
});
|
|
1038
|
+
}
|
|
1039
|
+
|
|
1008
1040
|
export async function run(
|
|
1009
1041
|
argv: readonly string[],
|
|
1010
1042
|
{
|
|
@@ -1036,43 +1068,7 @@ export async function run(
|
|
|
1036
1068
|
// `init` asks the CLI's OWN reader what it would lint, so the two sides `doctor` compares are
|
|
1037
1069
|
// not two implementations of the same question. A second resolver here is the defect the
|
|
1038
1070
|
// comparison exists to catch.
|
|
1039
|
-
if (a.cmd === "init") {
|
|
1040
|
-
// Deferred, not implemented: named and refused, rather than read as the directory argument.
|
|
1041
|
-
if (a.hooksMode !== null) {
|
|
1042
|
-
err(
|
|
1043
|
-
`--hooks=${a.hooksMode} is not implemented. init writes the hooks into .claude/settings.json ` +
|
|
1044
|
-
`(shared, committed) or, with --no-hooks, nowhere.`,
|
|
1045
|
-
);
|
|
1046
|
-
return 2;
|
|
1047
|
-
}
|
|
1048
|
-
if (a.format !== null && !isFormat(a.format)) {
|
|
1049
|
-
err(
|
|
1050
|
-
`--format must be one of ${FORMATS.join(", ")} — got \`${a.format}\``,
|
|
1051
|
-
);
|
|
1052
|
-
return 2;
|
|
1053
|
-
}
|
|
1054
|
-
return await init(a.paths[0] ?? ".", {
|
|
1055
|
-
log,
|
|
1056
|
-
err,
|
|
1057
|
-
cwd,
|
|
1058
|
-
version: ownVersion(),
|
|
1059
|
-
yes: a.yes,
|
|
1060
|
-
hooks: !a.noHooks,
|
|
1061
|
-
paper: a.paper,
|
|
1062
|
-
format: isFormat(a.format) ? a.format : null,
|
|
1063
|
-
createPaper: (papersRoot, name, format) =>
|
|
1064
|
-
createPaperAt(papersRoot, name, format, { log, err, cwd }),
|
|
1065
|
-
resolveCliPapers: (root: string): string | null => {
|
|
1066
|
-
const read = readConfig(
|
|
1067
|
-
{ ...a, config: null },
|
|
1068
|
-
{ log: () => {}, err: () => {}, cwd: root },
|
|
1069
|
-
);
|
|
1070
|
-
return read.code === undefined
|
|
1071
|
-
? (toPaths(papersDirOf(read.opts))[0] ?? null)
|
|
1072
|
-
: null;
|
|
1073
|
-
},
|
|
1074
|
-
});
|
|
1075
|
-
}
|
|
1071
|
+
if (a.cmd === "init") return await runInit(a, { log, err, cwd });
|
|
1076
1072
|
// `doctor` reads the config but must NOT die on a broken one — reporting that the config is
|
|
1077
1073
|
// broken is precisely its job. So a failed read becomes "the CLI would lint nothing", which is
|
|
1078
1074
|
// what it prints, rather than an early exit that tells the reader nothing about the hooks.
|
|
@@ -1102,45 +1098,48 @@ export async function run(
|
|
|
1102
1098
|
|
|
1103
1099
|
const cfg = readConfig(a, { log, err, cwd });
|
|
1104
1100
|
if (cfg.code !== undefined) return cfg.code;
|
|
1105
|
-
const { opts,
|
|
1101
|
+
const { opts, root } = cfg;
|
|
1106
1102
|
|
|
1107
1103
|
// A command-line argument OVERRIDES the config: one paper out of the corpus gets linted without
|
|
1108
1104
|
// editing a file.
|
|
1109
1105
|
//
|
|
1110
|
-
// 🔴 A path FROM THE CONFIG is resolved relative to the
|
|
1111
|
-
// Otherwise walking up is pointless: from `papers/aisec-2026` the
|
|
1112
|
-
// `"papersDir": "papers"` would point at `papers/aisec-2026/papers
|
|
1113
|
-
// run would fail with "nothing found" where everything is in place. A command-line argument stays
|
|
1106
|
+
// 🔴 A path FROM THE CONFIG is resolved relative to the PROJECT ROOT, not the current directory.
|
|
1107
|
+
// Otherwise walking up is pointless: from `papers/aisec-2026` the root would be found, but
|
|
1108
|
+
// `"papersDir": "papers"` would point at `papers/aisec-2026/papers`. A command-line argument stays
|
|
1114
1109
|
// relative to the current directory: it was typed here and now.
|
|
1115
1110
|
//
|
|
1116
1111
|
// Both kinds end up ABSOLUTE: ESLint below runs from `lintRoot`, not from here, and would resolve a
|
|
1117
1112
|
// relative argument against the wrong directory.
|
|
1118
1113
|
const paths =
|
|
1119
|
-
a.paths.length > 0
|
|
1120
|
-
? a.paths.map((p) => resolve(cwd, p))
|
|
1121
|
-
: toPaths(papersDirOf(opts)).map((rel) =>
|
|
1122
|
-
resolve(dirname(configPath ?? cwd), rel),
|
|
1123
|
-
);
|
|
1114
|
+
a.paths.length > 0 ? a.paths.map((p) => resolve(cwd, p)) : papersRoots(cfg);
|
|
1124
1115
|
if (paths.length === 0) {
|
|
1125
1116
|
err(
|
|
1126
|
-
`nothing to lint:
|
|
1127
|
-
`Run \`npx paperlint init\` here, or pass the directory: \`paperlint lint papers\`.`,
|
|
1117
|
+
`nothing to lint: "${PAPERS_DIR_FIELD}" names no directory. Pass one: \`paperlint lint papers\`.`,
|
|
1128
1118
|
);
|
|
1129
1119
|
return 2;
|
|
1130
1120
|
}
|
|
1121
|
+
// 🔴 NO PAPERS WHERE THE CONFIG POINTS IS AN ERROR, NOT A CLEAN RUN. With the default in play the
|
|
1122
|
+
// directory may simply not exist yet; a run over nothing would be green and say nothing.
|
|
1123
|
+
if (a.paths.length === 0) {
|
|
1124
|
+
const empty = paths.find((p) => papersIn(p).length === 0);
|
|
1125
|
+
if (empty !== undefined) {
|
|
1126
|
+
err(noPapersMessage(relative(cwd, empty) || "."));
|
|
1127
|
+
return 2;
|
|
1128
|
+
}
|
|
1129
|
+
}
|
|
1131
1130
|
|
|
1132
1131
|
// 🔴 STRUCTURE IS CHECKED BEFORE ESLint AND SEPARATELY FROM IT. A rule is invoked for the file
|
|
1133
1132
|
// handed to it; a missing file is never handed over, so no rule at all can report the absence —
|
|
1134
1133
|
// a directory without `PIPELINE-STATUS.md` simply gets not a single rule and reports clean. The
|
|
1135
1134
|
// analysis of why a structure plugin for ESLint does not cure this is in `structure.mjs`.
|
|
1136
1135
|
const structure = checkStructure(paths, opts.structure, { cwd });
|
|
1137
|
-
//
|
|
1138
|
-
//
|
|
1136
|
+
// The order, most general first so the most specific wins (ESLint: a later block wins): each
|
|
1137
|
+
// paper's venue preset → the root paperlint.json → the paper's own paperlint.json.
|
|
1139
1138
|
const papers = paperRuleBlocks(paths);
|
|
1140
1139
|
if (!papers.ok) return (err(papers.error), 2);
|
|
1141
1140
|
const withPapers = {
|
|
1142
1141
|
...opts,
|
|
1143
|
-
rules: [...papers.value, ...(opts.rules ?? [])],
|
|
1142
|
+
rules: [...papers.value.preset, ...(opts.rules ?? []), ...papers.value.own],
|
|
1144
1143
|
};
|
|
1145
1144
|
|
|
1146
1145
|
let texLanguage: unknown = null;
|
|
@@ -1153,11 +1152,20 @@ export async function run(
|
|
|
1153
1152
|
}
|
|
1154
1153
|
|
|
1155
1154
|
const eslint = new ESLint({
|
|
1156
|
-
cwd: lintRoot(
|
|
1155
|
+
cwd: lintRoot(root, paths),
|
|
1157
1156
|
overrideConfigFile: true,
|
|
1158
1157
|
overrideConfig: buildConfig(withPapers, texLanguage) as Linter.Config[],
|
|
1158
|
+
fix: a.fix,
|
|
1159
1159
|
});
|
|
1160
1160
|
|
|
1161
|
+
const unowned = await firstUnownedFile(eslint, paths);
|
|
1162
|
+
if (unowned !== null) {
|
|
1163
|
+
err(
|
|
1164
|
+
`${relative(cwd, unowned) || unowned} is not a file paperlint lints — it lints ${PAPER_FILE_PATTERNS.join(", ")}`,
|
|
1165
|
+
);
|
|
1166
|
+
return 2;
|
|
1167
|
+
}
|
|
1168
|
+
|
|
1161
1169
|
// 🔴 ESLint THROWS on an empty set (`NoFilesFoundError`) — the guard below simply never got
|
|
1162
1170
|
// reached, which is what the very first run over an empty directory showed: instead of a clear
|
|
1163
1171
|
// message a stack from the depths of eslint-helpers.js flew out. A failure stays a failure, but
|
|
@@ -1167,15 +1175,13 @@ export async function run(
|
|
|
1167
1175
|
try {
|
|
1168
1176
|
results = await eslint.lintFiles(paths);
|
|
1169
1177
|
} catch (e) {
|
|
1170
|
-
|
|
1171
|
-
if (
|
|
1172
|
-
fail?.messageTemplate === "file-not-found" ||
|
|
1173
|
-
/No files matching/i.test(fail?.message ?? "")
|
|
1174
|
-
)
|
|
1175
|
-
results = [];
|
|
1178
|
+
if (isEmptySet(e)) results = [];
|
|
1176
1179
|
else throw e;
|
|
1177
1180
|
}
|
|
1178
1181
|
|
|
1182
|
+
// `--fix` writes what the rules fixed; the report below is what is LEFT.
|
|
1183
|
+
if (a.fix) await ESLint.outputFixes(results);
|
|
1184
|
+
|
|
1179
1185
|
// 🔴 THE GUARD AGAINST A GREEN ZERO, the same one as in action.yml and for the same reason:
|
|
1180
1186
|
// ESLint exits zero when there are no findings, and "no findings" is byte-for-byte
|
|
1181
1187
|
// indistinguishable from "not a single rule got a single file". A rule whose glob did not match
|
|
@@ -1191,11 +1197,45 @@ export async function run(
|
|
|
1191
1197
|
a,
|
|
1192
1198
|
log,
|
|
1193
1199
|
err,
|
|
1194
|
-
where: relative(cwd,
|
|
1200
|
+
where: relative(cwd, root) || ".",
|
|
1195
1201
|
opts: withPapers,
|
|
1196
1202
|
});
|
|
1197
1203
|
}
|
|
1198
1204
|
|
|
1205
|
+
/**
|
|
1206
|
+
* The first path that is a FILE paperlint does not lint, or null. ESLint would answer such a file
|
|
1207
|
+
* with a warning result ("File ignored because of a matching ignore pattern"), which counts as a
|
|
1208
|
+
* linted file and turns a run over nothing into a green one. The question is asked of ESLint's
|
|
1209
|
+
* own matcher, against the same scope that decides what a directory yields.
|
|
1210
|
+
*/
|
|
1211
|
+
async function firstUnownedFile(
|
|
1212
|
+
eslint: ESLint,
|
|
1213
|
+
paths: readonly string[],
|
|
1214
|
+
): Promise<string | null> {
|
|
1215
|
+
for (const p of paths)
|
|
1216
|
+
if (
|
|
1217
|
+
existsSync(p) &&
|
|
1218
|
+
statSync(p).isFile() &&
|
|
1219
|
+
(await eslint.isPathIgnored(p))
|
|
1220
|
+
)
|
|
1221
|
+
return p;
|
|
1222
|
+
return null;
|
|
1223
|
+
}
|
|
1224
|
+
|
|
1225
|
+
/**
|
|
1226
|
+
* ESLint's refusals of an empty set: nothing matched (`file-not-found`), or everything that matched
|
|
1227
|
+
* is outside paperlint's scope (`all-matched-files-ignored` — a papers directory holding only
|
|
1228
|
+
* vendored scripts). Both mean "nothing was linted", which the caller reports itself.
|
|
1229
|
+
*/
|
|
1230
|
+
const isEmptySet = (e: unknown): boolean => {
|
|
1231
|
+
const fail = e as { messageTemplate?: string; message?: string } | null;
|
|
1232
|
+
return (
|
|
1233
|
+
fail?.messageTemplate === "file-not-found" ||
|
|
1234
|
+
fail?.messageTemplate === "all-matched-files-ignored" ||
|
|
1235
|
+
/No files matching/i.test(fail?.message ?? "")
|
|
1236
|
+
);
|
|
1237
|
+
};
|
|
1238
|
+
|
|
1199
1239
|
/**
|
|
1200
1240
|
* The end of `paperlint lint`: refuse an optional rule that reached no paper, print the findings, and
|
|
1201
1241
|
* decide the exit code. Pulled out of `run` so each question has its own function.
|