paperlint 6.1.0 → 6.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 (95) hide show
  1. package/dist/adapters/mp4box/index.d.ts +4 -0
  2. package/dist/adapters/mp4box/index.d.ts.map +1 -0
  3. package/dist/adapters/mp4box/index.js +46 -0
  4. package/dist/adapters/mp4box/index.js.map +1 -0
  5. package/dist/adapters/node/index.d.ts +1 -0
  6. package/dist/adapters/node/index.d.ts.map +1 -1
  7. package/dist/adapters/node/index.js +1 -0
  8. package/dist/adapters/node/index.js.map +1 -1
  9. package/dist/adapters/node/list-dir.io.d.ts +3 -0
  10. package/dist/adapters/node/list-dir.io.d.ts.map +1 -0
  11. package/dist/adapters/node/list-dir.io.js +15 -0
  12. package/dist/adapters/node/list-dir.io.js.map +1 -0
  13. package/dist/adapters/png/index.d.ts +11 -0
  14. package/dist/adapters/png/index.d.ts.map +1 -0
  15. package/dist/adapters/png/index.js +21 -0
  16. package/dist/adapters/png/index.js.map +1 -0
  17. package/dist/adapters/srt/index.d.ts +4 -0
  18. package/dist/adapters/srt/index.d.ts.map +1 -0
  19. package/dist/adapters/srt/index.js +12 -0
  20. package/dist/adapters/srt/index.js.map +1 -0
  21. package/dist/cli.d.ts.map +1 -1
  22. package/dist/cli.js +25 -4
  23. package/dist/cli.js.map +1 -1
  24. package/dist/domain/talk.d.ts +121 -0
  25. package/dist/domain/talk.d.ts.map +1 -0
  26. package/dist/domain/talk.js +121 -0
  27. package/dist/domain/talk.js.map +1 -0
  28. package/dist/lib/paper-config.d.ts +2 -0
  29. package/dist/lib/paper-config.d.ts.map +1 -1
  30. package/dist/lib/paper-config.js +3 -0
  31. package/dist/lib/paper-config.js.map +1 -1
  32. package/dist/paper-settings.d.ts +3 -0
  33. package/dist/paper-settings.d.ts.map +1 -1
  34. package/dist/paper-settings.js +15 -9
  35. package/dist/paper-settings.js.map +1 -1
  36. package/dist/ports/talk-media.d.ts +18 -0
  37. package/dist/ports/talk-media.d.ts.map +1 -0
  38. package/dist/ports/talk-media.js +2 -0
  39. package/dist/ports/talk-media.js.map +1 -0
  40. package/dist/presets.d.ts +3 -0
  41. package/dist/presets.d.ts.map +1 -1
  42. package/dist/presets.js +6 -10
  43. package/dist/presets.js.map +1 -1
  44. package/dist/talk-rules.d.ts +65 -0
  45. package/dist/talk-rules.d.ts.map +1 -0
  46. package/dist/talk-rules.js +451 -0
  47. package/dist/talk-rules.js.map +1 -0
  48. package/dist/tex-requirements.d.ts +3 -0
  49. package/dist/tex-requirements.d.ts.map +1 -1
  50. package/dist/tex-requirements.js +2 -0
  51. package/dist/tex-requirements.js.map +1 -1
  52. package/docs/configuration.md +2 -1
  53. package/docs/rules/talk/captions-cover.md +47 -0
  54. package/docs/rules/talk/duration-floor.md +38 -0
  55. package/docs/rules/talk/duration.md +43 -0
  56. package/docs/rules/talk/one-slide-size.md +38 -0
  57. package/docs/rules/talk/profile.md +64 -0
  58. package/docs/rules/talk/required-files.md +39 -0
  59. package/docs/rules/talk/undeclared.md +35 -0
  60. package/docs/rules/talk/video-format.md +43 -0
  61. package/docs/rules.md +11 -0
  62. package/docs/talk.md +94 -0
  63. package/fixtures/talk/audio-only.mp4 +0 -0
  64. package/fixtures/talk/one-slide.png +0 -0
  65. package/fixtures/talk/short-audio.mp4 +0 -0
  66. package/fixtures/talk/talk.mov +0 -0
  67. package/fixtures/talk/talk.mp4 +0 -0
  68. package/fixtures/talk/talk.srt +7 -0
  69. package/lib/paper-config.harness.mjs +5 -2
  70. package/lib/paper-config.ts +4 -0
  71. package/package.json +3 -1
  72. package/presets/agenticdev.jsonc +29 -0
  73. package/presets/venue-profile.schema.json +132 -0
  74. package/src/adapters/mp4box/index.test.ts +63 -0
  75. package/src/adapters/mp4box/index.ts +50 -0
  76. package/src/adapters/node/index.ts +1 -0
  77. package/src/adapters/node/list-dir.io.ts +15 -0
  78. package/src/adapters/node/list-dir.test.ts +30 -0
  79. package/src/adapters/png/index.test.ts +43 -0
  80. package/src/adapters/png/index.ts +29 -0
  81. package/src/adapters/srt/index.test.ts +24 -0
  82. package/src/adapters/srt/index.ts +13 -0
  83. package/src/cli.test.ts +12 -0
  84. package/src/cli.ts +31 -3
  85. package/src/domain/talk.test.ts +125 -0
  86. package/src/domain/talk.ts +274 -0
  87. package/src/new-paper.test.ts +1 -0
  88. package/src/paper-settings.test.ts +18 -1
  89. package/src/paper-settings.ts +21 -11
  90. package/src/ports/talk-media.ts +19 -0
  91. package/src/presets.ts +9 -10
  92. package/src/rule-cases.test.ts +110 -3
  93. package/src/talk-rules.test.ts +459 -0
  94. package/src/talk-rules.ts +672 -0
  95. package/src/tex-requirements.ts +9 -0
@@ -0,0 +1,459 @@
1
+ /**
2
+ * The talk rules: the pure judges on typed values, and each rule run on a paper tree on disk with the
3
+ * real media readers. `src/rule-cases.test.ts` runs every rule through the real CLI and ESLint; this
4
+ * file covers the branches one case per rule cannot.
5
+ */
6
+ import { readFileSync } from "node:fs";
7
+ import { dirname, join } from "node:path";
8
+ import { fileURLToPath } from "node:url";
9
+ import { describe, expect, it } from "vitest";
10
+ import { nodeFiles, nodeListDir } from "./adapters/node/index.ts";
11
+ import { probeVideo } from "./adapters/mp4box/index.ts";
12
+ import { imageSize } from "./adapters/png/index.ts";
13
+ import { captions } from "./adapters/srt/index.ts";
14
+ import { presetsDir } from "./package-dirs.ts";
15
+ import { venueTalkOf, type VideoProbe } from "./domain/talk.ts";
16
+ import {
17
+ clock,
18
+ DEFAULT_COVER,
19
+ judgeCover,
20
+ judgeDuration,
21
+ judgeVideoFormat,
22
+ requiredArtifacts,
23
+ talkRules,
24
+ type TalkRuleName,
25
+ } from "./talk-rules.ts";
26
+ import { useTempDir, writeTree } from "../test/support.ts";
27
+
28
+ const ROOT = dirname(dirname(fileURLToPath(import.meta.url)));
29
+ const media = (name: string): Uint8Array =>
30
+ readFileSync(join(ROOT, "fixtures", "talk", name));
31
+
32
+ const DEPS = {
33
+ files: nodeFiles,
34
+ venuesDir: presetsDir(),
35
+ media: { probeVideo, imageSize, captions },
36
+ listDir: nodeListDir,
37
+ };
38
+
39
+ const VENUE = {
40
+ modes: ["remote-video"],
41
+ artifacts: { "remote-video": { required: ["video"] } },
42
+ kinds: { short: { slot_s: 10, talk_s_min: 2, talk_s_max: 4 } },
43
+ video: { container: "mp4", min_height_px: 720, captions: "required" },
44
+ };
45
+
46
+ interface Report {
47
+ readonly messageId: string;
48
+ readonly data: Readonly<Record<string, string | number>> | undefined;
49
+ }
50
+
51
+ /** Rule `name` on a paper tree: the preset's talk block, the paper's settings, its files. */
52
+ function run(
53
+ name: TalkRuleName,
54
+ o: {
55
+ readonly venue?: Record<string, unknown> | null;
56
+ readonly settings?: Record<string, unknown>;
57
+ readonly files?: Record<string, string | Uint8Array>;
58
+ readonly options?: readonly unknown[];
59
+ readonly cwd?: string;
60
+ } = {},
61
+ ): readonly Report[] {
62
+ const dir = useTempDir("paperlint-talk-rules-");
63
+ const venue =
64
+ o.venue === null
65
+ ? { extends: "paperlint:acm-sigconf" }
66
+ : { extends: "paperlint:agenticdev", talk: { ...VENUE, ...o.venue } };
67
+ writeTree(dir, {
68
+ "package.json": "{}",
69
+ "p/paper.tex": "x",
70
+ "p/venue.jsonc": JSON.stringify(venue),
71
+ "p/paperlint.json": JSON.stringify(
72
+ o.settings ?? {
73
+ extends: "./venue.jsonc",
74
+ kind: "short",
75
+ talk: { mode: "remote-video" },
76
+ },
77
+ ),
78
+ "p/talk/talk.mp4": media("talk.mp4"),
79
+ "p/talk/talk.srt": media("talk.srt"),
80
+ ...o.files,
81
+ });
82
+ const reports: Report[] = [];
83
+ const visitor = talkRules(DEPS)[name].create({
84
+ filename: join(dir, "p", "paper.tex"),
85
+ ...(o.cwd === undefined ? {} : { cwd: o.cwd }),
86
+ sourceCode: { text: "x" },
87
+ options: o.options ?? [],
88
+ report: (d) => reports.push({ messageId: d.messageId, data: d.data }),
89
+ });
90
+ visitor.root?.();
91
+ return reports;
92
+ }
93
+ const ids = (r: readonly Report[]) => r.map((x) => x.messageId);
94
+ const declared = (talk: Record<string, unknown>, extra = {}) => ({
95
+ extends: "./venue.jsonc",
96
+ kind: "short",
97
+ talk,
98
+ ...extra,
99
+ });
100
+
101
+ describe("clock", () => {
102
+ it("prints minutes, seconds to a tenth, and the exact seconds", () => {
103
+ expect(clock(320.84)).toBe("5:20.8 (320.84 s)");
104
+ expect(clock(359.97)).toBe("6:00.0 (359.97 s)");
105
+ expect(clock(3)).toBe("0:03.0 (3 s)");
106
+ });
107
+ });
108
+
109
+ describe("talk/profile", () => {
110
+ it("is silent for the conforming tree, and on a file that is not paper.tex", () => {
111
+ expect(run("profile")).toEqual([]);
112
+ const v = talkRules(DEPS).profile.create({
113
+ filename: "/x/notes.tex",
114
+ sourceCode: { text: "" },
115
+ options: [],
116
+ report: () => undefined,
117
+ });
118
+ expect(v).toEqual({});
119
+ });
120
+
121
+ it("names a missing preset, a preset without a talk block, a missing kind and a kind with no slot", () => {
122
+ expect(
123
+ ids(run("profile", { settings: { talk: { mode: "remote-video" } } })),
124
+ ).toEqual(["noPreset"]);
125
+ expect(ids(run("profile", { venue: null }))).toEqual(["noTalkBlock"]);
126
+ expect(
127
+ ids(
128
+ run("profile", {
129
+ settings: {
130
+ extends: "./venue.jsonc",
131
+ talk: { mode: "remote-video" },
132
+ },
133
+ }),
134
+ ),
135
+ ).toEqual(["kindMissing"]);
136
+ expect(
137
+ ids(
138
+ run("profile", {
139
+ settings: declared({ mode: "remote-video" }, { kind: "full" }),
140
+ }),
141
+ ),
142
+ ).toEqual(["kindNoSlot"]);
143
+ });
144
+
145
+ it("accepts any mode when the venue lists none", () => {
146
+ expect(
147
+ run("profile", {
148
+ venue: { modes: undefined },
149
+ settings: declared({ mode: "in-person" }),
150
+ }),
151
+ ).toEqual([]);
152
+ });
153
+
154
+ it("is silent when the settings do not parse — pdf/profile says why", () => {
155
+ expect(run("profile", { settings: { talk: { mode: "zoom" } } })).toEqual(
156
+ [],
157
+ );
158
+ });
159
+ });
160
+
161
+ describe("talk/required-files and talk/undeclared", () => {
162
+ it("adds the captions when the venue requires them with the video", () => {
163
+ const vt = venueTalkOf({
164
+ artifacts: { "remote-video": { required: ["video"] } },
165
+ video: { captions: "required" },
166
+ });
167
+ expect(requiredArtifacts(vt, "remote-video")).toEqual([
168
+ "video",
169
+ "captions",
170
+ ]);
171
+ expect(requiredArtifacts(vt, "in-person")).toEqual([]);
172
+ });
173
+
174
+ it("names each missing file, under the name the paper gives it", () => {
175
+ const r = run("required-files", {
176
+ settings: declared({
177
+ mode: "remote-video",
178
+ files: { video: "mine.mp4" },
179
+ }),
180
+ });
181
+ expect(ids(r)).toEqual(["missing"]);
182
+ expect(r[0]?.data?.["artifact"]).toBe("video");
183
+ expect(String(r[0]?.data?.["file"])).toMatch(/p\/talk\/mine\.mp4$/);
184
+ expect(run("required-files", { venue: null })).toEqual([]);
185
+ });
186
+
187
+ it("warns about a video in talk/ when no talk is declared, and only then", () => {
188
+ const r = run("undeclared", {
189
+ settings: { extends: "./venue.jsonc", kind: "short" },
190
+ cwd: "/",
191
+ });
192
+ expect(ids(r)).toEqual(["undeclared"]);
193
+ // Shown relative to cwd "/": no leading slash. The temp root differs by OS
194
+ // (/tmp on Linux, /private/var/folders on macOS), so only the tail is fixed.
195
+ const shown = String(r[0]?.data?.["file"]);
196
+ expect(shown).not.toMatch(/^\//);
197
+ expect(shown).toMatch(/p\/talk\/talk\.mp4$/);
198
+ expect(run("undeclared")).toEqual([]);
199
+ expect(
200
+ run("duration", {
201
+ settings: { extends: "./venue.jsonc", kind: "short" },
202
+ }),
203
+ ).toEqual([]);
204
+ });
205
+ });
206
+
207
+ const probe = (p: Partial<VideoProbe> = {}): VideoProbe => ({
208
+ durationS: 3,
209
+ audioS: 3,
210
+ heightPx: 720,
211
+ widthPx: 1280,
212
+ brands: ["isom"],
213
+ ...p,
214
+ });
215
+ const slot = {
216
+ venue: "v",
217
+ kind: "short",
218
+ slot: { slotS: 10, talkSMin: 2, talkSMax: 4, qaS: null },
219
+ };
220
+ const at = { file: "t.mp4", tolS: 1 };
221
+
222
+ describe("talk/duration and talk/duration-floor", () => {
223
+ it("reports a video over the slot, and an audio track more than the tolerance away", () => {
224
+ expect(
225
+ ids(judgeDuration(probe({ durationS: 5, audioS: 5 }), slot, at)),
226
+ ).toEqual(["tooLong"]);
227
+ expect(ids(judgeDuration(probe({ audioS: 1.5 }), slot, at))).toEqual([
228
+ "trackMismatch",
229
+ ]);
230
+ expect(
231
+ judgeDuration(probe({ audioS: null, durationS: 9 }), null, at),
232
+ ).toEqual([]);
233
+ });
234
+
235
+ it("reads the tolerance option, and the real short-audio fixture", () => {
236
+ expect(
237
+ ids(
238
+ run("duration", {
239
+ files: { "p/talk/talk.mp4": media("short-audio.mp4") },
240
+ }),
241
+ ),
242
+ ).toEqual(["trackMismatch"]);
243
+ expect(
244
+ run("duration", {
245
+ files: { "p/talk/talk.mp4": media("short-audio.mp4") },
246
+ options: [{ trackToleranceS: 3 }],
247
+ }),
248
+ ).toEqual([]);
249
+ expect(
250
+ run("duration", { files: { "p/talk/talk.mp4": "not a video" } }),
251
+ ).toEqual([]);
252
+ });
253
+
254
+ it("warns under the floor; silent without a floor or a video", () => {
255
+ expect(
256
+ ids(
257
+ run("duration-floor", {
258
+ venue: {
259
+ kinds: { short: { slot_s: 9, talk_s_min: 5, talk_s_max: 9 } },
260
+ },
261
+ }),
262
+ ),
263
+ ).toEqual(["tooShort"]);
264
+ expect(
265
+ run("duration-floor", {
266
+ venue: { kinds: { short: { slot_s: 9, talk_s_max: 9 } } },
267
+ }),
268
+ ).toEqual([]);
269
+ expect(
270
+ run("duration-floor", {
271
+ settings: declared({
272
+ mode: "remote-video",
273
+ files: { video: "none.mp4" },
274
+ }),
275
+ }),
276
+ ).toEqual([]);
277
+ expect(
278
+ run("duration-floor", {
279
+ settings: declared({ mode: "remote-video" }, { kind: null }),
280
+ }),
281
+ ).toEqual([]);
282
+ });
283
+ });
284
+
285
+ describe("talk/video-format", () => {
286
+ const limits = {
287
+ container: "mp4" as const,
288
+ minHeightPx: 720,
289
+ maxBytes: 100,
290
+ captions: null,
291
+ };
292
+ it("reports QuickTime, a missing video track, a short picture and a large file", () => {
293
+ expect(
294
+ ids(judgeVideoFormat(probe({ brands: ["qt "] }), 1, limits)),
295
+ ).toEqual(["container"]);
296
+ expect(ids(judgeVideoFormat(probe({ heightPx: null }), 1, limits))).toEqual(
297
+ ["noVideoTrack"],
298
+ );
299
+ expect(ids(judgeVideoFormat(probe({ heightPx: 480 }), 1, limits))).toEqual([
300
+ "height",
301
+ ]);
302
+ expect(ids(judgeVideoFormat(probe(), 101, limits))).toEqual(["bytes"]);
303
+ expect(
304
+ judgeVideoFormat(probe({ heightPx: 1 }), 1e9, {
305
+ container: null,
306
+ minHeightPx: null,
307
+ maxBytes: null,
308
+ captions: null,
309
+ }),
310
+ ).toEqual([]);
311
+ });
312
+
313
+ it("reports a file that is not ISO media; silent without a video or a talk block", () => {
314
+ expect(
315
+ ids(run("video-format", { files: { "p/talk/talk.mp4": "not a video" } })),
316
+ ).toEqual(["unreadable"]);
317
+ expect(
318
+ ids(
319
+ run("video-format", {
320
+ files: { "p/talk/talk.mp4": media("talk.mov") },
321
+ }),
322
+ ),
323
+ ).toEqual(["container"]);
324
+ expect(
325
+ run("video-format", {
326
+ settings: declared({
327
+ mode: "remote-video",
328
+ files: { video: "none.mp4" },
329
+ }),
330
+ }),
331
+ ).toEqual([]);
332
+ expect(run("video-format", { venue: null })).toEqual([]);
333
+ });
334
+ });
335
+
336
+ describe("talk/one-slide-size", () => {
337
+ const png = { "p/talk/one-slide.png": media("one-slide.png") };
338
+ it("holds the image to the paper's size over the venue's", () => {
339
+ expect(
340
+ run("one-slide-size", {
341
+ files: png,
342
+ venue: { one_slide: { width_px: 16, height_px: 9 } },
343
+ }),
344
+ ).toEqual([]);
345
+ expect(
346
+ ids(
347
+ run("one-slide-size", {
348
+ files: png,
349
+ venue: { one_slide: { width_px: 1920, height_px: 1080 } },
350
+ }),
351
+ ),
352
+ ).toEqual(["size"]);
353
+ expect(
354
+ run("one-slide-size", {
355
+ files: png,
356
+ settings: declared({
357
+ mode: "remote-video",
358
+ one_slide: { width_px: 16, height_px: 9 },
359
+ }),
360
+ venue: { one_slide: { width_px: 1, height_px: 1 } },
361
+ }),
362
+ ).toEqual([]);
363
+ });
364
+
365
+ it("reports a file that is not a PNG; silent with no size declared or no file", () => {
366
+ expect(
367
+ ids(
368
+ run("one-slide-size", {
369
+ files: { "p/talk/one-slide.png": "x" },
370
+ venue: { one_slide: { width_px: 1, height_px: 1 } },
371
+ }),
372
+ ),
373
+ ).toEqual(["notPng"]);
374
+ expect(run("one-slide-size", { files: png })).toEqual([]);
375
+ expect(
376
+ run("one-slide-size", {
377
+ venue: { one_slide: { width_px: 1, height_px: 1 } },
378
+ }),
379
+ ).toEqual([]);
380
+ });
381
+ });
382
+
383
+ describe("talk/captions-cover", () => {
384
+ const cue = (startS: number, endS: number) => ({ startS, endS });
385
+ it("reports no cues, a late start, an early end and a long gap", () => {
386
+ expect(ids(judgeCover([], 10, DEFAULT_COVER))).toEqual(["noCues"]);
387
+ expect(ids(judgeCover([cue(2, 10)], 10, DEFAULT_COVER))).toEqual([
388
+ "lateStart",
389
+ ]);
390
+ expect(ids(judgeCover([cue(0, 7)], 10, DEFAULT_COVER))).toEqual([
391
+ "earlyEnd",
392
+ ]);
393
+ expect(ids(judgeCover([cue(7, 10), cue(0, 1)], 10, DEFAULT_COVER))).toEqual(
394
+ ["gap"],
395
+ );
396
+ expect(judgeCover([cue(0, 4), cue(5, 9)], 10, DEFAULT_COVER)).toEqual([]);
397
+ });
398
+
399
+ it("reads its thresholds from the options; silent without captions or a video", () => {
400
+ const late = { "p/talk/talk.srt": "1\n00:00:02,000 --> 00:00:02,900\nx\n" };
401
+ expect(ids(run("captions-cover", { files: late }))).toEqual(["lateStart"]);
402
+ expect(
403
+ run("captions-cover", { files: late, options: [{ maxStartS: 3 }] }),
404
+ ).toEqual([]);
405
+ expect(
406
+ run("captions-cover", {
407
+ settings: declared({
408
+ mode: "remote-video",
409
+ files: { captions: "none.srt" },
410
+ }),
411
+ }),
412
+ ).toEqual([]);
413
+ expect(
414
+ run("captions-cover", { files: { "p/talk/talk.mp4": "x" } }),
415
+ ).toEqual([]);
416
+ });
417
+ });
418
+
419
+ describe("without a preset or a talk block, only talk/profile speaks", () => {
420
+ const noPreset = { talk: { mode: "remote-video" }, kind: "short" };
421
+ it.each([
422
+ "required-files",
423
+ "duration",
424
+ "duration-floor",
425
+ "video-format",
426
+ ] as const)("%s is silent", (name) => {
427
+ expect(run(name, { settings: noPreset })).toEqual([]);
428
+ expect(run(name, { venue: null })).toEqual([]);
429
+ });
430
+
431
+ it("a duration mismatch is still reported without a slot", () => {
432
+ const short = { "p/talk/talk.mp4": media("short-audio.mp4") };
433
+ expect(ids(run("duration", { settings: noPreset, files: short }))).toEqual([
434
+ "trackMismatch",
435
+ ]);
436
+ });
437
+
438
+ it("a kind the venue sets no slot for: no floor, and profile lists the kinds or (none)", () => {
439
+ const full = declared({ mode: "remote-video" }, { kind: "full" });
440
+ expect(run("duration-floor", { settings: full })).toEqual([]);
441
+ const r = run("profile", { venue: { kinds: {} } });
442
+ expect(r[0]?.data?.["known"]).toBe("(none)");
443
+ });
444
+ });
445
+
446
+ describe("largest gap, in time order", () => {
447
+ const cue = (startS: number, endS: number) => ({ startS, endS });
448
+ it("keeps the longest of several gaps, and an overlap leaves none", () => {
449
+ const r = judgeCover(
450
+ [cue(0, 1), cue(7, 8), cue(8.5, 9.5)],
451
+ 10,
452
+ DEFAULT_COVER,
453
+ );
454
+ expect(r.map((f) => [f.messageId, f.data["s"]])).toEqual([["gap", "6.0"]]);
455
+ expect(
456
+ judgeCover([cue(0, 9), cue(1, 2), cue(8.5, 9.9)], 10, DEFAULT_COVER),
457
+ ).toEqual([]);
458
+ });
459
+ });