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/dist/cli.js
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 } 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";
|
|
@@ -38,12 +36,15 @@ export { isMain };
|
|
|
38
36
|
import { checkStructure, formatStructure, asEslintResults, } from "./structure.js";
|
|
39
37
|
import { buildPapers, papersIn, anyFailed, remedyFor, MAIN } from "./build.js";
|
|
40
38
|
import { prepareEngine } from "./build-engine.js";
|
|
41
|
-
import { runToolchain } from "./toolchain.js";
|
|
39
|
+
import { cacheRoot, cachedTree, runToolchain } from "./toolchain.js";
|
|
42
40
|
import { banalInstaller, parseBanalSettings } from "./adapters/banal/index.js";
|
|
43
41
|
import { curlDownload } from "./adapters/curl/index.js";
|
|
44
42
|
import { hostDirs, nodeAdapters, nodeFiles } from "./adapters/node/index.js";
|
|
45
43
|
import { VENUE_RULE_LEVELS, venueRules } from "./venue-rules.js";
|
|
46
|
-
import { paperRules } from "./paper-settings.js";
|
|
44
|
+
import { paperRules, stringFields, } from "./paper-settings.js";
|
|
45
|
+
import { referenceRules, REFERENCE_RULE_LEVELS } from "./reference-rules.js";
|
|
46
|
+
import { onlineReferences } from "./adapters/references/index.js";
|
|
47
|
+
import { narrowToOwners, ownedPatterns, ruleOwners, scopeToOwned, } from "./paper-files.js";
|
|
47
48
|
import { paperPreset, paperPresetProblem, } from "./presets.js";
|
|
48
49
|
import { mergeRequirements, declaredUnion, requirementsFor, NO_REQUIREMENTS, } from "./tex-requirements.js";
|
|
49
50
|
import { doctor } from "./doctor.js";
|
|
@@ -51,7 +52,7 @@ import { init, processInteractivity, askOnTerminal } from "./init.js";
|
|
|
51
52
|
import { DEFAULT_FORMAT, FORMATS, isFormat, newPaper, reportNewPaper, } from "./new-paper.js";
|
|
52
53
|
// The one source for the consumer's config key lives in the .mjs half of the package (the ESLint
|
|
53
54
|
// rules and the skill scripts import it too); its types are in lib/paper-config.d.mts.
|
|
54
|
-
import {
|
|
55
|
+
import { CONFIG_FILE, DEFAULT_PAPERS_ROOT, PAPERS_DIR_FIELD, SETTINGS_KEYS, findProjectRoot, } from "../lib/paper-config.mjs";
|
|
55
56
|
import { parseRuleBlocks, parseRuleEntries, shippedRuleIds, unknownKeys, } from "./rules-config.js";
|
|
56
57
|
export { init };
|
|
57
58
|
export { nextSteps } from "./init.js";
|
|
@@ -64,9 +65,11 @@ import typography from "../eslint-rules/paper-typography.mjs";
|
|
|
64
65
|
// @ts-expect-error — an ESLint rule in .mjs, it has no types
|
|
65
66
|
import texBuild from "../eslint-rules/tex-build.mjs";
|
|
66
67
|
// @ts-expect-error — an ESLint rule in .mjs, it has no types
|
|
67
|
-
import
|
|
68
|
+
import bibReachable from "../eslint-rules/bib-reachable-entry.mjs";
|
|
68
69
|
// @ts-expect-error — an ESLint rule in .mjs, it has no types
|
|
69
|
-
import
|
|
70
|
+
import reviewFrontmatter from "../eslint-rules/review-frontmatter.mjs";
|
|
71
|
+
// @ts-expect-error — an ESLint rule in .mjs, it has no types
|
|
72
|
+
import siblingFrontmatter from "../eslint-rules/sibling-frontmatter.mjs";
|
|
70
73
|
// @ts-expect-error — an ESLint rule in .mjs, it has no types
|
|
71
74
|
import pdfRules from "../eslint-rules/pdf-last-page-balance.mjs";
|
|
72
75
|
const USAGE = `paperlint — machine-checkable gates for a paper kept in git
|
|
@@ -84,7 +87,10 @@ const USAGE = `paperlint — machine-checkable gates for a paper kept in git
|
|
|
84
87
|
paper directory is ignored (--dry-run: print the plan only).
|
|
85
88
|
Compiles with paperlint's TeX Live, else one on PATH that has every
|
|
86
89
|
package the venue declares; on a terminal it offers to install
|
|
87
|
-
one, without a terminal it stops and names \`npx paperlint toolchain
|
|
90
|
+
one, without a terminal it stops and names \`npx paperlint toolchain\`.
|
|
91
|
+
Then checks the references online (the cited works exist, titles
|
|
92
|
+
and authors match) into _build/references.json — never failing
|
|
93
|
+
the build: without network it records "not checked"
|
|
88
94
|
npx paperlint toolchain [--check] install TeX Live with every package the venue presets declare
|
|
89
95
|
into ~/.cache/paperlint/texlive (PAPERLINT_TEXLIVE_DIR overrides); a second
|
|
90
96
|
run does nothing. --check: report what is missing, change nothing
|
|
@@ -100,53 +106,61 @@ init:
|
|
|
100
106
|
--format tex|md the new paper's source format; default tex
|
|
101
107
|
|
|
102
108
|
lint:
|
|
103
|
-
npx paperlint lint [paths…] [--config <file.json>] [--json]
|
|
109
|
+
npx paperlint lint [paths…] [--fix] [--config <file.json>] [--json]
|
|
104
110
|
|
|
105
111
|
<paths…> where your papers live, e.g. papers. Optional ONLY because the declaration
|
|
106
112
|
names it — one of the two must name the scope. There is no default
|
|
107
113
|
of ".": linting whatever happens to be in the checkout is how a green
|
|
108
114
|
report over a scope nobody chose gets produced.
|
|
115
|
+
--fix write every fix the rules offer (section signs, leading zeros, figure
|
|
116
|
+
references), then report what is left
|
|
109
117
|
--config <file> read the settings from this file instead of the discovered one
|
|
110
118
|
--json machine-readable findings on stdout, nothing else on it
|
|
111
119
|
--max-warnings <n> fail when warnings exceed n. Default -1: warnings never fail, because
|
|
112
120
|
most findings here are advisory and a gate that fails on advice gets muted
|
|
113
121
|
|
|
114
|
-
settings —
|
|
115
|
-
current directory, the way every other tool in the stack finds its config. \`--config\` names
|
|
116
|
-
another file of the same shape. \`papersDir\` is required; the rest is optional:
|
|
122
|
+
settings — paperlint.json, at two levels, one schema. Both are optional.
|
|
117
123
|
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
"authorListCommand": "node scripts/bib-authors.mjs",
|
|
121
|
-
"typographyDebt": { "papers/my-paper": { "sectionSign": 12 } },
|
|
122
|
-
"docFields": { "read": { "values": ["full", "abstract", "none"] } },
|
|
123
|
-
"reviewSince": "2026-08-23",
|
|
124
|
-
"minFindings": 3,
|
|
125
|
-
"causeMarker": "Cause:",
|
|
126
|
-
"rules": [ { "files": ["papers/my-paper/**"],
|
|
127
|
-
"rules": { "pdf/last-page-balance": "error" } } ]
|
|
128
|
-
}
|
|
124
|
+
paperlint.json (the project root, beside package.json) — found by walking up from the
|
|
125
|
+
current directory. \`--config\` names another file of the same shape.
|
|
129
126
|
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
127
|
+
{
|
|
128
|
+
"papersDir": "papers",
|
|
129
|
+
"rules": [ { "files": ["papers/my-paper/**"],
|
|
130
|
+
"rules": { "pdf/last-page-balance": "error" } } ]
|
|
131
|
+
}
|
|
135
132
|
|
|
136
|
-
|
|
133
|
+
"papersDir" defaults to "papers". "extends", "kind" and "pdf" here are defaults for every
|
|
134
|
+
paper. "papersDir", "structure" and the skills' keys are allowed only here.
|
|
137
135
|
|
|
138
|
-
|
|
136
|
+
<papersDir>/<paper>/paperlint.json — one paper, merged over the root file:
|
|
137
|
+
|
|
138
|
+
{ "extends": "paperlint:aisec", "kind": "research", "rules": { "pdf/last-page-balance": "error" } }
|
|
139
139
|
|
|
140
140
|
"extends" names a venue preset: paperlint:<name> (shipped: acm-sigconf, agenticdev, aisec, realm)
|
|
141
141
|
or ./path.jsonc, relative to the paperlint.json. npm presets are not supported yet.
|
|
142
142
|
|
|
143
|
-
"rules"
|
|
144
|
-
|
|
143
|
+
"rules" is { "<rule>": "<severity>" } for every paper file in scope, or ESLint flat-config
|
|
144
|
+
blocks (files, ignores, rules) with globs relative to that file. Order, later wins: paperlint's
|
|
145
|
+
own, the venue preset's, the root file's, the paper's. Optional rules (off unless turned on):
|
|
146
|
+
pdf/last-page-balance. The venue rules (pdf/fresh, pdf/profile, pdf/fonts, pdf/geometry,
|
|
147
|
+
pdf/limits, pdf/body-size, pdf/measured) are on for every paper with a venue preset; set one to
|
|
148
|
+
"off" to skip it. An unknown key, in either file, is an error.
|
|
145
149
|
`;
|
|
146
150
|
/** The config the user would otherwise write by hand. The data comes from `opts`, the mechanism is here. */
|
|
147
151
|
export function buildConfig(opts = {}, texLanguage) {
|
|
148
152
|
const paperRules = { ...researchQuestion.rules, ...typography.rules };
|
|
149
|
-
|
|
153
|
+
// The reference rules judge `_build/references.json`, and only on `paper.tex`.
|
|
154
|
+
const texPaperRules = {
|
|
155
|
+
...paperRules,
|
|
156
|
+
...referenceRules({ files: nodeFiles }),
|
|
157
|
+
};
|
|
158
|
+
// Each typography rule reports every occurrence where it is, and fixes it (`--fix`).
|
|
159
|
+
const prose = {
|
|
160
|
+
"paper/research-question": "warn",
|
|
161
|
+
"paper/section-word": "warn",
|
|
162
|
+
"paper/leading-zero": "warn",
|
|
163
|
+
};
|
|
150
164
|
const md = {
|
|
151
165
|
language: "markdown/gfm",
|
|
152
166
|
languageOptions: { frontmatter: "yaml" },
|
|
@@ -180,51 +194,30 @@ export function buildConfig(opts = {}, texLanguage) {
|
|
|
180
194
|
rules: {
|
|
181
195
|
"paper/stages": "error",
|
|
182
196
|
"paper/source": "error",
|
|
183
|
-
"paper/author-list": [
|
|
184
|
-
"warn",
|
|
185
|
-
opts.authorListCommand ? { command: opts.authorListCommand } : {},
|
|
186
|
-
],
|
|
187
197
|
},
|
|
188
198
|
},
|
|
189
199
|
{
|
|
190
200
|
files: ["**/paper.md", "**/draft.md"],
|
|
191
201
|
plugins: { markdown, paper: { rules: paperRules } },
|
|
192
202
|
...md,
|
|
193
|
-
rules:
|
|
194
|
-
"paper/research-question": "warn",
|
|
195
|
-
"paper/typography": typographyOpt,
|
|
196
|
-
},
|
|
203
|
+
rules: prose,
|
|
197
204
|
},
|
|
198
205
|
{
|
|
199
206
|
files: ["**/reviews/*.md"],
|
|
200
|
-
plugins: {
|
|
201
|
-
markdown,
|
|
202
|
-
review: { rules: { ...findingsCause.rules } },
|
|
203
|
-
doc: docFields,
|
|
204
|
-
},
|
|
207
|
+
plugins: { markdown, review: reviewFrontmatter },
|
|
205
208
|
...md,
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
{
|
|
219
|
-
minFindings: opts.minFindings ?? 3,
|
|
220
|
-
...(opts.causeMarker ? { causeMarker: opts.causeMarker } : {}),
|
|
221
|
-
...(opts.reviewSince ? { sinceCreated: opts.reviewSince } : {}),
|
|
222
|
-
},
|
|
223
|
-
],
|
|
224
|
-
...(opts.docFields
|
|
225
|
-
? { "doc/fields": ["warn", { fields: opts.docFields }] }
|
|
226
|
-
: {}),
|
|
227
|
-
},
|
|
209
|
+
// A review's frontmatter is a record, validated by paperlint's JSON Schema
|
|
210
|
+
// (eslint-rules/review-frontmatter.mjs).
|
|
211
|
+
rules: { "review/frontmatter": "error" },
|
|
212
|
+
},
|
|
213
|
+
{
|
|
214
|
+
// A sibling card says how much of the competing paper was read (`read:`). The cards are
|
|
215
|
+
// written by this package's analyze-sibling-paper skill; its README is an index, not a card.
|
|
216
|
+
files: ["**/siblings/*.md"],
|
|
217
|
+
ignores: ["**/siblings/README.md"],
|
|
218
|
+
plugins: { markdown, sibling: siblingFrontmatter },
|
|
219
|
+
...md,
|
|
220
|
+
rules: { "sibling/frontmatter": "warn" },
|
|
228
221
|
},
|
|
229
222
|
];
|
|
230
223
|
// `.tex` only if the language loaded: it pulls in the LaTeX parser, and dying because of it on a
|
|
@@ -234,21 +227,32 @@ export function buildConfig(opts = {}, texLanguage) {
|
|
|
234
227
|
files: ["**/paper.tex"],
|
|
235
228
|
plugins: {
|
|
236
229
|
tex: { languages: { latex: texLanguage }, rules: texBuild },
|
|
237
|
-
paper: { rules:
|
|
230
|
+
paper: { rules: texPaperRules },
|
|
231
|
+
bib: bibReachable,
|
|
238
232
|
},
|
|
239
233
|
language: "tex/latex",
|
|
240
234
|
rules: {
|
|
241
|
-
|
|
242
|
-
|
|
235
|
+
...prose,
|
|
236
|
+
...REFERENCE_RULE_LEVELS,
|
|
237
|
+
"paper/figure-ref-style": "warn",
|
|
238
|
+
"bib/reachable-entry": "warn",
|
|
243
239
|
"tex/future-promise": "warn",
|
|
244
240
|
"tex/acm-frontmatter-override": "error",
|
|
245
241
|
// Silent for a paper whose paperlint.json names no venue (src/venue-rules.ts).
|
|
246
242
|
...VENUE_RULE_LEVELS,
|
|
247
243
|
},
|
|
248
244
|
});
|
|
245
|
+
// 🔴 ONLY THE FILES THESE BLOCKS CLAIM ARE LINTED (src/paper-files.ts). Without this block ESLint's
|
|
246
|
+
// built-in defaults lint every .js/.mjs/.cjs under the papers directory — a paper's vendored
|
|
247
|
+
// `repro/` code failed the run with 74 parse errors and not one finding on a paper file. It goes
|
|
248
|
+
// FIRST: the `.template/` ignore below must come after its directory un-ignore to win.
|
|
249
|
+
const owners = ruleOwners(cfg);
|
|
250
|
+
cfg.unshift(scopeToOwned(ownedPatterns(cfg)));
|
|
249
251
|
// The consumer's own blocks, LAST, so a later block wins — ESLint's rule. Parsed by
|
|
250
|
-
// `readConfig`; each carries the settings file's directory as its `basePath`.
|
|
251
|
-
|
|
252
|
+
// `readConfig`; each carries the settings file's directory as its `basePath`. Each is split so a
|
|
253
|
+
// rule reaches only the files its plugin is registered for: `paper` is a different plugin beside
|
|
254
|
+
// PIPELINE-STATUS.md than beside paper.tex, and ESLint throws on a rule its plugin lacks.
|
|
255
|
+
cfg.push(...(opts.rules ?? []).flatMap((b) => narrowToOwners(b, owners)));
|
|
252
256
|
return cfg;
|
|
253
257
|
}
|
|
254
258
|
/**
|
|
@@ -287,48 +291,65 @@ export async function silentOptionalRules(eslint, lintedFiles, opts) {
|
|
|
287
291
|
return [...turnedOn].filter((id) => !reached.has(id));
|
|
288
292
|
}
|
|
289
293
|
/**
|
|
290
|
-
* Every linted paper's
|
|
291
|
-
*
|
|
292
|
-
*
|
|
293
|
-
*
|
|
294
|
+
* Every linted paper's rules, as ESLint blocks scoped to that paper, in two groups: what its venue
|
|
295
|
+
* preset turns on, and what its own `paperlint.json` says. The caller puts the root's blocks between
|
|
296
|
+
* them. A file that does not parse, or names a rule paperlint does not ship, stops the run with one
|
|
297
|
+
* line naming the file — the same strictness as the root's own `rules`.
|
|
294
298
|
*/
|
|
295
299
|
export function paperRuleBlocks(paths) {
|
|
296
|
-
|
|
297
|
-
const
|
|
300
|
+
// A FILE named on the command line belongs to the paper it sits in: that paper's settings apply.
|
|
301
|
+
const dirs = paths.map((p) => existsSync(p) && statSync(p).isFile() ? dirname(p) : p);
|
|
302
|
+
const papers = [...new Set(dirs.flatMap((p) => [p, ...papersIn(p)]))];
|
|
303
|
+
const out = { preset: [], own: [] };
|
|
298
304
|
for (const dir of papers) {
|
|
299
305
|
const p = paperPreset(dir, PRESET_DEPS);
|
|
300
306
|
if (p.kind === "settings-problem" && p.problem.kind === "broken")
|
|
301
307
|
return { ok: false, error: paperPresetProblem(dir, p) ?? dir };
|
|
302
|
-
const
|
|
303
|
-
if (!
|
|
304
|
-
return
|
|
305
|
-
|
|
306
|
-
|
|
308
|
+
const blocks = rulesOfPaper(dir, p);
|
|
309
|
+
if (!blocks.ok)
|
|
310
|
+
return blocks;
|
|
311
|
+
out.preset.push(...blocks.value.preset);
|
|
312
|
+
out.own.push(...blocks.value.own);
|
|
307
313
|
}
|
|
308
314
|
return { ok: true, value: out };
|
|
309
315
|
}
|
|
310
316
|
/**
|
|
311
|
-
* One paper's
|
|
312
|
-
*
|
|
317
|
+
* One paper's blocks: its preset chain's `rules`, and its own — either `{ id: severity }` over the
|
|
318
|
+
* paper's files, or ESLint blocks with globs relative to the paper. A preset that does not resolve
|
|
319
|
+
* contributes nothing here; `pdf/profile` reports it on the paper.
|
|
313
320
|
*/
|
|
314
321
|
function rulesOfPaper(dir, p) {
|
|
315
322
|
const settings = "settings" in p ? p.settings : null;
|
|
316
|
-
if (settings === null)
|
|
317
|
-
return { ok: true, value: null };
|
|
318
|
-
const own = paperRules(dir, settings, SHIPPED_RULES);
|
|
319
|
-
if (!own.ok)
|
|
320
|
-
return own;
|
|
321
323
|
const fromPreset = p.kind === "resolved"
|
|
322
324
|
? parseRuleEntries(p.preset.rules, `the venue preset ${p.preset.chain.join(" → ")} → "rules"`, SHIPPED_RULES)
|
|
323
325
|
: { ok: true, value: {} };
|
|
324
326
|
if (!fromPreset.ok)
|
|
325
327
|
return fromPreset;
|
|
326
|
-
const
|
|
328
|
+
const own = settings === null
|
|
329
|
+
? { ok: true, value: [] }
|
|
330
|
+
: Array.isArray(settings.rules)
|
|
331
|
+
? parseRuleBlocks(settings.rules, join(dir, CONFIG_FILE), SHIPPED_RULES, dir)
|
|
332
|
+
: ownRules(dir, settings);
|
|
333
|
+
if (!own.ok)
|
|
334
|
+
return own;
|
|
335
|
+
const scoped = (rules) => Object.keys(rules).length
|
|
336
|
+
? [{ basePath: dir, files: PAPER_FILE_PATTERNS, rules }]
|
|
337
|
+
: [];
|
|
327
338
|
return {
|
|
328
339
|
ok: true,
|
|
329
|
-
value:
|
|
330
|
-
|
|
331
|
-
|
|
340
|
+
value: { preset: scoped(fromPreset.value), own: [...own.value] },
|
|
341
|
+
};
|
|
342
|
+
}
|
|
343
|
+
/** A paper's `{ id: severity }` rules, as one block over the paper's files. */
|
|
344
|
+
function ownRules(dir, settings) {
|
|
345
|
+
const own = paperRules(dir, settings, SHIPPED_RULES);
|
|
346
|
+
if (!own.ok)
|
|
347
|
+
return own;
|
|
348
|
+
return {
|
|
349
|
+
ok: true,
|
|
350
|
+
value: own.value
|
|
351
|
+
? [{ basePath: dir, files: PAPER_FILE_PATTERNS, rules: own.value }]
|
|
352
|
+
: [],
|
|
332
353
|
};
|
|
333
354
|
}
|
|
334
355
|
/**
|
|
@@ -336,15 +357,17 @@ function rulesOfPaper(dir, p) {
|
|
|
336
357
|
* config. A wider glob (everything under the paper) would make ESLint lint files no block gives a
|
|
337
358
|
* language — `paperlint.json` itself would be parsed as JavaScript.
|
|
338
359
|
*/
|
|
339
|
-
const PAPER_FILE_PATTERNS =
|
|
340
|
-
...new Set(buildConfig({}, { sentinel: "tex language" }).flatMap((b) => b.files ?? [])),
|
|
341
|
-
];
|
|
360
|
+
const PAPER_FILE_PATTERNS = ownedPatterns(buildConfig({}, { sentinel: "tex language" }));
|
|
342
361
|
/**
|
|
343
|
-
* The
|
|
344
|
-
*
|
|
362
|
+
* The root `paperlint.json` after the boundary: an unknown key is refused by name, the paper
|
|
363
|
+
* defaults (`extends`, `kind`, `pdf`) are checked the way a paper's own are, and `rules` becomes
|
|
364
|
+
* parsed config blocks. Nothing after this sees the raw object.
|
|
345
365
|
*/
|
|
346
|
-
export function parseSettings(
|
|
347
|
-
|
|
366
|
+
export function parseSettings(json, where, baseDir) {
|
|
367
|
+
if (typeof json !== "object" || json === null || Array.isArray(json))
|
|
368
|
+
return { ok: false, error: `${where}: must be a JSON object` };
|
|
369
|
+
const raw = json;
|
|
370
|
+
const opts = raw;
|
|
348
371
|
const unknown = unknownKeys(raw);
|
|
349
372
|
if (unknown.length > 0)
|
|
350
373
|
return {
|
|
@@ -352,15 +375,18 @@ export function parseSettings(opts, where, baseDir) {
|
|
|
352
375
|
error: `${where}: unknown key${unknown.length > 1 ? "s" : ""} ${unknown.map((k) => `"${k}"`).join(", ")} — ` +
|
|
353
376
|
`a typo would otherwise read as "not set". Known keys: ${Object.keys(SETTINGS_KEYS).join(", ")}`,
|
|
354
377
|
};
|
|
378
|
+
const defaults = stringFields(raw);
|
|
379
|
+
if (!defaults.ok)
|
|
380
|
+
return { ok: false, error: `${where}: ${defaults.error}` };
|
|
355
381
|
const rules = parseRuleBlocks(raw["rules"], where, SHIPPED_RULES, baseDir);
|
|
356
382
|
if (!rules.ok)
|
|
357
383
|
return rules;
|
|
358
384
|
return { ok: true, value: { ...opts, rules: rules.value } };
|
|
359
385
|
}
|
|
360
386
|
/**
|
|
361
|
-
* The directory ESLint runs from. ESLint ignores every file outside it (#48), and
|
|
362
|
-
* `
|
|
363
|
-
*
|
|
387
|
+
* The directory ESLint runs from. ESLint ignores every file outside it (#48), and the consumer's
|
|
388
|
+
* `rules` globs are written relative to the config's directory. So: the deepest directory holding
|
|
389
|
+
* the config's directory and every path. With the
|
|
364
390
|
* papers inside the config's directory, that is the config's directory itself.
|
|
365
391
|
*/
|
|
366
392
|
const lintRoot = (home, paths) => commonDir([home, ...paths]);
|
|
@@ -378,6 +404,7 @@ export function parseArgs(argv) {
|
|
|
378
404
|
paths: [],
|
|
379
405
|
config: null,
|
|
380
406
|
json: false,
|
|
407
|
+
fix: false,
|
|
381
408
|
all: false,
|
|
382
409
|
dryRun: false,
|
|
383
410
|
check: false,
|
|
@@ -411,6 +438,8 @@ export function parseArgs(argv) {
|
|
|
411
438
|
continue;
|
|
412
439
|
if (a === "--json")
|
|
413
440
|
out.json = true;
|
|
441
|
+
else if (a === "--fix")
|
|
442
|
+
out.fix = true;
|
|
414
443
|
else if (a === "--all")
|
|
415
444
|
out.all = true;
|
|
416
445
|
else if (a === "--dry-run")
|
|
@@ -444,7 +473,6 @@ export function parseArgs(argv) {
|
|
|
444
473
|
}
|
|
445
474
|
return out;
|
|
446
475
|
}
|
|
447
|
-
export const PKG_NAME = "package.json";
|
|
448
476
|
/**
|
|
449
477
|
* This package's own version, from the `package.json` beside `src/` and `dist/` alike. `init` pins
|
|
450
478
|
* the CI action to its release tag; an unreadable manifest yields `undefined`, and init then keeps
|
|
@@ -459,119 +487,61 @@ function ownVersion() {
|
|
|
459
487
|
return undefined;
|
|
460
488
|
}
|
|
461
489
|
}
|
|
462
|
-
/**
|
|
463
|
-
* 🔴 THE CLI READS `package.json`, THE ONE DECLARATION. `paperlint init` writes the settings under
|
|
464
|
-
* the `package.json` key that the three hooks and `eslint-rules` already read; the CLI reads the
|
|
465
|
-
* same file, so the install and the check cannot look at different files (issue #33,
|
|
466
|
-
* `docs/install.md`).
|
|
467
|
-
*
|
|
468
|
-
* The walk goes up to the filesystem root, the way eslint, prettier and tsc find theirs, so a run
|
|
469
|
-
* from inside one paper sees the same settings as a run from the repository root.
|
|
470
|
-
*/
|
|
471
|
-
export function findConfig(startDir) {
|
|
472
|
-
let dir = resolve(startDir);
|
|
473
|
-
for (;;) {
|
|
474
|
-
const pkg = join(dir, PKG_NAME);
|
|
475
|
-
if (existsSync(pkg) && declaresSettings(pkg))
|
|
476
|
-
return pkg;
|
|
477
|
-
const up = dirname(dir);
|
|
478
|
-
if (up === dir)
|
|
479
|
-
return null;
|
|
480
|
-
dir = up;
|
|
481
|
-
}
|
|
482
|
-
}
|
|
483
|
-
/**
|
|
484
|
-
* A `package.json` WITHOUT the key is not a declaration and must not stop the walk — every
|
|
485
|
-
* project on the way up has one, so stopping there would make the search find nothing, always.
|
|
486
|
-
* An unparsable one is treated the same way here; `paperlint doctor` is the command that reports it.
|
|
487
|
-
*/
|
|
488
|
-
const declaresSettings = (pkgPath) => {
|
|
489
|
-
try {
|
|
490
|
-
const d = declaredSettings(JSON.parse(readFileSync(pkgPath, "utf8")));
|
|
491
|
-
return d.settings !== undefined || d.conflict !== null;
|
|
492
|
-
}
|
|
493
|
-
catch {
|
|
494
|
-
return false;
|
|
495
|
-
}
|
|
496
|
-
};
|
|
497
490
|
/**
|
|
498
491
|
* Reading the config, ONE reader for all commands. Pulled out of `run()` the moment a second
|
|
499
492
|
* command needed the same config (`build`): two copies of this block would have drifted apart on
|
|
500
493
|
* the very first edit — exactly the class that already cost us the empty-set guard in two places.
|
|
501
494
|
*
|
|
502
|
-
*
|
|
495
|
+
* 🔴 THE CONFIG FINDS ITSELF: the project root is the nearest directory up from `cwd` with a root
|
|
496
|
+
* `paperlint.json`, else with a `package.json`, else `cwd` (`findProjectRoot`). The file is
|
|
497
|
+
* optional — without one every setting has its default, and papers are in `papers/`. An explicit
|
|
498
|
+
* `--config` beats the discovered one: it was named out loud, and a substitution is never silent.
|
|
499
|
+
*
|
|
500
|
+
* @returns `{ opts, configPath, root }` on success, or `{ code }` — and then the caller exits with it.
|
|
503
501
|
*/
|
|
504
502
|
export function readConfig(a, { log = console.log, err = console.error, cwd = process.cwd(), } = {}) {
|
|
505
|
-
|
|
506
|
-
// loud, and a substitution is never silent. Either way the file has the shape of a
|
|
507
|
-
// `package.json`: the settings sit under the key.
|
|
508
|
-
const configPath = a.config ?? findConfig(cwd);
|
|
509
|
-
if (a.config && !existsSync(a.config)) {
|
|
503
|
+
if (a.config && !existsSync(resolve(cwd, a.config))) {
|
|
510
504
|
err(`config file not found: ${a.config}`);
|
|
511
505
|
return { code: 2 };
|
|
512
506
|
}
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
return { code: 2 };
|
|
524
|
-
}
|
|
525
|
-
const d = declaredSettings(parsed);
|
|
526
|
-
if (d.conflict !== null) {
|
|
527
|
-
err(d.conflict);
|
|
528
|
-
return { code: 2 };
|
|
529
|
-
}
|
|
530
|
-
opts = (d.settings ?? {});
|
|
531
|
-
legacyKey = d.legacy;
|
|
532
|
-
// The discovered config is NAMED out loud. Otherwise a run from someone else's directory picks
|
|
533
|
-
// up someone else's file and does not say so — and a typography-debt mismatch looks like a finding.
|
|
534
|
-
//
|
|
535
|
-
// 🔴 IN `--json` MODE — TO stderr. Machine output must be ONE parsable document: a line before
|
|
536
|
-
// the array breaks any `| jq`, and it breaks it for the consumer, not for us. Caught not by a
|
|
537
|
-
// test but by an attempt to wire our own action to this output; in the harness I first WORKED
|
|
538
|
-
// AROUND this line (stripped the first line before JSON.parse) — that is, the workaround hid
|
|
539
|
-
// the defect exactly where it should have been shouting.
|
|
540
|
-
(a.json ? err : log)(`config: ${relative(cwd, configPath) || PKG_NAME}`);
|
|
541
|
-
if (legacyKey)
|
|
542
|
-
(a.json ? err : log)(` ⚠ ${LEGACY_KEY_MESSAGE}`);
|
|
507
|
+
const root = a.config
|
|
508
|
+
? dirname(resolve(cwd, a.config))
|
|
509
|
+
: findProjectRoot(cwd);
|
|
510
|
+
const found = a.config ? resolve(cwd, a.config) : join(root, CONFIG_FILE);
|
|
511
|
+
const configPath = existsSync(found) ? found : null;
|
|
512
|
+
if (configPath === null)
|
|
513
|
+
return { opts: {}, configPath, root };
|
|
514
|
+
let parsed;
|
|
515
|
+
try {
|
|
516
|
+
parsed = JSON.parse(readFileSync(configPath, "utf8"));
|
|
543
517
|
}
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
const where = `${configPath ? basename(configPath) : PKG_NAME} → "${legacyKey ? LEGACY_CONFIG_KEY : CONFIG_KEY}"`;
|
|
547
|
-
const renamed = configPath ? renamedFieldMessage(opts, where) : null;
|
|
548
|
-
if (renamed) {
|
|
549
|
-
err(renamed);
|
|
518
|
+
catch (e) {
|
|
519
|
+
err(`${configPath} is not valid JSON: ${e.message}`);
|
|
550
520
|
return { code: 2 };
|
|
551
521
|
}
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
// does not know what it works on, and the one thing that cannot be guessed: a default of "." runs
|
|
562
|
-
// the rules over the whole checkout and exits green over a scope nobody chose.
|
|
563
|
-
if (configPath && !hasPapers(opts)) {
|
|
564
|
-
err(`${configPath} must declare \`${PAPERS_DIR_FIELD}\` — the directory your papers live in, e.g.\n` +
|
|
565
|
-
` { "${CONFIG_KEY}": { "${PAPERS_DIR_FIELD}": "papers" } }\n` +
|
|
566
|
-
`It is the one thing this tool cannot guess. \`npx paperlint init\` writes it for you.`);
|
|
522
|
+
// The discovered config is NAMED out loud. Otherwise a run from someone else's directory picks
|
|
523
|
+
// up someone else's file and does not say so — and its settings then look like findings.
|
|
524
|
+
//
|
|
525
|
+
// 🔴 IN `--json` MODE — TO stderr. Machine output must be ONE parsable document: a line before
|
|
526
|
+
// the array breaks any `| jq`, and it breaks it for the consumer, not for us.
|
|
527
|
+
(a.json ? err : log)(`config: ${relative(cwd, configPath) || CONFIG_FILE}`);
|
|
528
|
+
const settings = parseSettings(parsed, relative(cwd, configPath) || CONFIG_FILE, root);
|
|
529
|
+
if (!settings.ok) {
|
|
530
|
+
err(settings.error);
|
|
567
531
|
return { code: 2 };
|
|
568
532
|
}
|
|
569
|
-
return { opts, configPath };
|
|
533
|
+
return { opts: settings.value, configPath, root };
|
|
570
534
|
}
|
|
571
|
-
/** The papers directory field of the settings, read by its one declared name. */
|
|
535
|
+
/** The papers directory field of the settings, read by its one declared name; the default when absent. */
|
|
572
536
|
export function papersDirOf(opts) {
|
|
573
|
-
|
|
537
|
+
const declared = opts[PAPERS_DIR_FIELD];
|
|
538
|
+
return declared === undefined ? DEFAULT_PAPERS_ROOT : declared;
|
|
574
539
|
}
|
|
540
|
+
/** The error for a papers directory that is missing or holds no paper. */
|
|
541
|
+
export const noPapersMessage = (dir) => `no papers in ${dir}/ — create one with \`npx paperlint new <name>\`, or set "${PAPERS_DIR_FIELD}" ` +
|
|
542
|
+
`in ${CONFIG_FILE} if your papers live elsewhere`;
|
|
543
|
+
/** The papers directories of a read config, absolute: each relative to the project root. */
|
|
544
|
+
export const papersRoots = (cfg) => toPaths(papersDirOf(cfg.opts)).map((rel) => resolve(cfg.root, rel));
|
|
575
545
|
/** The papers directory may be one directory or several; both spellings normalise to a list. */
|
|
576
546
|
export function toPaths(papers) {
|
|
577
547
|
if (typeof papers === "string")
|
|
@@ -580,7 +550,6 @@ export function toPaths(papers) {
|
|
|
580
550
|
return papers.filter((x) => typeof x === "string" && x.trim());
|
|
581
551
|
return [];
|
|
582
552
|
}
|
|
583
|
-
const hasPapers = (opts) => toPaths(papersDirOf(opts)).length > 0;
|
|
584
553
|
/**
|
|
585
554
|
* `paperlint hook <name>` — run an editor hook. It exists for ONE thing: so that the wiring does not
|
|
586
555
|
* address the runtime from the project root.
|
|
@@ -684,11 +653,10 @@ async function runNew(a, { log, err, cwd, ask = askOnTerminal, }) {
|
|
|
684
653
|
const cfg = readConfig({ ...a, json: false }, { log: () => { }, err, cwd });
|
|
685
654
|
if (cfg.code !== undefined)
|
|
686
655
|
return cfg.code;
|
|
687
|
-
const roots =
|
|
656
|
+
const roots = papersRoots(cfg);
|
|
688
657
|
const papersRoot = roots[0];
|
|
689
|
-
if (
|
|
690
|
-
err(`no
|
|
691
|
-
`Run \`npx paperlint init\` first — it declares the directory in package.json.`);
|
|
658
|
+
if (papersRoot === undefined) {
|
|
659
|
+
err(`"${PAPERS_DIR_FIELD}" names no directory, so there is nowhere to put \`${name}\`.`);
|
|
692
660
|
return 2;
|
|
693
661
|
}
|
|
694
662
|
if (roots.length > 1)
|
|
@@ -706,12 +674,7 @@ async function runBuild(a, { log, err, cwd, }) {
|
|
|
706
674
|
const cfg = readConfig(a, { log, err, cwd });
|
|
707
675
|
if (cfg.code !== undefined)
|
|
708
676
|
return cfg.code;
|
|
709
|
-
const
|
|
710
|
-
// The key that used to name the scripts to run. It is read by nothing now; saying so beats a
|
|
711
|
-
// setting that silently stopped doing anything.
|
|
712
|
-
if (opts.buildScripts !== undefined)
|
|
713
|
-
log(`note: "buildScripts" in ${relative(cwd, configPath ?? "") || "the settings"} is ignored — paperlint builds the paper itself`);
|
|
714
|
-
const roots = toPaths(papersDirOf(opts)).map((rel) => resolve(configPath ? dirname(configPath) : cwd, rel));
|
|
677
|
+
const roots = papersRoots(cfg);
|
|
715
678
|
let targets;
|
|
716
679
|
if (a.all) {
|
|
717
680
|
targets = roots.flatMap((r) => papersIn(r));
|
|
@@ -735,6 +698,7 @@ async function runBuild(a, { log, err, cwd, }) {
|
|
|
735
698
|
cwd,
|
|
736
699
|
dryRun: a.dryRun,
|
|
737
700
|
log,
|
|
701
|
+
checkReferences: onlineReferences,
|
|
738
702
|
engine: () => engineEnv(targets, a, { log, err }),
|
|
739
703
|
});
|
|
740
704
|
if (out.kind === "no-engine")
|
|
@@ -765,9 +729,7 @@ export function toolchainTex(cwd) {
|
|
|
765
729
|
err: () => { },
|
|
766
730
|
cwd,
|
|
767
731
|
});
|
|
768
|
-
const roots = cfg.code === undefined
|
|
769
|
-
? toPaths(papersDirOf(cfg.opts)).map((rel) => resolve(dirname(cfg.configPath ?? cwd), rel))
|
|
770
|
-
: [];
|
|
732
|
+
const roots = cfg.code === undefined ? papersRoots(cfg) : [];
|
|
771
733
|
const chains = roots
|
|
772
734
|
.flatMap((r) => papersIn(r))
|
|
773
735
|
.map((dir) => paperPreset(dir, PRESET_DEPS))
|
|
@@ -820,6 +782,46 @@ function hostBanalInstaller() {
|
|
|
820
782
|
});
|
|
821
783
|
return banalInstaller({ ...ports, download }, s);
|
|
822
784
|
}
|
|
785
|
+
/** `paperlint init`: its flags checked here, the install itself in `init.ts`. */
|
|
786
|
+
async function runInit(a, { log, err, cwd, }) {
|
|
787
|
+
// Deferred, not implemented: named and refused, rather than read as the directory argument.
|
|
788
|
+
if (a.hooksMode !== null) {
|
|
789
|
+
err(`--hooks=${a.hooksMode} is not implemented. init writes the hooks into .claude/settings.json ` +
|
|
790
|
+
`(shared, committed) or, with --no-hooks, nowhere.`);
|
|
791
|
+
return 2;
|
|
792
|
+
}
|
|
793
|
+
if (a.format !== null && !isFormat(a.format)) {
|
|
794
|
+
err(`--format must be one of ${FORMATS.join(", ")} — got \`${a.format}\``);
|
|
795
|
+
return 2;
|
|
796
|
+
}
|
|
797
|
+
return await init(a.paths[0] ?? ".", {
|
|
798
|
+
log,
|
|
799
|
+
err,
|
|
800
|
+
cwd,
|
|
801
|
+
version: ownVersion(),
|
|
802
|
+
yes: a.yes,
|
|
803
|
+
hooks: !a.noHooks,
|
|
804
|
+
paper: a.paper,
|
|
805
|
+
format: isFormat(a.format) ? a.format : null,
|
|
806
|
+
createPaper: (papersRoot, name, format) => createPaperAt(papersRoot, name, format, { log, err, cwd }),
|
|
807
|
+
tex: {
|
|
808
|
+
installed: () => cachedTree(cacheRoot(process.env)) !== null,
|
|
809
|
+
install: () => runToolchain({
|
|
810
|
+
check: false,
|
|
811
|
+
log,
|
|
812
|
+
err,
|
|
813
|
+
banal: hostBanalInstaller(),
|
|
814
|
+
tex: toolchainTex(resolve(cwd, a.paths[0] ?? ".")),
|
|
815
|
+
}),
|
|
816
|
+
},
|
|
817
|
+
resolveCliPapers: (root) => {
|
|
818
|
+
const read = readConfig({ ...a, config: null }, { log: () => { }, err: () => { }, cwd: root });
|
|
819
|
+
return read.code === undefined
|
|
820
|
+
? (toPaths(papersDirOf(read.opts))[0] ?? null)
|
|
821
|
+
: null;
|
|
822
|
+
},
|
|
823
|
+
});
|
|
824
|
+
}
|
|
823
825
|
export async function run(argv, { log = console.log, err = console.error, cwd = process.cwd(), } = {}) {
|
|
824
826
|
const a = parseArgs(argv);
|
|
825
827
|
// The refusal must come FIRST: behind a flag without a value there is usually a typo, or a
|
|
@@ -838,35 +840,8 @@ export async function run(argv, { log = console.log, err = console.error, cwd =
|
|
|
838
840
|
// `init` asks the CLI's OWN reader what it would lint, so the two sides `doctor` compares are
|
|
839
841
|
// not two implementations of the same question. A second resolver here is the defect the
|
|
840
842
|
// comparison exists to catch.
|
|
841
|
-
if (a.cmd === "init")
|
|
842
|
-
|
|
843
|
-
if (a.hooksMode !== null) {
|
|
844
|
-
err(`--hooks=${a.hooksMode} is not implemented. init writes the hooks into .claude/settings.json ` +
|
|
845
|
-
`(shared, committed) or, with --no-hooks, nowhere.`);
|
|
846
|
-
return 2;
|
|
847
|
-
}
|
|
848
|
-
if (a.format !== null && !isFormat(a.format)) {
|
|
849
|
-
err(`--format must be one of ${FORMATS.join(", ")} — got \`${a.format}\``);
|
|
850
|
-
return 2;
|
|
851
|
-
}
|
|
852
|
-
return await init(a.paths[0] ?? ".", {
|
|
853
|
-
log,
|
|
854
|
-
err,
|
|
855
|
-
cwd,
|
|
856
|
-
version: ownVersion(),
|
|
857
|
-
yes: a.yes,
|
|
858
|
-
hooks: !a.noHooks,
|
|
859
|
-
paper: a.paper,
|
|
860
|
-
format: isFormat(a.format) ? a.format : null,
|
|
861
|
-
createPaper: (papersRoot, name, format) => createPaperAt(papersRoot, name, format, { log, err, cwd }),
|
|
862
|
-
resolveCliPapers: (root) => {
|
|
863
|
-
const read = readConfig({ ...a, config: null }, { log: () => { }, err: () => { }, cwd: root });
|
|
864
|
-
return read.code === undefined
|
|
865
|
-
? (toPaths(papersDirOf(read.opts))[0] ?? null)
|
|
866
|
-
: null;
|
|
867
|
-
},
|
|
868
|
-
});
|
|
869
|
-
}
|
|
843
|
+
if (a.cmd === "init")
|
|
844
|
+
return await runInit(a, { log, err, cwd });
|
|
870
845
|
// `doctor` reads the config but must NOT die on a broken one — reporting that the config is
|
|
871
846
|
// broken is precisely its job. So a failed read becomes "the CLI would lint nothing", which is
|
|
872
847
|
// what it prints, rather than an early exit that tells the reader nothing about the hooks.
|
|
@@ -894,39 +869,44 @@ export async function run(argv, { log = console.log, err = console.error, cwd =
|
|
|
894
869
|
const cfg = readConfig(a, { log, err, cwd });
|
|
895
870
|
if (cfg.code !== undefined)
|
|
896
871
|
return cfg.code;
|
|
897
|
-
const { opts,
|
|
872
|
+
const { opts, root } = cfg;
|
|
898
873
|
// A command-line argument OVERRIDES the config: one paper out of the corpus gets linted without
|
|
899
874
|
// editing a file.
|
|
900
875
|
//
|
|
901
|
-
// 🔴 A path FROM THE CONFIG is resolved relative to the
|
|
902
|
-
// Otherwise walking up is pointless: from `papers/aisec-2026` the
|
|
903
|
-
// `"papersDir": "papers"` would point at `papers/aisec-2026/papers
|
|
904
|
-
// run would fail with "nothing found" where everything is in place. A command-line argument stays
|
|
876
|
+
// 🔴 A path FROM THE CONFIG is resolved relative to the PROJECT ROOT, not the current directory.
|
|
877
|
+
// Otherwise walking up is pointless: from `papers/aisec-2026` the root would be found, but
|
|
878
|
+
// `"papersDir": "papers"` would point at `papers/aisec-2026/papers`. A command-line argument stays
|
|
905
879
|
// relative to the current directory: it was typed here and now.
|
|
906
880
|
//
|
|
907
881
|
// Both kinds end up ABSOLUTE: ESLint below runs from `lintRoot`, not from here, and would resolve a
|
|
908
882
|
// relative argument against the wrong directory.
|
|
909
|
-
const paths = a.paths.length > 0
|
|
910
|
-
? a.paths.map((p) => resolve(cwd, p))
|
|
911
|
-
: toPaths(papersDirOf(opts)).map((rel) => resolve(dirname(configPath ?? cwd), rel));
|
|
883
|
+
const paths = a.paths.length > 0 ? a.paths.map((p) => resolve(cwd, p)) : papersRoots(cfg);
|
|
912
884
|
if (paths.length === 0) {
|
|
913
|
-
err(`nothing to lint:
|
|
914
|
-
`Run \`npx paperlint init\` here, or pass the directory: \`paperlint lint papers\`.`);
|
|
885
|
+
err(`nothing to lint: "${PAPERS_DIR_FIELD}" names no directory. Pass one: \`paperlint lint papers\`.`);
|
|
915
886
|
return 2;
|
|
916
887
|
}
|
|
888
|
+
// 🔴 NO PAPERS WHERE THE CONFIG POINTS IS AN ERROR, NOT A CLEAN RUN. With the default in play the
|
|
889
|
+
// directory may simply not exist yet; a run over nothing would be green and say nothing.
|
|
890
|
+
if (a.paths.length === 0) {
|
|
891
|
+
const empty = paths.find((p) => papersIn(p).length === 0);
|
|
892
|
+
if (empty !== undefined) {
|
|
893
|
+
err(noPapersMessage(relative(cwd, empty) || "."));
|
|
894
|
+
return 2;
|
|
895
|
+
}
|
|
896
|
+
}
|
|
917
897
|
// 🔴 STRUCTURE IS CHECKED BEFORE ESLint AND SEPARATELY FROM IT. A rule is invoked for the file
|
|
918
898
|
// handed to it; a missing file is never handed over, so no rule at all can report the absence —
|
|
919
899
|
// a directory without `PIPELINE-STATUS.md` simply gets not a single rule and reports clean. The
|
|
920
900
|
// analysis of why a structure plugin for ESLint does not cure this is in `structure.mjs`.
|
|
921
901
|
const structure = checkStructure(paths, opts.structure, { cwd });
|
|
922
|
-
//
|
|
923
|
-
//
|
|
902
|
+
// The order, most general first so the most specific wins (ESLint: a later block wins): each
|
|
903
|
+
// paper's venue preset → the root paperlint.json → the paper's own paperlint.json.
|
|
924
904
|
const papers = paperRuleBlocks(paths);
|
|
925
905
|
if (!papers.ok)
|
|
926
906
|
return (err(papers.error), 2);
|
|
927
907
|
const withPapers = {
|
|
928
908
|
...opts,
|
|
929
|
-
rules: [...papers.value, ...(opts.rules ?? [])],
|
|
909
|
+
rules: [...papers.value.preset, ...(opts.rules ?? []), ...papers.value.own],
|
|
930
910
|
};
|
|
931
911
|
let texLanguage = null;
|
|
932
912
|
try {
|
|
@@ -938,10 +918,16 @@ export async function run(argv, { log = console.log, err = console.error, cwd =
|
|
|
938
918
|
/* without a LaTeX parser we work over markdown */
|
|
939
919
|
}
|
|
940
920
|
const eslint = new ESLint({
|
|
941
|
-
cwd: lintRoot(
|
|
921
|
+
cwd: lintRoot(root, paths),
|
|
942
922
|
overrideConfigFile: true,
|
|
943
923
|
overrideConfig: buildConfig(withPapers, texLanguage),
|
|
924
|
+
fix: a.fix,
|
|
944
925
|
});
|
|
926
|
+
const unowned = await firstUnownedFile(eslint, paths);
|
|
927
|
+
if (unowned !== null) {
|
|
928
|
+
err(`${relative(cwd, unowned) || unowned} is not a file paperlint lints — it lints ${PAPER_FILE_PATTERNS.join(", ")}`);
|
|
929
|
+
return 2;
|
|
930
|
+
}
|
|
945
931
|
// 🔴 ESLint THROWS on an empty set (`NoFilesFoundError`) — the guard below simply never got
|
|
946
932
|
// reached, which is what the very first run over an empty directory showed: instead of a clear
|
|
947
933
|
// message a stack from the depths of eslint-helpers.js flew out. A failure stays a failure, but
|
|
@@ -952,13 +938,14 @@ export async function run(argv, { log = console.log, err = console.error, cwd =
|
|
|
952
938
|
results = await eslint.lintFiles(paths);
|
|
953
939
|
}
|
|
954
940
|
catch (e) {
|
|
955
|
-
|
|
956
|
-
if (fail?.messageTemplate === "file-not-found" ||
|
|
957
|
-
/No files matching/i.test(fail?.message ?? ""))
|
|
941
|
+
if (isEmptySet(e))
|
|
958
942
|
results = [];
|
|
959
943
|
else
|
|
960
944
|
throw e;
|
|
961
945
|
}
|
|
946
|
+
// `--fix` writes what the rules fixed; the report below is what is LEFT.
|
|
947
|
+
if (a.fix)
|
|
948
|
+
await ESLint.outputFixes(results);
|
|
962
949
|
// 🔴 THE GUARD AGAINST A GREEN ZERO, the same one as in action.yml and for the same reason:
|
|
963
950
|
// ESLint exits zero when there are no findings, and "no findings" is byte-for-byte
|
|
964
951
|
// indistinguishable from "not a single rule got a single file". A rule whose glob did not match
|
|
@@ -971,10 +958,35 @@ export async function run(argv, { log = console.log, err = console.error, cwd =
|
|
|
971
958
|
a,
|
|
972
959
|
log,
|
|
973
960
|
err,
|
|
974
|
-
where: relative(cwd,
|
|
961
|
+
where: relative(cwd, root) || ".",
|
|
975
962
|
opts: withPapers,
|
|
976
963
|
});
|
|
977
964
|
}
|
|
965
|
+
/**
|
|
966
|
+
* The first path that is a FILE paperlint does not lint, or null. ESLint would answer such a file
|
|
967
|
+
* with a warning result ("File ignored because of a matching ignore pattern"), which counts as a
|
|
968
|
+
* linted file and turns a run over nothing into a green one. The question is asked of ESLint's
|
|
969
|
+
* own matcher, against the same scope that decides what a directory yields.
|
|
970
|
+
*/
|
|
971
|
+
async function firstUnownedFile(eslint, paths) {
|
|
972
|
+
for (const p of paths)
|
|
973
|
+
if (existsSync(p) &&
|
|
974
|
+
statSync(p).isFile() &&
|
|
975
|
+
(await eslint.isPathIgnored(p)))
|
|
976
|
+
return p;
|
|
977
|
+
return null;
|
|
978
|
+
}
|
|
979
|
+
/**
|
|
980
|
+
* ESLint's refusals of an empty set: nothing matched (`file-not-found`), or everything that matched
|
|
981
|
+
* is outside paperlint's scope (`all-matched-files-ignored` — a papers directory holding only
|
|
982
|
+
* vendored scripts). Both mean "nothing was linted", which the caller reports itself.
|
|
983
|
+
*/
|
|
984
|
+
const isEmptySet = (e) => {
|
|
985
|
+
const fail = e;
|
|
986
|
+
return (fail?.messageTemplate === "file-not-found" ||
|
|
987
|
+
fail?.messageTemplate === "all-matched-files-ignored" ||
|
|
988
|
+
/No files matching/i.test(fail?.message ?? ""));
|
|
989
|
+
};
|
|
978
990
|
/**
|
|
979
991
|
* The end of `paperlint lint`: refuse an optional rule that reached no paper, print the findings, and
|
|
980
992
|
* decide the exit code. Pulled out of `run` so each question has its own function.
|