paperlint 2.0.1 → 2.1.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.
- package/CONTRIBUTING.md +21 -0
- package/README.md +56 -14
- package/dist/build-engine.d.ts.map +1 -1
- package/dist/build-engine.js +5 -2
- package/dist/build-engine.js.map +1 -1
- package/dist/build.d.ts +1 -1
- package/dist/build.d.ts.map +1 -1
- package/dist/build.js +4 -3
- package/dist/build.js.map +1 -1
- package/dist/cli.d.ts +15 -1
- package/dist/cli.d.ts.map +1 -1
- package/dist/cli.js +138 -24
- package/dist/cli.js.map +1 -1
- package/dist/doctor.d.ts.map +1 -1
- package/dist/doctor.js +21 -4
- package/dist/doctor.js.map +1 -1
- package/dist/facts-file.d.ts +33 -9
- package/dist/facts-file.d.ts.map +1 -1
- package/dist/facts-file.js +117 -17
- package/dist/facts-file.js.map +1 -1
- package/dist/init.d.ts +11 -0
- package/dist/init.d.ts.map +1 -1
- package/dist/init.js +67 -3
- package/dist/init.js.map +1 -1
- package/dist/new-paper.d.ts +2 -2
- package/dist/new-paper.d.ts.map +1 -1
- package/dist/new-paper.js +17 -2
- package/dist/new-paper.js.map +1 -1
- package/dist/paper-settings.d.ts +68 -0
- package/dist/paper-settings.d.ts.map +1 -0
- package/dist/paper-settings.js +144 -0
- package/dist/paper-settings.js.map +1 -0
- package/dist/presets.d.ts +84 -0
- package/dist/presets.d.ts.map +1 -0
- package/dist/presets.js +186 -0
- package/dist/presets.js.map +1 -0
- package/dist/rules-config.d.ts +7 -0
- package/dist/rules-config.d.ts.map +1 -1
- package/dist/rules-config.js +11 -6
- package/dist/rules-config.js.map +1 -1
- package/dist/structure.d.ts.map +1 -1
- package/dist/structure.js +2 -1
- package/dist/structure.js.map +1 -1
- package/dist/tex-requirements.d.ts +63 -7
- package/dist/tex-requirements.d.ts.map +1 -1
- package/dist/tex-requirements.js +91 -24
- package/dist/tex-requirements.js.map +1 -1
- package/dist/venue-rules.d.ts +109 -0
- package/dist/venue-rules.d.ts.map +1 -0
- package/dist/venue-rules.js +387 -0
- package/dist/venue-rules.js.map +1 -0
- package/docs/configuration.md +67 -7
- package/docs/optional-rules.md +25 -24
- package/docs/rules.md +131 -2
- package/eslint-rules/papers.mjs +6 -2
- package/eslint-rules/pdf-last-page-balance.mjs +23 -2
- package/fixtures/build-e2e/acmart/paperlint.json +1 -0
- package/lib/paper-config.d.mts +7 -0
- package/lib/paper-config.mjs +31 -0
- package/package.json +1 -1
- package/skills/find-venue/SKILL.md +5 -1
- package/skills/find-venue/SKILL.md.spec.ts +4 -0
- package/skills/paper-pipeline/PIPELINE-MAP.md +14 -4
- package/skills/paper-pipeline/SKILL.md +4 -3
- package/skills/paper-pipeline/SKILL.md.spec.ts +3 -2
- package/skills/render-paper/extract-pdf-facts.harness.mjs +5 -5
- package/skills/render-paper/extract-pdf-facts.mjs +4 -4
- package/skills/study-accepted-papers/SKILL.md +2 -1
- package/skills/study-accepted-papers/SKILL.md.spec.ts +1 -0
- package/skills/submit-paper/SKILL.md +9 -1
- package/skills/submit-paper/SKILL.md.spec.ts +8 -0
- package/skills/submit-paper/references/venues/acm-sigconf.jsonc +86 -0
- package/skills/submit-paper/references/venues/agenticdev.jsonc +34 -102
- package/skills/submit-paper/references/venues/aisec.jsonc +21 -82
- package/skills/submit-paper/references/venues/realm.jsonc +45 -43
- package/skills/submit-paper/references/venues/tex-base.jsonc +1 -1
- package/skills/submit-paper/references/venues/venue-profile.schema.json +119 -29
- package/src/build-engine.ts +10 -2
- package/src/build.harness.mjs +23 -18
- package/src/build.ts +5 -4
- package/src/cli.harness.mjs +4 -2
- package/src/cli.ts +158 -29
- package/src/doctor.ts +28 -3
- package/src/facts-file.test.ts +54 -7
- package/src/facts-file.ts +145 -24
- package/src/init.ts +81 -3
- package/src/new-paper.harness.mjs +9 -6
- package/src/new-paper.test.ts +145 -0
- package/src/new-paper.ts +22 -2
- package/src/paper-settings-commands.test.ts +311 -0
- package/src/paper-settings.test.ts +220 -0
- package/src/paper-settings.ts +206 -0
- package/src/presets.test.ts +236 -0
- package/src/presets.ts +307 -0
- package/src/rules-config.ts +12 -7
- package/src/structure.harness.mjs +3 -3
- package/src/structure.ts +2 -1
- package/src/tex-requirements.harness.mjs +11 -17
- package/src/tex-requirements.ts +179 -29
- package/src/venue-rules.test.ts +545 -0
- package/src/venue-rules.ts +573 -0
- package/templates/paper/paperlint.json +4 -0
- package/fixtures/build-e2e/acmart/venue.json +0 -1
package/src/facts-file.ts
CHANGED
|
@@ -40,13 +40,16 @@ import { describeFailure, type PdfFacts, type PdfReader } from "./pdf-facts.ts";
|
|
|
40
40
|
import {
|
|
41
41
|
flatGeometry,
|
|
42
42
|
type FactsGeometryFields,
|
|
43
|
+
type FlatGeometry,
|
|
43
44
|
type Geometry,
|
|
44
45
|
} from "./domain/geometry.ts";
|
|
45
|
-
import { sha256Hex } from "./domain/sha256.ts";
|
|
46
|
+
import { parseSha256, sha256Hex, type Sha256 } from "./domain/sha256.ts";
|
|
46
47
|
import type { MeasureGeometry } from "./ports/measure-geometry.ts";
|
|
47
48
|
import type { AbsolutePath } from "./domain/paths.ts";
|
|
48
49
|
import type { Files } from "./ports/files.ts";
|
|
49
50
|
import { err, ok, type Result } from "./domain/result.ts";
|
|
51
|
+
import { paperPreset, paperPresetProblem } from "./presets.ts";
|
|
52
|
+
import { packageVenuesDir } from "../skills/paper-pipeline/scripts/consumer.mjs";
|
|
50
53
|
|
|
51
54
|
export const FACTS_SCHEMA = 2;
|
|
52
55
|
export const FACTS_DIR = "_build";
|
|
@@ -56,41 +59,42 @@ export const FACTS_FILE = "paper.facts.json";
|
|
|
56
59
|
export const factsPath = (paperDir: string): string =>
|
|
57
60
|
join(paperDir, FACTS_DIR, FACTS_FILE);
|
|
58
61
|
|
|
59
|
-
/** The `venue.json` a paper declares: where it is submitted, as which kind, and where its PDF is. */
|
|
60
|
-
export interface VenueDecl {
|
|
61
|
-
readonly venue: string;
|
|
62
|
-
readonly kind: string | null;
|
|
63
|
-
readonly pdf: string | null;
|
|
64
|
-
}
|
|
65
|
-
|
|
66
62
|
/**
|
|
67
63
|
* A path as the caller gave it. The `Files` adapter resolves a relative one against the process's
|
|
68
64
|
* cwd, which is what these callers have always meant; the brand is not a claim this code checked it.
|
|
69
65
|
*/
|
|
70
66
|
const at = (p: string): AbsolutePath => p as AbsolutePath;
|
|
71
67
|
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
/**
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
const venue = field("venue");
|
|
79
|
-
return venue ? { venue, kind: field("kind"), pdf: field("pdf") } : null;
|
|
68
|
+
/** What the facts need from a paper's `paperlint.json`: its venue's label, its kind, where its PDF is. */
|
|
69
|
+
export interface VenueDecl {
|
|
70
|
+
/** The venue preset's display label (`agenticdev`), or null when the paper extends none. */
|
|
71
|
+
readonly label: string | null;
|
|
72
|
+
readonly kind: string | null;
|
|
73
|
+
readonly pdf: string | null;
|
|
80
74
|
}
|
|
81
75
|
|
|
82
76
|
/**
|
|
83
|
-
*
|
|
84
|
-
*
|
|
77
|
+
* What a paper's `paperlint.json` declares, or null when it has none. THROWS with a one-line reason
|
|
78
|
+
* when the file does not parse, its `extends` does not resolve, or only the pre-2.1.0 `venue.json`
|
|
79
|
+
* is there: the build reports that as its `facts` failure rather than building against no venue.
|
|
85
80
|
*/
|
|
86
81
|
export function declaredVenue(
|
|
87
82
|
files: Files,
|
|
88
83
|
paperDir: string,
|
|
84
|
+
venuesDir: string = packageVenuesDir(),
|
|
89
85
|
): VenueDecl | null {
|
|
90
|
-
const
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
86
|
+
const p = paperPreset(paperDir, { files, venuesDir });
|
|
87
|
+
const problem = paperPresetProblem(paperDir, p);
|
|
88
|
+
if (problem !== null) throw new Error(problem);
|
|
89
|
+
if (p.kind === "resolved")
|
|
90
|
+
return {
|
|
91
|
+
label: p.preset.label,
|
|
92
|
+
kind: p.settings.kind,
|
|
93
|
+
pdf: p.settings.pdf,
|
|
94
|
+
};
|
|
95
|
+
return p.kind === "none" && p.settings
|
|
96
|
+
? { label: null, kind: p.settings.kind, pdf: p.settings.pdf }
|
|
97
|
+
: null;
|
|
94
98
|
}
|
|
95
99
|
|
|
96
100
|
// ── the document ────────────────────────────────────────────────────────────────────────
|
|
@@ -184,7 +188,7 @@ export interface MeasureOptions {
|
|
|
184
188
|
readonly kind?: string | null;
|
|
185
189
|
/** The page-geometry measurer (banal, as the composition root wired it). */
|
|
186
190
|
readonly measure: MeasureGeometry;
|
|
187
|
-
/** Where the PDF and `
|
|
191
|
+
/** Where the PDF and `paperlint.json` are read from. */
|
|
188
192
|
readonly files: Files;
|
|
189
193
|
}
|
|
190
194
|
|
|
@@ -215,7 +219,7 @@ export async function measurePaper(
|
|
|
215
219
|
const facts = factsDocument({
|
|
216
220
|
pdf: posix(relative(paperDir, pdf)),
|
|
217
221
|
sha: sha256Hex(bytes),
|
|
218
|
-
venue: o.venue ?? decl?.
|
|
222
|
+
venue: o.venue ?? decl?.label ?? null,
|
|
219
223
|
kind: o.kind ?? decl?.kind ?? null,
|
|
220
224
|
read: r.facts,
|
|
221
225
|
geometry,
|
|
@@ -239,3 +243,120 @@ export function writeFactsFile(
|
|
|
239
243
|
);
|
|
240
244
|
return out;
|
|
241
245
|
}
|
|
246
|
+
|
|
247
|
+
// ── reading ─────────────────────────────────────────────────────────────────────────────
|
|
248
|
+
|
|
249
|
+
/** What a rule judges, read back from the facts file and typed once. */
|
|
250
|
+
export interface ReadFacts {
|
|
251
|
+
/** As the file spells it: relative to the paper directory, or absolute. */
|
|
252
|
+
readonly pdf: string;
|
|
253
|
+
readonly sha: Sha256;
|
|
254
|
+
readonly fonts: readonly FontEntry[];
|
|
255
|
+
/** The geometry columns, or null when no measurer ran (`geometry_source` is null). */
|
|
256
|
+
readonly geometry: FlatGeometry | null;
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
/** Why a facts file cannot be judged. */
|
|
260
|
+
export type FactsProblem =
|
|
261
|
+
| { readonly kind: "schema"; readonly got: string }
|
|
262
|
+
| { readonly kind: "broken"; readonly why: string };
|
|
263
|
+
|
|
264
|
+
const isRecord = (v: unknown): v is Readonly<Record<string, unknown>> =>
|
|
265
|
+
typeof v === "object" && v !== null && !Array.isArray(v);
|
|
266
|
+
const isNum = (v: unknown): v is number =>
|
|
267
|
+
typeof v === "number" && Number.isFinite(v);
|
|
268
|
+
const numOrNull = (v: unknown): v is number | null => v === null || isNum(v);
|
|
269
|
+
|
|
270
|
+
function fontOf(v: unknown): FontEntry | null {
|
|
271
|
+
if (!isRecord(v)) return null;
|
|
272
|
+
const { name, type, embedded, program } = v;
|
|
273
|
+
return typeof name === "string" &&
|
|
274
|
+
typeof type === "string" &&
|
|
275
|
+
typeof embedded === "boolean" &&
|
|
276
|
+
typeof program === "string"
|
|
277
|
+
? { name, type, embedded, program }
|
|
278
|
+
: null;
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
const MAYBE_NUMBERS = [
|
|
282
|
+
"page_w_in",
|
|
283
|
+
"page_h_in",
|
|
284
|
+
"columns",
|
|
285
|
+
"body_pt",
|
|
286
|
+
"ref_pt",
|
|
287
|
+
] as const;
|
|
288
|
+
const NUMBERS = ["body_pages", "ref_pages", "appendix_pages"] as const;
|
|
289
|
+
|
|
290
|
+
/** The geometry columns when a measurer ran; the shape error otherwise. */
|
|
291
|
+
function geometryOf(
|
|
292
|
+
d: Readonly<Record<string, unknown>>,
|
|
293
|
+
): Result<FlatGeometry | null, string> {
|
|
294
|
+
if (d["geometry_source"] === null) return ok(null);
|
|
295
|
+
if (typeof d["geometry_source"] !== "string")
|
|
296
|
+
return err("`geometry_source` is neither a measurer's name nor null");
|
|
297
|
+
const bad =
|
|
298
|
+
MAYBE_NUMBERS.find((k) => !numOrNull(d[k])) ??
|
|
299
|
+
NUMBERS.find((k) => !isNum(d[k]));
|
|
300
|
+
if (bad) return err(`\`${bad}\` is not a number`);
|
|
301
|
+
const pagesByType = d["pages_by_type"];
|
|
302
|
+
if (!isRecord(pagesByType) || !Object.values(pagesByType).every(isNum))
|
|
303
|
+
return err("`pages_by_type` is not an object of page counts");
|
|
304
|
+
const n = (k: (typeof MAYBE_NUMBERS)[number]) => d[k] as number | null;
|
|
305
|
+
return ok({
|
|
306
|
+
page_w_in: n("page_w_in"),
|
|
307
|
+
page_h_in: n("page_h_in"),
|
|
308
|
+
columns: n("columns"),
|
|
309
|
+
body_pt: n("body_pt"),
|
|
310
|
+
ref_pt: n("ref_pt"),
|
|
311
|
+
body_pages: d["body_pages"] as number,
|
|
312
|
+
ref_pages: d["ref_pages"] as number,
|
|
313
|
+
appendix_pages: d["appendix_pages"] as number,
|
|
314
|
+
pages_by_type: pagesByType as Readonly<Record<string, number>>,
|
|
315
|
+
});
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
/** The PDF the facts describe: its path and its sha256. */
|
|
319
|
+
function artifactOf(
|
|
320
|
+
d: Readonly<Record<string, unknown>>,
|
|
321
|
+
): Result<{ pdf: string; sha: Sha256 }, string> {
|
|
322
|
+
const pdf = d["pdf"];
|
|
323
|
+
if (typeof pdf !== "string" || pdf === "") return err("no `pdf` path");
|
|
324
|
+
try {
|
|
325
|
+
return ok({ pdf, sha: parseSha256(String(d["pdf_sha256"])) });
|
|
326
|
+
} catch {
|
|
327
|
+
return err("no `pdf_sha256` of 64 hex digits");
|
|
328
|
+
}
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
/**
|
|
332
|
+
* The facts file's text → what a rule judges, or why it cannot be judged. The one reader of the
|
|
333
|
+
* file in `src/`, beside its one writer: a field renamed here and not there is a compile error in
|
|
334
|
+
* this module, not a rule that silently reads `undefined`. Pure.
|
|
335
|
+
*/
|
|
336
|
+
export function parseFactsText(text: string): Result<ReadFacts, FactsProblem> {
|
|
337
|
+
let d: unknown;
|
|
338
|
+
try {
|
|
339
|
+
d = JSON.parse(text);
|
|
340
|
+
} catch (e) {
|
|
341
|
+
return err({ kind: "broken", why: `not JSON (${(e as Error).message})` });
|
|
342
|
+
}
|
|
343
|
+
if (!isRecord(d)) return err({ kind: "broken", why: "not a JSON object" });
|
|
344
|
+
if (d["schema"] !== FACTS_SCHEMA)
|
|
345
|
+
return err({ kind: "schema", got: JSON.stringify(d["schema"] ?? null) });
|
|
346
|
+
const artifact = artifactOf(d);
|
|
347
|
+
if (!artifact.ok) return err({ kind: "broken", why: artifact.error });
|
|
348
|
+
const rawFonts = d["fonts"];
|
|
349
|
+
const fonts = Array.isArray(rawFonts) ? rawFonts.map(fontOf) : null;
|
|
350
|
+
if (fonts === null || fonts.some((f) => f === null))
|
|
351
|
+
return err({
|
|
352
|
+
kind: "broken",
|
|
353
|
+
why: "`fonts` is not a list of { name, type, embedded, program }",
|
|
354
|
+
});
|
|
355
|
+
const geometry = geometryOf(d);
|
|
356
|
+
if (!geometry.ok) return err({ kind: "broken", why: geometry.error });
|
|
357
|
+
return ok({
|
|
358
|
+
...artifact.value,
|
|
359
|
+
fonts: fonts as FontEntry[],
|
|
360
|
+
geometry: geometry.value,
|
|
361
|
+
});
|
|
362
|
+
}
|
package/src/init.ts
CHANGED
|
@@ -35,8 +35,14 @@
|
|
|
35
35
|
* an answer nobody gave. So the non-interactive path takes the stated default and SAYS which
|
|
36
36
|
* default it took and why nothing was asked, rather than pretending it asked.
|
|
37
37
|
*/
|
|
38
|
-
|
|
39
|
-
|
|
38
|
+
import {
|
|
39
|
+
existsSync,
|
|
40
|
+
mkdirSync,
|
|
41
|
+
readFileSync,
|
|
42
|
+
unlinkSync,
|
|
43
|
+
writeFileSync,
|
|
44
|
+
// eslint-disable-next-line boundaries/dependencies -- legacy I/O, moves behind a port in #76
|
|
45
|
+
} from "node:fs";
|
|
40
46
|
// eslint-disable-next-line boundaries/dependencies -- legacy I/O, moves behind a port in #76
|
|
41
47
|
import { spawnSync } from "node:child_process";
|
|
42
48
|
import { dirname, join, relative, resolve } from "node:path";
|
|
@@ -66,10 +72,13 @@ import {
|
|
|
66
72
|
CONFIG_KEY,
|
|
67
73
|
DEFAULT_PAPERS_ROOT,
|
|
68
74
|
LEGACY_CONFIG_KEY,
|
|
75
|
+
LEGACY_PAPER_SETTINGS_FILE,
|
|
69
76
|
PAPERS_DIR_FIELD,
|
|
77
|
+
PAPER_SETTINGS_FILE,
|
|
70
78
|
declaredSettings,
|
|
71
79
|
renamedFieldMessage,
|
|
72
80
|
} from "../lib/paper-config.mjs";
|
|
81
|
+
import { migrationOf } from "./paper-settings.ts";
|
|
73
82
|
|
|
74
83
|
/** How the papers directory was arrived at. Printed, because a guess must not read as a fact. */
|
|
75
84
|
export type PapersHow =
|
|
@@ -609,6 +618,73 @@ export interface InitOptions {
|
|
|
609
618
|
}
|
|
610
619
|
|
|
611
620
|
/** Reads one line from a real terminal. Kept out of `init` so the command stays testable. */
|
|
621
|
+
/**
|
|
622
|
+
* Move every paper's pre-2.1.0 `venue.json` to `paperlint.json`, the way the old package.json key
|
|
623
|
+
* is moved, and in the same step `"venue": "aisec"` becomes `"extends": "paperlint:aisec"`: written
|
|
624
|
+
* as `paperlint.json` when it is alone, removed when `paperlint.json` already says the same, and
|
|
625
|
+
* REFUSED — both files left as they are — when they differ, since there is no way to know which
|
|
626
|
+
* one the author means. `code` is 2 when anything was refused.
|
|
627
|
+
*/
|
|
628
|
+
export function migratePaperSettings(papersAbs: string): {
|
|
629
|
+
readonly code: number;
|
|
630
|
+
readonly lines: readonly string[];
|
|
631
|
+
} {
|
|
632
|
+
const lines: string[] = [];
|
|
633
|
+
let code = 0;
|
|
634
|
+
for (const dir of papersIn(papersAbs, [
|
|
635
|
+
...PAPER_MARKERS,
|
|
636
|
+
LEGACY_PAPER_SETTINGS_FILE,
|
|
637
|
+
])) {
|
|
638
|
+
const r = migrateOne(dir, (p) => relative(papersAbs, p));
|
|
639
|
+
if (r === null) continue;
|
|
640
|
+
lines.push(r.line);
|
|
641
|
+
if (r.refused) code = 2;
|
|
642
|
+
}
|
|
643
|
+
return {
|
|
644
|
+
code,
|
|
645
|
+
lines: lines.length ? ["", "paper settings", ...lines] : [],
|
|
646
|
+
};
|
|
647
|
+
}
|
|
648
|
+
|
|
649
|
+
/** One paper's move: done, and the line that says so — or null when there is nothing to move. */
|
|
650
|
+
function migrateOne(
|
|
651
|
+
dir: string,
|
|
652
|
+
shown: (p: string) => string,
|
|
653
|
+
): { readonly line: string; readonly refused: boolean } | null {
|
|
654
|
+
const [from, to] = [LEGACY_PAPER_SETTINGS_FILE, PAPER_SETTINGS_FILE].map(
|
|
655
|
+
(f) => join(dir, f),
|
|
656
|
+
) as [string, string];
|
|
657
|
+
const read = (p: string) => (existsSync(p) ? readFileSync(p) : null);
|
|
658
|
+
const plan = migrationOf(read(from), read(to));
|
|
659
|
+
switch (plan.kind) {
|
|
660
|
+
case "none":
|
|
661
|
+
return null;
|
|
662
|
+
case "move":
|
|
663
|
+
writeFileSync(to, plan.text);
|
|
664
|
+
unlinkSync(from);
|
|
665
|
+
return {
|
|
666
|
+
line: ` ✓ ${shown(from)} → ${shown(to)} ("venue" is now "extends": "paperlint:<name>"; renamed in paperlint 2.1.0)`,
|
|
667
|
+
refused: false,
|
|
668
|
+
};
|
|
669
|
+
case "drop-legacy":
|
|
670
|
+
unlinkSync(from);
|
|
671
|
+
return {
|
|
672
|
+
line: ` ✓ ${shown(from)} removed — ${shown(to)} already says the same`,
|
|
673
|
+
refused: false,
|
|
674
|
+
};
|
|
675
|
+
case "conflict":
|
|
676
|
+
return {
|
|
677
|
+
line: ` ✗ ${shown(from)} and ${shown(to)} both exist and differ — nothing was moved. Keep ${PAPER_SETTINGS_FILE}, copy what you need from ${LEGACY_PAPER_SETTINGS_FILE} into it (its "venue": "x" is "extends": "paperlint:x"), delete ${LEGACY_PAPER_SETTINGS_FILE}, then run init again`,
|
|
678
|
+
refused: true,
|
|
679
|
+
};
|
|
680
|
+
case "broken":
|
|
681
|
+
return {
|
|
682
|
+
line: ` ✗ ${shown(from)} was not moved: ${plan.why}`,
|
|
683
|
+
refused: true,
|
|
684
|
+
};
|
|
685
|
+
}
|
|
686
|
+
}
|
|
687
|
+
|
|
612
688
|
export async function askOnTerminal(question: string): Promise<string> {
|
|
613
689
|
// eslint-disable-next-line boundaries/dependencies -- legacy I/O, moves behind a port in #76
|
|
614
690
|
const { createInterface } = await import("node:readline/promises");
|
|
@@ -747,6 +823,8 @@ export async function init(
|
|
|
747
823
|
// guessed directory while lint and the hooks keep reading the declared one.
|
|
748
824
|
const papersDir =
|
|
749
825
|
decl.status === "kept" ? (decl.papers as string) : choice.papers;
|
|
826
|
+
const moved = migratePaperSettings(resolve(root, papersDir));
|
|
827
|
+
for (const line of moved.lines) (moved.code ? err : log)(line);
|
|
750
828
|
|
|
751
829
|
// ── 3. the skills, linked where Claude Code looks for them ─────────────────────────────
|
|
752
830
|
for (const line of reportSkillLinks(link(root), here)) log(line);
|
|
@@ -854,5 +932,5 @@ export async function init(
|
|
|
854
932
|
`doctor exits ${String(code)} — the install is NOT finished. The lines marked ✗ above say what is\n` +
|
|
855
933
|
`left; re-run \`npx paperlint doctor\` once you have done them.`,
|
|
856
934
|
);
|
|
857
|
-
return paperCode
|
|
935
|
+
return paperCode || moved.code || code;
|
|
858
936
|
}
|
|
@@ -98,11 +98,11 @@ try {
|
|
|
98
98
|
const papers = join(work, "papers");
|
|
99
99
|
const r = newPaper(papers, "demo", "tex");
|
|
100
100
|
check(
|
|
101
|
-
"a fresh folder gets the scorecard
|
|
101
|
+
"a fresh folder gets the scorecard, a .tex source and paperlint.json, all from the package",
|
|
102
102
|
r.ok &&
|
|
103
103
|
r.fresh &&
|
|
104
104
|
r.files.map((f) => `${f.file}:${f.status}:${f.from}`).join(" ") ===
|
|
105
|
-
"PIPELINE-STATUS.md:created:package paper.tex:created:package",
|
|
105
|
+
"PIPELINE-STATUS.md:created:package paper.tex:created:package paperlint.json:created:package",
|
|
106
106
|
);
|
|
107
107
|
const status = read(papers, "demo", STATUS_FILE);
|
|
108
108
|
const fm = front(status);
|
|
@@ -122,8 +122,10 @@ try {
|
|
|
122
122
|
);
|
|
123
123
|
const l = await lint(join(papers, "demo"));
|
|
124
124
|
check(
|
|
125
|
-
"🔴 `paperlint lint` passes the scaffold
|
|
126
|
-
l.code === 0 &&
|
|
125
|
+
"🔴 `paperlint lint` passes the scaffold — exit 0, not a missing-file error — with the one warning that no venue is chosen yet",
|
|
126
|
+
l.code === 0 &&
|
|
127
|
+
/names no venue preset yet/.test(l.text) &&
|
|
128
|
+
/1 problem \(0 errors, 1 warning\)/.test(l.text),
|
|
127
129
|
);
|
|
128
130
|
|
|
129
131
|
// The format contract of the skills' checker: four sections, parsed.
|
|
@@ -171,9 +173,10 @@ try {
|
|
|
171
173
|
);
|
|
172
174
|
check(
|
|
173
175
|
"wantedFiles asks for a source only when neither format is there",
|
|
174
|
-
wantedFiles(join(papers, "old"), "tex").join() ===
|
|
176
|
+
wantedFiles(join(papers, "old"), "tex").join() ===
|
|
177
|
+
`${STATUS_FILE},paperlint.json` &&
|
|
175
178
|
wantedFiles(join(papers, "nowhere"), "md").join() ===
|
|
176
|
-
`${STATUS_FILE},paper.md`,
|
|
179
|
+
`${STATUS_FILE},paper.md,paperlint.json`,
|
|
177
180
|
);
|
|
178
181
|
writeFileSync(join(papers, "afile"), "x");
|
|
179
182
|
check(
|
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `paperlint new` writes `<paper>/paperlint.json` — from the package's template, or from the
|
|
3
|
+
* project's `<papers>/.template/` when it has one — with no venue chosen yet, which lint then names
|
|
4
|
+
* in one warning instead of staying silent.
|
|
5
|
+
*/
|
|
6
|
+
import {
|
|
7
|
+
mkdirSync,
|
|
8
|
+
mkdtempSync,
|
|
9
|
+
readFileSync,
|
|
10
|
+
realpathSync,
|
|
11
|
+
rmSync,
|
|
12
|
+
writeFileSync,
|
|
13
|
+
} from "node:fs";
|
|
14
|
+
import { tmpdir } from "node:os";
|
|
15
|
+
import { join } from "node:path";
|
|
16
|
+
import { afterEach, describe, expect, it } from "vitest";
|
|
17
|
+
import { newPaper, OVERRIDE_DIR } from "./new-paper.ts";
|
|
18
|
+
import { parsePaperSettings } from "./paper-settings.ts";
|
|
19
|
+
import { run } from "./cli.ts";
|
|
20
|
+
|
|
21
|
+
const dirs: string[] = [];
|
|
22
|
+
afterEach(() => {
|
|
23
|
+
for (const d of dirs.splice(0)) rmSync(d, { recursive: true, force: true });
|
|
24
|
+
});
|
|
25
|
+
const tmp = () => {
|
|
26
|
+
const d = realpathSync(mkdtempSync(join(tmpdir(), "paperlint-new-")));
|
|
27
|
+
dirs.push(d);
|
|
28
|
+
return d;
|
|
29
|
+
};
|
|
30
|
+
const settingsOf = (dir: string) =>
|
|
31
|
+
JSON.parse(readFileSync(join(dir, "paperlint.json"), "utf8")) as Record<
|
|
32
|
+
string,
|
|
33
|
+
unknown
|
|
34
|
+
>;
|
|
35
|
+
|
|
36
|
+
describe("paperlint new — paperlint.json", () => {
|
|
37
|
+
it("is always written, from the package template: no venue chosen yet, and valid", () => {
|
|
38
|
+
const papers = join(tmp(), "papers");
|
|
39
|
+
const r = newPaper(papers, "demo", "tex");
|
|
40
|
+
expect(r.ok && r.files.find((f) => f.file === "paperlint.json")).toEqual({
|
|
41
|
+
file: "paperlint.json",
|
|
42
|
+
status: "created",
|
|
43
|
+
from: "package",
|
|
44
|
+
});
|
|
45
|
+
const s = settingsOf(join(papers, "demo"));
|
|
46
|
+
expect(s["extends"]).toBeNull();
|
|
47
|
+
expect(String(s["$comment"])).toMatch(/paperlint:agenticdev/);
|
|
48
|
+
expect(parsePaperSettings(s)).toEqual({
|
|
49
|
+
ok: true,
|
|
50
|
+
value: { extends: null, kind: null, pdf: null, rules: null },
|
|
51
|
+
});
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
it("comes from the project's <papers>/.template/ when it has one", () => {
|
|
55
|
+
const papers = join(tmp(), "papers");
|
|
56
|
+
mkdirSync(join(papers, OVERRIDE_DIR), { recursive: true });
|
|
57
|
+
writeFileSync(
|
|
58
|
+
join(papers, OVERRIDE_DIR, "paperlint.json"),
|
|
59
|
+
'{ "extends": "paperlint:aisec", "kind": "research" }\n',
|
|
60
|
+
);
|
|
61
|
+
const r = newPaper(papers, "house", "tex");
|
|
62
|
+
expect(r.ok && r.files.find((f) => f.file === "paperlint.json")?.from).toBe(
|
|
63
|
+
"project",
|
|
64
|
+
);
|
|
65
|
+
expect(settingsOf(join(papers, "house"))).toEqual({
|
|
66
|
+
extends: "paperlint:aisec",
|
|
67
|
+
kind: "research",
|
|
68
|
+
});
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
it("is never overwritten", () => {
|
|
72
|
+
const papers = join(tmp(), "papers");
|
|
73
|
+
mkdirSync(join(papers, "p"), { recursive: true });
|
|
74
|
+
writeFileSync(
|
|
75
|
+
join(papers, "p", "paperlint.json"),
|
|
76
|
+
'{"extends":"paperlint:aisec"}',
|
|
77
|
+
);
|
|
78
|
+
const r = newPaper(papers, "p", "tex");
|
|
79
|
+
expect(
|
|
80
|
+
r.ok && r.files.find((f) => f.file === "paperlint.json")?.status,
|
|
81
|
+
).toBe("kept");
|
|
82
|
+
expect(settingsOf(join(papers, "p"))).toEqual({
|
|
83
|
+
extends: "paperlint:aisec",
|
|
84
|
+
});
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
it("is not added beside a pre-2.1.0 venue.json — `paperlint init` moves that one", () => {
|
|
88
|
+
const papers = join(tmp(), "papers");
|
|
89
|
+
mkdirSync(join(papers, "old"), { recursive: true });
|
|
90
|
+
writeFileSync(join(papers, "old", "venue.json"), '{"venue":"aisec"}');
|
|
91
|
+
const r = newPaper(papers, "old", "tex");
|
|
92
|
+
expect(r.ok && r.files.map((f) => f.file)).not.toContain("paperlint.json");
|
|
93
|
+
});
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
describe("paperlint lint — a paper with no venue preset chosen", () => {
|
|
97
|
+
async function lintNew(extendsValue: string | null) {
|
|
98
|
+
const root = tmp();
|
|
99
|
+
writeFileSync(
|
|
100
|
+
join(root, "package.json"),
|
|
101
|
+
JSON.stringify({
|
|
102
|
+
name: "c",
|
|
103
|
+
private: true,
|
|
104
|
+
paperlint: { papersDir: "papers" },
|
|
105
|
+
}),
|
|
106
|
+
);
|
|
107
|
+
newPaper(join(root, "papers"), "demo", "tex");
|
|
108
|
+
if (extendsValue !== null)
|
|
109
|
+
writeFileSync(
|
|
110
|
+
join(root, "papers", "demo", "paperlint.json"),
|
|
111
|
+
JSON.stringify({ extends: extendsValue, kind: "short" }),
|
|
112
|
+
);
|
|
113
|
+
const out: string[] = [];
|
|
114
|
+
const code = await run(["lint", "--json"], {
|
|
115
|
+
cwd: root,
|
|
116
|
+
log: (s: string) => out.push(s),
|
|
117
|
+
err: () => {},
|
|
118
|
+
});
|
|
119
|
+
const messages = (
|
|
120
|
+
JSON.parse(out.join("\n")) as {
|
|
121
|
+
messages: { ruleId: string; severity: number; message: string }[];
|
|
122
|
+
}[]
|
|
123
|
+
).flatMap((r) => r.messages);
|
|
124
|
+
return { code, messages };
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
it("gets exactly one warning naming the file to set, and exits 0", async () => {
|
|
128
|
+
const { code, messages } = await lintNew(null);
|
|
129
|
+
expect(code).toBe(0);
|
|
130
|
+
expect(messages).toHaveLength(1);
|
|
131
|
+
expect(messages[0]?.ruleId).toBe("pdf/measured");
|
|
132
|
+
expect(messages[0]?.severity).toBe(1);
|
|
133
|
+
expect(messages[0]?.message).toMatch(/names no venue preset yet/);
|
|
134
|
+
expect(messages[0]?.message).toMatch(
|
|
135
|
+
/set "extends" in .*papers\/demo\/paperlint\.json/,
|
|
136
|
+
);
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
it("a paper with a real extends does not get it", async () => {
|
|
140
|
+
const { messages } = await lintNew("paperlint:agenticdev");
|
|
141
|
+
expect(messages.map((m) => m.message).join("\n")).not.toMatch(
|
|
142
|
+
/names no venue preset yet/,
|
|
143
|
+
);
|
|
144
|
+
});
|
|
145
|
+
});
|
package/src/new-paper.ts
CHANGED
|
@@ -25,6 +25,10 @@ import {
|
|
|
25
25
|
/* eslint-enable boundaries/dependencies */
|
|
26
26
|
import { join } from "node:path";
|
|
27
27
|
import { fileURLToPath } from "node:url";
|
|
28
|
+
import {
|
|
29
|
+
LEGACY_PAPER_SETTINGS_FILE,
|
|
30
|
+
PAPER_SETTINGS_FILE,
|
|
31
|
+
} from "../lib/paper-config.mjs";
|
|
28
32
|
|
|
29
33
|
export type PaperFormat = "tex" | "md";
|
|
30
34
|
export const FORMATS: readonly PaperFormat[] = ["tex", "md"];
|
|
@@ -83,16 +87,32 @@ export type NewPaperResult =
|
|
|
83
87
|
| { readonly ok: false; readonly reason: string };
|
|
84
88
|
|
|
85
89
|
/**
|
|
86
|
-
* The files a paper folder must end up with: the scorecard,
|
|
87
|
-
*
|
|
90
|
+
* The files a paper folder must end up with: the scorecard, a source in `format` — unless it
|
|
91
|
+
* already has a source in EITHER format, in which case that one stands — and `paperlint.json`.
|
|
88
92
|
*/
|
|
89
93
|
export function wantedFiles(dir: string, format: PaperFormat): string[] {
|
|
94
|
+
return [...scorecardAndSource(dir, format), ...settingsFile(dir)];
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
function scorecardAndSource(dir: string, format: PaperFormat): string[] {
|
|
90
98
|
const hasSource = Object.values(SOURCE_FILE).some((f) =>
|
|
91
99
|
existsSync(join(dir, f)),
|
|
92
100
|
);
|
|
93
101
|
return hasSource ? [STATUS_FILE] : [STATUS_FILE, SOURCE_FILE[format]];
|
|
94
102
|
}
|
|
95
103
|
|
|
104
|
+
/**
|
|
105
|
+
* `paperlint.json`, so that nobody has to know the file exists: its template says what to put in
|
|
106
|
+
* it, and until a venue preset is named lint says so in one warning. Not beside a pre-2.1.0
|
|
107
|
+
* `venue.json`: `paperlint init` moves that file, and a second, different one would make the move
|
|
108
|
+
* refuse.
|
|
109
|
+
*/
|
|
110
|
+
function settingsFile(dir: string): string[] {
|
|
111
|
+
return existsSync(join(dir, LEGACY_PAPER_SETTINGS_FILE))
|
|
112
|
+
? []
|
|
113
|
+
: [PAPER_SETTINGS_FILE];
|
|
114
|
+
}
|
|
115
|
+
|
|
96
116
|
// Documented in README.md#starting-a-paper — update it when this changes.
|
|
97
117
|
export function newPaper(
|
|
98
118
|
papersRoot: string,
|