paperlint 13.6.0 → 14.0.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.
@@ -9,8 +9,10 @@ import { join } from "node:path";
9
9
  import { test } from "vitest";
10
10
  import { z } from "zod";
11
11
  import {
12
+ APPROVAL_PRACTICE,
12
13
  DONE_PRACTICE,
13
14
  Exported,
15
+ approvalCard,
14
16
  ITEMS,
15
17
  PRACTICE,
16
18
  QUESTIONS,
@@ -62,6 +64,16 @@ interface DocObject {
62
64
  const isObject = (v: Doc | undefined): v is DocObject =>
63
65
  typeof v === "object" && v !== null && !Array.isArray(v);
64
66
 
67
+ /** Every optional field of a card, for an item and a practice item alike. */
68
+ const CARD = {
69
+ title: "T",
70
+ code: "c = 1",
71
+ context: "ctx",
72
+ file: "`src/a.ts`",
73
+ rules: [{ linter: "eslint", id: "no-console" }],
74
+ unit: { id: "L0", text: "One line.", mark: "One", part: 1, of: 1 },
75
+ readings: { a: "on", b: "off" },
76
+ };
65
77
  /** A config with EVERY optional field present, so every schema path has a value to spoil. */
66
78
  const FULL: DocObject = (() => {
67
79
  const doc = Doc.parse(
@@ -80,18 +92,8 @@ const FULL: DocObject = (() => {
80
92
  labeller_knows: ["python", "go", "rust"],
81
93
  familiarity: true,
82
94
  familiarity_question: "How well do you know {group}?",
83
- items: ITEMS.map((i, n) =>
84
- n === 0
85
- ? {
86
- ...i,
87
- title: "T",
88
- code: "c = 1",
89
- context: "ctx",
90
- rules: [{ linter: "eslint", id: "no-console" }],
91
- unit: { id: "L0", text: "One line.", part: 1, of: 1 },
92
- }
93
- : i,
94
- ),
95
+ practice: PRACTICE.map((p, n) => (n === 0 ? { ...p, ...CARD } : p)),
96
+ items: ITEMS.map((i, n) => (n === 0 ? { ...i, ...CARD } : i)),
95
97
  }),
96
98
  );
97
99
  assert.ok(isObject(doc));
@@ -235,9 +237,17 @@ function expectRefused(leaf: Leaf): void {
235
237
  assert.doesNotMatch(r.out, /Traceback/, `${what}\n${r.out}`);
236
238
  });
237
239
  if (!leaf.node.required && !leaf.member) {
240
+ const absent = spoil(leaf.path, undefined);
241
+ // familiarity_question asks nothing unless familiarity is on: the two leave together.
238
242
  const r = tryBuild(
239
243
  `schema-${leaf.path.replace(/\W/g, "_")}-absent`,
240
- spoil(leaf.path, undefined),
244
+ leaf.path === "familiarity"
245
+ ? Object.fromEntries(
246
+ Object.entries(absent).filter(
247
+ ([k]) => k !== "familiarity_question",
248
+ ),
249
+ )
250
+ : absent,
241
251
  );
242
252
  assert.equal(r.status, 0, `${leaf.path} is optional: ${r.out}`);
243
253
  }
@@ -245,6 +255,7 @@ function expectRefused(leaf: Leaf): void {
245
255
 
246
256
  const DOCUMENTED_FIELDS = [
247
257
  "study",
258
+ "kind",
248
259
  "title",
249
260
  "intro",
250
261
  "seed",
@@ -1257,19 +1268,42 @@ test("🔴 familiarity off with a familiarity_question is a contradiction the bu
1257
1268
  assert.notEqual(r.status, 0);
1258
1269
  assert.match(
1259
1270
  r.out,
1260
- /REFUSED: familiarity_question: .*`familiarity` is false/,
1271
+ /REFUSED: familiarity_question: .*`familiarity` is not true/,
1261
1272
  );
1262
1273
  });
1263
1274
 
1264
- test("familiarity left out: every group is still asked (rule 5), and a missing one is reported", () => {
1265
- const dir = setup("familiarity-default", approval());
1266
- const { key } = build(dir);
1267
- assert.deepEqual(key.allowed.familiarity, ["well", "somewhat", "not_really"]);
1275
+ test('🔴 familiarity left out asks no group, approval page or not; a page asks it only with `"familiarity": true`', () => {
1276
+ // Guards: the default is off for every page (#373), so an approval page built the old way asks nothing.
1277
+ for (const [name, cfg] of [
1278
+ ["fam-default-approval", approval()],
1279
+ ["fam-default-gold", config({ familiarity: undefined })],
1280
+ ] as const) {
1281
+ assert.deepEqual(build(setup(name, cfg)).key.allowed.familiarity, []);
1282
+ }
1283
+ const asks = setup("fam-on", config());
1284
+ assert.deepEqual(build(asks).key.allowed.familiarity, [
1285
+ "well",
1286
+ "somewhat",
1287
+ "not_really",
1288
+ ]);
1268
1289
  writeFileSync(
1269
- join(dir, "dump.json"),
1290
+ join(asks, "dump.json"),
1270
1291
  JSON.stringify({ answers: {}, groups: {} }),
1271
1292
  );
1272
- assert.match(exportAt(dir).out, /group python: no familiarity answer/);
1293
+ assert.match(exportAt(asks).out, /group python: no familiarity answer/);
1294
+ const r = run([
1295
+ "build",
1296
+ join(
1297
+ setup(
1298
+ "fam-question-alone",
1299
+ config({ familiarity: undefined, familiarity_question: "How well?" }),
1300
+ ),
1301
+ "items.json",
1302
+ ),
1303
+ join(WORK, "fam-question-alone-out"),
1304
+ ]);
1305
+ assert.equal(r.status, 1, r.out);
1306
+ assert.match(r.out, /REFUSED: familiarity_question: .*is not true/);
1273
1307
  });
1274
1308
 
1275
1309
  /** Items 0 and 3 (both Rust) as the two requirements of one line. */
@@ -1280,7 +1314,13 @@ const withUnit = (
1280
1314
  config({
1281
1315
  items: ITEMS.map((item, n) => {
1282
1316
  const u = parts.find(([at]) => at === n);
1283
- return u === undefined ? item : { ...item, unit: u[1] };
1317
+ // the first word of the line is marked unless the case says otherwise
1318
+ return u === undefined
1319
+ ? item
1320
+ : {
1321
+ ...item,
1322
+ unit: { mark: String(u[1]["text"]).split(" ")[0], ...u[1] },
1323
+ };
1284
1324
  }),
1285
1325
  });
1286
1326
 
@@ -1291,9 +1331,11 @@ const UNIT_REFUSALS: readonly (readonly [
1291
1331
  string,
1292
1332
  ])[] = [
1293
1333
  [
1294
- "unit-missing-part",
1295
- withUnit([[0, { id: "L1", text: LINE, part: 1, of: 2 }]]),
1296
- `items[0].unit: part 2 of 2 of line 'L1' is not among the items`,
1334
+ "unit-mark-not-in-line",
1335
+ withUnit([
1336
+ [0, { id: "L1", text: LINE, mark: "JavaScript only", part: 1, of: 2 }],
1337
+ ]),
1338
+ "items[0].unit.mark: 'JavaScript only' is not in the line 'TypeScript only (no JavaScript); top-level await is allowed.' — mark the requirement with the line's own words",
1297
1339
  ],
1298
1340
  [
1299
1341
  "unit-part-twice",
@@ -1332,14 +1374,6 @@ const UNIT_REFUSALS: readonly (readonly [
1332
1374
  ]),
1333
1375
  "items[3].unit.text: 'Another line.', but items[0] of line 'L1' says",
1334
1376
  ],
1335
- [
1336
- "unit-halves-of-two-occurrences",
1337
- withUnit([
1338
- [0, { id: "L1", text: LINE, part: 1, of: 2 }],
1339
- [3, { id: "L2", text: LINE, part: 2, of: 2 }],
1340
- ]),
1341
- "items[0].unit: part 2 of 2 of line 'L1' is not among the items",
1342
- ],
1343
1377
  [
1344
1378
  "unit-leaks-source",
1345
1379
  withUnit([[0, { id: "L1", text: "as in case-1003", part: 1, of: 1 }]]),
@@ -1347,6 +1381,32 @@ const UNIT_REFUSALS: readonly (readonly [
1347
1381
  ],
1348
1382
  ];
1349
1383
 
1384
+ test("🔴 a drawn sample holds one requirement of a line that has two: it builds, the drawn part marked", () => {
1385
+ // Guards: a unit with `of` above the parts present is a drawn part, not a lost one (#373, 5 of 9 cards).
1386
+ const { page } = build(
1387
+ setup(
1388
+ "unit-drawn-part",
1389
+ withUnit([
1390
+ [
1391
+ 0,
1392
+ {
1393
+ id: "L1",
1394
+ text: LINE,
1395
+ mark: "top-level await is allowed",
1396
+ part: 2,
1397
+ of: 2,
1398
+ },
1399
+ ],
1400
+ ]),
1401
+ ),
1402
+ );
1403
+ assert.ok(
1404
+ page.includes(
1405
+ `"unit": {"text": "${LINE}", "mark": "top-level await is allowed", "part": 2, "of": 2}`,
1406
+ ),
1407
+ );
1408
+ });
1409
+
1350
1410
  test("🔴 a line with several requirements: build refuses a unit whose parts do not add up, naming the item", () => {
1351
1411
  for (const [name, cfg, message] of UNIT_REFUSALS) {
1352
1412
  const dir = setup(name, cfg);
@@ -1375,7 +1435,12 @@ test("a line whose every requirement is an item builds, and each item carries it
1375
1435
  items: z.array(
1376
1436
  z.object({
1377
1437
  unit: z
1378
- .object({ text: z.string(), part: z.number(), of: z.number() })
1438
+ .object({
1439
+ text: z.string(),
1440
+ mark: z.string(),
1441
+ part: z.number(),
1442
+ of: z.number(),
1443
+ })
1379
1444
  .optional(),
1380
1445
  }),
1381
1446
  ),
@@ -1385,8 +1450,8 @@ test("a line whose every requirement is an item builds, and each item carries it
1385
1450
  .sort((a, b) => a.part - b.part);
1386
1451
  // what the page carries: the part and the line, never the line's private id
1387
1452
  assert.deepEqual(units, [
1388
- { text: LINE, part: 1, of: 2 },
1389
- { text: LINE, part: 2, of: 2 },
1453
+ { text: LINE, mark: "TypeScript", part: 1, of: 2 },
1454
+ { text: LINE, mark: "TypeScript", part: 2, of: 2 },
1390
1455
  ]);
1391
1456
  });
1392
1457
 
@@ -1403,3 +1468,95 @@ test("🔴 the same line in two places is two lines: four items, two ids, it bui
1403
1468
  const { page } = build(dir);
1404
1469
  assert.ok(!page.includes("a.md:3"), "the line's id is private");
1405
1470
  });
1471
+
1472
+ /** A witness card: an approval card's line and rule, plus the two readings and the file. */
1473
+ const witnessCard = <T extends { readonly body: string }>(c: T, n: number) => ({
1474
+ ...approvalCard(c, n),
1475
+ readings: { a: "on", b: "off" },
1476
+ file: "`src/a.ts`: B flags it",
1477
+ });
1478
+ const witnessPage = (
1479
+ items: readonly Record<string, unknown>[] = ITEMS.map(witnessCard),
1480
+ ) =>
1481
+ approval({
1482
+ kind: "witness",
1483
+ practice: APPROVAL_PRACTICE.map(witnessCard),
1484
+ items,
1485
+ });
1486
+
1487
+ /** Pages that miss their kind or a field it needs, each with its refusal. */
1488
+ function kindRefusals(): readonly (readonly [
1489
+ string,
1490
+ Record<string, unknown>,
1491
+ string,
1492
+ ])[] {
1493
+ const plain = { items: ITEMS, practice: APPROVAL_PRACTICE };
1494
+ const rulesOnly = {
1495
+ items: ITEMS.map((c, n) => ({ ...c, rules: approvalCard(c, n).rules })),
1496
+ practice: APPROVAL_PRACTICE.map((c, n) => ({
1497
+ ...c,
1498
+ rules: approvalCard(c, n).rules,
1499
+ })),
1500
+ };
1501
+ return [
1502
+ // the shape pages were built in until now: no kind, the line and the rule in free text
1503
+ [
1504
+ "kind-missing",
1505
+ approval({ ...plain, kind: undefined }),
1506
+ "kind: is required and missing — one of approval, witness, labels",
1507
+ ],
1508
+ [
1509
+ "kind-unknown",
1510
+ approval({ kind: "audit" }),
1511
+ "kind: 'audit' must be one of approval, witness, labels",
1512
+ ],
1513
+ [
1514
+ "approval-no-rules",
1515
+ approval(plain),
1516
+ "practice[0].rules: is required and missing — an approval card shows the rule read for it, with the linter's own description",
1517
+ ],
1518
+ [
1519
+ "approval-no-unit",
1520
+ approval(rulesOnly),
1521
+ "practice[0].unit: is required and missing — an approval card shows the line its requirement was drawn from, the requirement marked",
1522
+ ],
1523
+ [
1524
+ "witness-no-readings",
1525
+ approval({ kind: "witness" }),
1526
+ "practice[0].readings: is required and missing — a witness card shows the two readings, one row each",
1527
+ ],
1528
+ [
1529
+ "witness-no-file",
1530
+ witnessPage(
1531
+ ITEMS.map((c, n) => ({ ...witnessCard(c, n), file: undefined })),
1532
+ ),
1533
+ "items[0].file: is required and missing — a witness card shows the file the readings disagree on, on its own row",
1534
+ ],
1535
+ ];
1536
+ }
1537
+
1538
+ test("🔴 a page without its kind, or a card without its kind's fields, fails the schema naming the field", () => {
1539
+ const refused = kindRefusals();
1540
+ // Guards: the whole refusal, the field and why the page's kind needs it.
1541
+ assert.deepEqual(
1542
+ refused.map(([name, cfg]) => {
1543
+ const dir = setup(name, cfg);
1544
+ return run(["build", join(dir, "items.json"), join(dir, "out")]);
1545
+ }),
1546
+ refused.map(([, , message]) => ({
1547
+ status: 1,
1548
+ out: `REFUSED: ${message}\n`,
1549
+ })),
1550
+ );
1551
+ });
1552
+
1553
+ test("a page of each kind with its fields builds; a practice card shows its rule like a real one", () => {
1554
+ const witness = build(setup("witness-ok", witnessPage()));
1555
+ assert.ok(witness.page.includes('"readings": {"a": "on", "b": "off"}'));
1556
+ assert.ok(
1557
+ build(setup("approval-ok", approval())).page.includes(
1558
+ '"rules": [{"linter": "eslint", "id": "no-console", "description": "Disallow the use of `console`"}]',
1559
+ ),
1560
+ );
1561
+ assert.equal(build(setup("labels-ok", config())).key.seed, 7);
1562
+ });