From 8c098465c891b33f407bd4f498f1506c4242dc22 Mon Sep 17 00:00:00 2001 From: arnaud Date: Sat, 18 Jul 2026 18:06:16 +0200 Subject: [PATCH] :sparkles: Agressive reworks + AI SDK + Memory --- AGENTS.md | 1 + docs/DECISIONS.md | 217 ++++- eslint-rules/core-boundary.js | 173 ++++ eslint.config.js | 53 +- package-lock.json | 188 +++- package.json | 10 + scripts/check-boundaries.mjs | 141 +++ scripts/check-budgets.mjs | 179 ++++ src/app/Router.tsx | 149 +-- src/components/AgentToolsPicker.tsx | 12 +- src/components/Icon.tsx | 4 +- .../PromptArea/AttachmentSelector.tsx | 18 +- src/components/chat/MessageBubble.tsx | 13 +- src/components/chat/RememberSelectionMenu.tsx | 144 +++ src/components/chat/index.ts | 1 + src/config/flags.ts | 227 +++++ src/features/live/hooks/useVoice.ts | 4 +- src/features/live/lib/index.ts | 4 - src/features/live/lib/stt/index.ts | 46 +- src/features/live/lib/tts/index.ts | 34 +- src/features/live/lib/tts/kitten-voices.ts | 53 ++ src/features/live/lib/tts/kitten.ts | 47 +- .../live/lib/tts/supertonic-voices.ts | 83 ++ src/features/live/lib/tts/supertonic.ts | 78 +- src/features/marketplace/utils.ts | 4 +- src/features/traces/trace-service.ts | 46 + src/hooks/useAutoScroll.ts | 1 - src/i18n/locales/ar.ts | 23 + src/i18n/locales/de.ts | 23 + src/i18n/locales/en.ts | 21 + src/i18n/locales/es.ts | 23 + src/i18n/locales/fr.ts | 23 + src/i18n/locales/ko.ts | 23 + src/lib/chat.ts | 251 ++--- src/lib/code-tools/sandbox.ts | 35 +- src/lib/document-converter.ts | 16 +- src/lib/llm/ai-sdk/adapter.ts | 268 ++++++ src/lib/llm/ai-sdk/bindings.ts | 322 +++++++ src/lib/llm/ai-sdk/chatjimmy.ts | 172 ++++ src/lib/llm/ai-sdk/index.ts | 53 ++ src/lib/llm/index.ts | 50 +- src/lib/llm/models.ts | 398 +------- src/lib/llm/providers/anthropic.ts | 661 -------------- src/lib/llm/providers/chatjimmy.ts | 171 ---- src/lib/llm/providers/custom.ts | 131 --- src/lib/llm/providers/github-copilot.ts | 56 +- src/lib/llm/providers/google.ts | 859 ------------------ src/lib/llm/providers/huggingface.ts | 206 ----- src/lib/llm/providers/index.ts | 14 +- src/lib/llm/providers/lm-studio.ts | 348 ------- src/lib/llm/providers/local.ts | 68 +- src/lib/llm/providers/mistral.ts | 279 ------ src/lib/llm/providers/ollama.ts | 262 ------ src/lib/llm/providers/openai-compatible.ts | 389 -------- src/lib/llm/providers/openai.ts | 328 ------- src/lib/llm/providers/openrouter.ts | 192 ---- src/lib/memory-learning-service.ts | 372 +++++++- src/lib/models-dev/index.ts | 11 + src/lib/orchestrator/agent-runner.ts | 26 +- src/lib/subagent.ts | 153 ++++ src/lib/tool-executor/executor.ts | 56 ++ src/lib/tool-executor/index.ts | 6 + src/lib/yjs/doc.ts | 28 +- src/pages/Knowledge/AgentMemories.tsx | 222 +++-- .../Settings/components/FeaturesSection.tsx | 4 +- .../Settings/components/ProviderForm.tsx | 2 +- src/pages/Settings/i18n/ar.ts | 1 + src/pages/Settings/i18n/de.ts | 1 + src/pages/Settings/i18n/en.ts | 1 + src/pages/Settings/i18n/es.ts | 1 + src/pages/Settings/i18n/fr.ts | 1 + src/pages/Settings/i18n/ko.ts | 1 + .../Workspace/components/TranscriptView.tsx | 48 +- .../context-sections/MemorySection.tsx | 155 +++- src/stores/agentMemoryStore.ts | 34 + src/stores/agentStore.ts | 111 +-- src/test/components/MemorySection.test.tsx | 79 ++ .../components/RememberSelectionMenu.test.tsx | 146 +++ src/test/config/flags.test.ts | 94 ++ src/test/existing-task-orchestration.test.ts | 20 +- src/test/fixtures/golden/README.md | 32 + .../golden/llm/chatjimmy-chat-basic.json | 3 + .../golden/llm/chatjimmy-stream-basic.json | 3 + .../golden/llm/openai-chat-basic.json | 9 + .../golden/llm/openai-chat-tools.json | 19 + .../golden/llm/openai-stream-basic.json | 3 + src/test/golden/golden.test.ts | 77 ++ src/test/golden/index.ts | 125 +++ .../lib/crypto/content-encryption.test.ts | 1 - src/test/lib/detect-language.test.ts | 4 +- .../lib/llm/ai-sdk/chatjimmy-parity.test.ts | 94 ++ .../lib/llm/ai-sdk/compat-baseurl.test.ts | 93 ++ src/test/lib/llm/ai-sdk/openai-parity.test.ts | 166 ++++ .../lib/llm/ai-sdk/provider-validate.test.ts | 59 ++ src/test/lib/llm/models.test.ts | 100 -- src/test/lib/llm/providers/anthropic.test.ts | 113 --- src/test/lib/llm/providers/chatjimmy.test.ts | 268 ------ .../lib/llm/providers/github-copilot.test.ts | 289 ------ src/test/lib/llm/providers/google.test.ts | 485 ---------- src/test/lib/memory-learning-service.test.ts | 281 ++++++ src/test/lib/subagent.test.ts | 105 +++ src/test/message-attachments.test.ts | 34 +- src/test/orchestration-deduplication.test.ts | 8 + .../pages/Workspace/TranscriptView.test.ts | 82 ++ src/test/stores/conversationStore.test.ts | 6 +- src/test/tools/plugins/memory.test.ts | 97 ++ src/test/tools/plugins/skill-tools.test.ts | 3 - src/tools/plugins/delegate.ts | 91 ++ src/tools/plugins/index.ts | 21 +- src/tools/plugins/memory.ts | 144 +++ src/tools/plugins/skill-tools.ts | 7 +- src/tools/types.ts | 2 + 112 files changed, 5836 insertions(+), 6156 deletions(-) create mode 100644 eslint-rules/core-boundary.js create mode 100644 scripts/check-boundaries.mjs create mode 100644 scripts/check-budgets.mjs create mode 100644 src/components/chat/RememberSelectionMenu.tsx create mode 100644 src/config/flags.ts create mode 100644 src/features/live/lib/tts/kitten-voices.ts create mode 100644 src/features/live/lib/tts/supertonic-voices.ts create mode 100644 src/lib/llm/ai-sdk/adapter.ts create mode 100644 src/lib/llm/ai-sdk/bindings.ts create mode 100644 src/lib/llm/ai-sdk/chatjimmy.ts create mode 100644 src/lib/llm/ai-sdk/index.ts delete mode 100644 src/lib/llm/providers/anthropic.ts delete mode 100644 src/lib/llm/providers/chatjimmy.ts delete mode 100644 src/lib/llm/providers/custom.ts delete mode 100644 src/lib/llm/providers/google.ts delete mode 100644 src/lib/llm/providers/huggingface.ts delete mode 100644 src/lib/llm/providers/lm-studio.ts delete mode 100644 src/lib/llm/providers/mistral.ts delete mode 100644 src/lib/llm/providers/ollama.ts delete mode 100644 src/lib/llm/providers/openai-compatible.ts delete mode 100644 src/lib/llm/providers/openai.ts delete mode 100644 src/lib/llm/providers/openrouter.ts create mode 100644 src/lib/subagent.ts create mode 100644 src/test/components/MemorySection.test.tsx create mode 100644 src/test/components/RememberSelectionMenu.test.tsx create mode 100644 src/test/config/flags.test.ts create mode 100644 src/test/fixtures/golden/README.md create mode 100644 src/test/fixtures/golden/llm/chatjimmy-chat-basic.json create mode 100644 src/test/fixtures/golden/llm/chatjimmy-stream-basic.json create mode 100644 src/test/fixtures/golden/llm/openai-chat-basic.json create mode 100644 src/test/fixtures/golden/llm/openai-chat-tools.json create mode 100644 src/test/fixtures/golden/llm/openai-stream-basic.json create mode 100644 src/test/golden/golden.test.ts create mode 100644 src/test/golden/index.ts create mode 100644 src/test/lib/llm/ai-sdk/chatjimmy-parity.test.ts create mode 100644 src/test/lib/llm/ai-sdk/compat-baseurl.test.ts create mode 100644 src/test/lib/llm/ai-sdk/openai-parity.test.ts create mode 100644 src/test/lib/llm/ai-sdk/provider-validate.test.ts delete mode 100644 src/test/lib/llm/providers/anthropic.test.ts delete mode 100644 src/test/lib/llm/providers/chatjimmy.test.ts delete mode 100644 src/test/lib/llm/providers/github-copilot.test.ts delete mode 100644 src/test/lib/llm/providers/google.test.ts create mode 100644 src/test/lib/memory-learning-service.test.ts create mode 100644 src/test/lib/subagent.test.ts create mode 100644 src/test/pages/Workspace/TranscriptView.test.ts create mode 100644 src/test/tools/plugins/memory.test.ts create mode 100644 src/tools/plugins/delegate.ts create mode 100644 src/tools/plugins/memory.ts diff --git a/AGENTS.md b/AGENTS.md index 0405da9a..0a6f574f 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -42,6 +42,7 @@ Additional deep-dives in `docs/more/`: | [docs/more/LOCAL-BACKUP.md](docs/more/LOCAL-BACKUP.md) | Bidirectional file system sync | | [docs/more/EXTENSION-BRIDGE.md](docs/more/EXTENSION-BRIDGE.md) | Sandboxed extension API | | [docs/more/MEET-BOT.md](docs/more/MEET-BOT.md) | Google Meet integration | +| [docs/more/MEMORY.md](docs/more/MEMORY.md) | Agent long-term memory (KISS, agent-directed) | | [docs/more/SEARCH.md](docs/more/SEARCH.md) | Global search | --- diff --git a/docs/DECISIONS.md b/docs/DECISIONS.md index c3cea48f..1bb25911 100644 --- a/docs/DECISIONS.md +++ b/docs/DECISIONS.md @@ -278,4 +278,219 @@ Each record follows: **Context** (what prompted the decision), **Decision** (wha --- -**Related:** [ARCHITECTURE.md](./ARCHITECTURE.md), [CONVENTIONS.md](./CONVENTIONS.md), [VISION.md](./VISION.md) +## ADR-017: Browser-Only Strangler Re-Platform + +**Date:** Re-platform kickoff (Phase 0) + +**Context:** DEVS grew to ~283 K LoC and passes four of six success criteria (portable, universal, democratic, web-only) but fails **thin** and **KISS**. The failures are *accretion*, not *foundation*: a hand-rolled ~10 K-line LLM layer, ~30 K-line connector layer, and ~7 K-line orchestrator now duplicate capabilities the ecosystem standardises (AI SDK, MCP). The moat — the intersection of zero-install, local-first CRDT, model-agnostic BYOK, and privacy-by-architecture, deliverable as a URL — lives in the Yjs data layer, Web Crypto, i18n/RTL, and the `ee/` split, not in the plumbing. A blank-page rewrite would destroy the moat and re-accrete the same bloat. Full analysis: [revamp/REPORT.md](./revamp/REPORT.md). + +**Decision:** Do an **aggressive strangler re-platform**, not a rewrite. Keep the moat; delete or outsource the hand-rolled plumbing. Three hard invariants: + +1. **Browser-only stands.** Agent execution never leaves the browser tab. No Node/cloud sandbox (`@ai-sdk/sandbox-vercel`, `@vercel/sandbox`), no server-side runner, no WebContainers — ever. The enterprise control plane only supplies identity, policy, connectors (MCP gateway), sync, audit, and managed inference endpoints the browser calls. +2. **The core is the centre of gravity.** The target is the smallest browser-native local-first PWA (Yjs + Web Crypto + a minimal LLM facade + a minimal tool facade + a simple agent loop + a small inert policy reader). AI SDK, MCP, the DEVS Harness, and enterprise are **optional layers behind facades**, never the architectural centre. +3. **The DEVS Harness defaults to a thin, DEVS-owned browser implementation (Option B).** Conforming to the AI SDK `HarnessV1` abstraction (Option A) is adopted only if the Phase 0.5 spike proves it materially simpler and clears explicit kill criteria (no `fs`/`ws` leakage, no Node-polyfill pile, no stubbing the ~40% server-durability contract). Either way, browser-only holds. + +Methodology: **"Always-Green Strangler"** — trunk-based, `main` releasable at every commit; every swap ships behind a facade + feature flag; old and new coexist; old code is deleted only after parity is proven by golden/characterization tests. Enterprise is a **seam designed-in from Phase 0, implemented last, always optional**. + +**Consequences:** +- The two failing criteria (thin, KISS) are fixed by *removing* code; line count going down is a tracked success metric. +- Framework bets (AI SDK, harness, MCP) are gated by Phase 0.5 spikes and reversible behind facades; the fallback for the LLM layer is a minimal fetch-based browser adapter. +- Client-side policy is configuration/UX, **not** a security boundary; hard enterprise controls are enforced server-side at the gateway/connector edge. +- A long-lived `v2` branch is forbidden; every strangle ends with a delete-PR. +- The Yjs data layer, Web Crypto, i18n, and `ee/` foundations are not touched without a specific, tested reason. + +--- + +## ADR-018: Dependency-Justification Rule + +**Date:** Re-platform kickoff (Phase 0) + +**Context:** The re-platform outsources plumbing to the ecosystem (AI SDK, MCP, the harness, enterprise layers). The risk is that a new stack simply becomes the *next* centre of gravity and re-accretes bloat — trading one dependency mess for another. The origin failure was accretion; new dependencies must not repeat it. + +**Decision:** A dependency (or optional layer) is allowed **only if it deletes substantially more code than it adds and does not become a new architectural centre of gravity.** Every framework is an optional layer behind a facade, spike-gated, and subject to the core→optional import boundary. This rule is the explicit gate in the Phase 0.5 go/no-go ADRs for AI SDK (Spike A) and the DEVS Harness (Spike B). + +**Consequences:** +- Adoption ADRs must show the net LoC delta (added vs deleted) and confirm the dependency stays behind one facade. +- The core/optional import boundary is enforced by lint (`eslint-rules/core-boundary.js`) and the `scripts/check-boundaries.mjs` burn-down gate; core must never import an optional/enterprise module. +- Experimental dependencies (e.g. `@ai-sdk/harness`) are wrapped so a breaking release touches one module; their Node/cloud sandbox providers are never adopted. +- A dependency that adds more than it deletes, or that leaks into the core centre, is rejected regardless of feature appeal. + +--- + +## ADR-019: Spike A Outcome — Back the LLM Facade with the AI SDK (GREEN) + +**Date:** Phase 0.5 (spike) + +**Context:** Phase 3 replaces the hand-rolled ~9.5 K-line LLM provider layer with +the AI SDK behind the `LLMService` facade — but only if the SDK works *client-side* +(BYOK, CORS, streaming, tool-calls, multimodal) at an acceptable bundle cost. Spike +A probed `ai@7.0.31` + `@ai-sdk/openai|anthropic|google|openai-compatible` with real +Vite browser builds and provider source inspection. Full evidence: +[revamp/spikes/FINDINGS.md](./revamp/spikes/FINDINGS.md). + +**Decision:** **GREEN — back the provider facade with the AI SDK in Phase 3.** The +packages bundle cleanly for the browser (no Node built-in leakage), are +lazy-loadable per provider (~50–90 KB gzip each incl. shared `provider-utils`), +and expose `apiKey` / custom `headers` / `baseURL` / custom `fetch` on every +provider — covering BYOK, Anthropic's `anthropic-dangerous-direct-browser-access` +header, OpenAI-compatible/local endpoints, and a CORS/proxy escape hatch. No hard +browser guard; streaming, tool-calls, and multimodal file parts are first-class. +Roll provider-by-provider behind `engine.llm='ai-sdk'`; keep a minimal +`fetch`-based adapter (`engine.llm='fetch'`) behind the *same* facade as the +fallback. Per the `ai-sdk` skill, verify every API against `node_modules/ai/docs/` +(current APIs: `ToolLoopAgent`, `tool({ inputSchema })`, `stopWhen: isStepCount(n)`, +`toUIMessageStream`) — not memory. + +**Consequences:** +- Phase 3 deletes ~9 K lines of bespoke providers and adds a thin facade + lazy + adapters — clears the dependency-justification rule (ADR-018). +- Live-key smoke per provider is still required before each `engine.llm='ai-sdk'` + flip (the spike used no BYOK keys; viability was shown by source, bundle, docs, + and DEVS's existing browser usage). +- The `fetch` fallback keeps the bet reversible behind one facade. + +--- + +## ADR-020: Spike B Outcome — DEVS Harness is Option B (thin, DEVS-owned) + +**Date:** Phase 0.5 (spike) + +**Context:** ADR-017 defaults the DEVS Harness to a thin DEVS-owned implementation +(Option B) and adopts the AI SDK `HarnessV1` abstraction (Option A) only if a spike +proves it materially simpler and clears explicit kill criteria. Spike B probed +`@ai-sdk/harness@1.0.36`, `just-bash@3.1.0`, and `@ai-sdk/sandbox-just-bash` with +real browser builds and functional round-trips. Full evidence: +[revamp/spikes/FINDINGS.md](./revamp/spikes/FINDINGS.md). + +**Decision:** **Option B — a thin, DEVS-owned harness.** The three functional +probes passed (HarnessAgent bundles in-browser with only two trivial `fs`/`path` +stubs and no `ws`/`fs`/`net` leakage, ~+22.7 KB gzip marginal; `just-bash` runs a +full bash+virtual-FS round-trip on the already-present `quickjs-emscripten`; a +`ToolLoopAgent` streams one prompt turn into a `readUIMessageStream` consumer). But +Option A **fails a kill criterion**: `HarnessV1Session` declares **seven required +methods**, including the durability primitives `doContinueTurn` / `doSuspendTurn` / +`doDetach` / `doStop` (docs: *"Required on every adapter"*) that exist for process +boundaries, bridge sockets, and cross-slice runtime resumption — problems a +browser tab does not have. Conforming means implementing/stubbing ~40% of the +contract for non-problems, and the packages are experimental. + +**Consequences:** +- Build a thin `DevsHarness` facade in Phase 5A that **reuses** `ai`'s + `ToolLoopAgent` (agent loop), `toUIMessageStream`/`readUIMessageStream` (UI), + and `just-bash` on `quickjs-emscripten` (sandbox) — code that deletes more than + it adds (ADR-018). +- **Do not** take a dependency on `@ai-sdk/harness` or its `HarnessV1` contract; + **borrow its vocabulary** (built-in tool names, `allow-reads/allow-edits/allow-all` + permission modes, `{name,description,content,files}` skill shape) as *types*. +- Browser-only holds; no bridge (`ws`), no Node/cloud sandbox provider. +- Re-evaluate only if `@ai-sdk/harness` later makes the durability methods + optional and stabilises — cheap to revisit because the harness sits behind one + DEVS facade. + +--- + +## ADR-021: Phase 3 Start — AI SDK Behind `LLMService`, OpenAI First Slice + +**Date:** Phase 3 + +**Context:** ADR-019 (GREEN) cleared Phase 3 to back the `LLMService` facade with +the AI SDK, rolled provider-by-provider behind `engine.llm='ai-sdk'`, with a +`fetch` fallback behind the same facade. This ADR records the *pattern* chosen for +the strangle and the first provider slice (OpenAI). + +**Decision:** A **strangler adapter**, not a rewrite of the facade. +`AiSdkProvider` (`src/lib/llm/ai-sdk/adapter.ts`) implements the existing +`LLMProviderInterface`, replacing only `chat`/`streamChat` with `ai`'s +`generateText`/`streamText` (+ `tool`/`jsonSchema` for canonical→AI-SDK tool +mapping), and **delegates** `validateApiKey`/`getAvailableModels` to the tested +legacy provider. A per-provider `AiSdkBinding` builds the model, lazily importing +its `@ai-sdk/*` package. Selection is a one-line seam in registration — +`maybeWrapWithAiSdk(provider, legacy)` — that returns the legacy provider unless +`engine.llm==='ai-sdk'` *and* the provider has a binding. Default flag `legacy`, +so the change is **inert** until per-provider parity is proven. + +Key specifics (verified against `node_modules/ai/docs/`, not memory, per the +`ai-sdk` skill): +- OpenAI uses **`provider.chat(modelId)`** (Chat Completions), not the default + `openai(modelId)` Responses API — parity with the legacy layer and broad + OpenAI-compatible/local `baseURL` support. +- Canonical OpenAI-format tool schemas map via `tool({ inputSchema: jsonSchema(…) })` + with **no `execute`** — the model only *returns* calls; DEVS runs them, keeping + the agent loop intact. The legacy streaming `__TOOL_CALLS__` marker protocol is + preserved. +- `ai`/`@ai-sdk/openai` are **dynamically imported** in the adapter methods and + binding, so flipping the flag adds nothing to the boot graph (Phase 1 invariant; + verified: distinctive `ai` internals absent from boot, +1.3 KB shell delta). + +**Parity gate:** `src/test/lib/llm/ai-sdk/openai-parity.test.ts` drives the legacy +provider and the AI SDK adapter against **byte-identical mocked HTTP responses** +and asserts identical canonical output *and* a match against recorded golden +fixtures (`src/test/fixtures/golden/llm/openai-*`), for basic chat, tool-call +chat, and streamed text. This is what licenses deleting the hand-rolled OpenAI +provider — after the remaining live-key smoke (ADR-019) and a soak. + +**Consequences:** +- The pattern generalises: each further provider (Anthropic, Google, Mistral, + OpenRouter, Ollama, LM Studio, HuggingFace, OpenAI-compatible) is one slice — + add a binding, record fixtures, flip the flag, soak, delete. +- **Bespoke providers** (no `@ai-sdk/*` package) are ported by implementing the + AI SDK `LanguageModelV4` spec directly. First example: **ChatJimmy** + (`src/lib/llm/ai-sdk/chatjimmy.ts`) — an unauthenticated, non-OpenAI endpoint + with raw-text streaming and a `<|stats|>` trailer — now flows through the same + `AiSdkProvider` adapter, parity-gated by `chatjimmy-parity.test.ts`. +- **Adapter detail:** `ai@7` rejects `role:'system'` in `messages`; the adapter + sets `allowSystemInMessages: true` (DEVS system messages are agent-controlled + instructions), preserving legacy wire behaviour and the ChatJimmy + system→`systemPrompt` mapping. +- Special providers (Claude Code / Vertex / Copilot) are decided per-provider + later (keep as thin browser-callable adapters or drop). +- The `fetch` engine value remains reserved for the minimal-adapter fallback; the + current legacy providers already *are* fetch-based, so `legacy` covers it today. + +--- + +## ADR-022: Phase 3 Delete — Hand-Rolled LLM Providers Removed, AI SDK Is the Backend + +**Date:** Phase 3 + +**Context:** ADR-021 introduced the AI SDK behind the `LLMService` facade for one +provider behind a flag. The re-platform's premise (approved) is that the AI SDK +*replaces* the bloated hand-rolled provider layer — keeping it as a flagged +“legacy” fallback defeats the purpose. This ADR records the actual deletion. + +**Decision:** **Delete the hand-rolled provider classes; the AI SDK is the +unconditional backend for standard providers.** Eleven files (~3,900 lines) were +removed — `openai, anthropic, google, mistral, openrouter, ollama, lm-studio, +openai-compatible, custom, huggingface, chatjimmy` — and replaced by a single +generic `AiSdkProvider` (`ai-sdk/adapter.ts`) plus few-line `AiSdkBinding`s +(`ai-sdk/bindings.ts`, ~750 lines total incl. the ChatJimmy custom model). The +`engine.llm` flag no longer selects a backend (vestigial; `'fetch'` reserved). + +Provider-specific behaviour is preserved in the thin bindings (verified against +`@ai-sdk/*/docs`): Anthropic extended thinking (`budgetTokens`/adaptive + +`display:'summarized'`) + effort + browser BYOK header; Google `thinkingConfig` ++ search grounding; reasoning → canonical `thinking`; live model listing / key +validation as thin GETs (Ollama `/api/tags`, OpenAI-style `/models`). + +**Special providers kept** (not standard-API bloat — the AI SDK cannot host them +in-browser): **local** (WebGPU/transformers.js in-tab — the moat), **claude-code** +(local CLI bridge), **vertex-ai** (browser OAuth; the SDK's vertex provider needs +Node `google-auth-library`), **github-copilot** (device-flow token exchange + +model catalog; its OpenAI-compatible *chat* now rides the AI SDK binding, +decoupled from the deleted `OpenAIProvider`). + +**Consequences:** +- First-party LLM code drops ~3,150 lines net; 11 providers' wire protocols are + now the SDK's responsibility — clears the dependency-justification rule + (ADR-018) decisively (net-negative). +- Regression is guarded by golden fixtures recorded from the deleted providers + (`openai-*`, `chatjimmy-*`); the AI SDK path reproduces them. Boot went **down** + (providers were eagerly registered); `ai`/`@ai-sdk/*` internals stay off-boot. +- **Remaining gate:** per-provider **live-key smoke** (ADR-019) — the tests use + mocked responses; real round-trips (Anthropic thinking, Google grounding, + Ollama/LM-Studio, HF router) confirm wire correctness before broad release. +- **No** Node/cloud provider adapters were added; browser-only holds. + +--- + +**Related:** [ARCHITECTURE.md](./ARCHITECTURE.md), [CONVENTIONS.md](./CONVENTIONS.md), [VISION.md](./VISION.md), [revamp/REPORT.md](./revamp/REPORT.md) diff --git a/eslint-rules/core-boundary.js b/eslint-rules/core-boundary.js new file mode 100644 index 00000000..75889af9 --- /dev/null +++ b/eslint-rules/core-boundary.js @@ -0,0 +1,173 @@ +/** + * Custom ESLint plugin: core-boundary + * + * Guards the core/optional and facade boundaries of the "Always-Green + * Strangler" re-platform (REPORT §2.2, §3.2, Phase 0 task "Identify & harden + * the facades + the core/optional boundary"). + * + * Two rules: + * + * 1. `no-enterprise-in-core` — the core browser bundle must never import + * enterprise (`ee/`) control-plane code. Enterprise is a seam that lives on + * the far side of a signed policy, never in core (REPORT §2.2 hard rule). + * + * 2. `no-facade-bypass` — callers must reach a strangled subsystem only through + * its stable facade, never by importing an implementation directly. Today + * this forbids importing concrete LLM providers (`@/lib/llm/providers/*`) + * from anywhere except the `LLMService` facade itself. A small, explicit + * `allow` list grandfathers pre-existing debt so `main` stays green while + * new violations are blocked. + * + * The broader core→optional *feature* boundary (connectors, marketplace) carries + * substantial pre-existing coupling and is enforced by the deterministic + * `scripts/check-boundaries.mjs` burn-down gate instead, so this lint rule can + * stay at `error` with zero false positives. + */ + +// ─── helpers ──────────────────────────────────────────────────────────────── + +/** Normalise a filename to forward slashes for stable matching. */ +function normalise(filename) { + return String(filename).replace(/\\/g, '/') +} + +/** True when the current file is test/spec/fixture code (exempt from bans). */ +function isTestFile(filename) { + const f = normalise(filename) + return ( + f.includes('/src/test/') || + /\.(test|spec)\.(ts|tsx)$/.test(f) || + f.includes('/__tests__/') + ) +} + +function importSource(node) { + return node.source && typeof node.source.value === 'string' + ? node.source.value + : null +} + +// ─── rule: no-enterprise-in-core ──────────────────────────────────────────── + +const noEnterpriseInCore = { + meta: { + type: 'problem', + docs: { + description: + 'Core must not import enterprise (ee/) control-plane modules.', + }, + schema: [ + { + type: 'object', + properties: { + patterns: { type: 'array', items: { type: 'string' } }, + }, + additionalProperties: false, + }, + ], + messages: { + forbidden: + 'Core must not import enterprise module "{{source}}". Enterprise code lives in ee/ behind a signed policy, never in the core bundle (REPORT §2.2).', + }, + }, + create(context) { + const opts = context.options[0] || {} + const patterns = (opts.patterns || ['@/ee', '@ee', 'ee']).map(String) + // Match only as a module prefix (`p` or `p/...`), never as a substring, + // so paths like "coffee/" never false-positive on the "ee" pattern. + const matches = (src) => + patterns.some((p) => src === p || src.startsWith(`${p}/`)) + return { + ImportDeclaration(node) { + const src = importSource(node) + if (!src) return + if (matches(src)) { + context.report({ + node: node.source, + messageId: 'forbidden', + data: { source: src }, + }) + } + }, + } + }, +} + +// ─── rule: no-facade-bypass ───────────────────────────────────────────────── + +const noFacadeBypass = { + meta: { + type: 'problem', + docs: { + description: + 'Reach strangled subsystems through their facade, not a concrete implementation.', + }, + schema: [ + { + type: 'object', + properties: { + facades: { + type: 'array', + items: { + type: 'object', + properties: { + forbidden: { type: 'string' }, + via: { type: 'string' }, + ownerDir: { type: 'string' }, + }, + required: ['forbidden', 'via'], + additionalProperties: false, + }, + }, + allow: { type: 'array', items: { type: 'string' } }, + }, + additionalProperties: false, + }, + ], + messages: { + bypass: + 'Import "{{source}}" bypasses the {{via}} facade. Use {{via}} instead (REPORT §3.2). If this is grandfathered debt, add it to the rule allow-list.', + }, + }, + create(context) { + const opts = context.options[0] || {} + const facades = opts.facades || [] + const allow = (opts.allow || []).map(normalise) + const filename = normalise(context.getFilename()) + + if (isTestFile(filename)) return {} + if (allow.some((a) => filename.endsWith(a))) return {} + + return { + ImportDeclaration(node) { + const src = importSource(node) + if (!src) return + for (const facade of facades) { + const owner = facade.ownerDir ? normalise(facade.ownerDir) : null + // The facade's own implementation files may import implementations. + if (owner && filename.includes(owner)) continue + if (src === facade.forbidden || src.startsWith(`${facade.forbidden}/`)) { + context.report({ + node: node.source, + messageId: 'bypass', + data: { source: src, via: facade.via }, + }) + } + } + }, + } + }, +} + +// ─── plugin export ────────────────────────────────────────────────────────── + +export default { + meta: { + name: 'eslint-plugin-core-boundary', + version: '1.0.0', + }, + rules: { + 'no-enterprise-in-core': noEnterpriseInCore, + 'no-facade-bypass': noFacadeBypass, + }, +} diff --git a/eslint.config.js b/eslint.config.js index 2f7d6d83..f3d05464 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -5,9 +5,20 @@ import reactRefresh from 'eslint-plugin-react-refresh' import tseslint from '@typescript-eslint/eslint-plugin' import tsparser from '@typescript-eslint/parser' import connectorSecurity from './eslint-rules/connector-security.js' +import coreBoundary from './eslint-rules/core-boundary.js' export default [ - { ignores: ['dist', 'coverage', 'playwright-report', 'test-results'] }, + { + ignores: [ + 'dist', + 'coverage', + 'playwright-report', + 'test-results', + // Generated bundle (outDir of vite.extension-components.config.ts) — + // a build artifact, never hand-edited; linting it is meaningless. + 'public/extensions/components', + ], + }, { files: ['**/*.{ts,tsx}'], languageOptions: { @@ -48,6 +59,17 @@ export default [ 'react-hooks/exhaustive-deps': 'off', // Too many false positives with intentional exclusions 'react-hooks/rules-of-hooks': 'off', // Has false positives in this codebase 'react-refresh/only-export-components': 'off', // Not critical for this project + // Preserve intentional typographic whitespace (e.g. French narrow + // no-break spaces) inside i18n template literals/strings/JSX text. + 'no-irregular-whitespace': [ + 'error', + { + skipStrings: true, + skipTemplates: true, + skipComments: true, + skipJSXText: true, + }, + ], }, }, // Connector security rules — scoped to connector feature files only @@ -61,4 +83,33 @@ export default [ 'connector-security/require-error-sanitization': 'warn', }, }, + // Core/optional + facade boundary rules (re-platform, REPORT §2.2/§3.2). + // Scoped to src/** (the core PWA); ee/ lives outside this tree entirely. + { + files: ['src/**/*.{ts,tsx}'], + plugins: { + 'core-boundary': coreBoundary, + }, + rules: { + 'core-boundary/no-enterprise-in-core': 'error', + 'core-boundary/no-facade-bypass': [ + 'error', + { + facades: [ + { + forbidden: '@/lib/llm/providers', + via: 'the LLMService facade (@/lib/llm)', + ownerDir: 'src/lib/llm', + }, + ], + // Grandfathered facade-bypass debt — burn down, never grow. + // Tracked in docs/revamp/BOUNDARIES.md. + allow: [ + 'src/components/PromptArea/ModelSelector.tsx', + 'src/components/LocalLLMLoadingIndicator.tsx', + ], + }, + ], + }, + }, ] diff --git a/package-lock.json b/package-lock.json index e8192396..ed44eed5 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,6 +9,12 @@ "version": "0.8.0", "license": "MIT", "dependencies": { + "@ai-sdk/anthropic": "^4.0.16", + "@ai-sdk/google": "^4.0.18", + "@ai-sdk/mistral": "^4.0.13", + "@ai-sdk/openai": "^4.0.16", + "@ai-sdk/openai-compatible": "^3.0.12", + "@ai-sdk/provider": "^4.0.3", "@heroui/react": "^2.8.7", "@heroui/react_3": "npm:@heroui/react@^3.0.5", "@heroui/styles": "^3.0.1", @@ -18,6 +24,7 @@ "@monaco-editor/react": "^4.7.0", "@y/websocket-server": "^0.1.1", "abcjs": "^6.6.0", + "ai": "^7.0.31", "clsx": "^2.1.1", "date-fns": "^4.1.0", "echarts": "^6.0.0", @@ -155,6 +162,133 @@ "react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1" } }, + "node_modules/@ai-sdk/anthropic": { + "version": "4.0.16", + "resolved": "https://registry.npmjs.org/@ai-sdk/anthropic/-/anthropic-4.0.16.tgz", + "integrity": "sha512-vyH4D6Auih5H2xvVzzh2ep5pbdWiaV7JDC+jHUE7zZJ5Kyv0TteLav4DrOgHzRuyv8ptfUSqFF6Y8//f/Ec0fQ==", + "license": "Apache-2.0", + "dependencies": { + "@ai-sdk/provider": "4.0.3", + "@ai-sdk/provider-utils": "5.0.11" + }, + "engines": { + "node": ">=22" + }, + "peerDependencies": { + "zod": "^3.25.76 || ^4.1.8" + } + }, + "node_modules/@ai-sdk/gateway": { + "version": "4.0.23", + "resolved": "https://registry.npmjs.org/@ai-sdk/gateway/-/gateway-4.0.23.tgz", + "integrity": "sha512-f85diFdPMXYJpxCjOYZchMQkRH8h3r6lhK4Q2xmzJ7UA2OQ80L3W7tFu61742xGQK7zHWm5AhxYhNuc50H9SGQ==", + "license": "Apache-2.0", + "dependencies": { + "@ai-sdk/provider": "4.0.3", + "@ai-sdk/provider-utils": "5.0.11", + "@vercel/oidc": "3.2.0" + }, + "engines": { + "node": ">=22" + }, + "peerDependencies": { + "zod": "^3.25.76 || ^4.1.8" + } + }, + "node_modules/@ai-sdk/google": { + "version": "4.0.18", + "resolved": "https://registry.npmjs.org/@ai-sdk/google/-/google-4.0.18.tgz", + "integrity": "sha512-NRbXRAasXLgFLiZDTsDUiTUuQtEUDVZoqruB5Px3geltTEqOzOo2eHN5WnDp0/OgxwnrNH4olV/TVetza0PzmQ==", + "license": "Apache-2.0", + "dependencies": { + "@ai-sdk/provider": "4.0.3", + "@ai-sdk/provider-utils": "5.0.11" + }, + "engines": { + "node": ">=22" + }, + "peerDependencies": { + "zod": "^3.25.76 || ^4.1.8" + } + }, + "node_modules/@ai-sdk/mistral": { + "version": "4.0.13", + "resolved": "https://registry.npmjs.org/@ai-sdk/mistral/-/mistral-4.0.13.tgz", + "integrity": "sha512-VYEWPEY6MhhvUKE0Huu63NJXowu2/nbbO60kE6/sdV9mLTk36PahQ7lNZFkJ2XJQ3eUy19RRWQDqYmyxKMiPlA==", + "license": "Apache-2.0", + "dependencies": { + "@ai-sdk/provider": "4.0.3", + "@ai-sdk/provider-utils": "5.0.11" + }, + "engines": { + "node": ">=22" + }, + "peerDependencies": { + "zod": "^3.25.76 || ^4.1.8" + } + }, + "node_modules/@ai-sdk/openai": { + "version": "4.0.16", + "resolved": "https://registry.npmjs.org/@ai-sdk/openai/-/openai-4.0.16.tgz", + "integrity": "sha512-Yh+PsXaf9NbN7oA3oKwOuyjTiHMPD75phf3SqGbDNNKQ3Yj3oTntp/WhO3nCHIPA6gr5/1lyridQokmOpPf9oQ==", + "license": "Apache-2.0", + "dependencies": { + "@ai-sdk/provider": "4.0.3", + "@ai-sdk/provider-utils": "5.0.11" + }, + "engines": { + "node": ">=22" + }, + "peerDependencies": { + "zod": "^3.25.76 || ^4.1.8" + } + }, + "node_modules/@ai-sdk/openai-compatible": { + "version": "3.0.12", + "resolved": "https://registry.npmjs.org/@ai-sdk/openai-compatible/-/openai-compatible-3.0.12.tgz", + "integrity": "sha512-tN9BUb4jUGjqtbRPUsziLxASfogLNICcq/Qrwr55vpnzKvprV6Ukl8ynED2lZaNrAHU5U4zlGnyU/iuYrjQK6g==", + "license": "Apache-2.0", + "dependencies": { + "@ai-sdk/provider": "4.0.3", + "@ai-sdk/provider-utils": "5.0.11" + }, + "engines": { + "node": ">=22" + }, + "peerDependencies": { + "zod": "^3.25.76 || ^4.1.8" + } + }, + "node_modules/@ai-sdk/provider": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/@ai-sdk/provider/-/provider-4.0.3.tgz", + "integrity": "sha512-e0CpNWJUY7OxAFAnCZkw+ri9QOHWwTs1tXP42782KFGCU07qt8NiXCrCVowyCB5dP2r5/Uls+g2oPd8kOJn9dw==", + "license": "Apache-2.0", + "dependencies": { + "json-schema": "^0.4.0" + }, + "engines": { + "node": ">=22" + } + }, + "node_modules/@ai-sdk/provider-utils": { + "version": "5.0.11", + "resolved": "https://registry.npmjs.org/@ai-sdk/provider-utils/-/provider-utils-5.0.11.tgz", + "integrity": "sha512-7/96wE+ZsKB35iS9ASyllrE4Ym/EolXEB7AkuJ5FI++fmS85BVTAs77890C+1Z2jwHfBKjBQSBmsliOsAh0iFQ==", + "license": "Apache-2.0", + "dependencies": { + "@ai-sdk/provider": "4.0.3", + "@standard-schema/spec": "^1.1.0", + "@workflow/serde": "4.1.0", + "eventsource-parser": "^3.0.8" + }, + "engines": { + "node": ">=22" + }, + "peerDependencies": { + "zod": "^3.25.76 || ^4.1.8" + } + }, "node_modules/@alloc/quick-lru": { "version": "5.2.0", "resolved": "https://registry.npmjs.org/@alloc/quick-lru/-/quick-lru-5.2.0.tgz", @@ -6857,6 +6991,12 @@ "react-dom": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1" } }, + "node_modules/@standard-schema/spec": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz", + "integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==", + "license": "MIT" + }, "node_modules/@swc/helpers": { "version": "0.5.18", "resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.18.tgz", @@ -8117,6 +8257,15 @@ "d3-transition": "^3.0.1" } }, + "node_modules/@vercel/oidc": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/@vercel/oidc/-/oidc-3.2.0.tgz", + "integrity": "sha512-UycprH3T6n3jH0k44NHMa7pnFHGu/N05MjojYr+Mc6I7obkoLIJujSWwin1pCvdy/eOxrI/l3uDLQsmcrOb4ug==", + "license": "Apache-2.0", + "engines": { + "node": ">= 20" + } + }, "node_modules/@vitejs/plugin-basic-ssl": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/@vitejs/plugin-basic-ssl/-/plugin-basic-ssl-2.1.0.tgz", @@ -8300,6 +8449,12 @@ "url": "https://opencollective.com/vitest" } }, + "node_modules/@workflow/serde": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/@workflow/serde/-/serde-4.1.0.tgz", + "integrity": "sha512-pav4F2BoirECWR7Nf1TKt+2eETcBj7jj4cBefQ8VXQCA6NPkaKeLfj/zMgi+3zYV5ZIBT4GuUiphsj0/b9hPQQ==", + "license": "Apache-2.0" + }, "node_modules/@xmldom/xmldom": { "version": "0.8.13", "resolved": "https://registry.npmjs.org/@xmldom/xmldom/-/xmldom-0.8.13.tgz", @@ -8443,6 +8598,23 @@ "node": ">= 14" } }, + "node_modules/ai": { + "version": "7.0.31", + "resolved": "https://registry.npmjs.org/ai/-/ai-7.0.31.tgz", + "integrity": "sha512-pJfwKXjF5kw0rKRTePwYo60EfWb8wfzJAgf3ojln/YkOsVVKttzZAJVcRPsg37Z3a06ZdKkxX+DSrMAFlPm5Mw==", + "license": "Apache-2.0", + "dependencies": { + "@ai-sdk/gateway": "4.0.23", + "@ai-sdk/provider": "4.0.3", + "@ai-sdk/provider-utils": "5.0.11" + }, + "engines": { + "node": ">=22" + }, + "peerDependencies": { + "zod": "^3.25.76 || ^4.1.8" + } + }, "node_modules/ajv": { "version": "6.14.0", "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.14.0.tgz", @@ -10970,6 +11142,15 @@ "bare-events": "^2.7.0" } }, + "node_modules/eventsource-parser": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/eventsource-parser/-/eventsource-parser-3.1.0.tgz", + "integrity": "sha512-kJezFj9YFAMLeORyi7aCLxLbD5/qWMQnoMVlVPyHIll7lgRJCc3JVln9Vgl9nwQi0YkMnhdGTMNn7CkRRAptMg==", + "license": "MIT", + "engines": { + "node": ">=18.0.0" + } + }, "node_modules/expect-type": { "version": "1.2.2", "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.2.2.tgz", @@ -12235,6 +12416,12 @@ "dev": true, "license": "MIT" }, + "node_modules/json-schema": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/json-schema/-/json-schema-0.4.0.tgz", + "integrity": "sha512-es94M3nTIfsEPisRafak+HDLfHXnKBhV3vU5eqPcS3flIWqcxJWgXHXiey3YrpaNsanY5ei1VoYEbOzijuq9BA==", + "license": "(AFL-2.1 OR BSD-3-Clause)" + }, "node_modules/json-schema-traverse": { "version": "0.4.1", "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", @@ -19412,7 +19599,6 @@ "version": "3.25.76", "resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz", "integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==", - "dev": true, "license": "MIT", "funding": { "url": "https://github.com/sponsors/colinhacks" diff --git a/package.json b/package.json index 0b7acae2..b31c35f7 100644 --- a/package.json +++ b/package.json @@ -27,10 +27,19 @@ "test:e2e:smoke": "playwright test tests/e2e/core-user-journeys.spec.ts", "test:e2e:report": "playwright show-report", "lint": "eslint . --ext ts,tsx --report-unused-disable-directives --max-warnings 0", + "check:boundaries": "node scripts/check-boundaries.mjs", + "check:budgets": "node scripts/check-budgets.mjs", "format": "prettier --write .", "scripts": "./src/scripts/prepare-config-files.js" }, "dependencies": { + "@ai-sdk/anthropic": "^4.0.16", + "@ai-sdk/google": "^4.0.18", + "@ai-sdk/mistral": "^4.0.13", + "@ai-sdk/openai": "^4.0.16", + "@ai-sdk/openai-compatible": "^3.0.12", + "@ai-sdk/provider": "^4.0.3", + "@ai-sdk/provider-utils": "^5.0.11", "@heroui/react": "^2.8.7", "@heroui/react_3": "npm:@heroui/react@^3.0.5", "@heroui/styles": "^3.0.1", @@ -40,6 +49,7 @@ "@monaco-editor/react": "^4.7.0", "@y/websocket-server": "^0.1.1", "abcjs": "^6.6.0", + "ai": "^7.0.31", "clsx": "^2.1.1", "date-fns": "^4.1.0", "echarts": "^6.0.0", diff --git a/scripts/check-boundaries.mjs b/scripts/check-boundaries.mjs new file mode 100644 index 00000000..b902adf1 --- /dev/null +++ b/scripts/check-boundaries.mjs @@ -0,0 +1,141 @@ +#!/usr/bin/env node +/** + * check-boundaries.mjs — deterministic core→optional import gate (REPORT §2.2). + * + * The core browser PWA must not grow new dependencies on optional feature + * layers (connectors, marketplace, …). Those layers carry substantial + * pre-existing coupling that the re-platform burns down over Phases 4/6, so a + * hard "zero violations" rule would block `main` today. Instead this gate + * grandfathers the *current* violations in an allow-list and fails only when a + * NEW core→optional import appears — the strangler's ratchet. + * + * Exit codes: 0 = ok · 1 = new violation(s) · 2 = usage/IO error. + * + * Flags: + * --update Rewrite the allow-list from the current violations (review the + * diff!). Use only when intentionally adding/removing coupling. + */ + +import { promises as fs } from 'node:fs' +import * as path from 'node:path' +import { fileURLToPath } from 'node:url' + +const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..') +const SRC = path.join(ROOT, 'src') +const ALLOWLIST = path.join(ROOT, 'docs', 'revamp', 'boundary-allowlist.json') + +/** Optional feature layers guarded against core imports. */ +const OPTIONAL = ['connectors', 'marketplace', 'meeting-bot'] +const OPTIONAL_RE = new RegExp(`@/features/(${OPTIONAL.join('|')})(?:/|')`) + +/** A file is "optional" (exempt) if it lives inside an optional layer. */ +function isOptionalFile(rel) { + return OPTIONAL.some((o) => rel.startsWith(`src/features/${o}/`)) +} +function isTestFile(rel) { + return ( + rel.startsWith('src/test/') || + /\.(test|spec)\.(ts|tsx)$/.test(rel) || + rel.includes('/__tests__/') + ) +} + +async function walk(dir) { + const out = [] + for (const entry of await fs.readdir(dir, { withFileTypes: true })) { + const full = path.join(dir, entry.name) + if (entry.isDirectory()) out.push(...(await walk(full))) + else if (/\.(ts|tsx)$/.test(entry.name)) out.push(full) + } + return out +} + +function importedOptionalLayers(source) { + const layers = new Set() + const re = new RegExp(OPTIONAL_RE.source, 'g') + let m + while ((m = re.exec(source))) layers.add(m[1]) + return [...layers] +} + +async function collectViolations() { + const files = await walk(SRC) + const violations = {} + for (const file of files) { + const rel = path.relative(ROOT, file).replace(/\\/g, '/') + if (isOptionalFile(rel) || isTestFile(rel)) continue + const layers = importedOptionalLayers(await fs.readFile(file, 'utf-8')) + if (layers.length) violations[rel] = layers.sort() + } + return violations +} + +async function loadAllowlist() { + try { + return JSON.parse(await fs.readFile(ALLOWLIST, 'utf-8')) + } catch (err) { + if (err.code === 'ENOENT') return { grandfathered: {} } + throw err + } +} + +async function main() { + const update = process.argv.includes('--update') + const violations = await collectViolations() + const total = Object.keys(violations).length + + if (update) { + const doc = { + _comment: + 'Grandfathered core→optional import debt (REPORT §2.2). Burn down over Phases 4/6; never add new entries by hand — run scripts/check-boundaries.mjs --update only when intentionally changing coupling.', + generatedAt: new Date().toISOString().slice(0, 10), + grandfathered: violations, + } + await fs.mkdir(path.dirname(ALLOWLIST), { recursive: true }) + await fs.writeFile(ALLOWLIST, `${JSON.stringify(doc, null, 2)}\n`, 'utf-8') + console.log(`Updated allow-list: ${total} grandfathered core→optional file(s).`) + return 0 + } + + const allow = (await loadAllowlist()).grandfathered || {} + const added = [] + const cleared = [] + + for (const [file, layers] of Object.entries(violations)) { + const known = allow[file] || [] + const novel = layers.filter((l) => !known.includes(l)) + if (novel.length) added.push(`${file} → ${novel.join(', ')}`) + } + for (const file of Object.keys(allow)) { + if (!violations[file]) cleared.push(file) + } + + console.log( + `Core→optional imports: ${total} file(s) (grandfathered budget ${Object.keys(allow).length}).`, + ) + if (cleared.length) { + console.log( + `\n✅ ${cleared.length} file(s) no longer violate — trim the allow-list:\n ` + + cleared.join('\n '), + ) + } + if (added.length) { + console.error( + `\n❌ NEW core→optional import(s) — core must not grow this coupling (REPORT §2.2):\n ` + + added.join('\n ') + + `\n\nMove the code behind the tool/connector facade, or (if truly intentional) ` + + `run: node scripts/check-boundaries.mjs --update`, + ) + return 1 + } + console.log('✅ No new core→optional imports.') + return 0 +} + +main().then( + (code) => process.exit(code), + (err) => { + console.error(err) + process.exit(2) + }, +) diff --git a/scripts/check-budgets.mjs b/scripts/check-budgets.mjs new file mode 100644 index 00000000..1783405f --- /dev/null +++ b/scripts/check-budgets.mjs @@ -0,0 +1,179 @@ +#!/usr/bin/env node +/** + * check-budgets.mjs — universal performance budgets as a hard CI gate + * (REPORT §Phase 0 "Universal budgets", §5 metrics). + * + * "Universal" is a slogan without enforced budgets. This gate runs AFTER + * `npm run build` and enforces, against dist/: + * + * 1. Initial-shell JS budget — total gzipped bytes of the boot chunk graph + * (the entry