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.
@@ -1,4 +1,4 @@
1
- import { type RuleRef } from "./rule-descriptions.ts";
1
+ import { type Described as RuleText, type RuleRef } from "./rule-descriptions.ts";
2
2
  export declare class Refusal extends Error {
3
3
  readonly exitCode: number;
4
4
  constructor(exitCode: number, message: string);
@@ -13,8 +13,8 @@ type Json = null | boolean | number | string | readonly Json[] | {
13
13
  type JsonObject = {
14
14
  readonly [key: string]: Json;
15
15
  };
16
- declare const PAGE_FIELDS: readonly ["group", "title", "body", "code", "context"];
17
- type SchemaNode = {
16
+ declare const PAGE_FIELDS: readonly ["group", "title", "body", "code", "context", "file"];
17
+ type SchemaNode = ({
18
18
  kind: "string";
19
19
  required: boolean;
20
20
  pattern?: string;
@@ -40,7 +40,16 @@ type SchemaNode = {
40
40
  required: boolean;
41
41
  closed: boolean;
42
42
  fields: Readonly<Record<string, SchemaNode>>;
43
+ }) & {
44
+ why?: string;
43
45
  };
46
+ /**
47
+ * THE PAGE KINDS, and the card fields each requires on every item and practice card (#373). A fix to
48
+ * a card is a field; a kind that needs it makes it required, so a page built without it fails the
49
+ * schema with the field named instead of shipping the old card.
50
+ */
51
+ type Kind = "approval" | "witness" | "labels";
52
+ /** The declared schema (`schema` prints it): every field, those a kind requires left optional. */
44
53
  export declare const CONFIG_SCHEMA: SchemaNode;
45
54
  interface Option {
46
55
  readonly value: string;
@@ -58,28 +67,33 @@ interface Question {
58
67
  interface Unit {
59
68
  readonly id: string;
60
69
  readonly text: string;
70
+ readonly mark: string;
61
71
  readonly part: number;
62
72
  readonly of: number;
63
73
  }
64
- type Item = {
74
+ /** What a card shows, on an item and on a practice item alike. */
75
+ type Card = {
65
76
  readonly [F in (typeof PAGE_FIELDS)[number]]?: string;
66
77
  } & {
67
- readonly source_id: string;
68
- readonly group: string;
69
78
  readonly body: string;
70
79
  readonly rules?: readonly RuleRef[];
71
80
  readonly unit?: Unit;
81
+ readonly readings?: {
82
+ readonly a: string;
83
+ readonly b: string;
84
+ };
72
85
  };
73
- interface Practice {
74
- readonly title?: string;
75
- readonly body: string;
76
- readonly code?: string;
77
- readonly context?: string;
86
+ type Item = Card & {
87
+ readonly source_id: string;
88
+ readonly group: string;
89
+ };
90
+ type Practice = Card & {
78
91
  readonly expected: Readonly<Record<string, string>>;
79
92
  readonly why: string;
80
- }
93
+ };
81
94
  interface Config {
82
95
  readonly study: string;
96
+ readonly kind: Kind;
83
97
  readonly title: string;
84
98
  readonly intro?: string;
85
99
  readonly seed: number;
@@ -97,8 +111,8 @@ interface KeyEntry {
97
111
  readonly group: string;
98
112
  readonly on_page: boolean;
99
113
  }
100
- /** Rule → the linter's description, for every rule an item on the page names. */
101
- type Described = ReadonlyMap<string, string>;
114
+ /** Rule → the linter's description (and a ruff rule's family), for every rule a card names. */
115
+ type Described = ReadonlyMap<string, RuleText>;
102
116
  /** Only what the labeller can answer goes on the page, and only that gets an id, in id order. */
103
117
  declare function itemsOnPage(cfg: Config, keyPath: string): {
104
118
  items: Item[];
@@ -123,8 +137,21 @@ declare function payloadOf(cfg: Config, page: ReturnType<typeof itemsOnPage>, de
123
137
  questions: readonly Question[];
124
138
  practice: {
125
139
  round: number;
126
- items: readonly (Practice & {
127
- readonly id: string;
140
+ items: ({
141
+ rules?: never;
142
+ id: string;
143
+ expected: Readonly<Record<string, string>>;
144
+ why: string;
145
+ } | {
146
+ rules: {
147
+ description?: string;
148
+ family?: string;
149
+ linter: "eslint" | "ruff";
150
+ id: string;
151
+ }[];
152
+ id: string;
153
+ expected: Readonly<Record<string, string>>;
154
+ why: string;
128
155
  })[];
129
156
  };
130
157
  items: ({
@@ -132,9 +159,10 @@ declare function payloadOf(cfg: Config, page: ReturnType<typeof itemsOnPage>, de
132
159
  id: string;
133
160
  } | {
134
161
  rules: {
162
+ description?: string;
163
+ family?: string;
135
164
  linter: "eslint" | "ruff";
136
165
  id: string;
137
- description: string;
138
166
  }[];
139
167
  id: string;
140
168
  })[];
@@ -162,8 +190,21 @@ export declare function prepareBuild(configPath: string, outDir: string): {
162
190
  questions: readonly Question[];
163
191
  practice: {
164
192
  round: number;
165
- items: readonly (Practice & {
166
- readonly id: string;
193
+ items: ({
194
+ rules?: never;
195
+ id: string;
196
+ expected: Readonly<Record<string, string>>;
197
+ why: string;
198
+ } | {
199
+ rules: {
200
+ description?: string;
201
+ family?: string;
202
+ linter: "eslint" | "ruff";
203
+ id: string;
204
+ }[];
205
+ id: string;
206
+ expected: Readonly<Record<string, string>>;
207
+ why: string;
167
208
  })[];
168
209
  };
169
210
  items: ({
@@ -171,9 +212,10 @@ export declare function prepareBuild(configPath: string, outDir: string): {
171
212
  id: string;
172
213
  } | {
173
214
  rules: {
215
+ description?: string;
216
+ family?: string;
174
217
  linter: "eslint" | "ruff";
175
218
  id: string;
176
- description: string;
177
219
  }[];
178
220
  id: string;
179
221
  })[];
@@ -2,6 +2,11 @@ export interface RuleRef {
2
2
  readonly linter: "eslint" | "ruff";
3
3
  readonly id: string;
4
4
  }
5
+ /** What the linter says a rule does; a ruff rule also names the family it comes from. */
6
+ export interface Described {
7
+ readonly description: string;
8
+ readonly family?: string;
9
+ }
5
10
  /** A process run to its end: the exit status and what it printed. */
6
11
  export type RunProcess = (cmd: string, args: readonly string[]) => {
7
12
  readonly status: number | null;
@@ -9,4 +14,4 @@ export type RunProcess = (cmd: string, args: readonly string[]) => {
9
14
  };
10
15
  export declare const runProcess: RunProcess;
11
16
  /** The linter's own one-line description of `ref`, or undefined when it has none. */
12
- export declare function describeRule(ref: RuleRef, fromDir: string, run?: RunProcess): string | undefined;
17
+ export declare function describeRule(ref: RuleRef, fromDir: string, run?: RunProcess): Described | undefined;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "paperlint",
3
- "version": "13.6.0",
3
+ "version": "14.0.0",
4
4
  "description": "A linter for scientific papers written in LaTeX: catches mistakes before you submit to a conference or journal.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -10,7 +10,9 @@ The verdict is computed from the cells, never stored, over neighbours read in fu
10
10
  result · ⚪ a piece nobody has excluded yet could still complete a neighbour (an unread cell, or an
11
11
  abstract-only read).
12
12
  Errors: a 'yes' cell without a quote or with a quote not in the text · a 'no' cell on an abstract-only
13
- neighbour · a missing search source · a priority sentence in paper.tex (first / none of the studies /
13
+ neighbour · a missing search source · a 🟢 claim that some source has no search covering (a `searched`
14
+ entry covers the claims its "claims" names, or, without "claims", the claims "added" on or before its date:
15
+ novelty_search.py) · a priority sentence in paper.tex (first / none of the studies /
14
16
  to our knowledge / ...) not mapped to a 🟢 claim · CLAIMS.md disagreeing with the computed verdict ·
15
17
  a 🟢 claim without a refutation file · a claim without a known type or an objection_answer · a saved text
16
18
  with more than 30 % glued lines (two page columns merged by `pdftotext -layout`).
@@ -32,11 +34,12 @@ from quote_match import found, letters, norm # noqa: E402
32
34
  from novelty_coverage import coverage_errors # noqa: E402
33
35
  import novelty_candidates # noqa: E402
34
36
  import novelty_cells # noqa: E402
37
+ import novelty_search # noqa: E402
38
+ from novelty_search import SOURCES # noqa: E402
35
39
 
36
40
  # Boldness of a ledger status; ⚪ is not on the scale (unchecked is neither more nor less cautious).
37
41
  BOLDNESS = {"🟢": 2, "🟡": 1, "🔴": 0}
38
42
  FILE_NAME = re.compile(r"[\w./-]+\.(?:md|txt|json|tex|tsv|csv|py|ts|mjs)\b")
39
- SOURCES = ("venue-pc", "prev-edition", "arxiv-recent")
40
43
  TYPES = {"new-object", "transfer", "installed-base", "instrument", "refutation", "replication"}
41
44
  # The result itself: what is examined and what number comes out. A neighbour with both already reported the
42
45
  # result, so the claim's delta over it is setting (population, comparator, instrument): 🟡 at best, never 🟢,
@@ -158,6 +161,7 @@ def main():
158
161
 
159
162
  have = {s.get("source") for s in data.get("searched", []) if (s.get("how") or "").strip()}
160
163
  errors += [f"searched: no '{s}' search recorded (source + how)" for s in SOURCES if s not in have]
164
+ errors += novelty_search.entry_errors(data.get("searched", []))
161
165
 
162
166
  neighbours = data.get("neighbours", [])
163
167
  texts = {}
@@ -228,6 +232,7 @@ def main():
228
232
  errors.append(f"{cid}: 🟢 without a refutation pass (refutation.file)")
229
233
  elif not os.path.isfile(os.path.join(paper, ref["file"])):
230
234
  errors.append(f"{cid}: refutation file {ref['file']} does not exist")
235
+ errors += novelty_search.claim_errors(c, data.get("searched", []), v)
231
236
  print(f"{cid} {v} {why}")
232
237
  print(table(c, neighbours))
233
238
 
@@ -0,0 +1,279 @@
1
+ /**
2
+ * check_novelty.py, the search behind a 🟢: for each of the three sources (venue-pc, prev-edition, arxiv-recent) a
3
+ * free verdict needs a `searched` entry that covers THAT claim. Execution tier: the novelty fixture on disk, no
4
+ * model, no network. Run: npx vitest run skills/assess-novelty/scripts/check_novelty_search.test.ts
5
+ *
6
+ * Why (paperlint#367): three planned claims added to a table on 2026-11-04, whose searches were dated 2026-10-27
7
+ * and 2026-11-03, came out 🟢 against 30 neighbours collected for other claims; four works the consumer's own
8
+ * ledger names for one of them were not neighbours at all. `searched` was one list for the whole table, and the
9
+ * checker asked only for a refutation file.
10
+ *
11
+ * An entry covers a claim when its "claims" names it; an entry without "claims" (a table-wide search, as every
12
+ * table had before) covers a claim whose "added" date is on or before the entry's date.
13
+ * Each case copies the fixture and makes ONE change; every failing class has a clean twin that passes.
14
+ */
15
+ import {
16
+ cpSync,
17
+ mkdtempSync,
18
+ readFileSync,
19
+ realpathSync,
20
+ rmSync,
21
+ writeFileSync,
22
+ } from "node:fs";
23
+ import { spawnSync } from "node:child_process";
24
+ import { tmpdir } from "node:os";
25
+ import { dirname, join } from "node:path";
26
+ import { fileURLToPath } from "node:url";
27
+ import assert from "node:assert/strict";
28
+ import { test } from "vitest";
29
+
30
+ const here = dirname(fileURLToPath(import.meta.url));
31
+ const FIXTURE = join(here, "fixtures", "novelty");
32
+ const CHECKER = join(here, "check_novelty.py");
33
+ const SOURCES = ["venue-pc", "prev-edition", "arxiv-recent"];
34
+
35
+ // Copy the fixture, let `change` edit the novelty table, run the checker: the exit code, the printed verdict
36
+ // lines and every ERROR line, so a case can compare the whole list of errors.
37
+ const run = (change: (json: any) => void) => {
38
+ const dir = realpathSync(mkdtempSync(join(tmpdir(), "novelty-search-")));
39
+ try {
40
+ cpSync(FIXTURE, dir, { recursive: true });
41
+ const path = join(dir, "siblings", "novelty.json");
42
+ const json = JSON.parse(readFileSync(path, "utf8"));
43
+ change(json);
44
+ writeFileSync(path, JSON.stringify(json, null, 2));
45
+ const r = spawnSync("python3", ["-I", CHECKER, dir], { encoding: "utf8" });
46
+ const errors = r.stderr
47
+ .split("\n")
48
+ .filter((l) => l.startsWith("ERROR "))
49
+ .map((l) => l.slice("ERROR ".length));
50
+ return { code: r.status, out: r.stdout, errors };
51
+ } finally {
52
+ rmSync(dir, { recursive: true, force: true });
53
+ }
54
+ };
55
+ const claim = (j: any, id: string) => j.claims.find((c: any) => c.id === id);
56
+ const entry = (source: string, date: string, claims?: string[]) => ({
57
+ source,
58
+ how: `${source}: the search, as run`,
59
+ date,
60
+ ...(claims ? { claims } : {}),
61
+ });
62
+ // The searches as every table had them before: one list, no "claims"; the issue's two dates.
63
+ const legacy = (j: any) => {
64
+ j.searched = [
65
+ entry("venue-pc", "2026-10-27"),
66
+ entry("prev-edition", "2026-10-27"),
67
+ entry("arxiv-recent", "2026-11-03"),
68
+ ];
69
+ };
70
+ // A design-stage claim added after the last search, with P5-1's cells (🟢) and a refutation file.
71
+ const addC8 = (j: any, added?: string) => {
72
+ const c8 = { ...structuredClone(claim(j, "P5-1")), id: "C8", planned: true };
73
+ delete c8.ledger;
74
+ delete c8.added;
75
+ j.claims.push(added ? { ...c8, added } : c8);
76
+ };
77
+ const missingC8 = (sources: string, hint: string) =>
78
+ `C8: 🟢 but no search covers it in ${sources}: search for C8 there and record it with "claims": ["C8"]${hint}`;
79
+ const AFTER = (added: string) =>
80
+ ` (a search without "claims" covers only the claims added on or before its date; C8 was added ${added})`;
81
+ const NO_ADDED =
82
+ ' (or set "added" to the day C8 entered the table: a search without "claims" covers only the claims added on or before its date)';
83
+
84
+ test("check_novelty_search: a claim added after the table's searches", () => {
85
+ // fires: the issue's case — a claim added 2026-11-04, searches of 2026-10-27 and 2026-11-03, no "claims"
86
+ {
87
+ const r = run((j) => {
88
+ legacy(j);
89
+ for (const c of j.claims) c.added = "2026-10-08";
90
+ addC8(j, "2026-11-04");
91
+ });
92
+ // Guards: C8 computes 🟢 (the table prints it), yet the run fails, naming C8, all three sources and the fix.
93
+ assert.match(r.out, /C8\s+🟢/);
94
+ assert.deepEqual(
95
+ { code: r.code, errors: r.errors },
96
+ {
97
+ code: 1,
98
+ errors: [missingC8(SOURCES.join(", "), AFTER("2026-11-04"))],
99
+ },
100
+ );
101
+ }
102
+ // fires: the same claim with no "added" at all — the error names both fixes
103
+ {
104
+ const r = run((j) => {
105
+ legacy(j);
106
+ for (const c of j.claims) c.added = "2026-10-08";
107
+ addC8(j);
108
+ });
109
+ // Guards: a claim with no date is not assumed to predate the searches.
110
+ assert.deepEqual(
111
+ { code: r.code, errors: r.errors },
112
+ { code: 1, errors: [missingC8(SOURCES.join(", "), NO_ADDED)] },
113
+ );
114
+ }
115
+ // silent: the delta search recorded for C8 in each source
116
+ {
117
+ const r = run((j) => {
118
+ legacy(j);
119
+ for (const c of j.claims) c.added = "2026-10-08";
120
+ addC8(j, "2026-11-04");
121
+ j.searched.push(...SOURCES.map((s) => entry(s, "2026-11-04", ["C8"])));
122
+ });
123
+ // Guards: the per-claim search is what lets a 🟢 through.
124
+ assert.deepEqual(
125
+ { code: r.code, errors: r.errors },
126
+ { code: 0, errors: [] },
127
+ );
128
+ }
129
+ }, 120_000);
130
+
131
+ test("check_novelty_search: one source short, and searches without claims", () => {
132
+ // fires: the delta search ran in two sources only (the third entry says nothing of how) — the error names it
133
+ {
134
+ const r = run((j) => {
135
+ legacy(j);
136
+ for (const c of j.claims) c.added = "2026-10-08";
137
+ addC8(j, "2026-11-04");
138
+ j.searched.push(entry("venue-pc", "2026-11-04", ["C8"]));
139
+ j.searched.push(entry("prev-edition", "2026-11-04", ["C8"]));
140
+ j.searched.push({
141
+ ...entry("arxiv-recent", "2026-11-04", ["C8"]),
142
+ how: " ",
143
+ });
144
+ });
145
+ // Guards: coverage is counted per source, not "some search names C8"; an entry without a "how" is no search.
146
+ assert.deepEqual(
147
+ { code: r.code, errors: r.errors },
148
+ { code: 1, errors: [missingC8("arxiv-recent", AFTER("2026-11-04"))] },
149
+ );
150
+ }
151
+ // silent: a search without "claims" dated on or after the claim's added date covers it (same day counts)
152
+ {
153
+ const r = run((j) => {
154
+ legacy(j);
155
+ for (const c of j.claims) c.added = "2026-10-08";
156
+ addC8(j, "2026-10-27");
157
+ j.searched.push(entry("arxiv-recent", "2026-10-27"));
158
+ });
159
+ // Guards: a table-wide search still counts for every claim the table had on its date.
160
+ assert.deepEqual(
161
+ { code: r.code, errors: r.errors },
162
+ { code: 0, errors: [] },
163
+ );
164
+ }
165
+ // fires: a search that names other claims does not cover C8, whatever its date
166
+ {
167
+ const r = run((j) => {
168
+ addC8(j, "2026-10-01");
169
+ // the fixture's searches name R1 and P5-1, dated 2026-10-08, after C8's added date
170
+ });
171
+ // Guards: an entry with "claims" covers only the claims it names; no hint, since no entry lacks "claims".
172
+ assert.deepEqual(
173
+ { code: r.code, errors: r.errors },
174
+ { code: 1, errors: [missingC8(SOURCES.join(", "), "")] },
175
+ );
176
+ }
177
+ }, 120_000);
178
+
179
+ test("check_novelty_search: migrating a table, and claims that are not free", () => {
180
+ // fires: an old table as it was (no "claims", no "added") whose 🟢 claim must now say when it entered
181
+ {
182
+ const r = run((j) => {
183
+ for (const s of j.searched) delete s.claims;
184
+ });
185
+ // Guards: the migration is named in the error itself; R1 is 🔴 and needs no search to stay 🔴.
186
+ assert.deepEqual(
187
+ { code: r.code, errors: r.errors },
188
+ {
189
+ code: 1,
190
+ errors: [
191
+ `P5-1: 🟢 but no search covers it in ${SOURCES.join(", ")}: search for P5-1 there and record it with "claims": ["P5-1"] (or set "added" to the day P5-1 entered the table: a search without "claims" covers only the claims added on or before its date)`,
192
+ ],
193
+ },
194
+ );
195
+ }
196
+ // silent: the same table migrated by dating its claims
197
+ {
198
+ const r = run((j) => {
199
+ for (const s of j.searched) delete s.claims;
200
+ for (const c of j.claims) c.added = "2026-10-08";
201
+ });
202
+ // Guards: "added" on or before the searches' date is the whole migration for a table searched once.
203
+ assert.deepEqual(
204
+ { code: r.code, errors: r.errors },
205
+ { code: 0, errors: [] },
206
+ );
207
+ }
208
+ // silent: a 🔴 claim that no search covers
209
+ {
210
+ const r = run((j) => {
211
+ for (const s of j.searched) s.claims = ["P5-1"];
212
+ });
213
+ // Guards: only a 🟢 rests on the search having covered the claim.
214
+ assert.deepEqual(
215
+ { code: r.code, errors: r.errors },
216
+ { code: 0, errors: [] },
217
+ );
218
+ }
219
+ }, 120_000);
220
+
221
+ test("check_novelty_search: an explicit null scope is malformed, not a table-wide search", () => {
222
+ const r = run((j) => {
223
+ for (const e of j.searched) e.claims = null;
224
+ for (const c of j.claims) c.added = "2026-10-08";
225
+ });
226
+ // Guards: "claims": null is not the same as no "claims". A null scope in every source must not authorize 🟢 for
227
+ // every claim added before the searches (Codex review on #370); it is named and covers nothing.
228
+ assert.equal(r.code, 1);
229
+ assert.deepEqual(
230
+ r.errors.filter((e) => e.startsWith("searched[")),
231
+ nullScopeErrors(3),
232
+ );
233
+ assert.ok(
234
+ r.errors.some((e) =>
235
+ e.startsWith(
236
+ "P5-1: 🟢 but no search covers it in venue-pc, prev-edition, arxiv-recent",
237
+ ),
238
+ ),
239
+ r.errors.join("\n"),
240
+ );
241
+ }, 120_000);
242
+ const nullScopeErrors = (n: number) =>
243
+ Array.from(
244
+ { length: n },
245
+ (_, i) =>
246
+ `searched[${i}]: "claims" must be a non-empty list of claim ids, got None`,
247
+ );
248
+
249
+ test("check_novelty_search: malformed claims, added and dates", () => {
250
+ const r = run((j) => {
251
+ j.searched[0].claims = ["R1", "P5-1", "C9"];
252
+ j.searched[1].claims = [];
253
+ j.searched[2].claims = "P5-1";
254
+ claim(j, "R1").added = "4 Nov 2026";
255
+ j.searched.push({
256
+ source: "venue-pc",
257
+ how: "a later sweep",
258
+ date: "2026-13-01",
259
+ });
260
+ j.searched.push({ source: "venue-pc", how: "a sweep with no date" });
261
+ });
262
+ // Guards: each malformed field is named with its fix, and a malformed entry covers nothing (P5-1 is short of
263
+ // prev-edition and arxiv-recent, whose only entries are malformed). C9, no claim of the table, is no error: the
264
+ // entry records what was searched that day, and a claim dropped later does not make the record wrong.
265
+ assert.deepEqual(
266
+ { code: r.code, errors: r.errors },
267
+ {
268
+ code: 1,
269
+ errors: [
270
+ 'searched[1]: "claims" must be a non-empty list of claim ids, got []',
271
+ "searched[2]: \"claims\" must be a non-empty list of claim ids, got 'P5-1'",
272
+ "searched[3]: date '2026-13-01' is not YYYY-MM-DD; a search without \"claims\" covers the claims added on or before its date",
273
+ 'searched[4]: date None is not YYYY-MM-DD; a search without "claims" covers the claims added on or before its date',
274
+ "R1: \"added\" '4 Nov 2026' is not a date (YYYY-MM-DD)",
275
+ 'P5-1: 🟢 but no search covers it in prev-edition, arxiv-recent: search for P5-1 there and record it with "claims": ["P5-1"]',
276
+ ],
277
+ },
278
+ );
279
+ }, 120_000);
@@ -1,9 +1,12 @@
1
1
  {
2
2
  "paper": "paper.tex",
3
3
  "searched": [
4
- {"source": "venue-pc", "how": "venue PC list, DBLP 2024-2026 of each member", "date": "2026-10-08"},
5
- {"source": "prev-edition", "how": "previous edition accepted papers, titles and abstracts", "date": "2026-10-08"},
6
- {"source": "arxiv-recent", "how": "arXiv cs.SE since 2026-01: AGENTS.md, rules files", "date": "2026-10-08"}
4
+ {"source": "venue-pc", "how": "venue PC list, DBLP 2024-2026 of each member", "date": "2026-10-08",
5
+ "claims": ["R1", "P5-1"]},
6
+ {"source": "prev-edition", "how": "previous edition accepted papers, titles and abstracts", "date": "2026-10-08",
7
+ "claims": ["R1", "P5-1"]},
8
+ {"source": "arxiv-recent", "how": "arXiv cs.SE since 2026-01: AGENTS.md, rules files", "date": "2026-10-08",
9
+ "claims": ["R1", "P5-1"]}
7
10
  ],
8
11
  "neighbours": [
9
12
  {"id": "doe26", "cite": "doe2026rules", "source": "arxiv-recent", "read": "full",
@@ -0,0 +1,87 @@
1
+ """Which searches covered which claim: a 🟢 needs, in each of the three sources, a search that covered THAT claim.
2
+
3
+ A `searched` entry is {"source", "how", "date", "claims"?}. With "claims" (a non-empty list of claim ids) it covers
4
+ exactly the claims it names. Without it, it is a search of the whole table as it stood on its date: it covers the
5
+ claims whose "added" (YYYY-MM-DD, the day the claim entered the table) is on or before that date, and no claim
6
+ without "added". Why: a claim added a week after the last search inherited that search as if it had covered it, and
7
+ came out 🟢 against neighbours collected for other claims (paperlint#367). An entry counts only with a "how".
8
+
9
+ Migrating a table written before this: give each claim the day it entered the table as "added" (the searches
10
+ without "claims" dated on or after it then count for it), or name the claims in each entry's "claims".
11
+ """
12
+ import datetime
13
+ import re
14
+
15
+ SOURCES = ("venue-pc", "prev-edition", "arxiv-recent")
16
+ DAY = re.compile(r"\d{4}-\d{2}-\d{2}")
17
+ SCOPE = 'a search without "claims" covers only the claims added on or before its date'
18
+
19
+
20
+ def day(value):
21
+ """The date a YYYY-MM-DD string names, or None."""
22
+ if not isinstance(value, str) or not DAY.fullmatch(value):
23
+ return None
24
+ try:
25
+ return datetime.date.fromisoformat(value)
26
+ except ValueError:
27
+ return None
28
+
29
+
30
+ TABLE_WIDE = object() # an entry without "claims"; an explicit "claims": null is malformed, not this
31
+
32
+
33
+ def named(entry):
34
+ """The claim ids an entry names, or TABLE_WIDE for an entry without "claims" (a search of the whole table)."""
35
+ return entry["claims"] if "claims" in entry else TABLE_WIDE
36
+
37
+
38
+ def well_formed(entry):
39
+ ids = named(entry)
40
+ return ids is TABLE_WIDE or (isinstance(ids, list) and bool(ids) and all(isinstance(i, str) for i in ids))
41
+
42
+
43
+ def covers(entry, claim):
44
+ """Did this search cover this claim? A malformed entry covers nothing."""
45
+ if not (entry.get("how") or "").strip() or not well_formed(entry):
46
+ return False
47
+ ids = named(entry)
48
+ if ids is not TABLE_WIDE:
49
+ return claim.get("id") in ids
50
+ searched_on, added = day(entry.get("date")), day(claim.get("added"))
51
+ return searched_on is not None and added is not None and added <= searched_on
52
+
53
+
54
+ def entry_errors(searched):
55
+ """A malformed "claims", and an entry without "claims" whose date is not a date: either covers nothing.
56
+
57
+ An id in "claims" that is no longer a claim is not an error: the entry records what was searched that day, and
58
+ a claim dropped from the table later does not make the record wrong (a misspelt id leaves its claim uncovered,
59
+ which claim_errors names).
60
+ """
61
+ def one(i, e):
62
+ ids = named(e)
63
+ if ids is TABLE_WIDE:
64
+ return ([] if day(e.get("date")) else
65
+ [f"searched[{i}]: date {e.get('date')!r} is not YYYY-MM-DD; a search without \"claims\" covers"
66
+ " the claims added on or before its date"])
67
+ return [] if well_formed(e) else [f'searched[{i}]: "claims" must be a non-empty list of claim ids, got {ids!r}']
68
+ return [err for i, e in enumerate(searched) for err in one(i, e)]
69
+
70
+
71
+ def claim_errors(claim, searched, verdict):
72
+ """A malformed "added"; and, for a 🟢, the sources with no search that covered the claim, with the fix."""
73
+ cid = claim.get("id")
74
+ bad_added = (["%s: \"added\" %r is not a date (YYYY-MM-DD)" % (cid, claim["added"])]
75
+ if "added" in claim and day(claim["added"]) is None else [])
76
+ if verdict != "🟢":
77
+ return bad_added
78
+ missing = [s for s in SOURCES if not any(e.get("source") == s and covers(e, claim) for e in searched)]
79
+ if not missing:
80
+ return bad_added
81
+ table_wide = any(e.get("source") in missing and named(e) is TABLE_WIDE and (e.get("how") or "").strip()
82
+ for e in searched)
83
+ hint = ("" if not table_wide else
84
+ f' (or set "added" to the day {cid} entered the table: {SCOPE})' if day(claim.get("added")) is None else
85
+ f" ({SCOPE}; {cid} was added {claim['added']})")
86
+ return bad_added + [f"{cid}: 🟢 but no search covers it in {', '.join(missing)}: search for {cid} there and"
87
+ f' record it with "claims": ["{cid}"]{hint}']
@@ -4,7 +4,7 @@ description: Build a phone labelling page published as a claude.ai Artifact with
4
4
  allowed-tools: [Read, Write, Edit, Grep, Glob, Bash, Artifact, ArtifactData]
5
5
  ---
6
6
 
7
- <!-- vigiles:sha256:bd616b2694831fa1 compiled from skills/build-label-page/SKILL.md.spec.ts -->
7
+ <!-- vigiles:sha256:6e828827dc6fcfc1 compiled from skills/build-label-page/SKILL.md.spec.ts -->
8
8
 
9
9
  # build-label-page — a page the person labels on a phone, and the labels back in the repo
10
10
 
@@ -63,8 +63,8 @@ building. In one line each:
63
63
  source labels on the page. The generator refuses to build when an item carries a field outside an
64
64
  allow-list; a page written by hand checks its items against the same list.
65
65
  5. **Ask only what the labeller can answer.** Only the languages and tools they know; one "how well do
66
- you know it" question per group, used later as an analysis filter (off on an approval page,
67
- `"familiarity": false`: the author has not seen those repositories); "clear-cut or real boundary"
66
+ you know it" question per group when the study filters on it (`"familiarity": true`; left
67
+ out, none is asked, as on an approval page, whose author has not seen those repositories); "clear-cut or real boundary"
68
68
  is the labeller's judgement, not the designer's.
69
69
  6. **Export into the repository right away**, one JSON per page into the study's `data/` with the
70
70
  export date, commit it, and record answers given in chat with `source: "chat"`. If the page fails,
@@ -104,7 +104,8 @@ building. In one line each:
104
104
  ```
105
105
 
106
106
  It writes `label-page/page.html` and `label-page/key.json` (the only link from `G001` to the
107
- source id; it never goes on the page). Written by hand, the page meets the requirements above.
107
+ source id; it never goes on the page). `kind` (`approval`, `witness`, `labels`) is required,
108
+ and each kind requires its card fields on every item and practice card. Written by hand, the page meets the requirements above.
108
109
  4. **Read it as the labeller, then look at it.** Read the page text once as a person who knows
109
110
  nothing about the source: if anything on it tells the answer, take it out. Read each question as
110
111
  someone who did not design the study: can they tell what each answer means from the question, its
@@ -74,8 +74,8 @@ building. In one line each:
74
74
  source labels on the page. The generator refuses to build when an item carries a field outside an
75
75
  allow-list; a page written by hand checks its items against the same list.
76
76
  5. **Ask only what the labeller can answer.** Only the languages and tools they know; one "how well do
77
- you know it" question per group, used later as an analysis filter (off on an approval page,
78
- \`"familiarity": false\`: the author has not seen those repositories); "clear-cut or real boundary"
77
+ you know it" question per group when the study filters on it (\`"familiarity": true\`; left
78
+ out, none is asked, as on an approval page, whose author has not seen those repositories); "clear-cut or real boundary"
79
79
  is the labeller's judgement, not the designer's.
80
80
  6. **Export into the repository right away**, one JSON per page into the study's \`data/\` with the
81
81
  export date, commit it, and record answers given in chat with \`source: "chat"\`. If the page fails,
@@ -115,7 +115,8 @@ building. In one line each:
115
115
  \`\`\`
116
116
 
117
117
  It writes \`label-page/page.html\` and \`label-page/key.json\` (the only link from \`G001\` to the
118
- source id; it never goes on the page). Written by hand, the page meets the requirements above.
118
+ source id; it never goes on the page). \`kind\` (\`approval\`, \`witness\`, \`labels\`) is required,
119
+ and each kind requires its card fields on every item and practice card. Written by hand, the page meets the requirements above.
119
120
  4. **Read it as the labeller, then look at it.** Read the page text once as a person who knows
120
121
  nothing about the source: if anything on it tells the answer, take it out. Read each question as
121
122
  someone who did not design the study: can they tell what each answer means from the question, its