diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 11058cc..a2bb227 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -81,7 +81,7 @@ jobs: - run: mix format --check-formatted if: matrix.current - run: mix compile --warnings-as-errors - - run: mix credo + - run: mix credo --strict if: matrix.current - run: mix hex.audit - run: mix deps.audit @@ -89,7 +89,7 @@ jobs: - run: mix sobelow if: matrix.current - run: mix deps.unlock --check-unused - - run: mix test + - run: mix test --warnings-as-errors extension: runs-on: ubuntu-24.04 diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 9494bf3..f950085 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -66,7 +66,8 @@ site in `site/` (its own `package.json`, build and tests), in ## Commits and pull requests - Code follows [docs/CODING_STANDARDS.md](docs/CODING_STANDARDS.md); CI checks formatting - and lint, so run `mix format`, `mix credo` and `npm run lint` before pushing. + and lint, so run `mix format`, `mix credo --strict` and `npm run lint` before pushing + (`npm run lint:fix` fixes the JavaScript layout). - CI scans the dependencies (OSV-Scanner, `mix deps.audit`) and the code (Sobelow, CodeQL) for security problems. What fails a pull request, the licenses a new dependency may have, and how to accept a false positive are in the diff --git a/docs/CODING_STANDARDS.md b/docs/CODING_STANDARDS.md index 28d135e..dba09d2 100644 --- a/docs/CODING_STANDARDS.md +++ b/docs/CODING_STANDARDS.md @@ -1,14 +1,14 @@ # Coding standards -Last reviewed: 2026-10-05. +Last reviewed: 2026-10-06. The rules code in this repository follows. Where a tool decides, the tool wins; CI runs the tools on every pull request ([`.github/workflows/ci.yml`](../.github/workflows/ci.yml)). Run them before pushing: ```bash -(cd server && mix format && mix credo && mix compile --warnings-as-errors) -npm run lint +(cd server && mix format && mix credo --strict && mix compile --warnings-as-errors) +npm run lint # npm run lint:fix applies the JavaScript layout for you ``` [CONTRIBUTING.md](../CONTRIBUTING.md) has the rules that protect users; they apply to every @@ -18,11 +18,15 @@ language below. - **Layout** is whatever `mix format` produces ([`server/.formatter.exs`](../server/.formatter.exs)). CI fails on unformatted code. -- **Credo** ([`server/.credo.exs`](../server/.credo.exs)) decides the rest; CI runs `mix credo` - and fails on any issue. Strict mode (`mix credo --strict`) is planned in slice - [02](../slices/02-test-harness-and-ci/SPEC.md)'s addition. +- **Credo in strict mode** ([`server/.credo.exs`](../server/.credo.exs)) decides the rest: + its default checks, low-priority ones included (for example: a nested module called by + its full name gets an alias at the top; aliases in alphabetical order). The config sets + `strict: true`, so a plain `mix credo` reports what CI's `mix credo --strict` reports, and + CI fails on any issue. A check is switched off in code (`# credo:disable-for-next-line`) + only with a comment saying why; there are none today. - **No compiler warnings**: CI compiles with `--warnings-as-errors`, on the oldest and the - newest Elixir it supports. + newest Elixir it supports, and runs the tests with `mix test --warnings-as-errors`, so a + warning in a test file fails too. - Where both are silent, follow the community [Elixir Style Guide](https://github.com/christopheradams/elixir_style_guide). - Project rules: @@ -39,12 +43,38 @@ language below. ## JavaScript (`extension/`, `test/`, `scripts/`, `spec/tools/`) -- **ESLint** ([`eslint.config.js`](../eslint.config.js)) with the recommended rules plus - `eslint-plugin-no-unsanitized`, and `web-ext lint` for the extension. `npm run lint` must - pass. There is no JavaScript formatter yet (Prettier is proposed in slice - [53](../slices/53-openssf-best-practices/SPEC.md), open question 4); until then follow - [`.editorconfig`](../.editorconfig) (two spaces, LF, UTF-8) and the surrounding code: - double quotes and semicolons. +- **ESLint** ([`eslint.config.js`](../eslint.config.js)) decides, and `npm run lint` must + pass (CI runs it on every pull request; any finding is an error, there are no + warnings). It checks four things: + - **Correctness**: ESLint's recommended rules, plus stricter ones that catch real + mistakes: `===` everywhere except `x == null` (null or undefined), no shadowed names, + no unused variables or arguments (an argument a callback must take but doesn't use + starts with `_`), no assignments to parameters, no `eval`, `new Function` or string + timers, no expression statements such as `a && b()` or `(a(), b())`, no closures over + a loop's changing variables. The full list is `STRICTER` in the config. One exception: + Playwright tests hand values to callbacks that run in the browser + (`page.evaluate((id) => ..., id)`) under the same name, so shadowing is allowed there. + - **One idiom**: `const` unless reassigned, never `var`; object shorthand; `?.`, `??=`, + `||=` and spread instead of their long forms; dot access where possible. + - **Layout**: `LAYOUT` in the config, with + [`@stylistic/eslint-plugin`](https://eslint.style) (the formatting rules ESLint itself + moved out of its core): two-space indent, double quotes (a template literal or single + quotes only to avoid escaping), semicolons, trailing commas on multi-line lists, a + multi-line ternary's `?` and `:` at the start of the line, `{ a }` and `[a]` spacing, + quotes on object keys only where needed, one blank line at most, LF and a final + newline. `npm run lint:fix` (`eslint . --fix`) applies it. There is no line-length + limit: a line is as long as reads well, and short lines are not forced because the + popup has a hard size cap (slice 20 §8). `extension/spec/spec.js` is generated and + skips the layout rules. + - **Safety**: `eslint-plugin-no-unsanitized` and the HTML-string ban below. +- **`web-ext lint`** checks the extension package (also part of `npm run lint`). +- **Why not Prettier**: it was measured and not adopted (slice + [53](../slices/53-openssf-best-practices/SPEC.md), open question 4). At print widths from + 100 to 200 it rewrote 137 to 188 files (9,085 to 31,918 added lines) and grew what the + popup loads at open by 1.6 to 6.5 KB, past its 128 KB cap. The ESLint layout rules + describe the code as it was written: adopting them changed 12 files by 22 lines, all + whitespace or quotes. +- [`.editorconfig`](../.editorconfig) sets two spaces, LF and UTF-8 for every editor. - **This section is the JavaScript style guide** for the extension: - Classic scripts, no modules and no bundler. Each file attaches one namespace to `globalThis` (for example `KotikoI18n`), and pure modules also export themselves when diff --git a/eslint.config.js b/eslint.config.js index 8e44322..6739741 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -4,6 +4,7 @@ import js from "@eslint/js"; import globals from "globals"; import nounsanitized from "eslint-plugin-no-unsanitized"; +import stylistic from "@stylistic/eslint-plugin"; // Words, notes and model answers are untrusted text: the extension builds DOM with // textContent and DOM methods, never HTML strings (research 03 E1). @@ -12,6 +13,128 @@ const NO_HTML_STRINGS = ["innerHTML", "outerHTML", "insertAdjacentHTML"].map((pr message: "Build DOM with textContent and DOM methods; never parse strings as HTML.", })); +// Beyond the recommended set: rules that catch real mistakes (loose equality, shadowed +// names, unused arguments, eval and its relatives) and keep the code in one modern idiom. +// The whole code base passes them; see docs/CODING_STANDARDS.md. +const STRICTER = { + "array-callback-return": "error", + "block-scoped-var": "error", + curly: ["error", "multi-line"], + "default-case-last": "error", + "dot-notation": "error", + // `x == null` is the one loose comparison allowed: it means null or undefined. + eqeqeq: ["error", "always", { null: "ignore" }], + "guard-for-in": "error", + "logical-assignment-operators": "error", + "no-caller": "error", + "no-constructor-return": "error", + "no-else-return": ["error", { allowElseIf: false }], + "no-eval": "error", + "no-extend-native": "error", + "no-implicit-coercion": ["error", { allow: ["!!"] }], + "no-implied-eval": "error", + "no-inner-declarations": "error", + "no-iterator": "error", + "no-labels": "error", + "no-lone-blocks": "error", + "no-lonely-if": "error", + "no-loop-func": "error", + "no-multi-str": "error", + "no-new": "error", + "no-new-func": "error", + "no-new-wrappers": "error", + "no-object-constructor": "error", + "no-octal-escape": "error", + "no-param-reassign": "error", + "no-proto": "error", + "no-return-assign": "error", + "no-self-compare": "error", + "no-shadow": "error", + "no-template-curly-in-string": "error", + "no-throw-literal": "error", + "no-unassigned-vars": "error", + "no-unneeded-ternary": "error", + "no-unreachable-loop": "error", + "no-unused-expressions": "error", + "no-unused-vars": ["error", { args: "all", argsIgnorePattern: "^_", caughtErrors: "all", caughtErrorsIgnorePattern: "^_" }], + "no-useless-call": "error", + "no-useless-computed-key": "error", + "no-useless-concat": "error", + "no-useless-constructor": "error", + "no-useless-rename": "error", + "no-useless-return": "error", + "no-var": "error", + "object-shorthand": "error", + "operator-assignment": "error", + "prefer-arrow-callback": "error", + "prefer-const": ["error", { destructuring: "all" }], + "prefer-exponentiation-operator": "error", + "prefer-numeric-literals": "error", + "prefer-object-has-own": "error", + "prefer-object-spread": "error", + "prefer-promise-reject-errors": "error", + "prefer-regex-literals": "error", + "prefer-rest-params": "error", + "prefer-spread": "error", + radix: "error", + "symbol-description": "error", + "unicode-bom": "error", + yoda: "error", +}; + +// The layout the code already follows, written down so a tool checks it: two-space +// indent, double quotes, semicolons, trailing commas on multi-line lists, a ternary's `?` +// and `:` at the start of a continued line, and the usual spacing. `eslint --fix` applies +// it. There is no line-length limit: lines are as long as reads well. +const LAYOUT = { + "@stylistic/array-bracket-newline": ["error", "consistent"], + "@stylistic/array-bracket-spacing": ["error", "never"], + "@stylistic/arrow-parens": ["error", "always"], + "@stylistic/arrow-spacing": "error", + "@stylistic/block-spacing": "error", + "@stylistic/brace-style": ["error", "1tbs", { allowSingleLine: true }], + "@stylistic/comma-dangle": ["error", "always-multiline"], + "@stylistic/comma-spacing": "error", + "@stylistic/comma-style": "error", + "@stylistic/computed-property-spacing": "error", + "@stylistic/dot-location": ["error", "property"], + "@stylistic/eol-last": "error", + "@stylistic/function-call-spacing": "error", + "@stylistic/generator-star-spacing": ["error", "after"], + "@stylistic/indent": ["error", 2, { flatTernaryExpressions: true }], + "@stylistic/key-spacing": "error", + "@stylistic/keyword-spacing": "error", + "@stylistic/linebreak-style": ["error", "unix"], + "@stylistic/new-parens": "error", + "@stylistic/no-extra-semi": "error", + "@stylistic/no-floating-decimal": "error", + "@stylistic/no-mixed-spaces-and-tabs": "error", + "@stylistic/no-multi-spaces": "error", + "@stylistic/no-multiple-empty-lines": ["error", { max: 1, maxBOF: 0, maxEOF: 0 }], + "@stylistic/no-tabs": "error", + "@stylistic/no-trailing-spaces": "error", + "@stylistic/no-whitespace-before-property": "error", + "@stylistic/object-curly-newline": ["error", { consistent: true }], + "@stylistic/object-curly-spacing": ["error", "always"], + "@stylistic/operator-linebreak": ["error", "after", { overrides: { "?": "before", ":": "before" } }], + "@stylistic/padded-blocks": ["error", "never"], + "@stylistic/quote-props": ["error", "as-needed"], + "@stylistic/quotes": ["error", "double", { avoidEscape: true, allowTemplateLiterals: "avoidEscape" }], + "@stylistic/rest-spread-spacing": "error", + "@stylistic/semi": ["error", "always"], + "@stylistic/semi-spacing": "error", + "@stylistic/space-before-blocks": "error", + "@stylistic/space-before-function-paren": ["error", { anonymous: "always", named: "never", asyncArrow: "always" }], + "@stylistic/space-in-parens": "error", + "@stylistic/space-infix-ops": "error", + "@stylistic/space-unary-ops": "error", + "@stylistic/spaced-comment": ["error", "always"], + "@stylistic/switch-colon-spacing": "error", + "@stylistic/template-curly-spacing": "error", + "@stylistic/wrap-iife": ["error", "inside"], + "@stylistic/yield-star-spacing": ["error", "after"], +}; + export default [ { ignores: [ @@ -32,6 +155,13 @@ export default [ { linterOptions: { reportUnusedDisableDirectives: "error" }, }, + { rules: STRICTER }, + { + // extension/spec/spec.js is generated (spec/tools/sync-extension.mjs) as compact JSON. + ignores: ["extension/spec/spec.js"], + plugins: { "@stylistic": stylistic }, + rules: LAYOUT, + }, { // Extension code runs as classic scripts in the browser (content scripts, the // background worker, the popup). @@ -69,9 +199,14 @@ export default [ }, { // Callbacks passed to page.evaluate() run in the browser. Playwright fixtures must - // destructure their first argument, even when it's empty. + // destructure their first argument, even when it's empty. Such a callback can't see + // the test's variables, so it takes them as an argument under the same name + // (`page.evaluate((id) => ..., id)`): shadowing is the idiom there, not a mistake. files: ["test/e2e/**/*.mjs", "test/visual/**/*.mjs", "test/helpers/axe.mjs", "test/helpers/a11y-checks.mjs", "site/tests/**/*.spec.mjs"], languageOptions: { globals: { ...globals.node, ...globals.browser, ...globals.webextensions } }, - rules: { "no-empty-pattern": ["error", { allowObjectPatternsAsParameters: true }] }, + rules: { + "no-empty-pattern": ["error", { allowObjectPatternsAsParameters: true }], + "no-shadow": "off", + }, }, ]; diff --git a/extension/background.js b/extension/background.js index 610899e..8870f18 100644 --- a/extension/background.js +++ b/extension/background.js @@ -554,7 +554,7 @@ async function pronunciationPass() { wiktionaryPass ??= globalThis.KotikoWiktionaryPass.createPass({ store: await getStore(), pronounce: Pronounce, - fetchPage: (t) => wiktionaryPage(t, { maxWaitMs: 10_000 }), + fetchPage: (title) => wiktionaryPage(title, { maxWaitMs: 10_000 }), cache: pageCache, enabled: async () => (await home()) === "local", }); @@ -1699,7 +1699,7 @@ async function redoWord(id, key) { const records = (await jobWords(id, key)).filter((r) => r.result === "created" && r.undo === "done"); if (!records.length) return { ok: true }; const results = await writeOps(records.map((r) => ({ op: "restore", id: r.wordId }))); - const again = records.filter((r, i) => !results[i].ok && (results[i].code === "word_gone" || results[i].code === "word_conflict")); + const again = records.filter((_r, i) => !results[i].ok && (results[i].code === "word_gone" || results[i].code === "word_conflict")); const failed = results.find((r) => !r.ok && r.code !== "word_gone" && r.code !== "word_conflict"); if (failed) return { error: failed.message ?? failed.code, code: failed.code, details: failed.details ?? {} }; if (again.length) { @@ -1720,7 +1720,7 @@ ext.commands?.onCommand?.addListener((command, tab) => { if (command !== "reveal-word") return; const send = (id) => Promise.resolve(ext.tabs.sendMessage(id, { type: "reveal-word" })).catch(() => {}); if (tab?.id) send(tab.id); - else Promise.resolve(ext.tabs.query({ active: true, currentWindow: true })).then(([t]) => t?.id && send(t.id), () => {}); + else Promise.resolve(ext.tabs.query({ active: true, currentWindow: true })).then(([active]) => active?.id && send(active.id), () => {}); }); // The address and token of a 0.2 install are adopted once, by the storage upgrade diff --git a/extension/bulk/parse.js b/extension/bulk/parse.js index 1778e1e..f3c3086 100644 --- a/extension/bulk/parse.js +++ b/extension/bulk/parse.js @@ -91,15 +91,26 @@ for (let i = 0; i < text.length; i++) { const c = text[i]; if (quoted) { - if (c === '"' && text[i + 1] === '"') (cell += '"'), i++; - else if (c === '"') quoted = false; + if (c === '"' && text[i + 1] === '"') { + cell += '"'; + i++; + } else if (c === '"') quoted = false; else cell += c; } else if (c === '"' && cell === "") quoted = true; - else if (c === sep) row.push(cell), (cell = ""); - else if (c === "\n") row.push(cell), rows.push(row), (row = []), (cell = ""); - else cell += c; + else if (c === sep) { + row.push(cell); + cell = ""; + } else if (c === "\n") { + row.push(cell); + rows.push(row); + row = []; + cell = ""; + } else cell += c; + } + if (cell !== "" || row.length) { + row.push(cell); + rows.push(row); } - if (cell !== "" || row.length) row.push(cell), rows.push(row); return rows.filter((r) => r.some((x) => x.trim())); } diff --git a/extension/content/engine.js b/extension/content/engine.js index 8512176..c4f3a3e 100644 --- a/extension/content/engine.js +++ b/extension/content/engine.js @@ -249,7 +249,7 @@ if (r && r.sig === signature(p.items)) { // The same words in the same places: nothing to write, but the word behind each // (an edited pronunciation, a new note) is the current one. - r.nodes.filter(isMark).forEach((el, i) => p.items[i] && info.set(el, { ...p.items[i].info, T: p.T })); + for (const [i, el] of r.nodes.filter(isMark).entries()) if (p.items[i]) info.set(el, { ...p.items[i].info, T: p.T }); continue; } if (r) restore(p.T); diff --git a/extension/content/popover.js b/extension/content/popover.js index 0a2a7a0..97a04ff 100644 --- a/extension/content/popover.js +++ b/extension/content/popover.js @@ -68,7 +68,7 @@ if (!m) return s === "transparent" ? [0, 0, 0, 0] : null; let a = m[4] === undefined ? 1 : parseFloat(m[4]); if (m[4]?.endsWith("%")) a /= 100; - return [+m[1], +m[2], +m[3], a]; + return [Number(m[1]), Number(m[2]), Number(m[3]), a]; } // WCAG relative luminance of an sRGB color (0-255 channels). diff --git a/extension/dashboard.js b/extension/dashboard.js index cd5e2a6..70439f6 100644 --- a/extension/dashboard.js +++ b/extension/dashboard.js @@ -2062,10 +2062,9 @@ function confirmDialog({ text, confirm }) { return new Promise((resolve) => { - let d; const yes = el("button", { class: "btn btn-danger-fill", type: "button", onclick: () => { d.close(true); } }, confirm); const no = el("button", { class: "btn btn-secondary", type: "button", onclick: () => d.close(false) }, t("dash_cancel")); - d = dialog({ title: text, body: null, actions: [no, yes], onClose: (v) => resolve(v === true) }); + const d = dialog({ title: text, body: null, actions: [no, yes], onClose: (v) => resolve(v === true) }); no.focus(); }); } @@ -2127,14 +2126,13 @@ } function showShortcuts() { - const rows = [ + const shortcuts = [ ["/", "dash_key_search"], ["↑ ↓", "dash_key_move"], [t("dash_kbd_enter"), "dash_key_open"], [t("dash_kbd_space"), "dash_key_toggle"], [`${t("dash_kbd_shift")} + ↑ ↓`, "dash_key_extend"], ["Ctrl/⌘ + A", "dash_key_select_all"], [t("dash_kbd_delete"), "dash_key_delete"], ["P", "dash_key_pause"], ["M", "dash_key_move_lang"], ["N", "dash_key_add"], ["Esc", "dash_key_escape"], ["Ctrl/⌘ + Z", "dash_key_undo"], ["?", "dash_key_help"], ]; - let d; - const list = el("dl", { class: "keys" }, rows.map(([k, key]) => [el("dt", {}, el("kbd", {}, k)), el("dd", {}, t(key))])); - d = dialog({ title: t("dash_keys_title"), body: list, actions: [el("button", { class: "btn btn-primary", type: "button", onclick: () => d.close(null) }, t("dash_done"))] }); + const list = el("dl", { class: "keys" }, shortcuts.map(([k, key]) => [el("dt", {}, el("kbd", {}, k)), el("dd", {}, t(key))])); + const d = dialog({ title: t("dash_keys_title"), body: list, actions: [el("button", { class: "btn btn-primary", type: "button", onclick: () => d.close(null) }, t("dash_done"))] }); d.card.querySelector(".btn").focus(); } @@ -2304,7 +2302,7 @@ renderWords(); } - async function undoAdd(job, entry) { + async function undoAdd(entry) { entry.undo = "pending"; renderAddJobs(); const ids = [...state.records.values()].filter((r) => r.lang === entry.word.lang && M.nativeKey(r.native) === M.nativeKey(entry.word.native)).map((r) => r.id); @@ -2341,7 +2339,7 @@ entry.undo === "done" ? el("span", { class: "add-job-text" }, I18n.parts("undo_done_created", { native: bdi(entry.word.native, entry.word.lang) })) : [el("span", { class: "add-job-text" }, I18n.parts("dash_add_added", { word: candidateLine(entry.word) })), - el("button", { class: "btn btn-quiet btn-sm", type: "button", "aria-disabled": entry.undo === "pending" ? "true" : null, "aria-label": t("add_undo_label", { native: entry.word.native }), onclick: () => entry.undo !== "pending" && undoAdd(job, entry) }, icon("undo", 16), t("add_undo"))])); + el("button", { class: "btn btn-quiet btn-sm", type: "button", "aria-disabled": entry.undo === "pending" ? "true" : null, "aria-label": t("add_undo_label", { native: entry.word.native }), onclick: () => entry.undo !== "pending" && undoAdd(entry) }, icon("undo", 16), t("add_undo"))])); } if (job.known?.length) { items.push(el("li", { class: "add-job is-known" }, el("span", { class: "add-job-text" }, I18n.parts("add_already_known", { words: el("span", {}, job.known.map((n, i) => [i ? ", " : "", bdi(n, null, "word")])) })))); @@ -2503,7 +2501,7 @@ renderSegmented($("mixingMode"), [["balanced", "dash_mixing_balanced"], ["mix", "dash_mixing_mix"], ["priority", "dash_mixing_priority"]], m.mode, (v) => setMixing({ mode: v })); const langs = [...new Set(state.groups.map((g) => g.lang))]; const order = [...m.priority.filter((l) => langs.includes(l)), ...langs.filter((l) => !m.priority.includes(l))]; - const rows = (m.mode === "priority" ? order : langs).map((lang, i) => { + const langRows = (m.mode === "priority" ? order : langs).map((lang, i) => { const id = `weight-${lang}`; const control = m.mode === "priority" ? el("button", { class: "btn btn-quiet btn-sm", type: "button", disabled: i === 0 ? "" : null, "aria-label": t("dash_priority_up", { language: languageName(lang) }), onclick: () => setMixing({ priority: [...order.slice(0, i - 1), lang, order[i - 1], ...order.slice(i + 1)] }) }, icon("up", 14)) @@ -2511,7 +2509,7 @@ if (m.mode !== "priority") renderSegmented(control, WEIGHTS, String(m.weights[lang] ?? 1), (v) => setMixing({ weights: { ...m.weights, [lang]: Number(v) } })); return el("li", { class: "mixing-lang" }, el("span", { id, class: "field-label" }, m.mode === "priority" ? `${i + 1}. ${languageName(lang)}` : languageName(lang)), control); }); - $("mixingLangs").replaceChildren(...rows); + $("mixingLangs").replaceChildren(...langRows); $("freshFirst").setAttribute("aria-checked", String(m.freshDays > 0)); } @@ -2671,7 +2669,7 @@ if (!baseFocus && $("baseList").contains(active)) baseFocus = { lang: active.closest(".base-row")?.dataset.lang, action: active.dataset.action ?? null }; const list = bases(); const number = new Intl.NumberFormat(I18n.locale()); - const rows = list.map((tag, i) => { + const baseRows = list.map((tag, i) => { const name = languageName(tag); const endo = endonym(tag); const full = Bases.levelOf(tag) === "full"; @@ -2716,9 +2714,9 @@ baseButton("remove", t("dash_base_remove", { language: name }), false, () => removeBase(tag)))); return row; }); - $("baseList").replaceChildren(...rows); + $("baseList").replaceChildren(...baseRows); if (baseFocus) { - const row = rows.find((r) => r.dataset.lang === baseFocus.lang); + const row = baseRows.find((r) => r.dataset.lang === baseFocus.lang); const buttons = row ? [...row.querySelectorAll("button[data-action]")] : []; (buttons.find((b) => b.dataset.action === baseFocus.action && !b.disabled) ?? buttons.find((b) => !b.disabled))?.focus(); baseFocus = null; diff --git a/extension/data-tools.js b/extension/data-tools.js index e8a9c27..9ce6092 100644 --- a/extension/data-tools.js +++ b/extension/data-tools.js @@ -75,11 +75,11 @@ const { words = [] } = await send({ type: "words.list" }); const doc = B().exportDoc({ words, settings: settings ? await settingsNow() : null, version: version(), now: nowMs() }); const name = F().filename("backup", nowMs()); - const done = download(B().stringify(doc), name, "application/json"); + const saved = download(B().stringify(doc), name, "application/json"); await send({ type: "backup.saved" }).catch(() => {}); ctx.refresh(); ctx.toast({ text: t("data_saved_file", { file: name }) }); - if (wait) await done; + if (wait) await saved; return { name, count: doc.words.length }; } @@ -121,7 +121,6 @@ ...locales.map((l) => [l, display(l)]).sort((a, b) => a[1].localeCompare(b[1], ui)).map(([l, label]) => el("option", { value: l, selected: l === pick }, label)), el("option", { value: "" }, t("data_anki_elsewhere"))); select.value = pick; - let d; const go = el("button", { class: "btn btn-primary", type: "button", "data-action": "anki-download", onclick: () => { const name = F().filename("anki", nowMs()); const notetype = select.value ? names[select.value] ?? null : null; @@ -129,7 +128,7 @@ d.close(true); ctx.toast({ text: t("data_saved_file", { file: name }) }); } }, t("data_anki_download")); - d = ctx.dialog({ + const d = ctx.dialog({ title: t("data_anki_title"), body: el("div", { class: "data-dialog" }, el("label", { class: "field-label", for: "ankiLocale" }, t("data_anki_locale")), @@ -205,7 +204,7 @@ if (c.deletedLater) { rows.push(el("label", { class: "check-row" }, el("input", { type: "checkbox", checked: st.restoreDeleted, "data-action": "restore-deleted", onchange: (e) => repreview(e.target.checked) }), t("data_restore_deleted", { count: c.deletedLater }))); } - if (read.settings) rows.push(el("label", { class: "check-row" }, el("input", { type: "checkbox", checked: st.settings, "data-action": "restore-settings", onchange: (e) => (st.settings = e.target.checked, render()) }), t("data_restore_settings"))); + if (read.settings) rows.push(el("label", { class: "check-row" }, el("input", { type: "checkbox", checked: st.settings, "data-action": "restore-settings", onchange: (e) => { st.settings = e.target.checked; render(); } }), t("data_restore_settings"))); if (needPron && preview.home === "local") { rows.push(el("label", { class: "check-row" }, el("input", { type: "checkbox", checked: st.pron, "data-action": "restore-pronunciations", onchange: (e) => (st.pron = e.target.checked) }), t("data_restore_pron", { count: needPron, lookups: fmt(Math.ceil(needPron / RESPELL_BATCH)) }))); } @@ -244,7 +243,6 @@ render(); } - let d; go.addEventListener("click", async () => { st.busy = true; err.hidden = true; @@ -269,7 +267,7 @@ render(); } }); - d = ctx.dialog({ + const d = ctx.dialog({ title: t("data_restore_title", { file: filename || "kotiko-backup.json" }), body: el("div", { class: "data-dialog" }, el("p", { class: "num" }, t("data_restore_in_file", { count: read.total })), counts, notice, extra, err), actions: [el("button", { class: "btn btn-secondary", type: "button", onclick: () => d.close(false) }, t("dash_cancel")), go], diff --git a/extension/lib/add-queue.js b/extension/lib/add-queue.js index 6e00f42..67315d8 100644 --- a/extension/lib/add-queue.js +++ b/extension/lib/add-queue.js @@ -122,7 +122,7 @@ retryAt: null, seen: false, }; - const saved = await serial(async () => { + const stored = await serial(async () => { const jobs = await read(); const existing = jobs.find((j) => j.id === job.id); if (existing) return existing; @@ -130,7 +130,7 @@ return job; }); kick(); - return saved; + return stored; } const due = (j) => j.state === "queued" || (j.state === "waiting" && j.retryAt !== null && j.retryAt <= now()); diff --git a/extension/lib/controls.js b/extension/lib/controls.js index 34f2757..33e4ded 100644 --- a/extension/lib/controls.js +++ b/extension/lib/controls.js @@ -101,7 +101,7 @@ // no cheap signal: computed styles, from the top down let v = false; for (let i = chain.length - 1; i >= 0; i--) { - v = v || isPointerControl(chain[i], cursorOf); + v ||= isPointerControl(chain[i], cursorOf); cache.set(chain[i], v); } return v; diff --git a/extension/lib/i18n.js b/extension/lib/i18n.js index 3681f97..86c320a 100644 --- a/extension/lib/i18n.js +++ b/extension/lib/i18n.js @@ -31,9 +31,9 @@ // { locale, messages, fallback } while an override is active. let override = null; - let loader = async (locale) => { - const res = await fetch(ext.runtime.getURL(`_locales/${locale}/messages.json`)); - if (!res.ok) throw new Error(`No messages for ${locale}`); + let loader = async (tag) => { + const res = await fetch(ext.runtime.getURL(`_locales/${tag}/messages.json`)); + if (!res.ok) throw new Error(`No messages for ${tag}`); return res.json(); }; diff --git a/extension/lib/precedence.js b/extension/lib/precedence.js index 448b335..a256ef5 100644 --- a/extension/lib/precedence.js +++ b/extension/lib/precedence.js @@ -153,8 +153,8 @@ if (/^[-\u2010\u2011]|[-\u2010\u2011]$/.test(w.native ?? "")) continue; ok.push(w); } - const page = plain(surface, base, keyOf); - const real = ok.filter((w) => plainWord(w, base) !== page); + const shown = plain(surface, base, keyOf); + const real = ok.filter((w) => plainWord(w, base) !== shown); return { words: real, known: ok.length > 0 && real.length === 0 }; } diff --git a/extension/lib/store.js b/extension/lib/store.js index 70da5db..4e96304 100644 --- a/extension/lib/store.js +++ b/extension/lib/store.js @@ -185,24 +185,24 @@ async function liveCount(words) { const total = await request(words.count()); if (total < MAX_VOCABULARY) return total; - const all = await request(words.getAll()); - return all.filter((w) => !w.deleted_at && w.status !== "pending").length; + const records = await request(words.getAll()); + return records.filter((w) => !w.deleted_at && w.status !== "pending").length; } // Saves checked words: inserts each, or merges it into the live word holding its // natural key (slice 07). With `jobId`, the whole application is recorded so a second // one returns the first one's results without writing (slice 24's idempotency). async function upsertByNatural(input, { explicit = true, origin = "add", jobId = null, by = null } = {}) { - const list = Array.isArray(input) ? input : [input]; + const items = Array.isArray(input) ? input : [input]; const out = await tx(["words", "jobs"], "readwrite", async ({ words, jobs }) => { if (jobId) { const done = await request(jobs.get(jobId)); if (done) return { results: done.results, applied: true }; } const t = now(); - let count = null; + let live = null; const results = []; - for (const w of list) { + for (const w of items) { const existing = await liveByNatural(words, [w.lang, Merge.nativeKey(w.native), w.sense ?? "", w.base_lang]); if (existing) { const changes = Merge.changes(existing, { status: "active", ...w }, { explicit }); @@ -215,13 +215,13 @@ results.push({ result: "updated", word: pub(next), previous: pub(existing) }); continue; } - count ??= await liveCount(words); - if (count >= MAX_VOCABULARY) throw codedError("vocabulary_full", `At most ${MAX_VOCABULARY} words.`, { max: MAX_VOCABULARY }); + live ??= await liveCount(words); + if (live >= MAX_VOCABULARY) throw codedError("vocabulary_full", `At most ${MAX_VOCABULARY} words.`, { max: MAX_VOCABULARY }); let id = typeof w.id === "string" && UUID.test(w.id) ? w.id : null; if (id && (await request(words.get(id)))) id = null; const rec = record(w, { id: id ?? uuid7(t), nowMs: t, origin }); await request(words.add(rec)); - count++; + live++; results.push({ result: "created", word: pub(rec), previous: null }); } if (jobId) await request(jobs.put({ id: jobId, results, at: iso(t) })); @@ -237,9 +237,9 @@ // Live words (active and paused by default), newest first, as slice 07 records. async function list({ statuses = LIVE_STATUSES } = {}) { - const all = await tx("words", "readonly", ({ words }) => request(words.getAll())); + const records = await tx("words", "readonly", ({ words }) => request(words.getAll())); const want = new Set(statuses); - return all + return records .filter((w) => !w.deleted_at && want.has(w.status)) .sort((a, b) => (a.created_at < b.created_at ? 1 : a.created_at > b.created_at ? -1 : a.id < b.id ? 1 : -1)) .map(pub); diff --git a/extension/lib/word-search.js b/extension/lib/word-search.js index 019c59b..cb53316 100644 --- a/extension/lib/word-search.js +++ b/extension/lib/word-search.js @@ -19,7 +19,7 @@ // marks and Indic vowel signs are letters for search, so they stay. const MARKS = /[\u0300-\u036f\u064b-\u065f\u0670\u0591-\u05c7]/g; // Letters NFKD doesn't decompose that learners type without the stroke. - const EXTRA = { "ł": "l", "ø": "o", "đ": "d", "ħ": "h", "ı": "i", "ŀ": "l", "ß": "ss", "æ": "ae", "œ": "oe" }; + const EXTRA = { ł: "l", ø: "o", đ: "d", ħ: "h", ı: "i", ŀ: "l", ß: "ss", æ: "ae", œ: "oe" }; const EXTRA_RE = /[łøđħıŀßæœ]/g; // Languages whose lowercase differs from the default (Turkish dotless i, Lithuanian dots). const LOCALE_CASE = new Set(["tr", "az", "lt", "crh", "tt", "ba"]); @@ -112,7 +112,7 @@ if (!s || !q) return []; let folded = ""; const origin = []; // folded index -> [start, end) of the code point it came from - for (let i = 0; i < s.length; ) { + for (let i = 0; i < s.length;) { const cp = s.codePointAt(i); const len = cp > 0xffff ? 2 : 1; const f = fold(s.slice(i, i + len), lang); diff --git a/extension/lib/wordspec.js b/extension/lib/wordspec.js index 0ff4a8e..8755c38 100644 --- a/extension/lib/wordspec.js +++ b/extension/lib/wordspec.js @@ -57,7 +57,7 @@ return cut + "…"; } - const ROMANIAN = { "ş": "ș", "Ş": "Ș", "ţ": "ț", "Ţ": "Ț" }; + const ROMANIAN = { ş: "ș", Ş: "Ș", ţ: "ț", Ţ: "Ț" }; const romanian = (s) => (s === null ? s : s.replace(/[şŞţŢ]/g, (c) => ROMANIAN[c])); // Trailing .,!?;:。、!? and leading ¿¡ (¿perro? is perro). @@ -85,9 +85,9 @@ function langData(base) { const key = String(base); if (dataCache.has(key)) return dataCache.get(key); - const candidates = [key, primary(key)].filter((f, i, all) => all.indexOf(f) === i); + const tags = [key, primary(key)].filter((f, i, all) => all.indexOf(f) === i); const find = (kind, generic) => { - for (const f of generic ? [...candidates, "_generic"] : candidates) { + for (const f of generic ? [...tags, "_generic"] : tags) { if (FOLDERS[f]?.[kind] !== undefined) return f; } return null; diff --git a/extension/popup-more.js b/extension/popup-more.js index c464a9f..353a8a2 100644 --- a/extension/popup-more.js +++ b/extension/popup-more.js @@ -103,7 +103,7 @@ if (!chosen) return t("manual_need_language"); const own = bases.find((b) => primary(b) === primary(chosen)); if (own) return t("manual_same_base", { base: langName(own) }); - for (const [m, b] of meanings.map(([m], i) => [m, bases[i]])) { + for (const [m, b] of meanings.map((row, i) => [row[0], bases[i]])) { for (const f of m.value.split(/\s*[,、,]\s*/u).filter(Boolean)) if ([...f].length < (SHORT_FORMS.has(primary(b)) ? 1 : 2)) return t("manual_too_short", { form: f }); } return null; diff --git a/extension/popup.js b/extension/popup.js index dac23ea..700b140 100644 --- a/extension/popup.js +++ b/extension/popup.js @@ -576,8 +576,7 @@ } async function setHint(value) { - if (value === "*") value = (await (await more()).pickLanguage({ anchor: $("hintLang"), first: myLangs() })) ?? state.hint; - state.hint = value || null; + state.hint = (value === "*" ? (await (await more()).pickLanguage({ anchor: $("hintLang"), first: myLangs() })) ?? state.hint : value) || null; renderHint(); ext.storage.session?.set({ addHint: state.hint }).catch(() => {}); } @@ -620,14 +619,14 @@ for (const [key, rs] of groups) { rs.sort((a, b) => order(a) - order(b)); const live = rs.filter((r) => r.result !== "unchanged"); - const undo = state.undos.get(`${j.id}:${key}`) ?? (live.some((r) => r.undo === "failed") ? "failed" : live.some((r) => r.undo === "pending") ? "pending" : live.length && live.every((r) => r.undo === "done") ? "done" : null); + const u = state.undos.get(`${j.id}:${key}`) ?? (live.some((r) => r.undo === "failed") ? "failed" : live.some((r) => r.undo === "pending") ? "pending" : live.length && live.every((r) => r.undo === "done") ? "done" : null); const k = `${j.id}:${key}`; v.words.push({ key, records: rs, word: rs[0].word, result: ["created", "updated", "unchanged"].find((x) => rs.some((r) => r.result === x)), - undo, + undo: u, undoError: live.find((r) => r.undoError)?.undoError ?? null, fresh: !state.doneAtOpen.has(j.id) && !state.rendered.has(k), }); @@ -1073,9 +1072,9 @@ if (state.checking) return box.replaceChildren(el("p", { class: "conn-checking" }, t("settings_checking"))); if (!hasToken(s)) return box.replaceChildren(el("p", { class: "conn-checking" }, t("settings_not_connected"))); - const problem = syncProblem(s.syncError, wordTotal(s.words)); - if (problem) { - return box.replaceChildren(banner({ ...problem, actions: problem.actions.filter((a) => a !== "settings") }, () => syncNow())); + const issue = syncProblem(s.syncError, wordTotal(s.words)); + if (issue) { + return box.replaceChildren(banner({ ...issue, actions: issue.actions.filter((a) => a !== "settings") }, () => syncNow())); } if (!s.lastSync) return box.replaceChildren(el("p", { class: "conn-checking" }, t("settings_not_connected"))); box.replaceChildren(el("p", { class: "conn-ok" }, icon("success", 18), diff --git a/extension/ui/tools/popover-tokens.mjs b/extension/ui/tools/popover-tokens.mjs index 172c484..f5e9d42 100644 --- a/extension/ui/tools/popover-tokens.mjs +++ b/extension/ui/tools/popover-tokens.mjs @@ -58,7 +58,7 @@ export function declarations(body) { return [...stripComments(body).matchAll(/(--[\w-]+)\s*:\s*([^;]+);/g)].map((m) => [m[1], m[2].replace(/\s+/g, " ").trim()]); } -export const remToPx = (value) => value.replace(/(\d*\.?\d+)rem\b/g, (_m, n) => `${+(Number(n) * 16).toFixed(3)}px`); +export const remToPx = (value) => value.replace(/(\d*\.?\d+)rem\b/g, (_m, n) => `${Number((Number(n) * 16).toFixed(3))}px`); // The token sets of tokens.css. export function readTokens(css = fs.readFileSync(TOKENS_CSS, "utf8")) { diff --git a/extension/welcome.js b/extension/welcome.js index 3837646..0d7755b 100644 --- a/extension/welcome.js +++ b/extension/welcome.js @@ -251,9 +251,10 @@ }; input.addEventListener("input", render); input.addEventListener("keydown", (e) => { - if (e.key === "ArrowDown") (e.preventDefault(), move(1)); - else if (e.key === "ArrowUp") (e.preventDefault(), move(-1)); - else if (e.key === "Enter") { + if (e.key === "ArrowDown" || e.key === "ArrowUp") { + e.preventDefault(); + move(e.key === "ArrowDown" ? 1 : -1); + } else if (e.key === "Enter") { e.preventDefault(); if (active >= 0) onPick(options[active].tag); } else if (e.key === "Escape") { @@ -815,7 +816,7 @@ $("confirm")?.removeAttribute("aria-disabled"); return setResult(t("welcome_save_failed")); } - const saved = results.map((r) => r.word).filter((w) => w && w.native) ; + const saved = results.map((r) => r.word).filter((w) => w && w.native); state.saved = saved.length ? saved : words; state.hadWords = true; await finishOnboarding(false); diff --git a/package-lock.json b/package-lock.json index 0b2c495..2bb0be0 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,6 +9,7 @@ "@devicefarmer/adbkit": "file:scripts/no-adbkit", "@eslint/js": "10.0.1", "@playwright/test": "1.63.0", + "@stylistic/eslint-plugin": "5.10.0", "axe-core": "4.13.0", "chrome-webstore-upload": "6.0.1", "chrome-webstore-upload-cli": "4.0.2", @@ -719,6 +720,58 @@ "node": ">=12" } }, + "node_modules/@stylistic/eslint-plugin": { + "version": "5.10.0", + "resolved": "https://registry.npmjs.org/@stylistic/eslint-plugin/-/eslint-plugin-5.10.0.tgz", + "integrity": "sha512-nPK52ZHvot8Ju/0A4ucSX1dcPV2/1clx0kLcH5wDmrE4naKso7TUC/voUyU1O9OTKTrR6MYip6LP0ogEMQ9jPQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/eslint-utils": "^4.9.1", + "@typescript-eslint/types": "^8.56.0", + "eslint-visitor-keys": "^4.2.1", + "espree": "^10.4.0", + "estraverse": "^5.3.0", + "picomatch": "^4.0.3" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "peerDependencies": { + "eslint": "^9.0.0 || ^10.0.0" + } + }, + "node_modules/@stylistic/eslint-plugin/node_modules/eslint-visitor-keys": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-4.2.1.tgz", + "integrity": "sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/@stylistic/eslint-plugin/node_modules/espree": { + "version": "10.4.0", + "resolved": "https://registry.npmjs.org/espree/-/espree-10.4.0.tgz", + "integrity": "sha512-j6PAQ2uUr79PZhBjP5C5fhl8e39FmRnOjsD5lGnWrFU8i2G776tBK7+nP8KuQUTTyAZUwfQqXAgrVH5MbH9CYQ==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "acorn": "^8.15.0", + "acorn-jsx": "^5.3.2", + "eslint-visitor-keys": "^4.2.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, "node_modules/@types/esrecurse": { "version": "4.3.1", "resolved": "https://registry.npmjs.org/@types/esrecurse/-/esrecurse-4.3.1.tgz", @@ -757,6 +810,20 @@ "undici-types": "~8.9.0" } }, + "node_modules/@typescript-eslint/types": { + "version": "8.71.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.71.1.tgz", + "integrity": "sha512-ybYnPTUwg3VQQOFEiBPLs8Jxl1ry2vXdn1kVKguomBbvxpetdu3MKTNDqFNUrlG8ylP9MpZosmXMFYlGQHk1oQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, "node_modules/acorn": { "version": "8.18.0", "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.18.0.tgz", @@ -3868,6 +3935,19 @@ "dev": true, "license": "ISC" }, + "node_modules/picomatch": { + "version": "4.0.7", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.7.tgz", + "integrity": "sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, "node_modules/pino": { "version": "10.3.1", "resolved": "https://registry.npmjs.org/pino/-/pino-10.3.1.tgz", diff --git a/package.json b/package.json index 95e34e0..fb70b64 100644 --- a/package.json +++ b/package.json @@ -10,12 +10,14 @@ "test": "node --test 'test/{unit,dom,bg}/**/*.test.mjs'", "e2e": "playwright test", "lint": "eslint . && web-ext lint --source-dir extension --self-hosted", + "lint:fix": "eslint . --fix", "perf": "node test/perf/run.mjs" }, "devDependencies": { "@devicefarmer/adbkit": "file:scripts/no-adbkit", "@eslint/js": "10.0.1", "@playwright/test": "1.63.0", + "@stylistic/eslint-plugin": "5.10.0", "axe-core": "4.13.0", "chrome-webstore-upload": "6.0.1", "chrome-webstore-upload-cli": "4.0.2", diff --git a/scripts/bestpractices-links.mjs b/scripts/bestpractices-links.mjs index 181dda0..50a9bb8 100644 --- a/scripts/bestpractices-links.mjs +++ b/scripts/bestpractices-links.mjs @@ -80,12 +80,13 @@ export function parse(text) { continue; } if (row.every((c) => /^-+$/.test(c))) continue; - const at = (name) => row[header.indexOf(name)] ?? ""; + const cell = {}; + for (let i = 0; i < header.length; i++) cell[header[i]] = row[i] ?? ""; group.rows.push({ - criterion: at("Criterion").replace(/`/g, ""), - level: at("Level"), - status: at("Status"), - answer: at("Answer"), + criterion: cell.Criterion.replace(/`/g, ""), + level: cell.Level ?? "", + status: cell.Status, + answer: cell.Answer, }); } return { project, groups: groups.filter((g) => g.rows.length) }; diff --git a/scripts/check-old-name.mjs b/scripts/check-old-name.mjs index 553fb5e..8c5c2d9 100644 --- a/scripts/check-old-name.mjs +++ b/scripts/check-old-name.mjs @@ -86,7 +86,7 @@ if (found.length) { console.error(`The old name is still used in ${found.length} place(s):\n`); for (const f of found) console.error(` ${f}`); console.error( - `\nUse Kotiko. If a line must name the old one (compatibility or migration code), add a ` + + "\nUse Kotiko. If a line must name the old one (compatibility or migration code), add a " + `comment containing ${MARKER} on it or on the comment line just above it.`, ); process.exit(1); diff --git a/scripts/sync-story.mjs b/scripts/sync-story.mjs index 255c6d7..9d5abd9 100755 --- a/scripts/sync-story.mjs +++ b/scripts/sync-story.mjs @@ -32,7 +32,7 @@ export function extractStory(spec) { const start = lines.findIndex((l) => l.startsWith(HEADING)); if (start < 0) throw new Error(`${SOURCE} has no ${HEADING} line`); const paragraphs = []; - let current = []; + const current = []; let seen = false; for (const line of lines.slice(start + 1)) { if (!line.startsWith(">")) { diff --git a/server/.credo.exs b/server/.credo.exs index 0eb20f3..cd9a901 100644 --- a/server/.credo.exs +++ b/server/.credo.exs @@ -1,13 +1,16 @@ # SPDX-FileCopyrightText: 2026 ScriptKittyOS and the Kotiko contributors # SPDX-License-Identifier: Apache-2.0 -# Credo's default checks, with two limits relaxed for code that reads best as one flat -# list: the bot's command dispatch and the transcriber's response handling. +# Credo's default checks in strict mode (low-priority issues count too, so a plain +# `mix credo` reports the same as CI's `mix credo --strict`), with two limits relaxed for +# code that reads best as one flat list: the bot's command dispatch and the transcriber's +# response handling. %{ configs: [ %{ name: "default", files: %{included: ["lib/", "test/", "config/", "mix.exs"], excluded: []}, + strict: true, checks: %{ extra: [ {Credo.Check.Refactor.CyclomaticComplexity, max_complexity: 17}, diff --git a/server/lib/kotiko/application.ex b/server/lib/kotiko/application.ex index 50f9b8b..2b74372 100644 --- a/server/lib/kotiko/application.ex +++ b/server/lib/kotiko/application.ex @@ -23,6 +23,7 @@ defmodule Kotiko.Application do require Logger alias Kotiko.{Config, DataDir, Exposure, Token} alias Kotiko.Log.Redact + alias Kotiko.Plug.HostCheck @impl true def start(_type, _args) do @@ -31,7 +32,7 @@ defmodule Kotiko.Application do DataDir.resolve_and_migrate!() token_source = load_token() Redact.put_secrets(Redact.configured_secrets()) - Kotiko.Plug.HostCheck.init_table() + HostCheck.init_table() words = Kotiko.Migrations.run!() http = http_settings() log_summary(token_source, words, http) diff --git a/server/lib/kotiko/health.ex b/server/lib/kotiko/health.ex index a26996a..4a9759e 100644 --- a/server/lib/kotiko/health.ex +++ b/server/lib/kotiko/health.ex @@ -7,6 +7,7 @@ defmodule Kotiko.Health do speaks, and whether the database works. The route has no token, so this says nothing else (no word counts, no settings). """ + alias Ecto.Adapters.SQL # Versions of the /api/v routes. Empty until /api/v1 exists (slice 07); the # unversioned /api/words routes are implied while they exist. @@ -54,7 +55,7 @@ defmodule Kotiko.Health do task = Task.async(fn -> try do - match?({:ok, _}, Ecto.Adapters.SQL.query(repo, "SELECT 1", [], timeout: @db_timeout)) + match?({:ok, _}, SQL.query(repo, "SELECT 1", [], timeout: @db_timeout)) rescue _ -> false catch diff --git a/server/lib/kotiko/janitor.ex b/server/lib/kotiko/janitor.ex index 173fe3a..f2347f8 100644 --- a/server/lib/kotiko/janitor.ex +++ b/server/lib/kotiko/janitor.ex @@ -16,6 +16,7 @@ defmodule Kotiko.Janitor do use GenServer require Logger alias Kotiko.{AddRequests, Words} + alias Kotiko.LLM.Cache @first_run :timer.minutes(5) @every :timer.hours(24) @@ -46,7 +47,7 @@ defmodule Kotiko.Janitor do scrubbed: Words.scrub_tombstones(now), purged: Words.purge_tombstones(now), add_requests: AddRequests.purge(now), - lookup_cache: Kotiko.LLM.Cache.prune(now) + lookup_cache: Cache.prune(now) } if result.scrubbed + result.purged > 0 do diff --git a/server/lib/kotiko/lang.ex b/server/lib/kotiko/lang.ex index a0f66d8..83455b6 100644 --- a/server/lib/kotiko/lang.ex +++ b/server/lib/kotiko/lang.ex @@ -245,7 +245,7 @@ defmodule Kotiko.Lang do Enum.uniq(Enum.filter(code, &Map.has_key?(@langs, &1)) ++ Enum.sort(named)) end - @doc "The primary language subtag of a tag: \"pt-BR\" -> \"pt\"." + @doc ~s(The primary language subtag of a tag: "pt-BR" -> "pt".) def primary(tag) when is_binary(tag), do: tag |> String.split("-") |> hd() # ── parsing (slice 08 section 2, steps 1-10) ───────────────────────── diff --git a/server/lib/kotiko/llm/cache.ex b/server/lib/kotiko/llm/cache.ex index 28bb35e..4888ef7 100644 --- a/server/lib/kotiko/llm/cache.ex +++ b/server/lib/kotiko/llm/cache.ex @@ -21,7 +21,7 @@ defmodule Kotiko.LLM.Cache do use GenServer import Ecto.Query require Logger - alias Kotiko.{Repo, Spec, Word, WordSpec, Words} + alias Kotiko.{Repo, Spec, Word, Words, WordSpec} @prompt_hash :crypto.hash(:sha256, Spec.prompt_text()) |> Base.encode16(case: :lower) diff --git a/server/lib/kotiko/router_v1.ex b/server/lib/kotiko/router_v1.ex index 079074e..0242860 100644 --- a/server/lib/kotiko/router_v1.ex +++ b/server/lib/kotiko/router_v1.ex @@ -22,10 +22,12 @@ defmodule Kotiko.RouterV1 do Spec, UUID7, Word, - WordSpec, - Words + Words, + WordSpec } + alias Kotiko.LLM.Quota + plug :match plug :dispatch @@ -372,7 +374,7 @@ defmodule Kotiko.RouterV1 do # would ask now and the last lookup's result. Answers from memory: no model call, and # at most one quota refresh in the background. get "/llm/status" do - Kotiko.LLM.Quota.maybe_refresh() + Quota.maybe_refresh() json(conn, 200, Kotiko.LLM.status()) end diff --git a/server/test/kotiko/llm/catalog_test.exs b/server/test/kotiko/llm/catalog_test.exs index 92f92c7..751643f 100644 --- a/server/test/kotiko/llm/catalog_test.exs +++ b/server/test/kotiko/llm/catalog_test.exs @@ -5,8 +5,8 @@ defmodule Kotiko.LLM.CatalogTest do # Slice 10 section 1: which models, in what order. The /models answer is # test/fixtures/openrouter/models-2026-10-01.json (17 free models, 6 with JSON mode). use Kotiko.DataCase, async: false - alias Kotiko.LLMStub alias Kotiko.LLM.Catalog + alias Kotiko.LLMStub @moduletag :capture_log @models Path.expand("../../../../test/fixtures/openrouter/models-2026-10-01.json", __DIR__) diff --git a/server/test/kotiko/llm/quota_test.exs b/server/test/kotiko/llm/quota_test.exs index 152427e..afdf70b 100644 --- a/server/test/kotiko/llm/quota_test.exs +++ b/server/test/kotiko/llm/quota_test.exs @@ -4,8 +4,8 @@ defmodule Kotiko.LLM.QuotaTest do # Slice 10 section 3: the free lookups left today, from a stubbed GET /key. use Kotiko.DataCase, async: false - alias Kotiko.LLMStub alias Kotiko.LLM.{Quota, Slots} + alias Kotiko.LLMStub @moduletag :capture_log diff --git a/server/test/kotiko/pronunciation_refresh_test.exs b/server/test/kotiko/pronunciation_refresh_test.exs index 1da3ede..17a1828 100644 --- a/server/test/kotiko/pronunciation_refresh_test.exs +++ b/server/test/kotiko/pronunciation_refresh_test.exs @@ -5,6 +5,7 @@ defmodule Kotiko.PronunciationRefreshTest do # Slice 07 section 8, with the model stubbed and the clock passed in. use Kotiko.DataCase, async: false import ExUnit.CaptureLog + alias Kotiko.LLM.Quota alias Kotiko.{LLMStub, PronunciationRefresh} @respellings %{{"ja", "en"} => "ee-noo", {"ja", "es"} => "i-nu"} @@ -159,17 +160,17 @@ defmodule Kotiko.PronunciationRefreshTest do start_job() stub_model() put_app_env(:llm_url, "https://openrouter.ai/api/v1") - Kotiko.LLM.Quota.put(%{"free_model_daily_requests" => %{"remaining" => 10}}) - resets = Kotiko.LLM.Quota.next_midnight(DateTime.utc_now()) + Quota.put(%{"free_model_daily_requests" => %{"remaining" => 10}}) + resets = Quota.next_midnight(DateTime.utc_now()) assert {:wait, ^resets} = PronunciationRefresh.step() assert %{state: "waiting", retry_at: retry_at} = PronunciationRefresh.status() - assert retry_at == Kotiko.Word.timestamp(resets) + assert retry_at == Word.timestamp(resets) assert {:wait, again} = PronunciationRefresh.step() assert DateTime.compare(again, resets) == :eq assert requests() == [] - Kotiko.LLM.Quota.put(%{"free_model_daily_requests" => %{"remaining" => 40}}) + Quota.put(%{"free_model_daily_requests" => %{"remaining" => 40}}) assert {:continue, 1} = PronunciationRefresh.step(now: DateTime.add(resets, 1, :second)) end @@ -182,7 +183,7 @@ defmodule Kotiko.PronunciationRefreshTest do LLMStub.status(conn, 429, "Rate limit exceeded: free-models-per-day") end) - midnight = Kotiko.LLM.Quota.next_midnight(DateTime.utc_now()) + midnight = Quota.next_midnight(DateTime.utc_now()) assert {:wait, ^midnight} = PronunciationRefresh.step(now: now) assert {:wait, _} = PronunciationRefresh.step(now: now) # OpenRouter's own limit is shared by every free model: no second model is asked. diff --git a/server/test/kotiko/router_v1_test.exs b/server/test/kotiko/router_v1_test.exs index ea97831..afe2c08 100644 --- a/server/test/kotiko/router_v1_test.exs +++ b/server/test/kotiko/router_v1_test.exs @@ -5,6 +5,7 @@ defmodule Kotiko.RouterV1Test do # Slice 07's /api/v1 routes and the legacy /api/words routes a 0.2 extension uses. use Kotiko.ConnCase, async: false import ExUnit.CaptureLog + alias Kotiko.LLM.Quota alias Kotiko.{LLMStub, UUID7, Word, Words} @inu_es %{ @@ -343,7 +344,7 @@ defmodule Kotiko.RouterV1Test do put_app_env(:llm_models, ["a:free"]) LLMStub.stub(fn _, _ -> raise "no model call expected" end) - Kotiko.LLM.Quota.put(%{ + Quota.put(%{ "free_model_daily_requests" => %{"used" => 50, "limit" => 50, "remaining" => 0} }) diff --git a/server/test/mix/tasks/kotiko.token_test.exs b/server/test/mix/tasks/kotiko.token_test.exs index 7f800f4..7e1699c 100644 --- a/server/test/mix/tasks/kotiko.token_test.exs +++ b/server/test/mix/tasks/kotiko.token_test.exs @@ -3,8 +3,8 @@ defmodule Mix.Tasks.Kotiko.TokenTest do use ExUnit.Case, async: false - alias Mix.Tasks.Kotiko.Token, as: Task alias Kotiko.Token + alias Mix.Tasks.Kotiko.Token, as: Task # A fresh folder under the system temp dir per test, removed afterwards. setup do diff --git a/server/test/support/data_case.ex b/server/test/support/data_case.ex index 8351ba1..bf310aa 100644 --- a/server/test/support/data_case.ex +++ b/server/test/support/data_case.ex @@ -8,6 +8,7 @@ defmodule Kotiko.DataCase do `async: false`; the shared sandbox then also covers processes the test starts. """ use ExUnit.CaseTemplate + alias Ecto.Adapters.SQL alias Ecto.Adapters.SQL.Sandbox using do @@ -29,7 +30,7 @@ defmodule Kotiko.DataCase do # The sandbox's transaction is deferred, unlike the server's (immediate): a test that # reads and then writes would get "Database busy" at once if the previous test's # connection hadn't rolled back yet. Taking the write lock first waits for it instead. - Ecto.Adapters.SQL.query!( + SQL.query!( Kotiko.Repo, "UPDATE sync_state SET last_seq = last_seq WHERE id = 1" ) diff --git a/server/test/support/llm_stub.ex b/server/test/support/llm_stub.ex index 1dfa219..6d93c07 100644 --- a/server/test/support/llm_stub.ex +++ b/server/test/support/llm_stub.ex @@ -16,12 +16,13 @@ defmodule Kotiko.LLMStub do Options: `key:` the `/key` answer's `free_model_daily_requests` (a map) or a function of the conn; `models:` the `/models` answer's `data` (a list) or a function of the conn. """ + alias Kotiko.LLM.{Catalog, Quota} alias Plug.Conn def stub(fun, opts \\ []) do test = self() - Kotiko.LLM.Catalog.reset(entries: true) - Kotiko.LLM.Quota.reset() + Catalog.reset(entries: true) + Quota.reset() Req.Test.stub(Kotiko.LLM, fn %{method: "GET", request_path: path} = conn -> diff --git a/test/bg/background.test.mjs b/test/bg/background.test.mjs index 1e7381c..6671ca8 100644 --- a/test/bg/background.test.mjs +++ b/test/bg/background.test.mjs @@ -99,7 +99,7 @@ async function requested(fake, requests, n) { await sleep(10); } -const serverWith = (words) => stubFetch((url, init) => { +const serverWith = (words) => stubFetch((_url, init) => { if ((init.method ?? "GET") === "GET") return json(200, { words }); return json(404, { error: "No such route." }); }); @@ -261,7 +261,7 @@ describe("the learner's languages on the server (slice 41 §9)", () => { test("a send that fails is tried again at the next sync; an older server isn't asked again", async () => { let answer = () => json(500, { error: { code: "internal", message: "x", details: {} } }); - const { fetch, profile } = stubFetch((url, init) => ((init.method ?? "GET") === "GET" ? json(200, { words: WORDS }) : json(404, {})), { onProfile: () => answer() }); + const { fetch, profile } = stubFetch((_url, init) => ((init.method ?? "GET") === "GET" ? json(200, { words: WORDS }) : json(404, {})), { onProfile: () => answer() }); const { send, fake } = loadBackground({ fetch }); assert.deepEqual(await send({ type: "profile.sync" }, PAGE), { ok: false, code: "internal" }); answer = () => json(200, {}); @@ -432,7 +432,7 @@ describe("add and remove relay", () => { }); test("offline: a lookup that can't reach anything waits as offline, not as a busy model", async () => { - const { fetch } = stubFetch((url, init) => (init.method === "POST" ? Promise.reject(new TypeError("Failed to fetch")) : json(200, { words: WORDS }))); + const { fetch } = stubFetch((_url, init) => (init.method === "POST" ? Promise.reject(new TypeError("Failed to fetch")) : json(200, { words: WORDS }))); const { send, store, fake } = loadBackground({ fetch, globals: { navigator: { onLine: false } } }); await send({ type: "add", id: UUID, text: "x" }, POPUP); const job = await settled(fake, store, UUID); @@ -450,7 +450,7 @@ describe("add and remove relay", () => { } if (init.method === "POST") { remaining--; - return json(200, { words: [] , reply: "nothing" }); + return json(200, { words: [], reply: "nothing" }); } return json(200, { words: WORDS }); }); @@ -577,7 +577,7 @@ describe("sync correctness (slice 26)", () => { }); test("F11: saving a new token during an in-flight sync uses the new token", async () => { - const { fetch } = stubFetch(async (url, init) => { + const { fetch } = stubFetch(async (_url, init) => { const bad = init.headers.Authorization.endsWith("bad"); await sleep(bad ? 150 : 5); return bad ? new Response("unauthorized", { status: 401 }) : json(200, { words: WORDS }); diff --git a/test/bg/backup.test.mjs b/test/bg/backup.test.mjs index f58ec35..2306186 100644 --- a/test/bg/backup.test.mjs +++ b/test/bg/backup.test.mjs @@ -152,13 +152,13 @@ describe("delete everything (§6)", () => { test("with the server box ticked: the server's words are gone and its reset_epoch goes up; unticked, untouched", async () => { srv.reset(); const bg = await full({ server: true }); - const before = srv.state.v1.length; - assert.ok(before > 0); + const count = srv.state.v1.length; + assert.ok(count > 0); await bg.send({ type: "data.deleteAll", confirm: "delete-everything" }); - assert.equal(srv.state.v1.length, before, "unticked: the server is untouched"); + assert.equal(srv.state.v1.length, count, "unticked: the server is untouched"); const bg2 = await full({ server: true }); const res = await bg2.send({ type: "data.deleteAll", confirm: "delete-everything", server: true }); - assert.deepEqual(res.server, { deleted: before, reset_epoch: 1 }); + assert.deepEqual(res.server, { deleted: count, reset_epoch: 1 }); assert.equal(srv.state.v1.length, 0); assert.deepEqual(plain(bg2.fake.store.local), {}); }); diff --git a/test/bg/dashboard-api.test.mjs b/test/bg/dashboard-api.test.mjs index e3ad064..3fc02fd 100644 --- a/test/bg/dashboard-api.test.mjs +++ b/test/bg/dashboard-api.test.mjs @@ -92,13 +92,13 @@ describe("dashboard routes against the fixture server", () => { test("preview saves nothing; save adds; the refresh job pauses and resumes", async () => { srv.reset(); const { send } = bg(); - const before = (await send({ type: "words.list" })).words.length; + const count = (await send({ type: "words.list" })).words.length; const preview = await send({ type: "words.preview", text: "kniga", base_langs: ["en"] }); assert.equal(preview.candidates[0].native, "книга"); - assert.equal((await send({ type: "words.list" })).words.length, before, "nothing saved before the learner accepts"); + assert.equal((await send({ type: "words.list" })).words.length, count, "nothing saved before the learner accepts"); const saved = await send({ type: "words.save", words: preview.candidates, client_request_id: "0190a1b2-c3d4-7000-8000-000000000001" }); assert.equal(saved.results[0].result, "created"); - assert.equal((await send({ type: "words.list" })).words.length, before + 1); + assert.equal((await send({ type: "words.list" })).words.length, count + 1); srv.state.job = { state: "running", done: 40, total: 120 }; assert.deepEqual(await send({ type: "job.refresh" }), { state: "running", done: 40, total: 120 }); diff --git a/test/bg/local-mode.test.mjs b/test/bg/local-mode.test.mjs index d1284ea..ac909d3 100644 --- a/test/bg/local-mode.test.mjs +++ b/test/bg/local-mode.test.mjs @@ -363,8 +363,8 @@ describe("upgrading an existing install (slice 11 §8)", () => { test("a server install stays a server install: same words on pages, same requests, the token leaves storage.local", async () => { srv.reset(); await fetch(`${srv.url}/__control`, { method: "POST", body: JSON.stringify({ words: LEGACY_WORDS }) }); - const before = { serverUrl: srv.kotikoUrl, token: srv.token, words: LEGACY_WORDS, enabled: true, hiddenLangs: [], lastSync: 1 }; - const bg = loadBackground({ local: before }); + const legacy = { serverUrl: srv.kotikoUrl, token: srv.token, words: LEGACY_WORDS, enabled: true, hiddenLangs: [], lastSync: 1 }; + const bg = loadBackground({ local: legacy }); bg.fake.fireInstalled({ reason: "update" }); await bg.k.ready(); await bg.until(() => bg.store.lastSync > 1); @@ -481,7 +481,7 @@ describe("moving words (slice 11 §6)", () => { const bg = loadBackground({ local: { serverUrl: srv.kotikoUrl, token: srv.token } }); await bg.k.ready(); await bg.send({ type: "sync", force: true }, POPUP); - const before = bg.store.words.map((w) => w.native).sort(); + const natives = bg.store.words.map((w) => w.native).sort(); assert.deepEqual(await bg.send({ type: "migrate.preview", to: "local" }), { to: "local", count: 2, server: srv.kotikoUrl }); const res = await bg.send({ type: "migrate.run", to: "local", forget: true }); assert.deepEqual(res, { ok: true, total: 2 }); @@ -492,7 +492,7 @@ describe("moving words (slice 11 §6)", () => { assert.deepEqual(ids, [uuidFor(1), uuidFor(2)]); assert.deepEqual(bg.store.baseLangs.slice().sort(), ["en", "es"]); assert.deepEqual(bg.store.words.map((w) => w.native).sort(), ["спасибо", "犬"]); - assert.ok(before.includes("спасибо")); + assert.ok(natives.includes("спасибо")); // The server's data is untouched. assert.equal(srv.state.v1.filter((w) => !w.deleted_at).length, 2); }); diff --git a/test/bg/privacy.test.mjs b/test/bg/privacy.test.mjs index 548b2dc..9d0b38f 100644 --- a/test/bg/privacy.test.mjs +++ b/test/bg/privacy.test.mjs @@ -270,10 +270,10 @@ describe("a new install keeps nothing a page planted before its first start (sli assert.deepEqual(await store.secrets.ids(), []); assert.deepEqual(await store.list(), []); // From then on nothing goes to the planted address. - const before = requests.length; + const seen = requests.length; await bg.send({ type: "sync", force: true }, CONTENT); await bg.fake.idle(); await sleep(50); - assert.deepEqual(requests.slice(before).filter((r) => r.url.includes(":6666")), []); + assert.deepEqual(requests.slice(seen).filter((r) => r.url.includes(":6666")), []); }); }); diff --git a/test/dom/content.test.mjs b/test/dom/content.test.mjs index d13ce75..057ba42 100644 --- a/test/dom/content.test.mjs +++ b/test/dom/content.test.mjs @@ -99,7 +99,7 @@ describe("swapping", () => { }); test("one language for every occurrence of a word; the original word and the others stay out of the page DOM", async () => { - const { doc, spans } = await load(`

thanks thanks thanks

Many thanks.

`); + const { doc, spans } = await load("

thanks thanks thanks

Many thanks.

"); assert.equal(spans().length, 4); assert.equal(new Set(spans().map((s) => s.lang)).size, 1, "slice 18: one language per page per day"); assert.ok(spans().every((s) => !s.hasAttribute("title") && !Object.keys(s.dataset).length)); @@ -130,13 +130,13 @@ describe("swapping", () => { test("never puts the word list into markup", async () => { const evil = word("", "house", ["house"], "ru", { note: "note" }); - const { doc, spans } = await load(`

house

`, { words: [evil] }); + const { doc, spans } = await load("

house

", { words: [evil] }); assert.equal(doc.querySelector("img"), null); assert.equal(spans()[0].textContent, ""); }); test("asks the background for a sync on load", async () => { - const { fake } = await load(`

house

`); + const { fake } = await load("

house

"); assert.deepEqual(fake.calls.sendMessage, [{ type: "sync" }]); }); }); @@ -167,13 +167,13 @@ describe("screen readers and keyboards (slice 27 §2)", () => { }); test("both: the word, then the original, each in its own language", async () => { - const { spans } = await load(`

thanks for the house

`, { words: [WORDS[0]], prefs: { screenReader: "both" } }); + const { spans } = await load("

thanks for the house

", { words: [WORDS[0]], prefs: { screenReader: "both" } }); assert.deepEqual(heard(spans()[0]), ["дом@ru", ", ", "house@en"]); }); test("a Spanish page: the hidden original is tagged Spanish", async () => { const perro = word("dog", "perro", ["perro"], "en", { base_lang: "es" }); - const { spans } = await load(`

el perro duerme

`, { words: [perro], lang: "es", baseLangs: ["es"], prefs: { screenReader: "original" } }); + const { spans } = await load("

el perro duerme

", { words: [perro], lang: "es", baseLangs: ["es"], prefs: { screenReader: "original" } }); assert.equal(spans()[0].lang, "en"); assert.deepEqual(heard(spans()[0]), ["perro@es"]); }); @@ -534,9 +534,9 @@ describe("pages in the learner's languages", () => { }); test("the popup can ask what Kotiko made of the page", async () => { - const es = await load(`

Mi perro

`, { lang: "es", words: ES, baseLangs: ["es"] }); + const es = await load("

Mi perro

", { lang: "es", words: ES, baseLangs: ["es"] }); assert.deepEqual(await es.fake.deliver({ type: "page-status" }, POPUP), { base: "es", reason: "declared", lang: "es", words: 3 }); - const de = await load(`

Mein Hund

`, { lang: "de", words: ES, baseLangs: ["es"] }); + const de = await load("

Mein Hund

", { lang: "de", words: ES, baseLangs: ["es"] }); assert.deepEqual(await de.fake.deliver({ type: "page-status" }, POPUP), { base: null, reason: "declared_other", lang: "de", words: 0 }); assert.equal(await de.fake.deliver({ type: "page-status" }), undefined, "never to another tab's content script"); }); @@ -608,7 +608,7 @@ describe("framework-safe swapping (slice 15)", () => { }); test("F25: a replaced body is swapped too", async () => { - const { doc, $ } = await load(`

old house

`); + const { doc, $ } = await load("

old house

"); const next = doc.createElement("body"); next.innerHTML = `

A new house after navigation.

`; doc.documentElement.replaceChild(next, doc.body); @@ -856,7 +856,7 @@ describe("language precedence (slice 18)", () => { }); test("adding an unrelated word changes nothing on the page", async () => { - const { doc, dom, set, fake } = await load(`

many thanks, my house

thanks again

`); + const { doc, dom, set, fake } = await load("

many thanks, my house

thanks again

"); let writes = 0; new dom.window.MutationObserver((r) => (writes += r.length)).observe(doc.body, { subtree: true, childList: true, characterData: true, attributes: true }); const words = await fake.chrome.storage.local.get("words"); @@ -883,7 +883,7 @@ describe("language precedence (slice 18)", () => { }); test("hiding the winning language moves only that word", async () => { - const page = await load(`

thanks

`); + const page = await load("

thanks

"); const winner = langs(page.spans)[0]; await page.set({ hiddenLangs: [winner] }); assert.notEqual(langs(page.spans)[0], winner); @@ -903,7 +903,7 @@ describe("language precedence (slice 18)", () => { }); test("nothing about the page's address is stored", async () => { - const { fake } = await load(`

thanks

`, { url: "https://news.example.org/story?id=7" }); + const { fake } = await load("

thanks

", { url: "https://news.example.org/story?id=7" }); assert.doesNotMatch(JSON.stringify(fake.store), /news\.example|story|id=7/); }); }); diff --git a/test/dom/dashboard.test.mjs b/test/dom/dashboard.test.mjs index 9364a7c..2985685 100644 --- a/test/dom/dashboard.test.mjs +++ b/test/dom/dashboard.test.mjs @@ -1377,4 +1377,3 @@ describe("bulk add (slice 13)", () => { assert.equal(d.$('[data-action="save"]').disabled, true); }); }); - diff --git a/test/dom/popover.test.mjs b/test/dom/popover.test.mjs index 068552a..7e50c76 100644 --- a/test/dom/popover.test.mjs +++ b/test/dom/popover.test.mjs @@ -226,7 +226,7 @@ describe("the card's content (19 §1, §1a)", () => { test("a Latin-script target's label sits on its own line", async () => { const gracias = { id: 40, lang: "es", native: "gracias", pronunciation: "GRA-syas", pronunciation_source: "model", english: "thanks", forms: ["thanks"] }; - const p = await load({ html: `

thanks

`, words: [gracias] }); + const p = await load({ html: "

thanks

", words: [gracias] }); await p.hover("gracias"); assert.deepEqual(visibleLines(p.shadow()), ["gracias", "GRA-syas", "AI-generated", "Spanish · español", "thanks"]); }); @@ -485,7 +485,7 @@ describe("speak button (34)", () => { test("hidden when no voice exists for the language (Thai on Linux)", async () => { const thai = { id: 50, lang: "th", native: "ขอบคุณ", romanization: "khop khun", english: "thanks", forms: ["thanks"] }; - const p = await load({ html: `

thanks

`, words: [thai], voices: voiceLists()["linux-speech-dispatcher"] }); + const p = await load({ html: "

thanks

", words: [thai], voices: voiceLists()["linux-speech-dispatcher"] }); await p.hover("ขอบคุณ"); assert.equal(p.shadow().querySelector(".k-speak").hidden, true); p.key(p.doc.body, "s"); diff --git a/test/dom/popup.test.mjs b/test/dom/popup.test.mjs index 0ff5461..abf8c3f 100644 --- a/test/dom/popup.test.mjs +++ b/test/dom/popup.test.mjs @@ -26,7 +26,6 @@ const WORDS = [ ]; const CONNECTED = { serverUrl: "http://127.0.0.1:4999", token: "t0ken", lastSync: Date.UTC(2026, 9, 1, 11, 58), syncError: null }; - async function openPopup({ local = {}, session = {}, voices = null, locale = "en", tabUrl = `https://${HOST}/wiki/Cat`, permission = true, answer = () => ({ ok: true }), pageStatus = null } = {}) { const fake = createFakeChrome({ local: { words: [], ...local }, @@ -360,7 +359,7 @@ describe("adding words (D, slice 24)", () => { const ref = {}; const jobs = () => ref.p.fake.chrome.storage.local.get({ addJobs: [] }).then((s) => s.addJobs); const put = async (id, fn) => ref.p.fake.chrome.storage.local.set({ addJobs: (await jobs()).map((j) => (j.id === id ? fn(j) : j)) }); - const keyOf = (w) => `${w.lang}\u001f${w.native}`; + const keyOf = (word) => `${word.lang}\u001f${word.native}`; const answer = (msg) => { if (msg.type === "add") { const r = outcome(msg); @@ -380,7 +379,7 @@ describe("adding words (D, slice 24)", () => { return { answer, ref }; } const word = (id, lang, native, gloss, extra = {}) => ({ id, lang, native, gloss, base_lang: "en", forms: [gloss], ...extra }); - const rec = (w, result = "created", extra = {}) => ({ wordId: w.id, baseLang: w.base_lang, result, word: w, previous: null, undo: null, ...extra }); + const rec = (saved, result = "created", extra = {}) => ({ wordId: saved.id, baseLang: saved.base_lang, result, word: saved, previous: null, undo: null, ...extra }); async function add(text, outcome, local = {}) { const bg = background(outcome); const p = await openPopup({ local: { ...CONNECTED, words: WORDS, ...local }, answer: bg.answer }); @@ -615,7 +614,6 @@ describe("adding words (D, slice 24)", () => { assert.ok(!asked.includes("llmStatus")); assert.ok(!q.visible("#lookupsLeft")); }); - }); describe("languages", () => { diff --git a/test/helpers/a11y-checks.mjs b/test/helpers/a11y-checks.mjs index a6393a5..8e8e72e 100644 --- a/test/helpers/a11y-checks.mjs +++ b/test/helpers/a11y-checks.mjs @@ -93,7 +93,7 @@ export async function tabWalk(page, { max = 250 } = {}) { // A transition started by the focus change (a skip link sliding in) ends first. const running = document.getAnimations().filter((a) => a.playState === "running" && Number.isFinite(a.effect?.getComputedTiming?.().endTime)); await Promise.race([Promise.all(running.map((a) => a.finished.catch(() => {}))), new Promise((r) => setTimeout(r, 1000))]); - let el = document.activeElement; while (el?.shadowRoot?.activeElement) el = el.shadowRoot.activeElement; + let el = document.activeElement; while (el?.shadowRoot?.activeElement) el = el.shadowRoot.activeElement; if (!el || el === document.body || el === document.documentElement) return null; // A composite widget with aria-activedescendant (the word grid) may draw its ring on // the active item instead of itself. diff --git a/test/helpers/fake-chrome.mjs b/test/helpers/fake-chrome.mjs index dea8427..150de23 100644 --- a/test/helpers/fake-chrome.mjs +++ b/test/helpers/fake-chrome.mjs @@ -91,10 +91,9 @@ export function createFakeChrome(options = {}) { api: { async get(keys) { if (keys == null) return clone(data); - if (typeof keys === "string") keys = [keys]; - if (Array.isArray(keys)) { + if (typeof keys === "string" || Array.isArray(keys)) { const out = {}; - for (const k of keys) if (k in data) out[k] = clone(data[k]); + for (const k of [keys].flat()) if (k in data) out[k] = clone(data[k]); return out; } const out = {}; @@ -194,8 +193,9 @@ export function createFakeChrome(options = {}) { onStartup: createEvent(), }, alarms: { - async create(name, info = {}) { - if (typeof name === "object") [name, info] = ["", name]; + // create(name, info) or create(info), as in chrome.alarms. + async create(...args) { + const [name, info = {}] = typeof args[0] === "object" ? ["", args[0]] : args; calls.alarms.push({ name, info: clone(info) }); const delay = info.delayInMinutes ?? info.periodInMinutes ?? 0; alarms.set(name, { diff --git a/test/perf/run.mjs b/test/perf/run.mjs index 687d36a..aca19e9 100644 --- a/test/perf/run.mjs +++ b/test/perf/run.mjs @@ -163,7 +163,6 @@ benchmarks["wordcard.cardFor.x1000"] = () => { n += cardFor(w, { all: POPOVER_WORDS, others: [] }).also.length; } return `${n}`; // not a swap count - }; benchmarks["speak.pickVoice.x1000"] = () => { const langs = ["ru", "zh", "zh-Hant", "yue", "ja", "ar", "en", "es", "sr-Latn", "th"]; @@ -275,10 +274,10 @@ benchmarks["precedence.pick.100k"] = { }, run: ({ P, keyOf, words, entries, E }) => { const counts = {}; - for (let page = 0; page < 100; page++) { - const s = P.createSession({ salt: "bench", pageKey: `https://example.com/${page}`, dayKey: "2026-10-04", eligible: E, words, keyOf, now: Date.parse("2026-10-04T00:00:00Z") }); + for (let n = 0; n < 100; n++) { + const s = P.createSession({ salt: "bench", pageKey: `https://example.com/${n}`, dayKey: "2026-10-04", eligible: E, words, keyOf, now: Date.parse("2026-10-04T00:00:00Z") }); for (let i = 0; i < 1000; i++) { - const entry = entries[(page * 1000 + i) % entries.length]; + const entry = entries[(n * 1000 + i) % entries.length]; const c = s.choose({ base: "en", entry, surface: entry.key }); counts[c.lang] = (counts[c.lang] ?? 0) + 1; } diff --git a/test/unit/backup.test.mjs b/test/unit/backup.test.mjs index b59670a..18fc2dd 100644 --- a/test/unit/backup.test.mjs +++ b/test/unit/backup.test.mjs @@ -81,11 +81,13 @@ function manyRecords(n = 2000) { async function storeWith(records, now) { const store = await openStore(now); await store.tx("words", "readwrite", async ({ words }) => { - for (const r of records) await new Promise((res, rej) => { - const q = words.put({ ...r, native_key: L.Merge.nativeKey(r.native) }); - q.onsuccess = res; - q.onerror = () => rej(q.error); - }); + for (const r of records) { + await new Promise((res, rej) => { + const q = words.put({ ...r, native_key: L.Merge.nativeKey(r.native) }); + q.onsuccess = res; + q.onerror = () => rej(q.error); + }); + } }); return store; } diff --git a/test/unit/build-extension.test.mjs b/test/unit/build-extension.test.mjs index b8d9cdc..f448df2 100644 --- a/test/unit/build-extension.test.mjs +++ b/test/unit/build-extension.test.mjs @@ -46,8 +46,8 @@ function fixture(manifest = BASE_MANIFEST, extra = {}) { "ext/_locales/en/messages.json": "{}", "ext/Zeta.js": "// capital Z sorts before lowercase\n", "ext/.buildignore": "# comment\nui/tools/\n*.txt~\n", - "LICENSE": "license\n", - "NOTICE": "notice\n", + LICENSE: "license\n", + NOTICE: "notice\n", "LICENSES/Apache-2.0.txt": "apache\n", ...extra, }; diff --git a/test/unit/bulk-parse.test.mjs b/test/unit/bulk-parse.test.mjs index 40306ba..5e6956a 100644 --- a/test/unit/bulk-parse.test.mjs +++ b/test/unit/bulk-parse.test.mjs @@ -41,8 +41,8 @@ describe("one line (§3)", () => { } test("parentheses: a romanization, or a respelling as the pronunciation", async () => { - const { rows } = await parse("спасибо (spasibo) = thanks\nспасибо (spa-SEE-ba) = thanks", { base: "en", target: "ru" }); - assert.deepEqual(rows.map((r) => [r.romanization, r.pronunciation]), [["spasibo", null], [null, "spa-SEE-ba"]]); + const { rows: parsed } = await parse("спасибо (spasibo) = thanks\nспасибо (spa-SEE-ba) = thanks", { base: "en", target: "ru" }); + assert.deepEqual(parsed.map((r) => [r.romanization, r.pronunciation]), [["spasibo", null], [null, "spa-SEE-ba"]]); }); test("hyphens inside words never split; a spaced dash does; 10:30 isn't split at the colon", async () => { diff --git a/test/unit/llm-client.test.mjs b/test/unit/llm-client.test.mjs index b5be4be..1a6719f 100644 --- a/test/unit/llm-client.test.mjs +++ b/test/unit/llm-client.test.mjs @@ -67,7 +67,7 @@ describe("presets (spec/providers.json): URL, headers and JSON mode", () => { assert.deepEqual(L.spec.providers.providers.map((p) => p.id), cases.map((c) => c[0])); assert.equal(L.spec.providers.default, "openrouter"); }); - for (const [id, baseUrl, base, headers, json] of cases) { + for (const [id, baseUrl, base, headers, jsonMode] of cases) { test(`${id}: one add, the right request`, async () => { const keyless = id === "ollama" || id === "lmstudio"; const s = stub({ models: (req) => json200Models(id, req) }); @@ -80,7 +80,7 @@ describe("presets (spec/providers.json): URL, headers and JSON mode", () => { assert.equal(chat.method, "POST"); for (const [k, v] of Object.entries(headers)) assert.equal(chat.headers[k], v, k); assert.equal(chat.headers.Authorization, keyless ? undefined : `Bearer ${KEY}`); - assert.equal("response_format" in chat.body, json, "response_format"); + assert.equal("response_format" in chat.body, jsonMode, "response_format"); assert.equal(chat.body.messages[0].role, "system"); assert.match(chat.body.messages[0].content, /English/, "the prompt names the base"); assert.equal(chat.body.messages[1].content, "shukran"); @@ -212,7 +212,7 @@ describe("failures become slice 25 codes", () => { test("new settings abort a running attempt: the caller gets an AbortError and retries", async () => { const ctl = new AbortController(); - const fetch = (url, init) => new Promise((_, reject) => init.signal.addEventListener("abort", () => reject(Object.assign(new Error("aborted"), { name: "AbortError" })))); + const fetch = (_url, init) => new Promise((_, reject) => init.signal.addEventListener("abort", () => reject(Object.assign(new Error("aborted"), { name: "AbortError" })))); const p = client({ provider: "openai", model: "m", fetch }).lookup(REQ, { signal: ctl.signal }); ctl.abort(); await assert.rejects(p, { name: "AbortError" }); diff --git a/test/unit/local-store.test.mjs b/test/unit/local-store.test.mjs index f443771..7bbea71 100644 --- a/test/unit/local-store.test.mjs +++ b/test/unit/local-store.test.mjs @@ -173,7 +173,7 @@ describe("the projection for content scripts", () => { }); describe("the add queue", () => { - function queue({ lookup, save = async (job, words) => words.map((w) => ({ result: "created", word: { ...w, id: `id-${w.native}` } })), now = () => Date.now(), isFunctionWord } = {}) { + function queue({ lookup, save = async (_job, words) => words.map((w) => ({ result: "created", word: { ...w, id: `id-${w.native}` } })), now = () => Date.now(), isFunctionWord } = {}) { const fake = createFakeChrome(); const settled = []; // Timers that don't keep the test process alive (a job may wait an hour). @@ -248,7 +248,7 @@ describe("the add queue", () => { test("new settings abort a running lookup; it runs again, without counting the aborted try", async () => { let n = 0; const { q, job, until } = queue({ - lookup: (j, signal) => (n++ === 0 ? new Promise((_, reject) => signal.addEventListener("abort", () => reject(Object.assign(new Error("a"), { name: "AbortError" })))) : Promise.resolve(found("дом"))), + lookup: (_job, signal) => (n++ === 0 ? new Promise((_, reject) => signal.addEventListener("abort", () => reject(Object.assign(new Error("a"), { name: "AbortError" })))) : Promise.resolve(found("дом"))), }); await q.add({ id: "a1", text: "dom" }); await until(() => job("a1")?.state === "looking_up"); @@ -281,7 +281,7 @@ describe("the add queue", () => { test("resume: a job left looking up by a stopped worker is queued again with the same id", async () => { const fake = createFakeChrome({ local: { addJobs: [{ id: "r1", text: "x", state: "looking_up", createdAt: 1, attempts: 1, waits: 0, results: [], baseLangs: ["en"] }] } }); - const q = L.Queue.createAddQueue({ storage: fake.chrome.storage.local, lookup: async () => found("икс"), save: async (j, w) => w.map((x) => ({ result: "created", word: { ...x, id: "i" } })) }); + const q = L.Queue.createAddQueue({ storage: fake.chrome.storage.local, lookup: async () => found("икс"), save: async (_job, w) => w.map((x) => ({ result: "created", word: { ...x, id: "i" } })) }); await q.resume(); for (let i = 0; i < 100 && fake.store.local.addJobs[0].state !== "done"; i++) { await fake.idle(); diff --git a/test/unit/store-readiness.test.mjs b/test/unit/store-readiness.test.mjs index 6cfc9f6..84d6be0 100644 --- a/test/unit/store-readiness.test.mjs +++ b/test/unit/store-readiness.test.mjs @@ -210,8 +210,8 @@ describe("the listing (slice 28 §8)", () => { "single purpose": /### Single purpose\n\n> ([\s\S]*?)\n\n/.exec(CWS)[1], }; for (const [name, text] of Object.entries(texts)) assert.doesNotMatch(text.replace(ALLOWED, ""), /\bEnglish\b/, name); - const en = JSON.parse(read("extension/_locales/en/messages.json")); - for (const k of ["extStoreName", "extDescription"]) assert.doesNotMatch(en[k].message, /English/, k); + const messages = JSON.parse(read("extension/_locales/en/messages.json")); + for (const k of ["extStoreName", "extDescription"]) assert.doesNotMatch(messages[k].message, /English/, k); }); test("the mascot isn't named in the listing (DECISIONS 2026-10-01)", () => {