Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -81,15 +81,15 @@ 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
# Security-focused static analysis: any finding fails (server/.sobelow-conf).
- 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
Expand Down
3 changes: 2 additions & 1 deletion CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
56 changes: 43 additions & 13 deletions docs/CODING_STANDARDS.md
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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:
Expand All @@ -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
Expand Down
139 changes: 137 additions & 2 deletions eslint.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -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).
Expand All @@ -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: [
Expand All @@ -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).
Expand Down Expand Up @@ -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",
},
},
];
6 changes: 3 additions & 3 deletions extension/background.js
Original file line number Diff line number Diff line change
Expand Up @@ -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",
});
Expand Down Expand Up @@ -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) {
Expand All @@ -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
Expand Down
23 changes: 17 additions & 6 deletions extension/bulk/parse.js
Original file line number Diff line number Diff line change
Expand Up @@ -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()));
}

Expand Down
2 changes: 1 addition & 1 deletion extension/content/engine.js
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
2 changes: 1 addition & 1 deletion extension/content/popover.js
Original file line number Diff line number Diff line change
Expand Up @@ -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).
Expand Down
Loading