diff --git a/README.md b/README.md
index 409663fe..381806a1 100644
--- a/README.md
+++ b/README.md
@@ -13,7 +13,7 @@
-
+
@@ -511,7 +511,7 @@ The Settings → AI panel shows a live GPU status badge with adapter details and
| **Document Export** | docx + jszip | Word-compatible `.docx` generation (lazy-loaded) |
| **PWA** | Service Worker + Web App Manifest v3 | Offline support, installability, Workbox chunking |
| **i18n** | Custom React Context (`I18nContext.tsx`) | 2942 keys × 19 locales (de/en/es/fr/it + ar/he/fa RTL Beta + ja/zh/pt/el/fi/sv/hu/is/eu/ru/ko Beta); EN fallback; `localStorage` persistence |
-| **Testing** | Vitest 4.x (7567+ tests / 603 files) + Playwright E2E | Unit/integration + cross-browser E2E; Stryker mutation (manual workflow) |
+| **Testing** | Vitest 4.x (7593+ tests / 603 files) + Playwright E2E | Unit/integration + cross-browser E2E; Stryker mutation (manual workflow) |
| **Code Quality** | Biome (lint + format) + TypeScript 7 (tsgo) strict | `--error-on-warnings` in CI; zero `any` policy |
| **Visualization** | Force-directed graph | Interactive character relationship network |
| **Desktop** | Tauri v2 | Cross-platform installer; auto-updater via `latest.json` |
@@ -549,7 +549,7 @@ WorldScript-Studio/
│ ├── sw.js # PWA Service Worker
│ └── manifest.json # PWA Web App Manifest v3
├── tests/
-│ ├── unit/ # Vitest unit tests (7567+ tests, 603 files) — count spans tests/, components/, packages/*/tests/, not just this folder
+│ ├── unit/ # Vitest unit tests (7593+ tests, 603 files) — count spans tests/, components/, packages/*/tests/, not just this folder
│ │ ├── ai/ # aiSmallModules, aiCoreFallbackPaths
│ │ └── settings/ # WebLlmPanel, AiSections
│ └── e2e/ # Playwright specs + helpers.ts
@@ -714,7 +714,7 @@ The main pipeline is [`.github/workflows/ci.yml`](.github/workflows/ci.yml). Opt
Raw bundle-budget ceilings (KB per uncompressed asset): entry **2500 KB**, vendor **6200 KB**, other JavaScript **2500 KB**, and WASM **30000 KB**.
**Current test metrics (2026-09-07, source-synchronized; CI remains authoritative for pass/fail):**
-- **7567+ unit tests** across **603 test files** — CI is authoritative for pass/fail
+- **7593+ unit tests** across **603 test files** — CI is authoritative for pass/fail
- Coverage thresholds: lines ≥ 80 · branches ≥ 66 · functions ≥ 72 · statements ≥ 78 — enforced in CI (see Codecov badge for live metrics)
- i18n: **2942 keys × 19 locales** (en/de/fr/es/it + ar/he/fa RTL Beta + ja/zh/pt/el/fi/sv/hu/is/eu/ru/ko Beta)
diff --git a/docs/DEPENDABOT-TRIAGE.md b/docs/DEPENDABOT-TRIAGE.md
index 096ed53d..2d0c8d4c 100644
--- a/docs/DEPENDABOT-TRIAGE.md
+++ b/docs/DEPENDABOT-TRIAGE.md
@@ -81,14 +81,21 @@ rule; it applies to dependency PRs exactly as it does to feature PRs.
## OSV ignore-expiry review
-The 2026-08-20 consolidation review found **19** entries in `src-tauri/osv-scanner.toml`, all
-currently sharing the `2026-11-30T00:00:00Z` review deadline. This is a synchronized review cliff,
-not evidence that the risks were extended or resolved. The entries remain grouped by their real
-reason: legacy GTK3/WebKit bindings, build-time `proc-macro-error`/`paste`, archived Unicode data
-crates, and the transitive `extract-zip` advisory.
-
-No ignore deadline was extended in this pass. `extract-zip` remains a transitive Playwright browser
-download dependency with no patched release and no production-runtime footprint; it must still be
+The 2026-08-20 consolidation review found 19 entries in `src-tauri/osv-scanner.toml`, all
+currently sharing the `2026-11-30T00:00:00Z` review deadline. **2026-09-08 update: two more npm
+entries were added under the same deadline** — a second, distinct `extract-zip` 2.0.1 advisory
+(`GHSA-7pqw-9j4j-h8q3`, not an alias of the original) and `adm-zip` 0.6.0
+(`GHSA-vwc7-r8mq-g2x9`) — bringing the total to **21**. This is a synchronized review cliff, not
+evidence that the risks were extended or resolved; **`src-tauri/osv-scanner.toml` is the dynamic
+source of truth for the exact count and entries** — treat this list as a cluster summary, not a
+substitute for reading the file. The entries remain grouped by their real reason: legacy GTK3/WebKit
+bindings, build-time `proc-macro-error`/`paste`, archived Unicode data crates, both `extract-zip`
+advisories, and `adm-zip`.
+
+No ignore deadline was extended in this pass. `extract-zip` remains a transitive dependency of
+`@lhci/cli`'s own Puppeteer stack (`lighthouse` -> `puppeteer-core` -> `@puppeteer/browsers` ->
+`extract-zip`, verified via `pnpm audit` — not this repo's separate Playwright devDependency)
+with no patched release and no production-runtime footprint; it must still be
rechecked before expiry and removed as soon as an upstream fix or dependency-path change makes that
possible. Review each cluster against current upstream status before changing any deadline.
diff --git a/docs/IDB-ENCRYPTION.md b/docs/IDB-ENCRYPTION.md
index ebc4232b..bb0e8ce1 100644
--- a/docs/IDB-ENCRYPTION.md
+++ b/docs/IDB-ENCRYPTION.md
@@ -165,7 +165,7 @@ Every protected store writer runs inside `withProtectedWriteAdmission()` (shared
On the Tauri desktop build, primary project, settings, snapshot, image, Codex, RAG, and binder-asset data is persisted by the filesystem-backed store (`services/fs/*Store.ts`), not IndexedDB. That store writes plaintext (LZ-string compressed only, no encryption) regardless of `enableIdbAtRestEncryption`. Enabling the setting on desktop still shows `IdbUnlockModal`/`PassphraseModal` (the passphrase sentinel lives in the WebView's own IndexedDB, which persists on desktop too), but that unlock flow gates nothing on the filesystem side today — only the UI, not the actual manuscript files under `$APPDATA`, is shared with the web build. Character and world image reads use `storageService`, so they now follow the same selected backend as image uploads; this removes the prior desktop filesystem/IndexedDB split-persistence availability bug. See `README.md`'s "Encryption — which mechanism protects what" table for the authoritative per-mechanism breakdown. Extending real at-rest protection to the desktop filesystem store is a tracked, open gap — not yet implemented.
-**API keys (resolved 2026-08-14):** all provider API keys, including Gemini, now route through `storageService` directly to the IndexedDB key store (`services/storage/idbKeyStore.ts`, random non-extractable AES-GCM key) on every platform, desktop included. The Tauri filesystem adapter's `saveApiKey`/`getApiKey` (`services/fs/settingsFsStore.ts`) is now a defense-in-depth backstop rather than the active path: `saveApiKey` throws if ever called, and `getApiKey` silently removes any pre-existing legacy key file — whether from the pre-2026-07-29 unsalted-SHA-256 scheme or the since-hardened but still filesystem-reconstructible PBKDF2 scheme — and returns `null`. A user-facing "API Key Reset Required" notification fires only if that removal itself fails (a permissions/IO error surfacing through the catch path); the common case (file found and removed cleanly) is silent, since a re-prompt for a never-populated key is indistinguishable from normal first-use. `encryptText`/`decryptText` in `fsCore.ts` are no longer called anywhere in the codebase for API keys and remain only as shared crypto plumbing pending [PR #356](https://github.com/qnbs/WorldScript-Studio/pull/356)'s project-data encryption work. This also resolves the earlier Gemini split-persistence bug tracked in [#358](https://github.com/qnbs/WorldScript-Studio/issues/358): `components/ApiKeySection.tsx` and `services/geminiService.ts` now both route through `storageService`.
+**API keys (resolved 2026-08-14):** all provider API keys, including Gemini, now route through `storageService` directly to the IndexedDB key store (`services/storage/idbKeyStore.ts`, random non-extractable AES-GCM key) on every platform, desktop included. The Tauri filesystem adapter's `saveApiKey`/`getApiKey` (`services/fs/settingsFsStore.ts`) is now a defense-in-depth backstop rather than the active path: `saveApiKey` throws if ever called, and `getApiKey` silently removes any pre-existing legacy key file — whether from the pre-2026-07-29 unsalted-SHA-256 scheme or the since-hardened but still filesystem-reconstructible PBKDF2 scheme — and returns `null`. A user-facing "API Key Reset Required" notification fires only when a failure occurs after the key-file path is resolved — during the existence check or the removal attempt itself, e.g. a transient permissions/IO error — **and** the catch block's own follow-up removal then succeeds; earlier failures (acquiring the platform APIs or resolving the app-data path) never reach the cleanup/notification logic at all, and if the follow-up removal also fails, the notification is likewise suppressed and only a warning is logged. The common case — no legacy file exists, or the initial removal succeeds outright — returns silently, since a re-prompt for a never-populated key is indistinguishable from normal first-use. `encryptText`/`decryptText` in `fsCore.ts` are no longer called anywhere in the codebase for API keys and remain only as shared crypto plumbing pending R-15's project-data encryption work (`docs/native/CORE-MIGRATION-LEDGER.md` row 10 — check the ledger directly for its current readiness marker rather than trusting a copied status here — additionally gated behind row 9, the project state-shape compatibility adapter, converging first). This also resolves the earlier Gemini split-persistence bug tracked in [#358](https://github.com/qnbs/WorldScript-Studio/issues/358): `components/ApiKeySection.tsx` and `services/geminiService.ts` now both route through `storageService`.
The repository does **not** currently use `tauri-plugin-stronghold`, an OS keychain, or a transparent desktop-only passphrase store.
diff --git a/docs/SECURITY-THREAT-MODEL.md b/docs/SECURITY-THREAT-MODEL.md
index 3eb88204..77852737 100644
--- a/docs/SECURITY-THREAT-MODEL.md
+++ b/docs/SECURITY-THREAT-MODEL.md
@@ -40,8 +40,8 @@ This document provides a formal STRIDE threat analysis for WorldScript Studio, m
| Threat | Mitigation | Code Location |
|--------|------------|-------------|
| API key leakage via logs | StructuredLogger sanitization; never log keys | `services/logger.ts:sanitizeLogContext()` |
-| Desktop API key exposure via local filesystem read | **Resolved 2026-08-14 (was "not resolved" through 2026-08-13 — see history).** Filesystem API-key persistence is disabled: `storageService`'s key methods route directly to the IndexedDB key store (random non-extractable AES-GCM key) on every platform, desktop included — the filesystem adapter's own `saveApiKey` is a defense-in-depth backstop that throws if ever called directly. The prior PBKDF2-from-reconstructible-material derivation (`deriveFileSystemCryptoKey()`) is no longer used for API keys; it remains in `fsCore.ts` as shared crypto plumbing for other filesystem-encrypted data (see [PR #356](https://github.com/qnbs/WorldScript-Studio/pull/356)). Legacy filesystem key files are removed on a best-effort basis (each failure is logged, not retried indefinitely) and re-entry is required if cleanup or decryption fails. **Gemini split-persistence bug also resolved:** `components/ApiKeySection.tsx` and `services/geminiService.ts` now both route through `storageService`, closing [#358](https://github.com/qnbs/WorldScript-Studio/issues/358) (previously ApiKeySection read/wrote the Gemini key via `dbService` directly while geminiService read it via `storageService`, so a key saved on desktop was invisible to the code that used it). | `services/storage/idbKeyStore.ts`, `services/storageService.ts`, `services/fs/settingsFsStore.ts` |
-| Desktop project/settings/snapshot/Codex/RAG/image/binder-asset data disclosure via local file-read access | **Not resolved on current `main`.** Tauri filesystem records, including binder `.bin` payloads, are plaintext (some text records are compressed only), so an attacker who can read the app-data directory can disclose them. The separate tampering threat requires local file-write access and is modeled under Tampering above. Enabling the current browser/IndexedDB setting does not protect these files. [PR #356](https://github.com/qnbs/WorldScript-Studio/pull/356) is the active remediation; do not broaden this claim until its combined-state runtime proof completes. | `services/fs/*Store.ts` |
+| Desktop API key exposure via local filesystem read | **Resolved 2026-08-14 (was "not resolved" through 2026-08-13 — see history).** Filesystem API-key persistence is disabled: `storageService`'s key methods route directly to the IndexedDB key store (random non-extractable AES-GCM key) on every platform, desktop included — the filesystem adapter's own `saveApiKey` is a defense-in-depth backstop that throws if ever called directly. The prior PBKDF2-from-reconstructible-material derivation (`deriveFileSystemCryptoKey()`) is no longer used for API keys; it remains in `fsCore.ts` as shared crypto plumbing for other filesystem-encrypted data (see [PR #356](https://github.com/qnbs/WorldScript-Studio/pull/356), closed 2026-08-18 as superseded). Legacy filesystem key files are removed on a best-effort basis (each failure is logged, not retried indefinitely) and re-entry is required if cleanup or decryption fails. **Gemini split-persistence bug also resolved:** `components/ApiKeySection.tsx` and `services/geminiService.ts` now both route through `storageService`, closing [#358](https://github.com/qnbs/WorldScript-Studio/issues/358) (previously ApiKeySection read/wrote the Gemini key via `dbService` directly while geminiService read it via `storageService`, so a key saved on desktop was invisible to the code that used it). | `services/storage/idbKeyStore.ts`, `services/storageService.ts`, `services/fs/settingsFsStore.ts` |
+| Desktop project/settings/snapshot/Codex/RAG/image/binder-asset data disclosure via local file-read access | **Not resolved on current `main`.** Tauri filesystem records, including binder `.bin` payloads, are plaintext (some text records are compressed only), so an attacker who can read the app-data directory can disclose them. The separate tampering threat requires local file-write access and is modeled under Tampering above. Enabling the current browser/IndexedDB setting does not protect these files. R-15 (`docs/native/CORE-MIGRATION-LEDGER.md` row 10) is the design-complete remediation — implementation readiness is tracked live by row 10's own readiness marker, additionally gated behind row 9 (the project state-shape compatibility adapter) converging first; check the ledger directly rather than trusting a copied status here, and do not broaden this claim until implementation actually starts. | `services/fs/*Store.ts` |
| Manuscript data in IndexedDB | AES-256-GCM at-rest encryption | `services/storage/storageEncryptionService.ts` |
| Voice audio to cloud | Web Speech API consent gate | `components/voice/VoicePrivacyConsentModal.tsx` |
| DuckDB analytics unencrypted (SEC-6) | **Bounded by design, with one prose column now encrypted:** most persisted fields are local metadata only (titles, loglines, character names, word counts, embeddings) and **nothing leaves the device**. The one column that genuinely holds literal manuscript prose, `codex_mentions.excerpt`, is now cell-level encrypted (AES-256-GCM via `services/duckdb/duckdbEncryption.ts`, reusing the IDB at-rest encryption key) whenever `enableIdbAtRestEncryption` is active: `duckdbCodexWrite()` writes ciphertext into `excerpt_enc BLOB` and nulls the plaintext `excerpt` column; `services/duckdb/codexExcerptEncryptionMigration.ts` backfills any pre-existing plaintext rows once encryption is unlocked. Gated by `enableDuckDbAnalytics` **and** the Settings → Privacy "Analytics" opt-out (`isAnalyticsPersistenceAllowed` in `app/listenerMiddleware.ts`); turning the toggle off stops all DuckDB writes + inference telemetry. Full OPFS file-level encryption remains **infeasible** — DuckDB-WASM owns the OPFS file handle directly, so there is no app-level interception point; the other metadata columns stay intentionally plaintext (bounded-exposure design). | `app/listenerMiddleware.ts:isAnalyticsPersistenceAllowed`, `services/duckdb/duckdbAnalytics.ts:duckdbCodexWrite()`, `services/duckdb/duckdbEncryption.ts`, `services/duckdb/codexExcerptEncryptionMigration.ts` |
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index e14e25ca..226c4f91 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -8,7 +8,7 @@ overrides:
playwright: 1.61.0
playwright-core: 1.61.0
esbuild: '>=0.28.1'
- joi: ^18.2.1
+ joi: '>=18.2.5 <19'
typescript: 6.0.3
serialize-javascript: '>=7.0.3'
chrome-launcher: ^1.2.1
@@ -28,12 +28,12 @@ overrides:
dompurify: '>=3.4.13'
'@babel/core': '>=7.29.6 <8'
undici: '>=7.29.0 <8'
- js-yaml: '>=4.3.1 <5'
+ js-yaml: '>=4.3.2 <5'
nanoid@^3: 3.3.18
nanoid@^5: 5.1.16
adm-zip: '>=0.6.0'
postcss: '>=8.5.18'
- sharp: '>=0.35.0'
+ sharp: '>=0.35.4'
body-parser: '>=1.20.6 <2'
patchedDependencies:
@@ -1615,163 +1615,81 @@ packages:
resolution: {integrity: sha512-Td76q7j57o/tLVdgS746cYARfSyxk8iEfRxewL9h4OMzYhbW4TAcppl0mT4eyqXddh6L/jwoM75mo7ixa/pCeQ==}
engines: {node: '>=18'}
- '@img/sharp-darwin-arm64@0.35.3':
- resolution: {integrity: sha512-RMnFX7YQsMoh7lWfcM4NEHHymBX/rLuKNPVM84XE9ONPcaSCDgE7CHIHpSgPcO2xcRthgBy1HfNO319mwhIAkg==}
- engines: {node: '>=20.9.0'}
- cpu: [arm64]
- os: [darwin]
-
'@img/sharp-darwin-arm64@0.35.4':
resolution: {integrity: sha512-Uhfl4V4lhP2nbUVF9+hyH1+luj86f1gUFeo8ALYxFoULoU+G87D43BfeMP8XHsk9boxAnCY/bf2EHwhA7MuGsA==}
engines: {node: '>=20.9.0'}
cpu: [arm64]
os: [darwin]
- '@img/sharp-darwin-x64@0.35.3':
- resolution: {integrity: sha512-Xo+5uFBtLN0BKqieTxiFzFPQAUlBbbH5iBKyRX/z1JrbnYsHTfKJnUfL8+p2TPXr1pXqao4eeL4Rl144uDpK9w==}
- engines: {node: '>=20.9.0'}
- cpu: [x64]
- os: [darwin]
-
'@img/sharp-darwin-x64@0.35.4':
resolution: {integrity: sha512-hWniXY3bG5qKpkKrAwPe4y+VTPmf086YQAnkxWh7uA1YrlRouWGa0M0Mxj3ZjnXFkv7/TD1bTy9lGUK26vRvWw==}
engines: {node: '>=20.9.0'}
cpu: [x64]
os: [darwin]
- '@img/sharp-freebsd-wasm32@0.35.3':
- resolution: {integrity: sha512-lUxcqWIj2wMQ9BrwNjngcr1gWUr5xgaGThBRqPPalIC2n67Cqj1uPh8NnA/ZhAg8hUbKl+kVHKwgUIwe6ZYPrg==}
- engines: {node: '>=20.9.0'}
- os: [freebsd]
-
'@img/sharp-freebsd-wasm32@0.35.4':
resolution: {integrity: sha512-lIsKw/BU+kjB4eZjxrYrZmwOJYi3Ajrv66iAlBmUPyKc3HpnloevB1g3wxGD9P/5BbQ1brBGl65VRRrCvQDEqA==}
engines: {node: '>=20.9.0'}
os: [freebsd]
- '@img/sharp-libvips-darwin-arm64@1.3.2':
- resolution: {integrity: sha512-9J6ypZFpQBj4YnePGoq/S38w6nz+vqg5WZLrLGY4YuSemdMq47GMLBPO42MzwdGwpg/agZ7xzZcFHa48xlywfg==}
- cpu: [arm64]
- os: [darwin]
-
'@img/sharp-libvips-darwin-arm64@1.3.3':
resolution: {integrity: sha512-suTBPTDGrI9WodccaDdwZItTSaBYASlBk1NSfElSHrUfzu3szG6lvIF58+WiFvnfzuK8ZBFS5zE00PxqxnRiPg==}
cpu: [arm64]
os: [darwin]
- '@img/sharp-libvips-darwin-x64@1.3.2':
- resolution: {integrity: sha512-m2pW1n6cns9VaubNwsZ+c3CRYjxNQWgJ5gPlnL1nbBcpkBvFm6SCFN5o0psFHI8w9n11NKhFkeEDns98tiqbEw==}
- cpu: [x64]
- os: [darwin]
-
'@img/sharp-libvips-darwin-x64@1.3.3':
resolution: {integrity: sha512-FVJZ5mITMobmXIz/hPDTw0EintTW5H3WfrxwLqEqjiIihlu+hVRyGrFQ60xl0Lxn7Bt3zdpevPaQi0HEzqz9fw==}
cpu: [x64]
os: [darwin]
- '@img/sharp-libvips-linux-arm64@1.3.2':
- resolution: {integrity: sha512-dqVSFynCox4C/J8kT16V7SIFAns0IjgLwkvYT7p8LQVmJ5OS5b6tI9IGflxTeuBS//zXeFIUbwt5dwxyZ17cnA==}
- cpu: [arm64]
- os: [linux]
- libc: [glibc]
-
'@img/sharp-libvips-linux-arm64@1.3.3':
resolution: {integrity: sha512-0DaL0A6Xu6sQSQFwe4iVCrKWU2cCTItnRsYsCdxAMm9NF6twAA9BKnoqy4hqz4+azQ0JHuA26qiUKsf1XJ/v5A==}
cpu: [arm64]
os: [linux]
libc: [glibc]
- '@img/sharp-libvips-linux-arm@1.3.2':
- resolution: {integrity: sha512-1eMLzy92I4J6rmi4mAT8yC3HxOtniyGELlzGbNMLLeqe052ahFQ0h6LFq+lh5DsDIdYViIDst08abvSbcEdLXQ==}
- cpu: [arm]
- os: [linux]
- libc: [glibc]
-
'@img/sharp-libvips-linux-arm@1.3.3':
resolution: {integrity: sha512-3rbU4vqXXc3hY/OiXdl52xZvT0F1yEngWfvqudtPJg/KkyiaQw2DRsFrNzpmLvfavbwOq3qXn36GP8obHRULQA==}
cpu: [arm]
os: [linux]
libc: [glibc]
- '@img/sharp-libvips-linux-ppc64@1.3.2':
- resolution: {integrity: sha512-3z0NHDxD6n5I9gc05U1eW1AyRm+Gznzq3naMrthPNqE6oYykcogW0l/jfpJdjYnuNl8R7yI9pNbE1XiUeyq0Aw==}
- cpu: [ppc64]
- os: [linux]
- libc: [glibc]
-
'@img/sharp-libvips-linux-ppc64@1.3.3':
resolution: {integrity: sha512-cdn1OvUBwsXhbC0zSzJnNzf5MZ/mTrobawDvNXBTxe8VtqKAm0sRuEY2Evzovb/w9JMk4TvRxqt1mekSuJz64w==}
cpu: [ppc64]
os: [linux]
libc: [glibc]
- '@img/sharp-libvips-linux-riscv64@1.3.2':
- resolution: {integrity: sha512-bsb4rI+NldGOsXuej2r8OdSS8+zXDVaCWxyWrcv6kneTOlgAHtZABRzBBCwdsPiD90J4myNJuHpg6kA20ImW/w==}
- cpu: [riscv64]
- os: [linux]
- libc: [glibc]
-
'@img/sharp-libvips-linux-riscv64@1.3.3':
resolution: {integrity: sha512-HjPVx7yKz+0lqdhDlTw1tt90wamBoxhiXpvl1XZpJLiHH4RCJ5yDTqH+VlYPv2fwFs89JFw4c1IexYOcQUi4IQ==}
cpu: [riscv64]
os: [linux]
libc: [glibc]
- '@img/sharp-libvips-linux-s390x@1.3.2':
- resolution: {integrity: sha512-/ABshyj8gCpyIrNXnHn4LorDJ0HHm1VhXPBlxZ8zAtfVPAaSafXPGn+sUSIRiwaSBy0mmFjSjiXI5mkcwdChKQ==}
- cpu: [s390x]
- os: [linux]
- libc: [glibc]
-
'@img/sharp-libvips-linux-s390x@1.3.3':
resolution: {integrity: sha512-neWLh+3yCNThxnfy3c4BbVBeGgt9aftno+XbT56iK28RgeDs3UOFWviLWlUu0bArYVYJaFDK+RRohbicUNCm8Q==}
cpu: [s390x]
os: [linux]
libc: [glibc]
- '@img/sharp-libvips-linux-x64@1.3.2':
- resolution: {integrity: sha512-ITPEtgffGJ0S6G9dRyw/366tJQqFRcHWPHhC+Stpg3Z8AEMrDrTr2lhdz4f/Y/HMbRh//7Z5mBzEpVdi62Oc3w==}
- cpu: [x64]
- os: [linux]
- libc: [glibc]
-
'@img/sharp-libvips-linux-x64@1.3.3':
resolution: {integrity: sha512-4vKmvAst9nrowcqquKFAyZJUDolUaIp8uRiN0mWFguJ1IplC9/pitXtlnnlU4aa/eJw3J7i67V+pwUL+wZGdsA==}
cpu: [x64]
os: [linux]
libc: [glibc]
- '@img/sharp-libvips-linuxmusl-arm64@1.3.2':
- resolution: {integrity: sha512-zE9EdiUzUmg5mDT5a1rk5fYJ6GWPloTwWBYDS14naqHsL+EaMpDj1AWnpLgh3u0YCORv2Tt50wrcrpYqkP97Kw==}
- cpu: [arm64]
- os: [linux]
- libc: [musl]
-
'@img/sharp-libvips-linuxmusl-arm64@1.3.3':
resolution: {integrity: sha512-Y9kQaLMuNoB0bPYOOdcZMaseNrFpPodIWWMrx+CZyydf2xn68j9WYc6sWWRrDwNkzCQjKYfc68L7jKjGlHMibw==}
cpu: [arm64]
os: [linux]
libc: [musl]
- '@img/sharp-libvips-linuxmusl-x64@1.3.2':
- resolution: {integrity: sha512-m0lrLiUt+lBYnCFr8qV/65yMR4E/c7/wf78I5eKTdkEakFAlZ9QlzEM3QIhhAwVeUhLAHLcCq7a7Vszq/oFNZQ==}
- cpu: [x64]
- os: [linux]
- libc: [musl]
-
'@img/sharp-libvips-linuxmusl-x64@1.3.3':
resolution: {integrity: sha512-fj8Mv0HHfD1Rr+4I68+3agJynxDWtBFgicTbSOb9Bke6pIwzGcJ+RX/yHjmiEGFMCavY/dxvem7MyNaJF+wDiw==}
cpu: [x64]
os: [linux]
libc: [musl]
- '@img/sharp-linux-arm64@0.35.3':
- resolution: {integrity: sha512-QgKDspHPnrU+GQ55XPhGwyhC8acLVOOSyAvo1oVfFmrIXLkDNmGWzAfDZ4xK8oSA1qBQrALcHX0G5UZni/SuFQ==}
- engines: {node: '>=20.9.0'}
- cpu: [arm64]
- os: [linux]
- libc: [glibc]
-
'@img/sharp-linux-arm64@0.35.4':
resolution: {integrity: sha512-De4jpEnAU8Hd5oT0j1G3uL4ZvTuipVMn7YC6vPaJhy6/7EwEae0SVAoBrUMYQbkLGDm85taVWwuPc1a44LTzCQ==}
engines: {node: '>=20.9.0'}
@@ -1779,13 +1697,6 @@ packages:
os: [linux]
libc: [glibc]
- '@img/sharp-linux-arm@0.35.3':
- resolution: {integrity: sha512-affVWCTLooy8TSxbDx2qkzuDeaWLNVBA+P//FNBirHsXpP2fuBhk5AuboYUnrDnzoXes8GFjpTx0SBFOCRg+FA==}
- engines: {node: '>=20.9.0'}
- cpu: [arm]
- os: [linux]
- libc: [glibc]
-
'@img/sharp-linux-arm@0.35.4':
resolution: {integrity: sha512-7OAS8gI0EReKGVN2HssHlM6umJgxF5VI3xN0p9FA91p/YO+ou5hiNghLdZ5BEHztwaaK5+bLKRf8x/o2L2nk9A==}
engines: {node: '>=20.9.0'}
@@ -1793,13 +1704,6 @@ packages:
os: [linux]
libc: [glibc]
- '@img/sharp-linux-ppc64@0.35.3':
- resolution: {integrity: sha512-sMd8rDxmpLOwv/7N44klFjOD5DUO7FLdjiXDI0hoxYaf7Ar262dQIEkosE98bps+5HPLtp/EvNqeqQtOycP/IA==}
- engines: {node: '>=20.9.0'}
- cpu: [ppc64]
- os: [linux]
- libc: [glibc]
-
'@img/sharp-linux-ppc64@0.35.4':
resolution: {integrity: sha512-2oYZJeIl4kCcMGk4ouZVjnkCtFrpQFlNEtJ6GbxzhHQchwH0NH/qEb9ykmOl29dqwMq+JhFdZn+1ak2FKhI9fQ==}
engines: {node: '>=20.9.0'}
@@ -1807,13 +1711,6 @@ packages:
os: [linux]
libc: [glibc]
- '@img/sharp-linux-riscv64@0.35.3':
- resolution: {integrity: sha512-0Eob78yjlYPfL5vMNWAW55l3R9Y6BQS/gOfe0ZcP9mEz9ohhKSt4im1hayiknXgf8AWrFqMvJcKIdmLmEe7yeQ==}
- engines: {node: '>=20.9.0'}
- cpu: [riscv64]
- os: [linux]
- libc: [glibc]
-
'@img/sharp-linux-riscv64@0.35.4':
resolution: {integrity: sha512-cPbNChoRURAWdebDIHSenxRpgEdy7JkPydSnUxRm9VvKD7m0/xVaR/8Fzlu81pk5nHEvHH87UZUA7cTtwnbJSA==}
engines: {node: '>=20.9.0'}
@@ -1821,13 +1718,6 @@ packages:
os: [linux]
libc: [glibc]
- '@img/sharp-linux-s390x@0.35.3':
- resolution: {integrity: sha512-KgAxQ0DxpNOq1rG2t5cgTgShJFGSuU7XO45cqC+1NVOuZnP6tlgZRuSYOfNupGkHID0o3cJOsw4DVeJpMovcGw==}
- engines: {node: '>=20.9.0'}
- cpu: [s390x]
- os: [linux]
- libc: [glibc]
-
'@img/sharp-linux-s390x@0.35.4':
resolution: {integrity: sha512-RY0JFY8Fd6RonCBtHz+DvadaPkXDSI1AUn6yWL9TipqkZ1vY8w8evqdgyDFnkm4/K1ve1TvZiaePP5oSd4+WVQ==}
engines: {node: '>=20.9.0'}
@@ -1835,13 +1725,6 @@ packages:
os: [linux]
libc: [glibc]
- '@img/sharp-linux-x64@0.35.3':
- resolution: {integrity: sha512-8pqvxubL2PGdhlPy6GLqzDYMUjyRmKAwKHYKixpdJYBUK7PJ0C029XdsnpFIdgRZG68fZiGdHVWcKPvtiPB4cA==}
- engines: {node: '>=20.9.0'}
- cpu: [x64]
- os: [linux]
- libc: [glibc]
-
'@img/sharp-linux-x64@0.35.4':
resolution: {integrity: sha512-9qvvEAuk8k89TfWUoX2htWjbAMX8p+NxCppjpcg5k6xMsjhBQPTsoIh36h9Qde4WRuGpJeYnOjdosDn/cnv+OA==}
engines: {node: '>=20.9.0'}
@@ -1849,13 +1732,6 @@ packages:
os: [linux]
libc: [glibc]
- '@img/sharp-linuxmusl-arm64@0.35.3':
- resolution: {integrity: sha512-Vz0iQjzzcSX3HCbfwFfCSG/9SCIqyO0mH2sXyiHaAYfBk0cRsCWXRyQYX0ovCK/PAQBbTzQ0dsPQHh5MAFL59w==}
- engines: {node: '>=20.9.0'}
- cpu: [arm64]
- os: [linux]
- libc: [musl]
-
'@img/sharp-linuxmusl-arm64@0.35.4':
resolution: {integrity: sha512-KB5jxpfWQTr0nc3xdHtWChdbifHrBGsd2SM62Eyxrl8afikm+f5qGBU75SJIZBT/S1MC8XyacdlXBMSWq6OURA==}
engines: {node: '>=20.9.0'}
@@ -1863,13 +1739,6 @@ packages:
os: [linux]
libc: [musl]
- '@img/sharp-linuxmusl-x64@0.35.3':
- resolution: {integrity: sha512-6O1NPKcDVj9QEdg7Hx549EX8U0rp6yXQERqru6yRN7fGBn32UvIRJUlWnk+8xDCiG76hXVBbX82NZ/ZKr0euIg==}
- engines: {node: '>=20.9.0'}
- cpu: [x64]
- os: [linux]
- libc: [musl]
-
'@img/sharp-linuxmusl-x64@0.35.4':
resolution: {integrity: sha512-f+eZJZIQNEEd26RPSW+76chwOf1XtA2Y/O+5ocVyLliHkeih3e+jhLVBdNTd2rS3IbNXK8+ug93Vf5ZXtF5Lxg==}
engines: {node: '>=20.9.0'}
@@ -1877,54 +1746,27 @@ packages:
os: [linux]
libc: [musl]
- '@img/sharp-wasm32@0.35.3':
- resolution: {integrity: sha512-cZ0XkcYGpHZkqW6iCkqTcmUC0CD9DhD5d/qeZlZkfRBn6GnHniZXLUo5+9xw8Iv76YE6LQFN9YNBlKREcCG76w==}
- engines: {node: '>=20.9.0'}
-
'@img/sharp-wasm32@0.35.4':
resolution: {integrity: sha512-zQnl4Kwp7Q6NHsENtU2T/00Zi+w3AQNwz3+UaTyVBy2FpXrzXzGjndpK61onhZjRtRpQXxCTeqw19bVyXOh7jA==}
engines: {node: '>=20.9.0'}
- '@img/sharp-webcontainers-wasm32@0.35.3':
- resolution: {integrity: sha512-2rnq7bX3NzeR2T4YWgz8qiG4h3TSdMe+vN1iQXpJleSJ3SM5zQ8Fy2SyyXAWlbxpEZ2Y+Z4u1BePgJEYbSy80Q==}
- engines: {node: '>=20.9.0'}
- cpu: [wasm32]
-
'@img/sharp-webcontainers-wasm32@0.35.4':
resolution: {integrity: sha512-ESfNkywmCfPNyaZjxooddJQiQ+l/nTpGEOGthxiLnIHXC/CmcBixnfwUleX9mCz9ovrUUvKMap/pm8RYbzfwaA==}
engines: {node: '>=20.9.0'}
cpu: [wasm32]
- '@img/sharp-win32-arm64@0.35.3':
- resolution: {integrity: sha512-4bPwFdMbeC4JQ8L8LOyWp6nsHcboP5fxkp6iPOXz2Vg49R42TuMs2whkJ5OAP4/Ul035qOzy0AecOF9VOscn4w==}
- engines: {node: '>=20.9.0'}
- cpu: [arm64]
- os: [win32]
-
'@img/sharp-win32-arm64@0.35.4':
resolution: {integrity: sha512-iNdlBX9gLVvqe2I3uIJSIKTq6wckP/DYxZtcqxm09x5Gi24DnFBmPAWZmr60ZyYMG0xlzo6goG3670ar+RXvRw==}
engines: {node: '>=20.9.0'}
cpu: [arm64]
os: [win32]
- '@img/sharp-win32-ia32@0.35.3':
- resolution: {integrity: sha512-r53mXsBN6lFUDiST764SvgwUdHAqM4rPAiDzAmf4fLoB6X/rkfyTrLCg6+g17wJJiCmB3JYgHuUldCWUIRFSXw==}
- engines: {node: ^20.9.0}
- cpu: [ia32]
- os: [win32]
-
'@img/sharp-win32-ia32@0.35.4':
resolution: {integrity: sha512-kqRsbaa5CS6KHlpxnN7WhE6vAAugXyZButpRdvDWetlv6Qv4N9WTcrWzF7tXfB9T7MsoadqdI8hmwLq6UlLvtw==}
engines: {node: ^20.9.0}
cpu: [ia32]
os: [win32]
- '@img/sharp-win32-x64@0.35.3':
- resolution: {integrity: sha512-D4y1vNeZrIIJCN+uHaWVtH86B+aCrdMYYjicy9pXHvbGZeGYLLSd3wdVuC37FxVXlU1ARsk84eKWfWMXGYEqvA==}
- engines: {node: '>=20.9.0'}
- cpu: [x64]
- os: [win32]
-
'@img/sharp-win32-x64@0.35.4':
resolution: {integrity: sha512-XtmnYhBcrORsJ4XJngyzr/EWP0hRZLAZRFaApdKuviyqF78+ylxh2y06ZmtULAMOnObJ3ucpN0AcwSWnMowTRg==}
engines: {node: '>=20.9.0'}
@@ -5734,8 +5576,8 @@ packages:
resolution: {integrity: sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ==}
hasBin: true
- joi@18.2.3:
- resolution: {integrity: sha512-N5A3KTWQpPWT4ExxxPlUx7WmykGXRzhNidWhV41d6Abu9YfI2NyWCJuxdPnslJCPWtbRpSVOWSnSS6GakLM/Rg==}
+ joi@18.2.5:
+ resolution: {integrity: sha512-+gEA7rLfaNWx9JzawWPrPetSZwT16NUqHtECDgjyAJreXcs4TM7tx2Pa+VVJJK0YHM83ybrVdaT6UekHH50FJQ==}
engines: {node: '>= 20'}
jpeg-js@0.4.4:
@@ -5754,8 +5596,8 @@ packages:
js-tokens@4.0.0:
resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==}
- js-yaml@4.3.1:
- resolution: {integrity: sha512-CY6crGq313MX8GkwvB7tzgp99vjQxY1++5y10/BKN/GUfHqWaOGQMNZkBvqSzsZKWk/ijwHlWzzkLulsGHhjWQ==}
+ js-yaml@4.3.2:
+ resolution: {integrity: sha512-SFNOvSJ+Dgf/9An904Yx+CgSlIPCkIpao4qo51lpee25TIRejdH3rhR4EZMGoNx3/TP3O+wzWuiTFl4sqbltzA==}
hasBin: true
jsdom@29.1.1:
@@ -6996,15 +6838,6 @@ packages:
setprototypeof@1.2.0:
resolution: {integrity: sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==}
- sharp@0.35.3:
- resolution: {integrity: sha512-ej0zVHuZGHCiABXcNxeYhpRnPNPAcvbG8RMdBAhDAxLKkCRVSpK3Iyu7qbqw3JMzoj0REeM6f3tJLtVwl0023Q==}
- engines: {node: '>=20.9.0'}
- peerDependencies:
- '@types/node': '*'
- peerDependenciesMeta:
- '@types/node':
- optional: true
-
sharp@0.35.4:
resolution: {integrity: sha512-n++8XWcj+jCOr2IOl7h8LbKnGBDY4aPbmprMONBNFdn0ImXqpGVv5zliDs0V9HbmbCQLpbuo2ej9rAoOQTvMDA==}
engines: {node: '>=20.9.0'}
@@ -9391,218 +9224,114 @@ snapshots:
'@huggingface/tokenizers': 0.1.3
onnxruntime-node: 1.24.3
onnxruntime-web: 1.26.0-dev.20260416-b7804b056c
- sharp: 0.35.3(@types/node@25.9.2)
+ sharp: 0.35.4(@types/node@25.9.2)
transitivePeerDependencies:
- '@types/node'
optional: true
'@img/colour@1.1.0': {}
- '@img/sharp-darwin-arm64@0.35.3':
- optionalDependencies:
- '@img/sharp-libvips-darwin-arm64': 1.3.2
- optional: true
-
'@img/sharp-darwin-arm64@0.35.4':
optionalDependencies:
'@img/sharp-libvips-darwin-arm64': 1.3.3
optional: true
- '@img/sharp-darwin-x64@0.35.3':
- optionalDependencies:
- '@img/sharp-libvips-darwin-x64': 1.3.2
- optional: true
-
'@img/sharp-darwin-x64@0.35.4':
optionalDependencies:
'@img/sharp-libvips-darwin-x64': 1.3.3
optional: true
- '@img/sharp-freebsd-wasm32@0.35.3':
- dependencies:
- '@img/sharp-wasm32': 0.35.3
- optional: true
-
'@img/sharp-freebsd-wasm32@0.35.4':
dependencies:
'@img/sharp-wasm32': 0.35.4
optional: true
- '@img/sharp-libvips-darwin-arm64@1.3.2':
- optional: true
-
'@img/sharp-libvips-darwin-arm64@1.3.3':
optional: true
- '@img/sharp-libvips-darwin-x64@1.3.2':
- optional: true
-
'@img/sharp-libvips-darwin-x64@1.3.3':
optional: true
- '@img/sharp-libvips-linux-arm64@1.3.2':
- optional: true
-
'@img/sharp-libvips-linux-arm64@1.3.3':
optional: true
- '@img/sharp-libvips-linux-arm@1.3.2':
- optional: true
-
'@img/sharp-libvips-linux-arm@1.3.3':
optional: true
- '@img/sharp-libvips-linux-ppc64@1.3.2':
- optional: true
-
'@img/sharp-libvips-linux-ppc64@1.3.3':
optional: true
- '@img/sharp-libvips-linux-riscv64@1.3.2':
- optional: true
-
'@img/sharp-libvips-linux-riscv64@1.3.3':
optional: true
- '@img/sharp-libvips-linux-s390x@1.3.2':
- optional: true
-
'@img/sharp-libvips-linux-s390x@1.3.3':
optional: true
- '@img/sharp-libvips-linux-x64@1.3.2':
- optional: true
-
'@img/sharp-libvips-linux-x64@1.3.3':
optional: true
- '@img/sharp-libvips-linuxmusl-arm64@1.3.2':
- optional: true
-
'@img/sharp-libvips-linuxmusl-arm64@1.3.3':
optional: true
- '@img/sharp-libvips-linuxmusl-x64@1.3.2':
- optional: true
-
'@img/sharp-libvips-linuxmusl-x64@1.3.3':
optional: true
- '@img/sharp-linux-arm64@0.35.3':
- optionalDependencies:
- '@img/sharp-libvips-linux-arm64': 1.3.2
- optional: true
-
'@img/sharp-linux-arm64@0.35.4':
optionalDependencies:
'@img/sharp-libvips-linux-arm64': 1.3.3
optional: true
- '@img/sharp-linux-arm@0.35.3':
- optionalDependencies:
- '@img/sharp-libvips-linux-arm': 1.3.2
- optional: true
-
'@img/sharp-linux-arm@0.35.4':
optionalDependencies:
'@img/sharp-libvips-linux-arm': 1.3.3
optional: true
- '@img/sharp-linux-ppc64@0.35.3':
- optionalDependencies:
- '@img/sharp-libvips-linux-ppc64': 1.3.2
- optional: true
-
'@img/sharp-linux-ppc64@0.35.4':
optionalDependencies:
'@img/sharp-libvips-linux-ppc64': 1.3.3
optional: true
- '@img/sharp-linux-riscv64@0.35.3':
- optionalDependencies:
- '@img/sharp-libvips-linux-riscv64': 1.3.2
- optional: true
-
'@img/sharp-linux-riscv64@0.35.4':
optionalDependencies:
'@img/sharp-libvips-linux-riscv64': 1.3.3
optional: true
- '@img/sharp-linux-s390x@0.35.3':
- optionalDependencies:
- '@img/sharp-libvips-linux-s390x': 1.3.2
- optional: true
-
'@img/sharp-linux-s390x@0.35.4':
optionalDependencies:
'@img/sharp-libvips-linux-s390x': 1.3.3
optional: true
- '@img/sharp-linux-x64@0.35.3':
- optionalDependencies:
- '@img/sharp-libvips-linux-x64': 1.3.2
- optional: true
-
'@img/sharp-linux-x64@0.35.4':
optionalDependencies:
'@img/sharp-libvips-linux-x64': 1.3.3
optional: true
- '@img/sharp-linuxmusl-arm64@0.35.3':
- optionalDependencies:
- '@img/sharp-libvips-linuxmusl-arm64': 1.3.2
- optional: true
-
'@img/sharp-linuxmusl-arm64@0.35.4':
optionalDependencies:
'@img/sharp-libvips-linuxmusl-arm64': 1.3.3
optional: true
- '@img/sharp-linuxmusl-x64@0.35.3':
- optionalDependencies:
- '@img/sharp-libvips-linuxmusl-x64': 1.3.2
- optional: true
-
'@img/sharp-linuxmusl-x64@0.35.4':
optionalDependencies:
'@img/sharp-libvips-linuxmusl-x64': 1.3.3
optional: true
- '@img/sharp-wasm32@0.35.3':
- dependencies:
- '@emnapi/runtime': 1.11.3
- optional: true
-
'@img/sharp-wasm32@0.35.4':
dependencies:
'@emnapi/runtime': 1.11.3
optional: true
- '@img/sharp-webcontainers-wasm32@0.35.3':
- dependencies:
- '@img/sharp-wasm32': 0.35.3
- optional: true
-
'@img/sharp-webcontainers-wasm32@0.35.4':
dependencies:
'@img/sharp-wasm32': 0.35.4
optional: true
- '@img/sharp-win32-arm64@0.35.3':
- optional: true
-
'@img/sharp-win32-arm64@0.35.4':
optional: true
- '@img/sharp-win32-ia32@0.35.3':
- optional: true
-
'@img/sharp-win32-ia32@0.35.4':
optional: true
- '@img/sharp-win32-x64@0.35.3':
- optional: true
-
'@img/sharp-win32-x64@0.35.4':
optional: true
@@ -9741,7 +9470,7 @@ snapshots:
camelcase: 5.3.1
find-up: 4.1.0
get-package-type: 0.1.0
- js-yaml: 4.3.1
+ js-yaml: 4.3.2
resolve-from: 5.0.0
'@istanbuljs/schema@0.1.6': {}
@@ -9995,7 +9724,7 @@ snapshots:
dependencies:
debug: 4.4.3(supports-color@10.2.2)
isomorphic-fetch: 3.0.0
- js-yaml: 4.3.1
+ js-yaml: 4.3.2
lighthouse: 12.6.1(supports-color@10.2.2)
tree-kill: 1.2.2
transitivePeerDependencies:
@@ -13675,7 +13404,7 @@ snapshots:
jiti@2.7.0: {}
- joi@18.2.3:
+ joi@18.2.5:
dependencies:
'@hapi/address': 5.1.1
'@hapi/formula': 3.0.2
@@ -13695,7 +13424,7 @@ snapshots:
js-tokens@4.0.0: {}
- js-yaml@4.3.1:
+ js-yaml@4.3.2:
dependencies:
argparse: 2.0.1
@@ -15066,40 +14795,6 @@ snapshots:
setprototypeof@1.2.0: {}
- sharp@0.35.3(@types/node@25.9.2):
- dependencies:
- '@img/colour': 1.1.0
- detect-libc: 2.1.2
- semver: 7.8.5
- optionalDependencies:
- '@img/sharp-darwin-arm64': 0.35.3
- '@img/sharp-darwin-x64': 0.35.3
- '@img/sharp-freebsd-wasm32': 0.35.3
- '@img/sharp-libvips-darwin-arm64': 1.3.2
- '@img/sharp-libvips-darwin-x64': 1.3.2
- '@img/sharp-libvips-linux-arm': 1.3.2
- '@img/sharp-libvips-linux-arm64': 1.3.2
- '@img/sharp-libvips-linux-ppc64': 1.3.2
- '@img/sharp-libvips-linux-riscv64': 1.3.2
- '@img/sharp-libvips-linux-s390x': 1.3.2
- '@img/sharp-libvips-linux-x64': 1.3.2
- '@img/sharp-libvips-linuxmusl-arm64': 1.3.2
- '@img/sharp-libvips-linuxmusl-x64': 1.3.2
- '@img/sharp-linux-arm': 0.35.3
- '@img/sharp-linux-arm64': 0.35.3
- '@img/sharp-linux-ppc64': 0.35.3
- '@img/sharp-linux-riscv64': 0.35.3
- '@img/sharp-linux-s390x': 0.35.3
- '@img/sharp-linux-x64': 0.35.3
- '@img/sharp-linuxmusl-arm64': 0.35.3
- '@img/sharp-linuxmusl-x64': 0.35.3
- '@img/sharp-webcontainers-wasm32': 0.35.3
- '@img/sharp-win32-arm64': 0.35.3
- '@img/sharp-win32-ia32': 0.35.3
- '@img/sharp-win32-x64': 0.35.3
- '@types/node': 25.9.2
- optional: true
-
sharp@0.35.4(@types/node@25.9.2):
dependencies:
'@img/colour': 1.1.0
@@ -15903,7 +15598,7 @@ snapshots:
wait-on@7.2.0(debug@4.4.3(supports-color@10.2.2))(supports-color@10.2.2):
dependencies:
axios: 1.19.0(debug@4.4.3(supports-color@10.2.2))(supports-color@10.2.2)
- joi: 18.2.3
+ joi: 18.2.5
lodash: 4.18.1
minimist: 1.2.8
rxjs: 7.8.2
@@ -15914,7 +15609,7 @@ snapshots:
wait-on@9.1.0(debug@4.4.3(supports-color@10.2.2))(supports-color@10.2.2):
dependencies:
axios: 1.19.0(debug@4.4.3(supports-color@10.2.2))(supports-color@10.2.2)
- joi: 18.2.3
+ joi: 18.2.5
lodash: 4.18.1
minimist: 1.2.8
rxjs: 7.8.2
diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml
index fb070dbb..3ddbe044 100644
--- a/pnpm-workspace.yaml
+++ b/pnpm-workspace.yaml
@@ -66,7 +66,7 @@ overrides:
playwright: "1.61.0"
playwright-core: "1.61.0"
esbuild: ">=0.28.1"
- joi: "^18.2.1"
+ joi: ">=18.2.5 <19" # GHSA-6w3j-5fw6-r9vr + GHSA-gg4h-3hg2-grpc; capped at major 18 (wait-on's own declared range) so a future major can't force-install
typescript: "6.0.3"
serialize-javascript: ">=7.0.3"
chrome-launcher: "^1.2.1"
@@ -87,13 +87,13 @@ overrides:
dompurify: ">=3.4.13"
"@babel/core": ">=7.29.6 <8"
undici: ">=7.29.0 <8"
- js-yaml: ">=4.3.1 <5"
+ js-yaml: ">=4.3.2 <5" # GHSA-2883-xcg3-v3hh (jest/babel-istanbul transitive, dev-only)
nanoid@^3: "3.3.18"
nanoid@^5: "5.1.16"
# QNBS-v3 (2026-07-26): GHSA batch blocking the CI security gate — transitive-only dev paths.
adm-zip: ">=0.6.0" # GHSA-xcpc-8h2w-3j85 — 4GB memory allocation via crafted ZIP (onnxruntime-node)
postcss: ">=8.5.18" # GHSA — path traversal in previous source map (vite/storybook chain)
- sharp: ">=0.35.0" # inherited libvips vulnerabilities (@huggingface/transformers, miniflare)
+ sharp: ">=0.35.4" # GHSA-rgj7-g3m4-5g8c + inherited libvips vulnerabilities (@huggingface/transformers, miniflare)
body-parser: ">=1.20.6 <2" # GHSA-v422-hmwv-36x6 — DoS via empty urlencoded body (express via @lhci/cli, dev-only)
peerDependencyRules:
diff --git a/scripts/check-doc-metrics.d.mts b/scripts/check-doc-metrics.d.mts
index 00eb384b..d446ca8c 100644
--- a/scripts/check-doc-metrics.d.mts
+++ b/scripts/check-doc-metrics.d.mts
@@ -36,3 +36,4 @@ export function scanLocalizedBundleBudgetTruth(
export function getCanonicalProductionUrl(): string;
export function scanForUrlDrift(content: string, filePath: string, canonicalUrl: string): string[];
export const VERCEL_URL_PATTERN: RegExp;
+export function scanSecurityDocPrStatus(content: string, filePath: string): string[];
diff --git a/scripts/check-doc-metrics.mjs b/scripts/check-doc-metrics.mjs
index 4f83985c..7bd12432 100644
--- a/scripts/check-doc-metrics.mjs
+++ b/scripts/check-doc-metrics.mjs
@@ -499,6 +499,214 @@ export function scanForDrift(content, filePath, { localeCount, keyCount, latestV
return findings;
}
+// QNBS-v3: (audit F-1) reject a live/pending-remediation claim near a bare PR number in the two
+// security-status docs unless that same PR number also carries its actual closed/merged state
+// nearby — a stale "PR #356 is the active remediation" survived weeks after #356 closed because
+// nothing checked it. Deliberately scoped to these two files, not repo-wide: a blanket rule would
+// also reject the legitimate historical CHANGELOG entry, ADR narrative, and already-qualified
+// ROADMAP citations of the same PR elsewhere in the repo.
+const SECURITY_STATUS_DOCS = ['docs/SECURITY-THREAT-MODEL.md', 'docs/IDB-ENCRYPTION.md'];
+// QNBS-v3 (coderabbit): tolerate an inline-code span around the digits ("PR `#356`"), not just a
+// markdown-link bracket — both are real Markdown ways to format a PR reference.
+const PR_REFERENCE = /\[?PR\s*`?#(\d+)`?\]?/gi;
+// QNBS-v3 (codex): order-independent — catches "PR #N is the active remediation", "the active
+// remediation is PR #N", "PR #N remains the active remediation", "pending PR #N", "pending on
+// PR #N", "in progress on PR #N", etc. Proximity to a PR reference (not fixed word order) is what
+// makes a phrase a live-status CLAIM rather than incidental prose.
+const LIVE_STATUS_TRIGGER = /\bactive remediation\b|\bpending\b|\bin progress\b/gi;
+const STATUS_QUALIFIER_WORD = 'closed|merged|superseded';
+const STATUS_QUALIFIER_RE = new RegExp(`\\b(?:${STATUS_QUALIFIER_WORD})\\b`, 'gi');
+// QNBS-v3 (codex): a qualifier word only proves a completed status when it isn't negated
+// ("is not closed", "has not been closed", "is not yet closed") or prospective ("will be merged",
+// "may be merged") — checked for PRESENCE anywhere in the short lookback immediately before the
+// match, not requiring exact adjacency, so common compound/auxiliary forms are covered without
+// attempting a full negation-scope parser (a known, bounded best-effort heuristic, matching this
+// file's existing "crude but sufficient" sentence-split rationale).
+const QUALIFIER_NEGATION_OR_FUTURE_WORDS =
+ /\b(?:not|never|isn't|won't|will|would|should|may|might|could|going to)\b/i;
+// QNBS-v3 (codex): a live-status TRIGGER phrase negated or "no longer" true isn't a live claim at
+// all ("is not the active remediation", "no longer the active remediation").
+const TRIGGER_NEGATION_WORDS = /\b(?:not|never|no longer|isn't)\b/i;
+const NEGATION_LOOKBACK = 40;
+const PROXIMITY_WINDOW = 60;
+// QNBS-v3 (codex): an unordered/ordered Markdown list marker — same isolation reasoning as table
+// rows below.
+const LIST_ITEM_MARKER = /^(?:[-*+]|\d+\.)\s+/;
+
+// QNBS-v3 (CodeAnt): group physical lines into Markdown paragraphs (blank-line-delimited) before
+// matching — a naive per-line split let a status claim split across a soft-wrapped line evade
+// detection entirely. QNBS-v3 (codex): a Markdown table row or list item is its own logical unit
+// even though consecutive rows/items have no blank line between them — joining them let a
+// live-status claim in one row/item absorb an unrelated row/item's qualifier (or vice versa).
+function splitIntoParagraphs(content) {
+ const paragraphs = [];
+ let buffer = [];
+ let startLine = 0;
+ const flush = () => {
+ if (buffer.length > 0) {
+ paragraphs.push({ text: buffer.join(' '), startLine: startLine + 1 });
+ buffer = [];
+ }
+ };
+ content.split('\n').forEach((line, i) => {
+ const trimmed = line.trim();
+ if (trimmed === '') {
+ flush();
+ } else if (trimmed.startsWith('|')) {
+ // QNBS-v3 (codex): a table row is always a single physical line in standard Markdown — it
+ // never wraps — so push it immediately as its own unit, unlike a list item below.
+ flush();
+ paragraphs.push({ text: trimmed, startLine: i + 1 });
+ } else if (LIST_ITEM_MARKER.test(trimmed)) {
+ // QNBS-v3 (coderabbit): a new list item starts a new unit, but its own text may still
+ // soft-wrap across the following physical line(s) — buffer it like prose (don't push
+ // immediately) and let the next marker/table-row/blank line flush it.
+ flush();
+ startLine = i;
+ buffer.push(trimmed);
+ } else {
+ if (buffer.length === 0) startLine = i;
+ buffer.push(trimmed);
+ }
+ });
+ flush();
+ return paragraphs;
+}
+
+// QNBS-v3 (codex): a semicolon does not end a sentence — splitting on it separated a claim from
+// its own qualifying clause (e.g. "PR #N is the active remediation; it was later closed."). Only
+// a period genuinely ends a sentence here; the character-proximity window below is what actually
+// bounds how far a qualifier/trigger may be from a PR reference, not this split.
+function splitIntoSentences(paragraph) {
+ return paragraph.split(/(?<=\.)\s+/);
+}
+
+// QNBS-v3 (codex): a bracketed "#NNN" whose link target is a /pull/NNN URL (the same bare
+// shorthand style already used for issue links like "[#358](.../issues/358)" in these exact two
+// docs) is a real PR reference with no literal "PR" text — normalize it to include "PR" BEFORE
+// the URL is stripped below, since PR_REFERENCE needs the URL gone but the "PR" word present. The
+// \2 backreference ties the link text's number to the URL's /pull/ number so a mismatched pair
+// (accidentally or adversarially) isn't misattributed.
+function normalizePullShorthand(text) {
+ return text.replace(/\[(#(\d+))\]\((?:[^)]*\/pull\/\2)\)/gi, '[PR $1]');
+}
+
+// QNBS-v3 (codex): a markdown link's URL (github.com/.../pull/NNN) adds length between a PR
+// reference and its surrounding wording without adding meaning — strip it before measuring
+// proximity, so a long URL can't push a genuinely adjacent trigger/qualifier word out of window.
+function stripLinkUrls(text) {
+ return normalizePullShorthand(text).replace(/\]\([^)]*\)/g, ']');
+}
+
+// QNBS-v3 (codex): associate a word occurrence with its NEAREST PR reference by character
+// distance, not "any PR reference within a fixed window" — a raw-window check let a short,
+// unrelated PR's qualifier suppress a different PR's live claim when both sat close together
+// (e.g. "[PR #999] is the active remediation, unlike [PR #111], closed.").
+function nearestPrNumber(position, prRefs) {
+ let best = null;
+ let bestDistance = Infinity;
+ for (const ref of prRefs) {
+ const distance = position < ref.index ? ref.index - position : Math.max(0, position - ref.end);
+ if (distance < bestDistance) {
+ bestDistance = distance;
+ best = ref.prNumber;
+ }
+ }
+ return bestDistance <= PROXIMITY_WINDOW ? best : null;
+}
+
+function findPrReferences(sentence) {
+ return [...sentence.matchAll(PR_REFERENCE)].map((m) => ({
+ index: m.index,
+ end: m.index + m[0].length,
+ prNumber: m[1],
+ }));
+}
+
+// QNBS-v3 (coderabbit/codex): a negation word belonging to an EARLIER clause must not scope over
+// this match ("... is not encrypted, so PR #N is the active remediation" — the "not" modifies
+// "encrypted", not "active remediation"). Truncate the raw window at the last clause-separating
+// comma/semicolon/colon before the match.
+function lookback(sentence, matchIndex) {
+ const window = sentence.slice(Math.max(0, matchIndex - NEGATION_LOOKBACK), matchIndex);
+ const boundary = Math.max(
+ window.lastIndexOf(','),
+ window.lastIndexOf(';'),
+ window.lastIndexOf(':'),
+ );
+ return boundary === -1 ? window : window.slice(boundary + 1);
+}
+
+// QNBS-v3 (codex): "is not closed" / "will be merged" don't assert a completed status — only a
+// qualifier that isn't negated or prospective actually proves the PR is done.
+function isNegatedOrProspectiveQualifier(sentence, matchIndex) {
+ return QUALIFIER_NEGATION_OR_FUTURE_WORDS.test(lookback(sentence, matchIndex));
+}
+
+// QNBS-v3 (codex): "is not the active remediation" / "no longer the active remediation" don't
+// assert live status at all — the trigger phrase itself is negated away.
+function isNegatedTrigger(sentence, matchIndex) {
+ return TRIGGER_NEGATION_WORDS.test(lookback(sentence, matchIndex));
+}
+
+// QNBS-v3 (CodeScene): extracted so scanSecurityDocPrStatus itself stays a flat, shallow loop —
+// each of these small helpers owns exactly one nested loop+conditional, not three stacked in one.
+function collectQualifiedPrs(sentence, prRefs) {
+ const qualifiedPrs = new Set();
+ for (const m of sentence.matchAll(STATUS_QUALIFIER_RE)) {
+ const nearest = nearestPrNumber(m.index, prRefs);
+ if (nearest !== null && !isNegatedOrProspectiveQualifier(sentence, m.index)) {
+ qualifiedPrs.add(nearest);
+ }
+ }
+ return qualifiedPrs;
+}
+
+function collectUnqualifiedClaims(sentence, prRefs, qualifiedPrs) {
+ const claims = new Set();
+ for (const m of sentence.matchAll(LIVE_STATUS_TRIGGER)) {
+ const nearest = nearestPrNumber(m.index, prRefs);
+ if (nearest !== null && !qualifiedPrs.has(nearest) && !isNegatedTrigger(sentence, m.index)) {
+ claims.add(nearest);
+ }
+ }
+ return claims;
+}
+
+function findUnqualifiedClaimsInSentence(sentence) {
+ const prRefs = findPrReferences(sentence);
+ if (prRefs.length === 0) return [];
+ const qualifiedPrs = collectQualifiedPrs(sentence, prRefs);
+ return [...collectUnqualifiedClaims(sentence, prRefs, qualifiedPrs)];
+}
+
+// QNBS-v3 (codex): an HTML comment or fenced code block is never rendered prose — a literal
+// example inside one isn't a live assertion about a real PR. Blank out matched spans (keep
+// newlines) rather than remove lines, so line numbers stay stable for the findings below.
+// Known, accepted limitation: a single-backtick inline-code SPAN is deliberately not stripped —
+// PR_REFERENCE needs backtick tolerance for a real "PR `#356`" citation, and distinguishing that
+// from a whole illustrative phrase wrapped in one backtick pair isn't attempted here.
+function stripNonProseMarkdown(content) {
+ const blank = (match) => match.replace(/[^\n]/g, ' ');
+ return content.replace(//g, blank).replace(/```[\s\S]*?```/g, blank);
+}
+
+export function scanSecurityDocPrStatus(content, filePath) {
+ const findings = [];
+ const prose = stripNonProseMarkdown(content);
+ for (const { text: paragraph, startLine } of splitIntoParagraphs(prose)) {
+ const compact = stripLinkUrls(paragraph);
+ for (const sentence of splitIntoSentences(compact)) {
+ for (const prNumber of findUnqualifiedClaimsInSentence(sentence)) {
+ findings.push(
+ `${filePath}:${startLine} — asserts a live/pending remediation status near PR #${prNumber} without stating that PR's actual closed/merged state: "${sentence.trim()}"`,
+ );
+ }
+ }
+ }
+ return findings;
+}
+
// QNBS-v3: exits 1 on any finding — unlike check-coverage-ratchet.mjs this gate is blocking, since a doc claiming a wrong locale/key/release count is actively misleading, not just an opportunity.
// QNBS-v3 (F-10, CodeRabbit follow-up): locales/it/help.json IS included — it's exactly where the F-10 stale-URL drift happened; the in-app link reads the constant directly so it can't drift and isn't listed here.
const URL_CHECK_FILES = ['README.md', 'CLAUDE.md', 'locales/it/help.json'];
@@ -555,6 +763,21 @@ function main() {
allFindings.push(...scanForUrlDrift(content, relPath, canonicalUrl));
}
+ for (const relPath of SECURITY_STATUS_DOCS) {
+ const abs = join(root, relPath);
+ let content;
+ try {
+ content = readFileSync(abs, 'utf8');
+ } catch {
+ // QNBS-v3 (codex): these two files are this gate's required subjects — silently skipping a
+ // missing/unreadable one would make the live-status enforcement disappear exactly when its
+ // input is unavailable, the same failure mode as BUNDLE_BUDGET_DOCS below.
+ allFindings.push(`${relPath} — required security-status document is missing or unreadable`);
+ continue;
+ }
+ allFindings.push(...scanSecurityDocPrStatus(content, relPath));
+ }
+
for (const relPath of BUNDLE_BUDGET_DOCS) {
const abs = join(root, relPath);
let content;
diff --git a/src-tauri/osv-scanner.toml b/src-tauri/osv-scanner.toml
index 5ed060f0..ca344f5f 100644
--- a/src-tauri/osv-scanner.toml
+++ b/src-tauri/osv-scanner.toml
@@ -1,6 +1,6 @@
# osv-scanner ignore list for src-tauri/Cargo.lock + pnpm-lock.yaml
# All entries have been evaluated and accepted by the maintainer.
-# Reviewed: 2026-05-17 (Rust) / 2026-06-16 (npm)
+# Reviewed: 2026-05-17 (Rust) / 2026-09-08 (npm)
# ─── GTK3 Rust bindings (Linux WebKit stack) ────────────────────────────────
# webkit2gtk 2.0.2 (required by wry 0.55.x / Tauri 2.x for Linux) depends on
@@ -117,24 +117,66 @@ ignoreUntil = "2026-11-30T00:00:00Z"
reason = "unic-ucd-ident 0.9.0: unmaintained Unicode data crate; final version; no exploitable attack surface"
# ─── npm advisories (pnpm-lock.yaml) ─────────────────────────────────────────
-# Reviewed 2026-06-16 / 2026-07-26 / 2026-08-12. Most currently-flagged npm
-# advisories are remediated via pnpm-workspace.yaml overrides (tmp, ws,
-# form-data, protobufjs, @babel/core, dompurify→3.4.12, js-yaml→4.x,
-# body-parser→1.20.6, …). (The former dompurify GHSA-x4vx-rjvf-j5p4 and
-# js-yaml-3.x GHSA-h67p-54hq-rp68 ignores were dropped: both advisories no
-# longer match the lockfile after the overrides.) One documented ignore
-# remains below — no fixed version exists to override to.
+# Reviewed 2026-06-16 / 2026-07-26 / 2026-08-12 / 2026-09-08. Most
+# currently-flagged npm advisories are remediated via pnpm-workspace.yaml
+# overrides (tmp, ws, form-data, protobufjs, @babel/core, dompurify→3.4.13,
+# js-yaml→4.3.2, body-parser→1.20.6, joi→18.2.5, sharp→0.35.4, …). (The former
+# dompurify GHSA-x4vx-rjvf-j5p4 and js-yaml-3.x GHSA-h67p-54hq-rp68 ignores
+# were dropped: both advisories no longer match the lockfile after the
+# overrides.) Three documented ignores remain below — no fixed version exists
+# to override to for any of them.
# extract-zip@2.0.1 (CVE-2026-56876 / GHSA-jmr9-qjv8-65gv, CVSS 8.6): unvalidated
# symlink path traversal when extracting an attacker-controlled zip archive.
# extract-zip@2.0.1 is the latest published release — no patched version exists
# (first_patched_version: null on the advisory) — so pnpm.overrides cannot fix
-# this. It's a transitive devDependency of @puppeteer/browsers (Playwright's
-# browser-binary downloader): only ever extracts Playwright/Chromium's own
-# CDN-hosted zip releases, never a user- or attacker-supplied archive, and never
-# ships in the production app bundle. No exploitable attack surface here.
+# this. QNBS-v3 (codex, corrected): the actual lock-graph path (verified via
+# `pnpm audit`) is `@lhci/cli` -> `lighthouse` -> `puppeteer-core` ->
+# `@puppeteer/browsers` -> `extract-zip` — Lighthouse CI's own headless-Chrome
+# automation stack (which itself depends on Puppeteer internally), NOT this
+# repo's separate `playwright`/`playwright-core` devDependency despite the
+# similarly-named `@puppeteer/browsers` package. It only extracts
+# Puppeteer/Lighthouse's own CDN-hosted Chrome binary releases, never a user-
+# or attacker-supplied archive, and never ships in the production app bundle.
+# No exploitable attack surface here.
[[IgnoredVulns]]
id = "GHSA-jmr9-qjv8-65gv"
ignoreUntil = "2026-11-30T00:00:00Z"
-reason = "extract-zip 2.0.1: unvalidated symlink path traversal; latest release, no fixed version exists; transitive Playwright/@puppeteer/browsers devDependency only extracting Playwright's own CDN-hosted browser binaries, never user-supplied zips; no runtime footprint in the shipped app"
+reason = "extract-zip 2.0.1: unvalidated symlink path traversal; latest release, no fixed version exists; transitive @lhci/cli->lighthouse->puppeteer-core->@puppeteer/browsers devDependency (Lighthouse CI's own Puppeteer stack, not Playwright) only extracting its own CDN-hosted Chrome binaries, never user-supplied zips; no runtime footprint in the shipped app"
+
+# extract-zip@2.0.1 (GHSA-7pqw-9j4j-h8q3, CVSS 8.1): a second, distinct advisory
+# for the same package/version (CWE-22 path traversal via two archive entries
+# with identical names — a symlink followed by a regular file — bypassing the
+# containment check). Not an alias of GHSA-jmr9-qjv8-65gv above; OSV reports it
+# separately. Same "no patched version exists" and same non-exposure reasoning
+# applies unchanged: the same @lhci/cli->lighthouse->puppeteer-core->
+# @puppeteer/browsers devDependency chain above, only ever extracting
+# Puppeteer/Lighthouse's own CDN-hosted Chrome binaries, never ships in the app.
+
+[[IgnoredVulns]]
+id = "GHSA-7pqw-9j4j-h8q3"
+ignoreUntil = "2026-11-30T00:00:00Z"
+reason = "extract-zip 2.0.1: second, distinct symlink path-traversal advisory (two-entry name-collision technique); latest release, no fixed version exists; same transitive @lhci/cli->lighthouse->puppeteer-core->@puppeteer/browsers devDependency (Lighthouse CI's own Puppeteer stack, not Playwright), never user-supplied zips, no runtime footprint in the shipped app"
+
+# adm-zip@0.6.0 (CVE-2026-76845 / GHSA-vwc7-r8mq-g2x9, CVSS 6.8): symlink-following
+# path traversal on extraction with overwrite enabled, when an attacker can
+# pre-place a symlink at the extraction destination. 0.6.0 is the latest
+# published release with no patched version yet — pnpm.overrides already pins
+# the newest available release (see pnpm-workspace.yaml) but cannot fix an
+# unpatched vulnerability. adm-zip is a dependency of onnxruntime-node's own
+# postinstall/build script (which extracts its prebuilt native binary into
+# `path.join(os.tmpdir(), 'onnxruntime-node-pkgs_')` — a shared,
+# timestamp-named system temp directory, NOT a project-local one — verified
+# directly against onnxruntime-node@1.24.3's own install-utils.js). The actual
+# mitigation is that `pnpm-workspace.yaml`'s `allowBuilds: { onnxruntime-node:
+# false }` denies this package's lifecycle scripts outright, so this code path
+# never executes during a normal `pnpm install` in this repo, in CI, or in the
+# shipped app. If that `allowBuilds` entry is ever relaxed or removed, this
+# exposure model must be re-evaluated — the temp-directory extraction target
+# is not inherently safe on its own.
+
+[[IgnoredVulns]]
+id = "GHSA-vwc7-r8mq-g2x9"
+ignoreUntil = "2026-11-30T00:00:00Z"
+reason = "adm-zip 0.6.0: symlink-following path traversal on overwrite-enabled extraction; latest release, no fixed version exists; mitigated by pnpm-workspace.yaml's allowBuilds: onnxruntime-node=false denying its postinstall/build script outright (not by the extraction path itself, which uses a shared system temp directory) — re-evaluate if that allowBuilds entry ever changes"
diff --git a/tests/unit/checkDocMetrics.test.ts b/tests/unit/checkDocMetrics.test.ts
index 3fcee4d6..0984a7fe 100644
--- a/tests/unit/checkDocMetrics.test.ts
+++ b/tests/unit/checkDocMetrics.test.ts
@@ -16,6 +16,7 @@ import {
scanLocalizedBundleBudgetTruth,
scanReadmeTestMetrics,
scanReleaseTruth,
+ scanSecurityDocPrStatus,
stripHistoricalSections,
VERCEL_URL_PATTERN,
} from '../../scripts/check-doc-metrics.mjs';
@@ -554,3 +555,252 @@ describe('scanForUrlDrift', () => {
});
});
});
+
+// QNBS-v3 (audit F-1): a security doc asserting a PR is "the active remediation" or work is
+// "pending"/"in progress on" that PR must say so truthfully — this gate exists because
+// docs/SECURITY-THREAT-MODEL.md said exactly that about PR #356 for weeks after it closed.
+describe('scanSecurityDocPrStatus', () => {
+ it('flags an unqualified "is the active remediation" claim', () => {
+ const content =
+ '| Threat | [PR #356](https://github.com/qnbs/WorldScript-Studio/pull/356) is the active remediation | Loc |';
+ const findings = scanSecurityDocPrStatus(content, 'docs/SECURITY-THREAT-MODEL.md');
+ expect(findings).toHaveLength(1);
+ expect(findings[0]).toContain('docs/SECURITY-THREAT-MODEL.md:1');
+ });
+
+ it('flags an unqualified "pending [PR #NNN]" claim', () => {
+ const content =
+ "remain only as shared crypto plumbing pending [PR #356](https://github.com/qnbs/WorldScript-Studio/pull/356)'s project-data encryption work.";
+ const findings = scanSecurityDocPrStatus(content, 'docs/IDB-ENCRYPTION.md');
+ expect(findings).toHaveLength(1);
+ });
+
+ it('flags an unqualified "in progress on PR #NNN" claim', () => {
+ const content = 'Encryption work is in progress on PR #356.';
+ const findings = scanSecurityDocPrStatus(content, 'docs/IDB-ENCRYPTION.md');
+ expect(findings).toHaveLength(1);
+ });
+
+ it('does not flag the same claim once qualified as closed/superseded on the same line', () => {
+ const content =
+ 'PR #356 is the active remediation for the prior (now inaccurate) history — PR #356 was later closed as superseded by R-15.';
+ const findings = scanSecurityDocPrStatus(content, 'docs/SECURITY-THREAT-MODEL.md');
+ expect(findings).toHaveLength(0);
+ });
+
+ it('does not flag a bare historical PR citation with no live-status verb', () => {
+ const content =
+ 'it remains in fsCore.ts as shared crypto plumbing for other filesystem-encrypted data (see [PR #356](https://github.com/qnbs/WorldScript-Studio/pull/356), closed 2026-08-18 as superseded).';
+ const findings = scanSecurityDocPrStatus(content, 'docs/SECURITY-THREAT-MODEL.md');
+ expect(findings).toHaveLength(0);
+ });
+
+ it('does not flag "pending" prose with no PR-number anchor', () => {
+ const content =
+ 'Full at-rest protection for the desktop filesystem store is pending R-15 implementation.';
+ const findings = scanSecurityDocPrStatus(content, 'docs/IDB-ENCRYPTION.md');
+ expect(findings).toHaveLength(0);
+ });
+
+ // QNBS-v3 (CodeAnt): a naive per-physical-line split let a status claim soft-wrapped across two
+ // Markdown lines within the same paragraph evade detection entirely.
+ it('flags a claim even when Markdown wraps it across two physical lines of one paragraph', () => {
+ const content = [
+ 'Desktop plaintext persistence remains open. [PR #356](https://github.com/qnbs/pull/356)',
+ 'is the active remediation for this gap.',
+ ].join('\n');
+ const findings = scanSecurityDocPrStatus(content, 'docs/SECURITY-THREAT-MODEL.md');
+ expect(findings).toHaveLength(1);
+ expect(findings[0]).toContain('PR #356');
+ });
+
+ // QNBS-v3 (CodeAnt): the qualifier must be tied to the SAME PR number, not merely present
+ // anywhere in the sentence/line — otherwise a different, already-closed PR mentioned nearby
+ // would wrongly suppress a live claim about an unrelated, still-unqualified PR.
+ it('still flags an unqualified claim when a DIFFERENT PR is closed nearby', () => {
+ const content =
+ 'PR #999 is the active remediation for this gap, unlike PR #111 which was already closed.';
+ const findings = scanSecurityDocPrStatus(content, 'docs/SECURITY-THREAT-MODEL.md');
+ expect(findings).toHaveLength(1);
+ expect(findings[0]).toContain('PR #999');
+ });
+
+ // QNBS-v3 (codex): the original three-alternative regex required an exact word order and missed
+ // common natural phrasings — the fix is order-independent (trigger phrase near a PR reference).
+ it.each([
+ 'The active remediation is PR #356 for this gap.',
+ 'PR #356 remains the active remediation for this gap.',
+ 'Work is pending on PR #356 for this gap.',
+ ])('flags the natural-language variant: %s', (content) => {
+ const findings = scanSecurityDocPrStatus(content, 'docs/SECURITY-THREAT-MODEL.md');
+ expect(findings).toHaveLength(1);
+ expect(findings[0]).toContain('PR #356');
+ });
+
+ // QNBS-v3 (codex): consecutive Markdown table rows have no blank line between them — joining
+ // them into one paragraph let a live claim in one row absorb an unrelated row's qualifier.
+ it("does not let one table row's qualifier suppress a different row's unqualified claim", () => {
+ const content = ['| PR #356 was closed |', '| PR #999 is the active remediation |'].join('\n');
+ const findings = scanSecurityDocPrStatus(content, 'docs/SECURITY-THREAT-MODEL.md');
+ expect(findings).toHaveLength(1);
+ expect(findings[0]).toContain('PR #999');
+ });
+
+ // QNBS-v3 (codex): a long markdown-link URL between the PR reference and its qualifier must not
+ // make a genuinely, explicitly qualified claim look unqualified.
+ it('does not flag a claim qualified via a Markdown link with a long URL', () => {
+ const content =
+ '[PR #356](https://github.com/qnbs/WorldScript-Studio/pull/356) is the active remediation, but was closed.';
+ const findings = scanSecurityDocPrStatus(content, 'docs/SECURITY-THREAT-MODEL.md');
+ expect(findings).toHaveLength(0);
+ });
+
+ // QNBS-v3 (codex): a raw character-window qualifier check let a SHORT, unrelated PR's qualifier
+ // suppress a different PR's unqualified claim when both PRs sat close together. Nearest-PR
+ // association (not "any qualifier within the window") is required.
+ it('still flags an unqualified claim when a nearby DIFFERENT PR is closed right next to it', () => {
+ const content = '[PR #999] is the active remediation, unlike [PR #111], closed.';
+ const findings = scanSecurityDocPrStatus(content, 'docs/SECURITY-THREAT-MODEL.md');
+ expect(findings).toHaveLength(1);
+ expect(findings[0]).toContain('PR #999');
+ });
+
+ // QNBS-v3 (codex): consecutive Markdown list items have no blank line between them either —
+ // joining them let an unrelated later item's trigger word attach to an earlier item's PR.
+ it("does not let one list item's wording attach to a different item's PR reference", () => {
+ const content = ['- Historical context: PR #356', '- R-15 implementation is pending'].join(
+ '\n',
+ );
+ const findings = scanSecurityDocPrStatus(content, 'docs/SECURITY-THREAT-MODEL.md');
+ expect(findings).toHaveLength(0);
+ });
+
+ // QNBS-v3 (codex): a semicolon does not end a sentence — splitting on it separated a claim from
+ // its own qualifying clause.
+ it('does not flag a claim whose qualifier follows a semicolon in the same sentence', () => {
+ const content = 'PR #356 is the active remediation; it was later closed as superseded.';
+ const findings = scanSecurityDocPrStatus(content, 'docs/SECURITY-THREAT-MODEL.md');
+ expect(findings).toHaveLength(0);
+ });
+
+ // QNBS-v3 (coderabbit/codex): a list item's own text may soft-wrap across the following
+ // physical line — an immediate push-per-marker-line split it before the trigger was reached.
+ it('flags a live-status claim wrapped across a list item', () => {
+ const content = ['- PR `#356` is', ' the active remediation for this gap'].join('\n');
+ const findings = scanSecurityDocPrStatus(content, 'docs/SECURITY-THREAT-MODEL.md');
+ expect(findings).toHaveLength(1);
+ });
+
+ it('flags a Markdown-link PR reference wrapped across a list item', () => {
+ const content = [
+ '- [PR #999](https://github.com/qnbs/pull/999)',
+ 'is the active remediation',
+ ].join('\n');
+ const findings = scanSecurityDocPrStatus(content, 'docs/SECURITY-THREAT-MODEL.md');
+ expect(findings).toHaveLength(1);
+ });
+
+ // QNBS-v3 (codex): "is not closed" / "will be merged" don't assert a completed status — only an
+ // unnegated, non-prospective qualifier actually proves the PR is done.
+ it('still flags a claim whose only nearby qualifier is negated', () => {
+ const content = 'PR #999 is the active remediation; it is not closed.';
+ const findings = scanSecurityDocPrStatus(content, 'docs/SECURITY-THREAT-MODEL.md');
+ expect(findings).toHaveLength(1);
+ });
+
+ it('still flags a claim whose only nearby qualifier is prospective (future tense)', () => {
+ const content = 'PR #999 is the active remediation and will be merged soon.';
+ const findings = scanSecurityDocPrStatus(content, 'docs/SECURITY-THREAT-MODEL.md');
+ expect(findings).toHaveLength(1);
+ });
+
+ it('does not flag a claim with a genuinely completed (non-negated) qualifier', () => {
+ const content = 'PR #999 is the active remediation; it was later closed.';
+ const findings = scanSecurityDocPrStatus(content, 'docs/SECURITY-THREAT-MODEL.md');
+ expect(findings).toHaveLength(0);
+ });
+
+ // QNBS-v3 (codex): a negated or "no longer" trigger phrase explicitly denies live status — it
+ // isn't a claim at all.
+ it.each([
+ 'PR #356 is not the active remediation for this gap.',
+ 'PR #356 is no longer the active remediation for this gap.',
+ 'Work is not in progress on PR #356 for this gap.',
+ ])('does not flag a negated trigger: %s', (content) => {
+ const findings = scanSecurityDocPrStatus(content, 'docs/SECURITY-THREAT-MODEL.md');
+ expect(findings).toHaveLength(0);
+ });
+
+ // QNBS-v3 (codex): compound negation/auxiliary forms around a qualifier must not be accepted as
+ // proof of a completed status.
+ it.each([
+ 'PR #999 is the active remediation; it has not been closed.',
+ 'PR #999 is the active remediation; it is not yet closed.',
+ 'PR #999 is the active remediation and may be merged eventually.',
+ ])('still flags a claim with compound-negated/prospective qualifier: %s', (content) => {
+ const findings = scanSecurityDocPrStatus(content, 'docs/SECURITY-THREAT-MODEL.md');
+ expect(findings).toHaveLength(1);
+ });
+
+ // QNBS-v3 (codex): an HTML comment or fenced code block is never rendered prose — a literal
+ // example inside one isn't a live assertion about a real PR.
+ it('does not flag trigger wording inside an HTML comment', () => {
+ const content = '\nReal prose here.';
+ const findings = scanSecurityDocPrStatus(content, 'docs/SECURITY-THREAT-MODEL.md');
+ expect(findings).toHaveLength(0);
+ });
+
+ it('does not flag trigger wording inside a fenced code block', () => {
+ const content = ['```', 'PR #999 is the active remediation', '```'].join('\n');
+ const findings = scanSecurityDocPrStatus(content, 'docs/SECURITY-THREAT-MODEL.md');
+ expect(findings).toHaveLength(0);
+ });
+
+ it('still flags real prose surrounding a stripped HTML comment', () => {
+ const content = [
+ '',
+ 'PR #999 is the active remediation for this gap.',
+ ].join('\n');
+ const findings = scanSecurityDocPrStatus(content, 'docs/SECURITY-THREAT-MODEL.md');
+ expect(findings).toHaveLength(1);
+ });
+
+ // QNBS-v3 (coderabbit/codex): a negation word in an EARLIER clause must not suppress a
+ // genuinely live, unqualified claim in a later clause of the same sentence — a real
+ // false-negative that let a stale claim escape the gate entirely.
+ it('flags a claim when negation belongs to an earlier, unrelated clause', () => {
+ const content = 'Desktop project data is not encrypted, so PR #356 is the active remediation.';
+ const findings = scanSecurityDocPrStatus(content, 'docs/SECURITY-THREAT-MODEL.md');
+ expect(findings).toHaveLength(1);
+ });
+
+ it('flags a claim when negation modifies a different word in an earlier clause', () => {
+ const content = 'PR #356 is not complete, but remains the active remediation.';
+ const findings = scanSecurityDocPrStatus(content, 'docs/SECURITY-THREAT-MODEL.md');
+ expect(findings).toHaveLength(1);
+ });
+
+ // QNBS-v3 (codex): the bare "[#NNN](.../pull/NNN)" shorthand is the same convention already
+ // used for issue links (e.g. "[#358](.../issues/358)") in these exact two docs — a stale claim
+ // shouldn't evade the gate merely by using this link style instead of writing "PR #NNN".
+ it('flags a live-status claim using the bare "[#N](.../pull/N)" shorthand', () => {
+ const content =
+ '[#356](https://github.com/qnbs/WorldScript-Studio/pull/356) is the active remediation.';
+ const findings = scanSecurityDocPrStatus(content, 'docs/SECURITY-THREAT-MODEL.md');
+ expect(findings).toHaveLength(1);
+ });
+
+ it('does not treat a bare "[#N](.../issues/N)" shorthand as a PR reference', () => {
+ const content =
+ 'Closing [#358](https://github.com/qnbs/WorldScript-Studio/issues/358) is the active remediation.';
+ const findings = scanSecurityDocPrStatus(content, 'docs/SECURITY-THREAT-MODEL.md');
+ expect(findings).toHaveLength(0);
+ });
+
+ it('does not misattribute a mismatched "[#N](.../pull/M)" shorthand pair', () => {
+ const content =
+ '[#356](https://github.com/qnbs/WorldScript-Studio/pull/999) is the active remediation.';
+ const findings = scanSecurityDocPrStatus(content, 'docs/SECURITY-THREAT-MODEL.md');
+ expect(findings).toHaveLength(0);
+ });
+});