paperlint 11.1.1 → 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.
- package/dist/cli.d.ts +21 -0
- package/dist/cli.d.ts.map +1 -1
- package/dist/cli.js +83 -7
- package/dist/cli.js.map +1 -1
- package/dist/submission-confirm-hook.d.ts +59 -0
- package/dist/submission-confirm-hook.d.ts.map +1 -0
- package/dist/submission-confirm-hook.js +230 -0
- package/dist/submission-confirm-hook.js.map +1 -0
- package/dist/submission.d.ts +13 -1
- package/dist/submission.d.ts.map +1 -1
- package/dist/submission.js +55 -7
- package/dist/submission.js.map +1 -1
- package/dist/venue-rules.d.ts +33 -0
- package/dist/venue-rules.d.ts.map +1 -1
- package/dist/venue-rules.js +84 -9
- package/dist/venue-rules.js.map +1 -1
- package/docs/install.md +4 -4
- package/docs/layout.md +1 -0
- package/docs/submission.md +33 -1
- package/package.json +1 -1
- package/plugin/hooks/hooks.json +4 -0
- package/skills/submission-portal/SKILL.md +11 -10
- package/skills/submission-portal/SKILL.md.spec.ts +10 -9
- package/src/cli.test.ts +56 -0
- package/src/cli.ts +100 -6
- package/src/hooks-settings.harness.ts +6 -6
- package/src/submission-confirm-hook.test.ts +399 -0
- package/src/submission-confirm-hook.ts +330 -0
- package/src/submission-fields-run.test.ts +1 -1
- package/src/submission-new.test.ts +29 -2
- package/src/submission-update.test.ts +153 -3
- package/src/submission.ts +85 -10
- package/src/venue-rules.test.ts +15 -0
- package/src/venue-rules.ts +126 -10
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
|
-
|
|
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
|
|
1666
|
-
|
|
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(
|
|
2222
|
+
const out = await (await eslint.loadFormatter(LINT_FORMAT)).format(results);
|
|
2129
2223
|
log(
|
|
2130
2224
|
out.trim() ||
|
|
2131
2225
|
(structure.length > 0
|
|
@@ -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 —
|
|
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
|
|
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
|
);
|
|
@@ -0,0 +1,399 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The `submission-confirm` hook: which commands send a submission (asked), which do not (silent), and
|
|
3
|
+
* what the person is told — including when what it reads is missing, stale or broken.
|
|
4
|
+
*/
|
|
5
|
+
import { expect, test } from "vitest";
|
|
6
|
+
import { memoryFiles } from "./adapters/memory/index.ts";
|
|
7
|
+
import type { AbsolutePath } from "./domain/paths.ts";
|
|
8
|
+
import { sha256Hex } from "./domain/sha256.ts";
|
|
9
|
+
import type { Files } from "./ports/files.ts";
|
|
10
|
+
import { leafCommandsNormalized as shellLeaves } from "vigiles/hook";
|
|
11
|
+
import { submissionSend } from "./cli.ts";
|
|
12
|
+
import { presetsDir } from "./package-dirs.ts";
|
|
13
|
+
import {
|
|
14
|
+
submissionConfirm as confirmWith,
|
|
15
|
+
type ConfirmDeps,
|
|
16
|
+
} from "./submission-confirm-hook.ts";
|
|
17
|
+
import { HEX, PDF, facts, withPresets } from "../test/submission-fixtures.ts";
|
|
18
|
+
|
|
19
|
+
/** What the command sends, read through the real shell parser. */
|
|
20
|
+
const kind = (command: string, hosts: () => readonly string[]) => {
|
|
21
|
+
const reason =
|
|
22
|
+
submissionConfirm(payload(command), memoryFiles({}), {
|
|
23
|
+
portalHosts: hosts,
|
|
24
|
+
})?.hookSpecificOutput.permissionDecisionReason ?? null;
|
|
25
|
+
if (reason === null) return null;
|
|
26
|
+
if (reason.startsWith(HEAD)) return "paperlint";
|
|
27
|
+
return reason.startsWith("This command names") ? "direct" : reason;
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
/** The portal hosts the project declares, in these tests: one venue on its own domain. */
|
|
31
|
+
const HOSTS = ["submit.example-venue.org", "conference.org"];
|
|
32
|
+
|
|
33
|
+
/** The hook over `fs` (the shipped presets readable beside it), the project declaring `HOSTS`. */
|
|
34
|
+
const submissionConfirm = (
|
|
35
|
+
stdin: string,
|
|
36
|
+
fs: Files,
|
|
37
|
+
more: Partial<ConfirmDeps> = {},
|
|
38
|
+
) =>
|
|
39
|
+
confirmWith(stdin, {
|
|
40
|
+
files: withPresets(fs),
|
|
41
|
+
venuesDir: presetsDir(),
|
|
42
|
+
portalHosts: () => HOSTS,
|
|
43
|
+
shellLeaves,
|
|
44
|
+
readSend: submissionSend,
|
|
45
|
+
...more,
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
const payload = (command: string, cwd = "/w"): string =>
|
|
49
|
+
JSON.stringify({ tool_name: "Bash", tool_input: { command }, cwd });
|
|
50
|
+
|
|
51
|
+
test.each([
|
|
52
|
+
"npx paperlint submission update papers/p --save",
|
|
53
|
+
"npx paperlint submission new papers/p --save",
|
|
54
|
+
"npx paperlint submission update --submit --save papers/p",
|
|
55
|
+
"npx paperlint submission update papers/p --save=true",
|
|
56
|
+
"node node_modules/paperlint/bin/paperlint.mjs submission update papers/p --submit",
|
|
57
|
+
"pnpm exec paperlint submission new --save papers/p",
|
|
58
|
+
"HOTCRP_TOKEN_X=abc npx paperlint submission update papers/p --save",
|
|
59
|
+
"npm run build && npx paperlint submission update papers/p --save",
|
|
60
|
+
`npx paperlint "submission" 'update' papers/p "--save"`,
|
|
61
|
+
"npx paperlint --save submission update papers/p",
|
|
62
|
+
`npx paperlint submis"sion" up'date' papers/p --sa"ve"`,
|
|
63
|
+
])("asked: %s", (command) => {
|
|
64
|
+
expect(kind(command, () => [])).toBe("paperlint");
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
test.each([
|
|
68
|
+
'curl -X POST -H "Authorization: bearer $HOTCRP_TOKEN" https://x.hotcrp.com/api/paper',
|
|
69
|
+
"wget --post-file=paper.pdf https://venue.hotcrp.com/api/upload",
|
|
70
|
+
"http POST https://venue.hotcrp.com/api/paper",
|
|
71
|
+
`node -e "fetch('https://venue.hotcrp.com/api/paper', { method: 'POST' })"`,
|
|
72
|
+
`python3 -c "import requests; requests.post('https://venue.hotcrp.com/api/paper')"`,
|
|
73
|
+
"curl -F file=@paper.pdf https://SUBMIT.example-venue.org/api/upload",
|
|
74
|
+
`node -e "require('https').request('https://venue.hotcrp.com/api/paper', { method: 'POST' })"`,
|
|
75
|
+
"node upload.mjs submit.example-venue.org",
|
|
76
|
+
"curl -X POST CONFERENCE.org/api/paper",
|
|
77
|
+
"echo https://venue.hotcrp.com",
|
|
78
|
+
])("asked, straight at HotCRP: %s", (command) => {
|
|
79
|
+
expect(kind(command, () => HOSTS)).toBe("direct");
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
test.each([
|
|
83
|
+
"npx paperlint submission update papers/p",
|
|
84
|
+
"npx paperlint submission new papers/p",
|
|
85
|
+
"npx paperlint submission show papers/p",
|
|
86
|
+
"npx paperlint submission topics papers/p --save",
|
|
87
|
+
"grep -rn submission docs/",
|
|
88
|
+
"git log --grep submission",
|
|
89
|
+
"cat docs/submission.md",
|
|
90
|
+
"curl https://example.org/new --save-dir x",
|
|
91
|
+
"grep -rn hotcrp src/",
|
|
92
|
+
// `paperlint new` with a paper named "submission": not a send.
|
|
93
|
+
"npx paperlint new --save submission",
|
|
94
|
+
"cat src/adapters/hotcrp/index.ts",
|
|
95
|
+
"",
|
|
96
|
+
"curl https://example.org/api/paper",
|
|
97
|
+
])("silent: %s", (command) => {
|
|
98
|
+
expect(kind(command, () => HOSTS)).toBe(null);
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
test("the hosts are read only for a command that makes a request: anything else pays nothing", () => {
|
|
102
|
+
const never = () => {
|
|
103
|
+
throw new Error("read the hosts for a command with no request in it");
|
|
104
|
+
};
|
|
105
|
+
expect(kind("ls -la", never)).toBe(null);
|
|
106
|
+
// An empty host would be in every command: it matches nothing.
|
|
107
|
+
expect(kind("curl https://example.org/x", () => [""])).toBe(null);
|
|
108
|
+
expect(kind("npx paperlint submission new --save", never)).toBe("paperlint");
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
test("hosts that cannot be read: a request still asks, rather than passing unseen", () => {
|
|
112
|
+
expect(
|
|
113
|
+
submissionConfirm(payload("curl https://example.org/x"), memoryFiles({}), {
|
|
114
|
+
portalHosts: () => {
|
|
115
|
+
throw new Error("broken preset");
|
|
116
|
+
},
|
|
117
|
+
}),
|
|
118
|
+
).toStrictEqual({
|
|
119
|
+
hookSpecificOutput: {
|
|
120
|
+
hookEventName: "PreToolUse",
|
|
121
|
+
permissionDecision: "ask",
|
|
122
|
+
permissionDecisionReason:
|
|
123
|
+
"paperlint could not tell what this command sends (broken preset). Allow only if you have read the dry run's venue check.",
|
|
124
|
+
},
|
|
125
|
+
});
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
/** The made-up paper: its PDF, and its facts when given. */
|
|
129
|
+
const paper = (factsText: string | null): Files =>
|
|
130
|
+
memoryFiles({
|
|
131
|
+
"/w/papers/p/paper.pdf": PDF,
|
|
132
|
+
...(factsText === null
|
|
133
|
+
? {}
|
|
134
|
+
: { "/w/papers/p/_build/paper.facts.json": factsText }),
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
const reasonOf = (command: string, fs: Files, cwd = "/w"): string =>
|
|
138
|
+
submissionConfirm(payload(command, cwd), fs)?.hookSpecificOutput
|
|
139
|
+
.permissionDecisionReason ?? "(no ask)";
|
|
140
|
+
|
|
141
|
+
const SEND = "npx paperlint submission update papers/p --submit --save";
|
|
142
|
+
const HEAD =
|
|
143
|
+
"This sends the paper to the venue's portal (paperlint submission with --save or --submit).";
|
|
144
|
+
const LAST = "Allow only if you have read the dry run's venue check.";
|
|
145
|
+
|
|
146
|
+
test("the ask: the paper folder, the PDF and its sha256, the body pages the build counted, and the line to read the venue check", () => {
|
|
147
|
+
expect(submissionConfirm(payload(SEND), paper(facts()))).toStrictEqual({
|
|
148
|
+
hookSpecificOutput: {
|
|
149
|
+
hookEventName: "PreToolUse",
|
|
150
|
+
permissionDecision: "ask",
|
|
151
|
+
permissionDecisionReason: [
|
|
152
|
+
HEAD,
|
|
153
|
+
"paper folder: papers/p",
|
|
154
|
+
`PDF: papers/p/paper.pdf, sha256 ${HEX.slice(0, 12)}`,
|
|
155
|
+
"body pages: 4 (up to the references on page 5)",
|
|
156
|
+
LAST,
|
|
157
|
+
].join("\n"),
|
|
158
|
+
},
|
|
159
|
+
});
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
test("a stale build is said loudly; no facts, broken facts and a missing PDF are said too — and every one still asks", () => {
|
|
163
|
+
expect(reasonOf(SEND, paper(facts({ sha: "b".repeat(64) })))).toBe(
|
|
164
|
+
[
|
|
165
|
+
HEAD,
|
|
166
|
+
"paper folder: papers/p",
|
|
167
|
+
`PDF: papers/p/paper.pdf, sha256 ${HEX.slice(0, 12)}`,
|
|
168
|
+
"!! THE LAST BUILD IS NOT OF THIS PDF — the check did not see it. Rebuild (`paperlint build`) and run the dry run again.",
|
|
169
|
+
"body pages: 4 (up to the references on page 5)",
|
|
170
|
+
LAST,
|
|
171
|
+
].join("\n"),
|
|
172
|
+
);
|
|
173
|
+
expect(reasonOf(SEND, paper(null)).split("\n")[3]).toBe(
|
|
174
|
+
"no build facts (_build/paper.facts.json): the paper was not built, so the venue check did not see this PDF",
|
|
175
|
+
);
|
|
176
|
+
expect(reasonOf(SEND, paper("{")).split("\n")[3]).toMatch(
|
|
177
|
+
/^could not read the build facts \(_build\/paper\.facts\.json\): /u,
|
|
178
|
+
);
|
|
179
|
+
expect(
|
|
180
|
+
reasonOf(SEND, paper(JSON.stringify({ schema: 1 }))).split("\n")[3],
|
|
181
|
+
).toBe("could not read the build facts (_build/paper.facts.json): schema 1");
|
|
182
|
+
expect(
|
|
183
|
+
reasonOf(
|
|
184
|
+
"npx paperlint submission update papers/p --pdf gone.pdf --save",
|
|
185
|
+
paper(facts()),
|
|
186
|
+
).split("\n")[2],
|
|
187
|
+
).toBe("PDF: gone.pdf — NOT FOUND");
|
|
188
|
+
});
|
|
189
|
+
|
|
190
|
+
test("the body pages: up to the references when the build found them, else banal's count, else not counted", () => {
|
|
191
|
+
const noHeading = (measured: boolean) =>
|
|
192
|
+
JSON.stringify({
|
|
193
|
+
...JSON.parse(facts({ measured })),
|
|
194
|
+
pages_text: ["Only body."],
|
|
195
|
+
});
|
|
196
|
+
expect(reasonOf(SEND, paper(noHeading(true))).split("\n")[3]).toBe(
|
|
197
|
+
"body pages: 4 (as banal counts them)",
|
|
198
|
+
);
|
|
199
|
+
expect(reasonOf(SEND, paper(noHeading(false))).split("\n")[3]).toBe(
|
|
200
|
+
"body pages: not counted (the build found no references heading and measured no geometry)",
|
|
201
|
+
);
|
|
202
|
+
});
|
|
203
|
+
|
|
204
|
+
test("the paper and the PDF as the command names them: the current folder by default, --pdf=<file> too", () => {
|
|
205
|
+
const fs = memoryFiles({
|
|
206
|
+
"/w/papers/p/paper.pdf": PDF,
|
|
207
|
+
"/w/papers/p/out.pdf": PDF,
|
|
208
|
+
});
|
|
209
|
+
expect(
|
|
210
|
+
reasonOf("npx paperlint submission update --save", fs, "/w/papers/p")
|
|
211
|
+
.split("\n")
|
|
212
|
+
.slice(1, 3),
|
|
213
|
+
).toStrictEqual([
|
|
214
|
+
"paper folder: p (the current folder)",
|
|
215
|
+
`PDF: paper.pdf, sha256 ${HEX.slice(0, 12)}`,
|
|
216
|
+
]);
|
|
217
|
+
expect(
|
|
218
|
+
reasonOf(
|
|
219
|
+
"npx paperlint submission update --abstract a.txt --pdf=papers/p/out.pdf papers/p --save",
|
|
220
|
+
fs,
|
|
221
|
+
)
|
|
222
|
+
.split("\n")
|
|
223
|
+
.slice(1, 3),
|
|
224
|
+
).toStrictEqual([
|
|
225
|
+
"paper folder: papers/p",
|
|
226
|
+
`PDF: papers/p/out.pdf, sha256 ${HEX.slice(0, 12)}`,
|
|
227
|
+
]);
|
|
228
|
+
});
|
|
229
|
+
|
|
230
|
+
test("the paper's own `pdf` setting is the PDF hashed when the command names no --pdf, as the command sends it", () => {
|
|
231
|
+
const fs = memoryFiles({
|
|
232
|
+
"/w/papers/p/paperlint.json": JSON.stringify({ pdf: "out/main.pdf" }),
|
|
233
|
+
"/w/papers/p/out/main.pdf": PDF,
|
|
234
|
+
});
|
|
235
|
+
expect(reasonOf(SEND, fs).split("\n")[2]).toBe(
|
|
236
|
+
`PDF: papers/p/out/main.pdf, sha256 ${HEX.slice(0, 12)}`,
|
|
237
|
+
);
|
|
238
|
+
});
|
|
239
|
+
|
|
240
|
+
test("a paperlint.json that does not parse: the PDF is the default paper.pdf, and the ask still goes out", () => {
|
|
241
|
+
const fs = memoryFiles({
|
|
242
|
+
"/w/papers/p/paperlint.json": "{ nope",
|
|
243
|
+
"/w/papers/p/paper.pdf": PDF,
|
|
244
|
+
});
|
|
245
|
+
expect(reasonOf(SEND, fs).split("\n")[2]).toBe(
|
|
246
|
+
`PDF: papers/p/paper.pdf, sha256 ${HEX.slice(0, 12)}`,
|
|
247
|
+
);
|
|
248
|
+
});
|
|
249
|
+
|
|
250
|
+
test("a --pdf at the end with no value, which the command refuses: unresolved", () => {
|
|
251
|
+
expect(
|
|
252
|
+
reasonOf(
|
|
253
|
+
"npx paperlint submission update papers/p --save --pdf",
|
|
254
|
+
paper(facts()),
|
|
255
|
+
),
|
|
256
|
+
).toBe([HEAD, UNRESOLVED, LAST].join("\n"));
|
|
257
|
+
});
|
|
258
|
+
|
|
259
|
+
test("a command that only mentions `submission` beside a send is no second send", () => {
|
|
260
|
+
expect(
|
|
261
|
+
reasonOf(
|
|
262
|
+
"npx paperlint submission update papers/p --save; echo submission",
|
|
263
|
+
paper(facts()),
|
|
264
|
+
).split("\n")[1],
|
|
265
|
+
).toBe("paper folder: papers/p");
|
|
266
|
+
});
|
|
267
|
+
|
|
268
|
+
/** What the reason says for a send the parse cannot resolve. */
|
|
269
|
+
const UNRESOLVED =
|
|
270
|
+
"could not resolve which PDF this send uploads — check the dry run";
|
|
271
|
+
|
|
272
|
+
test("the send's words are read by the command's own parser: a value flag before the paper does not shift it; words it would refuse are unresolved", () => {
|
|
273
|
+
expect(
|
|
274
|
+
reasonOf(
|
|
275
|
+
"npx paperlint submission update --max-warnings 0 --config paperlint.json papers/p --save",
|
|
276
|
+
paper(facts()),
|
|
277
|
+
).split("\n")[1],
|
|
278
|
+
).toBe("paper folder: papers/p");
|
|
279
|
+
expect(
|
|
280
|
+
reasonOf(
|
|
281
|
+
"npx paperlint submission update papers/p --nope --save",
|
|
282
|
+
paper(null),
|
|
283
|
+
),
|
|
284
|
+
).toBe([HEAD, UNRESOLVED, LAST].join("\n"));
|
|
285
|
+
});
|
|
286
|
+
|
|
287
|
+
test("anything that may change the directory before the send makes its PDF unresolved, not guessed", () => {
|
|
288
|
+
for (const command of [
|
|
289
|
+
"cd papers/p && npx paperlint submission update --save",
|
|
290
|
+
"pushd papers/p; npx paperlint submission update --save",
|
|
291
|
+
"env -C papers/p npx paperlint submission update --save",
|
|
292
|
+
])
|
|
293
|
+
expect(reasonOf(command, paper(facts())), command).toBe(
|
|
294
|
+
[HEAD, UNRESOLVED, LAST].join("\n"),
|
|
295
|
+
);
|
|
296
|
+
});
|
|
297
|
+
|
|
298
|
+
test("a quoted --pdf=<file> with a space is one word: the PDF is that file", () => {
|
|
299
|
+
const fs = memoryFiles({ "/w/final copies/paper.pdf": PDF });
|
|
300
|
+
expect(
|
|
301
|
+
reasonOf(
|
|
302
|
+
`npx paperlint submission update papers/p --pdf="final copies/paper.pdf" --save`,
|
|
303
|
+
fs,
|
|
304
|
+
).split("\n")[2],
|
|
305
|
+
).toBe(`PDF: final copies/paper.pdf, sha256 ${HEX.slice(0, 12)}`);
|
|
306
|
+
});
|
|
307
|
+
|
|
308
|
+
/** A second paper's PDF, other bytes. */
|
|
309
|
+
const B = new TextEncoder().encode("%PDF b");
|
|
310
|
+
|
|
311
|
+
test("every send in a chain is described, one block each", () => {
|
|
312
|
+
const fs = memoryFiles({
|
|
313
|
+
"/w/papers/a/paper.pdf": PDF,
|
|
314
|
+
"/w/papers/b/paper.pdf": B,
|
|
315
|
+
});
|
|
316
|
+
const reason = reasonOf(
|
|
317
|
+
"npx paperlint submission update papers/a --save && npx paperlint submission new papers/b --save",
|
|
318
|
+
fs,
|
|
319
|
+
).split("\n");
|
|
320
|
+
expect(
|
|
321
|
+
reason.filter((l) => /^(?:send|paper folder|PDF)/u.test(l)),
|
|
322
|
+
).toStrictEqual([
|
|
323
|
+
"send 1 of 2:",
|
|
324
|
+
"paper folder: papers/a",
|
|
325
|
+
`PDF: papers/a/paper.pdf, sha256 ${HEX.slice(0, 12)}`,
|
|
326
|
+
"send 2 of 2:",
|
|
327
|
+
"paper folder: papers/b",
|
|
328
|
+
`PDF: papers/b/paper.pdf, sha256 ${sha256Hex(B).slice(0, 12)}`,
|
|
329
|
+
]);
|
|
330
|
+
});
|
|
331
|
+
|
|
332
|
+
test("a send whose words the shell would expand, or one inside a string, is asked about as unresolved", () => {
|
|
333
|
+
expect(
|
|
334
|
+
reasonOf('npx paperlint submission update "$P" --save', paper(null)),
|
|
335
|
+
).toBe([HEAD, UNRESOLVED, LAST].join("\n"));
|
|
336
|
+
expect(
|
|
337
|
+
reasonOf(
|
|
338
|
+
`bash -c "npx paperlint submission update papers/p --save"`,
|
|
339
|
+
paper(null),
|
|
340
|
+
),
|
|
341
|
+
).toBe([HEAD, UNRESOLVED, LAST].join("\n"));
|
|
342
|
+
});
|
|
343
|
+
|
|
344
|
+
test("a client run by its path or quoted is a request: the program is read by its basename", () => {
|
|
345
|
+
expect(
|
|
346
|
+
kind("/usr/bin/curl -X POST https://venue.hotcrp.com/api/paper", () => []),
|
|
347
|
+
).toBe("direct");
|
|
348
|
+
expect(
|
|
349
|
+
kind(
|
|
350
|
+
`"/usr/bin/curl" -F f=@p.pdf https://submit.example-venue.org/api`,
|
|
351
|
+
() => HOSTS,
|
|
352
|
+
),
|
|
353
|
+
).toBe("direct");
|
|
354
|
+
});
|
|
355
|
+
|
|
356
|
+
test("straight at HotCRP: asked, naming that paperlint's check is skipped", () => {
|
|
357
|
+
expect(
|
|
358
|
+
reasonOf(
|
|
359
|
+
"curl -F file=@paper.pdf https://venue.hotcrp.com/api/upload",
|
|
360
|
+
paper(null),
|
|
361
|
+
),
|
|
362
|
+
).toBe(
|
|
363
|
+
"This command names a venue's submission portal, so it may send to it outside paperlint and its venue check. Allow only if you have read the dry run's venue check.",
|
|
364
|
+
);
|
|
365
|
+
});
|
|
366
|
+
|
|
367
|
+
test("a command that sends nothing: no output at all — neither allow nor deny", () => {
|
|
368
|
+
expect(
|
|
369
|
+
submissionConfirm(
|
|
370
|
+
payload("npx paperlint submission update papers/p"),
|
|
371
|
+
paper(null),
|
|
372
|
+
),
|
|
373
|
+
).toBe(null);
|
|
374
|
+
expect(
|
|
375
|
+
submissionConfirm(JSON.stringify({ tool_input: {} }), paper(null)),
|
|
376
|
+
).toBe(null);
|
|
377
|
+
});
|
|
378
|
+
|
|
379
|
+
test("a crash while describing the send still asks; a payload that is not JSON is asked about", () => {
|
|
380
|
+
const broken: Files = {
|
|
381
|
+
isFile: () => false,
|
|
382
|
+
readBytes: (p: AbsolutePath) => {
|
|
383
|
+
throw new Error(`disk on fire at ${p}`);
|
|
384
|
+
},
|
|
385
|
+
writeAtomic: () => undefined,
|
|
386
|
+
};
|
|
387
|
+
expect(reasonOf(SEND, broken)).toBe(
|
|
388
|
+
`${HEAD} paperlint could not say what will be sent: disk on fire at /w/papers/p/paperlint.json. ${LAST}`,
|
|
389
|
+
);
|
|
390
|
+
expect(
|
|
391
|
+
submissionConfirm(`{"tool_input": {"command": "${SEND}"`, paper(null))
|
|
392
|
+
?.hookSpecificOutput.permissionDecision,
|
|
393
|
+
).toBe("ask");
|
|
394
|
+
// A relative or missing cwd: the hook's own working directory.
|
|
395
|
+
expect(
|
|
396
|
+
submissionConfirm(payload(SEND, "relative"), paper(null))
|
|
397
|
+
?.hookSpecificOutput.permissionDecision,
|
|
398
|
+
).toBe("ask");
|
|
399
|
+
});
|