diff --git a/.claude/skills/verify-debatelab/recipes/account.spec.ts b/.claude/skills/verify-debatelab/recipes/account.spec.ts index f5a588dc2..bb08af3a1 100644 --- a/.claude/skills/verify-debatelab/recipes/account.spec.ts +++ b/.claude/skills/verify-debatelab/recipes/account.spec.ts @@ -74,6 +74,7 @@ test("timer-run: the speech timer starts, pauses, and resets", async ({ signedIn await page.getByRole("button", { name: "Pause" }).click(); await expect(page.getByText("PAUSED").first()).toBeVisible(); const paused = await display.textContent(); + // eslint-disable-next-line no-restricted-properties -- observation window: a paused timer must not change. await page.waitForTimeout(1_500); expect(await display.textContent()).toBe(paused); await evidence.shot("01-paused"); diff --git a/.coderabbit.yaml b/.coderabbit.yaml new file mode 100644 index 000000000..f8919793d --- /dev/null +++ b/.coderabbit.yaml @@ -0,0 +1,18 @@ +# Merges with the organization settings in the CodeRabbit UI; only the keys +# below are set here. +inheritance: true +knowledge_base: + code_guidelines: + enabled: true + filePatterns: + - "AGENTS.md" + - "CODING_STANDARDS.md" + - "docs/standards/*.md" +reviews: + path_instructions: + - path: "**/*" + instructions: >- + Follow the review steps in CODING_STANDARDS.md and apply the area files + in docs/standards/ that match the changed files. Report a rule violation + as a finding with the rule's link. Skip what lint, typecheck, the test + suites and npm run docs:check already catch. diff --git a/CODING_STANDARDS.md b/CODING_STANDARDS.md new file mode 100644 index 000000000..4df9847a4 --- /dev/null +++ b/CODING_STANDARDS.md @@ -0,0 +1,31 @@ +# Coding standards + +Review rules for DebateLab diffs. They come from review findings on PRs #5 to #106 that the team accepted and fixed, and each rule links to a thread that produced it. A rule marked "single finding" came up once; weigh it below the rules reviewers raised repeatedly. + +## Review steps + +1. Map each changed file to its area below and read that area's file. Read [reviews.md](docs/standards/reviews.md) for every PR. +2. Check the diff against every rule in those files. A violation is a finding like any bug: give its severity, `file:line`, and the rule's link. +3. Done when every changed file has been checked against its area file, and the PR body has been checked against the diff. + +Skip what lint, typecheck, the test suites, and `npm run docs:check` already catch. Spend the review on what they cannot see. + +## Areas + +- [Async work and client state](docs/standards/async-and-state.md): stores, pages, and hooks that await anything; stale responses, races with user actions, cancellation, timeouts. +- [Trust boundaries and validation](docs/standards/boundaries.md): `src/lib/ai/`, Edge handlers, anything parsing model or network output; request budgets, failing closed, honest controls, usage and cost records. +- [Tests](docs/standards/tests.md): any test file; tests that fail when the behavior they name breaks. +- [Database and migrations](docs/standards/database.md): `supabase/migrations/`, `supabase/tests/`, privileged database clients. +- [CI, secrets, and privacy](docs/standards/security.md): `.github/workflows/`, credentials, telemetry and error reporting. +- [UI, language, and accessibility](docs/standards/ui.md): components, pages, and `src/locales/`. +- [PR claims and documentation](docs/standards/reviews.md): the PR body, commit messages, and the documents a change must update. + +## The rules reviewers raised most + +1. A test must fail when the behavior it names breaks. Check both sides of every limit, and exercise behavior rather than matching source text. (about 29 findings) +2. An async result that writes state checks that it is still current when it lands. Read the target of a user action (debate, user, record) when the user acts, before any `await`. (about 17) +3. Validate untrusted data where it enters: model output, Edge responses, persisted browser state, request bodies. Reject or normalize it there, before anything renders or saves it. (about 15) +4. Every user-facing string goes through `t()` with keys in all three catalogs, and every control has an accessible name. (about 15) +5. A timeout or abort stops the work underneath it, not only the promise the caller awaits. (about 9) +6. When a read fails, the answer is "unknown". Persist nothing and report no success on a failed read or write. (6) +7. The owning document changes in the same PR as the behavior it describes. (7) diff --git a/docs/README.md b/docs/README.md index a1ce93f4c..bed2b650b 100644 --- a/docs/README.md +++ b/docs/README.md @@ -15,6 +15,10 @@ Start with the smallest document that answers the task instead of loading every - [Agent guide](../AGENTS.md), current. Always-loaded commands, safety rules, and routing. - [Root README](../README.md), current. Product overview and quick start. +## Write and review code + +- [Coding standards](../CODING_STANDARDS.md), current. Review rules drawn from accepted review findings, split by area under [`standards/`](standards/). Read by reviewers; open an area file when a review finding cites it. + ## Set up and develop - [Development guide](development.md), current. Open for installation, environment behavior, linked worktrees, frontend, backend, database, or iOS workflows. diff --git a/docs/standards/async-and-state.md b/docs/standards/async-and-state.md new file mode 100644 index 000000000..85e8f9806 --- /dev/null +++ b/docs/standards/async-and-state.md @@ -0,0 +1,55 @@ +# Async work and client state + +Part of the [coding standards](../../CODING_STANDARDS.md). Race conditions were the second most common finding: a response from earlier work landing after the user moved on. + +## Capture the target when the user acts + +Read the debate, user, or record ID at the moment of the click, and pass it through the async chain. Do not read a store again after an `await`, because the user may have switched debates or accounts while you waited. + +- Clear All read the Current Debate after awaiting auth, so a switch during the wait deleted the other debate's sources ([#72](https://github.com/Programcode/DebateLab/pull/72#discussion_r4112813743)). +- Workspace research saved its sources into whichever personal debate was active when the response arrived ([#72](https://github.com/Programcode/DebateLab/pull/72#discussion_r4112813741)). +- A fallback that reads "the current debate" must respect one-off mode; use `activeDebateId()`, not `currentDebateId` ([#72](https://github.com/Programcode/DebateLab/pull/72#discussion_r4112813737)). + +## Stale responses must not overwrite newer state + +When a load and a write can overlap, give the write a counter or request key and have the load discard its result if the counter moved while it ran. This is the pattern `debate-store.ts` and the context pins use now. + +- Hydration replaced a debate created while it was loading ([#72](https://github.com/Programcode/DebateLab/pull/72#discussion_r4112813746)). A pin load overwrote a pin change made during the read ([#75](https://github.com/Programcode/DebateLab/pull/75#issuecomment-5873019386)). +- A load that started before a dismissal brought the dismissed announcement back ([#19](https://github.com/Programcode/DebateLab/pull/19#discussion_r3896612965)). +- A failed optimistic write undoes only its own change. Restoring a snapshot taken earlier also reverts other writes that succeeded since ([#75](https://github.com/Programcode/DebateLab/pull/75#issuecomment-5873818351)). +- Count against the store's current state, not a value captured at render, so two quick clicks see each other ([#77](https://github.com/Programcode/DebateLab/pull/77#issuecomment-5877394644)). +- Errors tied to one item carry that item's ID and render only beside it ([#19](https://github.com/Programcode/DebateLab/pull/19#discussion_r3896430653)). + +## Supersede in-flight work explicitly + +Starting new work for the same slot aborts the old request and hides its output. Track ownership with a request identity and check it before every state update in `then`, `catch`, and `finally`. + +- A clarify request started while a stream was running; the old stream later appended text and cleared the loading flag ([#9](https://github.com/Programcode/DebateLab/pull/9#discussion_r3861289857)). +- Cancel in-flight work on unmount as well as on clear and load ([#9](https://github.com/Programcode/DebateLab/pull/9#discussion_r3861496792)). +- Undo during a section regeneration was reverted when the regeneration landed. Drop a result if the record it targets changed after the work started ([#89](https://github.com/Programcode/DebateLab/pull/89#issuecomment-5951346940)). +- Key deduplication by a stable identity (user ID), not by a rotating access token ([#65](https://github.com/Programcode/DebateLab/pull/65#discussion_r4082668135)). + +## React effects and refs + +- Do not use an `active` closure flag in an effect that can return early without registering cleanup; a re-render can leave it `false` forever. Request-key refs already reject stale results ([#13](https://github.com/Programcode/DebateLab/pull/13#discussion_r3893649640)). +- Write refs that track the previous render inside an effect, not during render ([#20](https://github.com/Programcode/DebateLab/pull/20#discussion_r3896378571)). +- Derive view state from the same condition that chooses the rendered branch ([#20](https://github.com/Programcode/DebateLab/pull/20#discussion_r3896378587)). +- One-shot guards must flip after their first use ([#14](https://github.com/Programcode/DebateLab/pull/14#discussion_r3894645041)). + +## Auth transitions and persisted state + +- Persisted browser state records its owner. On sign-in, clear state whose owner differs or is missing. Do not use "was there a previous user" as the ownership check ([#13 review](https://github.com/Programcode/DebateLab/pull/13#issuecomment-5477866033)). +- Clear only the previous owner's data on an account switch, never the incoming user's ([#12](https://github.com/Programcode/DebateLab/pull/12#discussion_r3893641261)). +- Validate persisted state on every rehydrate. Zustand calls `migrate` only on a version change, so put validation in `merge` ([#13](https://github.com/Programcode/DebateLab/pull/13#discussion_r3893649636)). +- Do not serialize a whole store on every keystroke. Debounce drafts and cap stored history ([#13](https://github.com/Programcode/DebateLab/pull/13#discussion_r3893649632)). + +## Timeouts and cancellation + +A timeout that rejects the caller's promise but leaves the work running still spends money and CPU. + +- Pass one `AbortSignal` through every layer of the work: research, Exa, model calls, body reads ([#5](https://github.com/Programcode/DebateLab/pull/5#discussion_r3835805781), [#9](https://github.com/Programcode/DebateLab/pull/9#discussion_r3861289875)). A `Promise.race` timeout without cancellation leaves a DOCX conversion running ([#63](https://github.com/Programcode/DebateLab/pull/63#discussion_r4081818971)). +- Start the deadline and listen to the caller's signal before the first `await`, including `getSession()` ([#13 review](https://github.com/Programcode/DebateLab/pull/13#issuecomment-5477866033), [#16](https://github.com/Programcode/DebateLab/pull/16#discussion_r3895854139)). +- Every external fetch has a bound, and anything that delays app startup has a fail-open timeout ([#5](https://github.com/Programcode/DebateLab/pull/5#discussion_r3835805765), [#21](https://github.com/Programcode/DebateLab/pull/21#discussion_r3896608554)). +- A promise that wraps a callback API settles on every outcome, including cancel. The Google Picker promise never settled on cancel and left the panel disabled ([#63](https://github.com/Programcode/DebateLab/pull/63#discussion_r4081818959)). +- After a caller abort, normalize the error to `AbortError` so callers do not report it as a failure ([#16](https://github.com/Programcode/DebateLab/pull/16#discussion_r3895854139)). +- Secondary work after the main result (such as source-usage checks) gets its own budget and must not lose a result that already finished ([#69](https://github.com/Programcode/DebateLab/pull/69#discussion_r4093476756)). diff --git a/docs/standards/boundaries.md b/docs/standards/boundaries.md new file mode 100644 index 000000000..a8b12821f --- /dev/null +++ b/docs/standards/boundaries.md @@ -0,0 +1,56 @@ +# Trust boundaries and validation + +Part of the [coding standards](../../CODING_STANDARDS.md). The client AI boundary is [`src/lib/ai/client.ts`](../../src/lib/ai/client.ts); the server boundaries are the Edge Function handlers described in [supabase/README.md](../../supabase/README.md). + +## Validate at the boundary, then trust the type + +Model output, Edge responses, URLs, and request bodies are untrusted until a validator accepts them. Validate the complete shape where the data enters, before rendering, autosave, or a cast. + +- Validate the whole output with its schema before saving sources. A count check plus a cast let malformed output through ([#9](https://github.com/Programcode/DebateLab/pull/9#discussion_r3861289852)). +- Normalize optional fields to their primitive types. An object in `qualityLabel` reached React as a child ([#5](https://github.com/Programcode/DebateLab/pull/5#discussion_r3835805775)). +- Use `validateUrl` for every URL you render as a link, and store the value it returns, not the input ([#17](https://github.com/Programcode/DebateLab/pull/17#discussion_r3896309984), [#17 review](https://github.com/Programcode/DebateLab/pull/17#issuecomment-5481176881)). +- Validate raw types before coercing. `Boolean("false")` is `true` ([#29](https://github.com/Programcode/DebateLab/pull/29#discussion_r3937934126)). +- Invalid client input returns a 4xx, never a 500 from a property access on `null` ([#13 review](https://github.com/Programcode/DebateLab/pull/13#issuecomment-5477866033)). +- When the client validates locally, apply the same rules as the server, so a request the client accepted does not fail as a generic error ([#12](https://github.com/Programcode/DebateLab/pull/12#discussion_r3893641258)). +- Send only values the server contract accepts. Local-only source IDs made every AI request fail with 400 ([#66](https://github.com/Programcode/DebateLab/pull/66#discussion_r4085167559)). +- Prefer a typed result over `as` casts at call sites. Unchecked casts hide shape drift ([#18](https://github.com/Programcode/DebateLab/pull/18), single finding). + +## Model output can omit what the prompt asked for + +The shared JSON rules tell the model to omit a part it cannot fill. Map an omitted field to its documented empty value instead of failing the request ([#12](https://github.com/Programcode/DebateLab/pull/12#discussion_r3893641334)). A response variant that does not match the operation is an error the UI can recover from, not a silent return that leaves the page pending ([#12](https://github.com/Programcode/DebateLab/pull/12#discussion_r3893641293)). + +## Size limits are UTF-8 bytes + +The Edge limit is on serialized bytes, so budget in bytes, across the whole serialized payload, before transport ([#66](https://github.com/Programcode/DebateLab/pull/66#discussion_r4085167588), [#12](https://github.com/Programcode/DebateLab/pull/12#discussion_r3893641325)). + +- Do not silently shorten what the user typed. Reject it with a visible message, or keep the truncation marker so the server can report it ([#66](https://github.com/Programcode/DebateLab/pull/66#discussion_r4085302451)). +- A value exactly at the limit is allowed. Check the boundary with a test on each side ([#12](https://github.com/Programcode/DebateLab/pull/12#discussion_r3893641317)). +- Check `response.ok` before reading a body, and read bounded bodies inside the timeout ([#11](https://github.com/Programcode/DebateLab/pull/11#discussion_r3872004292)). + +## Fail closed, and say "unknown" when you do not know + +- A failed entitlement read is indeterminate. Treating it as "not Pro" persisted a downgrade of the user's model choice ([#47](https://github.com/Programcode/DebateLab/pull/47#discussion_r4054031767)). +- Check the result of every write before reporting success ([#63](https://github.com/Programcode/DebateLab/pull/63#discussion_r4081818967)). Lint rejects unhandled promises; a `void` in front of one is correct only when the promise cannot reject or handles its own errors and tells the user ([#63 outside diff](https://github.com/Programcode/DebateLab/pull/63)). +- Catch the specific error you expect (a missing file, a missing column) and rethrow the rest ([#5](https://github.com/Programcode/DebateLab/pull/5#discussion_r3835805773)). +- Read provider per-item status before falling back to a generic message ([#11](https://github.com/Programcode/DebateLab/pull/11#discussion_r3872004284)). +- A fix for a permission change covers every write path, not only the one that failed first ([#44](https://github.com/Programcode/DebateLab/pull/44#discussion_r4049229043)). + +## Controls must be honest + +A visible toggle or setting must change the request or deterministic post-processing. This is an existing team rule that reviewers enforced: + +- `useActiveSources` did not change the opponent request ([#11](https://github.com/Programcode/DebateLab/pull/11#discussion_r3872004224)). +- Research instructions were attached when research returned nothing, inviting unsupported citations ([#5](https://github.com/Programcode/DebateLab/pull/5#discussion_r3835805784)). +- A privacy disclosure stays only while the code enforces it ([#22](https://github.com/Programcode/DebateLab/pull/22#discussion_r3896703404)). +- Tier defaults must reach every fallback path, not only a migration ([#47](https://github.com/Programcode/DebateLab/pull/47#discussion_r4054031797)). + +## Usage and cost records + +Every provider call that can bill gets recorded, including retries, repairs, research sub-calls, and cache-only usage. A request that did not complete records its observed usage with a null cost. + +- Cache-only usage returned before pricing ([#5](https://github.com/Programcode/DebateLab/pull/5#discussion_r3835805777)). Repair attempts dropped the failed call's tokens ([#5 review](https://github.com/Programcode/DebateLab/pull/5#issuecomment-5379671525), [#9](https://github.com/Programcode/DebateLab/pull/9#discussion_r3861289886)). +- A stream that errors after its finish frame is incomplete ([#46](https://github.com/Programcode/DebateLab/pull/46#discussion_r4053914209)). + +## Keep one copy of a contract + +When two code paths implement the same privacy-sensitive payload or validation, move it to one helper. The two copies of the AI failure report had already drifted ([#18](https://github.com/Programcode/DebateLab/pull/18), single finding). Configuration that exists in two places (such as two CSP lists) changes together, with a test that compares them ([#15](https://github.com/Programcode/DebateLab/pull/15#discussion_r3894972424)). diff --git a/docs/standards/database.md b/docs/standards/database.md new file mode 100644 index 000000000..983226718 --- /dev/null +++ b/docs/standards/database.md @@ -0,0 +1,24 @@ +# Database and migrations + +Part of the [coding standards](../../CODING_STANDARDS.md). [supabase/README.md](../../supabase/README.md#migrations-and-database-tests) owns migration order, table grants, and the database gate. [docs/operations.md](../operations.md#supabase-changes) owns applying migrations to staging and production. + +## Constraints + +- A `CHECK` passes when its expression is `NULL`. Test nullable columns explicitly with `IS NOT NULL` inside the branch that requires them ([#19](https://github.com/Programcode/DebateLab/pull/19#discussion_r3896430665)). +- Grant the privileges a client read path needs, and let RLS limit the rows. Revoking `SELECT` broke the daily usage count ([#70](https://github.com/Programcode/DebateLab/pull/70#discussion_r4102131041)). When a migration narrows grants, check every client write path that touches the table ([#44](https://github.com/Programcode/DebateLab/pull/44#discussion_r4049229043)). + +## Locks on tables with production rows + +`npm run lint` runs squawk on new migrations. Check every `-- squawk-ignore` against the table's production size: an ignore is fine on a small table and wrong on a large one. Index builds on populated tables use `CREATE INDEX CONCURRENTLY` in their own file ([#13](https://github.com/Programcode/DebateLab/pull/13#discussion_r3893649726)), and long locks run at low traffic ([#11](https://github.com/Programcode/DebateLab/pull/11#discussion_r3872004307)). + +## Compare times on one clock + +Do not compare a timestamp produced by an Edge Function with one produced by Postgres. Clock skew made the erase fence either accept writes after "Delete all data" or reject fresh requests. Take both times from the database, or compare counters ([#13](https://github.com/Programcode/DebateLab/pull/13#discussion_r3893649730)). + +## Destructive data changes + +A migration that anyone can apply must not delete user data as a side effect. Put the deletion in a function, leave the call out of the migration, and run it as a separate step that needs explicit authorization for that environment. Write down the preservation and restore path before that step ([#72](https://github.com/Programcode/DebateLab/pull/72#discussion_r4112813734), [#71](https://github.com/Programcode/DebateLab/pull/71#discussion_r4103235907)). `20260926090100_legacy_debate_cleanup.sql` now follows this. + +## Privileged clients + +Use the generated `Database` type with `createClient(...)` rather than casting the client through `unknown` ([#13](https://github.com/Programcode/DebateLab/pull/13#discussion_r3893649719)). A service-role client filters by the user ID it validated from the bearer token, never by an ID from the request body ([#90 security review](https://github.com/Programcode/DebateLab/pull/90)). diff --git a/docs/standards/reviews.md b/docs/standards/reviews.md new file mode 100644 index 000000000..453ce9540 --- /dev/null +++ b/docs/standards/reviews.md @@ -0,0 +1,21 @@ +# PR claims and documentation + +Part of the [coding standards](../../CODING_STANDARDS.md). Check the PR body and commit messages against the diff, the same way you check the code. + +## Claims match the diff + +PR bodies and commit messages are evidence. The second-pass reviews on #34 to #38 blocked two PRs for claims the diff contradicted. + +- A claim of scope must survive the diff. "Nothing here is staging-dependent" is false when the diff removes the staging flag ([#37](https://github.com/Programcode/DebateLab/pull/37#issuecomment-5561106629)). "On every page" is false when the diff edits one file ([#38](https://github.com/Programcode/DebateLab/pull/38#issuecomment-5561106726)). +- A deletion shows the search behind it, covering dynamic imports, string-built paths, `e2e/`, and `page.evaluate` access that typecheck cannot see ([#34](https://github.com/Programcode/DebateLab/pull/34#issuecomment-5561106288), [#36](https://github.com/Programcode/DebateLab/pull/36#issuecomment-5561106513)). +- "No behavior change" holds for every input. `Response.json(undefined)` throws where `JSON.stringify` did not ([#36](https://github.com/Programcode/DebateLab/pull/36#issuecomment-5561106513)). +- Test counts in the commit and the PR body agree and come from the final head ([#35](https://github.com/Programcode/DebateLab/pull/35#issuecomment-5561106411)). +- Proof that used stubs says so: "This proves client dispatch only; it is not authenticated provider proof." ([#65](https://github.com/Programcode/DebateLab/pull/65#issuecomment-5795646211)) + +## Documents change with the code + +The owning document changes in the same PR as the behavior it describes ([docs/README.md](../README.md#maintenance-rule) names the owners). Reviewers found a plan with two statuses for one feature, a release checklist missing `test:edge`, a migration marked applied in one file and pending in another, and new ports with no documentation ([#17](https://github.com/Programcode/DebateLab/pull/17#discussion_r3896181597), [#26](https://github.com/Programcode/DebateLab/pull/26#discussion_r3897947143), [#63](https://github.com/Programcode/DebateLab/pull/63#discussion_r4081818982)). Commands in plans run on the pinned CLI version ([#29](https://github.com/Programcode/DebateLab/pull/29#discussion_r3937934134)). + +## The diff holds only this change + +A diff full of unrelated files usually means the branch was cut from a stale local `main`. A branch like that once put 20 plan files into a PR, and all three review findings landed on them ([#29](https://github.com/Programcode/DebateLab/pull/29#issuecomment-5546554434)). diff --git a/docs/standards/security.md b/docs/standards/security.md new file mode 100644 index 000000000..b9d32dcad --- /dev/null +++ b/docs/standards/security.md @@ -0,0 +1,35 @@ +# CI, secrets, and privacy + +Part of the [coding standards](../../CODING_STANDARDS.md). AGENTS.md "Hard boundaries" owns which credentials may enter a job. [docs/operations.md](../operations.md#secrets) owns secret names and rotation. + +## Workflows + +- `npm run lint` enforces SHA-pinned actions, `persist-credentials: false`, and a top-level `permissions:` block. Review what it cannot: each permission beyond `contents: read` is the smallest that works and has a comment saying why ([#27](https://github.com/Programcode/DebateLab/pull/27#discussion_r3898115822)). +- A job that loads protected secrets runs only on trusted refs. Check `github.ref == 'refs/heads/main'` for manual dispatch, and require `workflow_run.event == 'push'` for chained workflows, because a fork PR can have a branch named `main` ([#8](https://github.com/Programcode/DebateLab/pull/8#discussion_r3861144007), [#21](https://github.com/Programcode/DebateLab/pull/21#discussion_r3896484016)). +- A manual dispatch path enforces the same build gate as the automatic one ([#26](https://github.com/Programcode/DebateLab/pull/26#discussion_r3898040143)). +- Guard steps that depend on files an earlier conditional step creates ([#8](https://github.com/Programcode/DebateLab/pull/8#discussion_r3861005583)). + +## Where credentials may go + +- Never print an access code or token, even in a plan's example command ([#8](https://github.com/Programcode/DebateLab/pull/8#discussion_r3861005595)). +- Session state written by a test lives outside directories CI uploads as artifacts ([#11](https://github.com/Programcode/DebateLab/pull/11#discussion_r3872004119)). +- A suite that writes a real session into a browser restricts its base URL to loopback ([#21](https://github.com/Programcode/DebateLab/pull/21#discussion_r3896484019)). +- Documentation names the secret, never the account's email or password ([#68](https://github.com/Programcode/DebateLab/pull/68#discussion_r4095363422)). +- One secret, one purpose. Do not derive signing keys from the service-role key; rotating it would break every outstanding token ([#13](https://github.com/Programcode/DebateLab/pull/13#discussion_r3893649683)). + +## Recorded exceptions + +These were raised in review, judged valid, and accepted on purpose. Changing them needs Adam's decision, not a reviewer's. + +- Live provider keys reach same-repository PR jobs in `live-e2e.yml`. Adam accepted this on PR #68; the mitigation is dedicated low-limit keys ([#68](https://github.com/Programcode/DebateLab/pull/68#discussion_r4090563258)). +- The production canary account's credentials reach `prod-canary.yml` through Actions secrets, approved 2026-09-24 and recorded in AGENTS.md ([#68](https://github.com/Programcode/DebateLab/pull/68#discussion_r4090664336)). +- Manual canary runs can exhaust the account's daily quota; that shows as a quota error, not a false pass ([#68](https://github.com/Programcode/DebateLab/pull/68#discussion_r4093482054)). +- CodeRabbit rated the shared preview-origin allowlist from PR #95 as a high retained concern: an approved production preview runs unmerged code that can receive demo-session handoffs ([#95 security review](https://github.com/Programcode/DebateLab/pull/95)). [docs/operations.md](../operations.md#per-pr-previews) describes the current setup. This one has no recorded decision yet. + +## Telemetry and error reports + +[`src/lib/observability.ts`](../../src/lib/observability.ts) is the only path to PostHog and Sentry. + +- Events use a closed allowlist of names, and string attributes accept only listed values. An open string field lets a caller send debate content ([#22](https://github.com/Programcode/DebateLab/pull/22#discussion_r3896703404)). +- Error boundaries report a fixed error with fixed context, never the caught error, whose message can contain user input ([#16](https://github.com/Programcode/DebateLab/pull/16#discussion_r3895854129)). +- Count only events that really happened. A route event fired before the auth redirect recorded denied access as a visit ([#16 review](https://github.com/Programcode/DebateLab/pull/16#issuecomment-5480853309)). diff --git a/docs/standards/tests.md b/docs/standards/tests.md new file mode 100644 index 000000000..b137f61a0 --- /dev/null +++ b/docs/standards/tests.md @@ -0,0 +1,43 @@ +# Tests + +Part of the [coding standards](../../CODING_STANDARDS.md). [docs/testing.md](../testing.md) owns which gate proves what. This file covers how to write a single test. It was the largest group of findings: tests that passed whether or not the behavior worked. + +## A test must be able to fail + +Before you commit a test, break the code it covers and watch it fail. Reviewers caught these patterns: + +- **Matching source text.** Reading a file and asserting it contains a string passes when the logic is wrong, and fails on reformatting. Exercise the behavior instead ([#9](https://github.com/Programcode/DebateLab/pull/9#discussion_r3861496818), [#14](https://github.com/Programcode/DebateLab/pull/14#discussion_r3894645049), [#11](https://github.com/Programcode/DebateLab/pull/11#discussion_r3872004244)). Where a workflow or config file is the subject, parse it and assert the condition, the query, and the failure exit together ([#27](https://github.com/Programcode/DebateLab/pull/27#discussion_r3898115828)). +- **Assertions anchored to nothing.** `return false;` occurs everywhere in a file. Anchor to the function that owns the guard ([#11](https://github.com/Programcode/DebateLab/pull/11#discussion_r3872004273)). +- **Test data that never reaches the limit.** An aggregate cap test with inputs below the cap proves nothing ([#12](https://github.com/Programcode/DebateLab/pull/12#discussion_r3893641309), [#13](https://github.com/Programcode/DebateLab/pull/13#discussion_r3893649694)). +- **One side of a boundary.** Assert that 12 is accepted and 13 is rejected, with inputs that differ only in count, so a different rule cannot cause the rejection ([#13](https://github.com/Programcode/DebateLab/pull/13#discussion_r3893649674)). +- **`&&` where you meant `||`.** A leak check that requires both fields to leak passes when one does ([#13](https://github.com/Programcode/DebateLab/pull/13#discussion_r3893649704)). +- **Uncovered branches named in the title.** If the test name says "duplicate turns", assert the duplicate branch ([#12](https://github.com/Programcode/DebateLab/pull/12#discussion_r3893641303), [#5](https://github.com/Programcode/DebateLab/pull/5#discussion_r3835805782)). +- **`JSON.stringify` on an `Error`.** It drops `message` and `stack`, so a privacy check on the stringified call misses a leaked error. Destructure and check the error message directly ([#18](https://github.com/Programcode/DebateLab/pull/18), single finding). +- **Superseded invariants.** After a later migration changes a constraint, assert against the migration in force ([#11](https://github.com/Programcode/DebateLab/pull/11#discussion_r3872004255)). + +## End-to-end assertions prove completion + +A 200 status and some rendered text do not prove a flow finished. + +- Wait for content that renders only after the state you are testing resolves. `body` is visible during the auth loading state ([#11](https://github.com/Programcode/DebateLab/pull/11#discussion_r3872004087)). +- A streamed answer requires its final event, no error event, and no fallback request ([#68](https://github.com/Programcode/DebateLab/pull/68#discussion_r4090563262)). +- Assert that the secondary call you claim to cover actually happened ([#68](https://github.com/Programcode/DebateLab/pull/68#discussion_r4090563266)). +- Send the request a browser really sends. A CORS check with only an `Origin` header passed while Chromium blocked the real POST ([#68](https://github.com/Programcode/DebateLab/pull/68#discussion_r4090563272)). + +## Deterministic async tests + +- Wait on conditions, not fixed sleeps ([#9](https://github.com/Programcode/DebateLab/pull/9#discussion_r3861289865)). Lint blocks `waitForTimeout` in browser tests; a disable comment must name an observation window, a period in which something must not happen. +- Before asserting that something did not happen, drain pending work, or the negative assertion runs too early and always passes ([#9](https://github.com/Programcode/DebateLab/pull/9#discussion_r3861496807)). +- Route mocks by request content, not by call order, so a retry does not shift every later response ([#11](https://github.com/Programcode/DebateLab/pull/11#discussion_r3872004072)). +- Derive expected values from the loop index, not a counter the route handler mutates concurrently ([#13](https://github.com/Programcode/DebateLab/pull/13#discussion_r3893649614)). +- Fixtures with dates compute them from `Date.now()`. A hard-coded expiry turned four invite tests red on 2026-09-27 ([#74](https://github.com/Programcode/DebateLab/pull/74#issuecomment-5859465743)). +- Replace globals with `vi.stubGlobal`, which Vitest undoes after each test. A direct assignment to `window` leaks into later tests ([#13](https://github.com/Programcode/DebateLab/pull/13#discussion_r3893649664)). +- Prefer accessible names or test IDs over positional locators such as `.first()` ([#11](https://github.com/Programcode/DebateLab/pull/11#discussion_r3872004072)). + +## Name what the test proves + +A test that stubs the session and the network proves request construction, not backend behavior. Name it that way and keep fixture text neutral ([#65](https://github.com/Programcode/DebateLab/pull/65#discussion_r4082499616)). AGENTS.md states the general rule: stub sessions prove rendering only. + +## Mock providers follow the contract + +The mock provider applies the same rules the real path does, such as the contention count per length ([#9](https://github.com/Programcode/DebateLab/pull/9#discussion_r3861289845)). Otherwise tests and demos pass on output that production would reject. diff --git a/docs/standards/ui.md b/docs/standards/ui.md new file mode 100644 index 000000000..77633e243 --- /dev/null +++ b/docs/standards/ui.md @@ -0,0 +1,33 @@ +# UI, language, and accessibility + +Part of the [coding standards](../../CODING_STANDARDS.md). [docs/architecture.md](../architecture.md#language-behavior) owns how interface and AI-output language work. [docs/testing.md](../testing.md) owns the Hungarian parity checks. + +## Every string is translated + +- Every user-facing string, including toasts, errors, and accessible names, goes through `t()`. `src/test/i18n-keys.test.ts` fails when a literal key is missing from the catalogs or a `count` call lacks its plural forms. It cannot see dynamic keys (``t(`nav.${key}`)``) or raw values rendered as text, so check those by hand: an English word in a Hungarian screen is the usual sign ([#11](https://github.com/Programcode/DebateLab/pull/11#discussion_r3872004217), [#43](https://github.com/Programcode/DebateLab/pull/43#discussion_r4048412531)). +- Store a translation key or error kind in state and translate at render time. English strings stored in state stay English after a reload ([#12](https://github.com/Programcode/DebateLab/pull/12#discussion_r3893641290)). +- Use the localized feature name everywhere in a catalog: "Interrogatorio", "Keresztkérdezés" ([#12](https://github.com/Programcode/DebateLab/pull/12#discussion_r3893641268), [#13 review](https://github.com/Programcode/DebateLab/pull/13#issuecomment-5477866033)). + +## Accessible names + +- Icon-only links and buttons get an `aria-label`. The nav label hides below `xl`, which left the link nameless ([#13](https://github.com/Programcode/DebateLab/pull/13#discussion_r3894071703)). +- Repeated row actions include the row's name ("Delete "), so screen readers and role queries can tell them apart ([#19](https://github.com/Programcode/DebateLab/pull/19#discussion_r3896430613)). +- A label for a group of buttons uses `role="group"` and `aria-labelledby` ([#11](https://github.com/Programcode/DebateLab/pull/11#discussion_r3872004206)). +- Lists rendered from model output use index keys, because model strings can repeat ([#11](https://github.com/Programcode/DebateLab/pull/11#discussion_r3872004180)). + +## Motion + +When `shouldReduceMotion` is true, render a static element. Passing the flag to the phase but not to `initial`/`animate` still animates ([#20](https://github.com/Programcode/DebateLab/pull/20#discussion_r3896378557)). + +## Tell the user when the app refuses + +- When a store action rejects input (too long, expired, invalid), the UI shows why. A submit button that silently does nothing looks broken ([#13](https://github.com/Programcode/DebateLab/pull/13#discussion_r3893649646)). +- Clear a composer only after the handler accepts the message, so a rejected draft is not lost ([#66 outside diff](https://github.com/Programcode/DebateLab/pull/66)). +- Enforce rules such as deadlines in the store, and add the UI guard as a second layer ([#13](https://github.com/Programcode/DebateLab/pull/13#discussion_r3893649625)). + +## Pending actions + +- Keep a confirmation dialog open while its request is pending, and close it only on success. Radix closes on click unless you call `preventDefault()` ([#26](https://github.com/Programcode/DebateLab/pull/26#discussion_r3897947151)). +- Side effects such as scrolling run on a success signal, not on "loading became false" ([#20](https://github.com/Programcode/DebateLab/pull/20#discussion_r3896378580)). +- Open a new tab within the click handler. After an awaited request the user activation has expired and popup blockers drop the tab. Show a fallback link ([#63](https://github.com/Programcode/DebateLab/pull/63#discussion_r4081818952)). +- Close the mobile menu on every navigation inside the persistent layout, including the logo link ([#20](https://github.com/Programcode/DebateLab/pull/20#discussion_r3896378566)). diff --git a/docs/testing.md b/docs/testing.md index b85c23430..d83b135c9 100644 --- a/docs/testing.md +++ b/docs/testing.md @@ -29,7 +29,13 @@ npm run lint npm run typecheck ``` -Lint checks repository ESLint rules. Type checking uses `tsconfig.app.json` for the client and `tsconfig.agentic.json` for `e2e.config.ts` and `agentic/` (the e2e runner itself does not type-check), and emits no files. Neither command replaces runtime tests. +Lint runs ESLint, then `npm run lint:repo`: + +- ESLint is type-aware for `src/` (outside `src/test/`) and rejects promises nobody awaits or catches. `void` marks a promise that cannot reject or handles its own errors. In browser tests it rejects `waitForTimeout`; a deliberate observation window keeps a `no-restricted-properties` disable comment with its reason. +- [knip](../knip.json) fails on unused files and on dependencies that are unused or missing from `package.json`. +- [`scripts/check-repo-rules.mjs`](../scripts/check-repo-rules.mjs) requires a top-level `permissions:` block, SHA-pinned actions, and `persist-credentials: false` on checkout in every workflow. A positive `has_*_privilege` check must name one privilege, as a quoted word or a per-row column, since a list means "any of"; write negative checks as `NOT has_*_privilege(...)`. It runs [squawk](https://squawkhq.com) on migrations newer than its baseline; silence a deliberate lock with `-- squawk-ignore <rule>` and a reason. + +Type checking uses `tsconfig.app.json` for the client and `tsconfig.agentic.json` for `e2e.config.ts` and `agentic/` (the e2e runner itself does not type-check), and emits no files. Neither command replaces runtime tests. ## Unit and component tests diff --git a/e2e/first-run.spec.ts b/e2e/first-run.spec.ts index 1292bce80..193eeccf5 100644 --- a/e2e/first-run.spec.ts +++ b/e2e/first-run.spec.ts @@ -157,6 +157,7 @@ for (const locale of ['en', 'hu'] as const) { // Finishing is remembered across a reload. await page.goto('/app', { waitUntil: 'domcontentloaded' }); await expect(card).toBeVisible(); + // eslint-disable-next-line no-restricted-properties -- observation window: the finished tour must not reopen after its start delay. await page.waitForTimeout(800); await expect(tooltip).toHaveCount(0); }); diff --git a/e2e/home-navigation.spec.ts b/e2e/home-navigation.spec.ts index 9f74a749e..67b9aac5f 100644 --- a/e2e/home-navigation.spec.ts +++ b/e2e/home-navigation.spec.ts @@ -96,6 +96,7 @@ test('onboarding starts a fresh delay when a protected route reaches home', asyn }); await page.goto('/app/generate', { waitUntil: 'domcontentloaded' }); await expect(page.getByRole('heading', { name: 'Ready to Generate' })).toBeVisible({ timeout: 15_000 }); + // eslint-disable-next-line no-restricted-properties -- observation window: let the first onboarding delay run partway before navigating. await page.waitForTimeout(650); await page.locator('header a[href="/app"]').first().click(); diff --git a/e2e/navigation-performance.spec.ts b/e2e/navigation-performance.spec.ts index d6de7af2f..c421e266e 100644 --- a/e2e/navigation-performance.spec.ts +++ b/e2e/navigation-performance.spec.ts @@ -268,6 +268,7 @@ test('hover intent is limited to fine pointers and click stays in-document', asy if (supportsHover) { await expect.poll(requestCount).toBe(1); } else { + // eslint-disable-next-line no-restricted-properties -- observation window: no prefetch may start without hover intent. await page.waitForTimeout(INTENT_OBSERVATION_MS); expect(requestCount()).toBe(0); } @@ -330,6 +331,7 @@ for (const [label, connection] of [ expect(requestCount()).toBe(0); await signalAllowedIntent(page, link); + // eslint-disable-next-line no-restricted-properties -- observation window: this connection must not start a speculative request. await page.waitForTimeout(INTENT_OBSERVATION_MS); expect(requestCount()).toBe(0); @@ -391,6 +393,7 @@ test('subscription reads stay cached across sibling tool routes', async ({ page await openHome(page); await homeToolLink(page, 'attack').click(); await waitForContent(page, attackContent); + // eslint-disable-next-line no-restricted-properties -- observation window: no extra subscription request may follow. await page.waitForTimeout(100); expect(subscriptionRequests).toEqual(['user_settings', 'ai_requests']); diff --git a/e2e/workspace-collaboration.spec.ts b/e2e/workspace-collaboration.spec.ts index c9b23392d..28ecd29c5 100644 --- a/e2e/workspace-collaboration.spec.ts +++ b/e2e/workspace-collaboration.spec.ts @@ -281,6 +281,7 @@ test("real local users collaborate, recover, review, restore, and revoke access" await cdp.send("Input.imeSetComposition", { text: " tűrő", selectionStart: 5, selectionEnd: 5 }); await expect(owner.page.locator(".ProseMirror")).not.toContainText("tűrő"); await cdp.send("Input.imeSetComposition", { text: "", selectionStart: 0, selectionEnd: 0 }); + // eslint-disable-next-line no-restricted-properties -- observation window: a cancelled IME composition must not create a suggestion. await owner.page.waitForTimeout(500); await expect(member.page.getByRole("button", { name: "Reject" })).toHaveCount(0); await expect(owner.page.locator(".ProseMirror")).not.toContainText("tűrő"); diff --git a/eslint.config.js b/eslint.config.js index cecb5cf13..5bab31d96 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -23,4 +23,34 @@ export default tseslint.config( "@typescript-eslint/no-unused-vars": "off", }, }, + { + // A promise nobody awaits or catches turns a failed write into an + // unhandled rejection with no message for the user. Type-aware, so it + // runs on the client app only. + files: ["src/**/*.{ts,tsx}"], + ignores: ["src/test/**"], + languageOptions: { + parserOptions: { projectService: true, tsconfigRootDir: import.meta.dirname }, + }, + rules: { + "@typescript-eslint/no-floating-promises": [ + "error", + { allowForKnownSafeCalls: [{ from: "package", name: "NavigateFunction", package: "react-router" }] }, + ], + "@typescript-eslint/no-misused-promises": [ + "error", + { checksConditionals: false, checksVoidReturn: { attributes: false } }, + ], + }, + }, + { + // Fixed sleeps pass or fail with machine speed. Wait for a condition. + files: ["e2e/**/*.ts", "agentic/**/*.ts", ".claude/skills/verify-debatelab/**/*.ts"], + rules: { + "no-restricted-properties": [ + "error", + { property: "waitForTimeout", message: "Wait for a condition (expect(...).toBeVisible(), waitForResponse, expect.poll) instead of a fixed sleep." }, + ], + }, + }, ); diff --git a/knip.json b/knip.json new file mode 100644 index 000000000..55052f431 --- /dev/null +++ b/knip.json @@ -0,0 +1,47 @@ +{ + "$schema": "https://unpkg.com/knip@6/schema.json", + "entry": [ + "scripts/*.{mjs,ts}", + "scripts/staging/seed.mjs", + "e2e/full-stack/fake-openrouter.mjs", + "e2e.config.ts", + "agentic/**/*.e2e.ts", + "services/collaboration/src/index.ts", + "services/collaboration/src/**/*.test.ts", + ".claude/skills/verify-debatelab/bin/*.mjs", + ".claude/skills/verify-debatelab/recipes/*.spec.ts", + "e2e/**/*.setup.ts", + ".claude/skills/verify-debatelab/playwright.verify.config.ts" + ], + "project": [ + "src/**/*.{ts,tsx}", + "scripts/**/*.{mjs,ts}", + "e2e/**/*.{ts,mjs}", + "agentic/**/*.ts", + "services/collaboration/src/**/*.ts", + ".claude/skills/verify-debatelab/**/*.{ts,mjs}" + ], + "playwright": { + "config": [ + "playwright.config.ts", + "playwright.production.config.ts", + "playwright.workspace.config.ts" + ] + }, + "ignore": [ + "src/lib/workspace/document-conversion.ts" + ], + "ignoreBinaries": [ + "supabase" + ], + "ignoreDependencies": [ + "squawk-cli" + ], + "include": [ + "files", + "dependencies", + "devDependencies", + "unlisted", + "binaries" + ] +} diff --git a/package-lock.json b/package-lock.json index 4bcace633..34edc7d11 100644 --- a/package-lock.json +++ b/package-lock.json @@ -103,13 +103,16 @@ "eslint-plugin-react-refresh": "^0.4.20", "globals": "^15.15.0", "jsdom": "^20.0.3", + "knip": "^6.39.0", "postcss": "^8.5.6", + "squawk-cli": "^2.67.0", "tailwindcss": "^3.4.17", "tsx": "4.19.2", "typescript": "^5.8.3", "typescript-eslint": "^8.38.0", "vite": "^7.3.1", - "vitest": "^4.1.8" + "vitest": "^4.1.8", + "yaml": "^2.9.1" }, "engines": { "node": "22.x" @@ -621,6 +624,40 @@ "playwright": ">=1.63.0 <2" } }, + "node_modules/@emnapi/core": { + "version": "1.11.2", + "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.11.2.tgz", + "integrity": "sha512-TC8MkTuZUtcTSiFeuC0ksCh9QIJ5+F21MvZ4Wn4ORfYaFJ/0dsiudv5tVkejgwZlwQ39jL9WWDe2lz8x0WglOA==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/wasi-threads": "1.2.2", + "tslib": "^2.4.0" + } + }, + "node_modules/@emnapi/runtime": { + "version": "1.11.2", + "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.11.2.tgz", + "integrity": "sha512-kyOl3X0DuTiT1h2ft8r2fYO8JYtU9a9Xis/zBSiGArNaagCOWx90N1k2wxp18czFDH+OgcWGb5ZP/XMt3dcyPA==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@emnapi/wasi-threads": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.2.tgz", + "integrity": "sha512-c95qOXkHdydNKhscBTebqEC1CVAZpyqOfVfBzQ1qgzyl3gfeldUjIggDbIZgDKsHLgnsM+igH7TJ/eAasaVuMA==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, "node_modules/@esbuild/aix-ppc64": { "version": "0.28.2", "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.2.tgz", @@ -1949,6 +1986,28 @@ "node": "^22.20 || ^24.12 || >=25" } }, + "node_modules/@napi-rs/wasm-runtime": { + "version": "1.2.5", + "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.2.5.tgz", + "integrity": "sha512-HshSvXzmBxNzqejd3k/KemgQ3hdREUYFRRrgxOZ+gPljDIsO2SDZrVZMd4Z/HsxudQbE62OVyVFE8z9BCurfjA==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@tybys/wasm-util": "^0.10.3" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=23.5.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + }, + "peerDependencies": { + "@emnapi/core": "^1.7.1 || ^2.0.0-alpha.4", + "@emnapi/runtime": "^1.7.1 || ^2.0.0-alpha.4" + } + }, "node_modules/@nodelib/fs.scandir": { "version": "2.1.5", "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz", @@ -1971,32 +2030,684 @@ "node": ">= 8" } }, - "node_modules/@nodelib/fs.walk": { - "version": "1.2.8", - "resolved": "https://registry.npmjs.org/@nodelib/fs.walk/-/fs.walk-1.2.8.tgz", - "integrity": "sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==", + "node_modules/@nodelib/fs.walk": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/@nodelib/fs.walk/-/fs.walk-1.2.8.tgz", + "integrity": "sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==", + "license": "MIT", + "dependencies": { + "@nodelib/fs.scandir": "2.1.5", + "fastq": "^1.6.0" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/@openrouter/ai-sdk-provider": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/@openrouter/ai-sdk-provider/-/ai-sdk-provider-3.1.0.tgz", + "integrity": "sha512-BQy1TA9fKrh47s9ivsO6FY5QimN7GUF0i8Qon3pkxQclEgShso0dClpcWn8OIyPLEZZPFaFKgI8TW4S/O0p0Dw==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=22" + }, + "peerDependencies": { + "ai": "^7.0.0", + "zod": "^3.25.76 || ^4.1.8" + } + }, + "node_modules/@oxc-parser/binding-android-arm-eabi": { + "version": "0.150.0", + "resolved": "https://registry.npmjs.org/@oxc-parser/binding-android-arm-eabi/-/binding-android-arm-eabi-0.150.0.tgz", + "integrity": "sha512-oQef2Zu4Prz1KLKznz3HqZzU9uVoA5PMoDZuuLmqms7hKmKSAPzlaMnLllJq3t+rgKmfJJ2siPrpZfFrW06btw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxc-parser/binding-android-arm64": { + "version": "0.150.0", + "resolved": "https://registry.npmjs.org/@oxc-parser/binding-android-arm64/-/binding-android-arm64-0.150.0.tgz", + "integrity": "sha512-B6ofpoFiAUwIZ0MJ2IgHPvZK8FAtL4qzSZRwOkAKIfxEobE1mQC8nDdiFZj7pUaJiVUVCsfkMsBJKedzO8rZvA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxc-parser/binding-darwin-arm64": { + "version": "0.150.0", + "resolved": "https://registry.npmjs.org/@oxc-parser/binding-darwin-arm64/-/binding-darwin-arm64-0.150.0.tgz", + "integrity": "sha512-J+9IHKzx/bSz1JetOfD4zKXSK9sOm4/a7+0qomJODcTL6JsRXGeV/ZdkAPSIDydfFmWzYCraMlQEosSZEyBYkQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxc-parser/binding-darwin-x64": { + "version": "0.150.0", + "resolved": "https://registry.npmjs.org/@oxc-parser/binding-darwin-x64/-/binding-darwin-x64-0.150.0.tgz", + "integrity": "sha512-v6IPfcAcSYrBWXV5Tce1DmxDMXLhEYpIIWiRFPJopgCVscZdLaV4MRQOUxvL3usQlw+yrwYOjBFB9IwBx+jUiQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxc-parser/binding-freebsd-x64": { + "version": "0.150.0", + "resolved": "https://registry.npmjs.org/@oxc-parser/binding-freebsd-x64/-/binding-freebsd-x64-0.150.0.tgz", + "integrity": "sha512-AoR/4jD02HET0KO0yNT4nbTE+XJUxiO9jB1X/ycEjUE3WrIJ3IjV/Vf+gskhWLcqqeXfvNnkDtBR7epllAm88A==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxc-parser/binding-linux-arm-gnueabihf": { + "version": "0.150.0", + "resolved": "https://registry.npmjs.org/@oxc-parser/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-0.150.0.tgz", + "integrity": "sha512-A/hycCFjLUrCmLoL5O/vBp40ohlaXO1Ta3v5hqYZxicYFs129wZmgZJggcW4mYGYbZebQLhup+N8JjeQl8qwyw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxc-parser/binding-linux-arm-musleabihf": { + "version": "0.150.0", + "resolved": "https://registry.npmjs.org/@oxc-parser/binding-linux-arm-musleabihf/-/binding-linux-arm-musleabihf-0.150.0.tgz", + "integrity": "sha512-pbqahg1Pkz7J4RKmXm30s/iQu99hv64ayXCu8P4p75HcEH5azOdR4eGqiB6lFGkFR3mMpzaYsSKLkS8oJbjmeQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxc-parser/binding-linux-arm64-gnu": { + "version": "0.150.0", + "resolved": "https://registry.npmjs.org/@oxc-parser/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-0.150.0.tgz", + "integrity": "sha512-HV11aRbBQGwqv8bEo+K/6qr88uB4fEe1mhXncMhlVCrj+WpBSraxrUzHaPVmeQRU6x6x8v/3DuCbbo93rpp+fw==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxc-parser/binding-linux-arm64-musl": { + "version": "0.150.0", + "resolved": "https://registry.npmjs.org/@oxc-parser/binding-linux-arm64-musl/-/binding-linux-arm64-musl-0.150.0.tgz", + "integrity": "sha512-k6pVkJqALwtEuP4zukVhGRhdIy4+ofChUIUUsAHHyqDZlNsknmel3JjbggrJiWGaes6h/dIcWmEXsKW4SmK9oQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxc-parser/binding-linux-ppc64-gnu": { + "version": "0.150.0", + "resolved": "https://registry.npmjs.org/@oxc-parser/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-0.150.0.tgz", + "integrity": "sha512-tEFg39mw/rHO5n8GK2DR4ZMFfsPQZtnQIPbsIpjoQRomJc/2tRfsCSmCT6gNit+NZGoeJG5aH9ATDH5bf0WnoQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxc-parser/binding-linux-riscv64-gnu": { + "version": "0.150.0", + "resolved": "https://registry.npmjs.org/@oxc-parser/binding-linux-riscv64-gnu/-/binding-linux-riscv64-gnu-0.150.0.tgz", + "integrity": "sha512-0JO7IFkoek6HqV589Menx3hJySNXi6quRhO4tq2P7kpEHKEXoDATnoPVUpn5B7gDH2KLkdo751N0uIrGJFCTCw==", + "cpu": [ + "riscv64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxc-parser/binding-linux-riscv64-musl": { + "version": "0.150.0", + "resolved": "https://registry.npmjs.org/@oxc-parser/binding-linux-riscv64-musl/-/binding-linux-riscv64-musl-0.150.0.tgz", + "integrity": "sha512-7XPzREnyAS5wHU9aB+49Uaqgoo1gVCklHc3DRlc3fJy2tXD/eAJFN1QFz/crj+Ulup5P1+axNREF+/RGaB/IRA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxc-parser/binding-linux-s390x-gnu": { + "version": "0.150.0", + "resolved": "https://registry.npmjs.org/@oxc-parser/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-0.150.0.tgz", + "integrity": "sha512-7n7ZxcbRWDFaTdyj8M8p0O9OfzoMKm8O6syBAIgcSutbOALq0Bb9G52Me+XH0anMLZV+NgXc726gqgG2q39vcQ==", + "cpu": [ + "s390x" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxc-parser/binding-linux-x64-gnu": { + "version": "0.150.0", + "resolved": "https://registry.npmjs.org/@oxc-parser/binding-linux-x64-gnu/-/binding-linux-x64-gnu-0.150.0.tgz", + "integrity": "sha512-Vx0GSA9ZCRTUiczoEQnIIyXkMvtEY8uc6IiwLQtMe5ci2sXPqjrry0Ek5fsPP1k2kCzkML3ow9UOOEgnEDi7Bw==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxc-parser/binding-linux-x64-musl": { + "version": "0.150.0", + "resolved": "https://registry.npmjs.org/@oxc-parser/binding-linux-x64-musl/-/binding-linux-x64-musl-0.150.0.tgz", + "integrity": "sha512-ii4/9m3viDLssMnfXU7/Pni/3nYplApuGayceX4qsVGaqqQJCx3tHIH9M/HWIeSty5i8LjS21zPYT6lVIkwLxw==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxc-parser/binding-openharmony-arm64": { + "version": "0.150.0", + "resolved": "https://registry.npmjs.org/@oxc-parser/binding-openharmony-arm64/-/binding-openharmony-arm64-0.150.0.tgz", + "integrity": "sha512-ZtoxX5rez36ZWkwtiEhjkCPnlTPoQ2I7lIL0IYZ1yjxMYohbvoOjBmUIZzrf9W/HouONq0omIfTeCWEY+R380w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxc-parser/binding-win32-arm64-msvc": { + "version": "0.150.0", + "resolved": "https://registry.npmjs.org/@oxc-parser/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-0.150.0.tgz", + "integrity": "sha512-VqeRb5JX/bKYBrff7TUDvJyKY0914UzuCkuXIGxJS4FUmvMNfqkCbc7Sq90iMz9DlmAtlggwaBYQ9eg3h3ltiw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxc-parser/binding-win32-ia32-msvc": { + "version": "0.150.0", + "resolved": "https://registry.npmjs.org/@oxc-parser/binding-win32-ia32-msvc/-/binding-win32-ia32-msvc-0.150.0.tgz", + "integrity": "sha512-EPqJfeZ4Pgg2BJSsCV8GozxV0FDltRzpVtGVa1r2noJu1iu+opOw3gtBWXwIo9odCT/MdFfyHxdy0/CRqs3OqA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxc-parser/binding-win32-x64-msvc": { + "version": "0.150.0", + "resolved": "https://registry.npmjs.org/@oxc-parser/binding-win32-x64-msvc/-/binding-win32-x64-msvc-0.150.0.tgz", + "integrity": "sha512-n5YMzbqwPQqozbkrexi0lNZrUz5cnPHalYpFWe6Dau7AmKIWNo+y24IwzDuZEQtEdUHuKhXmMMih/MPjOI+CMw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxc-project/types": { + "version": "0.150.0", + "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.150.0.tgz", + "integrity": "sha512-rDS5/31E9HfPl/CIzGrn0DOlvBbXFseQ5URJ9sYMfstbKLD/c6Gm9vmRzRGDdAXyOIL4zmO37lc9RIwYqVruZw==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/oxc-project" + } + }, + "node_modules/@oxc-resolver/binding-android-arm-eabi": { + "version": "11.24.2", + "resolved": "https://registry.npmjs.org/@oxc-resolver/binding-android-arm-eabi/-/binding-android-arm-eabi-11.24.2.tgz", + "integrity": "sha512-y09e0L0SRI2OA2tUIrjBgoV3eH5hvUKXNkJqXmNo5V2WxIjyC7I7aJfRLMEVpA8yi95f90gFDvO0VMgrDw+vwA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@oxc-resolver/binding-android-arm64": { + "version": "11.24.2", + "resolved": "https://registry.npmjs.org/@oxc-resolver/binding-android-arm64/-/binding-android-arm64-11.24.2.tgz", + "integrity": "sha512-cl4icWaZFnLdg8m6qtnh5rBMuGbxc/ptStFHLeCNwr+2cZjkjNwQu/jYRS0CHlnPecOJMpuS5M6/BH+0J/YkEg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@oxc-resolver/binding-darwin-arm64": { + "version": "11.24.2", + "resolved": "https://registry.npmjs.org/@oxc-resolver/binding-darwin-arm64/-/binding-darwin-arm64-11.24.2.tgz", + "integrity": "sha512-At29QEMF6HajbQvgY8K6OXnHD1x9rad74xBEfmCB6ZqCGsdq75aK7tOYcTbOanMy8qdIBrfL3SMr3p/lfSlb9w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@oxc-resolver/binding-darwin-x64": { + "version": "11.24.2", + "resolved": "https://registry.npmjs.org/@oxc-resolver/binding-darwin-x64/-/binding-darwin-x64-11.24.2.tgz", + "integrity": "sha512-A5Kqr1EUj4oIL5CF4WRssq/o5P0Y11cwoFouMRmQ7YnC/A8V93nv1nb7aSU8HwcgmXropjLNkVTl4MN87cu28Q==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@oxc-resolver/binding-freebsd-x64": { + "version": "11.24.2", + "resolved": "https://registry.npmjs.org/@oxc-resolver/binding-freebsd-x64/-/binding-freebsd-x64-11.24.2.tgz", + "integrity": "sha512-R5xkRBRRz7ceH/P5Jrc6G7FmdUdgpLYyESFAUDVTNQ9K0sGPxcp4ljiwEwEqsvNcQ4sYbMRrWcHHBCu7ksAJVw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@oxc-resolver/binding-linux-arm-gnueabihf": { + "version": "11.24.2", + "resolved": "https://registry.npmjs.org/@oxc-resolver/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-11.24.2.tgz", + "integrity": "sha512-k/RuYL4L/R58IBn3wT5ma3Wh4k62bp1eYCFRWCmMsasUOqL+H6sW0VGFadEzKWXFFlz+2uIMoeMk9ySSZJHgbg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@oxc-resolver/binding-linux-arm-musleabihf": { + "version": "11.24.2", + "resolved": "https://registry.npmjs.org/@oxc-resolver/binding-linux-arm-musleabihf/-/binding-linux-arm-musleabihf-11.24.2.tgz", + "integrity": "sha512-bnHAak3ujYfH5pKk4NieFNbvYvernfoQDgwLddbZ3OtMYrem87/qjlA+u+aKG0oZcqSLGCful/6/CEA+aeAgaA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@oxc-resolver/binding-linux-arm64-gnu": { + "version": "11.24.2", + "resolved": "https://registry.npmjs.org/@oxc-resolver/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-11.24.2.tgz", + "integrity": "sha512-vDT3KHgzYp47gmtNOqL2VNhCyl5Zv643eyxm//A68J8DeUGXrvD1pZFiaT4jSfe+RInfnn1R2yVHye4enx6RnA==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@oxc-resolver/binding-linux-arm64-musl": { + "version": "11.24.2", + "resolved": "https://registry.npmjs.org/@oxc-resolver/binding-linux-arm64-musl/-/binding-linux-arm64-musl-11.24.2.tgz", + "integrity": "sha512-+kMlQvbzfyEYtu5FcjE4p+ttBLpKW4d/AsAsuE69BxV6V4twZJeIQZFfD8gh/wqglY0MkPSezWXQH0jBV13MUw==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@oxc-resolver/binding-linux-ppc64-gnu": { + "version": "11.24.2", + "resolved": "https://registry.npmjs.org/@oxc-resolver/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-11.24.2.tgz", + "integrity": "sha512-shjfMhmZ3gq9fv/w7bi3PnZlgOPG+2QAOFf0BJF0EgBSIGZ6PMLN2zbGEblTUYB/NKVDRyYhE2ff3dJ1QqNPkA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@oxc-resolver/binding-linux-riscv64-gnu": { + "version": "11.24.2", + "resolved": "https://registry.npmjs.org/@oxc-resolver/binding-linux-riscv64-gnu/-/binding-linux-riscv64-gnu-11.24.2.tgz", + "integrity": "sha512-zGelwFR5oRo+b69k8Lrzun86DyUHzfKN6cnjbR9l7Z7NIRznOE/2ZvPa1IUKqAL2PzAXOdwkfVqNvO1H2RlpAw==", + "cpu": [ + "riscv64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@oxc-resolver/binding-linux-riscv64-musl": { + "version": "11.24.2", + "resolved": "https://registry.npmjs.org/@oxc-resolver/binding-linux-riscv64-musl/-/binding-linux-riscv64-musl-11.24.2.tgz", + "integrity": "sha512-qxZ1SWCXJY0eyhAlP6Lmo9F2Nrtx7EkYj9oCgL8apDPCwXwCEDA2U697bbT81JIc2IrVjxO4KX6WU2N+oN9Z4w==", + "cpu": [ + "riscv64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@oxc-resolver/binding-linux-s390x-gnu": { + "version": "11.24.2", + "resolved": "https://registry.npmjs.org/@oxc-resolver/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-11.24.2.tgz", + "integrity": "sha512-sGCecF3cx2DFlH4t/z7ApnOnXqN48p5p5mlHDEnHTAukQa2P+qMVE4CwyWE9W+q/m3QJ7kKfGrIjax31f44oFQ==", + "cpu": [ + "s390x" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@oxc-resolver/binding-linux-x64-gnu": { + "version": "11.24.2", + "resolved": "https://registry.npmjs.org/@oxc-resolver/binding-linux-x64-gnu/-/binding-linux-x64-gnu-11.24.2.tgz", + "integrity": "sha512-k/VlMMcSzMlahb3/fENM4rTlsJ0s3fFROA0KXPBmKggqmTSaE383sl8F3KCOXPLmVsYfW6hCitMhXCEtNeZxxg==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@oxc-resolver/binding-linux-x64-musl": { + "version": "11.24.2", + "resolved": "https://registry.npmjs.org/@oxc-resolver/binding-linux-x64-musl/-/binding-linux-x64-musl-11.24.2.tgz", + "integrity": "sha512-8hbnZyNi97b/8wapYaIF9+t9GmZKBW2vunaOc3h9HGJptH7b7XpvZqOTBSm/MpTjr7H497BlgOaSfLUdhmy2bw==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@oxc-resolver/binding-openharmony-arm64": { + "version": "11.24.2", + "resolved": "https://registry.npmjs.org/@oxc-resolver/binding-openharmony-arm64/-/binding-openharmony-arm64-11.24.2.tgz", + "integrity": "sha512-MvyGik3a6pVgZ0t/kWlbmFxFLmXQJwgLsY2eYFHLpy0wGwRbfzeIGgDwQ3kXqE30z+kSXennRkCrT7TUvkptNg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ] + }, + "node_modules/@oxc-resolver/binding-wasm32-wasi": { + "version": "11.24.2", + "resolved": "https://registry.npmjs.org/@oxc-resolver/binding-wasm32-wasi/-/binding-wasm32-wasi-11.24.2.tgz", + "integrity": "sha512-vHcssMPwO08RTvj/c0iOBz90attxyG3wQJ0dTcyEQK43LRpcdLWZlV5feBhv6Isn6ahbQIzHbCgfa81+RiML0Q==", + "cpu": [ + "wasm32" + ], + "dev": true, "license": "MIT", + "optional": true, "dependencies": { - "@nodelib/fs.scandir": "2.1.5", - "fastq": "^1.6.0" + "@emnapi/core": "1.11.2", + "@emnapi/runtime": "1.11.2", + "@napi-rs/wasm-runtime": "^1.1.6" }, "engines": { - "node": ">= 8" + "node": ">=14.0.0" } }, - "node_modules/@openrouter/ai-sdk-provider": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/@openrouter/ai-sdk-provider/-/ai-sdk-provider-3.1.0.tgz", - "integrity": "sha512-BQy1TA9fKrh47s9ivsO6FY5QimN7GUF0i8Qon3pkxQclEgShso0dClpcWn8OIyPLEZZPFaFKgI8TW4S/O0p0Dw==", + "node_modules/@oxc-resolver/binding-win32-arm64-msvc": { + "version": "11.24.2", + "resolved": "https://registry.npmjs.org/@oxc-resolver/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-11.24.2.tgz", + "integrity": "sha512-uokJqro2iBqkFvJdKQLP7d8/BUmFwESQFVmIJUQKj1Xn1a/LysJoe1vmeECLF5b3jsV8CAL5sEMJXX6SdK9Nhg==", + "cpu": [ + "arm64" + ], "dev": true, - "license": "Apache-2.0", - "engines": { - "node": ">=22" - }, - "peerDependencies": { - "ai": "^7.0.0", - "zod": "^3.25.76 || ^4.1.8" - } + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@oxc-resolver/binding-win32-x64-msvc": { + "version": "11.24.2", + "resolved": "https://registry.npmjs.org/@oxc-resolver/binding-win32-x64-msvc/-/binding-win32-x64-msvc-11.24.2.tgz", + "integrity": "sha512-UqGPmo56KDfLlfXFAFIrNflHT8tFxWGEivWg3Zeyp4Uy2NlKN1FGPr6/BxcLGG3+kZ6Wp14g5Uj+n71boqZfiw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] }, "node_modules/@playwright/test": { "version": "1.63.0", @@ -4011,6 +4722,116 @@ "node": ">= 18" } }, + "node_modules/@squawk-cli/darwin-arm64": { + "version": "2.67.0", + "resolved": "https://registry.npmjs.org/@squawk-cli/darwin-arm64/-/darwin-arm64-2.67.0.tgz", + "integrity": "sha512-o/qIhh3doZzxcgmudv5a1hg7eYc4WqieDn4jDSnqsmvRWHr3JaTGggZex4CWzaIujSWs+LvsiwMd11Vi2iv15g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "(Apache-2.0 OR MIT)", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@squawk-cli/darwin-x64": { + "version": "2.67.0", + "resolved": "https://registry.npmjs.org/@squawk-cli/darwin-x64/-/darwin-x64-2.67.0.tgz", + "integrity": "sha512-+uRpodtYM/xPxgqPzZRE9xpCy5Ay+1J9hZ/Z+4r3R5BEPKIRiuVO6TK7WmHQgcsZsPcJN4NA0RHuC9lKDjz3RA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "(Apache-2.0 OR MIT)", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@squawk-cli/linux-arm64": { + "version": "2.67.0", + "resolved": "https://registry.npmjs.org/@squawk-cli/linux-arm64/-/linux-arm64-2.67.0.tgz", + "integrity": "sha512-rU/LRPxKoQ6y5wKrhy9Z15dFj/UN22qHoGE2nd6RCZOpGWaPw9ySiuXloVs4ncAGqVd84Sxalx7/22GChCuusA==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "(Apache-2.0 OR MIT)", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@squawk-cli/linux-arm64-musl": { + "version": "2.67.0", + "resolved": "https://registry.npmjs.org/@squawk-cli/linux-arm64-musl/-/linux-arm64-musl-2.67.0.tgz", + "integrity": "sha512-3GyY99ZIfqq9HBugC/FVlwq/DmmV5O04O65iQi2hibcPVmyGBtnF/GVHcnHnnAoV787UAwG9Jhiq0k/qe7WgXQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "(Apache-2.0 OR MIT)", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@squawk-cli/linux-x64": { + "version": "2.67.0", + "resolved": "https://registry.npmjs.org/@squawk-cli/linux-x64/-/linux-x64-2.67.0.tgz", + "integrity": "sha512-4hGTajHD2TDGoQkzve4Lg71WBxGHLIiF+DX2DkHB3pgfqmIXYww9zFHV1Zk/zMCpq7n+Z4UEvqnRb8W9OBgJ2w==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "(Apache-2.0 OR MIT)", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@squawk-cli/linux-x64-musl": { + "version": "2.67.0", + "resolved": "https://registry.npmjs.org/@squawk-cli/linux-x64-musl/-/linux-x64-musl-2.67.0.tgz", + "integrity": "sha512-xV9AlCj0imIOKFVcFiJAnznbIYcZnP1SB9J+rYMjwjbN2ml1y6pDYdsulf5/iUWl7GgY4k+J3QkXVMcEsb4xuw==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "(Apache-2.0 OR MIT)", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@squawk-cli/win32-x64": { + "version": "2.67.0", + "resolved": "https://registry.npmjs.org/@squawk-cli/win32-x64/-/win32-x64-2.67.0.tgz", + "integrity": "sha512-KH8lYlMBYWiivQxTYOFongUm8cmLO0VkBtJffcTqkH2rw8GU5HBm+Btfo7tF72FCDjN44/wcyY4WFtxtmOkw2Q==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "(Apache-2.0 OR MIT)", + "optional": true, + "os": [ + "win32" + ] + }, "node_modules/@standard-schema/spec": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz", @@ -5010,6 +5831,17 @@ "node": ">= 10" } }, + "node_modules/@tybys/wasm-util": { + "version": "0.10.4", + "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.4.tgz", + "integrity": "sha512-W3c4gRigFS0T/Ma4qIYF3GDAc5AQdHb1yL5znJT1Zv1YaD9Kitx656wBjvr19qbiosmZT8lWDM5BEMynUqX65A==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, "node_modules/@types/aria-query": { "version": "5.0.4", "resolved": "https://registry.npmjs.org/@types/aria-query/-/aria-query-5.0.4.tgz", @@ -8386,6 +9218,16 @@ "reusify": "^1.0.4" } }, + "node_modules/fd-package-json": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/fd-package-json/-/fd-package-json-2.0.0.tgz", + "integrity": "sha512-jKmm9YtsNXN789RS/0mSzOC1NUq9mkVd65vbSSVsKdjGvYXBuE4oWe2QOEoFeRmJg+lPuZxpmrfFclNhoRMneQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "walk-up-path": "^4.0.0" + } + }, "node_modules/fd-slicer": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/fd-slicer/-/fd-slicer-1.1.0.tgz", @@ -8503,6 +9345,23 @@ "node": ">= 6" } }, + "node_modules/formatly": { + "version": "0.7.1", + "resolved": "https://registry.npmjs.org/formatly/-/formatly-0.7.1.tgz", + "integrity": "sha512-ESuCEJE0SGgabl9q+zABI8LhJDmF+8nhpuqDzuM48HfJ9QTD5o6PqFJDFZY7w6f8xsh5lixuj5nJsQNvYOWvKQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "fd-package-json": "^2.0.0", + "package-manager-detector": "^1.8.0" + }, + "bin": { + "formatly": "bin/index.mjs" + }, + "engines": { + "node": ">=18.3.0" + } + }, "node_modules/forwarded": { "version": "0.2.0", "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz", @@ -9603,6 +10462,99 @@ "node": ">=6" } }, + "node_modules/knip": { + "version": "6.39.0", + "resolved": "https://registry.npmjs.org/knip/-/knip-6.39.0.tgz", + "integrity": "sha512-Y7g6Ve1ATMox1u1v+gx3z9LTlvzVLTJISm/LVn9TgBU4qc7OoC63AWGO8qCFPVDDdaqlYdXkDDvvO1METBfTRw==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/webpro" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/knip" + } + ], + "license": "ISC", + "dependencies": { + "fdir": "^6.5.0", + "formatly": "^0.7.0", + "get-tsconfig": "4.14.3", + "jiti": "^2.7.0", + "oxc-parser": "^0.150.0", + "oxc-resolver": "11.24.2", + "picomatch": "^4.0.7", + "smol-toml": "^1.8.0", + "strip-json-comments": "5.0.3", + "tinyglobby": "^0.2.17", + "unbash": "^4.0.11", + "yaml": "^2.9.1", + "zod": "^4.4.3" + }, + "bin": { + "knip": "bin/knip.js", + "knip-bun": "bin/knip-bun.js" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/knip/node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/knip/node_modules/jiti": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/jiti/-/jiti-2.7.0.tgz", + "integrity": "sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ==", + "dev": true, + "license": "MIT", + "bin": { + "jiti": "lib/jiti-cli.mjs" + } + }, + "node_modules/knip/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/knip/node_modules/strip-json-comments": { + "version": "5.0.3", + "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-5.0.3.tgz", + "integrity": "sha512-1tB5mhVo7U+ETBKNf92xT4hrQa3pm0MZ0PQvuDnWgAAGHDsfp4lPSpiS6psrSiet87wyGPh9ft6wmhOMQ0hDiw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.16" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/levn": { "version": "0.4.1", "resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz", @@ -10860,6 +11812,74 @@ "integrity": "sha512-TvAWxi0nDe1j/rtMcWcIj94+Ffe6n7zhow33h40SKxmsmozs6dz/e+EajymfoFcHd7sxNn8yHM8839uixMOV6g==", "license": "MIT" }, + "node_modules/oxc-parser": { + "version": "0.150.0", + "resolved": "https://registry.npmjs.org/oxc-parser/-/oxc-parser-0.150.0.tgz", + "integrity": "sha512-zwajKw1GUa57IOdafIS7/yOObGF2NihwIfZfs5yJTRprMSkiwhWOc/k+n8bwnUPbHr2/XsjfBO8KyTPaW3VpWg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@oxc-project/types": "^0.150.0" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "funding": { + "url": "https://github.com/sponsors/oxc-project" + }, + "optionalDependencies": { + "@oxc-parser/binding-android-arm-eabi": "0.150.0", + "@oxc-parser/binding-android-arm64": "0.150.0", + "@oxc-parser/binding-darwin-arm64": "0.150.0", + "@oxc-parser/binding-darwin-x64": "0.150.0", + "@oxc-parser/binding-freebsd-x64": "0.150.0", + "@oxc-parser/binding-linux-arm-gnueabihf": "0.150.0", + "@oxc-parser/binding-linux-arm-musleabihf": "0.150.0", + "@oxc-parser/binding-linux-arm64-gnu": "0.150.0", + "@oxc-parser/binding-linux-arm64-musl": "0.150.0", + "@oxc-parser/binding-linux-ppc64-gnu": "0.150.0", + "@oxc-parser/binding-linux-riscv64-gnu": "0.150.0", + "@oxc-parser/binding-linux-riscv64-musl": "0.150.0", + "@oxc-parser/binding-linux-s390x-gnu": "0.150.0", + "@oxc-parser/binding-linux-x64-gnu": "0.150.0", + "@oxc-parser/binding-linux-x64-musl": "0.150.0", + "@oxc-parser/binding-openharmony-arm64": "0.150.0", + "@oxc-parser/binding-win32-arm64-msvc": "0.150.0", + "@oxc-parser/binding-win32-ia32-msvc": "0.150.0", + "@oxc-parser/binding-win32-x64-msvc": "0.150.0" + } + }, + "node_modules/oxc-resolver": { + "version": "11.24.2", + "resolved": "https://registry.npmjs.org/oxc-resolver/-/oxc-resolver-11.24.2.tgz", + "integrity": "sha512-FY91FiDBj7ls5MsFS9jN3tjz2o0/zsdSsymlakySaBwVJZorHhkWyICLZMKxlu1R9vYo+sd3z1jwb4J8x7bNDw==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/Boshen" + }, + "optionalDependencies": { + "@oxc-resolver/binding-android-arm-eabi": "11.24.2", + "@oxc-resolver/binding-android-arm64": "11.24.2", + "@oxc-resolver/binding-darwin-arm64": "11.24.2", + "@oxc-resolver/binding-darwin-x64": "11.24.2", + "@oxc-resolver/binding-freebsd-x64": "11.24.2", + "@oxc-resolver/binding-linux-arm-gnueabihf": "11.24.2", + "@oxc-resolver/binding-linux-arm-musleabihf": "11.24.2", + "@oxc-resolver/binding-linux-arm64-gnu": "11.24.2", + "@oxc-resolver/binding-linux-arm64-musl": "11.24.2", + "@oxc-resolver/binding-linux-ppc64-gnu": "11.24.2", + "@oxc-resolver/binding-linux-riscv64-gnu": "11.24.2", + "@oxc-resolver/binding-linux-riscv64-musl": "11.24.2", + "@oxc-resolver/binding-linux-s390x-gnu": "11.24.2", + "@oxc-resolver/binding-linux-x64-gnu": "11.24.2", + "@oxc-resolver/binding-linux-x64-musl": "11.24.2", + "@oxc-resolver/binding-openharmony-arm64": "11.24.2", + "@oxc-resolver/binding-wasm32-wasi": "11.24.2", + "@oxc-resolver/binding-win32-arm64-msvc": "11.24.2", + "@oxc-resolver/binding-win32-x64-msvc": "11.24.2" + } + }, "node_modules/p-limit": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz", @@ -10898,6 +11918,13 @@ "integrity": "sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==", "license": "BlueOak-1.0.0" }, + "node_modules/package-manager-detector": { + "version": "1.9.0", + "resolved": "https://registry.npmjs.org/package-manager-detector/-/package-manager-detector-1.9.0.tgz", + "integrity": "sha512-zAMfbta9nPA8AKkA4WdmNAT8R8s6Dk0/xwPsqNnRidMFcLo1YkLuPDTgs2f64XdzS8vbLNnoJ+uUIkgbz7oRHA==", + "dev": true, + "license": "MIT" + }, "node_modules/pako": { "version": "2.2.0", "resolved": "https://registry.npmjs.org/pako/-/pako-2.2.0.tgz", @@ -12921,6 +13948,19 @@ "url": "https://github.com/chalk/slice-ansi?sponsor=1" } }, + "node_modules/smol-toml": { + "version": "1.9.0", + "resolved": "https://registry.npmjs.org/smol-toml/-/smol-toml-1.9.0.tgz", + "integrity": "sha512-hpd+HLON7HdZXqYchMM/+LaTTbdK0AU3NngIJ4KVyWbY9bfQqdL9cD+4yf6dUoU2Ap4VsU0JkQi6FxAI1B2mXQ==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">= 18" + }, + "funding": { + "url": "https://github.com/sponsors/cyyynthia" + } + }, "node_modules/sonner": { "version": "2.0.8", "resolved": "https://registry.npmjs.org/sonner/-/sonner-2.0.8.tgz", @@ -12982,6 +14022,25 @@ "integrity": "sha512-D9cPgkvLlV3t3IzL0D0YLvGA9Ahk4PcvVwUbN0dSGr1aP0Nrt4AEnTUbuGvquEC0mA64Gqt1fzirlRs5ibXx8g==", "license": "BSD-3-Clause" }, + "node_modules/squawk-cli": { + "version": "2.67.0", + "resolved": "https://registry.npmjs.org/squawk-cli/-/squawk-cli-2.67.0.tgz", + "integrity": "sha512-ksEFSZRWtGil08epWb1LsL0RPTqjxnUZ/Ugoz1b2zO+W3PFZtQq95BnsJg+hyN6jckb5IyLn6jHjHEBlE/aQfg==", + "dev": true, + "license": "(Apache-2.0 OR MIT)", + "bin": { + "squawk": "js/bin/squawk" + }, + "optionalDependencies": { + "@squawk-cli/darwin-arm64": "2.67.0", + "@squawk-cli/darwin-x64": "2.67.0", + "@squawk-cli/linux-arm64": "2.67.0", + "@squawk-cli/linux-arm64-musl": "2.67.0", + "@squawk-cli/linux-x64": "2.67.0", + "@squawk-cli/linux-x64-musl": "2.67.0", + "@squawk-cli/win32-x64": "2.67.0" + } + }, "node_modules/stackback": { "version": "0.0.2", "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz", @@ -14100,6 +15159,16 @@ "typescript": ">=4.8.4 <6.1.0" } }, + "node_modules/unbash": { + "version": "4.0.12", + "resolved": "https://registry.npmjs.org/unbash/-/unbash-4.0.12.tgz", + "integrity": "sha512-PnKvRX+Fbxl/usVlTr/31tXIOVAH+8OdC9qaxUQcvurwEnoc3Vk/ipi2Ce8IjMxzK9byfSGdsUBjqqJGc1o5lw==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=14" + } + }, "node_modules/underscore": { "version": "1.13.8", "resolved": "https://registry.npmjs.org/underscore/-/underscore-1.13.8.tgz", @@ -14667,6 +15736,16 @@ "node": ">=14" } }, + "node_modules/walk-up-path": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/walk-up-path/-/walk-up-path-4.0.0.tgz", + "integrity": "sha512-3hu+tD8YzSLGuFYtPRb48vdhKMi0KQV5sn+uWr8+7dMEq/2G/dtLrdDinkLjqq5TIbIBjYJ4Ax/n3YiaW7QM8A==", + "dev": true, + "license": "ISC", + "engines": { + "node": "20 || >=22" + } + }, "node_modules/web-vitals": { "version": "6.2.1", "resolved": "https://registry.npmjs.org/web-vitals/-/web-vitals-6.2.1.tgz", @@ -14942,6 +16021,22 @@ "node": ">=18" } }, + "node_modules/yaml": { + "version": "2.9.1", + "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.9.1.tgz", + "integrity": "sha512-3NxN8+78OdzbT7C/WjGsyfPAtJaN3FNDsWxv7Y7mcDsT/oOmgW8BpyQQFFBnvZE3j9Y2Sdz1ULFLezL7Eb2yFw==", + "devOptional": true, + "license": "ISC", + "bin": { + "yaml": "bin.mjs" + }, + "engines": { + "node": ">= 14.6" + }, + "funding": { + "url": "https://github.com/sponsors/eemeli" + } + }, "node_modules/yauzl": { "version": "2.10.0", "resolved": "https://registry.npmjs.org/yauzl/-/yauzl-2.10.0.tgz", diff --git a/package.json b/package.json index 55004e120..28a2c5689 100644 --- a/package.json +++ b/package.json @@ -16,7 +16,8 @@ "build:dev": "vite build --mode development", "ios:build": "vite build --mode capacitor && cap sync ios", "ios:open": "cap open ios", - "lint": "eslint .", + "lint": "eslint . && npm run lint:repo", + "lint:repo": "knip --no-config-hints && node scripts/check-repo-rules.mjs", "typecheck": "tsc --noEmit -p tsconfig.app.json && tsc --noEmit -p tsconfig.agentic.json", "preview": "vite preview", "smoke:jev": "node scripts/smoke-jev.mjs", @@ -131,13 +132,16 @@ "eslint-plugin-react-refresh": "^0.4.20", "globals": "^15.15.0", "jsdom": "^20.0.3", + "knip": "^6.39.0", "postcss": "^8.5.6", + "squawk-cli": "^2.67.0", "tailwindcss": "^3.4.17", "tsx": "4.19.2", "typescript": "^5.8.3", "typescript-eslint": "^8.38.0", "vite": "^7.3.1", - "vitest": "^4.1.8" + "vitest": "^4.1.8", + "yaml": "^2.9.1" }, "allowScripts": { "@swc/core@1.16.1": true, diff --git a/scripts/check-repo-rules.mjs b/scripts/check-repo-rules.mjs new file mode 100644 index 000000000..16bbdc85b --- /dev/null +++ b/scripts/check-repo-rules.mjs @@ -0,0 +1,211 @@ +#!/usr/bin/env node +// Repository rules that ESLint cannot see: workflow hardening, pgTAP privilege +// checks, and lock-safety of new migrations. Runs as part of `npm run lint`. +import { execFileSync } from "node:child_process"; +import { readdirSync, readFileSync } from "node:fs"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; +import { LineCounter, parseDocument } from "yaml"; + +const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), ".."); +const problems = []; +const report = (file, line, message) => problems.push(`${file}:${line}: ${message}`); + +// Migrations up to and including this one predate the squawk check and are +// already applied. Newer migrations must pass it. +const MIGRATION_BASELINE = "20261001100000_account_deletion_and_export.sql"; + +// Squawk rules that do not fit Supabase migrations: each file runs once inside +// a transaction, and column types follow the existing schema. +const SQUAWK_EXCLUDED_RULES = [ + "prefer-robust-stmts", + "require-statement-timeout", + "require-lock-timeout", + "prefer-bigint-over-int", + "prefer-bigint-over-smallint", + "prefer-identity", + "prefer-text-field", + "prefer-timestamptz", +]; + +function listFiles(dir, extensions) { + return readdirSync(path.join(root, dir)) + .filter((name) => extensions.some((extension) => name.endsWith(extension))) + .sort() + .map((name) => path.join(dir, name)); +} + +const lineOf = (source, index) => source.slice(0, index).split("\n").length; + +function checkWorkflows() { + for (const file of listFiles(".github/workflows", [".yml", ".yaml"])) { + const lineCounter = new LineCounter(); + const doc = parseDocument(readFileSync(path.join(root, file), "utf8"), { lineCounter }); + const workflow = doc.toJS(); + const lineAt = (keys) => { + const node = doc.getIn(keys, true); + return node?.range ? lineCounter.linePos(node.range[0]).line : 1; + }; + if (!workflow || typeof workflow !== "object" || !("permissions" in workflow)) { + report(file, 1, "set a top-level `permissions:` block"); + } + const checkPinned = (action, keys) => { + if (action.startsWith("./")) return; + if (action.startsWith("docker://")) { + if (!/@sha256:[0-9a-f]{64}$/.test(action)) report(file, lineAt(keys), `pin ${action} to an image digest (@sha256:...)`); + } else if (!/@[0-9a-f]{40}$/.test(action)) { + report(file, lineAt(keys), `pin ${action} to a full commit SHA`); + } + }; + for (const [jobName, job] of Object.entries(workflow?.jobs ?? {})) { + if (typeof job?.uses === "string") checkPinned(job.uses, ["jobs", jobName, "uses"]); + (job?.steps ?? []).forEach((step, index) => { + if (typeof step?.uses !== "string") return; + const keys = ["jobs", jobName, "steps", index, "uses"]; + checkPinned(step.uses, keys); + const persist = step.with?.["persist-credentials"]; + if (step.uses.startsWith("actions/checkout@") && persist !== false && persist !== "false") { + report(file, lineAt(keys), "set `with.persist-credentials: false` on actions/checkout"); + } + }); + } + } +} + +// Replaces SQL comments with spaces, keeping newlines, quoted strings and +// dollar-quoted bodies, so comments can neither supply nor hide a call's +// arguments. Block comments nest, as in PostgreSQL. +function blankComments(source) { + let out = ""; + let index = 0; + const copyUntil = (stop) => { + out += source.slice(index, stop); + index = stop; + }; + const blankUntil = (stop) => { + out += source.slice(index, stop).replace(/[^\n]/g, " "); + index = stop; + }; + while (index < source.length) { + const dollar = source.slice(index).match(/^\$[A-Za-z_]*\$/); + if (source[index] === "'") { + const end = source.indexOf("'", index + 1); + copyUntil(end === -1 ? source.length : end + 1); + } else if (dollar) { + const end = source.indexOf(dollar[0], index + dollar[0].length); + copyUntil(end === -1 ? source.length : end + dollar[0].length); + } else if (source.startsWith("--", index)) { + const end = source.indexOf("\n", index); + blankUntil(end === -1 ? source.length : end); + } else if (source.startsWith("/*", index)) { + let depth = 0; + let stop = index; + while (stop < source.length) { + if (source.startsWith("/*", stop)) { + depth += 1; + stop += 2; + } else if (source.startsWith("*/", stop)) { + depth -= 1; + stop += 2; + if (depth === 0) break; + } else { + stop += 1; + } + } + blankUntil(stop); + } else { + copyUntil(index + 1); + } + } + return out; +} + +// Whitespace and opening parentheses between NOT and the call do not change +// whether the call is negated. +const isNegated = (before) => /\bNOT[\s(]*$/i.test(before); + +// Top-level arguments of the call whose opening parenthesis is at `open`, +// split at commas outside parentheses, quoted and dollar-quoted strings. +function callArguments(source, open) { + const args = []; + let depth = 0; + let quoted = false; + let start = open + 1; + for (let index = open; index < source.length; index += 1) { + const char = source[index]; + const dollar = quoted ? null : source.slice(index).match(/^\$(?:[A-Za-z_][A-Za-z0-9_]*)?\$/); + if (dollar) { + const end = source.indexOf(dollar[0], index + dollar[0].length); + if (end === -1) break; + index = end + dollar[0].length - 1; + } else if (char === "'") quoted = !quoted; + else if (quoted) continue; + else if (char === "(") depth += 1; + else if (char === "," && depth === 1) { + args.push(source.slice(start, index)); + start = index + 1; + } else if (char === ")" && --depth === 0) { + args.push(source.slice(start, index)); + return args; + } + } + return args; +} + +function checkPgTapPrivileges() { + // has_table_privilege(role, table, 'SELECT,INSERT') is true when ANY listed + // privilege is held, so a list cannot prove that a role has all of them. + // Under NOT, a list correctly means "none of them". The check errs toward + // flagging: anything but one quoted privilege in a positive call is reported, + // and a negative written as `= false` is rewritten with NOT. + // Optional schema (pg_catalog.) and optional double quotes around either name. + const call = /(?:\b[A-Za-z_]+\.|"[A-Za-z_]+"\.)?(?:\bhas_[a-z_]+_privilege|"has_[a-z_]+_privilege")\s*\(/gi; + for (const file of listFiles("supabase/tests", [".sql"])) { + const source = blankComments(readFileSync(path.join(root, file), "utf8")); + for (const match of source.matchAll(call)) { + // A positive check names one privilege: a plain quoted word, or a column + // that yields one per row (bool_and over unnest). Lists, concatenation, + // dollar quotes and other expressions are flagged. + const privilege = callArguments(source, match.index + match[0].length - 1).at(-1) ?? ""; + const single = /^\s*(?:'[A-Za-z ]+'(?:\s*::\s*text)?|[A-Za-z_][A-Za-z0-9_]*)\s*$/.test(privilege); + if (!single && !isNegated(source.slice(0, match.index))) { + report(file, lineOf(source, match.index), "a privilege list means \"any of\": check one privilege per call, or write a negative check as NOT has_*_privilege(...)"); + } + } + } +} + +function checkNewMigrations() { + const fresh = listFiles("supabase/migrations", [".sql"]).filter( + (file) => path.basename(file) > MIGRATION_BASELINE, + ); + if (fresh.length === 0) return; + try { + execFileSync( + path.join(root, "node_modules/.bin/squawk"), + [ + "--reporter=gcc", + "--pg-version=17.0", + "--assume-in-transaction", + `--exclude=${SQUAWK_EXCLUDED_RULES.join(",")}`, + ...fresh, + ], + { cwd: root, encoding: "utf8", stdio: ["ignore", "pipe", "pipe"] }, + ); + } catch (error) { + const output = `${error.stdout ?? ""}${error.stderr ?? ""}`.replace(/\x1b\[[0-9;]*m/g, "").trim(); + problems.push( + output || String(error), + "Fix the lock or compatibility problem, or add `-- squawk-ignore <rule>` above the statement with the reason.", + ); + } +} + +checkWorkflows(); +checkPgTapPrivileges(); +checkNewMigrations(); + +if (problems.length > 0) { + console.error(problems.join("\n")); + process.exit(1); +} diff --git a/src/components/features/debate/CurrentDebateMenu.tsx b/src/components/features/debate/CurrentDebateMenu.tsx index f41ed2148..bb915637d 100644 --- a/src/components/features/debate/CurrentDebateMenu.tsx +++ b/src/components/features/debate/CurrentDebateMenu.tsx @@ -68,7 +68,7 @@ export function CurrentDebateMenu({ variant }: { variant: 'desktop' | 'mobile' } {oneOff && <p className="text-xs font-medium text-primary">{t('debate.oneOffActive', 'One-off mode is on')}</p>} </div> <div className="grid gap-1"> - <Button variant="ghost" className="justify-start" onClick={() => act(() => navigate('/app/debate'))}>{t('debate.open', 'Open debate')}</Button> + <Button variant="ghost" className="justify-start" onClick={() => act(() => { navigate('/app/debate'); })}>{t('debate.open', 'Open debate')}</Button> <Button variant="ghost" className="justify-start" onClick={() => act(() => openDialog('switch'))}>{t('debate.switch', 'Switch debate')}</Button> <Button variant="ghost" className="justify-start" onClick={() => act(() => openDialog('create'))}>{t('debate.startNew', 'Start new debate')}</Button> <Button variant="ghost" className="justify-start" onClick={() => act(() => setOneOff(!oneOff))}> diff --git a/src/components/features/workspace/WorkspaceEditor.tsx b/src/components/features/workspace/WorkspaceEditor.tsx index cea48f2e7..549f77cee 100644 --- a/src/components/features/workspace/WorkspaceEditor.tsx +++ b/src/components/features/workspace/WorkspaceEditor.tsx @@ -253,7 +253,9 @@ export function WorkspaceEditor({ if (!disposed) setEditor(activeEditor); }; const offline = () => setStatus("Offline"); - const online = () => provider?.connect(); + const online = () => { + provider?.connect().catch(() => setStatus("Offline")); + }; window.addEventListener("offline", offline); window.addEventListener("online", online); void start(); diff --git a/src/i18n.ts b/src/i18n.ts index 90dca2839..1007d61d8 100644 --- a/src/i18n.ts +++ b/src/i18n.ts @@ -5,7 +5,8 @@ import es from './locales/es.json'; import hu from './locales/hu.json'; import { languageName, type AppLanguage } from './lib/languages'; -i18n.use(initReactI18next).init({ +// Bundled resources make init synchronous; the returned promise cannot reject. +void i18n.use(initReactI18next).init({ resources: { en: { translation: en }, es: { translation: es }, hu: { translation: hu } }, lng: 'en', fallbackLng: 'en', diff --git a/src/lib/supabase/AuthProvider.tsx b/src/lib/supabase/AuthProvider.tsx index bb02e838e..b457be0fa 100644 --- a/src/lib/supabase/AuthProvider.tsx +++ b/src/lib/supabase/AuthProvider.tsx @@ -225,7 +225,7 @@ export function AuthProvider({ children }: { children: React.ReactNode }) { } }; - init(); + void init(); const { data: authListener } = supabase.auth.onAuthStateChange((event, newSession) => { console.log('[Auth] Event:', event, 'Provider Token:', newSession?.provider_token ? 'present' : 'null'); diff --git a/src/native-app-url-listener.ts b/src/native-app-url-listener.ts index b17c703f0..ba9957188 100644 --- a/src/native-app-url-listener.ts +++ b/src/native-app-url-listener.ts @@ -77,7 +77,11 @@ export function registerNativeAppUrlListener() { console.warn('[Native] Failed to read launch URL:', error); }); - App.addListener('appUrlOpen', ({ url }) => handleNativeAppUrlOnce(url)).catch((error) => { + App.addListener('appUrlOpen', ({ url }) => { + handleNativeAppUrlOnce(url).catch((error) => { + console.warn('[Native] Failed to handle app URL:', error); + }); + }).catch((error) => { console.warn('[Native] Failed to register app URL listener:', error); }); } diff --git a/src/pages/AdminPage.tsx b/src/pages/AdminPage.tsx index d3f272e9a..88c5f0896 100644 --- a/src/pages/AdminPage.tsx +++ b/src/pages/AdminPage.tsx @@ -591,7 +591,7 @@ export default function AdminPage() { setLoading(false); } }; - loadData(); + void loadData(); }, [fetchAdminData, t, toast]); useEffect(() => { diff --git a/src/pages/FlowchartPage.tsx b/src/pages/FlowchartPage.tsx index d93b02da4..ee160aff4 100644 --- a/src/pages/FlowchartPage.tsx +++ b/src/pages/FlowchartPage.tsx @@ -486,7 +486,7 @@ export default function FlowchartPage() { className="h-8 w-8 text-destructive opacity-70 hover:opacity-100" onClick={(e) => { e.stopPropagation(); - handleDeleteFlowchart(item.id, e); + void handleDeleteFlowchart(item.id, e); }} aria-label={t('common.delete', 'Delete')} > diff --git a/src/pages/GeneratePage.tsx b/src/pages/GeneratePage.tsx index 649cd8417..d92b4a7bf 100644 --- a/src/pages/GeneratePage.tsx +++ b/src/pages/GeneratePage.tsx @@ -441,7 +441,7 @@ export default function GeneratePage() { </DialogHeader> <div className="space-y-2"> <Label htmlFor="case-title">{t('generate.caseTitle', 'Case title')}</Label> - <Input id="case-title" value={saveTitle} onChange={(event) => setSaveTitle(event.target.value)} onKeyDown={(e) => { if (e.key === 'Enter' && saveTitle.trim()) confirmSave(); }} autoFocus /> + <Input id="case-title" value={saveTitle} onChange={(event) => setSaveTitle(event.target.value)} onKeyDown={(e) => { if (e.key === 'Enter' && saveTitle.trim()) void confirmSave(); }} autoFocus /> </div> <DialogFooter> <Button variant="outline" onClick={() => setSaveDialogOpen(false)}>{t('common.cancel', 'Cancel')}</Button> diff --git a/src/pages/SharedPage.tsx b/src/pages/SharedPage.tsx index 9eaaef840..5efd8a393 100644 --- a/src/pages/SharedPage.tsx +++ b/src/pages/SharedPage.tsx @@ -56,7 +56,7 @@ export default function SharedPage() { } }; - loadSharedItem(); + void loadSharedItem(); }, [code, t]); const handleImport = async () => { diff --git a/supabase/tests/current_debates_test.sql b/supabase/tests/current_debates_test.sql index a7c89c3d6..30a9c7e0c 100644 --- a/supabase/tests/current_debates_test.sql +++ b/supabase/tests/current_debates_test.sql @@ -6,9 +6,18 @@ INSERT INTO auth.users (id) VALUES ('d2222222-2222-4222-8222-222222222222'); SELECT ok( - has_table_privilege('authenticated', 'public.debates', 'SELECT,INSERT,UPDATE,DELETE') - AND has_table_privilege('authenticated', 'public.current_debates', 'SELECT,INSERT,UPDATE,DELETE') - AND has_table_privilege('authenticated', 'public.crossfires', 'SELECT,INSERT,UPDATE,DELETE') + has_table_privilege('authenticated', 'public.debates', 'SELECT') + AND has_table_privilege('authenticated', 'public.debates', 'INSERT') + AND has_table_privilege('authenticated', 'public.debates', 'UPDATE') + AND has_table_privilege('authenticated', 'public.debates', 'DELETE') + AND has_table_privilege('authenticated', 'public.current_debates', 'SELECT') + AND has_table_privilege('authenticated', 'public.current_debates', 'INSERT') + AND has_table_privilege('authenticated', 'public.current_debates', 'UPDATE') + AND has_table_privilege('authenticated', 'public.current_debates', 'DELETE') + AND has_table_privilege('authenticated', 'public.crossfires', 'SELECT') + AND has_table_privilege('authenticated', 'public.crossfires', 'INSERT') + AND has_table_privilege('authenticated', 'public.crossfires', 'UPDATE') + AND has_table_privilege('authenticated', 'public.crossfires', 'DELETE') AND has_function_privilege('authenticated', 'public.attach_to_debate(text, uuid, uuid, text)', 'EXECUTE') AND NOT has_function_privilege('anon', 'public.attach_to_debate(text, uuid, uuid, text)', 'EXECUTE'), 'signed-in clients can manage debates and attach work; anonymous callers cannot' diff --git a/supabase/tests/debate_context_pins_test.sql b/supabase/tests/debate_context_pins_test.sql index e7b2d6eff..463755199 100644 --- a/supabase/tests/debate_context_pins_test.sql +++ b/supabase/tests/debate_context_pins_test.sql @@ -6,10 +6,15 @@ INSERT INTO auth.users (id) VALUES ('e2222222-2222-4222-8222-222222222222'); SELECT ok( - has_table_privilege('authenticated', 'public.debate_context_pins', 'SELECT,INSERT,DELETE') + has_table_privilege('authenticated', 'public.debate_context_pins', 'SELECT') + AND has_table_privilege('authenticated', 'public.debate_context_pins', 'INSERT') + AND has_table_privilege('authenticated', 'public.debate_context_pins', 'DELETE') AND NOT has_table_privilege('authenticated', 'public.debate_context_pins', 'UPDATE') AND NOT has_table_privilege('anon', 'public.debate_context_pins', 'SELECT') - AND has_table_privilege('service_role', 'public.debate_context_pins', 'SELECT,INSERT,UPDATE,DELETE'), + AND has_table_privilege('service_role', 'public.debate_context_pins', 'SELECT') + AND has_table_privilege('service_role', 'public.debate_context_pins', 'INSERT') + AND has_table_privilege('service_role', 'public.debate_context_pins', 'UPDATE') + AND has_table_privilege('service_role', 'public.debate_context_pins', 'DELETE'), 'signed-in clients can read, add, and remove pins; anonymous callers cannot' ); diff --git a/supabase/tests/demo_access_codes_rls_test.sql b/supabase/tests/demo_access_codes_rls_test.sql index 8a189506d..70e161fab 100644 --- a/supabase/tests/demo_access_codes_rls_test.sql +++ b/supabase/tests/demo_access_codes_rls_test.sql @@ -22,7 +22,10 @@ SELECT ok( 'authenticated callers cannot write demo login attempts' ); SELECT ok( - has_table_privilege('service_role', 'public.demo_access_codes', 'SELECT,INSERT,UPDATE,DELETE'), + has_table_privilege('service_role', 'public.demo_access_codes', 'SELECT') + AND has_table_privilege('service_role', 'public.demo_access_codes', 'INSERT') + AND has_table_privilege('service_role', 'public.demo_access_codes', 'UPDATE') + AND has_table_privilege('service_role', 'public.demo_access_codes', 'DELETE'), 'service role owns demo-code access' ); SELECT ok( diff --git a/vitest.config.ts b/vitest.config.ts index 5a51ceeef..a924a324e 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -8,6 +8,10 @@ export default defineConfig({ environment: "jsdom", globals: true, setupFiles: ["./src/test/setup.ts"], + // Globals and env stubbed with vi.stubGlobal / vi.stubEnv reset after + // every test, so one test's stub cannot leak into the tests after it. + unstubGlobals: true, + unstubEnvs: true, // Edge function tests are Deno tests. `npm run test:edge` runs them under // the runtime they ship on, so Vitest does not re-run them through a shim. include: ["src/**/*.{test,spec}.{ts,tsx}"],