paperlint 11.1.0 → 11.1.2

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 (41) hide show
  1. package/.github/workflows/dependabot-automerge.yml +20 -6
  2. package/.github/workflows/release.yml +10 -0
  3. package/dist/cli.d.ts +21 -0
  4. package/dist/cli.d.ts.map +1 -1
  5. package/dist/cli.js +83 -7
  6. package/dist/cli.js.map +1 -1
  7. package/dist/submission-confirm-hook.d.ts +59 -0
  8. package/dist/submission-confirm-hook.d.ts.map +1 -0
  9. package/dist/submission-confirm-hook.js +230 -0
  10. package/dist/submission-confirm-hook.js.map +1 -0
  11. package/dist/submission.d.ts +13 -1
  12. package/dist/submission.d.ts.map +1 -1
  13. package/dist/submission.js +55 -7
  14. package/dist/submission.js.map +1 -1
  15. package/dist/venue-rules.d.ts +33 -0
  16. package/dist/venue-rules.d.ts.map +1 -1
  17. package/dist/venue-rules.js +84 -9
  18. package/dist/venue-rules.js.map +1 -1
  19. package/docs/install.md +4 -4
  20. package/docs/layout.md +1 -0
  21. package/docs/submission.md +33 -1
  22. package/package.json +1 -1
  23. package/plugin/hooks/hooks.json +4 -0
  24. package/scripts/refresh-deadlines/main.test.ts +5 -2
  25. package/scripts/refresh-deadlines/refresh.test.ts +15 -14
  26. package/skills/submission-portal/SKILL.md +11 -10
  27. package/skills/submission-portal/SKILL.md.spec.ts +10 -9
  28. package/src/cli.test.ts +56 -0
  29. package/src/cli.ts +100 -6
  30. package/src/cycle-rules.test.ts +14 -6
  31. package/src/hooks-settings.harness.ts +6 -6
  32. package/src/presets.test.ts +34 -24
  33. package/src/submission-confirm-hook.test.ts +399 -0
  34. package/src/submission-confirm-hook.ts +330 -0
  35. package/src/submission-fields-run.test.ts +1 -1
  36. package/src/submission-new.test.ts +29 -2
  37. package/src/submission-show.test.ts +9 -0
  38. package/src/submission-update.test.ts +153 -3
  39. package/src/submission.ts +85 -10
  40. package/src/venue-rules.test.ts +15 -0
  41. package/src/venue-rules.ts +126 -10
package/src/submission.ts CHANGED
@@ -18,6 +18,11 @@
18
18
  * sent (`src/domain/submission-fields.ts`). A checkbox field the portal did not take is said, and
19
19
  * fails the command: HotCRP keeps an option it does not know as nothing, without an error.
20
20
  *
21
+ * Before the portal is asked, `new` and `update` print what the venue rules find in the exact PDF
22
+ * they send, as `paperlint lint` prints it — or "venue rules: no errors" (paperlint#264). The verdict
23
+ * is the author's to act on; the one thing the command refuses is a `--save` whose build facts are
24
+ * missing or about other bytes, because then nothing was checked (exit 2).
25
+ *
21
26
  * 🔴 THE TOKEN IS AN INPUT, AND ONLY AN INPUT. It comes from the environment (the root reads it), is
22
27
  * handed to the adapter, and never appears in anything printed here.
23
28
  */
@@ -71,11 +76,12 @@ import {
71
76
  } from "./domain/portal-token.ts";
72
77
  import { LINE_BREAK, OPAQUE, runText } from "./domain/tex-document.ts";
73
78
  import type { LatexReader } from "./ports/latex.ts";
74
- import { factsPath, parseFactsText } from "./facts-file.ts";
79
+ import { factsPath, parseFactsText, type ReadFacts } from "./facts-file.ts";
75
80
  import { lineColumn, readPaper } from "./tex-paper.ts";
76
81
  import { originOf, type PaperSource } from "./domain/paper-source.ts";
77
82
  import type { SourcesCodec } from "./ports/sources-codec.ts";
78
83
  import { MAIN } from "./build.ts";
84
+ import { assessSent, venueMessages, type VenueMessage } from "./venue-rules.ts";
79
85
 
80
86
  /** How to make and set the site's token: the page, the scopes, the variable. Pure. */
81
87
  export function tokenSteps(
@@ -157,12 +163,21 @@ export interface SubmissionDeps {
157
163
  readonly latex: LatexReader;
158
164
  /** Reads the build's record of the files TeX read, for the includes (`new`). */
159
165
  readonly codec: SourcesCodec;
166
+ /** Findings on one file, printed as `paperlint lint` prints them (the root passes ESLint's `stylish`). */
167
+ readonly lintText: (
168
+ file: string,
169
+ messages: readonly VenueMessage[],
170
+ ) => Promise<string>;
160
171
  readonly log: (line: string) => void;
161
172
  readonly err: (line: string) => void;
162
173
  }
163
174
 
164
175
  /** A paper's portal, its submission there, and the PDF the paper builds. */
165
176
  export interface SubmissionTarget {
177
+ /** The paper's folder, absolute. */
178
+ readonly paperDir: string;
179
+ /** The paper's settings and venue preset, resolved: what the venue rules judge the PDF against. */
180
+ readonly resolved: Extract<PaperPreset, { readonly kind: "resolved" }>;
166
181
  readonly portal: SupportedPortal;
167
182
  /** The paper's submission on the portal; null before `new` created one. */
168
183
  readonly id: number | null;
@@ -194,6 +209,12 @@ export interface SubmissionTarget {
194
209
 
195
210
  const configOf = (dir: string): string => join(dir, CONFIG_FILE);
196
211
 
212
+ /** The PDF a paper sends when no `--pdf` is named: its `pdf` setting, else `paper.pdf`. Pure. */
213
+ export const paperPdf = (
214
+ paperDir: string,
215
+ settings: Pick<PaperSettings, "pdf"> | null,
216
+ ): string => join(paperDir, settings?.pdf ?? "paper.pdf");
217
+
197
218
  /** Why a paper without a venue preset has no portal: the key to set, or — with cycles — where. */
198
219
  const noPresetLine = (
199
220
  paperDir: string,
@@ -241,6 +262,8 @@ function targetOf(
241
262
  ? []
242
263
  : currentDeadlines(p.settings.cycles.current);
243
264
  return ok({
265
+ paperDir,
266
+ resolved: p,
244
267
  portal: portal.value,
245
268
  id: p.settings.submission?.id ?? null,
246
269
  config: configOf(paperDir),
@@ -249,7 +272,7 @@ function targetOf(
249
272
  fields: {},
250
273
  venueFields: p.preset.submissionFields,
251
274
  accepted: p.settings.cycles?.current.kind === "accepted",
252
- pdf: join(paperDir, p.settings.pdf ?? "paper.pdf"),
275
+ pdf: paperPdf(paperDir, p.settings),
253
276
  call: identity.type === "venue" ? identity.url : null,
254
277
  deadlines: deadlinesInForce(own, p.preset.deadlines),
255
278
  });
@@ -477,6 +500,53 @@ const changeOf = (
477
500
  fields,
478
501
  });
479
502
 
503
+ /**
504
+ * The venue rules on the bytes about to be sent, printed as `paperlint lint` prints them (ESLint's
505
+ * `stylish`), or one line when they find nothing. Returns whether a `--save` stops, said: the
506
+ * build's facts are missing, unreadable or about other bytes, so the rules did not see this PDF.
507
+ */
508
+ async function venueCheck(
509
+ t: SubmissionTarget,
510
+ bytes: Uint8Array,
511
+ save: boolean,
512
+ deps: RunDeps,
513
+ ): Promise<boolean> {
514
+ const a = assessSent(t.paperDir, t.resolved, bytes, deps.presets);
515
+ const tex = join(t.paperDir, MAIN);
516
+ const source = bytesAt(deps.files, tex);
517
+ const messages = venueMessages(
518
+ a,
519
+ source === null ? null : new TextDecoder().decode(source),
520
+ );
521
+ const printed = (await deps.lintText(tex, messages)).trim();
522
+ logAll(
523
+ printed === ""
524
+ ? [nothingFound(a.kind)]
525
+ : [`${VENUE_RULES}:`, ...printed.split("\n")],
526
+ deps,
527
+ );
528
+ // Checked on its own: a cycle still porting skips the venue rules, not this.
529
+ const refused = save && factsOfSent(t.paperDir, bytes, deps) === null;
530
+ if (refused) deps.err(REBUILD_REFUSAL);
531
+ return refused;
532
+ }
533
+
534
+ /** The line for a check that found nothing — which, while a cycle is porting, is that nothing ran. */
535
+ const nothingFound = (kind: string): string =>
536
+ kind === "porting"
537
+ ? "venue rules: none run — the current cycle's phase is \"porting\", so the venue's format does not apply yet"
538
+ : `${VENUE_RULES}: no errors`;
539
+
540
+ /**
541
+ * What the check is, said where it prints: the venue rules at paperlint's default levels and
542
+ * options. A paper's or the project's `rules` overrides are not read here; `paperlint lint` reads them.
543
+ */
544
+ const VENUE_RULES = "venue rules at their default options";
545
+
546
+ /** What `--save` says when the build's facts do not describe the PDF: nothing was sent. */
547
+ const REBUILD_REFUSAL =
548
+ "refused: no build facts describe this PDF (none, unreadable, or of other bytes), so the venue rules did not see it — rebuild (`paperlint build`), run the dry run again, and read its venue check; nothing was sent";
549
+
480
550
  /** `update`: send the PDF (and the abstract, and "submitted" when asked); a dry run unless saving. */
481
551
  async function runUpdate(
482
552
  t: SubmissionTarget & { readonly id: number },
@@ -493,7 +563,10 @@ async function runUpdate(
493
563
  );
494
564
  const abstract = abstractOf(a, deps);
495
565
  if (!abstract.ok) return (deps.err(abstract.error), 2);
496
- logAll([checkPdfLine(shown, t.call), ...fieldLines(t.fields)], deps);
566
+ deps.log(checkPdfLine(shown, t.call));
567
+ const refused = await venueCheck(t, bytes, a.save, deps);
568
+ logAll(fieldLines(t.fields), deps);
569
+ if (refused) return 2;
497
570
  const change = changeOf(a, bytes, abstract.value, t.fields);
498
571
  const r = await portal.update(t.id, change, { save: a.save });
499
572
  if (!r.ok) return failed(r.error, deps);
@@ -508,19 +581,19 @@ async function runUpdate(
508
581
  // ── new ──────────────────────────────────────────────────────────────────────────────
509
582
 
510
583
  /**
511
- * The metadata of the PDF that is sent, from the build's facts — the title's fallback; null when
512
- * there are no facts, or they describe another PDF.
584
+ * The build's facts about the PDF that is sent — what the title falls back on, and what a `--save`
585
+ * needs to exist; null when there are none, they do not read, or they describe other bytes.
513
586
  */
514
- function pdfMetadata(
587
+ function factsOfSent(
515
588
  paperDir: string,
516
589
  pdf: Uint8Array,
517
- deps: SubmissionDeps,
518
- ): Readonly<Record<string, string>> | null {
590
+ deps: Pick<SubmissionDeps, "files">,
591
+ ): ReadFacts | null {
519
592
  const bytes = bytesAt(deps.files, factsPath(paperDir));
520
593
  const facts =
521
594
  bytes === null ? null : parseFactsText(new TextDecoder().decode(bytes));
522
595
  return facts?.ok === true && facts.value.sha === sha256Hex(pdf)
523
- ? facts.value.text.metadata
596
+ ? facts.value
524
597
  : null;
525
598
  }
526
599
 
@@ -734,7 +807,7 @@ function titleAndAbstract(
734
807
  const own = abstractOf(a, deps);
735
808
  if (!own.ok) return own;
736
809
  const paper = assembledPaper(a.paperDir, deps);
737
- const metadata = pdfMetadata(a.paperDir, pdf, deps);
810
+ const metadata = factsOfSent(a.paperDir, pdf, deps)?.text.metadata ?? null;
738
811
  const title = titleOf(
739
812
  paper === null
740
813
  ? null
@@ -824,7 +897,9 @@ async function runNew(
824
897
  if (!n.ok) return (deps.err(n.error), 2);
825
898
  const { paper, shown } = n.value;
826
899
  deps.log(checkPdfLine(shown, t.call));
900
+ const refused = await venueCheck(t, paper.pdf.bytes, a.save, deps);
827
901
  logAll(newLines(paper), deps);
902
+ if (refused) return 2;
828
903
  const r = await portal.create(paper, { save: a.save });
829
904
  if (!r.ok) return failed(r.error, deps);
830
905
  const dropped = droppedLines(t.fields, t.venueFields, r.value);
@@ -16,6 +16,7 @@ import { presetsDir } from "./package-dirs.ts";
16
16
  import {
17
17
  ANONYMITY_RULE_LEVELS,
18
18
  anonymityRules,
19
+ findingText,
19
20
  judgeBodyEnd,
20
21
  PAGE_LIMIT_RULE_LEVELS,
21
22
  pageLimitRules,
@@ -1275,3 +1276,17 @@ describe("the cycle decides what applies", () => {
1275
1276
  expect(lint({ venue: onCycle({ outcome: accepted }), facts })).toEqual([]);
1276
1277
  });
1277
1278
  });
1279
+
1280
+ describe("findingText — a finding outside ESLint, as ESLint prints it", () => {
1281
+ it("fills each placeholder from the data, leaves one the data lacks as written, and prints an unknown id as itself", () => {
1282
+ expect(
1283
+ findingText("page-limit", {
1284
+ messageId: "pages",
1285
+ data: { what: "body pages", got: 3, max: 2, venue: "v" },
1286
+ }),
1287
+ ).toBe(
1288
+ "body pages: 3, over the limit 2 for v/{{kind}} — a desk reject; cut the text",
1289
+ );
1290
+ expect(findingText("fonts", { messageId: "nope", data: {} })).toBe("nope");
1291
+ });
1292
+ });
@@ -51,7 +51,13 @@ import {
51
51
  type ReadFacts,
52
52
  } from "./facts-file.ts";
53
53
  import type { KindLimits, VenueFormat } from "./tex-requirements.ts";
54
- import { paperPreset, presetProblemText, type Preset } from "./presets.ts";
54
+ import { entries } from "remeda";
55
+ import {
56
+ paperPreset,
57
+ presetProblemText,
58
+ type PaperPreset,
59
+ type Preset,
60
+ } from "./presets.ts";
55
61
  import type { PaperSettings } from "./paper-settings.ts";
56
62
  import type { FlatGeometry } from "./domain/geometry.ts";
57
63
  import { callerPath } from "./caller-path.ts";
@@ -171,18 +177,25 @@ const reviewOf = (
171
177
  identity: settings.identity,
172
178
  });
173
179
 
180
+ const isFinding = (v: object): v is Finding => "messageId" in v;
181
+
182
+ /** The facts, read, or the `pdf/fresh` finding that says why they cannot be. */
183
+ function readFacts(body: string): ReadFacts | Finding {
184
+ const parsed = parseFactsText(body);
185
+ if (parsed.ok) return parsed.value;
186
+ return parsed.error.kind === "schema"
187
+ ? finding("schema", { got: parsed.error.got, want: FACTS_SCHEMA })
188
+ : finding("factsBroken", { why: parsed.error.why });
189
+ }
190
+
174
191
  /** The facts about the PDF on disk, or the `pdf/fresh` finding that says why they are not. */
175
192
  function freshFacts(
176
193
  files: Files,
177
194
  paperDir: string,
178
195
  body: string,
179
196
  ): ReadFacts | Finding {
180
- const parsed = parseFactsText(body);
181
- if (!parsed.ok)
182
- return parsed.error.kind === "schema"
183
- ? finding("schema", { got: parsed.error.got, want: FACTS_SCHEMA })
184
- : finding("factsBroken", { why: parsed.error.why });
185
- const f = parsed.value;
197
+ const f = readFacts(body);
198
+ if (isFinding(f)) return f;
186
199
  const pdf = files.readBytes(
187
200
  callerPath(isAbsolute(f.pdf) ? f.pdf : join(paperDir, f.pdf)),
188
201
  );
@@ -190,8 +203,6 @@ function freshFacts(
190
203
  return sha256Hex(pdf) === f.sha ? f : finding("stale", { pdf: f.pdf });
191
204
  }
192
205
 
193
- const isFinding = (v: object): v is Finding => "messageId" in v;
194
-
195
206
  /** The paper's declared venue and kind, resolved: what the judges compare the facts against. */
196
207
  const resolvedOf = (preset: Preset, settings: PaperSettings): Resolved => ({
197
208
  venue: preset.label,
@@ -221,17 +232,49 @@ export function assessPaper(paperDir: string, deps: VenueRuleDeps): Assessment {
221
232
  kind: "unresolved",
222
233
  finding: finding("preset", { why: presetProblemText(p.problem) }),
223
234
  };
235
+ return assessBuild(paperDir, p, deps, (body) =>
236
+ freshFacts(deps.files, paperDir, body),
237
+ );
238
+ }
239
+
240
+ /** A paper whose preset resolved, against its build: `fresh` reads the facts and says whether they hold. */
241
+ function assessBuild(
242
+ paperDir: string,
243
+ p: Extract<PaperPreset, { readonly kind: "resolved" }>,
244
+ deps: VenueRuleDeps,
245
+ fresh: (factsText: string) => ReadFacts | Finding,
246
+ ): Assessment {
224
247
  if (p.settings.cycles !== null && isPorting(p.settings.cycles.current))
225
248
  return { kind: "porting" };
226
249
  const venue = resolvedOf(p.preset, p.settings);
227
250
  const factsText = text(deps.files, factsPath(paperDir));
228
251
  if (factsText === null) return { kind: "unbuilt", venue };
229
- const facts = freshFacts(deps.files, paperDir, factsText);
252
+ const facts = fresh(factsText);
230
253
  return isFinding(facts)
231
254
  ? { kind: "stale", finding: facts }
232
255
  : { kind: "ready", venue, facts };
233
256
  }
234
257
 
258
+ /**
259
+ * A paper whose preset resolved, assessed on `pdf` — the bytes `paperlint submission` is about to
260
+ * send. The facts hold when their SHA-256 is that of `pdf`; where the PDF they name is on disk, and
261
+ * whether it is still there, does not matter. Reads through `deps.files` only.
262
+ */
263
+ export function assessSent(
264
+ paperDir: string,
265
+ p: Extract<PaperPreset, { readonly kind: "resolved" }>,
266
+ pdf: Uint8Array,
267
+ deps: VenueRuleDeps,
268
+ ): Assessment {
269
+ const sha = sha256Hex(pdf);
270
+ return assessBuild(paperDir, p, deps, (body) => {
271
+ const f = readFacts(body);
272
+ return isFinding(f) || f.sha === sha
273
+ ? f
274
+ : finding("notSent", { pdf: f.pdf });
275
+ });
276
+ }
277
+
235
278
  // ── the judges: typed facts + typed format → findings. Pure. ─────────────────────────
236
279
 
237
280
  /** Type 3, not embedded, and each family the venue names missing from every font. */
@@ -658,6 +701,8 @@ const META: Readonly<Record<VenueRuleName, Meta>> = {
658
701
  "_build/paper.facts.json describes {{pdf}}, which is not on disk (a failed build removes it) — rebuild the paper",
659
702
  stale:
660
703
  "_build/paper.facts.json describes a DIFFERENT {{pdf}} than the one on disk (its SHA-256 differs), so the venue checks would judge an earlier build — rebuild the paper",
704
+ notSent:
705
+ "_build/paper.facts.json describes a {{pdf}} with another SHA-256 than the PDF being sent, so the venue checks would judge other bytes — rebuild the paper (`paperlint build`) from the source of the PDF you send",
661
706
  },
662
707
  },
663
708
  profile: {
@@ -874,3 +919,74 @@ export function pageLimitRules(
874
919
  ): Readonly<Record<"page-limit", VenueRuleModule>> {
875
920
  return { "page-limit": rule("page-limit", deps) };
876
921
  }
922
+
923
+ // ── the same findings, outside ESLint ──────────────────────────────────────────────────
924
+
925
+ /** Each venue rule by the id lint reports it under. */
926
+ const LINT_IDS = {
927
+ fresh: "pdf/fresh",
928
+ profile: "pdf/profile",
929
+ measured: "pdf/measured",
930
+ "page-limit": "format/page-limit",
931
+ fonts: "pdf/fonts",
932
+ geometry: "pdf/geometry",
933
+ "body-size": "pdf/body-size",
934
+ anonymity: "anonymity/identity",
935
+ } as const satisfies Record<VenueRuleName, string>;
936
+
937
+ const LEVELS = {
938
+ ...VENUE_RULE_LEVELS,
939
+ ...PAGE_LIMIT_RULE_LEVELS,
940
+ ...ANONYMITY_RULE_LEVELS,
941
+ };
942
+
943
+ /**
944
+ * A finding's message as ESLint prints it: the rule's message with `{{name}}` filled from its data,
945
+ * a placeholder the data lacks left as written, and an unknown message id as itself. Pure.
946
+ */
947
+ export function findingText(rule: VenueRuleName, f: Finding): string {
948
+ const message = META[rule].messages[f.messageId] ?? f.messageId;
949
+ return message.replace(/\{\{([^{}]+?)\}\}/gu, (whole, name: string) => {
950
+ const value = f.data[name.trim()];
951
+ return value === undefined ? whole : String(value);
952
+ });
953
+ }
954
+
955
+ /** One finding as a lint message: the fields ESLint's formatters read from a `Linter.LintMessage`. */
956
+ export interface VenueMessage {
957
+ readonly ruleId: string;
958
+ readonly severity: 1 | 2;
959
+ readonly message: string;
960
+ readonly messageId: string;
961
+ readonly line: number;
962
+ readonly column: number;
963
+ readonly endLine: number;
964
+ readonly endColumn: number;
965
+ }
966
+
967
+ /**
968
+ * Every venue rule's findings on an assessed paper, as the lint messages `paperlint lint` would
969
+ * report on its `paper.tex` (`source`; line 1 without one): the same judges, at paperlint's default
970
+ * levels and options. `paperlint submission` prints them with lint's formatter before a send. Paths
971
+ * are shown as they are: the one message that shows a path (`pdf/measured` on a paper without a
972
+ * preset) is never reached once a preset resolved. Pure.
973
+ */
974
+ export function venueMessages(
975
+ a: Assessment,
976
+ source: string | null,
977
+ ): readonly VenueMessage[] {
978
+ const line = source === null ? 1 : reportLine(source);
979
+ return entries(LINT_IDS).flatMap(([name, ruleId]) =>
980
+ JUDGES[name](a, { options: [], shown: String }).map((f) => ({
981
+ ruleId,
982
+ severity: LEVELS[ruleId] === "error" ? 2 : 1,
983
+ message: findingText(name, f),
984
+ messageId: f.messageId,
985
+ // ESLint's columns count from 1: the rule reports column 1 counted from 0.
986
+ line,
987
+ column: 2,
988
+ endLine: line,
989
+ endColumn: 3,
990
+ })),
991
+ );
992
+ }