paperlint 13.5.0 → 13.6.1
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/skills/build-label-page/scripts/label-page-core.d.ts +25 -5
- package/dist/skills/build-label-page/scripts/rule-descriptions.d.ts +12 -0
- package/package.json +1 -1
- package/skills/assess-novelty/scripts/check_novelty.py +7 -2
- package/skills/assess-novelty/scripts/check_novelty_search.test.ts +279 -0
- package/skills/assess-novelty/scripts/fixtures/novelty/siblings/novelty.json +6 -3
- package/skills/assess-novelty/scripts/novelty_search.py +87 -0
- package/skills/build-label-page/references/closed-choice-generator.md +7 -1
- package/skills/build-label-page/scripts/fixtures/ruff-rule-TID253.json +23 -0
- package/skills/build-label-page/scripts/label-page-core.js +34 -4
- package/skills/build-label-page/scripts/label-page-core.test.ts +97 -59
- package/skills/build-label-page/scripts/label-page-core.ts +52 -4
- package/skills/build-label-page/scripts/label-page-html.test.ts +46 -20
- package/skills/build-label-page/scripts/label-page.js +3 -0
- package/skills/build-label-page/scripts/label-page.ts +3 -0
- package/skills/build-label-page/scripts/page-template.html +4 -0
- package/skills/build-label-page/scripts/rule-descriptions.js +82 -0
- package/skills/build-label-page/scripts/rule-descriptions.test.ts +118 -0
- package/skills/build-label-page/scripts/rule-descriptions.ts +98 -0
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { type RuleRef } from "./rule-descriptions.ts";
|
|
1
2
|
export declare class Refusal extends Error {
|
|
2
3
|
readonly exitCode: number;
|
|
3
4
|
constructor(exitCode: number, message: string);
|
|
@@ -66,6 +67,7 @@ type Item = {
|
|
|
66
67
|
readonly source_id: string;
|
|
67
68
|
readonly group: string;
|
|
68
69
|
readonly body: string;
|
|
70
|
+
readonly rules?: readonly RuleRef[];
|
|
69
71
|
readonly unit?: Unit;
|
|
70
72
|
};
|
|
71
73
|
interface Practice {
|
|
@@ -95,6 +97,8 @@ interface KeyEntry {
|
|
|
95
97
|
readonly group: string;
|
|
96
98
|
readonly on_page: boolean;
|
|
97
99
|
}
|
|
100
|
+
/** Rule → the linter's description, for every rule an item on the page names. */
|
|
101
|
+
type Described = ReadonlyMap<string, string>;
|
|
98
102
|
/** Only what the labeller can answer goes on the page, and only that gets an id, in id order. */
|
|
99
103
|
declare function itemsOnPage(cfg: Config, keyPath: string): {
|
|
100
104
|
items: Item[];
|
|
@@ -103,7 +107,7 @@ declare function itemsOnPage(cfg: Config, keyPath: string): {
|
|
|
103
107
|
idOf: (i: Item) => string;
|
|
104
108
|
};
|
|
105
109
|
/** What a page shows: the questions, the practice and the items, blind, with their neutral ids. */
|
|
106
|
-
declare function payloadOf(cfg: Config, page: ReturnType<typeof itemsOnPage
|
|
110
|
+
declare function payloadOf(cfg: Config, page: ReturnType<typeof itemsOnPage>, described: Described): {
|
|
107
111
|
title: string;
|
|
108
112
|
intro: string;
|
|
109
113
|
groups: {
|
|
@@ -123,9 +127,17 @@ declare function payloadOf(cfg: Config, page: ReturnType<typeof itemsOnPage>): {
|
|
|
123
127
|
readonly id: string;
|
|
124
128
|
})[];
|
|
125
129
|
};
|
|
126
|
-
items: {
|
|
130
|
+
items: ({
|
|
131
|
+
rules?: never;
|
|
127
132
|
id: string;
|
|
128
|
-
}
|
|
133
|
+
} | {
|
|
134
|
+
rules: {
|
|
135
|
+
linter: "eslint" | "ruff";
|
|
136
|
+
id: string;
|
|
137
|
+
description: string;
|
|
138
|
+
}[];
|
|
139
|
+
id: string;
|
|
140
|
+
})[];
|
|
129
141
|
};
|
|
130
142
|
export type Payload = ReturnType<typeof payloadOf>;
|
|
131
143
|
/**
|
|
@@ -154,9 +166,17 @@ export declare function prepareBuild(configPath: string, outDir: string): {
|
|
|
154
166
|
readonly id: string;
|
|
155
167
|
})[];
|
|
156
168
|
};
|
|
157
|
-
items: {
|
|
169
|
+
items: ({
|
|
170
|
+
rules?: never;
|
|
158
171
|
id: string;
|
|
159
|
-
}
|
|
172
|
+
} | {
|
|
173
|
+
rules: {
|
|
174
|
+
linter: "eslint" | "ruff";
|
|
175
|
+
id: string;
|
|
176
|
+
description: string;
|
|
177
|
+
}[];
|
|
178
|
+
id: string;
|
|
179
|
+
})[];
|
|
160
180
|
};
|
|
161
181
|
dropped: number;
|
|
162
182
|
};
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
export interface RuleRef {
|
|
2
|
+
readonly linter: "eslint" | "ruff";
|
|
3
|
+
readonly id: string;
|
|
4
|
+
}
|
|
5
|
+
/** A process run to its end: the exit status and what it printed. */
|
|
6
|
+
export type RunProcess = (cmd: string, args: readonly string[]) => {
|
|
7
|
+
readonly status: number | null;
|
|
8
|
+
readonly stdout: string;
|
|
9
|
+
};
|
|
10
|
+
export declare const runProcess: RunProcess;
|
|
11
|
+
/** 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;
|
package/package.json
CHANGED
|
@@ -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
|
|
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
|
-
|
|
6
|
-
{"source": "
|
|
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}']
|
|
@@ -37,8 +37,14 @@ length.
|
|
|
37
37
|
- **`practice_round`**: raise it after rewording a question; the practice reopens and the answers of
|
|
38
38
|
the earlier round are kept but not counted.
|
|
39
39
|
- **`items`**: `source_id` (the study's own, never shown), `group`, `body`, and optionally `title`,
|
|
40
|
-
`code`, `context`, `unit`. Nothing else: the allow-list is fixed in the generator, and a field outside it
|
|
40
|
+
`code`, `context`, `rules`, `unit`. Nothing else: the allow-list is fixed in the generator, and a field outside it
|
|
41
41
|
is taken out of the item, not added to the list to make a build pass.
|
|
42
|
+
- **`rules`**: `[{linter, id}]` (`linter` is `eslint` or `ruff`) on an item read as a linter rule.
|
|
43
|
+
The card shows "Rule ruff TID253: Checks for module-level imports that should instead be imported
|
|
44
|
+
lazily…", the description taken at build time from the linter itself: `ruff rule <id>`'s "What it
|
|
45
|
+
does" paragraph, or the ESLint rule's `meta.docs.description` from the ESLint and plugins
|
|
46
|
+
installed where `items.json` is. The build refuses a rule the linter cannot describe; a
|
|
47
|
+
description is never typed into `items.json`.
|
|
42
48
|
- **`unit`**: `{id, text, part, of}` on an item that is one requirement of a line holding several
|
|
43
49
|
("TypeScript only (no JavaScript); top-level await is allowed" is two). Each requirement is its
|
|
44
50
|
own item with its own question, and its card shows "From the line (requirement 1 of 2):" and the
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "banned-module-level-imports",
|
|
3
|
+
"code": "TID253",
|
|
4
|
+
"linter": "flake8-tidy-imports",
|
|
5
|
+
"summary": "`{name}` is banned at the module level",
|
|
6
|
+
"message_formats": [
|
|
7
|
+
"`{name}` is banned at the module level"
|
|
8
|
+
],
|
|
9
|
+
"fix": "Fix is not available.",
|
|
10
|
+
"fix_availability": "None",
|
|
11
|
+
"explanation": "## What it does\nChecks for module-level imports that should instead be imported lazily\n(e.g., within a function definition, or an `if TYPE_CHECKING:` block, or\nsome other nested context).\n\n## Why is this bad?\nSome modules are expensive to import. For example, importing `torch` or\n`tensorflow` can introduce a noticeable delay in the startup time of a\nPython program.\n\nIn such cases, you may want to enforce that the module is imported lazily\nas needed, rather than at the top of the file. This could involve inlining\nthe import into the function that uses it, rather than importing it\nunconditionally, to ensure that the module is only imported when necessary.\n\n## Example\n```python\nimport tensorflow as tf\n\n\ndef show_version():\n print(tf.__version__)\n```\n\nUse instead:\n```python\ndef show_version():\n import tensorflow as tf\n\n print(tf.__version__)\n```\n\n## Options\n- `lint.flake8-tidy-imports.banned-module-level-imports`\n",
|
|
12
|
+
"preview": false,
|
|
13
|
+
"status": {
|
|
14
|
+
"Stable": {
|
|
15
|
+
"since": "v0.0.285"
|
|
16
|
+
}
|
|
17
|
+
},
|
|
18
|
+
"category": "restriction",
|
|
19
|
+
"source_location": {
|
|
20
|
+
"file": "crates/ruff_linter/src/rules/flake8_tidy_imports/rules/banned_module_level_imports.rs",
|
|
21
|
+
"line": 46
|
|
22
|
+
}
|
|
23
|
+
}
|
|
@@ -6,8 +6,9 @@
|
|
|
6
6
|
*/
|
|
7
7
|
import { createHash } from "node:crypto";
|
|
8
8
|
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
9
|
-
import { join } from "node:path";
|
|
9
|
+
import { dirname, join } from "node:path";
|
|
10
10
|
import { z } from "zod";
|
|
11
|
+
import { describeRule } from "./rule-descriptions.js";
|
|
11
12
|
// ── refusals and output ───────────────────────────────────────────────────────────────────────
|
|
12
13
|
export class Refusal extends Error {
|
|
13
14
|
exitCode;
|
|
@@ -89,11 +90,15 @@ const readJson = (path) => {
|
|
|
89
90
|
// labeller what the answer is, or where the item came from?" — never to make a build pass.
|
|
90
91
|
const PAGE_FIELDS = ["group", "title", "body", "code", "context"];
|
|
91
92
|
const PRIVATE_FIELDS = ["source_id"];
|
|
93
|
+
// The linter rules an item names ("read as ruff `TID253`"): the page shows each with the linter's
|
|
94
|
+
// own description, looked up at build time (rule-descriptions.ts), never typed into items.json.
|
|
95
|
+
const RULES_FIELD = "rules";
|
|
92
96
|
// One requirement of a line that holds several: the whole line and which part this item is. Shown on
|
|
93
97
|
// the card beneath the item; the line is text the labeller may read, like `context`.
|
|
94
98
|
const UNIT_FIELD = "unit";
|
|
95
99
|
const ALLOWED_FIELDS = [
|
|
96
100
|
...PAGE_FIELDS,
|
|
101
|
+
RULES_FIELD,
|
|
97
102
|
UNIT_FIELD,
|
|
98
103
|
...PRIVATE_FIELDS,
|
|
99
104
|
];
|
|
@@ -151,6 +156,7 @@ const obj = (fields, closed = true, required = true) => ({ kind: "object", requi
|
|
|
151
156
|
const ITEM_SCHEMA = obj({
|
|
152
157
|
source_id: string(),
|
|
153
158
|
...Object.fromEntries(PAGE_FIELDS.map((f) => [f, string(f === "group" || f === "body")])),
|
|
159
|
+
[RULES_FIELD]: array(obj({ linter: string(true, "eslint|ruff"), id: string() }), false, 1),
|
|
154
160
|
[UNIT_FIELD]: obj(
|
|
155
161
|
// `id` names one occurrence of the line (two files may hold the same words): never shown.
|
|
156
162
|
{ id: string(), text: string(), part: integer(), of: integer() }, true, false),
|
|
@@ -490,6 +496,25 @@ const shownFields = (item) => ({
|
|
|
490
496
|
},
|
|
491
497
|
}),
|
|
492
498
|
});
|
|
499
|
+
const ruleKey = (r) => `${r.linter} ${r.id}`;
|
|
500
|
+
/** Ask the linter for every rule named; refuse, by path, a rule it cannot describe. */
|
|
501
|
+
function describeRules(cfg, fromDir) {
|
|
502
|
+
return new Map(cfg.items.flatMap((item, n) => (item.rules ?? []).map((r, k) => {
|
|
503
|
+
const text = describeRule(r, fromDir);
|
|
504
|
+
return text === undefined
|
|
505
|
+
? fail(`items[${String(n)}].rules[${String(k)}]: ${r.linter} ${repr(r.id)} has no description from the linter (${r.linter === "ruff" ? "`ruff rule` does not know it, or ruff is not on PATH" : "not a rule of the ESLint, or of a plugin, installed where items.json is"}) — check the id`)
|
|
506
|
+
: [ruleKey(r), text];
|
|
507
|
+
})));
|
|
508
|
+
}
|
|
509
|
+
const rulesShown = (item, described) => item.rules === undefined
|
|
510
|
+
? {}
|
|
511
|
+
: {
|
|
512
|
+
[RULES_FIELD]: item.rules.map((r) => ({
|
|
513
|
+
linter: r.linter,
|
|
514
|
+
id: r.id,
|
|
515
|
+
description: String(described.get(ruleKey(r))),
|
|
516
|
+
})),
|
|
517
|
+
};
|
|
493
518
|
/** Only what the labeller can answer goes on the page, and only that gets an id, in id order. */
|
|
494
519
|
function itemsOnPage(cfg, keyPath) {
|
|
495
520
|
const knows = cfg.labeller_knows;
|
|
@@ -504,7 +529,7 @@ function itemsOnPage(cfg, keyPath) {
|
|
|
504
529
|
}
|
|
505
530
|
const practiceRound = (cfg) => cfg.practice_round ?? 1;
|
|
506
531
|
/** What a page shows: the questions, the practice and the items, blind, with their neutral ids. */
|
|
507
|
-
function payloadOf(cfg, page) {
|
|
532
|
+
function payloadOf(cfg, page, described) {
|
|
508
533
|
const used = Object.keys(cfg.groups).filter((g) => page.items.some((i) => i.group === g));
|
|
509
534
|
return {
|
|
510
535
|
title: cfg.title,
|
|
@@ -517,7 +542,11 @@ function payloadOf(cfg, page) {
|
|
|
517
542
|
allowed: allowedValues(cfg.questions, asksFamiliarity(cfg)),
|
|
518
543
|
questions: cfg.questions,
|
|
519
544
|
practice: { round: practiceRound(cfg), items: practiceOf(cfg) },
|
|
520
|
-
items: page.items.map((i) => ({
|
|
545
|
+
items: page.items.map((i) => ({
|
|
546
|
+
id: page.idOf(i),
|
|
547
|
+
...shownFields(i),
|
|
548
|
+
...rulesShown(i, described),
|
|
549
|
+
})),
|
|
521
550
|
};
|
|
522
551
|
}
|
|
523
552
|
/** Every id the key keeps (on the page now, or kept from an earlier build), with its source and group. */
|
|
@@ -585,13 +614,14 @@ export function prepareBuild(configPath, outDir) {
|
|
|
585
614
|
checkItemFields(cfg);
|
|
586
615
|
checkUnits(cfg);
|
|
587
616
|
checkNoSourceShown(cfg);
|
|
617
|
+
const described = describeRules(cfg, dirname(configPath));
|
|
588
618
|
mkdirSync(outDir, { recursive: true });
|
|
589
619
|
const keyPath = join(outDir, "key.json");
|
|
590
620
|
const earlier = roundsOf(keyPath);
|
|
591
621
|
const page = itemsOnPage(cfg, keyPath);
|
|
592
622
|
writeJson(keyPath, keyOf(cfg, page, earlier));
|
|
593
623
|
return {
|
|
594
|
-
payload: payloadOf(cfg, page),
|
|
624
|
+
payload: payloadOf(cfg, page, described),
|
|
595
625
|
dropped: cfg.items.length - page.items.length,
|
|
596
626
|
};
|
|
597
627
|
}
|