paperlint 11.1.0 → 11.1.1

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.
@@ -31,19 +31,29 @@ on:
31
31
  workflows: ["ci"]
32
32
  types: [completed]
33
33
 
34
- # Merge and delete branch — nothing else this workflow needs.
34
+ # Merge and delete branch, then start release.yml — nothing else this workflow needs.
35
35
  permissions:
36
36
  contents: write
37
37
  pull-requests: write
38
+ actions: write
38
39
 
39
40
  jobs:
40
41
  merge:
41
- # Three conditions, each blocking a case: the run was for a PR (not a push to main) ·
42
- # the run is GREEN · the author is a bot, not a person. Remove any one and meaning changes.
42
+ # Three conditions, each blocking a case: the run is GREEN · it was for one of two bots' pull
43
+ # requests, never a person's · and that pull request is the bot's own. Remove any one and
44
+ # meaning changes. The two bots:
45
+ # - Dependabot: its PR's `ci` is an ordinary `pull_request` run.
46
+ # - the deadline refresh (deadlines.yml): a PR opened with GITHUB_TOKEN starts no workflow, so
47
+ # deadlines.yml dispatches `ci` on its branch; that run is `workflow_dispatch` by
48
+ # github-actions[bot] on `bot/preset-deadlines`, and GitHub still lists the PR in
49
+ # `pull_requests` (measured on run 39489222900, PR #248).
43
50
  if: >
44
- github.event.workflow_run.event == 'pull_request' &&
45
- github.event.workflow_run.conclusion == 'success' &&
46
- github.event.workflow_run.actor.login == 'dependabot[bot]'
51
+ github.event.workflow_run.conclusion == 'success' && (
52
+ (github.event.workflow_run.event == 'pull_request' &&
53
+ github.event.workflow_run.actor.login == 'dependabot[bot]') ||
54
+ (github.event.workflow_run.event == 'workflow_dispatch' &&
55
+ github.event.workflow_run.actor.login == 'github-actions[bot]' &&
56
+ github.event.workflow_run.head_branch == 'bot/preset-deadlines'))
47
57
  runs-on: ubuntu-latest
48
58
  timeout-minutes: 5
49
59
  steps:
@@ -68,3 +78,7 @@ jobs:
68
78
  fi
69
79
  gh pr merge "${PR}" --repo "${REPO}" --squash --delete-branch --match-head-commit "${HEAD_SHA}"
70
80
  echo "merged PR #${PR} on green ci run ${RUN_ID}"
81
+ # A merge made with GITHUB_TOKEN pushes to main without starting release.yml (GitHub's rule
82
+ # against loops), so a `fix(presets):` would never be released. Start it by hand; a
83
+ # `deps:` merge releases nothing either way, so the dispatch is harmless there.
84
+ gh workflow run release.yml --repo "${REPO}" --ref main
@@ -10,6 +10,16 @@ name: Release
10
10
  on:
11
11
  push:
12
12
  branches: [main]
13
+ # dependabot-automerge.yml merges with GITHUB_TOKEN, and such a push starts no workflow: it
14
+ # dispatches this one instead.
15
+ workflow_dispatch:
16
+
17
+ # One release at a time: two runs started close together (a push and a dispatch, or two bot merges)
18
+ # would both read the same last tag, and the one that loses the tag race leaves its merge
19
+ # unreleased. A queued run reads every commit since the tag, so the last one in still releases all.
20
+ concurrency:
21
+ group: release
22
+ cancel-in-progress: false
13
23
 
14
24
  permissions:
15
25
  contents: write
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "paperlint",
3
- "version": "11.1.0",
3
+ "version": "11.1.1",
4
4
  "description": "A linter for scientific papers written in LaTeX: catches mistakes before you submit to a conference or journal.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -21,12 +21,15 @@ import { promisify } from "node:util";
21
21
  import { afterAll, beforeAll, describe, expect, test } from "vitest";
22
22
  import { parsePreset } from "#src/tex-requirements";
23
23
  import { argsOf, presetsIn, summaryTo } from "./main.ts";
24
+ import { FROZEN_DIR, frozenText } from "../../test/frozen-presets.ts";
24
25
 
25
26
  const ROOT = dirname(dirname(dirname(fileURLToPath(import.meta.url))));
26
27
  const PRESETS = join(ROOT, "presets");
27
28
  const CASSETTE = join(ROOT, "src", "adapters", "hotcrp", "cassette");
28
- const MSR = join(PRESETS, "msr.jsonc");
29
- const msrText = readFileSync(MSR, "utf8");
29
+ // Frozen at the portal's reading of 2026-10-19 (test/frozen-presets.ts): the shipped file's dates
30
+ // are the refresh's own output.
31
+ const MSR = join(FROZEN_DIR, "msr.jsonc");
32
+ const msrText = frozenText("msr.jsonc");
30
33
  const readings = (text: string) => parsePreset(text, MSR).deadlines ?? [];
31
34
  /** MSR with its submission deadline two days before the portal's. */
32
35
  const earlier = msrText.replace(
@@ -3,15 +3,15 @@
3
3
  * fake VenueDeadlines port that answers plain domain values (how HotCRP's page is parsed is
4
4
  * src/adapters/hotcrp/deadlines.test.ts's) and presets held in memory.
5
5
  */
6
- import { readdirSync, readFileSync } from "node:fs";
7
- import { dirname, join } from "node:path";
8
- import { fileURLToPath } from "node:url";
6
+ import { readdirSync } from "node:fs";
7
+ import { join } from "node:path";
9
8
  import { describe, expect, test } from "vitest";
10
9
  import type { PortalDeadlines } from "#src/domain/deadline";
11
10
  import { err, ok, type Result } from "#src/domain/result";
12
11
  import type { PortalFailure } from "#src/domain/submission";
13
12
  import type { VenueDeadlines } from "#src/ports/venue-deadlines";
14
13
  import { parsePreset } from "#src/tex-requirements";
14
+ import { FROZEN_DIR, frozenText } from "../../test/frozen-presets.ts";
15
15
  import {
16
16
  refreshPreset,
17
17
  runRefresh,
@@ -20,10 +20,11 @@ import {
20
20
  type PresetFiles,
21
21
  } from "./refresh.ts";
22
22
 
23
- const ROOT = dirname(dirname(dirname(fileURLToPath(import.meta.url))));
24
- const PRESETS = join(ROOT, "presets");
25
- const MSR = join(PRESETS, "msr.jsonc");
26
- const msrText = readFileSync(MSR, "utf8");
23
+ // The logic is tested over presets FROZEN at the portal's reading of 2026-10-19 (test/frozen-presets.ts):
24
+ // the shipped files' dates are the refresh's own output, and a test of the refresh that read them
25
+ // would turn red on the refresh's own pull request.
26
+ const MSR = join(FROZEN_DIR, "msr.jsonc");
27
+ const msrText = frozenText("msr.jsonc");
27
28
  const readings = (text: string) => parsePreset(text, MSR).deadlines ?? [];
28
29
  /** MSR with its submission deadline two days before the portal's. */
29
30
  const earlier = msrText.replace(
@@ -39,12 +40,12 @@ function inMemory(files: Readonly<Record<string, string>>) {
39
40
  write: (name, text) => {
40
41
  written[name] = text;
41
42
  },
42
- path: (name) => join(PRESETS, name),
43
+ path: (name) => join(FROZEN_DIR, name),
43
44
  };
44
45
  return { presets, written };
45
46
  }
46
47
 
47
- /** What MSR 2027's portal lists — the readings presets/msr.jsonc holds. */
48
+ /** What MSR 2027's portal lists — the readings the frozen msr.jsonc holds. */
48
49
  const MSR_PAGE: PortalDeadlines = {
49
50
  now: 1792434476,
50
51
  open: true,
@@ -164,12 +165,12 @@ describe("refreshPreset on MSR 2027's page", () => {
164
165
 
165
166
  describe("refreshPreset — what it skips, and a portal it cannot read", () => {
166
167
  test("a preset without a portal, or with one paperlint has no adapter for, is skipped", async () => {
167
- const aisec = join(PRESETS, "aisec.jsonc");
168
+ const aisec = join(FROZEN_DIR, "aisec.jsonc");
168
169
  const skipped = { kind: "skipped" };
169
170
  expect(
170
171
  await refreshPreset({
171
172
  file: aisec,
172
- text: readFileSync(aisec, "utf8"),
173
+ text: frozenText("aisec.jsonc"),
173
174
  portal: answering(ok(MSR_PAGE)),
174
175
  today: "2026-10-05",
175
176
  }),
@@ -226,9 +227,9 @@ describe("runRefresh — what it prints, writes and returns", () => {
226
227
  test("nothing changed: one line, no preset written, exit 0", async () => {
227
228
  const { presets, written } = inMemory(
228
229
  Object.fromEntries(
229
- readdirSync(PRESETS)
230
+ readdirSync(FROZEN_DIR)
230
231
  .filter((f) => f.endsWith(".jsonc"))
231
- .map((f) => [f, readFileSync(join(PRESETS, f), "utf8")]),
232
+ .map((f) => [f, frozenText(f)]),
232
233
  ),
233
234
  );
234
235
  const lines: string[] = [];
@@ -241,7 +242,7 @@ describe("runRefresh — what it prints, writes and returns", () => {
241
242
  });
242
243
  expect([code, written]).toStrictEqual([0, {}]);
243
244
  expect(lines.at(-1)).toMatch(
244
- /^✓ no deadline changed: 2 preset\(s\) with a portal read, \d+ skipped$/,
245
+ /^✓ no deadline changed: 2 preset\(s\) with a portal read, 1 skipped$/,
245
246
  );
246
247
  });
247
248
 
@@ -8,15 +8,23 @@ import { describe, expect, it } from "vitest";
8
8
  import { memoryFiles } from "./adapters/memory/index.ts";
9
9
  import { presetsDir } from "./package-dirs.ts";
10
10
  import { cycleRules } from "./cycle-rules.ts";
11
+ import { frozenText } from "../test/frozen-presets.ts";
11
12
 
12
13
  const VENUES = presetsDir();
13
14
  const DIR = "/work/papers/p";
14
- /** The shipped presets, so a cycle naming `paperlint:msr` resolves through the memory files. */
15
- const SHIPPED = Object.fromEntries(
16
- readdirSync(VENUES)
17
- .filter((f) => f.endsWith(".jsonc"))
18
- .map((f) => [join(VENUES, f), readFileSync(join(VENUES, f), "utf8")]),
19
- );
15
+ /**
16
+ * The shipped presets, so a cycle naming `paperlint:msr` resolves through the memory files — msr
17
+ * FROZEN (test/frozen-presets.ts): its deadlines are rewritten by the scheduled refresh, and the
18
+ * tests below assert the readings they sit beside.
19
+ */
20
+ const SHIPPED = {
21
+ ...Object.fromEntries(
22
+ readdirSync(VENUES)
23
+ .filter((f) => f.endsWith(".jsonc"))
24
+ .map((f) => [join(VENUES, f), readFileSync(join(VENUES, f), "utf8")]),
25
+ ),
26
+ [join(VENUES, "msr.jsonc")]: frozenText("msr.jsonc"),
27
+ };
20
28
 
21
29
  interface Reported {
22
30
  readonly rule: string;
@@ -10,6 +10,7 @@ import { readdirSync, readFileSync } from "node:fs";
10
10
  import { join, relative } from "node:path";
11
11
  import { describe, expect, it } from "vitest";
12
12
  import { memoryFiles } from "./adapters/memory/index.ts";
13
+ import { frozenText } from "../test/frozen-presets.ts";
13
14
  import { presetsDir } from "./package-dirs.ts";
14
15
  import { parsePreset } from "./tex-requirements.ts";
15
16
  import {
@@ -23,18 +24,27 @@ import {
23
24
  } from "./presets.ts";
24
25
 
25
26
  const VENUES = presetsDir();
26
- const shipped = Object.fromEntries(
27
+ const live = Object.fromEntries(
27
28
  readdirSync(VENUES)
28
29
  .filter((f) => f.endsWith(".jsonc") || f.endsWith(".json"))
29
30
  .map((f) => [join(VENUES, f), readFileSync(join(VENUES, f))]),
30
31
  );
32
+ // The shipped msr.jsonc's deadlines are rewritten by the scheduled refresh, so the tests of LOGIC
33
+ // read it FROZEN (test/frozen-presets.ts); only the shape test below reads the shipped file.
34
+ const shipped = {
35
+ ...live,
36
+ [join(VENUES, "msr.jsonc")]: frozenText("msr.jsonc"),
37
+ };
31
38
  const PAPER_FILE = "/work/papers/p/paperlint.json";
32
- const deps = (extra: Record<string, string> = {}) => ({
33
- files: memoryFiles({ ...shipped, ...extra }),
39
+ const deps = (extra: Record<string, string> = {}, base = shipped) => ({
40
+ files: memoryFiles({ ...base, ...extra }),
34
41
  venuesDir: VENUES,
35
42
  });
36
- const resolve = (spec: string, extra: Record<string, string> = {}) =>
37
- resolvePreset(spec, PAPER_FILE, deps(extra));
43
+ const resolve = (
44
+ spec: string,
45
+ extra: Record<string, string> = {},
46
+ base = shipped,
47
+ ) => resolvePreset(spec, PAPER_FILE, deps(extra, base));
38
48
  /** A venue preset of a project's own: the identity every venue declares, then `body`. */
39
49
  const venue = (body: Record<string, unknown>, name = "Our Workshop"): string =>
40
50
  JSON.stringify({
@@ -80,7 +90,7 @@ describe("shipped presets", () => {
80
90
 
81
91
  describe("shipped presets: paperlint:msr", () => {
82
92
  it("paperlint:msr → msr over ieee-conference: one kind, appendices counted as body, compsoc forbidden", () => {
83
- const r = resolve("paperlint:msr");
93
+ const r = resolve("paperlint:msr", {}, live);
84
94
  expect(r.ok).toBe(true);
85
95
  if (!r.ok) return;
86
96
  expect(r.value.chain.map((f) => f.split("/").pop())).toEqual([
@@ -103,26 +113,26 @@ describe("shipped presets: paperlint:msr", () => {
103
113
  kind: "hotcrp",
104
114
  url: "https://msr2027.hotcrp.com",
105
115
  });
106
- // The portal's deadlines, read 2026-10-19 18:05 UTC off https://msr2027.hotcrp.com/deadlines:
107
- // «Wednesday Oct 21, 2026, 7:59:59 AM EDT» (`"sub":1792583999`) and «Saturday Oct 24, 2026,
108
- // 7:59:59 AM EDT» (#235).
109
- expect(r.value.deadlines).toEqual([
110
- {
111
- what: "submission",
112
- at: "2026-10-21T11:59:59Z",
113
- source: "portal",
114
- url: "https://msr2027.hotcrp.com/deadlines",
115
- read: "2026-10-19",
116
- },
117
- {
118
- what: "resubmission",
119
- at: "2026-10-24T11:59:59Z",
116
+ // The deadlines' SHAPE, never their dates: the scheduled refresh rewrites those whenever the
117
+ // portal moves them (deadlines.yml), and a date asserted here turns its pull request red.
118
+ // Guards: the shipped preset keeps a submission deadline, every reading is the portal's own
119
+ // from the venue's deadlines page, with a read day and an instant that carries its zone.
120
+ expect(r.value.deadlines.map((d) => d.what)).toContain("submission");
121
+ expect(
122
+ r.value.deadlines.map((d) => ({
123
+ source: d.source,
124
+ url: d.url,
125
+ read: /^\d{4}-\d{2}-\d{2}$/.test(d.read),
126
+ at: /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}Z$/.test(d.at),
127
+ })),
128
+ ).toEqual(
129
+ r.value.deadlines.map(() => ({
120
130
  source: "portal",
121
131
  url: "https://msr2027.hotcrp.com/deadlines",
122
- read: "2026-10-19",
123
- },
124
- ]);
125
- expect(Date.parse("2026-10-21T11:59:59Z") / 1000).toBe(1792583999);
132
+ read: true,
133
+ at: true,
134
+ })),
135
+ );
126
136
  // No rule turned on: the IEEE capitalization rule has not been read and quoted.
127
137
  expect(r.value.rules).toEqual({});
128
138
  expect(r.value.registerAnchors).toEqual([]);
@@ -1,4 +1,5 @@
1
1
  /** `paperlint submission show`: what the portal holds, whether it is the local build, the deadlines in force. */
2
+ import { join } from "node:path";
2
3
  import { expect, test } from "vitest";
3
4
  import { memoryFiles } from "./adapters/memory/index.ts";
4
5
  import { err, ok } from "./domain/result.ts";
@@ -20,6 +21,12 @@ import {
20
21
  run,
21
22
  STEPS,
22
23
  } from "../test/submission-fixtures.ts";
24
+ import { frozenText } from "../test/frozen-presets.ts";
25
+
26
+ /** msr FROZEN (test/frozen-presets.ts): the shipped file's deadlines are the scheduled refresh's output. */
27
+ const frozenMsr = {
28
+ [join(presetsDir(), "msr.jsonc")]: frozenText("msr.jsonc"),
29
+ };
23
30
 
24
31
  test("show: the portal from the preset, the id from the paper, the token from the site's variable", async () => {
25
32
  const r = await run({});
@@ -130,6 +137,7 @@ test("show sends nothing, so it asks for no check", async () => {
130
137
 
131
138
  test("show prints the current attempt's deadlines in force: the preset's portal readings, the paper's call readings and override over them", () => {
132
139
  const fs = memoryFiles({
140
+ ...frozenMsr,
133
141
  "/w/package.json": "{}",
134
142
  [`${PAPER}/paperlint.json`]: JSON.stringify({
135
143
  cycles: [
@@ -188,6 +196,7 @@ test("show prints the current attempt's deadlines in force: the preset's portal
188
196
 
189
197
  test("show, in the flat form: the deadlines in force are the preset's readings alone", () => {
190
198
  const flat = memoryFiles({
199
+ ...frozenMsr,
191
200
  "/w/package.json": "{}",
192
201
  [`${PAPER}/paperlint.json`]: JSON.stringify({
193
202
  extends: "paperlint:msr",