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/cli.test.ts CHANGED
@@ -25,6 +25,7 @@ import {
25
25
  parseSettings,
26
26
  run,
27
27
  runHook,
28
+ portalHosts,
28
29
  silentOptionalRules,
29
30
  toolchainTex,
30
31
  } from "./cli.ts";
@@ -241,6 +242,40 @@ test("runHook paper-folder-layout: in-process, on the real disk — a refusal on
241
242
  );
242
243
  });
243
244
 
245
+ test("runHook submission-confirm: in-process — an ask on stdout for a send, nothing for anything else, and an ask when its input cannot be read", () => {
246
+ const hook = (stdin: () => string) => {
247
+ const out: string[] = [];
248
+ const code = runHook("submission-confirm", {
249
+ stdin,
250
+ out: (t) => out.push(t),
251
+ run: () => {
252
+ throw new Error("an in-process hook spawns nothing");
253
+ },
254
+ });
255
+ return { code, out };
256
+ };
257
+ const command = (c: string) => () =>
258
+ JSON.stringify({ tool_input: { command: c }, cwd: root });
259
+ const send = hook(command("npx paperlint submission update nope --save"));
260
+ assert.equal(send.code, 0);
261
+ assert.match(
262
+ send.out.join(""),
263
+ /^\{"hookSpecificOutput":\{"hookEventName":"PreToolUse","permissionDecision":"ask","permissionDecisionReason":"This sends the paper to the venue's portal/,
264
+ );
265
+ assert.deepEqual(hook(command("npx paperlint submission show nope")), {
266
+ code: 0,
267
+ out: [],
268
+ });
269
+ const unreadable = hook(() => {
270
+ throw new Error("EAGAIN");
271
+ });
272
+ assert.equal(unreadable.code, 0);
273
+ assert.match(
274
+ unreadable.out.join(""),
275
+ /"permissionDecision":"ask","permissionDecisionReason":"paperlint could not read this command/,
276
+ );
277
+ });
278
+
244
279
  test("runHook paper-folder-layout: by default its output goes to stdout, where Claude Code reads it", () => {
245
280
  const dir = join(root, "layout-hook-stdout");
246
281
  writeTree(dir, { "papers/p/PIPELINE-STATUS.md": "# status\n" });
@@ -450,6 +485,27 @@ test("build and toolchain over a broken or empty declaration", async () => {
450
485
  assert.deepEqual(toolchainTex(broken), toolchainTex(empty));
451
486
  });
452
487
 
488
+ test("portalHosts: the hosts of the shipped presets' portals, and of the venues the project's papers extend", () => {
489
+ const dir = writeTree(join(root, "portal-hosts"), {
490
+ "package.json": "{}",
491
+ "venue.jsonc": JSON.stringify({
492
+ type: "venue",
493
+ name: "Own Venue",
494
+ url: "https://own.example.org/cfp",
495
+ deadlines: [],
496
+ extends: "paperlint:ieee-conference",
497
+ portal: { kind: "hotcrp", url: "https://submit.own-venue.example.org" },
498
+ }),
499
+ "papers/p/PIPELINE-STATUS.md": "# status\n",
500
+ "papers/p/paper.tex": "x",
501
+ "papers/p/paperlint.json": JSON.stringify({ extends: "../../venue.jsonc" }),
502
+ });
503
+ const hosts = portalHosts(dir);
504
+ assert.ok(hosts.includes("aidc.submit.acsac.org"), hosts.join());
505
+ assert.ok(hosts.includes("msr2027.hotcrp.com"), hosts.join());
506
+ assert.ok(hosts.includes("submit.own-venue.example.org"), hosts.join());
507
+ });
508
+
453
509
  test("build --dry-run on a paper with a venue plans for the preset's packages", async () => {
454
510
  const dir = writeTree(join(root, "build-venue"), {
455
511
  "package.json": "{}",
package/src/cli.ts CHANGED
@@ -70,6 +70,7 @@ import {
70
70
  pageLimitRules,
71
71
  VENUE_RULE_LEVELS,
72
72
  venueRules,
73
+ type VenueMessage,
73
74
  } from "./venue-rules.ts";
74
75
  import {
75
76
  FORMAT_RULE_LEVELS,
@@ -116,11 +117,13 @@ import {
116
117
  settingsProblemLine,
117
118
  presetProblemText,
118
119
  resolvePreset,
120
+ resolveShipped,
119
121
  shippedPresets,
120
122
  shippedSpec,
121
123
  shippedVenueNames,
122
124
  SHIPPED_PREFIX,
123
125
  type PaperPreset,
126
+ type Preset,
124
127
  type PresetTemplate,
125
128
  } from "./presets.ts";
126
129
  import type { ToolInstaller } from "./ports/tool-installer.ts";
@@ -181,6 +184,11 @@ import {
181
184
  folderLayoutRules,
182
185
  } from "./folder-layout-rule.ts";
183
186
  import { folderLayoutHook } from "./folder-layout-hook.ts";
187
+ import {
188
+ submissionConfirm,
189
+ UNREADABLE_INPUT,
190
+ } from "./submission-confirm-hook.ts";
191
+ import { leafCommandsNormalized } from "vigiles/hook";
184
192
  import {
185
193
  REPRO_LAYOUT_RULE_LEVELS,
186
194
  reproLayoutRules,
@@ -238,7 +246,7 @@ const USAGE = `paperlint — machine-checkable gates for a paper kept in git
238
246
  create the paper's submission on the venue's portal: the title
239
247
  and abstract from the build, the authors from paperlint.json,
240
248
  the PDF. A DRY RUN unless --save; a save records the new id in
241
- paperlint.json
249
+ paperlint.json. Prints the venue check first, as update does
242
250
  npx paperlint submission show [paper]
243
251
  the paper's submission on the venue's portal: status, title,
244
252
  type, topics, abstract length, the PDF the portal holds, and
@@ -246,7 +254,9 @@ const USAGE = `paperlint — machine-checkable gates for a paper kept in git
246
254
  npx paperlint submission update [paper] [--pdf <file>] [--abstract <file>] [--submit] [--save]
247
255
  send the PDF (default: the paper's own), an abstract, or
248
256
  "submitted". A DRY RUN unless --save: the portal checks the
249
- change and keeps nothing
257
+ change and keeps nothing. First prints what the venue rules find
258
+ in that exact PDF, as lint does; --save refuses a PDF the last
259
+ build did not measure (exit 2)
250
260
  npx paperlint submission topics [paper]
251
261
  the topics the venue's portal offers, against the ones its
252
262
  preset records; exit 1 when they differ
@@ -1237,8 +1247,42 @@ const IN_PROCESS_HOOKS: Readonly<Record<string, (io: HookIo) => number>> = {
1237
1247
  }
1238
1248
  return 0;
1239
1249
  },
1250
+ // Never advisory-silent: an input it cannot read is asked about, not let through.
1251
+ "submission-confirm": ({ stdin, out }) => {
1252
+ const said = ((): ReturnType<typeof submissionConfirm> => {
1253
+ try {
1254
+ return submissionConfirm(stdin(), {
1255
+ ...PRESET_DEPS,
1256
+ portalHosts,
1257
+ shellLeaves: leafCommandsNormalized,
1258
+ readSend: submissionSend,
1259
+ });
1260
+ } catch {
1261
+ return UNREADABLE_INPUT;
1262
+ }
1263
+ })();
1264
+ if (said !== null) out(`${JSON.stringify(said)}\n`);
1265
+ return 0;
1266
+ },
1240
1267
  };
1241
1268
 
1269
+ /**
1270
+ * The words after `submission` read by this CLI's own parser, as the command reads them: the paper
1271
+ * and `--pdf` a send names, null for their defaults — or null when the words would not run cleanly
1272
+ * (a flag missing its value, an unknown flag, a stray operand), so `submission-confirm` says it
1273
+ * cannot tell rather than guess.
1274
+ */
1275
+ export function submissionSend(
1276
+ words: readonly string[],
1277
+ ): { readonly paper: string | null; readonly pdf: string | null } | null {
1278
+ const a = parseArgs(["submission", ...words]);
1279
+ const clean =
1280
+ a.missingValue === undefined &&
1281
+ a.unknownFlag === undefined &&
1282
+ a.paths.length <= 2;
1283
+ return clean ? { paper: a.paths[1] ?? null, pdf: a.pdf } : null;
1284
+ }
1285
+
1242
1286
  /**
1243
1287
  * `paperlint hook <name>` — run an editor hook. It exists for ONE thing: so that the wiring does not
1244
1288
  * address the runtime from the project root.
@@ -1653,17 +1697,40 @@ function paperRequirements(dir: string): TexRequirements {
1653
1697
  * package, so the shipped union alone would not see it.
1654
1698
  */
1655
1699
  export function toolchainTex(cwd: string): TexRequirements {
1700
+ const chains = projectPresets(cwd).map((p) => p.tex);
1701
+ return declaredUnion(undefined, chains).tex;
1702
+ }
1703
+
1704
+ /** The resolved venue preset of every paper under the project's papers directories, seen from `cwd`. */
1705
+ function projectPresets(cwd: string): readonly Preset[] {
1656
1706
  const cfg = readConfig(parseArgs(["toolchain"]), {
1657
1707
  log: () => {},
1658
1708
  err: () => {},
1659
1709
  cwd,
1660
1710
  });
1661
1711
  const roots = cfg.code === undefined ? papersRoots(cfg) : [];
1662
- const chains = roots
1712
+ return roots
1663
1713
  .flatMap((r) => papersIn(r))
1664
1714
  .map((dir) => paperPreset(dir, PRESET_DEPS))
1665
- .flatMap((p) => (p.kind === "resolved" ? [p.preset.tex] : []));
1666
- return declaredUnion(undefined, chains).tex;
1715
+ .flatMap((p) => (p.kind === "resolved" ? [p.preset] : []));
1716
+ }
1717
+
1718
+ /**
1719
+ * The host of every venue portal paperlint knows of from `cwd` — the shipped presets', and those the
1720
+ * project's papers extend: what `submission-confirm` asks about when a request goes straight there.
1721
+ */
1722
+ export function portalHosts(cwd: string): readonly string[] {
1723
+ return [...resolveShipped(PRESET_DEPS), ...projectPresets(cwd)].flatMap(
1724
+ (p) =>
1725
+ p.portal === null
1726
+ ? []
1727
+ : [
1728
+ p.portal.url
1729
+ .replace(/^[a-z][a-z0-9+.-]*:\/\//iu, "")
1730
+ .split("/", 1)
1731
+ .join(""),
1732
+ ],
1733
+ );
1667
1734
  }
1668
1735
 
1669
1736
  /**
@@ -1755,6 +1822,7 @@ const SIMPLE: Readonly<
1755
1822
  site: portalSite,
1756
1823
  latex: latexReader,
1757
1824
  codec: sourcesCodec,
1825
+ lintText,
1758
1826
  log,
1759
1827
  err,
1760
1828
  },
@@ -2082,6 +2150,32 @@ const isEmptySet = (e: unknown): boolean => {
2082
2150
  );
2083
2151
  };
2084
2152
 
2153
+ /**
2154
+ * Findings on one file, printed by the formatter `paperlint lint` prints with — what `paperlint
2155
+ * submission` shows of the venue rules before a send, so the two read the same.
2156
+ */
2157
+ export async function lintText(
2158
+ file: string,
2159
+ messages: readonly VenueMessage[],
2160
+ ): Promise<string> {
2161
+ const errorCount = messages.filter((m) => m.severity === 2).length;
2162
+ const result: Readonly<ESLint.LintResult> = {
2163
+ filePath: file,
2164
+ messages: [...messages],
2165
+ suppressedMessages: [],
2166
+ errorCount,
2167
+ fatalErrorCount: 0,
2168
+ warningCount: messages.length - errorCount,
2169
+ fixableErrorCount: 0,
2170
+ fixableWarningCount: 0,
2171
+ usedDeprecatedRules: [],
2172
+ };
2173
+ return (await new ESLint().loadFormatter(LINT_FORMAT)).format([result]);
2174
+ }
2175
+
2176
+ /** The formatter `paperlint lint` prints with. */
2177
+ const LINT_FORMAT = "stylish";
2178
+
2085
2179
  /**
2086
2180
  * The end of `paperlint lint`: refuse an optional rule that reached no paper, print the findings, and
2087
2181
  * decide the exit code. Pulled out of `run` so each question has its own function.
@@ -2125,7 +2219,7 @@ async function reportLint(
2125
2219
  // Missing things are printed FIRST: they explain why the report below may be suspiciously
2126
2220
  // short. The reverse order would read as "all clean — oh, and also this".
2127
2221
  if (structure.length > 0) log(formatStructure(structure));
2128
- const out = await (await eslint.loadFormatter("stylish")).format(results);
2222
+ const out = await (await eslint.loadFormatter(LINT_FORMAT)).format(results);
2129
2223
  log(
2130
2224
  out.trim() ||
2131
2225
  (structure.length > 0
@@ -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;
@@ -65,9 +65,9 @@ check(
65
65
  // ── the one source: plugin/hooks/hooks.json ─────────────────────────────────────────────
66
66
  const wiring = shippedWiring();
67
67
  check(
68
- "the hook names are DERIVED from hooks.json — four, in its order",
68
+ "the hook names are DERIVED from hooks.json — five, in its order",
69
69
  wiring.names.join() ===
70
- "paper-edit-guard,paper-folder-layout,paper-skills-nudge,paper-status-gates",
70
+ "paper-edit-guard,submission-confirm,paper-folder-layout,paper-skills-nudge,paper-status-gates",
71
71
  );
72
72
  // ── settings written by hand: shapes Claude Code would ignore are skipped, not crashed on ──
73
73
  check(
@@ -284,7 +284,7 @@ try {
284
284
  "and names the hooks that are not wired in ANY form",
285
285
  r.status === "foreign" &&
286
286
  r.missing.join() ===
287
- "paper-folder-layout,paper-skills-nudge,paper-status-gates",
287
+ "submission-confirm,paper-folder-layout,paper-skills-nudge,paper-status-gates",
288
288
  );
289
289
  // The control: without the check, vigiles' merge WOULD add a second copy. This is the
290
290
  // reason the check exists, measured rather than asserted.
@@ -320,13 +320,13 @@ try {
320
320
  const wiredOnce = merge({}, wiring.compiled, MANAGED_BY);
321
321
  check(
322
322
  "wired once each → ✓ wired, with the fresh-clone consequence",
323
- /✓ wired — paper-edit-guard, paper-folder-layout, paper-skills-nudge, paper-status-gates, once each/.test(
323
+ /✓ wired — paper-edit-guard, submission-confirm, paper-folder-layout, paper-skills-nudge, paper-status-gates, once each/.test(
324
324
  doc(wiredOnce),
325
325
  ) && /cannot run until `npm install`/.test(doc(wiredOnce)),
326
326
  );
327
327
  check(
328
328
  "nothing wired → ⚠ not wired, with the remedy",
329
- /⚠ not wired — none of the 4 hooks/.test(doc({})),
329
+ /⚠ not wired — none of the 5 hooks/.test(doc({})),
330
330
  );
331
331
  const twice = PreToolUseSettings.parse(structuredClone(wiredOnce));
332
332
  twice.hooks.PreToolUse[0]?.hooks.push({
@@ -354,7 +354,7 @@ try {
354
354
  });
355
355
  check(
356
356
  "🔴 partly wired BY HAND → the remedy is NOT `npx paperlint init`, which would refuse to write",
357
- /partly wired — missing: paper-folder-layout, paper-skills-nudge, paper-status-gates/.test(
357
+ /partly wired — missing: submission-confirm, paper-folder-layout, paper-skills-nudge, paper-status-gates/.test(
358
358
  handPartial,
359
359
  ) && /writes nothing then/.test(handPartial),
360
360
  );
@@ -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([]);