From 21fdbb5960e31e55b14f15a778f093b7a85eb3bb Mon Sep 17 00:00:00 2001 From: qnbs <155236708+qnbs@users.noreply.github.com> Date: Thu, 20 Aug 2026 14:44:23 +0200 Subject: [PATCH 1/5] fix(ci): close Rust dependency and deploy gate gaps --- .github/workflows/ci.yml | 20 ++--- docs/CI.md | 6 +- scripts/check-ci-invariants.mjs | 144 ++++++++++++++++++++++++++++++++ 3 files changed, 153 insertions(+), 17 deletions(-) create mode 100644 scripts/check-ci-invariants.mjs diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index e617a53c2..c3ae503ac 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -2,7 +2,7 @@ # WorldScript Studio – CI/CD Pipeline # security → quality (lint, i18n, tsgo, vitest+coverage) → build (+chunk budget, rollup analyze) # ├→ e2e | storybook (parallel) ; lighthouse after build -# deploy (main): needs build + e2e → GitHub Pages +# deploy (main): needs ci-success + build artifact → GitHub Pages # ============================================================ name: CI / CD @@ -85,7 +85,7 @@ jobs: uses: actions/dependency-review-action@a1d282b36b6f3519aa1f3fc636f609c47dddb294 # v5 # ---------------------------------------------------------- - # 0b. CHANGE DETECTION: path-scopes rust-tauri so a docs/frontend-only PR + # 0b. CHANGE DETECTION: path-scopes Rust gates so a docs/frontend-only PR # doesn't pay for a Rust toolchain + apt-get(libgtk/libwebkit) build it can't # affect. Fails OPEN (tauri=true) on any ambiguity — base SHA missing/unreachable # — so a detection error runs the real gate instead of silently skipping it. @@ -106,6 +106,7 @@ jobs: - name: Detect src-tauri / crates changes id: filter run: | + node scripts/check-ci-invariants.mjs --self-test --check-workflow if [ "${{ github.event_name }}" = "pull_request" ]; then BASE="${{ github.event.pull_request.base.sha }}" else @@ -118,16 +119,7 @@ jobs: exit 0 fi CHANGED=$(git diff --name-only "$BASE" "${{ github.sha }}") - if grep -qE '^(src-tauri/|\.github/workflows/ci\.yml$)' <<< "$CHANGED"; then - echo "tauri=true" >> "$GITHUB_OUTPUT" - else - echo "tauri=false" >> "$GITHUB_OUTPUT" - fi - if grep -qE '^(crates/|tests/fixtures/project-golden-masters/|\.github/workflows/ci\.yml$)' <<< "$CHANGED"; then - echo "crates=true" >> "$GITHUB_OUTPUT" - else - echo "crates=false" >> "$GITHUB_OUTPUT" - fi + printf '%s\n' "$CHANGED" | node scripts/check-ci-invariants.mjs >> "$GITHUB_OUTPUT" # ---------------------------------------------------------- # 1. QUALITY GATE: Lint + Typecheck + Tests (parallel matrix) @@ -454,8 +446,8 @@ jobs: name: 🚀 Deploy to GitHub Pages runs-on: ubuntu-latest timeout-minutes: 10 - needs: [build, e2e] - if: github.ref == 'refs/heads/main' && github.event_name != 'pull_request' + needs: [ci-success, build] + if: github.ref == 'refs/heads/main' && github.event_name != 'pull_request' && needs.ci-success.result == 'success' permissions: contents: read pages: write diff --git a/docs/CI.md b/docs/CI.md index 3296232e4..8fd8b51cb 100644 --- a/docs/CI.md +++ b/docs/CI.md @@ -83,7 +83,7 @@ e2e ──────┤ vrt ──────┘ build (main, non-PR) ──► upload-pages-artifact -deploy (main, non-PR) needs: build + e2e ──► GitHub Pages +ci-success + build artifact (main, non-PR) ──► deploy ──► GitHub Pages ``` Mutation testing (Stryker) is **not** in this graph — it runs only via manual `workflow_dispatch` on [`mutation.yml`](../.github/workflows/mutation.yml). See [Mutation testing status](#mutation-testing-status). @@ -98,8 +98,8 @@ Mutation testing (Stryker) is **not** in this graph — it runs only via manual | `lighthouse` | `build` | LHCI (mobile): **accessibility error gate** `minScore: 0.95`; **CLS error** ≤ 0.1; performance/SEO warn. Desktop run: `continue-on-error: true` until baselines stabilise. Timeout 25 min. | | `storybook` | `quality` | Cloud-first — Storybook build + test-runner only run in CI (not locally); Playwright browser cache `v5`; `--maxWorkers=2 --junit` (non-blocking, `continue-on-error: true` — see [exit criteria](#non-blocking-gates--exit-criteria-f-13)); artifacts uploaded always. Debug: manual `storybook-debug.yml` workflow. | | `vrt` | `build` | Visual regression against production `dist`; `toHaveScreenshot()` with committed PNG baselines (4 views × Chromium); artifacts uploaded always | -| `ci-success` | `security`, `quality`, `rust-tauri`, `build`, `e2e`, `vrt` | Required-status **aggregator** — `if: always()`, fails if any required release-safety job does not resolve to `success`; Storybook, Lighthouse and deep-E2E remain informational until their stability criteria are met. | -| `deploy` | `build`, `e2e` | **Only** `main` push (not PR): `deploy-pages` | +| `ci-success` | `security`, `quality`, `changes`, `rust-tauri`, `core-rust`, `build`, `e2e`, `vrt` | Required-status **aggregator** — `if: always()`, fails if any required release-safety job does not resolve to `success`; Storybook, Lighthouse and deep-E2E remain informational until their stability criteria are met. Rust jobs are legitimately skipped when their paths are untouched. | +| `deploy` | `ci-success`, `build` | **Only** `main` push (not PR), and only after the aggregate gate succeeds; retains the direct `build` dependency for the Pages artifact. | > **Desktop:** On-demand / tag-driven Tauri bundles live in [`tauri-build.yml`](../.github/workflows/tauri-build.yml); **`v*` tags** additionally publish installers on a **GitHub Release**. See [`docs/TAURI-CI.md`](TAURI-CI.md). Desktop CI does not block the web deploy graph above. > diff --git a/scripts/check-ci-invariants.mjs b/scripts/check-ci-invariants.mjs new file mode 100644 index 000000000..75af6953b --- /dev/null +++ b/scripts/check-ci-invariants.mjs @@ -0,0 +1,144 @@ +#!/usr/bin/env node +import { strict as assert } from 'node:assert'; +import { readFileSync } from 'node:fs'; +import { dirname, relative, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '..'); +const TAURI_MANIFEST = resolve(ROOT, 'src-tauri/Cargo.toml'); +const WORKFLOW = resolve(ROOT, '.github/workflows/ci.yml'); +const WORKFLOW_PATH = '.github/workflows/ci.yml'; +const CORE_ROOT = 'crates/'; +const PROJECT_FIXTURES_ROOT = 'tests/fixtures/project-golden-masters/'; + +function normalizePath(filePath) { + return filePath.replaceAll('\\', '/').replace(/^\.\//, ''); +} + +function isWithin(filePath, root) { + return filePath === root.replace(/\/$/, '') || filePath.startsWith(root); +} + +function localTauriDependencyRoots() { + const manifest = readFileSync(TAURI_MANIFEST, 'utf8'); + const pathValues = [...manifest.matchAll(/\bpath\s*=\s*"([^"]+)"/g)].map(([, value]) => value); + const roots = new Set(['src-tauri/']); + + for (const value of pathValues) { + const dependencyPath = normalizePath(relative(ROOT, resolve(dirname(TAURI_MANIFEST), value))); + if (!dependencyPath || dependencyPath.startsWith('../')) { + throw new Error(`Tauri path dependency escapes the repository: ${value}`); + } + roots.add(`${dependencyPath.split('/')[0]}/`); + } + + return [...roots]; +} + +function classifyChangedFiles(files) { + const changedFiles = files.map(normalizePath).filter(Boolean); + const tauriRoots = localTauriDependencyRoots(); + const tauri = changedFiles.some( + (filePath) => filePath === WORKFLOW_PATH || tauriRoots.some((root) => isWithin(filePath, root)), + ); + const crates = changedFiles.some( + (filePath) => + filePath === WORKFLOW_PATH || + isWithin(filePath, CORE_ROOT) || + isWithin(filePath, PROJECT_FIXTURES_ROOT), + ); + + return { tauri, crates }; +} + +function jobBlock(workflow, jobName) { + const lines = workflow.split('\n'); + const start = lines.indexOf(` ${jobName}:`); + if (start === -1) throw new Error(`Could not find CI job: ${jobName}`); + const end = lines.findIndex((line, index) => index > start && /^ {2}[\w-]+:$/.test(line)); + return lines.slice(start, end === -1 ? lines.length : end).join('\n'); +} + +function needsFor(workflow, jobName) { + const block = jobBlock(workflow, jobName); + const match = block.match(/^ {4}needs:\s*\[([^\]]+)\]/m); + if (!match) throw new Error(`CI job ${jobName} must use an inline needs list`); + return new Set( + match[1] + .split(',') + .map((item) => item.trim()) + .filter(Boolean), + ); +} + +function checkWorkflowContract() { + const workflow = readFileSync(WORKFLOW, 'utf8'); + const changes = jobBlock(workflow, 'changes'); + const aggregateNeeds = needsFor(workflow, 'ci-success'); + const deployNeeds = needsFor(workflow, 'deploy'); + + for (const requiredJob of [ + 'security', + 'quality', + 'changes', + 'rust-tauri', + 'core-rust', + 'build', + 'e2e', + 'vrt', + ]) { + assert(aggregateNeeds.has(requiredJob), `ci-success must include ${requiredJob}`); + } + assert(deployNeeds.has('ci-success'), 'deploy must depend on ci-success'); + assert(deployNeeds.has('build'), 'deploy must retain the build artifact dependency'); + assert.match( + jobBlock(workflow, 'deploy'), + /needs\.ci-success\.result\s*==\s*'success'/, + 'deploy must require a successful ci-success result', + ); + assert.match( + changes, + /node scripts\/check-ci-invariants\.mjs --self-test --check-workflow/, + 'changes must execute the CI invariant self-check', + ); + assert.match( + changes, + /node scripts\/check-ci-invariants\.mjs\s*>>\s*"\$GITHUB_OUTPUT"/, + 'changes must use the dependency-aware classifier for job outputs', + ); +} + +function runSelfTests() { + const cases = [ + [['src-tauri/src/lib.rs'], { tauri: true, crates: false }], + [['crates/worldscript-project/src/lib.rs'], { tauri: true, crates: true }], + [['crates/worldscript-project/Cargo.toml'], { tauri: true, crates: true }], + [['crates/Cargo.lock'], { tauri: true, crates: true }], + [[WORKFLOW_PATH], { tauri: true, crates: true }], + [['components/App.tsx'], { tauri: false, crates: false }], + ]; + + for (const [files, expected] of cases) { + assert.deepEqual(classifyChangedFiles(files), expected, `classification failed for ${files}`); + } +} + +function main() { + const args = new Set(process.argv.slice(2)); + if (args.has('--self-test')) runSelfTests(); + if (args.has('--check-workflow')) checkWorkflowContract(); + if (args.has('--self-test') || args.has('--check-workflow')) return; + + const files = readFileSync(0, 'utf8').split(/\r?\n/); + const result = classifyChangedFiles(files); + process.stdout.write(`tauri=${result.tauri}\ncrates=${result.crates}\n`); +} + +try { + main(); +} catch (error) { + process.stderr.write( + `[ci-invariants] ${error instanceof Error ? error.message : String(error)}\n`, + ); + process.exitCode = 1; +} From 77cf15cbd58577c559069ed6c8f0cc62fbb67dcf Mon Sep 17 00:00:00 2001 From: qnbs <155236708+qnbs@users.noreply.github.com> Date: Thu, 20 Aug 2026 14:58:04 +0200 Subject: [PATCH 2/5] test(ci): cover workflow policy invariants Replace the first runtime classifier self-check with the repository's existing node-environment Vitest policy-test pattern, which keeps the regression proof inside the quality gate without adding a YAML or TOML dependency. Use the verified same-run Pages artifact behavior to make deploy depend only on ci-success. This corrects the initial implementation shape while preserving the fail-open path classifier and aggregate authority intent. --- .github/workflows/ci.yml | 15 ++- CHANGELOG.md | 4 + docs/CI.md | 4 +- scripts/check-ci-invariants.mjs | 144 --------------------------- tests/unit/workflowPolicy.test.ts | 66 ++++++++++++ tests/utils/workflowPolicyParsers.ts | 58 +++++++++++ 6 files changed, 142 insertions(+), 149 deletions(-) delete mode 100644 scripts/check-ci-invariants.mjs create mode 100644 tests/unit/workflowPolicy.test.ts create mode 100644 tests/utils/workflowPolicyParsers.ts diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index c3ae503ac..dcab2580a 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -2,7 +2,7 @@ # WorldScript Studio – CI/CD Pipeline # security → quality (lint, i18n, tsgo, vitest+coverage) → build (+chunk budget, rollup analyze) # ├→ e2e | storybook (parallel) ; lighthouse after build -# deploy (main): needs ci-success + build artifact → GitHub Pages +# deploy (main): needs ci-success → GitHub Pages # ============================================================ name: CI / CD @@ -119,7 +119,16 @@ jobs: exit 0 fi CHANGED=$(git diff --name-only "$BASE" "${{ github.sha }}") - printf '%s\n' "$CHANGED" | node scripts/check-ci-invariants.mjs >> "$GITHUB_OUTPUT" + if grep -qE '^(src-tauri/|crates/|\.github/workflows/ci\.yml$)' <<< "$CHANGED"; then + echo "tauri=true" >> "$GITHUB_OUTPUT" + else + echo "tauri=false" >> "$GITHUB_OUTPUT" + fi + if grep -qE '^(crates/|tests/fixtures/project-golden-masters/|\.github/workflows/ci\.yml$)' <<< "$CHANGED"; then + echo "crates=true" >> "$GITHUB_OUTPUT" + else + echo "crates=false" >> "$GITHUB_OUTPUT" + fi # ---------------------------------------------------------- # 1. QUALITY GATE: Lint + Typecheck + Tests (parallel matrix) @@ -446,7 +455,7 @@ jobs: name: 🚀 Deploy to GitHub Pages runs-on: ubuntu-latest timeout-minutes: 10 - needs: [ci-success, build] + needs: [ci-success] if: github.ref == 'refs/heads/main' && github.event_name != 'pull_request' && needs.ci-success.result == 'success' permissions: contents: read diff --git a/CHANGELOG.md b/CHANGELOG.md index 4962b05ee..a0c97229b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Fixed + +- **CI authority closure:** Core path changes now select the Tauri consumer gate, workflow-policy tests protect local path-dependency coverage and aggregate deployment gating, and Pages deployment waits for `ci-success`. + ## [1.27.1] — 2026-08-14 > Desktop persistence/security stabilization (#363) — atomic writes and fail-closed key routing diff --git a/docs/CI.md b/docs/CI.md index 8fd8b51cb..f6b001e7c 100644 --- a/docs/CI.md +++ b/docs/CI.md @@ -83,7 +83,7 @@ e2e ──────┤ vrt ──────┘ build (main, non-PR) ──► upload-pages-artifact -ci-success + build artifact (main, non-PR) ──► deploy ──► GitHub Pages +ci-success (main, non-PR) ──► deploy ──► GitHub Pages ``` Mutation testing (Stryker) is **not** in this graph — it runs only via manual `workflow_dispatch` on [`mutation.yml`](../.github/workflows/mutation.yml). See [Mutation testing status](#mutation-testing-status). @@ -99,7 +99,7 @@ Mutation testing (Stryker) is **not** in this graph — it runs only via manual | `storybook` | `quality` | Cloud-first — Storybook build + test-runner only run in CI (not locally); Playwright browser cache `v5`; `--maxWorkers=2 --junit` (non-blocking, `continue-on-error: true` — see [exit criteria](#non-blocking-gates--exit-criteria-f-13)); artifacts uploaded always. Debug: manual `storybook-debug.yml` workflow. | | `vrt` | `build` | Visual regression against production `dist`; `toHaveScreenshot()` with committed PNG baselines (4 views × Chromium); artifacts uploaded always | | `ci-success` | `security`, `quality`, `changes`, `rust-tauri`, `core-rust`, `build`, `e2e`, `vrt` | Required-status **aggregator** — `if: always()`, fails if any required release-safety job does not resolve to `success`; Storybook, Lighthouse and deep-E2E remain informational until their stability criteria are met. Rust jobs are legitimately skipped when their paths are untouched. | -| `deploy` | `ci-success`, `build` | **Only** `main` push (not PR), and only after the aggregate gate succeeds; retains the direct `build` dependency for the Pages artifact. | +| `deploy` | `ci-success` | **Only** `main` push (not PR), and only after the aggregate gate succeeds; the Pages artifact is resolved from the same workflow run. | > **Desktop:** On-demand / tag-driven Tauri bundles live in [`tauri-build.yml`](../.github/workflows/tauri-build.yml); **`v*` tags** additionally publish installers on a **GitHub Release**. See [`docs/TAURI-CI.md`](TAURI-CI.md). Desktop CI does not block the web deploy graph above. > diff --git a/scripts/check-ci-invariants.mjs b/scripts/check-ci-invariants.mjs deleted file mode 100644 index 75af6953b..000000000 --- a/scripts/check-ci-invariants.mjs +++ /dev/null @@ -1,144 +0,0 @@ -#!/usr/bin/env node -import { strict as assert } from 'node:assert'; -import { readFileSync } from 'node:fs'; -import { dirname, relative, resolve } from 'node:path'; -import { fileURLToPath } from 'node:url'; - -const ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '..'); -const TAURI_MANIFEST = resolve(ROOT, 'src-tauri/Cargo.toml'); -const WORKFLOW = resolve(ROOT, '.github/workflows/ci.yml'); -const WORKFLOW_PATH = '.github/workflows/ci.yml'; -const CORE_ROOT = 'crates/'; -const PROJECT_FIXTURES_ROOT = 'tests/fixtures/project-golden-masters/'; - -function normalizePath(filePath) { - return filePath.replaceAll('\\', '/').replace(/^\.\//, ''); -} - -function isWithin(filePath, root) { - return filePath === root.replace(/\/$/, '') || filePath.startsWith(root); -} - -function localTauriDependencyRoots() { - const manifest = readFileSync(TAURI_MANIFEST, 'utf8'); - const pathValues = [...manifest.matchAll(/\bpath\s*=\s*"([^"]+)"/g)].map(([, value]) => value); - const roots = new Set(['src-tauri/']); - - for (const value of pathValues) { - const dependencyPath = normalizePath(relative(ROOT, resolve(dirname(TAURI_MANIFEST), value))); - if (!dependencyPath || dependencyPath.startsWith('../')) { - throw new Error(`Tauri path dependency escapes the repository: ${value}`); - } - roots.add(`${dependencyPath.split('/')[0]}/`); - } - - return [...roots]; -} - -function classifyChangedFiles(files) { - const changedFiles = files.map(normalizePath).filter(Boolean); - const tauriRoots = localTauriDependencyRoots(); - const tauri = changedFiles.some( - (filePath) => filePath === WORKFLOW_PATH || tauriRoots.some((root) => isWithin(filePath, root)), - ); - const crates = changedFiles.some( - (filePath) => - filePath === WORKFLOW_PATH || - isWithin(filePath, CORE_ROOT) || - isWithin(filePath, PROJECT_FIXTURES_ROOT), - ); - - return { tauri, crates }; -} - -function jobBlock(workflow, jobName) { - const lines = workflow.split('\n'); - const start = lines.indexOf(` ${jobName}:`); - if (start === -1) throw new Error(`Could not find CI job: ${jobName}`); - const end = lines.findIndex((line, index) => index > start && /^ {2}[\w-]+:$/.test(line)); - return lines.slice(start, end === -1 ? lines.length : end).join('\n'); -} - -function needsFor(workflow, jobName) { - const block = jobBlock(workflow, jobName); - const match = block.match(/^ {4}needs:\s*\[([^\]]+)\]/m); - if (!match) throw new Error(`CI job ${jobName} must use an inline needs list`); - return new Set( - match[1] - .split(',') - .map((item) => item.trim()) - .filter(Boolean), - ); -} - -function checkWorkflowContract() { - const workflow = readFileSync(WORKFLOW, 'utf8'); - const changes = jobBlock(workflow, 'changes'); - const aggregateNeeds = needsFor(workflow, 'ci-success'); - const deployNeeds = needsFor(workflow, 'deploy'); - - for (const requiredJob of [ - 'security', - 'quality', - 'changes', - 'rust-tauri', - 'core-rust', - 'build', - 'e2e', - 'vrt', - ]) { - assert(aggregateNeeds.has(requiredJob), `ci-success must include ${requiredJob}`); - } - assert(deployNeeds.has('ci-success'), 'deploy must depend on ci-success'); - assert(deployNeeds.has('build'), 'deploy must retain the build artifact dependency'); - assert.match( - jobBlock(workflow, 'deploy'), - /needs\.ci-success\.result\s*==\s*'success'/, - 'deploy must require a successful ci-success result', - ); - assert.match( - changes, - /node scripts\/check-ci-invariants\.mjs --self-test --check-workflow/, - 'changes must execute the CI invariant self-check', - ); - assert.match( - changes, - /node scripts\/check-ci-invariants\.mjs\s*>>\s*"\$GITHUB_OUTPUT"/, - 'changes must use the dependency-aware classifier for job outputs', - ); -} - -function runSelfTests() { - const cases = [ - [['src-tauri/src/lib.rs'], { tauri: true, crates: false }], - [['crates/worldscript-project/src/lib.rs'], { tauri: true, crates: true }], - [['crates/worldscript-project/Cargo.toml'], { tauri: true, crates: true }], - [['crates/Cargo.lock'], { tauri: true, crates: true }], - [[WORKFLOW_PATH], { tauri: true, crates: true }], - [['components/App.tsx'], { tauri: false, crates: false }], - ]; - - for (const [files, expected] of cases) { - assert.deepEqual(classifyChangedFiles(files), expected, `classification failed for ${files}`); - } -} - -function main() { - const args = new Set(process.argv.slice(2)); - if (args.has('--self-test')) runSelfTests(); - if (args.has('--check-workflow')) checkWorkflowContract(); - if (args.has('--self-test') || args.has('--check-workflow')) return; - - const files = readFileSync(0, 'utf8').split(/\r?\n/); - const result = classifyChangedFiles(files); - process.stdout.write(`tauri=${result.tauri}\ncrates=${result.crates}\n`); -} - -try { - main(); -} catch (error) { - process.stderr.write( - `[ci-invariants] ${error instanceof Error ? error.message : String(error)}\n`, - ); - process.exitCode = 1; -} diff --git a/tests/unit/workflowPolicy.test.ts b/tests/unit/workflowPolicy.test.ts new file mode 100644 index 000000000..434ae71db --- /dev/null +++ b/tests/unit/workflowPolicy.test.ts @@ -0,0 +1,66 @@ +// @vitest-environment node +import { readFileSync } from 'node:fs'; +import { fileURLToPath } from 'node:url'; +import { describe, expect, it } from 'vitest'; +import { + extractJobNames, + extractLocalPathDependencies, + extractNeeds, + extractRustClassifiers, + resolveDependencyPrefix, +} from '../utils/workflowPolicyParsers'; + +const repositoryRoot = fileURLToPath(new URL('../../', import.meta.url)); +const workflowPath = fileURLToPath(new URL('../../.github/workflows/ci.yml', import.meta.url)); +const tauriManifestPath = fileURLToPath(new URL('../../src-tauri/Cargo.toml', import.meta.url)); +const workflowSource = readFileSync(workflowPath, 'utf8'); +const tauriManifestSource = readFileSync(tauriManifestPath, 'utf8'); + +// QNBS-v3: Keep CI path and deployment authority policy executable against the real workflow files. +describe('CI workflow policy', () => { + it('covers every local Tauri path dependency with the Tauri classifier', () => { + const { tauri } = extractRustClassifiers(workflowSource); + for (const dependencyPath of extractLocalPathDependencies(tauriManifestSource)) { + const prefix = resolveDependencyPrefix(dependencyPath, tauriManifestPath, repositoryRoot); + expect( + tauri.test(`${prefix}__local_dependency_probe__`), + `Tauri path dependency ${dependencyPath} resolves to ${prefix}, but ${workflowPath} does not classify that prefix`, + ).toBe(true); + } + }); + + it('classifies the representative changed-file sets correctly', () => { + const { tauri, crates } = extractRustClassifiers(workflowSource); + const cases = [ + ['src-tauri/src/lib.rs', true, false], + ['crates/worldscript-project/src/validate.rs', true, true], + ['crates/worldscript-project/Cargo.toml', true, true], + ['crates/Cargo.lock', true, true], + ['.github/workflows/ci.yml', true, true], + ['components/App.tsx', false, false], + ] as const; + + for (const [filePath, expectedTauri, expectedCrates] of cases) { + expect(tauri.test(filePath), `${filePath} Tauri classification`).toBe(expectedTauri); + expect(crates.test(filePath), `${filePath} Core classification`).toBe(expectedCrates); + } + }); + + it('keeps deployment transitively downstream of ci-success', () => { + const needsByJob = new Map( + extractJobNames(workflowSource).map((jobName) => [ + jobName, + extractNeeds(workflowSource, jobName), + ]), + ); + const visited = new Set(); + const visit = (jobName: string): void => { + if (visited.has(jobName)) return; + visited.add(jobName); + for (const dependency of needsByJob.get(jobName) ?? []) visit(dependency); + }; + + visit('deploy'); + expect(visited).toContain('ci-success'); + }); +}); diff --git a/tests/utils/workflowPolicyParsers.ts b/tests/utils/workflowPolicyParsers.ts new file mode 100644 index 000000000..4b659f90a --- /dev/null +++ b/tests/utils/workflowPolicyParsers.ts @@ -0,0 +1,58 @@ +import { dirname, relative, resolve } from 'node:path'; + +function extractJobBlock(workflowSource: string, jobName: string): string { + const lines = workflowSource.split('\n'); + const start = lines.indexOf(` ${jobName}:`); + if (start === -1) throw new Error(`Could not find CI job: ${jobName}`); + const end = lines.findIndex((line, index) => index > start && /^ {2}[\w-]+:$/.test(line)); + return lines.slice(start, end === -1 ? lines.length : end).join('\n'); +} + +export function extractRustClassifiers(workflowSource: string): { + tauri: RegExp; + crates: RegExp; +} { + const changesJob = extractJobBlock(workflowSource, 'changes'); + const matches = [...changesJob.matchAll(/grep -qE '([^']+)' <<< "\$CHANGED"/g)]; + if (matches.length < 2 || matches[0]?.[1] === undefined || matches[1]?.[1] === undefined) { + throw new Error('The changes job must expose Tauri and Core path classifiers'); + } + return { tauri: new RegExp(matches[0][1]), crates: new RegExp(matches[1][1]) }; +} + +export function extractNeeds(workflowSource: string, jobName: string): string[] { + const match = extractJobBlock(workflowSource, jobName).match(/^ {4}needs:\s*\[([^\]]+)\]/m); + if (!match?.[1]) return []; + return match[1] + .split(',') + .map((job) => job.trim()) + .filter(Boolean); +} + +export function extractJobNames(workflowSource: string): string[] { + const jobsSection = workflowSource.slice(workflowSource.indexOf('\njobs:\n')); + return [...jobsSection.matchAll(/^ {2}([\w-]+):$/gm)] + .map(([, name]) => name) + .filter((name): name is string => name !== undefined); +} + +export function extractLocalPathDependencies(cargoSource: string): string[] { + return [...cargoSource.matchAll(/\bpath\s*=\s*"([^"]+)"/g)] + .map(([, pathValue]) => pathValue) + .filter((pathValue): pathValue is string => pathValue !== undefined); +} + +export function resolveDependencyPrefix( + dependencyPath: string, + manifestPath: string, + repositoryRoot: string, +): string { + const resolvedPath = relative( + repositoryRoot, + resolve(dirname(manifestPath), dependencyPath), + ).replaceAll('\\', '/'); + if (!resolvedPath || resolvedPath.startsWith('../')) { + throw new Error(`Tauri path dependency escapes the repository: ${dependencyPath}`); + } + return `${resolvedPath.split('/')[0]}/`; +} From d65ec4e3e75b88099ca39b55ed535cf15f77c629 Mon Sep 17 00:00:00 2001 From: qnbs <155236708+qnbs@users.noreply.github.com> Date: Thu, 20 Aug 2026 15:00:28 +0200 Subject: [PATCH 3/5] docs(ci): complete aggregate dependency graph --- docs/CI.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/docs/CI.md b/docs/CI.md index f6b001e7c..464662d70 100644 --- a/docs/CI.md +++ b/docs/CI.md @@ -77,7 +77,9 @@ security ──► quality ──┬──► build ──┬──► lighthous security ─┬ quality ──┼──► ci-success (required-status aggregator) +changes ──┤ rust ────┤ +core-rust ┤ build ────┤ e2e ──────┤ vrt ──────┘ From 18859cbdf58111486c5bfb72a0368dec67c0e21f Mon Sep 17 00:00:00 2001 From: qnbs <155236708+qnbs@users.noreply.github.com> Date: Thu, 20 Aug 2026 15:05:02 +0200 Subject: [PATCH 4/5] fix(ci): remove stale classifier check --- .github/workflows/ci.yml | 1 - 1 file changed, 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index dcab2580a..2dc348b98 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -106,7 +106,6 @@ jobs: - name: Detect src-tauri / crates changes id: filter run: | - node scripts/check-ci-invariants.mjs --self-test --check-workflow if [ "${{ github.event_name }}" = "pull_request" ]; then BASE="${{ github.event.pull_request.base.sha }}" else From 4b02b152baba2669fda930df8b2abcc9057b55db Mon Sep 17 00:00:00 2001 From: qnbs <155236708+qnbs@users.noreply.github.com> Date: Thu, 20 Aug 2026 15:05:20 +0200 Subject: [PATCH 5/5] docs: persist agent guidance --- AGENTS.md | 42 +++++++++++++++++++++++++++++++++++------- CLAUDE.md | 28 ++++++++++++++++++++++------ README.md | 15 +++++++++------ 3 files changed, 66 insertions(+), 19 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index bd657369f..a9048254f 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -16,6 +16,8 @@ The app supports a multi-provider AI stack (Gemini, OpenAI, Claude, Grok, OpenRouter, Ollama, WebLLM, ONNX Runtime Web, Transformers.js), four AI execution modes (Hybrid / Cloud / Local / Eco), real-time collaboration with E2E encryption, a Plot Board v2 with swimlane/canvas/timeline modes, character/world management, manuscript export, voice dictation, and a 19-locale i18n layer. +**Native desktop strategy is changing (ADR-0021, 2026-08-20) — read before touching desktop/native code.** CEF is retired from the target architecture. Current direction: React/PWA stays the web product → Tauri 2 is **transitional only** (itself retired once Qt reaches Stable) → an authoritative Rust Core (`crates/`, an independent Cargo workspace from `src-tauri/`) → Qt 6/Qt Quick as the primary native product → GPUI admitted later, behind a strict gate. Full plan: `docs/native/ROADMAP-QT-GPUI-DESKTOP.md`; decision record: `docs/adr/0021-qt-gpui-native-desktop-strategy.md`. Extraction has started: `crates/worldscript-project` (renderer-neutral project schema/validation/migration, headless — no GUI deps) is wired to one real Tauri command (`worldscript_project_validate` in `src-tauri/src/commands/project_core.rs`) via a cross-workspace Cargo path dependency, with no frontend caller yet. Priority order for what gets extracted next: `docs/native/CORE-MIGRATION-LEDGER.md`. + --- ## ⚠️ Critical Execution Environment Warning (Agent Must Follow) @@ -31,7 +33,8 @@ The app supports a multi-provider AI stack (Gemini, OpenAI, Claude, Grok, OpenRo ```bash pnpm run lint && pnpm run typecheck && pnpm run i18n:check ``` - Optional: `pnpm exec vitest run` **without** `--coverage` for a fast smoke test. + Optional targeted smoke test: `pnpm exec vitest run ` **without** `--coverage`. + **Hard rule:** Never invoke `pnpm test`, `npm run test`, or a bare Vitest wrapper; always use an explicit `pnpm exec vitest run ` command to avoid watch-mode hangs on constrained hardware. 4. **Audit cloud CI logs, fix locally, then re-push** – If the cloud CI run fails, inspect the logs via GitHub web UI or `gh run watch`, reproduce the specific failing test or lint error in isolation, fix it locally (quick tier to verify), commit, and push again for another cloud CI run. 5. **Sequential execution** – Do not parallelize builds, tests, or processes locally. Use single-threaded modes and avoid background tasks that compete for RAM/CPU. 6. **Resource budget** – Avoid spinning up the dev server (`pnpm run dev`) for extended periods if not needed. Prefer one-off commands (`pnpm run build`, `pnpm run typecheck`) and stop the server when done. @@ -170,9 +173,8 @@ pnpm run parity:check # Feature parity audit pnpm run suppressions:check # Biome-ignore count ratchet # Testing -pnpm run test # Vitest watch mode -pnpm run test:run # Vitest single run (no coverage) -pnpm run test:coverage # Vitest with V8 coverage (enforces thresholds) +pnpm exec vitest run # Targeted Vitest single run (no coverage) +pnpm exec vitest run --coverage # Targeted Vitest run with V8 coverage pnpm run test:e2e # Playwright E2E (CI=true required; CI-only by policy) pnpm run test:e2e:ui # Playwright E2E UI mode (CI=true required) pnpm run test:e2e:deep # Deep E2E feature-flag matrix (CI=true required) @@ -268,7 +270,8 @@ files via `simple-git-hooks` + `lint-staged`; CI remains mandatory when hooks ar ### Philosophy - **Cloud CI-first:** The canonical quality gate is GitHub Actions. Low-end local machines should run only the "Quick" tier. -- **Quick tier (local, before every push):** `pnpm run lint && pnpm run typecheck && pnpm run i18n:check`. Optionally: `pnpm exec vitest run` **without** `--coverage`. +- **Quick tier (local, before every push):** `pnpm run lint && pnpm run typecheck && pnpm run i18n:check`. Optionally: `pnpm exec vitest run ` **without** `--coverage`. +- **Vitest watch-mode hard rule:** Never invoke `pnpm test`, `npm run test`, or a bare Vitest wrapper; use an explicit targeted `pnpm exec vitest run ` command so constrained hardware never waits on watch mode. - **Heavy tier (CI):** Vitest with coverage thresholds, Playwright E2E (desktop + mobile emulation), Lighthouse CI, Stryker mutation, Storybook static build, bundle budget + analyze. ### Unit Tests (Vitest) @@ -326,8 +329,10 @@ deploy (main, non-PR) needs: build + e2e ──► GitHub Pages | Job | Purpose | |-----|---------| -| `security` | `pnpm audit --audit-level=high`, OSV scanner (pnpm + Cargo lockfiles), gitleaks secrets scan, dependency review on PRs | +| `security` | `pnpm audit --audit-level=high`, OSV scanner (pnpm + `src-tauri/` + `crates/` Cargo lockfiles), gitleaks secrets scan, dependency review on PRs | | `quality` | Node 22 + 24 matrix → Biome lint, suppression-debt ratchet, `i18n:check`, `parity:check`, `tsgo --noEmit`, Storybook build, Vitest + coverage, Codecov upload | +| `rust-tauri` | `fmt`/`check`/`clippy`/`test` for `src-tauri/`; path-scoped (skips on PRs that don't touch it), needs GTK/WebKit apt-get steps | +| `core-rust` | Same `fmt`/`check`/`clippy`/`test` for `crates/worldscript-project` (renderer-neutral Rust Core); path-scoped, no GUI deps so no apt-get steps needed | | `build` | Production build, smoke-test prod build in Chromium, bundle budget, rollup analyze artifact; on `main`: SLSA build provenance attestation + Pages artifact | | `e2e` | Playwright Chromium desktop + mobile emulation (`CI=true`); JUnit artifact for PR annotations | | `e2e-deep` | Feature-flag matrix + error paths; non-blocking (`continue-on-error: true`) | @@ -356,6 +361,24 @@ Edge builds run `scripts/build-edge.mjs` which sets `DEPLOY_TARGET=edge` and pat --- +## PR Review & Merge Discipline + +Never commit directly to `main` — always a feature branch + PR, even for a single-file edit. Wait for the **full CI suite to go green, including non-required/advisory jobs** (E2E, E2E Deep Coverage, Storybook, Lighthouse, Visual Regression), not just the branch-protection-required checks. Any `FAILURE` status — required or advisory — is zero-tolerance: investigate the actual root cause (pull the coverage report / job log) before deciding how to proceed; never assume a failing check is "probably fine" because your own latest commit looked unrelated — e.g. `codecov/patch` evaluates the PR's **entire accumulated diff**, not just your last commit. + +**Review-comment completeness — check three independent channels before declaring a PR review-clean, every time:** (1) GraphQL `reviewThreads` for inline per-line comments; (2) `gh api repos///issues//comments` for plain top-level bot comments (qodo-code-review posts its real findings only here, never as `reviewThreads`); (3) `gh api repos///pulls//reviews`, reading each review's full `.body` text (CodeRabbit's "🧹 Nitpick comments" and outside-diff-range findings live here, collapsed, invisible to the other two channels). A bot using one channel on a PR doesn't mean the others are covered. + +**Known review bots on this repo** (confirm still installed — this list can drift): CodeRabbit (`@coderabbitai review` to re-trigger), CodeAnt AI (5 CI status checks only — `CodeAnt - Quality Gates/SAST/SCA/SCR/Test Coverage` — not inline PR comments here), qodo-code-review (top-level comments, see above), Amazon Q Developer (`/q review` as a fresh top-level comment — not inside an existing thread; quota-conscious — call it once CodeRabbit/CodeAnt's own loop has already reached quiescence, not after every fix commit), Graphite AI Reviews (automatic, no confirmed manual trigger), chatgpt-codex-connector (intermittent/quota-limited availability — verify it's currently active rather than assuming silence means "nothing to report"). A bot's silence is not a clean pass by itself — for security/sandbox/IPC/FFI/packaging-adjacent PRs, verify at least one bot produced real review output (its actual comment/review text), not just a green check-run. + +**PR size:** keep every PR's changed-file count under ~100 — several review bots skip inline comments above that threshold. Check with `git diff --name-only ...HEAD | wc -l` before pushing; split into the fewest stacked PRs that stay under the limit if needed. + +**Known GitHub merge-gate quirks on this repo:** +- **Mergeable-state cache lag:** `gh pr merge` can fail with "base branch policy prohibits the merge" even after every check (required and advisory) shows concluded `success`, `mergeable: MERGEABLE`, and 0 unresolved review threads. Re-poll a few times (~60s) before concluding it's stuck. +- **Zombie `QUEUED` check-suites:** several installed GitHub Apps (Renovate, Cursor, Claude, Greptile, CodeAnt AI, Cloudflare Pages, coderabbitai, Codecov, Amazon Q Developer) can leave a check-suite stuck at `status: QUEUED` on a PR that never actually triggers their logic — invisible via `gh pr checks` (named checks look fine), only visible via GraphQL `commits(last:1){nodes{commit{checkSuites(first:20){nodes{app{name} status}}}}}`. +- **Stacked-PR auto-close on squash-merge + `--delete-branch`:** can auto-*close* (not retarget) a downstream PR based on the deleted branch. Recovery: temporarily restore the ref (`git push origin :refs/heads/`), `gh pr reopen`, `gh pr edit --base main`, delete the temp ref once nothing else depends on it. The reopened branch's history still contains the original un-squashed commits, so a plain `git rebase origin/main` re-conflicts even though the diff is clean — rebase only what's after the merged base's old tip: `git rebase --onto origin/main `. +- Neither quirk is authorization to bypass branch protection casually — `--admin` requires a maintainer's fresh, explicit go-ahead for that specific merge. + +--- + ## Security Considerations - **No build-time secrets.** API keys are entered via Settings UI and stored encrypted in IndexedDB (AES-256-GCM via Web Crypto API). Do not put AI keys in `.env` or host environment variables for inference. @@ -367,7 +390,7 @@ Edge builds run `scripts/build-edge.mjs` which sets `DEPLOY_TARGET=edge` and pat - **Service Worker:** AI hosts are network-only (`public/sw.js`). WASM/ONNX chunks are excluded from precache. - **Supply-chain:** SHA-pinned GitHub Actions, Dependabot weekly updates, OpenSSF Scorecard, CodeQL SAST, SLSA build provenance on `main`. - **Collaboration:** Yjs + `packages/collab-transport` (vendor fork of y-webrtc 10.3.0) with AES-256-GCM E2E encryption baked in (PBKDF2, 600k iterations, `extractable: false`). Signaling URLs are user-configurable. -- **Tauri isolation:** `vite.config.ts` externalizes `/^@tauri-apps//` so web builds never bundle Tauri APIs. Abstract Tauri calls through `services/tauriRuntime.ts`. +- **Tauri isolation:** `vite.config.ts` externalizes `/^@tauri-apps\//` so web builds never bundle Tauri APIs. Abstract Tauri calls through `services/desktopPlatform.ts` (the `DesktopPlatform` interface from `packages/desktop-contracts`) — new code must not import `@tauri-apps/*` directly outside `components/ui/`; enforced by `pnpm run guardrail:desktop-imports`. - **IDB at-rest encryption:** Optional feature (`featureFlags.enableIdbAtRestEncryption`) encrypts all project data, snapshots, and settings with AES-256-GCM + PBKDF2-derived key (600k iterations, SHA-256, 32-byte random salt). Web build uses passphrase unlock screen; Tauri build uses OS keychain via `tauri-plugin-stronghold`. - **Encrypted library backup:** One-click encrypted ZIP export from Settings → Data; `vault.bin` encrypted with AES-256-GCM, passphrase-derived key via PBKDF2. - **Vulnerability reporting:** GitHub Private Vulnerability Reporting preferred. 90-day coordinated disclosure embargo. @@ -525,6 +548,11 @@ Central orchestration layer for all background worker tasks — since ADR-0015, | `CONTRIBUTING.md` | Dev setup, Biome/Vitest/Playwright, architecture notes | | `CHANGELOG.md` | Keep a Changelog–style release notes | | `docs/CI.md` | GitHub Actions jobs, Node/pnpm parity, Act examples | +| `docs/CODEANT-REVIEW-LOOP.md` | Canonical PR review-correction loop procedure (any bot) | +| `docs/DEPENDABOT-TRIAGE.md` | Dependabot PR triage policy — ecosystem/grouping config, why there's no auto-merge, merge sequencing | +| `docs/adr/` | Architecture Decision Records, incl. ADR-0021 (Qt/GPUI native desktop strategy) | +| `docs/native/ROADMAP-QT-GPUI-DESKTOP.md` | Qt 6 + GPUI native desktop roadmap — 24-wave plan superseding Tauri, CEF retired | +| `docs/native/CORE-MIGRATION-LEDGER.md` | Rust Core extraction priority order (what's moved out of TS vs. deferred) | | `docs/DEPLOYMENT.md` | GitHub Pages + Vercel + Cloudflare Pages | | `docs/ACCESSIBILITY.md` | A11y architecture (live regions, focus, WCAG 2.2, Lighthouse 0.95 gate) | | `docs/BEST-PRACTICES.md` | Engineering + content guidelines, glossary, CI parity checklist | diff --git a/CLAUDE.md b/CLAUDE.md index 791f38002..c2f9b0773 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -17,9 +17,8 @@ pnpm run smoke:prod # Headless mount check on dist/ (run AFTER build; catches pnpm run lint # Biome lint (--error-on-warnings — warnings fail like CI) pnpm run lint:fix # Biome auto-fix (lint + format) pnpm run typecheck # TypeScript type check — EXACT CI command (tsgo --project tsconfig.tsgo.json --noEmit --checkers 4). typecheck:single = lighter single-checker (may miss errors the gate catches; do not trust for the gate) -pnpm run test # Vitest watch mode -pnpm run test:run # Vitest single run (CI mode) -pnpm run test:coverage # Vitest with V8 coverage (thresholds: lines 74%, branches 60%, functions 67%, statements 72%) +pnpm exec vitest run # Targeted Vitest single run (CI mode) +pnpm exec vitest run --coverage # Targeted Vitest coverage run pnpm run bench # Vitest perf benchmarks (tests/bench) — baseline gate for the Y.Doc-as-SoT / Local-First migration pnpm run content:guard # Validate community templates for secrets / eval payloads pnpm run i18n:check # Locale key parity + bundle rebuild (runs in CI quality job) @@ -38,9 +37,11 @@ pnpm run token:audit # audit-tokens.mjs — design-token usage gate (CI b **Run a single test file:** `pnpm exec vitest run tests/unit/serviceName.test.ts` **Run tests matching a name pattern:** `pnpm exec vitest run -t "pattern"` -**Quality gate (matches CI `quality` job):** `pnpm run lint && pnpm run i18n:check && pnpm run typecheck && pnpm exec vitest run --coverage`. Full pipeline graph: [`docs/CI.md`](docs/CI.md). Coverage thresholds: lines 74, branches 60, functions 67, statements 72 (see `vitest.config.ts`). +**Vitest watch-mode hard rule:** Never invoke `pnpm test`, `npm run test`, or a bare Vitest wrapper. Always use an explicit targeted `pnpm exec vitest run ` command; watch mode hangs the constrained development hardware. -**CI pipeline order:** `security` → `quality` (Biome + tsgo + Vitest matrix) → `build` / `e2e` / `storybook` (parallel) → `lighthouse` (after build) → `deploy` on `main`. `ci-success` is a required-status aggregator (`needs: [security, quality, build]`) so branch protection can require one context instead of three/four individual ones — see `docs/CI.md`. +**Quality gate (matches CI `quality` job):** `pnpm run lint && pnpm run i18n:check && pnpm run typecheck`. CI additionally runs full-suite coverage; locally use only the targeted form `pnpm exec vitest run --coverage` when debugging coverage. Full pipeline graph: [`docs/CI.md`](docs/CI.md). Coverage thresholds: lines 74, branches 60, functions 67, statements 72 (see `vitest.config.ts`). + +**CI pipeline order:** `security` → `quality` (Biome + tsgo + Vitest matrix) → `build` / `e2e` / `storybook` (parallel) → `lighthouse` (after build) → `deploy` on `main`. `ci-success` is a required-status aggregator (`needs: [security, quality, build]`) so branch protection can require one context instead of three/four individual ones — see `docs/CI.md`. Two additional jobs run in parallel with `quality`, both path-scoped via the `changes` job (legitimately `skipping` on PRs that don't touch their directory, which `ci-success` treats as a pass for that job only): `rust-tauri` (`src-tauri/**` — fmt/check/clippy/test, needs the GTK/WebKit apt-get steps) and `core-rust` (`crates/**` — same fmt/check/clippy/test for the renderer-neutral Rust Core, no GUI deps so no apt-get steps needed). **CI-cloud-first workflow (constrained local hardware only):** On low-end hardware, run only `lint`, `typecheck`, `i18n:check` locally before pushing. Coverage, E2E, Lighthouse, and Stryker are CI-gate jobs. After each push, update README.md badges and AUDIT.md quality-gate line with CI-reported numbers. Local CI simulation: `act pull_request --job quality` (Docker + `act`; see `infra/low-end-ci/DAILY-DRIVER.md`). @@ -53,13 +54,22 @@ pnpm run token:audit # audit-tokens.mjs — design-token usage gate (CI b **PR review-comment policy — the CodeRabbit Correction Loop (proactive, automatic, every PR):** Fix ALL inline comments (CodeRabbit + any other bot/human) on every PR, **without being asked** — this explicitly includes CodeRabbit's collapsed **nitpick** sections and **outside-diff-range** findings (both easy to miss since they're collapsed by default in the review UI), not just its top-level actionable comments. Validate findings against the *current* code (anchors may be stale); implement real **root-cause** fixes (code **+ tests + i18n + docs** in lockstep) or reply with evidence if a false positive. **Never add a new `biome-ignore`** — the suppression ratchet (`scripts/check-suppressions.mjs`) fails the quality gate; refactor so the rule passes honestly. Reply to each thread citing the resolving commit (`POST .../comments//replies`), resolve it (GraphQL `resolveReviewThread`), leave **0 unresolved**. Then commit, push, and **re-trigger** (`gh pr comment --body "@coderabbitai review"`) — check its **full review history**, not just the latest status (a "rate limited" latest status can hide an earlier real review). **Iron rule — loop until quiescent:** a push triggers a fresh review that often raises NEW findings (a "wave"); repeat the full cycle until **BOTH** a fresh review yields **0 new comments** AND **0 threads unresolved**. Only then merge (auto-squash; admin-squash only after CI is green + loop quiescent). CodeAnt AI shows up as 5 CI **status checks** (`CodeAnt - Quality Gates/SAST/SCA/SCR/Test Coverage`) to verify green — it is not the bot posting inline comments in this repo, so don't re-trigger it expecting a comment thread. Full canonical procedure: [`docs/CODEANT-REVIEW-LOOP.md`](docs/CODEANT-REVIEW-LOOP.md). +**Current review-bot roster (verify still installed before relying on any of these — bots get added/removed over time):** CodeRabbit (`@coderabbitai review` to re-trigger; posts both inline threads for actionable comments AND a separate review-body-only "🧹 Nitpick comments" section — the latter is invisible to a `reviewThreads` check). CodeAnt AI (CI status checks only, not inline comments in this repo — see above). qodo-code-review (posts real findings as **plain top-level PR comments**, `gh api repos///issues//comments`, not as `reviewThreads` at all — a clean `reviewThreads` result does not mean qodo has nothing to say). Amazon Q Developer (manual trigger is `/q review` posted as a **brand-new top-level comment**, not inside an existing thread; also auto-reviews new/reopened PRs after install — **quota-conscious usage**: call it once CodeRabbit/CodeAnt's own loop has already reached quiescence, not after every fix commit; only re-invoke a second time if that fix was substantial). Graphite AI Reviews (automatic on every PR once installed — no confirmed manual re-trigger command). chatgpt-codex-connector (availability is intermittent/quota-limited on its own weekly budget — confirm it's currently active before relying on its silence as a clean pass, and don't spam re-triggers while it's known-suspended). + +**Review-comment completeness requires checking three independent channels, every PR, before declaring "review-clean" or merging** — a bot can and does use more than one channel on the very same PR: (1) GraphQL `reviewThreads(first:50)` for inline per-line comments; (2) `gh api repos///issues//comments` for plain top-level comments (qodo's real findings live here); (3) `gh api repos///pulls//reviews`, reading each review's `.body` field in full (CodeRabbit's nitpick/outside-diff-range sections live here, collapsed, invisible to both of the above). None of the three implies the others are clean. + +**A review bot's silence is not the same as a clean pass.** Rate-limited, quota-exhausted, or never-triggered is a different state than "reviewed and found nothing" — verify at least one bot produced *substantive* output (its actual review body/comment text, not just a green check-run) before merging, especially for security/sandbox/IPC/FFI/packaging-adjacent changes. If every independent reviewer is simultaneously silent on such a PR, that's a gap worth surfacing, not something to proceed past as if the loop were satisfied. + **PR-size limit — keep every PR under ~100 changed files so CodeAnt actually reviews it.** CodeAnt does **not** post inline review comments on PRs that exceed ~100 changed files (the >100-file check hangs/skips). Since any i18n-touching change fans out across 19 locale source files + 19 rebuilt `bundle.json` per module, a multi-feature branch crosses 100 fast. **Before pushing, run `git diff --name-only ...HEAD | wc -l`.** If it is over ~100, split the work into the **fewest** stacked PRs that each stay clearly under the limit — group by which locale module-files they touch so the per-PR fan-out stays small (e.g. P0 batch touching `writer.json`; P1/P2 batch touching `common.json`/`dashboard.json`). Stack them (PR2 base = PR1's branch) so each PR's incremental diff — what CodeAnt sees — is small; when PR1 merges, PR2 auto-retargets to `main`. **Do not** make more PRs than needed: if everything fits under ~100 in one (or two) PRs, use that. Keep commits atomic per concern regardless of how they are bundled into PRs. **Branching & merge discipline (every change, no exceptions):** Never commit directly to `main` — always create a feature branch, push, and open a PR, even for a single-file doc/config/chore edit. Before merging, wait for the **full CI suite to go green, including non-required/advisory jobs** (`E2E Tests`, `E2E Deep Coverage`, `Storybook`, `Lighthouse`, `Visual Regression`) — not just the branch-protection-required checks (Security Audit, Build, Quality Gate ×2). When doing a structured multi-step sprint (an audit, a migration broken into workstreams), group related small workstreams into the fewest PRs that stay reviewable — by natural/documented boundaries, not one PR per tiny item — while keeping one commit per logical concern inside each PR. **Known merge-gate quirks (GitHub, this repo):** - **Mergeable-state cache lag vs. this repo's own wait-for-everything policy — two different things:** (1) GitHub itself blocks the merge button while *any* check is still `pending`, required or not (a real, observed technical constraint — it clears on its own once every check concludes, pass or fail); separately, once all checks have actually concluded, GitHub's branch protection only re-blocks on a *failing required* check. (2) This repo's own policy above is stricter than that floor: wait for the advisory jobs to *pass*, not just stop being `pending`, before merging. If `gh pr merge` still fails with "base branch policy prohibits the merge" after every job (required and advisory) shows a concluded `success`, and `mergeable: MERGEABLE`, and 0 review threads are unresolved, that's the mergeable-state *cache* lagging behind reality, not either policy above. Re-poll a few times at ~60s spacing. Never use `--admin` to route around any of this without a maintainer's fresh, explicit authorization for that specific merge. -- **Stacked-PR auto-close on squash-merge:** squash-merging a PR with `--delete-branch` can cause GitHub to **auto-close (not retarget)** a downstream PR whose base was the just-deleted branch, instead of the usual automatic retarget-to-`main`. Recovery: `git push origin :refs/heads/` to temporarily restore the ref, `gh pr reopen `, `gh pr edit --base main`, then delete the temp branch once `gh pr list --state open --json baseRefName` shows nothing still depends on it. +- **Stacked-PR auto-close on squash-merge:** squash-merging a PR with `--delete-branch` can cause GitHub to **auto-close (not retarget)** a downstream PR whose base was the just-deleted branch, instead of the usual automatic retarget-to-`main`. Recovery: `git push origin :refs/heads/` to temporarily restore the ref, `gh pr reopen `, `gh pr edit --base main`, then delete the temp branch once `gh pr list --state open --json baseRefName` shows nothing still depends on it. **After that recovery, a naive `git rebase origin/main` on the reopened branch re-conflicts** even though the PR's own diff is clean — its history still contains the original un-squashed commits from the now-merged base PR, and a plain rebase tries to replay each individually against main's one squashed commit. Fix: find the merged base branch's last commit SHA (`git log --oneline `) and rebase only what's after it — `git rebase --onto origin/main ` — then `git push --force-with-lease`. +- **Zombie `QUEUED` check-suites block `mergeStateStatus`.** Several installed GitHub Apps (Renovate, Cursor, the Claude GitHub App, Greptile, CodeAnt AI, Cloudflare Pages, coderabbitai, Codecov, Amazon Q Developer) can leave their check-suite object stuck at `status: QUEUED` (never `COMPLETED`) on a PR that never triggers their actual logic (e.g. a docs-only PR never fires Renovate). This is **invisible via `gh pr checks`** (named checks all show correctly) — it only shows via GraphQL: `commits(last:1){nodes{commit{checkSuites(first:20){nodes{app{name} status}}}}}`. It causes `gh pr merge` to fail with "the base branch policy prohibits the merge" for 20–35+ minutes even though the only real required check (`✅ CI Success`) passed and 0 review threads are unresolved. Before assuming this pattern (vs. a real blocker), confirm the required check genuinely concluded `success` and 0 review threads are unresolved across all channels (see the PR review-comment policy below) — then this is the one case where `--admin` is the correct unblock, once a maintainer has authorized it for that merge. +- **Any `FAILURE` status — required or advisory — is zero-tolerance; never rationalize it as "probably fine."** `main`'s HEAD commit must never show a red check. In particular, `codecov/patch` evaluates the **entire accumulated diff** against the target branch, not just your latest commit — "my part was docs-only" does not exempt the rest of the PR's diff from needing real coverage. Pull the actual failure detail (coverage report, job log) and understand the root cause before deciding whether to fix it now; never merge (via normal merge, `--auto`, or `--admin`) while any check shows `FAILURE`. +- **After re-triggering a specific advisory/non-required job, verify that exact job by name before merging** — advisory workflows don't gate `mergeable`/`mergeStateStatus` at all, so a clean `gh pr view --json mergeable` gives zero signal about them, and a large batch of "pass" results from other checks does not imply the specific re-run finished. Grep `gh pr checks `'s output for the exact job name and confirm it individually shows `pass`. **E2E notes:** Do NOT use `networkidle` waits (HMR keeps WebSocket open). Scope sidebar navigation via `#sidebar`. Shared helpers: `tests/e2e/helpers.ts`. Mobile E2E: set `RUN_MOBILE_E2E=1` locally (off by default). @@ -78,6 +88,12 @@ WorldScript Studio is an offline-first PWA — a React 19 SPA with Google Gemini **Live:** `https://worldscript-studio.vercel.app/` (Vercel, primary) · GitHub Pages: `https://qnbs.github.io/WorldScript-Studio/` · Cloudflare Pages: `wrangler.toml` · Vercel: `vercel.json`. +### Native desktop strategy (ADR-0021) — read before touching desktop/native code + +CEF (Chromium Embedded Framework) is **retired from the target architecture**, not deferred (2026-08-20). Current direction, decided in `docs/adr/0021-qt-gpui-native-desktop-strategy.md` (supersedes ADR-0019/0020): React/PWA stays the first-class web product → **Tauri 2 is transitional only** and is itself retired once Qt reaches Stable (not a permanent third runtime) → an authoritative **Rust Core** (`crates/`, independent Cargo workspace from `src-tauri/Cargo.toml` — see below) → **Qt 6/Qt Quick (QML)** as the primary native desktop product ("Hardened Edition") → **GPUI** admitted much later as a secondary native product ("Vision Edition") behind a strict gate. Full 24-wave roadmap and gates: `docs/native/ROADMAP-QT-GPUI-DESKTOP.md`. If you find CEF referenced as a *future* target anywhere in the repo, that's stale history (see `docs/historical/cef/README.md`) — flag it. + +**Rust Core extraction is in progress.** `crates/worldscript-project` (Wave 2 first slice) is a renderer-neutral project schema/validation/migration/plain-I/O crate mirroring `types.ts`'s `StoryProject`/`Character`/`World`/`StorySection` as plain `Vec` (not Redux's `EntityState` union — a real frontend caller needs a normalization adapter first, not yet built). Proven headless (`cargo test` + `wsproj` CLI, zero GUI/Tauri deps) and wired to one real Tauri command, `worldscript_project_validate` (`src-tauri/src/commands/project_core.rs`), via a **path dependency across two independent Cargo workspaces** — `crates/worldscript-project` is a member of the `crates/` workspace, `src-tauri/Cargo.toml` depends on it by path without unifying the workspaces; this compiles and links cleanly. `docs/native/CORE-MIGRATION-LEDGER.md` sets the capability-priority order for what's extracted next (logger/diagnostics and task-orchestration expansion before IDB storage/encryption or `features/project/` domain logic). CI: new `core-rust` job mirrors `rust-tauri`'s fmt/check/clippy/test but needs no GTK/WebKit apt-get steps (zero GUI deps); both are path-scoped via the `changes` job and legitimately show `skipping` on PRs that don't touch their respective directories. + ### Directory map ``` diff --git a/README.md b/README.md index 18160ef95..e6b3413b2 100644 --- a/README.md +++ b/README.md @@ -641,11 +641,11 @@ pnpm run build # Preview the production build locally pnpm run preview -# Run unit tests -pnpm run test:run +# Run one targeted unit-test file +pnpm exec vitest run -# Run unit tests with coverage -pnpm run test:coverage +# Run one targeted unit-test file with coverage +pnpm exec vitest run --coverage # Type check pnpm run typecheck @@ -714,14 +714,16 @@ The main pipeline is [`.github/workflows/ci.yml`](.github/workflows/ci.yml). Opt **CI-cloud-first workflow (recommended):** On constrained hardware run **`pnpm run lint && pnpm run i18n:check && pnpm run typecheck`** locally, then push and let CI handle coverage, E2E, Lighthouse, and Stryker. Authoritative numbers come from CI artifacts (Codecov, JUnit). After CI goes green, update the README badges and `AUDIT.md` quality-gate line from the reported metrics. See **[`docs/CI.md`](docs/CI.md) § Cloud CI-first vs local development** for the full post-merge doc-update checklist. -**Low-resource / laptop workflow:** **`pnpm run test:run`** exercises Vitest without `--coverage` — fast and memory-light. Full coverage (`pnpm exec vitest run --coverage`) is intentionally RAM-heavy; rely on the CI `quality` job unless you are debugging a specific threshold. +**Low-resource / laptop workflow:** Use **`pnpm exec vitest run `** for one targeted file without `--coverage` — fast and memory-light. Never invoke `pnpm test`, `npm run test`, or a bare Vitest wrapper: Vitest enters watch mode and hangs the session on constrained hardware. Full-suite coverage is CI-only; for local debugging, use `pnpm exec vitest run --coverage` on the specific file. **Quality-gate parity (matches CI `quality` job exactly):** ```bash -pnpm run lint && pnpm run i18n:check && pnpm run typecheck && pnpm exec vitest run --coverage +pnpm run lint && pnpm run i18n:check && pnpm run typecheck ``` +CI adds full-suite coverage; local validation must keep Vitest targeted with `pnpm exec vitest run [--coverage]`. + **Simulate CI locally with [Act](https://github.com/nektos/act):** ```bash @@ -776,6 +778,7 @@ See **[`CONTRIBUTING.md`](CONTRIBUTING.md)** for the full dev setup, Biome / Vit | [`docs/DEEPSOURCE-REMEDIATION-PLAN.md`](docs/DEEPSOURCE-REMEDIATION-PLAN.md) | Prioritised DeepSource backlog tracker (P0-security→P5-docs) with triage decisions | | [`docs/adr/`](docs/adr/README.md) | Architecture Decision Records — state-management boundaries, local-AI stack layering, WorkerBus v2 hybrid routing | | [`docs/native/ROADMAP-QT-GPUI-DESKTOP.md`](docs/native/ROADMAP-QT-GPUI-DESKTOP.md) | Qt 6 + GPUI native desktop roadmap (ADR-0021) — 24-wave plan: renderer-neutral Rust Core → Qt Hardened Edition → GPUI Vision Edition, succeeding Tauri; CEF retired, historical record in `docs/historical/cef/` | +| [`docs/native/CORE-MIGRATION-LEDGER.md`](docs/native/CORE-MIGRATION-LEDGER.md) | Rust Core extraction priority order — what's moved out of TypeScript vs. deferred, and why | | [`docs/architecture/native-readiness.md`](docs/architecture/native-readiness.md) | Native-Readiness scorecard (see ADR-0021) — cross-cutting architecture-quality checklist, re-scored at every architecture-changing PR | | [`docs/ACCESSIBILITY.md`](docs/ACCESSIBILITY.md) | A11y architecture (live regions, focus, WCAG 2.2, Lighthouse 0.95 gate) | | [`docs/BEST-PRACTICES.md`](docs/BEST-PRACTICES.md) | Engineering + content guidelines, glossary, CI parity checklist |