paperlint 14.1.0 → 14.3.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/CLAUDE.md CHANGED
@@ -1,4 +1,4 @@
1
- <!-- vigiles:sha256:58c66e374c084cc5 compiled from CLAUDE.md.spec.ts -->
1
+ <!-- vigiles:sha256:d595fb538eb0a81e compiled from CLAUDE.md.spec.ts -->
2
2
 
3
3
  # CLAUDE.md
4
4
 
@@ -106,6 +106,11 @@ Before writing machinery, **run the thing you are about to replace and paste its
106
106
 
107
107
  **Guidance only** — Before adding a CLI command or changing a delivery channel (skill links, hook wiring, the composite action, the npm package), read how comparable tools did it. "We need another command for X" has, in every tool examined, been the CONFIG not declaring something. [Details](docs/prior-art/README.md)
108
108
 
109
+ ### Browser Tests Find By Data Test
110
+
111
+ **Enforced by:** `eslint/playwright/no-raw-locators`
112
+ **Why:** A browser test finds an element only by `getByTestId` on `data-test`, and reads its values from the JSON in `data-test-meta`, never from CSS classes or visible text; content text is compared with the fixture's value. The older browser tests sit in `eslint-suppressions.json`, which may only shrink. [Details](docs/testing.md)
113
+
109
114
  ### New Code Is Functional
110
115
 
111
116
  **Enforced by:** `eslint/functional/no-let`
@@ -83,20 +83,45 @@ interface CodeLine {
83
83
  readonly n: number;
84
84
  readonly text: string;
85
85
  readonly mark: boolean;
86
+ readonly gutter?: string;
87
+ readonly context?: boolean;
88
+ }
89
+ /** One entry of a term's note: a verbatim quote or snippet, and the link to where it is from. */
90
+ interface NoteEntry {
91
+ readonly title?: string;
92
+ readonly says?: string;
93
+ readonly quote?: string;
94
+ readonly code?: string;
95
+ readonly example?: string;
96
+ readonly url: string;
97
+ readonly url_label: string;
98
+ }
99
+ interface Term {
100
+ readonly term: string;
101
+ readonly note: readonly NoteEntry[];
86
102
  }
87
103
  /** The content blocks (#380), each optional on any kind. */
88
104
  interface Blocks {
89
105
  readonly situation?: {
90
- readonly summary: string;
106
+ readonly summary?: string;
91
107
  readonly question: string;
92
108
  };
93
109
  readonly excerpt?: {
94
110
  readonly file?: string;
111
+ readonly lang?: string;
95
112
  readonly url?: string;
113
+ readonly gutter_header?: string;
96
114
  readonly lines: readonly CodeLine[];
115
+ readonly annotation?: string;
97
116
  readonly caption?: string;
98
117
  readonly mark_label?: string;
99
118
  };
119
+ readonly quote?: {
120
+ readonly text: string;
121
+ readonly source?: string;
122
+ };
123
+ readonly terms?: readonly Term[];
124
+ readonly answer_notes?: Readonly<Record<string, Readonly<Record<string, string>>>>;
100
125
  readonly references?: {
101
126
  readonly summary: string;
102
127
  readonly entries: readonly {
@@ -135,7 +160,7 @@ interface Blocks {
135
160
  type Card = {
136
161
  readonly [F in (typeof PAGE_FIELDS)[number]]?: string;
137
162
  } & Blocks & {
138
- readonly body: string;
163
+ readonly body?: string;
139
164
  readonly rules?: readonly RuleRef[];
140
165
  readonly unit?: Unit;
141
166
  readonly readings?: {
@@ -146,6 +171,7 @@ type Card = {
146
171
  type Item = Card & {
147
172
  readonly source_id: string;
148
173
  readonly group: string;
174
+ readonly answerers?: readonly string[];
149
175
  };
150
176
  type Practice = Card & {
151
177
  readonly expected: Readonly<Record<string, string>>;
@@ -159,6 +185,8 @@ interface Config {
159
185
  readonly seed: number;
160
186
  readonly groups: Readonly<Record<string, string>>;
161
187
  readonly labeller_knows?: readonly string[];
188
+ readonly answerers?: readonly string[];
189
+ readonly min_seconds?: number;
162
190
  readonly familiarity?: boolean;
163
191
  readonly familiarity_question?: string;
164
192
  readonly questions: readonly Question[];
@@ -171,6 +199,8 @@ interface KeyEntry {
171
199
  readonly source_id: string;
172
200
  readonly group: string;
173
201
  readonly on_page: boolean;
202
+ /** Who the item is shown to, when not every answerer of the page. */
203
+ readonly answerers?: readonly string[];
174
204
  }
175
205
  /** Rule → the linter's description (and a ruff rule's family), for every rule a card names. */
176
206
  type Described = ReadonlyMap<string, RuleText>;
@@ -183,21 +213,6 @@ declare function itemsOnPage(cfg: Config, keyPath: string): {
183
213
  };
184
214
  /** What a page shows: the questions, the practice and the items, blind, with their neutral ids. */
185
215
  declare function payloadOf(cfg: Config, page: ReturnType<typeof itemsOnPage>, described: Described): {
186
- title: string;
187
- intro: string;
188
- labels: {
189
- [x: string]: string;
190
- };
191
- groups: {
192
- id: string;
193
- label: string;
194
- }[];
195
- familiarity_question: string;
196
- familiarity: {
197
- value: string;
198
- label: string;
199
- }[];
200
- allowed: JsonObject;
201
216
  questions: readonly Question[];
202
217
  practice: {
203
218
  round: number;
@@ -219,9 +234,11 @@ declare function payloadOf(cfg: Config, page: ReturnType<typeof itemsOnPage>, de
219
234
  })[];
220
235
  };
221
236
  items: ({
237
+ answerers?: readonly string[];
222
238
  rules?: never;
223
239
  id: string;
224
240
  } | {
241
+ answerers?: readonly string[];
225
242
  rules: {
226
243
  description?: string;
227
244
  family?: string;
@@ -230,6 +247,23 @@ declare function payloadOf(cfg: Config, page: ReturnType<typeof itemsOnPage>, de
230
247
  }[];
231
248
  id: string;
232
249
  })[];
250
+ answerers?: readonly string[];
251
+ min_seconds?: number;
252
+ title: string;
253
+ intro: string;
254
+ labels: {
255
+ [x: string]: string;
256
+ };
257
+ groups: {
258
+ id: string;
259
+ label: string;
260
+ }[];
261
+ familiarity_question: string;
262
+ familiarity: {
263
+ value: string;
264
+ label: string;
265
+ }[];
266
+ allowed: JsonObject;
233
267
  };
234
268
  export type Payload = ReturnType<typeof payloadOf>;
235
269
  /**
@@ -239,21 +273,6 @@ export type Payload = ReturnType<typeof payloadOf>;
239
273
  */
240
274
  export declare function prepareBuild(configPath: string, outDir: string): {
241
275
  payload: {
242
- title: string;
243
- intro: string;
244
- labels: {
245
- [x: string]: string;
246
- };
247
- groups: {
248
- id: string;
249
- label: string;
250
- }[];
251
- familiarity_question: string;
252
- familiarity: {
253
- value: string;
254
- label: string;
255
- }[];
256
- allowed: JsonObject;
257
276
  questions: readonly Question[];
258
277
  practice: {
259
278
  round: number;
@@ -275,9 +294,11 @@ export declare function prepareBuild(configPath: string, outDir: string): {
275
294
  })[];
276
295
  };
277
296
  items: ({
297
+ answerers?: readonly string[];
278
298
  rules?: never;
279
299
  id: string;
280
300
  } | {
301
+ answerers?: readonly string[];
281
302
  rules: {
282
303
  description?: string;
283
304
  family?: string;
@@ -286,6 +307,23 @@ export declare function prepareBuild(configPath: string, outDir: string): {
286
307
  }[];
287
308
  id: string;
288
309
  })[];
310
+ answerers?: readonly string[];
311
+ min_seconds?: number;
312
+ title: string;
313
+ intro: string;
314
+ labels: {
315
+ [x: string]: string;
316
+ };
317
+ groups: {
318
+ id: string;
319
+ label: string;
320
+ }[];
321
+ familiarity_question: string;
322
+ familiarity: {
323
+ value: string;
324
+ label: string;
325
+ }[];
326
+ allowed: JsonObject;
289
327
  };
290
328
  dropped: number;
291
329
  };
package/docs/testing.md CHANGED
@@ -149,3 +149,15 @@ file matches, and it transpiles without type-checking, so `npm run check` runs
149
149
  really occur stays, with the test that reaches it. `scripts/coverage-config.test.ts` fails on any
150
150
  coverage-ignore comment and pins `.c8rc.json`'s `exclude` list.
151
151
  7. **Say what an assertion guards** in a comment directly above it (`// Guards: …`).
152
+ 8. **A browser test finds an element by `data-test` and reads its values from `data-test-meta`.**
153
+ The page marks every element a test touches with `data-test="<kind>"` and puts the element's
154
+ values in `data-test-meta` as JSON (`{"line":279,"marked":true}`). The test sets
155
+ `selectors.setTestIdAttribute("data-test")`, locates only with `getByTestId`, parses the meta and
156
+ compares it with one `deepEqual`. CSS classes stay for styling and visible text stays for people,
157
+ so a restyle or a reworded label does not turn a test red. Text that IS the content (an
158
+ annotation, a quote) is compared with the fixture's value, never with a literal typed into the
159
+ test. Accessibility state (`aria-expanded`) may be asserted on purpose, but it never locates
160
+ anything. `playwright/no-raw-locators`, `playwright/prefer-locator`, `playwright/no-wait-for-selector`
161
+ and `playwright/no-eval` hold the locating half; the older browser tests sit in
162
+ `eslint-suppressions.json`, which may only shrink. Example: `annotatedFacts` and
163
+ `annotatedExpected` in `skills/build-label-page/scripts/label-page-browser.test.ts`.
package/eslint.config.mjs CHANGED
@@ -19,6 +19,7 @@ import jsReasonRules from "#eslint-rules/js-needs-reason";
19
19
 
20
20
  import portRules from "#eslint-rules/install-path-literals";
21
21
  import n from "eslint-plugin-n";
22
+ import playwright from "eslint-plugin-playwright";
22
23
  import tseslint from "typescript-eslint";
23
24
  import boundaries from "eslint-plugin-boundaries";
24
25
  import functional from "eslint-plugin-functional";
@@ -697,6 +698,22 @@ export default [
697
698
  files: TEST_FILES,
698
699
  rules: { "max-lines-per-function": ["error", MAX_LINES_TESTS] },
699
700
  },
701
+ // Browser tests find an element by its test id (`data-test`, set in the page and in
702
+ // `selectors.setTestIdAttribute`) and read its values from `data-test-meta`: a CSS selector or a
703
+ // visible text breaks when the look changes. The rules fire under vitest as well as under
704
+ // Playwright's own runner (measured on label-page-browser.test.ts and candidate_page.test.ts).
705
+ // The plugin's `testIdAttribute` option belongs to `prefer-native-locators`, which this block does
706
+ // not enable, so there is nothing to configure.
707
+ {
708
+ files: TEST_FILES,
709
+ plugins: { playwright },
710
+ rules: {
711
+ "playwright/no-raw-locators": "error",
712
+ "playwright/prefer-locator": "error",
713
+ "playwright/no-wait-for-selector": "error",
714
+ "playwright/no-eval": "error",
715
+ },
716
+ },
700
717
  // 🔴 DEBT, NOT A PERMISSION. The novelty-check scripts and their harnesses were moved from a consumer
701
718
  // repository AS THEY WERE (the 2026-11-02 decision): untyped JSON in, `any` throughout. Making them pass the
702
719
  // type-safety rules below means rewriting them, which is paperlint#339 (TypeScript rewrite of the Python
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "paperlint",
3
- "version": "14.1.0",
3
+ "version": "14.3.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",
@@ -55,6 +55,7 @@
55
55
  "eslint-plugin-boundaries": "^7.2.0",
56
56
  "eslint-plugin-functional": "^10.0.1",
57
57
  "eslint-plugin-n": "^18.3.0",
58
+ "eslint-plugin-playwright": "^2.12.2",
58
59
  "eslint-plugin-project-structure": "^3.14.5",
59
60
  "eslint-plugin-sonarjs": "^4.2.1",
60
61
  "jsonc-parser": "^3.3.1",
@@ -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:64e09e4a829be441 compiled from skills/build-label-page/SKILL.md.spec.ts -->
7
+ <!-- vigiles:sha256:9d7a2c35df4e6a10 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
 
@@ -106,8 +106,10 @@ building. In one line each:
106
106
  It writes `label-page/page.html` and `label-page/key.json` (the only link from `G001` to the
107
107
  source id; it never goes on the page). `kind` (`approval`, `witness`, `labels`) is required,
108
108
  and each kind requires its card fields on every item and practice card. Any card may also carry
109
- the generic content blocks (a code excerpt with a marked line, references, a list, a table), every
110
- word on them from `items.json`, with the question right under the excerpt. `"preset":
109
+ the generic content blocks (a code excerpt with a marked line, a note under it and an extra
110
+ gutter column; the words asked about as a quote; tap-to-see notes on its terms; a sub-line under
111
+ each answer; references, a list, a table, none of them folded), every word on them from
112
+ `items.json`, with the question right under the excerpt. `"preset":
111
113
  "linter-config-witness"` is a narrow preset for comparing linter configs; the blocks underneath
112
114
  are generic. Written by hand, the page meets the requirements above.
113
115
  4. **Read it as the labeller, then look at it.** Read the page text once as a person who knows
@@ -142,6 +144,20 @@ building. In one line each:
142
144
  same pass, and add the line "labels → `data/<study>-labels-<date>.json`" to the paper's
143
145
  `NOTES.md` for the numbers that use them.
144
146
 
147
+ ## Two answerers, and a time on screen
148
+
149
+ Two optional top-level keys of `items.json`, both left out by default:
150
+
151
+ - `"answerers": ["author", "second"]` — two or more people answer the same published page, each
152
+ under their own name (picked on the page) and without seeing the other's answers; an item's own
153
+ `answerers` shows it to that subset only. The store's ids become `<answerer>~<id>`, and the
154
+ labels file keeps each answerer's answers under `by_answerer`.
155
+ - `"min_seconds": 30` — a card's answer saves only after the card has been on screen that long;
156
+ the seconds at its first save are stored and exported as `seconds`.
157
+
158
+ Details and the export's shape: [`references/closed-choice-generator.md`](references/closed-choice-generator.md),
159
+ "Two answerers, and a minimum time on screen".
160
+
145
161
  ## When the page misbehaves
146
162
 
147
163
  Look at what the store holds (`ArtifactData` `list`) before changing anything: the page may be fine
@@ -117,8 +117,10 @@ building. In one line each:
117
117
  It writes \`label-page/page.html\` and \`label-page/key.json\` (the only link from \`G001\` to the
118
118
  source id; it never goes on the page). \`kind\` (\`approval\`, \`witness\`, \`labels\`) is required,
119
119
  and each kind requires its card fields on every item and practice card. Any card may also carry
120
- the generic content blocks (a code excerpt with a marked line, references, a list, a table), every
121
- word on them from \`items.json\`, with the question right under the excerpt. \`"preset":
120
+ the generic content blocks (a code excerpt with a marked line, a note under it and an extra
121
+ gutter column; the words asked about as a quote; tap-to-see notes on its terms; a sub-line under
122
+ each answer; references, a list, a table, none of them folded), every word on them from
123
+ \`items.json\`, with the question right under the excerpt. \`"preset":
122
124
  "linter-config-witness"\` is a narrow preset for comparing linter configs; the blocks underneath
123
125
  are generic. Written by hand, the page meets the requirements above.
124
126
  4. **Read it as the labeller, then look at it.** Read the page text once as a person who knows
@@ -153,6 +155,20 @@ building. In one line each:
153
155
  same pass, and add the line "labels → \`data/<study>-labels-<date>.json\`" to the paper's
154
156
  \`NOTES.md\` for the numbers that use them.
155
157
 
158
+ ## Two answerers, and a time on screen
159
+
160
+ Two optional top-level keys of \`items.json\`, both left out by default:
161
+
162
+ - \`"answerers": ["author", "second"]\` — two or more people answer the same published page, each
163
+ under their own name (picked on the page) and without seeing the other's answers; an item's own
164
+ \`answerers\` shows it to that subset only. The store's ids become \`<answerer>~<id>\`, and the
165
+ labels file keeps each answerer's answers under \`by_answerer\`.
166
+ - \`"min_seconds": 30\` — a card's answer saves only after the card has been on screen that long;
167
+ the seconds at its first save are stored and exported as \`seconds\`.
168
+
169
+ Details and the export's shape: [\`references/closed-choice-generator.md\`](references/closed-choice-generator.md),
170
+ "Two answerers, and a minimum time on screen".
171
+
156
172
  ## When the page misbehaves
157
173
 
158
174
  Look at what the store holds (\`ArtifactData\` \`list\`) before changing anything: the page may be fine
@@ -21,7 +21,7 @@ with the core's payload. Another page shape would be another template on the sam
21
21
  Rules 2 to 5, 7 and the page half of 8 of [`checklist.md`](checklist.md): a filter instead of
22
22
  scrolling, complete-item counting, neutral ids in a seeded order, the leak guard (an item field
23
23
  outside the allow-list stops the build), save on click with a time, the store-unavailable banner,
24
- the "How to answer" screen (folded into a toggle once the practice is done), the practice before
24
+ the "How to answer" screen (folded into a toggle once the practice is done; it is page-level, not part of any card), the practice before
25
25
  the real items, one question per screen, each card's place (`labels.place`, by default
26
26
  "G007 · 7 of 20"), the card just
27
27
  answered kept under "Next unanswered" until the next answer (so a mis-tap can be changed), and a
@@ -111,7 +111,7 @@ Any card may carry these blocks, on any `kind`, and each one is optional. The pa
111
111
  "mark_label": "◀ the line in question", // optional; default «◀ this line»
112
112
  },
113
113
  "references": {
114
- "summary": "What the guideline says", // the folded block's toggle
114
+ "summary": "What the guideline says", // the block's heading
115
115
  "entries": [
116
116
  {
117
117
  "label": "Guideline", // optional row label
@@ -162,17 +162,50 @@ Any card may carry these blocks, on any `kind`, and each one is optional. The pa
162
162
  4. the older fields (`code`, `unit`, `rules`, `readings`, `file`, `context`);
163
163
  5. the situation's summary;
164
164
  6. the excerpt;
165
- 7. **the question and its answers**;
166
- 8. `references`, `more` and `table`, each folded and closed.
165
+ 7. **the question box**: the `quote` and its source, the question, and its answers with their `answer_notes`;
166
+ 8. `references`, `more` and `table`, each in place under its heading. Nothing on a card is folded:
167
+ a labeller who has to open a block to answer reads half a card (#392).
167
168
 
168
169
  The question therefore sits right under the code it names.
169
170
 
170
- - **`situation`**: `{summary, question}`.
171
- - `summary` is one sentence above the excerpt.
171
+ - **`situation`**: `{summary?, question}`.
172
+ - `summary` is one sentence above the excerpt; optional.
172
173
  - `question` replaces the text of the page's first question on this card. Other questions keep their own text.
173
174
  - In either text, `{line}` stands for the excerpt's first marked line. The page writes «line N: », then the line's code as code, without its indent. A code line holding a backtick reads as it is.
174
175
  - `{line}` on a card without an `excerpt` is refused by path.
175
- - **`excerpt`**: at least one line is marked (`mark: true`). The marked line gets a `▶` in the gutter and `mark_label` after its code, and keeps its background, so it reads as marked even without its colour. A line's `text` may be empty, for a blank line.
176
+ - **`excerpt`**: at least one line is marked (`mark: true`). The marked line gets a `▶` before its number and keeps its background, so it reads as marked even without its colour; after its code comes `mark_label`, unless the excerpt has an `annotation`. A line's `text` may be empty, for a blank line.
177
+ - `lang` follows the file name: «path · Python».
178
+ - Where two lines' numbers are not consecutive, a `⋮` row says what is left out (`labels.hidden`, `labels.hidden_one`). A line with `context: true` is drawn faint: one shown only to place the code, such as an enclosing block's opener.
179
+ - `gutter_header` and each line's `gutter` add a column between the number and the code: one word on top, one value per line («depth», `4`). The values are the consumer's, never computed by the page.
180
+ - `annotation` is one short fact in a box right under the first marked line, inside the code, with an arrow pointing at the line, as a code-review comment sits.
181
+
182
+ ### The approved card: a quote, notes on terms, answers that say what they mean
183
+
184
+ `scripts/fixtures/v7-cards-items.json` is a page of three such cards (#392). The fields:
185
+
186
+ ```jsonc
187
+ {
188
+ "quote": { "text": "Docstrings use Google-style format", "source": "`.github/copilot-instructions.md`, line 67" },
189
+ "situation": { "question": "Do these words forbid line 48?" },
190
+ "excerpt": { "file": "src/tables/merge.py", "lang": "Python", "annotation": "Ruff rule `D413` reports: …", "lines": [ … ] },
191
+ "terms": [
192
+ {
193
+ "term": "Google-style", // the card's own words that open the note
194
+ "note": [
195
+ // one or more entries; each has `quote` or `code` (verbatim), and its source link
196
+ { "title": "Google style", "quote": "Each section begins with a heading line, which ends with a colon.",
197
+ "example": "(e.g. `Args:`)", "url": "https://google.github.io/styleguide/pyguide.html", "url_label": "Google Python Style Guide" },
198
+ ],
199
+ },
200
+ ],
201
+ "answer_notes": { "forbid": { "forbid": "like a config with rule D413 on, which flags it" } }, // question → answer → sub-line
202
+ }
203
+ ```
204
+
205
+ - **`quote`** shows the words being asked about at the top of the question box, `source` as a small line under them. A card needs `body` or `quote`.
206
+ - **`terms`**: each `term` must occur in the quote, its source or the annotation, inside one backtick span or outside all of them; the build refuses one it cannot find there, and a note entry with neither `quote` nor `code`. A term opens from its first occurrence in the quote, else the source, else the annotation. On the page it is a dotted button: a tap (or hover, where there is hover) opens one floating note anchored to it, at most 280 px wide (300 with a snippet), flipped above the term when there is no room below, kept inside the viewport. A tap outside closes it and does nothing else, so it never answers a card; so does Esc. Answer buttons carry no terms.
207
+ - **`answer_notes`** puts a sub-line under an answer's label on this card: what the answer means here. A question or an answer the page does not have is refused.
208
+ - Whether a note pushes the labeller toward one answer is not checked: the consumer lists each note for the study's author.
176
209
  - **`references.entries[].fields`**:
177
210
  - A field without `style` is a paragraph («label: text» when it has a label). A long one shows three lines and a «more».
178
211
  - `style: "code"` is a code block on a `label` row.
@@ -189,6 +222,9 @@ Any card may carry these blocks, on any `kind`, and each one is optional. The pa
189
222
  | `line_part` | the `unit` row's label when the line holds several requirements | `{line} · part {part} of {of}` |
190
223
  | `repeat` | the repeat sentence | `Card {index} of {of}` |
191
224
  | `practice_example` | text before each answer's example on a practice button | empty |
225
+ | `hidden` | a `⋮` row for lines left out | `lines {from}–{to}` |
226
+ | `hidden_one` | a `⋮` row for one line left out | `line {from}` |
227
+ | `wait` | the bar under the answers while `min_seconds` runs | `⏳ You can answer in {s} s` |
192
228
 
193
229
  - The leak guard reads every string these blocks render, one string at a time.
194
230
 
@@ -199,7 +235,7 @@ A preset is opt-in. An items file names it with `"preset": "<name>"`. The preset
199
235
  - **`linter-config-witness`** is narrow: two linter configs of one requirement disagree on a line of code. The blocks underneath are generic.
200
236
  - **Input fields on a card:**
201
237
  - `example: {file, url?, lines, message, caught_by}`, which becomes the excerpt, with the caption «The linter says: …».
202
- - `rules: [{linter, id, explanation}]`, whose explanation has `source` (`ruff rule`, `eslint docs`, `plugin docs`, `config message` or `none`), plus `what`, `why`, `example_bad`, `example_good` and `url`. These become the references, folded under «What the linter rule does (official docs)».
238
+ - `rules: [{linter, id, explanation}]`, whose explanation has `source` (`ruff rule`, `eslint docs`, `plugin docs`, `config message` or `none`), plus `what`, `why`, `example_bad`, `example_good` and `url`. These become the references, under «What the linter rule does (official docs)».
203
239
  - `more_like_this: {count, files}`, which becomes the list «N more file(s) with the same situation».
204
240
  - `details: {a, b}`: rows of `{key, raw, plain, matches_example}`. They become the table «Settings behind this card», with «A only / B only / A and B» rows.
205
241
  - **Its labels**: «Card {n} of {total}», «Requirement», «Card {index} of {of} for this requirement», and «Example of this answer, about other code: ».
@@ -246,6 +282,48 @@ author cannot read it is an exclusion made after the draw and goes unrecorded. T
246
282
  units whose fate is obvious. Each dropped unit carries its reason in the labels file. The author's words, with the date, then go into
247
283
  `author_check` of the plan; a dropped unit is replaced by the next one in the draw.
248
284
 
285
+ ## Two answerers, and a minimum time on screen
286
+
287
+ Both keys are optional and top-level. Left out, the page, its store documents and the export are
288
+ exactly as above.
289
+
290
+ - **`answerers`**: two or more names (letters, digits, `-` and `_`), for a study where two people
291
+ answer the same published page, each under their own name and without seeing the other's
292
+ answers. The page first asks "Who is answering?"; the choice is kept in the browser's
293
+ localStorage when it has one (otherwise it asks again on every visit), and "Switch" beside
294
+ "Answering as …" asks again. Every document the page writes is named `<answerer>~<id>`
295
+ (`second~G004`, `second~r1-P2`, `second~python`): `~` is outside the name pattern, so no answerer,
296
+ item, group or practice id can hold it. The page reads only the current answerer's documents, so
297
+ their progress, done-state and practice are their own. An item may carry its own `answerers`, a
298
+ subset of the page's: it is shown only to those. A name an item lists that the page does not, a
299
+ name twice, or an item's list on a page without one stops the build.
300
+ - **`min_seconds`**: an item's answer saves only after its card has been on screen that long. The
301
+ cards are a list, so "on screen" is measured by an `IntersectionObserver`: half the card in view,
302
+ or half the viewport filled by a card taller than that, while the tab is visible. A locked card
303
+ shows "You can answer in N s" under its disabled buttons. The answer document gains `seconds`:
304
+ how long the card had been on screen at its first save, kept on every later save. The practice
305
+ cards are not held.
306
+
307
+ `key.json` carries `answerers`, each item's own subset (`ids.<id>.answerers`, only where it has one)
308
+ and `min_seconds`. With `answerers`, `export` refuses a document id without `<answerer>~`, an
309
+ answerer the key does not list, and an answer to an item not shown to its answerer, as it refuses
310
+ an unknown item. The labels file keeps each answerer's answers apart:
311
+
312
+ ```text
313
+ { "answerers": ["author", "second"],
314
+ "items": [{ "id": "G001", "source_id": "case-1000", "group": "python",
315
+ "answerers": ["author", "second"],
316
+ "by_answerer": { "author": { "answers": { "label": "yes" }, "complete": true,
317
+ "seconds": 41, "…": "the fields of an item above" },
318
+ "second": { "…": "…" } } }],
319
+ "by_answerer": { "author": { "groups": {}, "complete_items": 1, "total_items": 1, "…": "…" },
320
+ "second": { "…": "…" } } }
321
+ ```
322
+
323
+ An answerer with no document for an item is still listed, with every answer `null`. Each
324
+ answerer's practice is scored into its own file, `<study>-practice-r<round>-<answerer>-<date>.json`.
325
+ `seconds` appears only when the page was built with `min_seconds`.
326
+
249
327
  ## The export
250
328
 
251
329
  `export` reads the store's three collections (`answers`, `groups`, `practice`) from `dump.json`, in
@@ -0,0 +1,138 @@
1
+ {
2
+ "study": "demo-words-forbid",
3
+ "kind": "labels",
4
+ "title": "Do the words forbid it",
5
+ "seed": 3,
6
+ "min_seconds": 10,
7
+ "groups": { "all": "Cards" },
8
+ "labels": { "place": "Card {n} of {total}", "wait": "Save · available after {s} seconds on this card" },
9
+ "questions": [
10
+ {
11
+ "id": "forbid",
12
+ "text": "Do these words forbid the marked line?",
13
+ "short": "do the words forbid it?",
14
+ "options": [
15
+ { "value": "forbid", "label": "Forbid", "icon": "⛔", "example": "«No print calls» and the line print(x)" },
16
+ { "value": "allow", "label": "Allow", "icon": "✅", "example": "«No print calls» and the line log.info(x)" },
17
+ { "value": "unsettled", "label": "The words don't settle it", "icon": "↔️", "example": "«Keep functions short» and a 30-line function" },
18
+ { "value": "elsewhere", "label": "The words are about something else", "icon": "🔀", "example": "«Use tabs» and a line about a database query" },
19
+ { "value": "cannot", "label": "I can't judge (say why)", "icon": "❔", "example": "a line in a language you do not read", "ask_why": "What stops you?" }
20
+ ]
21
+ }
22
+ ],
23
+ "practice": [
24
+ {
25
+ "quote": { "text": "No print calls in library code", "source": "`CONTRIBUTING.md`, line 4" },
26
+ "situation": { "question": "Do these words forbid line 3?" },
27
+ "excerpt": { "file": "lib/sum.py", "lang": "Python", "lines": [
28
+ { "n": 2, "text": "def total(xs):", "mark": false },
29
+ { "n": 3, "text": " print(xs)", "mark": true }
30
+ ] },
31
+ "expected": { "forbid": "forbid" }, "why": "print is a print call, and lib/ is library code."
32
+ },
33
+ {
34
+ "quote": { "text": "No print calls in library code", "source": "`CONTRIBUTING.md`, line 4" },
35
+ "situation": { "question": "Do these words forbid line 3?" },
36
+ "excerpt": { "file": "lib/sum.py", "lang": "Python", "lines": [
37
+ { "n": 2, "text": "def total(xs):", "mark": false },
38
+ { "n": 3, "text": " log.debug(xs)", "mark": true }
39
+ ] },
40
+ "expected": { "forbid": "allow" }, "why": "A log call is not a print call."
41
+ },
42
+ {
43
+ "quote": { "text": "Use tabs for indentation", "source": "`STYLE.md`, line 2" },
44
+ "situation": { "question": "Do these words forbid line 8?" },
45
+ "excerpt": { "file": "db/query.py", "lang": "Python", "lines": [
46
+ { "n": 8, "text": "rows = db.execute(sql)", "mark": true }
47
+ ] },
48
+ "expected": { "forbid": "elsewhere" }, "why": "The words are about indentation; the line is a query."
49
+ }
50
+ ],
51
+ "items": [
52
+ {
53
+ "source_id": "card-docstring", "group": "all",
54
+ "quote": { "text": "Docstrings use Google-style format", "source": "`.github/copilot-instructions.md`, line 67" },
55
+ "situation": { "question": "Do these words forbid line 48?" },
56
+ "excerpt": {
57
+ "file": "src/tables/merge.py", "lang": "Python",
58
+ "annotation": "Ruff rule `D413` reports: “Missing blank line after last section (\"Returns\")”",
59
+ "lines": [
60
+ { "n": 37, "text": "def merge_tables(", "mark": false, "context": true },
61
+ { "n": 40, "text": " \"\"\"", "mark": false },
62
+ { "n": 41, "text": " Merge the tables picked by the flags.", "mark": false },
63
+ { "n": 42, "text": "", "mark": false },
64
+ { "n": 43, "text": " picks: For each table name, whether to keep its rows of each kind", "mark": false },
65
+ { "n": 44, "text": " (kept, dropped), and which of the two to merge first.", "mark": false },
66
+ { "n": 45, "text": " tables: Every table that can be merged.", "mark": false },
67
+ { "n": 46, "text": " Columns needed: \"name\", \"kind\"", "mark": false },
68
+ { "n": 47, "text": "", "mark": false },
69
+ { "n": 48, "text": " Returns:", "mark": true },
70
+ { "n": 49, "text": " One table with the rows of every table picked.", "mark": false },
71
+ { "n": 50, "text": " \"\"\"", "mark": false },
72
+ { "n": 51, "text": " kinds_needed = {", "mark": false }
73
+ ]
74
+ },
75
+ "terms": [
76
+ { "term": "Ruff", "note": [{ "quote": "An extremely fast Python linter and code formatter, written in Rust.", "url": "https://docs.astral.sh/ruff/", "url_label": "Ruff docs" }] },
77
+ { "term": "D413", "note": [{ "title": "Ruff rule D413", "quote": "Checks for missing blank lines after the last section of a multiline docstring", "url": "https://docs.astral.sh/ruff/rules/missing-blank-line-after-last-section/", "url_label": "docs" }] },
78
+ { "term": "Google-style", "note": [
79
+ { "title": "Google style", "quote": "Each section begins with a heading line, which ends with a colon.", "example": "(e.g. `Args:`, `Returns:`, `Raises:`)", "url": "https://google.github.io/styleguide/pyguide.html#383-functions-and-methods", "url_label": "Google Python Style Guide, 3.8.3" },
80
+ { "title": "NumPy style, the other common one", "quote": "Each heading should be underlined in hyphens", "example": "(e.g. `Returns` with `-------` on the line under it)", "url": "https://numpydoc.readthedocs.io/en/latest/format.html#sections", "url_label": "numpydoc format guide" }
81
+ ] },
82
+ { "term": ".github/copilot-instructions.md", "note": [{ "quote": "Repository-wide custom instructions apply to all requests made in the context of a repository.", "url": "https://docs.github.com/en/copilot/how-tos/copilot-on-github/customize-copilot/add-custom-instructions/add-repository-instructions", "url_label": "GitHub Docs" }] }
83
+ ],
84
+ "answer_notes": { "forbid": { "forbid": "like a config with rule D413 on, which flags it", "allow": "like a config without D413, which passes it" } }
85
+ },
86
+ {
87
+ "source_id": "card-call", "group": "all",
88
+ "quote": { "text": "Use cachedElement('elementId') instead of document.getElementById", "source": "`AGENTS.md`, line 7" },
89
+ "situation": { "question": "Do these words forbid line 22?" },
90
+ "excerpt": {
91
+ "file": "src/dom/cache.ts", "lang": "TypeScript",
92
+ "annotation": "calls `document.getElementById`",
93
+ "lines": [
94
+ { "n": 9, "text": "export const cachedElement = (id: string) => {", "mark": false, "context": true },
95
+ { "n": 19, "text": " return hit", "mark": false },
96
+ { "n": 20, "text": " }", "mark": false },
97
+ { "n": 21, "text": "", "mark": false },
98
+ { "n": 22, "text": " const el = document.getElementById(id)", "mark": true },
99
+ { "n": 23, "text": "", "mark": false },
100
+ { "n": 24, "text": " if (!el) {", "mark": false },
101
+ { "n": 25, "text": " throw new TypeError(`No element with id \"${id}\"`)", "mark": false }
102
+ ]
103
+ },
104
+ "terms": [
105
+ { "term": "cachedElement", "note": [{ "says": "this project's own function, defined at line 9 of the file above; its comment reads", "quote": "A cache for DOM elements", "url": "https://example.com/src/dom/cache.ts#L3-L9", "url_label": "src/dom/cache.ts" }] },
106
+ { "term": "document.getElementById", "note": [{ "quote": "returns an Element object representing the element whose id property matches the specified string", "url": "https://developer.mozilla.org/en-US/docs/Web/API/Document/getElementById", "url_label": "MDN" }] },
107
+ { "term": "AGENTS.md", "note": [{ "quote": "a dedicated, predictable place to provide the context and instructions to help AI coding agents work on your project.", "url": "https://agents.md/", "url_label": "agents.md" }] }
108
+ ],
109
+ "answer_notes": { "forbid": { "forbid": "like a config that bans this call and checks this file", "allow": "like a config that bans this call but doesn't check this file" } }
110
+ },
111
+ {
112
+ "source_id": "card-depth", "group": "all",
113
+ "quote": { "text": "Avoid deep nesting (use early returns and guard clauses)", "source": "`CLAUDE.md`, line 85" },
114
+ "situation": { "question": "Do these words forbid line 279?" },
115
+ "excerpt": {
116
+ "file": "src/index/FileIndex.ts", "lang": "TypeScript", "gutter_header": "depth",
117
+ "annotation": "nested 4 blocks deep",
118
+ "lines": [
119
+ { "n": 22, "text": "export class FileIndex {", "mark": false, "context": true },
120
+ { "n": 257, "text": " private onDeleted(uri: Uri): void {", "mark": false, "context": true },
121
+ { "n": 262, "text": " try {", "mark": false, "context": true, "gutter": "1" },
122
+ { "n": 276, "text": " if (entry.isDir) {", "mark": false, "gutter": "2" },
123
+ { "n": 277, "text": " const prefix = `${rel}/`;", "mark": false },
124
+ { "n": 278, "text": " for (const key of this.entries.keys()) {", "mark": false, "gutter": "3" },
125
+ { "n": 279, "text": " if (key.startsWith(prefix)) {", "mark": true, "gutter": "4" },
126
+ { "n": 280, "text": " this.entries.delete(key);", "mark": false },
127
+ { "n": 281, "text": " }", "mark": false },
128
+ { "n": 282, "text": " }", "mark": false }
129
+ ]
130
+ },
131
+ "terms": [
132
+ { "term": "guard clauses", "note": [{ "code": "function price(order) {\n if (!order) return 0;\n if (order.free) return 0;\n if (order.staff) return staffPrice(order);\n return listPrice(order);\n}", "url": "https://example.com/style-guide#guard-clauses", "url_label": "Style guide (example): guard clauses" }] },
133
+ { "term": "CLAUDE.md", "note": [{ "quote": "CLAUDE.md files are markdown files that give Claude persistent instructions for a project, your personal workflow, or your entire organization.", "url": "https://code.claude.com/docs/en/memory", "url_label": "Claude Code docs" }] }
134
+ ],
135
+ "answer_notes": { "forbid": { "forbid": "like a config with limit 3, which flags it", "allow": "like ESLint's default, limit 4, which passes it" } }
136
+ }
137
+ ]
138
+ }