paperlint 7.1.0 → 7.2.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.
Files changed (119) hide show
  1. package/.github/workflows/ci.yml +3 -0
  2. package/.github/workflows/deadlines.yml +100 -0
  3. package/README.md +6 -0
  4. package/dist/adapters/hotcrp/deadlines.d.ts +8 -0
  5. package/dist/adapters/hotcrp/deadlines.d.ts.map +1 -0
  6. package/dist/adapters/hotcrp/deadlines.io.d.ts +6 -0
  7. package/dist/adapters/hotcrp/deadlines.io.d.ts.map +1 -0
  8. package/dist/adapters/hotcrp/deadlines.io.js +26 -0
  9. package/dist/adapters/hotcrp/deadlines.io.js.map +1 -0
  10. package/dist/adapters/hotcrp/deadlines.js +134 -0
  11. package/dist/adapters/hotcrp/deadlines.js.map +1 -0
  12. package/dist/adapters/hotcrp/index.d.ts +2 -0
  13. package/dist/adapters/hotcrp/index.d.ts.map +1 -1
  14. package/dist/adapters/hotcrp/index.js +2 -0
  15. package/dist/adapters/hotcrp/index.js.map +1 -1
  16. package/dist/cli.d.ts.map +1 -1
  17. package/dist/cli.js +40 -5
  18. package/dist/cli.js.map +1 -1
  19. package/dist/cycle-rules.d.ts +24 -0
  20. package/dist/cycle-rules.d.ts.map +1 -0
  21. package/dist/cycle-rules.js +212 -0
  22. package/dist/cycle-rules.js.map +1 -0
  23. package/dist/domain/cycle.d.ts +148 -0
  24. package/dist/domain/cycle.d.ts.map +1 -0
  25. package/dist/domain/cycle.js +281 -0
  26. package/dist/domain/cycle.js.map +1 -0
  27. package/dist/domain/deadline.d.ts +128 -0
  28. package/dist/domain/deadline.d.ts.map +1 -0
  29. package/dist/domain/deadline.js +281 -0
  30. package/dist/domain/deadline.js.map +1 -0
  31. package/dist/eslint-rules/cycle-stage.d.ts +39 -0
  32. package/dist/eslint-rules/cycle-stage.d.ts.map +1 -0
  33. package/dist/eslint-rules/cycle-stage.js +119 -0
  34. package/dist/eslint-rules/cycle-stage.js.map +1 -0
  35. package/dist/lib/paper-config.d.ts.map +1 -1
  36. package/dist/lib/paper-config.js +2 -0
  37. package/dist/lib/paper-config.js.map +1 -1
  38. package/dist/paper-settings.d.ts +7 -0
  39. package/dist/paper-settings.d.ts.map +1 -1
  40. package/dist/paper-settings.js +61 -8
  41. package/dist/paper-settings.js.map +1 -1
  42. package/dist/ports/venue-deadlines.d.ts +14 -0
  43. package/dist/ports/venue-deadlines.d.ts.map +1 -0
  44. package/dist/ports/venue-deadlines.js +2 -0
  45. package/dist/ports/venue-deadlines.js.map +1 -0
  46. package/dist/presets.d.ts +12 -0
  47. package/dist/presets.d.ts.map +1 -1
  48. package/dist/presets.js +19 -11
  49. package/dist/presets.js.map +1 -1
  50. package/dist/submission.d.ts +8 -0
  51. package/dist/submission.d.ts.map +1 -1
  52. package/dist/submission.js +41 -10
  53. package/dist/submission.js.map +1 -1
  54. package/dist/tex-requirements.d.ts +3 -0
  55. package/dist/tex-requirements.d.ts.map +1 -1
  56. package/dist/tex-requirements.js +19 -0
  57. package/dist/tex-requirements.js.map +1 -1
  58. package/dist/tex-venue-rules.d.ts +19 -0
  59. package/dist/tex-venue-rules.d.ts.map +1 -1
  60. package/dist/tex-venue-rules.js +69 -0
  61. package/dist/tex-venue-rules.js.map +1 -1
  62. package/dist/venue-rules.d.ts +10 -0
  63. package/dist/venue-rules.d.ts.map +1 -1
  64. package/dist/venue-rules.js +26 -10
  65. package/dist/venue-rules.js.map +1 -1
  66. package/docs/configuration.md +139 -11
  67. package/docs/rules/cycle/evidence.md +87 -0
  68. package/docs/rules/cycle/port-done.md +75 -0
  69. package/docs/rules/cycle/record.md +152 -0
  70. package/docs/rules/cycle/stage.md +85 -0
  71. package/docs/rules.md +48 -43
  72. package/docs/submission.md +5 -0
  73. package/eslint-rules/cycle-stage.test.mjs +131 -0
  74. package/eslint-rules/cycle-stage.ts +141 -0
  75. package/eslint.config.mjs +41 -1
  76. package/lib/paper-config.ts +3 -0
  77. package/package.json +1 -1
  78. package/presets/aidc.jsonc +24 -3
  79. package/presets/aidc.md +6 -3
  80. package/presets/msr.jsonc +25 -1
  81. package/presets/msr.md +5 -1
  82. package/presets/venue-profile.schema.json +39 -0
  83. package/scripts/check.ts +4 -0
  84. package/scripts/refresh-deadlines.test.ts +431 -0
  85. package/scripts/refresh-deadlines.ts +387 -0
  86. package/src/adapters/hotcrp/cassette/README.md +22 -0
  87. package/src/adapters/hotcrp/cassette/deadlines-aidc26.html +17 -0
  88. package/src/adapters/hotcrp/cassette/deadlines-fse2027.html +24 -0
  89. package/src/adapters/hotcrp/cassette/deadlines-icse2027.html +25 -0
  90. package/src/adapters/hotcrp/cassette/deadlines-msr2027.html +25 -0
  91. package/src/adapters/hotcrp/cassette/deadlines-nosuch.html +18 -0
  92. package/src/adapters/hotcrp/cassette/deadlines-sosp26.html +26 -0
  93. package/src/adapters/hotcrp/deadlines.io.test.ts +86 -0
  94. package/src/adapters/hotcrp/deadlines.io.ts +27 -0
  95. package/src/adapters/hotcrp/deadlines.test.ts +170 -0
  96. package/src/adapters/hotcrp/deadlines.ts +161 -0
  97. package/src/adapters/hotcrp/index.ts +2 -0
  98. package/src/cli.test.ts +9 -0
  99. package/src/cli.ts +43 -4
  100. package/src/cycle-rules.test.ts +278 -0
  101. package/src/cycle-rules.ts +273 -0
  102. package/src/domain/cycle.test.ts +456 -0
  103. package/src/domain/cycle.ts +432 -0
  104. package/src/domain/deadline.test.ts +386 -0
  105. package/src/domain/deadline.ts +472 -0
  106. package/src/new-paper.test.ts +1 -0
  107. package/src/paper-settings.test.ts +167 -0
  108. package/src/paper-settings.ts +85 -11
  109. package/src/ports/venue-deadlines.ts +14 -0
  110. package/src/presets.test.ts +105 -0
  111. package/src/presets.ts +43 -10
  112. package/src/rule-cases.test.ts +144 -0
  113. package/src/submission.test.ts +105 -1
  114. package/src/submission.ts +73 -12
  115. package/src/tex-requirements.ts +31 -0
  116. package/src/tex-venue-rules.test.ts +76 -0
  117. package/src/tex-venue-rules.ts +85 -0
  118. package/src/venue-rules.test.ts +82 -0
  119. package/src/venue-rules.ts +44 -12
@@ -0,0 +1,278 @@
1
+ /**
2
+ * `cycle/record` and `cycle/evidence` on a paper folder's settings file: both halves of each — the
3
+ * finding on a planted defect, silence on a consistent record and on the flat form.
4
+ */
5
+ import { readdirSync, readFileSync } from "node:fs";
6
+ import { join } from "node:path";
7
+ import { describe, expect, it } from "vitest";
8
+ import { memoryFiles } from "./adapters/memory/index.ts";
9
+ import { presetsDir } from "./package-dirs.ts";
10
+ import { cycleRules } from "./cycle-rules.ts";
11
+
12
+ const VENUES = presetsDir();
13
+ 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
+ );
20
+
21
+ interface Reported {
22
+ readonly rule: string;
23
+ readonly messageId: string;
24
+ readonly message: string;
25
+ }
26
+
27
+ const closed = {
28
+ id: "alpha-2026",
29
+ venue: { kind: "preset", extends: "paperlint:acm-sigconf" },
30
+ kind: "short",
31
+ opened: "2026-07-01",
32
+ outcome: {
33
+ kind: "rejected",
34
+ date: "2026-09-08",
35
+ desk: true,
36
+ evidence: "reviews/alpha-decision.md",
37
+ },
38
+ };
39
+ const open = {
40
+ id: "beta-2027",
41
+ venue: { kind: "preset", extends: "paperlint:ieee-conference" },
42
+ kind: "technical",
43
+ opened: "2026-09-09",
44
+ deadlines: [
45
+ {
46
+ what: "submission",
47
+ observed: [
48
+ {
49
+ at: "2026-10-20T04:00:00Z",
50
+ source: "portal",
51
+ url: "https://beta2027.example.org/deadlines",
52
+ read: "2026-09-09",
53
+ },
54
+ ],
55
+ },
56
+ ],
57
+ };
58
+ const CALL = {
59
+ source: "call",
60
+ url: "https://2027.msrconf.org/track/msr-2027-technical-papers",
61
+ read: "2026-10-05",
62
+ };
63
+ /** A cycle at the shipped `paperlint:msr`, whose preset carries the portal's readings. */
64
+ const msr = (deadlines: readonly object[]) => ({
65
+ id: "msr-2027",
66
+ venue: { kind: "preset", extends: "paperlint:msr" },
67
+ kind: "technical",
68
+ opened: "2026-10-05",
69
+ deadlines,
70
+ });
71
+ const OVERRIDE = {
72
+ at: "2026-10-27T04:00:00Z",
73
+ reason: "the chairs extended the deadline by mail",
74
+ evidence: "mail/extension.eml",
75
+ };
76
+
77
+ /** Every cycle rule over the paper folder holding `settings` (a string: the raw file) and `extra` files. */
78
+ function lint(
79
+ settings: object | string | undefined,
80
+ extra: Readonly<Record<string, string>> = {},
81
+ file = "PIPELINE-STATUS.md",
82
+ ): Reported[] {
83
+ const files = memoryFiles({
84
+ ...(settings === undefined
85
+ ? {}
86
+ : {
87
+ [`${DIR}/paperlint.json`]:
88
+ typeof settings === "string" ? settings : JSON.stringify(settings),
89
+ }),
90
+ ...SHIPPED,
91
+ ...extra,
92
+ });
93
+ const out: Reported[] = [];
94
+ Object.entries(cycleRules({ files, venuesDir: VENUES })).forEach(
95
+ ([name, rule]) => {
96
+ rule
97
+ .create({
98
+ filename: `${DIR}/${file}`,
99
+ sourceCode: { getLocFromIndex: () => ({ line: 1, column: 0 }) },
100
+ report: (d) => {
101
+ const template = rule.meta.messages[d.messageId] ?? "";
102
+ out.push({
103
+ rule: `cycle/${name}`,
104
+ messageId: d.messageId,
105
+ message: Object.entries(d.data).reduce(
106
+ (m, [k, v]) => m.split(`{{${k}}}`).join(String(v)),
107
+ template,
108
+ ),
109
+ });
110
+ },
111
+ })
112
+ .root?.();
113
+ },
114
+ );
115
+ return out;
116
+ }
117
+
118
+ const EVIDENCE = { [`${DIR}/reviews/alpha-decision.md`]: "# decision\n" };
119
+
120
+ describe("cycle/record", () => {
121
+ it("reports on PIPELINE-STATUS.md only — any other file in the folder gets nothing", () => {
122
+ expect(lint({ cycles: [open, open] }, {}, "paper.tex")).toEqual([]);
123
+ });
124
+
125
+ it("a consistent record with its evidence on disk: every cycle rule is silent", () => {
126
+ expect(lint({ cycles: [closed, open] }, EVIDENCE)).toEqual([]);
127
+ });
128
+
129
+ it("the flat form, no settings file, or a file that is not JSON: silent — not this rule's to say", () => {
130
+ expect(lint({ extends: "paperlint:aidc", kind: "regular" })).toEqual([]);
131
+ expect(lint(undefined)).toEqual([]);
132
+ expect(lint("{ not json")).toEqual([]);
133
+ });
134
+
135
+ it("two open cycles: `broken`, naming both and the cure", () => {
136
+ const [f] = lint({
137
+ cycles: [{ ...closed, outcome: { kind: "open" } }, open],
138
+ });
139
+ expect(f?.messageId).toBe("broken");
140
+ expect(f?.message).toMatch(
141
+ /^paperlint\.json: cycles «alpha-2026» and «beta-2027» are both open/,
142
+ );
143
+ });
144
+
145
+ it("a deadline without its zone: `broken`, naming the entry and the key", () => {
146
+ const bad = msr([
147
+ { what: "submission", observed: [{ ...CALL, at: "2026-10-23" }] },
148
+ ]);
149
+ const m: unknown = expect.stringMatching(
150
+ /^paperlint\.json: cycles\[0\]\.deadlines\[0\]\.observed\[0\]: "at" must be an instant with its zone/,
151
+ );
152
+ expect(lint({ cycles: [bad] }).map((f) => f.message)).toEqual([m]);
153
+ });
154
+ });
155
+
156
+ describe("cycle/record — the deadlines, over the venue preset's readings", () => {
157
+ it("MSR 2027 as a newcomer records the call: abstract → registration, paper → submission; the preset's portal readings are derived — silent", () => {
158
+ // The portal's submission (2026-10-20T04:00Z, from the preset) is earlier than the call's
159
+ // registration (the end of 2026-10-20 AoE): two sources disagreeing, not a typo.
160
+ const fs = lint({
161
+ cycles: [
162
+ msr([
163
+ {
164
+ what: "registration",
165
+ observed: [{ ...CALL, at: "2026-10-20 AoE" }],
166
+ },
167
+ { what: "submission", observed: [{ ...CALL, at: "2026-10-23 AoE" }] },
168
+ ]),
169
+ ],
170
+ });
171
+ expect(fs).toEqual([]);
172
+ });
173
+
174
+ it("the call's own dates out of order: `order`, naming the cycle and the source", () => {
175
+ const fs = lint({
176
+ cycles: [
177
+ msr([
178
+ {
179
+ what: "registration",
180
+ observed: [{ ...CALL, at: "2026-10-25 AoE" }],
181
+ },
182
+ { what: "submission", observed: [{ ...CALL, at: "2026-10-23 AoE" }] },
183
+ ]),
184
+ ],
185
+ });
186
+ expect(fs.map((f) => f.messageId)).toEqual(["order"]);
187
+ expect(fs[0]?.message).toBe(
188
+ 'paperlint.json, cycle «msr-2027»: the call\'s "registration" (2026-10-26T11:59:59Z) is after its "submission" (2026-10-24T11:59:59Z) — a date typed wrong, or an AoE day miscounted (write a call\'s date as "YYYY-MM-DD AoE" and paperlint converts it)',
189
+ );
190
+ });
191
+ });
192
+
193
+ describe("cycle/record — a portal reading beside the preset's", () => {
194
+ it("a portal reading of a deadline the venue's preset already carries: `presetCopy`, naming the preset's reading", () => {
195
+ const fs = lint({
196
+ cycles: [
197
+ msr([
198
+ {
199
+ what: "submission",
200
+ observed: [
201
+ {
202
+ at: "2026-10-20T04:00:00Z",
203
+ source: "portal",
204
+ url: "https://msr2027.hotcrp.com/deadlines",
205
+ read: "2026-10-01",
206
+ },
207
+ ],
208
+ },
209
+ ]),
210
+ ],
211
+ });
212
+ expect(fs.map((f) => f.message)).toEqual([
213
+ "paperlint.json, cycle «msr-2027»: the \"submission\" deadline records a portal reading, and the preset paperlint:msr carries the portal's reading of it (2026-10-20T04:00:00Z, read 2026-10-05) — a second copy drifts when paperlint updates the preset: delete this one (the call's reading and an override stay)",
214
+ ]);
215
+ });
216
+
217
+ it("a portal reading of a venue whose preset carries none, or a named venue: the paper's to record — silent", () => {
218
+ expect(lint({ cycles: [open] })).toEqual([]);
219
+ expect(
220
+ lint({
221
+ cycles: [
222
+ {
223
+ ...open,
224
+ venue: {
225
+ kind: "named",
226
+ name: "Beta '27",
227
+ url: "https://beta.example.org",
228
+ },
229
+ },
230
+ ],
231
+ }),
232
+ ).toEqual([]);
233
+ });
234
+
235
+ it("a cycle whose preset does not resolve: its readings are judged alone (pdf/profile names the preset)", () => {
236
+ expect(
237
+ lint({
238
+ cycles: [
239
+ { ...open, venue: { kind: "preset", extends: "paperlint:nosuch" } },
240
+ ],
241
+ }),
242
+ ).toEqual([]);
243
+ });
244
+ });
245
+
246
+ describe("cycle/evidence", () => {
247
+ it("a closed cycle whose evidence file is not in the folder: `missing`, with the path", () => {
248
+ const fs = lint({ cycles: [closed, open] });
249
+ expect(fs.map((f) => f.rule)).toEqual(["cycle/evidence"]);
250
+ expect(fs[0]?.message).toMatch(
251
+ /^cycle «alpha-2026» is rejected \(2026-09-08\) with evidence `reviews\/alpha-decision\.md`, which is not in the paper folder/,
252
+ );
253
+ });
254
+
255
+ it("an override whose evidence is not in the folder: `overrideMissing`; on disk: silent", () => {
256
+ const settings = {
257
+ cycles: [msr([{ what: "submission", override: OVERRIDE }])],
258
+ };
259
+ expect(lint(settings).map((f) => [f.rule, f.message])).toEqual([
260
+ [
261
+ "cycle/evidence",
262
+ 'cycle «msr-2027»: the override of the "submission" deadline (2026-10-27T04:00:00Z) rests on `mail/extension.eml`, which is not in the paper folder — save the mail or the page that grants it there, or correct the path',
263
+ ],
264
+ ]);
265
+ expect(
266
+ lint(settings, { [`${DIR}/mail/extension.eml`]: "From: chairs\n" }),
267
+ ).toEqual([]);
268
+ });
269
+
270
+ it("an open cycle owes no evidence; a broken record is `cycle/record`'s alone", () => {
271
+ expect(lint({ cycles: [open] })).toEqual([]);
272
+ expect(
273
+ lint({ cycles: [{ ...closed, outcome: { kind: "open" } }, open] }).map(
274
+ (f) => f.rule,
275
+ ),
276
+ ).toEqual(["cycle/record"]);
277
+ });
278
+ });
@@ -0,0 +1,273 @@
1
+ /**
2
+ * THE CYCLE RULES over a paper's settings file — the `cycle` plugin, judged once per paper on its
3
+ * `PIPELINE-STATUS.md` (every paper folder has one, whatever its source format; `paper/folder-venue-leftover`
4
+ * reports there for the same reason):
5
+ *
6
+ * cycle/record error `cycles` in paperlint.json parses and names one current attempt: no two
7
+ * open cycles, the open one last, ids unique, every deadline an instant
8
+ * with its zone; each source's deadlines in order; no portal reading the
9
+ * venue's preset already carries
10
+ * cycle/evidence error every closed cycle's `outcome.evidence`, and every deadline override's
11
+ * `evidence`, is a file in the paper folder
12
+ *
13
+ * ── WHY ERROR, NOT WARN ───────────────────────────────────────────────────────────
14
+ * A warning is read once and never again. Each of these names a state that cannot be worked in:
15
+ * two open cycles is dual submission or a decision nobody recorded; a deadline without its zone is
16
+ * the mistake the record exists to prevent; a decision or an override whose evidence is not in the
17
+ * folder is a claim with nothing behind it. Nothing here depends on a clock or the network — the
18
+ * record is judged against itself, the installed venue preset and the disk, like `paper/stages`.
19
+ *
20
+ * ── THE PRESET'S READINGS ARE DERIVED, NOT COPIED ──────────────────────────────────
21
+ * A cycle whose venue is a preset gets that preset's `deadlines` (the portal's readings, refreshed in
22
+ * the package); the paper adds the call's readings and its overrides. A paper's own portal reading
23
+ * of a deadline the preset carries is a second copy of one fact, and it drifts the day the preset is
24
+ * updated — so it is reported.
25
+ *
26
+ * ── WHO SPEAKS WHEN THERE IS NOTHING TO JUDGE ──────────────────────────────────────
27
+ * No `paperlint.json`, one that is not JSON, or one without `cycles` (the flat form): silent — the
28
+ * flat form is still valid, and `pdf/profile` says when the whole file is broken. A `cycles` that
29
+ * does not parse, or names no current attempt (two open cycles), never reaches this rule through
30
+ * `paperlint lint`: `paperRuleBlocks` (src/cli.ts) refuses the settings file before ESLint runs, one
31
+ * line naming the file and the problem, exit 2 — the same strictness as an unknown key (measured
32
+ * 2026-10-05). The `broken` message below is what the rule says when a host runs it without that
33
+ * refusal; under the CLI only `order` is reachable.
34
+ */
35
+ import { basename, dirname, join } from "node:path";
36
+ import { callerPath } from "./caller-path.ts";
37
+ import {
38
+ cycleProblemText,
39
+ cyclesOf,
40
+ parseCycles,
41
+ type Cycle,
42
+ type Cycles,
43
+ } from "./domain/cycle.ts";
44
+ import { deadlineOrderProblems, type Reading } from "./domain/deadline.ts";
45
+ import { resolvePreset } from "./presets.ts";
46
+ import { fieldOf } from "./domain/record.ts";
47
+ import { err, ok, type Result } from "./domain/result.ts";
48
+ import type {
49
+ FolderRuleContext,
50
+ FolderRuleModule,
51
+ } from "./folder-venue-rule.ts";
52
+ import type { Finding, VenueRuleDeps } from "./venue-rules.ts";
53
+ import { rulePageUrl } from "./venue-rules.ts";
54
+ import { CONFIG_FILE } from "#lib/paper-config";
55
+
56
+ const STATUS_FILE = "PIPELINE-STATUS.md";
57
+
58
+ export type CycleRuleName = "record" | "evidence";
59
+
60
+ /** The `cycles` value of the paper's settings file; undefined when there is no file, no JSON, or no key. */
61
+ function cyclesValue(dir: string, deps: VenueRuleDeps): unknown {
62
+ const bytes = deps.files.readBytes(callerPath(join(dir, CONFIG_FILE)));
63
+ if (bytes === null) return undefined;
64
+ try {
65
+ return fieldOf(JSON.parse(new TextDecoder().decode(bytes)), "cycles");
66
+ } catch {
67
+ return undefined;
68
+ }
69
+ }
70
+
71
+ /** The paper's cycles, parsed and consistent, or the one line that says what is wrong. */
72
+ export function readCycles(
73
+ dir: string,
74
+ deps: VenueRuleDeps,
75
+ ): Result<Cycles | null, string> {
76
+ const list = parseCycles(cyclesValue(dir, deps));
77
+ if (!list.ok) return list;
78
+ if (list.value === null) return ok(null);
79
+ const cycles = cyclesOf(list.value);
80
+ return cycles.ok ? cycles : err(cycleProblemText(cycles.error));
81
+ }
82
+
83
+ /** A cycle's venue preset — its spec, as the cycle names it — and the readings it carries. */
84
+ export interface PresetOfCycle {
85
+ readonly spec: string;
86
+ readonly readings: readonly Reading[];
87
+ }
88
+
89
+ /** The preset of a cycle; null for a named venue or a preset that does not resolve. */
90
+ export type PresetReadings = (c: Cycle) => PresetOfCycle | null;
91
+
92
+ /** A paper's portal readings of deadlines its venue's preset already carries. */
93
+ function presetCopies(c: Cycle, preset: PresetOfCycle): readonly Finding[] {
94
+ return c.deadlines.flatMap((d) => {
95
+ const theirs = preset.readings.find(
96
+ (r) => r.what === d.what && r.source === "portal",
97
+ );
98
+ const ours = d.observed.some((o) => o.source === "portal");
99
+ return theirs !== undefined && ours
100
+ ? [
101
+ {
102
+ messageId: "presetCopy",
103
+ data: {
104
+ id: c.id,
105
+ what: d.what,
106
+ preset: preset.spec,
107
+ at: theirs.at,
108
+ read: theirs.read,
109
+ },
110
+ },
111
+ ]
112
+ : [];
113
+ });
114
+ }
115
+
116
+ /** `cycle/record`: the record's own problems. Pure over the read and the presets' readings. */
117
+ export function judgeRecord(
118
+ read: Result<Cycles | null, string>,
119
+ presetReadings: PresetReadings,
120
+ ): readonly Finding[] {
121
+ if (!read.ok) return [{ messageId: "broken", data: { why: read.error } }];
122
+ if (read.value === null) return [];
123
+ return read.value.list.flatMap((c) => {
124
+ const preset = presetReadings(c);
125
+ return [
126
+ ...deadlineOrderProblems(c.deadlines, preset?.readings ?? []).map(
127
+ (why) => ({ messageId: "order", data: { id: c.id, why } }),
128
+ ),
129
+ ...(preset === null ? [] : presetCopies(c, preset)),
130
+ ];
131
+ });
132
+ }
133
+
134
+ /** The overrides whose evidence is not on disk. */
135
+ const overrideEvidence = (
136
+ c: Cycle,
137
+ isFile: (relative: string) => boolean,
138
+ ): readonly Finding[] =>
139
+ c.deadlines.flatMap((d) =>
140
+ d.override !== null && !isFile(d.override.evidence)
141
+ ? [
142
+ {
143
+ messageId: "overrideMissing",
144
+ data: {
145
+ id: c.id,
146
+ what: d.what,
147
+ at: d.override.at,
148
+ evidence: d.override.evidence,
149
+ },
150
+ },
151
+ ]
152
+ : [],
153
+ );
154
+
155
+ /** The closed cycles, and the overrides, whose evidence is not on disk. */
156
+ export function judgeEvidence(
157
+ cycles: readonly Cycle[],
158
+ isFile: (relative: string) => boolean,
159
+ ): readonly Finding[] {
160
+ return cycles.flatMap((c) => {
161
+ const o = c.outcome;
162
+ return [
163
+ ...(o.kind !== "open" && !isFile(o.evidence)
164
+ ? [
165
+ {
166
+ messageId: "missing",
167
+ data: {
168
+ id: c.id,
169
+ outcome: o.kind,
170
+ date: o.date,
171
+ evidence: o.evidence,
172
+ },
173
+ },
174
+ ]
175
+ : []),
176
+ ...overrideEvidence(c, isFile),
177
+ ];
178
+ });
179
+ }
180
+
181
+ const META: Readonly<Record<CycleRuleName, FolderRuleModule["meta"]>> = {
182
+ record: {
183
+ type: "suggestion",
184
+ docs: {
185
+ description:
186
+ "the paper's `cycles` record parses and names one current attempt, each source's deadlines in order, no copy of the preset's portal readings",
187
+ url: rulePageUrl("cycle/record"),
188
+ },
189
+ schema: [],
190
+ messages: {
191
+ broken: `${CONFIG_FILE}: {{why}}`,
192
+ order: `${CONFIG_FILE}, cycle «{{id}}»: {{why}} — a date typed wrong, or an AoE day miscounted (write a call's date as "YYYY-MM-DD AoE" and paperlint converts it)`,
193
+ presetCopy: `${CONFIG_FILE}, cycle «{{id}}»: the "{{what}}" deadline records a portal reading, and the preset {{preset}} carries the portal's reading of it ({{at}}, read {{read}}) — a second copy drifts when paperlint updates the preset: delete this one (the call's reading and an override stay)`,
194
+ },
195
+ },
196
+ evidence: {
197
+ type: "suggestion",
198
+ docs: {
199
+ description:
200
+ "every closed cycle's decision, and every deadline override, points at a file in the paper folder — the mail, the reviews, the note",
201
+ url: rulePageUrl("cycle/evidence"),
202
+ },
203
+ schema: [],
204
+ messages: {
205
+ missing:
206
+ "cycle «{{id}}» is {{outcome}} ({{date}}) with evidence `{{evidence}}`, which is not in the paper folder — save the decision mail, the reviews or the note that records the decision there, or correct the path",
207
+ overrideMissing:
208
+ 'cycle «{{id}}»: the override of the "{{what}}" deadline ({{at}}) rests on `{{evidence}}`, which is not in the paper folder — save the mail or the page that grants it there, or correct the path',
209
+ },
210
+ },
211
+ };
212
+
213
+ /** Each cycle's venue preset's readings, resolved from the paper's settings file like `extends`. */
214
+ const presetReadingsOf =
215
+ (dir: string, deps: VenueRuleDeps): PresetReadings =>
216
+ (c) => {
217
+ if (c.venue.kind !== "preset") return null;
218
+ const spec = c.venue.extends;
219
+ const p = resolvePreset(spec, join(dir, CONFIG_FILE), deps);
220
+ return p.ok ? { spec, readings: p.value.deadlines } : null;
221
+ };
222
+
223
+ /** The findings of one rule for the paper folder `dir`. */
224
+ function findingsFor(
225
+ name: CycleRuleName,
226
+ dir: string,
227
+ deps: VenueRuleDeps,
228
+ ): readonly Finding[] {
229
+ const read = readCycles(dir, deps);
230
+ switch (name) {
231
+ case "record":
232
+ return judgeRecord(read, presetReadingsOf(dir, deps));
233
+ case "evidence":
234
+ return read.ok && read.value !== null
235
+ ? judgeEvidence(read.value.list, (rel) =>
236
+ deps.files.isFile(callerPath(join(dir, rel))),
237
+ )
238
+ : [];
239
+ }
240
+ }
241
+
242
+ function rule(name: CycleRuleName, deps: VenueRuleDeps): FolderRuleModule {
243
+ return {
244
+ meta: META[name],
245
+ create(context: FolderRuleContext) {
246
+ if (basename(context.filename) !== STATUS_FILE) return {};
247
+ return {
248
+ root() {
249
+ const at = context.sourceCode.getLocFromIndex(0);
250
+ findingsFor(name, dirname(context.filename), deps).forEach((f) => {
251
+ context.report({ loc: { start: at, end: at }, ...f });
252
+ });
253
+ },
254
+ };
255
+ },
256
+ };
257
+ }
258
+
259
+ /** The `cycle` plugin's rules over the settings file, for every paper's `PIPELINE-STATUS.md`. */
260
+ export function cycleRules(
261
+ deps: VenueRuleDeps,
262
+ ): Readonly<Record<CycleRuleName, FolderRuleModule>> {
263
+ return { record: rule("record", deps), evidence: rule("evidence", deps) };
264
+ }
265
+
266
+ /** The level each `cycle/*` rule on PIPELINE-STATUS.md is on at in paperlint's own config — `stage` too (eslint-rules/cycle-stage.ts). */
267
+ export const CYCLE_RULE_LEVELS: Readonly<
268
+ Record<`cycle/${CycleRuleName | "stage"}`, "error">
269
+ > = {
270
+ "cycle/record": "error",
271
+ "cycle/evidence": "error",
272
+ "cycle/stage": "error",
273
+ };