From cb9398cec9f7a1a6310f526c8801da0dd8950aea Mon Sep 17 00:00:00 2001 From: Ayla Croft Date: Tue, 6 Oct 2026 15:43:29 -0400 Subject: [PATCH 1/3] chore(server): pass Credo in strict mode and run it strictly in CI Credo's strict mode also reports its low-priority checks. It found 18 issues, all fixed in the code rather than switched off: nested modules called by their full name now have an alias at the top of the module, aliases are in alphabetical order, and a doc string with escaped quotes uses a sigil. The Credo config now sets strict mode, so a plain `mix credo` reports what CI reports. CI runs `mix credo --strict` and compiles the tests with `mix test --warnings-as-errors`, so a warning in a test file fails the build like one in the server code does. Co-Authored-By: Claude Opus 5.5 Signed-off-by: Ayla Croft --- .github/workflows/ci.yml | 4 ++-- server/.credo.exs | 7 +++++-- server/lib/kotiko/application.ex | 3 ++- server/lib/kotiko/health.ex | 3 ++- server/lib/kotiko/janitor.ex | 3 ++- server/lib/kotiko/lang.ex | 2 +- server/lib/kotiko/llm/cache.ex | 2 +- server/lib/kotiko/router_v1.ex | 8 +++++--- server/test/kotiko/llm/catalog_test.exs | 2 +- server/test/kotiko/llm/quota_test.exs | 2 +- server/test/kotiko/pronunciation_refresh_test.exs | 11 ++++++----- server/test/kotiko/router_v1_test.exs | 3 ++- server/test/mix/tasks/kotiko.token_test.exs | 2 +- server/test/support/data_case.ex | 3 ++- server/test/support/llm_stub.ex | 5 +++-- 15 files changed, 36 insertions(+), 24 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 6631010..5813d7c 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -67,12 +67,12 @@ 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 - run: mix deps.unlock --check-unused - - run: mix test + - run: mix test --warnings-as-errors extension: runs-on: ubuntu-24.04 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 ff80fc6..a05d25c 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 eca269b..5906669 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 -> From e5a481b5d624df9b8aab42802751bbadd24ee8be Mon Sep 17 00:00:00 2001 From: Ayla Croft Date: Tue, 6 Oct 2026 15:54:39 -0400 Subject: [PATCH 2/3] chore(extension): enforce the JavaScript layout and stricter ESLint rules ESLint now checks the layout the code already follows, with @stylistic/eslint-plugin (the formatting rules ESLint moved out of its core): two-space indent, double quotes, semicolons, trailing commas, a multi-line ternary's ? and : at the start of the line, and the usual spacing. `npm run lint:fix` applies it. Adopting it changed 12 files by 22 lines. Prettier was measured instead and not adopted: at print widths from 100 to 200 it rewrote 137 to 188 files and grew what the popup loads at open by 1.6 to 6.5 KB, past its 128 KB cap. ESLint also gains stricter rules that catch real mistakes: === except for `x == null`, no shadowed names, no unused arguments, no assignments to parameters, no expression statements, no closures over a loop's changing variables, eval and its relatives banned, and const and modern operators where they apply. Every finding was fixed in the code (renames, blocks instead of comma expressions, Number() instead of unary +); there are no new suppressions. What the popup loads at open is 29 bytes smaller. Playwright tests may still shadow a name: a callback that runs in the browser takes the test's values under the same name. docs/CODING_STANDARDS.md and CONTRIBUTING.md say what is enforced and by which tool, and that Credo runs in strict mode. Co-Authored-By: Claude Opus 5.5 Signed-off-by: Ayla Croft --- CONTRIBUTING.md | 3 +- docs/CODING_STANDARDS.md | 56 ++++++++--- eslint.config.js | 139 +++++++++++++++++++++++++- extension/background.js | 6 +- extension/bulk/parse.js | 23 +++-- extension/content/engine.js | 2 +- extension/content/popover.js | 2 +- extension/dashboard.js | 24 ++--- extension/data-tools.js | 12 +-- extension/lib/add-queue.js | 4 +- extension/lib/controls.js | 2 +- extension/lib/i18n.js | 6 +- extension/lib/precedence.js | 4 +- extension/lib/store.js | 20 ++-- extension/lib/word-search.js | 4 +- extension/lib/wordspec.js | 6 +- extension/popup-more.js | 2 +- extension/popup.js | 13 ++- extension/ui/tools/popover-tokens.mjs | 2 +- extension/welcome.js | 9 +- package-lock.json | 80 +++++++++++++++ package.json | 2 + scripts/check-old-name.mjs | 2 +- scripts/sync-story.mjs | 2 +- test/bg/background.test.mjs | 10 +- test/bg/backup.test.mjs | 8 +- test/bg/dashboard-api.test.mjs | 6 +- test/bg/local-mode.test.mjs | 8 +- test/bg/privacy.test.mjs | 4 +- test/dom/content.test.mjs | 22 ++-- test/dom/dashboard.test.mjs | 1 - test/dom/popover.test.mjs | 4 +- test/dom/popup.test.mjs | 6 +- test/helpers/a11y-checks.mjs | 2 +- test/helpers/fake-chrome.mjs | 10 +- test/perf/run.mjs | 7 +- test/unit/backup.test.mjs | 12 ++- test/unit/build-extension.test.mjs | 4 +- test/unit/bulk-parse.test.mjs | 4 +- test/unit/llm-client.test.mjs | 6 +- test/unit/local-store.test.mjs | 6 +- test/unit/store-readiness.test.mjs | 4 +- 42 files changed, 401 insertions(+), 148 deletions(-) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index ac1c44e..cb78406 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -65,7 +65,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). - Maintainers review every pull request; how decisions are made and who maintains what is in [GOVERNANCE.md](GOVERNANCE.md) and [MAINTAINERS.md](MAINTAINERS.md). - Commit messages and pull request titles follow 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 2661dbe..5860adc 100644 --- a/package-lock.json +++ b/package-lock.json @@ -8,6 +8,7 @@ "devDependencies": { "@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", @@ -774,6 +775,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", @@ -812,6 +865,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", @@ -3950,6 +4017,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 c2f3452..c35434d 100644 --- a/package.json +++ b/package.json @@ -10,11 +10,13 @@ "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": { "@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/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/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)", () => { From a62d9cadf4592e8e4948e845dad5bbbef109dc14 Mon Sep 17 00:00:00 2001 From: Ayla Croft Date: Tue, 6 Oct 2026 16:01:40 -0400 Subject: [PATCH 3/3] chore(scripts): no function created in the table-reading loop The stricter ESLint rules (no-loop-func) flagged bestpractices-links.mjs, which landed on main after this branch started: a closure inside the loop read the loop's header variable. A plain loop builds the cells instead. Co-Authored-By: Claude Opus 5.5 Signed-off-by: Ayla Croft --- scripts/bestpractices-links.mjs | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) 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) };