paperlint 4.2.0 → 5.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 +2 -2
- package/README.md +15 -4
- package/action.harness.mjs +54 -36
- package/action.yml +12 -27
- package/dist/adapters/latex/includes.d.ts +5 -1
- package/dist/adapters/latex/includes.d.ts.map +1 -1
- package/dist/adapters/latex/includes.js +8 -2
- package/dist/adapters/latex/includes.js.map +1 -1
- package/dist/build.d.ts.map +1 -1
- package/dist/build.js +7 -3
- package/dist/build.js.map +1 -1
- package/dist/cli.d.ts +2 -8
- package/dist/cli.d.ts.map +1 -1
- package/dist/cli.js +67 -37
- package/dist/cli.js.map +1 -1
- package/dist/domain/paper-source.js +2 -2
- package/dist/eslint-rules/latex-language.d.ts +5 -1
- package/dist/eslint-rules/latex-language.d.ts.map +1 -1
- package/dist/eslint-rules/latex-language.js +6 -3
- package/dist/eslint-rules/latex-language.js.map +1 -1
- package/dist/eslint-rules/paper-context.d.ts +20 -0
- package/dist/eslint-rules/paper-context.d.ts.map +1 -0
- package/dist/eslint-rules/paper-context.js +44 -0
- package/dist/eslint-rules/paper-context.js.map +1 -0
- package/dist/eslint-rules/paper-typography.d.ts +1 -1
- package/dist/eslint-rules/paper-typography.d.ts.map +1 -1
- package/dist/eslint-rules/paper-typography.js +32 -18
- package/dist/eslint-rules/paper-typography.js.map +1 -1
- package/dist/eslint-rules/tex-build.d.ts.map +1 -1
- package/dist/eslint-rules/tex-build.js +7 -1
- package/dist/eslint-rules/tex-build.js.map +1 -1
- package/dist/init.d.ts +4 -19
- package/dist/init.d.ts.map +1 -1
- package/dist/init.js +24 -29
- package/dist/init.js.map +1 -1
- package/dist/package-dirs.d.ts +2 -0
- package/dist/package-dirs.d.ts.map +1 -1
- package/dist/package-dirs.js +8 -0
- package/dist/package-dirs.js.map +1 -1
- package/dist/paper-files.d.ts +2 -2
- package/dist/paper-includes.d.ts +17 -0
- package/dist/paper-includes.d.ts.map +1 -0
- package/dist/paper-includes.js +51 -0
- package/dist/paper-includes.js.map +1 -0
- package/dist/rules-config.d.ts +1 -0
- package/dist/rules-config.d.ts.map +1 -1
- package/dist/rules-config.js.map +1 -1
- package/dist/structure.js +1 -1
- package/dist/structure.js.map +1 -1
- package/dist/tex-paper.d.ts +17 -3
- package/dist/tex-paper.d.ts.map +1 -1
- package/dist/tex-paper.js +36 -38
- package/dist/tex-paper.js.map +1 -1
- package/docs/configuration.md +5 -4
- package/docs/install.md +8 -8
- package/docs/prior-art/paper-folder-scaffolding.md +4 -7
- package/docs/rules.md +15 -11
- package/eslint-rules/latex-language.ts +10 -4
- package/eslint-rules/paper-context.test.ts +47 -0
- package/eslint-rules/paper-context.ts +57 -0
- package/eslint-rules/paper-typography.ts +67 -23
- package/eslint-rules/tex-build.ts +8 -1
- package/fixtures/real-markdown-paper/baseline.json +1 -2
- package/package.json +1 -1
- package/scripts/check.ts +2 -2
- package/scripts/eslint-report-guard.mjs +5 -2
- package/scripts/harness-api.frozen.json +0 -1
- package/skills/paper-pipeline/references/pipeline-status-template.md +2 -16
- package/src/adapters/latex/includes.ts +10 -2
- package/src/adapters/latex/index.test.ts +8 -0
- package/src/build.harness.mjs +4 -2
- package/src/build.test.ts +18 -1
- package/src/build.ts +7 -3
- package/src/cli.harness.mjs +22 -54
- package/src/cli.test.ts +2 -18
- package/src/cli.ts +78 -42
- package/src/domain/paper-source.ts +2 -2
- package/src/init.test.ts +33 -13
- package/src/init.ts +25 -48
- package/src/linted-set.test.ts +11 -1
- package/src/new-paper.harness.mjs +1 -5
- package/src/package-dirs.ts +9 -0
- package/src/paper-files.ts +2 -2
- package/src/paper-includes.test.ts +161 -0
- package/src/paper-includes.ts +77 -0
- package/src/rule-cases.test.ts +0 -30
- package/src/rule-docs.test.ts +0 -1
- package/src/rules-config.ts +1 -0
- package/src/structure.harness.ts +1 -3
- package/src/structure.ts +1 -1
- package/src/tex-paper.test.ts +78 -61
- package/src/tex-paper.ts +57 -48
- package/src/tex-venue-rules.test.ts +1 -1
- package/templates/paper/PIPELINE-STATUS.md +0 -4
- package/dist/action-ref.d.ts +0 -12
- package/dist/action-ref.d.ts.map +0 -1
- package/dist/action-ref.js +0 -16
- package/dist/action-ref.js.map +0 -1
- package/dist/eslint-rules/paper-research-question.d.ts +0 -31
- package/dist/eslint-rules/paper-research-question.d.ts.map +0 -1
- package/dist/eslint-rules/paper-research-question.js +0 -188
- package/dist/eslint-rules/paper-research-question.js.map +0 -1
- package/docs/rules/tex/missing-input.md +0 -58
- package/eslint-rules/paper-research-question.harness.mjs +0 -212
- package/eslint-rules/paper-research-question.ts +0 -199
- package/fixtures/paper-research-question/comment-only/PIPELINE-STATUS.md +0 -9
- package/fixtures/paper-research-question/comment-only/paper.tex +0 -7
- package/fixtures/paper-research-question/declared-not-in-paper/PIPELINE-STATUS.md +0 -10
- package/fixtures/paper-research-question/declared-not-in-paper/paper.tex +0 -6
- package/fixtures/paper-research-question/draft/PIPELINE-STATUS.md +0 -6
- package/fixtures/paper-research-question/draft/paper.tex +0 -2
- package/fixtures/paper-research-question/markdown-no-rq/PIPELINE-STATUS.md +0 -9
- package/fixtures/paper-research-question/markdown-no-rq/paper.md +0 -4
- package/fixtures/paper-research-question/shipped-no-rq/PIPELINE-STATUS.md +0 -12
- package/fixtures/paper-research-question/shipped-no-rq/paper.tex +0 -3
- package/fixtures/paper-research-question/shipped-with-rq/PIPELINE-STATUS.md +0 -10
- package/fixtures/paper-research-question/shipped-with-rq/paper.tex +0 -2
- package/src/action-ref.test.ts +0 -26
- package/src/action-ref.ts +0 -15
package/src/cli.test.ts
CHANGED
|
@@ -14,7 +14,6 @@ import type { runToolchain } from "./toolchain.ts";
|
|
|
14
14
|
import {
|
|
15
15
|
chooseVenue,
|
|
16
16
|
initTexLive,
|
|
17
|
-
ownVersion,
|
|
18
17
|
paperRuleBlocks,
|
|
19
18
|
parseArgs,
|
|
20
19
|
parseSettings,
|
|
@@ -259,7 +258,7 @@ test("build: a markdown-only paper is refused", async () => {
|
|
|
259
258
|
code: 1,
|
|
260
259
|
out: [
|
|
261
260
|
"papers/md",
|
|
262
|
-
` inputs: TEXINPUTS += ${venues}`,
|
|
261
|
+
` inputs: TEXINPUTS += the paper's directory, then ${venues}`,
|
|
263
262
|
" compile: refused — no paper.tex; paperlint compiles LaTeX, and this paper has none",
|
|
264
263
|
" measure: skipped — nothing is compiled",
|
|
265
264
|
" references: skipped — nothing is compiled",
|
|
@@ -288,7 +287,7 @@ test("build: with no TeX Live a dry run says where a real run stops, and a real
|
|
|
288
287
|
out: [
|
|
289
288
|
`engine: none — a real run would stop here: paperlint build: no TeX Live with every package these papers need — run \`npx paperlint toolchain\` (${missing})`,
|
|
290
289
|
"papers/tex",
|
|
291
|
-
` inputs: TEXINPUTS += ${venues}`,
|
|
290
|
+
` inputs: TEXINPUTS += the paper's directory, then ${venues}`,
|
|
292
291
|
" compile: paper.tex (\\documentclass{article})",
|
|
293
292
|
" measure: pdf.js → _build/paper.facts.json (facts for the lint rules; nothing is judged here)",
|
|
294
293
|
" references: online: citations exist, titles and authors match → _build/references.json (never fails the build)",
|
|
@@ -529,21 +528,6 @@ test("chooseVenue: a question that fails (the stream ended) takes the default, n
|
|
|
529
528
|
assert.deepEqual(r, { ok: true, value: null });
|
|
530
529
|
});
|
|
531
530
|
|
|
532
|
-
test("ownVersion: the manifest's version; an unreadable manifest or a non-string version is undefined", () => {
|
|
533
|
-
assert.deepEqual(
|
|
534
|
-
[
|
|
535
|
-
ownVersion(() => '{"version": "1.2.3"}'),
|
|
536
|
-
ownVersion(() => "{ nope"),
|
|
537
|
-
ownVersion(() => '{"version": 3}'),
|
|
538
|
-
ownVersion(() => {
|
|
539
|
-
throw new Error("ENOENT");
|
|
540
|
-
}),
|
|
541
|
-
typeof ownVersion(),
|
|
542
|
-
],
|
|
543
|
-
["1.2.3", undefined, undefined, undefined, "string"],
|
|
544
|
-
);
|
|
545
|
-
});
|
|
546
|
-
|
|
547
531
|
test("init's TeX Live install runs the toolchain over the project's papers, never a check", () => {
|
|
548
532
|
const dir = writeTree(join(root, "init-tex"), { "package.json": "{}" });
|
|
549
533
|
const calls: Parameters<typeof runToolchain>[0][] = [];
|
package/src/cli.ts
CHANGED
|
@@ -9,8 +9,8 @@
|
|
|
9
9
|
* but that is INTERNAL machinery, and you no longer need to know it in order to run them.
|
|
10
10
|
*
|
|
11
11
|
* Two entry points into the tool, and both are whole now:
|
|
12
|
-
* npx paperlint lint
|
|
13
|
-
* uses:
|
|
12
|
+
* npx paperlint lint ← here
|
|
13
|
+
* uses: ./node_modules/paperlint ← action.yml, after `npm ci`
|
|
14
14
|
*
|
|
15
15
|
* ⚠️ THE BOUNDARY THIS UTILITY HAS NO RIGHT TO ERASE: the consumer's data stays with the consumer.
|
|
16
16
|
* Where the papers are and the project's own rule blocks — that is about ONE corpus, so it lives in
|
|
@@ -42,6 +42,7 @@ import {
|
|
|
42
42
|
asEslintResults,
|
|
43
43
|
} from "./structure.ts";
|
|
44
44
|
import { buildPapers, papersIn, anyFailed, remedyFor, MAIN } from "./build.ts";
|
|
45
|
+
import { includeBlocks, paperBodies, unreadLines } from "./paper-includes.ts";
|
|
45
46
|
import { prepareEngine } from "./build-engine.ts";
|
|
46
47
|
import { cacheRoot, cachedTree, runToolchain } from "./toolchain.ts";
|
|
47
48
|
import { banalInstaller, parseBanalSettings } from "./adapters/banal/index.ts";
|
|
@@ -52,7 +53,6 @@ import { VENUE_RULE_LEVELS, venueRules } from "./venue-rules.ts";
|
|
|
52
53
|
import { TEX_VENUE_RULE_LEVELS, texVenueRules } from "./tex-venue-rules.ts";
|
|
53
54
|
import { claimProvenanceRule } from "./claim-provenance.ts";
|
|
54
55
|
import { registerRule } from "./register.ts";
|
|
55
|
-
import { missingInputRule } from "./tex-paper.ts";
|
|
56
56
|
import {
|
|
57
57
|
paperRules,
|
|
58
58
|
stringFields,
|
|
@@ -125,7 +125,6 @@ export { init };
|
|
|
125
125
|
export { nextSteps } from "./init.ts";
|
|
126
126
|
|
|
127
127
|
import paperStages from "#eslint-rules/paper-stages";
|
|
128
|
-
import researchQuestion from "#eslint-rules/paper-research-question";
|
|
129
128
|
import typography from "#eslint-rules/paper-typography";
|
|
130
129
|
import texBuild from "#eslint-rules/tex-build";
|
|
131
130
|
import bibReachable from "#eslint-rules/bib-reachable-entry";
|
|
@@ -228,7 +227,7 @@ export function buildConfig(
|
|
|
228
227
|
opts: PaperlintConfig = {},
|
|
229
228
|
texLanguage: unknown,
|
|
230
229
|
): ConfigBlock[] {
|
|
231
|
-
const paperRules = { ...
|
|
230
|
+
const paperRules = { ...typography.rules };
|
|
232
231
|
// The reference rules judge `_build/references.json`, and only on `paper.tex`.
|
|
233
232
|
const texPaperRules = {
|
|
234
233
|
...paperRules,
|
|
@@ -236,7 +235,6 @@ export function buildConfig(
|
|
|
236
235
|
};
|
|
237
236
|
// Each typography rule reports every occurrence where it is, and fixes it (`--fix`).
|
|
238
237
|
const prose = {
|
|
239
|
-
"paper/research-question": "warn",
|
|
240
238
|
"paper/section-word": "warn",
|
|
241
239
|
"paper/leading-zero": "warn",
|
|
242
240
|
};
|
|
@@ -319,10 +317,6 @@ export function buildConfig(
|
|
|
319
317
|
files: nodeFiles,
|
|
320
318
|
latex: latexReader,
|
|
321
319
|
}),
|
|
322
|
-
"missing-input": missingInputRule({
|
|
323
|
-
files: nodeFiles,
|
|
324
|
-
latex: latexReader,
|
|
325
|
-
}),
|
|
326
320
|
register: registerRule({
|
|
327
321
|
files: nodeFiles,
|
|
328
322
|
latex: latexReader,
|
|
@@ -341,7 +335,6 @@ export function buildConfig(
|
|
|
341
335
|
"tex/future-promise": "warn",
|
|
342
336
|
// A number with no owner: a prose rule, on beside the others above.
|
|
343
337
|
"tex/claim-provenance": "warn",
|
|
344
|
-
"tex/missing-input": "warn",
|
|
345
338
|
// Sentence-initial conjunctions over the whole body: register, not vocabulary.
|
|
346
339
|
"tex/register": "warn",
|
|
347
340
|
"tex/acm-frontmatter-override": "error",
|
|
@@ -350,17 +343,54 @@ export function buildConfig(
|
|
|
350
343
|
...TEX_VENUE_RULE_LEVELS,
|
|
351
344
|
},
|
|
352
345
|
});
|
|
346
|
+
// THE FILES A paper.tex INCLUDES FROM ITS BODY, each read on its own and reported at its own path
|
|
347
|
+
// (#144). The block claims every `.tex` but `paper.tex` — yet it is left out of the scope below, so
|
|
348
|
+
// it reaches only the files `paperlint lint` un-ignores by name for each paper (src/paper-includes.ts):
|
|
349
|
+
// a frozen version or a macro file is never enumerated. The rules are the ones that read a file as
|
|
350
|
+
// a fragment (no `document` environment: all of it is body); the whole-paper rules stay on
|
|
351
|
+
// `paper.tex`, which reads its includes itself (src/tex-paper.ts).
|
|
352
|
+
const fragments: readonly ConfigBlock[] = texLanguage
|
|
353
|
+
? [
|
|
354
|
+
{
|
|
355
|
+
files: ["**/*.tex"],
|
|
356
|
+
ignores: ["**/paper.tex"],
|
|
357
|
+
plugins: {
|
|
358
|
+
tex: {
|
|
359
|
+
languages: { latex: texLanguage },
|
|
360
|
+
rules: { "future-promise": texBuild["future-promise"] },
|
|
361
|
+
},
|
|
362
|
+
paper: {
|
|
363
|
+
rules: {
|
|
364
|
+
"section-word": typography.rules["section-word"],
|
|
365
|
+
"leading-zero": typography.rules["leading-zero"],
|
|
366
|
+
"figure-ref-style": typography.rules["figure-ref-style"],
|
|
367
|
+
},
|
|
368
|
+
},
|
|
369
|
+
},
|
|
370
|
+
language: "tex/latex",
|
|
371
|
+
rules: {
|
|
372
|
+
"paper/section-word": "warn",
|
|
373
|
+
"paper/leading-zero": "warn",
|
|
374
|
+
"paper/figure-ref-style": "warn",
|
|
375
|
+
"tex/future-promise": "warn",
|
|
376
|
+
},
|
|
377
|
+
},
|
|
378
|
+
]
|
|
379
|
+
: [];
|
|
353
380
|
// 🔴 ONLY THE FILES THESE BLOCKS CLAIM ARE LINTED (src/paper-files.ts). Without this block ESLint's
|
|
354
381
|
// built-in defaults lint every .js/.mjs/.cjs under the papers directory — a paper's vendored
|
|
355
382
|
// `repro/` code failed the run with 74 parse errors and not one finding on a paper file. It goes
|
|
356
383
|
// FIRST: the `.template/` ignore below must come after its directory un-ignore to win.
|
|
357
|
-
const owners = ruleOwners(cfg);
|
|
384
|
+
const owners = ruleOwners([...cfg, ...fragments]);
|
|
358
385
|
cfg.unshift(scopeToOwned(ownedPatterns(cfg)));
|
|
359
386
|
// The consumer's own blocks, LAST, so a later block wins — ESLint's rule. Parsed by
|
|
360
387
|
// `readConfig`; each carries the settings file's directory as its `basePath`. Each is split so a
|
|
361
388
|
// rule reaches only the files its plugin is registered for: `paper` is a different plugin beside
|
|
362
389
|
// PIPELINE-STATUS.md than beside paper.tex, and ESLint throws on a rule its plugin lacks.
|
|
363
|
-
cfg.push(
|
|
390
|
+
cfg.push(
|
|
391
|
+
...fragments,
|
|
392
|
+
...(opts.rules ?? []).flatMap((b) => narrowToOwners(b, owners)),
|
|
393
|
+
);
|
|
364
394
|
return cfg;
|
|
365
395
|
}
|
|
366
396
|
|
|
@@ -501,6 +531,17 @@ export async function silentOptionalRules(
|
|
|
501
531
|
return [...turnedOn].filter((id) => !reached.has(id));
|
|
502
532
|
}
|
|
503
533
|
|
|
534
|
+
/**
|
|
535
|
+
* The paper directories `paths` reach: each directory and the papers under it. A FILE named on the
|
|
536
|
+
* command line belongs to the paper it sits in.
|
|
537
|
+
*/
|
|
538
|
+
function paperDirs(paths: readonly string[]): readonly string[] {
|
|
539
|
+
const dirs = paths.map((p) =>
|
|
540
|
+
existsSync(p) && statSync(p).isFile() ? dirname(p) : p,
|
|
541
|
+
);
|
|
542
|
+
return [...new Set(dirs.flatMap((p) => [p, ...papersIn(p)]))];
|
|
543
|
+
}
|
|
544
|
+
|
|
504
545
|
/**
|
|
505
546
|
* Every linted paper's rules, as ESLint blocks scoped to that paper, in two groups: what its venue
|
|
506
547
|
* preset turns on, and what its own `paperlint.json` says. The caller puts the root's blocks between
|
|
@@ -510,11 +551,7 @@ export async function silentOptionalRules(
|
|
|
510
551
|
export function paperRuleBlocks(
|
|
511
552
|
paths: readonly string[],
|
|
512
553
|
): Parsed<{ preset: RuleBlock[]; own: RuleBlock[] }> {
|
|
513
|
-
|
|
514
|
-
const dirs = paths.map((p) =>
|
|
515
|
-
existsSync(p) && statSync(p).isFile() ? dirname(p) : p,
|
|
516
|
-
);
|
|
517
|
-
const papers = [...new Set(dirs.flatMap((p) => [p, ...papersIn(p)]))];
|
|
554
|
+
const papers = paperDirs(paths);
|
|
518
555
|
const out: { preset: RuleBlock[]; own: RuleBlock[] } = {
|
|
519
556
|
preset: [],
|
|
520
557
|
own: [],
|
|
@@ -765,28 +802,6 @@ const VALUE_FLAGS: ReadonlySet<string> = new Set([
|
|
|
765
802
|
"--max-warnings",
|
|
766
803
|
]);
|
|
767
804
|
|
|
768
|
-
/**
|
|
769
|
-
* This package's own version, from the `package.json` beside `src/` and `dist/` alike. `init` pins
|
|
770
|
-
* the CI action to its release tag; an unreadable manifest yields `undefined`, and init then keeps
|
|
771
|
-
* the placeholder instead of guessing.
|
|
772
|
-
*/
|
|
773
|
-
export function ownVersion(
|
|
774
|
-
readManifest: () => string = () =>
|
|
775
|
-
readFileSync(new URL("../package.json", import.meta.url), "utf8"),
|
|
776
|
-
): string | undefined {
|
|
777
|
-
try {
|
|
778
|
-
const manifest: unknown = JSON.parse(readManifest());
|
|
779
|
-
return typeof manifest === "object" &&
|
|
780
|
-
manifest !== null &&
|
|
781
|
-
"version" in manifest &&
|
|
782
|
-
typeof manifest.version === "string"
|
|
783
|
-
? manifest.version
|
|
784
|
-
: undefined;
|
|
785
|
-
} catch {
|
|
786
|
-
return undefined;
|
|
787
|
-
}
|
|
788
|
-
}
|
|
789
|
-
|
|
790
805
|
/**
|
|
791
806
|
* Reading the config, ONE reader for all commands. Pulled out of `run()` the moment a second
|
|
792
807
|
* command needed the same config (`build`): two copies of this block would have drifted apart on
|
|
@@ -1421,7 +1436,6 @@ async function runInit(
|
|
|
1421
1436
|
log,
|
|
1422
1437
|
err,
|
|
1423
1438
|
cwd,
|
|
1424
|
-
version: ownVersion(),
|
|
1425
1439
|
yes: a.yes,
|
|
1426
1440
|
hooks: !a.noHooks,
|
|
1427
1441
|
paper: a.paper,
|
|
@@ -1557,10 +1571,32 @@ export async function run(
|
|
|
1557
1571
|
texLanguage = null;
|
|
1558
1572
|
}
|
|
1559
1573
|
|
|
1574
|
+
// Each paper's body files, resolved the way the build resolves them — only when the LaTeX language
|
|
1575
|
+
// loaded, since only its rules read them. The project's papers too: `paperlint lint .` reaches
|
|
1576
|
+
// papers two levels down, which a directory's immediate papers do not.
|
|
1577
|
+
const bodies = texLanguage
|
|
1578
|
+
? paperBodies(paperDirs([...paths, ...papersRoots(cfg)]), {
|
|
1579
|
+
files: nodeFiles,
|
|
1580
|
+
latex: latexReader,
|
|
1581
|
+
})
|
|
1582
|
+
: [];
|
|
1583
|
+
// Named only for the papers this run lints: a paper elsewhere in the project is not this run's.
|
|
1584
|
+
const inScope = (dir: string) =>
|
|
1585
|
+
paths.some((p) => dir === p || dir.startsWith(`${p}${sep}`));
|
|
1586
|
+
unreadLines(
|
|
1587
|
+
bodies.filter((b) => inScope(b.dir)),
|
|
1588
|
+
(p) => shown(cwd, p),
|
|
1589
|
+
).forEach((l) => {
|
|
1590
|
+
err(l);
|
|
1591
|
+
});
|
|
1592
|
+
|
|
1560
1593
|
const eslint = new ESLint({
|
|
1561
1594
|
cwd: lintRoot(root, paths),
|
|
1562
1595
|
overrideConfigFile: true,
|
|
1563
|
-
overrideConfig: eslintConfig(
|
|
1596
|
+
overrideConfig: eslintConfig([
|
|
1597
|
+
...buildConfig(withPapers, texLanguage),
|
|
1598
|
+
...includeBlocks(bodies),
|
|
1599
|
+
]),
|
|
1564
1600
|
fix: a.fix,
|
|
1565
1601
|
});
|
|
1566
1602
|
|
|
@@ -11,8 +11,8 @@
|
|
|
11
11
|
*
|
|
12
12
|
* ── WHAT IS NOT DONE ─────────────────────────────────────────────────────────────
|
|
13
13
|
* No macro is expanded, so a path built from a macro (`\input{\dir/intro}`) is read as written. A
|
|
14
|
-
* file that cannot be found is
|
|
15
|
-
* through others, is spliced once.
|
|
14
|
+
* file that cannot be found is left out and named in `missing`; a file that includes itself,
|
|
15
|
+
* directly or through others, is spliced once.
|
|
16
16
|
*/
|
|
17
17
|
import { extname, normalize } from "node:path";
|
|
18
18
|
import type { Span } from "./tex-document.ts";
|
package/src/init.test.ts
CHANGED
|
@@ -21,8 +21,9 @@ import { tmpdir } from "node:os";
|
|
|
21
21
|
import { join } from "node:path";
|
|
22
22
|
import { afterEach, describe, expect, it } from "vitest";
|
|
23
23
|
import { FRESH_CLONE_NOTE } from "./hooks-settings.ts";
|
|
24
|
+
import { load as yamlLoad } from "js-yaml";
|
|
25
|
+
import { z } from "zod";
|
|
24
26
|
import {
|
|
25
|
-
UNPINNED_REF,
|
|
26
27
|
WORKFLOW_PATH,
|
|
27
28
|
choosePapers,
|
|
28
29
|
declarePapers,
|
|
@@ -33,6 +34,7 @@ import {
|
|
|
33
34
|
reportSkillLinks,
|
|
34
35
|
reportTexLive,
|
|
35
36
|
reportWorkflow,
|
|
37
|
+
workflowYaml,
|
|
36
38
|
} from "./init.ts";
|
|
37
39
|
import { runNode } from "../test/support.ts";
|
|
38
40
|
|
|
@@ -353,30 +355,48 @@ describe("paperlint init — how several candidate directories were decided, sai
|
|
|
353
355
|
const here = (p: string) => `<${p}>`;
|
|
354
356
|
|
|
355
357
|
describe("paperlint init — the workflow section, whole", () => {
|
|
356
|
-
it("the workflow
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
358
|
+
it("the written workflow installs the package, then runs the action from node_modules", () => {
|
|
359
|
+
// Guards: one revision. The action comes from the lockfile-pinned install, so there is no
|
|
360
|
+
// `zernie/paperlint@<ref>` to drift from package.json, and no `paths` to repeat papersDir.
|
|
361
|
+
const Workflow = z.object({
|
|
362
|
+
jobs: z.object({
|
|
363
|
+
papers: z.object({
|
|
364
|
+
steps: z.array(z.record(z.string(), z.unknown())),
|
|
365
|
+
}),
|
|
366
|
+
}),
|
|
367
|
+
});
|
|
368
|
+
expect(Workflow.parse(yamlLoad(workflowYaml())).jobs.papers.steps).toEqual([
|
|
369
|
+
{ uses: "actions/checkout@v4" },
|
|
370
|
+
{ uses: "actions/setup-node@v4", with: { "node-version": 22 } },
|
|
371
|
+
{ run: "npm ci" },
|
|
372
|
+
{ uses: "./node_modules/paperlint" },
|
|
360
373
|
]);
|
|
361
|
-
|
|
362
|
-
|
|
374
|
+
});
|
|
375
|
+
|
|
376
|
+
it("the workflow report: written, kept, declined, not asked", () => {
|
|
377
|
+
const ctx = { why: "stdin is not a terminal" };
|
|
378
|
+
expect(reportWorkflow("written", ctx)).toEqual([
|
|
379
|
+
` ✓ wrote ${WORKFLOW_PATH}`,
|
|
363
380
|
]);
|
|
364
381
|
expect(reportWorkflow("kept", ctx)).toEqual([
|
|
365
382
|
` ✓ ${WORKFLOW_PATH} is already there — kept, nothing overwritten`,
|
|
366
383
|
]);
|
|
367
|
-
const
|
|
368
|
-
" to run the same checks in CI, add
|
|
369
|
-
|
|
384
|
+
const steps = [
|
|
385
|
+
" to run the same checks in CI, add these steps to a workflow:",
|
|
386
|
+
" - uses: actions/checkout@v4",
|
|
387
|
+
" - uses: actions/setup-node@v4",
|
|
370
388
|
" with:",
|
|
371
|
-
"
|
|
389
|
+
" node-version: 22",
|
|
390
|
+
" - run: npm ci",
|
|
391
|
+
" - uses: ./node_modules/paperlint",
|
|
372
392
|
];
|
|
373
393
|
expect(reportWorkflow("declined", ctx)).toEqual([
|
|
374
394
|
" · declined — nothing written",
|
|
375
|
-
...
|
|
395
|
+
...steps,
|
|
376
396
|
]);
|
|
377
397
|
expect(reportWorkflow("not-asked", ctx)).toEqual([
|
|
378
398
|
" · stdin is not a terminal, so nothing was asked. Default taken: NO file written.",
|
|
379
|
-
...
|
|
399
|
+
...steps,
|
|
380
400
|
]);
|
|
381
401
|
});
|
|
382
402
|
});
|
package/src/init.ts
CHANGED
|
@@ -55,7 +55,6 @@ import {
|
|
|
55
55
|
} from "./doctor.ts";
|
|
56
56
|
import { PAPER_MARKERS, papersIn } from "./build.ts";
|
|
57
57
|
import { linkSkills, SKILLS_HOME, type LinkReport } from "./link-skills.ts";
|
|
58
|
-
import { actionRef } from "./action-ref.ts";
|
|
59
58
|
import {
|
|
60
59
|
FRESH_CLONE_NOTE,
|
|
61
60
|
SETTINGS_PATH,
|
|
@@ -270,29 +269,33 @@ export async function declarePapers(
|
|
|
270
269
|
|
|
271
270
|
export const WORKFLOW_PATH = join(".github", "workflows", "papers.yml");
|
|
272
271
|
|
|
273
|
-
/** Where the action is pinned when no release tag is known — obviously a placeholder. */
|
|
274
|
-
export const UNPINNED_REF = "<commit-sha>";
|
|
275
|
-
|
|
276
272
|
/**
|
|
277
|
-
* The CI
|
|
278
|
-
*
|
|
279
|
-
*
|
|
273
|
+
* The CI steps, one list for the file `init` writes and for the steps it prints. The action is
|
|
274
|
+
* called from the installed package, after `npm ci`: it and the CLI it runs are then one revision,
|
|
275
|
+
* set by the lockfile, and the action finds papersDir in paperlint.json itself.
|
|
280
276
|
*/
|
|
281
|
-
|
|
277
|
+
const CI_STEPS = [
|
|
278
|
+
"- uses: actions/checkout@v4",
|
|
279
|
+
"- uses: actions/setup-node@v4",
|
|
280
|
+
" with:",
|
|
281
|
+
" node-version: 22",
|
|
282
|
+
"- run: npm ci",
|
|
283
|
+
"- uses: ./node_modules/paperlint",
|
|
284
|
+
] as const;
|
|
285
|
+
|
|
286
|
+
const indent = (by: number) => (line: string) => `${" ".repeat(by)}${line}`;
|
|
287
|
+
|
|
288
|
+
/** The CI workflow `init` offers to write. */
|
|
289
|
+
export function workflowYaml(): string {
|
|
282
290
|
return [
|
|
283
|
-
|
|
284
|
-
? `# Written by \`paperlint init\`. Replace ${UNPINNED_REF} with a commit or release tag of the action.`
|
|
285
|
-
: `# Written by \`paperlint init\`, pinned to ${ref} — the release you installed.`,
|
|
291
|
+
`# Written by \`paperlint init\`.`,
|
|
286
292
|
`name: papers`,
|
|
287
293
|
`on: [push, pull_request]`,
|
|
288
294
|
`jobs:`,
|
|
289
295
|
` papers:`,
|
|
290
296
|
` runs-on: ubuntu-latest`,
|
|
291
297
|
` steps:`,
|
|
292
|
-
|
|
293
|
-
` - uses: zernie/paperlint@${ref ?? UNPINNED_REF}`,
|
|
294
|
-
` with:`,
|
|
295
|
-
` paths: ${papers}`,
|
|
298
|
+
...CI_STEPS.map(indent(6)),
|
|
296
299
|
``,
|
|
297
300
|
].join("\n");
|
|
298
301
|
}
|
|
@@ -301,16 +304,12 @@ export type WorkflowResult = "written" | "kept" | "declined" | "not-asked";
|
|
|
301
304
|
|
|
302
305
|
export async function offerWorkflow(
|
|
303
306
|
root: string,
|
|
304
|
-
papers: string,
|
|
305
307
|
{
|
|
306
308
|
ask,
|
|
307
309
|
interactive,
|
|
308
|
-
version,
|
|
309
310
|
}: {
|
|
310
311
|
ask?: (q: string) => Promise<string>;
|
|
311
312
|
interactive: boolean;
|
|
312
|
-
/** The running package's version; see `InitOptions.version`. */
|
|
313
|
-
version?: string | undefined;
|
|
314
313
|
},
|
|
315
314
|
): Promise<WorkflowResult> {
|
|
316
315
|
const path = join(root, WORKFLOW_PATH);
|
|
@@ -327,27 +326,17 @@ export async function offerWorkflow(
|
|
|
327
326
|
// No answer — an empty line, or a stream that ended — is the safe default, which is "no file".
|
|
328
327
|
if (answer !== "y" && answer !== "yes") return "declined";
|
|
329
328
|
mkdirSync(dirname(path), { recursive: true });
|
|
330
|
-
writeFileSync(path, workflowYaml(
|
|
329
|
+
writeFileSync(path, workflowYaml(), "utf8");
|
|
331
330
|
return "written";
|
|
332
331
|
}
|
|
333
332
|
|
|
334
333
|
/** What `init` says about the CI workflow — and, when none was written, the step to paste. */
|
|
335
334
|
export function reportWorkflow(
|
|
336
335
|
wf: WorkflowResult,
|
|
337
|
-
{
|
|
338
|
-
version,
|
|
339
|
-
papersDir,
|
|
340
|
-
why,
|
|
341
|
-
}: { version?: string | undefined; papersDir: string; why: string },
|
|
336
|
+
{ why }: { why: string },
|
|
342
337
|
): string[] {
|
|
343
|
-
const ref = actionRef(version);
|
|
344
338
|
const out: string[] = [];
|
|
345
|
-
if (wf === "written")
|
|
346
|
-
out.push(
|
|
347
|
-
ref === null
|
|
348
|
-
? ` ✓ wrote ${WORKFLOW_PATH} — pin ${UNPINNED_REF} before pushing it`
|
|
349
|
-
: ` ✓ wrote ${WORKFLOW_PATH}, pinned to ${ref}`,
|
|
350
|
-
);
|
|
339
|
+
if (wf === "written") out.push(` ✓ wrote ${WORKFLOW_PATH}`);
|
|
351
340
|
else if (wf === "kept")
|
|
352
341
|
out.push(
|
|
353
342
|
` ✓ ${WORKFLOW_PATH} is already there — kept, nothing overwritten`,
|
|
@@ -359,10 +348,8 @@ export function reportWorkflow(
|
|
|
359
348
|
);
|
|
360
349
|
if (wf !== "written" && wf !== "kept")
|
|
361
350
|
out.push(
|
|
362
|
-
` to run the same checks in CI, add
|
|
363
|
-
|
|
364
|
-
` with:`,
|
|
365
|
-
` paths: ${papersDir}`,
|
|
351
|
+
` to run the same checks in CI, add these steps to a workflow:`,
|
|
352
|
+
...CI_STEPS.map(indent(8)),
|
|
366
353
|
);
|
|
367
354
|
return out;
|
|
368
355
|
}
|
|
@@ -603,11 +590,6 @@ export interface InitOptions {
|
|
|
603
590
|
resolveCliPapers?: (root: string) => string | null;
|
|
604
591
|
/** Links the skills. Injected only so a test can stand in for the installed package. */
|
|
605
592
|
link?: (root: string) => LinkReport;
|
|
606
|
-
/**
|
|
607
|
-
* The version of the running package, read by the CLI from its own `package.json`. The CI
|
|
608
|
-
* workflow is pinned to its release tag (`actionRef`); absent or unreleased, the placeholder.
|
|
609
|
-
*/
|
|
610
|
-
version?: string | undefined;
|
|
611
593
|
/**
|
|
612
594
|
* TeX Live for `paperlint build`: whether paperlint's own tree is installed, and how to install
|
|
613
595
|
* it (`paperlint toolchain`). Passed in by the CLI; without them the step only names the command.
|
|
@@ -734,7 +716,6 @@ export async function init(
|
|
|
734
716
|
run = spawnSync,
|
|
735
717
|
resolveCliPapers,
|
|
736
718
|
link = (r: string) => linkSkills(r),
|
|
737
|
-
version,
|
|
738
719
|
} = opts;
|
|
739
720
|
// An injected `interactive` is a test standing in for a terminal; its reason is the classic one.
|
|
740
721
|
const { interactive, why } =
|
|
@@ -816,12 +797,8 @@ export async function init(
|
|
|
816
797
|
// ── 5. the one expensive, unguessable thing ───────────────────────────────────────────
|
|
817
798
|
log(``);
|
|
818
799
|
log(`CI`);
|
|
819
|
-
const wf = await offerWorkflow(root,
|
|
820
|
-
|
|
821
|
-
interactive,
|
|
822
|
-
version,
|
|
823
|
-
});
|
|
824
|
-
for (const line of reportWorkflow(wf, { version, papersDir, why })) log(line);
|
|
800
|
+
const wf = await offerWorkflow(root, { ask, interactive });
|
|
801
|
+
for (const line of reportWorkflow(wf, { why })) log(line);
|
|
825
802
|
|
|
826
803
|
// ── 6. a first paper — offered only where there is none, and only to a human ──────────
|
|
827
804
|
log(``);
|
package/src/linted-set.test.ts
CHANGED
|
@@ -26,7 +26,11 @@ afterEach(() => {
|
|
|
26
26
|
for (const d of dirs.splice(0)) rmSync(d, { recursive: true, force: true });
|
|
27
27
|
});
|
|
28
28
|
|
|
29
|
-
|
|
29
|
+
// The body is partly in `sections/intro.tex`, brought in by `\input` in the document; the preamble
|
|
30
|
+
// `\input`s a macro file and paperlint's own `paper-guards`. Only the body file is the paper.
|
|
31
|
+
const TEX =
|
|
32
|
+
"\\documentclass{article}\n\\input{macros}\n\\input{paper-guards}\n" +
|
|
33
|
+
"\\begin{document}x\n\\input{sections/intro}\n\\end{document}\n";
|
|
30
34
|
const JS = "export const x = 1;\n";
|
|
31
35
|
|
|
32
36
|
/** The files paperlint owns in the fixture paper. */
|
|
@@ -35,6 +39,7 @@ const OWNED = [
|
|
|
35
39
|
"papers/a/PIPELINE-STATUS.md",
|
|
36
40
|
"papers/a/reviews/r1.md",
|
|
37
41
|
"papers/a/siblings/smith2025.md",
|
|
42
|
+
"papers/a/sections/intro.tex",
|
|
38
43
|
];
|
|
39
44
|
|
|
40
45
|
/** Everything else a real paper folder and project carry. None of it may be linted. */
|
|
@@ -46,6 +51,10 @@ const NEIGHBOURS = {
|
|
|
46
51
|
"papers/a/repro/lib/y.mjs": JS,
|
|
47
52
|
"papers/a/repro/z.cjs": "module.exports = 1;\n",
|
|
48
53
|
"papers/a/repro/t.ts": "const t: number = 1;\n",
|
|
54
|
+
// A preamble include, a frozen version and a .tex nothing includes: none is the paper's body.
|
|
55
|
+
"papers/a/macros.tex": "\\newcommand{\\x}{y}\n",
|
|
56
|
+
"papers/a/versions/2026-01-01-submitted.tex": TEX,
|
|
57
|
+
"papers/a/notes/scratch.tex": "Scratch.\n",
|
|
49
58
|
"script.js": JS,
|
|
50
59
|
"node_modules/pkg/index.js": JS,
|
|
51
60
|
};
|
|
@@ -75,6 +84,7 @@ function project(): string {
|
|
|
75
84
|
"papers/a/PIPELINE-STATUS.md": "---\nstages: []\n---\n",
|
|
76
85
|
"papers/a/reviews/r1.md": "# review\n",
|
|
77
86
|
"papers/a/siblings/smith2025.md": "---\nread: full\n---\n# Smith 2025\n",
|
|
87
|
+
"papers/a/sections/intro.tex": "Intro.\n",
|
|
78
88
|
...NEIGHBOURS,
|
|
79
89
|
};
|
|
80
90
|
for (const [p, text] of Object.entries(all)) {
|
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
* - the scaffold is what `paperlint lint` ACCEPTS: the old first run opened with "missing
|
|
6
6
|
* PIPELINE-STATUS.md", and a scaffold that still produced a finding would move that error
|
|
7
7
|
* from a missing file into a created one;
|
|
8
|
-
* - the scorecard carries
|
|
8
|
+
* - the scorecard carries NO `stages` — a new paper has shipped nothing,
|
|
9
9
|
* and a placeholder stage would be a red `paper/stages` on the very first run;
|
|
10
10
|
* - the scorecard carries the `**Readiness verdict:**` line the status hook surfaces on every
|
|
11
11
|
* paper edit — without it the hook reports that the paper cannot say whether it is ready;
|
|
@@ -104,10 +104,6 @@ try {
|
|
|
104
104
|
"🔴 the scorecard has NO `stages` — a new paper has shipped nothing and owes nothing",
|
|
105
105
|
!("stages" in fm),
|
|
106
106
|
);
|
|
107
|
-
check(
|
|
108
|
-
"and carries `researchQuestion`, present and empty",
|
|
109
|
-
"researchQuestion" in fm && fm.researchQuestion === "",
|
|
110
|
-
);
|
|
111
107
|
check(
|
|
112
108
|
"the name is filled in, and no placeholder survives in any file",
|
|
113
109
|
/PIPELINE-STATUS — demo/.test(status) &&
|
package/src/package-dirs.ts
CHANGED
|
@@ -5,6 +5,9 @@
|
|
|
5
5
|
*
|
|
6
6
|
* presets/ the venue presets (`paperlint:<name>`) and their JSON Schema
|
|
7
7
|
* presets/tex/ the LaTeX inputs `paperlint build` puts on TEXINPUTS
|
|
8
|
+
*
|
|
9
|
+
* `texSearchPath` is where an `\input` is looked for, in order. The build hands it to TeX and the
|
|
10
|
+
* lint rules read includes through it, so the two cannot disagree about which file a paper means.
|
|
8
11
|
*/
|
|
9
12
|
import { fileURLToPath } from "node:url";
|
|
10
13
|
|
|
@@ -15,3 +18,9 @@ export const presetsDir = (): string =>
|
|
|
15
18
|
/** The LaTeX inputs a paper `\input`s by name (`paper-guards`, a venue's numbers). */
|
|
16
19
|
export const texInputsDir = (): string =>
|
|
17
20
|
fileURLToPath(new URL("../presets/tex", import.meta.url));
|
|
21
|
+
|
|
22
|
+
/** Where an `\input` in the paper in `paperDir` is looked for, first to last. */
|
|
23
|
+
export const texSearchPath = (paperDir: string): readonly string[] => [
|
|
24
|
+
paperDir,
|
|
25
|
+
texInputsDir(),
|
|
26
|
+
];
|
package/src/paper-files.ts
CHANGED
|
@@ -16,8 +16,8 @@
|
|
|
16
16
|
*
|
|
17
17
|
* 2. **Each rule scoped to the files it was written for** (`ruleOwners` + `narrowToOwners`).
|
|
18
18
|
* One plugin NAME is bound to different plugin objects in different blocks: `paper` holds
|
|
19
|
-
* `stages`/`source`/`author-list` beside `PIPELINE-STATUS.md` and `
|
|
20
|
-
* `
|
|
19
|
+
* `stages`/`source`/`author-list` beside `PIPELINE-STATUS.md` and `typography` beside
|
|
20
|
+
* `paper.md`/`paper.tex`. So a consumer block that named `paper/source`
|
|
21
21
|
* for every file under the papers directory reached `paper.tex`, where `paper` has no `source`
|
|
22
22
|
* — and ESLint threw `Could not find "source" in plugin "paper"` from inside `lintFiles`,
|
|
23
23
|
* after paperlint's own validation had (correctly) accepted the id. A block is therefore split
|