paperlint 2.1.0 → 3.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CLAUDE.md +6 -1
- package/README.md +243 -235
- 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-engine.d.ts.map +1 -1
- package/dist/build-engine.js +6 -2
- package/dist/build-engine.js.map +1 -1
- package/dist/build.d.ts +15 -0
- package/dist/build.d.ts.map +1 -1
- package/dist/build.js +60 -7
- package/dist/build.js.map +1 -1
- package/dist/cli.d.ts +94 -30
- package/dist/cli.d.ts.map +1 -1
- package/dist/cli.js +500 -288
- 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/engine.d.ts +9 -0
- package/dist/engine.d.ts.map +1 -1
- package/dist/engine.js +27 -0
- package/dist/engine.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 +144 -173
- 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 +8 -1
- package/dist/new-paper.d.ts.map +1 -1
- package/dist/new-paper.js +49 -25
- package/dist/new-paper.js.map +1 -1
- package/dist/paper-files.d.ts +85 -0
- package/dist/paper-files.d.ts.map +1 -0
- package/dist/paper-files.js +96 -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/toolchain.d.ts +5 -1
- package/dist/toolchain.d.ts.map +1 -1
- package/dist/toolchain.js +22 -5
- package/dist/toolchain.js.map +1 -1
- package/dist/types.d.ts +26 -18
- package/dist/types.d.ts.map +1 -1
- package/dist/venue-rules.d.ts.map +1 -1
- package/dist/venue-rules.js +12 -10
- package/dist/venue-rules.js.map +1 -1
- package/docs/configuration.md +163 -104
- package/docs/e2e.md +1 -1
- package/docs/install.md +25 -40
- package/docs/optional-rules.md +5 -4
- package/docs/rules.md +65 -24
- package/docs/skills.md +80 -0
- 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 +306 -191
- package/eslint-rules/paper-typography.test.mjs +337 -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/fixtures/toolchain-mirror/catalog.txt +1 -0
- package/fixtures/toolchain-mirror/install-tl +4 -1
- package/fixtures/toolchain-mirror/stub-fmtutil-sys +4 -0
- package/fixtures/toolchain-mirror/stub-tlmgr +15 -0
- 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 +5 -14
- 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-engine.ts +6 -1
- package/src/build.harness.mjs +54 -6
- package/src/build.ts +89 -11
- package/src/cli.harness.mjs +149 -294
- package/src/cli.mutations.mjs +35 -44
- package/src/cli.ts +637 -328
- package/src/doctor.harness.mjs +41 -84
- package/src/doctor.mutations.mjs +9 -9
- package/src/doctor.ts +44 -89
- package/src/engine.harness.mjs +50 -0
- package/src/engine.ts +35 -0
- 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 +187 -231
- 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/linted-set.test.ts +118 -0
- package/src/new-paper.harness.mjs +2 -0
- package/src/new-paper.mutations.mjs +0 -14
- package/src/new-paper.test.ts +173 -14
- package/src/new-paper.ts +82 -30
- package/src/paper-files.test.ts +395 -0
- package/src/paper-files.ts +200 -0
- package/src/paper-settings-commands.test.ts +121 -122
- package/src/paper-settings.test.ts +93 -64
- package/src/paper-settings.ts +99 -97
- package/src/parse-args.test.ts +48 -0
- 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/rules-off.test.ts +111 -0
- package/src/structure.harness.mjs +4 -2
- package/src/structure.ts +3 -3
- package/src/tex-requirements.ts +2 -2
- package/src/toolchain.harness.mjs +41 -6
- package/src/toolchain.ts +29 -5
- package/src/types.ts +36 -18
- package/src/venue-rules.test.ts +23 -11
- package/src/venue-rules.ts +12 -14
- package/templates/paper/paperlint.json +1 -1
- 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,20 +36,23 @@ 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";
|
|
47
|
-
import {
|
|
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";
|
|
48
|
+
import { paperPreset, paperPresetProblem, presetProblemText, resolvePreset, shippedPresets, SHIPPED_PREFIX, } from "./presets.js";
|
|
48
49
|
import { mergeRequirements, declaredUnion, requirementsFor, NO_REQUIREMENTS, } from "./tex-requirements.js";
|
|
49
50
|
import { doctor } from "./doctor.js";
|
|
50
51
|
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,17 +65,21 @@ 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";
|
|
75
|
+
/** The shipped venue presets, read from the package's venues directory — the one list. */
|
|
76
|
+
const SHIPPED_VENUES = () => shippedPresets(packageVenuesDir());
|
|
72
77
|
const USAGE = `paperlint — machine-checkable gates for a paper kept in git
|
|
73
78
|
|
|
74
79
|
npx paperlint init [dir] set the project up: detect the papers directory, declare it
|
|
75
80
|
in package.json, link the skills, wire the hooks into
|
|
76
81
|
.claude/settings.json, offer the CI step, report what is missing
|
|
77
|
-
npx paperlint new <name> [--format tex|md]
|
|
82
|
+
npx paperlint new <name> [--venue <preset>] [--kind <kind>] [--format tex|md]
|
|
78
83
|
create <papers>/<name>/ from the template; never overwrites,
|
|
79
84
|
on an existing folder adds only the missing files, then lints it
|
|
80
85
|
npx paperlint lint [paths…] run every rule over your papers
|
|
@@ -84,7 +89,10 @@ const USAGE = `paperlint — machine-checkable gates for a paper kept in git
|
|
|
84
89
|
paper directory is ignored (--dry-run: print the plan only).
|
|
85
90
|
Compiles with paperlint's TeX Live, else one on PATH that has every
|
|
86
91
|
package the venue declares; on a terminal it offers to install
|
|
87
|
-
one, without a terminal it stops and names \`npx paperlint toolchain
|
|
92
|
+
one, without a terminal it stops and names \`npx paperlint toolchain\`.
|
|
93
|
+
Then checks the references online (the cited works exist, titles
|
|
94
|
+
and authors match) into _build/references.json — never failing
|
|
95
|
+
the build: without network it records "not checked"
|
|
88
96
|
npx paperlint toolchain [--check] install TeX Live with every package the venue presets declare
|
|
89
97
|
into ~/.cache/paperlint/texlive (PAPERLINT_TEXLIVE_DIR overrides); a second
|
|
90
98
|
run does nothing. --check: report what is missing, change nothing
|
|
@@ -99,54 +107,71 @@ init:
|
|
|
99
107
|
--paper <name> create this paper too (without a human, the only way init creates one)
|
|
100
108
|
--format tex|md the new paper's source format; default tex
|
|
101
109
|
|
|
110
|
+
new:
|
|
111
|
+
--venue <preset> the venue preset, written as "extends" into the paper's paperlint.json:
|
|
112
|
+
a shipped one (${SHIPPED_VENUES().join(", ")}), or a path to your
|
|
113
|
+
own preset starting with ./ or ../, relative to where you run the command.
|
|
114
|
+
On a terminal without --venue, new asks; "none" leaves it unset
|
|
115
|
+
--kind <kind> the paper's kind at that venue (its page limit), e.g. short — one of the
|
|
116
|
+
preset's kinds; needs --venue
|
|
117
|
+
--format tex|md the paper's source format; default tex
|
|
118
|
+
|
|
102
119
|
lint:
|
|
103
|
-
npx paperlint lint [paths…] [--config <file.json>] [--json]
|
|
120
|
+
npx paperlint lint [paths…] [--fix] [--config <file.json>] [--json]
|
|
104
121
|
|
|
105
122
|
<paths…> where your papers live, e.g. papers. Optional ONLY because the declaration
|
|
106
123
|
names it — one of the two must name the scope. There is no default
|
|
107
124
|
of ".": linting whatever happens to be in the checkout is how a green
|
|
108
125
|
report over a scope nobody chose gets produced.
|
|
126
|
+
--fix write every fix the rules offer (section signs, leading zeros, figure
|
|
127
|
+
references), then report what is left
|
|
109
128
|
--config <file> read the settings from this file instead of the discovered one
|
|
110
129
|
--json machine-readable findings on stdout, nothing else on it
|
|
111
130
|
--max-warnings <n> fail when warnings exceed n. Default -1: warnings never fail, because
|
|
112
131
|
most findings here are advisory and a gate that fails on advice gets muted
|
|
113
132
|
|
|
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:
|
|
133
|
+
settings — paperlint.json, at two levels, one schema. Both are optional.
|
|
117
134
|
|
|
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
|
-
}
|
|
135
|
+
paperlint.json (the project root, beside package.json) — found by walking up from the
|
|
136
|
+
current directory. \`--config\` names another file of the same shape.
|
|
129
137
|
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
138
|
+
{
|
|
139
|
+
"papersDir": "papers",
|
|
140
|
+
"rules": [ { "files": ["papers/my-paper/**"],
|
|
141
|
+
"rules": { "pdf/last-page-balance": "error" } } ]
|
|
142
|
+
}
|
|
135
143
|
|
|
136
|
-
|
|
144
|
+
"papersDir" defaults to "papers". "extends", "kind" and "pdf" here are defaults for every
|
|
145
|
+
paper. "papersDir", "structure" and the skills' keys are allowed only here.
|
|
137
146
|
|
|
138
|
-
|
|
147
|
+
<papersDir>/<paper>/paperlint.json — one paper, merged over the root file:
|
|
139
148
|
|
|
140
|
-
|
|
149
|
+
{ "extends": "paperlint:aisec", "kind": "research", "rules": { "pdf/last-page-balance": "error" } }
|
|
150
|
+
|
|
151
|
+
"extends" names a venue preset: paperlint:<name> (shipped: ${SHIPPED_VENUES().join(", ")})
|
|
141
152
|
or ./path.jsonc, relative to the paperlint.json. npm presets are not supported yet.
|
|
142
153
|
|
|
143
|
-
"rules"
|
|
144
|
-
|
|
154
|
+
"rules" is { "<rule>": "<severity>" } for every paper file in scope, or ESLint flat-config
|
|
155
|
+
blocks (files, ignores, rules) with globs relative to that file. Order, later wins: paperlint's
|
|
156
|
+
own, the venue preset's, the root file's, the paper's. Optional rules (off unless turned on):
|
|
157
|
+
pdf/last-page-balance. The venue rules (pdf/fresh, pdf/profile, pdf/fonts, pdf/geometry,
|
|
158
|
+
pdf/limits, pdf/body-size, pdf/measured) are on for every paper with a venue preset; set one to
|
|
159
|
+
"off" to skip it. An unknown key, in either file, is an error.
|
|
145
160
|
`;
|
|
146
161
|
/** The config the user would otherwise write by hand. The data comes from `opts`, the mechanism is here. */
|
|
147
162
|
export function buildConfig(opts = {}, texLanguage) {
|
|
148
163
|
const paperRules = { ...researchQuestion.rules, ...typography.rules };
|
|
149
|
-
|
|
164
|
+
// The reference rules judge `_build/references.json`, and only on `paper.tex`.
|
|
165
|
+
const texPaperRules = {
|
|
166
|
+
...paperRules,
|
|
167
|
+
...referenceRules({ files: nodeFiles }),
|
|
168
|
+
};
|
|
169
|
+
// Each typography rule reports every occurrence where it is, and fixes it (`--fix`).
|
|
170
|
+
const prose = {
|
|
171
|
+
"paper/research-question": "warn",
|
|
172
|
+
"paper/section-word": "warn",
|
|
173
|
+
"paper/leading-zero": "warn",
|
|
174
|
+
};
|
|
150
175
|
const md = {
|
|
151
176
|
language: "markdown/gfm",
|
|
152
177
|
languageOptions: { frontmatter: "yaml" },
|
|
@@ -180,51 +205,30 @@ export function buildConfig(opts = {}, texLanguage) {
|
|
|
180
205
|
rules: {
|
|
181
206
|
"paper/stages": "error",
|
|
182
207
|
"paper/source": "error",
|
|
183
|
-
"paper/author-list": [
|
|
184
|
-
"warn",
|
|
185
|
-
opts.authorListCommand ? { command: opts.authorListCommand } : {},
|
|
186
|
-
],
|
|
187
208
|
},
|
|
188
209
|
},
|
|
189
210
|
{
|
|
190
211
|
files: ["**/paper.md", "**/draft.md"],
|
|
191
212
|
plugins: { markdown, paper: { rules: paperRules } },
|
|
192
213
|
...md,
|
|
193
|
-
rules:
|
|
194
|
-
"paper/research-question": "warn",
|
|
195
|
-
"paper/typography": typographyOpt,
|
|
196
|
-
},
|
|
214
|
+
rules: prose,
|
|
197
215
|
},
|
|
198
216
|
{
|
|
199
217
|
files: ["**/reviews/*.md"],
|
|
200
|
-
plugins: {
|
|
201
|
-
markdown,
|
|
202
|
-
review: { rules: { ...findingsCause.rules } },
|
|
203
|
-
doc: docFields,
|
|
204
|
-
},
|
|
218
|
+
plugins: { markdown, review: reviewFrontmatter },
|
|
205
219
|
...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
|
-
},
|
|
220
|
+
// A review's frontmatter is a record, validated by paperlint's JSON Schema
|
|
221
|
+
// (eslint-rules/review-frontmatter.mjs).
|
|
222
|
+
rules: { "review/frontmatter": "error" },
|
|
223
|
+
},
|
|
224
|
+
{
|
|
225
|
+
// A sibling card says how much of the competing paper was read (`read:`). The cards are
|
|
226
|
+
// written by this package's analyze-sibling-paper skill; its README is an index, not a card.
|
|
227
|
+
files: ["**/siblings/*.md"],
|
|
228
|
+
ignores: ["**/siblings/README.md"],
|
|
229
|
+
plugins: { markdown, sibling: siblingFrontmatter },
|
|
230
|
+
...md,
|
|
231
|
+
rules: { "sibling/frontmatter": "warn" },
|
|
228
232
|
},
|
|
229
233
|
];
|
|
230
234
|
// `.tex` only if the language loaded: it pulls in the LaTeX parser, and dying because of it on a
|
|
@@ -234,21 +238,32 @@ export function buildConfig(opts = {}, texLanguage) {
|
|
|
234
238
|
files: ["**/paper.tex"],
|
|
235
239
|
plugins: {
|
|
236
240
|
tex: { languages: { latex: texLanguage }, rules: texBuild },
|
|
237
|
-
paper: { rules:
|
|
241
|
+
paper: { rules: texPaperRules },
|
|
242
|
+
bib: bibReachable,
|
|
238
243
|
},
|
|
239
244
|
language: "tex/latex",
|
|
240
245
|
rules: {
|
|
241
|
-
|
|
242
|
-
|
|
246
|
+
...prose,
|
|
247
|
+
...REFERENCE_RULE_LEVELS,
|
|
248
|
+
"paper/figure-ref-style": "warn",
|
|
249
|
+
"bib/reachable-entry": "warn",
|
|
243
250
|
"tex/future-promise": "warn",
|
|
244
251
|
"tex/acm-frontmatter-override": "error",
|
|
245
252
|
// Silent for a paper whose paperlint.json names no venue (src/venue-rules.ts).
|
|
246
253
|
...VENUE_RULE_LEVELS,
|
|
247
254
|
},
|
|
248
255
|
});
|
|
256
|
+
// 🔴 ONLY THE FILES THESE BLOCKS CLAIM ARE LINTED (src/paper-files.ts). Without this block ESLint's
|
|
257
|
+
// built-in defaults lint every .js/.mjs/.cjs under the papers directory — a paper's vendored
|
|
258
|
+
// `repro/` code failed the run with 74 parse errors and not one finding on a paper file. It goes
|
|
259
|
+
// FIRST: the `.template/` ignore below must come after its directory un-ignore to win.
|
|
260
|
+
const owners = ruleOwners(cfg);
|
|
261
|
+
cfg.unshift(scopeToOwned(ownedPatterns(cfg)));
|
|
249
262
|
// 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
|
-
|
|
263
|
+
// `readConfig`; each carries the settings file's directory as its `basePath`. Each is split so a
|
|
264
|
+
// rule reaches only the files its plugin is registered for: `paper` is a different plugin beside
|
|
265
|
+
// PIPELINE-STATUS.md than beside paper.tex, and ESLint throws on a rule its plugin lacks.
|
|
266
|
+
cfg.push(...(opts.rules ?? []).flatMap((b) => narrowToOwners(b, owners)));
|
|
252
267
|
return cfg;
|
|
253
268
|
}
|
|
254
269
|
/**
|
|
@@ -256,6 +271,54 @@ export function buildConfig(opts = {}, texLanguage) {
|
|
|
256
271
|
* config rather than listed again. `@eslint/markdown` is a dependency's plugin, not paperlint's.
|
|
257
272
|
*/
|
|
258
273
|
export const SHIPPED_RULES = shippedRuleIds(buildConfig({}, { sentinel: "tex language" }), [markdown]);
|
|
274
|
+
/**
|
|
275
|
+
* Every plugin paperlint's config registers, ONE object per name, read off that config. In it one
|
|
276
|
+
* name is bound to different objects in different blocks (`paper` beside `PIPELINE-STATUS.md` is
|
|
277
|
+
* not `paper` beside `paper.tex`); here their rules are merged, so a config that lints any paper
|
|
278
|
+
* file knows every rule id. `@eslint/markdown` is a dependency's plugin and is left out. The LaTeX
|
|
279
|
+
* language rides on `tex` when `texLanguage` is given, and that is how a consumer registers it: a
|
|
280
|
+
* second `tex` plugin of its own is refused by ESLint ("Cannot redefine plugin "tex"", measured
|
|
281
|
+
* with and without the language here). Without it, `tex` carries rules only — for a config that
|
|
282
|
+
* lints markdown papers alone.
|
|
283
|
+
*/
|
|
284
|
+
export function rulePlugins(texLanguage) {
|
|
285
|
+
const out = {};
|
|
286
|
+
for (const block of buildConfig({}, texLanguage ?? { sentinel: "tex" }))
|
|
287
|
+
for (const [name, plugin] of Object.entries(block.plugins ?? {}))
|
|
288
|
+
if (plugin !== markdown)
|
|
289
|
+
out[name] = { rules: { ...out[name]?.rules, ...plugin.rules } };
|
|
290
|
+
if (out["tex"] && texLanguage !== undefined)
|
|
291
|
+
return {
|
|
292
|
+
...out,
|
|
293
|
+
tex: { ...out["tex"], languages: { latex: texLanguage } },
|
|
294
|
+
};
|
|
295
|
+
return out;
|
|
296
|
+
}
|
|
297
|
+
/**
|
|
298
|
+
* A flat-config fragment for a consumer's OWN ESLint run over paper files (#104): every rule
|
|
299
|
+
* paperlint ships, registered and turned off. Without it a `% eslint-disable-next-line
|
|
300
|
+
* paper/leading-zero -- why` — the documented escape hatch — fails that run with "Definition for
|
|
301
|
+
* rule 'paper/leading-zero' was not found". Off, because `paperlint lint` is where they run.
|
|
302
|
+
*
|
|
303
|
+
* The second block is not optional, and it is measured: a directive naming a rule that is off
|
|
304
|
+
* suppresses nothing, so ESLint reports it as an unused directive (a warning by default, an error
|
|
305
|
+
* under `reportUnusedDisableDirectives: "error"`). On the files paperlint lints that report is
|
|
306
|
+
* switched off in THIS run — `paperlint lint` still reports a directive that silences nothing.
|
|
307
|
+
*/
|
|
308
|
+
export function rulesOff(texLanguage) {
|
|
309
|
+
return [
|
|
310
|
+
{
|
|
311
|
+
name: "paperlint/rules-off",
|
|
312
|
+
plugins: rulePlugins(texLanguage),
|
|
313
|
+
rules: Object.fromEntries([...SHIPPED_RULES].map((id) => [id, "off"])),
|
|
314
|
+
},
|
|
315
|
+
{
|
|
316
|
+
name: "paperlint/rules-off/directives",
|
|
317
|
+
files: PAPER_FILE_PATTERNS,
|
|
318
|
+
linterOptions: { reportUnusedDisableDirectives: "off" },
|
|
319
|
+
},
|
|
320
|
+
];
|
|
321
|
+
}
|
|
259
322
|
/** Whether a rule entry (`"error"`, `2`, `["warn", {…}]`) turns the rule on. */
|
|
260
323
|
const isOn = (entry) => {
|
|
261
324
|
const sev = Array.isArray(entry) ? entry[0] : entry;
|
|
@@ -270,81 +333,103 @@ export const OPTIONAL_RULES = new Set([...SHIPPED_RULES].filter((id) => !buildCo
|
|
|
270
333
|
* 🔴 AN OPTIONAL RULE THAT IS ON AND REACHES NO PAPER IS A GREEN ZERO. A `files` glob that matches
|
|
271
334
|
* nothing — a typo, a path relative to the wrong directory — leaves the rule never invoked, and a
|
|
272
335
|
* rule that never runs reports exactly like a rule that passed. So for every optional rule the
|
|
273
|
-
* consumer turned on, some
|
|
336
|
+
* consumer turned on, some `paper.tex` must actually have it enabled; the ones none has are
|
|
274
337
|
* returned, for the caller to refuse.
|
|
338
|
+
*
|
|
339
|
+
* `files` is every `paper.tex` the guard may count: the ones this run linted AND every paper of the
|
|
340
|
+
* project. Judged against the run alone, `paperlint lint papers/c` failed on a block written for
|
|
341
|
+
* papers a and b (#103) — a block that reaches a paper outside this run is not dead. A glob that
|
|
342
|
+
* reaches no paper of the project at all still fails, from a subset as from the whole.
|
|
275
343
|
*/
|
|
276
|
-
export async function silentOptionalRules(eslint,
|
|
344
|
+
export async function silentOptionalRules(eslint, files, opts) {
|
|
277
345
|
const turnedOn = new Set((opts.rules ?? []).flatMap((b) => Object.entries(b.rules)
|
|
278
346
|
.filter(([id, e]) => OPTIONAL_RULES.has(id) && isOn(e))
|
|
279
347
|
.map(([id]) => id)));
|
|
280
348
|
const reached = new Set();
|
|
281
|
-
for (const f of
|
|
349
|
+
for (const f of new Set(files.filter((p) => basename(p) === MAIN))) {
|
|
350
|
+
// Undefined for a file outside ESLint's cwd or scope: it reaches nothing.
|
|
282
351
|
const cfg = (await eslint.calculateConfigForFile(f));
|
|
283
352
|
for (const id of turnedOn)
|
|
284
|
-
if (isOn(cfg
|
|
353
|
+
if (isOn(cfg?.rules?.[id]))
|
|
285
354
|
reached.add(id);
|
|
286
355
|
}
|
|
287
356
|
return [...turnedOn].filter((id) => !reached.has(id));
|
|
288
357
|
}
|
|
289
358
|
/**
|
|
290
|
-
* Every linted paper's
|
|
291
|
-
*
|
|
292
|
-
*
|
|
293
|
-
*
|
|
359
|
+
* Every linted paper's rules, as ESLint blocks scoped to that paper, in two groups: what its venue
|
|
360
|
+
* preset turns on, and what its own `paperlint.json` says. The caller puts the root's blocks between
|
|
361
|
+
* them. A file that does not parse, or names a rule paperlint does not ship, stops the run with one
|
|
362
|
+
* line naming the file — the same strictness as the root's own `rules`.
|
|
294
363
|
*/
|
|
295
364
|
export function paperRuleBlocks(paths) {
|
|
296
|
-
|
|
297
|
-
const
|
|
365
|
+
// A FILE named on the command line belongs to the paper it sits in: that paper's settings apply.
|
|
366
|
+
const dirs = paths.map((p) => existsSync(p) && statSync(p).isFile() ? dirname(p) : p);
|
|
367
|
+
const papers = [...new Set(dirs.flatMap((p) => [p, ...papersIn(p)]))];
|
|
368
|
+
const out = { preset: [], own: [] };
|
|
298
369
|
for (const dir of papers) {
|
|
299
370
|
const p = paperPreset(dir, PRESET_DEPS);
|
|
300
371
|
if (p.kind === "settings-problem" && p.problem.kind === "broken")
|
|
301
372
|
return { ok: false, error: paperPresetProblem(dir, p) ?? dir };
|
|
302
|
-
const
|
|
303
|
-
if (!
|
|
304
|
-
return
|
|
305
|
-
|
|
306
|
-
|
|
373
|
+
const blocks = rulesOfPaper(dir, p);
|
|
374
|
+
if (!blocks.ok)
|
|
375
|
+
return blocks;
|
|
376
|
+
out.preset.push(...blocks.value.preset);
|
|
377
|
+
out.own.push(...blocks.value.own);
|
|
307
378
|
}
|
|
308
379
|
return { ok: true, value: out };
|
|
309
380
|
}
|
|
310
381
|
/**
|
|
311
|
-
* One paper's
|
|
312
|
-
*
|
|
382
|
+
* One paper's blocks: its preset chain's `rules`, and its own — either `{ id: severity }` over the
|
|
383
|
+
* paper's files, or ESLint blocks with globs relative to the paper. A preset that does not resolve
|
|
384
|
+
* contributes nothing here; `pdf/profile` reports it on the paper.
|
|
313
385
|
*/
|
|
314
386
|
function rulesOfPaper(dir, p) {
|
|
315
387
|
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
388
|
const fromPreset = p.kind === "resolved"
|
|
322
389
|
? parseRuleEntries(p.preset.rules, `the venue preset ${p.preset.chain.join(" → ")} → "rules"`, SHIPPED_RULES)
|
|
323
390
|
: { ok: true, value: {} };
|
|
324
391
|
if (!fromPreset.ok)
|
|
325
392
|
return fromPreset;
|
|
326
|
-
const
|
|
393
|
+
const own = settings === null
|
|
394
|
+
? { ok: true, value: [] }
|
|
395
|
+
: Array.isArray(settings.rules)
|
|
396
|
+
? parseRuleBlocks(settings.rules, join(dir, CONFIG_FILE), SHIPPED_RULES, dir)
|
|
397
|
+
: ownRules(dir, settings);
|
|
398
|
+
if (!own.ok)
|
|
399
|
+
return own;
|
|
400
|
+
// No `files`: `narrowToOwners` scopes the block to what paperlint lints under `dir`, exceptions
|
|
401
|
+
// included. A copy of the globs here is how the siblings index got parsed as JavaScript (#101).
|
|
402
|
+
const scoped = (rules) => Object.keys(rules).length ? [{ basePath: dir, rules }] : [];
|
|
403
|
+
return {
|
|
404
|
+
ok: true,
|
|
405
|
+
value: { preset: scoped(fromPreset.value), own: [...own.value] },
|
|
406
|
+
};
|
|
407
|
+
}
|
|
408
|
+
/** A paper's `{ id: severity }` rules, as one block over the paper's files (narrowed later, #101). */
|
|
409
|
+
function ownRules(dir, settings) {
|
|
410
|
+
const own = paperRules(dir, settings, SHIPPED_RULES);
|
|
411
|
+
if (!own.ok)
|
|
412
|
+
return own;
|
|
327
413
|
return {
|
|
328
414
|
ok: true,
|
|
329
|
-
value:
|
|
330
|
-
? { basePath: dir, files: PAPER_FILE_PATTERNS, rules }
|
|
331
|
-
: null,
|
|
415
|
+
value: own.value ? [{ basePath: dir, rules: own.value }] : [],
|
|
332
416
|
};
|
|
333
417
|
}
|
|
334
418
|
/**
|
|
335
|
-
* The
|
|
336
|
-
*
|
|
337
|
-
* language — `paperlint.json` itself would be parsed as JavaScript.
|
|
419
|
+
* The globs paperlint lints, for the refusal message only. Which files a block reaches is decided by
|
|
420
|
+
* `narrowToOwners` from `ownedScopes` (src/paper-files.ts), where each glob keeps its exceptions.
|
|
338
421
|
*/
|
|
339
|
-
const PAPER_FILE_PATTERNS =
|
|
340
|
-
...new Set(buildConfig({}, { sentinel: "tex language" }).flatMap((b) => b.files ?? [])),
|
|
341
|
-
];
|
|
422
|
+
const PAPER_FILE_PATTERNS = ownedPatterns(buildConfig({}, { sentinel: "tex language" }));
|
|
342
423
|
/**
|
|
343
|
-
* The
|
|
344
|
-
*
|
|
424
|
+
* The root `paperlint.json` after the boundary: an unknown key is refused by name, the paper
|
|
425
|
+
* defaults (`extends`, `kind`, `pdf`) are checked the way a paper's own are, and `rules` becomes
|
|
426
|
+
* parsed config blocks. Nothing after this sees the raw object.
|
|
345
427
|
*/
|
|
346
|
-
export function parseSettings(
|
|
347
|
-
|
|
428
|
+
export function parseSettings(json, where, baseDir) {
|
|
429
|
+
if (typeof json !== "object" || json === null || Array.isArray(json))
|
|
430
|
+
return { ok: false, error: `${where}: must be a JSON object` };
|
|
431
|
+
const raw = json;
|
|
432
|
+
const opts = raw;
|
|
348
433
|
const unknown = unknownKeys(raw);
|
|
349
434
|
if (unknown.length > 0)
|
|
350
435
|
return {
|
|
@@ -352,15 +437,18 @@ export function parseSettings(opts, where, baseDir) {
|
|
|
352
437
|
error: `${where}: unknown key${unknown.length > 1 ? "s" : ""} ${unknown.map((k) => `"${k}"`).join(", ")} — ` +
|
|
353
438
|
`a typo would otherwise read as "not set". Known keys: ${Object.keys(SETTINGS_KEYS).join(", ")}`,
|
|
354
439
|
};
|
|
440
|
+
const defaults = stringFields(raw);
|
|
441
|
+
if (!defaults.ok)
|
|
442
|
+
return { ok: false, error: `${where}: ${defaults.error}` };
|
|
355
443
|
const rules = parseRuleBlocks(raw["rules"], where, SHIPPED_RULES, baseDir);
|
|
356
444
|
if (!rules.ok)
|
|
357
445
|
return rules;
|
|
358
446
|
return { ok: true, value: { ...opts, rules: rules.value } };
|
|
359
447
|
}
|
|
360
448
|
/**
|
|
361
|
-
* The directory ESLint runs from. ESLint ignores every file outside it (#48), and
|
|
362
|
-
* `
|
|
363
|
-
*
|
|
449
|
+
* The directory ESLint runs from. ESLint ignores every file outside it (#48), and the consumer's
|
|
450
|
+
* `rules` globs are written relative to the config's directory. So: the deepest directory holding
|
|
451
|
+
* the config's directory and every path. With the
|
|
364
452
|
* papers inside the config's directory, that is the config's directory itself.
|
|
365
453
|
*/
|
|
366
454
|
const lintRoot = (home, paths) => commonDir([home, ...paths]);
|
|
@@ -378,6 +466,7 @@ export function parseArgs(argv) {
|
|
|
378
466
|
paths: [],
|
|
379
467
|
config: null,
|
|
380
468
|
json: false,
|
|
469
|
+
fix: false,
|
|
381
470
|
all: false,
|
|
382
471
|
dryRun: false,
|
|
383
472
|
check: false,
|
|
@@ -385,6 +474,8 @@ export function parseArgs(argv) {
|
|
|
385
474
|
noHooks: false,
|
|
386
475
|
paper: null,
|
|
387
476
|
format: null,
|
|
477
|
+
venue: null,
|
|
478
|
+
kind: null,
|
|
388
479
|
hooksMode: null,
|
|
389
480
|
// -1 = warnings NEVER fail the run. In this set most findings are advisory by design, and a
|
|
390
481
|
// gate that fails on advice gets muted entirely.
|
|
@@ -406,11 +497,27 @@ export function parseArgs(argv) {
|
|
|
406
497
|
return v;
|
|
407
498
|
};
|
|
408
499
|
for (let i = 0; i < rest.length; i++) {
|
|
409
|
-
const
|
|
410
|
-
if (
|
|
500
|
+
const arg = rest[i];
|
|
501
|
+
if (arg === undefined)
|
|
411
502
|
continue;
|
|
503
|
+
// `--flag=value` is `--flag value` for every flag that takes a value: the CI action writes
|
|
504
|
+
// `--max-warnings="$N"`, and that form used to fall through to the list of paths.
|
|
505
|
+
const eq = arg.indexOf("=");
|
|
506
|
+
const inline = arg.startsWith("--") && eq > 0 && VALUE_FLAGS.has(arg.slice(0, eq))
|
|
507
|
+
? arg.slice(eq + 1)
|
|
508
|
+
: undefined;
|
|
509
|
+
const a = inline === undefined ? arg : arg.slice(0, eq);
|
|
510
|
+
const take = () => {
|
|
511
|
+
if (inline === undefined)
|
|
512
|
+
return valueFor(a, ++i);
|
|
513
|
+
if (inline === "")
|
|
514
|
+
out.missingValue = a;
|
|
515
|
+
return inline === "" ? undefined : inline;
|
|
516
|
+
};
|
|
412
517
|
if (a === "--json")
|
|
413
518
|
out.json = true;
|
|
519
|
+
else if (a === "--fix")
|
|
520
|
+
out.fix = true;
|
|
414
521
|
else if (a === "--all")
|
|
415
522
|
out.all = true;
|
|
416
523
|
else if (a === "--dry-run")
|
|
@@ -424,27 +531,43 @@ export function parseArgs(argv) {
|
|
|
424
531
|
else if (a.startsWith("--hooks="))
|
|
425
532
|
out.hooksMode = a.slice("--hooks=".length);
|
|
426
533
|
else if (a === "--paper")
|
|
427
|
-
out.paper =
|
|
534
|
+
out.paper = take() ?? null;
|
|
428
535
|
else if (a === "--format")
|
|
429
|
-
out.format =
|
|
536
|
+
out.format = take() ?? null;
|
|
537
|
+
else if (a === "--venue")
|
|
538
|
+
out.venue = take() ?? null;
|
|
539
|
+
else if (a === "--kind")
|
|
540
|
+
out.kind = take() ?? null;
|
|
430
541
|
// `--options` was the first spelling and is kept working. It named the wrong thing — every
|
|
431
542
|
// other tool in the stack calls this file its config — but a flag in someone's CI is not
|
|
432
543
|
// ours to break.
|
|
433
544
|
else if (a === "--config" || a === "--options")
|
|
434
|
-
out.config =
|
|
545
|
+
out.config = take() ?? null;
|
|
435
546
|
else if (a === "--max-warnings") {
|
|
436
|
-
const v =
|
|
547
|
+
const v = take();
|
|
437
548
|
if (v !== undefined)
|
|
438
549
|
out.maxWarnings = Number(v);
|
|
439
550
|
}
|
|
440
551
|
else if (a === "--help" || a === "-h")
|
|
441
552
|
out.help = true;
|
|
553
|
+
// An unknown flag is refused by name. Read as a path, it silently lints something else.
|
|
554
|
+
else if (a.startsWith("-") && a !== "-")
|
|
555
|
+
out.unknownFlag ??= a;
|
|
442
556
|
else
|
|
443
557
|
out.paths.push(a);
|
|
444
558
|
}
|
|
445
559
|
return out;
|
|
446
560
|
}
|
|
447
|
-
|
|
561
|
+
/** The flags that take a value, in either spelling: `--flag value` or `--flag=value`. */
|
|
562
|
+
const VALUE_FLAGS = new Set([
|
|
563
|
+
"--paper",
|
|
564
|
+
"--format",
|
|
565
|
+
"--venue",
|
|
566
|
+
"--kind",
|
|
567
|
+
"--config",
|
|
568
|
+
"--options",
|
|
569
|
+
"--max-warnings",
|
|
570
|
+
]);
|
|
448
571
|
/**
|
|
449
572
|
* This package's own version, from the `package.json` beside `src/` and `dist/` alike. `init` pins
|
|
450
573
|
* the CI action to its release tag; an unreadable manifest yields `undefined`, and init then keeps
|
|
@@ -459,119 +582,61 @@ function ownVersion() {
|
|
|
459
582
|
return undefined;
|
|
460
583
|
}
|
|
461
584
|
}
|
|
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
585
|
/**
|
|
498
586
|
* Reading the config, ONE reader for all commands. Pulled out of `run()` the moment a second
|
|
499
587
|
* command needed the same config (`build`): two copies of this block would have drifted apart on
|
|
500
588
|
* the very first edit — exactly the class that already cost us the empty-set guard in two places.
|
|
501
589
|
*
|
|
502
|
-
*
|
|
590
|
+
* 🔴 THE CONFIG FINDS ITSELF: the project root is the nearest directory up from `cwd` with a root
|
|
591
|
+
* `paperlint.json`, else with a `package.json`, else `cwd` (`findProjectRoot`). The file is
|
|
592
|
+
* optional — without one every setting has its default, and papers are in `papers/`. An explicit
|
|
593
|
+
* `--config` beats the discovered one: it was named out loud, and a substitution is never silent.
|
|
594
|
+
*
|
|
595
|
+
* @returns `{ opts, configPath, root }` on success, or `{ code }` — and then the caller exits with it.
|
|
503
596
|
*/
|
|
504
597
|
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)) {
|
|
598
|
+
if (a.config && !existsSync(resolve(cwd, a.config))) {
|
|
510
599
|
err(`config file not found: ${a.config}`);
|
|
511
600
|
return { code: 2 };
|
|
512
601
|
}
|
|
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}`);
|
|
602
|
+
const root = a.config
|
|
603
|
+
? dirname(resolve(cwd, a.config))
|
|
604
|
+
: findProjectRoot(cwd);
|
|
605
|
+
const found = a.config ? resolve(cwd, a.config) : join(root, CONFIG_FILE);
|
|
606
|
+
const configPath = existsSync(found) ? found : null;
|
|
607
|
+
if (configPath === null)
|
|
608
|
+
return { opts: {}, configPath, root };
|
|
609
|
+
let parsed;
|
|
610
|
+
try {
|
|
611
|
+
parsed = JSON.parse(readFileSync(configPath, "utf8"));
|
|
543
612
|
}
|
|
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);
|
|
613
|
+
catch (e) {
|
|
614
|
+
err(`${configPath} is not valid JSON: ${e.message}`);
|
|
550
615
|
return { code: 2 };
|
|
551
616
|
}
|
|
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.`);
|
|
617
|
+
// The discovered config is NAMED out loud. Otherwise a run from someone else's directory picks
|
|
618
|
+
// up someone else's file and does not say so — and its settings then look like findings.
|
|
619
|
+
//
|
|
620
|
+
// 🔴 IN `--json` MODE — TO stderr. Machine output must be ONE parsable document: a line before
|
|
621
|
+
// the array breaks any `| jq`, and it breaks it for the consumer, not for us.
|
|
622
|
+
(a.json ? err : log)(`config: ${relative(cwd, configPath) || CONFIG_FILE}`);
|
|
623
|
+
const settings = parseSettings(parsed, relative(cwd, configPath) || CONFIG_FILE, root);
|
|
624
|
+
if (!settings.ok) {
|
|
625
|
+
err(settings.error);
|
|
567
626
|
return { code: 2 };
|
|
568
627
|
}
|
|
569
|
-
return { opts, configPath };
|
|
628
|
+
return { opts: settings.value, configPath, root };
|
|
570
629
|
}
|
|
571
|
-
/** The papers directory field of the settings, read by its one declared name. */
|
|
630
|
+
/** The papers directory field of the settings, read by its one declared name; the default when absent. */
|
|
572
631
|
export function papersDirOf(opts) {
|
|
573
|
-
|
|
632
|
+
const declared = opts[PAPERS_DIR_FIELD];
|
|
633
|
+
return declared === undefined ? DEFAULT_PAPERS_ROOT : declared;
|
|
574
634
|
}
|
|
635
|
+
/** The error for a papers directory that is missing or holds no paper. */
|
|
636
|
+
export const noPapersMessage = (dir) => `no papers in ${dir}/ — create one with \`npx paperlint new <name>\`, or set "${PAPERS_DIR_FIELD}" ` +
|
|
637
|
+
`in ${CONFIG_FILE} if your papers live elsewhere`;
|
|
638
|
+
/** The papers directories of a read config, absolute: each relative to the project root. */
|
|
639
|
+
export const papersRoots = (cfg) => toPaths(papersDirOf(cfg.opts)).map((rel) => resolve(cfg.root, rel));
|
|
575
640
|
/** The papers directory may be one directory or several; both spellings normalise to a list. */
|
|
576
641
|
export function toPaths(papers) {
|
|
577
642
|
if (typeof papers === "string")
|
|
@@ -580,7 +645,6 @@ export function toPaths(papers) {
|
|
|
580
645
|
return papers.filter((x) => typeof x === "string" && x.trim());
|
|
581
646
|
return [];
|
|
582
647
|
}
|
|
583
|
-
const hasPapers = (opts) => toPaths(papersDirOf(opts)).length > 0;
|
|
584
648
|
/**
|
|
585
649
|
* `paperlint hook <name>` — run an editor hook. It exists for ONE thing: so that the wiring does not
|
|
586
650
|
* address the runtime from the project root.
|
|
@@ -648,16 +712,90 @@ resolve = (spec) => createRequire(import.meta.url).resolve(spec), } = {}) {
|
|
|
648
712
|
* become two implementations. The lint runs on THAT folder, so the first thing printed after the
|
|
649
713
|
* file list is its verdict rather than the old "missing PIPELINE-STATUS.md".
|
|
650
714
|
*/
|
|
651
|
-
export async function createPaperAt(papersRoot, name, format, { log, err, cwd, }) {
|
|
652
|
-
const result = newPaper(papersRoot, name, format);
|
|
715
|
+
export async function createPaperAt(papersRoot, name, format, { log, err, cwd, venue = null, }) {
|
|
716
|
+
const result = newPaper(papersRoot, name, format, { venue });
|
|
653
717
|
const here = (p) => relative(cwd, p) || p;
|
|
654
718
|
for (const line of reportNewPaper(result, here))
|
|
655
719
|
log(line);
|
|
656
720
|
if (!result.ok)
|
|
657
721
|
return 2;
|
|
722
|
+
const config = here(join(result.dir, CONFIG_FILE));
|
|
723
|
+
if (venue === null)
|
|
724
|
+
log(` venue: none yet — set "extends" in ${config}, or next time: paperlint new <name> --venue <preset> (${SHIPPED_VENUES().join(", ")})`);
|
|
725
|
+
else if (venue.kind === null && venue.kinds.length > 0)
|
|
726
|
+
log(` kind: not set — \`${venue.label}\` sets a page limit per kind (${venue.kinds.join(", ")}); add "kind" to ${config}. Until then lint reports pdf/profile`);
|
|
658
727
|
log(``);
|
|
659
728
|
return run(["lint", result.dir], { log, err, cwd });
|
|
660
729
|
}
|
|
730
|
+
/**
|
|
731
|
+
* `--venue` / `--kind` → the `extends` and `kind` to write into `<paperDir>/paperlint.json`, or why
|
|
732
|
+
* not. Parsed here, at the boundary, and resolved through the same `resolvePreset` lint uses — so a
|
|
733
|
+
* venue `new` accepts is a venue lint resolves. On a terminal with no `--venue` it asks, with
|
|
734
|
+
* "none" as the default; without a terminal it chooses nothing (null), as before.
|
|
735
|
+
*
|
|
736
|
+
* A path is relative to where the command runs, because that is where it was typed; it is written
|
|
737
|
+
* relative to the paper's `paperlint.json`, because that is what `extends` is relative to. Written
|
|
738
|
+
* as typed, `./venues/x.jsonc` would name `<paper>/venues/x.jsonc`.
|
|
739
|
+
*/
|
|
740
|
+
export async function chooseVenue(flags, { paperDir, cwd, interactive, ask, }) {
|
|
741
|
+
const shipped = SHIPPED_VENUES();
|
|
742
|
+
if (flags.kind !== null && flags.venue === null)
|
|
743
|
+
return bad(`--kind needs --venue: a kind is a page limit of one venue preset — \`paperlint new <name> --venue <preset> --kind ${flags.kind}\``);
|
|
744
|
+
const asked = async (q) => (await ask(q).catch(() => "")).trim();
|
|
745
|
+
const venue = flags.venue ??
|
|
746
|
+
(interactive
|
|
747
|
+
? await asked(`venue: ${[...shipped, "none"].join(" / ")} [none] `)
|
|
748
|
+
: "");
|
|
749
|
+
if (venue === "" || (flags.venue === null && venue === "none"))
|
|
750
|
+
return { ok: true, value: null };
|
|
751
|
+
const spec = venueSpec(venue, { shipped, paperDir, cwd });
|
|
752
|
+
if (!spec.ok)
|
|
753
|
+
return spec;
|
|
754
|
+
const r = resolvePreset(spec.value, join(paperDir, CONFIG_FILE), PRESET_DEPS);
|
|
755
|
+
if (!r.ok)
|
|
756
|
+
return bad(`--venue ${venue}: ${presetProblemText(r.error)}`);
|
|
757
|
+
const kinds = [...r.value.format.kinds.keys()];
|
|
758
|
+
const label = r.value.label;
|
|
759
|
+
if (flags.kind !== null) {
|
|
760
|
+
if (kinds.length === 0)
|
|
761
|
+
return bad(`--kind ${flags.kind}: \`${label}\` has no kinds — it sets no page limit, so there is no kind to choose`);
|
|
762
|
+
if (!kinds.includes(flags.kind))
|
|
763
|
+
return bad(`--kind ${flags.kind}: \`${label}\` has no kind \`${flags.kind}\`; its kinds: ${kinds.join(", ")}`);
|
|
764
|
+
}
|
|
765
|
+
const kind = flags.kind ??
|
|
766
|
+
(interactive && kinds.length > 0
|
|
767
|
+
? await asked(`kind: ${[...kinds, "later"].join(" / ")} [later] `)
|
|
768
|
+
: null);
|
|
769
|
+
return {
|
|
770
|
+
ok: true,
|
|
771
|
+
value: {
|
|
772
|
+
extends: spec.value,
|
|
773
|
+
kind: kind !== null && kinds.includes(kind) ? kind : null,
|
|
774
|
+
label,
|
|
775
|
+
kinds,
|
|
776
|
+
},
|
|
777
|
+
};
|
|
778
|
+
}
|
|
779
|
+
const bad = (error) => ({
|
|
780
|
+
ok: false,
|
|
781
|
+
error,
|
|
782
|
+
});
|
|
783
|
+
/** What `--venue` names → the `extends` value, relative to `<paperDir>/paperlint.json`. */
|
|
784
|
+
function venueSpec(venue, { shipped, paperDir, cwd, }) {
|
|
785
|
+
if (venue.startsWith("./") || venue.startsWith("../")) {
|
|
786
|
+
const file = resolve(cwd, venue);
|
|
787
|
+
if (!existsSync(file))
|
|
788
|
+
return bad(`--venue ${venue}: no such file (${file}) — a path is relative to where you run the command`);
|
|
789
|
+
const rel = relative(paperDir, file).split(sep).join("/");
|
|
790
|
+
return { ok: true, value: rel.startsWith("../") ? rel : `./${rel}` };
|
|
791
|
+
}
|
|
792
|
+
const name = venue.startsWith(SHIPPED_PREFIX)
|
|
793
|
+
? venue.slice(SHIPPED_PREFIX.length)
|
|
794
|
+
: venue;
|
|
795
|
+
if (!shipped.includes(name))
|
|
796
|
+
return bad(`--venue ${venue}: no such venue preset. Shipped: ${shipped.join(", ")} — or a path to your own preset, starting with ./ or ../`);
|
|
797
|
+
return { ok: true, value: `${SHIPPED_PREFIX}${name}` };
|
|
798
|
+
}
|
|
661
799
|
/**
|
|
662
800
|
* `paperlint new <name>` — the papers directory comes from the same declaration every other command
|
|
663
801
|
* reads; there is no second way to name it. See `new-paper.ts` for what it writes and why.
|
|
@@ -665,7 +803,7 @@ export async function createPaperAt(papersRoot, name, format, { log, err, cwd, }
|
|
|
665
803
|
async function runNew(a, { log, err, cwd, ask = askOnTerminal, }) {
|
|
666
804
|
const [name, ...extra] = a.paths;
|
|
667
805
|
if (!name || extra.length > 0) {
|
|
668
|
-
err(`\`new\` takes exactly one paper name: \`paperlint new my-paper [--format tex|md]\``);
|
|
806
|
+
err(`\`new\` takes exactly one paper name: \`paperlint new my-paper [--venue <preset>] [--kind <kind>] [--format tex|md]\``);
|
|
669
807
|
return 2;
|
|
670
808
|
}
|
|
671
809
|
let format = DEFAULT_FORMAT;
|
|
@@ -684,16 +822,37 @@ async function runNew(a, { log, err, cwd, ask = askOnTerminal, }) {
|
|
|
684
822
|
const cfg = readConfig({ ...a, json: false }, { log: () => { }, err, cwd });
|
|
685
823
|
if (cfg.code !== undefined)
|
|
686
824
|
return cfg.code;
|
|
687
|
-
const roots =
|
|
825
|
+
const roots = papersRoots(cfg);
|
|
688
826
|
const papersRoot = roots[0];
|
|
689
|
-
if (
|
|
690
|
-
err(`no
|
|
691
|
-
`Run \`npx paperlint init\` first — it declares the directory in package.json.`);
|
|
827
|
+
if (papersRoot === undefined) {
|
|
828
|
+
err(`"${PAPERS_DIR_FIELD}" names no directory, so there is nowhere to put \`${name}\`.`);
|
|
692
829
|
return 2;
|
|
693
830
|
}
|
|
694
831
|
if (roots.length > 1)
|
|
695
832
|
log(`several papers directories are declared — using the first: ${relative(cwd, papersRoot) || papersRoot}`);
|
|
696
|
-
|
|
833
|
+
const paperDir = join(papersRoot, name);
|
|
834
|
+
// An existing paperlint.json is never overwritten, so a venue for it is refused, not dropped.
|
|
835
|
+
const hasConfig = existsSync(join(paperDir, CONFIG_FILE));
|
|
836
|
+
if (hasConfig && (a.venue !== null || a.kind !== null)) {
|
|
837
|
+
err(`${relative(cwd, join(paperDir, CONFIG_FILE))} already exists and is never overwritten — set "extends" and "kind" in it by hand`);
|
|
838
|
+
return 2;
|
|
839
|
+
}
|
|
840
|
+
const venue = hasConfig
|
|
841
|
+
? { ok: true, value: null }
|
|
842
|
+
: await chooseVenue(a, {
|
|
843
|
+
paperDir,
|
|
844
|
+
cwd,
|
|
845
|
+
interactive: processInteractivity(a.yes).interactive,
|
|
846
|
+
ask,
|
|
847
|
+
});
|
|
848
|
+
if (!venue.ok)
|
|
849
|
+
return (err(venue.error), 2);
|
|
850
|
+
return createPaperAt(papersRoot, name, format, {
|
|
851
|
+
log,
|
|
852
|
+
err,
|
|
853
|
+
cwd,
|
|
854
|
+
venue: venue.value,
|
|
855
|
+
});
|
|
697
856
|
}
|
|
698
857
|
/**
|
|
699
858
|
* 🔴 THE TARGET IS NAMED, "EVERYTHING" IS AN OPTION. That is how it is for everyone whose build is
|
|
@@ -706,12 +865,7 @@ async function runBuild(a, { log, err, cwd, }) {
|
|
|
706
865
|
const cfg = readConfig(a, { log, err, cwd });
|
|
707
866
|
if (cfg.code !== undefined)
|
|
708
867
|
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));
|
|
868
|
+
const roots = papersRoots(cfg);
|
|
715
869
|
let targets;
|
|
716
870
|
if (a.all) {
|
|
717
871
|
targets = roots.flatMap((r) => papersIn(r));
|
|
@@ -735,6 +889,7 @@ async function runBuild(a, { log, err, cwd, }) {
|
|
|
735
889
|
cwd,
|
|
736
890
|
dryRun: a.dryRun,
|
|
737
891
|
log,
|
|
892
|
+
checkReferences: onlineReferences,
|
|
738
893
|
engine: () => engineEnv(targets, a, { log, err }),
|
|
739
894
|
});
|
|
740
895
|
if (out.kind === "no-engine")
|
|
@@ -765,9 +920,7 @@ export function toolchainTex(cwd) {
|
|
|
765
920
|
err: () => { },
|
|
766
921
|
cwd,
|
|
767
922
|
});
|
|
768
|
-
const roots = cfg.code === undefined
|
|
769
|
-
? toPaths(papersDirOf(cfg.opts)).map((rel) => resolve(dirname(cfg.configPath ?? cwd), rel))
|
|
770
|
-
: [];
|
|
923
|
+
const roots = cfg.code === undefined ? papersRoots(cfg) : [];
|
|
771
924
|
const chains = roots
|
|
772
925
|
.flatMap((r) => papersIn(r))
|
|
773
926
|
.map((dir) => paperPreset(dir, PRESET_DEPS))
|
|
@@ -820,6 +973,46 @@ function hostBanalInstaller() {
|
|
|
820
973
|
});
|
|
821
974
|
return banalInstaller({ ...ports, download }, s);
|
|
822
975
|
}
|
|
976
|
+
/** `paperlint init`: its flags checked here, the install itself in `init.ts`. */
|
|
977
|
+
async function runInit(a, { log, err, cwd, }) {
|
|
978
|
+
// Deferred, not implemented: named and refused, rather than read as the directory argument.
|
|
979
|
+
if (a.hooksMode !== null) {
|
|
980
|
+
err(`--hooks=${a.hooksMode} is not implemented. init writes the hooks into .claude/settings.json ` +
|
|
981
|
+
`(shared, committed) or, with --no-hooks, nowhere.`);
|
|
982
|
+
return 2;
|
|
983
|
+
}
|
|
984
|
+
if (a.format !== null && !isFormat(a.format)) {
|
|
985
|
+
err(`--format must be one of ${FORMATS.join(", ")} — got \`${a.format}\``);
|
|
986
|
+
return 2;
|
|
987
|
+
}
|
|
988
|
+
return await init(a.paths[0] ?? ".", {
|
|
989
|
+
log,
|
|
990
|
+
err,
|
|
991
|
+
cwd,
|
|
992
|
+
version: ownVersion(),
|
|
993
|
+
yes: a.yes,
|
|
994
|
+
hooks: !a.noHooks,
|
|
995
|
+
paper: a.paper,
|
|
996
|
+
format: isFormat(a.format) ? a.format : null,
|
|
997
|
+
createPaper: (papersRoot, name, format) => createPaperAt(papersRoot, name, format, { log, err, cwd }),
|
|
998
|
+
tex: {
|
|
999
|
+
installed: () => cachedTree(cacheRoot(process.env)) !== null,
|
|
1000
|
+
install: () => runToolchain({
|
|
1001
|
+
check: false,
|
|
1002
|
+
log,
|
|
1003
|
+
err,
|
|
1004
|
+
banal: hostBanalInstaller(),
|
|
1005
|
+
tex: toolchainTex(resolve(cwd, a.paths[0] ?? ".")),
|
|
1006
|
+
}),
|
|
1007
|
+
},
|
|
1008
|
+
resolveCliPapers: (root) => {
|
|
1009
|
+
const read = readConfig({ ...a, config: null }, { log: () => { }, err: () => { }, cwd: root });
|
|
1010
|
+
return read.code === undefined
|
|
1011
|
+
? (toPaths(papersDirOf(read.opts))[0] ?? null)
|
|
1012
|
+
: null;
|
|
1013
|
+
},
|
|
1014
|
+
});
|
|
1015
|
+
}
|
|
823
1016
|
export async function run(argv, { log = console.log, err = console.error, cwd = process.cwd(), } = {}) {
|
|
824
1017
|
const a = parseArgs(argv);
|
|
825
1018
|
// The refusal must come FIRST: behind a flag without a value there is usually a typo, or a
|
|
@@ -831,6 +1024,10 @@ export async function run(argv, { log = console.log, err = console.error, cwd =
|
|
|
831
1024
|
`not what the command line said.`);
|
|
832
1025
|
return 2;
|
|
833
1026
|
}
|
|
1027
|
+
if (a.unknownFlag !== undefined) {
|
|
1028
|
+
err(`unknown flag \`${a.unknownFlag}\` — \`paperlint --help\` lists every flag`);
|
|
1029
|
+
return 2;
|
|
1030
|
+
}
|
|
834
1031
|
if (a.help || !a.cmd) {
|
|
835
1032
|
log(USAGE);
|
|
836
1033
|
return a.help ? 0 : 2;
|
|
@@ -838,35 +1035,8 @@ export async function run(argv, { log = console.log, err = console.error, cwd =
|
|
|
838
1035
|
// `init` asks the CLI's OWN reader what it would lint, so the two sides `doctor` compares are
|
|
839
1036
|
// not two implementations of the same question. A second resolver here is the defect the
|
|
840
1037
|
// 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
|
-
}
|
|
1038
|
+
if (a.cmd === "init")
|
|
1039
|
+
return await runInit(a, { log, err, cwd });
|
|
870
1040
|
// `doctor` reads the config but must NOT die on a broken one — reporting that the config is
|
|
871
1041
|
// broken is precisely its job. So a failed read becomes "the CLI would lint nothing", which is
|
|
872
1042
|
// what it prints, rather than an early exit that tells the reader nothing about the hooks.
|
|
@@ -894,39 +1064,44 @@ export async function run(argv, { log = console.log, err = console.error, cwd =
|
|
|
894
1064
|
const cfg = readConfig(a, { log, err, cwd });
|
|
895
1065
|
if (cfg.code !== undefined)
|
|
896
1066
|
return cfg.code;
|
|
897
|
-
const { opts,
|
|
1067
|
+
const { opts, root } = cfg;
|
|
898
1068
|
// A command-line argument OVERRIDES the config: one paper out of the corpus gets linted without
|
|
899
1069
|
// editing a file.
|
|
900
1070
|
//
|
|
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
|
|
1071
|
+
// 🔴 A path FROM THE CONFIG is resolved relative to the PROJECT ROOT, not the current directory.
|
|
1072
|
+
// Otherwise walking up is pointless: from `papers/aisec-2026` the root would be found, but
|
|
1073
|
+
// `"papersDir": "papers"` would point at `papers/aisec-2026/papers`. A command-line argument stays
|
|
905
1074
|
// relative to the current directory: it was typed here and now.
|
|
906
1075
|
//
|
|
907
1076
|
// Both kinds end up ABSOLUTE: ESLint below runs from `lintRoot`, not from here, and would resolve a
|
|
908
1077
|
// 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));
|
|
1078
|
+
const paths = a.paths.length > 0 ? a.paths.map((p) => resolve(cwd, p)) : papersRoots(cfg);
|
|
912
1079
|
if (paths.length === 0) {
|
|
913
|
-
err(`nothing to lint:
|
|
914
|
-
`Run \`npx paperlint init\` here, or pass the directory: \`paperlint lint papers\`.`);
|
|
1080
|
+
err(`nothing to lint: "${PAPERS_DIR_FIELD}" names no directory. Pass one: \`paperlint lint papers\`.`);
|
|
915
1081
|
return 2;
|
|
916
1082
|
}
|
|
1083
|
+
// 🔴 NO PAPERS WHERE THE CONFIG POINTS IS AN ERROR, NOT A CLEAN RUN. With the default in play the
|
|
1084
|
+
// directory may simply not exist yet; a run over nothing would be green and say nothing.
|
|
1085
|
+
if (a.paths.length === 0) {
|
|
1086
|
+
const empty = paths.find((p) => papersIn(p).length === 0);
|
|
1087
|
+
if (empty !== undefined) {
|
|
1088
|
+
err(noPapersMessage(relative(cwd, empty) || "."));
|
|
1089
|
+
return 2;
|
|
1090
|
+
}
|
|
1091
|
+
}
|
|
917
1092
|
// 🔴 STRUCTURE IS CHECKED BEFORE ESLint AND SEPARATELY FROM IT. A rule is invoked for the file
|
|
918
1093
|
// handed to it; a missing file is never handed over, so no rule at all can report the absence —
|
|
919
1094
|
// a directory without `PIPELINE-STATUS.md` simply gets not a single rule and reports clean. The
|
|
920
1095
|
// analysis of why a structure plugin for ESLint does not cure this is in `structure.mjs`.
|
|
921
1096
|
const structure = checkStructure(paths, opts.structure, { cwd });
|
|
922
|
-
//
|
|
923
|
-
//
|
|
1097
|
+
// The order, most general first so the most specific wins (ESLint: a later block wins): each
|
|
1098
|
+
// paper's venue preset → the root paperlint.json → the paper's own paperlint.json.
|
|
924
1099
|
const papers = paperRuleBlocks(paths);
|
|
925
1100
|
if (!papers.ok)
|
|
926
1101
|
return (err(papers.error), 2);
|
|
927
1102
|
const withPapers = {
|
|
928
1103
|
...opts,
|
|
929
|
-
rules: [...papers.value, ...(opts.rules ?? [])],
|
|
1104
|
+
rules: [...papers.value.preset, ...(opts.rules ?? []), ...papers.value.own],
|
|
930
1105
|
};
|
|
931
1106
|
let texLanguage = null;
|
|
932
1107
|
try {
|
|
@@ -938,10 +1113,16 @@ export async function run(argv, { log = console.log, err = console.error, cwd =
|
|
|
938
1113
|
/* without a LaTeX parser we work over markdown */
|
|
939
1114
|
}
|
|
940
1115
|
const eslint = new ESLint({
|
|
941
|
-
cwd: lintRoot(
|
|
1116
|
+
cwd: lintRoot(root, paths),
|
|
942
1117
|
overrideConfigFile: true,
|
|
943
1118
|
overrideConfig: buildConfig(withPapers, texLanguage),
|
|
1119
|
+
fix: a.fix,
|
|
944
1120
|
});
|
|
1121
|
+
const unowned = await firstUnownedFile(eslint, paths);
|
|
1122
|
+
if (unowned !== null) {
|
|
1123
|
+
err(`${relative(cwd, unowned) || unowned} is not a file paperlint lints — it lints ${PAPER_FILE_PATTERNS.join(", ")}`);
|
|
1124
|
+
return 2;
|
|
1125
|
+
}
|
|
945
1126
|
// 🔴 ESLint THROWS on an empty set (`NoFilesFoundError`) — the guard below simply never got
|
|
946
1127
|
// reached, which is what the very first run over an empty directory showed: instead of a clear
|
|
947
1128
|
// message a stack from the depths of eslint-helpers.js flew out. A failure stays a failure, but
|
|
@@ -952,13 +1133,14 @@ export async function run(argv, { log = console.log, err = console.error, cwd =
|
|
|
952
1133
|
results = await eslint.lintFiles(paths);
|
|
953
1134
|
}
|
|
954
1135
|
catch (e) {
|
|
955
|
-
|
|
956
|
-
if (fail?.messageTemplate === "file-not-found" ||
|
|
957
|
-
/No files matching/i.test(fail?.message ?? ""))
|
|
1136
|
+
if (isEmptySet(e))
|
|
958
1137
|
results = [];
|
|
959
1138
|
else
|
|
960
1139
|
throw e;
|
|
961
1140
|
}
|
|
1141
|
+
// `--fix` writes what the rules fixed; the report below is what is LEFT.
|
|
1142
|
+
if (a.fix)
|
|
1143
|
+
await ESLint.outputFixes(results);
|
|
962
1144
|
// 🔴 THE GUARD AGAINST A GREEN ZERO, the same one as in action.yml and for the same reason:
|
|
963
1145
|
// ESLint exits zero when there are no findings, and "no findings" is byte-for-byte
|
|
964
1146
|
// indistinguishable from "not a single rule got a single file". A rule whose glob did not match
|
|
@@ -971,20 +1153,50 @@ export async function run(argv, { log = console.log, err = console.error, cwd =
|
|
|
971
1153
|
a,
|
|
972
1154
|
log,
|
|
973
1155
|
err,
|
|
974
|
-
where: relative(cwd,
|
|
1156
|
+
where: relative(cwd, root) || ".",
|
|
975
1157
|
opts: withPapers,
|
|
1158
|
+
// Every paper of the project, not only the ones on the command line (#103).
|
|
1159
|
+
projectPapers: papersRoots(cfg)
|
|
1160
|
+
.flatMap((r) => papersIn(r))
|
|
1161
|
+
.map((d) => join(d, MAIN))
|
|
1162
|
+
.filter((f) => existsSync(f)),
|
|
976
1163
|
});
|
|
977
1164
|
}
|
|
1165
|
+
/**
|
|
1166
|
+
* The first path that is a FILE paperlint does not lint, or null. ESLint would answer such a file
|
|
1167
|
+
* with a warning result ("File ignored because of a matching ignore pattern"), which counts as a
|
|
1168
|
+
* linted file and turns a run over nothing into a green one. The question is asked of ESLint's
|
|
1169
|
+
* own matcher, against the same scope that decides what a directory yields.
|
|
1170
|
+
*/
|
|
1171
|
+
async function firstUnownedFile(eslint, paths) {
|
|
1172
|
+
for (const p of paths)
|
|
1173
|
+
if (existsSync(p) &&
|
|
1174
|
+
statSync(p).isFile() &&
|
|
1175
|
+
(await eslint.isPathIgnored(p)))
|
|
1176
|
+
return p;
|
|
1177
|
+
return null;
|
|
1178
|
+
}
|
|
1179
|
+
/**
|
|
1180
|
+
* ESLint's refusals of an empty set: nothing matched (`file-not-found`), or everything that matched
|
|
1181
|
+
* is outside paperlint's scope (`all-matched-files-ignored` — a papers directory holding only
|
|
1182
|
+
* vendored scripts). Both mean "nothing was linted", which the caller reports itself.
|
|
1183
|
+
*/
|
|
1184
|
+
const isEmptySet = (e) => {
|
|
1185
|
+
const fail = e;
|
|
1186
|
+
return (fail?.messageTemplate === "file-not-found" ||
|
|
1187
|
+
fail?.messageTemplate === "all-matched-files-ignored" ||
|
|
1188
|
+
/No files matching/i.test(fail?.message ?? ""));
|
|
1189
|
+
};
|
|
978
1190
|
/**
|
|
979
1191
|
* The end of `paperlint lint`: refuse an optional rule that reached no paper, print the findings, and
|
|
980
1192
|
* decide the exit code. Pulled out of `run` so each question has its own function.
|
|
981
1193
|
*/
|
|
982
1194
|
async function reportLint(eslint,
|
|
983
1195
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- #49: replace with a real type
|
|
984
|
-
results, structure, { a, log, err, where, opts, }) {
|
|
985
|
-
const silent = await silentOptionalRules(eslint, results.map((r) => r.filePath), opts);
|
|
1196
|
+
results, structure, { a, log, err, where, opts, projectPapers, }) {
|
|
1197
|
+
const silent = await silentOptionalRules(eslint, [...results.map((r) => r.filePath), ...projectPapers], opts);
|
|
986
1198
|
if (silent.length > 0) {
|
|
987
|
-
err(`${silent.join(", ")} is turned on in "rules", but no
|
|
1199
|
+
err(`${silent.join(", ")} is turned on in "rules", but no paper.tex of the project gets it — check the block's ` +
|
|
988
1200
|
`"files" (relative to ${where}). A rule that never runs reports exactly like a rule that passed.`);
|
|
989
1201
|
return 1;
|
|
990
1202
|
}
|