From d47442b4d5ace6e241acf397d50683c1596f6764 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 21 Jul 2026 12:17:59 +0000 Subject: [PATCH] Revamp 6.2: QA rules + formats as addons + Add-ons page MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Wrap QA rules and import/export formats as extension contributions and ship the /addons catalogue (ROADMAP §6.2). - builtins: qa. manifests (one per QA rule) + format. manifests (xliff/docx/tmx/tbx/csv); BUILTIN_MANIFESTS = MT + QA + formats. - core/qa/registryRules: effectiveQaRules ANDs per-project toggles with the registry (defaults on when unregistered, so runQA stays pure); QAPanel runs QA through it and re-derives on registry change — toggling a QA addon changes QA output live. - features/addons/AddonsPage (/addons + nav): catalogue grouped by kind with permission chips + built-in badges; MT + QA rows toggle (enforced), formats shown always-on (format gating deferred). - Tests: qa.registryRules (disable removes the finding from runQA — the DoD; falls back all-on when empty); AddonsPage (lists MT/QA/formats; QA toggle flips registry). No migration. Green: 688 unit (+6) / 29 e2e / typecheck / build (flag-off Supabase-free). Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_015AEnTYBecWG64R3iP8GBiE --- docs/revamp/ROADMAP.md | 2 +- docs/revamp/STATUS.md | 5 +- src/app/routes.tsx | 2 + src/components/layout/navItems.ts | 3 +- src/core/extensions/builtins.ts | 72 +++++++++++- src/core/qa/registryRules.ts | 36 ++++++ src/features/addons/AddonsPage.tsx | 165 ++++++++++++++++++++++++++++ src/features/editor/qa/QAPanel.tsx | 20 +++- tests/unit/AddonsPage.test.tsx | 44 ++++++++ tests/unit/qa.registryRules.test.ts | 58 ++++++++++ 10 files changed, 399 insertions(+), 8 deletions(-) create mode 100644 src/core/qa/registryRules.ts create mode 100644 src/features/addons/AddonsPage.tsx create mode 100644 tests/unit/AddonsPage.test.tsx create mode 100644 tests/unit/qa.registryRules.test.ts diff --git a/docs/revamp/ROADMAP.md b/docs/revamp/ROADMAP.md index 0df8798..93488ce 100644 --- a/docs/revamp/ROADMAP.md +++ b/docs/revamp/ROADMAP.md @@ -88,7 +88,7 @@ Roadmap, status checklist, prompt pack, and vision docs committed to the repo. ### Milestone 6 — Extensions & connectors - **6.1 Extension registry + MT providers as built-in addons — M — deps: 0.1.** `core/extensions/types.ts` (`ExtensionManifest {id, name, version, kinds, permissions, builtIn}`; kinds: `mt-provider|qa-rule|panel|import-format|export-format|storage-connector`), `registry.ts` (enablement persisted in settings), `builtins.ts`; `src/extensions/mt/*` manifest wrappers delegating to existing `src/core/mt/*` (no logic moves); `mt/index.ts` resolves via registry. Existing MT tests untouched prove behavior preservation. **DoD**: four MT providers run through registry; disabling removes from panel. *Shipped: `core/extensions/{types,registry,builtins}.ts` (dependency-free singleton, enablement as a disabled set, `subscribe`) + `src/extensions/mt` wrappers; `core/mt` `enabledProviders` + `useMTSettings` AND the user setting with the registry (reactive via `useSyncExternalStore`); disabled set persisted under the device-local `extensions.disabled` key, registered from `main.tsx`. No Add-ons page yet (6.2).* -- **6.2 QA rules + formats as addons + Add-ons page — M — deps: 6.1.** Wrap `QA_CODES` rules and XLIFF/TMX/TBX/CSV/DOCX-export as contributions; `features/addons/AddonsPage.tsx` (catalogue, enable/disable, permission display, "suggested/built-in" UX); `/addons` route + nav. QA falls back to all-on when registry empty (keeps tests). **DoD**: Add-ons page lists everything pluggable; toggling a QA addon changes QA output. +- **6.2 QA rules + formats as addons + Add-ons page — M — deps: 6.1.** Wrap `QA_CODES` rules and XLIFF/TMX/TBX/CSV/DOCX-export as contributions; `features/addons/AddonsPage.tsx` (catalogue, enable/disable, permission display, "suggested/built-in" UX); `/addons` route + nav. QA falls back to all-on when registry empty (keeps tests). **DoD**: Add-ons page lists everything pluggable; toggling a QA addon changes QA output. *Shipped: QA (`qa.`) + format (`format.*`) manifests in `core/extensions/builtins`; `core/qa/registryRules` (`effectiveQaRules` ANDs toggles with the registry, defaults on when unregistered) wired into `QAPanel` (reactive); `features/addons/AddonsPage` at `/addons` (+ nav) grouping addons by kind with permission chips + built-in badges — MT + QA toggle (enforced), formats shown Always-on (format-level gating deferred).* - **6.3 Google Drive connector — M — deps: 6.1, 2.4.** `src/extensions/connectors/gdrive/` (Google Identity Services token client, `drive.file` scope — avoids verification burden; REST list/download/upload; token in memory/session only), generic `ConnectorFilePicker`; "From cloud" in ImportDialog + "Save to cloud" on DOCX export. Pure client OAuth — works for local-only users too. **DoD**: import DOCX from Drive; export back to Drive. - **6.4 OneDrive connector — S — deps: 6.3.** `src/extensions/connectors/onedrive/` via dynamic `@azure/msal-browser` (`loginPopup` — avoids redirect/hash-router interplay), Graph `Files.ReadWrite`. **DoD**: same loop on OneDrive; both connectors appear as addons with permission labels. *Milestone 6 = extensions + connectors shipped.* diff --git a/docs/revamp/STATUS.md b/docs/revamp/STATUS.md index 455f257..8036ae6 100644 --- a/docs/revamp/STATUS.md +++ b/docs/revamp/STATUS.md @@ -36,8 +36,8 @@ Rules for autonomous sessions: | 5.1 | Members & roles management | M | 4.1 | done (PR #60) | | 5.2 | Role-gated editing workflow | M | 5.1, 1.6 | done (PR #61) | | 5.3 | Approval workflow + attribution in versioning | S | 5.2 | done (PR #62) | -| 6.1 | Extension registry + MT providers as built-in addons | M | 0.1 | in-review (PR #63) | -| 6.2 | QA rules + formats as addons + Add-ons page | M | 6.1 | pending | +| 6.1 | Extension registry + MT providers as built-in addons | M | 0.1 | done (PR #63) | +| 6.2 | QA rules + formats as addons + Add-ons page | M | 6.1 | in-review (PR #64) | | 6.3 | Google Drive connector | M | 6.1, 2.4 | pending | | 6.4 | OneDrive connector | S | 6.3 | pending | @@ -79,3 +79,4 @@ Rules for autonomous sessions: - 2026-07-21 — Phase 5.1 merged in PR #60. Phase 5.2 built: role-gated editing workflow. `src/core/workflow/rules.ts` — the pure heart: `workflowCapabilities(role, stage, status) → {canEditDirect, mustSuggest, canResolveChanges, canReview, canManage}` (PM manages + edits through translation/review and is the only role that touches a `final` project; translator edits directly in translation but is **forced to suggest** in review, and must suggest on an already-`reviewed` segment; revisor waits during translation then edits + accepts/rejects in review; accept/reject is revisor/PM-only), plus `effectiveRoleStage(cloud)` (local-only → **PM-of-self**: project_manager/translation, every capability granted → zero regression) and `forcesSuggesting(role, stage)`. `supabase/migrations/0009_workflow.sql` — a `project_stage` enum + `projects.stage` (default `translation`) + `deadline`; writes are project_manager-only through the **existing** `projects_update` policy (no new policy). Numbering deviation: ROADMAP §5.2 sketched `0007_workflow.sql`, but `0007`/`0008` were taken (compaction + members), so it ships as `0009`. `WorkflowStage` added to `core/types`; `Project.cloud` gains `stage?`/`deadline?` (read on open). `projectCloud.ts` — `fetchCloudProject` now returns `stage`/`deadline`, `openCloudProject` stores them, and `setCloudProjectStage` + the `changeProjectStage(projectId, stage)` orchestration (writes Postgres — RLS rejects a non-PM — then patches the local `project.cloud.stage`). Wiring: a tiny `features/editor/workflow/useWorkflowStore` (default permissive PM/translation) is published by `EditorPage` from `effectiveRoleStage(project.cloud)`; `EditModeToggle` gains a `forced` prop (locked + disabled) driven by `forcesSuggesting`, and `EditorPage` pins `editMode='suggesting'` when forced; `ChangesPanel` (accept-all/reject-all + per-suggestion buttons) and `ChangeHoverCard` hide their resolve affordances when `!canResolveChanges`; a PM stage ` extensionRegistry.setEnabled(manifest.id, e.target.checked)} + data-testid={`addon-toggle-${manifest.id}`} + aria-label={`${enabled ? 'Disable' : 'Enable'} ${manifest.name}`} + /> + {enabled ? 'On' : 'Off'} + + ) : ( + + Always on + + )} + + ) +} + +function PermissionChip({ label }: { label: string }) { + return ( + + {label} + + ) +} diff --git a/src/features/editor/qa/QAPanel.tsx b/src/features/editor/qa/QAPanel.tsx index d560cfc..f1839c1 100644 --- a/src/features/editor/qa/QAPanel.tsx +++ b/src/features/editor/qa/QAPanel.tsx @@ -1,7 +1,9 @@ -import { useMemo } from 'react' +import { useMemo, useSyncExternalStore } from 'react' import { AlertTriangle, AlertCircle, CheckCircle2 } from 'lucide-react' import { runQA } from '@/core/qa/checks' import { QA_RULE_LABELS, type QAIssue } from '@/core/qa/types' +import { effectiveQaRules } from '@/core/qa/registryRules' +import { extensionRegistry } from '@/core/extensions/registry' import { useProjectSegments } from '../useProjectSegments' import { useGlossaryCache } from '../glossary/useGlossaryCache' import { useEditorSettings } from '../useEditorSettings' @@ -46,10 +48,22 @@ export function QAPanel({ projectId, targetLang }: QAPanelProps) { const settings = useEditorSettings() const actions = useEditorActionsStore((s) => s.actions) + // Re-run when a QA addon is toggled on the Add-ons page (§6.2). + const registryVersion = useSyncExternalStore( + (cb) => extensionRegistry.subscribe(cb), + () => extensionRegistry.getDisabledIds().join(','), + () => '', + ) + const issues = useMemo(() => { if (!segments) return [] - return runQA(segments, glossary ?? [], { targetLang, rules: settings.qaRules }) - }, [segments, glossary, targetLang, settings.qaRules]) + // Registry-disabled rules are removed from the effective toggles. + return runQA(segments, glossary ?? [], { + targetLang, + rules: effectiveQaRules(settings.qaRules), + }) + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [segments, glossary, targetLang, settings.qaRules, registryVersion]) if (!segments) { return ( diff --git a/tests/unit/AddonsPage.test.tsx b/tests/unit/AddonsPage.test.tsx new file mode 100644 index 0000000..a582844 --- /dev/null +++ b/tests/unit/AddonsPage.test.tsx @@ -0,0 +1,44 @@ +import { beforeEach, describe, expect, it } from 'vitest' +import { render, screen, fireEvent } from '@testing-library/react' +import AddonsPage from '@/features/addons/AddonsPage' +import { extensionRegistry } from '@/core/extensions/registry' +import { registerBuiltinExtensions, __resetBuiltinsForTest } from '@/core/extensions/builtins' + +describe('AddonsPage (§6.2)', () => { + beforeEach(() => { + extensionRegistry.__resetForTest() + __resetBuiltinsForTest() + registerBuiltinExtensions() + }) + + it('lists everything pluggable: MT providers, QA rules, and formats', () => { + render() + // A section per kind. + expect(screen.getByTestId('addons-section-mt-provider')).toBeInTheDocument() + expect(screen.getByTestId('addons-section-qa-rule')).toBeInTheDocument() + expect(screen.getByTestId('addons-section-import-format')).toBeInTheDocument() + // Representative entries of each kind. + expect(screen.getByTestId('addon-mt.claude')).toBeInTheDocument() + expect(screen.getByTestId('addon-qa.double_space')).toBeInTheDocument() + expect(screen.getByTestId('addon-format.xliff')).toBeInTheDocument() + }) + + it('toggling a QA addon flips its registry enablement', () => { + render() + const toggle = screen.getByTestId('addon-toggle-qa.double_space') as HTMLInputElement + expect(toggle.checked).toBe(true) + + fireEvent.click(toggle) + expect(extensionRegistry.isEnabled('qa.double_space')).toBe(false) + expect((screen.getByTestId('addon-toggle-qa.double_space') as HTMLInputElement).checked).toBe(false) + + fireEvent.click(screen.getByTestId('addon-toggle-qa.double_space')) + expect(extensionRegistry.isEnabled('qa.double_space')).toBe(true) + }) + + it('formats are shown as always-on (no toggle)', () => { + render() + expect(screen.getByTestId('addon-builtin-format.xliff')).toBeInTheDocument() + expect(screen.queryByTestId('addon-toggle-format.xliff')).toBeNull() + }) +}) diff --git a/tests/unit/qa.registryRules.test.ts b/tests/unit/qa.registryRules.test.ts new file mode 100644 index 0000000..6f4f5c8 --- /dev/null +++ b/tests/unit/qa.registryRules.test.ts @@ -0,0 +1,58 @@ +import { beforeEach, describe, expect, it } from 'vitest' +import { runQA } from '@/core/qa/checks' +import { DEFAULT_QA_RULES } from '@/core/qa/types' +import { effectiveQaRules, isQaRuleEnabled } from '@/core/qa/registryRules' +import { extensionRegistry } from '@/core/extensions/registry' +import { registerBuiltinExtensions, __resetBuiltinsForTest } from '@/core/extensions/builtins' +import type { Segment } from '@/core/types' + +function seg(target: string): Segment { + const now = new Date().toISOString() + return { + id: 's1', + projectId: 'p1', + index: 0, + source: 'src', + target, + status: 'translated', + createdAt: now, + updatedAt: now, + } +} + +describe('effectiveQaRules (registry-gated QA, §6.2)', () => { + beforeEach(() => { + extensionRegistry.__resetForTest() + __resetBuiltinsForTest() + }) + + it('falls back to all-on when no QA manifests are registered (keeps runQA pure)', () => { + // Nothing registered → every rule treated as enabled, toggles pass through. + expect(isQaRuleEnabled('double_space')).toBe(true) + expect(effectiveQaRules({ double_space: true })).toEqual({ double_space: true }) + }) + + it('forces a rule off when its addon is disabled, keeping the others', () => { + registerBuiltinExtensions() + extensionRegistry.setEnabled('qa.double_space', false) + + expect(isQaRuleEnabled('double_space')).toBe(false) + const effective = effectiveQaRules({ double_space: true, repeated_word: true }) + expect(effective.double_space).toBe(false) + expect(effective.repeated_word).toBe(true) + }) + + it('disabling the QA addon removes the finding from runQA output (the DoD)', () => { + registerBuiltinExtensions() + const segments = [seg('hello world')] // double space + + // Enabled: the double_space rule fires. + let issues = runQA(segments, [], { targetLang: 'en', rules: effectiveQaRules(DEFAULT_QA_RULES) }) + expect(issues.some((i) => i.code === 'double_space')).toBe(true) + + // Disable the addon → the finding is gone, others unaffected. + extensionRegistry.setEnabled('qa.double_space', false) + issues = runQA(segments, [], { targetLang: 'en', rules: effectiveQaRules(DEFAULT_QA_RULES) }) + expect(issues.some((i) => i.code === 'double_space')).toBe(false) + }) +})