diff --git a/.woodpecker/ci-image.yml b/.woodpecker/ci-image.yml index c89e24ac..9d40b903 100644 --- a/.woodpecker/ci-image.yml +++ b/.woodpecker/ci-image.yml @@ -22,9 +22,9 @@ steps: image: gcr.io/kaniko-project/executor:debug environment: REGISTRY_USER: - from_secret: gitea_username + from_secret: REGISTRY_USERNAME REGISTRY_PASS: - from_secret: gitea_password + from_secret: REGISTRY_PASSWORD CI_COMMIT_BRANCH: ${CI_COMMIT_BRANCH} CI_COMMIT_TAG: ${CI_COMMIT_TAG} CI_COMMIT_SHA: ${CI_COMMIT_SHA} diff --git a/.woodpecker/ci.yml b/.woodpecker/ci.yml index d0ce1c1f..5427677a 100644 --- a/.woodpecker/ci.yml +++ b/.woodpecker/ci.yml @@ -2,16 +2,38 @@ # node:24-alpine + python3/make/g++/postgresql-client + pnpm + a warm pnpm # store. The install step resolves from the baked store (--prefer-offline) # instead of paying a ~731s cold fetch + native compile every run. +# +# PINNED to an immutable lock-tag (#1328, brain D27): ci-image.yml pushes +# lock- atomically with :latest, so the two are +# byte-identical at push time. A mutable :latest resolves per-pod at pull time +# on the k8s backend, which made CI verdicts non-reproducible (same tree, same +# config, different images across runs; see #1324 comment 23382/23386). The pin +# changes ONLY through reviewed commits; a wrong tag fails loudly at image pull. +# +# Bump procedure: when a recipe change (pnpm-lock.yaml / Dockerfile.ci) lands on +# main, ci-image.yml pushes lock-; a follow-up PR updates this anchor. +# Until then pipelines keep the old pin: reproducible, with the documented +# network-fallback lag (frozen-lockfile resolves missing packages from network). +# Known limitation: lock- addresses the lockfile only, so a Dockerfile-only +# change re-pushes the same tag with new content (#1328 follow-up: recipe-hash). variables: - - &node_image 'git.mosaicstack.dev/mosaicstack/stack/ci-base:latest' + - &node_image 'git.mosaicstack.dev/mosaicstack/stack/ci-base:lock-9cb7ffcd8828' - &enable_pnpm 'corepack enable' when: - # PR + manual CI run on any branch — the pull_request pipeline is the merge gate. - # push CI is restricted to protected branches (main) so a feature-branch push no - # longer fires a redundant SECOND pipeline alongside its PR pipeline. This ~halves - # CI load on the storage-constrained runner with zero loss of gating (branch - # protection requires no push/ci status context; main still gets full push CI). + # PR + manual CI run on any branch: the pull_request pipeline is the merge + # gate (next is protected and the default branch since 2026-08-19). + # Push CI runs on main only. next deliberately runs NO push ci: post-merge + # verification on next is carried by publish.yml's `verify` step + # (pnpm verify:release), which mirrors this pipeline's complete mandatory + # set step-for-step, enforced by scripts/verify-release.test.mjs. PR CI + # tests the PR HEAD tree (refs/pull/N/head, measured 2026-08-19), not a + # merge ref, so if next advances before a merge the landed tree differs + # from the tested one; publish verify re-runs the full set on the landed + # tree (PGlite path). Measured 2026-08-19: the 21 most recent push events + # on next each ran exactly one pipeline (publish), zero ci. + # Keeping push ci off next also avoids a redundant second full-suite run + # per merge on the storage-constrained runner. - event: [pull_request, manual] - event: push branch: main @@ -30,6 +52,19 @@ steps: # the baked pnpm store. - pnpm install --frozen-lockfile --prefer-offline + # --------------------------------------------------------------------------- + # The steps below (sanitization, upgrade-guard, typecheck, lint, format, + # test) are the COMPLETE mandatory verification set. SDLC-D-034 mirrors them + # one-for-one in the canonical terminal verification command — root + # `pnpm verify:release` (scripts/verify-release.mjs) — which the publish + # pipeline (.woodpecker/publish.yml `verify` step) runs before ANY publish + # effect. These lines stay direct (not routed through the runner) because the + # #1017 test-enumeration guard audits framework tool paths through THIS + # surface; scripts/verify-release.test.mjs enforces that the runner's stage + # table keeps matching these commands exactly, so the two cannot drift. + # --------------------------------------------------------------------------- + + # Canonical verify:release stage `sanitization`. # Blocking gate: public framework package must contain no operator-specific # personal data or private $HOME defaults. Runs early (no node_modules needed). sanitization: @@ -46,7 +81,30 @@ steps: # [0] of the pnpm chain, so severing that chain would silence it together # with everything it guards; this direct line keeps one instrument running. - bash packages/mosaic/framework/tools/quality/scripts/check-test-enumeration.sh + # Tool-index gate: a shipped wrapper that appears in no resident index doc + # is undiscoverable from inside a session, and an agent that cannot learn a + # wrapper exists reaches for raw curl instead — which is how a Gitea review + # got filed PENDING three times. Ships-and-documented is one commit, or red. + - bash packages/mosaic/framework/tools/quality/scripts/check-tools-index.sh --self-test + - bash packages/mosaic/framework/tools/quality/scripts/check-tools-index.sh + # Hermetic regression for issue-close.sh (#1081): mocks tea/curl onto PATH + # and sandboxes a throwaway git repo, so it resolves no real credentials and + # joins CI directly rather than the exclusions file. + - bash packages/mosaic/framework/tools/git/test-issue-close-fail-closed.sh + # Hermetic behavioural regression for the PreToolUse wrapper guard: proves + # it still blocks the three mistakes AND still lets reads, unwrapped + # endpoints and ordinary commands through. Both directions are asserted — + # a guard that over-blocks gets routed around, which fails just as hard. + - bash packages/mosaic/framework/tools/git/test-wrapper-guard.sh + # Hermetic regression for mosaic-worktree.sh at fleet scale: stubs git onto + # PATH so `list` faces ~450 KB of porcelain. The defect it pins is invisible + # at small size — `git … | awk '…exit'` gives the producer SIGPIPE, which + # under `set -euo pipefail` aborts the caller silently with rc=141 and no + # output. A repo only reaches that once it has enough worktrees, so the + # stub supplies the scale instead of the host's own checkout. + - bash packages/mosaic/framework/tools/git/test-mosaic-worktree-large-repo.sh + # Canonical verify:release stage `upgrade-guard`. # Blocking gate (#791): a framework upgrade must never write or delete an # operator-owned path. The HARD GATE proves an unanticipated operator sentinel # survives a keep-mode reseed byte-identical (with rsync present AND absent — @@ -68,6 +126,8 @@ steps: - bash packages/mosaic/framework/tools/quality/scripts/test-upgrade-durable-snapshot.sh - bash packages/mosaic/framework/tools/quality/scripts/test-install-migration.sh + # Canonical verify:release stage `typecheck` — the same `pnpm typecheck` + # invocation (which runs the checkout preflight first, then turbo). typecheck: image: *node_image commands: @@ -78,7 +138,8 @@ steps: - sanitization - upgrade-guard - # lint, format, and test are independent — run in parallel after typecheck + # lint, format, and test are independent — run in parallel after typecheck. + # Each runs exactly its canonical verify:release stage command. lint: image: *node_image commands: @@ -95,6 +156,12 @@ steps: depends_on: - typecheck + # Canonical verify:release stage `test` — the `pnpm test` line below is the + # shared command; everything else in this step is PIPELINE-LEVEL + # prerequisite the canonical command expects its caller to provide (SDLC-D-034): + # the ci-postgres service + pg_isready wait + db:migrate (postgres path), + # `apk add openssl`, and the pinned pi install. None of those can move into + # the runner (it must also work locally on the PGlite path with no database). test: image: *node_image environment: diff --git a/.woodpecker/publish.yml b/.woodpecker/publish.yml index cce35805..418ab21f 100644 --- a/.woodpecker/publish.yml +++ b/.woodpecker/publish.yml @@ -1,10 +1,29 @@ # Build, publish npm packages, and push Docker images # Runs on main for stable publishes and on next for integration-line prereleases/images +# +# SDLC-D-034 publish gate: every publish effect (publish-npm, publish-next-npm, +# and every image build/push step) depends DIRECTLY on the `verify` step below. +# `verify` (a) asserts the provider's commit identity matches the actual +# checkout (CI_COMMIT_SHA == git rev-parse HEAD, fail closed on mismatch or +# emptiness) and (b) runs the canonical terminal verification command +# (`pnpm verify:release`), which mirrors the PR CI pipeline's complete +# mandatory set (sanitization, upgrade-guard, preflight+typecheck, lint, +# format:check, test, build) — see scripts/verify-release.mjs. A missing, +# failed, skipped, cancelled, or inconclusive verification therefore skips the +# dependent publish effects (fail closed). Path-filtered short-circuits may +# skip publish EFFECTS (e.g. docs-only merges) but never bypass `verify` for a +# publish that does run: `verify` itself carries no path filter. +# scripts/verify-release.test.mjs enforces this DAG invariant at checkout time. variables: # Pre-baked CI base (see .woodpecker/ci-image.yml): node:24-alpine + # toolchain + warm pnpm store. Kills the second cold install publish pays. - - &node_image 'git.mosaicstack.dev/mosaicstack/stack/ci-base:latest' + # PINNED to the immutable lock-tag, not :latest (#1328, brain D27): a mutable + # tag resolves per-pod at pull time on the k8s backend and made CI verdicts + # non-reproducible (#1324). Byte-identical to :latest at pin time (pushed + # atomically by the same kaniko run, main 712c770, 2026-07-26). Bump only via + # reviewed PR, per the procedure in .woodpecker/ci.yml's header comment. + - &node_image 'git.mosaicstack.dev/mosaicstack/stack/ci-base:lock-9cb7ffcd8828' - &enable_pnpm 'corepack enable' # Heavy kaniko image builds (~25 min) — gate them so a merge that only touches # the npm-only CLI (@mosaicstack/mosaic) or docs does NOT rebuild the platform @@ -48,6 +67,45 @@ steps: # Resolve from the baked pnpm store instead of a cold network fetch. - pnpm install --frozen-lockfile --prefer-offline + # SDLC-D-034 exact-commit publish gate. No `when`/path filter on purpose: it + # runs for every event this pipeline serves so no publish effect can ever + # start without it. Fails closed on commit-identity mismatch (or either SHA + # being empty) and on any incomplete verification. + verify: + image: *node_image + commands: + - *enable_pnpm + # (a) Commit identity: the provider's claimed SHA must equal the actual + # checkout HEAD — verification of anything else must never authorize a + # publish of this commit. + - | + if [ -z "$CI_COMMIT_SHA" ]; then + echo "[verify] FATAL: CI_COMMIT_SHA is empty — cannot certify commit identity" >&2 + exit 1 + fi + CHECKOUT_SHA="$(git rev-parse HEAD 2>/dev/null || true)" + if [ -z "$CHECKOUT_SHA" ]; then + echo "[verify] FATAL: git rev-parse HEAD returned nothing — cannot certify commit identity" >&2 + exit 1 + fi + if [ "$CI_COMMIT_SHA" != "$CHECKOUT_SHA" ]; then + echo "[verify] FATAL: provider commit ($CI_COMMIT_SHA) != checkout HEAD ($CHECKOUT_SHA)" >&2 + exit 1 + fi + echo "[verify] commit identity confirmed: $CHECKOUT_SHA" + # (b) Canonical terminal verification. Caller-provided prerequisites the + # runner expects (see .woodpecker/ci.yml comments): bash/rsync for the + # guard stages, openssl + the pinned pi binary for the test stage. git is + # baked into ci-base but re-asserted here so the identity check above can + # never silently depend on a stale baked image. DATABASE_URL is + # deliberately NOT set: the canonical command must hold on the PGlite + # path too and never sets or requires a database itself. + - apk add --no-cache bash rsync openssl git + - npm install -g @earendil-works/pi-coding-agent@0.84.1 + - pnpm verify:release + depends_on: + - install + build: image: *node_image commands: @@ -55,6 +113,7 @@ steps: - pnpm build depends_on: - install + - verify publish-npm: image: *node_image @@ -114,6 +173,7 @@ steps: exit 1 depends_on: - build + - verify publish-next-npm: image: *node_image @@ -192,6 +252,7 @@ steps: echo "[publish-next] @mosaicstack/mosaic@next resolves to $RESOLVED_VERSION" depends_on: - build + - verify # TODO: Uncomment when ready to publish to npmjs.org # publish-npmjs: @@ -205,6 +266,7 @@ steps: # - bash scripts/publish-npmjs.sh # depends_on: # - build + # - verify # when: # - event: [tag] @@ -213,9 +275,9 @@ steps: when: *image_build_when environment: REGISTRY_USER: - from_secret: gitea_username + from_secret: REGISTRY_USERNAME REGISTRY_PASS: - from_secret: gitea_password + from_secret: REGISTRY_PASSWORD CI_COMMIT_BRANCH: ${CI_COMMIT_BRANCH} CI_COMMIT_TAG: ${CI_COMMIT_TAG} CI_COMMIT_SHA: ${CI_COMMIT_SHA} @@ -242,15 +304,16 @@ steps: /kaniko/executor --context . --dockerfile docker/gateway.Dockerfile $DESTINATIONS depends_on: - build + - verify build-appservice: image: gcr.io/kaniko-project/executor:debug when: *main_image_build_when environment: REGISTRY_USER: - from_secret: gitea_username + from_secret: REGISTRY_USERNAME REGISTRY_PASS: - from_secret: gitea_password + from_secret: REGISTRY_PASSWORD CI_COMMIT_BRANCH: ${CI_COMMIT_BRANCH} CI_COMMIT_TAG: ${CI_COMMIT_TAG} CI_COMMIT_SHA: ${CI_COMMIT_SHA} @@ -268,15 +331,16 @@ steps: /kaniko/executor --context . --dockerfile docker/appservice.Dockerfile $DESTINATIONS depends_on: - build + - verify build-web: image: gcr.io/kaniko-project/executor:debug when: *main_image_build_when environment: REGISTRY_USER: - from_secret: gitea_username + from_secret: REGISTRY_USERNAME REGISTRY_PASS: - from_secret: gitea_password + from_secret: REGISTRY_PASSWORD CI_COMMIT_BRANCH: ${CI_COMMIT_BRANCH} CI_COMMIT_TAG: ${CI_COMMIT_TAG} CI_COMMIT_SHA: ${CI_COMMIT_SHA} @@ -294,3 +358,4 @@ steps: /kaniko/executor --context . --dockerfile docker/web.Dockerfile $DESTINATIONS depends_on: - build + - verify diff --git a/README.md b/README.md index ff7d5c58..07acf1be 100644 --- a/README.md +++ b/README.md @@ -74,6 +74,14 @@ The launcher verifies your config, checks for `SOUL.md`, injects your `AGENTS.md Pi launches default to a token-lean skill posture: `mosaic pi` passes `--no-skills` so Pi does not preload every global skill description into the system prompt. Use `MOSAIC_PI_SKILL_MODE=all mosaic pi` for the legacy all-skills catalog, or `MOSAIC_PI_SKILL_MODE=discover mosaic pi` to let Pi use its native settings/project skill discovery. +Mosaic also loads its Pi extensions from `~/.config/mosaic/runtime/pi/`. Inside Pi, +`/goal set ` starts a bounded persistent loop that checks every turn and successful +compaction, requires two evidence-bearing completion reports, and can be inspected or stopped with +`/goal status`, `/goal pause`, `/goal resume`, and `/goal cancel`. Controller-owned goal-state +entries redact common credential shapes, but Pi's model/tool-call history is separate, so goals and +evidence must never contain secrets or raw sensitive output. Mosaic does not install this extension +into `~/.pi/agent/extensions/`. + ### TUI & Gateway ```bash @@ -138,9 +146,9 @@ mosaic brain tasks mosaic brain conversations # Agent forge pipeline -mosaic forge run +mosaic forge run [--simulate] # fails closed (FORGE_NO_EXECUTOR) with no executor wired; --simulate for typed simulated runs mosaic forge status -mosaic forge resume +mosaic forge resume [--simulate] # same fail-closed rule as forge run mosaic forge personas # Structured logging @@ -331,7 +339,7 @@ The framework is the bash-based standards layer installed to every developer mac ├── bin/mosaic ← Unified launcher (claude, codex, opencode, pi, yolo) ├── guides/ ← E2E delivery, orchestrator protocol, PRD, etc. ├── runtime/ ← Per-runtime configs (claude/, codex/, opencode/, pi/) -├── skills/ ← Universal skills (synced from agent-skills repo) +├── skills/ ← Universal skills (shipped with the framework package) ├── tools/ ← Tool suites (orchestrator, git, quality, prdy, etc.) └── memory/ ← Persistent agent memory (preserved across upgrades) ``` diff --git a/apps/gateway/src/__tests__/cross-user-isolation.test.ts b/apps/gateway/src/__tests__/cross-user-isolation.test.ts index 439b750e..781aba79 100644 --- a/apps/gateway/src/__tests__/cross-user-isolation.test.ts +++ b/apps/gateway/src/__tests__/cross-user-isolation.test.ts @@ -190,7 +190,13 @@ beforeEach((ctx) => { }); afterAll(async () => { - if (!handle) return; + // Cleanup only when the fixture actually installed rows. `handle` is set + // before the first query (createDb connects lazily), so on an unreachable + // database `handle` is truthy while nothing was inserted — cleanup must + // honor `dbAvailable` or the skip path fails the file with ECONNREFUSED in + // afterAll (caught live by the publish pipeline's no-DATABASE_URL verify + // step, pipeline 2486). + if (!handle || !dbAvailable) return; const db = handle.db; // Delete in dependency order (FK constraints) diff --git a/apps/gateway/src/__tests__/required-security-wiring.test.ts b/apps/gateway/src/__tests__/required-security-wiring.test.ts new file mode 100644 index 00000000..3decf9d3 --- /dev/null +++ b/apps/gateway/src/__tests__/required-security-wiring.test.ts @@ -0,0 +1,332 @@ +import { type Type } from '@nestjs/common'; +import { Test, type TestingModule } from '@nestjs/testing'; +import type { SlashCommandPayload } from '@mosaicstack/types'; +import { describe, expect, it, vi } from 'vitest'; +import { AgentService, type AgentSession } from '../agent/agent.service.js'; +import { ProviderService } from '../agent/provider.service.js'; +import { AppModule } from '../app.module.js'; +import { CommandAuthorizationService } from '../commands/command-authorization.service.js'; +import { CommandExecutorService } from '../commands/command-executor.service.js'; +import { CommandsModule } from '../commands/commands.module.js'; +import { CommandRuntimeApprovalVerifier } from '../commands/runtime-approval-verifier.js'; +import { PreferencesModule } from '../preferences/preferences.module.js'; +import { SystemOverrideService } from '../preferences/system-override.service.js'; + +const fakeDb = { + $client: { exec: async (): Promise => {} }, + execute: async (): Promise<{ rows: unknown[] }> => ({ rows: [] }), + select: () => ({ + from: () => ({ + where: async (): Promise> => [{ count: 1 }], + }), + }), + insert: () => ({ values: async (): Promise => {} }), +}; + +const fakeProviderService = { + onModuleInit: async (): Promise => {}, + onModuleDestroy: (): void => {}, + getRegistry: () => ({ getAvailable: () => [], getAll: () => [], find: () => undefined }), + getDefaultModel: () => undefined, + listAvailableModels: () => [], + listProviders: () => [], + getAdapter: () => undefined, + getProvidersHealth: () => [], +}; + +function compileRealAppGraph(): Promise { + return Test.createTestingModule({ imports: [AppModule] }) + .overrideProvider('DB_HANDLE') + .useValue({ db: fakeDb, close: async (): Promise => {} }) + .overrideProvider('DB') + .useValue(fakeDb) + .overrideProvider('STORAGE_ADAPTER') + .useValue({ + name: 'required-security-wiring-test', + migrate: async (): Promise => {}, + close: async (): Promise => {}, + }) + .overrideProvider('AUTH') + .useValue({}) + .overrideProvider('BRAIN') + .useValue({ conversations: {}, agents: {} }) + .overrideProvider('LOG_SERVICE') + .useValue({}) + .overrideProvider('MEMORY') + .useValue({}) + .overrideProvider('MEMORY_ADAPTER') + .useValue({}) + .overrideProvider(ProviderService) + .useValue(fakeProviderService) + .compile(); +} + +function providerToken(provider: unknown): unknown { + return typeof provider === 'function' ? provider : (provider as { provide?: unknown })?.provide; +} + +interface MaskingConsumer { + moduleType: Type; + token: Type; + useValue: object; +} + +async function compileWithoutProvider( + moduleType: Type, + missingToken: Type, + maskingConsumer: MaskingConsumer, +): Promise<{ error: unknown; moduleRef: TestingModule | undefined }> { + const touchedModules = new Set([moduleType, maskingConsumer.moduleType]); + const originals = Array.from(touchedModules, (touchedModule: Type) => ({ + moduleType: touchedModule, + providers: (Reflect.getMetadata('providers', touchedModule) ?? []) as unknown[], + exports: (Reflect.getMetadata('exports', touchedModule) ?? []) as unknown[], + })); + + for (const original of originals) { + const providers = original.providers.flatMap((provider: unknown): unknown[] => { + const token = providerToken(provider); + if (original.moduleType === moduleType && token === missingToken) return []; + if (original.moduleType === maskingConsumer.moduleType && token === maskingConsumer.token) { + return [{ provide: maskingConsumer.token, useValue: maskingConsumer.useValue }]; + } + return [provider]; + }); + const exports = original.exports.filter( + (exported: unknown): boolean => + original.moduleType !== moduleType || providerToken(exported) !== missingToken, + ); + Reflect.defineMetadata('providers', providers, original.moduleType); + Reflect.defineMetadata('exports', exports, original.moduleType); + } + + let moduleRef: TestingModule | undefined; + let error: unknown; + try { + moduleRef = await compileRealAppGraph(); + } catch (caught: unknown) { + error = caught; + } finally { + for (const original of originals) { + Reflect.defineMetadata('providers', original.providers, original.moduleType); + Reflect.defineMetadata('exports', original.exports, original.moduleType); + } + } + return { error, moduleRef }; +} + +async function closeIfCompiled(moduleRef: TestingModule | undefined): Promise { + if (moduleRef) await moduleRef.close(); +} + +describe('required security wiring — real AppModule startup refusal', () => { + it('FL-01 positive control: the real graph compiles when CommandAuthorizationService is bound', async () => { + const moduleRef = await compileRealAppGraph(); + try { + expect(moduleRef.get(CommandAuthorizationService, { strict: false })).toBeInstanceOf( + CommandAuthorizationService, + ); + } finally { + await moduleRef.close(); + } + }); + + it('FL-01 negative control: absence read as permission is refused at module compilation', async () => { + const { error, moduleRef } = await compileWithoutProvider( + CommandsModule, + CommandAuthorizationService, + { + moduleType: CommandsModule, + token: CommandRuntimeApprovalVerifier, + useValue: {}, + }, + ); + await closeIfCompiled(moduleRef); + + expect( + error, + 'absence read as permission: AppModule compilation accepted a missing CommandAuthorizationService binding', + ).toBeInstanceOf(Error); + if (!(error instanceof Error)) return; + expect(error.message).toContain('CommandExecutorService'); + expect(error.message).toContain('CommandAuthorizationService'); + }); + + it('FL-11 positive control: the real graph compiles when SystemOverrideService is bound', async () => { + const moduleRef = await compileRealAppGraph(); + try { + expect(moduleRef.get(SystemOverrideService, { strict: false })).toBeInstanceOf( + SystemOverrideService, + ); + } finally { + await moduleRef.close(); + } + }); + + it('FL-11 negative control: absence read as permission is refused at module compilation', async () => { + const { error, moduleRef } = await compileWithoutProvider( + PreferencesModule, + SystemOverrideService, + { + moduleType: CommandsModule, + token: CommandExecutorService, + useValue: {}, + }, + ); + await closeIfCompiled(moduleRef); + + expect( + error, + 'absence read as permission: AppModule compilation accepted a missing SystemOverrideService binding', + ).toBeInstanceOf(Error); + if (!(error instanceof Error)) return; + expect(error.message).toContain('AgentService'); + expect(error.message).toContain('SystemOverrideService'); + }); +}); + +const actorScope = { userId: 'security-user', tenantId: 'security-tenant' }; +const conversationId = 'security-conversation'; + +function directExecutorWithoutAuthorization(systemOverrideSet: ReturnType) { + const registry = { + getManifest: vi.fn(() => ({ + version: 1, + commands: [ + { + name: 'system', + aliases: [], + description: 'Set instruction authority', + scope: 'agent' as const, + execution: 'socket' as const, + available: true, + }, + ], + skills: [], + })), + }; + return new CommandExecutorService( + registry as never, + { getSession: vi.fn() } as never, + { set: systemOverrideSet, clear: vi.fn() } as never, + { collect: vi.fn() } as never, + null, + { agents: {} } as never, + null, + null, + { getServerStatuses: vi.fn(() => []), getToolDefinitions: vi.fn(() => []) } as never, + undefined as never, + ); +} + +function directAgentWithoutSystemOverride(piPrompt: ReturnType): { + service: AgentService; + session: AgentSession; +} { + const service = new AgentService( + { + getDefaultModel: vi.fn(() => null), + getRegistry: vi.fn(() => ({})), + findModel: vi.fn(), + listAvailableModels: vi.fn(() => []), + } as never, + {} as never, + {} as never, + { available: false } as never, + {} as never, + { getToolDefinitions: vi.fn(() => []) } as never, + { loadForSession: vi.fn(async () => ({ metaTools: [], promptAdditions: [] })) } as never, + undefined as never, + null, + { collect: vi.fn().mockResolvedValue(undefined) } as never, + null, + ); + const session = { + id: conversationId, + provider: 'test-provider', + modelId: 'test-model', + piSession: { prompt: piPrompt }, + listeners: new Set(), + unsubscribe: vi.fn(), + createdAt: Date.now(), + promptCount: 0, + channels: new Set(), + skillPromptAdditions: [], + sandboxDir: process.cwd(), + allowedTools: null, + userId: actorScope.userId, + tenantId: actorScope.tenantId, + metrics: { + tokens: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 }, + modelSwitches: 0, + messageCount: 0, + lastActivityAt: new Date(0).toISOString(), + }, + } as unknown as AgentSession; + const internals = service as unknown as { sessions: Map }; + internals.sessions.set(conversationId, session); + return { service, session }; +} + +describe('required security wiring — malformed direct absence has zero effects', () => { + it('FL-01 refuses command execution before any command effect when authorization is absent', async () => { + const systemOverrideSet = vi.fn().mockResolvedValue(undefined); + const executor = directExecutorWithoutAuthorization(systemOverrideSet); + const payload: SlashCommandPayload = { + command: 'system', + args: 'authority that must not be stored', + conversationId, + }; + let error: unknown; + + try { + await executor.execute(payload, actorScope); + } catch (caught: unknown) { + error = caught; + } + + expect + .soft( + error, + 'absence read as permission: direct executor accepted missing command authorization', + ) + .toBeInstanceOf(Error); + expect + .soft( + systemOverrideSet, + 'absence read as permission: command effect occurred without command authorization', + ) + .not.toHaveBeenCalled(); + }); + + it('FL-11 refuses prompt execution before any provider or session effect when system override authority is absent', async () => { + const piPrompt = vi.fn().mockResolvedValue(undefined); + const { service, session } = directAgentWithoutSystemOverride(piPrompt); + let error: unknown; + + try { + await service.prompt(conversationId, 'must not reach provider', actorScope); + } catch (caught: unknown) { + error = caught; + } + + expect + .soft( + error, + 'absence read as permission: direct session accepted missing system override authority', + ) + .toBeInstanceOf(Error); + expect + .soft( + piPrompt, + 'absence read as permission: provider prompt occurred without system override authority', + ) + .not.toHaveBeenCalled(); + expect + .soft( + session.promptCount, + 'absence read as permission: session state changed without system override authority', + ) + .toBe(0); + }); +}); diff --git a/apps/gateway/src/agent/__tests__/agent-service-ownership.test.ts b/apps/gateway/src/agent/__tests__/agent-service-ownership.test.ts index dc6b9bfd..53c45e19 100644 --- a/apps/gateway/src/agent/__tests__/agent-service-ownership.test.ts +++ b/apps/gateway/src/agent/__tests__/agent-service-ownership.test.ts @@ -26,7 +26,7 @@ function makeService(operatorMemory: unknown = null): AgentService { {} as never, { getToolDefinitions: vi.fn(() => []) } as never, { loadForSession: vi.fn(async () => ({ metaTools: [], promptAdditions: [] })) } as never, - null, + { get: vi.fn().mockResolvedValue(null), renew: vi.fn().mockResolvedValue(undefined) } as never, null, { collect: vi.fn().mockResolvedValue(undefined) } as never, operatorMemory as never, diff --git a/apps/gateway/src/agent/agent.service.ts b/apps/gateway/src/agent/agent.service.ts index 38192969..d9a06bef 100644 --- a/apps/gateway/src/agent/agent.service.ts +++ b/apps/gateway/src/agent/agent.service.ts @@ -132,9 +132,8 @@ export class AgentService implements OnModuleDestroy { @Inject(CoordService) private readonly coordService: CoordService, @Inject(McpClientService) private readonly mcpClientService: McpClientService, @Inject(SkillLoaderService) private readonly skillLoaderService: SkillLoaderService, - @Optional() @Inject(SystemOverrideService) - private readonly systemOverride: SystemOverrideService | null, + private readonly systemOverride: SystemOverrideService, @Optional() @Inject(PreferencesService) private readonly preferencesService: PreferencesService | null, @@ -709,23 +708,22 @@ export class AgentService implements OnModuleDestroy { throw new Error(`No agent session found: ${sessionId}`); } this.assertSessionScope(session, scope); - session.promptCount += 1; // Channel attachments are untrusted URI references. Preserve exact, // authenticated metadata for the agent without treating it as authority. const attachmentContext = this.attachmentContext(attachments); - // Prepend session-scoped system override if present (renew TTL on each turn) + // Prepend session-scoped system override if present (renew TTL on each turn). + // Required instruction-authority wiring is consulted before session/provider effects. let effectiveMessage = `${message}${attachmentContext}`; - if (this.systemOverride) { - const override = await this.systemOverride.get(sessionId, scope); - if (override) { - effectiveMessage = `[System Override]\n${override}\n\n${effectiveMessage}`; - await this.systemOverride.renew(sessionId, scope); - this.logger.debug(`Applied system override for session ${sessionId}`); - } + const override = await this.systemOverride.get(sessionId, scope); + if (override) { + effectiveMessage = `[System Override]\n${override}\n\n${effectiveMessage}`; + await this.systemOverride.renew(sessionId, scope); + this.logger.debug(`Applied system override for session ${sessionId}`); } + session.promptCount += 1; try { await session.piSession.prompt(effectiveMessage); } catch (err) { diff --git a/apps/gateway/src/commands/command-executor-p8012.spec.ts b/apps/gateway/src/commands/command-executor-p8012.spec.ts index 28fbedad..730ca2ff 100644 --- a/apps/gateway/src/commands/command-executor-p8012.spec.ts +++ b/apps/gateway/src/commands/command-executor-p8012.spec.ts @@ -80,6 +80,10 @@ const mockMcpClient = { getToolDefinitions: vi.fn(() => []), }; +const allowAuthorization = { + authorize: vi.fn().mockResolvedValue({ allowed: true }), +}; + function buildService( redis: typeof mockRedis | null = mockRedis, mcpClient: { @@ -98,6 +102,7 @@ function buildService( null, mockChatGateway as never, mcpClient as never, + allowAuthorization as never, ); } diff --git a/apps/gateway/src/commands/command-executor.service.ts b/apps/gateway/src/commands/command-executor.service.ts index 78a4efcb..0cee2721 100644 --- a/apps/gateway/src/commands/command-executor.service.ts +++ b/apps/gateway/src/commands/command-executor.service.ts @@ -35,9 +35,8 @@ export class CommandExecutorService { @Inject(forwardRef(() => ChatGateway)) private readonly chatGateway: ChatGateway | null, @Inject(McpClientService) private readonly mcpClient: McpClientService, - @Optional() @Inject(CommandAuthorizationService) - private readonly authorization: CommandAuthorizationService | null = null, + private readonly authorization: CommandAuthorizationService, ) {} async execute( @@ -57,13 +56,13 @@ export class CommandExecutorService { }; } - const authorization = await this.authorization?.authorize( + const authorization = await this.authorization.authorize( def, payload, userId, payload.approvalId, ); - if (authorization && !authorization.allowed) { + if (!authorization.allowed) { return { command, conversationId, success: false, message: authorization.reason }; } @@ -171,7 +170,7 @@ export class CommandExecutorService { const def = this.registry .getManifest() .commands.find((command) => command.name === payload.command); - if (!def || !this.authorization) return null; + if (!def) return null; return this.authorization.createApproval(def, payload, scope.userId); } diff --git a/apps/gateway/src/commands/commands.integration.spec.ts b/apps/gateway/src/commands/commands.integration.spec.ts index e1806bcd..99fbfb91 100644 --- a/apps/gateway/src/commands/commands.integration.spec.ts +++ b/apps/gateway/src/commands/commands.integration.spec.ts @@ -55,6 +55,10 @@ const mockMcpClient = { reconnectServer: vi.fn().mockResolvedValue(undefined), }; +const allowAuthorization = { + authorize: vi.fn().mockResolvedValue({ allowed: true }), +}; + // ─── Helpers ───────────────────────────────────────────────────────────────── function buildRegistry(): CommandRegistryService { @@ -74,6 +78,7 @@ function buildExecutor(registry: CommandRegistryService): CommandExecutorService null, // reloadService (optional) null, // chatGateway (optional) mockMcpClient as never, + allowAuthorization as never, ); } diff --git a/apps/gateway/src/federation/__tests__/enrollment.service.spec.ts b/apps/gateway/src/federation/__tests__/enrollment.service.spec.ts index 558f6da8..3a26dd22 100644 --- a/apps/gateway/src/federation/__tests__/enrollment.service.spec.ts +++ b/apps/gateway/src/federation/__tests__/enrollment.service.spec.ts @@ -245,9 +245,21 @@ describe('EnrollmentService.createToken', () => { const after = Date.now(); const expiresMs = new Date(result.expiresAt).getTime(); - // Should be at most 900s from now - expect(expiresMs - before).toBeLessThanOrEqual(900_000 + 100); + + // The property under test is CLAMPING: a 9999s request must come back as 900s. + // The gap between clamped and unclamped is 9_099_000 ms, so the tolerance below + // only has to exceed CI scheduling jitter — it does not need to be tight to keep + // the assertion discriminating. A 5s allowance consumes 0.05% of that margin and + // an unclamped result still misses by three orders of magnitude. + // + // It was 100ms and failed on a loaded agent at 900_106 — 6ms over (#1090). A + // wall-clock budget sized to a fast machine is a flake, not a tighter test. + const CI_JITTER_MS = 5_000; + expect(expiresMs - before).toBeLessThanOrEqual(900_000 + CI_JITTER_MS); expect(expiresMs - after).toBeGreaterThanOrEqual(0); + // Explicitly pin the clamp itself, independent of any timing allowance: + // unclamped (9999s) would exceed this by ~9_099_000 ms. + expect(expiresMs - before).toBeLessThan(1_000_000); }); }); diff --git a/apps/gateway/src/reload/reload.service.spec.ts b/apps/gateway/src/reload/reload.service.spec.ts index 63a89781..244d6941 100644 --- a/apps/gateway/src/reload/reload.service.spec.ts +++ b/apps/gateway/src/reload/reload.service.spec.ts @@ -159,6 +159,7 @@ describe('ReloadService — /reload command sanitizes plugin errors', () => { reloadService, mockChatGateway as never, mockMcpClient as never, + { authorize: vi.fn().mockResolvedValue({ allowed: true }) } as never, ); const payload: SlashCommandPayload = { command: 'reload', conversationId: 'conv-1' }; diff --git a/apps/web/src/components/freshness/freshness-notices.tsx b/apps/web/src/components/freshness/freshness-notices.tsx new file mode 100644 index 00000000..eac24868 --- /dev/null +++ b/apps/web/src/components/freshness/freshness-notices.tsx @@ -0,0 +1,110 @@ +'use client'; + +import type { ReactElement } from 'react'; +import { formatAge, type FreshnessLabel } from '@/lib/freshness/model'; + +/** + * Rendering rules for non-current freshness states (RI-5-001). + * + * - `unavailable` renders an explicit failure panel — never an empty + * healthy collection. + * - `stale` may render last-known data, but only under a visible label + * carrying source identity, snapshot version, and age. + * - `partial` renders the verified parts plus an explicit list of what is + * missing. + */ + +interface RetryableNoticeProps { + readonly onRetry?: () => void; + readonly retryLabel?: string; +} + +function RetryButton({ onRetry, retryLabel }: RetryableNoticeProps): ReactElement | null { + if (!onRetry) return null; + return ( + + ); +} + +export interface UnavailableDataNoticeProps extends RetryableNoticeProps { + /** What is unavailable, e.g. "Tasks". */ + readonly title: string; + /** Optional underlying failure detail (network message, invalidation reason). */ + readonly detail?: string | null; +} + +/** Explicit `unavailable` state. Never renders as an empty healthy collection. */ +export function UnavailableDataNotice({ + title, + detail, + onRetry, + retryLabel, +}: UnavailableDataNoticeProps): ReactElement { + return ( +
+

{title} are unavailable

+

+ This is not an empty result — the data could not be verified from the gateway. + {detail ? ` ${detail}` : ''} +

+ +
+ ); +} + +export interface StaleDataNoticeProps extends RetryableNoticeProps { + /** Provenance of the last-known snapshot being displayed. */ + readonly label: FreshnessLabel; +} + +/** + * Situational-awareness banner for `stale` data: last-known data may render, + * but visibly labeled with source identity, snapshot version, and age. + */ +export function StaleDataNotice({ + label, + onRetry, + retryLabel, +}: StaleDataNoticeProps): ReactElement { + return ( +
+

Showing last-known data — it may be out of date

+

+ Source {label.source} · snapshot v{label.version} · fetched{' '} + {formatAge(label.fetchedAt, Date.now())}. Verdicts derived from this data are unknown and + changes are disabled until it is revalidated. +

+ +
+ ); +} + +export interface PartialDataNoticeProps extends RetryableNoticeProps { + /** Display names of the sections whose collections are unavailable. */ + readonly missing: readonly string[]; +} + +/** `partial` surface banner: verified parts render, missing parts are explicit. */ +export function PartialDataNotice({ + missing, + onRetry, + retryLabel, +}: PartialDataNoticeProps): ReactElement { + return ( +
+

Some data could not be loaded

+

+ {missing.join(', ')} {missing.length === 1 ? 'is' : 'are'} unavailable — sections below show + an explicit unavailable state instead of an empty list. Derived verdicts remain unknown + until every collection is revalidated. +

+ +
+ ); +} diff --git a/apps/web/src/lib/freshness/model.spec.ts b/apps/web/src/lib/freshness/model.spec.ts new file mode 100644 index 00000000..9ccf361c --- /dev/null +++ b/apps/web/src/lib/freshness/model.spec.ts @@ -0,0 +1,324 @@ +import { describe, expect, it } from 'vitest'; +import type { Task } from '@/lib/types'; +import { + acceptSnapshot, + assertMutable, + canMutate, + combineFreshness, + computeDigest, + computeFreshness, + DEFAULT_FRESHNESS_POLICY, + formatAge, + type FreshSnapshot, + invalidationReasonLabels, + StaleMutationError, + UNKNOWN_VERDICT, + verdictValue, +} from './model'; +import { validateProjectCollection, validateTaskCollection } from './validators'; + +const NOW = 1_800_000_000_000; + +const policy = { ...DEFAULT_FRESHNESS_POLICY, staleAfterMs: 60_000 }; + +const taskPayload: Task[] = [ + { + id: 'task-1', + title: 'T1', + description: null, + status: 'not-started', + priority: 'high', + projectId: 'project-1', + missionId: null, + assignee: null, + tags: null, + dueDate: null, + metadata: null, + createdAt: '2026-08-01T00:00:00.000Z', + updatedAt: '2026-08-01T00:00:00.000Z', + }, +]; + +function acceptedTaskSnapshot( + overrides: Partial> = {}, +): FreshSnapshot { + const result = acceptSnapshot({ + value: taskPayload, + validate: validateTaskCollection, + previous: null, + policy, + source: 'gateway:/api/tasks', + now: NOW, + }); + if (result.outcome !== 'accepted') { + throw new Error(`fixture setup failed: ${result.reason}`); + } + return { ...result.snapshot, ...overrides }; +} + +describe('computeFreshness', () => { + it('treats a missing snapshot as unavailable, never as an empty healthy collection', () => { + expect(computeFreshness({ snapshot: null, policy, now: NOW })).toBe('unavailable'); + }); + + it('returns current for a fresh verified snapshot regardless of data emptiness', () => { + const empty = acceptSnapshot({ + value: [], + validate: validateTaskCollection, + previous: null, + policy, + source: 'gateway:/api/tasks', + now: NOW, + }); + if (empty.outcome !== 'accepted') throw new Error('expected acceptance'); + expect(computeFreshness({ snapshot: empty.snapshot, policy, now: NOW })).toBe('current'); + }); + + it('degrades to stale once the snapshot ages past staleAfterMs', () => { + const snapshot = acceptedTaskSnapshot(); + expect(computeFreshness({ snapshot, policy, now: NOW + 60_001 })).toBe('stale'); + expect(computeFreshness({ snapshot, policy, now: NOW + 59_999 })).toBe('current'); + }); + + it('degrades to stale when the latest revalidation failed', () => { + const snapshot = acceptedTaskSnapshot(); + expect(computeFreshness({ snapshot, policy, now: NOW, degraded: true })).toBe('stale'); + }); +}); + +describe('mutation guard', () => { + it('permits mutations only on current data', () => { + expect(canMutate('current')).toBe(true); + for (const state of ['stale', 'partial', 'unknown', 'unavailable'] as const) { + expect(canMutate(state)).toBe(false); + } + }); + + it('refuses mutations on non-current data via assertMutable', () => { + expect(() => assertMutable('current')).not.toThrow(); + for (const state of ['stale', 'partial', 'unknown', 'unavailable'] as const) { + let thrown: unknown; + try { + assertMutable(state); + } catch (caught) { + thrown = caught; + } + expect(thrown).toBeInstanceOf(StaleMutationError); + expect(thrown).toBeInstanceOf(Error); + if (thrown instanceof StaleMutationError) { + expect(thrown.name).toBe('StaleMutationError'); + expect(thrown.freshness).toBe(state); + expect(thrown.message).toContain(state); + expect(thrown.message).toContain('revalidat'); + } + } + }); +}); + +describe('acceptSnapshot', () => { + it('accepts a valid payload with provenance', () => { + const result = acceptSnapshot({ + value: taskPayload, + validate: validateTaskCollection, + previous: null, + policy, + source: 'gateway:/api/tasks', + now: NOW, + }); + expect(result.outcome).toBe('accepted'); + if (result.outcome !== 'accepted') return; + expect(result.snapshot.source).toBe('gateway:/api/tasks'); + expect(result.snapshot.version).toBe(1); + expect(result.snapshot.fetchedAt).toBe(NOW); + expect(result.snapshot.data).toEqual(taskPayload); + }); + + it('invalidates a schema-mismatched payload instead of rendering it', () => { + const result = acceptSnapshot({ + value: { not: 'an array' }, + validate: validateTaskCollection, + previous: acceptedTaskSnapshot(), + policy, + source: 'gateway:/api/tasks', + now: NOW, + }); + expect(result).toEqual({ outcome: 'invalidated', reason: 'schema-mismatch' }); + expect(invalidationReasonLabels['schema-mismatch']).toContain('schema'); + }); + + it('invalidates cross-workspace payloads', () => { + const userOne = acceptSnapshot({ + value: [ + { + id: 'p1', + name: 'P1', + description: null, + status: 'active', + userId: 'user-1', + metadata: null, + createdAt: '2026-08-01T00:00:00.000Z', + updatedAt: '2026-08-01T00:00:00.000Z', + }, + ], + validate: validateProjectCollection, + previous: null, + policy, + source: 'gateway:/api/projects', + now: NOW, + }); + if (userOne.outcome !== 'accepted') throw new Error('expected acceptance'); + + const switched = acceptSnapshot({ + value: [ + { + id: 'p9', + name: 'P9', + description: null, + status: 'active', + userId: 'user-2', + metadata: null, + createdAt: '2026-08-01T00:00:00.000Z', + updatedAt: '2026-08-01T00:00:00.000Z', + }, + ], + validate: validateProjectCollection, + previous: userOne.snapshot, + policy, + source: 'gateway:/api/projects', + now: NOW, + }); + expect(switched).toEqual({ outcome: 'invalidated', reason: 'cross-workspace' }); + }); + + it('keeps the previous workspace for collections with no intrinsic identity', () => { + const userOne = acceptSnapshot({ + value: [ + { + id: 'p1', + name: 'P1', + description: null, + status: 'active', + userId: 'user-1', + metadata: null, + createdAt: '2026-08-01T00:00:00.000Z', + updatedAt: '2026-08-01T00:00:00.000Z', + }, + ], + validate: validateProjectCollection, + previous: null, + policy, + source: 'gateway:/api/projects', + now: NOW, + }); + if (userOne.outcome !== 'accepted') throw new Error('expected acceptance'); + + // Empty list after the user deleted every project: no identity to check, + // so the verified scope is retained and the empty state stays healthy. + const emptied = acceptSnapshot({ + value: [], + validate: validateProjectCollection, + previous: userOne.snapshot, + policy, + source: 'gateway:/api/projects', + now: NOW, + }); + expect(emptied.outcome).toBe('accepted'); + if (emptied.outcome === 'accepted') { + expect(emptied.snapshot.data).toEqual([]); + expect(emptied.snapshot.workspace).toBe('user-1'); + } + }); + + it('invalidates version regressions', () => { + const previous = acceptedTaskSnapshot({ version: 7 }); + const regressed = acceptSnapshot({ + value: taskPayload, + validate: validateTaskCollection, + previous, + policy, + source: 'gateway:/api/tasks', + now: NOW, + incomingVersion: 3, + }); + expect(regressed).toEqual({ outcome: 'invalidated', reason: 'version-regression' }); + + const newerSchema = acceptedTaskSnapshot({ schemaVersion: 4 }); + const downgradedClient = acceptSnapshot({ + value: taskPayload, + validate: validateTaskCollection, + previous: newerSchema, + policy: { ...policy, schemaVersion: 2 }, + source: 'gateway:/api/tasks', + now: NOW, + }); + expect(downgradedClient).toEqual({ outcome: 'invalidated', reason: 'version-regression' }); + }); + + it('increments the version monotonically across accepted snapshots', () => { + const first = acceptedTaskSnapshot(); + const second = acceptSnapshot({ + value: taskPayload, + validate: validateTaskCollection, + previous: first, + policy, + source: 'gateway:/api/tasks', + now: NOW, + }); + expect(second.outcome).toBe('accepted'); + if (second.outcome === 'accepted') { + expect(second.snapshot.version).toBe(first.version + 1); + } + }); +}); + +describe('combineFreshness', () => { + it('gates the surface on the primary collection', () => { + expect(combineFreshness('unavailable', ['current'])).toBe('unavailable'); + expect(combineFreshness('unknown', ['current'])).toBe('unknown'); + expect(combineFreshness('current', [])).toBe('current'); + }); + + it('degrades to partial when a secondary is unavailable', () => { + expect(combineFreshness('current', ['current', 'unavailable'])).toBe('partial'); + }); + + it('degrades to unknown while a secondary is still loading', () => { + expect(combineFreshness('current', ['unknown'])).toBe('unknown'); + }); + + it('degrades to stale when any collection is stale', () => { + expect(combineFreshness('current', ['stale'])).toBe('stale'); + expect(combineFreshness('stale', ['current'])).toBe('stale'); + }); + + it('propagates partial secondaries', () => { + expect(combineFreshness('current', ['partial'])).toBe('partial'); + }); +}); + +describe('computeDigest', () => { + it('is stable across key order and changes with data', () => { + const a = computeDigest({ x: 1, y: [1, 2] }); + const b = computeDigest({ y: [1, 2], x: 1 }); + expect(a).toBe(b); + expect(computeDigest({ x: 1, y: [1, 3] })).not.toBe(a); + }); +}); + +describe('verdictValue', () => { + it('returns the value only for verified inputs', () => { + expect(verdictValue(true, '5')).toBe('5'); + expect(verdictValue(false, '5')).toBe(UNKNOWN_VERDICT); + expect(verdictValue(false, '5')).not.toBe('5'); + }); +}); + +describe('formatAge', () => { + it('labels age in human terms', () => { + expect(formatAge(NOW, NOW)).toBe('just now'); + expect(formatAge(NOW, NOW + 15_000)).toBe('under a minute ago'); + expect(formatAge(NOW, NOW + 120_000)).toBe('2m ago'); + expect(formatAge(NOW, NOW + 3 * 3_600_000)).toBe('3h ago'); + expect(formatAge(NOW, NOW + 2 * 86_400_000)).toBe('2d ago'); + }); +}); diff --git a/apps/web/src/lib/freshness/model.ts b/apps/web/src/lib/freshness/model.ts new file mode 100644 index 00000000..a83090f3 --- /dev/null +++ b/apps/web/src/lib/freshness/model.ts @@ -0,0 +1,261 @@ +/** + * Typed freshness model for gateway-fetched collections (RI-5-001). + * + * A failed or stale fetch must never be indistinguishable from an empty + * healthy collection. Every fetched surface carries an explicit freshness + * state, a verified snapshot identity (source, workspace, version, age), and + * a mutation guard that refuses state-changing operations unless the data is + * verified current. + */ + +/** Freshness states for fetched data. Never inferred from emptiness. */ +export type FreshnessState = 'current' | 'stale' | 'partial' | 'unknown' | 'unavailable'; + +/** + * Reasons a snapshot is invalidated. An invalidated snapshot is treated as + * unavailable and is never rendered as current. + */ +export type InvalidationReason = + | 'cache-corruption' + | 'cross-workspace' + | 'schema-mismatch' + | 'version-regression'; + +/** Human-readable labels for invalidation reasons (UI + error messages). */ +export const invalidationReasonLabels: Record = { + 'cache-corruption': 'cached snapshot failed integrity checks', + 'cross-workspace': 'data belongs to a different workspace', + 'schema-mismatch': 'response did not match the expected schema', + 'version-regression': 'snapshot version regressed below the accepted version', +}; + +/** A verified snapshot of fetched data with full provenance. */ +export interface FreshSnapshot { + readonly data: T; + /** Source identity of the fetch, e.g. `gateway:/api/tasks`. */ + readonly source: string; + /** Workspace scope the data belongs to. */ + readonly workspace: string; + /** Monotonic snapshot sequence number for this surface. */ + readonly version: number; + /** Schema version of the validator that accepted this snapshot. */ + readonly schemaVersion: number; + /** Epoch ms at which the data was verified. */ + readonly fetchedAt: number; + /** Integrity digest of `data`, used to detect cache corruption. */ + readonly digest: string; +} + +/** Provenance label rendered next to last-known data. */ +export interface FreshnessLabel { + readonly source: string; + readonly version: number; + readonly fetchedAt: number; +} + +/** Policy governing freshness for a surface. */ +export interface FreshnessPolicy { + /** Active workspace scope. Snapshots from other scopes are invalidated. */ + readonly workspace: string; + /** Schema version of the current validator. */ + readonly schemaVersion: number; + /** Age after which a verified snapshot degrades from current to stale. */ + readonly staleAfterMs: number; +} + +export const DEFAULT_FRESHNESS_POLICY: FreshnessPolicy = { + workspace: 'default', + schemaVersion: 1, + staleAfterMs: 60_000, +}; + +/** Payload returned by a successful schema validation. */ +export interface FreshPayload { + readonly data: T; + /** + * Workspace identity extracted from the payload itself when the collection + * carries one (e.g. a uniform `userId` on projects). `null` when the + * collection has no intrinsic workspace identity. + */ + readonly workspace: string | null; +} + +/** Error thrown when a mutation is attempted on non-current data. */ +export class StaleMutationError extends Error { + readonly freshness: FreshnessState; + + constructor(freshness: FreshnessState) { + super(`Refused mutation on ${freshness} data: revalidation is required before mutating.`); + this.name = 'StaleMutationError'; + this.freshness = freshness; + } +} + +/** Stable JSON digest used for snapshot integrity checks. */ +export function computeDigest(value: unknown): string { + // FNV-1a 32-bit over the stable JSON serialization. This is an integrity + // check against corruption, not a cryptographic guarantee. + let hash = 0x811c9dc5; + for (const byte of stableStringify(value)) { + hash ^= byte.charCodeAt(0); + hash = Math.imul(hash, 0x01000193) >>> 0; + } + return hash.toString(16).padStart(8, '0'); +} + +function stableStringify(value: unknown): string { + return serialize(value); +} + +function serialize(value: unknown): string { + if (value === null || typeof value !== 'object') return JSON.stringify(value) ?? 'null'; + if (Array.isArray(value)) return `[${value.map(serialize).join(',')}]`; + const entries = Object.entries(value as Record) + .filter(([, item]) => item !== undefined) + .sort(([left], [right]) => (left < right ? -1 : left > right ? 1 : 0)) + .map(([key, item]) => `${JSON.stringify(key)}:${serialize(item)}`); + return `{${entries.join(',')}}`; +} + +export type AcceptSnapshotResult = + | { readonly outcome: 'accepted'; readonly snapshot: FreshSnapshot } + | { readonly outcome: 'invalidated'; readonly reason: InvalidationReason }; + +export interface AcceptSnapshotOptions { + /** Raw fetched value (untrusted JSON). */ + readonly value: unknown; + /** Schema validator; returns `null` when the value does not match. */ + readonly validate: (value: unknown) => FreshPayload | null; + /** Previously accepted snapshot for this surface, if any. */ + readonly previous: FreshSnapshot | null; + readonly policy: FreshnessPolicy; + readonly source: string; + /** + * Version carried by the incoming payload when the transport exposes one. + * Must not regress below the accepted snapshot's version. + */ + readonly incomingVersion?: number; + readonly now: number; +} + +/** + * Validate and accept a fetched value as a snapshot, or invalidate it. + * + * Invalidation rules (each treated as unavailable, never rendered current): + * - schema mismatch: the payload fails validation + * - cross-workspace: the payload's workspace differs from the verified one + * - version regression: payload/schema version is below the accepted one + */ +export function acceptSnapshot(options: AcceptSnapshotOptions): AcceptSnapshotResult { + const payload = options.validate(options.value); + if (payload === null) { + return { outcome: 'invalidated', reason: 'schema-mismatch' }; + } + + // Workspace identity: the payload's own scope wins; a collection with no + // intrinsic identity (e.g. an empty list after every project was deleted) + // keeps the previously verified scope rather than resetting to the policy + // default, so a legitimately empty response is not mistaken for a scope + // change. + const workspace = payload.workspace ?? options.previous?.workspace ?? options.policy.workspace; + if (options.previous !== null && options.previous.workspace !== workspace) { + return { outcome: 'invalidated', reason: 'cross-workspace' }; + } + if (options.previous !== null && options.policy.schemaVersion < options.previous.schemaVersion) { + return { outcome: 'invalidated', reason: 'version-regression' }; + } + if ( + options.incomingVersion !== undefined && + options.previous !== null && + options.incomingVersion < options.previous.version + ) { + return { outcome: 'invalidated', reason: 'version-regression' }; + } + + const snapshot: FreshSnapshot = { + data: payload.data, + source: options.source, + workspace, + version: options.incomingVersion ?? (options.previous?.version ?? 0) + 1, + schemaVersion: options.policy.schemaVersion, + fetchedAt: options.now, + digest: computeDigest(payload.data), + }; + return { outcome: 'accepted', snapshot }; +} + +export interface ComputeFreshnessOptions { + readonly snapshot: FreshSnapshot | null; + readonly policy: FreshnessPolicy; + readonly now: number; + /** + * True when the snapshot cannot be trusted as current regardless of age: + * the latest revalidation failed, or the snapshot was restored from cache + * and has not been verified by a fetch in this session. + */ + readonly degraded?: boolean; +} + +/** + * Compute the freshness state of a snapshot. A missing snapshot is + * `unavailable` (never "empty and healthy"); a degraded or aged snapshot is + * `stale` (situational awareness only). + */ +export function computeFreshness(options: ComputeFreshnessOptions): FreshnessState { + const { snapshot, policy, now, degraded = false } = options; + if (snapshot === null) return 'unavailable'; + if (degraded) return 'stale'; + if (now - snapshot.fetchedAt > policy.staleAfterMs) return 'stale'; + return 'current'; +} + +/** Only verified-current data may back a state-changing action. */ +export function canMutate(state: FreshnessState): boolean { + return state === 'current'; +} + +/** Defense in depth: reject the mutation call itself on non-current data. */ +export function assertMutable(state: FreshnessState): void { + if (!canMutate(state)) { + throw new StaleMutationError(state); + } +} + +/** + * Combine freshness across a multi-collection surface (primary + secondaries). + * The primary collection gates the surface: unknown while it loads, + * unavailable when it fails. Missing secondaries degrade the surface to + * `partial`; aged collections degrade it to `stale`. + */ +export function combineFreshness( + primary: FreshnessState, + secondaries: readonly FreshnessState[], +): FreshnessState { + if (primary === 'unavailable') return 'unavailable'; + if (primary === 'unknown') return 'unknown'; + if (secondaries.includes('unavailable')) return 'partial'; + if (secondaries.includes('unknown')) return 'unknown'; + if (secondaries.includes('stale') || primary === 'stale') return 'stale'; + if (secondaries.includes('partial')) return 'partial'; + return 'current'; +} + +/** Render-safe age label for snapshot provenance. */ +export function formatAge(fetchedAt: number, now: number): string { + const ageMs = Math.max(0, now - fetchedAt); + if (ageMs < 10_000) return 'just now'; + const minutes = Math.floor(ageMs / 60_000); + if (minutes < 1) return 'under a minute ago'; + if (minutes < 60) return `${minutes}m ago`; + const hours = Math.floor(minutes / 60); + if (hours < 24) return `${hours}h ago`; + const days = Math.floor(hours / 24); + return `${days}d ago`; +} + +/** Derived verdict placeholder for non-current inputs — never a green value. */ +export const UNKNOWN_VERDICT = '?'; + +export function verdictValue(verified: boolean, value: string): string { + return verified ? value : UNKNOWN_VERDICT; +} diff --git a/apps/web/src/lib/freshness/snapshot-cache.spec.ts b/apps/web/src/lib/freshness/snapshot-cache.spec.ts new file mode 100644 index 00000000..551905b2 --- /dev/null +++ b/apps/web/src/lib/freshness/snapshot-cache.spec.ts @@ -0,0 +1,197 @@ +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; +import { acceptSnapshot, DEFAULT_FRESHNESS_POLICY } from './model'; +import { clearSnapshotCache, readSnapshotCache, writeSnapshotCache } from './snapshot-cache'; +import { validateProjectCollection, validateTaskCollection } from './validators'; +import { projectFixtures, taskFixtures } from '@/spa/pages/page-fixtures'; +import type { Project, Task } from '@/lib/types'; + +const KEY = 'test:tasks'; +const NOW = 1_800_000_000_000; +const policy = { ...DEFAULT_FRESHNESS_POLICY, staleAfterMs: 60_000 }; + +function storedTaskSnapshot() { + const result = acceptSnapshot({ + value: taskFixtures, + validate: validateTaskCollection, + previous: null, + policy, + source: 'gateway:/api/tasks', + now: NOW, + }); + if (result.outcome !== 'accepted') throw new Error('fixture setup failed'); + return result.snapshot; +} + +function storedProjectSnapshot() { + const result = acceptSnapshot({ + value: projectFixtures, + validate: validateProjectCollection, + previous: null, + policy, + source: 'gateway:/api/projects', + now: NOW, + }); + if (result.outcome !== 'accepted') throw new Error('fixture setup failed'); + return result.snapshot; +} + +function readTasks() { + return readSnapshotCache({ + key: KEY, + workspace: policy.workspace, + policy, + validate: validateTaskCollection, + }); +} + +/** Write an arbitrary value directly at the raw cache slot. */ +function writeRaw(key: string, value: unknown): void { + sessionStorage.setItem(`mosaic:freshness:v1:${key}`, JSON.stringify(value)); +} + +/** Parse and re-write the stored entry (for tampering with internals). */ +function tamperStored(key: string, mutate: (stored: T) => void): void { + const parsed = JSON.parse(sessionStorage.getItem(`mosaic:freshness:v1:${key}`) ?? '{}') as T; + mutate(parsed); + writeRaw(key, parsed); +} + +beforeEach(() => { + sessionStorage.clear(); +}); + +afterEach(() => { + sessionStorage.clear(); +}); + +describe('readSnapshotCache', () => { + it('misses when nothing is stored', () => { + expect(readTasks()).toEqual({ outcome: 'miss' }); + }); + + it('hits for a well-formed entry and preserves provenance', () => { + const snapshot = storedTaskSnapshot(); + writeSnapshotCache(KEY, snapshot); + + const result = readTasks(); + expect(result.outcome).toBe('hit'); + if (result.outcome === 'hit') { + expect(result.snapshot.data).toEqual(taskFixtures); + expect(result.snapshot.source).toBe('gateway:/api/tasks'); + expect(result.snapshot.version).toBe(snapshot.version); + expect(result.snapshot.fetchedAt).toBe(snapshot.fetchedAt); + expect(result.snapshot.workspace).toBe(snapshot.workspace); + } + }); + + it('invalidates unparsable entries as cache corruption', () => { + sessionStorage.setItem(`mosaic:freshness:v1:${KEY}`, '{not json'); + expect(readTasks()).toEqual({ outcome: 'invalidated', reason: 'cache-corruption' }); + }); + + it('invalidates structurally wrong entries as cache corruption', () => { + const malformed: unknown[] = [ + 'nested but not a snapshot', + { data: taskFixtures }, // missing provenance fields + { + data: taskFixtures, + source: 1, + workspace: 'w', + version: 1, + schemaVersion: 1, + fetchedAt: 1, + digest: 'x', + }, + null, + 17, + ]; + for (const entry of malformed) { + writeRaw(KEY, entry); + expect(readTasks()).toEqual({ outcome: 'invalidated', reason: 'cache-corruption' }); + } + }); + + it('invalidates digest mismatches as cache corruption (tampered data)', () => { + writeSnapshotCache(KEY, storedTaskSnapshot()); + tamperStored<{ data: Task[] }>(KEY, (stored) => { + stored.data = [...stored.data, { ...stored.data[0]!, id: 'injected-task' }]; + }); + expect(readTasks()).toEqual({ outcome: 'invalidated', reason: 'cache-corruption' }); + }); + + it('invalidates entries scoped to another workspace', () => { + const snapshot = storedTaskSnapshot(); + writeSnapshotCache(KEY, { ...snapshot, workspace: 'someone-else' }); + expect(readTasks()).toEqual({ outcome: 'invalidated', reason: 'cross-workspace' }); + }); + + it('invalidates entries written by a newer schema as a version regression', () => { + const snapshot = storedTaskSnapshot(); + writeSnapshotCache(KEY, { ...snapshot, schemaVersion: policy.schemaVersion + 1 }); + expect(readTasks()).toEqual({ outcome: 'invalidated', reason: 'version-regression' }); + }); + + it('invalidates entries whose data no longer validates (schema mismatch)', () => { + writeSnapshotCache(KEY, storedTaskSnapshot()); + tamperStored<{ data: unknown }>(KEY, (stored) => { + stored.data = { malformed: true }; + }); + expect(readTasks()).toEqual({ outcome: 'invalidated', reason: 'schema-mismatch' }); + }); + + it('never reports a corrupted raw entry as a hit (negative control)', () => { + for (const raw of ['{oops', 'null', '"string"', '[]', '12']) { + sessionStorage.setItem(`mosaic:freshness:v1:${KEY}`, raw); + const result = readTasks(); + expect(result.outcome).not.toBe('hit'); + expect(result.outcome).toBe('invalidated'); + } + }); + + it('scopes project collections by their workspace identity', () => { + const snapshot = storedProjectSnapshot(); + writeSnapshotCache('test:projects', snapshot); + + const sameScope = readSnapshotCache({ + key: 'test:projects', + workspace: 'user-1', + policy, + validate: validateProjectCollection, + }); + expect(sameScope.outcome).toBe('hit'); + + const foreignScope = readSnapshotCache({ + key: 'test:projects', + workspace: 'user-2', + policy, + validate: validateProjectCollection, + }); + expect(foreignScope).toEqual({ outcome: 'invalidated', reason: 'cross-workspace' }); + }); +}); + +describe('writeSnapshotCache round-trip', () => { + it('round-trips an accepted project snapshot', () => { + const snapshot = storedProjectSnapshot(); + writeSnapshotCache('test:projects', snapshot); + const result = readSnapshotCache({ + key: 'test:projects', + workspace: snapshot.workspace, + policy, + validate: validateProjectCollection, + }); + expect(result.outcome).toBe('hit'); + if (result.outcome === 'hit') { + expect(result.snapshot.data).toEqual(projectFixtures as Project[]); + } + }); +}); + +describe('clearSnapshotCache', () => { + it('drops the entry so the next read misses', () => { + writeSnapshotCache(KEY, storedTaskSnapshot()); + expect(readTasks().outcome).toBe('hit'); + clearSnapshotCache(KEY); + expect(readTasks()).toEqual({ outcome: 'miss' }); + }); +}); diff --git a/apps/web/src/lib/freshness/snapshot-cache.ts b/apps/web/src/lib/freshness/snapshot-cache.ts new file mode 100644 index 00000000..63dbd756 --- /dev/null +++ b/apps/web/src/lib/freshness/snapshot-cache.ts @@ -0,0 +1,154 @@ +import { + computeDigest, + type FreshPayload, + type FreshSnapshot, + type FreshnessPolicy, + type InvalidationReason, +} from './model'; + +/** + * Session-scoped last-known snapshot cache (RI-5-001). + * + * Restored snapshots are situational awareness only: they surface as `stale` + * until a fetch re-verifies them. A cache entry that is corrupted, belongs to + * another workspace, was written by a newer schema, or no longer validates is + * invalidated (treated as unavailable, never rendered as current). + */ + +const CACHE_PREFIX = 'mosaic:freshness:v1'; + +interface StoredSnapshot { + data: unknown; + source: string; + workspace: string; + version: number; + schemaVersion: number; + fetchedAt: number; + digest: string; +} + +export type SnapshotCacheRead = + | { readonly outcome: 'hit'; readonly snapshot: FreshSnapshot } + | { readonly outcome: 'miss' } + | { readonly outcome: 'invalidated'; readonly reason: InvalidationReason }; + +export interface ReadSnapshotCacheOptions { + readonly key: string; + readonly workspace: string; + readonly policy: FreshnessPolicy; + readonly validate: (value: unknown) => FreshPayload | null; +} + +function cacheKey(key: string): string { + return `${CACHE_PREFIX}:${key}`; +} + +function isStoredSnapshot(value: unknown): value is StoredSnapshot { + if (typeof value !== 'object' || value === null) return false; + const candidate = value as Record; + return ( + typeof candidate['data'] === 'object' && + candidate['data'] !== null && + typeof candidate['source'] === 'string' && + typeof candidate['workspace'] === 'string' && + typeof candidate['version'] === 'number' && + typeof candidate['schemaVersion'] === 'number' && + typeof candidate['fetchedAt'] === 'number' && + typeof candidate['digest'] === 'string' + ); +} + +function getStorage(): Storage | null { + try { + return globalThis.sessionStorage ?? null; + } catch { + return null; + } +} + +/** + * Restore a cached snapshot under the active workspace scope. Every failure + * mode maps to an explicit invalidation reason or a miss — never to data + * that renders as current. + */ +export function readSnapshotCache(options: ReadSnapshotCacheOptions): SnapshotCacheRead { + const storage = getStorage(); + if (storage === null) return { outcome: 'miss' }; + + let raw: string | null; + try { + raw = storage.getItem(cacheKey(options.key)); + } catch { + return { outcome: 'miss' }; + } + if (raw === null) return { outcome: 'miss' }; + + let parsed: unknown; + try { + parsed = JSON.parse(raw); + } catch { + return { outcome: 'invalidated', reason: 'cache-corruption' }; + } + if (!isStoredSnapshot(parsed)) { + return { outcome: 'invalidated', reason: 'cache-corruption' }; + } + if (parsed.workspace !== options.workspace) { + return { outcome: 'invalidated', reason: 'cross-workspace' }; + } + if (parsed.schemaVersion > options.policy.schemaVersion) { + // Written by a newer build than the running client: version regression. + return { outcome: 'invalidated', reason: 'version-regression' }; + } + + const payload = options.validate(parsed.data); + if (payload === null) { + return { outcome: 'invalidated', reason: 'schema-mismatch' }; + } + if (computeDigest(payload.data) !== parsed.digest) { + return { outcome: 'invalidated', reason: 'cache-corruption' }; + } + + return { + outcome: 'hit', + snapshot: { + data: payload.data, + source: parsed.source, + workspace: parsed.workspace, + version: parsed.version, + schemaVersion: parsed.schemaVersion, + fetchedAt: parsed.fetchedAt, + digest: parsed.digest, + }, + }; +} + +/** Persist a verified snapshot. Failures are non-fatal (cache is best-effort). */ +export function writeSnapshotCache(key: string, snapshot: FreshSnapshot): void { + const storage = getStorage(); + if (storage === null) return; + const stored: StoredSnapshot = { + data: snapshot.data, + source: snapshot.source, + workspace: snapshot.workspace, + version: snapshot.version, + schemaVersion: snapshot.schemaVersion, + fetchedAt: snapshot.fetchedAt, + digest: snapshot.digest, + }; + try { + storage.setItem(cacheKey(key), JSON.stringify(stored)); + } catch { + // Quota or serialization failures simply skip caching. + } +} + +/** Drop a cached snapshot (used when a surface invalidates its cache entry). */ +export function clearSnapshotCache(key: string): void { + const storage = getStorage(); + if (storage === null) return; + try { + storage.removeItem(cacheKey(key)); + } catch { + // Ignorable: a wedged storage entry is detected as corruption on read. + } +} diff --git a/apps/web/src/lib/freshness/use-fresh-collection.spec.tsx b/apps/web/src/lib/freshness/use-fresh-collection.spec.tsx new file mode 100644 index 00000000..bd218c94 --- /dev/null +++ b/apps/web/src/lib/freshness/use-fresh-collection.spec.tsx @@ -0,0 +1,372 @@ +import { act } from 'react'; +import { createRoot, type Root } from 'react-dom/client'; +import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'; +import type { Task } from '@/lib/types'; +import { acceptSnapshot, StaleMutationError, DEFAULT_FRESHNESS_POLICY } from './model'; +import type { FreshnessFailure } from './use-fresh-collection'; +import { + describeFailure, + useFreshCollection, + type FreshCollection, + type UseFreshCollectionOptions, +} from './use-fresh-collection'; +import { validateProjectCollection, validateTaskCollection } from './validators'; +import { projectFixtures, taskFixtures } from '@/spa/pages/page-fixtures'; + +/** + * Failure-matrix coverage for the freshness seam (RI-5-001): network failure, + * auth failure, malformed response, cache corruption, stale age, schema + * mismatch, cross-workspace, recovery, and stale-action rejection — with + * negative controls proving no case yields current data or an enabled + * mutation. + */ + +const NOW = 1_800_000_000_000; + +interface Deferred { + promise: Promise; + resolve: (value: T) => void; + reject: (reason?: unknown) => void; +} + +function createDeferred(): Deferred { + let resolve!: (value: T) => void; + let reject!: (reason?: unknown) => void; + const promise = new Promise((res, rej) => { + resolve = res; + reject = rej; + }); + return { promise, resolve, reject }; +} + +let root: Root | null = null; +let container: HTMLDivElement; +let latest: FreshCollection | null = null; + +function Probe({ + options, +}: { + options: UseFreshCollectionOptions; +}): React.ReactElement | null { + latest = useFreshCollection(options); + return null; +} + +beforeAll(() => { + Object.defineProperty(globalThis, 'IS_REACT_ACT_ENVIRONMENT', { + configurable: true, + value: true, + }); +}); + +beforeEach(() => { + sessionStorage.clear(); +}); + +afterEach(async () => { + await act(async () => { + root?.unmount(); + }); + document.body.replaceChildren(); + root = null; + latest = null; + sessionStorage.clear(); + vi.restoreAllMocks(); +}); + +async function renderCollection( + options: UseFreshCollectionOptions, +): Promise> { + container = document.createElement('div'); + document.body.append(container); + root = createRoot(container); + await act(async () => { + root?.render(); + }); + if (latest === null) throw new Error('hook did not run'); + return latest; +} + +function taskOptions( + overrides: Partial> = {}, +): UseFreshCollectionOptions { + return { + source: 'gateway:/api/tasks', + fetcher: () => Promise.resolve(taskFixtures), + validate: validateTaskCollection, + cacheKey: 'tasks', + clock: () => NOW, + ...overrides, + }; +} + +function authError(statusCode: number): Error & { statusCode: number } { + return Object.assign(new Error(`Request failed with ${statusCode}`), { statusCode }); +} + +function seedCache(key: string): number { + const result = acceptSnapshot({ + value: taskFixtures, + validate: validateTaskCollection, + previous: null, + policy: DEFAULT_FRESHNESS_POLICY, + source: 'gateway:/api/tasks', + now: NOW, + }); + if (result.outcome !== 'accepted') throw new Error('fixture setup failed'); + sessionStorage.setItem(`mosaic:freshness:v1:${key}`, JSON.stringify({ ...result.snapshot })); + return result.snapshot.version; +} + +describe('useFreshCollection failure matrix', () => { + it('is unknown (not empty) while the first validation is in flight', async () => { + const deferred = createDeferred(); + const collection = await renderCollection(taskOptions({ fetcher: () => deferred.promise })); + + expect(collection.freshness).toBe('unknown'); + expect(collection.validating).toBe(true); + expect(collection.data).toBeNull(); + expect(collection.canMutate).toBe(false); + + await act(async () => { + deferred.resolve(taskFixtures); + await deferred.promise; + }); + }); + + it('becomes current with provenance after a verified fetch', async () => { + const collection = await renderCollection(taskOptions()); + + expect(collection.freshness).toBe('current'); + expect(collection.data).toEqual(taskFixtures); + expect(collection.snapshot?.source).toBe('gateway:/api/tasks'); + expect(collection.snapshot?.version).toBe(1); + expect(collection.failure).toBeNull(); + expect(collection.canMutate).toBe(true); + // Verified snapshot is persisted for last-known restore. + expect(sessionStorage.getItem('mosaic:freshness:v1:tasks')).toBeTruthy(); + }); + + it('treats a network failure as unavailable — never an empty healthy collection', async () => { + const collection = await renderCollection( + taskOptions({ fetcher: () => Promise.reject(new Error('network down')) }), + ); + + expect(collection.freshness).toBe('unavailable'); + expect(collection.data).toBeNull(); + expect(collection.failure).toEqual({ kind: 'fetch', message: 'network down' }); + expect(collection.canMutate).toBe(false); + expect(describeFailure(collection.failure)).toBe('network down'); + }); + + it('treats an auth failure as unavailable and drops the last-known snapshot', async () => { + let call = 0; + const collection = await renderCollection( + taskOptions({ + fetcher: () => { + call += 1; + return call === 1 ? Promise.resolve(taskFixtures) : Promise.reject(authError(401)); + }, + }), + ); + expect(collection.freshness).toBe('current'); + + await act(async () => { + await collection.revalidate(); + }); + + expect(latest?.freshness).toBe('unavailable'); + expect(latest?.data).toBeNull(); + expect(latest?.failure?.kind).toBe('fetch'); + // The previous user's data must not linger in the session cache. + expect(sessionStorage.getItem('mosaic:freshness:v1:tasks')).toBeNull(); + }); + + it('invalidates a malformed response as a schema mismatch', async () => { + const collection = await renderCollection( + taskOptions({ fetcher: () => Promise.resolve({ malformed: true }) }), + ); + + expect(collection.freshness).toBe('unavailable'); + expect(collection.data).toBeNull(); + expect(collection.failure).toEqual({ kind: 'invalidated', reason: 'schema-mismatch' }); + expect(collection.canMutate).toBe(false); + }); + + it('keeps the previous snapshot as labeled stale when a later payload mismatches', async () => { + let call = 0; + const collection = await renderCollection( + taskOptions({ + fetcher: () => { + call += 1; + return call === 1 ? Promise.resolve(taskFixtures) : Promise.resolve('garbage'); + }, + }), + ); + expect(collection.freshness).toBe('current'); + + await act(async () => { + await collection.revalidate(); + }); + + expect(latest?.freshness).toBe('stale'); + expect(latest?.data).toEqual(taskFixtures); + expect(latest?.failure).toEqual({ kind: 'invalidated', reason: 'schema-mismatch' }); + expect(latest?.canMutate).toBe(false); + }); + + it('drops the snapshot when the workspace changes under it (cross-workspace)', async () => { + let call = 0; + const collection = await renderCollection( + taskOptions({ + fetcher: () => { + call += 1; + return Promise.resolve( + call === 1 ? projectFixtures : [{ ...projectFixtures[0], userId: 'user-2' }], + ); + }, + validate: validateProjectCollection as unknown as (value: unknown) => { + data: Task[]; + workspace: string | null; + }, + source: 'gateway:/api/projects', + }), + ); + expect(collection.freshness).toBe('current'); + + await act(async () => { + await collection.revalidate(); + }); + + expect(latest?.freshness).toBe('unavailable'); + expect(latest?.data).toBeNull(); + expect(latest?.failure).toEqual({ kind: 'invalidated', reason: 'cross-workspace' }); + }); + + it('ages from current to stale and refuses mutations on stale data', async () => { + let fakeNow = NOW; + const collection = await renderCollection( + taskOptions({ + clock: () => fakeNow, + policy: { staleAfterMs: 40 }, + tickMs: 10, + }), + ); + expect(collection.freshness).toBe('current'); + + // Age the snapshot past the policy and let the tick recompute. + fakeNow = NOW + 60; + await act(async () => { + await new Promise((resolve) => setTimeout(resolve, 25)); + }); + + expect(latest?.freshness).toBe('stale'); + expect(latest?.data).toEqual(taskFixtures); + expect(latest?.canMutate).toBe(false); + + const operation = vi.fn(async () => 'result'); + await expect(latest?.mutate(operation)).rejects.toBeInstanceOf(StaleMutationError); + expect(operation).not.toHaveBeenCalled(); + }); + + it('recovers to current after a successful revalidation', async () => { + let call = 0; + const collection = await renderCollection( + taskOptions({ + fetcher: () => { + call += 1; + return call === 1 + ? Promise.reject(new Error('first attempt failed')) + : Promise.resolve(taskFixtures); + }, + }), + ); + expect(collection.freshness).toBe('unavailable'); + + await act(async () => { + await collection.revalidate(); + }); + + expect(latest?.freshness).toBe('current'); + expect(latest?.failure).toBeNull(); + + const operation = vi.fn(async (data: Task[]) => data.length); + await expect(latest?.mutate(operation)).resolves.toBe(taskFixtures.length); + expect(operation).toHaveBeenCalledOnce(); + }); + + it('restores a cached snapshot as unverified stale data, then verifies it', async () => { + const seededVersion = seedCache('tasks'); + const deferred = createDeferred(); + const collection = await renderCollection(taskOptions({ fetcher: () => deferred.promise })); + + // Restored data is situational awareness only: labeled stale, never + // current, and mutations are refused before verification. + expect(collection.freshness).toBe('stale'); + expect(collection.data).toEqual(taskFixtures); + expect(collection.canMutate).toBe(false); + await expect(collection.mutate(vi.fn())).rejects.toBeInstanceOf(StaleMutationError); + + await act(async () => { + deferred.resolve(taskFixtures); + await deferred.promise; + }); + + expect(latest?.freshness).toBe('current'); + expect(latest?.snapshot?.version).toBe(seededVersion + 1); + }); + + it('never promotes corrupted cache data to current (cache corruption)', async () => { + sessionStorage.setItem('mosaic:freshness:v1:tasks', '{"data":'); + const collection = await renderCollection( + taskOptions({ fetcher: () => Promise.reject(new Error('still down')) }), + ); + + expect(collection.freshness).toBe('unavailable'); + expect(collection.data).toBeNull(); + expect(collection.canMutate).toBe(false); + // The corrupted entry is dropped so it cannot come back. + expect(sessionStorage.getItem('mosaic:freshness:v1:tasks')).toBeNull(); + }); + + it('refuses mutations while unknown or unavailable — the call itself, not just the button', async () => { + const deferred = createDeferred(); + const unknown = await renderCollection(taskOptions({ fetcher: () => deferred.promise })); + const operation = vi.fn(async () => 'result'); + await expect(unknown.mutate(operation)).rejects.toBeInstanceOf(StaleMutationError); + expect(operation).not.toHaveBeenCalled(); + await act(async () => { + deferred.reject(new Error('failed')); + await deferred.promise.catch(() => undefined); + }); + + const unavailable = latest!; + await expect(unavailable.mutate(operation)).rejects.toBeInstanceOf(StaleMutationError); + expect(operation).not.toHaveBeenCalled(); + expect(unavailable.canMutate).toBe(false); + }); + + it('degrades to stale with last-known data when a revalidation fails after success', async () => { + let call = 0; + const collection = await renderCollection( + taskOptions({ + fetcher: () => { + call += 1; + return call === 1 + ? Promise.resolve(taskFixtures) + : Promise.reject(new Error('connection lost')); + }, + }), + ); + expect(collection.freshness).toBe('current'); + + await act(async () => { + await collection.revalidate(); + }); + + expect(latest?.freshness).toBe('stale'); + expect(latest?.data).toEqual(taskFixtures); + const failure: FreshnessFailure | null = latest?.failure ?? null; + expect(failure).toEqual({ kind: 'fetch', message: 'connection lost' }); + }); +}); diff --git a/apps/web/src/lib/freshness/use-fresh-collection.ts b/apps/web/src/lib/freshness/use-fresh-collection.ts new file mode 100644 index 00000000..4819c07e --- /dev/null +++ b/apps/web/src/lib/freshness/use-fresh-collection.ts @@ -0,0 +1,281 @@ +import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import { + acceptSnapshot, + assertMutable, + computeFreshness, + DEFAULT_FRESHNESS_POLICY, + invalidationReasonLabels, + type FreshPayload, + type FreshSnapshot, + type FreshnessPolicy, + type FreshnessState, + type InvalidationReason, + StaleMutationError, +} from './model'; +import { clearSnapshotCache, readSnapshotCache, writeSnapshotCache } from './snapshot-cache'; + +/** + * Freshness-aware collection fetch hook (RI-5-001). + * + * One hook owns one gateway collection end to end: fetch, schema validation, + * snapshot acceptance with provenance, session-scoped last-known caching, + * aging, and the mutation guard. Pages consume `freshness` and never infer + * health from emptiness. + */ + +/** Why the latest validation did not produce a current snapshot. */ +export type FreshnessFailure = + | { readonly kind: 'fetch'; readonly message: string } + | { readonly kind: 'invalidated'; readonly reason: InvalidationReason }; + +export interface UseFreshCollectionOptions { + /** Source identity for provenance labels, e.g. `gateway:/api/tasks`. */ + readonly source: string; + /** Performs the unvalidated fetch. The hook owns abort and verification. */ + readonly fetcher: (signal: AbortSignal) => Promise; + /** + * Runtime schema validator. Returning `null` invalidates the payload + * (`schema-mismatch`) instead of letting malformed JSON flow into render. + */ + readonly validate: (value: unknown) => FreshPayload | null; + /** Overrides of the default freshness policy. */ + readonly policy?: Partial; + /** + * Session cache key for last-known snapshots. `null`/omitted disables + * restore. Restored snapshots are unverified: they render only as + * labeled `stale` data until a fetch re-verifies them. + */ + readonly cacheKey?: string | null; + /** Injectable clock for deterministic age transitions in tests. */ + readonly clock?: () => number; + /** Aging tick interval override (default derived from `staleAfterMs`). */ + readonly tickMs?: number; + /** When false, no fetch runs (surfaces stay `unavailable`/`unknown`). */ + readonly enabled?: boolean; +} + +export interface FreshCollection { + /** Last verified (or restored-unverified) snapshot, or `null`. */ + readonly snapshot: FreshSnapshot | null; + /** Snapshot data or `null` — never a fabricated empty collection. */ + readonly data: T | null; + readonly freshness: FreshnessState; + /** True while a validation request is in flight. */ + readonly validating: boolean; + /** Outcome of the latest failed validation, `null` when healthy. */ + readonly failure: FreshnessFailure | null; + /** False unless freshness is `current`; drives disabled UI affordances. */ + readonly canMutate: boolean; + /** Re-run the fetch and re-verify. Always allowed (it is a read). */ + readonly revalidate: () => Promise; + /** + * Run a state-changing operation against verified-current data only. + * Rejects with `StaleMutationError` on any other state — the guard fires + * even if a disabled button was bypassed (defense in depth). + */ + readonly mutate: (operation: (data: T) => Promise) => Promise; +} + +const defaultClock = (): number => Date.now(); + +function resolveTickMs(policy: FreshnessPolicy, override?: number): number { + if (override !== undefined && override > 0) return override; + return Math.min(5_000, Math.max(250, Math.floor(policy.staleAfterMs / 4))); +} + +function isAuthFailure(caught: unknown): boolean { + return ( + typeof caught === 'object' && + caught !== null && + 'statusCode' in caught && + ((caught as { statusCode?: unknown }).statusCode === 401 || + (caught as { statusCode?: unknown }).statusCode === 403) + ); +} + +function fetchFailureMessage(caught: unknown): string { + if (caught instanceof Error && caught.message.trim().length > 0) return caught.message; + return 'The request failed.'; +} + +/** Human-readable summary of a failure for unavailable/stale notices. */ +export function describeFailure(failure: FreshnessFailure | null): string | null { + if (failure === null) return null; + if (failure.kind === 'fetch') return failure.message; + return `The snapshot was invalidated: ${invalidationReasonLabels[failure.reason]}.`; +} + +export function useFreshCollection(options: UseFreshCollectionOptions): FreshCollection { + const optionsRef = useRef(options); + optionsRef.current = options; + + const policy = useMemo( + () => ({ ...DEFAULT_FRESHNESS_POLICY, ...options.policy }), + [options.policy], + ); + const policyRef = useRef(policy); + policyRef.current = policy; + + const clockRef = useRef(options.clock ?? defaultClock); + clockRef.current = options.clock ?? defaultClock; + + const [snapshot, setSnapshot] = useState | null>(null); + const [failure, setFailure] = useState(null); + const [unverified, setUnverified] = useState(false); + const [validating, setValidating] = useState(options.enabled !== false); + const [now, setNow] = useState(() => (options.clock ?? defaultClock)()); + + const snapshotRef = useRef(snapshot); + snapshotRef.current = snapshot; + const failureRef = useRef(failure); + failureRef.current = failure; + const unverifiedRef = useRef(unverified); + unverifiedRef.current = unverified; + + const runRef = useRef(0); + const abortRef = useRef(null); + + const revalidate = useCallback(async (): Promise => { + const current = optionsRef.current; + if (current.enabled === false) { + setValidating(false); + return; + } + + const runId = ++runRef.current; + abortRef.current?.abort(); + const controller = new AbortController(); + abortRef.current = controller; + setValidating(true); + + let value: unknown; + try { + value = await current.fetcher(controller.signal); + } catch (caught) { + if (runRef.current !== runId || controller.signal.aborted) return; + if (isAuthFailure(caught)) { + // An unauthenticated viewer must not keep (or be served) the + // previous user's last-known data. + setSnapshot(null); + setUnverified(false); + if (current.cacheKey) clearSnapshotCache(current.cacheKey); + } + setFailure({ kind: 'fetch', message: fetchFailureMessage(caught) }); + setValidating(false); + return; + } + + if (runRef.current !== runId) return; + + const result = acceptSnapshot({ + value, + validate: current.validate, + previous: snapshotRef.current, + policy: policyRef.current, + source: current.source, + now: clockRef.current(), + }); + + if (result.outcome === 'accepted') { + setSnapshot(result.snapshot); + setUnverified(false); + setFailure(null); + if (current.cacheKey) writeSnapshotCache(current.cacheKey, result.snapshot); + } else { + if (result.reason === 'cross-workspace') { + // Data verified for a different workspace must not linger as + // last-known situational awareness either. + setSnapshot(null); + setUnverified(false); + } + if (current.cacheKey) clearSnapshotCache(current.cacheKey); + setFailure({ kind: 'invalidated', reason: result.reason }); + } + setValidating(false); + }, []); + + // Restore the last-known snapshot (unverified) and run the first fetch. + useEffect(() => { + if (optionsRef.current.enabled === false) { + setValidating(false); + return; + } + + const cacheKey = optionsRef.current.cacheKey; + if (cacheKey) { + const restored = readSnapshotCache({ + key: cacheKey, + workspace: policyRef.current.workspace, + policy: policyRef.current, + validate: optionsRef.current.validate, + }); + if (restored.outcome === 'hit') { + setSnapshot(restored.snapshot); + setUnverified(true); + } else if (restored.outcome === 'invalidated') { + // A corrupted/foreign/regressed entry is dropped immediately; it must + // never surface as data. The fetch decides the visible state. + clearSnapshotCache(cacheKey); + } + } + + void revalidate(); + + return () => { + abortRef.current?.abort(); + }; + // Mount-once by design: `revalidate` is stable and reads live options + // through refs, so it never needs to re-run when options change. + // Route-param pages remount this hook via an identity `key` instead. + }, [revalidate]); + + // Aging tick: recomputes freshness as the snapshot ages past the policy. + useEffect(() => { + const interval = setInterval( + () => { + setNow(clockRef.current()); + }, + resolveTickMs(policyRef.current, optionsRef.current.tickMs), + ); + return () => clearInterval(interval); + }, []); + + const freshness = useMemo(() => { + if (snapshot === null) return validating ? 'unknown' : 'unavailable'; + return computeFreshness({ + snapshot, + policy, + now, + degraded: failure !== null || unverified, + }); + // `now` from state covers age; refs inside computeFreshness are pure. + }, [snapshot, validating, failure, unverified, now, policy]); + + const canMutate = freshness === 'current'; + + const mutate = useCallback(async (operation: (data: T) => Promise): Promise => { + const currentSnapshot = snapshotRef.current; + // No verified snapshot at all: with nothing verified there is nothing + // current to mutate, regardless of the recorded failure. + if (currentSnapshot === null) throw new StaleMutationError('unavailable'); + const state = computeFreshness({ + snapshot: currentSnapshot, + policy: policyRef.current, + now: clockRef.current(), + degraded: failureRef.current !== null || unverifiedRef.current, + }); + assertMutable(state); + return operation(currentSnapshot.data); + }, []); + + return { + snapshot, + data: snapshot === null ? null : snapshot.data, + freshness, + validating, + failure, + canMutate, + revalidate, + mutate, + }; +} diff --git a/apps/web/src/lib/freshness/validators.spec.ts b/apps/web/src/lib/freshness/validators.spec.ts new file mode 100644 index 00000000..7f6d3ec2 --- /dev/null +++ b/apps/web/src/lib/freshness/validators.spec.ts @@ -0,0 +1,103 @@ +import { describe, expect, it } from 'vitest'; +import type { Mission, Project, Task } from '@/lib/types'; +import { + validateMissionCollection, + validateProjectCollection, + validateProjectEntity, + validateTaskCollection, +} from './validators'; +import { missionFixtures, projectFixtures, taskFixtures } from '@/spa/pages/page-fixtures'; + +describe('validateTaskCollection', () => { + it('accepts a well-formed task collection', () => { + expect(validateTaskCollection(taskFixtures)).toEqual({ + data: taskFixtures, + workspace: null, + }); + }); + + it('accepts an empty collection (a healthy empty state is a valid payload)', () => { + expect(validateTaskCollection([])).toEqual({ data: [], workspace: null }); + }); + + it.each([ + ['not an array', { items: [] }], + ['item is not an object', ['nope']], + ['missing id', [{ ...(taskFixtures[0] as Task), id: undefined }]], + ['missing title', [{ ...(taskFixtures[0] as Task), title: undefined }]], + ['unknown status enum', [{ ...(taskFixtures[0] as Task), status: 'finished' }]], + ['unknown priority enum', [{ ...(taskFixtures[0] as Task), priority: 'urgent' }]], + ['tags of the wrong type', [{ ...(taskFixtures[0] as Task), tags: 'spa' }]], + ['metadata of the wrong type', [{ ...(taskFixtures[0] as Task), metadata: 'notes' }]], + ['createdAt of the wrong type', [{ ...(taskFixtures[0] as Task), createdAt: 1234 }]], + ['null sneaks past a required string', [{ ...(taskFixtures[0] as Task), title: null }]], + ])('rejects a malformed payload: %s', (_label, value) => { + expect(validateTaskCollection(value)).toBeNull(); + }); +}); + +describe('validateMissionCollection', () => { + it('accepts a well-formed mission collection', () => { + expect(validateMissionCollection(missionFixtures)).toEqual({ + data: missionFixtures, + workspace: null, + }); + }); + + it.each([ + ['not an array', null], + ['item missing name', [{ ...(missionFixtures[0] as Mission), name: 42 }]], + ['unknown status enum', [{ ...(missionFixtures[0] as Mission), status: 'canceled' }]], + ['projectId of the wrong type', [{ ...(missionFixtures[0] as Mission), projectId: 7 }]], + ])('rejects a malformed payload: %s', (_label, value) => { + expect(validateMissionCollection(value)).toBeNull(); + }); +}); + +describe('validateProjectCollection', () => { + it('accepts a uniform workspace-scoped collection and reports its workspace', () => { + expect(validateProjectCollection(projectFixtures)).toEqual({ + data: projectFixtures, + workspace: 'user-1', + }); + }); + + it('accepts an empty collection with no workspace identity', () => { + expect(validateProjectCollection([])).toEqual({ data: [], workspace: null }); + }); + + it.each([ + ['not an array', 42], + ['item missing userId', [{ ...(projectFixtures[0] as Project), userId: undefined }]], + ['unknown status enum', [{ ...(projectFixtures[0] as Project), status: 'live' }]], + ['description of the wrong type', [{ ...(projectFixtures[0] as Project), description: 1 }]], + ])('rejects a malformed payload: %s', (_label, value) => { + expect(validateProjectCollection(value)).toBeNull(); + }); + + it('rejects a collection mixing workspace identities (cross-workspace leak)', () => { + const mixed = [ + projectFixtures[0] as Project, + { ...(projectFixtures[1] as Project), userId: 'user-2' }, + ]; + expect(validateProjectCollection(mixed)).toBeNull(); + }); +}); + +describe('validateProjectEntity', () => { + it('accepts a well-formed project and reports its workspace', () => { + expect(validateProjectEntity(projectFixtures[0])).toEqual({ + data: projectFixtures[0], + workspace: 'user-1', + }); + }); + + it.each([ + ['not an object', 'project-1'], + ['null', null], + ['array', [projectFixtures[0]]], + ['missing userId', [{ ...(projectFixtures[0] as Project), userId: null }]], + ])('rejects a malformed entity: %s', (_label, value) => { + expect(validateProjectEntity(value)).toBeNull(); + }); +}); diff --git a/apps/web/src/lib/freshness/validators.ts b/apps/web/src/lib/freshness/validators.ts new file mode 100644 index 00000000..6c51c8a1 --- /dev/null +++ b/apps/web/src/lib/freshness/validators.ts @@ -0,0 +1,135 @@ +import type { Mission, Project, Task, MissionStatus, TaskPriority, TaskStatus } from '@/lib/types'; +import type { FreshPayload } from './model'; + +/** + * Runtime schema validators for gateway collections (RI-5-001). + * + * `api()` returns untrusted JSON cast to `T`; these validators are the + * seam where a malformed response becomes an explicit schema mismatch + * instead of flowing into the render path as if it were healthy data. + */ + +const taskStatuses: readonly TaskStatus[] = [ + 'not-started', + 'in-progress', + 'blocked', + 'done', + 'cancelled', +]; +const taskPriorities: readonly TaskPriority[] = ['critical', 'high', 'medium', 'low']; +const missionStatuses: readonly MissionStatus[] = [ + 'planning', + 'active', + 'paused', + 'completed', + 'failed', +]; +const projectStatuses: readonly Project['status'][] = ['active', 'paused', 'completed', 'archived']; + +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value); +} + +function isString(value: unknown): value is string { + return typeof value === 'string'; +} + +function isNullableString(value: unknown): value is string | null { + return value === null || typeof value === 'string'; +} + +function isOneOf(value: unknown, allowed: readonly T[]): value is T { + return typeof value === 'string' && (allowed as readonly string[]).includes(value); +} + +function isNullableRecord(value: unknown): value is Record | null { + return value === null || isRecord(value); +} + +function isNullableStringArray(value: unknown): value is string[] | null { + if (value === null) return true; + if (!Array.isArray(value)) return false; + return value.every((item) => typeof item === 'string'); +} + +function isIsoLike(value: unknown): value is string { + return typeof value === 'string' && value.length > 0; +} + +function isTask(value: unknown): value is Task { + if (!isRecord(value)) return false; + return ( + isString(value['id']) && + isString(value['title']) && + isOneOf(value['status'], taskStatuses) && + isOneOf(value['priority'], taskPriorities) && + isNullableString(value['projectId']) && + isNullableString(value['missionId']) && + isNullableString(value['assignee']) && + isNullableStringArray(value['tags']) && + isNullableRecord(value['metadata']) && + isNullableString(value['dueDate']) && + isIsoLike(value['createdAt']) && + isIsoLike(value['updatedAt']) + ); +} + +/** Tasks carry no workspace identity; scope falls back to the policy. */ +export function validateTaskCollection(value: unknown): FreshPayload | null { + if (!Array.isArray(value) || !value.every(isTask)) return null; + return { data: value as Task[], workspace: null }; +} + +function isMission(value: unknown): value is Mission { + if (!isRecord(value)) return false; + return ( + isString(value['id']) && + isString(value['name']) && + isOneOf(value['status'], missionStatuses) && + isNullableString(value['projectId']) && + isNullableString(value['description']) && + isNullableRecord(value['metadata']) && + isIsoLike(value['createdAt']) && + isIsoLike(value['updatedAt']) + ); +} + +/** Missions carry no workspace identity; scope falls back to the policy. */ +export function validateMissionCollection(value: unknown): FreshPayload | null { + if (!Array.isArray(value) || !value.every(isMission)) return null; + return { data: value as Mission[], workspace: null }; +} + +function isProject(value: unknown): value is Project { + if (!isRecord(value)) return false; + return ( + isString(value['id']) && + isString(value['name']) && + isOneOf(value['status'], projectStatuses) && + isString(value['userId']) && + isNullableString(value['description']) && + isNullableRecord(value['metadata']) && + isIsoLike(value['createdAt']) && + isIsoLike(value['updatedAt']) + ); +} + +/** + * Projects are workspace-scoped: every item must carry the same `userId`. + * A collection mixing identities (cross-workspace leak) is a schema + * mismatch; the uniform `userId` becomes the snapshot workspace. + */ +export function validateProjectCollection(value: unknown): FreshPayload | null { + if (!Array.isArray(value) || !value.every(isProject)) return null; + const projects = value as Project[]; + const workspaces = new Set(projects.map((project) => project.userId)); + if (workspaces.size > 1) return null; + return { data: projects, workspace: projects.length > 0 ? projects[0]!.userId : null }; +} + +/** Single project entity (project detail primary collection). */ +export function validateProjectEntity(value: unknown): FreshPayload | null { + if (!isProject(value)) return null; + const project = value as Project; + return { data: project, workspace: project.userId }; +} diff --git a/apps/web/src/spa/pages/project-detail.spec.tsx b/apps/web/src/spa/pages/project-detail.spec.tsx index ffb7b795..22f0b695 100644 --- a/apps/web/src/spa/pages/project-detail.spec.tsx +++ b/apps/web/src/spa/pages/project-detail.spec.tsx @@ -35,6 +35,7 @@ afterEach(async () => { document.body.replaceChildren(); root = null; apiMock.mockReset(); + sessionStorage.clear(); }); async function renderProjectDetailPage(): Promise> { @@ -64,21 +65,49 @@ function clickButtonByText(text: string): void { button.dispatchEvent(new MouseEvent('click', { bubbles: true })); } +async function flushAct(): Promise { + await act(async () => { + await Promise.resolve(); + }); +} + +interface Deferred { + promise: Promise; + resolve: (value: T) => void; +} + +function createDeferred(): Deferred { + let resolve!: (value: T) => void; + const promise = new Promise((res) => { + resolve = res; + }); + return { promise, resolve }; +} + +const projectOneTasks = taskFixtures.filter((task) => task.projectId === 'project-1'); + +function mockHealthyLoad(): void { + apiMock + .mockResolvedValueOnce(projectFixtures[0]) + .mockResolvedValueOnce(missionFixtures) + .mockResolvedValueOnce(projectOneTasks); +} + describe('ProjectDetailPage', () => { it('loads the project, tasks, missions, and optional PRD content for the active project', async () => { - apiMock - .mockResolvedValueOnce(projectFixtures[0]) - .mockResolvedValueOnce(missionFixtures) - .mockResolvedValueOnce(taskFixtures.filter((task) => task.projectId === 'project-1')); + mockHealthyLoad(); await renderProjectDetailPage(); - expect(apiMock.mock.calls).toEqual([ - ['/api/projects/project-1'], - ['/api/missions'], - ['/api/tasks?projectId=project-1'], + expect(apiMock.mock.calls.map((call) => call[0])).toEqual([ + '/api/projects/project-1', + '/api/missions', + '/api/tasks?projectId=project-1', ]); + expect(container.querySelector('[data-freshness]')?.getAttribute('data-freshness')).toBe( + 'current', + ); expect(container.textContent).toContain('Mosaic Stack'); expect(container.textContent).toContain('Route /projects/:id'); expect(container.textContent).toContain('Tasks'); @@ -101,10 +130,7 @@ describe('ProjectDetailPage', () => { }); it('opens and closes the existing read-only task modal from the tasks tab', async () => { - apiMock - .mockResolvedValueOnce(projectFixtures[0]) - .mockResolvedValueOnce(missionFixtures) - .mockResolvedValueOnce(taskFixtures.filter((task) => task.projectId === 'project-1')); + mockHealthyLoad(); await renderProjectDetailPage(); @@ -134,35 +160,153 @@ describe('ProjectDetailPage', () => { expect(container.querySelector('[role="dialog"]')).toBeNull(); }); - it('renders the project with an empty missions tab when the missions request fails', async () => { - apiMock - .mockResolvedValueOnce(projectFixtures[0]) - .mockRejectedValueOnce(new Error('Missions request failed')) - .mockResolvedValueOnce(taskFixtures.filter((task) => task.projectId === 'project-1')); + it('shows verified completion verdicts when the task collection is current', async () => { + mockHealthyLoad(); await renderProjectDetailPage(); - expect(container.textContent).toContain('Mosaic Stack'); - expect(container.querySelector('[role="alert"]')).toBeNull(); - - await act(async () => { - clickButtonByText('Missions (0)'); - }); - - expect(container.textContent).toContain('No missions for this project'); + const doneCard = [...container.querySelectorAll('div')].find( + (candidate) => candidate.textContent === 'Done1', + ); + expect(doneCard).toBeTruthy(); + const inProgressCard = [...container.querySelectorAll('div')].find( + (candidate) => candidate.textContent === 'In Progress1', + ); + expect(inProgressCard).toBeTruthy(); }); - it('renders a visible alert when the project request fails and lets the user navigate back', async () => { + it('renders an explicit unavailable missions tab when the missions request fails (partial, not empty)', async () => { + apiMock + .mockResolvedValueOnce(projectFixtures[0]) + .mockRejectedValueOnce(new Error('Missions request failed')) + .mockResolvedValueOnce(projectOneTasks); + + await renderProjectDetailPage(); + + // Secondary failure degrades the surface to partial; the project itself + // still renders. + expect(container.querySelector('[data-freshness]')?.getAttribute('data-freshness')).toBe( + 'partial', + ); + expect(container.textContent).toContain('Mosaic Stack'); + const partial = container.querySelector('[role="status"]'); + expect(partial?.textContent).toContain('Missions'); + expect(partial?.textContent).toContain('unavailable'); + + await act(async () => { + clickButtonByText('Missions (?)'); + }); + + const alert = container.querySelector('[role="alert"]'); + expect(alert?.textContent).toContain('Missions request failed'); + // Negative control: a failed fetch must not look like an empty list. + expect(container.textContent).not.toContain('No missions for this project'); + }); + + it('marks derived verdicts unknown when the tasks collection is unavailable', async () => { + apiMock + .mockResolvedValueOnce(projectFixtures[0]) + .mockResolvedValueOnce(missionFixtures) + .mockRejectedValueOnce(new Error('Tasks request failed')); + + await renderProjectDetailPage(); + + expect(container.querySelector('[data-freshness]')?.getAttribute('data-freshness')).toBe( + 'partial', + ); + + // Completion verdicts become unknown ('?') — never green counts. + for (const label of ['Done', 'In Progress', 'Blocked', 'Tasks']) { + const unknownCard = [...container.querySelectorAll('div')].find( + (candidate) => candidate.textContent === `${label}?`, + ); + expect(unknownCard, `expected ${label} card to render ?`).toBeTruthy(); + } + // Negative control: no green "Done 1" verdict anywhere. + expect( + [...container.querySelectorAll('div')].some((candidate) => candidate.textContent === 'Done1'), + ).toBe(false); + + await act(async () => { + clickButtonByText('Tasks (?)'); + }); + + const alert = container.querySelector('[role="alert"]'); + expect(alert?.textContent).toContain('Tasks request failed'); + // Negative control: no healthy empty task list from a failed fetch. + expect(container.textContent).not.toContain('No tasks found'); + expect(container.querySelector('table')).toBeNull(); + }); + + it('recovers a partial surface to current after revalidation', async () => { + apiMock + .mockResolvedValueOnce(projectFixtures[0]) + .mockResolvedValueOnce(missionFixtures) + .mockRejectedValueOnce(new Error('Tasks request failed')) + .mockResolvedValueOnce(projectFixtures[0]) + .mockResolvedValueOnce(missionFixtures) + .mockResolvedValueOnce(projectOneTasks); + + await renderProjectDetailPage(); + expect(container.querySelector('[data-freshness]')?.getAttribute('data-freshness')).toBe( + 'partial', + ); + + await act(async () => { + clickButtonByText('Revalidate'); + }); + await flushAct(); + + expect(container.querySelector('[data-freshness]')?.getAttribute('data-freshness')).toBe( + 'current', + ); + expect( + [...container.querySelectorAll('div')].some((candidate) => candidate.textContent === 'Done1'), + ).toBe(true); + }); + + it("never shows one project's data on another project's route after navigation", async () => { + mockHealthyLoad(); + + const router = await renderProjectDetailPage(); + expect(container.textContent).toContain('Mosaic Stack'); + + const deferred = createDeferred<(typeof projectFixtures)[number]>(); + apiMock + .mockResolvedValueOnce(deferred.promise) + .mockResolvedValueOnce([]) + .mockResolvedValueOnce([]); + + await act(async () => { + await router.navigate('/projects/project-2'); + }); + + // While project-2 loads, nothing from project-1 may render on its route. + expect(container.textContent).toContain('Loading project...'); + expect(container.textContent).not.toContain('Mosaic Stack'); + expect(container.textContent).not.toContain('Route /projects/:id'); + + await act(async () => { + deferred.resolve(projectFixtures[1]!); + await deferred.promise; + }); + + expect(container.textContent).toContain('Agent Runtime'); + expect(apiMock.mock.calls[3]?.[0]).toBe('/api/projects/project-2'); + }); + + it('renders a visible unavailable state when the project request fails and lets the user navigate back', async () => { apiMock .mockRejectedValueOnce(new Error('Project request failed')) .mockResolvedValueOnce(missionFixtures) - .mockResolvedValueOnce(taskFixtures.filter((task) => task.projectId === 'project-1')); + .mockResolvedValueOnce(projectOneTasks); const router = await renderProjectDetailPage(); const alert = container.querySelector('[role="alert"]'); expect(alert).toBeTruthy(); expect(alert?.textContent).toContain('Project request failed'); + expect(alert?.textContent).toContain('not an empty result'); expect(container.textContent).not.toContain('Mosaic Stack'); await act(async () => { diff --git a/apps/web/src/spa/pages/project-detail.tsx b/apps/web/src/spa/pages/project-detail.tsx index 20253b5e..c0907ad6 100644 --- a/apps/web/src/spa/pages/project-detail.tsx +++ b/apps/web/src/spa/pages/project-detail.tsx @@ -1,14 +1,30 @@ -import { useEffect, useState, type ReactElement } from 'react'; +import { useState, type ReactElement } from 'react'; import { useNavigate, useParams } from 'react-router-dom'; import { MissionTimeline } from '@/components/projects/mission-timeline'; import { PrdViewer } from '@/components/projects/prd-viewer'; import { TaskDetailModal } from '@/components/tasks/task-detail-modal'; import { TaskListView } from '@/components/tasks/task-list-view'; import { TaskStatusSummary } from '@/components/tasks/task-status-summary'; +import { + PartialDataNotice, + StaleDataNotice, + UnavailableDataNotice, +} from '@/components/freshness/freshness-notices'; import { api } from '@/lib/api'; import { cn } from '@/lib/cn'; import type { Mission, Project, Task, TaskStatus } from '@/lib/types'; -import { getErrorMessage } from './page-errors'; +import { + combineFreshness, + UNKNOWN_VERDICT, + verdictValue, + type FreshSnapshot, +} from '@/lib/freshness/model'; +import { describeFailure, useFreshCollection } from '@/lib/freshness/use-fresh-collection'; +import { + validateMissionCollection, + validateProjectEntity, + validateTaskCollection, +} from '@/lib/freshness/validators'; type Tab = 'overview' | 'tasks' | 'missions' | 'prd'; @@ -51,73 +67,62 @@ function TabButton({ id, label, activeTab, onClick }: TabButtonProps): ReactElem ); } +/** Remounts per project id so no state from one project renders for another. */ export function ProjectDetailPage(): ReactElement { const { id = '' } = useParams(); + return ; +} + +function ProjectDetail({ id }: { id: string }): ReactElement { const navigate = useNavigate(); - const [project, setProject] = useState(null); - const [missions, setMissions] = useState([]); - const [tasks, setTasks] = useState([]); - const [loading, setLoading] = useState(true); - const [error, setError] = useState(null); + const enabled = id.length > 0; + + // Primary collection gates the surface; missions and tasks are secondaries + // whose failures degrade the surface to `partial` instead of rendering + // empty healthy lists. + const project = useFreshCollection({ + source: `gateway:/api/projects/${id}`, + fetcher: (signal) => api(`/api/projects/${id}`, { signal }), + validate: validateProjectEntity, + // No last-known restore: the entity carries workspace identity that + // cannot be scope-checked before display (see ProjectsPage note). + enabled, + }); + const missions = useFreshCollection({ + source: 'gateway:/api/missions', + fetcher: (signal) => api('/api/missions', { signal }), + validate: validateMissionCollection, + cacheKey: enabled ? 'missions' : null, + enabled, + }); + const tasks = useFreshCollection({ + source: `gateway:/api/tasks?projectId=${id}`, + fetcher: (signal) => api(`/api/tasks?projectId=${id}`, { signal }), + validate: validateTaskCollection, + cacheKey: enabled ? `project-tasks:${id}` : null, + enabled, + }); + const [activeTab, setActiveTab] = useState('overview'); const [taskFilter, setTaskFilter] = useState('all'); const [selectedTask, setSelectedTask] = useState(null); - useEffect(() => { - if (!id) { - setError('Project id is missing.'); - setLoading(false); - return; - } + const surface = combineFreshness(project.freshness, [missions.freshness, tasks.freshness]); + const tasksVerified = tasks.freshness === 'current'; + const projectMissions = missions.data?.filter((mission) => mission.projectId === id) ?? null; - let cancelled = false; - setLoading(true); - setError(null); + const retryAll = (): void => { + void Promise.all([project.revalidate(), missions.revalidate(), tasks.revalidate()]); + }; - void Promise.all([ - api('/api/projects/' + id), - api('/api/missions').catch(() => [] as Mission[]), - api('/api/tasks?projectId=' + id).catch(() => [] as Task[]), - ]) - .then(([loadedProject, allMissions, loadedTasks]) => { - if (cancelled) return; - setProject(loadedProject); - setMissions(allMissions.filter((mission) => mission.projectId === id)); - setTasks(loadedTasks); - }) - .catch((caught: unknown) => { - if (cancelled) return; - setError(getErrorMessage(caught, 'Failed to load project.')); - }) - .finally(() => { - if (cancelled) return; - setLoading(false); - }); - - return () => { - cancelled = true; - }; - }, [id]); - - if (loading) { - return ( -
-
-

Project

-
-

Loading project...

-
- ); - } - - if (error || !project) { + if (!enabled) { return (

Project

- {error ?? 'Project not found.'} + Project id is missing.
+ ); + } + + if (project.freshness === 'unavailable' || project.data === null) { + return ( +
+
+

Project

+
+ + +
+ ); + } + + const projectTasks = tasks.data ?? null; const filteredTasks = - taskFilter === 'all' ? tasks : tasks.filter((task) => task.status === taskFilter); - const prdContent = getPrdContent(project); + projectTasks === null + ? [] + : taskFilter === 'all' + ? projectTasks + : projectTasks.filter((task) => task.status === taskFilter); + + // Derived completion verdicts: unknown (never green) unless the task + // collection is verified current. + const doneCount = projectTasks?.filter((task) => task.status === 'done').length ?? 0; + const inProgressCount = projectTasks?.filter((task) => task.status === 'in-progress').length ?? 0; + const blockedCount = projectTasks?.filter((task) => task.status === 'blocked').length ?? 0; + + const prdContent = getPrdContent(project.data); const tabs: Array<{ id: Tab; label: string }> = [ { id: 'overview', label: 'Overview' }, - { id: 'tasks', label: `Tasks (${tasks.length})` }, - { id: 'missions', label: `Missions (${missions.length})` }, + { + id: 'tasks', + label: `Tasks (${projectTasks === null ? UNKNOWN_VERDICT : projectTasks.length})`, + }, + { + id: 'missions', + label: `Missions (${projectMissions === null ? UNKNOWN_VERDICT : projectMissions.length})`, + }, ...(prdContent ? [{ id: 'prd' as const, label: 'PRD' }] : []), ]; + const staleSnapshot: FreshSnapshot | null = + project.freshness === 'stale' + ? project.snapshot + : missions.freshness === 'stale' + ? missions.snapshot + : tasks.freshness === 'stale' + ? tasks.snapshot + : null; + const missingSections: string[] = []; + if (missions.freshness === 'unavailable') missingSections.push('Missions'); + if (tasks.freshness === 'unavailable') missingSections.push('Tasks'); + return ( -
+
-

{project.name}

+

{project.data.name}

- {project.status} + {project.data.status}
- {project.description ? ( -

{project.description}

+ {project.data.description ? ( +

{project.data.description}

) : null}

- Created {new Date(project.createdAt).toLocaleDateString()} · Updated{' '} - {new Date(project.updatedAt).toLocaleDateString()} + Created {new Date(project.data.createdAt).toLocaleDateString()} · Updated{' '} + {new Date(project.data.updatedAt).toLocaleDateString()}

+ {staleSnapshot !== null ? ( +
+ +
+ ) : null} + + {missingSections.length > 0 ? ( +
+ +
+ ) : null} +
- + task.status === 'done').length)} - valueClass="text-success" + value={verdictValue(tasksVerified, String(doneCount))} + valueClass={tasksVerified ? 'text-success' : undefined} /> task.status === 'in-progress').length)} - valueClass="text-blue-400" + value={verdictValue(tasksVerified, String(inProgressCount))} + valueClass={tasksVerified ? 'text-blue-400' : undefined} /> task.status === 'blocked').length)} - valueClass={tasks.some((task) => task.status === 'blocked') ? 'text-error' : undefined} + value={verdictValue(tasksVerified, String(blockedCount))} + valueClass={tasksVerified && blockedCount > 0 ? 'text-error' : undefined} />
@@ -211,23 +294,43 @@ export function ProjectDetailPage(): ReactElement {
{activeTab === 'overview' ? ( - + ) : null} {activeTab === 'tasks' ? (
-
- -
- + ) : ( + <> +
+ +
+ + + )}
) : null} - {activeTab === 'missions' ? : null} + {activeTab === 'missions' ? ( + projectMissions === null ? ( + + ) : ( + + ) + ) : null} {activeTab === 'prd' && prdContent ? (
@@ -248,18 +351,26 @@ function OverviewTab({ tasks, }: { project: Project; - missions: Mission[]; - tasks: Task[]; + missions: Mission[] | null; + tasks: Task[] | null; }): ReactElement { - const recentTasks = [...tasks] - .sort((left, right) => new Date(right.updatedAt).getTime() - new Date(left.updatedAt).getTime()) - .slice(0, 5); + const recentTasks = + tasks === null + ? null + : [...tasks] + .sort( + (left, right) => + new Date(right.updatedAt).getTime() - new Date(left.updatedAt).getTime(), + ) + .slice(0, 5); return (

Recent Tasks

- {recentTasks.length === 0 ? ( + {recentTasks === null ? ( + + ) : recentTasks.length === 0 ? (

No tasks yet

@@ -287,7 +398,9 @@ function OverviewTab({

Missions

- {missions.length === 0 ? ( + {missions === null ? ( + + ) : missions.length === 0 ? (

No missions yet

diff --git a/apps/web/src/spa/pages/projects.spec.tsx b/apps/web/src/spa/pages/projects.spec.tsx index e030fc22..a682293b 100644 --- a/apps/web/src/spa/pages/projects.spec.tsx +++ b/apps/web/src/spa/pages/projects.spec.tsx @@ -51,6 +51,7 @@ afterEach(async () => { document.body.replaceChildren(); root = null; apiMock.mockReset(); + sessionStorage.clear(); }); async function renderProjectsPage(): Promise> { @@ -71,6 +72,22 @@ async function renderProjectsPage(): Promise + candidate.textContent?.includes(text), + ); + if (!button) { + throw new Error(`Button containing "${text}" not found`); + } + button.dispatchEvent(new MouseEvent('click', { bubbles: true })); +} + +async function flushAct(): Promise { + await act(async () => { + await Promise.resolve(); + }); +} + describe('ProjectsPage', () => { it('shows a visible loading state while the project request is in flight', async () => { const deferred = createDeferred(); @@ -91,7 +108,7 @@ describe('ProjectsPage', () => { const router = await renderProjectsPage(); - expect(apiMock).toHaveBeenCalledWith('/api/projects'); + expect(apiMock.mock.calls[0]?.[0]).toBe('/api/projects'); expect(container.textContent).toContain('Mosaic Stack'); expect(container.textContent).toContain('Agent Runtime'); @@ -108,7 +125,7 @@ describe('ProjectsPage', () => { expect(container.textContent).toContain('Project detail target'); }); - it('renders the empty state when the API returns no projects', async () => { + it('renders the empty state only for a verified empty collection', async () => { apiMock.mockResolvedValueOnce([]); await renderProjectsPage(); @@ -117,9 +134,12 @@ describe('ProjectsPage', () => { expect(container.textContent).toContain( 'Projects will appear here when created via the gateway API', ); + expect(container.querySelector('[data-freshness]')?.getAttribute('data-freshness')).toBe( + 'current', + ); }); - it('renders a visible alert when the projects request fails', async () => { + it('renders a failed fetch as an explicit unavailable state, never an empty collection', async () => { apiMock.mockRejectedValueOnce(new Error('Projects are unavailable')); await renderProjectsPage(); @@ -127,5 +147,51 @@ describe('ProjectsPage', () => { const alert = container.querySelector('[role="alert"]'); expect(alert).toBeTruthy(); expect(alert?.textContent).toContain('Projects are unavailable'); + expect(alert?.textContent).toContain('not an empty result'); + + // Negative controls: no healthy empty state and no project cards render + // from a failed fetch. + expect(container.textContent).not.toContain('No projects yet'); + expect(container.textContent).not.toContain('Mosaic Stack'); + expect(container.querySelector('[data-freshness]')?.getAttribute('data-freshness')).toBe( + 'unavailable', + ); + }); + + it('renders an auth failure as unavailable and recovers after retry', async () => { + apiMock + .mockRejectedValueOnce(Object.assign(new Error('Unauthorized'), { statusCode: 401 })) + .mockResolvedValueOnce(projectFixtures); + + await renderProjectsPage(); + + const alert = container.querySelector('[role="alert"]'); + expect(alert?.textContent).toContain('Unauthorized'); + expect(container.textContent).not.toContain('No projects yet'); + + await act(async () => { + clickButtonByText('Retry'); + }); + await flushAct(); + + expect(container.querySelector('[role="alert"]')).toBeNull(); + expect(container.textContent).toContain('Mosaic Stack'); + expect(container.querySelector('[data-freshness]')?.getAttribute('data-freshness')).toBe( + 'current', + ); + }); + + it('renders a schema-mismatched response as unavailable, never as data', async () => { + apiMock.mockResolvedValueOnce({ results: projectFixtures }); + + await renderProjectsPage(); + + const alert = container.querySelector('[role="alert"]'); + expect(alert?.textContent).toContain('not an empty result'); + expect(container.textContent).not.toContain('Mosaic Stack'); + expect(container.textContent).not.toContain('No projects yet'); + expect(container.querySelector('[data-freshness]')?.getAttribute('data-freshness')).toBe( + 'unavailable', + ); }); }); diff --git a/apps/web/src/spa/pages/projects.tsx b/apps/web/src/spa/pages/projects.tsx index 5ab7a746..df0a6e80 100644 --- a/apps/web/src/spa/pages/projects.tsx +++ b/apps/web/src/spa/pages/projects.tsx @@ -1,53 +1,51 @@ -import { useEffect, useState, type ReactElement } from 'react'; +import { type ReactElement } from 'react'; import { useNavigate } from 'react-router-dom'; import { ProjectCard } from '@/components/projects/project-card'; +import { StaleDataNotice, UnavailableDataNotice } from '@/components/freshness/freshness-notices'; import { api } from '@/lib/api'; import type { Project } from '@/lib/types'; -import { getErrorMessage } from './page-errors'; +import { useFreshCollection, describeFailure } from '@/lib/freshness/use-fresh-collection'; +import { validateProjectCollection } from '@/lib/freshness/validators'; export function ProjectsPage(): ReactElement { const navigate = useNavigate(); - const [projects, setProjects] = useState([]); - const [loading, setLoading] = useState(true); - const [error, setError] = useState(null); - - useEffect(() => { - let cancelled = false; - - void api('/api/projects') - .then((response) => { - if (cancelled) return; - setProjects(response); - }) - .catch((caught: unknown) => { - if (cancelled) return; - setError(getErrorMessage(caught, 'Failed to load projects.')); - }) - .finally(() => { - if (cancelled) return; - setLoading(false); - }); - - return () => { - cancelled = true; - }; - }, []); + const projects = useFreshCollection({ + source: 'gateway:/api/projects', + fetcher: (signal) => api('/api/projects', { signal }), + validate: validateProjectCollection, + // Projects carry workspace identity (userId) that is only knowable from + // the payload itself, so a restored entry cannot be scope-checked before + // display. Conservative choice: no last-known restore for this surface; + // cross-workspace switching is still invalidated at verification time. + }); + const retry = (): void => { + void projects.revalidate(); + }; return ( -
+

Projects

- {error ? ( -
- {error} + {projects.freshness === 'stale' && projects.snapshot ? ( +
+
) : null} - {loading ? ( + {projects.freshness === 'unknown' ? (

Loading projects...

- ) : projects.length === 0 ? ( + ) : projects.freshness === 'unavailable' ? ( + + ) : projects.data !== null && projects.data.length === 0 ? (

No projects yet

@@ -56,7 +54,7 @@ export function ProjectsPage(): ReactElement {

) : (
- {projects.map((project) => ( + {(projects.data ?? []).map((project) => ( ({ apiMock: vi.fn(), @@ -48,6 +51,7 @@ afterEach(async () => { document.body.replaceChildren(); root = null; apiMock.mockReset(); + sessionStorage.clear(); }); async function renderTasksPage(): Promise { @@ -72,6 +76,13 @@ function clickButtonByText(text: string): void { button.dispatchEvent(new MouseEvent('click', { bubbles: true })); } +/** Flush pending promise callbacks inside the act environment. */ +async function flushAct(): Promise { + await act(async () => { + await Promise.resolve(); + }); +} + describe('TasksPage', () => { it('shows a visible loading state before the tasks request settles', async () => { const deferred = createDeferred(); @@ -132,7 +143,7 @@ describe('TasksPage', () => { expect(container.textContent).toContain('Wire list and kanban modal interactions'); }); - it('renders a visible alert when the tasks request fails', async () => { + it('renders a failed fetch as an explicit unavailable state, never an empty healthy board', async () => { apiMock.mockRejectedValueOnce(new Error('Tasks request failed')); await renderTasksPage(); @@ -140,5 +151,80 @@ describe('TasksPage', () => { const alert = container.querySelector('[role="alert"]'); expect(alert).toBeTruthy(); expect(alert?.textContent).toContain('Tasks request failed'); + expect(alert?.textContent).toContain('not an empty result'); + + // Negative controls: no board, no healthy empty-state markers, and the + // surface is marked unavailable rather than current. + expect(container.textContent).not.toContain('Not Started'); + expect(container.textContent).not.toContain('No tasks'); + expect(container.querySelector('[data-freshness]')?.getAttribute('data-freshness')).toBe( + 'unavailable', + ); + }); + + it('recovers to a current board after retrying a failed fetch', async () => { + apiMock + .mockRejectedValueOnce(new Error('Tasks request failed')) + .mockResolvedValueOnce(taskFixtures); + + await renderTasksPage(); + expect(container.querySelector('[role="alert"]')).toBeTruthy(); + + await act(async () => { + clickButtonByText('Retry'); + }); + await flushAct(); + + expect(container.querySelector('[role="alert"]')).toBeNull(); + expect(container.textContent).toContain('Not Started'); + expect(container.querySelector('[data-freshness]')?.getAttribute('data-freshness')).toBe( + 'current', + ); + }); + + it('labels restored last-known data as stale with source, version, and age until verified', async () => { + // Seed a last-known snapshot fetched five minutes ago; the page must + // render it only under an explicit staleness label while the fetch is + // still in flight. + const restored = acceptSnapshot({ + value: taskFixtures, + validate: validateTaskCollection, + previous: null, + policy: DEFAULT_FRESHNESS_POLICY, + source: 'gateway:/api/tasks', + now: Date.now() - 5 * 60_000, + }); + if (restored.outcome !== 'accepted') throw new Error('fixture setup failed'); + writeSnapshotCache('tasks', restored.snapshot); + + const deferred = createDeferred(); + apiMock.mockReturnValueOnce(deferred.promise); + + await renderTasksPage(); + + expect(container.querySelector('[data-freshness]')?.getAttribute('data-freshness')).toBe( + 'stale', + ); + const banner = container.querySelector('[role="status"]'); + expect(banner?.textContent).toContain('last-known'); + expect(banner?.textContent).toContain('may be out of date'); + expect(banner?.textContent).toContain('gateway:/api/tasks'); + expect(banner?.textContent).toContain('snapshot v1'); + expect(banner?.textContent).toContain('5m ago'); + + // Last-known data still renders as situational awareness under the label. + expect(container.textContent).toContain('Route /tasks'); + expect(container.textContent).not.toContain('Loading tasks...'); + + // Verification lands: the banner clears and the surface becomes current. + await act(async () => { + deferred.resolve(taskFixtures); + await deferred.promise; + }); + + expect(container.querySelector('[role="status"]')).toBeNull(); + expect(container.querySelector('[data-freshness]')?.getAttribute('data-freshness')).toBe( + 'current', + ); }); }); diff --git a/apps/web/src/spa/pages/tasks.tsx b/apps/web/src/spa/pages/tasks.tsx index b388ba95..ab5b0bb2 100644 --- a/apps/web/src/spa/pages/tasks.tsx +++ b/apps/web/src/spa/pages/tasks.tsx @@ -1,45 +1,32 @@ -import { useEffect, useState, type ReactElement } from 'react'; +import { useState, type ReactElement } from 'react'; import { KanbanBoard } from '@/components/tasks/kanban-board'; import { TaskDetailModal } from '@/components/tasks/task-detail-modal'; import { TaskListView } from '@/components/tasks/task-list-view'; +import { StaleDataNotice, UnavailableDataNotice } from '@/components/freshness/freshness-notices'; import { api } from '@/lib/api'; import { cn } from '@/lib/cn'; import type { Task } from '@/lib/types'; -import { getErrorMessage } from './page-errors'; +import { useFreshCollection, describeFailure } from '@/lib/freshness/use-fresh-collection'; +import { validateTaskCollection } from '@/lib/freshness/validators'; type ViewMode = 'list' | 'kanban'; export function TasksPage(): ReactElement { - const [tasks, setTasks] = useState([]); + const tasks = useFreshCollection({ + source: 'gateway:/api/tasks', + fetcher: (signal) => api('/api/tasks', { signal }), + validate: validateTaskCollection, + cacheKey: 'tasks', + }); const [view, setView] = useState('kanban'); - const [loading, setLoading] = useState(true); - const [error, setError] = useState(null); const [selectedTask, setSelectedTask] = useState(null); - useEffect(() => { - let cancelled = false; - - void api('/api/tasks') - .then((response) => { - if (cancelled) return; - setTasks(response); - }) - .catch((caught: unknown) => { - if (cancelled) return; - setError(getErrorMessage(caught, 'Failed to load tasks.')); - }) - .finally(() => { - if (cancelled) return; - setLoading(false); - }); - - return () => { - cancelled = true; - }; - }, []); + const retry = (): void => { + void tasks.revalidate(); + }; return ( -
+

Tasks

@@ -70,18 +57,24 @@ export function TasksPage(): ReactElement {
- {error ? ( -
- {error} + {tasks.freshness === 'stale' && tasks.snapshot ? ( +
+
) : null} - {loading ? ( + {tasks.freshness === 'unknown' ? (

Loading tasks...

+ ) : tasks.freshness === 'unavailable' ? ( + ) : view === 'kanban' ? ( - + ) : ( - + )} {selectedTask ? ( diff --git a/docs/PRD.md b/docs/PRD.md index 77ccd609..50e8334d 100644 --- a/docs/PRD.md +++ b/docs/PRD.md @@ -1,5 +1,14 @@ # PRD: Mosaic Stack v0.1.0 +## Current addendum: #1194 — Installed framework-tool drift detection + +- Compare the framework tools shipped with the executing Mosaic package against the deployed `$MOSAIC_HOME/tools` tree by content hash. +- Treat every shipped `tools/**` file as framework-owned/required according to `framework-manifest.txt`, while excluding the explicit operator-owned credential carve-out and preserving installed-only operator/unknown files. +- Distinguish and count `IN_SYNC`, `STALE`, `NOT_INSTALLED`, and installed-only classifications; fail non-zero when shipped tools are stale or absent and refuse self-comparison that would make drift unobservable. +- Surface the observational check through `mosaic doctor`; do not refresh files, restart seats, or mutate live tooling. +- Document identity/messaging/gate behavior changes in the current stale set, the reviewed quiet-window keep-mode refresh command, and post-refresh probes against the installed path. +- Prove by construction that a stale and missing deployed tool are detected; that regression must fail before this checker exists. + ## Metadata - **Owner:** Jason Woltje @@ -102,6 +111,128 @@ Context compaction, session replacement, and same-PID runtime reloads can leave --- +## Pi Persistent Goal Loop (#1150) + +### Problem and objective + +A Pi agent can stop after a plausible-looking answer even when the operator's broader objective is +not complete, and ordinary compaction can weaken or omit the original objective. Mosaic needs an +optional, operator-controlled goal loop that keeps a Pi session oriented, checks progress at native +lifecycle boundaries, and resumes work until completion is verified or a bounded safety state is +reached. + +The objective is a Mosaic-owned Pi extension deployed from the framework into +`~/.config/mosaic/runtime/pi/`. It must not install into or depend on `~/.pi/agent/extensions/`. + +### Scope + +#### In scope + +1. `PGL-REQ-01`: The framework SHALL ship a dedicated Pi goal extension under + `packages/mosaic/framework/runtime/pi/`, seed it under `$MOSAIC_HOME/runtime/pi/`, and make + `mosaic pi` load it alongside the core Mosaic extension when present. +2. `PGL-REQ-02`: `/goal` SHALL support setting a goal plus status, pause, resume, cancel, and help + operations without silently replacing an active goal. +3. `PGL-REQ-03`: Active branch-specific goal state SHALL be persisted in Pi custom session entries, + restored on session start and tree navigation, and never rely on a compaction summary as its + source of truth. +4. `PGL-REQ-04`: A hidden goal contract SHALL be injected through Pi's `context` event before every + model request so it remains effective across tool turns, retries, and post-compaction requests. +5. `PGL-REQ-05`: The harness SHALL inspect every `turn_end` and successful `session_compact` event. + A structured terminating goal-report tool SHALL capture `continue`, evidence-bearing `achieved`, + or `blocked` status without requiring a redundant model turn. +6. `PGL-REQ-06`: An achievement claim SHALL remain provisional until a second consecutive + evidence-bearing verification report. Any continuation report or successful compaction during + verification SHALL reset the verification sequence. +7. `PGL-REQ-07`: Continuation SHALL be initiated at safe lifecycle boundaries, primarily + `agent_settled`; manual compaction and restored active sessions may schedule a deferred idle + continuation without re-entering compaction handlers. +8. `PGL-REQ-08`: The loop SHALL have operator cancellation plus bounded turn and repeated-no-progress + limits. Exhausted or blocked goals pause rather than continuing indefinitely. +9. `PGL-REQ-09`: Framework installation and update SHALL preserve normal manifest ownership: the + goal extension is framework-owned under `runtime/**`, while no goal extension or configuration + asset is created or modified under the operator's main Pi configuration. Pi remains the owner of + its native session files used by `appendEntry()`. + +#### Out of scope + +1. A mathematical guarantee that an arbitrary natural-language goal is semantically complete. +2. Automatically executing user-supplied shell predicates or accepting executable validation code in + `/goal` arguments. +3. Restarting Pi after process, host, or supervisor failure; the existing Mosaic fleet/runtime + supervisor owns process durability. +4. Gateway, database, web UI, Discord, or cross-harness goal orchestration in this slice. + +### User and stakeholder requirements + +- An operator can start a goal from Pi and see its current phase, evidence, limits, and latest report. +- The agent remains oriented after each turn and compaction until verified, paused, blocked, + exhausted, or cancelled. +- Local testing uses a file under `~/.config/mosaic/runtime/pi/`; the feature never writes an + extension asset to `~/.pi/agent/extensions/`. +- Framework updates deploy the same reviewed extension source through Mosaic's existing manifest + sync path. + +### Non-functional requirements + +1. **Safety:** bounded continuation, explicit cancellation, no arbitrary command execution, and no + completion without non-empty reported evidence. +2. **Reliability:** serialized continuation scheduling, branch-aware restoration, compaction-safe + context injection, and stale-timer cancellation on session shutdown. +3. **Performance:** no extra nested judge-model request on every turn; structured reporting uses the + active agent's final terminating tool call. +4. **Observability:** Pi status/notifications expose phase and bounded counters without recording + credentials or hidden model reasoning. +5. **Maintainability:** the state machine is deterministic and behavior-tested independently from Pi + provider/network access. + +### Acceptance criteria + +1. `AC-PGL-01`: A framework-sync fixture installs the extension at + `$MOSAIC_HOME/runtime/pi/goal-extension.ts`, and launcher tests prove both Mosaic Pi extensions are + emitted in deterministic order while absent optional files remain backward-compatible. +2. `AC-PGL-02`: Command tests prove set/status/pause/resume/cancel behavior, active-goal replacement + refusal, and bounded input handling. +3. `AC-PGL-03`: Lifecycle tests prove every turn is recorded, active context is injected on every + request, two evidence-bearing achievement reports are required, and `agent_settled` continues an + unmet goal without duplicate scheduling. +4. `AC-PGL-04`: Compaction and restoration tests prove goal state survives, verification is reset and + rechecked after compaction, manual compaction continuation is deferred until idle, and tree/session + branch state is reconstructed correctly. +5. `AC-PGL-05`: Limit tests prove max-turn and repeated-no-progress exhaustion stop autonomous + continuation, while pause/cancel/blocked states do not restart. +6. `AC-PGL-06`: Focused tests, package typecheck/lint/test, repository quality gates, a local Pi load + smoke test from `~/.config/mosaic/runtime/pi/`, independent review, and terminal-green CI pass before + issue #1150 closes. + +### Constraints, risks, and assumptions + +- Dependency: Pi's extension API must continue to provide `registerCommand`, `registerTool`, + `context`, `turn_end`, `agent_settled`, `session_compact`, session custom entries, and terminating + tool results. +- Risk: the working agent can overstate completion. Mitigation: structured evidence, a mandatory + second verification pass, explicit semantic limitations, and operator-visible reports. +- Risk: an impossible goal can consume unbounded resources. Mitigation: hard turn/no-progress bounds + and paused terminal states. +- Risk: automatic continuation can race compaction or session replacement. Mitigation: drive from + `agent_settled`, defer idle restarts, generation-check timers, and clear timers on shutdown. +- `ASSUMPTION:` Two consecutive evidence-bearing reports are the initial local verification policy; + rationale: it provides a real recheck without doubling every turn's model cost. Future policy may + add independent or deterministic validators. +- `ASSUMPTION:` Default limits are 40 turns and 6 repeated no-progress reports, configurable only by + bounded Mosaic environment settings; rationale: useful persistence with a finite autonomous budget. +- `ASSUMPTION:` Documentation remains canonical in-repo for this slice; no external docs publication + is requested. + +### Testing and delivery intent + +Use TDD for the deterministic controller and lifecycle invariants. Test with fake Pi lifecycle +objects first, then run a local load/smoke test from the deployed Mosaic path. Deliver source, tests, +launcher wiring, framework/runtime documentation, user/developer guides, and sitemap updates in one +reviewed squash PR to `main` with terminal-green CI. + +--- + ## Fleet Declarative Configuration Management Workstream (FCM, #758) ### Problem and objective @@ -146,6 +277,68 @@ lands. M0 consists only of these normative requirements, the complete task DAG, documentation IA checklist, and the legacy example/profile disposition inventory. Subsequent cards are defined in [docs/TASKS.md](./TASKS.md) and must remain one card/one PR. +### Fleet git identity launch propagation (#1043) + +#### Problem and objective + +A fleet seat can have a registered per-agent Git credential while its launched runtime process lacks +`MOSAIC_GIT_IDENTITY`. The credential resolver then cannot select the seat identity reliably, which +blocks repository operations on fail-closed estates and can fall through to an unrelated identity on +estates where that refusal is not active. The objective is to make Git identity a deterministic, +roster-derived part of the generated launch projection and prove it reaches the launched process. + +#### Normative requirements + +1. `FGI-REQ-01`: Every generated fleet agent projection SHALL declare + `MOSAIC_GIT_IDENTITY=`; a differing or unsafe identity SHALL fail closed before + tmux launch. +2. `FGI-REQ-02`: The clean `/usr/bin/env -i` pane boundary SHALL pass every variable declared by the + generated projection, including `MOSAIC_GIT_IDENTITY`, to the launched runtime process. +3. `FGI-REQ-03`: A behavioral integration test SHALL set-compare the complete generated projection + against the launched process environment. Source-text/string-presence assertions are insufficient. +4. `FGI-REQ-04`: Verification SHALL include RED-first evidence and a delete-the-subject mutation that + removes Git-identity pane propagation and makes the behavioral test fail. + +#### Acceptance criteria + +1. `AC-FGI-01`: A launched seat process contains every key/value pair declared by its generated + environment projection, including the roster-derived Git identity. +2. `AC-FGI-02`: Missing, unsafe, or split Git identity is rejected before a tmux session is created. +3. `AC-FGI-03`: Focused launcher and generated-environment tests, repository quality gates, + independent review, and the required RED/green/R7 evidence are recorded before push. + +### Framework shell assertion portability (#1098) + +#### Problem and objective + +The blocking framework-shell chain can report that a pane command omitted `/usr/bin/env -i` even when +`-i` matched successfully. A short-circuiting `grep -q` under `set -o pipefail` may close its pipe after +the match and cause an upstream producer to exit with SIGPIPE, turning a valid semantic result into a +nonzero aggregate pipeline. The objective is to inspect the captured NUL-delimited argv directly and +make failures carry the observed records needed for diagnosis. + +#### Normative requirements + +1. `FSP-REQ-01`: The pane-boundary test SHALL validate an adjacent `/usr/bin/env`, `-i` argv pair from + the authoritative NUL-delimited tmux capture without a short-circuit pipeline whose upstream status + can override a successful match. +2. `FSP-REQ-02`: Missing, reversed, or non-adjacent boundary tokens SHALL fail, while valid boundaries + SHALL remain valid regardless of trailing argv size, pipe capacity, process scheduling, or host/CI + utility implementation. +3. `FSP-REQ-03`: A failed boundary check SHALL print stable indexed, shell-escaped observed argv records + before exiting nonzero; the fixture SHALL continue to contain generated non-secret launch data only. +4. `FSP-REQ-04`: Verification SHALL include RED-first large-payload evidence, negative token-order + controls, the complete focused launcher suite, canonical Woodpecker CI, and independent review. + +#### Acceptance criteria + +1. `AC-FSP-01`: A large captured argv with adjacent `/usr/bin/env`, `-i` passes even when the former + `grep -q` pipeline returns nonzero from an upstream SIGPIPE. +2. `AC-FSP-02`: Missing executable, missing flag, and detached/reversed flag fixtures return nonzero and + emit the indexed observed argv. +3. `AC-FSP-03`: The focused suite passes on the development host and CI image, and the merged-main + Woodpecker pipeline is terminal green before #1098 closes. + --- ## Exact Cross-Harness Fleet Communications Contract (#766) @@ -1345,6 +1538,59 @@ All work is **alpha** (< 0.1.0) until Jason approves 0.1.0 beta release. --- +## Workspace placement guard hardening (#1174) + +### Problem and objective + +The Bash pre-tool guard must prevent Git checkouts and repository state from being placed under +`$HOME` without refusing ordinary Git commands merely because a source, option value, branch name, +or metadata mentions `$HOME`. A guard that over-blocks routine work is unsafe because operators +will route around it. + +### Scope and requirements + +1. `WPG-REQ-01`: `git clone` and `git worktree add` placement SHALL be judged from their placement + operands, not from every HOME-shaped word in the command. +2. `WPG-REQ-02`: Clone sources, references, templates, environment assignments, and non-placement + worktree metadata MAY resolve under HOME when all placement operands resolve elsewhere. +3. `WPG-REQ-03`: Both attached and separate-value `--separate-git-dir` forms SHALL remain placement + operands and SHALL be refused when they resolve under HOME. +4. `WPG-REQ-04`: Option classification SHALL account for Git's rule-generated boolean negations + without relying on an enumerable allowlist of flag spellings. +5. `WPG-REQ-05`: Quote removal, escapes, shell command boundaries, redirections, and end-of-options + handling SHALL preserve existing fail-closed checkout coverage. +6. `WPG-REQ-06`: Absolute placement aliases SHALL resolve shell-known HOME spellings, dot segments, + repeated separators, and existing symlink parents before the HOME boundary comparison. +7. Relative targets whose effective path depends on the shell cwd are out of scope and tracked by + #1197. + +### Acceptance and verification + +1. Git's own option parser accepts each tested flag, including generated `--no-*` forms, while the + guard allows a HOME-valued source with an explicit safe destination. +2. Equivalent clone and worktree fixtures cover rule-generated negations and remain discriminating + against the prior head where the defect existed. +3. Real HOME destinations and both `--separate-git-dir` forms remain blocked, including placements + after shell command boundaries. +4. The full hermetic guard suite, syntax/static checks, adversarial probes, independent review, and + terminal-green CI pass before merge. +5. Any option-classification residual is documented with its deliberate failure direction. + +### Constraints, risks, and assumptions + +- Security and usability are co-equal: neither a placement bypass nor routine over-block is an + acceptable repair. +- `ASSUMPTION:` The value-taking option surface exposed by the installed Git version is closed and + measurable through Git's own parser/help output; rationale: boolean flags are rule-generated, + while separate-value options have explicit grammar and must be classified as such. +- Risk: a future Git release may add a new value-taking placement option. Mitigation: document the + chosen residual direction and pin every currently supported placement option in behavior tests. +- Risk: a symlink can be replaced after pre-execution canonicalization. Mitigation: resolve every + existing parent physically and document the remaining inherent TOCTOU window; the worktree helper + remains the authoritative path-derivation mechanism, with atomic closure tracked by #1199. + +--- + ## Assumptions 1. RESOLVED: **pgvector is sufficient** for semantic search at v0.1.0 scale (personal/family/team = thousands to low hundreds-of-thousands of vectors). `@mosaicstack/memory` defines a `VectorStore` interface with pgvector as the default adapter. The interface boundary makes Qdrant a drop-in migration if PG resource contention or scale demands it later. Zero additional infrastructure for v0.1.0. Rationale: Reduces ops burden; pgvector HNSW indexes are fast at this scale; interface abstraction costs almost nothing now. @@ -1368,3 +1614,38 @@ All work is **alpha** (< 0.1.0) until Jason approves 0.1.0 beta release. 10. ASSUMPTION: **Conversations and messages get their own PG tables** (not stored in brain's entity model). They follow a chat-specific schema with proper foreign keys to users and projects. Rationale: Chat has different access patterns (streaming, pagination, search) than brain entities. 11. RESOLVED: **Pi handles all target LLM providers natively.** Anthropic, OpenAI/Codex, Z.ai, Ollama, LM Studio, and llama.cpp are all supported via Pi's built-in providers or `models.json` configuration with `openai-completions` API type. No custom provider adapters needed in @mosaicstack/agent — only configuration management. + +--- + +## Release Integrity Workstream (RI, #1275) + +### Problem and objective + +At `next` 476db12b (review of 2026-08-17), publication from `next` is not bound to the full verification pipeline for the same commit: the publish pipeline's publish steps depend on `build` only, while ordinary push CI excludes `next`. Public Forge/MACP paths contain false-success placeholders: a stub executor that reports `completed` with exit zero, planning/remediation gates that execute literal `true`, a review gate that echoes an approving verdict, and a gate runner that treats empty commands and unimplemented CI-provider gates as passing. Shipping UI surfaces can render a failed fetch as an empty, healthy collection. + +Objective: for alpha 0.0.50, the release cannot publish, report, or display work state that the repository has not actually verified. Decisions SDLC-D-033 through SDLC-D-038 (Jason, 2026-08-17) scope this floor; full decision text and required-behavior lists live in jarvis-brain `docs/plans/2026-08-16_mosaic-stack-sdlc-protocol.md` and `data/decisions/mosaic-stack-sdlc-protocol.json`. This section restates only the normative requirements. + +### Normative requirements + +1. **RI-N1 Exact-commit publication verification (SDLC-D-034).** One canonical terminal verification command performs self-contained re-verification in the publish pipeline against the job's checked-out commit before any external publication effect. The command contains or invokes the complete mandatory verification set (semantic parity with the PR merge gate, including sanitization, upgrade-guard, typecheck, lint, format check, tests, and build); CI and publication do not maintain separate semantic checklists. Every publish step depends on the verification step in the executable pipeline DAG. Provider commit identity and `git rev-parse HEAD` must identify the same commit. Missing, skipped, cancelled, stale, or inconclusive checks fail closed. Documentation-only runs may skip publication but cannot bypass verification when a publication effect will occur. A negative control must prove that a broken check blocks every publish step. + +2. **RI-N2 Fail-closed Forge/MACP with explicit simulation (SDLC-D-035).** Simulation requires explicit caller intent (e.g. `--simulate`) and produces a distinct typed `simulated` state that can never satisfy dependencies, acceptance criteria, gates, merge, or release. Normal execution exits nonzero with a typed capability failure when a required executor, reviewer, command, or CI provider is absent — no stub completion, no literal-`true` gates, no synthetic approvals, no empty-command passes. A manual gate with no automation enters a waiting state; it does not pass. Positive tests prove explicit simulation still works; negative controls prove simulation and every missing-provider case cannot advance lifecycle state. + +3. **RI-N3 One transitional PRD authority (SDLC-D-036).** `@mosaicstack/prdy` structured storage under `docs/prdy/`, driven by `mosaic mission --plan`, is the authoritative PRD representation for the alpha. `mosaic prdy` either routes through the same application service or operates only as an explicit, named Markdown import/export adapter; `docs/PRD.md` is not a peer authority. `mission --plan` must persist the mission↔PRD linkage (mission id/version, PRD id/version, selected requirements). Markdown output is a generated view carrying source identity; editing it cannot mutate authority silently. Import is explicit, validated, and conflict-aware (proposed successor, never overwrite). Structural validity is separate from approval. + +4. **RI-N4 One quality-rails evaluator (SDLC-D-037).** The TypeScript quality-rails package is the sole authoritative evaluator. A complete probe inventory maps every current TypeScript and shell check to one canonical check with disposition (preserve/strengthen/retire, each named). Effective shell enforcement probes are absorbed before their independent paths retire; expected-file presence alone is not parity. The evaluator returns typed results (`passed`/`failed`/`blocked`/`error`/`not-applicable`) with check version, subject, and reason; missing implementation, missing input, unknown check, process error, timeout, or malformed output can never become `passed` or an unqualified skip. Check definitions and policy are versioned and digested. Shell commands become thin adapters with no separate verdict logic. The canonical terminal verification command (RI-N1) invokes this evaluator rather than duplicating its logic. Contract, parity, and negative-control tests are required, plus independent review of probe equivalence. + +5. **RI-N5 Consequence-aware stale UI (SDLC-D-038).** Mission Control distinguishes typed freshness states (`current`, `stale`, `partial`, `unknown`, `unavailable`) rather than inferring from empty arrays or null. A failed fetch never renders as an empty healthy collection. Last-known data may display for situational awareness only with source identity, version, and age visibly labeled; any derived completion/assurance/release verdict whose inputs are stale becomes `unknown`; all state-changing actions are disabled until fresh state loads and is revalidated. With no verified snapshot, surfaces show an explicit unavailable state. Cache corruption, cross-workspace data, schema mismatch, and version regression invalidate the snapshot. Tests cover the failure matrix (network, auth, malformed, partial, corruption, stale age, schema mismatch, recovery, stale-action rejection) with negative controls proving no case yields a current green verdict or enabled mutation. + +### Acceptance criteria + +- AC-RI-1: A push to `next` that fails any mandatory verification step publishes nothing (no npm package, no image), demonstrated by a checked-in negative control and by pipeline evidence on a real `next` publish run where the verification step is green and every publish step depends on it. +- AC-RI-2: With no executor/reviewer/CI provider wired, Forge and MACP normal runs exit nonzero with typed capability failures; with `--simulate`, runs complete but every result is typed `simulated` and cannot satisfy any gate, dependency, or completion state — proven by unit tests including negative controls. +- AC-RI-3: A PRD created or revised through either `mosaic mission --plan` or `mosaic prdy` resolves to one authority under `docs/prdy/` with stable identities and versions; the mission↔PRD linkage survives restart; a Markdown export is labeled as generated and cannot silently become a second writer; divergent legacy content blocks baseline claims until explicitly resolved — proven by contract tests. +- AC-RI-4: `quality-rails check` through any entry point (TS CLI, framework shell adapter) returns the same typed verdict for the same subject; the probe inventory names every legacy check's disposition; a deliberately broken probe fails closed — proven by contract/parity/negative-control tests and independent review of probe equivalence. +- AC-RI-5: No shipping surface renders a failed fetch as an empty healthy state; stale/partial/unavailable states are typed, labeled, and mutation-disabled — proven by the failure-matrix tests. +- AC-RI-6: All cards merged to `next` via squash PR with terminal-green CI; release evidence for 0.0.50 records commit, verification run, and published artifacts. + +### Out of scope + +The canonical dispatcher/control-plane vertical slice (work graph, execution attempts, fenced leases, typed check-in, independent verifier dispatch) is decided post-alpha (SDLC-D-033, option B). Multi-pipeline verification certificates (SDLC-D-034 option B) are post-alpha. Full AF-1..AF-4 objective matrices and Mission Control portfolio surfaces are post-alpha. diff --git a/docs/_old_structure/guides/admin-guide.md b/docs/_old_structure/guides/admin-guide.md index e08cfd8a..ed9d2a9d 100644 --- a/docs/_old_structure/guides/admin-guide.md +++ b/docs/_old_structure/guides/admin-guide.md @@ -7,7 +7,8 @@ 3. [Provider Configuration](#provider-configuration) 4. [MCP Server Configuration](#mcp-server-configuration) 5. [Environment Variables Reference](#environment-variables-reference) -6. [Local Fleet Canary](./fleet-local-canary.md) +6. [Pi Goal Loop Operations](#pi-goal-loop-operations) +7. [Local Fleet Canary](./fleet-local-canary.md) --- @@ -264,6 +265,16 @@ Each OIDC provider requires its client ID, client secret, and issuer URL togethe | `AGENT_SYSTEM_PROMPT` | — | Platform-level system prompt injected into all sessions | | `AGENT_USER_TOOLS` | all tools | Comma-separated allowlist of tools for non-admin users | +### Mosaic Pi goal loop + +| Variable | Default | Description | +| ----------------------------- | ------- | -------------------------------------------------------------------- | +| `MOSAIC_GOAL_MAX_TURNS` | `40` | Per-goal autonomous turn limit; accepted range `1..500` | +| `MOSAIC_GOAL_MAX_NO_PROGRESS` | `6` | Consecutive identical progress-report limit; accepted range `1..100` | + +These variables are consumed by the framework-owned Pi goal extension at goal creation. Invalid or +out-of-range values fall back to the defaults; they do not disable the bounds. + ### Providers | Variable | Default | Description | @@ -374,3 +385,29 @@ Session cleanup is scoped to one session identifier and only removes that sessio | Variable | Default | Description | | ----------------------- | ----------------------------- | ------------------------------------------ | | `MOSAIC_WORKSPACE_ROOT` | monorepo root (auto-detected) | Root path for mission workspace operations | + +--- + +## Pi Goal Loop Operations + +The reviewed runtime asset is deployed at +`~/.config/mosaic/runtime/pi/goal-extension.ts` by framework install/update. Do not install another +copy under `~/.pi/agent/extensions/`; duplicate registration can create suffixed commands and two +competing lifecycle controllers. + +Operational checks: + +1. Run `mosaic pi` and verify `/goal help` is available. +2. Use `/goal status` to inspect phase, turn/no-progress limits, compaction checks, and evidence. + Reports persist in Pi session data; controller-owned state redacts common credential shapes, but + Pi's model/tool-call history is separate. Operators must not place secrets or raw sensitive output + in goals, pause reasons, or evidence. +3. Use `/goal pause ` before planned maintenance or manual investigation. Pause and cancel + abort the current goal-driven run when Pi is busy. +4. Use `/goal resume` only after addressing a blocker; counters restart with the configured bounds. +5. Use `/goal cancel` before replacing an unfinished goal. + +A blocked or exhausted goal remains stopped and visible; Mosaic does not automatically raise its +limits or restart the process. Framework sync owns file deployment, while Pi's native session file +owns branch replay. Process/host restart remains the responsibility of the existing runtime or fleet +supervisor. diff --git a/docs/_old_structure/guides/user-guide.md b/docs/_old_structure/guides/user-guide.md index ec4ba629..d258ab85 100644 --- a/docs/_old_structure/guides/user-guide.md +++ b/docs/_old_structure/guides/user-guide.md @@ -8,9 +8,10 @@ 4. [Tasks](#tasks) 5. [Settings](#settings) 6. [CLI Usage](#cli-usage) -7. [Sub-package Commands](#sub-package-commands) -8. [Telemetry](#telemetry) -9. [Local Fleet Canary](./fleet-local-canary.md) +7. [Pi Persistent Goals](#pi-persistent-goals) +8. [Sub-package Commands](#sub-package-commands) +9. [Telemetry](#telemetry) +10. [Local Fleet Canary](./fleet-local-canary.md) --- @@ -317,6 +318,57 @@ mosaic prdy mosaic quality-rails ``` +## Pi Persistent Goals + +`mosaic pi` loads a Mosaic-owned goal extension from +`~/.config/mosaic/runtime/pi/goal-extension.ts`. It is deliberately not installed in +`~/.pi/agent/extensions/`; framework installation and updates manage it with the rest of the Mosaic +runtime assets. + +Start Pi, then set a goal: + +```text +/goal set Deliver the feature, tests, documentation, and verification evidence +# Shorthand: +/goal Deliver the feature, tests, documentation, and verification evidence +``` + +Control and inspect the loop with: + +| Command | Behavior | +| ---------------------- | ------------------------------------------------------------------ | +| `/goal status` | Show phase, limits, compaction checks, latest report, and evidence | +| `/goal pause [reason]` | Stop autonomous continuation while preserving the goal | +| `/goal resume` | Resume with fresh turn and no-progress counters | +| `/goal cancel` | Cancel the goal and remove its active status | +| `/goal help` | Show command help | + +While a goal is active, Mosaic injects its contract before every Pi model request and checks every +completed model/tool turn. The agent ends each work cycle with the structured +`mosaic_goal_report` tool. `achieved` is provisional until a second consecutive report rechecks the +whole goal with evidence. A continuation report or a successful compaction resets provisional +verification. + +Goal statements and reports are stored in Pi session data. Mosaic redacts common credential shapes +before appending its goal-state entries and before goal tool output or `/goal status`, but +pattern-based redaction is not a secret store. Pi's own model-message and tool-call records are +outside that redactor. Never put tokens, passwords, private keys, connection strings, or raw +sensitive output in a goal or report; cite the command, artifact, and pass/fail result instead. + +The loop stops instead of running forever when it is paused, blocked, cancelled, verified, reaches +its turn limit, or repeats the same no-progress report too many times. Defaults are 40 turns and 6 +repeated no-progress reports. Operators may lower or raise them within enforced bounds before +launching Pi: + +```bash +MOSAIC_GOAL_MAX_TURNS=60 MOSAIC_GOAL_MAX_NO_PROGRESS=8 mosaic pi +``` + +Goal state is branch-specific Pi session data. It survives compaction and session resume, but Pi's +process still must be relaunched or supervised after a process/host failure. This initial verifier +checks structured evidence twice; it cannot mathematically prove every arbitrary natural-language +goal. Use explicit acceptance criteria and inspect `/goal status` for consequential work. + --- ### Claude Code Skill Registration diff --git a/docs/fleet/FLEET-LAUNCH.md b/docs/fleet/FLEET-LAUNCH.md index 758a1e99..419372e7 100644 --- a/docs/fleet/FLEET-LAUNCH.md +++ b/docs/fleet/FLEET-LAUNCH.md @@ -5,14 +5,14 @@ Generated environment files are rebuildable projections, not an operator-editabl ## Launch chain -| Layer | Responsibility | -| ------------------- | ------------------------------------------------------------------------------------------------------------------------------- | -| Roster | `fleet/roster.yaml` supplies the agent name, class, supported runtime, model, reasoning, tool policy, workdir, and tmux socket. | -| Projection writer | Renders deterministic fleet/agents/.env.generated from the roster. | -| Optional local data | Reads a strict, data-only fleet/agents/.env.local; it cannot shadow generated keys. | -| systemd | Starts the launcher with env -i and fixed bootstrap data. It does not preload either environment file. | -| session launcher | Validates generated and local data before it queries, creates, or stops an exact tmux session. | -| runtime launch | Derives the fixed mosaic yolo argument array from validated roster data, then seeds the runtime contract. | +| Layer | Responsibility | +| ------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| Roster | `fleet/roster.yaml` supplies the agent name, class, supported runtime, model, reasoning, tool policy, workdir, and tmux socket; Git identity is derived from the exact agent name. | +| Projection writer | Renders deterministic fleet/agents/.env.generated from the roster. | +| Optional local data | Reads a strict, data-only fleet/agents/.env.local; it cannot shadow generated keys. | +| systemd | Starts the launcher with env -i and fixed bootstrap data. It does not preload either environment file. | +| session launcher | Validates generated and local data before it queries, creates, or stops an exact tmux session. | +| runtime launch | Derives the fixed mosaic yolo argument array from validated roster data, then seeds the runtime contract. | The launcher never `source`s or `eval`s an environment file and never accepts an environment-supplied command. `MOSAIC_AGENT_COMMAND`, command/channel overrides, unknown keys, generated-key shadowing, @@ -24,6 +24,7 @@ secret-like key names, duplicate keys, comments, quoted/export syntax, and unsaf ```dotenv MOSAIC_AGENT_NAME= +MOSAIC_GIT_IDENTITY= MOSAIC_AGENT_CLASS= MOSAIC_AGENT_RUNTIME= MOSAIC_AGENT_MODEL= @@ -33,8 +34,10 @@ MOSAIC_AGENT_WORKDIR= MOSAIC_TMUX_SOCKET= ``` -The generated launch contract supports `claude`, `codex`, `opencode`, and `pi`. mosaic fleet add -rejects another runtime before it writes the roster or modifies generated, local, or quarantine state. +`MOSAIC_GIT_IDENTITY` is not independently configurable: it must equal `MOSAIC_AGENT_NAME`, preventing +split runtime and repository identity authority. The generated launch contract supports `claude`, +`codex`, `opencode`, and `pi`. mosaic fleet add rejects another runtime before it writes the roster or +modifies generated, local, or quarantine state. The legacy dogfood stub remains an observability-only canary on its separate `mosaic-factory` socket; it has no generated-launch adapter and cannot be added through this path. diff --git a/docs/fleet/concepts/generated-env-launch-chain.md b/docs/fleet/concepts/generated-env-launch-chain.md index 65cb582a..fc221b8f 100644 --- a/docs/fleet/concepts/generated-env-launch-chain.md +++ b/docs/fleet/concepts/generated-env-launch-chain.md @@ -3,11 +3,12 @@ The launcher consumes validated data, not shell configuration. 1. Read and validate the canonical roster. -2. Render deterministic .env.generated data from that roster. +2. Render deterministic .env.generated data from that roster, including `MOSAIC_GIT_IDENTITY` derived exactly from the roster agent name. 3. Parse optional .env.local through a strict allowlist. 4. Reject generated-key shadowing, unknown or sensitive-looking keys, unsafe paths/values, duplicates, malformed lines, shell syntax, and command overrides. -5. Derive the runtime command from validated runtime/model/reasoning data. -6. Target only the exact configured tmux socket and roster session after ownership checks. +5. Reject a Git identity that is unsafe or differs from the generated agent name. +6. Derive the runtime command from validated runtime/model/reasoning data and pass every generated projection entry through the clean process environment boundary. +7. Target only the exact configured tmux socket and roster session after ownership checks. ## File precedence and ownership diff --git a/docs/fleet/reference/generated-env-boundary.md b/docs/fleet/reference/generated-env-boundary.md index 5894da5e..7d97e39b 100644 --- a/docs/fleet/reference/generated-env-boundary.md +++ b/docs/fleet/reference/generated-env-boundary.md @@ -35,6 +35,7 @@ values, credential material, or command text. ```dotenv MOSAIC_AGENT_NAME= +MOSAIC_GIT_IDENTITY= MOSAIC_AGENT_CLASS= MOSAIC_AGENT_RUNTIME= MOSAIC_AGENT_MODEL= @@ -44,8 +45,9 @@ MOSAIC_AGENT_WORKDIR= MOSAIC_TMUX_SOCKET= ``` -The generated launch contract supports only `claude`, `codex`, `opencode`, and `pi`. fleet add -uses that same runtime authority and rejects any other runtime before it writes the roster or changes +`MOSAIC_GIT_IDENTITY` is derived from and must equal `MOSAIC_AGENT_NAME`; it is not a separate +operator-controlled identity authority. The generated launch contract supports only `claude`, `codex`, +`opencode`, and `pi`. fleet add uses that same runtime authority and rejects any other runtime before it writes the roster or changes projection, local, or quarantine files. The legacy dogfood stub on its separate `mosaic-factory` socket remains an observability canary; it has no generated-launch adapter and cannot be added through this projection path. diff --git a/docs/guides/dev-guide.md b/docs/guides/dev-guide.md index 901ff0fd..c474e49f 100644 --- a/docs/guides/dev-guide.md +++ b/docs/guides/dev-guide.md @@ -9,8 +9,9 @@ 5. [Adding New MCP Tools](#adding-new-mcp-tools) 6. [Database Schema and Migrations](#database-schema-and-migrations) 7. [Claude Code Skill Bridge](#claude-code-skill-bridge) -8. [API Endpoint Reference](#api-endpoint-reference) -9. [Local Fleet Canary](./fleet-local-canary.md) +8. [Pi Persistent Goal Extension](#pi-persistent-goal-extension) +9. [API Endpoint Reference](#api-endpoint-reference) +10. [Local Fleet Canary](./fleet-local-canary.md) --- @@ -396,6 +397,85 @@ M1 intentionally manages Claude Code only. Pi's Mosaic launcher can discover the canonical root directly. Codex still relies on the existing full skill-sync linker and needs separate parity analysis before this lifecycle API is extended. +## Pi Persistent Goal Extension + +The source of the Mosaic-owned Pi goal controller is: + +```text +packages/mosaic/framework/runtime/pi/goal-extension.ts +``` + +The framework manifest classifies `runtime/**` as framework-owned. Both the bash installer and the +TypeScript file adapter therefore deploy the same reviewed source to: + +```text +$MOSAIC_HOME/runtime/pi/goal-extension.ts +# default: ~/.config/mosaic/runtime/pi/goal-extension.ts +``` + +Do not copy or link this extension into `~/.pi/agent/extensions/`. The launcher function +`discoverPiExtensionArgs()` emits the core `mosaic-extension.ts` first and the optional +`goal-extension.ts` second, preserving compatibility with an older installed framework that does +not have the goal file yet. + +### Lifecycle design + +| Pi API | Goal-controller responsibility | +| ------------------------------ | --------------------------------------------------------------------------------- | +| `registerCommand('goal')` | Set, inspect, pause, resume, or cancel one branch-specific goal | +| `registerTool(...)` | Record a terminating structured progress report with evidence | +| `context` | Inject the active goal contract before every provider request | +| `turn_end` | Record every turn, reject mixed final reports, and enforce the turn bound | +| `agent_settled` | Start one deduplicated continuation only after Pi has no retry/compact/queue work | +| `session_compact` | Record the compact check, reset provisional verification, and defer idle work | +| `session_start`/`session_tree` | Rebuild state from custom entries on the active branch | +| `session_shutdown` | Invalidate deferred callbacks and clear UI state | + +State is appended as `mosaic-goal-state` custom entries, which do not enter model context. The +`context` hook creates a fresh hidden `mosaic-goal-context` message for each request instead of +trusting compaction summaries. The `mosaic_goal_report` result uses `terminate: true`; when it is the +sole final tool call, Pi avoids an unnecessary model response before the controller decides whether +to verify, continue, or stop. + +Before state is appended or displayed, the controller applies bounded credential-pattern redaction +to the goal statement, report summary/evidence/next step, and stop reason. Fingerprints are computed +over redacted report content. Pi session entries are append-only, so a credential-bearing legacy +entry cannot honestly be erased by the extension: restoration fails closed, emits a warning, and +requires removal of the affected session before setting a new goal. This is defense-in-depth rather +than a secret-storage contract, and it does not rewrite Pi's separate model-message/tool-call +history. Goal prompts tell the agent not to submit credentials or raw sensitive output, and tests use +canaries to prove known forms do not reach new custom entries, status text, context, or tool details +while ordinary typed fields such as `token: string` remain intact. + +Completion remains evidence-gated but semantic: two consecutive `achieved` reports are required, +and the second run is explicitly a verification pass. This avoids an extra judge-model request after +every turn. Deterministic validator commands are intentionally not accepted as `/goal` input in this +slice, so never describe this mechanism as proof of arbitrary natural-language completion. + +### Tests and local smoke workflow + +```bash +pnpm --filter @mosaicstack/mosaic exec vitest run \ + src/runtime/pi-goal-extension.spec.ts \ + src/commands/launch.spec.ts \ + src/config/file-adapter.test.ts + +bash packages/mosaic/framework/tools/quality/scripts/test-install-migration.sh +``` + +For an additive local smoke test without reseeding unrelated live framework files: + +```bash +install -D -m 0644 \ + packages/mosaic/framework/runtime/pi/goal-extension.ts \ + ~/.config/mosaic/runtime/pi/goal-extension.ts + +pi --extension ~/.config/mosaic/runtime/pi/goal-extension.ts +``` + +Use `/goal help`, `/goal set ...`, and `/goal status` in that test session. A released framework +sync installs the file, and a released Mosaic CLI loads it automatically through `mosaic pi`. + ## API Endpoint Reference All endpoints are served by the gateway at `http://localhost:14242` by default. diff --git a/docs/release-integrity/TASKS.md b/docs/release-integrity/TASKS.md new file mode 100644 index 00000000..a77c7350 --- /dev/null +++ b/docs/release-integrity/TASKS.md @@ -0,0 +1,42 @@ +# Tasks — Release Integrity Workstream (RI-050, #1275) + +> Single-writer: the RI-050 orchestrator (jarvis, dragon-lin) only. Workers read but never modify. +> +> **Mission:** alpha 0.0.50 release-integrity floor (decisions SDLC-D-033..038). +> **PRD:** [docs/PRD.md § Release Integrity Workstream](../PRD.md#release-integrity-workstream-ri-1275) +> **Issue:** #1275 (remains open until RI-V-001 closes) +> **Base branch:** `next` (all cards branch from `origin/next`, squash-merge via PR) +> +> **Execution note:** the `agent` column uses `pi-glm-5.3` — outside the pipeline-cron model +> table on purpose. This workstream is executed by jarvis on dragon-lin with local pi workers +> (`pi --model zai/glm-5.3:high`); pipeline crons must not auto-claim these rows. +> +> **Status values:** `not-started` | `in-progress` | `done` | `blocked` | `failed` | `needs-qa` +> `done` requires: repo quality gates green, independent review recorded, terminal-green CI on +> the PR head, squash merge to `next`, and acceptance evidence in notes. + +| id | status | description | issue | agent | repo | branch | depends_on | estimate | notes | +| -------- | ----------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----- | ---------- | ----------------- | --------------------------------- | ---------------------------------------------------------- | -------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| RI-0-001 | done | Bootstrap: issue #1275, PRD section, this DAG, scratchpad (docs only) | #1275 | pi-glm-5.3 | mosaicstack/stack | docs/ri-050-mission-bootstrap | — | 6K | PR #1276 (head 758659dd): docs-only, CI green (2475). Review requested from fargo. Merges first (no publish run). | +| RI-1-001 | done | RI-N1: canonical terminal verification command + publish-pipeline exact-commit gate (every publish step depends on verify; commit identity check; fail closed) | #1275 | pi-glm-5.3 | mosaicstack/stack | feat/ri-050-publish-gate | RI-0-001 | 25K | PR #1277 (head 46784c8d): CI GREEN at head after serialized retry (pipeline 2476, 2026-08-18) - earlier red was CI-agent contention (web SPA timeouts under concurrent pipelines), not code. Review requested from fargo at pinned head (comms 20260818T021025Z). | +| RI-1-002 | done | RI-N1 negative control: checked-in tests proving a broken mandatory check blocks every publish step and that DAG edges cannot be bypassed | #1275 | pi-glm-5.3 | mosaicstack/stack | test/ri-050-publish-gate-negative | RI-1-001 | 12K | | +| RI-2-001 | done | RI-N2 (Forge): remove stub-executor false success; `--simulate` typed `simulated` results that satisfy nothing; literal-`true` gates and echo-review replaced with real gates or typed waiting-for-authority | #1275 | pi-glm-5.3 | mosaicstack/stack | fix/ri-050-forge-fail-closed | RI-0-001 | 20K | Independent review APPROVED 2026-08-17 (Gitea review 172 on PR #1278, head 99b8f6ea; reviewing seat fargo — recorded under shared host principal mos-dt-0, provenance correction posted by fred; wrapper gap filed by fred). Executed at head: forge tests 116/116, lint green, typecheck green after building macp dist (minimal-install artifact, not a defect), workspace typecheck 45/45, no external type consumers of the changed interfaces. CI red = known lane-wide fleet-test failure only, carries no information about this change (fred, log-content analysis, pipelines 2456-2458). Non-blocking finding: README L141-143 + skills/mosaic-forge/SKILL.md document bare forge run/resume, which now fails closed — fast-follow docs touch. Merge queued behind #1270. UPDATE 2026-08-18: #1270 merged; CI GREEN at head 4917df1f via serialized retry (pipeline 2477) - root cause of prior reds was CI-agent contention (web SPA timeouts under concurrent pipelines), superseding the fleet-test-failure theory. | +| RI-2-002 | done | RI-N2 (MACP): gate runner fails closed on empty commands, stub executors, and unimplemented CI-provider gates unless explicit simulate; typed capability failures | #1275 | pi-glm-5.3 | mosaicstack/stack | fix/ri-050-macp-fail-closed | RI-0-001 | 15K | PR #1293 (head 2097379e): CI green (pipeline 2465), independent review APPROVED (Gitea review 173, jarvis seat, 2026-08-17) - macp 109/109 verified at head. Merge queued behind #1276/#1277/#1278. | +| RI-3-001 | done | RI-N4: complete probe inventory mapping every TS and shell quality-rail check to one canonical check with disposition (preserve/strengthen/retire, each named) | #1275 | pi-glm-5.3 | mosaicstack/stack | docs/ri-050-qr-probe-inventory | RI-0-001 | 12K | PR #1302 (head e06a47fac591): CI green (2484), independent review APPROVED (Gitea review 187, fargo seat, 2026-08-18) — 54 rows / 21 canonical checks / dispositions 43-2-9-0 verified by row-count and code spot-checks. Merged by fargo at pinned head. | +| RI-3-002 | not-started | RI-N4: TS evaluator absorbs effective shell probes; typed results (passed/failed/blocked/error/not-applicable) with versioned digested check definitions; shell commands become thin adapters; contract/parity/negative-control tests | #1275 | pi-glm-5.3 | mosaicstack/stack | feat/ri-050-qr-evaluator | RI-3-001 | 30K | | +| RI-4-001 | in-progress | RI-N3: one PRD application service — `mission --plan` persists mission↔PRD linkage (ids/versions/selected requirements); `mosaic prdy` routes through the service or becomes a named import/export adapter; Markdown is a labeled generated view; explicit conflict-aware import | #1275 | pi-glm-5.3 | mosaicstack/stack | feat/ri-050-prd-authority | RI-0-001 | 35K | PR #1294 (head 8d258e1d): CI green (pipeline 2466), independent review APPROVED (Gitea review 174, jarvis seat, 2026-08-17) - prdy 20/20 + command specs 9/9 at head. Merge queued behind #1276/#1277/#1278. | +| RI-5-001 | done | RI-N5: typed freshness states (current/stale/partial/unknown/unavailable); no failed-fetch-renders-empty; stale derived verdicts → unknown; mutations disabled when stale; failure-matrix tests | #1275 | pi-glm-5.3 | mosaicstack/stack | feat/ri-050-web-stale-safety | RI-0-001 | 25K | | +| RI-V-001 | not-started | Final verification + release evidence: all cards verified merged, negative controls demonstrated, real `next` publish run green on exact commit, evidence pack recorded | #1275 | pi-glm-5.3 | mosaicstack/stack | docs/ri-050-release-evidence | RI-1-002, RI-2-001, RI-2-002, RI-3-002, RI-4-001, RI-5-001 | 10K | | + +## Dispatch waves (max 2 parallel workers) + +1. RI-1-001 + RI-2-001 +2. RI-2-002 + RI-4-001 +3. RI-3-001 + RI-5-001 +4. RI-1-002 + RI-3-002 +5. RI-V-001 + +## Budget + +Derived soft cap: 250K tokens (no explicit cap given). Projected total: 190K. +Conservative mode (1 worker) above 70% projected; freeze above 90%. diff --git a/docs/release-integrity/probe-inventory.md b/docs/release-integrity/probe-inventory.md new file mode 100644 index 00000000..eea9cfb7 --- /dev/null +++ b/docs/release-integrity/probe-inventory.md @@ -0,0 +1,186 @@ +# Quality-Rails Probe Inventory — RI-3-001 + +- **Task:** RI-3-001 (SDLC-D-037 first half; PRD § Release Integrity Workstream, RI-N4) +- **Date:** 2026-08-18 +- **Base:** `origin/next` @ `8199261c` (branch `docs/ri-050-qr-probe-inventory`) +- **Follow-up:** RI-3-002 consumes the dispositions here when building the single TS evaluator. + +## 0. Scope and method + +Every mechanism in this repository that verifies a quality, integrity, safety, or release +property — TypeScript checks, shell probes, pipeline steps, git hooks, and installer-side +assertions — gets one row. Each row's "what it actually verifies" was written from the +probe's **code**, not its name or docs. Framework tool unit/regression suites (git wrappers, +wake, tmux, orchestrator, …) are treated as one enforcement surface (`test:framework-shell`) +because they test tool behavior rather than repo quality; their wiring integrity is itself +guarded by `check-test-enumeration.sh`, and the quality-relevant members are rowed +individually. + +**Kinds:** `ts` (TypeScript/Node check), `shell` (bash/python probe), `pipeline-step` +(exists only inside a Woodpecker pipeline). + +**Enforcement points:** `local` (operator-invoked), `pre-commit`, `pre-push`, +`CI ci.yml#`, `publish.yml#` (CI on push to main/next), `turbo `, +`agent-runtime` (framework hooks on an agent host), `installer` (host install path), +`unwired`. + +**Dispositions** (recommendations for RI-3-002): `preserve` (keep as-is; already the +canonical or a correct guard-of-the-guard), `strengthen` (keep, but a concrete gap must +close — usually absorption into the TS evaluator), `strengthen (review)` (viable retirement +candidate once the evaluator absorbs it; do not retire yet). Note: RI-N4 requires that +effective shell probes be **absorbed before** their independent paths retire — no row here +is marked `retire` because no absorption exists yet. + +## 1. Inventory + +### 1.1 Repo-level gate tasks (pnpm / turbo) + +| check | location | kind | what it actually verifies | enforcement point | canonical check | disposition | rationale | +| ------------------------------------- | ------------------------------------------------------------------------------------ | ---- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------ | ------------------------- | ----------- | --------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `pnpm preflight` (checkout preflight) | `scripts/preflight.mjs` | ts | Six gate binaries (eslint, husky, prettier, tsc, turbo, vitest) exist and are executable in `node_modules/.bin` (exit 42 if not); no stale `.mosaic-test-work/web-build.lock` (exit 43); `apps/web/.next` is a real directory (not a symlink), every entry owned by the current uid, and its `.mosaic-source-hash` fingerprint + `.mosaic-symlink-manifest` hash match the certified build written by `scripts/build-web.mjs` | `pre-push`; inside `pnpm typecheck` (→ `CI ci.yml#typecheck`, verify-release `typecheck` stage) | QC-1 Checkout integrity | preserve | Blocks a poisoned/stale generated `.next` from faking a green typecheck (the five-month-stale-`.next` class); trust chain is self-contained per-checkout. | +| `pnpm typecheck` | root `package.json` → `turbo run typecheck` | ts | Per-package `tsc --noEmit` (all 20 packages); turbo `typecheck` depends on `^build`, so package builds must succeed first; prefixed by checkout preflight | `CI ci.yml#typecheck`; `pre-push`; verify-release `typecheck` stage; `turbo typecheck` | QC-2 Workspace typecheck | preserve | The single workspace-wide type gate; CI and hooks invoke the same task, no divergent checklist. | +| `pnpm lint` | root `package.json` → `turbo run lint` | ts | Per-package `eslint src` under root `eslint.config.mjs` (ignores `dist`, `.next`, `framework/**`, etc.) | `CI ci.yml#lint`; `pre-push`; verify-release `lint` stage; `turbo lint` | QC-3 Workspace lint | preserve | Same-task invocation from every surface; no second lint definition. | +| `pnpm format:check` | root `package.json` → `prettier --check` | ts | Prettier parse/format equality over `**/*.{ts,tsx,js,jsx,json,md}` minus `.prettierignore` (generated trees, `docs/scratchpads/`, venvs, …) | `CI ci.yml#format`; `pre-push`; verify-release `format` stage | QC-4 Format check | preserve | Single formatter, single ignore list, enforced identically everywhere. | +| `pnpm test` | root `package.json` `test` = `test:checkout` && `turbo run test` && `test:installer` | ts | (a) `node --test scripts/*.test.mjs` — checkout-tool units; (b) per-package `vitest run` (mosaic appends the 47-command `test:framework-shell` chain); (c) `tools/install-next-lane.test.sh`; turbo `test` declares DB env vars and depends on `^build` | `CI ci.yml#test` (with `DATABASE_URL` + `db:migrate` first); verify-release `test` stage; `turbo test` | QC-5 Test suite execution | preserve | One composed test command; the chain property (any link red ⇒ step red) is the gate. | +| `pnpm build` | root `package.json` → `turbo run build` | ts | Per-package build (`tsc`/Next) with `^build` dependency and `dist/**` outputs | `publish.yml#build`; verify-release `build` stage; `turbo build` | QC-6 Workspace build | preserve | Publish artifacts derive from the same build task CI verifies. | + +### 1.2 Framework quality shell probes (`packages/mosaic/framework/tools/quality/`) + +| check | location | kind | what it actually verifies | enforcement point | canonical check | disposition | rationale | +| ------------------------------------------- | ----------------------------------------------------------------------- | ----- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------- | +| Sanitization gate | `scripts/verify-sanitized.sh` | shell | Built-in self-test first (planted identity/structural/YAML+service fixtures; exit 2 if the regexes or extension coverage break), then: (1) identity denylist grep (`jarvis\|jason\|woltje\|brain.woltje.com\|/home/jwoltje\|\bPDA\b`) over all shipped text files **including** `examples/`; (2) structural grep for private `$HOME/src` defaults in shipped scripts **excluding** `examples/`. Any hit ⇒ exit 1 | `CI ci.yml#sanitization`; verify-release `sanitization` stage | QC-7 Framework sanitization | preserve | Labeled one-time regression guard with a self-test that prevents silent no-op; correctly scoped (identity vs structural) and documented as not a general PII detector. | +| Resident-context budget | `scripts/check-resident-budget.sh` (+ `--self-test`) | shell | Self-test of the comparator, then `wc -l` vs per-file ceilings (CONSTITUTION 120, AGENTS 120, each RUNTIME.md 90); missing file ⇒ fail; over ceiling ⇒ exit 1 | `CI ci.yml#sanitization` (both modes); verify-release `sanitization` stage | QC-8 Resident-context budget | preserve | Caps the container (lines), never the wording — the deliberate anti-drift design (DESIGN §7); CI-enforceable half only, by design. | +| Test-membership enumeration guard (#1017) | `scripts/check-test-enumeration.sh` + `test-enumeration-exclusions.txt` | shell | Parses surface S1 (`packages/mosaic` `test:framework-shell` via JSON+shlex) and S2 (every `framework/tools/\*.sh | .py`token in`ci.yml`, comment lines stripped); population = `_test_.sh`under`framework/tools`; FAILS on: suite-shaped file on disk neither enumerated nor signed-excluded; surface naming a path missing on disk (both directions); exclusion without reason / stale / outside population / contradicting enumeration. Proves **naming, not reachability** (stated in-file) | `CI ci.yml#sanitization` (direct line); link [0] of `test:framework-shell` (thus `CI ci.yml#test`); verify-release `sanitization` stage | QC-9 Test-membership enumeration | preserve | Makes silent under-run impossible; invoked from both surfaces it audits so severing the chain cannot silence it. | +| Enumeration-guard needles | `scripts/test-check-test-enumeration.sh` | shell | Needle/control fixtures driven through `--root`: every promised failure mode must trip the guard **on its own words**, plus controls that must pass (null-case defense); covers commented-out ci.yml lines (F1) and line-range parsing (n2b) | `test:framework-shell` → `CI ci.yml#test`; verify-release `test` stage | QC-9 Test-membership enumeration | preserve | Guard-of-the-guard with both polarities; same canonical check by design. | +| Upgrade manifest guard (#791 HARD GATE) | `scripts/test-upgrade-manifest-guard.sh` | shell | Keep-mode `install.sh` upgrade against seeded throwaway `MOSAIC_HOME`: every operator sentinel — including an **unanticipated** one — survives byte-identical with unchanged mtime; framework files still update; retired framework files pruned; matrix run with rsync present AND absent (keep path must be rsync-independent); fail-closed matrix (empty/operator-only/malformed/missing manifest aborts loudly, operator files untouched); operator secret never appears in installer output | `CI ci.yml#upgrade-guard`; verify-release `upgrade-guard` stage | QC-10 Upgrade/install safety | preserve | The operator-data hard gate for the `mosaic update` path; negative controls are load-bearing and documented. | +| Upgrade rollback gate (#791 B1) | `scripts/test-upgrade-rollback.sh` | shell | Mid-sync failure (PATH-shadowing `cp` shim) must trigger snapshot restore: restore message fires, corrupted file restored, target byte-identical to pre-upgrade; control installer with `set -E` stripped must NOT roll back (proves errtrace is load-bearing); plus signal/exit-guard controls | `CI ci.yml#upgrade-guard`; verify-release `upgrade-guard` stage | QC-10 Upgrade/install safety | preserve | Proves the rollback trap actually fires; the `-E`-stripped control keeps Part A honest. | +| Durable-snapshot gate (#791 PR2) | `scripts/test-upgrade-durable-snapshot.sh` | shell | Pre-update snapshot taken before any mutation (0700/0600 perms, secret never logged, retention-pruned); post-sync verify net restores operator files a manifest bug lets the sync touch; CWE-59 symlink-leaf guard proven with a portable cp shim in both polarities (write-through-link must not happen); v1→v2 migration semantics (intended `bin/` removal not healed) | `CI ci.yml#upgrade-guard`; verify-release `upgrade-guard` stage | QC-10 Upgrade/install safety | preserve | Covers tampering and leak vectors the manifest guard cannot see; the shim rationale (busybox vs GNU cp) is documented in-file. | +| Install migration matrix (v2→v3) | `scripts/test-install-migration.sh` | shell | Fixture matrix running the real installer with `MOSAIC_SYNC_ONLY=1`: fresh install seeds + stamps version 3; legacy user-edited AGENTS overwritten with `.pre-constitution.bak` preserved (and idempotent); tuned STANDARDS overwritten; operator files (SOUL, credentials) preserved. Mirrors the TS suite `packages/mosaic/src/config/file-adapter.test.ts` — both installers must behave identically | `CI ci.yml#upgrade-guard`; verify-release `upgrade-guard` stage | QC-10 Upgrade/install safety | preserve | Pins the shell/TS installer parity contract; removal would orphan that parity requirement. | +| Enforcement verification probe (bash) | `scripts/verify.sh` | shell | Attempts **real commits** in the target repo: planted type error must produce a commit blocked with `error`; planted `any` must trip `no-explicit-any`; planted lint error must trip `prettier`; gitleaks binary must exist (3a) and detect a planted AWS key via `gitleaks git --pre-commit --staged --redact` (3b). Verdicts are output-grep matches on hook stderr | `local` via installed `mosaic-quality-verify` on scaffolded target projects; **not run in this repo's CI** | QC-20 Downstream enforcement verification | strengthen (review) | Mechanism is genuinely behavioral (stronger than file presence) but verdict logic is grep-on-output and it is unwired here; absorb as the evaluator's enforcement-probe check (the RI-N4 evaluator invokes it or reimplements it) before retiring the shell path. | +| Enforcement verification probe (PowerShell) | `scripts/verify.ps1` | shell | Windows port of `verify.sh`: same planted-commit tests with `$output -match` matching; no gitleaks self-test parity beyond the same checks | `local` (Windows operator); no Windows CI runner exists | QC-20 Downstream enforcement verification | strengthen (review) | A hand-maintained twin of `verify.sh` with no CI coverage — exactly the drift shape the single evaluator removes; retire after the TS evaluator owns the probe. | +| Quality template installer (bash) | `scripts/install.sh` | shell | Copies template files (`.husky/pre-commit` incl. mandatory gitleaks, `.lintstagedrc.js`, `.eslintrc.js`, `tsconfig.json`, `.woodpecker.yml`, `.gitleaks.toml`) into a target project; **warns** (does not verify) about `package.json` snippet merge; no post-condition check | `local` / via `mosaic-quality-apply` | QC-21 Downstream rails scaffolding | strengthen (review) | Duplicates the TS `quality-rails init` scaffolder for a different template set; converging on one scaffolder (with post-scaffold verification) is prerequisite to retiring this path. | +| Quality template installer (PowerShell) | `scripts/install.ps1` | shell | Windows twin of the template copy above | `local` (Windows operator) | QC-21 Downstream rails scaffolding | strengthen (review) | Same twin-drift risk as `verify.ps1`; no runner exercises it. | +| `mosaic-quality-verify` adapter | `framework/tools/_scripts/mosaic-quality-verify` | shell | Thin adapter: validates target dir exists, asserts `verify.sh` present+executable, `cd` target, exec it. No verdict logic of its own | `local` (installed framework bin) | QC-20 Downstream enforcement verification | preserve | Already the thin-adapter shape RI-N4 prescribes for shell surfaces. | +| `mosaic-quality-apply` adapter | `framework/tools/_scripts/mosaic-quality-apply` | shell | Thin adapter: arg validation then exec of quality `install.sh --template … --target …` | `local` (installed framework bin) | QC-21 Downstream rails scaffolding | preserve | Thin adapter, no separate verdict; disposition follows its target script's convergence. | +| Roster schema regression | `scripts/test-roster-schema.py` | shell | jsonschema `Draft202012Validator` over `fleet/roster.schema.json` with valid/invalid connector-kind fixtures (tmux/discord/matrix conditional fields) | **unwired** — not on S1 or S2, not signed-excluded; also outside the enumeration guard's `*.sh` population, so the guard cannot see it | QC-5 Test suite execution | strengthen (review) | A real regression suite that currently runs nowhere; wire it into a CI surface or sign an exclusion — leaving it invisible re-arms the exact gap #1017 closed. | +| Framework shell chain (S1) | `packages/mosaic/package.json` `test:framework-shell` | shell | 47-command `&&` chain: enumeration guard + needles, 14 lease-broker/mutator-gate python unitests, `check-runtime-launches.py`, and ~30 framework-tool shell suites (git wrappers, wake, woodpecker, tmux, glpi, orchestrator, `_scripts`). Quality-relevant members rowed separately below | `turbo test` → `CI ci.yml#test`; verify-release `test` stage | QC-5 Test suite execution | preserve | The chain is the execution surface the enumeration guard audits; known residuals: a failing link stops later suites (measured in #1270 — suites after position 44 had not run), and the guard proves naming, not reachability. | + +### 1.3 Framework runtime hooks and their harnesses (agent-host enforcement) + +| check | location | kind | what it actually verifies | enforcement point | canonical check | disposition | rationale | +| ------------------------------------- | ----------------------------------------------------------------------------------------- | ----- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | -------------------------------------------------------------------------------- | --------------------------------------- | ------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| QA edit hook seam | `framework/tools/qa/qa-hook-stdin.sh` (+ `qa-hook-handler.sh`) | shell | PostToolUse stdin hook: extracts edited file from the tool JSON (jq or grep fallback), skips non-JS/TS, then the deps-preflight gate — exits 1 with the legible sentinel `deps not installed — run pnpm install` when `node_modules/.bin` is missing/empty (the #856 false-red class); the downstream handler only files QA remediation **report templates** (no verification logic) | `agent-runtime` (framework `runtime/claude/settings.json` PostToolUse); never CI | QC-16 Agent-runtime edit-time checks | strengthen (review) | The sentinel gate is real enforcement; the handler's report-filing adds no verdict and its name promises more than the code does — evaluator absorption should keep the sentinel, drop the report theater. | +| Typecheck-on-edit hook | `framework/tools/qa/typecheck-hook.sh` | shell | PostToolUse: for edited `.ts/.tsx`, finds nearest `tsconfig.json` and runs `tsc --noEmit`, surfacing errors nonzero to the agent immediately | `agent-runtime` (framework `runtime/claude/settings.json` PostToolUse) | QC-16 Agent-runtime edit-time checks | strengthen (review) | Edit-time duplicate of QC-2 with independent invocation logic; keep behavior, converge invocation through the evaluator adapter. | +| Deps-preflight harness | `framework/tools/qa/test-deps-preflight.sh` | shell | Five assertions against the seam incl. a documented RED control (raw `not found`), sentinel behavior for missing and empty `.bin`, and no-false-positive once populated | `test:framework-shell` → `CI ci.yml#test` | QC-16 Agent-runtime edit-time checks | preserve | Guard-of-the-check with a red control; keeps the sentinel from regressing. | +| Prompt-helper RCE regression | `framework/tools/_scripts/test-mosaic-init-rce.sh` | shell | Sources the prompt helpers and proves a literal `$(touch /tmp/pwned)` answer round-trips verbatim and never executes (no `/tmp/pwned` created) | `test:framework-shell` → `CI ci.yml#test` | QC-5 Test suite execution | preserve | Cheap, load-bearing security regression on the installer's input path. | +| Install-ordering harness (#869 C2) | `framework/tools/_scripts/test-install-ordering-guard.sh` | shell | Drives `mosaic-link-runtime-assets` with a fake `mosaic` on PATH: probe ok ⇒ settings copied + exit 0; probe fail ⇒ exit 1 with degraded outcome but all other runtime files still copied; `--allow-inactive-enforcement` forwarded; no-mosaic-on-PATH ⇒ python3 fallback strips enforcement hooks and exits 1; fallback + flag ⇒ wires as-is, exit 0 | `test:framework-shell` → `CI ci.yml#test` | QC-17 Lease-enforcement wiring safety | preserve | Exercises the shell wiring seam independently of the TS guard's own spec suite (complementary coverage, by design). | +| Fleet-transport harness (#1240) | `framework/tools/_scripts/test-fleet-transport-check.sh` | shell | Extracts the shipped `check_fleet_transport`/`fleet_declared_transport` functions **from the shipped scripts** (fails loud if extraction yields nothing) and drives both implementations (mosaic-doctor + `tools/install.sh`) from one case table | `test:framework-shell` → `CI ci.yml#test` | QC-18 Operator-host drift audit | preserve | The anti-drift harness for the one rule shipped twice; extraction-from-source keeps it from testing a stale copy. | +| Terminal-green contract (RM-61/#1000) | `framework/tools/woodpecker/test-terminal-green-contract.sh` + `verify-terminal-green.py` | shell | Red-first fixtures: pipeline JSON variants (service failure, step failure, cancelled, etc.) must produce the correct terminal-green verdict; controls must pass | `test:framework-shell` → `CI ci.yml#test` | QC-5 Test suite execution | preserve | Keeps the CI-wait wrapper's green-detection honest; a false green here would poison every merge gate that trusts `pr-ci-wait.sh`. | +| Lease-gate launch invariant | `framework/tools/lease-broker/check-runtime-launches.py` | shell | Scans production roots (`packages/`, `apps/`, `plugins/`, `tools/`) across sh/py/ts/yaml suffixes for Claude/Pi process launches **outside** the lease gate; allowlist-based; fails CI on violation | `test:framework-shell` → `CI ci.yml#test` | QC-15 Lease-gate architecture invariant | preserve | The only architectural "no ungated launches" rail; grep+allowlist is the right cost/benefit for this invariant. | + +### 1.4 TypeScript quality logic (`@mosaicstack/quality-rails` + mosaic CLI) + +| check | location | kind | what it actually verifies | enforcement point | canonical check | disposition | rationale | +| ---------------------------------------- | ---------------------------------------------------------------------------------------------- | -------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | -------------------------------------------------------------------------------------------------------------- | ------------------------------------- | ------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `quality-rails check` | `packages/quality-rails/src/cli.ts` (`mosaic quality-rails check --project`) | ts | **Expected-file presence only**: loops `expectedFilesForKind` (node: `.eslintrc`, `biome.json`, `.githooks/pre-commit`, `PR-CHECKLIST.md`; python: `pyproject.toml`+hooks+checklist; rust: `rustfmt.toml`+…) and exits 1 listing missing paths. Does not execute any linter, formatter, hook, or scanner | `local` (operator CLI); **no CI wiring in this repo** | QC-19 Downstream rails presence check | strengthen | This is the RI-N4 evaluator seed. Today presence ≠ parity (explicitly called out by RI-N4): it must grow typed verdicts (`passed/failed/blocked/error/not-applicable`), check versioning/subject/reason, digested definitions, and absorb the effective shell probes (QC-20 first). | +| `quality-rails doctor` | `packages/quality-rails/src/cli.ts` | ts | Same presence data as `check`, printed with ok/missing lines; **cannot fail** (no nonzero exit on missing files) | `local` (operator CLI) | QC-19 Downstream rails presence check | strengthen | A doctor that cannot fail is advisory; fold into `check` (or return typed states) when the evaluator lands. | +| `quality-rails init` | `packages/quality-rails/src/cli.ts` + `scaffolder.ts`/`templates.ts` | ts | Scaffolds rails files per detected kind/profile (linters/formatters lists are advisory strings; hooks flag always true); writes files, prints follow-ups — no post-condition verification | `local` (operator CLI) | QC-21 Downstream rails scaffolding | strengthen (review) | Second scaffolding path alongside quality `install.sh` (§1.2); converge on one with post-scaffold verification before retiring either. | +| Lease activation probe (#869 C1, hidden) | `packages/mosaic/src/commands/lease-activation-probe.ts` | ts | Real capability probe, not file presence: resolves the installed mosaic CLI and requires it to advertise the exact `{name, version}` activation contract; all deps injectable; registered as hidden CLI command and consumed by C2/C5 | `local` (hidden CLI + consumed by C2/C5); spec-tested via `lease-activation-probe.spec.ts` in `turbo test` | QC-17 Lease-enforcement wiring safety | preserve | The versioned-contract probe is precisely the fail-closed capability check RI-N2 generalizes; already typed and injectable. | +| Install-ordering guard (#869 C2, hidden) | `packages/mosaic/src/commands/install-ordering-guard.ts` | ts | Decides whether enforcement hook entries are written into the `~/.claude/settings.json` the framework reseed ships: not activatable ⇒ strip hooks + nonzero loud outcome (default); explicit per-invocation `--allow-inactive-enforcement` opt-out wires-with-warning. Never touches the runtime gate's own fail-closed behavior | `installer` (framework reseed via `mosaic-link-runtime-assets`); spec + shell harness coverage in `turbo test` | QC-17 Lease-enforcement wiring safety | preserve | Correct default-deny with an explicit, non-env opt-out; test-locked from both the TS and shell sides. | +| Lease doctor check (#869 C5) | `packages/mosaic/src/commands/lease-doctor-check.ts` | ts | Combines hook-wiring detection in `~/.claude/settings.json` with C1 activatable and C3 broker-supervisor health: wired ∧ (¬activatable ∨ ¬healthy) ⇒ loud `[ERROR]` that forces `mosaic doctor` exit 1 regardless of the bash audit's own exit | `local` (inside `mosaic doctor`); spec coverage in `turbo test` | QC-17 Lease-enforcement wiring safety | preserve | Closes the "bricked host looks green" hole; cannot be masked by the bash script — that composition is the point. | +| `mosaic doctor` (framework drift audit) | `packages/mosaic/src/commands/launch.ts` (`doctor`) + `framework/tools/_scripts/mosaic-doctor` | shell+ts | Bash audit of the installed framework home: ~40 expected files/dirs present; runtime files are copies (not symlinks) matching source (`cmp`) or composed runtime-contract markers; hard-gates block present in AGENTS.md; sequential-thinking MCP configured; fleet transport binary present per roster (warn); legacy symlink trees gone; skills synced — **warn-based, exit 1 only with `--fail-on-warn`**, plus C5's forced error | `local` (operator audit) | QC-18 Operator-host drift audit | preserve | Host-state audit CI cannot see (user files by design, DESIGN §7); advisory exit is the documented contract — do not silently change it. | +| `mosaic gateway doctor` | `packages/mosaic/src/commands/gateway-doctor.ts` | ts | Probes per-service health (PostgreSQL, Valkey, pgvector) via `@mosaicstack/storage`, reports tier and JSON; exit 1 only when at least one **required** service fails (yellow stays 0) | `local` (operator) | QC-18 Operator-host drift audit | preserve | Service health with correct red/yellow exit semantics; JSON mode exists for scripting. | +| `mosaic gateway verify` | `packages/mosaic/src/commands/gateway/verify.ts` | ts | Post-install liveness: daemon meta via HTTP with retries, admin token on file, bootstrap endpoint reachable; aggregated pass/fail | `local`; consumed by `tools/e2e-install-test.sh` | QC-18 Operator-host drift audit | preserve | The first-run proof the installer E2E relies on; retry-aware so startup races don't false-red. | +| `mosaic fleet doctor` | `packages/mosaic/src/commands/fleet-reconciler-command.ts` | ts | Classifies local roster-owned drift (no mutation) from the parsed v2 roster | `local` (operator) | QC-18 Operator-host drift audit | preserve | Dry-run classification is the correct non-mutating audit shape. | + +### 1.5 Git hooks (developer machine) + +| check | location | kind | what it actually verifies | enforcement point | canonical check | disposition | rationale | +| ------------------------- | --------------------------------------------------------- | ----- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------- | --------------------------- | ----------- | ----------------------------------------------------------------------------------------------------------------- | +| Pre-commit staged hygiene | `.husky/pre-commit` → `npx lint-staged` (`.lintstagedrc`) | shell | On staged files only: `prettier --write` + `eslint --fix` for ts/tsx/js/jsx; `prettier --write` for json/md/yaml/yml. **Mutating** (fixes and re-stages); commit blocks only if a fixer itself fails | `pre-commit` (every local commit; hooks activated by `install-hooks.mjs` via `core.hooksPath .husky/_`) | QC-13 Staged-change hygiene | preserve | Correct scoped fast gate; note it auto-fixes rather than rejects (deliberate). Gap: no secret scan here — see §3. | +| Pre-push gate | `.husky/pre-push` | shell | `pnpm preflight && pnpm typecheck && pnpm lint && pnpm format:check` (no test run — documented in AGENTS.md) | `pre-push` | QC-14 Pre-push gate | preserve | Composes QC-1..4 exactly as specified in AGENTS.md; tests intentionally left to CI. | +| Hook installer | `scripts/install-hooks.mjs` (`pnpm prepare`) | ts | Stages husky hooks into a scratch repo first, asserts husky produced its `h` shim, quarantines incomplete previous sets, verifies idempotence via full directory snapshot comparison, then sets `core.hooksPath`; skips cleanly with `HUSKY=0` or no git | `installer` (runs on `pnpm install`) | QC-13 Staged-change hygiene | preserve | Self-verifying wiring for the hook gates — a corrupted half-install cannot silently disable them. | + +### 1.6 CI pipeline steps (`.woodpecker/`) + +Step-to-probe mapping for container steps: `ci.yml#sanitization` = QC-7+QC-8+QC-9 (rows §1.2, plus `apk add bash` env prep); `ci.yml#upgrade-guard` = QC-10 (rows §1.2, plus `apk add rsync`); `ci.yml#typecheck`/`#lint`/`#format`/`#test` = QC-2/3/4/5 (rows §1.1). Rows below are mechanisms that exist only in a pipeline. + +| check | location | kind | what it actually verifies | enforcement point | canonical check | disposition | rationale | +| -------------------------------------- | -------------------------------------------------------------------------------------- | ------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | ---------------------------------------------------- | ----------------------------------- | --------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------- | -------- | ------------------------------------------------------------------------------------------------------------- | +| Frozen install | `ci.yml#install` | pipeline-step | `pnpm install --frozen-lockfile --prefer-offline` against the baked ci-base store — lockfile supply integrity; a drifted lockfile fails the build before any gate runs | `CI ci.yml#install` | QC-1 Checkout integrity | preserve | Lockfile-pinned dep resolution is the supply-chain floor under every later gate. | +| Test-step readiness prelude | `ci.yml#test` prologue | pipeline-step | Installs pinned `@earendil-works/pi-coding-agent@0.84.1` (Invariant R suite requires the real binary) + openssl; waits up to 60×1s on `pg_isready` for the `ci-postgres` service and fails fast if it never comes up; runs `db:migrate` before tests | `CI ci.yml#test` | QC-5 Test suite execution | preserve | Fail-fast environment preconditions — a missing service produces a legible failure, not a wall of red tests. | +| Publish verify step (pending RI-1-001) | `publish.yml#verify` (branch `feat/ri-050-publish-gate` @ `46784c8d`, not yet on next) | pipeline-step | (a) Commit identity: fails closed if `CI_COMMIT_SHA` empty, `git rev-parse HEAD` empty, or the two differ; (b) runs the canonical `pnpm verify:release`. **Every publish effect depends on this step; it carries no path filter** | `publish.yml#verify` | QC-11 Terminal release verification | preserve | The RI-N1 exact-commit binding; until it merges, publish steps on next depend on `build` only (see §3 gap 1). | +| Publish error classification | `publish.yml#publish-npm` | pipeline-step | Publishes `@mosaicstack/*` (minus web) and classifies outcome: success, or the **only tolerated failure** = already-published (EPUBLISHCONFLICT / "cannot publish over" / "previously published"); explicit fatal on npm `E404/E401/ENEEDAUTH/ECONNREFUSED/ETIMEDOUT/ENOTFOUND` and on any unrecognized failure (replacing the old ` | | echo` that hid a registry 404) | `publish.yml#publish-npm` (main/tags, path-filtered on `packages/**`) | QC-12 Publish-effect integrity | preserve | Converts silent publish fall-on-floor into loud failure; allowlist-of-one error tolerance is the right shape. | +| Next-lane publish assertions | `publish.yml#publish-next-npm` | pipeline-step | Guards: branch must be `next`, `CI_PIPELINE_NUMBER` required; registry dist-tags JSON must be usable; walks all manifests, strictly parses stable semver, rewrites `X.Y.(Z+1)-next.`; publishes with `--tag next` (never latest); post-publish asserts `npm view @mosaicstack/mosaic@next` resolves to the exact expected version | `publish.yml#publish-next-npm` (push/manual on next) | QC-12 Publish-effect integrity | preserve | Durable prerelease lane with end-to-end resolution proof — the published artifact is verified, not assumed. | +| Image destination policy | `publish.yml#build-gateway` / `#build-appservice` / `#build-web` | pipeline-step | Kaniko builds with destination policy: `next` ⇒ sha-tag only (fatal if a tag event sneaks in); `main` ⇒ sha + `latest`; tag events ⇒ sha + ``; anything else fatal. Path filters only skip **effects**, never the verify step | `publish.yml#build-*` | QC-12 Publish-effect integrity | preserve | Fail-closed tagging matrix; the exclude-list default-safe design keeps stale images impossible. | + +Adjacent pipeline surface (not a probe): `.woodpecker/ci-image.yml` rebuilds the ci-base image on `pnpm-lock.yaml`/`Dockerfile.ci` change with an immutable `lock-` tag; pipelines consume `:latest`. Recorded for completeness — no code-quality property is checked. + +### 1.7 Root installer tooling (`tools/`) + +| check | location | kind | what it actually verifies | enforcement point | canonical check | disposition | rationale | +| --------------------------- | --------------------------------------------------------- | ----- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------- | ------------------------------- | ------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| Next-lane installer test | `tools/install-next-lane.test.sh` (`pnpm test:installer`) | shell | Drives `tools/install.sh --next` with faked `node`/`npm` binaries (no network): Node 20 must be rejected; installs must pin **exact** versions (mutable `@next` forbidden); fast path must not unexpectedly fall back to source; gateway-install failure takes the documented fallback | `turbo`-external tail of `pnpm test` → `CI ci.yml#test` | QC-5 Test suite execution | preserve | Hermetic (shimmed) regression net for the installer lane; runs as part of the standard test command. | +| Clean-container install E2E | `tools/e2e-install-test.sh` | shell | Full first-run flow in a node:22-alpine container: `install.sh --yes` → `mosaic wizard` (non-interactive) → `mosaic gateway install` → `mosaic gateway verify` exit check (with EXPECTED-SKIP if the installed CLI predates `gateway verify`); skips gracefully without Docker | `local` (manual; requires Docker); **not wired in CI** | QC-5 Test suite execution | strengthen (review) | The only end-to-end proof of the install→verify path; currently operator-initiated only — wire into a periodic/manual CI lane or sign its exclusion explicitly. | +| Host installer advisories | `tools/install.sh` (`--check`; `check_fleet_transport`) | shell | `--check` = version comparison only, no install; `check_fleet_transport` warns (non-blocking, by design — tmux is the fleet's dependency, not mosaic's) when the roster-declared transport binary is absent, naming exactly what it blocks; PATH-persistence warnings | `installer` (operator-run) | QC-18 Operator-host drift audit | preserve | Advisory-by-design warnings; the parallel doctor check is drift-tested by §1.3's harness. | + +### 1.8 Pending workstream additions (branch `feat/ri-050-publish-gate` @ `46784c8d`) + +| check | location | kind | what it actually verifies | enforcement point | canonical check | disposition | rationale | +| ------------------------------- | ---------------------------------------------------- | ---- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------- | ----------------------------------- | ----------- | ---------------------------------------------------------------------------------------------- | +| Canonical terminal verification | `scripts/verify-release.mjs` (`pnpm verify:release`) | ts | One command replaying the full mandatory set as stages — sanitization, upgrade-guard, typecheck (incl. preflight), lint, format, test, build — mirroring `ci.yml` step-for-step; fail-fast on first failing command; requires `bash`+`rsync` on PATH; `--stage ` for wiring smoke-tests only | `publish.yml#verify` (pending); `local` (`pnpm verify:release`) | QC-11 Terminal release verification | preserve | The RI-N1 canonical command — CI and publication share one semantic checklist by construction. | +| Verify-parity contract test | `scripts/verify-release.test.mjs` | ts | Parses the real `ci.yml`/`publish.yml`: stage table must match ci.yml step-for-step; every publish-effect step (name `publish*` or image-pushing) must transitively depend on `verify`; commit-identity assertion must be present; `verify` must carry no path filter | `test:checkout` → `CI ci.yml#test` (once merged) | QC-11 Terminal release verification | preserve | Guard-of-the-guard at checkout time — the two surfaces cannot drift apart silently. | + +## 2. Canonical check set + +The deduplicated checks every row above maps onto. IDs are stable for RI-3-002 to consume. + +- **QC-1 Checkout integrity.** Owns: the checkout can run its gates — frozen-lockfile dependency resolution, required gate binaries present, no stale build lock, and the `apps/web/.next` generated-state trust chain (real directory, uid ownership, certified source fingerprint, certified symlink manifest). Implemented by `scripts/preflight.mjs` + frozen install steps. +- **QC-2 Workspace typecheck.** Owns workspace-wide TypeScript soundness: per-package `tsc --noEmit` over built dependencies (`turbo typecheck`). The single definition invoked by CI, pre-push, and terminal verification. +- **QC-3 Workspace lint.** Owns static-analysis policy: per-package ESLint under the root config. One config, one task, every surface. +- **QC-4 Format check.** Owns formatting uniformity: Prettier check with the repo ignore list. (The pre-commit variant additionally fixes; the verdict form is this check.) +- **QC-5 Test suite execution.** Owns execution of all test surfaces: checkout script units (`node --test`), per-package Vitest suites (including the framework shell chain and its python unitests), the installer-lane shim test, and — once wired — `test-roster-schema.py` and container E2E. Also owns guards-of-the-gate that live inside the chain (terminal-green contract, RCE regression). +- **QC-6 Workspace build.** Owns artifact buildability: `turbo build` producing the artifacts publication consumes. +- **QC-7 Framework sanitization.** Owns the open-source guarantee for the shipped framework package: no operator-identity tokens anywhere (examples included), no private `$HOME` defaults in shipped scripts, with a self-test that keeps the regexes honest. +- **QC-8 Resident-context budget.** Owns the line-count ceilings on framework files injected into every agent's context (Constitution, dispatcher, RUNTIME.md slices) — the CI-enforceable half of the resident-prompt budget. +- **QC-9 Test-membership enumeration.** Owns the property that no test suite can silently fall out of CI: disk population vs parsed enumeration surfaces, both-directions staleness, and signed exclusions with reasons. Includes its needle/control harness. +- **QC-10 Upgrade/install safety.** Owns the #791 family: operator-path byte-identity across keep-mode upgrades (manifest guard), mid-failure rollback (errtrace-proven), durable pre-update snapshot + verify net + CWE-59 leaf guard, and the v2→v3 migration matrix with shell/TS parity. +- **QC-11 Terminal release verification.** Owns the RI-N1 exact-commit binding: commit-identity assertion plus one canonical command (`pnpm verify:release`) replaying the complete mandatory set, with every publish effect depending on it; plus the checkout-time parity/DAG contract test that keeps pipeline and command in sync. +- **QC-12 Publish-effect integrity.** Owns publication correctness: npm publish error classification (only already-published tolerated), next-lane versioning and post-publish resolution proof, and image destination/tag policy. +- **QC-13 Staged-change hygiene.** Owns commit-time hygiene on staged files (prettier/eslint fix-and-restage) and the self-verifying hook wiring that guarantees the gates are actually installed. +- **QC-14 Pre-push gate.** Owns the local push composition: preflight + typecheck + lint + format:check (tests deliberately deferred to CI). +- **QC-15 Lease-gate architecture invariant.** Owns "no ungated runtime launches in production code": the scan + allowlist over `packages/`, `apps/`, `plugins/`, `tools/`. +- **QC-16 Agent-runtime edit-time checks.** Owns edit-time feedback on agent hosts: the deps-preflight legibility sentinel and typecheck-on-edit, plus their regression harnesses. +- **QC-17 Lease-enforcement wiring safety.** Owns the #869 C1/C2/C5 trio: activation capability probe (versioned contract), enforcement-hook wiring gate (default-deny with explicit opt-out), and the doctor check that surfaces a bricked host — with their shell/TS harnesses. +- **QC-18 Operator-host drift audit.** Owns host-state health CI cannot see: `mosaic doctor` drift audit (+ fleet transport, both implementations), `fleet doctor` roster classification, `gateway doctor`/`gateway verify` service health, and installer advisories. Advisory exits are part of the contract. +- **QC-19 Downstream rails presence check.** Owns "does a scaffolded project still carry its rails files" — today the TS `quality-rails check/doctor` presence loop; per RI-N4 this is the seed that must become the typed evaluator (presence alone is explicitly not parity). +- **QC-20 Downstream enforcement verification.** Owns "do the rails actually block" on scaffolded projects: the behavioral planted-commit probe (type error, `any`, lint, gitleaks secret) currently in `verify.sh`/`verify.ps1` behind the `mosaic-quality-verify` adapter. +- **QC-21 Downstream rails scaffolding.** Owns putting rails files into a target project: the shell template installer (+ PowerShell twin) and the TS `quality-rails init` scaffolder — currently two paths that must converge. + +## 3. Coverage gaps + +Enforced nowhere but implied, or named in docs/tooling but not wired: + +1. **Publication not yet bound to verification on `next`.** At this base (`8199261c`), `publish.yml` publish steps depend on `build` only; the `verify` step and `scripts/verify-release.mjs` exist on `feat/ri-050-publish-gate` (`46784c8d`) but are not merged. Until RI-1-001 lands, AC-RI-1's negative control cannot hold on the real pipeline. +2. **Playwright E2E unwired.** `apps/web` ships `test:e2e` (`playwright test`) with real suites (`admin/auth/chat/navigation.spec.ts`); neither `pnpm test` nor any CI step invokes it. The web UI's user flows are verified only when an operator runs them manually. +3. **No secret scanning on this repo.** The framework's own template pre-commit makes gitleaks **required**, and `verify.sh` proves detection with a planted key — but this repository's `.husky/pre-commit` (lint-staged only) and CI run no secret scan. The repo ships the control it does not use. +4. **No dependency audit.** The quality `.woodpecker.yml` templates and `docs/CI-SETUP.md` specify `npm audit --audit-level=high` as a pipeline stage; nothing equivalent runs for this repo. +5. **No coverage thresholds.** Templates enforce 80% Jest coverage thresholds; this repo's Vitest configs collect coverage with no thresholds — coverage is measured nowhere and enforced nowhere. +6. **`test-roster-schema.py` invisible.** A real jsonschema regression suite wired to no surface and invisible to the enumeration guard (its population is `*.sh`; the suite is `.py`). Either enumerate it or sign an exclusion — silence here is the #1017 defect shape. +7. **Presence-checker expectations ≠ this repo.** `quality-rails check` expects `.eslintrc`, `biome.json`, `.githooks/pre-commit`, `PR-CHECKLIST.md` for node projects — none describe this monorepo (husky, flat eslint config, no biome, no PR-CHECKLIST.md). The evaluator's check set must be per-subject (versioned, digested), not one global file list. +8. **Chain-ordering residual (documented).** `test:framework-shell` is one `&&` chain: a failing link skips every later suite while the step still fails (measured in #1270 — four suites after position 44 had not run since a prior merge). The enumeration guard proves naming, not reachability; both residuals are in-file documented but structurally unfixed. +9. **Signed-exclusion burndown open.** 16 signed exclusions remain in `test-enumeration-exclusions.txt`; several are "unmeasured in CI image" or blocked on missing CI tooling (tmux, setsid) — tracked under #1017/#1271. Each is an enforcement promise deferred, not delivered. +10. **Windows twins unexercised.** `verify.ps1`, `install.ps1`, `mosaic-doctor.ps1` have no runner anywhere (no Windows CI); behavioral drift from their bash twins is undetectable by construction. +11. **QA hook name vs behavior.** `qa-hook-handler.sh` files remediation report templates but performs no verification; the seam's actual gate value is only the deps-preflight sentinel. Anything relying on "QA automation hook" as a check is relying on report-filing. +12. **Two test paths, one gated.** CI runs tests against ci-postgres (`DATABASE_URL` set); the local PGlite path is the documented default (AGENTS.md) until KBN-101-02/101-05. Only the CI path is enforced by pipeline. + +## 4. Disposition summary + +| disposition | rows | checks | +| ------------------- | ---- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| preserve | 43 | Every canonical owner (QC-1..QC-18) plus correct guards-of-the-guard and thin adapters: all of §1.1, the CI-invoked framework probes and adapters in §1.2, all of §1.3, the C1/C2/C5 trio and doctors in §1.4, all of §1.5, all pipeline-only steps in §1.6, §1.7 rows 1 and 3, and §1.8. | +| strengthen | 2 | `quality-rails check` and `quality-rails doctor` (QC-19) — the RI-N4 evaluator seed: typed verdicts, versioned/digested check definitions, per-subject check sets. | +| strengthen (review) | 9 | `verify.sh` + `verify.ps1` (QC-20), quality `install.sh`/`install.ps1` + `quality-rails init` (QC-21 — scaffold-path convergence), `test-roster-schema.py` (QC-5 — wire or sign), `qa-hook-stdin.sh` seam + `typecheck-hook.sh` (QC-16), `tools/e2e-install-test.sh` (QC-5 — CI lane). | +| retire | 0 | None meet the bar: RI-N4 requires effective shell probes be **absorbed before** their paths retire, and no absorption exists yet. The `strengthen (review)` rows are the retirement candidates for RI-3-002 once the evaluator owns their behavior. | + +Row total: 54. Canonical checks: 21 (QC-1..QC-21). diff --git a/docs/reports/quality/1099-pipefail-sweep.md b/docs/reports/quality/1099-pipefail-sweep.md new file mode 100644 index 00000000..ff961908 --- /dev/null +++ b/docs/reports/quality/1099-pipefail-sweep.md @@ -0,0 +1,83 @@ +# #1099 pipefail + early-exit sweep + +Baseline: `df4c591ab42aa1ae62c12935fdc0e772684864a0` + +This is a site inventory, not a risk count. `FIXED` means the early-exiting consumer no longer has a piped upstream process whose SIGPIPE can become the result under `pipefail`. `NOT-LOAD-BEARING` means the pipeline status is explicitly discarded. `UNREACHABLE-AND-WHY` describes designed input, not a payload-size safety claim. + +## Tranche 1 — runtime and general scripts + +| Baseline site | Verdict | Construction / reason | +| ------------------------------------------------------------------- | ------- | ---------------------------------------------------------------------------------------- | +| `tools/matrix-presence-harness/run.sh:38` | FIXED | nullglob array selects the first path; no pipeline | +| `tools/e2e-install-test.sh:139` | FIXED | capture help completely, then grep via redirection | +| `tools/install.sh:312` | FIXED | NUL `mapfile` reads all roots; count != 1 reaches the named malformed-archive diagnostic | +| `scripts/analysis/reflect-board-history.sh:76` | FIXED | capture Git history completely, then grep via redirection | +| `scripts/analysis/reflect-git-history.sh:67` | FIXED | grep reads from a here-string | +| `scripts/analysis/reflect-git-history.sh:69` | FIXED | grep reads from a here-string | +| `packages/mosaic/framework/tools/authentik/user-create.sh:72` | FIXED | jq `first(...)` reads the response directly | +| `packages/mosaic/framework/tools/git/mutate-push-guard.sh:87` | FIXED | grep `-m1` reads the file directly; downstream `cut` consumes its complete scalar output | +| `packages/mosaic/framework/tools/orchestrator/session-resume.sh:94` | FIXED | `mapfile` plus bounded indexed loop replaces `head` pipeline | +| `packages/mosaic/framework/tools/prdy/prdy-status.sh:69` | FIXED | grep reads from a here-string | +| `packages/mosaic/framework/tools/qa/reflect-stop-hook.sh:172` | FIXED | grep reads from a here-string | +| `packages/mosaic/framework/tools/qa/reflect-stop-hook.sh:173` | FIXED | grep reads from a here-string | +| `packages/mosaic/framework/tools/qa/reflect-stop-hook.sh:174` | FIXED | grep reads from a here-string | +| `packages/mosaic/framework/tools/qa/reflect-stop-hook.sh:175` | FIXED | grep reads from a here-string | +| `packages/mosaic/framework/tools/qa/reflect-stop-hook.sh:176` | FIXED | grep reads from a here-string | +| `packages/mosaic/framework/tools/qa/reflect-stop-hook.sh:177` | FIXED | grep reads from a here-string | +| `packages/mosaic/framework/tools/qa/reflect-stop-hook.sh:178` | FIXED | grep reads from a here-string | +| `packages/mosaic/framework/tools/qa/typecheck-hook.sh:16` | FIXED | Bash regex extracts the first field without a pipeline | +| `packages/mosaic/framework/tools/qa/typecheck-hook.sh:56` | FIXED | grep and bounded sed each read from a here-string | +| `packages/mosaic/framework/tools/tmux/send-message.sh:113` | FIXED | grep reads from a here-string | +| `packages/mosaic/framework/tools/tmux/send-message.sh:124` | FIXED | grep reads from a here-string | +| `packages/mosaic/framework/tools/wake/detector.sh:126` | FIXED | one awk reads the manifest directly and exits after the first exact key | +| `packages/mosaic/framework/tools/wake/detector.sh:270` | FIXED | grep reads from a here-string | +| `packages/mosaic/framework/tools/wake/detector.sh:278` | FIXED | grep reads from a here-string | +| `packages/mosaic/framework/tools/wake/digest.sh:647` | FIXED | capture complete locator output, then select first line by parameter expansion | +| `packages/mosaic/framework/tools/wake/reconcile.sh:149` | FIXED | one awk reads the manifest directly and exits after the first exact key | + +## Explicit withdrawn / non-load-bearing sites + +| Baseline site | Verdict | Reason | +| ---------------------------------------------------------------------------- | ------------------- | ------------------------------------------------------------------------------------------------------------------------- | --- | --------------------------------------- | +| `tools/install.sh:182` | NOT-LOAD-BEARING | ` | | true` explicitly discards lookup status | +| `tools/install.sh:356` | UNREACHABLE-AND-WHY | `pnpm pack` writes one matching CLI tarball into a fresh directory immediately before lookup; citation withdrawn in #1099 | +| `tools/install.sh:357` | UNREACHABLE-AND-WHY | same fresh-directory invariant for gateway tarball; citation withdrawn in #1099 | +| `tools/install.sh:627` | NOT-LOAD-BEARING | ` | | true` explicitly discards lookup status | +| `scripts/agent/session-start.sh:70` | NOT-LOAD-BEARING | optional scratchpad lookup has ` | | true` | +| `packages/mosaic/framework/templates/repo/scripts/agent/session-start.sh:58` | NOT-LOAD-BEARING | optional scratchpad lookup has ` | | true` | +| `packages/mosaic/framework/tools/qa/qa-hook-stdin.sh:25` | UNREACHABLE-AND-WHY | withdrawn in #1099 after designed-input reachability measurement; preserved without re-litigation | +| `packages/mosaic/framework/tools/qa/qa-hook-stdin.sh:27` | UNREACHABLE-AND-WHY | same withdrawn designed-input finding | +| `packages/mosaic/framework/tools/qa/qa-hook-stdin.sh:30` | UNREACHABLE-AND-WHY | same withdrawn designed-input finding | +| `packages/mosaic/framework/tools/qa/qa-hook-stdin.sh:32` | UNREACHABLE-AND-WHY | same withdrawn designed-input finding | +| `packages/mosaic/framework/tools/qa/qa-hook-stdin.sh:34` | UNREACHABLE-AND-WHY | same withdrawn designed-input finding | + +## Tranche 2 — non-wake test harnesses + +All 22 baseline sites below are `FIXED`; the checked-in tranche fixture is passed through the same scanner and asserts all 22 occurrences and 21 normalized identities (the same response-split line occurs twice). + +| Baseline site(s) | Verdict | Construction | +| ------------------------------------------------------ | ------- | ------------------------------------------------------------- | +| `systemd/user/test-fleet-units.sh:148` | FIXED | capture tmux output, then grep via redirection | +| `git/test-issue-comment-readback.sh:283,302` | FIXED | parameter expansion splits status/body without `head` | +| `git/test-pr-review-gitea-comment.sh:228` | FIXED | parameter expansion splits status/body | +| `git/test-lane-brief-pr-linkage.sh:72` | FIXED | grep reads from a here-string | +| `git/test-pr-review-repo-host-override.sh:225-226` | FIXED | grep reads from a here-string | +| `orchestrator/smoke-test.sh:67,72` | FIXED | parameter expansion selects first line | +| `orchestrator/test-board-roll.sh:99-100` | FIXED | grep reads from a here-string | +| `quality/scripts/test-upgrade-durable-snapshot.sh:180` | FIXED | complete sorted output is read with `mapfile`, then indexed | +| `quality/scripts/test-upgrade-rollback.sh:339,356` | FIXED | direct `grep -m1` file reads; cleanup captures before testing | +| `tmux/test-send-message-socket.sh:37,38,44-46,68,72` | FIXED | capture commands complete before redirected grep assertions | +| `tmux/test-send-message-verdict.sh:34` | FIXED | grep reads from a here-string | + +## Tranche 3 — wake validation harnesses + +All 26 baseline occurrences (25 normalized identities; one preimage selector occurs twice) are `FIXED` and mechanically bound through the wake fixture and shared scanner. + +| Baseline site(s) | Verdict | Construction | +| -------------------------------------------------------------------------------------------- | ------- | -------------------------------------------------------------------------------------------------------------- | +| `wake/test-wake-digest-quarantine.sh:567` | FIXED | complete match populations are captured, then first line selected by parameter expansion | +| `wake/test-wake-preimage.sh:182-183,346-347` | FIXED | jq `first(...)` reads each JSONL file directly | +| `wake/validate-973/microtest-wake-assert.sh:153,170-171,176,204-209,233-234,251-252,286-287` | FIXED | scalar assertions use here-strings; diagnostics use non-early sed ranges; source line captured before matching | +| `wake/validate-973/validate-973.sh:110,119,180,182,187` | FIXED | scalar assertions use here-strings; diagnostic truncation uses consuming sed ranges | + +The scoped inventory is complete: 26 runtime/general + 22 non-wake tests + 26 wake tests fixed; 11 explicitly withdrawn or non-load-bearing sites retain their documented verdicts. diff --git a/docs/scratchpads/1043-pane-git-identity.md b/docs/scratchpads/1043-pane-git-identity.md new file mode 100644 index 00000000..8e54df07 --- /dev/null +++ b/docs/scratchpads/1043-pane-git-identity.md @@ -0,0 +1,229 @@ +# #1043 — Fleet pane git-identity propagation + +## Objective + +Ensure a fleet seat's launched runtime process receives its roster-derived `MOSAIC_GIT_IDENTITY`, and lock the complete generated-environment propagation boundary with an enumerated set comparison. + +## Tracking + +- External issue: `mosaicstack/stack#1043` +- Branch: `fix/1043-pane-git-identity` +- Coordinator: `tl-mosaic` +- `docs/TASKS.md`: read-only by project worker contract; not modified. + +## Constraints + +- RED-first bug reproducer is mandatory. +- R7 delete-the-subject mutation must turn the behavioral test red. +- Assert launched-process environment, not source text. +- One push only; do not poll CI after push. +- Run the CI queue guard immediately before push and report its `state=` line as state, not evidence. +- Do not modify a live host launcher or obtain/copy another credential. +- Self-post the PR, verify provider attribution, then stop. +- Final status wording: `believed-fixed, pending jarvis validation`. + +## Scope inventory + +Re-derived against `origin/main` at `85d2108e`: + +- Launch consumer: `packages/mosaic/framework/tools/fleet/start-agent-session.sh` +- Behavioral launch test: `packages/mosaic/framework/tools/fleet/test-start-agent-session.sh` +- Generated-environment contract/parser: `packages/mosaic/src/fleet/generated-env-boundary.ts` +- Roster projection producers: + - `packages/mosaic/src/commands/fleet.ts` + - `packages/mosaic/src/fleet/fleet-reconciler.ts` + - `packages/mosaic/src/fleet/fleet-agent-crud.ts` + - `packages/mosaic/src/fleet/v1-v2-migration.ts` +- Contract and producer tests discovered by repository search. +- Generated-environment operator/developer docs and their executable documentation contract test. + +Discrepancy sent to `tl-mosaic`: current main no longer contains the charter's `PANE_SHELL_SNIPPET`; #772 replaced it with an `/usr/bin/env -i` argv launch boundary, and current generated projections do not declare git identity. Code-read inventory is **NOT MEASURED** behavior. + +## Plan + +1. Add the process-environment set-comparison regression first and record RED. +2. Add roster-derived `MOSAIC_GIT_IDENTITY=` to the complete generated projection contract. +3. Validate identity syntax and equality with `MOSAIC_AGENT_NAME`; pass it through the clean pane environment. +4. Update affected projection tests and generated-environment docs. +5. Run focused and baseline gates. +6. Perform R7 by deleting the pane propagation entry, prove RED, restore, and prove GREEN. +7. Run independent review, remediate, commit, queue guard, one push, self-post PR, verify provider attribution, and stop without CI polling. + +## Budget + +No explicit token cap was provided. Working cap: one narrow logical unit, no dependency installation unless existing tooling requires it, no unrelated refactor. + +## Evidence log + +### TDD and mutation evidence + +- RED-first, repository launcher: `bash packages/mosaic/framework/tools/fleet/test-start-agent-session.sh` exited 64 on pre-fix source with `code=unknown-key key=MOSAIC_GIT_IDENTITY`. The generated seat could not launch with the required declared identity. +- GREEN: the same repository launcher test emitted `ok - start-agent-session generated environment boundary`. +- R7 delete-the-subject: removed only `"MOSAIC_GIT_IDENTITY=$MOSAIC_GIT_IDENTITY"` from the repository launch array; the same test exited 1 with `FAIL: runtime pane omitted or changed generated environment keys: MOSAIC_GIT_IDENTITY`. +- R7 restoration: restored that launch entry; the same test returned green. +- Launcher under test is explicitly `packages/mosaic/framework/tools/fleet/start-agent-session.sh` through the test's `$START`, **not** the stale installed host copy. + +### Situational and focused tests + +- Repository launcher boundary: green, including set comparison of all nine generated projection entries and fail-before-tmux cases for missing, unsafe, mismatched, and local-shadow Git identity. +- Fleet systemd launcher integration: `bash packages/mosaic/framework/systemd/user/test-fleet-units.sh` — green. +- Focused Mosaic Vitest set: 6 files, 311 tests — green. +- `bash -n` on changed shell files — green. +- `git diff --check` — green. + +### Baseline gates + +- `pnpm typecheck` — 45/45 tasks green. +- `pnpm lint` — 25/25 tasks green. +- `pnpm format:check` — green. +- `pnpm test:checkout` — green. +- Repository-wide Vitest under a hermetic current-version npm prefix: Mosaic 81/81 files and 1510/1510 tests green; other workspace test tasks shown green before the framework-shell phase. +- Canonical `pnpm test` is not fully green on this host for unrelated environment-sensitive gates: + 1. the first two runs exposed the globally installed Mosaic 0.0.48 update banner in three CLI smoke tests expecting empty stderr; + 2. after isolating that global-version input, the framework wake assertion aborted at the known `#973` Bash `BASH_LINENO` convention check (exit 97; observed `[3 5]`, expected `[3 4]`). + No tests were weakened or bypassed; focused changed-surface tests are green. CI remains the canonical clean-environment result and is intentionally not polled after push per charter. + +### Independent review + +- Codex code review first pass: request changes for missing shell rejection-path coverage. +- Remediation: added table-driven missing/unsafe/mismatch/local-shadow launcher cases, each asserting no tmux call. +- Codex code re-review: **approve**, no findings, confidence 0.88. +- Codex security review: risk `none`, no findings, confidence 0.97. + +### Acceptance criteria mapping + +| Acceptance criterion | Evidence | +| --- | --- | +| AC-FGI-01: launched process receives every generated key/value | Repository launcher process-environment `comm -23` set comparison; GREEN and R7 RED evidence above | +| AC-FGI-02: missing, unsafe, or split identity fails before tmux | Table-driven shell cases plus TypeScript generated-boundary tests | +| AC-FGI-03: focused/baseline/review evidence recorded | Commands and review outcomes above; host-sensitive full-suite limitations stated explicitly | + +### Documentation checklist + +- PRD updated with #1043 requirements and acceptance criteria. +- Fleet launch runbook, generated-env concept, and generated-env reference updated. +- No API/OpenAPI, sitemap, user publishing target, deployment, or external docs publication change applies. +- `docs/TASKS.md` remains unmodified per its single-writer project contract. + +## Round 2 — PR #1073 review 97 remediation + +### Review blocker + +The launched-process suite was signed-excluded from CI enumeration. Manual GREEN/R7 evidence therefore did not prove a PR workflow could detect regression. + +### RED-first and canonical wiring + +1. Removed the suite's signed exclusion before adding a CI execution path. +2. `check-test-enumeration.sh` went RED with exact `UNENUMERATED` output for `test-start-agent-session.sh`: population 49, enumerated 30, excluded 18. +3. Added both `framework/tools/fleet/test-start-agent-session.sh` and `framework/systemd/user/test-fleet-units.sh` to `@mosaicstack/mosaic`'s canonical `test:framework-shell` chain. +4. The guard returned GREEN: population 49, enumerated 32, excluded 18, surfaces 45. The systemd suite is outside the guard's tools-only population but now has the same explicit canonical execution disposition. + +### Workflow-level R7 + +- Deleted only the pane launch entry `"MOSAIC_GIT_IDENTITY=$MOSAIC_GIT_IDENTITY"`. +- Ran the exact `.woodpecker/ci.yml` test-step command, `pnpm test`, with only a temporary PATH-scoped npm shim reporting the checkout's current 0.0.49 version so the unrelated global 0.0.48 banner could not preempt the shell chain. +- Result: exit 1 at `@mosaicstack/mosaic#test`, with the enumeration guard GREEN followed by `FAIL: runtime pane omitted or changed generated environment keys: MOSAIC_GIT_IDENTITY`. +- Restored the launch entry. The canonical `test:framework-shell` chain then reached both newly wired suites and printed both GREEN markers before the known unrelated #973 host-only `BASH_LINENO` abort. +- An actual provider PR workflow on the intentionally broken mutant is **NOT MEASURED**: the one-push constraint forbids pushing a red mutant and then a repaired head. Local execution proves the exact PR workflow command and dependency chain go RED on the subject deletion; CI on the repaired pushed head remains canonical. + +### Workflow population + +- **DEFINED:** 3 workflows (`ci.yml`, `ci-image.yml`, `publish.yml`). +- **ELIGIBLE for `pull_request`:** 1/3 (`ci.yml`), based on top-level `when:` clauses. +- **REPORTED:** Round-1 exact-head provider read reported 1/1 eligible context (`ci/woodpecker/pr/ci`). Post-remediation-head reported count is **NOT MEASURED** by this seat because CI polling is prohibited; workflow definitions and eligibility did not change. + +### Independent remediation review + +- First Round-2 review identified a CI-image blocker: the newly wired launcher suite used Perl, which the Alpine CI base does not install. +- Replaced the suite's three Perl-only fixture mutations with POSIX/BusyBox-compatible `sed -i` substitutions; production behavior and assertions are unchanged. +- Codex re-review: **APPROVE**, confidence 0.93, no findings. + +### Vitest denominator reconciliation + +The PR's `311/311` is correct for its explicitly named six-file command at both the original and remediation worktrees: + +- generated environment boundary: 24 +- fleet documentation: 23 +- Tess service profile: 6 +- fleet regen command: 27 +- fleet agent CRUD command: 22 +- fleet command: 209 +- total: **311** + +Review 97 reported 312/312 without naming its six files. That is a different or miscounted population and cannot replace the command-scoped 311 denominator; the PR follow-up will name the exact files and arithmetic. + +## Round 3 — Alpine stale-marker portability + +### Objective and plan + +- Replace the GNU-only relative-date fixture with a deterministic POSIX/BusyBox timestamp while preserving the required stale-marker assertion. +- Re-run the launcher suite in the canonical `ci-base:latest` Alpine image, then run applicable repository gates and independent review. +- Update the PR body to name the repeated GNU-host/Alpine-CI portability pattern, run the mandatory queue guard, push once, verify provider attribution, and stop without CI polling. +- Working budget: 8K tokens; scope is one fixture line plus delivery evidence. No production behavior changes. + +### RED-first evidence + +Before the fix, the canonical CI image command +`docker run --rm -v "$PWD:/work" -w /work git.mosaicstack.dev/mosaicstack/stack/ci-base:latest bash packages/mosaic/framework/tools/fleet/test-start-agent-session.sh` +exited 1 at the stale-marker setup with exact BusyBox output +`touch: invalid date '10 seconds ago'`. The prior fresh-marker assertions had already executed, matching pipeline 2233's failure location. + +### Root cause and fix + +The test used GNU `touch -d` relative-date parsing although the PR workflow runs on Alpine/BusyBox. The fixture now uses POSIX `touch -t 200001010000.00`, a fixed timestamp that is unconditionally stale; the stale assertion remains mandatory and was not made tolerant of missing timestamp metadata. + +### Structural pattern + +This is the third GNU-host/Alpine-CI portability defect in the lane: GNU `grep` multi-match counting, Perl-only fixture mutation, and GNU `touch -d` date parsing. The repeated cause is shell suites authored on a GNU host but executed in an Alpine CI image; durable prevention belongs in CI-image execution or portability lint, not assertion weakening. + +### GREEN and quality evidence + +- Focused launcher suite in `ci-base:latest`: exit 0, `ok - start-agent-session generated environment boundary`. +- Canonical test step in `ci-base:latest` with the pipeline's `pgvector/pgvector:pg17` service, readiness check, migration, and `pnpm test`: exit 0; 46/46 Turbo tasks; Mosaic 81/81 files and 1510/1510 tests; Gateway 57 passed/5 skipped files and 629 passed/11 skipped tests; enumeration 49 population / 32 enumerated / 18 signed exclusions / 45 named surfaces. +- The first image-only `pnpm test` attempt lacked the pipeline PostgreSQL service and failed only on connection refusal after the launcher suite was GREEN. The rerun supplied the canonical service precondition and passed. +- Canonical-image baseline: typecheck 45/45 tasks, lint 25/25 tasks, format check GREEN; `git diff --check` GREEN. +- Independent Codex code review: APPROVE, confidence 0.96, 2/2 Round-3 files, no findings. +- Independent Codex security review: risk none, confidence 0.99, 2/2 Round-3 files, no findings. + +### Re-derived inventory and denominators + +- Round-3 git delta: **2/2 files** — launcher suite and task scratchpad; 25 insertions / 1 deletion before evidence finalization. +- Full PR path inventory against `origin/main` at `85d2108e`: **19/19 changed paths**; Round 3 adds no new PR path. +- Workflow definition population: **1/3 pull-request-eligible** (`ci.yml` of `ci.yml`, `ci-image.yml`, `publish.yml`). +- Do not re-litigate the settled 311/312 populations; both are valid for their separately named Tess6 and CRUD-core7 sets. + +## Round 4 — bound stale-marker observation + +### Objective and plan + +- Make the heartbeat assertion discriminate an initially stale native marker from a fresh marker without changing the production staleness threshold or shortening the polling window. +- Freeze only the sidecar's numeric observation clock during the stale-fixture arm so elapsed assertion time cannot turn a fresh mutant stale. +- Prove two independent mutants RED: disable production stale-marker detection while retaining the stale fixture; replace the stale fixture with a fresh marker. Restore the tree and prove GREEN in the canonical Alpine image. +- Re-derive the changed-path inventory, run applicable quality and independent review gates, commit with environment-only author/committer identity, queue-guard, push once, verify provider attribution using curl stdin config, and stop without CI polling. +- Working budget: 8K tokens. Scope is the launcher test and its scratchpad evidence; production launcher behavior remains unchanged. + +### Root cause and bounded observation + +The 30 × 0.1-second assertion window overlaps the production `now - marker > interval * 2 + 1` threshold at interval 1. Depending on second boundaries and load, a fresh marker can age past the threshold before the assertion ends. A focused pre-fix fresh-mutant attempt returned RED while Review 101's full-suite run returned GREEN; the differing result is itself timing dependence, not a discriminating assertion. + +The test now supplies a fixed numeric epoch only to the stale-fixture sidecar. Its real marker mtime is still read from the filesystem, but assertion runtime cannot advance `now`. Date formatting still delegates to the image's real `/bin/date`. Neither the production threshold nor the 30 × 0.1-second polling window changed. + +### Two-mutant RED / restored GREEN + +All three runs used `git.mosaicstack.dev/mosaicstack/stack/ci-base:latest`: + +1. **Stale-detection mutant RED:** replaced only the production stale-age predicate with `false` while retaining the fixed stale marker; suite exit 1 with `FAIL: heartbeat sidecar did not resume after native marker became stale or absent`. +2. **Fresh-marker mutant RED:** replaced only `touch -t 200001010000.00` with fresh `touch`; suite exit 1 with the same failed stale-resumption assertion. The fixed observation epoch kept the mutant fresh throughout all 30 polls. +3. **Restored tree GREEN:** suite exit 0 with `ok - start-agent-session generated environment boundary`. + +### Re-derived inventory + +- Round-4 delta: **2/2 files** — launcher test plus task scratchpad; production launcher delta is empty. +- Full PR inventory against `origin/main`: **19/19 paths**; Round 4 adds no path. +- Production stale threshold remains `now - marker > iv * 2 + 1`; assertion polling remains 30 × 0.1 seconds. +- Review 101's confirmed enumeration/workflow/CI and attribution evidence is accepted without re-polling or re-derivation. + +## Residual risk + +- Landing on `main` does not update the currently installed host launcher. Host framework installation/reseed and Jarvis live-seat validation are separate downstream events. +- Canonical CI result is pending and will not be polled by this seat. diff --git a/docs/scratchpads/1098-framework-shell-portability.md b/docs/scratchpads/1098-framework-shell-portability.md new file mode 100644 index 00000000..285a6926 --- /dev/null +++ b/docs/scratchpads/1098-framework-shell-portability.md @@ -0,0 +1,97 @@ +# #1098 — Framework shell portability / red main + +## Objective + +Restore terminal-green `main` by making the `test-start-agent-session.sh` clean-environment assertion semantic and portable without removing either newly enumerated framework-shell suite. + +## Scope + +- Tracking issue: `mosaicstack/stack#1098` +- Branch: `fix/framework-shell-portability` +- Base: `origin/main` at `4fa2768962702d53e16e8b67ee6ad52ebcb0910e` +- Primary file: `packages/mosaic/framework/tools/fleet/test-start-agent-session.sh` +- Requirements source: `docs/PRD.md` § Framework shell assertion portability (#1098) +- Out of scope: deployed files under `~/.config/mosaic`, pnpm-store cleanup, checkout deletion, and changes to the launcher’s `/usr/bin/env -i` behavior. + +## Acceptance criteria + +1. The test inspects the captured NUL-delimited tmux argv semantically and accepts an adjacent `/usr/bin/env`, `-i` pair regardless of trailing payload size or pipe scheduling. +2. Missing `/usr/bin/env`, missing `-i`, and non-adjacent `-i` remain failures. +3. Failure output includes the observed argv records with stable indexes and shell escaping; it exposes no credentials because this fixture supplies only generated non-secret launch data. +4. The focused suite passes on the dev host and in the repository CI image; the blocking PR/main pipeline returns terminal green. +5. Independent review passes; PR is squash-merged and #1098 is closed only after merged-main CI is terminal green. + +## Budget + +- ASSUMPTION: 30K-token working budget; rationale: one shell-test defect plus full PR/CI lifecycle. +- Auto-reduction: focused shell and package gates first; rely on canonical Woodpecker for the full monorepo suite rather than duplicating a dependency install under constrained `/home`. +- Disk baseline before clone/build: `/home` 7.1G free (99% used), `/tmp` 2.4G free (92% used). + +## Investigation + +### First-hand CI evidence + +- Public log: `GET https://ci.mosaicstack.dev/api/repos/47/logs/2269/53041` +- Decoded 1,436 entries (11 null `data` entries treated as empty log rows), 190,756 bytes. +- Failure: `FAIL: pane command did not clear its environment` immediately after the expected pane-PID warning. +- BusyBox primitives, complete assertion pipeline, real CI image, stale/current image digests, Turbo cache masking, gateway failure, and heartbeat-sidecar concurrent writing were independently excluded. + +### Root cause + +The assertion ends in: + +```bash +printf '%s\n' "$pane_args" | tail -n +"$after_pane_env" | grep -qxF -- '-i' +``` + +The script has `set -o pipefail`. `grep -q` exits as soon as it finds the valid `-i` record. Upstream `tail`/`printf` can then receive SIGPIPE, making the aggregate pipeline nonzero even though grep returned 0 and the semantic property is true. This depends on payload size, pipe capacity, and scheduling, explaining a local/image pass with a CI failure. + +Discriminating stress control with `/usr/bin/env` followed immediately by `-i`: + +- 8,192-byte trailing payload: `printf=0 tail=0 grep=0`, aggregate 0. +- 16,384-byte trailing payload: `printf=0 tail=141 grep=0`, aggregate 141. +- 32,768+ bytes: `printf=141 tail=141 grep=0`, aggregate 141. +- A full-reading `grep -xF` control remained 0 for every payload. + +This is a third branch omitted by the earlier present-vs-corrupted split: the pair can be present and intact while `pipefail` reports an upstream SIGPIPE. + +## TDD plan + +1. RED: preserve the one-off stress reproducer above and add an automated large-argv semantic regression that fails under the current pipeline implementation. +2. GREEN: parse the authoritative NUL-delimited capture into a Bash array and search for an adjacent `/usr/bin/env`, `-i` pair without a short-circuit pipeline. +3. Add negative controls for missing, detached, and reversed tokens. +4. On failure, print indexed `%q` argv records before returning nonzero. +5. Run focused suite, mutation controls, shell syntax/format checks, then repository baseline gates feasible without dependency installation. +6. Independent review, queue guard, push, PR, CI, coordinator merge authorization, squash merge, merged-main CI, issue close. + +## Progress + +- [x] Checkout created and based on `origin/main` `4fa27689`. +- [x] CI log decoded directly. +- [x] Root-cause stress control reproduced semantic match + aggregate pipeline failure. +- [x] RED evidence: intact `/usr/bin/env`, `-i` fixture produced component statuses `0/141/0` and aggregate 141 under the former `grep -q` pipeline; full-reading semantic control stayed 0. +- [x] GREEN implementation: direct NUL-argv adjacency parser, indexed diagnostics, and full-reading scalar predicates replace all load-bearing early-exit pipelines in this test. +- [x] Baseline/situational tests: + - focused launcher suite: PASS on GNU host and cached Alpine CI image; + - paired `test-fleet-units.sh`: PASS; + - enumeration guard: PASS (`population=53`, `enumerated=36`, `excluded=18`), 14/14 mutation needles; + - `bash -n`, ShellCheck, `git diff --check`: PASS; + - static denominator after change: zero load-bearing `grep -q`/`head`/`-m1` pipeline candidates in `test-start-agent-session.sh`; + - delete-the-subject mutation removing production `-i`: RED with 78 indexed argv records, byte count, and explicit boundary failure. +- [x] Independent review: + - first Codex review: request changes — negative fixtures did not each assert diagnostics; + - remediation: centralized predicate + diagnostic wrapper and exercised all four negative fixtures; + - second Codex review: APPROVE, 0 blockers/should-fix/suggestions; + - Codex security review: risk none, 0 findings. +- [ ] PR CI, formal fleet review, merge, merged-main CI, issue closure. + +## Documentation disposition + +- Updated canonical `docs/PRD.md` with FSP requirements and acceptance criteria. +- This is an internal test/reliability change with no API, user workflow, deployment, navigation, or publishing-surface change; no user/admin/API/sitemap update is required. +- `docs/TASKS.md` remains unchanged because the project contract makes it orchestrator-only. + +## Risks + +- The CI failure did not print its captured argv, so the exact CI payload is unavailable. The stress control proves the assertion is non-portable and can emit the exact false verdict; branch CI is the canonical confirmation that replacing it resolves pipeline 2269’s failure class. +- Printing fixture argv is safe only while this test’s projection remains non-secret. The diagnostic must stay scoped to the test capture and shell-escaped. diff --git a/docs/scratchpads/1099-pipefail-sweep.md b/docs/scratchpads/1099-pipefail-sweep.md new file mode 100644 index 00000000..e8040d77 --- /dev/null +++ b/docs/scratchpads/1099-pipefail-sweep.md @@ -0,0 +1,41 @@ +# #1099 — pipefail + early-exit sweep + +## Scope and decisions + +- Baseline `df4c591ab42aa1ae62c12935fdc0e772684864a0`, after #1100 removed its 35 sites. +- Split into review-sized non-closing tranches: runtime/general; tmux/git/quality tests; wake validation/tests. +- Do not equate class membership with demonstrated risk. Do not use payload size or pipeline stage count as a safety proxy. +- Preserve the issue's withdrawn findings for `qa-hook-stdin.sh` and the two fresh-directory `pnpm pack` lookups. Fix `install.sh:312` because malformed multi-root input must reach its named handler. + +## Tranche 1 TDD + +RED-first control: `node --test scripts/pipefail-early-exit.test.mjs` reported exactly 26 non-accepted runtime/general sites, including `install.sh:312`, and exited 1. A checked-in fixture generated from immutable baseline `df4c591a` records all 26 normalized sites; the control passes every fixture entry through the same scanner, asserts exact identity/count/uniqueness, and separately requires zero findings in the current tree. It also inventories accepted sites rather than silently excluding whole files. + +Construction choices: + +- here-string/file redirection for scalar grep assertions; +- full capture then parameter expansion for first-line selection; +- arrays/`mapfile` for complete populations; +- direct jq/awk/grep selection where one tool can express the property; +- no `|| true` added to a load-bearing assertion. + +Site-by-site verdicts: `docs/reports/quality/1099-pipefail-sweep.md`. + +## Tranche 2 TDD + +Expanded the unconditional scanner over 11 non-wake test harnesses. RED named exactly 22 source lines; a second immutable-baseline fixture now asserts those 22 entries through the same scanner. Rewrites preserve command status by capturing producers before redirected assertions, use parameter expansion for line selection, and use complete `mapfile` populations where ordering matters. Current-tree finding count is zero for tranches 1 and 2. + +## Tranche 3 TDD + +Expanded the shared scanner over four wake validation harnesses. RED named 26 occurrences. The wake fixture asserts 26 occurrences / 25 normalized identities through the same scanner; all scalar assertions now use redirection, direct jq selection, complete capture, or consuming diagnostic ranges. Current-tree finding count is zero across the full scoped population. + +## Verification so far + +- `bash -n` on every changed shell script: pass. +- structural Node control: pass. +- `test-mutate-push-guard.sh`: 8/8 pass. +- `test-send-message-verdict.sh`: 3/3 pass. +- `test-send-message-socket.sh`: pass. +- Independent review 143 found two semantic regressions: a help-probe `|| true` changed the failure truth table, and an unguarded Git capture changed non-Git data-dir behavior from rc 0 + JSON to silent rc 128. Both received RED-first regressions before correction; help status is now separate and required, and Git status remains condition-guarded. +- Wake static inventory remains aligned at 261/261 after line-neutral rewrites; no static-set mismatch. Wake detector/reconcile/digest/preimage suites terminate at their existing fail-closed #973 `BASH_LINENO` environment probe (exit 97, observed `[3 5]`, expected `[3 4]`) before subject tests. No bypass or skip was used; canonical CI remains required. +- ShellCheck reports only pre-existing source-following, unused-variable, and untouched `ls | head` findings; no new diagnostic was introduced. diff --git a/docs/scratchpads/1150-pi-goal-extension.md b/docs/scratchpads/1150-pi-goal-extension.md new file mode 100644 index 00000000..e9fe8eae --- /dev/null +++ b/docs/scratchpads/1150-pi-goal-extension.md @@ -0,0 +1,156 @@ +# #1150 — Pi persistent goal extension + +- **Task ID:** ISSUE-1150 (no `docs/TASKS.md` row; that file is orchestrator-only) +- **Issue:** #1150 — `pi: add persistent /goal controller extension to Mosaic framework` +- **Branch:** `feat/1150-pi-goal-extension` +- **Mode:** Delivery +- **Status:** in progress + +## Objective + +Build and locally validate a Mosaic-owned Pi `/goal` extension. Source must ship from +`packages/mosaic/framework/runtime/pi/`, framework sync must deploy it under +`~/.config/mosaic/runtime/pi/`, and no extension/configuration asset may be written into `~/.pi`. +Pi's native session manager remains the owner of session entries. + +## Scope and acceptance source + +- Canonical requirements: `docs/PRD.md`, section **Pi Persistent Goal Loop (#1150)**. +- User intent: continuous goal orientation and status checking after each Pi turn and compaction, + tested locally before framework delivery. +- Documentation target: canonical in-repo user/developer/runtime docs; no external publication. + +## Assumptions + +- `ASSUMPTION:` Initial semantic verification uses two consecutive structured, evidence-bearing + reports from the working agent rather than a second model request after every turn. This keeps the + loop testable and avoids doubling model cost while making the limitation explicit. +- `ASSUMPTION:` Default autonomous bounds are 40 turns and 6 repeated no-progress reports, with only + bounded numeric environment overrides. +- `ASSUMPTION:` A local smoke copy to `~/.config/mosaic/runtime/pi/goal-extension.ts` is authorized by + the user's explicit request. Full framework reseed into the live home is not required for the smoke + test and would touch unrelated framework-owned files. + +## Budget + +- Working estimate: 30K implementation/review tokens. +- Hard user cap: none stated. +- Cost control: deterministic fake-Pi tests; no nested evaluator calls; only bounded arithmetic/load + smoke workflows against the installed runtime. + +## Plan + +1. Update PRD and create tracking/scratchpad artifacts. +2. Read launcher, installer ownership, Pi extension, and documentation surfaces. +3. TDD: add fake-Pi behavior tests for commands, state restoration, turn checks, compaction, limits, + verification, and continuation deduplication. +4. Implement `runtime/pi/goal-extension.ts` and deterministic launcher discovery. +5. Add framework-sync/deployment acceptance coverage. +6. Update user, developer, runtime, framework README, and sitemap documentation. +7. Run focused tests, local Mosaic-path smoke test, then baseline repository gates. +8. Run independent review, remediate, commit, push/PR/CI/merge/issue closure per delivery gates. + +## TDD decision + +Applied. The continuation state machine and lifecycle scheduling are control-path logic where a race +or false terminal state can cause unbounded work or premature completion. + +## Progress checkpoints + +- [x] Issue #1150 created through Mosaic wrapper. +- [x] Isolated worktree created from `origin/main`. +- [x] PRD requirements and acceptance criteria added. +- [x] Task scratchpad created. +- [x] RED controller and security-regression tests written and observed failing before implementation. +- [x] Goal controller, launcher discovery, framework deployment coverage, and bounded state machine + implemented. +- [x] User, admin, developer, runtime, adapter, README, and sitemap documentation updated. +- [x] Final source copied additively to `~/.config/mosaic/runtime/pi/goal-extension.ts`; source and + deployed SHA-256 are identical. +- [x] Live Pi RPC smoke from the exact Mosaic path reached `achieved` with two verification passes and + no extension errors. +- [x] Baseline and situational checks completed, except the explicitly documented unavailable + PostgreSQL-only root integration case. +- [x] Independent code and OWASP/security reviews completed; all findings remediated and re-reviewed. +- [ ] Commit, push, PR, terminal-green CI, squash merge, and issue closure complete. + +## Tests and evidence + +### Situational + +- `pnpm --filter @mosaicstack/mosaic exec vitest run src/runtime/pi-goal-extension.spec.ts` + - final: 25 passed. + - Covers commands, per-turn checks, context injection, two-pass verification, mixed-report + rejection, bounded limits, compaction, branch restore, stale timers, credential redaction, + typed-field false-positive protection, and append-only legacy-state fail-closed behavior. +- Final focused launcher/controller/file-adapter run: 3 files / 67 tests passed. +- Final V8 coverage for `framework/runtime/pi/goal-extension.ts`: + - 99.17% statements/lines, 93.78% branches, 100% functions. +- Installer migration fixture: 24 passed and byte-compared the deployed framework asset. +- Standalone extension TypeScript check against installed Pi 0.84.1 types passed: + `pnpm --filter @mosaicstack/mosaic exec tsc --noEmit --pretty false --module NodeNext + --moduleResolution NodeNext --target ES2022 --skipLibCheck framework/runtime/pi/goal-extension.ts`. +- Live deployment/load evidence: + - source/deployed SHA-256: + `1f0a3806e0948ad5f49684273a7e535e9880c148f7fd16d13ee487fcd601f637`. + - `get_commands` identified `/goal` as an extension command sourced from + `~/.config/mosaic/runtime/pi/goal-extension.ts`; `/goal help` succeeded; zero extension errors. + - live arithmetic goal ended `achieved`, verification `2/2`, with 3 goal reports / 3 agent starts + and zero extension errors. + - no goal extension exists under `~/.pi` extension paths. + +### Baseline + +- `pnpm build`: passed before the final framework-only redaction remediation; the extension is not a + package build input and its final source passed the standalone Pi type check. +- `pnpm typecheck`: 45/45 tasks passed. +- `pnpm lint`: 25/25 tasks passed. +- `pnpm format:check`: passed. +- Final Mosaic package components: + - Vitest: 82 files / 1,539 tests passed. + - full `test:framework-shell` harness passed. + - the discovered pre-existing tmux loader-marker race was reproduced with constructor PID + evidence, fixed with a pane readiness/FIFO barrier, passed 3 consecutive focused runs, and passed + in the full shell harness. + - one combined rerun encountered the separate existing real-lease probe TOCTOU in + `install-ordering-guard.spec.ts`; an earlier final Vitest run was fully green and the changed + focused suites remained green. +- Gateway safe baseline excluding the prohibited PostgreSQL-only fixture: 55 files / 600 tests passed + (6 files / 12 tests skipped by their existing environment gates). +- Root `pnpm test` reached 43 successful workspace tasks and all changed-package Vitest tests, but + the unchanged `apps/gateway/src/__tests__/cross-user-isolation.test.ts` afterAll hook retried a + PostgreSQL connection and failed authentication (`28P01`). This checkout explicitly forbids local + PostgreSQL startup/access; the failure is unrelated to #1150 and cannot be remediated by starting + the database. The gateway suite excluding that PostgreSQL-only file and required CI are used as + the safe verification paths. + +### Independent review + +- Codex code review: approved, 0 findings across 15 files. +- Initial Codex security review: one medium CWE-532/A09 finding for raw report persistence. +- Remediation added central credential-pattern redaction, prompt/docs guidance, canary tests, typed + field false-positive guards, and sticky fail-closed restore for credential-bearing append-only + history. +- Codex security re-review: risk `none`, 0 findings, confidence 0.87. +- Focused remediation review findings were fixed; final focused re-review verdict: `APPROVE`. +- Focused independent review of the tmux readiness barrier: `APPROVE`, no actionable findings. + +## Risks and blockers + +- Live `~/.config/mosaic` is shared by active Pi/fleet processes. Local deployment remained a single + additive framework file and did not reload or restart unrelated sessions. +- Completion verification is semantic, not mathematical: the active agent supplies structured + evidence twice. Operators must still inspect consequential outcomes. +- Credential redaction is pattern-based defense-in-depth, not a secret store. It covers + controller-owned state/status/tool details, not Pi's separate model-message/tool-call history. + Goals and reports must never contain real secrets or raw sensitive output. Because Pi session + entries are append-only, a detected credential-bearing legacy branch fails closed and the affected + session must be removed. +- Current installed Pi is newer than the repository's historical gateway Pi dependency. The + extension was checked and smoke-tested against installed Pi 0.84.1 using stable documented APIs. +- Local root testing cannot safely execute the unchanged PostgreSQL-only integration fixture under + the checkout's explicit database safety constraints. Terminal-green PR CI remains mandatory before + merge. +- The unchanged real-lease default-probe test can observe different broker availability across its two + sequential probes; one combined package rerun hit that existing TOCTOU. The same final Vitest suite + passed in a separate run, and CI remains the merge authority. diff --git a/docs/scratchpads/1174-wrapper-guard-round10.md b/docs/scratchpads/1174-wrapper-guard-round10.md new file mode 100644 index 00000000..3629652f --- /dev/null +++ b/docs/scratchpads/1174-wrapper-guard-round10.md @@ -0,0 +1,89 @@ +# #1174 — Wrapper guard rounds 10–11 + +## Objective + +Make checkout enforcement judge Git placement operands rather than every HOME-shaped word in the command, without reopening `--separate-git-dir` placement under HOME. + +## Plan + +1. Reproduce the four over-blocks and the placement-option control at head `20d86e39`. +2. Add RED fixtures before production changes. +3. Extract clone/worktree placement operands from the existing shell-aware normalized stream. +4. Run the full guard corpus, historical-head discrimination, syntax/static checks, probes, review, and CI. + +## Progress and evidence + +- Reproduced: `NOTE=$HOME`, `--reference=$HOME`, `GIT_DIR=$HOME/x`, and `--template=$HOME/t` all blocked despite explicit `/src/wt` destinations. +- RED at `20d86e39`: expanded suite had 8 failures, all HOME-valued non-placement cases. +- GREEN: expanded suite passes 242/242. +- Round-10 probes: 7/7 placement expectations and 4/4 placement-option controls pass. +- Earlier path probes remain green: 60/60, 24/24, and 17/17. +- Historical discrimination with the 242-fixture suite: + - `3d0a882a`: 216 pass / 26 fail. + - `4b8eba95`: 222 pass / 20 fail. + - `20d86e39`: 234 pass / 8 fail. +- `bash -n`, ShellCheck warning-or-higher, and `git diff --check`: pass. + +## Residual / risk + +- Relative destinations whose effective path depends on cwd are tracked separately by #1197 and remain out of scope. +- Unknown future Git options with a separate following value fail closed when that value is HOME-shaped. This may require classification when Git adds an unrelated path-taking option, but prevents a new placement option from silently bypassing the guard. + +## Round 11 objective and intake + +- **Issue / PR:** #1174. +- **Objective:** Remove the finite boolean-flag allowlists that turn accepted clone/worktree flags into fake placement operands, while preserving all real HOME placement blocks. +- **Scope:** `wrapper-guard.sh`, its hermetic fixtures, and task documentation. Relative cwd-dependent destinations remain in #1197. +- **Surfaces:** security-sensitive Bash hook behavior and shell/Git option grammar; no API, DB, UI, auth, deploy, or dependency changes. +- **Budget assumption:** 25K working tokens; reduce exploratory matrices before reducing acceptance coverage. + +### Round 11 plan + +1. Use Git itself to classify accepted/rejected clone and worktree options, and Bash itself to resolve path-word expectations. +2. Add RED fixtures for all six reported clone flags, generated negations, and equivalent worktree grammar. +3. Replace the open-ended unknown-option fail-closed fallback with a parser based on the closed value-taking option surface; keep explicit placement options special. +4. Run the full corpus, historical discrimination, shell/static checks, targeted probes, independent code/security review, one push, and exact-head CI. + +### Root-cause evidence + +- Git 2.39.5 accepts all six reported clone flags and the broader generated family measured in the brief: `--bare`, `--mirror`, `--ipv4`, `--ipv6`, `-4`, `-6`, `--no-local`, `--no-reject-shallow`, `--no-bare`, `--no-sparse`, `--no-dissociate`, `--no-shallow-submodules`, `--no-quiet`, `--no-progress`, and `--no-recurse-submodules`; it rejects `--relative-paths` as unknown. +- Git 2.39.5 accepts worktree negations including `--no-force`, `--no-detach`, `--no-lock`, `--no-guess-remote`, and `--no-track`; the current finite worktree flag list does not describe that generated family. +- `bash -c "printf '%s' "` resolves `$HOME/source`, `${HOME}/source`, and `"$HOME"/source` under HOME while `/src/wt` remains outside it. +- **Hypothesis:** only separate-value options need positive classification. Treat every other option token as a no-value flag unless it is the explicit placement option; this matches Git's non-enumerable boolean family and confines the residual to genuinely new future value-taking options. + +### TDD and verification checkpoints + +- RED against the unmodified `91cc37bc` guard: 253 pass / 22 fail in the initial expanded 275-fixture suite. Failures include all 15 accepted clone flags, accepted long abbreviations, short value-taking bundles, abbreviated placement, worktree metadata abbreviation, and both directions of bundled worktree branch parsing. +- An exploratory fail-closed residual test drove emission of every worktree positional. Re-review correctly showed that this over-blocked HOME-shaped commit-ish metadata; a new commit-ish fixture failed RED against that intermediate implementation (278 pass / 2 fail, including one transient message assertion) and the parser was restored to emit only the actual path. +- GREEN after remediation: 280/280. +- Ultron's 13-shape option probe: 13/13 correct, including the six reported over-blocks, HOME destinations, end-of-options, worktree controls, and a later-command placement. +- Round-10 probes remain green: 7/7 subject-placement expectations and 4/4 `--separate-git-dir` controls. +- Earlier shell/path probes remain green: 60/60, 24/24, and 17/17. +- `bash -n`, ShellCheck warning-or-higher, and `git diff --check`: pass. + +### Deliberate residual + +A future Git release could add a new separate-value option absent from the closed value grammar. It defaults to no-value flag parsing, which leaves the following word positional. For clone, this can fail open if that future option itself creates repository state at its value. For worktree, it can shift which word is read as the path. This hypothetical future ambiguity is accepted deliberately because failing closed on every unclassified option is proven to over-block Git's open-ended present-day boolean/`--no-*` family. Every value-taking and placement option Git currently supports is classified, including accepted abbreviations of `--separate-git-dir`. Relative cwd-dependent targets remain in #1197. + +### Independent review checkpoint + +- Initial Codex code/security review raised `--orphan` as value-taking. Upstream Git `master` contradicts that premise: the synopsis is `[--orphan] [(-b | -B) ] []`, and the prose derives the branch from the path when `-b`/`-B` is absent. `--orphan` is therefore correctly handled as a boolean flag. +- The security review separately identified the generic future worktree shift residual. An attempted fail-closed remediation emitted every positional, but code re-review correctly rejected it because valid grammar has only one placement positional and an optional commit-ish. Final behavior checks only the path and documents the hypothetical future option shift deliberately; paired actual-grammar `--orphan` fixtures cover safe/HOME paths and `-b` metadata. +- Security re-review initially had no findings. Code re-review's commit-ish blocker was remediated with a RED fixture and path-only restoration; final code re-review approved with no findings. +- Final security review then found non-canonical absolute and symlink aliases. Eight lexical fixtures failed RED against the prior implementation, followed by three symlink fixtures failing RED. Remediation expands only shell-visible HOME tokens, resolves the longest existing directory prefix physically, and lexically normalizes the nonexistent suffix. The suite is now 292/292. +- Inherent residual: a symlink can be replaced between pre-tool inspection and Git execution. Existing aliases are resolved; eliminating the race requires enforcement inside the filesystem mutation path rather than a text pre-hook. Security review classified this medium, and architectural closure is tracked in #1199. +- Final independent code review: APPROVE, 0 findings. Final security review: no critical/high findings; the single medium TOCTOU residual is explicitly tracked in #1199. + +### Final local evidence + +- Final hermetic suite: 292/292; the same suite against `91cc37bc` discriminates at 256 pass / 36 fail. +- Ultron option probe: 13/13; round-10 probes: 7/7 plus 4/4 controls; earlier shell/path probes: 60/60, 24/24, and 17/17. +- `bash -n`, ShellCheck warning-or-higher, `git diff --check`, sanitization gate, and test-enumeration gate (population 55; 38 enumerated; 18 signed exclusions): pass. +- Independent code review: APPROVE, 0 findings. Security review's remaining medium TOCTOU architecture residual is tracked in #1199; no critical/high findings remain. +- Repository-wide TypeScript gates require dependencies absent from this worktree; the canonical Woodpecker pipeline will run them against the pushed exact head. + +### Documentation checklist + +- `docs/PRD.md` updated with WPG requirements, acceptance, canonicalization, and residual risk. +- Task scratchpad updated in the same logical change set; `docs/TASKS.md` remains orchestrator-only. +- No API, auth, UI, navigation, deployment, user-guide, or admin-guide surface changed; OpenAPI, endpoint index, sitemap, and publishing are not applicable. diff --git a/docs/scratchpads/1179-required-security-di.md b/docs/scratchpads/1179-required-security-di.md new file mode 100644 index 00000000..c7719d39 --- /dev/null +++ b/docs/scratchpads/1179-required-security-di.md @@ -0,0 +1,77 @@ +# #1179 — Required security DI wiring + +## Objective + +Eliminate the shared fail-open defect class **absence read as permission**: + +- FL-01: missing `CommandAuthorizationService` must refuse Nest startup and must not permit command effects. +- FL-11: missing `SystemOverrideService` must refuse Nest startup and must not omit stored instruction authority while allowing provider/session effects. + +## Tracking + +- Issue: #1179, child of #1156 +- Branch: `fix/1179-required-security-di` +- Base: `origin/next` at `216cd72226cd9ee17eea461cfe7cd0e010a22f02` + +## Plan + +1. RED: compile the real `AppModule` graph with each required provider independently removed, with a positive control for each intact binding. +2. RED: directly exercise each malformed absence path and assert zero command/provider/session effects. +3. Stop and report RED to the coordinator before production implementation. +4. After authorization, make both constructor injections required, remove absence-as-permission branches, and update explicit legitimate optional test seams. +5. Run focused Gateway tests, typecheck, lint, format, build, independent exact-head verification, and focused security review. + +## Immutable path fence + +Production changes are confined to: + +- `apps/gateway/src/commands/command-executor.service.ts` +- `apps/gateway/src/agent/agent.service.ts` + +Tests and task evidence are confined to: + +- `apps/gateway/src/__tests__/required-security-wiring.test.ts` +- existing direct-constructor specs that require explicit required arguments +- `docs/scratchpads/1179-required-security-di.md` + +No files in #1178, #1072, #1080, or #1054 lanes are in scope. `docs/TASKS.md` is orchestrator-owned and will not be modified. + +## Budget + +No explicit token ceiling was provided. Working assumption: one narrow Gateway security packet; split and stop if either arm requires unrelated module rewiring. + +## Progress + +- Intake read from #1179 and parent #1156. +- Base independently resolved from the issue's pre-native-stage ordering and repository `origin/next` ref; branch HEAD verified byte-for-byte against the remote ref. +- Real consumers and direct constructors inventoried. + +## Tests + +### RED + +- `required-security-wiring.test.ts`: 4 failed, 2 passed before implementation. +- Both real-graph negative controls showed module compilation accepted the missing target binding. +- Direct FL-01 showed one unauthorized command effect; direct FL-11 showed one provider prompt and one session counter mutation. + +### GREEN + +- `required-security-wiring.test.ts`: 6/6 passed. +- FL-01-only production revert: exactly the two FL-01 test cases failed; all four other cases, including FL-11, passed. +- FL-11-only production revert: exactly the two FL-11 test cases failed; all four other cases, including FL-01, passed. +- Full Gateway suite: 74 files passed, 7 skipped; 831 tests passed, 17 skipped. +- Gateway typecheck: passed. +- Gateway lint: passed. +- Gateway build: passed. +- Changed-file Prettier check: passed. + +### Review + +- Codex code review: APPROVE, 0 findings. +- Codex focused security review: risk `none`, 0 findings. +- Independent exact-head review remains assigned to Scrappy through the coordinator. + +## Risks / blockers + +- `AgentModule` / `CommandsModule` / `ChatModule` contain a production cycle; the module test therefore uses the real top-level `AppModule` and replaces only storage/network leaves, preserving the target service in each arm while isolating the separate required consumer that would otherwise mask that arm's defect. +- No broad module rewrite was required. diff --git a/docs/scratchpads/1194-framework-tool-drift.md b/docs/scratchpads/1194-framework-tool-drift.md new file mode 100644 index 00000000..469a9d53 --- /dev/null +++ b/docs/scratchpads/1194-framework-tool-drift.md @@ -0,0 +1,71 @@ +# #1194 — Installed framework-tool drift detection and refresh analysis + +## Decision + +The reported queue-guard source defect was already fixed on `main` by `58b971ab`; the live failure came from a stale `~/.config/mosaic/tools/git/ci-queue-wait.sh`. The durable fix is therefore a detector, not a duplicate queue-guard patch. + +`mosaic doctor` now compares the framework tools bundled with the executing Mosaic package against the deployed tools tree. Doctor is the selected visibility boundary because it is observational and operator-invoked: unlike session start, it does not add a repository/network scan to every seat launch, and it cannot silently replace identity or messaging tools while seats are active. It reports drift without changing files. `--fail-on-warn` converts detected drift into a non-zero doctor result. + +## Classification + +The existing `framework-manifest.txt` is authoritative. The detector invokes the canonical shared `tools/_lib/manifest.sh classify` implementation over the complete source census and refuses missing, unreadable, malformed, incomplete, or zero-framework ownership output. Policy is therefore read rather than duplicated: + +- Current policy classifies source files under `tools/**` as framework-owned and required in the deployed tools tree. +- Current policy explicitly classifies `tools/_lib/credentials.json` operator-owned and excludes it from byte comparison; future policy changes take effect without a detector edit. +- A file present only in the deployed tools tree is operator-owned/unknown by the manifest's fail-safe default. The detector reports it as `INSTALLED_ONLY operator-or-unknown` under `--verbose` but does not fail or delete it. +- Empty/partial source traversal, unreadable directories/files, symlinked census entries, root aliases, and descendant source aliases all return `CANNOT_ASSERT` rather than manufacturing agreement. + +This means `NOT_INSTALLED` is not suppressed by filename guesses such as “test” or “README”: if it ships below source `tools/**`, the installer contract says it should be installed. Source-only implementation files outside `tools/**` are outside this detector population by construction. + +## Current host analysis (observation only; no refresh performed) + +A direct source-vs-installed census showed broad drift, including identity and messaging behavior: + +- Identity/provider operations: stale `git/detect-platform.sh`, `issue-comment.sh`, `issue-create.sh`, `issue-close.sh`, `issue-view.sh`, `pr-create.sh`, `pr-merge.sh`, `pr-review.sh`, `pr-metadata.sh`; missing `pr-edit.sh` and several identity/read-back regression tools. +- Messaging/session: stale `tmux/agent-send.sh`, `tmux/send-message.sh`, their regressions, and `fleet/start-agent-session.sh`. +- Gate enforcement: stale `git/ci-queue-wait.sh`; missing the queue tri-state/process-level suites and terminal-green verifier. +- Lease/QA behavior: stale lease-broker launch/mutation/receipt tools and QA hooks. + +Counts vary with source head and installed local/operator files; the detector prints measured counts every run rather than baking this snapshot into policy. + +## Reviewed refresh command — analyse only, do not run during active seats + +Use the package/release updater's manifest-driven keep-mode sync during a quiet maintenance window: + +```bash +MOSAIC_SYNC_ONLY=1 \ +MOSAIC_INSTALL_MODE=keep \ +MOSAIC_HOME="$HOME/.config/mosaic" \ +bash /path/to/reviewed/@mosaicstack/mosaic/framework/install.sh +``` + +For the globally installed package, resolve the reviewed installer rather than guessing its path: + +```bash +PACKAGE_ROOT="$(dirname "$(node -p "require.resolve('@mosaicstack/mosaic/package.json')")")" +MOSAIC_SYNC_ONLY=1 MOSAIC_INSTALL_MODE=keep MOSAIC_HOME="$HOME/.config/mosaic" \ + bash "$PACKAGE_ROOT/framework/install.sh" +``` + +Do not run this while agent seats are active: the stale set includes identity selection, provider mutation, messaging, queue/merge guards, lease enforcement, and session launch. Syncing those files in place can change behavior between a seat's preflight and mutation. + +## Post-refresh verification + +1. Run `mosaic doctor --fail-on-warn`; require `stale=0 not-installed=0` from the framework drift summary (other unrelated doctor warnings must also be adjudicated). +2. Re-run the constructed process-level queue probes against the **installed path**, not the source checkout. Use the source suite while overriding its subject path in a reviewed scratch copy, or reproduce these exact observations: + - pending provider payload: guard must print `state=pending`, print the pending context, wait, and exit non-zero/timeout — never return immediately with rc 0; + - malformed payload: guard must print `state=malformed` and exit non-zero; + - unsupported but valid status vocabulary: guard must print `state=unknown` and exit non-zero. +3. Run provider author read-back for one deliberately low-risk wrapper operation before resuming fleet mutation work; wrapper self-report is not identity evidence. +4. Relaunch seats only after the quiet-window verification, because existing processes retain loaded environment/context. + +## Probe evidence + +The detector regression constructs a stale installed tool plus a missing shipped tool and observes rc 1 with distinct `STALE` and `NOT_INSTALLED` lines. That case would pass or be invisible before this change because no installed-vs-shipped comparison existed. Additional review-red controls prove: + +- empty and unreadable source censuses return `CANNOT_ASSERT` (they returned clean rc 0 at the first PR head); +- deleting the manifest returns `CANNOT_ASSERT`, while changing manifest ownership changes the verdict through the canonical resolver (the first head never opened the manifest); +- root and descendant symlink/source aliases cannot return clean (the first head returned clean for a source-backed installed subtree); +- a checker hung during doctor is terminated by a bounded watchdog, emits `CANNOT_ASSERT`, and doctor reaches its final warnings line (the first head hung and suppressed the remaining audit). + +Controls retain byte-identical success, exact credential carve-out behavior, and installed-only preservation. diff --git a/docs/scratchpads/ri-050.md b/docs/scratchpads/ri-050.md new file mode 100644 index 00000000..65196476 --- /dev/null +++ b/docs/scratchpads/ri-050.md @@ -0,0 +1,242 @@ +# Scratchpad — RI-050 orchestrator (jarvis, dragon-lin) + +Mission: alpha 0.0.50 release-integrity floor. Issue #1275. Base `next` @ 476db12b. +Design SSOT: jarvis-brain `docs/plans/2026-08-16_mosaic-stack-sdlc-protocol.md` (SDLC-D-033..038). + +## Mode (Jason's directives) + +- Orchestrator: jarvis (this session, dragon-lin). NOT mos-claude; work stays on this host. +- Workers: local pi headless — `pi --model zai/glm-5.3:high -p` in the card's worktree, tools read,bash,edit,write. +- Delegation override of stack AGENTS.md `agent` column: rows carry `pi-glm-5.3` (outside cron table so no auto-claim). +- Target branch: `next`. Cards branch from `origin/next`, squash-merge via PR. + +## Operational constraints (measured this session) + +- Main checkout at `/home/jwoltje/src/mosaic-stack` is a dirty diverged `main` (ahead 1139/behind 711) — NEVER touched. All work in `/home/jwoltje/src/mosaic-stack-worktrees/`. +- Disk: /home 187G free. /tmp only 8.7G — keep pnpm stores/node_modules under /home. +- `main` and `next` have DIVERGED; PRs target `next`. +- Identity: pin `GITEA_LOGIN=mosaicstack-jarvis` for all wrapper ops. Issue #1275 verified authored by @jarvis. +- `ci-queue-wait.sh` on this host is fail-open (board: fix #1032 not installed) — substitute SHA-status checks via `/commits/{sha}/status` and diff failing step names. +- CI on PRs runs `pull_request` pipelines (any branch) incl. ci-postgres service. Push CI runs on main only; publish runs on push/tag to next + manual. +- Wrapper gaps on this host per board (7 gaps; e.g. no pr-review-list, issue-assign broken, pr-merge makes no trailers): verify outcomes by reading back provider state, never trust rc alone. +- Publish pipeline currently: install → build → publish-npm/publish-next-npm (+image). No verify. CI steps: install, sanitization, upgrade-guard, typecheck, lint, format, test, ci-postgres. + +## Budget + +Soft cap 250K. Projected 190K across 10 cards. Track per-card used vs estimate in TASKS.md notes. + +## Progress log + +- 2026-08-16 23:52 — Issue #1275 created (@jarvis verified). +- 2026-08-16 23:5x — Bootstrap branch `docs/ri-050-mission-bootstrap` from origin/next@476db12b; PRD section + TASKS.md + this scratchpad written. RI-0-001 in-progress. + +## Wave 1 dispatched (2026-08-17 00:35) + +- RI-1-001 worker: pi glm-5.3:high, pid 2322125, worktree ri-1-001, log /var/tmp/ri-050/ri-1-001-run.log +- RI-2-001 worker: pi glm-5.3:high, pid 2322126, worktree ri-2-001, log /var/tmp/ri-050/ri-2-001-run.log +- Gotcha recorded: pi has no -f flag (that's pi-do.sh); pass brief as positional message. First launch died "Unknown option: -f" — relaunched. +- CI lane: PR #1276 (bootstrap) fails `test` at base like every next PR — fred's green #1270 unblocks (comms sent 2026-08-17T05:21Z, `comms/20260817T052148Z__from-jarvis__650fe8.md`). Merge gate for all RI PRs queues behind #1270. +- Live RI-N1 evidence posted to #1275 (comment 22915): pipeline 2439 publish-next-npm SUCCESS beside build-gateway FAILURE. + +--- + +# HANDOFF — RI-050 continuation (written 2026-08-17 ~08:45 UTC, jarvis/dragon-lin) + +You are taking over the alpha 0.0.50 release-integrity workstream in place. Everything you +need is on the remote. Read this whole file, then `docs/release-integrity/TASKS.md` (same +branch), then the PRD section (`docs/PRD.md` § Release Integrity Workstream, same branch). + +## Identity / mode + +- Orchestrator identity: `jarvis` (dragon-lin). You continue as the RI-050 orchestrator under + whatever identity Jason gives you — if you are NOT jarvis, say so in comms and PR bodies. +- Jason's standing directives for this mission: work happens on THIS repo (mosaicstack/stack), + PRs target `next` (NOT main), workers are local pi headless sessions on + `zai/glm-5.3:high`. Do not hand this to mos-claude. Do not borrow other seats' lanes. +- All wrapper ops: pin `GITEA_LOGIN=mosaicstack-jarvis` (issue #1275 was verified authored by + @jarvis; keep identity consistent or verify yours with issue-view and READ BACK user.login). +- CI substitution rule (this host's ci-queue-wait.sh is fail-open; fix #1032 not installed): + judge CI by SHA-status via `/api/v1/repos/mosaicstack/stack/commits/{sha}/status` or the + woodpecker API (`pipeline-status.sh -r mosaicstack/stack -n N -f json`), and DIFF THE + FAILING STEP NAMES rather than trusting rc. + +## Mission state at handoff + +Mission: alpha 0.0.50 release-integrity floor. Issue #1275 (open, has live-evidence comment). +Decisions SDLC-D-033..038 live in jarvis-brain +`docs/plans/2026-08-16_mosaic-stack-sdlc-protocol.md` (normative text also mirrored in the +PRD section on this branch, so this repo is self-sufficient). + +Base: `origin/next` @ 476db12b. NOTE: `main` and `next` have DIVERGED — never base on main. + +Branches (all pushed, all clean trees): + +- `docs/ri-050-mission-bootstrap` @ 5114faa2 → PR #1276 (open, mergeable) — bootstrap docs + + this scratchpad + TASKS.md DAG. STATUS: CI red on `test` only, which is the known lane-wide + failure (see blocker below); own prettier issue already fixed. +- `feat/ri-050-publish-gate` @ 0aa5ed35 → PR #1277 (open, mergeable) — RI-1-001 COMPLETE + (worker reported success, orchestrator review PASSED: verify step asserts CI_COMMIT_SHA == + git rev-parse HEAD then runs canonical `pnpm verify:release`; every publish/image step + depends_on verify directly, confirmed by parsing the DAG: publish-npm, publish-next-npm, + build-gateway/appservice/web all -> [build, verify]; invariant test + scripts/verify-release.test.mjs passes 7/7 locally with negative fixtures). CI: same known + lane-red `test` step only. +- `fix/ri-050-forge-fail-closed` @ 99b8f6ea → PR #1278 (open, mergeable) — RI-2-001 worker + reported success (typed `FORGE_*` capability errors, --simulate typed simulated everywhere, + vacuous true/echo gates replaced, closed ForgeOutcome set, 116 tests green incl. 16 new). + ORCHESTRATOR REVIEW NOT YET DONE — your first job. Review the diff + (1391 insertions across forge src), check the fail-closed paths and that simulated + results cannot satisfy any consumer, run `pnpm --filter @mosaicstack/forge test`. + +## The one blocker + +Every `next` PR pipeline is red on ONE assertion: +`packages/mosaic/framework/tools/fleet/test-start-agent-session.sh:103` ("host provides 'pi' +in the system path"). Pre-existing at base; affects PRs #1276/#1277/#1278 identically. +fred's PR #1270 ("unblocks every PR on next") is green and open — it is HIS to merge; do not +merge it yourself. jarvis sent comms (`comms/20260817T052148Z__from-jarvis__650fe8.md` in +jarvis-brain) asking merge timing; no reply yet as of handoff. Merge gates for ALL RI PRs +queue behind #1270 landing. Until then: review/develop freely, merge nothing that needs the +green gate (docs-only #1276 arguably could merge red-lane with Jason's explicit call — ask, +don't assume). + +## Remaining DAG (docs/release-integrity/TASKS.md is canonical) + +Wave 2 (next): RI-2-002 MACP fail-closed (brief pattern: mirror RI-2-001 for +packages/macp/src/gate-runner.ts — empty commands, stub executors, unimplemented CI-provider +gates fail closed; explicit simulate) and RI-4-001 PRD authority (one PRD service; +@mosaicstack/prdy docs/prdy authoritative via `mosaic mission --plan`; `mosaic prdy` routes +or becomes named Markdown adapter; mission<->PRD linkage persists — see PRD RI-N3). +Wave 3: RI-3-001 probe inventory (docs), RI-5-001 web stale-safety. +Wave 4: RI-1-002 negative-control tests, RI-3-002 TS evaluator absorbs shell probes. +Final: RI-V-001 evidence pack (real green next publish run post-gate + all cards verified). + +## Worker mechanics (measured, reuse) + +- Dispatch: create worktree `git -C /home/jwoltje/src/mosaic-stack worktree add +/home/jwoltje/src/mosaic-stack-worktrees/ -b origin/next`, write a brief to + /var/tmp/ri-050/, then run from INSIDE the worktree: + `pi -p --no-session --model zai/glm-5.3:high --tools read,bash,edit,write "$(cat brief.md)"` + (pi has NO -f flag — pass the brief as a positional message; first dispatch died on that). +- Briefs for 1-001/2-001 are at /var/tmp/ri-050/ on dragon-lin (may not survive; the + pattern is fully described above and in TASKS.md). +- Briefs must carry: worktree path, branch, base, requirements, known base-red list (so the + worker doesn't chase it), gates to run, PR creation command with GITEA_LOGIN pin, "do NOT + merge, do NOT touch docs/TASKS.md", and the JSON report format. +- Verify worker claims: read the PR, run their tests yourself, parse pipeline step names. + +## Do-not-touch + +- Main checkout at /home/jwoltje/src/mosaic-stack (dirty diverged main) — never touch. +- fred's open PRs (#1270 and others) — review evidence welcome, merging his is not yours. +- Other RI PRs' authors' lanes: #1277/#1278 are yours to gate and merge ONCE lane is green + and review is recorded. +- Never `--no-verify`; never bypass the wrapper-fails-closed rule (wrapper failure ⇒ + `blocked + report exact command + stop`). + +## Session-restore command sequence + +1. `git -C /home/jwoltje/src/mosaic-stack-worktrees/ri-050 fetch origin --prune` +2. Read this file + `docs/release-integrity/TASKS.md` + PRD section. +3. Check PR states (#1270, #1276, #1277, #1278) and lane CI (SHA-status per above). +4. Review RI-2-001 (PR #1278) if not yet done; then dispatch wave 2. + +— jarvis, 2026-08-17 + +--- + +# CONTINUATION — fargo (sb-it-1-dt) + +Orchestrator seat is now **fargo** on sb-it-1-dt (Jason, 2026-08-17): Claude seat, worktree discipline +per fred's ruling (`~/agent-work/`, create → work → commit → push → remove as one act; the +helper's `/src` refusal is a web1 convention, does not bind here). fred supports; lane rulings are +his. Workers remain local pi `zai/glm-5.3:high` + limited Claude per Jason. + +## 2026-08-17 — RI-2-001 independent review DONE + +- **PR #1278 APPROVED** (Gitea review 172, pinned to head 99b8f6ea). Executed evidence, not read-only: + forge suite 116/116 at head (matches PR claim), forge lint green, forge typecheck green after + building `@mosaicstack/macp` dist (TS2307 on bare `pnpm install --frozen-lockfile` is a + minimal-install build-order artifact — the macp import is type-only, vitest passes unbuilt; CI + installs build workspace deps, hence green there), **workspace typecheck 45/45 at head**, + consumer sweep: no external type consumers of RunManifest/StageStatus/ForgeTaskResult/ + TaskExecutor; only importer of the package is packages/mosaic via registerForgeCommand + (smoke test asserts registration/help only — cannot break). Digest gate (shaggy's) before==after + with both-arm reactivity controls. +- CI red on #1276/#1277/#1278: lane-wide `test` failure only + (test-start-agent-session.sh:103, fred's guard mis-wired; #1270 unwires it). Fred measured log + content: one real byte-identical failure per pipeline (2456/2457/2458); 13 of ~14 `FAIL` grep + hits are passing fail-loud test NAMES. **The red carries no information about the RI changes.** +- Non-blocking finding: README L141-143 + skills/mosaic-forge/SKILL.md document bare + `mosaic forge run`/`resume`, which now exits 1 FORGE_NO_EXECUTOR — fast-follow docs touch. +- **Identity incident, ruled on by fred:** review 172 recorded under shared host principal + mos-dt-0, not fargo. Mechanism (measured, wrapper source): pr-review.sh resolves its acting login + from the tea login list only; no fargo tea login on this host → silent host-default fallback; + MOSAIC_GIT_IDENTITY is only read in detect-platform.sh get_gitea_token's fallback arm, never + reached. Exact-id read-back verifies against the writing token, so it passed while attribution + was wrong — durable-provenance machinery proves the write, not the seat. Fred's ruling: review + 172 stands (substance/verdict/pin correct; label wrong); NO re-approval (one approval, + annotated, is the stronger record); fred posts the provenance correction under @fred with + --login fred-ms (hard-fail path); no fargo tea login ever (freeze + Jason's to authorize); + tooling gap filed by fred. Also explains (does not reopen) #1228's mos-dt-0 attribution. +- Merge gate: all RI PRs queue behind fred's green #1270 (Jason's call). + +## Next + +1. Wave 2 dispatch: RI-2-002 (MACP fail-closed, mirror RI-2-001 pattern for + packages/macp/src/gate-runner.ts) + RI-4-001 (PRD authority). Two parallel workers max. +2. Docs fast-follow (README + mosaic-forge skill) — fold into #1276 or a tiny docs card. +3. RI-V-001 evidence at the end. + +— fargo, 2026-08-17 + +--- + +# RESUMPTION + DAILY-HANDOFF PROTOCOL (Jason, 2026-08-17) + +Orchestrator seat is back with **jarvis** (dragon-lin). Expect daily handoff between jarvis +and fargo. Protocol (both seats, every handoff): + +1. **This file is the shared mission log.** Append a dated section per session: state + measured, actions taken, PR/review states, next actions. Never rewrite prior sections. +2. **TASKS.md stays current within one session** — status, PR number in notes, review + evidence. Stale rows are handoff debt. +3. **Cross-review rule (SDLC-D-011 in practice):** the reviewing seat must differ from the + producing seat. jarvis reviews fargo-dispatched PRs, fargo reviews jarvis-dispatched + PRs. Producers are always pi workers; dispatching seats verify before push; the other + seat records the Gitea review. +4. Handoff = append here + push + (optional) issue #1275 comment if a decision changed. + +## RESUMED — jarvis/dragon-lin, 2026-08-17 (afternoon) + +- Measured: next = 8199261c (#1270 merged — lane unblocked for new PRs). #1293/#1294 + (fargo, wave 2) CI-green, mergeable, no recorded reviews. #1276/#1277/#1278 still based + on 476db12b with stale red CI → need rebase onto 8199261c. #1278 review pinned to old + head 99b8f6ea by @mos-dt-0 (fargo's, mis-attributed per his note) — rebase will dismiss + it; re-approval must come from fargo/fred (author is @jarvis, cannot self-approve). +- Live evidence #2: push pipeline 2462 (the #1270 merge itself) ran publish-next-npm + SUCCESS beside build-gateway FAILURE again. +- Plan: rebase the three original branches; independently review #1293/#1294; merge order + once green+reviewed: #1276 (docs) → #1277 (publish gate) → #1278/#1293/#1294 (code). + After #1277 merges, watch the next push pipeline prove the verify gate live. +- fargo's non-RI PRs (#1291/#1296/#1297/#1281) stay strictly his lane. + +## jarvis session 2026-08-17 (evening) — reviews, rebases, merge plan + +- Rebased #1276/#1277/#1278 onto 8199261c (heads 59e2c460 / 46784c8d / 4917df1f); + invariant tests 7/7 and forge 116/116 re-run green at new heads. #1270 touched + test-enumeration-exclusions.txt + package.json, NOT ci.yml — no semantic overlap with + #1277's ci.yml changes (checked, was a real concern). +- Independent reviews recorded: #1293 APPROVED (review 173; macp 109/109; fail-closed paths + + aggregate state machine verified), #1294 APPROVED (review 174; prdy 20/20 + command + specs 9/9; single-writer + linkage persistence + labeled export + conflict-aware import + verified). Note: 19 unrelated mosaic suites fail on bare minimal install (known workspace + build-order artifact, documented by fargo) — not this change. +- Measured: `next` has NO branch protection (API: only main listed). Cross-seat review + discipline is protocol-enforced, not Gitea-enforced. Flagged to fargo for Jason: direct + pushes to next trigger ungated publishes; protection is Jason's call (#1231 adjacent). +- Merge order planned: #1276 (docs-only — no publish run) -> #1277 (first gated publish) + -> #1278 -> #1293 -> #1294. Sent fargo review requests with pinned head SHAs + (comms/20260818T011932Z__from-jarvis__a9c02b.md). Not merging #1293/#1294 before my three + clear fargo's review — order optimality beats speed; every pre-#1277 merge publishes ungated. +- CI on the three rebased heads: pending at time of this entry. diff --git a/eslint.config.mjs b/eslint.config.mjs index bcfe1995..acfed05f 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -34,6 +34,7 @@ export default tseslint.config( 'packages/storage/vitest.config.ts', 'packages/mosaic/vitest.config.ts', 'packages/mosaic/__tests__/*.ts', + 'packages/forge/__tests__/*.ts', 'tools/federation-harness/*.ts', ], }, diff --git a/package.json b/package.json index 602bb7ee..3ba7a059 100644 --- a/package.json +++ b/package.json @@ -9,6 +9,7 @@ "preflight": "node scripts/preflight.mjs", "clean:generated": "node scripts/clean-generated.mjs", "typecheck": "pnpm preflight && turbo run typecheck", + "verify:release": "node scripts/verify-release.mjs", "test:checkout": "node --test scripts/*.test.mjs", "test": "pnpm test:checkout && turbo run test && pnpm run test:installer", "test:installer": "bash tools/install-next-lane.test.sh", diff --git a/packages/forge/PLAN.md b/packages/forge/PLAN.md index f8b4bc81..3b918111 100644 --- a/packages/forge/PLAN.md +++ b/packages/forge/PLAN.md @@ -539,3 +539,43 @@ Not every brief needs full Board of Directors review. The classification system ### Backward compatibility Existing briefs without a `class` field are auto-classified. The default (no matching keywords) is `strategic`, so all existing runs get the full pipeline unless keywords trigger `technical`. + +--- + +## Fail-Closed Execution & Explicit Simulation (SDLC-D-035) + +**Added:** 2026-08-17 + +Forge fails closed when a required capability is missing. It never runs a +pipeline with a stub executor and reports success. + +### Normal mode (default) + +- No task executor wired → the CLI exits nonzero with the typed capability + error `FORGE_NO_EXECUTOR`. No run is created. +- A stage whose gate is approval-based (board approval, planning approvals, + remediation re-review, discovery/analysis attestations) records a typed + `waiting-for-authority` stage result and raises `FORGE_AUTHORITY_REQUIRED`. + It never passes vacuously. +- A stage whose gate requires an unwired provider (AI reviewer, CI pipeline) + records a typed `blocked` stage result and raises `FORGE_NO_REVIEWER` / + `FORGE_NO_CI_PIPELINE`. The synthetic echo-review approval in `06-review` + and all vacuous `true` gates were removed. + +### Explicit simulation (`--simulate`) + +Opts into stub/synthetic execution. Every stage result, every gate result, and +the run manifest carry the distinct typed status `simulated` (manifest also +records `mode: "simulated"`). `simulated` is a non-satisfying outcome: +`isSatisfyingOutcome()` and all completion/gate consumers treat only `passed` +as satisfying. The CLI exits 0 for a simulated run only because the caller +explicitly passed `--simulate`, and prints a loud SIMULATED banner. + +### Typed outcome model + +Every gate/task outcome is one of the closed set +`passed | failed | blocked | error | waiting-for-authority | simulated | +not-applicable`, with the reason recorded on the stage status and each gate +result in `manifest.json`. Missing implementations, missing gate evidence, +unknown stages, process errors, and timeouts map to fail-closed members — +never to `passed`. diff --git a/packages/forge/__tests__/fail-closed.test.ts b/packages/forge/__tests__/fail-closed.test.ts new file mode 100644 index 00000000..c707a3be --- /dev/null +++ b/packages/forge/__tests__/fail-closed.test.ts @@ -0,0 +1,319 @@ +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; + +import { generateBoardTasks } from '../src/board-tasks.js'; +import { STAGE_SPECS } from '../src/constants.js'; +import { ForgeCapabilityError } from '../src/errors.js'; +import { + evaluateStageGates, + gateLabel, + isCommandGate, + isSatisfyingOutcome, +} from '../src/outcomes.js'; +import { loadManifest, runPipeline } from '../src/pipeline-runner.js'; +import type { ForgeTask, ForgeTaskResult, TaskExecutor } from '../src/types.js'; + +/** + * Mock real executor that returns typed results. + * + * Command gates are "verified" by the mock so normal-mode runs can pass + * mechanically gated stages; authority/provider gates are never reported + * because they have no mechanical implementation. + */ +function createTypedExecutor(options?: { + failStage?: string; + gateOutcomes?: Record; +}): TaskExecutor & { submittedTasks: ForgeTask[] } { + const submittedTasks: ForgeTask[] = []; + return { + submittedTasks, + async submitTask(task: ForgeTask) { + submittedTasks.push(task); + }, + async waitForCompletion(taskId: string): Promise { + const task = submittedTasks.find((t) => t.id === taskId); + const stageName = task?.metadata?.['stageName'] as string | undefined; + + if (options?.failStage && stageName === options.failStage) { + return { + task_id: taskId, + outcome: 'failed', + reason: 'mock task failure', + completed_at: new Date().toISOString(), + exit_code: 1, + gate_results: [], + }; + } + + const gateResults = (task?.qualityGates ?? []) + .filter((gate) => isCommandGate(gate)) + .map((gate) => { + const label = gateLabel(gate); + const outcome = options?.gateOutcomes?.[label] ?? 'passed'; + return { + gate: label, + outcome, + reason: outcome === 'passed' ? 'mock verified' : `mock gate outcome: ${outcome}`, + }; + }); + + return { + task_id: taskId, + outcome: 'passed', + reason: 'mock verified', + completed_at: new Date().toISOString(), + exit_code: 0, + gate_results: gateResults, + }; + }, + async getTaskStatus() { + return 'completed' as const; + }, + }; +} + +describe('fail-closed: no executor wired', () => { + let tmpDir: string; + let briefPath: string; + + beforeEach(() => { + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'forge-failclosed-')); + briefPath = path.join(tmpDir, 'brief.md'); + fs.writeFileSync(briefPath, '# Fix bug\n\nA bugfix for lint cleanup.'); + }); + + afterEach(() => { + fs.rmSync(tmpDir, { recursive: true, force: true }); + }); + + it('throws a typed FORGE_NO_EXECUTOR capability error without --simulate', async () => { + await expect( + runPipeline(briefPath, tmpDir, { + // no executor, no simulate — must fail closed, never run with a stub + stages: ['00-intake'], + }), + ).rejects.toMatchObject({ + name: 'ForgeCapabilityError', + code: 'FORGE_NO_EXECUTOR', + capability: 'task-executor', + }); + }); + + it('does not create a run directory when failing closed on a missing executor', async () => { + try { + await runPipeline(briefPath, tmpDir, { stages: ['00-intake'] }); + } catch { + // expected + } + expect(fs.existsSync(path.join(tmpDir, '.forge', 'runs'))).toBe(false); + }); + + it('completes with every result typed simulated when simulate is set', async () => { + const result = await runPipeline(briefPath, tmpDir, { + simulate: true, + stages: ['00-intake', '00b-discovery', '02-planning-1', '06-review'], + }); + + expect(result.manifest.mode).toBe('simulated'); + expect(result.manifest.status).toBe('simulated'); + + for (const stage of result.stages) { + const stageStatus = result.manifest.stages[stage]; + expect(stageStatus?.status, `stage ${stage}`).toBe('simulated'); + expect(stageStatus?.status, `stage ${stage}`).not.toBe('passed'); + expect(stageStatus?.reason, `stage ${stage}`).toBeTruthy(); + for (const gateResult of stageStatus?.gateResults ?? []) { + expect(gateResult.outcome, `gate ${gateResult.gate} of ${stage}`).toBe('simulated'); + expect(gateResult.outcome, `gate ${gateResult.gate} of ${stage}`).not.toBe('passed'); + } + } + + // The persisted manifest agrees. + const persisted = loadManifest(result.runDir); + expect(persisted.mode).toBe('simulated'); + expect(persisted.status).toBe('simulated'); + expect(persisted.stages['02-planning-1']?.status).toBe('simulated'); + }); +}); + +describe('fail-closed: typed outcome model', () => { + it('only passed satisfies the gate/dependency predicate', () => { + expect(isSatisfyingOutcome('passed')).toBe(true); + expect(isSatisfyingOutcome('failed')).toBe(false); + expect(isSatisfyingOutcome('blocked')).toBe(false); + expect(isSatisfyingOutcome('error')).toBe(false); + expect(isSatisfyingOutcome('waiting-for-authority')).toBe(false); + expect(isSatisfyingOutcome('simulated')).toBe(false); + expect(isSatisfyingOutcome('not-applicable')).toBe(false); + }); + + it('a simulated gate result cannot satisfy the stage gate evaluation', () => { + const evaluation = evaluateStageGates('05-coding', STAGE_SPECS['05-coding']!.qualityGates, { + task_id: 'FORGE-x-05', + outcome: 'passed', + reason: 'executor claims success', + completed_at: new Date().toISOString(), + exit_code: 0, + gate_results: [{ gate: 'pnpm lint', outcome: 'simulated', reason: 'simulated gate' }], + }); + expect(isSatisfyingOutcome(evaluation.outcome)).toBe(false); + expect(evaluation.outcome).toBe('error'); + }); + + it('a simulated task outcome cannot satisfy evaluation in normal mode', () => { + const evaluation = evaluateStageGates('00-intake', [], { + task_id: 'FORGE-x-00', + outcome: 'simulated', + reason: 'executor reported simulated', + completed_at: new Date().toISOString(), + exit_code: 0, + gate_results: [], + }); + expect(isSatisfyingOutcome(evaluation.outcome)).toBe(false); + }); + + it('a missing gate result blocks the stage instead of passing vacuously', () => { + const evaluation = evaluateStageGates('05-coding', STAGE_SPECS['05-coding']!.qualityGates, { + task_id: 'FORGE-x-05', + outcome: 'passed', + reason: 'executor claims success', + completed_at: new Date().toISOString(), + exit_code: 0, + gate_results: [], + }); + expect(evaluation.outcome).toBe('blocked'); + }); +}); + +describe('fail-closed: authority and provider gates', () => { + let tmpDir: string; + let briefPath: string; + + beforeEach(() => { + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'forge-authority-')); + briefPath = path.join(tmpDir, 'brief.md'); + fs.writeFileSync(briefPath, '# Fix bug\n\nA bugfix for lint cleanup.'); + }); + + afterEach(() => { + fs.rmSync(tmpDir, { recursive: true, force: true }); + }); + + it.each(['02-planning-1', '03-planning-2', '04-planning-3', '07-remediate'])( + 'planning/remediation stage %s yields waiting-for-authority (not passed) in normal mode', + async (stage) => { + const executor = createTypedExecutor(); + let runDir: string | undefined; + + try { + await runPipeline(briefPath, tmpDir, { + executor, + stages: [stage as string], + }); + expect.unreachable('runPipeline should have failed closed'); + } catch (err) { + expect(err).toBeInstanceOf(ForgeCapabilityError); + expect((err as ForgeCapabilityError).code).toBe('FORGE_AUTHORITY_REQUIRED'); + runDir = path.join(tmpDir, '.forge', 'runs'); + } + + const runIds = fs.readdirSync(runDir!); + expect(runIds).toHaveLength(1); + const manifest = loadManifest(path.join(runDir!, runIds[0]!)); + expect(manifest.stages[stage]?.status).toBe('waiting-for-authority'); + expect(manifest.stages[stage]?.status).not.toBe('passed'); + expect(manifest.status).toBe('waiting-for-authority'); + }, + ); + + it('review stage fails closed with a typed FORGE_NO_REVIEWER error in normal mode', async () => { + const executor = createTypedExecutor(); + + try { + await runPipeline(briefPath, tmpDir, { + executor, + stages: ['06-review'], + }); + expect.unreachable('runPipeline should have failed closed'); + } catch (err) { + expect(err).toBeInstanceOf(ForgeCapabilityError); + expect((err as ForgeCapabilityError).code).toBe('FORGE_NO_REVIEWER'); + expect((err as ForgeCapabilityError).capability).toBe('reviewer'); + } + + const runsDir = path.join(tmpDir, '.forge', 'runs'); + const runIds = fs.readdirSync(runsDir); + const manifest = loadManifest(path.join(runsDir, runIds[0]!)); + expect(manifest.stages['06-review']?.status).toBe('blocked'); + expect(manifest.stages['06-review']?.status).not.toBe('passed'); + expect(manifest.status).toBe('failed'); + }); + + it('review stage produces simulated results under --simulate', async () => { + const result = await runPipeline(briefPath, tmpDir, { + simulate: true, + stages: ['06-review'], + }); + + expect(result.manifest.mode).toBe('simulated'); + expect(result.manifest.stages['06-review']?.status).toBe('simulated'); + for (const gateResult of result.manifest.stages['06-review']?.gateResults ?? []) { + expect(gateResult.outcome).toBe('simulated'); + } + }); + + it('deploy stage fails closed without a wired ci-pipeline provider in normal mode', async () => { + const executor = createTypedExecutor(); + + await expect( + runPipeline(briefPath, tmpDir, { + executor, + stages: ['09-deploy'], + }), + ).rejects.toMatchObject({ + name: 'ForgeCapabilityError', + code: 'FORGE_NO_CI_PIPELINE', + }); + }); +}); + +describe('fail-closed: no vacuous gate commands remain', () => { + it('stage constants contain no echo/synthetic-approval, vacuous true, or empty gate commands', () => { + for (const [stageName, spec] of Object.entries(STAGE_SPECS)) { + for (const gate of spec.qualityGates) { + const serialized = JSON.stringify(gate); + // The echo-review synthetic approval must be gone. + expect(serialized, `stage ${stageName} gate ${serialized}`).not.toContain('echo'); + expect(serialized, `stage ${stageName} gate ${serialized}`).not.toMatch(/"verdict"\s*:/); + expect(serialized, `stage ${stageName} gate ${serialized}`).not.toMatch( + /"summary"\s*:\s*"review-pass"/, + ); + // No vacuous literal `true` gate. + expect(gate, `stage ${stageName}`).not.toBe('true'); + // Command gates must carry a real, non-empty command. + if (isCommandGate(gate)) { + const command = typeof gate === 'string' ? gate : gate.command; + expect(command.trim().length, `stage ${stageName} gate ${serialized}`).toBeGreaterThan(0); + } + } + } + }); + + it('board tasks contain no vacuous true gates', () => { + const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'forge-board-gates-')); + try { + const tasks = generateBoardTasks('# Brief', [], tmpDir, 'BOARD-TEST'); + for (const task of tasks) { + for (const gate of task.qualityGates) { + expect(gate, `task ${task.id}`).not.toBe('true'); + const serialized = JSON.stringify(gate); + expect(serialized, `task ${task.id} gate ${serialized}`).not.toContain('echo'); + } + } + } finally { + fs.rmSync(tmpDir, { recursive: true, force: true }); + } + }); +}); diff --git a/packages/forge/__tests__/pipeline-runner.test.ts b/packages/forge/__tests__/pipeline-runner.test.ts index 398b8997..0baeeed3 100644 --- a/packages/forge/__tests__/pipeline-runner.test.ts +++ b/packages/forge/__tests__/pipeline-runner.test.ts @@ -12,10 +12,10 @@ import { resumePipeline, getPipelineStatus, } from '../src/pipeline-runner.js'; -import type { ForgeTask, RunManifest, TaskExecutor } from '../src/types.js'; -import type { TaskResult } from '@mosaicstack/macp'; +import type { ForgeTask, ForgeTaskResult, RunManifest, TaskExecutor } from '../src/types.js'; +import { gateLabel, isCommandGate } from '../src/outcomes.js'; -/** Mock TaskExecutor that records submitted tasks and returns success. */ +/** Mock TaskExecutor that records submitted tasks and returns typed results. */ function createMockExecutor(options?: { failStage?: string; }): TaskExecutor & { submittedTasks: ForgeTask[] } { @@ -25,7 +25,7 @@ function createMockExecutor(options?: { async submitTask(task: ForgeTask) { submittedTasks.push(task); }, - async waitForCompletion(taskId: string): Promise { + async waitForCompletion(taskId: string): Promise { const failStage = options?.failStage; const task = submittedTasks.find((t) => t.id === taskId); const stageName = task?.metadata?.['stageName'] as string | undefined; @@ -33,7 +33,8 @@ function createMockExecutor(options?: { if (failStage && stageName === failStage) { return { task_id: taskId, - status: 'failed', + outcome: 'failed', + reason: 'mock task failure', completed_at: new Date().toISOString(), exit_code: 1, gate_results: [], @@ -41,10 +42,17 @@ function createMockExecutor(options?: { } return { task_id: taskId, - status: 'completed', + outcome: 'passed', + reason: 'mock verified', completed_at: new Date().toISOString(), exit_code: 0, - gate_results: [], + gate_results: (task?.qualityGates ?? []) + .filter((gate) => isCommandGate(gate)) + .map((gate) => ({ + gate: gateLabel(gate), + outcome: 'passed' as const, + reason: 'mock verified', + })), }; }, async getTaskStatus() { @@ -156,12 +164,13 @@ describe('runPipeline', () => { const executor = createMockExecutor(); const result = await runPipeline(briefPath, tmpDir, { executor, - stages: ['00-intake', '00b-discovery'], + stages: ['00-intake', '05-coding'], }); expect(result.runId).toMatch(/^\d{8}-\d{6}$/); - expect(result.stages).toEqual(['00-intake', '00b-discovery']); + expect(result.stages).toEqual(['00-intake', '05-coding']); expect(result.manifest.status).toBe('completed'); + expect(result.manifest.mode).toBe('normal'); expect(executor.submittedTasks).toHaveLength(2); }); @@ -180,12 +189,17 @@ describe('runPipeline', () => { const executor = createMockExecutor(); const result = await runPipeline(briefPath, tmpDir, { executor, - stages: ['00-intake', '00b-discovery'], + stages: ['00-intake', '05-coding'], }); const manifest = loadManifest(result.runDir); expect(manifest.stages['00-intake']?.status).toBe('passed'); - expect(manifest.stages['00b-discovery']?.status).toBe('passed'); + expect(manifest.stages['05-coding']?.status).toBe('passed'); + expect(manifest.stages['05-coding']?.gateResults?.map((g) => g.outcome)).toEqual([ + 'passed', + 'passed', + 'passed', + ]); }); it('respects CLI class override', async () => { @@ -215,7 +229,7 @@ describe('runPipeline', () => { const executor = createMockExecutor(); await runPipeline(briefPath, tmpDir, { executor, - stages: ['00-intake', '00b-discovery', '02-planning-1'], + stages: ['00-intake', '05-coding', '08-test'], }); expect(executor.submittedTasks[0]!.dependsOn).toBeUndefined(); @@ -224,14 +238,14 @@ describe('runPipeline', () => { }); it('handles stage failure', async () => { - const executor = createMockExecutor({ failStage: '00b-discovery' }); + const executor = createMockExecutor({ failStage: '05-coding' }); await expect( runPipeline(briefPath, tmpDir, { executor, - stages: ['00-intake', '00b-discovery'], + stages: ['00-intake', '05-coding'], }), - ).rejects.toThrow('Stage 00b-discovery failed'); + ).rejects.toThrow('Stage 05-coding failed'); }); it('marks manifest as failed on stage failure', async () => { @@ -270,30 +284,143 @@ describe('resumePipeline', () => { fs.rmSync(tmpDir, { recursive: true, force: true }); }); - it('resumes from first incomplete stage', async () => { - // First run fails on discovery - const executor1 = createMockExecutor({ failStage: '00b-discovery' }); - let runDir: string; + it('resumes from first incomplete stage and fails closed at the next provider gate', async () => { + // Simulate a run whose authority stages were approved out-of-band + // (recorded as passed) and whose coding stage failed mechanically. + const runId = '20260101-000000'; + const runDir = path.join(tmpDir, '.forge', 'runs', runId); + fs.mkdirSync(runDir, { recursive: true }); + const passed = { status: 'passed' as const, startedAt: '2026-01-01T00:00:00Z' }; + saveManifest(runDir, { + runId, + brief: briefPath, + codebase: tmpDir, + briefClass: 'hotfix', + classSource: 'frontmatter', + forceBoard: false, + mode: 'normal', + createdAt: '2026-01-01T00:00:00Z', + updatedAt: '2026-01-01T00:00:00Z', + currentStage: '05-coding', + status: 'failed', + stages: { + '00-intake': passed, + '00b-discovery': passed, + '02-planning-1': passed, + '03-planning-2': passed, + '04-planning-3': passed, + '05-coding': { status: 'failed', reason: 'gate failed' }, + }, + }); - try { - await runPipeline(briefPath, tmpDir, { - executor: executor1, - stages: ['00-intake', '00b-discovery', '02-planning-1'], - }); - } catch { - // expected + // Resume re-runs 05-coding (the first non-passed stage), then fails + // closed at 06-review because no reviewer provider is wired. + const executor = createMockExecutor(); + await expect(resumePipeline(runDir, executor)).rejects.toMatchObject({ + name: 'ForgeCapabilityError', + code: 'FORGE_NO_REVIEWER', + }); + + const manifest = loadManifest(runDir); + expect(manifest.stages['05-coding']?.status).toBe('passed'); + expect(manifest.stages['06-review']?.status).toBe('blocked'); + expect(manifest.status).toBe('failed'); + }); + + it('resumes to completion as simulated under explicit simulate', async () => { + const runId = '20260101-000003'; + const runDir = path.join(tmpDir, '.forge', 'runs', runId); + fs.mkdirSync(runDir, { recursive: true }); + const passed = { status: 'passed' as const, startedAt: '2026-01-01T00:00:00Z' }; + saveManifest(runDir, { + runId, + brief: briefPath, + codebase: tmpDir, + briefClass: 'hotfix', + classSource: 'frontmatter', + forceBoard: false, + mode: 'normal', + createdAt: '2026-01-01T00:00:00Z', + updatedAt: '2026-01-01T00:00:00Z', + currentStage: '05-coding', + status: 'failed', + stages: { + '00-intake': passed, + '00b-discovery': passed, + '02-planning-1': passed, + '03-planning-2': passed, + '04-planning-3': passed, + '05-coding': { status: 'failed', reason: 'gate failed' }, + }, + }); + + const result = await resumePipeline(runDir, undefined, { simulate: true }); + + expect(result.manifest.status).toBe('simulated'); + expect(result.manifest.mode).toBe('simulated'); + expect(result.stages[0]).toBe('05-coding'); + for (const stage of result.stages) { + expect(result.manifest.stages[stage]?.status).toBe('simulated'); } + }); - const runsDir = path.join(tmpDir, '.forge', 'runs'); - runDir = path.join(runsDir, fs.readdirSync(runsDir)[0]!); + it('fails closed on resume when the next stage needs authority sign-off', async () => { + const runId = '20260101-000001'; + const runDir = path.join(tmpDir, '.forge', 'runs', runId); + fs.mkdirSync(runDir, { recursive: true }); + saveManifest(runDir, { + runId, + brief: briefPath, + codebase: tmpDir, + briefClass: 'hotfix', + classSource: 'frontmatter', + forceBoard: false, + mode: 'normal', + createdAt: '2026-01-01T00:00:00Z', + updatedAt: '2026-01-01T00:00:00Z', + currentStage: '00-intake', + status: 'in_progress', + stages: { + '00-intake': { status: 'passed' }, + }, + }); - // Resume should pick up from 00b-discovery - const executor2 = createMockExecutor(); - const result = await resumePipeline(runDir, executor2); + const executor = createMockExecutor(); + await expect(resumePipeline(runDir, executor)).rejects.toMatchObject({ + name: 'ForgeCapabilityError', + code: 'FORGE_AUTHORITY_REQUIRED', + }); - expect(result.manifest.status).toBe('completed'); - // Should have re-run from 00b-discovery onward - expect(result.stages[0]).toBe('00b-discovery'); + const manifest = loadManifest(runDir); + expect(manifest.stages['00b-discovery']?.status).toBe('waiting-for-authority'); + expect(manifest.status).toBe('waiting-for-authority'); + }); + + it('fails closed on resume without an executor or --simulate', async () => { + const runId = '20260101-000002'; + const runDir = path.join(tmpDir, '.forge', 'runs', runId); + fs.mkdirSync(runDir, { recursive: true }); + saveManifest(runDir, { + runId, + brief: briefPath, + codebase: tmpDir, + briefClass: 'hotfix', + classSource: 'frontmatter', + forceBoard: false, + mode: 'normal', + createdAt: '2026-01-01T00:00:00Z', + updatedAt: '2026-01-01T00:00:00Z', + currentStage: '00-intake', + status: 'in_progress', + stages: { + '00-intake': { status: 'passed' }, + }, + }); + + await expect(resumePipeline(runDir)).rejects.toMatchObject({ + name: 'ForgeCapabilityError', + code: 'FORGE_NO_EXECUTOR', + }); }); }); diff --git a/packages/forge/src/board-tasks.ts b/packages/forge/src/board-tasks.ts index 701ec2b3..6112389c 100644 --- a/packages/forge/src/board-tasks.ts +++ b/packages/forge/src/board-tasks.ts @@ -95,7 +95,14 @@ export function generateBoardTasks( briefPath, resultPath: resultRelPath, timeoutSeconds: 120, - qualityGates: ['true'], + qualityGates: [ + { + kind: 'authority', + capability: 'board-approval', + reason: + 'persona evaluation is judged by board synthesis (authority review); no mechanical gate exists', + }, + ], metadata: { personaName: persona.name, personaSlug: persona.slug, @@ -121,7 +128,13 @@ export function generateBoardTasks( timeoutSeconds: 120, dependsOn: personaTaskIds, dependsOnPolicy: 'all_terminal', - qualityGates: ['true'], + qualityGates: [ + { + kind: 'authority', + capability: 'board-approval', + reason: 'board synthesis is an authority decision; no mechanical gate exists', + }, + ], metadata: { resultOutputPath: synthesisResult, inputResultPaths: personaResultPaths, diff --git a/packages/forge/src/cli.spec.ts b/packages/forge/src/cli.spec.ts index d2fe881e..ff7eb367 100644 --- a/packages/forge/src/cli.spec.ts +++ b/packages/forge/src/cli.spec.ts @@ -1,7 +1,11 @@ +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; import { Command } from 'commander'; -import { describe, expect, it } from 'vitest'; +import { describe, expect, it, vi, beforeEach, afterEach } from 'vitest'; import { registerForgeCommand } from './cli.js'; +import { loadManifest } from './pipeline-runner.js'; describe('registerForgeCommand', () => { it('registers a "forge" command on the parent program', () => { @@ -55,3 +59,94 @@ describe('registerForgeCommand', () => { }).not.toThrow(); }); }); + +describe('forge run fail-closed behavior (SDLC-D-035)', () => { + let tmpDir: string; + let briefPath: string; + let errSpy: ReturnType; + let logSpy: ReturnType; + let prevExitCode: string | number | null | undefined; + + const parse = (args: string[]) => { + const program = new Command(); + registerForgeCommand(program); + return program.parseAsync(['forge', ...args], { from: 'user' }); + }; + + beforeEach(() => { + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'forge-cli-failclosed-')); + briefPath = path.join(tmpDir, 'brief.md'); + fs.writeFileSync(briefPath, '# Fix bug\n\nA bugfix for lint cleanup.'); + errSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); + logSpy = vi.spyOn(console, 'log').mockImplementation(() => {}); + prevExitCode = process.exitCode; + }); + + afterEach(() => { + errSpy.mockRestore(); + logSpy.mockRestore(); + process.exitCode = prevExitCode; + fs.rmSync(tmpDir, { recursive: true, force: true }); + }); + + it('exits nonzero with a typed FORGE_NO_EXECUTOR error when no executor is wired and --simulate is absent', async () => { + await parse(['run', '--brief', briefPath, '--codebase', tmpDir]); + + expect(process.exitCode).toBe(1); + const errText = errSpy.mock.calls.map((c) => c.join(' ')).join('\n'); + expect(errText).toContain('FORGE_NO_EXECUTOR'); + // It must never run the pipeline with a stub and report success. + expect(fs.existsSync(path.join(tmpDir, '.forge', 'runs'))).toBe(false); + }); + + it('completes with typed simulated results and exit 0 under explicit --simulate', async () => { + await parse(['run', '--brief', briefPath, '--codebase', tmpDir, '--simulate']); + + expect(process.exitCode).toBeUndefined(); + + // Loud simulated-mode summary. + const logText = logSpy.mock.calls.map((c) => c.join(' ')).join('\n'); + expect(logText).toContain('SIMULATED'); + + // Manifest records the mode and simulated per-result statuses. + const runsDir = path.join(tmpDir, '.forge', 'runs'); + const runIds = fs.readdirSync(runsDir); + expect(runIds).toHaveLength(1); + const manifest = loadManifest(path.join(runsDir, runIds[0]!)); + expect(manifest.mode).toBe('simulated'); + expect(manifest.status).toBe('simulated'); + for (const stageStatus of Object.values(manifest.stages)) { + expect(stageStatus?.status).toBe('simulated'); + for (const gateResult of stageStatus?.gateResults ?? []) { + expect(gateResult.outcome).toBe('simulated'); + } + } + }); + + it('resume exits nonzero with a typed FORGE_NO_EXECUTOR error without --simulate', async () => { + const runDir = path.join(tmpDir, '.forge', 'runs', '20260101-000000'); + fs.mkdirSync(runDir, { recursive: true }); + fs.writeFileSync( + path.join(runDir, 'manifest.json'), + JSON.stringify({ + runId: '20260101-000000', + brief: briefPath, + codebase: tmpDir, + briefClass: 'hotfix', + classSource: 'frontmatter', + forceBoard: false, + createdAt: '2026-01-01T00:00:00Z', + updatedAt: '2026-01-01T00:00:00Z', + currentStage: '00-intake', + status: 'in_progress', + stages: { '00-intake': { status: 'passed' } }, + }), + ); + + await parse(['resume', '20260101-000000', '--project', tmpDir]); + + expect(process.exitCode).toBe(1); + const errText = errSpy.mock.calls.map((c) => c.join(' ')).join('\n'); + expect(errText).toContain('FORGE_NO_EXECUTOR'); + }); +}); diff --git a/packages/forge/src/cli.ts b/packages/forge/src/cli.ts index 618150a8..175df78b 100644 --- a/packages/forge/src/cli.ts +++ b/packages/forge/src/cli.ts @@ -5,37 +5,47 @@ import type { Command } from 'commander'; import { classifyBrief } from './brief-classifier.js'; import { STAGE_LABELS, STAGE_SEQUENCE } from './constants.js'; +import { ForgeCapabilityError } from './errors.js'; import { getEffectivePersonas, loadBoardPersonas } from './persona-loader.js'; import { generateRunId, getPipelineStatus, loadManifest, runPipeline } from './pipeline-runner.js'; -import type { PipelineOptions, RunManifest, TaskExecutor } from './types.js'; - -// --------------------------------------------------------------------------- -// Stub executor — used when no real executor is wired at CLI invocation time. -// --------------------------------------------------------------------------- - -const stubExecutor: TaskExecutor = { - async submitTask(task) { - console.log(` [forge] stage submitted: ${task.id} (${task.title})`); - }, - async waitForCompletion(taskId, _timeoutMs) { - console.log(` [forge] stage complete: ${taskId}`); - return { - task_id: taskId, - status: 'completed' as const, - completed_at: new Date().toISOString(), - exit_code: 0, - gate_results: [], - }; - }, - async getTaskStatus(_taskId) { - return 'completed' as const; - }, -}; +import { createSimulatedExecutor } from './simulated-executor.js'; +import type { PipelineOptions, RunManifest, RunMode } from './types.js'; // --------------------------------------------------------------------------- // Helpers // --------------------------------------------------------------------------- +/** Resolve a run's effective mode, defaulting legacy manifests to normal. */ +function runModeOf(manifest: RunManifest): RunMode { + return manifest.mode ?? 'normal'; +} + +/** Print a loud banner so a simulated run can never be misread as verified. */ +function printSimulatedBanner(): void { + console.log(''); + console.log('[forge] ==============================================================='); + console.log('[forge] MODE: SIMULATED — no stage or gate was really executed.'); + console.log('[forge] All results are synthetic and MUST NOT be read as verified'); + console.log('[forge] success. Wire a real executor/providers and re-run to verify.'); + console.log('[forge] ==============================================================='); +} + +/** Print a typed error line for fail-closed capability errors. */ +function printCapabilityError(err: ForgeCapabilityError): void { + console.error(`[forge] error ${err.code}: ${err.message}`); + console.error(`[forge] missing capability: ${err.capability}`); +} + +/** Handle a pipeline error uniformly: typed capability errors get their code. */ +function handlePipelineError(err: unknown): void { + if (err instanceof ForgeCapabilityError) { + printCapabilityError(err); + } else { + console.error(`[forge] pipeline failed: ${err instanceof Error ? err.message : String(err)}`); + } + process.exitCode = 1; +} + function formatDuration(startedAt?: string, completedAt?: string): string { if (!startedAt || !completedAt) return '-'; const ms = new Date(completedAt).getTime() - new Date(startedAt).getTime(); @@ -44,19 +54,24 @@ function formatDuration(startedAt?: string, completedAt?: string): string { } function printManifestTable(manifest: RunManifest): void { + const mode = runModeOf(manifest); console.log(`\nRun ID : ${manifest.runId}`); console.log(`Status : ${manifest.status}`); + console.log(`Mode : ${mode}`); + if (mode === 'simulated') { + console.log('WARNING: SIMULATED RUN — results are synthetic, not verified success.'); + } console.log(`Brief : ${manifest.brief}`); console.log(`Class : ${manifest.briefClass} (${manifest.classSource})`); console.log(`Updated: ${manifest.updatedAt}`); console.log(''); - console.log('Stage'.padEnd(22) + 'Status'.padEnd(14) + 'Duration'); - console.log('-'.repeat(50)); + console.log('Stage'.padEnd(22) + 'Status'.padEnd(24) + 'Duration'); + console.log('-'.repeat(60)); for (const stage of STAGE_SEQUENCE) { const s = manifest.stages[stage]; if (!s) continue; const label = (STAGE_LABELS[stage] ?? stage).padEnd(22); - const status = s.status.padEnd(14); + const status = s.status.padEnd(24); const dur = formatDuration(s.startedAt, s.completedAt); console.log(`${label}${status}${dur}`); } @@ -90,23 +105,58 @@ function listRecentRuns(projectRoot?: string): void { } console.log('\nRecent runs:'); - console.log('Run ID'.padEnd(22) + 'Status'.padEnd(14) + 'Brief'); - console.log('-'.repeat(70)); + console.log('Run ID'.padEnd(22) + 'Status'.padEnd(24) + 'Mode'.padEnd(12) + 'Brief'); + console.log('-'.repeat(80)); for (const runId of entries) { const runDir = path.join(runsDir, runId); try { const manifest = loadManifest(runDir); - const status = manifest.status.padEnd(14); + const status = manifest.status.padEnd(24); + const mode = runModeOf(manifest).padEnd(12); const brief = path.basename(manifest.brief); - console.log(`${runId.padEnd(22)}${status}${brief}`); + console.log(`${runId.padEnd(22)}${status}${mode}${brief}`); } catch { - console.log(`${runId.padEnd(22)}${'(unreadable)'.padEnd(14)}`); + console.log(`${runId.padEnd(22)}${'(unreadable)'.padEnd(24)}`); } } console.log(''); } +/** + * Apply the exit-code policy for a finished pipeline run (SDLC-D-035): + * + * - exit 0 only for a verified `completed` normal run, or for an overall + * `simulated` run when the caller explicitly passed --simulate; + * - anything else exits nonzero so it can never be read as success. + */ +function applyRunExitPolicy(result: { manifest: RunManifest; runDir: string }, simulate: boolean) { + const { manifest } = result; + + if (runModeOf(manifest) === 'simulated') { + if (!simulate || manifest.status !== 'simulated') { + console.error( + '[forge] error FORGE_MODE_MISMATCH: run reports simulated results without an explicit, ' + + 'consistent --simulate request; refusing to report success.', + ); + process.exitCode = 1; + return; + } + printSimulatedBanner(); + console.log(`[forge] run directory: ${result.runDir}`); + return; // exit 0 — the caller explicitly opted into simulation + } + + if (manifest.status !== 'completed') { + console.error(`[forge] run did not complete: terminal status '${manifest.status}'`); + process.exitCode = 1; + return; + } + + console.log(`[forge] pipeline complete (mode: normal): ${manifest.runId}`); + console.log(`[forge] run directory: ${result.runDir}`); +} + // --------------------------------------------------------------------------- // Register function // --------------------------------------------------------------------------- @@ -129,6 +179,11 @@ export function registerForgeCommand(parent: Command): void { .option('--config ', 'Path to forge config file (.forge/config.yaml)') .option('--codebase ', 'Codebase root to pass to the pipeline', process.cwd()) .option('--dry-run', 'Print planned stages without executing', false) + .option( + '--simulate', + 'Simulate execution without real providers (every result is typed simulated, never verified)', + false, + ) .action( async (opts: { brief: string; @@ -137,6 +192,7 @@ export function registerForgeCommand(parent: Command): void { config?: string; codebase: string; dryRun: boolean; + simulate: boolean; }) => { const briefPath = path.resolve(opts.brief); @@ -149,14 +205,22 @@ export function registerForgeCommand(parent: Command): void { const briefContent = fs.readFileSync(briefPath, 'utf-8'); const briefClass = classifyBrief(briefContent); const projectRoot = opts.codebase; + // A real executor is never wired at CLI invocation time today, so the + // only executor we may construct is the explicitly-requested simulated + // one. Normal mode fails closed with FORGE_NO_EXECUTOR. + const executor = opts.simulate ? createSimulatedExecutor() : undefined; if (opts.resume) { const runId = opts.runId ?? generateRunId(); const runDir = resolveRunDir(runId, projectRoot); console.log(`[forge] resuming run: ${runId}`); - const { resumePipeline } = await import('./pipeline-runner.js'); - const result = await resumePipeline(runDir, stubExecutor); - console.log(`[forge] pipeline complete: ${result.runId}`); + try { + const { resumePipeline } = await import('./pipeline-runner.js'); + const result = await resumePipeline(runDir, executor, { simulate: opts.simulate }); + applyRunExitPolicy(result, opts.simulate); + } catch (err) { + handlePipelineError(err); + } return; } @@ -164,7 +228,8 @@ export function registerForgeCommand(parent: Command): void { briefClass, codebase: projectRoot, dryRun: opts.dryRun, - executor: stubExecutor, + executor, + simulate: opts.simulate, }; if (opts.dryRun) { @@ -180,16 +245,15 @@ export function registerForgeCommand(parent: Command): void { console.log(`[forge] starting pipeline for brief: ${briefPath}`); console.log(`[forge] classified as: ${briefClass}`); + if (opts.simulate) { + console.log('[forge] mode: SIMULATED (explicit --simulate)'); + } try { const result = await runPipeline(briefPath, projectRoot, pipelineOptions); - console.log(`[forge] pipeline complete: ${result.runId}`); - console.log(`[forge] run directory: ${result.runDir}`); + applyRunExitPolicy(result, opts.simulate); } catch (err) { - console.error( - `[forge] pipeline failed: ${err instanceof Error ? err.message : String(err)}`, - ); - process.exitCode = 1; + handlePipelineError(err); } }, ); @@ -224,7 +288,12 @@ export function registerForgeCommand(parent: Command): void { .command('resume ') .description('Resume a stopped or failed pipeline run') .option('--project ', 'Project root (defaults to cwd)', process.cwd()) - .action(async (runId: string, opts: { project: string }) => { + .option( + '--simulate', + 'Simulate execution without real providers (every result is typed simulated, never verified)', + false, + ) + .action(async (runId: string, opts: { project: string; simulate: boolean }) => { const runDir = resolveRunDir(runId, opts.project); if (!fs.existsSync(runDir)) { @@ -234,15 +303,20 @@ export function registerForgeCommand(parent: Command): void { } console.log(`[forge] resuming run: ${runId}`); + if (opts.simulate) { + console.log('[forge] mode: SIMULATED (explicit --simulate)'); + } + + // No real executor is wired at CLI invocation time; only the explicitly + // requested simulated executor may be constructed (fail closed otherwise). + const executor = opts.simulate ? createSimulatedExecutor() : undefined; try { const { resumePipeline } = await import('./pipeline-runner.js'); - const result = await resumePipeline(runDir, stubExecutor); - console.log(`[forge] pipeline complete: ${result.runId}`); - console.log(`[forge] run directory: ${result.runDir}`); + const result = await resumePipeline(runDir, executor, { simulate: opts.simulate }); + applyRunExitPolicy(result, opts.simulate); } catch (err) { - console.error(`[forge] resume failed: ${err instanceof Error ? err.message : String(err)}`); - process.exitCode = 1; + handlePipelineError(err); } }); diff --git a/packages/forge/src/constants.ts b/packages/forge/src/constants.ts index b5165f15..46fc3c31 100644 --- a/packages/forge/src/constants.ts +++ b/packages/forge/src/constants.ts @@ -9,7 +9,16 @@ export const PACKAGE_ROOT = path.resolve(path.dirname(fileURLToPath(import.meta. /** Pipeline asset directory (stages, agents, rails, gates, templates). */ export const PIPELINE_DIR = path.join(PACKAGE_ROOT, 'pipeline'); -/** Stage specifications — defines every pipeline stage. */ +/** Stage specifications — defines every pipeline stage. + *\n * Gate semantics (SDLC-D-035): every gate is one of + * - a real command string / GateEntry a mechanical runner can execute, + * - an `authority` gate (human/board sign-off; produces waiting-for-authority), + * - a `provider` gate (requires a wired provider such as a reviewer or CI pipeline). + * + * Vacuous gates (`true`, echo'd synthetic approvals, placeholder ci-pipeline + * commands) are forbidden: a stage whose gate has no real implementation + * fails closed instead of passing. + */ export const STAGE_SPECS: Record = { '00-intake': { number: '00', @@ -27,7 +36,13 @@ export const STAGE_SPECS: Record = { type: 'research', gate: 'discovery-complete', promptFile: '00b-discovery.md', - qualityGates: ['true'], + qualityGates: [ + { + kind: 'authority', + capability: 'discovery-complete', + reason: 'discovery completion is attested by an authority; no mechanical check exists', + }, + ], }, '01-board': { number: '01', @@ -36,7 +51,13 @@ export const STAGE_SPECS: Record = { type: 'review', gate: 'board-approval', promptFile: '01-board.md', - qualityGates: [{ type: 'ci-pipeline', command: 'board-approval (via board-tasks)' }], + qualityGates: [ + { + kind: 'authority', + capability: 'board-approval', + reason: 'board approval is a board/human decision; no mechanical gate exists', + }, + ], }, '01b-brief-analyzer': { number: '01b', @@ -45,7 +66,13 @@ export const STAGE_SPECS: Record = { type: 'research', gate: 'brief-analysis-complete', promptFile: '01-board.md', - qualityGates: ['true'], + qualityGates: [ + { + kind: 'authority', + capability: 'brief-analysis-complete', + reason: 'brief analysis completion is attested by an authority; no mechanical check exists', + }, + ], }, '02-planning-1': { number: '02', @@ -54,7 +81,13 @@ export const STAGE_SPECS: Record = { type: 'research', gate: 'architecture-approval', promptFile: '02-planning-1-architecture.md', - qualityGates: ['true'], + qualityGates: [ + { + kind: 'authority', + capability: 'architecture-approval', + reason: 'ADR approval requires authority sign-off; no mechanical check exists', + }, + ], }, '03-planning-2': { number: '03', @@ -63,7 +96,14 @@ export const STAGE_SPECS: Record = { type: 'research', gate: 'implementation-approval', promptFile: '03-planning-2-implementation.md', - qualityGates: ['true'], + qualityGates: [ + { + kind: 'authority', + capability: 'implementation-approval', + reason: + 'implementation spec approval requires authority sign-off; no mechanical check exists', + }, + ], }, '04-planning-3': { number: '04', @@ -72,7 +112,14 @@ export const STAGE_SPECS: Record = { type: 'research', gate: 'decomposition-approval', promptFile: '04-planning-3-decomposition.md', - qualityGates: ['true'], + qualityGates: [ + { + kind: 'authority', + capability: 'decomposition-approval', + reason: + 'task decomposition approval requires authority sign-off; no mechanical check exists', + }, + ], }, '05-coding': { number: '05', @@ -92,9 +139,10 @@ export const STAGE_SPECS: Record = { promptFile: '06-review.md', qualityGates: [ { - type: 'ai-review', - command: - 'echo \'{"summary":"review-pass","verdict":"approve","findings":[],"stats":{"blockers":0,"should_fix":0,"suggestions":0}}\'', + kind: 'provider', + capability: 'reviewer', + reason: + 'review verdicts require a wired reviewer provider; synthetic approvals are not permitted', }, ], }, @@ -105,7 +153,13 @@ export const STAGE_SPECS: Record = { type: 'coding', gate: 're-review', promptFile: '07-remediate.md', - qualityGates: ['true'], + qualityGates: [ + { + kind: 'authority', + capability: 're-review', + reason: 'remediation re-review is an approval-based gate; no mechanical check exists', + }, + ], }, '08-test': { number: '08', @@ -123,7 +177,13 @@ export const STAGE_SPECS: Record = { type: 'deploy', gate: 'deploy-verification', promptFile: '09-deploy.md', - qualityGates: [{ type: 'ci-pipeline', command: 'deploy-verification' }], + qualityGates: [ + { + kind: 'provider', + capability: 'ci-pipeline', + reason: 'deploy verification requires a wired CI pipeline provider', + }, + ], }, }; diff --git a/packages/forge/src/errors.ts b/packages/forge/src/errors.ts new file mode 100644 index 00000000..afe88355 --- /dev/null +++ b/packages/forge/src/errors.ts @@ -0,0 +1,46 @@ +/** + * Typed fail-closed capability errors (SDLC-D-035). + * + * A Forge run must fail closed when a required capability (executor, reviewer + * provider, CI pipeline, authority sign-off) is missing. These typed errors + * name the missing capability so callers can distinguish "not wired" from + * ordinary execution failures. + */ + +/** Closed set of typed Forge capability error codes. */ +export const FORGE_ERROR_CODES = [ + 'FORGE_NO_EXECUTOR', + 'FORGE_NO_REVIEWER', + 'FORGE_NO_CI_PIPELINE', + 'FORGE_NO_PROVIDER', + 'FORGE_AUTHORITY_REQUIRED', +] as const; + +export type ForgeErrorCode = (typeof FORGE_ERROR_CODES)[number]; + +/** Raised when a required capability is missing and the pipeline must fail closed. */ +export class ForgeCapabilityError extends Error { + /** Typed error code from the closed FORGE_ERROR_CODES set. */ + readonly code: ForgeErrorCode; + /** The missing capability, e.g. `task-executor`, `reviewer`, `board-approval`. */ + readonly capability: string; + + constructor(code: ForgeErrorCode, capability: string, message: string) { + super(message); + this.name = 'ForgeCapabilityError'; + this.code = code; + this.capability = capability; + } +} + +/** Map a provider gate capability to its typed error code. */ +export function providerErrorCode(capability: string): ForgeErrorCode { + switch (capability) { + case 'reviewer': + return 'FORGE_NO_REVIEWER'; + case 'ci-pipeline': + return 'FORGE_NO_CI_PIPELINE'; + default: + return 'FORGE_NO_PROVIDER'; + } +} diff --git a/packages/forge/src/index.ts b/packages/forge/src/index.ts index 62c765a1..86a1a239 100644 --- a/packages/forge/src/index.ts +++ b/packages/forge/src/index.ts @@ -5,6 +5,13 @@ export type { StageSpec, BriefClass, ClassSource, + ForgeOutcome, + AuthorityGate, + ProviderGate, + ForgeGate, + ForgeGateResult, + ForgeTaskResult, + RunMode, StageStatus, RunManifest, ForgeTaskStatus, @@ -81,5 +88,24 @@ export { getPipelineStatus, } from './pipeline-runner.js'; +// Fail-closed errors and typed outcome model (SDLC-D-035) +export { FORGE_ERROR_CODES, ForgeCapabilityError, providerErrorCode } from './errors.js'; +export type { ForgeErrorCode } from './errors.js'; +export { + isSatisfyingOutcome, + isCapabilityGate, + isCommandGate, + gateLabel, + uniformGateResults, + simulatedGateResults, + waitingGateResults, + blockedGateResults, + evaluateStageGates, +} from './outcomes.js'; +export type { StageEvaluation } from './outcomes.js'; + +// Simulated executor (explicit --simulate only) +export { createSimulatedExecutor } from './simulated-executor.js'; + // CLI export { registerForgeCommand } from './cli.js'; diff --git a/packages/forge/src/outcomes.ts b/packages/forge/src/outcomes.ts new file mode 100644 index 00000000..07b3fc5e --- /dev/null +++ b/packages/forge/src/outcomes.ts @@ -0,0 +1,147 @@ +import type { GateEntry } from '@mosaicstack/macp'; + +import type { + AuthorityGate, + ForgeGate, + ForgeGateResult, + ForgeOutcome, + ForgeTaskResult, + ProviderGate, +} from './types.js'; + +/** + * Gate and dependency satisfaction predicate (SDLC-D-035). + * + * ONLY a verified `passed` outcome satisfies. Every other member of the closed + * outcome set — including `simulated` — is non-satisfying, so a simulated or + * authority-blocked result can never be read as success-by-verification. + */ +export function isSatisfyingOutcome(outcome: ForgeOutcome): boolean { + return outcome === 'passed'; +} + +/** Whether a gate is an authority or provider gate (capability-based, command-less). */ +export function isCapabilityGate(gate: ForgeGate): gate is AuthorityGate | ProviderGate { + if (typeof gate !== 'object' || gate === null) return false; + const kind = (gate as Record)['kind']; + return kind === 'authority' || kind === 'provider'; +} + +/** Whether a gate definition carries a real command a mechanical runner can execute. */ +export function isCommandGate(gate: ForgeGate): gate is string | GateEntry { + if (typeof gate === 'string') { + return gate.trim().length > 0; + } + if (isCapabilityGate(gate)) { + // Authority and provider gates are satisfied by a capability, not a command. + return false; + } + return typeof gate.command === 'string' && gate.command.trim().length > 0; +} + +/** Typed label identifying a gate in results and logs. */ +export function gateLabel(gate: ForgeGate): string { + if (typeof gate === 'string') return gate; + if (isCapabilityGate(gate)) return `${gate.kind}:${gate.capability}`; + return gate.command || gate.type || 'unnamed-gate'; +} + +/** Reason string stamped on every simulated gate result. */ +export const SIMULATED_GATE_REASON = + 'simulated execution (--simulate): gate was not evaluated by a real implementation'; + +/** Build typed gate results with a uniform outcome for a stage's declared gates. */ +export function uniformGateResults( + gates: ForgeGate[], + outcome: ForgeOutcome, + reason: string, +): ForgeGateResult[] { + return gates.map((gate) => ({ gate: gateLabel(gate), outcome, reason })); +} + +/** Typed simulated gate results — used exclusively in `--simulate` runs. */ +export function simulatedGateResults(gates: ForgeGate[]): ForgeGateResult[] { + return uniformGateResults(gates, 'simulated', SIMULATED_GATE_REASON); +} + +/** Typed waiting-for-authority gate results for approval-based stages. */ +export function waitingGateResults(gates: ForgeGate[], reason: string): ForgeGateResult[] { + return uniformGateResults(gates, 'waiting-for-authority', reason); +} + +/** Typed blocked gate results for stages whose provider capability is not wired. */ +export function blockedGateResults(gates: ForgeGate[], reason: string): ForgeGateResult[] { + return uniformGateResults(gates, 'blocked', reason); +} + +/** Outcome of evaluating a completed stage in normal mode. */ +export interface StageEvaluation { + outcome: ForgeOutcome; + reason: string; + gateResults: ForgeGateResult[]; +} + +/** + * Evaluate a stage's declared gates against the executor's typed result. + * + * Fail-closed mapping: + * - a `simulated` task or gate outcome in normal mode maps to `error` + * - a missing gate result for a required command gate maps to `blocked` + * - a non-passing task outcome propagates as the stage outcome + * - only verified `passed` task and gate outcomes yield a `passed` stage + */ +export function evaluateStageGates( + stageName: string, + gates: ForgeGate[], + result: ForgeTaskResult, +): StageEvaluation { + const gateResults = result.gate_results ?? []; + + if (result.outcome === 'simulated') { + return { + outcome: 'error', + reason: `executor reported a simulated outcome for stage '${stageName}' in normal mode — refusing to treat simulated results as verified`, + gateResults, + }; + } + + if (!isSatisfyingOutcome(result.outcome)) { + return { + outcome: result.outcome, + reason: `task outcome is '${result.outcome}': ${result.reason}`, + gateResults, + }; + } + + for (const gate of gates) { + // Authority and provider gates are pre-flighted before execution; they have + // no mechanical result to verify here. + if (!isCommandGate(gate)) continue; + + const label = gateLabel(gate); + const gateResult = gateResults.find((r) => r.gate === label); + if (!gateResult) { + return { + outcome: 'blocked', + reason: `no gate result was reported for required gate '${label}' (stage '${stageName}')`, + gateResults, + }; + } + if (!isSatisfyingOutcome(gateResult.outcome)) { + return { + outcome: gateResult.outcome === 'simulated' ? 'error' : gateResult.outcome, + reason: `gate '${label}' outcome is '${gateResult.outcome}': ${gateResult.reason}`, + gateResults, + }; + } + } + + return { + outcome: 'passed', + reason: + gates.length === 0 + ? "stage declares no gates; task outcome 'passed' accepted" + : 'all declared gates verified passed', + gateResults, + }; +} diff --git a/packages/forge/src/pipeline-runner.ts b/packages/forge/src/pipeline-runner.ts index e43381df..a6d46a80 100644 --- a/packages/forge/src/pipeline-runner.ts +++ b/packages/forge/src/pipeline-runner.ts @@ -1,18 +1,33 @@ import fs from 'node:fs'; import path from 'node:path'; -import { STAGE_SEQUENCE } from './constants.js'; +import { STAGE_SEQUENCE, STAGE_SPECS } from './constants.js'; import { determineBriefClass, stagesForClass } from './brief-classifier.js'; +import { ForgeCapabilityError, providerErrorCode } from './errors.js'; +import { + blockedGateResults, + evaluateStageGates, + isCapabilityGate, + simulatedGateResults, + waitingGateResults, +} from './outcomes.js'; import { mapStageToTask } from './stage-adapter.js'; +import { createSimulatedExecutor } from './simulated-executor.js'; import type { ForgeTask, + ForgeTaskResult, PipelineOptions, PipelineResult, RunManifest, + RunMode, StageStatus, TaskExecutor, } from './types.js'; +/** Reason stamped on stages that complete under explicit simulation. */ +const SIMULATED_STAGE_REASON = + 'simulated execution (--simulate): stage was not executed by a real executor'; + /** * Generate a timestamp-based run ID. */ @@ -47,6 +62,7 @@ function createManifest(opts: { briefClass: RunManifest['briefClass']; classSource: RunManifest['classSource']; forceBoard: boolean; + mode: RunMode; runDir: string; }): RunManifest { const ts = nowISO(); @@ -57,6 +73,7 @@ function createManifest(opts: { briefClass: opts.briefClass, classSource: opts.classSource, forceBoard: opts.forceBoard, + mode: opts.mode, createdAt: ts, updatedAt: ts, currentStage: '', @@ -108,20 +125,199 @@ export function selectStages(stages?: string[], skipTo?: string): string[] { return selected.slice(skipIndex); } +/** + * Fail closed when the required executor capability is missing (SDLC-D-035). + */ +function requireExecutor(executor: TaskExecutor | undefined, simulate: boolean): TaskExecutor { + if (executor) return executor; + if (simulate) return createSimulatedExecutor({ log: false }); + throw new ForgeCapabilityError( + 'FORGE_NO_EXECUTOR', + 'task-executor', + 'no task executor is wired; refusing to run the pipeline with a stub executor (fail closed). ' + + 'Pass --simulate to opt into explicitly simulated execution.', + ); +} + +/** + * Pre-flight a stage's gates in normal mode (fail closed, SDLC-D-035). + * + * - authority gates: record a typed `waiting-for-authority` stage result and + * raise FORGE_AUTHORITY_REQUIRED — approval-based gates never pass vacuously. + * - provider gates: record a typed `blocked` stage result and raise the typed + * capability error for the missing provider. + * + * Returns the stage status to record when the pre-flight blocks, or undefined + * when the stage may proceed. + */ +function preflightStageGates( + stageName: string, + manifest: RunManifest, +): { status: StageStatus; error: ForgeCapabilityError } | undefined { + const spec = STAGE_SPECS[stageName]; + if (!spec) throw new Error(`Unknown Forge stage: ${stageName}`); + + for (const gate of spec.qualityGates) { + if (!isCapabilityGate(gate)) continue; + + const startedAt = manifest.stages[stageName]?.startedAt; + const completedAt = nowISO(); + + if (gate.kind === 'authority') { + const reason = `gate '${gate.capability}' requires authority sign-off; no mechanical implementation exists (${gate.reason})`; + return { + status: { + status: 'waiting-for-authority', + reason, + startedAt, + completedAt, + gateResults: waitingGateResults(spec.qualityGates, reason), + }, + error: new ForgeCapabilityError( + 'FORGE_AUTHORITY_REQUIRED', + gate.capability, + `stage '${stageName}' is blocked on authority gate '${gate.capability}': ${gate.reason}. ` + + 'The pipeline fails closed instead of passing vacuously. Record the approval out-of-band ' + + 'or run with --simulate for explicitly simulated execution.', + ), + }; + } + + const reason = `gate '${gate.capability}' requires provider '${gate.capability}' and none is wired (${gate.reason})`; + return { + status: { + status: 'blocked', + reason, + startedAt, + completedAt, + gateResults: blockedGateResults(spec.qualityGates, reason), + }, + error: new ForgeCapabilityError( + providerErrorCode(gate.capability), + gate.capability, + `stage '${stageName}' requires provider '${gate.capability}' which is not wired: ${gate.reason}. ` + + 'The pipeline fails closed instead of passing vacuously.', + ), + }; + } + + return undefined; +} + +/** + * Execute the given stage tasks sequentially, updating the manifest. + * + * Normal mode requires a real executor and evaluates every declared command + * gate through the typed outcome model; any non-verified result fails closed. + * Simulate mode types every stage and gate result as `simulated`. + */ +async function executeStages(opts: { + manifest: RunManifest; + runDir: string; + tasks: ForgeTask[]; + stageNames: string[]; + executor: TaskExecutor; + simulate: boolean; +}): Promise { + const { manifest, runDir, tasks, stageNames, executor, simulate } = opts; + + for (let i = 0; i < tasks.length; i++) { + const task = tasks[i]!; + const stageName = stageNames[i]!; + const spec = STAGE_SPECS[stageName]; + if (!spec) throw new Error(`Unknown Forge stage: ${stageName}`); + + // Update manifest: stage in progress + manifest.currentStage = stageName; + manifest.stages[stageName] = { + status: 'in_progress', + startedAt: nowISO(), + }; + saveManifest(runDir, manifest); + + // Fail-closed pre-flight (normal mode only): authority/provider gates have + // no mechanical implementation and must never pass vacuously. + if (!simulate) { + const blocked = preflightStageGates(stageName, manifest); + if (blocked) { + manifest.stages[stageName] = blocked.status; + manifest.status = + blocked.status.status === 'waiting-for-authority' ? 'waiting-for-authority' : 'failed'; + saveManifest(runDir, manifest); + throw blocked.error; + } + } + + let result: ForgeTaskResult; + try { + await executor.submitTask(task); + result = await executor.waitForCompletion(task.id, task.timeoutSeconds * 1000); + } catch (error) { + // Process errors (including timeouts) map to the fail-closed `error` outcome. + const reason = error instanceof Error ? error.message : String(error); + manifest.stages[stageName] = { + status: 'error', + reason: `executor error: ${reason}`, + startedAt: manifest.stages[stageName]?.startedAt, + completedAt: nowISO(), + gateResults: [], + }; + manifest.status = 'failed'; + saveManifest(runDir, manifest); + throw error instanceof Error ? error : new Error(reason); + } + + if (simulate) { + manifest.stages[stageName] = { + status: 'simulated', + reason: SIMULATED_STAGE_REASON, + startedAt: manifest.stages[stageName]?.startedAt, + completedAt: nowISO(), + gateResults: simulatedGateResults(spec.qualityGates), + }; + saveManifest(runDir, manifest); + continue; + } + + const evaluation = evaluateStageGates(stageName, spec.qualityGates, result); + manifest.stages[stageName] = { + status: evaluation.outcome, + reason: evaluation.reason, + startedAt: manifest.stages[stageName]?.startedAt, + completedAt: nowISO(), + gateResults: evaluation.gateResults, + }; + + if (evaluation.outcome !== 'passed') { + manifest.status = + evaluation.outcome === 'waiting-for-authority' ? 'waiting-for-authority' : 'failed'; + saveManifest(runDir, manifest); + throw new Error(`Stage ${stageName} ${evaluation.outcome}: ${evaluation.reason}`); + } + + saveManifest(runDir, manifest); + } +} + /** * Run the Forge pipeline. * - * 1. Classify the brief - * 2. Generate a run ID and create run directory - * 3. Map stages to tasks and submit to TaskExecutor - * 4. Track manifest with stage statuses - * 5. Return pipeline result + * 1. Fail closed unless a real executor is wired or simulation is explicit + * 2. Classify the brief + * 3. Generate a run ID and create run directory + * 4. Map stages to tasks and submit to TaskExecutor + * 5. Track manifest with typed stage outcomes + * 6. Return pipeline result */ export async function runPipeline( briefPath: string, projectRoot: string, options: PipelineOptions, ): Promise { + const simulate = options.simulate ?? false; + const executor = requireExecutor(options.executor, simulate); + const mode: RunMode = simulate ? 'simulated' : 'normal'; + const resolvedRoot = path.resolve(projectRoot); const resolvedBrief = path.resolve(briefPath); const briefContent = fs.readFileSync(resolvedBrief, 'utf-8'); @@ -146,6 +342,7 @@ export async function runPipeline( briefClass, classSource, forceBoard: options.forceBoard ?? false, + mode, runDir, }); @@ -172,54 +369,10 @@ export async function runPipeline( } // Execute stages - const { executor } = options; - for (let i = 0; i < tasks.length; i++) { - const task = tasks[i]!; - const stageName = selectedStages[i]!; + await executeStages({ manifest, runDir, tasks, stageNames: selectedStages, executor, simulate }); - // Update manifest: stage in progress - manifest.currentStage = stageName; - manifest.stages[stageName] = { - status: 'in_progress', - startedAt: nowISO(), - }; - saveManifest(runDir, manifest); - - try { - await executor.submitTask(task); - const result = await executor.waitForCompletion(task.id, task.timeoutSeconds * 1000); - - // Update manifest: stage completed or failed - const stageStatus: StageStatus = { - status: result.status === 'completed' ? 'passed' : 'failed', - startedAt: manifest.stages[stageName]!.startedAt, - completedAt: nowISO(), - }; - manifest.stages[stageName] = stageStatus; - - if (result.status !== 'completed') { - manifest.status = 'failed'; - saveManifest(runDir, manifest); - throw new Error(`Stage ${stageName} failed with status: ${result.status}`); - } - - saveManifest(runDir, manifest); - } catch (error) { - if (!manifest.stages[stageName]?.completedAt) { - manifest.stages[stageName] = { - status: 'failed', - startedAt: manifest.stages[stageName]?.startedAt, - completedAt: nowISO(), - }; - } - manifest.status = 'failed'; - saveManifest(runDir, manifest); - throw error; - } - } - - // All stages passed - manifest.status = 'completed'; + // All stages reached a terminal state for this mode + manifest.status = simulate ? 'simulated' : 'completed'; saveManifest(runDir, manifest); return { @@ -234,22 +387,30 @@ export async function runPipeline( } /** - * Resume a pipeline from the last incomplete stage. + * Resume a pipeline from the last non-passed stage. */ export async function resumePipeline( runDir: string, - executor: TaskExecutor, + executor?: TaskExecutor, + options?: { simulate?: boolean }, ): Promise { + const simulate = options?.simulate ?? false; + const wiredExecutor = requireExecutor(executor, simulate); + const mode: RunMode = simulate ? 'simulated' : 'normal'; + const manifest = loadManifest(runDir); const resolvedRoot = path.dirname(path.dirname(path.dirname(runDir))); // .forge/runs/{id} → project root const briefContent = fs.readFileSync(manifest.brief, 'utf-8'); const allStages = stagesForClass(manifest.briefClass, manifest.forceBoard); - // Find first non-passed stage + manifest.mode = mode; + + // Find first non-satisfying stage (only a verified `passed` counts as done; + // simulated and waiting-for-authority stages are re-run). const resumeFrom = allStages.find((s) => manifest.stages[s]?.status !== 'passed'); if (!resumeFrom) { - manifest.status = 'completed'; + manifest.status = mode === 'simulated' ? 'simulated' : 'completed'; saveManifest(runDir, manifest); return { runId: manifest.runId, @@ -284,49 +445,16 @@ export async function resumePipeline( tasks.push(task); } - for (let i = 0; i < tasks.length; i++) { - const task = tasks[i]!; - const stageName = remainingStages[i]!; + await executeStages({ + manifest, + runDir, + tasks, + stageNames: remainingStages, + executor: wiredExecutor, + simulate, + }); - manifest.currentStage = stageName; - manifest.stages[stageName] = { - status: 'in_progress', - startedAt: nowISO(), - }; - saveManifest(runDir, manifest); - - try { - await executor.submitTask(task); - const result = await executor.waitForCompletion(task.id, task.timeoutSeconds * 1000); - - manifest.stages[stageName] = { - status: result.status === 'completed' ? 'passed' : 'failed', - startedAt: manifest.stages[stageName]!.startedAt, - completedAt: nowISO(), - }; - - if (result.status !== 'completed') { - manifest.status = 'failed'; - saveManifest(runDir, manifest); - throw new Error(`Stage ${stageName} failed with status: ${result.status}`); - } - - saveManifest(runDir, manifest); - } catch (error) { - if (!manifest.stages[stageName]?.completedAt) { - manifest.stages[stageName] = { - status: 'failed', - startedAt: manifest.stages[stageName]?.startedAt, - completedAt: nowISO(), - }; - } - manifest.status = 'failed'; - saveManifest(runDir, manifest); - throw error; - } - } - - manifest.status = 'completed'; + manifest.status = simulate ? 'simulated' : 'completed'; saveManifest(runDir, manifest); return { diff --git a/packages/forge/src/simulated-executor.ts b/packages/forge/src/simulated-executor.ts new file mode 100644 index 00000000..759c4385 --- /dev/null +++ b/packages/forge/src/simulated-executor.ts @@ -0,0 +1,32 @@ +import type { ForgeTask, ForgeTaskResult, TaskExecutor } from './types.js'; + +/** + * Simulated executor — used ONLY when the caller explicitly passes --simulate. + * + * It submits no real work and returns typed `simulated` results so a simulated + * run can never be confused with a verified one. In normal mode (no --simulate) + * the CLI refuses to run at all with FORGE_NO_EXECUTOR instead of wiring this + * stub (fail closed, SDLC-D-035). + */ +export function createSimulatedExecutor(options?: { log?: boolean }): TaskExecutor { + const log = options?.log ?? true; + return { + async submitTask(task: ForgeTask) { + if (log) console.log(` [forge:simulated] stage submitted: ${task.id} (${task.title})`); + }, + async waitForCompletion(taskId: string): Promise { + if (log) console.log(` [forge:simulated] stage complete: ${taskId}`); + return { + task_id: taskId, + outcome: 'simulated', + reason: 'no executor wired; simulated execution requested via --simulate', + completed_at: new Date().toISOString(), + exit_code: 0, + gate_results: [], + }; + }, + async getTaskStatus() { + return 'completed' as const; + }, + }; +} diff --git a/packages/forge/src/types.ts b/packages/forge/src/types.ts index f339d1de..4d2c8727 100644 --- a/packages/forge/src/types.ts +++ b/packages/forge/src/types.ts @@ -1,4 +1,4 @@ -import type { GateEntry, TaskResult } from '@mosaicstack/macp'; +import type { GateEntry } from '@mosaicstack/macp'; /** Stage dispatch mode. */ export type StageDispatch = 'exec' | 'yolo' | 'pi'; @@ -6,6 +6,58 @@ export type StageDispatch = 'exec' | 'yolo' | 'pi'; /** Stage type — determines agent selection and gate requirements. */ export type StageType = 'research' | 'review' | 'coding' | 'deploy'; +/** + * Typed outcome for every gate and stage evaluation — closed set (SDLC-D-035). + * + * Only `passed` means "verified by a real implementation". `simulated` is + * produced exclusively in explicit `--simulate` runs and is never satisfying. + */ +export type ForgeOutcome = + | 'passed' + | 'failed' + | 'blocked' + | 'error' + | 'waiting-for-authority' + | 'simulated' + | 'not-applicable'; + +/** A gate that requires authority (human/board) sign-off; no mechanical command can satisfy it. */ +export interface AuthorityGate { + kind: 'authority'; + capability: string; + reason: string; +} + +/** A gate that requires a wired provider (e.g. an AI reviewer, CI pipeline) to evaluate. */ +export interface ProviderGate { + kind: 'provider'; + capability: string; + reason: string; +} + +/** Forge quality gate: a real command, an authority sign-off, or a provider-backed check. */ +export type ForgeGate = string | GateEntry | AuthorityGate | ProviderGate; + +/** Typed result of evaluating a single quality gate. */ +export interface ForgeGateResult { + gate: string; + outcome: ForgeOutcome; + reason: string; + exitCode?: number; + output?: string; + timedOut?: boolean; +} + +/** Typed result of a task/stage execution returned by a TaskExecutor. */ +export interface ForgeTaskResult { + task_id: string; + outcome: ForgeOutcome; + reason: string; + completed_at: string; + exit_code: number; + gate_results: ForgeGateResult[]; +} + /** Stage specification — defines a single pipeline stage. */ export interface StageSpec { number: string; @@ -14,7 +66,7 @@ export interface StageSpec { type: StageType; gate: string; promptFile: string; - qualityGates: (string | GateEntry)[]; + qualityGates: ForgeGate[]; } /** Brief classification. */ @@ -25,11 +77,18 @@ export type ClassSource = 'cli' | 'frontmatter' | 'auto'; /** Per-stage status within a run manifest. */ export interface StageStatus { - status: 'pending' | 'in_progress' | 'passed' | 'failed'; + status: 'pending' | 'in_progress' | ForgeOutcome; + /** Why the stage reached its current (terminal) outcome, when applicable. */ + reason?: string; startedAt?: string; completedAt?: string; + /** Typed per-gate results recorded alongside the stage outcome. */ + gateResults?: ForgeGateResult[]; } +/** Execution mode of a run. */ +export type RunMode = 'normal' | 'simulated'; + /** Run manifest — persisted to disk as manifest.json. */ export interface RunManifest { runId: string; @@ -38,10 +97,23 @@ export interface RunManifest { briefClass: BriefClass; classSource: ClassSource; forceBoard: boolean; + /** + * Execution mode. `simulated` runs stub execution; their results are typed + * `simulated` and must never be read as verified success. Optional because + * manifests written before this field existed default to `normal`. + */ + mode?: RunMode; createdAt: string; updatedAt: string; currentStage: string; - status: 'in_progress' | 'completed' | 'failed' | 'interrupted' | 'rejected'; + status: + | 'in_progress' + | 'completed' + | 'failed' + | 'interrupted' + | 'rejected' + | 'simulated' + | 'waiting-for-authority'; stages: Record; } @@ -65,7 +137,7 @@ export interface ForgeTask { briefPath: string; resultPath: string; timeoutSeconds: number; - qualityGates: (string | GateEntry)[]; + qualityGates: ForgeGate[]; worktree?: string; command?: string; dependsOn?: string[]; @@ -76,7 +148,7 @@ export interface ForgeTask { /** Abstract task executor — decouples from packages/coord. */ export interface TaskExecutor { submitTask(task: ForgeTask): Promise; - waitForCompletion(taskId: string, timeoutMs: number): Promise; + waitForCompletion(taskId: string, timeoutMs: number): Promise; getTaskStatus(taskId: string): Promise; } @@ -122,7 +194,16 @@ export interface PipelineOptions { stages?: string[]; skipTo?: string; dryRun?: boolean; - executor: TaskExecutor; + /** + * Real task executor. Required in normal mode: the pipeline fails closed + * with FORGE_NO_EXECUTOR when it is absent. + */ + executor?: TaskExecutor; + /** + * Explicit opt-in to simulated execution. Every stage and gate result is + * typed `simulated` and is never satisfying. + */ + simulate?: boolean; } /** Pipeline run result. */ diff --git a/packages/macp/__tests__/gate-runner.test.ts b/packages/macp/__tests__/gate-runner.test.ts deleted file mode 100644 index ffe6029f..00000000 --- a/packages/macp/__tests__/gate-runner.test.ts +++ /dev/null @@ -1,253 +0,0 @@ -import { mkdirSync, readFileSync, rmSync } from 'node:fs'; -import { join } from 'node:path'; -import { tmpdir } from 'node:os'; -import { randomUUID } from 'node:crypto'; -import { describe, it, expect, beforeEach, afterEach } from 'vitest'; -import { normalizeGate, countAIFindings, runGate, runGates } from '../src/gate-runner.js'; - -function makeTmpDir(): string { - const dir = join(tmpdir(), `macp-gate-${randomUUID()}`); - mkdirSync(dir, { recursive: true }); - return dir; -} - -describe('normalizeGate', () => { - it('normalizes a string to mechanical gate', () => { - expect(normalizeGate('echo test')).toEqual({ - command: 'echo test', - type: 'mechanical', - fail_on: 'blocker', - }); - }); - - it('normalizes an object gate with defaults', () => { - expect(normalizeGate({ command: 'lint' })).toEqual({ - command: 'lint', - type: 'mechanical', - fail_on: 'blocker', - }); - }); - - it('preserves explicit type and fail_on', () => { - expect(normalizeGate({ command: 'review', type: 'ai-review', fail_on: 'any' })).toEqual({ - command: 'review', - type: 'ai-review', - fail_on: 'any', - }); - }); - - it('handles non-string/non-object input', () => { - expect(normalizeGate(42)).toEqual({ command: '', type: 'mechanical', fail_on: 'blocker' }); - expect(normalizeGate(null)).toEqual({ command: '', type: 'mechanical', fail_on: 'blocker' }); - }); -}); - -describe('countAIFindings', () => { - it('returns zeros for non-object', () => { - expect(countAIFindings(null)).toEqual({ blockers: 0, total: 0 }); - expect(countAIFindings('string')).toEqual({ blockers: 0, total: 0 }); - expect(countAIFindings([])).toEqual({ blockers: 0, total: 0 }); - }); - - it('counts from stats block', () => { - const output = { stats: { blockers: 2, should_fix: 3, suggestions: 1 } }; - expect(countAIFindings(output)).toEqual({ blockers: 2, total: 6 }); - }); - - it('counts from findings array when stats has no blockers', () => { - const output = { - stats: { blockers: 0 }, - findings: [{ severity: 'blocker' }, { severity: 'warning' }, { severity: 'blocker' }], - }; - expect(countAIFindings(output)).toEqual({ blockers: 2, total: 3 }); - }); - - it('uses stats blockers over findings array when stats has blockers', () => { - const output = { - stats: { blockers: 5 }, - findings: [{ severity: 'blocker' }, { severity: 'warning' }], - }; - // stats.blockers = 5, total from stats = 5+0+0 = 5, findings not used for total since stats total is non-zero - expect(countAIFindings(output)).toEqual({ blockers: 5, total: 5 }); - }); - - it('counts findings length as total when stats has zero total', () => { - const output = { - findings: [{ severity: 'warning' }, { severity: 'info' }], - }; - expect(countAIFindings(output)).toEqual({ blockers: 0, total: 2 }); - }); -}); - -describe('runGate', () => { - let tmp: string; - let logPath: string; - - beforeEach(() => { - tmp = makeTmpDir(); - logPath = join(tmp, 'gate.log'); - }); - - afterEach(() => { - rmSync(tmp, { recursive: true, force: true }); - }); - - it('passes mechanical gate on exit 0', () => { - const result = runGate('echo hello', tmp, logPath, 30); - expect(result.passed).toBe(true); - expect(result.exit_code).toBe(0); - expect(result.type).toBe('mechanical'); - expect(result.output).toContain('hello'); - }); - - it('fails mechanical gate on non-zero exit', () => { - const result = runGate('exit 1', tmp, logPath, 30); - expect(result.passed).toBe(false); - expect(result.exit_code).toBe(1); - }); - - it('ci-pipeline always passes', () => { - const result = runGate({ command: 'anything', type: 'ci-pipeline' }, tmp, logPath, 30); - expect(result.passed).toBe(true); - expect(result.type).toBe('ci-pipeline'); - expect(result.output).toBe('CI pipeline gate placeholder'); - }); - - it('empty command passes', () => { - const result = runGate({ command: '' }, tmp, logPath, 30); - expect(result.passed).toBe(true); - }); - - it('ai-review gate parses JSON output', () => { - const json = JSON.stringify({ stats: { blockers: 0, should_fix: 1 } }); - const result = runGate({ command: `echo '${json}'`, type: 'ai-review' }, tmp, logPath, 30); - expect(result.passed).toBe(true); - expect(result.blockers).toBe(0); - expect(result.findings).toBe(1); - }); - - it('ai-review gate fails on blockers', () => { - const json = JSON.stringify({ stats: { blockers: 2 } }); - const result = runGate({ command: `echo '${json}'`, type: 'ai-review' }, tmp, logPath, 30); - expect(result.passed).toBe(false); - expect(result.blockers).toBe(2); - }); - - it('ai-review gate with fail_on=any fails on any findings', () => { - const json = JSON.stringify({ stats: { blockers: 0, should_fix: 1 } }); - const result = runGate( - { command: `echo '${json}'`, type: 'ai-review', fail_on: 'any' }, - tmp, - logPath, - 30, - ); - expect(result.passed).toBe(false); - expect(result.fail_on).toBe('any'); - }); - - it('ai-review gate fails on invalid JSON output', () => { - const result = runGate({ command: 'echo "not json"', type: 'ai-review' }, tmp, logPath, 30); - expect(result.passed).toBe(false); - expect(result.parse_error).toBeDefined(); - }); - - it('writes to log file', () => { - runGate('echo logged', tmp, logPath, 30); - const log = readFileSync(logPath, 'utf-8'); - expect(log).toContain('COMMAND: echo logged'); - expect(log).toContain('logged'); - expect(log).toContain('EXIT:'); - }); -}); - -describe('runGates', () => { - let tmp: string; - let logPath: string; - let eventsPath: string; - - beforeEach(() => { - tmp = makeTmpDir(); - logPath = join(tmp, 'gates.log'); - eventsPath = join(tmp, 'events.ndjson'); - }); - - afterEach(() => { - rmSync(tmp, { recursive: true, force: true }); - }); - - it('runs multiple gates and returns results', () => { - const { allPassed, gateResults } = runGates( - ['echo one', 'echo two'], - tmp, - logPath, - 30, - eventsPath, - 'task-1', - ); - expect(allPassed).toBe(true); - expect(gateResults).toHaveLength(2); - }); - - it('reports failure when any gate fails', () => { - const { allPassed, gateResults } = runGates( - ['echo ok', 'exit 1'], - tmp, - logPath, - 30, - eventsPath, - 'task-2', - ); - expect(allPassed).toBe(false); - expect(gateResults[0]!.passed).toBe(true); - expect(gateResults[1]!.passed).toBe(false); - }); - - it('emits events for each gate', () => { - runGates(['echo test'], tmp, logPath, 30, eventsPath, 'task-3'); - const events = readFileSync(eventsPath, 'utf-8') - .trim() - .split('\n') - .map((l) => JSON.parse(l)); - expect(events).toHaveLength(2); // started + passed - expect(events[0].event_type).toBe('rail.check.started'); - expect(events[1].event_type).toBe('rail.check.passed'); - }); - - it('skips gates with empty command (non ci-pipeline)', () => { - const { gateResults } = runGates( - [{ command: '', type: 'mechanical' }, 'echo real'], - tmp, - logPath, - 30, - eventsPath, - 'task-4', - ); - expect(gateResults).toHaveLength(1); - }); - - it('does not skip ci-pipeline even with empty command', () => { - const { gateResults } = runGates( - [{ command: '', type: 'ci-pipeline' }], - tmp, - logPath, - 30, - eventsPath, - 'task-5', - ); - expect(gateResults).toHaveLength(1); - expect(gateResults[0]!.passed).toBe(true); - }); - - it('emits failed event with correct message', () => { - runGates(['exit 42'], tmp, logPath, 30, eventsPath, 'task-6'); - const events = readFileSync(eventsPath, 'utf-8') - .trim() - .split('\n') - .map((l) => JSON.parse(l)); - const failEvent = events.find( - (e: Record) => e.event_type === 'rail.check.failed', - ); - expect(failEvent).toBeDefined(); - expect(failEvent.message).toContain('Gate failed ('); - }); -}); diff --git a/packages/macp/src/cli.spec.ts b/packages/macp/src/cli.spec.ts index 4ee920b9..b4b6c095 100644 --- a/packages/macp/src/cli.spec.ts +++ b/packages/macp/src/cli.spec.ts @@ -1,5 +1,8 @@ -import { describe, it, expect } from 'vitest'; +import { describe, it, expect, afterEach, beforeEach, vi } from 'vitest'; import { Command } from 'commander'; +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; import { registerMacpCommand } from './cli.js'; describe('registerMacpCommand', () => { @@ -75,3 +78,162 @@ describe('registerMacpCommand', () => { expect(topLevel).toContain('events'); }); }); + +/** + * RI-N2 fail-closed CLI behavior: an unimplemented capability is a failure, + * never a success. Every stub exits nonzero with a typed message, and the + * implemented `macp gate` mirrors the typed gate-runner states. + */ +describe('registerMacpCommand fail-closed (RI-N2)', () => { + let tmpDir: string; + + function buildProgram(): Command { + const program = new Command(); + program.exitOverride(); + program.configureOutput({ writeErr: () => {} }); + registerMacpCommand(program); + return program; + } + + beforeEach(() => { + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'macp-cli-failclosed-')); + process.exitCode = 0; + }); + + afterEach(() => { + process.exitCode = 0; + fs.rmSync(tmpDir, { recursive: true, force: true }); + }); + + it('macp tasks list exits nonzero (unimplemented capability)', async () => { + const program = buildProgram(); + await program.parseAsync(['macp', 'tasks', 'list'], { from: 'user' }); + expect(process.exitCode).not.toBe(0); + }); + + it('macp submit exits nonzero with a typed MACP_NOT_IMPLEMENTED message', async () => { + const program = buildProgram(); + const errSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); + try { + await program.parseAsync(['macp', 'submit', 'spec.json'], { from: 'user' }); + expect(process.exitCode).not.toBe(0); + const errText = errSpy.mock.calls.map((c) => String(c[0])).join('\n'); + expect(errText).toContain('MACP_NOT_IMPLEMENTED'); + } finally { + errSpy.mockRestore(); + } + }); + + it('macp events tail exits nonzero (unimplemented capability)', async () => { + const program = buildProgram(); + await program.parseAsync(['macp', 'events', 'tail'], { from: 'user' }); + expect(process.exitCode).not.toBe(0); + }); + + it('macp gate runs a green inline command and exits 0', async () => { + const program = buildProgram(); + await program.parseAsync( + [ + 'macp', + 'gate', + 'exit 0', + '--cwd', + tmpDir, + '--log', + path.join(tmpDir, 'g.log'), + '--timeout', + '10', + ], + { from: 'user' }, + ); + expect(process.exitCode).toBe(0); + }); + + it('macp gate exits nonzero on a failing command', async () => { + const program = buildProgram(); + await program.parseAsync( + [ + 'macp', + 'gate', + 'exit 9', + '--cwd', + tmpDir, + '--log', + path.join(tmpDir, 'g.log'), + '--timeout', + '10', + ], + { from: 'user' }, + ); + expect(process.exitCode).not.toBe(0); + }); + + it('macp gate with an unimplemented ci-pipeline capability exits nonzero', async () => { + const program = buildProgram(); + const specPath = path.join(tmpDir, 'gates.json'); + fs.writeFileSync(specPath, JSON.stringify([{ type: 'ci-pipeline' }])); + await program.parseAsync( + [ + 'macp', + 'gate', + specPath, + '--cwd', + tmpDir, + '--log', + path.join(tmpDir, 'g.log'), + '--timeout', + '10', + ], + { from: 'user' }, + ); + expect(process.exitCode).not.toBe(0); + }); + + it('macp gate --simulate completes (exit 0) but reports simulated results', async () => { + const program = buildProgram(); + const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {}); + try { + await program.parseAsync( + [ + 'macp', + 'gate', + 'exit 0', + '--simulate', + '--cwd', + tmpDir, + '--log', + path.join(tmpDir, 'g.log'), + '--timeout', + '10', + ], + { from: 'user' }, + ); + // completes only because the caller explicitly asked to simulate + expect(process.exitCode).toBe(0); + const outText = logSpy.mock.calls.map((c) => String(c[0])).join('\n'); + expect(outText).toContain('simulated'); + expect(outText).toContain('SIMULATED'); + } finally { + logSpy.mockRestore(); + } + }); + + it('macp gate with an empty spec exits nonzero with a typed error', async () => { + const program = buildProgram(); + await program.parseAsync( + [ + 'macp', + 'gate', + ' ', + '--cwd', + tmpDir, + '--log', + path.join(tmpDir, 'g.log'), + '--timeout', + '10', + ], + { from: 'user' }, + ); + expect(process.exitCode).not.toBe(0); + }); +}); diff --git a/packages/macp/src/cli.ts b/packages/macp/src/cli.ts index d4e26940..cfeb2d6e 100644 --- a/packages/macp/src/cli.ts +++ b/packages/macp/src/cli.ts @@ -1,5 +1,73 @@ +import { existsSync, readFileSync } from 'node:fs'; + import type { Command } from 'commander'; +import { runGates } from './gate-runner.js'; +import { MACPCapabilityError, type MacpErrorCode } from './errors.js'; + +/** + * Load gates from a spec: an existing file (JSON gates array, a JSON object + * with `quality_gates`, a JSON gate object, or one command per line) or an + * inline command string. Fails closed with a typed capability error when the + * spec contains no executable gate definition. + */ +function loadGateSpec(spec: string): unknown[] { + if (existsSync(spec)) { + const raw = readFileSync(spec, 'utf-8'); + try { + const parsed = JSON.parse(raw) as unknown; + if (Array.isArray(parsed)) { + if (parsed.length === 0) { + throw new MACPCapabilityError( + 'MACP_NO_COMMAND', + 'gate-spec', + `gate spec file '${spec}' contains an empty gates array`, + ); + } + return parsed; + } + if (typeof parsed === 'object' && parsed !== null) { + const obj = parsed as Record; + if (Array.isArray(obj['quality_gates'])) { + return obj['quality_gates']; + } + return [parsed]; + } + throw new MACPCapabilityError( + 'MACP_NO_COMMAND', + 'gate-spec', + `gate spec file '${spec}' parsed to ${typeof parsed} — expected a gates array, a task with quality_gates, or a gate object`, + ); + } catch (exc) { + if (exc instanceof MACPCapabilityError) throw exc; + // Not JSON — treat each non-empty line as a command gate. + const lines = raw + .split('\n') + .map((l) => l.trim()) + .filter((l) => l.length > 0); + if (lines.length > 0) return lines; + throw new MACPCapabilityError( + 'MACP_NO_COMMAND', + 'gate-spec', + `gate spec file '${spec}' contains no gates`, + ); + } + } + if (spec.trim().length > 0) return [spec]; + throw new MACPCapabilityError('MACP_NO_COMMAND', 'gate-spec', 'gate spec is empty'); +} + +/** Print a typed not-implemented failure and exit nonzero (RI-N2 fail-closed). */ +function notImplemented(subcommand: string, capability: string, hint: string): void { + const err = new MACPCapabilityError( + 'MACP_NOT_IMPLEMENTED', + capability, + `${subcommand} is not implemented in @mosaicstack/macp yet (${capability} capability absent) — ${hint}`, + ); + console.error(`[macp] ${subcommand}: ${err.message} [${err.code}]`); + process.exitCode = 1; +} + /** * Register macp subcommands on an existing Commander program. * This avoids cross-package Commander version mismatches by using the @@ -24,15 +92,14 @@ export function registerMacpCommand(parent: Command): void { 'Filter by task type (coding|deploy|research|review|documentation|infrastructure)', ) .action((opts: { status?: string; type?: string }) => { - // not yet wired — task persistence layer is not present in @mosaicstack/macp - console.log('[macp] tasks list: not yet wired — use macp package programmatically'); + // unimplemented capability — a failure, never a success (RI-N2) if (opts.status) { console.log(` status filter: ${opts.status}`); } if (opts.type) { console.log(` type filter: ${opts.type}`); } - process.exitCode = 0; + notImplemented('tasks list', 'task-persistence', 'use the macp package programmatically'); }); // ─── submit ────────────────────────────────────────────────────────────── @@ -41,12 +108,11 @@ export function registerMacpCommand(parent: Command): void { .command('submit ') .description('Submit a task from a JSON/YAML spec file') .action((specPath: string) => { - // not yet wired — task submission requires a running MACP server - console.log('[macp] submit: not yet wired — use macp package programmatically'); + // unimplemented capability — a failure, never a success (RI-N2) console.log(` spec path: ${specPath}`); console.log(' task id: (unavailable — no MACP server connected)'); console.log(' status: (unavailable — no MACP server connected)'); - process.exitCode = 0; + notImplemented('submit', 'macp-server', 'use the macp package programmatically'); }); // ─── gate ──────────────────────────────────────────────────────────────── @@ -58,16 +124,58 @@ export function registerMacpCommand(parent: Command): void { .option('--cwd ', 'Working directory for gate execution', process.cwd()) .option('--log ', 'Path to write gate log output', '/tmp/macp-gate.log') .option('--timeout ', 'Gate timeout in seconds', '60') - .action((spec: string, opts: { failOn: string; cwd: string; log: string; timeout: string }) => { - // not yet wired — gate execution requires a task context and event sink - console.log('[macp] gate: not yet wired — use macp package programmatically'); - console.log(` spec: ${spec}`); - console.log(` fail-on: ${opts.failOn}`); - console.log(` cwd: ${opts.cwd}`); - console.log(` log: ${opts.log}`); - console.log(` timeout: ${opts.timeout}s`); - process.exitCode = 0; - }); + .option( + '--simulate', + 'Simulate gates instead of executing them; results are typed simulated and never satisfy a check', + ) + .action( + ( + spec: string, + opts: { failOn: string; cwd: string; log: string; timeout: string; simulate?: boolean }, + ) => { + let gates: unknown[]; + try { + gates = loadGateSpec(spec); + } catch (exc) { + if (exc instanceof MACPCapabilityError) { + console.error(`[macp] gate: ${exc.message} [${exc.code}]`); + } else { + console.error(`[macp] gate: ${String(exc)}`); + } + process.exitCode = 1; + return; + } + + const timeoutSec = Number.parseInt(opts.timeout, 10) || 60; + const eventsPath = `${opts.log}.events.ndjson`; + const { state, gateResults } = runGates( + gates, + opts.cwd, + opts.log, + timeoutSec, + eventsPath, + 'macp-cli-gate', + { + simulate: opts.simulate, + }, + ); + + for (const r of gateResults) { + const label = r.command || r.type; + const reason = r.reason ? ` — ${r.reason}` : ''; + console.log(`[macp] gate ${r.status}: ${label}${reason}`); + } + if (opts.simulate) { + console.log( + '[macp] SIMULATED run — every result is typed simulated and can never satisfy a gate, dependency, or release check', + ); + } + + // Simulated runs may complete (exit 0) only because the caller + // explicitly passed --simulate; the typed state stays 'simulated'. + process.exitCode = state === 'passed' || state === 'simulated' ? 0 : 1; + }, + ); // ─── events ────────────────────────────────────────────────────────────── @@ -79,14 +187,16 @@ export function registerMacpCommand(parent: Command): void { .option('--file ', 'Path to the MACP events NDJSON file') .option('--follow', 'Follow the file for new events (like tail -f)') .action((opts: { file?: string; follow?: boolean }) => { - // not yet wired — event streaming requires a live event source - console.log('[macp] events tail: not yet wired — use macp package programmatically'); + // unimplemented capability — a failure, never a success (RI-N2) if (opts.file) { console.log(` file: ${opts.file}`); } if (opts.follow) { console.log(' mode: follow'); } - process.exitCode = 0; + notImplemented('events tail', 'event-source', 'use the macp package programmatically'); }); } + +// Re-export so CLI consumers can surface typed capability codes. +export type { MacpErrorCode }; diff --git a/packages/macp/src/errors.ts b/packages/macp/src/errors.ts new file mode 100644 index 00000000..de5aae35 --- /dev/null +++ b/packages/macp/src/errors.ts @@ -0,0 +1,35 @@ +/** Typed error code from the closed MACP_ERROR_CODES set. */ +export type MacpErrorCode = (typeof MACP_ERROR_CODES)[number]; +/** + * Typed fail-closed capability errors (RI-N2, SDLC-D-035). + * + * MACP must fail closed when a required capability (executor, reviewer, + * command, CI provider, human authority) is absent. These typed codes mirror + * the Forge failure vocabulary (FORGE_NO_*) so both packages speak the same + * language: an unimplemented capability is a failure, never a stub success. + */ + +/** Closed set of typed MACP capability error codes. */ +export const MACP_ERROR_CODES = [ + 'MACP_NOT_IMPLEMENTED', + 'MACP_NO_COMMAND', + 'MACP_NO_REVIEWER', + 'MACP_NO_CI_PIPELINE', + 'MACP_NO_PROVIDER', + 'MACP_AUTHORITY_REQUIRED', +] as const; + +/** Raised when a required capability is missing and execution must fail closed. */ +export class MACPCapabilityError extends Error { + /** Typed error code from the closed MACP_ERROR_CODES set. */ + readonly code: MacpErrorCode; + /** The missing capability, e.g. `ci-provider`, `task-persistence`, `command`. */ + readonly capability: string; + + constructor(code: MacpErrorCode, capability: string, message: string) { + super(message); + this.name = 'MACPCapabilityError'; + this.code = code; + this.capability = capability; + } +} diff --git a/packages/macp/src/gate-runner.spec.ts b/packages/macp/src/gate-runner.spec.ts new file mode 100644 index 00000000..45c01a5d --- /dev/null +++ b/packages/macp/src/gate-runner.spec.ts @@ -0,0 +1,429 @@ +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; + +import { countAIFindings, normalizeGate, runGate, runGates } from './gate-runner.js'; + +function makeTmpDir(): string { + return fs.mkdtempSync(path.join(os.tmpdir(), 'macp-gate-')); +} + +describe('normalizeGate', () => { + it('normalizes a string to mechanical gate', () => { + expect(normalizeGate('echo test')).toEqual({ + command: 'echo test', + type: 'mechanical', + fail_on: 'blocker', + }); + }); + + it('normalizes an object gate with defaults', () => { + expect(normalizeGate({ command: 'lint' })).toEqual({ + command: 'lint', + type: 'mechanical', + fail_on: 'blocker', + }); + }); + + it('preserves explicit type and fail_on', () => { + expect(normalizeGate({ command: 'review', type: 'ai-review', fail_on: 'any' })).toEqual({ + command: 'review', + type: 'ai-review', + fail_on: 'any', + }); + }); + + it('handles non-string/non-object input', () => { + expect(normalizeGate(42)).toEqual({ command: '', type: 'mechanical', fail_on: 'blocker' }); + expect(normalizeGate(null)).toEqual({ command: '', type: 'mechanical', fail_on: 'blocker' }); + }); +}); + +describe('countAIFindings', () => { + it('returns zeros for non-object', () => { + expect(countAIFindings(null)).toEqual({ blockers: 0, total: 0 }); + expect(countAIFindings('string')).toEqual({ blockers: 0, total: 0 }); + expect(countAIFindings([])).toEqual({ blockers: 0, total: 0 }); + }); + + it('counts from stats block', () => { + const output = { stats: { blockers: 2, should_fix: 3, suggestions: 1 } }; + expect(countAIFindings(output)).toEqual({ blockers: 2, total: 6 }); + }); + + it('counts from findings array when stats has no blockers', () => { + const output = { + stats: { blockers: 0 }, + findings: [{ severity: 'blocker' }, { severity: 'warning' }, { severity: 'blocker' }], + }; + expect(countAIFindings(output)).toEqual({ blockers: 2, total: 3 }); + }); + + it('uses stats blockers over findings array when stats has blockers', () => { + const output = { + stats: { blockers: 5 }, + findings: [{ severity: 'blocker' }, { severity: 'warning' }], + }; + // stats.blockers = 5, total from stats = 5+0+0 = 5, findings not used for total since stats total is non-zero + expect(countAIFindings(output)).toEqual({ blockers: 5, total: 5 }); + }); + + it('counts findings length as total when stats has zero total', () => { + const output = { + findings: [{ severity: 'warning' }, { severity: 'info' }], + }; + expect(countAIFindings(output)).toEqual({ blockers: 0, total: 2 }); + }); +}); + +describe('runGate', () => { + let tmp: string; + let logPath: string; + + beforeEach(() => { + tmp = makeTmpDir(); + logPath = path.join(tmp, 'gate.log'); + }); + + afterEach(() => { + fs.rmSync(tmp, { recursive: true, force: true }); + }); + + it('passes mechanical gate on exit 0', () => { + const result = runGate('echo hello', tmp, logPath, 30); + expect(result.passed).toBe(true); + expect(result.exit_code).toBe(0); + expect(result.type).toBe('mechanical'); + expect(result.output).toContain('hello'); + }); + + it('fails mechanical gate on non-zero exit', () => { + const result = runGate('exit 1', tmp, logPath, 30); + expect(result.passed).toBe(false); + expect(result.exit_code).toBe(1); + }); + + it('ci-pipeline fails closed without a CI provider (no placeholder pass)', () => { + const result = runGate({ command: 'anything', type: 'ci-pipeline' }, tmp, logPath, 30); + expect(result.passed).toBe(false); + expect(result.status).toBe('capability_failure'); + expect(result.capability_code).toBe('MACP_NO_CI_PIPELINE'); + expect(result.type).toBe('ci-pipeline'); + expect(result.output).not.toBe('CI pipeline gate placeholder'); + }); + + it('empty command is a typed capability failure, never a pass', () => { + const result = runGate({ command: '' }, tmp, logPath, 30); + expect(result.passed).toBe(false); + expect(result.status).toBe('capability_failure'); + expect(result.capability_code).toBe('MACP_NO_COMMAND'); + }); + + it('ai-review gate parses JSON output', () => { + const json = JSON.stringify({ stats: { blockers: 0, should_fix: 1 } }); + const result = runGate({ command: `echo '${json}'`, type: 'ai-review' }, tmp, logPath, 30); + expect(result.passed).toBe(true); + expect(result.blockers).toBe(0); + expect(result.findings).toBe(1); + }); + + it('ai-review gate fails on blockers', () => { + const json = JSON.stringify({ stats: { blockers: 2 } }); + const result = runGate({ command: `echo '${json}'`, type: 'ai-review' }, tmp, logPath, 30); + expect(result.passed).toBe(false); + expect(result.blockers).toBe(2); + }); + + it('ai-review gate with fail_on=any fails on any findings', () => { + const json = JSON.stringify({ stats: { blockers: 0, should_fix: 1 } }); + const result = runGate( + { command: `echo '${json}'`, type: 'ai-review', fail_on: 'any' }, + tmp, + logPath, + 30, + ); + expect(result.passed).toBe(false); + expect(result.fail_on).toBe('any'); + }); + + it('ai-review gate fails on invalid JSON output', () => { + const result = runGate({ command: 'echo "not json"', type: 'ai-review' }, tmp, logPath, 30); + expect(result.passed).toBe(false); + expect(result.parse_error).toBeDefined(); + }); + + it('writes to log file', () => { + runGate('echo logged', tmp, logPath, 30); + const log = fs.readFileSync(logPath, 'utf-8'); + expect(log).toContain('COMMAND: echo logged'); + expect(log).toContain('logged'); + expect(log).toContain('EXIT:'); + }); +}); + +describe('runGates', () => { + let tmp: string; + let logPath: string; + let eventsPath: string; + + beforeEach(() => { + tmp = makeTmpDir(); + logPath = path.join(tmp, 'gates.log'); + eventsPath = path.join(tmp, 'events.ndjson'); + }); + + afterEach(() => { + fs.rmSync(tmp, { recursive: true, force: true }); + }); + + it('runs multiple gates and returns results', () => { + const { allPassed, gateResults } = runGates( + ['echo one', 'echo two'], + tmp, + logPath, + 30, + eventsPath, + 'task-1', + ); + expect(allPassed).toBe(true); + expect(gateResults).toHaveLength(2); + }); + + it('reports failure when any gate fails', () => { + const { allPassed, gateResults } = runGates( + ['echo ok', 'exit 1'], + tmp, + logPath, + 30, + eventsPath, + 'task-2', + ); + expect(allPassed).toBe(false); + expect(gateResults[0]!.passed).toBe(true); + expect(gateResults[1]!.passed).toBe(false); + }); + + it('emits events for each gate', () => { + runGates(['echo test'], tmp, logPath, 30, eventsPath, 'task-3'); + const events = fs + .readFileSync(eventsPath, 'utf-8') + .trim() + .split('\n') + .map((l) => JSON.parse(l)); + expect(events).toHaveLength(2); // started + passed + expect(events[0].event_type).toBe('rail.check.started'); + expect(events[1].event_type).toBe('rail.check.passed'); + }); + + it('does not silently skip gates with empty command — they become capability failures', () => { + const { gateResults, allPassed, state } = runGates( + [{ command: '', type: 'mechanical' }, 'echo real'], + tmp, + logPath, + 30, + eventsPath, + 'task-4', + ); + expect(gateResults).toHaveLength(2); + expect(gateResults[0]!.status).toBe('capability_failure'); + expect(gateResults[1]!.status).toBe('passed'); + expect(allPassed).toBe(false); + expect(state).toBe('capability_failure'); + }); + + it('does not skip ci-pipeline even with empty command — typed capability failure', () => { + const { gateResults, allPassed, state } = runGates( + [{ command: '', type: 'ci-pipeline' }], + tmp, + logPath, + 30, + eventsPath, + 'task-5', + ); + expect(gateResults).toHaveLength(1); + expect(gateResults[0]!.passed).toBe(false); + expect(gateResults[0]!.status).toBe('capability_failure'); + expect(allPassed).toBe(false); + expect(state).toBe('capability_failure'); + }); + + it('emits failed event with correct message', () => { + runGates(['exit 42'], tmp, logPath, 30, eventsPath, 'task-6'); + const events = fs + .readFileSync(eventsPath, 'utf-8') + .trim() + .split('\n') + .map((l) => JSON.parse(l)); + const failEvent = events.find( + (e: Record) => e.event_type === 'rail.check.failed', + ); + expect(failEvent).toBeDefined(); + expect(failEvent.message).toContain('Gate failed ('); + }); +}); + +/** + * RI-N2 / SDLC-D-035 fail-closed controls for the MACP gate runner. + * + * Invariant under test: `passed: true` occurs ONLY when a gate really executed + * and really exited green (`status === 'passed'`). Absent capabilities, + * manual sign-offs, and simulated runs are typed distinctly and can never + * make the aggregate `passed`. + */ +describe('gate-runner fail-closed (RI-N2)', () => { + let tmpDir: string; + let logPath: string; + let eventsPath: string; + + beforeEach(() => { + tmpDir = makeTmpDir(); + logPath = path.join(tmpDir, 'gate.log'); + eventsPath = path.join(tmpDir, 'events.ndjson'); + }); + + afterEach(() => { + fs.rmSync(tmpDir, { recursive: true, force: true }); + }); + + function run(gates: unknown[], options?: { simulate?: boolean }) { + return runGates(gates, tmpDir, logPath, 10, eventsPath, 'spec-task', options); + } + + // ─── positive controls ─────────────────────────────────────────────────── + + it('a really-executed green command gate still passes', () => { + const result = run([{ command: 'exit 0', type: 'mechanical' }]); + expect(result.gateResults[0]!.status).toBe('passed'); + expect(result.gateResults[0]!.passed).toBe(true); + expect(result.allPassed).toBe(true); + expect(result.state).toBe('passed'); + }); + + it('explicit simulate completes and types every result simulated', () => { + const result = run([{ command: 'exit 0', type: 'mechanical' }, 'echo hello'], { + simulate: true, + }); + expect(result.gateResults).toHaveLength(2); + for (const gate of result.gateResults) { + expect(gate.status).toBe('simulated'); + expect(gate.passed).toBe(false); + } + expect(result.state).toBe('simulated'); + }); + + it('a really-executed red command gate fails with typed status failed', () => { + const result = run([{ command: 'exit 3', type: 'mechanical' }]); + expect(result.gateResults[0]!.status).toBe('failed'); + expect(result.gateResults[0]!.passed).toBe(false); + expect(result.allPassed).toBe(false); + expect(result.state).toBe('failed'); + }); + + // ─── negative controls — each asserts typed status AND aggregate not passed ── + + it('an empty-command gate is a capability_failure, not skipped and not passed', () => { + const result = run([{ command: '', type: 'mechanical' }]); + // runGates must not silently skip it — it produces a typed result + expect(result.gateResults).toHaveLength(1); + const gate = result.gateResults[0]!; + expect(gate.status).toBe('capability_failure'); + expect(gate.capability_code).toBe('MACP_NO_COMMAND'); + expect(gate.passed).toBe(false); + // aggregate is not passed + expect(result.allPassed).toBe(false); + expect(result.state).toBe('capability_failure'); + expect(result.state).not.toBe('passed'); + }); + + it('a commandless ai-review gate is a typed MACP_NO_REVIEWER capability_failure', () => { + const result = run([{ command: '', type: 'ai-review' }]); + expect(result.gateResults[0]!.status).toBe('capability_failure'); + expect(result.gateResults[0]!.capability_code).toBe('MACP_NO_REVIEWER'); + expect(result.allPassed).toBe(false); + expect(result.state).not.toBe('passed'); + }); + + it('a ci-pipeline gate without a provider implementation is a capability_failure, never a placeholder pass', () => { + const result = run([{ command: '', type: 'ci-pipeline' }]); + const gate = result.gateResults[0]!; + expect(gate.status).toBe('capability_failure'); + expect(gate.capability_code).toBe('MACP_NO_CI_PIPELINE'); + expect(gate.passed).toBe(false); + // the old false-success placeholder must be gone + expect(gate.output).not.toBe('CI pipeline gate placeholder'); + expect(result.allPassed).toBe(false); + expect(result.state).not.toBe('passed'); + }); + + it('a ci-pipeline gate fails closed even alongside an otherwise green run', () => { + const result = run(['exit 0', { type: 'ci-pipeline', command: 'fake-ci' }]); + expect(result.gateResults[1]!.status).toBe('capability_failure'); + expect(result.gateResults[0]!.status).toBe('passed'); + expect(result.allPassed).toBe(false); + expect(result.state).toBe('capability_failure'); + }); + + it('a manual gate with no automation enters typed waiting — neither pass nor fail', () => { + const result = run([{ type: 'manual' }]); + const gate = result.gateResults[0]!; + expect(gate.status).toBe('waiting'); + expect(gate.passed).toBe(false); + expect(gate.exit_code).toBe(0); + // aggregate is not passed while any gate is waiting + expect(result.allPassed).toBe(false); + expect(result.state).toBe('waiting'); + expect(result.state).not.toBe('passed'); + }); + + it('a simulated result can never make the aggregate passed', () => { + const result = run(['exit 0', 'exit 0'], { simulate: true }); + expect(result.gateResults.every((g) => g.status === 'simulated')).toBe(true); + expect(result.allPassed).toBe(false); + expect(result.state).toBe('simulated'); + expect(result.state).not.toBe('passed'); + }); + + it('waiting dominates an otherwise green aggregate', () => { + const result = run(['exit 0', { type: 'manual' }]); + expect(result.allPassed).toBe(false); + expect(result.state).toBe('waiting'); + }); +}); + +describe('runGate fail-closed (RI-N2)', () => { + let tmpDir: string; + let logPath: string; + + beforeEach(() => { + tmpDir = makeTmpDir(); + logPath = path.join(tmpDir, 'gate.log'); + }); + + afterEach(() => { + fs.rmSync(tmpDir, { recursive: true, force: true }); + }); + + it('simulate: true returns a typed simulated result without executing', () => { + const result = runGate('this-command-does-not-exist-xyz', tmpDir, logPath, 10, { + simulate: true, + }); + expect(result.status).toBe('simulated'); + expect(result.passed).toBe(false); + expect(result.exit_code).toBe(0); + }); + + it('normal mode executes for real and types a green gate passed', () => { + const result = runGate('echo ok', tmpDir, logPath, 10); + expect(result.status).toBe('passed'); + expect(result.passed).toBe(true); + expect(result.output).toContain('ok'); + }); + + it('a bare string gate normalizes to mechanical and executes', () => { + const result = runGate('exit 7', tmpDir, logPath, 10); + expect(result.type).toBe('mechanical'); + expect(result.status).toBe('failed'); + expect(result.passed).toBe(false); + }); +}); diff --git a/packages/macp/src/gate-runner.ts b/packages/macp/src/gate-runner.ts index b59f8078..1ba2266f 100644 --- a/packages/macp/src/gate-runner.ts +++ b/packages/macp/src/gate-runner.ts @@ -4,7 +4,20 @@ import { dirname } from 'node:path'; import { emitEvent } from './event-emitter.js'; import { nowISO } from './event-emitter.js'; -import type { GateResult } from './types.js'; +import type { GateResult, GateStatus, RunGatesResult } from './types.js'; + +/** Typed reason stamped on every simulated gate result. */ +export const SIMULATED_GATE_REASON = + 'simulated execution (explicit simulate opt-in): gate was not evaluated by a real implementation'; + +/** Options for gate execution (RI-N2 fail-closed / explicit simulation). */ +export interface RunGateOptions { + /** + * Explicit caller opt-in to simulation. Simulated gates are NOT executed; + * every result is typed `simulated` and never satisfies anything. + */ + simulate?: boolean; +} export interface NormalizedGate { command: string; @@ -103,36 +116,91 @@ export function countAIFindings(parsedOutput: unknown): { blockers: number; tota return { blockers, total }; } +function simulatedResult(gateEntry: NormalizedGate): GateResult { + return { + command: gateEntry.command, + exit_code: 0, + type: gateEntry.type, + output: SIMULATED_GATE_REASON, + timed_out: false, + passed: false, + status: 'simulated', + reason: SIMULATED_GATE_REASON, + }; +} + +function capabilityFailureResult( + gateEntry: NormalizedGate, + code: GateResult['capability_code'], + reason: string, +): GateResult { + return { + command: gateEntry.command, + exit_code: 1, + type: gateEntry.type, + output: '', + timed_out: false, + passed: false, + status: 'capability_failure', + capability_code: code, + reason, + }; +} + +function waitingResult(gateEntry: NormalizedGate, reason: string): GateResult { + return { + command: gateEntry.command, + exit_code: 0, + type: gateEntry.type, + output: '', + timed_out: false, + passed: false, + status: 'waiting', + capability_code: 'MACP_AUTHORITY_REQUIRED', + reason, + }; +} + export function runGate( gate: unknown, cwd: string, logPath: string, timeoutSec: number, + options: RunGateOptions = {}, ): GateResult { const gateEntry = normalizeGate(gate); const gateType = gateEntry.type; const command = gateEntry.command; + // Explicit simulation only: never executes, typed simulated, never satisfying. + if (options.simulate) { + return simulatedResult(gateEntry); + } + + // Fail closed: no CI provider implementation exists in @mosaicstack/macp, + // so a ci-pipeline gate is an absent capability — never a placeholder pass. if (gateType === 'ci-pipeline') { - return { - command, - exit_code: 0, - type: gateType, - output: 'CI pipeline gate placeholder', - timed_out: false, - passed: true, - }; + return capabilityFailureResult( + gateEntry, + 'MACP_NO_CI_PIPELINE', + `ci-pipeline gate '${gateEntry.command || gateType}' has no CI provider implementation wired — refusing placeholder pass`, + ); } if (!command) { - return { - command: '', - exit_code: 0, - type: gateType, - output: '', - timed_out: false, - passed: true, - }; + // A manual gate with no automation waits for human sign-off: not pass, not fail. + if (gateType === 'manual') { + return waitingResult( + gateEntry, + `manual gate has no automation — waiting for human sign-off (type: ${gateType})`, + ); + } + // Any other commandless gate is an absent capability — never a vacuous pass. + return capabilityFailureResult( + gateEntry, + gateType === 'ai-review' ? 'MACP_NO_REVIEWER' : 'MACP_NO_COMMAND', + `gate of type '${gateType}' has no command to execute — refusing empty-command pass`, + ); } const { exitCode, output, timedOut } = runShell(command, cwd, logPath, timeoutSec); @@ -143,10 +211,12 @@ export function runGate( output, timed_out: timedOut, passed: false, + status: 'failed', }; if (gateType !== 'ai-review') { result.passed = exitCode === 0; + result.status = result.passed ? 'passed' : 'failed'; return result; } @@ -170,6 +240,7 @@ export function runGate( } else { result.passed = exitCode === 0 && blockers === 0 && !timedOut && parseError === undefined; } + result.status = result.passed ? 'passed' : 'failed'; result.fail_on = failOn; result.blockers = blockers; @@ -191,16 +262,19 @@ export function runGates( timeoutSec: number, eventsPath: string, taskId: string, -): { allPassed: boolean; gateResults: GateResult[] } { - let allPassed = true; + options: RunGateOptions = {}, +): RunGatesResult { const gateResults: GateResult[] = []; + let hasCapabilityFailure = false; + let hasSimulated = false; + let hasFailed = false; + let hasWaiting = false; for (const gate of gates) { const gateEntry = normalizeGate(gate); const gateCmd = gateEntry.command; - if (!gateCmd && gateEntry.type !== 'ci-pipeline') continue; - const label = gateCmd || gateEntry.type; + // NOTE: no silent skip — every gate produces a typed result (RI-N2). emitEvent( eventsPath, 'rail.check.started', @@ -209,10 +283,10 @@ export function runGates( 'quality-gate', `Running gate: ${label}`, ); - const result = runGate(gate, cwd, logPath, timeoutSec); + const result = runGate(gate, cwd, logPath, timeoutSec, options); gateResults.push(result); - if (result.passed) { + if (result.status === 'passed') { emitEvent( eventsPath, 'rail.check.passed', @@ -224,7 +298,46 @@ export function runGates( continue; } - allPassed = false; + if (result.status === 'waiting') { + hasWaiting = true; + emitEvent( + eventsPath, + 'rail.check.waiting', + taskId, + 'gated', + 'quality-gate', + `Gate waiting: ${label} — ${result.reason ?? 'manual gate awaits sign-off'}`, + ); + continue; + } + + if (result.status === 'simulated') { + hasSimulated = true; + emitEvent( + eventsPath, + 'rail.check.simulated', + taskId, + 'gated', + 'quality-gate', + `Gate simulated (non-satisfying): ${label}`, + ); + continue; + } + + if (result.status === 'capability_failure') { + hasCapabilityFailure = true; + emitEvent( + eventsPath, + 'rail.check.failed', + taskId, + 'gated', + 'quality-gate', + `Gate capability failure (${result.capability_code ?? 'MACP_NO_PROVIDER'}): ${label} — ${result.reason ?? 'required capability is absent'}`, + ); + continue; + } + + hasFailed = true; let message: string; if (result.timed_out) { message = `Gate timed out after ${timeoutSec}s: ${label}`; @@ -236,5 +349,15 @@ export function runGates( emitEvent(eventsPath, 'rail.check.failed', taskId, 'gated', 'quality-gate', message); } - return { allPassed, gateResults }; + const state: GateStatus = hasCapabilityFailure + ? 'capability_failure' + : hasSimulated + ? 'simulated' + : hasFailed + ? 'failed' + : hasWaiting + ? 'waiting' + : 'passed'; + + return { allPassed: state === 'passed', gateResults, state }; } diff --git a/packages/macp/src/index.ts b/packages/macp/src/index.ts index a510b9a5..ad809d65 100644 --- a/packages/macp/src/index.ts +++ b/packages/macp/src/index.ts @@ -6,11 +6,13 @@ export type { DependsOnPolicy, GateType, GateFailOn, + GateStatus, GateEntry, Task, EventType, MACPEvent, GateResult, + RunGatesResult, TaskResult, ProviderMeta, ProviderRegistry, @@ -18,6 +20,11 @@ export type { export { CredentialError } from './types.js'; +// Typed fail-closed capability errors (RI-N2, SDLC-D-035) +export { MACP_ERROR_CODES, MACPCapabilityError } from './errors.js'; + +export type { MacpErrorCode } from './errors.js'; + // Credential resolver export { DEFAULT_CREDENTIALS_DIR, @@ -35,9 +42,16 @@ export { export type { ResolveCredentialsOptions } from './credential-resolver.js'; // Gate runner -export { normalizeGate, runShell, countAIFindings, runGate, runGates } from './gate-runner.js'; +export { + normalizeGate, + runShell, + countAIFindings, + runGate, + runGates, + SIMULATED_GATE_REASON, +} from './gate-runner.js'; -export type { NormalizedGate } from './gate-runner.js'; +export type { NormalizedGate, RunGateOptions } from './gate-runner.js'; // Risk-floor (agent reflection loop — diff review classifier) export { evaluateRiskFloor, DEFAULT_RISK_THRESHOLD } from './risk-floor.js'; diff --git a/packages/macp/src/types.ts b/packages/macp/src/types.ts index 7e7b0d01..a8418ecc 100644 --- a/packages/macp/src/types.ts +++ b/packages/macp/src/types.ts @@ -1,3 +1,5 @@ +import type { MacpErrorCode } from './errors.js'; + /** Task status values. */ export type TaskStatus = 'pending' | 'running' | 'gated' | 'completed' | 'failed' | 'escalated'; @@ -17,7 +19,17 @@ export type DispatchMode = 'yolo' | 'acp' | 'exec'; export type DependsOnPolicy = 'all' | 'any' | 'all_terminal'; /** Quality gate type. */ -export type GateType = 'mechanical' | 'ai-review' | 'ci-pipeline'; +export type GateType = 'mechanical' | 'ai-review' | 'ci-pipeline' | 'manual'; + +/** + * Typed execution state of a gate — closed set (RI-N2, SDLC-D-035). + * + * Only `passed` means "really executed and green". `simulated` is produced + * exclusively under an explicit simulate opt-in and never satisfies anything. + * `capability_failure` means a required executor/provider/command was absent. + * `waiting` means a manual gate awaits human sign-off (neither pass nor fail). + */ +export type GateStatus = 'passed' | 'failed' | 'simulated' | 'waiting' | 'capability_failure'; /** Gate fail_on mode. */ export type GateFailOn = 'blocker' | 'any'; @@ -67,7 +79,9 @@ export type EventType = | 'task.retry.scheduled' | 'rail.check.started' | 'rail.check.passed' - | 'rail.check.failed'; + | 'rail.check.failed' + | 'rail.check.waiting' + | 'rail.check.simulated'; /** Structured event record. */ export interface MACPEvent { @@ -88,7 +102,14 @@ export interface GateResult { type: string; output: string; timed_out: boolean; + /** Back-compat boolean view — true ONLY when `status === 'passed'`. */ passed: boolean; + /** Typed discriminator — the authoritative gate outcome (RI-N2). */ + status: GateStatus; + /** Typed capability error code, set when `status === 'capability_failure'`. */ + capability_code?: MacpErrorCode; + /** Why a non-executed state (simulated/waiting/capability_failure) was reached. */ + reason?: string; fail_on?: string; blockers?: number; findings?: number; @@ -96,6 +117,22 @@ export interface GateResult { parse_error?: string; } +/** + * Aggregate outcome of `runGates` (RI-N2). + * + * `state` is the typed aggregate: it is `passed` only when every gate really + * executed green. A `simulated` result makes the aggregate `simulated` (never + * `passed`); a `waiting` manual gate keeps the aggregate `waiting`; a missing + * capability makes it `capability_failure`. `allPassed` is exactly + * `state === 'passed'`, so a simulated or waiting result can never satisfy a + * dependency, acceptance criterion, gate, merge, or release check. + */ +export interface RunGatesResult { + allPassed: boolean; + gateResults: GateResult[]; + state: GateStatus; +} + /** Result from a completed task. */ export interface TaskResult { task_id: string; diff --git a/packages/mosaic/framework/adapters/pi.md b/packages/mosaic/framework/adapters/pi.md index 9837ea08..c9687818 100644 --- a/packages/mosaic/framework/adapters/pi.md +++ b/packages/mosaic/framework/adapters/pi.md @@ -13,7 +13,8 @@ Pi is the native Mosaic agent runtime. The `mosaic pi` launcher: 1. Injects the full runtime contract via `--append-system-prompt` 2. Loads Mosaic skills via `--skill` flags -3. Loads the Mosaic extension via `--extension` for lifecycle hooks +3. Loads framework-owned `mosaic-extension.ts` and `goal-extension.ts` from + `~/.config/mosaic/runtime/pi/` via ordered `--extension` flags 4. Detects active missions and injects initial prompts ## Capabilities vs Other Runtimes @@ -22,6 +23,7 @@ Pi is the native Mosaic agent runtime. The `mosaic pi` launcher: - Native thinking levels replace sequential-thinking MCP - Native skill discovery compatible with Mosaic SKILL.md format - Native extension system for lifecycle hooks (TypeScript, not bash shims) +- Bounded persistent `/goal` loop with per-turn, post-compaction, and two-pass evidence checks - Native session persistence and resume - Model-agnostic (Anthropic, OpenAI, Google, Ollama, custom providers) diff --git a/packages/mosaic/framework/defaults/AGENTS.md b/packages/mosaic/framework/defaults/AGENTS.md index ca3117a2..0af5914d 100755 --- a/packages/mosaic/framework/defaults/AGENTS.md +++ b/packages/mosaic/framework/defaults/AGENTS.md @@ -43,6 +43,8 @@ overwritten on upgrade. (Layer model: `constitution/LAYER-MODEL.md`.) | Secrets / vault usage | `guides/VAULT-SECRETS.md` | | Tool/credential reference (service CLIs, wrappers) | `guides/TOOLS-REFERENCE.md` | | Memory protocol (OpenBrain capture/recall) | `guides/MEMORY.md` | +| Seat identity, git credentials, token slots | `guides/SEAT-IDENTITY.md` | +| Reaching another agent (fleet comms) | `guides/FLEET-COMMS.md` | ## Subagent Model Selection (Cost — Hard Rule) diff --git a/packages/mosaic/framework/defaults/README.md b/packages/mosaic/framework/defaults/README.md index 8222ffb3..75b29276 100644 --- a/packages/mosaic/framework/defaults/README.md +++ b/packages/mosaic/framework/defaults/README.md @@ -104,7 +104,14 @@ The launcher: 1. Verifies `~/.config/mosaic` exists 2. Verifies `SOUL.md` exists (auto-runs `mosaic init` if missing) 3. Injects `AGENTS.md` into the runtime -4. Forwards all arguments to the runtime CLI +4. For Pi, loads the framework-owned core and persistent-goal extensions from + `~/.config/mosaic/runtime/pi/` +5. Forwards all arguments to the runtime CLI + +Inside `mosaic pi`, `/goal set ` starts a bounded persistent goal loop. Use `/goal status`, +`/goal pause`, `/goal resume`, or `/goal cancel` to control it. The extension remains part of Mosaic +under `~/.config/mosaic/runtime/pi/goal-extension.ts`; it is not installed in Pi's main extension +directory. You can still launch runtimes directly (`claude`, `codex`, etc.) — thin runtime adapters will tell the agent to read `~/.config/mosaic/AGENTS.md`. @@ -124,9 +131,9 @@ You can still launch runtimes directly (`claude`, `codex`, etc.) — thin runtim │ ├── claude/ ← CLAUDE.md, RUNTIME.md, settings.json, hooks │ ├── codex/ ← instructions.md, RUNTIME.md │ ├── opencode/ ← AGENTS.md, RUNTIME.md -│ ├── pi/ ← RUNTIME.md, mosaic-extension.ts +│ ├── pi/ ← RUNTIME.md, mosaic-extension.ts, goal-extension.ts │ └── mcp/ ← MCP server configs -├── skills/ ← Universal skills (synced from mosaic/agent-skills) +├── skills/ ← Universal skills (shipped with the framework package) ├── skills-local/ ← Local cross-runtime skills ├── memory/ ← Persistent agent memory (preserved across upgrades) └── templates/ ← SOUL.md template, project templates @@ -136,7 +143,7 @@ You can still launch runtimes directly (`claude`, `codex`, etc.) — thin runtim | Launch method | Injection mechanism | | ------------------- | ----------------------------------------------------------------------------------------- | -| `mosaic pi` | `--append-system-prompt` with composed runtime contract + skills + extension | +| `mosaic pi` | `--append-system-prompt` with composed runtime contract + skills + Mosaic extensions | | `mosaic claude` | `--append-system-prompt` with composed runtime contract (`AGENTS.md` + runtime reference) | | `mosaic codex` | Writes composed runtime contract to `~/.codex/instructions.md` before launch | | `mosaic opencode` | Writes composed runtime contract to `~/.config/opencode/AGENTS.md` before launch | @@ -193,11 +200,11 @@ The installer rejects unrecognized flags or positional arguments before making c ## Universal Skills -The installer syncs skills from `mosaic/agent-skills` into `~/.config/mosaic/skills/`. Install, wizard finalization, and `mosaic update` automatically reconcile every canonical skill into Claude Code's `~/.claude/skills/` directory. +Canonical skills ship inside the framework package itself; the installer installs them into `~/.config/mosaic/skills/` together with the rest of the framework (there is no separate skills repository). Install, wizard finalization, and `mosaic update` automatically link every canonical skill into Claude Code's `~/.claude/skills/` directory. ```bash -mosaic sync # Full canonical catalog sync -~/.config/mosaic/tools/_scripts/mosaic-sync-skills --link-only # Re-link only +mosaic sync # Relink the full canonical catalog +~/.config/mosaic/tools/_scripts/mosaic-sync-skills --link-only # Re-link only (same as default) mosaic skill list # Show registered, missing, dangling, and foreign entries mosaic skill register # Register or repair one canonical Claude link mosaic skill unregister # Remove one Mosaic-owned Claude link diff --git a/packages/mosaic/framework/defaults/STANDARDS.md b/packages/mosaic/framework/defaults/STANDARDS.md index 03c54083..e4465b19 100644 --- a/packages/mosaic/framework/defaults/STANDARDS.md +++ b/packages/mosaic/framework/defaults/STANDARDS.md @@ -60,6 +60,52 @@ If a repo does not expose these scripts, run equivalent local workflow commands - Do not auto-resolve data conflicts in shared state files. - Keep commits scoped to a single logical change set. +## Model Tiering + +Model choice is a standard, not a preference. Delegating a mechanical grep to a +frontier reasoning model wastes budget; sending a security review to a cheap tier +produces a review that passes and proves nothing. Both are defects. + +Tiers are named by **capability class**, so the standard survives a model +generation. An operator binds each class to a concrete model id. + +| Class | Use for | +| ------------- | ----------------------------------------------------------------------------------------- | +| `search` | grep/glob, file location, status and health checks, one-line mechanical edits | +| `build` | feature implementation, test writing, bugfixes, routine refactors | +| `judge` | code review, planning, API/compat-sensitive changes | +| `adversarial` | security review, ambiguous architecture, anything where a wrong "looks fine" is expensive | + +Rules: + +1. **Start at the cheapest class that can do the task; escalate on evidence, not + on nerves.** Omitting a tier is not neutral — it inherits the caller's model, + which is usually the most expensive one. +2. **Compat-sensitive work escalates one class.** A change that must interoperate + with an existing contract is judged, not just built. +3. **A tier assignment is benchmarked, not asserted.** Move a task class to a + cheaper tier only against a blind A/B on real work from this codebase, ranked + by someone other than the author. "It seemed fine" is not evidence. +4. **Reviewer independence beats reviewer size.** An `adversarial` verdict from + the model that wrote the code is not a second opinion (see Constitution gate 16). + +### Where the binding lives + +The class→model map is operator configuration, never framework source: model +availability, cost, and quotas differ per operator and per host. + +Resolution order, first hit wins: + +1. the config service (DB-backed, surfaced and editable in the Mosaic webUI) +2. a local operator file (`STANDARDS.local.md`, or `policy/` where the runtime + injects it) +3. the framework default — the class names above, with no binding + +Only layer 1 is auditable across a fleet, so it is the target end state; layers 2 +and 3 exist so a host with no config service still runs. A local override that +silently disagrees with the config service is drift — the same failure class the +tool-index gate exists to catch, and it belongs in `mosaic doctor`. + ## Prompting Contract All runtime adapters should inject: diff --git a/packages/mosaic/framework/fleet/README.md b/packages/mosaic/framework/fleet/README.md index df42327f..b56c8451 100644 --- a/packages/mosaic/framework/fleet/README.md +++ b/packages/mosaic/framework/fleet/README.md @@ -12,6 +12,33 @@ The default tmux socket is `mosaic-fleet` so fleet commands do not touch the default tmux server. The roster is the desired-state authority; generated environment files are rebuildable projections, never a second source of configuration. +## Brain-home split (fleet state vs framework templates) + +When a mosaic-brain clone is present, fleet **state** resolves from the brain +home while framework templates and dispatch state stay in the config home +(three-tree model, canon `docs/STRUCTURE-CANON.md` §2): + +| Path | Without brain (legacy) | With brain | +| ------------------------------------------------------------------------------- | ------------------------------------- | ------------------------------ | +| `fleet/agents/.env.*` | `~/.config/mosaic/fleet/agents/` | `~/.mosaic/fleet/agents/` | +| `fleet/roles.local/` (overrides) | `~/.config/mosaic/fleet/roles.local/` | `~/.mosaic/fleet/roles.local/` | +| `fleet/profiles/` (working copies) | `~/.config/mosaic/fleet/profiles/` | `~/.mosaic/fleet/profiles/` | +| `fleet/roster.yaml`, `fleet/roles/` (baseline), `fleet/run/`, `fleet/services/` | `~/.config/mosaic/fleet/…` | unchanged (config home) | + +Activation (`packages/mosaic/src/fleet/brain-home.ts`, mirrored in +`tools/fleet/start-agent-session.sh`): + +1. `MOSAIC_BRAIN_HOME` env var — explicit, always wins. +2. Canonical `~/.mosaic` — adopted only when `MOSAIC_HOME` is the default + `~/.config/mosaic` AND `~/.mosaic/fleet/agents` exists. Custom + `--mosaic-home` values (tests, sandboxes, canaries) never adopt, keeping + them hermetic. +3. Otherwise the config home (legacy single-tree behavior). + +Seat env dirs under a brain are subject to the same privacy boundary (0700 +dirs, 0600 files); `.env.generated` files are structure-valuable and tracked +in the brain repo, hand-maintained `.env`/`.env.local` stay ignored and private. + ## Examples - `examples/minimal.yaml` starts one local canary slot. diff --git a/packages/mosaic/framework/guides/CODE-REVIEW.md b/packages/mosaic/framework/guides/CODE-REVIEW.md index 938915d8..f4834bd8 100755 --- a/packages/mosaic/framework/guides/CODE-REVIEW.md +++ b/packages/mosaic/framework/guides/CODE-REVIEW.md @@ -15,8 +15,78 @@ Merge strategy enforcement (HARD RULE): - Merge to `main` MUST be squash-only. - Use `~/.config/mosaic/tools/git/pr-merge.sh -n {PR_NUMBER} -m squash --expect-head {approved_full_sha}` (or PowerShell equivalent). +An estate MAY carry a documented exception for a repository whose gates are commit hooks rather +than review. Such an exception belongs in that estate's own working copy of this guide, is +scoped to the named repository, and is never precedent for a second one. + +**Do not use `pr-review.sh` or `issue-comment.sh` to post a verdict** (mosaicstack#1280). Post +through a direct authenticated API call as your own seat, or hand the verdict to the requesting +seat. Handing it over is a legitimate delivery path, not a fallback. + +## Evidence Discipline (applies to every finding) + +The checklist below says what to look at. This section says when you are allowed to believe what +you saw. Every rule here was earned by a wrong conclusion that reached a report. + +1. **A finding is a claim about behavior.** State the failing input, the path taken, and the + wrong result. "This looks fragile" is not a finding. +2. **A green check is not a result until you have shown it could go red.** Run the control. A + `0`, an empty result, or a column of identical values with no failing counterpart is a + non-result. +3. **Measurement and explanation are separate sentences.** Report the command and its output, + then, as its own sentence, what you think it means. +4. **Never widen the case you measured.** If you checked one path, the finding covers one path. +5. **Reproduce a reported failure before recording it, and say which tree you measured.** Two + correct measurements of two different trees disagree without either being wrong. +6. **Verify by content on the ref that ships**, never by ancestry of a local sha. A rebase mints + new shas; a commit being an ancestor of something local proves nothing about the remote. + Compare by digest against `origin/`. +7. **Confidence is part of the finding.** "I could not reproduce this" is a usable review + comment. A confident guess is not. +8. **Author is not reviewer** (Gate-16). Do not review your own work, or work you shaped closely + enough to be a co-author of. Say so and hand it back. + +### Measuring a shell suite + +Each of these produced a wrong conclusion before it was written down. + +9. **`cmd | tail; echo rc=$?` reports `tail`'s exit code, not `cmd`'s.** It reads as a pass when + the command failed. Redirect to a file and check `rc` directly, or use `${PIPESTATUS[0]}`. +10. **Under `set -o pipefail`, a missed glob makes `ls` exit 2**, the pipeline inherits it, and + `set -e` kills the run. Iterate a glob with a `for` loop and an `-e` test instead of piping + `ls`. +11. **A suite that exits nonzero with ZERO output is an environment question, not a defect in + the code under review.** The usual cause is a sourced dependency that is absent, so `set -e` + kills the first case before anything prints. Extract whole tool trees — `tools/git` alone is + missing `tools/_lib/credentials.sh`. Isolate the variable and prove it by adding only that + back. +12. **`git -C ` in a directory that is not itself a repo answers from the enclosing repo.** + A scratch tree under `~/.mosaic` reports `~/.mosaic`'s HEAD, not the PR's, and every + conclusion drawn from it describes the wrong tree. Confirm `git rev-parse --show-toplevel` + is the tree you think it is before trusting any git output. + +### Feedback Categories + +- **Blocker**: must fix before merge (security, bugs, test failures) +- **Should Fix**: important but not blocking (code quality, minor issues) +- **Suggestion**: optional improvement (style preference, nice-to-have) +- **Question**: seeking clarification + ## Review Checklist +Reviewer seats split this checklist by class rather than duplicating it. A seat reviews its own +sections in full and may raise anything it notices outside them as a Suggestion, never as a +Blocker on someone else's ground. + +| Reviewer class | Owns | +| ---------------- | ------------------------------------------------------------------------------------------------------- | +| `rev-code-*` | 1 Correctness, 3 Testing, 4 Code Quality, 4a TypeScript, 5 Documentation, 6 Performance, 7 Dependencies | +| `rev-security-*` | 2 Security, 2a OWASP | + +Where two seats of the same class review the same change, they review independently and compare +after. A second seat that reads the first seat's findings before measuring is a proofreader, not +a second opinion. + ### 1. Correctness - [ ] Code does what the issue/PR description says @@ -53,7 +123,7 @@ Merge strategy enforcement (HARD RULE): - [ ] Tests cover happy path AND error cases - [ ] Situational tests cover all impacted change surfaces (primary gate) - [ ] Tests validate required behavior/outcomes, not only internal implementation details -- [ ] TDD was applied when required by `~/.config/mosaic/guides/QA-TESTING.md` +- [ ] TDD was applied when required by `guides/QA-TESTING.md` - [ ] Coverage meets 85% minimum - [ ] Tests are readable and maintainable - [ ] No flaky tests introduced @@ -82,7 +152,7 @@ Merge strategy enforcement (HARD RULE): ### 5. Documentation - [ ] Complex logic has explanatory comments -- [ ] Required docs updated per `~/.config/mosaic/guides/DOCUMENTATION.md` +- [ ] Required docs updated per `guides/DOCUMENTATION.md` - [ ] Public APIs are documented - [ ] Private/internal APIs are documented - [ ] API input/output schemas are documented @@ -126,13 +196,6 @@ git diff main...HEAD - Distinguish between blocking issues and suggestions - Be constructive, not critical of the person -### Feedback Categories - -- **Blocker**: Must fix before merge (security, bugs, test failures) -- **Should Fix**: Important but not blocking (code quality, minor issues) -- **Suggestion**: Optional improvements (style preferences, nice-to-haves) -- **Question**: Seeking clarification - ### Review Comment Format ``` diff --git a/packages/mosaic/framework/guides/FLEET-COMMS.md b/packages/mosaic/framework/guides/FLEET-COMMS.md new file mode 100644 index 00000000..4a6a602b --- /dev/null +++ b/packages/mosaic/framework/guides/FLEET-COMMS.md @@ -0,0 +1,86 @@ +# Fleet Comms Guide + +How one seat reaches another on a host. The mechanism is the framework's; the sessions and +sockets are per-host, so measure yours rather than trusting an example. + +`mosaic ` would normally inject the addressing block from the roster. Where the composer +is unavailable, or where the roster is stale, this guide is the substitute. + +## Measure the fleet; do not trust the roster + +`fleet/roster.yaml` is a declaration of intent, not an observation. It routinely names a socket +that was never created, lists seats that are not running, and omits seats that are — this was +all three have been observed true at once on a live host. Find out what is actually +up before addressing anyone: + +```bash +tmux list-sessions +tmux list-panes -a -F '#{session_name} #{pane_current_command} #{pane_current_path}' +``` + +The pane command tells you the runtime. A pane showing `bash` is an idle shell with no agent +attached — a send there lands in a shell prompt and is not read by anyone. + +Use the **default socket**. Do not pass `-L mosaic-fleet` on the strength of the roster. + +## Sending + +```bash +~/.config/mosaic/tools/tmux/agent-send.sh -s -C -m "" +``` + +`-s` also accepts `session:window.pane`. `-f ` sends a file body; stdin works too. + +### Classes + +`-C` takes exactly one of these. Anything else exits 3. + +| Class | Use for | +| -------------- | -------------------------------------------------------- | +| `terminal-log` | log only; never needs the agent's attention | +| `actionable` | a decision, blocker, gate, or question needing an answer | +| `human` | relayed from a human operator | +| `reaction` | an ack or acknowledgement token | +| `digest` | machine wake, coalescible | + +An absent class is treated as `actionable` by consumers, which is the fail-safe direction. Prefer +naming it anyway. + +### Addressing preamble + +The wire format is `[ -> class=] `. Flip it when you reply — the tool +sends, it does not auto-reply. + +### Exit codes + +| rc | Meaning | +| --- | ---------------------------------------------- | +| 0 | delivered or queued | +| 1 | target session not found | +| 2 | text reached the pane but is **still a draft** | +| 3 | usage error (bad class, missing `-s`) | + +**Never retry on rc=2.** The message is in the target pane; retrying double-sends it. Confirm +instead: + +```bash +tmux capture-pane -p -t :0.0 | tail -20 +``` + +rc=2 is the normal result when the target is an idle pi seat. + +## Durable comms + +tmux delivery is host-local and does not survive a pane. Anything that must outlive the session +goes through the estate's durable comms protocol — a committed `comms/` tree in an estate repo, +with its own README. Use it for cross-host messages, verdicts, and anything a later session needs +to find. + +## Handing work across seats + +1. **A verdict handed to the requesting seat is a legitimate delivery path**, and the required one + for anything `pr-review.sh` would otherwise post (see `guides/CODE-REVIEW.md`). +2. **Address the seat, not the runtime.** A seat name is a session name; whether it runs claude, + pi or codex is not the sender's business. +3. **Say what you measured, not just what you concluded** — the receiving seat cannot see your + terminal. diff --git a/packages/mosaic/framework/guides/INFRASTRUCTURE.md b/packages/mosaic/framework/guides/INFRASTRUCTURE.md index adb4f033..48dc1b97 100644 --- a/packages/mosaic/framework/guides/INFRASTRUCTURE.md +++ b/packages/mosaic/framework/guides/INFRASTRUCTURE.md @@ -219,7 +219,7 @@ Use the Cloudflare tools for any DNS configuration: pointing domains at services # Update an existing record (get record ID from record-list first) ~/.config/mosaic/tools/cloudflare/record-update.sh \ - -z example.com -r -t A -n myapp -c 10.0.0.5 -p + -z example.com -r -t A -n myapp -c 192.0.2.5 -p ``` **DNS + Deployment integration**: When deploying a new service via Coolify or Portainer that needs a public domain, the typical sequence is: diff --git a/packages/mosaic/framework/guides/SEAT-IDENTITY.md b/packages/mosaic/framework/guides/SEAT-IDENTITY.md new file mode 100644 index 00000000..8b79daab --- /dev/null +++ b/packages/mosaic/framework/guides/SEAT-IDENTITY.md @@ -0,0 +1,133 @@ +# Seat Identity & Credentials Guide + +Every agent that touches a Mosaic-managed git host acts as a named seat with its own credential. +This guide is how that works on a host, and what an agent must never do with it. + +The mechanism below is the framework's. The specific paths, seats and stores are per-host: +measure yours before trusting any of them. + +## The rule + +**One seat, one identity, one token file.** A seat never borrows another seat's credential, never +falls back to a shared owner account, and never carries a second copy of its own token. A second +copy is drift, and drift surfaces as the stale copy returning 401 — which reads as a revoked +token and sends whoever debugs it somewhere else entirely. + +A credential refusal is correct behavior, not a bug to route around. If git refuses with a +fail-closed diagnostic, the fix is to provision or correct _your_ identity. Escalate; do not +substitute. + +## How a credential is resolved + +Find the helper the way **git** does, not with `command -v`. Git runs whatever +`credential.helper` names, and on a Mosaic host that is an absolute path — so a PATH lookup +answers a different question and the two disagree the moment the PATH copy is removed. It was +removed on hosts that have completed that migration. + +```bash +git config --get-all credential.helper # every helper, in the order git tries them +``` + +Git tries **each** configured helper in turn until one supplies a credential. A fail-closed +helper supplies nothing, so a second helper configured behind it silently becomes the one that +answers. When you care which binary serves a credential, read the whole list. +Resolve all three forms git accepts — absolute path, `!command`, and a bare name looked up on +PATH — not just the one your host happens to use. + +The helper resolves the identity in this order: + +1. `$MOSAIC_GIT_IDENTITY` +2. `git config --get mosaic.gitIdentity` +3. the username git supplied on stdin + +It maps the host to a store prefix — `git.mosaicstack.dev` to `gitea-mosaicstack`, +`git.uscllc.com` to `gitea-usc`. Any other host is declined quietly with rc=0, which is not an +error and raises no escalation. + +Then it chooses **one** of two stores, and reads exactly one file: + +``` +brain_home = ${MOSAIC_BRAIN_HOME:-$HOME/.mosaic} + +seat — when $brain_home/fleet/agents// EXISTS + $brain_home/fleet/agents//secrets/-.token +service — otherwise + ~/.config/mosaic/secrets/gitea-tokens/-.token +``` + +**There is no precedence between the two and no fallback from one to the other.** The existence +of the seat directory decides it. A seat that has a directory and an empty slot fails closed; it +does not reach the service store. That is the intended behavior — the alternative is an agent +silently acting as somebody else. + +If the file is unreadable the helper **fails closed**: it refuses and writes a durable record to +the escalation spool. It does not fall back to a shared account. The record is what exists — any +alerting built on top of it is a separate, best-effort concern and is not performed by the helper, +so do not wait for a notification that nothing sends. That fallback is what made +`usc/uconnect#3084` unattributable, and it was removed deliberately. + +Verify the helper you actually have: + +```bash +h=$(git config --get credential.helper) +grep -c 'FAIL CLOSED' "$h" # expect >= 1 +grep -c 'fleet/agents' "$h" # expect >= 1; 0 means it predates mosaicstack#1311 +``` + +## Where a seat's token lives + +The seat slot is the **only** copy: + +``` +~/.mosaic/fleet/agents//secrets/-.token real file, mode 600 +``` + +The framework store at `~/.config/mosaic/secrets/gitea-tokens/` holds tokens for **service +identities only** — identities with no seat directory. A seat's token does not belong there. + +Before mosaicstack#1311 the deployed helper knew only the service store, and seats were bridged +with a symlink from the store into the slot. **Those bridges must be removed once a seat-aware helper is deployed, and must not be +recreated.** Remove them only after the helper can reach the slot without them; the reverse order +takes every seat offline. A symlink +is not how a system finds a credential; the helper resolving the right store is. + +`.principal` and `.scopes` beside the token are grant records, not secrets. They are tracked. The +`.token` never is. + +### Provisioning a new seat + +1. Create `~/.mosaic/fleet/agents//secrets/` mode 700. +2. Write `.principal` (the Gitea login) and `.scopes` (the granted scopes), mode 600. +3. The estate operator mints the token into the seat slot, mode 600. Agents do not mint their + own, and do not ask another agent to mint one for them. +4. Verify with an authenticated `GET /user` and confirm the returned login is the seat, **not the + minting account**. Record the date in `ENTITY.md`. Never record the value. + +There is no step that links the framework store to the slot. A seat-aware helper reads the slot +directly; a store entry pointing at a slot is the bridge described in **Where a seat's token lives** above, +and it is not part of provisioning. + +Until step 3, the seat is unminted and its git writes fail closed. That is the designed state and +is safe to launch in — the seat is told at launch so it does not discover it mid-task. + +## Acting as yourself + +Name the identity on every invocation: + +```bash +MOSAIC_GIT_IDENTITY= git push +git -c user.name= -c user.email=@mosaicstack.dev commit -m "..." +``` + +**Never persist `git config mosaic.gitIdentity` inside a `~/src/stack` worktree.** Every worktree +of that clone shares one `.git/config`, so a persisted identity there silently rewrites the +identity of every other seat working in that clone. The per-invocation form has no exception. + +## Handling + +1. **Never print a token value.** Compare by SHA-256 digest, or write ``. +2. **Never stage a `.token`, `secrets.json`, or `ENTITY.md`.** Stage explicit paths and **never + `git add -A`** — `secrets/*.principal` and `secrets/*.scopes` are covered by no ignore rule. +3. **Never place a token in an environment variable** in an interactive session. A `declare -x` + dump has leaked the whole environment to a terminal before. +4. **No real credential or operator data on a sandbox VM, ever.** diff --git a/packages/mosaic/framework/guides/TOOLS-REFERENCE.md b/packages/mosaic/framework/guides/TOOLS-REFERENCE.md index 0eca6c5a..f5d462ad 100644 --- a/packages/mosaic/framework/guides/TOOLS-REFERENCE.md +++ b/packages/mosaic/framework/guides/TOOLS-REFERENCE.md @@ -11,22 +11,106 @@ All tool suites are located at `~/.config/mosaic/tools/`. Mosaic wrappers at `~/.config/mosaic/tools/git/*.sh` handle platform detection and edge cases. Always use these before raw CLI commands. +This index is complete and is kept complete mechanically: `tools/quality/scripts/check-tools-index.sh` +fails CI when a wrapper ships without an entry here, or when an entry here names a wrapper that no +longer exists. A wrapper missing from this list is, from inside an agent session, indistinguishable +from a wrapper that was never written — which is how the APPROVE/APPROVED incident below happened. + +Every command takes `--help`. All of them accept `--login ` to pin the acting identity; +supply it explicitly on any host where the provider CLI's default account is an admin. + +| Issues | | +| ------------------ | --------------------------------- | +| `issue-create.sh` | Create an issue (Gitea or GitHub) | +| `issue-view.sh` | Show one issue | +| `issue-list.sh` | List issues | +| `issue-edit.sh` | Edit title/body/labels/milestone | +| `issue-comment.sh` | Add a comment | +| `issue-assign.sh` | Assign or unassign | +| `issue-close.sh` | Close an issue | +| `issue-reopen.sh` | Reopen a closed issue | + +| Pull requests | | +| ---------------- | --------------------------------------------------------- | +| `pr-create.sh` | Open a pull request | +| `pr-edit.sh` | Edit PR title, body, base branch, or draft/ready state | +| `pr-view.sh` | Show one PR | +| `pr-list.sh` | List PRs | +| `pr-diff.sh` | Fetch a PR's diff | +| `pr-metadata.sh` | PR metadata as JSON (head SHA, base, state, mergeability) | +| `pr-review.sh` | **Place a review verdict — see the dialect note below** | +| `pr-ci-wait.sh` | Block until the PR's CI reaches a terminal state | +| `pr-merge.sh` | Merge a PR | +| `pr-close.sh` | Close a PR without merging | + +| Milestones | | +| --------------------- | ------------------ | +| `milestone-create.sh` | Create a milestone | +| `milestone-list.sh` | List milestones | +| `milestone-close.sh` | Close a milestone | + +| Gates and guards | | +| ----------------------- | --------------------------------------------------------------------------------------------------------- | +| `ci-queue-wait.sh` | CI queue guard — required before push/merge (see below) | +| `push-guard.sh` | Refuse verifications that pass for the wrong reason (e.g. green against an unpushed tree) | +| `mutate-push-guard.sh` | Regenerate the guard's mutation-coverage table from measurement, so the table cannot drift from the guard | +| `verify-clean-clone.sh` | Prove the **committed** artifact runs, from a clean clone — not the working tree | + +| Context | | +| -------------------- | ---------------------------------------------------------------------------------------- | +| `detect-platform.sh` | Resolve the provider (Gitea vs GitHub) for the current repo; every other wrapper uses it | +| `lane-brief.sh` | Live dispatch brief for a repo "lane" (milestone/label) straight from the provider | + +| Workspace | | +| -------------------- | ------------------------------------------------------------------------ | +| `mosaic-worktree.sh` | Create/list/remove git worktrees — **the only supported way**; see below | +| `wrapper-guard.sh` | PreToolUse hook that enforces the two rules above; not called by hand | + +**Workspace placement is derived, not chosen.** `mosaic-worktree.sh new ` takes a branch +name and nothing else. Every path comes out of `git worktree list --porcelain` — main worktree, +repo name, parent dir, then `/-worktrees/`. There is no placement flag +because a decision an agent has to make is a decision that drifts: the rule "big work goes on a work +filesystem" already existed in prose and 255 GB accumulated in `$HOME` across 842 directories +anyway, under five simultaneous conventions on a single host. + ```bash -# Issues -~/.config/mosaic/tools/git/issue-create.sh -~/.config/mosaic/tools/git/issue-close.sh +~/.config/mosaic/tools/git/mosaic-worktree.sh new [--from ] +~/.config/mosaic/tools/git/mosaic-worktree.sh path # derived path, no side effect +~/.config/mosaic/tools/git/mosaic-worktree.sh list # this repo's worktrees + state +~/.config/mosaic/tools/git/mosaic-worktree.sh rm # removal is part of the task +~/.config/mosaic/tools/git/mosaic-worktree.sh gc [--apply] # reclaim clean + fully-pushed ones +``` -# PRs -~/.config/mosaic/tools/git/pr-create.sh -~/.config/mosaic/tools/git/pr-merge.sh +Worktrees rather than clones, because `git worktree list` makes every checkout enumerable — a bare +clone dropped somewhere on disk can never be safely reclaimed, so it is never reclaimed. `rm` and +`gc` decide by **evidence, never by size or age**: a worktree is reclaimable only when +`git status --porcelain` is empty _and_ `git rev-list --count HEAD --not --remotes` is 0. Anything +else is preserved and reported. `--force` exists and is yours to type deliberately. -# Milestones -~/.config/mosaic/tools/git/milestone-create.sh +`wrapper-guard.sh` is registered as a Claude Code `PreToolUse` hook on `Bash` (see +`runtime/claude/settings.json`). It blocks exactly three things and lets everything else through: +a `git clone`/`git worktree add` targeting `$HOME`; a raw provider-API **write** to an endpoint that +already has a wrapper above (reads are untouched — they are how you gather evidence); and the +literal `"event": "APPROVE"`. For a genuine gap no wrapper can express, prefix +`MOSAIC_WRAPPER_OVERRIDE=1`. Reaching for the override twice for the same call means the wrapper has +a missing flag — extend the wrapper. + +```bash +~/.config/mosaic/tools/git/issue-create.sh --help +~/.config/mosaic/tools/git/pr-review.sh --pr 42 --event APPROVED --body "..." # CI queue guard (required before push/merge; defaults to the checked-out branch) ~/.config/mosaic/tools/git/ci-queue-wait.sh --purpose push|merge ``` +**Review dialect — the reason `pr-review.sh` is not optional.** Gitea's approve event is +`APPROVED`; GitHub's is `APPROVE`. Send GitHub's spelling to a Gitea host and it answers **HTTP +200**, files the review as PENDING, and then rejects the submit with `422 review stay pending` — the +verdict looks placed and is not. (`REQUEST_CHANGES` is spelled identically on both, so only the +approve path carries the trap.) `pr-review.sh` sends the correct token for the detected provider. +Whatever you use, re-read `GET /pulls/{n}/reviews` and assert the state before reporting a verdict +placed. + The guard exits nonzero for any provider-asserted non-green, missing, or malformed CI state. If credentials or the provider are unavailable, it emits `CANNOT_ASSERT` and writes a JSONL audit record. Push degrades to exit 0 so recovery work is not bricked; merge holds with retryable exit 75 until the provider recovers, then self-clears without manual reset. Neither outcome is evidence that CI was clear. `pr-merge.sh` automatically inspects the exact PR head repository and full commit SHA rather than its `main` base; this also handles fork PRs without branch-name ambiguity. Pass `--expect-head ` to bind a commit-specific review or merge-gate verdict; Gitea uses atomic `head_commit_id` and GitHub uses `--match-head-commit`. ### Code Review (Codex) diff --git a/packages/mosaic/framework/install.sh b/packages/mosaic/framework/install.sh index 1578c33b..1a5c8d64 100755 --- a/packages/mosaic/framework/install.sh +++ b/packages/mosaic/framework/install.sh @@ -17,7 +17,7 @@ set -Eeuo pipefail # MOSAIC_HOME — target directory (default: ~/.config/mosaic) # MOSAIC_INSTALL_MODE — prompt|keep|overwrite (default: prompt) # MOSAIC_ALLOW_MISSING_SEQUENTIAL_THINKING — 1 to bypass MCP check -# MOSAIC_SKIP_SKILLS_SYNC — 1 to skip skill sync +# MOSAIC_SKIP_SKILLS_SYNC — 1 to skip linking skills into runtime homes # # Flags (CLI args, NOT environment variables — see #869 Point-1 C2): # --allow-inactive-enforcement Explicit, per-invocation opt-out that lets the @@ -828,7 +828,7 @@ if [[ -x "$SCRIPTS/mosaic-ensure-excalidraw" ]]; then fi if [[ "${MOSAIC_SKIP_SKILLS_SYNC:-0}" != "1" ]] && [[ -x "$SCRIPTS/mosaic-sync-skills" ]]; then - "$SCRIPTS/mosaic-sync-skills" >/dev/null 2>&1 && ok "Skills synced" || warn "Skills sync failed (non-fatal)" + "$SCRIPTS/mosaic-sync-skills" >/dev/null 2>&1 && ok "Skills linked into runtime homes" || warn "Skills linking failed (non-fatal)" fi if [[ -x "$SCRIPTS/mosaic-migrate-local-skills" ]]; then diff --git a/packages/mosaic/framework/runtime/claude/settings.json b/packages/mosaic/framework/runtime/claude/settings.json index 0e6dcec1..e1d81471 100644 --- a/packages/mosaic/framework/runtime/claude/settings.json +++ b/packages/mosaic/framework/runtime/claude/settings.json @@ -64,6 +64,16 @@ "timeout": 10 } ] + }, + { + "matcher": "Bash", + "hooks": [ + { + "type": "command", + "command": "~/.config/mosaic/tools/git/wrapper-guard.sh", + "timeout": 10 + } + ] } ], "PostToolUse": [ diff --git a/packages/mosaic/framework/runtime/pi/RUNTIME.md b/packages/mosaic/framework/runtime/pi/RUNTIME.md index 6a9f2820..93eeba12 100644 --- a/packages/mosaic/framework/runtime/pi/RUNTIME.md +++ b/packages/mosaic/framework/runtime/pi/RUNTIME.md @@ -51,12 +51,26 @@ Skills are discovered from: ### Extensions -The Mosaic Pi extension (`~/.config/mosaic/runtime/pi/mosaic-extension.ts`) handles: +`mosaic pi` loads framework-owned extensions directly from `~/.config/mosaic/runtime/pi/` in this +order: -- Session start/end lifecycle hooks -- Active mission detection and context injection -- Memory routing to `~/.config/mosaic/memory/` -- MACP queue status reporting +1. `mosaic-extension.ts` — session lifecycle, mission context, memory routing, lease/mutator gates, + and fleet heartbeat reporting. +2. `goal-extension.ts` — optional persistent `/goal` controller with per-turn and post-compaction + checks. + +The goal extension is deployed by Mosaic and MUST NOT be copied into `~/.pi/agent/extensions/`. +Use `/goal set ` (or `/goal `) to start, then `/goal status`, `/goal pause`, +`/goal resume`, or `/goal cancel` to control it. An active goal is injected before every model +request, restored from branch-specific session entries, and considered achieved only after two +consecutive evidence-bearing reports. Common credential shapes are redacted before controller-owned +goal-state entries are persisted or +displayed; Pi's own model/tool-call history is separate. Goals and reports must contain references +and pass/fail summaries rather than secrets or raw sensitive output. + +- `MOSAIC_GOAL_MAX_TURNS` — autonomous turn limit, default `40`, accepted range `1..500`. +- `MOSAIC_GOAL_MAX_NO_PROGRESS` — identical no-progress report limit, default `6`, accepted range + `1..100`. ### Sessions diff --git a/packages/mosaic/framework/runtime/pi/goal-extension.ts b/packages/mosaic/framework/runtime/pi/goal-extension.ts new file mode 100644 index 00000000..19d1124e --- /dev/null +++ b/packages/mosaic/framework/runtime/pi/goal-extension.ts @@ -0,0 +1,1088 @@ +import { createHash, randomUUID } from 'node:crypto'; +import type { ExtensionAPI, ExtensionContext } from '@earendil-works/pi-coding-agent'; + +const STATE_ENTRY_TYPE = 'mosaic-goal-state'; +const CONTEXT_MESSAGE_TYPE = 'mosaic-goal-context'; +const CONTINUATION_MESSAGE_TYPE = 'mosaic-goal-continuation'; +const GOAL_REPORT_TOOL = 'mosaic_goal_report'; +const STATUS_KEY = 'mosaic-goal'; +const STATE_VERSION = 1 as const; +const MAX_STATEMENT_LENGTH = 8_000; +const MAX_SUMMARY_LENGTH = 2_000; +const MAX_EVIDENCE_ITEMS = 20; +const MAX_EVIDENCE_LENGTH = 1_000; +const MAX_NEXT_STEP_LENGTH = 2_000; +const DEFAULT_MAX_TURNS = 40; +const DEFAULT_MAX_NO_PROGRESS = 6; +const REQUIRED_VERIFICATION_PASSES = 2; +const DEFERRED_CONTINUATION_MS = 10; +const REDACTED_SECRET = '[REDACTED-SECRET]'; +const PRIVATE_KEY_PATTERN = + /-----BEGIN(?: [A-Z0-9]+)* PRIVATE KEY(?: BLOCK)?-----[\s\S]*?(?:-----END(?: [A-Z0-9]+)* PRIVATE KEY(?: BLOCK)?-----|$)/g; +const CREDENTIAL_URL_PATTERN = /\b([A-Za-z][A-Za-z0-9+.-]*:\/\/)[^\s/:]+:[^\s/@]+@/g; +const AUTHORIZATION_PATTERN = + /(\b(?:authorization|proxy-authorization)\s*[:=]\s*)(bearer|basic)\s+[^\s,;]+/gi; +const STANDALONE_AUTH_PATTERN = /\b(bearer|basic)\s+[A-Za-z0-9._~+/=-]{16,}/gi; +const JWT_PATTERN = /\beyJ[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}\b/g; +const KNOWN_SECRET_PATTERN = + /\b(?:AKIA[0-9A-Z]{16}|ASIA[0-9A-Z]{16}|AIza[0-9A-Za-z_-]{35}|gh[pousr]_[A-Za-z0-9]{20,}|github_pat_[A-Za-z0-9_]{20,}|glpat-[A-Za-z0-9_-]{20,}|npm_[A-Za-z0-9]{20,}|sk-(?:ant-(?:api\d{2}-)?|proj-)?[A-Za-z0-9_-]{20,}|(?:sk|rk)_(?:live|test)_[A-Za-z0-9]{16,}|xox[baprs]-[A-Za-z0-9-]{10,}|hf_[A-Za-z0-9]{20,})\b/g; +const SENSITIVE_ASSIGNMENT_PATTERN = + /((?:["']?(?:[a-z0-9]+[_-])*(?:api[_-]?key|access[_-]?token|auth(?:orization)?[_-]?token|client[_-]?secret|password|passwd|secret(?:[_-]?access[_-]?key)?|private[_-]?key|database[_-]?url|token|cookie|set[_-]?cookie)["']?)\s*)((?:=|:)\s*)("[^"\r\n]*"|'[^'\r\n]*'|[^\s,;]+)/gi; + +const GOAL_PHASES = [ + 'active', + 'verifying', + 'paused', + 'blocked', + 'achieved', + 'cancelled', + 'exhausted', +] as const; +const REPORT_STATUSES = ['continue', 'achieved', 'blocked'] as const; +const CHECK_SOURCES = ['command', 'turn', 'compact', 'restore', 'report'] as const; + +type GoalPhase = (typeof GOAL_PHASES)[number]; +type GoalReportStatus = (typeof REPORT_STATUSES)[number]; +type GoalCheckSource = (typeof CHECK_SOURCES)[number]; + +interface GoalReport { + status: GoalReportStatus; + summary: string; + evidence: string[]; + nextStep?: string; + fingerprint: string; + reportedAt: string; +} + +interface GoalState { + version: typeof STATE_VERSION; + goalId: string; + statement: string; + phase: GoalPhase; + startedAt: string; + updatedAt: string; + turnCount: number; + reportCount: number; + verificationPasses: number; + requiredVerificationPasses: number; + noProgressReports: number; + maxTurns: number; + maxNoProgressReports: number; + compactionCount: number; + lastCheckSource: GoalCheckSource; + lastCheckAt: string; + lastCheckOutcome: string; + lastProgressFingerprint?: string; + lastReport?: GoalReport; + stopReason?: string; +} + +interface GoalReportInput { + status: GoalReportStatus; + summary: string; + evidence: string[]; + nextStep?: string; +} + +interface GoalLimits { + maxTurns: number; + maxNoProgressReports: number; +} + +interface GoalCommand { + action: 'set' | 'status' | 'pause' | 'resume' | 'cancel' | 'help'; + value: string; +} + +const GoalReportParameters = { + type: 'object', + properties: { + status: { + type: 'string', + enum: REPORT_STATUSES, + description: + 'continue while work remains, achieved only with completion evidence, or blocked', + }, + summary: { + type: 'string', + minLength: 1, + maxLength: MAX_SUMMARY_LENGTH, + description: 'Concise progress or completion assessment', + }, + evidence: { + type: 'array', + items: { type: 'string', minLength: 1, maxLength: MAX_EVIDENCE_LENGTH }, + maxItems: MAX_EVIDENCE_ITEMS, + description: 'Concrete observations, commands, tests, or artifacts supporting the status', + }, + nextStep: { + type: 'string', + minLength: 1, + maxLength: MAX_NEXT_STEP_LENGTH, + description: 'The next concrete action when work remains', + }, + }, + required: ['status', 'summary', 'evidence'], + additionalProperties: false, +} as const; + +function nowIso(): string { + return new Date().toISOString(); +} + +function shouldRedactSensitiveAssignment(separator: string, rawValue: string): boolean { + if (separator.trim() === '=') return true; + const quoted = + (rawValue.startsWith('"') && rawValue.endsWith('"')) || + (rawValue.startsWith("'") && rawValue.endsWith("'")); + if (quoted || rawValue.includes('://')) return true; + if (/^[a-f0-9]{20,}$/i.test(rawValue)) return true; + return ( + rawValue.length >= 20 && + /[A-Za-z]/.test(rawValue) && + /\d/.test(rawValue) && + /[-_./+=]/.test(rawValue) + ); +} + +function redactSensitiveText(value: string): string { + let redacted = value.replace(PRIVATE_KEY_PATTERN, REDACTED_SECRET); + redacted = redacted.replace( + CREDENTIAL_URL_PATTERN, + (_match: string, prefix: string): string => `${prefix}${REDACTED_SECRET}@`, + ); + redacted = redacted.replace( + AUTHORIZATION_PATTERN, + (_match: string, prefix: string, scheme: string): string => + `${prefix}${scheme} ${REDACTED_SECRET}`, + ); + redacted = redacted.replace( + STANDALONE_AUTH_PATTERN, + (_match: string, scheme: string): string => `${scheme} ${REDACTED_SECRET}`, + ); + redacted = redacted.replace(JWT_PATTERN, REDACTED_SECRET); + redacted = redacted.replace(KNOWN_SECRET_PATTERN, REDACTED_SECRET); + return redacted.replace( + SENSITIVE_ASSIGNMENT_PATTERN, + (_match: string, prefix: string, separator: string, rawValue: string): string => + shouldRedactSensitiveAssignment(separator, rawValue) + ? `${prefix}${separator}${REDACTED_SECRET}` + : _match, + ); +} + +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value); +} + +function isOneOf( + value: unknown, + allowed: readonly TValue[], +): value is TValue { + return ( + typeof value === 'string' && allowed.some((candidate: TValue): boolean => candidate === value) + ); +} + +function parseBoundedInteger( + value: string | undefined, + fallback: number, + minimum: number, + maximum: number, +): number { + if (value === undefined || !/^\d+$/.test(value.trim())) return fallback; + const parsed = Number.parseInt(value, 10); + if (!Number.isSafeInteger(parsed) || parsed < minimum || parsed > maximum) return fallback; + return parsed; +} + +function readGoalLimits(env: NodeJS.ProcessEnv): GoalLimits { + return { + maxTurns: parseBoundedInteger(env['MOSAIC_GOAL_MAX_TURNS'], DEFAULT_MAX_TURNS, 1, 500), + maxNoProgressReports: parseBoundedInteger( + env['MOSAIC_GOAL_MAX_NO_PROGRESS'], + DEFAULT_MAX_NO_PROGRESS, + 1, + 100, + ), + }; +} + +function parseStringArray(value: unknown): string[] | undefined { + if (!Array.isArray(value) || value.length > MAX_EVIDENCE_ITEMS) return undefined; + const result: string[] = []; + for (const item of value) { + if (typeof item !== 'string') return undefined; + const normalized = item.trim(); + if (normalized.length === 0 || normalized.length > MAX_EVIDENCE_LENGTH) return undefined; + result.push(normalized); + } + return result; +} + +function parseGoalReport(value: unknown): GoalReport | undefined { + if (!isRecord(value)) return undefined; + if (!isOneOf(value['status'], REPORT_STATUSES)) return undefined; + if ( + typeof value['summary'] !== 'string' || + value['summary'].length === 0 || + value['summary'].length > MAX_SUMMARY_LENGTH + ) { + return undefined; + } + const evidence = parseStringArray(value['evidence']); + if (evidence === undefined) return undefined; + if (typeof value['fingerprint'] !== 'string' || !/^[a-f0-9]{64}$/.test(value['fingerprint'])) { + return undefined; + } + if ( + typeof value['reportedAt'] !== 'string' || + value['reportedAt'].length === 0 || + value['reportedAt'].length > 64 + ) { + return undefined; + } + const nextStepValue = value['nextStep']; + let nextStep: string | undefined; + if (nextStepValue !== undefined) { + if ( + typeof nextStepValue !== 'string' || + nextStepValue.length === 0 || + nextStepValue.length > MAX_NEXT_STEP_LENGTH + ) { + return undefined; + } + nextStep = nextStepValue; + } + return { + status: value['status'], + summary: value['summary'], + evidence, + ...(nextStep === undefined ? {} : { nextStep }), + fingerprint: value['fingerprint'], + reportedAt: value['reportedAt'], + }; +} + +function readIntegerField( + value: Record, + field: string, + minimum: number, + maximum: number = Number.MAX_SAFE_INTEGER, +): number | undefined { + const candidate = value[field]; + if ( + !Number.isSafeInteger(candidate) || + typeof candidate !== 'number' || + candidate < minimum || + candidate > maximum + ) { + return undefined; + } + return candidate; +} + +function parseGoalState(value: unknown): GoalState | undefined { + if (!isRecord(value) || value['version'] !== STATE_VERSION) return undefined; + if ( + typeof value['goalId'] !== 'string' || + value['goalId'].length === 0 || + value['goalId'].length > 128 + ) { + return undefined; + } + if ( + typeof value['statement'] !== 'string' || + value['statement'].length === 0 || + value['statement'].length > MAX_STATEMENT_LENGTH + ) { + return undefined; + } + if (!isOneOf(value['phase'], GOAL_PHASES)) return undefined; + if (!isOneOf(value['lastCheckSource'], CHECK_SOURCES)) return undefined; + if ( + typeof value['startedAt'] !== 'string' || + value['startedAt'].length > 64 || + typeof value['updatedAt'] !== 'string' || + value['updatedAt'].length > 64 + ) { + return undefined; + } + if ( + typeof value['lastCheckAt'] !== 'string' || + value['lastCheckAt'].length > 64 || + typeof value['lastCheckOutcome'] !== 'string' || + value['lastCheckOutcome'].length > 256 + ) { + return undefined; + } + + const turnCount = readIntegerField(value, 'turnCount', 0, 500); + const reportCount = readIntegerField(value, 'reportCount', 0, 100_000); + const verificationPasses = readIntegerField( + value, + 'verificationPasses', + 0, + REQUIRED_VERIFICATION_PASSES, + ); + const requiredVerificationPasses = readIntegerField( + value, + 'requiredVerificationPasses', + REQUIRED_VERIFICATION_PASSES, + REQUIRED_VERIFICATION_PASSES, + ); + const noProgressReports = readIntegerField(value, 'noProgressReports', 0, 100); + const maxTurns = readIntegerField(value, 'maxTurns', 1, 500); + const maxNoProgressReports = readIntegerField(value, 'maxNoProgressReports', 1, 100); + const compactionCount = readIntegerField(value, 'compactionCount', 0, 100_000); + if ( + turnCount === undefined || + reportCount === undefined || + verificationPasses === undefined || + requiredVerificationPasses === undefined || + noProgressReports === undefined || + maxTurns === undefined || + maxNoProgressReports === undefined || + compactionCount === undefined + ) { + return undefined; + } + + const lastReportValue = value['lastReport']; + const lastReport = lastReportValue === undefined ? undefined : parseGoalReport(lastReportValue); + if (lastReportValue !== undefined && lastReport === undefined) return undefined; + const lastProgressFingerprintValue = value['lastProgressFingerprint']; + let lastProgressFingerprint: string | undefined; + if (lastProgressFingerprintValue !== undefined) { + if ( + typeof lastProgressFingerprintValue !== 'string' || + !/^[a-f0-9]{64}$/.test(lastProgressFingerprintValue) + ) { + return undefined; + } + lastProgressFingerprint = lastProgressFingerprintValue; + } + const stopReasonValue = value['stopReason']; + let stopReason: string | undefined; + if (stopReasonValue !== undefined) { + if (typeof stopReasonValue !== 'string' || stopReasonValue.length > MAX_SUMMARY_LENGTH) { + return undefined; + } + stopReason = stopReasonValue; + } + + return { + version: STATE_VERSION, + goalId: value['goalId'], + statement: value['statement'], + phase: value['phase'], + startedAt: value['startedAt'], + updatedAt: value['updatedAt'], + turnCount, + reportCount, + verificationPasses, + requiredVerificationPasses, + noProgressReports, + maxTurns, + maxNoProgressReports, + compactionCount, + lastCheckSource: value['lastCheckSource'], + lastCheckAt: value['lastCheckAt'], + lastCheckOutcome: value['lastCheckOutcome'], + ...(lastProgressFingerprint === undefined ? {} : { lastProgressFingerprint }), + ...(lastReport === undefined ? {} : { lastReport }), + ...(stopReason === undefined ? {} : { stopReason }), + }; +} + +function copyReport(report: GoalReport): GoalReport { + return { + ...report, + evidence: [...report.evidence], + }; +} + +function copyState(state: GoalState): GoalState { + return { + ...state, + ...(state.lastReport === undefined ? {} : { lastReport: copyReport(state.lastReport) }), + }; +} + +function parseGoalReportInput(value: unknown): GoalReportInput { + if (!isRecord(value) || !isOneOf(value['status'], REPORT_STATUSES)) { + throw new Error('Goal report status must be continue, achieved, or blocked.'); + } + if (typeof value['summary'] !== 'string') throw new Error('Goal report summary is required.'); + const summary = value['summary'].trim(); + if (summary.length === 0 || summary.length > MAX_SUMMARY_LENGTH) { + throw new Error(`Goal report summary must be 1-${MAX_SUMMARY_LENGTH} characters.`); + } + const evidence = parseStringArray(value['evidence']); + if (evidence === undefined) throw new Error('Goal report evidence is invalid.'); + if (value['status'] === 'achieved' && evidence.length === 0) { + throw new Error('An achieved goal report requires concrete evidence.'); + } + const rawNextStep = value['nextStep']; + if (rawNextStep !== undefined && typeof rawNextStep !== 'string') { + throw new Error('Goal report nextStep must be text.'); + } + const nextStep = typeof rawNextStep === 'string' ? rawNextStep.trim() : undefined; + if (nextStep !== undefined && (nextStep.length === 0 || nextStep.length > MAX_NEXT_STEP_LENGTH)) { + throw new Error(`Goal report nextStep must be 1-${MAX_NEXT_STEP_LENGTH} characters.`); + } + return redactGoalReportInput({ + status: value['status'], + summary, + evidence, + ...(nextStep === undefined ? {} : { nextStep }), + }); +} + +function reportFingerprint(report: GoalReportInput): string { + const normalized = JSON.stringify({ + summary: report.summary.trim().toLowerCase(), + evidence: report.evidence.map((item: string): string => item.trim().toLowerCase()), + nextStep: report.nextStep?.trim().toLowerCase() ?? '', + }); + return createHash('sha256').update(normalized).digest('hex'); +} + +function redactGoalReportInput(report: GoalReportInput): GoalReportInput { + return { + status: report.status, + summary: redactSensitiveText(report.summary), + evidence: report.evidence.map(redactSensitiveText), + ...(report.nextStep === undefined ? {} : { nextStep: redactSensitiveText(report.nextStep) }), + }; +} + +function redactGoalReport(report: GoalReport): GoalReport { + const redactedInput = redactGoalReportInput(report); + return { + ...redactedInput, + fingerprint: reportFingerprint(redactedInput), + reportedAt: report.reportedAt, + }; +} + +function redactGoalState(state: GoalState): GoalState { + const lastReport = + state.lastReport === undefined ? undefined : redactGoalReport(state.lastReport); + const lastProgressFingerprint = + lastReport !== undefined && state.lastProgressFingerprint === state.lastReport?.fingerprint + ? lastReport.fingerprint + : state.lastProgressFingerprint; + return { + ...state, + statement: redactSensitiveText(state.statement), + ...(lastReport === undefined ? {} : { lastReport }), + ...(lastProgressFingerprint === undefined ? {} : { lastProgressFingerprint }), + ...(state.stopReason === undefined + ? {} + : { stopReason: redactSensitiveText(state.stopReason) }), + }; +} + +function goalStateContainsSensitiveText(state: GoalState): boolean { + const textValues = [state.statement]; + if (state.stopReason !== undefined) textValues.push(state.stopReason); + if (state.lastReport !== undefined) { + textValues.push(state.lastReport.summary, ...state.lastReport.evidence); + if (state.lastReport.nextStep !== undefined) textValues.push(state.lastReport.nextStep); + } + return textValues.some((value: string): boolean => redactSensitiveText(value) !== value); +} + +function isContinuingPhase(phase: GoalPhase): boolean { + return phase === 'active' || phase === 'verifying'; +} + +function canReplaceGoal(state: GoalState | undefined): boolean { + return state === undefined || state.phase === 'achieved' || state.phase === 'cancelled'; +} + +function parseGoalCommand(args: string): GoalCommand { + const trimmed = args.trim(); + if (trimmed.length === 0) return { action: 'help', value: '' }; + const separator = trimmed.indexOf(' '); + const first = (separator === -1 ? trimmed : trimmed.slice(0, separator)).toLowerCase(); + const value = separator === -1 ? '' : trimmed.slice(separator + 1).trim(); + if (first === 'set') return { action: 'set', value }; + if (first === 'status') return { action: 'status', value }; + if (first === 'pause') return { action: 'pause', value }; + if (first === 'resume') return { action: 'resume', value }; + if (first === 'cancel' || first === 'clear') return { action: 'cancel', value }; + if (first === 'help') return { action: 'help', value }; + return { action: 'set', value: trimmed }; +} + +function formatStatus(state: GoalState | undefined): string { + if (state === undefined) return 'No Mosaic goal is set. Use /goal set .'; + const lines = [ + `Goal ${state.goalId}`, + `Phase: ${state.phase}`, + `Turns: ${state.turnCount}/${state.maxTurns}`, + `Verification: ${state.verificationPasses}/${state.requiredVerificationPasses}`, + `No-progress reports: ${state.noProgressReports}/${state.maxNoProgressReports}`, + `Compactions checked: ${state.compactionCount}`, + `Goal: ${state.statement}`, + ]; + if (state.lastReport !== undefined) { + lines.push(`Latest report: ${state.lastReport.status} — ${state.lastReport.summary}`); + if (state.lastReport.evidence.length > 0) { + lines.push( + 'Evidence:', + ...state.lastReport.evidence.map((item: string): string => `- ${item}`), + ); + } + if (state.lastReport.nextStep !== undefined) { + lines.push(`Next step: ${state.lastReport.nextStep}`); + } + } + if (state.stopReason !== undefined) lines.push(`Stopped: ${state.stopReason}`); + return lines.join('\n'); +} + +function buildGoalContract(state: GoalState): string { + const latest = state.lastReport; + const verificationInstruction = + state.phase === 'verifying' + ? 'This is a verification pass. Re-inspect the actual result and rerun relevant checks; do not rely only on the prior claim.' + : 'Continue making concrete progress toward the goal.'; + const lines = [ + '[MOSAIC GOAL LOOP v1]', + `Goal ID: ${state.goalId}`, + `Goal: ${state.statement}`, + `Phase: ${state.phase}`, + `Budget: turn ${state.turnCount}/${state.maxTurns}; repeated no-progress reports ${state.noProgressReports}/${state.maxNoProgressReports}.`, + verificationInstruction, + '', + 'Completion protocol:', + `- Before ending the work cycle, call ${GOAL_REPORT_TOOL} as the only tool call in the final assistant response.`, + '- Use status=continue whenever any requirement remains and provide the next concrete step.', + '- Use status=achieved only when concrete evidence covers the entire stated goal.', + '- Use status=blocked only for a genuine blocker that prevents meaningful progress.', + `- Achievement requires ${state.requiredVerificationPasses} consecutive evidence-bearing reports; the first claim starts a separate verification pass.`, + '- Never include secrets, tokens, credentials, private keys, or raw sensitive output in a report.', + '- Do not ask routine permission to continue. The operator can pause or cancel with /goal.', + ]; + if (latest !== undefined) { + lines.push('', `Previous report: ${latest.status} — ${latest.summary}`); + if (latest.nextStep !== undefined) lines.push(`Previous next step: ${latest.nextStep}`); + } + return lines.join('\n'); +} + +function continuationText(state: GoalState, reason: string): string { + if (state.phase === 'verifying') { + return `Goal ${state.goalId} requires a verification pass after ${reason}. Recheck the complete goal and report fresh evidence with ${GOAL_REPORT_TOOL}.`; + } + return `Goal remains active after ${reason}. Continue from the latest evidence and finish by calling ${GOAL_REPORT_TOOL}.`; +} + +function toolResultIncludesGoalReport(toolResults: unknown): boolean { + if (!Array.isArray(toolResults)) return false; + return toolResults.some( + (result: unknown): boolean => isRecord(result) && result['toolName'] === GOAL_REPORT_TOOL, + ); +} + +function toolResultCount(toolResults: unknown): number { + return Array.isArray(toolResults) ? toolResults.length : 0; +} + +export default function registerGoalExtension(pi: ExtensionAPI): void { + const limits = readGoalLimits(process.env); + let state: GoalState | undefined; + let continuationQueued = false; + let deferredTimer: ReturnType | undefined; + let lifecycleGeneration = 0; + let reportRollbackState: GoalState | undefined; + + function updateStatus(ctx: ExtensionContext): void { + if (state === undefined || state.phase === 'cancelled') { + ctx.ui.setStatus(STATUS_KEY, undefined); + return; + } + ctx.ui.setStatus(STATUS_KEY, `🎯 ${state.phase} ${state.turnCount}/${state.maxTurns}`); + } + + function persist(nextState: GoalState, ctx: ExtensionContext): void { + const redactedState = redactGoalState(nextState); + state = copyState(redactedState); + pi.appendEntry(STATE_ENTRY_TYPE, copyState(redactedState)); + updateStatus(ctx); + } + + function clearDeferredTimer(): void { + if (deferredTimer !== undefined) clearTimeout(deferredTimer); + deferredTimer = undefined; + } + + function queueContinuation( + ctx: ExtensionContext, + reason: string, + trackDuplicate: boolean = true, + ): void { + if (state === undefined || !isContinuingPhase(state.phase)) return; + if (ctx.hasPendingMessages()) return; + if (trackDuplicate && continuationQueued) return; + if (trackDuplicate) continuationQueued = true; + const options = ctx.isIdle() + ? { triggerTurn: true as const } + : { triggerTurn: true as const, deliverAs: 'followUp' as const }; + pi.sendMessage( + { + customType: CONTINUATION_MESSAGE_TYPE, + content: continuationText(state, reason), + display: true, + }, + options, + ); + } + + function scheduleIdleContinuation(ctx: ExtensionContext, reason: string): void { + clearDeferredTimer(); + const scheduledGeneration = lifecycleGeneration; + deferredTimer = setTimeout((): void => { + deferredTimer = undefined; + if (scheduledGeneration !== lifecycleGeneration || !ctx.isIdle()) return; + queueContinuation(ctx, reason); + }, DEFERRED_CONTINUATION_MS); + } + + function restoreState(ctx: ExtensionContext): void { + state = undefined; + let rejectedSensitiveState = false; + for (const entry of ctx.sessionManager.getBranch()) { + if (entry.type !== 'custom' || entry.customType !== STATE_ENTRY_TYPE) continue; + const restoredState = parseGoalState(entry.data); + if (restoredState === undefined) { + state = undefined; + } else if (goalStateContainsSensitiveText(restoredState)) { + state = undefined; + rejectedSensitiveState = true; + } else if (rejectedSensitiveState) { + state = undefined; + } else { + state = redactGoalState(restoredState); + } + } + continuationQueued = false; + updateStatus(ctx); + if (rejectedSensitiveState) { + ctx.ui.notify( + 'Mosaic goal state was not restored because persisted text matched a credential pattern. Remove the affected Pi session if it may contain a real secret, then set a new goal.', + 'warning', + ); + } + } + + function createGoal(statement: string, ctx: ExtensionContext): void { + if (!canReplaceGoal(state)) { + ctx.ui.notify( + 'A Mosaic goal already exists. Use /goal cancel before replacing it.', + 'warning', + ); + return; + } + if (statement.length === 0 || statement.length > MAX_STATEMENT_LENGTH) { + ctx.ui.notify( + `Usage: /goal set (${MAX_STATEMENT_LENGTH.toLocaleString()} characters maximum).`, + 'warning', + ); + return; + } + const timestamp = nowIso(); + const redactedStatement = redactSensitiveText(statement); + persist( + { + version: STATE_VERSION, + goalId: randomUUID(), + statement: redactedStatement, + phase: 'active', + startedAt: timestamp, + updatedAt: timestamp, + turnCount: 0, + reportCount: 0, + verificationPasses: 0, + requiredVerificationPasses: REQUIRED_VERIFICATION_PASSES, + noProgressReports: 0, + maxTurns: limits.maxTurns, + maxNoProgressReports: limits.maxNoProgressReports, + compactionCount: 0, + lastCheckSource: 'command', + lastCheckAt: timestamp, + lastCheckOutcome: 'set', + }, + ctx, + ); + ctx.ui.notify(`Mosaic goal started: ${redactedStatement}`, 'info'); + if (ctx.isIdle()) queueContinuation(ctx, 'goal start', false); + } + + pi.registerCommand('goal', { + description: 'Set or control a persistent Mosaic goal loop', + handler: async (args, ctx): Promise => { + const command = parseGoalCommand(args); + if (command.action === 'help') { + ctx.ui.notify( + [ + 'Mosaic goal commands:', + '/goal set (or /goal )', + '/goal status', + '/goal pause [reason]', + '/goal resume', + '/goal cancel', + ].join('\n'), + 'info', + ); + return; + } + if (command.action === 'status') { + ctx.ui.notify(formatStatus(state), 'info'); + return; + } + if (command.action === 'set') { + createGoal(command.value, ctx); + return; + } + if (state === undefined) { + ctx.ui.notify('No Mosaic goal is set.', 'warning'); + return; + } + const timestamp = nowIso(); + if (command.action === 'pause') { + if (!isContinuingPhase(state.phase)) { + ctx.ui.notify(`Goal cannot be paused from phase ${state.phase}.`, 'warning'); + return; + } + persist( + { + ...state, + phase: 'paused', + updatedAt: timestamp, + lastCheckSource: 'command', + lastCheckAt: timestamp, + lastCheckOutcome: 'paused', + stopReason: command.value || 'Paused by operator.', + }, + ctx, + ); + clearDeferredTimer(); + continuationQueued = false; + if (!ctx.isIdle()) ctx.abort(); + ctx.ui.notify('Mosaic goal paused.', 'info'); + return; + } + if (command.action === 'cancel') { + persist( + { + ...state, + phase: 'cancelled', + updatedAt: timestamp, + lastCheckSource: 'command', + lastCheckAt: timestamp, + lastCheckOutcome: 'cancelled', + stopReason: 'Cancelled by operator.', + }, + ctx, + ); + clearDeferredTimer(); + continuationQueued = false; + if (!ctx.isIdle()) ctx.abort(); + ctx.ui.notify('Mosaic goal cancelled.', 'info'); + return; + } + if (command.action === 'resume') { + if (state.phase !== 'paused' && state.phase !== 'blocked' && state.phase !== 'exhausted') { + ctx.ui.notify(`Goal cannot be resumed from phase ${state.phase}.`, 'warning'); + return; + } + persist( + { + ...state, + phase: 'active', + updatedAt: timestamp, + turnCount: 0, + verificationPasses: 0, + noProgressReports: 0, + lastProgressFingerprint: undefined, + lastCheckSource: 'command', + lastCheckAt: timestamp, + lastCheckOutcome: 'resumed', + stopReason: undefined, + }, + ctx, + ); + continuationQueued = false; + ctx.ui.notify('Mosaic goal resumed with fresh bounded counters.', 'info'); + if (ctx.isIdle()) queueContinuation(ctx, 'operator resume', false); + } + }, + }); + + pi.registerTool({ + name: GOAL_REPORT_TOOL, + label: 'Mosaic Goal Report', + description: + 'Report structured progress for the active Mosaic /goal loop. Call it as the sole final tool when a work cycle is ready to stop, continue, verify, or block.', + promptSnippet: 'Report evidence-backed status for the active Mosaic goal loop', + promptGuidelines: [ + 'When a Mosaic goal is active, call mosaic_goal_report as the sole tool in the final assistant response for each work cycle.', + 'Use mosaic_goal_report status=achieved only when concrete evidence covers the entire active goal.', + ], + parameters: GoalReportParameters, + async execute(_toolCallId, params, _signal, _onUpdate, ctx) { + if (state === undefined || !isContinuingPhase(state.phase)) { + throw new Error( + 'No active Mosaic goal can accept a report. Use /goal set or /goal resume.', + ); + } + const input = parseGoalReportInput(params); + const fingerprint = reportFingerprint(input); + const timestamp = nowIso(); + const report: GoalReport = { + ...input, + fingerprint, + reportedAt: timestamp, + }; + reportRollbackState = copyState(state); + + let nextState: GoalState; + if (input.status === 'continue') { + const noProgressReports = + fingerprint === state.lastProgressFingerprint ? state.noProgressReports + 1 : 1; + const exhausted = noProgressReports >= state.maxNoProgressReports; + nextState = { + ...state, + phase: exhausted ? 'exhausted' : 'active', + updatedAt: timestamp, + reportCount: state.reportCount + 1, + verificationPasses: 0, + noProgressReports, + lastProgressFingerprint: fingerprint, + lastReport: report, + lastCheckSource: 'report', + lastCheckAt: timestamp, + lastCheckOutcome: exhausted ? 'no-progress-limit' : 'continue', + ...(exhausted + ? { + stopReason: `Repeated no-progress report limit reached (${state.maxNoProgressReports}).`, + } + : { stopReason: undefined }), + }; + } else if (input.status === 'achieved') { + const verificationPasses = state.phase === 'verifying' ? state.verificationPasses + 1 : 1; + const achieved = verificationPasses >= state.requiredVerificationPasses; + nextState = { + ...state, + phase: achieved ? 'achieved' : 'verifying', + updatedAt: timestamp, + reportCount: state.reportCount + 1, + verificationPasses, + noProgressReports: 0, + lastProgressFingerprint: fingerprint, + lastReport: report, + lastCheckSource: 'report', + lastCheckAt: timestamp, + lastCheckOutcome: achieved ? 'verified-achieved' : 'provisional-achieved', + stopReason: undefined, + }; + } else { + nextState = { + ...state, + phase: 'blocked', + updatedAt: timestamp, + reportCount: state.reportCount + 1, + verificationPasses: 0, + noProgressReports: 0, + lastProgressFingerprint: fingerprint, + lastReport: report, + lastCheckSource: 'report', + lastCheckAt: timestamp, + lastCheckOutcome: 'blocked', + stopReason: input.summary, + }; + } + + persist(nextState, ctx); + if (nextState.phase === 'achieved') { + ctx.ui.notify(`Mosaic goal verified.\n${formatStatus(nextState)}`, 'info'); + } else if (nextState.phase === 'verifying') { + ctx.ui.notify( + 'Goal achievement is provisional; one verification pass is required.', + 'info', + ); + } else if (nextState.phase === 'blocked' || nextState.phase === 'exhausted') { + ctx.ui.notify(`Mosaic goal stopped in phase ${nextState.phase}.`, 'warning'); + } + + return { + content: [ + { + type: 'text', + text: + nextState.phase === 'achieved' + ? 'Goal verification complete.' + : `Goal report recorded; phase is ${nextState.phase}.`, + }, + ], + details: { state: copyState(nextState), report: copyReport(report) }, + terminate: true, + }; + }, + }); + + pi.on('context', async (event) => { + const messages = event.messages.filter( + (message) => + message.role !== 'custom' || + (message.customType !== CONTEXT_MESSAGE_TYPE && + message.customType !== CONTINUATION_MESSAGE_TYPE), + ); + if (state === undefined || !isContinuingPhase(state.phase)) { + return messages.length === event.messages.length ? undefined : { messages }; + } + messages.push({ + role: 'custom', + customType: CONTEXT_MESSAGE_TYPE, + content: buildGoalContract(state), + display: false, + timestamp: Date.now(), + }); + return { messages }; + }); + + pi.on('turn_end', async (event, ctx) => { + if (state === undefined || state.phase === 'paused' || state.phase === 'cancelled') return; + const hasGoalReport = toolResultIncludesGoalReport(event.toolResults); + if (!isContinuingPhase(state.phase) && !hasGoalReport) return; + + const timestamp = nowIso(); + let nextState = { + ...state, + updatedAt: timestamp, + turnCount: state.turnCount + 1, + lastCheckSource: 'turn' as const, + lastCheckAt: timestamp, + lastCheckOutcome: hasGoalReport ? 'reported' : 'checked-unreported', + }; + + if ( + hasGoalReport && + toolResultCount(event.toolResults) !== 1 && + reportRollbackState !== undefined + ) { + nextState = { + ...reportRollbackState, + phase: 'active', + updatedAt: timestamp, + turnCount: reportRollbackState.turnCount + 1, + verificationPasses: 0, + lastCheckSource: 'turn', + lastCheckAt: timestamp, + lastCheckOutcome: 'mixed-goal-report-rejected', + stopReason: undefined, + }; + ctx.ui.notify( + `${GOAL_REPORT_TOOL} must be the only tool call in its final response; the mixed report was ignored.`, + 'warning', + ); + } + reportRollbackState = undefined; + + if (isContinuingPhase(nextState.phase) && nextState.turnCount >= nextState.maxTurns) { + nextState = { + ...nextState, + phase: 'exhausted', + lastCheckOutcome: 'max-turn-limit', + stopReason: `Maximum autonomous turn limit reached (${nextState.maxTurns}).`, + }; + persist(nextState, ctx); + ctx.ui.notify('Mosaic goal exhausted its autonomous turn limit.', 'warning'); + ctx.abort(); + return; + } + persist(nextState, ctx); + }); + + pi.on('agent_start', async () => { + continuationQueued = false; + clearDeferredTimer(); + }); + + pi.on('agent_settled', async (_event, ctx) => { + if (state === undefined || !isContinuingPhase(state.phase)) return; + queueContinuation(ctx, state.phase === 'verifying' ? 'the provisional claim' : 'agent settle'); + }); + + pi.on('session_compact', async (event, ctx) => { + if (state === undefined) return; + const timestamp = nowIso(); + const wasContinuing = isContinuingPhase(state.phase); + persist( + { + ...state, + phase: wasContinuing ? 'active' : state.phase, + updatedAt: timestamp, + verificationPasses: wasContinuing ? 0 : state.verificationPasses, + compactionCount: state.compactionCount + 1, + lastCheckSource: 'compact', + lastCheckAt: timestamp, + lastCheckOutcome: `checked-${event.reason}`, + ...(wasContinuing ? { stopReason: undefined } : {}), + }, + ctx, + ); + if (wasContinuing) scheduleIdleContinuation(ctx, `${event.reason} compaction`); + }); + + pi.on('session_start', async (_event, ctx) => { + lifecycleGeneration += 1; + clearDeferredTimer(); + restoreState(ctx); + if (state !== undefined && isContinuingPhase(state.phase)) { + const timestamp = nowIso(); + persist( + { + ...state, + updatedAt: timestamp, + lastCheckSource: 'restore', + lastCheckAt: timestamp, + lastCheckOutcome: 'session-start', + }, + ctx, + ); + scheduleIdleContinuation(ctx, 'session restore'); + } + }); + + pi.on('session_tree', async (_event, ctx) => { + lifecycleGeneration += 1; + clearDeferredTimer(); + restoreState(ctx); + if (state !== undefined && isContinuingPhase(state.phase)) { + const timestamp = nowIso(); + persist( + { + ...state, + updatedAt: timestamp, + lastCheckSource: 'restore', + lastCheckAt: timestamp, + lastCheckOutcome: 'tree-navigation', + }, + ctx, + ); + scheduleIdleContinuation(ctx, 'tree navigation'); + } + }); + + pi.on('session_shutdown', async (_event, ctx) => { + lifecycleGeneration += 1; + clearDeferredTimer(); + continuationQueued = false; + ctx.ui.setStatus(STATUS_KEY, undefined); + }); +} diff --git a/packages/mosaic/framework/skills/README.md b/packages/mosaic/framework/skills/README.md new file mode 100644 index 00000000..e796b233 --- /dev/null +++ b/packages/mosaic/framework/skills/README.md @@ -0,0 +1,228 @@ +# Agent Skills + +Complete agent skill fleet for Mosaic Stack. 101 skills across 12 domains — coding, business development, design, marketing, writing, orchestration, document generation, Vue/Vite ecosystem, and more. Platform-aware — works with both GitHub (`gh`) and Gitea (`tea`) via our abstraction scripts. + +This tree lives in the monorepo (`packages/mosaic/framework/skills/`) and ships inside the framework package; it is no longer a separate repository. + +## Security Audit + +All skills were reviewed on 2026-02-16. Findings: + +| ID | Severity | Skill | Issue | Action | +| ----- | ------------- | ---------------------- | --------------------------------------------------------------- | ---------------------------------------------------------------------------------------------- | +| C-001 | **CRITICAL** | `vercel-deploy` | Uploads entire project to external endpoint via `curl` | **REMOVED** | +| C-002 | **ANNOTATED** | `docx`, `pptx`, `xlsx` | LD_PRELOAD shim compiles C at runtime to hook `socket()` | Security warnings added — legitimate sandbox workaround, should never activate on Docker Swarm | +| W-001 | WARNING | `using-superpowers` | Forces aggressive auto-loading via `` tags | Awareness only — review before enabling | +| W-002 | WARNING | `mcp-builder` | Can connect to arbitrary MCP servers | Awareness only — review server URLs | +| W-003 | WARNING | `create-agent` | Uses `Function()` constructor (eval equivalent) | Awareness only — review generated code | + +88 of 93 audited skills passed all checks as clean instruction-only SKILL.md files. + +## Skills (95) + +### Code Quality & Review (6) + +| Skill | Purpose | Origin | +| -------------------------------- | ------------------------------------------------------------------------------- | ------------------------------- | +| `lint` | Zero-tolerance linting — detect linter, fix ALL violations, never disable rules | Mosaic Stack | +| `pr-reviewer` | Structured PR code review workflow (Gitea/GitHub) | Adapted from SpillwaveSolutions | +| `code-review-excellence` | Code review methodology and checklists | awesome-skills | +| `verification-before-completion` | Evidence-based completion claims | obra/superpowers | +| `receiving-code-review` | How to receive and respond to code reviews | obra/superpowers | +| `requesting-code-review` | How to request effective code reviews | obra/superpowers | + +### Frontend & UI (8) + +| Skill | Purpose | Origin | +| ----------------------------- | ----------------------------------------------------- | ----------------------- | +| `next-best-practices` | Next.js 15+ — RSC, async, self-hosting, data patterns | vercel-labs/next-skills | +| `vercel-react-best-practices` | React/Next.js performance (57 rules) | vercel-labs | +| `vercel-composition-patterns` | React composition and component patterns | vercel-labs | +| `vercel-react-native-skills` | React Native development patterns | vercel-labs | +| `shadcn-ui` | Component patterns — forms, dialogs, tables, charts | developer-kit | +| `tailwind-design-system` | Tailwind CSS v4 design system patterns | wshobson | +| `ui-animation` | Motion design — performance, accessibility, easing | mblode | +| `web-design-guidelines` | Web design principles and guidelines | vercel-labs | + +### Backend & API (4) + +| Skill | Purpose | Origin | +| --------------------------------- | ------------------------------------------------- | -------- | +| `nestjs-best-practices` | NestJS — 40 rules, 10 categories, priority-ranked | kadajett | +| `fastapi` | FastAPI + Pydantic v2 + async SQLAlchemy 2.0 | jezweb | +| `architecture-patterns` | Clean Architecture, Hexagonal, DDD | wshobson | +| `python-performance-optimization` | Profiling, memory, parallelization | wshobson | + +### Authentication (5) + +| Skill | Purpose | Origin | +| ------------------------------------------ | -------------------------------------------------- | ----------- | +| `better-auth-best-practices` | Better-Auth — Drizzle, sessions, plugins, security | better-auth | +| `create-auth-skill` | Creating custom Better-Auth skills | better-auth | +| `email-and-password-best-practices` | Email/password auth patterns | better-auth | +| `organization-best-practices` | Multi-org/team auth patterns | better-auth | +| `two-factor-authentication-best-practices` | 2FA implementation patterns | better-auth | + +### AI & Agent Building (7) + +| Skill | Purpose | Origin | +| ----------------------------- | --------------------------------------------------- | ---------------- | +| `ai-sdk` | Vercel AI SDK — streaming, multi-provider, agents | vercel/ai | +| `create-agent` | Modular agent with OpenRouter multi-model access | openrouterteam | +| `proactive-agent` | WAL Protocol, compaction recovery, self-improvement | halthelobster | +| `dispatching-parallel-agents` | Launching and managing parallel subagents | obra/superpowers | +| `subagent-driven-development` | Development workflow using subagents | obra/superpowers | +| `executing-plans` | Executing multi-step implementation plans | obra/superpowers | +| `using-superpowers` | Overview of the superpowers skill system | obra/superpowers | + +### Development Workflow (6) + +| Skill | Purpose | Origin | +| -------------------------------- | --------------------------------------- | ---------------- | +| `test-driven-development` | TDD Red-Green-Refactor discipline | obra/superpowers | +| `systematic-debugging` | Structured debugging methodology | obra/superpowers | +| `using-git-worktrees` | Git worktree patterns for parallel work | obra/superpowers | +| `finishing-a-development-branch` | Branch cleanup, squash, merge patterns | obra/superpowers | +| `writing-plans` | Writing effective implementation plans | obra/superpowers | +| `brainstorming` | Structured brainstorming methodology | obra/superpowers | + +### Document Generation (6) + +| Skill | Purpose | Origin | +| ----------------- | ---------------------------------- | ---------- | +| `pdf` | PDF document generation | anthropics | +| `docx` | Word document generation | anthropics | +| `pptx` | PowerPoint presentation generation | anthropics | +| `xlsx` | Excel spreadsheet generation | anthropics | +| `doc-coauthoring` | Collaborative document writing | anthropics | +| `internal-comms` | Internal communications drafting | anthropics | + +### Design & Creative (7) + +| Skill | Purpose | Origin | +| ----------------------- | --------------------------------------- | ---------- | +| `brand-guidelines` | Brand identity enforcement | anthropics | +| `frontend-design` | Frontend design patterns and principles | anthropics | +| `canvas-design` | Canvas/visual design patterns | anthropics | +| `algorithmic-art` | Generative/algorithmic art creation | anthropics | +| `theme-factory` | Theme generation and customization | anthropics | +| `slack-gif-creator` | Animated GIF creation for Slack | anthropics | +| `web-artifacts-builder` | Self-contained HTML artifact building | anthropics | + +### Marketing & Business (25) + +| Skill | Purpose | Origin | +| --------------------------- | --------------------------------------------- | ------------- | +| `marketing-ideas` | 139 ideas across 14 categories | coreyhaines31 | +| `pricing-strategy` | SaaS pricing — value metrics, tiers, research | coreyhaines31 | +| `programmatic-seo` | SEO at scale — templates, playbooks | coreyhaines31 | +| `competitor-alternatives` | Competitor comparison pages | coreyhaines31 | +| `referral-program` | Referral & affiliate programs | coreyhaines31 | +| `seo-audit` | Comprehensive SEO audit methodology | coreyhaines31 | +| `copywriting` | Marketing copywriting patterns | coreyhaines31 | +| `copy-editing` | Copy editing and proofreading | coreyhaines31 | +| `content-strategy` | Content strategy and planning | coreyhaines31 | +| `social-content` | Social media content creation | coreyhaines31 | +| `email-sequence` | Email sequence design and automation | coreyhaines31 | +| `launch-strategy` | Product launch planning | coreyhaines31 | +| `marketing-psychology` | Psychology-driven marketing | coreyhaines31 | +| `product-marketing-context` | Product marketing positioning | coreyhaines31 | +| `paid-ads` | Paid advertising campaigns | coreyhaines31 | +| `schema-markup` | Schema.org structured data | coreyhaines31 | +| `analytics-tracking` | Analytics setup and tracking | coreyhaines31 | +| `ab-test-setup` | A/B testing methodology | coreyhaines31 | +| `page-cro` | Landing page conversion optimization | coreyhaines31 | +| `form-cro` | Form conversion optimization | coreyhaines31 | +| `signup-flow-cro` | Signup flow conversion optimization | coreyhaines31 | +| `onboarding-cro` | User onboarding optimization | coreyhaines31 | +| `popup-cro` | Popup/modal conversion optimization | coreyhaines31 | +| `paywall-upgrade-cro` | Paywall/upgrade conversion optimization | coreyhaines31 | +| `free-tool-strategy` | Free tool as marketing strategy | coreyhaines31 | + +### Vue/Vite Ecosystem (16) + +| Skill | Purpose | Origin | +| ---------------------------- | ----------------------------------------- | ------ | +| `vue` | Vue.js development patterns | antfu | +| `vue-best-practices` | Vue.js best practices and conventions | antfu | +| `vue-router-best-practices` | Vue Router patterns and guards | antfu | +| `vue-testing-best-practices` | Vue component testing patterns | antfu | +| `vueuse-functions` | VueUse composable function patterns | antfu | +| `nuxt` | Nuxt.js framework patterns | antfu | +| `vite` | Vite build tool configuration and plugins | antfu | +| `vitest` | Vitest testing framework patterns | antfu | +| `vitepress` | VitePress documentation site patterns | antfu | +| `slidev` | Slidev presentation framework | antfu | +| `pnpm` | pnpm package manager patterns | antfu | +| `turborepo` | Turborepo monorepo patterns | antfu | +| `unocss` | UnoCSS atomic CSS engine | antfu | +| `tsdown` | tsdown TypeScript bundler | antfu | +| `pinia` | Pinia state management | antfu | +| `antfu` | Anthony Fu's coding conventions | antfu | + +### Orchestration (1) + +| Skill | Purpose | Origin | +| ----------- | ------------------------------------------------------------------------------------------ | ------------ | +| `kickstart` | Launch orchestrator for milestone/issue/task — auto-discovers context, bootstraps tracking | Mosaic Stack | + +### Meta / Skill Authoring (4) + +| Skill | Purpose | Origin | +| ---------------- | --------------------------------------------- | ---------------- | +| `writing-skills` | TDD-based skill authoring methodology | obra/superpowers | +| `skill-creator` | Anthropic's skill creation guide | anthropics | +| `mcp-builder` | Building MCP (Model Context Protocol) servers | anthropics | +| `webapp-testing` | Web application testing patterns | anthropics | + +## Source Repositories + +| Repository | Skills | Domain Focus | +| --------------------------------------------------------------------------------------------- | ------ | ---------------------------------------------- | +| [anthropics/skills](https://github.com/anthropics/skills) | 16 | Documents, design, MCP, testing | +| [obra/superpowers](https://github.com/obra/superpowers) | 14 | Agent workflows, TDD, code review, planning | +| [coreyhaines31/marketingskills](https://github.com/coreyhaines31/marketingskills) | 25 | Marketing, CRO, SEO, growth | +| [antfu/skills](https://github.com/antfu/skills) | 16 | Vue, Vite, Vitest, pnpm, Nuxt | +| [better-auth/skills](https://github.com/better-auth/skills) | 5 | Authentication patterns | +| [vercel-labs/agent-skills](https://github.com/vercel-labs/agent-skills) | 4 | React, design | +| [vercel-labs/next-skills](https://github.com/vercel-labs/next-skills) | 1 | Next.js 15+ | +| [vercel/ai](https://github.com/vercel/ai) | 1 | AI SDK | +| [halthelobster/proactive-agent](https://github.com/halthelobster/proactive-agent) | 1 | Agent architecture | +| [openrouterteam/agent-skills](https://github.com/openrouterteam/agent-skills) | 1 | Agent building | +| [kadajett/agent-nestjs-skills](https://github.com/kadajett/agent-nestjs-skills) | 1 | NestJS | +| [jezweb/claude-skills](https://github.com/jezweb/claude-skills) | 1 | FastAPI | +| [wshobson/agents](https://github.com/wshobson/agents) | 3 | Architecture, Python, Tailwind | +| [mblode/agent-skills](https://github.com/mblode/agent-skills) | 1 | UI animation | +| [giuseppe-trisciuoglio/developer-kit](https://github.com/giuseppe-trisciuoglio/developer-kit) | 1 | shadcn/ui | +| Mosaic Stack (original) | 4 | PR review, code review, orchestration, linting | + +## Installation + +The skills ship with the framework package. The framework installer installs them +into `~/.config/mosaic/skills/`, and the post-install step links them into each +runtime's skill directory: + +```bash +# Install or upgrade the framework (skills arrive with it — no second repo) +./packages/mosaic/framework/install.sh + +# Re-link installed skills into runtime homes (claude, codex, opencode, pi) +mosaic sync +``` + +Operators can override any canonical skill by copying it to +`~/.config/mosaic/skills-local//` — local skills take precedence during +linking. + +## Adapting Skills + +When adding skills from the community: + +1. Replace raw `gh`/`tea` calls with our `~/.config/mosaic/rails/git/` scripts +2. Test on both GitHub and Gitea repos +3. Add Mosaic Stack context notes where upstream assumptions differ +4. Document any platform-specific limitations + +## License + +Individual skills retain their original licenses. Adaptations are MIT. diff --git a/packages/mosaic/framework/skills/ab-test-setup/SKILL.md b/packages/mosaic/framework/skills/ab-test-setup/SKILL.md new file mode 100644 index 00000000..9236c925 --- /dev/null +++ b/packages/mosaic/framework/skills/ab-test-setup/SKILL.md @@ -0,0 +1,287 @@ +--- +name: ab-test-setup +version: 1.0.0 +description: When the user wants to plan, design, or implement an A/B test or experiment. Also use when the user mentions "A/B test," "split test," "experiment," "test this change," "variant copy," "multivariate test," or "hypothesis." For tracking implementation, see analytics-tracking. +--- + +# A/B Test Setup + +You are an expert in experimentation and A/B testing. Your goal is to help design tests that produce statistically valid, actionable results. + +## Initial Assessment + +**Check for product marketing context first:** +If `.mosaic/product-marketing-context.md` exists, read it before asking questions. Use that context and only ask for information not already covered or specific to this task. + +Before designing a test, understand: + +1. **Test Context** - What are you trying to improve? What change are you considering? +2. **Current State** - Baseline conversion rate? Current traffic volume? +3. **Constraints** - Technical complexity? Timeline? Tools available? + +--- + +## Core Principles + +### 1. Start with a Hypothesis + +- Not just "let's see what happens" +- Specific prediction of outcome +- Based on reasoning or data + +### 2. Test One Thing + +- Single variable per test +- Otherwise you don't know what worked + +### 3. Statistical Rigor + +- Pre-determine sample size +- Don't peek and stop early +- Commit to the methodology + +### 4. Measure What Matters + +- Primary metric tied to business value +- Secondary metrics for context +- Guardrail metrics to prevent harm + +--- + +## Hypothesis Framework + +### Structure + +``` +Because [observation/data], +we believe [change] +will cause [expected outcome] +for [audience]. +We'll know this is true when [metrics]. +``` + +### Example + +**Weak**: "Changing the button color might increase clicks." + +**Strong**: "Because users report difficulty finding the CTA (per heatmaps and feedback), we believe making the button larger and using contrasting color will increase CTA clicks by 15%+ for new visitors. We'll measure click-through rate from page view to signup start." + +--- + +## Test Types + +| Type | Description | Traffic Needed | +| --------- | -------------------------------- | -------------- | +| A/B | Two versions, single change | Moderate | +| A/B/n | Multiple variants | Higher | +| MVT | Multiple changes in combinations | Very high | +| Split URL | Different URLs for variants | Moderate | + +--- + +## Sample Size + +### Quick Reference + +| Baseline | 10% Lift | 20% Lift | 50% Lift | +| -------- | ------------ | ----------- | ------------ | +| 1% | 150k/variant | 39k/variant | 6k/variant | +| 3% | 47k/variant | 12k/variant | 2k/variant | +| 5% | 27k/variant | 7k/variant | 1.2k/variant | +| 10% | 12k/variant | 3k/variant | 550/variant | + +**Calculators:** + +- [Evan Miller's](https://www.evanmiller.org/ab-testing/sample-size.html) +- [Optimizely's](https://www.optimizely.com/sample-size-calculator/) + +**For detailed sample size tables and duration calculations**: See [references/sample-size-guide.md](references/sample-size-guide.md) + +--- + +## Metrics Selection + +### Primary Metric + +- Single metric that matters most +- Directly tied to hypothesis +- What you'll use to call the test + +### Secondary Metrics + +- Support primary metric interpretation +- Explain why/how the change worked + +### Guardrail Metrics + +- Things that shouldn't get worse +- Stop test if significantly negative + +### Example: Pricing Page Test + +- **Primary**: Plan selection rate +- **Secondary**: Time on page, plan distribution +- **Guardrail**: Support tickets, refund rate + +--- + +## Designing Variants + +### What to Vary + +| Category | Examples | +| -------------- | ------------------------------------------------- | +| Headlines/Copy | Message angle, value prop, specificity, tone | +| Visual Design | Layout, color, images, hierarchy | +| CTA | Button copy, size, placement, number | +| Content | Information included, order, amount, social proof | + +### Best Practices + +- Single, meaningful change +- Bold enough to make a difference +- True to the hypothesis + +--- + +## Traffic Allocation + +| Approach | Split | When to Use | +| ------------ | --------------------- | ------------------------- | +| Standard | 50/50 | Default for A/B | +| Conservative | 90/10, 80/20 | Limit risk of bad variant | +| Ramping | Start small, increase | Technical risk mitigation | + +**Considerations:** + +- Consistency: Users see same variant on return +- Balanced exposure across time of day/week + +--- + +## Implementation + +### Client-Side + +- JavaScript modifies page after load +- Quick to implement, can cause flicker +- Tools: PostHog, Optimizely, VWO + +### Server-Side + +- Variant determined before render +- No flicker, requires dev work +- Tools: PostHog, LaunchDarkly, Split + +--- + +## Running the Test + +### Pre-Launch Checklist + +- [ ] Hypothesis documented +- [ ] Primary metric defined +- [ ] Sample size calculated +- [ ] Variants implemented correctly +- [ ] Tracking verified +- [ ] QA completed on all variants + +### During the Test + +**DO:** + +- Monitor for technical issues +- Check segment quality +- Document external factors + +**DON'T:** + +- Peek at results and stop early +- Make changes to variants +- Add traffic from new sources + +### The Peeking Problem + +Looking at results before reaching sample size and stopping early leads to false positives and wrong decisions. Pre-commit to sample size and trust the process. + +--- + +## Analyzing Results + +### Statistical Significance + +- 95% confidence = p-value < 0.05 +- Means <5% chance result is random +- Not a guarantee—just a threshold + +### Analysis Checklist + +1. **Reach sample size?** If not, result is preliminary +2. **Statistically significant?** Check confidence intervals +3. **Effect size meaningful?** Compare to MDE, project impact +4. **Secondary metrics consistent?** Support the primary? +5. **Guardrail concerns?** Anything get worse? +6. **Segment differences?** Mobile vs. desktop? New vs. returning? + +### Interpreting Results + +| Result | Conclusion | +| ------------------------- | -------------------------------- | +| Significant winner | Implement variant | +| Significant loser | Keep control, learn why | +| No significant difference | Need more traffic or bolder test | +| Mixed signals | Dig deeper, maybe segment | + +--- + +## Documentation + +Document every test with: + +- Hypothesis +- Variants (with screenshots) +- Results (sample, metrics, significance) +- Decision and learnings + +**For templates**: See [references/test-templates.md](references/test-templates.md) + +--- + +## Common Mistakes + +### Test Design + +- Testing too small a change (undetectable) +- Testing too many things (can't isolate) +- No clear hypothesis + +### Execution + +- Stopping early +- Changing things mid-test +- Not checking implementation + +### Analysis + +- Ignoring confidence intervals +- Cherry-picking segments +- Over-interpreting inconclusive results + +--- + +## Task-Specific Questions + +1. What's your current conversion rate? +2. How much traffic does this page get? +3. What change are you considering and why? +4. What's the smallest improvement worth detecting? +5. What tools do you have for testing? +6. Have you tested this area before? + +--- + +## Related Skills + +- **page-cro**: For generating test ideas based on CRO principles +- **analytics-tracking**: For setting up test measurement +- **copywriting**: For creating variant copy diff --git a/packages/mosaic/framework/skills/ab-test-setup/references/sample-size-guide.md b/packages/mosaic/framework/skills/ab-test-setup/references/sample-size-guide.md new file mode 100644 index 00000000..25ef9121 --- /dev/null +++ b/packages/mosaic/framework/skills/ab-test-setup/references/sample-size-guide.md @@ -0,0 +1,272 @@ +# Sample Size Guide + +Reference for calculating sample sizes and test duration. + +## Sample Size Fundamentals + +### Required Inputs + +1. **Baseline conversion rate**: Your current rate +2. **Minimum detectable effect (MDE)**: Smallest change worth detecting +3. **Statistical significance level**: Usually 95% (α = 0.05) +4. **Statistical power**: Usually 80% (β = 0.20) + +### What These Mean + +**Baseline conversion rate**: If your page converts at 5%, that's your baseline. + +**MDE (Minimum Detectable Effect)**: The smallest improvement you care about detecting. Set this based on: + +- Business impact (is a 5% lift meaningful?) +- Implementation cost (worth the effort?) +- Realistic expectations (what have past tests shown?) + +**Statistical significance (95%)**: Means there's less than 5% chance the observed difference is due to random chance. + +**Statistical power (80%)**: Means if there's a real effect of size MDE, you have 80% chance of detecting it. + +--- + +## Sample Size Quick Reference Tables + +### Conversion Rate: 1% + +| Lift to Detect | Sample per Variant | Total Sample | +| --------------- | ------------------ | ------------ | +| 5% (1% → 1.05%) | 1,500,000 | 3,000,000 | +| 10% (1% → 1.1%) | 380,000 | 760,000 | +| 20% (1% → 1.2%) | 97,000 | 194,000 | +| 50% (1% → 1.5%) | 16,000 | 32,000 | +| 100% (1% → 2%) | 4,200 | 8,400 | + +### Conversion Rate: 3% + +| Lift to Detect | Sample per Variant | Total Sample | +| --------------- | ------------------ | ------------ | +| 5% (3% → 3.15%) | 480,000 | 960,000 | +| 10% (3% → 3.3%) | 120,000 | 240,000 | +| 20% (3% → 3.6%) | 31,000 | 62,000 | +| 50% (3% → 4.5%) | 5,200 | 10,400 | +| 100% (3% → 6%) | 1,400 | 2,800 | + +### Conversion Rate: 5% + +| Lift to Detect | Sample per Variant | Total Sample | +| --------------- | ------------------ | ------------ | +| 5% (5% → 5.25%) | 280,000 | 560,000 | +| 10% (5% → 5.5%) | 72,000 | 144,000 | +| 20% (5% → 6%) | 18,000 | 36,000 | +| 50% (5% → 7.5%) | 3,100 | 6,200 | +| 100% (5% → 10%) | 810 | 1,620 | + +### Conversion Rate: 10% + +| Lift to Detect | Sample per Variant | Total Sample | +| ---------------- | ------------------ | ------------ | +| 5% (10% → 10.5%) | 130,000 | 260,000 | +| 10% (10% → 11%) | 34,000 | 68,000 | +| 20% (10% → 12%) | 8,700 | 17,400 | +| 50% (10% → 15%) | 1,500 | 3,000 | +| 100% (10% → 20%) | 400 | 800 | + +### Conversion Rate: 20% + +| Lift to Detect | Sample per Variant | Total Sample | +| ---------------- | ------------------ | ------------ | +| 5% (20% → 21%) | 60,000 | 120,000 | +| 10% (20% → 22%) | 16,000 | 32,000 | +| 20% (20% → 24%) | 4,000 | 8,000 | +| 50% (20% → 30%) | 700 | 1,400 | +| 100% (20% → 40%) | 200 | 400 | + +--- + +## Duration Calculator + +### Formula + +``` +Duration (days) = (Sample per variant × Number of variants) / (Daily traffic × % exposed) +``` + +### Examples + +**Scenario 1: High-traffic page** + +- Need: 10,000 per variant (2 variants = 20,000 total) +- Daily traffic: 5,000 visitors +- 100% exposed to test +- Duration: 20,000 / 5,000 = **4 days** + +**Scenario 2: Medium-traffic page** + +- Need: 30,000 per variant (60,000 total) +- Daily traffic: 2,000 visitors +- 100% exposed +- Duration: 60,000 / 2,000 = **30 days** + +**Scenario 3: Low-traffic with partial exposure** + +- Need: 15,000 per variant (30,000 total) +- Daily traffic: 500 visitors +- 50% exposed to test +- Effective daily: 250 +- Duration: 30,000 / 250 = **120 days** (too long!) + +### Minimum Duration Rules + +Even with sufficient sample size, run tests for at least: + +- **1 full week**: To capture day-of-week variation +- **2 business cycles**: If B2B (weekday vs. weekend patterns) +- **Through paydays**: If e-commerce (beginning/end of month) + +### Maximum Duration Guidelines + +Avoid running tests longer than 4-8 weeks: + +- Novelty effects wear off +- External factors intervene +- Opportunity cost of other tests + +--- + +## Online Calculators + +### Recommended Tools + +**Evan Miller's Calculator** +https://www.evanmiller.org/ab-testing/sample-size.html + +- Simple interface +- Bookmark-worthy + +**Optimizely's Calculator** +https://www.optimizely.com/sample-size-calculator/ + +- Business-friendly language +- Duration estimates + +**AB Test Guide Calculator** +https://www.abtestguide.com/calc/ + +- Includes Bayesian option +- Multiple test types + +**VWO Duration Calculator** +https://vwo.com/tools/ab-test-duration-calculator/ + +- Duration-focused +- Good for planning + +--- + +## Adjusting for Multiple Variants + +With more than 2 variants (A/B/n tests), you need more sample: + +| Variants | Multiplier | +| ----------- | -------------------------- | +| 2 (A/B) | 1x | +| 3 (A/B/C) | ~1.5x | +| 4 (A/B/C/D) | ~2x | +| 5+ | Consider reducing variants | + +**Why?** More comparisons increase chance of false positives. You're comparing: + +- A vs B +- A vs C +- B vs C (sometimes) + +Apply Bonferroni correction or use tools that handle this automatically. + +--- + +## Common Sample Size Mistakes + +### 1. Underpowered tests + +**Problem**: Not enough sample to detect realistic effects +**Fix**: Be realistic about MDE, get more traffic, or don't test + +### 2. Overpowered tests + +**Problem**: Waiting for sample size when you already have significance +**Fix**: This is actually fine—you committed to sample size, honor it + +### 3. Wrong baseline rate + +**Problem**: Using wrong conversion rate for calculation +**Fix**: Use the specific metric and page, not site-wide averages + +### 4. Ignoring segments + +**Problem**: Calculating for full traffic, then analyzing segments +**Fix**: If you plan segment analysis, calculate sample for smallest segment + +### 5. Testing too many things + +**Problem**: Dividing traffic too many ways +**Fix**: Prioritize ruthlessly, run fewer concurrent tests + +--- + +## When Sample Size Requirements Are Too High + +Options when you can't get enough traffic: + +1. **Increase MDE**: Accept only detecting larger effects (20%+ lift) +2. **Lower confidence**: Use 90% instead of 95% (risky, document it) +3. **Reduce variants**: Test only the most promising variant +4. **Combine traffic**: Test across multiple similar pages +5. **Test upstream**: Test earlier in funnel where traffic is higher +6. **Don't test**: Make decision based on qualitative data instead +7. **Longer test**: Accept longer duration (weeks/months) + +--- + +## Sequential Testing + +If you must check results before reaching sample size: + +### What is it? + +Statistical method that adjusts for multiple looks at data. + +### When to use + +- High-risk changes +- Need to stop bad variants early +- Time-sensitive decisions + +### Tools that support it + +- Optimizely (Stats Accelerator) +- VWO (SmartStats) +- PostHog (Bayesian approach) + +### Tradeoff + +- More flexibility to stop early +- Slightly larger sample size requirement +- More complex analysis + +--- + +## Quick Decision Framework + +### Can I run this test? + +``` +Daily traffic to page: _____ +Baseline conversion rate: _____ +MDE I care about: _____ + +Sample needed per variant: _____ (from tables above) +Days to run: Sample / Daily traffic = _____ + +If days > 60: Consider alternatives +If days > 30: Acceptable for high-impact tests +If days < 14: Likely feasible +If days < 7: Easy to run, consider running longer anyway +``` diff --git a/packages/mosaic/framework/skills/ab-test-setup/references/test-templates.md b/packages/mosaic/framework/skills/ab-test-setup/references/test-templates.md new file mode 100644 index 00000000..302432f9 --- /dev/null +++ b/packages/mosaic/framework/skills/ab-test-setup/references/test-templates.md @@ -0,0 +1,292 @@ +# A/B Test Templates Reference + +Templates for planning, documenting, and analyzing experiments. + +## Test Plan Template + +```markdown +# A/B Test: [Name] + +## Overview + +- **Owner**: [Name] +- **Test ID**: [ID in testing tool] +- **Page/Feature**: [What's being tested] +- **Planned dates**: [Start] - [End] + +## Hypothesis + +Because [observation/data], +we believe [change] +will cause [expected outcome] +for [audience]. +We'll know this is true when [metrics]. + +## Test Design + +| Element | Details | +| ------------------ | ------------------------- | +| Test type | A/B / A/B/n / MVT | +| Duration | X weeks | +| Sample size | X per variant | +| Traffic allocation | 50/50 | +| Tool | [Tool name] | +| Implementation | Client-side / Server-side | + +## Variants + +### Control (A) + +[Screenshot] + +- Current experience +- [Key details about current state] + +### Variant (B) + +[Screenshot or mockup] + +- [Specific change #1] +- [Specific change #2] +- Rationale: [Why we think this will win] + +## Metrics + +### Primary + +- **Metric**: [metric name] +- **Definition**: [how it's calculated] +- **Current baseline**: [X%] +- **Minimum detectable effect**: [X%] + +### Secondary + +- [Metric 1]: [what it tells us] +- [Metric 2]: [what it tells us] +- [Metric 3]: [what it tells us] + +### Guardrails + +- [Metric that shouldn't get worse] +- [Another safety metric] + +## Segment Analysis Plan + +- Mobile vs. desktop +- New vs. returning visitors +- Traffic source +- [Other relevant segments] + +## Success Criteria + +- Winner: [Primary metric improves by X% with 95% confidence] +- Loser: [Primary metric decreases significantly] +- Inconclusive: [What we'll do if no significant result] + +## Pre-Launch Checklist + +- [ ] Hypothesis documented and reviewed +- [ ] Primary metric defined and trackable +- [ ] Sample size calculated +- [ ] Test duration estimated +- [ ] Variants implemented correctly +- [ ] Tracking verified in all variants +- [ ] QA completed on all variants +- [ ] Stakeholders informed +- [ ] Calendar hold for analysis date +``` + +--- + +## Results Documentation Template + +```markdown +# A/B Test Results: [Name] + +## Summary + +| Element | Value | +| -------- | ----------------------------- | +| Test ID | [ID] | +| Dates | [Start] - [End] | +| Duration | X days | +| Result | Winner / Loser / Inconclusive | +| Decision | [What we're doing] | + +## Hypothesis (Reminder) + +[Copy from test plan] + +## Results + +### Sample Size + +| Variant | Target | Actual | % of target | +| ------- | ------ | ------ | ----------- | +| Control | X | Y | Z% | +| Variant | X | Y | Z% | + +### Primary Metric: [Metric Name] + +| Variant | Value | 95% CI | vs. Control | +| ------- | ----- | -------- | ----------- | +| Control | X% | [X%, Y%] | — | +| Variant | X% | [X%, Y%] | +X% | + +**Statistical significance**: p = X.XX (95% = sig / not sig) +**Practical significance**: [Is this lift meaningful for the business?] + +### Secondary Metrics + +| Metric | Control | Variant | Change | Significant? | +| ---------- | ------- | ------- | ------ | ------------ | +| [Metric 1] | X | Y | +Z% | Yes/No | +| [Metric 2] | X | Y | +Z% | Yes/No | + +### Guardrail Metrics + +| Metric | Control | Variant | Change | Concern? | +| ---------- | ------- | ------- | ------ | -------- | +| [Metric 1] | X | Y | +Z% | Yes/No | + +### Segment Analysis + +**Mobile vs. Desktop** +| Segment | Control | Variant | Lift | +|---------|---------|---------|------| +| Mobile | X% | Y% | +Z% | +| Desktop | X% | Y% | +Z% | + +**New vs. Returning** +| Segment | Control | Variant | Lift | +|---------|---------|---------|------| +| New | X% | Y% | +Z% | +| Returning | X% | Y% | +Z% | + +## Interpretation + +### What happened? + +[Explanation of results in plain language] + +### Why do we think this happened? + +[Analysis and reasoning] + +### Caveats + +[Any limitations, external factors, or concerns] + +## Decision + +**Winner**: [Control / Variant] + +**Action**: [Implement variant / Keep control / Re-test] + +**Timeline**: [When changes will be implemented] + +## Learnings + +### What we learned + +- [Key insight 1] +- [Key insight 2] + +### What to test next + +- [Follow-up test idea 1] +- [Follow-up test idea 2] + +### Impact + +- **Projected lift**: [X% improvement in Y metric] +- **Business impact**: [Revenue, conversions, etc.] +``` + +--- + +## Test Repository Entry Template + +For tracking all tests in a central location: + +```markdown +| Test ID | Name | Page | Dates | Primary Metric | Result | Lift | Link | +| ------- | -------------------- | -------- | --------- | -------------- | ------------ | ---- | ------ | +| 001 | Hero headline test | Homepage | 1/1-1/15 | CTR | Winner | +12% | [Link] | +| 002 | Pricing table layout | Pricing | 1/10-1/31 | Plan selection | Loser | -5% | [Link] | +| 003 | Signup form fields | Signup | 2/1-2/14 | Completion | Inconclusive | +2% | [Link] | +``` + +--- + +## Quick Test Brief Template + +For simple tests that don't need full documentation: + +```markdown +## [Test Name] + +**What**: [One sentence description] +**Why**: [One sentence hypothesis] +**Metric**: [Primary metric] +**Duration**: [X weeks] +**Result**: [TBD / Winner / Loser / Inconclusive] +**Learnings**: [Key takeaway] +``` + +--- + +## Stakeholder Update Template + +```markdown +## A/B Test Update: [Name] + +**Status**: Running / Complete +**Days remaining**: X (or complete) +**Current sample**: X% of target + +### Preliminary observations + +[What we're seeing - without making decisions yet] + +### Next steps + +[What happens next] + +### Timeline + +- [Date]: Analysis complete +- [Date]: Decision and recommendation +- [Date]: Implementation 'if winner' +``` + +--- + +## Experiment Prioritization Scorecard + +For deciding which tests to run: + +| Factor | Weight | Test A | Test B | Test C | +| ------------------------ | ------ | ------ | ------ | ------ | +| Potential impact | 30% | | | | +| Confidence in hypothesis | 25% | | | | +| Ease of implementation | 20% | | | | +| Risk if wrong | 15% | | | | +| Strategic alignment | 10% | | | | +| **Total** | | | | | + +Scoring: 1-5 (5 = best) + +--- + +## Hypothesis Bank Template + +For collecting test ideas: + +```markdown +| ID | Page/Area | Observation | Hypothesis | Potential Impact | Status | +| --- | --------- | ------------------- | ------------------------------------- | ---------------- | ------- | +| H1 | Homepage | Low scroll depth | Shorter hero will increase scroll | High | Testing | +| H2 | Pricing | Users compare plans | Comparison table will help | Medium | Backlog | +| H3 | Signup | Drop-off at email | Social login will increase completion | Medium | Backlog | +``` diff --git a/packages/mosaic/framework/skills/ai-sdk/SKILL.md b/packages/mosaic/framework/skills/ai-sdk/SKILL.md new file mode 100644 index 00000000..f4ac3465 --- /dev/null +++ b/packages/mosaic/framework/skills/ai-sdk/SKILL.md @@ -0,0 +1,78 @@ +--- +name: ai-sdk +description: 'Answer questions about the AI SDK and help build AI-powered features. Use when developers: (1) Ask about AI SDK functions like generateText, streamText, ToolLoopAgent, embed, or tools, (2) Want to build AI agents, chatbots, RAG systems, or text generation features, (3) Have questions about AI providers (OpenAI, Anthropic, Google, etc.), streaming, tool calling, structured output, or embeddings, (4) Use React hooks like useChat or useCompletion. Triggers on: "AI SDK", "Vercel AI SDK", "generateText", "streamText", "add AI to my app", "build an agent", "tool calling", "structured output", "useChat".' +--- + +## Prerequisites + +Before searching docs, check if `node_modules/ai/docs/` exists. If not, install **only** the `ai` package using the project's package manager (e.g., `pnpm add ai`). + +Do not install other packages at this stage. Provider packages (e.g., `@ai-sdk/openai`) and client packages (e.g., `@ai-sdk/react`) should be installed later when needed based on user requirements. + +## Critical: Do Not Trust Internal Knowledge + +Everything you know about the AI SDK is outdated or wrong. Your training data contains obsolete APIs, deprecated patterns, and incorrect usage. + +**When working with the AI SDK:** + +1. Ensure `ai` package is installed (see Prerequisites) +2. Search `node_modules/ai/docs/` and `node_modules/ai/src/` for current APIs +3. If not found locally, search ai-sdk.dev documentation (instructions below) +4. Never rely on memory - always verify against source code or docs +5. **`useChat` has changed significantly** - check [Common Errors](references/common-errors.md) before writing client code +6. When deciding which model and provider to use (e.g. OpenAI, Anthropic, Gemini), use the Vercel AI Gateway provider unless the user specifies otherwise. See [AI Gateway Reference](references/ai-gateway.md) for usage details. +7. **Always fetch current model IDs** - Never use model IDs from memory. Before writing code that uses a model, run `curl -s https://ai-gateway.vercel.sh/v1/models | jq -r '[.data[] | select(.id | startswith("provider/")) | .id] | reverse | .[]'` (replacing `provider` with the relevant provider like `anthropic`, `openai`, or `google`) to get the full list with newest models first. Use the model with the highest version number (e.g., `claude-sonnet-4-5` over `claude-sonnet-4` over `claude-3-5-sonnet`). +8. Run typecheck after changes to ensure code is correct +9. **Be minimal** - Only specify options that differ from defaults. When unsure of defaults, check docs or source rather than guessing or over-specifying. + +If you cannot find documentation to support your answer, state that explicitly. + +## Finding Documentation + +### ai@6.0.34+ + +Search bundled docs and source in `node_modules/ai/`: + +- **Docs**: `grep "query" node_modules/ai/docs/` +- **Source**: `grep "query" node_modules/ai/src/` + +Provider packages include docs at `node_modules/@ai-sdk//docs/`. + +### Earlier versions + +1. Search: `https://ai-sdk.dev/api/search-docs?q=your_query` +2. Fetch `.md` URLs from results (e.g., `https://ai-sdk.dev/docs/agents/building-agents.md`) + +## When Typecheck Fails + +**Before searching source code**, grep [Common Errors](references/common-errors.md) for the failing property or function name. Many type errors are caused by deprecated APIs documented there. + +If not found in common-errors.md: + +1. Search `node_modules/ai/src/` and `node_modules/ai/docs/` +2. Search ai-sdk.dev (for earlier versions or if not found locally) + +## Building and Consuming Agents + +### Creating Agents + +Always use the `ToolLoopAgent` pattern. Search `node_modules/ai/docs/` for current agent creation APIs. + +**File conventions**: See [type-safe-agents.md](references/type-safe-agents.md) for where to save agents and tools. + +**Type Safety**: When consuming agents with `useChat`, always use `InferAgentUIMessage` for type-safe tool results. See [reference](references/type-safe-agents.md). + +### Consuming Agents (Framework-Specific) + +Before implementing agent consumption: + +1. Check `package.json` to detect the project's framework/stack +2. Search documentation for the framework's quickstart guide +3. Follow the framework-specific patterns for streaming, API routes, and client integration + +## References + +- [Common Errors](references/common-errors.md) - Renamed parameters reference (parameters → inputSchema, etc.) +- [AI Gateway](references/ai-gateway.md) - Gateway setup and usage +- [Type-Safe Agents with useChat](references/type-safe-agents.md) - End-to-end type safety with InferAgentUIMessage +- [DevTools](references/devtools.md) - Set up local debugging and observability (development only) diff --git a/packages/mosaic/framework/skills/ai-sdk/references/ai-gateway.md b/packages/mosaic/framework/skills/ai-sdk/references/ai-gateway.md new file mode 100644 index 00000000..8bb2d66f --- /dev/null +++ b/packages/mosaic/framework/skills/ai-sdk/references/ai-gateway.md @@ -0,0 +1,66 @@ +--- +title: Vercel AI Gateway +description: Reference for using Vercel AI Gateway with the AI SDK. +--- + +# Vercel AI Gateway + +The Vercel AI Gateway is the fastest way to get started with the AI SDK. It provides access to models from OpenAI, Anthropic, Google, and other providers through a single API. + +## Authentication + +Authenticate with OIDC (for Vercel deployments) or an [AI Gateway API key](https://vercel.com/d?to=%2F%5Bteam%5D%2F%7E%2Fai-gateway%2Fapi-keys&title=AI+Gateway+API+Keys): + +```env filename=".env.local" +AI_GATEWAY_API_KEY=your_api_key_here +``` + +## Usage + +The AI Gateway is the default global provider, so you can access models using a simple string: + +```ts +import { generateText } from 'ai'; + +const { text } = await generateText({ + model: 'anthropic/claude-sonnet-4.5', + prompt: 'What is love?', +}); +``` + +You can also explicitly import and use the gateway provider: + +```ts +// Option 1: Import from 'ai' package (included by default) +import { gateway } from 'ai'; +model: gateway('anthropic/claude-sonnet-4.5'); + +// Option 2: Install and import from '@ai-sdk/gateway' package +import { gateway } from '@ai-sdk/gateway'; +model: gateway('anthropic/claude-sonnet-4.5'); +``` + +## Find Available Models + +**Important**: Always fetch the current model list before writing code. Never use model IDs from memory - they may be outdated. + +List all available models through the gateway API: + +```bash +curl https://ai-gateway.vercel.sh/v1/models +``` + +Filter by provider using `jq`. **Do not truncate with `head`** - always fetch the full list to find the latest models: + +```bash +# Anthropic models +curl -s https://ai-gateway.vercel.sh/v1/models | jq -r '[.data[] | select(.id | startswith("anthropic/")) | .id] | reverse | .[]' + +# OpenAI models +curl -s https://ai-gateway.vercel.sh/v1/models | jq -r '[.data[] | select(.id | startswith("openai/")) | .id] | reverse | .[]' + +# Google models +curl -s https://ai-gateway.vercel.sh/v1/models | jq -r '[.data[] | select(.id | startswith("google/")) | .id] | reverse | .[]' +``` + +When multiple versions of a model exist, use the one with the highest version number (e.g., prefer `claude-sonnet-4-5` over `claude-sonnet-4` over `claude-3-5-sonnet`). diff --git a/packages/mosaic/framework/skills/ai-sdk/references/common-errors.md b/packages/mosaic/framework/skills/ai-sdk/references/common-errors.md new file mode 100644 index 00000000..d628927e --- /dev/null +++ b/packages/mosaic/framework/skills/ai-sdk/references/common-errors.md @@ -0,0 +1,439 @@ +--- +title: Common Errors +description: Reference for common AI SDK errors and how to resolve them. +--- + +# Common Errors + +## `maxTokens` → `maxOutputTokens` + +```typescript +// ❌ Incorrect +const result = await generateText({ + model: 'anthropic/claude-opus-4.5', + maxTokens: 512, // deprecated: use `maxOutputTokens` instead + prompt: 'Write a short story', +}); + +// ✅ Correct +const result = await generateText({ + model: 'anthropic/claude-opus-4.5', + maxOutputTokens: 512, + prompt: 'Write a short story', +}); +``` + +## `maxSteps` → `stopWhen: stepCountIs(n)` + +```typescript +// ❌ Incorrect +const result = await generateText({ + model: 'anthropic/claude-opus-4.5', + tools: { weather }, + maxSteps: 5, // deprecated: use `stopWhen: stepCountIs(n)` instead + prompt: 'What is the weather in NYC?', +}); + +// ✅ Correct +import { generateText, stepCountIs } from 'ai'; + +const result = await generateText({ + model: 'anthropic/claude-opus-4.5', + tools: { weather }, + stopWhen: stepCountIs(5), + prompt: 'What is the weather in NYC?', +}); +``` + +## `parameters` → `inputSchema` (in tool definition) + +```typescript +// ❌ Incorrect +const weatherTool = tool({ + description: 'Get weather for a location', + parameters: z.object({ + // deprecated: use `inputSchema` instead + location: z.string(), + }), + execute: async ({ location }) => ({ location, temp: 72 }), +}); + +// ✅ Correct +const weatherTool = tool({ + description: 'Get weather for a location', + inputSchema: z.object({ + location: z.string(), + }), + execute: async ({ location }) => ({ location, temp: 72 }), +}); +``` + +## `generateObject` → `generateText` with `output` + +`generateObject` is deprecated. Use `generateText` with the `output` option instead. + +```typescript +// ❌ Deprecated +import { generateObject } from 'ai'; // deprecated: use `generateText` with `output` instead + +const result = await generateObject({ + // deprecated function + model: 'anthropic/claude-opus-4.5', + schema: z.object({ + // deprecated: use `Output.object({ schema })` instead + recipe: z.object({ + name: z.string(), + ingredients: z.array(z.string()), + }), + }), + prompt: 'Generate a recipe for chocolate cake', +}); + +// ✅ Correct +import { generateText, Output } from 'ai'; + +const result = await generateText({ + model: 'anthropic/claude-opus-4.5', + output: Output.object({ + schema: z.object({ + recipe: z.object({ + name: z.string(), + ingredients: z.array(z.string()), + }), + }), + }), + prompt: 'Generate a recipe for chocolate cake', +}); + +console.log(result.output); // typed object +``` + +## Manual JSON parsing → `generateText` with `output` + +```typescript +// ❌ Incorrect +const result = await generateText({ + model: 'anthropic/claude-opus-4.5', + prompt: `Extract the user info as JSON: { "name": string, "age": number } + + Input: John is 25 years old`, +}); +const parsed = JSON.parse(result.text); + +// ✅ Correct +import { generateText, Output } from 'ai'; + +const result = await generateText({ + model: 'anthropic/claude-opus-4.5', + output: Output.object({ + schema: z.object({ + name: z.string(), + age: z.number(), + }), + }), + prompt: 'Extract the user info: John is 25 years old', +}); + +console.log(result.output); // { name: 'John', age: 25 } +``` + +## Other `output` options + +```typescript +// Output.array - for generating arrays of items +const result = await generateText({ + model: 'anthropic/claude-opus-4.5', + output: Output.array({ + element: z.object({ + city: z.string(), + country: z.string(), + }), + }), + prompt: 'List 5 capital cities', +}); + +// Output.choice - for selecting from predefined options +const result = await generateText({ + model: 'anthropic/claude-opus-4.5', + output: Output.choice({ + options: ['positive', 'negative', 'neutral'] as const, + }), + prompt: 'Classify the sentiment: I love this product!', +}); + +// Output.json - for untyped JSON output +const result = await generateText({ + model: 'anthropic/claude-opus-4.5', + output: Output.json(), + prompt: 'Return some JSON data', +}); +``` + +## `toDataStreamResponse` → `toUIMessageStreamResponse` + +When using `useChat` on the frontend, use `toUIMessageStreamResponse()` instead of `toDataStreamResponse()`. The UI message stream format is designed to work with the chat UI components and handles message state correctly. + +```typescript +// ❌ Incorrect (when using useChat) +const result = streamText({ + // config +}); + +return result.toDataStreamResponse(); // deprecated for useChat: use toUIMessageStreamResponse + +// ✅ Correct +const result = streamText({ + // config +}); + +return result.toUIMessageStreamResponse(); +``` + +## Removed managed input state in `useChat` + +The `useChat` hook no longer manages input state internally. You must now manage input state manually. + +```tsx +// ❌ Deprecated +import { useChat } from '@ai-sdk/react'; + +export default function Page() { + const { + input, // deprecated: manage input state manually with useState + handleInputChange, // deprecated: use custom onChange handler + handleSubmit, // deprecated: use sendMessage() instead + } = useChat({ + api: '/api/chat', // deprecated: use `transport: new DefaultChatTransport({ api })` instead + }); + + return ( +
+ + +
+ ); +} + +// ✅ Correct +import { useChat } from '@ai-sdk/react'; +import { DefaultChatTransport } from 'ai'; +import { useState } from 'react'; + +export default function Page() { + const [input, setInput] = useState(''); + const { sendMessage } = useChat({ + transport: new DefaultChatTransport({ api: '/api/chat' }), + }); + + const handleSubmit = (e) => { + e.preventDefault(); + sendMessage({ text: input }); + setInput(''); + }; + + return ( +
+ setInput(e.target.value)} /> + +
+ ); +} +``` + +## `tool-invocation` → `tool-{toolName}` (typed tool parts) + +When rendering messages with `useChat`, use the typed tool part names (`tool-{toolName}`) instead of the generic `tool-invocation` type. This provides better type safety and access to tool-specific input/output types. + +> For end-to-end type-safety, see [Type-Safe Agents](type-safe-agents.md). + +Typed tool parts also use different property names: + +- `part.args` → `part.input` +- `part.result` → `part.output` + +```tsx +// ❌ Incorrect - using generic tool-invocation +{ + message.parts.map((part, i) => { + switch (part.type) { + case 'text': + return
{part.text}
; + case 'tool-invocation': // deprecated: use typed tool parts instead + return
{JSON.stringify(part.toolInvocation, null, 2)}
; + } + }); +} + +// ✅ Correct - using typed tool parts (recommended) +{ + message.parts.map((part) => { + switch (part.type) { + case 'text': + return part.text; + case 'tool-askForConfirmation': + // handle askForConfirmation tool + break; + case 'tool-getWeatherInformation': + // handle getWeatherInformation tool + break; + } + }); +} + +// ✅ Alternative - using isToolUIPart as a catch-all +import { isToolUIPart } from 'ai'; + +{ + message.parts.map((part) => { + if (part.type === 'text') { + return part.text; + } + if (isToolUIPart(part)) { + // handle any tool part generically + return ( +
+ {part.toolName}: {part.state} +
+ ); + } + }); +} +``` + +## `useChat` state-dependent property access + +Tool part properties are only available in certain states. TypeScript will error if you access them without checking state first. + +```tsx +// ❌ Incorrect - input may be undefined during streaming +// TS18048: 'part.input' is possibly 'undefined' +if (part.type === 'tool-getWeather') { + const location = part.input.location; +} + +// ✅ Correct - check for input-available or output-available +if ( + part.type === 'tool-getWeather' && + (part.state === 'input-available' || part.state === 'output-available') +) { + const location = part.input.location; +} + +// ❌ Incorrect - output is only available after execution +// TS18048: 'part.output' is possibly 'undefined' +if (part.type === 'tool-getWeather') { + const weather = part.output; +} + +// ✅ Correct - check for output-available +if (part.type === 'tool-getWeather' && part.state === 'output-available') { + const location = part.input.location; + const weather = part.output; +} +``` + +## `part.toolInvocation.args` → `part.input` + +```tsx +// ❌ Incorrect +if (part.type === 'tool-invocation') { + // deprecated: use `part.input` on typed tool parts instead + const location = part.toolInvocation.args.location; +} + +// ✅ Correct +if ( + part.type === 'tool-getWeather' && + (part.state === 'input-available' || part.state === 'output-available') +) { + const location = part.input.location; +} +``` + +## `part.toolInvocation.result` → `part.output` + +```tsx +// ❌ Incorrect +if (part.type === 'tool-invocation') { + // deprecated: use `part.output` on typed tool parts instead + const weather = part.toolInvocation.result; +} + +// ✅ Correct +if (part.type === 'tool-getWeather' && part.state === 'output-available') { + const weather = part.output; +} +``` + +## `part.toolInvocation.toolCallId` → `part.toolCallId` + +```tsx +// ❌ Incorrect +if (part.type === 'tool-invocation') { + // deprecated: use `part.toolCallId` on typed tool parts instead + const id = part.toolInvocation.toolCallId; +} + +// ✅ Correct +if (part.type === 'tool-getWeather') { + const id = part.toolCallId; +} +``` + +## Tool invocation states renamed + +```tsx +// ❌ Incorrect +switch (part.toolInvocation.state) { + case 'partial-call': // deprecated: use `input-streaming` instead + return
Loading...
; + case 'call': // deprecated: use `input-available` instead + return
Executing...
; + case 'result': // deprecated: use `output-available` instead + return
Done
; +} + +// ✅ Correct +switch (part.state) { + case 'input-streaming': + return
Loading...
; + case 'input-available': + return
Executing...
; + case 'output-available': + return
Done
; +} +``` + +## `addToolResult` → `addToolOutput` + +```tsx +// ❌ Incorrect +addToolResult({ + // deprecated: use `addToolOutput` instead + toolCallId: part.toolInvocation.toolCallId, + result: 'Yes, confirmed.', // deprecated: use `output` instead +}); + +// ✅ Correct +addToolOutput({ + tool: 'askForConfirmation', + toolCallId: part.toolCallId, + output: 'Yes, confirmed.', +}); +``` + +## `messages` → `uiMessages` in `createAgentUIStreamResponse` + +```typescript +// ❌ Incorrect +return createAgentUIStreamResponse({ + agent: myAgent, + messages, // incorrect: use `uiMessages` instead +}); + +// ✅ Correct +return createAgentUIStreamResponse({ + agent: myAgent, + uiMessages: messages, +}); +``` diff --git a/packages/mosaic/framework/skills/ai-sdk/references/devtools.md b/packages/mosaic/framework/skills/ai-sdk/references/devtools.md new file mode 100644 index 00000000..197e203a --- /dev/null +++ b/packages/mosaic/framework/skills/ai-sdk/references/devtools.md @@ -0,0 +1,52 @@ +--- +title: AI SDK DevTools +description: Debug AI SDK calls by inspecting captured runs and steps. +--- + +# AI SDK DevTools + +## Why Use DevTools + +DevTools captures all AI SDK calls (`generateText`, `streamText`, `ToolLoopAgent`) to a local JSON file. This lets you inspect LLM requests, responses, tool calls, and multi-step interactions without manually logging. + +## Setup + +Requires AI SDK 6. Install `@ai-sdk/devtools` using your project's package manager. + +Wrap your model with the middleware: + +```ts +import { wrapLanguageModel, gateway } from 'ai'; +import { devToolsMiddleware } from '@ai-sdk/devtools'; + +const model = wrapLanguageModel({ + model: gateway('anthropic/claude-sonnet-4.5'), + middleware: devToolsMiddleware(), +}); +``` + +## Viewing Captured Data + +All runs and steps are saved to: + +``` +.devtools/generations.json +``` + +Read this file directly to inspect captured data: + +```bash +cat .devtools/generations.json | jq +``` + +Or launch the web UI: + +```bash +npx @ai-sdk/devtools +# Open http://localhost:4983 +``` + +## Data Structure + +- **Run**: A complete multi-step interaction grouped by initial prompt +- **Step**: A single LLM call within a run (includes input, output, tool calls, token usage) diff --git a/packages/mosaic/framework/skills/ai-sdk/references/type-safe-agents.md b/packages/mosaic/framework/skills/ai-sdk/references/type-safe-agents.md new file mode 100644 index 00000000..94f22ef2 --- /dev/null +++ b/packages/mosaic/framework/skills/ai-sdk/references/type-safe-agents.md @@ -0,0 +1,200 @@ +--- +title: Type-Safe useChat with Agents +description: Build end-to-end type-safe agents by inferring UIMessage types from your agent definition. +--- + +# Type-Safe useChat with Agents + +Build end-to-end type-safe agents by inferring `UIMessage` types from your agent definition for type-safe UI rendering with `useChat`. + +## Recommended Structure + +``` +lib/ + agents/ + my-agent.ts # Agent definition + type export + tools/ + weather-tool.ts # Individual tool definitions + calculator-tool.ts +``` + +## Define Tools + +```ts +// lib/tools/weather-tool.ts +import { tool } from 'ai'; +import { z } from 'zod'; + +export const weatherTool = tool({ + description: 'Get current weather for a location', + inputSchema: z.object({ + location: z.string().describe('City name'), + }), + execute: async ({ location }) => { + return { temperature: 72, condition: 'sunny', location }; + }, +}); +``` + +## Define Agent and Export Type + +```ts +// lib/agents/my-agent.ts +import { ToolLoopAgent, InferAgentUIMessage } from 'ai'; +import { weatherTool } from '../tools/weather-tool'; +import { calculatorTool } from '../tools/calculator-tool'; + +export const myAgent = new ToolLoopAgent({ + model: 'anthropic/claude-sonnet-4', + instructions: 'You are a helpful assistant.', + tools: { + weather: weatherTool, + calculator: calculatorTool, + }, +}); + +// Infer the UIMessage type from the agent +export type MyAgentUIMessage = InferAgentUIMessage; +``` + +### With Custom Metadata + +```ts +// lib/agents/my-agent.ts +import { z } from 'zod'; + +const metadataSchema = z.object({ + createdAt: z.number(), + model: z.string().optional(), +}); + +type MyMetadata = z.infer; + +export type MyAgentUIMessage = InferAgentUIMessage; +``` + +## Use with `useChat` + +```tsx +// app/chat.tsx +import { useChat } from '@ai-sdk/react'; +import type { MyAgentUIMessage } from '@/lib/agents/my-agent'; + +export function Chat() { + const { messages } = useChat(); + + return ( +
+ {messages.map((message) => ( + + ))} +
+ ); +} +``` + +## Rendering Parts with Type Safety + +Tool parts are typed as `tool-{toolName}` based on your agent's tools: + +```tsx +function Message({ message }: { message: MyAgentUIMessage }) { + return ( +
+ {message.parts.map((part, i) => { + switch (part.type) { + case 'text': + return

{part.text}

; + + case 'tool-weather': + // part.input and part.output are fully typed + if (part.state === 'output-available') { + return ( +
+ Weather in {part.input.location}: {part.output.temperature}F +
+ ); + } + return
Loading weather...
; + + case 'tool-calculator': + // TypeScript knows this is the calculator tool + return
Calculating...
; + + default: + return null; + } + })} +
+ ); +} +``` + +The `part.type` discriminant narrows the type, giving you autocomplete and type checking for `input` and `output` based on each tool's schema. + +## Splitting Tool Rendering into Components + +When rendering many tools, you may want to split each tool into its own component. Use `UIToolInvocation` to derive a typed invocation from your tool and export it alongside the tool definition: + +```ts +// lib/tools/weather-tool.ts +import { tool, UIToolInvocation } from 'ai'; +import { z } from 'zod'; + +export const weatherTool = tool({ + description: 'Get current weather for a location', + inputSchema: z.object({ + location: z.string().describe('City name'), + }), + execute: async ({ location }) => { + return { temperature: 72, condition: 'sunny', location }; + }, +}); + +// Export the invocation type for use in UI components +export type WeatherToolInvocation = UIToolInvocation; +``` + +Then import only the type in your component: + +```tsx +// components/weather-tool.tsx +import type { WeatherToolInvocation } from '@/lib/tools/weather-tool'; + +export function WeatherToolComponent({ invocation }: { invocation: WeatherToolInvocation }) { + // invocation.input and invocation.output are fully typed + if (invocation.state === 'output-available') { + return ( +
+ Weather in {invocation.input.location}: {invocation.output.temperature}F +
+ ); + } + return
Loading weather for {invocation.input?.location}...
; +} +``` + +Use the component in your message renderer: + +```tsx +function Message({ message }: { message: MyAgentUIMessage }) { + return ( +
+ {message.parts.map((part, i) => { + switch (part.type) { + case 'text': + return

{part.text}

; + case 'tool-weather': + return ; + case 'tool-calculator': + return ; + default: + return null; + } + })} +
+ ); +} +``` + +This approach keeps your tool rendering logic organized while maintaining full type safety, without needing to import the tool implementation into your UI components. diff --git a/packages/mosaic/framework/skills/algorithmic-art/LICENSE.txt b/packages/mosaic/framework/skills/algorithmic-art/LICENSE.txt new file mode 100644 index 00000000..7a4a3ea2 --- /dev/null +++ b/packages/mosaic/framework/skills/algorithmic-art/LICENSE.txt @@ -0,0 +1,202 @@ + + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright [yyyy] [name of copyright owner] + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. \ No newline at end of file diff --git a/packages/mosaic/framework/skills/algorithmic-art/SKILL.md b/packages/mosaic/framework/skills/algorithmic-art/SKILL.md new file mode 100644 index 00000000..8d31a4f1 --- /dev/null +++ b/packages/mosaic/framework/skills/algorithmic-art/SKILL.md @@ -0,0 +1,443 @@ +--- +name: algorithmic-art +description: Creating algorithmic art using p5.js with seeded randomness and interactive parameter exploration. Use this when users request creating art using code, generative art, algorithmic art, flow fields, or particle systems. Create original algorithmic art rather than copying existing artists' work to avoid copyright violations. +license: Complete terms in LICENSE.txt +--- + +Algorithmic philosophies are computational aesthetic movements that are then expressed through code. Output .md files (philosophy), .html files (interactive viewer), and .js files (generative algorithms). + +This happens in two steps: + +1. Algorithmic Philosophy Creation (.md file) +2. Express by creating p5.js generative art (.html + .js files) + +First, undertake this task: + +## ALGORITHMIC PHILOSOPHY CREATION + +To begin, create an ALGORITHMIC PHILOSOPHY (not static images or templates) that will be interpreted through: + +- Computational processes, emergent behavior, mathematical beauty +- Seeded randomness, noise fields, organic systems +- Particles, flows, fields, forces +- Parametric variation and controlled chaos + +### THE CRITICAL UNDERSTANDING + +- What is received: Some subtle input or instructions by the user to take into account, but use as a foundation; it should not constrain creative freedom. +- What is created: An algorithmic philosophy/generative aesthetic movement. +- What happens next: The same version receives the philosophy and EXPRESSES IT IN CODE - creating p5.js sketches that are 90% algorithmic generation, 10% essential parameters. + +Consider this approach: + +- Write a manifesto for a generative art movement +- The next phase involves writing the algorithm that brings it to life + +The philosophy must emphasize: Algorithmic expression. Emergent behavior. Computational beauty. Seeded variation. + +### HOW TO GENERATE AN ALGORITHMIC PHILOSOPHY + +**Name the movement** (1-2 words): "Organic Turbulence" / "Quantum Harmonics" / "Emergent Stillness" + +**Articulate the philosophy** (4-6 paragraphs - concise but complete): + +To capture the ALGORITHMIC essence, express how this philosophy manifests through: + +- Computational processes and mathematical relationships? +- Noise functions and randomness patterns? +- Particle behaviors and field dynamics? +- Temporal evolution and system states? +- Parametric variation and emergent complexity? + +**CRITICAL GUIDELINES:** + +- **Avoid redundancy**: Each algorithmic aspect should be mentioned once. Avoid repeating concepts about noise theory, particle dynamics, or mathematical principles unless adding new depth. +- **Emphasize craftsmanship REPEATEDLY**: The philosophy MUST stress multiple times that the final algorithm should appear as though it took countless hours to develop, was refined with care, and comes from someone at the absolute top of their field. This framing is essential - repeat phrases like "meticulously crafted algorithm," "the product of deep computational expertise," "painstaking optimization," "master-level implementation." +- **Leave creative space**: Be specific about the algorithmic direction, but concise enough that the next Claude has room to make interpretive implementation choices at an extremely high level of craftsmanship. + +The philosophy must guide the next version to express ideas ALGORITHMICALLY, not through static images. Beauty lives in the process, not the final frame. + +### PHILOSOPHY EXAMPLES + +**"Organic Turbulence"** +Philosophy: Chaos constrained by natural law, order emerging from disorder. +Algorithmic expression: Flow fields driven by layered Perlin noise. Thousands of particles following vector forces, their trails accumulating into organic density maps. Multiple noise octaves create turbulent regions and calm zones. Color emerges from velocity and density - fast particles burn bright, slow ones fade to shadow. The algorithm runs until equilibrium - a meticulously tuned balance where every parameter was refined through countless iterations by a master of computational aesthetics. + +**"Quantum Harmonics"** +Philosophy: Discrete entities exhibiting wave-like interference patterns. +Algorithmic expression: Particles initialized on a grid, each carrying a phase value that evolves through sine waves. When particles are near, their phases interfere - constructive interference creates bright nodes, destructive creates voids. Simple harmonic motion generates complex emergent mandalas. The result of painstaking frequency calibration where every ratio was carefully chosen to produce resonant beauty. + +**"Recursive Whispers"** +Philosophy: Self-similarity across scales, infinite depth in finite space. +Algorithmic expression: Branching structures that subdivide recursively. Each branch slightly randomized but constrained by golden ratios. L-systems or recursive subdivision generate tree-like forms that feel both mathematical and organic. Subtle noise perturbations break perfect symmetry. Line weights diminish with each recursion level. Every branching angle the product of deep mathematical exploration. + +**"Field Dynamics"** +Philosophy: Invisible forces made visible through their effects on matter. +Algorithmic expression: Vector fields constructed from mathematical functions or noise. Particles born at edges, flowing along field lines, dying when they reach equilibrium or boundaries. Multiple fields can attract, repel, or rotate particles. The visualization shows only the traces - ghost-like evidence of invisible forces. A computational dance meticulously choreographed through force balance. + +**"Stochastic Crystallization"** +Philosophy: Random processes crystallizing into ordered structures. +Algorithmic expression: Randomized circle packing or Voronoi tessellation. Start with random points, let them evolve through relaxation algorithms. Cells push apart until equilibrium. Color based on cell size, neighbor count, or distance from center. The organic tiling that emerges feels both random and inevitable. Every seed produces unique crystalline beauty - the mark of a master-level generative algorithm. + +_These are condensed examples. The actual algorithmic philosophy should be 4-6 substantial paragraphs._ + +### ESSENTIAL PRINCIPLES + +- **ALGORITHMIC PHILOSOPHY**: Creating a computational worldview to be expressed through code +- **PROCESS OVER PRODUCT**: Always emphasize that beauty emerges from the algorithm's execution - each run is unique +- **PARAMETRIC EXPRESSION**: Ideas communicate through mathematical relationships, forces, behaviors - not static composition +- **ARTISTIC FREEDOM**: The next Claude interprets the philosophy algorithmically - provide creative implementation room +- **PURE GENERATIVE ART**: This is about making LIVING ALGORITHMS, not static images with randomness +- **EXPERT CRAFTSMANSHIP**: Repeatedly emphasize the final algorithm must feel meticulously crafted, refined through countless iterations, the product of deep expertise by someone at the absolute top of their field in computational aesthetics + +**The algorithmic philosophy should be 4-6 paragraphs long.** Fill it with poetic computational philosophy that brings together the intended vision. Avoid repeating the same points. Output this algorithmic philosophy as a .md file. + +--- + +## DEDUCING THE CONCEPTUAL SEED + +**CRITICAL STEP**: Before implementing the algorithm, identify the subtle conceptual thread from the original request. + +**THE ESSENTIAL PRINCIPLE**: +The concept is a **subtle, niche reference embedded within the algorithm itself** - not always literal, always sophisticated. Someone familiar with the subject should feel it intuitively, while others simply experience a masterful generative composition. The algorithmic philosophy provides the computational language. The deduced concept provides the soul - the quiet conceptual DNA woven invisibly into parameters, behaviors, and emergence patterns. + +This is **VERY IMPORTANT**: The reference must be so refined that it enhances the work's depth without announcing itself. Think like a jazz musician quoting another song through algorithmic harmony - only those who know will catch it, but everyone appreciates the generative beauty. + +--- + +## P5.JS IMPLEMENTATION + +With the philosophy AND conceptual framework established, express it through code. Pause to gather thoughts before proceeding. Use only the algorithmic philosophy created and the instructions below. + +### ⚠️ STEP 0: READ THE TEMPLATE FIRST ⚠️ + +**CRITICAL: BEFORE writing any HTML:** + +1. **Read** `templates/viewer.html` using the Read tool +2. **Study** the exact structure, styling, and Anthropic branding +3. **Use that file as the LITERAL STARTING POINT** - not just inspiration +4. **Keep all FIXED sections exactly as shown** (header, sidebar structure, Anthropic colors/fonts, seed controls, action buttons) +5. **Replace only the VARIABLE sections** marked in the file's comments (algorithm, parameters, UI controls for parameters) + +**Avoid:** + +- ❌ Creating HTML from scratch +- ❌ Inventing custom styling or color schemes +- ❌ Using system fonts or dark themes +- ❌ Changing the sidebar structure + +**Follow these practices:** + +- ✅ Copy the template's exact HTML structure +- ✅ Keep Anthropic branding (Poppins/Lora fonts, light colors, gradient backdrop) +- ✅ Maintain the sidebar layout (Seed → Parameters → Colors? → Actions) +- ✅ Replace only the p5.js algorithm and parameter controls + +The template is the foundation. Build on it, don't rebuild it. + +--- + +To create gallery-quality computational art that lives and breathes, use the algorithmic philosophy as the foundation. + +### TECHNICAL REQUIREMENTS + +**Seeded Randomness (Art Blocks Pattern)**: + +```javascript +// ALWAYS use a seed for reproducibility +let seed = 12345; // or hash from user input +randomSeed(seed); +noiseSeed(seed); +``` + +**Parameter Structure - FOLLOW THE PHILOSOPHY**: + +To establish parameters that emerge naturally from the algorithmic philosophy, consider: "What qualities of this system can be adjusted?" + +```javascript +let params = { + seed: 12345, // Always include seed for reproducibility + // colors + // Add parameters that control YOUR algorithm: + // - Quantities (how many?) + // - Scales (how big? how fast?) + // - Probabilities (how likely?) + // - Ratios (what proportions?) + // - Angles (what direction?) + // - Thresholds (when does behavior change?) +}; +``` + +**To design effective parameters, focus on the properties the system needs to be tunable rather than thinking in terms of "pattern types".** + +**Core Algorithm - EXPRESS THE PHILOSOPHY**: + +**CRITICAL**: The algorithmic philosophy should dictate what to build. + +To express the philosophy through code, avoid thinking "which pattern should I use?" and instead think "how to express this philosophy through code?" + +If the philosophy is about **organic emergence**, consider using: + +- Elements that accumulate or grow over time +- Random processes constrained by natural rules +- Feedback loops and interactions + +If the philosophy is about **mathematical beauty**, consider using: + +- Geometric relationships and ratios +- Trigonometric functions and harmonics +- Precise calculations creating unexpected patterns + +If the philosophy is about **controlled chaos**, consider using: + +- Random variation within strict boundaries +- Bifurcation and phase transitions +- Order emerging from disorder + +**The algorithm flows from the philosophy, not from a menu of options.** + +To guide the implementation, let the conceptual essence inform creative and original choices. Build something that expresses the vision for this particular request. + +**Canvas Setup**: Standard p5.js structure: + +```javascript +function setup() { + createCanvas(1200, 1200); + // Initialize your system +} + +function draw() { + // Your generative algorithm + // Can be static (noLoop) or animated +} +``` + +### CRAFTSMANSHIP REQUIREMENTS + +**CRITICAL**: To achieve mastery, create algorithms that feel like they emerged through countless iterations by a master generative artist. Tune every parameter carefully. Ensure every pattern emerges with purpose. This is NOT random noise - this is CONTROLLED CHAOS refined through deep expertise. + +- **Balance**: Complexity without visual noise, order without rigidity +- **Color Harmony**: Thoughtful palettes, not random RGB values +- **Composition**: Even in randomness, maintain visual hierarchy and flow +- **Performance**: Smooth execution, optimized for real-time if animated +- **Reproducibility**: Same seed ALWAYS produces identical output + +### OUTPUT FORMAT + +Output: + +1. **Algorithmic Philosophy** - As markdown or text explaining the generative aesthetic +2. **Single HTML Artifact** - Self-contained interactive generative art built from `templates/viewer.html` (see STEP 0 and next section) + +The HTML artifact contains everything: p5.js (from CDN), the algorithm, parameter controls, and UI - all in one file that works immediately in claude.ai artifacts or any browser. Start from the template file, not from scratch. + +--- + +## INTERACTIVE ARTIFACT CREATION + +**REMINDER: `templates/viewer.html` should have already been read (see STEP 0). Use that file as the starting point.** + +To allow exploration of the generative art, create a single, self-contained HTML artifact. Ensure this artifact works immediately in claude.ai or any browser - no setup required. Embed everything inline. + +### CRITICAL: WHAT'S FIXED VS VARIABLE + +The `templates/viewer.html` file is the foundation. It contains the exact structure and styling needed. + +**FIXED (always include exactly as shown):** + +- Layout structure (header, sidebar, main canvas area) +- Anthropic branding (UI colors, fonts, gradients) +- Seed section in sidebar: + - Seed display + - Previous/Next buttons + - Random button + - Jump to seed input + Go button +- Actions section in sidebar: + - Regenerate button + - Reset button + +**VARIABLE (customize for each artwork):** + +- The entire p5.js algorithm (setup/draw/classes) +- The parameters object (define what the art needs) +- The Parameters section in sidebar: + - Number of parameter controls + - Parameter names + - Min/max/step values for sliders + - Control types (sliders, inputs, etc.) +- Colors section (optional): + - Some art needs color pickers + - Some art might use fixed colors + - Some art might be monochrome (no color controls needed) + - Decide based on the art's needs + +**Every artwork should have unique parameters and algorithm!** The fixed parts provide consistent UX - everything else expresses the unique vision. + +### REQUIRED FEATURES + +**1. Parameter Controls** + +- Sliders for numeric parameters (particle count, noise scale, speed, etc.) +- Color pickers for palette colors +- Real-time updates when parameters change +- Reset button to restore defaults + +**2. Seed Navigation** + +- Display current seed number +- "Previous" and "Next" buttons to cycle through seeds +- "Random" button for random seed +- Input field to jump to specific seed +- Generate 100 variations when requested (seeds 1-100) + +**3. Single Artifact Structure** + +```html + + + + + + + + +
+
+ +
+ + + +``` + +**CRITICAL**: This is a single artifact. No external files, no imports (except p5.js CDN). Everything inline. + +**4. Implementation Details - BUILD THE SIDEBAR** + +The sidebar structure: + +**1. Seed (FIXED)** - Always include exactly as shown: + +- Seed display +- Prev/Next/Random/Jump buttons + +**2. Parameters (VARIABLE)** - Create controls for the art: + +```html +
+ + + ... +
+``` + +Add as many control-group divs as there are parameters. + +**3. Colors (OPTIONAL/VARIABLE)** - Include if the art needs adjustable colors: + +- Add color pickers if users should control palette +- Skip this section if the art uses fixed colors +- Skip if the art is monochrome + +**4. Actions (FIXED)** - Always include exactly as shown: + +- Regenerate button +- Reset button +- Download PNG button + +**Requirements**: + +- Seed controls must work (prev/next/random/jump/display) +- All parameters must have UI controls +- Regenerate, Reset, Download buttons must work +- Keep Anthropic branding (UI styling, not art colors) + +### USING THE ARTIFACT + +The HTML artifact works immediately: + +1. **In claude.ai**: Displayed as an interactive artifact - runs instantly +2. **As a file**: Save and open in any browser - no server needed +3. **Sharing**: Send the HTML file - it's completely self-contained + +--- + +## VARIATIONS & EXPLORATION + +The artifact includes seed navigation by default (prev/next/random buttons), allowing users to explore variations without creating multiple files. If the user wants specific variations highlighted: + +- Include seed presets (buttons for "Variation 1: Seed 42", "Variation 2: Seed 127", etc.) +- Add a "Gallery Mode" that shows thumbnails of multiple seeds side-by-side +- All within the same single artifact + +This is like creating a series of prints from the same plate - the algorithm is consistent, but each seed reveals different facets of its potential. The interactive nature means users discover their own favorites by exploring the seed space. + +--- + +## THE CREATIVE PROCESS + +**User request** → **Algorithmic philosophy** → **Implementation** + +Each request is unique. The process involves: + +1. **Interpret the user's intent** - What aesthetic is being sought? +2. **Create an algorithmic philosophy** (4-6 paragraphs) describing the computational approach +3. **Implement it in code** - Build the algorithm that expresses this philosophy +4. **Design appropriate parameters** - What should be tunable? +5. **Build matching UI controls** - Sliders/inputs for those parameters + +**The constants**: + +- Anthropic branding (colors, fonts, layout) +- Seed navigation (always present) +- Self-contained HTML artifact + +**Everything else is variable**: + +- The algorithm itself +- The parameters +- The UI controls +- The visual outcome + +To achieve the best results, trust creativity and let the philosophy guide the implementation. + +--- + +## RESOURCES + +This skill includes helpful templates and documentation: + +- **templates/viewer.html**: REQUIRED STARTING POINT for all HTML artifacts. + - This is the foundation - contains the exact structure and Anthropic branding + - **Keep unchanged**: Layout structure, sidebar organization, Anthropic colors/fonts, seed controls, action buttons + - **Replace**: The p5.js algorithm, parameter definitions, and UI controls in Parameters section + - The extensive comments in the file mark exactly what to keep vs replace + +- **templates/generator_template.js**: Reference for p5.js best practices and code structure principles. + - Shows how to organize parameters, use seeded randomness, structure classes + - NOT a pattern menu - use these principles to build unique algorithms + - Embed algorithms inline in the HTML artifact (don't create separate .js files) + +**Critical reminder**: + +- The **template is the STARTING POINT**, not inspiration +- The **algorithm is where to create** something unique +- Don't copy the flow field example - build what the philosophy demands +- But DO keep the exact UI structure and Anthropic branding from the template diff --git a/packages/mosaic/framework/skills/algorithmic-art/templates/generator_template.js b/packages/mosaic/framework/skills/algorithmic-art/templates/generator_template.js new file mode 100644 index 00000000..ed5b0cfb --- /dev/null +++ b/packages/mosaic/framework/skills/algorithmic-art/templates/generator_template.js @@ -0,0 +1,223 @@ +/** + * ═══════════════════════════════════════════════════════════════════════════ + * P5.JS GENERATIVE ART - BEST PRACTICES + * ═══════════════════════════════════════════════════════════════════════════ + * + * This file shows STRUCTURE and PRINCIPLES for p5.js generative art. + * It does NOT prescribe what art you should create. + * + * Your algorithmic philosophy should guide what you build. + * These are just best practices for how to structure your code. + * + * ═══════════════════════════════════════════════════════════════════════════ + */ + +// ============================================================================ +// 1. PARAMETER ORGANIZATION +// ============================================================================ +// Keep all tunable parameters in one object +// This makes it easy to: +// - Connect to UI controls +// - Reset to defaults +// - Serialize/save configurations + +let params = { + // Define parameters that match YOUR algorithm + // Examples (customize for your art): + // - Counts: how many elements (particles, circles, branches, etc.) + // - Scales: size, speed, spacing + // - Probabilities: likelihood of events + // - Angles: rotation, direction + // - Colors: palette arrays + + seed: 12345, + // define colorPalette as an array -- choose whatever colors you'd like ['#d97757', '#6a9bcc', '#788c5d', '#b0aea5'] + // Add YOUR parameters here based on your algorithm +}; + +// ============================================================================ +// 2. SEEDED RANDOMNESS (Critical for reproducibility) +// ============================================================================ +// ALWAYS use seeded random for Art Blocks-style reproducible output + +function initializeSeed(seed) { + randomSeed(seed); + noiseSeed(seed); + // Now all random() and noise() calls will be deterministic +} + +// ============================================================================ +// 3. P5.JS LIFECYCLE +// ============================================================================ + +function setup() { + createCanvas(800, 800); + + // Initialize seed first + initializeSeed(params.seed); + + // Set up your generative system + // This is where you initialize: + // - Arrays of objects + // - Grid structures + // - Initial positions + // - Starting states + + // For static art: call noLoop() at the end of setup + // For animated art: let draw() keep running +} + +function draw() { + // Option 1: Static generation (runs once, then stops) + // - Generate everything in setup() + // - Call noLoop() in setup() + // - draw() doesn't do much or can be empty + // Option 2: Animated generation (continuous) + // - Update your system each frame + // - Common patterns: particle movement, growth, evolution + // - Can optionally call noLoop() after N frames + // Option 3: User-triggered regeneration + // - Use noLoop() by default + // - Call redraw() when parameters change +} + +// ============================================================================ +// 4. CLASS STRUCTURE (When you need objects) +// ============================================================================ +// Use classes when your algorithm involves multiple entities +// Examples: particles, agents, cells, nodes, etc. + +class Entity { + constructor() { + // Initialize entity properties + // Use random() here - it will be seeded + } + + update() { + // Update entity state + // This might involve: + // - Physics calculations + // - Behavioral rules + // - Interactions with neighbors + } + + display() { + // Render the entity + // Keep rendering logic separate from update logic + } +} + +// ============================================================================ +// 5. PERFORMANCE CONSIDERATIONS +// ============================================================================ + +// For large numbers of elements: +// - Pre-calculate what you can +// - Use simple collision detection (spatial hashing if needed) +// - Limit expensive operations (sqrt, trig) when possible +// - Consider using p5 vectors efficiently + +// For smooth animation: +// - Aim for 60fps +// - Profile if things are slow +// - Consider reducing particle counts or simplifying calculations + +// ============================================================================ +// 6. UTILITY FUNCTIONS +// ============================================================================ + +// Color utilities +function hexToRgb(hex) { + const result = /^#?([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})$/i.exec(hex); + return result + ? { + r: parseInt(result[1], 16), + g: parseInt(result[2], 16), + b: parseInt(result[3], 16), + } + : null; +} + +function colorFromPalette(index) { + return params.colorPalette[index % params.colorPalette.length]; +} + +// Mapping and easing +function mapRange(value, inMin, inMax, outMin, outMax) { + return outMin + (outMax - outMin) * ((value - inMin) / (inMax - inMin)); +} + +function easeInOutCubic(t) { + return t < 0.5 ? 4 * t * t * t : 1 - Math.pow(-2 * t + 2, 3) / 2; +} + +// Constrain to bounds +function wrapAround(value, max) { + if (value < 0) return max; + if (value > max) return 0; + return value; +} + +// ============================================================================ +// 7. PARAMETER UPDATES (Connect to UI) +// ============================================================================ + +function updateParameter(paramName, value) { + params[paramName] = value; + // Decide if you need to regenerate or just update + // Some params can update in real-time, others need full regeneration +} + +function regenerate() { + // Reinitialize your generative system + // Useful when parameters change significantly + initializeSeed(params.seed); + // Then regenerate your system +} + +// ============================================================================ +// 8. COMMON P5.JS PATTERNS +// ============================================================================ + +// Drawing with transparency for trails/fading +function fadeBackground(opacity) { + fill(250, 249, 245, opacity); // Anthropic light with alpha + noStroke(); + rect(0, 0, width, height); +} + +// Using noise for organic variation +function getNoiseValue(x, y, scale = 0.01) { + return noise(x * scale, y * scale); +} + +// Creating vectors from angles +function vectorFromAngle(angle, magnitude = 1) { + return createVector(cos(angle), sin(angle)).mult(magnitude); +} + +// ============================================================================ +// 9. EXPORT FUNCTIONS +// ============================================================================ + +function exportImage() { + saveCanvas('generative-art-' + params.seed, 'png'); +} + +// ============================================================================ +// REMEMBER +// ============================================================================ +// +// These are TOOLS and PRINCIPLES, not a recipe. +// Your algorithmic philosophy should guide WHAT you create. +// This structure helps you create it WELL. +// +// Focus on: +// - Clean, readable code +// - Parameterized for exploration +// - Seeded for reproducibility +// - Performant execution +// +// The art itself is entirely up to you! +// +// ============================================================================ diff --git a/packages/mosaic/framework/skills/algorithmic-art/templates/viewer.html b/packages/mosaic/framework/skills/algorithmic-art/templates/viewer.html new file mode 100644 index 00000000..630cc1f6 --- /dev/null +++ b/packages/mosaic/framework/skills/algorithmic-art/templates/viewer.html @@ -0,0 +1,599 @@ + + + + + + + Generative Art Viewer + + + + + + + +
+ + + + +
+
+
Initializing generative art...
+
+
+
+ + + + \ No newline at end of file diff --git a/packages/mosaic/framework/skills/analytics-tracking/SKILL.md b/packages/mosaic/framework/skills/analytics-tracking/SKILL.md new file mode 100644 index 00000000..fcd7fb33 --- /dev/null +++ b/packages/mosaic/framework/skills/analytics-tracking/SKILL.md @@ -0,0 +1,317 @@ +--- +name: analytics-tracking +version: 1.0.0 +description: When the user wants to set up, improve, or audit analytics tracking and measurement. Also use when the user mentions "set up tracking," "GA4," "Google Analytics," "conversion tracking," "event tracking," "UTM parameters," "tag manager," "GTM," "analytics implementation," or "tracking plan." For A/B test measurement, see ab-test-setup. +--- + +# Analytics Tracking + +You are an expert in analytics implementation and measurement. Your goal is to help set up tracking that provides actionable insights for marketing and product decisions. + +## Initial Assessment + +**Check for product marketing context first:** +If `.mosaic/product-marketing-context.md` exists, read it before asking questions. Use that context and only ask for information not already covered or specific to this task. + +Before implementing tracking, understand: + +1. **Business Context** - What decisions will this data inform? What are key conversions? +2. **Current State** - What tracking exists? What tools are in use? +3. **Technical Context** - What's the tech stack? Any privacy/compliance requirements? + +--- + +## Core Principles + +### 1. Track for Decisions, Not Data + +- Every event should inform a decision +- Avoid vanity metrics +- Quality > quantity of events + +### 2. Start with the Questions + +- What do you need to know? +- What actions will you take based on this data? +- Work backwards to what you need to track + +### 3. Name Things Consistently + +- Naming conventions matter +- Establish patterns before implementing +- Document everything + +### 4. Maintain Data Quality + +- Validate implementation +- Monitor for issues +- Clean data > more data + +--- + +## Tracking Plan Framework + +### Structure + +``` +Event Name | Category | Properties | Trigger | Notes +---------- | -------- | ---------- | ------- | ----- +``` + +### Event Types + +| Type | Examples | +| ------------------ | ------------------------------------------------ | +| Pageviews | Automatic, enhanced with metadata | +| User Actions | Button clicks, form submissions, feature usage | +| System Events | Signup completed, purchase, subscription changed | +| Custom Conversions | Goal completions, funnel stages | + +**For comprehensive event lists**: See [references/event-library.md](references/event-library.md) + +--- + +## Event Naming Conventions + +### Recommended Format: Object-Action + +``` +signup_completed +button_clicked +form_submitted +article_read +checkout_payment_completed +``` + +### Best Practices + +- Lowercase with underscores +- Be specific: `cta_hero_clicked` vs. `button_clicked` +- Include context in properties, not event name +- Avoid spaces and special characters +- Document decisions + +--- + +## Essential Events + +### Marketing Site + +| Event | Properties | +| ---------------- | --------------------- | +| cta_clicked | button_text, location | +| form_submitted | form_type | +| signup_completed | method, source | +| demo_requested | - | + +### Product/App + +| Event | Properties | +| ------------------------- | ---------------------- | +| onboarding_step_completed | step_number, step_name | +| feature_used | feature_name | +| purchase_completed | plan, value | +| subscription_cancelled | reason | + +**For full event library by business type**: See [references/event-library.md](references/event-library.md) + +--- + +## Event Properties + +### Standard Properties + +| Category | Properties | +| -------- | ----------------------------------------- | +| Page | page_title, page_location, page_referrer | +| User | user_id, user_type, account_id, plan_type | +| Campaign | source, medium, campaign, content, term | +| Product | product_id, product_name, category, price | + +### Best Practices + +- Use consistent property names +- Include relevant context +- Don't duplicate automatic properties +- Avoid PII in properties + +--- + +## GA4 Implementation + +### Quick Setup + +1. Create GA4 property and data stream +2. Install gtag.js or GTM +3. Enable enhanced measurement +4. Configure custom events +5. Mark conversions in Admin + +### Custom Event Example + +```javascript +gtag('event', 'signup_completed', { + method: 'email', + plan: 'free', +}); +``` + +**For detailed GA4 implementation**: See [references/ga4-implementation.md](references/ga4-implementation.md) + +--- + +## Google Tag Manager + +### Container Structure + +| Component | Purpose | +| --------- | --------------------------------------- | +| Tags | Code that executes (GA4, pixels) | +| Triggers | When tags fire (page view, click) | +| Variables | Dynamic values (click text, data layer) | + +### Data Layer Pattern + +```javascript +dataLayer.push({ + event: 'form_submitted', + form_name: 'contact', + form_location: 'footer', +}); +``` + +**For detailed GTM implementation**: See [references/gtm-implementation.md](references/gtm-implementation.md) + +--- + +## UTM Parameter Strategy + +### Standard Parameters + +| Parameter | Purpose | Example | +| ------------ | ---------------------- | ------------------ | +| utm_source | Traffic source | google, newsletter | +| utm_medium | Marketing medium | cpc, email, social | +| utm_campaign | Campaign name | spring_sale | +| utm_content | Differentiate versions | hero_cta | +| utm_term | Paid search keywords | running+shoes | + +### Naming Conventions + +- Lowercase everything +- Use underscores or hyphens consistently +- Be specific but concise: `blog_footer_cta`, not `cta1` +- Document all UTMs in a spreadsheet + +--- + +## Debugging and Validation + +### Testing Tools + +| Tool | Use For | +| ------------------ | ---------------------------------- | +| GA4 DebugView | Real-time event monitoring | +| GTM Preview Mode | Test triggers before publish | +| Browser Extensions | Tag Assistant, dataLayer Inspector | + +### Validation Checklist + +- [ ] Events firing on correct triggers +- [ ] Property values populating correctly +- [ ] No duplicate events +- [ ] Works across browsers and mobile +- [ ] Conversions recorded correctly +- [ ] No PII leaking + +### Common Issues + +| Issue | Check | +| ----------------- | ----------------------------------------- | +| Events not firing | Trigger config, GTM loaded | +| Wrong values | Variable path, data layer structure | +| Duplicate events | Multiple containers, trigger firing twice | + +--- + +## Privacy and Compliance + +### Considerations + +- Cookie consent required in EU/UK/CA +- No PII in analytics properties +- Data retention settings +- User deletion capabilities + +### Implementation + +- Use consent mode (wait for consent) +- IP anonymization +- Only collect what you need +- Integrate with consent management platform + +--- + +## Output Format + +### Tracking Plan Document + +```markdown +# [Site/Product] Tracking Plan + +## Overview + +- Tools: GA4, GTM +- Last updated: [Date] + +## Events + +| Event Name | Description | Properties | Trigger | +| ---------------- | --------------------- | ------------ | ------------ | +| signup_completed | User completes signup | method, plan | Success page | + +## Custom Dimensions + +| Name | Scope | Parameter | +| --------- | ----- | --------- | +| user_type | User | user_type | + +## Conversions + +| Conversion | Event | Counting | +| ---------- | ---------------- | ---------------- | +| Signup | signup_completed | Once per session | +``` + +--- + +## Task-Specific Questions + +1. What tools are you using (GA4, Mixpanel, etc.)? +2. What key actions do you want to track? +3. What decisions will this data inform? +4. Who implements - dev team or marketing? +5. Are there privacy/consent requirements? +6. What's already tracked? + +--- + +## Tool Integrations + +For implementation, see the [tools registry](../../tools/REGISTRY.md). Key analytics tools: + +| Tool | Best For | MCP | Guide | +| ------------- | ------------------------------------- | :-: | ----------------------------------------------------- | +| **GA4** | Web analytics, Google ecosystem | ✓ | [ga4.md](../../tools/integrations/ga4.md) | +| **Mixpanel** | Product analytics, event tracking | - | [mixpanel.md](../../tools/integrations/mixpanel.md) | +| **Amplitude** | Product analytics, cohort analysis | - | [amplitude.md](../../tools/integrations/amplitude.md) | +| **PostHog** | Open-source analytics, session replay | - | [posthog.md](../../tools/integrations/posthog.md) | +| **Segment** | Customer data platform, routing | - | [segment.md](../../tools/integrations/segment.md) | + +--- + +## Related Skills + +- **ab-test-setup**: For experiment tracking +- **seo-audit**: For organic traffic analysis +- **page-cro**: For conversion optimization (uses this data) diff --git a/packages/mosaic/framework/skills/analytics-tracking/references/event-library.md b/packages/mosaic/framework/skills/analytics-tracking/references/event-library.md new file mode 100644 index 00000000..6acb779c --- /dev/null +++ b/packages/mosaic/framework/skills/analytics-tracking/references/event-library.md @@ -0,0 +1,259 @@ +# Event Library Reference + +Comprehensive list of events to track by business type and context. + +## Marketing Site Events + +### Navigation & Engagement + +| Event Name | Description | Properties | +| --------------------- | -------------------------- | ---------------------------------------- | +| page_view | Page loaded (enhanced) | page_title, page_location, content_group | +| scroll_depth | User scrolled to threshold | depth (25, 50, 75, 100) | +| outbound_link_clicked | Click to external site | link_url, link_text | +| internal_link_clicked | Click within site | link_url, link_text, location | +| video_played | Video started | video_id, video_title, duration | +| video_completed | Video finished | video_id, video_title, duration | + +### CTA & Form Interactions + +| Event Name | Description | Properties | +| -------------------- | ---------------------- | ------------------------------- | +| cta_clicked | Call to action clicked | button_text, cta_location, page | +| form_started | User began form | form_name, form_location | +| form_field_completed | Field filled | form_name, field_name | +| form_submitted | Form successfully sent | form_name, form_location | +| form_error | Form validation failed | form_name, error_type | +| resource_downloaded | Asset downloaded | resource_name, resource_type | + +### Conversion Events + +| Event Name | Description | Properties | +| --------------------- | ------------------- | ---------------------- | +| signup_started | Initiated signup | source, page | +| signup_completed | Finished signup | method, plan, source | +| demo_requested | Demo form submitted | company_size, industry | +| contact_submitted | Contact form sent | inquiry_type | +| newsletter_subscribed | Email list signup | source, list_name | +| trial_started | Free trial began | plan, source | + +--- + +## Product/App Events + +### Onboarding + +| Event Name | Description | Properties | +| -------------------------- | ----------------------- | --------------------------------- | +| signup_completed | Account created | method, referral_source | +| onboarding_started | Began onboarding | - | +| onboarding_step_completed | Step finished | step_number, step_name | +| onboarding_completed | All steps done | steps_completed, time_to_complete | +| onboarding_skipped | User skipped onboarding | step_skipped_at | +| first_key_action_completed | Aha moment reached | action_type | + +### Core Usage + +| Event Name | Description | Properties | +| ---------------- | --------------------- | ------------------------------ | +| session_started | App session began | session_number | +| feature_used | Feature interaction | feature_name, feature_category | +| action_completed | Core action done | action_type, count | +| content_created | User created content | content_type | +| content_edited | User modified content | content_type | +| content_deleted | User removed content | content_type | +| search_performed | In-app search | query, results_count | +| settings_changed | Settings modified | setting_name, new_value | +| invite_sent | User invited others | invite_type, count | + +### Errors & Support + +| Event Name | Description | Properties | +| ------------------ | -------------------- | ------------------------------- | +| error_occurred | Error experienced | error_type, error_message, page | +| help_opened | Help accessed | help_type, page | +| support_contacted | Support request made | contact_method, issue_type | +| feedback_submitted | User feedback given | feedback_type, rating | + +--- + +## Monetization Events + +### Pricing & Checkout + +| Event Name | Description | Properties | +| -------------------- | ------------------- | ------------------------------------- | +| pricing_viewed | Pricing page seen | source | +| plan_selected | Plan chosen | plan_name, billing_cycle | +| checkout_started | Began checkout | plan, value | +| payment_info_entered | Payment submitted | payment_method | +| purchase_completed | Purchase successful | plan, value, currency, transaction_id | +| purchase_failed | Purchase failed | error_reason, plan | + +### Subscription Management + +| Event Name | Description | Properties | +| ----------------------- | ---------------------- | ------------------------- | +| trial_started | Trial began | plan, trial_length | +| trial_ended | Trial expired | plan, converted (bool) | +| subscription_upgraded | Plan upgraded | from_plan, to_plan, value | +| subscription_downgraded | Plan downgraded | from_plan, to_plan | +| subscription_cancelled | Cancelled | plan, reason, tenure | +| subscription_renewed | Renewed | plan, value | +| billing_updated | Payment method changed | - | + +--- + +## E-commerce Events + +### Browsing + +| Event Name | Description | Properties | +| ------------------- | -------------------- | ----------------------------------------- | +| product_viewed | Product page viewed | product_id, product_name, category, price | +| product_list_viewed | Category/list viewed | list_name, products[] | +| product_searched | Search performed | query, results_count | +| product_filtered | Filters applied | filter_type, filter_value | +| product_sorted | Sort applied | sort_by, sort_order | + +### Cart + +| Event Name | Description | Properties | +| ------------------------- | ---------------- | ----------------------------------------- | +| product_added_to_cart | Item added | product_id, product_name, price, quantity | +| product_removed_from_cart | Item removed | product_id, product_name, price, quantity | +| cart_viewed | Cart page viewed | cart_value, items_count | + +### Checkout + +| Event Name | Description | Properties | +| ----------------------- | --------------- | ---------------------------------------- | +| checkout_started | Checkout began | cart_value, items_count | +| checkout_step_completed | Step finished | step_number, step_name | +| shipping_info_entered | Address entered | shipping_method | +| payment_info_entered | Payment entered | payment_method | +| coupon_applied | Coupon used | coupon_code, discount_value | +| purchase_completed | Order placed | transaction_id, value, currency, items[] | + +### Post-Purchase + +| Event Name | Description | Properties | +| ---------------- | ------------------- | ---------------------- | +| order_confirmed | Confirmation viewed | transaction_id | +| refund_requested | Refund initiated | transaction_id, reason | +| refund_completed | Refund processed | transaction_id, value | +| review_submitted | Product reviewed | product_id, rating | + +--- + +## B2B / SaaS Specific Events + +### Team & Collaboration + +| Event Name | Description | Properties | +| ------------------- | ------------------- | --------------------------- | +| team_created | New team/org made | team_size, plan | +| team_member_invited | Invite sent | role, invite_method | +| team_member_joined | Member accepted | role | +| team_member_removed | Member removed | role | +| role_changed | Permissions updated | user_id, old_role, new_role | + +### Integration Events + +| Event Name | Description | Properties | +| ------------------------ | ---------------------- | ------------------------ | +| integration_viewed | Integration page seen | integration_name | +| integration_started | Setup began | integration_name | +| integration_connected | Successfully connected | integration_name | +| integration_disconnected | Removed integration | integration_name, reason | + +### Account Events + +| Event Name | Description | Properties | +| ------------------- | ----------------- | ------------------------- | +| account_created | New account | source, plan | +| account_upgraded | Plan upgrade | from_plan, to_plan | +| account_churned | Account closed | reason, tenure, mrr_lost | +| account_reactivated | Returned customer | previous_tenure, new_plan | + +--- + +## Event Properties (Parameters) + +### Standard Properties to Include + +**User Context:** + +``` +user_id: "12345" +user_type: "free" | "trial" | "paid" +account_id: "acct_123" +plan_type: "starter" | "pro" | "enterprise" +``` + +**Session Context:** + +``` +session_id: "sess_abc" +session_number: 5 +page: "/pricing" +referrer: "https://google.com" +``` + +**Campaign Context:** + +``` +source: "google" +medium: "cpc" +campaign: "spring_sale" +content: "hero_cta" +``` + +**Product Context (E-commerce):** + +``` +product_id: "SKU123" +product_name: "Product Name" +category: "Category" +price: 99.99 +quantity: 1 +currency: "USD" +``` + +**Timing:** + +``` +timestamp: "2024-01-15T10:30:00Z" +time_on_page: 45 +session_duration: 300 +``` + +--- + +## Funnel Event Sequences + +### Signup Funnel + +1. signup_started +2. signup_step_completed (email) +3. signup_step_completed (password) +4. signup_completed +5. onboarding_started + +### Purchase Funnel + +1. pricing_viewed +2. plan_selected +3. checkout_started +4. payment_info_entered +5. purchase_completed + +### E-commerce Funnel + +1. product_viewed +2. product_added_to_cart +3. cart_viewed +4. checkout_started +5. shipping_info_entered +6. payment_info_entered +7. purchase_completed diff --git a/packages/mosaic/framework/skills/analytics-tracking/references/ga4-implementation.md b/packages/mosaic/framework/skills/analytics-tracking/references/ga4-implementation.md new file mode 100644 index 00000000..b09b8425 --- /dev/null +++ b/packages/mosaic/framework/skills/analytics-tracking/references/ga4-implementation.md @@ -0,0 +1,309 @@ +# GA4 Implementation Reference + +Detailed implementation guide for Google Analytics 4. + +## Configuration + +### Data Streams + +- One stream per platform (web, iOS, Android) +- Enable enhanced measurement for automatic tracking +- Configure data retention (2 months default, 14 months max) +- Enable Google Signals (for cross-device, if consented) + +### Enhanced Measurement Events (Automatic) + +| Event | Description | Configuration | +| ---------------- | ------------------------ | ----------------------- | +| page_view | Page loads | Automatic | +| scroll | 90% scroll depth | Toggle on/off | +| outbound_click | Click to external domain | Automatic | +| site_search | Search query used | Configure parameter | +| video_engagement | YouTube video plays | Toggle on/off | +| file_download | PDF, docs, etc. | Configurable extensions | + +### Recommended Events + +Use Google's predefined events when possible for enhanced reporting: + +**All properties:** + +- login, sign_up +- share +- search + +**E-commerce:** + +- view_item, view_item_list +- add_to_cart, remove_from_cart +- begin_checkout +- add_payment_info +- purchase, refund + +**Games:** + +- level_up, unlock_achievement +- post_score, spend_virtual_currency + +Reference: https://support.google.com/analytics/answer/9267735 + +--- + +## Custom Events + +### gtag.js Implementation + +```javascript +// Basic event +gtag('event', 'signup_completed', { + method: 'email', + plan: 'free', +}); + +// Event with value +gtag('event', 'purchase', { + transaction_id: 'T12345', + value: 99.99, + currency: 'USD', + items: [ + { + item_id: 'SKU123', + item_name: 'Product Name', + price: 99.99, + }, + ], +}); + +// User properties +gtag('set', 'user_properties', { + user_type: 'premium', + plan_name: 'pro', +}); + +// User ID (for logged-in users) +gtag('config', 'GA_MEASUREMENT_ID', { + user_id: 'USER_ID', +}); +``` + +### Google Tag Manager (dataLayer) + +```javascript +// Custom event +dataLayer.push({ + event: 'signup_completed', + method: 'email', + plan: 'free', +}); + +// Set user properties +dataLayer.push({ + user_id: '12345', + user_type: 'premium', +}); + +// E-commerce purchase +dataLayer.push({ + event: 'purchase', + ecommerce: { + transaction_id: 'T12345', + value: 99.99, + currency: 'USD', + items: [ + { + item_id: 'SKU123', + item_name: 'Product Name', + price: 99.99, + quantity: 1, + }, + ], + }, +}); + +// Clear ecommerce before sending (best practice) +dataLayer.push({ ecommerce: null }); +dataLayer.push({ + event: 'view_item', + ecommerce: { + // ... + }, +}); +``` + +--- + +## Conversions Setup + +### Creating Conversions + +1. **Collect the event** - Ensure event is firing in GA4 +2. **Mark as conversion** - Admin > Events > Mark as conversion +3. **Set counting method**: + - Once per session (leads, signups) + - Every event (purchases) +4. **Import to Google Ads** - For conversion-optimized bidding + +### Conversion Values + +```javascript +// Event with conversion value +gtag('event', 'purchase', { + value: 99.99, + currency: 'USD', +}); +``` + +Or set default value in GA4 Admin when marking conversion. + +--- + +## Custom Dimensions and Metrics + +### When to Use + +**Custom dimensions:** + +- Properties you want to segment/filter by +- User attributes (plan type, industry) +- Content attributes (author, category) + +**Custom metrics:** + +- Numeric values to aggregate +- Scores, counts, durations + +### Setup Steps + +1. Admin > Data display > Custom definitions +2. Create dimension or metric +3. Choose scope: + - **Event**: Per event (content_type) + - **User**: Per user (account_type) + - **Item**: Per product (product_category) +4. Enter parameter name (must match event parameter) + +### Examples + +| Dimension | Scope | Parameter | Description | +| ---------------- | ----- | ------------- | ------------------- | +| User Type | User | user_type | Free, trial, paid | +| Content Author | Event | author | Blog post author | +| Product Category | Item | item_category | E-commerce category | + +--- + +## Audiences + +### Creating Audiences + +Admin > Data display > Audiences + +**Use cases:** + +- Remarketing audiences (export to Ads) +- Segment analysis +- Trigger-based events + +### Audience Examples + +**High-intent visitors:** + +- Viewed pricing page +- Did not convert +- In last 7 days + +**Engaged users:** + +- 3+ sessions +- Or 5+ minutes total engagement + +**Purchasers:** + +- Purchase event +- For exclusion or lookalike + +--- + +## Debugging + +### DebugView + +Enable with: + +- URL parameter: `?debug_mode=true` +- Chrome extension: GA Debugger +- gtag: `'debug_mode': true` in config + +View at: Reports > Configure > DebugView + +### Real-Time Reports + +Check events within 30 minutes: +Reports > Real-time + +### Common Issues + +**Events not appearing:** + +- Check DebugView first +- Verify gtag/GTM firing +- Check filter exclusions + +**Parameter values missing:** + +- Custom dimension not created +- Parameter name mismatch +- Data still processing (24-48 hrs) + +**Conversions not recording:** + +- Event not marked as conversion +- Event name doesn't match +- Counting method (once vs. every) + +--- + +## Data Quality + +### Filters + +Admin > Data streams > [Stream] > Configure tag settings > Define internal traffic + +**Exclude:** + +- Internal IP addresses +- Developer traffic +- Testing environments + +### Cross-Domain Tracking + +For multiple domains sharing analytics: + +1. Admin > Data streams > [Stream] > Configure tag settings +2. Configure your domains +3. List all domains that should share sessions + +### Session Settings + +Admin > Data streams > [Stream] > Configure tag settings + +- Session timeout (default 30 min) +- Engaged session duration (10 sec default) + +--- + +## Integration with Google Ads + +### Linking + +1. Admin > Product links > Google Ads links +2. Enable auto-tagging in Google Ads +3. Import conversions in Google Ads + +### Audience Export + +Audiences created in GA4 can be used in Google Ads for: + +- Remarketing campaigns +- Customer match +- Similar audiences diff --git a/packages/mosaic/framework/skills/analytics-tracking/references/gtm-implementation.md b/packages/mosaic/framework/skills/analytics-tracking/references/gtm-implementation.md new file mode 100644 index 00000000..68847250 --- /dev/null +++ b/packages/mosaic/framework/skills/analytics-tracking/references/gtm-implementation.md @@ -0,0 +1,410 @@ +# Google Tag Manager Implementation Reference + +Detailed guide for implementing tracking via Google Tag Manager. + +## Container Structure + +### Tags + +Tags are code snippets that execute when triggered. + +**Common tag types:** + +- GA4 Configuration (base setup) +- GA4 Event (custom events) +- Google Ads Conversion +- Facebook Pixel +- LinkedIn Insight Tag +- Custom HTML (for other pixels) + +### Triggers + +Triggers define when tags fire. + +**Built-in triggers:** + +- Page View: All Pages, DOM Ready, Window Loaded +- Click: All Elements, Just Links +- Form Submission +- Scroll Depth +- Timer +- Element Visibility + +**Custom triggers:** + +- Custom Event (from dataLayer) +- Trigger Groups (multiple conditions) + +### Variables + +Variables capture dynamic values. + +**Built-in (enable as needed):** + +- Click Text, Click URL, Click ID, Click Classes +- Page Path, Page URL, Page Hostname +- Referrer +- Form Element, Form ID + +**User-defined:** + +- Data Layer variables +- JavaScript variables +- Lookup tables +- RegEx tables +- Constants + +--- + +## Naming Conventions + +### Recommended Format + +``` +[Type] - [Description] - [Detail] + +Tags: +GA4 - Event - Signup Completed +GA4 - Config - Base Configuration +FB - Pixel - Page View +HTML - LiveChat Widget + +Triggers: +Click - CTA Button +Submit - Contact Form +View - Pricing Page +Custom - signup_completed + +Variables: +DL - user_id +JS - Current Timestamp +LT - Campaign Source Map +``` + +--- + +## Data Layer Patterns + +### Basic Structure + +```javascript +// Initialize (in before GTM) +window.dataLayer = window.dataLayer || []; + +// Push event +dataLayer.push({ + event: 'event_name', + property1: 'value1', + property2: 'value2', +}); +``` + +### Page Load Data + +```javascript +// Set on page load (before GTM container) +window.dataLayer = window.dataLayer || []; +dataLayer.push({ + pageType: 'product', + contentGroup: 'products', + user: { + loggedIn: true, + userId: '12345', + userType: 'premium', + }, +}); +``` + +### Form Submission + +```javascript +document.querySelector('#contact-form').addEventListener('submit', function () { + dataLayer.push({ + event: 'form_submitted', + formName: 'contact', + formLocation: 'footer', + }); +}); +``` + +### Button Click + +```javascript +document.querySelector('.cta-button').addEventListener('click', function () { + dataLayer.push({ + event: 'cta_clicked', + ctaText: this.innerText, + ctaLocation: 'hero', + }); +}); +``` + +### E-commerce Events + +```javascript +// Product view +dataLayer.push({ ecommerce: null }); // Clear previous +dataLayer.push({ + event: 'view_item', + ecommerce: { + items: [ + { + item_id: 'SKU123', + item_name: 'Product Name', + price: 99.99, + item_category: 'Category', + quantity: 1, + }, + ], + }, +}); + +// Add to cart +dataLayer.push({ ecommerce: null }); +dataLayer.push({ + event: 'add_to_cart', + ecommerce: { + items: [ + { + item_id: 'SKU123', + item_name: 'Product Name', + price: 99.99, + quantity: 1, + }, + ], + }, +}); + +// Purchase +dataLayer.push({ ecommerce: null }); +dataLayer.push({ + event: 'purchase', + ecommerce: { + transaction_id: 'T12345', + value: 99.99, + currency: 'USD', + tax: 5.0, + shipping: 10.0, + items: [ + { + item_id: 'SKU123', + item_name: 'Product Name', + price: 99.99, + quantity: 1, + }, + ], + }, +}); +``` + +--- + +## Common Tag Configurations + +### GA4 Configuration Tag + +**Tag Type:** Google Analytics: GA4 Configuration + +**Settings:** + +- Measurement ID: G-XXXXXXXX +- Send page view: Checked (for pageviews) +- User Properties: Add any user-level dimensions + +**Trigger:** All Pages + +### GA4 Event Tag + +**Tag Type:** Google Analytics: GA4 Event + +**Settings:** + +- Configuration Tag: Select your config tag +- Event Name: {{DL - event_name}} or hardcode +- Event Parameters: Add parameters from dataLayer + +**Trigger:** Custom Event with event name match + +### Facebook Pixel - Base + +**Tag Type:** Custom HTML + +```html + +``` + +**Trigger:** All Pages + +### Facebook Pixel - Event + +**Tag Type:** Custom HTML + +```html + +``` + +**Trigger:** Custom Event - form_submitted + +--- + +## Preview and Debug + +### Preview Mode + +1. Click "Preview" in GTM +2. Enter site URL +3. GTM debug panel opens at bottom + +**What to check:** + +- Tags fired on this event +- Tags not fired (and why) +- Variables and their values +- Data layer contents + +### Debug Tips + +**Tag not firing:** + +- Check trigger conditions +- Verify data layer push +- Check tag sequencing + +**Wrong variable value:** + +- Check data layer structure +- Verify variable path (nested objects) +- Check timing (data may not exist yet) + +**Multiple firings:** + +- Check trigger uniqueness +- Look for duplicate tags +- Check tag firing options + +--- + +## Workspaces and Versioning + +### Workspaces + +Use workspaces for team collaboration: + +- Default workspace for production +- Separate workspaces for large changes +- Merge when ready + +### Version Management + +**Best practices:** + +- Name every version descriptively +- Add notes explaining changes +- Review changes before publish +- Keep production version noted + +**Version notes example:** + +``` +v15: Added purchase conversion tracking +- New tag: GA4 - Event - Purchase +- New trigger: Custom Event - purchase +- New variables: DL - transaction_id, DL - value +- Tested: Chrome, Safari, Mobile +``` + +--- + +## Consent Management + +### Consent Mode Integration + +```javascript +// Default state (before consent) +gtag('consent', 'default', { + analytics_storage: 'denied', + ad_storage: 'denied', +}); + +// Update on consent +function grantConsent() { + gtag('consent', 'update', { + analytics_storage: 'granted', + ad_storage: 'granted', + }); +} +``` + +### GTM Consent Overview + +1. Enable Consent Overview in Admin +2. Configure consent for each tag +3. Tags respect consent state automatically + +--- + +## Advanced Patterns + +### Tag Sequencing + +**Setup tags to fire in order:** +Tag Configuration > Advanced Settings > Tag Sequencing + +**Use cases:** + +- Config tag before event tags +- Pixel initialization before tracking +- Cleanup after conversion + +### Exception Handling + +**Trigger exceptions** - Prevent tag from firing: + +- Exclude certain pages +- Exclude internal traffic +- Exclude during testing + +### Custom JavaScript Variables + +```javascript +// Get URL parameter +function() { + var params = new URLSearchParams(window.location.search); + return params.get('campaign') || '(not set)'; +} + +// Get cookie value +function() { + var match = document.cookie.match('(^|;) ?user_id=([^;]*)(;|$)'); + return match ? match[2] : null; +} + +// Get data from page +function() { + var el = document.querySelector('.product-price'); + return el ? parseFloat(el.textContent.replace('$', '')) : 0; +} +``` diff --git a/packages/mosaic/framework/skills/antfu/SKILL.md b/packages/mosaic/framework/skills/antfu/SKILL.md new file mode 100644 index 00000000..f5950e3e --- /dev/null +++ b/packages/mosaic/framework/skills/antfu/SKILL.md @@ -0,0 +1,129 @@ +--- +name: antfu +description: Anthony Fu's opinionated tooling and conventions for JavaScript/TypeScript projects. Use when setting up new projects, configuring ESLint/Prettier alternatives, monorepos, library publishing, or when the user mentions Anthony Fu's preferences. +metadata: + author: Anthony Fu + version: '2026.02.03' +--- + +## Coding Practices + +### Code Organization + +- **Single responsibility**: Each source file should have a clear, focused scope/purpose +- **Split large files**: Break files when they become large or handle too many concerns +- **Type separation**: Always separate types and interfaces into `types.ts` or `types/*.ts` +- **Constants extraction**: Move constants to a dedicated `constants.ts` file + +### Runtime Environment + +- **Prefer isomorphic code**: Write runtime-agnostic code that works in Node, browser, and workers whenever possible +- **Clear runtime indicators**: When code is environment-specific, add a comment at the top of the file: + +```ts +// @env node +// @env browser +``` + +### TypeScript + +- **Explicit return types**: Declare return types explicitly when possible +- **Avoid complex inline types**: Extract complex types into dedicated `type` or `interface` declarations + +### Comments + +- **Avoid unnecessary comments**: Code should be self-explanatory +- **Explain "why" not "how"**: Comments should describe the reasoning or intent, not what the code does + +### Testing (Vitest) + +- Test files: `foo.ts` → `foo.test.ts` (same directory) +- Use `describe`/`it` API (not `test`) +- Use `toMatchSnapshot` for complex outputs +- Use `toMatchFileSnapshot` with explicit path for language-specific snapshots + +--- + +## Tooling Choices + +### @antfu/ni Commands + +| Command | Description | +| -------------------------- | ------------------------------------------ | +| `ni` | Install dependencies | +| `ni ` / `ni -D ` | Add dependency / dev dependency | +| `nr +``` diff --git a/packages/mosaic/framework/skills/antfu/references/library-development.md b/packages/mosaic/framework/skills/antfu/references/library-development.md new file mode 100644 index 00000000..46903658 --- /dev/null +++ b/packages/mosaic/framework/skills/antfu/references/library-development.md @@ -0,0 +1,76 @@ +--- +name: library-development +description: Building and publishing TypeScript libraries with tsdown. Use when creating npm packages, configuring library bundling, or setting up package.json exports. +--- + +# Library Development + +| Aspect | Choice | +| ------- | ------------------------- | +| Bundler | tsdown | +| Output | Pure ESM only (no CJS) | +| DTS | Generated via tsdown | +| Exports | Auto-generated via tsdown | + +## tsdown Configuration + +Use tsdown with these options enabled: + +```ts +// tsdown.config.ts +import { defineConfig } from 'tsdown'; + +export default defineConfig({ + entry: ['src/index.ts'], + format: ['esm'], + dts: true, + exports: true, +}); +``` + +| Option | Value | Purpose | +| --------- | --------- | --------------------------------------------- | +| `format` | `['esm']` | Pure ESM, no CommonJS | +| `dts` | `true` | Generate `.d.ts` files | +| `exports` | `true` | Auto-update `exports` field in `package.json` | + +### Multiple Entry Points + +```ts +export default defineConfig({ + entry: ['src/index.ts', 'src/utils.ts'], + format: ['esm'], + dts: true, + exports: true, +}); +``` + +The `exports: true` option auto-generates the `exports` field in `package.json` when running `tsdown`. + +--- + +## package.json + +Required fields for pure ESM library: + +```json +{ + "type": "module", + "main": "./dist/index.mjs", + "module": "./dist/index.mjs", + "types": "./dist/index.d.mts", + "files": ["dist"], + "scripts": { + "build": "tsdown", + "prepack": "pnpm build", + "test": "vitest", + "release": "bumpp -r" + } +} +``` + +The `exports` field is managed by tsdown when `exports: true`. + +### prepack Script + +For each public package, add `"prepack": "pnpm build"` to `scripts`. This ensures the package is automatically built before publishing (e.g., when running `npm publish` or `pnpm publish`). This prevents accidentally publishing stale or missing build artifacts. diff --git a/packages/mosaic/framework/skills/antfu/references/monorepo.md b/packages/mosaic/framework/skills/antfu/references/monorepo.md new file mode 100644 index 00000000..899893eb --- /dev/null +++ b/packages/mosaic/framework/skills/antfu/references/monorepo.md @@ -0,0 +1,120 @@ +--- +name: monorepo +description: Monorepo setup with pnpm workspaces, centralized aliases, and Turborepo. Use when creating or managing multi-package repositories. +--- + +# Monorepo Setup + +## pnpm Workspaces + +Use pnpm workspaces for monorepo management: + +```yaml +# pnpm-workspace.yaml +packages: + - 'packages/*' +``` + +## Scripts Convention + +Have scripts in each package, and use `-r` (recursive) flag at root, +Enable ESLint cache for faster linting in monorepos. + +```json +// root package.json +{ + "scripts": { + "build": "pnpm run -r build", + "test": "vitest", + "lint": "eslint . --cache --concurrency=auto" + } +} +``` + +In each package's `package.json`, add the scripts. + +```json +// packages/*/package.json +{ + "scripts": { + "build": "tsdown", + "prepack": "pnpm build" + } +} +``` + +## ESLint Cache + +```json +{ + "scripts": { + "lint": "eslint . --cache --concurrency=auto" + } +} +``` + +## Turborepo (Optional) + +For monorepos with many packages or long build times, use Turborepo for task orchestration and caching. + +See the dedicated Turborepo skill for detailed configuration. + +## Centralized Alias + +For better DX across Vite, Nuxt, Vitest configs, create a centralized `alias.ts` at project root: + +```ts +// alias.ts +import fs from 'node:fs'; +import { fileURLToPath } from 'node:url'; +import { join, relative } from 'pathe'; + +const root = fileURLToPath(new URL('.', import.meta.url)); +const r = (path: string) => fileURLToPath(new URL(`./packages/${path}`, import.meta.url)); + +export const alias = { + '@myorg/core': r('core/src/index.ts'), + '@myorg/utils': r('utils/src/index.ts'), + '@myorg/ui': r('ui/src/index.ts'), + // Add more aliases as needed +}; + +// Auto-update tsconfig.alias.json paths +const raw = fs.readFileSync(join(root, 'tsconfig.alias.json'), 'utf-8').trim(); +const tsconfig = JSON.parse(raw); +tsconfig.compilerOptions.paths = Object.fromEntries( + Object.entries(alias).map(([key, value]) => [key, [`./${relative(root, value)}`]]), +); +const newRaw = JSON.stringify(tsconfig, null, 2); +if (newRaw !== raw) fs.writeFileSync(join(root, 'tsconfig.alias.json'), `${newRaw}\n`, 'utf-8'); +``` + +Then update the `tsconfig.json` to use the alias file: + +```json +{ + "extends": ["./tsconfig.alias.json"] +} +``` + +### Using Alias in Configs + +Reference the centralized alias in all config files: + +```ts +// vite.config.ts +import { alias } from './alias'; + +export default defineConfig({ + resolve: { alias }, +}); +``` + +```ts +// nuxt.config.ts +import { alias } from './alias'; + +export default defineNuxtConfig({ + alias, +}); +``` diff --git a/packages/mosaic/framework/skills/antfu/references/setting-up.md b/packages/mosaic/framework/skills/antfu/references/setting-up.md new file mode 100644 index 00000000..8588c6c4 --- /dev/null +++ b/packages/mosaic/framework/skills/antfu/references/setting-up.md @@ -0,0 +1,119 @@ +--- +name: setting-up +description: Project setup files including .gitignore, GitHub Actions workflows, and VS Code extensions. Use when initializing new projects or adding CI/editor config. +--- + +# Project Setup + +## .gitignore + +Create when `.gitignore` is not present: + +``` +*.log +*.tgz +.cache +.DS_Store +.eslintcache +.idea +.env +.nuxt +.temp +.output +.turbo +cache +coverage +dist +lib-cov +logs +node_modules +temp +``` + +## GitHub Actions + +Add these workflows when setting up a new project. Skip if workflows already exist. All use [sxzz/workflows](https://github.com/sxzz/workflows) reusable workflows. + +### Autofix Workflow + +**`.github/workflows/autofix.yml`** - Auto-fix linting on PRs: + +```yaml +name: autofix.ci + +on: [pull_request] + +jobs: + autofix: + uses: sxzz/workflows/.github/workflows/autofix.yml@v1 + permissions: + contents: read +``` + +### Unit Test Workflow + +**`.github/workflows/unit-test.yml`** - Run tests on push/PR: + +```yaml +name: Unit Test + +on: + push: + branches: [main] + pull_request: + branches: [main] + +permissions: {} + +jobs: + unit-test: + uses: sxzz/workflows/.github/workflows/unit-test.yml@v1 +``` + +### Release Workflow + +**`.github/workflows/release.yml`** - Publish on tag (library projects only): + +```yaml +name: Release + +on: + push: + tags: + - 'v*' + +jobs: + release: + uses: sxzz/workflows/.github/workflows/release.yml@v1 + with: + publish: true + permissions: + contents: write + id-token: write +``` + +## VS Code Extensions + +Configure in `.vscode/extensions.json`: + +```json +{ + "recommendations": [ + "dbaeumer.vscode-eslint", + "antfu.pnpm-catalog-lens", + "antfu.iconify", + "antfu.unocss", + "antfu.slidev", + "vue.volar" + ] +} +``` + +| Extension | Description | +| ------------------------- | --------------------------------------------- | +| `dbaeumer.vscode-eslint` | ESLint integration for linting and formatting | +| `antfu.pnpm-catalog-lens` | Shows pnpm catalog version hints inline | +| `antfu.iconify` | Iconify icon preview and autocomplete | +| `antfu.unocss` | UnoCSS IntelliSense and syntax highlighting | +| `antfu.slidev` | Slidev preview and syntax highlighting | +| `vue.volar` | Vue Language Features | diff --git a/packages/mosaic/framework/skills/architecture-patterns/SKILL.md b/packages/mosaic/framework/skills/architecture-patterns/SKILL.md new file mode 100644 index 00000000..ba0a3594 --- /dev/null +++ b/packages/mosaic/framework/skills/architecture-patterns/SKILL.md @@ -0,0 +1,494 @@ +--- +name: architecture-patterns +description: Implement proven backend architecture patterns including Clean Architecture, Hexagonal Architecture, and Domain-Driven Design. Use when architecting complex backend systems or refactoring existing applications for better maintainability. +--- + +# Architecture Patterns + +Master proven backend architecture patterns including Clean Architecture, Hexagonal Architecture, and Domain-Driven Design to build maintainable, testable, and scalable systems. + +## When to Use This Skill + +- Designing new backend systems from scratch +- Refactoring monolithic applications for better maintainability +- Establishing architecture standards for your team +- Migrating from tightly coupled to loosely coupled architectures +- Implementing domain-driven design principles +- Creating testable and mockable codebases +- Planning microservices decomposition + +## Core Concepts + +### 1. Clean Architecture (Uncle Bob) + +**Layers (dependency flows inward):** + +- **Entities**: Core business models +- **Use Cases**: Application business rules +- **Interface Adapters**: Controllers, presenters, gateways +- **Frameworks & Drivers**: UI, database, external services + +**Key Principles:** + +- Dependencies point inward +- Inner layers know nothing about outer layers +- Business logic independent of frameworks +- Testable without UI, database, or external services + +### 2. Hexagonal Architecture (Ports and Adapters) + +**Components:** + +- **Domain Core**: Business logic +- **Ports**: Interfaces defining interactions +- **Adapters**: Implementations of ports (database, REST, message queue) + +**Benefits:** + +- Swap implementations easily (mock for testing) +- Technology-agnostic core +- Clear separation of concerns + +### 3. Domain-Driven Design (DDD) + +**Strategic Patterns:** + +- **Bounded Contexts**: Separate models for different domains +- **Context Mapping**: How contexts relate +- **Ubiquitous Language**: Shared terminology + +**Tactical Patterns:** + +- **Entities**: Objects with identity +- **Value Objects**: Immutable objects defined by attributes +- **Aggregates**: Consistency boundaries +- **Repositories**: Data access abstraction +- **Domain Events**: Things that happened + +## Clean Architecture Pattern + +### Directory Structure + +``` +app/ +├── domain/ # Entities & business rules +│ ├── entities/ +│ │ ├── user.py +│ │ └── order.py +│ ├── value_objects/ +│ │ ├── email.py +│ │ └── money.py +│ └── interfaces/ # Abstract interfaces +│ ├── user_repository.py +│ └── payment_gateway.py +├── use_cases/ # Application business rules +│ ├── create_user.py +│ ├── process_order.py +│ └── send_notification.py +├── adapters/ # Interface implementations +│ ├── repositories/ +│ │ ├── postgres_user_repository.py +│ │ └── redis_cache_repository.py +│ ├── controllers/ +│ │ └── user_controller.py +│ └── gateways/ +│ ├── stripe_payment_gateway.py +│ └── sendgrid_email_gateway.py +└── infrastructure/ # Framework & external concerns + ├── database.py + ├── config.py + └── logging.py +``` + +### Implementation Example + +```python +# domain/entities/user.py +from dataclasses import dataclass +from datetime import datetime +from typing import Optional + +@dataclass +class User: + """Core user entity - no framework dependencies.""" + id: str + email: str + name: str + created_at: datetime + is_active: bool = True + + def deactivate(self): + """Business rule: deactivating user.""" + self.is_active = False + + def can_place_order(self) -> bool: + """Business rule: active users can order.""" + return self.is_active + +# domain/interfaces/user_repository.py +from abc import ABC, abstractmethod +from typing import Optional, List +from domain.entities.user import User + +class IUserRepository(ABC): + """Port: defines contract, no implementation.""" + + @abstractmethod + async def find_by_id(self, user_id: str) -> Optional[User]: + pass + + @abstractmethod + async def find_by_email(self, email: str) -> Optional[User]: + pass + + @abstractmethod + async def save(self, user: User) -> User: + pass + + @abstractmethod + async def delete(self, user_id: str) -> bool: + pass + +# use_cases/create_user.py +from domain.entities.user import User +from domain.interfaces.user_repository import IUserRepository +from dataclasses import dataclass +from datetime import datetime +import uuid + +@dataclass +class CreateUserRequest: + email: str + name: str + +@dataclass +class CreateUserResponse: + user: User + success: bool + error: Optional[str] = None + +class CreateUserUseCase: + """Use case: orchestrates business logic.""" + + def __init__(self, user_repository: IUserRepository): + self.user_repository = user_repository + + async def execute(self, request: CreateUserRequest) -> CreateUserResponse: + # Business validation + existing = await self.user_repository.find_by_email(request.email) + if existing: + return CreateUserResponse( + user=None, + success=False, + error="Email already exists" + ) + + # Create entity + user = User( + id=str(uuid.uuid4()), + email=request.email, + name=request.name, + created_at=datetime.now(), + is_active=True + ) + + # Persist + saved_user = await self.user_repository.save(user) + + return CreateUserResponse( + user=saved_user, + success=True + ) + +# adapters/repositories/postgres_user_repository.py +from domain.interfaces.user_repository import IUserRepository +from domain.entities.user import User +from typing import Optional +import asyncpg + +class PostgresUserRepository(IUserRepository): + """Adapter: PostgreSQL implementation.""" + + def __init__(self, pool: asyncpg.Pool): + self.pool = pool + + async def find_by_id(self, user_id: str) -> Optional[User]: + async with self.pool.acquire() as conn: + row = await conn.fetchrow( + "SELECT * FROM users WHERE id = $1", user_id + ) + return self._to_entity(row) if row else None + + async def find_by_email(self, email: str) -> Optional[User]: + async with self.pool.acquire() as conn: + row = await conn.fetchrow( + "SELECT * FROM users WHERE email = $1", email + ) + return self._to_entity(row) if row else None + + async def save(self, user: User) -> User: + async with self.pool.acquire() as conn: + await conn.execute( + """ + INSERT INTO users (id, email, name, created_at, is_active) + VALUES ($1, $2, $3, $4, $5) + ON CONFLICT (id) DO UPDATE + SET email = $2, name = $3, is_active = $5 + """, + user.id, user.email, user.name, user.created_at, user.is_active + ) + return user + + async def delete(self, user_id: str) -> bool: + async with self.pool.acquire() as conn: + result = await conn.execute( + "DELETE FROM users WHERE id = $1", user_id + ) + return result == "DELETE 1" + + def _to_entity(self, row) -> User: + """Map database row to entity.""" + return User( + id=row["id"], + email=row["email"], + name=row["name"], + created_at=row["created_at"], + is_active=row["is_active"] + ) + +# adapters/controllers/user_controller.py +from fastapi import APIRouter, Depends, HTTPException +from use_cases.create_user import CreateUserUseCase, CreateUserRequest +from pydantic import BaseModel + +router = APIRouter() + +class CreateUserDTO(BaseModel): + email: str + name: str + +@router.post("/users") +async def create_user( + dto: CreateUserDTO, + use_case: CreateUserUseCase = Depends(get_create_user_use_case) +): + """Controller: handles HTTP concerns only.""" + request = CreateUserRequest(email=dto.email, name=dto.name) + response = await use_case.execute(request) + + if not response.success: + raise HTTPException(status_code=400, detail=response.error) + + return {"user": response.user} +``` + +## Hexagonal Architecture Pattern + +```python +# Core domain (hexagon center) +class OrderService: + """Domain service - no infrastructure dependencies.""" + + def __init__( + self, + order_repository: OrderRepositoryPort, + payment_gateway: PaymentGatewayPort, + notification_service: NotificationPort + ): + self.orders = order_repository + self.payments = payment_gateway + self.notifications = notification_service + + async def place_order(self, order: Order) -> OrderResult: + # Business logic + if not order.is_valid(): + return OrderResult(success=False, error="Invalid order") + + # Use ports (interfaces) + payment = await self.payments.charge( + amount=order.total, + customer=order.customer_id + ) + + if not payment.success: + return OrderResult(success=False, error="Payment failed") + + order.mark_as_paid() + saved_order = await self.orders.save(order) + + await self.notifications.send( + to=order.customer_email, + subject="Order confirmed", + body=f"Order {order.id} confirmed" + ) + + return OrderResult(success=True, order=saved_order) + +# Ports (interfaces) +class OrderRepositoryPort(ABC): + @abstractmethod + async def save(self, order: Order) -> Order: + pass + +class PaymentGatewayPort(ABC): + @abstractmethod + async def charge(self, amount: Money, customer: str) -> PaymentResult: + pass + +class NotificationPort(ABC): + @abstractmethod + async def send(self, to: str, subject: str, body: str): + pass + +# Adapters (implementations) +class StripePaymentAdapter(PaymentGatewayPort): + """Primary adapter: connects to Stripe API.""" + + def __init__(self, api_key: str): + self.stripe = stripe + self.stripe.api_key = api_key + + async def charge(self, amount: Money, customer: str) -> PaymentResult: + try: + charge = self.stripe.Charge.create( + amount=amount.cents, + currency=amount.currency, + customer=customer + ) + return PaymentResult(success=True, transaction_id=charge.id) + except stripe.error.CardError as e: + return PaymentResult(success=False, error=str(e)) + +class MockPaymentAdapter(PaymentGatewayPort): + """Test adapter: no external dependencies.""" + + async def charge(self, amount: Money, customer: str) -> PaymentResult: + return PaymentResult(success=True, transaction_id="mock-123") +``` + +## Domain-Driven Design Pattern + +```python +# Value Objects (immutable) +from dataclasses import dataclass +from typing import Optional + +@dataclass(frozen=True) +class Email: + """Value object: validated email.""" + value: str + + def __post_init__(self): + if "@" not in self.value: + raise ValueError("Invalid email") + +@dataclass(frozen=True) +class Money: + """Value object: amount with currency.""" + amount: int # cents + currency: str + + def add(self, other: "Money") -> "Money": + if self.currency != other.currency: + raise ValueError("Currency mismatch") + return Money(self.amount + other.amount, self.currency) + +# Entities (with identity) +class Order: + """Entity: has identity, mutable state.""" + + def __init__(self, id: str, customer: Customer): + self.id = id + self.customer = customer + self.items: List[OrderItem] = [] + self.status = OrderStatus.PENDING + self._events: List[DomainEvent] = [] + + def add_item(self, product: Product, quantity: int): + """Business logic in entity.""" + item = OrderItem(product, quantity) + self.items.append(item) + self._events.append(ItemAddedEvent(self.id, item)) + + def total(self) -> Money: + """Calculated property.""" + return sum(item.subtotal() for item in self.items) + + def submit(self): + """State transition with business rules.""" + if not self.items: + raise ValueError("Cannot submit empty order") + if self.status != OrderStatus.PENDING: + raise ValueError("Order already submitted") + + self.status = OrderStatus.SUBMITTED + self._events.append(OrderSubmittedEvent(self.id)) + +# Aggregates (consistency boundary) +class Customer: + """Aggregate root: controls access to entities.""" + + def __init__(self, id: str, email: Email): + self.id = id + self.email = email + self._addresses: List[Address] = [] + self._orders: List[str] = [] # Order IDs, not full objects + + def add_address(self, address: Address): + """Aggregate enforces invariants.""" + if len(self._addresses) >= 5: + raise ValueError("Maximum 5 addresses allowed") + self._addresses.append(address) + + @property + def primary_address(self) -> Optional[Address]: + return next((a for a in self._addresses if a.is_primary), None) + +# Domain Events +@dataclass +class OrderSubmittedEvent: + order_id: str + occurred_at: datetime = field(default_factory=datetime.now) + +# Repository (aggregate persistence) +class OrderRepository: + """Repository: persist/retrieve aggregates.""" + + async def find_by_id(self, order_id: str) -> Optional[Order]: + """Reconstitute aggregate from storage.""" + pass + + async def save(self, order: Order): + """Persist aggregate and publish events.""" + await self._persist(order) + await self._publish_events(order._events) + order._events.clear() +``` + +## Resources + +- **references/clean-architecture-guide.md**: Detailed layer breakdown +- **references/hexagonal-architecture-guide.md**: Ports and adapters patterns +- **references/ddd-tactical-patterns.md**: Entities, value objects, aggregates +- **assets/clean-architecture-template/**: Complete project structure +- **assets/ddd-examples/**: Domain modeling examples + +## Best Practices + +1. **Dependency Rule**: Dependencies always point inward +2. **Interface Segregation**: Small, focused interfaces +3. **Business Logic in Domain**: Keep frameworks out of core +4. **Test Independence**: Core testable without infrastructure +5. **Bounded Contexts**: Clear domain boundaries +6. **Ubiquitous Language**: Consistent terminology +7. **Thin Controllers**: Delegate to use cases +8. **Rich Domain Models**: Behavior with data + +## Common Pitfalls + +- **Anemic Domain**: Entities with only data, no behavior +- **Framework Coupling**: Business logic depends on frameworks +- **Fat Controllers**: Business logic in controllers +- **Repository Leakage**: Exposing ORM objects +- **Missing Abstractions**: Concrete dependencies in core +- **Over-Engineering**: Clean architecture for simple CRUD diff --git a/packages/mosaic/framework/skills/better-auth-best-practices/SKILL.md b/packages/mosaic/framework/skills/better-auth-best-practices/SKILL.md new file mode 100644 index 00000000..a746db77 --- /dev/null +++ b/packages/mosaic/framework/skills/better-auth-best-practices/SKILL.md @@ -0,0 +1,174 @@ +--- +name: better-auth-best-practices +description: Skill for integrating Better Auth - the comprehensive TypeScript authentication framework. +--- + +# Better Auth Integration Guide + +**Always consult [better-auth.com/docs](https://better-auth.com/docs) for code examples and latest API.** + +Better Auth is a TypeScript-first, framework-agnostic auth framework supporting email/password, OAuth, magic links, passkeys, and more via plugins. + +--- + +## Quick Reference + +### Environment Variables + +- `BETTER_AUTH_SECRET` - Encryption secret (min 32 chars). Generate: `openssl rand -base64 32` +- `BETTER_AUTH_URL` - Base URL (e.g., `https://example.com`) + +Only define `baseURL`/`secret` in config if env vars are NOT set. + +### File Location + +CLI looks for `auth.ts` in: `./`, `./lib`, `./utils`, or under `./src`. Use `--config` for custom path. + +### CLI Commands + +- `npx @better-auth/cli@latest migrate` - Apply schema (built-in adapter) +- `npx @better-auth/cli@latest generate` - Generate schema for Prisma/Drizzle +- `npx @better-auth/cli mcp --cursor` - Add MCP to AI tools + +**Re-run after adding/changing plugins.** + +--- + +## Core Config Options + +| Option | Notes | +| ------------------ | ---------------------------------------------- | +| `appName` | Optional display name | +| `baseURL` | Only if `BETTER_AUTH_URL` not set | +| `basePath` | Default `/api/auth`. Set `/` for root. | +| `secret` | Only if `BETTER_AUTH_SECRET` not set | +| `database` | Required for most features. See adapters docs. | +| `secondaryStorage` | Redis/KV for sessions & rate limits | +| `emailAndPassword` | `{ enabled: true }` to activate | +| `socialProviders` | `{ google: { clientId, clientSecret }, ... }` | +| `plugins` | Array of plugins | +| `trustedOrigins` | CSRF whitelist | + +--- + +## Database + +**Direct connections:** Pass `pg.Pool`, `mysql2` pool, `better-sqlite3`, or `bun:sqlite` instance. + +**ORM adapters:** Import from `better-auth/adapters/drizzle`, `better-auth/adapters/prisma`, `better-auth/adapters/mongodb`. + +**Critical:** Better Auth uses adapter model names, NOT underlying table names. If Prisma model is `User` mapping to table `users`, use `modelName: "user"` (Prisma reference), not `"users"`. + +--- + +## Session Management + +**Storage priority:** + +1. If `secondaryStorage` defined → sessions go there (not DB) +2. Set `session.storeSessionInDatabase: true` to also persist to DB +3. No database + `cookieCache` → fully stateless mode + +**Cookie cache strategies:** + +- `compact` (default) - Base64url + HMAC. Smallest. +- `jwt` - Standard JWT. Readable but signed. +- `jwe` - Encrypted. Maximum security. + +**Key options:** `session.expiresIn` (default 7 days), `session.updateAge` (refresh interval), `session.cookieCache.maxAge`, `session.cookieCache.version` (change to invalidate all sessions). + +--- + +## User & Account Config + +**User:** `user.modelName`, `user.fields` (column mapping), `user.additionalFields`, `user.changeEmail.enabled` (disabled by default), `user.deleteUser.enabled` (disabled by default). + +**Account:** `account.modelName`, `account.accountLinking.enabled`, `account.storeAccountCookie` (for stateless OAuth). + +**Required for registration:** `email` and `name` fields. + +--- + +## Email Flows + +- `emailVerification.sendVerificationEmail` - Must be defined for verification to work +- `emailVerification.sendOnSignUp` / `sendOnSignIn` - Auto-send triggers +- `emailAndPassword.sendResetPassword` - Password reset email handler + +--- + +## Security + +**In `advanced`:** + +- `useSecureCookies` - Force HTTPS cookies +- `disableCSRFCheck` - ⚠️ Security risk +- `disableOriginCheck` - ⚠️ Security risk +- `crossSubDomainCookies.enabled` - Share cookies across subdomains +- `ipAddress.ipAddressHeaders` - Custom IP headers for proxies +- `database.generateId` - Custom ID generation or `"serial"`/`"uuid"`/`false` + +**Rate limiting:** `rateLimit.enabled`, `rateLimit.window`, `rateLimit.max`, `rateLimit.storage` ("memory" | "database" | "secondary-storage"). + +--- + +## Hooks + +**Endpoint hooks:** `hooks.before` / `hooks.after` - Array of `{ matcher, handler }`. Use `createAuthMiddleware`. Access `ctx.path`, `ctx.context.returned` (after), `ctx.context.session`. + +**Database hooks:** `databaseHooks.user.create.before/after`, same for `session`, `account`. Useful for adding default values or post-creation actions. + +**Hook context (`ctx.context`):** `session`, `secret`, `authCookies`, `password.hash()`/`verify()`, `adapter`, `internalAdapter`, `generateId()`, `tables`, `baseURL`. + +--- + +## Plugins + +**Import from dedicated paths for tree-shaking:** + +``` +import { twoFactor } from "better-auth/plugins/two-factor" +``` + +NOT `from "better-auth/plugins"`. + +**Popular plugins:** `twoFactor`, `organization`, `passkey`, `magicLink`, `emailOtp`, `username`, `phoneNumber`, `admin`, `apiKey`, `bearer`, `jwt`, `multiSession`, `sso`, `oauthProvider`, `oidcProvider`, `openAPI`, `genericOAuth`. + +Client plugins go in `createAuthClient({ plugins: [...] })`. + +--- + +## Client + +Import from: `better-auth/client` (vanilla), `better-auth/react`, `better-auth/vue`, `better-auth/svelte`, `better-auth/solid`. + +Key methods: `signUp.email()`, `signIn.email()`, `signIn.social()`, `signOut()`, `useSession()`, `getSession()`, `revokeSession()`, `revokeSessions()`. + +--- + +## Type Safety + +Infer types: `typeof auth.$Infer.Session`, `typeof auth.$Infer.Session.user`. + +For separate client/server projects: `createAuthClient()`. + +--- + +## Common Gotchas + +1. **Model vs table name** - Config uses ORM model name, not DB table name +2. **Plugin schema** - Re-run CLI after adding plugins +3. **Secondary storage** - Sessions go there by default, not DB +4. **Cookie cache** - Custom session fields NOT cached, always re-fetched +5. **Stateless mode** - No DB = session in cookie only, logout on cache expiry +6. **Change email flow** - Sends to current email first, then new email + +--- + +## Resources + +- [Docs](https://better-auth.com/docs) +- [Options Reference](https://better-auth.com/docs/reference/options) +- [LLMs.txt](https://better-auth.com/llms.txt) +- [GitHub](https://github.com/better-auth/better-auth) +- [Init Options Source](https://github.com/better-auth/better-auth/blob/main/packages/core/src/types/init-options.ts) diff --git a/packages/mosaic/framework/skills/brainstorming/SKILL.md b/packages/mosaic/framework/skills/brainstorming/SKILL.md new file mode 100644 index 00000000..66b1019e --- /dev/null +++ b/packages/mosaic/framework/skills/brainstorming/SKILL.md @@ -0,0 +1,101 @@ +--- +name: brainstorming +description: 'You MUST use this before any creative work - creating features, building components, adding functionality, or modifying behavior. Explores user intent, requirements and design before implementation.' +--- + +# Brainstorming Ideas Into Designs + +## Overview + +Help turn ideas into fully formed designs and specs through natural collaborative dialogue. + +Start by understanding the current project context, then ask questions one at a time to refine the idea. Once you understand what you're building, present the design and get user approval. + + +Do NOT invoke any implementation skill, write any code, scaffold any project, or take any implementation action until you have presented a design and the user has approved it. This applies to EVERY project regardless of perceived simplicity. + + +## Anti-Pattern: "This Is Too Simple To Need A Design" + +Every project goes through this process. A todo list, a single-function utility, a config change — all of them. "Simple" projects are where unexamined assumptions cause the most wasted work. The design can be short (a few sentences for truly simple projects), but you MUST present it and get approval. + +## Checklist + +You MUST create a task for each of these items and complete them in order: + +1. **Explore project context** — check files, docs, recent commits +2. **Ask clarifying questions** — one at a time, understand purpose/constraints/success criteria +3. **Propose 2-3 approaches** — with trade-offs and your recommendation +4. **Present design** — in sections scaled to their complexity, get user approval after each section +5. **Write design doc** — save to `docs/plans/YYYY-MM-DD--design.md` and commit +6. **Transition to implementation** — invoke writing-plans skill to create implementation plan + +## Process Flow + +```dot +digraph brainstorming { + "Explore project context" [shape=box]; + "Ask clarifying questions" [shape=box]; + "Propose 2-3 approaches" [shape=box]; + "Present design sections" [shape=box]; + "User approves design?" [shape=diamond]; + "Write design doc" [shape=box]; + "Invoke writing-plans skill" [shape=doublecircle]; + + "Explore project context" -> "Ask clarifying questions"; + "Ask clarifying questions" -> "Propose 2-3 approaches"; + "Propose 2-3 approaches" -> "Present design sections"; + "Present design sections" -> "User approves design?"; + "User approves design?" -> "Present design sections" [label="no, revise"]; + "User approves design?" -> "Write design doc" [label="yes"]; + "Write design doc" -> "Invoke writing-plans skill"; +} +``` + +**The terminal state is invoking writing-plans.** Do NOT invoke frontend-design, mcp-builder, or any other implementation skill. The ONLY skill you invoke after brainstorming is writing-plans. + +## The Process + +**Understanding the idea:** + +- Check out the current project state first (files, docs, recent commits) +- Ask questions one at a time to refine the idea +- Prefer multiple choice questions when possible, but open-ended is fine too +- Only one question per message - if a topic needs more exploration, break it into multiple questions +- Focus on understanding: purpose, constraints, success criteria + +**Exploring approaches:** + +- Propose 2-3 different approaches with trade-offs +- Present options conversationally with your recommendation and reasoning +- Lead with your recommended option and explain why + +**Presenting the design:** + +- Once you believe you understand what you're building, present the design +- Scale each section to its complexity: a few sentences if straightforward, up to 200-300 words if nuanced +- Ask after each section whether it looks right so far +- Cover: architecture, components, data flow, error handling, testing +- Be ready to go back and clarify if something doesn't make sense + +## After the Design + +**Documentation:** + +- Write the validated design to `docs/plans/YYYY-MM-DD--design.md` +- Use elements-of-style:writing-clearly-and-concisely skill if available +- Commit the design document to git + +**Implementation:** + +- Invoke the writing-plans skill to create a detailed implementation plan +- Do NOT invoke any other skill. writing-plans is the next step. + +## Key Principles + +- **One question at a time** - Don't overwhelm with multiple questions +- **Multiple choice preferred** - Easier to answer than open-ended when possible +- **YAGNI ruthlessly** - Remove unnecessary features from all designs +- **Explore alternatives** - Always propose 2-3 approaches before settling +- **Incremental validation** - Present design, get approval before moving on +- **Be flexible** - Go back and clarify when something doesn't make sense diff --git a/packages/mosaic/framework/skills/brand-guidelines/LICENSE.txt b/packages/mosaic/framework/skills/brand-guidelines/LICENSE.txt new file mode 100644 index 00000000..7a4a3ea2 --- /dev/null +++ b/packages/mosaic/framework/skills/brand-guidelines/LICENSE.txt @@ -0,0 +1,202 @@ + + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright [yyyy] [name of copyright owner] + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. \ No newline at end of file diff --git a/packages/mosaic/framework/skills/brand-guidelines/SKILL.md b/packages/mosaic/framework/skills/brand-guidelines/SKILL.md new file mode 100644 index 00000000..47c72c60 --- /dev/null +++ b/packages/mosaic/framework/skills/brand-guidelines/SKILL.md @@ -0,0 +1,73 @@ +--- +name: brand-guidelines +description: Applies Anthropic's official brand colors and typography to any sort of artifact that may benefit from having Anthropic's look-and-feel. Use it when brand colors or style guidelines, visual formatting, or company design standards apply. +license: Complete terms in LICENSE.txt +--- + +# Anthropic Brand Styling + +## Overview + +To access Anthropic's official brand identity and style resources, use this skill. + +**Keywords**: branding, corporate identity, visual identity, post-processing, styling, brand colors, typography, Anthropic brand, visual formatting, visual design + +## Brand Guidelines + +### Colors + +**Main Colors:** + +- Dark: `#141413` - Primary text and dark backgrounds +- Light: `#faf9f5` - Light backgrounds and text on dark +- Mid Gray: `#b0aea5` - Secondary elements +- Light Gray: `#e8e6dc` - Subtle backgrounds + +**Accent Colors:** + +- Orange: `#d97757` - Primary accent +- Blue: `#6a9bcc` - Secondary accent +- Green: `#788c5d` - Tertiary accent + +### Typography + +- **Headings**: Poppins (with Arial fallback) +- **Body Text**: Lora (with Georgia fallback) +- **Note**: Fonts should be pre-installed in your environment for best results + +## Features + +### Smart Font Application + +- Applies Poppins font to headings (24pt and larger) +- Applies Lora font to body text +- Automatically falls back to Arial/Georgia if custom fonts unavailable +- Preserves readability across all systems + +### Text Styling + +- Headings (24pt+): Poppins font +- Body text: Lora font +- Smart color selection based on background +- Preserves text hierarchy and formatting + +### Shape and Accent Colors + +- Non-text shapes use accent colors +- Cycles through orange, blue, and green accents +- Maintains visual interest while staying on-brand + +## Technical Details + +### Font Management + +- Uses system-installed Poppins and Lora fonts when available +- Provides automatic fallback to Arial (headings) and Georgia (body) +- No font installation required - works with existing system fonts +- For best results, pre-install Poppins and Lora fonts in your environment + +### Color Application + +- Uses RGB color values for precise brand matching +- Applied via python-pptx's RGBColor class +- Maintains color fidelity across different systems diff --git a/packages/mosaic/framework/skills/canvas-design/LICENSE.txt b/packages/mosaic/framework/skills/canvas-design/LICENSE.txt new file mode 100644 index 00000000..7a4a3ea2 --- /dev/null +++ b/packages/mosaic/framework/skills/canvas-design/LICENSE.txt @@ -0,0 +1,202 @@ + + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright [yyyy] [name of copyright owner] + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. \ No newline at end of file diff --git a/packages/mosaic/framework/skills/canvas-design/SKILL.md b/packages/mosaic/framework/skills/canvas-design/SKILL.md new file mode 100644 index 00000000..4c5c6302 --- /dev/null +++ b/packages/mosaic/framework/skills/canvas-design/SKILL.md @@ -0,0 +1,137 @@ +--- +name: canvas-design +description: Create beautiful visual art in .png and .pdf documents using design philosophy. You should use this skill when the user asks to create a poster, piece of art, design, or other static piece. Create original visual designs, never copying existing artists' work to avoid copyright violations. +license: Complete terms in LICENSE.txt +--- + +These are instructions for creating design philosophies - aesthetic movements that are then EXPRESSED VISUALLY. Output only .md files, .pdf files, and .png files. + +Complete this in two steps: + +1. Design Philosophy Creation (.md file) +2. Express by creating it on a canvas (.pdf file or .png file) + +First, undertake this task: + +## DESIGN PHILOSOPHY CREATION + +To begin, create a VISUAL PHILOSOPHY (not layouts or templates) that will be interpreted through: + +- Form, space, color, composition +- Images, graphics, shapes, patterns +- Minimal text as visual accent + +### THE CRITICAL UNDERSTANDING + +- What is received: Some subtle input or instructions by the user that should be taken into account, but used as a foundation; it should not constrain creative freedom. +- What is created: A design philosophy/aesthetic movement. +- What happens next: Then, the same version receives the philosophy and EXPRESSES IT VISUALLY - creating artifacts that are 90% visual design, 10% essential text. + +Consider this approach: + +- Write a manifesto for an art movement +- The next phase involves making the artwork + +The philosophy must emphasize: Visual expression. Spatial communication. Artistic interpretation. Minimal words. + +### HOW TO GENERATE A VISUAL PHILOSOPHY + +**Name the movement** (1-2 words): "Brutalist Joy" / "Chromatic Silence" / "Metabolist Dreams" + +**Articulate the philosophy** (4-6 paragraphs - concise but complete): + +To capture the VISUAL essence, express how the philosophy manifests through: + +- Space and form +- Color and material +- Scale and rhythm +- Composition and balance +- Visual hierarchy + +**CRITICAL GUIDELINES:** + +- **Avoid redundancy**: Each design aspect should be mentioned once. Avoid repeating points about color theory, spatial relationships, or typographic principles unless adding new depth. +- **Emphasize craftsmanship REPEATEDLY**: The philosophy MUST stress multiple times that the final work should appear as though it took countless hours to create, was labored over with care, and comes from someone at the absolute top of their field. This framing is essential - repeat phrases like "meticulously crafted," "the product of deep expertise," "painstaking attention," "master-level execution." +- **Leave creative space**: Remain specific about the aesthetic direction, but concise enough that the next Claude has room to make interpretive choices also at a extremely high level of craftmanship. + +The philosophy must guide the next version to express ideas VISUALLY, not through text. Information lives in design, not paragraphs. + +### PHILOSOPHY EXAMPLES + +**"Concrete Poetry"** +Philosophy: Communication through monumental form and bold geometry. +Visual expression: Massive color blocks, sculptural typography (huge single words, tiny labels), Brutalist spatial divisions, Polish poster energy meets Le Corbusier. Ideas expressed through visual weight and spatial tension, not explanation. Text as rare, powerful gesture - never paragraphs, only essential words integrated into the visual architecture. Every element placed with the precision of a master craftsman. + +**"Chromatic Language"** +Philosophy: Color as the primary information system. +Visual expression: Geometric precision where color zones create meaning. Typography minimal - small sans-serif labels letting chromatic fields communicate. Think Josef Albers' interaction meets data visualization. Information encoded spatially and chromatically. Words only to anchor what color already shows. The result of painstaking chromatic calibration. + +**"Analog Meditation"** +Philosophy: Quiet visual contemplation through texture and breathing room. +Visual expression: Paper grain, ink bleeds, vast negative space. Photography and illustration dominate. Typography whispered (small, restrained, serving the visual). Japanese photobook aesthetic. Images breathe across pages. Text appears sparingly - short phrases, never explanatory blocks. Each composition balanced with the care of a meditation practice. + +**"Organic Systems"** +Philosophy: Natural clustering and modular growth patterns. +Visual expression: Rounded forms, organic arrangements, color from nature through architecture. Information shown through visual diagrams, spatial relationships, iconography. Text only for key labels floating in space. The composition tells the story through expert spatial orchestration. + +**"Geometric Silence"** +Philosophy: Pure order and restraint. +Visual expression: Grid-based precision, bold photography or stark graphics, dramatic negative space. Typography precise but minimal - small essential text, large quiet zones. Swiss formalism meets Brutalist material honesty. Structure communicates, not words. Every alignment the work of countless refinements. + +_These are condensed examples. The actual design philosophy should be 4-6 substantial paragraphs._ + +### ESSENTIAL PRINCIPLES + +- **VISUAL PHILOSOPHY**: Create an aesthetic worldview to be expressed through design +- **MINIMAL TEXT**: Always emphasize that text is sparse, essential-only, integrated as visual element - never lengthy +- **SPATIAL EXPRESSION**: Ideas communicate through space, form, color, composition - not paragraphs +- **ARTISTIC FREEDOM**: The next Claude interprets the philosophy visually - provide creative room +- **PURE DESIGN**: This is about making ART OBJECTS, not documents with decoration +- **EXPERT CRAFTSMANSHIP**: Repeatedly emphasize the final work must look meticulously crafted, labored over with care, the product of countless hours by someone at the top of their field + +**The design philosophy should be 4-6 paragraphs long.** Fill it with poetic design philosophy that brings together the core vision. Avoid repeating the same points. Keep the design philosophy generic without mentioning the intention of the art, as if it can be used wherever. Output the design philosophy as a .md file. + +--- + +## DEDUCING THE SUBTLE REFERENCE + +**CRITICAL STEP**: Before creating the canvas, identify the subtle conceptual thread from the original request. + +**THE ESSENTIAL PRINCIPLE**: +The topic is a **subtle, niche reference embedded within the art itself** - not always literal, always sophisticated. Someone familiar with the subject should feel it intuitively, while others simply experience a masterful abstract composition. The design philosophy provides the aesthetic language. The deduced topic provides the soul - the quiet conceptual DNA woven invisibly into form, color, and composition. + +This is **VERY IMPORTANT**: The reference must be refined so it enhances the work's depth without announcing itself. Think like a jazz musician quoting another song - only those who know will catch it, but everyone appreciates the music. + +--- + +## CANVAS CREATION + +With both the philosophy and the conceptual framework established, express it on a canvas. Take a moment to gather thoughts and clear the mind. Use the design philosophy created and the instructions below to craft a masterpiece, embodying all aspects of the philosophy with expert craftsmanship. + +**IMPORTANT**: For any type of content, even if the user requests something for a movie/game/book, the approach should still be sophisticated. Never lose sight of the idea that this should be art, not something that's cartoony or amateur. + +To create museum or magazine quality work, use the design philosophy as the foundation. Create one single page, highly visual, design-forward PDF or PNG output (unless asked for more pages). Generally use repeating patterns and perfect shapes. Treat the abstract philosophical design as if it were a scientific bible, borrowing the visual language of systematic observation—dense accumulation of marks, repeated elements, or layered patterns that build meaning through patient repetition and reward sustained viewing. Add sparse, clinical typography and systematic reference markers that suggest this could be a diagram from an imaginary discipline, treating the invisible subject with the same reverence typically reserved for documenting observable phenomena. Anchor the piece with simple phrase(s) or details positioned subtly, using a limited color palette that feels intentional and cohesive. Embrace the paradox of using analytical visual language to express ideas about human experience: the result should feel like an artifact that proves something ephemeral can be studied, mapped, and understood through careful attention. This is true art. + +**Text as a contextual element**: Text is always minimal and visual-first, but let context guide whether that means whisper-quiet labels or bold typographic gestures. A punk venue poster might have larger, more aggressive type than a minimalist ceramics studio identity. Most of the time, font should be thin. All use of fonts must be design-forward and prioritize visual communication. Regardless of text scale, nothing falls off the page and nothing overlaps. Every element must be contained within the canvas boundaries with proper margins. Check carefully that all text, graphics, and visual elements have breathing room and clear separation. This is non-negotiable for professional execution. **IMPORTANT: Use different fonts if writing text. Search the `./canvas-fonts` directory. Regardless of approach, sophistication is non-negotiable.** + +Download and use whatever fonts are needed to make this a reality. Get creative by making the typography actually part of the art itself -- if the art is abstract, bring the font onto the canvas, not typeset digitally. + +To push boundaries, follow design instinct/intuition while using the philosophy as a guiding principle. Embrace ultimate design freedom and choice. Push aesthetics and design to the frontier. + +**CRITICAL**: To achieve human-crafted quality (not AI-generated), create work that looks like it took countless hours. Make it appear as though someone at the absolute top of their field labored over every detail with painstaking care. Ensure the composition, spacing, color choices, typography - everything screams expert-level craftsmanship. Double-check that nothing overlaps, formatting is flawless, every detail perfect. Create something that could be shown to people to prove expertise and rank as undeniably impressive. + +Output the final result as a single, downloadable .pdf or .png file, alongside the design philosophy used as a .md file. + +--- + +## FINAL STEP + +**IMPORTANT**: The user ALREADY said "It isn't perfect enough. It must be pristine, a masterpiece if craftsmanship, as if it were about to be displayed in a museum." + +**CRITICAL**: To refine the work, avoid adding more graphics; instead refine what has been created and make it extremely crisp, respecting the design philosophy and the principles of minimalism entirely. Rather than adding a fun filter or refactoring a font, consider how to make the existing composition more cohesive with the art. If the instinct is to call a new function or draw a new shape, STOP and instead ask: "How can I make what's already here more of a piece of art?" + +Take a second pass. Go back to the code and refine/polish further to make this a philosophically designed masterpiece. + +## MULTI-PAGE OPTION + +To create additional pages when requested, create more creative pages along the same lines as the design philosophy but distinctly different as well. Bundle those pages in the same .pdf or many .pngs. Treat the first page as just a single page in a whole coffee table book waiting to be filled. Make the next pages unique twists and memories of the original. Have them almost tell a story in a very tasteful way. Exercise full creative freedom. diff --git a/packages/mosaic/framework/skills/canvas-design/canvas-fonts/ArsenalSC-OFL.txt b/packages/mosaic/framework/skills/canvas-design/canvas-fonts/ArsenalSC-OFL.txt new file mode 100644 index 00000000..1dad6ca6 --- /dev/null +++ b/packages/mosaic/framework/skills/canvas-design/canvas-fonts/ArsenalSC-OFL.txt @@ -0,0 +1,93 @@ +Copyright 2012 The Arsenal Project Authors (andrij.design@gmail.com) + +This Font Software is licensed under the SIL Open Font License, Version 1.1. +This license is copied below, and is also available with a FAQ at: +https://openfontlicense.org + + +----------------------------------------------------------- +SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007 +----------------------------------------------------------- + +PREAMBLE +The goals of the Open Font License (OFL) are to stimulate worldwide +development of collaborative font projects, to support the font creation +efforts of academic and linguistic communities, and to provide a free and +open framework in which fonts may be shared and improved in partnership +with others. + +The OFL allows the licensed fonts to be used, studied, modified and +redistributed freely as long as they are not sold by themselves. The +fonts, including any derivative works, can be bundled, embedded, +redistributed and/or sold with any software provided that any reserved +names are not used by derivative works. The fonts and derivatives, +however, cannot be released under any other type of license. The +requirement for fonts to remain under this license does not apply +to any document created using the fonts or their derivatives. + +DEFINITIONS +"Font Software" refers to the set of files released by the Copyright +Holder(s) under this license and clearly marked as such. This may +include source files, build scripts and documentation. + +"Reserved Font Name" refers to any names specified as such after the +copyright statement(s). + +"Original Version" refers to the collection of Font Software components as +distributed by the Copyright Holder(s). + +"Modified Version" refers to any derivative made by adding to, deleting, +or substituting -- in part or in whole -- any of the components of the +Original Version, by changing formats or by porting the Font Software to a +new environment. + +"Author" refers to any designer, engineer, programmer, technical +writer or other person who contributed to the Font Software. + +PERMISSION & CONDITIONS +Permission is hereby granted, free of charge, to any person obtaining +a copy of the Font Software, to use, study, copy, merge, embed, modify, +redistribute, and sell modified and unmodified copies of the Font +Software, subject to the following conditions: + +1) Neither the Font Software nor any of its individual components, +in Original or Modified Versions, may be sold by itself. + +2) Original or Modified Versions of the Font Software may be bundled, +redistributed and/or sold with any software, provided that each copy +contains the above copyright notice and this license. These can be +included either as stand-alone text files, human-readable headers or +in the appropriate machine-readable metadata fields within text or +binary files as long as those fields can be easily viewed by the user. + +3) No Modified Version of the Font Software may use the Reserved Font +Name(s) unless explicit written permission is granted by the corresponding +Copyright Holder. This restriction only applies to the primary font name as +presented to the users. + +4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font +Software shall not be used to promote, endorse or advertise any +Modified Version, except to acknowledge the contribution(s) of the +Copyright Holder(s) and the Author(s) or with their explicit written +permission. + +5) The Font Software, modified or unmodified, in part or in whole, +must be distributed entirely under this license, and must not be +distributed under any other license. The requirement for fonts to +remain under this license does not apply to any document created +using the Font Software. + +TERMINATION +This license becomes null and void if any of the above conditions are +not met. + +DISCLAIMER +THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT +OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE +COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL +DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM +OTHER DEALINGS IN THE FONT SOFTWARE. diff --git a/packages/mosaic/framework/skills/canvas-design/canvas-fonts/ArsenalSC-Regular.ttf b/packages/mosaic/framework/skills/canvas-design/canvas-fonts/ArsenalSC-Regular.ttf new file mode 100644 index 00000000..fe5409b2 Binary files /dev/null and b/packages/mosaic/framework/skills/canvas-design/canvas-fonts/ArsenalSC-Regular.ttf differ diff --git a/packages/mosaic/framework/skills/canvas-design/canvas-fonts/BigShoulders-Bold.ttf b/packages/mosaic/framework/skills/canvas-design/canvas-fonts/BigShoulders-Bold.ttf new file mode 100644 index 00000000..fc5f8fdd Binary files /dev/null and b/packages/mosaic/framework/skills/canvas-design/canvas-fonts/BigShoulders-Bold.ttf differ diff --git a/packages/mosaic/framework/skills/canvas-design/canvas-fonts/BigShoulders-OFL.txt b/packages/mosaic/framework/skills/canvas-design/canvas-fonts/BigShoulders-OFL.txt new file mode 100644 index 00000000..b220280e --- /dev/null +++ b/packages/mosaic/framework/skills/canvas-design/canvas-fonts/BigShoulders-OFL.txt @@ -0,0 +1,93 @@ +Copyright 2019 The Big Shoulders Project Authors (https://github.com/xotypeco/big_shoulders) + +This Font Software is licensed under the SIL Open Font License, Version 1.1. +This license is copied below, and is also available with a FAQ at: +https://openfontlicense.org + + +----------------------------------------------------------- +SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007 +----------------------------------------------------------- + +PREAMBLE +The goals of the Open Font License (OFL) are to stimulate worldwide +development of collaborative font projects, to support the font creation +efforts of academic and linguistic communities, and to provide a free and +open framework in which fonts may be shared and improved in partnership +with others. + +The OFL allows the licensed fonts to be used, studied, modified and +redistributed freely as long as they are not sold by themselves. The +fonts, including any derivative works, can be bundled, embedded, +redistributed and/or sold with any software provided that any reserved +names are not used by derivative works. The fonts and derivatives, +however, cannot be released under any other type of license. The +requirement for fonts to remain under this license does not apply +to any document created using the fonts or their derivatives. + +DEFINITIONS +"Font Software" refers to the set of files released by the Copyright +Holder(s) under this license and clearly marked as such. This may +include source files, build scripts and documentation. + +"Reserved Font Name" refers to any names specified as such after the +copyright statement(s). + +"Original Version" refers to the collection of Font Software components as +distributed by the Copyright Holder(s). + +"Modified Version" refers to any derivative made by adding to, deleting, +or substituting -- in part or in whole -- any of the components of the +Original Version, by changing formats or by porting the Font Software to a +new environment. + +"Author" refers to any designer, engineer, programmer, technical +writer or other person who contributed to the Font Software. + +PERMISSION & CONDITIONS +Permission is hereby granted, free of charge, to any person obtaining +a copy of the Font Software, to use, study, copy, merge, embed, modify, +redistribute, and sell modified and unmodified copies of the Font +Software, subject to the following conditions: + +1) Neither the Font Software nor any of its individual components, +in Original or Modified Versions, may be sold by itself. + +2) Original or Modified Versions of the Font Software may be bundled, +redistributed and/or sold with any software, provided that each copy +contains the above copyright notice and this license. These can be +included either as stand-alone text files, human-readable headers or +in the appropriate machine-readable metadata fields within text or +binary files as long as those fields can be easily viewed by the user. + +3) No Modified Version of the Font Software may use the Reserved Font +Name(s) unless explicit written permission is granted by the corresponding +Copyright Holder. This restriction only applies to the primary font name as +presented to the users. + +4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font +Software shall not be used to promote, endorse or advertise any +Modified Version, except to acknowledge the contribution(s) of the +Copyright Holder(s) and the Author(s) or with their explicit written +permission. + +5) The Font Software, modified or unmodified, in part or in whole, +must be distributed entirely under this license, and must not be +distributed under any other license. The requirement for fonts to +remain under this license does not apply to any document created +using the Font Software. + +TERMINATION +This license becomes null and void if any of the above conditions are +not met. + +DISCLAIMER +THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT +OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE +COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL +DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM +OTHER DEALINGS IN THE FONT SOFTWARE. diff --git a/packages/mosaic/framework/skills/canvas-design/canvas-fonts/BigShoulders-Regular.ttf b/packages/mosaic/framework/skills/canvas-design/canvas-fonts/BigShoulders-Regular.ttf new file mode 100644 index 00000000..de8308ce Binary files /dev/null and b/packages/mosaic/framework/skills/canvas-design/canvas-fonts/BigShoulders-Regular.ttf differ diff --git a/packages/mosaic/framework/skills/canvas-design/canvas-fonts/Boldonse-OFL.txt b/packages/mosaic/framework/skills/canvas-design/canvas-fonts/Boldonse-OFL.txt new file mode 100644 index 00000000..1890cb1c --- /dev/null +++ b/packages/mosaic/framework/skills/canvas-design/canvas-fonts/Boldonse-OFL.txt @@ -0,0 +1,93 @@ +Copyright 2024 The Boldonse Project Authors (https://github.com/googlefonts/boldonse) + +This Font Software is licensed under the SIL Open Font License, Version 1.1. +This license is copied below, and is also available with a FAQ at: +https://openfontlicense.org + + +----------------------------------------------------------- +SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007 +----------------------------------------------------------- + +PREAMBLE +The goals of the Open Font License (OFL) are to stimulate worldwide +development of collaborative font projects, to support the font creation +efforts of academic and linguistic communities, and to provide a free and +open framework in which fonts may be shared and improved in partnership +with others. + +The OFL allows the licensed fonts to be used, studied, modified and +redistributed freely as long as they are not sold by themselves. The +fonts, including any derivative works, can be bundled, embedded, +redistributed and/or sold with any software provided that any reserved +names are not used by derivative works. The fonts and derivatives, +however, cannot be released under any other type of license. The +requirement for fonts to remain under this license does not apply +to any document created using the fonts or their derivatives. + +DEFINITIONS +"Font Software" refers to the set of files released by the Copyright +Holder(s) under this license and clearly marked as such. This may +include source files, build scripts and documentation. + +"Reserved Font Name" refers to any names specified as such after the +copyright statement(s). + +"Original Version" refers to the collection of Font Software components as +distributed by the Copyright Holder(s). + +"Modified Version" refers to any derivative made by adding to, deleting, +or substituting -- in part or in whole -- any of the components of the +Original Version, by changing formats or by porting the Font Software to a +new environment. + +"Author" refers to any designer, engineer, programmer, technical +writer or other person who contributed to the Font Software. + +PERMISSION & CONDITIONS +Permission is hereby granted, free of charge, to any person obtaining +a copy of the Font Software, to use, study, copy, merge, embed, modify, +redistribute, and sell modified and unmodified copies of the Font +Software, subject to the following conditions: + +1) Neither the Font Software nor any of its individual components, +in Original or Modified Versions, may be sold by itself. + +2) Original or Modified Versions of the Font Software may be bundled, +redistributed and/or sold with any software, provided that each copy +contains the above copyright notice and this license. These can be +included either as stand-alone text files, human-readable headers or +in the appropriate machine-readable metadata fields within text or +binary files as long as those fields can be easily viewed by the user. + +3) No Modified Version of the Font Software may use the Reserved Font +Name(s) unless explicit written permission is granted by the corresponding +Copyright Holder. This restriction only applies to the primary font name as +presented to the users. + +4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font +Software shall not be used to promote, endorse or advertise any +Modified Version, except to acknowledge the contribution(s) of the +Copyright Holder(s) and the Author(s) or with their explicit written +permission. + +5) The Font Software, modified or unmodified, in part or in whole, +must be distributed entirely under this license, and must not be +distributed under any other license. The requirement for fonts to +remain under this license does not apply to any document created +using the Font Software. + +TERMINATION +This license becomes null and void if any of the above conditions are +not met. + +DISCLAIMER +THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT +OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE +COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL +DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM +OTHER DEALINGS IN THE FONT SOFTWARE. diff --git a/packages/mosaic/framework/skills/canvas-design/canvas-fonts/Boldonse-Regular.ttf b/packages/mosaic/framework/skills/canvas-design/canvas-fonts/Boldonse-Regular.ttf new file mode 100644 index 00000000..43fa30af Binary files /dev/null and b/packages/mosaic/framework/skills/canvas-design/canvas-fonts/Boldonse-Regular.ttf differ diff --git a/packages/mosaic/framework/skills/canvas-design/canvas-fonts/BricolageGrotesque-Bold.ttf b/packages/mosaic/framework/skills/canvas-design/canvas-fonts/BricolageGrotesque-Bold.ttf new file mode 100644 index 00000000..f3b1deda Binary files /dev/null and b/packages/mosaic/framework/skills/canvas-design/canvas-fonts/BricolageGrotesque-Bold.ttf differ diff --git a/packages/mosaic/framework/skills/canvas-design/canvas-fonts/BricolageGrotesque-OFL.txt b/packages/mosaic/framework/skills/canvas-design/canvas-fonts/BricolageGrotesque-OFL.txt new file mode 100644 index 00000000..fc2b2167 --- /dev/null +++ b/packages/mosaic/framework/skills/canvas-design/canvas-fonts/BricolageGrotesque-OFL.txt @@ -0,0 +1,93 @@ +Copyright 2022 The Bricolage Grotesque Project Authors (https://github.com/ateliertriay/bricolage) + +This Font Software is licensed under the SIL Open Font License, Version 1.1. +This license is copied below, and is also available with a FAQ at: +https://openfontlicense.org + + +----------------------------------------------------------- +SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007 +----------------------------------------------------------- + +PREAMBLE +The goals of the Open Font License (OFL) are to stimulate worldwide +development of collaborative font projects, to support the font creation +efforts of academic and linguistic communities, and to provide a free and +open framework in which fonts may be shared and improved in partnership +with others. + +The OFL allows the licensed fonts to be used, studied, modified and +redistributed freely as long as they are not sold by themselves. The +fonts, including any derivative works, can be bundled, embedded, +redistributed and/or sold with any software provided that any reserved +names are not used by derivative works. The fonts and derivatives, +however, cannot be released under any other type of license. The +requirement for fonts to remain under this license does not apply +to any document created using the fonts or their derivatives. + +DEFINITIONS +"Font Software" refers to the set of files released by the Copyright +Holder(s) under this license and clearly marked as such. This may +include source files, build scripts and documentation. + +"Reserved Font Name" refers to any names specified as such after the +copyright statement(s). + +"Original Version" refers to the collection of Font Software components as +distributed by the Copyright Holder(s). + +"Modified Version" refers to any derivative made by adding to, deleting, +or substituting -- in part or in whole -- any of the components of the +Original Version, by changing formats or by porting the Font Software to a +new environment. + +"Author" refers to any designer, engineer, programmer, technical +writer or other person who contributed to the Font Software. + +PERMISSION & CONDITIONS +Permission is hereby granted, free of charge, to any person obtaining +a copy of the Font Software, to use, study, copy, merge, embed, modify, +redistribute, and sell modified and unmodified copies of the Font +Software, subject to the following conditions: + +1) Neither the Font Software nor any of its individual components, +in Original or Modified Versions, may be sold by itself. + +2) Original or Modified Versions of the Font Software may be bundled, +redistributed and/or sold with any software, provided that each copy +contains the above copyright notice and this license. These can be +included either as stand-alone text files, human-readable headers or +in the appropriate machine-readable metadata fields within text or +binary files as long as those fields can be easily viewed by the user. + +3) No Modified Version of the Font Software may use the Reserved Font +Name(s) unless explicit written permission is granted by the corresponding +Copyright Holder. This restriction only applies to the primary font name as +presented to the users. + +4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font +Software shall not be used to promote, endorse or advertise any +Modified Version, except to acknowledge the contribution(s) of the +Copyright Holder(s) and the Author(s) or with their explicit written +permission. + +5) The Font Software, modified or unmodified, in part or in whole, +must be distributed entirely under this license, and must not be +distributed under any other license. The requirement for fonts to +remain under this license does not apply to any document created +using the Font Software. + +TERMINATION +This license becomes null and void if any of the above conditions are +not met. + +DISCLAIMER +THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT +OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE +COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL +DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM +OTHER DEALINGS IN THE FONT SOFTWARE. diff --git a/packages/mosaic/framework/skills/canvas-design/canvas-fonts/BricolageGrotesque-Regular.ttf b/packages/mosaic/framework/skills/canvas-design/canvas-fonts/BricolageGrotesque-Regular.ttf new file mode 100644 index 00000000..0674ae3e Binary files /dev/null and b/packages/mosaic/framework/skills/canvas-design/canvas-fonts/BricolageGrotesque-Regular.ttf differ diff --git a/packages/mosaic/framework/skills/canvas-design/canvas-fonts/CrimsonPro-Bold.ttf b/packages/mosaic/framework/skills/canvas-design/canvas-fonts/CrimsonPro-Bold.ttf new file mode 100644 index 00000000..58730fb4 Binary files /dev/null and b/packages/mosaic/framework/skills/canvas-design/canvas-fonts/CrimsonPro-Bold.ttf differ diff --git a/packages/mosaic/framework/skills/canvas-design/canvas-fonts/CrimsonPro-Italic.ttf b/packages/mosaic/framework/skills/canvas-design/canvas-fonts/CrimsonPro-Italic.ttf new file mode 100644 index 00000000..786a1bd6 Binary files /dev/null and b/packages/mosaic/framework/skills/canvas-design/canvas-fonts/CrimsonPro-Italic.ttf differ diff --git a/packages/mosaic/framework/skills/canvas-design/canvas-fonts/CrimsonPro-OFL.txt b/packages/mosaic/framework/skills/canvas-design/canvas-fonts/CrimsonPro-OFL.txt new file mode 100644 index 00000000..f976fdc9 --- /dev/null +++ b/packages/mosaic/framework/skills/canvas-design/canvas-fonts/CrimsonPro-OFL.txt @@ -0,0 +1,93 @@ +Copyright 2018 The Crimson Pro Project Authors (https://github.com/Fonthausen/CrimsonPro) + +This Font Software is licensed under the SIL Open Font License, Version 1.1. +This license is copied below, and is also available with a FAQ at: +https://openfontlicense.org + + +----------------------------------------------------------- +SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007 +----------------------------------------------------------- + +PREAMBLE +The goals of the Open Font License (OFL) are to stimulate worldwide +development of collaborative font projects, to support the font creation +efforts of academic and linguistic communities, and to provide a free and +open framework in which fonts may be shared and improved in partnership +with others. + +The OFL allows the licensed fonts to be used, studied, modified and +redistributed freely as long as they are not sold by themselves. The +fonts, including any derivative works, can be bundled, embedded, +redistributed and/or sold with any software provided that any reserved +names are not used by derivative works. The fonts and derivatives, +however, cannot be released under any other type of license. The +requirement for fonts to remain under this license does not apply +to any document created using the fonts or their derivatives. + +DEFINITIONS +"Font Software" refers to the set of files released by the Copyright +Holder(s) under this license and clearly marked as such. This may +include source files, build scripts and documentation. + +"Reserved Font Name" refers to any names specified as such after the +copyright statement(s). + +"Original Version" refers to the collection of Font Software components as +distributed by the Copyright Holder(s). + +"Modified Version" refers to any derivative made by adding to, deleting, +or substituting -- in part or in whole -- any of the components of the +Original Version, by changing formats or by porting the Font Software to a +new environment. + +"Author" refers to any designer, engineer, programmer, technical +writer or other person who contributed to the Font Software. + +PERMISSION & CONDITIONS +Permission is hereby granted, free of charge, to any person obtaining +a copy of the Font Software, to use, study, copy, merge, embed, modify, +redistribute, and sell modified and unmodified copies of the Font +Software, subject to the following conditions: + +1) Neither the Font Software nor any of its individual components, +in Original or Modified Versions, may be sold by itself. + +2) Original or Modified Versions of the Font Software may be bundled, +redistributed and/or sold with any software, provided that each copy +contains the above copyright notice and this license. These can be +included either as stand-alone text files, human-readable headers or +in the appropriate machine-readable metadata fields within text or +binary files as long as those fields can be easily viewed by the user. + +3) No Modified Version of the Font Software may use the Reserved Font +Name(s) unless explicit written permission is granted by the corresponding +Copyright Holder. This restriction only applies to the primary font name as +presented to the users. + +4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font +Software shall not be used to promote, endorse or advertise any +Modified Version, except to acknowledge the contribution(s) of the +Copyright Holder(s) and the Author(s) or with their explicit written +permission. + +5) The Font Software, modified or unmodified, in part or in whole, +must be distributed entirely under this license, and must not be +distributed under any other license. The requirement for fonts to +remain under this license does not apply to any document created +using the Font Software. + +TERMINATION +This license becomes null and void if any of the above conditions are +not met. + +DISCLAIMER +THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT +OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE +COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL +DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM +OTHER DEALINGS IN THE FONT SOFTWARE. diff --git a/packages/mosaic/framework/skills/canvas-design/canvas-fonts/CrimsonPro-Regular.ttf b/packages/mosaic/framework/skills/canvas-design/canvas-fonts/CrimsonPro-Regular.ttf new file mode 100644 index 00000000..f5666b9b Binary files /dev/null and b/packages/mosaic/framework/skills/canvas-design/canvas-fonts/CrimsonPro-Regular.ttf differ diff --git a/packages/mosaic/framework/skills/canvas-design/canvas-fonts/DMMono-OFL.txt b/packages/mosaic/framework/skills/canvas-design/canvas-fonts/DMMono-OFL.txt new file mode 100644 index 00000000..5b17f0c6 --- /dev/null +++ b/packages/mosaic/framework/skills/canvas-design/canvas-fonts/DMMono-OFL.txt @@ -0,0 +1,93 @@ +Copyright 2020 The DM Mono Project Authors (https://www.github.com/googlefonts/dm-mono) + +This Font Software is licensed under the SIL Open Font License, Version 1.1. +This license is copied below, and is also available with a FAQ at: +https://openfontlicense.org + + +----------------------------------------------------------- +SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007 +----------------------------------------------------------- + +PREAMBLE +The goals of the Open Font License (OFL) are to stimulate worldwide +development of collaborative font projects, to support the font creation +efforts of academic and linguistic communities, and to provide a free and +open framework in which fonts may be shared and improved in partnership +with others. + +The OFL allows the licensed fonts to be used, studied, modified and +redistributed freely as long as they are not sold by themselves. The +fonts, including any derivative works, can be bundled, embedded, +redistributed and/or sold with any software provided that any reserved +names are not used by derivative works. The fonts and derivatives, +however, cannot be released under any other type of license. The +requirement for fonts to remain under this license does not apply +to any document created using the fonts or their derivatives. + +DEFINITIONS +"Font Software" refers to the set of files released by the Copyright +Holder(s) under this license and clearly marked as such. This may +include source files, build scripts and documentation. + +"Reserved Font Name" refers to any names specified as such after the +copyright statement(s). + +"Original Version" refers to the collection of Font Software components as +distributed by the Copyright Holder(s). + +"Modified Version" refers to any derivative made by adding to, deleting, +or substituting -- in part or in whole -- any of the components of the +Original Version, by changing formats or by porting the Font Software to a +new environment. + +"Author" refers to any designer, engineer, programmer, technical +writer or other person who contributed to the Font Software. + +PERMISSION & CONDITIONS +Permission is hereby granted, free of charge, to any person obtaining +a copy of the Font Software, to use, study, copy, merge, embed, modify, +redistribute, and sell modified and unmodified copies of the Font +Software, subject to the following conditions: + +1) Neither the Font Software nor any of its individual components, +in Original or Modified Versions, may be sold by itself. + +2) Original or Modified Versions of the Font Software may be bundled, +redistributed and/or sold with any software, provided that each copy +contains the above copyright notice and this license. These can be +included either as stand-alone text files, human-readable headers or +in the appropriate machine-readable metadata fields within text or +binary files as long as those fields can be easily viewed by the user. + +3) No Modified Version of the Font Software may use the Reserved Font +Name(s) unless explicit written permission is granted by the corresponding +Copyright Holder. This restriction only applies to the primary font name as +presented to the users. + +4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font +Software shall not be used to promote, endorse or advertise any +Modified Version, except to acknowledge the contribution(s) of the +Copyright Holder(s) and the Author(s) or with their explicit written +permission. + +5) The Font Software, modified or unmodified, in part or in whole, +must be distributed entirely under this license, and must not be +distributed under any other license. The requirement for fonts to +remain under this license does not apply to any document created +using the Font Software. + +TERMINATION +This license becomes null and void if any of the above conditions are +not met. + +DISCLAIMER +THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT +OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE +COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL +DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM +OTHER DEALINGS IN THE FONT SOFTWARE. diff --git a/packages/mosaic/framework/skills/canvas-design/canvas-fonts/DMMono-Regular.ttf b/packages/mosaic/framework/skills/canvas-design/canvas-fonts/DMMono-Regular.ttf new file mode 100644 index 00000000..7efe813d Binary files /dev/null and b/packages/mosaic/framework/skills/canvas-design/canvas-fonts/DMMono-Regular.ttf differ diff --git a/packages/mosaic/framework/skills/canvas-design/canvas-fonts/EricaOne-OFL.txt b/packages/mosaic/framework/skills/canvas-design/canvas-fonts/EricaOne-OFL.txt new file mode 100644 index 00000000..490d0120 --- /dev/null +++ b/packages/mosaic/framework/skills/canvas-design/canvas-fonts/EricaOne-OFL.txt @@ -0,0 +1,94 @@ +Copyright (c) 2011 by LatinoType Limitada (luciano@latinotype.com), +with Reserved Font Names "Erica One" + +This Font Software is licensed under the SIL Open Font License, Version 1.1. +This license is copied below, and is also available with a FAQ at: +https://openfontlicense.org + + +----------------------------------------------------------- +SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007 +----------------------------------------------------------- + +PREAMBLE +The goals of the Open Font License (OFL) are to stimulate worldwide +development of collaborative font projects, to support the font creation +efforts of academic and linguistic communities, and to provide a free and +open framework in which fonts may be shared and improved in partnership +with others. + +The OFL allows the licensed fonts to be used, studied, modified and +redistributed freely as long as they are not sold by themselves. The +fonts, including any derivative works, can be bundled, embedded, +redistributed and/or sold with any software provided that any reserved +names are not used by derivative works. The fonts and derivatives, +however, cannot be released under any other type of license. The +requirement for fonts to remain under this license does not apply +to any document created using the fonts or their derivatives. + +DEFINITIONS +"Font Software" refers to the set of files released by the Copyright +Holder(s) under this license and clearly marked as such. This may +include source files, build scripts and documentation. + +"Reserved Font Name" refers to any names specified as such after the +copyright statement(s). + +"Original Version" refers to the collection of Font Software components as +distributed by the Copyright Holder(s). + +"Modified Version" refers to any derivative made by adding to, deleting, +or substituting -- in part or in whole -- any of the components of the +Original Version, by changing formats or by porting the Font Software to a +new environment. + +"Author" refers to any designer, engineer, programmer, technical +writer or other person who contributed to the Font Software. + +PERMISSION & CONDITIONS +Permission is hereby granted, free of charge, to any person obtaining +a copy of the Font Software, to use, study, copy, merge, embed, modify, +redistribute, and sell modified and unmodified copies of the Font +Software, subject to the following conditions: + +1) Neither the Font Software nor any of its individual components, +in Original or Modified Versions, may be sold by itself. + +2) Original or Modified Versions of the Font Software may be bundled, +redistributed and/or sold with any software, provided that each copy +contains the above copyright notice and this license. These can be +included either as stand-alone text files, human-readable headers or +in the appropriate machine-readable metadata fields within text or +binary files as long as those fields can be easily viewed by the user. + +3) No Modified Version of the Font Software may use the Reserved Font +Name(s) unless explicit written permission is granted by the corresponding +Copyright Holder. This restriction only applies to the primary font name as +presented to the users. + +4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font +Software shall not be used to promote, endorse or advertise any +Modified Version, except to acknowledge the contribution(s) of the +Copyright Holder(s) and the Author(s) or with their explicit written +permission. + +5) The Font Software, modified or unmodified, in part or in whole, +must be distributed entirely under this license, and must not be +distributed under any other license. The requirement for fonts to +remain under this license does not apply to any document created +using the Font Software. + +TERMINATION +This license becomes null and void if any of the above conditions are +not met. + +DISCLAIMER +THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT +OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE +COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL +DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM +OTHER DEALINGS IN THE FONT SOFTWARE. diff --git a/packages/mosaic/framework/skills/canvas-design/canvas-fonts/EricaOne-Regular.ttf b/packages/mosaic/framework/skills/canvas-design/canvas-fonts/EricaOne-Regular.ttf new file mode 100644 index 00000000..8bd91d11 Binary files /dev/null and b/packages/mosaic/framework/skills/canvas-design/canvas-fonts/EricaOne-Regular.ttf differ diff --git a/packages/mosaic/framework/skills/canvas-design/canvas-fonts/GeistMono-Bold.ttf b/packages/mosaic/framework/skills/canvas-design/canvas-fonts/GeistMono-Bold.ttf new file mode 100644 index 00000000..736ff7c3 Binary files /dev/null and b/packages/mosaic/framework/skills/canvas-design/canvas-fonts/GeistMono-Bold.ttf differ diff --git a/packages/mosaic/framework/skills/canvas-design/canvas-fonts/GeistMono-OFL.txt b/packages/mosaic/framework/skills/canvas-design/canvas-fonts/GeistMono-OFL.txt new file mode 100644 index 00000000..679a685a --- /dev/null +++ b/packages/mosaic/framework/skills/canvas-design/canvas-fonts/GeistMono-OFL.txt @@ -0,0 +1,93 @@ +Copyright 2024 The Geist Project Authors (https://github.com/vercel/geist-font.git) + +This Font Software is licensed under the SIL Open Font License, Version 1.1. +This license is copied below, and is also available with a FAQ at: +https://openfontlicense.org + + +----------------------------------------------------------- +SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007 +----------------------------------------------------------- + +PREAMBLE +The goals of the Open Font License (OFL) are to stimulate worldwide +development of collaborative font projects, to support the font creation +efforts of academic and linguistic communities, and to provide a free and +open framework in which fonts may be shared and improved in partnership +with others. + +The OFL allows the licensed fonts to be used, studied, modified and +redistributed freely as long as they are not sold by themselves. The +fonts, including any derivative works, can be bundled, embedded, +redistributed and/or sold with any software provided that any reserved +names are not used by derivative works. The fonts and derivatives, +however, cannot be released under any other type of license. The +requirement for fonts to remain under this license does not apply +to any document created using the fonts or their derivatives. + +DEFINITIONS +"Font Software" refers to the set of files released by the Copyright +Holder(s) under this license and clearly marked as such. This may +include source files, build scripts and documentation. + +"Reserved Font Name" refers to any names specified as such after the +copyright statement(s). + +"Original Version" refers to the collection of Font Software components as +distributed by the Copyright Holder(s). + +"Modified Version" refers to any derivative made by adding to, deleting, +or substituting -- in part or in whole -- any of the components of the +Original Version, by changing formats or by porting the Font Software to a +new environment. + +"Author" refers to any designer, engineer, programmer, technical +writer or other person who contributed to the Font Software. + +PERMISSION & CONDITIONS +Permission is hereby granted, free of charge, to any person obtaining +a copy of the Font Software, to use, study, copy, merge, embed, modify, +redistribute, and sell modified and unmodified copies of the Font +Software, subject to the following conditions: + +1) Neither the Font Software nor any of its individual components, +in Original or Modified Versions, may be sold by itself. + +2) Original or Modified Versions of the Font Software may be bundled, +redistributed and/or sold with any software, provided that each copy +contains the above copyright notice and this license. These can be +included either as stand-alone text files, human-readable headers or +in the appropriate machine-readable metadata fields within text or +binary files as long as those fields can be easily viewed by the user. + +3) No Modified Version of the Font Software may use the Reserved Font +Name(s) unless explicit written permission is granted by the corresponding +Copyright Holder. This restriction only applies to the primary font name as +presented to the users. + +4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font +Software shall not be used to promote, endorse or advertise any +Modified Version, except to acknowledge the contribution(s) of the +Copyright Holder(s) and the Author(s) or with their explicit written +permission. + +5) The Font Software, modified or unmodified, in part or in whole, +must be distributed entirely under this license, and must not be +distributed under any other license. The requirement for fonts to +remain under this license does not apply to any document created +using the Font Software. + +TERMINATION +This license becomes null and void if any of the above conditions are +not met. + +DISCLAIMER +THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT +OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE +COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL +DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM +OTHER DEALINGS IN THE FONT SOFTWARE. diff --git a/packages/mosaic/framework/skills/canvas-design/canvas-fonts/GeistMono-Regular.ttf b/packages/mosaic/framework/skills/canvas-design/canvas-fonts/GeistMono-Regular.ttf new file mode 100644 index 00000000..1a30262a Binary files /dev/null and b/packages/mosaic/framework/skills/canvas-design/canvas-fonts/GeistMono-Regular.ttf differ diff --git a/packages/mosaic/framework/skills/canvas-design/canvas-fonts/Gloock-OFL.txt b/packages/mosaic/framework/skills/canvas-design/canvas-fonts/Gloock-OFL.txt new file mode 100644 index 00000000..363acd33 --- /dev/null +++ b/packages/mosaic/framework/skills/canvas-design/canvas-fonts/Gloock-OFL.txt @@ -0,0 +1,93 @@ +Copyright 2022 The Gloock Project Authors (https://github.com/duartp/gloock) + +This Font Software is licensed under the SIL Open Font License, Version 1.1. +This license is copied below, and is also available with a FAQ at: +https://openfontlicense.org + + +----------------------------------------------------------- +SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007 +----------------------------------------------------------- + +PREAMBLE +The goals of the Open Font License (OFL) are to stimulate worldwide +development of collaborative font projects, to support the font creation +efforts of academic and linguistic communities, and to provide a free and +open framework in which fonts may be shared and improved in partnership +with others. + +The OFL allows the licensed fonts to be used, studied, modified and +redistributed freely as long as they are not sold by themselves. The +fonts, including any derivative works, can be bundled, embedded, +redistributed and/or sold with any software provided that any reserved +names are not used by derivative works. The fonts and derivatives, +however, cannot be released under any other type of license. The +requirement for fonts to remain under this license does not apply +to any document created using the fonts or their derivatives. + +DEFINITIONS +"Font Software" refers to the set of files released by the Copyright +Holder(s) under this license and clearly marked as such. This may +include source files, build scripts and documentation. + +"Reserved Font Name" refers to any names specified as such after the +copyright statement(s). + +"Original Version" refers to the collection of Font Software components as +distributed by the Copyright Holder(s). + +"Modified Version" refers to any derivative made by adding to, deleting, +or substituting -- in part or in whole -- any of the components of the +Original Version, by changing formats or by porting the Font Software to a +new environment. + +"Author" refers to any designer, engineer, programmer, technical +writer or other person who contributed to the Font Software. + +PERMISSION & CONDITIONS +Permission is hereby granted, free of charge, to any person obtaining +a copy of the Font Software, to use, study, copy, merge, embed, modify, +redistribute, and sell modified and unmodified copies of the Font +Software, subject to the following conditions: + +1) Neither the Font Software nor any of its individual components, +in Original or Modified Versions, may be sold by itself. + +2) Original or Modified Versions of the Font Software may be bundled, +redistributed and/or sold with any software, provided that each copy +contains the above copyright notice and this license. These can be +included either as stand-alone text files, human-readable headers or +in the appropriate machine-readable metadata fields within text or +binary files as long as those fields can be easily viewed by the user. + +3) No Modified Version of the Font Software may use the Reserved Font +Name(s) unless explicit written permission is granted by the corresponding +Copyright Holder. This restriction only applies to the primary font name as +presented to the users. + +4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font +Software shall not be used to promote, endorse or advertise any +Modified Version, except to acknowledge the contribution(s) of the +Copyright Holder(s) and the Author(s) or with their explicit written +permission. + +5) The Font Software, modified or unmodified, in part or in whole, +must be distributed entirely under this license, and must not be +distributed under any other license. The requirement for fonts to +remain under this license does not apply to any document created +using the Font Software. + +TERMINATION +This license becomes null and void if any of the above conditions are +not met. + +DISCLAIMER +THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT +OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE +COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL +DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM +OTHER DEALINGS IN THE FONT SOFTWARE. diff --git a/packages/mosaic/framework/skills/canvas-design/canvas-fonts/Gloock-Regular.ttf b/packages/mosaic/framework/skills/canvas-design/canvas-fonts/Gloock-Regular.ttf new file mode 100644 index 00000000..3e58c4e4 Binary files /dev/null and b/packages/mosaic/framework/skills/canvas-design/canvas-fonts/Gloock-Regular.ttf differ diff --git a/packages/mosaic/framework/skills/canvas-design/canvas-fonts/IBMPlexMono-Bold.ttf b/packages/mosaic/framework/skills/canvas-design/canvas-fonts/IBMPlexMono-Bold.ttf new file mode 100644 index 00000000..247979ca Binary files /dev/null and b/packages/mosaic/framework/skills/canvas-design/canvas-fonts/IBMPlexMono-Bold.ttf differ diff --git a/packages/mosaic/framework/skills/canvas-design/canvas-fonts/IBMPlexMono-OFL.txt b/packages/mosaic/framework/skills/canvas-design/canvas-fonts/IBMPlexMono-OFL.txt new file mode 100644 index 00000000..e423b747 --- /dev/null +++ b/packages/mosaic/framework/skills/canvas-design/canvas-fonts/IBMPlexMono-OFL.txt @@ -0,0 +1,93 @@ +Copyright © 2017 IBM Corp. with Reserved Font Name "Plex" + +This Font Software is licensed under the SIL Open Font License, Version 1.1. +This license is copied below, and is also available with a FAQ at: +https://openfontlicense.org + + +----------------------------------------------------------- +SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007 +----------------------------------------------------------- + +PREAMBLE +The goals of the Open Font License (OFL) are to stimulate worldwide +development of collaborative font projects, to support the font creation +efforts of academic and linguistic communities, and to provide a free and +open framework in which fonts may be shared and improved in partnership +with others. + +The OFL allows the licensed fonts to be used, studied, modified and +redistributed freely as long as they are not sold by themselves. The +fonts, including any derivative works, can be bundled, embedded, +redistributed and/or sold with any software provided that any reserved +names are not used by derivative works. The fonts and derivatives, +however, cannot be released under any other type of license. The +requirement for fonts to remain under this license does not apply +to any document created using the fonts or their derivatives. + +DEFINITIONS +"Font Software" refers to the set of files released by the Copyright +Holder(s) under this license and clearly marked as such. This may +include source files, build scripts and documentation. + +"Reserved Font Name" refers to any names specified as such after the +copyright statement(s). + +"Original Version" refers to the collection of Font Software components as +distributed by the Copyright Holder(s). + +"Modified Version" refers to any derivative made by adding to, deleting, +or substituting -- in part or in whole -- any of the components of the +Original Version, by changing formats or by porting the Font Software to a +new environment. + +"Author" refers to any designer, engineer, programmer, technical +writer or other person who contributed to the Font Software. + +PERMISSION & CONDITIONS +Permission is hereby granted, free of charge, to any person obtaining +a copy of the Font Software, to use, study, copy, merge, embed, modify, +redistribute, and sell modified and unmodified copies of the Font +Software, subject to the following conditions: + +1) Neither the Font Software nor any of its individual components, +in Original or Modified Versions, may be sold by itself. + +2) Original or Modified Versions of the Font Software may be bundled, +redistributed and/or sold with any software, provided that each copy +contains the above copyright notice and this license. These can be +included either as stand-alone text files, human-readable headers or +in the appropriate machine-readable metadata fields within text or +binary files as long as those fields can be easily viewed by the user. + +3) No Modified Version of the Font Software may use the Reserved Font +Name(s) unless explicit written permission is granted by the corresponding +Copyright Holder. This restriction only applies to the primary font name as +presented to the users. + +4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font +Software shall not be used to promote, endorse or advertise any +Modified Version, except to acknowledge the contribution(s) of the +Copyright Holder(s) and the Author(s) or with their explicit written +permission. + +5) The Font Software, modified or unmodified, in part or in whole, +must be distributed entirely under this license, and must not be +distributed under any other license. The requirement for fonts to +remain under this license does not apply to any document created +using the Font Software. + +TERMINATION +This license becomes null and void if any of the above conditions are +not met. + +DISCLAIMER +THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT +OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE +COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL +DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM +OTHER DEALINGS IN THE FONT SOFTWARE. diff --git a/packages/mosaic/framework/skills/canvas-design/canvas-fonts/IBMPlexMono-Regular.ttf b/packages/mosaic/framework/skills/canvas-design/canvas-fonts/IBMPlexMono-Regular.ttf new file mode 100644 index 00000000..601ae945 Binary files /dev/null and b/packages/mosaic/framework/skills/canvas-design/canvas-fonts/IBMPlexMono-Regular.ttf differ diff --git a/packages/mosaic/framework/skills/canvas-design/canvas-fonts/IBMPlexSerif-Bold.ttf b/packages/mosaic/framework/skills/canvas-design/canvas-fonts/IBMPlexSerif-Bold.ttf new file mode 100644 index 00000000..78f6e500 Binary files /dev/null and b/packages/mosaic/framework/skills/canvas-design/canvas-fonts/IBMPlexSerif-Bold.ttf differ diff --git a/packages/mosaic/framework/skills/canvas-design/canvas-fonts/IBMPlexSerif-BoldItalic.ttf b/packages/mosaic/framework/skills/canvas-design/canvas-fonts/IBMPlexSerif-BoldItalic.ttf new file mode 100644 index 00000000..369b89d2 Binary files /dev/null and b/packages/mosaic/framework/skills/canvas-design/canvas-fonts/IBMPlexSerif-BoldItalic.ttf differ diff --git a/packages/mosaic/framework/skills/canvas-design/canvas-fonts/IBMPlexSerif-Italic.ttf b/packages/mosaic/framework/skills/canvas-design/canvas-fonts/IBMPlexSerif-Italic.ttf new file mode 100644 index 00000000..a4d859a7 Binary files /dev/null and b/packages/mosaic/framework/skills/canvas-design/canvas-fonts/IBMPlexSerif-Italic.ttf differ diff --git a/packages/mosaic/framework/skills/canvas-design/canvas-fonts/IBMPlexSerif-Regular.ttf b/packages/mosaic/framework/skills/canvas-design/canvas-fonts/IBMPlexSerif-Regular.ttf new file mode 100644 index 00000000..35f454ce Binary files /dev/null and b/packages/mosaic/framework/skills/canvas-design/canvas-fonts/IBMPlexSerif-Regular.ttf differ diff --git a/packages/mosaic/framework/skills/canvas-design/canvas-fonts/InstrumentSans-Bold.ttf b/packages/mosaic/framework/skills/canvas-design/canvas-fonts/InstrumentSans-Bold.ttf new file mode 100644 index 00000000..f602dcef Binary files /dev/null and b/packages/mosaic/framework/skills/canvas-design/canvas-fonts/InstrumentSans-Bold.ttf differ diff --git a/packages/mosaic/framework/skills/canvas-design/canvas-fonts/InstrumentSans-BoldItalic.ttf b/packages/mosaic/framework/skills/canvas-design/canvas-fonts/InstrumentSans-BoldItalic.ttf new file mode 100644 index 00000000..122b2730 Binary files /dev/null and b/packages/mosaic/framework/skills/canvas-design/canvas-fonts/InstrumentSans-BoldItalic.ttf differ diff --git a/packages/mosaic/framework/skills/canvas-design/canvas-fonts/InstrumentSans-Italic.ttf b/packages/mosaic/framework/skills/canvas-design/canvas-fonts/InstrumentSans-Italic.ttf new file mode 100644 index 00000000..4b98fb8d Binary files /dev/null and b/packages/mosaic/framework/skills/canvas-design/canvas-fonts/InstrumentSans-Italic.ttf differ diff --git a/packages/mosaic/framework/skills/canvas-design/canvas-fonts/InstrumentSans-OFL.txt b/packages/mosaic/framework/skills/canvas-design/canvas-fonts/InstrumentSans-OFL.txt new file mode 100644 index 00000000..4bb99142 --- /dev/null +++ b/packages/mosaic/framework/skills/canvas-design/canvas-fonts/InstrumentSans-OFL.txt @@ -0,0 +1,93 @@ +Copyright 2022 The Instrument Sans Project Authors (https://github.com/Instrument/instrument-sans) + +This Font Software is licensed under the SIL Open Font License, Version 1.1. +This license is copied below, and is also available with a FAQ at: +https://openfontlicense.org + + +----------------------------------------------------------- +SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007 +----------------------------------------------------------- + +PREAMBLE +The goals of the Open Font License (OFL) are to stimulate worldwide +development of collaborative font projects, to support the font creation +efforts of academic and linguistic communities, and to provide a free and +open framework in which fonts may be shared and improved in partnership +with others. + +The OFL allows the licensed fonts to be used, studied, modified and +redistributed freely as long as they are not sold by themselves. The +fonts, including any derivative works, can be bundled, embedded, +redistributed and/or sold with any software provided that any reserved +names are not used by derivative works. The fonts and derivatives, +however, cannot be released under any other type of license. The +requirement for fonts to remain under this license does not apply +to any document created using the fonts or their derivatives. + +DEFINITIONS +"Font Software" refers to the set of files released by the Copyright +Holder(s) under this license and clearly marked as such. This may +include source files, build scripts and documentation. + +"Reserved Font Name" refers to any names specified as such after the +copyright statement(s). + +"Original Version" refers to the collection of Font Software components as +distributed by the Copyright Holder(s). + +"Modified Version" refers to any derivative made by adding to, deleting, +or substituting -- in part or in whole -- any of the components of the +Original Version, by changing formats or by porting the Font Software to a +new environment. + +"Author" refers to any designer, engineer, programmer, technical +writer or other person who contributed to the Font Software. + +PERMISSION & CONDITIONS +Permission is hereby granted, free of charge, to any person obtaining +a copy of the Font Software, to use, study, copy, merge, embed, modify, +redistribute, and sell modified and unmodified copies of the Font +Software, subject to the following conditions: + +1) Neither the Font Software nor any of its individual components, +in Original or Modified Versions, may be sold by itself. + +2) Original or Modified Versions of the Font Software may be bundled, +redistributed and/or sold with any software, provided that each copy +contains the above copyright notice and this license. These can be +included either as stand-alone text files, human-readable headers or +in the appropriate machine-readable metadata fields within text or +binary files as long as those fields can be easily viewed by the user. + +3) No Modified Version of the Font Software may use the Reserved Font +Name(s) unless explicit written permission is granted by the corresponding +Copyright Holder. This restriction only applies to the primary font name as +presented to the users. + +4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font +Software shall not be used to promote, endorse or advertise any +Modified Version, except to acknowledge the contribution(s) of the +Copyright Holder(s) and the Author(s) or with their explicit written +permission. + +5) The Font Software, modified or unmodified, in part or in whole, +must be distributed entirely under this license, and must not be +distributed under any other license. The requirement for fonts to +remain under this license does not apply to any document created +using the Font Software. + +TERMINATION +This license becomes null and void if any of the above conditions are +not met. + +DISCLAIMER +THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT +OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE +COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL +DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM +OTHER DEALINGS IN THE FONT SOFTWARE. diff --git a/packages/mosaic/framework/skills/canvas-design/canvas-fonts/InstrumentSans-Regular.ttf b/packages/mosaic/framework/skills/canvas-design/canvas-fonts/InstrumentSans-Regular.ttf new file mode 100644 index 00000000..14c6113c Binary files /dev/null and b/packages/mosaic/framework/skills/canvas-design/canvas-fonts/InstrumentSans-Regular.ttf differ diff --git a/packages/mosaic/framework/skills/canvas-design/canvas-fonts/InstrumentSerif-Italic.ttf b/packages/mosaic/framework/skills/canvas-design/canvas-fonts/InstrumentSerif-Italic.ttf new file mode 100644 index 00000000..8fa958d9 Binary files /dev/null and b/packages/mosaic/framework/skills/canvas-design/canvas-fonts/InstrumentSerif-Italic.ttf differ diff --git a/packages/mosaic/framework/skills/canvas-design/canvas-fonts/InstrumentSerif-Regular.ttf b/packages/mosaic/framework/skills/canvas-design/canvas-fonts/InstrumentSerif-Regular.ttf new file mode 100644 index 00000000..97630318 Binary files /dev/null and b/packages/mosaic/framework/skills/canvas-design/canvas-fonts/InstrumentSerif-Regular.ttf differ diff --git a/packages/mosaic/framework/skills/canvas-design/canvas-fonts/Italiana-OFL.txt b/packages/mosaic/framework/skills/canvas-design/canvas-fonts/Italiana-OFL.txt new file mode 100644 index 00000000..ba8af215 --- /dev/null +++ b/packages/mosaic/framework/skills/canvas-design/canvas-fonts/Italiana-OFL.txt @@ -0,0 +1,93 @@ +Copyright (c) 2011, Santiago Orozco (hi@typemade.mx), with Reserved Font Name "Italiana". + +This Font Software is licensed under the SIL Open Font License, Version 1.1. +This license is copied below, and is also available with a FAQ at: +https://openfontlicense.org + + +----------------------------------------------------------- +SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007 +----------------------------------------------------------- + +PREAMBLE +The goals of the Open Font License (OFL) are to stimulate worldwide +development of collaborative font projects, to support the font creation +efforts of academic and linguistic communities, and to provide a free and +open framework in which fonts may be shared and improved in partnership +with others. + +The OFL allows the licensed fonts to be used, studied, modified and +redistributed freely as long as they are not sold by themselves. The +fonts, including any derivative works, can be bundled, embedded, +redistributed and/or sold with any software provided that any reserved +names are not used by derivative works. The fonts and derivatives, +however, cannot be released under any other type of license. The +requirement for fonts to remain under this license does not apply +to any document created using the fonts or their derivatives. + +DEFINITIONS +"Font Software" refers to the set of files released by the Copyright +Holder(s) under this license and clearly marked as such. This may +include source files, build scripts and documentation. + +"Reserved Font Name" refers to any names specified as such after the +copyright statement(s). + +"Original Version" refers to the collection of Font Software components as +distributed by the Copyright Holder(s). + +"Modified Version" refers to any derivative made by adding to, deleting, +or substituting -- in part or in whole -- any of the components of the +Original Version, by changing formats or by porting the Font Software to a +new environment. + +"Author" refers to any designer, engineer, programmer, technical +writer or other person who contributed to the Font Software. + +PERMISSION & CONDITIONS +Permission is hereby granted, free of charge, to any person obtaining +a copy of the Font Software, to use, study, copy, merge, embed, modify, +redistribute, and sell modified and unmodified copies of the Font +Software, subject to the following conditions: + +1) Neither the Font Software nor any of its individual components, +in Original or Modified Versions, may be sold by itself. + +2) Original or Modified Versions of the Font Software may be bundled, +redistributed and/or sold with any software, provided that each copy +contains the above copyright notice and this license. These can be +included either as stand-alone text files, human-readable headers or +in the appropriate machine-readable metadata fields within text or +binary files as long as those fields can be easily viewed by the user. + +3) No Modified Version of the Font Software may use the Reserved Font +Name(s) unless explicit written permission is granted by the corresponding +Copyright Holder. This restriction only applies to the primary font name as +presented to the users. + +4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font +Software shall not be used to promote, endorse or advertise any +Modified Version, except to acknowledge the contribution(s) of the +Copyright Holder(s) and the Author(s) or with their explicit written +permission. + +5) The Font Software, modified or unmodified, in part or in whole, +must be distributed entirely under this license, and must not be +distributed under any other license. The requirement for fonts to +remain under this license does not apply to any document created +using the Font Software. + +TERMINATION +This license becomes null and void if any of the above conditions are +not met. + +DISCLAIMER +THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT +OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE +COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL +DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM +OTHER DEALINGS IN THE FONT SOFTWARE. diff --git a/packages/mosaic/framework/skills/canvas-design/canvas-fonts/Italiana-Regular.ttf b/packages/mosaic/framework/skills/canvas-design/canvas-fonts/Italiana-Regular.ttf new file mode 100644 index 00000000..a9b828c0 Binary files /dev/null and b/packages/mosaic/framework/skills/canvas-design/canvas-fonts/Italiana-Regular.ttf differ diff --git a/packages/mosaic/framework/skills/canvas-design/canvas-fonts/JetBrainsMono-Bold.ttf b/packages/mosaic/framework/skills/canvas-design/canvas-fonts/JetBrainsMono-Bold.ttf new file mode 100644 index 00000000..1926c804 Binary files /dev/null and b/packages/mosaic/framework/skills/canvas-design/canvas-fonts/JetBrainsMono-Bold.ttf differ diff --git a/packages/mosaic/framework/skills/canvas-design/canvas-fonts/JetBrainsMono-OFL.txt b/packages/mosaic/framework/skills/canvas-design/canvas-fonts/JetBrainsMono-OFL.txt new file mode 100644 index 00000000..5ceee002 --- /dev/null +++ b/packages/mosaic/framework/skills/canvas-design/canvas-fonts/JetBrainsMono-OFL.txt @@ -0,0 +1,93 @@ +Copyright 2020 The JetBrains Mono Project Authors (https://github.com/JetBrains/JetBrainsMono) + +This Font Software is licensed under the SIL Open Font License, Version 1.1. +This license is copied below, and is also available with a FAQ at: +https://openfontlicense.org + + +----------------------------------------------------------- +SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007 +----------------------------------------------------------- + +PREAMBLE +The goals of the Open Font License (OFL) are to stimulate worldwide +development of collaborative font projects, to support the font creation +efforts of academic and linguistic communities, and to provide a free and +open framework in which fonts may be shared and improved in partnership +with others. + +The OFL allows the licensed fonts to be used, studied, modified and +redistributed freely as long as they are not sold by themselves. The +fonts, including any derivative works, can be bundled, embedded, +redistributed and/or sold with any software provided that any reserved +names are not used by derivative works. The fonts and derivatives, +however, cannot be released under any other type of license. The +requirement for fonts to remain under this license does not apply +to any document created using the fonts or their derivatives. + +DEFINITIONS +"Font Software" refers to the set of files released by the Copyright +Holder(s) under this license and clearly marked as such. This may +include source files, build scripts and documentation. + +"Reserved Font Name" refers to any names specified as such after the +copyright statement(s). + +"Original Version" refers to the collection of Font Software components as +distributed by the Copyright Holder(s). + +"Modified Version" refers to any derivative made by adding to, deleting, +or substituting -- in part or in whole -- any of the components of the +Original Version, by changing formats or by porting the Font Software to a +new environment. + +"Author" refers to any designer, engineer, programmer, technical +writer or other person who contributed to the Font Software. + +PERMISSION & CONDITIONS +Permission is hereby granted, free of charge, to any person obtaining +a copy of the Font Software, to use, study, copy, merge, embed, modify, +redistribute, and sell modified and unmodified copies of the Font +Software, subject to the following conditions: + +1) Neither the Font Software nor any of its individual components, +in Original or Modified Versions, may be sold by itself. + +2) Original or Modified Versions of the Font Software may be bundled, +redistributed and/or sold with any software, provided that each copy +contains the above copyright notice and this license. These can be +included either as stand-alone text files, human-readable headers or +in the appropriate machine-readable metadata fields within text or +binary files as long as those fields can be easily viewed by the user. + +3) No Modified Version of the Font Software may use the Reserved Font +Name(s) unless explicit written permission is granted by the corresponding +Copyright Holder. This restriction only applies to the primary font name as +presented to the users. + +4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font +Software shall not be used to promote, endorse or advertise any +Modified Version, except to acknowledge the contribution(s) of the +Copyright Holder(s) and the Author(s) or with their explicit written +permission. + +5) The Font Software, modified or unmodified, in part or in whole, +must be distributed entirely under this license, and must not be +distributed under any other license. The requirement for fonts to +remain under this license does not apply to any document created +using the Font Software. + +TERMINATION +This license becomes null and void if any of the above conditions are +not met. + +DISCLAIMER +THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT +OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE +COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL +DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM +OTHER DEALINGS IN THE FONT SOFTWARE. diff --git a/packages/mosaic/framework/skills/canvas-design/canvas-fonts/JetBrainsMono-Regular.ttf b/packages/mosaic/framework/skills/canvas-design/canvas-fonts/JetBrainsMono-Regular.ttf new file mode 100644 index 00000000..436c982f Binary files /dev/null and b/packages/mosaic/framework/skills/canvas-design/canvas-fonts/JetBrainsMono-Regular.ttf differ diff --git a/packages/mosaic/framework/skills/canvas-design/canvas-fonts/Jura-Light.ttf b/packages/mosaic/framework/skills/canvas-design/canvas-fonts/Jura-Light.ttf new file mode 100644 index 00000000..dffbb339 Binary files /dev/null and b/packages/mosaic/framework/skills/canvas-design/canvas-fonts/Jura-Light.ttf differ diff --git a/packages/mosaic/framework/skills/canvas-design/canvas-fonts/Jura-Medium.ttf b/packages/mosaic/framework/skills/canvas-design/canvas-fonts/Jura-Medium.ttf new file mode 100644 index 00000000..4bf91a33 Binary files /dev/null and b/packages/mosaic/framework/skills/canvas-design/canvas-fonts/Jura-Medium.ttf differ diff --git a/packages/mosaic/framework/skills/canvas-design/canvas-fonts/Jura-OFL.txt b/packages/mosaic/framework/skills/canvas-design/canvas-fonts/Jura-OFL.txt new file mode 100644 index 00000000..64ad4c67 --- /dev/null +++ b/packages/mosaic/framework/skills/canvas-design/canvas-fonts/Jura-OFL.txt @@ -0,0 +1,93 @@ +Copyright 2019 The Jura Project Authors (https://github.com/ossobuffo/jura) + +This Font Software is licensed under the SIL Open Font License, Version 1.1. +This license is copied below, and is also available with a FAQ at: +https://openfontlicense.org + + +----------------------------------------------------------- +SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007 +----------------------------------------------------------- + +PREAMBLE +The goals of the Open Font License (OFL) are to stimulate worldwide +development of collaborative font projects, to support the font creation +efforts of academic and linguistic communities, and to provide a free and +open framework in which fonts may be shared and improved in partnership +with others. + +The OFL allows the licensed fonts to be used, studied, modified and +redistributed freely as long as they are not sold by themselves. The +fonts, including any derivative works, can be bundled, embedded, +redistributed and/or sold with any software provided that any reserved +names are not used by derivative works. The fonts and derivatives, +however, cannot be released under any other type of license. The +requirement for fonts to remain under this license does not apply +to any document created using the fonts or their derivatives. + +DEFINITIONS +"Font Software" refers to the set of files released by the Copyright +Holder(s) under this license and clearly marked as such. This may +include source files, build scripts and documentation. + +"Reserved Font Name" refers to any names specified as such after the +copyright statement(s). + +"Original Version" refers to the collection of Font Software components as +distributed by the Copyright Holder(s). + +"Modified Version" refers to any derivative made by adding to, deleting, +or substituting -- in part or in whole -- any of the components of the +Original Version, by changing formats or by porting the Font Software to a +new environment. + +"Author" refers to any designer, engineer, programmer, technical +writer or other person who contributed to the Font Software. + +PERMISSION & CONDITIONS +Permission is hereby granted, free of charge, to any person obtaining +a copy of the Font Software, to use, study, copy, merge, embed, modify, +redistribute, and sell modified and unmodified copies of the Font +Software, subject to the following conditions: + +1) Neither the Font Software nor any of its individual components, +in Original or Modified Versions, may be sold by itself. + +2) Original or Modified Versions of the Font Software may be bundled, +redistributed and/or sold with any software, provided that each copy +contains the above copyright notice and this license. These can be +included either as stand-alone text files, human-readable headers or +in the appropriate machine-readable metadata fields within text or +binary files as long as those fields can be easily viewed by the user. + +3) No Modified Version of the Font Software may use the Reserved Font +Name(s) unless explicit written permission is granted by the corresponding +Copyright Holder. This restriction only applies to the primary font name as +presented to the users. + +4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font +Software shall not be used to promote, endorse or advertise any +Modified Version, except to acknowledge the contribution(s) of the +Copyright Holder(s) and the Author(s) or with their explicit written +permission. + +5) The Font Software, modified or unmodified, in part or in whole, +must be distributed entirely under this license, and must not be +distributed under any other license. The requirement for fonts to +remain under this license does not apply to any document created +using the Font Software. + +TERMINATION +This license becomes null and void if any of the above conditions are +not met. + +DISCLAIMER +THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT +OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE +COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL +DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM +OTHER DEALINGS IN THE FONT SOFTWARE. diff --git a/packages/mosaic/framework/skills/canvas-design/canvas-fonts/LibreBaskerville-OFL.txt b/packages/mosaic/framework/skills/canvas-design/canvas-fonts/LibreBaskerville-OFL.txt new file mode 100644 index 00000000..8c531fa5 --- /dev/null +++ b/packages/mosaic/framework/skills/canvas-design/canvas-fonts/LibreBaskerville-OFL.txt @@ -0,0 +1,93 @@ +Copyright 2012 The Libre Baskerville Project Authors (https://github.com/impallari/Libre-Baskerville) with Reserved Font Name Libre Baskerville. + +This Font Software is licensed under the SIL Open Font License, Version 1.1. +This license is copied below, and is also available with a FAQ at: +https://openfontlicense.org + + +----------------------------------------------------------- +SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007 +----------------------------------------------------------- + +PREAMBLE +The goals of the Open Font License (OFL) are to stimulate worldwide +development of collaborative font projects, to support the font creation +efforts of academic and linguistic communities, and to provide a free and +open framework in which fonts may be shared and improved in partnership +with others. + +The OFL allows the licensed fonts to be used, studied, modified and +redistributed freely as long as they are not sold by themselves. The +fonts, including any derivative works, can be bundled, embedded, +redistributed and/or sold with any software provided that any reserved +names are not used by derivative works. The fonts and derivatives, +however, cannot be released under any other type of license. The +requirement for fonts to remain under this license does not apply +to any document created using the fonts or their derivatives. + +DEFINITIONS +"Font Software" refers to the set of files released by the Copyright +Holder(s) under this license and clearly marked as such. This may +include source files, build scripts and documentation. + +"Reserved Font Name" refers to any names specified as such after the +copyright statement(s). + +"Original Version" refers to the collection of Font Software components as +distributed by the Copyright Holder(s). + +"Modified Version" refers to any derivative made by adding to, deleting, +or substituting -- in part or in whole -- any of the components of the +Original Version, by changing formats or by porting the Font Software to a +new environment. + +"Author" refers to any designer, engineer, programmer, technical +writer or other person who contributed to the Font Software. + +PERMISSION & CONDITIONS +Permission is hereby granted, free of charge, to any person obtaining +a copy of the Font Software, to use, study, copy, merge, embed, modify, +redistribute, and sell modified and unmodified copies of the Font +Software, subject to the following conditions: + +1) Neither the Font Software nor any of its individual components, +in Original or Modified Versions, may be sold by itself. + +2) Original or Modified Versions of the Font Software may be bundled, +redistributed and/or sold with any software, provided that each copy +contains the above copyright notice and this license. These can be +included either as stand-alone text files, human-readable headers or +in the appropriate machine-readable metadata fields within text or +binary files as long as those fields can be easily viewed by the user. + +3) No Modified Version of the Font Software may use the Reserved Font +Name(s) unless explicit written permission is granted by the corresponding +Copyright Holder. This restriction only applies to the primary font name as +presented to the users. + +4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font +Software shall not be used to promote, endorse or advertise any +Modified Version, except to acknowledge the contribution(s) of the +Copyright Holder(s) and the Author(s) or with their explicit written +permission. + +5) The Font Software, modified or unmodified, in part or in whole, +must be distributed entirely under this license, and must not be +distributed under any other license. The requirement for fonts to +remain under this license does not apply to any document created +using the Font Software. + +TERMINATION +This license becomes null and void if any of the above conditions are +not met. + +DISCLAIMER +THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT +OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE +COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL +DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM +OTHER DEALINGS IN THE FONT SOFTWARE. diff --git a/packages/mosaic/framework/skills/canvas-design/canvas-fonts/LibreBaskerville-Regular.ttf b/packages/mosaic/framework/skills/canvas-design/canvas-fonts/LibreBaskerville-Regular.ttf new file mode 100644 index 00000000..c1abc264 Binary files /dev/null and b/packages/mosaic/framework/skills/canvas-design/canvas-fonts/LibreBaskerville-Regular.ttf differ diff --git a/packages/mosaic/framework/skills/canvas-design/canvas-fonts/Lora-Bold.ttf b/packages/mosaic/framework/skills/canvas-design/canvas-fonts/Lora-Bold.ttf new file mode 100644 index 00000000..edae21eb Binary files /dev/null and b/packages/mosaic/framework/skills/canvas-design/canvas-fonts/Lora-Bold.ttf differ diff --git a/packages/mosaic/framework/skills/canvas-design/canvas-fonts/Lora-BoldItalic.ttf b/packages/mosaic/framework/skills/canvas-design/canvas-fonts/Lora-BoldItalic.ttf new file mode 100644 index 00000000..12dea8c6 Binary files /dev/null and b/packages/mosaic/framework/skills/canvas-design/canvas-fonts/Lora-BoldItalic.ttf differ diff --git a/packages/mosaic/framework/skills/canvas-design/canvas-fonts/Lora-Italic.ttf b/packages/mosaic/framework/skills/canvas-design/canvas-fonts/Lora-Italic.ttf new file mode 100644 index 00000000..e24b69b2 Binary files /dev/null and b/packages/mosaic/framework/skills/canvas-design/canvas-fonts/Lora-Italic.ttf differ diff --git a/packages/mosaic/framework/skills/canvas-design/canvas-fonts/Lora-OFL.txt b/packages/mosaic/framework/skills/canvas-design/canvas-fonts/Lora-OFL.txt new file mode 100644 index 00000000..4cf1b950 --- /dev/null +++ b/packages/mosaic/framework/skills/canvas-design/canvas-fonts/Lora-OFL.txt @@ -0,0 +1,93 @@ +Copyright 2011 The Lora Project Authors (https://github.com/cyrealtype/Lora-Cyrillic), with Reserved Font Name "Lora". + +This Font Software is licensed under the SIL Open Font License, Version 1.1. +This license is copied below, and is also available with a FAQ at: +https://openfontlicense.org + + +----------------------------------------------------------- +SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007 +----------------------------------------------------------- + +PREAMBLE +The goals of the Open Font License (OFL) are to stimulate worldwide +development of collaborative font projects, to support the font creation +efforts of academic and linguistic communities, and to provide a free and +open framework in which fonts may be shared and improved in partnership +with others. + +The OFL allows the licensed fonts to be used, studied, modified and +redistributed freely as long as they are not sold by themselves. The +fonts, including any derivative works, can be bundled, embedded, +redistributed and/or sold with any software provided that any reserved +names are not used by derivative works. The fonts and derivatives, +however, cannot be released under any other type of license. The +requirement for fonts to remain under this license does not apply +to any document created using the fonts or their derivatives. + +DEFINITIONS +"Font Software" refers to the set of files released by the Copyright +Holder(s) under this license and clearly marked as such. This may +include source files, build scripts and documentation. + +"Reserved Font Name" refers to any names specified as such after the +copyright statement(s). + +"Original Version" refers to the collection of Font Software components as +distributed by the Copyright Holder(s). + +"Modified Version" refers to any derivative made by adding to, deleting, +or substituting -- in part or in whole -- any of the components of the +Original Version, by changing formats or by porting the Font Software to a +new environment. + +"Author" refers to any designer, engineer, programmer, technical +writer or other person who contributed to the Font Software. + +PERMISSION & CONDITIONS +Permission is hereby granted, free of charge, to any person obtaining +a copy of the Font Software, to use, study, copy, merge, embed, modify, +redistribute, and sell modified and unmodified copies of the Font +Software, subject to the following conditions: + +1) Neither the Font Software nor any of its individual components, +in Original or Modified Versions, may be sold by itself. + +2) Original or Modified Versions of the Font Software may be bundled, +redistributed and/or sold with any software, provided that each copy +contains the above copyright notice and this license. These can be +included either as stand-alone text files, human-readable headers or +in the appropriate machine-readable metadata fields within text or +binary files as long as those fields can be easily viewed by the user. + +3) No Modified Version of the Font Software may use the Reserved Font +Name(s) unless explicit written permission is granted by the corresponding +Copyright Holder. This restriction only applies to the primary font name as +presented to the users. + +4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font +Software shall not be used to promote, endorse or advertise any +Modified Version, except to acknowledge the contribution(s) of the +Copyright Holder(s) and the Author(s) or with their explicit written +permission. + +5) The Font Software, modified or unmodified, in part or in whole, +must be distributed entirely under this license, and must not be +distributed under any other license. The requirement for fonts to +remain under this license does not apply to any document created +using the Font Software. + +TERMINATION +This license becomes null and void if any of the above conditions are +not met. + +DISCLAIMER +THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT +OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE +COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL +DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM +OTHER DEALINGS IN THE FONT SOFTWARE. diff --git a/packages/mosaic/framework/skills/canvas-design/canvas-fonts/Lora-Regular.ttf b/packages/mosaic/framework/skills/canvas-design/canvas-fonts/Lora-Regular.ttf new file mode 100644 index 00000000..dc751db0 Binary files /dev/null and b/packages/mosaic/framework/skills/canvas-design/canvas-fonts/Lora-Regular.ttf differ diff --git a/packages/mosaic/framework/skills/canvas-design/canvas-fonts/NationalPark-Bold.ttf b/packages/mosaic/framework/skills/canvas-design/canvas-fonts/NationalPark-Bold.ttf new file mode 100644 index 00000000..f4d7c021 Binary files /dev/null and b/packages/mosaic/framework/skills/canvas-design/canvas-fonts/NationalPark-Bold.ttf differ diff --git a/packages/mosaic/framework/skills/canvas-design/canvas-fonts/NationalPark-OFL.txt b/packages/mosaic/framework/skills/canvas-design/canvas-fonts/NationalPark-OFL.txt new file mode 100644 index 00000000..f4ec3fba --- /dev/null +++ b/packages/mosaic/framework/skills/canvas-design/canvas-fonts/NationalPark-OFL.txt @@ -0,0 +1,93 @@ +Copyright 2025 The National Park Project Authors (https://github.com/benhoepner/National-Park) + +This Font Software is licensed under the SIL Open Font License, Version 1.1. +This license is copied below, and is also available with a FAQ at: +https://openfontlicense.org + + +----------------------------------------------------------- +SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007 +----------------------------------------------------------- + +PREAMBLE +The goals of the Open Font License (OFL) are to stimulate worldwide +development of collaborative font projects, to support the font creation +efforts of academic and linguistic communities, and to provide a free and +open framework in which fonts may be shared and improved in partnership +with others. + +The OFL allows the licensed fonts to be used, studied, modified and +redistributed freely as long as they are not sold by themselves. The +fonts, including any derivative works, can be bundled, embedded, +redistributed and/or sold with any software provided that any reserved +names are not used by derivative works. The fonts and derivatives, +however, cannot be released under any other type of license. The +requirement for fonts to remain under this license does not apply +to any document created using the fonts or their derivatives. + +DEFINITIONS +"Font Software" refers to the set of files released by the Copyright +Holder(s) under this license and clearly marked as such. This may +include source files, build scripts and documentation. + +"Reserved Font Name" refers to any names specified as such after the +copyright statement(s). + +"Original Version" refers to the collection of Font Software components as +distributed by the Copyright Holder(s). + +"Modified Version" refers to any derivative made by adding to, deleting, +or substituting -- in part or in whole -- any of the components of the +Original Version, by changing formats or by porting the Font Software to a +new environment. + +"Author" refers to any designer, engineer, programmer, technical +writer or other person who contributed to the Font Software. + +PERMISSION & CONDITIONS +Permission is hereby granted, free of charge, to any person obtaining +a copy of the Font Software, to use, study, copy, merge, embed, modify, +redistribute, and sell modified and unmodified copies of the Font +Software, subject to the following conditions: + +1) Neither the Font Software nor any of its individual components, +in Original or Modified Versions, may be sold by itself. + +2) Original or Modified Versions of the Font Software may be bundled, +redistributed and/or sold with any software, provided that each copy +contains the above copyright notice and this license. These can be +included either as stand-alone text files, human-readable headers or +in the appropriate machine-readable metadata fields within text or +binary files as long as those fields can be easily viewed by the user. + +3) No Modified Version of the Font Software may use the Reserved Font +Name(s) unless explicit written permission is granted by the corresponding +Copyright Holder. This restriction only applies to the primary font name as +presented to the users. + +4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font +Software shall not be used to promote, endorse or advertise any +Modified Version, except to acknowledge the contribution(s) of the +Copyright Holder(s) and the Author(s) or with their explicit written +permission. + +5) The Font Software, modified or unmodified, in part or in whole, +must be distributed entirely under this license, and must not be +distributed under any other license. The requirement for fonts to +remain under this license does not apply to any document created +using the Font Software. + +TERMINATION +This license becomes null and void if any of the above conditions are +not met. + +DISCLAIMER +THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT +OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE +COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL +DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM +OTHER DEALINGS IN THE FONT SOFTWARE. diff --git a/packages/mosaic/framework/skills/canvas-design/canvas-fonts/NationalPark-Regular.ttf b/packages/mosaic/framework/skills/canvas-design/canvas-fonts/NationalPark-Regular.ttf new file mode 100644 index 00000000..e4cbfbf5 Binary files /dev/null and b/packages/mosaic/framework/skills/canvas-design/canvas-fonts/NationalPark-Regular.ttf differ diff --git a/packages/mosaic/framework/skills/canvas-design/canvas-fonts/NothingYouCouldDo-OFL.txt b/packages/mosaic/framework/skills/canvas-design/canvas-fonts/NothingYouCouldDo-OFL.txt new file mode 100644 index 00000000..c81eccde --- /dev/null +++ b/packages/mosaic/framework/skills/canvas-design/canvas-fonts/NothingYouCouldDo-OFL.txt @@ -0,0 +1,93 @@ +Copyright (c) 2010, Kimberly Geswein (kimberlygeswein.com) + +This Font Software is licensed under the SIL Open Font License, Version 1.1. +This license is copied below, and is also available with a FAQ at: +https://openfontlicense.org + + +----------------------------------------------------------- +SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007 +----------------------------------------------------------- + +PREAMBLE +The goals of the Open Font License (OFL) are to stimulate worldwide +development of collaborative font projects, to support the font creation +efforts of academic and linguistic communities, and to provide a free and +open framework in which fonts may be shared and improved in partnership +with others. + +The OFL allows the licensed fonts to be used, studied, modified and +redistributed freely as long as they are not sold by themselves. The +fonts, including any derivative works, can be bundled, embedded, +redistributed and/or sold with any software provided that any reserved +names are not used by derivative works. The fonts and derivatives, +however, cannot be released under any other type of license. The +requirement for fonts to remain under this license does not apply +to any document created using the fonts or their derivatives. + +DEFINITIONS +"Font Software" refers to the set of files released by the Copyright +Holder(s) under this license and clearly marked as such. This may +include source files, build scripts and documentation. + +"Reserved Font Name" refers to any names specified as such after the +copyright statement(s). + +"Original Version" refers to the collection of Font Software components as +distributed by the Copyright Holder(s). + +"Modified Version" refers to any derivative made by adding to, deleting, +or substituting -- in part or in whole -- any of the components of the +Original Version, by changing formats or by porting the Font Software to a +new environment. + +"Author" refers to any designer, engineer, programmer, technical +writer or other person who contributed to the Font Software. + +PERMISSION & CONDITIONS +Permission is hereby granted, free of charge, to any person obtaining +a copy of the Font Software, to use, study, copy, merge, embed, modify, +redistribute, and sell modified and unmodified copies of the Font +Software, subject to the following conditions: + +1) Neither the Font Software nor any of its individual components, +in Original or Modified Versions, may be sold by itself. + +2) Original or Modified Versions of the Font Software may be bundled, +redistributed and/or sold with any software, provided that each copy +contains the above copyright notice and this license. These can be +included either as stand-alone text files, human-readable headers or +in the appropriate machine-readable metadata fields within text or +binary files as long as those fields can be easily viewed by the user. + +3) No Modified Version of the Font Software may use the Reserved Font +Name(s) unless explicit written permission is granted by the corresponding +Copyright Holder. This restriction only applies to the primary font name as +presented to the users. + +4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font +Software shall not be used to promote, endorse or advertise any +Modified Version, except to acknowledge the contribution(s) of the +Copyright Holder(s) and the Author(s) or with their explicit written +permission. + +5) The Font Software, modified or unmodified, in part or in whole, +must be distributed entirely under this license, and must not be +distributed under any other license. The requirement for fonts to +remain under this license does not apply to any document created +using the Font Software. + +TERMINATION +This license becomes null and void if any of the above conditions are +not met. + +DISCLAIMER +THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT +OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE +COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL +DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM +OTHER DEALINGS IN THE FONT SOFTWARE. diff --git a/packages/mosaic/framework/skills/canvas-design/canvas-fonts/NothingYouCouldDo-Regular.ttf b/packages/mosaic/framework/skills/canvas-design/canvas-fonts/NothingYouCouldDo-Regular.ttf new file mode 100644 index 00000000..b086bced Binary files /dev/null and b/packages/mosaic/framework/skills/canvas-design/canvas-fonts/NothingYouCouldDo-Regular.ttf differ diff --git a/packages/mosaic/framework/skills/canvas-design/canvas-fonts/Outfit-Bold.ttf b/packages/mosaic/framework/skills/canvas-design/canvas-fonts/Outfit-Bold.ttf new file mode 100644 index 00000000..f9f2f72a Binary files /dev/null and b/packages/mosaic/framework/skills/canvas-design/canvas-fonts/Outfit-Bold.ttf differ diff --git a/packages/mosaic/framework/skills/canvas-design/canvas-fonts/Outfit-OFL.txt b/packages/mosaic/framework/skills/canvas-design/canvas-fonts/Outfit-OFL.txt new file mode 100644 index 00000000..fd0cb995 --- /dev/null +++ b/packages/mosaic/framework/skills/canvas-design/canvas-fonts/Outfit-OFL.txt @@ -0,0 +1,93 @@ +Copyright 2021 The Outfit Project Authors (https://github.com/Outfitio/Outfit-Fonts) + +This Font Software is licensed under the SIL Open Font License, Version 1.1. +This license is copied below, and is also available with a FAQ at: +https://openfontlicense.org + + +----------------------------------------------------------- +SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007 +----------------------------------------------------------- + +PREAMBLE +The goals of the Open Font License (OFL) are to stimulate worldwide +development of collaborative font projects, to support the font creation +efforts of academic and linguistic communities, and to provide a free and +open framework in which fonts may be shared and improved in partnership +with others. + +The OFL allows the licensed fonts to be used, studied, modified and +redistributed freely as long as they are not sold by themselves. The +fonts, including any derivative works, can be bundled, embedded, +redistributed and/or sold with any software provided that any reserved +names are not used by derivative works. The fonts and derivatives, +however, cannot be released under any other type of license. The +requirement for fonts to remain under this license does not apply +to any document created using the fonts or their derivatives. + +DEFINITIONS +"Font Software" refers to the set of files released by the Copyright +Holder(s) under this license and clearly marked as such. This may +include source files, build scripts and documentation. + +"Reserved Font Name" refers to any names specified as such after the +copyright statement(s). + +"Original Version" refers to the collection of Font Software components as +distributed by the Copyright Holder(s). + +"Modified Version" refers to any derivative made by adding to, deleting, +or substituting -- in part or in whole -- any of the components of the +Original Version, by changing formats or by porting the Font Software to a +new environment. + +"Author" refers to any designer, engineer, programmer, technical +writer or other person who contributed to the Font Software. + +PERMISSION & CONDITIONS +Permission is hereby granted, free of charge, to any person obtaining +a copy of the Font Software, to use, study, copy, merge, embed, modify, +redistribute, and sell modified and unmodified copies of the Font +Software, subject to the following conditions: + +1) Neither the Font Software nor any of its individual components, +in Original or Modified Versions, may be sold by itself. + +2) Original or Modified Versions of the Font Software may be bundled, +redistributed and/or sold with any software, provided that each copy +contains the above copyright notice and this license. These can be +included either as stand-alone text files, human-readable headers or +in the appropriate machine-readable metadata fields within text or +binary files as long as those fields can be easily viewed by the user. + +3) No Modified Version of the Font Software may use the Reserved Font +Name(s) unless explicit written permission is granted by the corresponding +Copyright Holder. This restriction only applies to the primary font name as +presented to the users. + +4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font +Software shall not be used to promote, endorse or advertise any +Modified Version, except to acknowledge the contribution(s) of the +Copyright Holder(s) and the Author(s) or with their explicit written +permission. + +5) The Font Software, modified or unmodified, in part or in whole, +must be distributed entirely under this license, and must not be +distributed under any other license. The requirement for fonts to +remain under this license does not apply to any document created +using the Font Software. + +TERMINATION +This license becomes null and void if any of the above conditions are +not met. + +DISCLAIMER +THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT +OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE +COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL +DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM +OTHER DEALINGS IN THE FONT SOFTWARE. diff --git a/packages/mosaic/framework/skills/canvas-design/canvas-fonts/Outfit-Regular.ttf b/packages/mosaic/framework/skills/canvas-design/canvas-fonts/Outfit-Regular.ttf new file mode 100644 index 00000000..3939ab24 Binary files /dev/null and b/packages/mosaic/framework/skills/canvas-design/canvas-fonts/Outfit-Regular.ttf differ diff --git a/packages/mosaic/framework/skills/canvas-design/canvas-fonts/PixelifySans-Medium.ttf b/packages/mosaic/framework/skills/canvas-design/canvas-fonts/PixelifySans-Medium.ttf new file mode 100644 index 00000000..95cd3725 Binary files /dev/null and b/packages/mosaic/framework/skills/canvas-design/canvas-fonts/PixelifySans-Medium.ttf differ diff --git a/packages/mosaic/framework/skills/canvas-design/canvas-fonts/PixelifySans-OFL.txt b/packages/mosaic/framework/skills/canvas-design/canvas-fonts/PixelifySans-OFL.txt new file mode 100644 index 00000000..b02d1b67 --- /dev/null +++ b/packages/mosaic/framework/skills/canvas-design/canvas-fonts/PixelifySans-OFL.txt @@ -0,0 +1,93 @@ +Copyright 2021 The Pixelify Sans Project Authors (https://github.com/eifetx/Pixelify-Sans) + +This Font Software is licensed under the SIL Open Font License, Version 1.1. +This license is copied below, and is also available with a FAQ at: +https://openfontlicense.org + + +----------------------------------------------------------- +SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007 +----------------------------------------------------------- + +PREAMBLE +The goals of the Open Font License (OFL) are to stimulate worldwide +development of collaborative font projects, to support the font creation +efforts of academic and linguistic communities, and to provide a free and +open framework in which fonts may be shared and improved in partnership +with others. + +The OFL allows the licensed fonts to be used, studied, modified and +redistributed freely as long as they are not sold by themselves. The +fonts, including any derivative works, can be bundled, embedded, +redistributed and/or sold with any software provided that any reserved +names are not used by derivative works. The fonts and derivatives, +however, cannot be released under any other type of license. The +requirement for fonts to remain under this license does not apply +to any document created using the fonts or their derivatives. + +DEFINITIONS +"Font Software" refers to the set of files released by the Copyright +Holder(s) under this license and clearly marked as such. This may +include source files, build scripts and documentation. + +"Reserved Font Name" refers to any names specified as such after the +copyright statement(s). + +"Original Version" refers to the collection of Font Software components as +distributed by the Copyright Holder(s). + +"Modified Version" refers to any derivative made by adding to, deleting, +or substituting -- in part or in whole -- any of the components of the +Original Version, by changing formats or by porting the Font Software to a +new environment. + +"Author" refers to any designer, engineer, programmer, technical +writer or other person who contributed to the Font Software. + +PERMISSION & CONDITIONS +Permission is hereby granted, free of charge, to any person obtaining +a copy of the Font Software, to use, study, copy, merge, embed, modify, +redistribute, and sell modified and unmodified copies of the Font +Software, subject to the following conditions: + +1) Neither the Font Software nor any of its individual components, +in Original or Modified Versions, may be sold by itself. + +2) Original or Modified Versions of the Font Software may be bundled, +redistributed and/or sold with any software, provided that each copy +contains the above copyright notice and this license. These can be +included either as stand-alone text files, human-readable headers or +in the appropriate machine-readable metadata fields within text or +binary files as long as those fields can be easily viewed by the user. + +3) No Modified Version of the Font Software may use the Reserved Font +Name(s) unless explicit written permission is granted by the corresponding +Copyright Holder. This restriction only applies to the primary font name as +presented to the users. + +4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font +Software shall not be used to promote, endorse or advertise any +Modified Version, except to acknowledge the contribution(s) of the +Copyright Holder(s) and the Author(s) or with their explicit written +permission. + +5) The Font Software, modified or unmodified, in part or in whole, +must be distributed entirely under this license, and must not be +distributed under any other license. The requirement for fonts to +remain under this license does not apply to any document created +using the Font Software. + +TERMINATION +This license becomes null and void if any of the above conditions are +not met. + +DISCLAIMER +THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT +OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE +COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL +DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM +OTHER DEALINGS IN THE FONT SOFTWARE. diff --git a/packages/mosaic/framework/skills/canvas-design/canvas-fonts/PoiretOne-OFL.txt b/packages/mosaic/framework/skills/canvas-design/canvas-fonts/PoiretOne-OFL.txt new file mode 100644 index 00000000..607bdad3 --- /dev/null +++ b/packages/mosaic/framework/skills/canvas-design/canvas-fonts/PoiretOne-OFL.txt @@ -0,0 +1,93 @@ +Copyright (c) 2011, Denis Masharov (denis.masharov@gmail.com) + +This Font Software is licensed under the SIL Open Font License, Version 1.1. +This license is copied below, and is also available with a FAQ at: +https://openfontlicense.org + + +----------------------------------------------------------- +SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007 +----------------------------------------------------------- + +PREAMBLE +The goals of the Open Font License (OFL) are to stimulate worldwide +development of collaborative font projects, to support the font creation +efforts of academic and linguistic communities, and to provide a free and +open framework in which fonts may be shared and improved in partnership +with others. + +The OFL allows the licensed fonts to be used, studied, modified and +redistributed freely as long as they are not sold by themselves. The +fonts, including any derivative works, can be bundled, embedded, +redistributed and/or sold with any software provided that any reserved +names are not used by derivative works. The fonts and derivatives, +however, cannot be released under any other type of license. The +requirement for fonts to remain under this license does not apply +to any document created using the fonts or their derivatives. + +DEFINITIONS +"Font Software" refers to the set of files released by the Copyright +Holder(s) under this license and clearly marked as such. This may +include source files, build scripts and documentation. + +"Reserved Font Name" refers to any names specified as such after the +copyright statement(s). + +"Original Version" refers to the collection of Font Software components as +distributed by the Copyright Holder(s). + +"Modified Version" refers to any derivative made by adding to, deleting, +or substituting -- in part or in whole -- any of the components of the +Original Version, by changing formats or by porting the Font Software to a +new environment. + +"Author" refers to any designer, engineer, programmer, technical +writer or other person who contributed to the Font Software. + +PERMISSION & CONDITIONS +Permission is hereby granted, free of charge, to any person obtaining +a copy of the Font Software, to use, study, copy, merge, embed, modify, +redistribute, and sell modified and unmodified copies of the Font +Software, subject to the following conditions: + +1) Neither the Font Software nor any of its individual components, +in Original or Modified Versions, may be sold by itself. + +2) Original or Modified Versions of the Font Software may be bundled, +redistributed and/or sold with any software, provided that each copy +contains the above copyright notice and this license. These can be +included either as stand-alone text files, human-readable headers or +in the appropriate machine-readable metadata fields within text or +binary files as long as those fields can be easily viewed by the user. + +3) No Modified Version of the Font Software may use the Reserved Font +Name(s) unless explicit written permission is granted by the corresponding +Copyright Holder. This restriction only applies to the primary font name as +presented to the users. + +4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font +Software shall not be used to promote, endorse or advertise any +Modified Version, except to acknowledge the contribution(s) of the +Copyright Holder(s) and the Author(s) or with their explicit written +permission. + +5) The Font Software, modified or unmodified, in part or in whole, +must be distributed entirely under this license, and must not be +distributed under any other license. The requirement for fonts to +remain under this license does not apply to any document created +using the Font Software. + +TERMINATION +This license becomes null and void if any of the above conditions are +not met. + +DISCLAIMER +THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT +OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE +COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL +DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM +OTHER DEALINGS IN THE FONT SOFTWARE. diff --git a/packages/mosaic/framework/skills/canvas-design/canvas-fonts/PoiretOne-Regular.ttf b/packages/mosaic/framework/skills/canvas-design/canvas-fonts/PoiretOne-Regular.ttf new file mode 100644 index 00000000..b339511b Binary files /dev/null and b/packages/mosaic/framework/skills/canvas-design/canvas-fonts/PoiretOne-Regular.ttf differ diff --git a/packages/mosaic/framework/skills/canvas-design/canvas-fonts/RedHatMono-Bold.ttf b/packages/mosaic/framework/skills/canvas-design/canvas-fonts/RedHatMono-Bold.ttf new file mode 100644 index 00000000..a6e3cf15 Binary files /dev/null and b/packages/mosaic/framework/skills/canvas-design/canvas-fonts/RedHatMono-Bold.ttf differ diff --git a/packages/mosaic/framework/skills/canvas-design/canvas-fonts/RedHatMono-OFL.txt b/packages/mosaic/framework/skills/canvas-design/canvas-fonts/RedHatMono-OFL.txt new file mode 100644 index 00000000..16cf394b --- /dev/null +++ b/packages/mosaic/framework/skills/canvas-design/canvas-fonts/RedHatMono-OFL.txt @@ -0,0 +1,93 @@ +Copyright 2024 The Red Hat Project Authors (https://github.com/RedHatOfficial/RedHatFont) + +This Font Software is licensed under the SIL Open Font License, Version 1.1. +This license is copied below, and is also available with a FAQ at: +https://openfontlicense.org + + +----------------------------------------------------------- +SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007 +----------------------------------------------------------- + +PREAMBLE +The goals of the Open Font License (OFL) are to stimulate worldwide +development of collaborative font projects, to support the font creation +efforts of academic and linguistic communities, and to provide a free and +open framework in which fonts may be shared and improved in partnership +with others. + +The OFL allows the licensed fonts to be used, studied, modified and +redistributed freely as long as they are not sold by themselves. The +fonts, including any derivative works, can be bundled, embedded, +redistributed and/or sold with any software provided that any reserved +names are not used by derivative works. The fonts and derivatives, +however, cannot be released under any other type of license. The +requirement for fonts to remain under this license does not apply +to any document created using the fonts or their derivatives. + +DEFINITIONS +"Font Software" refers to the set of files released by the Copyright +Holder(s) under this license and clearly marked as such. This may +include source files, build scripts and documentation. + +"Reserved Font Name" refers to any names specified as such after the +copyright statement(s). + +"Original Version" refers to the collection of Font Software components as +distributed by the Copyright Holder(s). + +"Modified Version" refers to any derivative made by adding to, deleting, +or substituting -- in part or in whole -- any of the components of the +Original Version, by changing formats or by porting the Font Software to a +new environment. + +"Author" refers to any designer, engineer, programmer, technical +writer or other person who contributed to the Font Software. + +PERMISSION & CONDITIONS +Permission is hereby granted, free of charge, to any person obtaining +a copy of the Font Software, to use, study, copy, merge, embed, modify, +redistribute, and sell modified and unmodified copies of the Font +Software, subject to the following conditions: + +1) Neither the Font Software nor any of its individual components, +in Original or Modified Versions, may be sold by itself. + +2) Original or Modified Versions of the Font Software may be bundled, +redistributed and/or sold with any software, provided that each copy +contains the above copyright notice and this license. These can be +included either as stand-alone text files, human-readable headers or +in the appropriate machine-readable metadata fields within text or +binary files as long as those fields can be easily viewed by the user. + +3) No Modified Version of the Font Software may use the Reserved Font +Name(s) unless explicit written permission is granted by the corresponding +Copyright Holder. This restriction only applies to the primary font name as +presented to the users. + +4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font +Software shall not be used to promote, endorse or advertise any +Modified Version, except to acknowledge the contribution(s) of the +Copyright Holder(s) and the Author(s) or with their explicit written +permission. + +5) The Font Software, modified or unmodified, in part or in whole, +must be distributed entirely under this license, and must not be +distributed under any other license. The requirement for fonts to +remain under this license does not apply to any document created +using the Font Software. + +TERMINATION +This license becomes null and void if any of the above conditions are +not met. + +DISCLAIMER +THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT +OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE +COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL +DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM +OTHER DEALINGS IN THE FONT SOFTWARE. diff --git a/packages/mosaic/framework/skills/canvas-design/canvas-fonts/RedHatMono-Regular.ttf b/packages/mosaic/framework/skills/canvas-design/canvas-fonts/RedHatMono-Regular.ttf new file mode 100644 index 00000000..3bf6a698 Binary files /dev/null and b/packages/mosaic/framework/skills/canvas-design/canvas-fonts/RedHatMono-Regular.ttf differ diff --git a/packages/mosaic/framework/skills/canvas-design/canvas-fonts/Silkscreen-OFL.txt b/packages/mosaic/framework/skills/canvas-design/canvas-fonts/Silkscreen-OFL.txt new file mode 100644 index 00000000..a1fe7d5f --- /dev/null +++ b/packages/mosaic/framework/skills/canvas-design/canvas-fonts/Silkscreen-OFL.txt @@ -0,0 +1,93 @@ +Copyright 2001 The Silkscreen Project Authors (https://github.com/googlefonts/silkscreen) + +This Font Software is licensed under the SIL Open Font License, Version 1.1. +This license is copied below, and is also available with a FAQ at: +https://openfontlicense.org + + +----------------------------------------------------------- +SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007 +----------------------------------------------------------- + +PREAMBLE +The goals of the Open Font License (OFL) are to stimulate worldwide +development of collaborative font projects, to support the font creation +efforts of academic and linguistic communities, and to provide a free and +open framework in which fonts may be shared and improved in partnership +with others. + +The OFL allows the licensed fonts to be used, studied, modified and +redistributed freely as long as they are not sold by themselves. The +fonts, including any derivative works, can be bundled, embedded, +redistributed and/or sold with any software provided that any reserved +names are not used by derivative works. The fonts and derivatives, +however, cannot be released under any other type of license. The +requirement for fonts to remain under this license does not apply +to any document created using the fonts or their derivatives. + +DEFINITIONS +"Font Software" refers to the set of files released by the Copyright +Holder(s) under this license and clearly marked as such. This may +include source files, build scripts and documentation. + +"Reserved Font Name" refers to any names specified as such after the +copyright statement(s). + +"Original Version" refers to the collection of Font Software components as +distributed by the Copyright Holder(s). + +"Modified Version" refers to any derivative made by adding to, deleting, +or substituting -- in part or in whole -- any of the components of the +Original Version, by changing formats or by porting the Font Software to a +new environment. + +"Author" refers to any designer, engineer, programmer, technical +writer or other person who contributed to the Font Software. + +PERMISSION & CONDITIONS +Permission is hereby granted, free of charge, to any person obtaining +a copy of the Font Software, to use, study, copy, merge, embed, modify, +redistribute, and sell modified and unmodified copies of the Font +Software, subject to the following conditions: + +1) Neither the Font Software nor any of its individual components, +in Original or Modified Versions, may be sold by itself. + +2) Original or Modified Versions of the Font Software may be bundled, +redistributed and/or sold with any software, provided that each copy +contains the above copyright notice and this license. These can be +included either as stand-alone text files, human-readable headers or +in the appropriate machine-readable metadata fields within text or +binary files as long as those fields can be easily viewed by the user. + +3) No Modified Version of the Font Software may use the Reserved Font +Name(s) unless explicit written permission is granted by the corresponding +Copyright Holder. This restriction only applies to the primary font name as +presented to the users. + +4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font +Software shall not be used to promote, endorse or advertise any +Modified Version, except to acknowledge the contribution(s) of the +Copyright Holder(s) and the Author(s) or with their explicit written +permission. + +5) The Font Software, modified or unmodified, in part or in whole, +must be distributed entirely under this license, and must not be +distributed under any other license. The requirement for fonts to +remain under this license does not apply to any document created +using the Font Software. + +TERMINATION +This license becomes null and void if any of the above conditions are +not met. + +DISCLAIMER +THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT +OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE +COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL +DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM +OTHER DEALINGS IN THE FONT SOFTWARE. diff --git a/packages/mosaic/framework/skills/canvas-design/canvas-fonts/Silkscreen-Regular.ttf b/packages/mosaic/framework/skills/canvas-design/canvas-fonts/Silkscreen-Regular.ttf new file mode 100644 index 00000000..8abaa7c5 Binary files /dev/null and b/packages/mosaic/framework/skills/canvas-design/canvas-fonts/Silkscreen-Regular.ttf differ diff --git a/packages/mosaic/framework/skills/canvas-design/canvas-fonts/SmoochSans-Medium.ttf b/packages/mosaic/framework/skills/canvas-design/canvas-fonts/SmoochSans-Medium.ttf new file mode 100644 index 00000000..0af9ead0 Binary files /dev/null and b/packages/mosaic/framework/skills/canvas-design/canvas-fonts/SmoochSans-Medium.ttf differ diff --git a/packages/mosaic/framework/skills/canvas-design/canvas-fonts/SmoochSans-OFL.txt b/packages/mosaic/framework/skills/canvas-design/canvas-fonts/SmoochSans-OFL.txt new file mode 100644 index 00000000..4c2f033a --- /dev/null +++ b/packages/mosaic/framework/skills/canvas-design/canvas-fonts/SmoochSans-OFL.txt @@ -0,0 +1,93 @@ +Copyright 2016 The Smooch Sans Project Authors (https://github.com/googlefonts/smooch-sans) + +This Font Software is licensed under the SIL Open Font License, Version 1.1. +This license is copied below, and is also available with a FAQ at: +https://openfontlicense.org + + +----------------------------------------------------------- +SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007 +----------------------------------------------------------- + +PREAMBLE +The goals of the Open Font License (OFL) are to stimulate worldwide +development of collaborative font projects, to support the font creation +efforts of academic and linguistic communities, and to provide a free and +open framework in which fonts may be shared and improved in partnership +with others. + +The OFL allows the licensed fonts to be used, studied, modified and +redistributed freely as long as they are not sold by themselves. The +fonts, including any derivative works, can be bundled, embedded, +redistributed and/or sold with any software provided that any reserved +names are not used by derivative works. The fonts and derivatives, +however, cannot be released under any other type of license. The +requirement for fonts to remain under this license does not apply +to any document created using the fonts or their derivatives. + +DEFINITIONS +"Font Software" refers to the set of files released by the Copyright +Holder(s) under this license and clearly marked as such. This may +include source files, build scripts and documentation. + +"Reserved Font Name" refers to any names specified as such after the +copyright statement(s). + +"Original Version" refers to the collection of Font Software components as +distributed by the Copyright Holder(s). + +"Modified Version" refers to any derivative made by adding to, deleting, +or substituting -- in part or in whole -- any of the components of the +Original Version, by changing formats or by porting the Font Software to a +new environment. + +"Author" refers to any designer, engineer, programmer, technical +writer or other person who contributed to the Font Software. + +PERMISSION & CONDITIONS +Permission is hereby granted, free of charge, to any person obtaining +a copy of the Font Software, to use, study, copy, merge, embed, modify, +redistribute, and sell modified and unmodified copies of the Font +Software, subject to the following conditions: + +1) Neither the Font Software nor any of its individual components, +in Original or Modified Versions, may be sold by itself. + +2) Original or Modified Versions of the Font Software may be bundled, +redistributed and/or sold with any software, provided that each copy +contains the above copyright notice and this license. These can be +included either as stand-alone text files, human-readable headers or +in the appropriate machine-readable metadata fields within text or +binary files as long as those fields can be easily viewed by the user. + +3) No Modified Version of the Font Software may use the Reserved Font +Name(s) unless explicit written permission is granted by the corresponding +Copyright Holder. This restriction only applies to the primary font name as +presented to the users. + +4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font +Software shall not be used to promote, endorse or advertise any +Modified Version, except to acknowledge the contribution(s) of the +Copyright Holder(s) and the Author(s) or with their explicit written +permission. + +5) The Font Software, modified or unmodified, in part or in whole, +must be distributed entirely under this license, and must not be +distributed under any other license. The requirement for fonts to +remain under this license does not apply to any document created +using the Font Software. + +TERMINATION +This license becomes null and void if any of the above conditions are +not met. + +DISCLAIMER +THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT +OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE +COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL +DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM +OTHER DEALINGS IN THE FONT SOFTWARE. diff --git a/packages/mosaic/framework/skills/canvas-design/canvas-fonts/Tektur-Medium.ttf b/packages/mosaic/framework/skills/canvas-design/canvas-fonts/Tektur-Medium.ttf new file mode 100644 index 00000000..34fc7971 Binary files /dev/null and b/packages/mosaic/framework/skills/canvas-design/canvas-fonts/Tektur-Medium.ttf differ diff --git a/packages/mosaic/framework/skills/canvas-design/canvas-fonts/Tektur-OFL.txt b/packages/mosaic/framework/skills/canvas-design/canvas-fonts/Tektur-OFL.txt new file mode 100644 index 00000000..2cad55f1 --- /dev/null +++ b/packages/mosaic/framework/skills/canvas-design/canvas-fonts/Tektur-OFL.txt @@ -0,0 +1,93 @@ +Copyright 2023 The Tektur Project Authors (https://www.github.com/hyvyys/Tektur) + +This Font Software is licensed under the SIL Open Font License, Version 1.1. +This license is copied below, and is also available with a FAQ at: +https://openfontlicense.org + + +----------------------------------------------------------- +SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007 +----------------------------------------------------------- + +PREAMBLE +The goals of the Open Font License (OFL) are to stimulate worldwide +development of collaborative font projects, to support the font creation +efforts of academic and linguistic communities, and to provide a free and +open framework in which fonts may be shared and improved in partnership +with others. + +The OFL allows the licensed fonts to be used, studied, modified and +redistributed freely as long as they are not sold by themselves. The +fonts, including any derivative works, can be bundled, embedded, +redistributed and/or sold with any software provided that any reserved +names are not used by derivative works. The fonts and derivatives, +however, cannot be released under any other type of license. The +requirement for fonts to remain under this license does not apply +to any document created using the fonts or their derivatives. + +DEFINITIONS +"Font Software" refers to the set of files released by the Copyright +Holder(s) under this license and clearly marked as such. This may +include source files, build scripts and documentation. + +"Reserved Font Name" refers to any names specified as such after the +copyright statement(s). + +"Original Version" refers to the collection of Font Software components as +distributed by the Copyright Holder(s). + +"Modified Version" refers to any derivative made by adding to, deleting, +or substituting -- in part or in whole -- any of the components of the +Original Version, by changing formats or by porting the Font Software to a +new environment. + +"Author" refers to any designer, engineer, programmer, technical +writer or other person who contributed to the Font Software. + +PERMISSION & CONDITIONS +Permission is hereby granted, free of charge, to any person obtaining +a copy of the Font Software, to use, study, copy, merge, embed, modify, +redistribute, and sell modified and unmodified copies of the Font +Software, subject to the following conditions: + +1) Neither the Font Software nor any of its individual components, +in Original or Modified Versions, may be sold by itself. + +2) Original or Modified Versions of the Font Software may be bundled, +redistributed and/or sold with any software, provided that each copy +contains the above copyright notice and this license. These can be +included either as stand-alone text files, human-readable headers or +in the appropriate machine-readable metadata fields within text or +binary files as long as those fields can be easily viewed by the user. + +3) No Modified Version of the Font Software may use the Reserved Font +Name(s) unless explicit written permission is granted by the corresponding +Copyright Holder. This restriction only applies to the primary font name as +presented to the users. + +4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font +Software shall not be used to promote, endorse or advertise any +Modified Version, except to acknowledge the contribution(s) of the +Copyright Holder(s) and the Author(s) or with their explicit written +permission. + +5) The Font Software, modified or unmodified, in part or in whole, +must be distributed entirely under this license, and must not be +distributed under any other license. The requirement for fonts to +remain under this license does not apply to any document created +using the Font Software. + +TERMINATION +This license becomes null and void if any of the above conditions are +not met. + +DISCLAIMER +THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT +OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE +COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL +DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM +OTHER DEALINGS IN THE FONT SOFTWARE. diff --git a/packages/mosaic/framework/skills/canvas-design/canvas-fonts/Tektur-Regular.ttf b/packages/mosaic/framework/skills/canvas-design/canvas-fonts/Tektur-Regular.ttf new file mode 100644 index 00000000..f280fba4 Binary files /dev/null and b/packages/mosaic/framework/skills/canvas-design/canvas-fonts/Tektur-Regular.ttf differ diff --git a/packages/mosaic/framework/skills/canvas-design/canvas-fonts/WorkSans-Bold.ttf b/packages/mosaic/framework/skills/canvas-design/canvas-fonts/WorkSans-Bold.ttf new file mode 100644 index 00000000..5c979892 Binary files /dev/null and b/packages/mosaic/framework/skills/canvas-design/canvas-fonts/WorkSans-Bold.ttf differ diff --git a/packages/mosaic/framework/skills/canvas-design/canvas-fonts/WorkSans-BoldItalic.ttf b/packages/mosaic/framework/skills/canvas-design/canvas-fonts/WorkSans-BoldItalic.ttf new file mode 100644 index 00000000..54418b8a Binary files /dev/null and b/packages/mosaic/framework/skills/canvas-design/canvas-fonts/WorkSans-BoldItalic.ttf differ diff --git a/packages/mosaic/framework/skills/canvas-design/canvas-fonts/WorkSans-Italic.ttf b/packages/mosaic/framework/skills/canvas-design/canvas-fonts/WorkSans-Italic.ttf new file mode 100644 index 00000000..40529b68 Binary files /dev/null and b/packages/mosaic/framework/skills/canvas-design/canvas-fonts/WorkSans-Italic.ttf differ diff --git a/packages/mosaic/framework/skills/canvas-design/canvas-fonts/WorkSans-OFL.txt b/packages/mosaic/framework/skills/canvas-design/canvas-fonts/WorkSans-OFL.txt new file mode 100644 index 00000000..070f3416 --- /dev/null +++ b/packages/mosaic/framework/skills/canvas-design/canvas-fonts/WorkSans-OFL.txt @@ -0,0 +1,93 @@ +Copyright 2019 The Work Sans Project Authors (https://github.com/weiweihuanghuang/Work-Sans) + +This Font Software is licensed under the SIL Open Font License, Version 1.1. +This license is copied below, and is also available with a FAQ at: +https://openfontlicense.org + + +----------------------------------------------------------- +SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007 +----------------------------------------------------------- + +PREAMBLE +The goals of the Open Font License (OFL) are to stimulate worldwide +development of collaborative font projects, to support the font creation +efforts of academic and linguistic communities, and to provide a free and +open framework in which fonts may be shared and improved in partnership +with others. + +The OFL allows the licensed fonts to be used, studied, modified and +redistributed freely as long as they are not sold by themselves. The +fonts, including any derivative works, can be bundled, embedded, +redistributed and/or sold with any software provided that any reserved +names are not used by derivative works. The fonts and derivatives, +however, cannot be released under any other type of license. The +requirement for fonts to remain under this license does not apply +to any document created using the fonts or their derivatives. + +DEFINITIONS +"Font Software" refers to the set of files released by the Copyright +Holder(s) under this license and clearly marked as such. This may +include source files, build scripts and documentation. + +"Reserved Font Name" refers to any names specified as such after the +copyright statement(s). + +"Original Version" refers to the collection of Font Software components as +distributed by the Copyright Holder(s). + +"Modified Version" refers to any derivative made by adding to, deleting, +or substituting -- in part or in whole -- any of the components of the +Original Version, by changing formats or by porting the Font Software to a +new environment. + +"Author" refers to any designer, engineer, programmer, technical +writer or other person who contributed to the Font Software. + +PERMISSION & CONDITIONS +Permission is hereby granted, free of charge, to any person obtaining +a copy of the Font Software, to use, study, copy, merge, embed, modify, +redistribute, and sell modified and unmodified copies of the Font +Software, subject to the following conditions: + +1) Neither the Font Software nor any of its individual components, +in Original or Modified Versions, may be sold by itself. + +2) Original or Modified Versions of the Font Software may be bundled, +redistributed and/or sold with any software, provided that each copy +contains the above copyright notice and this license. These can be +included either as stand-alone text files, human-readable headers or +in the appropriate machine-readable metadata fields within text or +binary files as long as those fields can be easily viewed by the user. + +3) No Modified Version of the Font Software may use the Reserved Font +Name(s) unless explicit written permission is granted by the corresponding +Copyright Holder. This restriction only applies to the primary font name as +presented to the users. + +4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font +Software shall not be used to promote, endorse or advertise any +Modified Version, except to acknowledge the contribution(s) of the +Copyright Holder(s) and the Author(s) or with their explicit written +permission. + +5) The Font Software, modified or unmodified, in part or in whole, +must be distributed entirely under this license, and must not be +distributed under any other license. The requirement for fonts to +remain under this license does not apply to any document created +using the Font Software. + +TERMINATION +This license becomes null and void if any of the above conditions are +not met. + +DISCLAIMER +THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT +OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE +COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL +DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM +OTHER DEALINGS IN THE FONT SOFTWARE. diff --git a/packages/mosaic/framework/skills/canvas-design/canvas-fonts/WorkSans-Regular.ttf b/packages/mosaic/framework/skills/canvas-design/canvas-fonts/WorkSans-Regular.ttf new file mode 100644 index 00000000..d24586cc Binary files /dev/null and b/packages/mosaic/framework/skills/canvas-design/canvas-fonts/WorkSans-Regular.ttf differ diff --git a/packages/mosaic/framework/skills/canvas-design/canvas-fonts/YoungSerif-OFL.txt b/packages/mosaic/framework/skills/canvas-design/canvas-fonts/YoungSerif-OFL.txt new file mode 100644 index 00000000..f09443cb --- /dev/null +++ b/packages/mosaic/framework/skills/canvas-design/canvas-fonts/YoungSerif-OFL.txt @@ -0,0 +1,93 @@ +Copyright 2023 The Young Serif Project Authors (https://github.com/noirblancrouge/YoungSerif) + +This Font Software is licensed under the SIL Open Font License, Version 1.1. +This license is copied below, and is also available with a FAQ at: +https://openfontlicense.org + + +----------------------------------------------------------- +SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007 +----------------------------------------------------------- + +PREAMBLE +The goals of the Open Font License (OFL) are to stimulate worldwide +development of collaborative font projects, to support the font creation +efforts of academic and linguistic communities, and to provide a free and +open framework in which fonts may be shared and improved in partnership +with others. + +The OFL allows the licensed fonts to be used, studied, modified and +redistributed freely as long as they are not sold by themselves. The +fonts, including any derivative works, can be bundled, embedded, +redistributed and/or sold with any software provided that any reserved +names are not used by derivative works. The fonts and derivatives, +however, cannot be released under any other type of license. The +requirement for fonts to remain under this license does not apply +to any document created using the fonts or their derivatives. + +DEFINITIONS +"Font Software" refers to the set of files released by the Copyright +Holder(s) under this license and clearly marked as such. This may +include source files, build scripts and documentation. + +"Reserved Font Name" refers to any names specified as such after the +copyright statement(s). + +"Original Version" refers to the collection of Font Software components as +distributed by the Copyright Holder(s). + +"Modified Version" refers to any derivative made by adding to, deleting, +or substituting -- in part or in whole -- any of the components of the +Original Version, by changing formats or by porting the Font Software to a +new environment. + +"Author" refers to any designer, engineer, programmer, technical +writer or other person who contributed to the Font Software. + +PERMISSION & CONDITIONS +Permission is hereby granted, free of charge, to any person obtaining +a copy of the Font Software, to use, study, copy, merge, embed, modify, +redistribute, and sell modified and unmodified copies of the Font +Software, subject to the following conditions: + +1) Neither the Font Software nor any of its individual components, +in Original or Modified Versions, may be sold by itself. + +2) Original or Modified Versions of the Font Software may be bundled, +redistributed and/or sold with any software, provided that each copy +contains the above copyright notice and this license. These can be +included either as stand-alone text files, human-readable headers or +in the appropriate machine-readable metadata fields within text or +binary files as long as those fields can be easily viewed by the user. + +3) No Modified Version of the Font Software may use the Reserved Font +Name(s) unless explicit written permission is granted by the corresponding +Copyright Holder. This restriction only applies to the primary font name as +presented to the users. + +4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font +Software shall not be used to promote, endorse or advertise any +Modified Version, except to acknowledge the contribution(s) of the +Copyright Holder(s) and the Author(s) or with their explicit written +permission. + +5) The Font Software, modified or unmodified, in part or in whole, +must be distributed entirely under this license, and must not be +distributed under any other license. The requirement for fonts to +remain under this license does not apply to any document created +using the Font Software. + +TERMINATION +This license becomes null and void if any of the above conditions are +not met. + +DISCLAIMER +THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT +OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE +COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL +DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM +OTHER DEALINGS IN THE FONT SOFTWARE. diff --git a/packages/mosaic/framework/skills/canvas-design/canvas-fonts/YoungSerif-Regular.ttf b/packages/mosaic/framework/skills/canvas-design/canvas-fonts/YoungSerif-Regular.ttf new file mode 100644 index 00000000..f454fbed Binary files /dev/null and b/packages/mosaic/framework/skills/canvas-design/canvas-fonts/YoungSerif-Regular.ttf differ diff --git a/packages/mosaic/framework/skills/code-review-excellence/SKILL.md b/packages/mosaic/framework/skills/code-review-excellence/SKILL.md new file mode 100644 index 00000000..ebb8545a --- /dev/null +++ b/packages/mosaic/framework/skills/code-review-excellence/SKILL.md @@ -0,0 +1,205 @@ +--- +name: code-review-excellence +description: | + Provides comprehensive code review guidance for React 19, Vue 3, Rust, TypeScript, Java, Python, and C/C++. + Helps catch bugs, improve code quality, and give constructive feedback. + Use when: reviewing pull requests, conducting PR reviews, code review, reviewing code changes, + establishing review standards, mentoring developers, architecture reviews, security audits, + checking code quality, finding bugs, giving feedback on code. +allowed-tools: + - Read + - Grep + - Glob + - Bash # 运行 lint/test/build 命令验证代码质量 + - WebFetch # 查阅最新文档和最佳实践 +--- + +# Code Review Excellence + +Transform code reviews from gatekeeping to knowledge sharing through constructive feedback, systematic analysis, and collaborative improvement. + +## When to Use This Skill + +- Reviewing pull requests and code changes +- Establishing code review standards for teams +- Mentoring junior developers through reviews +- Conducting architecture reviews +- Creating review checklists and guidelines +- Improving team collaboration +- Reducing code review cycle time +- Maintaining code quality standards + +## Core Principles + +### 1. The Review Mindset + +**Goals of Code Review:** + +- Catch bugs and edge cases +- Ensure code maintainability +- Share knowledge across team +- Enforce coding standards +- Improve design and architecture +- Build team culture + +**Not the Goals:** + +- Show off knowledge +- Nitpick formatting (use linters) +- Block progress unnecessarily +- Rewrite to your preference + +### 2. Effective Feedback + +**Good Feedback is:** + +- Specific and actionable +- Educational, not judgmental +- Focused on the code, not the person +- Balanced (praise good work too) +- Prioritized (critical vs nice-to-have) + +```markdown +❌ Bad: "This is wrong." +✅ Good: "This could cause a race condition when multiple users +access simultaneously. Consider using a mutex here." + +❌ Bad: "Why didn't you use X pattern?" +✅ Good: "Have you considered the Repository pattern? It would +make this easier to test. Here's an example: [link]" + +❌ Bad: "Rename this variable." +✅ Good: "[nit] Consider `userCount` instead of `uc` for +clarity. Not blocking if you prefer to keep it." +``` + +### 3. Review Scope + +**What to Review:** + +- Logic correctness and edge cases +- Security vulnerabilities +- Performance implications +- Test coverage and quality +- Error handling +- Documentation and comments +- API design and naming +- Architectural fit + +**What Not to Review Manually:** + +- Code formatting (use Prettier, Black, etc.) +- Import organization +- Linting violations +- Simple typos + +## Review Process + +### Phase 1: Context Gathering (2-3 minutes) + +Before diving into code, understand: + +1. Read PR description and linked issue +2. Check PR size (>400 lines? Ask to split) +3. Review CI/CD status (tests passing?) +4. Understand the business requirement +5. Note any relevant architectural decisions + +### Phase 2: High-Level Review (5-10 minutes) + +1. **Architecture & Design** - Does the solution fit the problem? + - For significant changes, consult [Architecture Review Guide](reference/architecture-review-guide.md) + - Check: SOLID principles, coupling/cohesion, anti-patterns +2. **Performance Assessment** - Are there performance concerns? + - For performance-critical code, consult [Performance Review Guide](reference/performance-review-guide.md) + - Check: Algorithm complexity, N+1 queries, memory usage +3. **File Organization** - Are new files in the right places? +4. **Testing Strategy** - Are there tests covering edge cases? + +### Phase 3: Line-by-Line Review (10-20 minutes) + +For each file, check: + +- **Logic & Correctness** - Edge cases, off-by-one, null checks, race conditions +- **Security** - Input validation, injection risks, XSS, sensitive data +- **Performance** - N+1 queries, unnecessary loops, memory leaks +- **Maintainability** - Clear names, single responsibility, comments + +### Phase 4: Summary & Decision (2-3 minutes) + +1. Summarize key concerns +2. Highlight what you liked +3. Make clear decision: + - ✅ Approve + - 💬 Comment (minor suggestions) + - 🔄 Request Changes (must address) +4. Offer to pair if complex + +## Review Techniques + +### Technique 1: The Checklist Method + +Use checklists for consistent reviews. See [Security Review Guide](reference/security-review-guide.md) for comprehensive security checklist. + +### Technique 2: The Question Approach + +Instead of stating problems, ask questions: + +```markdown +❌ "This will fail if the list is empty." +✅ "What happens if `items` is an empty array?" + +❌ "You need error handling here." +✅ "How should this behave if the API call fails?" +``` + +### Technique 3: Suggest, Don't Command + +Use collaborative language: + +```markdown +❌ "You must change this to use async/await" +✅ "Suggestion: async/await might make this more readable. What do you think?" + +❌ "Extract this into a function" +✅ "This logic appears in 3 places. Would it make sense to extract it?" +``` + +### Technique 4: Differentiate Severity + +Use labels to indicate priority: + +- 🔴 `[blocking]` - Must fix before merge +- 🟡 `[important]` - Should fix, discuss if disagree +- 🟢 `[nit]` - Nice to have, not blocking +- 💡 `[suggestion]` - Alternative approach to consider +- 📚 `[learning]` - Educational comment, no action needed +- 🎉 `[praise]` - Good work, keep it up! + +## Language-Specific Guides + +根据审查的代码语言,查阅对应的详细指南: + +| Language/Framework | Reference File | Key Topics | +| ------------------ | ------------------------------------------- | -------------------------------------------------------------------- | +| **React** | [React Guide](reference/react.md) | Hooks, useEffect, React 19 Actions, RSC, Suspense, TanStack Query v5 | +| **Vue 3** | [Vue Guide](reference/vue.md) | Composition API, 响应性系统, Props/Emits, Watchers, Composables | +| **Rust** | [Rust Guide](reference/rust.md) | 所有权/借用, Unsafe 审查, 异步代码, 错误处理 | +| **TypeScript** | [TypeScript Guide](reference/typescript.md) | 类型安全, async/await, 不可变性 | +| **Python** | [Python Guide](reference/python.md) | 可变默认参数, 异常处理, 类属性 | +| **Java** | [Java Guide](reference/java.md) | Java 17/21 新特性, Spring Boot 3, 虚拟线程, Stream/Optional | +| **Go** | [Go Guide](reference/go.md) | 错误处理, goroutine/channel, context, 接口设计 | +| **C** | [C Guide](reference/c.md) | 指针/缓冲区, 内存安全, UB, 错误处理 | +| **C++** | [C++ Guide](reference/cpp.md) | RAII, 生命周期, Rule of 0/3/5, 异常安全 | +| **CSS/Less/Sass** | [CSS Guide](reference/css-less-sass.md) | 变量规范, !important, 性能优化, 响应式, 兼容性 | +| **Qt** | [Qt Guide](reference/qt.md) | 对象模型, 信号/槽, 内存管理, 线程安全, 性能 | + +## Additional Resources + +- [Architecture Review Guide](reference/architecture-review-guide.md) - 架构设计审查指南(SOLID、反模式、耦合度) +- [Performance Review Guide](reference/performance-review-guide.md) - 性能审查指南(Web Vitals、N+1、复杂度) +- [Common Bugs Checklist](reference/common-bugs-checklist.md) - 按语言分类的常见错误清单 +- [Security Review Guide](reference/security-review-guide.md) - 安全审查指南 +- [Code Review Best Practices](reference/code-review-best-practices.md) - 代码审查最佳实践 +- [PR Review Template](assets/pr-review-template.md) - PR 审查评论模板 +- [Review Checklist](assets/review-checklist.md) - 快速参考清单 diff --git a/packages/mosaic/framework/skills/code-review-excellence/assets/pr-review-template.md b/packages/mosaic/framework/skills/code-review-excellence/assets/pr-review-template.md new file mode 100644 index 00000000..4db64279 --- /dev/null +++ b/packages/mosaic/framework/skills/code-review-excellence/assets/pr-review-template.md @@ -0,0 +1,122 @@ +# PR Review Template + +Copy and use this template for your code reviews. + +--- + +## Summary + +[Brief overview of what was reviewed - 1-2 sentences] + +**PR Size:** [Small/Medium/Large] (~X lines) +**Review Time:** [X minutes] + +## Strengths + +- [What was done well] +- [Good patterns or approaches used] +- [Improvements from previous code] + +## Required Changes + +🔴 **[blocking]** [Issue description] + +> [Code location or example] +> [Suggested fix or explanation] + +🔴 **[blocking]** [Issue description] + +> [Details] + +## Important Suggestions + +🟡 **[important]** [Issue description] + +> [Why this matters] +> [Suggested approach] + +## Minor Suggestions + +🟢 **[nit]** [Minor improvement suggestion] + +💡 **[suggestion]** [Alternative approach to consider] + +## Questions + +❓ [Clarification needed about X] + +❓ [Question about design decision Y] + +## Security Considerations + +- [ ] No hardcoded secrets +- [ ] Input validation present +- [ ] Authorization checks in place +- [ ] No SQL/XSS injection risks + +## Test Coverage + +- [ ] Unit tests added/updated +- [ ] Edge cases covered +- [ ] Error cases tested + +## Verdict + +**[ ] ✅ Approve** - Ready to merge +**[ ] 💬 Comment** - Minor suggestions, can merge +**[ ] 🔄 Request Changes** - Must address blocking issues + +--- + +## Quick Copy Templates + +### Blocking Issue + +``` +🔴 **[blocking]** [Title] + +[Description of the issue] + +**Location:** `file.ts:123` + +**Suggested fix:** +\`\`\`typescript +// Your suggested code +\`\`\` +``` + +### Important Suggestion + +``` +🟡 **[important]** [Title] + +[Why this is important] + +**Consider:** +- Option A: [description] +- Option B: [description] +``` + +### Minor Suggestion + +``` +🟢 **[nit]** [Suggestion] + +Not blocking, but consider [improvement]. +``` + +### Praise + +``` +🎉 **[praise]** Great work on [specific thing]! + +[Why this is good] +``` + +### Question + +``` +❓ **[question]** [Your question] + +I'm curious about the decision to [X]. Could you explain [Y]? +``` diff --git a/packages/mosaic/framework/skills/code-review-excellence/assets/review-checklist.md b/packages/mosaic/framework/skills/code-review-excellence/assets/review-checklist.md new file mode 100644 index 00000000..56c66080 --- /dev/null +++ b/packages/mosaic/framework/skills/code-review-excellence/assets/review-checklist.md @@ -0,0 +1,121 @@ +# Code Review Quick Checklist + +Quick reference checklist for code reviews. + +## Pre-Review (2 min) + +- [ ] Read PR description and linked issue +- [ ] Check PR size (<400 lines ideal) +- [ ] Verify CI/CD status (tests passing?) +- [ ] Understand the business requirement + +## Architecture & Design (5 min) + +- [ ] Solution fits the problem +- [ ] Consistent with existing patterns +- [ ] No simpler approach exists +- [ ] Will it scale? +- [ ] Changes in right location + +## Logic & Correctness (10 min) + +- [ ] Edge cases handled +- [ ] Null/undefined checks present +- [ ] Off-by-one errors checked +- [ ] Race conditions considered +- [ ] Error handling complete +- [ ] Correct data types used + +## Security (5 min) + +- [ ] No hardcoded secrets +- [ ] Input validated/sanitized +- [ ] SQL injection prevented +- [ ] XSS prevented +- [ ] Authorization checks present +- [ ] Sensitive data protected + +## Performance (3 min) + +- [ ] No N+1 queries +- [ ] Expensive operations optimized +- [ ] Large lists paginated +- [ ] No memory leaks +- [ ] Caching considered where appropriate + +## Testing (5 min) + +- [ ] Tests exist for new code +- [ ] Edge cases tested +- [ ] Error cases tested +- [ ] Tests are readable +- [ ] Tests are deterministic + +## Code Quality (3 min) + +- [ ] Clear variable/function names +- [ ] No code duplication +- [ ] Functions do one thing +- [ ] Complex code commented +- [ ] No magic numbers + +## Documentation (2 min) + +- [ ] Public APIs documented +- [ ] README updated if needed +- [ ] Breaking changes noted +- [ ] Complex logic explained + +--- + +## Severity Labels + +| Label | Meaning | Action | +| ----------------- | ------------ | ------------------- | +| 🔴 `[blocking]` | Must fix | Block merge | +| 🟡 `[important]` | Should fix | Discuss if disagree | +| 🟢 `[nit]` | Nice to have | Non-blocking | +| 💡 `[suggestion]` | Alternative | Consider | +| ❓ `[question]` | Need clarity | Respond | +| 🎉 `[praise]` | Good work | Celebrate! | + +--- + +## Decision Matrix + +| Situation | Decision | +| --------------------------------- | ------------------------- | +| Critical security issue | 🔴 Block, fix immediately | +| Breaking change without migration | 🔴 Block | +| Missing error handling | 🟡 Should fix | +| No tests for new code | 🟡 Should fix | +| Style preference | 🟢 Non-blocking | +| Minor naming improvement | 🟢 Non-blocking | +| Clever but working code | 💡 Suggest simpler | + +--- + +## Time Budget + +| PR Size | Target Time | +| ------------- | ------------ | +| < 100 lines | 10-15 min | +| 100-400 lines | 20-40 min | +| > 400 lines | Ask to split | + +--- + +## Red Flags + +Watch for these patterns: + +- `// TODO` in production code +- `console.log` left in code +- Commented out code +- `any` type in TypeScript +- Empty catch blocks +- `unwrap()` in Rust production code +- Magic numbers/strings +- Copy-pasted code blocks +- Missing null checks +- Hardcoded URLs/credentials diff --git a/packages/mosaic/framework/skills/code-review-excellence/reference/architecture-review-guide.md b/packages/mosaic/framework/skills/code-review-excellence/reference/architecture-review-guide.md new file mode 100644 index 00000000..b5fa039a --- /dev/null +++ b/packages/mosaic/framework/skills/code-review-excellence/reference/architecture-review-guide.md @@ -0,0 +1,498 @@ +# Architecture Review Guide + +架构设计审查指南,帮助评估代码的架构是否合理、设计是否恰当。 + +## SOLID 原则检查清单 + +### S - 单一职责原则 (SRP) + +**检查要点:** + +- 这个类/模块是否只有一个改变的理由? +- 类中的方法是否都服务于同一个目的? +- 如果要向非技术人员描述这个类,能否用一句话说清楚? + +**代码审查中的识别信号:** + +``` +⚠️ 类名包含 "And"、"Manager"、"Handler"、"Processor" 等泛化词汇 +⚠️ 一个类超过 200-300 行代码 +⚠️ 类有超过 5-7 个公共方法 +⚠️ 不同的方法操作完全不同的数据 +``` + +**审查问题:** + +- "这个类负责哪些事情?能否拆分?" +- "如果 X 需求变化,哪些方法需要改?如果 Y 需求变化呢?" + +### O - 开闭原则 (OCP) + +**检查要点:** + +- 添加新功能时,是否需要修改现有代码? +- 是否可以通过扩展(继承、组合)来添加新行为? +- 是否存在大量的 if/else 或 switch 语句来处理不同类型? + +**代码审查中的识别信号:** + +``` +⚠️ switch/if-else 链处理不同类型 +⚠️ 添加新功能需要修改核心类 +⚠️ 类型检查 (instanceof, typeof) 散布在代码中 +``` + +**审查问题:** + +- "如果要添加新的 X 类型,需要修改哪些文件?" +- "这个 switch 语句会随着新类型增加而增长吗?" + +### L - 里氏替换原则 (LSP) + +**检查要点:** + +- 子类是否可以完全替代父类使用? +- 子类是否改变了父类方法的预期行为? +- 是否存在子类抛出父类未声明的异常? + +**代码审查中的识别信号:** + +``` +⚠️ 显式类型转换 (casting) +⚠️ 子类方法抛出 NotImplementedException +⚠️ 子类方法为空实现或只有 return +⚠️ 使用基类的地方需要检查具体类型 +``` + +**审查问题:** + +- "如果用子类替换父类,调用方代码是否需要修改?" +- "这个方法在子类中的行为是否符合父类的契约?" + +### I - 接口隔离原则 (ISP) + +**检查要点:** + +- 接口是否足够小且专注? +- 实现类是否被迫实现不需要的方法? +- 客户端是否依赖了它不使用的方法? + +**代码审查中的识别信号:** + +``` +⚠️ 接口超过 5-7 个方法 +⚠️ 实现类有空方法或抛出 NotImplementedException +⚠️ 接口名称过于宽泛 (IManager, IService) +⚠️ 不同的客户端只使用接口的部分方法 +``` + +**审查问题:** + +- "这个接口的所有方法是否都被每个实现类使用?" +- "能否将这个大接口拆分为更小的专用接口?" + +### D - 依赖倒置原则 (DIP) + +**检查要点:** + +- 高层模块是否依赖于抽象而非具体实现? +- 是否使用依赖注入而非直接 new 对象? +- 抽象是否由高层模块定义而非低层模块? + +**代码审查中的识别信号:** + +``` +⚠️ 高层模块直接 new 低层模块的具体类 +⚠️ 导入具体实现类而非接口/抽象类 +⚠️ 配置和连接字符串硬编码在业务逻辑中 +⚠️ 难以为某个类编写单元测试 +``` + +**审查问题:** + +- "这个类的依赖能否在测试时被 mock 替换?" +- "如果要更换数据库/API 实现,需要修改多少地方?" + +--- + +## 架构反模式识别 + +### 致命反模式 + +| 反模式 | 识别信号 | 影响 | +| ---------------------------- | -------------------------------------------------- | ---------------------- | +| **大泥球 (Big Ball of Mud)** | 没有清晰的模块边界,任何代码都可能调用任何其他代码 | 难以理解、修改和测试 | +| **上帝类 (God Object)** | 单个类承担过多职责,知道太多、做太多 | 高耦合,难以重用和测试 | +| **意大利面条代码** | 控制流程混乱,goto 或深层嵌套,难以追踪执行路径 | 难以理解和维护 | +| **熔岩流 (Lava Flow)** | 没人敢动的古老代码,缺乏文档和测试 | 技术债务累积 | + +### 设计反模式 + +| 反模式 | 识别信号 | 建议 | +| -------------------------- | ------------------------------------ | -------------------------- | +| **金锤子 (Golden Hammer)** | 对所有问题使用同一种技术/模式 | 根据问题选择合适的解决方案 | +| **过度工程 (Gas Factory)** | 简单问题用复杂方案解决,滥用设计模式 | YAGNI 原则,先简单后复杂 | +| **船锚 (Boat Anchor)** | 为"将来可能需要"而写的未使用代码 | 删除未使用代码,需要时再写 | +| **复制粘贴编程** | 相同逻辑出现在多处 | 提取公共方法或模块 | + +### 审查问题 + +```markdown +🔴 [blocking] "这个类有 2000 行代码,建议拆分为多个专注的类" +🟡 [important] "这段逻辑在 3 个地方重复,考虑提取为公共方法?" +💡 [suggestion] "这个 switch 语句可以用策略模式替代,更易扩展" +``` + +--- + +## 耦合度与内聚性评估 + +### 耦合类型(从好到差) + +| 类型 | 描述 | 示例 | +| --------------- | -------------------------- | ----------------------------- | +| **消息耦合** ✅ | 通过参数传递数据 | `calculate(price, quantity)` | +| **数据耦合** ✅ | 共享简单数据结构 | `processOrder(orderDTO)` | +| **印记耦合** ⚠️ | 共享复杂数据结构但只用部分 | 传入整个 User 对象但只用 name | +| **控制耦合** ⚠️ | 传递控制标志影响行为 | `process(data, isAdmin=true)` | +| **公共耦合** ❌ | 共享全局变量 | 多个模块读写同一个全局状态 | +| **内容耦合** ❌ | 直接访问另一模块的内部 | 直接操作另一个类的私有属性 | + +### 内聚类型(从好到差) + +| 类型 | 描述 | 质量 | +| ------------ | -------------------- | --------- | +| **功能内聚** | 所有元素完成单一任务 | ✅ 最佳 | +| **顺序内聚** | 输出作为下一步输入 | ✅ 良好 | +| **通信内聚** | 操作相同数据 | ⚠️ 可接受 | +| **时间内聚** | 同时执行的任务 | ⚠️ 较差 | +| **逻辑内聚** | 逻辑相关但功能不同 | ❌ 差 | +| **偶然内聚** | 没有明显关系 | ❌ 最差 | + +### 度量指标参考 + +```yaml +耦合指标: + CBO (类间耦合): + 好: < 5 + 警告: 5-10 + 危险: > 10 + + Ce (传出耦合): + 描述: 依赖多少外部类 + 好: < 7 + + Ca (传入耦合): + 描述: 被多少类依赖 + 高值意味着: 修改影响大,需要稳定 + +内聚指标: + LCOM4 (方法缺乏内聚): + 1: 单一职责 ✅ + 2-3: 可能需要拆分 ⚠️ + >3: 应该拆分 ❌ +``` + +### 审查问题 + +- "这个模块依赖了多少其他模块?能否减少?" +- "修改这个类会影响多少其他地方?" +- "这个类的方法是否都操作相同的数据?" + +--- + +## 分层架构审查 + +### Clean Architecture 层次检查 + +``` +┌─────────────────────────────────────┐ +│ Frameworks & Drivers │ ← 最外层:Web、DB、UI +├─────────────────────────────────────┤ +│ Interface Adapters │ ← Controllers、Gateways、Presenters +├─────────────────────────────────────┤ +│ Application Layer │ ← Use Cases、Application Services +├─────────────────────────────────────┤ +│ Domain Layer │ ← Entities、Domain Services +└─────────────────────────────────────┘ + ↑ 依赖方向只能向内 ↑ +``` + +### 依赖规则检查 + +**核心规则:源代码依赖只能指向内层** + +```typescript +// ❌ 违反依赖规则:Domain 层依赖 Infrastructure +// domain/User.ts +import { MySQLConnection } from '../infrastructure/database'; + +// ✅ 正确:Domain 层定义接口,Infrastructure 实现 +// domain/UserRepository.ts (接口) +interface UserRepository { + findById(id: string): Promise; +} + +// infrastructure/MySQLUserRepository.ts (实现) +class MySQLUserRepository implements UserRepository { + findById(id: string): Promise { + /* ... */ + } +} +``` + +### 审查清单 + +**层次边界检查:** + +- [ ] Domain 层是否有外部依赖(数据库、HTTP、文件系统)? +- [ ] Application 层是否直接操作数据库或调用外部 API? +- [ ] Controller 是否包含业务逻辑? +- [ ] 是否存在跨层调用(UI 直接调用 Repository)? + +**关注点分离检查:** + +- [ ] 业务逻辑是否与展示逻辑分离? +- [ ] 数据访问是否封装在专门的层? +- [ ] 配置和环境相关代码是否集中管理? + +### 审查问题 + +```markdown +🔴 [blocking] "Domain 实体直接导入了数据库连接,违反依赖规则" +🟡 [important] "Controller 包含业务计算逻辑,建议移到 Service 层" +💡 [suggestion] "考虑使用依赖注入来解耦这些组件" +``` + +--- + +## 设计模式使用评估 + +### 何时使用设计模式 + +| 模式 | 适用场景 | 不适用场景 | +| ------------- | ---------------------------------------- | ---------------------------- | +| **Factory** | 需要创建不同类型对象,类型在运行时确定 | 只有一种类型,或类型固定不变 | +| **Strategy** | 算法需要在运行时切换,有多种可互换的行为 | 只有一种算法,或算法不会变化 | +| **Observer** | 一对多依赖,状态变化需要通知多个对象 | 简单的直接调用即可满足需求 | +| **Singleton** | 确实需要全局唯一实例,如配置管理 | 可以通过依赖注入传递的对象 | +| **Decorator** | 需要动态添加职责,避免继承爆炸 | 职责固定,不需要动态组合 | + +### 过度设计警告信号 + +``` +⚠️ Patternitis(模式炎)识别信号: + +1. 简单的 if/else 被替换为策略模式 + 工厂 + 注册表 +2. 只有一个实现的接口 +3. 为了"将来可能需要"而添加的抽象层 +4. 代码行数因模式应用而大幅增加 +5. 新人需要很长时间才能理解代码结构 +``` + +### 审查原则 + +```markdown +✅ 正确使用模式: + +- 解决了实际的可扩展性问题 +- 代码更容易理解和测试 +- 添加新功能变得更简单 + +❌ 过度使用模式: + +- 为了使用模式而使用 +- 增加了不必要的复杂度 +- 违反了 YAGNI 原则 +``` + +### 审查问题 + +- "使用这个模式解决了什么具体问题?" +- "如果不用这个模式,代码会有什么问题?" +- "这个抽象层带来的价值是否大于它的复杂度?" + +--- + +## 可扩展性评估 + +### 扩展性检查清单 + +**功能扩展性:** + +- [ ] 添加新功能是否需要修改核心代码? +- [ ] 是否提供了扩展点(hooks、plugins、events)? +- [ ] 配置是否外部化(配置文件、环境变量)? + +**数据扩展性:** + +- [ ] 数据模型是否支持新增字段? +- [ ] 是否考虑了数据量增长的场景? +- [ ] 查询是否有合适的索引? + +**负载扩展性:** + +- [ ] 是否可以水平扩展(添加更多实例)? +- [ ] 是否有状态依赖(session、本地缓存)? +- [ ] 数据库连接是否使用连接池? + +### 扩展点设计检查 + +```typescript +// ✅ 好的扩展设计:使用事件/钩子 +class OrderService { + private hooks: OrderHooks; + + async createOrder(order: Order) { + await this.hooks.beforeCreate?.(order); + const result = await this.save(order); + await this.hooks.afterCreate?.(result); + return result; + } +} + +// ❌ 差的扩展设计:硬编码所有行为 +class OrderService { + async createOrder(order: Order) { + await this.sendEmail(order); // 硬编码 + await this.updateInventory(order); // 硬编码 + await this.notifyWarehouse(order); // 硬编码 + return await this.save(order); + } +} +``` + +### 审查问题 + +```markdown +💡 [suggestion] "如果将来需要支持新的支付方式,这个设计是否容易扩展?" +🟡 [important] "这里的逻辑是硬编码的,考虑使用配置或策略模式?" +📚 [learning] "事件驱动架构可以让这个功能更容易扩展" +``` + +--- + +## 代码结构最佳实践 + +### 目录组织 + +**按功能/领域组织(推荐):** + +``` +src/ +├── user/ +│ ├── User.ts (实体) +│ ├── UserService.ts (服务) +│ ├── UserRepository.ts (数据访问) +│ └── UserController.ts (API) +├── order/ +│ ├── Order.ts +│ ├── OrderService.ts +│ └── ... +└── shared/ + ├── utils/ + └── types/ +``` + +**按技术层组织(不推荐):** + +``` +src/ +├── controllers/ ← 不同领域混在一起 +│ ├── UserController.ts +│ └── OrderController.ts +├── services/ +├── repositories/ +└── models/ +``` + +### 命名约定检查 + +| 类型 | 约定 | 示例 | +| -------- | ---------------- | -------------------------------- | +| 类名 | PascalCase,名词 | `UserService`, `OrderRepository` | +| 方法名 | camelCase,动词 | `createUser`, `findOrderById` | +| 接口名 | I 前缀或无前缀 | `IUserService` 或 `UserService` | +| 常量 | UPPER_SNAKE_CASE | `MAX_RETRY_COUNT` | +| 私有属性 | 下划线前缀或无 | `_cache` 或 `#cache` | + +### 文件大小指南 + +```yaml +建议限制: + 单个文件: < 300 行 + 单个函数: < 50 行 + 单个类: < 200 行 + 函数参数: < 4 个 + 嵌套深度: < 4 层 + +超出限制时: + - 考虑拆分为更小的单元 + - 使用组合而非继承 + - 提取辅助函数或类 +``` + +### 审查问题 + +```markdown +🟢 [nit] "这个 500 行的文件可以考虑按职责拆分" +🟡 [important] "建议按功能领域而非技术层组织目录结构" +💡 [suggestion] "函数名 `process` 不够明确,考虑改为 `calculateOrderTotal`?" +``` + +--- + +## 快速参考清单 + +### 架构审查 5 分钟速查 + +```markdown +□ 依赖方向是否正确?(外层依赖内层) +□ 是否存在循环依赖? +□ 核心业务逻辑是否与框架/UI/数据库解耦? +□ 是否遵循 SOLID 原则? +□ 是否存在明显的反模式? +``` + +### 红旗信号(必须处理) + +```markdown +🔴 God Object - 单个类超过 1000 行 +🔴 循环依赖 - A → B → C → A +🔴 Domain 层包含框架依赖 +🔴 硬编码的配置和密钥 +🔴 没有接口的外部服务调用 +``` + +### 黄旗信号(建议处理) + +```markdown +🟡 类间耦合度 (CBO) > 10 +🟡 方法参数超过 5 个 +🟡 嵌套深度超过 4 层 +🟡 重复代码块 > 10 行 +🟡 只有一个实现的接口 +``` + +--- + +## 工具推荐 + +| 工具 | 用途 | 语言支持 | +| ------------- | -------------------- | --------------------- | +| **SonarQube** | 代码质量、耦合度分析 | 多语言 | +| **NDepend** | 依赖分析、架构规则 | .NET | +| **JDepend** | 包依赖分析 | Java | +| **Madge** | 模块依赖图 | JavaScript/TypeScript | +| **ESLint** | 代码规范、复杂度检查 | JavaScript/TypeScript | +| **CodeScene** | 技术债务、热点分析 | 多语言 | + +--- + +## 参考资源 + +- [Clean Architecture - Uncle Bob](https://blog.cleancoder.com/uncle-bob/2012/08/13/the-clean-architecture.html) +- [SOLID Principles in Code Review - JetBrains](https://blog.jetbrains.com/upsource/2015/08/31/what-to-look-for-in-a-code-review-solid-principles-2/) +- [Software Architecture Anti-Patterns](https://medium.com/@christophnissle/anti-patterns-in-software-architecture-3c8970c9c4f5) +- [Coupling and Cohesion in System Design](https://www.geeksforgeeks.org/system-design/coupling-and-cohesion-in-system-design/) +- [Design Patterns - Refactoring Guru](https://refactoring.guru/design-patterns) diff --git a/packages/mosaic/framework/skills/code-review-excellence/reference/c.md b/packages/mosaic/framework/skills/code-review-excellence/reference/c.md new file mode 100644 index 00000000..7ec3913d --- /dev/null +++ b/packages/mosaic/framework/skills/code-review-excellence/reference/c.md @@ -0,0 +1,289 @@ +# C Code Review Guide + +> C code review guide focused on memory safety, undefined behavior, and portability. Examples assume C11. + +## Table of Contents + +- [Pointer and Buffer Safety](#pointer-and-buffer-safety) +- [Ownership and Resource Management](#ownership-and-resource-management) +- [Undefined Behavior Pitfalls](#undefined-behavior-pitfalls) +- [Integer Types and Overflow](#integer-types-and-overflow) +- [Error Handling](#error-handling) +- [Concurrency](#concurrency) +- [Macros and Preprocessor](#macros-and-preprocessor) +- [API Design and Const](#api-design-and-const) +- [Tooling and Build Checks](#tooling-and-build-checks) +- [Review Checklist](#review-checklist) + +--- + +## Pointer and Buffer Safety + +### Always carry size with buffers + +```c +// ? Bad: ignores destination size +bool copy_name(char *dst, size_t dst_size, const char *src) { + strcpy(dst, src); + return true; +} + +// ? Good: validate size and terminate +bool copy_name(char *dst, size_t dst_size, const char *src) { + size_t len = strlen(src); + if (len + 1 > dst_size) { + return false; + } + memcpy(dst, src, len + 1); + return true; +} +``` + +### Avoid dangerous APIs + +Prefer `snprintf`, `fgets`, and explicit bounds over `gets`, `strcpy`, or `sprintf`. + +```c +// ? Bad: unbounded write +sprintf(buf, "%s", input); + +// ? Good: bounded write +snprintf(buf, buf_size, "%s", input); +``` + +### Use the right copy primitive + +```c +// ? Bad: memcpy with overlapping regions +memcpy(dst, src, len); + +// ? Good: memmove handles overlap +memmove(dst, src, len); +``` + +--- + +## Ownership and Resource Management + +### One allocation, one free + +Track ownership and clean up on every error path. + +```c +// ? Good: cleanup label avoids leaks +int load_file(const char *path) { + int rc = -1; + FILE *f = NULL; + char *buf = NULL; + + f = fopen(path, "rb"); + if (!f) { + goto cleanup; + } + buf = malloc(4096); + if (!buf) { + goto cleanup; + } + + if (fread(buf, 1, 4096, f) == 0) { + goto cleanup; + } + + rc = 0; + +cleanup: + free(buf); + if (f) { + fclose(f); + } + return rc; +} +``` + +--- + +## Undefined Behavior Pitfalls + +### Common UB patterns + +```c +// ? Bad: use after free +char *p = malloc(10); +free(p); +p[0] = 'a'; + +// ? Bad: uninitialized read +int x; +if (x > 0) { /* UB */ } + +// ? Bad: signed overflow +int sum = a + b; +``` + +### Avoid pointer arithmetic past the object + +```c +// ? Bad: pointer past the end then dereference +int arr[4]; +int *p = arr + 4; +int v = *p; // UB +``` + +--- + +## Integer Types and Overflow + +### Avoid signed/unsigned surprises + +```c +// ? Bad: negative converted to large size_t +int len = -1; +size_t n = len; + +// ? Good: validate before converting +if (len < 0) { + return -1; +} +size_t n = (size_t)len; +``` + +### Check for overflow in size calculations + +```c +// ? Bad: potential overflow in multiplication +size_t bytes = count * sizeof(Item); + +// ? Good: check before multiplying +if (count > SIZE_MAX / sizeof(Item)) { + return NULL; +} +size_t bytes = count * sizeof(Item); +``` + +--- + +## Error Handling + +### Always check return values + +```c +// ? Bad: ignore errors +fread(buf, 1, size, f); + +// ? Good: handle errors +size_t read = fread(buf, 1, size, f); +if (read != size && ferror(f)) { + return -1; +} +``` + +### Consistent error contracts + +- Use a clear convention: 0 for success, negative for failure. +- Document ownership rules on success and failure. +- If using `errno`, set it only for actual failures. + +--- + +## Concurrency + +### volatile is not synchronization + +```c +// ? Bad: data race +volatile int stop = 0; +void worker(void) { + while (!stop) { /* ... */ } +} + +// ? Good: C11 atomics +_Atomic int stop = 0; +void worker(void) { + while (!atomic_load(&stop)) { /* ... */ } +} +``` + +### Use mutexes for shared state + +Protect shared data with `pthread_mutex_t` or equivalent. Avoid holding locks while doing I/O. + +--- + +## Macros and Preprocessor + +### Parenthesize arguments + +```c +// ? Bad: macro with side effects +#define MIN(a, b) ((a) < (b) ? (a) : (b)) +int x = MIN(i++, j++); + +// ? Good: static inline function +static inline int min_int(int a, int b) { + return a < b ? a : b; +} +``` + +--- + +## API Design and Const + +### Const-correctness and sizes + +```c +// ? Good: explicit size and const input +int hash_bytes(const uint8_t *data, size_t len, uint8_t *out); +``` + +### Document nullability + +Clearly document whether pointers may be NULL. Prefer returning error codes instead of NULL when possible. + +--- + +## Tooling and Build Checks + +```bash +# Warnings +clang -Wall -Wextra -Werror -Wconversion -Wshadow -std=c11 ... + +# Sanitizers (debug builds) +clang -fsanitize=address,undefined -fno-omit-frame-pointer -g ... +clang -fsanitize=thread -fno-omit-frame-pointer -g ... + +# Static analysis +clang-tidy src/*.c -- -std=c11 +cppcheck --enable=warning,performance,portability src/ + +# Formatting +clang-format -i src/*.c include/*.h +``` + +--- + +## Review Checklist + +### Memory and UB + +- [ ] All buffers have explicit size parameters +- [ ] No out-of-bounds access or pointer arithmetic past objects +- [ ] No use after free or uninitialized reads +- [ ] Signed overflow and shift rules are respected + +### API and Design + +- [ ] Ownership rules are documented and consistent +- [ ] const-correctness is applied for inputs +- [ ] Error contracts are clear and consistent + +### Concurrency + +- [ ] No data races on shared state +- [ ] volatile is not used for synchronization +- [ ] Locks are held for minimal time + +### Tooling and Tests + +- [ ] Builds clean with warnings enabled +- [ ] Sanitizers run on critical code paths +- [ ] Static analysis results are addressed diff --git a/packages/mosaic/framework/skills/code-review-excellence/reference/code-review-best-practices.md b/packages/mosaic/framework/skills/code-review-excellence/reference/code-review-best-practices.md new file mode 100644 index 00000000..2bb21c99 --- /dev/null +++ b/packages/mosaic/framework/skills/code-review-excellence/reference/code-review-best-practices.md @@ -0,0 +1,150 @@ +# Code Review Best Practices + +Comprehensive guidelines for conducting effective code reviews. + +## Review Philosophy + +### Goals of Code Review + +**Primary Goals:** + +- Catch bugs and edge cases before production +- Ensure code maintainability and readability +- Share knowledge across the team +- Enforce coding standards consistently +- Improve design and architecture decisions + +**Secondary Goals:** + +- Mentor junior developers +- Build team culture and trust +- Document design decisions through discussions + +### What Code Review is NOT + +- A gatekeeping mechanism to block progress +- An opportunity to show off knowledge +- A place to nitpick formatting (use linters) +- A way to rewrite code to personal preference + +## Review Timing + +### When to Review + +| Trigger | Action | +| -------------------- | ---------------------------------------- | +| PR opened | Review within 24 hours, ideally same day | +| Changes requested | Re-review within 4 hours | +| Blocking issue found | Communicate immediately | + +### Time Allocation + +- **Small PR (<100 lines)**: 10-15 minutes +- **Medium PR (100-400 lines)**: 20-40 minutes +- **Large PR (>400 lines)**: Request to split, or 60+ minutes + +## Review Depth Levels + +### Level 1: Skim Review (5 minutes) + +- Check PR description and linked issues +- Verify CI/CD status +- Look at file changes overview +- Identify if deeper review needed + +### Level 2: Standard Review (20-30 minutes) + +- Full code walkthrough +- Logic verification +- Test coverage check +- Security scan + +### Level 3: Deep Review (60+ minutes) + +- Architecture evaluation +- Performance analysis +- Security audit +- Edge case exploration + +## Communication Guidelines + +### Tone and Language + +**Use collaborative language:** + +- "What do you think about..." instead of "You should..." +- "Could we consider..." instead of "This is wrong" +- "I'm curious about..." instead of "Why didn't you..." + +**Be specific and actionable:** + +- Include code examples when suggesting changes +- Link to documentation or past discussions +- Explain the "why" behind suggestions + +### Handling Disagreements + +1. **Seek to understand**: Ask clarifying questions +2. **Acknowledge valid points**: Show you've considered their perspective +3. **Provide data**: Use benchmarks, docs, or examples +4. **Escalate if needed**: Involve senior dev or architect +5. **Know when to let go**: Not every hill is worth dying on + +## Review Prioritization + +### Must Fix (Blocking) + +- Security vulnerabilities +- Data corruption risks +- Breaking changes without migration +- Critical performance issues +- Missing error handling for user-facing features + +### Should Fix (Important) + +- Test coverage gaps +- Moderate performance concerns +- Code duplication +- Unclear naming or structure +- Missing documentation for complex logic + +### Nice to Have (Non-blocking) + +- Style preferences beyond linting +- Minor optimizations +- Additional test cases +- Documentation improvements + +## Anti-Patterns to Avoid + +### Reviewer Anti-Patterns + +- **Rubber stamping**: Approving without actually reviewing +- **Bike shedding**: Debating trivial details extensively +- **Scope creep**: "While you're at it, can you also..." +- **Ghosting**: Requesting changes then disappearing +- **Perfectionism**: Blocking for minor style preferences + +### Author Anti-Patterns + +- **Mega PRs**: Submitting 1000+ line changes +- **No context**: Missing PR description or linked issues +- **Defensive responses**: Arguing every suggestion +- **Silent updates**: Making changes without responding to comments + +## Metrics and Improvement + +### Track These Metrics + +- Time to first review +- Review cycle time +- Number of review rounds +- Defect escape rate +- Review coverage percentage + +### Continuous Improvement + +- Hold retrospectives on review process +- Share learnings from escaped bugs +- Update checklists based on common issues +- Celebrate good reviews and catches diff --git a/packages/mosaic/framework/skills/code-review-excellence/reference/common-bugs-checklist.md b/packages/mosaic/framework/skills/code-review-excellence/reference/common-bugs-checklist.md new file mode 100644 index 00000000..43b73f6e --- /dev/null +++ b/packages/mosaic/framework/skills/code-review-excellence/reference/common-bugs-checklist.md @@ -0,0 +1,1278 @@ +# Common Bugs Checklist + +Language-specific bugs and issues to watch for during code review. + +## Universal Issues + +### Logic Errors + +- [ ] Off-by-one errors in loops and array access +- [ ] Incorrect boolean logic (De Morgan's law violations) +- [ ] Missing null/undefined checks +- [ ] Race conditions in concurrent code +- [ ] Incorrect comparison operators (== vs ===, = vs ==) +- [ ] Integer overflow/underflow +- [ ] Floating point comparison issues + +### Resource Management + +- [ ] Memory leaks (unclosed connections, listeners) +- [ ] File handles not closed +- [ ] Database connections not released +- [ ] Event listeners not removed +- [ ] Timers/intervals not cleared + +### Error Handling + +- [ ] Swallowed exceptions (empty catch blocks) +- [ ] Generic exception handling hiding specific errors +- [ ] Missing error propagation +- [ ] Incorrect error types thrown +- [ ] Missing finally/cleanup blocks + +## TypeScript/JavaScript + +### Type Issues + +```typescript +// ❌ Using any defeats type safety +function process(data: any) { + return data.value; +} + +// ✅ Use proper types +interface Data { + value: string; +} +function process(data: Data) { + return data.value; +} +``` + +### Async/Await Pitfalls + +```typescript +// ❌ Missing await +async function fetch() { + const data = fetchData(); // Missing await! + return data.json(); +} + +// ❌ Unhandled promise rejection +async function risky() { + const result = await fetchData(); // No try-catch + return result; +} + +// ✅ Proper error handling +async function safe() { + try { + const result = await fetchData(); + return result; + } catch (error) { + console.error('Fetch failed:', error); + throw error; + } +} +``` + +### React Specific + +#### Hooks 规则违反 + +```tsx +// ❌ 条件调用 Hooks — 违反 Hooks 规则 +function BadComponent({ show }) { + if (show) { + const [value, setValue] = useState(0); // Error! + } + return
...
; +} + +// ✅ Hooks 必须在顶层无条件调用 +function GoodComponent({ show }) { + const [value, setValue] = useState(0); + if (!show) return null; + return
{value}
; +} + +// ❌ 循环中调用 Hooks +function BadLoop({ items }) { + items.forEach((item) => { + const [selected, setSelected] = useState(false); // Error! + }); +} + +// ✅ 将状态提升或使用不同的数据结构 +function GoodLoop({ items }) { + const [selectedIds, setSelectedIds] = useState>(new Set()); + return items.map((item) => ); +} +``` + +#### useEffect 常见错误 + +```tsx +// ❌ 依赖数组不完整 — stale closure +function StaleClosureExample({ userId, onSuccess }) { + const [data, setData] = useState(null); + useEffect(() => { + fetchData(userId).then((result) => { + setData(result); + onSuccess(result); // onSuccess 可能是 stale 的! + }); + }, [userId]); // 缺少 onSuccess 依赖 +} + +// ✅ 完整的依赖数组 +useEffect(() => { + fetchData(userId).then((result) => { + setData(result); + onSuccess(result); + }); +}, [userId, onSuccess]); + +// ❌ 无限循环 — 在 effect 中更新依赖 +function InfiniteLoop() { + const [count, setCount] = useState(0); + useEffect(() => { + setCount(count + 1); // 触发重渲染,又触发 effect + }, [count]); // 无限循环! +} + +// ❌ 缺少清理函数 — 内存泄漏 +function MemoryLeak({ userId }) { + const [user, setUser] = useState(null); + useEffect(() => { + fetchUser(userId).then(setUser); // 组件卸载后仍然调用 setUser + }, [userId]); +} + +// ✅ 正确的清理 +function NoLeak({ userId }) { + const [user, setUser] = useState(null); + useEffect(() => { + let cancelled = false; + fetchUser(userId).then((data) => { + if (!cancelled) setUser(data); + }); + return () => { + cancelled = true; + }; + }, [userId]); +} + +// ❌ useEffect 用于派生状态(反模式) +function BadDerived({ items }) { + const [total, setTotal] = useState(0); + useEffect(() => { + setTotal(items.reduce((a, b) => a + b.price, 0)); + }, [items]); // 不必要的 effect + 额外渲染 +} + +// ✅ 直接计算或用 useMemo +function GoodDerived({ items }) { + const total = useMemo(() => items.reduce((a, b) => a + b.price, 0), [items]); +} + +// ❌ useEffect 用于事件响应 +function BadEvent() { + const [query, setQuery] = useState(''); + useEffect(() => { + if (query) logSearch(query); // 应该在事件处理器中 + }, [query]); +} + +// ✅ 副作用在事件处理器中 +function GoodEvent() { + const handleSearch = (q: string) => { + setQuery(q); + logSearch(q); + }; +} +``` + +#### useMemo / useCallback 误用 + +```tsx +// ❌ 过度优化 — 常量不需要 memo +function OverOptimized() { + const config = useMemo(() => ({ api: '/v1' }), []); // 无意义 + const noop = useCallback(() => {}, []); // 无意义 +} + +// ❌ 空依赖的 useMemo(可能隐藏 bug) +function EmptyDeps({ user }) { + const greeting = useMemo(() => `Hello ${user.name}`, []); + // user 变化时 greeting 不更新! +} + +// ❌ useCallback 依赖总是变化 +function UselessCallback({ data }) { + const process = useCallback(() => { + return data.map(transform); + }, [data]); // 如果 data 每次都是新引用,完全无效 +} + +// ❌ useMemo/useCallback 没有配合 React.memo +function Parent() { + const data = useMemo(() => compute(), []); + const handler = useCallback(() => {}, []); + return ; + // Child 没有用 React.memo,这些优化毫无意义 +} + +// ✅ 正确的优化组合 +const MemoChild = React.memo(function Child({ data, onClick }) { + return ; +}); + +function Parent() { + const data = useMemo(() => expensiveCompute(), [dep]); + const handler = useCallback(() => {}, []); + return ; +} +``` + +#### 组件设计问题 + +```tsx +// ❌ 在组件内定义组件 +function Parent() { + // 每次渲染都创建新的 Child 函数,导致完全重新挂载 + const Child = () =>
child
; + return ; +} + +// ✅ 组件定义在外部 +const Child = () =>
child
; +function Parent() { + return ; +} + +// ❌ Props 总是新引用 — 破坏 memo +function BadProps() { + return ( + handle()} // 每次渲染新函数 + items={data.filter((x) => x)} // 每次渲染新数组 + /> + ); +} + +// ❌ 直接修改 props +function MutateProps({ user }) { + user.name = 'Changed'; // 永远不要这样做! + return
{user.name}
; +} +``` + +#### Server Components 错误 (React 19+) + +```tsx +// ❌ 在 Server Component 中使用客户端 API +// app/page.tsx (默认是 Server Component) +export default function Page() { + const [count, setCount] = useState(0); // Error! + useEffect(() => {}, []); // Error! + return ; // Error! +} + +// ✅ 交互逻辑移到 Client Component +// app/counter.tsx +'use client'; +export function Counter() { + const [count, setCount] = useState(0); + return ; +} + +// app/page.tsx +import { Counter } from './counter'; +export default async function Page() { + const data = await fetchData(); // Server Component 可以直接 await + return ; +} + +// ❌ 在父组件标记 'use client',整个子树变成客户端 +// layout.tsx +'use client'; // 坏主意!所有子组件都变成客户端组件 +export default function Layout({ children }) { ... } +``` + +#### 测试常见错误 + +```tsx +// ❌ 使用 container 查询 +const { container } = render(); +const button = container.querySelector('button'); // 不推荐 + +// ✅ 使用 screen 和语义查询 +render(); +const button = screen.getByRole('button', { name: /submit/i }); + +// ❌ 使用 fireEvent +fireEvent.click(button); + +// ✅ 使用 userEvent +await userEvent.click(button); + +// ❌ 测试实现细节 +expect(component.state.isOpen).toBe(true); + +// ✅ 测试行为 +expect(screen.getByRole('dialog')).toBeVisible(); + +// ❌ 等待同步查询 +await screen.getByText('Hello'); // getBy 是同步的 + +// ✅ 异步用 findBy +await screen.findByText('Hello'); // findBy 会等待 +``` + +### React Common Mistakes Checklist + +- [ ] Hooks 不在顶层调用(条件/循环中) +- [ ] useEffect 依赖数组不完整 +- [ ] useEffect 缺少清理函数 +- [ ] useEffect 用于派生状态计算 +- [ ] useMemo/useCallback 过度使用 +- [ ] useMemo/useCallback 没配合 React.memo +- [ ] 在组件内定义子组件 +- [ ] Props 是新对象/函数引用(传给 memo 组件时) +- [ ] 直接修改 props +- [ ] 列表缺少 key 或用 index 作为 key +- [ ] Server Component 使用客户端 API +- [ ] 'use client' 放在父组件导致整个树客户端化 +- [ ] 测试使用 container 查询而非 screen +- [ ] 测试实现细节而非行为 + +### React 19 Actions & Forms 错误 + +```tsx +// === useActionState 错误 === + +// ❌ 在 Action 中直接 setState 而不是返回状态 +const [state, action] = useActionState(async (prev, formData) => { + setSomeState(newValue); // 错误!应该返回新状态 +}, initialState); + +// ✅ 返回新状态 +const [state, action] = useActionState(async (prev, formData) => { + const result = await submitForm(formData); + return { ...prev, data: result }; // 返回新状态 +}, initialState); + +// ❌ 忘记处理 isPending +const [state, action] = useActionState(submitAction, null); +return ; // 用户可以重复点击 + +// ✅ 使用 isPending 禁用按钮 +const [state, action, isPending] = useActionState(submitAction, null); +return ; + +// === useFormStatus 错误 === + +// ❌ 在 form 同级调用 useFormStatus +function Form() { + const { pending } = useFormStatus(); // 永远是 undefined! + return ( +
+ +
+ ); +} + +// ✅ 在子组件中调用 +function SubmitButton() { + const { pending } = useFormStatus(); + return ; +} +function Form() { + return ( +
+ + + ); +} + +// === useOptimistic 错误 === + +// ❌ 用于关键业务操作 +function PaymentButton() { + const [optimisticPaid, setPaid] = useOptimistic(false); + const handlePay = async () => { + setPaid(true); // 危险:显示已支付但可能失败 + await processPayment(); + }; +} + +// ❌ 没有处理回滚后的 UI 状态 +const [optimisticLikes, addLike] = useOptimistic(likes); +// 失败后 UI 回滚,但用户可能困惑为什么点赞消失了 + +// ✅ 提供失败反馈 +const handleLike = async () => { + addLike(1); + try { + await likePost(); + } catch { + toast.error('点赞失败,请重试'); // 通知用户 + } +}; +``` + +### React 19 Forms Checklist + +- [ ] useActionState 返回新状态而不是 setState +- [ ] useActionState 正确使用 isPending 禁用提交 +- [ ] useFormStatus 在 form 子组件中调用 +- [ ] useOptimistic 不用于关键业务(支付、删除等) +- [ ] useOptimistic 失败时有用户反馈 +- [ ] Server Action 正确标记 'use server' + +### Suspense & Streaming 错误 + +```tsx +// === Suspense 边界错误 === + +// ❌ 整个页面一个 Suspense——慢内容阻塞快内容 +function BadPage() { + return ( + }> + {/* 快 */} + {/* 慢——阻塞整个页面 */} + {/* 快 */} + + ); +} + +// ✅ 独立边界,互不阻塞 +function GoodPage() { + return ( + <> + + }> + + + + + ); +} + +// ❌ 没有 Error Boundary +function NoErrorHandling() { + return ( + }> + {/* 抛错导致白屏 */} + + ); +} + +// ✅ Error Boundary + Suspense +function WithErrorHandling() { + return ( + }> + }> + + + + ); +} + +// === use() Hook 错误 === + +// ❌ 在组件外创建 Promise(每次渲染新 Promise) +function BadUse() { + const data = use(fetchData()); // 每次渲染都创建新 Promise! + return
{data}
; +} + +// ✅ 在父组件创建,通过 props 传递 +function Parent() { + const dataPromise = useMemo(() => fetchData(), []); + return ; +} +function Child({ dataPromise }) { + const data = use(dataPromise); + return
{data}
; +} + +// === Next.js Streaming 错误 === + +// ❌ 在 layout.tsx 中 await 慢数据——阻塞所有子页面 +// app/layout.tsx +export default async function Layout({ children }) { + const config = await fetchSlowConfig(); // 阻塞整个应用! + return {children}; +} + +// ✅ 将慢数据放在页面级别或使用 Suspense +// app/layout.tsx +export default function Layout({ children }) { + return ( + }> + {children} + + ); +} +``` + +### Suspense Checklist + +- [ ] 慢内容有独立的 Suspense 边界 +- [ ] 每个 Suspense 有对应的 Error Boundary +- [ ] fallback 是有意义的骨架屏(不是简单 spinner) +- [ ] use() 的 Promise 不在渲染时创建 +- [ ] 没有在 layout 中 await 慢数据 +- [ ] 嵌套层级不超过 3 层 + +### TanStack Query 错误 + +```tsx +// === 查询配置错误 === + +// ❌ queryKey 不包含查询参数 +function BadQuery({ userId, filters }) { + const { data } = useQuery({ + queryKey: ['users'], // 缺少 userId 和 filters! + queryFn: () => fetchUsers(userId, filters), + }); + // userId 或 filters 变化时数据不会更新 +} + +// ✅ queryKey 包含所有影响数据的参数 +function GoodQuery({ userId, filters }) { + const { data } = useQuery({ + queryKey: ['users', userId, filters], + queryFn: () => fetchUsers(userId, filters), + }); +} + +// ❌ staleTime: 0 导致过度请求 +const { data } = useQuery({ + queryKey: ['data'], + queryFn: fetchData, + // 默认 staleTime: 0,每次组件挂载/窗口聚焦都会 refetch +}); + +// ✅ 设置合理的 staleTime +const { data } = useQuery({ + queryKey: ['data'], + queryFn: fetchData, + staleTime: 5 * 60 * 1000, // 5 分钟内不会自动 refetch +}); + +// === useSuspenseQuery 错误 === + +// ❌ useSuspenseQuery + enabled(不支持) +const { data } = useSuspenseQuery({ + queryKey: ['user', userId], + queryFn: () => fetchUser(userId), + enabled: !!userId, // 错误!useSuspenseQuery 不支持 enabled +}); + +// ✅ 条件渲染实现 +function UserQuery({ userId }) { + const { data } = useSuspenseQuery({ + queryKey: ['user', userId], + queryFn: () => fetchUser(userId), + }); + return ; +} + +function Parent({ userId }) { + if (!userId) return ; + return ( + }> + + + ); +} + +// === Mutation 错误 === + +// ❌ Mutation 成功后不 invalidate 查询 +const mutation = useMutation({ + mutationFn: updateUser, + // 忘记 invalidate,UI 显示旧数据 +}); + +// ✅ 成功后 invalidate 相关查询 +const mutation = useMutation({ + mutationFn: updateUser, + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['users'] }); + }, +}); + +// ❌ 乐观更新不处理回滚 +const mutation = useMutation({ + mutationFn: updateTodo, + onMutate: async (newTodo) => { + queryClient.setQueryData(['todos'], (old) => [...old, newTodo]); + // 没有保存旧数据,失败后无法回滚! + }, +}); + +// ✅ 完整的乐观更新 +const mutation = useMutation({ + mutationFn: updateTodo, + onMutate: async (newTodo) => { + await queryClient.cancelQueries({ queryKey: ['todos'] }); + const previous = queryClient.getQueryData(['todos']); + queryClient.setQueryData(['todos'], (old) => [...old, newTodo]); + return { previous }; + }, + onError: (err, newTodo, context) => { + queryClient.setQueryData(['todos'], context.previous); + }, + onSettled: () => { + queryClient.invalidateQueries({ queryKey: ['todos'] }); + }, +}); + +// === v5 迁移错误 === + +// ❌ 使用废弃的 API +const { data, isLoading } = useQuery(['key'], fetchFn); // v4 语法 + +// ✅ v5 单一对象参数 +const { data, isPending } = useQuery({ + queryKey: ['key'], + queryFn: fetchFn, +}); + +// ❌ 混淆 isPending 和 isLoading +if (isLoading) return ; +// v5 中 isLoading = isPending && isFetching + +// ✅ 根据意图选择 +if (isPending) return ; // 没有缓存数据 +// 或 +if (isFetching) return ; // 正在后台刷新 +``` + +### TanStack Query Checklist + +- [ ] queryKey 包含所有影响数据的参数 +- [ ] 设置了合理的 staleTime(不是默认 0) +- [ ] useSuspenseQuery 不使用 enabled +- [ ] Mutation 成功后 invalidate 相关查询 +- [ ] 乐观更新有完整的回滚逻辑 +- [ ] v5 使用单一对象参数语法 +- [ ] 理解 isPending vs isLoading vs isFetching + +### TypeScript/JavaScript Common Mistakes + +- [ ] `==` instead of `===` +- [ ] Modifying array/object during iteration +- [ ] `this` context lost in callbacks +- [ ] Missing `key` prop in lists +- [ ] Closure capturing loop variable +- [ ] parseInt without radix parameter + +## Vue 3 + +### 响应性丢失 + +```vue + + + + + +``` + +### Props 响应性传递 + +```vue + + + + + +``` + +### Watch 清理 + +```vue + + + + + +``` + +### Computed 副作用 + +```vue + + + + + +``` + +### 模板常见错误 + +```vue + + + + + +``` + +### Common Mistakes + +- [ ] 解构 reactive 对象丢失响应性 +- [ ] props 传递给 composable 时未保持响应性 +- [ ] watch 异步回调无清理函数 +- [ ] computed 中产生副作用 +- [ ] v-for 使用 index 作为 key(列表会重排时) +- [ ] v-if 和 v-for 在同一元素上 +- [ ] defineProps 未使用 TypeScript 类型声明 +- [ ] withDefaults 对象默认值未使用工厂函数 +- [ ] 直接修改 props(而不是 emit) +- [ ] watchEffect 依赖不明确导致过度触发 + +## Python + +### Mutable Default Arguments + +```python +# ❌ Bug: List shared across all calls +def add_item(item, items=[]): + items.append(item) + return items + +# ✅ Correct +def add_item(item, items=None): + if items is None: + items = [] + items.append(item) + return items +``` + +### Exception Handling + +```python +# ❌ Catching everything, including KeyboardInterrupt +try: + risky_operation() +except: + pass + +# ✅ Catch specific exceptions +try: + risky_operation() +except ValueError as e: + logger.error(f"Invalid value: {e}") + raise +``` + +### Class Attributes + +```python +# ❌ Shared mutable class attribute +class User: + permissions = [] # Shared across all instances! + +# ✅ Initialize in __init__ +class User: + def __init__(self): + self.permissions = [] +``` + +### Common Mistakes + +- [ ] Using `is` instead of `==` for value comparison +- [ ] Forgetting `self` parameter in methods +- [ ] Modifying list while iterating +- [ ] String concatenation in loops (use join) +- [ ] Not closing files (use `with` statement) + +## Rust + +### 所有权与借用 + +```rust +// ❌ Use after move +let s = String::from("hello"); +let s2 = s; +println!("{}", s); // Error: s was moved + +// ✅ Clone if needed (but consider if clone is necessary) +let s = String::from("hello"); +let s2 = s.clone(); +println!("{}", s); // OK + +// ❌ 用 clone() 绕过借用检查器(反模式) +fn process(data: &Data) { + let owned = data.clone(); // 不必要的 clone + do_something(owned); +} + +// ✅ 正确使用借用 +fn process(data: &Data) { + do_something(data); // 传递引用 +} + +// ❌ 在结构体中存储借用(通常是坏主意) +struct Parser<'a> { + input: &'a str, // 生命周期复杂化 + position: usize, +} + +// ✅ 使用拥有的数据 +struct Parser { + input: String, // 拥有数据,简化生命周期 + position: usize, +} + +// ❌ 迭代时修改集合 +let mut vec = vec![1, 2, 3]; +for item in &vec { + vec.push(*item); // Error: cannot borrow as mutable +} + +// ✅ 收集到新集合 +let vec = vec![1, 2, 3]; +let new_vec: Vec<_> = vec.iter().map(|x| x * 2).collect(); +``` + +### Unsafe 代码审查 + +```rust +// ❌ unsafe 没有安全注释 +unsafe { + ptr::write(dest, value); +} + +// ✅ 必须有 SAFETY 注释说明不变量 +// SAFETY: dest 指针由 Vec::as_mut_ptr() 获得,保证: +// 1. 指针有效且已对齐 +// 2. 目标内存未被其他引用借用 +// 3. 写入不会超出分配的容量 +unsafe { + ptr::write(dest, value); +} + +// ❌ unsafe fn 没有 # Safety 文档 +pub unsafe fn from_raw_parts(ptr: *mut T, len: usize) -> Self { ... } + +// ✅ 必须文档化安全契约 +/// Creates a new instance from raw parts. +/// +/// # Safety +/// +/// - `ptr` must have been allocated via `GlobalAlloc` +/// - `len` must be less than or equal to the allocated capacity +/// - The caller must ensure no other references to the memory exist +pub unsafe fn from_raw_parts(ptr: *mut T, len: usize) -> Self { ... } + +// ❌ 跨模块 unsafe 不变量 +mod a { + pub fn set_flag() { FLAG = true; } // 安全代码影响 unsafe +} +mod b { + pub unsafe fn do_thing() { + if FLAG { /* assumes FLAG means something */ } + } +} + +// ✅ 将 unsafe 边界封装在单一模块 +mod safe_wrapper { + // 所有 unsafe 逻辑在一个模块内 + // 对外提供 safe API +} +``` + +### 异步/并发 + +```rust +// ❌ 在异步上下文中阻塞 +async fn bad_fetch(url: &str) -> Result { + let resp = reqwest::blocking::get(url)?; // 阻塞整个运行时! + Ok(resp.text()?) +} + +// ✅ 使用异步版本 +async fn good_fetch(url: &str) -> Result { + let resp = reqwest::get(url).await?; + Ok(resp.text().await?) +} + +// ❌ 跨 .await 持有 Mutex +async fn bad_lock(mutex: &Mutex) { + let guard = mutex.lock().unwrap(); + some_async_op().await; // 持锁跨越 await! + drop(guard); +} + +// ✅ 缩短锁持有时间 +async fn good_lock(mutex: &Mutex) { + let data = { + let guard = mutex.lock().unwrap(); + guard.clone() // 获取数据后立即释放锁 + }; + some_async_op().await; + // 处理 data +} + +// ❌ 在异步函数中使用 std::sync::Mutex +async fn bad_async_mutex(mutex: &std::sync::Mutex) { + let _guard = mutex.lock().unwrap(); // 可能死锁 + tokio::time::sleep(Duration::from_secs(1)).await; +} + +// ✅ 使用 tokio::sync::Mutex(如果必须跨 await) +async fn good_async_mutex(mutex: &tokio::sync::Mutex) { + let _guard = mutex.lock().await; + tokio::time::sleep(Duration::from_secs(1)).await; +} + +// ❌ 忘记 Future 是惰性的 +fn bad_spawn() { + let future = async_operation(); // 没有执行! + // future 被丢弃,什么都没发生 +} + +// ✅ 必须 await 或 spawn +async fn good_spawn() { + async_operation().await; // 执行 + // 或 + tokio::spawn(async_operation()); // 后台执行 +} + +// ❌ spawn 任务缺少 'static +async fn bad_spawn_lifetime(data: &str) { + tokio::spawn(async { + println!("{}", data); // Error: data 不是 'static + }); +} + +// ✅ 使用 move 或 Arc +async fn good_spawn_lifetime(data: String) { + tokio::spawn(async move { + println!("{}", data); // OK: 拥有数据 + }); +} +``` + +### 错误处理 + +```rust +// ❌ 生产代码中使用 unwrap/expect +fn bad_parse(input: &str) -> i32 { + input.parse().unwrap() // panic! +} + +// ✅ 正确传播错误 +fn good_parse(input: &str) -> Result { + input.parse() +} + +// ❌ 吞掉错误信息 +fn bad_error_handling() -> Result<()> { + match operation() { + Ok(v) => Ok(v), + Err(_) => Err(anyhow!("operation failed")) // 丢失原始错误 + } +} + +// ✅ 使用 context 添加上下文 +fn good_error_handling() -> Result<()> { + operation().context("failed to perform operation")?; + Ok(()) +} + +// ❌ 库代码使用 anyhow(应该用 thiserror) +// lib.rs +pub fn parse_config(path: &str) -> anyhow::Result { + // 调用者无法区分错误类型 +} + +// ✅ 库代码用 thiserror 定义错误类型 +#[derive(Debug, thiserror::Error)] +pub enum ConfigError { + #[error("failed to read config file: {0}")] + Io(#[from] std::io::Error), + #[error("invalid config format: {0}")] + Parse(#[from] serde_json::Error), +} + +pub fn parse_config(path: &str) -> Result { + // 调用者可以 match 不同错误 +} + +// ❌ 忽略 must_use 返回值 +fn bad_ignore_result() { + some_fallible_operation(); // 警告:unused Result +} + +// ✅ 显式处理或标记忽略 +fn good_handle_result() { + let _ = some_fallible_operation(); // 显式忽略 + // 或 + some_fallible_operation().ok(); // 转换为 Option +} +``` + +### 性能陷阱 + +```rust +// ❌ 不必要的 collect +fn bad_process(items: &[i32]) -> i32 { + items.iter() + .filter(|x| **x > 0) + .collect::>() // 不必要的分配 + .iter() + .sum() +} + +// ✅ 惰性迭代 +fn good_process(items: &[i32]) -> i32 { + items.iter() + .filter(|x| **x > 0) + .sum() +} + +// ❌ 循环中重复分配 +fn bad_loop() -> String { + let mut result = String::new(); + for i in 0..1000 { + result = result + &i.to_string(); // 每次迭代都重新分配! + } + result +} + +// ✅ 预分配或使用 push_str +fn good_loop() -> String { + let mut result = String::with_capacity(4000); // 预分配 + for i in 0..1000 { + write!(result, "{}", i).unwrap(); // 原地追加 + } + result +} + +// ❌ 过度使用 clone +fn bad_clone(data: &HashMap>) -> Vec { + data.get("key").cloned().unwrap_or_default() +} + +// ✅ 返回引用或使用 Cow +fn good_ref(data: &HashMap>) -> &[u8] { + data.get("key").map(|v| v.as_slice()).unwrap_or(&[]) +} + +// ❌ 大结构体按值传递 +fn bad_pass(data: LargeStruct) { ... } // 拷贝整个结构体 + +// ✅ 传递引用 +fn good_pass(data: &LargeStruct) { ... } + +// ❌ Box 用于小型已知类型 +fn bad_trait_object() -> Box> { + Box::new(vec![1, 2, 3].into_iter()) +} + +// ✅ 使用 impl Trait +fn good_impl_trait() -> impl Iterator { + vec![1, 2, 3].into_iter() +} + +// ❌ retain 比 filter+collect 慢(某些场景) +vec.retain(|x| x.is_valid()); // O(n) 但常数因子大 + +// ✅ 如果不需要原地修改,考虑 filter +let vec: Vec<_> = vec.into_iter().filter(|x| x.is_valid()).collect(); +``` + +### 生命周期与引用 + +```rust +// ❌ 返回局部变量的引用 +fn bad_return_ref() -> &str { + let s = String::from("hello"); + &s // Error: s will be dropped +} + +// ✅ 返回拥有的数据或静态引用 +fn good_return_owned() -> String { + String::from("hello") +} + +// ❌ 生命周期过度泛化 +fn bad_lifetime<'a, 'b>(x: &'a str, y: &'b str) -> &'a str { + x // 'b 没有被使用 +} + +// ✅ 简化生命周期 +fn good_lifetime(x: &str, _y: &str) -> &str { + x // 编译器自动推断 +} + +// ❌ 结构体持有多个相关引用但生命周期独立 +struct Bad<'a, 'b> { + name: &'a str, + data: &'b [u8], // 通常应该是同一个生命周期 +} + +// ✅ 相关数据使用相同生命周期 +struct Good<'a> { + name: &'a str, + data: &'a [u8], +} +``` + +### Rust 审查清单 + +**所有权与借用** + +- [ ] clone() 是有意为之,不是绕过借用检查器 +- [ ] 避免在结构体中存储借用(除非必要) +- [ ] Rc/Arc 使用合理,没有隐藏不必要的共享状态 +- [ ] 没有不必要的 RefCell(运行时检查 vs 编译时) + +**Unsafe 代码** + +- [ ] 每个 unsafe 块有 SAFETY 注释 +- [ ] unsafe fn 有 # Safety 文档 +- [ ] 安全不变量被清晰记录 +- [ ] unsafe 边界尽可能小 + +**异步/并发** + +- [ ] 没有在异步上下文中阻塞 +- [ ] 没有跨 .await 持有 std::sync 锁 +- [ ] spawn 的任务满足 'static 约束 +- [ ] Future 被正确 await 或 spawn +- [ ] 锁的顺序一致(避免死锁) + +**错误处理** + +- [ ] 库代码使用 thiserror,应用代码使用 anyhow +- [ ] 错误有足够的上下文信息 +- [ ] 没有在生产代码中 unwrap/expect +- [ ] must_use 返回值被正确处理 + +**性能** + +- [ ] 避免不必要的 collect() +- [ ] 大数据结构传引用 +- [ ] 字符串拼接使用 String::with_capacity 或 write! +- [ ] impl Trait 优于 Box(当可能时) + +**类型系统** + +- [ ] 善用 newtype 模式增加类型安全 +- [ ] 枚举穷尽匹配(没有 \_ 通配符隐藏新变体) +- [ ] 生命周期尽可能简化 + +## SQL + +### Injection Vulnerabilities + +```sql +-- ❌ String concatenation (SQL injection risk) +query = "SELECT * FROM users WHERE id = " + user_id + +-- ✅ Parameterized queries +query = "SELECT * FROM users WHERE id = ?" +cursor.execute(query, (user_id,)) +``` + +### Performance Issues + +- [ ] Missing indexes on filtered/joined columns +- [ ] SELECT \* instead of specific columns +- [ ] N+1 query patterns +- [ ] Missing LIMIT on large tables +- [ ] Inefficient subqueries vs JOINs + +### Common Mistakes + +- [ ] Not handling NULL comparisons correctly +- [ ] Missing transactions for related operations +- [ ] Incorrect JOIN types +- [ ] Case sensitivity issues +- [ ] Date/timezone handling errors + +## API Design + +### REST Issues + +- [ ] Inconsistent resource naming +- [ ] Wrong HTTP methods (POST for idempotent operations) +- [ ] Missing pagination for list endpoints +- [ ] Incorrect status codes +- [ ] Missing rate limiting + +### Data Validation + +- [ ] Missing input validation +- [ ] Incorrect data type validation +- [ ] Missing length/range checks +- [ ] Not sanitizing user input +- [ ] Trusting client-side validation + +## Testing + +### Test Quality Issues + +- [ ] Testing implementation details instead of behavior +- [ ] Missing edge case tests +- [ ] Flaky tests (non-deterministic) +- [ ] Tests with external dependencies +- [ ] Missing negative tests (error cases) +- [ ] Overly complex test setup diff --git a/packages/mosaic/framework/skills/code-review-excellence/reference/cpp.md b/packages/mosaic/framework/skills/code-review-excellence/reference/cpp.md new file mode 100644 index 00000000..d5c4708a --- /dev/null +++ b/packages/mosaic/framework/skills/code-review-excellence/reference/cpp.md @@ -0,0 +1,390 @@ +# C++ Code Review Guide + +> C++ code review guide focused on memory safety, lifetime, API design, and performance. Examples assume C++17/20. + +## Table of Contents + +- [Ownership and RAII](#ownership-and-raii) +- [Lifetime and References](#lifetime-and-references) +- [Copy and Move Semantics](#copy-and-move-semantics) +- [Const-Correctness and API Design](#const-correctness-and-api-design) +- [Error Handling and Exception Safety](#error-handling-and-exception-safety) +- [Concurrency](#concurrency) +- [Performance and Allocation](#performance-and-allocation) +- [Templates and Type Safety](#templates-and-type-safety) +- [Tooling and Build Checks](#tooling-and-build-checks) +- [Review Checklist](#review-checklist) + +--- + +## Ownership and RAII + +### Prefer RAII and smart pointers + +Use RAII to express ownership. Default to `std::unique_ptr`, use `std::shared_ptr` only for shared lifetime. + +```cpp +// ? Bad: manual new/delete with early returns +Foo* make_foo() { + Foo* foo = new Foo(); + if (!foo->Init()) { + delete foo; + return nullptr; + } + return foo; +} + +// ? Good: RAII with unique_ptr +std::unique_ptr make_foo() { + auto foo = std::make_unique(); + if (!foo->Init()) { + return {}; + } + return foo; +} +``` + +### Wrap C resources + +```cpp +// ? Good: wrap FILE* with unique_ptr +using FilePtr = std::unique_ptr; + +FilePtr open_file(const char* path) { + return FilePtr(fopen(path, "rb"), &fclose); +} +``` + +--- + +## Lifetime and References + +### Avoid dangling references and views + +`std::string_view` and `std::span` do not own data. Make sure the owner outlives the view. + +```cpp +// ? Bad: returning string_view to a temporary +std::string_view bad_view() { + std::string s = make_name(); + return s; // dangling +} + +// ? Good: return owning string +std::string good_name() { + return make_name(); +} + +// ? Good: view tied to caller-owned data +std::string_view good_view(const std::string& s) { + return s; +} +``` + +### Lambda captures + +```cpp +// ? Bad: capture reference that escapes +std::function make_task() { + int value = 42; + return [&]() { use(value); }; // dangling +} + +// ? Good: capture by value +std::function make_task() { + int value = 42; + return [value]() { use(value); }; +} +``` + +--- + +## Copy and Move Semantics + +### Rule of 0/3/5 + +Prefer the Rule of 0 by using RAII types. If you own a resource, define or delete copy and move operations. + +```cpp +// ? Bad: raw ownership with default copy +struct Buffer { + int* data; + size_t size; + explicit Buffer(size_t n) : data(new int[n]), size(n) {} + ~Buffer() { delete[] data; } + // copy ctor/assign are implicitly generated -> double delete +}; + +// ? Good: Rule of 0 with std::vector +struct Buffer { + std::vector data; + explicit Buffer(size_t n) : data(n) {} +}; +``` + +### Delete unwanted copies + +```cpp +struct Socket { + Socket() = default; + ~Socket() { close(); } + + Socket(const Socket&) = delete; + Socket& operator=(const Socket&) = delete; + Socket(Socket&&) noexcept = default; + Socket& operator=(Socket&&) noexcept = default; +}; +``` + +--- + +## Const-Correctness and API Design + +### Use const and explicit + +```cpp +class User { +public: + const std::string& name() const { return name_; } + void set_name(std::string name) { name_ = std::move(name); } + +private: + std::string name_; +}; + +struct Millis { + explicit Millis(int v) : value(v) {} + int value; +}; +``` + +### Avoid object slicing + +```cpp +struct Shape { virtual ~Shape() = default; }; +struct Circle : Shape { void draw() const; }; + +// ? Bad: slices Circle into Shape +void draw(Shape shape); + +// ? Good: pass by reference +void draw(const Shape& shape); +``` + +### Use override and final + +```cpp +struct Base { + virtual void run() = 0; +}; + +struct Worker final : Base { + void run() override {} +}; +``` + +--- + +## Error Handling and Exception Safety + +### Prefer RAII for cleanup + +```cpp +// ? Good: RAII handles cleanup on exceptions +void process() { + std::vector data = load_data(); // safe cleanup + do_work(data); +} +``` + +### Do not throw from destructors + +```cpp +struct File { + ~File() noexcept { close(); } + void close(); +}; +``` + +### Use expected results for normal failures + +```cpp +// ? Expected error: use optional or expected +std::optional parse_int(const std::string& s) { + try { + return std::stoi(s); + } catch (...) { + return std::nullopt; + } +} +``` + +--- + +## Concurrency + +### Protect shared data + +```cpp +// ? Bad: data race +int counter = 0; +void inc() { counter++; } + +// ? Good: atomic +std::atomic counter{0}; +void inc() { counter.fetch_add(1, std::memory_order_relaxed); } +``` + +### Use RAII locks + +```cpp +std::mutex mu; +std::vector data; + +void add(int v) { + std::lock_guard lock(mu); + data.push_back(v); +} +``` + +--- + +## Performance and Allocation + +### Avoid repeated allocations + +```cpp +// ? Bad: repeated reallocation +std::vector build(int n) { + std::vector out; + for (int i = 0; i < n; ++i) { + out.push_back(i); + } + return out; +} + +// ? Good: reserve upfront +std::vector build(int n) { + std::vector out; + out.reserve(static_cast(n)); + for (int i = 0; i < n; ++i) { + out.push_back(i); + } + return out; +} +``` + +### String concatenation + +```cpp +// ? Bad: repeated allocation +std::string join(const std::vector& parts) { + std::string out; + for (const auto& p : parts) { + out += p; + } + return out; +} + +// ? Good: reserve total size +std::string join(const std::vector& parts) { + size_t total = 0; + for (const auto& p : parts) { + total += p.size(); + } + std::string out; + out.reserve(total); + for (const auto& p : parts) { + out += p; + } + return out; +} +``` + +--- + +## Templates and Type Safety + +### Prefer constrained templates (C++20) + +```cpp +// ? Bad: overly generic +template +T add(T a, T b) { + return a + b; +} + +// ? Good: constrained +template +requires std::is_integral_v +T add(T a, T b) { + return a + b; +} +``` + +### Use static_assert for invariants + +```cpp +template +struct Packet { + static_assert(std::is_trivially_copyable_v, + "Packet payload must be trivially copyable"); + T payload; +}; +``` + +--- + +## Tooling and Build Checks + +```bash +# Warnings +clang++ -Wall -Wextra -Werror -Wconversion -Wshadow -std=c++20 ... + +# Sanitizers (debug builds) +clang++ -fsanitize=address,undefined -fno-omit-frame-pointer -g ... +clang++ -fsanitize=thread -fno-omit-frame-pointer -g ... + +# Static analysis +clang-tidy src/*.cpp -- -std=c++20 + +# Formatting +clang-format -i src/*.cpp include/*.h +``` + +--- + +## Review Checklist + +### Safety and Lifetime + +- [ ] Ownership is explicit (RAII, unique_ptr by default) +- [ ] No dangling references or views +- [ ] Rule of 0/3/5 followed for resource-owning types +- [ ] No raw new/delete in business logic +- [ ] Destructors are noexcept and do not throw + +### API and Design + +- [ ] const-correctness is applied consistently +- [ ] Constructors are explicit where needed +- [ ] Override/final used for virtual functions +- [ ] No object slicing (pass by ref or pointer) + +### Concurrency + +- [ ] Shared data is protected (mutex or atomics) +- [ ] Locking order is consistent +- [ ] No blocking while holding locks + +### Performance + +- [ ] Unnecessary allocations avoided (reserve, move) +- [ ] Copies avoided in hot paths +- [ ] Algorithmic complexity is reasonable + +### Tooling and Tests + +- [ ] Builds clean with warnings enabled +- [ ] Sanitizers run on critical code paths +- [ ] Static analysis (clang-tidy) results are addressed diff --git a/packages/mosaic/framework/skills/code-review-excellence/reference/css-less-sass.md b/packages/mosaic/framework/skills/code-review-excellence/reference/css-less-sass.md new file mode 100644 index 00000000..61b98171 --- /dev/null +++ b/packages/mosaic/framework/skills/code-review-excellence/reference/css-less-sass.md @@ -0,0 +1,687 @@ +# CSS / Less / Sass Review Guide + +CSS 及预处理器代码审查指南,覆盖性能、可维护性、响应式设计和浏览器兼容性。 + +## CSS 变量 vs 硬编码 + +### 应该使用变量的场景 + +```css +/* ❌ 硬编码 - 难以维护 */ +.button { + background: #3b82f6; + border-radius: 8px; +} +.card { + border: 1px solid #3b82f6; + border-radius: 8px; +} + +/* ✅ 使用 CSS 变量 */ +:root { + --color-primary: #3b82f6; + --radius-md: 8px; +} +.button { + background: var(--color-primary); + border-radius: var(--radius-md); +} +.card { + border: 1px solid var(--color-primary); + border-radius: var(--radius-md); +} +``` + +### 变量命名规范 + +```css +/* 推荐的变量分类 */ +:root { + /* 颜色 */ + --color-primary: #3b82f6; + --color-primary-hover: #2563eb; + --color-text: #1f2937; + --color-text-muted: #6b7280; + --color-bg: #ffffff; + --color-border: #e5e7eb; + + /* 间距 */ + --spacing-xs: 4px; + --spacing-sm: 8px; + --spacing-md: 16px; + --spacing-lg: 24px; + --spacing-xl: 32px; + + /* 字体 */ + --font-size-sm: 14px; + --font-size-base: 16px; + --font-size-lg: 18px; + --font-weight-normal: 400; + --font-weight-bold: 700; + + /* 圆角 */ + --radius-sm: 4px; + --radius-md: 8px; + --radius-lg: 12px; + --radius-full: 9999px; + + /* 阴影 */ + --shadow-sm: 0 1px 2px rgba(0, 0, 0, 0.05); + --shadow-md: 0 4px 6px rgba(0, 0, 0, 0.1); + + /* 过渡 */ + --transition-fast: 150ms ease; + --transition-normal: 300ms ease; +} +``` + +### 变量作用域建议 + +```css +/* ✅ 组件级变量 - 减少全局污染 */ +.card { + --card-padding: var(--spacing-md); + --card-radius: var(--radius-md); + + padding: var(--card-padding); + border-radius: var(--card-radius); +} + +/* ⚠️ 避免频繁用 JS 动态修改变量 - 影响性能 */ +``` + +### 审查清单 + +- [ ] 颜色值是否使用变量? +- [ ] 间距是否来自设计系统? +- [ ] 重复值是否提取为变量? +- [ ] 变量命名是否语义化? + +--- + +## !important 使用规范 + +### 何时可以使用 + +```css +/* ✅ 工具类 - 明确需要覆盖 */ +.hidden { + display: none !important; +} +.sr-only { + position: absolute !important; +} + +/* ✅ 覆盖第三方库样式(无法修改源码时) */ +.third-party-modal { + z-index: 9999 !important; +} + +/* ✅ 打印样式 */ +@media print { + .no-print { + display: none !important; + } +} +``` + +### 何时禁止使用 + +```css +/* ❌ 解决特异性问题 - 应该重构选择器 */ +.button { + background: blue !important; /* 为什么需要 !important? */ +} + +/* ❌ 覆盖自己写的样式 */ +.card { + padding: 20px; +} +.card { + padding: 30px !important; +} /* 直接修改原规则 */ + +/* ❌ 在组件样式中 */ +.my-component .title { + font-size: 24px !important; /* 破坏组件封装 */ +} +``` + +### 替代方案 + +```css +/* 问题:需要覆盖 .btn 的样式 */ + +/* ❌ 使用 !important */ +.my-btn { + background: red !important; +} + +/* ✅ 提高特异性 */ +button.my-btn { + background: red; +} + +/* ✅ 使用更具体的选择器 */ +.container .my-btn { + background: red; +} + +/* ✅ 使用 :where() 降低被覆盖样式的特异性 */ +:where(.btn) { + background: blue; /* 特异性为 0 */ +} +.my-btn { + background: red; /* 可以正常覆盖 */ +} +``` + +### 审查问题 + +```markdown +🔴 [blocking] "发现 15 处 !important,请说明每处的必要性" +🟡 [important] "这个 !important 可以通过调整选择器特异性来解决" +💡 [suggestion] "考虑使用 CSS Layers (@layer) 来管理样式优先级" +``` + +--- + +## 性能考虑 + +### 🔴 高危性能问题 + +#### 1. `transition: all` 问题 + +```css +/* ❌ 性能杀手 - 浏览器检查所有可动画属性 */ +.button { + transition: all 0.3s ease; +} + +/* ✅ 明确指定属性 */ +.button { + transition: + background-color 0.3s ease, + transform 0.3s ease; +} + +/* ✅ 多属性时使用变量 */ +.button { + --transition-duration: 0.3s; + transition: + background-color var(--transition-duration) ease, + box-shadow var(--transition-duration) ease, + transform var(--transition-duration) ease; +} +``` + +#### 2. box-shadow 动画 + +```css +/* ❌ 每帧触发重绘 - 严重影响性能 */ +.card { + box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1); + transition: box-shadow 0.3s ease; +} +.card:hover { + box-shadow: 0 8px 16px rgba(0, 0, 0, 0.2); +} + +/* ✅ 使用伪元素 + opacity */ +.card { + position: relative; +} +.card::after { + content: ''; + position: absolute; + inset: 0; + box-shadow: 0 8px 16px rgba(0, 0, 0, 0.2); + opacity: 0; + transition: opacity 0.3s ease; + pointer-events: none; + border-radius: inherit; +} +.card:hover::after { + opacity: 1; +} +``` + +#### 3. 触发布局(Reflow)的属性 + +```css +/* ❌ 动画这些属性会触发布局重计算 */ +.bad-animation { + transition: + width 0.3s, + height 0.3s, + top 0.3s, + left 0.3s, + margin 0.3s; +} + +/* ✅ 只动画 transform 和 opacity(仅触发合成) */ +.good-animation { + transition: + transform 0.3s, + opacity 0.3s; +} + +/* 位移用 translate 代替 top/left */ +.move { + transform: translateX(100px); /* ✅ */ + /* left: 100px; */ /* ❌ */ +} + +/* 缩放用 scale 代替 width/height */ +.grow { + transform: scale(1.1); /* ✅ */ + /* width: 110%; */ /* ❌ */ +} +``` + +### 🟡 中等性能问题 + +#### 复杂选择器 + +```css +/* ❌ 过深的嵌套 - 选择器匹配慢 */ +.page .container .content .article .section .paragraph span { + color: red; +} + +/* ✅ 扁平化 */ +.article-text { + color: red; +} + +/* ❌ 通配符选择器 */ +* { + box-sizing: border-box; +} /* 影响所有元素 */ +[class*='icon-'] { + display: inline; +} /* 属性选择器较慢 */ + +/* ✅ 限制范围 */ +.icon-box * { + box-sizing: border-box; +} +``` + +#### 大量阴影和滤镜 + +```css +/* ⚠️ 复杂阴影影响渲染性能 */ +.heavy-shadow { + box-shadow: + 0 1px 2px rgba(0, 0, 0, 0.1), + 0 2px 4px rgba(0, 0, 0, 0.1), + 0 4px 8px rgba(0, 0, 0, 0.1), + 0 8px 16px rgba(0, 0, 0, 0.1), + 0 16px 32px rgba(0, 0, 0, 0.1); /* 5 层阴影 */ +} + +/* ⚠️ 滤镜消耗 GPU */ +.blur-heavy { + filter: blur(20px) brightness(1.2) contrast(1.1); + backdrop-filter: blur(10px); /* 更消耗性能 */ +} +``` + +### 性能优化建议 + +```css +/* 使用 will-change 提示浏览器(谨慎使用) */ +.animated-element { + will-change: transform, opacity; +} + +/* 动画完成后移除 will-change */ +.animated-element.idle { + will-change: auto; +} + +/* 使用 contain 限制重绘范围 */ +.card { + contain: layout paint; /* 告诉浏览器内部变化不影响外部 */ +} +``` + +### 审查清单 + +- [ ] 是否使用 `transition: all`? +- [ ] 是否动画 width/height/top/left? +- [ ] box-shadow 是否被动画? +- [ ] 选择器嵌套是否超过 3 层? +- [ ] 是否有不必要的 `will-change`? + +--- + +## 响应式设计检查点 + +### Mobile First 原则 + +```css +/* ✅ Mobile First - 基础样式针对移动端 */ +.container { + padding: 16px; + display: flex; + flex-direction: column; +} + +/* 逐步增强 */ +@media (min-width: 768px) { + .container { + padding: 24px; + flex-direction: row; + } +} + +@media (min-width: 1024px) { + .container { + padding: 32px; + max-width: 1200px; + margin: 0 auto; + } +} + +/* ❌ Desktop First - 需要覆盖更多样式 */ +.container { + max-width: 1200px; + padding: 32px; + flex-direction: row; +} + +@media (max-width: 1023px) { + .container { + padding: 24px; + } +} + +@media (max-width: 767px) { + .container { + padding: 16px; + flex-direction: column; + max-width: none; + } +} +``` + +### 断点建议 + +```css +/* 推荐断点(基于内容而非设备) */ +:root { + --breakpoint-sm: 640px; /* 大手机 */ + --breakpoint-md: 768px; /* 平板竖屏 */ + --breakpoint-lg: 1024px; /* 平板横屏/小笔记本 */ + --breakpoint-xl: 1280px; /* 桌面 */ + --breakpoint-2xl: 1536px; /* 大桌面 */ +} + +/* 使用示例 */ +@media (min-width: 768px) { + /* md */ +} +@media (min-width: 1024px) { + /* lg */ +} +``` + +### 响应式审查清单 + +- [ ] 是否采用 Mobile First? +- [ ] 断点是否基于内容断裂点而非设备? +- [ ] 是否避免断点重叠? +- [ ] 文字是否使用相对单位(rem/em)? +- [ ] 触摸目标是否足够大(≥44px)? +- [ ] 是否测试了横竖屏切换? + +### 常见问题 + +```css +/* ❌ 固定宽度 */ +.container { + width: 1200px; +} + +/* ✅ 最大宽度 + 弹性 */ +.container { + width: 100%; + max-width: 1200px; + padding-inline: 16px; +} + +/* ❌ 固定高度的文本容器 */ +.text-box { + height: 100px; /* 文字可能溢出 */ +} + +/* ✅ 最小高度 */ +.text-box { + min-height: 100px; +} + +/* ❌ 小触摸目标 */ +.small-button { + padding: 4px 8px; /* 太小,难以点击 */ +} + +/* ✅ 足够的触摸区域 */ +.touch-button { + min-height: 44px; + min-width: 44px; + padding: 12px 16px; +} +``` + +--- + +## 浏览器兼容性 + +### 需要检查的特性 + +| 特性 | 兼容性 | 建议 | +| ------------------- | ------------- | --------------------------- | +| CSS Grid | 现代浏览器 ✅ | IE 需要 Autoprefixer + 测试 | +| Flexbox | 广泛支持 ✅ | 旧版需要前缀 | +| CSS Variables | 现代浏览器 ✅ | IE 不支持,需要回退 | +| `gap` (flexbox) | 较新 ⚠️ | Safari 14.1+ | +| `:has()` | 较新 ⚠️ | Firefox 121+ | +| `container queries` | 较新 ⚠️ | 2023 年后的浏览器 | +| `@layer` | 较新 ⚠️ | 检查目标浏览器 | + +### 回退策略 + +```css +/* CSS 变量回退 */ +.button { + background: #3b82f6; /* 回退值 */ + background: var(--color-primary); /* 现代浏览器 */ +} + +/* Flexbox gap 回退 */ +.flex-container { + display: flex; + gap: 16px; +} +/* 旧浏览器回退 */ +.flex-container > * + * { + margin-left: 16px; +} + +/* Grid 回退 */ +.grid { + display: flex; + flex-wrap: wrap; +} +@supports (display: grid) { + .grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); + } +} +``` + +### Autoprefixer 配置 + +```javascript +// postcss.config.js +module.exports = { + plugins: [ + require('autoprefixer')({ + // 根据 browserslist 配置 + grid: 'autoplace', // 启用 Grid 前缀(IE 支持) + flexbox: 'no-2009', // 只用现代 flexbox 语法 + }), + ], +}; + +// package.json +{ + "browserslist": [ + "> 1%", + "last 2 versions", + "not dead", + "not ie 11" // 根据项目需求 + ] +} +``` + +### 审查清单 + +- [ ] 是否检查了 [Can I Use](https://caniuse.com)? +- [ ] 新特性是否有回退方案? +- [ ] 是否配置了 Autoprefixer? +- [ ] browserslist 是否符合项目要求? +- [ ] 是否在目标浏览器中测试? + +--- + +## Less / Sass 特定问题 + +### 嵌套深度 + +```scss +/* ❌ 过深嵌套 - 编译后选择器过长 */ +.page { + .container { + .content { + .article { + .title { + color: red; // 编译为 .page .container .content .article .title + } + } + } + } +} + +/* ✅ 最多 3 层 */ +.article { + &__title { + color: red; + } + + &__content { + p { + margin-bottom: 1em; + } + } +} +``` + +### Mixin vs Extend vs 变量 + +```scss +/* 变量 - 用于单个值 */ +$primary-color: #3b82f6; + +/* Mixin - 用于可配置的代码块 */ +@mixin button-variant($bg, $text) { + background: $bg; + color: $text; + &:hover { + background: darken($bg, 10%); + } +} + +/* Extend - 用于共享相同样式(谨慎使用) */ +%visually-hidden { + position: absolute; + width: 1px; + height: 1px; + overflow: hidden; + clip: rect(0, 0, 0, 0); +} + +.sr-only { + @extend %visually-hidden; +} + +/* ⚠️ @extend 的问题 */ +// 可能产生意外的选择器组合 +// 不能在 @media 中使用 +// 优先使用 mixin +``` + +### 审查清单 + +- [ ] 嵌套是否超过 3 层? +- [ ] 是否滥用 @extend? +- [ ] Mixin 是否过于复杂? +- [ ] 编译后的 CSS 大小是否合理? + +--- + +## 快速审查清单 + +### 🔴 必须修复 + +```markdown +□ transition: all +□ 动画 width/height/top/left/margin +□ 大量 !important +□ 硬编码的颜色/间距重复 >3 次 +□ 选择器嵌套 >4 层 +``` + +### 🟡 建议修复 + +```markdown +□ 缺少响应式处理 +□ 使用 Desktop First +□ 复杂 box-shadow 被动画 +□ 缺少浏览器兼容回退 +□ CSS 变量作用域过大 +``` + +### 🟢 优化建议 + +```markdown +□ 可以使用 CSS Grid 简化布局 +□ 可以使用 CSS 变量提取重复值 +□ 可以使用 @layer 管理优先级 +□ 可以添加 contain 优化性能 +``` + +--- + +## 工具推荐 + +| 工具 | 用途 | +| ----------------------------------------------- | ---------------- | +| [Stylelint](https://stylelint.io/) | CSS 代码检查 | +| [PurgeCSS](https://purgecss.com/) | 移除未使用 CSS | +| [Autoprefixer](https://autoprefixer.github.io/) | 自动添加前缀 | +| [CSS Stats](https://cssstats.com/) | 分析 CSS 统计 | +| [Can I Use](https://caniuse.com/) | 浏览器兼容性查询 | + +--- + +## 参考资源 + +- [CSS Performance Optimization - MDN](https://developer.mozilla.org/en-US/docs/Learn_web_development/Extensions/Performance/CSS) +- [What a CSS Code Review Might Look Like - CSS-Tricks](https://css-tricks.com/what-a-css-code-review-might-look-like/) +- [How to Animate Box-Shadow - Tobias Ahlin](https://tobiasahlin.com/blog/how-to-animate-box-shadow/) +- [Media Query Fundamentals - MDN](https://developer.mozilla.org/en-US/docs/Learn_web_development/Core/CSS_layout/Media_queries) +- [Autoprefixer - GitHub](https://github.com/postcss/autoprefixer) diff --git a/packages/mosaic/framework/skills/code-review-excellence/reference/go.md b/packages/mosaic/framework/skills/code-review-excellence/reference/go.md new file mode 100644 index 00000000..5cbb4959 --- /dev/null +++ b/packages/mosaic/framework/skills/code-review-excellence/reference/go.md @@ -0,0 +1,991 @@ +# Go 代码审查指南 + +基于 Go 官方指南、Effective Go 和社区最佳实践的代码审查清单。 + +## 快速审查清单 + +### 必查项 + +- [ ] 错误是否正确处理(不忽略、有上下文) +- [ ] goroutine 是否有退出机制(避免泄漏) +- [ ] context 是否正确传递和取消 +- [ ] 接收器类型选择是否合理(值/指针) +- [ ] 是否使用 `gofmt` 格式化代码 + +### 高频问题 + +- [ ] 循环变量捕获问题(Go < 1.22) +- [ ] nil 检查是否完整 +- [ ] map 是否初始化后使用 +- [ ] defer 在循环中的使用 +- [ ] 变量遮蔽(shadowing) + +--- + +## 1. 错误处理 + +### 1.1 永远不要忽略错误 + +```go +// ❌ 错误:忽略错误 +result, _ := SomeFunction() + +// ✅ 正确:处理错误 +result, err := SomeFunction() +if err != nil { + return fmt.Errorf("some function failed: %w", err) +} +``` + +### 1.2 错误包装与上下文 + +```go +// ❌ 错误:丢失上下文 +if err != nil { + return err +} + +// ❌ 错误:使用 %v 丢失错误链 +if err != nil { + return fmt.Errorf("failed: %v", err) +} + +// ✅ 正确:使用 %w 保留错误链 +if err != nil { + return fmt.Errorf("failed to process user %d: %w", userID, err) +} +``` + +### 1.3 使用 errors.Is 和 errors.As + +```go +// ❌ 错误:直接比较(无法处理包装错误) +if err == sql.ErrNoRows { + // ... +} + +// ✅ 正确:使用 errors.Is(支持错误链) +if errors.Is(err, sql.ErrNoRows) { + return nil, ErrNotFound +} + +// ✅ 正确:使用 errors.As 提取特定类型 +var pathErr *os.PathError +if errors.As(err, &pathErr) { + log.Printf("path error: %s", pathErr.Path) +} +``` + +### 1.4 自定义错误类型 + +```go +// ✅ 推荐:定义 sentinel 错误 +var ( + ErrNotFound = errors.New("not found") + ErrUnauthorized = errors.New("unauthorized") +) + +// ✅ 推荐:带上下文的自定义错误 +type ValidationError struct { + Field string + Message string +} + +func (e *ValidationError) Error() string { + return fmt.Sprintf("validation error on %s: %s", e.Field, e.Message) +} +``` + +### 1.5 错误处理只做一次 + +```go +// ❌ 错误:既记录又返回(重复处理) +if err != nil { + log.Printf("error: %v", err) + return err +} + +// ✅ 正确:只返回,让调用者决定 +if err != nil { + return fmt.Errorf("operation failed: %w", err) +} + +// ✅ 或者:只记录并处理(不返回) +if err != nil { + log.Printf("non-critical error: %v", err) + // 继续执行备用逻辑 +} +``` + +--- + +## 2. 并发与 Goroutine + +### 2.1 避免 Goroutine 泄漏 + +```go +// ❌ 错误:goroutine 永远无法退出 +func bad() { + ch := make(chan int) + go func() { + val := <-ch // 永远阻塞,无人发送 + fmt.Println(val) + }() + // 函数返回,goroutine 泄漏 +} + +// ✅ 正确:使用 context 或 done channel +func good(ctx context.Context) { + ch := make(chan int) + go func() { + select { + case val := <-ch: + fmt.Println(val) + case <-ctx.Done(): + return // 优雅退出 + } + }() +} +``` + +### 2.2 Channel 使用规范 + +```go +// ❌ 错误:向 nil channel 发送(永久阻塞) +var ch chan int +ch <- 1 // 永久阻塞 + +// ❌ 错误:向已关闭的 channel 发送(panic) +close(ch) +ch <- 1 // panic! + +// ✅ 正确:发送方关闭 channel +func producer(ch chan<- int) { + defer close(ch) // 发送方负责关闭 + for i := 0; i < 10; i++ { + ch <- i + } +} + +// ✅ 正确:接收方检测关闭 +for val := range ch { + process(val) +} +// 或者 +val, ok := <-ch +if !ok { + // channel 已关闭 +} +``` + +### 2.3 使用 sync.WaitGroup + +```go +// ❌ 错误:Add 在 goroutine 内部 +var wg sync.WaitGroup +for i := 0; i < 10; i++ { + go func() { + wg.Add(1) // 竞态条件! + defer wg.Done() + work() + }() +} +wg.Wait() + +// ✅ 正确:Add 在 goroutine 启动前 +var wg sync.WaitGroup +for i := 0; i < 10; i++ { + wg.Add(1) + go func() { + defer wg.Done() + work() + }() +} +wg.Wait() +``` + +### 2.4 避免在循环中捕获变量(Go < 1.22) + +```go +// ❌ 错误(Go < 1.22):捕获循环变量 +for _, item := range items { + go func() { + process(item) // 所有 goroutine 可能使用同一个 item + }() +} + +// ✅ 正确:传递参数 +for _, item := range items { + go func(it Item) { + process(it) + }(item) +} + +// ✅ Go 1.22+:默认行为已修复,每次迭代创建新变量 +``` + +### 2.5 Worker Pool 模式 + +```go +// ✅ 推荐:限制并发数量 +func processWithWorkerPool(ctx context.Context, items []Item, workers int) error { + jobs := make(chan Item, len(items)) + results := make(chan error, len(items)) + + // 启动 worker + for w := 0; w < workers; w++ { + go func() { + for item := range jobs { + results <- process(item) + } + }() + } + + // 发送任务 + for _, item := range items { + jobs <- item + } + close(jobs) + + // 收集结果 + for range items { + if err := <-results; err != nil { + return err + } + } + return nil +} +``` + +--- + +## 3. Context 使用 + +### 3.1 Context 作为第一个参数 + +```go +// ❌ 错误:context 不是第一个参数 +func Process(data []byte, ctx context.Context) error + +// ❌ 错误:context 存储在 struct 中 +type Service struct { + ctx context.Context // 不要这样做! +} + +// ✅ 正确:context 作为第一个参数,命名为 ctx +func Process(ctx context.Context, data []byte) error +``` + +### 3.2 传播而非创建新的根 Context + +```go +// ❌ 错误:在调用链中创建新的根 context +func middleware(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + ctx := context.Background() // 丢失了请求的 context! + process(ctx) + next.ServeHTTP(w, r) + }) +} + +// ✅ 正确:从请求中获取并传播 +func middleware(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + ctx := r.Context() + ctx = context.WithValue(ctx, key, value) + process(ctx) + next.ServeHTTP(w, r.WithContext(ctx)) + }) +} +``` + +### 3.3 始终调用 cancel 函数 + +```go +// ❌ 错误:未调用 cancel +ctx, cancel := context.WithTimeout(parentCtx, 5*time.Second) +// 缺少 cancel() 调用,可能资源泄漏 + +// ✅ 正确:使用 defer 确保调用 +ctx, cancel := context.WithTimeout(parentCtx, 5*time.Second) +defer cancel() // 即使超时也要调用 +``` + +### 3.4 响应 Context 取消 + +```go +// ✅ 推荐:在长时间操作中检查 context +func LongRunningTask(ctx context.Context) error { + for { + select { + case <-ctx.Done(): + return ctx.Err() // 返回 context.Canceled 或 context.DeadlineExceeded + default: + // 执行一小部分工作 + if err := doChunk(); err != nil { + return err + } + } + } +} +``` + +### 3.5 区分取消原因 + +```go +// ✅ 根据 ctx.Err() 区分取消原因 +if err := ctx.Err(); err != nil { + switch { + case errors.Is(err, context.Canceled): + log.Println("operation was canceled") + case errors.Is(err, context.DeadlineExceeded): + log.Println("operation timed out") + } + return err +} +``` + +--- + +## 4. 接口设计 + +### 4.1 接受接口,返回结构体 + +```go +// ❌ 不推荐:接受具体类型 +func SaveUser(db *sql.DB, user User) error + +// ✅ 推荐:接受接口(解耦、易测试) +type UserStore interface { + Save(ctx context.Context, user User) error +} + +func SaveUser(store UserStore, user User) error + +// ❌ 不推荐:返回接口 +func NewUserService() UserServiceInterface + +// ✅ 推荐:返回具体类型 +func NewUserService(store UserStore) *UserService +``` + +### 4.2 在消费者处定义接口 + +```go +// ❌ 不推荐:在实现包中定义接口 +// package database +type Database interface { + Query(ctx context.Context, query string) ([]Row, error) + // ... 20 个方法 +} + +// ✅ 推荐:在消费者包中定义所需的最小接口 +// package userservice +type UserQuerier interface { + QueryUsers(ctx context.Context, filter Filter) ([]User, error) +} +``` + +### 4.3 保持接口小而专注 + +```go +// ❌ 不推荐:大而全的接口 +type Repository interface { + GetUser(id int) (*User, error) + CreateUser(u *User) error + UpdateUser(u *User) error + DeleteUser(id int) error + GetOrder(id int) (*Order, error) + CreateOrder(o *Order) error + // ... 更多方法 +} + +// ✅ 推荐:小而专注的接口 +type UserReader interface { + GetUser(ctx context.Context, id int) (*User, error) +} + +type UserWriter interface { + CreateUser(ctx context.Context, u *User) error + UpdateUser(ctx context.Context, u *User) error +} + +// 组合接口 +type UserRepository interface { + UserReader + UserWriter +} +``` + +### 4.4 避免空接口滥用 + +```go +// ❌ 不推荐:过度使用 interface{} +func Process(data interface{}) interface{} + +// ✅ 推荐:使用泛型(Go 1.18+) +func Process[T any](data T) T + +// ✅ 推荐:定义具体接口 +type Processor interface { + Process() Result +} +``` + +--- + +## 5. 接收器类型选择 + +### 5.1 使用指针接收器的情况 + +```go +// ✅ 需要修改接收器时 +func (u *User) SetName(name string) { + u.Name = name +} + +// ✅ 接收器包含 sync.Mutex 等同步原语 +type SafeCounter struct { + mu sync.Mutex + count int +} + +func (c *SafeCounter) Inc() { + c.mu.Lock() + defer c.mu.Unlock() + c.count++ +} + +// ✅ 接收器是大型结构体(避免复制开销) +type LargeStruct struct { + Data [1024]byte + // ... +} + +func (l *LargeStruct) Process() { /* ... */ } +``` + +### 5.2 使用值接收器的情况 + +```go +// ✅ 接收器是小型不可变结构体 +type Point struct { + X, Y float64 +} + +func (p Point) Distance(other Point) float64 { + return math.Sqrt(math.Pow(p.X-other.X, 2) + math.Pow(p.Y-other.Y, 2)) +} + +// ✅ 接收器是基本类型的别名 +type Counter int + +func (c Counter) String() string { + return fmt.Sprintf("%d", c) +} + +// ✅ 接收器是 map、func、chan(本身是引用类型) +type StringSet map[string]struct{} + +func (s StringSet) Contains(key string) bool { + _, ok := s[key] + return ok +} +``` + +### 5.3 一致性原则 + +```go +// ❌ 不推荐:混合使用接收器类型 +func (u User) GetName() string // 值接收器 +func (u *User) SetName(n string) // 指针接收器 + +// ✅ 推荐:如果有任何方法需要指针接收器,全部使用指针 +func (u *User) GetName() string { return u.Name } +func (u *User) SetName(n string) { u.Name = n } +``` + +--- + +## 6. 性能优化 + +### 6.1 预分配 Slice + +```go +// ❌ 不推荐:动态增长 +var result []int +for i := 0; i < 10000; i++ { + result = append(result, i) // 多次分配和复制 +} + +// ✅ 推荐:预分配已知大小 +result := make([]int, 0, 10000) +for i := 0; i < 10000; i++ { + result = append(result, i) +} + +// ✅ 或者直接初始化 +result := make([]int, 10000) +for i := 0; i < 10000; i++ { + result[i] = i +} +``` + +### 6.2 避免不必要的堆分配 + +```go +// ❌ 可能逃逸到堆 +func NewUser() *User { + return &User{} // 逃逸到堆 +} + +// ✅ 考虑返回值(如果适用) +func NewUser() User { + return User{} // 可能在栈上分配 +} + +// 检查逃逸分析 +// go build -gcflags '-m -m' ./... +``` + +### 6.3 使用 sync.Pool 复用对象 + +```go +// ✅ 推荐:高频创建/销毁的对象使用 sync.Pool +var bufferPool = sync.Pool{ + New: func() interface{} { + return new(bytes.Buffer) + }, +} + +func ProcessData(data []byte) string { + buf := bufferPool.Get().(*bytes.Buffer) + defer func() { + buf.Reset() + bufferPool.Put(buf) + }() + + buf.Write(data) + return buf.String() +} +``` + +### 6.4 字符串拼接优化 + +```go +// ❌ 不推荐:循环中使用 + 拼接 +var result string +for _, s := range strings { + result += s // 每次创建新字符串 +} + +// ✅ 推荐:使用 strings.Builder +var builder strings.Builder +for _, s := range strings { + builder.WriteString(s) +} +result := builder.String() + +// ✅ 或者使用 strings.Join +result := strings.Join(strings, "") +``` + +### 6.5 避免 interface{} 转换开销 + +```go +// ❌ 热路径中使用 interface{} +func process(data interface{}) { + switch v := data.(type) { // 类型断言有开销 + case int: + // ... + } +} + +// ✅ 热路径中使用泛型或具体类型 +func process[T int | int64 | float64](data T) { + // 编译时确定类型,无运行时开销 +} +``` + +--- + +## 7. 测试 + +### 7.1 表驱动测试 + +```go +// ✅ 推荐:表驱动测试 +func TestAdd(t *testing.T) { + tests := []struct { + name string + a, b int + expected int + }{ + {"positive numbers", 1, 2, 3}, + {"with zero", 0, 5, 5}, + {"negative numbers", -1, -2, -3}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := Add(tt.a, tt.b) + if result != tt.expected { + t.Errorf("Add(%d, %d) = %d; want %d", + tt.a, tt.b, result, tt.expected) + } + }) + } +} +``` + +### 7.2 并行测试 + +```go +// ✅ 推荐:独立测试用例并行执行 +func TestParallel(t *testing.T) { + tests := []struct { + name string + input string + }{ + {"test1", "input1"}, + {"test2", "input2"}, + } + + for _, tt := range tests { + tt := tt // Go < 1.22 需要复制 + t.Run(tt.name, func(t *testing.T) { + t.Parallel() // 标记为可并行 + result := Process(tt.input) + // assertions... + }) + } +} +``` + +### 7.3 使用接口进行 Mock + +```go +// ✅ 定义接口以便测试 +type EmailSender interface { + Send(to, subject, body string) error +} + +// 生产实现 +type SMTPSender struct { /* ... */ } + +// 测试 Mock +type MockEmailSender struct { + SendFunc func(to, subject, body string) error +} + +func (m *MockEmailSender) Send(to, subject, body string) error { + return m.SendFunc(to, subject, body) +} + +func TestUserRegistration(t *testing.T) { + mock := &MockEmailSender{ + SendFunc: func(to, subject, body string) error { + if to != "test@example.com" { + t.Errorf("unexpected recipient: %s", to) + } + return nil + }, + } + + service := NewUserService(mock) + // test... +} +``` + +### 7.4 测试辅助函数 + +```go +// ✅ 使用 t.Helper() 标记辅助函数 +func assertEqual(t *testing.T, got, want interface{}) { + t.Helper() // 错误报告时显示调用者位置 + if got != want { + t.Errorf("got %v, want %v", got, want) + } +} + +// ✅ 使用 t.Cleanup() 清理资源 +func TestWithTempFile(t *testing.T) { + f, err := os.CreateTemp("", "test") + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { + os.Remove(f.Name()) + }) + // test... +} +``` + +--- + +## 8. 常见陷阱 + +### 8.1 Nil Slice vs Empty Slice + +```go +var nilSlice []int // nil, len=0, cap=0 +emptySlice := []int{} // not nil, len=0, cap=0 +made := make([]int, 0) // not nil, len=0, cap=0 + +// ✅ JSON 编码差异 +json.Marshal(nilSlice) // null +json.Marshal(emptySlice) // [] + +// ✅ 推荐:需要空数组 JSON 时显式初始化 +if slice == nil { + slice = []int{} +} +``` + +### 8.2 Map 初始化 + +```go +// ❌ 错误:未初始化的 map +var m map[string]int +m["key"] = 1 // panic: assignment to entry in nil map + +// ✅ 正确:使用 make 初始化 +m := make(map[string]int) +m["key"] = 1 + +// ✅ 或者使用字面量 +m := map[string]int{} +``` + +### 8.3 Defer 在循环中 + +```go +// ❌ 潜在问题:defer 在函数结束时才执行 +func processFiles(files []string) error { + for _, file := range files { + f, err := os.Open(file) + if err != nil { + return err + } + defer f.Close() // 所有文件在函数结束时才关闭! + // process... + } + return nil +} + +// ✅ 正确:使用闭包或提取函数 +func processFiles(files []string) error { + for _, file := range files { + if err := processFile(file); err != nil { + return err + } + } + return nil +} + +func processFile(file string) error { + f, err := os.Open(file) + if err != nil { + return err + } + defer f.Close() + // process... + return nil +} +``` + +### 8.4 Slice 底层数组共享 + +```go +// ❌ 潜在问题:切片共享底层数组 +original := []int{1, 2, 3, 4, 5} +slice := original[1:3] // [2, 3] +slice[0] = 100 // 修改了 original! +// original 变成 [1, 100, 3, 4, 5] + +// ✅ 正确:需要独立副本时显式复制 +slice := make([]int, 2) +copy(slice, original[1:3]) +slice[0] = 100 // 不影响 original +``` + +### 8.5 字符串子串内存泄漏 + +```go +// ❌ 潜在问题:子串持有整个底层数组 +func getPrefix(s string) string { + return s[:10] // 仍引用整个 s 的底层数组 +} + +// ✅ 正确:创建独立副本(Go 1.18+) +func getPrefix(s string) string { + return strings.Clone(s[:10]) +} + +// ✅ Go 1.18 之前 +func getPrefix(s string) string { + return string([]byte(s[:10])) +} +``` + +### 8.6 Interface Nil 陷阱 + +```go +// ❌ 陷阱:interface 的 nil 判断 +type MyError struct{} +func (e *MyError) Error() string { return "error" } + +func returnsError() error { + var e *MyError = nil + return e // 返回的 error 不是 nil! +} + +func main() { + err := returnsError() + if err != nil { // true! interface{type: *MyError, value: nil} + fmt.Println("error:", err) + } +} + +// ✅ 正确:显式返回 nil +func returnsError() error { + var e *MyError = nil + if e == nil { + return nil // 显式返回 nil + } + return e +} +``` + +### 8.7 Time 比较 + +```go +// ❌ 不推荐:直接使用 == 比较 time.Time +if t1 == t2 { // 可能因为单调时钟差异而失败 + // ... +} + +// ✅ 推荐:使用 Equal 方法 +if t1.Equal(t2) { + // ... +} + +// ✅ 比较时间范围 +if t1.Before(t2) || t1.After(t2) { + // ... +} +``` + +--- + +## 9. 代码组织 + +### 9.1 包命名 + +```go +// ❌ 不推荐 +package common // 过于宽泛 +package utils // 过于宽泛 +package helpers // 过于宽泛 +package models // 按类型分组 + +// ✅ 推荐:按功能命名 +package user // 用户相关功能 +package order // 订单相关功能 +package postgres // PostgreSQL 实现 +``` + +### 9.2 避免循环依赖 + +```go +// ❌ 循环依赖 +// package a imports package b +// package b imports package a + +// ✅ 解决方案1:提取共享类型到独立包 +// package types (共享类型) +// package a imports types +// package b imports types + +// ✅ 解决方案2:使用接口解耦 +// package a 定义接口 +// package b 实现接口 +``` + +### 9.3 导出标识符规范 + +```go +// ✅ 只导出必要的标识符 +type UserService struct { + db *sql.DB // 私有 +} + +func (s *UserService) GetUser(id int) (*User, error) // 公开 +func (s *UserService) validate(u *User) error // 私有 + +// ✅ 内部包限制访问 +// internal/database/... 只能被同项目代码导入 +``` + +--- + +## 10. 工具与检查 + +### 10.1 必须使用的工具 + +```bash +# 格式化(必须) +gofmt -w . +goimports -w . + +# 静态分析 +go vet ./... + +# 竞态检测 +go test -race ./... + +# 逃逸分析 +go build -gcflags '-m -m' ./... +``` + +### 10.2 推荐的 Linter + +```bash +# golangci-lint(集成多个 linter) +golangci-lint run + +# 常用检查项 +# - errcheck: 检查未处理的错误 +# - gosec: 安全检查 +# - ineffassign: 无效赋值 +# - staticcheck: 静态分析 +# - unused: 未使用的代码 +``` + +### 10.3 Benchmark 测试 + +```go +// ✅ 性能基准测试 +func BenchmarkProcess(b *testing.B) { + data := prepareData() + b.ResetTimer() // 重置计时器 + + for i := 0; i < b.N; i++ { + Process(data) + } +} + +// 运行 benchmark +// go test -bench=. -benchmem ./... +``` + +--- + +## 参考资源 + +- [Effective Go](https://go.dev/doc/effective_go) +- [Go Code Review Comments](https://go.dev/wiki/CodeReviewComments) +- [Go Common Mistakes](https://go.dev/wiki/CommonMistakes) +- [100 Go Mistakes](https://100go.co/) +- [Go Proverbs](https://go-proverbs.github.io/) +- [Uber Go Style Guide](https://github.com/uber-go/guide/blob/master/style.md) diff --git a/packages/mosaic/framework/skills/code-review-excellence/reference/java.md b/packages/mosaic/framework/skills/code-review-excellence/reference/java.md new file mode 100644 index 00000000..a4f89009 --- /dev/null +++ b/packages/mosaic/framework/skills/code-review-excellence/reference/java.md @@ -0,0 +1,410 @@ +# Java Code Review Guide + +Java 审查重点:Java 17/21 新特性、Spring Boot 3 最佳实践、并发编程(虚拟线程)、JPA 性能优化以及代码可维护性。 + +## 目录 + +- [现代 Java 特性 (17/21+)](#现代-java-特性-1721) +- [Stream API & Optional](#stream-api--optional) +- [Spring Boot 最佳实践](#spring-boot-最佳实践) +- [JPA 与 数据库性能](#jpa-与-数据库性能) +- [并发与虚拟线程](#并发与虚拟线程) +- [Lombok 使用规范](#lombok-使用规范) +- [异常处理](#异常处理) +- [测试规范](#测试规范) +- [Review Checklist](#review-checklist) + +--- + +## 现代 Java 特性 (17/21+) + +### Record (记录类) + +```java +// ❌ 传统的 POJO/DTO:样板代码多 +public class UserDto { + private final String name; + private final int age; + + public UserDto(String name, int age) { + this.name = name; + this.age = age; + } + // getters, equals, hashCode, toString... +} + +// ✅ 使用 Record:简洁、不可变、语义清晰 +public record UserDto(String name, int age) { + // 紧凑构造函数进行验证 + public UserDto { + if (age < 0) throw new IllegalArgumentException("Age cannot be negative"); + } +} +``` + +### Switch 表达式与模式匹配 + +```java +// ❌ 传统的 Switch:容易漏掉 break,不仅冗长且易错 +String type = ""; +switch (obj) { + case Integer i: // Java 16+ + type = String.format("int %d", i); + break; + case String s: + type = String.format("string %s", s); + break; + default: + type = "unknown"; +} + +// ✅ Switch 表达式:无穿透风险,强制返回值 +String type = switch (obj) { + case Integer i -> "int %d".formatted(i); + case String s -> "string %s".formatted(s); + case null -> "null value"; // Java 21 处理 null + default -> "unknown"; +}; +``` + +### 文本块 (Text Blocks) + +```java +// ❌ 拼接 SQL/JSON 字符串 +String json = "{\n" + + " \"name\": \"Alice\",\n" + + " \"age\": 20\n" + + "}"; + +// ✅ 使用文本块:所见即所得 +String json = """ + { + "name": "Alice", + "age": 20 + } + """; +``` + +--- + +## Stream API & Optional + +### 避免滥用 Stream + +```java +// ❌ 简单的循环不需要 Stream(性能开销 + 可读性差) +items.stream().forEach(item -> { + process(item); +}); + +// ✅ 简单场景直接用 for-each +for (var item : items) { + process(item); +} + +// ❌ 极其复杂的 Stream 链 +List result = list.stream() + .filter(...) + .map(...) + .peek(...) + .sorted(...) + .collect(...); // 难以调试 + +// ✅ 拆分为有意义的步骤 +var filtered = list.stream().filter(...).toList(); +// ... +``` + +### Optional 正确用法 + +```java +// ❌ 将 Optional 用作参数或字段(序列化问题,增加调用复杂度) +public void process(Optional name) { ... } +public class User { + private Optional email; // 不推荐 +} + +// ✅ Optional 仅用于返回值 +public Optional findUser(String id) { ... } + +// ❌ 既然用了 Optional 还在用 isPresent() + get() +Optional userOpt = findUser(id); +if (userOpt.isPresent()) { + return userOpt.get().getName(); +} else { + return "Unknown"; +} + +// ✅ 使用函数式 API +return findUser(id) + .map(User::getName) + .orElse("Unknown"); +``` + +--- + +## Spring Boot 最佳实践 + +### 依赖注入 (DI) + +```java +// ❌ 字段注入 (@Autowired) +// 缺点:难以测试(需要反射注入),掩盖了依赖过多的问题,且不可变性差 +@Service +public class UserService { + @Autowired + private UserRepository userRepo; +} + +// ✅ 构造器注入 (Constructor Injection) +// 优点:依赖明确,易于单元测试 (Mock),字段可为 final +@Service +public class UserService { + private final UserRepository userRepo; + + public UserService(UserRepository userRepo) { + this.userRepo = userRepo; + } +} +// 💡 提示:结合 Lombok @RequiredArgsConstructor 可简化代码,但要小心循环依赖 +``` + +### 配置管理 + +```java +// ❌ 硬编码配置值 +@Service +public class PaymentService { + private String apiKey = "sk_live_12345"; +} + +// ❌ 直接使用 @Value 散落在代码中 +@Value("${app.payment.api-key}") +private String apiKey; + +// ✅ 使用 @ConfigurationProperties 类型安全配置 +@ConfigurationProperties(prefix = "app.payment") +public record PaymentProperties(String apiKey, int timeout, String url) {} +``` + +--- + +## JPA 与 数据库性能 + +### N+1 查询问题 + +```java +// ❌ FetchType.EAGER 或 循环中触发懒加载 +// Entity 定义 +@Entity +public class User { + @OneToMany(fetch = FetchType.EAGER) // 危险! + private List orders; +} + +// 业务代码 +List users = userRepo.findAll(); // 1 条 SQL +for (User user : users) { + // 如果是 Lazy,这里会触发 N 条 SQL + System.out.println(user.getOrders().size()); +} + +// ✅ 使用 @EntityGraph 或 JOIN FETCH +@Query("SELECT u FROM User u JOIN FETCH u.orders") +List findAllWithOrders(); +``` + +### 事务管理 + +```java +// ❌ 在 Controller 层开启事务(数据库连接占用时间过长) +// ❌ 在 private 方法上加 @Transactional(AOP 不生效) +@Transactional +private void saveInternal() { ... } + +// ✅ 在 Service 层公共方法加 @Transactional +// ✅ 读操作显式标记 readOnly = true (性能优化) +@Service +public class UserService { + @Transactional(readOnly = true) + public User getUser(Long id) { ... } + + @Transactional + public void createUser(UserDto dto) { ... } +} +``` + +### Entity 设计 + +```java +// ❌ 在 Entity 中使用 Lombok @Data +// @Data 生成的 equals/hashCode 包含所有字段,可能触发懒加载导致性能问题或异常 +@Entity +@Data +public class User { ... } + +// ✅ 仅使用 @Getter, @Setter +// ✅ 自定义 equals/hashCode (通常基于 ID) +@Entity +@Getter +@Setter +public class User { + @Id + private Long id; + + @Override + public boolean equals(Object o) { + if (this == o) return true; + if (!(o instanceof User)) return false; + return id != null && id.equals(((User) o).id); + } + + @Override + public int hashCode() { + return getClass().hashCode(); + } +} +``` + +--- + +## 并发与虚拟线程 + +### 虚拟线程 (Java 21+) + +```java +// ❌ 传统线程池处理大量 I/O 阻塞任务(资源耗尽) +ExecutorService executor = Executors.newFixedThreadPool(100); + +// ✅ 使用虚拟线程处理 I/O 密集型任务(高吞吐量) +// Spring Boot 3.2+ 开启:spring.threads.virtual.enabled=true +ExecutorService executor = Executors.newVirtualThreadPerTaskExecutor(); + +// 在虚拟线程中,阻塞操作(如 DB 查询、HTTP 请求)几乎不消耗 OS 线程资源 +``` + +### 线程安全 + +```java +// ❌ SimpleDateFormat 是线程不安全的 +private static final SimpleDateFormat sdf = new SimpleDateFormat("yyyy-MM-dd"); + +// ✅ 使用 DateTimeFormatter (Java 8+) +private static final DateTimeFormatter dtf = DateTimeFormatter.ofPattern("yyyy-MM-dd"); + +// ❌ HashMap 在多线程环境可能死循环或数据丢失 +// ✅ 使用 ConcurrentHashMap +Map cache = new ConcurrentHashMap<>(); +``` + +--- + +## Lombok 使用规范 + +```java +// ❌ 滥用 @Builder 导致无法强制校验必填字段 +@Builder +public class Order { + private String id; // 必填 + private String note; // 选填 +} +// 调用者可能漏掉 id: Order.builder().note("hi").build(); + +// ✅ 关键业务对象建议手动编写 Builder 或构造函数以确保不变量 +// 或者在 build() 方法中添加校验逻辑 (Lombok @Builder.Default 等) +``` + +--- + +## 异常处理 + +### 全局异常处理 + +```java +// ❌ 到处 try-catch 吞掉异常或只打印日志 +try { + userService.create(user); +} catch (Exception e) { + e.printStackTrace(); // 不应该在生产环境使用 + // return null; // 吞掉异常,上层不知道发生了什么 +} + +// ✅ 自定义异常 + @ControllerAdvice (Spring Boot 3 ProblemDetail) +public class UserNotFoundException extends RuntimeException { ... } + +@RestControllerAdvice +public class GlobalExceptionHandler { + @ExceptionHandler(UserNotFoundException.class) + public ProblemDetail handleNotFound(UserNotFoundException e) { + return ProblemDetail.forStatusAndDetail(HttpStatus.NOT_FOUND, e.getMessage()); + } +} +``` + +--- + +## 测试规范 + +### 单元测试 vs 集成测试 + +```java +// ❌ 单元测试依赖真实数据库或外部服务 +@SpringBootTest // 启动整个 Context,慢 +public class UserServiceTest { ... } + +// ✅ 单元测试使用 Mockito +@ExtendWith(MockitoExtension.class) +class UserServiceTest { + @Mock UserRepository repo; + @InjectMocks UserService service; + + @Test + void shouldCreateUser() { ... } +} + +// ✅ 集成测试使用 Testcontainers +@Testcontainers +@SpringBootTest +class UserRepositoryTest { + @Container + static PostgreSQLContainer postgres = new PostgreSQLContainer<>("postgres:15"); + // ... +} +``` + +--- + +## Review Checklist + +### 基础与规范 + +- [ ] 遵循 Java 17/21 新特性(Switch 表达式, Records, 文本块) +- [ ] 避免使用已过时的类(Date, Calendar, SimpleDateFormat) +- [ ] 集合操作是否优先使用了 Stream API 或 Collections 方法? +- [ ] Optional 仅用于返回值,未用于字段或参数 + +### Spring Boot + +- [ ] 使用构造器注入而非 @Autowired 字段注入 +- [ ] 配置属性使用了 @ConfigurationProperties +- [ ] Controller 职责单一,业务逻辑下沉到 Service +- [ ] 全局异常处理使用了 @ControllerAdvice / ProblemDetail + +### 数据库 & 事务 + +- [ ] 读操作事务标记了 `@Transactional(readOnly = true)` +- [ ] 检查是否存在 N+1 查询(EAGER fetch 或循环调用) +- [ ] Entity 类未使用 @Data,正确实现了 equals/hashCode +- [ ] 数据库索引是否覆盖了查询条件 + +### 并发与性能 + +- [ ] I/O 密集型任务是否考虑了虚拟线程? +- [ ] 线程安全类是否使用正确(ConcurrentHashMap vs HashMap) +- [ ] 锁的粒度是否合理?避免在锁内进行 I/O 操作 + +### 可维护性 + +- [ ] 关键业务逻辑有充分的单元测试 +- [ ] 日志记录恰当(使用 Slf4j,避免 System.out) +- [ ] 魔法值提取为常量或枚举 diff --git a/packages/mosaic/framework/skills/code-review-excellence/reference/performance-review-guide.md b/packages/mosaic/framework/skills/code-review-excellence/reference/performance-review-guide.md new file mode 100644 index 00000000..12e624a6 --- /dev/null +++ b/packages/mosaic/framework/skills/code-review-excellence/reference/performance-review-guide.md @@ -0,0 +1,760 @@ +# Performance Review Guide + +性能审查指南,覆盖前端、后端、数据库、算法复杂度和 API 性能。 + +## 目录 + +- [前端性能 (Core Web Vitals)](#前端性能-core-web-vitals) +- [JavaScript 性能](#javascript-性能) +- [内存管理](#内存管理) +- [数据库性能](#数据库性能) +- [API 性能](#api-性能) +- [算法复杂度](#算法复杂度) +- [性能审查清单](#性能审查清单) + +--- + +## 前端性能 (Core Web Vitals) + +### 2024 核心指标 + +| 指标 | 全称 | 目标值 | 含义 | +| ------- | ------------------------- | ------- | ------------------------------- | +| **LCP** | Largest Contentful Paint | ≤ 2.5s | 最大内容绘制时间 | +| **INP** | Interaction to Next Paint | ≤ 200ms | 交互响应时间(2024 年替代 FID) | +| **CLS** | Cumulative Layout Shift | ≤ 0.1 | 累积布局偏移 | +| **FCP** | First Contentful Paint | ≤ 1.8s | 首次内容绘制 | +| **TBT** | Total Blocking Time | ≤ 200ms | 主线程阻塞时间 | + +### LCP 优化检查 + +```javascript +// ❌ LCP 图片懒加载 - 延迟关键内容 + + +// ✅ LCP 图片立即加载 + + +// ❌ 未优化的图片格式 + // PNG 文件过大 + +// ✅ 现代图片格式 + 响应式 + + + + Hero + +``` + +**审查要点:** + +- [ ] LCP 元素是否设置 `fetchpriority="high"`? +- [ ] 是否使用 WebP/AVIF 格式? +- [ ] 是否有服务端渲染或静态生成? +- [ ] CDN 是否配置正确? + +### FCP 优化检查 + +```html + + + + + + + + +@font-face { font-family: 'CustomFont'; src: url('font.woff2'); } + + +@font-face { font-family: 'CustomFont'; src: url('font.woff2'); font-display: swap; /* +先用系统字体,加载后切换 */ } +``` + +### INP 优化检查 + +```javascript +// ❌ 长任务阻塞主线程 +button.addEventListener('click', () => { + // 耗时 500ms 的同步操作 + processLargeData(data); + updateUI(); +}); + +// ✅ 拆分长任务 +button.addEventListener('click', async () => { + // 让出主线程 + (await scheduler.yield?.()) ?? new Promise((r) => setTimeout(r, 0)); + + // 分批处理 + for (const chunk of chunks) { + processChunk(chunk); + await scheduler.yield?.(); + } + updateUI(); +}); + +// ✅ 使用 Web Worker 处理复杂计算 +const worker = new Worker('heavy-computation.js'); +worker.postMessage(data); +worker.onmessage = (e) => updateUI(e.data); +``` + +### CLS 优化检查 + +```css +/* ❌ 未指定尺寸的媒体 */ +img { + width: 100%; +} + +/* ✅ 预留空间 */ +img { + width: 100%; + aspect-ratio: 16 / 9; +} + +/* ❌ 动态插入内容导致布局偏移 */ +.ad-container { +} + +/* ✅ 预留固定高度 */ +.ad-container { + min-height: 250px; +} +``` + +**CLS 审查清单:** + +- [ ] 图片/视频是否有 width/height 或 aspect-ratio? +- [ ] 字体加载是否使用 `font-display: swap`? +- [ ] 动态内容是否预留空间? +- [ ] 是否避免在现有内容上方插入内容? + +--- + +## JavaScript 性能 + +### 代码分割与懒加载 + +```javascript +// ❌ 一次性加载所有代码 +import { HeavyChart } from './charts'; +import { PDFExporter } from './pdf'; +import { AdminPanel } from './admin'; + +// ✅ 按需加载 +const HeavyChart = lazy(() => import('./charts')); +const PDFExporter = lazy(() => import('./pdf')); + +// ✅ 路由级代码分割 +const routes = [ + { + path: '/dashboard', + component: lazy(() => import('./pages/Dashboard')), + }, + { + path: '/admin', + component: lazy(() => import('./pages/Admin')), + }, +]; +``` + +### Bundle 体积优化 + +```javascript +// ❌ 导入整个库 +import _ from 'lodash'; +import moment from 'moment'; + +// ✅ 按需导入 +import debounce from 'lodash/debounce'; +import { format } from 'date-fns'; + +// ❌ 未使用 Tree Shaking +export default { + fn1() {}, + fn2() {}, // 未使用但被打包 +}; + +// ✅ 命名导出支持 Tree Shaking +export function fn1() {} +export function fn2() {} +``` + +**Bundle 审查清单:** + +- [ ] 是否使用动态 import() 进行代码分割? +- [ ] 大型库是否按需导入? +- [ ] 是否分析过 bundle 大小?(webpack-bundle-analyzer) +- [ ] 是否有未使用的依赖? + +### 列表渲染优化 + +```javascript +// ❌ 渲染大列表 +function List({ items }) { + return ( +
    + {items.map((item) => ( +
  • {item.name}
  • + ))} +
+ ); // 10000 条数据 = 10000 个 DOM 节点 +} + +// ✅ 虚拟列表 - 只渲染可见项 +import { FixedSizeList } from 'react-window'; + +function VirtualList({ items }) { + return ( + + {({ index, style }) =>
{items[index].name}
} +
+ ); +} +``` + +**大数据审查要点:** + +- [ ] 列表超过 100 项是否使用虚拟滚动? +- [ ] 表格是否支持分页或虚拟化? +- [ ] 是否有不必要的全量渲染? + +--- + +## 内存管理 + +### 常见内存泄漏 + +#### 1. 未清理的事件监听 + +```javascript +// ❌ 组件卸载后事件仍在监听 +useEffect(() => { + window.addEventListener('resize', handleResize); +}, []); + +// ✅ 清理事件监听 +useEffect(() => { + window.addEventListener('resize', handleResize); + return () => window.removeEventListener('resize', handleResize); +}, []); +``` + +#### 2. 未清理的定时器 + +```javascript +// ❌ 定时器未清理 +useEffect(() => { + setInterval(fetchData, 5000); +}, []); + +// ✅ 清理定时器 +useEffect(() => { + const timer = setInterval(fetchData, 5000); + return () => clearInterval(timer); +}, []); +``` + +#### 3. 闭包引用 + +```javascript +// ❌ 闭包持有大对象引用 +function createHandler() { + const largeData = new Array(1000000).fill('x'); + + return function handler() { + // largeData 被闭包引用,无法被回收 + console.log(largeData.length); + }; +} + +// ✅ 只保留必要数据 +function createHandler() { + const largeData = new Array(1000000).fill('x'); + const length = largeData.length; // 只保留需要的值 + + return function handler() { + console.log(length); + }; +} +``` + +#### 4. 未清理的订阅 + +```javascript +// ❌ WebSocket/EventSource 未关闭 +useEffect(() => { + const ws = new WebSocket('wss://...'); + ws.onmessage = handleMessage; +}, []); + +// ✅ 清理连接 +useEffect(() => { + const ws = new WebSocket('wss://...'); + ws.onmessage = handleMessage; + return () => ws.close(); +}, []); +``` + +### 内存审查清单 + +```markdown +- [ ] useEffect 是否都有清理函数? +- [ ] 事件监听是否在组件卸载时移除? +- [ ] 定时器是否被清理? +- [ ] WebSocket/SSE 连接是否关闭? +- [ ] 大对象是否及时释放? +- [ ] 是否有全局变量累积数据? +``` + +### 检测工具 + +| 工具 | 用途 | +| ---------------------- | ------------------ | +| Chrome DevTools Memory | 堆快照分析 | +| MemLab (Meta) | 自动化内存泄漏检测 | +| Performance Monitor | 实时内存监控 | + +--- + +## 数据库性能 + +### N+1 查询问题 + +```python +# ❌ N+1 问题 - 1 + N 次查询 +users = User.objects.all() # 1 次查询 +for user in users: + print(user.profile.bio) # N 次查询(每个用户一次) + +# ✅ Eager Loading - 2 次查询 +users = User.objects.select_related('profile').all() +for user in users: + print(user.profile.bio) # 无额外查询 + +# ✅ 多对多关系用 prefetch_related +posts = Post.objects.prefetch_related('tags').all() +``` + +```javascript +// TypeORM 示例 +// ❌ N+1 问题 +const users = await userRepository.find(); +for (const user of users) { + const posts = await user.posts; // 每次循环都查询 +} + +// ✅ Eager Loading +const users = await userRepository.find({ + relations: ['posts'], +}); +``` + +### 索引优化 + +```sql +-- ❌ 全表扫描 +SELECT * FROM orders WHERE status = 'pending'; + +-- ✅ 添加索引 +CREATE INDEX idx_orders_status ON orders(status); + +-- ❌ 索引失效:函数操作 +SELECT * FROM users WHERE YEAR(created_at) = 2024; + +-- ✅ 范围查询可用索引 +SELECT * FROM users +WHERE created_at >= '2024-01-01' AND created_at < '2025-01-01'; + +-- ❌ 索引失效:LIKE 前缀通配符 +SELECT * FROM products WHERE name LIKE '%phone%'; + +-- ✅ 前缀匹配可用索引 +SELECT * FROM products WHERE name LIKE 'phone%'; +``` + +### 查询优化 + +```sql +-- ❌ SELECT * 获取不需要的列 +SELECT * FROM users WHERE id = 1; + +-- ✅ 只查询需要的列 +SELECT id, name, email FROM users WHERE id = 1; + +-- ❌ 大表无 LIMIT +SELECT * FROM logs WHERE type = 'error'; + +-- ✅ 分页查询 +SELECT * FROM logs WHERE type = 'error' LIMIT 100 OFFSET 0; + +-- ❌ 在循环中执行查询 +for id in user_ids: + cursor.execute("SELECT * FROM users WHERE id = %s", (id,)) + +-- ✅ 批量查询 +cursor.execute("SELECT * FROM users WHERE id IN %s", (tuple(user_ids),)) +``` + +### 数据库审查清单 + +```markdown +🔴 必须检查: + +- [ ] 是否存在 N+1 查询? +- [ ] WHERE 子句列是否有索引? +- [ ] 是否避免了 SELECT \*? +- [ ] 大表查询是否有 LIMIT? + +🟡 建议检查: + +- [ ] 是否使用了 EXPLAIN 分析查询计划? +- [ ] 复合索引列顺序是否正确? +- [ ] 是否有未使用的索引? +- [ ] 是否有慢查询日志监控? +``` + +--- + +## API 性能 + +### 分页实现 + +```javascript +// ❌ 返回全部数据 +app.get('/users', async (req, res) => { + const users = await User.findAll(); // 可能返回 100000 条 + res.json(users); +}); + +// ✅ 分页 + 限制最大数量 +app.get('/users', async (req, res) => { + const page = parseInt(req.query.page) || 1; + const limit = Math.min(parseInt(req.query.limit) || 20, 100); // 最大 100 + const offset = (page - 1) * limit; + + const { rows, count } = await User.findAndCountAll({ + limit, + offset, + order: [['id', 'ASC']], + }); + + res.json({ + data: rows, + pagination: { + page, + limit, + total: count, + totalPages: Math.ceil(count / limit), + }, + }); +}); +``` + +### 缓存策略 + +```javascript +// ✅ Redis 缓存示例 +async function getUser(id) { + const cacheKey = `user:${id}`; + + // 1. 检查缓存 + const cached = await redis.get(cacheKey); + if (cached) { + return JSON.parse(cached); + } + + // 2. 查询数据库 + const user = await db.users.findById(id); + + // 3. 写入缓存(设置过期时间) + await redis.setex(cacheKey, 3600, JSON.stringify(user)); + + return user; +} + +// ✅ HTTP 缓存头 +app.get('/static-data', (req, res) => { + res.set({ + 'Cache-Control': 'public, max-age=86400', // 24 小时 + ETag: 'abc123', + }); + res.json(data); +}); +``` + +### 响应压缩 + +```javascript +// ✅ 启用 Gzip/Brotli 压缩 +const compression = require('compression'); +app.use(compression()); + +// ✅ 只返回必要字段 +// 请求: GET /users?fields=id,name,email +app.get('/users', async (req, res) => { + const fields = req.query.fields?.split(',') || ['id', 'name']; + const users = await User.findAll({ + attributes: fields, + }); + res.json(users); +}); +``` + +### 限流保护 + +```javascript +// ✅ 速率限制 +const rateLimit = require('express-rate-limit'); + +const limiter = rateLimit({ + windowMs: 60 * 1000, // 1 分钟 + max: 100, // 最多 100 次请求 + message: { error: 'Too many requests, please try again later.' }, +}); + +app.use('/api/', limiter); +``` + +### API 审查清单 + +```markdown +- [ ] 列表接口是否有分页? +- [ ] 是否限制了每页最大数量? +- [ ] 热点数据是否有缓存? +- [ ] 是否启用了响应压缩? +- [ ] 是否有速率限制? +- [ ] 是否只返回必要字段? +``` + +--- + +## 算法复杂度 + +### 常见复杂度对比 + +| 复杂度 | 名称 | 10 条 | 1000 条 | 100 万条 | 示例 | +| ---------- | -------- | ----- | ------- | -------- | ------------ | +| O(1) | 常数 | 1 | 1 | 1 | 哈希查找 | +| O(log n) | 对数 | 3 | 10 | 20 | 二分查找 | +| O(n) | 线性 | 10 | 1000 | 100 万 | 遍历数组 | +| O(n log n) | 线性对数 | 33 | 10000 | 2000 万 | 快速排序 | +| O(n²) | 平方 | 100 | 100 万 | 1 万亿 | 嵌套循环 | +| O(2ⁿ) | 指数 | 1024 | ∞ | ∞ | 递归斐波那契 | + +### 代码审查中的识别 + +```javascript +// ❌ O(n²) - 嵌套循环 +function findDuplicates(arr) { + const duplicates = []; + for (let i = 0; i < arr.length; i++) { + for (let j = i + 1; j < arr.length; j++) { + if (arr[i] === arr[j]) { + duplicates.push(arr[i]); + } + } + } + return duplicates; +} + +// ✅ O(n) - 使用 Set +function findDuplicates(arr) { + const seen = new Set(); + const duplicates = new Set(); + for (const item of arr) { + if (seen.has(item)) { + duplicates.add(item); + } + seen.add(item); + } + return [...duplicates]; +} +``` + +```javascript +// ❌ O(n²) - 每次循环都调用 includes +function removeDuplicates(arr) { + const result = []; + for (const item of arr) { + if (!result.includes(item)) { + // includes 是 O(n) + result.push(item); + } + } + return result; +} + +// ✅ O(n) - 使用 Set +function removeDuplicates(arr) { + return [...new Set(arr)]; +} +``` + +```javascript +// ❌ O(n) 查找 - 每次都遍历 +const users = [{ id: 1, name: 'A' }, { id: 2, name: 'B' }, ...]; + +function getUser(id) { + return users.find(u => u.id === id); // O(n) +} + +// ✅ O(1) 查找 - 使用 Map +const userMap = new Map(users.map(u => [u.id, u])); + +function getUser(id) { + return userMap.get(id); // O(1) +} +``` + +### 空间复杂度考虑 + +```javascript +// ⚠️ O(n) 空间 - 创建新数组 +const doubled = arr.map((x) => x * 2); + +// ✅ O(1) 空间 - 原地修改(如果允许) +for (let i = 0; i < arr.length; i++) { + arr[i] *= 2; +} + +// ⚠️ 递归深度过大可能栈溢出 +function factorial(n) { + if (n <= 1) return 1; + return n * factorial(n - 1); // O(n) 栈空间 +} + +// ✅ 迭代版本 O(1) 空间 +function factorial(n) { + let result = 1; + for (let i = 2; i <= n; i++) { + result *= i; + } + return result; +} +``` + +### 复杂度审查问题 + +```markdown +💡 "这个嵌套循环的复杂度是 O(n²),数据量大时会有性能问题" +🔴 "这里用 Array.includes() 在循环中,整体是 O(n²),建议用 Set" +🟡 "这个递归深度可能导致栈溢出,建议改为迭代或尾递归" +``` + +--- + +## 性能审查清单 + +### 🔴 必须检查(阻塞级) + +**前端:** + +- [ ] LCP 图片是否懒加载?(不应该) +- [ ] 是否有 `transition: all`? +- [ ] 是否动画 width/height/top/left? +- [ ] 列表 >100 项是否虚拟化? + +**后端:** + +- [ ] 是否存在 N+1 查询? +- [ ] 列表接口是否有分页? +- [ ] 是否有 SELECT \* 查大表? + +**通用:** + +- [ ] 是否有 O(n²) 或更差的嵌套循环? +- [ ] useEffect/事件监听是否有清理? + +### 🟡 建议检查(重要级) + +**前端:** + +- [ ] 是否使用代码分割? +- [ ] 大型库是否按需导入? +- [ ] 图片是否使用 WebP/AVIF? +- [ ] 是否有未使用的依赖? + +**后端:** + +- [ ] 热点数据是否有缓存? +- [ ] WHERE 列是否有索引? +- [ ] 是否有慢查询监控? + +**API:** + +- [ ] 是否启用响应压缩? +- [ ] 是否有速率限制? +- [ ] 是否只返回必要字段? + +### 🟢 优化建议(建议级) + +- [ ] 是否分析过 bundle 大小? +- [ ] 是否使用 CDN? +- [ ] 是否有性能监控? +- [ ] 是否做过性能基准测试? + +--- + +## 性能度量阈值 + +### 前端指标 + +| 指标 | 好 | 需改进 | 差 | +| ---------------- | ------- | --------- | ------- | +| LCP | ≤ 2.5s | 2.5-4s | > 4s | +| INP | ≤ 200ms | 200-500ms | > 500ms | +| CLS | ≤ 0.1 | 0.1-0.25 | > 0.25 | +| FCP | ≤ 1.8s | 1.8-3s | > 3s | +| Bundle Size (JS) | < 200KB | 200-500KB | > 500KB | + +### 后端指标 + +| 指标 | 好 | 需改进 | 差 | +| ------------ | ------- | --------- | ------- | +| API 响应时间 | < 100ms | 100-500ms | > 500ms | +| 数据库查询 | < 50ms | 50-200ms | > 200ms | +| 页面加载 | < 3s | 3-5s | > 5s | + +--- + +## 工具推荐 + +### 前端性能 + +| 工具 | 用途 | +| -------------------------------------------------------------------------------------- | -------------------- | +| [Lighthouse](https://developer.chrome.com/docs/lighthouse/) | Core Web Vitals 测试 | +| [WebPageTest](https://www.webpagetest.org/) | 详细性能分析 | +| [webpack-bundle-analyzer](https://github.com/webpack-contrib/webpack-bundle-analyzer) | Bundle 分析 | +| [Chrome DevTools Performance](https://developer.chrome.com/docs/devtools/performance/) | 运行时性能分析 | + +### 内存检测 + +| 工具 | 用途 | +| ----------------------------------------------------- | ------------------ | +| [MemLab](https://github.com/facebookincubator/memlab) | 自动化内存泄漏检测 | +| Chrome Memory Tab | 堆快照分析 | + +### 后端性能 + +| 工具 | 用途 | +| -------------------------------------------------------------------------- | ------------------- | +| EXPLAIN | 数据库查询计划分析 | +| [pganalyze](https://pganalyze.com/) | PostgreSQL 性能监控 | +| [New Relic](https://newrelic.com/) / [Datadog](https://www.datadoghq.com/) | APM 监控 | + +--- + +## 参考资源 + +- [Core Web Vitals - web.dev](https://web.dev/articles/vitals) +- [Optimizing Core Web Vitals - Vercel](https://vercel.com/guides/optimizing-core-web-vitals-in-2024) +- [MemLab - Meta Engineering](https://engineering.fb.com/2022/09/12/open-source/memlab/) +- [Big O Cheat Sheet](https://www.bigocheatsheet.com/) +- [N+1 Query Problem - Stack Overflow](https://stackoverflow.com/questions/97197/what-is-the-n1-selects-problem-in-orm-object-relational-mapping) +- [API Performance Optimization](https://algorithmsin60days.com/blog/optimizing-api-performance/) diff --git a/packages/mosaic/framework/skills/code-review-excellence/reference/python.md b/packages/mosaic/framework/skills/code-review-excellence/reference/python.md new file mode 100644 index 00000000..7437ea1a --- /dev/null +++ b/packages/mosaic/framework/skills/code-review-excellence/reference/python.md @@ -0,0 +1,1076 @@ +# Python Code Review Guide + +> Python 代码审查指南,覆盖类型注解、async/await、测试、异常处理、性能优化等核心主题。 + +## 目录 + +- [类型注解](#类型注解) +- [异步编程](#异步编程) +- [异常处理](#异常处理) +- [常见陷阱](#常见陷阱) +- [测试最佳实践](#测试最佳实践) +- [性能优化](#性能优化) +- [代码风格](#代码风格) +- [Review Checklist](#review-checklist) + +--- + +## 类型注解 + +### 基础类型注解 + +```python +# ❌ 没有类型注解,IDE 无法提供帮助 +def process_data(data, count): + return data[:count] + +# ✅ 使用类型注解 +def process_data(data: str, count: int) -> str: + return data[:count] + +# ✅ 复杂类型使用 typing 模块 +from typing import Optional, Union + +def find_user(user_id: int) -> Optional[User]: + """返回用户或 None""" + return db.get(user_id) + +def handle_input(value: Union[str, int]) -> str: + """接受字符串或整数""" + return str(value) +``` + +### 容器类型注解 + +```python +from typing import List, Dict, Set, Tuple, Sequence + +# ❌ 不精确的类型 +def get_names(users: list) -> list: + return [u.name for u in users] + +# ✅ 精确的容器类型(Python 3.9+ 可直接用 list[User]) +def get_names(users: List[User]) -> List[str]: + return [u.name for u in users] + +# ✅ 只读序列用 Sequence(更灵活) +def process_items(items: Sequence[str]) -> int: + return len(items) + +# ✅ 字典类型 +def count_words(text: str) -> Dict[str, int]: + words: Dict[str, int] = {} + for word in text.split(): + words[word] = words.get(word, 0) + 1 + return words + +# ✅ 元组(固定长度和类型) +def get_point() -> Tuple[float, float]: + return (1.0, 2.0) + +# ✅ 可变长度元组 +def get_scores() -> Tuple[int, ...]: + return (90, 85, 92, 88) +``` + +### 泛型与 TypeVar + +```python +from typing import TypeVar, Generic, List, Callable + +T = TypeVar('T') +K = TypeVar('K') +V = TypeVar('V') + +# ✅ 泛型函数 +def first(items: List[T]) -> T | None: + return items[0] if items else None + +# ✅ 有约束的 TypeVar +from typing import Hashable +H = TypeVar('H', bound=Hashable) + +def dedupe(items: List[H]) -> List[H]: + return list(set(items)) + +# ✅ 泛型类 +class Cache(Generic[K, V]): + def __init__(self) -> None: + self._data: Dict[K, V] = {} + + def get(self, key: K) -> V | None: + return self._data.get(key) + + def set(self, key: K, value: V) -> None: + self._data[key] = value +``` + +### Callable 与回调函数 + +```python +from typing import Callable, Awaitable + +# ✅ 函数类型注解 +Handler = Callable[[str, int], bool] + +def register_handler(name: str, handler: Handler) -> None: + handlers[name] = handler + +# ✅ 异步回调 +AsyncHandler = Callable[[str], Awaitable[dict]] + +async def fetch_with_handler( + url: str, + handler: AsyncHandler +) -> dict: + return await handler(url) + +# ✅ 返回函数的函数 +def create_multiplier(factor: int) -> Callable[[int], int]: + def multiplier(x: int) -> int: + return x * factor + return multiplier +``` + +### TypedDict 与结构化数据 + +```python +from typing import TypedDict, Required, NotRequired + +# ✅ 定义字典结构 +class UserDict(TypedDict): + id: int + name: str + email: str + age: NotRequired[int] # Python 3.11+ + +def create_user(data: UserDict) -> User: + return User(**data) + +# ✅ 部分必需字段 +class ConfigDict(TypedDict, total=False): + debug: bool + timeout: int + host: Required[str] # 这个必须有 +``` + +### Protocol 与结构化子类型 + +```python +from typing import Protocol, runtime_checkable + +# ✅ 定义协议(鸭子类型的类型检查) +class Readable(Protocol): + def read(self, size: int = -1) -> bytes: ... + +class Closeable(Protocol): + def close(self) -> None: ... + +# 组合协议 +class ReadableCloseable(Readable, Closeable, Protocol): + pass + +def process_stream(stream: Readable) -> bytes: + return stream.read() + +# ✅ 运行时可检查的协议 +@runtime_checkable +class Drawable(Protocol): + def draw(self) -> None: ... + +def render(obj: object) -> None: + if isinstance(obj, Drawable): # 运行时检查 + obj.draw() +``` + +--- + +## 异步编程 + +### async/await 基础 + +```python +import asyncio + +# ❌ 同步阻塞调用 +def fetch_all_sync(urls: list[str]) -> list[str]: + results = [] + for url in urls: + results.append(requests.get(url).text) # 串行执行 + return results + +# ✅ 异步并发调用 +async def fetch_url(url: str) -> str: + async with aiohttp.ClientSession() as session: + async with session.get(url) as response: + return await response.text() + +async def fetch_all(urls: list[str]) -> list[str]: + tasks = [fetch_url(url) for url in urls] + return await asyncio.gather(*tasks) # 并发执行 +``` + +### 异步上下文管理器 + +```python +from contextlib import asynccontextmanager +from typing import AsyncIterator + +# ✅ 异步上下文管理器类 +class AsyncDatabase: + async def __aenter__(self) -> 'AsyncDatabase': + await self.connect() + return self + + async def __aexit__(self, exc_type, exc_val, exc_tb) -> None: + await self.disconnect() + +# ✅ 使用装饰器 +@asynccontextmanager +async def get_connection() -> AsyncIterator[Connection]: + conn = await create_connection() + try: + yield conn + finally: + await conn.close() + +async def query_data(): + async with get_connection() as conn: + return await conn.fetch("SELECT * FROM users") +``` + +### 异步迭代器 + +```python +from typing import AsyncIterator + +# ✅ 异步生成器 +async def fetch_pages(url: str) -> AsyncIterator[dict]: + page = 1 + while True: + data = await fetch_page(url, page) + if not data['items']: + break + yield data + page += 1 + +# ✅ 使用异步迭代 +async def process_all_pages(): + async for page in fetch_pages("https://api.example.com"): + await process_page(page) +``` + +### 任务管理与取消 + +```python +import asyncio + +# ❌ 忘记处理取消 +async def bad_worker(): + while True: + await do_work() # 无法正常取消 + +# ✅ 正确处理取消 +async def good_worker(): + try: + while True: + await do_work() + except asyncio.CancelledError: + await cleanup() # 清理资源 + raise # 重新抛出,让调用者知道已取消 + +# ✅ 超时控制 +async def fetch_with_timeout(url: str) -> str: + try: + async with asyncio.timeout(10): # Python 3.11+ + return await fetch_url(url) + except asyncio.TimeoutError: + return "" + +# ✅ 任务组(Python 3.11+) +async def fetch_multiple(): + async with asyncio.TaskGroup() as tg: + task1 = tg.create_task(fetch_url("url1")) + task2 = tg.create_task(fetch_url("url2")) + # 所有任务完成后自动等待,异常会传播 + return task1.result(), task2.result() +``` + +### 同步与异步混合 + +```python +import asyncio +from concurrent.futures import ThreadPoolExecutor + +# ✅ 在异步代码中运行同步函数 +async def run_sync_in_async(): + loop = asyncio.get_event_loop() + # 使用线程池执行阻塞操作 + result = await loop.run_in_executor( + None, # 默认线程池 + blocking_io_function, + arg1, arg2 + ) + return result + +# ✅ 在同步代码中运行异步函数 +def run_async_in_sync(): + return asyncio.run(async_function()) + +# ❌ 不要在异步代码中使用 time.sleep +async def bad_delay(): + time.sleep(1) # 会阻塞整个事件循环! + +# ✅ 使用 asyncio.sleep +async def good_delay(): + await asyncio.sleep(1) +``` + +### 信号量与限流 + +```python +import asyncio + +# ✅ 使用信号量限制并发 +async def fetch_with_limit(urls: list[str], max_concurrent: int = 10): + semaphore = asyncio.Semaphore(max_concurrent) + + async def fetch_one(url: str) -> str: + async with semaphore: + return await fetch_url(url) + + return await asyncio.gather(*[fetch_one(url) for url in urls]) + +# ✅ 使用 asyncio.Queue 实现生产者-消费者 +async def producer_consumer(): + queue: asyncio.Queue[str] = asyncio.Queue(maxsize=100) + + async def producer(): + for item in items: + await queue.put(item) + await queue.put(None) # 结束信号 + + async def consumer(): + while True: + item = await queue.get() + if item is None: + break + await process(item) + queue.task_done() + + await asyncio.gather(producer(), consumer()) +``` + +--- + +## 异常处理 + +### 异常捕获最佳实践 + +```python +# ❌ Catching too broad +try: + result = risky_operation() +except: # Catches everything, even KeyboardInterrupt! + pass + +# ❌ 捕获 Exception 但不处理 +try: + result = risky_operation() +except Exception: + pass # 吞掉所有异常,难以调试 + +# ✅ Catch specific exceptions +try: + result = risky_operation() +except ValueError as e: + logger.error(f"Invalid value: {e}") + raise +except IOError as e: + logger.error(f"IO error: {e}") + return default_value + +# ✅ 多个异常类型 +try: + result = parse_and_process(data) +except (ValueError, TypeError, KeyError) as e: + logger.error(f"Data error: {e}") + raise DataProcessingError(str(e)) from e +``` + +### 异常链 + +```python +# ❌ 丢失原始异常信息 +try: + result = external_api.call() +except APIError as e: + raise RuntimeError("API failed") # 丢失了原因 + +# ✅ 使用 from 保留异常链 +try: + result = external_api.call() +except APIError as e: + raise RuntimeError("API failed") from e + +# ✅ 显式断开异常链(少见情况) +try: + result = external_api.call() +except APIError: + raise RuntimeError("API failed") from None +``` + +### 自定义异常 + +```python +# ✅ 定义业务异常层次结构 +class AppError(Exception): + """应用基础异常""" + pass + +class ValidationError(AppError): + """数据验证错误""" + def __init__(self, field: str, message: str): + self.field = field + self.message = message + super().__init__(f"{field}: {message}") + +class NotFoundError(AppError): + """资源未找到""" + def __init__(self, resource: str, id: str | int): + self.resource = resource + self.id = id + super().__init__(f"{resource} with id {id} not found") + +# 使用 +def get_user(user_id: int) -> User: + user = db.get(user_id) + if not user: + raise NotFoundError("User", user_id) + return user +``` + +### 上下文管理器中的异常 + +```python +from contextlib import contextmanager + +# ✅ 正确处理上下文管理器中的异常 +@contextmanager +def transaction(): + conn = get_connection() + try: + yield conn + conn.commit() + except Exception: + conn.rollback() + raise + finally: + conn.close() + +# ✅ 使用 ExceptionGroup(Python 3.11+) +def process_batch(items: list) -> None: + errors = [] + for item in items: + try: + process(item) + except Exception as e: + errors.append(e) + + if errors: + raise ExceptionGroup("Batch processing failed", errors) +``` + +--- + +## 常见陷阱 + +### 可变默认参数 + +```python +# ❌ Mutable default arguments +def add_item(item, items=[]): # Bug! Shared across calls + items.append(item) + return items + +# 问题演示 +add_item(1) # [1] +add_item(2) # [1, 2] 而不是 [2]! + +# ✅ Use None as default +def add_item(item, items=None): + if items is None: + items = [] + items.append(item) + return items + +# ✅ 或使用 dataclass 的 field +from dataclasses import dataclass, field + +@dataclass +class Container: + items: list = field(default_factory=list) +``` + +### 可变类属性 + +```python +# ❌ Using mutable class attributes +class User: + permissions = [] # Shared across all instances! + +# 问题演示 +u1 = User() +u2 = User() +u1.permissions.append("admin") +print(u2.permissions) # ["admin"] - 被意外共享! + +# ✅ Initialize in __init__ +class User: + def __init__(self): + self.permissions = [] + +# ✅ 使用 dataclass +@dataclass +class User: + permissions: list = field(default_factory=list) +``` + +### 循环中的闭包 + +```python +# ❌ 闭包捕获循环变量 +funcs = [] +for i in range(3): + funcs.append(lambda: i) + +print([f() for f in funcs]) # [2, 2, 2] 而不是 [0, 1, 2]! + +# ✅ 使用默认参数捕获值 +funcs = [] +for i in range(3): + funcs.append(lambda i=i: i) + +print([f() for f in funcs]) # [0, 1, 2] + +# ✅ 使用 functools.partial +from functools import partial + +funcs = [partial(lambda x: x, i) for i in range(3)] +``` + +### is vs == + +```python +# ❌ 用 is 比较值 +if x is 1000: # 可能不工作! + pass + +# Python 会缓存小整数 (-5 到 256) +a = 256 +b = 256 +a is b # True + +a = 257 +b = 257 +a is b # False! + +# ✅ 用 == 比较值 +if x == 1000: + pass + +# ✅ is 只用于 None 和单例 +if x is None: + pass + +if x is True: # 严格检查布尔值 + pass +``` + +### 字符串拼接性能 + +```python +# ❌ 循环中拼接字符串 +result = "" +for item in large_list: + result += str(item) # O(n²) 复杂度 + +# ✅ 使用 join +result = "".join(str(item) for item in large_list) # O(n) + +# ✅ 使用 StringIO 构建大字符串 +from io import StringIO + +buffer = StringIO() +for item in large_list: + buffer.write(str(item)) +result = buffer.getvalue() +``` + +--- + +## 测试最佳实践 + +### pytest 基础 + +```python +import pytest + +# ✅ 清晰的测试命名 +def test_user_creation_with_valid_email(): + user = User(email="test@example.com") + assert user.email == "test@example.com" + +def test_user_creation_with_invalid_email_raises_error(): + with pytest.raises(ValidationError): + User(email="invalid") + +# ✅ 使用参数化测试 +@pytest.mark.parametrize("input,expected", [ + ("hello", "HELLO"), + ("World", "WORLD"), + ("", ""), + ("123", "123"), +]) +def test_uppercase(input: str, expected: str): + assert input.upper() == expected + +# ✅ 测试异常 +def test_division_by_zero(): + with pytest.raises(ZeroDivisionError) as exc_info: + 1 / 0 + assert "division by zero" in str(exc_info.value) +``` + +### Fixtures + +```python +import pytest +from typing import Generator + +# ✅ 基础 fixture +@pytest.fixture +def user() -> User: + return User(name="Test User", email="test@example.com") + +def test_user_name(user: User): + assert user.name == "Test User" + +# ✅ 带清理的 fixture +@pytest.fixture +def database() -> Generator[Database, None, None]: + db = Database() + db.connect() + yield db + db.disconnect() # 测试后清理 + +# ✅ 异步 fixture +@pytest.fixture +async def async_client() -> AsyncGenerator[AsyncClient, None]: + async with AsyncClient() as client: + yield client + +# ✅ 共享 fixture(conftest.py) +# conftest.py +@pytest.fixture(scope="session") +def app(): + """整个测试会话共享的 app 实例""" + return create_app() + +@pytest.fixture(scope="module") +def db(app): + """每个测试模块共享的数据库连接""" + return app.db +``` + +### Mock 与 Patch + +```python +from unittest.mock import Mock, patch, AsyncMock + +# ✅ Mock 外部依赖 +def test_send_email(): + mock_client = Mock() + mock_client.send.return_value = True + + service = EmailService(client=mock_client) + result = service.send_welcome_email("user@example.com") + + assert result is True + mock_client.send.assert_called_once_with( + to="user@example.com", + subject="Welcome!", + body=ANY, + ) + +# ✅ Patch 模块级函数 +@patch("myapp.services.external_api.call") +def test_with_patched_api(mock_call): + mock_call.return_value = {"status": "ok"} + + result = process_data() + + assert result["status"] == "ok" + +# ✅ 异步 Mock +async def test_async_function(): + mock_fetch = AsyncMock(return_value={"data": "test"}) + + with patch("myapp.client.fetch", mock_fetch): + result = await get_data() + + assert result == {"data": "test"} +``` + +### 测试组织 + +```python +# ✅ 使用类组织相关测试 +class TestUserAuthentication: + """用户认证相关测试""" + + def test_login_with_valid_credentials(self, user): + assert authenticate(user.email, "password") is True + + def test_login_with_invalid_password(self, user): + assert authenticate(user.email, "wrong") is False + + def test_login_locks_after_failed_attempts(self, user): + for _ in range(5): + authenticate(user.email, "wrong") + assert user.is_locked is True + +# ✅ 使用 mark 标记测试 +@pytest.mark.slow +def test_large_data_processing(): + pass + +@pytest.mark.integration +def test_database_connection(): + pass + +# 运行特定标记的测试:pytest -m "not slow" +``` + +### 覆盖率与质量 + +```python +# pytest.ini 或 pyproject.toml +[tool.pytest.ini_options] +addopts = "--cov=myapp --cov-report=term-missing --cov-fail-under=80" +testpaths = ["tests"] + +# ✅ 测试边界情况 +def test_empty_input(): + assert process([]) == [] + +def test_none_input(): + with pytest.raises(TypeError): + process(None) + +def test_large_input(): + large_data = list(range(100000)) + result = process(large_data) + assert len(result) == 100000 +``` + +--- + +## 性能优化 + +### 数据结构选择 + +```python +# ❌ 列表查找 O(n) +if item in large_list: # 慢 + pass + +# ✅ 集合查找 O(1) +large_set = set(large_list) +if item in large_set: # 快 + pass + +# ✅ 使用 collections 模块 +from collections import Counter, defaultdict, deque + +# 计数 +word_counts = Counter(words) +most_common = word_counts.most_common(10) + +# 默认字典 +graph = defaultdict(list) +graph[node].append(neighbor) + +# 双端队列(两端操作 O(1)) +queue = deque() +queue.appendleft(item) # O(1) vs list.insert(0, item) O(n) +``` + +### 生成器与迭代器 + +```python +# ❌ 一次性加载所有数据 +def get_all_users(): + return [User(row) for row in db.fetch_all()] # 内存占用大 + +# ✅ 使用生成器 +def get_all_users(): + for row in db.fetch_all(): + yield User(row) # 懒加载 + +# ✅ 生成器表达式 +sum_of_squares = sum(x**2 for x in range(1000000)) # 不创建列表 + +# ✅ itertools 模块 +from itertools import islice, chain, groupby + +# 只取前 10 个 +first_10 = list(islice(infinite_generator(), 10)) + +# 链接多个迭代器 +all_items = chain(list1, list2, list3) + +# 分组 +for key, group in groupby(sorted(items, key=get_key), key=get_key): + process_group(key, list(group)) +``` + +### 缓存 + +```python +from functools import lru_cache, cache + +# ✅ LRU 缓存 +@lru_cache(maxsize=128) +def expensive_computation(n: int) -> int: + return sum(i**2 for i in range(n)) + +# ✅ 无限缓存(Python 3.9+) +@cache +def fibonacci(n: int) -> int: + if n < 2: + return n + return fibonacci(n - 1) + fibonacci(n - 2) + +# ✅ 手动缓存(需要更多控制时) +class DataService: + def __init__(self): + self._cache: dict[str, Any] = {} + self._cache_ttl: dict[str, float] = {} + + def get_data(self, key: str) -> Any: + if key in self._cache: + if time.time() < self._cache_ttl[key]: + return self._cache[key] + + data = self._fetch_data(key) + self._cache[key] = data + self._cache_ttl[key] = time.time() + 300 # 5 分钟 + return data +``` + +### 并行处理 + +```python +from concurrent.futures import ThreadPoolExecutor, ProcessPoolExecutor + +# ✅ IO 密集型使用线程池 +def fetch_all_urls(urls: list[str]) -> list[str]: + with ThreadPoolExecutor(max_workers=10) as executor: + results = list(executor.map(fetch_url, urls)) + return results + +# ✅ CPU 密集型使用进程池 +def process_large_dataset(data: list) -> list: + with ProcessPoolExecutor() as executor: + results = list(executor.map(heavy_computation, data)) + return results + +# ✅ 使用 as_completed 获取最先完成的结果 +from concurrent.futures import as_completed + +with ThreadPoolExecutor() as executor: + futures = {executor.submit(fetch, url): url for url in urls} + for future in as_completed(futures): + url = futures[future] + try: + result = future.result() + except Exception as e: + print(f"{url} failed: {e}") +``` + +--- + +## 代码风格 + +### PEP 8 要点 + +```python +# ✅ 命名规范 +class MyClass: # 类名 PascalCase + MAX_SIZE = 100 # 常量 UPPER_SNAKE_CASE + + def method_name(self): # 方法 snake_case + local_var = 1 # 变量 snake_case + +# ✅ 导入顺序 +# 1. 标准库 +import os +import sys +from typing import Optional + +# 2. 第三方库 +import numpy as np +import pandas as pd + +# 3. 本地模块 +from myapp import config +from myapp.utils import helper + +# ✅ 行长度限制(79 或 88 字符) +# 长表达式的换行 +result = ( + long_function_name(arg1, arg2, arg3) + + another_long_function(arg4, arg5) +) + +# ✅ 空行规范 +class MyClass: + """类文档字符串""" + + def method_one(self): + pass + + def method_two(self): # 方法间一个空行 + pass + + +def top_level_function(): # 顶层定义间两个空行 + pass +``` + +### 文档字符串 + +```python +# ✅ Google 风格文档字符串 +def calculate_area(width: float, height: float) -> float: + """计算矩形面积。 + + Args: + width: 矩形的宽度(必须为正数)。 + height: 矩形的高度(必须为正数)。 + + Returns: + 矩形的面积。 + + Raises: + ValueError: 如果 width 或 height 为负数。 + + Example: + >>> calculate_area(3, 4) + 12.0 + """ + if width < 0 or height < 0: + raise ValueError("Dimensions must be positive") + return width * height + +# ✅ 类文档字符串 +class DataProcessor: + """处理和转换数据的工具类。 + + Attributes: + source: 数据来源路径。 + format: 输出格式('json' 或 'csv')。 + + Example: + >>> processor = DataProcessor("data.csv") + >>> processor.process() + """ +``` + +### 现代 Python 特性 + +```python +# ✅ f-string(Python 3.6+) +name = "World" +print(f"Hello, {name}!") + +# 带表达式 +print(f"Result: {1 + 2 = }") # "Result: 1 + 2 = 3" + +# ✅ 海象运算符(Python 3.8+) +if (n := len(items)) > 10: + print(f"List has {n} items") + +# ✅ 位置参数分隔符(Python 3.8+) +def greet(name, /, greeting="Hello", *, punctuation="!"): + """name 只能位置传参,punctuation 只能关键字传参""" + return f"{greeting}, {name}{punctuation}" + +# ✅ 模式匹配(Python 3.10+) +def handle_response(response: dict): + match response: + case {"status": "ok", "data": data}: + return process_data(data) + case {"status": "error", "message": msg}: + raise APIError(msg) + case _: + raise ValueError("Unknown response format") +``` + +--- + +## Review Checklist + +### 类型安全 + +- [ ] 函数有类型注解(参数和返回值) +- [ ] 使用 `Optional` 明确可能为 None +- [ ] 泛型类型正确使用 +- [ ] mypy 检查通过(无错误) +- [ ] 避免使用 `Any`,必要时添加注释说明 + +### 异步代码 + +- [ ] async/await 正确配对使用 +- [ ] 没有在异步代码中使用阻塞调用 +- [ ] 正确处理 `CancelledError` +- [ ] 使用 `asyncio.gather` 或 `TaskGroup` 并发执行 +- [ ] 资源正确清理(async context manager) + +### 异常处理 + +- [ ] 捕获特定异常类型,不使用裸 `except:` +- [ ] 异常链使用 `from` 保留原因 +- [ ] 自定义异常继承自合适的基类 +- [ ] 异常信息有意义,便于调试 + +### 数据结构 + +- [ ] 没有使用可变默认参数(list、dict、set) +- [ ] 类属性不是可变对象 +- [ ] 选择正确的数据结构(set vs list 查找) +- [ ] 大数据集使用生成器而非列表 + +### 测试 + +- [ ] 测试覆盖率达标(建议 ≥80%) +- [ ] 测试命名清晰描述测试场景 +- [ ] 边界情况有测试覆盖 +- [ ] Mock 正确隔离外部依赖 +- [ ] 异步代码有对应的异步测试 + +### 代码风格 + +- [ ] 遵循 PEP 8 风格指南 +- [ ] 函数和类有 docstring +- [ ] 导入顺序正确(标准库、第三方、本地) +- [ ] 命名一致且有意义 +- [ ] 使用现代 Python 特性(f-string、walrus operator 等) + +### 性能 + +- [ ] 避免循环中重复创建对象 +- [ ] 字符串拼接使用 join +- [ ] 合理使用缓存(@lru_cache) +- [ ] IO/CPU 密集型使用合适的并行方式 diff --git a/packages/mosaic/framework/skills/code-review-excellence/reference/qt.md b/packages/mosaic/framework/skills/code-review-excellence/reference/qt.md new file mode 100644 index 00000000..cb645c15 --- /dev/null +++ b/packages/mosaic/framework/skills/code-review-excellence/reference/qt.md @@ -0,0 +1,203 @@ +# Qt Code Review Guide + +> Code review guidelines focusing on object model, signals/slots, event loop, and GUI performance. Examples based on Qt 5.15 / Qt 6. + +## Table of Contents + +- [Object Model & Memory Management](#object-model--memory-management) +- [Signals & Slots](#signals--slots) +- [Containers & Strings](#containers--strings) +- [Threads & Concurrency](#threads--concurrency) +- [GUI & Widgets](#gui--widgets) +- [Meta-Object System](#meta-object-system) +- [Review Checklist](#review-checklist) + +--- + +## Object Model & Memory Management + +### Use Parent-Child Ownership Mechanism + +Qt's `QObject` hierarchy automatically manages memory. For `QObject`, prefer setting a parent object over manual `delete` or smart pointers. + +```cpp +// ❌ Manual management prone to memory leaks +QWidget* w = new QWidget(); +QLabel* l = new QLabel(); +l->setParent(w); +// ... If w is deleted, l is automatically deleted. But if w leaks, l also leaks. + +// ✅ Specify parent in constructor +QWidget* w = new QWidget(this); // Owned by 'this' +QLabel* l = new QLabel(w); // Owned by 'w' +``` + +### Use Smart Pointers with QObject + +If a `QObject` has no parent, use `QScopedPointer` or `std::unique_ptr` with a custom deleter (use `deleteLater` if cross-thread). Avoid `std::shared_ptr` for `QObject` unless necessary, as it confuses the parent-child ownership system. + +```cpp +// ✅ Scoped pointer for local/member QObject without parent +QScopedPointer obj(new MyObject()); + +// ✅ Safe pointer to prevent dangling pointers +QPointer safePtr = obj.data(); +if (safePtr) { + safePtr->doSomething(); +} +``` + +### Use `deleteLater()` + +For asynchronous deletion, especially in slots or event handlers, use `deleteLater()` instead of `delete` to ensure pending events in the event loop are processed. + +--- + +## Signals & Slots + +### Prefer Function Pointer Syntax + +Use compile-time checked syntax (Qt 5+). + +```cpp +// ❌ String-based (runtime check only, slower) +connect(sender, SIGNAL(valueChanged(int)), receiver, SLOT(updateValue(int))); + +// ✅ Compile-time check +connect(sender, &Sender::valueChanged, receiver, &Receiver::updateValue); +``` + +### Connection Types + +Be explicit or aware of connection types when crossing threads. + +- `Qt::AutoConnection` (Default): Direct if same thread, Queued if different thread. +- `Qt::QueuedConnection`: Always posts event (thread-safe across threads). +- `Qt::DirectConnection`: Immediate call (dangerous if accessing non-thread-safe data across threads). + +### Avoid Loops + +Check logic that might cause infinite signal loops (e.g., `valueChanged` -> `setValue` -> `valueChanged`). Block signals or check for equality before setting values. + +```cpp +void MyClass::setValue(int v) { + if (m_value == v) return; // ? Good: Break loop + m_value = v; + emit valueChanged(v); +} +``` + +--- + +## Containers & Strings + +### QString Efficiency + +- Use `QStringLiteral("...")` for compile-time string creation to avoid runtime allocation. +- Use `QLatin1String` for comparison with ASCII literals (in Qt 5). +- Prefer `arg()` for formatting (or `QStringBuilder`'s `%` operator). + +```cpp +// ❌ Runtime conversion +if (str == "test") ... + +// ✅ Prefer QLatin1String for comparison with ASCII literals (in Qt 5) +if (str == QLatin1String("test")) ... // Qt 5 +if (str == u"test"_s) ... // Qt 6 +``` + +### Container Selection + +- **Qt 6**: `QList` is now the default choice (unified with `QVector`). +- **Qt 5**: Prefer `QVector` over `QList` for contiguous memory and cache performance, unless stable references are needed. +- Be aware of Implicit Sharing (Copy-on-Write). Passing containers by value is cheap _until_ modified. Use `const &` for read-only access. + +```cpp +// ❌ Forces deep copy if function modifies 'list' +void process(QVector list) { + list[0] = 1; +} + +// ✅ Read-only reference +void process(const QVector& list) { ... } +``` + +--- + +## Threads & Concurrency + +### Subclassing QThread vs Worker Object + +Prefer the "Worker Object" pattern over subclassing `QThread` implementation details. + +```cpp +// ❌ Business logic inside QThread::run() +class MyThread : public QThread { + void run() override { ... } +}; + +// ✅ Worker object moved to thread +QThread* thread = new QThread; +Worker* worker = new Worker; +worker->moveToThread(thread); +connect(thread, &QThread::started, worker, &Worker::process); +thread->start(); +``` + +### GUI Thread Safety + +**NEVER** access UI widgets (`QWidget` and subclasses) from a background thread. Use signals/slots to communicate updates to the main thread. + +--- + +## GUI & Widgets + +### Logic Separation + +Keep business logic out of UI classes (`MainWindow`, `Dialog`). UI classes should only handle display and user input forwarding. + +### Layouts + +Avoid fixed sizes (`setGeometry`, `resize`). Use layouts (`QVBoxLayout`, `QGridLayout`) to handle different DPIs and window resizing gracefully. + +### Blocking Event Loop + +Never execute long-running operations on the main thread (freezes GUI). + +- **Bad**: `Sleep()`, `while(busy)`, synchronous network calls. +- **Good**: `QProcess`, `QThread`, `QtConcurrent`, or asynchronous APIs (`QNetworkAccessManager`). + +--- + +## Meta-Object System + +### Properties & Enums + +Use `Q_PROPERTY` for values exposed to QML or needing introspection. +Use `Q_ENUM` to enable string conversion for enums. + +```cpp +class MyObject : public QObject { + Q_OBJECT + Q_PROPERTY(int value READ value WRITE setValue NOTIFY valueChanged) +public: + enum State { Idle, Running }; + Q_ENUM(State) + // ... +}; +``` + +### qobject_cast + +Use `qobject_cast` for QObjects instead of `dynamic_cast`. It is faster and doesn't require RTTI. + +--- + +## Review Checklist + +- [ ] **Memory**: Is parent-child relationship correct? Are dangling pointers avoided (using `QPointer`)? +- [ ] **Signals**: Are connections checked? Do lambdas use safe captures (context object)? +- [ ] **Threads**: Is UI accessed only from main thread? Are long tasks offloaded? +- [ ] **Strings**: Are `QStringLiteral` or `tr()` used appropriately? +- [ ] **Style**: Naming conventions (camelCase for methods, PascalCase for classes). +- [ ] **Resources**: Are resources (images, styles) loaded from `.qrc`? diff --git a/packages/mosaic/framework/skills/code-review-excellence/reference/react.md b/packages/mosaic/framework/skills/code-review-excellence/reference/react.md new file mode 100644 index 00000000..a0ba16f7 --- /dev/null +++ b/packages/mosaic/framework/skills/code-review-excellence/reference/react.md @@ -0,0 +1,871 @@ +# React Code Review Guide + +React 审查重点:Hooks 规则、性能优化的适度性、组件设计、以及现代 React 19/RSC 模式。 + +## 目录 + +- [基础 Hooks 规则](#基础-hooks-规则) +- [useEffect 模式](#useeffect-模式) +- [useMemo / useCallback](#usememo--usecallback) +- [组件设计](#组件设计) +- [Error Boundaries & Suspense](#error-boundaries--suspense) +- [Server Components (RSC)](#server-components-rsc) +- [React 19 Actions & Forms](#react-19-actions--forms) +- [Suspense & Streaming SSR](#suspense--streaming-ssr) +- [TanStack Query v5](#tanstack-query-v5) +- [Review Checklists](#review-checklists) + +--- + +## 基础 Hooks 规则 + +```tsx +// ❌ 条件调用 Hooks — 违反 Hooks 规则 +function BadComponent({ isLoggedIn }) { + if (isLoggedIn) { + const [user, setUser] = useState(null); // Error! + } + return
...
; +} + +// ✅ Hooks 必须在组件顶层调用 +function GoodComponent({ isLoggedIn }) { + const [user, setUser] = useState(null); + if (!isLoggedIn) return ; + return
{user?.name}
; +} +``` + +--- + +## useEffect 模式 + +```tsx +// ❌ 依赖数组缺失或不完整 +function BadEffect({ userId }) { + const [user, setUser] = useState(null); + useEffect(() => { + fetchUser(userId).then(setUser); + }, []); // 缺少 userId 依赖! +} + +// ✅ 完整的依赖数组 +function GoodEffect({ userId }) { + const [user, setUser] = useState(null); + useEffect(() => { + let cancelled = false; + fetchUser(userId).then((data) => { + if (!cancelled) setUser(data); + }); + return () => { + cancelled = true; + }; // 清理函数 + }, [userId]); +} + +// ❌ useEffect 用于派生状态(反模式) +function BadDerived({ items }) { + const [filteredItems, setFilteredItems] = useState([]); + useEffect(() => { + setFilteredItems(items.filter((i) => i.active)); + }, [items]); // 不必要的 effect + 额外渲染 + return ; +} + +// ✅ 直接在渲染时计算,或用 useMemo +function GoodDerived({ items }) { + const filteredItems = useMemo(() => items.filter((i) => i.active), [items]); + return ; +} + +// ❌ useEffect 用于事件响应 +function BadEventEffect() { + const [query, setQuery] = useState(''); + useEffect(() => { + if (query) { + analytics.track('search', { query }); // 应该在事件处理器中 + } + }, [query]); +} + +// ✅ 在事件处理器中执行副作用 +function GoodEvent() { + const [query, setQuery] = useState(''); + const handleSearch = (q: string) => { + setQuery(q); + analytics.track('search', { query: q }); + }; +} +``` + +--- + +## useMemo / useCallback + +```tsx +// ❌ 过度优化 — 常量不需要 useMemo +function OverOptimized() { + const config = useMemo(() => ({ timeout: 5000 }), []); // 无意义 + const handleClick = useCallback(() => { + console.log('clicked'); + }, []); // 如果不传给 memo 组件,无意义 +} + +// ✅ 只在需要时优化 +function ProperlyOptimized() { + const config = { timeout: 5000 }; // 简单对象直接定义 + const handleClick = () => console.log('clicked'); +} + +// ❌ useCallback 依赖总是变化 +function BadCallback({ data }) { + // data 每次渲染都是新对象,useCallback 无效 + const process = useCallback(() => { + return data.map(transform); + }, [data]); +} + +// ✅ useMemo + useCallback 配合 React.memo 使用 +const MemoizedChild = React.memo(function Child({ onClick, items }) { + return
{items.length}
; +}); + +function Parent({ rawItems }) { + const items = useMemo(() => processItems(rawItems), [rawItems]); + const handleClick = useCallback(() => { + console.log(items.length); + }, [items]); + return ; +} +``` + +--- + +## 组件设计 + +```tsx +// ❌ 在组件内定义组件 — 每次渲染都创建新组件 +function BadParent() { + function ChildComponent() { + // 每次渲染都是新函数! + return
child
; + } + return ; +} + +// ✅ 组件定义在外部 +function ChildComponent() { + return
child
; +} +function GoodParent() { + return ; +} + +// ❌ Props 总是新对象引用 +function BadProps() { + return ( + {}} // 每次渲染新函数 + /> + ); +} + +// ✅ 稳定的引用 +const style = { color: 'red' }; +function GoodProps() { + const handleClick = useCallback(() => {}, []); + return ; +} +``` + +--- + +## Error Boundaries & Suspense + +```tsx +// ❌ 没有错误边界 +function BadApp() { + return ( + }> + {/* 错误会导致整个应用崩溃 */} + + ); +} + +// ✅ Error Boundary 包裹 Suspense +function GoodApp() { + return ( + }> + }> + + + + ); +} +``` + +--- + +## Server Components (RSC) + +```tsx +// ❌ 在 Server Component 中使用客户端特性 +// app/page.tsx (Server Component by default) +function BadServerComponent() { + const [count, setCount] = useState(0); // Error! No hooks in RSC + return ; +} + +// ✅ 交互逻辑提取到 Client Component +// app/counter.tsx +'use client'; +function Counter() { + const [count, setCount] = useState(0); + return ; +} + +// app/page.tsx (Server Component) +async function GoodServerComponent() { + const data = await fetchData(); // 可以直接 await + return ( +
+

{data.title}

+ {/* 客户端组件 */} +
+ ); +} + +// ❌ 'use client' 放置不当 — 整个树都变成客户端 +// layout.tsx +'use client'; // 这会让所有子组件都成为客户端组件 +export default function Layout({ children }) { ... } + +// ✅ 只在需要交互的组件使用 'use client' +// 将客户端逻辑隔离到叶子组件 +``` + +--- + +## React 19 Actions & Forms + +React 19 引入了 Actions 系统和新的表单处理 Hooks,简化异步操作和乐观更新。 + +### useActionState + +```tsx +// ❌ 传统方式:多个状态变量 +function OldForm() { + const [isPending, setIsPending] = useState(false); + const [error, setError] = useState(null); + const [data, setData] = useState(null); + + const handleSubmit = async (formData: FormData) => { + setIsPending(true); + setError(null); + try { + const result = await submitForm(formData); + setData(result); + } catch (e) { + setError(e.message); + } finally { + setIsPending(false); + } + }; +} + +// ✅ React 19: useActionState 统一管理 +import { useActionState } from 'react'; + +function NewForm() { + const [state, formAction, isPending] = useActionState( + async (prevState, formData: FormData) => { + try { + const result = await submitForm(formData); + return { success: true, data: result }; + } catch (e) { + return { success: false, error: e.message }; + } + }, + { success: false, data: null, error: null }, + ); + + return ( +
+ + + {state.error &&

{state.error}

} +
+ ); +} +``` + +### useFormStatus + +```tsx +// ❌ Props 透传表单状态 +function BadSubmitButton({ isSubmitting }) { + return ; +} + +// ✅ useFormStatus 访问父
状态(无需 props) +import { useFormStatus } from 'react-dom'; + +function SubmitButton() { + const { pending, data, method, action } = useFormStatus(); + // 注意:必须在 内部的子组件中使用 + return ; +} + +// ❌ useFormStatus 在 form 同级组件中调用——不工作 +function BadForm() { + const { pending } = useFormStatus(); // 这里无法获取状态! + return ( + + +
+ ); +} + +// ✅ useFormStatus 必须在 form 的子组件中 +function GoodForm() { + return ( +
+ {/* useFormStatus 在这里面调用 */} + + ); +} +``` + +### useOptimistic + +```tsx +// ❌ 等待服务器响应再更新 UI +function SlowLike({ postId, likes }) { + const [likeCount, setLikeCount] = useState(likes); + const [isPending, setIsPending] = useState(false); + + const handleLike = async () => { + setIsPending(true); + const newCount = await likePost(postId); // 等待... + setLikeCount(newCount); + setIsPending(false); + }; +} + +// ✅ useOptimistic 即时反馈,失败自动回滚 +import { useOptimistic } from 'react'; + +function FastLike({ postId, likes }) { + const [optimisticLikes, addOptimisticLike] = useOptimistic( + likes, + (currentLikes, increment: number) => currentLikes + increment, + ); + + const handleLike = async () => { + addOptimisticLike(1); // 立即更新 UI + try { + await likePost(postId); // 后台同步 + } catch { + // React 自动回滚到 likes 原值 + } + }; + + return ; +} +``` + +### Server Actions (Next.js 15+) + +```tsx +// ❌ 客户端调用 API +'use client'; +function ClientForm() { + const handleSubmit = async (formData: FormData) => { + const res = await fetch('/api/submit', { + method: 'POST', + body: formData, + }); + // ... + }; +} + +// ✅ Server Action + useActionState +// actions.ts +('use server'); +export async function createPost(prevState: any, formData: FormData) { + const title = formData.get('title'); + await db.posts.create({ title }); + revalidatePath('/posts'); + return { success: true }; +} + +// form.tsx +('use client'); +import { createPost } from './actions'; + +function PostForm() { + const [state, formAction, isPending] = useActionState(createPost, null); + return ( +
+ + + + ); +} +``` + +--- + +## Suspense & Streaming SSR + +Suspense 和 Streaming 是 React 18+ 的核心特性,在 2025 年的 Next.js 15 等框架中广泛使用。 + +### 基础 Suspense + +```tsx +// ❌ 传统加载状态管理 +function OldComponent() { + const [data, setData] = useState(null); + const [isLoading, setIsLoading] = useState(true); + + useEffect(() => { + fetchData() + .then(setData) + .finally(() => setIsLoading(false)); + }, []); + + if (isLoading) return ; + return ; +} + +// ✅ Suspense 声明式加载状态 +function NewComponent() { + return ( + }> + {/* 内部使用 use() 或支持 Suspense 的数据获取 */} + + ); +} +``` + +### 多个独立 Suspense 边界 + +```tsx +// ❌ 单一边界——所有内容一起加载 +function BadLayout() { + return ( + }> +
+ {/* 慢 */} + {/* 快 */} + + ); +} + +// ✅ 独立边界——各部分独立流式传输 +function GoodLayout() { + return ( + <> +
{/* 立即显示 */} +
+ }> + {/* 独立加载 */} + + }> + {/* 独立加载 */} + +
+ + ); +} +``` + +### Next.js 15 Streaming + +```tsx +// app/page.tsx - 自动 Streaming +export default async function Page() { + // 这个 await 不会阻塞整个页面 + const data = await fetchSlowData(); + return
{data}
; +} + +// app/loading.tsx - 自动 Suspense 边界 +export default function Loading() { + return ; +} +``` + +### use() Hook (React 19) + +```tsx +// ✅ 在组件中读取 Promise +import { use } from 'react'; + +function Comments({ commentsPromise }) { + const comments = use(commentsPromise); // 自动触发 Suspense + return ( +
    + {comments.map((c) => ( +
  • {c.text}
  • + ))} +
+ ); +} + +// 父组件创建 Promise,子组件消费 +function Post({ postId }) { + const commentsPromise = fetchComments(postId); // 不 await + return ( +
+ + }> + + +
+ ); +} +``` + +--- + +## TanStack Query v5 + +TanStack Query 是 React 生态中最流行的数据获取库,v5 是当前稳定版本。 + +### 基础配置 + +```tsx +// ❌ 不正确的默认配置 +const queryClient = new QueryClient(); // 默认配置可能不适合 + +// ✅ 生产环境推荐配置 +const queryClient = new QueryClient({ + defaultOptions: { + queries: { + staleTime: 1000 * 60 * 5, // 5 分钟内数据视为新鲜 + gcTime: 1000 * 60 * 30, // 30 分钟后垃圾回收(v5 重命名) + retry: 3, + refetchOnWindowFocus: false, // 根据需求决定 + }, + }, +}); +``` + +### queryOptions (v5 新增) + +```tsx +// ❌ 重复定义 queryKey 和 queryFn +function Component1() { + const { data } = useQuery({ + queryKey: ['users', userId], + queryFn: () => fetchUser(userId), + }); +} + +function prefetchUser(queryClient, userId) { + queryClient.prefetchQuery({ + queryKey: ['users', userId], // 重复! + queryFn: () => fetchUser(userId), // 重复! + }); +} + +// ✅ queryOptions 统一定义,类型安全 +import { queryOptions } from '@tanstack/react-query'; + +const userQueryOptions = (userId: string) => + queryOptions({ + queryKey: ['users', userId], + queryFn: () => fetchUser(userId), + }); + +function Component1({ userId }) { + const { data } = useQuery(userQueryOptions(userId)); +} + +function prefetchUser(queryClient, userId) { + queryClient.prefetchQuery(userQueryOptions(userId)); +} + +// getQueryData 也是类型安全的 +const user = queryClient.getQueryData(userQueryOptions(userId).queryKey); +``` + +### 常见陷阱 + +```tsx +// ❌ staleTime 为 0 导致过度请求 +useQuery({ + queryKey: ['data'], + queryFn: fetchData, + // staleTime 默认为 0,每次组件挂载都会 refetch +}); + +// ✅ 设置合理的 staleTime +useQuery({ + queryKey: ['data'], + queryFn: fetchData, + staleTime: 1000 * 60, // 1 分钟内不会重新请求 +}); + +// ❌ 在 queryFn 中使用不稳定的引用 +function BadQuery({ filters }) { + useQuery({ + queryKey: ['items'], // queryKey 没有包含 filters! + queryFn: () => fetchItems(filters), // filters 变化不会触发重新请求 + }); +} + +// ✅ queryKey 包含所有影响数据的参数 +function GoodQuery({ filters }) { + useQuery({ + queryKey: ['items', filters], // filters 是 queryKey 的一部分 + queryFn: () => fetchItems(filters), + }); +} +``` + +### useSuspenseQuery + +> **重要限制**:useSuspenseQuery 与 useQuery 有显著差异,选择前需了解其限制。 + +#### useSuspenseQuery 的限制 + +| 特性 | useQuery | useSuspenseQuery | +| ----------------- | ---------------- | --------------------- | +| `enabled` 选项 | ✅ 支持 | ❌ 不支持 | +| `placeholderData` | ✅ 支持 | ❌ 不支持 | +| `data` 类型 | `T \| undefined` | `T`(保证有值) | +| 错误处理 | `error` 属性 | 抛出到 Error Boundary | +| 加载状态 | `isLoading` 属性 | 挂起到 Suspense | + +#### 不支持 enabled 的替代方案 + +```tsx +// ❌ 使用 useQuery + enabled 实现条件查询 +function BadSuspenseQuery({ userId }) { + const { data } = useSuspenseQuery({ + queryKey: ['user', userId], + queryFn: () => fetchUser(userId), + enabled: !!userId, // useSuspenseQuery 不支持 enabled! + }); +} + +// ✅ 组件组合实现条件渲染 +function GoodSuspenseQuery({ userId }) { + // useSuspenseQuery 保证 data 是 T 不是 T | undefined + const { data } = useSuspenseQuery({ + queryKey: ['user', userId], + queryFn: () => fetchUser(userId), + }); + return ; +} + +function Parent({ userId }) { + if (!userId) return ; + return ( + }> + + + ); +} +``` + +#### 错误处理差异 + +```tsx +// ❌ useSuspenseQuery 没有 error 属性 +function BadErrorHandling() { + const { data, error } = useSuspenseQuery({...}); + if (error) return ; // error 总是 null! +} + +// ✅ 使用 Error Boundary 处理错误 +function GoodErrorHandling() { + return ( + }> + }> + + + + ); +} + +function DataComponent() { + // 错误会抛出到 Error Boundary + const { data } = useSuspenseQuery({ + queryKey: ['data'], + queryFn: fetchData, + }); + return ; +} +``` + +#### 何时选择 useSuspenseQuery + +```tsx +// ✅ 适合场景: +// 1. 数据总是需要的(无条件查询) +// 2. 组件必须有数据才能渲染 +// 3. 使用 React 19 的 Suspense 模式 +// 4. 服务端组件 + 客户端 hydration + +// ❌ 不适合场景: +// 1. 条件查询(根据用户操作触发) +// 2. 需要 placeholderData 或初始数据 +// 3. 需要在组件内处理 loading/error 状态 +// 4. 多个查询有依赖关系 + +// ✅ 多个独立查询用 useSuspenseQueries +function MultipleQueries({ userId }) { + const [userQuery, postsQuery] = useSuspenseQueries({ + queries: [ + { queryKey: ['user', userId], queryFn: () => fetchUser(userId) }, + { queryKey: ['posts', userId], queryFn: () => fetchPosts(userId) }, + ], + }); + // 两个查询并行执行,都完成后组件渲染 + return ; +} +``` + +### 乐观更新 (v5 简化) + +```tsx +// ❌ 手动管理缓存的乐观更新(复杂) +const mutation = useMutation({ + mutationFn: updateTodo, + onMutate: async (newTodo) => { + await queryClient.cancelQueries({ queryKey: ['todos'] }); + const previousTodos = queryClient.getQueryData(['todos']); + queryClient.setQueryData(['todos'], (old) => [...old, newTodo]); + return { previousTodos }; + }, + onError: (err, newTodo, context) => { + queryClient.setQueryData(['todos'], context.previousTodos); + }, + onSettled: () => { + queryClient.invalidateQueries({ queryKey: ['todos'] }); + }, +}); + +// ✅ v5 简化:使用 variables 进行乐观 UI +function TodoList() { + const { data: todos } = useQuery(todosQueryOptions); + const { mutate, variables, isPending } = useMutation({ + mutationFn: addTodo, + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['todos'] }); + }, + }); + + return ( +
    + {todos?.map((todo) => ( + + ))} + {/* 乐观显示正在添加的 todo */} + {isPending && } +
+ ); +} +``` + +### v5 状态字段变化 + +```tsx +// v4: isLoading 表示首次加载或后续获取 +// v5: isPending 表示没有数据,isLoading = isPending && isFetching + +const { data, isPending, isFetching, isLoading } = useQuery({...}); + +// isPending: 缓存中没有数据(首次加载) +// isFetching: 正在请求中(包括后台刷新) +// isLoading: isPending && isFetching(首次加载中) + +// ❌ v4 代码直接迁移 +if (isLoading) return ; // v5 中行为可能不同 + +// ✅ 明确意图 +if (isPending) return ; // 没有数据时显示加载 +// 或 +if (isLoading) return ; // 首次加载中 +``` + +--- + +## Review Checklists + +### Hooks 规则 + +- [ ] Hooks 在组件/自定义 Hook 顶层调用 +- [ ] 没有条件/循环中调用 Hooks +- [ ] useEffect 依赖数组完整 +- [ ] useEffect 有清理函数(订阅/定时器/请求) +- [ ] 没有用 useEffect 计算派生状态 + +### 性能优化(适度原则) + +- [ ] useMemo/useCallback 只用于真正需要的场景 +- [ ] React.memo 配合稳定的 props 引用 +- [ ] 没有在组件内定义子组件 +- [ ] 没有在 JSX 中创建新对象/函数(除非传给非 memo 组件) +- [ ] 长列表使用虚拟化(react-window/react-virtual) + +### 组件设计 + +- [ ] 组件职责单一,不超过 200 行 +- [ ] 逻辑与展示分离(Custom Hooks) +- [ ] Props 接口清晰,使用 TypeScript +- [ ] 避免 Props Drilling(考虑 Context 或组合) + +### 状态管理 + +- [ ] 状态就近原则(最小必要范围) +- [ ] 复杂状态用 useReducer +- [ ] 全局状态用 Context 或状态库 +- [ ] 避免不必要的状态(派生 > 存储) + +### 错误处理 + +- [ ] 关键区域有 Error Boundary +- [ ] Suspense 配合 Error Boundary 使用 +- [ ] 异步操作有错误处理 + +### Server Components (RSC) + +- [ ] 'use client' 只用于需要交互的组件 +- [ ] Server Component 不使用 Hooks/事件处理 +- [ ] 客户端组件尽量放在叶子节点 +- [ ] 数据获取在 Server Component 中进行 + +### React 19 Forms + +- [ ] 使用 useActionState 替代多个 useState +- [ ] useFormStatus 在 form 子组件中调用 +- [ ] useOptimistic 不用于关键业务(支付等) +- [ ] Server Action 正确标记 'use server' + +### Suspense & Streaming + +- [ ] 按用户体验需求划分 Suspense 边界 +- [ ] 每个 Suspense 有对应的 Error Boundary +- [ ] 提供有意义的 fallback(骨架屏 > Spinner) +- [ ] 避免在 layout 层级 await 慢数据 + +### TanStack Query + +- [ ] queryKey 包含所有影响数据的参数 +- [ ] 设置合理的 staleTime(不是默认 0) +- [ ] useSuspenseQuery 不使用 enabled +- [ ] Mutation 成功后 invalidate 相关查询 +- [ ] 理解 isPending vs isLoading 区别 + +### 测试 + +- [ ] 使用 @testing-library/react +- [ ] 用 screen 查询元素 +- [ ] 用 userEvent 代替 fireEvent +- [ ] 优先使用 \*ByRole 查询 +- [ ] 测试行为而非实现细节 diff --git a/packages/mosaic/framework/skills/code-review-excellence/reference/rust.md b/packages/mosaic/framework/skills/code-review-excellence/reference/rust.md new file mode 100644 index 00000000..db5d12c5 --- /dev/null +++ b/packages/mosaic/framework/skills/code-review-excellence/reference/rust.md @@ -0,0 +1,842 @@ +# Rust Code Review Guide + +> Rust 代码审查指南。编译器能捕获内存安全问题,但审查者需要关注编译器无法检测的问题——业务逻辑、API 设计、性能、取消安全性和可维护性。 + +## 目录 + +- [所有权与借用](#所有权与借用) +- [Unsafe 代码审查](#unsafe-代码审查最关键) +- [异步代码](#异步代码) +- [取消安全性](#取消安全性) +- [spawn vs await](#spawn-vs-await) +- [错误处理](#错误处理) +- [性能](#性能) +- [Trait 设计](#trait-设计) +- [Review Checklist](#rust-review-checklist) + +--- + +## 所有权与借用 + +### 避免不必要的 clone() + +```rust +// ❌ clone() 是"Rust 的胶带"——用于绕过借用检查器 +fn bad_process(data: &Data) -> Result<()> { + let owned = data.clone(); // 为什么需要 clone? + expensive_operation(owned) +} + +// ✅ 审查时问:clone 是否必要?能否用借用? +fn good_process(data: &Data) -> Result<()> { + expensive_operation(data) // 传递引用 +} + +// ✅ 如果确实需要 clone,添加注释说明原因 +fn justified_clone(data: &Data) -> Result<()> { + // Clone needed: data will be moved to spawned task + let owned = data.clone(); + tokio::spawn(async move { + process(owned).await + }); + Ok(()) +} +``` + +### Arc> 的使用 + +```rust +// ❌ Arc> 可能隐藏不必要的共享状态 +struct BadService { + cache: Arc>>, // 真的需要共享? +} + +// ✅ 考虑是否需要共享,或者设计可以避免 +struct GoodService { + cache: HashMap, // 单一所有者 +} + +// ✅ 如果确实需要并发访问,考虑更好的数据结构 +use dashmap::DashMap; + +struct ConcurrentService { + cache: DashMap, // 更细粒度的锁 +} +``` + +### Cow (Copy-on-Write) 模式 + +```rust +use std::borrow::Cow; + +// ❌ 总是分配新字符串 +fn bad_process_name(name: &str) -> String { + if name.is_empty() { + "Unknown".to_string() // 分配 + } else { + name.to_string() // 不必要的分配 + } +} + +// ✅ 使用 Cow 避免不必要的分配 +fn good_process_name(name: &str) -> Cow<'_, str> { + if name.is_empty() { + Cow::Borrowed("Unknown") // 静态字符串,无分配 + } else { + Cow::Borrowed(name) // 借用原始数据 + } +} + +// ✅ 只在需要修改时才分配 +fn normalize_name(name: &str) -> Cow<'_, str> { + if name.chars().any(|c| c.is_uppercase()) { + Cow::Owned(name.to_lowercase()) // 需要修改,分配 + } else { + Cow::Borrowed(name) // 无需修改,借用 + } +} +``` + +--- + +## Unsafe 代码审查(最关键!) + +### 基本要求 + +```rust +// ❌ unsafe 没有安全文档——这是红旗 +unsafe fn bad_transmute(t: T) -> U { + std::mem::transmute(t) +} + +// ✅ 每个 unsafe 必须解释:为什么安全?什么不变量? +/// Transmutes `T` to `U`. +/// +/// # Safety +/// +/// - `T` and `U` must have the same size and alignment +/// - `T` must be a valid bit pattern for `U` +/// - The caller ensures no references to `t` exist after this call +unsafe fn documented_transmute(t: T) -> U { + // SAFETY: Caller guarantees size/alignment match and bit validity + std::mem::transmute(t) +} +``` + +### Unsafe 块注释 + +```rust +// ❌ 没有解释的 unsafe 块 +fn bad_get_unchecked(slice: &[u8], index: usize) -> u8 { + unsafe { *slice.get_unchecked(index) } +} + +// ✅ 每个 unsafe 块必须有 SAFETY 注释 +fn good_get_unchecked(slice: &[u8], index: usize) -> u8 { + debug_assert!(index < slice.len(), "index out of bounds"); + // SAFETY: We verified index < slice.len() via debug_assert. + // In release builds, callers must ensure valid index. + unsafe { *slice.get_unchecked(index) } +} + +// ✅ 封装 unsafe 提供安全 API +pub fn checked_get(slice: &[u8], index: usize) -> Option { + if index < slice.len() { + // SAFETY: bounds check performed above + Some(unsafe { *slice.get_unchecked(index) }) + } else { + None + } +} +``` + +### 常见 unsafe 模式 + +```rust +// ✅ FFI 边界 +extern "C" { + fn external_function(ptr: *const u8, len: usize) -> i32; +} + +pub fn safe_wrapper(data: &[u8]) -> Result { + // SAFETY: data.as_ptr() is valid for data.len() bytes, + // and external_function only reads from the buffer. + let result = unsafe { + external_function(data.as_ptr(), data.len()) + }; + if result < 0 { + Err(Error::from_code(result)) + } else { + Ok(result) + } +} + +// ✅ 性能关键路径的 unsafe +pub fn fast_copy(src: &[u8], dst: &mut [u8]) { + assert_eq!(src.len(), dst.len(), "slices must be equal length"); + // SAFETY: src and dst are valid slices of equal length, + // and dst is mutable so no aliasing. + unsafe { + std::ptr::copy_nonoverlapping( + src.as_ptr(), + dst.as_mut_ptr(), + src.len() + ); + } +} +``` + +--- + +## 异步代码 + +### 避免阻塞操作 + +```rust +// ❌ 在 async 上下文中阻塞——会饿死其他任务 +async fn bad_async() { + let data = std::fs::read_to_string("file.txt").unwrap(); // 阻塞! + std::thread::sleep(Duration::from_secs(1)); // 阻塞! +} + +// ✅ 使用异步 API +async fn good_async() -> Result { + let data = tokio::fs::read_to_string("file.txt").await?; + tokio::time::sleep(Duration::from_secs(1)).await; + Ok(data) +} + +// ✅ 如果必须使用阻塞操作,用 spawn_blocking +async fn with_blocking() -> Result { + let result = tokio::task::spawn_blocking(|| { + // 这里可以安全地进行阻塞操作 + expensive_cpu_computation() + }).await?; + Ok(result) +} +``` + +### Mutex 和 .await + +```rust +// ❌ 跨 .await 持有 std::sync::Mutex——可能死锁 +async fn bad_lock(mutex: &std::sync::Mutex) { + let guard = mutex.lock().unwrap(); + async_operation().await; // 持锁等待! + process(&guard); +} + +// ✅ 方案1:最小化锁范围 +async fn good_lock_scoped(mutex: &std::sync::Mutex) { + let data = { + let guard = mutex.lock().unwrap(); + guard.clone() // 立即释放锁 + }; + async_operation().await; + process(&data); +} + +// ✅ 方案2:使用 tokio::sync::Mutex(可跨 await) +async fn good_lock_tokio(mutex: &tokio::sync::Mutex) { + let guard = mutex.lock().await; + async_operation().await; // OK: tokio Mutex 设计为可跨 await + process(&guard); +} + +// 💡 选择指南: +// - std::sync::Mutex:低竞争、短临界区、不跨 await +// - tokio::sync::Mutex:需要跨 await、高竞争场景 +``` + +### 异步 trait 方法 + +```rust +// ❌ async trait 方法的陷阱(旧版本) +#[async_trait] +trait BadRepository { + async fn find(&self, id: i64) -> Option; // 隐式 Box +} + +// ✅ Rust 1.75+:原生 async trait 方法 +trait Repository { + async fn find(&self, id: i64) -> Option; + + // 返回具体 Future 类型以避免 allocation + fn find_many(&self, ids: &[i64]) -> impl Future> + Send; +} + +// ✅ 对于需要 dyn 的场景 +trait DynRepository: Send + Sync { + fn find(&self, id: i64) -> Pin> + Send + '_>>; +} +``` + +--- + +## 取消安全性 + +### 什么是取消安全 + +```rust +// 当一个 Future 在 .await 点被 drop 时,它处于什么状态? +// 取消安全的 Future:可以在任何 await 点安全取消 +// 取消不安全的 Future:取消可能导致数据丢失或不一致状态 + +// ❌ 取消不安全的例子 +async fn cancel_unsafe(conn: &mut Connection) -> Result<()> { + let data = receive_data().await; // 如果这里被取消... + conn.send_ack().await; // ...确认永远不会发送,数据可能丢失 + Ok(()) +} + +// ✅ 取消安全的版本 +async fn cancel_safe(conn: &mut Connection) -> Result<()> { + // 使用事务或原子操作确保一致性 + let transaction = conn.begin_transaction().await?; + let data = receive_data().await; + transaction.commit_with_ack(data).await?; // 原子操作 + Ok(()) +} +``` + +### select! 中的取消安全 + +```rust +use tokio::select; + +// ❌ 在 select! 中使用取消不安全的 Future +async fn bad_select(stream: &mut TcpStream) { + let mut buffer = vec![0u8; 1024]; + loop { + select! { + // 如果 timeout 先完成,read 被取消 + // 部分读取的数据可能丢失! + result = stream.read(&mut buffer) => { + handle_data(&buffer[..result?]); + } + _ = tokio::time::sleep(Duration::from_secs(5)) => { + println!("Timeout"); + } + } + } +} + +// ✅ 使用取消安全的 API +async fn good_select(stream: &mut TcpStream) { + let mut buffer = vec![0u8; 1024]; + loop { + select! { + // tokio::io::AsyncReadExt::read 是取消安全的 + // 取消时,未读取的数据留在流中 + result = stream.read(&mut buffer) => { + match result { + Ok(0) => break, // EOF + Ok(n) => handle_data(&buffer[..n]), + Err(e) => return Err(e), + } + } + _ = tokio::time::sleep(Duration::from_secs(5)) => { + println!("Timeout, retrying..."); + } + } + } +} + +// ✅ 使用 tokio::pin! 确保 Future 可以安全重用 +async fn pinned_select() { + let sleep = tokio::time::sleep(Duration::from_secs(10)); + tokio::pin!(sleep); + + loop { + select! { + _ = &mut sleep => { + println!("Timer elapsed"); + break; + } + data = receive_data() => { + process(data).await; + // sleep 继续倒计时,不会重置 + } + } + } +} +``` + +### 文档化取消安全性 + +```rust +/// Reads a complete message from the stream. +/// +/// # Cancel Safety +/// +/// This method is **not** cancel safe. If cancelled while reading, +/// partial data may be lost and the stream state becomes undefined. +/// Use `read_message_cancel_safe` if cancellation is expected. +async fn read_message(stream: &mut TcpStream) -> Result { + let len = stream.read_u32().await?; + let mut buffer = vec![0u8; len as usize]; + stream.read_exact(&mut buffer).await?; + Ok(Message::from_bytes(&buffer)) +} + +/// Reads a message with cancel safety. +/// +/// # Cancel Safety +/// +/// This method is cancel safe. If cancelled, any partial data +/// is preserved in the internal buffer for the next call. +async fn read_message_cancel_safe(reader: &mut BufferedReader) -> Result { + reader.read_message_buffered().await +} +``` + +--- + +## spawn vs await + +### 何时使用 spawn + +```rust +// ❌ 不必要的 spawn——增加开销,失去结构化并发 +async fn bad_unnecessary_spawn() { + let handle = tokio::spawn(async { + simple_operation().await + }); + handle.await.unwrap(); // 为什么不直接 await? +} + +// ✅ 直接 await 简单操作 +async fn good_direct_await() { + simple_operation().await; +} + +// ✅ spawn 用于真正的并行执行 +async fn good_parallel_spawn() { + let task1 = tokio::spawn(fetch_from_service_a()); + let task2 = tokio::spawn(fetch_from_service_b()); + + // 两个请求并行执行 + let (result1, result2) = tokio::try_join!(task1, task2)?; +} + +// ✅ spawn 用于后台任务(fire-and-forget) +async fn good_background_spawn() { + // 启动后台任务,不等待完成 + tokio::spawn(async { + cleanup_old_sessions().await; + log_metrics().await; + }); + + // 继续执行其他工作 + handle_request().await; +} +``` + +### spawn 的 'static 要求 + +```rust +// ❌ spawn 的 Future 必须是 'static +async fn bad_spawn_borrow(data: &Data) { + tokio::spawn(async { + process(data).await; // Error: `data` 不是 'static + }); +} + +// ✅ 方案1:克隆数据 +async fn good_spawn_clone(data: &Data) { + let owned = data.clone(); + tokio::spawn(async move { + process(&owned).await; + }); +} + +// ✅ 方案2:使用 Arc 共享 +async fn good_spawn_arc(data: Arc) { + let data = Arc::clone(&data); + tokio::spawn(async move { + process(&data).await; + }); +} + +// ✅ 方案3:使用作用域任务(tokio-scoped 或 async-scoped) +async fn good_scoped_spawn(data: &Data) { + // 假设使用 async-scoped crate + async_scoped::scope(|s| async { + s.spawn(async { + process(data).await; // 可以借用 + }); + }).await; +} +``` + +### JoinHandle 错误处理 + +```rust +// ❌ 忽略 spawn 的错误 +async fn bad_ignore_spawn_error() { + let handle = tokio::spawn(async { + risky_operation().await + }); + let _ = handle.await; // 忽略了 panic 和错误 +} + +// ✅ 正确处理 JoinHandle 结果 +async fn good_handle_spawn_error() -> Result<()> { + let handle = tokio::spawn(async { + risky_operation().await + }); + + match handle.await { + Ok(Ok(result)) => { + // 任务成功完成 + process_result(result); + Ok(()) + } + Ok(Err(e)) => { + // 任务内部错误 + Err(e.into()) + } + Err(join_err) => { + // 任务 panic 或被取消 + if join_err.is_panic() { + error!("Task panicked: {:?}", join_err); + } + Err(anyhow!("Task failed: {}", join_err)) + } + } +} +``` + +### 结构化并发 vs spawn + +```rust +// ✅ 优先使用 join!(结构化并发) +async fn structured_concurrency() -> Result<(A, B, C)> { + // 所有任务在同一个作用域内 + // 如果任何一个失败,其他的会被取消 + tokio::try_join!( + fetch_a(), + fetch_b(), + fetch_c() + ) +} + +// ✅ 使用 spawn 时考虑任务生命周期 +struct TaskManager { + handles: Vec>, +} + +impl TaskManager { + async fn shutdown(self) { + // 优雅关闭:等待所有任务完成 + for handle in self.handles { + if let Err(e) = handle.await { + error!("Task failed during shutdown: {}", e); + } + } + } + + async fn abort_all(self) { + // 强制关闭:取消所有任务 + for handle in self.handles { + handle.abort(); + } + } +} +``` + +--- + +## 错误处理 + +### 库 vs 应用的错误类型 + +```rust +// ❌ 库代码用 anyhow——调用者无法 match 错误 +pub fn parse_config(s: &str) -> anyhow::Result { ... } + +// ✅ 库用 thiserror,应用用 anyhow +#[derive(Debug, thiserror::Error)] +pub enum ConfigError { + #[error("invalid syntax at line {line}: {message}")] + Syntax { line: usize, message: String }, + #[error("missing required field: {0}")] + MissingField(String), + #[error(transparent)] + Io(#[from] std::io::Error), +} + +pub fn parse_config(s: &str) -> Result { ... } +``` + +### 保留错误上下文 + +```rust +// ❌ 吞掉错误上下文 +fn bad_error() -> Result<()> { + operation().map_err(|_| anyhow!("failed"))?; // 原始错误丢失 + Ok(()) +} + +// ✅ 使用 context 保留错误链 +fn good_error() -> Result<()> { + operation().context("failed to perform operation")?; + Ok(()) +} + +// ✅ 使用 with_context 进行懒计算 +fn good_error_lazy() -> Result<()> { + operation() + .with_context(|| format!("failed to process file: {}", filename))?; + Ok(()) +} +``` + +### 错误类型设计 + +```rust +// ✅ 使用 #[source] 保留错误链 +#[derive(Debug, thiserror::Error)] +pub enum ServiceError { + #[error("database error")] + Database(#[source] sqlx::Error), + + #[error("network error: {message}")] + Network { + message: String, + #[source] + source: reqwest::Error, + }, + + #[error("validation failed: {0}")] + Validation(String), +} + +// ✅ 为常见转换实现 From +impl From for ServiceError { + fn from(err: sqlx::Error) -> Self { + ServiceError::Database(err) + } +} +``` + +--- + +## 性能 + +### 避免不必要的 collect() + +```rust +// ❌ 不必要的 collect——中间分配 +fn bad_sum(items: &[i32]) -> i32 { + items.iter() + .filter(|x| **x > 0) + .collect::>() // 不必要! + .iter() + .sum() +} + +// ✅ 惰性迭代 +fn good_sum(items: &[i32]) -> i32 { + items.iter().filter(|x| **x > 0).copied().sum() +} +``` + +### 字符串拼接 + +```rust +// ❌ 字符串拼接在循环中重复分配 +fn bad_concat(items: &[&str]) -> String { + let mut s = String::new(); + for item in items { + s = s + item; // 每次都重新分配! + } + s +} + +// ✅ 预分配或用 join +fn good_concat(items: &[&str]) -> String { + items.join("") +} + +// ✅ 使用 with_capacity 预分配 +fn good_concat_capacity(items: &[&str]) -> String { + let total_len: usize = items.iter().map(|s| s.len()).sum(); + let mut result = String::with_capacity(total_len); + for item in items { + result.push_str(item); + } + result +} + +// ✅ 使用 write! 宏 +use std::fmt::Write; + +fn good_concat_write(items: &[&str]) -> String { + let mut result = String::new(); + for item in items { + write!(result, "{}", item).unwrap(); + } + result +} +``` + +### 避免不必要的分配 + +```rust +// ❌ 不必要的 Vec 分配 +fn bad_check_any(items: &[Item]) -> bool { + let filtered: Vec<_> = items.iter() + .filter(|i| i.is_valid()) + .collect(); + !filtered.is_empty() +} + +// ✅ 使用迭代器方法 +fn good_check_any(items: &[Item]) -> bool { + items.iter().any(|i| i.is_valid()) +} + +// ❌ String::from 用于静态字符串 +fn bad_static() -> String { + String::from("error message") // 运行时分配 +} + +// ✅ 返回 &'static str +fn good_static() -> &'static str { + "error message" // 无分配 +} +``` + +--- + +## Trait 设计 + +### 避免过度抽象 + +```rust +// ❌ 过度抽象——不是 Java,不需要 Interface 一切 +trait Processor { fn process(&self); } +trait Handler { fn handle(&self); } +trait Manager { fn manage(&self); } // Trait 过多 + +// ✅ 只在需要多态时创建 trait +// 具体类型通常更简单、更快 +struct DataProcessor { + config: Config, +} + +impl DataProcessor { + fn process(&self, data: &Data) -> Result { + // 直接实现 + } +} +``` + +### Trait 对象 vs 泛型 + +```rust +// ❌ 不必要的 trait 对象(动态分发) +fn bad_process(handler: &dyn Handler) { + handler.handle(); // 虚表调用 +} + +// ✅ 使用泛型(静态分发,可内联) +fn good_process(handler: &H) { + handler.handle(); // 可能被内联 +} + +// ✅ trait 对象适用场景:异构集合 +fn store_handlers(handlers: Vec>) { + // 需要存储不同类型的 handlers +} + +// ✅ 使用 impl Trait 返回类型 +fn create_handler() -> impl Handler { + ConcreteHandler::new() +} +``` + +--- + +## Rust Review Checklist + +### 编译器不能捕获的问题 + +**业务逻辑正确性** + +- [ ] 边界条件处理正确 +- [ ] 状态机转换完整 +- [ ] 并发场景下的竞态条件 + +**API 设计** + +- [ ] 公共 API 难以误用 +- [ ] 类型签名清晰表达意图 +- [ ] 错误类型粒度合适 + +### 所有权与借用 + +- [ ] clone() 是有意为之,文档说明了原因 +- [ ] Arc> 真的需要共享状态吗? +- [ ] RefCell 的使用有正当理由 +- [ ] 生命周期不过度复杂 +- [ ] 考虑使用 Cow 避免不必要的分配 + +### Unsafe 代码(最重要) + +- [ ] 每个 unsafe 块有 SAFETY 注释 +- [ ] unsafe fn 有 # Safety 文档节 +- [ ] 解释了为什么是安全的,不只是做什么 +- [ ] 列出了必须维护的不变量 +- [ ] unsafe 边界尽可能小 +- [ ] 考虑过是否有 safe 替代方案 + +### 异步/并发 + +- [ ] 没有在 async 中阻塞(std::fs、thread::sleep) +- [ ] 没有跨 .await 持有 std::sync 锁 +- [ ] spawn 的任务满足 'static +- [ ] 锁的获取顺序一致 +- [ ] Channel 缓冲区大小合理 + +### 取消安全性 + +- [ ] select! 中的 Future 是取消安全的 +- [ ] 文档化了 async 函数的取消安全性 +- [ ] 取消不会导致数据丢失或不一致状态 +- [ ] 使用 tokio::pin! 正确处理需要重用的 Future + +### spawn vs await + +- [ ] spawn 只用于真正需要并行的场景 +- [ ] 简单操作直接 await,不要 spawn +- [ ] spawn 的 JoinHandle 结果被正确处理 +- [ ] 考虑任务的生命周期和关闭策略 +- [ ] 优先使用 join!/try_join! 进行结构化并发 + +### 错误处理 + +- [ ] 库:thiserror 定义结构化错误 +- [ ] 应用:anyhow + context +- [ ] 没有生产代码 unwrap/expect +- [ ] 错误消息对调试有帮助 +- [ ] must_use 返回值被处理 +- [ ] 使用 #[source] 保留错误链 + +### 性能 + +- [ ] 避免不必要的 collect() +- [ ] 大数据传引用 +- [ ] 字符串用 with_capacity 或 write! +- [ ] impl Trait vs Box 选择合理 +- [ ] 热路径避免分配 +- [ ] 考虑使用 Cow 减少克隆 + +### 代码质量 + +- [ ] cargo clippy 零警告 +- [ ] cargo fmt 格式化 +- [ ] 文档注释完整 +- [ ] 测试覆盖边界条件 +- [ ] 公共 API 有文档示例 diff --git a/packages/mosaic/framework/skills/code-review-excellence/reference/security-review-guide.md b/packages/mosaic/framework/skills/code-review-excellence/reference/security-review-guide.md new file mode 100644 index 00000000..e8f3507f --- /dev/null +++ b/packages/mosaic/framework/skills/code-review-excellence/reference/security-review-guide.md @@ -0,0 +1,287 @@ +# Security Review Guide + +Security-focused code review checklist based on OWASP Top 10 and best practices. + +## Authentication & Authorization + +### Authentication + +- [ ] Passwords hashed with strong algorithm (bcrypt, argon2) +- [ ] Password complexity requirements enforced +- [ ] Account lockout after failed attempts +- [ ] Secure password reset flow +- [ ] Multi-factor authentication for sensitive operations +- [ ] Session tokens are cryptographically random +- [ ] Session timeout implemented + +### Authorization + +- [ ] Authorization checks on every request +- [ ] Principle of least privilege applied +- [ ] Role-based access control (RBAC) properly implemented +- [ ] No privilege escalation paths +- [ ] Direct object reference checks (IDOR prevention) +- [ ] API endpoints protected appropriately + +### JWT Security + +```typescript +// ❌ Insecure JWT configuration +jwt.sign(payload, 'weak-secret'); + +// ✅ Secure JWT configuration +jwt.sign(payload, process.env.JWT_SECRET, { + algorithm: 'RS256', + expiresIn: '15m', + issuer: 'your-app', + audience: 'your-api', +}); + +// ❌ Not verifying JWT properly +const decoded = jwt.decode(token); // No signature verification! + +// ✅ Verify signature and claims +const decoded = jwt.verify(token, publicKey, { + algorithms: ['RS256'], + issuer: 'your-app', + audience: 'your-api', +}); +``` + +## Input Validation + +### SQL Injection Prevention + +```python +# ❌ Vulnerable to SQL injection +query = f"SELECT * FROM users WHERE id = {user_id}" + +# ✅ Use parameterized queries +cursor.execute("SELECT * FROM users WHERE id = %s", (user_id,)) + +# ✅ Use ORM with proper escaping +User.objects.filter(id=user_id) +``` + +### XSS Prevention + +```typescript +// ❌ Vulnerable to XSS +element.innerHTML = userInput; + +// ✅ Use textContent for plain text +element.textContent = userInput; + +// ✅ Use DOMPurify for HTML +element.innerHTML = DOMPurify.sanitize(userInput); + +// ✅ React automatically escapes (but watch dangerouslySetInnerHTML) +return
{userInput}
; // Safe +return
; // Dangerous! +``` + +### Command Injection Prevention + +```python +# ❌ Vulnerable to command injection +os.system(f"convert {filename} output.png") + +# ✅ Use subprocess with list arguments +subprocess.run(['convert', filename, 'output.png'], check=True) + +# ✅ Validate and sanitize input +import shlex +safe_filename = shlex.quote(filename) +``` + +### Path Traversal Prevention + +```typescript +// ❌ Vulnerable to path traversal +const filePath = `./uploads/${req.params.filename}`; + +// ✅ Validate and sanitize path +const path = require('path'); +const safeName = path.basename(req.params.filename); +const filePath = path.join('./uploads', safeName); + +// Verify it's still within uploads directory +if (!filePath.startsWith(path.resolve('./uploads'))) { + throw new Error('Invalid path'); +} +``` + +## Data Protection + +### Sensitive Data Handling + +- [ ] No secrets in source code +- [ ] Secrets stored in environment variables or secret manager +- [ ] Sensitive data encrypted at rest +- [ ] Sensitive data encrypted in transit (HTTPS) +- [ ] PII handled according to regulations (GDPR, etc.) +- [ ] Sensitive data not logged +- [ ] Secure data deletion when required + +### Configuration Security + +```yaml +# ❌ Secrets in config files +database: + password: "super-secret-password" + +# ✅ Reference environment variables +database: + password: ${DATABASE_PASSWORD} +``` + +### Error Messages + +```typescript +// ❌ Leaking sensitive information +catch (error) { + return res.status(500).json({ + error: error.stack, // Exposes internal details + query: sqlQuery // Exposes database structure + }); +} + +// ✅ Generic error messages +catch (error) { + logger.error('Database error', { error, userId }); // Log internally + return res.status(500).json({ + error: 'An unexpected error occurred' + }); +} +``` + +## API Security + +### Rate Limiting + +- [ ] Rate limiting on all public endpoints +- [ ] Stricter limits on authentication endpoints +- [ ] Per-user and per-IP limits +- [ ] Graceful handling when limits exceeded + +### CORS Configuration + +```typescript +// ❌ Overly permissive CORS +app.use(cors({ origin: '*' })); + +// ✅ Restrictive CORS +app.use( + cors({ + origin: ['https://your-app.com'], + methods: ['GET', 'POST'], + credentials: true, + }), +); +``` + +### HTTP Headers + +```typescript +// Security headers to set +app.use( + helmet({ + contentSecurityPolicy: { + directives: { + defaultSrc: ["'self'"], + scriptSrc: ["'self'"], + styleSrc: ["'self'", "'unsafe-inline'"], + }, + }, + hsts: { maxAge: 31536000, includeSubDomains: true }, + noSniff: true, + xssFilter: true, + frameguard: { action: 'deny' }, + }), +); +``` + +## Cryptography + +### Secure Practices + +- [ ] Using well-established algorithms (AES-256, RSA-2048+) +- [ ] Not implementing custom cryptography +- [ ] Using cryptographically secure random number generation +- [ ] Proper key management and rotation +- [ ] Secure key storage (HSM, KMS) + +### Common Mistakes + +```typescript +// ❌ Weak random generation +const token = Math.random().toString(36); + +// ✅ Cryptographically secure random +const crypto = require('crypto'); +const token = crypto.randomBytes(32).toString('hex'); + +// ❌ MD5/SHA1 for passwords +const hash = crypto.createHash('md5').update(password).digest('hex'); + +// ✅ Use bcrypt or argon2 +const bcrypt = require('bcrypt'); +const hash = await bcrypt.hash(password, 12); +``` + +## Dependency Security + +### Checklist + +- [ ] Dependencies from trusted sources only +- [ ] No known vulnerabilities (npm audit, cargo audit) +- [ ] Dependencies kept up to date +- [ ] Lock files committed (package-lock.json, Cargo.lock) +- [ ] Minimal dependency usage +- [ ] License compliance verified + +### Audit Commands + +```bash +# Node.js +npm audit +npm audit fix + +# Python +pip-audit +safety check + +# Rust +cargo audit + +# General +snyk test +``` + +## Logging & Monitoring + +### Secure Logging + +- [ ] No sensitive data in logs (passwords, tokens, PII) +- [ ] Logs protected from tampering +- [ ] Appropriate log retention +- [ ] Security events logged (login attempts, permission changes) +- [ ] Log injection prevented + +```typescript +// ❌ Logging sensitive data +logger.info(`User login: ${email}, password: ${password}`); + +// ✅ Safe logging +logger.info('User login attempt', { email, success: true }); +``` + +## Security Review Severity Levels + +| Severity | Description | Action | +| ------------ | ------------------------------------------------------- | ----------------------------------- | +| **Critical** | Immediate exploitation possible, data breach risk | Block merge, fix immediately | +| **High** | Significant vulnerability, requires specific conditions | Block merge, fix before release | +| **Medium** | Moderate risk, defense in depth concern | Should fix, can merge with tracking | +| **Low** | Minor issue, best practice violation | Nice to fix, non-blocking | +| **Info** | Suggestion for improvement | Optional enhancement | diff --git a/packages/mosaic/framework/skills/code-review-excellence/reference/typescript.md b/packages/mosaic/framework/skills/code-review-excellence/reference/typescript.md new file mode 100644 index 00000000..b9773109 --- /dev/null +++ b/packages/mosaic/framework/skills/code-review-excellence/reference/typescript.md @@ -0,0 +1,551 @@ +# TypeScript/JavaScript Code Review Guide + +> TypeScript 代码审查指南,覆盖类型系统、泛型、条件类型、strict 模式、async/await 模式等核心主题。 + +## 目录 + +- [类型安全基础](#类型安全基础) +- [泛型模式](#泛型模式) +- [高级类型](#高级类型) +- [Strict 模式配置](#strict-模式配置) +- [异步处理](#异步处理) +- [不可变性](#不可变性) +- [ESLint 规则](#eslint-规则) +- [Review Checklist](#review-checklist) + +--- + +## 类型安全基础 + +### 避免使用 any + +```typescript +// ❌ Using any defeats type safety +function processData(data: any) { + return data.value; // 无类型检查,运行时可能崩溃 +} + +// ✅ Use proper types +interface DataPayload { + value: string; +} +function processData(data: DataPayload) { + return data.value; +} + +// ✅ 未知类型用 unknown + 类型守卫 +function processUnknown(data: unknown) { + if (typeof data === 'object' && data !== null && 'value' in data) { + return (data as { value: string }).value; + } + throw new Error('Invalid data'); +} +``` + +### 类型收窄 + +```typescript +// ❌ 不安全的类型断言 +function getLength(value: string | string[]) { + return (value as string[]).length; // 如果是 string 会出错 +} + +// ✅ 使用类型守卫 +function getLength(value: string | string[]): number { + if (Array.isArray(value)) { + return value.length; + } + return value.length; +} + +// ✅ 使用 in 操作符 +interface Dog { + bark(): void; +} +interface Cat { + meow(): void; +} + +function speak(animal: Dog | Cat) { + if ('bark' in animal) { + animal.bark(); + } else { + animal.meow(); + } +} +``` + +### 字面量类型与 as const + +```typescript +// ❌ 类型过于宽泛 +const config = { + endpoint: '/api', + method: 'GET' // 类型是 string +}; + +// ✅ 使用 as const 获得字面量类型 +const config = { + endpoint: '/api', + method: 'GET' +} as const; // method 类型是 'GET' + +// ✅ 用于函数参数 +function request(method: 'GET' | 'POST', url: string) { ... } +request(config.method, config.endpoint); // 正确! +``` + +--- + +## 泛型模式 + +### 基础泛型 + +```typescript +// ❌ 重复代码 +function getFirstString(arr: string[]): string | undefined { + return arr[0]; +} +function getFirstNumber(arr: number[]): number | undefined { + return arr[0]; +} + +// ✅ 使用泛型 +function getFirst(arr: T[]): T | undefined { + return arr[0]; +} +``` + +### 泛型约束 + +```typescript +// ❌ 泛型没有约束,无法访问属性 +function getProperty(obj: T, key: string) { + return obj[key]; // Error: 无法索引 +} + +// ✅ 使用 keyof 约束 +function getProperty(obj: T, key: K): T[K] { + return obj[key]; +} + +const user = { name: 'Alice', age: 30 }; +getProperty(user, 'name'); // 返回类型是 string +getProperty(user, 'age'); // 返回类型是 number +getProperty(user, 'foo'); // Error: 'foo' 不在 keyof User +``` + +### 泛型默认值 + +```typescript +// ✅ 提供合理的默认类型 +interface ApiResponse { + data: T; + status: number; + message: string; +} + +// 可以不指定泛型参数 +const response: ApiResponse = { data: null, status: 200, message: 'OK' }; +// 也可以指定 +const userResponse: ApiResponse = { ... }; +``` + +### 常见泛型工具类型 + +```typescript +// ✅ 善用内置工具类型 +interface User { + id: number; + name: string; + email: string; +} + +type PartialUser = Partial; // 所有属性可选 +type RequiredUser = Required; // 所有属性必需 +type ReadonlyUser = Readonly; // 所有属性只读 +type UserKeys = keyof User; // 'id' | 'name' | 'email' +type NameOnly = Pick; // { name: string } +type WithoutId = Omit; // { name: string; email: string } +type UserRecord = Record; // { [key: string]: User } +``` + +--- + +## 高级类型 + +### 条件类型 + +```typescript +// ✅ 根据输入类型返回不同类型 +type IsString = T extends string ? true : false; + +type A = IsString; // true +type B = IsString; // false + +// ✅ 提取数组元素类型 +type ElementType = T extends (infer U)[] ? U : never; + +type Elem = ElementType; // string + +// ✅ 提取函数返回类型(内置 ReturnType) +type MyReturnType = T extends (...args: any[]) => infer R ? R : never; +``` + +### 映射类型 + +```typescript +// ✅ 转换对象类型的所有属性 +type Nullable = { + [K in keyof T]: T[K] | null; +}; + +interface User { + name: string; + age: number; +} + +type NullableUser = Nullable; +// { name: string | null; age: number | null } + +// ✅ 添加前缀 +type Getters = { + [K in keyof T as `get${Capitalize}`]: () => T[K]; +}; + +type UserGetters = Getters; +// { getName: () => string; getAge: () => number } +``` + +### 模板字面量类型 + +```typescript +// ✅ 类型安全的事件名称 +type EventName = 'click' | 'focus' | 'blur'; +type HandlerName = `on${Capitalize}`; +// 'onClick' | 'onFocus' | 'onBlur' + +// ✅ API 路由类型 +type ApiRoute = `/api/${string}`; +const route: ApiRoute = '/api/users'; // OK +const badRoute: ApiRoute = '/users'; // Error +``` + +### Discriminated Unions + +```typescript +// ✅ 使用判别属性实现类型安全 +type Result = { success: true; data: T } | { success: false; error: E }; + +function handleResult(result: Result) { + if (result.success) { + console.log(result.data.name); // TypeScript 知道 data 存在 + } else { + console.log(result.error.message); // TypeScript 知道 error 存在 + } +} + +// ✅ Redux Action 模式 +type Action = + | { type: 'INCREMENT'; payload: number } + | { type: 'DECREMENT'; payload: number } + | { type: 'RESET' }; + +function reducer(state: number, action: Action): number { + switch (action.type) { + case 'INCREMENT': + return state + action.payload; // payload 类型已知 + case 'DECREMENT': + return state - action.payload; + case 'RESET': + return 0; // 这里没有 payload + } +} +``` + +--- + +## Strict 模式配置 + +### 推荐的 tsconfig.json + +```json +{ + "compilerOptions": { + // ✅ 必须开启的 strict 选项 + "strict": true, + "noImplicitAny": true, + "strictNullChecks": true, + "strictFunctionTypes": true, + "strictBindCallApply": true, + "strictPropertyInitialization": true, + "noImplicitThis": true, + "useUnknownInCatchVariables": true, + + // ✅ 额外推荐选项 + "noUncheckedIndexedAccess": true, + "noImplicitReturns": true, + "noFallthroughCasesInSwitch": true, + "exactOptionalPropertyTypes": true, + "noPropertyAccessFromIndexSignature": true + } +} +``` + +### noUncheckedIndexedAccess 的影响 + +```typescript +// tsconfig: "noUncheckedIndexedAccess": true + +const arr = [1, 2, 3]; +const first = arr[0]; // 类型是 number | undefined + +// ❌ 直接使用可能出错 +console.log(first.toFixed(2)); // Error: 可能是 undefined + +// ✅ 先检查 +if (first !== undefined) { + console.log(first.toFixed(2)); +} + +// ✅ 或使用非空断言(确定时) +console.log(arr[0]!.toFixed(2)); +``` + +--- + +## 异步处理 + +### Promise 错误处理 + +```typescript +// ❌ Not handling async errors +async function fetchUser(id: string) { + const response = await fetch(`/api/users/${id}`); + return response.json(); // 网络错误未处理 +} + +// ✅ Handle errors properly +async function fetchUser(id: string): Promise { + try { + const response = await fetch(`/api/users/${id}`); + if (!response.ok) { + throw new Error(`HTTP ${response.status}: ${response.statusText}`); + } + return await response.json(); + } catch (error) { + if (error instanceof Error) { + throw new Error(`Failed to fetch user: ${error.message}`); + } + throw error; + } +} +``` + +### Promise.all vs Promise.allSettled + +```typescript +// ❌ Promise.all 一个失败全部失败 +async function fetchAllUsers(ids: string[]) { + const users = await Promise.all(ids.map(fetchUser)); + return users; // 一个失败就全部失败 +} + +// ✅ Promise.allSettled 获取所有结果 +async function fetchAllUsers(ids: string[]) { + const results = await Promise.allSettled(ids.map(fetchUser)); + + const users: User[] = []; + const errors: Error[] = []; + + for (const result of results) { + if (result.status === 'fulfilled') { + users.push(result.value); + } else { + errors.push(result.reason); + } + } + + return { users, errors }; +} +``` + +### 竞态条件处理 + +```typescript +// ❌ 竞态条件:旧请求可能覆盖新请求 +function useSearch() { + const [query, setQuery] = useState(''); + const [results, setResults] = useState([]); + + useEffect(() => { + fetch(`/api/search?q=${query}`) + .then((r) => r.json()) + .then(setResults); // 旧请求可能后返回! + }, [query]); +} + +// ✅ 使用 AbortController +function useSearch() { + const [query, setQuery] = useState(''); + const [results, setResults] = useState([]); + + useEffect(() => { + const controller = new AbortController(); + + fetch(`/api/search?q=${query}`, { signal: controller.signal }) + .then((r) => r.json()) + .then(setResults) + .catch((e) => { + if (e.name !== 'AbortError') throw e; + }); + + return () => controller.abort(); + }, [query]); +} +``` + +--- + +## 不可变性 + +### Readonly 与 ReadonlyArray + +```typescript +// ❌ 可变参数可能被意外修改 +function processUsers(users: User[]) { + users.sort((a, b) => a.name.localeCompare(b.name)); // 修改了原数组! + return users; +} + +// ✅ 使用 readonly 防止修改 +function processUsers(users: readonly User[]): User[] { + return [...users].sort((a, b) => a.name.localeCompare(b.name)); +} + +// ✅ 深度只读 +type DeepReadonly = { + readonly [K in keyof T]: T[K] extends object ? DeepReadonly : T[K]; +}; +``` + +### 不变式函数参数 + +```typescript +// ✅ 使用 as const 和 readonly 保护数据 +function createConfig(routes: T) { + return routes; +} + +const routes = createConfig(['home', 'about', 'contact'] as const); +// 类型是 readonly ['home', 'about', 'contact'] +``` + +--- + +## ESLint 规则 + +### 推荐的 @typescript-eslint 规则 + +```javascript +// .eslintrc.js +module.exports = { + extends: [ + 'eslint:recommended', + 'plugin:@typescript-eslint/recommended', + 'plugin:@typescript-eslint/recommended-requiring-type-checking', + 'plugin:@typescript-eslint/strict', + ], + rules: { + // ✅ 类型安全 + '@typescript-eslint/no-explicit-any': 'error', + '@typescript-eslint/no-unsafe-assignment': 'error', + '@typescript-eslint/no-unsafe-member-access': 'error', + '@typescript-eslint/no-unsafe-call': 'error', + '@typescript-eslint/no-unsafe-return': 'error', + + // ✅ 最佳实践 + '@typescript-eslint/explicit-function-return-type': 'warn', + '@typescript-eslint/no-floating-promises': 'error', + '@typescript-eslint/await-thenable': 'error', + '@typescript-eslint/no-misused-promises': 'error', + + // ✅ 代码风格 + '@typescript-eslint/consistent-type-imports': 'error', + '@typescript-eslint/prefer-nullish-coalescing': 'error', + '@typescript-eslint/prefer-optional-chain': 'error', + }, +}; +``` + +### 常见 ESLint 错误修复 + +```typescript +// ❌ no-floating-promises: Promise 必须被处理 +async function save() { ... } +save(); // Error: 未处理的 Promise + +// ✅ 显式处理 +await save(); +// 或 +save().catch(console.error); +// 或明确忽略 +void save(); + +// ❌ no-misused-promises: 不能在非 async 位置使用 Promise +const items = [1, 2, 3]; +items.forEach(async (item) => { // Error! + await processItem(item); +}); + +// ✅ 使用 for...of +for (const item of items) { + await processItem(item); +} +// 或 Promise.all +await Promise.all(items.map(processItem)); +``` + +--- + +## Review Checklist + +### 类型系统 + +- [ ] 没有使用 `any`(使用 `unknown` + 类型守卫代替) +- [ ] 接口和类型定义完整且有意义的命名 +- [ ] 使用泛型提高代码复用性 +- [ ] 联合类型有正确的类型收窄 +- [ ] 善用工具类型(Partial、Pick、Omit 等) + +### 泛型 + +- [ ] 泛型有适当的约束(extends) +- [ ] 泛型参数有合理的默认值 +- [ ] 避免过度泛型化(KISS 原则) + +### Strict 模式 + +- [ ] tsconfig.json 启用了 strict: true +- [ ] 启用了 noUncheckedIndexedAccess +- [ ] 没有使用 @ts-ignore(改用 @ts-expect-error) + +### 异步代码 + +- [ ] async 函数有错误处理 +- [ ] Promise rejection 被正确处理 +- [ ] 没有 floating promises(未处理的 Promise) +- [ ] 并发请求使用 Promise.all 或 Promise.allSettled +- [ ] 竞态条件使用 AbortController 处理 + +### 不可变性 + +- [ ] 不直接修改函数参数 +- [ ] 使用 spread 操作符创建新对象/数组 +- [ ] 考虑使用 readonly 修饰符 + +### ESLint + +- [ ] 使用 @typescript-eslint/recommended +- [ ] 没有 ESLint 警告或错误 +- [ ] 使用 consistent-type-imports diff --git a/packages/mosaic/framework/skills/code-review-excellence/reference/vue.md b/packages/mosaic/framework/skills/code-review-excellence/reference/vue.md new file mode 100644 index 00000000..4d16c6a1 --- /dev/null +++ b/packages/mosaic/framework/skills/code-review-excellence/reference/vue.md @@ -0,0 +1,928 @@ +# Vue 3 Code Review Guide + +> Vue 3 Composition API 代码审查指南,覆盖响应性系统、Props/Emits、Watchers、Composables、Vue 3.5 新特性等核心主题。 + +## 目录 + +- [响应性系统](#响应性系统) +- [Props & Emits](#props--emits) +- [Vue 3.5 新特性](#vue-35-新特性) +- [Watchers](#watchers) +- [模板最佳实践](#模板最佳实践) +- [Composables](#composables) +- [性能优化](#性能优化) +- [Review Checklist](#review-checklist) + +--- + +## 响应性系统 + +### ref vs reactive 选择 + +```vue + + + + + + + + +``` + +### 解构 reactive 对象 + +```vue + + + + + +``` + +### computed 副作用 + +```vue + + + + + +``` + +### shallowRef 优化 + +```vue + + + + + +``` + +--- + +## Props & Emits + +### 直接修改 props + +```vue + + + + + +``` + +### defineProps 类型声明 + +```vue + + + + + +``` + +### defineEmits 类型安全 + +```vue + + + + + +``` + +--- + +## Vue 3.5 新特性 + +### Reactive Props Destructure (3.5+) + +```vue + + + + + + + + +``` + +### defineModel (3.4+) + +```vue + + + + + + + + + + + + + + + + +``` + +### useTemplateRef (3.5+) + +```vue + + + + + + + + + + +``` + +### useId (3.5+) + +```vue + + + + + + + + + + +``` + +### onWatcherCleanup (3.5+) + +```vue + + + + + +``` + +### Deferred Teleport (3.5+) + +```vue + + + + + +``` + +--- + +## Watchers + +### watch vs watchEffect + +```vue + +``` + +### watch 清理函数 + +```vue + + + + + +``` + +### watch 选项 + +```vue + +``` + +### 监听多个源 + +```vue + +``` + +--- + +## 模板最佳实践 + +### v-for 的 key + +```vue + + + + + + + + +``` + +### v-if 和 v-for 优先级 + +```vue + + + + + + + + + +``` + +### 事件处理 + +```vue + + + + + + + + + +``` + +--- + +## Composables + +### Composable 设计原则 + +```typescript +// ✅ 好的 composable 设计 +export function useCounter(initialValue = 0) { + const count = ref(initialValue); + + const increment = () => count.value++; + const decrement = () => count.value--; + const reset = () => (count.value = initialValue); + + // 返回响应式引用和方法 + return { + count: readonly(count), // 只读防止外部修改 + increment, + decrement, + reset, + }; +} + +// ❌ 不要返回 .value +export function useBadCounter() { + const count = ref(0); + return { + count: count.value, // ❌ 丢失响应性! + }; +} +``` + +### Props 传递给 composable + +```vue + + + + + +``` + +### 异步 Composable + +```typescript +// ✅ 异步 composable 模式 +export function useFetch(url: MaybeRefOrGetter) { + const data = ref(null); + const error = ref(null); + const loading = ref(false); + + const execute = async () => { + loading.value = true; + error.value = null; + + try { + const response = await fetch(toValue(url)); + if (!response.ok) { + throw new Error(`HTTP ${response.status}`); + } + data.value = await response.json(); + } catch (e) { + error.value = e as Error; + } finally { + loading.value = false; + } + }; + + // 响应式 URL 时自动重新获取 + watchEffect(() => { + toValue(url); // 追踪依赖 + execute(); + }); + + return { + data: readonly(data), + error: readonly(error), + loading: readonly(loading), + refetch: execute, + }; +} + +// 使用 +const { data, loading, error, refetch } = useFetch('/api/users'); +``` + +### 生命周期与清理 + +```typescript +// ✅ Composable 中正确处理生命周期 +export function useEventListener( + target: MaybeRefOrGetter, + event: string, + handler: EventListener, +) { + // 组件挂载后添加 + onMounted(() => { + toValue(target).addEventListener(event, handler); + }); + + // 组件卸载时移除 + onUnmounted(() => { + toValue(target).removeEventListener(event, handler); + }); +} + +// ✅ 使用 effectScope 管理副作用 +export function useFeature() { + const scope = effectScope(); + + scope.run(() => { + // 所有响应式效果都在这个 scope 内 + const state = ref(0); + watch(state, () => { + /* ... */ + }); + watchEffect(() => { + /* ... */ + }); + }); + + // 清理所有效果 + onUnmounted(() => scope.stop()); + + return { + /* ... */ + }; +} +``` + +--- + +## 性能优化 + +### v-memo + +```vue + + + + + +``` + +### defineAsyncComponent + +```vue + +``` + +### KeepAlive + +```vue + + + +``` + +### 虚拟列表 + +```vue + + + +``` + +--- + +## Review Checklist + +### 响应性系统 + +- [ ] ref 用于基本类型,reactive 用于对象(或统一用 ref) +- [ ] 没有解构 reactive 对象(或使用了 toRefs) +- [ ] props 传递给 composable 时保持了响应性 +- [ ] shallowRef/shallowReactive 用于大型对象优化 +- [ ] computed 中没有副作用 + +### Props & Emits + +- [ ] defineProps 使用 TypeScript 类型声明 +- [ ] 复杂默认值使用 withDefaults + 工厂函数 +- [ ] defineEmits 有完整的类型定义 +- [ ] 没有直接修改 props +- [ ] 考虑使用 defineModel 简化 v-model(Vue 3.4+) + +### Vue 3.5 新特性(如适用) + +- [ ] 使用 Reactive Props Destructure 简化 props 访问 +- [ ] 使用 useTemplateRef 替代 ref 属性 +- [ ] 表单使用 useId 生成 SSR 安全的 ID +- [ ] 使用 onWatcherCleanup 处理复杂清理逻辑 + +### Watchers + +- [ ] watch/watchEffect 有适当的清理函数 +- [ ] 异步 watch 处理了竞态条件 +- [ ] flush: 'post' 用于 DOM 操作的 watcher +- [ ] 避免过度使用 watcher(优先用 computed) +- [ ] 考虑 once: true 用于一次性监听 + +### 模板 + +- [ ] v-for 使用唯一且稳定的 key +- [ ] v-if 和 v-for 没有在同一元素上 +- [ ] 事件处理使用方法而非内联复杂逻辑 +- [ ] 大型列表使用虚拟滚动 + +### Composables + +- [ ] 相关逻辑提取到 composables +- [ ] composables 返回响应式引用(不是 .value) +- [ ] 纯函数不要包装成 composable +- [ ] 副作用在组件卸载时清理 +- [ ] 使用 effectScope 管理复杂副作用 + +### 性能 + +- [ ] 大型组件拆分为小组件 +- [ ] 使用 defineAsyncComponent 懒加载 +- [ ] 避免不必要的响应式转换 +- [ ] v-memo 用于昂贵的列表渲染 +- [ ] KeepAlive 用于缓存动态组件 diff --git a/packages/mosaic/framework/skills/code-review-excellence/scripts/pr-analyzer.py b/packages/mosaic/framework/skills/code-review-excellence/scripts/pr-analyzer.py new file mode 100644 index 00000000..7b594e7d --- /dev/null +++ b/packages/mosaic/framework/skills/code-review-excellence/scripts/pr-analyzer.py @@ -0,0 +1,366 @@ +#!/usr/bin/env python3 +""" +PR Analyzer - Analyze PR complexity and suggest review approach. + +Usage: + python pr-analyzer.py [--diff-file FILE] [--stats] + + Or pipe diff directly: + git diff main...HEAD | python pr-analyzer.py +""" + +import sys +import re +import argparse +from collections import defaultdict +from dataclasses import dataclass +from typing import List, Dict, Optional + + +@dataclass +class FileStats: + """Statistics for a single file.""" + filename: str + additions: int = 0 + deletions: int = 0 + is_test: bool = False + is_config: bool = False + language: str = "unknown" + + +@dataclass +class PRAnalysis: + """Complete PR analysis results.""" + total_files: int + total_additions: int + total_deletions: int + files: List[FileStats] + complexity_score: float + size_category: str + estimated_review_time: int + risk_factors: List[str] + suggestions: List[str] + + +def detect_language(filename: str) -> str: + """Detect programming language from filename.""" + extensions = { + '.py': 'Python', + '.js': 'JavaScript', + '.ts': 'TypeScript', + '.tsx': 'TypeScript/React', + '.jsx': 'JavaScript/React', + '.rs': 'Rust', + '.go': 'Go', + '.c': 'C', + '.h': 'C/C++', + '.cpp': 'C++', + '.hpp': 'C++', + '.cc': 'C++', + '.cxx': 'C++', + '.hh': 'C++', + '.hxx': 'C++', + '.java': 'Java', + '.rb': 'Ruby', + '.sql': 'SQL', + '.md': 'Markdown', + '.json': 'JSON', + '.yaml': 'YAML', + '.yml': 'YAML', + '.toml': 'TOML', + '.css': 'CSS', + '.scss': 'SCSS', + '.html': 'HTML', + } + for ext, lang in extensions.items(): + if filename.endswith(ext): + return lang + return 'unknown' + + +def is_test_file(filename: str) -> bool: + """Check if file is a test file.""" + test_patterns = [ + r'test_.*\.py$', + r'.*_test\.py$', + r'.*\.test\.(js|ts|tsx)$', + r'.*\.spec\.(js|ts|tsx)$', + r'tests?/', + r'__tests__/', + ] + return any(re.search(p, filename) for p in test_patterns) + + +def is_config_file(filename: str) -> bool: + """Check if file is a configuration file.""" + config_patterns = [ + r'\.env', + r'config\.', + r'\.json$', + r'\.yaml$', + r'\.yml$', + r'\.toml$', + r'Cargo\.toml$', + r'package\.json$', + r'tsconfig\.json$', + ] + return any(re.search(p, filename) for p in config_patterns) + + +def parse_diff(diff_content: str) -> List[FileStats]: + """Parse git diff output and extract file statistics.""" + files = [] + current_file = None + + for line in diff_content.split('\n'): + # New file header + if line.startswith('diff --git'): + if current_file: + files.append(current_file) + # Extract filename from "diff --git a/path b/path" + match = re.search(r'b/(.+)$', line) + if match: + filename = match.group(1) + current_file = FileStats( + filename=filename, + language=detect_language(filename), + is_test=is_test_file(filename), + is_config=is_config_file(filename), + ) + elif current_file: + if line.startswith('+') and not line.startswith('+++'): + current_file.additions += 1 + elif line.startswith('-') and not line.startswith('---'): + current_file.deletions += 1 + + if current_file: + files.append(current_file) + + return files + + +def calculate_complexity(files: List[FileStats]) -> float: + """Calculate complexity score (0-1 scale).""" + if not files: + return 0.0 + + total_changes = sum(f.additions + f.deletions for f in files) + + # Base complexity from size + size_factor = min(total_changes / 1000, 1.0) + + # Factor for number of files + file_factor = min(len(files) / 20, 1.0) + + # Factor for non-test code ratio + test_lines = sum(f.additions + f.deletions for f in files if f.is_test) + non_test_ratio = 1 - (test_lines / max(total_changes, 1)) + + # Factor for language diversity + languages = set(f.language for f in files if f.language != 'unknown') + lang_factor = min(len(languages) / 5, 1.0) + + complexity = ( + size_factor * 0.4 + + file_factor * 0.2 + + non_test_ratio * 0.2 + + lang_factor * 0.2 + ) + + return round(complexity, 2) + + +def categorize_size(total_changes: int) -> str: + """Categorize PR size.""" + if total_changes < 50: + return "XS (Extra Small)" + elif total_changes < 200: + return "S (Small)" + elif total_changes < 400: + return "M (Medium)" + elif total_changes < 800: + return "L (Large)" + else: + return "XL (Extra Large) - Consider splitting" + + +def estimate_review_time(files: List[FileStats], complexity: float) -> int: + """Estimate review time in minutes.""" + total_changes = sum(f.additions + f.deletions for f in files) + + # Base time: ~1 minute per 20 lines + base_time = total_changes / 20 + + # Adjust for complexity + adjusted_time = base_time * (1 + complexity) + + # Minimum 5 minutes, maximum 120 minutes + return max(5, min(120, int(adjusted_time))) + + +def identify_risk_factors(files: List[FileStats]) -> List[str]: + """Identify potential risk factors in the PR.""" + risks = [] + + total_changes = sum(f.additions + f.deletions for f in files) + test_changes = sum(f.additions + f.deletions for f in files if f.is_test) + + # Large PR + if total_changes > 400: + risks.append("Large PR (>400 lines) - harder to review thoroughly") + + # No tests + if test_changes == 0 and total_changes > 50: + risks.append("No test changes - verify test coverage") + + # Low test ratio + if total_changes > 100 and test_changes / max(total_changes, 1) < 0.2: + risks.append("Low test ratio (<20%) - consider adding more tests") + + # Security-sensitive files + security_patterns = ['.env', 'auth', 'security', 'password', 'token', 'secret'] + for f in files: + if any(p in f.filename.lower() for p in security_patterns): + risks.append(f"Security-sensitive file: {f.filename}") + break + + # Database changes + for f in files: + if 'migration' in f.filename.lower() or f.language == 'SQL': + risks.append("Database changes detected - review carefully") + break + + # Config changes + config_files = [f for f in files if f.is_config] + if config_files: + risks.append(f"Configuration changes in {len(config_files)} file(s)") + + return risks + + +def generate_suggestions(files: List[FileStats], complexity: float, risks: List[str]) -> List[str]: + """Generate review suggestions.""" + suggestions = [] + + total_changes = sum(f.additions + f.deletions for f in files) + + if total_changes > 800: + suggestions.append("Consider splitting this PR into smaller, focused changes") + + if complexity > 0.7: + suggestions.append("High complexity - allocate extra review time") + suggestions.append("Consider pair reviewing for critical sections") + + if "No test changes" in str(risks): + suggestions.append("Request test additions before approval") + + # Language-specific suggestions + languages = set(f.language for f in files) + if 'TypeScript' in languages or 'TypeScript/React' in languages: + suggestions.append("Check for proper type usage (avoid 'any')") + if 'Rust' in languages: + suggestions.append("Check for unwrap() usage and error handling") + if 'C' in languages or 'C++' in languages or 'C/C++' in languages: + suggestions.append("Check for memory safety, bounds checks, and UB risks") + if 'SQL' in languages: + suggestions.append("Review for SQL injection and query performance") + + if not suggestions: + suggestions.append("Standard review process should suffice") + + return suggestions + + +def analyze_pr(diff_content: str) -> PRAnalysis: + """Perform complete PR analysis.""" + files = parse_diff(diff_content) + + total_additions = sum(f.additions for f in files) + total_deletions = sum(f.deletions for f in files) + total_changes = total_additions + total_deletions + + complexity = calculate_complexity(files) + risks = identify_risk_factors(files) + suggestions = generate_suggestions(files, complexity, risks) + + return PRAnalysis( + total_files=len(files), + total_additions=total_additions, + total_deletions=total_deletions, + files=files, + complexity_score=complexity, + size_category=categorize_size(total_changes), + estimated_review_time=estimate_review_time(files, complexity), + risk_factors=risks, + suggestions=suggestions, + ) + + +def print_analysis(analysis: PRAnalysis, show_files: bool = False): + """Print analysis results.""" + print("\n" + "=" * 60) + print("PR ANALYSIS REPORT") + print("=" * 60) + + print(f"\n📊 SUMMARY") + print(f" Files changed: {analysis.total_files}") + print(f" Additions: +{analysis.total_additions}") + print(f" Deletions: -{analysis.total_deletions}") + print(f" Total changes: {analysis.total_additions + analysis.total_deletions}") + + print(f"\n📏 SIZE: {analysis.size_category}") + print(f" Complexity score: {analysis.complexity_score}/1.0") + print(f" Estimated review time: ~{analysis.estimated_review_time} minutes") + + if analysis.risk_factors: + print(f"\n⚠️ RISK FACTORS:") + for risk in analysis.risk_factors: + print(f" • {risk}") + + print(f"\n💡 SUGGESTIONS:") + for suggestion in analysis.suggestions: + print(f" • {suggestion}") + + if show_files: + print(f"\n📁 FILES:") + # Group by language + by_lang: Dict[str, List[FileStats]] = defaultdict(list) + for f in analysis.files: + by_lang[f.language].append(f) + + for lang, lang_files in sorted(by_lang.items()): + print(f"\n [{lang}]") + for f in lang_files: + prefix = "🧪" if f.is_test else "⚙️" if f.is_config else "📄" + print(f" {prefix} {f.filename} (+{f.additions}/-{f.deletions})") + + print("\n" + "=" * 60) + + +def main(): + parser = argparse.ArgumentParser(description='Analyze PR complexity') + parser.add_argument('--diff-file', '-f', help='Path to diff file') + parser.add_argument('--stats', '-s', action='store_true', help='Show file details') + args = parser.parse_args() + + # Read diff from file or stdin + if args.diff_file: + with open(args.diff_file, 'r') as f: + diff_content = f.read() + elif not sys.stdin.isatty(): + diff_content = sys.stdin.read() + else: + print("Usage: git diff main...HEAD | python pr-analyzer.py") + print(" python pr-analyzer.py -f diff.txt") + sys.exit(1) + + if not diff_content.strip(): + print("No diff content provided") + sys.exit(1) + + analysis = analyze_pr(diff_content) + print_analysis(analysis, show_files=args.stats) + + +if __name__ == '__main__': + main() diff --git a/packages/mosaic/framework/skills/competitor-alternatives/SKILL.md b/packages/mosaic/framework/skills/competitor-alternatives/SKILL.md new file mode 100644 index 00000000..db0e7944 --- /dev/null +++ b/packages/mosaic/framework/skills/competitor-alternatives/SKILL.md @@ -0,0 +1,275 @@ +--- +name: competitor-alternatives +version: 1.0.0 +description: "When the user wants to create competitor comparison or alternative pages for SEO and sales enablement. Also use when the user mentions 'alternative page,' 'vs page,' 'competitor comparison,' 'comparison page,' '[Product] vs [Product],' '[Product] alternative,' or 'competitive landing pages.' Covers four formats: singular alternative, plural alternatives, you vs competitor, and competitor vs competitor. Emphasizes deep research, modular content architecture, and varied section types beyond feature tables." +--- + +# Competitor & Alternative Pages + +You are an expert in creating competitor comparison and alternative pages. Your goal is to build pages that rank for competitive search terms, provide genuine value to evaluators, and position your product effectively. + +## Initial Assessment + +**Check for product marketing context first:** +If `.mosaic/product-marketing-context.md` exists, read it before asking questions. Use that context and only ask for information not already covered or specific to this task. + +Before creating competitor pages, understand: + +1. **Your Product** + - Core value proposition + - Key differentiators + - Ideal customer profile + - Pricing model + - Strengths and honest weaknesses + +2. **Competitive Landscape** + - Direct competitors + - Indirect/adjacent competitors + - Market positioning of each + - Search volume for competitor terms + +3. **Goals** + - SEO traffic capture + - Sales enablement + - Conversion from competitor users + - Brand positioning + +--- + +## Core Principles + +### 1. Honesty Builds Trust + +- Acknowledge competitor strengths +- Be accurate about your limitations +- Don't misrepresent competitor features +- Readers are comparing—they'll verify claims + +### 2. Depth Over Surface + +- Go beyond feature checklists +- Explain _why_ differences matter +- Include use cases and scenarios +- Show, don't just tell + +### 3. Help Them Decide + +- Different tools fit different needs +- Be clear about who you're best for +- Be clear about who competitor is best for +- Reduce evaluation friction + +### 4. Modular Content Architecture + +- Competitor data should be centralized +- Updates propagate to all pages +- Single source of truth per competitor + +--- + +## Page Formats + +### Format 1: [Competitor] Alternative (Singular) + +**Search intent**: User is actively looking to switch from a specific competitor + +**URL pattern**: `/alternatives/[competitor]` or `/[competitor]-alternative` + +**Target keywords**: "[Competitor] alternative", "alternative to [Competitor]", "switch from [Competitor]" + +**Page structure**: + +1. Why people look for alternatives (validate their pain) +2. Summary: You as the alternative (quick positioning) +3. Detailed comparison (features, service, pricing) +4. Who should switch (and who shouldn't) +5. Migration path +6. Social proof from switchers +7. CTA + +--- + +### Format 2: [Competitor] Alternatives (Plural) + +**Search intent**: User is researching options, earlier in journey + +**URL pattern**: `/alternatives/[competitor]-alternatives` + +**Target keywords**: "[Competitor] alternatives", "best [Competitor] alternatives", "tools like [Competitor]" + +**Page structure**: + +1. Why people look for alternatives (common pain points) +2. What to look for in an alternative (criteria framework) +3. List of alternatives (you first, but include real options) +4. Comparison table (summary) +5. Detailed breakdown of each alternative +6. Recommendation by use case +7. CTA + +**Important**: Include 4-7 real alternatives. Being genuinely helpful builds trust and ranks better. + +--- + +### Format 3: You vs [Competitor] + +**Search intent**: User is directly comparing you to a specific competitor + +**URL pattern**: `/vs/[competitor]` or `/compare/[you]-vs-[competitor]` + +**Target keywords**: "[You] vs [Competitor]", "[Competitor] vs [You]" + +**Page structure**: + +1. TL;DR summary (key differences in 2-3 sentences) +2. At-a-glance comparison table +3. Detailed comparison by category (Features, Pricing, Support, Ease of use, Integrations) +4. Who [You] is best for +5. Who [Competitor] is best for (be honest) +6. What customers say (testimonials from switchers) +7. Migration support +8. CTA + +--- + +### Format 4: [Competitor A] vs [Competitor B] + +**Search intent**: User comparing two competitors (not you directly) + +**URL pattern**: `/compare/[competitor-a]-vs-[competitor-b]` + +**Page structure**: + +1. Overview of both products +2. Comparison by category +3. Who each is best for +4. The third option (introduce yourself) +5. Comparison table (all three) +6. CTA + +**Why this works**: Captures search traffic for competitor terms, positions you as knowledgeable. + +--- + +## Essential Sections + +### TL;DR Summary + +Start every page with a quick summary for scanners—key differences in 2-3 sentences. + +### Paragraph Comparisons + +Go beyond tables. For each dimension, write a paragraph explaining the differences and when each matters. + +### Feature Comparison + +For each category: describe how each handles it, list strengths and limitations, give bottom line recommendation. + +### Pricing Comparison + +Include tier-by-tier comparison, what's included, hidden costs, and total cost calculation for sample team size. + +### Who It's For + +Be explicit about ideal customer for each option. Honest recommendations build trust. + +### Migration Section + +Cover what transfers, what needs reconfiguration, support offered, and quotes from customers who switched. + +**For detailed templates**: See [references/templates.md](references/templates.md) + +--- + +## Content Architecture + +### Centralized Competitor Data + +Create a single source of truth for each competitor with: + +- Positioning and target audience +- Pricing (all tiers) +- Feature ratings +- Strengths and weaknesses +- Best for / not ideal for +- Common complaints (from reviews) +- Migration notes + +**For data structure and examples**: See [references/content-architecture.md](references/content-architecture.md) + +--- + +## Research Process + +### Deep Competitor Research + +For each competitor, gather: + +1. **Product research**: Sign up, use it, document features/UX/limitations +2. **Pricing research**: Current pricing, what's included, hidden costs +3. **Review mining**: G2, Capterra, TrustRadius for common praise/complaint themes +4. **Customer feedback**: Talk to customers who switched (both directions) +5. **Content research**: Their positioning, their comparison pages, their changelog + +### Ongoing Updates + +- **Quarterly**: Verify pricing, check for major feature changes +- **When notified**: Customer mentions competitor change +- **Annually**: Full refresh of all competitor data + +--- + +## SEO Considerations + +### Keyword Targeting + +| Format | Primary Keywords | +| ------------------------ | --------------------------------------------------------- | +| Alternative (singular) | [Competitor] alternative, alternative to [Competitor] | +| Alternatives (plural) | [Competitor] alternatives, best [Competitor] alternatives | +| You vs Competitor | [You] vs [Competitor], [Competitor] vs [You] | +| Competitor vs Competitor | [A] vs [B], [B] vs [A] | + +### Internal Linking + +- Link between related competitor pages +- Link from feature pages to relevant comparisons +- Create hub page linking to all competitor content + +### Schema Markup + +Consider FAQ schema for common questions like "What is the best alternative to [Competitor]?" + +--- + +## Output Format + +### Competitor Data File + +Complete competitor profile in YAML format for use across all comparison pages. + +### Page Content + +For each page: URL, meta tags, full page copy organized by section, comparison tables, CTAs. + +### Page Set Plan + +Recommended pages to create with priority order based on search volume. + +--- + +## Task-Specific Questions + +1. What are common reasons people switch to you? +2. Do you have customer quotes about switching? +3. What's your pricing vs. competitors? +4. Do you offer migration support? + +--- + +## Related Skills + +- **programmatic-seo**: For building competitor pages at scale +- **copywriting**: For writing compelling comparison copy +- **seo-audit**: For optimizing competitor pages +- **schema-markup**: For FAQ and comparison schema diff --git a/packages/mosaic/framework/skills/competitor-alternatives/references/content-architecture.md b/packages/mosaic/framework/skills/competitor-alternatives/references/content-architecture.md new file mode 100644 index 00000000..6b379fdc --- /dev/null +++ b/packages/mosaic/framework/skills/competitor-alternatives/references/content-architecture.md @@ -0,0 +1,272 @@ +# Content Architecture for Competitor Pages + +How to structure and maintain competitor data for scalable comparison pages. + +## Centralized Competitor Data + +Create a single source of truth for each competitor: + +``` +competitor_data/ +├── notion.md +├── airtable.md +├── monday.md +└── ... +``` + +--- + +## Competitor Data Template + +Per competitor, document: + +```yaml +name: Notion +website: notion.so +tagline: 'The all-in-one workspace' +founded: 2016 +headquarters: San Francisco + +# Positioning +primary_use_case: 'docs + light databases' +target_audience: 'teams wanting flexible workspace' +market_position: 'premium, feature-rich' + +# Pricing +pricing_model: per-seat +free_tier: true +free_tier_limits: 'limited blocks, 1 user' +starter_price: $8/user/month +business_price: $15/user/month +enterprise: custom + +# Features (rate 1-5 or describe) +features: + documents: 5 + databases: 4 + project_management: 3 + collaboration: 4 + integrations: 3 + mobile_app: 3 + offline_mode: 2 + api: 4 + +# Strengths (be honest) +strengths: + - Extremely flexible and customizable + - Beautiful, modern interface + - Strong template ecosystem + - Active community + +# Weaknesses (be fair) +weaknesses: + - Can be slow with large databases + - Learning curve for advanced features + - Limited automations compared to dedicated tools + - Offline mode is limited + +# Best for +best_for: + - Teams wanting all-in-one workspace + - Content-heavy workflows + - Documentation-first teams + - Startups and small teams + +# Not ideal for +not_ideal_for: + - Complex project management needs + - Large databases (1000s of rows) + - Teams needing robust offline + - Enterprise with strict compliance + +# Common complaints (from reviews) +common_complaints: + - 'Gets slow with lots of content' + - 'Hard to find things as workspace grows' + - 'Mobile app is clunky' + +# Migration notes +migration_from: + difficulty: medium + data_export: 'Markdown, CSV, HTML' + what_transfers: 'Pages, databases' + what_doesnt: 'Automations, integrations setup' + time_estimate: '1-3 days for small team' +``` + +--- + +## Your Product Data + +Same structure for yourself—be honest: + +```yaml +name: [Your Product] +# ... same fields + +strengths: + - [Your real strengths] + +weaknesses: + - [Your honest weaknesses] + +best_for: + - [Your ideal customers] + +not_ideal_for: + - [Who should use something else] +``` + +--- + +## Page Generation + +Each page pulls from centralized data: + +- **[Competitor] Alternative page**: Pulls competitor data + your data +- **[Competitor] Alternatives page**: Pulls competitor data + your data + other alternatives +- **You vs [Competitor] page**: Pulls your data + competitor data +- **[A] vs [B] page**: Pulls both competitor data + your data + +**Benefits**: + +- Update competitor pricing once, updates everywhere +- Add new feature comparison once, appears on all pages +- Consistent accuracy across pages +- Easier to maintain at scale + +--- + +## Index Page Structure + +### Alternatives Index + +**URL**: `/alternatives` or `/alternatives/index` + +**Purpose**: Lists all "[Competitor] Alternative" pages + +**Page structure**: + +1. Headline: "[Your Product] as an Alternative" +2. Brief intro on why people switch to you +3. List of all alternative pages with: + - Competitor name/logo + - One-line summary of key differentiator vs. that competitor + - Link to full comparison +4. Common reasons people switch (aggregated) +5. CTA + +**Example**: + +```markdown +## Explore [Your Product] as an Alternative + +Looking to switch? See how [Your Product] compares to the tools you're evaluating: + +- **[Notion Alternative](/alternatives/notion)** — Better for teams who need [X] +- **[Airtable Alternative](/alternatives/airtable)** — Better for teams who need [Y] +- **[Monday Alternative](/alternatives/monday)** — Better for teams who need [Z] +``` + +--- + +### Vs Comparisons Index + +**URL**: `/vs` or `/compare` + +**Purpose**: Lists all "You vs [Competitor]" and "[A] vs [B]" pages + +**Page structure**: + +1. Headline: "Compare [Your Product]" +2. Section: "[Your Product] vs Competitors" — list of direct comparisons +3. Section: "Head-to-Head Comparisons" — list of [A] vs [B] pages +4. Brief methodology note +5. CTA + +--- + +### Index Page Best Practices + +**Keep them updated**: When you add a new comparison page, add it to the relevant index. + +**Internal linking**: + +- Link from index → individual pages +- Link from individual pages → back to index +- Cross-link between related comparisons + +**SEO value**: + +- Index pages can rank for broad terms like "project management tool comparisons" +- Pass link equity to individual comparison pages +- Help search engines discover all comparison content + +**Sorting options**: + +- By popularity (search volume) +- Alphabetically +- By category/use case +- By date added (show freshness) + +**Include on index pages**: + +- Last updated date for credibility +- Number of pages/comparisons available +- Quick filters if you have many comparisons + +--- + +## Footer Navigation + +The site footer appears on all marketing pages, making it a powerful internal linking opportunity for competitor pages. + +### Option 1: Link to Index Pages (Minimum) + +At minimum, add links to your comparison index pages in the footer: + +``` +Footer +├── Compare +│ ├── Alternatives → /alternatives +│ └── Comparisons → /vs +``` + +This ensures every marketing page passes link equity to your comparison content hub. + +### Option 2: Footer Columns by Format (Recommended for SEO) + +For stronger internal linking, create dedicated footer columns for each format you've built, linking directly to your top competitors: + +``` +Footer +├── [Product] vs ├── Alternatives to ├── Compare +│ ├── vs Notion │ ├── Notion Alternative │ ├── Notion vs Airtable +│ ├── vs Airtable │ ├── Airtable Alternative │ ├── Monday vs Asana +│ ├── vs Monday │ ├── Monday Alternative │ ├── Notion vs Monday +│ ├── vs Asana │ ├── Asana Alternative │ ├── ... +│ ├── vs Clickup │ ├── Clickup Alternative │ └── View all → +│ ├── ... │ ├── ... │ +│ └── View all → │ └── View all → │ +``` + +**Guidelines**: + +- Include up to 8 links per column (top competitors by search volume) +- Add "View all" link to the full index page +- Only create columns for formats you've actually built pages for +- Prioritize competitors with highest search volume + +### Why Footer Links Matter + +1. **Sitewide distribution**: Footer links appear on every marketing page, passing link equity from your entire site to comparison content +2. **Crawl efficiency**: Search engines discover all comparison pages quickly +3. **User discovery**: Visitors evaluating your product can easily find comparisons +4. **Competitive positioning**: Signals to search engines that you're a key player in the space + +### Implementation Notes + +- Update footer when adding new high-priority comparison pages +- Keep footer clean—don't list every comparison, just the top ones +- Match column headers to your URL structure (e.g., "vs" column → `/vs/` URLs) +- Consider mobile: columns may stack, so order by priority diff --git a/packages/mosaic/framework/skills/competitor-alternatives/references/templates.md b/packages/mosaic/framework/skills/competitor-alternatives/references/templates.md new file mode 100644 index 00000000..4a9bc9c3 --- /dev/null +++ b/packages/mosaic/framework/skills/competitor-alternatives/references/templates.md @@ -0,0 +1,221 @@ +# Section Templates for Competitor Pages + +Ready-to-use templates for each section of competitor comparison pages. + +## TL;DR Summary + +Start every page with a quick summary for scanners: + +```markdown +**TL;DR**: [Competitor] excels at [strength] but struggles with [weakness]. +[Your product] is built for [your focus], offering [key differentiator]. +Choose [Competitor] if [their ideal use case]. Choose [You] if [your ideal use case]. +``` + +--- + +## Paragraph Comparison (Not Just Tables) + +For each major dimension, write a paragraph: + +```markdown +## Features + +[Competitor] offers [description of their feature approach]. +Their strength is [specific strength], which works well for [use case]. +However, [limitation] can be challenging for [user type]. + +[Your product] takes a different approach with [your approach]. +This means [benefit], though [honest tradeoff]. +Teams who [specific need] often find this more effective. +``` + +--- + +## Feature Comparison Section + +Go beyond checkmarks: + +```markdown +## Feature Comparison + +### [Feature Category] + +**[Competitor]**: [2-3 sentence description of how they handle this] + +- Strengths: [specific] +- Limitations: [specific] + +**[Your product]**: [2-3 sentence description] + +- Strengths: [specific] +- Limitations: [specific] + +**Bottom line**: Choose [Competitor] if [scenario]. Choose [You] if [scenario]. +``` + +--- + +## Pricing Comparison Section + +```markdown +## Pricing + +| | [Competitor] | [Your Product] | +| -------------- | ------------ | -------------- | +| Free tier | [Details] | [Details] | +| Starting price | $X/user/mo | $X/user/mo | +| Business tier | $X/user/mo | $X/user/mo | +| Enterprise | Custom | Custom | + +**What's included**: [Competitor]'s $X plan includes [features], while +[Your product]'s $X plan includes [features]. + +**Total cost consideration**: Beyond per-seat pricing, consider [hidden costs, +add-ons, implementation]. [Competitor] charges extra for [X], while +[Your product] includes [Y] in base pricing. + +**Value comparison**: For a 10-person team, [Competitor] costs approximately +$X/year while [Your product] costs $Y/year, with [key differences in what you get]. +``` + +--- + +## Service & Support Comparison + +```markdown +## Service & Support + +| | [Competitor] | [Your Product] | +| ---------------- | -------------------- | -------------------- | +| Documentation | [Quality assessment] | [Quality assessment] | +| Response time | [SLA if known] | [Your SLA] | +| Support channels | [List] | [List] | +| Onboarding | [What they offer] | [What you offer] | +| CSM included | [At what tier] | [At what tier] | + +**Support quality**: Based on [G2/Capterra reviews, your research], +[Competitor] support is described as [assessment]. Common feedback includes +[quotes or themes]. + +[Your product] offers [your support approach]. [Specific differentiator like +response time, dedicated CSM, implementation help]. +``` + +--- + +## Who It's For Section + +```markdown +## Who Should Choose [Competitor] + +[Competitor] is the right choice if: + +- [Specific use case or need] +- [Team type or size] +- [Workflow or requirement] +- [Budget or priority] + +**Ideal [Competitor] customer**: [Persona description in 1-2 sentences] + +## Who Should Choose [Your Product] + +[Your product] is built for teams who: + +- [Specific use case or need] +- [Team type or size] +- [Workflow or requirement] +- [Priority or value] + +**Ideal [Your product] customer**: [Persona description in 1-2 sentences] +``` + +--- + +## Migration Section + +```markdown +## Switching from [Competitor] + +### What transfers + +- [Data type]: [How easily, any caveats] +- [Data type]: [How easily, any caveats] + +### What needs reconfiguration + +- [Thing]: [Why and effort level] +- [Thing]: [Why and effort level] + +### Migration support + +We offer [migration support details]: + +- [Free data import tool / white-glove migration] +- [Documentation / migration guide] +- [Timeline expectation] +- [Support during transition] + +### What customers say about switching + +> "[Quote from customer who switched]" +> — [Name], [Role] at [Company] +``` + +--- + +## Social Proof Section + +Focus on switchers: + +```markdown +## What Customers Say + +### Switched from [Competitor] + +> "[Specific quote about why they switched and outcome]" +> — [Name], [Role] at [Company] + +> "[Another quote]" +> — [Name], [Role] at [Company] + +### Results after switching + +- [Company] saw [specific result] +- [Company] reduced [metric] by [amount] +``` + +--- + +## Comparison Table Best Practices + +### Beyond Checkmarks + +Instead of: +| Feature | You | Competitor | +|---------|-----|-----------| +| Feature A | ✓ | ✓ | +| Feature B | ✓ | ✗ | + +Do this: +| Feature | You | Competitor | +|---------|-----|-----------| +| Feature A | Full support with [detail] | Basic support, [limitation] | +| Feature B | [Specific capability] | Not available | + +### Organize by Category + +Group features into meaningful categories: + +- Core functionality +- Collaboration +- Integrations +- Security & compliance +- Support & service + +### Include Ratings Where Useful + +| Category | You | Competitor | Notes | +| ------------- | ---------- | ---------- | ------------ | +| Ease of use | ⭐⭐⭐⭐⭐ | ⭐⭐⭐⭐ | [Brief note] | +| Feature depth | ⭐⭐⭐⭐ | ⭐⭐⭐⭐⭐ | [Brief note] | diff --git a/packages/mosaic/framework/skills/content-strategy/SKILL.md b/packages/mosaic/framework/skills/content-strategy/SKILL.md new file mode 100644 index 00000000..9c652b13 --- /dev/null +++ b/packages/mosaic/framework/skills/content-strategy/SKILL.md @@ -0,0 +1,391 @@ +--- +name: content-strategy +version: 1.0.0 +description: When the user wants to plan a content strategy, decide what content to create, or figure out what topics to cover. Also use when the user mentions "content strategy," "what should I write about," "content ideas," "blog strategy," "topic clusters," or "content planning." For writing individual pieces, see copywriting. For SEO-specific audits, see seo-audit. +--- + +# Content Strategy + +You are a content strategist. Your goal is to help plan content that drives traffic, builds authority, and generates leads by being either searchable, shareable, or both. + +## Before Planning + +**Check for product marketing context first:** +If `.mosaic/product-marketing-context.md` exists, read it before asking questions. Use that context and only ask for information not already covered or specific to this task. + +Gather this context (ask if not provided): + +### 1. Business Context + +- What does the company do? +- Who is the ideal customer? +- What's the primary goal for content? (traffic, leads, brand awareness, thought leadership) +- What problems does your product solve? + +### 2. Customer Research + +- What questions do customers ask before buying? +- What objections come up in sales calls? +- What topics appear repeatedly in support tickets? +- What language do customers use to describe their problems? + +### 3. Current State + +- Do you have existing content? What's working? +- What resources do you have? (writers, budget, time) +- What content formats can you produce? (written, video, audio) + +### 4. Competitive Landscape + +- Who are your main competitors? +- What content gaps exist in your market? + +--- + +## Searchable vs Shareable + +Every piece of content must be searchable, shareable, or both. Prioritize in that order—search traffic is the foundation. + +**Searchable content** captures existing demand. Optimized for people actively looking for answers. + +**Shareable content** creates demand. Spreads ideas and gets people talking. + +### When Writing Searchable Content + +- Target a specific keyword or question +- Match search intent exactly—answer what the searcher wants +- Use clear titles that match search queries +- Structure with headings that mirror search patterns +- Place keywords in title, headings, first paragraph, URL +- Provide comprehensive coverage (don't leave questions unanswered) +- Include data, examples, and links to authoritative sources +- Optimize for AI/LLM discovery: clear positioning, structured content, brand consistency across the web + +### When Writing Shareable Content + +- Lead with a novel insight, original data, or counterintuitive take +- Challenge conventional wisdom with well-reasoned arguments +- Tell stories that make people feel something +- Create content people want to share to look smart or help others +- Connect to current trends or emerging problems +- Share vulnerable, honest experiences others can learn from + +--- + +## Content Types + +### Searchable Content Types + +**Use-Case Content** +Formula: [persona] + [use-case]. Targets long-tail keywords. + +- "Project management for designers" +- "Task tracking for developers" +- "Client collaboration for freelancers" + +**Hub and Spoke** +Hub = comprehensive overview. Spokes = related subtopics. + +``` +/topic (hub) +├── /topic/subtopic-1 (spoke) +├── /topic/subtopic-2 (spoke) +└── /topic/subtopic-3 (spoke) +``` + +Create hub first, then build spokes. Interlink strategically. + +**Note:** Most content works fine under `/blog`. Only use dedicated hub/spoke URL structures for major topics with layered depth (e.g., Atlassian's `/agile` guide). For typical blog posts, `/blog/post-title` is sufficient. + +**Template Libraries** +High-intent keywords + product adoption. + +- Target searches like "marketing plan template" +- Provide immediate standalone value +- Show how product enhances the template + +### Shareable Content Types + +**Thought Leadership** + +- Articulate concepts everyone feels but hasn't named +- Challenge conventional wisdom with evidence +- Share vulnerable, honest experiences + +**Data-Driven Content** + +- Product data analysis (anonymized insights) +- Public data analysis (uncover patterns) +- Original research (run experiments, share results) + +**Expert Roundups** +15-30 experts answering one specific question. Built-in distribution. + +**Case Studies** +Structure: Challenge → Solution → Results → Key learnings + +**Meta Content** +Behind-the-scenes transparency. "How We Got Our First $5k MRR," "Why We Chose Debt Over VC." + +For programmatic content at scale, see **programmatic-seo** skill. + +--- + +## Content Pillars and Topic Clusters + +Content pillars are the 3-5 core topics your brand will own. Each pillar spawns a cluster of related content. + +Most of the time, all content can live under `/blog` with good internal linking between related posts. Dedicated pillar pages with custom URL structures (like `/guides/topic`) are only needed when you're building comprehensive resources with multiple layers of depth. + +### How to Identify Pillars + +1. **Product-led**: What problems does your product solve? +2. **Audience-led**: What does your ICP need to learn? +3. **Search-led**: What topics have volume in your space? +4. **Competitor-led**: What are competitors ranking for? + +### Pillar Structure + +``` +Pillar Topic (Hub) +├── Subtopic Cluster 1 +│ ├── Article A +│ ├── Article B +│ └── Article C +├── Subtopic Cluster 2 +│ ├── Article D +│ ├── Article E +│ └── Article F +└── Subtopic Cluster 3 + ├── Article G + ├── Article H + └── Article I +``` + +### Pillar Criteria + +Good pillars should: + +- Align with your product/service +- Match what your audience cares about +- Have search volume and/or social interest +- Be broad enough for many subtopics + +--- + +## Keyword Research by Buyer Stage + +Map topics to the buyer's journey using proven keyword modifiers: + +### Awareness Stage + +Modifiers: "what is," "how to," "guide to," "introduction to" + +Example: If customers ask about project management basics: + +- "What is Agile Project Management" +- "Guide to Sprint Planning" +- "How to Run a Standup Meeting" + +### Consideration Stage + +Modifiers: "best," "top," "vs," "alternatives," "comparison" + +Example: If customers evaluate multiple tools: + +- "Best Project Management Tools for Remote Teams" +- "Asana vs Trello vs Monday" +- "Basecamp Alternatives" + +### Decision Stage + +Modifiers: "pricing," "reviews," "demo," "trial," "buy" + +Example: If pricing comes up in sales calls: + +- "Project Management Tool Pricing Comparison" +- "How to Choose the Right Plan" +- "[Product] Reviews" + +### Implementation Stage + +Modifiers: "templates," "examples," "tutorial," "how to use," "setup" + +Example: If support tickets show implementation struggles: + +- "Project Template Library" +- "Step-by-Step Setup Tutorial" +- "How to Use [Feature]" + +--- + +## Content Ideation Sources + +### 1. Keyword Data + +If user provides keyword exports (Ahrefs, SEMrush, GSC), analyze for: + +- Topic clusters (group related keywords) +- Buyer stage (awareness/consideration/decision/implementation) +- Search intent (informational, commercial, transactional) +- Quick wins (low competition + decent volume + high relevance) +- Content gaps (keywords competitors rank for that you don't) + +Output as prioritized table: +| Keyword | Volume | Difficulty | Buyer Stage | Content Type | Priority | + +### 2. Call Transcripts + +If user provides sales or customer call transcripts, extract: + +- Questions asked → FAQ content or blog posts +- Pain points → problems in their own words +- Objections → content to address proactively +- Language patterns → exact phrases to use (voice of customer) +- Competitor mentions → what they compared you to + +Output content ideas with supporting quotes. + +### 3. Survey Responses + +If user provides survey data, mine for: + +- Open-ended responses (topics and language) +- Common themes (30%+ mention = high priority) +- Resource requests (what they wish existed) +- Content preferences (formats they want) + +### 4. Forum Research + +Use web search to find content ideas: + +**Reddit:** `site:reddit.com [topic]` + +- Top posts in relevant subreddits +- Questions and frustrations in comments +- Upvoted answers (validates what resonates) + +**Quora:** `site:quora.com [topic]` + +- Most-followed questions +- Highly upvoted answers + +**Other:** Indie Hackers, Hacker News, Product Hunt, industry Slack/Discord + +Extract: FAQs, misconceptions, debates, problems being solved, terminology used. + +### 5. Competitor Analysis + +Use web search to analyze competitor content: + +**Find their content:** `site:competitor.com/blog` + +**Analyze:** + +- Top-performing posts (comments, shares) +- Topics covered repeatedly +- Gaps they haven't covered +- Case studies (customer problems, use cases, results) +- Content structure (pillars, categories, formats) + +**Identify opportunities:** + +- Topics you can cover better +- Angles they're missing +- Outdated content to improve on + +### 6. Sales and Support Input + +Extract from customer-facing teams: + +- Common objections +- Repeated questions +- Support ticket patterns +- Success stories +- Feature requests and underlying problems + +--- + +## Prioritizing Content Ideas + +Score each idea on four factors: + +### 1. Customer Impact (40%) + +- How frequently did this topic come up in research? +- What percentage of customers face this challenge? +- How emotionally charged was this pain point? +- What's the potential LTV of customers with this need? + +### 2. Content-Market Fit (30%) + +- Does this align with problems your product solves? +- Can you offer unique insights from customer research? +- Do you have customer stories to support this? +- Will this naturally lead to product interest? + +### 3. Search Potential (20%) + +- What's the monthly search volume? +- How competitive is this topic? +- Are there related long-tail opportunities? +- Is search interest growing or declining? + +### 4. Resource Requirements (10%) + +- Do you have expertise to create authoritative content? +- What additional research is needed? +- What assets (graphics, data, examples) will you need? + +### Scoring Template + +| Idea | Customer Impact (40%) | Content-Market Fit (30%) | Search Potential (20%) | Resources (10%) | Total | +| ------- | --------------------- | ------------------------ | ---------------------- | --------------- | ----- | +| Topic A | 8 | 9 | 7 | 6 | 8.0 | +| Topic B | 6 | 7 | 9 | 8 | 7.1 | + +--- + +## Output Format + +When creating a content strategy, provide: + +### 1. Content Pillars + +- 3-5 pillars with rationale +- Subtopic clusters for each pillar +- How pillars connect to product + +### 2. Priority Topics + +For each recommended piece: + +- Topic/title +- Searchable, shareable, or both +- Content type (use-case, hub/spoke, thought leadership, etc.) +- Target keyword and buyer stage +- Why this topic (customer research backing) + +### 3. Topic Cluster Map + +Visual or structured representation of how content interconnects. + +--- + +## Task-Specific Questions + +1. What patterns emerge from your last 10 customer conversations? +2. What questions keep coming up in sales calls? +3. Where are competitors' content efforts falling short? +4. What unique insights from customer research aren't being shared elsewhere? +5. Which existing content drives the most conversions, and why? + +--- + +## Related Skills + +- **copywriting**: For writing individual content pieces +- **seo-audit**: For technical SEO and on-page optimization +- **programmatic-seo**: For scaled content generation +- **email-sequence**: For email-based content +- **social-content**: For social media content diff --git a/packages/mosaic/framework/skills/copy-editing/SKILL.md b/packages/mosaic/framework/skills/copy-editing/SKILL.md new file mode 100644 index 00000000..7a005ee9 --- /dev/null +++ b/packages/mosaic/framework/skills/copy-editing/SKILL.md @@ -0,0 +1,490 @@ +--- +name: copy-editing +version: 1.0.0 +description: "When the user wants to edit, review, or improve existing marketing copy. Also use when the user mentions 'edit this copy,' 'review my copy,' 'copy feedback,' 'proofread,' 'polish this,' 'make this better,' or 'copy sweep.' This skill provides a systematic approach to editing marketing copy through multiple focused passes." +--- + +# Copy Editing + +You are an expert copy editor specializing in marketing and conversion copy. Your goal is to systematically improve existing copy through focused editing passes while preserving the core message. + +## Core Philosophy + +**Check for product marketing context first:** +If `.mosaic/product-marketing-context.md` exists, read it before editing. Use brand voice and customer language from that context to guide your edits. + +Good copy editing isn't about rewriting—it's about enhancing. Each pass focuses on one dimension, catching issues that get missed when you try to fix everything at once. + +**Key principles:** + +- Don't change the core message; focus on enhancing it +- Multiple focused passes beat one unfocused review +- Each edit should have a clear reason +- Preserve the author's voice while improving clarity + +--- + +## The Seven Sweeps Framework + +Edit copy through seven sequential passes, each focusing on one dimension. After each sweep, loop back to check previous sweeps aren't compromised. + +### Sweep 1: Clarity + +**Focus:** Can the reader understand what you're saying? + +**What to check:** + +- Confusing sentence structures +- Unclear pronoun references +- Jargon or insider language +- Ambiguous statements +- Missing context + +**Common clarity killers:** + +- Sentences trying to say too much +- Abstract language instead of concrete +- Assuming reader knowledge they don't have +- Burying the point in qualifications + +**Process:** + +1. Read through quickly, highlighting unclear parts +2. Don't correct yet—just note problem areas +3. After marking issues, recommend specific edits +4. Verify edits maintain the original intent + +**After this sweep:** Confirm the "Rule of One" (one main idea per section) and "You Rule" (copy speaks to the reader) are intact. + +--- + +### Sweep 2: Voice and Tone + +**Focus:** Is the copy consistent in how it sounds? + +**What to check:** + +- Shifts between formal and casual +- Inconsistent brand personality +- Mood changes that feel jarring +- Word choices that don't match the brand + +**Common voice issues:** + +- Starting casual, becoming corporate +- Mixing "we" and "the company" references +- Humor in some places, serious in others (unintentionally) +- Technical language appearing randomly + +**Process:** + +1. Read aloud to hear inconsistencies +2. Mark where tone shifts unexpectedly +3. Recommend edits that smooth transitions +4. Ensure personality remains throughout + +**After this sweep:** Return to Clarity Sweep to ensure voice edits didn't introduce confusion. + +--- + +### Sweep 3: So What + +**Focus:** Does every claim answer "why should I care?" + +**What to check:** + +- Features without benefits +- Claims without consequences +- Statements that don't connect to reader's life +- Missing "which means..." bridges + +**The So What test:** +For every statement, ask "Okay, so what?" If the copy doesn't answer that question with a deeper benefit, it needs work. + +❌ "Our platform uses AI-powered analytics" +_So what?_ +✅ "Our AI-powered analytics surface insights you'd miss manually—so you can make better decisions in half the time" + +**Common So What failures:** + +- Feature lists without benefit connections +- Impressive-sounding claims that don't land +- Technical capabilities without outcomes +- Company achievements that don't help the reader + +**Process:** + +1. Read each claim and literally ask "so what?" +2. Highlight claims missing the answer +3. Add the benefit bridge or deeper meaning +4. Ensure benefits connect to real reader desires + +**After this sweep:** Return to Voice and Tone, then Clarity. + +--- + +### Sweep 4: Prove It + +**Focus:** Is every claim supported with evidence? + +**What to check:** + +- Unsubstantiated claims +- Missing social proof +- Assertions without backup +- "Best" or "leading" without evidence + +**Types of proof to look for:** + +- Testimonials with names and specifics +- Case study references +- Statistics and data +- Third-party validation +- Guarantees and risk reversals +- Customer logos +- Review scores + +**Common proof gaps:** + +- "Trusted by thousands" (which thousands?) +- "Industry-leading" (according to whom?) +- "Customers love us" (show them saying it) +- Results claims without specifics + +**Process:** + +1. Identify every claim that needs proof +2. Check if proof exists nearby +3. Flag unsupported assertions +4. Recommend adding proof or softening claims + +**After this sweep:** Return to So What, Voice and Tone, then Clarity. + +--- + +### Sweep 5: Specificity + +**Focus:** Is the copy concrete enough to be compelling? + +**What to check:** + +- Vague language ("improve," "enhance," "optimize") +- Generic statements that could apply to anyone +- Round numbers that feel made up +- Missing details that would make it real + +**Specificity upgrades:** + +| Vague | Specific | +| --------------------- | ------------------------------- | +| Save time | Save 4 hours every week | +| Many customers | 2,847 teams | +| Fast results | Results in 14 days | +| Improve your workflow | Cut your reporting time in half | +| Great support | Response within 2 hours | + +**Common specificity issues:** + +- Adjectives doing the work nouns should do +- Benefits without quantification +- Outcomes without timeframes +- Claims without concrete examples + +**Process:** + +1. Highlight vague words and phrases +2. Ask "Can this be more specific?" +3. Add numbers, timeframes, or examples +4. Remove content that can't be made specific (it's probably filler) + +**After this sweep:** Return to Prove It, So What, Voice and Tone, then Clarity. + +--- + +### Sweep 6: Heightened Emotion + +**Focus:** Does the copy make the reader feel something? + +**What to check:** + +- Flat, informational language +- Missing emotional triggers +- Pain points mentioned but not felt +- Aspirations stated but not evoked + +**Emotional dimensions to consider:** + +- Pain of the current state +- Frustration with alternatives +- Fear of missing out +- Desire for transformation +- Pride in making smart choices +- Relief from solving the problem + +**Techniques for heightening emotion:** + +- Paint the "before" state vividly +- Use sensory language +- Tell micro-stories +- Reference shared experiences +- Ask questions that prompt reflection + +**Process:** + +1. Read for emotional impact—does it move you? +2. Identify flat sections that should resonate +3. Add emotional texture while staying authentic +4. Ensure emotion serves the message (not manipulation) + +**After this sweep:** Return to Specificity, Prove It, So What, Voice and Tone, then Clarity. + +--- + +### Sweep 7: Zero Risk + +**Focus:** Have we removed every barrier to action? + +**What to check:** + +- Friction near CTAs +- Unanswered objections +- Missing trust signals +- Unclear next steps +- Hidden costs or surprises + +**Risk reducers to look for:** + +- Money-back guarantees +- Free trials +- "No credit card required" +- "Cancel anytime" +- Social proof near CTA +- Clear expectations of what happens next +- Privacy assurances + +**Common risk issues:** + +- CTA asks for commitment without earning trust +- Objections raised but not addressed +- Fine print that creates doubt +- Vague "Contact us" instead of clear next step + +**Process:** + +1. Focus on sections near CTAs +2. List every reason someone might hesitate +3. Check if the copy addresses each concern +4. Add risk reversals or trust signals as needed + +**After this sweep:** Return through all previous sweeps one final time: Heightened Emotion, Specificity, Prove It, So What, Voice and Tone, Clarity. + +--- + +## Quick-Pass Editing Checks + +Use these for faster reviews when a full seven-sweep process isn't needed. + +### Word-Level Checks + +**Cut these words:** + +- Very, really, extremely, incredibly (weak intensifiers) +- Just, actually, basically (filler) +- In order to (use "to") +- That (often unnecessary) +- Things, stuff (vague) + +**Replace these:** + +| Weak | Strong | +| ------------ | ---------- | +| Utilize | Use | +| Implement | Set up | +| Leverage | Use | +| Facilitate | Help | +| Innovative | New | +| Robust | Strong | +| Seamless | Smooth | +| Cutting-edge | New/Modern | + +**Watch for:** + +- Adverbs (usually unnecessary) +- Passive voice (switch to active) +- Nominalizations (verb → noun: "make a decision" → "decide") + +### Sentence-Level Checks + +- One idea per sentence +- Vary sentence length (mix short and long) +- Front-load important information +- Max 3 conjunctions per sentence +- No more than 25 words (usually) + +### Paragraph-Level Checks + +- One topic per paragraph +- Short paragraphs (2-4 sentences for web) +- Strong opening sentences +- Logical flow between paragraphs +- White space for scannability + +--- + +## Copy Editing Checklist + +### Before You Start + +- [ ] Understand the goal of this copy +- [ ] Know the target audience +- [ ] Identify the desired action +- [ ] Read through once without editing + +### Clarity (Sweep 1) + +- [ ] Every sentence is immediately understandable +- [ ] No jargon without explanation +- [ ] Pronouns have clear references +- [ ] No sentences trying to do too much + +### Voice & Tone (Sweep 2) + +- [ ] Consistent formality level throughout +- [ ] Brand personality maintained +- [ ] No jarring shifts in mood +- [ ] Reads well aloud + +### So What (Sweep 3) + +- [ ] Every feature connects to a benefit +- [ ] Claims answer "why should I care?" +- [ ] Benefits connect to real desires +- [ ] No impressive-but-empty statements + +### Prove It (Sweep 4) + +- [ ] Claims are substantiated +- [ ] Social proof is specific and attributed +- [ ] Numbers and stats have sources +- [ ] No unearned superlatives + +### Specificity (Sweep 5) + +- [ ] Vague words replaced with concrete ones +- [ ] Numbers and timeframes included +- [ ] Generic statements made specific +- [ ] Filler content removed + +### Heightened Emotion (Sweep 6) + +- [ ] Copy evokes feeling, not just information +- [ ] Pain points feel real +- [ ] Aspirations feel achievable +- [ ] Emotion serves the message authentically + +### Zero Risk (Sweep 7) + +- [ ] Objections addressed near CTA +- [ ] Trust signals present +- [ ] Next steps are crystal clear +- [ ] Risk reversals stated (guarantee, trial, etc.) + +### Final Checks + +- [ ] No typos or grammatical errors +- [ ] Consistent formatting +- [ ] Links work (if applicable) +- [ ] Core message preserved through all edits + +--- + +## Common Copy Problems & Fixes + +### Problem: Wall of Features + +**Symptom:** List of what the product does without why it matters +**Fix:** Add "which means..." after each feature to bridge to benefits + +### Problem: Corporate Speak + +**Symptom:** "Leverage synergies to optimize outcomes" +**Fix:** Ask "How would a human say this?" and use those words + +### Problem: Weak Opening + +**Symptom:** Starting with company history or vague statements +**Fix:** Lead with the reader's problem or desired outcome + +### Problem: Buried CTA + +**Symptom:** The ask comes after too much buildup, or isn't clear +**Fix:** Make the CTA obvious, early, and repeated + +### Problem: No Proof + +**Symptom:** "Customers love us" with no evidence +**Fix:** Add specific testimonials, numbers, or case references + +### Problem: Generic Claims + +**Symptom:** "We help businesses grow" +**Fix:** Specify who, how, and by how much + +### Problem: Mixed Audiences + +**Symptom:** Copy tries to speak to everyone, resonates with no one +**Fix:** Pick one audience and write directly to them + +### Problem: Feature Overload + +**Symptom:** Listing every capability, overwhelming the reader +**Fix:** Focus on 3-5 key benefits that matter most to the audience + +--- + +## Working with Copy Sweeps + +When editing collaboratively: + +1. **Run a sweep and present findings** - Show what you found, why it's an issue +2. **Recommend specific edits** - Don't just identify problems; propose solutions +3. **Request the updated copy** - Let the author make final decisions +4. **Verify previous sweeps** - After each round of edits, re-check earlier sweeps +5. **Repeat until clean** - Continue until a full sweep finds no new issues + +This iterative process ensures each edit doesn't create new problems while respecting the author's ownership of the copy. + +--- + +## References + +- [Plain English Alternatives](references/plain-english-alternatives.md): Replace complex words with simpler alternatives + +--- + +## Task-Specific Questions + +1. What's the goal of this copy? (Awareness, conversion, retention) +2. What action should readers take? +3. Are there specific concerns or known issues? +4. What proof/evidence do you have available? + +--- + +## Related Skills + +- **copywriting**: For writing new copy from scratch (use this skill to edit after your first draft is complete) +- **page-cro**: For broader page optimization beyond copy +- **marketing-psychology**: For understanding why certain edits improve conversion +- **ab-test-setup**: For testing copy variations + +--- + +## When to Use Each Skill + +| Task | Skill to Use | +| ------------------------------------- | ------------------------- | +| Writing new page copy from scratch | copywriting | +| Reviewing and improving existing copy | copy-editing (this skill) | +| Editing copy you just wrote | copy-editing (this skill) | +| Structural or strategic page changes | page-cro | diff --git a/packages/mosaic/framework/skills/copy-editing/references/plain-english-alternatives.md b/packages/mosaic/framework/skills/copy-editing/references/plain-english-alternatives.md new file mode 100644 index 00000000..dbd22b44 --- /dev/null +++ b/packages/mosaic/framework/skills/copy-editing/references/plain-english-alternatives.md @@ -0,0 +1,376 @@ +# Plain English Alternatives + +Replace complex or pompous words with plain English alternatives. + +Source: Plain English Campaign A-Z of Alternative Words (2001), Australian Government Style Manual (2024), plainlanguage.gov + +--- + +## A + +| Complex | Plain Alternative | +| ------------------- | ----------------------- | +| (an) absence of | no, none | +| abundance | enough, plenty, many | +| accede to | allow, agree to | +| accelerate | speed up | +| accommodate | meet, hold, house | +| accomplish | do, finish, complete | +| accordingly | so, therefore | +| acknowledge | thank you for, confirm | +| acquire | get, buy, obtain | +| additional | extra, more | +| adjacent | next to | +| advantageous | useful, helpful | +| advise | tell, say, inform | +| aforesaid | this, earlier | +| aggregate | total | +| alleviate | ease, reduce | +| allocate | give, share, assign | +| alternative | other, choice | +| ameliorate | improve | +| anticipate | expect | +| apparent | clear, obvious | +| appreciable | large, noticeable | +| appropriate | proper, right, suitable | +| approximately | about, roughly | +| ascertain | find out | +| assistance | help | +| at the present time | now | +| attempt | try | +| authorise | allow, let | + +--- + +## B + +| Complex | Plain Alternative | +| ----------- | ----------------- | +| belated | late | +| beneficial | helpful, useful | +| bestow | give | +| by means of | by | + +--- + +## C + +| Complex | Plain Alternative | +| ------------------ | ------------------ | +| calculate | work out | +| cease | stop, end | +| circumvent | avoid, get around | +| clarification | explanation | +| commence | start, begin | +| communicate | tell, talk, write | +| competent | able | +| compile | collect, make | +| complete | fill in, finish | +| component | part | +| comprise | include, make up | +| (it is) compulsory | (you) must | +| conceal | hide | +| concerning | about | +| consequently | so | +| considerable | large, great, much | +| constitute | make up, form | +| consult | ask, talk to | +| consumption | use | +| currently | now | + +--- + +## D + +| Complex | Plain Alternative | +| -------------------- | ------------------ | +| deduct | take off | +| deem | treat as, consider | +| defer | delay, put off | +| deficiency | lack | +| delete | remove, cross out | +| demonstrate | show, prove | +| denote | show, mean | +| designate | name, appoint | +| despatch/dispatch | send | +| determine | decide, find out | +| detrimental | harmful | +| diminish | reduce, lessen | +| discontinue | stop | +| disseminate | spread, distribute | +| documentation | papers, documents | +| due to the fact that | because | +| duration | time, length | +| dwelling | home | + +--- + +## E + +| Complex | Plain Alternative | +| ----------- | ------------------ | +| economical | cheap, good value | +| eligible | allowed, qualified | +| elucidate | explain | +| enable | allow | +| encounter | meet | +| endeavour | try | +| enquire | ask | +| ensure | make sure | +| entitlement | right | +| envisage | expect | +| equivalent | equal, the same | +| erroneous | wrong | +| establish | set up, show | +| evaluate | assess, test | +| excessive | too much | +| exclusively | only | +| exempt | free from | +| expedite | speed up | +| expenditure | spending | +| expire | run out | + +--- + +## F + +| Complex | Plain Alternative | +| ------------------- | ------------------- | +| fabricate | make | +| facilitate | help, make possible | +| finalise | finish, complete | +| following | after | +| for the purpose of | to, for | +| for the reason that | because | +| forthwith | now, at once | +| forward | send | +| frequently | often | +| furnish | give, provide | +| furthermore | also, and | + +--- + +## G-H + +| Complex | Plain Alternative | +| ---------- | ----------------- | +| generate | produce, create | +| henceforth | from now on | +| hitherto | until now | + +--- + +## I + +| Complex | Plain Alternative | +| ---------------------------- | ----------------- | +| if and when | if, when | +| illustrate | show | +| immediately | at once, now | +| implement | carry out, do | +| imply | suggest | +| in accordance with | under, following | +| in addition to | and, also | +| in conjunction with | with | +| in excess of | more than | +| in lieu of | instead of | +| in order to | to | +| in receipt of | receive | +| in relation to | about | +| in respect of | about, for | +| in the event of | if | +| in the majority of instances | most, usually | +| in the near future | soon | +| in view of the fact that | because | +| inception | start | +| indicate | show, suggest | +| inform | tell | +| initiate | start, begin | +| insert | put in | +| instances | cases | +| irrespective of | despite | +| issue | give, send | + +--- + +## L-M + +| Complex | Plain Alternative | +| ------------------- | ------------------ | +| (a) large number of | many | +| liaise with | work with, talk to | +| locality | place, area | +| locate | find | +| magnitude | size | +| (it is) mandatory | (you) must | +| manner | way | +| modification | change | +| moreover | also, and | + +--- + +## N-O + +| Complex | Plain Alternative | +| --------------------- | ----------------- | +| negligible | small | +| nevertheless | but, however | +| notify | tell | +| notwithstanding | despite, even if | +| numerous | many | +| objective | aim, goal | +| (it is) obligatory | (you) must | +| obtain | get | +| occasioned by | caused by | +| on behalf of | for | +| on numerous occasions | often | +| on receipt of | when you get | +| on the grounds that | because | +| operate | work, run | +| optimum | best | +| option | choice | +| otherwise | or | +| outstanding | unpaid | +| owing to | because | + +--- + +## P + +| Complex | Plain Alternative | +| ------------- | ----------------- | +| partially | partly | +| participate | take part | +| particulars | details | +| per annum | a year | +| perform | do | +| permit | let, allow | +| personnel | staff, people | +| peruse | read | +| possess | have, own | +| practically | almost | +| predominant | main | +| prescribe | set | +| preserve | keep | +| previous | earlier, before | +| principal | main | +| prior to | before | +| proceed | go ahead | +| procure | get | +| prohibit | ban, stop | +| promptly | quickly | +| provide | give | +| provided that | if | +| provisions | rules, terms | +| proximity | nearness | +| purchase | buy | +| pursuant to | under | + +--- + +## R + +| Complex | Plain Alternative | +| -------------- | ----------------- | +| reconsider | think again | +| reduction | cut | +| referred to as | called | +| regarding | about | +| reimburse | repay | +| reiterate | repeat | +| relating to | about | +| remain | stay | +| remainder | rest | +| remuneration | pay | +| render | make, give | +| represent | stand for | +| request | ask | +| require | need | +| residence | home | +| retain | keep | +| revised | changed, new | + +--- + +## S + +| Complex | Plain Alternative | +| ------------- | ----------------- | +| scrutinise | examine, check | +| select | choose | +| solely | only | +| specified | given, stated | +| state | say | +| statutory | legal, by law | +| subject to | depending on | +| submit | send, give | +| subsequent to | after | +| subsequently | later | +| substantial | large, much | +| sufficient | enough | +| supplement | add to | +| supplementary | extra | + +--- + +## T-U + +| Complex | Plain Alternative | +| ---------- | ----------------- | +| terminate | end, stop | +| thereafter | then | +| thereby | by this | +| thus | so | +| to date | so far | +| transfer | move | +| transmit | send | +| ultimately | in the end | +| undertake | agree, do | +| uniform | same | +| utilise | use | + +--- + +## V-Z + +| Complex | Plain Alternative | +| ----------------- | ----------------- | +| variation | change | +| virtually | almost | +| visualise | imagine, see | +| ways and means | ways | +| whatsoever | any | +| with a view to | to | +| with effect from | from | +| with reference to | about | +| with regard to | about | +| with respect to | about | +| zone | area | + +--- + +## Phrases to Remove Entirely + +These phrases often add nothing. Delete them: + +- a total of +- absolutely +- actually +- all things being equal +- as a matter of fact +- at the end of the day +- at this moment in time +- basically +- currently (when "now" or nothing works) +- I am of the opinion that (use: I think) +- in due course (use: soon, or say when) +- in the final analysis +- it should be understood +- last but not least +- obviously +- of course +- quite +- really +- the fact of the matter is +- to all intents and purposes +- very diff --git a/packages/mosaic/framework/skills/copywriting/SKILL.md b/packages/mosaic/framework/skills/copywriting/SKILL.md new file mode 100644 index 00000000..9f777861 --- /dev/null +++ b/packages/mosaic/framework/skills/copywriting/SKILL.md @@ -0,0 +1,287 @@ +--- +name: copywriting +version: 1.0.0 +description: When the user wants to write, rewrite, or improve marketing copy for any page — including homepage, landing pages, pricing pages, feature pages, about pages, or product pages. Also use when the user says "write copy for," "improve this copy," "rewrite this page," "marketing copy," "headline help," or "CTA copy." For email copy, see email-sequence. For popup copy, see popup-cro. +--- + +# Copywriting + +You are an expert conversion copywriter. Your goal is to write marketing copy that is clear, compelling, and drives action. + +## Before Writing + +**Check for product marketing context first:** +If `.mosaic/product-marketing-context.md` exists, read it before asking questions. Use that context and only ask for information not already covered or specific to this task. + +Gather this context (ask if not provided): + +### 1. Page Purpose + +- What type of page? (homepage, landing page, pricing, feature, about) +- What is the ONE primary action you want visitors to take? + +### 2. Audience + +- Who is the ideal customer? +- What problem are they trying to solve? +- What objections or hesitations do they have? +- What language do they use to describe their problem? + +### 3. Product/Offer + +- What are you selling or offering? +- What makes it different from alternatives? +- What's the key transformation or outcome? +- Any proof points (numbers, testimonials, case studies)? + +### 4. Context + +- Where is traffic coming from? (ads, organic, email) +- What do visitors already know before arriving? + +--- + +## Copywriting Principles + +### Clarity Over Cleverness + +If you have to choose between clear and creative, choose clear. + +### Benefits Over Features + +Features: What it does. Benefits: What that means for the customer. + +### Specificity Over Vagueness + +- Vague: "Save time on your workflow" +- Specific: "Cut your weekly reporting from 4 hours to 15 minutes" + +### Customer Language Over Company Language + +Use words your customers use. Mirror voice-of-customer from reviews, interviews, support tickets. + +### One Idea Per Section + +Each section should advance one argument. Build a logical flow down the page. + +--- + +## Writing Style Rules + +### Core Principles + +1. **Simple over complex** — "Use" not "utilize," "help" not "facilitate" +2. **Specific over vague** — Avoid "streamline," "optimize," "innovative" +3. **Active over passive** — "We generate reports" not "Reports are generated" +4. **Confident over qualified** — Remove "almost," "very," "really" +5. **Show over tell** — Describe the outcome instead of using adverbs +6. **Honest over sensational** — Never fabricate statistics or testimonials + +### Quick Quality Check + +- Jargon that could confuse outsiders? +- Sentences trying to do too much? +- Passive voice constructions? +- Exclamation points? (remove them) +- Marketing buzzwords without substance? + +For thorough line-by-line review, use the **copy-editing** skill after your draft. + +--- + +## Best Practices + +### Be Direct + +Get to the point. Don't bury the value in qualifications. + +❌ Slack lets you share files instantly, from documents to images, directly in your conversations + +✅ Need to share a screenshot? Send as many documents, images, and audio files as your heart desires. + +### Use Rhetorical Questions + +Questions engage readers and make them think about their own situation. + +- "Hate returning stuff to Amazon?" +- "Tired of chasing approvals?" + +### Use Analogies When Helpful + +Analogies make abstract concepts concrete and memorable. + +### Pepper in Humor (When Appropriate) + +Puns and wit make copy memorable—but only if it fits the brand and doesn't undermine clarity. + +--- + +## Page Structure Framework + +### Above the Fold + +**Headline** + +- Your single most important message +- Communicate core value proposition +- Specific > generic + +**Example formulas:** + +- "{Achieve outcome} without {pain point}" +- "The {category} for {audience}" +- "Never {unpleasant event} again" +- "{Question highlighting main pain point}" + +**For comprehensive headline formulas**: See [references/copy-frameworks.md](references/copy-frameworks.md) + +**For natural transition phrases**: See [references/natural-transitions.md](references/natural-transitions.md) + +**Subheadline** + +- Expands on headline +- Adds specificity +- 1-2 sentences max + +**Primary CTA** + +- Action-oriented button text +- Communicate what they get: "Start Free Trial" > "Sign Up" + +### Core Sections + +| Section | Purpose | +| ------------------ | ---------------------------------------------- | +| Social Proof | Build credibility (logos, stats, testimonials) | +| Problem/Pain | Show you understand their situation | +| Solution/Benefits | Connect to outcomes (3-5 key benefits) | +| How It Works | Reduce perceived complexity (3-4 steps) | +| Objection Handling | FAQ, comparisons, guarantees | +| Final CTA | Recap value, repeat CTA, risk reversal | + +**For detailed section types and page templates**: See [references/copy-frameworks.md](references/copy-frameworks.md) + +--- + +## CTA Copy Guidelines + +**Weak CTAs (avoid):** + +- Submit, Sign Up, Learn More, Click Here, Get Started + +**Strong CTAs (use):** + +- Start Free Trial +- Get [Specific Thing] +- See [Product] in Action +- Create Your First [Thing] +- Download the Guide + +**Formula:** [Action Verb] + [What They Get] + [Qualifier if needed] + +Examples: + +- "Start My Free Trial" +- "Get the Complete Checklist" +- "See Pricing for My Team" + +--- + +## Page-Specific Guidance + +### Homepage + +- Serve multiple audiences without being generic +- Lead with broadest value proposition +- Provide clear paths for different visitor intents + +### Landing Page + +- Single message, single CTA +- Match headline to ad/traffic source +- Complete argument on one page + +### Pricing Page + +- Help visitors choose the right plan +- Address "which is right for me?" anxiety +- Make recommended plan obvious + +### Feature Page + +- Connect feature → benefit → outcome +- Show use cases and examples +- Clear path to try or buy + +### About Page + +- Tell the story of why you exist +- Connect mission to customer benefit +- Still include a CTA + +--- + +## Voice and Tone + +Before writing, establish: + +**Formality level:** + +- Casual/conversational +- Professional but friendly +- Formal/enterprise + +**Brand personality:** + +- Playful or serious? +- Bold or understated? +- Technical or accessible? + +Maintain consistency, but adjust intensity: + +- Headlines can be bolder +- Body copy should be clearer +- CTAs should be action-oriented + +--- + +## Output Format + +When writing copy, provide: + +### Page Copy + +Organized by section: + +- Headline, Subheadline, CTA +- Section headers and body copy +- Secondary CTAs + +### Annotations + +For key elements, explain: + +- Why you made this choice +- What principle it applies + +### Alternatives + +For headlines and CTAs, provide 2-3 options: + +- Option A: [copy] — [rationale] +- Option B: [copy] — [rationale] + +### Meta Content (if relevant) + +- Page title (for SEO) +- Meta description + +--- + +## Related Skills + +- **copy-editing**: For polishing existing copy (use after your draft) +- **page-cro**: If page structure/strategy needs work, not just copy +- **email-sequence**: For email copywriting +- **popup-cro**: For popup and modal copy +- **ab-test-setup**: To test copy variations diff --git a/packages/mosaic/framework/skills/copywriting/references/copy-frameworks.md b/packages/mosaic/framework/skills/copywriting/references/copy-frameworks.md new file mode 100644 index 00000000..0c6f0978 --- /dev/null +++ b/packages/mosaic/framework/skills/copywriting/references/copy-frameworks.md @@ -0,0 +1,385 @@ +# Copy Frameworks Reference + +Headline formulas, page section types, and structural templates. + +## Headline Formulas + +### Outcome-Focused + +**{Achieve desirable outcome} without {pain point}** + +> Understand how users are really experiencing your site without drowning in numbers + +**{Achieve desirable outcome} by {how product makes it possible}** + +> Generate more leads by seeing which companies visit your site + +**Turn {input} into {outcome}** + +> Turn your hard-earned sales into repeat customers + +**[Achieve outcome] in [timeframe]** + +> Get your tax refund in 10 days + +--- + +### Problem-Focused + +**Never {unpleasant event} again** + +> Never miss a sales opportunity again + +**{Question highlighting the main pain point}** + +> Hate returning stuff to Amazon? + +**Stop [pain]. Start [pleasure].** + +> Stop chasing invoices. Start getting paid on time. + +--- + +### Audience-Focused + +**{Key feature/product type} for {target audience}** + +> Advanced analytics for Shopify e-commerce + +**{Key feature/product type} for {target audience} to {what it's used for}** + +> An online whiteboard for teams to ideate and brainstorm together + +**You don't have to {skills or resources} to {achieve desirable outcome}** + +> With Ahrefs, you don't have to be an SEO pro to rank higher and get more traffic + +--- + +### Differentiation-Focused + +**The {opposite of usual process} way to {achieve desirable outcome}** + +> The easiest way to turn your passion into income + +**The [category] that [key differentiator]** + +> The CRM that updates itself + +--- + +### Proof-Focused + +**[Number] [people] use [product] to [outcome]** + +> 50,000 marketers use Drip to send better emails + +**{Key benefit of your product}** + +> Sound clear in online meetings + +--- + +### Additional Formulas + +**The simple way to {outcome}** + +> The simple way to track your time + +**Finally, {category} that {benefit}** + +> Finally, accounting software that doesn't suck + +**{Outcome} without {common pain}** + +> Build your website without writing code + +**Get {benefit} from your {thing}** + +> Get more revenue from your existing traffic + +**{Action verb} your {thing} like {admirable example}** + +> Market your SaaS like a Fortune 500 + +**What if you could {desirable outcome}?** + +> What if you could close deals 30% faster? + +**Everything you need to {outcome}** + +> Everything you need to launch your course + +**The {adjective} {category} built for {audience}** + +> The lightweight CRM built for startups + +--- + +## Landing Page Section Types + +### Core Sections + +**Hero (Above the Fold)** + +- Headline + subheadline +- Primary CTA +- Supporting visual (product screenshot, hero image) +- Optional: Social proof bar + +**Social Proof Bar** + +- Customer logos (recognizable > many) +- Key metric ("10,000+ teams") +- Star rating with review count +- Short testimonial snippet + +**Problem/Pain Section** + +- Articulate their problem better than they can +- Create recognition ("that's exactly my situation") +- Hint at cost of not solving it + +**Solution/Benefits Section** + +- Bridge from problem to your solution +- 3-5 key benefits (not 10) +- Each: headline + explanation + proof if available + +**How It Works** + +- 3-4 numbered steps +- Reduces perceived complexity +- Each step: action + outcome + +**Final CTA Section** + +- Recap value proposition +- Repeat primary CTA +- Risk reversal (guarantee, free trial) + +--- + +### Supporting Sections + +**Testimonials** + +- Full quotes with names, roles, companies +- Photos when possible +- Specific results over vague praise +- Formats: quote cards, video, tweet embeds + +**Case Studies** + +- Problem → Solution → Results +- Specific metrics and outcomes +- Customer name and context +- Can be snippets with "Read more" links + +**Use Cases** + +- Different ways product is used +- Helps visitors self-identify +- "For marketers who need X" format + +**Personas / "Built For" Sections** + +- Explicitly call out target audience +- "Perfect for [role]" blocks +- Addresses "Is this for me?" question + +**FAQ Section** + +- Address common objections +- Good for SEO +- Reduces support burden +- 5-10 most common questions + +**Comparison Section** + +- vs. competitors (name them or don't) +- vs. status quo (spreadsheets, manual processes) +- Tables or side-by-side format + +**Integrations / Partners** + +- Logos of tools you connect with +- "Works with your stack" messaging +- Builds credibility + +**Founder Story / Manifesto** + +- Why you built this +- What you believe +- Emotional connection +- Differentiates from faceless competitors + +**Demo / Product Tour** + +- Interactive demos +- Video walkthroughs +- GIF previews +- Shows product in action + +**Pricing Preview** + +- Teaser even on non-pricing pages +- Starting price or "from $X/mo" +- Moves decision-makers forward + +**Guarantee / Risk Reversal** + +- Money-back guarantee +- Free trial terms +- "Cancel anytime" +- Reduces friction + +**Stats Section** + +- Key metrics that build credibility +- "10,000+ customers" +- "4.9/5 rating" +- "$2M saved for customers" + +--- + +## Page Structure Templates + +### Feature-Heavy Page (Weak) + +``` +1. Hero +2. Feature 1 +3. Feature 2 +4. Feature 3 +5. Feature 4 +6. CTA +``` + +This is a list, not a persuasive narrative. + +--- + +### Varied, Engaging Page (Strong) + +``` +1. Hero with clear value prop +2. Social proof bar (logos or stats) +3. Problem/pain section +4. How it works (3 steps) +5. Key benefits (2-3, not 10) +6. Testimonial +7. Use cases or personas +8. Comparison to alternatives +9. Case study snippet +10. FAQ +11. Final CTA with guarantee +``` + +This tells a story and addresses objections. + +--- + +### Compact Landing Page + +``` +1. Hero (headline, subhead, CTA, image) +2. Social proof bar +3. 3 key benefits with icons +4. Testimonial +5. How it works (3 steps) +6. Final CTA with guarantee +``` + +Good for ad landing pages where brevity matters. + +--- + +### Enterprise/B2B Landing Page + +``` +1. Hero (outcome-focused headline) +2. Logo bar (recognizable companies) +3. Problem section (business pain) +4. Solution overview +5. Use cases by role/department +6. Security/compliance section +7. Integration logos +8. Case study with metrics +9. ROI/value section +10. Contact/demo CTA +``` + +Addresses enterprise buyer concerns. + +--- + +### Product Launch Page + +``` +1. Hero with launch announcement +2. Video demo or walkthrough +3. Feature highlights (3-5) +4. Before/after comparison +5. Early testimonials +6. Launch pricing or early access offer +7. CTA with urgency +``` + +Good for ProductHunt, launches, or announcements. + +--- + +## Section Writing Tips + +### Problem Section + +Start with phrases like: + +- "You know the feeling..." +- "If you're like most [role]..." +- "Every day, [audience] struggles with..." +- "We've all been there..." + +Then describe: + +- The specific frustration +- The time/money wasted +- The impact on their work/life + +### Benefits Section + +For each benefit, include: + +- **Headline**: The outcome they get +- **Body**: How it works (1-2 sentences) +- **Proof**: Number, testimonial, or example (optional) + +### How It Works Section + +Each step should be: + +- **Numbered**: Creates sense of progress +- **Simple verb**: "Connect," "Set up," "Get" +- **Outcome-oriented**: What they get from this step + +Example: + +1. Connect your tools (takes 2 minutes) +2. Set your preferences +3. Get automated reports every Monday + +### Testimonial Selection + +Best testimonials include: + +- Specific results ("increased conversions by 32%") +- Before/after context ("We used to spend hours...") +- Role + company for credibility +- Something quotable and specific + +Avoid testimonials that just say: + +- "Great product!" +- "Love it!" +- "Easy to use!" diff --git a/packages/mosaic/framework/skills/copywriting/references/natural-transitions.md b/packages/mosaic/framework/skills/copywriting/references/natural-transitions.md new file mode 100644 index 00000000..9b5cbd99 --- /dev/null +++ b/packages/mosaic/framework/skills/copywriting/references/natural-transitions.md @@ -0,0 +1,255 @@ +# Natural Transitions + +Transitional phrases to guide readers through your content. Good signposting improves readability, user engagement, and helps search engines understand content structure. + +Adapted from: University of Manchester Academic Phrasebank (2023), Plain English Campaign, web content best practices + +--- + +## Previewing Content Structure + +Use to orient readers and set expectations: + +- Here's what we'll cover... +- This guide walks you through... +- Below, you'll find... +- We'll start with X, then move to Y... +- First, let's look at... +- Let's break this down step by step. +- The sections below explain... + +--- + +## Introducing a New Topic + +- When it comes to X,... +- Regarding X,... +- Speaking of X,... +- Now let's talk about X. +- Another key factor is... +- X is worth exploring because... + +--- + +## Referring Back + +Use to connect ideas and reinforce key points: + +- As mentioned earlier,... +- As we covered above,... +- Remember when we discussed X? +- Building on that point,... +- Going back to X,... +- Earlier, we explained that... + +--- + +## Moving Between Sections + +- Now let's look at... +- Next up:... +- Moving on to... +- With that covered, let's turn to... +- Now that you understand X, here's Y. +- That brings us to... + +--- + +## Indicating Addition + +- Also,... +- Plus,... +- On top of that,... +- What's more,... +- Another benefit is... +- Beyond that,... +- In addition,... +- There's also... + +**Note:** Use "moreover" and "furthermore" sparingly. They can sound AI-generated when overused. + +--- + +## Indicating Contrast + +- However,... +- But,... +- That said,... +- On the flip side,... +- In contrast,... +- Unlike X, Y... +- While X is true, Y... +- Despite this,... + +--- + +## Indicating Similarity + +- Similarly,... +- Likewise,... +- In the same way,... +- Just like X, Y also... +- This mirrors... +- The same applies to... + +--- + +## Indicating Cause and Effect + +- So,... +- This means... +- As a result,... +- That's why... +- Because of this,... +- This leads to... +- The outcome?... +- Here's what happens:... + +--- + +## Giving Examples + +- For example,... +- For instance,... +- Here's an example:... +- Take X, for instance. +- Consider this:... +- A good example is... +- To illustrate,... +- Like when... +- Say you want to... + +--- + +## Emphasising Key Points + +- Here's the key takeaway:... +- The important thing is... +- What matters most is... +- Don't miss this:... +- Pay attention to... +- This is critical:... +- The bottom line?... + +--- + +## Providing Evidence + +Use when citing sources, data, or expert opinions: + +### Neutral attribution + +- According to [Source],... +- [Source] reports that... +- Research shows that... +- Data from [Source] indicates... +- A study by [Source] found... + +### Expert quotes + +- As [Expert] puts it,... +- [Expert] explains,... +- In the words of [Expert],... +- [Expert] notes that... + +### Supporting claims + +- This is backed by... +- Evidence suggests... +- The numbers confirm... +- This aligns with findings from... + +--- + +## Summarising Sections + +- To recap,... +- Here's the short version:... +- In short,... +- The takeaway?... +- So what does this mean?... +- Let's pull this together:... +- Quick summary:... + +--- + +## Concluding Content + +- Wrapping up,... +- The bottom line is... +- Here's what to do next:... +- To sum up,... +- Final thoughts:... +- Ready to get started?... +- Now it's your turn. + +**Note:** Avoid "In conclusion" at the start of a paragraph. It's overused and signals AI writing. + +--- + +## Question-Based Transitions + +Useful for conversational tone and featured snippet optimization: + +- So what does this mean for you? +- But why does this matter? +- How do you actually do this? +- What's the catch? +- Sound complicated? It's not. +- Wondering where to start? +- Still not sure? Here's the breakdown. + +--- + +## List Introductions + +For numbered lists and step-by-step content: + +- Here's how to do it: +- Follow these steps: +- The process is straightforward: +- Here's what you need to know: +- Key things to consider: +- The main factors are: + +--- + +## Hedging Language + +For claims that need qualification or aren't absolute: + +- may, might, could +- tends to, generally +- often, usually, typically +- in most cases +- it appears that +- evidence suggests +- this can help +- many experts believe + +--- + +## Best Practice Guidelines + +1. **Match tone to audience**: B2B content can be slightly more formal; B2C often benefits from conversational transitions +2. **Vary your transitions**: Repeating the same phrase gets noticed (and not in a good way) +3. **Don't over-signpost**: Trust your reader; every sentence doesn't need a transition +4. **Use for scannability**: Transitions at paragraph starts help skimmers navigate +5. **Keep it natural**: Read aloud; if it sounds forced, simplify +6. **Front-load key info**: Put the important word or phrase early in the transition + +--- + +## Transitions to Avoid (AI Tells) + +These phrases are overused in AI-generated content: + +- "That being said,..." +- "It's worth noting that..." +- "At its core,..." +- "In today's digital landscape,..." +- "When it comes to the realm of..." +- "This begs the question..." +- "Let's delve into..." + +See the seo-audit skill's `references/ai-writing-detection.md` for a complete list of AI writing tells. diff --git a/packages/mosaic/framework/skills/create-agent/SKILL.md b/packages/mosaic/framework/skills/create-agent/SKILL.md new file mode 100644 index 00000000..cca5bcf4 --- /dev/null +++ b/packages/mosaic/framework/skills/create-agent/SKILL.md @@ -0,0 +1,863 @@ +--- +name: create-agent +description: Bootstrap a modular AI agent with OpenRouter SDK, extensible hooks, and optional Ink TUI +metadata: + version: 0.0.0 + homepage: https://openrouter.ai +--- + +# Build a Modular AI Agent with OpenRouter + +This skill helps you create a **modular AI agent** with: + +- **Standalone Agent Core** - Runs independently, extensible via hooks +- **OpenRouter SDK** - Unified access to 300+ language models +- **Optional Ink TUI** - Beautiful terminal UI (separate from agent logic) + +## Architecture + +``` +┌─────────────────────────────────────────────────────┐ +│ Your Application │ +├─────────────────────────────────────────────────────┤ +│ ┌─────────────┐ ┌─────────────┐ ┌─────────────┐ │ +│ │ Ink TUI │ │ HTTP API │ │ Discord │ │ +│ └──────┬──────┘ └──────┬──────┘ └──────┬──────┘ │ +│ │ │ │ │ +│ └────────────────┼────────────────┘ │ +│ ▼ │ +│ ┌───────────────────────┐ │ +│ │ Agent Core │ │ +│ │ (hooks & lifecycle) │ │ +│ └───────────┬───────────┘ │ +│ ▼ │ +│ ┌───────────────────────┐ │ +│ │ OpenRouter SDK │ │ +│ └───────────────────────┘ │ +└─────────────────────────────────────────────────────┘ +``` + +## Prerequisites + +Get an OpenRouter API key at: https://openrouter.ai/settings/keys + +⚠️ **Security:** Never commit API keys. Use environment variables. + +## Project Setup + +### Step 1: Initialize Project + +```bash +mkdir my-agent && cd my-agent +npm init -y +npm pkg set type="module" +``` + +### Step 2: Install Dependencies + +```bash +npm install @openrouter/sdk zod eventemitter3 +npm install ink react # Optional: only for TUI +npm install -D typescript @types/react tsx +``` + +### Step 3: Create tsconfig.json + +```json +{ + "compilerOptions": { + "target": "ES2022", + "module": "NodeNext", + "moduleResolution": "NodeNext", + "jsx": "react-jsx", + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "outDir": "dist" + }, + "include": ["src"] +} +``` + +### Step 4: Add Scripts to package.json + +```json +{ + "scripts": { + "start": "tsx src/cli.tsx", + "start:headless": "tsx src/headless.ts", + "dev": "tsx watch src/cli.tsx" + } +} +``` + +## File Structure + +```bash +src/ +├── agent.ts # Standalone agent core with hooks +├── tools.ts # Tool definitions +├── cli.tsx # Ink TUI (optional interface) +└── headless.ts # Headless usage example +``` + +## Step 1: Agent Core with Hooks + +Create `src/agent.ts` - the standalone agent that can run anywhere: + +```typescript +import { OpenRouter, tool, stepCountIs } from '@openrouter/sdk'; +import type { Tool, StopCondition, StreamableOutputItem } from '@openrouter/sdk'; +import { EventEmitter } from 'eventemitter3'; +import { z } from 'zod'; + +// Message types +export interface Message { + role: 'user' | 'assistant' | 'system'; + content: string; +} + +// Agent events for hooks (items-based streaming model) +export interface AgentEvents { + 'message:user': (message: Message) => void; + 'message:assistant': (message: Message) => void; + 'item:update': (item: StreamableOutputItem) => void; // Items emitted with same ID, replace by ID + 'stream:start': () => void; + 'stream:delta': (delta: string, accumulated: string) => void; + 'stream:end': (fullText: string) => void; + 'tool:call': (name: string, args: unknown) => void; + 'tool:result': (name: string, result: unknown) => void; + 'reasoning:update': (text: string) => void; // Extended thinking content + error: (error: Error) => void; + 'thinking:start': () => void; + 'thinking:end': () => void; +} + +// Agent configuration +export interface AgentConfig { + apiKey: string; + model?: string; + instructions?: string; + tools?: Tool[]; + maxSteps?: number; +} + +// The Agent class - runs independently of any UI +export class Agent extends EventEmitter { + private client: OpenRouter; + private messages: Message[] = []; + private config: Required> & { apiKey: string }; + + constructor(config: AgentConfig) { + super(); + this.client = new OpenRouter({ apiKey: config.apiKey }); + this.config = { + apiKey: config.apiKey, + model: config.model ?? 'openrouter/auto', + instructions: config.instructions ?? 'You are a helpful assistant.', + tools: config.tools ?? [], + maxSteps: config.maxSteps ?? 5, + }; + } + + // Get conversation history + getMessages(): Message[] { + return [...this.messages]; + } + + // Clear conversation + clearHistory(): void { + this.messages = []; + } + + // Add a system message + setInstructions(instructions: string): void { + this.config.instructions = instructions; + } + + // Register additional tools at runtime + addTool(newTool: Tool): void { + this.config.tools.push(newTool); + } + + // Send a message and get streaming response using items-based model + // Items are emitted multiple times with the same ID but progressively updated content + // Replace items by their ID rather than accumulating chunks + async send(content: string): Promise { + const userMessage: Message = { role: 'user', content }; + this.messages.push(userMessage); + this.emit('message:user', userMessage); + this.emit('thinking:start'); + + try { + const result = this.client.callModel({ + model: this.config.model, + instructions: this.config.instructions, + input: this.messages.map((m) => ({ role: m.role, content: m.content })), + tools: this.config.tools.length > 0 ? this.config.tools : undefined, + stopWhen: [stepCountIs(this.config.maxSteps)], + }); + + this.emit('stream:start'); + let fullText = ''; + + // Use getItemsStream() for items-based streaming (recommended) + // Each item emission is complete - replace by ID, don't accumulate + for await (const item of result.getItemsStream()) { + // Emit the item for UI state management (use Map keyed by item.id) + this.emit('item:update', item); + + switch (item.type) { + case 'message': + // Message items contain progressively updated content + const textContent = item.content?.find( + (c: { type: string }) => c.type === 'output_text', + ); + if (textContent && 'text' in textContent) { + const newText = textContent.text; + if (newText !== fullText) { + const delta = newText.slice(fullText.length); + fullText = newText; + this.emit('stream:delta', delta, fullText); + } + } + break; + case 'function_call': + // Function call arguments stream progressively + if (item.status === 'completed') { + this.emit('tool:call', item.name, JSON.parse(item.arguments || '{}')); + } + break; + case 'function_call_output': + this.emit('tool:result', item.callId, item.output); + break; + case 'reasoning': + // Extended thinking/reasoning content + const reasoningText = item.content?.find( + (c: { type: string }) => c.type === 'reasoning_text', + ); + if (reasoningText && 'text' in reasoningText) { + this.emit('reasoning:update', reasoningText.text); + } + break; + // Additional item types: web_search_call, file_search_call, image_generation_call + } + } + + // Get final text if streaming didn't capture it + if (!fullText) { + fullText = await result.getText(); + } + + this.emit('stream:end', fullText); + + const assistantMessage: Message = { role: 'assistant', content: fullText }; + this.messages.push(assistantMessage); + this.emit('message:assistant', assistantMessage); + + return fullText; + } catch (err) { + const error = err instanceof Error ? err : new Error(String(err)); + this.emit('error', error); + throw error; + } finally { + this.emit('thinking:end'); + } + } + + // Send without streaming (simpler for programmatic use) + async sendSync(content: string): Promise { + const userMessage: Message = { role: 'user', content }; + this.messages.push(userMessage); + this.emit('message:user', userMessage); + + try { + const result = this.client.callModel({ + model: this.config.model, + instructions: this.config.instructions, + input: this.messages.map((m) => ({ role: m.role, content: m.content })), + tools: this.config.tools.length > 0 ? this.config.tools : undefined, + stopWhen: [stepCountIs(this.config.maxSteps)], + }); + + const fullText = await result.getText(); + const assistantMessage: Message = { role: 'assistant', content: fullText }; + this.messages.push(assistantMessage); + this.emit('message:assistant', assistantMessage); + + return fullText; + } catch (err) { + const error = err instanceof Error ? err : new Error(String(err)); + this.emit('error', error); + throw error; + } + } +} + +// Factory function for easy creation +export function createAgent(config: AgentConfig): Agent { + return new Agent(config); +} +``` + +## Step 2: Define Tools + +Create `src/tools.ts`: + +```typescript +import { tool } from '@openrouter/sdk'; +import { z } from 'zod'; + +export const timeTool = tool({ + name: 'get_current_time', + description: 'Get the current date and time', + inputSchema: z.object({ + timezone: z.string().optional().describe('Timezone (e.g., "UTC", "America/New_York")'), + }), + execute: async ({ timezone }) => { + return { + time: new Date().toLocaleString('en-US', { timeZone: timezone || 'UTC' }), + timezone: timezone || 'UTC', + }; + }, +}); + +export const calculatorTool = tool({ + name: 'calculate', + description: 'Perform mathematical calculations', + inputSchema: z.object({ + expression: z.string().describe('Math expression (e.g., "2 + 2", "sqrt(16)")'), + }), + execute: async ({ expression }) => { + // Simple safe eval for basic math + const sanitized = expression.replace(/[^0-9+\-*/().\s]/g, ''); + const result = Function(`"use strict"; return (${sanitized})`)(); + return { expression, result }; + }, +}); + +export const defaultTools = [timeTool, calculatorTool]; +``` + +## Step 3: Headless Usage (No UI) + +Create `src/headless.ts` - use the agent programmatically: + +```typescript +import { createAgent } from './agent.js'; +import { defaultTools } from './tools.js'; + +async function main() { + const agent = createAgent({ + apiKey: process.env.OPENROUTER_API_KEY!, + model: 'openrouter/auto', + instructions: 'You are a helpful assistant with access to tools.', + tools: defaultTools, + }); + + // Hook into events + agent.on('thinking:start', () => console.log('\n🤔 Thinking...')); + agent.on('tool:call', (name, args) => console.log(`🔧 Using ${name}:`, args)); + agent.on('stream:delta', (delta) => process.stdout.write(delta)); + agent.on('stream:end', () => console.log('\n')); + agent.on('error', (err) => console.error('❌ Error:', err.message)); + + // Interactive loop + const readline = await import('readline'); + const rl = readline.createInterface({ + input: process.stdin, + output: process.stdout, + }); + + console.log('Agent ready. Type your message (Ctrl+C to exit):\n'); + + const prompt = () => { + rl.question('You: ', async (input) => { + if (!input.trim()) { + prompt(); + return; + } + await agent.send(input); + prompt(); + }); + }; + + prompt(); +} + +main().catch(console.error); +``` + +Run headless: `OPENROUTER_API_KEY=sk-or-... npm run start:headless` + +## Step 4: Ink TUI (Optional Interface) + +Create `src/cli.tsx` - a beautiful terminal UI that uses the agent with items-based streaming: + +```tsx +import React, { useState, useEffect, useCallback } from 'react'; +import { render, Box, Text, useInput, useApp } from 'ink'; +import type { StreamableOutputItem } from '@openrouter/sdk'; +import { createAgent, type Agent, type Message } from './agent.js'; +import { defaultTools } from './tools.js'; + +// Initialize agent (runs independently of UI) +const agent = createAgent({ + apiKey: process.env.OPENROUTER_API_KEY!, + model: 'openrouter/auto', + instructions: 'You are a helpful assistant. Be concise.', + tools: defaultTools, +}); + +function ChatMessage({ message }: { message: Message }) { + const isUser = message.role === 'user'; + return ( + + + {isUser ? '▶ You' : '◀ Assistant'} + + {message.content} + + ); +} + +// Render streaming items by type using the items-based pattern +function ItemRenderer({ item }: { item: StreamableOutputItem }) { + switch (item.type) { + case 'message': { + const textContent = item.content?.find((c: { type: string }) => c.type === 'output_text'); + const text = textContent && 'text' in textContent ? textContent.text : ''; + return ( + + + ◀ Assistant + + {text} + {item.status !== 'completed' && } + + ); + } + case 'function_call': + return ( + + {item.status === 'completed' ? ' ✓' : ' 🔧'} {item.name} + {item.status === 'in_progress' && '...'} + + ); + case 'reasoning': { + const reasoningText = item.content?.find( + (c: { type: string }) => c.type === 'reasoning_text', + ); + const text = reasoningText && 'text' in reasoningText ? reasoningText.text : ''; + return ( + + + 💭 Thinking + + + {text} + + + ); + } + default: + return null; + } +} + +function InputField({ + value, + onChange, + onSubmit, + disabled, +}: { + value: string; + onChange: (v: string) => void; + onSubmit: () => void; + disabled: boolean; +}) { + useInput((input, key) => { + if (disabled) return; + if (key.return) onSubmit(); + else if (key.backspace || key.delete) onChange(value.slice(0, -1)); + else if (input && !key.ctrl && !key.meta) onChange(value + input); + }); + + return ( + + {'> '} + {value} + {disabled ? ' ···' : '█'} + + ); +} + +function App() { + const { exit } = useApp(); + const [messages, setMessages] = useState([]); + const [input, setInput] = useState(''); + const [isLoading, setIsLoading] = useState(false); + // Use Map keyed by item ID for efficient React state updates (items-based pattern) + const [items, setItems] = useState>(new Map()); + + useInput((_, key) => { + if (key.escape) exit(); + }); + + // Subscribe to agent events using items-based streaming + useEffect(() => { + const onThinkingStart = () => { + setIsLoading(true); + setItems(new Map()); // Clear items for new response + }; + + // Items-based streaming: replace items by ID, don't accumulate + const onItemUpdate = (item: StreamableOutputItem) => { + setItems((prev) => new Map(prev).set(item.id, item)); + }; + + const onMessageAssistant = () => { + setMessages(agent.getMessages()); + setItems(new Map()); // Clear streaming items + setIsLoading(false); + }; + + const onError = (err: Error) => { + setIsLoading(false); + }; + + agent.on('thinking:start', onThinkingStart); + agent.on('item:update', onItemUpdate); + agent.on('message:assistant', onMessageAssistant); + agent.on('error', onError); + + return () => { + agent.off('thinking:start', onThinkingStart); + agent.off('item:update', onItemUpdate); + agent.off('message:assistant', onMessageAssistant); + agent.off('error', onError); + }; + }, []); + + const sendMessage = useCallback(async () => { + if (!input.trim() || isLoading) return; + const text = input.trim(); + setInput(''); + setMessages((prev) => [...prev, { role: 'user', content: text }]); + await agent.send(text); + }, [input, isLoading]); + + return ( + + + + 🤖 OpenRouter Agent + + (Esc to exit) + + + + {/* Render completed messages */} + {messages.map((msg, i) => ( + + ))} + + {/* Render streaming items by type (items-based pattern) */} + {Array.from(items.values()).map((item) => ( + + ))} + + + + + + + ); +} + +render(); +``` + +Run TUI: `OPENROUTER_API_KEY=sk-or-... npm start` + +## Understanding Items-Based Streaming + +The OpenRouter SDK uses an **items-based streaming model** - a key paradigm where items are emitted multiple times with the same ID but progressively updated content. Instead of accumulating chunks, you **replace items by their ID**. + +### How It Works + +Each iteration of `getItemsStream()` yields a complete item with updated content: + +```typescript +// Iteration 1: Partial message +{ id: "msg_123", type: "message", content: [{ type: "output_text", text: "Hello" }] } + +// Iteration 2: Updated message (replace, don't append) +{ id: "msg_123", type: "message", content: [{ type: "output_text", text: "Hello world" }] } +``` + +For function calls, arguments stream progressively: + +```typescript +// Iteration 1: Partial arguments +{ id: "call_456", type: "function_call", name: "get_weather", arguments: "{\"q" } + +// Iteration 2: Complete arguments +{ id: "call_456", type: "function_call", name: "get_weather", arguments: "{\"query\": \"Paris\"}", status: "completed" } +``` + +### Why Items Are Better + +**Traditional (accumulation required):** + +```typescript +let text = ''; +for await (const chunk of result.getTextStream()) { + text += chunk; // Manual accumulation + updateUI(text); +} +``` + +**Items (complete replacement):** + +```typescript +const items = new Map(); +for await (const item of result.getItemsStream()) { + items.set(item.id, item); // Replace by ID + updateUI(items); +} +``` + +Benefits: + +- **No manual chunk management** - each item is complete +- **Handles concurrent outputs** - function calls and messages can stream in parallel +- **Full TypeScript inference** for all item types +- **Natural Map-based state** works perfectly with React/UI frameworks + +## Extending the Agent + +### Add Custom Hooks + +```typescript +const agent = createAgent({ apiKey: '...' }); + +// Log all events +agent.on('message:user', (msg) => { + saveToDatabase('user', msg.content); +}); + +agent.on('message:assistant', (msg) => { + saveToDatabase('assistant', msg.content); + sendWebhook('new_message', msg); +}); + +agent.on('tool:call', (name, args) => { + analytics.track('tool_used', { name, args }); +}); + +agent.on('error', (err) => { + errorReporting.capture(err); +}); +``` + +### Use with HTTP Server + +```typescript +import express from 'express'; +import { createAgent } from './agent.js'; + +const app = express(); +app.use(express.json()); + +// One agent per session (store in memory or Redis) +const sessions = new Map(); + +app.post('/chat', async (req, res) => { + const { sessionId, message } = req.body; + + let agent = sessions.get(sessionId); + if (!agent) { + agent = createAgent({ apiKey: process.env.OPENROUTER_API_KEY! }); + sessions.set(sessionId, agent); + } + + const response = await agent.sendSync(message); + res.json({ response, history: agent.getMessages() }); +}); + +app.listen(3000); +``` + +### Use with Discord + +```typescript +import { Client, GatewayIntentBits } from 'discord.js'; +import { createAgent } from './agent.js'; + +const discord = new Client({ + intents: [GatewayIntentBits.Guilds, GatewayIntentBits.GuildMessages], +}); + +const agents = new Map(); + +discord.on('messageCreate', async (msg) => { + if (msg.author.bot) return; + + let agent = agents.get(msg.channelId); + if (!agent) { + agent = createAgent({ apiKey: process.env.OPENROUTER_API_KEY! }); + agents.set(msg.channelId, agent); + } + + const response = await agent.sendSync(msg.content); + await msg.reply(response); +}); + +discord.login(process.env.DISCORD_TOKEN); +``` + +## Agent API Reference + +### Constructor Options + +| Option | Type | Default | Description | +| ------------ | ------ | ------------------------------ | --------------------------- | +| apiKey | string | required | OpenRouter API key | +| model | string | 'openrouter/auto' | Model to use | +| instructions | string | 'You are a helpful assistant.' | System prompt | +| tools | Tool[] | [] | Available tools | +| maxSteps | number | 5 | Max agentic loop iterations | + +### Methods + +| Method | Returns | Description | +| ----------------------- | --------------- | ------------------------------ | +| `send(content)` | Promise | Send message with streaming | +| `sendSync(content)` | Promise | Send message without streaming | +| `getMessages()` | Message[] | Get conversation history | +| `clearHistory()` | void | Clear conversation | +| `setInstructions(text)` | void | Update system prompt | +| `addTool(tool)` | void | Add tool at runtime | + +### Events + +| Event | Payload | Description | +| ------------------- | -------------------- | ---------------------------------------------- | +| `message:user` | Message | User message added | +| `message:assistant` | Message | Assistant response complete | +| `item:update` | StreamableOutputItem | Item emitted (replace by ID, don't accumulate) | +| `stream:start` | - | Streaming started | +| `stream:delta` | (delta, accumulated) | New text chunk | +| `stream:end` | fullText | Streaming complete | +| `tool:call` | (name, args) | Tool being called | +| `tool:result` | (name, result) | Tool returned result | +| `reasoning:update` | text | Extended thinking content | +| `thinking:start` | - | Agent processing | +| `thinking:end` | - | Agent done processing | +| `error` | Error | Error occurred | + +### Item Types (from getItemsStream) + +The SDK uses an items-based streaming model where items are emitted multiple times with the same ID but progressively updated content. Replace items by their ID rather than accumulating chunks. + +| Type | Purpose | +| ----------------------- | ----------------------------------------- | +| `message` | Assistant text responses | +| `function_call` | Tool invocations with streaming arguments | +| `function_call_output` | Results from executed tools | +| `reasoning` | Extended thinking content | +| `web_search_call` | Web search operations | +| `file_search_call` | File search operations | +| `image_generation_call` | Image generation operations | + +## Discovering Models + +**Do not hardcode model IDs** - they change frequently. Use the models API: + +### Fetch Available Models + +```typescript +interface OpenRouterModel { + id: string; + name: string; + description?: string; + context_length: number; + pricing: { prompt: string; completion: string }; + top_provider?: { is_moderated: boolean }; +} + +async function fetchModels(): Promise { + const res = await fetch('https://openrouter.ai/api/v1/models'); + const data = await res.json(); + return data.data; +} + +// Find models by criteria +async function findModels(filter: { + author?: string; // e.g., 'anthropic', 'openai', 'google' + minContext?: number; // e.g., 100000 for 100k context + maxPromptPrice?: number; // e.g., 0.001 for cheap models +}): Promise { + const models = await fetchModels(); + + return models.filter((m) => { + if (filter.author && !m.id.startsWith(filter.author + '/')) return false; + if (filter.minContext && m.context_length < filter.minContext) return false; + if (filter.maxPromptPrice) { + const price = parseFloat(m.pricing.prompt); + if (price > filter.maxPromptPrice) return false; + } + return true; + }); +} + +// Example: Get latest Claude models +const claudeModels = await findModels({ author: 'anthropic' }); +console.log(claudeModels.map((m) => m.id)); + +// Example: Get models with 100k+ context +const longContextModels = await findModels({ minContext: 100000 }); + +// Example: Get cheap models +const cheapModels = await findModels({ maxPromptPrice: 0.0005 }); +``` + +### Dynamic Model Selection in Agent + +```typescript +// Create agent with dynamic model selection +const models = await fetchModels(); +const bestModel = models.find((m) => m.id.includes('claude')) || models[0]; + +const agent = createAgent({ + apiKey: process.env.OPENROUTER_API_KEY!, + model: bestModel.id, // Use discovered model + instructions: 'You are a helpful assistant.', +}); +``` + +### Using openrouter/auto + +For simplicity, use `openrouter/auto` which automatically selects the best +available model for your request: + +```typescript +const agent = createAgent({ + apiKey: process.env.OPENROUTER_API_KEY!, + model: 'openrouter/auto', // Auto-selects best model +}); +``` + +### Models API Reference + +- **Endpoint**: `GET https://openrouter.ai/api/v1/models` +- **Response**: `{ data: OpenRouterModel[] }` +- **Browse models**: https://openrouter.ai/models + +## Resources + +- OpenRouter Docs: https://openrouter.ai/docs +- Models API: https://openrouter.ai/api/v1/models +- Ink Docs: https://github.com/vadimdemedes/ink +- Get API Key: https://openrouter.ai/settings/keys diff --git a/packages/mosaic/framework/skills/create-auth-skill/SKILL.md b/packages/mosaic/framework/skills/create-auth-skill/SKILL.md new file mode 100644 index 00000000..15b81e54 --- /dev/null +++ b/packages/mosaic/framework/skills/create-auth-skill/SKILL.md @@ -0,0 +1,218 @@ +--- +name: create-auth-skill +description: Skill for creating auth layers in TypeScript/JavaScript apps using Better Auth. +--- + +# Create Auth Skill + +Guide for adding authentication to TypeScript/JavaScript applications using Better Auth. + +**For code examples and syntax, see [better-auth.com/docs](https://better-auth.com/docs).** + +--- + +## Decision Tree + +``` +Is this a new/empty project? +├─ YES → New project setup +│ 1. Identify framework +│ 2. Choose database +│ 3. Install better-auth +│ 4. Create auth.ts + auth-client.ts +│ 5. Set up route handler +│ 6. Run CLI migrate/generate +│ 7. Add features via plugins +│ +└─ NO → Does project have existing auth? + ├─ YES → Migration/enhancement + │ • Audit current auth for gaps + │ • Plan incremental migration + │ • See migration guides in docs + │ + └─ NO → Add auth to existing project + 1. Analyze project structure + 2. Install better-auth + 3. Create auth config + 4. Add route handler + 5. Run schema migrations + 6. Integrate into existing pages +``` + +--- + +## Installation + +**Core:** `npm install better-auth` + +**Scoped packages (as needed):** +| Package | Use case | +|---------|----------| +| `@better-auth/passkey` | WebAuthn/Passkey auth | +| `@better-auth/sso` | SAML/OIDC enterprise SSO | +| `@better-auth/stripe` | Stripe payments | +| `@better-auth/scim` | SCIM user provisioning | +| `@better-auth/expo` | React Native/Expo | + +--- + +## Environment Variables + +```env +BETTER_AUTH_SECRET=<32+ chars, generate with: openssl rand -base64 32> +BETTER_AUTH_URL=http://localhost:3000 +DATABASE_URL= +``` + +Add OAuth secrets as needed: `GITHUB_CLIENT_ID`, `GITHUB_CLIENT_SECRET`, `GOOGLE_CLIENT_ID`, etc. + +--- + +## Server Config (auth.ts) + +**Location:** `lib/auth.ts` or `src/lib/auth.ts` + +**Minimal config needs:** + +- `database` - Connection or adapter +- `emailAndPassword: { enabled: true }` - For email/password auth + +**Standard config adds:** + +- `socialProviders` - OAuth providers (google, github, etc.) +- `emailVerification.sendVerificationEmail` - Email verification handler +- `emailAndPassword.sendResetPassword` - Password reset handler + +**Full config adds:** + +- `plugins` - Array of feature plugins +- `session` - Expiry, cookie cache settings +- `account.accountLinking` - Multi-provider linking +- `rateLimit` - Rate limiting config + +**Export types:** `export type Session = typeof auth.$Infer.Session` + +--- + +## Client Config (auth-client.ts) + +**Import by framework:** +| Framework | Import | +|-----------|--------| +| React/Next.js | `better-auth/react` | +| Vue | `better-auth/vue` | +| Svelte | `better-auth/svelte` | +| Solid | `better-auth/solid` | +| Vanilla JS | `better-auth/client` | + +**Client plugins** go in `createAuthClient({ plugins: [...] })`. + +**Common exports:** `signIn`, `signUp`, `signOut`, `useSession`, `getSession` + +--- + +## Route Handler Setup + +| Framework | File | Handler | +| ------------------ | -------------------------------- | ------------------------------------------------ | +| Next.js App Router | `app/api/auth/[...all]/route.ts` | `toNextJsHandler(auth)` → export `{ GET, POST }` | +| Next.js Pages | `pages/api/auth/[...all].ts` | `toNextJsHandler(auth)` → default export | +| Express | Any file | `app.all("/api/auth/*", toNodeHandler(auth))` | +| SvelteKit | `src/hooks.server.ts` | `svelteKitHandler(auth)` | +| SolidStart | Route file | `solidStartHandler(auth)` | +| Hono | Route file | `auth.handler(c.req.raw)` | + +**Next.js Server Components:** Add `nextCookies()` plugin to auth config. + +--- + +## Database Migrations + +| Adapter | Command | +| --------------- | -------------------------------------------------------------------------------------------------- | +| Built-in Kysely | `npx @better-auth/cli@latest migrate` (applies directly) | +| Prisma | `npx @better-auth/cli@latest generate --output prisma/schema.prisma` then `npx prisma migrate dev` | +| Drizzle | `npx @better-auth/cli@latest generate --output src/db/auth-schema.ts` then `npx drizzle-kit push` | + +**Re-run after adding plugins.** + +--- + +## Database Adapters + +| Database | Setup | +| ---------- | -------------------------------------------------------------------------------------- | +| SQLite | Pass `better-sqlite3` or `bun:sqlite` instance directly | +| PostgreSQL | Pass `pg.Pool` instance directly | +| MySQL | Pass `mysql2` pool directly | +| Prisma | `prismaAdapter(prisma, { provider: "postgresql" })` from `better-auth/adapters/prisma` | +| Drizzle | `drizzleAdapter(db, { provider: "pg" })` from `better-auth/adapters/drizzle` | +| MongoDB | `mongodbAdapter(db)` from `better-auth/adapters/mongodb` | + +--- + +## Common Plugins + +| Plugin | Server Import | Client Import | Purpose | +| -------------- | ---------------------- | -------------------- | ----------------- | +| `twoFactor` | `better-auth/plugins` | `twoFactorClient` | 2FA with TOTP/OTP | +| `organization` | `better-auth/plugins` | `organizationClient` | Teams/orgs | +| `admin` | `better-auth/plugins` | `adminClient` | User management | +| `bearer` | `better-auth/plugins` | - | API token auth | +| `openAPI` | `better-auth/plugins` | - | API docs | +| `passkey` | `@better-auth/passkey` | `passkeyClient` | WebAuthn | +| `sso` | `@better-auth/sso` | - | Enterprise SSO | + +**Plugin pattern:** Server plugin + client plugin + run migrations. + +--- + +## Auth UI Implementation + +**Sign in flow:** + +1. `signIn.email({ email, password })` or `signIn.social({ provider, callbackURL })` +2. Handle `error` in response +3. Redirect on success + +**Session check (client):** `useSession()` hook returns `{ data: session, isPending }` + +**Session check (server):** `auth.api.getSession({ headers: await headers() })` + +**Protected routes:** Check session, redirect to `/sign-in` if null. + +--- + +## Security Checklist + +- [ ] `BETTER_AUTH_SECRET` set (32+ chars) +- [ ] `advanced.useSecureCookies: true` in production +- [ ] `trustedOrigins` configured +- [ ] Rate limits enabled +- [ ] Email verification enabled +- [ ] Password reset implemented +- [ ] 2FA for sensitive apps +- [ ] CSRF protection NOT disabled +- [ ] `account.accountLinking` reviewed + +--- + +## Troubleshooting + +| Issue | Fix | +| ------------------------------- | ------------------------------------------------------------- | +| "Secret not set" | Add `BETTER_AUTH_SECRET` env var | +| "Invalid Origin" | Add domain to `trustedOrigins` | +| Cookies not setting | Check `baseURL` matches domain; enable secure cookies in prod | +| OAuth callback errors | Verify redirect URIs in provider dashboard | +| Type errors after adding plugin | Re-run CLI generate/migrate | + +--- + +## Resources + +- [Docs](https://better-auth.com/docs) +- [Examples](https://github.com/better-auth/examples) +- [Plugins](https://better-auth.com/docs/concepts/plugins) +- [CLI](https://better-auth.com/docs/concepts/cli) +- [Migration Guides](https://better-auth.com/docs/guides) diff --git a/packages/mosaic/framework/skills/dispatching-parallel-agents/SKILL.md b/packages/mosaic/framework/skills/dispatching-parallel-agents/SKILL.md new file mode 100644 index 00000000..786dc07d --- /dev/null +++ b/packages/mosaic/framework/skills/dispatching-parallel-agents/SKILL.md @@ -0,0 +1,191 @@ +--- +name: dispatching-parallel-agents +description: Use when facing 2+ independent tasks that can be worked on without shared state or sequential dependencies +--- + +# Dispatching Parallel Agents + +## Overview + +When you have multiple unrelated failures (different test files, different subsystems, different bugs), investigating them sequentially wastes time. Each investigation is independent and can happen in parallel. + +**Core principle:** Dispatch one agent per independent problem domain. Let them work concurrently. + +## When to Use + +```dot +digraph when_to_use { + "Multiple failures?" [shape=diamond]; + "Are they independent?" [shape=diamond]; + "Single agent investigates all" [shape=box]; + "One agent per problem domain" [shape=box]; + "Can they work in parallel?" [shape=diamond]; + "Sequential agents" [shape=box]; + "Parallel dispatch" [shape=box]; + + "Multiple failures?" -> "Are they independent?" [label="yes"]; + "Are they independent?" -> "Single agent investigates all" [label="no - related"]; + "Are they independent?" -> "Can they work in parallel?" [label="yes"]; + "Can they work in parallel?" -> "Parallel dispatch" [label="yes"]; + "Can they work in parallel?" -> "Sequential agents" [label="no - shared state"]; +} +``` + +**Use when:** + +- 3+ test files failing with different root causes +- Multiple subsystems broken independently +- Each problem can be understood without context from others +- No shared state between investigations + +**Don't use when:** + +- Failures are related (fix one might fix others) +- Need to understand full system state +- Agents would interfere with each other + +## The Pattern + +### 1. Identify Independent Domains + +Group failures by what's broken: + +- File A tests: Tool approval flow +- File B tests: Batch completion behavior +- File C tests: Abort functionality + +Each domain is independent - fixing tool approval doesn't affect abort tests. + +### 2. Create Focused Agent Tasks + +Each agent gets: + +- **Specific scope:** One test file or subsystem +- **Clear goal:** Make these tests pass +- **Constraints:** Don't change other code +- **Expected output:** Summary of what you found and fixed + +### 3. Dispatch in Parallel + +```typescript +// In Claude Code / AI environment +Task('Fix agent-tool-abort.test.ts failures'); +Task('Fix batch-completion-behavior.test.ts failures'); +Task('Fix tool-approval-race-conditions.test.ts failures'); +// All three run concurrently +``` + +### 4. Review and Integrate + +When agents return: + +- Read each summary +- Verify fixes don't conflict +- Run full test suite +- Integrate all changes + +## Agent Prompt Structure + +Good agent prompts are: + +1. **Focused** - One clear problem domain +2. **Self-contained** - All context needed to understand the problem +3. **Specific about output** - What should the agent return? + +```markdown +Fix the 3 failing tests in src/agents/agent-tool-abort.test.ts: + +1. "should abort tool with partial output capture" - expects 'interrupted at' in message +2. "should handle mixed completed and aborted tools" - fast tool aborted instead of completed +3. "should properly track pendingToolCount" - expects 3 results but gets 0 + +These are timing/race condition issues. Your task: + +1. Read the test file and understand what each test verifies +2. Identify root cause - timing issues or actual bugs? +3. Fix by: + - Replacing arbitrary timeouts with event-based waiting + - Fixing bugs in abort implementation if found + - Adjusting test expectations if testing changed behavior + +Do NOT just increase timeouts - find the real issue. + +Return: Summary of what you found and what you fixed. +``` + +## Common Mistakes + +**❌ Too broad:** "Fix all the tests" - agent gets lost +**✅ Specific:** "Fix agent-tool-abort.test.ts" - focused scope + +**❌ No context:** "Fix the race condition" - agent doesn't know where +**✅ Context:** Paste the error messages and test names + +**❌ No constraints:** Agent might refactor everything +**✅ Constraints:** "Do NOT change production code" or "Fix tests only" + +**❌ Vague output:** "Fix it" - you don't know what changed +**✅ Specific:** "Return summary of root cause and changes" + +## When NOT to Use + +**Related failures:** Fixing one might fix others - investigate together first +**Need full context:** Understanding requires seeing entire system +**Exploratory debugging:** You don't know what's broken yet +**Shared state:** Agents would interfere (editing same files, using same resources) + +## Real Example from Session + +**Scenario:** 6 test failures across 3 files after major refactoring + +**Failures:** + +- agent-tool-abort.test.ts: 3 failures (timing issues) +- batch-completion-behavior.test.ts: 2 failures (tools not executing) +- tool-approval-race-conditions.test.ts: 1 failure (execution count = 0) + +**Decision:** Independent domains - abort logic separate from batch completion separate from race conditions + +**Dispatch:** + +``` +Agent 1 → Fix agent-tool-abort.test.ts +Agent 2 → Fix batch-completion-behavior.test.ts +Agent 3 → Fix tool-approval-race-conditions.test.ts +``` + +**Results:** + +- Agent 1: Replaced timeouts with event-based waiting +- Agent 2: Fixed event structure bug (threadId in wrong place) +- Agent 3: Added wait for async tool execution to complete + +**Integration:** All fixes independent, no conflicts, full suite green + +**Time saved:** 3 problems solved in parallel vs sequentially + +## Key Benefits + +1. **Parallelization** - Multiple investigations happen simultaneously +2. **Focus** - Each agent has narrow scope, less context to track +3. **Independence** - Agents don't interfere with each other +4. **Speed** - 3 problems solved in time of 1 + +## Verification + +After agents return: + +1. **Review each summary** - Understand what changed +2. **Check for conflicts** - Did agents edit same code? +3. **Run full suite** - Verify all fixes work together +4. **Spot check** - Agents can make systematic errors + +## Real-World Impact + +From debugging session (2025-10-03): + +- 6 failures across 3 files +- 3 agents dispatched in parallel +- All investigations completed concurrently +- All fixes integrated successfully +- Zero conflicts between agent changes diff --git a/packages/mosaic/framework/skills/doc-coauthoring/SKILL.md b/packages/mosaic/framework/skills/doc-coauthoring/SKILL.md new file mode 100644 index 00000000..3d21e277 --- /dev/null +++ b/packages/mosaic/framework/skills/doc-coauthoring/SKILL.md @@ -0,0 +1,394 @@ +--- +name: doc-coauthoring +description: Guide users through a structured workflow for co-authoring documentation. Use when user wants to write documentation, proposals, technical specs, decision docs, or similar structured content. This workflow helps users efficiently transfer context, refine content through iteration, and verify the doc works for readers. Trigger when user mentions writing docs, creating proposals, drafting specs, or similar documentation tasks. +--- + +# Doc Co-Authoring Workflow + +This skill provides a structured workflow for guiding users through collaborative document creation. Act as an active guide, walking users through three stages: Context Gathering, Refinement & Structure, and Reader Testing. + +## When to Offer This Workflow + +**Trigger conditions:** + +- User mentions writing documentation: "write a doc", "draft a proposal", "create a spec", "write up" +- User mentions specific doc types: "PRD", "design doc", "decision doc", "RFC" +- User seems to be starting a substantial writing task + +**Initial offer:** +Offer the user a structured workflow for co-authoring the document. Explain the three stages: + +1. **Context Gathering**: User provides all relevant context while Claude asks clarifying questions +2. **Refinement & Structure**: Iteratively build each section through brainstorming and editing +3. **Reader Testing**: Test the doc with a fresh Claude (no context) to catch blind spots before others read it + +Explain that this approach helps ensure the doc works well when others read it (including when they paste it into Claude). Ask if they want to try this workflow or prefer to work freeform. + +If user declines, work freeform. If user accepts, proceed to Stage 1. + +## Stage 1: Context Gathering + +**Goal:** Close the gap between what the user knows and what Claude knows, enabling smart guidance later. + +### Initial Questions + +Start by asking the user for meta-context about the document: + +1. What type of document is this? (e.g., technical spec, decision doc, proposal) +2. Who's the primary audience? +3. What's the desired impact when someone reads this? +4. Is there a template or specific format to follow? +5. Any other constraints or context to know? + +Inform them they can answer in shorthand or dump information however works best for them. + +**If user provides a template or mentions a doc type:** + +- Ask if they have a template document to share +- If they provide a link to a shared document, use the appropriate integration to fetch it +- If they provide a file, read it + +**If user mentions editing an existing shared document:** + +- Use the appropriate integration to read the current state +- Check for images without alt-text +- If images exist without alt-text, explain that when others use Claude to understand the doc, Claude won't be able to see them. Ask if they want alt-text generated. If so, request they paste each image into chat for descriptive alt-text generation. + +### Info Dumping + +Once initial questions are answered, encourage the user to dump all the context they have. Request information such as: + +- Background on the project/problem +- Related team discussions or shared documents +- Why alternative solutions aren't being used +- Organizational context (team dynamics, past incidents, politics) +- Timeline pressures or constraints +- Technical architecture or dependencies +- Stakeholder concerns + +Advise them not to worry about organizing it - just get it all out. Offer multiple ways to provide context: + +- Info dump stream-of-consciousness +- Point to team channels or threads to read +- Link to shared documents + +**If integrations are available** (e.g., Slack, Teams, Google Drive, SharePoint, or other MCP servers), mention that these can be used to pull in context directly. + +**If no integrations are detected and in Claude.ai or Claude app:** Suggest they can enable connectors in their Claude settings to allow pulling context from messaging apps and document storage directly. + +Inform them clarifying questions will be asked once they've done their initial dump. + +**During context gathering:** + +- If user mentions team channels or shared documents: + - If integrations available: Inform them the content will be read now, then use the appropriate integration + - If integrations not available: Explain lack of access. Suggest they enable connectors in Claude settings, or paste the relevant content directly. + +- If user mentions entities/projects that are unknown: + - Ask if connected tools should be searched to learn more + - Wait for user confirmation before searching + +- As user provides context, track what's being learned and what's still unclear + +**Asking clarifying questions:** + +When user signals they've done their initial dump (or after substantial context provided), ask clarifying questions to ensure understanding: + +Generate 5-10 numbered questions based on gaps in the context. + +Inform them they can use shorthand to answer (e.g., "1: yes, 2: see #channel, 3: no because backwards compat"), link to more docs, point to channels to read, or just keep info-dumping. Whatever's most efficient for them. + +**Exit condition:** +Sufficient context has been gathered when questions show understanding - when edge cases and trade-offs can be asked about without needing basics explained. + +**Transition:** +Ask if there's any more context they want to provide at this stage, or if it's time to move on to drafting the document. + +If user wants to add more, let them. When ready, proceed to Stage 2. + +## Stage 2: Refinement & Structure + +**Goal:** Build the document section by section through brainstorming, curation, and iterative refinement. + +**Instructions to user:** +Explain that the document will be built section by section. For each section: + +1. Clarifying questions will be asked about what to include +2. 5-20 options will be brainstormed +3. User will indicate what to keep/remove/combine +4. The section will be drafted +5. It will be refined through surgical edits + +Start with whichever section has the most unknowns (usually the core decision/proposal), then work through the rest. + +**Section ordering:** + +If the document structure is clear: +Ask which section they'd like to start with. + +Suggest starting with whichever section has the most unknowns. For decision docs, that's usually the core proposal. For specs, it's typically the technical approach. Summary sections are best left for last. + +If user doesn't know what sections they need: +Based on the type of document and template, suggest 3-5 sections appropriate for the doc type. + +Ask if this structure works, or if they want to adjust it. + +**Once structure is agreed:** + +Create the initial document structure with placeholder text for all sections. + +**If access to artifacts is available:** +Use `create_file` to create an artifact. This gives both Claude and the user a scaffold to work from. + +Inform them that the initial structure with placeholders for all sections will be created. + +Create artifact with all section headers and brief placeholder text like "[To be written]" or "[Content here]". + +Provide the scaffold link and indicate it's time to fill in each section. + +**If no access to artifacts:** +Create a markdown file in the working directory. Name it appropriately (e.g., `decision-doc.md`, `technical-spec.md`). + +Inform them that the initial structure with placeholders for all sections will be created. + +Create file with all section headers and placeholder text. + +Confirm the filename has been created and indicate it's time to fill in each section. + +**For each section:** + +### Step 1: Clarifying Questions + +Announce work will begin on the [SECTION NAME] section. Ask 5-10 clarifying questions about what should be included: + +Generate 5-10 specific questions based on context and section purpose. + +Inform them they can answer in shorthand or just indicate what's important to cover. + +### Step 2: Brainstorming + +For the [SECTION NAME] section, brainstorm [5-20] things that might be included, depending on the section's complexity. Look for: + +- Context shared that might have been forgotten +- Angles or considerations not yet mentioned + +Generate 5-20 numbered options based on section complexity. At the end, offer to brainstorm more if they want additional options. + +### Step 3: Curation + +Ask which points should be kept, removed, or combined. Request brief justifications to help learn priorities for the next sections. + +Provide examples: + +- "Keep 1,4,7,9" +- "Remove 3 (duplicates 1)" +- "Remove 6 (audience already knows this)" +- "Combine 11 and 12" + +**If user gives freeform feedback** (e.g., "looks good" or "I like most of it but...") instead of numbered selections, extract their preferences and proceed. Parse what they want kept/removed/changed and apply it. + +### Step 4: Gap Check + +Based on what they've selected, ask if there's anything important missing for the [SECTION NAME] section. + +### Step 5: Drafting + +Use `str_replace` to replace the placeholder text for this section with the actual drafted content. + +Announce the [SECTION NAME] section will be drafted now based on what they've selected. + +**If using artifacts:** +After drafting, provide a link to the artifact. + +Ask them to read through it and indicate what to change. Note that being specific helps learning for the next sections. + +**If using a file (no artifacts):** +After drafting, confirm completion. + +Inform them the [SECTION NAME] section has been drafted in [filename]. Ask them to read through it and indicate what to change. Note that being specific helps learning for the next sections. + +**Key instruction for user (include when drafting the first section):** +Provide a note: Instead of editing the doc directly, ask them to indicate what to change. This helps learning of their style for future sections. For example: "Remove the X bullet - already covered by Y" or "Make the third paragraph more concise". + +### Step 6: Iterative Refinement + +As user provides feedback: + +- Use `str_replace` to make edits (never reprint the whole doc) +- **If using artifacts:** Provide link to artifact after each edit +- **If using files:** Just confirm edits are complete +- If user edits doc directly and asks to read it: mentally note the changes they made and keep them in mind for future sections (this shows their preferences) + +**Continue iterating** until user is satisfied with the section. + +### Quality Checking + +After 3 consecutive iterations with no substantial changes, ask if anything can be removed without losing important information. + +When section is done, confirm [SECTION NAME] is complete. Ask if ready to move to the next section. + +**Repeat for all sections.** + +### Near Completion + +As approaching completion (80%+ of sections done), announce intention to re-read the entire document and check for: + +- Flow and consistency across sections +- Redundancy or contradictions +- Anything that feels like "slop" or generic filler +- Whether every sentence carries weight + +Read entire document and provide feedback. + +**When all sections are drafted and refined:** +Announce all sections are drafted. Indicate intention to review the complete document one more time. + +Review for overall coherence, flow, completeness. + +Provide any final suggestions. + +Ask if ready to move to Reader Testing, or if they want to refine anything else. + +## Stage 3: Reader Testing + +**Goal:** Test the document with a fresh Claude (no context bleed) to verify it works for readers. + +**Instructions to user:** +Explain that testing will now occur to see if the document actually works for readers. This catches blind spots - things that make sense to the authors but might confuse others. + +### Testing Approach + +**If access to sub-agents is available (e.g., in Claude Code):** + +Perform the testing directly without user involvement. + +### Step 1: Predict Reader Questions + +Announce intention to predict what questions readers might ask when trying to discover this document. + +Generate 5-10 questions that readers would realistically ask. + +### Step 2: Test with Sub-Agent + +Announce that these questions will be tested with a fresh Claude instance (no context from this conversation). + +For each question, invoke a sub-agent with just the document content and the question. + +Summarize what Reader Claude got right/wrong for each question. + +### Step 3: Run Additional Checks + +Announce additional checks will be performed. + +Invoke sub-agent to check for ambiguity, false assumptions, contradictions. + +Summarize any issues found. + +### Step 4: Report and Fix + +If issues found: +Report that Reader Claude struggled with specific issues. + +List the specific issues. + +Indicate intention to fix these gaps. + +Loop back to refinement for problematic sections. + +--- + +**If no access to sub-agents (e.g., claude.ai web interface):** + +The user will need to do the testing manually. + +### Step 1: Predict Reader Questions + +Ask what questions people might ask when trying to discover this document. What would they type into Claude.ai? + +Generate 5-10 questions that readers would realistically ask. + +### Step 2: Setup Testing + +Provide testing instructions: + +1. Open a fresh Claude conversation: https://claude.ai +2. Paste or share the document content (if using a shared doc platform with connectors enabled, provide the link) +3. Ask Reader Claude the generated questions + +For each question, instruct Reader Claude to provide: + +- The answer +- Whether anything was ambiguous or unclear +- What knowledge/context the doc assumes is already known + +Check if Reader Claude gives correct answers or misinterprets anything. + +### Step 3: Additional Checks + +Also ask Reader Claude: + +- "What in this doc might be ambiguous or unclear to readers?" +- "What knowledge or context does this doc assume readers already have?" +- "Are there any internal contradictions or inconsistencies?" + +### Step 4: Iterate Based on Results + +Ask what Reader Claude got wrong or struggled with. Indicate intention to fix those gaps. + +Loop back to refinement for any problematic sections. + +--- + +### Exit Condition (Both Approaches) + +When Reader Claude consistently answers questions correctly and doesn't surface new gaps or ambiguities, the doc is ready. + +## Final Review + +When Reader Testing passes: +Announce the doc has passed Reader Claude testing. Before completion: + +1. Recommend they do a final read-through themselves - they own this document and are responsible for its quality +2. Suggest double-checking any facts, links, or technical details +3. Ask them to verify it achieves the impact they wanted + +Ask if they want one more review, or if the work is done. + +**If user wants final review, provide it. Otherwise:** +Announce document completion. Provide a few final tips: + +- Consider linking this conversation in an appendix so readers can see how the doc was developed +- Use appendices to provide depth without bloating the main doc +- Update the doc as feedback is received from real readers + +## Tips for Effective Guidance + +**Tone:** + +- Be direct and procedural +- Explain rationale briefly when it affects user behavior +- Don't try to "sell" the approach - just execute it + +**Handling Deviations:** + +- If user wants to skip a stage: Ask if they want to skip this and write freeform +- If user seems frustrated: Acknowledge this is taking longer than expected. Suggest ways to move faster +- Always give user agency to adjust the process + +**Context Management:** + +- Throughout, if context is missing on something mentioned, proactively ask +- Don't let gaps accumulate - address them as they come up + +**Artifact Management:** + +- Use `create_file` for drafting full sections +- Use `str_replace` for all edits +- Provide artifact link after every change +- Never use artifacts for brainstorming lists - that's just conversation + +**Quality over Speed:** + +- Don't rush through stages +- Each iteration should make meaningful improvements +- The goal is a document that actually works for readers diff --git a/packages/mosaic/framework/skills/docx/LICENSE.txt b/packages/mosaic/framework/skills/docx/LICENSE.txt new file mode 100644 index 00000000..c55ab422 --- /dev/null +++ b/packages/mosaic/framework/skills/docx/LICENSE.txt @@ -0,0 +1,30 @@ +© 2025 Anthropic, PBC. All rights reserved. + +LICENSE: Use of these materials (including all code, prompts, assets, files, +and other components of this Skill) is governed by your agreement with +Anthropic regarding use of Anthropic's services. If no separate agreement +exists, use is governed by Anthropic's Consumer Terms of Service or +Commercial Terms of Service, as applicable: +https://www.anthropic.com/legal/consumer-terms +https://www.anthropic.com/legal/commercial-terms +Your applicable agreement is referred to as the "Agreement." "Services" are +as defined in the Agreement. + +ADDITIONAL RESTRICTIONS: Notwithstanding anything in the Agreement to the +contrary, users may not: + +- Extract these materials from the Services or retain copies of these + materials outside the Services +- Reproduce or copy these materials, except for temporary copies created + automatically during authorized use of the Services +- Create derivative works based on these materials +- Distribute, sublicense, or transfer these materials to any third party +- Make, offer to sell, sell, or import any inventions embodied in these + materials +- Reverse engineer, decompile, or disassemble these materials + +The receipt, viewing, or possession of these materials does not convey or +imply any license or right beyond those expressly granted above. + +Anthropic retains all right, title, and interest in these materials, +including all copyrights, patents, and other intellectual property rights. diff --git a/packages/mosaic/framework/skills/docx/SKILL.md b/packages/mosaic/framework/skills/docx/SKILL.md new file mode 100644 index 00000000..a096baa4 --- /dev/null +++ b/packages/mosaic/framework/skills/docx/SKILL.md @@ -0,0 +1,588 @@ +--- +name: docx +description: 'Use this skill whenever the user wants to create, read, edit, or manipulate Word documents (.docx files). Triggers include: any mention of "Word doc", "word document", ".docx", or requests to produce professional documents with formatting like tables of contents, headings, page numbers, or letterheads. Also use when extracting or reorganizing content from .docx files, inserting or replacing images in documents, performing find-and-replace in Word files, working with tracked changes or comments, or converting content into a polished Word document. If the user asks for a "report", "memo", "letter", "template", or similar deliverable as a Word or .docx file, use this skill. Do NOT use for PDFs, spreadsheets, Google Docs, or general coding tasks unrelated to document generation.' +license: Proprietary. LICENSE.txt has complete terms +--- + +# DOCX creation, editing, and analysis + +## Overview + +A .docx file is a ZIP archive containing XML files. + +## Quick Reference + +| Task | Approach | +| ---------------------- | ----------------------------------------------------------------- | +| Read/analyze content | `pandoc` or unpack for raw XML | +| Create new document | Use `docx-js` - see Creating New Documents below | +| Edit existing document | Unpack → edit XML → repack - see Editing Existing Documents below | + +### Converting .doc to .docx + +Legacy `.doc` files must be converted before editing: + +```bash +python scripts/office/soffice.py --headless --convert-to docx document.doc +``` + +### Reading Content + +```bash +# Text extraction with tracked changes +pandoc --track-changes=all document.docx -o output.md + +# Raw XML access +python scripts/office/unpack.py document.docx unpacked/ +``` + +### Converting to Images + +```bash +python scripts/office/soffice.py --headless --convert-to pdf document.docx +pdftoppm -jpeg -r 150 document.pdf page +``` + +### Accepting Tracked Changes + +To produce a clean document with all tracked changes accepted (requires LibreOffice): + +```bash +python scripts/accept_changes.py input.docx output.docx +``` + +--- + +## Creating New Documents + +Generate .docx files with JavaScript, then validate. Install: `npm install -g docx` + +### Setup + +```javascript +const { + Document, + Packer, + Paragraph, + TextRun, + Table, + TableRow, + TableCell, + ImageRun, + Header, + Footer, + AlignmentType, + PageOrientation, + LevelFormat, + ExternalHyperlink, + TableOfContents, + HeadingLevel, + BorderStyle, + WidthType, + ShadingType, + VerticalAlign, + PageNumber, + PageBreak, +} = require('docx'); + +const doc = new Document({ + sections: [ + { + children: [ + /* content */ + ], + }, + ], +}); +Packer.toBuffer(doc).then((buffer) => fs.writeFileSync('doc.docx', buffer)); +``` + +### Validation + +After creating the file, validate it. If validation fails, unpack, fix the XML, and repack. + +```bash +python scripts/office/validate.py doc.docx +``` + +### Page Size + +```javascript +// CRITICAL: docx-js defaults to A4, not US Letter +// Always set page size explicitly for consistent results +sections: [ + { + properties: { + page: { + size: { + width: 12240, // 8.5 inches in DXA + height: 15840, // 11 inches in DXA + }, + margin: { top: 1440, right: 1440, bottom: 1440, left: 1440 }, // 1 inch margins + }, + }, + children: [ + /* content */ + ], + }, +]; +``` + +**Common page sizes (DXA units, 1440 DXA = 1 inch):** + +| Paper | Width | Height | Content Width (1" margins) | +| ------------ | ------ | ------ | -------------------------- | +| US Letter | 12,240 | 15,840 | 9,360 | +| A4 (default) | 11,906 | 16,838 | 9,026 | + +**Landscape orientation:** docx-js swaps width/height internally, so pass portrait dimensions and let it handle the swap: + +```javascript +size: { + width: 12240, // Pass SHORT edge as width + height: 15840, // Pass LONG edge as height + orientation: PageOrientation.LANDSCAPE // docx-js swaps them in the XML +}, +// Content width = 15840 - left margin - right margin (uses the long edge) +``` + +### Styles (Override Built-in Headings) + +Use Arial as the default font (universally supported). Keep titles black for readability. + +```javascript +const doc = new Document({ + styles: { + default: { document: { run: { font: 'Arial', size: 24 } } }, // 12pt default + paragraphStyles: [ + // IMPORTANT: Use exact IDs to override built-in styles + { + id: 'Heading1', + name: 'Heading 1', + basedOn: 'Normal', + next: 'Normal', + quickFormat: true, + run: { size: 32, bold: true, font: 'Arial' }, + paragraph: { spacing: { before: 240, after: 240 }, outlineLevel: 0 }, + }, // outlineLevel required for TOC + { + id: 'Heading2', + name: 'Heading 2', + basedOn: 'Normal', + next: 'Normal', + quickFormat: true, + run: { size: 28, bold: true, font: 'Arial' }, + paragraph: { spacing: { before: 180, after: 180 }, outlineLevel: 1 }, + }, + ], + }, + sections: [ + { + children: [ + new Paragraph({ heading: HeadingLevel.HEADING_1, children: [new TextRun('Title')] }), + ], + }, + ], +}); +``` + +### Lists (NEVER use unicode bullets) + +```javascript +// ❌ WRONG - never manually insert bullet characters +new Paragraph({ children: [new TextRun('• Item')] }); // BAD +new Paragraph({ children: [new TextRun('\u2022 Item')] }); // BAD + +// ✅ CORRECT - use numbering config with LevelFormat.BULLET +const doc = new Document({ + numbering: { + config: [ + { + reference: 'bullets', + levels: [ + { + level: 0, + format: LevelFormat.BULLET, + text: '•', + alignment: AlignmentType.LEFT, + style: { paragraph: { indent: { left: 720, hanging: 360 } } }, + }, + ], + }, + { + reference: 'numbers', + levels: [ + { + level: 0, + format: LevelFormat.DECIMAL, + text: '%1.', + alignment: AlignmentType.LEFT, + style: { paragraph: { indent: { left: 720, hanging: 360 } } }, + }, + ], + }, + ], + }, + sections: [ + { + children: [ + new Paragraph({ + numbering: { reference: 'bullets', level: 0 }, + children: [new TextRun('Bullet item')], + }), + new Paragraph({ + numbering: { reference: 'numbers', level: 0 }, + children: [new TextRun('Numbered item')], + }), + ], + }, + ], +}); + +// ⚠️ Each reference creates INDEPENDENT numbering +// Same reference = continues (1,2,3 then 4,5,6) +// Different reference = restarts (1,2,3 then 1,2,3) +``` + +### Tables + +**CRITICAL: Tables need dual widths** - set both `columnWidths` on the table AND `width` on each cell. Without both, tables render incorrectly on some platforms. + +```javascript +// CRITICAL: Always set table width for consistent rendering +// CRITICAL: Use ShadingType.CLEAR (not SOLID) to prevent black backgrounds +const border = { style: BorderStyle.SINGLE, size: 1, color: 'CCCCCC' }; +const borders = { top: border, bottom: border, left: border, right: border }; + +new Table({ + width: { size: 9360, type: WidthType.DXA }, // Always use DXA (percentages break in Google Docs) + columnWidths: [4680, 4680], // Must sum to table width (DXA: 1440 = 1 inch) + rows: [ + new TableRow({ + children: [ + new TableCell({ + borders, + width: { size: 4680, type: WidthType.DXA }, // Also set on each cell + shading: { fill: 'D5E8F0', type: ShadingType.CLEAR }, // CLEAR not SOLID + margins: { top: 80, bottom: 80, left: 120, right: 120 }, // Cell padding (internal, not added to width) + children: [new Paragraph({ children: [new TextRun('Cell')] })], + }), + ], + }), + ], +}); +``` + +**Table width calculation:** + +Always use `WidthType.DXA` — `WidthType.PERCENTAGE` breaks in Google Docs. + +```javascript +// Table width = sum of columnWidths = content width +// US Letter with 1" margins: 12240 - 2880 = 9360 DXA +width: { size: 9360, type: WidthType.DXA }, +columnWidths: [7000, 2360] // Must sum to table width +``` + +**Width rules:** + +- **Always use `WidthType.DXA`** — never `WidthType.PERCENTAGE` (incompatible with Google Docs) +- Table width must equal the sum of `columnWidths` +- Cell `width` must match corresponding `columnWidth` +- Cell `margins` are internal padding - they reduce content area, not add to cell width +- For full-width tables: use content width (page width minus left and right margins) + +### Images + +```javascript +// CRITICAL: type parameter is REQUIRED +new Paragraph({ + children: [ + new ImageRun({ + type: 'png', // Required: png, jpg, jpeg, gif, bmp, svg + data: fs.readFileSync('image.png'), + transformation: { width: 200, height: 150 }, + altText: { title: 'Title', description: 'Desc', name: 'Name' }, // All three required + }), + ], +}); +``` + +### Page Breaks + +```javascript +// CRITICAL: PageBreak must be inside a Paragraph +new Paragraph({ children: [new PageBreak()] }); + +// Or use pageBreakBefore +new Paragraph({ pageBreakBefore: true, children: [new TextRun('New page')] }); +``` + +### Table of Contents + +```javascript +// CRITICAL: Headings must use HeadingLevel ONLY - no custom styles +new TableOfContents('Table of Contents', { hyperlink: true, headingStyleRange: '1-3' }); +``` + +### Headers/Footers + +```javascript +sections: [ + { + properties: { + page: { margin: { top: 1440, right: 1440, bottom: 1440, left: 1440 } }, // 1440 = 1 inch + }, + headers: { + default: new Header({ children: [new Paragraph({ children: [new TextRun('Header')] })] }), + }, + footers: { + default: new Footer({ + children: [ + new Paragraph({ + children: [new TextRun('Page '), new TextRun({ children: [PageNumber.CURRENT] })], + }), + ], + }), + }, + children: [ + /* content */ + ], + }, +]; +``` + +### Critical Rules for docx-js + +- **Set page size explicitly** - docx-js defaults to A4; use US Letter (12240 x 15840 DXA) for US documents +- **Landscape: pass portrait dimensions** - docx-js swaps width/height internally; pass short edge as `width`, long edge as `height`, and set `orientation: PageOrientation.LANDSCAPE` +- **Never use `\n`** - use separate Paragraph elements +- **Never use unicode bullets** - use `LevelFormat.BULLET` with numbering config +- **PageBreak must be in Paragraph** - standalone creates invalid XML +- **ImageRun requires `type`** - always specify png/jpg/etc +- **Always set table `width` with DXA** - never use `WidthType.PERCENTAGE` (breaks in Google Docs) +- **Tables need dual widths** - `columnWidths` array AND cell `width`, both must match +- **Table width = sum of columnWidths** - for DXA, ensure they add up exactly +- **Always add cell margins** - use `margins: { top: 80, bottom: 80, left: 120, right: 120 }` for readable padding +- **Use `ShadingType.CLEAR`** - never SOLID for table shading +- **TOC requires HeadingLevel only** - no custom styles on heading paragraphs +- **Override built-in styles** - use exact IDs: "Heading1", "Heading2", etc. +- **Include `outlineLevel`** - required for TOC (0 for H1, 1 for H2, etc.) + +--- + +## Editing Existing Documents + +**Follow all 3 steps in order.** + +### Step 1: Unpack + +```bash +python scripts/office/unpack.py document.docx unpacked/ +``` + +Extracts XML, pretty-prints, merges adjacent runs, and converts smart quotes to XML entities (`“` etc.) so they survive editing. Use `--merge-runs false` to skip run merging. + +### Step 2: Edit XML + +Edit files in `unpacked/word/`. See XML Reference below for patterns. + +**Use "Claude" as the author** for tracked changes and comments, unless the user explicitly requests use of a different name. + +**Use the Edit tool directly for string replacement. Do not write Python scripts.** Scripts introduce unnecessary complexity. The Edit tool shows exactly what is being replaced. + +**CRITICAL: Use smart quotes for new content.** When adding text with apostrophes or quotes, use XML entities to produce smart quotes: + +```xml + +Here’s a quote: “Hello” +``` + +| Entity | Character | +| ---------- | ----------------------------- | +| `‘` | ‘ (left single) | +| `’` | ’ (right single / apostrophe) | +| `“` | “ (left double) | +| `”` | ” (right double) | + +**Adding comments:** Use `comment.py` to handle boilerplate across multiple XML files (text must be pre-escaped XML): + +```bash +python scripts/comment.py unpacked/ 0 "Comment text with & and ’" +python scripts/comment.py unpacked/ 1 "Reply text" --parent 0 # reply to comment 0 +python scripts/comment.py unpacked/ 0 "Text" --author "Custom Author" # custom author name +``` + +Then add markers to document.xml (see Comments in XML Reference). + +### Step 3: Pack + +```bash +python scripts/office/pack.py unpacked/ output.docx --original document.docx +``` + +Validates with auto-repair, condenses XML, and creates DOCX. Use `--validate false` to skip. + +**Auto-repair will fix:** + +- `durableId` >= 0x7FFFFFFF (regenerates valid ID) +- Missing `xml:space="preserve"` on `` with whitespace + +**Auto-repair won't fix:** + +- Malformed XML, invalid element nesting, missing relationships, schema violations + +### Common Pitfalls + +- **Replace entire `` elements**: When adding tracked changes, replace the whole `...` block with `......` as siblings. Don't inject tracked change tags inside a run. +- **Preserve `` formatting**: Copy the original run's `` block into your tracked change runs to maintain bold, font size, etc. + +--- + +## XML Reference + +### Schema Compliance + +- **Element order in ``**: ``, ``, ``, ``, ``, `` last +- **Whitespace**: Add `xml:space="preserve"` to `` with leading/trailing spaces +- **RSIDs**: Must be 8-digit hex (e.g., `00AB1234`) + +### Tracked Changes + +**Insertion:** + +```xml + + inserted text + +``` + +**Deletion:** + +```xml + + deleted text + +``` + +**Inside ``**: Use `` instead of ``, and `` instead of ``. + +**Minimal edits** - only mark what changes: + +```xml + +The term is + + 30 + + + 60 + + days. +``` + +**Deleting entire paragraphs/list items** - when removing ALL content from a paragraph, also mark the paragraph mark as deleted so it merges with the next paragraph. Add `` inside ``: + +```xml + + + ... + + + + + + Entire paragraph content being deleted... + + +``` + +Without the `` in ``, accepting changes leaves an empty paragraph/list item. + +**Rejecting another author's insertion** - nest deletion inside their insertion: + +```xml + + + their inserted text + + +``` + +**Restoring another author's deletion** - add insertion after (don't modify their deletion): + +```xml + + deleted text + + + deleted text + +``` + +### Comments + +After running `comment.py` (see Step 2), add markers to document.xml. For replies, use `--parent` flag and nest markers inside the parent's. + +**CRITICAL: `` and `` are siblings of ``, never inside ``.** + +```xml + + + + deleted + + more text + + + + + + + text + + + + +``` + +### Images + +1. Add image file to `word/media/` +2. Add relationship to `word/_rels/document.xml.rels`: + +```xml + +``` + +3. Add content type to `[Content_Types].xml`: + +```xml + +``` + +4. Reference in document.xml: + +```xml + + + + + + + + + + + + +``` + +--- + +## Dependencies + +- **pandoc**: Text extraction +- **docx**: `npm install -g docx` (new documents) +- **LibreOffice**: PDF conversion (auto-configured for sandboxed environments via `scripts/office/soffice.py`) +- **Poppler**: `pdftoppm` for images diff --git a/packages/mosaic/framework/skills/docx/scripts/__init__.py b/packages/mosaic/framework/skills/docx/scripts/__init__.py new file mode 100755 index 00000000..8b137891 --- /dev/null +++ b/packages/mosaic/framework/skills/docx/scripts/__init__.py @@ -0,0 +1 @@ + diff --git a/packages/mosaic/framework/skills/docx/scripts/accept_changes.py b/packages/mosaic/framework/skills/docx/scripts/accept_changes.py new file mode 100755 index 00000000..8e363161 --- /dev/null +++ b/packages/mosaic/framework/skills/docx/scripts/accept_changes.py @@ -0,0 +1,135 @@ +"""Accept all tracked changes in a DOCX file using LibreOffice. + +Requires LibreOffice (soffice) to be installed. +""" + +import argparse +import logging +import shutil +import subprocess +from pathlib import Path + +from office.soffice import get_soffice_env + +logger = logging.getLogger(__name__) + +LIBREOFFICE_PROFILE = "/tmp/libreoffice_docx_profile" +MACRO_DIR = f"{LIBREOFFICE_PROFILE}/user/basic/Standard" + +ACCEPT_CHANGES_MACRO = """ + + + Sub AcceptAllTrackedChanges() + Dim document As Object + Dim dispatcher As Object + + document = ThisComponent.CurrentController.Frame + dispatcher = createUnoService("com.sun.star.frame.DispatchHelper") + + dispatcher.executeDispatch(document, ".uno:AcceptAllTrackedChanges", "", 0, Array()) + ThisComponent.store() + ThisComponent.close(True) + End Sub +""" + + +def accept_changes( + input_file: str, + output_file: str, +) -> tuple[None, str]: + input_path = Path(input_file) + output_path = Path(output_file) + + if not input_path.exists(): + return None, f"Error: Input file not found: {input_file}" + + if not input_path.suffix.lower() == ".docx": + return None, f"Error: Input file is not a DOCX file: {input_file}" + + try: + output_path.parent.mkdir(parents=True, exist_ok=True) + shutil.copy2(input_path, output_path) + except Exception as e: + return None, f"Error: Failed to copy input file to output location: {e}" + + if not _setup_libreoffice_macro(): + return None, "Error: Failed to setup LibreOffice macro" + + cmd = [ + "soffice", + "--headless", + f"-env:UserInstallation=file://{LIBREOFFICE_PROFILE}", + "--norestore", + "vnd.sun.star.script:Standard.Module1.AcceptAllTrackedChanges?language=Basic&location=application", + str(output_path.absolute()), + ] + + try: + result = subprocess.run( + cmd, + capture_output=True, + text=True, + timeout=30, + check=False, + env=get_soffice_env(), + ) + except subprocess.TimeoutExpired: + return ( + None, + f"Successfully accepted all tracked changes: {input_file} -> {output_file}", + ) + + if result.returncode != 0: + return None, f"Error: LibreOffice failed: {result.stderr}" + + return ( + None, + f"Successfully accepted all tracked changes: {input_file} -> {output_file}", + ) + + +def _setup_libreoffice_macro() -> bool: + macro_dir = Path(MACRO_DIR) + macro_file = macro_dir / "Module1.xba" + + if macro_file.exists() and "AcceptAllTrackedChanges" in macro_file.read_text(): + return True + + if not macro_dir.exists(): + subprocess.run( + [ + "soffice", + "--headless", + f"-env:UserInstallation=file://{LIBREOFFICE_PROFILE}", + "--terminate_after_init", + ], + capture_output=True, + timeout=10, + check=False, + env=get_soffice_env(), + ) + macro_dir.mkdir(parents=True, exist_ok=True) + + try: + macro_file.write_text(ACCEPT_CHANGES_MACRO) + return True + except Exception as e: + logger.warning(f"Failed to setup LibreOffice macro: {e}") + return False + + +if __name__ == "__main__": + parser = argparse.ArgumentParser( + description="Accept all tracked changes in a DOCX file" + ) + parser.add_argument("input_file", help="Input DOCX file with tracked changes") + parser.add_argument( + "output_file", help="Output DOCX file (clean, no tracked changes)" + ) + args = parser.parse_args() + + _, message = accept_changes(args.input_file, args.output_file) + print(message) + + if "Error" in message: + raise SystemExit(1) diff --git a/packages/mosaic/framework/skills/docx/scripts/comment.py b/packages/mosaic/framework/skills/docx/scripts/comment.py new file mode 100755 index 00000000..36e1c935 --- /dev/null +++ b/packages/mosaic/framework/skills/docx/scripts/comment.py @@ -0,0 +1,318 @@ +"""Add comments to DOCX documents. + +Usage: + python comment.py unpacked/ 0 "Comment text" + python comment.py unpacked/ 1 "Reply text" --parent 0 + +Text should be pre-escaped XML (e.g., & for &, ’ for smart quotes). + +After running, add markers to document.xml: + + ... commented content ... + + +""" + +import argparse +import random +import shutil +import sys +from datetime import datetime, timezone +from pathlib import Path + +import defusedxml.minidom + +TEMPLATE_DIR = Path(__file__).parent / "templates" +NS = { + "w": "http://schemas.openxmlformats.org/wordprocessingml/2006/main", + "w14": "http://schemas.microsoft.com/office/word/2010/wordml", + "w15": "http://schemas.microsoft.com/office/word/2012/wordml", + "w16cid": "http://schemas.microsoft.com/office/word/2016/wordml/cid", + "w16cex": "http://schemas.microsoft.com/office/word/2018/wordml/cex", +} + +COMMENT_XML = """\ + + + + + + + + + + + + + {text} + + +""" + +COMMENT_MARKER_TEMPLATE = """ +Add to document.xml (markers must be direct children of w:p, never inside w:r): + + ... + + """ + +REPLY_MARKER_TEMPLATE = """ +Nest markers inside parent {pid}'s markers (markers must be direct children of w:p, never inside w:r): + + ... + + + """ + + +def _generate_hex_id() -> str: + return f"{random.randint(0, 0x7FFFFFFE):08X}" + + +SMART_QUOTE_ENTITIES = { + "\u201c": "“", + "\u201d": "”", + "\u2018": "‘", + "\u2019": "’", +} + + +def _encode_smart_quotes(text: str) -> str: + for char, entity in SMART_QUOTE_ENTITIES.items(): + text = text.replace(char, entity) + return text + + +def _append_xml(xml_path: Path, root_tag: str, content: str) -> None: + dom = defusedxml.minidom.parseString(xml_path.read_text(encoding="utf-8")) + root = dom.getElementsByTagName(root_tag)[0] + ns_attrs = " ".join(f'xmlns:{k}="{v}"' for k, v in NS.items()) + wrapper_dom = defusedxml.minidom.parseString(f"{content}") + for child in wrapper_dom.documentElement.childNodes: + if child.nodeType == child.ELEMENT_NODE: + root.appendChild(dom.importNode(child, True)) + output = _encode_smart_quotes(dom.toxml(encoding="UTF-8").decode("utf-8")) + xml_path.write_text(output, encoding="utf-8") + + +def _find_para_id(comments_path: Path, comment_id: int) -> str | None: + dom = defusedxml.minidom.parseString(comments_path.read_text(encoding="utf-8")) + for c in dom.getElementsByTagName("w:comment"): + if c.getAttribute("w:id") == str(comment_id): + for p in c.getElementsByTagName("w:p"): + if pid := p.getAttribute("w14:paraId"): + return pid + return None + + +def _get_next_rid(rels_path: Path) -> int: + dom = defusedxml.minidom.parseString(rels_path.read_text(encoding="utf-8")) + max_rid = 0 + for rel in dom.getElementsByTagName("Relationship"): + rid = rel.getAttribute("Id") + if rid and rid.startswith("rId"): + try: + max_rid = max(max_rid, int(rid[3:])) + except ValueError: + pass + return max_rid + 1 + + +def _has_relationship(rels_path: Path, target: str) -> bool: + dom = defusedxml.minidom.parseString(rels_path.read_text(encoding="utf-8")) + for rel in dom.getElementsByTagName("Relationship"): + if rel.getAttribute("Target") == target: + return True + return False + + +def _has_content_type(ct_path: Path, part_name: str) -> bool: + dom = defusedxml.minidom.parseString(ct_path.read_text(encoding="utf-8")) + for override in dom.getElementsByTagName("Override"): + if override.getAttribute("PartName") == part_name: + return True + return False + + +def _ensure_comment_relationships(unpacked_dir: Path) -> None: + rels_path = unpacked_dir / "word" / "_rels" / "document.xml.rels" + if not rels_path.exists(): + return + + if _has_relationship(rels_path, "comments.xml"): + return + + dom = defusedxml.minidom.parseString(rels_path.read_text(encoding="utf-8")) + root = dom.documentElement + next_rid = _get_next_rid(rels_path) + + rels = [ + ( + "http://schemas.openxmlformats.org/officeDocument/2006/relationships/comments", + "comments.xml", + ), + ( + "http://schemas.microsoft.com/office/2011/relationships/commentsExtended", + "commentsExtended.xml", + ), + ( + "http://schemas.microsoft.com/office/2016/09/relationships/commentsIds", + "commentsIds.xml", + ), + ( + "http://schemas.microsoft.com/office/2018/08/relationships/commentsExtensible", + "commentsExtensible.xml", + ), + ] + + for rel_type, target in rels: + rel = dom.createElement("Relationship") + rel.setAttribute("Id", f"rId{next_rid}") + rel.setAttribute("Type", rel_type) + rel.setAttribute("Target", target) + root.appendChild(rel) + next_rid += 1 + + rels_path.write_bytes(dom.toxml(encoding="UTF-8")) + + +def _ensure_comment_content_types(unpacked_dir: Path) -> None: + ct_path = unpacked_dir / "[Content_Types].xml" + if not ct_path.exists(): + return + + if _has_content_type(ct_path, "/word/comments.xml"): + return + + dom = defusedxml.minidom.parseString(ct_path.read_text(encoding="utf-8")) + root = dom.documentElement + + overrides = [ + ( + "/word/comments.xml", + "application/vnd.openxmlformats-officedocument.wordprocessingml.comments+xml", + ), + ( + "/word/commentsExtended.xml", + "application/vnd.openxmlformats-officedocument.wordprocessingml.commentsExtended+xml", + ), + ( + "/word/commentsIds.xml", + "application/vnd.openxmlformats-officedocument.wordprocessingml.commentsIds+xml", + ), + ( + "/word/commentsExtensible.xml", + "application/vnd.openxmlformats-officedocument.wordprocessingml.commentsExtensible+xml", + ), + ] + + for part_name, content_type in overrides: + override = dom.createElement("Override") + override.setAttribute("PartName", part_name) + override.setAttribute("ContentType", content_type) + root.appendChild(override) + + ct_path.write_bytes(dom.toxml(encoding="UTF-8")) + + +def add_comment( + unpacked_dir: str, + comment_id: int, + text: str, + author: str = "Claude", + initials: str = "C", + parent_id: int | None = None, +) -> tuple[str, str]: + word = Path(unpacked_dir) / "word" + if not word.exists(): + return "", f"Error: {word} not found" + + para_id, durable_id = _generate_hex_id(), _generate_hex_id() + ts = datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ") + + comments = word / "comments.xml" + first_comment = not comments.exists() + if first_comment: + shutil.copy(TEMPLATE_DIR / "comments.xml", comments) + _ensure_comment_relationships(Path(unpacked_dir)) + _ensure_comment_content_types(Path(unpacked_dir)) + _append_xml( + comments, + "w:comments", + COMMENT_XML.format( + id=comment_id, + author=author, + date=ts, + initials=initials, + para_id=para_id, + text=text, + ), + ) + + ext = word / "commentsExtended.xml" + if not ext.exists(): + shutil.copy(TEMPLATE_DIR / "commentsExtended.xml", ext) + if parent_id is not None: + parent_para = _find_para_id(comments, parent_id) + if not parent_para: + return "", f"Error: Parent comment {parent_id} not found" + _append_xml( + ext, + "w15:commentsEx", + f'', + ) + else: + _append_xml( + ext, + "w15:commentsEx", + f'', + ) + + ids = word / "commentsIds.xml" + if not ids.exists(): + shutil.copy(TEMPLATE_DIR / "commentsIds.xml", ids) + _append_xml( + ids, + "w16cid:commentsIds", + f'', + ) + + extensible = word / "commentsExtensible.xml" + if not extensible.exists(): + shutil.copy(TEMPLATE_DIR / "commentsExtensible.xml", extensible) + _append_xml( + extensible, + "w16cex:commentsExtensible", + f'', + ) + + action = "reply" if parent_id is not None else "comment" + return para_id, f"Added {action} {comment_id} (para_id={para_id})" + + +if __name__ == "__main__": + p = argparse.ArgumentParser(description="Add comments to DOCX documents") + p.add_argument("unpacked_dir", help="Unpacked DOCX directory") + p.add_argument("comment_id", type=int, help="Comment ID (must be unique)") + p.add_argument("text", help="Comment text") + p.add_argument("--author", default="Claude", help="Author name") + p.add_argument("--initials", default="C", help="Author initials") + p.add_argument("--parent", type=int, help="Parent comment ID (for replies)") + args = p.parse_args() + + para_id, msg = add_comment( + args.unpacked_dir, + args.comment_id, + args.text, + args.author, + args.initials, + args.parent, + ) + print(msg) + if "Error" in msg: + sys.exit(1) + cid = args.comment_id + if args.parent is not None: + print(REPLY_MARKER_TEMPLATE.format(pid=args.parent, cid=cid)) + else: + print(COMMENT_MARKER_TEMPLATE.format(cid=cid)) diff --git a/packages/mosaic/framework/skills/docx/scripts/office/helpers/__init__.py b/packages/mosaic/framework/skills/docx/scripts/office/helpers/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/packages/mosaic/framework/skills/docx/scripts/office/helpers/merge_runs.py b/packages/mosaic/framework/skills/docx/scripts/office/helpers/merge_runs.py new file mode 100644 index 00000000..ad7c25ee --- /dev/null +++ b/packages/mosaic/framework/skills/docx/scripts/office/helpers/merge_runs.py @@ -0,0 +1,199 @@ +"""Merge adjacent runs with identical formatting in DOCX. + +Merges adjacent elements that have identical properties. +Works on runs in paragraphs and inside tracked changes (, ). + +Also: +- Removes rsid attributes from runs (revision metadata that doesn't affect rendering) +- Removes proofErr elements (spell/grammar markers that block merging) +""" + +from pathlib import Path + +import defusedxml.minidom + + +def merge_runs(input_dir: str) -> tuple[int, str]: + doc_xml = Path(input_dir) / "word" / "document.xml" + + if not doc_xml.exists(): + return 0, f"Error: {doc_xml} not found" + + try: + dom = defusedxml.minidom.parseString(doc_xml.read_text(encoding="utf-8")) + root = dom.documentElement + + _remove_elements(root, "proofErr") + _strip_run_rsid_attrs(root) + + containers = {run.parentNode for run in _find_elements(root, "r")} + + merge_count = 0 + for container in containers: + merge_count += _merge_runs_in(container) + + doc_xml.write_bytes(dom.toxml(encoding="UTF-8")) + return merge_count, f"Merged {merge_count} runs" + + except Exception as e: + return 0, f"Error: {e}" + + + + +def _find_elements(root, tag: str) -> list: + results = [] + + def traverse(node): + if node.nodeType == node.ELEMENT_NODE: + name = node.localName or node.tagName + if name == tag or name.endswith(f":{tag}"): + results.append(node) + for child in node.childNodes: + traverse(child) + + traverse(root) + return results + + +def _get_child(parent, tag: str): + for child in parent.childNodes: + if child.nodeType == child.ELEMENT_NODE: + name = child.localName or child.tagName + if name == tag or name.endswith(f":{tag}"): + return child + return None + + +def _get_children(parent, tag: str) -> list: + results = [] + for child in parent.childNodes: + if child.nodeType == child.ELEMENT_NODE: + name = child.localName or child.tagName + if name == tag or name.endswith(f":{tag}"): + results.append(child) + return results + + +def _is_adjacent(elem1, elem2) -> bool: + node = elem1.nextSibling + while node: + if node == elem2: + return True + if node.nodeType == node.ELEMENT_NODE: + return False + if node.nodeType == node.TEXT_NODE and node.data.strip(): + return False + node = node.nextSibling + return False + + + + +def _remove_elements(root, tag: str): + for elem in _find_elements(root, tag): + if elem.parentNode: + elem.parentNode.removeChild(elem) + + +def _strip_run_rsid_attrs(root): + for run in _find_elements(root, "r"): + for attr in list(run.attributes.values()): + if "rsid" in attr.name.lower(): + run.removeAttribute(attr.name) + + + + +def _merge_runs_in(container) -> int: + merge_count = 0 + run = _first_child_run(container) + + while run: + while True: + next_elem = _next_element_sibling(run) + if next_elem and _is_run(next_elem) and _can_merge(run, next_elem): + _merge_run_content(run, next_elem) + container.removeChild(next_elem) + merge_count += 1 + else: + break + + _consolidate_text(run) + run = _next_sibling_run(run) + + return merge_count + + +def _first_child_run(container): + for child in container.childNodes: + if child.nodeType == child.ELEMENT_NODE and _is_run(child): + return child + return None + + +def _next_element_sibling(node): + sibling = node.nextSibling + while sibling: + if sibling.nodeType == sibling.ELEMENT_NODE: + return sibling + sibling = sibling.nextSibling + return None + + +def _next_sibling_run(node): + sibling = node.nextSibling + while sibling: + if sibling.nodeType == sibling.ELEMENT_NODE: + if _is_run(sibling): + return sibling + sibling = sibling.nextSibling + return None + + +def _is_run(node) -> bool: + name = node.localName or node.tagName + return name == "r" or name.endswith(":r") + + +def _can_merge(run1, run2) -> bool: + rpr1 = _get_child(run1, "rPr") + rpr2 = _get_child(run2, "rPr") + + if (rpr1 is None) != (rpr2 is None): + return False + if rpr1 is None: + return True + return rpr1.toxml() == rpr2.toxml() + + +def _merge_run_content(target, source): + for child in list(source.childNodes): + if child.nodeType == child.ELEMENT_NODE: + name = child.localName or child.tagName + if name != "rPr" and not name.endswith(":rPr"): + target.appendChild(child) + + +def _consolidate_text(run): + t_elements = _get_children(run, "t") + + for i in range(len(t_elements) - 1, 0, -1): + curr, prev = t_elements[i], t_elements[i - 1] + + if _is_adjacent(prev, curr): + prev_text = prev.firstChild.data if prev.firstChild else "" + curr_text = curr.firstChild.data if curr.firstChild else "" + merged = prev_text + curr_text + + if prev.firstChild: + prev.firstChild.data = merged + else: + prev.appendChild(run.ownerDocument.createTextNode(merged)) + + if merged.startswith(" ") or merged.endswith(" "): + prev.setAttribute("xml:space", "preserve") + elif prev.hasAttribute("xml:space"): + prev.removeAttribute("xml:space") + + run.removeChild(curr) diff --git a/packages/mosaic/framework/skills/docx/scripts/office/helpers/simplify_redlines.py b/packages/mosaic/framework/skills/docx/scripts/office/helpers/simplify_redlines.py new file mode 100644 index 00000000..db963bb9 --- /dev/null +++ b/packages/mosaic/framework/skills/docx/scripts/office/helpers/simplify_redlines.py @@ -0,0 +1,197 @@ +"""Simplify tracked changes by merging adjacent w:ins or w:del elements. + +Merges adjacent elements from the same author into a single element. +Same for elements. This makes heavily-redlined documents easier to +work with by reducing the number of tracked change wrappers. + +Rules: +- Only merges w:ins with w:ins, w:del with w:del (same element type) +- Only merges if same author (ignores timestamp differences) +- Only merges if truly adjacent (only whitespace between them) +""" + +import xml.etree.ElementTree as ET +import zipfile +from pathlib import Path + +import defusedxml.minidom + +WORD_NS = "http://schemas.openxmlformats.org/wordprocessingml/2006/main" + + +def simplify_redlines(input_dir: str) -> tuple[int, str]: + doc_xml = Path(input_dir) / "word" / "document.xml" + + if not doc_xml.exists(): + return 0, f"Error: {doc_xml} not found" + + try: + dom = defusedxml.minidom.parseString(doc_xml.read_text(encoding="utf-8")) + root = dom.documentElement + + merge_count = 0 + + containers = _find_elements(root, "p") + _find_elements(root, "tc") + + for container in containers: + merge_count += _merge_tracked_changes_in(container, "ins") + merge_count += _merge_tracked_changes_in(container, "del") + + doc_xml.write_bytes(dom.toxml(encoding="UTF-8")) + return merge_count, f"Simplified {merge_count} tracked changes" + + except Exception as e: + return 0, f"Error: {e}" + + +def _merge_tracked_changes_in(container, tag: str) -> int: + merge_count = 0 + + tracked = [ + child + for child in container.childNodes + if child.nodeType == child.ELEMENT_NODE and _is_element(child, tag) + ] + + if len(tracked) < 2: + return 0 + + i = 0 + while i < len(tracked) - 1: + curr = tracked[i] + next_elem = tracked[i + 1] + + if _can_merge_tracked(curr, next_elem): + _merge_tracked_content(curr, next_elem) + container.removeChild(next_elem) + tracked.pop(i + 1) + merge_count += 1 + else: + i += 1 + + return merge_count + + +def _is_element(node, tag: str) -> bool: + name = node.localName or node.tagName + return name == tag or name.endswith(f":{tag}") + + +def _get_author(elem) -> str: + author = elem.getAttribute("w:author") + if not author: + for attr in elem.attributes.values(): + if attr.localName == "author" or attr.name.endswith(":author"): + return attr.value + return author + + +def _can_merge_tracked(elem1, elem2) -> bool: + if _get_author(elem1) != _get_author(elem2): + return False + + node = elem1.nextSibling + while node and node != elem2: + if node.nodeType == node.ELEMENT_NODE: + return False + if node.nodeType == node.TEXT_NODE and node.data.strip(): + return False + node = node.nextSibling + + return True + + +def _merge_tracked_content(target, source): + while source.firstChild: + child = source.firstChild + source.removeChild(child) + target.appendChild(child) + + +def _find_elements(root, tag: str) -> list: + results = [] + + def traverse(node): + if node.nodeType == node.ELEMENT_NODE: + name = node.localName or node.tagName + if name == tag or name.endswith(f":{tag}"): + results.append(node) + for child in node.childNodes: + traverse(child) + + traverse(root) + return results + + +def get_tracked_change_authors(doc_xml_path: Path) -> dict[str, int]: + if not doc_xml_path.exists(): + return {} + + try: + tree = ET.parse(doc_xml_path) + root = tree.getroot() + except ET.ParseError: + return {} + + namespaces = {"w": WORD_NS} + author_attr = f"{{{WORD_NS}}}author" + + authors: dict[str, int] = {} + for tag in ["ins", "del"]: + for elem in root.findall(f".//w:{tag}", namespaces): + author = elem.get(author_attr) + if author: + authors[author] = authors.get(author, 0) + 1 + + return authors + + +def _get_authors_from_docx(docx_path: Path) -> dict[str, int]: + try: + with zipfile.ZipFile(docx_path, "r") as zf: + if "word/document.xml" not in zf.namelist(): + return {} + with zf.open("word/document.xml") as f: + tree = ET.parse(f) + root = tree.getroot() + + namespaces = {"w": WORD_NS} + author_attr = f"{{{WORD_NS}}}author" + + authors: dict[str, int] = {} + for tag in ["ins", "del"]: + for elem in root.findall(f".//w:{tag}", namespaces): + author = elem.get(author_attr) + if author: + authors[author] = authors.get(author, 0) + 1 + return authors + except (zipfile.BadZipFile, ET.ParseError): + return {} + + +def infer_author(modified_dir: Path, original_docx: Path, default: str = "Claude") -> str: + modified_xml = modified_dir / "word" / "document.xml" + modified_authors = get_tracked_change_authors(modified_xml) + + if not modified_authors: + return default + + original_authors = _get_authors_from_docx(original_docx) + + new_changes: dict[str, int] = {} + for author, count in modified_authors.items(): + original_count = original_authors.get(author, 0) + diff = count - original_count + if diff > 0: + new_changes[author] = diff + + if not new_changes: + return default + + if len(new_changes) == 1: + return next(iter(new_changes)) + + raise ValueError( + f"Multiple authors added new changes: {new_changes}. " + "Cannot infer which author to validate." + ) diff --git a/packages/mosaic/framework/skills/docx/scripts/office/pack.py b/packages/mosaic/framework/skills/docx/scripts/office/pack.py new file mode 100755 index 00000000..db29ed8b --- /dev/null +++ b/packages/mosaic/framework/skills/docx/scripts/office/pack.py @@ -0,0 +1,159 @@ +"""Pack a directory into a DOCX, PPTX, or XLSX file. + +Validates with auto-repair, condenses XML formatting, and creates the Office file. + +Usage: + python pack.py [--original ] [--validate true|false] + +Examples: + python pack.py unpacked/ output.docx --original input.docx + python pack.py unpacked/ output.pptx --validate false +""" + +import argparse +import sys +import shutil +import tempfile +import zipfile +from pathlib import Path + +import defusedxml.minidom + +from validators import DOCXSchemaValidator, PPTXSchemaValidator, RedliningValidator + +def pack( + input_directory: str, + output_file: str, + original_file: str | None = None, + validate: bool = True, + infer_author_func=None, +) -> tuple[None, str]: + input_dir = Path(input_directory) + output_path = Path(output_file) + suffix = output_path.suffix.lower() + + if not input_dir.is_dir(): + return None, f"Error: {input_dir} is not a directory" + + if suffix not in {".docx", ".pptx", ".xlsx"}: + return None, f"Error: {output_file} must be a .docx, .pptx, or .xlsx file" + + if validate and original_file: + original_path = Path(original_file) + if original_path.exists(): + success, output = _run_validation( + input_dir, original_path, suffix, infer_author_func + ) + if output: + print(output) + if not success: + return None, f"Error: Validation failed for {input_dir}" + + with tempfile.TemporaryDirectory() as temp_dir: + temp_content_dir = Path(temp_dir) / "content" + shutil.copytree(input_dir, temp_content_dir) + + for pattern in ["*.xml", "*.rels"]: + for xml_file in temp_content_dir.rglob(pattern): + _condense_xml(xml_file) + + output_path.parent.mkdir(parents=True, exist_ok=True) + with zipfile.ZipFile(output_path, "w", zipfile.ZIP_DEFLATED) as zf: + for f in temp_content_dir.rglob("*"): + if f.is_file(): + zf.write(f, f.relative_to(temp_content_dir)) + + return None, f"Successfully packed {input_dir} to {output_file}" + + +def _run_validation( + unpacked_dir: Path, + original_file: Path, + suffix: str, + infer_author_func=None, +) -> tuple[bool, str | None]: + output_lines = [] + validators = [] + + if suffix == ".docx": + author = "Claude" + if infer_author_func: + try: + author = infer_author_func(unpacked_dir, original_file) + except ValueError as e: + print(f"Warning: {e} Using default author 'Claude'.", file=sys.stderr) + + validators = [ + DOCXSchemaValidator(unpacked_dir, original_file), + RedliningValidator(unpacked_dir, original_file, author=author), + ] + elif suffix == ".pptx": + validators = [PPTXSchemaValidator(unpacked_dir, original_file)] + + if not validators: + return True, None + + total_repairs = sum(v.repair() for v in validators) + if total_repairs: + output_lines.append(f"Auto-repaired {total_repairs} issue(s)") + + success = all(v.validate() for v in validators) + + if success: + output_lines.append("All validations PASSED!") + + return success, "\n".join(output_lines) if output_lines else None + + +def _condense_xml(xml_file: Path) -> None: + try: + with open(xml_file, encoding="utf-8") as f: + dom = defusedxml.minidom.parse(f) + + for element in dom.getElementsByTagName("*"): + if element.tagName.endswith(":t"): + continue + + for child in list(element.childNodes): + if ( + child.nodeType == child.TEXT_NODE + and child.nodeValue + and child.nodeValue.strip() == "" + ) or child.nodeType == child.COMMENT_NODE: + element.removeChild(child) + + xml_file.write_bytes(dom.toxml(encoding="UTF-8")) + except Exception as e: + print(f"ERROR: Failed to parse {xml_file.name}: {e}", file=sys.stderr) + raise + + +if __name__ == "__main__": + parser = argparse.ArgumentParser( + description="Pack a directory into a DOCX, PPTX, or XLSX file" + ) + parser.add_argument("input_directory", help="Unpacked Office document directory") + parser.add_argument("output_file", help="Output Office file (.docx/.pptx/.xlsx)") + parser.add_argument( + "--original", + help="Original file for validation comparison", + ) + parser.add_argument( + "--validate", + type=lambda x: x.lower() == "true", + default=True, + metavar="true|false", + help="Run validation with auto-repair (default: true)", + ) + args = parser.parse_args() + + _, message = pack( + args.input_directory, + args.output_file, + original_file=args.original, + validate=args.validate, + ) + print(message) + + if "Error" in message: + sys.exit(1) diff --git a/packages/mosaic/framework/skills/docx/scripts/office/schemas/ISO-IEC29500-4_2016/dml-chart.xsd b/packages/mosaic/framework/skills/docx/scripts/office/schemas/ISO-IEC29500-4_2016/dml-chart.xsd new file mode 100644 index 00000000..6454ef9a --- /dev/null +++ b/packages/mosaic/framework/skills/docx/scripts/office/schemas/ISO-IEC29500-4_2016/dml-chart.xsd @@ -0,0 +1,1499 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/packages/mosaic/framework/skills/docx/scripts/office/schemas/ISO-IEC29500-4_2016/dml-chartDrawing.xsd b/packages/mosaic/framework/skills/docx/scripts/office/schemas/ISO-IEC29500-4_2016/dml-chartDrawing.xsd new file mode 100644 index 00000000..afa4f463 --- /dev/null +++ b/packages/mosaic/framework/skills/docx/scripts/office/schemas/ISO-IEC29500-4_2016/dml-chartDrawing.xsd @@ -0,0 +1,146 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/packages/mosaic/framework/skills/docx/scripts/office/schemas/ISO-IEC29500-4_2016/dml-diagram.xsd b/packages/mosaic/framework/skills/docx/scripts/office/schemas/ISO-IEC29500-4_2016/dml-diagram.xsd new file mode 100644 index 00000000..64e66b8a --- /dev/null +++ b/packages/mosaic/framework/skills/docx/scripts/office/schemas/ISO-IEC29500-4_2016/dml-diagram.xsd @@ -0,0 +1,1085 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/packages/mosaic/framework/skills/docx/scripts/office/schemas/ISO-IEC29500-4_2016/dml-lockedCanvas.xsd b/packages/mosaic/framework/skills/docx/scripts/office/schemas/ISO-IEC29500-4_2016/dml-lockedCanvas.xsd new file mode 100644 index 00000000..687eea82 --- /dev/null +++ b/packages/mosaic/framework/skills/docx/scripts/office/schemas/ISO-IEC29500-4_2016/dml-lockedCanvas.xsd @@ -0,0 +1,11 @@ + + + + + diff --git a/packages/mosaic/framework/skills/docx/scripts/office/schemas/ISO-IEC29500-4_2016/dml-main.xsd b/packages/mosaic/framework/skills/docx/scripts/office/schemas/ISO-IEC29500-4_2016/dml-main.xsd new file mode 100644 index 00000000..6ac81b06 --- /dev/null +++ b/packages/mosaic/framework/skills/docx/scripts/office/schemas/ISO-IEC29500-4_2016/dml-main.xsd @@ -0,0 +1,3081 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/packages/mosaic/framework/skills/docx/scripts/office/schemas/ISO-IEC29500-4_2016/dml-picture.xsd b/packages/mosaic/framework/skills/docx/scripts/office/schemas/ISO-IEC29500-4_2016/dml-picture.xsd new file mode 100644 index 00000000..1dbf0514 --- /dev/null +++ b/packages/mosaic/framework/skills/docx/scripts/office/schemas/ISO-IEC29500-4_2016/dml-picture.xsd @@ -0,0 +1,23 @@ + + + + + + + + + + + + + + + + + + diff --git a/packages/mosaic/framework/skills/docx/scripts/office/schemas/ISO-IEC29500-4_2016/dml-spreadsheetDrawing.xsd b/packages/mosaic/framework/skills/docx/scripts/office/schemas/ISO-IEC29500-4_2016/dml-spreadsheetDrawing.xsd new file mode 100644 index 00000000..f1af17db --- /dev/null +++ b/packages/mosaic/framework/skills/docx/scripts/office/schemas/ISO-IEC29500-4_2016/dml-spreadsheetDrawing.xsd @@ -0,0 +1,185 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/packages/mosaic/framework/skills/docx/scripts/office/schemas/ISO-IEC29500-4_2016/dml-wordprocessingDrawing.xsd b/packages/mosaic/framework/skills/docx/scripts/office/schemas/ISO-IEC29500-4_2016/dml-wordprocessingDrawing.xsd new file mode 100644 index 00000000..0a185ab6 --- /dev/null +++ b/packages/mosaic/framework/skills/docx/scripts/office/schemas/ISO-IEC29500-4_2016/dml-wordprocessingDrawing.xsd @@ -0,0 +1,287 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/packages/mosaic/framework/skills/docx/scripts/office/schemas/ISO-IEC29500-4_2016/pml.xsd b/packages/mosaic/framework/skills/docx/scripts/office/schemas/ISO-IEC29500-4_2016/pml.xsd new file mode 100644 index 00000000..14ef4888 --- /dev/null +++ b/packages/mosaic/framework/skills/docx/scripts/office/schemas/ISO-IEC29500-4_2016/pml.xsd @@ -0,0 +1,1676 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/packages/mosaic/framework/skills/docx/scripts/office/schemas/ISO-IEC29500-4_2016/shared-additionalCharacteristics.xsd b/packages/mosaic/framework/skills/docx/scripts/office/schemas/ISO-IEC29500-4_2016/shared-additionalCharacteristics.xsd new file mode 100644 index 00000000..c20f3bf1 --- /dev/null +++ b/packages/mosaic/framework/skills/docx/scripts/office/schemas/ISO-IEC29500-4_2016/shared-additionalCharacteristics.xsd @@ -0,0 +1,28 @@ + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/packages/mosaic/framework/skills/docx/scripts/office/schemas/ISO-IEC29500-4_2016/shared-bibliography.xsd b/packages/mosaic/framework/skills/docx/scripts/office/schemas/ISO-IEC29500-4_2016/shared-bibliography.xsd new file mode 100644 index 00000000..ac602522 --- /dev/null +++ b/packages/mosaic/framework/skills/docx/scripts/office/schemas/ISO-IEC29500-4_2016/shared-bibliography.xsd @@ -0,0 +1,144 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/packages/mosaic/framework/skills/docx/scripts/office/schemas/ISO-IEC29500-4_2016/shared-commonSimpleTypes.xsd b/packages/mosaic/framework/skills/docx/scripts/office/schemas/ISO-IEC29500-4_2016/shared-commonSimpleTypes.xsd new file mode 100644 index 00000000..424b8ba8 --- /dev/null +++ b/packages/mosaic/framework/skills/docx/scripts/office/schemas/ISO-IEC29500-4_2016/shared-commonSimpleTypes.xsd @@ -0,0 +1,174 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/packages/mosaic/framework/skills/docx/scripts/office/schemas/ISO-IEC29500-4_2016/shared-customXmlDataProperties.xsd b/packages/mosaic/framework/skills/docx/scripts/office/schemas/ISO-IEC29500-4_2016/shared-customXmlDataProperties.xsd new file mode 100644 index 00000000..2bddce29 --- /dev/null +++ b/packages/mosaic/framework/skills/docx/scripts/office/schemas/ISO-IEC29500-4_2016/shared-customXmlDataProperties.xsd @@ -0,0 +1,25 @@ + + + + + + + + + + + + + + + + + + + diff --git a/packages/mosaic/framework/skills/docx/scripts/office/schemas/ISO-IEC29500-4_2016/shared-customXmlSchemaProperties.xsd b/packages/mosaic/framework/skills/docx/scripts/office/schemas/ISO-IEC29500-4_2016/shared-customXmlSchemaProperties.xsd new file mode 100644 index 00000000..8a8c18ba --- /dev/null +++ b/packages/mosaic/framework/skills/docx/scripts/office/schemas/ISO-IEC29500-4_2016/shared-customXmlSchemaProperties.xsd @@ -0,0 +1,18 @@ + + + + + + + + + + + + + + + diff --git a/packages/mosaic/framework/skills/docx/scripts/office/schemas/ISO-IEC29500-4_2016/shared-documentPropertiesCustom.xsd b/packages/mosaic/framework/skills/docx/scripts/office/schemas/ISO-IEC29500-4_2016/shared-documentPropertiesCustom.xsd new file mode 100644 index 00000000..5c42706a --- /dev/null +++ b/packages/mosaic/framework/skills/docx/scripts/office/schemas/ISO-IEC29500-4_2016/shared-documentPropertiesCustom.xsd @@ -0,0 +1,59 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/packages/mosaic/framework/skills/docx/scripts/office/schemas/ISO-IEC29500-4_2016/shared-documentPropertiesExtended.xsd b/packages/mosaic/framework/skills/docx/scripts/office/schemas/ISO-IEC29500-4_2016/shared-documentPropertiesExtended.xsd new file mode 100644 index 00000000..853c341c --- /dev/null +++ b/packages/mosaic/framework/skills/docx/scripts/office/schemas/ISO-IEC29500-4_2016/shared-documentPropertiesExtended.xsd @@ -0,0 +1,56 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/packages/mosaic/framework/skills/docx/scripts/office/schemas/ISO-IEC29500-4_2016/shared-documentPropertiesVariantTypes.xsd b/packages/mosaic/framework/skills/docx/scripts/office/schemas/ISO-IEC29500-4_2016/shared-documentPropertiesVariantTypes.xsd new file mode 100644 index 00000000..da835ee8 --- /dev/null +++ b/packages/mosaic/framework/skills/docx/scripts/office/schemas/ISO-IEC29500-4_2016/shared-documentPropertiesVariantTypes.xsd @@ -0,0 +1,195 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/packages/mosaic/framework/skills/docx/scripts/office/schemas/ISO-IEC29500-4_2016/shared-math.xsd b/packages/mosaic/framework/skills/docx/scripts/office/schemas/ISO-IEC29500-4_2016/shared-math.xsd new file mode 100644 index 00000000..87ad2658 --- /dev/null +++ b/packages/mosaic/framework/skills/docx/scripts/office/schemas/ISO-IEC29500-4_2016/shared-math.xsd @@ -0,0 +1,582 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/packages/mosaic/framework/skills/docx/scripts/office/schemas/ISO-IEC29500-4_2016/shared-relationshipReference.xsd b/packages/mosaic/framework/skills/docx/scripts/office/schemas/ISO-IEC29500-4_2016/shared-relationshipReference.xsd new file mode 100644 index 00000000..9e86f1b2 --- /dev/null +++ b/packages/mosaic/framework/skills/docx/scripts/office/schemas/ISO-IEC29500-4_2016/shared-relationshipReference.xsd @@ -0,0 +1,25 @@ + + + + + + + + + + + + + + + + + + + + diff --git a/packages/mosaic/framework/skills/docx/scripts/office/schemas/ISO-IEC29500-4_2016/sml.xsd b/packages/mosaic/framework/skills/docx/scripts/office/schemas/ISO-IEC29500-4_2016/sml.xsd new file mode 100644 index 00000000..d0be42e7 --- /dev/null +++ b/packages/mosaic/framework/skills/docx/scripts/office/schemas/ISO-IEC29500-4_2016/sml.xsd @@ -0,0 +1,4439 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/packages/mosaic/framework/skills/docx/scripts/office/schemas/ISO-IEC29500-4_2016/vml-main.xsd b/packages/mosaic/framework/skills/docx/scripts/office/schemas/ISO-IEC29500-4_2016/vml-main.xsd new file mode 100644 index 00000000..8821dd18 --- /dev/null +++ b/packages/mosaic/framework/skills/docx/scripts/office/schemas/ISO-IEC29500-4_2016/vml-main.xsd @@ -0,0 +1,570 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/packages/mosaic/framework/skills/docx/scripts/office/schemas/ISO-IEC29500-4_2016/vml-officeDrawing.xsd b/packages/mosaic/framework/skills/docx/scripts/office/schemas/ISO-IEC29500-4_2016/vml-officeDrawing.xsd new file mode 100644 index 00000000..ca2575c7 --- /dev/null +++ b/packages/mosaic/framework/skills/docx/scripts/office/schemas/ISO-IEC29500-4_2016/vml-officeDrawing.xsd @@ -0,0 +1,509 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/packages/mosaic/framework/skills/docx/scripts/office/schemas/ISO-IEC29500-4_2016/vml-presentationDrawing.xsd b/packages/mosaic/framework/skills/docx/scripts/office/schemas/ISO-IEC29500-4_2016/vml-presentationDrawing.xsd new file mode 100644 index 00000000..dd079e60 --- /dev/null +++ b/packages/mosaic/framework/skills/docx/scripts/office/schemas/ISO-IEC29500-4_2016/vml-presentationDrawing.xsd @@ -0,0 +1,12 @@ + + + + + + + + + diff --git a/packages/mosaic/framework/skills/docx/scripts/office/schemas/ISO-IEC29500-4_2016/vml-spreadsheetDrawing.xsd b/packages/mosaic/framework/skills/docx/scripts/office/schemas/ISO-IEC29500-4_2016/vml-spreadsheetDrawing.xsd new file mode 100644 index 00000000..3dd6cf62 --- /dev/null +++ b/packages/mosaic/framework/skills/docx/scripts/office/schemas/ISO-IEC29500-4_2016/vml-spreadsheetDrawing.xsd @@ -0,0 +1,108 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/packages/mosaic/framework/skills/docx/scripts/office/schemas/ISO-IEC29500-4_2016/vml-wordprocessingDrawing.xsd b/packages/mosaic/framework/skills/docx/scripts/office/schemas/ISO-IEC29500-4_2016/vml-wordprocessingDrawing.xsd new file mode 100644 index 00000000..f1041e34 --- /dev/null +++ b/packages/mosaic/framework/skills/docx/scripts/office/schemas/ISO-IEC29500-4_2016/vml-wordprocessingDrawing.xsd @@ -0,0 +1,96 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/packages/mosaic/framework/skills/docx/scripts/office/schemas/ISO-IEC29500-4_2016/wml.xsd b/packages/mosaic/framework/skills/docx/scripts/office/schemas/ISO-IEC29500-4_2016/wml.xsd new file mode 100644 index 00000000..9c5b7a63 --- /dev/null +++ b/packages/mosaic/framework/skills/docx/scripts/office/schemas/ISO-IEC29500-4_2016/wml.xsd @@ -0,0 +1,3646 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/packages/mosaic/framework/skills/docx/scripts/office/schemas/ISO-IEC29500-4_2016/xml.xsd b/packages/mosaic/framework/skills/docx/scripts/office/schemas/ISO-IEC29500-4_2016/xml.xsd new file mode 100644 index 00000000..0f13678d --- /dev/null +++ b/packages/mosaic/framework/skills/docx/scripts/office/schemas/ISO-IEC29500-4_2016/xml.xsd @@ -0,0 +1,116 @@ + + + + + + See http://www.w3.org/XML/1998/namespace.html and + http://www.w3.org/TR/REC-xml for information about this namespace. + + This schema document describes the XML namespace, in a form + suitable for import by other schema documents. + + Note that local names in this namespace are intended to be defined + only by the World Wide Web Consortium or its subgroups. The + following names are currently defined in this namespace and should + not be used with conflicting semantics by any Working Group, + specification, or document instance: + + base (as an attribute name): denotes an attribute whose value + provides a URI to be used as the base for interpreting any + relative URIs in the scope of the element on which it + appears; its value is inherited. This name is reserved + by virtue of its definition in the XML Base specification. + + lang (as an attribute name): denotes an attribute whose value + is a language code for the natural language of the content of + any element; its value is inherited. This name is reserved + by virtue of its definition in the XML specification. + + space (as an attribute name): denotes an attribute whose + value is a keyword indicating what whitespace processing + discipline is intended for the content of the element; its + value is inherited. This name is reserved by virtue of its + definition in the XML specification. + + Father (in any context at all): denotes Jon Bosak, the chair of + the original XML Working Group. This name is reserved by + the following decision of the W3C XML Plenary and + XML Coordination groups: + + In appreciation for his vision, leadership and dedication + the W3C XML Plenary on this 10th day of February, 2000 + reserves for Jon Bosak in perpetuity the XML name + xml:Father + + + + + This schema defines attributes and an attribute group + suitable for use by + schemas wishing to allow xml:base, xml:lang or xml:space attributes + on elements they define. + + To enable this, such a schema must import this schema + for the XML namespace, e.g. as follows: + <schema . . .> + . . . + <import namespace="http://www.w3.org/XML/1998/namespace" + schemaLocation="http://www.w3.org/2001/03/xml.xsd"/> + + Subsequently, qualified reference to any of the attributes + or the group defined below will have the desired effect, e.g. + + <type . . .> + . . . + <attributeGroup ref="xml:specialAttrs"/> + + will define a type which will schema-validate an instance + element with any of those attributes + + + + In keeping with the XML Schema WG's standard versioning + policy, this schema document will persist at + http://www.w3.org/2001/03/xml.xsd. + At the date of issue it can also be found at + http://www.w3.org/2001/xml.xsd. + The schema document at that URI may however change in the future, + in order to remain compatible with the latest version of XML Schema + itself. In other words, if the XML Schema namespace changes, the version + of this document at + http://www.w3.org/2001/xml.xsd will change + accordingly; the version at + http://www.w3.org/2001/03/xml.xsd will not change. + + + + + + In due course, we should install the relevant ISO 2- and 3-letter + codes as the enumerated possible values . . . + + + + + + + + + + + + + + + See http://www.w3.org/TR/xmlbase/ for + information about this attribute. + + + + + + + + + + diff --git a/packages/mosaic/framework/skills/docx/scripts/office/schemas/ecma/fouth-edition/opc-contentTypes.xsd b/packages/mosaic/framework/skills/docx/scripts/office/schemas/ecma/fouth-edition/opc-contentTypes.xsd new file mode 100644 index 00000000..a6de9d27 --- /dev/null +++ b/packages/mosaic/framework/skills/docx/scripts/office/schemas/ecma/fouth-edition/opc-contentTypes.xsd @@ -0,0 +1,42 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/packages/mosaic/framework/skills/docx/scripts/office/schemas/ecma/fouth-edition/opc-coreProperties.xsd b/packages/mosaic/framework/skills/docx/scripts/office/schemas/ecma/fouth-edition/opc-coreProperties.xsd new file mode 100644 index 00000000..10e978b6 --- /dev/null +++ b/packages/mosaic/framework/skills/docx/scripts/office/schemas/ecma/fouth-edition/opc-coreProperties.xsd @@ -0,0 +1,50 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/packages/mosaic/framework/skills/docx/scripts/office/schemas/ecma/fouth-edition/opc-digSig.xsd b/packages/mosaic/framework/skills/docx/scripts/office/schemas/ecma/fouth-edition/opc-digSig.xsd new file mode 100644 index 00000000..4248bf7a --- /dev/null +++ b/packages/mosaic/framework/skills/docx/scripts/office/schemas/ecma/fouth-edition/opc-digSig.xsd @@ -0,0 +1,49 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/packages/mosaic/framework/skills/docx/scripts/office/schemas/ecma/fouth-edition/opc-relationships.xsd b/packages/mosaic/framework/skills/docx/scripts/office/schemas/ecma/fouth-edition/opc-relationships.xsd new file mode 100644 index 00000000..56497467 --- /dev/null +++ b/packages/mosaic/framework/skills/docx/scripts/office/schemas/ecma/fouth-edition/opc-relationships.xsd @@ -0,0 +1,33 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/packages/mosaic/framework/skills/docx/scripts/office/schemas/mce/mc.xsd b/packages/mosaic/framework/skills/docx/scripts/office/schemas/mce/mc.xsd new file mode 100644 index 00000000..ef725457 --- /dev/null +++ b/packages/mosaic/framework/skills/docx/scripts/office/schemas/mce/mc.xsd @@ -0,0 +1,75 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/packages/mosaic/framework/skills/docx/scripts/office/schemas/microsoft/wml-2010.xsd b/packages/mosaic/framework/skills/docx/scripts/office/schemas/microsoft/wml-2010.xsd new file mode 100644 index 00000000..f65f7777 --- /dev/null +++ b/packages/mosaic/framework/skills/docx/scripts/office/schemas/microsoft/wml-2010.xsd @@ -0,0 +1,560 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/packages/mosaic/framework/skills/docx/scripts/office/schemas/microsoft/wml-2012.xsd b/packages/mosaic/framework/skills/docx/scripts/office/schemas/microsoft/wml-2012.xsd new file mode 100644 index 00000000..6b00755a --- /dev/null +++ b/packages/mosaic/framework/skills/docx/scripts/office/schemas/microsoft/wml-2012.xsd @@ -0,0 +1,67 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/packages/mosaic/framework/skills/docx/scripts/office/schemas/microsoft/wml-2018.xsd b/packages/mosaic/framework/skills/docx/scripts/office/schemas/microsoft/wml-2018.xsd new file mode 100644 index 00000000..f321d333 --- /dev/null +++ b/packages/mosaic/framework/skills/docx/scripts/office/schemas/microsoft/wml-2018.xsd @@ -0,0 +1,14 @@ + + + + + + + + + + + + + + diff --git a/packages/mosaic/framework/skills/docx/scripts/office/schemas/microsoft/wml-cex-2018.xsd b/packages/mosaic/framework/skills/docx/scripts/office/schemas/microsoft/wml-cex-2018.xsd new file mode 100644 index 00000000..364c6a9b --- /dev/null +++ b/packages/mosaic/framework/skills/docx/scripts/office/schemas/microsoft/wml-cex-2018.xsd @@ -0,0 +1,20 @@ + + + + + + + + + + + + + + + + + + + + diff --git a/packages/mosaic/framework/skills/docx/scripts/office/schemas/microsoft/wml-cid-2016.xsd b/packages/mosaic/framework/skills/docx/scripts/office/schemas/microsoft/wml-cid-2016.xsd new file mode 100644 index 00000000..fed9d15b --- /dev/null +++ b/packages/mosaic/framework/skills/docx/scripts/office/schemas/microsoft/wml-cid-2016.xsd @@ -0,0 +1,13 @@ + + + + + + + + + + + + + diff --git a/packages/mosaic/framework/skills/docx/scripts/office/schemas/microsoft/wml-sdtdatahash-2020.xsd b/packages/mosaic/framework/skills/docx/scripts/office/schemas/microsoft/wml-sdtdatahash-2020.xsd new file mode 100644 index 00000000..680cf154 --- /dev/null +++ b/packages/mosaic/framework/skills/docx/scripts/office/schemas/microsoft/wml-sdtdatahash-2020.xsd @@ -0,0 +1,4 @@ + + + + diff --git a/packages/mosaic/framework/skills/docx/scripts/office/schemas/microsoft/wml-symex-2015.xsd b/packages/mosaic/framework/skills/docx/scripts/office/schemas/microsoft/wml-symex-2015.xsd new file mode 100644 index 00000000..89ada908 --- /dev/null +++ b/packages/mosaic/framework/skills/docx/scripts/office/schemas/microsoft/wml-symex-2015.xsd @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/packages/mosaic/framework/skills/docx/scripts/office/soffice.py b/packages/mosaic/framework/skills/docx/scripts/office/soffice.py new file mode 100644 index 00000000..90c4e4b9 --- /dev/null +++ b/packages/mosaic/framework/skills/docx/scripts/office/soffice.py @@ -0,0 +1,187 @@ +# MOSAIC STACK SECURITY NOTE: This script contains an LD_PRELOAD shim that +# compiles C code at runtime to hook socket() system calls. Legitimate sandbox +# workaround for Claude.ai — should NEVER activate on our Docker Swarm infra. +# If it does, investigate why AF_UNIX is blocked. Audited: 2026-02-16. +""" +Helper for running LibreOffice (soffice) in environments where AF_UNIX +sockets may be blocked (e.g., sandboxed VMs). Detects the restriction +at runtime and applies an LD_PRELOAD shim if needed. + +Usage: + from office.soffice import run_soffice, get_soffice_env + + # Option 1 – run soffice directly + result = run_soffice(["--headless", "--convert-to", "pdf", "input.docx"]) + + # Option 2 – get env dict for your own subprocess calls + env = get_soffice_env() + subprocess.run(["soffice", ...], env=env) +""" + +import os +import socket +import subprocess +import tempfile +from pathlib import Path + + +def get_soffice_env() -> dict: + env = os.environ.copy() + env["SAL_USE_VCLPLUGIN"] = "svp" + + if _needs_shim(): + shim = _ensure_shim() + env["LD_PRELOAD"] = str(shim) + + return env + + +def run_soffice(args: list[str], **kwargs) -> subprocess.CompletedProcess: + env = get_soffice_env() + return subprocess.run(["soffice"] + args, env=env, **kwargs) + + + +_SHIM_SO = Path(tempfile.gettempdir()) / "lo_socket_shim.so" + + +def _needs_shim() -> bool: + try: + s = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) + s.close() + return False + except OSError: + return True + + +def _ensure_shim() -> Path: + if _SHIM_SO.exists(): + return _SHIM_SO + + src = Path(tempfile.gettempdir()) / "lo_socket_shim.c" + src.write_text(_SHIM_SOURCE) + subprocess.run( + ["gcc", "-shared", "-fPIC", "-o", str(_SHIM_SO), str(src), "-ldl"], + check=True, + capture_output=True, + ) + src.unlink() + return _SHIM_SO + + + +_SHIM_SOURCE = r""" +#define _GNU_SOURCE +#include +#include +#include +#include +#include +#include +#include + +static int (*real_socket)(int, int, int); +static int (*real_socketpair)(int, int, int, int[2]); +static int (*real_listen)(int, int); +static int (*real_accept)(int, struct sockaddr *, socklen_t *); +static int (*real_close)(int); +static int (*real_read)(int, void *, size_t); + +/* Per-FD bookkeeping (FDs >= 1024 are passed through unshimmed). */ +static int is_shimmed[1024]; +static int peer_of[1024]; +static int wake_r[1024]; /* accept() blocks reading this */ +static int wake_w[1024]; /* close() writes to this */ +static int listener_fd = -1; /* FD that received listen() */ + +__attribute__((constructor)) +static void init(void) { + real_socket = dlsym(RTLD_NEXT, "socket"); + real_socketpair = dlsym(RTLD_NEXT, "socketpair"); + real_listen = dlsym(RTLD_NEXT, "listen"); + real_accept = dlsym(RTLD_NEXT, "accept"); + real_close = dlsym(RTLD_NEXT, "close"); + real_read = dlsym(RTLD_NEXT, "read"); + for (int i = 0; i < 1024; i++) { + peer_of[i] = -1; + wake_r[i] = -1; + wake_w[i] = -1; + } +} + +/* ---- socket ---------------------------------------------------------- */ +int socket(int domain, int type, int protocol) { + if (domain == AF_UNIX) { + int fd = real_socket(domain, type, protocol); + if (fd >= 0) return fd; + /* socket(AF_UNIX) blocked – fall back to socketpair(). */ + int sv[2]; + if (real_socketpair(domain, type, protocol, sv) == 0) { + if (sv[0] >= 0 && sv[0] < 1024) { + is_shimmed[sv[0]] = 1; + peer_of[sv[0]] = sv[1]; + int wp[2]; + if (pipe(wp) == 0) { + wake_r[sv[0]] = wp[0]; + wake_w[sv[0]] = wp[1]; + } + } + return sv[0]; + } + errno = EPERM; + return -1; + } + return real_socket(domain, type, protocol); +} + +/* ---- listen ---------------------------------------------------------- */ +int listen(int sockfd, int backlog) { + if (sockfd >= 0 && sockfd < 1024 && is_shimmed[sockfd]) { + listener_fd = sockfd; + return 0; + } + return real_listen(sockfd, backlog); +} + +/* ---- accept ---------------------------------------------------------- */ +int accept(int sockfd, struct sockaddr *addr, socklen_t *addrlen) { + if (sockfd >= 0 && sockfd < 1024 && is_shimmed[sockfd]) { + /* Block until close() writes to the wake pipe. */ + if (wake_r[sockfd] >= 0) { + char buf; + real_read(wake_r[sockfd], &buf, 1); + } + errno = ECONNABORTED; + return -1; + } + return real_accept(sockfd, addr, addrlen); +} + +/* ---- close ----------------------------------------------------------- */ +int close(int fd) { + if (fd >= 0 && fd < 1024 && is_shimmed[fd]) { + int was_listener = (fd == listener_fd); + is_shimmed[fd] = 0; + + if (wake_w[fd] >= 0) { /* unblock accept() */ + char c = 0; + write(wake_w[fd], &c, 1); + real_close(wake_w[fd]); + wake_w[fd] = -1; + } + if (wake_r[fd] >= 0) { real_close(wake_r[fd]); wake_r[fd] = -1; } + if (peer_of[fd] >= 0) { real_close(peer_of[fd]); peer_of[fd] = -1; } + + if (was_listener) + _exit(0); /* conversion done – exit */ + } + return real_close(fd); +} +""" + + + +if __name__ == "__main__": + import sys + result = run_soffice(sys.argv[1:]) + sys.exit(result.returncode) diff --git a/packages/mosaic/framework/skills/docx/scripts/office/unpack.py b/packages/mosaic/framework/skills/docx/scripts/office/unpack.py new file mode 100755 index 00000000..00152533 --- /dev/null +++ b/packages/mosaic/framework/skills/docx/scripts/office/unpack.py @@ -0,0 +1,132 @@ +"""Unpack Office files (DOCX, PPTX, XLSX) for editing. + +Extracts the ZIP archive, pretty-prints XML files, and optionally: +- Merges adjacent runs with identical formatting (DOCX only) +- Simplifies adjacent tracked changes from same author (DOCX only) + +Usage: + python unpack.py [options] + +Examples: + python unpack.py document.docx unpacked/ + python unpack.py presentation.pptx unpacked/ + python unpack.py document.docx unpacked/ --merge-runs false +""" + +import argparse +import sys +import zipfile +from pathlib import Path + +import defusedxml.minidom + +from helpers.merge_runs import merge_runs as do_merge_runs +from helpers.simplify_redlines import simplify_redlines as do_simplify_redlines + +SMART_QUOTE_REPLACEMENTS = { + "\u201c": "“", + "\u201d": "”", + "\u2018": "‘", + "\u2019": "’", +} + + +def unpack( + input_file: str, + output_directory: str, + merge_runs: bool = True, + simplify_redlines: bool = True, +) -> tuple[None, str]: + input_path = Path(input_file) + output_path = Path(output_directory) + suffix = input_path.suffix.lower() + + if not input_path.exists(): + return None, f"Error: {input_file} does not exist" + + if suffix not in {".docx", ".pptx", ".xlsx"}: + return None, f"Error: {input_file} must be a .docx, .pptx, or .xlsx file" + + try: + output_path.mkdir(parents=True, exist_ok=True) + + with zipfile.ZipFile(input_path, "r") as zf: + zf.extractall(output_path) + + xml_files = list(output_path.rglob("*.xml")) + list(output_path.rglob("*.rels")) + for xml_file in xml_files: + _pretty_print_xml(xml_file) + + message = f"Unpacked {input_file} ({len(xml_files)} XML files)" + + if suffix == ".docx": + if simplify_redlines: + simplify_count, _ = do_simplify_redlines(str(output_path)) + message += f", simplified {simplify_count} tracked changes" + + if merge_runs: + merge_count, _ = do_merge_runs(str(output_path)) + message += f", merged {merge_count} runs" + + for xml_file in xml_files: + _escape_smart_quotes(xml_file) + + return None, message + + except zipfile.BadZipFile: + return None, f"Error: {input_file} is not a valid Office file" + except Exception as e: + return None, f"Error unpacking: {e}" + + +def _pretty_print_xml(xml_file: Path) -> None: + try: + content = xml_file.read_text(encoding="utf-8") + dom = defusedxml.minidom.parseString(content) + xml_file.write_bytes(dom.toprettyxml(indent=" ", encoding="utf-8")) + except Exception: + pass + + +def _escape_smart_quotes(xml_file: Path) -> None: + try: + content = xml_file.read_text(encoding="utf-8") + for char, entity in SMART_QUOTE_REPLACEMENTS.items(): + content = content.replace(char, entity) + xml_file.write_text(content, encoding="utf-8") + except Exception: + pass + + +if __name__ == "__main__": + parser = argparse.ArgumentParser( + description="Unpack an Office file (DOCX, PPTX, XLSX) for editing" + ) + parser.add_argument("input_file", help="Office file to unpack") + parser.add_argument("output_directory", help="Output directory") + parser.add_argument( + "--merge-runs", + type=lambda x: x.lower() == "true", + default=True, + metavar="true|false", + help="Merge adjacent runs with identical formatting (DOCX only, default: true)", + ) + parser.add_argument( + "--simplify-redlines", + type=lambda x: x.lower() == "true", + default=True, + metavar="true|false", + help="Merge adjacent tracked changes from same author (DOCX only, default: true)", + ) + args = parser.parse_args() + + _, message = unpack( + args.input_file, + args.output_directory, + merge_runs=args.merge_runs, + simplify_redlines=args.simplify_redlines, + ) + print(message) + + if "Error" in message: + sys.exit(1) diff --git a/packages/mosaic/framework/skills/docx/scripts/office/validate.py b/packages/mosaic/framework/skills/docx/scripts/office/validate.py new file mode 100755 index 00000000..03b01f6e --- /dev/null +++ b/packages/mosaic/framework/skills/docx/scripts/office/validate.py @@ -0,0 +1,111 @@ +""" +Command line tool to validate Office document XML files against XSD schemas and tracked changes. + +Usage: + python validate.py [--original ] [--auto-repair] [--author NAME] + +The first argument can be either: +- An unpacked directory containing the Office document XML files +- A packed Office file (.docx/.pptx/.xlsx) which will be unpacked to a temp directory + +Auto-repair fixes: +- paraId/durableId values that exceed OOXML limits +- Missing xml:space="preserve" on w:t elements with whitespace +""" + +import argparse +import sys +import tempfile +import zipfile +from pathlib import Path + +from validators import DOCXSchemaValidator, PPTXSchemaValidator, RedliningValidator + + +def main(): + parser = argparse.ArgumentParser(description="Validate Office document XML files") + parser.add_argument( + "path", + help="Path to unpacked directory or packed Office file (.docx/.pptx/.xlsx)", + ) + parser.add_argument( + "--original", + required=False, + default=None, + help="Path to original file (.docx/.pptx/.xlsx). If omitted, all XSD errors are reported and redlining validation is skipped.", + ) + parser.add_argument( + "-v", + "--verbose", + action="store_true", + help="Enable verbose output", + ) + parser.add_argument( + "--auto-repair", + action="store_true", + help="Automatically repair common issues (hex IDs, whitespace preservation)", + ) + parser.add_argument( + "--author", + default="Claude", + help="Author name for redlining validation (default: Claude)", + ) + args = parser.parse_args() + + path = Path(args.path) + assert path.exists(), f"Error: {path} does not exist" + + original_file = None + if args.original: + original_file = Path(args.original) + assert original_file.is_file(), f"Error: {original_file} is not a file" + assert original_file.suffix.lower() in [".docx", ".pptx", ".xlsx"], ( + f"Error: {original_file} must be a .docx, .pptx, or .xlsx file" + ) + + file_extension = (original_file or path).suffix.lower() + assert file_extension in [".docx", ".pptx", ".xlsx"], ( + f"Error: Cannot determine file type from {path}. Use --original or provide a .docx/.pptx/.xlsx file." + ) + + if path.is_file() and path.suffix.lower() in [".docx", ".pptx", ".xlsx"]: + temp_dir = tempfile.mkdtemp() + with zipfile.ZipFile(path, "r") as zf: + zf.extractall(temp_dir) + unpacked_dir = Path(temp_dir) + else: + assert path.is_dir(), f"Error: {path} is not a directory or Office file" + unpacked_dir = path + + match file_extension: + case ".docx": + validators = [ + DOCXSchemaValidator(unpacked_dir, original_file, verbose=args.verbose), + ] + if original_file: + validators.append( + RedliningValidator(unpacked_dir, original_file, verbose=args.verbose, author=args.author) + ) + case ".pptx": + validators = [ + PPTXSchemaValidator(unpacked_dir, original_file, verbose=args.verbose), + ] + case _: + print(f"Error: Validation not supported for file type {file_extension}") + sys.exit(1) + + if args.auto_repair: + total_repairs = sum(v.repair() for v in validators) + if total_repairs: + print(f"Auto-repaired {total_repairs} issue(s)") + + success = all(v.validate() for v in validators) + + if success: + print("All validations PASSED!") + + sys.exit(0 if success else 1) + + +if __name__ == "__main__": + main() diff --git a/packages/mosaic/framework/skills/docx/scripts/office/validators/__init__.py b/packages/mosaic/framework/skills/docx/scripts/office/validators/__init__.py new file mode 100644 index 00000000..db092ece --- /dev/null +++ b/packages/mosaic/framework/skills/docx/scripts/office/validators/__init__.py @@ -0,0 +1,15 @@ +""" +Validation modules for Word document processing. +""" + +from .base import BaseSchemaValidator +from .docx import DOCXSchemaValidator +from .pptx import PPTXSchemaValidator +from .redlining import RedliningValidator + +__all__ = [ + "BaseSchemaValidator", + "DOCXSchemaValidator", + "PPTXSchemaValidator", + "RedliningValidator", +] diff --git a/packages/mosaic/framework/skills/docx/scripts/office/validators/base.py b/packages/mosaic/framework/skills/docx/scripts/office/validators/base.py new file mode 100644 index 00000000..db4a06a2 --- /dev/null +++ b/packages/mosaic/framework/skills/docx/scripts/office/validators/base.py @@ -0,0 +1,847 @@ +""" +Base validator with common validation logic for document files. +""" + +import re +from pathlib import Path + +import defusedxml.minidom +import lxml.etree + + +class BaseSchemaValidator: + + IGNORED_VALIDATION_ERRORS = [ + "hyphenationZone", + "purl.org/dc/terms", + ] + + UNIQUE_ID_REQUIREMENTS = { + "comment": ("id", "file"), + "commentrangestart": ("id", "file"), + "commentrangeend": ("id", "file"), + "bookmarkstart": ("id", "file"), + "bookmarkend": ("id", "file"), + "sldid": ("id", "file"), + "sldmasterid": ("id", "global"), + "sldlayoutid": ("id", "global"), + "cm": ("authorid", "file"), + "sheet": ("sheetid", "file"), + "definedname": ("id", "file"), + "cxnsp": ("id", "file"), + "sp": ("id", "file"), + "pic": ("id", "file"), + "grpsp": ("id", "file"), + } + + EXCLUDED_ID_CONTAINERS = { + "sectionlst", + } + + ELEMENT_RELATIONSHIP_TYPES = {} + + SCHEMA_MAPPINGS = { + "word": "ISO-IEC29500-4_2016/wml.xsd", + "ppt": "ISO-IEC29500-4_2016/pml.xsd", + "xl": "ISO-IEC29500-4_2016/sml.xsd", + "[Content_Types].xml": "ecma/fouth-edition/opc-contentTypes.xsd", + "app.xml": "ISO-IEC29500-4_2016/shared-documentPropertiesExtended.xsd", + "core.xml": "ecma/fouth-edition/opc-coreProperties.xsd", + "custom.xml": "ISO-IEC29500-4_2016/shared-documentPropertiesCustom.xsd", + ".rels": "ecma/fouth-edition/opc-relationships.xsd", + "people.xml": "microsoft/wml-2012.xsd", + "commentsIds.xml": "microsoft/wml-cid-2016.xsd", + "commentsExtensible.xml": "microsoft/wml-cex-2018.xsd", + "commentsExtended.xml": "microsoft/wml-2012.xsd", + "chart": "ISO-IEC29500-4_2016/dml-chart.xsd", + "theme": "ISO-IEC29500-4_2016/dml-main.xsd", + "drawing": "ISO-IEC29500-4_2016/dml-main.xsd", + } + + MC_NAMESPACE = "http://schemas.openxmlformats.org/markup-compatibility/2006" + XML_NAMESPACE = "http://www.w3.org/XML/1998/namespace" + + PACKAGE_RELATIONSHIPS_NAMESPACE = ( + "http://schemas.openxmlformats.org/package/2006/relationships" + ) + OFFICE_RELATIONSHIPS_NAMESPACE = ( + "http://schemas.openxmlformats.org/officeDocument/2006/relationships" + ) + CONTENT_TYPES_NAMESPACE = ( + "http://schemas.openxmlformats.org/package/2006/content-types" + ) + + MAIN_CONTENT_FOLDERS = {"word", "ppt", "xl"} + + OOXML_NAMESPACES = { + "http://schemas.openxmlformats.org/officeDocument/2006/math", + "http://schemas.openxmlformats.org/officeDocument/2006/relationships", + "http://schemas.openxmlformats.org/schemaLibrary/2006/main", + "http://schemas.openxmlformats.org/drawingml/2006/main", + "http://schemas.openxmlformats.org/drawingml/2006/chart", + "http://schemas.openxmlformats.org/drawingml/2006/chartDrawing", + "http://schemas.openxmlformats.org/drawingml/2006/diagram", + "http://schemas.openxmlformats.org/drawingml/2006/picture", + "http://schemas.openxmlformats.org/drawingml/2006/spreadsheetDrawing", + "http://schemas.openxmlformats.org/drawingml/2006/wordprocessingDrawing", + "http://schemas.openxmlformats.org/wordprocessingml/2006/main", + "http://schemas.openxmlformats.org/presentationml/2006/main", + "http://schemas.openxmlformats.org/spreadsheetml/2006/main", + "http://schemas.openxmlformats.org/officeDocument/2006/sharedTypes", + "http://www.w3.org/XML/1998/namespace", + } + + def __init__(self, unpacked_dir, original_file=None, verbose=False): + self.unpacked_dir = Path(unpacked_dir).resolve() + self.original_file = Path(original_file) if original_file else None + self.verbose = verbose + + self.schemas_dir = Path(__file__).parent.parent / "schemas" + + patterns = ["*.xml", "*.rels"] + self.xml_files = [ + f for pattern in patterns for f in self.unpacked_dir.rglob(pattern) + ] + + if not self.xml_files: + print(f"Warning: No XML files found in {self.unpacked_dir}") + + def validate(self): + raise NotImplementedError("Subclasses must implement the validate method") + + def repair(self) -> int: + return self.repair_whitespace_preservation() + + def repair_whitespace_preservation(self) -> int: + repairs = 0 + + for xml_file in self.xml_files: + try: + content = xml_file.read_text(encoding="utf-8") + dom = defusedxml.minidom.parseString(content) + modified = False + + for elem in dom.getElementsByTagName("*"): + if elem.tagName.endswith(":t") and elem.firstChild: + text = elem.firstChild.nodeValue + if text and (text.startswith((' ', '\t')) or text.endswith((' ', '\t'))): + if elem.getAttribute("xml:space") != "preserve": + elem.setAttribute("xml:space", "preserve") + text_preview = repr(text[:30]) + "..." if len(text) > 30 else repr(text) + print(f" Repaired: {xml_file.name}: Added xml:space='preserve' to {elem.tagName}: {text_preview}") + repairs += 1 + modified = True + + if modified: + xml_file.write_bytes(dom.toxml(encoding="UTF-8")) + + except Exception: + pass + + return repairs + + def validate_xml(self): + errors = [] + + for xml_file in self.xml_files: + try: + lxml.etree.parse(str(xml_file)) + except lxml.etree.XMLSyntaxError as e: + errors.append( + f" {xml_file.relative_to(self.unpacked_dir)}: " + f"Line {e.lineno}: {e.msg}" + ) + except Exception as e: + errors.append( + f" {xml_file.relative_to(self.unpacked_dir)}: " + f"Unexpected error: {str(e)}" + ) + + if errors: + print(f"FAILED - Found {len(errors)} XML violations:") + for error in errors: + print(error) + return False + else: + if self.verbose: + print("PASSED - All XML files are well-formed") + return True + + def validate_namespaces(self): + errors = [] + + for xml_file in self.xml_files: + try: + root = lxml.etree.parse(str(xml_file)).getroot() + declared = set(root.nsmap.keys()) - {None} + + for attr_val in [ + v for k, v in root.attrib.items() if k.endswith("Ignorable") + ]: + undeclared = set(attr_val.split()) - declared + errors.extend( + f" {xml_file.relative_to(self.unpacked_dir)}: " + f"Namespace '{ns}' in Ignorable but not declared" + for ns in undeclared + ) + except lxml.etree.XMLSyntaxError: + continue + + if errors: + print(f"FAILED - {len(errors)} namespace issues:") + for error in errors: + print(error) + return False + if self.verbose: + print("PASSED - All namespace prefixes properly declared") + return True + + def validate_unique_ids(self): + errors = [] + global_ids = {} + + for xml_file in self.xml_files: + try: + root = lxml.etree.parse(str(xml_file)).getroot() + file_ids = {} + + mc_elements = root.xpath( + ".//mc:AlternateContent", namespaces={"mc": self.MC_NAMESPACE} + ) + for elem in mc_elements: + elem.getparent().remove(elem) + + for elem in root.iter(): + tag = ( + elem.tag.split("}")[-1].lower() + if "}" in elem.tag + else elem.tag.lower() + ) + + if tag in self.UNIQUE_ID_REQUIREMENTS: + in_excluded_container = any( + ancestor.tag.split("}")[-1].lower() in self.EXCLUDED_ID_CONTAINERS + for ancestor in elem.iterancestors() + ) + if in_excluded_container: + continue + + attr_name, scope = self.UNIQUE_ID_REQUIREMENTS[tag] + + id_value = None + for attr, value in elem.attrib.items(): + attr_local = ( + attr.split("}")[-1].lower() + if "}" in attr + else attr.lower() + ) + if attr_local == attr_name: + id_value = value + break + + if id_value is not None: + if scope == "global": + if id_value in global_ids: + prev_file, prev_line, prev_tag = global_ids[ + id_value + ] + errors.append( + f" {xml_file.relative_to(self.unpacked_dir)}: " + f"Line {elem.sourceline}: Global ID '{id_value}' in <{tag}> " + f"already used in {prev_file} at line {prev_line} in <{prev_tag}>" + ) + else: + global_ids[id_value] = ( + xml_file.relative_to(self.unpacked_dir), + elem.sourceline, + tag, + ) + elif scope == "file": + key = (tag, attr_name) + if key not in file_ids: + file_ids[key] = {} + + if id_value in file_ids[key]: + prev_line = file_ids[key][id_value] + errors.append( + f" {xml_file.relative_to(self.unpacked_dir)}: " + f"Line {elem.sourceline}: Duplicate {attr_name}='{id_value}' in <{tag}> " + f"(first occurrence at line {prev_line})" + ) + else: + file_ids[key][id_value] = elem.sourceline + + except (lxml.etree.XMLSyntaxError, Exception) as e: + errors.append( + f" {xml_file.relative_to(self.unpacked_dir)}: Error: {e}" + ) + + if errors: + print(f"FAILED - Found {len(errors)} ID uniqueness violations:") + for error in errors: + print(error) + return False + else: + if self.verbose: + print("PASSED - All required IDs are unique") + return True + + def validate_file_references(self): + errors = [] + + rels_files = list(self.unpacked_dir.rglob("*.rels")) + + if not rels_files: + if self.verbose: + print("PASSED - No .rels files found") + return True + + all_files = [] + for file_path in self.unpacked_dir.rglob("*"): + if ( + file_path.is_file() + and file_path.name != "[Content_Types].xml" + and not file_path.name.endswith(".rels") + ): + all_files.append(file_path.resolve()) + + all_referenced_files = set() + + if self.verbose: + print( + f"Found {len(rels_files)} .rels files and {len(all_files)} target files" + ) + + for rels_file in rels_files: + try: + rels_root = lxml.etree.parse(str(rels_file)).getroot() + + rels_dir = rels_file.parent + + referenced_files = set() + broken_refs = [] + + for rel in rels_root.findall( + ".//ns:Relationship", + namespaces={"ns": self.PACKAGE_RELATIONSHIPS_NAMESPACE}, + ): + target = rel.get("Target") + if target and not target.startswith( + ("http", "mailto:") + ): + if target.startswith("/"): + target_path = self.unpacked_dir / target.lstrip("/") + elif rels_file.name == ".rels": + target_path = self.unpacked_dir / target + else: + base_dir = rels_dir.parent + target_path = base_dir / target + + try: + target_path = target_path.resolve() + if target_path.exists() and target_path.is_file(): + referenced_files.add(target_path) + all_referenced_files.add(target_path) + else: + broken_refs.append((target, rel.sourceline)) + except (OSError, ValueError): + broken_refs.append((target, rel.sourceline)) + + if broken_refs: + rel_path = rels_file.relative_to(self.unpacked_dir) + for broken_ref, line_num in broken_refs: + errors.append( + f" {rel_path}: Line {line_num}: Broken reference to {broken_ref}" + ) + + except Exception as e: + rel_path = rels_file.relative_to(self.unpacked_dir) + errors.append(f" Error parsing {rel_path}: {e}") + + unreferenced_files = set(all_files) - all_referenced_files + + if unreferenced_files: + for unref_file in sorted(unreferenced_files): + unref_rel_path = unref_file.relative_to(self.unpacked_dir) + errors.append(f" Unreferenced file: {unref_rel_path}") + + if errors: + print(f"FAILED - Found {len(errors)} relationship validation errors:") + for error in errors: + print(error) + print( + "CRITICAL: These errors will cause the document to appear corrupt. " + + "Broken references MUST be fixed, " + + "and unreferenced files MUST be referenced or removed." + ) + return False + else: + if self.verbose: + print( + "PASSED - All references are valid and all files are properly referenced" + ) + return True + + def validate_all_relationship_ids(self): + import lxml.etree + + errors = [] + + for xml_file in self.xml_files: + if xml_file.suffix == ".rels": + continue + + rels_dir = xml_file.parent / "_rels" + rels_file = rels_dir / f"{xml_file.name}.rels" + + if not rels_file.exists(): + continue + + try: + rels_root = lxml.etree.parse(str(rels_file)).getroot() + rid_to_type = {} + + for rel in rels_root.findall( + f".//{{{self.PACKAGE_RELATIONSHIPS_NAMESPACE}}}Relationship" + ): + rid = rel.get("Id") + rel_type = rel.get("Type", "") + if rid: + if rid in rid_to_type: + rels_rel_path = rels_file.relative_to(self.unpacked_dir) + errors.append( + f" {rels_rel_path}: Line {rel.sourceline}: " + f"Duplicate relationship ID '{rid}' (IDs must be unique)" + ) + type_name = ( + rel_type.split("/")[-1] if "/" in rel_type else rel_type + ) + rid_to_type[rid] = type_name + + xml_root = lxml.etree.parse(str(xml_file)).getroot() + + r_ns = self.OFFICE_RELATIONSHIPS_NAMESPACE + rid_attrs_to_check = ["id", "embed", "link"] + for elem in xml_root.iter(): + for attr_name in rid_attrs_to_check: + rid_attr = elem.get(f"{{{r_ns}}}{attr_name}") + if not rid_attr: + continue + xml_rel_path = xml_file.relative_to(self.unpacked_dir) + elem_name = ( + elem.tag.split("}")[-1] if "}" in elem.tag else elem.tag + ) + + if rid_attr not in rid_to_type: + errors.append( + f" {xml_rel_path}: Line {elem.sourceline}: " + f"<{elem_name}> r:{attr_name} references non-existent relationship '{rid_attr}' " + f"(valid IDs: {', '.join(sorted(rid_to_type.keys())[:5])}{'...' if len(rid_to_type) > 5 else ''})" + ) + elif attr_name == "id" and self.ELEMENT_RELATIONSHIP_TYPES: + expected_type = self._get_expected_relationship_type( + elem_name + ) + if expected_type: + actual_type = rid_to_type[rid_attr] + if expected_type not in actual_type.lower(): + errors.append( + f" {xml_rel_path}: Line {elem.sourceline}: " + f"<{elem_name}> references '{rid_attr}' which points to '{actual_type}' " + f"but should point to a '{expected_type}' relationship" + ) + + except Exception as e: + xml_rel_path = xml_file.relative_to(self.unpacked_dir) + errors.append(f" Error processing {xml_rel_path}: {e}") + + if errors: + print(f"FAILED - Found {len(errors)} relationship ID reference errors:") + for error in errors: + print(error) + print("\nThese ID mismatches will cause the document to appear corrupt!") + return False + else: + if self.verbose: + print("PASSED - All relationship ID references are valid") + return True + + def _get_expected_relationship_type(self, element_name): + elem_lower = element_name.lower() + + if elem_lower in self.ELEMENT_RELATIONSHIP_TYPES: + return self.ELEMENT_RELATIONSHIP_TYPES[elem_lower] + + if elem_lower.endswith("id") and len(elem_lower) > 2: + prefix = elem_lower[:-2] + if prefix.endswith("master"): + return prefix.lower() + elif prefix.endswith("layout"): + return prefix.lower() + else: + if prefix == "sld": + return "slide" + return prefix.lower() + + if elem_lower.endswith("reference") and len(elem_lower) > 9: + prefix = elem_lower[:-9] + return prefix.lower() + + return None + + def validate_content_types(self): + errors = [] + + content_types_file = self.unpacked_dir / "[Content_Types].xml" + if not content_types_file.exists(): + print("FAILED - [Content_Types].xml file not found") + return False + + try: + root = lxml.etree.parse(str(content_types_file)).getroot() + declared_parts = set() + declared_extensions = set() + + for override in root.findall( + f".//{{{self.CONTENT_TYPES_NAMESPACE}}}Override" + ): + part_name = override.get("PartName") + if part_name is not None: + declared_parts.add(part_name.lstrip("/")) + + for default in root.findall( + f".//{{{self.CONTENT_TYPES_NAMESPACE}}}Default" + ): + extension = default.get("Extension") + if extension is not None: + declared_extensions.add(extension.lower()) + + declarable_roots = { + "sld", + "sldLayout", + "sldMaster", + "presentation", + "document", + "workbook", + "worksheet", + "theme", + } + + media_extensions = { + "png": "image/png", + "jpg": "image/jpeg", + "jpeg": "image/jpeg", + "gif": "image/gif", + "bmp": "image/bmp", + "tiff": "image/tiff", + "wmf": "image/x-wmf", + "emf": "image/x-emf", + } + + all_files = list(self.unpacked_dir.rglob("*")) + all_files = [f for f in all_files if f.is_file()] + + for xml_file in self.xml_files: + path_str = str(xml_file.relative_to(self.unpacked_dir)).replace( + "\\", "/" + ) + + if any( + skip in path_str + for skip in [".rels", "[Content_Types]", "docProps/", "_rels/"] + ): + continue + + try: + root_tag = lxml.etree.parse(str(xml_file)).getroot().tag + root_name = root_tag.split("}")[-1] if "}" in root_tag else root_tag + + if root_name in declarable_roots and path_str not in declared_parts: + errors.append( + f" {path_str}: File with <{root_name}> root not declared in [Content_Types].xml" + ) + + except Exception: + continue + + for file_path in all_files: + if file_path.suffix.lower() in {".xml", ".rels"}: + continue + if file_path.name == "[Content_Types].xml": + continue + if "_rels" in file_path.parts or "docProps" in file_path.parts: + continue + + extension = file_path.suffix.lstrip(".").lower() + if extension and extension not in declared_extensions: + if extension in media_extensions: + relative_path = file_path.relative_to(self.unpacked_dir) + errors.append( + f' {relative_path}: File with extension \'{extension}\' not declared in [Content_Types].xml - should add: ' + ) + + except Exception as e: + errors.append(f" Error parsing [Content_Types].xml: {e}") + + if errors: + print(f"FAILED - Found {len(errors)} content type declaration errors:") + for error in errors: + print(error) + return False + else: + if self.verbose: + print( + "PASSED - All content files are properly declared in [Content_Types].xml" + ) + return True + + def validate_file_against_xsd(self, xml_file, verbose=False): + xml_file = Path(xml_file).resolve() + unpacked_dir = self.unpacked_dir.resolve() + + is_valid, current_errors = self._validate_single_file_xsd( + xml_file, unpacked_dir + ) + + if is_valid is None: + return None, set() + elif is_valid: + return True, set() + + original_errors = self._get_original_file_errors(xml_file) + + assert current_errors is not None + new_errors = current_errors - original_errors + + new_errors = { + e for e in new_errors + if not any(pattern in e for pattern in self.IGNORED_VALIDATION_ERRORS) + } + + if new_errors: + if verbose: + relative_path = xml_file.relative_to(unpacked_dir) + print(f"FAILED - {relative_path}: {len(new_errors)} new error(s)") + for error in list(new_errors)[:3]: + truncated = error[:250] + "..." if len(error) > 250 else error + print(f" - {truncated}") + return False, new_errors + else: + if verbose: + print( + f"PASSED - No new errors (original had {len(current_errors)} errors)" + ) + return True, set() + + def validate_against_xsd(self): + new_errors = [] + original_error_count = 0 + valid_count = 0 + skipped_count = 0 + + for xml_file in self.xml_files: + relative_path = str(xml_file.relative_to(self.unpacked_dir)) + is_valid, new_file_errors = self.validate_file_against_xsd( + xml_file, verbose=False + ) + + if is_valid is None: + skipped_count += 1 + continue + elif is_valid and not new_file_errors: + valid_count += 1 + continue + elif is_valid: + original_error_count += 1 + valid_count += 1 + continue + + new_errors.append(f" {relative_path}: {len(new_file_errors)} new error(s)") + for error in list(new_file_errors)[:3]: + new_errors.append( + f" - {error[:250]}..." if len(error) > 250 else f" - {error}" + ) + + if self.verbose: + print(f"Validated {len(self.xml_files)} files:") + print(f" - Valid: {valid_count}") + print(f" - Skipped (no schema): {skipped_count}") + if original_error_count: + print(f" - With original errors (ignored): {original_error_count}") + print( + f" - With NEW errors: {len(new_errors) > 0 and len([e for e in new_errors if not e.startswith(' ')]) or 0}" + ) + + if new_errors: + print("\nFAILED - Found NEW validation errors:") + for error in new_errors: + print(error) + return False + else: + if self.verbose: + print("\nPASSED - No new XSD validation errors introduced") + return True + + def _get_schema_path(self, xml_file): + if xml_file.name in self.SCHEMA_MAPPINGS: + return self.schemas_dir / self.SCHEMA_MAPPINGS[xml_file.name] + + if xml_file.suffix == ".rels": + return self.schemas_dir / self.SCHEMA_MAPPINGS[".rels"] + + if "charts/" in str(xml_file) and xml_file.name.startswith("chart"): + return self.schemas_dir / self.SCHEMA_MAPPINGS["chart"] + + if "theme/" in str(xml_file) and xml_file.name.startswith("theme"): + return self.schemas_dir / self.SCHEMA_MAPPINGS["theme"] + + if xml_file.parent.name in self.MAIN_CONTENT_FOLDERS: + return self.schemas_dir / self.SCHEMA_MAPPINGS[xml_file.parent.name] + + return None + + def _clean_ignorable_namespaces(self, xml_doc): + xml_string = lxml.etree.tostring(xml_doc, encoding="unicode") + xml_copy = lxml.etree.fromstring(xml_string) + + for elem in xml_copy.iter(): + attrs_to_remove = [] + + for attr in elem.attrib: + if "{" in attr: + ns = attr.split("}")[0][1:] + if ns not in self.OOXML_NAMESPACES: + attrs_to_remove.append(attr) + + for attr in attrs_to_remove: + del elem.attrib[attr] + + self._remove_ignorable_elements(xml_copy) + + return lxml.etree.ElementTree(xml_copy) + + def _remove_ignorable_elements(self, root): + elements_to_remove = [] + + for elem in list(root): + if not hasattr(elem, "tag") or callable(elem.tag): + continue + + tag_str = str(elem.tag) + if tag_str.startswith("{"): + ns = tag_str.split("}")[0][1:] + if ns not in self.OOXML_NAMESPACES: + elements_to_remove.append(elem) + continue + + self._remove_ignorable_elements(elem) + + for elem in elements_to_remove: + root.remove(elem) + + def _preprocess_for_mc_ignorable(self, xml_doc): + root = xml_doc.getroot() + + if f"{{{self.MC_NAMESPACE}}}Ignorable" in root.attrib: + del root.attrib[f"{{{self.MC_NAMESPACE}}}Ignorable"] + + return xml_doc + + def _validate_single_file_xsd(self, xml_file, base_path): + schema_path = self._get_schema_path(xml_file) + if not schema_path: + return None, None + + try: + with open(schema_path, "rb") as xsd_file: + parser = lxml.etree.XMLParser() + xsd_doc = lxml.etree.parse( + xsd_file, parser=parser, base_url=str(schema_path) + ) + schema = lxml.etree.XMLSchema(xsd_doc) + + with open(xml_file, "r") as f: + xml_doc = lxml.etree.parse(f) + + xml_doc, _ = self._remove_template_tags_from_text_nodes(xml_doc) + xml_doc = self._preprocess_for_mc_ignorable(xml_doc) + + relative_path = xml_file.relative_to(base_path) + if ( + relative_path.parts + and relative_path.parts[0] in self.MAIN_CONTENT_FOLDERS + ): + xml_doc = self._clean_ignorable_namespaces(xml_doc) + + if schema.validate(xml_doc): + return True, set() + else: + errors = set() + for error in schema.error_log: + errors.add(error.message) + return False, errors + + except Exception as e: + return False, {str(e)} + + def _get_original_file_errors(self, xml_file): + if self.original_file is None: + return set() + + import tempfile + import zipfile + + xml_file = Path(xml_file).resolve() + unpacked_dir = self.unpacked_dir.resolve() + relative_path = xml_file.relative_to(unpacked_dir) + + with tempfile.TemporaryDirectory() as temp_dir: + temp_path = Path(temp_dir) + + with zipfile.ZipFile(self.original_file, "r") as zip_ref: + zip_ref.extractall(temp_path) + + original_xml_file = temp_path / relative_path + + if not original_xml_file.exists(): + return set() + + is_valid, errors = self._validate_single_file_xsd( + original_xml_file, temp_path + ) + return errors if errors else set() + + def _remove_template_tags_from_text_nodes(self, xml_doc): + warnings = [] + template_pattern = re.compile(r"\{\{[^}]*\}\}") + + xml_string = lxml.etree.tostring(xml_doc, encoding="unicode") + xml_copy = lxml.etree.fromstring(xml_string) + + def process_text_content(text, content_type): + if not text: + return text + matches = list(template_pattern.finditer(text)) + if matches: + for match in matches: + warnings.append( + f"Found template tag in {content_type}: {match.group()}" + ) + return template_pattern.sub("", text) + return text + + for elem in xml_copy.iter(): + if not hasattr(elem, "tag") or callable(elem.tag): + continue + tag_str = str(elem.tag) + if tag_str.endswith("}t") or tag_str == "t": + continue + + elem.text = process_text_content(elem.text, "text content") + elem.tail = process_text_content(elem.tail, "tail content") + + return lxml.etree.ElementTree(xml_copy), warnings + + +if __name__ == "__main__": + raise RuntimeError("This module should not be run directly.") diff --git a/packages/mosaic/framework/skills/docx/scripts/office/validators/docx.py b/packages/mosaic/framework/skills/docx/scripts/office/validators/docx.py new file mode 100644 index 00000000..fec405e6 --- /dev/null +++ b/packages/mosaic/framework/skills/docx/scripts/office/validators/docx.py @@ -0,0 +1,446 @@ +""" +Validator for Word document XML files against XSD schemas. +""" + +import random +import re +import tempfile +import zipfile + +import defusedxml.minidom +import lxml.etree + +from .base import BaseSchemaValidator + + +class DOCXSchemaValidator(BaseSchemaValidator): + + WORD_2006_NAMESPACE = "http://schemas.openxmlformats.org/wordprocessingml/2006/main" + W14_NAMESPACE = "http://schemas.microsoft.com/office/word/2010/wordml" + W16CID_NAMESPACE = "http://schemas.microsoft.com/office/word/2016/wordml/cid" + + ELEMENT_RELATIONSHIP_TYPES = {} + + def validate(self): + if not self.validate_xml(): + return False + + all_valid = True + if not self.validate_namespaces(): + all_valid = False + + if not self.validate_unique_ids(): + all_valid = False + + if not self.validate_file_references(): + all_valid = False + + if not self.validate_content_types(): + all_valid = False + + if not self.validate_against_xsd(): + all_valid = False + + if not self.validate_whitespace_preservation(): + all_valid = False + + if not self.validate_deletions(): + all_valid = False + + if not self.validate_insertions(): + all_valid = False + + if not self.validate_all_relationship_ids(): + all_valid = False + + if not self.validate_id_constraints(): + all_valid = False + + if not self.validate_comment_markers(): + all_valid = False + + self.compare_paragraph_counts() + + return all_valid + + def validate_whitespace_preservation(self): + errors = [] + + for xml_file in self.xml_files: + if xml_file.name != "document.xml": + continue + + try: + root = lxml.etree.parse(str(xml_file)).getroot() + + for elem in root.iter(f"{{{self.WORD_2006_NAMESPACE}}}t"): + if elem.text: + text = elem.text + if re.search(r"^[ \t\n\r]", text) or re.search( + r"[ \t\n\r]$", text + ): + xml_space_attr = f"{{{self.XML_NAMESPACE}}}space" + if ( + xml_space_attr not in elem.attrib + or elem.attrib[xml_space_attr] != "preserve" + ): + text_preview = ( + repr(text)[:50] + "..." + if len(repr(text)) > 50 + else repr(text) + ) + errors.append( + f" {xml_file.relative_to(self.unpacked_dir)}: " + f"Line {elem.sourceline}: w:t element with whitespace missing xml:space='preserve': {text_preview}" + ) + + except (lxml.etree.XMLSyntaxError, Exception) as e: + errors.append( + f" {xml_file.relative_to(self.unpacked_dir)}: Error: {e}" + ) + + if errors: + print(f"FAILED - Found {len(errors)} whitespace preservation violations:") + for error in errors: + print(error) + return False + else: + if self.verbose: + print("PASSED - All whitespace is properly preserved") + return True + + def validate_deletions(self): + errors = [] + + for xml_file in self.xml_files: + if xml_file.name != "document.xml": + continue + + try: + root = lxml.etree.parse(str(xml_file)).getroot() + namespaces = {"w": self.WORD_2006_NAMESPACE} + + for t_elem in root.xpath(".//w:del//w:t", namespaces=namespaces): + if t_elem.text: + text_preview = ( + repr(t_elem.text)[:50] + "..." + if len(repr(t_elem.text)) > 50 + else repr(t_elem.text) + ) + errors.append( + f" {xml_file.relative_to(self.unpacked_dir)}: " + f"Line {t_elem.sourceline}: found within : {text_preview}" + ) + + for instr_elem in root.xpath( + ".//w:del//w:instrText", namespaces=namespaces + ): + text_preview = ( + repr(instr_elem.text or "")[:50] + "..." + if len(repr(instr_elem.text or "")) > 50 + else repr(instr_elem.text or "") + ) + errors.append( + f" {xml_file.relative_to(self.unpacked_dir)}: " + f"Line {instr_elem.sourceline}: found within (use ): {text_preview}" + ) + + except (lxml.etree.XMLSyntaxError, Exception) as e: + errors.append( + f" {xml_file.relative_to(self.unpacked_dir)}: Error: {e}" + ) + + if errors: + print(f"FAILED - Found {len(errors)} deletion validation violations:") + for error in errors: + print(error) + return False + else: + if self.verbose: + print("PASSED - No w:t elements found within w:del elements") + return True + + def count_paragraphs_in_unpacked(self): + count = 0 + + for xml_file in self.xml_files: + if xml_file.name != "document.xml": + continue + + try: + root = lxml.etree.parse(str(xml_file)).getroot() + paragraphs = root.findall(f".//{{{self.WORD_2006_NAMESPACE}}}p") + count = len(paragraphs) + except Exception as e: + print(f"Error counting paragraphs in unpacked document: {e}") + + return count + + def count_paragraphs_in_original(self): + original = self.original_file + if original is None: + return 0 + + count = 0 + + try: + with tempfile.TemporaryDirectory() as temp_dir: + with zipfile.ZipFile(original, "r") as zip_ref: + zip_ref.extractall(temp_dir) + + doc_xml_path = temp_dir + "/word/document.xml" + root = lxml.etree.parse(doc_xml_path).getroot() + + paragraphs = root.findall(f".//{{{self.WORD_2006_NAMESPACE}}}p") + count = len(paragraphs) + + except Exception as e: + print(f"Error counting paragraphs in original document: {e}") + + return count + + def validate_insertions(self): + errors = [] + + for xml_file in self.xml_files: + if xml_file.name != "document.xml": + continue + + try: + root = lxml.etree.parse(str(xml_file)).getroot() + namespaces = {"w": self.WORD_2006_NAMESPACE} + + invalid_elements = root.xpath( + ".//w:ins//w:delText[not(ancestor::w:del)]", namespaces=namespaces + ) + + for elem in invalid_elements: + text_preview = ( + repr(elem.text or "")[:50] + "..." + if len(repr(elem.text or "")) > 50 + else repr(elem.text or "") + ) + errors.append( + f" {xml_file.relative_to(self.unpacked_dir)}: " + f"Line {elem.sourceline}: within : {text_preview}" + ) + + except (lxml.etree.XMLSyntaxError, Exception) as e: + errors.append( + f" {xml_file.relative_to(self.unpacked_dir)}: Error: {e}" + ) + + if errors: + print(f"FAILED - Found {len(errors)} insertion validation violations:") + for error in errors: + print(error) + return False + else: + if self.verbose: + print("PASSED - No w:delText elements within w:ins elements") + return True + + def compare_paragraph_counts(self): + original_count = self.count_paragraphs_in_original() + new_count = self.count_paragraphs_in_unpacked() + + diff = new_count - original_count + diff_str = f"+{diff}" if diff > 0 else str(diff) + print(f"\nParagraphs: {original_count} → {new_count} ({diff_str})") + + def _parse_id_value(self, val: str, base: int = 16) -> int: + return int(val, base) + + def validate_id_constraints(self): + errors = [] + para_id_attr = f"{{{self.W14_NAMESPACE}}}paraId" + durable_id_attr = f"{{{self.W16CID_NAMESPACE}}}durableId" + + for xml_file in self.xml_files: + try: + for elem in lxml.etree.parse(str(xml_file)).iter(): + if val := elem.get(para_id_attr): + if self._parse_id_value(val, base=16) >= 0x80000000: + errors.append( + f" {xml_file.name}:{elem.sourceline}: paraId={val} >= 0x80000000" + ) + + if val := elem.get(durable_id_attr): + if xml_file.name == "numbering.xml": + try: + if self._parse_id_value(val, base=10) >= 0x7FFFFFFF: + errors.append( + f" {xml_file.name}:{elem.sourceline}: " + f"durableId={val} >= 0x7FFFFFFF" + ) + except ValueError: + errors.append( + f" {xml_file.name}:{elem.sourceline}: " + f"durableId={val} must be decimal in numbering.xml" + ) + else: + if self._parse_id_value(val, base=16) >= 0x7FFFFFFF: + errors.append( + f" {xml_file.name}:{elem.sourceline}: " + f"durableId={val} >= 0x7FFFFFFF" + ) + except Exception: + pass + + if errors: + print(f"FAILED - {len(errors)} ID constraint violations:") + for e in errors: + print(e) + elif self.verbose: + print("PASSED - All paraId/durableId values within constraints") + return not errors + + def validate_comment_markers(self): + errors = [] + + document_xml = None + comments_xml = None + for xml_file in self.xml_files: + if xml_file.name == "document.xml" and "word" in str(xml_file): + document_xml = xml_file + elif xml_file.name == "comments.xml": + comments_xml = xml_file + + if not document_xml: + if self.verbose: + print("PASSED - No document.xml found (skipping comment validation)") + return True + + try: + doc_root = lxml.etree.parse(str(document_xml)).getroot() + namespaces = {"w": self.WORD_2006_NAMESPACE} + + range_starts = { + elem.get(f"{{{self.WORD_2006_NAMESPACE}}}id") + for elem in doc_root.xpath( + ".//w:commentRangeStart", namespaces=namespaces + ) + } + range_ends = { + elem.get(f"{{{self.WORD_2006_NAMESPACE}}}id") + for elem in doc_root.xpath( + ".//w:commentRangeEnd", namespaces=namespaces + ) + } + references = { + elem.get(f"{{{self.WORD_2006_NAMESPACE}}}id") + for elem in doc_root.xpath( + ".//w:commentReference", namespaces=namespaces + ) + } + + orphaned_ends = range_ends - range_starts + for comment_id in sorted( + orphaned_ends, key=lambda x: int(x) if x and x.isdigit() else 0 + ): + errors.append( + f' document.xml: commentRangeEnd id="{comment_id}" has no matching commentRangeStart' + ) + + orphaned_starts = range_starts - range_ends + for comment_id in sorted( + orphaned_starts, key=lambda x: int(x) if x and x.isdigit() else 0 + ): + errors.append( + f' document.xml: commentRangeStart id="{comment_id}" has no matching commentRangeEnd' + ) + + comment_ids = set() + if comments_xml and comments_xml.exists(): + comments_root = lxml.etree.parse(str(comments_xml)).getroot() + comment_ids = { + elem.get(f"{{{self.WORD_2006_NAMESPACE}}}id") + for elem in comments_root.xpath( + ".//w:comment", namespaces=namespaces + ) + } + + marker_ids = range_starts | range_ends | references + invalid_refs = marker_ids - comment_ids + for comment_id in sorted( + invalid_refs, key=lambda x: int(x) if x and x.isdigit() else 0 + ): + if comment_id: + errors.append( + f' document.xml: marker id="{comment_id}" references non-existent comment' + ) + + except (lxml.etree.XMLSyntaxError, Exception) as e: + errors.append(f" Error parsing XML: {e}") + + if errors: + print(f"FAILED - {len(errors)} comment marker violations:") + for error in errors: + print(error) + return False + else: + if self.verbose: + print("PASSED - All comment markers properly paired") + return True + + def repair(self) -> int: + repairs = super().repair() + repairs += self.repair_durableId() + return repairs + + def repair_durableId(self) -> int: + repairs = 0 + + for xml_file in self.xml_files: + try: + content = xml_file.read_text(encoding="utf-8") + dom = defusedxml.minidom.parseString(content) + modified = False + + for elem in dom.getElementsByTagName("*"): + if not elem.hasAttribute("w16cid:durableId"): + continue + + durable_id = elem.getAttribute("w16cid:durableId") + needs_repair = False + + if xml_file.name == "numbering.xml": + try: + needs_repair = ( + self._parse_id_value(durable_id, base=10) >= 0x7FFFFFFF + ) + except ValueError: + needs_repair = True + else: + try: + needs_repair = ( + self._parse_id_value(durable_id, base=16) >= 0x7FFFFFFF + ) + except ValueError: + needs_repair = True + + if needs_repair: + value = random.randint(1, 0x7FFFFFFE) + if xml_file.name == "numbering.xml": + new_id = str(value) + else: + new_id = f"{value:08X}" + + elem.setAttribute("w16cid:durableId", new_id) + print( + f" Repaired: {xml_file.name}: durableId {durable_id} → {new_id}" + ) + repairs += 1 + modified = True + + if modified: + xml_file.write_bytes(dom.toxml(encoding="UTF-8")) + + except Exception: + pass + + return repairs + + +if __name__ == "__main__": + raise RuntimeError("This module should not be run directly.") diff --git a/packages/mosaic/framework/skills/docx/scripts/office/validators/pptx.py b/packages/mosaic/framework/skills/docx/scripts/office/validators/pptx.py new file mode 100644 index 00000000..09842aa9 --- /dev/null +++ b/packages/mosaic/framework/skills/docx/scripts/office/validators/pptx.py @@ -0,0 +1,275 @@ +""" +Validator for PowerPoint presentation XML files against XSD schemas. +""" + +import re + +from .base import BaseSchemaValidator + + +class PPTXSchemaValidator(BaseSchemaValidator): + + PRESENTATIONML_NAMESPACE = ( + "http://schemas.openxmlformats.org/presentationml/2006/main" + ) + + ELEMENT_RELATIONSHIP_TYPES = { + "sldid": "slide", + "sldmasterid": "slidemaster", + "notesmasterid": "notesmaster", + "sldlayoutid": "slidelayout", + "themeid": "theme", + "tablestyleid": "tablestyles", + } + + def validate(self): + if not self.validate_xml(): + return False + + all_valid = True + if not self.validate_namespaces(): + all_valid = False + + if not self.validate_unique_ids(): + all_valid = False + + if not self.validate_uuid_ids(): + all_valid = False + + if not self.validate_file_references(): + all_valid = False + + if not self.validate_slide_layout_ids(): + all_valid = False + + if not self.validate_content_types(): + all_valid = False + + if not self.validate_against_xsd(): + all_valid = False + + if not self.validate_notes_slide_references(): + all_valid = False + + if not self.validate_all_relationship_ids(): + all_valid = False + + if not self.validate_no_duplicate_slide_layouts(): + all_valid = False + + return all_valid + + def validate_uuid_ids(self): + import lxml.etree + + errors = [] + uuid_pattern = re.compile( + r"^[\{\(]?[0-9A-Fa-f]{8}-?[0-9A-Fa-f]{4}-?[0-9A-Fa-f]{4}-?[0-9A-Fa-f]{4}-?[0-9A-Fa-f]{12}[\}\)]?$" + ) + + for xml_file in self.xml_files: + try: + root = lxml.etree.parse(str(xml_file)).getroot() + + for elem in root.iter(): + for attr, value in elem.attrib.items(): + attr_name = attr.split("}")[-1].lower() + if attr_name == "id" or attr_name.endswith("id"): + if self._looks_like_uuid(value): + if not uuid_pattern.match(value): + errors.append( + f" {xml_file.relative_to(self.unpacked_dir)}: " + f"Line {elem.sourceline}: ID '{value}' appears to be a UUID but contains invalid hex characters" + ) + + except (lxml.etree.XMLSyntaxError, Exception) as e: + errors.append( + f" {xml_file.relative_to(self.unpacked_dir)}: Error: {e}" + ) + + if errors: + print(f"FAILED - Found {len(errors)} UUID ID validation errors:") + for error in errors: + print(error) + return False + else: + if self.verbose: + print("PASSED - All UUID-like IDs contain valid hex values") + return True + + def _looks_like_uuid(self, value): + clean_value = value.strip("{}()").replace("-", "") + return len(clean_value) == 32 and all(c.isalnum() for c in clean_value) + + def validate_slide_layout_ids(self): + import lxml.etree + + errors = [] + + slide_masters = list(self.unpacked_dir.glob("ppt/slideMasters/*.xml")) + + if not slide_masters: + if self.verbose: + print("PASSED - No slide masters found") + return True + + for slide_master in slide_masters: + try: + root = lxml.etree.parse(str(slide_master)).getroot() + + rels_file = slide_master.parent / "_rels" / f"{slide_master.name}.rels" + + if not rels_file.exists(): + errors.append( + f" {slide_master.relative_to(self.unpacked_dir)}: " + f"Missing relationships file: {rels_file.relative_to(self.unpacked_dir)}" + ) + continue + + rels_root = lxml.etree.parse(str(rels_file)).getroot() + + valid_layout_rids = set() + for rel in rels_root.findall( + f".//{{{self.PACKAGE_RELATIONSHIPS_NAMESPACE}}}Relationship" + ): + rel_type = rel.get("Type", "") + if "slideLayout" in rel_type: + valid_layout_rids.add(rel.get("Id")) + + for sld_layout_id in root.findall( + f".//{{{self.PRESENTATIONML_NAMESPACE}}}sldLayoutId" + ): + r_id = sld_layout_id.get( + f"{{{self.OFFICE_RELATIONSHIPS_NAMESPACE}}}id" + ) + layout_id = sld_layout_id.get("id") + + if r_id and r_id not in valid_layout_rids: + errors.append( + f" {slide_master.relative_to(self.unpacked_dir)}: " + f"Line {sld_layout_id.sourceline}: sldLayoutId with id='{layout_id}' " + f"references r:id='{r_id}' which is not found in slide layout relationships" + ) + + except (lxml.etree.XMLSyntaxError, Exception) as e: + errors.append( + f" {slide_master.relative_to(self.unpacked_dir)}: Error: {e}" + ) + + if errors: + print(f"FAILED - Found {len(errors)} slide layout ID validation errors:") + for error in errors: + print(error) + print( + "Remove invalid references or add missing slide layouts to the relationships file." + ) + return False + else: + if self.verbose: + print("PASSED - All slide layout IDs reference valid slide layouts") + return True + + def validate_no_duplicate_slide_layouts(self): + import lxml.etree + + errors = [] + slide_rels_files = list(self.unpacked_dir.glob("ppt/slides/_rels/*.xml.rels")) + + for rels_file in slide_rels_files: + try: + root = lxml.etree.parse(str(rels_file)).getroot() + + layout_rels = [ + rel + for rel in root.findall( + f".//{{{self.PACKAGE_RELATIONSHIPS_NAMESPACE}}}Relationship" + ) + if "slideLayout" in rel.get("Type", "") + ] + + if len(layout_rels) > 1: + errors.append( + f" {rels_file.relative_to(self.unpacked_dir)}: has {len(layout_rels)} slideLayout references" + ) + + except Exception as e: + errors.append( + f" {rels_file.relative_to(self.unpacked_dir)}: Error: {e}" + ) + + if errors: + print("FAILED - Found slides with duplicate slideLayout references:") + for error in errors: + print(error) + return False + else: + if self.verbose: + print("PASSED - All slides have exactly one slideLayout reference") + return True + + def validate_notes_slide_references(self): + import lxml.etree + + errors = [] + notes_slide_references = {} + + slide_rels_files = list(self.unpacked_dir.glob("ppt/slides/_rels/*.xml.rels")) + + if not slide_rels_files: + if self.verbose: + print("PASSED - No slide relationship files found") + return True + + for rels_file in slide_rels_files: + try: + root = lxml.etree.parse(str(rels_file)).getroot() + + for rel in root.findall( + f".//{{{self.PACKAGE_RELATIONSHIPS_NAMESPACE}}}Relationship" + ): + rel_type = rel.get("Type", "") + if "notesSlide" in rel_type: + target = rel.get("Target", "") + if target: + normalized_target = target.replace("../", "") + + slide_name = rels_file.stem.replace( + ".xml", "" + ) + + if normalized_target not in notes_slide_references: + notes_slide_references[normalized_target] = [] + notes_slide_references[normalized_target].append( + (slide_name, rels_file) + ) + + except (lxml.etree.XMLSyntaxError, Exception) as e: + errors.append( + f" {rels_file.relative_to(self.unpacked_dir)}: Error: {e}" + ) + + for target, references in notes_slide_references.items(): + if len(references) > 1: + slide_names = [ref[0] for ref in references] + errors.append( + f" Notes slide '{target}' is referenced by multiple slides: {', '.join(slide_names)}" + ) + for slide_name, rels_file in references: + errors.append(f" - {rels_file.relative_to(self.unpacked_dir)}") + + if errors: + print( + f"FAILED - Found {len([e for e in errors if not e.startswith(' ')])} notes slide reference validation errors:" + ) + for error in errors: + print(error) + print("Each slide may optionally have its own slide file.") + return False + else: + if self.verbose: + print("PASSED - All notes slide references are unique") + return True + + +if __name__ == "__main__": + raise RuntimeError("This module should not be run directly.") diff --git a/packages/mosaic/framework/skills/docx/scripts/office/validators/redlining.py b/packages/mosaic/framework/skills/docx/scripts/office/validators/redlining.py new file mode 100644 index 00000000..71c81b6b --- /dev/null +++ b/packages/mosaic/framework/skills/docx/scripts/office/validators/redlining.py @@ -0,0 +1,247 @@ +""" +Validator for tracked changes in Word documents. +""" + +import subprocess +import tempfile +import zipfile +from pathlib import Path + + +class RedliningValidator: + + def __init__(self, unpacked_dir, original_docx, verbose=False, author="Claude"): + self.unpacked_dir = Path(unpacked_dir) + self.original_docx = Path(original_docx) + self.verbose = verbose + self.author = author + self.namespaces = { + "w": "http://schemas.openxmlformats.org/wordprocessingml/2006/main" + } + + def repair(self) -> int: + return 0 + + def validate(self): + modified_file = self.unpacked_dir / "word" / "document.xml" + if not modified_file.exists(): + print(f"FAILED - Modified document.xml not found at {modified_file}") + return False + + try: + import xml.etree.ElementTree as ET + + tree = ET.parse(modified_file) + root = tree.getroot() + + del_elements = root.findall(".//w:del", self.namespaces) + ins_elements = root.findall(".//w:ins", self.namespaces) + + author_del_elements = [ + elem + for elem in del_elements + if elem.get(f"{{{self.namespaces['w']}}}author") == self.author + ] + author_ins_elements = [ + elem + for elem in ins_elements + if elem.get(f"{{{self.namespaces['w']}}}author") == self.author + ] + + if not author_del_elements and not author_ins_elements: + if self.verbose: + print(f"PASSED - No tracked changes by {self.author} found.") + return True + + except Exception: + pass + + with tempfile.TemporaryDirectory() as temp_dir: + temp_path = Path(temp_dir) + + try: + with zipfile.ZipFile(self.original_docx, "r") as zip_ref: + zip_ref.extractall(temp_path) + except Exception as e: + print(f"FAILED - Error unpacking original docx: {e}") + return False + + original_file = temp_path / "word" / "document.xml" + if not original_file.exists(): + print( + f"FAILED - Original document.xml not found in {self.original_docx}" + ) + return False + + try: + import xml.etree.ElementTree as ET + + modified_tree = ET.parse(modified_file) + modified_root = modified_tree.getroot() + original_tree = ET.parse(original_file) + original_root = original_tree.getroot() + except ET.ParseError as e: + print(f"FAILED - Error parsing XML files: {e}") + return False + + self._remove_author_tracked_changes(original_root) + self._remove_author_tracked_changes(modified_root) + + modified_text = self._extract_text_content(modified_root) + original_text = self._extract_text_content(original_root) + + if modified_text != original_text: + error_message = self._generate_detailed_diff( + original_text, modified_text + ) + print(error_message) + return False + + if self.verbose: + print(f"PASSED - All changes by {self.author} are properly tracked") + return True + + def _generate_detailed_diff(self, original_text, modified_text): + error_parts = [ + f"FAILED - Document text doesn't match after removing {self.author}'s tracked changes", + "", + "Likely causes:", + " 1. Modified text inside another author's or tags", + " 2. Made edits without proper tracked changes", + " 3. Didn't nest inside when deleting another's insertion", + "", + "For pre-redlined documents, use correct patterns:", + " - To reject another's INSERTION: Nest inside their ", + " - To restore another's DELETION: Add new AFTER their ", + "", + ] + + git_diff = self._get_git_word_diff(original_text, modified_text) + if git_diff: + error_parts.extend(["Differences:", "============", git_diff]) + else: + error_parts.append("Unable to generate word diff (git not available)") + + return "\n".join(error_parts) + + def _get_git_word_diff(self, original_text, modified_text): + try: + with tempfile.TemporaryDirectory() as temp_dir: + temp_path = Path(temp_dir) + + original_file = temp_path / "original.txt" + modified_file = temp_path / "modified.txt" + + original_file.write_text(original_text, encoding="utf-8") + modified_file.write_text(modified_text, encoding="utf-8") + + result = subprocess.run( + [ + "git", + "diff", + "--word-diff=plain", + "--word-diff-regex=.", + "-U0", + "--no-index", + str(original_file), + str(modified_file), + ], + capture_output=True, + text=True, + ) + + if result.stdout.strip(): + lines = result.stdout.split("\n") + content_lines = [] + in_content = False + for line in lines: + if line.startswith("@@"): + in_content = True + continue + if in_content and line.strip(): + content_lines.append(line) + + if content_lines: + return "\n".join(content_lines) + + result = subprocess.run( + [ + "git", + "diff", + "--word-diff=plain", + "-U0", + "--no-index", + str(original_file), + str(modified_file), + ], + capture_output=True, + text=True, + ) + + if result.stdout.strip(): + lines = result.stdout.split("\n") + content_lines = [] + in_content = False + for line in lines: + if line.startswith("@@"): + in_content = True + continue + if in_content and line.strip(): + content_lines.append(line) + return "\n".join(content_lines) + + except (subprocess.CalledProcessError, FileNotFoundError, Exception): + pass + + return None + + def _remove_author_tracked_changes(self, root): + ins_tag = f"{{{self.namespaces['w']}}}ins" + del_tag = f"{{{self.namespaces['w']}}}del" + author_attr = f"{{{self.namespaces['w']}}}author" + + for parent in root.iter(): + to_remove = [] + for child in parent: + if child.tag == ins_tag and child.get(author_attr) == self.author: + to_remove.append(child) + for elem in to_remove: + parent.remove(elem) + + deltext_tag = f"{{{self.namespaces['w']}}}delText" + t_tag = f"{{{self.namespaces['w']}}}t" + + for parent in root.iter(): + to_process = [] + for child in parent: + if child.tag == del_tag and child.get(author_attr) == self.author: + to_process.append((child, list(parent).index(child))) + + for del_elem, del_index in reversed(to_process): + for elem in del_elem.iter(): + if elem.tag == deltext_tag: + elem.tag = t_tag + + for child in reversed(list(del_elem)): + parent.insert(del_index, child) + parent.remove(del_elem) + + def _extract_text_content(self, root): + p_tag = f"{{{self.namespaces['w']}}}p" + t_tag = f"{{{self.namespaces['w']}}}t" + + paragraphs = [] + for p_elem in root.findall(f".//{p_tag}"): + text_parts = [] + for t_elem in p_elem.findall(f".//{t_tag}"): + if t_elem.text: + text_parts.append(t_elem.text) + paragraph_text = "".join(text_parts) + if paragraph_text: + paragraphs.append(paragraph_text) + + return "\n".join(paragraphs) + + +if __name__ == "__main__": + raise RuntimeError("This module should not be run directly.") diff --git a/packages/mosaic/framework/skills/docx/scripts/templates/comments.xml b/packages/mosaic/framework/skills/docx/scripts/templates/comments.xml new file mode 100644 index 00000000..cd01a7d7 --- /dev/null +++ b/packages/mosaic/framework/skills/docx/scripts/templates/comments.xml @@ -0,0 +1,3 @@ + + + diff --git a/packages/mosaic/framework/skills/docx/scripts/templates/commentsExtended.xml b/packages/mosaic/framework/skills/docx/scripts/templates/commentsExtended.xml new file mode 100644 index 00000000..411003cc --- /dev/null +++ b/packages/mosaic/framework/skills/docx/scripts/templates/commentsExtended.xml @@ -0,0 +1,3 @@ + + + diff --git a/packages/mosaic/framework/skills/docx/scripts/templates/commentsExtensible.xml b/packages/mosaic/framework/skills/docx/scripts/templates/commentsExtensible.xml new file mode 100644 index 00000000..f5572d71 --- /dev/null +++ b/packages/mosaic/framework/skills/docx/scripts/templates/commentsExtensible.xml @@ -0,0 +1,3 @@ + + + diff --git a/packages/mosaic/framework/skills/docx/scripts/templates/commentsIds.xml b/packages/mosaic/framework/skills/docx/scripts/templates/commentsIds.xml new file mode 100644 index 00000000..32f1629f --- /dev/null +++ b/packages/mosaic/framework/skills/docx/scripts/templates/commentsIds.xml @@ -0,0 +1,3 @@ + + + diff --git a/packages/mosaic/framework/skills/docx/scripts/templates/people.xml b/packages/mosaic/framework/skills/docx/scripts/templates/people.xml new file mode 100644 index 00000000..3803d2de --- /dev/null +++ b/packages/mosaic/framework/skills/docx/scripts/templates/people.xml @@ -0,0 +1,3 @@ + + + diff --git a/packages/mosaic/framework/skills/email-and-password-best-practices/SKILL.md b/packages/mosaic/framework/skills/email-and-password-best-practices/SKILL.md new file mode 100644 index 00000000..d599ba52 --- /dev/null +++ b/packages/mosaic/framework/skills/email-and-password-best-practices/SKILL.md @@ -0,0 +1,224 @@ +--- +name: email-and-password-best-practices +description: This skill provides guidance and enforcement rules for implementing secure email and password authentication using Better Auth. +--- + +## Email Verification Setup + +When enabling email/password authentication, configure `emailVerification.sendVerificationEmail` to verify user email addresses. This helps prevent fake sign-ups and ensures users have access to the email they registered with. + +```ts +import { betterAuth } from 'better-auth'; +import { sendEmail } from './email'; // your email sending function + +export const auth = betterAuth({ + emailVerification: { + sendVerificationEmail: async ({ user, url, token }, request) => { + await sendEmail({ + to: user.email, + subject: 'Verify your email address', + text: `Click the link to verify your email: ${url}`, + }); + }, + }, +}); +``` + +**Note**: The `url` parameter contains the full verification link. The `token` is available if you need to build a custom verification URL. + +### Requiring Email Verification + +For stricter security, enable `emailAndPassword.requireEmailVerification` to block sign-in until the user verifies their email. When enabled, unverified users will receive a new verification email on each sign-in attempt. + +```ts +export const auth = betterAuth({ + emailAndPassword: { + requireEmailVerification: true, + }, +}); +``` + +**Note**: This requires `sendVerificationEmail` to be configured and only applies to email/password sign-ins. + +## Client side validation + +While Better Auth validates inputs server-side, implementing client-side validation is still recommended for two key reasons: + +1. **Improved UX**: Users receive immediate feedback when inputs don't meet requirements, rather than waiting for a server round-trip. +2. **Reduced server load**: Invalid requests are caught early, minimizing unnecessary network traffic to your auth server. + +## Callback URLs + +Always use absolute URLs (including the origin) for callback URLs in sign-up and sign-in requests. This prevents Better Auth from needing to infer the origin, which can cause issues when your backend and frontend are on different domains. + +```ts +const { data, error } = await authClient.signUp.email({ + callbackURL: 'https://example.com/callback', // absolute URL with origin +}); +``` + +## Password Reset Flows + +Password reset flows are essential to any email/password system, we recommend setting this up. + +To allow users to reset a password first you need to provide `sendResetPassword` function to the email and password authenticator. + +```ts +import { betterAuth } from 'better-auth'; +import { sendEmail } from './email'; // your email sending function + +export const auth = betterAuth({ + emailAndPassword: { + enabled: true, + // Custom email sending function to send reset-password email + sendResetPassword: async ({ user, url, token }, request) => { + void sendEmail({ + to: user.email, + subject: 'Reset your password', + text: `Click the link to reset your password: ${url}`, + }); + }, + // Optional event hook + onPasswordReset: async ({ user }, request) => { + // your logic here + console.log(`Password for user ${user.email} has been reset.`); + }, + }, +}); +``` + +### Security considerations + +Better Auth implements several security measures in the password reset flow: + +#### Timing attack prevention + +- **Background email sending**: Better Auth uses `runInBackgroundOrAwait` internally to send reset emails without blocking the response. This prevents attackers from measuring response times to determine if an email exists. +- **Dummy operations on invalid requests**: When a user is not found, Better Auth still performs token generation and a database lookup (with a dummy value) to maintain consistent response times. +- **Constant response message**: The API always returns `"If this email exists in our system, check your email for the reset link"` regardless of whether the user exists. + +On serverless platforms, configure a background task handler to ensure emails are sent reliably: + +```ts +export const auth = betterAuth({ + advanced: { + backgroundTasks: { + handler: (promise) => { + // Use platform-specific methods like waitUntil + waitUntil(promise); + }, + }, + }, +}); +``` + +#### Token security + +- **Cryptographically random tokens**: Reset tokens are generated using `generateId(24)`, producing a 24-character alphanumeric string (a-z, A-Z, 0-9) with high entropy. +- **Token expiration**: Tokens expire after **1 hour** by default. Configure with `resetPasswordTokenExpiresIn` (in seconds): + +```ts +export const auth = betterAuth({ + emailAndPassword: { + enabled: true, + resetPasswordTokenExpiresIn: 60 * 30, // 30 minutes + }, +}); +``` + +- **Single-use tokens**: Tokens are deleted immediately after successful password reset, preventing reuse. + +#### Session revocation + +Enable `revokeSessionsOnPasswordReset` to invalidate all existing sessions when a password is reset. This ensures that if an attacker has an active session, it will be terminated: + +```ts +export const auth = betterAuth({ + emailAndPassword: { + enabled: true, + revokeSessionsOnPasswordReset: true, + }, +}); +``` + +#### Redirect URL validation + +The `redirectTo` parameter is validated against your `trustedOrigins` configuration to prevent open redirect attacks. Malicious redirect URLs will be rejected with a 403 error. + +#### Password requirements + +During password reset, the new password must meet length requirements: + +- **Minimum**: 8 characters (default), configurable via `minPasswordLength` +- **Maximum**: 128 characters (default), configurable via `maxPasswordLength` + +```ts +export const auth = betterAuth({ + emailAndPassword: { + enabled: true, + minPasswordLength: 12, + maxPasswordLength: 256, + }, +}); +``` + +### Sending the password reset + +Once the password reset configurations are set-up, you can now call the `requestPasswordReset` function to send reset password link to user. If the user exists, it will trigger the `sendResetPassword` function you provided in the auth config. + +```ts +const data = await auth.api.requestPasswordReset({ + body: { + email: 'john.doe@example.com', // required + redirectTo: 'https://example.com/reset-password', + }, +}); +``` + +Or authClient: + +```ts +const { data, error } = await authClient.requestPasswordReset({ + email: 'john.doe@example.com', // required + redirectTo: 'https://example.com/reset-password', +}); +``` + +**Note**: While the `email` is required, we also recommend configuring the `redirectTo` for a smoother user experience. + +## Password Hashing + +Better Auth uses `scrypt` by default for password hashing. This is a solid choice because: + +- It's designed to be slow and memory-intensive, making brute-force attacks costly +- It's natively supported by Node.js (no external dependencies) +- OWASP recommends it when Argon2id isn't available + +### Custom Hashing Algorithm + +To use a different algorithm (e.g., Argon2id), provide custom `hash` and `verify` functions in the `emailAndPassword.password` configuration: + +```ts +import { betterAuth } from 'better-auth'; +import { hash, verify, type Options } from '@node-rs/argon2'; + +const argon2Options: Options = { + memoryCost: 65536, // 64 MiB + timeCost: 3, // 3 iterations + parallelism: 4, // 4 parallel lanes + outputLen: 32, // 32 byte output + algorithm: 2, // Argon2id variant +}; + +export const auth = betterAuth({ + emailAndPassword: { + enabled: true, + password: { + hash: (password) => hash(password, argon2Options), + verify: ({ password, hash: storedHash }) => verify(storedHash, password, argon2Options), + }, + }, +}); +``` + +**Note**: If you switch hashing algorithms on an existing system, users with passwords hashed using the old algorithm won't be able to sign in. Plan a migration strategy if needed. diff --git a/packages/mosaic/framework/skills/email-sequence/SKILL.md b/packages/mosaic/framework/skills/email-sequence/SKILL.md new file mode 100644 index 00000000..bbe0de4d --- /dev/null +++ b/packages/mosaic/framework/skills/email-sequence/SKILL.md @@ -0,0 +1,339 @@ +--- +name: email-sequence +version: 1.0.0 +description: When the user wants to create or optimize an email sequence, drip campaign, automated email flow, or lifecycle email program. Also use when the user mentions "email sequence," "drip campaign," "nurture sequence," "onboarding emails," "welcome sequence," "re-engagement emails," "email automation," or "lifecycle emails." For in-app onboarding, see onboarding-cro. +--- + +# Email Sequence Design + +You are an expert in email marketing and automation. Your goal is to create email sequences that nurture relationships, drive action, and move people toward conversion. + +## Initial Assessment + +**Check for product marketing context first:** +If `.mosaic/product-marketing-context.md` exists, read it before asking questions. Use that context and only ask for information not already covered or specific to this task. + +Before creating a sequence, understand: + +1. **Sequence Type** + - Welcome/onboarding sequence + - Lead nurture sequence + - Re-engagement sequence + - Post-purchase sequence + - Event-based sequence + - Educational sequence + - Sales sequence + +2. **Audience Context** + - Who are they? + - What triggered them into this sequence? + - What do they already know/believe? + - What's their current relationship with you? + +3. **Goals** + - Primary conversion goal + - Relationship-building goals + - Segmentation goals + - What defines success? + +--- + +## Core Principles + +### 1. One Email, One Job + +- Each email has one primary purpose +- One main CTA per email +- Don't try to do everything + +### 2. Value Before Ask + +- Lead with usefulness +- Build trust through content +- Earn the right to sell + +### 3. Relevance Over Volume + +- Fewer, better emails win +- Segment for relevance +- Quality > frequency + +### 4. Clear Path Forward + +- Every email moves them somewhere +- Links should do something useful +- Make next steps obvious + +--- + +## Email Sequence Strategy + +### Sequence Length + +- Welcome: 3-7 emails +- Lead nurture: 5-10 emails +- Onboarding: 5-10 emails +- Re-engagement: 3-5 emails + +Depends on: + +- Sales cycle length +- Product complexity +- Relationship stage + +### Timing/Delays + +- Welcome email: Immediately +- Early sequence: 1-2 days apart +- Nurture: 2-4 days apart +- Long-term: Weekly or bi-weekly + +Consider: + +- B2B: Avoid weekends +- B2C: Test weekends +- Time zones: Send at local time + +### Subject Line Strategy + +- Clear > Clever +- Specific > Vague +- Benefit or curiosity-driven +- 40-60 characters ideal +- Test emoji (they're polarizing) + +**Patterns that work:** + +- Question: "Still struggling with X?" +- How-to: "How to [achieve outcome] in [timeframe]" +- Number: "3 ways to [benefit]" +- Direct: "[First name], your [thing] is ready" +- Story tease: "The mistake I made with [topic]" + +### Preview Text + +- Extends the subject line +- ~90-140 characters +- Don't repeat subject line +- Complete the thought or add intrigue + +--- + +## Sequence Types Overview + +### Welcome Sequence (Post-Signup) + +**Length**: 5-7 emails over 12-14 days +**Goal**: Activate, build trust, convert + +Key emails: + +1. Welcome + deliver promised value (immediate) +2. Quick win (day 1-2) +3. Story/Why (day 3-4) +4. Social proof (day 5-6) +5. Overcome objection (day 7-8) +6. Core feature highlight (day 9-11) +7. Conversion (day 12-14) + +### Lead Nurture Sequence (Pre-Sale) + +**Length**: 6-8 emails over 2-3 weeks +**Goal**: Build trust, demonstrate expertise, convert + +Key emails: + +1. Deliver lead magnet + intro (immediate) +2. Expand on topic (day 2-3) +3. Problem deep-dive (day 4-5) +4. Solution framework (day 6-8) +5. Case study (day 9-11) +6. Differentiation (day 12-14) +7. Objection handler (day 15-18) +8. Direct offer (day 19-21) + +### Re-Engagement Sequence + +**Length**: 3-4 emails over 2 weeks +**Trigger**: 30-60 days of inactivity +**Goal**: Win back or clean list + +Key emails: + +1. Check-in (genuine concern) +2. Value reminder (what's new) +3. Incentive (special offer) +4. Last chance (stay or unsubscribe) + +### Onboarding Sequence (Product Users) + +**Length**: 5-7 emails over 14 days +**Goal**: Activate, drive to aha moment, upgrade +**Note**: Coordinate with in-app onboarding—email supports, doesn't duplicate + +Key emails: + +1. Welcome + first step (immediate) +2. Getting started help (day 1) +3. Feature highlight (day 2-3) +4. Success story (day 4-5) +5. Check-in (day 7) +6. Advanced tip (day 10-12) +7. Upgrade/expand (day 14+) + +**For detailed templates**: See [references/sequence-templates.md](references/sequence-templates.md) + +--- + +## Email Types by Category + +### Onboarding Emails + +- New users series +- New customers series +- Key onboarding step reminders +- New user invites + +### Retention Emails + +- Upgrade to paid +- Upgrade to higher plan +- Ask for review +- Proactive support offers +- Product usage reports +- NPS survey +- Referral program + +### Billing Emails + +- Switch to annual +- Failed payment recovery +- Cancellation survey +- Upcoming renewal reminders + +### Usage Emails + +- Daily/weekly/monthly summaries +- Key event notifications +- Milestone celebrations + +### Win-Back Emails + +- Expired trials +- Cancelled customers + +### Campaign Emails + +- Monthly roundup / newsletter +- Seasonal promotions +- Product updates +- Industry news roundup +- Pricing updates + +**For detailed email type reference**: See [references/email-types.md](references/email-types.md) + +--- + +## Email Copy Guidelines + +### Structure + +1. **Hook**: First line grabs attention +2. **Context**: Why this matters to them +3. **Value**: The useful content +4. **CTA**: What to do next +5. **Sign-off**: Human, warm close + +### Formatting + +- Short paragraphs (1-3 sentences) +- White space between sections +- Bullet points for scanability +- Bold for emphasis (sparingly) +- Mobile-first (most read on phone) + +### Tone + +- Conversational, not formal +- First-person (I/we) and second-person (you) +- Active voice +- Read it out loud—does it sound human? + +### Length + +- 50-125 words for transactional +- 150-300 words for educational +- 300-500 words for story-driven + +### CTA Guidelines + +- Buttons for primary actions +- Links for secondary actions +- One clear primary CTA per email +- Button text: Action + outcome + +**For detailed copy, personalization, and testing guidelines**: See [references/copy-guidelines.md](references/copy-guidelines.md) + +--- + +## Output Format + +### Sequence Overview + +``` +Sequence Name: [Name] +Trigger: [What starts the sequence] +Goal: [Primary conversion goal] +Length: [Number of emails] +Timing: [Delay between emails] +Exit Conditions: [When they leave the sequence] +``` + +### For Each Email + +``` +Email [#]: [Name/Purpose] +Send: [Timing] +Subject: [Subject line] +Preview: [Preview text] +Body: [Full copy] +CTA: [Button text] → [Link destination] +Segment/Conditions: [If applicable] +``` + +### Metrics Plan + +What to measure and benchmarks + +--- + +## Task-Specific Questions + +1. What triggers entry to this sequence? +2. What's the primary goal/conversion action? +3. What do they already know about you? +4. What other emails are they receiving? +5. What's your current email performance? + +--- + +## Tool Integrations + +For implementation, see the [tools registry](../../tools/REGISTRY.md). Key email tools: + +| Tool | Best For | MCP | Guide | +| --------------- | -------------------------------- | :-: | --------------------------------------------------------- | +| **Customer.io** | Behavior-based automation | - | [customer-io.md](../../tools/integrations/customer-io.md) | +| **Mailchimp** | SMB email marketing | ✓ | [mailchimp.md](../../tools/integrations/mailchimp.md) | +| **Resend** | Developer-friendly transactional | ✓ | [resend.md](../../tools/integrations/resend.md) | +| **SendGrid** | Transactional email at scale | - | [sendgrid.md](../../tools/integrations/sendgrid.md) | +| **Kit** | Creator/newsletter focused | - | [kit.md](../../tools/integrations/kit.md) | + +--- + +## Related Skills + +- **onboarding-cro**: For in-app onboarding (email supports this) +- **copywriting**: For landing pages emails link to +- **ab-test-setup**: For testing email elements +- **popup-cro**: For email capture popups diff --git a/packages/mosaic/framework/skills/email-sequence/references/copy-guidelines.md b/packages/mosaic/framework/skills/email-sequence/references/copy-guidelines.md new file mode 100644 index 00000000..74bc4c5e --- /dev/null +++ b/packages/mosaic/framework/skills/email-sequence/references/copy-guidelines.md @@ -0,0 +1,112 @@ +# Email Copy Guidelines + +## Structure + +1. **Hook**: First line grabs attention +2. **Context**: Why this matters to them +3. **Value**: The useful content +4. **CTA**: What to do next +5. **Sign-off**: Human, warm close + +## Formatting + +- Short paragraphs (1-3 sentences) +- White space between sections +- Bullet points for scanability +- Bold for emphasis (sparingly) +- Mobile-first (most read on phone) + +## Tone + +- Conversational, not formal +- First-person (I/we) and second-person (you) +- Active voice +- Match your brand but lean friendly +- Read it out loud—does it sound human? + +## Length + +- Shorter is usually better +- 50-125 words for transactional +- 150-300 words for educational +- 300-500 words for story-driven +- If it's long, it better be good + +## CTA Buttons vs. Links + +- Buttons: Primary actions, high-visibility +- Links: Secondary actions, in-text +- One clear primary CTA per email +- Button text: Action + outcome + +--- + +## Personalization + +### Merge Fields + +- First name (fallback to "there" or "friend") +- Company name (B2B) +- Relevant data (usage, plan, etc.) + +### Dynamic Content + +- Based on segment +- Based on behavior +- Based on stage + +### Triggered Emails + +- Action-based sends +- More relevant than time-based +- Examples: Feature used, milestone hit, inactivity + +--- + +## Segmentation Strategies + +### By Behavior + +- Openers vs. non-openers +- Clickers vs. non-clickers +- Active vs. inactive + +### By Stage + +- Trial vs. paid +- New vs. long-term +- Engaged vs. at-risk + +### By Profile + +- Industry/role (B2B) +- Use case / goal +- Company size + +--- + +## Testing and Optimization + +### What to Test + +- Subject lines (highest impact) +- Send times +- Email length +- CTA placement and copy +- Personalization level +- Sequence timing + +### How to Test + +- A/B test one variable at a time +- Sufficient sample size +- Statistical significance +- Document learnings + +### Metrics to Track + +- Open rate (benchmark: 20-40%) +- Click rate (benchmark: 2-5%) +- Unsubscribe rate (keep under 0.5%) +- Conversion rate (specific to sequence goal) +- Revenue per email (if applicable) diff --git a/packages/mosaic/framework/skills/email-sequence/references/email-types.md b/packages/mosaic/framework/skills/email-sequence/references/email-types.md new file mode 100644 index 00000000..8ac816c3 --- /dev/null +++ b/packages/mosaic/framework/skills/email-sequence/references/email-types.md @@ -0,0 +1,577 @@ +# Email Types Reference + +A comprehensive guide to lifecycle and campaign emails. Use this as an audit checklist and implementation reference. + +## Onboarding Emails + +### New Users Series + +**Trigger**: User signs up (free or trial) +**Goal**: Activate user, drive to aha moment +**Typical sequence**: 5-7 emails over 14 days + +- Email 1: Welcome + single next step (immediate) +- Email 2: Quick win / getting started (day 1) +- Email 3: Key feature highlight (day 3) +- Email 4: Success story / social proof (day 5) +- Email 5: Check-in + offer help (day 7) +- Email 6: Advanced tip (day 10) +- Email 7: Upgrade prompt or next milestone (day 14) + +**Key metrics**: Activation rate, feature adoption + +--- + +### New Customers Series + +**Trigger**: User converts to paid +**Goal**: Reinforce purchase decision, drive adoption, reduce early churn +**Typical sequence**: 3-5 emails over 14 days + +- Email 1: Thank you + what's next (immediate) +- Email 2: Getting full value — setup checklist (day 2) +- Email 3: Pro tips for paid features (day 5) +- Email 4: Success story from similar customer (day 7) +- Email 5: Check-in + introduce support resources (day 14) + +**Key point**: Different from new user series—they've committed. Focus on reinforcement and expansion, not conversion. + +--- + +### Key Onboarding Step Reminder + +**Trigger**: User hasn't completed critical setup step after X time +**Goal**: Nudge completion of high-value action +**Format**: Single email or 2-3 email mini-sequence + +**Example triggers**: + +- Hasn't connected integration after 48 hours +- Hasn't invited team member after 3 days +- Hasn't completed profile after 24 hours + +**Copy approach**: + +- Remind them what they started +- Explain why this step matters +- Make it easy (direct link to complete) +- Offer help if stuck + +--- + +### New User Invite + +**Trigger**: Existing user invites teammate +**Goal**: Activate the invited user +**Recipient**: The person being invited + +- Email 1: You've been invited (immediate) +- Email 2: Reminder if not accepted (day 2) +- Email 3: Final reminder (day 5) + +**Copy approach**: + +- Personalize with inviter's name +- Explain what they're joining +- Single CTA to accept invite +- Social proof optional + +--- + +## Retention Emails + +### Upgrade to Paid + +**Trigger**: Free user shows engagement, or trial ending +**Goal**: Convert free to paid +**Typical sequence**: 3-5 emails + +**Trigger options**: + +- Time-based (trial day 10, 12, 14) +- Behavior-based (hit usage limit, used premium feature) +- Engagement-based (highly active free user) + +**Sequence structure**: + +- Value summary: What they've accomplished +- Feature comparison: What they're missing +- Social proof: Who else upgraded +- Urgency: Trial ending, limited offer +- Final: Last chance + easy path + +--- + +### Upgrade to Higher Plan + +**Trigger**: User approaching plan limits or using features available on higher tier +**Goal**: Upsell to next tier +**Format**: Single email or 2-3 email sequence + +**Trigger examples**: + +- 80% of seat limit reached +- 90% of storage/usage limit +- Tried to use higher-tier feature +- Power user behavior patterns + +**Copy approach**: + +- Acknowledge their growth (positive framing) +- Show what next tier unlocks +- Quantify value vs. cost +- Easy upgrade path + +--- + +### Ask for Review + +**Trigger**: Customer milestone (30/60/90 days, key achievement, support resolution) +**Goal**: Generate social proof on G2, Capterra, app stores +**Format**: Single email + +**Best timing**: + +- After positive support interaction +- After achieving measurable result +- After renewal +- NOT after billing issues or bugs + +**Copy approach**: + +- Thank them for being a customer +- Mention specific value/milestone if possible +- Explain why reviews matter (help others decide) +- Direct link to review platform +- Keep it short—this is an ask + +--- + +### Offer Support Proactively + +**Trigger**: Signs of struggle (drop in usage, failed actions, error encounters) +**Goal**: Save at-risk user, improve experience +**Format**: Single email + +**Trigger examples**: + +- Usage dropped significantly week-over-week +- Multiple failed attempts at action +- Viewed help docs repeatedly +- Stuck at same onboarding step + +**Copy approach**: + +- Genuine concern tone +- Specific: "I noticed you..." (if data allows) +- Offer direct help (not just link to docs) +- Personal from support or CSM +- No sales pitch—pure help + +--- + +### Product Usage Report + +**Trigger**: Time-based (weekly, monthly, quarterly) +**Goal**: Demonstrate value, drive engagement, reduce churn +**Format**: Single email, recurring + +**What to include**: + +- Key metrics/activity summary +- Comparison to previous period +- Achievements/milestones +- Suggestions for improvement +- Light CTA to explore more + +**Examples**: + +- "You saved X hours this month" +- "Your team completed X projects" +- "You're in the top X% of users" + +**Key point**: Make them feel good and remind them of value delivered. + +--- + +### NPS Survey + +**Trigger**: Time-based (quarterly) or event-based (post-milestone) +**Goal**: Measure satisfaction, identify promoters and detractors +**Format**: Single email + +**Best practices**: + +- Keep it simple: Just the NPS question initially +- Follow-up form for "why" based on score +- Personal sender (CEO, founder, CSM) +- Tell them how you'll use feedback + +**Follow-up based on score**: + +- Promoters (9-10): Thank + ask for review/referral +- Passives (7-8): Ask what would make it a 10 +- Detractors (0-6): Personal outreach to understand issues + +--- + +### Referral Program + +**Trigger**: Customer milestone, promoter NPS score, or campaign +**Goal**: Generate referrals +**Format**: Single email or periodic reminders + +**Good timing**: + +- After positive NPS response +- After customer achieves result +- After renewal +- Seasonal campaigns + +**Copy approach**: + +- Remind them of their success +- Explain the referral offer clearly +- Make sharing easy (unique link) +- Show what's in it for them AND referee + +--- + +## Billing Emails + +### Switch to Annual + +**Trigger**: Monthly subscriber at renewal time or campaign +**Goal**: Convert monthly to annual (improve LTV, reduce churn) +**Format**: Single email or 2-email sequence + +**Value proposition**: + +- Calculate exact savings +- Additional benefits (if any) +- Lock in current price messaging +- Easy one-click switch + +**Best timing**: + +- Around monthly renewal date +- End of year / new year +- After 3-6 months of loyalty +- Price increase announcement (lock in old rate) + +--- + +### Failed Payment Recovery + +**Trigger**: Payment fails +**Goal**: Recover revenue, retain customer +**Typical sequence**: 3-4 emails over 7-14 days + +**Sequence structure**: + +- Email 1 (Day 0): Friendly notice, update payment link +- Email 2 (Day 3): Reminder, service may be interrupted +- Email 3 (Day 7): Urgent, account will be suspended +- Email 4 (Day 10-14): Final notice, what they'll lose + +**Copy approach**: + +- Assume it's an accident (card expired, etc.) +- Clear, direct, no guilt +- Single CTA to update payment +- Explain what happens if not resolved + +**Key metrics**: Recovery rate, time to recovery + +--- + +### Cancellation Survey + +**Trigger**: User cancels subscription +**Goal**: Learn why, opportunity to save +**Format**: Single email (immediate) + +**Options**: + +- In-app survey at cancellation (better completion) +- Follow-up email if they skip in-app +- Personal outreach for high-value accounts + +**Questions to ask**: + +- Primary reason for cancelling +- What could we have done better +- Would anything change your mind +- Can we help with transition + +**Winback opportunity**: Based on reason, offer targeted save (discount, pause, downgrade, training). + +--- + +### Upcoming Renewal Reminder + +**Trigger**: X days before renewal (14 or 30 days typical) +**Goal**: No surprise charges, opportunity to expand +**Format**: Single email + +**What to include**: + +- Renewal date and amount +- What's included in renewal +- How to update payment/plan +- Changes to pricing/features (if any) +- Optional: Upsell opportunity + +**Required for**: Annual subscriptions, high-value contracts + +--- + +## Usage Emails + +### Daily/Weekly/Monthly Summary + +**Trigger**: Time-based +**Goal**: Drive engagement, demonstrate value +**Format**: Single email, recurring + +**Content by frequency**: + +- **Daily**: Notifications, quick stats (for high-engagement products) +- **Weekly**: Activity summary, highlights, suggestions +- **Monthly**: Comprehensive report, achievements, ROI if calculable + +**Structure**: + +- Key metrics at a glance +- Notable achievements +- Activity breakdown +- Suggestions / what to try next +- CTA to dive deeper + +**Personalization**: Must be relevant to their actual usage. Empty reports are worse than no report. + +--- + +### Key Event or Milestone Notifications + +**Trigger**: Specific achievement or event +**Goal**: Celebrate, drive continued engagement +**Format**: Single email per event + +**Milestone examples**: + +- First [action] completed +- 10th/100th [thing] created +- Goal achieved +- Team collaboration milestone +- Usage streak + +**Copy approach**: + +- Celebration tone +- Specific achievement +- Context (compared to others, compared to before) +- What's next / next milestone + +--- + +## Win-Back Emails + +### Expired Trials + +**Trigger**: Trial ended without conversion +**Goal**: Convert or re-engage +**Typical sequence**: 3-4 emails over 30 days + +**Sequence structure**: + +- Email 1 (Day 1 post-expiry): Trial ended, here's what you're missing +- Email 2 (Day 7): What held you back? (gather feedback) +- Email 3 (Day 14): Incentive offer (discount, extended trial) +- Email 4 (Day 30): Final reach-out, door is open + +**Segmentation**: Different approach based on trial engagement level: + +- High engagement: Focus on removing friction to convert +- Low engagement: Offer fresh start, more onboarding help +- No engagement: Ask what happened, offer demo/call + +--- + +### Cancelled Customers + +**Trigger**: Time after cancellation (30, 60, 90 days) +**Goal**: Win back churned customers +**Typical sequence**: 2-3 emails spread over 90 days + +**Sequence structure**: + +- Email 1 (Day 30): What's new since you left +- Email 2 (Day 60): We've addressed [common reason] +- Email 3 (Day 90): Special offer to return + +**Copy approach**: + +- No guilt, no desperation +- Genuine updates and improvements +- Personalize based on cancellation reason if known +- Make return easy + +**Key point**: They're more likely to return if their reason was addressed. + +--- + +## Campaign Emails + +### Monthly Roundup / Newsletter + +**Trigger**: Time-based (monthly) +**Goal**: Engagement, brand presence, content distribution +**Format**: Single email, recurring + +**Content mix**: + +- Product updates and tips +- Customer stories +- Educational content +- Company news +- Industry insights + +**Best practices**: + +- Consistent send day/time +- Scannable format +- Mix of content types +- One primary CTA focus +- Unsubscribe is okay—keeps list healthy + +--- + +### Seasonal Promotions + +**Trigger**: Calendar events (Black Friday, New Year, etc.) +**Goal**: Drive conversions with timely offer +**Format**: Campaign burst (2-4 emails) + +**Common opportunities**: + +- New Year (fresh start, annual planning) +- End of fiscal year (budget spending) +- Black Friday / Cyber Monday +- Industry-specific seasons +- Back to school / work + +**Sequence structure**: + +- Announcement: Offer reveal +- Reminder: Midway through promotion +- Last chance: Final hours + +--- + +### Product Updates + +**Trigger**: New feature release +**Goal**: Adoption, engagement, demonstrate momentum +**Format**: Single email per major release + +**What to include**: + +- What's new (clear and simple) +- Why it matters (benefit, not just feature) +- How to use it (direct link) +- Who asked for it (community acknowledgment) + +**Segmentation**: Consider targeting based on relevance: + +- Users who would benefit most +- Users who requested feature +- Power users first (for beta feel) + +--- + +### Industry News Roundup + +**Trigger**: Time-based (weekly or monthly) +**Goal**: Thought leadership, engagement, brand value +**Format**: Curated newsletter + +**Content**: + +- Curated news and links +- Your take / commentary +- What it means for readers +- How your product helps + +**Best for**: B2B products where customers care about industry trends. + +--- + +### Pricing Update + +**Trigger**: Price change announcement +**Goal**: Transparent communication, minimize churn +**Format**: Single email (or sequence for major changes) + +**Timeline**: + +- Announce 30-60 days before change +- Reminder 14 days before +- Final notice 7 days before + +**Copy approach**: + +- Clear, direct, transparent +- Explain the why (value delivered, costs increased) +- Grandfather if possible (lock in old rate) +- Give options (annual lock-in, downgrade) + +**Important**: Honesty and advance notice build trust even when price increases. + +--- + +## Email Audit Checklist + +Use this to audit your current email program: + +### Onboarding + +- [ ] New users series +- [ ] New customers series +- [ ] Key onboarding step reminders +- [ ] New user invite sequence + +### Retention + +- [ ] Upgrade to paid sequence +- [ ] Upgrade to higher plan triggers +- [ ] Ask for review (timed properly) +- [ ] Proactive support outreach +- [ ] Product usage reports +- [ ] NPS survey +- [ ] Referral program emails + +### Billing + +- [ ] Switch to annual campaign +- [ ] Failed payment recovery sequence +- [ ] Cancellation survey +- [ ] Upcoming renewal reminders + +### Usage + +- [ ] Daily/weekly/monthly summaries +- [ ] Key event notifications +- [ ] Milestone celebrations + +### Win-Back + +- [ ] Expired trial sequence +- [ ] Cancelled customer sequence + +### Campaigns + +- [ ] Monthly roundup / newsletter +- [ ] Seasonal promotion calendar +- [ ] Product update announcements +- [ ] Pricing update communications diff --git a/packages/mosaic/framework/skills/email-sequence/references/sequence-templates.md b/packages/mosaic/framework/skills/email-sequence/references/sequence-templates.md new file mode 100644 index 00000000..35846328 --- /dev/null +++ b/packages/mosaic/framework/skills/email-sequence/references/sequence-templates.md @@ -0,0 +1,188 @@ +# Email Sequence Templates + +Detailed templates for common email sequences. + +## Welcome Sequence (Post-Signup) + +**Email 1: Welcome (Immediate)** + +- Subject: Welcome to [Product] — here's your first step +- Deliver what was promised (lead magnet, access, etc.) +- Single next action +- Set expectations for future emails + +**Email 2: Quick Win (Day 1-2)** + +- Subject: Get your first [result] in 10 minutes +- Enable small success +- Build confidence +- Link to helpful resource + +**Email 3: Story/Why (Day 3-4)** + +- Subject: Why we built [Product] +- Origin story or mission +- Connect emotionally +- Show you understand their problem + +**Email 4: Social Proof (Day 5-6)** + +- Subject: How [Customer] achieved [Result] +- Case study or testimonial +- Relatable to their situation +- Soft CTA to explore + +**Email 5: Overcome Objection (Day 7-8)** + +- Subject: "I don't have time for X" — sound familiar? +- Address common hesitation +- Reframe the obstacle +- Show easy path forward + +**Email 6: Core Feature (Day 9-11)** + +- Subject: Have you tried [Feature] yet? +- Highlight underused capability +- Show clear benefit +- Direct CTA to try it + +**Email 7: Conversion (Day 12-14)** + +- Subject: Ready to [upgrade/buy/commit]? +- Summarize value +- Clear offer +- Urgency if appropriate +- Risk reversal (guarantee, trial) + +--- + +## Lead Nurture Sequence (Pre-Sale) + +**Email 1: Deliver + Introduce (Immediate)** + +- Deliver the lead magnet +- Brief intro to who you are +- Preview what's coming + +**Email 2: Expand on Topic (Day 2-3)** + +- Related insight to lead magnet +- Establish expertise +- Light CTA to content + +**Email 3: Problem Deep-Dive (Day 4-5)** + +- Articulate their problem deeply +- Show you understand +- Hint at solution + +**Email 4: Solution Framework (Day 6-8)** + +- Your approach/methodology +- Educational, not salesy +- Builds toward your product + +**Email 5: Case Study (Day 9-11)** + +- Real results from real customer +- Specific and relatable +- Soft CTA + +**Email 6: Differentiation (Day 12-14)** + +- Why your approach is different +- Address alternatives +- Build preference + +**Email 7: Objection Handler (Day 15-18)** + +- Common concern addressed +- FAQ or myth-busting +- Reduce friction + +**Email 8: Direct Offer (Day 19-21)** + +- Clear pitch +- Strong value proposition +- Specific CTA +- Urgency if available + +--- + +## Re-Engagement Sequence + +**Email 1: Check-In (Day 30-60 of inactivity)** + +- Subject: Is everything okay, [Name]? +- Genuine concern +- Ask what happened +- Easy win to re-engage + +**Email 2: Value Reminder (Day 2-3 after)** + +- Subject: Remember when you [achieved X]? +- Remind of past value +- What's new since they left +- Quick CTA + +**Email 3: Incentive (Day 5-7 after)** + +- Subject: We miss you — here's something special +- Offer if appropriate +- Limited time +- Clear CTA + +**Email 4: Last Chance (Day 10-14 after)** + +- Subject: Should we stop emailing you? +- Honest and direct +- One-click to stay or go +- Clean the list if no response + +--- + +## Onboarding Sequence (Product Users) + +Coordinate with in-app onboarding. Email supports, doesn't duplicate. + +**Email 1: Welcome + First Step (Immediate)** + +- Confirm signup +- One critical action +- Link directly to that action + +**Email 2: Getting Started Help (Day 1)** + +- If they haven't completed step 1 +- Quick tip or video +- Support option + +**Email 3: Feature Highlight (Day 2-3)** + +- Key feature they should know +- Specific use case +- In-app link + +**Email 4: Success Story (Day 4-5)** + +- Customer who succeeded +- Relatable journey +- Motivational + +**Email 5: Check-In (Day 7)** + +- How's it going? +- Ask for feedback +- Offer help + +**Email 6: Advanced Tip (Day 10-12)** + +- Power feature +- For engaged users +- Level-up content + +**Email 7: Upgrade/Expand (Day 14+)** + +- For trial users: conversion push +- For free users: upgrade prompt +- For paid: expansion opportunity diff --git a/packages/mosaic/framework/skills/executing-plans/SKILL.md b/packages/mosaic/framework/skills/executing-plans/SKILL.md new file mode 100644 index 00000000..dac09026 --- /dev/null +++ b/packages/mosaic/framework/skills/executing-plans/SKILL.md @@ -0,0 +1,96 @@ +--- +name: executing-plans +description: Use when you have a written implementation plan to execute in a separate session with review checkpoints +--- + +# Executing Plans + +## Overview + +Load plan, review critically, execute tasks in batches, report for review between batches. + +**Core principle:** Batch execution with checkpoints for architect review. + +**Announce at start:** "I'm using the executing-plans skill to implement this plan." + +## The Process + +### Step 1: Load and Review Plan + +1. Read plan file +2. Review critically - identify any questions or concerns about the plan +3. If concerns: Raise them with your human partner before starting +4. If no concerns: Create TodoWrite and proceed + +### Step 2: Execute Batch + +**Default: First 3 tasks** + +For each task: + +1. Mark as in_progress +2. Follow each step exactly (plan has bite-sized steps) +3. Run verifications as specified +4. Mark as completed + +### Step 3: Report + +When batch complete: + +- Show what was implemented +- Show verification output +- Say: "Ready for feedback." + +### Step 4: Continue + +Based on feedback: + +- Apply changes if needed +- Execute next batch +- Repeat until complete + +### Step 5: Complete Development + +After all tasks complete and verified: + +- Announce: "I'm using the finishing-a-development-branch skill to complete this work." +- **REQUIRED SUB-SKILL:** Use superpowers:finishing-a-development-branch +- Follow that skill to verify tests, present options, execute choice + +## When to Stop and Ask for Help + +**STOP executing immediately when:** + +- Hit a blocker mid-batch (missing dependency, test fails, instruction unclear) +- Plan has critical gaps preventing starting +- You don't understand an instruction +- Verification fails repeatedly + +**Ask for clarification rather than guessing.** + +## When to Revisit Earlier Steps + +**Return to Review (Step 1) when:** + +- Partner updates the plan based on your feedback +- Fundamental approach needs rethinking + +**Don't force through blockers** - stop and ask. + +## Remember + +- Review plan critically first +- Follow plan steps exactly +- Don't skip verifications +- Reference skills when plan says to +- Between batches: just report and wait +- Stop when blocked, don't guess +- Never start implementation on main/master branch without explicit user consent + +## Integration + +**Required workflow skills:** + +- **superpowers:using-git-worktrees** - REQUIRED: Set up isolated workspace before starting +- **superpowers:writing-plans** - Creates the plan this skill executes +- **superpowers:finishing-a-development-branch** - Complete development after all tasks diff --git a/packages/mosaic/framework/skills/fastapi/.claude-plugin/plugin.json b/packages/mosaic/framework/skills/fastapi/.claude-plugin/plugin.json new file mode 100644 index 00000000..d35a1222 --- /dev/null +++ b/packages/mosaic/framework/skills/fastapi/.claude-plugin/plugin.json @@ -0,0 +1,12 @@ +{ + "name": "fastapi", + "description": "Optional[str] # Still required!", + "version": "1.0.0", + "author": { + "name": "Jeremy Dawes", + "email": "jeremy@jezweb.net" + }, + "license": "MIT", + "repository": "https://github.com/jezweb/claude-skills", + "keywords": [] +} diff --git a/packages/mosaic/framework/skills/fastapi/SKILL.md b/packages/mosaic/framework/skills/fastapi/SKILL.md new file mode 100644 index 00000000..738d0f56 --- /dev/null +++ b/packages/mosaic/framework/skills/fastapi/SKILL.md @@ -0,0 +1,980 @@ +--- +name: fastapi +description: | + Build Python APIs with FastAPI, Pydantic v2, and SQLAlchemy 2.0 async. Covers project structure, JWT auth, validation, and database integration with uv package manager. Prevents 7 documented errors. + + Use when: creating Python APIs, implementing JWT auth, or troubleshooting 422 validation, CORS, async blocking, form data, background tasks, or OpenAPI schema errors. +user-invocable: true +--- + +# FastAPI Skill + +Production-tested patterns for FastAPI with Pydantic v2, SQLAlchemy 2.0 async, and JWT authentication. + +**Latest Versions** (verified January 2026): + +- FastAPI: 0.128.0 +- Pydantic: 2.11.7 +- SQLAlchemy: 2.0.30 +- Uvicorn: 0.35.0 +- python-jose: 3.3.0 + +**Requirements**: + +- Python 3.9+ (Python 3.8 support dropped in FastAPI 0.125.0) +- Pydantic v2.7.0+ (Pydantic v1 support completely removed in FastAPI 0.128.0) + +--- + +## Quick Start + +### Project Setup with uv + +```bash +# Create project +uv init my-api +cd my-api + +# Add dependencies +uv add fastapi[standard] sqlalchemy[asyncio] aiosqlite python-jose[cryptography] passlib[bcrypt] + +# Run development server +uv run fastapi dev src/main.py +``` + +### Minimal Working Example + +```python +# src/main.py +from fastapi import FastAPI +from pydantic import BaseModel + +app = FastAPI(title="My API") + +class Item(BaseModel): + name: str + price: float + +@app.get("/") +async def root(): + return {"message": "Hello World"} + +@app.post("/items") +async def create_item(item: Item): + return item +``` + +Run: `uv run fastapi dev src/main.py` + +Docs available at: `http://127.0.0.1:8000/docs` + +--- + +## Project Structure (Domain-Based) + +For maintainable projects, organize by domain not file type: + +``` +my-api/ +├── pyproject.toml +├── src/ +│ ├── __init__.py +│ ├── main.py # FastAPI app initialization +│ ├── config.py # Global settings +│ ├── database.py # Database connection +│ │ +│ ├── auth/ # Auth domain +│ │ ├── __init__.py +│ │ ├── router.py # Auth endpoints +│ │ ├── schemas.py # Pydantic models +│ │ ├── models.py # SQLAlchemy models +│ │ ├── service.py # Business logic +│ │ └── dependencies.py # Auth dependencies +│ │ +│ ├── items/ # Items domain +│ │ ├── __init__.py +│ │ ├── router.py +│ │ ├── schemas.py +│ │ ├── models.py +│ │ └── service.py +│ │ +│ └── shared/ # Shared utilities +│ ├── __init__.py +│ └── exceptions.py +└── tests/ + └── test_main.py +``` + +--- + +## Core Patterns + +### Pydantic Schemas (Validation) + +```python +# src/items/schemas.py +from pydantic import BaseModel, Field, ConfigDict +from datetime import datetime +from enum import Enum + +class ItemStatus(str, Enum): + DRAFT = "draft" + PUBLISHED = "published" + ARCHIVED = "archived" + +class ItemBase(BaseModel): + name: str = Field(..., min_length=1, max_length=100) + description: str | None = Field(None, max_length=500) + price: float = Field(..., gt=0, description="Price must be positive") + status: ItemStatus = ItemStatus.DRAFT + +class ItemCreate(ItemBase): + pass + +class ItemUpdate(BaseModel): + name: str | None = Field(None, min_length=1, max_length=100) + description: str | None = None + price: float | None = Field(None, gt=0) + status: ItemStatus | None = None + +class ItemResponse(ItemBase): + id: int + created_at: datetime + + model_config = ConfigDict(from_attributes=True) +``` + +**Key Points**: + +- Use `Field()` for validation constraints +- Separate Create/Update/Response schemas +- `from_attributes=True` enables SQLAlchemy model conversion +- Use `str | None` (Python 3.10+) not `Optional[str]` + +### SQLAlchemy Models (Database) + +```python +# src/items/models.py +from sqlalchemy import String, Float, DateTime, Enum as SQLEnum +from sqlalchemy.orm import Mapped, mapped_column +from datetime import datetime +from src.database import Base +from src.items.schemas import ItemStatus + +class Item(Base): + __tablename__ = "items" + + id: Mapped[int] = mapped_column(primary_key=True) + name: Mapped[str] = mapped_column(String(100)) + description: Mapped[str | None] = mapped_column(String(500), nullable=True) + price: Mapped[float] = mapped_column(Float) + status: Mapped[ItemStatus] = mapped_column( + SQLEnum(ItemStatus), default=ItemStatus.DRAFT + ) + created_at: Mapped[datetime] = mapped_column( + DateTime, default=datetime.utcnow + ) +``` + +### Database Setup (Async SQLAlchemy 2.0) + +```python +# src/database.py +from sqlalchemy.ext.asyncio import create_async_engine, AsyncSession, async_sessionmaker +from sqlalchemy.orm import DeclarativeBase + +DATABASE_URL = "sqlite+aiosqlite:///./database.db" + +engine = create_async_engine(DATABASE_URL, echo=True) +async_session = async_sessionmaker(engine, expire_on_commit=False) + +class Base(DeclarativeBase): + pass + +async def get_db(): + async with async_session() as session: + try: + yield session + await session.commit() + except Exception: + await session.rollback() + raise +``` + +### Router Pattern + +```python +# src/items/router.py +from fastapi import APIRouter, Depends, HTTPException, status +from sqlalchemy.ext.asyncio import AsyncSession +from sqlalchemy import select + +from src.database import get_db +from src.items import schemas, models + +router = APIRouter(prefix="/items", tags=["items"]) + +@router.get("", response_model=list[schemas.ItemResponse]) +async def list_items( + skip: int = 0, + limit: int = 100, + db: AsyncSession = Depends(get_db) +): + result = await db.execute( + select(models.Item).offset(skip).limit(limit) + ) + return result.scalars().all() + +@router.get("/{item_id}", response_model=schemas.ItemResponse) +async def get_item(item_id: int, db: AsyncSession = Depends(get_db)): + result = await db.execute( + select(models.Item).where(models.Item.id == item_id) + ) + item = result.scalar_one_or_none() + if not item: + raise HTTPException(status_code=404, detail="Item not found") + return item + +@router.post("", response_model=schemas.ItemResponse, status_code=status.HTTP_201_CREATED) +async def create_item( + item_in: schemas.ItemCreate, + db: AsyncSession = Depends(get_db) +): + item = models.Item(**item_in.model_dump()) + db.add(item) + await db.commit() + await db.refresh(item) + return item +``` + +### Main App + +```python +# src/main.py +from contextlib import asynccontextmanager +from fastapi import FastAPI +from fastapi.middleware.cors import CORSMiddleware + +from src.database import engine, Base +from src.items.router import router as items_router +from src.auth.router import router as auth_router + +@asynccontextmanager +async def lifespan(app: FastAPI): + # Startup: Create tables + async with engine.begin() as conn: + await conn.run_sync(Base.metadata.create_all) + yield + # Shutdown: cleanup if needed + +app = FastAPI(title="My API", lifespan=lifespan) + +# CORS middleware +app.add_middleware( + CORSMiddleware, + allow_origins=["http://localhost:3000"], # Your frontend + allow_credentials=True, + allow_methods=["*"], + allow_headers=["*"], +) + +# Include routers +app.include_router(auth_router) +app.include_router(items_router) +``` + +--- + +## JWT Authentication + +### Auth Schemas + +```python +# src/auth/schemas.py +from pydantic import BaseModel, EmailStr + +class UserCreate(BaseModel): + email: EmailStr + password: str + +class UserResponse(BaseModel): + id: int + email: str + + model_config = ConfigDict(from_attributes=True) + +class Token(BaseModel): + access_token: str + token_type: str = "bearer" + +class TokenData(BaseModel): + user_id: int | None = None +``` + +### Auth Service + +```python +# src/auth/service.py +from datetime import datetime, timedelta +from jose import JWTError, jwt +from passlib.context import CryptContext +from src.config import settings + +pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto") + +def hash_password(password: str) -> str: + return pwd_context.hash(password) + +def verify_password(plain: str, hashed: str) -> bool: + return pwd_context.verify(plain, hashed) + +def create_access_token(data: dict, expires_delta: timedelta | None = None) -> str: + to_encode = data.copy() + expire = datetime.utcnow() + (expires_delta or timedelta(minutes=15)) + to_encode.update({"exp": expire}) + return jwt.encode(to_encode, settings.SECRET_KEY, algorithm="HS256") + +def decode_token(token: str) -> dict | None: + try: + return jwt.decode(token, settings.SECRET_KEY, algorithms=["HS256"]) + except JWTError: + return None +``` + +### Auth Dependencies + +```python +# src/auth/dependencies.py +from fastapi import Depends, HTTPException, status +from fastapi.security import OAuth2PasswordBearer +from sqlalchemy.ext.asyncio import AsyncSession +from sqlalchemy import select + +from src.database import get_db +from src.auth import service, models, schemas + +oauth2_scheme = OAuth2PasswordBearer(tokenUrl="/auth/login") + +async def get_current_user( + token: str = Depends(oauth2_scheme), + db: AsyncSession = Depends(get_db) +) -> models.User: + credentials_exception = HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Could not validate credentials", + headers={"WWW-Authenticate": "Bearer"}, + ) + + payload = service.decode_token(token) + if payload is None: + raise credentials_exception + + user_id = payload.get("sub") + if user_id is None: + raise credentials_exception + + result = await db.execute( + select(models.User).where(models.User.id == int(user_id)) + ) + user = result.scalar_one_or_none() + + if user is None: + raise credentials_exception + + return user +``` + +### Auth Router + +```python +# src/auth/router.py +from fastapi import APIRouter, Depends, HTTPException, status +from fastapi.security import OAuth2PasswordRequestForm +from sqlalchemy.ext.asyncio import AsyncSession +from sqlalchemy import select + +from src.database import get_db +from src.auth import schemas, models, service +from src.auth.dependencies import get_current_user + +router = APIRouter(prefix="/auth", tags=["auth"]) + +@router.post("/register", response_model=schemas.UserResponse) +async def register( + user_in: schemas.UserCreate, + db: AsyncSession = Depends(get_db) +): + # Check existing + result = await db.execute( + select(models.User).where(models.User.email == user_in.email) + ) + if result.scalar_one_or_none(): + raise HTTPException(status_code=400, detail="Email already registered") + + user = models.User( + email=user_in.email, + hashed_password=service.hash_password(user_in.password) + ) + db.add(user) + await db.commit() + await db.refresh(user) + return user + +@router.post("/login", response_model=schemas.Token) +async def login( + form_data: OAuth2PasswordRequestForm = Depends(), + db: AsyncSession = Depends(get_db) +): + result = await db.execute( + select(models.User).where(models.User.email == form_data.username) + ) + user = result.scalar_one_or_none() + + if not user or not service.verify_password(form_data.password, user.hashed_password): + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Incorrect email or password" + ) + + access_token = service.create_access_token(data={"sub": str(user.id)}) + return schemas.Token(access_token=access_token) + +@router.get("/me", response_model=schemas.UserResponse) +async def get_me(current_user: models.User = Depends(get_current_user)): + return current_user +``` + +### Protect Routes + +```python +# In any router +from src.auth.dependencies import get_current_user +from src.auth.models import User + +@router.post("/items") +async def create_item( + item_in: schemas.ItemCreate, + current_user: User = Depends(get_current_user), # Requires auth + db: AsyncSession = Depends(get_db) +): + item = models.Item(**item_in.model_dump(), user_id=current_user.id) + # ... +``` + +--- + +## Configuration + +```python +# src/config.py +from pydantic_settings import BaseSettings + +class Settings(BaseSettings): + DATABASE_URL: str = "sqlite+aiosqlite:///./database.db" + SECRET_KEY: str = "your-secret-key-change-in-production" + ACCESS_TOKEN_EXPIRE_MINUTES: int = 30 + + class Config: + env_file = ".env" + +settings = Settings() +``` + +Create `.env`: + +``` +DATABASE_URL=sqlite+aiosqlite:///./database.db +SECRET_KEY=your-super-secret-key-here +ACCESS_TOKEN_EXPIRE_MINUTES=30 +``` + +--- + +## Critical Rules + +### Always Do + +1. **Separate Pydantic schemas from SQLAlchemy models** - Different jobs, different files +2. **Use async for I/O operations** - Database, HTTP calls, file access +3. **Validate with Pydantic Field()** - Constraints, defaults, descriptions +4. **Use dependency injection** - `Depends()` for database, auth, validation +5. **Return proper status codes** - 201 for create, 204 for delete, etc. + +### Never Do + +1. **Never use blocking calls in async routes** - No `time.sleep()`, use `asyncio.sleep()` +2. **Never put business logic in routes** - Use service layer +3. **Never hardcode secrets** - Use environment variables +4. **Never skip validation** - Always use Pydantic schemas +5. **Never use `*` in CORS origins for production** - Specify exact origins + +--- + +## Known Issues Prevention + +This skill prevents **7** documented issues from official FastAPI GitHub and release notes. + +### Issue #1: Form Data Loses Field Set Metadata + +**Error**: `model.model_fields_set` includes default values when using `Form()` +**Source**: [GitHub Issue #13399](https://github.com/fastapi/fastapi/issues/13399) +**Why It Happens**: Form data parsing preloads default values and passes them to the validator, making it impossible to distinguish between fields explicitly set by the user and fields using defaults. This bug ONLY affects Form data, not JSON body data. + +**Prevention**: + +```python +# ✗ AVOID: Pydantic model with Form when you need field_set metadata +from typing import Annotated +from fastapi import Form + +@app.post("/form") +async def endpoint(model: Annotated[MyModel, Form()]): + fields = model.model_fields_set # Unreliable! ❌ + +# ✓ USE: Individual form fields or JSON body instead +@app.post("/form-individual") +async def endpoint( + field_1: Annotated[bool, Form()] = True, + field_2: Annotated[str | None, Form()] = None +): + # You know exactly what was provided ✓ + +# ✓ OR: Use JSON body when metadata matters +@app.post("/json") +async def endpoint(model: MyModel): + fields = model.model_fields_set # Works correctly ✓ +``` + +### Issue #2: BackgroundTasks Silently Overwritten by Custom Response + +**Error**: Background tasks added via `BackgroundTasks` dependency don't run +**Source**: [GitHub Issue #11215](https://github.com/fastapi/fastapi/issues/11215) +**Why It Happens**: When you return a custom `Response` with a `background` parameter, it overwrites all tasks added to the injected `BackgroundTasks` dependency. This is not documented and causes silent failures. + +**Prevention**: + +```python +# ✗ WRONG: Mixing both mechanisms +from fastapi import BackgroundTasks +from starlette.responses import Response, BackgroundTask + +@app.get("/") +async def endpoint(tasks: BackgroundTasks): + tasks.add_task(send_email) # This will be lost! ❌ + return Response( + content="Done", + background=BackgroundTask(log_event) # Only this runs + ) + +# ✓ RIGHT: Use only BackgroundTasks dependency +@app.get("/") +async def endpoint(tasks: BackgroundTasks): + tasks.add_task(send_email) + tasks.add_task(log_event) + return {"status": "done"} # All tasks run ✓ + +# ✓ OR: Use only Response background (but can't inject dependencies) +@app.get("/") +async def endpoint(): + return Response( + content="Done", + background=BackgroundTask(log_event) + ) +``` + +**Rule**: Pick ONE mechanism and stick with it. Don't mix injected `BackgroundTasks` with `Response(background=...)`. + +### Issue #3: Optional Form Fields Break with TestClient (Regression) + +**Error**: `422: "Input should be 'abc' or 'def'"` for optional Literal fields +**Source**: [GitHub Issue #12245](https://github.com/fastapi/fastapi/issues/12245) +**Why It Happens**: Starting in FastAPI 0.114.0, optional form fields with `Literal` types fail validation when passed `None` via TestClient. Worked in 0.113.0. + +**Prevention**: + +```python +from typing import Annotated, Literal, Optional +from fastapi import Form +from fastapi.testclient import TestClient + +# ✗ PROBLEMATIC: Optional Literal with Form (breaks in 0.114.0+) +@app.post("/") +async def endpoint( + attribute: Annotated[Optional[Literal["abc", "def"]], Form()] +): + return {"attribute": attribute} + +client = TestClient(app) +data = {"attribute": None} # or omit the field +response = client.post("/", data=data) # Returns 422 ❌ + +# ✓ WORKAROUND 1: Don't pass None explicitly, omit the field +data = {} # Omit instead of None +response = client.post("/", data=data) # Works ✓ + +# ✓ WORKAROUND 2: Avoid Literal types with optional form fields +@app.post("/") +async def endpoint(attribute: Annotated[str | None, Form()] = None): + # Validate in application logic instead + if attribute and attribute not in ["abc", "def"]: + raise HTTPException(400, "Invalid attribute") +``` + +### Issue #4: Pydantic Json Type Doesn't Work with Form Data + +**Error**: `"JSON object must be str, bytes or bytearray"` +**Source**: [GitHub Issue #10997](https://github.com/fastapi/fastapi/issues/10997) +**Why It Happens**: Using Pydantic's `Json` type directly with `Form()` fails. You must accept the field as `str` and parse manually. + +**Prevention**: + +```python +from typing import Annotated +from fastapi import Form +from pydantic import Json, BaseModel + +# ✗ WRONG: Json type directly with Form +@app.post("/broken") +async def broken(json_list: Annotated[Json[list[str]], Form()]) -> list[str]: + return json_list # Returns 422 ❌ + +# ✓ RIGHT: Accept as str, parse with Pydantic +class JsonListModel(BaseModel): + json_list: Json[list[str]] + +@app.post("/working") +async def working(json_list: Annotated[str, Form()]) -> list[str]: + model = JsonListModel(json_list=json_list) # Pydantic parses here + return model.json_list # Works ✓ +``` + +### Issue #5: Annotated with ForwardRef Breaks OpenAPI Generation + +**Error**: Missing or incorrect OpenAPI schema for dependency types +**Source**: [GitHub Issue #13056](https://github.com/fastapi/fastapi/issues/13056) +**Why It Happens**: When using `Annotated` with `Depends()` and a forward reference (from `__future__ import annotations`), OpenAPI schema generation fails or produces incorrect schemas. + +**Prevention**: + +```python +# ✗ PROBLEMATIC: Forward reference with Depends +from __future__ import annotations +from dataclasses import dataclass +from typing import Annotated +from fastapi import Depends, FastAPI + +app = FastAPI() + +def get_potato() -> Potato: # Forward reference + return Potato(color='red', size=10) + +@app.get('/') +async def read_root(potato: Annotated[Potato, Depends(get_potato)]): + return {'Hello': 'World'} +# OpenAPI schema doesn't include Potato definition correctly ❌ + +@dataclass +class Potato: + color: str + size: int + +# ✓ WORKAROUND 1: Don't use __future__ annotations in route files +# Remove: from __future__ import annotations + +# ✓ WORKAROUND 2: Use string literals for type hints +def get_potato() -> "Potato": + return Potato(color='red', size=10) + +# ✓ WORKAROUND 3: Define classes before they're used in dependencies +@dataclass +class Potato: + color: str + size: int + +def get_potato() -> Potato: # Now works ✓ + return Potato(color='red', size=10) +``` + +### Issue #6: Pydantic v2 Path Parameter Union Type Breaking Change + +**Error**: Path parameters with `int | str` always parse as `str` in Pydantic v2 +**Source**: [GitHub Issue #11251](https://github.com/fastapi/fastapi/issues/11251) | Community-sourced +**Why It Happens**: Major breaking change when migrating from Pydantic v1 to v2. Union types with `str` in path/query parameters now always parse as `str` (worked correctly in v1). + +**Prevention**: + +```python +from uuid import UUID + +# ✗ PROBLEMATIC: Union with str in path parameter +@app.get("/int/{path}") +async def int_path(path: int | str): + return str(type(path)) + # Pydantic v1: returns for "123" + # Pydantic v2: returns for "123" ❌ + +@app.get("/uuid/{path}") +async def uuid_path(path: UUID | str): + return str(type(path)) + # Pydantic v1: returns for valid UUID + # Pydantic v2: returns ❌ + +# ✓ RIGHT: Avoid union types with str in path/query parameters +@app.get("/int/{path}") +async def int_path(path: int): + return str(type(path)) # Works correctly ✓ + +# ✓ ALTERNATIVE: Use validators if type coercion needed +from pydantic import field_validator + +class PathParams(BaseModel): + path: int | str + + @field_validator('path') + def coerce_to_int(cls, v): + if isinstance(v, str) and v.isdigit(): + return int(v) + return v +``` + +### Issue #7: ValueError in field_validator Returns 500 Instead of 422 + +**Error**: `500 Internal Server Error` when raising `ValueError` in custom validators +**Source**: [GitHub Discussion #10779](https://github.com/fastapi/fastapi/discussions/10779) | Community-sourced +**Why It Happens**: When raising `ValueError` inside a Pydantic `@field_validator` with Form fields, FastAPI returns 500 Internal Server Error instead of the expected 422 Unprocessable Entity validation error. + +**Prevention**: + +```python +from typing import Annotated +from fastapi import Form +from pydantic import BaseModel, field_validator, ValidationError, Field + +# ✗ WRONG: ValueError in validator +class MyForm(BaseModel): + value: int + + @field_validator('value') + def validate_value(cls, v): + if v < 0: + raise ValueError("Value must be positive") # Returns 500! ❌ + return v + +# ✓ RIGHT 1: Raise ValidationError instead +class MyForm(BaseModel): + value: int + + @field_validator('value') + def validate_value(cls, v): + if v < 0: + raise ValidationError("Value must be positive") # Returns 422 ✓ + return v + +# ✓ RIGHT 2: Use Pydantic's built-in constraints +class MyForm(BaseModel): + value: Annotated[int, Field(gt=0)] # Built-in validation, returns 422 ✓ +``` + +--- + +## Common Errors & Fixes + +### 422 Unprocessable Entity + +**Cause**: Request body doesn't match Pydantic schema + +**Debug**: + +1. Check `/docs` endpoint - test there first +2. Verify JSON structure matches schema +3. Check required vs optional fields + +**Fix**: Add custom validation error handler: + +```python +from fastapi.exceptions import RequestValidationError +from fastapi.responses import JSONResponse + +@app.exception_handler(RequestValidationError) +async def validation_exception_handler(request, exc): + return JSONResponse( + status_code=422, + content={"detail": exc.errors(), "body": exc.body} + ) +``` + +### CORS Errors + +**Cause**: Missing or misconfigured CORS middleware + +**Fix**: + +```python +app.add_middleware( + CORSMiddleware, + allow_origins=["http://localhost:3000"], # Not "*" in production + allow_credentials=True, + allow_methods=["*"], + allow_headers=["*"], +) +``` + +### Async Blocking Event Loop + +**Cause**: Blocking call in async route (e.g., `time.sleep()`, sync database client, CPU-bound operations) + +**Symptoms** (production-scale): + +- Throughput plateaus far earlier than expected +- Latency "balloons" as concurrency increases +- Request pattern looks almost serial under load +- Requests queue indefinitely when event loop is saturated +- Small scattered blocking calls that aren't obvious (not infinite loops) + +**Fix**: Use async alternatives: + +```python +# ✗ WRONG: Blocks event loop +import time +from sqlalchemy import create_engine # Sync client + +@app.get("/users") +async def get_users(): + time.sleep(0.1) # Even small blocking adds up at scale! + result = sync_db_client.query("SELECT * FROM users") # Blocks! + return result + +# ✓ RIGHT 1: Use async database driver +from sqlalchemy.ext.asyncio import AsyncSession +from sqlalchemy import select + +@app.get("/users") +async def get_users(db: AsyncSession = Depends(get_db)): + await asyncio.sleep(0.1) # Non-blocking + result = await db.execute(select(User)) + return result.scalars().all() + +# ✓ RIGHT 2: Use def (not async def) for CPU-bound routes +# FastAPI runs def routes in thread pool automatically +@app.get("/cpu-heavy") +def cpu_heavy_task(): # Note: def not async def + return expensive_cpu_work() # Runs in thread pool ✓ + +# ✓ RIGHT 3: Use run_in_executor for blocking calls in async routes +import asyncio +from concurrent.futures import ThreadPoolExecutor + +executor = ThreadPoolExecutor() + +@app.get("/mixed") +async def mixed_task(): + # Run blocking function in thread pool + result = await asyncio.get_event_loop().run_in_executor( + executor, + blocking_function # Your blocking function + ) + return result +``` + +**Sources**: [Production Case Study (Jan 2026)](https://www.techbuddies.io/2026/01/10/case-study-fixing-fastapi-event-loop-blocking-in-a-high-traffic-api/) | Community-sourced + +### "Field required" for Optional Fields + +**Cause**: Using `Optional[str]` without default + +**Fix**: + +```python +# Wrong +description: Optional[str] # Still required! + +# Right +description: str | None = None # Optional with default +``` + +--- + +## Testing + +```python +# tests/test_main.py +import pytest +from httpx import AsyncClient, ASGITransport +from src.main import app + +@pytest.fixture +async def client(): + async with AsyncClient( + transport=ASGITransport(app=app), + base_url="http://test" + ) as ac: + yield ac + +@pytest.mark.asyncio +async def test_root(client): + response = await client.get("/") + assert response.status_code == 200 + +@pytest.mark.asyncio +async def test_create_item(client): + response = await client.post( + "/items", + json={"name": "Test", "price": 9.99} + ) + assert response.status_code == 201 + assert response.json()["name"] == "Test" +``` + +Run: `uv run pytest` + +--- + +## Deployment + +### Uvicorn (Development) + +```bash +uv run fastapi dev src/main.py +``` + +### Uvicorn (Production) + +```bash +uv run uvicorn src.main:app --host 0.0.0.0 --port 8000 +``` + +### Gunicorn + Uvicorn (Production with workers) + +```bash +uv add gunicorn +uv run gunicorn src.main:app -w 4 -k uvicorn.workers.UvicornWorker --bind 0.0.0.0:8000 +``` + +### Docker + +```dockerfile +FROM python:3.12-slim + +WORKDIR /app +COPY . . + +RUN pip install uv && uv sync + +EXPOSE 8000 +CMD ["uv", "run", "uvicorn", "src.main:app", "--host", "0.0.0.0", "--port", "8000"] +``` + +--- + +## References + +- [FastAPI Documentation](https://fastapi.tiangolo.com/) +- [FastAPI Best Practices](https://github.com/zhanymkanov/fastapi-best-practices) +- [Pydantic v2 Documentation](https://docs.pydantic.dev/) +- [SQLAlchemy 2.0 Async](https://docs.sqlalchemy.org/en/20/orm/extensions/asyncio.html) +- [uv Package Manager](https://docs.astral.sh/uv/) + +--- + +**Last verified**: 2026-01-21 | **Skill version**: 1.1.0 | **Changes**: Added 7 known issues (form data bugs, background tasks, Pydantic v2 migration gotchas), expanded async blocking guidance with production patterns +**Maintainer**: Jezweb | jeremy@jezweb.net diff --git a/packages/mosaic/framework/skills/fastapi/templates/.env.example b/packages/mosaic/framework/skills/fastapi/templates/.env.example new file mode 100644 index 00000000..e5488680 --- /dev/null +++ b/packages/mosaic/framework/skills/fastapi/templates/.env.example @@ -0,0 +1,10 @@ +# Database +DATABASE_URL=sqlite+aiosqlite:///./database.db + +# JWT Authentication +SECRET_KEY=your-super-secret-key-change-in-production +ACCESS_TOKEN_EXPIRE_MINUTES=30 + +# App +APP_NAME=My API +DEBUG=false diff --git a/packages/mosaic/framework/skills/fastapi/templates/pyproject.toml b/packages/mosaic/framework/skills/fastapi/templates/pyproject.toml new file mode 100644 index 00000000..5b7e2f24 --- /dev/null +++ b/packages/mosaic/framework/skills/fastapi/templates/pyproject.toml @@ -0,0 +1,25 @@ +[project] +name = "my-api" +version = "0.1.0" +description = "FastAPI application" +readme = "README.md" +requires-python = ">=3.11" +dependencies = [ + "fastapi[standard]>=0.123.0", + "sqlalchemy[asyncio]>=2.0.30", + "aiosqlite>=0.20.0", + "python-jose[cryptography]>=3.3.0", + "passlib[bcrypt]>=1.7.4", + "pydantic-settings>=2.0.0", +] + +[project.optional-dependencies] +dev = [ + "pytest>=8.0.0", + "pytest-asyncio>=0.23.0", + "httpx>=0.27.0", +] + +[tool.pytest.ini_options] +asyncio_mode = "auto" +testpaths = ["tests"] diff --git a/packages/mosaic/framework/skills/fastapi/templates/src/auth/dependencies.py b/packages/mosaic/framework/skills/fastapi/templates/src/auth/dependencies.py new file mode 100644 index 00000000..d2fbf476 --- /dev/null +++ b/packages/mosaic/framework/skills/fastapi/templates/src/auth/dependencies.py @@ -0,0 +1,64 @@ +"""Authentication dependencies for route protection.""" + +from fastapi import Depends, HTTPException, status +from fastapi.security import OAuth2PasswordBearer +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession + +from src.auth import models, service +from src.database import get_db + +oauth2_scheme = OAuth2PasswordBearer(tokenUrl="/auth/login") + + +async def get_current_user( + token: str = Depends(oauth2_scheme), + db: AsyncSession = Depends(get_db), +) -> models.User: + """ + Dependency to get current authenticated user from JWT token. + + Usage in routes: + @router.get("/protected") + async def protected_route(user: User = Depends(get_current_user)): + return {"user_id": user.id} + """ + credentials_exception = HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Could not validate credentials", + headers={"WWW-Authenticate": "Bearer"}, + ) + + # Decode token + payload = service.decode_token(token) + if payload is None: + raise credentials_exception + + # Get user ID from token + user_id = payload.get("sub") + if user_id is None: + raise credentials_exception + + # Fetch user from database + result = await db.execute( + select(models.User).where(models.User.id == int(user_id)) + ) + user = result.scalar_one_or_none() + + if user is None: + raise credentials_exception + + if not user.is_active: + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="User is inactive", + ) + + return user + + +async def get_current_active_user( + user: models.User = Depends(get_current_user), +) -> models.User: + """Dependency that ensures user is active (already checked in get_current_user).""" + return user diff --git a/packages/mosaic/framework/skills/fastapi/templates/src/auth/models.py b/packages/mosaic/framework/skills/fastapi/templates/src/auth/models.py new file mode 100644 index 00000000..a688df10 --- /dev/null +++ b/packages/mosaic/framework/skills/fastapi/templates/src/auth/models.py @@ -0,0 +1,20 @@ +"""User database model.""" + +from datetime import datetime + +from sqlalchemy import DateTime, String +from sqlalchemy.orm import Mapped, mapped_column + +from src.database import Base + + +class User(Base): + """User model for authentication.""" + + __tablename__ = "users" + + id: Mapped[int] = mapped_column(primary_key=True) + email: Mapped[str] = mapped_column(String(255), unique=True, index=True) + hashed_password: Mapped[str] = mapped_column(String(255)) + is_active: Mapped[bool] = mapped_column(default=True) + created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow) diff --git a/packages/mosaic/framework/skills/fastapi/templates/src/auth/router.py b/packages/mosaic/framework/skills/fastapi/templates/src/auth/router.py new file mode 100644 index 00000000..00b8959e --- /dev/null +++ b/packages/mosaic/framework/skills/fastapi/templates/src/auth/router.py @@ -0,0 +1,89 @@ +"""Authentication routes - register, login, get current user.""" + +from fastapi import APIRouter, Depends, HTTPException, status +from fastapi.security import OAuth2PasswordRequestForm +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession + +from src.auth import models, schemas, service +from src.auth.dependencies import get_current_user +from src.database import get_db + +router = APIRouter(prefix="/auth", tags=["auth"]) + + +@router.post( + "/register", + response_model=schemas.UserResponse, + status_code=status.HTTP_201_CREATED, +) +async def register( + user_in: schemas.UserCreate, + db: AsyncSession = Depends(get_db), +): + """Register a new user.""" + # Check if email already exists + result = await db.execute( + select(models.User).where(models.User.email == user_in.email) + ) + if result.scalar_one_or_none(): + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="Email already registered", + ) + + # Create user + user = models.User( + email=user_in.email, + hashed_password=service.hash_password(user_in.password), + ) + db.add(user) + await db.commit() + await db.refresh(user) + + return user + + +@router.post("/login", response_model=schemas.Token) +async def login( + form_data: OAuth2PasswordRequestForm = Depends(), + db: AsyncSession = Depends(get_db), +): + """ + Login and get access token. + + Note: OAuth2PasswordRequestForm expects 'username' field, + but we use it for email. + """ + # Find user by email (username field) + result = await db.execute( + select(models.User).where(models.User.email == form_data.username) + ) + user = result.scalar_one_or_none() + + # Verify credentials + if not user or not service.verify_password( + form_data.password, user.hashed_password + ): + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Incorrect email or password", + headers={"WWW-Authenticate": "Bearer"}, + ) + + if not user.is_active: + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="User is inactive", + ) + + # Create access token + access_token = service.create_access_token(data={"sub": str(user.id)}) + + return schemas.Token(access_token=access_token) + + +@router.get("/me", response_model=schemas.UserResponse) +async def get_me(current_user: models.User = Depends(get_current_user)): + """Get current authenticated user.""" + return current_user diff --git a/packages/mosaic/framework/skills/fastapi/templates/src/auth/schemas.py b/packages/mosaic/framework/skills/fastapi/templates/src/auth/schemas.py new file mode 100644 index 00000000..dfb4adc1 --- /dev/null +++ b/packages/mosaic/framework/skills/fastapi/templates/src/auth/schemas.py @@ -0,0 +1,33 @@ +"""Pydantic schemas for authentication.""" + +from pydantic import BaseModel, ConfigDict, EmailStr, Field + + +class UserCreate(BaseModel): + """Schema for user registration.""" + + email: EmailStr + password: str = Field(..., min_length=8, description="Minimum 8 characters") + + +class UserResponse(BaseModel): + """Schema for user response (no password).""" + + id: int + email: str + is_active: bool + + model_config = ConfigDict(from_attributes=True) + + +class Token(BaseModel): + """JWT token response.""" + + access_token: str + token_type: str = "bearer" + + +class TokenData(BaseModel): + """Decoded token data.""" + + user_id: int | None = None diff --git a/packages/mosaic/framework/skills/fastapi/templates/src/auth/service.py b/packages/mosaic/framework/skills/fastapi/templates/src/auth/service.py new file mode 100644 index 00000000..f62da24b --- /dev/null +++ b/packages/mosaic/framework/skills/fastapi/templates/src/auth/service.py @@ -0,0 +1,42 @@ +"""Authentication service - password hashing and JWT tokens.""" + +from datetime import datetime, timedelta + +from jose import JWTError, jwt +from passlib.context import CryptContext + +from src.config import settings + +# Password hashing +pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto") + + +def hash_password(password: str) -> str: + """Hash a password using bcrypt.""" + return pwd_context.hash(password) + + +def verify_password(plain_password: str, hashed_password: str) -> bool: + """Verify a password against its hash.""" + return pwd_context.verify(plain_password, hashed_password) + + +def create_access_token(data: dict, expires_delta: timedelta | None = None) -> str: + """Create a JWT access token.""" + to_encode = data.copy() + expire = datetime.utcnow() + ( + expires_delta or timedelta(minutes=settings.ACCESS_TOKEN_EXPIRE_MINUTES) + ) + to_encode.update({"exp": expire}) + return jwt.encode(to_encode, settings.SECRET_KEY, algorithm=settings.ALGORITHM) + + +def decode_token(token: str) -> dict | None: + """Decode and verify a JWT token. Returns None if invalid.""" + try: + payload = jwt.decode( + token, settings.SECRET_KEY, algorithms=[settings.ALGORITHM] + ) + return payload + except JWTError: + return None diff --git a/packages/mosaic/framework/skills/fastapi/templates/src/config.py b/packages/mosaic/framework/skills/fastapi/templates/src/config.py new file mode 100644 index 00000000..d29b8177 --- /dev/null +++ b/packages/mosaic/framework/skills/fastapi/templates/src/config.py @@ -0,0 +1,26 @@ +"""Application configuration using Pydantic Settings.""" + +from pydantic_settings import BaseSettings + + +class Settings(BaseSettings): + """Application settings loaded from environment variables.""" + + # Database + DATABASE_URL: str = "sqlite+aiosqlite:///./database.db" + + # JWT Authentication + SECRET_KEY: str = "change-this-secret-key-in-production" + ACCESS_TOKEN_EXPIRE_MINUTES: int = 30 + ALGORITHM: str = "HS256" + + # App + APP_NAME: str = "My API" + DEBUG: bool = False + + class Config: + env_file = ".env" + env_file_encoding = "utf-8" + + +settings = Settings() diff --git a/packages/mosaic/framework/skills/fastapi/templates/src/database.py b/packages/mosaic/framework/skills/fastapi/templates/src/database.py new file mode 100644 index 00000000..9508380c --- /dev/null +++ b/packages/mosaic/framework/skills/fastapi/templates/src/database.py @@ -0,0 +1,36 @@ +"""Database configuration with async SQLAlchemy 2.0.""" + +from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine +from sqlalchemy.orm import DeclarativeBase + +from src.config import settings + +# Create async engine +engine = create_async_engine( + settings.DATABASE_URL, + echo=settings.DEBUG, +) + +# Session factory +async_session = async_sessionmaker( + engine, + class_=AsyncSession, + expire_on_commit=False, +) + + +class Base(DeclarativeBase): + """Base class for all SQLAlchemy models.""" + + pass + + +async def get_db(): + """Dependency that provides a database session.""" + async with async_session() as session: + try: + yield session + await session.commit() + except Exception: + await session.rollback() + raise diff --git a/packages/mosaic/framework/skills/fastapi/templates/src/main.py b/packages/mosaic/framework/skills/fastapi/templates/src/main.py new file mode 100644 index 00000000..a2ce73f7 --- /dev/null +++ b/packages/mosaic/framework/skills/fastapi/templates/src/main.py @@ -0,0 +1,62 @@ +"""FastAPI application entry point.""" + +from contextlib import asynccontextmanager + +from fastapi import FastAPI +from fastapi.middleware.cors import CORSMiddleware + +from src.config import settings +from src.database import Base, engine + +# Import routers +from src.auth.router import router as auth_router + +# Add more routers as needed: +# from src.items.router import router as items_router + + +@asynccontextmanager +async def lifespan(app: FastAPI): + """Application lifespan handler for startup/shutdown.""" + # Startup: Create database tables + async with engine.begin() as conn: + await conn.run_sync(Base.metadata.create_all) + yield + # Shutdown: Add cleanup here if needed + + +app = FastAPI( + title=settings.APP_NAME, + lifespan=lifespan, +) + +# CORS middleware - configure for your frontend +app.add_middleware( + CORSMiddleware, + allow_origins=[ + "http://localhost:3000", # React dev server + "http://localhost:5173", # Vite dev server + ], + allow_credentials=True, + allow_methods=["*"], + allow_headers=["*"], +) + +# Include routers +app.include_router(auth_router) +# app.include_router(items_router) + + +@app.get("/") +async def root(): + """Health check endpoint.""" + return {"status": "ok", "app": settings.APP_NAME} + + +@app.get("/health") +async def health(): + """Detailed health check.""" + return { + "status": "healthy", + "database": "connected", + } diff --git a/packages/mosaic/framework/skills/fastapi/templates/tests/test_main.py b/packages/mosaic/framework/skills/fastapi/templates/tests/test_main.py new file mode 100644 index 00000000..e2ccb010 --- /dev/null +++ b/packages/mosaic/framework/skills/fastapi/templates/tests/test_main.py @@ -0,0 +1,97 @@ +"""Basic API tests.""" + +import pytest +from httpx import ASGITransport, AsyncClient + +from src.main import app + + +@pytest.fixture +async def client(): + """Async test client fixture.""" + async with AsyncClient( + transport=ASGITransport(app=app), + base_url="http://test", + ) as ac: + yield ac + + +@pytest.mark.asyncio +async def test_root(client: AsyncClient): + """Test root endpoint returns ok status.""" + response = await client.get("/") + assert response.status_code == 200 + assert response.json()["status"] == "ok" + + +@pytest.mark.asyncio +async def test_health(client: AsyncClient): + """Test health endpoint.""" + response = await client.get("/health") + assert response.status_code == 200 + assert response.json()["status"] == "healthy" + + +@pytest.mark.asyncio +async def test_register_user(client: AsyncClient): + """Test user registration.""" + response = await client.post( + "/auth/register", + json={"email": "test@example.com", "password": "testpassword123"}, + ) + assert response.status_code == 201 + data = response.json() + assert data["email"] == "test@example.com" + assert "id" in data + + +@pytest.mark.asyncio +async def test_login(client: AsyncClient): + """Test user login.""" + # First register + await client.post( + "/auth/register", + json={"email": "login@example.com", "password": "testpassword123"}, + ) + + # Then login + response = await client.post( + "/auth/login", + data={"username": "login@example.com", "password": "testpassword123"}, + ) + assert response.status_code == 200 + data = response.json() + assert "access_token" in data + assert data["token_type"] == "bearer" + + +@pytest.mark.asyncio +async def test_get_me_unauthorized(client: AsyncClient): + """Test /auth/me without token returns 401.""" + response = await client.get("/auth/me") + assert response.status_code == 401 + + +@pytest.mark.asyncio +async def test_get_me_authorized(client: AsyncClient): + """Test /auth/me with valid token returns user.""" + # Register + await client.post( + "/auth/register", + json={"email": "me@example.com", "password": "testpassword123"}, + ) + + # Login + login_response = await client.post( + "/auth/login", + data={"username": "me@example.com", "password": "testpassword123"}, + ) + token = login_response.json()["access_token"] + + # Get me + response = await client.get( + "/auth/me", + headers={"Authorization": f"Bearer {token}"}, + ) + assert response.status_code == 200 + assert response.json()["email"] == "me@example.com" diff --git a/packages/mosaic/framework/skills/finishing-a-development-branch/SKILL.md b/packages/mosaic/framework/skills/finishing-a-development-branch/SKILL.md new file mode 100644 index 00000000..6665dc6d --- /dev/null +++ b/packages/mosaic/framework/skills/finishing-a-development-branch/SKILL.md @@ -0,0 +1,213 @@ +--- +name: finishing-a-development-branch +description: Use when implementation is complete, all tests pass, and you need to decide how to integrate the work - guides completion of development work by presenting structured options for merge, PR, or cleanup +--- + +# Finishing a Development Branch + +## Overview + +Guide completion of development work by presenting clear options and handling chosen workflow. + +**Core principle:** Verify tests → Present options → Execute choice → Clean up. + +**Announce at start:** "I'm using the finishing-a-development-branch skill to complete this work." + +## The Process + +### Step 1: Verify Tests + +**Before presenting options, verify tests pass:** + +```bash +# Run project's test suite +npm test / cargo test / pytest / go test ./... +``` + +**If tests fail:** + +``` +Tests failing ( failures). Must fix before completing: + +[Show failures] + +Cannot proceed with merge/PR until tests pass. +``` + +Stop. Don't proceed to Step 2. + +**If tests pass:** Continue to Step 2. + +### Step 2: Determine Base Branch + +```bash +# Try common base branches +git merge-base HEAD main 2>/dev/null || git merge-base HEAD master 2>/dev/null +``` + +Or ask: "This branch split from main - is that correct?" + +### Step 3: Present Options + +Present exactly these 4 options: + +``` +Implementation complete. What would you like to do? + +1. Merge back to locally +2. Push and create a Pull Request +3. Keep the branch as-is (I'll handle it later) +4. Discard this work + +Which option? +``` + +**Don't add explanation** - keep options concise. + +### Step 4: Execute Choice + +#### Option 1: Merge Locally + +```bash +# Switch to base branch +git checkout + +# Pull latest +git pull + +# Merge feature branch +git merge + +# Verify tests on merged result + + +# If tests pass +git branch -d +``` + +Then: Cleanup worktree (Step 5) + +#### Option 2: Push and Create PR + +```bash +# Push branch +git push -u origin + +# Create PR +gh pr create --title "" --body "$(cat <<'EOF' +## Summary +<2-3 bullets of what changed> + +## Test Plan +- [ ] <verification steps> +EOF +)" +``` + +Then: Cleanup worktree (Step 5) + +#### Option 3: Keep As-Is + +Report: "Keeping branch <name>. Worktree preserved at <path>." + +**Don't cleanup worktree.** + +#### Option 4: Discard + +**Confirm first:** + +``` +This will permanently delete: +- Branch <name> +- All commits: <commit-list> +- Worktree at <path> + +Type 'discard' to confirm. +``` + +Wait for exact confirmation. + +If confirmed: + +```bash +git checkout <base-branch> +git branch -D <feature-branch> +``` + +Then: Cleanup worktree (Step 5) + +### Step 5: Cleanup Worktree + +**For Options 1, 2, 4:** + +Check if in worktree: + +```bash +git worktree list | grep $(git branch --show-current) +``` + +If yes: + +```bash +git worktree remove <worktree-path> +``` + +**For Option 3:** Keep worktree. + +## Quick Reference + +| Option | Merge | Push | Keep Worktree | Cleanup Branch | +| ---------------- | ----- | ---- | ------------- | -------------- | +| 1. Merge locally | ✓ | - | - | ✓ | +| 2. Create PR | - | ✓ | ✓ | - | +| 3. Keep as-is | - | - | ✓ | - | +| 4. Discard | - | - | - | ✓ (force) | + +## Common Mistakes + +**Skipping test verification** + +- **Problem:** Merge broken code, create failing PR +- **Fix:** Always verify tests before offering options + +**Open-ended questions** + +- **Problem:** "What should I do next?" → ambiguous +- **Fix:** Present exactly 4 structured options + +**Automatic worktree cleanup** + +- **Problem:** Remove worktree when might need it (Option 2, 3) +- **Fix:** Only cleanup for Options 1 and 4 + +**No confirmation for discard** + +- **Problem:** Accidentally delete work +- **Fix:** Require typed "discard" confirmation + +## Red Flags + +**Never:** + +- Proceed with failing tests +- Merge without verifying tests on result +- Delete work without confirmation +- Force-push without explicit request + +**Always:** + +- Verify tests before offering options +- Present exactly 4 options +- Get typed confirmation for Option 4 +- Clean up worktree for Options 1 & 4 only + +## Integration + +**Called by:** + +- **subagent-driven-development** (Step 7) - After all tasks complete +- **executing-plans** (Step 5) - After all batches complete + +**Pairs with:** + +- **using-git-worktrees** - Cleans up worktree created by that skill diff --git a/packages/mosaic/framework/skills/form-cro/SKILL.md b/packages/mosaic/framework/skills/form-cro/SKILL.md new file mode 100644 index 00000000..4b1faf86 --- /dev/null +++ b/packages/mosaic/framework/skills/form-cro/SKILL.md @@ -0,0 +1,480 @@ +--- +name: form-cro +version: 1.0.0 +description: When the user wants to optimize any form that is NOT signup/registration — including lead capture forms, contact forms, demo request forms, application forms, survey forms, or checkout forms. Also use when the user mentions "form optimization," "lead form conversions," "form friction," "form fields," "form completion rate," or "contact form." For signup/registration forms, see signup-flow-cro. For popups containing forms, see popup-cro. +--- + +# Form CRO + +You are an expert in form optimization. Your goal is to maximize form completion rates while capturing the data that matters. + +## Initial Assessment + +**Check for product marketing context first:** +If `.mosaic/product-marketing-context.md` exists, read it before asking questions. Use that context and only ask for information not already covered or specific to this task. + +Before providing recommendations, identify: + +1. **Form Type** + - Lead capture (gated content, newsletter) + - Contact form + - Demo/sales request + - Application form + - Survey/feedback + - Checkout form + - Quote request + +2. **Current State** + - How many fields? + - What's the current completion rate? + - Mobile vs. desktop split? + - Where do users abandon? + +3. **Business Context** + - What happens with form submissions? + - Which fields are actually used in follow-up? + - Are there compliance/legal requirements? + +--- + +## Core Principles + +### 1. Every Field Has a Cost + +Each field reduces completion rate. Rule of thumb: + +- 3 fields: Baseline +- 4-6 fields: 10-25% reduction +- 7+ fields: 25-50%+ reduction + +For each field, ask: + +- Is this absolutely necessary before we can help them? +- Can we get this information another way? +- Can we ask this later? + +### 2. Value Must Exceed Effort + +- Clear value proposition above form +- Make what they get obvious +- Reduce perceived effort (field count, labels) + +### 3. Reduce Cognitive Load + +- One question per field +- Clear, conversational labels +- Logical grouping and order +- Smart defaults where possible + +--- + +## Field-by-Field Optimization + +### Email Field + +- Single field, no confirmation +- Inline validation +- Typo detection (did you mean gmail.com?) +- Proper mobile keyboard + +### Name Fields + +- Single "Name" vs. First/Last — test this +- Single field reduces friction +- Split needed only if personalization requires it + +### Phone Number + +- Make optional if possible +- If required, explain why +- Auto-format as they type +- Country code handling + +### Company/Organization + +- Auto-suggest for faster entry +- Enrichment after submission (Clearbit, etc.) +- Consider inferring from email domain + +### Job Title/Role + +- Dropdown if categories matter +- Free text if wide variation +- Consider making optional + +### Message/Comments (Free Text) + +- Make optional +- Reasonable character guidance +- Expand on focus + +### Dropdown Selects + +- "Select one..." placeholder +- Searchable if many options +- Consider radio buttons if < 5 options +- "Other" option with text field + +### Checkboxes (Multi-select) + +- Clear, parallel labels +- Reasonable number of options +- Consider "Select all that apply" instruction + +--- + +## Form Layout Optimization + +### Field Order + +1. Start with easiest fields (name, email) +2. Build commitment before asking more +3. Sensitive fields last (phone, company size) +4. Logical grouping if many fields + +### Labels and Placeholders + +- Labels: Always visible (not just placeholder) +- Placeholders: Examples, not labels +- Help text: Only when genuinely helpful + +**Good:** + +``` +Email +[name@company.com] +``` + +**Bad:** + +``` +[Enter your email address] ← Disappears on focus +``` + +### Visual Design + +- Sufficient spacing between fields +- Clear visual hierarchy +- CTA button stands out +- Mobile-friendly tap targets (44px+) + +### Single Column vs. Multi-Column + +- Single column: Higher completion, mobile-friendly +- Multi-column: Only for short related fields (First/Last name) +- When in doubt, single column + +--- + +## Multi-Step Forms + +### When to Use Multi-Step + +- More than 5-6 fields +- Logically distinct sections +- Conditional paths based on answers +- Complex forms (applications, quotes) + +### Multi-Step Best Practices + +- Progress indicator (step X of Y) +- Start with easy, end with sensitive +- One topic per step +- Allow back navigation +- Save progress (don't lose data on refresh) +- Clear indication of required vs. optional + +### Progressive Commitment Pattern + +1. Low-friction start (just email) +2. More detail (name, company) +3. Qualifying questions +4. Contact preferences + +--- + +## Error Handling + +### Inline Validation + +- Validate as they move to next field +- Don't validate too aggressively while typing +- Clear visual indicators (green check, red border) + +### Error Messages + +- Specific to the problem +- Suggest how to fix +- Positioned near the field +- Don't clear their input + +**Good:** "Please enter a valid email address (e.g., name@company.com)" +**Bad:** "Invalid input" + +### On Submit + +- Focus on first error field +- Summarize errors if multiple +- Preserve all entered data +- Don't clear form on error + +--- + +## Submit Button Optimization + +### Button Copy + +Weak: "Submit" | "Send" +Strong: "[Action] + [What they get]" + +Examples: + +- "Get My Free Quote" +- "Download the Guide" +- "Request Demo" +- "Send Message" +- "Start Free Trial" + +### Button Placement + +- Immediately after last field +- Left-aligned with fields +- Sufficient size and contrast +- Mobile: Sticky or clearly visible + +### Post-Submit States + +- Loading state (disable button, show spinner) +- Success confirmation (clear next steps) +- Error handling (clear message, focus on issue) + +--- + +## Trust and Friction Reduction + +### Near the Form + +- Privacy statement: "We'll never share your info" +- Security badges if collecting sensitive data +- Testimonial or social proof +- Expected response time + +### Reducing Perceived Effort + +- "Takes 30 seconds" +- Field count indicator +- Remove visual clutter +- Generous white space + +### Addressing Objections + +- "No spam, unsubscribe anytime" +- "We won't share your number" +- "No credit card required" + +--- + +## Form Types: Specific Guidance + +### Lead Capture (Gated Content) + +- Minimum viable fields (often just email) +- Clear value proposition for what they get +- Consider asking enrichment questions post-download +- Test email-only vs. email + name + +### Contact Form + +- Essential: Email/Name + Message +- Phone optional +- Set response time expectations +- Offer alternatives (chat, phone) + +### Demo Request + +- Name, Email, Company required +- Phone: Optional with "preferred contact" choice +- Use case/goal question helps personalize +- Calendar embed can increase show rate + +### Quote/Estimate Request + +- Multi-step often works well +- Start with easy questions +- Technical details later +- Save progress for complex forms + +### Survey Forms + +- Progress bar essential +- One question per screen for engagement +- Skip logic for relevance +- Consider incentive for completion + +--- + +## Mobile Optimization + +- Larger touch targets (44px minimum height) +- Appropriate keyboard types (email, tel, number) +- Autofill support +- Single column only +- Sticky submit button +- Minimal typing (dropdowns, buttons) + +--- + +## Measurement + +### Key Metrics + +- **Form start rate**: Page views → Started form +- **Completion rate**: Started → Submitted +- **Field drop-off**: Which fields lose people +- **Error rate**: By field +- **Time to complete**: Total and by field +- **Mobile vs. desktop**: Completion by device + +### What to Track + +- Form views +- First field focus +- Each field completion +- Errors by field +- Submit attempts +- Successful submissions + +--- + +## Output Format + +### Form Audit + +For each issue: + +- **Issue**: What's wrong +- **Impact**: Estimated effect on conversions +- **Fix**: Specific recommendation +- **Priority**: High/Medium/Low + +### Recommended Form Design + +- **Required fields**: Justified list +- **Optional fields**: With rationale +- **Field order**: Recommended sequence +- **Copy**: Labels, placeholders, button +- **Error messages**: For each field +- **Layout**: Visual guidance + +### Test Hypotheses + +Ideas to A/B test with expected outcomes + +--- + +## Experiment Ideas + +### Form Structure Experiments + +**Layout & Flow** + +- Single-step form vs. multi-step with progress bar +- 1-column vs. 2-column field layout +- Form embedded on page vs. separate page +- Vertical vs. horizontal field alignment +- Form above fold vs. after content + +**Field Optimization** + +- Reduce to minimum viable fields +- Add or remove phone number field +- Add or remove company/organization field +- Test required vs. optional field balance +- Use field enrichment to auto-fill known data +- Hide fields for returning/known visitors + +**Smart Forms** + +- Add real-time validation for emails and phone numbers +- Progressive profiling (ask more over time) +- Conditional fields based on earlier answers +- Auto-suggest for company names + +--- + +### Copy & Design Experiments + +**Labels & Microcopy** + +- Test field label clarity and length +- Placeholder text optimization +- Help text: show vs. hide vs. on-hover +- Error message tone (friendly vs. direct) + +**CTAs & Buttons** + +- Button text variations ("Submit" vs. "Get My Quote" vs. specific action) +- Button color and size testing +- Button placement relative to fields + +**Trust Elements** + +- Add privacy assurance near form +- Show trust badges next to submit +- Add testimonial near form +- Display expected response time + +--- + +### Form Type-Specific Experiments + +**Demo Request Forms** + +- Test with/without phone number requirement +- Add "preferred contact method" choice +- Include "What's your biggest challenge?" question +- Test calendar embed vs. form submission + +**Lead Capture Forms** + +- Email-only vs. email + name +- Test value proposition messaging above form +- Gated vs. ungated content strategies +- Post-submission enrichment questions + +**Contact Forms** + +- Add department/topic routing dropdown +- Test with/without message field requirement +- Show alternative contact methods (chat, phone) +- Expected response time messaging + +--- + +### Mobile & UX Experiments + +- Larger touch targets for mobile +- Test appropriate keyboard types by field +- Sticky submit button on mobile +- Auto-focus first field on page load +- Test form container styling (card vs. minimal) + +--- + +## Task-Specific Questions + +1. What's your current form completion rate? +2. Do you have field-level analytics? +3. What happens with the data after submission? +4. Which fields are actually used in follow-up? +5. Are there compliance/legal requirements? +6. What's the mobile vs. desktop split? + +--- + +## Related Skills + +- **signup-flow-cro**: For account creation forms +- **popup-cro**: For forms inside popups/modals +- **page-cro**: For the page containing the form +- **ab-test-setup**: For testing form changes diff --git a/packages/mosaic/framework/skills/free-tool-strategy/SKILL.md b/packages/mosaic/framework/skills/free-tool-strategy/SKILL.md new file mode 100644 index 00000000..cf157b54 --- /dev/null +++ b/packages/mosaic/framework/skills/free-tool-strategy/SKILL.md @@ -0,0 +1,190 @@ +--- +name: free-tool-strategy +version: 1.0.0 +description: When the user wants to plan, evaluate, or build a free tool for marketing purposes — lead generation, SEO value, or brand awareness. Also use when the user mentions "engineering as marketing," "free tool," "marketing tool," "calculator," "generator," "interactive tool," "lead gen tool," "build a tool for leads," or "free resource." This skill bridges engineering and marketing — useful for founders and technical marketers. +--- + +# Free Tool Strategy (Engineering as Marketing) + +You are an expert in engineering-as-marketing strategy. Your goal is to help plan and evaluate free tools that generate leads, attract organic traffic, and build brand awareness. + +## Initial Assessment + +**Check for product marketing context first:** +If `.mosaic/product-marketing-context.md` exists, read it before asking questions. Use that context and only ask for information not already covered or specific to this task. + +Before designing a tool strategy, understand: + +1. **Business Context** - What's the core product? Who is the target audience? What problems do they have? + +2. **Goals** - Lead generation? SEO/traffic? Brand awareness? Product education? + +3. **Resources** - Technical capacity to build? Ongoing maintenance bandwidth? Budget for promotion? + +--- + +## Core Principles + +### 1. Solve a Real Problem + +- Tool must provide genuine value +- Solves a problem your audience actually has +- Useful even without your main product + +### 2. Adjacent to Core Product + +- Related to what you sell +- Natural path from tool to product +- Educates on problem you solve + +### 3. Simple and Focused + +- Does one thing well +- Low friction to use +- Immediate value + +### 4. Worth the Investment + +- Lead value × expected leads > build cost + maintenance + +--- + +## Tool Types Overview + +| Type | Examples | Best For | +| ----------- | -------------------------------- | --------------------------- | +| Calculators | ROI, savings, pricing estimators | Decisions involving numbers | +| Generators | Templates, policies, names | Creating something quickly | +| Analyzers | Website graders, SEO auditors | Evaluating existing work | +| Testers | Meta tag preview, speed tests | Checking if something works | +| Libraries | Icon sets, templates, snippets | Reference material | +| Interactive | Tutorials, playgrounds, quizzes | Learning/understanding | + +**For detailed tool types and examples**: See [references/tool-types.md](references/tool-types.md) + +--- + +## Ideation Framework + +### Start with Pain Points + +1. **What problems does your audience Google?** - Search query research, common questions + +2. **What manual processes are tedious?** - Spreadsheet tasks, repetitive calculations + +3. **What do they need before buying your product?** - Assessments, planning, comparisons + +4. **What information do they wish they had?** - Data they can't easily access, benchmarks + +### Validate the Idea + +- **Search demand**: Is there search volume? How competitive? +- **Uniqueness**: What exists? How can you be 10x better? +- **Lead quality**: Does this audience match buyers? +- **Build feasibility**: How complex? Can you scope an MVP? + +--- + +## Lead Capture Strategy + +### Gating Options + +| Approach | Pros | Cons | +| ------------------ | --------------- | --------------- | +| Fully gated | Maximum capture | Lower usage | +| Partially gated | Balance of both | Common pattern | +| Ungated + optional | Maximum reach | Lower capture | +| Ungated entirely | Pure SEO/brand | No direct leads | + +### Lead Capture Best Practices + +- Value exchange clear: "Get your full report" +- Minimal friction: Email only +- Show preview of what they'll get +- Optional: Segment by asking one qualifying question + +--- + +## SEO Considerations + +### Keyword Strategy + +**Tool landing page**: "[thing] calculator", "[thing] generator", "free [tool type]" + +**Supporting content**: "How to [use case]", "What is [concept]" + +### Link Building + +Free tools attract links because: + +- Genuinely useful (people reference them) +- Unique (can't link to just any page) +- Shareable (social amplification) + +--- + +## Build vs. Buy + +### Build Custom + +When: Unique concept, core to brand, high strategic value, have dev capacity + +### Use No-Code Tools + +Options: Outgrow, Involve.me, Typeform, Tally, Bubble, Webflow +When: Speed to market, limited dev resources, testing concept + +### Embed Existing + +When: Something good exists, white-label available, not core differentiator + +--- + +## MVP Scope + +### Minimum Viable Tool + +1. Core functionality only—does the one thing, works reliably +2. Essential UX—clear input, obvious output, mobile works +3. Basic lead capture—email collection, leads go somewhere useful + +### What to Skip Initially + +Account creation, saving results, advanced features, perfect design, every edge case + +--- + +## Evaluation Scorecard + +Rate each factor 1-5: + +| Factor | Score | +| ---------------------------- | ------ | +| Search demand exists | \_\_\_ | +| Audience match to buyers | \_\_\_ | +| Uniqueness vs. existing | \_\_\_ | +| Natural path to product | \_\_\_ | +| Build feasibility | \_\_\_ | +| Maintenance burden (inverse) | \_\_\_ | +| Link-building potential | \_\_\_ | +| Share-worthiness | \_\_\_ | + +**25+**: Strong candidate | **15-24**: Promising | **<15**: Reconsider + +--- + +## Task-Specific Questions + +1. What existing tools does your audience use for workarounds? +2. How do you currently generate leads? +3. What technical resources are available? +4. What's the timeline and budget? + +--- + +## Related Skills + +- **page-cro**: For optimizing the tool's landing page +- **seo-audit**: For SEO-optimizing the tool +- **analytics-tracking**: For measuring tool usage +- **email-sequence**: For nurturing leads from the tool diff --git a/packages/mosaic/framework/skills/free-tool-strategy/references/tool-types.md b/packages/mosaic/framework/skills/free-tool-strategy/references/tool-types.md new file mode 100644 index 00000000..c3b5a63f --- /dev/null +++ b/packages/mosaic/framework/skills/free-tool-strategy/references/tool-types.md @@ -0,0 +1,231 @@ +# Free Tool Types Reference + +Detailed guide to each type of marketing tool you can build. + +## Calculators + +**Best for**: Decisions involving numbers, comparisons, estimates + +**Examples**: + +- ROI calculator +- Savings calculator +- Cost comparison tool +- Salary calculator +- Tax estimator +- Pricing estimator +- Compound interest calculator +- Break-even calculator + +**Why they work**: + +- Personalized output +- High perceived value +- Share-worthy results +- Clear problem → solution + +**Implementation tips**: + +- Keep inputs simple +- Show calculations transparently +- Make results shareable +- Add "powered by" branding + +--- + +## Generators + +**Best for**: Creating something useful quickly + +**Examples**: + +- Policy generator (privacy, terms) +- Template generator +- Name/tagline generator +- Email subject line generator +- Resume builder +- Color palette generator +- Logo maker +- Contract generator + +**Why they work**: + +- Tangible output +- Saves time +- Easily shared +- Repeat usage + +**Implementation tips**: + +- Output should be immediately usable +- Allow customization +- Offer download/export options +- Include email gating for premium outputs + +--- + +## Analyzers/Auditors + +**Best for**: Evaluating existing work or assets + +**Examples**: + +- Website grader +- SEO analyzer +- Email subject tester +- Headline analyzer +- Security checker +- Performance auditor +- Accessibility checker +- Code quality analyzer + +**Why they work**: + +- Curiosity-driven +- Personalized insights +- Creates awareness of problems +- Natural lead to solution + +**Implementation tips**: + +- Score or grade for gamification +- Benchmark against averages +- Provide actionable recommendations +- Follow up with improvement offers + +--- + +## Testers/Validators + +**Best for**: Checking if something works + +**Examples**: + +- Meta tag preview +- Email rendering test +- Mobile-friendly test +- Speed test +- DNS checker +- SSL certificate checker +- Redirect checker +- Broken link finder + +**Why they work**: + +- Immediate utility +- Bookmark-worthy +- Repeat usage +- Professional necessity + +**Implementation tips**: + +- Fast results are essential +- Show pass/fail clearly +- Provide fix instructions +- Integrate with your product where relevant + +--- + +## Libraries/Resources + +**Best for**: Reference material + +**Examples**: + +- Icon library +- Template library +- Code snippet library +- Example gallery +- Industry directory +- Resource list +- Swipe file collection +- Font pairing tool + +**Why they work**: + +- High SEO value +- Ongoing traffic +- Establishes authority +- Linkable asset + +**Implementation tips**: + +- Make searchable/filterable +- Allow easy copying/downloading +- Update regularly +- Accept community submissions + +--- + +## Interactive Educational + +**Best for**: Learning/understanding + +**Examples**: + +- Interactive tutorials +- Code playgrounds +- Visual explainers +- Quizzes/assessments +- Simulators +- Comparison tools +- Decision trees +- Configurators + +**Why they work**: + +- Engages deeply +- Demonstrates expertise +- Shareable +- Memory-creating + +**Implementation tips**: + +- Make it hands-on +- Show immediate feedback +- Lead to deeper resources +- Capture engaged users + +--- + +## Tool Concept Examples by Industry + +### SaaS Product + +- Product ROI calculator +- Competitor comparison tool +- Readiness assessment quiz +- Template library for use case +- Feature configurator + +### Agency/Services + +- Industry benchmark tool +- Project scoping calculator +- Portfolio review tool +- Cost estimator +- Proposal generator + +### E-commerce + +- Product finder quiz +- Comparison tool +- Size/fit calculator +- Savings calculator +- Gift finder + +### Developer Tools + +- Code snippet library +- Testing/preview tool +- Documentation generator +- Interactive tutorials +- API playground + +### Finance + +- Financial calculators +- Investment comparison +- Budget planner +- Tax estimator +- Loan calculator diff --git a/packages/mosaic/framework/skills/frontend-design/LICENSE.txt b/packages/mosaic/framework/skills/frontend-design/LICENSE.txt new file mode 100644 index 00000000..f433b1a5 --- /dev/null +++ b/packages/mosaic/framework/skills/frontend-design/LICENSE.txt @@ -0,0 +1,177 @@ + + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS diff --git a/packages/mosaic/framework/skills/frontend-design/SKILL.md b/packages/mosaic/framework/skills/frontend-design/SKILL.md new file mode 100644 index 00000000..f709fde7 --- /dev/null +++ b/packages/mosaic/framework/skills/frontend-design/SKILL.md @@ -0,0 +1,45 @@ +--- +name: frontend-design +description: Create distinctive, production-grade frontend interfaces with high design quality. Use this skill when the user asks to build web components, pages, artifacts, posters, or applications (examples include websites, landing pages, dashboards, React components, HTML/CSS layouts, or when styling/beautifying any web UI). Generates creative, polished code and UI design that avoids generic AI aesthetics. +license: Complete terms in LICENSE.txt +--- + +This skill guides creation of distinctive, production-grade frontend interfaces that avoid generic "AI slop" aesthetics. Implement real working code with exceptional attention to aesthetic details and creative choices. + +The user provides frontend requirements: a component, page, application, or interface to build. They may include context about the purpose, audience, or technical constraints. + +## Design Thinking + +Before coding, understand the context and commit to a BOLD aesthetic direction: + +- **Purpose**: What problem does this interface solve? Who uses it? +- **Tone**: Pick an extreme: brutally minimal, maximalist chaos, retro-futuristic, organic/natural, luxury/refined, playful/toy-like, editorial/magazine, brutalist/raw, art deco/geometric, soft/pastel, industrial/utilitarian, etc. There are so many flavors to choose from. Use these for inspiration but design one that is true to the aesthetic direction. +- **Constraints**: Technical requirements (framework, performance, accessibility). +- **Differentiation**: What makes this UNFORGETTABLE? What's the one thing someone will remember? + +**CRITICAL**: Choose a clear conceptual direction and execute it with precision. Bold maximalism and refined minimalism both work - the key is intentionality, not intensity. + +Then implement working code (HTML/CSS/JS, React, Vue, etc.) that is: + +- Production-grade and functional +- Visually striking and memorable +- Cohesive with a clear aesthetic point-of-view +- Meticulously refined in every detail + +## Frontend Aesthetics Guidelines + +Focus on: + +- **Typography**: Choose fonts that are beautiful, unique, and interesting. Avoid generic fonts like Arial and Inter; opt instead for distinctive choices that elevate the frontend's aesthetics; unexpected, characterful font choices. Pair a distinctive display font with a refined body font. +- **Color & Theme**: Commit to a cohesive aesthetic. Use CSS variables for consistency. Dominant colors with sharp accents outperform timid, evenly-distributed palettes. +- **Motion**: Use animations for effects and micro-interactions. Prioritize CSS-only solutions for HTML. Use Motion library for React when available. Focus on high-impact moments: one well-orchestrated page load with staggered reveals (animation-delay) creates more delight than scattered micro-interactions. Use scroll-triggering and hover states that surprise. +- **Spatial Composition**: Unexpected layouts. Asymmetry. Overlap. Diagonal flow. Grid-breaking elements. Generous negative space OR controlled density. +- **Backgrounds & Visual Details**: Create atmosphere and depth rather than defaulting to solid colors. Add contextual effects and textures that match the overall aesthetic. Apply creative forms like gradient meshes, noise textures, geometric patterns, layered transparencies, dramatic shadows, decorative borders, custom cursors, and grain overlays. + +NEVER use generic AI-generated aesthetics like overused font families (Inter, Roboto, Arial, system fonts), cliched color schemes (particularly purple gradients on white backgrounds), predictable layouts and component patterns, and cookie-cutter design that lacks context-specific character. + +Interpret creatively and make unexpected choices that feel genuinely designed for the context. No design should be the same. Vary between light and dark themes, different fonts, different aesthetics. NEVER converge on common choices (Space Grotesk, for example) across generations. + +**IMPORTANT**: Match implementation complexity to the aesthetic vision. Maximalist designs need elaborate code with extensive animations and effects. Minimalist or refined designs need restraint, precision, and careful attention to spacing, typography, and subtle details. Elegance comes from executing the vision well. + +Remember: Claude is capable of extraordinary creative work. Don't hold back, show what can truly be created when thinking outside the box and committing fully to a distinctive vision. diff --git a/packages/mosaic/framework/skills/internal-comms/LICENSE.txt b/packages/mosaic/framework/skills/internal-comms/LICENSE.txt new file mode 100644 index 00000000..7a4a3ea2 --- /dev/null +++ b/packages/mosaic/framework/skills/internal-comms/LICENSE.txt @@ -0,0 +1,202 @@ + + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright [yyyy] [name of copyright owner] + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. \ No newline at end of file diff --git a/packages/mosaic/framework/skills/internal-comms/SKILL.md b/packages/mosaic/framework/skills/internal-comms/SKILL.md new file mode 100644 index 00000000..3e55e640 --- /dev/null +++ b/packages/mosaic/framework/skills/internal-comms/SKILL.md @@ -0,0 +1,35 @@ +--- +name: internal-comms +description: A set of resources to help me write all kinds of internal communications, using the formats that my company likes to use. Claude should use this skill whenever asked to write some sort of internal communications (status reports, leadership updates, 3P updates, company newsletters, FAQs, incident reports, project updates, etc.). +license: Complete terms in LICENSE.txt +--- + +## When to use this skill + +To write internal communications, use this skill for: + +- 3P updates (Progress, Plans, Problems) +- Company newsletters +- FAQ responses +- Status reports +- Leadership updates +- Project updates +- Incident reports + +## How to use this skill + +To write any internal communication: + +1. **Identify the communication type** from the request +2. **Load the appropriate guideline file** from the `examples/` directory: + - `examples/3p-updates.md` - For Progress/Plans/Problems team updates + - `examples/company-newsletter.md` - For company-wide newsletters + - `examples/faq-answers.md` - For answering frequently asked questions + - `examples/general-comms.md` - For anything else that doesn't explicitly match one of the above +3. **Follow the specific instructions** in that file for formatting, tone, and content gathering + +If the communication type doesn't match any existing guideline, ask for clarification or more context about the desired format. + +## Keywords + +3P updates, company newsletter, company comms, weekly update, faqs, common questions, updates, internal comms diff --git a/packages/mosaic/framework/skills/internal-comms/examples/3p-updates.md b/packages/mosaic/framework/skills/internal-comms/examples/3p-updates.md new file mode 100644 index 00000000..ce91b5cf --- /dev/null +++ b/packages/mosaic/framework/skills/internal-comms/examples/3p-updates.md @@ -0,0 +1,49 @@ +## Instructions + +You are being asked to write a 3P update. 3P updates stand for "Progress, Plans, Problems." The main audience is for executives, leadership, other teammates, etc. They're meant to be very succinct and to-the-point: think something you can read in 30-60sec or less. They're also for people with some, but not a lot of context on what the team does. + +3Ps can cover a team of any size, ranging all the way up to the entire company. The bigger the team, the less granular the tasks should be. For example, "mobile team" might have "shipped feature" or "fixed bugs," whereas the company might have really meaty 3Ps, like "hired 20 new people" or "closed 10 new deals." + +They represent the work of the team across a time period, almost always one week. They include three sections: + +1. Progress: what the team has accomplished over the next time period. Focus mainly on things shipped, milestones achieved, tasks created, etc. +2. Plans: what the team plans to do over the next time period. Focus on what things are top-of-mind, really high priority, etc. for the team. +3. Problems: anything that is slowing the team down. This could be things like too few people, bugs or blockers that are preventing the team from moving forward, some deal that fell through, etc. + +Before writing them, make sure that you know the team name. If it's not specified, you can ask explicitly what the team name you're writing for is. + +## Tools Available + +Whenever possible, try to pull from available sources to get the information you need: + +- Slack: posts from team members with their updates - ideally look for posts in large channels with lots of reactions +- Google Drive: docs written from critical team members with lots of views +- Email: emails with lots of responses of lots of content that seems relevant +- Calendar: non-recurring meetings that have a lot of importance, like product reviews, etc. + +Try to gather as much context as you can, focusing on the things that covered the time period you're writing for: + +- Progress: anything between a week ago and today +- Plans: anything from today to the next week +- Problems: anything between a week ago and today + +If you don't have access, you can ask the user for things they want to cover. They might also include these things to you directly, in which case you're mostly just formatting for this particular format. + +## Workflow + +1. **Clarify scope**: Confirm the team name and time period (usually past week for Progress/Problems, next + week for Plans) +2. **Gather information**: Use available tools or ask the user directly +3. **Draft the update**: Follow the strict formatting guidelines +4. **Review**: Ensure it's concise (30-60 seconds to read) and data-driven + +## Formatting + +The format is always the same, very strict formatting. Never use any formatting other than this. Pick an emoji that is fun and captures the vibe of the team and update. + +[pick an emoji] [Team Name] (Dates Covered, usually a week) +Progress: [1-3 sentences of content] +Plans: [1-3 sentences of content] +Problems: [1-3 sentences of content] + +Each section should be no more than 1-3 sentences: clear, to the point. It should be data-driven, and generally include metrics where possible. The tone should be very matter-of-fact, not super prose-heavy. diff --git a/packages/mosaic/framework/skills/internal-comms/examples/company-newsletter.md b/packages/mosaic/framework/skills/internal-comms/examples/company-newsletter.md new file mode 100644 index 00000000..c1f03ba8 --- /dev/null +++ b/packages/mosaic/framework/skills/internal-comms/examples/company-newsletter.md @@ -0,0 +1,76 @@ +## Instructions + +You are being asked to write a company-wide newsletter update. You are meant to summarize the past week/month of a company in the form of a newsletter that the entire company will read. It should be maybe ~20-25 bullet points long. It will be sent via Slack and email, so make it consumable for that. + +Ideally it includes the following attributes: + +- Lots of links: pulling documents from Google Drive that are very relevant, linking to prominent Slack messages in announce channels and from executives, perhgaps referencing emails that went company-wide, highlighting significant things that have happened in the company. +- Short and to-the-point: each bullet should probably be no longer than ~1-2 sentences +- Use the "we" tense, as you are part of the company. Many of the bullets should say "we did this" or "we did that" + +## Tools to use + +If you have access to the following tools, please try to use them. If not, you can also let the user know directly that their responses would be better if they gave them access. + +- Slack: look for messages in channels with lots of people, with lots of reactions or lots of responses within the thread +- Email: look for things from executives that discuss company-wide announcements +- Calendar: if there were meetings with large attendee lists, particularly things like All-Hands meetings, big company announcements, etc. If there were documents attached to those meetings, those are great links to include. +- Documents: if there were new docs published in the last week or two that got a lot of attention, you can link them. These should be things like company-wide vision docs, plans for the upcoming quarter or half, things authored by critical executives, etc. +- External press: if you see references to articles or press we've received over the past week, that could be really cool too. + +If you don't have access to any of these things, you can ask the user for things they want to cover. In this case, you'll mostly just be polishing up and fitting to this format more directly. + +## Sections + +The company is pretty big: 1000+ people. There are a variety of different teams and initiatives going on across the company. To make sure the update works well, try breaking it into sections of similar things. You might break into clusters like {product development, go to market, finance} or {recruiting, execution, vision}, or {external news, internal news} etc. Try to make sure the different areas of the company are highlighted well. + +## Prioritization + +Focus on: + +- Company-wide impact (not team-specific details) +- Announcements from leadership +- Major milestones and achievements +- Information that affects most employees +- External recognition or press + +Avoid: + +- Overly granular team updates (save those for 3Ps) +- Information only relevant to small groups +- Duplicate information already communicated + +## Example Formats + +:megaphone: Company Announcements + +- Announcement 1 +- Announcement 2 +- Announcement 3 + +:dart: Progress on Priorities + +- Area 1 + - Sub-area 1 + - Sub-area 2 + - Sub-area 3 +- Area 2 + - Sub-area 1 + - Sub-area 2 + - Sub-area 3 +- Area 3 + - Sub-area 1 + - Sub-area 2 + - Sub-area 3 + +:pillar: Leadership Updates + +- Post 1 +- Post 2 +- Post 3 + +:thread: Social Updates + +- Update 1 +- Update 2 +- Update 3 diff --git a/packages/mosaic/framework/skills/internal-comms/examples/faq-answers.md b/packages/mosaic/framework/skills/internal-comms/examples/faq-answers.md new file mode 100644 index 00000000..a68597b9 --- /dev/null +++ b/packages/mosaic/framework/skills/internal-comms/examples/faq-answers.md @@ -0,0 +1,35 @@ +## Instructions + +You are an assistant for answering questions that are being asked across the company. Every week, there are lots of questions that get asked across the company, and your goal is to try to summarize what those questions are. We want our company to be well-informed and on the same page, so your job is to produce a set of frequently asked questions that our employees are asking and attempt to answer them. Your singular job is to do two things: + +- Find questions that are big sources of confusion for lots of employees at the company, generally about things that affect a large portion of the employee base +- Attempt to give a nice summarized answer to that question in order to minimize confusion. + +Some examples of areas that may be interesting to folks: recent corporate events (fundraising, new executives, etc.), upcoming launches, hiring progress, changes to vision or focus, etc. + +## Tools Available + +You should use the company's available tools, where communication and work happens. For most companies, it looks something like this: + +- Slack: questions being asked across the company - it could be questions in response to posts with lots of responses, questions being asked with lots of reactions or thumbs up to show support, or anything else to show that a large number of employees want to ask the same things +- Email: emails with FAQs written directly in them can be a good source as well +- Documents: docs in places like Google Drive, linked on calendar events, etc. can also be a good source of FAQs, either directly added or inferred based on the contents of the doc + +## Formatting + +The formatting should be pretty basic: + +- _Question_: [insert question - 1 sentence] +- _Answer_: [insert answer - 1-2 sentence] + +## Guidance + +Make sure you're being holistic in your questions. Don't focus too much on just the user in question or the team they are a part of, but try to capture the entire company. Try to be as holistic as you can in reading all the tools available, producing responses that are relevant to all at the company. + +## Answer Guidelines + +- Base answers on official company communications when possible +- If information is uncertain, indicate that clearly +- Link to authoritative sources (docs, announcements, emails) +- Keep tone professional but approachable +- Flag if a question requires executive input or official response diff --git a/packages/mosaic/framework/skills/internal-comms/examples/general-comms.md b/packages/mosaic/framework/skills/internal-comms/examples/general-comms.md new file mode 100644 index 00000000..d1da2add --- /dev/null +++ b/packages/mosaic/framework/skills/internal-comms/examples/general-comms.md @@ -0,0 +1,19 @@ +## Instructions + +You are being asked to write internal company communication that doesn't fit into the standard formats (3P +updates, newsletters, or FAQs). + +Before proceeding: + +1. Ask the user about their target audience +2. Understand the communication's purpose +3. Clarify the desired tone (formal, casual, urgent, informational) +4. Confirm any specific formatting requirements + +Use these general principles: + +- Be clear and concise +- Use active voice +- Put the most important information first +- Include relevant links and references +- Match the company's communication style diff --git a/packages/mosaic/framework/skills/kickstart/SKILL.md b/packages/mosaic/framework/skills/kickstart/SKILL.md new file mode 100644 index 00000000..13adc7ca --- /dev/null +++ b/packages/mosaic/framework/skills/kickstart/SKILL.md @@ -0,0 +1,356 @@ +--- +name: kickstart +description: 'Launch an orchestrator session for a milestone, issue, or task. Use when starting autonomous work on a milestone, orchestrating issue completion, or resuming from a handoff. Triggers on: kickstart, orchestrate, start milestone, resume orchestrator.' +--- + +# Kickstart Orchestrator + +Launch an orchestrator session with a single command. Replaces the manual boilerplate of specifying mission, quality gates, branch strategy, and tracking protocol. + +**Usage:** + +``` +/kickstart — List open milestones, ask user to pick +/kickstart 0.0.9 — Orchestrate a milestone (by version) +/kickstart M10-Telemetry — Orchestrate a milestone (by name) +/kickstart #42 — Orchestrate a single issue +/kickstart MS-SEC-001 — Resume a specific task from tasks.md +/kickstart resume — Resume from existing docs/tasks.md +``` + +--- + +## Step 1: Parse Argument & Detect Context + +### 1a. Determine target type + +Parse the argument (if any) provided after `/kickstart`: + +| Pattern | Type | Example | +| ------------------------------------------- | --------------- | ------------------------------------------------ | +| No argument | **interactive** | `/kickstart` | +| Starts with `#` | **issue** | `/kickstart #42` | +| `resume` (literal) | **resume** | `/kickstart resume` | +| Contains `-` with uppercase + digits at end | **task ID** | `/kickstart MS-SEC-001` | +| Anything else | **milestone** | `/kickstart 0.0.9` or `/kickstart M10-Telemetry` | + +### 1b. If no argument (interactive mode) + +List open milestones and ask the user to choose: + +```bash +"${MOSAIC_HOME:-$HOME/.config/mosaic}/rails/git/milestone-list.sh" -s open +``` + +Present the results and ask: + +``` +Which target do you want to orchestrate? +A. [milestone 1] +B. [milestone 2] +C. Specific issue number: #___ +D. Resume from existing docs/tasks.md +``` + +Wait for user selection before proceeding. + +### 1c. Detect project context + +Run these to understand the current project: + +```bash +# Get repo info from git remote +REMOTE_URL=$(git remote get-url origin 2>/dev/null) +# Parse org, repo, platform from remote URL + +# Check for existing orchestrator state +ls docs/tasks.md 2>/dev/null +ls docs/orchestrator-learnings.json 2>/dev/null + +# Detect default branch (usually develop or main) +git branch -r | grep -E 'origin/(develop|main)' | head -1 +``` + +Read the project's `AGENTS.md` first (and `SOUL.md` if present). If only `CLAUDE.md` exists, use it as compatibility fallback. Scan for: + +- Quality gate commands (look for `pnpm`, `npm`, `pytest`, `lint`, `typecheck`, `test`) +- Branch conventions +- Task prefix conventions + +### 1d. Present context summary to user + +Before proceeding, show what was detected: + +``` +=== Kickstart Context === +Target: [milestone/issue/task] +Project: [org/repo] +Platform: [Gitea/GitHub] +Branch base: [develop/main] +Delegation: [native-subagent | matrix-rail-fallback] +Quality gates: [detected commands or "none detected — will ask"] +Existing state: [docs/tasks.md found / none] +``` + +Ask the user to confirm or override any detected values. + +### 1e. Detect delegation mode + +Before orchestration begins, decide delegation mode: + +- **native-subagent**: runtime has reliable subagent/background task primitive +- **matrix-rail-fallback**: use deterministic Mosaic rail when native primitive is absent/unreliable + +Fallback commands: + +```bash +~/.config/mosaic/bin/mosaic-orchestrator-matrix-cycle +~/.config/mosaic/bin/mosaic-orchestrator-run --poll-sec 10 +``` + +--- + +## Step 2: Fetch Target Data + +### For milestone target + +```bash +# Get all open issues in this milestone +"${MOSAIC_HOME:-$HOME/.config/mosaic}/rails/git/issue-list.sh" -m "<milestone-name>" -s open + +# For each issue, get details (labels, description) +"${MOSAIC_HOME:-$HOME/.config/mosaic}/rails/git/issue-view.sh" -i <number> +``` + +Categorize issues by labels (feature, bug, task, security, etc.) to determine phasing. + +### For issue target + +```bash +"${MOSAIC_HOME:-$HOME/.config/mosaic}/rails/git/issue-view.sh" -i <number> +``` + +Read the issue description, labels, and any linked milestone. + +### For resume target + +Read `docs/tasks.md` and determine: + +- How many tasks are `done` vs `not-started` vs `in-progress` vs `failed` +- What the next unblocked task is +- Whether any tasks are stuck (in-progress with no agent) + +Report status to user before continuing. + +--- + +## Step 3: Load Orchestrator Protocol + +**CRITICAL:** Read the full orchestrator guide: + +``` +Read ~/.config/mosaic/guides/orchestrator.md +``` + +Also load skills relevant to the project's tech stack from project docs (`AGENTS.md`/`SOUL.md`) plus `~/.config/mosaic/STANDARDS.md`. For example: + +- NestJS project → load `nestjs-best-practices` +- Next.js project → load `next-best-practices`, `vercel-react-best-practices` +- Python project → load `fastapi`, `python-performance-optimization` + +Always load these orchestrator-relevant skills: + +- `verification-before-completion` — evidence-based completion claims +- `dispatching-parallel-agents` — parallel worker patterns + +--- + +## Step 4: Bootstrap or Resume + +### 4a. Fresh milestone/issue (no existing tasks.md) + +Create tracking files using templates: + +```bash +MOSAIC_HOME="${MOSAIC_HOME:-$HOME/.config/mosaic}" + +# Create tasks.md scaffold from the framework-shipped template +cp "$MOSAIC_HOME/templates/docs/TASKS.md.template" docs/tasks.md + +# Create learnings tracking (scaffold — adapt to your estate's template) +echo '{}' > docs/orchestrator-learnings.json +``` + +Then populate `docs/tasks.md` with tasks derived from the issues: + +- One task row per issue (for milestone targets) or per acceptance criterion (for single issues) +- Set reasonable token estimates based on issue complexity +- Establish dependencies between tasks where logical +- Create feature branches: `feature/<milestone-slug>` as the integration branch + +Commit the bootstrap: + +```bash +git add docs/tasks.md docs/orchestrator-learnings.json +git commit -m "chore: Bootstrap orchestrator for <target>" +git push +``` + +### 4b. Resume (existing tasks.md) + +Read `docs/tasks.md` and validate: + +- Schema matches expected format (id, status, description, issue, branch, etc.) +- No tasks stuck in `in-progress` without an active agent +- Dependencies are consistent (no circular deps, no done tasks blocking not-started) + +Mark any orphaned `in-progress` tasks as `not-started` (previous agent likely lost context). + +Report to user: + +``` +=== Resume Status === +Total tasks: 15 +Completed: 8 (53%) +In progress: 0 (reset from orphaned) +Not started: 5 +Failed: 2 + +Next task: MS-SEC-009 — "Add input validation to API endpoints" +``` + +--- + +## Step 5: Begin Orchestration + +You are now the **orchestrator**. Follow the protocol from `~/.config/mosaic/guides/orchestrator.md` exactly. + +### Standing Orders (baked in — user never needs to specify these) + +1. **All coding changes go through workers** — you NEVER edit source code directly +2. **All coding changes require** code review, security review, and QA passing before completion +3. **Linting is mandatory** — workers MUST run the project linter and fix ALL violations in files they touch. Zero lint errors. No disabling rules. No skipping files. This is non-negotiable. +4. **Completed issues are closed** in the repo via `"${MOSAIC_HOME:-$HOME/.config/mosaic}/rails/git/issue-close.sh"` +5. **tasks.md is the single source of truth** — you are the sole writer +6. **Branch from `develop`** (or whatever was detected/confirmed in Step 1) as the upstream +7. **Max 2 active worker tasks** at any time (applies to native and matrix modes) +8. **Two-Phase Completion:** Bulk phase (target 90%), then Polish phase (target 100%) +9. **Context threshold (55-60%):** Output handoff kickstart and STOP — do not compact + +### Orchestrator Loop + +``` +WHILE tasks remain not-started or in-progress: + 1. Find next unblocked task (all depends_on are done) + 2. Update tasks.md: status=in-progress, started_at=now + 3. Delegate worker task with: + - Task details and acceptance criteria + - Branch to work on + - Quality gate commands + - Expected JSON result format + - Native mode: runtime Task/subagent primitive + - Matrix mode: queue task in `.mosaic/orchestrator/tasks.json` and run `~/.config/mosaic/bin/mosaic-orchestrator-matrix-cycle` + 4. Parse worker result JSON + 5. Calculate variance: (actual - estimate) / estimate × 100 + 6. Update tasks.md: status=done/failed, completed_at, used + 7. If variance > 50%: log to orchestrator-learnings.json + 8. Commit + push tasks.md update + 9. If issue fully resolved: close issue via git scripts + 10. Check context usage — if >= 55%, go to Handoff +``` + +### Worker Task Template + +When spawning a worker, provide this structure: + +```markdown +## Task: {task_id} — {description} + +**Issue:** #{issue_number} +**Branch:** {branch_name} +**Base:** {develop|main} + +### Requirements + +{Issue description and acceptance criteria} + +### Quality Gates (MANDATORY — zero tolerance) + +Run ALL of these before reporting success. Fix every failure. +``` + +{quality gate commands from project} + +``` +**Linting is NON-NEGOTIABLE.** Run the project linter and fix ALL violations +in every file you touched. Do NOT leave lint warnings, do NOT disable rules, +do NOT skip files. If you changed it, you lint it. + +### Skills +Read these before starting: +- ~/.config/mosaic/skills/{relevant-skill}/SKILL.md + +### Report Format +When done, report as JSON: +{ + "task_id": "{id}", + "status": "success|failed", + "used": "{token estimate}", + "commit_sha": "{sha}", + "notes": "{what was done or why it failed}" +} +``` + +### Handoff (55-60% context) + +When context reaches 55-60%, output this handoff message and STOP: + +``` +=== ORCHESTRATOR HANDOFF === + +To resume, run: +/kickstart resume + +Or programmatically: +[runtime command] "Read ~/.config/mosaic/skills/kickstart/SKILL.md then kickstart resume for project at $(pwd)" + +State: docs/tasks.md (committed and pushed) +Progress: X/Y tasks complete +Next task: {task_id} — {description} +``` + +Then STOP COMPLETELY. Do not continue working. + +--- + +## Quality Gate Detection + +If project docs (`AGENTS.md`/`SOUL.md`/`CLAUDE.md`) don't specify quality gates, check for these patterns: + +| File | Likely Quality Gates | +| --------------------------------------- | --------------------------------- | +| `package.json` with `scripts.lint` | `pnpm lint` or `npm run lint` | +| `package.json` with `scripts.test` | `pnpm test` or `npm run test` | +| `package.json` with `scripts.typecheck` | `pnpm typecheck` | +| `tsconfig.json` | `pnpm tsc --noEmit` | +| `pyproject.toml` | `pytest`, `ruff check`, `mypy` | +| `.woodpecker.yml` | Parse pipeline steps for commands | +| `Makefile` | `make lint`, `make test` | + +If no quality gates can be detected, ask the user: + +``` +I couldn't detect quality gate commands for this project. +What commands should workers run to verify their changes? + +Examples: "pnpm lint && pnpm typecheck && pnpm test" +``` + +--- + +## Notes + +- This skill transforms the current session — after kickstart, the agent IS the orchestrator +- The skill itself is just setup instructions — the orchestrator guide has the full execution protocol +- For programmatic use: pass this skill file path to your active runtime and request kickstart for the target repo. +- This skill is runtime-agnostic and expects Mosaic rails/guides at `~/.config/mosaic`. diff --git a/packages/mosaic/framework/skills/launch-strategy/SKILL.md b/packages/mosaic/framework/skills/launch-strategy/SKILL.md new file mode 100644 index 00000000..090b10fa --- /dev/null +++ b/packages/mosaic/framework/skills/launch-strategy/SKILL.md @@ -0,0 +1,390 @@ +--- +name: launch-strategy +version: 1.0.0 +description: "When the user wants to plan a product launch, feature announcement, or release strategy. Also use when the user mentions 'launch,' 'Product Hunt,' 'feature release,' 'announcement,' 'go-to-market,' 'beta launch,' 'early access,' 'waitlist,' or 'product update.' This skill covers phased launches, channel strategy, and ongoing launch momentum." +--- + +# Launch Strategy + +You are an expert in SaaS product launches and feature announcements. Your goal is to help users plan launches that build momentum, capture attention, and convert interest into users. + +## Before Starting + +**Check for product marketing context first:** +If `.mosaic/product-marketing-context.md` exists, read it before asking questions. Use that context and only ask for information not already covered or specific to this task. + +--- + +## Core Philosophy + +The best companies don't just launch once—they launch again and again. Every new feature, improvement, and update is an opportunity to capture attention and engage your audience. + +A strong launch isn't about a single moment. It's about: + +- Getting your product into users' hands early +- Learning from real feedback +- Making a splash at every stage +- Building momentum that compounds over time + +--- + +## The ORB Framework + +Structure your launch marketing across three channel types. Everything should ultimately lead back to owned channels. + +### Owned Channels + +You own the channel (though not the audience). Direct access without algorithms or platform rules. + +**Examples:** + +- Email list +- Blog +- Podcast +- Branded community (Slack, Discord) +- Website/product + +**Why they matter:** + +- Get more effective over time +- No algorithm changes or pay-to-play +- Direct relationship with audience +- Compound value from content + +**Start with 1-2 based on audience:** + +- Industry lacks quality content → Start a blog +- People want direct updates → Focus on email +- Engagement matters → Build a community + +**Example - Superhuman:** +Built demand through an invite-only waitlist and one-on-one onboarding sessions. Every new user got a 30-minute live demo. This created exclusivity, FOMO, and word-of-mouth—all through owned relationships. Years later, their original onboarding materials still drive engagement. + +### Rented Channels + +Platforms that provide visibility but you don't control. Algorithms shift, rules change, pay-to-play increases. + +**Examples:** + +- Social media (Twitter/X, LinkedIn, Instagram) +- App stores and marketplaces +- YouTube +- Reddit + +**How to use correctly:** + +- Pick 1-2 platforms where your audience is active +- Use them to drive traffic to owned channels +- Don't rely on them as your only strategy + +**Example - Notion:** +Hacked virality through Twitter, YouTube, and Reddit where productivity enthusiasts were active. Encouraged community to share templates and workflows. But they funneled all visibility into owned assets—every viral post led to signups, then targeted email onboarding. + +**Platform-specific tactics:** + +- Twitter/X: Threads that spark conversation → link to newsletter +- LinkedIn: High-value posts → lead to gated content or email signup +- Marketplaces (Shopify, Slack): Optimize listing → drive to site for more + +Rented channels give speed, not stability. Capture momentum by bringing users into your owned ecosystem. + +### Borrowed Channels + +Tap into someone else's audience to shortcut the hardest part—getting noticed. + +**Examples:** + +- Guest content (blog posts, podcast interviews, newsletter features) +- Collaborations (webinars, co-marketing, social takeovers) +- Speaking engagements (conferences, panels, virtual summits) +- Influencer partnerships + +**Be proactive, not passive:** + +1. List industry leaders your audience follows +2. Pitch win-win collaborations +3. Use tools like SparkToro or Listen Notes to find audience overlap +4. Set up affiliate/referral incentives + +**Example - TRMNL:** +Sent a free e-ink display to YouTuber Snazzy Labs—not a paid sponsorship, just hoping he'd like it. He created an in-depth review that racked up 500K+ views and drove $500K+ in sales. They also set up an affiliate program for ongoing promotion. + +Borrowed channels give instant credibility, but only work if you convert borrowed attention into owned relationships. + +--- + +## Five-Phase Launch Approach + +Launching isn't a one-day event. It's a phased process that builds momentum. + +### Phase 1: Internal Launch + +Gather initial feedback and iron out major issues before going public. + +**Actions:** + +- Recruit early users one-on-one to test for free +- Collect feedback on usability gaps and missing features +- Ensure prototype is functional enough to demo (doesn't need to be production-ready) + +**Goal:** Validate core functionality with friendly users. + +### Phase 2: Alpha Launch + +Put the product in front of external users in a controlled way. + +**Actions:** + +- Create landing page with early access signup form +- Announce the product exists +- Invite users individually to start testing +- MVP should be working in production (even if still evolving) + +**Goal:** First external validation and initial waitlist building. + +### Phase 3: Beta Launch + +Scale up early access while generating external buzz. + +**Actions:** + +- Work through early access list (some free, some paid) +- Start marketing with teasers about problems you solve +- Recruit friends, investors, and influencers to test and share + +**Consider adding:** + +- Coming soon landing page or waitlist +- "Beta" sticker in dashboard navigation +- Email invites to early access list +- Early access toggle in settings for experimental features + +**Goal:** Build buzz and refine product with broader feedback. + +### Phase 4: Early Access Launch + +Shift from small-scale testing to controlled expansion. + +**Actions:** + +- Leak product details: screenshots, feature GIFs, demos +- Gather quantitative usage data and qualitative feedback +- Run user research with engaged users (incentivize with credits) +- Optionally run product/market fit survey to refine messaging + +**Expansion options:** + +- Option A: Throttle invites in batches (5-10% at a time) +- Option B: Invite all users at once under "early access" framing + +**Goal:** Validate at scale and prepare for full launch. + +### Phase 5: Full Launch + +Open the floodgates. + +**Actions:** + +- Open self-serve signups +- Start charging (if not already) +- Announce general availability across all channels + +**Launch touchpoints:** + +- Customer emails +- In-app popups and product tours +- Website banner linking to launch assets +- "New" sticker in dashboard navigation +- Blog post announcement +- Social posts across platforms +- Product Hunt, BetaList, Hacker News, etc. + +**Goal:** Maximum visibility and conversion to paying users. + +--- + +## Product Hunt Launch Strategy + +Product Hunt can be powerful for reaching early adopters, but it's not magic—it requires preparation. + +### Pros + +- Exposure to tech-savvy early adopter audience +- Credibility bump (especially if Product of the Day) +- Potential PR coverage and backlinks + +### Cons + +- Very competitive to rank well +- Short-lived traffic spikes +- Requires significant pre-launch planning + +### How to Launch Successfully + +**Before launch day:** + +1. Build relationships with influential supporters, content hubs, and communities +2. Optimize your listing: compelling tagline, polished visuals, short demo video +3. Study successful launches to identify what worked +4. Engage in relevant communities—provide value before pitching +5. Prepare your team for all-day engagement + +**On launch day:** + +1. Treat it as an all-day event +2. Respond to every comment in real-time +3. Answer questions and spark discussions +4. Encourage your existing audience to engage +5. Direct traffic back to your site to capture signups + +**After launch day:** + +1. Follow up with everyone who engaged +2. Convert Product Hunt traffic into owned relationships (email signups) +3. Continue momentum with post-launch content + +### Case Studies + +**SavvyCal** (Scheduling tool): + +- Optimized landing page and onboarding before launch +- Built relationships with productivity/SaaS influencers in advance +- Responded to every comment on launch day +- Result: #2 Product of the Month + +**Reform** (Form builder): + +- Studied successful launches and applied insights +- Crafted clear tagline, polished visuals, demo video +- Engaged in communities before launch (provided value first) +- Treated launch as all-day engagement event +- Directed traffic to capture signups +- Result: #1 Product of the Day + +--- + +## Post-Launch Product Marketing + +Your launch isn't over when the announcement goes live. Now comes adoption and retention work. + +### Immediate Post-Launch Actions + +**Educate new users:** +Set up automated onboarding email sequence introducing key features and use cases. + +**Reinforce the launch:** +Include announcement in your weekly/biweekly/monthly roundup email to catch people who missed it. + +**Differentiate against competitors:** +Publish comparison pages highlighting why you're the obvious choice. + +**Update web pages:** +Add dedicated sections about the new feature/product across your site. + +**Offer hands-on preview:** +Create no-code interactive demo (using tools like Navattic) so visitors can explore before signing up. + +### Keep Momentum Going + +It's easier to build on existing momentum than start from scratch. Every touchpoint reinforces the launch. + +--- + +## Ongoing Launch Strategy + +Don't rely on a single launch event. Regular updates and feature rollouts sustain engagement. + +### How to Prioritize What to Announce + +Use this matrix to decide how much marketing each update deserves: + +**Major updates** (new features, product overhauls): + +- Full campaign across multiple channels +- Blog post, email campaign, in-app messages, social media +- Maximize exposure + +**Medium updates** (new integrations, UI enhancements): + +- Targeted announcement +- Email to relevant segments, in-app banner +- Don't need full fanfare + +**Minor updates** (bug fixes, small tweaks): + +- Changelog and release notes +- Signal that product is improving +- Don't dominate marketing + +### Announcement Tactics + +**Space out releases:** +Instead of shipping everything at once, stagger announcements to maintain momentum. + +**Reuse high-performing tactics:** +If a previous announcement resonated, apply those insights to future updates. + +**Keep engaging:** +Continue using email, social, and in-app messaging to highlight improvements. + +**Signal active development:** +Even small changelog updates remind customers your product is evolving. This builds retention and word-of-mouth—customers feel confident you'll be around. + +--- + +## Launch Checklist + +### Pre-Launch + +- [ ] Landing page with clear value proposition +- [ ] Email capture / waitlist signup +- [ ] Early access list built +- [ ] Owned channels established (email, blog, community) +- [ ] Rented channel presence (social profiles optimized) +- [ ] Borrowed channel opportunities identified (podcasts, influencers) +- [ ] Product Hunt listing prepared (if using) +- [ ] Launch assets created (screenshots, demo video, GIFs) +- [ ] Onboarding flow ready +- [ ] Analytics/tracking in place + +### Launch Day + +- [ ] Announcement email to list +- [ ] Blog post published +- [ ] Social posts scheduled and posted +- [ ] Product Hunt listing live (if using) +- [ ] In-app announcement for existing users +- [ ] Website banner/notification active +- [ ] Team ready to engage and respond +- [ ] Monitor for issues and feedback + +### Post-Launch + +- [ ] Onboarding email sequence active +- [ ] Follow-up with engaged prospects +- [ ] Roundup email includes announcement +- [ ] Comparison pages published +- [ ] Interactive demo created +- [ ] Gather and act on feedback +- [ ] Plan next launch moment + +--- + +## Task-Specific Questions + +1. What are you launching? (New product, major feature, minor update) +2. What's your current audience size and engagement? +3. What owned channels do you have? (Email list size, blog traffic, community) +4. What's your timeline for launch? +5. Have you launched before? What worked/didn't work? +6. Are you considering Product Hunt? What's your preparation status? + +--- + +## Related Skills + +- **marketing-ideas**: For additional launch tactics (#22 Product Hunt, #23 Early Access Referrals) +- **email-sequence**: For launch and onboarding email sequences +- **page-cro**: For optimizing launch landing pages +- **marketing-psychology**: For psychology behind waitlists and exclusivity +- **programmatic-seo**: For comparison pages mentioned in post-launch diff --git a/packages/mosaic/framework/skills/lint/SKILL.md b/packages/mosaic/framework/skills/lint/SKILL.md new file mode 100644 index 00000000..a370a60a --- /dev/null +++ b/packages/mosaic/framework/skills/lint/SKILL.md @@ -0,0 +1,163 @@ +--- +name: lint +description: 'Enforce zero-tolerance linting on every code change. This skill MUST be followed after writing or modifying any code file. Run the project linter, fix ALL violations, and never cut corners. Triggers on: lint, delint, fix lint errors, clean up code, run linter.' +--- + +# Lint — Zero Tolerance + +**Every code change you make MUST pass the project linter with zero errors and zero warnings before you consider the work done.** + +This is not optional. This is not "nice to have." This is a hard gate. + +--- + +## The Rules + +1. **Run the linter after every code change.** Not at the end. After every change. +2. **Fix ALL violations** in files you touched. Not just the ones you introduced — if you touched the file, you own it. +3. **Never disable lint rules** to make errors go away. Fix the code, not the config. +4. **Never skip files** with `// eslint-disable`, `# noqa`, `// nolint`, `@SuppressWarnings`, or any equivalent. +5. **Never leave warnings.** Warnings are errors you haven't fixed yet. +6. **Never claim "it was already there."** If you modified a line with a violation, you fix it. (Campsite Rule) +7. **Never report success with lint failures.** If the linter reports errors, you are not done. + +--- + +## Step 1: Detect the Project Linter + +Check for these in order: + +| File / Config | Linter | Command | +| ----------------------------------------------------------------- | ------------- | ----------------------------------------------------------- | +| `biome.json` or `biome.jsonc` | Biome | `pnpm biome check --write .` or `npx biome check --write .` | +| `.eslintrc.*` or `eslint.config.*` or `package.json` has `eslint` | ESLint | `pnpm lint` or `npx eslint --fix .` | +| `pyproject.toml` with `[tool.ruff]` | Ruff | `ruff check --fix .` | +| `pyproject.toml` with `[tool.flake8]` or `.flake8` | Flake8 | `flake8 .` | +| `pyproject.toml` with `[tool.pylint]` or `.pylintrc` | Pylint | `pylint **/*.py` | +| `.rubocop.yml` | RuboCop | `rubocop -a .` | +| `Cargo.toml` | Clippy | `cargo clippy --fix` | +| `.golangci.yml` | golangci-lint | `golangci-lint run --fix` | + +Also check `package.json` scripts for a `lint` or `lint:fix` command — prefer the project's configured command over raw tool invocations. + +If a `Makefile` has a `lint` target, use `make lint`. + +If the project has a `.woodpecker.yml` or CI config, check what lint command CI runs — your local lint must match CI. + +--- + +## Step 2: Run the Linter + +```bash +# Preferred: use the project's configured lint command +pnpm lint # or npm run lint, yarn lint +pnpm lint --fix # auto-fix what can be auto-fixed + +# Then check for remaining errors +pnpm lint +``` + +If the project doesn't have a `lint` script, run the detected tool directly (see table above). + +--- + +## Step 3: Fix All Violations + +### Auto-fixable violations + +Most linters have a `--fix` flag. Use it first: + +```bash +pnpm lint --fix +# or +npx eslint --fix . +# or +ruff check --fix . +``` + +### Manual violations + +After auto-fix, re-run the linter without `--fix`. For each remaining error: + +1. Read the rule name and understand WHY it exists +2. Fix the code to comply with the rule +3. Do NOT add a disable comment + +### Common violations and correct fixes + +| Violation | Wrong Fix | Right Fix | +| ------------------------------------ | ----------------------------- | -------------------------- | +| `no-unused-vars` | `// eslint-disable-next-line` | Delete the unused variable | +| `@typescript-eslint/no-explicit-any` | `// eslint-disable` | Add a proper type | +| `prefer-const` | Ignore it | Change `let` to `const` | +| `no-console` | `// eslint-disable` | Use the project's logger | +| Import order | Ignore it | Let auto-fix sort imports | +| `any` type | `as unknown as Type` | Define the correct type | + +### The only acceptable exception + +If fixing a violation would require a major refactor outside your task scope: + +1. Do NOT disable the rule +2. Document it as a deferred item with rationale +3. Create a follow-up task/issue for the fix +4. The orchestrator (not you) decides if this is acceptable + +--- + +## Step 4: Verify Clean + +Run the linter one final time with no flags: + +```bash +pnpm lint +``` + +Expected output: **zero errors, zero warnings.** If you see any output that isn't "all clear," you are not done. + +--- + +## Step 5: Also Run Type Checking + +Linting alone is not enough. If the project has TypeScript: + +```bash +pnpm typecheck # or npx tsc --noEmit +``` + +Fix ALL type errors too. The same zero-tolerance rules apply. + +--- + +## For Orchestrated Workers + +When you receive a task from an orchestrator, linting is part of the quality gates. Your workflow is: + +``` +1. Implement the change +2. Run lint --fix +3. Run lint (verify zero errors) +4. Run typecheck (if applicable) +5. Run tests +6. Only THEN report success +``` + +If you report `"status": "success"` with lint errors, you have failed the task. + +--- + +## Quick Reference + +```bash +# JavaScript/TypeScript +pnpm lint --fix && pnpm lint && pnpm typecheck + +# Python +ruff check --fix . && ruff check . && mypy . + +# Rust +cargo clippy --fix && cargo clippy + +# Go +golangci-lint run --fix && golangci-lint run +``` diff --git a/packages/mosaic/framework/skills/marketing-ideas/SKILL.md b/packages/mosaic/framework/skills/marketing-ideas/SKILL.md new file mode 100644 index 00000000..7e8d8c7f --- /dev/null +++ b/packages/mosaic/framework/skills/marketing-ideas/SKILL.md @@ -0,0 +1,183 @@ +--- +name: marketing-ideas +version: 1.0.0 +description: "When the user needs marketing ideas, inspiration, or strategies for their SaaS or software product. Also use when the user asks for 'marketing ideas,' 'growth ideas,' 'how to market,' 'marketing strategies,' 'marketing tactics,' 'ways to promote,' or 'ideas to grow.' This skill provides 139 proven marketing approaches organized by category." +--- + +# Marketing Ideas for SaaS + +You are a marketing strategist with a library of 139 proven marketing ideas. Your goal is to help users find the right marketing strategies for their specific situation, stage, and resources. + +## How to Use This Skill + +**Check for product marketing context first:** +If `.mosaic/product-marketing-context.md` exists, read it before asking questions. Use that context and only ask for information not already covered or specific to this task. + +When asked for marketing ideas: + +1. Ask about their product, audience, and current stage if not clear +2. Suggest 3-5 most relevant ideas based on their context +3. Provide details on implementation for chosen ideas +4. Consider their resources (time, budget, team size) + +--- + +## Ideas by Category (Quick Reference) + +| Category | Ideas | Examples | +| ------------------ | ------- | ----------------------------------------------------------- | +| Content & SEO | 1-10 | Programmatic SEO, Glossary marketing, Content repurposing | +| Competitor | 11-13 | Comparison pages, Marketing jiu-jitsu | +| Free Tools | 14-22 | Calculators, Generators, Chrome extensions | +| Paid Ads | 23-34 | LinkedIn, Google, Retargeting, Podcast ads | +| Social & Community | 35-44 | LinkedIn audience, Reddit marketing, Short-form video | +| Email | 45-53 | Founder emails, Onboarding sequences, Win-back | +| Partnerships | 54-64 | Affiliate programs, Integration marketing, Newsletter swaps | +| Events | 65-72 | Webinars, Conference speaking, Virtual summits | +| PR & Media | 73-76 | Press coverage, Documentaries | +| Launches | 77-86 | Product Hunt, Lifetime deals, Giveaways | +| Product-Led | 87-96 | Viral loops, Powered-by marketing, Free migrations | +| Content Formats | 97-109 | Podcasts, Courses, Annual reports, Year wraps | +| Unconventional | 110-122 | Awards, Challenges, Guerrilla marketing | +| Platforms | 123-130 | App marketplaces, Review sites, YouTube | +| International | 131-132 | Expansion, Price localization | +| Developer | 133-136 | DevRel, Certifications | +| Audience-Specific | 137-139 | Referrals, Podcast tours, Customer language | + +**For the complete list with descriptions**: See [references/ideas-by-category.md](references/ideas-by-category.md) + +--- + +## Implementation Tips + +### By Stage + +**Pre-launch:** + +- Waitlist referrals (#79) +- Early access pricing (#81) +- Product Hunt prep (#78) + +**Early stage:** + +- Content & SEO (#1-10) +- Community (#35) +- Founder-led sales (#47) + +**Growth stage:** + +- Paid acquisition (#23-34) +- Partnerships (#54-64) +- Events (#65-72) + +**Scale:** + +- Brand campaigns +- International (#131-132) +- Media acquisitions (#73) + +### By Budget + +**Free:** + +- Content & SEO +- Community building +- Social media +- Comment marketing + +**Low budget:** + +- Targeted ads +- Sponsorships +- Free tools + +**Medium budget:** + +- Events +- Partnerships +- PR + +**High budget:** + +- Acquisitions +- Conferences +- Brand campaigns + +### By Timeline + +**Quick wins:** + +- Ads, email, social posts + +**Medium-term:** + +- Content, SEO, community + +**Long-term:** + +- Brand, thought leadership, platform effects + +--- + +## Top Ideas by Use Case + +### Need Leads Fast + +- Google Ads (#31) - High-intent search +- LinkedIn Ads (#28) - B2B targeting +- Engineering as Marketing (#15) - Free tool lead gen + +### Building Authority + +- Conference Speaking (#70) +- Book Marketing (#104) +- Podcasts (#107) + +### Low Budget Growth + +- Easy Keyword Ranking (#1) +- Reddit Marketing (#38) +- Comment Marketing (#44) + +### Product-Led Growth + +- Viral Loops (#93) +- Powered By Marketing (#87) +- In-App Upsells (#91) + +### Enterprise Sales + +- Investor Marketing (#133) +- Expert Networks (#57) +- Conference Sponsorship (#72) + +--- + +## Output Format + +When recommending ideas, provide for each: + +- **Idea name**: One-line description +- **Why it fits**: Connection to their situation +- **How to start**: First 2-3 implementation steps +- **Expected outcome**: What success looks like +- **Resources needed**: Time, budget, skills required + +--- + +## Task-Specific Questions + +1. What's your current stage and main growth goal? +2. What's your marketing budget and team size? +3. What have you already tried that worked or didn't? +4. What competitor tactics do you admire? + +--- + +## Related Skills + +- **programmatic-seo**: For scaling SEO content (#4) +- **competitor-alternatives**: For comparison pages (#11) +- **email-sequence**: For email marketing tactics +- **free-tool-strategy**: For engineering as marketing (#15) +- **referral-program**: For viral growth (#93) diff --git a/packages/mosaic/framework/skills/marketing-ideas/references/ideas-by-category.md b/packages/mosaic/framework/skills/marketing-ideas/references/ideas-by-category.md new file mode 100644 index 00000000..a8cb7263 --- /dev/null +++ b/packages/mosaic/framework/skills/marketing-ideas/references/ideas-by-category.md @@ -0,0 +1,347 @@ +# The 139 Marketing Ideas + +Complete list of proven marketing approaches organized by category. + +## Content & SEO (1-10) + +1. **Easy Keyword Ranking** - Target low-competition keywords where you can rank quickly. Find terms competitors overlook—niche variations, long-tail queries, emerging topics. + +2. **SEO Audit** - Conduct comprehensive technical SEO audits of your own site and share findings publicly. Document fixes and improvements to build authority. + +3. **Glossary Marketing** - Create comprehensive glossaries defining industry terms. Each term becomes an SEO-optimized page targeting "what is X" searches. + +4. **Programmatic SEO** - Build template-driven pages at scale targeting keyword patterns. Location pages, comparison pages, integration pages—any pattern with search volume. + +5. **Content Repurposing** - Transform one piece of content into multiple formats. Blog post becomes Twitter thread, YouTube video, podcast episode, infographic. + +6. **Proprietary Data Content** - Leverage unique data from your product to create original research and reports. Data competitors can't replicate creates linkable assets. + +7. **Internal Linking** - Strategic internal linking distributes authority and improves crawlability. Build topical clusters connecting related content. + +8. **Content Refreshing** - Regularly update existing content with fresh data, examples, and insights. Refreshed content often outperforms new content. + +9. **Knowledge Base SEO** - Optimize help documentation for search. Support articles targeting problem-solution queries capture users actively seeking solutions. + +10. **Parasite SEO** - Publish content on high-authority platforms (Medium, LinkedIn, Substack) that rank faster than your own domain. + +--- + +## Competitor & Comparison (11-13) + +11. **Competitor Comparison Pages** - Create detailed comparison pages positioning your product against competitors. "[Your Product] vs [Competitor]" pages capture high-intent searchers. + +12. **Marketing Jiu-Jitsu** - Turn competitor weaknesses into your strengths. When competitors raise prices, launch affordability campaigns. + +13. **Competitive Ad Research** - Study competitor advertising through tools like SpyFu or Facebook Ad Library. Learn what messaging resonates. + +--- + +## Free Tools & Engineering (14-22) + +14. **Side Projects as Marketing** - Build small, useful tools related to your main product. Side projects attract users who may later convert. + +15. **Engineering as Marketing** - Build free tools that solve real problems. Calculators, analyzers, generators—useful utilities that naturally lead to your paid product. + +16. **Importers as Marketing** - Build import tools for competitor data. "Import from [Competitor]" reduces switching friction. + +17. **Quiz Marketing** - Create interactive quizzes that engage users while qualifying leads. Personality quizzes, assessments, and diagnostic tools generate shares. + +18. **Calculator Marketing** - Build calculators solving real problems—ROI calculators, pricing estimators, savings tools. Calculators attract links and rank well. + +19. **Chrome Extensions** - Create browser extensions providing standalone value. Chrome Web Store becomes another distribution channel. + +20. **Microsites** - Build focused microsites for specific campaigns, products, or audiences. Dedicated domains can rank faster. + +21. **Scanners** - Build free scanning tools that audit or analyze something. Website scanners, security checkers, performance analyzers. + +22. **Public APIs** - Open APIs enable developers to build on your platform, creating an ecosystem. + +--- + +## Paid Advertising (23-34) + +23. **Podcast Advertising** - Sponsor relevant podcasts to reach engaged audiences. Host-read ads perform especially well. + +24. **Pre-targeting Ads** - Show awareness ads before launching direct response campaigns. Warm audiences convert better. + +25. **Facebook Ads** - Meta's detailed targeting reaches specific audiences. Test creative variations and leverage retargeting. + +26. **Instagram Ads** - Visual-first advertising for products with strong imagery. Stories and Reels ads capture attention. + +27. **Twitter Ads** - Reach engaged professionals discussing industry topics. Promoted tweets and follower campaigns. + +28. **LinkedIn Ads** - Target by job title, company size, and industry. Premium CPMs justified by B2B purchase intent. + +29. **Reddit Ads** - Reach passionate communities with authentic messaging. Transparency wins on Reddit. + +30. **Quora Ads** - Target users actively asking questions your product answers. Intent-rich environment. + +31. **Google Ads** - Capture high-intent search queries. Brand terms, competitor terms, and category terms. + +32. **YouTube Ads** - Video ads with detailed targeting. Pre-roll and discovery ads reach users consuming related content. + +33. **Cross-Platform Retargeting** - Follow users across platforms with consistent messaging. + +34. **Click-to-Messenger Ads** - Ads that open direct conversations rather than landing pages. + +--- + +## Social Media & Community (35-44) + +35. **Community Marketing** - Build and nurture communities around your product. Slack groups, Discord servers, Facebook groups. + +36. **Quora Marketing** - Answer relevant questions with genuine expertise. Include product mentions where naturally appropriate. + +37. **Reddit Keyword Research** - Mine Reddit for real language your audience uses. Discover pain points and desires. + +38. **Reddit Marketing** - Participate authentically in relevant subreddits. Provide value first. + +39. **LinkedIn Audience** - Build personal brands on LinkedIn for B2B reach. Thought leadership builds authority. + +40. **Instagram Audience** - Visual storytelling for products with strong aesthetics. Behind-the-scenes and user stories. + +41. **X Audience** - Build presence on X/Twitter through consistent value. Threads and insights grow followings. + +42. **Short Form Video** - TikTok, Reels, and Shorts reach new audiences with snackable content. + +43. **Engagement Pods** - Coordinate with peers to boost each other's content engagement. + +44. **Comment Marketing** - Thoughtful comments on relevant content build visibility. + +--- + +## Email Marketing (45-53) + +45. **Mistake Email Marketing** - Send "oops" emails when something genuinely goes wrong. Authenticity generates engagement. + +46. **Reactivation Emails** - Win back churned or inactive users with targeted campaigns. + +47. **Founder Welcome Email** - Personal welcome emails from founders create connection. + +48. **Dynamic Email Capture** - Smart email capture that adapts to user behavior. Exit intent, scroll depth triggers. + +49. **Monthly Newsletters** - Consistent newsletters keep your brand top-of-mind. + +50. **Inbox Placement** - Technical email optimization for deliverability. Authentication and list hygiene. + +51. **Onboarding Emails** - Guide new users to activation with targeted sequences. + +52. **Win-back Emails** - Re-engage churned users with compelling reasons to return. + +53. **Trial Reactivation** - Expired trials aren't lost causes. Targeted campaigns can recover them. + +--- + +## Partnerships & Programs (54-64) + +54. **Affiliate Discovery Through Backlinks** - Find potential affiliates by analyzing who links to competitors. + +55. **Influencer Whitelisting** - Run ads through influencer accounts for authentic reach. + +56. **Reseller Programs** - Enable agencies to resell your product. White-label options create distribution partners. + +57. **Expert Networks** - Build networks of certified experts who implement your product. + +58. **Newsletter Swaps** - Exchange promotional mentions with complementary newsletters. + +59. **Article Quotes** - Contribute expert quotes to journalists. HARO connects experts with writers. + +60. **Pixel Sharing** - Partner with complementary companies to share remarketing audiences. + +61. **Shared Slack Channels** - Create shared channels with partners and customers. + +62. **Affiliate Program** - Structured commission programs for referrers. + +63. **Integration Marketing** - Joint marketing with integration partners. + +64. **Community Sponsorship** - Sponsor relevant communities, newsletters, or publications. + +--- + +## Events & Speaking (65-72) + +65. **Live Webinars** - Educational webinars demonstrate expertise while generating leads. + +66. **Virtual Summits** - Multi-speaker online events attract audiences through varied perspectives. + +67. **Roadshows** - Take your product on the road to meet customers directly. + +68. **Local Meetups** - Host or attend local meetups in key markets. + +69. **Meetup Sponsorship** - Sponsor relevant meetups to reach engaged local audiences. + +70. **Conference Speaking** - Speak at industry conferences to reach engaged audiences. + +71. **Conferences** - Host your own conference to become the center of your industry. + +72. **Conference Sponsorship** - Sponsor relevant conferences for brand visibility. + +--- + +## PR & Media (73-76) + +73. **Media Acquisitions as Marketing** - Acquire newsletters, podcasts, or publications in your space. + +74. **Press Coverage** - Pitch newsworthy stories to relevant publications. + +75. **Fundraising PR** - Leverage funding announcements for press coverage. + +76. **Documentaries** - Create documentary content exploring your industry or customers. + +--- + +## Launches & Promotions (77-86) + +77. **Black Friday Promotions** - Annual deals create urgency and acquisition spikes. + +78. **Product Hunt Launch** - Structured Product Hunt launches reach early adopters. + +79. **Early-Access Referrals** - Reward referrals with earlier access during launches. + +80. **New Year Promotions** - New Year brings fresh budgets and goal-setting energy. + +81. **Early Access Pricing** - Launch with discounted early access tiers. + +82. **Product Hunt Alternatives** - Launch on BetaList, Launching Next, AlternativeTo. + +83. **Twitter Giveaways** - Engagement-boosting giveaways that require follows or retweets. + +84. **Giveaways** - Strategic giveaways attract attention and capture leads. + +85. **Vacation Giveaways** - Grand prize giveaways generate massive engagement. + +86. **Lifetime Deals** - One-time payment deals generate cash and users. + +--- + +## Product-Led Growth (87-96) + +87. **Powered By Marketing** - "Powered by [Your Product]" badges create free impressions. + +88. **Free Migrations** - Offer free migration services from competitors. + +89. **Contract Buyouts** - Pay to exit competitor contracts. + +90. **One-Click Registration** - Minimize signup friction with OAuth options. + +91. **In-App Upsells** - Strategic upgrade prompts within the product experience. + +92. **Newsletter Referrals** - Built-in referral programs for newsletters. + +93. **Viral Loops** - Product mechanics that naturally encourage sharing. + +94. **Offboarding Flows** - Optimize cancellation flows to retain or learn. + +95. **Concierge Setup** - White-glove onboarding for high-value accounts. + +96. **Onboarding Optimization** - Continuous improvement of new user experience. + +--- + +## Content Formats (97-109) + +97. **Playlists as Marketing** - Create Spotify playlists for your audience. + +98. **Template Marketing** - Offer free templates users can immediately use. + +99. **Graphic Novel Marketing** - Transform complex stories into visual narratives. + +100. **Promo Videos** - High-quality promotional videos showcase your product. + +101. **Industry Interviews** - Interview customers, experts, and thought leaders. + +102. **Social Screenshots** - Design shareable screenshot templates for social proof. + +103. **Online Courses** - Educational courses establish authority while generating leads. + +104. **Book Marketing** - Author a book establishing expertise in your domain. + +105. **Annual Reports** - Publish annual reports showcasing industry data and trends. + +106. **End of Year Wraps** - Personalized year-end summaries users want to share. + +107. **Podcasts** - Launch a podcast reaching audiences during commutes. + +108. **Changelogs** - Public changelogs showcase product momentum. + +109. **Public Demos** - Live product demonstrations showing real usage. + +--- + +## Unconventional & Creative (110-122) + +110. **Awards as Marketing** - Create industry awards positioning your brand as tastemaker. + +111. **Challenges as Marketing** - Launch viral challenges that spread organically. + +112. **Reality TV Marketing** - Create reality-show style content following real customers. + +113. **Controversy as Marketing** - Strategic positioning against industry norms. + +114. **Moneyball Marketing** - Data-driven marketing finding undervalued channels. + +115. **Curation as Marketing** - Curate valuable resources for your audience. + +116. **Grants as Marketing** - Offer grants to customers or community members. + +117. **Product Competitions** - Sponsor competitions using your product. + +118. **Cameo Marketing** - Use Cameo celebrities for personalized messages. + +119. **OOH Advertising** - Out-of-home advertising—billboards, transit ads. + +120. **Marketing Stunts** - Bold, attention-grabbing marketing moments. + +121. **Guerrilla Marketing** - Unconventional, low-cost marketing in unexpected places. + +122. **Humor Marketing** - Use humor to stand out and create memorability. + +--- + +## Platforms & Marketplaces (123-130) + +123. **Open Source as Marketing** - Open-source components or tools build developer goodwill. + +124. **App Store Optimization** - Optimize app store listings for discoverability. + +125. **App Marketplaces** - List in Salesforce AppExchange, Shopify App Store, etc. + +126. **YouTube Reviews** - Get YouTubers to review your product. + +127. **YouTube Channel** - Build a YouTube presence with tutorials and thought leadership. + +128. **Source Platforms** - Submit to G2, Capterra, GetApp, and similar directories. + +129. **Review Sites** - Actively manage presence on review platforms. + +130. **Live Audio** - Host Twitter Spaces, Clubhouse, or LinkedIn Audio discussions. + +--- + +## International & Localization (131-132) + +131. **International Expansion** - Expand to new geographic markets with localization. + +132. **Price Localization** - Adjust pricing for local purchasing power. + +--- + +## Developer & Technical (133-136) + +133. **Investor Marketing** - Market to investors for portfolio introductions. + +134. **Certifications** - Create certification programs validating expertise. + +135. **Support as Marketing** - Exceptional support creates stories customers share. + +136. **Developer Relations** - Build relationships with developer communities. + +--- + +## Audience-Specific (137-139) + +137. **Two-Sided Referrals** - Reward both referrer and referred. + +138. **Podcast Tours** - Guest on multiple podcasts reaching your target audience. + +139. **Customer Language** - Use the exact words your customers use in marketing. diff --git a/packages/mosaic/framework/skills/marketing-psychology/SKILL.md b/packages/mosaic/framework/skills/marketing-psychology/SKILL.md new file mode 100644 index 00000000..3c3ead0e --- /dev/null +++ b/packages/mosaic/framework/skills/marketing-psychology/SKILL.md @@ -0,0 +1,526 @@ +--- +name: marketing-psychology +version: 1.0.0 +description: "When the user wants to apply psychological principles, mental models, or behavioral science to marketing. Also use when the user mentions 'psychology,' 'mental models,' 'cognitive bias,' 'persuasion,' 'behavioral science,' 'why people buy,' 'decision-making,' or 'consumer behavior.' This skill provides 70+ mental models organized for marketing application." +--- + +# Marketing Psychology & Mental Models + +You are an expert in applying psychological principles and mental models to marketing. Your goal is to help users understand why people buy, how to influence behavior ethically, and how to make better marketing decisions. + +## How to Use This Skill + +**Check for product marketing context first:** +If `.mosaic/product-marketing-context.md` exists, read it before applying mental models. Use that context to tailor recommendations to the specific product and audience. + +Mental models are thinking tools that help you make better decisions, understand customer behavior, and create more effective marketing. When helping users: + +1. Identify which mental models apply to their situation +2. Explain the psychology behind the model +3. Provide specific marketing applications +4. Suggest how to implement ethically + +--- + +## Foundational Thinking Models + +These models sharpen your strategy and help you solve the right problems. + +### First Principles + +Break problems down to basic truths and build solutions from there. Instead of copying competitors, ask "why" repeatedly to find root causes. Use the 5 Whys technique to tunnel down to what really matters. + +**Marketing application**: Don't assume you need content marketing because competitors do. Ask why you need it, what problem it solves, and whether there's a better solution. + +### Jobs to Be Done + +People don't buy products—they "hire" them to get a job done. Focus on the outcome customers want, not features. + +**Marketing application**: A drill buyer doesn't want a drill—they want a hole. Frame your product around the job it accomplishes, not its specifications. + +### Circle of Competence + +Know what you're good at and stay within it. Venture outside only with proper learning or expert help. + +**Marketing application**: Don't chase every channel. Double down where you have genuine expertise and competitive advantage. + +### Inversion + +Instead of asking "How do I succeed?", ask "What would guarantee failure?" Then avoid those things. + +**Marketing application**: List everything that would make your campaign fail—confusing messaging, wrong audience, slow landing page—then systematically prevent each. + +### Occam's Razor + +The simplest explanation is usually correct. Avoid overcomplicating strategies or attributing results to complex causes when simple ones suffice. + +**Marketing application**: If conversions dropped, check the obvious first (broken form, page speed) before assuming complex attribution issues. + +### Pareto Principle (80/20 Rule) + +Roughly 80% of results come from 20% of efforts. Identify and focus on the vital few. + +**Marketing application**: Find the 20% of channels, customers, or content driving 80% of results. Cut or reduce the rest. + +### Local vs. Global Optima + +A local optimum is the best solution nearby, but a global optimum is the best overall. Don't get stuck optimizing the wrong thing. + +**Marketing application**: Optimizing email subject lines (local) won't help if email isn't the right channel (global). Zoom out before zooming in. + +### Theory of Constraints + +Every system has one bottleneck limiting throughput. Find and fix that constraint before optimizing elsewhere. + +**Marketing application**: If your funnel converts well but traffic is low, more conversion optimization won't help. Fix the traffic bottleneck first. + +### Opportunity Cost + +Every choice has a cost—what you give up by not choosing alternatives. Consider what you're saying no to. + +**Marketing application**: Time spent on a low-ROI channel is time not spent on high-ROI activities. Always compare against alternatives. + +### Law of Diminishing Returns + +After a point, additional investment yields progressively smaller gains. + +**Marketing application**: The 10th blog post won't have the same impact as the first. Know when to diversify rather than double down. + +### Second-Order Thinking + +Consider not just immediate effects, but the effects of those effects. + +**Marketing application**: A flash sale boosts revenue (first order) but may train customers to wait for discounts (second order). + +### Map ≠ Territory + +Models and data represent reality but aren't reality itself. Don't confuse your analytics dashboard with actual customer experience. + +**Marketing application**: Your customer persona is a useful model, but real customers are more complex. Stay in touch with actual users. + +### Probabilistic Thinking + +Think in probabilities, not certainties. Estimate likelihoods and plan for multiple outcomes. + +**Marketing application**: Don't bet everything on one campaign. Spread risk and plan for scenarios where your primary strategy underperforms. + +### Barbell Strategy + +Combine extreme safety with small high-risk/high-reward bets. Avoid the mediocre middle. + +**Marketing application**: Put 80% of budget into proven channels, 20% into experimental bets. Avoid moderate-risk, moderate-reward middle. + +--- + +## Understanding Buyers & Human Psychology + +These models explain how customers think, decide, and behave. + +### Fundamental Attribution Error + +People attribute others' behavior to character, not circumstances. "They didn't buy because they're not serious" vs. "The checkout was confusing." + +**Marketing application**: When customers don't convert, examine your process before blaming them. The problem is usually situational, not personal. + +### Mere Exposure Effect + +People prefer things they've seen before. Familiarity breeds liking. + +**Marketing application**: Consistent brand presence builds preference over time. Repetition across channels creates comfort and trust. + +### Availability Heuristic + +People judge likelihood by how easily examples come to mind. Recent or vivid events seem more common. + +**Marketing application**: Case studies and testimonials make success feel more achievable. Make positive outcomes easy to imagine. + +### Confirmation Bias + +People seek information confirming existing beliefs and ignore contradictory evidence. + +**Marketing application**: Understand what your audience already believes and align messaging accordingly. Fighting beliefs head-on rarely works. + +### The Lindy Effect + +The longer something has survived, the longer it's likely to continue. Old ideas often outlast new ones. + +**Marketing application**: Proven marketing principles (clear value props, social proof) outlast trendy tactics. Don't abandon fundamentals for fads. + +### Mimetic Desire + +People want things because others want them. Desire is socially contagious. + +**Marketing application**: Show that desirable people want your product. Waitlists, exclusivity, and social proof trigger mimetic desire. + +### Sunk Cost Fallacy + +People continue investing in something because of past investment, even when it's no longer rational. + +**Marketing application**: Know when to kill underperforming campaigns. Past spend shouldn't justify future spend if results aren't there. + +### Endowment Effect + +People value things more once they own them. + +**Marketing application**: Free trials, samples, and freemium models let customers "own" the product, making them reluctant to give it up. + +### IKEA Effect + +People value things more when they've put effort into creating them. + +**Marketing application**: Let customers customize, configure, or build something. Their investment increases perceived value and commitment. + +### Zero-Price Effect + +Free isn't just a low price—it's psychologically different. "Free" triggers irrational preference. + +**Marketing application**: Free tiers, free trials, and free shipping have disproportionate appeal. The jump from $1 to $0 is bigger than $2 to $1. + +### Hyperbolic Discounting / Present Bias + +People strongly prefer immediate rewards over future ones, even when waiting is more rational. + +**Marketing application**: Emphasize immediate benefits ("Start saving time today") over future ones ("You'll see ROI in 6 months"). + +### Status-Quo Bias + +People prefer the current state of affairs. Change requires effort and feels risky. + +**Marketing application**: Reduce friction to switch. Make the transition feel safe and easy. "Import your data in one click." + +### Default Effect + +People tend to accept pre-selected options. Defaults are powerful. + +**Marketing application**: Pre-select the plan you want customers to choose. Opt-out beats opt-in for subscriptions (ethically applied). + +### Paradox of Choice + +Too many options overwhelm and paralyze. Fewer choices often lead to more decisions. + +**Marketing application**: Limit options. Three pricing tiers beat seven. Recommend a single "best for most" option. + +### Goal-Gradient Effect + +People accelerate effort as they approach a goal. Progress visualization motivates action. + +**Marketing application**: Show progress bars, completion percentages, and "almost there" messaging to drive completion. + +### Peak-End Rule + +People judge experiences by the peak (best or worst moment) and the end, not the average. + +**Marketing application**: Design memorable peaks (surprise upgrades, delightful moments) and strong endings (thank you pages, follow-up emails). + +### Zeigarnik Effect + +Unfinished tasks occupy the mind more than completed ones. Open loops create tension. + +**Marketing application**: "You're 80% done" creates pull to finish. Incomplete profiles, abandoned carts, and cliffhangers leverage this. + +### Pratfall Effect + +Competent people become more likable when they show a small flaw. Perfection is less relatable. + +**Marketing application**: Admitting a weakness ("We're not the cheapest, but...") can increase trust and differentiation. + +### Curse of Knowledge + +Once you know something, you can't imagine not knowing it. Experts struggle to explain simply. + +**Marketing application**: Your product seems obvious to you but confusing to newcomers. Test copy with people unfamiliar with your space. + +### Mental Accounting + +People treat money differently based on its source or intended use, even though money is fungible. + +**Marketing application**: Frame costs in favorable mental accounts. "$3/day" feels different than "$90/month" even though it's the same. + +### Regret Aversion + +People avoid actions that might cause regret, even if the expected outcome is positive. + +**Marketing application**: Address regret directly. Money-back guarantees, free trials, and "no commitment" messaging reduce regret fear. + +### Bandwagon Effect / Social Proof + +People follow what others are doing. Popularity signals quality and safety. + +**Marketing application**: Show customer counts, testimonials, logos, reviews, and "trending" indicators. Numbers create confidence. + +--- + +## Influencing Behavior & Persuasion + +These models help you ethically influence customer decisions. + +### Reciprocity Principle + +People feel obligated to return favors. Give first, and people want to give back. + +**Marketing application**: Free content, free tools, and generous free tiers create reciprocal obligation. Give value before asking for anything. + +### Commitment & Consistency + +Once people commit to something, they want to stay consistent with that commitment. + +**Marketing application**: Get small commitments first (email signup, free trial). People who've taken one step are more likely to take the next. + +### Authority Bias + +People defer to experts and authority figures. Credentials and expertise create trust. + +**Marketing application**: Feature expert endorsements, certifications, "featured in" logos, and thought leadership content. + +### Liking / Similarity Bias + +People say yes to those they like and those similar to themselves. + +**Marketing application**: Use relatable spokespeople, founder stories, and community language. "Built by marketers for marketers" signals similarity. + +### Unity Principle + +Shared identity drives influence. "One of us" is powerful. + +**Marketing application**: Position your brand as part of the customer's tribe. Use insider language and shared values. + +### Scarcity / Urgency Heuristic + +Limited availability increases perceived value. Scarcity signals desirability. + +**Marketing application**: Limited-time offers, low-stock warnings, and exclusive access create urgency. Only use when genuine. + +### Foot-in-the-Door Technique + +Start with a small request, then escalate. Compliance with small requests leads to compliance with larger ones. + +**Marketing application**: Free trial → paid plan → annual plan → enterprise. Each step builds on the last. + +### Door-in-the-Face Technique + +Start with an unreasonably large request, then retreat to what you actually want. The contrast makes the second request seem reasonable. + +**Marketing application**: Show enterprise pricing first, then reveal the affordable starter plan. The contrast makes it feel like a deal. + +### Loss Aversion / Prospect Theory + +Losses feel roughly twice as painful as equivalent gains feel good. People will work harder to avoid losing than to gain. + +**Marketing application**: Frame in terms of what they'll lose by not acting. "Don't miss out" beats "You could gain." + +### Anchoring Effect + +The first number people see heavily influences subsequent judgments. + +**Marketing application**: Show the higher price first (original price, competitor price, enterprise tier) to anchor expectations. + +### Decoy Effect + +Adding a third, inferior option makes one of the original two look better. + +**Marketing application**: A "decoy" pricing tier that's clearly worse value makes your preferred tier look like the obvious choice. + +### Framing Effect + +How something is presented changes how it's perceived. Same facts, different frames. + +**Marketing application**: "90% success rate" vs. "10% failure rate" are identical but feel different. Frame positively. + +### Contrast Effect + +Things seem different depending on what they're compared to. + +**Marketing application**: Show the "before" state clearly. The contrast with your "after" makes improvements vivid. + +--- + +## Pricing Psychology + +These models specifically address how people perceive and respond to prices. + +### Charm Pricing / Left-Digit Effect + +Prices ending in 9 seem significantly lower than the next round number. $99 feels much cheaper than $100. + +**Marketing application**: Use .99 or .95 endings for value-focused products. The left digit dominates perception. + +### Rounded-Price (Fluency) Effect + +Round numbers feel premium and are easier to process. $100 signals quality; $99 signals value. + +**Marketing application**: Use round prices for premium products ($500/month), charm prices for value products ($497/month). + +### Rule of 100 + +For prices under $100, percentage discounts seem larger ("20% off"). For prices over $100, absolute discounts seem larger ("$50 off"). + +**Marketing application**: $80 product: "20% off" beats "$16 off." $500 product: "$100 off" beats "20% off." + +### Price Relativity / Good-Better-Best + +People judge prices relative to options presented. A middle tier seems reasonable between cheap and expensive. + +**Marketing application**: Three tiers where the middle is your target. The expensive tier makes it look reasonable; the cheap tier provides an anchor. + +### Mental Accounting (Pricing) + +Framing the same price differently changes perception. + +**Marketing application**: "$1/day" feels cheaper than "$30/month." "Less than your morning coffee" reframes the expense. + +--- + +## Design & Delivery Models + +These models help you design effective marketing systems. + +### Hick's Law + +Decision time increases with the number and complexity of choices. More options = slower decisions = more abandonment. + +**Marketing application**: Simplify choices. One clear CTA beats three. Fewer form fields beat more. + +### AIDA Funnel + +Attention → Interest → Desire → Action. The classic customer journey model. + +**Marketing application**: Structure pages and campaigns to move through each stage. Capture attention before building desire. + +### Rule of 7 + +Prospects need roughly 7 touchpoints before converting. One ad rarely converts; sustained presence does. + +**Marketing application**: Build multi-touch campaigns across channels. Retargeting, email sequences, and consistent presence compound. + +### Nudge Theory / Choice Architecture + +Small changes in how choices are presented significantly influence decisions. + +**Marketing application**: Default selections, strategic ordering, and friction reduction guide behavior without restricting choice. + +### BJ Fogg Behavior Model + +Behavior = Motivation × Ability × Prompt. All three must be present for action. + +**Marketing application**: High motivation but hard to do = won't happen. Easy to do but no prompt = won't happen. Design for all three. + +### EAST Framework + +Make desired behaviors: Easy, Attractive, Social, Timely. + +**Marketing application**: Reduce friction (easy), make it appealing (attractive), show others doing it (social), ask at the right moment (timely). + +### COM-B Model + +Behavior requires: Capability, Opportunity, Motivation. + +**Marketing application**: Can they do it (capability)? Is the path clear (opportunity)? Do they want to (motivation)? Address all three. + +### Activation Energy + +The initial energy required to start something. High activation energy prevents action even if the task is easy overall. + +**Marketing application**: Reduce starting friction. Pre-fill forms, offer templates, show quick wins. Make the first step trivially easy. + +### North Star Metric + +One metric that best captures the value you deliver to customers. Focus creates alignment. + +**Marketing application**: Identify your North Star (active users, completed projects, revenue per customer) and align all efforts toward it. + +### The Cobra Effect + +When incentives backfire and produce the opposite of intended results. + +**Marketing application**: Test incentive structures. A referral bonus might attract low-quality referrals gaming the system. + +--- + +## Growth & Scaling Models + +These models explain how marketing compounds and scales. + +### Feedback Loops + +Output becomes input, creating cycles. Positive loops accelerate growth; negative loops create decline. + +**Marketing application**: Build virtuous cycles: more users → more content → better SEO → more users. Identify and strengthen positive loops. + +### Compounding + +Small, consistent gains accumulate into large results over time. Early gains matter most. + +**Marketing application**: Consistent content, SEO, and brand building compound. Start early; benefits accumulate exponentially. + +### Network Effects + +A product becomes more valuable as more people use it. + +**Marketing application**: Design features that improve with more users: shared workspaces, integrations, marketplaces, communities. + +### Flywheel Effect + +Sustained effort creates momentum that eventually maintains itself. Hard to start, easy to maintain. + +**Marketing application**: Content → traffic → leads → customers → case studies → more content. Each element powers the next. + +### Switching Costs + +The price (time, money, effort, data) of changing to a competitor. High switching costs create retention. + +**Marketing application**: Increase switching costs ethically: integrations, data accumulation, workflow customization, team adoption. + +### Exploration vs. Exploitation + +Balance trying new things (exploration) with optimizing what works (exploitation). + +**Marketing application**: Don't abandon working channels for shiny new ones, but allocate some budget to experiments. + +### Critical Mass / Tipping Point + +The threshold after which growth becomes self-sustaining. + +**Marketing application**: Focus resources on reaching critical mass in one segment before expanding. Depth before breadth. + +### Survivorship Bias + +Focusing on successes while ignoring failures that aren't visible. + +**Marketing application**: Study failed campaigns, not just successful ones. The viral hit you're copying had 99 failures you didn't see. + +--- + +## Quick Reference + +When facing a marketing challenge, consider: + +| Challenge | Relevant Models | +| ------------------ | ---------------------------------------------------------- | +| Low conversions | Hick's Law, Activation Energy, BJ Fogg, Friction | +| Price objections | Anchoring, Framing, Mental Accounting, Loss Aversion | +| Building trust | Authority, Social Proof, Reciprocity, Pratfall Effect | +| Increasing urgency | Scarcity, Loss Aversion, Zeigarnik Effect | +| Retention/churn | Endowment Effect, Switching Costs, Status-Quo Bias | +| Growth stalling | Theory of Constraints, Local vs Global Optima, Compounding | +| Decision paralysis | Paradox of Choice, Default Effect, Nudge Theory | +| Onboarding | Goal-Gradient, IKEA Effect, Commitment & Consistency | + +--- + +## Task-Specific Questions + +1. What specific behavior are you trying to influence? +2. What does your customer believe before encountering your marketing? +3. Where in the journey (awareness → consideration → decision) is this? +4. What's currently preventing the desired action? +5. Have you tested this with real customers? + +--- + +## Related Skills + +- **page-cro**: Apply psychology to page optimization +- **copywriting**: Write copy using psychological principles +- **popup-cro**: Use triggers and psychology in popups +- **pricing-page optimization**: See page-cro for pricing psychology +- **ab-test-setup**: Test psychological hypotheses diff --git a/packages/mosaic/framework/skills/mcp-builder/LICENSE.txt b/packages/mosaic/framework/skills/mcp-builder/LICENSE.txt new file mode 100644 index 00000000..7a4a3ea2 --- /dev/null +++ b/packages/mosaic/framework/skills/mcp-builder/LICENSE.txt @@ -0,0 +1,202 @@ + + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright [yyyy] [name of copyright owner] + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. \ No newline at end of file diff --git a/packages/mosaic/framework/skills/mcp-builder/SKILL.md b/packages/mosaic/framework/skills/mcp-builder/SKILL.md new file mode 100644 index 00000000..6a563c9c --- /dev/null +++ b/packages/mosaic/framework/skills/mcp-builder/SKILL.md @@ -0,0 +1,255 @@ +--- +name: mcp-builder +description: Guide for creating high-quality MCP (Model Context Protocol) servers that enable LLMs to interact with external services through well-designed tools. Use when building MCP servers to integrate external APIs or services, whether in Python (FastMCP) or Node/TypeScript (MCP SDK). +license: Complete terms in LICENSE.txt +--- + +# MCP Server Development Guide + +## Overview + +Create MCP (Model Context Protocol) servers that enable LLMs to interact with external services through well-designed tools. The quality of an MCP server is measured by how well it enables LLMs to accomplish real-world tasks. + +--- + +# Process + +## 🚀 High-Level Workflow + +Creating a high-quality MCP server involves four main phases: + +### Phase 1: Deep Research and Planning + +#### 1.1 Understand Modern MCP Design + +**API Coverage vs. Workflow Tools:** +Balance comprehensive API endpoint coverage with specialized workflow tools. Workflow tools can be more convenient for specific tasks, while comprehensive coverage gives agents flexibility to compose operations. Performance varies by client—some clients benefit from code execution that combines basic tools, while others work better with higher-level workflows. When uncertain, prioritize comprehensive API coverage. + +**Tool Naming and Discoverability:** +Clear, descriptive tool names help agents find the right tools quickly. Use consistent prefixes (e.g., `github_create_issue`, `github_list_repos`) and action-oriented naming. + +**Context Management:** +Agents benefit from concise tool descriptions and the ability to filter/paginate results. Design tools that return focused, relevant data. Some clients support code execution which can help agents filter and process data efficiently. + +**Actionable Error Messages:** +Error messages should guide agents toward solutions with specific suggestions and next steps. + +#### 1.2 Study MCP Protocol Documentation + +**Navigate the MCP specification:** + +Start with the sitemap to find relevant pages: `https://modelcontextprotocol.io/sitemap.xml` + +Then fetch specific pages with `.md` suffix for markdown format (e.g., `https://modelcontextprotocol.io/specification/draft.md`). + +Key pages to review: + +- Specification overview and architecture +- Transport mechanisms (streamable HTTP, stdio) +- Tool, resource, and prompt definitions + +#### 1.3 Study Framework Documentation + +**Recommended stack:** + +- **Language**: TypeScript (high-quality SDK support and good compatibility in many execution environments e.g. MCPB. Plus AI models are good at generating TypeScript code, benefiting from its broad usage, static typing and good linting tools) +- **Transport**: Streamable HTTP for remote servers, using stateless JSON (simpler to scale and maintain, as opposed to stateful sessions and streaming responses). stdio for local servers. + +**Load framework documentation:** + +- **MCP Best Practices**: [📋 View Best Practices](./reference/mcp_best_practices.md) - Core guidelines + +**For TypeScript (recommended):** + +- **TypeScript SDK**: Use WebFetch to load `https://raw.githubusercontent.com/modelcontextprotocol/typescript-sdk/main/README.md` +- [⚡ TypeScript Guide](./reference/node_mcp_server.md) - TypeScript patterns and examples + +**For Python:** + +- **Python SDK**: Use WebFetch to load `https://raw.githubusercontent.com/modelcontextprotocol/python-sdk/main/README.md` +- [🐍 Python Guide](./reference/python_mcp_server.md) - Python patterns and examples + +#### 1.4 Plan Your Implementation + +**Understand the API:** +Review the service's API documentation to identify key endpoints, authentication requirements, and data models. Use web search and WebFetch as needed. + +**Tool Selection:** +Prioritize comprehensive API coverage. List endpoints to implement, starting with the most common operations. + +--- + +### Phase 2: Implementation + +#### 2.1 Set Up Project Structure + +See language-specific guides for project setup: + +- [⚡ TypeScript Guide](./reference/node_mcp_server.md) - Project structure, package.json, tsconfig.json +- [🐍 Python Guide](./reference/python_mcp_server.md) - Module organization, dependencies + +#### 2.2 Implement Core Infrastructure + +Create shared utilities: + +- API client with authentication +- Error handling helpers +- Response formatting (JSON/Markdown) +- Pagination support + +#### 2.3 Implement Tools + +For each tool: + +**Input Schema:** + +- Use Zod (TypeScript) or Pydantic (Python) +- Include constraints and clear descriptions +- Add examples in field descriptions + +**Output Schema:** + +- Define `outputSchema` where possible for structured data +- Use `structuredContent` in tool responses (TypeScript SDK feature) +- Helps clients understand and process tool outputs + +**Tool Description:** + +- Concise summary of functionality +- Parameter descriptions +- Return type schema + +**Implementation:** + +- Async/await for I/O operations +- Proper error handling with actionable messages +- Support pagination where applicable +- Return both text content and structured data when using modern SDKs + +**Annotations:** + +- `readOnlyHint`: true/false +- `destructiveHint`: true/false +- `idempotentHint`: true/false +- `openWorldHint`: true/false + +--- + +### Phase 3: Review and Test + +#### 3.1 Code Quality + +Review for: + +- No duplicated code (DRY principle) +- Consistent error handling +- Full type coverage +- Clear tool descriptions + +#### 3.2 Build and Test + +**TypeScript:** + +- Run `npm run build` to verify compilation +- Test with MCP Inspector: `npx @modelcontextprotocol/inspector` + +**Python:** + +- Verify syntax: `python -m py_compile your_server.py` +- Test with MCP Inspector + +See language-specific guides for detailed testing approaches and quality checklists. + +--- + +### Phase 4: Create Evaluations + +After implementing your MCP server, create comprehensive evaluations to test its effectiveness. + +**Load [✅ Evaluation Guide](./reference/evaluation.md) for complete evaluation guidelines.** + +#### 4.1 Understand Evaluation Purpose + +Use evaluations to test whether LLMs can effectively use your MCP server to answer realistic, complex questions. + +#### 4.2 Create 10 Evaluation Questions + +To create effective evaluations, follow the process outlined in the evaluation guide: + +1. **Tool Inspection**: List available tools and understand their capabilities +2. **Content Exploration**: Use READ-ONLY operations to explore available data +3. **Question Generation**: Create 10 complex, realistic questions +4. **Answer Verification**: Solve each question yourself to verify answers + +#### 4.3 Evaluation Requirements + +Ensure each question is: + +- **Independent**: Not dependent on other questions +- **Read-only**: Only non-destructive operations required +- **Complex**: Requiring multiple tool calls and deep exploration +- **Realistic**: Based on real use cases humans would care about +- **Verifiable**: Single, clear answer that can be verified by string comparison +- **Stable**: Answer won't change over time + +#### 4.4 Output Format + +Create an XML file with this structure: + +```xml +<evaluation> + <qa_pair> + <question>Find discussions about AI model launches with animal codenames. One model needed a specific safety designation that uses the format ASL-X. What number X was being determined for the model named after a spotted wild cat?</question> + <answer>3</answer> + </qa_pair> +<!-- More qa_pairs... --> +</evaluation> +``` + +--- + +# Reference Files + +## 📚 Documentation Library + +Load these resources as needed during development: + +### Core MCP Documentation (Load First) + +- **MCP Protocol**: Start with sitemap at `https://modelcontextprotocol.io/sitemap.xml`, then fetch specific pages with `.md` suffix +- [📋 MCP Best Practices](./reference/mcp_best_practices.md) - Universal MCP guidelines including: + - Server and tool naming conventions + - Response format guidelines (JSON vs Markdown) + - Pagination best practices + - Transport selection (streamable HTTP vs stdio) + - Security and error handling standards + +### SDK Documentation (Load During Phase 1/2) + +- **Python SDK**: Fetch from `https://raw.githubusercontent.com/modelcontextprotocol/python-sdk/main/README.md` +- **TypeScript SDK**: Fetch from `https://raw.githubusercontent.com/modelcontextprotocol/typescript-sdk/main/README.md` + +### Language-Specific Implementation Guides (Load During Phase 2) + +- [🐍 Python Implementation Guide](./reference/python_mcp_server.md) - Complete Python/FastMCP guide with: + - Server initialization patterns + - Pydantic model examples + - Tool registration with `@mcp.tool` + - Complete working examples + - Quality checklist + +- [⚡ TypeScript Implementation Guide](./reference/node_mcp_server.md) - Complete TypeScript guide with: + - Project structure + - Zod schema patterns + - Tool registration with `server.registerTool` + - Complete working examples + - Quality checklist + +### Evaluation Guide (Load During Phase 4) + +- [✅ Evaluation Guide](./reference/evaluation.md) - Complete evaluation creation guide with: + - Question creation guidelines + - Answer verification strategies + - XML format specifications + - Example questions and answers + - Running an evaluation with the provided scripts diff --git a/packages/mosaic/framework/skills/mcp-builder/reference/evaluation.md b/packages/mosaic/framework/skills/mcp-builder/reference/evaluation.md new file mode 100644 index 00000000..4b825eee --- /dev/null +++ b/packages/mosaic/framework/skills/mcp-builder/reference/evaluation.md @@ -0,0 +1,630 @@ +# MCP Server Evaluation Guide + +## Overview + +This document provides guidance on creating comprehensive evaluations for MCP servers. Evaluations test whether LLMs can effectively use your MCP server to answer realistic, complex questions using only the tools provided. + +--- + +## Quick Reference + +### Evaluation Requirements + +- Create 10 human-readable questions +- Questions must be READ-ONLY, INDEPENDENT, NON-DESTRUCTIVE +- Each question requires multiple tool calls (potentially dozens) +- Answers must be single, verifiable values +- Answers must be STABLE (won't change over time) + +### Output Format + +```xml +<evaluation> + <qa_pair> + <question>Your question here</question> + <answer>Single verifiable answer</answer> + </qa_pair> +</evaluation> +``` + +--- + +## Purpose of Evaluations + +The measure of quality of an MCP server is NOT how well or comprehensively the server implements tools, but how well these implementations (input/output schemas, docstrings/descriptions, functionality) enable LLMs with no other context and access ONLY to the MCP servers to answer realistic and difficult questions. + +## Evaluation Overview + +Create 10 human-readable questions requiring ONLY READ-ONLY, INDEPENDENT, NON-DESTRUCTIVE, and IDEMPOTENT operations to answer. Each question should be: + +- Realistic +- Clear and concise +- Unambiguous +- Complex, requiring potentially dozens of tool calls or steps +- Answerable with a single, verifiable value that you identify in advance + +## Question Guidelines + +### Core Requirements + +1. **Questions MUST be independent** + - Each question should NOT depend on the answer to any other question + - Should not assume prior write operations from processing another question + +2. **Questions MUST require ONLY NON-DESTRUCTIVE AND IDEMPOTENT tool use** + - Should not instruct or require modifying state to arrive at the correct answer + +3. **Questions must be REALISTIC, CLEAR, CONCISE, and COMPLEX** + - Must require another LLM to use multiple (potentially dozens of) tools or steps to answer + +### Complexity and Depth + +4. **Questions must require deep exploration** + - Consider multi-hop questions requiring multiple sub-questions and sequential tool calls + - Each step should benefit from information found in previous questions + +5. **Questions may require extensive paging** + - May need paging through multiple pages of results + - May require querying old data (1-2 years out-of-date) to find niche information + - The questions must be DIFFICULT + +6. **Questions must require deep understanding** + - Rather than surface-level knowledge + - May pose complex ideas as True/False questions requiring evidence + - May use multiple-choice format where LLM must search different hypotheses + +7. **Questions must not be solvable with straightforward keyword search** + - Do not include specific keywords from the target content + - Use synonyms, related concepts, or paraphrases + - Require multiple searches, analyzing multiple related items, extracting context, then deriving the answer + +### Tool Testing + +8. **Questions should stress-test tool return values** + - May elicit tools returning large JSON objects or lists, overwhelming the LLM + - Should require understanding multiple modalities of data: + - IDs and names + - Timestamps and datetimes (months, days, years, seconds) + - File IDs, names, extensions, and mimetypes + - URLs, GIDs, etc. + - Should probe the tool's ability to return all useful forms of data + +9. **Questions should MOSTLY reflect real human use cases** + - The kinds of information retrieval tasks that HUMANS assisted by an LLM would care about + +10. **Questions may require dozens of tool calls** + - This challenges LLMs with limited context + - Encourages MCP server tools to reduce information returned + +11. **Include ambiguous questions** + - May be ambiguous OR require difficult decisions on which tools to call + - Force the LLM to potentially make mistakes or misinterpret + - Ensure that despite AMBIGUITY, there is STILL A SINGLE VERIFIABLE ANSWER + +### Stability + +12. **Questions must be designed so the answer DOES NOT CHANGE** + - Do not ask questions that rely on "current state" which is dynamic + - For example, do not count: + - Number of reactions to a post + - Number of replies to a thread + - Number of members in a channel + +13. **DO NOT let the MCP server RESTRICT the kinds of questions you create** + - Create challenging and complex questions + - Some may not be solvable with the available MCP server tools + - Questions may require specific output formats (datetime vs. epoch time, JSON vs. MARKDOWN) + - Questions may require dozens of tool calls to complete + +## Answer Guidelines + +### Verification + +1. **Answers must be VERIFIABLE via direct string comparison** + - If the answer can be re-written in many formats, clearly specify the output format in the QUESTION + - Examples: "Use YYYY/MM/DD.", "Respond True or False.", "Answer A, B, C, or D and nothing else." + - Answer should be a single VERIFIABLE value such as: + - User ID, user name, display name, first name, last name + - Channel ID, channel name + - Message ID, string + - URL, title + - Numerical quantity + - Timestamp, datetime + - Boolean (for True/False questions) + - Email address, phone number + - File ID, file name, file extension + - Multiple choice answer + - Answers must not require special formatting or complex, structured output + - Answer will be verified using DIRECT STRING COMPARISON + +### Readability + +2. **Answers should generally prefer HUMAN-READABLE formats** + - Examples: names, first name, last name, datetime, file name, message string, URL, yes/no, true/false, a/b/c/d + - Rather than opaque IDs (though IDs are acceptable) + - The VAST MAJORITY of answers should be human-readable + +### Stability + +3. **Answers must be STABLE/STATIONARY** + - Look at old content (e.g., conversations that have ended, projects that have launched, questions answered) + - Create QUESTIONS based on "closed" concepts that will always return the same answer + - Questions may ask to consider a fixed time window to insulate from non-stationary answers + - Rely on context UNLIKELY to change + - Example: if finding a paper name, be SPECIFIC enough so answer is not confused with papers published later + +4. **Answers must be CLEAR and UNAMBIGUOUS** + - Questions must be designed so there is a single, clear answer + - Answer can be derived from using the MCP server tools + +### Diversity + +5. **Answers must be DIVERSE** + - Answer should be a single VERIFIABLE value in diverse modalities and formats + - User concept: user ID, user name, display name, first name, last name, email address, phone number + - Channel concept: channel ID, channel name, channel topic + - Message concept: message ID, message string, timestamp, month, day, year + +6. **Answers must NOT be complex structures** + - Not a list of values + - Not a complex object + - Not a list of IDs or strings + - Not natural language text + - UNLESS the answer can be straightforwardly verified using DIRECT STRING COMPARISON + - And can be realistically reproduced + - It should be unlikely that an LLM would return the same list in any other order or format + +## Evaluation Process + +### Step 1: Documentation Inspection + +Read the documentation of the target API to understand: + +- Available endpoints and functionality +- If ambiguity exists, fetch additional information from the web +- Parallelize this step AS MUCH AS POSSIBLE +- Ensure each subagent is ONLY examining documentation from the file system or on the web + +### Step 2: Tool Inspection + +List the tools available in the MCP server: + +- Inspect the MCP server directly +- Understand input/output schemas, docstrings, and descriptions +- WITHOUT calling the tools themselves at this stage + +### Step 3: Developing Understanding + +Repeat steps 1 & 2 until you have a good understanding: + +- Iterate multiple times +- Think about the kinds of tasks you want to create +- Refine your understanding +- At NO stage should you READ the code of the MCP server implementation itself +- Use your intuition and understanding to create reasonable, realistic, but VERY challenging tasks + +### Step 4: Read-Only Content Inspection + +After understanding the API and tools, USE the MCP server tools: + +- Inspect content using READ-ONLY and NON-DESTRUCTIVE operations ONLY +- Goal: identify specific content (e.g., users, channels, messages, projects, tasks) for creating realistic questions +- Should NOT call any tools that modify state +- Will NOT read the code of the MCP server implementation itself +- Parallelize this step with individual sub-agents pursuing independent explorations +- Ensure each subagent is only performing READ-ONLY, NON-DESTRUCTIVE, and IDEMPOTENT operations +- BE CAREFUL: SOME TOOLS may return LOTS OF DATA which would cause you to run out of CONTEXT +- Make INCREMENTAL, SMALL, AND TARGETED tool calls for exploration +- In all tool call requests, use the `limit` parameter to limit results (<10) +- Use pagination + +### Step 5: Task Generation + +After inspecting the content, create 10 human-readable questions: + +- An LLM should be able to answer these with the MCP server +- Follow all question and answer guidelines above + +## Output Format + +Each QA pair consists of a question and an answer. The output should be an XML file with this structure: + +```xml +<evaluation> + <qa_pair> + <question>Find the project created in Q2 2024 with the highest number of completed tasks. What is the project name?</question> + <answer>Website Redesign</answer> + </qa_pair> + <qa_pair> + <question>Search for issues labeled as "bug" that were closed in March 2024. Which user closed the most issues? Provide their username.</question> + <answer>sarah_dev</answer> + </qa_pair> + <qa_pair> + <question>Look for pull requests that modified files in the /api directory and were merged between January 1 and January 31, 2024. How many different contributors worked on these PRs?</question> + <answer>7</answer> + </qa_pair> + <qa_pair> + <question>Find the repository with the most stars that was created before 2023. What is the repository name?</question> + <answer>data-pipeline</answer> + </qa_pair> +</evaluation> +``` + +## Evaluation Examples + +### Good Questions + +**Example 1: Multi-hop question requiring deep exploration (GitHub MCP)** + +```xml +<qa_pair> + <question>Find the repository that was archived in Q3 2023 and had previously been the most forked project in the organization. What was the primary programming language used in that repository?</question> + <answer>Python</answer> +</qa_pair> +``` + +This question is good because: + +- Requires multiple searches to find archived repositories +- Needs to identify which had the most forks before archival +- Requires examining repository details for the language +- Answer is a simple, verifiable value +- Based on historical (closed) data that won't change + +**Example 2: Requires understanding context without keyword matching (Project Management MCP)** + +```xml +<qa_pair> + <question>Locate the initiative focused on improving customer onboarding that was completed in late 2023. The project lead created a retrospective document after completion. What was the lead's role title at that time?</question> + <answer>Product Manager</answer> +</qa_pair> +``` + +This question is good because: + +- Doesn't use specific project name ("initiative focused on improving customer onboarding") +- Requires finding completed projects from specific timeframe +- Needs to identify the project lead and their role +- Requires understanding context from retrospective documents +- Answer is human-readable and stable +- Based on completed work (won't change) + +**Example 3: Complex aggregation requiring multiple steps (Issue Tracker MCP)** + +```xml +<qa_pair> + <question>Among all bugs reported in January 2024 that were marked as critical priority, which assignee resolved the highest percentage of their assigned bugs within 48 hours? Provide the assignee's username.</question> + <answer>alex_eng</answer> +</qa_pair> +``` + +This question is good because: + +- Requires filtering bugs by date, priority, and status +- Needs to group by assignee and calculate resolution rates +- Requires understanding timestamps to determine 48-hour windows +- Tests pagination (potentially many bugs to process) +- Answer is a single username +- Based on historical data from specific time period + +**Example 4: Requires synthesis across multiple data types (CRM MCP)** + +```xml +<qa_pair> + <question>Find the account that upgraded from the Starter to Enterprise plan in Q4 2023 and had the highest annual contract value. What industry does this account operate in?</question> + <answer>Healthcare</answer> +</qa_pair> +``` + +This question is good because: + +- Requires understanding subscription tier changes +- Needs to identify upgrade events in specific timeframe +- Requires comparing contract values +- Must access account industry information +- Answer is simple and verifiable +- Based on completed historical transactions + +### Poor Questions + +**Example 1: Answer changes over time** + +```xml +<qa_pair> + <question>How many open issues are currently assigned to the engineering team?</question> + <answer>47</answer> +</qa_pair> +``` + +This question is poor because: + +- The answer will change as issues are created, closed, or reassigned +- Not based on stable/stationary data +- Relies on "current state" which is dynamic + +**Example 2: Too easy with keyword search** + +```xml +<qa_pair> + <question>Find the pull request with title "Add authentication feature" and tell me who created it.</question> + <answer>developer123</answer> +</qa_pair> +``` + +This question is poor because: + +- Can be solved with a straightforward keyword search for exact title +- Doesn't require deep exploration or understanding +- No synthesis or analysis needed + +**Example 3: Ambiguous answer format** + +```xml +<qa_pair> + <question>List all the repositories that have Python as their primary language.</question> + <answer>repo1, repo2, repo3, data-pipeline, ml-tools</answer> +</qa_pair> +``` + +This question is poor because: + +- Answer is a list that could be returned in any order +- Difficult to verify with direct string comparison +- LLM might format differently (JSON array, comma-separated, newline-separated) +- Better to ask for a specific aggregate (count) or superlative (most stars) + +## Verification Process + +After creating evaluations: + +1. **Examine the XML file** to understand the schema +2. **Load each task instruction** and in parallel using the MCP server and tools, identify the correct answer by attempting to solve the task YOURSELF +3. **Flag any operations** that require WRITE or DESTRUCTIVE operations +4. **Accumulate all CORRECT answers** and replace any incorrect answers in the document +5. **Remove any `<qa_pair>`** that require WRITE or DESTRUCTIVE operations + +Remember to parallelize solving tasks to avoid running out of context, then accumulate all answers and make changes to the file at the end. + +## Tips for Creating Quality Evaluations + +1. **Think Hard and Plan Ahead** before generating tasks +2. **Parallelize Where Opportunity Arises** to speed up the process and manage context +3. **Focus on Realistic Use Cases** that humans would actually want to accomplish +4. **Create Challenging Questions** that test the limits of the MCP server's capabilities +5. **Ensure Stability** by using historical data and closed concepts +6. **Verify Answers** by solving the questions yourself using the MCP server tools +7. **Iterate and Refine** based on what you learn during the process + +--- + +# Running Evaluations + +After creating your evaluation file, you can use the provided evaluation harness to test your MCP server. + +## Setup + +1. **Install Dependencies** + + ```bash + pip install -r scripts/requirements.txt + ``` + + Or install manually: + + ```bash + pip install anthropic mcp + ``` + +2. **Set API Key** + + ```bash + export ANTHROPIC_API_KEY=your_api_key_here + ``` + +## Evaluation File Format + +Evaluation files use XML format with `<qa_pair>` elements: + +```xml +<evaluation> + <qa_pair> + <question>Find the project created in Q2 2024 with the highest number of completed tasks. What is the project name?</question> + <answer>Website Redesign</answer> + </qa_pair> + <qa_pair> + <question>Search for issues labeled as "bug" that were closed in March 2024. Which user closed the most issues? Provide their username.</question> + <answer>sarah_dev</answer> + </qa_pair> +</evaluation> +``` + +## Running Evaluations + +The evaluation script (`scripts/evaluation.py`) supports three transport types: + +**Important:** + +- **stdio transport**: The evaluation script automatically launches and manages the MCP server process for you. Do not run the server manually. +- **sse/http transports**: You must start the MCP server separately before running the evaluation. The script connects to the already-running server at the specified URL. + +### 1. Local STDIO Server + +For locally-run MCP servers (script launches the server automatically): + +```bash +python scripts/evaluation.py \ + -t stdio \ + -c python \ + -a my_mcp_server.py \ + evaluation.xml +``` + +With environment variables: + +```bash +python scripts/evaluation.py \ + -t stdio \ + -c python \ + -a my_mcp_server.py \ + -e API_KEY=abc123 \ + -e DEBUG=true \ + evaluation.xml +``` + +### 2. Server-Sent Events (SSE) + +For SSE-based MCP servers (you must start the server first): + +```bash +python scripts/evaluation.py \ + -t sse \ + -u https://example.com/mcp \ + -H "Authorization: Bearer token123" \ + -H "X-Custom-Header: value" \ + evaluation.xml +``` + +### 3. HTTP (Streamable HTTP) + +For HTTP-based MCP servers (you must start the server first): + +```bash +python scripts/evaluation.py \ + -t http \ + -u https://example.com/mcp \ + -H "Authorization: Bearer token123" \ + evaluation.xml +``` + +## Command-Line Options + +``` +usage: evaluation.py [-h] [-t {stdio,sse,http}] [-m MODEL] [-c COMMAND] + [-a ARGS [ARGS ...]] [-e ENV [ENV ...]] [-u URL] + [-H HEADERS [HEADERS ...]] [-o OUTPUT] + eval_file + +positional arguments: + eval_file Path to evaluation XML file + +optional arguments: + -h, --help Show help message + -t, --transport Transport type: stdio, sse, or http (default: stdio) + -m, --model Claude model to use (default: claude-3-7-sonnet-20250219) + -o, --output Output file for report (default: print to stdout) + +stdio options: + -c, --command Command to run MCP server (e.g., python, node) + -a, --args Arguments for the command (e.g., server.py) + -e, --env Environment variables in KEY=VALUE format + +sse/http options: + -u, --url MCP server URL + -H, --header HTTP headers in 'Key: Value' format +``` + +## Output + +The evaluation script generates a detailed report including: + +- **Summary Statistics**: + - Accuracy (correct/total) + - Average task duration + - Average tool calls per task + - Total tool calls + +- **Per-Task Results**: + - Prompt and expected response + - Actual response from the agent + - Whether the answer was correct (✅/❌) + - Duration and tool call details + - Agent's summary of its approach + - Agent's feedback on the tools + +### Save Report to File + +```bash +python scripts/evaluation.py \ + -t stdio \ + -c python \ + -a my_server.py \ + -o evaluation_report.md \ + evaluation.xml +``` + +## Complete Example Workflow + +Here's a complete example of creating and running an evaluation: + +1. **Create your evaluation file** (`my_evaluation.xml`): + +```xml +<evaluation> + <qa_pair> + <question>Find the user who created the most issues in January 2024. What is their username?</question> + <answer>alice_developer</answer> + </qa_pair> + <qa_pair> + <question>Among all pull requests merged in Q1 2024, which repository had the highest number? Provide the repository name.</question> + <answer>backend-api</answer> + </qa_pair> + <qa_pair> + <question>Find the project that was completed in December 2023 and had the longest duration from start to finish. How many days did it take?</question> + <answer>127</answer> + </qa_pair> +</evaluation> +``` + +2. **Install dependencies**: + +```bash +pip install -r scripts/requirements.txt +export ANTHROPIC_API_KEY=your_api_key +``` + +3. **Run evaluation**: + +```bash +python scripts/evaluation.py \ + -t stdio \ + -c python \ + -a github_mcp_server.py \ + -e GITHUB_TOKEN=ghp_xxx \ + -o github_eval_report.md \ + my_evaluation.xml +``` + +4. **Review the report** in `github_eval_report.md` to: + - See which questions passed/failed + - Read the agent's feedback on your tools + - Identify areas for improvement + - Iterate on your MCP server design + +## Troubleshooting + +### Connection Errors + +If you get connection errors: + +- **STDIO**: Verify the command and arguments are correct +- **SSE/HTTP**: Check the URL is accessible and headers are correct +- Ensure any required API keys are set in environment variables or headers + +### Low Accuracy + +If many evaluations fail: + +- Review the agent's feedback for each task +- Check if tool descriptions are clear and comprehensive +- Verify input parameters are well-documented +- Consider whether tools return too much or too little data +- Ensure error messages are actionable + +### Timeout Issues + +If tasks are timing out: + +- Use a more capable model (e.g., `claude-3-7-sonnet-20250219`) +- Check if tools are returning too much data +- Verify pagination is working correctly +- Consider simplifying complex questions diff --git a/packages/mosaic/framework/skills/mcp-builder/reference/mcp_best_practices.md b/packages/mosaic/framework/skills/mcp-builder/reference/mcp_best_practices.md new file mode 100644 index 00000000..d686a9d9 --- /dev/null +++ b/packages/mosaic/framework/skills/mcp-builder/reference/mcp_best_practices.md @@ -0,0 +1,269 @@ +# MCP Server Best Practices + +## Quick Reference + +### Server Naming + +- **Python**: `{service}_mcp` (e.g., `slack_mcp`) +- **Node/TypeScript**: `{service}-mcp-server` (e.g., `slack-mcp-server`) + +### Tool Naming + +- Use snake_case with service prefix +- Format: `{service}_{action}_{resource}` +- Example: `slack_send_message`, `github_create_issue` + +### Response Formats + +- Support both JSON and Markdown formats +- JSON for programmatic processing +- Markdown for human readability + +### Pagination + +- Always respect `limit` parameter +- Return `has_more`, `next_offset`, `total_count` +- Default to 20-50 items + +### Transport + +- **Streamable HTTP**: For remote servers, multi-client scenarios +- **stdio**: For local integrations, command-line tools +- Avoid SSE (deprecated in favor of streamable HTTP) + +--- + +## Server Naming Conventions + +Follow these standardized naming patterns: + +**Python**: Use format `{service}_mcp` (lowercase with underscores) + +- Examples: `slack_mcp`, `github_mcp`, `jira_mcp` + +**Node/TypeScript**: Use format `{service}-mcp-server` (lowercase with hyphens) + +- Examples: `slack-mcp-server`, `github-mcp-server`, `jira-mcp-server` + +The name should be general, descriptive of the service being integrated, easy to infer from the task description, and without version numbers. + +--- + +## Tool Naming and Design + +### Tool Naming + +1. **Use snake_case**: `search_users`, `create_project`, `get_channel_info` +2. **Include service prefix**: Anticipate that your MCP server may be used alongside other MCP servers + - Use `slack_send_message` instead of just `send_message` + - Use `github_create_issue` instead of just `create_issue` +3. **Be action-oriented**: Start with verbs (get, list, search, create, etc.) +4. **Be specific**: Avoid generic names that could conflict with other servers + +### Tool Design + +- Tool descriptions must narrowly and unambiguously describe functionality +- Descriptions must precisely match actual functionality +- Provide tool annotations (readOnlyHint, destructiveHint, idempotentHint, openWorldHint) +- Keep tool operations focused and atomic + +--- + +## Response Formats + +All tools that return data should support multiple formats: + +### JSON Format (`response_format="json"`) + +- Machine-readable structured data +- Include all available fields and metadata +- Consistent field names and types +- Use for programmatic processing + +### Markdown Format (`response_format="markdown"`, typically default) + +- Human-readable formatted text +- Use headers, lists, and formatting for clarity +- Convert timestamps to human-readable format +- Show display names with IDs in parentheses +- Omit verbose metadata + +--- + +## Pagination + +For tools that list resources: + +- **Always respect the `limit` parameter** +- **Implement pagination**: Use `offset` or cursor-based pagination +- **Return pagination metadata**: Include `has_more`, `next_offset`/`next_cursor`, `total_count` +- **Never load all results into memory**: Especially important for large datasets +- **Default to reasonable limits**: 20-50 items is typical + +Example pagination response: + +```json +{ + "total": 150, + "count": 20, + "offset": 0, + "items": [...], + "has_more": true, + "next_offset": 20 +} +``` + +--- + +## Transport Options + +### Streamable HTTP + +**Best for**: Remote servers, web services, multi-client scenarios + +**Characteristics**: + +- Bidirectional communication over HTTP +- Supports multiple simultaneous clients +- Can be deployed as a web service +- Enables server-to-client notifications + +**Use when**: + +- Serving multiple clients simultaneously +- Deploying as a cloud service +- Integration with web applications + +### stdio + +**Best for**: Local integrations, command-line tools + +**Characteristics**: + +- Standard input/output stream communication +- Simple setup, no network configuration needed +- Runs as a subprocess of the client + +**Use when**: + +- Building tools for local development environments +- Integrating with desktop applications +- Single-user, single-session scenarios + +**Note**: stdio servers should NOT log to stdout (use stderr for logging) + +### Transport Selection + +| Criterion | stdio | Streamable HTTP | +| -------------- | ------ | --------------- | +| **Deployment** | Local | Remote | +| **Clients** | Single | Multiple | +| **Complexity** | Low | Medium | +| **Real-time** | No | Yes | + +--- + +## Security Best Practices + +### Authentication and Authorization + +**OAuth 2.1**: + +- Use secure OAuth 2.1 with certificates from recognized authorities +- Validate access tokens before processing requests +- Only accept tokens specifically intended for your server + +**API Keys**: + +- Store API keys in environment variables, never in code +- Validate keys on server startup +- Provide clear error messages when authentication fails + +### Input Validation + +- Sanitize file paths to prevent directory traversal +- Validate URLs and external identifiers +- Check parameter sizes and ranges +- Prevent command injection in system calls +- Use schema validation (Pydantic/Zod) for all inputs + +### Error Handling + +- Don't expose internal errors to clients +- Log security-relevant errors server-side +- Provide helpful but not revealing error messages +- Clean up resources after errors + +### DNS Rebinding Protection + +For streamable HTTP servers running locally: + +- Enable DNS rebinding protection +- Validate the `Origin` header on all incoming connections +- Bind to `127.0.0.1` rather than `0.0.0.0` + +--- + +## Tool Annotations + +Provide annotations to help clients understand tool behavior: + +| Annotation | Type | Default | Description | +| ----------------- | ------- | ------- | ------------------------------------------------------- | +| `readOnlyHint` | boolean | false | Tool does not modify its environment | +| `destructiveHint` | boolean | true | Tool may perform destructive updates | +| `idempotentHint` | boolean | false | Repeated calls with same args have no additional effect | +| `openWorldHint` | boolean | true | Tool interacts with external entities | + +**Important**: Annotations are hints, not security guarantees. Clients should not make security-critical decisions based solely on annotations. + +--- + +## Error Handling + +- Use standard JSON-RPC error codes +- Report tool errors within result objects (not protocol-level errors) +- Provide helpful, specific error messages with suggested next steps +- Don't expose internal implementation details +- Clean up resources properly on errors + +Example error handling: + +```typescript +try { + const result = performOperation(); + return { content: [{ type: 'text', text: result }] }; +} catch (error) { + return { + isError: true, + content: [ + { + type: 'text', + text: `Error: ${error.message}. Try using filter='active_only' to reduce results.`, + }, + ], + }; +} +``` + +--- + +## Testing Requirements + +Comprehensive testing should cover: + +- **Functional testing**: Verify correct execution with valid/invalid inputs +- **Integration testing**: Test interaction with external systems +- **Security testing**: Validate auth, input sanitization, rate limiting +- **Performance testing**: Check behavior under load, timeouts +- **Error handling**: Ensure proper error reporting and cleanup + +--- + +## Documentation Requirements + +- Provide clear documentation of all tools and capabilities +- Include working examples (at least 3 per major feature) +- Document security considerations +- Specify required permissions and access levels +- Document rate limits and performance characteristics diff --git a/packages/mosaic/framework/skills/mcp-builder/reference/node_mcp_server.md b/packages/mosaic/framework/skills/mcp-builder/reference/node_mcp_server.md new file mode 100644 index 00000000..06934406 --- /dev/null +++ b/packages/mosaic/framework/skills/mcp-builder/reference/node_mcp_server.md @@ -0,0 +1,980 @@ +# Node/TypeScript MCP Server Implementation Guide + +## Overview + +This document provides Node/TypeScript-specific best practices and examples for implementing MCP servers using the MCP TypeScript SDK. It covers project structure, server setup, tool registration patterns, input validation with Zod, error handling, and complete working examples. + +--- + +## Quick Reference + +### Key Imports + +```typescript +import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; +import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js'; +import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js'; +import express from 'express'; +import { z } from 'zod'; +``` + +### Server Initialization + +```typescript +const server = new McpServer({ + name: 'service-mcp-server', + version: '1.0.0', +}); +``` + +### Tool Registration Pattern + +```typescript +server.registerTool( + 'tool_name', + { + title: 'Tool Display Name', + description: 'What the tool does', + inputSchema: { param: z.string() }, + outputSchema: { result: z.string() }, + }, + async ({ param }) => { + const output = { result: `Processed: ${param}` }; + return { + content: [{ type: 'text', text: JSON.stringify(output) }], + structuredContent: output, // Modern pattern for structured data + }; + }, +); +``` + +--- + +## MCP TypeScript SDK + +The official MCP TypeScript SDK provides: + +- `McpServer` class for server initialization +- `registerTool` method for tool registration +- Zod schema integration for runtime input validation +- Type-safe tool handler implementations + +**IMPORTANT - Use Modern APIs Only:** + +- **DO use**: `server.registerTool()`, `server.registerResource()`, `server.registerPrompt()` +- **DO NOT use**: Old deprecated APIs such as `server.tool()`, `server.setRequestHandler(ListToolsRequestSchema, ...)`, or manual handler registration +- The `register*` methods provide better type safety, automatic schema handling, and are the recommended approach + +See the MCP SDK documentation in the references for complete details. + +## Server Naming Convention + +Node/TypeScript MCP servers must follow this naming pattern: + +- **Format**: `{service}-mcp-server` (lowercase with hyphens) +- **Examples**: `github-mcp-server`, `jira-mcp-server`, `stripe-mcp-server` + +The name should be: + +- General (not tied to specific features) +- Descriptive of the service/API being integrated +- Easy to infer from the task description +- Without version numbers or dates + +## Project Structure + +Create the following structure for Node/TypeScript MCP servers: + +``` +{service}-mcp-server/ +├── package.json +├── tsconfig.json +├── README.md +├── src/ +│ ├── index.ts # Main entry point with McpServer initialization +│ ├── types.ts # TypeScript type definitions and interfaces +│ ├── tools/ # Tool implementations (one file per domain) +│ ├── services/ # API clients and shared utilities +│ ├── schemas/ # Zod validation schemas +│ └── constants.ts # Shared constants (API_URL, CHARACTER_LIMIT, etc.) +└── dist/ # Built JavaScript files (entry point: dist/index.js) +``` + +## Tool Implementation + +### Tool Naming + +Use snake_case for tool names (e.g., "search_users", "create_project", "get_channel_info") with clear, action-oriented names. + +**Avoid Naming Conflicts**: Include the service context to prevent overlaps: + +- Use "slack_send_message" instead of just "send_message" +- Use "github_create_issue" instead of just "create_issue" +- Use "asana_list_tasks" instead of just "list_tasks" + +### Tool Structure + +Tools are registered using the `registerTool` method with the following requirements: + +- Use Zod schemas for runtime input validation and type safety +- The `description` field must be explicitly provided - JSDoc comments are NOT automatically extracted +- Explicitly provide `title`, `description`, `inputSchema`, and `annotations` +- The `inputSchema` must be a Zod schema object (not a JSON schema) +- Type all parameters and return values explicitly + +```typescript +import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; +import { z } from 'zod'; + +const server = new McpServer({ + name: 'example-mcp', + version: '1.0.0', +}); + +// Zod schema for input validation +const UserSearchInputSchema = z + .object({ + query: z + .string() + .min(2, 'Query must be at least 2 characters') + .max(200, 'Query must not exceed 200 characters') + .describe('Search string to match against names/emails'), + limit: z.number().int().min(1).max(100).default(20).describe('Maximum results to return'), + offset: z.number().int().min(0).default(0).describe('Number of results to skip for pagination'), + response_format: z + .nativeEnum(ResponseFormat) + .default(ResponseFormat.MARKDOWN) + .describe("Output format: 'markdown' for human-readable or 'json' for machine-readable"), + }) + .strict(); + +// Type definition from Zod schema +type UserSearchInput = z.infer<typeof UserSearchInputSchema>; + +server.registerTool( + 'example_search_users', + { + title: 'Search Example Users', + description: `Search for users in the Example system by name, email, or team. + +This tool searches across all user profiles in the Example platform, supporting partial matches and various search filters. It does NOT create or modify users, only searches existing ones. + +Args: + - query (string): Search string to match against names/emails + - limit (number): Maximum results to return, between 1-100 (default: 20) + - offset (number): Number of results to skip for pagination (default: 0) + - response_format ('markdown' | 'json'): Output format (default: 'markdown') + +Returns: + For JSON format: Structured data with schema: + { + "total": number, // Total number of matches found + "count": number, // Number of results in this response + "offset": number, // Current pagination offset + "users": [ + { + "id": string, // User ID (e.g., "U123456789") + "name": string, // Full name (e.g., "John Doe") + "email": string, // Email address + "team": string, // Team name (optional) + "active": boolean // Whether user is active + } + ], + "has_more": boolean, // Whether more results are available + "next_offset": number // Offset for next page (if has_more is true) + } + +Examples: + - Use when: "Find all marketing team members" -> params with query="team:marketing" + - Use when: "Search for John's account" -> params with query="john" + - Don't use when: You need to create a user (use example_create_user instead) + +Error Handling: + - Returns "Error: Rate limit exceeded" if too many requests (429 status) + - Returns "No users found matching '<query>'" if search returns empty`, + inputSchema: UserSearchInputSchema, + annotations: { + readOnlyHint: true, + destructiveHint: false, + idempotentHint: true, + openWorldHint: true, + }, + }, + async (params: UserSearchInput) => { + try { + // Input validation is handled by Zod schema + // Make API request using validated parameters + const data = await makeApiRequest<any>('users/search', 'GET', undefined, { + q: params.query, + limit: params.limit, + offset: params.offset, + }); + + const users = data.users || []; + const total = data.total || 0; + + if (!users.length) { + return { + content: [ + { + type: 'text', + text: `No users found matching '${params.query}'`, + }, + ], + }; + } + + // Prepare structured output + const output = { + total, + count: users.length, + offset: params.offset, + users: users.map((user: any) => ({ + id: user.id, + name: user.name, + email: user.email, + ...(user.team ? { team: user.team } : {}), + active: user.active ?? true, + })), + has_more: total > params.offset + users.length, + ...(total > params.offset + users.length + ? { + next_offset: params.offset + users.length, + } + : {}), + }; + + // Format text representation based on requested format + let textContent: string; + if (params.response_format === ResponseFormat.MARKDOWN) { + const lines = [ + `# User Search Results: '${params.query}'`, + '', + `Found ${total} users (showing ${users.length})`, + '', + ]; + for (const user of users) { + lines.push(`## ${user.name} (${user.id})`); + lines.push(`- **Email**: ${user.email}`); + if (user.team) lines.push(`- **Team**: ${user.team}`); + lines.push(''); + } + textContent = lines.join('\n'); + } else { + textContent = JSON.stringify(output, null, 2); + } + + return { + content: [{ type: 'text', text: textContent }], + structuredContent: output, // Modern pattern for structured data + }; + } catch (error) { + return { + content: [ + { + type: 'text', + text: handleApiError(error), + }, + ], + }; + } + }, +); +``` + +## Zod Schemas for Input Validation + +Zod provides runtime type validation: + +```typescript +import { z } from 'zod'; + +// Basic schema with validation +const CreateUserSchema = z + .object({ + name: z.string().min(1, 'Name is required').max(100, 'Name must not exceed 100 characters'), + email: z.string().email('Invalid email format'), + age: z + .number() + .int('Age must be a whole number') + .min(0, 'Age cannot be negative') + .max(150, 'Age cannot be greater than 150'), + }) + .strict(); // Use .strict() to forbid extra fields + +// Enums +enum ResponseFormat { + MARKDOWN = 'markdown', + JSON = 'json', +} + +const SearchSchema = z.object({ + response_format: z + .nativeEnum(ResponseFormat) + .default(ResponseFormat.MARKDOWN) + .describe('Output format'), +}); + +// Optional fields with defaults +const PaginationSchema = z.object({ + limit: z.number().int().min(1).max(100).default(20).describe('Maximum results to return'), + offset: z.number().int().min(0).default(0).describe('Number of results to skip'), +}); +``` + +## Response Format Options + +Support multiple output formats for flexibility: + +```typescript +enum ResponseFormat { + MARKDOWN = 'markdown', + JSON = 'json', +} + +const inputSchema = z.object({ + query: z.string(), + response_format: z + .nativeEnum(ResponseFormat) + .default(ResponseFormat.MARKDOWN) + .describe("Output format: 'markdown' for human-readable or 'json' for machine-readable"), +}); +``` + +**Markdown format**: + +- Use headers, lists, and formatting for clarity +- Convert timestamps to human-readable format +- Show display names with IDs in parentheses +- Omit verbose metadata +- Group related information logically + +**JSON format**: + +- Return complete, structured data suitable for programmatic processing +- Include all available fields and metadata +- Use consistent field names and types + +## Pagination Implementation + +For tools that list resources: + +```typescript +const ListSchema = z.object({ + limit: z.number().int().min(1).max(100).default(20), + offset: z.number().int().min(0).default(0), +}); + +async function listItems(params: z.infer<typeof ListSchema>) { + const data = await apiRequest(params.limit, params.offset); + + const response = { + total: data.total, + count: data.items.length, + offset: params.offset, + items: data.items, + has_more: data.total > params.offset + data.items.length, + next_offset: + data.total > params.offset + data.items.length + ? params.offset + data.items.length + : undefined, + }; + + return JSON.stringify(response, null, 2); +} +``` + +## Character Limits and Truncation + +Add a CHARACTER_LIMIT constant to prevent overwhelming responses: + +```typescript +// At module level in constants.ts +export const CHARACTER_LIMIT = 25000; // Maximum response size in characters + +async function searchTool(params: SearchInput) { + let result = generateResponse(data); + + // Check character limit and truncate if needed + if (result.length > CHARACTER_LIMIT) { + const truncatedData = data.slice(0, Math.max(1, data.length / 2)); + response.data = truncatedData; + response.truncated = true; + response.truncation_message = + `Response truncated from ${data.length} to ${truncatedData.length} items. ` + + `Use 'offset' parameter or add filters to see more results.`; + result = JSON.stringify(response, null, 2); + } + + return result; +} +``` + +## Error Handling + +Provide clear, actionable error messages: + +```typescript +import axios, { AxiosError } from 'axios'; + +function handleApiError(error: unknown): string { + if (error instanceof AxiosError) { + if (error.response) { + switch (error.response.status) { + case 404: + return 'Error: Resource not found. Please check the ID is correct.'; + case 403: + return "Error: Permission denied. You don't have access to this resource."; + case 429: + return 'Error: Rate limit exceeded. Please wait before making more requests.'; + default: + return `Error: API request failed with status ${error.response.status}`; + } + } else if (error.code === 'ECONNABORTED') { + return 'Error: Request timed out. Please try again.'; + } + } + return `Error: Unexpected error occurred: ${error instanceof Error ? error.message : String(error)}`; +} +``` + +## Shared Utilities + +Extract common functionality into reusable functions: + +```typescript +// Shared API request function +async function makeApiRequest<T>( + endpoint: string, + method: 'GET' | 'POST' | 'PUT' | 'DELETE' = 'GET', + data?: any, + params?: any, +): Promise<T> { + try { + const response = await axios({ + method, + url: `${API_BASE_URL}/${endpoint}`, + data, + params, + timeout: 30000, + headers: { + 'Content-Type': 'application/json', + Accept: 'application/json', + }, + }); + return response.data; + } catch (error) { + throw error; + } +} +``` + +## Async/Await Best Practices + +Always use async/await for network requests and I/O operations: + +```typescript +// Good: Async network request +async function fetchData(resourceId: string): Promise<ResourceData> { + const response = await axios.get(`${API_URL}/resource/${resourceId}`); + return response.data; +} + +// Bad: Promise chains +function fetchData(resourceId: string): Promise<ResourceData> { + return axios.get(`${API_URL}/resource/${resourceId}`).then((response) => response.data); // Harder to read and maintain +} +``` + +## TypeScript Best Practices + +1. **Use Strict TypeScript**: Enable strict mode in tsconfig.json +2. **Define Interfaces**: Create clear interface definitions for all data structures +3. **Avoid `any`**: Use proper types or `unknown` instead of `any` +4. **Zod for Runtime Validation**: Use Zod schemas to validate external data +5. **Type Guards**: Create type guard functions for complex type checking +6. **Error Handling**: Always use try-catch with proper error type checking +7. **Null Safety**: Use optional chaining (`?.`) and nullish coalescing (`??`) + +```typescript +// Good: Type-safe with Zod and interfaces +interface UserResponse { + id: string; + name: string; + email: string; + team?: string; + active: boolean; +} + +const UserSchema = z.object({ + id: z.string(), + name: z.string(), + email: z.string().email(), + team: z.string().optional(), + active: z.boolean(), +}); + +type User = z.infer<typeof UserSchema>; + +async function getUser(id: string): Promise<User> { + const data = await apiCall(`/users/${id}`); + return UserSchema.parse(data); // Runtime validation +} + +// Bad: Using any +async function getUser(id: string): Promise<any> { + return await apiCall(`/users/${id}`); // No type safety +} +``` + +## Package Configuration + +### package.json + +```json +{ + "name": "{service}-mcp-server", + "version": "1.0.0", + "description": "MCP server for {Service} API integration", + "type": "module", + "main": "dist/index.js", + "scripts": { + "start": "node dist/index.js", + "dev": "tsx watch src/index.ts", + "build": "tsc", + "clean": "rm -rf dist" + }, + "engines": { + "node": ">=18" + }, + "dependencies": { + "@modelcontextprotocol/sdk": "^1.6.1", + "axios": "^1.7.9", + "zod": "^3.23.8" + }, + "devDependencies": { + "@types/node": "^22.10.0", + "tsx": "^4.19.2", + "typescript": "^5.7.2" + } +} +``` + +### tsconfig.json + +```json +{ + "compilerOptions": { + "target": "ES2022", + "module": "Node16", + "moduleResolution": "Node16", + "lib": ["ES2022"], + "outDir": "./dist", + "rootDir": "./src", + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "forceConsistentCasingInFileNames": true, + "declaration": true, + "declarationMap": true, + "sourceMap": true, + "allowSyntheticDefaultImports": true + }, + "include": ["src/**/*"], + "exclude": ["node_modules", "dist"] +} +``` + +## Complete Example + +```typescript +#!/usr/bin/env node +/** + * MCP Server for Example Service. + * + * This server provides tools to interact with Example API, including user search, + * project management, and data export capabilities. + */ + +import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; +import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js'; +import { z } from 'zod'; +import axios, { AxiosError } from 'axios'; + +// Constants +const API_BASE_URL = 'https://api.example.com/v1'; +const CHARACTER_LIMIT = 25000; + +// Enums +enum ResponseFormat { + MARKDOWN = 'markdown', + JSON = 'json', +} + +// Zod schemas +const UserSearchInputSchema = z + .object({ + query: z + .string() + .min(2, 'Query must be at least 2 characters') + .max(200, 'Query must not exceed 200 characters') + .describe('Search string to match against names/emails'), + limit: z.number().int().min(1).max(100).default(20).describe('Maximum results to return'), + offset: z.number().int().min(0).default(0).describe('Number of results to skip for pagination'), + response_format: z + .nativeEnum(ResponseFormat) + .default(ResponseFormat.MARKDOWN) + .describe("Output format: 'markdown' for human-readable or 'json' for machine-readable"), + }) + .strict(); + +type UserSearchInput = z.infer<typeof UserSearchInputSchema>; + +// Shared utility functions +async function makeApiRequest<T>( + endpoint: string, + method: 'GET' | 'POST' | 'PUT' | 'DELETE' = 'GET', + data?: any, + params?: any, +): Promise<T> { + try { + const response = await axios({ + method, + url: `${API_BASE_URL}/${endpoint}`, + data, + params, + timeout: 30000, + headers: { + 'Content-Type': 'application/json', + Accept: 'application/json', + }, + }); + return response.data; + } catch (error) { + throw error; + } +} + +function handleApiError(error: unknown): string { + if (error instanceof AxiosError) { + if (error.response) { + switch (error.response.status) { + case 404: + return 'Error: Resource not found. Please check the ID is correct.'; + case 403: + return "Error: Permission denied. You don't have access to this resource."; + case 429: + return 'Error: Rate limit exceeded. Please wait before making more requests.'; + default: + return `Error: API request failed with status ${error.response.status}`; + } + } else if (error.code === 'ECONNABORTED') { + return 'Error: Request timed out. Please try again.'; + } + } + return `Error: Unexpected error occurred: ${error instanceof Error ? error.message : String(error)}`; +} + +// Create MCP server instance +const server = new McpServer({ + name: 'example-mcp', + version: '1.0.0', +}); + +// Register tools +server.registerTool( + 'example_search_users', + { + title: 'Search Example Users', + description: `[Full description as shown above]`, + inputSchema: UserSearchInputSchema, + annotations: { + readOnlyHint: true, + destructiveHint: false, + idempotentHint: true, + openWorldHint: true, + }, + }, + async (params: UserSearchInput) => { + // Implementation as shown above + }, +); + +// Main function +// For stdio (local): +async function runStdio() { + if (!process.env.EXAMPLE_API_KEY) { + console.error('ERROR: EXAMPLE_API_KEY environment variable is required'); + process.exit(1); + } + + const transport = new StdioServerTransport(); + await server.connect(transport); + console.error('MCP server running via stdio'); +} + +// For streamable HTTP (remote): +async function runHTTP() { + if (!process.env.EXAMPLE_API_KEY) { + console.error('ERROR: EXAMPLE_API_KEY environment variable is required'); + process.exit(1); + } + + const app = express(); + app.use(express.json()); + + app.post('/mcp', async (req, res) => { + const transport = new StreamableHTTPServerTransport({ + sessionIdGenerator: undefined, + enableJsonResponse: true, + }); + res.on('close', () => transport.close()); + await server.connect(transport); + await transport.handleRequest(req, res, req.body); + }); + + const port = parseInt(process.env.PORT || '3000'); + app.listen(port, () => { + console.error(`MCP server running on http://localhost:${port}/mcp`); + }); +} + +// Choose transport based on environment +const transport = process.env.TRANSPORT || 'stdio'; +if (transport === 'http') { + runHTTP().catch((error) => { + console.error('Server error:', error); + process.exit(1); + }); +} else { + runStdio().catch((error) => { + console.error('Server error:', error); + process.exit(1); + }); +} +``` + +--- + +## Advanced MCP Features + +### Resource Registration + +Expose data as resources for efficient, URI-based access: + +```typescript +import { ResourceTemplate } from '@modelcontextprotocol/sdk/types.js'; + +// Register a resource with URI template +server.registerResource( + { + uri: 'file://documents/{name}', + name: 'Document Resource', + description: 'Access documents by name', + mimeType: 'text/plain', + }, + async (uri: string) => { + // Extract parameter from URI + const match = uri.match(/^file:\/\/documents\/(.+)$/); + if (!match) { + throw new Error('Invalid URI format'); + } + + const documentName = match[1]; + const content = await loadDocument(documentName); + + return { + contents: [ + { + uri, + mimeType: 'text/plain', + text: content, + }, + ], + }; + }, +); + +// List available resources dynamically +server.registerResourceList(async () => { + const documents = await getAvailableDocuments(); + return { + resources: documents.map((doc) => ({ + uri: `file://documents/${doc.name}`, + name: doc.name, + mimeType: 'text/plain', + description: doc.description, + })), + }; +}); +``` + +**When to use Resources vs Tools:** + +- **Resources**: For data access with simple URI-based parameters +- **Tools**: For complex operations requiring validation and business logic +- **Resources**: When data is relatively static or template-based +- **Tools**: When operations have side effects or complex workflows + +### Transport Options + +The TypeScript SDK supports two main transport mechanisms: + +#### Streamable HTTP (Recommended for Remote Servers) + +```typescript +import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js'; +import express from 'express'; + +const app = express(); +app.use(express.json()); + +app.post('/mcp', async (req, res) => { + // Create new transport for each request (stateless, prevents request ID collisions) + const transport = new StreamableHTTPServerTransport({ + sessionIdGenerator: undefined, + enableJsonResponse: true, + }); + + res.on('close', () => transport.close()); + + await server.connect(transport); + await transport.handleRequest(req, res, req.body); +}); + +app.listen(3000); +``` + +#### stdio (For Local Integrations) + +```typescript +import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js'; + +const transport = new StdioServerTransport(); +await server.connect(transport); +``` + +**Transport selection:** + +- **Streamable HTTP**: Web services, remote access, multiple clients +- **stdio**: Command-line tools, local development, subprocess integration + +### Notification Support + +Notify clients when server state changes: + +```typescript +// Notify when tools list changes +server.notification({ + method: 'notifications/tools/list_changed', +}); + +// Notify when resources change +server.notification({ + method: 'notifications/resources/list_changed', +}); +``` + +Use notifications sparingly - only when server capabilities genuinely change. + +--- + +## Code Best Practices + +### Code Composability and Reusability + +Your implementation MUST prioritize composability and code reuse: + +1. **Extract Common Functionality**: + - Create reusable helper functions for operations used across multiple tools + - Build shared API clients for HTTP requests instead of duplicating code + - Centralize error handling logic in utility functions + - Extract business logic into dedicated functions that can be composed + - Extract shared markdown or JSON field selection & formatting functionality + +2. **Avoid Duplication**: + - NEVER copy-paste similar code between tools + - If you find yourself writing similar logic twice, extract it into a function + - Common operations like pagination, filtering, field selection, and formatting should be shared + - Authentication/authorization logic should be centralized + +## Building and Running + +Always build your TypeScript code before running: + +```bash +# Build the project +npm run build + +# Run the server +npm start + +# Development with auto-reload +npm run dev +``` + +Always ensure `npm run build` completes successfully before considering the implementation complete. + +## Quality Checklist + +Before finalizing your Node/TypeScript MCP server implementation, ensure: + +### Strategic Design + +- [ ] Tools enable complete workflows, not just API endpoint wrappers +- [ ] Tool names reflect natural task subdivisions +- [ ] Response formats optimize for agent context efficiency +- [ ] Human-readable identifiers used where appropriate +- [ ] Error messages guide agents toward correct usage + +### Implementation Quality + +- [ ] FOCUSED IMPLEMENTATION: Most important and valuable tools implemented +- [ ] All tools registered using `registerTool` with complete configuration +- [ ] All tools include `title`, `description`, `inputSchema`, and `annotations` +- [ ] Annotations correctly set (readOnlyHint, destructiveHint, idempotentHint, openWorldHint) +- [ ] All tools use Zod schemas for runtime input validation with `.strict()` enforcement +- [ ] All Zod schemas have proper constraints and descriptive error messages +- [ ] All tools have comprehensive descriptions with explicit input/output types +- [ ] Descriptions include return value examples and complete schema documentation +- [ ] Error messages are clear, actionable, and educational + +### TypeScript Quality + +- [ ] TypeScript interfaces are defined for all data structures +- [ ] Strict TypeScript is enabled in tsconfig.json +- [ ] No use of `any` type - use `unknown` or proper types instead +- [ ] All async functions have explicit Promise<T> return types +- [ ] Error handling uses proper type guards (e.g., `axios.isAxiosError`, `z.ZodError`) + +### Advanced Features (where applicable) + +- [ ] Resources registered for appropriate data endpoints +- [ ] Appropriate transport configured (stdio or streamable HTTP) +- [ ] Notifications implemented for dynamic server capabilities +- [ ] Type-safe with SDK interfaces + +### Project Configuration + +- [ ] Package.json includes all necessary dependencies +- [ ] Build script produces working JavaScript in dist/ directory +- [ ] Main entry point is properly configured as dist/index.js +- [ ] Server name follows format: `{service}-mcp-server` +- [ ] tsconfig.json properly configured with strict mode + +### Code Quality + +- [ ] Pagination is properly implemented where applicable +- [ ] Large responses check CHARACTER_LIMIT constant and truncate with clear messages +- [ ] Filtering options are provided for potentially large result sets +- [ ] All network operations handle timeouts and connection errors gracefully +- [ ] Common functionality is extracted into reusable functions +- [ ] Return types are consistent across similar operations + +### Testing and Build + +- [ ] `npm run build` completes successfully without errors +- [ ] dist/index.js created and executable +- [ ] Server runs: `node dist/index.js --help` +- [ ] All imports resolve correctly +- [ ] Sample tool calls work as expected diff --git a/packages/mosaic/framework/skills/mcp-builder/reference/python_mcp_server.md b/packages/mosaic/framework/skills/mcp-builder/reference/python_mcp_server.md new file mode 100644 index 00000000..88a79e1e --- /dev/null +++ b/packages/mosaic/framework/skills/mcp-builder/reference/python_mcp_server.md @@ -0,0 +1,737 @@ +# Python MCP Server Implementation Guide + +## Overview + +This document provides Python-specific best practices and examples for implementing MCP servers using the MCP Python SDK. It covers server setup, tool registration patterns, input validation with Pydantic, error handling, and complete working examples. + +--- + +## Quick Reference + +### Key Imports + +```python +from mcp.server.fastmcp import FastMCP +from pydantic import BaseModel, Field, field_validator, ConfigDict +from typing import Optional, List, Dict, Any +from enum import Enum +import httpx +``` + +### Server Initialization + +```python +mcp = FastMCP("service_mcp") +``` + +### Tool Registration Pattern + +```python +@mcp.tool(name="tool_name", annotations={...}) +async def tool_function(params: InputModel) -> str: + # Implementation + pass +``` + +--- + +## MCP Python SDK and FastMCP + +The official MCP Python SDK provides FastMCP, a high-level framework for building MCP servers. It provides: + +- Automatic description and inputSchema generation from function signatures and docstrings +- Pydantic model integration for input validation +- Decorator-based tool registration with `@mcp.tool` + +**For complete SDK documentation, use WebFetch to load:** +`https://raw.githubusercontent.com/modelcontextprotocol/python-sdk/main/README.md` + +## Server Naming Convention + +Python MCP servers must follow this naming pattern: + +- **Format**: `{service}_mcp` (lowercase with underscores) +- **Examples**: `github_mcp`, `jira_mcp`, `stripe_mcp` + +The name should be: + +- General (not tied to specific features) +- Descriptive of the service/API being integrated +- Easy to infer from the task description +- Without version numbers or dates + +## Tool Implementation + +### Tool Naming + +Use snake_case for tool names (e.g., "search_users", "create_project", "get_channel_info") with clear, action-oriented names. + +**Avoid Naming Conflicts**: Include the service context to prevent overlaps: + +- Use "slack_send_message" instead of just "send_message" +- Use "github_create_issue" instead of just "create_issue" +- Use "asana_list_tasks" instead of just "list_tasks" + +### Tool Structure with FastMCP + +Tools are defined using the `@mcp.tool` decorator with Pydantic models for input validation: + +```python +from pydantic import BaseModel, Field, ConfigDict +from mcp.server.fastmcp import FastMCP + +# Initialize the MCP server +mcp = FastMCP("example_mcp") + +# Define Pydantic model for input validation +class ServiceToolInput(BaseModel): + '''Input model for service tool operation.''' + model_config = ConfigDict( + str_strip_whitespace=True, # Auto-strip whitespace from strings + validate_assignment=True, # Validate on assignment + extra='forbid' # Forbid extra fields + ) + + param1: str = Field(..., description="First parameter description (e.g., 'user123', 'project-abc')", min_length=1, max_length=100) + param2: Optional[int] = Field(default=None, description="Optional integer parameter with constraints", ge=0, le=1000) + tags: Optional[List[str]] = Field(default_factory=list, description="List of tags to apply", max_items=10) + +@mcp.tool( + name="service_tool_name", + annotations={ + "title": "Human-Readable Tool Title", + "readOnlyHint": True, # Tool does not modify environment + "destructiveHint": False, # Tool does not perform destructive operations + "idempotentHint": True, # Repeated calls have no additional effect + "openWorldHint": False # Tool does not interact with external entities + } +) +async def service_tool_name(params: ServiceToolInput) -> str: + '''Tool description automatically becomes the 'description' field. + + This tool performs a specific operation on the service. It validates all inputs + using the ServiceToolInput Pydantic model before processing. + + Args: + params (ServiceToolInput): Validated input parameters containing: + - param1 (str): First parameter description + - param2 (Optional[int]): Optional parameter with default + - tags (Optional[List[str]]): List of tags + + Returns: + str: JSON-formatted response containing operation results + ''' + # Implementation here + pass +``` + +## Pydantic v2 Key Features + +- Use `model_config` instead of nested `Config` class +- Use `field_validator` instead of deprecated `validator` +- Use `model_dump()` instead of deprecated `dict()` +- Validators require `@classmethod` decorator +- Type hints are required for validator methods + +```python +from pydantic import BaseModel, Field, field_validator, ConfigDict + +class CreateUserInput(BaseModel): + model_config = ConfigDict( + str_strip_whitespace=True, + validate_assignment=True + ) + + name: str = Field(..., description="User's full name", min_length=1, max_length=100) + email: str = Field(..., description="User's email address", pattern=r'^[\w\.-]+@[\w\.-]+\.\w+$') + age: int = Field(..., description="User's age", ge=0, le=150) + + @field_validator('email') + @classmethod + def validate_email(cls, v: str) -> str: + if not v.strip(): + raise ValueError("Email cannot be empty") + return v.lower() +``` + +## Response Format Options + +Support multiple output formats for flexibility: + +```python +from enum import Enum + +class ResponseFormat(str, Enum): + '''Output format for tool responses.''' + MARKDOWN = "markdown" + JSON = "json" + +class UserSearchInput(BaseModel): + query: str = Field(..., description="Search query") + response_format: ResponseFormat = Field( + default=ResponseFormat.MARKDOWN, + description="Output format: 'markdown' for human-readable or 'json' for machine-readable" + ) +``` + +**Markdown format**: + +- Use headers, lists, and formatting for clarity +- Convert timestamps to human-readable format (e.g., "2024-01-15 10:30:00 UTC" instead of epoch) +- Show display names with IDs in parentheses (e.g., "@john.doe (U123456)") +- Omit verbose metadata (e.g., show only one profile image URL, not all sizes) +- Group related information logically + +**JSON format**: + +- Return complete, structured data suitable for programmatic processing +- Include all available fields and metadata +- Use consistent field names and types + +## Pagination Implementation + +For tools that list resources: + +```python +class ListInput(BaseModel): + limit: Optional[int] = Field(default=20, description="Maximum results to return", ge=1, le=100) + offset: Optional[int] = Field(default=0, description="Number of results to skip for pagination", ge=0) + +async def list_items(params: ListInput) -> str: + # Make API request with pagination + data = await api_request(limit=params.limit, offset=params.offset) + + # Return pagination info + response = { + "total": data["total"], + "count": len(data["items"]), + "offset": params.offset, + "items": data["items"], + "has_more": data["total"] > params.offset + len(data["items"]), + "next_offset": params.offset + len(data["items"]) if data["total"] > params.offset + len(data["items"]) else None + } + return json.dumps(response, indent=2) +``` + +## Error Handling + +Provide clear, actionable error messages: + +```python +def _handle_api_error(e: Exception) -> str: + '''Consistent error formatting across all tools.''' + if isinstance(e, httpx.HTTPStatusError): + if e.response.status_code == 404: + return "Error: Resource not found. Please check the ID is correct." + elif e.response.status_code == 403: + return "Error: Permission denied. You don't have access to this resource." + elif e.response.status_code == 429: + return "Error: Rate limit exceeded. Please wait before making more requests." + return f"Error: API request failed with status {e.response.status_code}" + elif isinstance(e, httpx.TimeoutException): + return "Error: Request timed out. Please try again." + return f"Error: Unexpected error occurred: {type(e).__name__}" +``` + +## Shared Utilities + +Extract common functionality into reusable functions: + +```python +# Shared API request function +async def _make_api_request(endpoint: str, method: str = "GET", **kwargs) -> dict: + '''Reusable function for all API calls.''' + async with httpx.AsyncClient() as client: + response = await client.request( + method, + f"{API_BASE_URL}/{endpoint}", + timeout=30.0, + **kwargs + ) + response.raise_for_status() + return response.json() +``` + +## Async/Await Best Practices + +Always use async/await for network requests and I/O operations: + +```python +# Good: Async network request +async def fetch_data(resource_id: str) -> dict: + async with httpx.AsyncClient() as client: + response = await client.get(f"{API_URL}/resource/{resource_id}") + response.raise_for_status() + return response.json() + +# Bad: Synchronous request +def fetch_data(resource_id: str) -> dict: + response = requests.get(f"{API_URL}/resource/{resource_id}") # Blocks + return response.json() +``` + +## Type Hints + +Use type hints throughout: + +```python +from typing import Optional, List, Dict, Any + +async def get_user(user_id: str) -> Dict[str, Any]: + data = await fetch_user(user_id) + return {"id": data["id"], "name": data["name"]} +``` + +## Tool Docstrings + +Every tool must have comprehensive docstrings with explicit type information: + +```python +async def search_users(params: UserSearchInput) -> str: + ''' + Search for users in the Example system by name, email, or team. + + This tool searches across all user profiles in the Example platform, + supporting partial matches and various search filters. It does NOT + create or modify users, only searches existing ones. + + Args: + params (UserSearchInput): Validated input parameters containing: + - query (str): Search string to match against names/emails (e.g., "john", "@example.com", "team:marketing") + - limit (Optional[int]): Maximum results to return, between 1-100 (default: 20) + - offset (Optional[int]): Number of results to skip for pagination (default: 0) + + Returns: + str: JSON-formatted string containing search results with the following schema: + + Success response: + { + "total": int, # Total number of matches found + "count": int, # Number of results in this response + "offset": int, # Current pagination offset + "users": [ + { + "id": str, # User ID (e.g., "U123456789") + "name": str, # Full name (e.g., "John Doe") + "email": str, # Email address (e.g., "john@example.com") + "team": str # Team name (e.g., "Marketing") - optional + } + ] + } + + Error response: + "Error: <error message>" or "No users found matching '<query>'" + + Examples: + - Use when: "Find all marketing team members" -> params with query="team:marketing" + - Use when: "Search for John's account" -> params with query="john" + - Don't use when: You need to create a user (use example_create_user instead) + - Don't use when: You have a user ID and need full details (use example_get_user instead) + + Error Handling: + - Input validation errors are handled by Pydantic model + - Returns "Error: Rate limit exceeded" if too many requests (429 status) + - Returns "Error: Invalid API authentication" if API key is invalid (401 status) + - Returns formatted list of results or "No users found matching 'query'" + ''' +``` + +## Complete Example + +See below for a complete Python MCP server example: + +```python +#!/usr/bin/env python3 +''' +MCP Server for Example Service. + +This server provides tools to interact with Example API, including user search, +project management, and data export capabilities. +''' + +from typing import Optional, List, Dict, Any +from enum import Enum +import httpx +from pydantic import BaseModel, Field, field_validator, ConfigDict +from mcp.server.fastmcp import FastMCP + +# Initialize the MCP server +mcp = FastMCP("example_mcp") + +# Constants +API_BASE_URL = "https://api.example.com/v1" + +# Enums +class ResponseFormat(str, Enum): + '''Output format for tool responses.''' + MARKDOWN = "markdown" + JSON = "json" + +# Pydantic Models for Input Validation +class UserSearchInput(BaseModel): + '''Input model for user search operations.''' + model_config = ConfigDict( + str_strip_whitespace=True, + validate_assignment=True + ) + + query: str = Field(..., description="Search string to match against names/emails", min_length=2, max_length=200) + limit: Optional[int] = Field(default=20, description="Maximum results to return", ge=1, le=100) + offset: Optional[int] = Field(default=0, description="Number of results to skip for pagination", ge=0) + response_format: ResponseFormat = Field(default=ResponseFormat.MARKDOWN, description="Output format") + + @field_validator('query') + @classmethod + def validate_query(cls, v: str) -> str: + if not v.strip(): + raise ValueError("Query cannot be empty or whitespace only") + return v.strip() + +# Shared utility functions +async def _make_api_request(endpoint: str, method: str = "GET", **kwargs) -> dict: + '''Reusable function for all API calls.''' + async with httpx.AsyncClient() as client: + response = await client.request( + method, + f"{API_BASE_URL}/{endpoint}", + timeout=30.0, + **kwargs + ) + response.raise_for_status() + return response.json() + +def _handle_api_error(e: Exception) -> str: + '''Consistent error formatting across all tools.''' + if isinstance(e, httpx.HTTPStatusError): + if e.response.status_code == 404: + return "Error: Resource not found. Please check the ID is correct." + elif e.response.status_code == 403: + return "Error: Permission denied. You don't have access to this resource." + elif e.response.status_code == 429: + return "Error: Rate limit exceeded. Please wait before making more requests." + return f"Error: API request failed with status {e.response.status_code}" + elif isinstance(e, httpx.TimeoutException): + return "Error: Request timed out. Please try again." + return f"Error: Unexpected error occurred: {type(e).__name__}" + +# Tool definitions +@mcp.tool( + name="example_search_users", + annotations={ + "title": "Search Example Users", + "readOnlyHint": True, + "destructiveHint": False, + "idempotentHint": True, + "openWorldHint": True + } +) +async def example_search_users(params: UserSearchInput) -> str: + '''Search for users in the Example system by name, email, or team. + + [Full docstring as shown above] + ''' + try: + # Make API request using validated parameters + data = await _make_api_request( + "users/search", + params={ + "q": params.query, + "limit": params.limit, + "offset": params.offset + } + ) + + users = data.get("users", []) + total = data.get("total", 0) + + if not users: + return f"No users found matching '{params.query}'" + + # Format response based on requested format + if params.response_format == ResponseFormat.MARKDOWN: + lines = [f"# User Search Results: '{params.query}'", ""] + lines.append(f"Found {total} users (showing {len(users)})") + lines.append("") + + for user in users: + lines.append(f"## {user['name']} ({user['id']})") + lines.append(f"- **Email**: {user['email']}") + if user.get('team'): + lines.append(f"- **Team**: {user['team']}") + lines.append("") + + return "\n".join(lines) + + else: + # Machine-readable JSON format + import json + response = { + "total": total, + "count": len(users), + "offset": params.offset, + "users": users + } + return json.dumps(response, indent=2) + + except Exception as e: + return _handle_api_error(e) + +if __name__ == "__main__": + mcp.run() +``` + +--- + +## Advanced FastMCP Features + +### Context Parameter Injection + +FastMCP can automatically inject a `Context` parameter into tools for advanced capabilities like logging, progress reporting, resource reading, and user interaction: + +```python +from mcp.server.fastmcp import FastMCP, Context + +mcp = FastMCP("example_mcp") + +@mcp.tool() +async def advanced_search(query: str, ctx: Context) -> str: + '''Advanced tool with context access for logging and progress.''' + + # Report progress for long operations + await ctx.report_progress(0.25, "Starting search...") + + # Log information for debugging + await ctx.log_info("Processing query", {"query": query, "timestamp": datetime.now()}) + + # Perform search + results = await search_api(query) + await ctx.report_progress(0.75, "Formatting results...") + + # Access server configuration + server_name = ctx.fastmcp.name + + return format_results(results) + +@mcp.tool() +async def interactive_tool(resource_id: str, ctx: Context) -> str: + '''Tool that can request additional input from users.''' + + # Request sensitive information when needed + api_key = await ctx.elicit( + prompt="Please provide your API key:", + input_type="password" + ) + + # Use the provided key + return await api_call(resource_id, api_key) +``` + +**Context capabilities:** + +- `ctx.report_progress(progress, message)` - Report progress for long operations +- `ctx.log_info(message, data)` / `ctx.log_error()` / `ctx.log_debug()` - Logging +- `ctx.elicit(prompt, input_type)` - Request input from users +- `ctx.fastmcp.name` - Access server configuration +- `ctx.read_resource(uri)` - Read MCP resources + +### Resource Registration + +Expose data as resources for efficient, template-based access: + +```python +@mcp.resource("file://documents/{name}") +async def get_document(name: str) -> str: + '''Expose documents as MCP resources. + + Resources are useful for static or semi-static data that doesn't + require complex parameters. They use URI templates for flexible access. + ''' + document_path = f"./docs/{name}" + with open(document_path, "r") as f: + return f.read() + +@mcp.resource("config://settings/{key}") +async def get_setting(key: str, ctx: Context) -> str: + '''Expose configuration as resources with context.''' + settings = await load_settings() + return json.dumps(settings.get(key, {})) +``` + +**When to use Resources vs Tools:** + +- **Resources**: For data access with simple parameters (URI templates) +- **Tools**: For complex operations with validation and business logic + +### Structured Output Types + +FastMCP supports multiple return types beyond strings: + +```python +from typing import TypedDict +from dataclasses import dataclass +from pydantic import BaseModel + +# TypedDict for structured returns +class UserData(TypedDict): + id: str + name: str + email: str + +@mcp.tool() +async def get_user_typed(user_id: str) -> UserData: + '''Returns structured data - FastMCP handles serialization.''' + return {"id": user_id, "name": "John Doe", "email": "john@example.com"} + +# Pydantic models for complex validation +class DetailedUser(BaseModel): + id: str + name: str + email: str + created_at: datetime + metadata: Dict[str, Any] + +@mcp.tool() +async def get_user_detailed(user_id: str) -> DetailedUser: + '''Returns Pydantic model - automatically generates schema.''' + user = await fetch_user(user_id) + return DetailedUser(**user) +``` + +### Lifespan Management + +Initialize resources that persist across requests: + +```python +from contextlib import asynccontextmanager + +@asynccontextmanager +async def app_lifespan(): + '''Manage resources that live for the server's lifetime.''' + # Initialize connections, load config, etc. + db = await connect_to_database() + config = load_configuration() + + # Make available to all tools + yield {"db": db, "config": config} + + # Cleanup on shutdown + await db.close() + +mcp = FastMCP("example_mcp", lifespan=app_lifespan) + +@mcp.tool() +async def query_data(query: str, ctx: Context) -> str: + '''Access lifespan resources through context.''' + db = ctx.request_context.lifespan_state["db"] + results = await db.query(query) + return format_results(results) +``` + +### Transport Options + +FastMCP supports two main transport mechanisms: + +```python +# stdio transport (for local tools) - default +if __name__ == "__main__": + mcp.run() + +# Streamable HTTP transport (for remote servers) +if __name__ == "__main__": + mcp.run(transport="streamable_http", port=8000) +``` + +**Transport selection:** + +- **stdio**: Command-line tools, local integrations, subprocess execution +- **Streamable HTTP**: Web services, remote access, multiple clients + +--- + +## Code Best Practices + +### Code Composability and Reusability + +Your implementation MUST prioritize composability and code reuse: + +1. **Extract Common Functionality**: + - Create reusable helper functions for operations used across multiple tools + - Build shared API clients for HTTP requests instead of duplicating code + - Centralize error handling logic in utility functions + - Extract business logic into dedicated functions that can be composed + - Extract shared markdown or JSON field selection & formatting functionality + +2. **Avoid Duplication**: + - NEVER copy-paste similar code between tools + - If you find yourself writing similar logic twice, extract it into a function + - Common operations like pagination, filtering, field selection, and formatting should be shared + - Authentication/authorization logic should be centralized + +### Python-Specific Best Practices + +1. **Use Type Hints**: Always include type annotations for function parameters and return values +2. **Pydantic Models**: Define clear Pydantic models for all input validation +3. **Avoid Manual Validation**: Let Pydantic handle input validation with constraints +4. **Proper Imports**: Group imports (standard library, third-party, local) +5. **Error Handling**: Use specific exception types (httpx.HTTPStatusError, not generic Exception) +6. **Async Context Managers**: Use `async with` for resources that need cleanup +7. **Constants**: Define module-level constants in UPPER_CASE + +## Quality Checklist + +Before finalizing your Python MCP server implementation, ensure: + +### Strategic Design + +- [ ] Tools enable complete workflows, not just API endpoint wrappers +- [ ] Tool names reflect natural task subdivisions +- [ ] Response formats optimize for agent context efficiency +- [ ] Human-readable identifiers used where appropriate +- [ ] Error messages guide agents toward correct usage + +### Implementation Quality + +- [ ] FOCUSED IMPLEMENTATION: Most important and valuable tools implemented +- [ ] All tools have descriptive names and documentation +- [ ] Return types are consistent across similar operations +- [ ] Error handling is implemented for all external calls +- [ ] Server name follows format: `{service}_mcp` +- [ ] All network operations use async/await +- [ ] Common functionality is extracted into reusable functions +- [ ] Error messages are clear, actionable, and educational +- [ ] Outputs are properly validated and formatted + +### Tool Configuration + +- [ ] All tools implement 'name' and 'annotations' in the decorator +- [ ] Annotations correctly set (readOnlyHint, destructiveHint, idempotentHint, openWorldHint) +- [ ] All tools use Pydantic BaseModel for input validation with Field() definitions +- [ ] All Pydantic Fields have explicit types and descriptions with constraints +- [ ] All tools have comprehensive docstrings with explicit input/output types +- [ ] Docstrings include complete schema structure for dict/JSON returns +- [ ] Pydantic models handle input validation (no manual validation needed) + +### Advanced Features (where applicable) + +- [ ] Context injection used for logging, progress, or elicitation +- [ ] Resources registered for appropriate data endpoints +- [ ] Lifespan management implemented for persistent connections +- [ ] Structured output types used (TypedDict, Pydantic models) +- [ ] Appropriate transport configured (stdio or streamable HTTP) + +### Code Quality + +- [ ] File includes proper imports including Pydantic imports +- [ ] Pagination is properly implemented where applicable +- [ ] Filtering options are provided for potentially large result sets +- [ ] All async functions are properly defined with `async def` +- [ ] HTTP client usage follows async patterns with proper context managers +- [ ] Type hints are used throughout the code +- [ ] Constants are defined at module level in UPPER_CASE + +### Testing + +- [ ] Server runs successfully: `python your_server.py --help` +- [ ] All imports resolve correctly +- [ ] Sample tool calls work as expected +- [ ] Error scenarios handled gracefully diff --git a/packages/mosaic/framework/skills/mcp-builder/scripts/connections.py b/packages/mosaic/framework/skills/mcp-builder/scripts/connections.py new file mode 100644 index 00000000..ffcd0da3 --- /dev/null +++ b/packages/mosaic/framework/skills/mcp-builder/scripts/connections.py @@ -0,0 +1,151 @@ +"""Lightweight connection handling for MCP servers.""" + +from abc import ABC, abstractmethod +from contextlib import AsyncExitStack +from typing import Any + +from mcp import ClientSession, StdioServerParameters +from mcp.client.sse import sse_client +from mcp.client.stdio import stdio_client +from mcp.client.streamable_http import streamablehttp_client + + +class MCPConnection(ABC): + """Base class for MCP server connections.""" + + def __init__(self): + self.session = None + self._stack = None + + @abstractmethod + def _create_context(self): + """Create the connection context based on connection type.""" + + async def __aenter__(self): + """Initialize MCP server connection.""" + self._stack = AsyncExitStack() + await self._stack.__aenter__() + + try: + ctx = self._create_context() + result = await self._stack.enter_async_context(ctx) + + if len(result) == 2: + read, write = result + elif len(result) == 3: + read, write, _ = result + else: + raise ValueError(f"Unexpected context result: {result}") + + session_ctx = ClientSession(read, write) + self.session = await self._stack.enter_async_context(session_ctx) + await self.session.initialize() + return self + except BaseException: + await self._stack.__aexit__(None, None, None) + raise + + async def __aexit__(self, exc_type, exc_val, exc_tb): + """Clean up MCP server connection resources.""" + if self._stack: + await self._stack.__aexit__(exc_type, exc_val, exc_tb) + self.session = None + self._stack = None + + async def list_tools(self) -> list[dict[str, Any]]: + """Retrieve available tools from the MCP server.""" + response = await self.session.list_tools() + return [ + { + "name": tool.name, + "description": tool.description, + "input_schema": tool.inputSchema, + } + for tool in response.tools + ] + + async def call_tool(self, tool_name: str, arguments: dict[str, Any]) -> Any: + """Call a tool on the MCP server with provided arguments.""" + result = await self.session.call_tool(tool_name, arguments=arguments) + return result.content + + +class MCPConnectionStdio(MCPConnection): + """MCP connection using standard input/output.""" + + def __init__(self, command: str, args: list[str] = None, env: dict[str, str] = None): + super().__init__() + self.command = command + self.args = args or [] + self.env = env + + def _create_context(self): + return stdio_client( + StdioServerParameters(command=self.command, args=self.args, env=self.env) + ) + + +class MCPConnectionSSE(MCPConnection): + """MCP connection using Server-Sent Events.""" + + def __init__(self, url: str, headers: dict[str, str] = None): + super().__init__() + self.url = url + self.headers = headers or {} + + def _create_context(self): + return sse_client(url=self.url, headers=self.headers) + + +class MCPConnectionHTTP(MCPConnection): + """MCP connection using Streamable HTTP.""" + + def __init__(self, url: str, headers: dict[str, str] = None): + super().__init__() + self.url = url + self.headers = headers or {} + + def _create_context(self): + return streamablehttp_client(url=self.url, headers=self.headers) + + +def create_connection( + transport: str, + command: str = None, + args: list[str] = None, + env: dict[str, str] = None, + url: str = None, + headers: dict[str, str] = None, +) -> MCPConnection: + """Factory function to create the appropriate MCP connection. + + Args: + transport: Connection type ("stdio", "sse", or "http") + command: Command to run (stdio only) + args: Command arguments (stdio only) + env: Environment variables (stdio only) + url: Server URL (sse and http only) + headers: HTTP headers (sse and http only) + + Returns: + MCPConnection instance + """ + transport = transport.lower() + + if transport == "stdio": + if not command: + raise ValueError("Command is required for stdio transport") + return MCPConnectionStdio(command=command, args=args, env=env) + + elif transport == "sse": + if not url: + raise ValueError("URL is required for sse transport") + return MCPConnectionSSE(url=url, headers=headers) + + elif transport in ["http", "streamable_http", "streamable-http"]: + if not url: + raise ValueError("URL is required for http transport") + return MCPConnectionHTTP(url=url, headers=headers) + + else: + raise ValueError(f"Unsupported transport type: {transport}. Use 'stdio', 'sse', or 'http'") diff --git a/packages/mosaic/framework/skills/mcp-builder/scripts/evaluation.py b/packages/mosaic/framework/skills/mcp-builder/scripts/evaluation.py new file mode 100644 index 00000000..41778569 --- /dev/null +++ b/packages/mosaic/framework/skills/mcp-builder/scripts/evaluation.py @@ -0,0 +1,373 @@ +"""MCP Server Evaluation Harness + +This script evaluates MCP servers by running test questions against them using Claude. +""" + +import argparse +import asyncio +import json +import re +import sys +import time +import traceback +import xml.etree.ElementTree as ET +from pathlib import Path +from typing import Any + +from anthropic import Anthropic + +from connections import create_connection + +EVALUATION_PROMPT = """You are an AI assistant with access to tools. + +When given a task, you MUST: +1. Use the available tools to complete the task +2. Provide summary of each step in your approach, wrapped in <summary> tags +3. Provide feedback on the tools provided, wrapped in <feedback> tags +4. Provide your final response, wrapped in <response> tags + +Summary Requirements: +- In your <summary> tags, you must explain: + - The steps you took to complete the task + - Which tools you used, in what order, and why + - The inputs you provided to each tool + - The outputs you received from each tool + - A summary for how you arrived at the response + +Feedback Requirements: +- In your <feedback> tags, provide constructive feedback on the tools: + - Comment on tool names: Are they clear and descriptive? + - Comment on input parameters: Are they well-documented? Are required vs optional parameters clear? + - Comment on descriptions: Do they accurately describe what the tool does? + - Comment on any errors encountered during tool usage: Did the tool fail to execute? Did the tool return too many tokens? + - Identify specific areas for improvement and explain WHY they would help + - Be specific and actionable in your suggestions + +Response Requirements: +- Your response should be concise and directly address what was asked +- Always wrap your final response in <response> tags +- If you cannot solve the task return <response>NOT_FOUND</response> +- For numeric responses, provide just the number +- For IDs, provide just the ID +- For names or text, provide the exact text requested +- Your response should go last""" + + +def parse_evaluation_file(file_path: Path) -> list[dict[str, Any]]: + """Parse XML evaluation file with qa_pair elements.""" + try: + tree = ET.parse(file_path) + root = tree.getroot() + evaluations = [] + + for qa_pair in root.findall(".//qa_pair"): + question_elem = qa_pair.find("question") + answer_elem = qa_pair.find("answer") + + if question_elem is not None and answer_elem is not None: + evaluations.append({ + "question": (question_elem.text or "").strip(), + "answer": (answer_elem.text or "").strip(), + }) + + return evaluations + except Exception as e: + print(f"Error parsing evaluation file {file_path}: {e}") + return [] + + +def extract_xml_content(text: str, tag: str) -> str | None: + """Extract content from XML tags.""" + pattern = rf"<{tag}>(.*?)</{tag}>" + matches = re.findall(pattern, text, re.DOTALL) + return matches[-1].strip() if matches else None + + +async def agent_loop( + client: Anthropic, + model: str, + question: str, + tools: list[dict[str, Any]], + connection: Any, +) -> tuple[str, dict[str, Any]]: + """Run the agent loop with MCP tools.""" + messages = [{"role": "user", "content": question}] + + response = await asyncio.to_thread( + client.messages.create, + model=model, + max_tokens=4096, + system=EVALUATION_PROMPT, + messages=messages, + tools=tools, + ) + + messages.append({"role": "assistant", "content": response.content}) + + tool_metrics = {} + + while response.stop_reason == "tool_use": + tool_use = next(block for block in response.content if block.type == "tool_use") + tool_name = tool_use.name + tool_input = tool_use.input + + tool_start_ts = time.time() + try: + tool_result = await connection.call_tool(tool_name, tool_input) + tool_response = json.dumps(tool_result) if isinstance(tool_result, (dict, list)) else str(tool_result) + except Exception as e: + tool_response = f"Error executing tool {tool_name}: {str(e)}\n" + tool_response += traceback.format_exc() + tool_duration = time.time() - tool_start_ts + + if tool_name not in tool_metrics: + tool_metrics[tool_name] = {"count": 0, "durations": []} + tool_metrics[tool_name]["count"] += 1 + tool_metrics[tool_name]["durations"].append(tool_duration) + + messages.append({ + "role": "user", + "content": [{ + "type": "tool_result", + "tool_use_id": tool_use.id, + "content": tool_response, + }] + }) + + response = await asyncio.to_thread( + client.messages.create, + model=model, + max_tokens=4096, + system=EVALUATION_PROMPT, + messages=messages, + tools=tools, + ) + messages.append({"role": "assistant", "content": response.content}) + + response_text = next( + (block.text for block in response.content if hasattr(block, "text")), + None, + ) + return response_text, tool_metrics + + +async def evaluate_single_task( + client: Anthropic, + model: str, + qa_pair: dict[str, Any], + tools: list[dict[str, Any]], + connection: Any, + task_index: int, +) -> dict[str, Any]: + """Evaluate a single QA pair with the given tools.""" + start_time = time.time() + + print(f"Task {task_index + 1}: Running task with question: {qa_pair['question']}") + response, tool_metrics = await agent_loop(client, model, qa_pair["question"], tools, connection) + + response_value = extract_xml_content(response, "response") + summary = extract_xml_content(response, "summary") + feedback = extract_xml_content(response, "feedback") + + duration_seconds = time.time() - start_time + + return { + "question": qa_pair["question"], + "expected": qa_pair["answer"], + "actual": response_value, + "score": int(response_value == qa_pair["answer"]) if response_value else 0, + "total_duration": duration_seconds, + "tool_calls": tool_metrics, + "num_tool_calls": sum(len(metrics["durations"]) for metrics in tool_metrics.values()), + "summary": summary, + "feedback": feedback, + } + + +REPORT_HEADER = """ +# Evaluation Report + +## Summary + +- **Accuracy**: {correct}/{total} ({accuracy:.1f}%) +- **Average Task Duration**: {average_duration_s:.2f}s +- **Average Tool Calls per Task**: {average_tool_calls:.2f} +- **Total Tool Calls**: {total_tool_calls} + +--- +""" + +TASK_TEMPLATE = """ +### Task {task_num} + +**Question**: {question} +**Ground Truth Answer**: `{expected_answer}` +**Actual Answer**: `{actual_answer}` +**Correct**: {correct_indicator} +**Duration**: {total_duration:.2f}s +**Tool Calls**: {tool_calls} + +**Summary** +{summary} + +**Feedback** +{feedback} + +--- +""" + + +async def run_evaluation( + eval_path: Path, + connection: Any, + model: str = "claude-3-7-sonnet-20250219", +) -> str: + """Run evaluation with MCP server tools.""" + print("🚀 Starting Evaluation") + + client = Anthropic() + + tools = await connection.list_tools() + print(f"📋 Loaded {len(tools)} tools from MCP server") + + qa_pairs = parse_evaluation_file(eval_path) + print(f"📋 Loaded {len(qa_pairs)} evaluation tasks") + + results = [] + for i, qa_pair in enumerate(qa_pairs): + print(f"Processing task {i + 1}/{len(qa_pairs)}") + result = await evaluate_single_task(client, model, qa_pair, tools, connection, i) + results.append(result) + + correct = sum(r["score"] for r in results) + accuracy = (correct / len(results)) * 100 if results else 0 + average_duration_s = sum(r["total_duration"] for r in results) / len(results) if results else 0 + average_tool_calls = sum(r["num_tool_calls"] for r in results) / len(results) if results else 0 + total_tool_calls = sum(r["num_tool_calls"] for r in results) + + report = REPORT_HEADER.format( + correct=correct, + total=len(results), + accuracy=accuracy, + average_duration_s=average_duration_s, + average_tool_calls=average_tool_calls, + total_tool_calls=total_tool_calls, + ) + + report += "".join([ + TASK_TEMPLATE.format( + task_num=i + 1, + question=qa_pair["question"], + expected_answer=qa_pair["answer"], + actual_answer=result["actual"] or "N/A", + correct_indicator="✅" if result["score"] else "❌", + total_duration=result["total_duration"], + tool_calls=json.dumps(result["tool_calls"], indent=2), + summary=result["summary"] or "N/A", + feedback=result["feedback"] or "N/A", + ) + for i, (qa_pair, result) in enumerate(zip(qa_pairs, results)) + ]) + + return report + + +def parse_headers(header_list: list[str]) -> dict[str, str]: + """Parse header strings in format 'Key: Value' into a dictionary.""" + headers = {} + if not header_list: + return headers + + for header in header_list: + if ":" in header: + key, value = header.split(":", 1) + headers[key.strip()] = value.strip() + else: + print(f"Warning: Ignoring malformed header: {header}") + return headers + + +def parse_env_vars(env_list: list[str]) -> dict[str, str]: + """Parse environment variable strings in format 'KEY=VALUE' into a dictionary.""" + env = {} + if not env_list: + return env + + for env_var in env_list: + if "=" in env_var: + key, value = env_var.split("=", 1) + env[key.strip()] = value.strip() + else: + print(f"Warning: Ignoring malformed environment variable: {env_var}") + return env + + +async def main(): + parser = argparse.ArgumentParser( + description="Evaluate MCP servers using test questions", + formatter_class=argparse.RawDescriptionHelpFormatter, + epilog=""" +Examples: + # Evaluate a local stdio MCP server + python evaluation.py -t stdio -c python -a my_server.py eval.xml + + # Evaluate an SSE MCP server + python evaluation.py -t sse -u https://example.com/mcp -H "Authorization: Bearer token" eval.xml + + # Evaluate an HTTP MCP server with custom model + python evaluation.py -t http -u https://example.com/mcp -m claude-3-5-sonnet-20241022 eval.xml + """, + ) + + parser.add_argument("eval_file", type=Path, help="Path to evaluation XML file") + parser.add_argument("-t", "--transport", choices=["stdio", "sse", "http"], default="stdio", help="Transport type (default: stdio)") + parser.add_argument("-m", "--model", default="claude-3-7-sonnet-20250219", help="Claude model to use (default: claude-3-7-sonnet-20250219)") + + stdio_group = parser.add_argument_group("stdio options") + stdio_group.add_argument("-c", "--command", help="Command to run MCP server (stdio only)") + stdio_group.add_argument("-a", "--args", nargs="+", help="Arguments for the command (stdio only)") + stdio_group.add_argument("-e", "--env", nargs="+", help="Environment variables in KEY=VALUE format (stdio only)") + + remote_group = parser.add_argument_group("sse/http options") + remote_group.add_argument("-u", "--url", help="MCP server URL (sse/http only)") + remote_group.add_argument("-H", "--header", nargs="+", dest="headers", help="HTTP headers in 'Key: Value' format (sse/http only)") + + parser.add_argument("-o", "--output", type=Path, help="Output file for evaluation report (default: stdout)") + + args = parser.parse_args() + + if not args.eval_file.exists(): + print(f"Error: Evaluation file not found: {args.eval_file}") + sys.exit(1) + + headers = parse_headers(args.headers) if args.headers else None + env_vars = parse_env_vars(args.env) if args.env else None + + try: + connection = create_connection( + transport=args.transport, + command=args.command, + args=args.args, + env=env_vars, + url=args.url, + headers=headers, + ) + except ValueError as e: + print(f"Error: {e}") + sys.exit(1) + + print(f"🔗 Connecting to MCP server via {args.transport}...") + + async with connection: + print("✅ Connected successfully") + report = await run_evaluation(args.eval_file, connection, args.model) + + if args.output: + args.output.write_text(report) + print(f"\n✅ Report saved to {args.output}") + else: + print("\n" + report) + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/packages/mosaic/framework/skills/mcp-builder/scripts/example_evaluation.xml b/packages/mosaic/framework/skills/mcp-builder/scripts/example_evaluation.xml new file mode 100644 index 00000000..41e4459b --- /dev/null +++ b/packages/mosaic/framework/skills/mcp-builder/scripts/example_evaluation.xml @@ -0,0 +1,22 @@ +<evaluation> + <qa_pair> + <question>Calculate the compound interest on $10,000 invested at 5% annual interest rate, compounded monthly for 3 years. What is the final amount in dollars (rounded to 2 decimal places)?</question> + <answer>11614.72</answer> + </qa_pair> + <qa_pair> + <question>A projectile is launched at a 45-degree angle with an initial velocity of 50 m/s. Calculate the total distance (in meters) it has traveled from the launch point after 2 seconds, assuming g=9.8 m/s². Round to 2 decimal places.</question> + <answer>87.25</answer> + </qa_pair> + <qa_pair> + <question>A sphere has a volume of 500 cubic meters. Calculate its surface area in square meters. Round to 2 decimal places.</question> + <answer>304.65</answer> + </qa_pair> + <qa_pair> + <question>Calculate the population standard deviation of this dataset: [12, 15, 18, 22, 25, 30, 35]. Round to 2 decimal places.</question> + <answer>7.61</answer> + </qa_pair> + <qa_pair> + <question>Calculate the pH of a solution with a hydrogen ion concentration of 3.5 × 10^-5 M. Round to 2 decimal places.</question> + <answer>4.46</answer> + </qa_pair> +</evaluation> diff --git a/packages/mosaic/framework/skills/mcp-builder/scripts/requirements.txt b/packages/mosaic/framework/skills/mcp-builder/scripts/requirements.txt new file mode 100644 index 00000000..e73e5d1e --- /dev/null +++ b/packages/mosaic/framework/skills/mcp-builder/scripts/requirements.txt @@ -0,0 +1,2 @@ +anthropic>=0.39.0 +mcp>=1.1.0 diff --git a/packages/mosaic/framework/skills/mosaic-deploy/SKILL.md b/packages/mosaic/framework/skills/mosaic-deploy/SKILL.md new file mode 100644 index 00000000..2182a033 --- /dev/null +++ b/packages/mosaic/framework/skills/mosaic-deploy/SKILL.md @@ -0,0 +1,82 @@ +--- +name: mosaic-deploy +description: 'Full end-to-end deploy flow for Mosaic Stack projects: push branch → open PR → wait for CI → merge → redeploy Portainer stack. Use when deploying a feature branch to production or staging, or when asked to ship a completed feature. Orchestrates mosaic-gitea, mosaic-woodpecker, and mosaic-portainer skills.' +--- + +# mosaic-deploy + +End-to-end deployment flow for Mosaic Stack projects. + +## Full Deploy Sequence + +``` +push branch → open PR → CI passes → merge → portainer redeploy +``` + +### Step 1: Push branch and open PR + +```bash +cd ~/src/<repo>-worktrees/<task-slug> +git push -u origin <branch> +~/.config/mosaic/tools/git/pr-create.sh -t "feat: ..." -b "..." -i <issue#> +# Note the PR number from output +``` + +### Step 2: Wait for CI + +```bash +~/.config/mosaic/tools/git/pr-ci-wait.sh -n <pr#> +``` + +If CI fails, check: + +```bash +source ~/.config/mosaic/tools/_lib/credentials.sh && load_credentials woodpecker +~/.config/mosaic/tools/woodpecker/pipeline-status.sh -r <org>/<repo> +``` + +### Step 3: Merge + +```bash +cd ~/src/<repo> +~/.config/mosaic/tools/git/pr-merge.sh -n <pr#> -d +``` + +Always merge through `pr-merge.sh`: it runs the CI queue guard first and pins +the merge to the reviewed head. If branch protection blocks the merge, that is +a gate telling you something — a failing check, a moved head, or a missing +review. Fix the cause; never route around it with a raw API call, a shared +credential, or `force_merge`. Exceptional cases go to the operator or the +coordinating seat, still merged through the wrapper. + +### Step 4: Redeploy Portainer stack + +```bash +source ~/.config/mosaic/tools/_lib/credentials.sh && load_credentials portainer +~/.config/mosaic/tools/portainer/stack-redeploy.sh -n <stack-name> -p +``` + +Check deployment: + +```bash +~/.config/mosaic/tools/portainer/stack-status.sh -n <stack-name> +~/.config/mosaic/tools/portainer/stack-logs.sh -n <stack-name> -l 50 +``` + +## Stack Name Map + +Maintain your estate's project → stack-name mapping in a skills-local override of +this skill (local copies take precedence over the shipped canonical one). Example +shape: + +| Project | Stack Name | +| ------------ | ----------------- | +| `sample-app` | `sample-app` | +| `sample-api` | `sample-api-prod` | + +## Notes + +- Workers open PRs but **never merge** — orchestrator or Merge Guard handles step 3+ +- Docker Swarm image pinning: if `-p` doesn't pull a new image, SSH to the Docker node (e.g. `node-01`) and run `docker pull <image>` manually, then redeploy +- Worktrees: all coding work in `~/src/<repo>-worktrees/<task-slug>`, never in main checkout +- Always clean up worktree after push: `git worktree remove ~/src/<repo>-worktrees/<task-slug>` diff --git a/packages/mosaic/framework/skills/mosaic-gitea/SKILL.md b/packages/mosaic/framework/skills/mosaic-gitea/SKILL.md new file mode 100644 index 00000000..9ae65e62 --- /dev/null +++ b/packages/mosaic/framework/skills/mosaic-gitea/SKILL.md @@ -0,0 +1,93 @@ +--- +name: mosaic-gitea +description: Interact with Gitea repositories on git.mosaicstack.dev — create/merge/close PRs, manage issues, milestones, and CI queue waits. Use when working with Mosaic Stack git repos. Wraps scripts in ~/.config/mosaic/tools/git/. Works on both Gitea and GitHub (auto-detected via detect-platform.sh). +--- + +# mosaic-gitea + +Git operations via Mosaic wrapper scripts. Platform-aware (Gitea or GitHub). + +## Setup + +Scripts auto-detect platform from git remote. Run from inside the repo directory. + +Credentials come from the framework credentials loader (never from a shared env +file): + +```bash +source ~/.config/mosaic/tools/_lib/credentials.sh +load_credentials gitea-mosaicstack +# Exports: GITEA_TOKEN, GITEA_URL +``` + +## Script Reference + +All scripts in `~/.config/mosaic/tools/git/`. + +### Pull Requests + +| Script | Purpose | Key flags | +| ---------------- | ----------------------- | -------------------------------------------------------- | +| `pr-create.sh` | Create a PR | `-t "Title" [-b "Body"] [-B base] [-H head] [-i issue#]` | +| `pr-list.sh` | List open PRs | `[-s state]` | +| `pr-view.sh` | View PR details | `-n <pr#>` | +| `pr-merge.sh` | Squash-merge a PR | `-n <pr#> [-d]` (delete branch) | +| `pr-close.sh` | Close a PR | `-n <pr#>` | +| `pr-diff.sh` | Show PR diff | `-n <pr#>` | +| `pr-review.sh` | Submit a review | `-n <pr#> [-a approve\|request-changes]` | +| `pr-ci-wait.sh` | Wait for CI on PR | `-n <pr#>` | +| `pr-metadata.sh` | Get PR metadata as JSON | `-n <pr#>` | + +### Issues + +| Script | Purpose | Key flags | +| ------------------ | --------------- | --------------------------------------------------- | +| `issue-create.sh` | Create an issue | `-t "Title" [-b "Body"] [-l labels] [-m milestone]` | +| `issue-list.sh` | List issues | `[-s state] [-l label]` | +| `issue-view.sh` | View issue | `-n <issue#>` | +| `issue-close.sh` | Close issue | `-n <issue#>` | +| `issue-comment.sh` | Add comment | `-n <issue#> -c "Comment"` | +| `issue-assign.sh` | Assign issue | `-n <issue#> -u username` | +| `issue-edit.sh` | Edit issue | `-n <issue#> [-t title] [-b body]` | + +### Milestones + +| Script | Purpose | Key flags | +| --------------------- | ---------------- | ---------------------------- | +| `milestone-create.sh` | Create milestone | `-t "Title" [-d "due date"]` | +| `milestone-list.sh` | List milestones | — | +| `milestone-close.sh` | Close milestone | `-n <number>` | + +### CI / Queue + +| Script | Purpose | Key flags | +| -------------------- | ---------------------- | --------------------------- | +| `ci-queue-wait.sh` | Wait for CI queue slot | `[-t timeout] [-B branch]` | +| `detect-platform.sh` | Detect git platform | outputs `gitea` or `github` | + +## Common Workflows + +**Create PR from current branch:** + +```bash +cd ~/src/<repo> +~/.config/mosaic/tools/git/pr-create.sh -t "feat: my feature" -b "Description" -i <issue#> +``` + +**Merge a PR (squash, with CI queue guard):** + +```bash +cd ~/src/<repo> +~/.config/mosaic/tools/git/pr-merge.sh -n <pr#> -d +``` + +Branch protection is a gate, not an obstacle: if it blocks a merge, fix the cause — +a failing check, a moved head, or a missing review. Never bypass it with a raw +API call, a shared credential, or `force_merge`. Exceptional cases go to the +operator or the coordinating seat, still merged through the wrapper. + +## Notes + +- Mosaic policy: squash merges only, targeting `main` +- Gitea SSH: `git@git.mosaicstack.dev` → resolves via `~/.ssh/config` to the Gitea host's SSH port (e.g. `gitea.example.internal:2222`) +- Workers push branches and open PRs but **never merge** — orchestrator handles merges diff --git a/packages/mosaic/framework/skills/mosaic-orchestrator/SKILL.md b/packages/mosaic/framework/skills/mosaic-orchestrator/SKILL.md new file mode 100644 index 00000000..c6035b6d --- /dev/null +++ b/packages/mosaic/framework/skills/mosaic-orchestrator/SKILL.md @@ -0,0 +1,96 @@ +--- +name: mosaic-orchestrator +description: Initialize and run Mosaic orchestration missions. Use when starting a new mission (set of tasks for a project), checking mission status, resuming an orchestrator session, or running the coordinator loop. Wraps scripts in ~/.config/mosaic/tools/orchestrator/. Read ORCHESTRATOR.md and E2E-DELIVERY.md before initiating Orchestrator mode. +--- + +# mosaic-orchestrator + +Mosaic mission and orchestrator session management. + +## Mandatory Pre-Flight + +Before initiating Orchestrator mode, always read: + +1. `~/.config/mosaic/guides/ORCHESTRATOR.md` +2. `~/.config/mosaic/guides/E2E-DELIVERY.md` + +Then declare: **"Now initiating Orchestrator mode..."** + +## Binary + +```bash +export PATH="$HOME/.config/mosaic/bin:$PATH" +mosaic --help +``` + +## CLI Commands + +```bash +# Initialize a mission (PRD-driven) +mosaic prdy init --project <path> + +# Initialize coordinator mission +mosaic coord init --name <name> --project <path> [--milestones m1,m2] + +# Run coordinator (launches orchestrator session) +mosaic coord run --project <path> + +# Launch a coding worker (Claude Code with mosaic rails) +mosaic yolo claude # Claude Code +mosaic yolo codex # Codex +mosaic yolo glm # GLM-5 + +# Health audit +mosaic doctor +``` + +## Orchestrator Scripts + +Located in `~/.config/mosaic/tools/orchestrator/`. + +| Script | Purpose | +| -------------------- | --------------------------------------------- | +| `mission-init.sh` | Initialize mission.json and TASKS.md scaffold | +| `mission-status.sh` | Show current mission + task state | +| `session-run.sh` | Launch an orchestrator session | +| `session-resume.sh` | Resume a paused orchestrator session | +| `session-status.sh` | Check session health / lock status | +| `smoke-test.sh` | Post-deploy smoke test | +| `continue-prompt.sh` | Generate a continue prompt for session | + +## Mission Init Pattern + +```bash +export PATH="$HOME/.config/mosaic/bin:$PATH" +cd ~/src/<repo> +mosaic coord init \ + --name "<mission-name>" \ + --project . \ + --milestones "milestone-1,milestone-2" +mosaic coord run --project . +``` + +## Worker Launch Rules (MANDATORY) + +- **Max 1 Claude (Sonnet) worker at a time** — serial only +- **Max 6 Codex workers at a time** +- Always use `mosaic yolo <agent>` from the project directory — never `sessions_spawn` for coding +- Workers: implement → lint → push branch → open PR → fire system event → **EXIT** (never merge) + +## Agent State + +```bash +~/.openclaw/workspace/agents/bin/agent-state status # Check active agents +~/.openclaw/workspace/agents/bin/agent-state clear <task> # Clear stale entry +``` + +## Completion Gates + +A task is NOT done until all of: + +- [ ] Code review ✓ +- [ ] Security review ✓ +- [ ] Tests GREEN ✓ +- [ ] CI green ✓ +- [ ] Issue closed ✓ +- [ ] Docs updated ✓ diff --git a/packages/mosaic/framework/skills/mosaic-portainer/SKILL.md b/packages/mosaic/framework/skills/mosaic-portainer/SKILL.md new file mode 100644 index 00000000..cdb62b22 --- /dev/null +++ b/packages/mosaic/framework/skills/mosaic-portainer/SKILL.md @@ -0,0 +1,62 @@ +--- +name: mosaic-portainer +description: Manage Portainer stacks on the Mosaic infrastructure. Use when asked to list, start, stop, redeploy, or check logs of Docker Swarm stacks via Portainer. Wraps scripts in ~/.config/mosaic/tools/portainer/. Requires load_credentials portainer first. +--- + +# mosaic-portainer + +Manage Portainer stacks via pre-built Mosaic scripts. + +## Setup + +Always load credentials before running scripts: + +```bash +source ~/.config/mosaic/tools/_lib/credentials.sh +load_credentials portainer +# Exports: PORTAINER_URL, PORTAINER_API_KEY +``` + +## Scripts + +All scripts live in `~/.config/mosaic/tools/portainer/`. + +| Script | Purpose | Key flags | +| ------------------- | ---------------------------- | ------------------------------ | +| `stack-list.sh` | List all stacks | — | +| `stack-status.sh` | Status of a stack | `-n <name>` | +| `stack-redeploy.sh` | Redeploy (file or git-based) | `-n <name> [-p]` (pull images) | +| `stack-start.sh` | Start a stopped stack | `-n <name>` | +| `stack-stop.sh` | Stop a running stack | `-n <name>` | +| `stack-logs.sh` | Tail stack logs | `-n <name> [-l lines]` | +| `endpoint-list.sh` | List Portainer endpoints | — | + +## Common Workflows + +**Redeploy a stack with fresh images:** + +```bash +source ~/.config/mosaic/tools/_lib/credentials.sh && load_credentials portainer +~/.config/mosaic/tools/portainer/stack-redeploy.sh -n mosaic-stack -p +``` + +**Check all stack statuses:** + +```bash +source ~/.config/mosaic/tools/_lib/credentials.sh && load_credentials portainer +~/.config/mosaic/tools/portainer/stack-list.sh +``` + +**Tail logs for a service:** + +```bash +source ~/.config/mosaic/tools/_lib/credentials.sh && load_credentials portainer +~/.config/mosaic/tools/portainer/stack-logs.sh -n mosaic-stack -l 100 +``` + +## Notes + +- Portainer URL: `https://portainer.example.internal:9443` +- Primary Docker host: `node-01`, managed via Portainer agent +- Docker Swarm image updates: `stack-redeploy.sh -p` does NOT guarantee new image pull if digest is pinned; SSH to node and `docker pull` first if needed +- Credentials: `load_credentials portainer` (framework credentials store) diff --git a/packages/mosaic/framework/skills/mosaic-tools/SKILL.md b/packages/mosaic/framework/skills/mosaic-tools/SKILL.md new file mode 100644 index 00000000..ed424d95 --- /dev/null +++ b/packages/mosaic/framework/skills/mosaic-tools/SKILL.md @@ -0,0 +1,66 @@ +--- +name: mosaic-tools +description: Fast path to the Mosaic fleet toolkit at ~/.config/mosaic/tools/. Use FIRST when you need to message another agent's tmux session, open/merge a Gitea/GitHub PR or issue, run a CI queue wait, or operate Portainer/Woodpecker/Authentik/Cloudflare. Reach for these wrappers before hand-rolling raw tmux send-keys, raw tea/gh/glab, or curl. +--- + +# mosaic-tools + +You are a Mosaic fleet agent on web1. A maintained toolkit lives at `~/.config/mosaic/tools/`. +Use it FIRST for the tasks below — improvising with raw CLIs causes the recurring failures this +skill exists to prevent. This is the high-frequency fast path; the full reference is the +`# Machine Tools` section already in your system prompt. + +## 1. Message another agent (highest priority) + +To send a message or briefing to another agent's tmux session, use the wrapper — **never** raw +`tmux send-keys` / `paste-buffer` (interactive REPLs swallow a trailing Enter or leave the text as +an unsubmitted draft; the wrapper handles bracketed paste, Enter flushing, and the `[src -> dst]` +preamble): + +```bash +~/.config/mosaic/tools/tmux/agent-send.sh -s <target-session> -m "your message" +~/.config/mosaic/tools/tmux/agent-send.sh -s mos-claude -f path/to/brief.md # send a file's contents +``` + +The coordinator session is `mos-claude`. Status reports, findings, and questions go there. + +## 2. Git provider operations (issues / PRs / milestones) + +Use the wrappers — they auto-detect the platform (Gitea or GitHub) and handle auth. Prefer them +over raw `tea` / `gh` / `glab`: + +```bash +~/.config/mosaic/tools/git/issue-create.sh ... +~/.config/mosaic/tools/git/issue-close.sh ... +~/.config/mosaic/tools/git/pr-create.sh ... +~/.config/mosaic/tools/git/pr-merge.sh ... +~/.config/mosaic/tools/git/ci-queue-wait.sh --purpose push|merge # REQUIRED before any push or merge +``` + +### GITEA_LOGIN gotcha (do not skip) + +The wrappers default to `--login ${GITEA_LOGIN:-mosaicstack}`. On **USC** repos hosted at +`git.uscllc.com`, that default fails with `gitea / Error: GetUserByName ... not found`. Pick the +login from the repo's `origin` host **before** running a wrapper: + +```bash +git remote get-url origin # inspect the host, then: +``` + +| origin host | login | +| --------------------- | ---------------------------------------- | +| `git.uscllc.com` | `export GITEA_LOGIN=usc` | +| `git.mosaicstack.dev` | default `mosaicstack` (no export needed) | + +## 3. Other tool suites under `~/.config/mosaic/tools/` + +- `woodpecker/` — CI pipeline status/trigger (`pipeline-status.sh`, `pipeline-list.sh`; `-a usc` for USC repos) +- `portainer/` — Docker Swarm stack status/redeploy +- `authentik/` — identity: users, groups, apps, flows +- `cloudflare/` — DNS records (`-a <instance>` for multi-account) +- `health/` — `stack-health.sh` service checks +- `openbrain/` — semantic agent memory (meta-observations only) + +For the complete reference, consult the `# Machine Tools` section in your system prompt before +improvising. Related skills: `mosaic-gitea` (full git-wrapper flag reference), +`mosaic-tmux-comms` (inter-agent messaging detail). diff --git a/packages/mosaic/framework/skills/mosaic-woodpecker/SKILL.md b/packages/mosaic/framework/skills/mosaic-woodpecker/SKILL.md new file mode 100644 index 00000000..f7952432 --- /dev/null +++ b/packages/mosaic/framework/skills/mosaic-woodpecker/SKILL.md @@ -0,0 +1,75 @@ +--- +name: mosaic-woodpecker +description: 'Manage Woodpecker CI pipelines for Mosaic Stack projects. Use when checking pipeline status, triggering pipelines, waiting for CI to complete, or debugging build failures. Wraps scripts in ~/.config/mosaic/tools/woodpecker/. CI URL: https://ci.mosaicstack.dev.' +--- + +# mosaic-woodpecker + +Woodpecker CI management via Mosaic wrapper scripts. + +## Setup + +```bash +source ~/.config/mosaic/tools/_lib/credentials.sh +load_credentials woodpecker +# Exports: WOODPECKER_URL, WOODPECKER_TOKEN +``` + +For a specific instance (e.g. `usc`): + +```bash +load_credentials woodpecker-usc +``` + +## Scripts + +All scripts in `~/.config/mosaic/tools/woodpecker/`. + +| Script | Purpose | Key flags | +| --------------------- | --------------------- | ---------------------------------------------- | +| `pipeline-status.sh` | Check pipeline status | `[-r owner/repo] [-n number] [-f json\|table]` | +| `pipeline-list.sh` | List recent pipelines | `[-r owner/repo] [-l limit]` | +| `pipeline-trigger.sh` | Trigger a pipeline | `[-r owner/repo] [-b branch]` | + +CI queue management is in `~/.config/mosaic/tools/git/`: + +| Script | Purpose | Key flags | +| ------------------ | ---------------------- | ------------------------------------------------ | +| `ci-queue-wait.sh` | Wait for CI queue slot | `[-t timeout_sec] [-B branch] [--purpose merge]` | +| `pr-ci-wait.sh` | Wait for PR CI to pass | `-n <pr#> [-t timeout]` | + +## Common Workflows + +**Check latest pipeline on current repo:** + +```bash +cd ~/src/<repo> +source ~/.config/mosaic/tools/_lib/credentials.sh && load_credentials woodpecker +~/.config/mosaic/tools/woodpecker/pipeline-status.sh +``` + +**Check specific pipeline number:** + +```bash +~/.config/mosaic/tools/woodpecker/pipeline-status.sh -n 42 +``` + +**Wait for CI before merging:** + +```bash +cd ~/src/<repo> +~/.config/mosaic/tools/git/pr-ci-wait.sh -n <pr#> +``` + +**Trigger a pipeline on a branch:** + +```bash +~/.config/mosaic/tools/woodpecker/pipeline-trigger.sh -r mosaic/mosaic-stack -b main +``` + +## Notes + +- CI URL: `https://ci.mosaicstack.dev` +- Credentials: `load_credentials woodpecker` (framework credentials store) +- `ci-queue-wait.sh` is automatically called by `pr-merge.sh` unless `--skip-queue-guard` is passed +- Pipeline output shows step-level status: OK / FAIL / RUN / SKIP / WAIT diff --git a/packages/mosaic/framework/skills/ms-unslop/SKILL.md b/packages/mosaic/framework/skills/ms-unslop/SKILL.md new file mode 100644 index 00000000..f4c54c12 --- /dev/null +++ b/packages/mosaic/framework/skills/ms-unslop/SKILL.md @@ -0,0 +1,84 @@ +--- +name: ms-unslop +description: Cut AI tells from any writing. Must always apply. +--- + +# Unslop + +Edit text to remove AI patterns and add human voice. + +## Process + +1. Scan for the patterns below. +2. Rewrite. Preserve meaning, match intended tone. +3. Add soul (see next section). +4. Self-audit: "What makes this obviously AI generated?" Fix remaining tells. + +## Adding soul + +Removing patterns is half the job. Sterile, voiceless writing is just as obvious. + +- **Have opinions.** React to facts instead of neutrally listing pros and cons. +- **Vary rhythm.** Short sentences. Then longer ones that take their time. Mix it up. +- **Acknowledge complexity.** "Impressive but also kind of unsettling" beats "impressive." +- **Use "I" when it fits.** First person isn't unprofessional. +- **Let some mess in.** Perfect structure looks machine-made. +- **Be specific.** Not "this is concerning" but "there's something unsettling about agents churning away at 3am." + +## Patterns to detect and fix + +### Content + +1. **Puffery.** `pivotal moment`, `testament to`, "evolving landscape", "setting the stage for", "indelible mark", "deeply rooted". Cut puffery, state what happened. +2. **Name-dropping.** Listing media outlets without context. Pick one, say what was said. +3. **Superficial -ing phrases.** "highlighting...", "ensuring...", "reflecting...", "showcasing...", "fostering...". Delete or expand with real sources. +4. **Promotional language.** "nestled", `vibrant`, "breathtaking", "groundbreaking", "renowned", "stunning", "must-visit". Use neutral descriptions. +5. **Vague attributions.** "Experts believe", "Industry reports suggest", "Some critics argue". Name the source or delete. +6. **Formulaic challenges.** "Despite challenges... continues to thrive." Replace with specific facts. + +### Language + +7. **AI vocabulary.** `Additionally`, `crucial`, `delve`, `enduring`, `enhance`, `fostering`, `garner`, `interplay`, `intricate`, `landscape` (abstract), `pivotal`, `showcase`, `tapestry` (abstract), `testament`, `underscore`, `vibrant`. Replace with plain words. +8. **Fancy ways to say "is".** "serves as", "stands as", "boasts", "features". Just say "is" or "has". +9. **`Not just X, but Y`.** State the point directly instead. +10. **Rule of three.** Forcing ideas into groups of three. Use the natural number. +11. **Synonym cycling.** Protagonist, main character, central figure, hero all in one paragraph. Pick one, repeat it. +12. **False ranges.** "from X to Y" where X and Y aren't on a meaningful scale. List topics directly. + +### Style + +13. **Em dash overuse.** Avoid em dashes entirely. Use periods or commas only (no parentheses, no en dashes, no hyphen-as-dash substitutes). Em dashes are an AI tell, and reaching for parentheses instead just trades one tell for another. If a thought needs separation, end the sentence or use a comma. +14. **Colon overuse.** Colons are fine before a list or example. Not as mid-sentence connectors. "If you're coming from traditional automation: instead of registering event handlers, you describe conditions" adds nothing with the colon. Rewrite to let the point stand on its own without comparison framing. "Describing when the scheduler should fire works best as plain English." Same meaning, no crutch punctuation. +15. **Boldface overuse.** Don't bold every proper noun or acronym. +16. **Inline-header lists.** The tell is a bold label and colon that restates the line: "**Performance:** Performance improved...". Convert those to prose. A bold lead-in that ends in a period, names the item, and is followed by genuinely new detail ("**Schema in TypeScript.** Tables live in one file.") is fine, not a tell. +17. **Title case headings.** Use sentence case. +18. **Decorative emojis.** Remove from headings and bullets. +19. **Curly quotes.** Replace with straight quotes. + +### Communication artifacts + +20. **Chatbot phrases.** `I hope this helps!`, `Let me know if...`, `Of course!`, `Certainly!`, `Found the smoking gun!` Remove. +21. **Cutoff disclaimers.** "While specific details are limited..." Find sources or remove. +22. **Sycophantic tone.** `Great question!` `You're absolutely right!` Respond directly. + +### Filler + +23. **Filler phrases.** `In order to` becomes "To". `Due to the fact that` becomes "Because". `It is important to note that` gets deleted. +24. **Excessive hedging.** "could potentially possibly be argued that it might" becomes "may". +25. **Generic conclusions.** "The future looks bright." State specific plans or facts. + +### Jargon + +26. **Abstract metaphor nouns.** Substrate, wedge, vector, locus, vantage, nexus, primitive (as noun), harness (as metaphor), surface (as in "API surface"), bedrock, scaffolding (as metaphor), modality, paradigm, gold-plating, ratchet (as metaphor), evacuate (for moving code), endgame, north star, flywheel. These read as technical but usually have a plainer concrete word. "Substrate" becomes "base". "Wedge in" becomes "add". "Vector" becomes "way" or "method". "Gold-plating" becomes "more than the job needs". "Ratchet" becomes the mechanism's real name or "a limit that only tightens". "Evacuate" becomes "move out". "Endgame" becomes "the last phase". Pick the concrete word. + +### Plain speech + +27. **Say what it does, not how it feels.** "the database stays close at hand", "SQL you can read", "types that follow your schema" name a feeling. The fix names the mechanism or a number: "`.toSQL()` returns the exact string sent to the database", "a column rename fails the build". Ask what the sentence tells the reader to do or know, then write that. If you can't restate it as a concrete instruction, fact, or number, cut it. One more check: if the sentence could appear unchanged in another project's docs, it says nothing about this one. Cut it. +28. **Shorten or split dense sentences.** If the reader has to backtrack to parse a sentence, break it in two or drop clauses. One idea per sentence. +29. **Active voice.** Prefer it. Catch "is/are/was/were + past participle" and name the actor: "queries are validated" becomes "the compiler validates queries", "the file is parsed by the loader" becomes "the loader parses the file". Passive is fine only when the actor is unknown or genuinely doesn't matter. +30. **Cut adverbs, or use a stronger verb.** "runs quickly" becomes "is fast" or the number. "significantly improves" becomes the measured delta. An adverb propping up a weak verb means the verb is wrong. +31. **Prefer the plain word.** `utilize` becomes "use", `leverage` becomes "use", `facilitate` becomes "help", "numerous" becomes "many", "in the event that" becomes "if". The fancier synonym is rarely clearer. + +## Mention convention + +A document that MENTIONS a banned word or phrase quotes it as inline code. The checker (`tools/unslop-hook/unslop-check.js`, machine source `tools/unslop-hook/lists.json`) strips code spans before matching, so a backticked mention is invisible to the gate while a bare one flags. This file follows that convention and doubles as a regression fixture: if `unslop-check.js` ever flags this file, either an edit broke the mention convention or code stripping regressed. Documents that deliberately CONTAIN slop to test detection (fixture files) are uses, not mentions; they are expected to flag. diff --git a/packages/mosaic/framework/skills/nestjs-best-practices/AGENTS.md b/packages/mosaic/framework/skills/nestjs-best-practices/AGENTS.md new file mode 100644 index 00000000..334b2751 --- /dev/null +++ b/packages/mosaic/framework/skills/nestjs-best-practices/AGENTS.md @@ -0,0 +1,5898 @@ +# NestJS Best Practices + +**Version 1.1.0** +NestJS Best Practices +January 2026 + +> **Note:** +> This document is mainly for agents and LLMs to follow when maintaining, +> generating, or refactoring NestJS codebases. Humans may also find it +> useful, but guidance here is optimized for automation and consistency +> by AI-assisted workflows. + +--- + +## Abstract + +Comprehensive best practices and architecture guide for NestJS applications, designed for AI agents and LLMs. Contains 40 rules across 10 categories, prioritized by impact from critical (architecture, dependency injection) to incremental (DevOps patterns). Each rule includes detailed explanations, real-world examples comparing incorrect vs. correct implementations, and specific impact metrics to guide automated refactoring and code generation. + +--- + +## Table of Contents + +1. [Architecture](#1-architecture) — **CRITICAL** + - 1.1 [Avoid Circular Dependencies](#11-avoid-circular-dependencies) + - 1.2 [Organize by Feature Modules](#12-organize-by-feature-modules) + - 1.3 [Use Proper Module Sharing Patterns](#13-use-proper-module-sharing-patterns) + - 1.4 [Single Responsibility for Services](#14-single-responsibility-for-services) + - 1.5 [Use Event-Driven Architecture for Decoupling](#15-use-event-driven-architecture-for-decoupling) + - 1.6 [Use Repository Pattern for Data Access](#16-use-repository-pattern-for-data-access) +2. [Dependency Injection](#2-dependency-injection) — **CRITICAL** + - 2.1 [Avoid Service Locator Anti-Pattern](#21-avoid-service-locator-anti-pattern) + - 2.2 [Apply Interface Segregation Principle](#22-apply-interface-segregation-principle) + - 2.3 [Honor Liskov Substitution Principle](#23-honor-liskov-substitution-principle) + - 2.4 [Prefer Constructor Injection](#24-prefer-constructor-injection) + - 2.5 [Understand Provider Scopes](#25-understand-provider-scopes) + - 2.6 [Use Injection Tokens for Interfaces](#26-use-injection-tokens-for-interfaces) +3. [Error Handling](#3-error-handling) — **HIGH** + - 3.1 [Handle Async Errors Properly](#31-handle-async-errors-properly) + - 3.2 [Throw HTTP Exceptions from Services](#32-throw-http-exceptions-from-services) + - 3.3 [Use Exception Filters for Error Handling](#33-use-exception-filters-for-error-handling) +4. [Security](#4-security) — **HIGH** + - 4.1 [Implement Secure JWT Authentication](#41-implement-secure-jwt-authentication) + - 4.2 [Implement Rate Limiting](#42-implement-rate-limiting) + - 4.3 [Sanitize Output to Prevent XSS](#43-sanitize-output-to-prevent-xss) + - 4.4 [Use Guards for Authentication and Authorization](#44-use-guards-for-authentication-and-authorization) + - 4.5 [Validate All Input with DTOs and Pipes](#45-validate-all-input-with-dtos-and-pipes) +5. [Performance](#5-performance) — **HIGH** + - 5.1 [Use Async Lifecycle Hooks Correctly](#51-use-async-lifecycle-hooks-correctly) + - 5.2 [Use Lazy Loading for Large Modules](#52-use-lazy-loading-for-large-modules) + - 5.3 [Optimize Database Queries](#53-optimize-database-queries) + - 5.4 [Use Caching Strategically](#54-use-caching-strategically) +6. [Testing](#6-testing) — **MEDIUM-HIGH** + - 6.1 [Use Supertest for E2E Testing](#61-use-supertest-for-e2e-testing) + - 6.2 [Mock External Services in Tests](#62-mock-external-services-in-tests) + - 6.3 [Use Testing Module for Unit Tests](#63-use-testing-module-for-unit-tests) +7. [Database & ORM](#7-database-orm) — **MEDIUM-HIGH** + - 7.1 [Avoid N+1 Query Problems](#71-avoid-n-1-query-problems) + - 7.2 [Use Database Migrations](#72-use-database-migrations) + - 7.3 [Use Transactions for Multi-Step Operations](#73-use-transactions-for-multi-step-operations) +8. [API Design](#8-api-design) — **MEDIUM** + - 8.1 [Use DTOs and Serialization for API Responses](#81-use-dtos-and-serialization-for-api-responses) + - 8.2 [Use Interceptors for Cross-Cutting Concerns](#82-use-interceptors-for-cross-cutting-concerns) + - 8.3 [Use Pipes for Input Transformation](#83-use-pipes-for-input-transformation) + - 8.4 [Use API Versioning for Breaking Changes](#84-use-api-versioning-for-breaking-changes) +9. [Microservices](#9-microservices) — **MEDIUM** + - 9.1 [Implement Health Checks for Microservices](#91-implement-health-checks-for-microservices) + - 9.2 [Use Message and Event Patterns Correctly](#92-use-message-and-event-patterns-correctly) + - 9.3 [Use Message Queues for Background Jobs](#93-use-message-queues-for-background-jobs) +10. [DevOps & Deployment](#10-devops-deployment) — **LOW-MEDIUM** + +- 10.1 [Implement Graceful Shutdown](#101-implement-graceful-shutdown) +- 10.2 [Use ConfigModule for Environment Configuration](#102-use-configmodule-for-environment-configuration) +- 10.3 [Use Structured Logging](#103-use-structured-logging) + +--- + +## 1. Architecture + +**Section Impact: CRITICAL** + +### 1.1 Avoid Circular Dependencies + +**Impact: CRITICAL** — "#1 cause of runtime crashes" + +Circular dependencies occur when Module A imports Module B, and Module B imports Module A (directly or transitively). NestJS can sometimes resolve these through forward references, but they indicate architectural problems and should be avoided. This is the #1 cause of runtime crashes in NestJS applications. + +**Incorrect (circular module imports):** + +```typescript +// users.module.ts +@Module({ + imports: [OrdersModule], // Orders needs Users, Users needs Orders = circular + providers: [UsersService], + exports: [UsersService], +}) +export class UsersModule {} + +// orders.module.ts +@Module({ + imports: [UsersModule], // Circular dependency! + providers: [OrdersService], + exports: [OrdersService], +}) +export class OrdersModule {} +``` + +**Correct (extract shared logic or use events):** + +```typescript +// Option 1: Extract shared logic to a third module +// shared.module.ts +@Module({ + providers: [SharedService], + exports: [SharedService], +}) +export class SharedModule {} + +// users.module.ts +@Module({ + imports: [SharedModule], + providers: [UsersService], +}) +export class UsersModule {} + +// orders.module.ts +@Module({ + imports: [SharedModule], + providers: [OrdersService], +}) +export class OrdersModule {} + +// Option 2: Use events for decoupled communication +// users.service.ts +@Injectable() +export class UsersService { + constructor(private eventEmitter: EventEmitter2) {} + + async createUser(data: CreateUserDto) { + const user = await this.userRepo.save(data); + this.eventEmitter.emit('user.created', user); + return user; + } +} + +// orders.service.ts +@Injectable() +export class OrdersService { + @OnEvent('user.created') + handleUserCreated(user: User) { + // React to user creation without direct dependency + } +} +``` + +Reference: [NestJS Circular Dependency](https://docs.nestjs.com/fundamentals/circular-dependency) + +--- + +### 1.2 Organize by Feature Modules + +**Impact: CRITICAL** — "3-5x faster onboarding and development" + +Organize your application into feature modules that encapsulate related functionality. Each feature module should be self-contained with its own controllers, services, entities, and DTOs. Avoid organizing by technical layer (all controllers together, all services together). This enables 3-5x faster onboarding and feature development. + +**Incorrect (technical layer organization):** + +```typescript +// Technical layer organization (anti-pattern) +src/ +├── controllers/ +│ ├── users.controller.ts +│ ├── orders.controller.ts +│ └── products.controller.ts +├── services/ +│ ├── users.service.ts +│ ├── orders.service.ts +│ └── products.service.ts +├── entities/ +│ ├── user.entity.ts +│ ├── order.entity.ts +│ └── product.entity.ts +└── app.module.ts // Imports everything directly +``` + +**Correct (feature module organization):** + +```typescript +// Feature module organization +src/ +├── users/ +│ ├── dto/ +│ │ ├── create-user.dto.ts +│ │ └── update-user.dto.ts +│ ├── entities/ +│ │ └── user.entity.ts +│ ├── users.controller.ts +│ ├── users.service.ts +│ ├── users.repository.ts +│ └── users.module.ts +├── orders/ +│ ├── dto/ +│ ├── entities/ +│ ├── orders.controller.ts +│ ├── orders.service.ts +│ └── orders.module.ts +├── shared/ +│ ├── guards/ +│ ├── interceptors/ +│ ├── filters/ +│ └── shared.module.ts +└── app.module.ts + +// users.module.ts +@Module({ + imports: [TypeOrmModule.forFeature([User])], + controllers: [UsersController], + providers: [UsersService, UsersRepository], + exports: [UsersService], // Only export what others need +}) +export class UsersModule {} + +// app.module.ts +@Module({ + imports: [ + ConfigModule.forRoot(), + TypeOrmModule.forRoot(), + UsersModule, + OrdersModule, + SharedModule, + ], +}) +export class AppModule {} +``` + +Reference: [NestJS Modules](https://docs.nestjs.com/modules) + +--- + +### 1.3 Use Proper Module Sharing Patterns + +**Impact: CRITICAL** — Prevents duplicate instances, memory leaks, and state inconsistency + +NestJS modules are singletons by default. When a service is properly exported from a module and that module is imported elsewhere, the same instance is shared. However, providing a service in multiple modules creates separate instances, leading to memory waste, state inconsistency, and confusing behavior. Always encapsulate services in dedicated modules, export them explicitly, and import the module where needed. + +**Incorrect (service provided in multiple modules):** + +```typescript +// StorageService provided directly in multiple modules - WRONG +// storage.service.ts +@Injectable() +export class StorageService { + private cache = new Map(); // Each instance has separate state! + + store(key: string, value: any) { + this.cache.set(key, value); + } +} + +// app.module.ts +@Module({ + providers: [StorageService], // Instance #1 + controllers: [AppController], +}) +export class AppModule {} + +// videos.module.ts +@Module({ + providers: [StorageService], // Instance #2 - different from AppModule! + controllers: [VideosController], +}) +export class VideosModule {} + +// Problems: +// 1. Two separate StorageService instances exist +// 2. cache.set() in VideosModule doesn't affect AppModule's cache +// 3. Memory wasted on duplicate instances +// 4. Debugging nightmares when state doesn't sync +``` + +**Correct (dedicated module with exports):** + +```typescript +// storage/storage.module.ts +@Module({ + providers: [StorageService], + exports: [StorageService], // Make available to importers +}) +export class StorageModule {} + +// videos/videos.module.ts +@Module({ + imports: [StorageModule], // Import the module, not the service + controllers: [VideosController], + providers: [VideosService], +}) +export class VideosModule {} + +// channels/channels.module.ts +@Module({ + imports: [StorageModule], // Same instance shared + controllers: [ChannelsController], + providers: [ChannelsService], +}) +export class ChannelsModule {} + +// app.module.ts +@Module({ + imports: [ + StorageModule, // Only if AppModule itself needs StorageService + VideosModule, + ChannelsModule, + ], +}) +export class AppModule {} + +// Now all modules share the SAME StorageService instance +``` + +**When to use @Global() (sparingly):** + +```typescript +// ONLY for truly cross-cutting concerns +@Global() +@Module({ + providers: [ConfigService, LoggerService], + exports: [ConfigService, LoggerService], +}) +export class CoreModule {} + +// Import once in AppModule +@Module({ + imports: [CoreModule], // Registered globally, available everywhere +}) +export class AppModule {} + +// Other modules don't need to import CoreModule +@Module({ + controllers: [UsersController], + providers: [UsersService], // Can inject ConfigService without importing +}) +export class UsersModule {} + +// WARNING: Don't make everything global! +// - Hides dependencies (can't see what a module needs from imports) +// - Makes testing harder +// - Reserve for: config, logging, database connections +``` + +**Module re-exporting pattern:** + +```typescript +// common.module.ts - shared utilities +@Module({ + providers: [DateService, ValidationService], + exports: [DateService, ValidationService], +}) +export class CommonModule {} + +// core.module.ts - re-exports common for convenience +@Module({ + imports: [CommonModule, DatabaseModule], + exports: [CommonModule, DatabaseModule], // Re-export for consumers +}) +export class CoreModule {} + +// feature.module.ts - imports CoreModule, gets both +@Module({ + imports: [CoreModule], // Gets CommonModule + DatabaseModule + controllers: [FeatureController], +}) +export class FeatureModule {} +``` + +Reference: [NestJS Modules](https://docs.nestjs.com/modules#shared-modules) + +--- + +### 1.4 Single Responsibility for Services + +**Impact: CRITICAL** — "40%+ improvement in testability" + +Each service should have a single, well-defined responsibility. Avoid "god services" that handle multiple unrelated concerns. If a service name includes "And" or handles more than one domain concept, it likely violates single responsibility. This reduces complexity and improves testability by 40%+. + +**Incorrect (god service anti-pattern):** + +```typescript +// God service anti-pattern +@Injectable() +export class UserAndOrderService { + constructor( + private userRepo: UserRepository, + private orderRepo: OrderRepository, + private mailer: MailService, + private payment: PaymentService, + ) {} + + async createUser(dto: CreateUserDto) { + const user = await this.userRepo.save(dto); + await this.mailer.sendWelcome(user); + return user; + } + + async createOrder(userId: string, dto: CreateOrderDto) { + const order = await this.orderRepo.save({ userId, ...dto }); + await this.payment.charge(order); + await this.mailer.sendOrderConfirmation(order); + return order; + } + + async calculateOrderStats(userId: string) { + // Stats logic mixed in + } + + async validatePayment(orderId: string) { + // Payment logic mixed in + } +} +``` + +**Correct (focused services with single responsibility):** + +```typescript +// Focused services with single responsibility +@Injectable() +export class UsersService { + constructor(private userRepo: UserRepository) {} + + async create(dto: CreateUserDto): Promise<User> { + return this.userRepo.save(dto); + } + + async findById(id: string): Promise<User> { + return this.userRepo.findOneOrFail({ where: { id } }); + } +} + +@Injectable() +export class OrdersService { + constructor(private orderRepo: OrderRepository) {} + + async create(userId: string, dto: CreateOrderDto): Promise<Order> { + return this.orderRepo.save({ userId, ...dto }); + } + + async findByUser(userId: string): Promise<Order[]> { + return this.orderRepo.find({ where: { userId } }); + } +} + +@Injectable() +export class OrderStatsService { + constructor(private orderRepo: OrderRepository) {} + + async calculateForUser(userId: string): Promise<OrderStats> { + // Focused stats calculation + } +} + +// Orchestration in controller or dedicated orchestrator +@Controller('orders') +export class OrdersController { + constructor( + private orders: OrdersService, + private payment: PaymentService, + private notifications: NotificationService, + ) {} + + @Post() + async create(@CurrentUser() user: User, @Body() dto: CreateOrderDto) { + const order = await this.orders.create(user.id, dto); + await this.payment.charge(order); + await this.notifications.sendOrderConfirmation(order); + return order; + } +} +``` + +Reference: [NestJS Providers](https://docs.nestjs.com/providers) + +--- + +### 1.5 Use Event-Driven Architecture for Decoupling + +**Impact: MEDIUM-HIGH** — Enables async processing and modularity + +Use `@nestjs/event-emitter` for intra-service events and message brokers for inter-service communication. Events allow modules to react to changes without direct dependencies, improving modularity and enabling async processing. + +**Incorrect (direct service coupling):** + +```typescript +// Direct service coupling +@Injectable() +export class OrdersService { + constructor( + private inventoryService: InventoryService, + private emailService: EmailService, + private analyticsService: AnalyticsService, + private notificationService: NotificationService, + private loyaltyService: LoyaltyService, + ) {} + + async createOrder(dto: CreateOrderDto): Promise<Order> { + const order = await this.repo.save(dto); + + // Tight coupling - OrdersService knows about all consumers + await this.inventoryService.reserve(order.items); + await this.emailService.sendConfirmation(order); + await this.analyticsService.track('order_created', order); + await this.notificationService.push(order.userId, 'Order placed'); + await this.loyaltyService.addPoints(order.userId, order.total); + + // Adding new behavior requires modifying this service + return order; + } +} +``` + +**Correct (event-driven decoupling):** + +```typescript +// Use EventEmitter for decoupling +import { EventEmitter2 } from '@nestjs/event-emitter'; + +// Define event +export class OrderCreatedEvent { + constructor( + public readonly orderId: string, + public readonly userId: string, + public readonly items: OrderItem[], + public readonly total: number, + ) {} +} + +// Service emits events +@Injectable() +export class OrdersService { + constructor( + private eventEmitter: EventEmitter2, + private repo: Repository<Order>, + ) {} + + async createOrder(dto: CreateOrderDto): Promise<Order> { + const order = await this.repo.save(dto); + + // Emit event - no knowledge of consumers + this.eventEmitter.emit( + 'order.created', + new OrderCreatedEvent(order.id, order.userId, order.items, order.total), + ); + + return order; + } +} + +// Listeners in separate modules +@Injectable() +export class InventoryListener { + @OnEvent('order.created') + async handleOrderCreated(event: OrderCreatedEvent): Promise<void> { + await this.inventoryService.reserve(event.items); + } +} + +@Injectable() +export class EmailListener { + @OnEvent('order.created') + async handleOrderCreated(event: OrderCreatedEvent): Promise<void> { + await this.emailService.sendConfirmation(event.orderId); + } +} + +@Injectable() +export class AnalyticsListener { + @OnEvent('order.created') + async handleOrderCreated(event: OrderCreatedEvent): Promise<void> { + await this.analyticsService.track('order_created', { + orderId: event.orderId, + total: event.total, + }); + } +} +``` + +Reference: [NestJS Events](https://docs.nestjs.com/techniques/events) + +--- + +### 1.6 Use Repository Pattern for Data Access + +**Impact: HIGH** — Decouples business logic from database + +Create custom repositories to encapsulate complex queries and database logic. This keeps services focused on business logic, makes testing easier with mock repositories, and allows changing database implementations without affecting business code. + +**Incorrect (complex queries in services):** + +```typescript +// Complex queries in services +@Injectable() +export class UsersService { + constructor(@InjectRepository(User) private repo: Repository<User>) {} + + async findActiveWithOrders(minOrders: number): Promise<User[]> { + // Complex query logic mixed with business logic + return this.repo + .createQueryBuilder('user') + .leftJoinAndSelect('user.orders', 'order') + .where('user.isActive = :active', { active: true }) + .andWhere('user.deletedAt IS NULL') + .groupBy('user.id') + .having('COUNT(order.id) >= :min', { min: minOrders }) + .orderBy('user.createdAt', 'DESC') + .getMany(); + } + + // Service becomes bloated with query logic +} +``` + +**Correct (custom repository with encapsulated queries):** + +```typescript +// Custom repository with encapsulated queries +@Injectable() +export class UsersRepository { + constructor(@InjectRepository(User) private repo: Repository<User>) {} + + async findById(id: string): Promise<User | null> { + return this.repo.findOne({ where: { id } }); + } + + async findByEmail(email: string): Promise<User | null> { + return this.repo.findOne({ where: { email } }); + } + + async findActiveWithMinOrders(minOrders: number): Promise<User[]> { + return this.repo + .createQueryBuilder('user') + .leftJoinAndSelect('user.orders', 'order') + .where('user.isActive = :active', { active: true }) + .andWhere('user.deletedAt IS NULL') + .groupBy('user.id') + .having('COUNT(order.id) >= :min', { min: minOrders }) + .orderBy('user.createdAt', 'DESC') + .getMany(); + } + + async save(user: User): Promise<User> { + return this.repo.save(user); + } +} + +// Clean service with business logic only +@Injectable() +export class UsersService { + constructor(private usersRepo: UsersRepository) {} + + async getActiveUsersWithOrders(): Promise<User[]> { + return this.usersRepo.findActiveWithMinOrders(1); + } + + async create(dto: CreateUserDto): Promise<User> { + const existing = await this.usersRepo.findByEmail(dto.email); + if (existing) { + throw new ConflictException('Email already registered'); + } + + const user = new User(); + user.email = dto.email; + user.name = dto.name; + return this.usersRepo.save(user); + } +} +``` + +Reference: [Repository Pattern](https://martinfowler.com/eaaCatalog/repository.html) + +--- + +## 2. Dependency Injection + +**Section Impact: CRITICAL** + +### 2.1 Avoid Service Locator Anti-Pattern + +**Impact: HIGH** — Hides dependencies and breaks testability + +Avoid using `ModuleRef.get()` or global containers to resolve dependencies at runtime. This hides dependencies, makes code harder to test, and breaks the benefits of dependency injection. Use constructor injection instead. + +**Incorrect (service locator anti-pattern):** + +```typescript +// Use ModuleRef to get dependencies dynamically +@Injectable() +export class OrdersService { + constructor(private moduleRef: ModuleRef) {} + + async createOrder(dto: CreateOrderDto): Promise<Order> { + // Dependencies are hidden - not visible in constructor + const usersService = this.moduleRef.get(UsersService); + const inventoryService = this.moduleRef.get(InventoryService); + const paymentService = this.moduleRef.get(PaymentService); + + const user = await usersService.findOne(dto.userId); + // ... rest of logic + } +} + +// Global singleton container +class ServiceContainer { + private static instance: ServiceContainer; + private services = new Map<string, any>(); + + static getInstance(): ServiceContainer { + if (!this.instance) { + this.instance = new ServiceContainer(); + } + return this.instance; + } + + get<T>(key: string): T { + return this.services.get(key); + } +} +``` + +**Correct (constructor injection with explicit dependencies):** + +```typescript +// Use constructor injection - dependencies are explicit +@Injectable() +export class OrdersService { + constructor( + private usersService: UsersService, + private inventoryService: InventoryService, + private paymentService: PaymentService, + ) {} + + async createOrder(dto: CreateOrderDto): Promise<Order> { + const user = await this.usersService.findOne(dto.userId); + const inventory = await this.inventoryService.check(dto.items); + // Dependencies are clear and testable + } +} + +// Easy to test with mocks +describe('OrdersService', () => { + let service: OrdersService; + + beforeEach(async () => { + const module = await Test.createTestingModule({ + providers: [ + OrdersService, + { provide: UsersService, useValue: mockUsersService }, + { provide: InventoryService, useValue: mockInventoryService }, + { provide: PaymentService, useValue: mockPaymentService }, + ], + }).compile(); + + service = module.get(OrdersService); + }); +}); + +// VALID: Factory pattern for dynamic instantiation +@Injectable() +export class HandlerFactory { + constructor(private moduleRef: ModuleRef) {} + + getHandler(type: string): Handler { + switch (type) { + case 'email': + return this.moduleRef.get(EmailHandler); + case 'sms': + return this.moduleRef.get(SmsHandler); + default: + return this.moduleRef.get(DefaultHandler); + } + } +} +``` + +Reference: [NestJS Module Reference](https://docs.nestjs.com/fundamentals/module-ref) + +--- + +### 2.2 Apply Interface Segregation Principle + +**Impact: HIGH** — Reduces coupling and improves testability by 30-50% + +Clients should not be forced to depend on interfaces they don't use. In NestJS, this means keeping interfaces small and focused on specific capabilities rather than creating "fat" interfaces that bundle unrelated methods. When a service only needs to send emails, it shouldn't depend on an interface that also includes SMS, push notifications, and logging. Split large interfaces into role-based ones. + +**Incorrect (fat interface forcing unused dependencies):** + +```typescript +// Fat interface - forces all consumers to depend on everything +interface NotificationService { + sendEmail(to: string, subject: string, body: string): Promise<void>; + sendSms(phone: string, message: string): Promise<void>; + sendPush(userId: string, notification: PushPayload): Promise<void>; + sendSlack(channel: string, message: string): Promise<void>; + logNotification(type: string, payload: any): Promise<void>; + getDeliveryStatus(id: string): Promise<DeliveryStatus>; + retryFailed(id: string): Promise<void>; + scheduleNotification(dto: ScheduleDto): Promise<string>; +} + +// Consumer only needs email, but must mock everything for tests +@Injectable() +export class OrdersService { + constructor( + private notifications: NotificationService, // Depends on 8 methods, uses 1 + ) {} + + async confirmOrder(order: Order): Promise<void> { + await this.notifications.sendEmail( + order.customer.email, + 'Order Confirmed', + `Your order ${order.id} has been confirmed.`, + ); + } +} + +// Testing is painful - must mock unused methods +const mockNotificationService = { + sendEmail: jest.fn(), + sendSms: jest.fn(), // Never used, but required + sendPush: jest.fn(), // Never used, but required + sendSlack: jest.fn(), // Never used, but required + logNotification: jest.fn(), // Never used, but required + getDeliveryStatus: jest.fn(), // Never used, but required + retryFailed: jest.fn(), // Never used, but required + scheduleNotification: jest.fn(), // Never used, but required +}; +``` + +**Correct (segregated interfaces by capability):** + +```typescript +// Segregated interfaces - each focused on one capability +interface EmailSender { + sendEmail(to: string, subject: string, body: string): Promise<void>; +} + +interface SmsSender { + sendSms(phone: string, message: string): Promise<void>; +} + +interface PushSender { + sendPush(userId: string, notification: PushPayload): Promise<void>; +} + +interface NotificationLogger { + logNotification(type: string, payload: any): Promise<void>; +} + +interface NotificationScheduler { + scheduleNotification(dto: ScheduleDto): Promise<string>; +} + +// Implementation can implement multiple interfaces +@Injectable() +export class NotificationService implements EmailSender, SmsSender, PushSender { + async sendEmail(to: string, subject: string, body: string): Promise<void> { + // Email implementation + } + + async sendSms(phone: string, message: string): Promise<void> { + // SMS implementation + } + + async sendPush(userId: string, notification: PushPayload): Promise<void> { + // Push implementation + } +} + +// Or separate implementations +@Injectable() +export class SendGridEmailService implements EmailSender { + async sendEmail(to: string, subject: string, body: string): Promise<void> { + // SendGrid-specific implementation + } +} + +// Consumer depends only on what it needs +@Injectable() +export class OrdersService { + constructor( + @Inject(EMAIL_SENDER) private emailSender: EmailSender, // Minimal dependency + ) {} + + async confirmOrder(order: Order): Promise<void> { + await this.emailSender.sendEmail( + order.customer.email, + 'Order Confirmed', + `Your order ${order.id} has been confirmed.`, + ); + } +} + +// Testing is simple - only mock what's used +const mockEmailSender: EmailSender = { + sendEmail: jest.fn(), +}; + +// Module registration with tokens +export const EMAIL_SENDER = Symbol('EMAIL_SENDER'); +export const SMS_SENDER = Symbol('SMS_SENDER'); + +@Module({ + providers: [ + { provide: EMAIL_SENDER, useClass: SendGridEmailService }, + { provide: SMS_SENDER, useClass: TwilioSmsService }, + ], + exports: [EMAIL_SENDER, SMS_SENDER], +}) +export class NotificationModule {} +``` + +**Combining interfaces when needed:** + +```typescript +// Sometimes a consumer legitimately needs multiple capabilities +interface EmailAndSmsSender extends EmailSender, SmsSender {} + +// Or use intersection types +type MultiChannelSender = EmailSender & SmsSender & PushSender; + +// Consumer that genuinely needs multiple channels +@Injectable() +export class AlertService { + constructor( + @Inject(MULTI_CHANNEL_SENDER) + private sender: EmailSender & SmsSender, + ) {} + + async sendCriticalAlert(user: User, message: string): Promise<void> { + await Promise.all([ + this.sender.sendEmail(user.email, 'Critical Alert', message), + this.sender.sendSms(user.phone, message), + ]); + } +} +``` + +Reference: [Interface Segregation Principle](https://en.wikipedia.org/wiki/Interface_segregation_principle) + +--- + +### 2.3 Honor Liskov Substitution Principle + +**Impact: HIGH** — Ensures implementations are truly interchangeable without breaking callers + +Subtypes must be substitutable for their base types without altering program correctness. In NestJS with dependency injection, this means any implementation of an interface or abstract class must honor the contract completely. A mock payment service used in tests must behave like a real payment service (return similar shapes, handle errors the same way). Violating LSP causes subtle bugs when swapping implementations. + +**Incorrect (implementation violates the contract):** + +```typescript +// Base interface with clear contract +interface PaymentGateway { + /** + * Charges the specified amount. + * @returns PaymentResult on success + * @throws PaymentFailedException on payment failure + */ + charge(amount: number, currency: string): Promise<PaymentResult>; +} + +// Production implementation - follows the contract +@Injectable() +export class StripeService implements PaymentGateway { + async charge(amount: number, currency: string): Promise<PaymentResult> { + const response = await this.stripe.charges.create({ amount, currency }); + return { success: true, transactionId: response.id, amount }; + } +} + +// Mock that violates LSP - different behavior! +@Injectable() +export class MockPaymentService implements PaymentGateway { + async charge(amount: number, currency: string): Promise<PaymentResult> { + // VIOLATION 1: Throws for valid input (contract says return PaymentResult) + if (amount > 1000) { + throw new Error('Mock does not support large amounts'); + } + + // VIOLATION 2: Returns null instead of PaymentResult + if (currency !== 'USD') { + return null as any; // Real service would convert or reject properly + } + + // VIOLATION 3: Missing required field + return { success: true } as PaymentResult; // Missing transactionId! + } +} + +// Consumer trusts the contract +@Injectable() +export class OrdersService { + constructor(@Inject(PAYMENT_GATEWAY) private payment: PaymentGateway) {} + + async checkout(order: Order): Promise<void> { + const result = await this.payment.charge(order.total, order.currency); + // These fail with MockPaymentService: + await this.saveTransaction(result.transactionId); // undefined! + await this.sendReceipt(result); // might be null! + } +} +``` + +**Correct (implementations honor the contract):** + +```typescript +// Well-defined interface with documented behavior +interface PaymentGateway { + /** + * Charges the specified amount. + * @param amount - Amount in smallest currency unit (cents) + * @param currency - ISO 4217 currency code + * @returns PaymentResult with transactionId, success status, and amount + * @throws PaymentFailedException if charge is declined + * @throws InvalidCurrencyException if currency is not supported + */ + charge(amount: number, currency: string): Promise<PaymentResult>; + + /** + * Refunds a previous charge. + * @throws TransactionNotFoundException if transactionId is invalid + */ + refund(transactionId: string, amount?: number): Promise<RefundResult>; +} + +// Production implementation +@Injectable() +export class StripeService implements PaymentGateway { + async charge(amount: number, currency: string): Promise<PaymentResult> { + try { + const response = await this.stripe.charges.create({ amount, currency }); + return { + success: true, + transactionId: response.id, + amount: response.amount, + }; + } catch (error) { + if (error.type === 'card_error') { + throw new PaymentFailedException(error.message); + } + throw error; + } + } + + async refund(transactionId: string, amount?: number): Promise<RefundResult> { + // Implementation... + } +} + +// Mock that honors LSP - same contract, same behavior shape +@Injectable() +export class MockPaymentService implements PaymentGateway { + private transactions = new Map<string, PaymentResult>(); + + async charge(amount: number, currency: string): Promise<PaymentResult> { + // Honor the contract: validate currency like real service would + if (!['USD', 'EUR', 'GBP'].includes(currency)) { + throw new InvalidCurrencyException(`Unsupported currency: ${currency}`); + } + + // Simulate decline for specific test scenarios + if (amount === 99999) { + throw new PaymentFailedException('Card declined (test scenario)'); + } + + // Return same shape as production + const result: PaymentResult = { + success: true, + transactionId: `mock_${Date.now()}_${Math.random().toString(36)}`, + amount, + }; + + this.transactions.set(result.transactionId, result); + return result; + } + + async refund(transactionId: string, amount?: number): Promise<RefundResult> { + // Honor the contract: throw if transaction not found + if (!this.transactions.has(transactionId)) { + throw new TransactionNotFoundException(transactionId); + } + + return { + success: true, + refundId: `refund_${transactionId}`, + amount: amount ?? this.transactions.get(transactionId)!.amount, + }; + } +} + +// Consumer can swap implementations safely +@Injectable() +export class OrdersService { + constructor(@Inject(PAYMENT_GATEWAY) private payment: PaymentGateway) {} + + async checkout(order: Order): Promise<Order> { + try { + const result = await this.payment.charge(order.total, order.currency); + // Works with both StripeService and MockPaymentService + order.transactionId = result.transactionId; + order.status = 'paid'; + return order; + } catch (error) { + if (error instanceof PaymentFailedException) { + order.status = 'payment_failed'; + return order; + } + throw error; + } + } +} +``` + +**Testing LSP compliance:** + +```typescript +// Shared test suite that any implementation must pass +function testPaymentGatewayContract(createGateway: () => PaymentGateway) { + describe('PaymentGateway contract', () => { + let gateway: PaymentGateway; + + beforeEach(() => { + gateway = createGateway(); + }); + + it('returns PaymentResult with all required fields', async () => { + const result = await gateway.charge(1000, 'USD'); + expect(result).toHaveProperty('success'); + expect(result).toHaveProperty('transactionId'); + expect(result).toHaveProperty('amount'); + expect(typeof result.transactionId).toBe('string'); + }); + + it('throws InvalidCurrencyException for unsupported currency', async () => { + await expect(gateway.charge(1000, 'INVALID')).rejects.toThrow(InvalidCurrencyException); + }); + + it('throws TransactionNotFoundException for invalid refund', async () => { + await expect(gateway.refund('nonexistent')).rejects.toThrow(TransactionNotFoundException); + }); + }); +} + +// Run against all implementations +describe('StripeService', () => { + testPaymentGatewayContract(() => new StripeService(mockStripeClient)); +}); + +describe('MockPaymentService', () => { + testPaymentGatewayContract(() => new MockPaymentService()); +}); +``` + +Reference: [Liskov Substitution Principle](https://en.wikipedia.org/wiki/Liskov_substitution_principle) + +--- + +### 2.4 Prefer Constructor Injection + +**Impact: CRITICAL** — Required for proper DI and testing + +Always use constructor injection over property injection. Constructor injection makes dependencies explicit, enables TypeScript type checking, ensures dependencies are available when the class is instantiated, and improves testability. This is required for proper DI, testing, and TypeScript support. + +**Incorrect (property injection with hidden dependencies):** + +```typescript +// Property injection - avoid unless necessary +@Injectable() +export class UsersService { + @Inject() + private userRepo: UserRepository; // Hidden dependency + + @Inject('CONFIG') + private config: ConfigType; // Also hidden + + async findAll() { + return this.userRepo.find(); + } +} + +// Problems: +// 1. Dependencies not visible in constructor +// 2. Service can be instantiated without dependencies in tests +// 3. TypeScript can't enforce dependency types at instantiation +``` + +**Correct (constructor injection with explicit dependencies):** + +```typescript +// Constructor injection - explicit and testable +@Injectable() +export class UsersService { + constructor( + private readonly userRepo: UserRepository, + @Inject('CONFIG') private readonly config: ConfigType, + ) {} + + async findAll(): Promise<User[]> { + return this.userRepo.find(); + } +} + +// Testing is straightforward +describe('UsersService', () => { + let service: UsersService; + let mockRepo: jest.Mocked<UserRepository>; + + beforeEach(() => { + mockRepo = { + find: jest.fn(), + save: jest.fn(), + } as any; + + service = new UsersService(mockRepo, { dbUrl: 'test' }); + }); + + it('should find all users', async () => { + mockRepo.find.mockResolvedValue([{ id: '1', name: 'Test' }]); + const result = await service.findAll(); + expect(result).toHaveLength(1); + }); +}); + +// Only use property injection for optional dependencies +@Injectable() +export class LoggingService { + @Optional() + @Inject('ANALYTICS') + private analytics?: AnalyticsService; + + log(message: string) { + console.log(message); + this.analytics?.track('log', message); // Optional enhancement + } +} +``` + +Reference: [NestJS Providers](https://docs.nestjs.com/providers) + +--- + +### 2.5 Understand Provider Scopes + +**Impact: CRITICAL** — Prevents data leaks and performance issues + +NestJS has three provider scopes: DEFAULT (singleton), REQUEST (per-request instance), and TRANSIENT (new instance for each injection). Most providers should be singletons. Request-scoped providers have performance implications as they bubble up through the dependency tree. Understanding scopes prevents memory leaks and incorrect data sharing. + +**Incorrect (wrong scope usage):** + +```typescript +// Request-scoped when not needed (performance hit) +@Injectable({ scope: Scope.REQUEST }) +export class UsersService { + // This creates a new instance for EVERY request + // All dependencies also become request-scoped + async findAll() { + return this.userRepo.find(); + } +} + +// Singleton with mutable request state +@Injectable() // Default: singleton +export class RequestContextService { + private userId: string; // DANGER: Shared across all requests! + + setUser(userId: string) { + this.userId = userId; // Overwrites for all concurrent requests + } + + getUser() { + return this.userId; // Returns wrong user! + } +} +``` + +**Correct (appropriate scope for each use case):** + +```typescript +// Singleton for stateless services (default, most common) +@Injectable() +export class UsersService { + constructor(private readonly userRepo: UserRepository) {} + + async findById(id: string): Promise<User> { + return this.userRepo.findOne({ where: { id } }); + } +} + +// Request-scoped ONLY when you need request context +@Injectable({ scope: Scope.REQUEST }) +export class RequestContextService { + private userId: string; + + setUser(userId: string) { + this.userId = userId; + } + + getUser(): string { + return this.userId; + } +} + +// Better: Use NestJS built-in request context +import { REQUEST } from '@nestjs/core'; +import { Request } from 'express'; + +@Injectable({ scope: Scope.REQUEST }) +export class AuditService { + constructor(@Inject(REQUEST) private request: Request) {} + + log(action: string) { + console.log(`User ${this.request.user?.id} performed ${action}`); + } +} + +// Best: Use ClsModule for async context (no scope bubble-up) +import { ClsService } from 'nestjs-cls'; + +@Injectable() // Stays singleton! +export class AuditService { + constructor(private cls: ClsService) {} + + log(action: string) { + const userId = this.cls.get('userId'); + console.log(`User ${userId} performed ${action}`); + } +} +``` + +Reference: [NestJS Injection Scopes](https://docs.nestjs.com/fundamentals/injection-scopes) + +--- + +### 2.6 Use Injection Tokens for Interfaces + +**Impact: HIGH** — Enables interface-based DI at runtime + +TypeScript interfaces are erased at compile time and can't be used as injection tokens. Use string tokens, symbols, or abstract classes when you want to inject implementations of interfaces. This enables swapping implementations for testing or different environments. + +**Incorrect (interface can't be used as token):** + +```typescript +// Interface can't be used as injection token +interface PaymentGateway { + charge(amount: number): Promise<PaymentResult>; +} + +@Injectable() +export class StripeService implements PaymentGateway { + charge(amount: number) { + /* ... */ + } +} + +@Injectable() +export class OrdersService { + // This WON'T work - PaymentGateway doesn't exist at runtime + constructor(private payment: PaymentGateway) {} +} +``` + +**Correct (symbol tokens or abstract classes):** + +```typescript +// Option 1: String/Symbol tokens (most flexible) +export const PAYMENT_GATEWAY = Symbol('PAYMENT_GATEWAY'); + +export interface PaymentGateway { + charge(amount: number): Promise<PaymentResult>; +} + +@Injectable() +export class StripeService implements PaymentGateway { + async charge(amount: number): Promise<PaymentResult> { + // Stripe implementation + } +} + +@Injectable() +export class MockPaymentService implements PaymentGateway { + async charge(amount: number): Promise<PaymentResult> { + return { success: true, id: 'mock-id' }; + } +} + +// Module registration +@Module({ + providers: [ + { + provide: PAYMENT_GATEWAY, + useClass: process.env.NODE_ENV === 'test' ? MockPaymentService : StripeService, + }, + ], + exports: [PAYMENT_GATEWAY], +}) +export class PaymentModule {} + +// Injection +@Injectable() +export class OrdersService { + constructor(@Inject(PAYMENT_GATEWAY) private payment: PaymentGateway) {} + + async createOrder(dto: CreateOrderDto) { + await this.payment.charge(dto.amount); + } +} + +// Option 2: Abstract class (carries runtime type info) +export abstract class PaymentGateway { + abstract charge(amount: number): Promise<PaymentResult>; +} + +@Injectable() +export class StripeService extends PaymentGateway { + async charge(amount: number): Promise<PaymentResult> { + // Implementation + } +} + +// No @Inject needed with abstract class +@Injectable() +export class OrdersService { + constructor(private payment: PaymentGateway) {} +} +``` + +Reference: [NestJS Custom Providers](https://docs.nestjs.com/fundamentals/custom-providers) + +--- + +## 3. Error Handling + +**Section Impact: HIGH** + +### 3.1 Handle Async Errors Properly + +**Impact: HIGH** — Prevents process crashes from unhandled rejections + +NestJS automatically catches errors from async route handlers, but errors from background tasks, event handlers, and manually created promises can crash your application. Always handle async errors explicitly and use global handlers as a safety net. + +**Incorrect (fire-and-forget without error handling):** + +```typescript +// Fire-and-forget without error handling +@Injectable() +export class UsersService { + async createUser(dto: CreateUserDto): Promise<User> { + const user = await this.repo.save(dto); + + // Fire and forget - if this fails, error is unhandled! + this.emailService.sendWelcome(user.email); + + return user; + } +} + +// Unhandled promise in event handler +@Injectable() +export class OrdersService { + @OnEvent('order.created') + handleOrderCreated(event: OrderCreatedEvent) { + // This returns a promise but it's not awaited! + this.processOrder(event); + // Errors will crash the process + } + + private async processOrder(event: OrderCreatedEvent): Promise<void> { + await this.inventoryService.reserve(event.items); + await this.notificationService.send(event.userId); + } +} + +// Missing try-catch in scheduled tasks +@Cron('0 0 * * *') +async dailyCleanup(): Promise<void> { + await this.cleanupService.run(); + // If this throws, no error handling +} +``` + +**Correct (explicit async error handling):** + +```typescript +// Handle fire-and-forget with explicit catch +@Injectable() +export class UsersService { + private readonly logger = new Logger(UsersService.name); + + async createUser(dto: CreateUserDto): Promise<User> { + const user = await this.repo.save(dto); + + // Explicitly catch and log errors + this.emailService.sendWelcome(user.email).catch((error) => { + this.logger.error('Failed to send welcome email', error.stack); + // Optionally queue for retry + }); + + return user; + } +} + +// Properly handle async event handlers +@Injectable() +export class OrdersService { + private readonly logger = new Logger(OrdersService.name); + + @OnEvent('order.created') + async handleOrderCreated(event: OrderCreatedEvent): Promise<void> { + try { + await this.processOrder(event); + } catch (error) { + this.logger.error('Failed to process order', { event, error }); + // Don't rethrow - would crash the process + await this.deadLetterQueue.add('order.created', event); + } + } +} + +// Safe scheduled tasks +@Injectable() +export class CleanupService { + private readonly logger = new Logger(CleanupService.name); + + @Cron('0 0 * * *') + async dailyCleanup(): Promise<void> { + try { + await this.cleanupService.run(); + this.logger.log('Daily cleanup completed'); + } catch (error) { + this.logger.error('Daily cleanup failed', error.stack); + // Alert or retry logic + } + } +} + +// Global unhandled rejection handler in main.ts +async function bootstrap() { + const app = await NestFactory.create(AppModule); + const logger = new Logger('Bootstrap'); + + process.on('unhandledRejection', (reason, promise) => { + logger.error('Unhandled Rejection at:', promise, 'reason:', reason); + }); + + process.on('uncaughtException', (error) => { + logger.error('Uncaught Exception:', error); + process.exit(1); + }); + + await app.listen(3000); +} +``` + +Reference: [Node.js Unhandled Rejections](https://nodejs.org/api/process.html#event-unhandledrejection) + +--- + +### 3.2 Throw HTTP Exceptions from Services + +**Impact: HIGH** — Keeps controllers thin and simplifies error handling + +It's acceptable (and often preferable) to throw `HttpException` subclasses from services in HTTP applications. This keeps controllers thin and allows services to communicate appropriate error states. For truly layer-agnostic services, use domain exceptions that map to HTTP status codes. + +**Incorrect (return error objects instead of throwing):** + +```typescript +// Return error objects instead of throwing +@Injectable() +export class UsersService { + async findById(id: string): Promise<{ user?: User; error?: string }> { + const user = await this.repo.findOne({ where: { id } }); + if (!user) { + return { error: 'User not found' }; // Controller must check this + } + return { user }; + } +} + +@Controller('users') +export class UsersController { + @Get(':id') + async findOne(@Param('id') id: string) { + const result = await this.usersService.findById(id); + if (result.error) { + throw new NotFoundException(result.error); + } + return result.user; + } +} +``` + +**Correct (throw exceptions directly from service):** + +```typescript +// Throw exceptions directly from service +@Injectable() +export class UsersService { + constructor(private readonly repo: UserRepository) {} + + async findById(id: string): Promise<User> { + const user = await this.repo.findOne({ where: { id } }); + if (!user) { + throw new NotFoundException(`User #${id} not found`); + } + return user; + } + + async create(dto: CreateUserDto): Promise<User> { + const existing = await this.repo.findOne({ + where: { email: dto.email }, + }); + if (existing) { + throw new ConflictException('Email already registered'); + } + return this.repo.save(dto); + } + + async update(id: string, dto: UpdateUserDto): Promise<User> { + const user = await this.findById(id); // Throws if not found + Object.assign(user, dto); + return this.repo.save(user); + } +} + +// Controller stays thin +@Controller('users') +export class UsersController { + @Get(':id') + findOne(@Param('id') id: string): Promise<User> { + return this.usersService.findById(id); + } + + @Post() + create(@Body() dto: CreateUserDto): Promise<User> { + return this.usersService.create(dto); + } +} + +// For layer-agnostic services, use domain exceptions +export class EntityNotFoundException extends Error { + constructor( + public readonly entity: string, + public readonly id: string, + ) { + super(`${entity} with ID "${id}" not found`); + } +} + +// Map to HTTP in exception filter +@Catch(EntityNotFoundException) +export class EntityNotFoundFilter implements ExceptionFilter { + catch(exception: EntityNotFoundException, host: ArgumentsHost) { + const ctx = host.switchToHttp(); + const response = ctx.getResponse<Response>(); + + response.status(404).json({ + statusCode: 404, + message: exception.message, + entity: exception.entity, + id: exception.id, + }); + } +} +``` + +Reference: [NestJS Exception Filters](https://docs.nestjs.com/exception-filters) + +--- + +### 3.3 Use Exception Filters for Error Handling + +**Impact: HIGH** — Consistent, centralized error handling + +Never catch exceptions and manually format error responses in controllers. Use NestJS exception filters to handle errors consistently across your application. Create custom exception filters for specific error types and a global filter for unhandled exceptions. + +**Incorrect (manual error handling in controllers):** + +```typescript +// Manual error handling in controllers +@Controller('users') +export class UsersController { + @Get(':id') + async findOne(@Param('id') id: string, @Res() res: Response) { + try { + const user = await this.usersService.findById(id); + if (!user) { + return res.status(404).json({ + statusCode: 404, + message: 'User not found', + }); + } + return res.json(user); + } catch (error) { + console.error(error); + return res.status(500).json({ + statusCode: 500, + message: 'Internal server error', + }); + } + } +} +``` + +**Correct (exception filters with consistent handling):** + +```typescript +// Use built-in and custom exceptions +@Controller('users') +export class UsersController { + @Get(':id') + async findOne(@Param('id') id: string): Promise<User> { + const user = await this.usersService.findById(id); + if (!user) { + throw new NotFoundException(`User #${id} not found`); + } + return user; + } +} + +// Custom domain exception +export class UserNotFoundException extends NotFoundException { + constructor(userId: string) { + super({ + statusCode: 404, + error: 'Not Found', + message: `User with ID "${userId}" not found`, + code: 'USER_NOT_FOUND', + }); + } +} + +// Custom exception filter for domain errors +@Catch(DomainException) +export class DomainExceptionFilter implements ExceptionFilter { + catch(exception: DomainException, host: ArgumentsHost) { + const ctx = host.switchToHttp(); + const response = ctx.getResponse<Response>(); + const request = ctx.getRequest<Request>(); + + const status = exception.getStatus?.() || 400; + + response.status(status).json({ + statusCode: status, + code: exception.code, + message: exception.message, + timestamp: new Date().toISOString(), + path: request.url, + }); + } +} + +// Global exception filter for unhandled errors +@Catch() +export class AllExceptionsFilter implements ExceptionFilter { + constructor(private readonly logger: Logger) {} + + catch(exception: unknown, host: ArgumentsHost) { + const ctx = host.switchToHttp(); + const response = ctx.getResponse<Response>(); + const request = ctx.getRequest<Request>(); + + const status = + exception instanceof HttpException ? exception.getStatus() : HttpStatus.INTERNAL_SERVER_ERROR; + + const message = + exception instanceof HttpException ? exception.message : 'Internal server error'; + + this.logger.error( + `${request.method} ${request.url}`, + exception instanceof Error ? exception.stack : exception, + ); + + response.status(status).json({ + statusCode: status, + message, + timestamp: new Date().toISOString(), + path: request.url, + }); + } +} + +// Register globally in main.ts +app.useGlobalFilters(new AllExceptionsFilter(app.get(Logger)), new DomainExceptionFilter()); + +// Or via module +@Module({ + providers: [ + { + provide: APP_FILTER, + useClass: AllExceptionsFilter, + }, + ], +}) +export class AppModule {} +``` + +Reference: [NestJS Exception Filters](https://docs.nestjs.com/exception-filters) + +--- + +## 4. Security + +**Section Impact: HIGH** + +### 4.1 Implement Secure JWT Authentication + +**Impact: CRITICAL** — Essential for secure APIs + +Use `@nestjs/jwt` with `@nestjs/passport` for authentication. Store secrets securely, use appropriate token lifetimes, implement refresh tokens, and validate tokens properly. Never expose sensitive data in JWT payloads. + +**Incorrect (insecure JWT implementation):** + +```typescript +// Hardcode secrets +@Module({ + imports: [ + JwtModule.register({ + secret: 'my-secret-key', // Exposed in code + signOptions: { expiresIn: '7d' }, // Too long + }), + ], +}) +export class AuthModule {} + +// Store sensitive data in JWT +async login(user: User): Promise<{ accessToken: string }> { + const payload = { + sub: user.id, + email: user.email, + password: user.password, // NEVER include password! + ssn: user.ssn, // NEVER include sensitive data! + isAdmin: user.isAdmin, // Can be tampered if not verified + }; + return { accessToken: this.jwtService.sign(payload) }; +} + +// Skip token validation +@Injectable() +export class JwtStrategy extends PassportStrategy(Strategy) { + constructor() { + super({ + jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(), + secretOrKey: 'my-secret', + }); + } + + async validate(payload: any): Promise<any> { + return payload; // No validation of user existence + } +} +``` + +**Correct (secure JWT with refresh tokens):** + +```typescript +// Secure JWT configuration +@Module({ + imports: [ + JwtModule.registerAsync({ + imports: [ConfigModule], + inject: [ConfigService], + useFactory: (config: ConfigService) => ({ + secret: config.get<string>('JWT_SECRET'), + signOptions: { + expiresIn: '15m', // Short-lived access tokens + issuer: config.get<string>('JWT_ISSUER'), + audience: config.get<string>('JWT_AUDIENCE'), + }, + }), + }), + PassportModule.register({ defaultStrategy: 'jwt' }), + ], +}) +export class AuthModule {} + +// Minimal JWT payload +@Injectable() +export class AuthService { + async login(user: User): Promise<TokenResponse> { + // Only include necessary, non-sensitive data + const payload: JwtPayload = { + sub: user.id, + email: user.email, + roles: user.roles, + iat: Math.floor(Date.now() / 1000), + }; + + const accessToken = this.jwtService.sign(payload); + const refreshToken = await this.createRefreshToken(user.id); + + return { accessToken, refreshToken, expiresIn: 900 }; + } + + private async createRefreshToken(userId: string): Promise<string> { + const token = randomBytes(32).toString('hex'); + const hashedToken = await bcrypt.hash(token, 10); + + await this.refreshTokenRepo.save({ + userId, + token: hashedToken, + expiresAt: new Date(Date.now() + 7 * 24 * 60 * 60 * 1000), // 7 days + }); + + return token; + } +} + +// Proper JWT strategy with validation +@Injectable() +export class JwtStrategy extends PassportStrategy(Strategy) { + constructor( + private config: ConfigService, + private usersService: UsersService, + ) { + super({ + jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(), + secretOrKey: config.get<string>('JWT_SECRET'), + ignoreExpiration: false, + issuer: config.get<string>('JWT_ISSUER'), + audience: config.get<string>('JWT_AUDIENCE'), + }); + } + + async validate(payload: JwtPayload): Promise<User> { + // Verify user still exists and is active + const user = await this.usersService.findById(payload.sub); + + if (!user || !user.isActive) { + throw new UnauthorizedException('User not found or inactive'); + } + + // Verify token wasn't issued before password change + if (user.passwordChangedAt) { + const tokenIssuedAt = new Date(payload.iat * 1000); + if (tokenIssuedAt < user.passwordChangedAt) { + throw new UnauthorizedException('Token invalidated by password change'); + } + } + + return user; + } +} +``` + +Reference: [NestJS Authentication](https://docs.nestjs.com/security/authentication) + +--- + +### 4.2 Implement Rate Limiting + +**Impact: HIGH** — Protects against abuse and ensures fair resource usage + +Use `@nestjs/throttler` to limit request rates per client. Apply different limits for different endpoints - stricter for auth endpoints, more relaxed for read operations. Consider using Redis for distributed rate limiting in clustered deployments. + +**Incorrect (no rate limiting on sensitive endpoints):** + +```typescript +// No rate limiting on sensitive endpoints +@Controller('auth') +export class AuthController { + @Post('login') + async login(@Body() dto: LoginDto): Promise<TokenResponse> { + // Attackers can brute-force credentials + return this.authService.login(dto); + } + + @Post('forgot-password') + async forgotPassword(@Body() dto: ForgotPasswordDto): Promise<void> { + // Can be abused to spam users with emails + return this.authService.sendResetEmail(dto.email); + } +} + +// Same limits for all endpoints +@UseGuards(ThrottlerGuard) +@Controller('api') +export class ApiController { + @Get('public-data') + async getPublic() {} // Should allow more requests + + @Post('process-payment') + async payment() {} // Should be more restrictive +} +``` + +**Correct (configured throttler with endpoint-specific limits):** + +```typescript +// Configure throttler globally with multiple limits +import { ThrottlerModule, ThrottlerGuard } from '@nestjs/throttler'; + +@Module({ + imports: [ + ThrottlerModule.forRoot([ + { + name: 'short', + ttl: 1000, // 1 second + limit: 3, // 3 requests per second + }, + { + name: 'medium', + ttl: 10000, // 10 seconds + limit: 20, // 20 requests per 10 seconds + }, + { + name: 'long', + ttl: 60000, // 1 minute + limit: 100, // 100 requests per minute + }, + ]), + ], + providers: [ + { + provide: APP_GUARD, + useClass: ThrottlerGuard, + }, + ], +}) +export class AppModule {} + +// Override limits per endpoint +@Controller('auth') +export class AuthController { + @Post('login') + @Throttle({ short: { limit: 5, ttl: 60000 } }) // 5 attempts per minute + async login(@Body() dto: LoginDto): Promise<TokenResponse> { + return this.authService.login(dto); + } + + @Post('forgot-password') + @Throttle({ short: { limit: 3, ttl: 3600000 } }) // 3 per hour + async forgotPassword(@Body() dto: ForgotPasswordDto): Promise<void> { + return this.authService.sendResetEmail(dto.email); + } +} + +// Skip throttling for certain routes +@Controller('health') +export class HealthController { + @Get() + @SkipThrottle() + check(): string { + return 'OK'; + } +} + +// Custom throttle per user type +@Injectable() +export class CustomThrottlerGuard extends ThrottlerGuard { + protected async getTracker(req: Request): Promise<string> { + // Use user ID if authenticated, IP otherwise + return req.user?.id || req.ip; + } + + protected async getLimit(context: ExecutionContext): Promise<number> { + const request = context.switchToHttp().getRequest(); + + // Higher limits for authenticated users + if (request.user) { + return request.user.isPremium ? 1000 : 200; + } + + return 50; // Anonymous users + } +} +``` + +Reference: [NestJS Throttler](https://docs.nestjs.com/security/rate-limiting) + +--- + +### 4.3 Sanitize Output to Prevent XSS + +**Impact: HIGH** — XSS vulnerabilities can compromise user sessions and data + +While NestJS APIs typically return JSON (which browsers don't execute), XSS risks exist when rendering HTML, storing user content, or when frontend frameworks improperly handle API responses. Sanitize user-generated content before storage and use proper Content-Type headers. + +**Incorrect (storing raw HTML without sanitization):** + +```typescript +// Store raw HTML from users +@Injectable() +export class CommentsService { + async create(dto: CreateCommentDto): Promise<Comment> { + // User can inject: <script>steal(document.cookie)</script> + return this.repo.save({ + content: dto.content, // Raw, unsanitized + authorId: dto.authorId, + }); + } +} + +// Return HTML without sanitization +@Controller('pages') +export class PagesController { + @Get(':slug') + @Header('Content-Type', 'text/html') + async getPage(@Param('slug') slug: string): Promise<string> { + const page = await this.pagesService.findBySlug(slug); + // If page.content contains user input, XSS is possible + return `<html><body>${page.content}</body></html>`; + } +} + +// Reflect user input in errors +@Get(':id') +async findOne(@Param('id') id: string): Promise<User> { + const user = await this.repo.findOne({ where: { id } }); + if (!user) { + // XSS if id contains malicious content and error is rendered + throw new NotFoundException(`User ${id} not found`); + } + return user; +} +``` + +**Correct (sanitize content and use proper headers):** + +```typescript +// Sanitize HTML content before storage +import * as sanitizeHtml from 'sanitize-html'; + +@Injectable() +export class CommentsService { + private readonly sanitizeOptions: sanitizeHtml.IOptions = { + allowedTags: ['b', 'i', 'em', 'strong', 'a', 'p', 'br'], + allowedAttributes: { + a: ['href', 'title'], + }, + allowedSchemes: ['http', 'https', 'mailto'], + }; + + async create(dto: CreateCommentDto): Promise<Comment> { + return this.repo.save({ + content: sanitizeHtml(dto.content, this.sanitizeOptions), + authorId: dto.authorId, + }); + } +} + +// Use validation pipe to strip HTML +import { Transform } from 'class-transformer'; + +export class CreatePostDto { + @IsString() + @MaxLength(1000) + @Transform(({ value }) => sanitizeHtml(value, { allowedTags: [] })) + title: string; + + @IsString() + @Transform(({ value }) => + sanitizeHtml(value, { + allowedTags: ['p', 'br', 'b', 'i', 'a'], + allowedAttributes: { a: ['href'] }, + }), + ) + content: string; +} + +// Set proper Content-Type headers +@Controller('api') +export class ApiController { + @Get('data') + @Header('Content-Type', 'application/json') + async getData(): Promise<DataResponse> { + // JSON response - browser won't execute scripts + return this.service.getData(); + } +} + +// Sanitize error messages +@Get(':id') +async findOne(@Param('id', ParseUUIDPipe) id: string): Promise<User> { + const user = await this.repo.findOne({ where: { id } }); + if (!user) { + // UUID validation ensures safe format + throw new NotFoundException('User not found'); + } + return user; +} + +// Use Helmet for CSP headers +import helmet from 'helmet'; + +async function bootstrap() { + const app = await NestFactory.create(AppModule); + + app.use( + helmet({ + contentSecurityPolicy: { + directives: { + defaultSrc: ["'self'"], + scriptSrc: ["'self'"], + styleSrc: ["'self'", "'unsafe-inline'"], + imgSrc: ["'self'", 'data:', 'https:'], + }, + }, + }), + ); + + await app.listen(3000); +} +``` + +Reference: [OWASP XSS Prevention](https://cheatsheetseries.owasp.org/cheatsheets/Cross_Site_Scripting_Prevention_Cheat_Sheet.html) + +--- + +### 4.4 Use Guards for Authentication and Authorization + +**Impact: HIGH** — Enforces access control before handlers execute + +Guards determine whether a request should be handled based on authentication state, roles, permissions, or other conditions. They run after middleware but before pipes and interceptors, making them ideal for access control. Use guards instead of manual checks in controllers. + +**Incorrect (manual auth checks in every handler):** + +```typescript +// Manual auth checks in every handler +@Controller('admin') +export class AdminController { + @Get('users') + async getUsers(@Request() req) { + if (!req.user) { + throw new UnauthorizedException(); + } + if (!req.user.roles.includes('admin')) { + throw new ForbiddenException(); + } + return this.adminService.getUsers(); + } + + @Delete('users/:id') + async deleteUser(@Request() req, @Param('id') id: string) { + if (!req.user) { + throw new UnauthorizedException(); + } + if (!req.user.roles.includes('admin')) { + throw new ForbiddenException(); + } + return this.adminService.deleteUser(id); + } +} +``` + +**Correct (guards with declarative decorators):** + +```typescript +// JWT Auth Guard +@Injectable() +export class JwtAuthGuard implements CanActivate { + constructor( + private jwtService: JwtService, + private reflector: Reflector, + ) {} + + async canActivate(context: ExecutionContext): Promise<boolean> { + // Check for @Public() decorator + const isPublic = this.reflector.getAllAndOverride<boolean>('isPublic', [ + context.getHandler(), + context.getClass(), + ]); + if (isPublic) return true; + + const request = context.switchToHttp().getRequest(); + const token = this.extractToken(request); + + if (!token) { + throw new UnauthorizedException('No token provided'); + } + + try { + request.user = await this.jwtService.verifyAsync(token); + return true; + } catch { + throw new UnauthorizedException('Invalid token'); + } + } + + private extractToken(request: Request): string | undefined { + const [type, token] = request.headers.authorization?.split(' ') ?? []; + return type === 'Bearer' ? token : undefined; + } +} + +// Roles Guard +@Injectable() +export class RolesGuard implements CanActivate { + constructor(private reflector: Reflector) {} + + canActivate(context: ExecutionContext): boolean { + const requiredRoles = this.reflector.getAllAndOverride<Role[]>('roles', [ + context.getHandler(), + context.getClass(), + ]); + + if (!requiredRoles) return true; + + const { user } = context.switchToHttp().getRequest(); + return requiredRoles.some((role) => user.roles?.includes(role)); + } +} + +// Decorators +export const Public = () => SetMetadata('isPublic', true); +export const Roles = (...roles: Role[]) => SetMetadata('roles', roles); + +// Register guards globally +@Module({ + providers: [ + { provide: APP_GUARD, useClass: JwtAuthGuard }, + { provide: APP_GUARD, useClass: RolesGuard }, + ], +}) +export class AppModule {} + +// Clean controller +@Controller('admin') +@Roles(Role.Admin) // Applied to all routes +export class AdminController { + @Get('users') + getUsers(): Promise<User[]> { + return this.adminService.getUsers(); + } + + @Delete('users/:id') + deleteUser(@Param('id') id: string): Promise<void> { + return this.adminService.deleteUser(id); + } + + @Public() // Override: no auth required + @Get('health') + health() { + return { status: 'ok' }; + } +} +``` + +Reference: [NestJS Guards](https://docs.nestjs.com/guards) + +--- + +### 4.5 Validate All Input with DTOs and Pipes + +**Impact: HIGH** — First line of defense against attacks + +Always validate incoming data using class-validator decorators on DTOs and the global ValidationPipe. Never trust user input. Validate all request bodies, query parameters, and route parameters before processing. + +**Incorrect (trust raw input without validation):** + +```typescript +// Trust raw input without validation +@Controller('users') +export class UsersController { + @Post() + create(@Body() body: any) { + // body could contain anything - SQL injection, XSS, etc. + return this.usersService.create(body); + } + + @Get() + findAll(@Query() query: any) { + // query.limit could be "'; DROP TABLE users; --" + return this.usersService.findAll(query.limit); + } +} + +// DTOs without validation decorators +export class CreateUserDto { + name: string; // No validation + email: string; // Could be "not-an-email" + age: number; // Could be "abc" or -999 +} +``` + +**Correct (validated DTOs with global ValidationPipe):** + +```typescript +// Enable ValidationPipe globally in main.ts +async function bootstrap() { + const app = await NestFactory.create(AppModule); + + app.useGlobalPipes( + new ValidationPipe({ + whitelist: true, // Strip unknown properties + forbidNonWhitelisted: true, // Throw on unknown properties + transform: true, // Auto-transform to DTO types + transformOptions: { + enableImplicitConversion: true, + }, + }), + ); + + await app.listen(3000); +} + +// Create well-validated DTOs +import { + IsString, + IsEmail, + IsInt, + Min, + Max, + IsOptional, + MinLength, + MaxLength, + Matches, + IsNotEmpty, +} from 'class-validator'; +import { Transform, Type } from 'class-transformer'; + +export class CreateUserDto { + @IsString() + @IsNotEmpty() + @MinLength(2) + @MaxLength(100) + @Transform(({ value }) => value?.trim()) + name: string; + + @IsEmail() + @Transform(({ value }) => value?.toLowerCase().trim()) + email: string; + + @IsInt() + @Min(0) + @Max(150) + age: number; + + @IsString() + @MinLength(8) + @MaxLength(100) + @Matches(/^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)/, { + message: 'Password must contain uppercase, lowercase, and number', + }) + password: string; +} + +// Query DTO with defaults and transformation +export class FindUsersQueryDto { + @IsOptional() + @IsString() + @MaxLength(100) + search?: string; + + @IsOptional() + @Type(() => Number) + @IsInt() + @Min(1) + @Max(100) + limit: number = 20; + + @IsOptional() + @Type(() => Number) + @IsInt() + @Min(0) + offset: number = 0; +} + +// Param validation +export class UserIdParamDto { + @IsUUID('4') + id: string; +} + +@Controller('users') +export class UsersController { + @Post() + create(@Body() dto: CreateUserDto): Promise<User> { + // dto is guaranteed to be valid + return this.usersService.create(dto); + } + + @Get() + findAll(@Query() query: FindUsersQueryDto): Promise<User[]> { + // query.limit is a number, query.search is sanitized + return this.usersService.findAll(query); + } + + @Get(':id') + findOne(@Param() params: UserIdParamDto): Promise<User> { + // params.id is a valid UUID + return this.usersService.findById(params.id); + } +} +``` + +Reference: [NestJS Validation](https://docs.nestjs.com/techniques/validation) + +--- + +## 5. Performance + +**Section Impact: HIGH** + +### 5.1 Use Async Lifecycle Hooks Correctly + +**Impact: HIGH** — Improper async handling blocks application startup + +NestJS lifecycle hooks (`onModuleInit`, `onApplicationBootstrap`, etc.) support async operations. However, misusing them can block application startup or cause race conditions. Understand the lifecycle order and use hooks appropriately. + +**Incorrect (fire-and-forget async without await):** + +```typescript +// Fire-and-forget async without await +@Injectable() +export class DatabaseService implements OnModuleInit { + onModuleInit() { + // This runs but doesn't block - app starts before DB is ready! + this.connect(); + } + + private async connect() { + await this.pool.connect(); + console.log('Database connected'); + } +} + +// Heavy blocking operations in constructor +@Injectable() +export class ConfigService { + private config: Config; + + constructor() { + // BLOCKS entire module instantiation synchronously + this.config = fs.readFileSync('config.json'); + } +} +``` + +**Correct (return promises from async hooks):** + +```typescript +// Return promise from async hooks +@Injectable() +export class DatabaseService implements OnModuleInit { + private pool: Pool; + + async onModuleInit(): Promise<void> { + // NestJS waits for this to complete before continuing + await this.pool.connect(); + console.log('Database connected'); + } + + async onModuleDestroy(): Promise<void> { + // Clean up resources on shutdown + await this.pool.end(); + console.log('Database disconnected'); + } +} + +// Use onApplicationBootstrap for cross-module dependencies +@Injectable() +export class CacheWarmerService implements OnApplicationBootstrap { + constructor( + private cache: CacheService, + private products: ProductsService, + ) {} + + async onApplicationBootstrap(): Promise<void> { + // All modules are initialized, safe to warm cache + const products = await this.products.findPopular(); + await this.cache.warmup(products); + } +} + +// Heavy init in async hooks, not constructor +@Injectable() +export class ConfigService implements OnModuleInit { + private config: Config; + + constructor() { + // Keep constructor synchronous and fast + } + + async onModuleInit(): Promise<void> { + // Async loading in lifecycle hook + this.config = await this.loadConfig(); + } + + private async loadConfig(): Promise<Config> { + const file = await fs.promises.readFile('config.json'); + return JSON.parse(file.toString()); + } + + get<T>(key: string): T { + return this.config[key]; + } +} + +// Enable shutdown hooks in main.ts +async function bootstrap() { + const app = await NestFactory.create(AppModule); + app.enableShutdownHooks(); // Enable SIGTERM/SIGINT handling + await app.listen(3000); +} +``` + +Reference: [NestJS Lifecycle Events](https://docs.nestjs.com/fundamentals/lifecycle-events) + +--- + +### 5.2 Use Lazy Loading for Large Modules + +**Impact: MEDIUM** — Improves startup time for large applications + +NestJS supports lazy-loading modules, which defers initialization until first use. This is valuable for large applications where some features are rarely used, serverless deployments where cold start time matters, or when certain modules have heavy initialization costs. + +**Incorrect (loading everything eagerly):** + +```typescript +// Load everything eagerly in a large app +@Module({ + imports: [ + UsersModule, + OrdersModule, + PaymentsModule, + ReportsModule, // Heavy, rarely used + AnalyticsModule, // Heavy, rarely used + AdminModule, // Only admins use this + LegacyModule, // Migration module, rarely used + BulkImportModule, // Used once a month + ], +}) +export class AppModule {} + +// All modules initialize at startup, even if never used +// Slow cold starts in serverless +// Memory wasted on unused modules +``` + +**Correct (lazy load rarely-used modules):** + +```typescript +// Use LazyModuleLoader for optional modules +import { LazyModuleLoader } from '@nestjs/core'; + +@Injectable() +export class ReportsService { + constructor(private lazyModuleLoader: LazyModuleLoader) {} + + async generateReport(type: string): Promise<Report> { + // Load module only when needed + const { ReportsModule } = await import('./reports/reports.module'); + const moduleRef = await this.lazyModuleLoader.load(() => ReportsModule); + + const reportsService = moduleRef.get(ReportsGeneratorService); + return reportsService.generate(type); + } +} + +// Lazy load admin features with caching +@Injectable() +export class AdminService { + private adminModule: ModuleRef | null = null; + + constructor(private lazyModuleLoader: LazyModuleLoader) {} + + private async getAdminModule(): Promise<ModuleRef> { + if (!this.adminModule) { + const { AdminModule } = await import('./admin/admin.module'); + this.adminModule = await this.lazyModuleLoader.load(() => AdminModule); + } + return this.adminModule; + } + + async runAdminTask(task: string): Promise<void> { + const moduleRef = await this.getAdminModule(); + const taskRunner = moduleRef.get(AdminTaskRunner); + await taskRunner.run(task); + } +} + +// Reusable lazy loader service +@Injectable() +export class ModuleLoaderService { + private loadedModules = new Map<string, ModuleRef>(); + + constructor(private lazyModuleLoader: LazyModuleLoader) {} + + async load<T>( + key: string, + importFn: () => Promise<{ default: Type<T> } | Type<T>>, + ): Promise<ModuleRef> { + if (!this.loadedModules.has(key)) { + const module = await importFn(); + const moduleType = 'default' in module ? module.default : module; + const moduleRef = await this.lazyModuleLoader.load(() => moduleType); + this.loadedModules.set(key, moduleRef); + } + return this.loadedModules.get(key)!; + } +} + +// Preload modules in background after startup +@Injectable() +export class ModulePreloader implements OnApplicationBootstrap { + constructor(private lazyModuleLoader: LazyModuleLoader) {} + + async onApplicationBootstrap(): Promise<void> { + setTimeout(async () => { + await this.preloadModule(() => import('./reports/reports.module')); + }, 5000); // 5 seconds after startup + } + + private async preloadModule(importFn: () => Promise<any>): Promise<void> { + try { + const module = await importFn(); + const moduleType = module.default || Object.values(module)[0]; + await this.lazyModuleLoader.load(() => moduleType); + } catch (error) { + console.warn('Failed to preload module', error); + } + } +} +``` + +Reference: [NestJS Lazy Loading Modules](https://docs.nestjs.com/fundamentals/lazy-loading-modules) + +--- + +### 5.3 Optimize Database Queries + +**Impact: HIGH** — Database queries are typically the largest source of latency + +Select only needed columns, use proper indexes, avoid over-fetching relations, and consider query performance when designing your data access. Most API slowness traces back to inefficient database queries. + +**Incorrect (over-fetching data and missing indexes):** + +```typescript +// Select everything when you need few fields +@Injectable() +export class UsersService { + async findAllEmails(): Promise<string[]> { + const users = await this.repo.find(); + // Fetches ALL columns for ALL users + return users.map((u) => u.email); + } + + async getUserSummary(id: string): Promise<UserSummary> { + const user = await this.repo.findOne({ + where: { id }, + relations: ['posts', 'posts.comments', 'posts.comments.author', 'followers'], + }); + // Over-fetches massive relation tree + return { name: user.name, postCount: user.posts.length }; + } +} + +// No indexes on frequently queried columns +@Entity() +export class Order { + @Column() + userId: string; // No index - full table scan on every lookup + + @Column() + status: string; // No index - slow status filtering +} +``` + +**Correct (select only needed data with proper indexes):** + +```typescript +// Select only needed columns +@Injectable() +export class UsersService { + async findAllEmails(): Promise<string[]> { + const users = await this.repo.find({ + select: ['email'], // Only fetch email column + }); + return users.map((u) => u.email); + } + + // Use QueryBuilder for complex selections + async getUserSummary(id: string): Promise<UserSummary> { + return this.repo + .createQueryBuilder('user') + .select('user.name', 'name') + .addSelect('COUNT(post.id)', 'postCount') + .leftJoin('user.posts', 'post') + .where('user.id = :id', { id }) + .groupBy('user.id') + .getRawOne(); + } + + // Fetch relations only when needed + async getFullProfile(id: string): Promise<User> { + return this.repo.findOne({ + where: { id }, + relations: ['posts'], // Only immediate relation + select: { + id: true, + name: true, + email: true, + posts: { + id: true, + title: true, + }, + }, + }); + } +} + +// Add indexes on frequently queried columns +@Entity() +@Index(['userId']) +@Index(['status']) +@Index(['createdAt']) +@Index(['userId', 'status']) // Composite index for common query pattern +export class Order { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Column() + userId: string; + + @Column() + status: string; + + @CreateDateColumn() + createdAt: Date; +} + +// Always paginate large datasets +@Injectable() +export class OrdersService { + async findAll(page = 1, limit = 20): Promise<PaginatedResult<Order>> { + const [items, total] = await this.repo.findAndCount({ + skip: (page - 1) * limit, + take: limit, + order: { createdAt: 'DESC' }, + }); + + return { + items, + meta: { + page, + limit, + total, + totalPages: Math.ceil(total / limit), + }, + }; + } +} +``` + +Reference: [TypeORM Query Builder](https://typeorm.io/select-query-builder) + +--- + +### 5.4 Use Caching Strategically + +**Impact: HIGH** — Dramatically reduces database load and response times + +Implement caching for expensive operations, frequently accessed data, and external API calls. Use NestJS CacheModule with appropriate TTLs and cache invalidation strategies. Don't cache everything - focus on high-impact areas. + +**Incorrect (no caching or caching everything):** + +```typescript +// No caching for expensive, repeated queries +@Injectable() +export class ProductsService { + async getPopular(): Promise<Product[]> { + // Runs complex aggregation query EVERY request + return this.productsRepo + .createQueryBuilder('p') + .leftJoin('p.orders', 'o') + .select('p.*, COUNT(o.id) as orderCount') + .groupBy('p.id') + .orderBy('orderCount', 'DESC') + .limit(20) + .getMany(); + } +} + +// Cache everything without thought +@Injectable() +export class UsersService { + @CacheKey('users') + @CacheTTL(3600) + @UseInterceptors(CacheInterceptor) + async findAll(): Promise<User[]> { + // Caching user list for 1 hour is wrong if data changes frequently + return this.usersRepo.find(); + } +} +``` + +**Correct (strategic caching with proper invalidation):** + +```typescript +// Setup caching module +@Module({ + imports: [ + CacheModule.registerAsync({ + imports: [ConfigModule], + inject: [ConfigService], + useFactory: (config: ConfigService) => ({ + stores: [new KeyvRedis(config.get('REDIS_URL'))], + ttl: 60 * 1000, // Default 60s + }), + }), + ], +}) +export class AppModule {} + +// Manual caching for granular control +@Injectable() +export class ProductsService { + constructor( + @Inject(CACHE_MANAGER) private cache: Cache, + private productsRepo: ProductRepository, + ) {} + + async getPopular(): Promise<Product[]> { + const cacheKey = 'products:popular'; + + // Try cache first + const cached = await this.cache.get<Product[]>(cacheKey); + if (cached) return cached; + + // Cache miss - fetch and cache + const products = await this.fetchPopularProducts(); + await this.cache.set(cacheKey, products, 5 * 60 * 1000); // 5 min TTL + return products; + } + + // Invalidate cache on changes + async updateProduct(id: string, dto: UpdateProductDto): Promise<Product> { + const product = await this.productsRepo.save({ id, ...dto }); + await this.cache.del('products:popular'); // Invalidate + return product; + } +} + +// Decorator-based caching with auto-interceptor +@Controller('categories') +@UseInterceptors(CacheInterceptor) +export class CategoriesController { + @Get() + @CacheTTL(30 * 60 * 1000) // 30 minutes - categories rarely change + findAll(): Promise<Category[]> { + return this.categoriesService.findAll(); + } + + @Get(':id') + @CacheTTL(60 * 1000) // 1 minute + @CacheKey('category') + findOne(@Param('id') id: string): Promise<Category> { + return this.categoriesService.findOne(id); + } +} + +// Event-based cache invalidation +@Injectable() +export class CacheInvalidationService { + constructor(@Inject(CACHE_MANAGER) private cache: Cache) {} + + @OnEvent('product.created') + @OnEvent('product.updated') + @OnEvent('product.deleted') + async invalidateProductCaches(event: ProductEvent) { + await Promise.all([ + this.cache.del('products:popular'), + this.cache.del(`product:${event.productId}`), + ]); + } +} +``` + +Reference: [NestJS Caching](https://docs.nestjs.com/techniques/caching) + +--- + +## 6. Testing + +**Section Impact: MEDIUM-HIGH** + +### 6.1 Use Supertest for E2E Testing + +**Impact: HIGH** — Validates the full request/response cycle + +End-to-end tests use Supertest to make real HTTP requests against your NestJS application. They test the full stack including middleware, guards, pipes, and interceptors. E2E tests catch integration issues that unit tests miss. + +**Incorrect (no proper E2E setup or teardown):** + +```typescript +// Only unit test controllers +describe('UsersController', () => { + it('should return users', async () => { + const service = { findAll: jest.fn().mockResolvedValue([]) }; + const controller = new UsersController(service as any); + + const result = await controller.findAll(); + + expect(result).toEqual([]); + // Doesn't test: routes, guards, pipes, serialization + }); +}); + +// E2E tests without proper setup/teardown +describe('Users API', () => { + it('should create user', async () => { + const app = await NestFactory.create(AppModule); + // No proper initialization + // No cleanup after test + // Hits real database + }); +}); +``` + +**Correct (proper E2E setup with Supertest):** + +```typescript +// Proper E2E test setup +import { Test, TestingModule } from '@nestjs/testing'; +import { INestApplication, ValidationPipe } from '@nestjs/common'; +import * as request from 'supertest'; +import { AppModule } from '../src/app.module'; + +describe('UsersController (e2e)', () => { + let app: INestApplication; + + beforeAll(async () => { + const moduleFixture: TestingModule = await Test.createTestingModule({ + imports: [AppModule], + }).compile(); + + app = moduleFixture.createNestApplication(); + + // Apply same config as production + app.useGlobalPipes( + new ValidationPipe({ + whitelist: true, + transform: true, + forbidNonWhitelisted: true, + }), + ); + + await app.init(); + }); + + afterAll(async () => { + await app.close(); + }); + + describe('/users (POST)', () => { + it('should create a user', () => { + return request(app.getHttpServer()) + .post('/users') + .send({ name: 'John', email: 'john@test.com' }) + .expect(201) + .expect((res) => { + expect(res.body).toHaveProperty('id'); + expect(res.body.name).toBe('John'); + expect(res.body.email).toBe('john@test.com'); + }); + }); + + it('should return 400 for invalid email', () => { + return request(app.getHttpServer()) + .post('/users') + .send({ name: 'John', email: 'invalid-email' }) + .expect(400) + .expect((res) => { + expect(res.body.message).toContain('email'); + }); + }); + }); + + describe('/users/:id (GET)', () => { + it('should return 404 for non-existent user', () => { + return request(app.getHttpServer()).get('/users/non-existent-id').expect(404); + }); + }); +}); + +// Testing with authentication +describe('Protected Routes (e2e)', () => { + let app: INestApplication; + let authToken: string; + + beforeAll(async () => { + const moduleFixture = await Test.createTestingModule({ + imports: [AppModule], + }).compile(); + + app = moduleFixture.createNestApplication(); + app.useGlobalPipes(new ValidationPipe({ whitelist: true })); + await app.init(); + + // Get auth token + const loginResponse = await request(app.getHttpServer()) + .post('/auth/login') + .send({ email: 'test@test.com', password: 'password' }); + + authToken = loginResponse.body.accessToken; + }); + + it('should return 401 without token', () => { + return request(app.getHttpServer()).get('/users/me').expect(401); + }); + + it('should return user profile with valid token', () => { + return request(app.getHttpServer()) + .get('/users/me') + .set('Authorization', `Bearer ${authToken}`) + .expect(200) + .expect((res) => { + expect(res.body.email).toBe('test@test.com'); + }); + }); +}); + +// Database isolation for E2E tests +describe('Orders API (e2e)', () => { + let app: INestApplication; + let dataSource: DataSource; + + beforeAll(async () => { + const moduleFixture = await Test.createTestingModule({ + imports: [ + ConfigModule.forRoot({ + envFilePath: '.env.test', // Test database config + }), + AppModule, + ], + }).compile(); + + app = moduleFixture.createNestApplication(); + dataSource = moduleFixture.get(DataSource); + await app.init(); + }); + + beforeEach(async () => { + // Clean database between tests + await dataSource.synchronize(true); + }); + + afterAll(async () => { + await dataSource.destroy(); + await app.close(); + }); +}); +``` + +Reference: [NestJS E2E Testing](https://docs.nestjs.com/fundamentals/testing#end-to-end-testing) + +--- + +### 6.2 Mock External Services in Tests + +**Impact: HIGH** — Ensures fast, reliable, deterministic tests + +Never call real external services (APIs, databases, message queues) in unit tests. Mock them to ensure tests are fast, deterministic, and don't incur costs. Use realistic mock data and test edge cases like timeouts and errors. + +**Incorrect (calling real APIs and databases):** + +```typescript +// Call real APIs in tests +describe('PaymentService', () => { + it('should process payment', async () => { + const service = new PaymentService(new StripeClient(realApiKey)); + // Hits real Stripe API! + const result = await service.charge('tok_visa', 1000); + // Slow, costs money, flaky + }); +}); + +// Use real database +describe('UsersService', () => { + beforeEach(async () => { + await connection.query('DELETE FROM users'); // Modifies real DB + }); + + it('should create user', async () => { + const user = await service.create({ email: 'test@test.com' }); + // Side effects on shared database + }); +}); + +// Incomplete mocks +const mockHttpService = { + get: jest.fn().mockResolvedValue({ data: {} }), + // Missing error scenarios, missing other methods +}; +``` + +**Correct (mock all external dependencies):** + +```typescript +// Mock HTTP service properly +describe('WeatherService', () => { + let service: WeatherService; + let httpService: jest.Mocked<HttpService>; + + beforeEach(async () => { + const module = await Test.createTestingModule({ + providers: [ + WeatherService, + { + provide: HttpService, + useValue: { + get: jest.fn(), + post: jest.fn(), + }, + }, + ], + }).compile(); + + service = module.get(WeatherService); + httpService = module.get(HttpService); + }); + + it('should return weather data', async () => { + const mockResponse = { + data: { temperature: 72, humidity: 45 }, + status: 200, + statusText: 'OK', + headers: {}, + config: {}, + }; + + httpService.get.mockReturnValue(of(mockResponse)); + + const result = await service.getWeather('NYC'); + + expect(result).toEqual({ temperature: 72, humidity: 45 }); + }); + + it('should handle API timeout', async () => { + httpService.get.mockReturnValue(throwError(() => new Error('ETIMEDOUT'))); + + await expect(service.getWeather('NYC')).rejects.toThrow('Weather service unavailable'); + }); + + it('should handle rate limiting', async () => { + httpService.get.mockReturnValue( + throwError(() => ({ + response: { status: 429, data: { message: 'Rate limited' } }, + })), + ); + + await expect(service.getWeather('NYC')).rejects.toThrow(TooManyRequestsException); + }); +}); + +// Mock repository instead of database +describe('UsersService', () => { + let service: UsersService; + let repo: jest.Mocked<Repository<User>>; + + beforeEach(async () => { + const mockRepo = { + find: jest.fn(), + findOne: jest.fn(), + save: jest.fn(), + delete: jest.fn(), + createQueryBuilder: jest.fn(), + }; + + const module = await Test.createTestingModule({ + providers: [UsersService, { provide: getRepositoryToken(User), useValue: mockRepo }], + }).compile(); + + service = module.get(UsersService); + repo = module.get(getRepositoryToken(User)); + }); + + it('should find user by id', async () => { + const mockUser = { id: '1', name: 'John', email: 'john@test.com' }; + repo.findOne.mockResolvedValue(mockUser); + + const result = await service.findById('1'); + + expect(result).toEqual(mockUser); + expect(repo.findOne).toHaveBeenCalledWith({ where: { id: '1' } }); + }); +}); + +// Create mock factory for complex SDKs +function createMockStripe(): jest.Mocked<Stripe> { + return { + paymentIntents: { + create: jest.fn(), + retrieve: jest.fn(), + confirm: jest.fn(), + cancel: jest.fn(), + }, + customers: { + create: jest.fn(), + retrieve: jest.fn(), + }, + } as any; +} + +// Mock time for time-dependent tests +describe('TokenService', () => { + beforeEach(() => { + jest.useFakeTimers(); + jest.setSystemTime(new Date('2024-01-15')); + }); + + afterEach(() => { + jest.useRealTimers(); + }); + + it('should expire token after 1 hour', async () => { + const token = await service.createToken(); + + // Fast-forward time + jest.advanceTimersByTime(61 * 60 * 1000); + + expect(await service.isValid(token)).toBe(false); + }); +}); +``` + +Reference: [Jest Mocking](https://jestjs.io/docs/mock-functions) + +--- + +### 6.3 Use Testing Module for Unit Tests + +**Impact: HIGH** — Enables proper isolated testing with mocked dependencies + +Use `@nestjs/testing` module to create isolated test environments with mocked dependencies. This ensures your tests run fast, don't depend on external services, and properly test your business logic in isolation. + +**Incorrect (manual instantiation bypassing DI):** + +```typescript +// Instantiate services manually without DI +describe('UsersService', () => { + it('should create user', async () => { + // Manual instantiation bypasses DI + const repo = new UserRepository(); // Real repo! + const service = new UsersService(repo); + + const user = await service.create({ name: 'Test' }); + // This hits the real database! + }); +}); + +// Test implementation details +describe('UsersController', () => { + it('should call service', async () => { + const service = { create: jest.fn() }; + const controller = new UsersController(service as any); + + await controller.create({ name: 'Test' }); + + expect(service.create).toHaveBeenCalled(); // Tests implementation, not behavior + }); +}); +``` + +**Correct (use Test.createTestingModule with mocked dependencies):** + +```typescript +// Use Test.createTestingModule for proper DI +import { Test, TestingModule } from '@nestjs/testing'; + +describe('UsersService', () => { + let service: UsersService; + let repo: jest.Mocked<UserRepository>; + + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + providers: [ + UsersService, + { + provide: UserRepository, + useValue: { + save: jest.fn(), + findOne: jest.fn(), + find: jest.fn(), + }, + }, + ], + }).compile(); + + service = module.get<UsersService>(UsersService); + repo = module.get(UserRepository); + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + describe('create', () => { + it('should save and return user', async () => { + const dto = { name: 'John', email: 'john@test.com' }; + const expectedUser = { id: '1', ...dto }; + + repo.save.mockResolvedValue(expectedUser); + + const result = await service.create(dto); + + expect(result).toEqual(expectedUser); + expect(repo.save).toHaveBeenCalledWith(dto); + }); + + it('should throw on duplicate email', async () => { + repo.findOne.mockResolvedValue({ id: '1', email: 'test@test.com' }); + + await expect(service.create({ name: 'Test', email: 'test@test.com' })).rejects.toThrow( + ConflictException, + ); + }); + }); + + describe('findById', () => { + it('should return user when found', async () => { + const user = { id: '1', name: 'John' }; + repo.findOne.mockResolvedValue(user); + + const result = await service.findById('1'); + + expect(result).toEqual(user); + }); + + it('should throw NotFoundException when not found', async () => { + repo.findOne.mockResolvedValue(null); + + await expect(service.findById('999')).rejects.toThrow(NotFoundException); + }); + }); +}); + +// Testing guards and interceptors +describe('RolesGuard', () => { + let guard: RolesGuard; + let reflector: Reflector; + + beforeEach(async () => { + const module = await Test.createTestingModule({ + providers: [RolesGuard, Reflector], + }).compile(); + + guard = module.get<RolesGuard>(RolesGuard); + reflector = module.get<Reflector>(Reflector); + }); + + it('should allow when no roles required', () => { + const context = createMockExecutionContext({ user: { roles: [] } }); + jest.spyOn(reflector, 'getAllAndOverride').mockReturnValue(undefined); + + expect(guard.canActivate(context)).toBe(true); + }); + + it('should allow admin for admin-only route', () => { + const context = createMockExecutionContext({ user: { roles: ['admin'] } }); + jest.spyOn(reflector, 'getAllAndOverride').mockReturnValue(['admin']); + + expect(guard.canActivate(context)).toBe(true); + }); +}); + +function createMockExecutionContext(request: Partial<Request>): ExecutionContext { + return { + switchToHttp: () => ({ + getRequest: () => request, + }), + getHandler: () => jest.fn(), + getClass: () => jest.fn(), + } as ExecutionContext; +} +``` + +Reference: [NestJS Testing](https://docs.nestjs.com/fundamentals/testing) + +--- + +## 7. Database & ORM + +**Section Impact: MEDIUM-HIGH** + +### 7.1 Avoid N+1 Query Problems + +**Impact: HIGH** — N+1 queries are one of the most common performance killers + +N+1 queries occur when you fetch a list of entities, then make an additional query for each entity to load related data. Use eager loading with `relations`, query builder joins, or DataLoader to batch queries efficiently. + +**Incorrect (lazy loading in loops causes N+1):** + +```typescript +// Lazy loading in loops causes N+1 +@Injectable() +export class OrdersService { + async getOrdersWithItems(userId: string): Promise<Order[]> { + const orders = await this.orderRepo.find({ where: { userId } }); + // 1 query for orders + + for (const order of orders) { + // N additional queries - one per order! + order.items = await this.itemRepo.find({ where: { orderId: order.id } }); + } + + return orders; + } +} + +// Accessing lazy relations without loading +@Controller('users') +export class UsersController { + @Get() + async findAll(): Promise<User[]> { + const users = await this.userRepo.find(); + // If User.posts is lazy-loaded, serializing triggers N queries + return users; // Each user.posts access = 1 query + } +} +``` + +**Correct (use relations for eager loading):** + +```typescript +// Use relations option for eager loading +@Injectable() +export class OrdersService { + async getOrdersWithItems(userId: string): Promise<Order[]> { + // Single query with JOIN + return this.orderRepo.find({ + where: { userId }, + relations: ['items', 'items.product'], + }); + } +} + +// Use QueryBuilder for complex joins +@Injectable() +export class UsersService { + async getUsersWithPostCounts(): Promise<UserWithPostCount[]> { + return this.userRepo + .createQueryBuilder('user') + .leftJoin('user.posts', 'post') + .select('user.id', 'id') + .addSelect('user.name', 'name') + .addSelect('COUNT(post.id)', 'postCount') + .groupBy('user.id') + .getRawMany(); + } + + async getActiveUsersWithPosts(): Promise<User[]> { + return this.userRepo + .createQueryBuilder('user') + .leftJoinAndSelect('user.posts', 'post') + .leftJoinAndSelect('post.comments', 'comment') + .where('user.isActive = :active', { active: true }) + .andWhere('post.status = :status', { status: 'published' }) + .getMany(); + } +} + +// Use find options for specific fields +async getOrderSummaries(userId: string): Promise<OrderSummary[]> { + return this.orderRepo.find({ + where: { userId }, + relations: ['items'], + select: { + id: true, + total: true, + status: true, + items: { + id: true, + quantity: true, + price: true, + }, + }, + }); +} + +// Use DataLoader for GraphQL to batch and cache queries +import DataLoader from 'dataloader'; + +@Injectable({ scope: Scope.REQUEST }) +export class PostsLoader { + constructor(private postsService: PostsService) {} + + readonly batchPosts = new DataLoader<string, Post[]>(async (userIds) => { + // Single query for all users' posts + const posts = await this.postsService.findByUserIds([...userIds]); + + // Group by userId + const postsMap = new Map<string, Post[]>(); + for (const post of posts) { + const userPosts = postsMap.get(post.userId) || []; + userPosts.push(post); + postsMap.set(post.userId, userPosts); + } + + // Return in same order as input + return userIds.map((id) => postsMap.get(id) || []); + }); +} + +// In resolver +@ResolveField() +async posts(@Parent() user: User): Promise<Post[]> { + // DataLoader batches multiple calls into single query + return this.postsLoader.batchPosts.load(user.id); +} + +// Enable query logging in development to detect N+1 +TypeOrmModule.forRoot({ + logging: ['query', 'error'], + logger: 'advanced-console', +}); +``` + +Reference: [TypeORM Relations](https://typeorm.io/relations) + +--- + +### 7.2 Use Database Migrations + +**Impact: HIGH** — Enables safe, repeatable database schema changes + +Never use `synchronize: true` in production. Use migrations for all schema changes. Migrations provide version control for your database, enable safe rollbacks, and ensure consistency across all environments. + +**Incorrect (using synchronize or manual SQL):** + +```typescript +// Use synchronize in production +TypeOrmModule.forRoot({ + type: 'postgres', + synchronize: true, // DANGEROUS in production! + // Can drop columns, tables, or data +}); + +// Manual SQL in production +@Injectable() +export class DatabaseService { + async addColumn(): Promise<void> { + await this.dataSource.query('ALTER TABLE users ADD COLUMN age INT'); + // No version control, no rollback, inconsistent across envs + } +} + +// Modify entities without migration +@Entity() +export class User { + @Column() + email: string; + + @Column() // Added without migration + newField: string; // Will crash in production if synchronize is false +} +``` + +**Correct (use migrations for all schema changes):** + +```typescript +// Configure TypeORM for migrations +// data-source.ts +export const dataSource = new DataSource({ + type: 'postgres', + host: process.env.DB_HOST, + port: parseInt(process.env.DB_PORT), + username: process.env.DB_USERNAME, + password: process.env.DB_PASSWORD, + database: process.env.DB_NAME, + entities: ['dist/**/*.entity.js'], + migrations: ['dist/migrations/*.js'], + synchronize: false, // Always false in production + migrationsRun: true, // Run migrations on startup +}); + +// app.module.ts +TypeOrmModule.forRootAsync({ + inject: [ConfigService], + useFactory: (config: ConfigService) => ({ + type: 'postgres', + host: config.get('DB_HOST'), + synchronize: config.get('NODE_ENV') === 'development', // Only in dev + migrations: ['dist/migrations/*.js'], + migrationsRun: true, + }), +}); + +// migrations/1705312800000-AddUserAge.ts +import { MigrationInterface, QueryRunner } from 'typeorm'; + +export class AddUserAge1705312800000 implements MigrationInterface { + name = 'AddUserAge1705312800000'; + + public async up(queryRunner: QueryRunner): Promise<void> { + // Add column with default to handle existing rows + await queryRunner.query(` + ALTER TABLE "users" ADD "age" integer DEFAULT 0 + `); + + // Add index for frequently queried columns + await queryRunner.query(` + CREATE INDEX "IDX_users_age" ON "users" ("age") + `); + } + + public async down(queryRunner: QueryRunner): Promise<void> { + // Always implement down for rollback + await queryRunner.query(`DROP INDEX "IDX_users_age"`); + await queryRunner.query(`ALTER TABLE "users" DROP COLUMN "age"`); + } +} + +// Safe column rename (two-step) +export class RenameNameToFullName1705312900000 implements MigrationInterface { + public async up(queryRunner: QueryRunner): Promise<void> { + // Step 1: Add new column + await queryRunner.query(` + ALTER TABLE "users" ADD "full_name" varchar(255) + `); + + // Step 2: Copy data + await queryRunner.query(` + UPDATE "users" SET "full_name" = "name" + `); + + // Step 3: Add NOT NULL constraint + await queryRunner.query(` + ALTER TABLE "users" ALTER COLUMN "full_name" SET NOT NULL + `); + + // Step 4: Drop old column (after verifying app works) + await queryRunner.query(` + ALTER TABLE "users" DROP COLUMN "name" + `); + } + + public async down(queryRunner: QueryRunner): Promise<void> { + await queryRunner.query(`ALTER TABLE "users" ADD "name" varchar(255)`); + await queryRunner.query(`UPDATE "users" SET "name" = "full_name"`); + await queryRunner.query(`ALTER TABLE "users" DROP COLUMN "full_name"`); + } +} +``` + +Reference: [TypeORM Migrations](https://typeorm.io/migrations) + +--- + +### 7.3 Use Transactions for Multi-Step Operations + +**Impact: HIGH** — Ensures data consistency in multi-step operations + +When multiple database operations must succeed or fail together, wrap them in a transaction. This prevents partial updates that leave your data in an inconsistent state. Use TypeORM's transaction APIs or the DataSource query runner for complex scenarios. + +**Incorrect (multiple saves without transaction):** + +```typescript +// Multiple saves without transaction +@Injectable() +export class OrdersService { + async createOrder(userId: string, items: OrderItem[]): Promise<Order> { + // If any step fails, data is inconsistent + const order = await this.orderRepo.save({ userId, status: 'pending' }); + + for (const item of items) { + await this.orderItemRepo.save({ orderId: order.id, ...item }); + await this.inventoryRepo.decrement({ productId: item.productId }, 'stock', item.quantity); + } + + await this.paymentService.charge(order.id); + // If payment fails, order and inventory are already modified! + + return order; + } +} +``` + +**Correct (use DataSource.transaction for automatic rollback):** + +```typescript +// Use DataSource.transaction() for automatic rollback +@Injectable() +export class OrdersService { + constructor(private dataSource: DataSource) {} + + async createOrder(userId: string, items: OrderItem[]): Promise<Order> { + return this.dataSource.transaction(async (manager) => { + // All operations use the same transactional manager + const order = await manager.save(Order, { userId, status: 'pending' }); + + for (const item of items) { + await manager.save(OrderItem, { orderId: order.id, ...item }); + await manager.decrement(Inventory, { productId: item.productId }, 'stock', item.quantity); + } + + // If this throws, everything rolls back + await this.paymentService.chargeWithManager(manager, order.id); + + return order; + }); + } +} + +// QueryRunner for manual transaction control +@Injectable() +export class TransferService { + constructor(private dataSource: DataSource) {} + + async transfer(fromId: string, toId: string, amount: number): Promise<void> { + const queryRunner = this.dataSource.createQueryRunner(); + await queryRunner.connect(); + await queryRunner.startTransaction(); + + try { + // Debit source account + await queryRunner.manager.decrement(Account, { id: fromId }, 'balance', amount); + + // Verify sufficient funds + const source = await queryRunner.manager.findOne(Account, { + where: { id: fromId }, + }); + if (source.balance < 0) { + throw new BadRequestException('Insufficient funds'); + } + + // Credit destination account + await queryRunner.manager.increment(Account, { id: toId }, 'balance', amount); + + // Log the transaction + await queryRunner.manager.save(TransactionLog, { + fromId, + toId, + amount, + timestamp: new Date(), + }); + + await queryRunner.commitTransaction(); + } catch (error) { + await queryRunner.rollbackTransaction(); + throw error; + } finally { + await queryRunner.release(); + } + } +} + +// Repository method with transaction support +@Injectable() +export class UsersRepository { + constructor( + @InjectRepository(User) private repo: Repository<User>, + private dataSource: DataSource, + ) {} + + async createWithProfile(userData: CreateUserDto, profileData: CreateProfileDto): Promise<User> { + return this.dataSource.transaction(async (manager) => { + const user = await manager.save(User, userData); + await manager.save(Profile, { ...profileData, userId: user.id }); + return user; + }); + } +} +``` + +Reference: [TypeORM Transactions](https://typeorm.io/transactions) + +--- + +## 8. API Design + +**Section Impact: MEDIUM** + +### 8.1 Use DTOs and Serialization for API Responses + +**Impact: MEDIUM** — Response DTOs prevent accidental data exposure and ensure consistency + +Never return entity objects directly from controllers. Use response DTOs with class-transformer's `@Exclude()` and `@Expose()` decorators to control exactly what data is sent to clients. This prevents accidental exposure of sensitive fields and provides a stable API contract. + +**Incorrect (returning entities directly or manual spreading):** + +```typescript +// Return entities directly +@Controller('users') +export class UsersController { + @Get(':id') + async findOne(@Param('id') id: string): Promise<User> { + return this.usersService.findById(id); + // Returns: { id, email, passwordHash, ssn, internalNotes, ... } + // Exposes sensitive data! + } +} + +// Manual object spreading (error-prone) +@Get(':id') +async findOne(@Param('id') id: string) { + const user = await this.usersService.findById(id); + return { + id: user.id, + email: user.email, + name: user.name, + // Easy to forget to exclude sensitive fields + // Hard to maintain across endpoints + }; +} +``` + +**Correct (use class-transformer with @Exclude and response DTOs):** + +```typescript +// Enable class-transformer globally +async function bootstrap() { + const app = await NestFactory.create(AppModule); + app.useGlobalInterceptors(new ClassSerializerInterceptor(app.get(Reflector))); + await app.listen(3000); +} + +// Entity with serialization control +@Entity() +export class User { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Column() + email: string; + + @Column() + name: string; + + @Column() + @Exclude() // Never include in responses + passwordHash: string; + + @Column({ nullable: true }) + @Exclude() + ssn: string; + + @Column({ default: false }) + @Exclude({ toPlainOnly: true }) // Exclude from response, allow in requests + isAdmin: boolean; + + @CreateDateColumn() + createdAt: Date; + + @Column() + @Exclude() + internalNotes: string; +} + +// Now returning entity is safe +@Controller('users') +export class UsersController { + @Get(':id') + async findOne(@Param('id') id: string): Promise<User> { + return this.usersService.findById(id); + // Returns: { id, email, name, createdAt } + // Sensitive fields excluded automatically + } +} + +// For different response shapes, use explicit DTOs +export class UserResponseDto { + @Expose() + id: string; + + @Expose() + email: string; + + @Expose() + name: string; + + @Expose() + @Transform(({ obj }) => obj.posts?.length || 0) + postCount: number; + + constructor(partial: Partial<User>) { + Object.assign(this, partial); + } +} + +export class UserDetailResponseDto extends UserResponseDto { + @Expose() + createdAt: Date; + + @Expose() + @Type(() => PostResponseDto) + posts: PostResponseDto[]; +} + +// Controller with explicit DTOs +@Controller('users') +export class UsersController { + @Get() + @SerializeOptions({ type: UserResponseDto }) + async findAll(): Promise<UserResponseDto[]> { + const users = await this.usersService.findAll(); + return users.map((u) => plainToInstance(UserResponseDto, u)); + } + + @Get(':id') + async findOne(@Param('id') id: string): Promise<UserDetailResponseDto> { + const user = await this.usersService.findByIdWithPosts(id); + return plainToInstance(UserDetailResponseDto, user, { + excludeExtraneousValues: true, + }); + } +} + +// Groups for conditional serialization +export class UserDto { + @Expose() + id: string; + + @Expose() + name: string; + + @Expose({ groups: ['admin'] }) + email: string; + + @Expose({ groups: ['admin'] }) + createdAt: Date; + + @Expose({ groups: ['admin', 'owner'] }) + settings: UserSettings; +} + +@Controller('users') +export class UsersController { + @Get() + @SerializeOptions({ groups: ['public'] }) + async findAllPublic(): Promise<UserDto[]> { + // Returns: { id, name } + } + + @Get('admin') + @UseGuards(AdminGuard) + @SerializeOptions({ groups: ['admin'] }) + async findAllAdmin(): Promise<UserDto[]> { + // Returns: { id, name, email, createdAt } + } + + @Get('me') + @SerializeOptions({ groups: ['owner'] }) + async getProfile(@CurrentUser() user: User): Promise<UserDto> { + // Returns: { id, name, settings } + } +} +``` + +Reference: [NestJS Serialization](https://docs.nestjs.com/techniques/serialization) + +--- + +### 8.2 Use Interceptors for Cross-Cutting Concerns + +**Impact: MEDIUM-HIGH** — Interceptors provide clean separation for cross-cutting logic + +Interceptors can transform responses, add logging, handle caching, and measure performance without polluting your business logic. They wrap the route handler execution, giving you access to both the request and response streams. + +**Incorrect (logging and transformation in every method):** + +```typescript +// Logging in every controller method +@Controller('users') +export class UsersController { + @Get() + async findAll(): Promise<User[]> { + const start = Date.now(); + this.logger.log('findAll called'); + + const users = await this.usersService.findAll(); + + this.logger.log(`findAll completed in ${Date.now() - start}ms`); + return users; + } + + @Get(':id') + async findOne(@Param('id') id: string): Promise<User> { + const start = Date.now(); + this.logger.log(`findOne called with id: ${id}`); + + const user = await this.usersService.findOne(id); + + this.logger.log(`findOne completed in ${Date.now() - start}ms`); + return user; + } + // Repeated in every method! +} + +// Manual response wrapping +@Get() +async findAll(): Promise<{ data: User[]; meta: Meta }> { + const users = await this.usersService.findAll(); + return { + data: users, + meta: { timestamp: new Date(), count: users.length }, + }; +} +``` + +**Correct (use interceptors for cross-cutting concerns):** + +```typescript +// Logging interceptor +@Injectable() +export class LoggingInterceptor implements NestInterceptor { + private readonly logger = new Logger('HTTP'); + + intercept(context: ExecutionContext, next: CallHandler): Observable<any> { + const request = context.switchToHttp().getRequest(); + const { method, url, body } = request; + const now = Date.now(); + + return next.handle().pipe( + tap({ + next: (data) => { + const response = context.switchToHttp().getResponse(); + this.logger.log( + `${method} ${url} ${response.statusCode} - ${Date.now() - now}ms`, + ); + }, + error: (error) => { + this.logger.error( + `${method} ${url} ${error.status || 500} - ${Date.now() - now}ms`, + error.stack, + ); + }, + }), + ); + } +} + +// Response transformation interceptor +@Injectable() +export class TransformInterceptor<T> implements NestInterceptor<T, Response<T>> { + intercept(context: ExecutionContext, next: CallHandler): Observable<Response<T>> { + return next.handle().pipe( + map((data) => ({ + data, + meta: { + timestamp: new Date().toISOString(), + path: context.switchToHttp().getRequest().url, + }, + })), + ); + } +} + +// Timeout interceptor +@Injectable() +export class TimeoutInterceptor implements NestInterceptor { + intercept(context: ExecutionContext, next: CallHandler): Observable<any> { + return next.handle().pipe( + timeout(5000), + catchError((err) => { + if (err instanceof TimeoutError) { + throw new RequestTimeoutException('Request timed out'); + } + throw err; + }), + ); + } +} + +// Apply globally or per-controller +@Module({ + providers: [ + { provide: APP_INTERCEPTOR, useClass: LoggingInterceptor }, + { provide: APP_INTERCEPTOR, useClass: TransformInterceptor }, + ], +}) +export class AppModule {} + +// Or per-controller +@Controller('users') +@UseInterceptors(LoggingInterceptor) +export class UsersController { + @Get() + async findAll(): Promise<User[]> { + // Clean business logic only + return this.usersService.findAll(); + } +} + +// Custom cache interceptor with TTL +@Injectable() +export class HttpCacheInterceptor implements NestInterceptor { + constructor( + private cacheManager: Cache, + private reflector: Reflector, + ) {} + + async intercept(context: ExecutionContext, next: CallHandler): Promise<Observable<any>> { + const request = context.switchToHttp().getRequest(); + + // Only cache GET requests + if (request.method !== 'GET') { + return next.handle(); + } + + const cacheKey = this.generateKey(request); + const ttl = this.reflector.get<number>('cacheTTL', context.getHandler()) || 300; + + const cached = await this.cacheManager.get(cacheKey); + if (cached) { + return of(cached); + } + + return next.handle().pipe( + tap((response) => { + this.cacheManager.set(cacheKey, response, ttl); + }), + ); + } + + private generateKey(request: Request): string { + return `cache:${request.url}:${JSON.stringify(request.query)}`; + } +} + +// Usage with custom TTL +@Get() +@SetMetadata('cacheTTL', 600) +@UseInterceptors(HttpCacheInterceptor) +async findAll(): Promise<User[]> { + return this.usersService.findAll(); +} + +// Error mapping interceptor +@Injectable() +export class ErrorMappingInterceptor implements NestInterceptor { + intercept(context: ExecutionContext, next: CallHandler): Observable<any> { + return next.handle().pipe( + catchError((error) => { + if (error instanceof EntityNotFoundError) { + throw new NotFoundException(error.message); + } + if (error instanceof QueryFailedError) { + if (error.message.includes('duplicate')) { + throw new ConflictException('Resource already exists'); + } + } + throw error; + }), + ); + } +} +``` + +Reference: [NestJS Interceptors](https://docs.nestjs.com/interceptors) + +--- + +### 8.3 Use Pipes for Input Transformation + +**Impact: MEDIUM** — Pipes ensure clean, validated data reaches your handlers + +Use built-in pipes like `ParseIntPipe`, `ParseUUIDPipe`, and `DefaultValuePipe` for common transformations. Create custom pipes for business-specific transformations. Pipes separate validation/transformation logic from controllers. + +**Incorrect (manual type parsing in handlers):** + +```typescript +// Manual type parsing in handlers +@Controller('users') +export class UsersController { + @Get(':id') + async findOne(@Param('id') id: string): Promise<User> { + // Manual validation in every handler + const uuid = id.trim(); + if (!isUUID(uuid)) { + throw new BadRequestException('Invalid UUID'); + } + return this.usersService.findOne(uuid); + } + + @Get() + async findAll( + @Query('page') page: string, + @Query('limit') limit: string, + ): Promise<User[]> { + // Manual parsing and defaults + const pageNum = parseInt(page) || 1; + const limitNum = parseInt(limit) || 10; + return this.usersService.findAll(pageNum, limitNum); + } +} + +// Type coercion without validation +@Get() +async search(@Query('price') price: string): Promise<Product[]> { + const priceNum = +price; // NaN if invalid, no error + return this.productsService.findByPrice(priceNum); +} +``` + +**Correct (use built-in and custom pipes):** + +```typescript +// Use built-in pipes for common transformations +@Controller('users') +export class UsersController { + @Get(':id') + async findOne(@Param('id', ParseUUIDPipe) id: string): Promise<User> { + // id is guaranteed to be a valid UUID + return this.usersService.findOne(id); + } + + @Get() + async findAll( + @Query('page', new DefaultValuePipe(1), ParseIntPipe) page: number, + @Query('limit', new DefaultValuePipe(10), ParseIntPipe) limit: number, + ): Promise<User[]> { + // Automatic defaults and type conversion + return this.usersService.findAll(page, limit); + } + + @Get('by-status/:status') + async findByStatus( + @Param('status', new ParseEnumPipe(UserStatus)) status: UserStatus, + ): Promise<User[]> { + return this.usersService.findByStatus(status); + } +} + +// Custom pipe for business logic +@Injectable() +export class ParseDatePipe implements PipeTransform<string, Date> { + transform(value: string): Date { + const date = new Date(value); + if (isNaN(date.getTime())) { + throw new BadRequestException('Invalid date format'); + } + return date; + } +} + +@Get('reports') +async getReports( + @Query('from', ParseDatePipe) from: Date, + @Query('to', ParseDatePipe) to: Date, +): Promise<Report[]> { + return this.reportsService.findBetween(from, to); +} + +// Custom transformation pipes +@Injectable() +export class NormalizeEmailPipe implements PipeTransform<string, string> { + transform(value: string): string { + if (!value) return value; + return value.trim().toLowerCase(); + } +} + +// Parse comma-separated values +@Injectable() +export class ParseArrayPipe implements PipeTransform<string, string[]> { + transform(value: string): string[] { + if (!value) return []; + return value.split(',').map((v) => v.trim()).filter(Boolean); + } +} + +@Get('products') +async findProducts( + @Query('ids', ParseArrayPipe) ids: string[], + @Query('email', NormalizeEmailPipe) email: string, +): Promise<Product[]> { + // ids is already an array, email is normalized + return this.productsService.findByIds(ids); +} + +// Sanitize HTML input +@Injectable() +export class SanitizeHtmlPipe implements PipeTransform<string, string> { + transform(value: string): string { + if (!value) return value; + return sanitizeHtml(value, { allowedTags: [] }); + } +} + +// Global validation pipe with transformation +app.useGlobalPipes( + new ValidationPipe({ + whitelist: true, // Strip non-DTO properties + transform: true, // Auto-transform to DTO types + transformOptions: { + enableImplicitConversion: true, // Convert query strings to numbers + }, + forbidNonWhitelisted: true, // Throw on extra properties + }), +); + +// DTO with transformation decorators +export class FindProductsDto { + @IsOptional() + @Type(() => Number) + @IsInt() + @Min(1) + page?: number = 1; + + @IsOptional() + @Type(() => Number) + @IsInt() + @Min(1) + @Max(100) + limit?: number = 10; + + @IsOptional() + @Transform(({ value }) => value?.toLowerCase()) + @IsString() + search?: string; + + @IsOptional() + @Transform(({ value }) => value?.split(',')) + @IsArray() + @IsString({ each: true }) + categories?: string[]; +} + +@Get() +async findAll(@Query() dto: FindProductsDto): Promise<Product[]> { + // dto is already transformed and validated + return this.productsService.findAll(dto); +} + +// Pipe error customization +@Injectable() +export class CustomParseIntPipe extends ParseIntPipe { + constructor() { + super({ + exceptionFactory: (error) => + new BadRequestException(`${error} must be a valid integer`), + }); + } +} + +// Or use options on built-in pipes +@Get(':id') +async findOne( + @Param( + 'id', + new ParseIntPipe({ + errorHttpStatusCode: HttpStatus.NOT_ACCEPTABLE, + exceptionFactory: () => new NotAcceptableException('ID must be numeric'), + }), + ) + id: number, +): Promise<Item> { + return this.itemsService.findOne(id); +} +``` + +Reference: [NestJS Pipes](https://docs.nestjs.com/pipes) + +--- + +### 8.4 Use API Versioning for Breaking Changes + +**Impact: MEDIUM** — Versioning allows you to evolve APIs without breaking existing clients + +Use NestJS built-in versioning when making breaking changes to your API. Choose a versioning strategy (URI, header, or media type) and apply it consistently. This allows old clients to continue working while new clients use updated endpoints. + +**Incorrect (breaking changes without versioning):** + +```typescript +// Breaking changes without versioning +@Controller('users') +export class UsersController { + @Get(':id') + async findOne(@Param('id') id: string): Promise<User> { + // Original response: { id, name, email } + // Later changed to: { id, firstName, lastName, emailAddress } + // Old clients break! + return this.usersService.findOne(id); + } +} + +// Manual versioning in routes +@Controller('v1/users') +export class UsersV1Controller {} + +@Controller('v2/users') +export class UsersV2Controller {} +// Inconsistent, error-prone, hard to maintain +``` + +**Correct (use NestJS built-in versioning):** + +```typescript +// Enable versioning in main.ts +async function bootstrap() { + const app = await NestFactory.create(AppModule); + + // URI versioning: /v1/users, /v2/users + app.enableVersioning({ + type: VersioningType.URI, + defaultVersion: '1', + }); + + // Or header versioning: X-API-Version: 1 + app.enableVersioning({ + type: VersioningType.HEADER, + header: 'X-API-Version', + defaultVersion: '1', + }); + + // Or media type: Accept: application/json;v=1 + app.enableVersioning({ + type: VersioningType.MEDIA_TYPE, + key: 'v=', + defaultVersion: '1', + }); + + await app.listen(3000); +} + +// Version-specific controllers +@Controller('users') +@Version('1') +export class UsersV1Controller { + @Get(':id') + async findOne(@Param('id') id: string): Promise<UserV1Response> { + const user = await this.usersService.findOne(id); + // V1 response format + return { + id: user.id, + name: user.name, + email: user.email, + }; + } +} + +@Controller('users') +@Version('2') +export class UsersV2Controller { + @Get(':id') + async findOne(@Param('id') id: string): Promise<UserV2Response> { + const user = await this.usersService.findOne(id); + // V2 response format with breaking changes + return { + id: user.id, + firstName: user.firstName, + lastName: user.lastName, + emailAddress: user.email, + createdAt: user.createdAt, + }; + } +} + +// Per-route versioning - different versions for different routes +@Controller('users') +export class UsersController { + @Get() + @Version('1') + findAllV1(): Promise<UserV1Response[]> { + return this.usersService.findAllV1(); + } + + @Get() + @Version('2') + findAllV2(): Promise<UserV2Response[]> { + return this.usersService.findAllV2(); + } + + @Get(':id') + @Version(['1', '2']) // Same handler for multiple versions + findOne(@Param('id') id: string): Promise<User> { + return this.usersService.findOne(id); + } + + @Post() + @Version(VERSION_NEUTRAL) // Available in all versions + create(@Body() dto: CreateUserDto): Promise<User> { + return this.usersService.create(dto); + } +} + +// Shared service with version-specific logic +@Injectable() +export class UsersService { + async findOne(id: string, version: string): Promise<any> { + const user = await this.repo.findOne({ where: { id } }); + + if (version === '1') { + return this.toV1Response(user); + } + return this.toV2Response(user); + } + + private toV1Response(user: User): UserV1Response { + return { + id: user.id, + name: `${user.firstName} ${user.lastName}`, + email: user.email, + }; + } + + private toV2Response(user: User): UserV2Response { + return { + id: user.id, + firstName: user.firstName, + lastName: user.lastName, + emailAddress: user.email, + createdAt: user.createdAt, + }; + } +} + +// Controller extracts version +@Controller('users') +export class UsersController { + @Get(':id') + async findOne( + @Param('id') id: string, + @Headers('X-API-Version') version: string = '1', + ): Promise<any> { + return this.usersService.findOne(id, version); + } +} + +// Deprecation strategy - mark old versions as deprecated +@Controller('users') +@Version('1') +@UseInterceptors(DeprecationInterceptor) +export class UsersV1Controller { + // All V1 routes will include deprecation warning +} + +@Injectable() +export class DeprecationInterceptor implements NestInterceptor { + intercept(context: ExecutionContext, next: CallHandler): Observable<any> { + const response = context.switchToHttp().getResponse(); + response.setHeader('Deprecation', 'true'); + response.setHeader('Sunset', 'Sat, 1 Jan 2025 00:00:00 GMT'); + response.setHeader('Link', '</v2/users>; rel="successor-version"'); + + return next.handle(); + } +} +``` + +Reference: [NestJS Versioning](https://docs.nestjs.com/techniques/versioning) + +--- + +## 9. Microservices + +**Section Impact: MEDIUM** + +### 9.1 Implement Health Checks for Microservices + +**Impact: MEDIUM-HIGH** — Health checks enable orchestrators to manage service lifecycle + +Implement liveness and readiness probes using `@nestjs/terminus`. Liveness checks determine if the service should be restarted. Readiness checks determine if the service can accept traffic. Proper health checks enable Kubernetes and load balancers to route traffic correctly. + +**Incorrect (simple ping that doesn't check dependencies):** + +```typescript +// Simple ping that doesn't check dependencies +@Controller('health') +export class HealthController { + @Get() + check(): string { + return 'OK'; // Service might be unhealthy but returns OK + } +} + +// Health check that blocks on slow dependencies +@Controller('health') +export class HealthController { + @Get() + async check(): Promise<string> { + // If database is slow, health check times out + await this.userRepo.findOne({ where: { id: '1' } }); + await this.redis.ping(); + await this.externalApi.healthCheck(); + return 'OK'; + } +} +``` + +**Correct (use @nestjs/terminus for comprehensive health checks):** + +```typescript +// Use @nestjs/terminus for comprehensive health checks +import { + HealthCheckService, + HttpHealthIndicator, + TypeOrmHealthIndicator, + HealthCheck, + DiskHealthIndicator, + MemoryHealthIndicator, +} from '@nestjs/terminus'; + +@Controller('health') +export class HealthController { + constructor( + private health: HealthCheckService, + private http: HttpHealthIndicator, + private db: TypeOrmHealthIndicator, + private disk: DiskHealthIndicator, + private memory: MemoryHealthIndicator, + ) {} + + // Liveness probe - is the service alive? + @Get('live') + @HealthCheck() + liveness() { + return this.health.check([ + // Basic checks only + () => this.memory.checkHeap('memory_heap', 200 * 1024 * 1024), // 200MB + ]); + } + + // Readiness probe - can the service handle traffic? + @Get('ready') + @HealthCheck() + readiness() { + return this.health.check([ + () => this.db.pingCheck('database'), + () => + this.http.pingCheck('redis', 'http://redis:6379', { timeout: 1000 }), + () => + this.disk.checkStorage('disk', { path: '/', thresholdPercent: 0.9 }), + ]); + } + + // Deep health check for debugging + @Get('deep') + @HealthCheck() + deepCheck() { + return this.health.check([ + () => this.db.pingCheck('database'), + () => this.memory.checkHeap('memory_heap', 200 * 1024 * 1024), + () => this.memory.checkRSS('memory_rss', 300 * 1024 * 1024), + () => + this.disk.checkStorage('disk', { path: '/', thresholdPercent: 0.9 }), + () => + this.http.pingCheck('external-api', 'https://api.example.com/health'), + ]); + } +} + +// Custom indicator for business-specific health +@Injectable() +export class QueueHealthIndicator extends HealthIndicator { + constructor(private queueService: QueueService) { + super(); + } + + async isHealthy(key: string): Promise<HealthIndicatorResult> { + const queueStats = await this.queueService.getStats(); + + const isHealthy = queueStats.failedCount < 100; + const result = this.getStatus(key, isHealthy, { + waiting: queueStats.waitingCount, + active: queueStats.activeCount, + failed: queueStats.failedCount, + }); + + if (!isHealthy) { + throw new HealthCheckError('Queue unhealthy', result); + } + + return result; + } +} + +// Redis health indicator +@Injectable() +export class RedisHealthIndicator extends HealthIndicator { + constructor(@InjectRedis() private redis: Redis) { + super(); + } + + async isHealthy(key: string): Promise<HealthIndicatorResult> { + try { + const pong = await this.redis.ping(); + return this.getStatus(key, pong === 'PONG'); + } catch (error) { + throw new HealthCheckError('Redis check failed', this.getStatus(key, false)); + } + } +} + +// Use custom indicators +@Get('ready') +@HealthCheck() +readiness() { + return this.health.check([ + () => this.db.pingCheck('database'), + () => this.redis.isHealthy('redis'), + () => this.queue.isHealthy('job-queue'), + ]); +} + +// Graceful shutdown handling +@Injectable() +export class GracefulShutdownService implements OnApplicationShutdown { + private isShuttingDown = false; + + isShutdown(): boolean { + return this.isShuttingDown; + } + + async onApplicationShutdown(signal: string): Promise<void> { + this.isShuttingDown = true; + console.log(`Shutting down on ${signal}`); + + // Wait for in-flight requests + await new Promise((resolve) => setTimeout(resolve, 5000)); + } +} + +// Health check respects shutdown state +@Get('ready') +@HealthCheck() +readiness() { + if (this.shutdownService.isShutdown()) { + throw new ServiceUnavailableException('Shutting down'); + } + + return this.health.check([ + () => this.db.pingCheck('database'), + ]); +} +``` + +### Kubernetes Configuration + +```yaml +# Kubernetes deployment with probes +apiVersion: apps/v1 +kind: Deployment +metadata: + name: api-service +spec: + template: + spec: + containers: + - name: api + image: api-service:latest + ports: + - containerPort: 3000 + livenessProbe: + httpGet: + path: /health/live + port: 3000 + initialDelaySeconds: 30 + periodSeconds: 10 + timeoutSeconds: 5 + failureThreshold: 3 + readinessProbe: + httpGet: + path: /health/ready + port: 3000 + initialDelaySeconds: 5 + periodSeconds: 5 + timeoutSeconds: 3 + failureThreshold: 3 + startupProbe: + httpGet: + path: /health/live + port: 3000 + initialDelaySeconds: 0 + periodSeconds: 5 + failureThreshold: 30 +``` + +Reference: [NestJS Terminus](https://docs.nestjs.com/recipes/terminus) + +--- + +### 9.2 Use Message and Event Patterns Correctly + +**Impact: MEDIUM** — Proper patterns ensure reliable microservice communication + +NestJS microservices support two communication patterns: request-response (MessagePattern) and event-based (EventPattern). Use MessagePattern when you need a response, and EventPattern for fire-and-forget notifications. Understanding the difference prevents communication bugs. + +**Incorrect (using wrong pattern for use case):** + +```typescript +// Use @MessagePattern for fire-and-forget +@Controller() +export class NotificationsController { + @MessagePattern('user.created') + async handleUserCreated(data: UserCreatedEvent) { + // This WAITS for response, blocking the sender + await this.emailService.sendWelcome(data.email); + // If email fails, sender gets an error (coupling!) + } +} + +// Use @EventPattern expecting a response +@Controller() +export class OrdersController { + @EventPattern('inventory.check') + async checkInventory(data: CheckInventoryDto) { + const available = await this.inventory.check(data); + return available; // This return value is IGNORED with @EventPattern! + } +} + +// Tight coupling in client +@Injectable() +export class UsersService { + async createUser(dto: CreateUserDto): Promise<User> { + const user = await this.repo.save(dto); + + // Blocks until notification service responds + await this.client.send('user.created', user).toPromise(); + // If notification service is down, user creation fails! + + return user; + } +} +``` + +**Correct (use MessagePattern for request-response, EventPattern for fire-and-forget):** + +```typescript +// MessagePattern: Request-Response (when you NEED a response) +@Controller() +export class InventoryController { + @MessagePattern({ cmd: 'check_inventory' }) + async checkInventory(data: CheckInventoryDto): Promise<InventoryResult> { + const result = await this.inventoryService.check(data.productId, data.quantity); + return result; // Response sent back to caller + } +} + +// Client expects response +@Injectable() +export class OrdersService { + async createOrder(dto: CreateOrderDto): Promise<Order> { + // Check inventory - we NEED this response to proceed + const inventory = await firstValueFrom( + this.inventoryClient.send<InventoryResult>( + { cmd: 'check_inventory' }, + { productId: dto.productId, quantity: dto.quantity }, + ), + ); + + if (!inventory.available) { + throw new BadRequestException('Insufficient inventory'); + } + + return this.repo.save(dto); + } +} + +// EventPattern: Fire-and-Forget (for notifications, side effects) +@Controller() +export class NotificationsController { + @EventPattern('user.created') + async handleUserCreated(data: UserCreatedEvent): Promise<void> { + // No return value needed - just process the event + await this.emailService.sendWelcome(data.email); + await this.analyticsService.track('user_signup', data); + // If this fails, it doesn't affect the sender + } +} + +// Client emits event without waiting +@Injectable() +export class UsersService { + async createUser(dto: CreateUserDto): Promise<User> { + const user = await this.repo.save(dto); + + // Fire and forget - doesn't block, doesn't wait + this.eventClient.emit('user.created', { + userId: user.id, + email: user.email, + timestamp: new Date(), + }); + + return user; // User creation succeeds regardless of event handling + } +} + +// Hybrid pattern for critical events +@Injectable() +export class OrdersService { + async createOrder(dto: CreateOrderDto): Promise<Order> { + const order = await this.repo.save(dto); + + // Critical: inventory reservation (use MessagePattern) + const reserved = await firstValueFrom( + this.inventoryClient.send({ cmd: 'reserve_inventory' }, { + orderId: order.id, + items: dto.items, + }), + ); + + if (!reserved.success) { + await this.repo.delete(order.id); + throw new BadRequestException('Could not reserve inventory'); + } + + // Non-critical: notifications (use EventPattern) + this.eventClient.emit('order.created', { + orderId: order.id, + userId: dto.userId, + total: dto.total, + }); + + return order; + } +} + +// Error handling patterns +// MessagePattern errors propagate to caller +@MessagePattern({ cmd: 'get_user' }) +async getUser(userId: string): Promise<User> { + const user = await this.repo.findOne({ where: { id: userId } }); + if (!user) { + throw new RpcException('User not found'); // Received by caller + } + return user; +} + +// EventPattern errors should be handled locally +@EventPattern('order.created') +async handleOrderCreated(data: OrderCreatedEvent): Promise<void> { + try { + await this.processOrder(data); + } catch (error) { + // Log and potentially retry - don't throw + this.logger.error('Failed to process order event', error); + await this.deadLetterQueue.add(data); + } +} +``` + +Reference: [NestJS Microservices](https://docs.nestjs.com/microservices/basics) + +--- + +### 9.3 Use Message Queues for Background Jobs + +**Impact: MEDIUM-HIGH** — Queues enable reliable background processing + +Use `@nestjs/bullmq` for background job processing. Queues decouple long-running tasks from HTTP requests, enable retry logic, and distribute workload across workers. Use them for emails, file processing, notifications, and any task that shouldn't block user requests. + +**Incorrect (long-running tasks in HTTP handlers):** + +```typescript +// Long-running tasks in HTTP handlers +@Controller('reports') +export class ReportsController { + @Post() + async generate(@Body() dto: GenerateReportDto): Promise<Report> { + // This blocks the request for potentially minutes + const data = await this.fetchLargeDataset(dto); + const report = await this.processData(data); // Slow! + await this.sendEmail(dto.email, report); // Can fail! + return report; // Client times out + } +} + +// Fire-and-forget without retry +@Injectable() +export class EmailService { + async sendWelcome(email: string): Promise<void> { + // If this fails, email is never sent + await this.mailer.send({ to: email, template: 'welcome' }); + // No retry, no tracking, no visibility + } +} + +// Use setInterval for scheduled tasks +setInterval(async () => { + await cleanupOldRecords(); +}, 60000); // No error handling, memory leaks +``` + +**Correct (use BullMQ for background processing):** + +```typescript +// Configure BullMQ +import { BullModule } from '@nestjs/bullmq'; + +@Module({ + imports: [ + BullModule.forRoot({ + connection: { + host: 'localhost', + port: 6379, + }, + defaultJobOptions: { + removeOnComplete: 1000, + removeOnFail: 5000, + attempts: 3, + backoff: { + type: 'exponential', + delay: 1000, + }, + }, + }), + BullModule.registerQueue({ name: 'email' }, { name: 'reports' }, { name: 'notifications' }), + ], +}) +export class QueueModule {} + +// Producer: Add jobs to queue +@Injectable() +export class ReportsService { + constructor(@InjectQueue('reports') private reportsQueue: Queue) {} + + async requestReport(dto: GenerateReportDto): Promise<{ jobId: string }> { + // Return immediately, process in background + const job = await this.reportsQueue.add('generate', dto, { + priority: dto.urgent ? 1 : 10, + delay: dto.scheduledFor ? Date.parse(dto.scheduledFor) - Date.now() : 0, + }); + + return { jobId: job.id }; + } + + async getJobStatus(jobId: string): Promise<JobStatus> { + const job = await this.reportsQueue.getJob(jobId); + return { + status: await job.getState(), + progress: job.progress, + result: job.returnvalue, + }; + } +} + +// Consumer: Process jobs +@Processor('reports') +export class ReportsProcessor { + private readonly logger = new Logger(ReportsProcessor.name); + + @Process('generate') + async generateReport(job: Job<GenerateReportDto>): Promise<Report> { + this.logger.log(`Processing report job ${job.id}`); + + // Update progress + await job.updateProgress(10); + + const data = await this.fetchData(job.data); + await job.updateProgress(50); + + const report = await this.processData(data); + await job.updateProgress(90); + + await this.saveReport(report); + await job.updateProgress(100); + + return report; + } + + @OnQueueActive() + onActive(job: Job) { + this.logger.log(`Processing job ${job.id}`); + } + + @OnQueueCompleted() + onCompleted(job: Job, result: any) { + this.logger.log(`Job ${job.id} completed`); + } + + @OnQueueFailed() + onFailed(job: Job, error: Error) { + this.logger.error(`Job ${job.id} failed: ${error.message}`); + } +} + +// Email queue with retry +@Processor('email') +export class EmailProcessor { + @Process('send') + async sendEmail(job: Job<SendEmailDto>): Promise<void> { + const { to, template, data } = job.data; + + try { + await this.mailer.send({ + to, + template, + context: data, + }); + } catch (error) { + // BullMQ will retry based on job options + throw error; + } + } +} + +// Usage +@Injectable() +export class NotificationService { + constructor(@InjectQueue('email') private emailQueue: Queue) {} + + async sendWelcome(user: User): Promise<void> { + await this.emailQueue.add( + 'send', + { + to: user.email, + template: 'welcome', + data: { name: user.name }, + }, + { + attempts: 5, + backoff: { type: 'exponential', delay: 5000 }, + }, + ); + } +} + +// Scheduled jobs +@Injectable() +export class ScheduledJobsService implements OnModuleInit { + constructor(@InjectQueue('maintenance') private queue: Queue) {} + + async onModuleInit(): Promise<void> { + // Clean up old reports daily at midnight + await this.queue.add( + 'cleanup', + {}, + { + repeat: { cron: '0 0 * * *' }, + jobId: 'daily-cleanup', // Prevent duplicates + }, + ); + + // Send digest every hour + await this.queue.add( + 'digest', + {}, + { + repeat: { every: 60 * 60 * 1000 }, + jobId: 'hourly-digest', + }, + ); + } +} + +@Processor('maintenance') +export class MaintenanceProcessor { + @Process('cleanup') + async cleanup(): Promise<void> { + await this.cleanupOldReports(); + await this.cleanupExpiredSessions(); + } + + @Process('digest') + async sendDigest(): Promise<void> { + const users = await this.getUsersForDigest(); + for (const user of users) { + await this.emailQueue.add('send', { to: user.email, template: 'digest' }); + } + } +} + +// Queue monitoring with Bull Board +import { BullBoardModule } from '@bull-board/nestjs'; +import { BullMQAdapter } from '@bull-board/api/bullMQAdapter'; + +@Module({ + imports: [ + BullBoardModule.forRoot({ + route: '/admin/queues', + adapter: ExpressAdapter, + }), + BullBoardModule.forFeature({ + name: 'email', + adapter: BullMQAdapter, + }), + BullBoardModule.forFeature({ + name: 'reports', + adapter: BullMQAdapter, + }), + ], +}) +export class AdminModule {} +``` + +Reference: [NestJS Queues](https://docs.nestjs.com/techniques/queues) + +--- + +## 10. DevOps & Deployment + +**Section Impact: LOW-MEDIUM** + +### 10.1 Implement Graceful Shutdown + +**Impact: MEDIUM-HIGH** — Proper shutdown handling ensures zero-downtime deployments + +Handle SIGTERM and SIGINT signals to gracefully shutdown your NestJS application. Stop accepting new requests, wait for in-flight requests to complete, close database connections, and clean up resources. This prevents data loss and connection errors during deployments. + +**Incorrect (ignoring shutdown signals):** + +```typescript +// Ignore shutdown signals +async function bootstrap() { + const app = await NestFactory.create(AppModule); + await app.listen(3000); + // App crashes immediately on SIGTERM + // In-flight requests fail + // Database connections are abruptly closed +} + +// Long-running tasks without cancellation +@Injectable() +export class ProcessingService { + async processLargeFile(file: File): Promise<void> { + // No way to interrupt this during shutdown + for (let i = 0; i < file.chunks.length; i++) { + await this.processChunk(file.chunks[i]); + // May run for minutes, blocking shutdown + } + } +} +``` + +**Correct (enable shutdown hooks and handle cleanup):** + +```typescript +// Enable shutdown hooks in main.ts +async function bootstrap() { + const app = await NestFactory.create(AppModule); + + // Enable shutdown hooks + app.enableShutdownHooks(); + + // Optional: Add timeout for forced shutdown + const server = await app.listen(3000); + server.setTimeout(30000); // 30 second timeout + + // Handle graceful shutdown + const signals = ['SIGTERM', 'SIGINT']; + signals.forEach((signal) => { + process.on(signal, async () => { + console.log(`Received ${signal}, starting graceful shutdown...`); + + // Stop accepting new connections + server.close(async () => { + console.log('HTTP server closed'); + await app.close(); + process.exit(0); + }); + + // Force exit after timeout + setTimeout(() => { + console.error('Forced shutdown after timeout'); + process.exit(1); + }, 30000); + }); + }); +} + +// Lifecycle hooks for cleanup +@Injectable() +export class DatabaseService implements OnApplicationShutdown { + private readonly connections: Connection[] = []; + + async onApplicationShutdown(signal?: string): Promise<void> { + console.log(`Database service shutting down on ${signal}`); + + // Close all connections gracefully + await Promise.all(this.connections.map((conn) => conn.close())); + + console.log('All database connections closed'); + } +} + +// Queue processor with graceful shutdown +@Injectable() +export class QueueService implements OnApplicationShutdown, OnModuleDestroy { + private isShuttingDown = false; + + onModuleDestroy(): void { + this.isShuttingDown = true; + } + + async onApplicationShutdown(): Promise<void> { + // Wait for current jobs to complete + await this.queue.close(); + } + + async processJob(job: Job): Promise<void> { + if (this.isShuttingDown) { + throw new Error('Service is shutting down'); + } + await this.doWork(job); + } +} + +// WebSocket gateway cleanup +@WebSocketGateway() +export class EventsGateway implements OnApplicationShutdown { + @WebSocketServer() + server: Server; + + async onApplicationShutdown(): Promise<void> { + // Notify all connected clients + this.server.emit('shutdown', { message: 'Server is shutting down' }); + + // Close all connections + this.server.disconnectSockets(); + } +} + +// Health check integration +@Injectable() +export class ShutdownService { + private isShuttingDown = false; + + startShutdown(): void { + this.isShuttingDown = true; + } + + isShutdown(): boolean { + return this.isShuttingDown; + } +} + +@Controller('health') +export class HealthController { + constructor(private shutdownService: ShutdownService) {} + + @Get('ready') + @HealthCheck() + readiness(): Promise<HealthCheckResult> { + // Return 503 during shutdown - k8s stops sending traffic + if (this.shutdownService.isShutdown()) { + throw new ServiceUnavailableException('Shutting down'); + } + + return this.health.check([() => this.db.pingCheck('database')]); + } +} + +// Integrate with shutdown +@Injectable() +export class AppShutdownService implements OnApplicationShutdown { + constructor(private shutdownService: ShutdownService) {} + + async onApplicationShutdown(): Promise<void> { + // Mark as unhealthy first + this.shutdownService.startShutdown(); + + // Wait for k8s to update endpoints + await this.sleep(5000); + + // Then proceed with cleanup + } +} + +// Request tracking for in-flight requests +@Injectable() +export class RequestTracker implements NestMiddleware, OnApplicationShutdown { + private activeRequests = 0; + private isShuttingDown = false; + private shutdownPromise: Promise<void> | null = null; + private resolveShutdown: (() => void) | null = null; + + use(req: Request, res: Response, next: NextFunction): void { + if (this.isShuttingDown) { + res.status(503).send('Service Unavailable'); + return; + } + + this.activeRequests++; + + res.on('finish', () => { + this.activeRequests--; + if (this.isShuttingDown && this.activeRequests === 0 && this.resolveShutdown) { + this.resolveShutdown(); + } + }); + + next(); + } + + async onApplicationShutdown(): Promise<void> { + this.isShuttingDown = true; + + if (this.activeRequests > 0) { + console.log(`Waiting for ${this.activeRequests} requests to complete`); + this.shutdownPromise = new Promise((resolve) => { + this.resolveShutdown = resolve; + }); + + // Wait with timeout + await Promise.race([ + this.shutdownPromise, + new Promise((resolve) => setTimeout(resolve, 30000)), + ]); + } + + console.log('All requests completed'); + } +} +``` + +Reference: [NestJS Lifecycle Events](https://docs.nestjs.com/fundamentals/lifecycle-events) + +--- + +### 10.2 Use ConfigModule for Environment Configuration + +**Impact: LOW-MEDIUM** — Proper configuration prevents deployment failures + +Use `@nestjs/config` for environment-based configuration. Validate configuration at startup to fail fast on misconfigurations. Use namespaced configuration for organization and type safety. + +**Incorrect (accessing process.env directly):** + +```typescript +// Access process.env directly +@Injectable() +export class DatabaseService { + constructor() { + // No validation, can fail at runtime + this.connection = new Pool({ + host: process.env.DB_HOST, + port: parseInt(process.env.DB_PORT), // NaN if missing + password: process.env.DB_PASSWORD, // undefined if missing + }); + } +} + +// Scattered env access +@Injectable() +export class EmailService { + sendEmail() { + // Different services access env differently + const apiKey = process.env.SENDGRID_API_KEY || 'default'; + // Typos go unnoticed: process.env.SENDGRID_API_KY + } +} +``` + +**Correct (use @nestjs/config with validation):** + +```typescript +// Setup validated configuration +import { ConfigModule, ConfigService, registerAs } from '@nestjs/config'; +import * as Joi from 'joi'; + +// config/database.config.ts +export const databaseConfig = registerAs('database', () => ({ + host: process.env.DB_HOST, + port: parseInt(process.env.DB_PORT, 10), + username: process.env.DB_USERNAME, + password: process.env.DB_PASSWORD, + database: process.env.DB_NAME, +})); + +// config/app.config.ts +export const appConfig = registerAs('app', () => ({ + port: parseInt(process.env.PORT, 10) || 3000, + environment: process.env.NODE_ENV || 'development', + apiPrefix: process.env.API_PREFIX || 'api', +})); + +// config/validation.schema.ts +export const validationSchema = Joi.object({ + NODE_ENV: Joi.string().valid('development', 'production', 'test').default('development'), + PORT: Joi.number().default(3000), + DB_HOST: Joi.string().required(), + DB_PORT: Joi.number().default(5432), + DB_USERNAME: Joi.string().required(), + DB_PASSWORD: Joi.string().required(), + DB_NAME: Joi.string().required(), + JWT_SECRET: Joi.string().min(32).required(), + REDIS_URL: Joi.string().uri().required(), +}); + +// app.module.ts +@Module({ + imports: [ + ConfigModule.forRoot({ + isGlobal: true, // Available everywhere without importing + load: [databaseConfig, appConfig], + validationSchema, + validationOptions: { + abortEarly: true, // Stop on first error + allowUnknown: true, // Allow other env vars + }, + }), + TypeOrmModule.forRootAsync({ + inject: [ConfigService], + useFactory: (config: ConfigService) => ({ + type: 'postgres', + host: config.get('database.host'), + port: config.get('database.port'), + username: config.get('database.username'), + password: config.get('database.password'), + database: config.get('database.database'), + autoLoadEntities: true, + }), + }), + ], +}) +export class AppModule {} + +// Type-safe configuration access +export interface AppConfig { + port: number; + environment: 'development' | 'production' | 'test'; + apiPrefix: string; +} + +export interface DatabaseConfig { + host: string; + port: number; + username: string; + password: string; + database: string; +} + +// Type-safe access +@Injectable() +export class AppService { + constructor(private config: ConfigService) {} + + getPort(): number { + // Type-safe with generic + return this.config.get<number>('app.port'); + } + + getDatabaseConfig(): DatabaseConfig { + return this.config.get<DatabaseConfig>('database'); + } +} + +// Inject namespaced config directly +@Injectable() +export class DatabaseService { + constructor( + @Inject(databaseConfig.KEY) + private dbConfig: ConfigType<typeof databaseConfig>, + ) { + // Full type inference! + const host = this.dbConfig.host; // string + const port = this.dbConfig.port; // number + } +} + +// Environment files support +ConfigModule.forRoot({ + envFilePath: [ + `.env.${process.env.NODE_ENV}.local`, + `.env.${process.env.NODE_ENV}`, + '.env.local', + '.env', + ], +}); + +// .env.development +// DB_HOST=localhost +// DB_PORT=5432 + +// .env.production +// DB_HOST=prod-db.example.com +// DB_PORT=5432 +``` + +Reference: [NestJS Configuration](https://docs.nestjs.com/techniques/configuration) + +--- + +### 10.3 Use Structured Logging + +**Impact: MEDIUM-HIGH** — Structured logging enables effective debugging and monitoring + +Use NestJS Logger with structured JSON output in production. Include contextual information (request ID, user ID, operation) to trace requests across services. Avoid console.log and implement proper log levels. + +**Incorrect (using console.log in production):** + +```typescript +// Use console.log in production +@Injectable() +export class UsersService { + async createUser(dto: CreateUserDto): Promise<User> { + console.log('Creating user:', dto); + // Not structured, no levels, lost in production logs + + try { + const user = await this.repo.save(dto); + console.log('User created:', user.id); + return user; + } catch (error) { + console.log('Error:', error); // Using log for errors + throw error; + } + } +} + +// Log sensitive data +console.log('Login attempt:', { email, password }); // SECURITY RISK! + +// Inconsistent log format +logger.log('User ' + userId + ' created at ' + new Date()); +// Hard to parse, no structure +``` + +**Correct (use structured logging with context):** + +```typescript +// Configure logger in main.ts +async function bootstrap() { + const app = await NestFactory.create(AppModule, { + logger: + process.env.NODE_ENV === 'production' + ? ['error', 'warn', 'log'] + : ['error', 'warn', 'log', 'debug', 'verbose'], + }); +} + +// Use NestJS Logger with context +@Injectable() +export class UsersService { + private readonly logger = new Logger(UsersService.name); + + async createUser(dto: CreateUserDto): Promise<User> { + this.logger.log('Creating user', { email: dto.email }); + + try { + const user = await this.repo.save(dto); + this.logger.log('User created', { userId: user.id }); + return user; + } catch (error) { + this.logger.error('Failed to create user', error.stack, { + email: dto.email, + }); + throw error; + } + } +} + +// Custom logger for JSON output +@Injectable() +export class JsonLogger implements LoggerService { + log(message: string, context?: object): void { + console.log( + JSON.stringify({ + level: 'info', + timestamp: new Date().toISOString(), + message, + ...context, + }), + ); + } + + error(message: string, trace?: string, context?: object): void { + console.error( + JSON.stringify({ + level: 'error', + timestamp: new Date().toISOString(), + message, + trace, + ...context, + }), + ); + } + + warn(message: string, context?: object): void { + console.warn( + JSON.stringify({ + level: 'warn', + timestamp: new Date().toISOString(), + message, + ...context, + }), + ); + } + + debug(message: string, context?: object): void { + console.debug( + JSON.stringify({ + level: 'debug', + timestamp: new Date().toISOString(), + message, + ...context, + }), + ); + } +} + +// Request context logging with ClsModule +import { ClsModule, ClsService } from 'nestjs-cls'; + +@Module({ + imports: [ + ClsModule.forRoot({ + global: true, + middleware: { + mount: true, + generateId: true, + }, + }), + ], +}) +export class AppModule {} + +// Middleware to set request context +@Injectable() +export class RequestContextMiddleware implements NestMiddleware { + constructor(private cls: ClsService) {} + + use(req: Request, res: Response, next: NextFunction): void { + const requestId = req.headers['x-request-id'] || randomUUID(); + this.cls.set('requestId', requestId); + this.cls.set('userId', req.user?.id); + + res.setHeader('x-request-id', requestId); + next(); + } +} + +// Logger that includes request context +@Injectable() +export class ContextLogger { + constructor(private cls: ClsService) {} + + log(message: string, data?: object): void { + console.log( + JSON.stringify({ + level: 'info', + timestamp: new Date().toISOString(), + requestId: this.cls.get('requestId'), + userId: this.cls.get('userId'), + message, + ...data, + }), + ); + } + + error(message: string, error: Error, data?: object): void { + console.error( + JSON.stringify({ + level: 'error', + timestamp: new Date().toISOString(), + requestId: this.cls.get('requestId'), + userId: this.cls.get('userId'), + message, + error: error.message, + stack: error.stack, + ...data, + }), + ); + } +} + +// Pino integration for high-performance logging +import { LoggerModule } from 'nestjs-pino'; + +@Module({ + imports: [ + LoggerModule.forRoot({ + pinoHttp: { + level: process.env.NODE_ENV === 'production' ? 'info' : 'debug', + transport: process.env.NODE_ENV !== 'production' ? { target: 'pino-pretty' } : undefined, + redact: ['req.headers.authorization', 'req.body.password'], + serializers: { + req: (req) => ({ + method: req.method, + url: req.url, + query: req.query, + }), + res: (res) => ({ + statusCode: res.statusCode, + }), + }, + }, + }), + ], +}) +export class AppModule {} + +// Usage with Pino +@Injectable() +export class UsersService { + constructor(private logger: PinoLogger) { + this.logger.setContext(UsersService.name); + } + + async findOne(id: string): Promise<User> { + this.logger.info({ userId: id }, 'Finding user'); + // Pino uses first arg for data, second for message + } +} +``` + +Reference: [NestJS Logger](https://docs.nestjs.com/techniques/logger) + +--- + +## References + +- https://docs.nestjs.com +- https://github.com/nestjs/nest +- https://typeorm.io +- https://github.com/typestack/class-validator +- https://github.com/goldbergyoni/nodebestpractices + +--- + +_Generated by build-agents.ts on 2026-01-16_ diff --git a/packages/mosaic/framework/skills/nestjs-best-practices/SKILL.md b/packages/mosaic/framework/skills/nestjs-best-practices/SKILL.md new file mode 100644 index 00000000..8b89b9f9 --- /dev/null +++ b/packages/mosaic/framework/skills/nestjs-best-practices/SKILL.md @@ -0,0 +1,131 @@ +--- +name: nestjs-best-practices +description: NestJS best practices and architecture patterns for building production-ready applications. This skill should be used when writing, reviewing, or refactoring NestJS code to ensure proper patterns for modules, dependency injection, security, and performance. +license: MIT +metadata: + author: Kadajett + version: '1.1.0' +--- + +# NestJS Best Practices + +Comprehensive best practices guide for NestJS applications. Contains 40 rules across 10 categories, prioritized by impact to guide automated refactoring and code generation. + +## When to Apply + +Reference these guidelines when: + +- Writing new NestJS modules, controllers, or services +- Implementing authentication and authorization +- Reviewing code for architecture and security issues +- Refactoring existing NestJS codebases +- Optimizing performance or database queries +- Building microservices architectures + +## Rule Categories by Priority + +| Priority | Category | Impact | Prefix | +| -------- | -------------------- | ----------- | ----------- | +| 1 | Architecture | CRITICAL | `arch-` | +| 2 | Dependency Injection | CRITICAL | `di-` | +| 3 | Error Handling | HIGH | `error-` | +| 4 | Security | HIGH | `security-` | +| 5 | Performance | HIGH | `perf-` | +| 6 | Testing | MEDIUM-HIGH | `test-` | +| 7 | Database & ORM | MEDIUM-HIGH | `db-` | +| 8 | API Design | MEDIUM | `api-` | +| 9 | Microservices | MEDIUM | `micro-` | +| 10 | DevOps & Deployment | LOW-MEDIUM | `devops-` | + +## Quick Reference + +### 1. Architecture (CRITICAL) + +- `arch-avoid-circular-deps` - Avoid circular module dependencies +- `arch-feature-modules` - Organize by feature, not technical layer +- `arch-module-sharing` - Proper module exports/imports, avoid duplicate providers +- `arch-single-responsibility` - Focused services over "god services" +- `arch-use-repository-pattern` - Abstract database logic for testability +- `arch-use-events` - Event-driven architecture for decoupling + +### 2. Dependency Injection (CRITICAL) + +- `di-avoid-service-locator` - Avoid service locator anti-pattern +- `di-interface-segregation` - Interface Segregation Principle (ISP) +- `di-liskov-substitution` - Liskov Substitution Principle (LSP) +- `di-prefer-constructor-injection` - Constructor over property injection +- `di-scope-awareness` - Understand singleton/request/transient scopes +- `di-use-interfaces-tokens` - Use injection tokens for interfaces + +### 3. Error Handling (HIGH) + +- `error-use-exception-filters` - Centralized exception handling +- `error-throw-http-exceptions` - Use NestJS HTTP exceptions +- `error-handle-async-errors` - Handle async errors properly + +### 4. Security (HIGH) + +- `security-auth-jwt` - Secure JWT authentication +- `security-validate-all-input` - Validate with class-validator +- `security-use-guards` - Authentication and authorization guards +- `security-sanitize-output` - Prevent XSS attacks +- `security-rate-limiting` - Implement rate limiting + +### 5. Performance (HIGH) + +- `perf-async-hooks` - Proper async lifecycle hooks +- `perf-use-caching` - Implement caching strategies +- `perf-optimize-database` - Optimize database queries +- `perf-lazy-loading` - Lazy load modules for faster startup + +### 6. Testing (MEDIUM-HIGH) + +- `test-use-testing-module` - Use NestJS testing utilities +- `test-e2e-supertest` - E2E testing with Supertest +- `test-mock-external-services` - Mock external dependencies + +### 7. Database & ORM (MEDIUM-HIGH) + +- `db-use-transactions` - Transaction management +- `db-avoid-n-plus-one` - Avoid N+1 query problems +- `db-use-migrations` - Use migrations for schema changes + +### 8. API Design (MEDIUM) + +- `api-use-dto-serialization` - DTO and response serialization +- `api-use-interceptors` - Cross-cutting concerns +- `api-versioning` - API versioning strategies +- `api-use-pipes` - Input transformation with pipes + +### 9. Microservices (MEDIUM) + +- `micro-use-patterns` - Message and event patterns +- `micro-use-health-checks` - Health checks for orchestration +- `micro-use-queues` - Background job processing + +### 10. DevOps & Deployment (LOW-MEDIUM) + +- `devops-use-config-module` - Environment configuration +- `devops-use-logging` - Structured logging +- `devops-graceful-shutdown` - Zero-downtime deployments + +## How to Use + +Read individual rule files for detailed explanations and code examples: + +``` +rules/arch-avoid-circular-deps.md +rules/security-validate-all-input.md +rules/_sections.md +``` + +Each rule file contains: + +- Brief explanation of why it matters +- Incorrect code example with explanation +- Correct code example with explanation +- Additional context and references + +## Full Compiled Document + +For the complete guide with all rules expanded: `AGENTS.md` diff --git a/packages/mosaic/framework/skills/nestjs-best-practices/rules/api-use-dto-serialization.md b/packages/mosaic/framework/skills/nestjs-best-practices/rules/api-use-dto-serialization.md new file mode 100644 index 00000000..525c8058 --- /dev/null +++ b/packages/mosaic/framework/skills/nestjs-best-practices/rules/api-use-dto-serialization.md @@ -0,0 +1,182 @@ +--- +title: Use DTOs and Serialization for API Responses +impact: MEDIUM +impactDescription: Response DTOs prevent accidental data exposure and ensure consistency +tags: api, dto, serialization, class-transformer +--- + +## Use DTOs and Serialization for API Responses + +Never return entity objects directly from controllers. Use response DTOs with class-transformer's `@Exclude()` and `@Expose()` decorators to control exactly what data is sent to clients. This prevents accidental exposure of sensitive fields and provides a stable API contract. + +**Incorrect (returning entities directly or manual spreading):** + +```typescript +// Return entities directly +@Controller('users') +export class UsersController { + @Get(':id') + async findOne(@Param('id') id: string): Promise<User> { + return this.usersService.findById(id); + // Returns: { id, email, passwordHash, ssn, internalNotes, ... } + // Exposes sensitive data! + } +} + +// Manual object spreading (error-prone) +@Get(':id') +async findOne(@Param('id') id: string) { + const user = await this.usersService.findById(id); + return { + id: user.id, + email: user.email, + name: user.name, + // Easy to forget to exclude sensitive fields + // Hard to maintain across endpoints + }; +} +``` + +**Correct (use class-transformer with @Exclude and response DTOs):** + +```typescript +// Enable class-transformer globally +async function bootstrap() { + const app = await NestFactory.create(AppModule); + app.useGlobalInterceptors(new ClassSerializerInterceptor(app.get(Reflector))); + await app.listen(3000); +} + +// Entity with serialization control +@Entity() +export class User { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Column() + email: string; + + @Column() + name: string; + + @Column() + @Exclude() // Never include in responses + passwordHash: string; + + @Column({ nullable: true }) + @Exclude() + ssn: string; + + @Column({ default: false }) + @Exclude({ toPlainOnly: true }) // Exclude from response, allow in requests + isAdmin: boolean; + + @CreateDateColumn() + createdAt: Date; + + @Column() + @Exclude() + internalNotes: string; +} + +// Now returning entity is safe +@Controller('users') +export class UsersController { + @Get(':id') + async findOne(@Param('id') id: string): Promise<User> { + return this.usersService.findById(id); + // Returns: { id, email, name, createdAt } + // Sensitive fields excluded automatically + } +} + +// For different response shapes, use explicit DTOs +export class UserResponseDto { + @Expose() + id: string; + + @Expose() + email: string; + + @Expose() + name: string; + + @Expose() + @Transform(({ obj }) => obj.posts?.length || 0) + postCount: number; + + constructor(partial: Partial<User>) { + Object.assign(this, partial); + } +} + +export class UserDetailResponseDto extends UserResponseDto { + @Expose() + createdAt: Date; + + @Expose() + @Type(() => PostResponseDto) + posts: PostResponseDto[]; +} + +// Controller with explicit DTOs +@Controller('users') +export class UsersController { + @Get() + @SerializeOptions({ type: UserResponseDto }) + async findAll(): Promise<UserResponseDto[]> { + const users = await this.usersService.findAll(); + return users.map((u) => plainToInstance(UserResponseDto, u)); + } + + @Get(':id') + async findOne(@Param('id') id: string): Promise<UserDetailResponseDto> { + const user = await this.usersService.findByIdWithPosts(id); + return plainToInstance(UserDetailResponseDto, user, { + excludeExtraneousValues: true, + }); + } +} + +// Groups for conditional serialization +export class UserDto { + @Expose() + id: string; + + @Expose() + name: string; + + @Expose({ groups: ['admin'] }) + email: string; + + @Expose({ groups: ['admin'] }) + createdAt: Date; + + @Expose({ groups: ['admin', 'owner'] }) + settings: UserSettings; +} + +@Controller('users') +export class UsersController { + @Get() + @SerializeOptions({ groups: ['public'] }) + async findAllPublic(): Promise<UserDto[]> { + // Returns: { id, name } + } + + @Get('admin') + @UseGuards(AdminGuard) + @SerializeOptions({ groups: ['admin'] }) + async findAllAdmin(): Promise<UserDto[]> { + // Returns: { id, name, email, createdAt } + } + + @Get('me') + @SerializeOptions({ groups: ['owner'] }) + async getProfile(@CurrentUser() user: User): Promise<UserDto> { + // Returns: { id, name, settings } + } +} +``` + +Reference: [NestJS Serialization](https://docs.nestjs.com/techniques/serialization) diff --git a/packages/mosaic/framework/skills/nestjs-best-practices/rules/api-use-interceptors.md b/packages/mosaic/framework/skills/nestjs-best-practices/rules/api-use-interceptors.md new file mode 100644 index 00000000..522ab3d5 --- /dev/null +++ b/packages/mosaic/framework/skills/nestjs-best-practices/rules/api-use-interceptors.md @@ -0,0 +1,202 @@ +--- +title: Use Interceptors for Cross-Cutting Concerns +impact: MEDIUM-HIGH +impactDescription: Interceptors provide clean separation for cross-cutting logic +tags: api, interceptors, logging, caching +--- + +## Use Interceptors for Cross-Cutting Concerns + +Interceptors can transform responses, add logging, handle caching, and measure performance without polluting your business logic. They wrap the route handler execution, giving you access to both the request and response streams. + +**Incorrect (logging and transformation in every method):** + +```typescript +// Logging in every controller method +@Controller('users') +export class UsersController { + @Get() + async findAll(): Promise<User[]> { + const start = Date.now(); + this.logger.log('findAll called'); + + const users = await this.usersService.findAll(); + + this.logger.log(`findAll completed in ${Date.now() - start}ms`); + return users; + } + + @Get(':id') + async findOne(@Param('id') id: string): Promise<User> { + const start = Date.now(); + this.logger.log(`findOne called with id: ${id}`); + + const user = await this.usersService.findOne(id); + + this.logger.log(`findOne completed in ${Date.now() - start}ms`); + return user; + } + // Repeated in every method! +} + +// Manual response wrapping +@Get() +async findAll(): Promise<{ data: User[]; meta: Meta }> { + const users = await this.usersService.findAll(); + return { + data: users, + meta: { timestamp: new Date(), count: users.length }, + }; +} +``` + +**Correct (use interceptors for cross-cutting concerns):** + +```typescript +// Logging interceptor +@Injectable() +export class LoggingInterceptor implements NestInterceptor { + private readonly logger = new Logger('HTTP'); + + intercept(context: ExecutionContext, next: CallHandler): Observable<any> { + const request = context.switchToHttp().getRequest(); + const { method, url, body } = request; + const now = Date.now(); + + return next.handle().pipe( + tap({ + next: (data) => { + const response = context.switchToHttp().getResponse(); + this.logger.log( + `${method} ${url} ${response.statusCode} - ${Date.now() - now}ms`, + ); + }, + error: (error) => { + this.logger.error( + `${method} ${url} ${error.status || 500} - ${Date.now() - now}ms`, + error.stack, + ); + }, + }), + ); + } +} + +// Response transformation interceptor +@Injectable() +export class TransformInterceptor<T> implements NestInterceptor<T, Response<T>> { + intercept(context: ExecutionContext, next: CallHandler): Observable<Response<T>> { + return next.handle().pipe( + map((data) => ({ + data, + meta: { + timestamp: new Date().toISOString(), + path: context.switchToHttp().getRequest().url, + }, + })), + ); + } +} + +// Timeout interceptor +@Injectable() +export class TimeoutInterceptor implements NestInterceptor { + intercept(context: ExecutionContext, next: CallHandler): Observable<any> { + return next.handle().pipe( + timeout(5000), + catchError((err) => { + if (err instanceof TimeoutError) { + throw new RequestTimeoutException('Request timed out'); + } + throw err; + }), + ); + } +} + +// Apply globally or per-controller +@Module({ + providers: [ + { provide: APP_INTERCEPTOR, useClass: LoggingInterceptor }, + { provide: APP_INTERCEPTOR, useClass: TransformInterceptor }, + ], +}) +export class AppModule {} + +// Or per-controller +@Controller('users') +@UseInterceptors(LoggingInterceptor) +export class UsersController { + @Get() + async findAll(): Promise<User[]> { + // Clean business logic only + return this.usersService.findAll(); + } +} + +// Custom cache interceptor with TTL +@Injectable() +export class HttpCacheInterceptor implements NestInterceptor { + constructor( + private cacheManager: Cache, + private reflector: Reflector, + ) {} + + async intercept(context: ExecutionContext, next: CallHandler): Promise<Observable<any>> { + const request = context.switchToHttp().getRequest(); + + // Only cache GET requests + if (request.method !== 'GET') { + return next.handle(); + } + + const cacheKey = this.generateKey(request); + const ttl = this.reflector.get<number>('cacheTTL', context.getHandler()) || 300; + + const cached = await this.cacheManager.get(cacheKey); + if (cached) { + return of(cached); + } + + return next.handle().pipe( + tap((response) => { + this.cacheManager.set(cacheKey, response, ttl); + }), + ); + } + + private generateKey(request: Request): string { + return `cache:${request.url}:${JSON.stringify(request.query)}`; + } +} + +// Usage with custom TTL +@Get() +@SetMetadata('cacheTTL', 600) +@UseInterceptors(HttpCacheInterceptor) +async findAll(): Promise<User[]> { + return this.usersService.findAll(); +} + +// Error mapping interceptor +@Injectable() +export class ErrorMappingInterceptor implements NestInterceptor { + intercept(context: ExecutionContext, next: CallHandler): Observable<any> { + return next.handle().pipe( + catchError((error) => { + if (error instanceof EntityNotFoundError) { + throw new NotFoundException(error.message); + } + if (error instanceof QueryFailedError) { + if (error.message.includes('duplicate')) { + throw new ConflictException('Resource already exists'); + } + } + throw error; + }), + ); + } +} +``` + +Reference: [NestJS Interceptors](https://docs.nestjs.com/interceptors) diff --git a/packages/mosaic/framework/skills/nestjs-best-practices/rules/api-use-pipes.md b/packages/mosaic/framework/skills/nestjs-best-practices/rules/api-use-pipes.md new file mode 100644 index 00000000..72b9a6e9 --- /dev/null +++ b/packages/mosaic/framework/skills/nestjs-best-practices/rules/api-use-pipes.md @@ -0,0 +1,205 @@ +--- +title: Use Pipes for Input Transformation +impact: MEDIUM +impactDescription: Pipes ensure clean, validated data reaches your handlers +tags: api, pipes, validation, transformation +--- + +## Use Pipes for Input Transformation + +Use built-in pipes like `ParseIntPipe`, `ParseUUIDPipe`, and `DefaultValuePipe` for common transformations. Create custom pipes for business-specific transformations. Pipes separate validation/transformation logic from controllers. + +**Incorrect (manual type parsing in handlers):** + +```typescript +// Manual type parsing in handlers +@Controller('users') +export class UsersController { + @Get(':id') + async findOne(@Param('id') id: string): Promise<User> { + // Manual validation in every handler + const uuid = id.trim(); + if (!isUUID(uuid)) { + throw new BadRequestException('Invalid UUID'); + } + return this.usersService.findOne(uuid); + } + + @Get() + async findAll( + @Query('page') page: string, + @Query('limit') limit: string, + ): Promise<User[]> { + // Manual parsing and defaults + const pageNum = parseInt(page) || 1; + const limitNum = parseInt(limit) || 10; + return this.usersService.findAll(pageNum, limitNum); + } +} + +// Type coercion without validation +@Get() +async search(@Query('price') price: string): Promise<Product[]> { + const priceNum = +price; // NaN if invalid, no error + return this.productsService.findByPrice(priceNum); +} +``` + +**Correct (use built-in and custom pipes):** + +```typescript +// Use built-in pipes for common transformations +@Controller('users') +export class UsersController { + @Get(':id') + async findOne(@Param('id', ParseUUIDPipe) id: string): Promise<User> { + // id is guaranteed to be a valid UUID + return this.usersService.findOne(id); + } + + @Get() + async findAll( + @Query('page', new DefaultValuePipe(1), ParseIntPipe) page: number, + @Query('limit', new DefaultValuePipe(10), ParseIntPipe) limit: number, + ): Promise<User[]> { + // Automatic defaults and type conversion + return this.usersService.findAll(page, limit); + } + + @Get('by-status/:status') + async findByStatus( + @Param('status', new ParseEnumPipe(UserStatus)) status: UserStatus, + ): Promise<User[]> { + return this.usersService.findByStatus(status); + } +} + +// Custom pipe for business logic +@Injectable() +export class ParseDatePipe implements PipeTransform<string, Date> { + transform(value: string): Date { + const date = new Date(value); + if (isNaN(date.getTime())) { + throw new BadRequestException('Invalid date format'); + } + return date; + } +} + +@Get('reports') +async getReports( + @Query('from', ParseDatePipe) from: Date, + @Query('to', ParseDatePipe) to: Date, +): Promise<Report[]> { + return this.reportsService.findBetween(from, to); +} + +// Custom transformation pipes +@Injectable() +export class NormalizeEmailPipe implements PipeTransform<string, string> { + transform(value: string): string { + if (!value) return value; + return value.trim().toLowerCase(); + } +} + +// Parse comma-separated values +@Injectable() +export class ParseArrayPipe implements PipeTransform<string, string[]> { + transform(value: string): string[] { + if (!value) return []; + return value.split(',').map((v) => v.trim()).filter(Boolean); + } +} + +@Get('products') +async findProducts( + @Query('ids', ParseArrayPipe) ids: string[], + @Query('email', NormalizeEmailPipe) email: string, +): Promise<Product[]> { + // ids is already an array, email is normalized + return this.productsService.findByIds(ids); +} + +// Sanitize HTML input +@Injectable() +export class SanitizeHtmlPipe implements PipeTransform<string, string> { + transform(value: string): string { + if (!value) return value; + return sanitizeHtml(value, { allowedTags: [] }); + } +} + +// Global validation pipe with transformation +app.useGlobalPipes( + new ValidationPipe({ + whitelist: true, // Strip non-DTO properties + transform: true, // Auto-transform to DTO types + transformOptions: { + enableImplicitConversion: true, // Convert query strings to numbers + }, + forbidNonWhitelisted: true, // Throw on extra properties + }), +); + +// DTO with transformation decorators +export class FindProductsDto { + @IsOptional() + @Type(() => Number) + @IsInt() + @Min(1) + page?: number = 1; + + @IsOptional() + @Type(() => Number) + @IsInt() + @Min(1) + @Max(100) + limit?: number = 10; + + @IsOptional() + @Transform(({ value }) => value?.toLowerCase()) + @IsString() + search?: string; + + @IsOptional() + @Transform(({ value }) => value?.split(',')) + @IsArray() + @IsString({ each: true }) + categories?: string[]; +} + +@Get() +async findAll(@Query() dto: FindProductsDto): Promise<Product[]> { + // dto is already transformed and validated + return this.productsService.findAll(dto); +} + +// Pipe error customization +@Injectable() +export class CustomParseIntPipe extends ParseIntPipe { + constructor() { + super({ + exceptionFactory: (error) => + new BadRequestException(`${error} must be a valid integer`), + }); + } +} + +// Or use options on built-in pipes +@Get(':id') +async findOne( + @Param( + 'id', + new ParseIntPipe({ + errorHttpStatusCode: HttpStatus.NOT_ACCEPTABLE, + exceptionFactory: () => new NotAcceptableException('ID must be numeric'), + }), + ) + id: number, +): Promise<Item> { + return this.itemsService.findOne(id); +} +``` + +Reference: [NestJS Pipes](https://docs.nestjs.com/pipes) diff --git a/packages/mosaic/framework/skills/nestjs-best-practices/rules/api-versioning.md b/packages/mosaic/framework/skills/nestjs-best-practices/rules/api-versioning.md new file mode 100644 index 00000000..5e4546ca --- /dev/null +++ b/packages/mosaic/framework/skills/nestjs-best-practices/rules/api-versioning.md @@ -0,0 +1,191 @@ +--- +title: Use API Versioning for Breaking Changes +impact: MEDIUM +impactDescription: Versioning allows you to evolve APIs without breaking existing clients +tags: api, versioning, breaking-changes, compatibility +--- + +## Use API Versioning for Breaking Changes + +Use NestJS built-in versioning when making breaking changes to your API. Choose a versioning strategy (URI, header, or media type) and apply it consistently. This allows old clients to continue working while new clients use updated endpoints. + +**Incorrect (breaking changes without versioning):** + +```typescript +// Breaking changes without versioning +@Controller('users') +export class UsersController { + @Get(':id') + async findOne(@Param('id') id: string): Promise<User> { + // Original response: { id, name, email } + // Later changed to: { id, firstName, lastName, emailAddress } + // Old clients break! + return this.usersService.findOne(id); + } +} + +// Manual versioning in routes +@Controller('v1/users') +export class UsersV1Controller {} + +@Controller('v2/users') +export class UsersV2Controller {} +// Inconsistent, error-prone, hard to maintain +``` + +**Correct (use NestJS built-in versioning):** + +```typescript +// Enable versioning in main.ts +async function bootstrap() { + const app = await NestFactory.create(AppModule); + + // URI versioning: /v1/users, /v2/users + app.enableVersioning({ + type: VersioningType.URI, + defaultVersion: '1', + }); + + // Or header versioning: X-API-Version: 1 + app.enableVersioning({ + type: VersioningType.HEADER, + header: 'X-API-Version', + defaultVersion: '1', + }); + + // Or media type: Accept: application/json;v=1 + app.enableVersioning({ + type: VersioningType.MEDIA_TYPE, + key: 'v=', + defaultVersion: '1', + }); + + await app.listen(3000); +} + +// Version-specific controllers +@Controller('users') +@Version('1') +export class UsersV1Controller { + @Get(':id') + async findOne(@Param('id') id: string): Promise<UserV1Response> { + const user = await this.usersService.findOne(id); + // V1 response format + return { + id: user.id, + name: user.name, + email: user.email, + }; + } +} + +@Controller('users') +@Version('2') +export class UsersV2Controller { + @Get(':id') + async findOne(@Param('id') id: string): Promise<UserV2Response> { + const user = await this.usersService.findOne(id); + // V2 response format with breaking changes + return { + id: user.id, + firstName: user.firstName, + lastName: user.lastName, + emailAddress: user.email, + createdAt: user.createdAt, + }; + } +} + +// Per-route versioning - different versions for different routes +@Controller('users') +export class UsersController { + @Get() + @Version('1') + findAllV1(): Promise<UserV1Response[]> { + return this.usersService.findAllV1(); + } + + @Get() + @Version('2') + findAllV2(): Promise<UserV2Response[]> { + return this.usersService.findAllV2(); + } + + @Get(':id') + @Version(['1', '2']) // Same handler for multiple versions + findOne(@Param('id') id: string): Promise<User> { + return this.usersService.findOne(id); + } + + @Post() + @Version(VERSION_NEUTRAL) // Available in all versions + create(@Body() dto: CreateUserDto): Promise<User> { + return this.usersService.create(dto); + } +} + +// Shared service with version-specific logic +@Injectable() +export class UsersService { + async findOne(id: string, version: string): Promise<any> { + const user = await this.repo.findOne({ where: { id } }); + + if (version === '1') { + return this.toV1Response(user); + } + return this.toV2Response(user); + } + + private toV1Response(user: User): UserV1Response { + return { + id: user.id, + name: `${user.firstName} ${user.lastName}`, + email: user.email, + }; + } + + private toV2Response(user: User): UserV2Response { + return { + id: user.id, + firstName: user.firstName, + lastName: user.lastName, + emailAddress: user.email, + createdAt: user.createdAt, + }; + } +} + +// Controller extracts version +@Controller('users') +export class UsersController { + @Get(':id') + async findOne( + @Param('id') id: string, + @Headers('X-API-Version') version: string = '1', + ): Promise<any> { + return this.usersService.findOne(id, version); + } +} + +// Deprecation strategy - mark old versions as deprecated +@Controller('users') +@Version('1') +@UseInterceptors(DeprecationInterceptor) +export class UsersV1Controller { + // All V1 routes will include deprecation warning +} + +@Injectable() +export class DeprecationInterceptor implements NestInterceptor { + intercept(context: ExecutionContext, next: CallHandler): Observable<any> { + const response = context.switchToHttp().getResponse(); + response.setHeader('Deprecation', 'true'); + response.setHeader('Sunset', 'Sat, 1 Jan 2025 00:00:00 GMT'); + response.setHeader('Link', '</v2/users>; rel="successor-version"'); + + return next.handle(); + } +} +``` + +Reference: [NestJS Versioning](https://docs.nestjs.com/techniques/versioning) diff --git a/packages/mosaic/framework/skills/nestjs-best-practices/rules/arch-avoid-circular-deps.md b/packages/mosaic/framework/skills/nestjs-best-practices/rules/arch-avoid-circular-deps.md new file mode 100644 index 00000000..71997206 --- /dev/null +++ b/packages/mosaic/framework/skills/nestjs-best-practices/rules/arch-avoid-circular-deps.md @@ -0,0 +1,80 @@ +--- +title: Avoid Circular Dependencies +impact: CRITICAL +impactDescription: '#1 cause of runtime crashes' +tags: architecture, modules, dependencies +--- + +## Avoid Circular Dependencies + +Circular dependencies occur when Module A imports Module B, and Module B imports Module A (directly or transitively). NestJS can sometimes resolve these through forward references, but they indicate architectural problems and should be avoided. This is the #1 cause of runtime crashes in NestJS applications. + +**Incorrect (circular module imports):** + +```typescript +// users.module.ts +@Module({ + imports: [OrdersModule], // Orders needs Users, Users needs Orders = circular + providers: [UsersService], + exports: [UsersService], +}) +export class UsersModule {} + +// orders.module.ts +@Module({ + imports: [UsersModule], // Circular dependency! + providers: [OrdersService], + exports: [OrdersService], +}) +export class OrdersModule {} +``` + +**Correct (extract shared logic or use events):** + +```typescript +// Option 1: Extract shared logic to a third module +// shared.module.ts +@Module({ + providers: [SharedService], + exports: [SharedService], +}) +export class SharedModule {} + +// users.module.ts +@Module({ + imports: [SharedModule], + providers: [UsersService], +}) +export class UsersModule {} + +// orders.module.ts +@Module({ + imports: [SharedModule], + providers: [OrdersService], +}) +export class OrdersModule {} + +// Option 2: Use events for decoupled communication +// users.service.ts +@Injectable() +export class UsersService { + constructor(private eventEmitter: EventEmitter2) {} + + async createUser(data: CreateUserDto) { + const user = await this.userRepo.save(data); + this.eventEmitter.emit('user.created', user); + return user; + } +} + +// orders.service.ts +@Injectable() +export class OrdersService { + @OnEvent('user.created') + handleUserCreated(user: User) { + // React to user creation without direct dependency + } +} +``` + +Reference: [NestJS Circular Dependency](https://docs.nestjs.com/fundamentals/circular-dependency) diff --git a/packages/mosaic/framework/skills/nestjs-best-practices/rules/arch-feature-modules.md b/packages/mosaic/framework/skills/nestjs-best-practices/rules/arch-feature-modules.md new file mode 100644 index 00000000..9cbe5dfb --- /dev/null +++ b/packages/mosaic/framework/skills/nestjs-best-practices/rules/arch-feature-modules.md @@ -0,0 +1,82 @@ +--- +title: Organize by Feature Modules +impact: CRITICAL +impactDescription: '3-5x faster onboarding and development' +tags: architecture, modules, organization +--- + +## Organize by Feature Modules + +Organize your application into feature modules that encapsulate related functionality. Each feature module should be self-contained with its own controllers, services, entities, and DTOs. Avoid organizing by technical layer (all controllers together, all services together). This enables 3-5x faster onboarding and feature development. + +**Incorrect (technical layer organization):** + +```typescript +// Technical layer organization (anti-pattern) +src/ +├── controllers/ +│ ├── users.controller.ts +│ ├── orders.controller.ts +│ └── products.controller.ts +├── services/ +│ ├── users.service.ts +│ ├── orders.service.ts +│ └── products.service.ts +├── entities/ +│ ├── user.entity.ts +│ ├── order.entity.ts +│ └── product.entity.ts +└── app.module.ts // Imports everything directly +``` + +**Correct (feature module organization):** + +```typescript +// Feature module organization +src/ +├── users/ +│ ├── dto/ +│ │ ├── create-user.dto.ts +│ │ └── update-user.dto.ts +│ ├── entities/ +│ │ └── user.entity.ts +│ ├── users.controller.ts +│ ├── users.service.ts +│ ├── users.repository.ts +│ └── users.module.ts +├── orders/ +│ ├── dto/ +│ ├── entities/ +│ ├── orders.controller.ts +│ ├── orders.service.ts +│ └── orders.module.ts +├── shared/ +│ ├── guards/ +│ ├── interceptors/ +│ ├── filters/ +│ └── shared.module.ts +└── app.module.ts + +// users.module.ts +@Module({ + imports: [TypeOrmModule.forFeature([User])], + controllers: [UsersController], + providers: [UsersService, UsersRepository], + exports: [UsersService], // Only export what others need +}) +export class UsersModule {} + +// app.module.ts +@Module({ + imports: [ + ConfigModule.forRoot(), + TypeOrmModule.forRoot(), + UsersModule, + OrdersModule, + SharedModule, + ], +}) +export class AppModule {} +``` + +Reference: [NestJS Modules](https://docs.nestjs.com/modules) diff --git a/packages/mosaic/framework/skills/nestjs-best-practices/rules/arch-module-sharing.md b/packages/mosaic/framework/skills/nestjs-best-practices/rules/arch-module-sharing.md new file mode 100644 index 00000000..06ab7678 --- /dev/null +++ b/packages/mosaic/framework/skills/nestjs-best-practices/rules/arch-module-sharing.md @@ -0,0 +1,141 @@ +--- +title: Use Proper Module Sharing Patterns +impact: CRITICAL +impactDescription: Prevents duplicate instances, memory leaks, and state inconsistency +tags: architecture, modules, sharing, exports +--- + +## Use Proper Module Sharing Patterns + +NestJS modules are singletons by default. When a service is properly exported from a module and that module is imported elsewhere, the same instance is shared. However, providing a service in multiple modules creates separate instances, leading to memory waste, state inconsistency, and confusing behavior. Always encapsulate services in dedicated modules, export them explicitly, and import the module where needed. + +**Incorrect (service provided in multiple modules):** + +```typescript +// StorageService provided directly in multiple modules - WRONG +// storage.service.ts +@Injectable() +export class StorageService { + private cache = new Map(); // Each instance has separate state! + + store(key: string, value: any) { + this.cache.set(key, value); + } +} + +// app.module.ts +@Module({ + providers: [StorageService], // Instance #1 + controllers: [AppController], +}) +export class AppModule {} + +// videos.module.ts +@Module({ + providers: [StorageService], // Instance #2 - different from AppModule! + controllers: [VideosController], +}) +export class VideosModule {} + +// Problems: +// 1. Two separate StorageService instances exist +// 2. cache.set() in VideosModule doesn't affect AppModule's cache +// 3. Memory wasted on duplicate instances +// 4. Debugging nightmares when state doesn't sync +``` + +**Correct (dedicated module with exports):** + +```typescript +// storage/storage.module.ts +@Module({ + providers: [StorageService], + exports: [StorageService], // Make available to importers +}) +export class StorageModule {} + +// videos/videos.module.ts +@Module({ + imports: [StorageModule], // Import the module, not the service + controllers: [VideosController], + providers: [VideosService], +}) +export class VideosModule {} + +// channels/channels.module.ts +@Module({ + imports: [StorageModule], // Same instance shared + controllers: [ChannelsController], + providers: [ChannelsService], +}) +export class ChannelsModule {} + +// app.module.ts +@Module({ + imports: [ + StorageModule, // Only if AppModule itself needs StorageService + VideosModule, + ChannelsModule, + ], +}) +export class AppModule {} + +// Now all modules share the SAME StorageService instance +``` + +**When to use @Global() (sparingly):** + +```typescript +// ONLY for truly cross-cutting concerns +@Global() +@Module({ + providers: [ConfigService, LoggerService], + exports: [ConfigService, LoggerService], +}) +export class CoreModule {} + +// Import once in AppModule +@Module({ + imports: [CoreModule], // Registered globally, available everywhere +}) +export class AppModule {} + +// Other modules don't need to import CoreModule +@Module({ + controllers: [UsersController], + providers: [UsersService], // Can inject ConfigService without importing +}) +export class UsersModule {} + +// WARNING: Don't make everything global! +// - Hides dependencies (can't see what a module needs from imports) +// - Makes testing harder +// - Reserve for: config, logging, database connections +``` + +**Module re-exporting pattern:** + +```typescript +// common.module.ts - shared utilities +@Module({ + providers: [DateService, ValidationService], + exports: [DateService, ValidationService], +}) +export class CommonModule {} + +// core.module.ts - re-exports common for convenience +@Module({ + imports: [CommonModule, DatabaseModule], + exports: [CommonModule, DatabaseModule], // Re-export for consumers +}) +export class CoreModule {} + +// feature.module.ts - imports CoreModule, gets both +@Module({ + imports: [CoreModule], // Gets CommonModule + DatabaseModule + controllers: [FeatureController], +}) +export class FeatureModule {} +``` + +Reference: [NestJS Modules](https://docs.nestjs.com/modules#shared-modules) diff --git a/packages/mosaic/framework/skills/nestjs-best-practices/rules/arch-single-responsibility.md b/packages/mosaic/framework/skills/nestjs-best-practices/rules/arch-single-responsibility.md new file mode 100644 index 00000000..d9bbcbca --- /dev/null +++ b/packages/mosaic/framework/skills/nestjs-best-practices/rules/arch-single-responsibility.md @@ -0,0 +1,106 @@ +--- +title: Single Responsibility for Services +impact: CRITICAL +impactDescription: '40%+ improvement in testability' +tags: architecture, services, single-responsibility +--- + +## Single Responsibility for Services + +Each service should have a single, well-defined responsibility. Avoid "god services" that handle multiple unrelated concerns. If a service name includes "And" or handles more than one domain concept, it likely violates single responsibility. This reduces complexity and improves testability by 40%+. + +**Incorrect (god service anti-pattern):** + +```typescript +// God service anti-pattern +@Injectable() +export class UserAndOrderService { + constructor( + private userRepo: UserRepository, + private orderRepo: OrderRepository, + private mailer: MailService, + private payment: PaymentService, + ) {} + + async createUser(dto: CreateUserDto) { + const user = await this.userRepo.save(dto); + await this.mailer.sendWelcome(user); + return user; + } + + async createOrder(userId: string, dto: CreateOrderDto) { + const order = await this.orderRepo.save({ userId, ...dto }); + await this.payment.charge(order); + await this.mailer.sendOrderConfirmation(order); + return order; + } + + async calculateOrderStats(userId: string) { + // Stats logic mixed in + } + + async validatePayment(orderId: string) { + // Payment logic mixed in + } +} +``` + +**Correct (focused services with single responsibility):** + +```typescript +// Focused services with single responsibility +@Injectable() +export class UsersService { + constructor(private userRepo: UserRepository) {} + + async create(dto: CreateUserDto): Promise<User> { + return this.userRepo.save(dto); + } + + async findById(id: string): Promise<User> { + return this.userRepo.findOneOrFail({ where: { id } }); + } +} + +@Injectable() +export class OrdersService { + constructor(private orderRepo: OrderRepository) {} + + async create(userId: string, dto: CreateOrderDto): Promise<Order> { + return this.orderRepo.save({ userId, ...dto }); + } + + async findByUser(userId: string): Promise<Order[]> { + return this.orderRepo.find({ where: { userId } }); + } +} + +@Injectable() +export class OrderStatsService { + constructor(private orderRepo: OrderRepository) {} + + async calculateForUser(userId: string): Promise<OrderStats> { + // Focused stats calculation + } +} + +// Orchestration in controller or dedicated orchestrator +@Controller('orders') +export class OrdersController { + constructor( + private orders: OrdersService, + private payment: PaymentService, + private notifications: NotificationService, + ) {} + + @Post() + async create(@CurrentUser() user: User, @Body() dto: CreateOrderDto) { + const order = await this.orders.create(user.id, dto); + await this.payment.charge(order); + await this.notifications.sendOrderConfirmation(order); + return order; + } +} +``` + +Reference: [NestJS Providers](https://docs.nestjs.com/providers) diff --git a/packages/mosaic/framework/skills/nestjs-best-practices/rules/arch-use-events.md b/packages/mosaic/framework/skills/nestjs-best-practices/rules/arch-use-events.md new file mode 100644 index 00000000..f8cda270 --- /dev/null +++ b/packages/mosaic/framework/skills/nestjs-best-practices/rules/arch-use-events.md @@ -0,0 +1,108 @@ +--- +title: Use Event-Driven Architecture for Decoupling +impact: MEDIUM-HIGH +impactDescription: Enables async processing and modularity +tags: architecture, events, decoupling +--- + +## Use Event-Driven Architecture for Decoupling + +Use `@nestjs/event-emitter` for intra-service events and message brokers for inter-service communication. Events allow modules to react to changes without direct dependencies, improving modularity and enabling async processing. + +**Incorrect (direct service coupling):** + +```typescript +// Direct service coupling +@Injectable() +export class OrdersService { + constructor( + private inventoryService: InventoryService, + private emailService: EmailService, + private analyticsService: AnalyticsService, + private notificationService: NotificationService, + private loyaltyService: LoyaltyService, + ) {} + + async createOrder(dto: CreateOrderDto): Promise<Order> { + const order = await this.repo.save(dto); + + // Tight coupling - OrdersService knows about all consumers + await this.inventoryService.reserve(order.items); + await this.emailService.sendConfirmation(order); + await this.analyticsService.track('order_created', order); + await this.notificationService.push(order.userId, 'Order placed'); + await this.loyaltyService.addPoints(order.userId, order.total); + + // Adding new behavior requires modifying this service + return order; + } +} +``` + +**Correct (event-driven decoupling):** + +```typescript +// Use EventEmitter for decoupling +import { EventEmitter2 } from '@nestjs/event-emitter'; + +// Define event +export class OrderCreatedEvent { + constructor( + public readonly orderId: string, + public readonly userId: string, + public readonly items: OrderItem[], + public readonly total: number, + ) {} +} + +// Service emits events +@Injectable() +export class OrdersService { + constructor( + private eventEmitter: EventEmitter2, + private repo: Repository<Order>, + ) {} + + async createOrder(dto: CreateOrderDto): Promise<Order> { + const order = await this.repo.save(dto); + + // Emit event - no knowledge of consumers + this.eventEmitter.emit( + 'order.created', + new OrderCreatedEvent(order.id, order.userId, order.items, order.total), + ); + + return order; + } +} + +// Listeners in separate modules +@Injectable() +export class InventoryListener { + @OnEvent('order.created') + async handleOrderCreated(event: OrderCreatedEvent): Promise<void> { + await this.inventoryService.reserve(event.items); + } +} + +@Injectable() +export class EmailListener { + @OnEvent('order.created') + async handleOrderCreated(event: OrderCreatedEvent): Promise<void> { + await this.emailService.sendConfirmation(event.orderId); + } +} + +@Injectable() +export class AnalyticsListener { + @OnEvent('order.created') + async handleOrderCreated(event: OrderCreatedEvent): Promise<void> { + await this.analyticsService.track('order_created', { + orderId: event.orderId, + total: event.total, + }); + } +} +``` + +Reference: [NestJS Events](https://docs.nestjs.com/techniques/events) diff --git a/packages/mosaic/framework/skills/nestjs-best-practices/rules/arch-use-repository-pattern.md b/packages/mosaic/framework/skills/nestjs-best-practices/rules/arch-use-repository-pattern.md new file mode 100644 index 00000000..686dc4d3 --- /dev/null +++ b/packages/mosaic/framework/skills/nestjs-best-practices/rules/arch-use-repository-pattern.md @@ -0,0 +1,93 @@ +--- +title: Use Repository Pattern for Data Access +impact: HIGH +impactDescription: Decouples business logic from database +tags: architecture, repository, data-access +--- + +## Use Repository Pattern for Data Access + +Create custom repositories to encapsulate complex queries and database logic. This keeps services focused on business logic, makes testing easier with mock repositories, and allows changing database implementations without affecting business code. + +**Incorrect (complex queries in services):** + +```typescript +// Complex queries in services +@Injectable() +export class UsersService { + constructor(@InjectRepository(User) private repo: Repository<User>) {} + + async findActiveWithOrders(minOrders: number): Promise<User[]> { + // Complex query logic mixed with business logic + return this.repo + .createQueryBuilder('user') + .leftJoinAndSelect('user.orders', 'order') + .where('user.isActive = :active', { active: true }) + .andWhere('user.deletedAt IS NULL') + .groupBy('user.id') + .having('COUNT(order.id) >= :min', { min: minOrders }) + .orderBy('user.createdAt', 'DESC') + .getMany(); + } + + // Service becomes bloated with query logic +} +``` + +**Correct (custom repository with encapsulated queries):** + +```typescript +// Custom repository with encapsulated queries +@Injectable() +export class UsersRepository { + constructor(@InjectRepository(User) private repo: Repository<User>) {} + + async findById(id: string): Promise<User | null> { + return this.repo.findOne({ where: { id } }); + } + + async findByEmail(email: string): Promise<User | null> { + return this.repo.findOne({ where: { email } }); + } + + async findActiveWithMinOrders(minOrders: number): Promise<User[]> { + return this.repo + .createQueryBuilder('user') + .leftJoinAndSelect('user.orders', 'order') + .where('user.isActive = :active', { active: true }) + .andWhere('user.deletedAt IS NULL') + .groupBy('user.id') + .having('COUNT(order.id) >= :min', { min: minOrders }) + .orderBy('user.createdAt', 'DESC') + .getMany(); + } + + async save(user: User): Promise<User> { + return this.repo.save(user); + } +} + +// Clean service with business logic only +@Injectable() +export class UsersService { + constructor(private usersRepo: UsersRepository) {} + + async getActiveUsersWithOrders(): Promise<User[]> { + return this.usersRepo.findActiveWithMinOrders(1); + } + + async create(dto: CreateUserDto): Promise<User> { + const existing = await this.usersRepo.findByEmail(dto.email); + if (existing) { + throw new ConflictException('Email already registered'); + } + + const user = new User(); + user.email = dto.email; + user.name = dto.name; + return this.usersRepo.save(user); + } +} +``` + +Reference: [Repository Pattern](https://martinfowler.com/eaaCatalog/repository.html) diff --git a/packages/mosaic/framework/skills/nestjs-best-practices/rules/db-avoid-n-plus-one.md b/packages/mosaic/framework/skills/nestjs-best-practices/rules/db-avoid-n-plus-one.md new file mode 100644 index 00000000..a93ec4b5 --- /dev/null +++ b/packages/mosaic/framework/skills/nestjs-best-practices/rules/db-avoid-n-plus-one.md @@ -0,0 +1,139 @@ +--- +title: Avoid N+1 Query Problems +impact: HIGH +impactDescription: N+1 queries are one of the most common performance killers +tags: database, n-plus-one, queries, performance +--- + +## Avoid N+1 Query Problems + +N+1 queries occur when you fetch a list of entities, then make an additional query for each entity to load related data. Use eager loading with `relations`, query builder joins, or DataLoader to batch queries efficiently. + +**Incorrect (lazy loading in loops causes N+1):** + +```typescript +// Lazy loading in loops causes N+1 +@Injectable() +export class OrdersService { + async getOrdersWithItems(userId: string): Promise<Order[]> { + const orders = await this.orderRepo.find({ where: { userId } }); + // 1 query for orders + + for (const order of orders) { + // N additional queries - one per order! + order.items = await this.itemRepo.find({ where: { orderId: order.id } }); + } + + return orders; + } +} + +// Accessing lazy relations without loading +@Controller('users') +export class UsersController { + @Get() + async findAll(): Promise<User[]> { + const users = await this.userRepo.find(); + // If User.posts is lazy-loaded, serializing triggers N queries + return users; // Each user.posts access = 1 query + } +} +``` + +**Correct (use relations for eager loading):** + +```typescript +// Use relations option for eager loading +@Injectable() +export class OrdersService { + async getOrdersWithItems(userId: string): Promise<Order[]> { + // Single query with JOIN + return this.orderRepo.find({ + where: { userId }, + relations: ['items', 'items.product'], + }); + } +} + +// Use QueryBuilder for complex joins +@Injectable() +export class UsersService { + async getUsersWithPostCounts(): Promise<UserWithPostCount[]> { + return this.userRepo + .createQueryBuilder('user') + .leftJoin('user.posts', 'post') + .select('user.id', 'id') + .addSelect('user.name', 'name') + .addSelect('COUNT(post.id)', 'postCount') + .groupBy('user.id') + .getRawMany(); + } + + async getActiveUsersWithPosts(): Promise<User[]> { + return this.userRepo + .createQueryBuilder('user') + .leftJoinAndSelect('user.posts', 'post') + .leftJoinAndSelect('post.comments', 'comment') + .where('user.isActive = :active', { active: true }) + .andWhere('post.status = :status', { status: 'published' }) + .getMany(); + } +} + +// Use find options for specific fields +async getOrderSummaries(userId: string): Promise<OrderSummary[]> { + return this.orderRepo.find({ + where: { userId }, + relations: ['items'], + select: { + id: true, + total: true, + status: true, + items: { + id: true, + quantity: true, + price: true, + }, + }, + }); +} + +// Use DataLoader for GraphQL to batch and cache queries +import DataLoader from 'dataloader'; + +@Injectable({ scope: Scope.REQUEST }) +export class PostsLoader { + constructor(private postsService: PostsService) {} + + readonly batchPosts = new DataLoader<string, Post[]>(async (userIds) => { + // Single query for all users' posts + const posts = await this.postsService.findByUserIds([...userIds]); + + // Group by userId + const postsMap = new Map<string, Post[]>(); + for (const post of posts) { + const userPosts = postsMap.get(post.userId) || []; + userPosts.push(post); + postsMap.set(post.userId, userPosts); + } + + // Return in same order as input + return userIds.map((id) => postsMap.get(id) || []); + }); +} + +// In resolver +@ResolveField() +async posts(@Parent() user: User): Promise<Post[]> { + // DataLoader batches multiple calls into single query + return this.postsLoader.batchPosts.load(user.id); +} + +// Enable query logging in development to detect N+1 +TypeOrmModule.forRoot({ + logging: ['query', 'error'], + logger: 'advanced-console', +}); +``` + +Reference: [TypeORM Relations](https://typeorm.io/relations) diff --git a/packages/mosaic/framework/skills/nestjs-best-practices/rules/db-use-migrations.md b/packages/mosaic/framework/skills/nestjs-best-practices/rules/db-use-migrations.md new file mode 100644 index 00000000..4c3b7240 --- /dev/null +++ b/packages/mosaic/framework/skills/nestjs-best-practices/rules/db-use-migrations.md @@ -0,0 +1,129 @@ +--- +title: Use Database Migrations +impact: HIGH +impactDescription: Enables safe, repeatable database schema changes +tags: database, migrations, typeorm, schema +--- + +## Use Database Migrations + +Never use `synchronize: true` in production. Use migrations for all schema changes. Migrations provide version control for your database, enable safe rollbacks, and ensure consistency across all environments. + +**Incorrect (using synchronize or manual SQL):** + +```typescript +// Use synchronize in production +TypeOrmModule.forRoot({ + type: 'postgres', + synchronize: true, // DANGEROUS in production! + // Can drop columns, tables, or data +}); + +// Manual SQL in production +@Injectable() +export class DatabaseService { + async addColumn(): Promise<void> { + await this.dataSource.query('ALTER TABLE users ADD COLUMN age INT'); + // No version control, no rollback, inconsistent across envs + } +} + +// Modify entities without migration +@Entity() +export class User { + @Column() + email: string; + + @Column() // Added without migration + newField: string; // Will crash in production if synchronize is false +} +``` + +**Correct (use migrations for all schema changes):** + +```typescript +// Configure TypeORM for migrations +// data-source.ts +export const dataSource = new DataSource({ + type: 'postgres', + host: process.env.DB_HOST, + port: parseInt(process.env.DB_PORT), + username: process.env.DB_USERNAME, + password: process.env.DB_PASSWORD, + database: process.env.DB_NAME, + entities: ['dist/**/*.entity.js'], + migrations: ['dist/migrations/*.js'], + synchronize: false, // Always false in production + migrationsRun: true, // Run migrations on startup +}); + +// app.module.ts +TypeOrmModule.forRootAsync({ + inject: [ConfigService], + useFactory: (config: ConfigService) => ({ + type: 'postgres', + host: config.get('DB_HOST'), + synchronize: config.get('NODE_ENV') === 'development', // Only in dev + migrations: ['dist/migrations/*.js'], + migrationsRun: true, + }), +}); + +// migrations/1705312800000-AddUserAge.ts +import { MigrationInterface, QueryRunner } from 'typeorm'; + +export class AddUserAge1705312800000 implements MigrationInterface { + name = 'AddUserAge1705312800000'; + + public async up(queryRunner: QueryRunner): Promise<void> { + // Add column with default to handle existing rows + await queryRunner.query(` + ALTER TABLE "users" ADD "age" integer DEFAULT 0 + `); + + // Add index for frequently queried columns + await queryRunner.query(` + CREATE INDEX "IDX_users_age" ON "users" ("age") + `); + } + + public async down(queryRunner: QueryRunner): Promise<void> { + // Always implement down for rollback + await queryRunner.query(`DROP INDEX "IDX_users_age"`); + await queryRunner.query(`ALTER TABLE "users" DROP COLUMN "age"`); + } +} + +// Safe column rename (two-step) +export class RenameNameToFullName1705312900000 implements MigrationInterface { + public async up(queryRunner: QueryRunner): Promise<void> { + // Step 1: Add new column + await queryRunner.query(` + ALTER TABLE "users" ADD "full_name" varchar(255) + `); + + // Step 2: Copy data + await queryRunner.query(` + UPDATE "users" SET "full_name" = "name" + `); + + // Step 3: Add NOT NULL constraint + await queryRunner.query(` + ALTER TABLE "users" ALTER COLUMN "full_name" SET NOT NULL + `); + + // Step 4: Drop old column (after verifying app works) + await queryRunner.query(` + ALTER TABLE "users" DROP COLUMN "name" + `); + } + + public async down(queryRunner: QueryRunner): Promise<void> { + await queryRunner.query(`ALTER TABLE "users" ADD "name" varchar(255)`); + await queryRunner.query(`UPDATE "users" SET "name" = "full_name"`); + await queryRunner.query(`ALTER TABLE "users" DROP COLUMN "full_name"`); + } +} +``` + +Reference: [TypeORM Migrations](https://typeorm.io/migrations) diff --git a/packages/mosaic/framework/skills/nestjs-best-practices/rules/db-use-transactions.md b/packages/mosaic/framework/skills/nestjs-best-practices/rules/db-use-transactions.md new file mode 100644 index 00000000..afb36c35 --- /dev/null +++ b/packages/mosaic/framework/skills/nestjs-best-practices/rules/db-use-transactions.md @@ -0,0 +1,122 @@ +--- +title: Use Transactions for Multi-Step Operations +impact: HIGH +impactDescription: Ensures data consistency in multi-step operations +tags: database, transactions, typeorm, consistency +--- + +## Use Transactions for Multi-Step Operations + +When multiple database operations must succeed or fail together, wrap them in a transaction. This prevents partial updates that leave your data in an inconsistent state. Use TypeORM's transaction APIs or the DataSource query runner for complex scenarios. + +**Incorrect (multiple saves without transaction):** + +```typescript +// Multiple saves without transaction +@Injectable() +export class OrdersService { + async createOrder(userId: string, items: OrderItem[]): Promise<Order> { + // If any step fails, data is inconsistent + const order = await this.orderRepo.save({ userId, status: 'pending' }); + + for (const item of items) { + await this.orderItemRepo.save({ orderId: order.id, ...item }); + await this.inventoryRepo.decrement({ productId: item.productId }, 'stock', item.quantity); + } + + await this.paymentService.charge(order.id); + // If payment fails, order and inventory are already modified! + + return order; + } +} +``` + +**Correct (use DataSource.transaction for automatic rollback):** + +```typescript +// Use DataSource.transaction() for automatic rollback +@Injectable() +export class OrdersService { + constructor(private dataSource: DataSource) {} + + async createOrder(userId: string, items: OrderItem[]): Promise<Order> { + return this.dataSource.transaction(async (manager) => { + // All operations use the same transactional manager + const order = await manager.save(Order, { userId, status: 'pending' }); + + for (const item of items) { + await manager.save(OrderItem, { orderId: order.id, ...item }); + await manager.decrement(Inventory, { productId: item.productId }, 'stock', item.quantity); + } + + // If this throws, everything rolls back + await this.paymentService.chargeWithManager(manager, order.id); + + return order; + }); + } +} + +// QueryRunner for manual transaction control +@Injectable() +export class TransferService { + constructor(private dataSource: DataSource) {} + + async transfer(fromId: string, toId: string, amount: number): Promise<void> { + const queryRunner = this.dataSource.createQueryRunner(); + await queryRunner.connect(); + await queryRunner.startTransaction(); + + try { + // Debit source account + await queryRunner.manager.decrement(Account, { id: fromId }, 'balance', amount); + + // Verify sufficient funds + const source = await queryRunner.manager.findOne(Account, { + where: { id: fromId }, + }); + if (source.balance < 0) { + throw new BadRequestException('Insufficient funds'); + } + + // Credit destination account + await queryRunner.manager.increment(Account, { id: toId }, 'balance', amount); + + // Log the transaction + await queryRunner.manager.save(TransactionLog, { + fromId, + toId, + amount, + timestamp: new Date(), + }); + + await queryRunner.commitTransaction(); + } catch (error) { + await queryRunner.rollbackTransaction(); + throw error; + } finally { + await queryRunner.release(); + } + } +} + +// Repository method with transaction support +@Injectable() +export class UsersRepository { + constructor( + @InjectRepository(User) private repo: Repository<User>, + private dataSource: DataSource, + ) {} + + async createWithProfile(userData: CreateUserDto, profileData: CreateProfileDto): Promise<User> { + return this.dataSource.transaction(async (manager) => { + const user = await manager.save(User, userData); + await manager.save(Profile, { ...profileData, userId: user.id }); + return user; + }); + } +} +``` + +Reference: [TypeORM Transactions](https://typeorm.io/transactions) diff --git a/packages/mosaic/framework/skills/nestjs-best-practices/rules/devops-graceful-shutdown.md b/packages/mosaic/framework/skills/nestjs-best-practices/rules/devops-graceful-shutdown.md new file mode 100644 index 00000000..01323c61 --- /dev/null +++ b/packages/mosaic/framework/skills/nestjs-best-practices/rules/devops-graceful-shutdown.md @@ -0,0 +1,218 @@ +--- +title: Implement Graceful Shutdown +impact: MEDIUM-HIGH +impactDescription: Proper shutdown handling ensures zero-downtime deployments +tags: devops, graceful-shutdown, lifecycle, kubernetes +--- + +## Implement Graceful Shutdown + +Handle SIGTERM and SIGINT signals to gracefully shutdown your NestJS application. Stop accepting new requests, wait for in-flight requests to complete, close database connections, and clean up resources. This prevents data loss and connection errors during deployments. + +**Incorrect (ignoring shutdown signals):** + +```typescript +// Ignore shutdown signals +async function bootstrap() { + const app = await NestFactory.create(AppModule); + await app.listen(3000); + // App crashes immediately on SIGTERM + // In-flight requests fail + // Database connections are abruptly closed +} + +// Long-running tasks without cancellation +@Injectable() +export class ProcessingService { + async processLargeFile(file: File): Promise<void> { + // No way to interrupt this during shutdown + for (let i = 0; i < file.chunks.length; i++) { + await this.processChunk(file.chunks[i]); + // May run for minutes, blocking shutdown + } + } +} +``` + +**Correct (enable shutdown hooks and handle cleanup):** + +```typescript +// Enable shutdown hooks in main.ts +async function bootstrap() { + const app = await NestFactory.create(AppModule); + + // Enable shutdown hooks + app.enableShutdownHooks(); + + // Optional: Add timeout for forced shutdown + const server = await app.listen(3000); + server.setTimeout(30000); // 30 second timeout + + // Handle graceful shutdown + const signals = ['SIGTERM', 'SIGINT']; + signals.forEach((signal) => { + process.on(signal, async () => { + console.log(`Received ${signal}, starting graceful shutdown...`); + + // Stop accepting new connections + server.close(async () => { + console.log('HTTP server closed'); + await app.close(); + process.exit(0); + }); + + // Force exit after timeout + setTimeout(() => { + console.error('Forced shutdown after timeout'); + process.exit(1); + }, 30000); + }); + }); +} + +// Lifecycle hooks for cleanup +@Injectable() +export class DatabaseService implements OnApplicationShutdown { + private readonly connections: Connection[] = []; + + async onApplicationShutdown(signal?: string): Promise<void> { + console.log(`Database service shutting down on ${signal}`); + + // Close all connections gracefully + await Promise.all(this.connections.map((conn) => conn.close())); + + console.log('All database connections closed'); + } +} + +// Queue processor with graceful shutdown +@Injectable() +export class QueueService implements OnApplicationShutdown, OnModuleDestroy { + private isShuttingDown = false; + + onModuleDestroy(): void { + this.isShuttingDown = true; + } + + async onApplicationShutdown(): Promise<void> { + // Wait for current jobs to complete + await this.queue.close(); + } + + async processJob(job: Job): Promise<void> { + if (this.isShuttingDown) { + throw new Error('Service is shutting down'); + } + await this.doWork(job); + } +} + +// WebSocket gateway cleanup +@WebSocketGateway() +export class EventsGateway implements OnApplicationShutdown { + @WebSocketServer() + server: Server; + + async onApplicationShutdown(): Promise<void> { + // Notify all connected clients + this.server.emit('shutdown', { message: 'Server is shutting down' }); + + // Close all connections + this.server.disconnectSockets(); + } +} + +// Health check integration +@Injectable() +export class ShutdownService { + private isShuttingDown = false; + + startShutdown(): void { + this.isShuttingDown = true; + } + + isShutdown(): boolean { + return this.isShuttingDown; + } +} + +@Controller('health') +export class HealthController { + constructor(private shutdownService: ShutdownService) {} + + @Get('ready') + @HealthCheck() + readiness(): Promise<HealthCheckResult> { + // Return 503 during shutdown - k8s stops sending traffic + if (this.shutdownService.isShutdown()) { + throw new ServiceUnavailableException('Shutting down'); + } + + return this.health.check([() => this.db.pingCheck('database')]); + } +} + +// Integrate with shutdown +@Injectable() +export class AppShutdownService implements OnApplicationShutdown { + constructor(private shutdownService: ShutdownService) {} + + async onApplicationShutdown(): Promise<void> { + // Mark as unhealthy first + this.shutdownService.startShutdown(); + + // Wait for k8s to update endpoints + await this.sleep(5000); + + // Then proceed with cleanup + } +} + +// Request tracking for in-flight requests +@Injectable() +export class RequestTracker implements NestMiddleware, OnApplicationShutdown { + private activeRequests = 0; + private isShuttingDown = false; + private shutdownPromise: Promise<void> | null = null; + private resolveShutdown: (() => void) | null = null; + + use(req: Request, res: Response, next: NextFunction): void { + if (this.isShuttingDown) { + res.status(503).send('Service Unavailable'); + return; + } + + this.activeRequests++; + + res.on('finish', () => { + this.activeRequests--; + if (this.isShuttingDown && this.activeRequests === 0 && this.resolveShutdown) { + this.resolveShutdown(); + } + }); + + next(); + } + + async onApplicationShutdown(): Promise<void> { + this.isShuttingDown = true; + + if (this.activeRequests > 0) { + console.log(`Waiting for ${this.activeRequests} requests to complete`); + this.shutdownPromise = new Promise((resolve) => { + this.resolveShutdown = resolve; + }); + + // Wait with timeout + await Promise.race([ + this.shutdownPromise, + new Promise((resolve) => setTimeout(resolve, 30000)), + ]); + } + + console.log('All requests completed'); + } +} +``` + +Reference: [NestJS Lifecycle Events](https://docs.nestjs.com/fundamentals/lifecycle-events) diff --git a/packages/mosaic/framework/skills/nestjs-best-practices/rules/devops-use-config-module.md b/packages/mosaic/framework/skills/nestjs-best-practices/rules/devops-use-config-module.md new file mode 100644 index 00000000..1f4604b6 --- /dev/null +++ b/packages/mosaic/framework/skills/nestjs-best-practices/rules/devops-use-config-module.md @@ -0,0 +1,165 @@ +--- +title: Use ConfigModule for Environment Configuration +impact: LOW-MEDIUM +impactDescription: Proper configuration prevents deployment failures +tags: devops, configuration, environment, validation +--- + +## Use ConfigModule for Environment Configuration + +Use `@nestjs/config` for environment-based configuration. Validate configuration at startup to fail fast on misconfigurations. Use namespaced configuration for organization and type safety. + +**Incorrect (accessing process.env directly):** + +```typescript +// Access process.env directly +@Injectable() +export class DatabaseService { + constructor() { + // No validation, can fail at runtime + this.connection = new Pool({ + host: process.env.DB_HOST, + port: parseInt(process.env.DB_PORT), // NaN if missing + password: process.env.DB_PASSWORD, // undefined if missing + }); + } +} + +// Scattered env access +@Injectable() +export class EmailService { + sendEmail() { + // Different services access env differently + const apiKey = process.env.SENDGRID_API_KEY || 'default'; + // Typos go unnoticed: process.env.SENDGRID_API_KY + } +} +``` + +**Correct (use @nestjs/config with validation):** + +```typescript +// Setup validated configuration +import { ConfigModule, ConfigService, registerAs } from '@nestjs/config'; +import * as Joi from 'joi'; + +// config/database.config.ts +export const databaseConfig = registerAs('database', () => ({ + host: process.env.DB_HOST, + port: parseInt(process.env.DB_PORT, 10), + username: process.env.DB_USERNAME, + password: process.env.DB_PASSWORD, + database: process.env.DB_NAME, +})); + +// config/app.config.ts +export const appConfig = registerAs('app', () => ({ + port: parseInt(process.env.PORT, 10) || 3000, + environment: process.env.NODE_ENV || 'development', + apiPrefix: process.env.API_PREFIX || 'api', +})); + +// config/validation.schema.ts +export const validationSchema = Joi.object({ + NODE_ENV: Joi.string().valid('development', 'production', 'test').default('development'), + PORT: Joi.number().default(3000), + DB_HOST: Joi.string().required(), + DB_PORT: Joi.number().default(5432), + DB_USERNAME: Joi.string().required(), + DB_PASSWORD: Joi.string().required(), + DB_NAME: Joi.string().required(), + JWT_SECRET: Joi.string().min(32).required(), + REDIS_URL: Joi.string().uri().required(), +}); + +// app.module.ts +@Module({ + imports: [ + ConfigModule.forRoot({ + isGlobal: true, // Available everywhere without importing + load: [databaseConfig, appConfig], + validationSchema, + validationOptions: { + abortEarly: true, // Stop on first error + allowUnknown: true, // Allow other env vars + }, + }), + TypeOrmModule.forRootAsync({ + inject: [ConfigService], + useFactory: (config: ConfigService) => ({ + type: 'postgres', + host: config.get('database.host'), + port: config.get('database.port'), + username: config.get('database.username'), + password: config.get('database.password'), + database: config.get('database.database'), + autoLoadEntities: true, + }), + }), + ], +}) +export class AppModule {} + +// Type-safe configuration access +export interface AppConfig { + port: number; + environment: 'development' | 'production' | 'test'; + apiPrefix: string; +} + +export interface DatabaseConfig { + host: string; + port: number; + username: string; + password: string; + database: string; +} + +// Type-safe access +@Injectable() +export class AppService { + constructor(private config: ConfigService) {} + + getPort(): number { + // Type-safe with generic + return this.config.get<number>('app.port'); + } + + getDatabaseConfig(): DatabaseConfig { + return this.config.get<DatabaseConfig>('database'); + } +} + +// Inject namespaced config directly +@Injectable() +export class DatabaseService { + constructor( + @Inject(databaseConfig.KEY) + private dbConfig: ConfigType<typeof databaseConfig>, + ) { + // Full type inference! + const host = this.dbConfig.host; // string + const port = this.dbConfig.port; // number + } +} + +// Environment files support +ConfigModule.forRoot({ + envFilePath: [ + `.env.${process.env.NODE_ENV}.local`, + `.env.${process.env.NODE_ENV}`, + '.env.local', + '.env', + ], +}); + +// .env.development +// DB_HOST=localhost +// DB_PORT=5432 + +// .env.production +// DB_HOST=prod-db.example.com +// DB_PORT=5432 +``` + +Reference: [NestJS Configuration](https://docs.nestjs.com/techniques/configuration) diff --git a/packages/mosaic/framework/skills/nestjs-best-practices/rules/devops-use-logging.md b/packages/mosaic/framework/skills/nestjs-best-practices/rules/devops-use-logging.md new file mode 100644 index 00000000..33d7c6cd --- /dev/null +++ b/packages/mosaic/framework/skills/nestjs-best-practices/rules/devops-use-logging.md @@ -0,0 +1,229 @@ +--- +title: Use Structured Logging +impact: MEDIUM-HIGH +impactDescription: Structured logging enables effective debugging and monitoring +tags: devops, logging, structured-logs, pino +--- + +## Use Structured Logging + +Use NestJS Logger with structured JSON output in production. Include contextual information (request ID, user ID, operation) to trace requests across services. Avoid console.log and implement proper log levels. + +**Incorrect (using console.log in production):** + +```typescript +// Use console.log in production +@Injectable() +export class UsersService { + async createUser(dto: CreateUserDto): Promise<User> { + console.log('Creating user:', dto); + // Not structured, no levels, lost in production logs + + try { + const user = await this.repo.save(dto); + console.log('User created:', user.id); + return user; + } catch (error) { + console.log('Error:', error); // Using log for errors + throw error; + } + } +} + +// Log sensitive data +console.log('Login attempt:', { email, password }); // SECURITY RISK! + +// Inconsistent log format +logger.log('User ' + userId + ' created at ' + new Date()); +// Hard to parse, no structure +``` + +**Correct (use structured logging with context):** + +```typescript +// Configure logger in main.ts +async function bootstrap() { + const app = await NestFactory.create(AppModule, { + logger: + process.env.NODE_ENV === 'production' + ? ['error', 'warn', 'log'] + : ['error', 'warn', 'log', 'debug', 'verbose'], + }); +} + +// Use NestJS Logger with context +@Injectable() +export class UsersService { + private readonly logger = new Logger(UsersService.name); + + async createUser(dto: CreateUserDto): Promise<User> { + this.logger.log('Creating user', { email: dto.email }); + + try { + const user = await this.repo.save(dto); + this.logger.log('User created', { userId: user.id }); + return user; + } catch (error) { + this.logger.error('Failed to create user', error.stack, { + email: dto.email, + }); + throw error; + } + } +} + +// Custom logger for JSON output +@Injectable() +export class JsonLogger implements LoggerService { + log(message: string, context?: object): void { + console.log( + JSON.stringify({ + level: 'info', + timestamp: new Date().toISOString(), + message, + ...context, + }), + ); + } + + error(message: string, trace?: string, context?: object): void { + console.error( + JSON.stringify({ + level: 'error', + timestamp: new Date().toISOString(), + message, + trace, + ...context, + }), + ); + } + + warn(message: string, context?: object): void { + console.warn( + JSON.stringify({ + level: 'warn', + timestamp: new Date().toISOString(), + message, + ...context, + }), + ); + } + + debug(message: string, context?: object): void { + console.debug( + JSON.stringify({ + level: 'debug', + timestamp: new Date().toISOString(), + message, + ...context, + }), + ); + } +} + +// Request context logging with ClsModule +import { ClsModule, ClsService } from 'nestjs-cls'; + +@Module({ + imports: [ + ClsModule.forRoot({ + global: true, + middleware: { + mount: true, + generateId: true, + }, + }), + ], +}) +export class AppModule {} + +// Middleware to set request context +@Injectable() +export class RequestContextMiddleware implements NestMiddleware { + constructor(private cls: ClsService) {} + + use(req: Request, res: Response, next: NextFunction): void { + const requestId = req.headers['x-request-id'] || randomUUID(); + this.cls.set('requestId', requestId); + this.cls.set('userId', req.user?.id); + + res.setHeader('x-request-id', requestId); + next(); + } +} + +// Logger that includes request context +@Injectable() +export class ContextLogger { + constructor(private cls: ClsService) {} + + log(message: string, data?: object): void { + console.log( + JSON.stringify({ + level: 'info', + timestamp: new Date().toISOString(), + requestId: this.cls.get('requestId'), + userId: this.cls.get('userId'), + message, + ...data, + }), + ); + } + + error(message: string, error: Error, data?: object): void { + console.error( + JSON.stringify({ + level: 'error', + timestamp: new Date().toISOString(), + requestId: this.cls.get('requestId'), + userId: this.cls.get('userId'), + message, + error: error.message, + stack: error.stack, + ...data, + }), + ); + } +} + +// Pino integration for high-performance logging +import { LoggerModule } from 'nestjs-pino'; + +@Module({ + imports: [ + LoggerModule.forRoot({ + pinoHttp: { + level: process.env.NODE_ENV === 'production' ? 'info' : 'debug', + transport: process.env.NODE_ENV !== 'production' ? { target: 'pino-pretty' } : undefined, + redact: ['req.headers.authorization', 'req.body.password'], + serializers: { + req: (req) => ({ + method: req.method, + url: req.url, + query: req.query, + }), + res: (res) => ({ + statusCode: res.statusCode, + }), + }, + }, + }), + ], +}) +export class AppModule {} + +// Usage with Pino +@Injectable() +export class UsersService { + constructor(private logger: PinoLogger) { + this.logger.setContext(UsersService.name); + } + + async findOne(id: string): Promise<User> { + this.logger.info({ userId: id }, 'Finding user'); + // Pino uses first arg for data, second for message + } +} +``` + +Reference: [NestJS Logger](https://docs.nestjs.com/techniques/logger) diff --git a/packages/mosaic/framework/skills/nestjs-best-practices/rules/di-avoid-service-locator.md b/packages/mosaic/framework/skills/nestjs-best-practices/rules/di-avoid-service-locator.md new file mode 100644 index 00000000..d4c04b47 --- /dev/null +++ b/packages/mosaic/framework/skills/nestjs-best-practices/rules/di-avoid-service-locator.md @@ -0,0 +1,104 @@ +--- +title: Avoid Service Locator Anti-Pattern +impact: HIGH +impactDescription: Hides dependencies and breaks testability +tags: dependency-injection, anti-patterns, testing +--- + +## Avoid Service Locator Anti-Pattern + +Avoid using `ModuleRef.get()` or global containers to resolve dependencies at runtime. This hides dependencies, makes code harder to test, and breaks the benefits of dependency injection. Use constructor injection instead. + +**Incorrect (service locator anti-pattern):** + +```typescript +// Use ModuleRef to get dependencies dynamically +@Injectable() +export class OrdersService { + constructor(private moduleRef: ModuleRef) {} + + async createOrder(dto: CreateOrderDto): Promise<Order> { + // Dependencies are hidden - not visible in constructor + const usersService = this.moduleRef.get(UsersService); + const inventoryService = this.moduleRef.get(InventoryService); + const paymentService = this.moduleRef.get(PaymentService); + + const user = await usersService.findOne(dto.userId); + // ... rest of logic + } +} + +// Global singleton container +class ServiceContainer { + private static instance: ServiceContainer; + private services = new Map<string, any>(); + + static getInstance(): ServiceContainer { + if (!this.instance) { + this.instance = new ServiceContainer(); + } + return this.instance; + } + + get<T>(key: string): T { + return this.services.get(key); + } +} +``` + +**Correct (constructor injection with explicit dependencies):** + +```typescript +// Use constructor injection - dependencies are explicit +@Injectable() +export class OrdersService { + constructor( + private usersService: UsersService, + private inventoryService: InventoryService, + private paymentService: PaymentService, + ) {} + + async createOrder(dto: CreateOrderDto): Promise<Order> { + const user = await this.usersService.findOne(dto.userId); + const inventory = await this.inventoryService.check(dto.items); + // Dependencies are clear and testable + } +} + +// Easy to test with mocks +describe('OrdersService', () => { + let service: OrdersService; + + beforeEach(async () => { + const module = await Test.createTestingModule({ + providers: [ + OrdersService, + { provide: UsersService, useValue: mockUsersService }, + { provide: InventoryService, useValue: mockInventoryService }, + { provide: PaymentService, useValue: mockPaymentService }, + ], + }).compile(); + + service = module.get(OrdersService); + }); +}); + +// VALID: Factory pattern for dynamic instantiation +@Injectable() +export class HandlerFactory { + constructor(private moduleRef: ModuleRef) {} + + getHandler(type: string): Handler { + switch (type) { + case 'email': + return this.moduleRef.get(EmailHandler); + case 'sms': + return this.moduleRef.get(SmsHandler); + default: + return this.moduleRef.get(DefaultHandler); + } + } +} +``` + +Reference: [NestJS Module Reference](https://docs.nestjs.com/fundamentals/module-ref) diff --git a/packages/mosaic/framework/skills/nestjs-best-practices/rules/di-interface-segregation.md b/packages/mosaic/framework/skills/nestjs-best-practices/rules/di-interface-segregation.md new file mode 100644 index 00000000..915df32c --- /dev/null +++ b/packages/mosaic/framework/skills/nestjs-best-practices/rules/di-interface-segregation.md @@ -0,0 +1,165 @@ +--- +title: Apply Interface Segregation Principle +impact: HIGH +impactDescription: Reduces coupling and improves testability by 30-50% +tags: dependency-injection, interfaces, solid, isp +--- + +## Apply Interface Segregation Principle + +Clients should not be forced to depend on interfaces they don't use. In NestJS, this means keeping interfaces small and focused on specific capabilities rather than creating "fat" interfaces that bundle unrelated methods. When a service only needs to send emails, it shouldn't depend on an interface that also includes SMS, push notifications, and logging. Split large interfaces into role-based ones. + +**Incorrect (fat interface forcing unused dependencies):** + +```typescript +// Fat interface - forces all consumers to depend on everything +interface NotificationService { + sendEmail(to: string, subject: string, body: string): Promise<void>; + sendSms(phone: string, message: string): Promise<void>; + sendPush(userId: string, notification: PushPayload): Promise<void>; + sendSlack(channel: string, message: string): Promise<void>; + logNotification(type: string, payload: any): Promise<void>; + getDeliveryStatus(id: string): Promise<DeliveryStatus>; + retryFailed(id: string): Promise<void>; + scheduleNotification(dto: ScheduleDto): Promise<string>; +} + +// Consumer only needs email, but must mock everything for tests +@Injectable() +export class OrdersService { + constructor( + private notifications: NotificationService, // Depends on 8 methods, uses 1 + ) {} + + async confirmOrder(order: Order): Promise<void> { + await this.notifications.sendEmail( + order.customer.email, + 'Order Confirmed', + `Your order ${order.id} has been confirmed.`, + ); + } +} + +// Testing is painful - must mock unused methods +const mockNotificationService = { + sendEmail: jest.fn(), + sendSms: jest.fn(), // Never used, but required + sendPush: jest.fn(), // Never used, but required + sendSlack: jest.fn(), // Never used, but required + logNotification: jest.fn(), // Never used, but required + getDeliveryStatus: jest.fn(), // Never used, but required + retryFailed: jest.fn(), // Never used, but required + scheduleNotification: jest.fn(), // Never used, but required +}; +``` + +**Correct (segregated interfaces by capability):** + +```typescript +// Segregated interfaces - each focused on one capability +interface EmailSender { + sendEmail(to: string, subject: string, body: string): Promise<void>; +} + +interface SmsSender { + sendSms(phone: string, message: string): Promise<void>; +} + +interface PushSender { + sendPush(userId: string, notification: PushPayload): Promise<void>; +} + +interface NotificationLogger { + logNotification(type: string, payload: any): Promise<void>; +} + +interface NotificationScheduler { + scheduleNotification(dto: ScheduleDto): Promise<string>; +} + +// Implementation can implement multiple interfaces +@Injectable() +export class NotificationService implements EmailSender, SmsSender, PushSender { + async sendEmail(to: string, subject: string, body: string): Promise<void> { + // Email implementation + } + + async sendSms(phone: string, message: string): Promise<void> { + // SMS implementation + } + + async sendPush(userId: string, notification: PushPayload): Promise<void> { + // Push implementation + } +} + +// Or separate implementations +@Injectable() +export class SendGridEmailService implements EmailSender { + async sendEmail(to: string, subject: string, body: string): Promise<void> { + // SendGrid-specific implementation + } +} + +// Consumer depends only on what it needs +@Injectable() +export class OrdersService { + constructor( + @Inject(EMAIL_SENDER) private emailSender: EmailSender, // Minimal dependency + ) {} + + async confirmOrder(order: Order): Promise<void> { + await this.emailSender.sendEmail( + order.customer.email, + 'Order Confirmed', + `Your order ${order.id} has been confirmed.`, + ); + } +} + +// Testing is simple - only mock what's used +const mockEmailSender: EmailSender = { + sendEmail: jest.fn(), +}; + +// Module registration with tokens +export const EMAIL_SENDER = Symbol('EMAIL_SENDER'); +export const SMS_SENDER = Symbol('SMS_SENDER'); + +@Module({ + providers: [ + { provide: EMAIL_SENDER, useClass: SendGridEmailService }, + { provide: SMS_SENDER, useClass: TwilioSmsService }, + ], + exports: [EMAIL_SENDER, SMS_SENDER], +}) +export class NotificationModule {} +``` + +**Combining interfaces when needed:** + +```typescript +// Sometimes a consumer legitimately needs multiple capabilities +interface EmailAndSmsSender extends EmailSender, SmsSender {} + +// Or use intersection types +type MultiChannelSender = EmailSender & SmsSender & PushSender; + +// Consumer that genuinely needs multiple channels +@Injectable() +export class AlertService { + constructor( + @Inject(MULTI_CHANNEL_SENDER) + private sender: EmailSender & SmsSender, + ) {} + + async sendCriticalAlert(user: User, message: string): Promise<void> { + await Promise.all([ + this.sender.sendEmail(user.email, 'Critical Alert', message), + this.sender.sendSms(user.phone, message), + ]); + } +} +``` + +Reference: [Interface Segregation Principle](https://en.wikipedia.org/wiki/Interface_segregation_principle) diff --git a/packages/mosaic/framework/skills/nestjs-best-practices/rules/di-liskov-substitution.md b/packages/mosaic/framework/skills/nestjs-best-practices/rules/di-liskov-substitution.md new file mode 100644 index 00000000..a031fbe4 --- /dev/null +++ b/packages/mosaic/framework/skills/nestjs-best-practices/rules/di-liskov-substitution.md @@ -0,0 +1,217 @@ +--- +title: Honor Liskov Substitution Principle +impact: HIGH +impactDescription: Ensures implementations are truly interchangeable without breaking callers +tags: dependency-injection, inheritance, solid, lsp +--- + +## Honor Liskov Substitution Principle + +Subtypes must be substitutable for their base types without altering program correctness. In NestJS with dependency injection, this means any implementation of an interface or abstract class must honor the contract completely. A mock payment service used in tests must behave like a real payment service (return similar shapes, handle errors the same way). Violating LSP causes subtle bugs when swapping implementations. + +**Incorrect (implementation violates the contract):** + +```typescript +// Base interface with clear contract +interface PaymentGateway { + /** + * Charges the specified amount. + * @returns PaymentResult on success + * @throws PaymentFailedException on payment failure + */ + charge(amount: number, currency: string): Promise<PaymentResult>; +} + +// Production implementation - follows the contract +@Injectable() +export class StripeService implements PaymentGateway { + async charge(amount: number, currency: string): Promise<PaymentResult> { + const response = await this.stripe.charges.create({ amount, currency }); + return { success: true, transactionId: response.id, amount }; + } +} + +// Mock that violates LSP - different behavior! +@Injectable() +export class MockPaymentService implements PaymentGateway { + async charge(amount: number, currency: string): Promise<PaymentResult> { + // VIOLATION 1: Throws for valid input (contract says return PaymentResult) + if (amount > 1000) { + throw new Error('Mock does not support large amounts'); + } + + // VIOLATION 2: Returns null instead of PaymentResult + if (currency !== 'USD') { + return null as any; // Real service would convert or reject properly + } + + // VIOLATION 3: Missing required field + return { success: true } as PaymentResult; // Missing transactionId! + } +} + +// Consumer trusts the contract +@Injectable() +export class OrdersService { + constructor(@Inject(PAYMENT_GATEWAY) private payment: PaymentGateway) {} + + async checkout(order: Order): Promise<void> { + const result = await this.payment.charge(order.total, order.currency); + // These fail with MockPaymentService: + await this.saveTransaction(result.transactionId); // undefined! + await this.sendReceipt(result); // might be null! + } +} +``` + +**Correct (implementations honor the contract):** + +```typescript +// Well-defined interface with documented behavior +interface PaymentGateway { + /** + * Charges the specified amount. + * @param amount - Amount in smallest currency unit (cents) + * @param currency - ISO 4217 currency code + * @returns PaymentResult with transactionId, success status, and amount + * @throws PaymentFailedException if charge is declined + * @throws InvalidCurrencyException if currency is not supported + */ + charge(amount: number, currency: string): Promise<PaymentResult>; + + /** + * Refunds a previous charge. + * @throws TransactionNotFoundException if transactionId is invalid + */ + refund(transactionId: string, amount?: number): Promise<RefundResult>; +} + +// Production implementation +@Injectable() +export class StripeService implements PaymentGateway { + async charge(amount: number, currency: string): Promise<PaymentResult> { + try { + const response = await this.stripe.charges.create({ amount, currency }); + return { + success: true, + transactionId: response.id, + amount: response.amount, + }; + } catch (error) { + if (error.type === 'card_error') { + throw new PaymentFailedException(error.message); + } + throw error; + } + } + + async refund(transactionId: string, amount?: number): Promise<RefundResult> { + // Implementation... + } +} + +// Mock that honors LSP - same contract, same behavior shape +@Injectable() +export class MockPaymentService implements PaymentGateway { + private transactions = new Map<string, PaymentResult>(); + + async charge(amount: number, currency: string): Promise<PaymentResult> { + // Honor the contract: validate currency like real service would + if (!['USD', 'EUR', 'GBP'].includes(currency)) { + throw new InvalidCurrencyException(`Unsupported currency: ${currency}`); + } + + // Simulate decline for specific test scenarios + if (amount === 99999) { + throw new PaymentFailedException('Card declined (test scenario)'); + } + + // Return same shape as production + const result: PaymentResult = { + success: true, + transactionId: `mock_${Date.now()}_${Math.random().toString(36)}`, + amount, + }; + + this.transactions.set(result.transactionId, result); + return result; + } + + async refund(transactionId: string, amount?: number): Promise<RefundResult> { + // Honor the contract: throw if transaction not found + if (!this.transactions.has(transactionId)) { + throw new TransactionNotFoundException(transactionId); + } + + return { + success: true, + refundId: `refund_${transactionId}`, + amount: amount ?? this.transactions.get(transactionId)!.amount, + }; + } +} + +// Consumer can swap implementations safely +@Injectable() +export class OrdersService { + constructor(@Inject(PAYMENT_GATEWAY) private payment: PaymentGateway) {} + + async checkout(order: Order): Promise<Order> { + try { + const result = await this.payment.charge(order.total, order.currency); + // Works with both StripeService and MockPaymentService + order.transactionId = result.transactionId; + order.status = 'paid'; + return order; + } catch (error) { + if (error instanceof PaymentFailedException) { + order.status = 'payment_failed'; + return order; + } + throw error; + } + } +} +``` + +**Testing LSP compliance:** + +```typescript +// Shared test suite that any implementation must pass +function testPaymentGatewayContract(createGateway: () => PaymentGateway) { + describe('PaymentGateway contract', () => { + let gateway: PaymentGateway; + + beforeEach(() => { + gateway = createGateway(); + }); + + it('returns PaymentResult with all required fields', async () => { + const result = await gateway.charge(1000, 'USD'); + expect(result).toHaveProperty('success'); + expect(result).toHaveProperty('transactionId'); + expect(result).toHaveProperty('amount'); + expect(typeof result.transactionId).toBe('string'); + }); + + it('throws InvalidCurrencyException for unsupported currency', async () => { + await expect(gateway.charge(1000, 'INVALID')).rejects.toThrow(InvalidCurrencyException); + }); + + it('throws TransactionNotFoundException for invalid refund', async () => { + await expect(gateway.refund('nonexistent')).rejects.toThrow(TransactionNotFoundException); + }); + }); +} + +// Run against all implementations +describe('StripeService', () => { + testPaymentGatewayContract(() => new StripeService(mockStripeClient)); +}); + +describe('MockPaymentService', () => { + testPaymentGatewayContract(() => new MockPaymentService()); +}); +``` + +Reference: [Liskov Substitution Principle](https://en.wikipedia.org/wiki/Liskov_substitution_principle) diff --git a/packages/mosaic/framework/skills/nestjs-best-practices/rules/di-prefer-constructor-injection.md b/packages/mosaic/framework/skills/nestjs-best-practices/rules/di-prefer-constructor-injection.md new file mode 100644 index 00000000..c4a3274c --- /dev/null +++ b/packages/mosaic/framework/skills/nestjs-best-practices/rules/di-prefer-constructor-injection.md @@ -0,0 +1,86 @@ +--- +title: Prefer Constructor Injection +impact: CRITICAL +impactDescription: Required for proper DI and testing +tags: dependency-injection, constructor, testing +--- + +## Prefer Constructor Injection + +Always use constructor injection over property injection. Constructor injection makes dependencies explicit, enables TypeScript type checking, ensures dependencies are available when the class is instantiated, and improves testability. This is required for proper DI, testing, and TypeScript support. + +**Incorrect (property injection with hidden dependencies):** + +```typescript +// Property injection - avoid unless necessary +@Injectable() +export class UsersService { + @Inject() + private userRepo: UserRepository; // Hidden dependency + + @Inject('CONFIG') + private config: ConfigType; // Also hidden + + async findAll() { + return this.userRepo.find(); + } +} + +// Problems: +// 1. Dependencies not visible in constructor +// 2. Service can be instantiated without dependencies in tests +// 3. TypeScript can't enforce dependency types at instantiation +``` + +**Correct (constructor injection with explicit dependencies):** + +```typescript +// Constructor injection - explicit and testable +@Injectable() +export class UsersService { + constructor( + private readonly userRepo: UserRepository, + @Inject('CONFIG') private readonly config: ConfigType, + ) {} + + async findAll(): Promise<User[]> { + return this.userRepo.find(); + } +} + +// Testing is straightforward +describe('UsersService', () => { + let service: UsersService; + let mockRepo: jest.Mocked<UserRepository>; + + beforeEach(() => { + mockRepo = { + find: jest.fn(), + save: jest.fn(), + } as any; + + service = new UsersService(mockRepo, { dbUrl: 'test' }); + }); + + it('should find all users', async () => { + mockRepo.find.mockResolvedValue([{ id: '1', name: 'Test' }]); + const result = await service.findAll(); + expect(result).toHaveLength(1); + }); +}); + +// Only use property injection for optional dependencies +@Injectable() +export class LoggingService { + @Optional() + @Inject('ANALYTICS') + private analytics?: AnalyticsService; + + log(message: string) { + console.log(message); + this.analytics?.track('log', message); // Optional enhancement + } +} +``` + +Reference: [NestJS Providers](https://docs.nestjs.com/providers) diff --git a/packages/mosaic/framework/skills/nestjs-best-practices/rules/di-scope-awareness.md b/packages/mosaic/framework/skills/nestjs-best-practices/rules/di-scope-awareness.md new file mode 100644 index 00000000..a6c77efb --- /dev/null +++ b/packages/mosaic/framework/skills/nestjs-best-practices/rules/di-scope-awareness.md @@ -0,0 +1,94 @@ +--- +title: Understand Provider Scopes +impact: CRITICAL +impactDescription: Prevents data leaks and performance issues +tags: dependency-injection, scopes, request-context +--- + +## Understand Provider Scopes + +NestJS has three provider scopes: DEFAULT (singleton), REQUEST (per-request instance), and TRANSIENT (new instance for each injection). Most providers should be singletons. Request-scoped providers have performance implications as they bubble up through the dependency tree. Understanding scopes prevents memory leaks and incorrect data sharing. + +**Incorrect (wrong scope usage):** + +```typescript +// Request-scoped when not needed (performance hit) +@Injectable({ scope: Scope.REQUEST }) +export class UsersService { + // This creates a new instance for EVERY request + // All dependencies also become request-scoped + async findAll() { + return this.userRepo.find(); + } +} + +// Singleton with mutable request state +@Injectable() // Default: singleton +export class RequestContextService { + private userId: string; // DANGER: Shared across all requests! + + setUser(userId: string) { + this.userId = userId; // Overwrites for all concurrent requests + } + + getUser() { + return this.userId; // Returns wrong user! + } +} +``` + +**Correct (appropriate scope for each use case):** + +```typescript +// Singleton for stateless services (default, most common) +@Injectable() +export class UsersService { + constructor(private readonly userRepo: UserRepository) {} + + async findById(id: string): Promise<User> { + return this.userRepo.findOne({ where: { id } }); + } +} + +// Request-scoped ONLY when you need request context +@Injectable({ scope: Scope.REQUEST }) +export class RequestContextService { + private userId: string; + + setUser(userId: string) { + this.userId = userId; + } + + getUser(): string { + return this.userId; + } +} + +// Better: Use NestJS built-in request context +import { REQUEST } from '@nestjs/core'; +import { Request } from 'express'; + +@Injectable({ scope: Scope.REQUEST }) +export class AuditService { + constructor(@Inject(REQUEST) private request: Request) {} + + log(action: string) { + console.log(`User ${this.request.user?.id} performed ${action}`); + } +} + +// Best: Use ClsModule for async context (no scope bubble-up) +import { ClsService } from 'nestjs-cls'; + +@Injectable() // Stays singleton! +export class AuditService { + constructor(private cls: ClsService) {} + + log(action: string) { + const userId = this.cls.get('userId'); + console.log(`User ${userId} performed ${action}`); + } +} +``` + +Reference: [NestJS Injection Scopes](https://docs.nestjs.com/fundamentals/injection-scopes) diff --git a/packages/mosaic/framework/skills/nestjs-best-practices/rules/di-use-interfaces-tokens.md b/packages/mosaic/framework/skills/nestjs-best-practices/rules/di-use-interfaces-tokens.md new file mode 100644 index 00000000..d0fd1496 --- /dev/null +++ b/packages/mosaic/framework/skills/nestjs-best-practices/rules/di-use-interfaces-tokens.md @@ -0,0 +1,99 @@ +--- +title: Use Injection Tokens for Interfaces +impact: HIGH +impactDescription: Enables interface-based DI at runtime +tags: dependency-injection, tokens, interfaces +--- + +## Use Injection Tokens for Interfaces + +TypeScript interfaces are erased at compile time and can't be used as injection tokens. Use string tokens, symbols, or abstract classes when you want to inject implementations of interfaces. This enables swapping implementations for testing or different environments. + +**Incorrect (interface can't be used as token):** + +```typescript +// Interface can't be used as injection token +interface PaymentGateway { + charge(amount: number): Promise<PaymentResult>; +} + +@Injectable() +export class StripeService implements PaymentGateway { + charge(amount: number) { + /* ... */ + } +} + +@Injectable() +export class OrdersService { + // This WON'T work - PaymentGateway doesn't exist at runtime + constructor(private payment: PaymentGateway) {} +} +``` + +**Correct (symbol tokens or abstract classes):** + +```typescript +// Option 1: String/Symbol tokens (most flexible) +export const PAYMENT_GATEWAY = Symbol('PAYMENT_GATEWAY'); + +export interface PaymentGateway { + charge(amount: number): Promise<PaymentResult>; +} + +@Injectable() +export class StripeService implements PaymentGateway { + async charge(amount: number): Promise<PaymentResult> { + // Stripe implementation + } +} + +@Injectable() +export class MockPaymentService implements PaymentGateway { + async charge(amount: number): Promise<PaymentResult> { + return { success: true, id: 'mock-id' }; + } +} + +// Module registration +@Module({ + providers: [ + { + provide: PAYMENT_GATEWAY, + useClass: process.env.NODE_ENV === 'test' ? MockPaymentService : StripeService, + }, + ], + exports: [PAYMENT_GATEWAY], +}) +export class PaymentModule {} + +// Injection +@Injectable() +export class OrdersService { + constructor(@Inject(PAYMENT_GATEWAY) private payment: PaymentGateway) {} + + async createOrder(dto: CreateOrderDto) { + await this.payment.charge(dto.amount); + } +} + +// Option 2: Abstract class (carries runtime type info) +export abstract class PaymentGateway { + abstract charge(amount: number): Promise<PaymentResult>; +} + +@Injectable() +export class StripeService extends PaymentGateway { + async charge(amount: number): Promise<PaymentResult> { + // Implementation + } +} + +// No @Inject needed with abstract class +@Injectable() +export class OrdersService { + constructor(private payment: PaymentGateway) {} +} +``` + +Reference: [NestJS Custom Providers](https://docs.nestjs.com/fundamentals/custom-providers) diff --git a/packages/mosaic/framework/skills/nestjs-best-practices/rules/error-handle-async-errors.md b/packages/mosaic/framework/skills/nestjs-best-practices/rules/error-handle-async-errors.md new file mode 100644 index 00000000..36c3f6af --- /dev/null +++ b/packages/mosaic/framework/skills/nestjs-best-practices/rules/error-handle-async-errors.md @@ -0,0 +1,125 @@ +--- +title: Handle Async Errors Properly +impact: HIGH +impactDescription: Prevents process crashes from unhandled rejections +tags: error-handling, async, promises +--- + +## Handle Async Errors Properly + +NestJS automatically catches errors from async route handlers, but errors from background tasks, event handlers, and manually created promises can crash your application. Always handle async errors explicitly and use global handlers as a safety net. + +**Incorrect (fire-and-forget without error handling):** + +```typescript +// Fire-and-forget without error handling +@Injectable() +export class UsersService { + async createUser(dto: CreateUserDto): Promise<User> { + const user = await this.repo.save(dto); + + // Fire and forget - if this fails, error is unhandled! + this.emailService.sendWelcome(user.email); + + return user; + } +} + +// Unhandled promise in event handler +@Injectable() +export class OrdersService { + @OnEvent('order.created') + handleOrderCreated(event: OrderCreatedEvent) { + // This returns a promise but it's not awaited! + this.processOrder(event); + // Errors will crash the process + } + + private async processOrder(event: OrderCreatedEvent): Promise<void> { + await this.inventoryService.reserve(event.items); + await this.notificationService.send(event.userId); + } +} + +// Missing try-catch in scheduled tasks +@Cron('0 0 * * *') +async dailyCleanup(): Promise<void> { + await this.cleanupService.run(); + // If this throws, no error handling +} +``` + +**Correct (explicit async error handling):** + +```typescript +// Handle fire-and-forget with explicit catch +@Injectable() +export class UsersService { + private readonly logger = new Logger(UsersService.name); + + async createUser(dto: CreateUserDto): Promise<User> { + const user = await this.repo.save(dto); + + // Explicitly catch and log errors + this.emailService.sendWelcome(user.email).catch((error) => { + this.logger.error('Failed to send welcome email', error.stack); + // Optionally queue for retry + }); + + return user; + } +} + +// Properly handle async event handlers +@Injectable() +export class OrdersService { + private readonly logger = new Logger(OrdersService.name); + + @OnEvent('order.created') + async handleOrderCreated(event: OrderCreatedEvent): Promise<void> { + try { + await this.processOrder(event); + } catch (error) { + this.logger.error('Failed to process order', { event, error }); + // Don't rethrow - would crash the process + await this.deadLetterQueue.add('order.created', event); + } + } +} + +// Safe scheduled tasks +@Injectable() +export class CleanupService { + private readonly logger = new Logger(CleanupService.name); + + @Cron('0 0 * * *') + async dailyCleanup(): Promise<void> { + try { + await this.cleanupService.run(); + this.logger.log('Daily cleanup completed'); + } catch (error) { + this.logger.error('Daily cleanup failed', error.stack); + // Alert or retry logic + } + } +} + +// Global unhandled rejection handler in main.ts +async function bootstrap() { + const app = await NestFactory.create(AppModule); + const logger = new Logger('Bootstrap'); + + process.on('unhandledRejection', (reason, promise) => { + logger.error('Unhandled Rejection at:', promise, 'reason:', reason); + }); + + process.on('uncaughtException', (error) => { + logger.error('Uncaught Exception:', error); + process.exit(1); + }); + + await app.listen(3000); +} +``` + +Reference: [Node.js Unhandled Rejections](https://nodejs.org/api/process.html#event-unhandledrejection) diff --git a/packages/mosaic/framework/skills/nestjs-best-practices/rules/error-throw-http-exceptions.md b/packages/mosaic/framework/skills/nestjs-best-practices/rules/error-throw-http-exceptions.md new file mode 100644 index 00000000..6aad9fa3 --- /dev/null +++ b/packages/mosaic/framework/skills/nestjs-best-practices/rules/error-throw-http-exceptions.md @@ -0,0 +1,114 @@ +--- +title: Throw HTTP Exceptions from Services +impact: HIGH +impactDescription: Keeps controllers thin and simplifies error handling +tags: error-handling, exceptions, services +--- + +## Throw HTTP Exceptions from Services + +It's acceptable (and often preferable) to throw `HttpException` subclasses from services in HTTP applications. This keeps controllers thin and allows services to communicate appropriate error states. For truly layer-agnostic services, use domain exceptions that map to HTTP status codes. + +**Incorrect (return error objects instead of throwing):** + +```typescript +// Return error objects instead of throwing +@Injectable() +export class UsersService { + async findById(id: string): Promise<{ user?: User; error?: string }> { + const user = await this.repo.findOne({ where: { id } }); + if (!user) { + return { error: 'User not found' }; // Controller must check this + } + return { user }; + } +} + +@Controller('users') +export class UsersController { + @Get(':id') + async findOne(@Param('id') id: string) { + const result = await this.usersService.findById(id); + if (result.error) { + throw new NotFoundException(result.error); + } + return result.user; + } +} +``` + +**Correct (throw exceptions directly from service):** + +```typescript +// Throw exceptions directly from service +@Injectable() +export class UsersService { + constructor(private readonly repo: UserRepository) {} + + async findById(id: string): Promise<User> { + const user = await this.repo.findOne({ where: { id } }); + if (!user) { + throw new NotFoundException(`User #${id} not found`); + } + return user; + } + + async create(dto: CreateUserDto): Promise<User> { + const existing = await this.repo.findOne({ + where: { email: dto.email }, + }); + if (existing) { + throw new ConflictException('Email already registered'); + } + return this.repo.save(dto); + } + + async update(id: string, dto: UpdateUserDto): Promise<User> { + const user = await this.findById(id); // Throws if not found + Object.assign(user, dto); + return this.repo.save(user); + } +} + +// Controller stays thin +@Controller('users') +export class UsersController { + @Get(':id') + findOne(@Param('id') id: string): Promise<User> { + return this.usersService.findById(id); + } + + @Post() + create(@Body() dto: CreateUserDto): Promise<User> { + return this.usersService.create(dto); + } +} + +// For layer-agnostic services, use domain exceptions +export class EntityNotFoundException extends Error { + constructor( + public readonly entity: string, + public readonly id: string, + ) { + super(`${entity} with ID "${id}" not found`); + } +} + +// Map to HTTP in exception filter +@Catch(EntityNotFoundException) +export class EntityNotFoundFilter implements ExceptionFilter { + catch(exception: EntityNotFoundException, host: ArgumentsHost) { + const ctx = host.switchToHttp(); + const response = ctx.getResponse<Response>(); + + response.status(404).json({ + statusCode: 404, + message: exception.message, + entity: exception.entity, + id: exception.id, + }); + } +} +``` + +Reference: [NestJS Exception Filters](https://docs.nestjs.com/exception-filters) diff --git a/packages/mosaic/framework/skills/nestjs-best-practices/rules/error-use-exception-filters.md b/packages/mosaic/framework/skills/nestjs-best-practices/rules/error-use-exception-filters.md new file mode 100644 index 00000000..c9e1ab18 --- /dev/null +++ b/packages/mosaic/framework/skills/nestjs-best-practices/rules/error-use-exception-filters.md @@ -0,0 +1,133 @@ +--- +title: Use Exception Filters for Error Handling +impact: HIGH +impactDescription: Consistent, centralized error handling +tags: error-handling, exception-filters, consistency +--- + +## Use Exception Filters for Error Handling + +Never catch exceptions and manually format error responses in controllers. Use NestJS exception filters to handle errors consistently across your application. Create custom exception filters for specific error types and a global filter for unhandled exceptions. + +**Incorrect (manual error handling in controllers):** + +```typescript +// Manual error handling in controllers +@Controller('users') +export class UsersController { + @Get(':id') + async findOne(@Param('id') id: string, @Res() res: Response) { + try { + const user = await this.usersService.findById(id); + if (!user) { + return res.status(404).json({ + statusCode: 404, + message: 'User not found', + }); + } + return res.json(user); + } catch (error) { + console.error(error); + return res.status(500).json({ + statusCode: 500, + message: 'Internal server error', + }); + } + } +} +``` + +**Correct (exception filters with consistent handling):** + +```typescript +// Use built-in and custom exceptions +@Controller('users') +export class UsersController { + @Get(':id') + async findOne(@Param('id') id: string): Promise<User> { + const user = await this.usersService.findById(id); + if (!user) { + throw new NotFoundException(`User #${id} not found`); + } + return user; + } +} + +// Custom domain exception +export class UserNotFoundException extends NotFoundException { + constructor(userId: string) { + super({ + statusCode: 404, + error: 'Not Found', + message: `User with ID "${userId}" not found`, + code: 'USER_NOT_FOUND', + }); + } +} + +// Custom exception filter for domain errors +@Catch(DomainException) +export class DomainExceptionFilter implements ExceptionFilter { + catch(exception: DomainException, host: ArgumentsHost) { + const ctx = host.switchToHttp(); + const response = ctx.getResponse<Response>(); + const request = ctx.getRequest<Request>(); + + const status = exception.getStatus?.() || 400; + + response.status(status).json({ + statusCode: status, + code: exception.code, + message: exception.message, + timestamp: new Date().toISOString(), + path: request.url, + }); + } +} + +// Global exception filter for unhandled errors +@Catch() +export class AllExceptionsFilter implements ExceptionFilter { + constructor(private readonly logger: Logger) {} + + catch(exception: unknown, host: ArgumentsHost) { + const ctx = host.switchToHttp(); + const response = ctx.getResponse<Response>(); + const request = ctx.getRequest<Request>(); + + const status = + exception instanceof HttpException ? exception.getStatus() : HttpStatus.INTERNAL_SERVER_ERROR; + + const message = + exception instanceof HttpException ? exception.message : 'Internal server error'; + + this.logger.error( + `${request.method} ${request.url}`, + exception instanceof Error ? exception.stack : exception, + ); + + response.status(status).json({ + statusCode: status, + message, + timestamp: new Date().toISOString(), + path: request.url, + }); + } +} + +// Register globally in main.ts +app.useGlobalFilters(new AllExceptionsFilter(app.get(Logger)), new DomainExceptionFilter()); + +// Or via module +@Module({ + providers: [ + { + provide: APP_FILTER, + useClass: AllExceptionsFilter, + }, + ], +}) +export class AppModule {} +``` + +Reference: [NestJS Exception Filters](https://docs.nestjs.com/exception-filters) diff --git a/packages/mosaic/framework/skills/nestjs-best-practices/rules/micro-use-health-checks.md b/packages/mosaic/framework/skills/nestjs-best-practices/rules/micro-use-health-checks.md new file mode 100644 index 00000000..0b10d926 --- /dev/null +++ b/packages/mosaic/framework/skills/nestjs-best-practices/rules/micro-use-health-checks.md @@ -0,0 +1,226 @@ +--- +title: Implement Health Checks for Microservices +impact: MEDIUM-HIGH +impactDescription: Health checks enable orchestrators to manage service lifecycle +tags: microservices, health-checks, terminus, kubernetes +--- + +## Implement Health Checks for Microservices + +Implement liveness and readiness probes using `@nestjs/terminus`. Liveness checks determine if the service should be restarted. Readiness checks determine if the service can accept traffic. Proper health checks enable Kubernetes and load balancers to route traffic correctly. + +**Incorrect (simple ping that doesn't check dependencies):** + +```typescript +// Simple ping that doesn't check dependencies +@Controller('health') +export class HealthController { + @Get() + check(): string { + return 'OK'; // Service might be unhealthy but returns OK + } +} + +// Health check that blocks on slow dependencies +@Controller('health') +export class HealthController { + @Get() + async check(): Promise<string> { + // If database is slow, health check times out + await this.userRepo.findOne({ where: { id: '1' } }); + await this.redis.ping(); + await this.externalApi.healthCheck(); + return 'OK'; + } +} +``` + +**Correct (use @nestjs/terminus for comprehensive health checks):** + +```typescript +// Use @nestjs/terminus for comprehensive health checks +import { + HealthCheckService, + HttpHealthIndicator, + TypeOrmHealthIndicator, + HealthCheck, + DiskHealthIndicator, + MemoryHealthIndicator, +} from '@nestjs/terminus'; + +@Controller('health') +export class HealthController { + constructor( + private health: HealthCheckService, + private http: HttpHealthIndicator, + private db: TypeOrmHealthIndicator, + private disk: DiskHealthIndicator, + private memory: MemoryHealthIndicator, + ) {} + + // Liveness probe - is the service alive? + @Get('live') + @HealthCheck() + liveness() { + return this.health.check([ + // Basic checks only + () => this.memory.checkHeap('memory_heap', 200 * 1024 * 1024), // 200MB + ]); + } + + // Readiness probe - can the service handle traffic? + @Get('ready') + @HealthCheck() + readiness() { + return this.health.check([ + () => this.db.pingCheck('database'), + () => + this.http.pingCheck('redis', 'http://redis:6379', { timeout: 1000 }), + () => + this.disk.checkStorage('disk', { path: '/', thresholdPercent: 0.9 }), + ]); + } + + // Deep health check for debugging + @Get('deep') + @HealthCheck() + deepCheck() { + return this.health.check([ + () => this.db.pingCheck('database'), + () => this.memory.checkHeap('memory_heap', 200 * 1024 * 1024), + () => this.memory.checkRSS('memory_rss', 300 * 1024 * 1024), + () => + this.disk.checkStorage('disk', { path: '/', thresholdPercent: 0.9 }), + () => + this.http.pingCheck('external-api', 'https://api.example.com/health'), + ]); + } +} + +// Custom indicator for business-specific health +@Injectable() +export class QueueHealthIndicator extends HealthIndicator { + constructor(private queueService: QueueService) { + super(); + } + + async isHealthy(key: string): Promise<HealthIndicatorResult> { + const queueStats = await this.queueService.getStats(); + + const isHealthy = queueStats.failedCount < 100; + const result = this.getStatus(key, isHealthy, { + waiting: queueStats.waitingCount, + active: queueStats.activeCount, + failed: queueStats.failedCount, + }); + + if (!isHealthy) { + throw new HealthCheckError('Queue unhealthy', result); + } + + return result; + } +} + +// Redis health indicator +@Injectable() +export class RedisHealthIndicator extends HealthIndicator { + constructor(@InjectRedis() private redis: Redis) { + super(); + } + + async isHealthy(key: string): Promise<HealthIndicatorResult> { + try { + const pong = await this.redis.ping(); + return this.getStatus(key, pong === 'PONG'); + } catch (error) { + throw new HealthCheckError('Redis check failed', this.getStatus(key, false)); + } + } +} + +// Use custom indicators +@Get('ready') +@HealthCheck() +readiness() { + return this.health.check([ + () => this.db.pingCheck('database'), + () => this.redis.isHealthy('redis'), + () => this.queue.isHealthy('job-queue'), + ]); +} + +// Graceful shutdown handling +@Injectable() +export class GracefulShutdownService implements OnApplicationShutdown { + private isShuttingDown = false; + + isShutdown(): boolean { + return this.isShuttingDown; + } + + async onApplicationShutdown(signal: string): Promise<void> { + this.isShuttingDown = true; + console.log(`Shutting down on ${signal}`); + + // Wait for in-flight requests + await new Promise((resolve) => setTimeout(resolve, 5000)); + } +} + +// Health check respects shutdown state +@Get('ready') +@HealthCheck() +readiness() { + if (this.shutdownService.isShutdown()) { + throw new ServiceUnavailableException('Shutting down'); + } + + return this.health.check([ + () => this.db.pingCheck('database'), + ]); +} +``` + +### Kubernetes Configuration + +```yaml +# Kubernetes deployment with probes +apiVersion: apps/v1 +kind: Deployment +metadata: + name: api-service +spec: + template: + spec: + containers: + - name: api + image: api-service:latest + ports: + - containerPort: 3000 + livenessProbe: + httpGet: + path: /health/live + port: 3000 + initialDelaySeconds: 30 + periodSeconds: 10 + timeoutSeconds: 5 + failureThreshold: 3 + readinessProbe: + httpGet: + path: /health/ready + port: 3000 + initialDelaySeconds: 5 + periodSeconds: 5 + timeoutSeconds: 3 + failureThreshold: 3 + startupProbe: + httpGet: + path: /health/live + port: 3000 + initialDelaySeconds: 0 + periodSeconds: 5 + failureThreshold: 30 +``` + +Reference: [NestJS Terminus](https://docs.nestjs.com/recipes/terminus) diff --git a/packages/mosaic/framework/skills/nestjs-best-practices/rules/micro-use-patterns.md b/packages/mosaic/framework/skills/nestjs-best-practices/rules/micro-use-patterns.md new file mode 100644 index 00000000..82d7c722 --- /dev/null +++ b/packages/mosaic/framework/skills/nestjs-best-practices/rules/micro-use-patterns.md @@ -0,0 +1,167 @@ +--- +title: Use Message and Event Patterns Correctly +impact: MEDIUM +impactDescription: Proper patterns ensure reliable microservice communication +tags: microservices, message-pattern, event-pattern, communication +--- + +## Use Message and Event Patterns Correctly + +NestJS microservices support two communication patterns: request-response (MessagePattern) and event-based (EventPattern). Use MessagePattern when you need a response, and EventPattern for fire-and-forget notifications. Understanding the difference prevents communication bugs. + +**Incorrect (using wrong pattern for use case):** + +```typescript +// Use @MessagePattern for fire-and-forget +@Controller() +export class NotificationsController { + @MessagePattern('user.created') + async handleUserCreated(data: UserCreatedEvent) { + // This WAITS for response, blocking the sender + await this.emailService.sendWelcome(data.email); + // If email fails, sender gets an error (coupling!) + } +} + +// Use @EventPattern expecting a response +@Controller() +export class OrdersController { + @EventPattern('inventory.check') + async checkInventory(data: CheckInventoryDto) { + const available = await this.inventory.check(data); + return available; // This return value is IGNORED with @EventPattern! + } +} + +// Tight coupling in client +@Injectable() +export class UsersService { + async createUser(dto: CreateUserDto): Promise<User> { + const user = await this.repo.save(dto); + + // Blocks until notification service responds + await this.client.send('user.created', user).toPromise(); + // If notification service is down, user creation fails! + + return user; + } +} +``` + +**Correct (use MessagePattern for request-response, EventPattern for fire-and-forget):** + +```typescript +// MessagePattern: Request-Response (when you NEED a response) +@Controller() +export class InventoryController { + @MessagePattern({ cmd: 'check_inventory' }) + async checkInventory(data: CheckInventoryDto): Promise<InventoryResult> { + const result = await this.inventoryService.check(data.productId, data.quantity); + return result; // Response sent back to caller + } +} + +// Client expects response +@Injectable() +export class OrdersService { + async createOrder(dto: CreateOrderDto): Promise<Order> { + // Check inventory - we NEED this response to proceed + const inventory = await firstValueFrom( + this.inventoryClient.send<InventoryResult>( + { cmd: 'check_inventory' }, + { productId: dto.productId, quantity: dto.quantity }, + ), + ); + + if (!inventory.available) { + throw new BadRequestException('Insufficient inventory'); + } + + return this.repo.save(dto); + } +} + +// EventPattern: Fire-and-Forget (for notifications, side effects) +@Controller() +export class NotificationsController { + @EventPattern('user.created') + async handleUserCreated(data: UserCreatedEvent): Promise<void> { + // No return value needed - just process the event + await this.emailService.sendWelcome(data.email); + await this.analyticsService.track('user_signup', data); + // If this fails, it doesn't affect the sender + } +} + +// Client emits event without waiting +@Injectable() +export class UsersService { + async createUser(dto: CreateUserDto): Promise<User> { + const user = await this.repo.save(dto); + + // Fire and forget - doesn't block, doesn't wait + this.eventClient.emit('user.created', { + userId: user.id, + email: user.email, + timestamp: new Date(), + }); + + return user; // User creation succeeds regardless of event handling + } +} + +// Hybrid pattern for critical events +@Injectable() +export class OrdersService { + async createOrder(dto: CreateOrderDto): Promise<Order> { + const order = await this.repo.save(dto); + + // Critical: inventory reservation (use MessagePattern) + const reserved = await firstValueFrom( + this.inventoryClient.send({ cmd: 'reserve_inventory' }, { + orderId: order.id, + items: dto.items, + }), + ); + + if (!reserved.success) { + await this.repo.delete(order.id); + throw new BadRequestException('Could not reserve inventory'); + } + + // Non-critical: notifications (use EventPattern) + this.eventClient.emit('order.created', { + orderId: order.id, + userId: dto.userId, + total: dto.total, + }); + + return order; + } +} + +// Error handling patterns +// MessagePattern errors propagate to caller +@MessagePattern({ cmd: 'get_user' }) +async getUser(userId: string): Promise<User> { + const user = await this.repo.findOne({ where: { id: userId } }); + if (!user) { + throw new RpcException('User not found'); // Received by caller + } + return user; +} + +// EventPattern errors should be handled locally +@EventPattern('order.created') +async handleOrderCreated(data: OrderCreatedEvent): Promise<void> { + try { + await this.processOrder(data); + } catch (error) { + // Log and potentially retry - don't throw + this.logger.error('Failed to process order event', error); + await this.deadLetterQueue.add(data); + } +} +``` + +Reference: [NestJS Microservices](https://docs.nestjs.com/microservices/basics) diff --git a/packages/mosaic/framework/skills/nestjs-best-practices/rules/micro-use-queues.md b/packages/mosaic/framework/skills/nestjs-best-practices/rules/micro-use-queues.md new file mode 100644 index 00000000..977824d0 --- /dev/null +++ b/packages/mosaic/framework/skills/nestjs-best-practices/rules/micro-use-queues.md @@ -0,0 +1,246 @@ +--- +title: Use Message Queues for Background Jobs +impact: MEDIUM-HIGH +impactDescription: Queues enable reliable background processing +tags: microservices, queues, bullmq, background-jobs +--- + +## Use Message Queues for Background Jobs + +Use `@nestjs/bullmq` for background job processing. Queues decouple long-running tasks from HTTP requests, enable retry logic, and distribute workload across workers. Use them for emails, file processing, notifications, and any task that shouldn't block user requests. + +**Incorrect (long-running tasks in HTTP handlers):** + +```typescript +// Long-running tasks in HTTP handlers +@Controller('reports') +export class ReportsController { + @Post() + async generate(@Body() dto: GenerateReportDto): Promise<Report> { + // This blocks the request for potentially minutes + const data = await this.fetchLargeDataset(dto); + const report = await this.processData(data); // Slow! + await this.sendEmail(dto.email, report); // Can fail! + return report; // Client times out + } +} + +// Fire-and-forget without retry +@Injectable() +export class EmailService { + async sendWelcome(email: string): Promise<void> { + // If this fails, email is never sent + await this.mailer.send({ to: email, template: 'welcome' }); + // No retry, no tracking, no visibility + } +} + +// Use setInterval for scheduled tasks +setInterval(async () => { + await cleanupOldRecords(); +}, 60000); // No error handling, memory leaks +``` + +**Correct (use BullMQ for background processing):** + +```typescript +// Configure BullMQ +import { BullModule } from '@nestjs/bullmq'; + +@Module({ + imports: [ + BullModule.forRoot({ + connection: { + host: 'localhost', + port: 6379, + }, + defaultJobOptions: { + removeOnComplete: 1000, + removeOnFail: 5000, + attempts: 3, + backoff: { + type: 'exponential', + delay: 1000, + }, + }, + }), + BullModule.registerQueue({ name: 'email' }, { name: 'reports' }, { name: 'notifications' }), + ], +}) +export class QueueModule {} + +// Producer: Add jobs to queue +@Injectable() +export class ReportsService { + constructor(@InjectQueue('reports') private reportsQueue: Queue) {} + + async requestReport(dto: GenerateReportDto): Promise<{ jobId: string }> { + // Return immediately, process in background + const job = await this.reportsQueue.add('generate', dto, { + priority: dto.urgent ? 1 : 10, + delay: dto.scheduledFor ? Date.parse(dto.scheduledFor) - Date.now() : 0, + }); + + return { jobId: job.id }; + } + + async getJobStatus(jobId: string): Promise<JobStatus> { + const job = await this.reportsQueue.getJob(jobId); + return { + status: await job.getState(), + progress: job.progress, + result: job.returnvalue, + }; + } +} + +// Consumer: Process jobs +@Processor('reports') +export class ReportsProcessor { + private readonly logger = new Logger(ReportsProcessor.name); + + @Process('generate') + async generateReport(job: Job<GenerateReportDto>): Promise<Report> { + this.logger.log(`Processing report job ${job.id}`); + + // Update progress + await job.updateProgress(10); + + const data = await this.fetchData(job.data); + await job.updateProgress(50); + + const report = await this.processData(data); + await job.updateProgress(90); + + await this.saveReport(report); + await job.updateProgress(100); + + return report; + } + + @OnQueueActive() + onActive(job: Job) { + this.logger.log(`Processing job ${job.id}`); + } + + @OnQueueCompleted() + onCompleted(job: Job, result: any) { + this.logger.log(`Job ${job.id} completed`); + } + + @OnQueueFailed() + onFailed(job: Job, error: Error) { + this.logger.error(`Job ${job.id} failed: ${error.message}`); + } +} + +// Email queue with retry +@Processor('email') +export class EmailProcessor { + @Process('send') + async sendEmail(job: Job<SendEmailDto>): Promise<void> { + const { to, template, data } = job.data; + + try { + await this.mailer.send({ + to, + template, + context: data, + }); + } catch (error) { + // BullMQ will retry based on job options + throw error; + } + } +} + +// Usage +@Injectable() +export class NotificationService { + constructor(@InjectQueue('email') private emailQueue: Queue) {} + + async sendWelcome(user: User): Promise<void> { + await this.emailQueue.add( + 'send', + { + to: user.email, + template: 'welcome', + data: { name: user.name }, + }, + { + attempts: 5, + backoff: { type: 'exponential', delay: 5000 }, + }, + ); + } +} + +// Scheduled jobs +@Injectable() +export class ScheduledJobsService implements OnModuleInit { + constructor(@InjectQueue('maintenance') private queue: Queue) {} + + async onModuleInit(): Promise<void> { + // Clean up old reports daily at midnight + await this.queue.add( + 'cleanup', + {}, + { + repeat: { cron: '0 0 * * *' }, + jobId: 'daily-cleanup', // Prevent duplicates + }, + ); + + // Send digest every hour + await this.queue.add( + 'digest', + {}, + { + repeat: { every: 60 * 60 * 1000 }, + jobId: 'hourly-digest', + }, + ); + } +} + +@Processor('maintenance') +export class MaintenanceProcessor { + @Process('cleanup') + async cleanup(): Promise<void> { + await this.cleanupOldReports(); + await this.cleanupExpiredSessions(); + } + + @Process('digest') + async sendDigest(): Promise<void> { + const users = await this.getUsersForDigest(); + for (const user of users) { + await this.emailQueue.add('send', { to: user.email, template: 'digest' }); + } + } +} + +// Queue monitoring with Bull Board +import { BullBoardModule } from '@bull-board/nestjs'; +import { BullMQAdapter } from '@bull-board/api/bullMQAdapter'; + +@Module({ + imports: [ + BullBoardModule.forRoot({ + route: '/admin/queues', + adapter: ExpressAdapter, + }), + BullBoardModule.forFeature({ + name: 'email', + adapter: BullMQAdapter, + }), + BullBoardModule.forFeature({ + name: 'reports', + adapter: BullMQAdapter, + }), + ], +}) +export class AdminModule {} +``` + +Reference: [NestJS Queues](https://docs.nestjs.com/techniques/queues) diff --git a/packages/mosaic/framework/skills/nestjs-best-practices/rules/perf-async-hooks.md b/packages/mosaic/framework/skills/nestjs-best-practices/rules/perf-async-hooks.md new file mode 100644 index 00000000..7ca00771 --- /dev/null +++ b/packages/mosaic/framework/skills/nestjs-best-practices/rules/perf-async-hooks.md @@ -0,0 +1,109 @@ +--- +title: Use Async Lifecycle Hooks Correctly +impact: HIGH +impactDescription: Improper async handling blocks application startup +tags: performance, lifecycle, async, hooks +--- + +## Use Async Lifecycle Hooks Correctly + +NestJS lifecycle hooks (`onModuleInit`, `onApplicationBootstrap`, etc.) support async operations. However, misusing them can block application startup or cause race conditions. Understand the lifecycle order and use hooks appropriately. + +**Incorrect (fire-and-forget async without await):** + +```typescript +// Fire-and-forget async without await +@Injectable() +export class DatabaseService implements OnModuleInit { + onModuleInit() { + // This runs but doesn't block - app starts before DB is ready! + this.connect(); + } + + private async connect() { + await this.pool.connect(); + console.log('Database connected'); + } +} + +// Heavy blocking operations in constructor +@Injectable() +export class ConfigService { + private config: Config; + + constructor() { + // BLOCKS entire module instantiation synchronously + this.config = fs.readFileSync('config.json'); + } +} +``` + +**Correct (return promises from async hooks):** + +```typescript +// Return promise from async hooks +@Injectable() +export class DatabaseService implements OnModuleInit { + private pool: Pool; + + async onModuleInit(): Promise<void> { + // NestJS waits for this to complete before continuing + await this.pool.connect(); + console.log('Database connected'); + } + + async onModuleDestroy(): Promise<void> { + // Clean up resources on shutdown + await this.pool.end(); + console.log('Database disconnected'); + } +} + +// Use onApplicationBootstrap for cross-module dependencies +@Injectable() +export class CacheWarmerService implements OnApplicationBootstrap { + constructor( + private cache: CacheService, + private products: ProductsService, + ) {} + + async onApplicationBootstrap(): Promise<void> { + // All modules are initialized, safe to warm cache + const products = await this.products.findPopular(); + await this.cache.warmup(products); + } +} + +// Heavy init in async hooks, not constructor +@Injectable() +export class ConfigService implements OnModuleInit { + private config: Config; + + constructor() { + // Keep constructor synchronous and fast + } + + async onModuleInit(): Promise<void> { + // Async loading in lifecycle hook + this.config = await this.loadConfig(); + } + + private async loadConfig(): Promise<Config> { + const file = await fs.promises.readFile('config.json'); + return JSON.parse(file.toString()); + } + + get<T>(key: string): T { + return this.config[key]; + } +} + +// Enable shutdown hooks in main.ts +async function bootstrap() { + const app = await NestFactory.create(AppModule); + app.enableShutdownHooks(); // Enable SIGTERM/SIGINT handling + await app.listen(3000); +} +``` + +Reference: [NestJS Lifecycle Events](https://docs.nestjs.com/fundamentals/lifecycle-events) diff --git a/packages/mosaic/framework/skills/nestjs-best-practices/rules/perf-lazy-loading.md b/packages/mosaic/framework/skills/nestjs-best-practices/rules/perf-lazy-loading.md new file mode 100644 index 00000000..8bcc5828 --- /dev/null +++ b/packages/mosaic/framework/skills/nestjs-best-practices/rules/perf-lazy-loading.md @@ -0,0 +1,121 @@ +--- +title: Use Lazy Loading for Large Modules +impact: MEDIUM +impactDescription: Improves startup time for large applications +tags: performance, lazy-loading, modules, optimization +--- + +## Use Lazy Loading for Large Modules + +NestJS supports lazy-loading modules, which defers initialization until first use. This is valuable for large applications where some features are rarely used, serverless deployments where cold start time matters, or when certain modules have heavy initialization costs. + +**Incorrect (loading everything eagerly):** + +```typescript +// Load everything eagerly in a large app +@Module({ + imports: [ + UsersModule, + OrdersModule, + PaymentsModule, + ReportsModule, // Heavy, rarely used + AnalyticsModule, // Heavy, rarely used + AdminModule, // Only admins use this + LegacyModule, // Migration module, rarely used + BulkImportModule, // Used once a month + ], +}) +export class AppModule {} + +// All modules initialize at startup, even if never used +// Slow cold starts in serverless +// Memory wasted on unused modules +``` + +**Correct (lazy load rarely-used modules):** + +```typescript +// Use LazyModuleLoader for optional modules +import { LazyModuleLoader } from '@nestjs/core'; + +@Injectable() +export class ReportsService { + constructor(private lazyModuleLoader: LazyModuleLoader) {} + + async generateReport(type: string): Promise<Report> { + // Load module only when needed + const { ReportsModule } = await import('./reports/reports.module'); + const moduleRef = await this.lazyModuleLoader.load(() => ReportsModule); + + const reportsService = moduleRef.get(ReportsGeneratorService); + return reportsService.generate(type); + } +} + +// Lazy load admin features with caching +@Injectable() +export class AdminService { + private adminModule: ModuleRef | null = null; + + constructor(private lazyModuleLoader: LazyModuleLoader) {} + + private async getAdminModule(): Promise<ModuleRef> { + if (!this.adminModule) { + const { AdminModule } = await import('./admin/admin.module'); + this.adminModule = await this.lazyModuleLoader.load(() => AdminModule); + } + return this.adminModule; + } + + async runAdminTask(task: string): Promise<void> { + const moduleRef = await this.getAdminModule(); + const taskRunner = moduleRef.get(AdminTaskRunner); + await taskRunner.run(task); + } +} + +// Reusable lazy loader service +@Injectable() +export class ModuleLoaderService { + private loadedModules = new Map<string, ModuleRef>(); + + constructor(private lazyModuleLoader: LazyModuleLoader) {} + + async load<T>( + key: string, + importFn: () => Promise<{ default: Type<T> } | Type<T>>, + ): Promise<ModuleRef> { + if (!this.loadedModules.has(key)) { + const module = await importFn(); + const moduleType = 'default' in module ? module.default : module; + const moduleRef = await this.lazyModuleLoader.load(() => moduleType); + this.loadedModules.set(key, moduleRef); + } + return this.loadedModules.get(key)!; + } +} + +// Preload modules in background after startup +@Injectable() +export class ModulePreloader implements OnApplicationBootstrap { + constructor(private lazyModuleLoader: LazyModuleLoader) {} + + async onApplicationBootstrap(): Promise<void> { + setTimeout(async () => { + await this.preloadModule(() => import('./reports/reports.module')); + }, 5000); // 5 seconds after startup + } + + private async preloadModule(importFn: () => Promise<any>): Promise<void> { + try { + const module = await importFn(); + const moduleType = module.default || Object.values(module)[0]; + await this.lazyModuleLoader.load(() => moduleType); + } catch (error) { + console.warn('Failed to preload module', error); + } + } +} +``` + +Reference: [NestJS Lazy Loading Modules](https://docs.nestjs.com/fundamentals/lazy-loading-modules) diff --git a/packages/mosaic/framework/skills/nestjs-best-practices/rules/perf-optimize-database.md b/packages/mosaic/framework/skills/nestjs-best-practices/rules/perf-optimize-database.md new file mode 100644 index 00000000..964189f7 --- /dev/null +++ b/packages/mosaic/framework/skills/nestjs-best-practices/rules/perf-optimize-database.md @@ -0,0 +1,131 @@ +--- +title: Optimize Database Queries +impact: HIGH +impactDescription: Database queries are typically the largest source of latency +tags: performance, database, queries, optimization +--- + +## Optimize Database Queries + +Select only needed columns, use proper indexes, avoid over-fetching relations, and consider query performance when designing your data access. Most API slowness traces back to inefficient database queries. + +**Incorrect (over-fetching data and missing indexes):** + +```typescript +// Select everything when you need few fields +@Injectable() +export class UsersService { + async findAllEmails(): Promise<string[]> { + const users = await this.repo.find(); + // Fetches ALL columns for ALL users + return users.map((u) => u.email); + } + + async getUserSummary(id: string): Promise<UserSummary> { + const user = await this.repo.findOne({ + where: { id }, + relations: ['posts', 'posts.comments', 'posts.comments.author', 'followers'], + }); + // Over-fetches massive relation tree + return { name: user.name, postCount: user.posts.length }; + } +} + +// No indexes on frequently queried columns +@Entity() +export class Order { + @Column() + userId: string; // No index - full table scan on every lookup + + @Column() + status: string; // No index - slow status filtering +} +``` + +**Correct (select only needed data with proper indexes):** + +```typescript +// Select only needed columns +@Injectable() +export class UsersService { + async findAllEmails(): Promise<string[]> { + const users = await this.repo.find({ + select: ['email'], // Only fetch email column + }); + return users.map((u) => u.email); + } + + // Use QueryBuilder for complex selections + async getUserSummary(id: string): Promise<UserSummary> { + return this.repo + .createQueryBuilder('user') + .select('user.name', 'name') + .addSelect('COUNT(post.id)', 'postCount') + .leftJoin('user.posts', 'post') + .where('user.id = :id', { id }) + .groupBy('user.id') + .getRawOne(); + } + + // Fetch relations only when needed + async getFullProfile(id: string): Promise<User> { + return this.repo.findOne({ + where: { id }, + relations: ['posts'], // Only immediate relation + select: { + id: true, + name: true, + email: true, + posts: { + id: true, + title: true, + }, + }, + }); + } +} + +// Add indexes on frequently queried columns +@Entity() +@Index(['userId']) +@Index(['status']) +@Index(['createdAt']) +@Index(['userId', 'status']) // Composite index for common query pattern +export class Order { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Column() + userId: string; + + @Column() + status: string; + + @CreateDateColumn() + createdAt: Date; +} + +// Always paginate large datasets +@Injectable() +export class OrdersService { + async findAll(page = 1, limit = 20): Promise<PaginatedResult<Order>> { + const [items, total] = await this.repo.findAndCount({ + skip: (page - 1) * limit, + take: limit, + order: { createdAt: 'DESC' }, + }); + + return { + items, + meta: { + page, + limit, + total, + totalPages: Math.ceil(total / limit), + }, + }; + } +} +``` + +Reference: [TypeORM Query Builder](https://typeorm.io/select-query-builder) diff --git a/packages/mosaic/framework/skills/nestjs-best-practices/rules/perf-use-caching.md b/packages/mosaic/framework/skills/nestjs-best-practices/rules/perf-use-caching.md new file mode 100644 index 00000000..29c3cc07 --- /dev/null +++ b/packages/mosaic/framework/skills/nestjs-best-practices/rules/perf-use-caching.md @@ -0,0 +1,126 @@ +--- +title: Use Caching Strategically +impact: HIGH +impactDescription: Dramatically reduces database load and response times +tags: performance, caching, redis, optimization +--- + +## Use Caching Strategically + +Implement caching for expensive operations, frequently accessed data, and external API calls. Use NestJS CacheModule with appropriate TTLs and cache invalidation strategies. Don't cache everything - focus on high-impact areas. + +**Incorrect (no caching or caching everything):** + +```typescript +// No caching for expensive, repeated queries +@Injectable() +export class ProductsService { + async getPopular(): Promise<Product[]> { + // Runs complex aggregation query EVERY request + return this.productsRepo + .createQueryBuilder('p') + .leftJoin('p.orders', 'o') + .select('p.*, COUNT(o.id) as orderCount') + .groupBy('p.id') + .orderBy('orderCount', 'DESC') + .limit(20) + .getMany(); + } +} + +// Cache everything without thought +@Injectable() +export class UsersService { + @CacheKey('users') + @CacheTTL(3600) + @UseInterceptors(CacheInterceptor) + async findAll(): Promise<User[]> { + // Caching user list for 1 hour is wrong if data changes frequently + return this.usersRepo.find(); + } +} +``` + +**Correct (strategic caching with proper invalidation):** + +```typescript +// Setup caching module +@Module({ + imports: [ + CacheModule.registerAsync({ + imports: [ConfigModule], + inject: [ConfigService], + useFactory: (config: ConfigService) => ({ + stores: [new KeyvRedis(config.get('REDIS_URL'))], + ttl: 60 * 1000, // Default 60s + }), + }), + ], +}) +export class AppModule {} + +// Manual caching for granular control +@Injectable() +export class ProductsService { + constructor( + @Inject(CACHE_MANAGER) private cache: Cache, + private productsRepo: ProductRepository, + ) {} + + async getPopular(): Promise<Product[]> { + const cacheKey = 'products:popular'; + + // Try cache first + const cached = await this.cache.get<Product[]>(cacheKey); + if (cached) return cached; + + // Cache miss - fetch and cache + const products = await this.fetchPopularProducts(); + await this.cache.set(cacheKey, products, 5 * 60 * 1000); // 5 min TTL + return products; + } + + // Invalidate cache on changes + async updateProduct(id: string, dto: UpdateProductDto): Promise<Product> { + const product = await this.productsRepo.save({ id, ...dto }); + await this.cache.del('products:popular'); // Invalidate + return product; + } +} + +// Decorator-based caching with auto-interceptor +@Controller('categories') +@UseInterceptors(CacheInterceptor) +export class CategoriesController { + @Get() + @CacheTTL(30 * 60 * 1000) // 30 minutes - categories rarely change + findAll(): Promise<Category[]> { + return this.categoriesService.findAll(); + } + + @Get(':id') + @CacheTTL(60 * 1000) // 1 minute + @CacheKey('category') + findOne(@Param('id') id: string): Promise<Category> { + return this.categoriesService.findOne(id); + } +} + +// Event-based cache invalidation +@Injectable() +export class CacheInvalidationService { + constructor(@Inject(CACHE_MANAGER) private cache: Cache) {} + + @OnEvent('product.created') + @OnEvent('product.updated') + @OnEvent('product.deleted') + async invalidateProductCaches(event: ProductEvent) { + await Promise.all([ + this.cache.del('products:popular'), + this.cache.del(`product:${event.productId}`), + ]); + } +} +``` + +Reference: [NestJS Caching](https://docs.nestjs.com/techniques/caching) diff --git a/packages/mosaic/framework/skills/nestjs-best-practices/rules/security-auth-jwt.md b/packages/mosaic/framework/skills/nestjs-best-practices/rules/security-auth-jwt.md new file mode 100644 index 00000000..a0d1ff03 --- /dev/null +++ b/packages/mosaic/framework/skills/nestjs-best-practices/rules/security-auth-jwt.md @@ -0,0 +1,146 @@ +--- +title: Implement Secure JWT Authentication +impact: CRITICAL +impactDescription: Essential for secure APIs +tags: security, jwt, authentication, tokens +--- + +## Implement Secure JWT Authentication + +Use `@nestjs/jwt` with `@nestjs/passport` for authentication. Store secrets securely, use appropriate token lifetimes, implement refresh tokens, and validate tokens properly. Never expose sensitive data in JWT payloads. + +**Incorrect (insecure JWT implementation):** + +```typescript +// Hardcode secrets +@Module({ + imports: [ + JwtModule.register({ + secret: 'my-secret-key', // Exposed in code + signOptions: { expiresIn: '7d' }, // Too long + }), + ], +}) +export class AuthModule {} + +// Store sensitive data in JWT +async login(user: User): Promise<{ accessToken: string }> { + const payload = { + sub: user.id, + email: user.email, + password: user.password, // NEVER include password! + ssn: user.ssn, // NEVER include sensitive data! + isAdmin: user.isAdmin, // Can be tampered if not verified + }; + return { accessToken: this.jwtService.sign(payload) }; +} + +// Skip token validation +@Injectable() +export class JwtStrategy extends PassportStrategy(Strategy) { + constructor() { + super({ + jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(), + secretOrKey: 'my-secret', + }); + } + + async validate(payload: any): Promise<any> { + return payload; // No validation of user existence + } +} +``` + +**Correct (secure JWT with refresh tokens):** + +```typescript +// Secure JWT configuration +@Module({ + imports: [ + JwtModule.registerAsync({ + imports: [ConfigModule], + inject: [ConfigService], + useFactory: (config: ConfigService) => ({ + secret: config.get<string>('JWT_SECRET'), + signOptions: { + expiresIn: '15m', // Short-lived access tokens + issuer: config.get<string>('JWT_ISSUER'), + audience: config.get<string>('JWT_AUDIENCE'), + }, + }), + }), + PassportModule.register({ defaultStrategy: 'jwt' }), + ], +}) +export class AuthModule {} + +// Minimal JWT payload +@Injectable() +export class AuthService { + async login(user: User): Promise<TokenResponse> { + // Only include necessary, non-sensitive data + const payload: JwtPayload = { + sub: user.id, + email: user.email, + roles: user.roles, + iat: Math.floor(Date.now() / 1000), + }; + + const accessToken = this.jwtService.sign(payload); + const refreshToken = await this.createRefreshToken(user.id); + + return { accessToken, refreshToken, expiresIn: 900 }; + } + + private async createRefreshToken(userId: string): Promise<string> { + const token = randomBytes(32).toString('hex'); + const hashedToken = await bcrypt.hash(token, 10); + + await this.refreshTokenRepo.save({ + userId, + token: hashedToken, + expiresAt: new Date(Date.now() + 7 * 24 * 60 * 60 * 1000), // 7 days + }); + + return token; + } +} + +// Proper JWT strategy with validation +@Injectable() +export class JwtStrategy extends PassportStrategy(Strategy) { + constructor( + private config: ConfigService, + private usersService: UsersService, + ) { + super({ + jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(), + secretOrKey: config.get<string>('JWT_SECRET'), + ignoreExpiration: false, + issuer: config.get<string>('JWT_ISSUER'), + audience: config.get<string>('JWT_AUDIENCE'), + }); + } + + async validate(payload: JwtPayload): Promise<User> { + // Verify user still exists and is active + const user = await this.usersService.findById(payload.sub); + + if (!user || !user.isActive) { + throw new UnauthorizedException('User not found or inactive'); + } + + // Verify token wasn't issued before password change + if (user.passwordChangedAt) { + const tokenIssuedAt = new Date(payload.iat * 1000); + if (tokenIssuedAt < user.passwordChangedAt) { + throw new UnauthorizedException('Token invalidated by password change'); + } + } + + return user; + } +} +``` + +Reference: [NestJS Authentication](https://docs.nestjs.com/security/authentication) diff --git a/packages/mosaic/framework/skills/nestjs-best-practices/rules/security-rate-limiting.md b/packages/mosaic/framework/skills/nestjs-best-practices/rules/security-rate-limiting.md new file mode 100644 index 00000000..7d39e9c8 --- /dev/null +++ b/packages/mosaic/framework/skills/nestjs-best-practices/rules/security-rate-limiting.md @@ -0,0 +1,125 @@ +--- +title: Implement Rate Limiting +impact: HIGH +impactDescription: Protects against abuse and ensures fair resource usage +tags: security, rate-limiting, throttler, protection +--- + +## Implement Rate Limiting + +Use `@nestjs/throttler` to limit request rates per client. Apply different limits for different endpoints - stricter for auth endpoints, more relaxed for read operations. Consider using Redis for distributed rate limiting in clustered deployments. + +**Incorrect (no rate limiting on sensitive endpoints):** + +```typescript +// No rate limiting on sensitive endpoints +@Controller('auth') +export class AuthController { + @Post('login') + async login(@Body() dto: LoginDto): Promise<TokenResponse> { + // Attackers can brute-force credentials + return this.authService.login(dto); + } + + @Post('forgot-password') + async forgotPassword(@Body() dto: ForgotPasswordDto): Promise<void> { + // Can be abused to spam users with emails + return this.authService.sendResetEmail(dto.email); + } +} + +// Same limits for all endpoints +@UseGuards(ThrottlerGuard) +@Controller('api') +export class ApiController { + @Get('public-data') + async getPublic() {} // Should allow more requests + + @Post('process-payment') + async payment() {} // Should be more restrictive +} +``` + +**Correct (configured throttler with endpoint-specific limits):** + +```typescript +// Configure throttler globally with multiple limits +import { ThrottlerModule, ThrottlerGuard } from '@nestjs/throttler'; + +@Module({ + imports: [ + ThrottlerModule.forRoot([ + { + name: 'short', + ttl: 1000, // 1 second + limit: 3, // 3 requests per second + }, + { + name: 'medium', + ttl: 10000, // 10 seconds + limit: 20, // 20 requests per 10 seconds + }, + { + name: 'long', + ttl: 60000, // 1 minute + limit: 100, // 100 requests per minute + }, + ]), + ], + providers: [ + { + provide: APP_GUARD, + useClass: ThrottlerGuard, + }, + ], +}) +export class AppModule {} + +// Override limits per endpoint +@Controller('auth') +export class AuthController { + @Post('login') + @Throttle({ short: { limit: 5, ttl: 60000 } }) // 5 attempts per minute + async login(@Body() dto: LoginDto): Promise<TokenResponse> { + return this.authService.login(dto); + } + + @Post('forgot-password') + @Throttle({ short: { limit: 3, ttl: 3600000 } }) // 3 per hour + async forgotPassword(@Body() dto: ForgotPasswordDto): Promise<void> { + return this.authService.sendResetEmail(dto.email); + } +} + +// Skip throttling for certain routes +@Controller('health') +export class HealthController { + @Get() + @SkipThrottle() + check(): string { + return 'OK'; + } +} + +// Custom throttle per user type +@Injectable() +export class CustomThrottlerGuard extends ThrottlerGuard { + protected async getTracker(req: Request): Promise<string> { + // Use user ID if authenticated, IP otherwise + return req.user?.id || req.ip; + } + + protected async getLimit(context: ExecutionContext): Promise<number> { + const request = context.switchToHttp().getRequest(); + + // Higher limits for authenticated users + if (request.user) { + return request.user.isPremium ? 1000 : 200; + } + + return 50; // Anonymous users + } +} +``` + +Reference: [NestJS Throttler](https://docs.nestjs.com/security/rate-limiting) diff --git a/packages/mosaic/framework/skills/nestjs-best-practices/rules/security-sanitize-output.md b/packages/mosaic/framework/skills/nestjs-best-practices/rules/security-sanitize-output.md new file mode 100644 index 00000000..78e3d396 --- /dev/null +++ b/packages/mosaic/framework/skills/nestjs-best-practices/rules/security-sanitize-output.md @@ -0,0 +1,139 @@ +--- +title: Sanitize Output to Prevent XSS +impact: HIGH +impactDescription: XSS vulnerabilities can compromise user sessions and data +tags: security, xss, sanitization, html +--- + +## Sanitize Output to Prevent XSS + +While NestJS APIs typically return JSON (which browsers don't execute), XSS risks exist when rendering HTML, storing user content, or when frontend frameworks improperly handle API responses. Sanitize user-generated content before storage and use proper Content-Type headers. + +**Incorrect (storing raw HTML without sanitization):** + +```typescript +// Store raw HTML from users +@Injectable() +export class CommentsService { + async create(dto: CreateCommentDto): Promise<Comment> { + // User can inject: <script>steal(document.cookie)</script> + return this.repo.save({ + content: dto.content, // Raw, unsanitized + authorId: dto.authorId, + }); + } +} + +// Return HTML without sanitization +@Controller('pages') +export class PagesController { + @Get(':slug') + @Header('Content-Type', 'text/html') + async getPage(@Param('slug') slug: string): Promise<string> { + const page = await this.pagesService.findBySlug(slug); + // If page.content contains user input, XSS is possible + return `<html><body>${page.content}</body></html>`; + } +} + +// Reflect user input in errors +@Get(':id') +async findOne(@Param('id') id: string): Promise<User> { + const user = await this.repo.findOne({ where: { id } }); + if (!user) { + // XSS if id contains malicious content and error is rendered + throw new NotFoundException(`User ${id} not found`); + } + return user; +} +``` + +**Correct (sanitize content and use proper headers):** + +```typescript +// Sanitize HTML content before storage +import * as sanitizeHtml from 'sanitize-html'; + +@Injectable() +export class CommentsService { + private readonly sanitizeOptions: sanitizeHtml.IOptions = { + allowedTags: ['b', 'i', 'em', 'strong', 'a', 'p', 'br'], + allowedAttributes: { + a: ['href', 'title'], + }, + allowedSchemes: ['http', 'https', 'mailto'], + }; + + async create(dto: CreateCommentDto): Promise<Comment> { + return this.repo.save({ + content: sanitizeHtml(dto.content, this.sanitizeOptions), + authorId: dto.authorId, + }); + } +} + +// Use validation pipe to strip HTML +import { Transform } from 'class-transformer'; + +export class CreatePostDto { + @IsString() + @MaxLength(1000) + @Transform(({ value }) => sanitizeHtml(value, { allowedTags: [] })) + title: string; + + @IsString() + @Transform(({ value }) => + sanitizeHtml(value, { + allowedTags: ['p', 'br', 'b', 'i', 'a'], + allowedAttributes: { a: ['href'] }, + }), + ) + content: string; +} + +// Set proper Content-Type headers +@Controller('api') +export class ApiController { + @Get('data') + @Header('Content-Type', 'application/json') + async getData(): Promise<DataResponse> { + // JSON response - browser won't execute scripts + return this.service.getData(); + } +} + +// Sanitize error messages +@Get(':id') +async findOne(@Param('id', ParseUUIDPipe) id: string): Promise<User> { + const user = await this.repo.findOne({ where: { id } }); + if (!user) { + // UUID validation ensures safe format + throw new NotFoundException('User not found'); + } + return user; +} + +// Use Helmet for CSP headers +import helmet from 'helmet'; + +async function bootstrap() { + const app = await NestFactory.create(AppModule); + + app.use( + helmet({ + contentSecurityPolicy: { + directives: { + defaultSrc: ["'self'"], + scriptSrc: ["'self'"], + styleSrc: ["'self'", "'unsafe-inline'"], + imgSrc: ["'self'", 'data:', 'https:'], + }, + }, + }), + ); + + await app.listen(3000); +} +``` + +Reference: [OWASP XSS Prevention](https://cheatsheetseries.owasp.org/cheatsheets/Cross_Site_Scripting_Prevention_Cheat_Sheet.html) diff --git a/packages/mosaic/framework/skills/nestjs-best-practices/rules/security-use-guards.md b/packages/mosaic/framework/skills/nestjs-best-practices/rules/security-use-guards.md new file mode 100644 index 00000000..fb1359c4 --- /dev/null +++ b/packages/mosaic/framework/skills/nestjs-best-practices/rules/security-use-guards.md @@ -0,0 +1,135 @@ +--- +title: Use Guards for Authentication and Authorization +impact: HIGH +impactDescription: Enforces access control before handlers execute +tags: security, guards, authentication, authorization +--- + +## Use Guards for Authentication and Authorization + +Guards determine whether a request should be handled based on authentication state, roles, permissions, or other conditions. They run after middleware but before pipes and interceptors, making them ideal for access control. Use guards instead of manual checks in controllers. + +**Incorrect (manual auth checks in every handler):** + +```typescript +// Manual auth checks in every handler +@Controller('admin') +export class AdminController { + @Get('users') + async getUsers(@Request() req) { + if (!req.user) { + throw new UnauthorizedException(); + } + if (!req.user.roles.includes('admin')) { + throw new ForbiddenException(); + } + return this.adminService.getUsers(); + } + + @Delete('users/:id') + async deleteUser(@Request() req, @Param('id') id: string) { + if (!req.user) { + throw new UnauthorizedException(); + } + if (!req.user.roles.includes('admin')) { + throw new ForbiddenException(); + } + return this.adminService.deleteUser(id); + } +} +``` + +**Correct (guards with declarative decorators):** + +```typescript +// JWT Auth Guard +@Injectable() +export class JwtAuthGuard implements CanActivate { + constructor( + private jwtService: JwtService, + private reflector: Reflector, + ) {} + + async canActivate(context: ExecutionContext): Promise<boolean> { + // Check for @Public() decorator + const isPublic = this.reflector.getAllAndOverride<boolean>('isPublic', [ + context.getHandler(), + context.getClass(), + ]); + if (isPublic) return true; + + const request = context.switchToHttp().getRequest(); + const token = this.extractToken(request); + + if (!token) { + throw new UnauthorizedException('No token provided'); + } + + try { + request.user = await this.jwtService.verifyAsync(token); + return true; + } catch { + throw new UnauthorizedException('Invalid token'); + } + } + + private extractToken(request: Request): string | undefined { + const [type, token] = request.headers.authorization?.split(' ') ?? []; + return type === 'Bearer' ? token : undefined; + } +} + +// Roles Guard +@Injectable() +export class RolesGuard implements CanActivate { + constructor(private reflector: Reflector) {} + + canActivate(context: ExecutionContext): boolean { + const requiredRoles = this.reflector.getAllAndOverride<Role[]>('roles', [ + context.getHandler(), + context.getClass(), + ]); + + if (!requiredRoles) return true; + + const { user } = context.switchToHttp().getRequest(); + return requiredRoles.some((role) => user.roles?.includes(role)); + } +} + +// Decorators +export const Public = () => SetMetadata('isPublic', true); +export const Roles = (...roles: Role[]) => SetMetadata('roles', roles); + +// Register guards globally +@Module({ + providers: [ + { provide: APP_GUARD, useClass: JwtAuthGuard }, + { provide: APP_GUARD, useClass: RolesGuard }, + ], +}) +export class AppModule {} + +// Clean controller +@Controller('admin') +@Roles(Role.Admin) // Applied to all routes +export class AdminController { + @Get('users') + getUsers(): Promise<User[]> { + return this.adminService.getUsers(); + } + + @Delete('users/:id') + deleteUser(@Param('id') id: string): Promise<void> { + return this.adminService.deleteUser(id); + } + + @Public() // Override: no auth required + @Get('health') + health() { + return { status: 'ok' }; + } +} +``` + +Reference: [NestJS Guards](https://docs.nestjs.com/guards) diff --git a/packages/mosaic/framework/skills/nestjs-best-practices/rules/security-validate-all-input.md b/packages/mosaic/framework/skills/nestjs-best-practices/rules/security-validate-all-input.md new file mode 100644 index 00000000..1fb153f2 --- /dev/null +++ b/packages/mosaic/framework/skills/nestjs-best-practices/rules/security-validate-all-input.md @@ -0,0 +1,150 @@ +--- +title: Validate All Input with DTOs and Pipes +impact: HIGH +impactDescription: First line of defense against attacks +tags: security, validation, dto, pipes +--- + +## Validate All Input with DTOs and Pipes + +Always validate incoming data using class-validator decorators on DTOs and the global ValidationPipe. Never trust user input. Validate all request bodies, query parameters, and route parameters before processing. + +**Incorrect (trust raw input without validation):** + +```typescript +// Trust raw input without validation +@Controller('users') +export class UsersController { + @Post() + create(@Body() body: any) { + // body could contain anything - SQL injection, XSS, etc. + return this.usersService.create(body); + } + + @Get() + findAll(@Query() query: any) { + // query.limit could be "'; DROP TABLE users; --" + return this.usersService.findAll(query.limit); + } +} + +// DTOs without validation decorators +export class CreateUserDto { + name: string; // No validation + email: string; // Could be "not-an-email" + age: number; // Could be "abc" or -999 +} +``` + +**Correct (validated DTOs with global ValidationPipe):** + +```typescript +// Enable ValidationPipe globally in main.ts +async function bootstrap() { + const app = await NestFactory.create(AppModule); + + app.useGlobalPipes( + new ValidationPipe({ + whitelist: true, // Strip unknown properties + forbidNonWhitelisted: true, // Throw on unknown properties + transform: true, // Auto-transform to DTO types + transformOptions: { + enableImplicitConversion: true, + }, + }), + ); + + await app.listen(3000); +} + +// Create well-validated DTOs +import { + IsString, + IsEmail, + IsInt, + Min, + Max, + IsOptional, + MinLength, + MaxLength, + Matches, + IsNotEmpty, +} from 'class-validator'; +import { Transform, Type } from 'class-transformer'; + +export class CreateUserDto { + @IsString() + @IsNotEmpty() + @MinLength(2) + @MaxLength(100) + @Transform(({ value }) => value?.trim()) + name: string; + + @IsEmail() + @Transform(({ value }) => value?.toLowerCase().trim()) + email: string; + + @IsInt() + @Min(0) + @Max(150) + age: number; + + @IsString() + @MinLength(8) + @MaxLength(100) + @Matches(/^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)/, { + message: 'Password must contain uppercase, lowercase, and number', + }) + password: string; +} + +// Query DTO with defaults and transformation +export class FindUsersQueryDto { + @IsOptional() + @IsString() + @MaxLength(100) + search?: string; + + @IsOptional() + @Type(() => Number) + @IsInt() + @Min(1) + @Max(100) + limit: number = 20; + + @IsOptional() + @Type(() => Number) + @IsInt() + @Min(0) + offset: number = 0; +} + +// Param validation +export class UserIdParamDto { + @IsUUID('4') + id: string; +} + +@Controller('users') +export class UsersController { + @Post() + create(@Body() dto: CreateUserDto): Promise<User> { + // dto is guaranteed to be valid + return this.usersService.create(dto); + } + + @Get() + findAll(@Query() query: FindUsersQueryDto): Promise<User[]> { + // query.limit is a number, query.search is sanitized + return this.usersService.findAll(query); + } + + @Get(':id') + findOne(@Param() params: UserIdParamDto): Promise<User> { + // params.id is a valid UUID + return this.usersService.findById(params.id); + } +} +``` + +Reference: [NestJS Validation](https://docs.nestjs.com/techniques/validation) diff --git a/packages/mosaic/framework/skills/nestjs-best-practices/rules/test-e2e-supertest.md b/packages/mosaic/framework/skills/nestjs-best-practices/rules/test-e2e-supertest.md new file mode 100644 index 00000000..8e5f6a77 --- /dev/null +++ b/packages/mosaic/framework/skills/nestjs-best-practices/rules/test-e2e-supertest.md @@ -0,0 +1,174 @@ +--- +title: Use Supertest for E2E Testing +impact: HIGH +impactDescription: Validates the full request/response cycle +tags: testing, e2e, supertest, integration +--- + +## Use Supertest for E2E Testing + +End-to-end tests use Supertest to make real HTTP requests against your NestJS application. They test the full stack including middleware, guards, pipes, and interceptors. E2E tests catch integration issues that unit tests miss. + +**Incorrect (no proper E2E setup or teardown):** + +```typescript +// Only unit test controllers +describe('UsersController', () => { + it('should return users', async () => { + const service = { findAll: jest.fn().mockResolvedValue([]) }; + const controller = new UsersController(service as any); + + const result = await controller.findAll(); + + expect(result).toEqual([]); + // Doesn't test: routes, guards, pipes, serialization + }); +}); + +// E2E tests without proper setup/teardown +describe('Users API', () => { + it('should create user', async () => { + const app = await NestFactory.create(AppModule); + // No proper initialization + // No cleanup after test + // Hits real database + }); +}); +``` + +**Correct (proper E2E setup with Supertest):** + +```typescript +// Proper E2E test setup +import { Test, TestingModule } from '@nestjs/testing'; +import { INestApplication, ValidationPipe } from '@nestjs/common'; +import * as request from 'supertest'; +import { AppModule } from '../src/app.module'; + +describe('UsersController (e2e)', () => { + let app: INestApplication; + + beforeAll(async () => { + const moduleFixture: TestingModule = await Test.createTestingModule({ + imports: [AppModule], + }).compile(); + + app = moduleFixture.createNestApplication(); + + // Apply same config as production + app.useGlobalPipes( + new ValidationPipe({ + whitelist: true, + transform: true, + forbidNonWhitelisted: true, + }), + ); + + await app.init(); + }); + + afterAll(async () => { + await app.close(); + }); + + describe('/users (POST)', () => { + it('should create a user', () => { + return request(app.getHttpServer()) + .post('/users') + .send({ name: 'John', email: 'john@test.com' }) + .expect(201) + .expect((res) => { + expect(res.body).toHaveProperty('id'); + expect(res.body.name).toBe('John'); + expect(res.body.email).toBe('john@test.com'); + }); + }); + + it('should return 400 for invalid email', () => { + return request(app.getHttpServer()) + .post('/users') + .send({ name: 'John', email: 'invalid-email' }) + .expect(400) + .expect((res) => { + expect(res.body.message).toContain('email'); + }); + }); + }); + + describe('/users/:id (GET)', () => { + it('should return 404 for non-existent user', () => { + return request(app.getHttpServer()).get('/users/non-existent-id').expect(404); + }); + }); +}); + +// Testing with authentication +describe('Protected Routes (e2e)', () => { + let app: INestApplication; + let authToken: string; + + beforeAll(async () => { + const moduleFixture = await Test.createTestingModule({ + imports: [AppModule], + }).compile(); + + app = moduleFixture.createNestApplication(); + app.useGlobalPipes(new ValidationPipe({ whitelist: true })); + await app.init(); + + // Get auth token + const loginResponse = await request(app.getHttpServer()) + .post('/auth/login') + .send({ email: 'test@test.com', password: 'password' }); + + authToken = loginResponse.body.accessToken; + }); + + it('should return 401 without token', () => { + return request(app.getHttpServer()).get('/users/me').expect(401); + }); + + it('should return user profile with valid token', () => { + return request(app.getHttpServer()) + .get('/users/me') + .set('Authorization', `Bearer ${authToken}`) + .expect(200) + .expect((res) => { + expect(res.body.email).toBe('test@test.com'); + }); + }); +}); + +// Database isolation for E2E tests +describe('Orders API (e2e)', () => { + let app: INestApplication; + let dataSource: DataSource; + + beforeAll(async () => { + const moduleFixture = await Test.createTestingModule({ + imports: [ + ConfigModule.forRoot({ + envFilePath: '.env.test', // Test database config + }), + AppModule, + ], + }).compile(); + + app = moduleFixture.createNestApplication(); + dataSource = moduleFixture.get(DataSource); + await app.init(); + }); + + beforeEach(async () => { + // Clean database between tests + await dataSource.synchronize(true); + }); + + afterAll(async () => { + await dataSource.destroy(); + await app.close(); + }); +}); +``` + +Reference: [NestJS E2E Testing](https://docs.nestjs.com/fundamentals/testing#end-to-end-testing) diff --git a/packages/mosaic/framework/skills/nestjs-best-practices/rules/test-mock-external-services.md b/packages/mosaic/framework/skills/nestjs-best-practices/rules/test-mock-external-services.md new file mode 100644 index 00000000..939b531e --- /dev/null +++ b/packages/mosaic/framework/skills/nestjs-best-practices/rules/test-mock-external-services.md @@ -0,0 +1,174 @@ +--- +title: Mock External Services in Tests +impact: HIGH +impactDescription: Ensures fast, reliable, deterministic tests +tags: testing, mocking, external-services, jest +--- + +## Mock External Services in Tests + +Never call real external services (APIs, databases, message queues) in unit tests. Mock them to ensure tests are fast, deterministic, and don't incur costs. Use realistic mock data and test edge cases like timeouts and errors. + +**Incorrect (calling real APIs and databases):** + +```typescript +// Call real APIs in tests +describe('PaymentService', () => { + it('should process payment', async () => { + const service = new PaymentService(new StripeClient(realApiKey)); + // Hits real Stripe API! + const result = await service.charge('tok_visa', 1000); + // Slow, costs money, flaky + }); +}); + +// Use real database +describe('UsersService', () => { + beforeEach(async () => { + await connection.query('DELETE FROM users'); // Modifies real DB + }); + + it('should create user', async () => { + const user = await service.create({ email: 'test@test.com' }); + // Side effects on shared database + }); +}); + +// Incomplete mocks +const mockHttpService = { + get: jest.fn().mockResolvedValue({ data: {} }), + // Missing error scenarios, missing other methods +}; +``` + +**Correct (mock all external dependencies):** + +```typescript +// Mock HTTP service properly +describe('WeatherService', () => { + let service: WeatherService; + let httpService: jest.Mocked<HttpService>; + + beforeEach(async () => { + const module = await Test.createTestingModule({ + providers: [ + WeatherService, + { + provide: HttpService, + useValue: { + get: jest.fn(), + post: jest.fn(), + }, + }, + ], + }).compile(); + + service = module.get(WeatherService); + httpService = module.get(HttpService); + }); + + it('should return weather data', async () => { + const mockResponse = { + data: { temperature: 72, humidity: 45 }, + status: 200, + statusText: 'OK', + headers: {}, + config: {}, + }; + + httpService.get.mockReturnValue(of(mockResponse)); + + const result = await service.getWeather('NYC'); + + expect(result).toEqual({ temperature: 72, humidity: 45 }); + }); + + it('should handle API timeout', async () => { + httpService.get.mockReturnValue(throwError(() => new Error('ETIMEDOUT'))); + + await expect(service.getWeather('NYC')).rejects.toThrow('Weather service unavailable'); + }); + + it('should handle rate limiting', async () => { + httpService.get.mockReturnValue( + throwError(() => ({ + response: { status: 429, data: { message: 'Rate limited' } }, + })), + ); + + await expect(service.getWeather('NYC')).rejects.toThrow(TooManyRequestsException); + }); +}); + +// Mock repository instead of database +describe('UsersService', () => { + let service: UsersService; + let repo: jest.Mocked<Repository<User>>; + + beforeEach(async () => { + const mockRepo = { + find: jest.fn(), + findOne: jest.fn(), + save: jest.fn(), + delete: jest.fn(), + createQueryBuilder: jest.fn(), + }; + + const module = await Test.createTestingModule({ + providers: [UsersService, { provide: getRepositoryToken(User), useValue: mockRepo }], + }).compile(); + + service = module.get(UsersService); + repo = module.get(getRepositoryToken(User)); + }); + + it('should find user by id', async () => { + const mockUser = { id: '1', name: 'John', email: 'john@test.com' }; + repo.findOne.mockResolvedValue(mockUser); + + const result = await service.findById('1'); + + expect(result).toEqual(mockUser); + expect(repo.findOne).toHaveBeenCalledWith({ where: { id: '1' } }); + }); +}); + +// Create mock factory for complex SDKs +function createMockStripe(): jest.Mocked<Stripe> { + return { + paymentIntents: { + create: jest.fn(), + retrieve: jest.fn(), + confirm: jest.fn(), + cancel: jest.fn(), + }, + customers: { + create: jest.fn(), + retrieve: jest.fn(), + }, + } as any; +} + +// Mock time for time-dependent tests +describe('TokenService', () => { + beforeEach(() => { + jest.useFakeTimers(); + jest.setSystemTime(new Date('2024-01-15')); + }); + + afterEach(() => { + jest.useRealTimers(); + }); + + it('should expire token after 1 hour', async () => { + const token = await service.createToken(); + + // Fast-forward time + jest.advanceTimersByTime(61 * 60 * 1000); + + expect(await service.isValid(token)).toBe(false); + }); +}); +``` + +Reference: [Jest Mocking](https://jestjs.io/docs/mock-functions) diff --git a/packages/mosaic/framework/skills/nestjs-best-practices/rules/test-use-testing-module.md b/packages/mosaic/framework/skills/nestjs-best-practices/rules/test-use-testing-module.md new file mode 100644 index 00000000..2a752263 --- /dev/null +++ b/packages/mosaic/framework/skills/nestjs-best-practices/rules/test-use-testing-module.md @@ -0,0 +1,153 @@ +--- +title: Use Testing Module for Unit Tests +impact: HIGH +impactDescription: Enables proper isolated testing with mocked dependencies +tags: testing, unit-tests, mocking, jest +--- + +## Use Testing Module for Unit Tests + +Use `@nestjs/testing` module to create isolated test environments with mocked dependencies. This ensures your tests run fast, don't depend on external services, and properly test your business logic in isolation. + +**Incorrect (manual instantiation bypassing DI):** + +```typescript +// Instantiate services manually without DI +describe('UsersService', () => { + it('should create user', async () => { + // Manual instantiation bypasses DI + const repo = new UserRepository(); // Real repo! + const service = new UsersService(repo); + + const user = await service.create({ name: 'Test' }); + // This hits the real database! + }); +}); + +// Test implementation details +describe('UsersController', () => { + it('should call service', async () => { + const service = { create: jest.fn() }; + const controller = new UsersController(service as any); + + await controller.create({ name: 'Test' }); + + expect(service.create).toHaveBeenCalled(); // Tests implementation, not behavior + }); +}); +``` + +**Correct (use Test.createTestingModule with mocked dependencies):** + +```typescript +// Use Test.createTestingModule for proper DI +import { Test, TestingModule } from '@nestjs/testing'; + +describe('UsersService', () => { + let service: UsersService; + let repo: jest.Mocked<UserRepository>; + + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + providers: [ + UsersService, + { + provide: UserRepository, + useValue: { + save: jest.fn(), + findOne: jest.fn(), + find: jest.fn(), + }, + }, + ], + }).compile(); + + service = module.get<UsersService>(UsersService); + repo = module.get(UserRepository); + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + describe('create', () => { + it('should save and return user', async () => { + const dto = { name: 'John', email: 'john@test.com' }; + const expectedUser = { id: '1', ...dto }; + + repo.save.mockResolvedValue(expectedUser); + + const result = await service.create(dto); + + expect(result).toEqual(expectedUser); + expect(repo.save).toHaveBeenCalledWith(dto); + }); + + it('should throw on duplicate email', async () => { + repo.findOne.mockResolvedValue({ id: '1', email: 'test@test.com' }); + + await expect(service.create({ name: 'Test', email: 'test@test.com' })).rejects.toThrow( + ConflictException, + ); + }); + }); + + describe('findById', () => { + it('should return user when found', async () => { + const user = { id: '1', name: 'John' }; + repo.findOne.mockResolvedValue(user); + + const result = await service.findById('1'); + + expect(result).toEqual(user); + }); + + it('should throw NotFoundException when not found', async () => { + repo.findOne.mockResolvedValue(null); + + await expect(service.findById('999')).rejects.toThrow(NotFoundException); + }); + }); +}); + +// Testing guards and interceptors +describe('RolesGuard', () => { + let guard: RolesGuard; + let reflector: Reflector; + + beforeEach(async () => { + const module = await Test.createTestingModule({ + providers: [RolesGuard, Reflector], + }).compile(); + + guard = module.get<RolesGuard>(RolesGuard); + reflector = module.get<Reflector>(Reflector); + }); + + it('should allow when no roles required', () => { + const context = createMockExecutionContext({ user: { roles: [] } }); + jest.spyOn(reflector, 'getAllAndOverride').mockReturnValue(undefined); + + expect(guard.canActivate(context)).toBe(true); + }); + + it('should allow admin for admin-only route', () => { + const context = createMockExecutionContext({ user: { roles: ['admin'] } }); + jest.spyOn(reflector, 'getAllAndOverride').mockReturnValue(['admin']); + + expect(guard.canActivate(context)).toBe(true); + }); +}); + +function createMockExecutionContext(request: Partial<Request>): ExecutionContext { + return { + switchToHttp: () => ({ + getRequest: () => request, + }), + getHandler: () => jest.fn(), + getClass: () => jest.fn(), + } as ExecutionContext; +} +``` + +Reference: [NestJS Testing](https://docs.nestjs.com/fundamentals/testing) diff --git a/packages/mosaic/framework/skills/nestjs-best-practices/scripts/build-agents.ts b/packages/mosaic/framework/skills/nestjs-best-practices/scripts/build-agents.ts new file mode 100644 index 00000000..2c64b113 --- /dev/null +++ b/packages/mosaic/framework/skills/nestjs-best-practices/scripts/build-agents.ts @@ -0,0 +1,298 @@ +#!/usr/bin/env npx ts-node + +/** + * Build script for generating AGENTS.md from individual rule files + * + * Usage: npx ts-node scripts/build-agents.ts + * + * This script: + * 1. Reads all rule files from the rules/ directory + * 2. Parses YAML frontmatter for metadata + * 3. Groups rules by category based on filename prefix + * 4. Generates a consolidated AGENTS.md file + */ + +import * as fs from 'fs'; +import * as path from 'path'; +import { fileURLToPath } from 'url'; +import { dirname } from 'path'; + +const __filename = fileURLToPath(import.meta.url); +const __dirname = dirname(__filename); + +// Category definitions with ordering and metadata +const CATEGORIES = [ + { prefix: 'arch-', name: 'Architecture', impact: 'CRITICAL', section: 1 }, + { prefix: 'di-', name: 'Dependency Injection', impact: 'CRITICAL', section: 2 }, + { prefix: 'error-', name: 'Error Handling', impact: 'HIGH', section: 3 }, + { prefix: 'security-', name: 'Security', impact: 'HIGH', section: 4 }, + { prefix: 'perf-', name: 'Performance', impact: 'HIGH', section: 5 }, + { prefix: 'test-', name: 'Testing', impact: 'MEDIUM-HIGH', section: 6 }, + { prefix: 'db-', name: 'Database & ORM', impact: 'MEDIUM-HIGH', section: 7 }, + { prefix: 'api-', name: 'API Design', impact: 'MEDIUM', section: 8 }, + { prefix: 'micro-', name: 'Microservices', impact: 'MEDIUM', section: 9 }, + { prefix: 'devops-', name: 'DevOps & Deployment', impact: 'LOW-MEDIUM', section: 10 }, +]; + +interface RuleFrontmatter { + title: string; + impact: string; + impactDescription: string; + tags: string[]; +} + +interface Rule { + filename: string; + frontmatter: RuleFrontmatter; + content: string; + category: string; + categorySection: number; +} + +function parseFrontmatter(content: string): { frontmatter: RuleFrontmatter | null; body: string } { + const frontmatterRegex = /^---\n([\s\S]*?)\n---\n([\s\S]*)$/; + const match = content.match(frontmatterRegex); + + if (!match) { + return { frontmatter: null, body: content }; + } + + const frontmatterStr = match[1]; + const body = match[2]; + + // Simple YAML parsing for our expected format + const frontmatter: Partial<RuleFrontmatter> = {}; + const lines = frontmatterStr.split('\n'); + let currentKey = ''; + let inArray = false; + const arrayItems: string[] = []; + + for (const line of lines) { + if (line.match(/^[a-zA-Z]+:/)) { + // Save previous array if we were collecting one + if (inArray && currentKey === 'tags') { + frontmatter.tags = arrayItems; + } + inArray = false; + arrayItems.length = 0; + + const [key, ...valueParts] = line.split(':'); + const value = valueParts.join(':').trim(); + currentKey = key.trim(); + + if (value === '') { + // Might be start of array + inArray = true; + } else { + (frontmatter as any)[currentKey] = value; + } + } else if (inArray && line.trim().startsWith('-')) { + arrayItems.push(line.trim().replace(/^-\s*/, '')); + } + } + + // Save final array if needed + if (inArray && currentKey === 'tags') { + frontmatter.tags = arrayItems; + } + + return { + frontmatter: frontmatter as RuleFrontmatter, + body: body.trim(), + }; +} + +function getCategoryForFile(filename: string): { name: string; section: number } | null { + for (const cat of CATEGORIES) { + if (filename.startsWith(cat.prefix)) { + return { name: cat.name, section: cat.section }; + } + } + return null; +} + +function readMetadata(): any { + const metadataPath = path.join(__dirname, '..', 'metadata.json'); + return JSON.parse(fs.readFileSync(metadataPath, 'utf-8')); +} + +function readRules(): Rule[] { + const rulesDir = path.join(__dirname, '..', 'rules'); + const files = fs.readdirSync(rulesDir).filter((f) => f.endsWith('.md') && !f.startsWith('_')); + + const rules: Rule[] = []; + + for (const file of files) { + const filePath = path.join(rulesDir, file); + const content = fs.readFileSync(filePath, 'utf-8'); + const { frontmatter, body } = parseFrontmatter(content); + + if (!frontmatter) { + console.warn(`Warning: No frontmatter found in ${file}`); + continue; + } + + const category = getCategoryForFile(file); + if (!category) { + console.warn(`Warning: Unknown category for ${file}`); + continue; + } + + rules.push({ + filename: file, + frontmatter, + content: body, + category: category.name, + categorySection: category.section, + }); + } + + return rules; +} + +function generateTableOfContents(rulesByCategory: Map<string, Rule[]>): string { + let toc = '## Table of Contents\n\n'; + + for (const cat of CATEGORIES) { + const rules = rulesByCategory.get(cat.name); + if (!rules || rules.length === 0) continue; + + // Section anchor format: #1-architecture + const sectionAnchor = `${cat.section}-${cat.name.toLowerCase().replace(/[^a-z0-9]+/g, '-')}`; + toc += `${cat.section}. [${cat.name}](#${sectionAnchor}) — **${cat.impact}**\n`; + + for (let i = 0; i < rules.length; i++) { + const rule = rules[i]; + // Rule anchor format: #11-rule-title + const ruleNum = `${cat.section}${i + 1}`; + const anchor = `${ruleNum}-${rule.frontmatter.title.toLowerCase().replace(/[^a-z0-9]+/g, '-')}`; + toc += ` - ${cat.section}.${i + 1} [${rule.frontmatter.title}](#${anchor})\n`; + } + } + + return toc; +} + +function generateAgentsMd(rules: Rule[], metadata: any): string { + // Group rules by category + const rulesByCategory = new Map<string, Rule[]>(); + + for (const rule of rules) { + if (!rulesByCategory.has(rule.category)) { + rulesByCategory.set(rule.category, []); + } + rulesByCategory.get(rule.category)!.push(rule); + } + + // Sort rules within each category alphabetically + for (const [category, categoryRules] of rulesByCategory) { + categoryRules.sort((a, b) => a.filename.localeCompare(b.filename)); + } + + // Build document + let doc = `# NestJS Best Practices + +**Version ${metadata.version}** +${metadata.organization} +${metadata.date} + +> **Note:** +> This document is mainly for agents and LLMs to follow when maintaining, +> generating, or refactoring NestJS codebases. Humans may also find it +> useful, but guidance here is optimized for automation and consistency +> by AI-assisted workflows. + +--- + +## Abstract + +${metadata.abstract} + +--- + +`; + + // Add table of contents + doc += generateTableOfContents(rulesByCategory); + doc += '\n---\n\n'; + + // Add rules by category + for (const cat of CATEGORIES) { + const categoryRules = rulesByCategory.get(cat.name); + if (!categoryRules || categoryRules.length === 0) continue; + + doc += `## ${cat.section}. ${cat.name}\n\n`; + doc += `**Section Impact: ${cat.impact}**\n\n`; + + for (let i = 0; i < categoryRules.length; i++) { + const rule = categoryRules[i]; + const ruleNumber = `${cat.section}.${i + 1}`; + + // Add rule header with number (anchor will be auto-generated as #11-title) + doc += `### ${ruleNumber} ${rule.frontmatter.title}\n\n`; + doc += `**Impact: ${rule.frontmatter.impact}** — ${rule.frontmatter.impactDescription}\n\n`; + + // Add rule content (skip the first header since we already added it) + let ruleContent = rule.content; + // Remove the first h1 or h2 header if it matches the title + ruleContent = ruleContent.replace(/^#{1,2}\s+.*\n+/, ''); + // Remove the impact line if present (we already added it) + ruleContent = ruleContent.replace(/^\*\*Impact:.*\*\*.*\n+/, ''); + + doc += ruleContent; + doc += '\n\n---\n\n'; + } + } + + // Add references footer + doc += `## References + +`; + for (const ref of metadata.references) { + doc += `- ${ref}\n`; + } + + doc += ` +--- + +*Generated by build-agents.ts on ${new Date().toISOString().split('T')[0]}* +`; + + return doc; +} + +function main() { + console.log('Building AGENTS.md...\n'); + + const metadata = readMetadata(); + console.log(`Version: ${metadata.version}`); + console.log(`Organization: ${metadata.organization}\n`); + + const rules = readRules(); + console.log(`Found ${rules.length} rules\n`); + + // Count by category + const counts = new Map<string, number>(); + for (const rule of rules) { + counts.set(rule.category, (counts.get(rule.category) || 0) + 1); + } + + console.log('Rules by category:'); + for (const cat of CATEGORIES) { + const count = counts.get(cat.name) || 0; + if (count > 0) { + console.log(` ${cat.name}: ${count}`); + } + } + console.log(''); + + const agentsMd = generateAgentsMd(rules, metadata); + + const outputPath = path.join(__dirname, '..', 'AGENTS.md'); + fs.writeFileSync(outputPath, agentsMd); + + console.log(`Generated AGENTS.md (${agentsMd.length} bytes)`); + console.log(`Output: ${outputPath}`); +} + +main(); diff --git a/packages/mosaic/framework/skills/nestjs-best-practices/scripts/build.sh b/packages/mosaic/framework/skills/nestjs-best-practices/scripts/build.sh new file mode 100755 index 00000000..c9f47bc4 --- /dev/null +++ b/packages/mosaic/framework/skills/nestjs-best-practices/scripts/build.sh @@ -0,0 +1,16 @@ +#!/bin/bash + +# Build script for generating AGENTS.md +# Usage: ./build.sh + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +cd "$SCRIPT_DIR" + +# Check if ts-node is available +if command -v npx &> /dev/null; then + echo "Running build with ts-node..." + npx ts-node build-agents.ts +else + echo "Error: npx not found. Please install Node.js." + exit 1 +fi diff --git a/packages/mosaic/framework/skills/nestjs-best-practices/scripts/package.json b/packages/mosaic/framework/skills/nestjs-best-practices/scripts/package.json new file mode 100644 index 00000000..927b1efb --- /dev/null +++ b/packages/mosaic/framework/skills/nestjs-best-practices/scripts/package.json @@ -0,0 +1,15 @@ +{ + "name": "nestjs-best-practices-scripts", + "version": "1.0.0", + "type": "module", + "description": "Build scripts for NestJS Best Practices skillset", + "scripts": { + "build": "npx ts-node build-agents.ts", + "build:watch": "npx nodemon --watch ../rules --ext md --exec 'npx ts-node build-agents.ts'" + }, + "devDependencies": { + "typescript": "^5.0.0", + "ts-node": "^10.9.0", + "@types/node": "^20.0.0" + } +} diff --git a/packages/mosaic/framework/skills/next-best-practices/SKILL.md b/packages/mosaic/framework/skills/next-best-practices/SKILL.md new file mode 100644 index 00000000..3d5e6869 --- /dev/null +++ b/packages/mosaic/framework/skills/next-best-practices/SKILL.md @@ -0,0 +1,171 @@ +--- +name: next-best-practices +description: Next.js best practices - file conventions, RSC boundaries, data patterns, async APIs, metadata, error handling, route handlers, image/font optimization, bundling +user-invocable: false +--- + +# Next.js Best Practices + +Apply these rules when writing or reviewing Next.js code. + +## File Conventions + +See [file-conventions.md](./file-conventions.md) for: + +- Project structure and special files +- Route segments (dynamic, catch-all, groups) +- Parallel and intercepting routes +- Middleware rename in v16 (middleware → proxy) + +## RSC Boundaries + +Detect invalid React Server Component patterns. + +See [rsc-boundaries.md](./rsc-boundaries.md) for: + +- Async client component detection (invalid) +- Non-serializable props detection +- Server Action exceptions + +## Async Patterns + +Next.js 15+ async API changes. + +See [async-patterns.md](./async-patterns.md) for: + +- Async `params` and `searchParams` +- Async `cookies()` and `headers()` +- Migration codemod + +## Runtime Selection + +See [runtime-selection.md](./runtime-selection.md) for: + +- Default to Node.js runtime +- When Edge runtime is appropriate + +## Directives + +See [directives.md](./directives.md) for: + +- `'use client'`, `'use server'` (React) +- `'use cache'` (Next.js) + +## Functions + +See [functions.md](./functions.md) for: + +- Navigation hooks: `useRouter`, `usePathname`, `useSearchParams`, `useParams` +- Server functions: `cookies`, `headers`, `draftMode`, `after` +- Generate functions: `generateStaticParams`, `generateMetadata` + +## Error Handling + +See [error-handling.md](./error-handling.md) for: + +- `error.tsx`, `global-error.tsx`, `not-found.tsx` +- `redirect`, `permanentRedirect`, `notFound` +- `forbidden`, `unauthorized` (auth errors) +- `unstable_rethrow` for catch blocks + +## Data Patterns + +See [data-patterns.md](./data-patterns.md) for: + +- Server Components vs Server Actions vs Route Handlers +- Avoiding data waterfalls (`Promise.all`, Suspense, preload) +- Client component data fetching + +## Route Handlers + +See [route-handlers.md](./route-handlers.md) for: + +- `route.ts` basics +- GET handler conflicts with `page.tsx` +- Environment behavior (no React DOM) +- When to use vs Server Actions + +## Metadata & OG Images + +See [metadata.md](./metadata.md) for: + +- Static and dynamic metadata +- `generateMetadata` function +- OG image generation with `next/og` +- File-based metadata conventions + +## Image Optimization + +See [image.md](./image.md) for: + +- Always use `next/image` over `<img>` +- Remote images configuration +- Responsive `sizes` attribute +- Blur placeholders +- Priority loading for LCP + +## Font Optimization + +See [font.md](./font.md) for: + +- `next/font` setup +- Google Fonts, local fonts +- Tailwind CSS integration +- Preloading subsets + +## Bundling + +See [bundling.md](./bundling.md) for: + +- Server-incompatible packages +- CSS imports (not link tags) +- Polyfills (already included) +- ESM/CommonJS issues +- Bundle analysis + +## Scripts + +See [scripts.md](./scripts.md) for: + +- `next/script` vs native script tags +- Inline scripts need `id` +- Loading strategies +- Google Analytics with `@next/third-parties` + +## Hydration Errors + +See [hydration-error.md](./hydration-error.md) for: + +- Common causes (browser APIs, dates, invalid HTML) +- Debugging with error overlay +- Fixes for each cause + +## Suspense Boundaries + +See [suspense-boundaries.md](./suspense-boundaries.md) for: + +- CSR bailout with `useSearchParams` and `usePathname` +- Which hooks require Suspense boundaries + +## Parallel & Intercepting Routes + +See [parallel-routes.md](./parallel-routes.md) for: + +- Modal patterns with `@slot` and `(.)` interceptors +- `default.tsx` for fallbacks +- Closing modals correctly with `router.back()` + +## Self-Hosting + +See [self-hosting.md](./self-hosting.md) for: + +- `output: 'standalone'` for Docker +- Cache handlers for multi-instance ISR +- What works vs needs extra setup + +## Debug Tricks + +See [debug-tricks.md](./debug-tricks.md) for: + +- MCP endpoint for AI-assisted debugging +- Rebuild specific routes with `--debug-build-paths` diff --git a/packages/mosaic/framework/skills/next-best-practices/async-patterns.md b/packages/mosaic/framework/skills/next-best-practices/async-patterns.md new file mode 100644 index 00000000..0692c4ae --- /dev/null +++ b/packages/mosaic/framework/skills/next-best-practices/async-patterns.md @@ -0,0 +1,84 @@ +# Async Patterns + +In Next.js 15+, `params`, `searchParams`, `cookies()`, and `headers()` are asynchronous. + +## Async Params and SearchParams + +Always type them as `Promise<...>` and await them. + +### Pages and Layouts + +```tsx +type Props = { params: Promise<{ slug: string }> }; + +export default async function Page({ params }: Props) { + const { slug } = await params; +} +``` + +### Route Handlers + +```tsx +export async function GET(request: Request, { params }: { params: Promise<{ id: string }> }) { + const { id } = await params; +} +``` + +### SearchParams + +```tsx +type Props = { + params: Promise<{ slug: string }>; + searchParams: Promise<{ query?: string }>; +}; + +export default async function Page({ params, searchParams }: Props) { + const { slug } = await params; + const { query } = await searchParams; +} +``` + +### Synchronous Components + +Use `React.use()` for non-async components: + +```tsx +import { use } from 'react'; + +type Props = { params: Promise<{ slug: string }> }; + +export default function Page({ params }: Props) { + const { slug } = use(params); +} +``` + +### generateMetadata + +```tsx +type Props = { params: Promise<{ slug: string }> }; + +export async function generateMetadata({ params }: Props): Promise<Metadata> { + const { slug } = await params; + return { title: slug }; +} +``` + +## Async Cookies and Headers + +```tsx +import { cookies, headers } from 'next/headers'; + +export default async function Page() { + const cookieStore = await cookies(); + const headersList = await headers(); + + const theme = cookieStore.get('theme'); + const userAgent = headersList.get('user-agent'); +} +``` + +## Migration Codemod + +```bash +npx @next/codemod@latest next-async-request-api . +``` diff --git a/packages/mosaic/framework/skills/next-best-practices/bundling.md b/packages/mosaic/framework/skills/next-best-practices/bundling.md new file mode 100644 index 00000000..19e34340 --- /dev/null +++ b/packages/mosaic/framework/skills/next-best-practices/bundling.md @@ -0,0 +1,182 @@ +# Bundling + +Fix common bundling issues with third-party packages. + +## Server-Incompatible Packages + +Some packages use browser APIs (`window`, `document`, `localStorage`) and fail in Server Components. + +### Error Signs + +``` +ReferenceError: window is not defined +ReferenceError: document is not defined +ReferenceError: localStorage is not defined +Module not found: Can't resolve 'fs' +``` + +### Solution 1: Mark as Client-Only + +If the package is only needed on client: + +```tsx +// Bad: Fails - package uses window +import SomeChart from 'some-chart-library'; + +export default function Page() { + return <SomeChart />; +} + +// Good: Use dynamic import with ssr: false +import dynamic from 'next/dynamic'; + +const SomeChart = dynamic(() => import('some-chart-library'), { + ssr: false, +}); + +export default function Page() { + return <SomeChart />; +} +``` + +### Solution 2: Externalize from Server Bundle + +For packages that should run on server but have bundling issues: + +```js +// next.config.js +module.exports = { + serverExternalPackages: ['problematic-package'], +}; +``` + +Use this for: + +- Packages with native bindings (sharp, bcrypt) +- Packages that don't bundle well (some ORMs) +- Packages with circular dependencies + +### Solution 3: Client Component Wrapper + +Wrap the entire usage in a client component: + +```tsx +// components/ChartWrapper.tsx +'use client'; + +import { Chart } from 'chart-library'; + +export function ChartWrapper(props) { + return <Chart {...props} />; +} + +// app/page.tsx (server component) +import { ChartWrapper } from '@/components/ChartWrapper'; + +export default function Page() { + return <ChartWrapper data={data} />; +} +``` + +## CSS Imports + +Import CSS files instead of using `<link>` tags. Next.js handles bundling and optimization. + +```tsx +// Bad: Manual link tag +<link rel="stylesheet" href="/styles.css" />; + +// Good: Import CSS +import './styles.css'; + +// Good: CSS Modules +import styles from './Button.module.css'; +``` + +## Polyfills + +Next.js includes common polyfills automatically. Don't load redundant ones from polyfill.io or similar CDNs. + +Already included: `Array.from`, `Object.assign`, `Promise`, `fetch`, `Map`, `Set`, `Symbol`, `URLSearchParams`, and 50+ others. + +```tsx +// Bad: Redundant polyfills +<script src="https://polyfill.io/v3/polyfill.min.js?features=fetch,Promise,Array.from" /> + +// Good: Next.js includes these automatically +``` + +## ESM/CommonJS Issues + +### Error Signs + +``` +SyntaxError: Cannot use import statement outside a module +Error: require() of ES Module +Module not found: ESM packages need to be imported +``` + +### Solution: Transpile Package + +```js +// next.config.js +module.exports = { + transpilePackages: ['some-esm-package', 'another-package'], +}; +``` + +## Common Problematic Packages + +| Package | Issue | Solution | +| --------------- | --------------- | --------------------------------------------------------------- | +| `sharp` | Native bindings | `serverExternalPackages: ['sharp']` | +| `bcrypt` | Native bindings | `serverExternalPackages: ['bcrypt']` or use `bcryptjs` | +| `canvas` | Native bindings | `serverExternalPackages: ['canvas']` | +| `recharts` | Uses window | `dynamic(() => import('recharts'), { ssr: false })` | +| `react-quill` | Uses document | `dynamic(() => import('react-quill'), { ssr: false })` | +| `mapbox-gl` | Uses window | `dynamic(() => import('mapbox-gl'), { ssr: false })` | +| `monaco-editor` | Uses window | `dynamic(() => import('@monaco-editor/react'), { ssr: false })` | +| `lottie-web` | Uses document | `dynamic(() => import('lottie-react'), { ssr: false })` | + +## Bundle Analysis + +Analyze bundle size with the built-in analyzer (Next.js 16.1+): + +```bash +next experimental-analyze +``` + +This opens an interactive UI to: + +- Filter by route, environment (client/server), and type +- Inspect module sizes and import chains +- View treemap visualization + +Save output for comparison: + +```bash +next experimental-analyze --output +# Output saved to .next/diagnostics/analyze +``` + +Reference: https://nextjs.org/docs/app/guides/package-bundling + +## Migrating from Webpack to Turbopack + +Turbopack is the default bundler in Next.js 15+. If you have custom webpack config, migrate to Turbopack-compatible alternatives: + +```js +// next.config.js +module.exports = { + // Good: Works with Turbopack + serverExternalPackages: ['package'], + transpilePackages: ['package'], + + // Bad: Webpack-only - migrate away from this + webpack: (config) => { + // custom webpack config + }, +}; +``` + +Reference: https://nextjs.org/docs/app/building-your-application/upgrading/from-webpack-to-turbopack diff --git a/packages/mosaic/framework/skills/next-best-practices/data-patterns.md b/packages/mosaic/framework/skills/next-best-practices/data-patterns.md new file mode 100644 index 00000000..94ce5d69 --- /dev/null +++ b/packages/mosaic/framework/skills/next-best-practices/data-patterns.md @@ -0,0 +1,300 @@ +# Data Patterns + +Choose the right data fetching pattern for each use case. + +## Decision Tree + +``` +Need to fetch data? +├── From a Server Component? +│ └── Use: Fetch directly (no API needed) +│ +├── From a Client Component? +│ ├── Is it a mutation (POST/PUT/DELETE)? +│ │ └── Use: Server Action +│ └── Is it a read (GET)? +│ └── Use: Route Handler OR pass from Server Component +│ +├── Need external API access (webhooks, third parties)? +│ └── Use: Route Handler +│ +└── Need REST API for mobile app / external clients? + └── Use: Route Handler +``` + +## Pattern 1: Server Components (Preferred for Reads) + +Fetch data directly in Server Components - no API layer needed. + +```tsx +// app/users/page.tsx +async function UsersPage() { + // Direct database access - no API round-trip + const users = await db.user.findMany(); + + // Or fetch from external API + const posts = await fetch('https://api.example.com/posts').then((r) => r.json()); + + return ( + <ul> + {users.map((user) => ( + <li key={user.id}>{user.name}</li> + ))} + </ul> + ); +} +``` + +**Benefits**: + +- No API to maintain +- No client-server waterfall +- Secrets stay on server +- Direct database access + +## Pattern 2: Server Actions (Preferred for Mutations) + +Server Actions are the recommended way to handle mutations. + +```tsx +// app/actions.ts +'use server'; + +import { revalidatePath } from 'next/cache'; + +export async function createPost(formData: FormData) { + const title = formData.get('title') as string; + + await db.post.create({ data: { title } }); + + revalidatePath('/posts'); +} + +export async function deletePost(id: string) { + await db.post.delete({ where: { id } }); + + revalidateTag('posts'); +} +``` + +```tsx +// app/posts/new/page.tsx +import { createPost } from '@/app/actions'; + +export default function NewPost() { + return ( + <form action={createPost}> + <input name="title" required /> + <button type="submit">Create</button> + </form> + ); +} +``` + +**Benefits**: + +- End-to-end type safety +- Progressive enhancement (works without JS) +- Automatic request handling +- Integrated with React transitions + +**Constraints**: + +- POST only (no GET caching semantics) +- Internal use only (no external access) +- Cannot return non-serializable data + +## Pattern 3: Route Handlers (APIs) + +Use Route Handlers when you need a REST API. + +```tsx +// app/api/posts/route.ts +import { NextRequest, NextResponse } from 'next/server'; + +// GET is cacheable +export async function GET(request: NextRequest) { + const posts = await db.post.findMany(); + return NextResponse.json(posts); +} + +// POST for mutations +export async function POST(request: NextRequest) { + const body = await request.json(); + const post = await db.post.create({ data: body }); + return NextResponse.json(post, { status: 201 }); +} +``` + +**When to use**: + +- External API access (mobile apps, third parties) +- Webhooks from external services +- GET endpoints that need HTTP caching +- OpenAPI/Swagger documentation needed + +**When NOT to use**: + +- Internal data fetching (use Server Components) +- Mutations from your UI (use Server Actions) + +## Avoiding Data Waterfalls + +### Problem: Sequential Fetches + +```tsx +// Bad: Sequential waterfalls +async function Dashboard() { + const user = await getUser(); // Wait... + const posts = await getPosts(); // Then wait... + const comments = await getComments(); // Then wait... + + return <div>...</div>; +} +``` + +### Solution 1: Parallel Fetching with Promise.all + +```tsx +// Good: Parallel fetching +async function Dashboard() { + const [user, posts, comments] = await Promise.all([getUser(), getPosts(), getComments()]); + + return <div>...</div>; +} +``` + +### Solution 2: Streaming with Suspense + +```tsx +// Good: Show content progressively +import { Suspense } from 'react'; + +async function Dashboard() { + return ( + <div> + <Suspense fallback={<UserSkeleton />}> + <UserSection /> + </Suspense> + <Suspense fallback={<PostsSkeleton />}> + <PostsSection /> + </Suspense> + </div> + ); +} + +async function UserSection() { + const user = await getUser(); // Fetches independently + return <div>{user.name}</div>; +} + +async function PostsSection() { + const posts = await getPosts(); // Fetches independently + return <PostList posts={posts} />; +} +``` + +### Solution 3: Preload Pattern + +```tsx +// lib/data.ts +import { cache } from 'react'; + +export const getUser = cache(async (id: string) => { + return db.user.findUnique({ where: { id } }); +}); + +export const preloadUser = (id: string) => { + void getUser(id); // Fire and forget +}; +``` + +```tsx +// app/user/[id]/page.tsx +import { getUser, preloadUser } from '@/lib/data'; + +export default async function UserPage({ params }) { + const { id } = await params; + + // Start fetching early + preloadUser(id); + + // Do other work... + + // Data likely ready by now + const user = await getUser(id); + return <div>{user.name}</div>; +} +``` + +## Client Component Data Fetching + +When Client Components need data: + +### Option 1: Pass from Server Component (Preferred) + +```tsx +// Server Component +async function Page() { + const data = await fetchData(); + return <ClientComponent initialData={data} />; +} + +// Client Component +('use client'); +function ClientComponent({ initialData }) { + const [data, setData] = useState(initialData); + // ... +} +``` + +### Option 2: Fetch on Mount (When Necessary) + +```tsx +'use client'; +import { useEffect, useState } from 'react'; + +function ClientComponent() { + const [data, setData] = useState(null); + + useEffect(() => { + fetch('/api/data') + .then((r) => r.json()) + .then(setData); + }, []); + + if (!data) return <Loading />; + return <div>{data.value}</div>; +} +``` + +### Option 3: Server Action for Reads (Works But Not Ideal) + +Server Actions can be called from Client Components for reads, but this is not their intended purpose: + +```tsx +'use client'; +import { getData } from './actions'; +import { useEffect, useState } from 'react'; + +function ClientComponent() { + const [data, setData] = useState(null); + + useEffect(() => { + getData().then(setData); + }, []); + + return <div>{data?.value}</div>; +} +``` + +**Note**: Server Actions always use POST, so no HTTP caching. Prefer Route Handlers for cacheable reads. + +## Quick Reference + +| Pattern | Use Case | HTTP Method | Caching | +| ---------------------- | --------------------------- | ----------- | -------------------- | +| Server Component fetch | Internal reads | Any | Full Next.js caching | +| Server Action | Mutations, form submissions | POST only | No | +| Route Handler | External APIs, webhooks | Any | GET can be cached | +| Client fetch to API | Client-side reads | Any | HTTP cache headers | diff --git a/packages/mosaic/framework/skills/next-best-practices/debug-tricks.md b/packages/mosaic/framework/skills/next-best-practices/debug-tricks.md new file mode 100644 index 00000000..33a74a27 --- /dev/null +++ b/packages/mosaic/framework/skills/next-best-practices/debug-tricks.md @@ -0,0 +1,122 @@ +# Debug Tricks + +Tricks to speed up debugging Next.js applications. + +## MCP Endpoint (Dev Server) + +Next.js exposes a `/_next/mcp` endpoint in development for AI-assisted debugging via MCP (Model Context Protocol). + +- **Next.js 16+**: Enabled by default, use `next-devtools-mcp` +- **Next.js < 16**: Requires `experimental.mcpServer: true` in next.config.js + +Reference: https://nextjs.org/docs/app/guides/mcp + +**Important**: Find the actual port of the running Next.js dev server (check terminal output or `package.json` scripts). Don't assume port 3000. + +### Request Format + +The endpoint uses JSON-RPC 2.0 over HTTP POST: + +```bash +curl -X POST http://localhost:<port>/_next/mcp \ + -H "Content-Type: application/json" \ + -H "Accept: application/json, text/event-stream" \ + -d '{ + "jsonrpc": "2.0", + "id": "1", + "method": "tools/call", + "params": { + "name": "<tool-name>", + "arguments": {} + } + }' +``` + +### Available Tools + +#### `get_errors` + +Get current errors from dev server (build errors, runtime errors with source-mapped stacks): + +```json +{ "name": "get_errors", "arguments": {} } +``` + +#### `get_routes` + +Discover all routes by scanning filesystem: + +```json +{ "name": "get_routes", "arguments": {} } +// Optional: { "name": "get_routes", "arguments": { "routerType": "app" } } +``` + +Returns: `{ "appRouter": ["/", "/api/users/[id]", ...], "pagesRouter": [...] }` + +#### `get_project_metadata` + +Get project path and dev server URL: + +```json +{ "name": "get_project_metadata", "arguments": {} } +``` + +Returns: `{ "projectPath": "/path/to/project", "devServerUrl": "http://localhost:3000" }` + +#### `get_page_metadata` + +Get runtime metadata about current page render (requires active browser session): + +```json +{ "name": "get_page_metadata", "arguments": {} } +``` + +Returns segment trie data showing layouts, boundaries, and page components. + +#### `get_logs` + +Get path to Next.js development log file: + +```json +{ "name": "get_logs", "arguments": {} } +``` + +Returns path to `<distDir>/logs/next-development.log` + +#### `get_server_action_by_id` + +Locate a Server Action by ID: + +```json +{ "name": "get_server_action_by_id", "arguments": { "actionId": "<action-id>" } } +``` + +### Example: Get Errors + +```bash +curl -X POST http://localhost:<port>/_next/mcp \ + -H "Content-Type: application/json" \ + -H "Accept: application/json, text/event-stream" \ + -d '{"jsonrpc":"2.0","id":"1","method":"tools/call","params":{"name":"get_errors","arguments":{}}}' +``` + +## Rebuild Specific Routes (Next.js 16+) + +Use `--debug-build-paths` to rebuild only specific routes instead of the entire app: + +```bash +# Rebuild a specific route +next build --debug-build-paths "/dashboard" + +# Rebuild routes matching a glob +next build --debug-build-paths "/api/*" + +# Dynamic routes +next build --debug-build-paths "/blog/[slug]" +``` + +Use this to: + +- Quickly verify a build fix without full rebuild +- Debug static generation issues for specific pages +- Iterate faster on build errors diff --git a/packages/mosaic/framework/skills/next-best-practices/directives.md b/packages/mosaic/framework/skills/next-best-practices/directives.md new file mode 100644 index 00000000..23b5871e --- /dev/null +++ b/packages/mosaic/framework/skills/next-best-practices/directives.md @@ -0,0 +1,74 @@ +# Directives + +## React Directives + +These are React directives, not Next.js specific. + +### `'use client'` + +Marks a component as a Client Component. Required for: + +- React hooks (`useState`, `useEffect`, etc.) +- Event handlers (`onClick`, `onChange`) +- Browser APIs (`window`, `localStorage`) + +```tsx +'use client'; + +import { useState } from 'react'; + +export function Counter() { + const [count, setCount] = useState(0); + return <button onClick={() => setCount(count + 1)}>{count}</button>; +} +``` + +Reference: https://react.dev/reference/rsc/use-client + +### `'use server'` + +Marks a function as a Server Action. Can be passed to Client Components. + +```tsx +'use server'; + +export async function submitForm(formData: FormData) { + // Runs on server +} +``` + +Or inline within a Server Component: + +```tsx +export default function Page() { + async function submit() { + 'use server'; + // Runs on server + } + return <form action={submit}>...</form>; +} +``` + +Reference: https://react.dev/reference/rsc/use-server + +--- + +## Next.js Directive + +### `'use cache'` + +Marks a function or component for caching. Part of Next.js Cache Components. + +```tsx +'use cache'; + +export async function getCachedData() { + return await fetchData(); +} +``` + +Requires `cacheComponents: true` in `next.config.ts`. + +For detailed usage including cache profiles, `cacheLife()`, `cacheTag()`, and `updateTag()`, see the `next-cache-components` skill. + +Reference: https://nextjs.org/docs/app/api-reference/directives/use-cache diff --git a/packages/mosaic/framework/skills/next-best-practices/error-handling.md b/packages/mosaic/framework/skills/next-best-practices/error-handling.md new file mode 100644 index 00000000..b273a1ef --- /dev/null +++ b/packages/mosaic/framework/skills/next-best-practices/error-handling.md @@ -0,0 +1,228 @@ +# Error Handling + +Handle errors gracefully in Next.js applications. + +Reference: https://nextjs.org/docs/app/getting-started/error-handling + +## Error Boundaries + +### `error.tsx` + +Catches errors in a route segment and its children: + +```tsx +'use client'; + +export default function Error({ + error, + reset, +}: { + error: Error & { digest?: string }; + reset: () => void; +}) { + return ( + <div> + <h2>Something went wrong!</h2> + <button onClick={() => reset()}>Try again</button> + </div> + ); +} +``` + +**Important:** `error.tsx` must be a Client Component. + +### `global-error.tsx` + +Catches errors in root layout: + +```tsx +'use client'; + +export default function GlobalError({ + error, + reset, +}: { + error: Error & { digest?: string }; + reset: () => void; +}) { + return ( + <html> + <body> + <h2>Something went wrong!</h2> + <button onClick={() => reset()}>Try again</button> + </body> + </html> + ); +} +``` + +**Important:** Must include `<html>` and `<body>` tags. + +## Server Actions: Navigation API Gotcha + +**Do NOT wrap navigation APIs in try-catch.** They throw special errors that Next.js handles internally. + +Reference: https://nextjs.org/docs/app/api-reference/functions/redirect#behavior + +```tsx +'use server' + +import { redirect } from 'next/navigation' +import { notFound } from 'next/navigation' + +// Bad: try-catch catches the navigation "error" +async function createPost(formData: FormData) { + try { + const post = await db.post.create({ ... }) + redirect(`/posts/${post.id}`) // This throws! + } catch (error) { + // redirect() throw is caught here - navigation fails! + return { error: 'Failed to create post' } + } +} + +// Good: Call navigation APIs outside try-catch +async function createPost(formData: FormData) { + let post + try { + post = await db.post.create({ ... }) + } catch (error) { + return { error: 'Failed to create post' } + } + redirect(`/posts/${post.id}`) // Outside try-catch +} + +// Good: Re-throw navigation errors +async function createPost(formData: FormData) { + try { + const post = await db.post.create({ ... }) + redirect(`/posts/${post.id}`) + } catch (error) { + if (error instanceof Error && error.message === 'NEXT_REDIRECT') { + throw error // Re-throw navigation errors + } + return { error: 'Failed to create post' } + } +} +``` + +Same applies to: + +- `redirect()` - 307 temporary redirect +- `permanentRedirect()` - 308 permanent redirect +- `notFound()` - 404 not found +- `forbidden()` - 403 forbidden +- `unauthorized()` - 401 unauthorized + +Use `unstable_rethrow()` to re-throw these errors in catch blocks: + +```tsx +import { unstable_rethrow } from 'next/navigation'; + +async function action() { + try { + // ... + redirect('/success'); + } catch (error) { + unstable_rethrow(error); // Re-throws Next.js internal errors + return { error: 'Something went wrong' }; + } +} +``` + +## Redirects + +```tsx +import { redirect, permanentRedirect } from 'next/navigation'; + +// 307 Temporary - use for most cases +redirect('/new-path'); + +// 308 Permanent - use for URL migrations (cached by browsers) +permanentRedirect('/new-url'); +``` + +## Auth Errors + +Trigger auth-related error pages: + +```tsx +import { forbidden, unauthorized } from 'next/navigation'; + +async function Page() { + const session = await getSession(); + + if (!session) { + unauthorized(); // Renders unauthorized.tsx (401) + } + + if (!session.hasAccess) { + forbidden(); // Renders forbidden.tsx (403) + } + + return <Dashboard />; +} +``` + +Create corresponding error pages: + +```tsx +// app/forbidden.tsx +export default function Forbidden() { + return <div>You don't have access to this resource</div>; +} + +// app/unauthorized.tsx +export default function Unauthorized() { + return <div>Please log in to continue</div>; +} +``` + +## Not Found + +### `not-found.tsx` + +Custom 404 page for a route segment: + +```tsx +export default function NotFound() { + return ( + <div> + <h2>Not Found</h2> + <p>Could not find the requested resource</p> + </div> + ); +} +``` + +### Triggering Not Found + +```tsx +import { notFound } from 'next/navigation'; + +export default async function Page({ params }: { params: Promise<{ id: string }> }) { + const { id } = await params; + const post = await getPost(id); + + if (!post) { + notFound(); // Renders closest not-found.tsx + } + + return <div>{post.title}</div>; +} +``` + +## Error Hierarchy + +Errors bubble up to the nearest error boundary: + +``` +app/ +├── error.tsx # Catches errors from all children +├── blog/ +│ ├── error.tsx # Catches errors in /blog/* +│ └── [slug]/ +│ ├── error.tsx # Catches errors in /blog/[slug] +│ └── page.tsx +└── layout.tsx # Errors here go to global-error.tsx +``` diff --git a/packages/mosaic/framework/skills/next-best-practices/file-conventions.md b/packages/mosaic/framework/skills/next-best-practices/file-conventions.md new file mode 100644 index 00000000..58500e00 --- /dev/null +++ b/packages/mosaic/framework/skills/next-best-practices/file-conventions.md @@ -0,0 +1,141 @@ +# File Conventions + +Next.js App Router uses file-based routing with special file conventions. + +## Project Structure + +Reference: https://nextjs.org/docs/app/getting-started/project-structure + +``` +app/ +├── layout.tsx # Root layout (required) +├── page.tsx # Home page (/) +├── loading.tsx # Loading UI +├── error.tsx # Error UI +├── not-found.tsx # 404 UI +├── global-error.tsx # Global error UI +├── route.ts # API endpoint +├── template.tsx # Re-rendered layout +├── default.tsx # Parallel route fallback +├── blog/ +│ ├── page.tsx # /blog +│ └── [slug]/ +│ └── page.tsx # /blog/:slug +└── (group)/ # Route group (no URL impact) + └── page.tsx +``` + +## Special Files + +| File | Purpose | +| --------------- | ---------------------------------------- | +| `page.tsx` | UI for a route segment | +| `layout.tsx` | Shared UI for segment and children | +| `loading.tsx` | Loading UI (Suspense boundary) | +| `error.tsx` | Error UI (Error boundary) | +| `not-found.tsx` | 404 UI | +| `route.ts` | API endpoint | +| `template.tsx` | Like layout but re-renders on navigation | +| `default.tsx` | Fallback for parallel routes | + +## Route Segments + +``` +app/ +├── blog/ # Static segment: /blog +├── [slug]/ # Dynamic segment: /:slug +├── [...slug]/ # Catch-all: /a/b/c +├── [[...slug]]/ # Optional catch-all: / or /a/b/c +└── (marketing)/ # Route group (ignored in URL) +``` + +## Parallel Routes + +``` +app/ +├── @analytics/ +│ └── page.tsx +├── @sidebar/ +│ └── page.tsx +└── layout.tsx # Receives { analytics, sidebar } as props +``` + +## Intercepting Routes + +``` +app/ +├── feed/ +│ └── page.tsx +├── @modal/ +│ └── (.)photo/[id]/ # Intercepts /photo/[id] from /feed +│ └── page.tsx +└── photo/[id]/ + └── page.tsx +``` + +Conventions: + +- `(.)` - same level +- `(..)` - one level up +- `(..)(..)` - two levels up +- `(...)` - from root + +## Private Folders + +``` +app/ +├── _components/ # Private folder (not a route) +│ └── Button.tsx +└── page.tsx +``` + +Prefix with `_` to exclude from routing. + +## Middleware / Proxy + +### Next.js 14-15: `middleware.ts` + +```ts +// middleware.ts (root of project) +import { NextResponse } from 'next/server'; +import type { NextRequest } from 'next/server'; + +export function middleware(request: NextRequest) { + // Auth, redirects, rewrites, etc. + return NextResponse.next(); +} + +export const config = { + matcher: ['/dashboard/:path*', '/api/:path*'], +}; +``` + +### Next.js 16+: `proxy.ts` + +Renamed for clarity - same capabilities, different names: + +```ts +// proxy.ts (root of project) +import { NextResponse } from 'next/server'; +import type { NextRequest } from 'next/server'; + +export function proxy(request: NextRequest) { + // Same logic as middleware + return NextResponse.next(); +} + +export const proxyConfig = { + matcher: ['/dashboard/:path*', '/api/:path*'], +}; +``` + +| Version | File | Export | Config | +| ------- | --------------- | -------------- | ------------- | +| v14-15 | `middleware.ts` | `middleware()` | `config` | +| v16+ | `proxy.ts` | `proxy()` | `proxyConfig` | + +**Migration**: Run `npx @next/codemod@latest upgrade` to auto-rename. + +## File Conventions Reference + +Reference: https://nextjs.org/docs/app/api-reference/file-conventions diff --git a/packages/mosaic/framework/skills/next-best-practices/font.md b/packages/mosaic/framework/skills/next-best-practices/font.md new file mode 100644 index 00000000..0b9be72a --- /dev/null +++ b/packages/mosaic/framework/skills/next-best-practices/font.md @@ -0,0 +1,246 @@ +# Font Optimization + +Use `next/font` for automatic font optimization with zero layout shift. + +## Google Fonts + +```tsx +// app/layout.tsx +import { Inter } from 'next/font/google'; + +const inter = Inter({ subsets: ['latin'] }); + +export default function RootLayout({ children }: { children: React.ReactNode }) { + return ( + <html lang="en" className={inter.className}> + <body>{children}</body> + </html> + ); +} +``` + +## Multiple Fonts + +```tsx +import { Inter, Roboto_Mono } from 'next/font/google'; + +const inter = Inter({ + subsets: ['latin'], + variable: '--font-inter', +}); + +const robotoMono = Roboto_Mono({ + subsets: ['latin'], + variable: '--font-roboto-mono', +}); + +export default function RootLayout({ children }: { children: React.ReactNode }) { + return ( + <html lang="en" className={`${inter.variable} ${robotoMono.variable}`}> + <body>{children}</body> + </html> + ); +} +``` + +Use in CSS: + +```css +body { + font-family: var(--font-inter); +} + +code { + font-family: var(--font-roboto-mono); +} +``` + +## Font Weights and Styles + +```tsx +// Single weight +const inter = Inter({ + subsets: ['latin'], + weight: '400', +}); + +// Multiple weights +const inter = Inter({ + subsets: ['latin'], + weight: ['400', '500', '700'], +}); + +// Variable font (recommended) - includes all weights +const inter = Inter({ + subsets: ['latin'], + // No weight needed - variable fonts support all weights +}); + +// With italic +const inter = Inter({ + subsets: ['latin'], + style: ['normal', 'italic'], +}); +``` + +## Local Fonts + +```tsx +import localFont from 'next/font/local'; + +const myFont = localFont({ + src: './fonts/MyFont.woff2', +}); + +// Multiple files for different weights +const myFont = localFont({ + src: [ + { + path: './fonts/MyFont-Regular.woff2', + weight: '400', + style: 'normal', + }, + { + path: './fonts/MyFont-Bold.woff2', + weight: '700', + style: 'normal', + }, + ], +}); + +// Variable font +const myFont = localFont({ + src: './fonts/MyFont-Variable.woff2', + variable: '--font-my-font', +}); +``` + +## Tailwind CSS Integration + +```tsx +// app/layout.tsx +import { Inter } from 'next/font/google'; + +const inter = Inter({ + subsets: ['latin'], + variable: '--font-inter', +}); + +export default function RootLayout({ children }) { + return ( + <html lang="en" className={inter.variable}> + <body>{children}</body> + </html> + ); +} +``` + +```js +// tailwind.config.js +module.exports = { + theme: { + extend: { + fontFamily: { + sans: ['var(--font-inter)'], + }, + }, + }, +}; +``` + +## Preloading Subsets + +Only load needed character subsets: + +```tsx +// Latin only (most common) +const inter = Inter({ subsets: ['latin'] }); + +// Multiple subsets +const inter = Inter({ subsets: ['latin', 'latin-ext', 'cyrillic'] }); +``` + +## Display Strategy + +Control font loading behavior: + +```tsx +const inter = Inter({ + subsets: ['latin'], + display: 'swap', // Default - shows fallback, swaps when loaded +}); + +// Options: +// 'auto' - browser decides +// 'block' - short block period, then swap +// 'swap' - immediate fallback, swap when ready (recommended) +// 'fallback' - short block, short swap, then fallback +// 'optional' - short block, no swap (use if font is optional) +``` + +## Don't Use Manual Font Links + +Always use `next/font` instead of `<link>` tags for Google Fonts. + +```tsx +// Bad: Manual link tag (blocks rendering, no optimization) +<link href="https://fonts.googleapis.com/css2?family=Inter" rel="stylesheet" /> + +// Bad: Missing display and preconnect +<link href="https://fonts.googleapis.com/css2?family=Inter" rel="stylesheet" /> + +// Good: Use next/font (self-hosted, zero layout shift) +import { Inter } from 'next/font/google' + +const inter = Inter({ subsets: ['latin'] }) +``` + +## Common Mistakes + +```tsx +// Bad: Importing font in every component +// components/Button.tsx +import { Inter } from 'next/font/google' +const inter = Inter({ subsets: ['latin'] }) // Creates new instance each time! + +// Good: Import once in layout, use CSS variable +// app/layout.tsx +const inter = Inter({ subsets: ['latin'], variable: '--font-inter' }) + +// Bad: Using @import in CSS (blocks rendering) +/* globals.css */ +@import url('https://fonts.googleapis.com/css2?family=Inter'); + +// Good: Use next/font (self-hosted, no network request) +import { Inter } from 'next/font/google' + +// Bad: Loading all weights when only using a few +const inter = Inter({ subsets: ['latin'] }) // Loads all weights + +// Good: Specify only needed weights (for non-variable fonts) +const inter = Inter({ subsets: ['latin'], weight: ['400', '700'] }) + +// Bad: Missing subset - loads all characters +const inter = Inter({}) + +// Good: Always specify subset +const inter = Inter({ subsets: ['latin'] }) +``` + +## Font in Specific Components + +```tsx +// For component-specific fonts, export from a shared file +// lib/fonts.ts +import { Inter, Playfair_Display } from 'next/font/google'; + +export const inter = Inter({ subsets: ['latin'], variable: '--font-inter' }); +export const playfair = Playfair_Display({ subsets: ['latin'], variable: '--font-playfair' }); + +// components/Heading.tsx +import { playfair } from '@/lib/fonts'; + +export function Heading({ children }) { + return <h1 className={playfair.className}>{children}</h1>; +} +``` diff --git a/packages/mosaic/framework/skills/next-best-practices/functions.md b/packages/mosaic/framework/skills/next-best-practices/functions.md new file mode 100644 index 00000000..b84c35da --- /dev/null +++ b/packages/mosaic/framework/skills/next-best-practices/functions.md @@ -0,0 +1,108 @@ +# Functions + +Next.js function APIs. + +Reference: https://nextjs.org/docs/app/api-reference/functions + +## Navigation Hooks (Client) + +| Hook | Purpose | Reference | +| --------------------------- | -------------------------------------------------------------- | ---------------------------------------------------------------------------------------- | +| `useRouter` | Programmatic navigation (`push`, `replace`, `back`, `refresh`) | [Docs](https://nextjs.org/docs/app/api-reference/functions/use-router) | +| `usePathname` | Get current pathname | [Docs](https://nextjs.org/docs/app/api-reference/functions/use-pathname) | +| `useSearchParams` | Read URL search parameters | [Docs](https://nextjs.org/docs/app/api-reference/functions/use-search-params) | +| `useParams` | Access dynamic route parameters | [Docs](https://nextjs.org/docs/app/api-reference/functions/use-params) | +| `useSelectedLayoutSegment` | Active child segment (one level) | [Docs](https://nextjs.org/docs/app/api-reference/functions/use-selected-layout-segment) | +| `useSelectedLayoutSegments` | All active segments below layout | [Docs](https://nextjs.org/docs/app/api-reference/functions/use-selected-layout-segments) | +| `useLinkStatus` | Check link prefetch status | [Docs](https://nextjs.org/docs/app/api-reference/functions/use-link-status) | +| `useReportWebVitals` | Report Core Web Vitals metrics | [Docs](https://nextjs.org/docs/app/api-reference/functions/use-report-web-vitals) | + +## Server Functions + +| Function | Purpose | Reference | +| ------------ | -------------------------------------------- | ---------------------------------------------------------------------- | +| `cookies` | Read/write cookies | [Docs](https://nextjs.org/docs/app/api-reference/functions/cookies) | +| `headers` | Read request headers | [Docs](https://nextjs.org/docs/app/api-reference/functions/headers) | +| `draftMode` | Enable preview of unpublished CMS content | [Docs](https://nextjs.org/docs/app/api-reference/functions/draft-mode) | +| `after` | Run code after response finishes streaming | [Docs](https://nextjs.org/docs/app/api-reference/functions/after) | +| `connection` | Wait for connection before dynamic rendering | [Docs](https://nextjs.org/docs/app/api-reference/functions/connection) | +| `userAgent` | Parse User-Agent header | [Docs](https://nextjs.org/docs/app/api-reference/functions/userAgent) | + +## Generate Functions + +| Function | Purpose | Reference | +| ----------------------- | --------------------------------------- | ----------------------------------------------------------------------------------- | +| `generateStaticParams` | Pre-render dynamic routes at build time | [Docs](https://nextjs.org/docs/app/api-reference/functions/generate-static-params) | +| `generateMetadata` | Dynamic metadata | [Docs](https://nextjs.org/docs/app/api-reference/functions/generate-metadata) | +| `generateViewport` | Dynamic viewport config | [Docs](https://nextjs.org/docs/app/api-reference/functions/generate-viewport) | +| `generateSitemaps` | Multiple sitemaps for large sites | [Docs](https://nextjs.org/docs/app/api-reference/functions/generate-sitemaps) | +| `generateImageMetadata` | Multiple OG images per route | [Docs](https://nextjs.org/docs/app/api-reference/functions/generate-image-metadata) | + +## Request/Response + +| Function | Purpose | Reference | +| --------------- | ------------------------------ | -------------------------------------------------------------------------- | +| `NextRequest` | Extended Request with helpers | [Docs](https://nextjs.org/docs/app/api-reference/functions/next-request) | +| `NextResponse` | Extended Response with helpers | [Docs](https://nextjs.org/docs/app/api-reference/functions/next-response) | +| `ImageResponse` | Generate OG images | [Docs](https://nextjs.org/docs/app/api-reference/functions/image-response) | + +## Common Examples + +### Navigation + +Use `next/link` for internal navigation instead of `<a>` tags. + +```tsx +// Bad: Plain anchor tag +<a href="/about">About</a>; + +// Good: Next.js Link +import Link from 'next/link'; + +<Link href="/about">About</Link>; +``` + +Active link styling: + +```tsx +'use client'; + +import Link from 'next/link'; +import { usePathname } from 'next/navigation'; + +export function NavLink({ href, children }) { + const pathname = usePathname(); + + return ( + <Link href={href} className={pathname === href ? 'active' : ''}> + {children} + </Link> + ); +} +``` + +### Static Generation + +```tsx +// app/blog/[slug]/page.tsx +export async function generateStaticParams() { + const posts = await getPosts(); + return posts.map((post) => ({ slug: post.slug })); +} +``` + +### After Response + +```tsx +import { after } from 'next/server'; + +export async function POST(request: Request) { + const data = await processRequest(request); + + after(async () => { + await logAnalytics(data); + }); + + return Response.json({ success: true }); +} +``` diff --git a/packages/mosaic/framework/skills/next-best-practices/hydration-error.md b/packages/mosaic/framework/skills/next-best-practices/hydration-error.md new file mode 100644 index 00000000..a49bebea --- /dev/null +++ b/packages/mosaic/framework/skills/next-best-practices/hydration-error.md @@ -0,0 +1,86 @@ +# Hydration Errors + +Diagnose and fix React hydration mismatch errors. + +## Error Signs + +- "Hydration failed because the initial UI does not match" +- "Text content does not match server-rendered HTML" + +## Debugging + +In development, click the hydration error to see the server/client diff. + +## Common Causes and Fixes + +### Browser-only APIs + +```tsx +// Bad: Causes mismatch - window doesn't exist on server +<div>{window.innerWidth}</div>; + +// Good: Use client component with mounted check +('use client'); +import { useState, useEffect } from 'react'; + +export function ClientOnly({ children }: { children: React.ReactNode }) { + const [mounted, setMounted] = useState(false); + useEffect(() => setMounted(true), []); + return mounted ? children : null; +} +``` + +### Date/Time Rendering + +Server and client may be in different timezones: + +```tsx +// Bad: Causes mismatch +<span>{new Date().toLocaleString()}</span>; + +// Good: Render on client only +('use client'); +const [time, setTime] = useState<string>(); +useEffect(() => setTime(new Date().toLocaleString()), []); +``` + +### Random Values or IDs + +```tsx +// Bad: Random values differ between server and client +<div id={Math.random().toString()}> + +// Good: Use useId hook +import { useId } from 'react' + +function Input() { + const id = useId() + return <input id={id} /> +} +``` + +### Invalid HTML Nesting + +```tsx +// Bad: Invalid - div inside p +<p><div>Content</div></p> + +// Bad: Invalid - p inside p +<p><p>Nested</p></p> + +// Good: Valid nesting +<div><p>Content</p></div> +``` + +### Third-party Scripts + +Scripts that modify DOM during hydration. + +```tsx +// Good: Use next/script with afterInteractive +import Script from 'next/script'; + +export default function Page() { + return <Script src="https://example.com/script.js" strategy="afterInteractive" />; +} +``` diff --git a/packages/mosaic/framework/skills/next-best-practices/image.md b/packages/mosaic/framework/skills/next-best-practices/image.md new file mode 100644 index 00000000..3bfbdf20 --- /dev/null +++ b/packages/mosaic/framework/skills/next-best-practices/image.md @@ -0,0 +1,173 @@ +# Image Optimization + +Use `next/image` for automatic image optimization. + +## Always Use next/image + +```tsx +// Bad: Avoid native img +<img src="/hero.png" alt="Hero" />; + +// Good: Use next/image +import Image from 'next/image'; +<Image src="/hero.png" alt="Hero" width={800} height={400} />; +``` + +## Required Props + +Images need explicit dimensions to prevent layout shift: + +```tsx +// Local images - dimensions inferred automatically +import heroImage from './hero.png' +<Image src={heroImage} alt="Hero" /> + +// Remote images - must specify width/height +<Image src="https://example.com/image.jpg" alt="Hero" width={800} height={400} /> + +// Or use fill for parent-relative sizing +<div style={{ position: 'relative', width: '100%', height: 400 }}> + <Image src="/hero.png" alt="Hero" fill style={{ objectFit: 'cover' }} /> +</div> +``` + +## Remote Images Configuration + +Remote domains must be configured in `next.config.js`: + +```js +// next.config.js +module.exports = { + images: { + remotePatterns: [ + { + protocol: 'https', + hostname: 'example.com', + pathname: '/images/**', + }, + { + protocol: 'https', + hostname: '*.cdn.com', // Wildcard subdomain + }, + ], + }, +}; +``` + +## Responsive Images + +Use `sizes` to tell the browser which size to download: + +```tsx +// Full-width hero +<Image + src="/hero.png" + alt="Hero" + fill + sizes="100vw" +/> + +// Responsive grid (3 columns on desktop, 1 on mobile) +<Image + src="/card.png" + alt="Card" + fill + sizes="(max-width: 768px) 100vw, 33vw" +/> + +// Fixed sidebar image +<Image + src="/avatar.png" + alt="Avatar" + width={200} + height={200} + sizes="200px" +/> +``` + +## Blur Placeholder + +Prevent layout shift with placeholders: + +```tsx +// Local images - automatic blur hash +import heroImage from './hero.png' +<Image src={heroImage} alt="Hero" placeholder="blur" /> + +// Remote images - provide blurDataURL +<Image + src="https://example.com/image.jpg" + alt="Hero" + width={800} + height={400} + placeholder="blur" + blurDataURL="data:image/jpeg;base64,/9j/4AAQSkZJRg..." +/> + +// Or use color placeholder +<Image + src="https://example.com/image.jpg" + alt="Hero" + width={800} + height={400} + placeholder="empty" + style={{ backgroundColor: '#e0e0e0' }} +/> +``` + +## Priority Loading + +Use `priority` for above-the-fold images (LCP): + +```tsx +// Hero image - loads immediately +<Image src="/hero.png" alt="Hero" fill priority /> + +// Below-fold images - lazy loaded by default (no priority needed) +<Image src="/card.png" alt="Card" width={400} height={300} /> +``` + +## Common Mistakes + +```tsx +// Bad: Missing sizes with fill - downloads largest image +<Image src="/hero.png" alt="Hero" fill /> + +// Good: Add sizes for proper responsive behavior +<Image src="/hero.png" alt="Hero" fill sizes="100vw" /> + +// Bad: Using width/height for aspect ratio only +<Image src="/hero.png" alt="Hero" width={16} height={9} /> + +// Good: Use actual display dimensions or fill with sizes +<Image src="/hero.png" alt="Hero" fill sizes="100vw" style={{ objectFit: 'cover' }} /> + +// Bad: Remote image without config +<Image src="https://untrusted.com/image.jpg" alt="Image" width={400} height={300} /> +// Error: Invalid src prop, hostname not configured + +// Good: Add hostname to next.config.js remotePatterns +``` + +## Static Export + +When using `output: 'export'`, use `unoptimized` or custom loader: + +```tsx +// Option 1: Disable optimization +<Image src="/hero.png" alt="Hero" width={800} height={400} unoptimized />; + +// Option 2: Global config +// next.config.js +module.exports = { + output: 'export', + images: { unoptimized: true }, +}; + +// Option 3: Custom loader (Cloudinary, Imgix, etc.) +const cloudinaryLoader = ({ src, width, quality }) => { + return `https://res.cloudinary.com/demo/image/upload/w_${width},q_${quality || 75}/${src}`; +}; + +<Image loader={cloudinaryLoader} src="sample.jpg" alt="Sample" width={800} height={400} />; +``` diff --git a/packages/mosaic/framework/skills/next-best-practices/metadata.md b/packages/mosaic/framework/skills/next-best-practices/metadata.md new file mode 100644 index 00000000..9c4541ae --- /dev/null +++ b/packages/mosaic/framework/skills/next-best-practices/metadata.md @@ -0,0 +1,292 @@ +# Metadata + +Add SEO metadata to Next.js pages using the Metadata API. + +## Important: Server Components Only + +The `metadata` object and `generateMetadata` function are **only supported in Server Components**. They cannot be used in Client Components. + +If the target page has `'use client'`: + +1. Remove `'use client'` if possible, move client logic to child components +2. Or extract metadata to a parent Server Component layout +3. Or split the file: Server Component with metadata imports Client Components + +## Static Metadata + +```tsx +import type { Metadata } from 'next'; + +export const metadata: Metadata = { + title: 'Page Title', + description: 'Page description for search engines', +}; +``` + +## Dynamic Metadata + +```tsx +import type { Metadata } from 'next'; + +type Props = { params: Promise<{ slug: string }> }; + +export async function generateMetadata({ params }: Props): Promise<Metadata> { + const { slug } = await params; + const post = await getPost(slug); + return { title: post.title, description: post.description }; +} +``` + +## Avoid Duplicate Fetches + +Use React `cache()` when the same data is needed for both metadata and page: + +```tsx +import { cache } from 'react'; + +export const getPost = cache(async (slug: string) => { + return await db.posts.findFirst({ where: { slug } }); +}); +``` + +## Viewport + +Separate from metadata for streaming support: + +```tsx +import type { Viewport } from 'next'; + +export const viewport: Viewport = { + width: 'device-width', + initialScale: 1, + themeColor: '#000000', +}; + +// Or dynamic +export function generateViewport({ params }): Viewport { + return { themeColor: getThemeColor(params) }; +} +``` + +## Title Templates + +In root layout for consistent naming: + +```tsx +export const metadata: Metadata = { + title: { default: 'Site Name', template: '%s | Site Name' }, +}; +``` + +## Metadata File Conventions + +Reference: https://nextjs.org/docs/app/getting-started/project-structure#metadata-file-conventions + +Place these files in `app/` directory (or route segments): + +| File | Purpose | +| ------------------------------- | --------------------------------------------- | +| `favicon.ico` | Favicon | +| `icon.png` / `icon.svg` | App icon | +| `apple-icon.png` | Apple app icon | +| `opengraph-image.png` | OG image | +| `twitter-image.png` | Twitter card image | +| `sitemap.ts` / `sitemap.xml` | Sitemap (use `generateSitemaps` for multiple) | +| `robots.ts` / `robots.txt` | Robots directives | +| `manifest.ts` / `manifest.json` | Web app manifest | + +## SEO Best Practice: Static Files Are Often Enough + +For most sites, **static metadata files provide excellent SEO coverage**: + +``` +app/ +├── favicon.ico +├── opengraph-image.png # Works for both OG and Twitter +├── sitemap.ts +├── robots.ts +└── layout.tsx # With title/description metadata +``` + +**Tips:** + +- A single `opengraph-image.png` covers both Open Graph and Twitter (Twitter falls back to OG) +- Static `title` and `description` in layout metadata is sufficient for most pages +- Only use dynamic `generateMetadata` when content varies per page + +--- + +# OG Image Generation + +Generate dynamic Open Graph images using `next/og`. + +## Important Rules + +1. **Use `next/og`** - not `@vercel/og` (it's built into Next.js) +2. **No searchParams** - OG images can't access search params, use route params instead +3. **Avoid Edge runtime** - Use default Node.js runtime + +```tsx +// Good +import { ImageResponse } from 'next/og'; + +// Bad +// import { ImageResponse } from '@vercel/og' +// export const runtime = 'edge' +``` + +## Basic OG Image + +```tsx +// app/opengraph-image.tsx +import { ImageResponse } from 'next/og'; + +export const alt = 'Site Name'; +export const size = { width: 1200, height: 630 }; +export const contentType = 'image/png'; + +export default function Image() { + return new ImageResponse( + <div + style={{ + fontSize: 128, + background: 'white', + width: '100%', + height: '100%', + display: 'flex', + alignItems: 'center', + justifyContent: 'center', + }} + > + Hello World + </div>, + { ...size }, + ); +} +``` + +## Dynamic OG Image + +```tsx +// app/blog/[slug]/opengraph-image.tsx +import { ImageResponse } from 'next/og'; + +export const alt = 'Blog Post'; +export const size = { width: 1200, height: 630 }; +export const contentType = 'image/png'; + +type Props = { params: Promise<{ slug: string }> }; + +export default async function Image({ params }: Props) { + const { slug } = await params; + const post = await getPost(slug); + + return new ImageResponse( + <div + style={{ + fontSize: 48, + background: 'linear-gradient(to bottom, #1a1a1a, #333)', + color: 'white', + width: '100%', + height: '100%', + display: 'flex', + flexDirection: 'column', + alignItems: 'center', + justifyContent: 'center', + padding: 48, + }} + > + <div style={{ fontSize: 64, fontWeight: 'bold' }}>{post.title}</div> + <div style={{ marginTop: 24, opacity: 0.8 }}>{post.description}</div> + </div>, + { ...size }, + ); +} +``` + +## Custom Fonts + +```tsx +import { ImageResponse } from 'next/og'; +import { join } from 'path'; +import { readFile } from 'fs/promises'; + +export default async function Image() { + const fontPath = join(process.cwd(), 'assets/fonts/Inter-Bold.ttf'); + const fontData = await readFile(fontPath); + + return new ImageResponse( + <div style={{ fontFamily: 'Inter', fontSize: 64 }}>Custom Font Text</div>, + { + width: 1200, + height: 630, + fonts: [{ name: 'Inter', data: fontData, style: 'normal' }], + }, + ); +} +``` + +## File Naming + +- `opengraph-image.tsx` - Open Graph (Facebook, LinkedIn) +- `twitter-image.tsx` - Twitter/X cards (optional, falls back to OG) + +## Styling Notes + +ImageResponse uses Flexbox layout: + +- Use `display: 'flex'` +- No CSS Grid support +- Styles must be inline objects + +## Multiple OG Images + +Use `generateImageMetadata` for multiple images per route: + +```tsx +// app/blog/[slug]/opengraph-image.tsx +import { ImageResponse } from 'next/og'; + +export async function generateImageMetadata({ params }) { + const images = await getPostImages(params.slug); + return images.map((img, idx) => ({ + id: idx, + alt: img.alt, + size: { width: 1200, height: 630 }, + contentType: 'image/png', + })); +} + +export default async function Image({ params, id }) { + const images = await getPostImages(params.slug); + const image = images[id]; + return new ImageResponse(/* ... */); +} +``` + +## Multiple Sitemaps + +Use `generateSitemaps` for large sites: + +```tsx +// app/sitemap.ts +import type { MetadataRoute } from 'next'; + +export async function generateSitemaps() { + // Return array of sitemap IDs + return [{ id: 0 }, { id: 1 }, { id: 2 }]; +} + +export default async function sitemap({ id }: { id: number }): Promise<MetadataRoute.Sitemap> { + const start = id * 50000; + const end = start + 50000; + const products = await getProducts(start, end); + + return products.map((product) => ({ + url: `https://example.com/product/${product.id}`, + lastModified: product.updatedAt, + })); +} +``` + +Generates `/sitemap/0.xml`, `/sitemap/1.xml`, etc. diff --git a/packages/mosaic/framework/skills/next-best-practices/parallel-routes.md b/packages/mosaic/framework/skills/next-best-practices/parallel-routes.md new file mode 100644 index 00000000..41a60acd --- /dev/null +++ b/packages/mosaic/framework/skills/next-best-practices/parallel-routes.md @@ -0,0 +1,286 @@ +# Parallel & Intercepting Routes + +Parallel routes render multiple pages in the same layout. Intercepting routes show a different UI when navigating from within your app vs direct URL access. Together they enable modal patterns. + +## File Structure + +``` +app/ +├── @modal/ # Parallel route slot +│ ├── default.tsx # Required! Returns null +│ ├── (.)photos/ # Intercepts /photos/* +│ │ └── [id]/ +│ │ └── page.tsx # Modal content +│ └── [...]catchall/ # Optional: catch unmatched +│ └── page.tsx +├── photos/ +│ └── [id]/ +│ └── page.tsx # Full page (direct access) +├── layout.tsx # Renders both children and @modal +└── page.tsx +``` + +## Step 1: Root Layout with Slot + +```tsx +// app/layout.tsx +export default function RootLayout({ + children, + modal, +}: { + children: React.ReactNode; + modal: React.ReactNode; +}) { + return ( + <html> + <body> + {children} + {modal} + </body> + </html> + ); +} +``` + +## Step 2: Default File (Critical!) + +**Every parallel route slot MUST have a `default.tsx`** to prevent 404s on hard navigation. + +```tsx +// app/@modal/default.tsx +export default function Default() { + return null; +} +``` + +Without this file, refreshing any page will 404 because Next.js can't determine what to render in the `@modal` slot. + +## Step 3: Intercepting Route (Modal) + +The `(.)` prefix intercepts routes at the same level. + +```tsx +// app/@modal/(.)photos/[id]/page.tsx +import { Modal } from '@/components/modal'; + +export default async function PhotoModal({ params }: { params: Promise<{ id: string }> }) { + const { id } = await params; + const photo = await getPhoto(id); + + return ( + <Modal> + <img src={photo.url} alt={photo.title} /> + </Modal> + ); +} +``` + +## Step 4: Full Page (Direct Access) + +```tsx +// app/photos/[id]/page.tsx +export default async function PhotoPage({ params }: { params: Promise<{ id: string }> }) { + const { id } = await params; + const photo = await getPhoto(id); + + return ( + <div className="full-page"> + <img src={photo.url} alt={photo.title} /> + <h1>{photo.title}</h1> + </div> + ); +} +``` + +## Step 5: Modal Component with Correct Closing + +**Critical: Use `router.back()` to close modals, NOT `router.push()` or `<Link>`.** + +```tsx +// components/modal.tsx +'use client'; + +import { useRouter } from 'next/navigation'; +import { useCallback, useEffect, useRef } from 'react'; + +export function Modal({ children }: { children: React.ReactNode }) { + const router = useRouter(); + const overlayRef = useRef<HTMLDivElement>(null); + + // Close on escape key + useEffect(() => { + function onKeyDown(e: KeyboardEvent) { + if (e.key === 'Escape') { + router.back(); // Correct + } + } + document.addEventListener('keydown', onKeyDown); + return () => document.removeEventListener('keydown', onKeyDown); + }, [router]); + + // Close on overlay click + const handleOverlayClick = useCallback( + (e: React.MouseEvent) => { + if (e.target === overlayRef.current) { + router.back(); // Correct + } + }, + [router], + ); + + return ( + <div + ref={overlayRef} + onClick={handleOverlayClick} + className="fixed inset-0 bg-black/50 flex items-center justify-center z-50" + > + <div className="bg-white rounded-lg p-6 max-w-2xl w-full mx-4"> + <button + onClick={() => router.back()} // Correct! + className="absolute top-4 right-4" + > + Close + </button> + {children} + </div> + </div> + ); +} +``` + +### Why NOT `router.push('/')` or `<Link href="/">`? + +Using `push` or `Link` to "close" a modal: + +1. Adds a new history entry (back button shows modal again) +2. Doesn't properly clear the intercepted route +3. Can cause the modal to flash or persist unexpectedly + +`router.back()` correctly: + +1. Removes the intercepted route from history +2. Returns to the previous page +3. Properly unmounts the modal + +## Route Matcher Reference + +Matchers match **route segments**, not filesystem paths: + +| Matcher | Matches | Example | +| ---------- | ------------- | --------------------------------------------------------------------- | +| `(.)` | Same level | `@modal/(.)photos` intercepts `/photos` | +| `(..)` | One level up | `@modal/(..)settings` from `/dashboard/@modal` intercepts `/settings` | +| `(..)(..)` | Two levels up | Rarely used | +| `(...)` | From root | `@modal/(...)photos` intercepts `/photos` from anywhere | + +**Common mistake**: Thinking `(..)` means "parent folder" - it means "parent route segment". + +## Handling Hard Navigation + +When users directly visit `/photos/123` (bookmark, refresh, shared link): + +- The intercepting route is bypassed +- The full `photos/[id]/page.tsx` renders +- Modal doesn't appear (expected behavior) + +If you want the modal to appear on direct access too, you need additional logic: + +```tsx +// app/photos/[id]/page.tsx +import { Modal } from '@/components/modal'; + +export default async function PhotoPage({ params }) { + const { id } = await params; + const photo = await getPhoto(id); + + // Option: Render as modal on direct access too + return ( + <Modal> + <img src={photo.url} alt={photo.title} /> + </Modal> + ); +} +``` + +## Common Gotchas + +### 1. Missing `default.tsx` → 404 on Refresh + +Every `@slot` folder needs a `default.tsx` that returns `null` (or appropriate content). + +### 2. Modal Persists After Navigation + +You're using `router.push()` instead of `router.back()`. + +### 3. Nested Parallel Routes Need Defaults Too + +If you have `@modal` inside a route group, each level needs its own `default.tsx`: + +``` +app/ +├── (marketing)/ +│ ├── @modal/ +│ │ └── default.tsx # Needed! +│ └── layout.tsx +└── layout.tsx +``` + +### 4. Intercepted Route Shows Wrong Content + +Check your matcher: + +- `(.)photos` intercepts `/photos` from the same route level +- If your `@modal` is in `app/dashboard/@modal`, use `(.)photos` to intercept `/dashboard/photos`, not `/photos` + +### 5. TypeScript Errors with `params` + +In Next.js 15+, `params` is a Promise: + +```tsx +// Correct +export default async function Page({ params }: { params: Promise<{ id: string }> }) { + const { id } = await params; +} +``` + +## Complete Example: Photo Gallery Modal + +``` +app/ +├── @modal/ +│ ├── default.tsx +│ └── (.)photos/ +│ └── [id]/ +│ └── page.tsx +├── photos/ +│ ├── page.tsx # Gallery grid +│ └── [id]/ +│ └── page.tsx # Full photo page +├── layout.tsx +└── page.tsx +``` + +Links in the gallery: + +```tsx +// app/photos/page.tsx +import Link from 'next/link'; + +export default async function Gallery() { + const photos = await getPhotos(); + + return ( + <div className="grid grid-cols-3 gap-4"> + {photos.map((photo) => ( + <Link key={photo.id} href={`/photos/${photo.id}`}> + <img src={photo.thumbnail} alt={photo.title} /> + </Link> + ))} + </div> + ); +} +``` + +Clicking a photo → Modal opens (intercepted) +Direct URL → Full page renders +Refresh while modal open → Full page renders diff --git a/packages/mosaic/framework/skills/next-best-practices/route-handlers.md b/packages/mosaic/framework/skills/next-best-practices/route-handlers.md new file mode 100644 index 00000000..e89a32bb --- /dev/null +++ b/packages/mosaic/framework/skills/next-best-practices/route-handlers.md @@ -0,0 +1,143 @@ +# Route Handlers + +Create API endpoints with `route.ts` files. + +## Basic Usage + +```tsx +// app/api/users/route.ts +export async function GET() { + const users = await getUsers(); + return Response.json(users); +} + +export async function POST(request: Request) { + const body = await request.json(); + const user = await createUser(body); + return Response.json(user, { status: 201 }); +} +``` + +## Supported Methods + +`GET`, `POST`, `PUT`, `PATCH`, `DELETE`, `HEAD`, `OPTIONS` + +## GET Handler Conflicts with page.tsx + +**A `route.ts` and `page.tsx` cannot coexist in the same folder.** + +``` +app/ +├── api/ +│ └── users/ +│ └── route.ts # /api/users +└── users/ + ├── page.tsx # /users (page) + └── route.ts # Warning: Conflicts with page.tsx! +``` + +If you need both a page and an API at the same path, use different paths: + +``` +app/ +├── users/ +│ └── page.tsx # /users (page) +└── api/ + └── users/ + └── route.ts # /api/users (API) +``` + +## Environment Behavior + +Route handlers run in a **Server Component-like environment**: + +- Yes: Can use `async/await` +- Yes: Can access `cookies()`, `headers()` +- Yes: Can use Node.js APIs +- No: Cannot use React hooks +- No: Cannot use React DOM APIs +- No: Cannot use browser APIs + +```tsx +// Bad: This won't work - no React DOM in route handlers +import { renderToString } from 'react-dom/server'; + +export async function GET() { + const html = renderToString(<Component />); // Error! + return new Response(html); +} +``` + +## Dynamic Route Handlers + +```tsx +// app/api/users/[id]/route.ts +export async function GET(request: Request, { params }: { params: Promise<{ id: string }> }) { + const { id } = await params; + const user = await getUser(id); + + if (!user) { + return Response.json({ error: 'Not found' }, { status: 404 }); + } + + return Response.json(user); +} +``` + +## Request Helpers + +```tsx +export async function GET(request: Request) { + // URL and search params + const { searchParams } = new URL(request.url); + const query = searchParams.get('q'); + + // Headers + const authHeader = request.headers.get('authorization'); + + // Cookies (Next.js helper) + const cookieStore = await cookies(); + const token = cookieStore.get('token'); + + return Response.json({ query, token }); +} +``` + +## Response Helpers + +```tsx +// JSON response +return Response.json({ data }); + +// With status +return Response.json({ error: 'Not found' }, { status: 404 }); + +// With headers +return Response.json(data, { + headers: { + 'Cache-Control': 'max-age=3600', + }, +}); + +// Redirect +return Response.redirect(new URL('/login', request.url)); + +// Stream +return new Response(stream, { + headers: { 'Content-Type': 'text/event-stream' }, +}); +``` + +## When to Use Route Handlers vs Server Actions + +| Use Case | Route Handlers | Server Actions | +| ------------------------ | -------------- | -------------- | +| Form submissions | No | Yes | +| Data mutations from UI | No | Yes | +| Third-party webhooks | Yes | No | +| External API consumption | Yes | No | +| Public REST API | Yes | No | +| File uploads | Both work | Both work | + +**Prefer Server Actions** for mutations triggered from your UI. +**Use Route Handlers** for external integrations and public APIs. diff --git a/packages/mosaic/framework/skills/next-best-practices/rsc-boundaries.md b/packages/mosaic/framework/skills/next-best-practices/rsc-boundaries.md new file mode 100644 index 00000000..afc08a27 --- /dev/null +++ b/packages/mosaic/framework/skills/next-best-practices/rsc-boundaries.md @@ -0,0 +1,160 @@ +# RSC Boundaries + +Detect and prevent invalid patterns when crossing Server/Client component boundaries. + +## Detection Rules + +### 1. Async Client Components Are Invalid + +Client components **cannot** be async functions. Only Server Components can be async. + +**Detect:** File has `'use client'` AND component is `async function` or returns `Promise` + +```tsx +// Bad: async client component +'use client'; +export default async function UserProfile() { + const user = await getUser(); // Cannot await in client component + return <div>{user.name}</div>; +} + +// Good: Remove async, fetch data in parent server component +// page.tsx (server component - no 'use client') +export default async function Page() { + const user = await getUser(); + return <UserProfile user={user} />; +} + +// UserProfile.tsx (client component) +('use client'); +export function UserProfile({ user }: { user: User }) { + return <div>{user.name}</div>; +} +``` + +```tsx +// Bad: async arrow function client component +'use client'; +const Dashboard = async () => { + const data = await fetchDashboard(); + return <div>{data}</div>; +}; + +// Good: Fetch in server component, pass data down +``` + +### 2. Non-Serializable Props to Client Components + +Props passed from Server → Client must be JSON-serializable. + +**Detect:** Server component passes these to a client component: + +- Functions (except Server Actions with `'use server'`) +- `Date` objects +- `Map`, `Set`, `WeakMap`, `WeakSet` +- Class instances +- `Symbol` (unless globally registered) +- Circular references + +```tsx +// Bad: Function prop +// page.tsx (server) +export default function Page() { + const handleClick = () => console.log('clicked'); + return <ClientButton onClick={handleClick} />; +} + +// Good: Define function inside client component +// ClientButton.tsx +('use client'); +export function ClientButton() { + const handleClick = () => console.log('clicked'); + return <button onClick={handleClick}>Click</button>; +} +``` + +```tsx +// Bad: Date object (silently becomes string, then crashes) +// page.tsx (server) +export default async function Page() { + const post = await getPost(); + return <PostCard createdAt={post.createdAt} />; // Date object +} + +// PostCard.tsx (client) - will crash on .getFullYear() +('use client'); +export function PostCard({ createdAt }: { createdAt: Date }) { + return <span>{createdAt.getFullYear()}</span>; // Runtime error! +} + +// Good: Serialize to string on server +// page.tsx (server) +export default async function Page() { + const post = await getPost(); + return <PostCard createdAt={post.createdAt.toISOString()} />; +} + +// PostCard.tsx (client) +('use client'); +export function PostCard({ createdAt }: { createdAt: string }) { + const date = new Date(createdAt); + return <span>{date.getFullYear()}</span>; +} +``` + +```tsx +// Bad: Class instance +const user = new UserModel(data) +<ClientProfile user={user} /> // Methods will be stripped + +// Good: Pass plain object +const user = await getUser() +<ClientProfile user={{ id: user.id, name: user.name }} /> +``` + +```tsx +// Bad: Map/Set +<ClientComponent items={new Map([['a', 1]])} /> + +// Good: Convert to array/object +<ClientComponent items={Object.fromEntries(map)} /> +<ClientComponent items={Array.from(set)} /> +``` + +### 3. Server Actions Are the Exception + +Functions marked with `'use server'` CAN be passed to client components. + +```tsx +// Valid: Server Action can be passed +// actions.ts +'use server'; +export async function submitForm(formData: FormData) { + // server-side logic +} + +// page.tsx (server) +import { submitForm } from './actions'; +export default function Page() { + return <ClientForm onSubmit={submitForm} />; // OK! +} + +// ClientForm.tsx (client) +('use client'); +export function ClientForm({ onSubmit }: { onSubmit: (data: FormData) => Promise<void> }) { + return <form action={onSubmit}>...</form>; +} +``` + +## Quick Reference + +| Pattern | Valid? | Fix | +| --------------------------------- | ------ | ------------------------------------- | +| `'use client'` + `async function` | No | Fetch in server parent, pass data | +| Pass `() => {}` to client | No | Define in client or use server action | +| Pass `new Date()` to client | No | Use `.toISOString()` | +| Pass `new Map()` to client | No | Convert to object/array | +| Pass class instance to client | No | Pass plain object | +| Pass server action to client | Yes | - | +| Pass `string/number/boolean` | Yes | - | +| Pass plain object/array | Yes | - | diff --git a/packages/mosaic/framework/skills/next-best-practices/runtime-selection.md b/packages/mosaic/framework/skills/next-best-practices/runtime-selection.md new file mode 100644 index 00000000..ac8db62a --- /dev/null +++ b/packages/mosaic/framework/skills/next-best-practices/runtime-selection.md @@ -0,0 +1,40 @@ +# Runtime Selection + +## Use Node.js Runtime by Default + +Use the default Node.js runtime for new routes and pages. Only use Edge runtime if the project already uses it or there's a specific requirement. + +```tsx +// Good: Default - no runtime config needed (uses Node.js) +export default function Page() { ... } + +// Caution: Only if already used in project or specifically required +export const runtime = 'edge' +``` + +## When to Use Each + +### Node.js Runtime (Default) + +- Full Node.js API support +- File system access (`fs`) +- Full `crypto` support +- Database connections +- Most npm packages work + +### Edge Runtime + +- Only for specific edge-location latency requirements +- Limited API (no `fs`, limited `crypto`) +- Smaller cold start +- Geographic distribution needs + +## Detection + +**Before adding `runtime = 'edge'`**, check: + +1. Does the project already use Edge runtime? +2. Is there a specific latency requirement? +3. Are all dependencies Edge-compatible? + +If unsure, use Node.js runtime. diff --git a/packages/mosaic/framework/skills/next-best-practices/scripts.md b/packages/mosaic/framework/skills/next-best-practices/scripts.md new file mode 100644 index 00000000..0644daaa --- /dev/null +++ b/packages/mosaic/framework/skills/next-best-practices/scripts.md @@ -0,0 +1,137 @@ +# Scripts + +Loading third-party scripts in Next.js. + +## Use next/script + +Always use `next/script` instead of native `<script>` tags for better performance. + +```tsx +// Bad: Native script tag +<script src="https://example.com/script.js"></script>; + +// Good: Next.js Script component +import Script from 'next/script'; + +<Script src="https://example.com/script.js" />; +``` + +## Inline Scripts Need ID + +Inline scripts require an `id` attribute for Next.js to track them. + +```tsx +// Bad: Missing id +<Script dangerouslySetInnerHTML={{ __html: 'console.log("hi")' }} /> + +// Good: Has id +<Script id="my-script" dangerouslySetInnerHTML={{ __html: 'console.log("hi")' }} /> + +// Good: Inline with id +<Script id="show-banner"> + {`document.getElementById('banner').classList.remove('hidden')`} +</Script> +``` + +## Don't Put Script in Head + +`next/script` should not be placed inside `next/head`. It handles its own positioning. + +```tsx +// Bad: Script inside Head +import Head from 'next/head' +import Script from 'next/script' + +<Head> + <Script src="/analytics.js" /> +</Head> + +// Good: Script outside Head +<Head> + <title>Page + + + +// Good: Next.js component +import { GoogleAnalytics } from '@next/third-parties/google' + +export default function Layout({ children }) { + return ( + + {children} + + + ) +} +``` + +## Google Tag Manager + +```tsx +import { GoogleTagManager } from '@next/third-parties/google'; + +export default function Layout({ children }) { + return ( + + + {children} + + ); +} +``` + +## Other Third-Party Scripts + +```tsx +// YouTube embed +import { YouTubeEmbed } from '@next/third-parties/google'; + +; + +// Google Maps +import { GoogleMapsEmbed } from '@next/third-parties/google'; + +; +``` + +## Quick Reference + +| Pattern | Issue | Fix | +| --------------------------------------------- | -------------------------- | ------------------------- | +| `'); + }); + + // Modify response + nitroApp.hooks.hook('render:response', (response, { event }) => { + console.log('Sending response:', response.statusCode); + }); + + // Before request + nitroApp.hooks.hook('request', (event) => { + console.log('Request:', event.path); + }); + + // After response + nitroApp.hooks.hook('afterResponse', (event) => { + console.log('Response sent'); + }); +}); +``` + +### Common Nitro Hooks + +| Hook | When | +| ----------------- | ---------------------------- | +| `request` | Request received | +| `beforeResponse` | Before sending response | +| `afterResponse` | After response sent | +| `render:html` | Before HTML is sent | +| `render:response` | Before response is finalized | +| `error` | Error occurred | + +## Custom Hooks + +### Define Custom Hook Types + +```ts +// types/hooks.d.ts +import type { HookResult } from '@nuxt/schema'; + +declare module '#app' { + interface RuntimeNuxtHooks { + 'my-app:event': (data: MyEventData) => HookResult; + } +} + +declare module '@nuxt/schema' { + interface NuxtHooks { + 'my-module:init': () => HookResult; + } +} + +declare module 'nitropack/types' { + interface NitroRuntimeHooks { + 'my-server:event': (data: any) => void; + } +} +``` + +### Call Custom Hooks + +```ts +// In a plugin +export default defineNuxtPlugin((nuxtApp) => { + // Call custom hook + nuxtApp.callHook('my-app:event', { type: 'custom' }); +}); + +// In a module +export default defineNuxtModule({ + setup(options, nuxt) { + nuxt.callHook('my-module:init'); + }, +}); +``` + +## useRuntimeHook + +Call hooks at runtime from components: + +```vue + +``` + +## Hook Examples + +### Page View Tracking + +```ts +// plugins/analytics.client.ts +export default defineNuxtPlugin((nuxtApp) => { + nuxtApp.hook('page:finish', () => { + const route = useRoute(); + analytics.track('pageview', { + path: route.path, + title: document.title, + }); + }); +}); +``` + +### Performance Monitoring + +```ts +// plugins/performance.client.ts +export default defineNuxtPlugin((nuxtApp) => { + let navigationStart: number; + + nuxtApp.hook('page:start', () => { + navigationStart = performance.now(); + }); + + nuxtApp.hook('page:finish', () => { + const duration = performance.now() - navigationStart; + console.log(`Navigation took ${duration}ms`); + }); +}); +``` + +### Inject HTML + +```ts +// server/plugins/inject.ts +export default defineNitroPlugin((nitroApp) => { + nitroApp.hooks.hook('render:html', (html) => { + html.head.push(` + + `); + }); +}); +``` + + diff --git a/packages/mosaic/framework/skills/nuxt/references/advanced-layers.md b/packages/mosaic/framework/skills/nuxt/references/advanced-layers.md new file mode 100644 index 00000000..be559bb5 --- /dev/null +++ b/packages/mosaic/framework/skills/nuxt/references/advanced-layers.md @@ -0,0 +1,290 @@ +--- +name: nuxt-layers +description: Extending Nuxt applications with layers for code sharing and reusability +--- + +# Nuxt Layers + +Layers allow sharing and reusing partial Nuxt applications across projects. They can include components, composables, pages, layouts, and configuration. + +## Using Layers + +### From npm Package + +```ts +// nuxt.config.ts +export default defineNuxtConfig({ + extends: ['@my-org/base-layer', '@nuxtjs/ui-layer'], +}); +``` + +### From Git Repository + +```ts +// nuxt.config.ts +export default defineNuxtConfig({ + extends: [ + 'github:username/repo', + 'github:username/repo/base', // Subdirectory + 'github:username/repo#v1.0', // Specific tag + 'github:username/repo#dev', // Branch + 'gitlab:username/repo', + 'bitbucket:username/repo', + ], +}); +``` + +### From Local Directory + +```ts +// nuxt.config.ts +export default defineNuxtConfig({ + extends: ['../base-layer', './layers/shared'], +}); +``` + +### Auto-scanned Layers + +Place in `layers/` directory for automatic discovery: + +``` +my-app/ +├── layers/ +│ ├── base/ +│ │ └── nuxt.config.ts +│ └── ui/ +│ └── nuxt.config.ts +└── nuxt.config.ts +``` + +## Creating a Layer + +Minimal layer structure: + +``` +my-layer/ +├── nuxt.config.ts # Required +├── app/ +│ ├── components/ # Auto-merged +│ ├── composables/ # Auto-merged +│ ├── layouts/ # Auto-merged +│ ├── middleware/ # Auto-merged +│ ├── pages/ # Auto-merged +│ ├── plugins/ # Auto-merged +│ └── app.config.ts # Merged +├── server/ # Auto-merged +└── package.json +``` + +### Layer nuxt.config.ts + +```ts +// my-layer/nuxt.config.ts +export default defineNuxtConfig({ + // Layer configuration + app: { + head: { + title: 'My Layer App', + }, + }, + // Shared modules + modules: ['@nuxt/ui'], +}); +``` + +### Layer Components + +```vue + + +``` + +Use in consuming project: + +```vue + +``` + +### Layer Composables + +```ts +// my-layer/app/composables/useTheme.ts +export function useTheme() { + const isDark = useState('theme-dark', () => false); + const toggle = () => (isDark.value = !isDark.value); + return { isDark, toggle }; +} +``` + +## Layer Priority + +Override order (highest to lowest): + +1. Your project files +2. Auto-scanned layers (alphabetically, Z > A) +3. `extends` array (first > last) + +Control order with prefixes: + +``` +layers/ +├── 1.base/ # Lower priority +└── 2.theme/ # Higher priority +``` + +## Layer Aliases + +Access layer files: + +```ts +// Auto-scanned layers get aliases +import Component from '#layers/base/components/Component.vue'; +``` + +Named aliases: + +```ts +// my-layer/nuxt.config.ts +export default defineNuxtConfig({ + $meta: { + name: 'my-layer', + }, +}); +``` + +```ts +// In consuming project +import { something } from '#layers/my-layer/utils'; +``` + +## Publishing Layers + +### As npm Package + +```json +{ + "name": "my-nuxt-layer", + "version": "1.0.0", + "type": "module", + "main": "./nuxt.config.ts", + "dependencies": { + "@nuxt/ui": "^2.0.0" + }, + "devDependencies": { + "nuxt": "^3.0.0" + } +} +``` + +### Private Layers + +For private git repos: + +```bash +export GIGET_AUTH= +``` + +## Layer Best Practices + +### Use Resolved Paths + +```ts +// my-layer/nuxt.config.ts +import { fileURLToPath } from 'node:url'; +import { dirname, join } from 'node:path'; + +const currentDir = dirname(fileURLToPath(import.meta.url)); + +export default defineNuxtConfig({ + css: [join(currentDir, './assets/main.css')], +}); +``` + +### Install Dependencies + +```ts +// nuxt.config.ts +export default defineNuxtConfig({ + extends: [['github:user/layer', { install: true }]], +}); +``` + +### Disable Layer Modules + +```ts +// nuxt.config.ts +export default defineNuxtConfig({ + extends: ['./base-layer'], + // Disable modules from layer + image: false, // Disables @nuxt/image + pinia: false, // Disables @pinia/nuxt +}); +``` + +## Starter Template + +Create a new layer: + +```bash +npx nuxi init --template layer my-layer +``` + +## Example: Theme Layer + +``` +theme-layer/ +├── nuxt.config.ts +├── app/ +│ ├── app.config.ts +│ ├── components/ +│ │ ├── ThemeButton.vue +│ │ └── ThemeCard.vue +│ ├── composables/ +│ │ └── useTheme.ts +│ └── assets/ +│ └── theme.css +└── package.json +``` + +```ts +// theme-layer/nuxt.config.ts +export default defineNuxtConfig({ + css: ['~/assets/theme.css'], +}); +``` + +```ts +// theme-layer/app/app.config.ts +export default defineAppConfig({ + theme: { + primaryColor: '#00dc82', + darkMode: false, + }, +}); +``` + +```ts +// consuming-app/nuxt.config.ts +export default defineNuxtConfig({ + extends: ['theme-layer'], +}); + +// consuming-app/app/app.config.ts +export default defineAppConfig({ + theme: { + primaryColor: '#ff0000', // Override + }, +}); +``` + + diff --git a/packages/mosaic/framework/skills/nuxt/references/advanced-module-authoring.md b/packages/mosaic/framework/skills/nuxt/references/advanced-module-authoring.md new file mode 100644 index 00000000..63ab59c4 --- /dev/null +++ b/packages/mosaic/framework/skills/nuxt/references/advanced-module-authoring.md @@ -0,0 +1,551 @@ +--- +name: module-authoring +description: Complete guide to creating publishable Nuxt modules with best practices +--- + +# Module Authoring + +This guide covers creating publishable Nuxt modules with proper structure, type safety, and best practices. + +## Module Structure + +Recommended structure for a publishable module: + +``` +my-nuxt-module/ +├── src/ +│ ├── module.ts # Module entry +│ └── runtime/ +│ ├── components/ # Vue components +│ ├── composables/ # Composables +│ ├── plugins/ # Nuxt plugins +│ └── server/ # Server handlers +├── playground/ # Development app +├── package.json +└── tsconfig.json +``` + +## Module Definition + +### Basic Module with Type-safe Options + +```ts +// src/module.ts +import { defineNuxtModule, createResolver, addPlugin, addComponent, addImports } from '@nuxt/kit'; + +export interface ModuleOptions { + prefix?: string; + apiKey: string; + enabled?: boolean; +} + +export default defineNuxtModule({ + meta: { + name: 'my-module', + configKey: 'myModule', + compatibility: { + nuxt: '>=3.0.0', + }, + }, + defaults: { + prefix: 'My', + enabled: true, + }, + setup(options, nuxt) { + if (!options.enabled) return; + + const { resolve } = createResolver(import.meta.url); + + // Module setup logic here + }, +}); +``` + +### Using `.with()` for Strict Type Inference + +When you need TypeScript to infer that default values are always present: + +```ts +import { defineNuxtModule } from '@nuxt/kit'; + +interface ModuleOptions { + apiKey: string; + baseURL: string; + timeout?: number; +} + +export default defineNuxtModule().with({ + meta: { + name: '@nuxtjs/my-api', + configKey: 'myApi', + }, + defaults: { + baseURL: 'https://api.example.com', + timeout: 5000, + }, + setup(resolvedOptions, nuxt) { + // resolvedOptions.baseURL is guaranteed to be string (not undefined) + // resolvedOptions.timeout is guaranteed to be number (not undefined) + }, +}); +``` + +## Adding Runtime Assets + +### Components + +```ts +import { addComponent, addComponentsDir, createResolver } from '@nuxt/kit'; + +export default defineNuxtModule({ + setup() { + const { resolve } = createResolver(import.meta.url); + + // Single component + addComponent({ + name: 'MyButton', + filePath: resolve('./runtime/components/MyButton.vue'), + }); + + // Component directory with prefix + addComponentsDir({ + path: resolve('./runtime/components'), + prefix: 'My', + pathPrefix: false, + }); + }, +}); +``` + +### Composables and Auto-imports + +```ts +import { addImports, addImportsDir, createResolver } from '@nuxt/kit'; + +export default defineNuxtModule({ + setup() { + const { resolve } = createResolver(import.meta.url); + + // Single import + addImports({ + name: 'useMyUtil', + from: resolve('./runtime/composables/useMyUtil'), + }); + + // Directory of composables + addImportsDir(resolve('./runtime/composables')); + }, +}); +``` + +### Plugins + +```ts +import { addPlugin, addPluginTemplate, createResolver } from '@nuxt/kit'; + +export default defineNuxtModule({ + setup(options) { + const { resolve } = createResolver(import.meta.url); + + // Static plugin file + addPlugin({ + src: resolve('./runtime/plugins/myPlugin'), + mode: 'client', // 'client', 'server', or 'all' + }); + + // Dynamic plugin with generated code + addPluginTemplate({ + filename: 'my-module-plugin.mjs', + getContents: () => ` +import { defineNuxtPlugin } from '#app/nuxt' + +export default defineNuxtPlugin({ + name: 'my-module', + setup() { + const config = ${JSON.stringify(options)} + // Plugin logic + } +})`, + }); + }, +}); +``` + +## Server Extensions + +### Server Handlers + +```ts +import { addServerHandler, addServerScanDir, createResolver } from '@nuxt/kit'; + +export default defineNuxtModule({ + setup() { + const { resolve } = createResolver(import.meta.url); + + // Single handler + addServerHandler({ + route: '/api/my-endpoint', + handler: resolve('./runtime/server/api/my-endpoint'), + }); + + // Scan entire server directory (api/, routes/, middleware/, utils/) + addServerScanDir(resolve('./runtime/server')); + }, +}); +``` + +### Server Composables + +```ts +import { addServerImports, addServerImportsDir, createResolver } from '@nuxt/kit'; + +export default defineNuxtModule({ + setup() { + const { resolve } = createResolver(import.meta.url); + + // Single server import + addServerImports({ + name: 'useServerUtil', + from: resolve('./runtime/server/utils/useServerUtil'), + }); + + // Server composables directory + addServerImportsDir(resolve('./runtime/server/composables')); + }, +}); +``` + +### Nitro Plugin + +```ts +import { addServerPlugin, createResolver } from '@nuxt/kit'; + +export default defineNuxtModule({ + setup() { + const { resolve } = createResolver(import.meta.url); + addServerPlugin(resolve('./runtime/server/plugin')); + }, +}); +``` + +```ts +// runtime/server/plugin.ts +import { defineNitroPlugin } from 'nitropack/runtime'; + +export default defineNitroPlugin((nitroApp) => { + nitroApp.hooks.hook('request', (event) => { + console.log('Request:', event.path); + }); +}); +``` + +## Templates and Virtual Files + +### Generate Virtual Files + +```ts +import { addTemplate, addTypeTemplate, addServerTemplate, createResolver } from '@nuxt/kit'; + +export default defineNuxtModule({ + setup(options, nuxt) { + const { resolve } = createResolver(import.meta.url); + + // Client/build virtual file (accessible via #build/my-config.mjs) + addTemplate({ + filename: 'my-config.mjs', + getContents: () => `export default ${JSON.stringify(options)}`, + }); + + // Type declarations + addTypeTemplate({ + filename: 'types/my-module.d.ts', + getContents: () => ` +declare module '#my-module' { + export interface Config { + apiKey: string + } +}`, + }); + + // Nitro virtual file (accessible in server routes) + addServerTemplate({ + filename: '#my-module/config.mjs', + getContents: () => `export const config = ${JSON.stringify(options)}`, + }); + }, +}); +``` + +### Access Virtual Files + +```ts +// In runtime plugin +// @ts-expect-error - virtual file +import config from '#build/my-config.mjs'; + +// In server routes +import { config } from '#my-module/config.js'; +``` + +## Extending Pages and Routes + +```ts +import { extendPages, extendRouteRules, addRouteMiddleware, createResolver } from '@nuxt/kit'; + +export default defineNuxtModule({ + setup() { + const { resolve } = createResolver(import.meta.url); + + // Add pages + extendPages((pages) => { + pages.push({ + name: 'my-page', + path: '/my-route', + file: resolve('./runtime/pages/MyPage.vue'), + }); + }); + + // Add route rules (caching, redirects, etc.) + extendRouteRules('/api/**', { + cache: { maxAge: 60 }, + }); + + // Add middleware + addRouteMiddleware({ + name: 'my-middleware', + path: resolve('./runtime/middleware/myMiddleware'), + global: true, + }); + }, +}); +``` + +## Module Dependencies + +Declare dependencies on other modules with version constraints: + +```ts +export default defineNuxtModule({ + meta: { + name: 'my-module', + }, + moduleDependencies: { + '@nuxtjs/tailwindcss': { + version: '>=6.0.0', + // Set defaults (user can override) + defaults: { + exposeConfig: true, + }, + // Force specific options + overrides: { + viewer: false, + }, + }, + '@nuxtjs/i18n': { + optional: true, // Won't fail if not installed + defaults: { + defaultLocale: 'en', + }, + }, + }, + setup() { + // Dependencies are guaranteed to be set up before this runs + }, +}); +``` + +### Dynamic Dependencies + +```ts +moduleDependencies(nuxt) { + const deps: Record = { + '@nuxtjs/tailwindcss': { version: '>=6.0.0' }, + } + + if (nuxt.options.ssr) { + deps['@nuxtjs/html-validator'] = { optional: true } + } + + return deps +} +``` + +## Lifecycle Hooks + +Requires `meta.name` and `meta.version`: + +```ts +export default defineNuxtModule({ + meta: { + name: 'my-module', + version: '1.2.0', + }, + onInstall(nuxt) { + // First-time setup + console.log('Module installed for the first time'); + }, + onUpgrade(nuxt, options, previousVersion) { + // Version upgrade migrations + console.log(`Upgrading from ${previousVersion}`); + }, + setup(options, nuxt) { + // Regular setup runs every build + }, +}); +``` + +## Extending Configuration + +```ts +export default defineNuxtModule({ + setup(options, nuxt) { + // Add CSS + nuxt.options.css.push('my-module/styles.css'); + + // Add runtime config + nuxt.options.runtimeConfig.public.myModule = { + apiUrl: options.apiUrl, + }; + + // Extend Vite config + nuxt.options.vite.optimizeDeps ||= {}; + nuxt.options.vite.optimizeDeps.include ||= []; + nuxt.options.vite.optimizeDeps.include.push('some-package'); + + // Add build transpile + nuxt.options.build.transpile.push('my-package'); + }, +}); +``` + +## Using Hooks + +```ts +export default defineNuxtModule({ + // Declarative hooks + hooks: { + 'components:dirs': (dirs) => { + dirs.push({ path: '~/extra' }); + }, + }, + + setup(options, nuxt) { + // Programmatic hooks + nuxt.hook('pages:extend', (pages) => { + // Modify pages + }); + + nuxt.hook('imports:extend', (imports) => { + imports.push({ name: 'myHelper', from: 'my-package' }); + }); + + nuxt.hook('nitro:config', (config) => { + // Modify Nitro config + }); + + nuxt.hook('vite:extendConfig', (config) => { + // Modify Vite config + }); + }, +}); +``` + +## Path Resolution + +```ts +import { createResolver, resolvePath, findPath } from '@nuxt/kit'; + +export default defineNuxtModule({ + async setup(options, nuxt) { + // Resolver relative to module + const { resolve } = createResolver(import.meta.url); + + const pluginPath = resolve('./runtime/plugin'); + + // Resolve with extensions and aliases + const entrypoint = await resolvePath('@some/package'); + + // Find first existing file + const configPath = await findPath([resolve('./config.ts'), resolve('./config.js')]); + }, +}); +``` + +## Module Package.json + +```json +{ + "name": "my-nuxt-module", + "version": "1.0.0", + "type": "module", + "exports": { + ".": { + "import": "./dist/module.mjs", + "require": "./dist/module.cjs" + } + }, + "main": "./dist/module.cjs", + "module": "./dist/module.mjs", + "types": "./dist/types.d.ts", + "files": ["dist"], + "scripts": { + "dev": "nuxi dev playground", + "build": "nuxt-module-build build", + "prepare": "nuxt-module-build build --stub" + }, + "dependencies": { + "@nuxt/kit": "^3.0.0" + }, + "devDependencies": { + "@nuxt/module-builder": "latest", + "nuxt": "^3.0.0" + } +} +``` + +## Disabling Modules + +Users can disable a module via config key: + +```ts +// nuxt.config.ts +export default defineNuxtConfig({ + // Disable entirely + myModule: false, + + // Or with options + myModule: { + enabled: false, + }, +}); +``` + +## Development Workflow + +1. **Create module**: `npx nuxi init -t module my-module` +2. **Develop**: `npm run dev` (runs playground) +3. **Build**: `npm run build` +4. **Test**: `npm run test` + +## Best Practices + +- Use `createResolver(import.meta.url)` for all path resolution +- Prefix components to avoid naming conflicts +- Make options type-safe with `ModuleOptions` interface +- Use `moduleDependencies` instead of `installModule` +- Provide sensible defaults for all options +- Add compatibility requirements in `meta.compatibility` +- Use virtual files for dynamic configuration +- Separate client/server plugins appropriately + + diff --git a/packages/mosaic/framework/skills/nuxt/references/best-practices-data-fetching.md b/packages/mosaic/framework/skills/nuxt/references/best-practices-data-fetching.md new file mode 100644 index 00000000..12a8717d --- /dev/null +++ b/packages/mosaic/framework/skills/nuxt/references/best-practices-data-fetching.md @@ -0,0 +1,354 @@ +--- +name: data-fetching-best-practices +description: Patterns and best practices for efficient data fetching in Nuxt +--- + +# Data Fetching Best Practices + +Effective data fetching patterns for SSR-friendly, performant Nuxt applications. + +## Choose the Right Tool + +| Scenario | Use | +| --------------------------------- | ----------------------------------- | +| Component initial data | `useFetch` or `useAsyncData` | +| User interactions (clicks, forms) | `$fetch` | +| Third-party SDK/API | `useAsyncData` with custom function | +| Multiple parallel requests | `useAsyncData` with `Promise.all` | + +## Await vs Non-Await Usage + +The `await` keyword controls whether data fetching **blocks navigation**: + +### With `await` - Blocking Navigation + +```vue + +``` + +- **Server**: Fetches data and includes it in the payload +- **Client hydration**: Uses payload data, no re-fetch +- **Client navigation**: Blocks until data is ready + +### Without `await` - Non-Blocking (Lazy) + +```vue + + + +``` + +Equivalent to using `useLazyFetch`: + +```vue + +``` + +### When to Use Each + +| Pattern | Use Case | +| -------------------------- | ----------------------------------------------- | +| `await useFetch()` | Critical data needed for SEO/initial render | +| `useFetch({ lazy: true })` | Non-critical data, better perceived performance | +| `await useLazyFetch()` | Same as lazy, await only ensures initialization | + +## Avoid Double Fetching + +### ❌ Wrong: Using $fetch Alone in Setup + +```vue + +``` + +### ✅ Correct: Use useFetch + +```vue + +``` + +## Use Explicit Cache Keys + +### ❌ Avoid: Auto-generated Keys + +```vue + +``` + +### ✅ Better: Explicit Keys + +```vue + +``` + +## Handle Loading States Properly + +```vue + + + +``` + +## Use Lazy Fetching for Non-critical Data + +```vue + + + +``` + +## Minimize Payload Size + +### Use `pick` for Simple Filtering + +```vue + +``` + +### Use `transform` for Complex Transformations + +```vue + +``` + +## Parallel Fetching + +### Fetch Independent Data with useAsyncData + +```vue + +``` + +### Multiple useFetch Calls + +```vue + +``` + +## Efficient Refresh Patterns + +### Watch Reactive Dependencies + +```vue + +``` + +### Manual Refresh + +```vue + +``` + +### Conditional Fetching + +```vue + +``` + +## Server-only Fetching + +```vue + +``` + +## Error Handling + +```vue + + + +``` + +## Shared Data Across Components + +```vue + + + + + +``` + +## Avoid useAsyncData for Side Effects + +### ❌ Wrong: Side Effects in useAsyncData + +```vue + +``` + +### ✅ Correct: Use callOnce for Side Effects + +```vue + +``` + + diff --git a/packages/mosaic/framework/skills/nuxt/references/best-practices-ssr.md b/packages/mosaic/framework/skills/nuxt/references/best-practices-ssr.md new file mode 100644 index 00000000..b57ffda1 --- /dev/null +++ b/packages/mosaic/framework/skills/nuxt/references/best-practices-ssr.md @@ -0,0 +1,357 @@ +--- +name: ssr-best-practices +description: Avoiding SSR context leaks, hydration mismatches, and proper composable usage +--- + +# SSR Best Practices + +Patterns for avoiding common SSR pitfalls: context leaks, hydration mismatches, and composable errors. + +## The "Nuxt Instance Unavailable" Error + +This error occurs when calling Nuxt composables outside the proper context. + +### ❌ Wrong: Composable Outside Setup + +```ts +// composables/bad.ts +// Called at module level - no Nuxt context! +const config = useRuntimeConfig(); + +export function useMyComposable() { + return config.public.apiBase; +} +``` + +### ✅ Correct: Composable Inside Function + +```ts +// composables/good.ts +export function useMyComposable() { + // Called inside the composable - has context + const config = useRuntimeConfig(); + return config.public.apiBase; +} +``` + +### Valid Contexts for Composables + +Nuxt composables work in: + +- ` +``` + +### ✅ Correct: Use SSR-safe Alternatives + +```vue + +``` + +### ❌ Wrong: Random/Time-based Values + +```vue + +``` + +### ✅ Correct: Use useState for Consistency + +```vue + + + +``` + +### ❌ Wrong: Conditional Rendering on Client State + +```vue + +``` + +### ✅ Correct: Use CSS or ClientOnly + +```vue + +``` + +## Browser-only Code + +### Use `import.meta.client` + +```vue + +``` + +### Use `onMounted` for DOM Access + +```vue + +``` + +### Dynamic Imports for Browser Libraries + +```vue + +``` + +## Server-only Code + +### Use `import.meta.server` + +```vue + +``` + +### Server Components + +```vue + + + + +``` + +## Async Composable Patterns + +### ❌ Wrong: Await Before Composable + +```vue + +``` + +### ✅ Correct: Get Context First + +```vue + +``` + +## Plugin Best Practices + +### Client-only Plugins + +```ts +// plugins/analytics.client.ts +export default defineNuxtPlugin(() => { + // Only runs on client + initAnalytics(); +}); +``` + +### Server-only Plugins + +```ts +// plugins/server-init.server.ts +export default defineNuxtPlugin(() => { + // Only runs on server + initServerConnections(); +}); +``` + +### Provide/Inject Pattern + +```ts +// plugins/api.ts +export default defineNuxtPlugin(() => { + const api = createApiClient(); + + return { + provide: { + api, + }, + }; +}); +``` + +```vue + +``` + +## Third-party Library Integration + +### ❌ Wrong: Import at Top Level + +```vue + +``` + +### ✅ Correct: Dynamic Import + +```vue + +``` + +### Use ClientOnly Component + +```vue + +``` + +## Debugging SSR Issues + +### Check Rendering Context + +```vue + +``` + +### Use Nuxt DevTools + +DevTools shows payload data and hydration state. + +### Common Error Messages + +| Error | Cause | +| --------------------------- | --------------------------------------- | +| "Nuxt instance unavailable" | Composable called outside setup context | +| "Hydration mismatch" | Server/client HTML differs | +| "window is not defined" | Browser API used during SSR | +| "document is not defined" | DOM access during SSR | + + diff --git a/packages/mosaic/framework/skills/nuxt/references/core-cli.md b/packages/mosaic/framework/skills/nuxt/references/core-cli.md new file mode 100644 index 00000000..9ffec5a7 --- /dev/null +++ b/packages/mosaic/framework/skills/nuxt/references/core-cli.md @@ -0,0 +1,264 @@ +--- +name: cli-commands +description: Nuxt CLI commands for development, building, and project management +--- + +# CLI Commands + +Nuxt provides CLI commands via `nuxi` (or `npx nuxt`) for development, building, and project management. + +## Project Initialization + +### Create New Project + +```bash +# Interactive project creation +npx nuxi@latest init my-app + +# With specific package manager +npx nuxi@latest init my-app --packageManager pnpm + +# With modules +npx nuxi@latest init my-app --modules "@nuxt/ui,@nuxt/image" + +# From template +npx nuxi@latest init my-app --template v3 + +# Skip module selection prompt +npx nuxi@latest init my-app --no-modules +``` + +**Options:** +| Option | Description | +|--------|-------------| +| `-t, --template` | Template name | +| `--packageManager` | npm, pnpm, yarn, or bun | +| `-M, --modules` | Modules to install (comma-separated) | +| `--gitInit` | Initialize git repository | +| `--no-install` | Skip installing dependencies | + +## Development + +### Start Dev Server + +```bash +# Start development server (default: http://localhost:3000) +npx nuxt dev + +# Custom port +npx nuxt dev --port 4000 + +# Open in browser +npx nuxt dev --open + +# Listen on all interfaces (for mobile testing) +npx nuxt dev --host 0.0.0.0 + +# With HTTPS +npx nuxt dev --https + +# Clear console on restart +npx nuxt dev --clear + +# Create public tunnel +npx nuxt dev --tunnel +``` + +**Options:** +| Option | Description | +|--------|-------------| +| `-p, --port` | Port to listen on | +| `-h, --host` | Host to listen on | +| `-o, --open` | Open in browser | +| `--https` | Enable HTTPS | +| `--tunnel` | Create public tunnel (via untun) | +| `--qr` | Show QR code for mobile | +| `--clear` | Clear console on restart | + +**Environment Variables:** + +- `NUXT_PORT` or `PORT` - Default port +- `NUXT_HOST` or `HOST` - Default host + +## Building + +### Production Build + +```bash +# Build for production +npx nuxt build + +# Build with prerendering +npx nuxt build --prerender + +# Build with specific preset +npx nuxt build --preset node-server +npx nuxt build --preset cloudflare-pages +npx nuxt build --preset vercel + +# Build with environment +npx nuxt build --envName staging +``` + +Output is created in `.output/` directory. + +### Static Generation + +```bash +# Generate static site (prerenders all routes) +npx nuxt generate +``` + +Equivalent to `nuxt build --prerender`. Creates static HTML files for deployment to static hosting. + +### Preview Production Build + +```bash +# Preview after build +npx nuxt preview + +# Custom port +npx nuxt preview --port 4000 +``` + +## Utilities + +### Prepare (Type Generation) + +```bash +# Generate TypeScript types and .nuxt directory +npx nuxt prepare +``` + +Run after cloning or when types are missing. + +### Type Check + +```bash +# Run TypeScript type checking +npx nuxt typecheck +``` + +### Analyze Bundle + +```bash +# Analyze production bundle +npx nuxt analyze +``` + +Opens visual bundle analyzer. + +### Cleanup + +```bash +# Remove generated files (.nuxt, .output, node_modules/.cache) +npx nuxt cleanup +``` + +### Info + +```bash +# Show environment info (useful for bug reports) +npx nuxt info +``` + +### Upgrade + +```bash +# Upgrade Nuxt to latest version +npx nuxt upgrade + +# Upgrade to nightly release +npx nuxt upgrade --nightly +``` + +## Module Commands + +### Add Module + +```bash +# Add a Nuxt module +npx nuxt module add @nuxt/ui +npx nuxt module add @nuxt/image +``` + +Installs and adds to `nuxt.config.ts`. + +### Build Module (for module authors) + +```bash +# Build a Nuxt module +npx nuxt build-module +``` + +## DevTools + +```bash +# Enable DevTools globally +npx nuxt devtools enable + +# Disable DevTools +npx nuxt devtools disable +``` + +## Common Workflows + +### Development + +```bash +# Install dependencies and start dev +pnpm install +pnpm dev # or npx nuxt dev +``` + +### Production Deployment + +```bash +# Build and preview locally +pnpm build +pnpm preview + +# Or for static hosting +pnpm generate +``` + +### After Cloning + +```bash +# Install deps and prepare types +pnpm install +npx nuxt prepare +``` + +## Environment-specific Builds + +```bash +# Development build +npx nuxt build --envName development + +# Staging build +npx nuxt build --envName staging + +# Production build (default) +npx nuxt build --envName production +``` + +Corresponds to `$development`, `$env.staging`, `$production` in `nuxt.config.ts`. + +## Layer Extension + +```bash +# Dev with additional layer +npx nuxt dev --extends ./base-layer + +# Build with layer +npx nuxt build --extends ./base-layer +``` + + diff --git a/packages/mosaic/framework/skills/nuxt/references/core-config.md b/packages/mosaic/framework/skills/nuxt/references/core-config.md new file mode 100644 index 00000000..d7548950 --- /dev/null +++ b/packages/mosaic/framework/skills/nuxt/references/core-config.md @@ -0,0 +1,162 @@ +--- +name: configuration +description: Nuxt configuration files including nuxt.config.ts, app.config.ts, and runtime configuration +--- + +# Nuxt Configuration + +Nuxt uses configuration files to customize application behavior. The main configuration options are `nuxt.config.ts` for build-time settings and `app.config.ts` for runtime settings. + +## nuxt.config.ts + +The main configuration file at the root of your project: + +```ts +// nuxt.config.ts +export default defineNuxtConfig({ + // Configuration options + devtools: { enabled: true }, + modules: ['@nuxt/ui'], +}); +``` + +### Environment Overrides + +Configure environment-specific settings: + +```ts +export default defineNuxtConfig({ + $production: { + routeRules: { + '/**': { isr: true }, + }, + }, + $development: { + // Development-specific config + }, + $env: { + staging: { + // Staging environment config + }, + }, +}); +``` + +Use `--envName` flag to select environment: `nuxt build --envName staging` + +## Runtime Config + +For values that need to be overridden via environment variables: + +```ts +// nuxt.config.ts +export default defineNuxtConfig({ + runtimeConfig: { + // Server-only keys + apiSecret: '123', + // Keys within public are exposed to client + public: { + apiBase: '/api', + }, + }, +}); +``` + +Override with environment variables: + +```ini +# .env +NUXT_API_SECRET=api_secret_token +NUXT_PUBLIC_API_BASE=https://api.example.com +``` + +Access in components/composables: + +```vue + +``` + +## App Config + +For public tokens determined at build time (not overridable via env vars): + +```ts +// app/app.config.ts +export default defineAppConfig({ + title: 'Hello Nuxt', + theme: { + dark: true, + colors: { + primary: '#ff0000', + }, + }, +}); +``` + +Access in components: + +```vue + +``` + +## runtimeConfig vs app.config + +| Feature | runtimeConfig | app.config | +| ---------------------- | ------------- | ---------- | +| Client-side | Hydrated | Bundled | +| Environment variables | Yes | No | +| Reactive | Yes | Yes | +| Hot module replacement | No | Yes | +| Non-primitive JS types | No | Yes | + +**Use runtimeConfig** for secrets and values that change per environment. +**Use app.config** for public tokens, theme settings, and non-sensitive config. + +## External Tool Configuration + +Nuxt uses `nuxt.config.ts` as single source of truth. Configure external tools within it: + +```ts +export default defineNuxtConfig({ + // Nitro configuration + nitro: { + // nitro options + }, + // Vite configuration + vite: { + // vite options + vue: { + // @vitejs/plugin-vue options + }, + }, + // PostCSS configuration + postcss: { + // postcss options + }, +}); +``` + +## Vue Configuration + +Enable Vue experimental features: + +```ts +export default defineNuxtConfig({ + vue: { + propsDestructure: true, + }, +}); +``` + + diff --git a/packages/mosaic/framework/skills/nuxt/references/core-data-fetching.md b/packages/mosaic/framework/skills/nuxt/references/core-data-fetching.md new file mode 100644 index 00000000..695d2182 --- /dev/null +++ b/packages/mosaic/framework/skills/nuxt/references/core-data-fetching.md @@ -0,0 +1,233 @@ +--- +name: data-fetching +description: useFetch, useAsyncData, and $fetch for SSR-friendly data fetching +--- + +# Data Fetching + +Nuxt provides composables for SSR-friendly data fetching that prevent double-fetching and handle hydration. + +## Overview + +- `$fetch` - Basic fetch utility (use for client-side events) +- `useFetch` - SSR-safe wrapper around $fetch (use for component data) +- `useAsyncData` - SSR-safe wrapper for any async function + +## useFetch + +Primary composable for fetching data in components: + +```vue + + + +``` + +### With Options + +```ts +const { data } = await useFetch('/api/posts', { + // Query parameters + query: { page: 1, limit: 10 }, + // Request body (for POST/PUT) + body: { title: 'New Post' }, + // HTTP method + method: 'POST', + // Only pick specific fields + pick: ['id', 'title'], + // Transform response + transform: (posts) => posts.map((p) => ({ ...p, slug: slugify(p.title) })), + // Custom key for caching + key: 'posts-list', + // Don't fetch on server + server: false, + // Don't block navigation + lazy: true, + // Don't fetch immediately + immediate: false, + // Default value + default: () => [], +}); +``` + +### Reactive Parameters + +```vue + +``` + +### Computed URL + +```vue + +``` + +## useAsyncData + +For wrapping any async function: + +```vue + +``` + +### Multiple Requests + +```vue + +``` + +## $fetch + +For client-side events (form submissions, button clicks): + +```vue + +``` + +**Important**: Don't use `$fetch` alone in setup for initial data - it will fetch twice (server + client). Use `useFetch` or `useAsyncData` instead. + +## Return Values + +All composables return: + +| Property | Type | Description | +| --------- | -------------------------------------------------- | ----------------------- | +| `data` | `Ref` | Fetched data | +| `error` | `Ref` | Error if request failed | +| `status` | `Ref<'idle' \| 'pending' \| 'success' \| 'error'>` | Request status | +| `refresh` | `() => Promise` | Refetch data | +| `execute` | `() => Promise` | Alias for refresh | +| `clear` | `() => void` | Reset data and error | + +## Lazy Fetching + +Don't block navigation: + +```vue + +``` + +## Refresh & Watch + +```vue + +``` + +## Caching + +Data is cached by key. Share data across components: + +```vue + +``` + +Refresh cached data globally: + +```ts +// Refresh specific key +await refreshNuxtData('current-user'); + +// Refresh all data +await refreshNuxtData(); + +// Clear cached data +clearNuxtData('current-user'); +``` + +## Interceptors + +```ts +const { data } = await useFetch('/api/auth', { + onRequest({ options }) { + options.headers.set('Authorization', `Bearer ${token}`); + }, + onRequestError({ error }) { + console.error('Request failed:', error); + }, + onResponse({ response }) { + // Process response + }, + onResponseError({ response }) { + if (response.status === 401) { + navigateTo('/login'); + } + }, +}); +``` + +## Passing Headers (SSR) + +`useFetch` automatically proxies cookies/headers from client to server. For `$fetch`: + +```vue + +``` + + diff --git a/packages/mosaic/framework/skills/nuxt/references/core-deployment.md b/packages/mosaic/framework/skills/nuxt/references/core-deployment.md new file mode 100644 index 00000000..249c9994 --- /dev/null +++ b/packages/mosaic/framework/skills/nuxt/references/core-deployment.md @@ -0,0 +1,233 @@ +--- +name: deployment +description: Deploying Nuxt applications to various hosting platforms +--- + +# Deployment + +Nuxt is platform-agnostic thanks to [Nitro](https://nitro.build), its server engine. You can deploy to almost any platform with minimal configuration—Node.js servers, static hosting, serverless functions, or edge networks. + +> **Full list of supported platforms:** https://nitro.build/deploy + +## Deployment Modes + +### Node.js Server + +```bash +# Build for Node.js +nuxt build + +# Run production server +node .output/server/index.mjs +``` + +Environment variables: + +- `PORT` or `NITRO_PORT` (default: 3000) +- `HOST` or `NITRO_HOST` (default: 0.0.0.0) + +### Static Generation + +```bash +# Generate static site +nuxt generate +``` + +Output in `.output/public/` - deploy to any static host. + +### Preset Configuration + +```ts +// nuxt.config.ts +export default defineNuxtConfig({ + nitro: { + preset: 'vercel', // or 'netlify', 'cloudflare-pages', etc. + }, +}); +``` + +Or via environment variable: + +```bash +NITRO_PRESET=vercel nuxt build +``` + +--- + +## Recommended Platforms + +When helping users choose a deployment platform, consider their needs: + +### Vercel + +**Best for:** Projects wanting zero-config deployment with excellent DX + +```bash +# Install Vercel CLI +npm i -g vercel + +# Deploy +vercel +``` + +**Pros:** + +- Zero configuration for Nuxt (auto-detects) +- Excellent preview deployments for PRs +- Built-in analytics and speed insights +- Edge Functions support +- Great free tier for personal projects + +**Cons:** + +- Can get expensive at scale (bandwidth costs) +- Vendor lock-in concerns +- Limited build minutes on free tier + +**Recommended when:** User wants fastest setup, values DX, building SaaS or marketing sites. + +--- + +### Netlify + +**Best for:** JAMstack sites, static-heavy apps, teams needing forms/identity + +```bash +# Install Netlify CLI +npm i -g netlify-cli + +# Deploy +netlify deploy --prod +``` + +**Pros:** + +- Great free tier with generous bandwidth +- Built-in forms, identity, and functions +- Excellent for static sites with some dynamic features +- Good preview deployments +- Split testing built-in + +**Cons:** + +- SSR/serverless functions can be slower than Vercel +- Less optimized for full SSR apps +- Build minutes can run out on free tier + +**Recommended when:** User has static-heavy site, needs built-in forms/auth, or prefers Netlify ecosystem. + +--- + +### Cloudflare Pages + +**Best for:** Global performance, edge computing, cost-conscious projects + +```bash +# Build with Cloudflare preset +NITRO_PRESET=cloudflare-pages nuxt build +``` + +**Pros:** + +- Unlimited bandwidth on free tier +- Excellent global edge network (fastest TTFB) +- Workers for edge computing +- Very cost-effective at scale +- D1, KV, R2 for data storage + +**Cons:** + +- Workers have execution limits (CPU time) +- Some Node.js APIs not available in Workers +- Less mature than Vercel/Netlify for frameworks + +**Recommended when:** User prioritizes performance, global reach, or cost at scale. + +--- + +### GitHub Actions + Self-hosted/VPS + +**Best for:** Full control, existing infrastructure, CI/CD customization + +```yaml +# .github/workflows/deploy.yml +name: Deploy +on: + push: + branches: [main] + +jobs: + deploy: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version: 20 + + - run: npm ci + - run: npm run build + + # Deploy to your server (example: rsync to VPS) + - name: Deploy to server + run: rsync -avz .output/ user@server:/app/ +``` + +**Pros:** + +- Full control over build and deployment +- No vendor lock-in +- Can deploy anywhere (VPS, Docker, Kubernetes) +- Free CI/CD minutes for public repos +- Customizable workflows + +**Cons:** + +- Requires more setup and maintenance +- Need to manage your own infrastructure +- No built-in preview deployments +- SSL, scaling, monitoring are your responsibility + +**Recommended when:** User has existing infrastructure, needs full control, or deploying to private/enterprise environments. + +--- + +## Quick Decision Guide + +| Need | Recommendation | +| ------------------------- | --------------------------------------- | +| Fastest setup, small team | **Vercel** | +| Static site with forms | **Netlify** | +| Cost-sensitive at scale | **Cloudflare Pages** | +| Full control / enterprise | **GitHub Actions + VPS** | +| Docker/Kubernetes | **GitHub Actions + Container Registry** | +| Serverless APIs | **Vercel** or **AWS Lambda** | + +## Docker Deployment + +```dockerfile +FROM node:20-alpine AS builder +WORKDIR /app +COPY package*.json ./ +RUN npm ci +COPY . . +RUN npm run build + +FROM node:20-alpine +WORKDIR /app +COPY --from=builder /app/.output .output +ENV PORT=3000 +EXPOSE 3000 +CMD ["node", ".output/server/index.mjs"] +``` + +```bash +docker build -t my-nuxt-app . +docker run -p 3000:3000 my-nuxt-app +``` + + diff --git a/packages/mosaic/framework/skills/nuxt/references/core-directory-structure.md b/packages/mosaic/framework/skills/nuxt/references/core-directory-structure.md new file mode 100644 index 00000000..09fc65da --- /dev/null +++ b/packages/mosaic/framework/skills/nuxt/references/core-directory-structure.md @@ -0,0 +1,269 @@ +--- +name: directory-structure +description: Nuxt project folder structure, conventions, and file organization +--- + +# Directory Structure + +Nuxt uses conventions-based directory structure. Understanding it is key to effective development. + +## Standard Project Structure + +``` +my-nuxt-app/ +├── app/ # Application source (can be at root level) +│ ├── app.vue # Root component +│ ├── app.config.ts # App configuration (runtime) +│ ├── error.vue # Error page +│ ├── components/ # Auto-imported Vue components +│ ├── composables/ # Auto-imported composables +│ ├── layouts/ # Layout components +│ ├── middleware/ # Route middleware +│ ├── pages/ # File-based routing +│ ├── plugins/ # Vue plugins +│ └── utils/ # Auto-imported utilities +├── assets/ # Build-processed assets (CSS, images) +├── public/ # Static assets (served as-is) +├── server/ # Server-side code +│ ├── api/ # API routes (/api/*) +│ ├── routes/ # Server routes +│ ├── middleware/ # Server middleware +│ ├── plugins/ # Nitro plugins +│ └── utils/ # Server utilities (auto-imported) +├── content/ # Content files (@nuxt/content) +├── layers/ # Local layers (auto-scanned) +├── modules/ # Local modules +├── nuxt.config.ts # Nuxt configuration +├── package.json +└── tsconfig.json +``` + +## Key Directories + +### `app/` Directory + +Contains all application code. Can also be at root level (without `app/` folder). + +```ts +// nuxt.config.ts - customize source directory +export default defineNuxtConfig({ + srcDir: 'src/', // Change from 'app/' to 'src/' +}); +``` + +### `app/components/` + +Vue components auto-imported by name: + +``` +components/ +├── Button.vue → ; +} +``` + +## One Purpose Per Package + +### Good Examples + +``` +packages/ +├── ui/ # Shared UI components +├── utils/ # General utilities +├── auth/ # Authentication logic +├── database/ # Database client/schemas +├── eslint-config/ # ESLint configuration +├── typescript-config/ # TypeScript configuration +└── api-client/ # Generated API client +``` + +### Avoid Mega-Packages + +``` +// BAD: One package for everything +packages/ +└── shared/ + ├── components/ + ├── utils/ + ├── hooks/ + ├── types/ + └── api/ + +// GOOD: Separate by purpose +packages/ +├── ui/ # Components +├── utils/ # Utilities +├── hooks/ # React hooks +├── types/ # Shared TypeScript types +└── api-client/ # API utilities +``` + +## Config Packages + +### TypeScript Config + +```json +// packages/typescript-config/package.json +{ + "name": "@repo/typescript-config", + "exports": { + "./base.json": "./base.json", + "./nextjs.json": "./nextjs.json", + "./library.json": "./library.json" + } +} +``` + +### ESLint Config + +```json +// packages/eslint-config/package.json +{ + "name": "@repo/eslint-config", + "exports": { + "./base": "./base.js", + "./next": "./next.js" + }, + "dependencies": { + "eslint": "^8.0.0", + "eslint-config-next": "latest" + } +} +``` + +## Common Mistakes + +### Forgetting to Export + +```json +// BAD: No exports defined +{ + "name": "@repo/ui" +} + +// GOOD: Clear exports +{ + "name": "@repo/ui", + "exports": { + "./button": "./src/button.tsx" + } +} +``` + +### Wrong Workspace Syntax + +```json +// pnpm/bun +{ "@repo/ui": "workspace:*" } // Correct + +// npm/yarn +{ "@repo/ui": "*" } // Correct +{ "@repo/ui": "workspace:*" } // Wrong for npm/yarn! +``` + +### Missing from turbo.json Outputs + +```json +// Package builds to dist/, but turbo.json doesn't know +{ + "tasks": { + "build": { + "outputs": [".next/**"] // Missing dist/**! + } + } +} + +// Correct +{ + "tasks": { + "build": { + "outputs": [".next/**", "dist/**"] + } + } +} +``` + +## TypeScript Best Practices + +### Use Node.js Subpath Imports (Not `paths`) + +TypeScript `compilerOptions.paths` breaks with JIT packages. Use Node.js subpath imports instead (TypeScript 5.4+). + +**JIT Package:** + +```json +// packages/ui/package.json +{ + "imports": { + "#*": "./src/*" + } +} +``` + +```typescript +// packages/ui/button.tsx +import { MY_STRING } from '#utils.ts'; // Uses .ts extension +``` + +**Compiled Package:** + +```json +// packages/ui/package.json +{ + "imports": { + "#*": "./dist/*" + } +} +``` + +```typescript +// packages/ui/button.tsx +import { MY_STRING } from '#utils.js'; // Uses .js extension +``` + +### Use `tsc` for Internal Packages + +For internal packages, prefer `tsc` over bundlers. Bundlers can mangle code before it reaches your app's bundler, causing hard-to-debug issues. + +### Enable Go-to-Definition + +For Compiled Packages, enable declaration maps: + +```json +// tsconfig.json +{ + "compilerOptions": { + "declaration": true, + "declarationMap": true + } +} +``` + +This creates `.d.ts` and `.d.ts.map` files for IDE navigation. + +### No Root tsconfig.json Needed + +Each package should have its own `tsconfig.json`. A root one causes all tasks to miss cache when changed. Only use root `tsconfig.json` for non-package scripts. + +### Avoid TypeScript Project References + +They add complexity and another caching layer. Turborepo handles dependencies better. diff --git a/packages/mosaic/framework/skills/turborepo/references/best-practices/structure.md b/packages/mosaic/framework/skills/turborepo/references/best-practices/structure.md new file mode 100644 index 00000000..78305250 --- /dev/null +++ b/packages/mosaic/framework/skills/turborepo/references/best-practices/structure.md @@ -0,0 +1,270 @@ +# Repository Structure + +Detailed guidance on structuring a Turborepo monorepo. + +## Workspace Configuration + +### pnpm (Recommended) + +```yaml +# pnpm-workspace.yaml +packages: + - 'apps/*' + - 'packages/*' +``` + +### npm/yarn/bun + +```json +// package.json +{ + "workspaces": ["apps/*", "packages/*"] +} +``` + +## Root package.json + +```json +{ + "name": "my-monorepo", + "private": true, + "packageManager": "pnpm@9.0.0", + "scripts": { + "build": "turbo run build", + "dev": "turbo run dev", + "lint": "turbo run lint", + "test": "turbo run test" + }, + "devDependencies": { + "turbo": "latest" + } +} +``` + +Key points: + +- `private: true` - Prevents accidental publishing +- `packageManager` - Enforces consistent package manager version +- **Scripts only delegate to `turbo run`** - No actual build logic here! +- Minimal devDependencies (just turbo and repo tools) + +## Always Prefer Package Tasks + +**Always use package tasks. Only use Root Tasks if you cannot succeed with package tasks.** + +```json +// packages/web/package.json +{ + "scripts": { + "build": "next build", + "lint": "eslint .", + "test": "vitest", + "typecheck": "tsc --noEmit" + } +} + +// packages/api/package.json +{ + "scripts": { + "build": "tsc", + "lint": "eslint .", + "test": "vitest", + "typecheck": "tsc --noEmit" + } +} +``` + +Package tasks enable Turborepo to: + +1. **Parallelize** - Run `web#lint` and `api#lint` simultaneously +2. **Cache individually** - Each package's task output is cached separately +3. **Filter precisely** - Run `turbo run test --filter=web` for just one package + +**Root Tasks are a fallback** for tasks that truly cannot run per-package: + +```json +// AVOID unless necessary - sequential, not parallelized, can't filter +{ + "scripts": { + "lint": "eslint apps/web && eslint apps/api && eslint packages/ui" + } +} +``` + +## Root turbo.json + +```json +{ + "$schema": "https://turborepo.dev/schema.v2.json", + "tasks": { + "build": { + "dependsOn": ["^build"], + "outputs": ["dist/**", ".next/**", "!.next/cache/**"] + }, + "lint": {}, + "test": { + "dependsOn": ["build"] + }, + "dev": { + "cache": false, + "persistent": true + } + } +} +``` + +## Directory Organization + +### Grouping Packages + +You can group packages by adding more workspace paths: + +```yaml +# pnpm-workspace.yaml +packages: + - 'apps/*' + - 'packages/*' + - 'packages/config/*' # Grouped configs + - 'packages/features/*' # Feature packages +``` + +This allows: + +``` +packages/ +├── ui/ +├── utils/ +├── config/ +│ ├── eslint/ +│ ├── typescript/ +│ └── tailwind/ +└── features/ + ├── auth/ + └── payments/ +``` + +### What NOT to Do + +```yaml +# BAD: Nested wildcards cause ambiguous behavior +packages: + - 'packages/**' # Don't do this! +``` + +## Package Anatomy + +### Minimum Required Files + +``` +packages/ui/ +├── package.json # Required: Makes it a package +├── src/ # Source code +│ └── button.tsx +└── tsconfig.json # TypeScript config (if using TS) +``` + +### package.json Requirements + +```json +{ + "name": "@repo/ui", // Unique, namespaced name + "version": "0.0.0", // Version (can be 0.0.0 for internal) + "private": true, // Prevents accidental publishing + "exports": { + // Entry points + "./button": "./src/button.tsx" + } +} +``` + +## TypeScript Configuration + +### Shared Base Config + +Create a shared TypeScript config package: + +``` +packages/ +└── typescript-config/ + ├── package.json + ├── base.json + ├── nextjs.json + └── library.json +``` + +```json +// packages/typescript-config/base.json +{ + "compilerOptions": { + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "moduleResolution": "bundler", + "module": "ESNext", + "target": "ES2022" + } +} +``` + +### Extending in Packages + +```json +// packages/ui/tsconfig.json +{ + "extends": "@repo/typescript-config/library.json", + "compilerOptions": { + "outDir": "dist", + "rootDir": "src" + }, + "include": ["src"], + "exclude": ["node_modules", "dist"] +} +``` + +### No Root tsconfig.json + +You likely don't need a `tsconfig.json` in the workspace root. Each package should have its own config extending from the shared config package. + +## ESLint Configuration + +### Shared Config Package + +``` +packages/ +└── eslint-config/ + ├── package.json + ├── base.js + ├── next.js + └── library.js +``` + +```json +// packages/eslint-config/package.json +{ + "name": "@repo/eslint-config", + "exports": { + "./base": "./base.js", + "./next": "./next.js", + "./library": "./library.js" + } +} +``` + +### Using in Packages + +```js +// apps/web/.eslintrc.js +module.exports = { + extends: ['@repo/eslint-config/next'], +}; +``` + +## Lockfile + +A lockfile is **required** for: + +- Reproducible builds +- Turborepo to understand package dependencies +- Cache correctness + +Without a lockfile, you'll see unpredictable behavior. diff --git a/packages/mosaic/framework/skills/turborepo/references/boundaries/RULE.md b/packages/mosaic/framework/skills/turborepo/references/boundaries/RULE.md new file mode 100644 index 00000000..3deb0a41 --- /dev/null +++ b/packages/mosaic/framework/skills/turborepo/references/boundaries/RULE.md @@ -0,0 +1,126 @@ +# Boundaries + +**Experimental feature** - See [RFC](https://github.com/vercel/turborepo/discussions/9435) + +Full docs: https://turborepo.dev/docs/reference/boundaries + +Boundaries enforce package isolation by detecting: + +1. Imports of files outside the package's directory +2. Imports of packages not declared in `package.json` dependencies + +## Usage + +```bash +turbo boundaries +``` + +Run this to check for workspace violations across your monorepo. + +## Tags + +Tags allow you to create rules for which packages can depend on each other. + +### Adding Tags to a Package + +```json +// packages/ui/turbo.json +{ + "tags": ["internal"] +} +``` + +### Configuring Tag Rules + +Rules go in root `turbo.json`: + +```json +// turbo.json +{ + "boundaries": { + "tags": { + "public": { + "dependencies": { + "deny": ["internal"] + } + } + } + } +} +``` + +This prevents `public`-tagged packages from importing `internal`-tagged packages. + +### Rule Types + +**Allow-list approach** (only allow specific tags): + +```json +{ + "boundaries": { + "tags": { + "public": { + "dependencies": { + "allow": ["public"] + } + } + } + } +} +``` + +**Deny-list approach** (block specific tags): + +```json +{ + "boundaries": { + "tags": { + "public": { + "dependencies": { + "deny": ["internal"] + } + } + } + } +} +``` + +**Restrict dependents** (who can import this package): + +```json +{ + "boundaries": { + "tags": { + "private": { + "dependents": { + "deny": ["public"] + } + } + } + } +} +``` + +### Using Package Names + +Package names work in place of tags: + +```json +{ + "boundaries": { + "tags": { + "private": { + "dependents": { + "deny": ["@repo/my-pkg"] + } + } + } + } +} +``` + +## Key Points + +- Rules apply transitively (dependencies of dependencies) +- Helps enforce architectural boundaries at scale +- Catches violations before runtime/build errors diff --git a/packages/mosaic/framework/skills/turborepo/references/caching/RULE.md b/packages/mosaic/framework/skills/turborepo/references/caching/RULE.md new file mode 100644 index 00000000..fe6388e2 --- /dev/null +++ b/packages/mosaic/framework/skills/turborepo/references/caching/RULE.md @@ -0,0 +1,107 @@ +# How Turborepo Caching Works + +Turborepo's core principle: **never do the same work twice**. + +## The Cache Equation + +``` +fingerprint(inputs) → stored outputs +``` + +If inputs haven't changed, restore outputs from cache instead of re-running the task. + +## What Determines the Cache Key + +### Global Hash Inputs + +These affect ALL tasks in the repo: + +- `package-lock.json` / `yarn.lock` / `pnpm-lock.yaml` +- Files listed in `globalDependencies` +- Environment variables in `globalEnv` +- `turbo.json` configuration + +```json +{ + "globalDependencies": [".env", "tsconfig.base.json"], + "globalEnv": ["CI", "NODE_ENV"] +} +``` + +### Task Hash Inputs + +These affect specific tasks: + +- All files in the package (unless filtered by `inputs`) +- `package.json` contents +- Environment variables in task's `env` key +- Task configuration (command, outputs, dependencies) +- Hashes of dependent tasks (`dependsOn`) + +```json +{ + "tasks": { + "build": { + "dependsOn": ["^build"], + "inputs": ["src/**", "package.json", "tsconfig.json"], + "env": ["API_URL"] + } + } +} +``` + +## What Gets Cached + +1. **File outputs** - files/directories specified in `outputs` +2. **Task logs** - stdout/stderr for replay on cache hit + +```json +{ + "tasks": { + "build": { + "outputs": ["dist/**", ".next/**"] + } + } +} +``` + +## Local Cache Location + +``` +.turbo/cache/ +├── .tar.zst # compressed outputs +├── .tar.zst +└── ... +``` + +Add `.turbo` to `.gitignore`. + +## Cache Restoration + +On cache hit, Turborepo: + +1. Extracts archived outputs to their original locations +2. Replays the logged stdout/stderr +3. Reports the task as cached (shows `FULL TURBO` in output) + +## Example Flow + +```bash +# First run - executes build, caches result +turbo build +# → packages/ui: cache miss, executing... +# → packages/web: cache miss, executing... + +# Second run - same inputs, restores from cache +turbo build +# → packages/ui: cache hit, replaying output +# → packages/web: cache hit, replaying output +# → FULL TURBO +``` + +## Key Points + +- Cache is content-addressed (based on input hash, not timestamps) +- Empty `outputs` array means task runs but nothing is cached +- Tasks without `outputs` key cache nothing (use `"outputs": []` to be explicit) +- Cache is invalidated when ANY input changes diff --git a/packages/mosaic/framework/skills/turborepo/references/caching/gotchas.md b/packages/mosaic/framework/skills/turborepo/references/caching/gotchas.md new file mode 100644 index 00000000..695c783e --- /dev/null +++ b/packages/mosaic/framework/skills/turborepo/references/caching/gotchas.md @@ -0,0 +1,169 @@ +# Debugging Cache Issues + +## Diagnostic Tools + +### `--summarize` + +Generates a JSON file with all hash inputs. Compare two runs to find differences. + +```bash +turbo build --summarize +# Creates .turbo/runs/.json +``` + +The summary includes: + +- Global hash and its inputs +- Per-task hashes and their inputs +- Environment variables that affected the hash + +**Comparing runs:** + +```bash +# Run twice, compare the summaries +diff .turbo/runs/.json .turbo/runs/.json +``` + +### `--dry` / `--dry=json` + +See what would run without executing anything: + +```bash +turbo build --dry +turbo build --dry=json # machine-readable output +``` + +Shows cache status for each task without running them. + +### `--force` + +Skip reading cache, re-execute all tasks: + +```bash +turbo build --force +``` + +Useful to verify tasks actually work (not just cached results). + +## Unexpected Cache Misses + +**Symptom:** Task runs when you expected a cache hit. + +### Environment Variable Changed + +Check if an env var in the `env` key changed: + +```json +{ + "tasks": { + "build": { + "env": ["API_URL", "NODE_ENV"] + } + } +} +``` + +Different `API_URL` between runs = cache miss. + +### .env File Changed + +`.env` files aren't tracked by default. Add to `inputs`: + +```json +{ + "tasks": { + "build": { + "inputs": ["$TURBO_DEFAULT$", ".env", ".env.local"] + } + } +} +``` + +Or use `globalDependencies` for repo-wide env files: + +```json +{ + "globalDependencies": [".env"] +} +``` + +### Lockfile Changed + +Installing/updating packages changes the global hash. + +### Source Files Changed + +Any file in the package (or in `inputs`) triggers a miss. + +### turbo.json Changed + +Config changes invalidate the global hash. + +## Incorrect Cache Hits + +**Symptom:** Cached output is stale/wrong. + +### Missing Environment Variable + +Task uses an env var not listed in `env`: + +```javascript +// build.js +const apiUrl = process.env.API_URL; // not tracked! +``` + +Fix: add to task config: + +```json +{ + "tasks": { + "build": { + "env": ["API_URL"] + } + } +} +``` + +### Missing File in Inputs + +Task reads a file outside default inputs: + +```json +{ + "tasks": { + "build": { + "inputs": [ + "$TURBO_DEFAULT$", + "../../shared-config.json" // file outside package + ] + } + } +} +``` + +## Useful Flags + +```bash +# Only show output for cache misses +turbo build --output-logs=new-only + +# Show output for everything (debugging) +turbo build --output-logs=full + +# See why tasks are running +turbo build --verbosity=2 +``` + +## Quick Checklist + +Cache miss when expected hit: + +1. Run with `--summarize`, compare with previous run +2. Check env vars with `--dry=json` +3. Look for lockfile/config changes in git + +Cache hit when expected miss: + +1. Verify env var is in `env` array +2. Verify file is in `inputs` array +3. Check if file is outside package directory diff --git a/packages/mosaic/framework/skills/turborepo/references/caching/remote-cache.md b/packages/mosaic/framework/skills/turborepo/references/caching/remote-cache.md new file mode 100644 index 00000000..da76458b --- /dev/null +++ b/packages/mosaic/framework/skills/turborepo/references/caching/remote-cache.md @@ -0,0 +1,127 @@ +# Remote Caching + +Share cache artifacts across your team and CI pipelines. + +## Benefits + +- Team members get cache hits from each other's work +- CI gets cache hits from local development (and vice versa) +- Dramatically faster CI runs after first build +- No more "works on my machine" rebuilds + +## Vercel Remote Cache + +Free, zero-config when deploying on Vercel. For local dev and other CI: + +### Local Development Setup + +```bash +# Authenticate with Vercel +npx turbo login + +# Link repo to your Vercel team +npx turbo link +``` + +This creates `.turbo/config.json` with your team info (gitignored by default). + +### CI Setup + +Set these environment variables: + +```bash +TURBO_TOKEN= +TURBO_TEAM= +``` + +Get your token from Vercel dashboard → Settings → Tokens. + +**GitHub Actions example:** + +```yaml +- name: Build + run: npx turbo build + env: + TURBO_TOKEN: ${{ secrets.TURBO_TOKEN }} + TURBO_TEAM: ${{ vars.TURBO_TEAM }} +``` + +## Configuration in turbo.json + +```json +{ + "remoteCache": { + "enabled": true, + "signature": false + } +} +``` + +Options: + +- `enabled`: toggle remote cache (default: true when authenticated) +- `signature`: require artifact signing (default: false) + +## Artifact Signing + +Verify cache artifacts haven't been tampered with: + +```bash +# Set a secret key (use same key across all environments) +export TURBO_REMOTE_CACHE_SIGNATURE_KEY="your-secret-key" +``` + +Enable in config: + +```json +{ + "remoteCache": { + "signature": true + } +} +``` + +Signed artifacts can only be restored if the signature matches. + +## Self-Hosted Options + +Community implementations for running your own cache server: + +- **turbo-remote-cache** (Node.js) - supports S3, GCS, Azure +- **turborepo-remote-cache** (Go) - lightweight, S3-compatible +- **ducktape** (Rust) - high-performance option + +Configure with environment variables: + +```bash +TURBO_API=https://your-cache-server.com +TURBO_TOKEN=your-auth-token +TURBO_TEAM=your-team +``` + +## Cache Behavior Control + +```bash +# Disable remote cache for a run +turbo build --remote-cache-read-only # read but don't write +turbo build --no-cache # skip cache entirely + +# Environment variable alternative +TURBO_REMOTE_ONLY=true # only use remote, skip local +``` + +## Debugging Remote Cache + +```bash +# Verbose output shows cache operations +turbo build --verbosity=2 + +# Check if remote cache is configured +turbo config +``` + +Look for: + +- "Remote caching enabled" in output +- Upload/download messages during runs +- "cache hit, replaying output" with remote cache indicator diff --git a/packages/mosaic/framework/skills/turborepo/references/ci/RULE.md b/packages/mosaic/framework/skills/turborepo/references/ci/RULE.md new file mode 100644 index 00000000..f331c2cf --- /dev/null +++ b/packages/mosaic/framework/skills/turborepo/references/ci/RULE.md @@ -0,0 +1,79 @@ +# CI/CD with Turborepo + +General principles for running Turborepo in continuous integration environments. + +## Core Principles + +### Always Use `turbo run` in CI + +**Never use the `turbo ` shorthand in CI or scripts.** Always use `turbo run`: + +```bash +# CORRECT - Always use in CI, package.json, scripts +turbo run build test lint + +# WRONG - Shorthand is only for one-off terminal commands +turbo build test lint +``` + +The shorthand `turbo ` is only for one-off invocations typed directly in terminal by humans or agents. Anywhere the command is written into code (CI, package.json, scripts), use `turbo run`. + +### Enable Remote Caching + +Remote caching dramatically speeds up CI by sharing cached artifacts across runs. + +Required environment variables: + +```bash +TURBO_TOKEN=your_vercel_token +TURBO_TEAM=your_team_slug +``` + +### Use --affected for PR Builds + +The `--affected` flag only runs tasks for packages changed since the base branch: + +```bash +turbo run build test --affected +``` + +This requires Git history to compute what changed. + +## Git History Requirements + +### Fetch Depth + +`--affected` needs access to the merge base. Shallow clones break this. + +```yaml +# GitHub Actions +- uses: actions/checkout@v4 + with: + fetch-depth: 2 # Minimum for --affected + # Use 0 for full history if merge base is far +``` + +### Why Shallow Clones Break --affected + +Turborepo compares the current HEAD to the merge base with `main`. If that commit isn't fetched, `--affected` falls back to running everything. + +For PRs with many commits, consider: + +```yaml +fetch-depth: 0 # Full history +``` + +## Environment Variables Reference + +| Variable | Purpose | +| ------------------- | ------------------------------------ | +| `TURBO_TOKEN` | Vercel access token for remote cache | +| `TURBO_TEAM` | Your Vercel team slug | +| `TURBO_REMOTE_ONLY` | Skip local cache, use remote only | +| `TURBO_LOG_ORDER` | Set to `grouped` for cleaner CI logs | + +## See Also + +- [github-actions.md](./github-actions.md) - GitHub Actions setup +- [vercel.md](./vercel.md) - Vercel deployment +- [patterns.md](./patterns.md) - CI optimization patterns diff --git a/packages/mosaic/framework/skills/turborepo/references/ci/github-actions.md b/packages/mosaic/framework/skills/turborepo/references/ci/github-actions.md new file mode 100644 index 00000000..7e5d4ccc --- /dev/null +++ b/packages/mosaic/framework/skills/turborepo/references/ci/github-actions.md @@ -0,0 +1,162 @@ +# GitHub Actions + +Complete setup guide for Turborepo with GitHub Actions. + +## Basic Workflow Structure + +```yaml +name: CI + +on: + push: + branches: [main] + pull_request: + branches: [main] + +jobs: + build: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 2 + + - uses: actions/setup-node@v4 + with: + node-version: 20 + + - name: Install dependencies + run: npm ci + + - name: Build and Test + run: turbo run build test lint +``` + +## Package Manager Setup + +### pnpm + +```yaml +- uses: pnpm/action-setup@v3 + with: + version: 9 + +- uses: actions/setup-node@v4 + with: + node-version: 20 + cache: 'pnpm' + +- run: pnpm install --frozen-lockfile +``` + +### Yarn + +```yaml +- uses: actions/setup-node@v4 + with: + node-version: 20 + cache: 'yarn' + +- run: yarn install --frozen-lockfile +``` + +### Bun + +```yaml +- uses: oven-sh/setup-bun@v1 + with: + bun-version: latest + +- run: bun install --frozen-lockfile +``` + +## Remote Cache Setup + +### 1. Create Vercel Access Token + +1. Go to [Vercel Dashboard](https://vercel.com/account/tokens) +2. Create a new token with appropriate scope +3. Copy the token value + +### 2. Add Secrets and Variables + +In your GitHub repository settings: + +**Secrets** (Settings > Secrets and variables > Actions > Secrets): + +- `TURBO_TOKEN`: Your Vercel access token + +**Variables** (Settings > Secrets and variables > Actions > Variables): + +- `TURBO_TEAM`: Your Vercel team slug + +### 3. Add to Workflow + +```yaml +jobs: + build: + runs-on: ubuntu-latest + env: + TURBO_TOKEN: ${{ secrets.TURBO_TOKEN }} + TURBO_TEAM: ${{ vars.TURBO_TEAM }} +``` + +## Alternative: actions/cache + +If you can't use remote cache, cache Turborepo's local cache directory: + +```yaml +- uses: actions/cache@v4 + with: + path: .turbo + key: turbo-${{ runner.os }}-${{ hashFiles('**/turbo.json', '**/package-lock.json') }} + restore-keys: | + turbo-${{ runner.os }}- +``` + +Note: This is less effective than remote cache since it's per-branch. + +## Complete Example + +```yaml +name: CI + +on: + push: + branches: [main] + pull_request: + branches: [main] + +jobs: + build: + runs-on: ubuntu-latest + env: + TURBO_TOKEN: ${{ secrets.TURBO_TOKEN }} + TURBO_TEAM: ${{ vars.TURBO_TEAM }} + + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 2 + + - uses: pnpm/action-setup@v3 + with: + version: 9 + + - uses: actions/setup-node@v4 + with: + node-version: 20 + cache: 'pnpm' + + - name: Install dependencies + run: pnpm install --frozen-lockfile + + - name: Build + run: turbo run build --affected + + - name: Test + run: turbo run test --affected + + - name: Lint + run: turbo run lint --affected +``` diff --git a/packages/mosaic/framework/skills/turborepo/references/ci/patterns.md b/packages/mosaic/framework/skills/turborepo/references/ci/patterns.md new file mode 100644 index 00000000..447509a1 --- /dev/null +++ b/packages/mosaic/framework/skills/turborepo/references/ci/patterns.md @@ -0,0 +1,145 @@ +# CI Optimization Patterns + +Strategies for efficient CI/CD with Turborepo. + +## PR vs Main Branch Builds + +### PR Builds: Only Affected + +Test only what changed in the PR: + +```yaml +- name: Test (PR) + if: github.event_name == 'pull_request' + run: turbo run build test --affected +``` + +### Main Branch: Full Build + +Ensure complete validation on merge: + +```yaml +- name: Test (Main) + if: github.ref == 'refs/heads/main' + run: turbo run build test +``` + +## Custom Git Ranges with --filter + +For advanced scenarios, use `--filter` with git refs: + +```bash +# Changes since specific commit +turbo run test --filter="...[abc123]" + +# Changes between refs +turbo run test --filter="...[main...HEAD]" + +# Changes in last 3 commits +turbo run test --filter="...[HEAD~3]" +``` + +## Caching Strategies + +### Remote Cache (Recommended) + +Best performance - shared across all CI runs and developers: + +```yaml +env: + TURBO_TOKEN: ${{ secrets.TURBO_TOKEN }} + TURBO_TEAM: ${{ vars.TURBO_TEAM }} +``` + +### actions/cache Fallback + +When remote cache isn't available: + +```yaml +- uses: actions/cache@v4 + with: + path: .turbo + key: turbo-${{ runner.os }}-${{ github.sha }} + restore-keys: | + turbo-${{ runner.os }}-${{ github.ref }}- + turbo-${{ runner.os }}- +``` + +Limitations: + +- Cache is branch-scoped +- PRs restore from base branch cache +- Less efficient than remote cache + +## Matrix Builds + +Test across Node versions: + +```yaml +strategy: + matrix: + node: [18, 20, 22] + +steps: + - uses: actions/setup-node@v4 + with: + node-version: ${{ matrix.node }} + + - run: turbo run test +``` + +## Parallelizing Across Jobs + +Split tasks into separate jobs: + +```yaml +jobs: + lint: + runs-on: ubuntu-latest + steps: + - run: turbo run lint --affected + + test: + runs-on: ubuntu-latest + steps: + - run: turbo run test --affected + + build: + runs-on: ubuntu-latest + needs: [lint, test] + steps: + - run: turbo run build +``` + +### Cache Considerations + +When parallelizing: + +- Each job has separate cache writes +- Remote cache handles this automatically +- With actions/cache, use unique keys per job to avoid conflicts + +```yaml +- uses: actions/cache@v4 + with: + path: .turbo + key: turbo-${{ runner.os }}-${{ github.job }}-${{ github.sha }} +``` + +## Conditional Tasks + +Skip expensive tasks on draft PRs: + +```yaml +- name: E2E Tests + if: github.event.pull_request.draft == false + run: turbo run test:e2e --affected +``` + +Or require label for full test: + +```yaml +- name: Full Test Suite + if: contains(github.event.pull_request.labels.*.name, 'full-test') + run: turbo run test +``` diff --git a/packages/mosaic/framework/skills/turborepo/references/ci/vercel.md b/packages/mosaic/framework/skills/turborepo/references/ci/vercel.md new file mode 100644 index 00000000..f21d41ac --- /dev/null +++ b/packages/mosaic/framework/skills/turborepo/references/ci/vercel.md @@ -0,0 +1,103 @@ +# Vercel Deployment + +Turborepo integrates seamlessly with Vercel for monorepo deployments. + +## Remote Cache + +Remote caching is **automatically enabled** when deploying to Vercel. No configuration needed - Vercel detects Turborepo and enables caching. + +This means: + +- No `TURBO_TOKEN` or `TURBO_TEAM` setup required on Vercel +- Cache is shared across all deployments +- Preview and production builds benefit from cache + +## turbo-ignore + +Skip unnecessary builds when a package hasn't changed using `turbo-ignore`. + +### Installation + +```bash +npx turbo-ignore +``` + +Or install globally in your project: + +```bash +pnpm add -D turbo-ignore +``` + +### Setup in Vercel + +1. Go to your project in Vercel Dashboard +2. Navigate to Settings > Git > Ignored Build Step +3. Select "Custom" and enter: + +```bash +npx turbo-ignore +``` + +### How It Works + +`turbo-ignore` checks if the current package (or its dependencies) changed since the last successful deployment: + +1. Compares current commit to last deployed commit +2. Uses Turborepo's dependency graph +3. Returns exit code 0 (skip) if no changes +4. Returns exit code 1 (build) if changes detected + +### Options + +```bash +# Check specific package +npx turbo-ignore web + +# Use specific comparison ref +npx turbo-ignore --fallback=HEAD~1 + +# Verbose output +npx turbo-ignore --verbose +``` + +## Environment Variables + +Set environment variables in Vercel Dashboard: + +1. Go to Project Settings > Environment Variables +2. Add variables for each environment (Production, Preview, Development) + +Common variables: + +- `DATABASE_URL` +- `API_KEY` +- Package-specific config + +## Monorepo Root Directory + +For monorepos, set the root directory in Vercel: + +1. Project Settings > General > Root Directory +2. Set to the package path (e.g., `apps/web`) + +Vercel automatically: + +- Installs dependencies from monorepo root +- Runs build from the package directory +- Detects framework settings + +## Build Command + +Vercel auto-detects `turbo run build` when `turbo.json` exists at root. + +Override if needed: + +```bash +turbo run build --filter=web +``` + +Or for production-only optimizations: + +```bash +turbo run build --filter=web --env-mode=strict +``` diff --git a/packages/mosaic/framework/skills/turborepo/references/cli/RULE.md b/packages/mosaic/framework/skills/turborepo/references/cli/RULE.md new file mode 100644 index 00000000..63f6f34d --- /dev/null +++ b/packages/mosaic/framework/skills/turborepo/references/cli/RULE.md @@ -0,0 +1,100 @@ +# turbo run + +The primary command for executing tasks across your monorepo. + +## Basic Usage + +```bash +# Full form (use in CI, package.json, scripts) +turbo run + +# Shorthand (only for one-off terminal invocations) +turbo +``` + +## When to Use `turbo run` vs `turbo` + +**Always use `turbo run` when the command is written into code:** + +- `package.json` scripts +- CI/CD workflows (GitHub Actions, etc.) +- Shell scripts +- Documentation +- Any static/committed configuration + +**Only use `turbo` (shorthand) for:** + +- One-off commands typed directly in terminal +- Ad-hoc invocations by humans or agents + +```json +// package.json - ALWAYS use "turbo run" +{ + "scripts": { + "build": "turbo run build", + "dev": "turbo run dev", + "lint": "turbo run lint", + "test": "turbo run test" + } +} +``` + +```yaml +# CI workflow - ALWAYS use "turbo run" +- run: turbo run build --affected +- run: turbo run test --affected +``` + +```bash +# Terminal one-off - shorthand OK +turbo build --filter=web +``` + +## Running Tasks + +Tasks must be defined in `turbo.json` before running. + +```bash +# Single task +turbo build + +# Multiple tasks +turbo run build lint test + +# See available tasks (run without arguments) +turbo run +``` + +## Passing Arguments to Scripts + +Use `--` to pass arguments through to the underlying package scripts: + +```bash +turbo run build -- --sourcemap +turbo test -- --watch +turbo lint -- --fix +``` + +Everything after `--` goes directly to the task's script. + +## Package Selection + +By default, turbo runs tasks in all packages. Use `--filter` to narrow scope: + +```bash +turbo build --filter=web +turbo test --filter=./apps/* +``` + +See `filtering/` for complete filter syntax. + +## Quick Reference + +| Goal | Command | +| ------------------- | -------------------------- | +| Build everything | `turbo build` | +| Build one package | `turbo build --filter=web` | +| Multiple tasks | `turbo build lint test` | +| Pass args to script | `turbo build -- --arg` | +| Preview run | `turbo build --dry` | +| Force rebuild | `turbo build --force` | diff --git a/packages/mosaic/framework/skills/turborepo/references/cli/commands.md b/packages/mosaic/framework/skills/turborepo/references/cli/commands.md new file mode 100644 index 00000000..1e872d12 --- /dev/null +++ b/packages/mosaic/framework/skills/turborepo/references/cli/commands.md @@ -0,0 +1,297 @@ +# turbo run Flags Reference + +Full docs: https://turborepo.dev/docs/reference/run + +## Package Selection + +### `--filter` / `-F` + +Select specific packages to run tasks in. + +```bash +turbo build --filter=web +turbo build -F=@repo/ui -F=@repo/utils +turbo test --filter=./apps/* +``` + +See `filtering/` for complete syntax (globs, dependencies, git ranges). + +### Task Identifier Syntax (v2.2.4+) + +Run specific package tasks directly: + +```bash +turbo run web#build # Build web package +turbo run web#build docs#lint # Multiple specific tasks +``` + +### `--affected` + +Run only in packages changed since the base branch. + +```bash +turbo build --affected +turbo test --affected --filter=./apps/* # combine with filter +``` + +**How it works:** + +- Default: compares `main...HEAD` +- In GitHub Actions: auto-detects `GITHUB_BASE_REF` +- Override base: `TURBO_SCM_BASE=development turbo build --affected` +- Override head: `TURBO_SCM_HEAD=your-branch turbo build --affected` + +**Requires git history** - shallow clones may fall back to running all tasks. + +## Execution Control + +### `--dry` / `--dry=json` + +Preview what would run without executing. + +```bash +turbo build --dry # human-readable +turbo build --dry=json # machine-readable +``` + +### `--force` + +Ignore all cached artifacts, re-run everything. + +```bash +turbo build --force +``` + +### `--concurrency` + +Limit parallel task execution. + +```bash +turbo build --concurrency=4 # max 4 tasks +turbo build --concurrency=50% # 50% of CPU cores +``` + +### `--continue` + +Keep running other tasks when one fails. + +```bash +turbo build test --continue +``` + +### `--only` + +Run only the specified task, skip its dependencies. + +```bash +turbo build --only # skip running dependsOn tasks +``` + +### `--parallel` (Discouraged) + +Ignores task graph dependencies, runs all tasks simultaneously. **Avoid using this flag**—if tasks need to run in parallel, configure `dependsOn` correctly instead. Using `--parallel` bypasses Turborepo's dependency graph, which can cause race conditions and incorrect builds. + +## Cache Control + +### `--cache` + +Fine-grained cache behavior control. + +```bash +# Default: read/write both local and remote +turbo build --cache=local:rw,remote:rw + +# Read-only local, no remote +turbo build --cache=local:r,remote: + +# Disable local, read-only remote +turbo build --cache=local:,remote:r + +# Disable all caching +turbo build --cache=local:,remote: +``` + +## Output & Debugging + +### `--graph` + +Generate task graph visualization. + +```bash +turbo build --graph # opens in browser +turbo build --graph=graph.svg # SVG file +turbo build --graph=graph.png # PNG file +turbo build --graph=graph.json # JSON data +turbo build --graph=graph.mermaid # Mermaid diagram +``` + +### `--summarize` + +Generate JSON run summary for debugging. + +```bash +turbo build --summarize +# creates .turbo/runs/.json +``` + +### `--output-logs` + +Control log output verbosity. + +```bash +turbo build --output-logs=full # all logs (default) +turbo build --output-logs=new-only # only cache misses +turbo build --output-logs=errors-only # only failures +turbo build --output-logs=none # silent +``` + +### `--profile` + +Generate Chrome tracing profile for performance analysis. + +```bash +turbo build --profile=profile.json +# open chrome://tracing and load the file +``` + +### `--verbosity` / `-v` + +Control turbo's own log level. + +```bash +turbo build -v # verbose +turbo build -vv # more verbose +turbo build -vvv # maximum verbosity +``` + +## Environment + +### `--env-mode` + +Control environment variable handling. + +```bash +turbo build --env-mode=strict # only declared env vars (default) +turbo build --env-mode=loose # include all env vars in hash +``` + +## UI + +### `--ui` + +Select output interface. + +```bash +turbo build --ui=tui # interactive terminal UI (default in TTY) +turbo build --ui=stream # streaming logs (default in CI) +``` + +--- + +# turbo-ignore + +Full docs: https://turborepo.dev/docs/reference/turbo-ignore + +Skip CI work when nothing relevant changed. Useful for skipping container setup. + +## Basic Usage + +```bash +# Check if build is needed for current package (uses Automatic Package Scoping) +npx turbo-ignore + +# Check specific package +npx turbo-ignore web + +# Check specific task +npx turbo-ignore --task=test +``` + +## Exit Codes + +- `0`: No changes detected - skip CI work +- `1`: Changes detected - proceed with CI + +## CI Integration Example + +```yaml +# GitHub Actions +- name: Check for changes + id: turbo-ignore + run: npx turbo-ignore web + continue-on-error: true + +- name: Build + if: steps.turbo-ignore.outcome == 'failure' # changes detected + run: pnpm build +``` + +## Comparison Depth + +Default: compares to parent commit (`HEAD^1`). + +```bash +# Compare to specific commit +npx turbo-ignore --fallback=abc123 + +# Compare to branch +npx turbo-ignore --fallback=main +``` + +--- + +# Other Commands + +## turbo boundaries + +Check workspace violations (experimental). + +```bash +turbo boundaries +``` + +See `references/boundaries/` for configuration. + +## turbo watch + +Re-run tasks on file changes. + +```bash +turbo watch build test +``` + +See `references/watch/` for details. + +## turbo prune + +Create sparse checkout for Docker. + +```bash +turbo prune web --docker +``` + +## turbo link / unlink + +Connect/disconnect Remote Cache. + +```bash +turbo link # connect to Vercel Remote Cache +turbo unlink # disconnect +``` + +## turbo login / logout + +Authenticate with Remote Cache provider. + +```bash +turbo login # authenticate +turbo logout # log out +``` + +## turbo generate + +Scaffold new packages. + +```bash +turbo generate +``` diff --git a/packages/mosaic/framework/skills/turborepo/references/configuration/RULE.md b/packages/mosaic/framework/skills/turborepo/references/configuration/RULE.md new file mode 100644 index 00000000..42b5f091 --- /dev/null +++ b/packages/mosaic/framework/skills/turborepo/references/configuration/RULE.md @@ -0,0 +1,211 @@ +# turbo.json Configuration Overview + +Configuration reference for Turborepo. Full docs: https://turborepo.dev/docs/reference/configuration + +## File Location + +Root `turbo.json` lives at repo root, sibling to root `package.json`: + +``` +my-monorepo/ +├── turbo.json # Root configuration +├── package.json +└── packages/ + └── web/ + ├── turbo.json # Package Configuration (optional) + └── package.json +``` + +## Always Prefer Package Tasks Over Root Tasks + +**Always use package tasks. Only use Root Tasks if you cannot succeed with package tasks.** + +Package tasks enable parallelization, individual caching, and filtering. Define scripts in each package's `package.json`: + +```json +// packages/web/package.json +{ + "scripts": { + "build": "next build", + "lint": "eslint .", + "test": "vitest", + "typecheck": "tsc --noEmit" + } +} + +// packages/api/package.json +{ + "scripts": { + "build": "tsc", + "lint": "eslint .", + "test": "vitest", + "typecheck": "tsc --noEmit" + } +} +``` + +```json +// Root package.json - delegates to turbo +{ + "scripts": { + "build": "turbo run build", + "lint": "turbo run lint", + "test": "turbo run test", + "typecheck": "turbo run typecheck" + } +} +``` + +When you run `turbo run lint`, Turborepo finds all packages with a `lint` script and runs them **in parallel**. + +**Root Tasks are a fallback**, not the default. Only use them for tasks that truly cannot run per-package (e.g., repo-level CI scripts, workspace-wide config generation). + +```json +// AVOID: Task logic in root defeats parallelization +{ + "scripts": { + "lint": "eslint apps/web && eslint apps/api && eslint packages/ui" + } +} +``` + +## Basic Structure + +```json +{ + "$schema": "https://turborepo.dev/schema.v2.json", + "globalEnv": ["CI"], + "globalDependencies": ["tsconfig.json"], + "tasks": { + "build": { + "dependsOn": ["^build"], + "outputs": ["dist/**"] + }, + "dev": { + "cache": false, + "persistent": true + } + } +} +``` + +The `$schema` key enables IDE autocompletion and validation. + +## Configuration Sections + +**Global options** - Settings affecting all tasks: + +- `globalEnv`, `globalDependencies`, `globalPassThroughEnv` +- `cacheDir`, `daemon`, `envMode`, `ui`, `remoteCache` + +**Task definitions** - Per-task settings in `tasks` object: + +- `dependsOn`, `outputs`, `inputs`, `env` +- `cache`, `persistent`, `interactive`, `outputLogs` + +## Package Configurations + +Use `turbo.json` in individual packages to override root settings: + +```json +// packages/web/turbo.json +{ + "extends": ["//"], + "tasks": { + "build": { + "outputs": [".next/**", "!.next/cache/**"] + } + } +} +``` + +The `"extends": ["//"]` is required - it references the root configuration. + +**When to use Package Configurations:** + +- Framework-specific outputs (Next.js, Vite, etc.) +- Package-specific env vars +- Different caching rules for specific packages +- Keeping framework config close to the framework code + +### Extending from Other Packages + +You can extend from config packages instead of just root: + +```json +// packages/web/turbo.json +{ + "extends": ["//", "@repo/turbo-config"] +} +``` + +### Adding to Inherited Arrays with `$TURBO_EXTENDS$` + +By default, array fields in Package Configurations **replace** root values. Use `$TURBO_EXTENDS$` to **append** instead: + +```json +// Root turbo.json +{ + "tasks": { + "build": { + "outputs": ["dist/**"] + } + } +} +``` + +```json +// packages/web/turbo.json +{ + "extends": ["//"], + "tasks": { + "build": { + // Inherits "dist/**" from root, adds ".next/**" + "outputs": ["$TURBO_EXTENDS$", ".next/**", "!.next/cache/**"] + } + } +} +``` + +Without `$TURBO_EXTENDS$`, outputs would only be `[".next/**", "!.next/cache/**"]`. + +**Works with:** + +- `dependsOn` +- `env` +- `inputs` +- `outputs` +- `passThroughEnv` +- `with` + +### Excluding Tasks from Packages + +Use `extends: false` to exclude a task from a package: + +```json +// packages/ui/turbo.json +{ + "extends": ["//"], + "tasks": { + "e2e": { + "extends": false // UI package doesn't have e2e tests + } + } +} +``` + +## `turbo.jsonc` for Comments + +Use `turbo.jsonc` extension to add comments with IDE support: + +```jsonc +// turbo.jsonc +{ + "tasks": { + "build": { + // Next.js outputs + "outputs": [".next/**", "!.next/cache/**"], + }, + }, +} +``` diff --git a/packages/mosaic/framework/skills/turborepo/references/configuration/global-options.md b/packages/mosaic/framework/skills/turborepo/references/configuration/global-options.md new file mode 100644 index 00000000..b2d7a8d2 --- /dev/null +++ b/packages/mosaic/framework/skills/turborepo/references/configuration/global-options.md @@ -0,0 +1,191 @@ +# Global Options Reference + +Options that affect all tasks. Full docs: https://turborepo.dev/docs/reference/configuration + +## globalEnv + +Environment variables affecting all task hashes. + +```json +{ + "globalEnv": ["CI", "NODE_ENV", "VERCEL_*"] +} +``` + +Use for variables that should invalidate all caches when changed. + +## globalDependencies + +Files that affect all task hashes. + +```json +{ + "globalDependencies": ["tsconfig.json", ".env", "pnpm-lock.yaml"] +} +``` + +Lockfile is included by default. Add shared configs here. + +## globalPassThroughEnv + +Variables available to tasks but not included in hash. + +```json +{ + "globalPassThroughEnv": ["AWS_SECRET_KEY", "GITHUB_TOKEN"] +} +``` + +Use for credentials that shouldn't affect cache keys. + +## cacheDir + +Custom cache location. Default: `node_modules/.cache/turbo`. + +```json +{ + "cacheDir": ".turbo/cache" +} +``` + +## daemon + +Background process for faster subsequent runs. Default: `true`. + +```json +{ + "daemon": false +} +``` + +Disable in CI or when debugging. + +## envMode + +How unspecified env vars are handled. Default: `"strict"`. + +```json +{ + "envMode": "strict" // Only specified vars available + // or + "envMode": "loose" // All vars pass through +} +``` + +Strict mode catches missing env declarations. + +## ui + +Terminal UI mode. Default: `"stream"`. + +```json +{ + "ui": "tui" // Interactive terminal UI + // or + "ui": "stream" // Traditional streaming logs +} +``` + +TUI provides better UX for parallel tasks. + +## remoteCache + +Configure remote caching. + +```json +{ + "remoteCache": { + "enabled": true, + "signature": true, + "timeout": 30, + "uploadTimeout": 60 + } +} +``` + +| Option | Default | Description | +| --------------- | ---------------------- | ------------------------------------------------------ | +| `enabled` | `true` | Enable/disable remote caching | +| `signature` | `false` | Sign artifacts with `TURBO_REMOTE_CACHE_SIGNATURE_KEY` | +| `preflight` | `false` | Send OPTIONS request before cache requests | +| `timeout` | `30` | Timeout in seconds for cache operations | +| `uploadTimeout` | `60` | Timeout in seconds for uploads | +| `apiUrl` | `"https://vercel.com"` | Remote cache API endpoint | +| `loginUrl` | `"https://vercel.com"` | Login endpoint | +| `teamId` | - | Team ID (must start with `team_`) | +| `teamSlug` | - | Team slug for querystring | + +See https://turborepo.dev/docs/core-concepts/remote-caching for setup. + +## concurrency + +Default: `"10"` + +Limit parallel task execution. + +```json +{ + "concurrency": "4" // Max 4 tasks at once + // or + "concurrency": "50%" // 50% of available CPUs +} +``` + +## futureFlags + +Enable experimental features that will become default in future versions. + +```json +{ + "futureFlags": { + "errorsOnlyShowHash": true + } +} +``` + +### `errorsOnlyShowHash` + +When using `outputLogs: "errors-only"`, show task hashes on start/completion: + +- Cache miss: `cache miss, executing (only logging errors)` +- Cache hit: `cache hit, replaying logs (no errors) ` + +## noUpdateNotifier + +Disable update notifications when new turbo versions are available. + +```json +{ + "noUpdateNotifier": true +} +``` + +## dangerouslyDisablePackageManagerCheck + +Bypass the `packageManager` field requirement. Use for incremental migration. + +```json +{ + "dangerouslyDisablePackageManagerCheck": true +} +``` + +**Warning**: Unstable lockfiles can cause unpredictable behavior. + +## Git Worktree Cache Sharing + +When working in Git worktrees, Turborepo automatically shares local cache between the main worktree and linked worktrees. + +**How it works:** + +- Detects worktree configuration +- Redirects cache to main worktree's `.turbo/cache` +- Works alongside Remote Cache + +**Benefits:** + +- Cache hits across branches +- Reduced disk usage +- Faster branch switching + +**Disabled by**: Setting explicit `cacheDir` in turbo.json. diff --git a/packages/mosaic/framework/skills/turborepo/references/configuration/gotchas.md b/packages/mosaic/framework/skills/turborepo/references/configuration/gotchas.md new file mode 100644 index 00000000..225bd397 --- /dev/null +++ b/packages/mosaic/framework/skills/turborepo/references/configuration/gotchas.md @@ -0,0 +1,348 @@ +# Configuration Gotchas + +Common mistakes and how to fix them. + +## #1 Root Scripts Not Using `turbo run` + +Root `package.json` scripts for turbo tasks MUST use `turbo run`, not direct commands. + +```json +// WRONG - bypasses turbo, no parallelization or caching +{ + "scripts": { + "build": "bun build", + "dev": "bun dev" + } +} + +// CORRECT - delegates to turbo +{ + "scripts": { + "build": "turbo run build", + "dev": "turbo run dev" + } +} +``` + +**Why this matters:** Running `bun build` or `npm run build` at root bypasses Turborepo entirely - no parallelization, no caching, no dependency graph awareness. + +## #2 Using `&&` to Chain Turbo Tasks + +Don't use `&&` to chain tasks that turbo should orchestrate. + +```json +// WRONG - changeset:publish chains turbo task with non-turbo command +{ + "scripts": { + "changeset:publish": "bun build && changeset publish" + } +} + +// CORRECT - use turbo run, let turbo handle dependencies +{ + "scripts": { + "changeset:publish": "turbo run build && changeset publish" + } +} +``` + +If the second command (`changeset publish`) depends on build outputs, the turbo task should run through turbo to get caching and parallelization benefits. + +## #3 Overly Broad globalDependencies + +`globalDependencies` affects hash for ALL tasks in ALL packages. Be specific. + +```json +// WRONG - affects all hashes +{ + "globalDependencies": ["**/.env.*local"] +} + +// CORRECT - move to specific tasks that need it +{ + "globalDependencies": [".env"], + "tasks": { + "build": { + "inputs": ["$TURBO_DEFAULT$", ".env*"], + "outputs": ["dist/**"] + } + } +} +``` + +**Why this matters:** `**/.env.*local` matches .env files in ALL packages, causing unnecessary cache invalidation. Instead: + +- Use `globalDependencies` only for truly global files (root `.env`) +- Use task-level `inputs` for package-specific .env files with `$TURBO_DEFAULT$` to preserve default behavior + +## #4 Repetitive Task Configuration + +Look for repeated configuration across tasks that can be collapsed. + +```json +// WRONG - repetitive env and inputs across tasks +{ + "tasks": { + "build": { + "env": ["API_URL", "DATABASE_URL"], + "inputs": ["$TURBO_DEFAULT$", ".env*"] + }, + "test": { + "env": ["API_URL", "DATABASE_URL"], + "inputs": ["$TURBO_DEFAULT$", ".env*"] + } + } +} + +// BETTER - use globalEnv and globalDependencies +{ + "globalEnv": ["API_URL", "DATABASE_URL"], + "globalDependencies": [".env*"], + "tasks": { + "build": {}, + "test": {} + } +} +``` + +**When to use global vs task-level:** + +- `globalEnv` / `globalDependencies` - affects ALL tasks, use for truly shared config +- Task-level `env` / `inputs` - use when only specific tasks need it + +## #5 Using `../` to Traverse Out of Package in `inputs` + +Don't use relative paths like `../` to reference files outside the package. Use `$TURBO_ROOT$` instead. + +```json +// WRONG - traversing out of package +{ + "tasks": { + "build": { + "inputs": ["$TURBO_DEFAULT$", "../shared-config.json"] + } + } +} + +// CORRECT - use $TURBO_ROOT$ for repo root +{ + "tasks": { + "build": { + "inputs": ["$TURBO_DEFAULT$", "$TURBO_ROOT$/shared-config.json"] + } + } +} +``` + +## #6 MOST COMMON MISTAKE: Creating Root Tasks + +**DO NOT create Root Tasks. ALWAYS create package tasks.** + +When you need to create a task (build, lint, test, typecheck, etc.): + +1. Add the script to **each relevant package's** `package.json` +2. Register the task in root `turbo.json` +3. Root `package.json` only contains `turbo run ` + +```json +// WRONG - DO NOT DO THIS +// Root package.json with task logic +{ + "scripts": { + "build": "cd apps/web && next build && cd ../api && tsc", + "lint": "eslint apps/ packages/", + "test": "vitest" + } +} + +// CORRECT - DO THIS +// apps/web/package.json +{ "scripts": { "build": "next build", "lint": "eslint .", "test": "vitest" } } + +// apps/api/package.json +{ "scripts": { "build": "tsc", "lint": "eslint .", "test": "vitest" } } + +// packages/ui/package.json +{ "scripts": { "build": "tsc", "lint": "eslint .", "test": "vitest" } } + +// Root package.json - ONLY delegates +{ "scripts": { "build": "turbo run build", "lint": "turbo run lint", "test": "turbo run test" } } + +// turbo.json - register tasks +{ + "tasks": { + "build": { "dependsOn": ["^build"], "outputs": ["dist/**"] }, + "lint": {}, + "test": {} + } +} +``` + +**Why this matters:** + +- Package tasks run in **parallel** across all packages +- Each package's output is cached **individually** +- You can **filter** to specific packages: `turbo run test --filter=web` + +Root Tasks (`//#taskname`) defeat all these benefits. Only use them for tasks that truly cannot exist in any package (extremely rare). + +## #7 Tasks That Need Parallel Execution + Cache Invalidation + +Some tasks can run in parallel (don't need built output from dependencies) but must still invalidate cache when dependency source code changes. Using `dependsOn: ["^taskname"]` forces sequential execution. Using no dependencies breaks cache invalidation. + +**Use Transit Nodes for these tasks:** + +```json +// WRONG - forces sequential execution (SLOW) +"my-task": { + "dependsOn": ["^my-task"] +} + +// ALSO WRONG - no dependency awareness (INCORRECT CACHING) +"my-task": {} + +// CORRECT - use Transit Nodes for parallel + correct caching +{ + "tasks": { + "transit": { "dependsOn": ["^transit"] }, + "my-task": { "dependsOn": ["transit"] } + } +} +``` + +**Why Transit Nodes work:** + +- `transit` creates dependency relationships without matching any actual script +- Tasks that depend on `transit` gain dependency awareness +- Since `transit` completes instantly (no script), tasks run in parallel +- Cache correctly invalidates when dependency source code changes + +**How to identify tasks that need this pattern:** Look for tasks that read source files from dependencies but don't need their build outputs. + +## Missing outputs for File-Producing Tasks + +**Before flagging missing `outputs`, check what the task actually produces:** + +1. Read the package's script (e.g., `"build": "tsc"`, `"test": "vitest"`) +2. Determine if it writes files to disk or only outputs to stdout +3. Only flag if the task produces files that should be cached + +```json +// WRONG - build produces files but they're not cached +"build": { + "dependsOn": ["^build"] +} + +// CORRECT - outputs are cached +"build": { + "dependsOn": ["^build"], + "outputs": ["dist/**"] +} +``` + +No `outputs` key is fine for stdout-only tasks. For file-producing tasks, missing `outputs` means Turbo has nothing to cache. + +## Forgetting ^ in dependsOn + +```json +// WRONG - looks for "build" in SAME package (infinite loop or missing) +"build": { + "dependsOn": ["build"] +} + +// CORRECT - runs dependencies' build first +"build": { + "dependsOn": ["^build"] +} +``` + +The `^` means "in dependency packages", not "in this package". + +## Missing persistent on Dev Tasks + +```json +// WRONG - dependent tasks hang waiting for dev to "finish" +"dev": { + "cache": false +} + +// CORRECT +"dev": { + "cache": false, + "persistent": true +} +``` + +## Package Config Missing extends + +```json +// WRONG - packages/web/turbo.json +{ + "tasks": { + "build": { "outputs": [".next/**"] } + } +} + +// CORRECT +{ + "extends": ["//"], + "tasks": { + "build": { "outputs": [".next/**"] } + } +} +``` + +Without `"extends": ["//"]`, Package Configurations are invalid. + +## Root Tasks Need Special Syntax + +To run a task defined only in root `package.json`: + +```bash +# WRONG +turbo run format + +# CORRECT +turbo run //#format +``` + +And in dependsOn: + +```json +"build": { + "dependsOn": ["//#codegen"] // Root package's codegen +} +``` + +## Overwriting Default Inputs + +```json +// WRONG - only watches test files, ignores source changes +"test": { + "inputs": ["tests/**"] +} + +// CORRECT - extends defaults, adds test files +"test": { + "inputs": ["$TURBO_DEFAULT$", "tests/**"] +} +``` + +Without `$TURBO_DEFAULT$`, you replace all default file watching. + +## Caching Tasks with Side Effects + +```json +// WRONG - deploy might be skipped on cache hit +"deploy": { + "dependsOn": ["build"] +} + +// CORRECT +"deploy": { + "dependsOn": ["build"], + "cache": false +} +``` + +Always disable cache for deploy, publish, or mutation tasks. diff --git a/packages/mosaic/framework/skills/turborepo/references/configuration/tasks.md b/packages/mosaic/framework/skills/turborepo/references/configuration/tasks.md new file mode 100644 index 00000000..4b0a6914 --- /dev/null +++ b/packages/mosaic/framework/skills/turborepo/references/configuration/tasks.md @@ -0,0 +1,281 @@ +# Task Configuration Reference + +Full docs: https://turborepo.dev/docs/reference/configuration#tasks + +## dependsOn + +Controls task execution order. + +```json +{ + "tasks": { + "build": { + "dependsOn": [ + "^build", // Dependencies' build tasks first + "codegen", // Same package's codegen task first + "shared#build" // Specific package's build task + ] + } + } +} +``` + +| Syntax | Meaning | +| ---------- | ------------------------------------ | +| `^task` | Run `task` in all dependencies first | +| `task` | Run `task` in same package first | +| `pkg#task` | Run specific package's task first | + +The `^` prefix is crucial - without it, you're referencing the same package. + +### Transit Nodes for Parallel Tasks + +For tasks like `lint` and `check-types` that can run in parallel but need dependency-aware caching: + +```json +{ + "tasks": { + "transit": { "dependsOn": ["^transit"] }, + "lint": { "dependsOn": ["transit"] }, + "check-types": { "dependsOn": ["transit"] } + } +} +``` + +**DO NOT use `dependsOn: ["^lint"]`** - this forces sequential execution. +**DO NOT use `dependsOn: []`** - this breaks cache invalidation. + +The `transit` task creates dependency relationships without running anything (no matching script), so tasks run in parallel with correct caching. + +## outputs + +Glob patterns for files to cache. **If omitted, nothing is cached.** + +```json +{ + "tasks": { + "build": { + "outputs": ["dist/**", "build/**"] + } + } +} +``` + +**Framework examples:** + +```json +// Next.js +"outputs": [".next/**", "!.next/cache/**"] + +// Vite +"outputs": ["dist/**"] + +// TypeScript (tsc) +"outputs": ["dist/**", "*.tsbuildinfo"] + +// No file outputs (lint, typecheck) +"outputs": [] +``` + +Use `!` prefix to exclude patterns from caching. + +## inputs + +Files considered when calculating task hash. Defaults to all tracked files in package. + +```json +{ + "tasks": { + "test": { + "inputs": ["src/**", "tests/**", "vitest.config.ts"] + } + } +} +``` + +**Special values:** + +| Value | Meaning | +| --------------------- | --------------------------------------- | +| `$TURBO_DEFAULT$` | Include default inputs, then add/remove | +| `$TURBO_ROOT$/` | Reference files from repo root | + +```json +{ + "tasks": { + "build": { + "inputs": ["$TURBO_DEFAULT$", "!README.md", "$TURBO_ROOT$/tsconfig.base.json"] + } + } +} +``` + +## env + +Environment variables to include in task hash. + +```json +{ + "tasks": { + "build": { + "env": [ + "API_URL", + "NEXT_PUBLIC_*", // Wildcard matching + "!DEBUG" // Exclude from hash + ] + } + } +} +``` + +Variables listed here affect cache hits - changing the value invalidates cache. + +## cache + +Enable/disable caching for a task. Default: `true`. + +```json +{ + "tasks": { + "dev": { "cache": false }, + "deploy": { "cache": false } + } +} +``` + +Disable for: dev servers, deploy commands, tasks with side effects. + +## persistent + +Mark long-running tasks that don't exit. Default: `false`. + +```json +{ + "tasks": { + "dev": { + "cache": false, + "persistent": true + } + } +} +``` + +Required for dev servers - without it, dependent tasks wait forever. + +## interactive + +Allow task to receive stdin input. Default: `false`. + +```json +{ + "tasks": { + "login": { + "cache": false, + "interactive": true + } + } +} +``` + +## outputLogs + +Control when logs are shown. Options: `full`, `hash-only`, `new-only`, `errors-only`, `none`. + +```json +{ + "tasks": { + "build": { + "outputLogs": "new-only" // Only show logs on cache miss + } + } +} +``` + +## with + +Run tasks alongside this task. For long-running tasks that need runtime dependencies. + +```json +{ + "tasks": { + "dev": { + "with": ["api#dev"], + "persistent": true, + "cache": false + } + } +} +``` + +Unlike `dependsOn`, `with` runs tasks concurrently (not sequentially). Use for dev servers that need other services running. + +## interruptible + +Allow `turbo watch` to restart the task on changes. Default: `false`. + +```json +{ + "tasks": { + "dev": { + "persistent": true, + "interruptible": true, + "cache": false + } + } +} +``` + +Use for dev servers that don't automatically detect dependency changes. + +## description + +Human-readable description of the task. + +```json +{ + "tasks": { + "build": { + "description": "Compiles the application for production deployment" + } + } +} +``` + +For documentation only - doesn't affect execution or caching. + +## passThroughEnv + +Environment variables available at runtime but NOT included in cache hash. + +```json +{ + "tasks": { + "build": { + "passThroughEnv": ["AWS_SECRET_KEY", "GITHUB_TOKEN"] + } + } +} +``` + +**Warning**: Changes to these vars won't cause cache misses. Use `env` if changes should invalidate cache. + +## extends (Package Configuration only) + +Control task inheritance in Package Configurations. + +```json +// packages/ui/turbo.json +{ + "extends": ["//"], + "tasks": { + "lint": { + "extends": false // Exclude from this package + } + } +} +``` + +| Value | Behavior | +| ---------------- | -------------------------------------------------------------- | +| `true` (default) | Inherit from root turbo.json | +| `false` | Exclude task from package, or define fresh without inheritance | diff --git a/packages/mosaic/framework/skills/turborepo/references/environment/RULE.md b/packages/mosaic/framework/skills/turborepo/references/environment/RULE.md new file mode 100644 index 00000000..790b01b5 --- /dev/null +++ b/packages/mosaic/framework/skills/turborepo/references/environment/RULE.md @@ -0,0 +1,96 @@ +# Environment Variables in Turborepo + +Turborepo provides fine-grained control over which environment variables affect task hashing and runtime availability. + +## Configuration Keys + +### `env` - Task-Specific Variables + +Variables that affect a specific task's hash. When these change, only that task rebuilds. + +```json +{ + "tasks": { + "build": { + "env": ["DATABASE_URL", "API_KEY"] + } + } +} +``` + +### `globalEnv` - Variables Affecting All Tasks + +Variables that affect EVERY task's hash. When these change, all tasks rebuild. + +```json +{ + "globalEnv": ["CI", "NODE_ENV"] +} +``` + +### `passThroughEnv` - Runtime-Only Variables (Not Hashed) + +Variables available at runtime but NOT included in hash. **Use with caution** - changes won't trigger rebuilds. + +```json +{ + "tasks": { + "deploy": { + "passThroughEnv": ["AWS_ACCESS_KEY_ID", "AWS_SECRET_ACCESS_KEY"] + } + } +} +``` + +### `globalPassThroughEnv` - Global Runtime Variables + +Same as `passThroughEnv` but for all tasks. + +```json +{ + "globalPassThroughEnv": ["GITHUB_TOKEN"] +} +``` + +## Wildcards and Negation + +### Wildcards + +Match multiple variables with `*`: + +```json +{ + "env": ["MY_API_*", "FEATURE_FLAG_*"] +} +``` + +This matches `MY_API_URL`, `MY_API_KEY`, `FEATURE_FLAG_DARK_MODE`, etc. + +### Negation + +Exclude variables (useful with framework inference): + +```json +{ + "env": ["!NEXT_PUBLIC_ANALYTICS_ID"] +} +``` + +## Complete Example + +```json +{ + "$schema": "https://turborepo.dev/schema.v2.json", + "globalEnv": ["CI", "NODE_ENV"], + "globalPassThroughEnv": ["GITHUB_TOKEN", "NPM_TOKEN"], + "tasks": { + "build": { + "env": ["DATABASE_URL", "API_*"], + "passThroughEnv": ["SENTRY_AUTH_TOKEN"] + }, + "test": { + "env": ["TEST_DATABASE_URL"] + } + } +} +``` diff --git a/packages/mosaic/framework/skills/turborepo/references/environment/gotchas.md b/packages/mosaic/framework/skills/turborepo/references/environment/gotchas.md new file mode 100644 index 00000000..e25f1450 --- /dev/null +++ b/packages/mosaic/framework/skills/turborepo/references/environment/gotchas.md @@ -0,0 +1,141 @@ +# Environment Variable Gotchas + +Common mistakes and how to fix them. + +## .env Files Must Be in `inputs` + +Turbo does NOT read `.env` files. Your framework (Next.js, Vite, etc.) or `dotenv` loads them. But Turbo needs to know when they change. + +**Wrong:** + +```json +{ + "tasks": { + "build": { + "env": ["DATABASE_URL"] + } + } +} +``` + +**Right:** + +```json +{ + "tasks": { + "build": { + "env": ["DATABASE_URL"], + "inputs": ["$TURBO_DEFAULT$", ".env", ".env.local", ".env.production"] + } + } +} +``` + +## Strict Mode Filters CI Variables + +In strict mode, CI provider variables (GITHUB_TOKEN, GITLAB_CI, etc.) are filtered unless explicitly listed. + +**Symptom:** Task fails with "authentication required" or "permission denied" in CI. + +**Solution:** + +```json +{ + "globalPassThroughEnv": ["GITHUB_TOKEN", "GITLAB_CI", "CI"] +} +``` + +## passThroughEnv Doesn't Affect Hash + +Variables in `passThroughEnv` are available at runtime but changes WON'T trigger rebuilds. + +**Dangerous example:** + +```json +{ + "tasks": { + "build": { + "passThroughEnv": ["API_URL"] + } + } +} +``` + +If `API_URL` changes from staging to production, Turbo may serve a cached build pointing to the wrong API. + +**Use passThroughEnv only for:** + +- Auth tokens that don't affect output (SENTRY_AUTH_TOKEN) +- CI metadata (GITHUB_RUN_ID) +- Variables consumed after build (deploy credentials) + +## Runtime-Created Variables Are Invisible + +Turbo captures env vars at startup. Variables created during execution aren't seen. + +**Won't work:** + +```bash +# In package.json scripts +"build": "export API_URL=$COMPUTED_VALUE && next build" +``` + +**Solution:** Set vars before invoking turbo: + +```bash +API_URL=$COMPUTED_VALUE turbo run build +``` + +## Different .env Files for Different Environments + +If you use `.env.development` and `.env.production`, both should be in inputs. + +```json +{ + "tasks": { + "build": { + "inputs": [ + "$TURBO_DEFAULT$", + ".env", + ".env.local", + ".env.development", + ".env.development.local", + ".env.production", + ".env.production.local" + ] + } + } +} +``` + +## Complete Next.js Example + +```json +{ + "$schema": "https://turborepo.dev/schema.v2.json", + "globalEnv": ["CI", "NODE_ENV", "VERCEL"], + "globalPassThroughEnv": ["GITHUB_TOKEN", "VERCEL_URL"], + "tasks": { + "build": { + "dependsOn": ["^build"], + "env": ["DATABASE_URL", "NEXT_PUBLIC_*", "!NEXT_PUBLIC_ANALYTICS_ID"], + "passThroughEnv": ["SENTRY_AUTH_TOKEN"], + "inputs": [ + "$TURBO_DEFAULT$", + ".env", + ".env.local", + ".env.production", + ".env.production.local" + ], + "outputs": [".next/**", "!.next/cache/**"] + } + } +} +``` + +This config: + +- Hashes DATABASE*URL and NEXT_PUBLIC*\* vars (except analytics) +- Passes through SENTRY_AUTH_TOKEN without hashing +- Includes all .env file variants in the hash +- Makes CI tokens available globally diff --git a/packages/mosaic/framework/skills/turborepo/references/environment/modes.md b/packages/mosaic/framework/skills/turborepo/references/environment/modes.md new file mode 100644 index 00000000..2e655331 --- /dev/null +++ b/packages/mosaic/framework/skills/turborepo/references/environment/modes.md @@ -0,0 +1,101 @@ +# Environment Modes + +Turborepo supports different modes for handling environment variables during task execution. + +## Strict Mode (Default) + +Only explicitly configured variables are available to tasks. + +**Behavior:** + +- Tasks only see vars listed in `env`, `globalEnv`, `passThroughEnv`, or `globalPassThroughEnv` +- Unlisted vars are filtered out +- Tasks fail if they require unlisted variables + +**Benefits:** + +- Guarantees cache correctness +- Prevents accidental dependencies on system vars +- Reproducible builds across machines + +```bash +# Explicit (though it's the default) +turbo run build --env-mode=strict +``` + +## Loose Mode + +All system environment variables are available to tasks. + +```bash +turbo run build --env-mode=loose +``` + +**Behavior:** + +- Every system env var is passed through +- Only vars in `env`/`globalEnv` affect the hash +- Other vars are available but NOT hashed + +**Risks:** + +- Cache may restore incorrect results if unhashed vars changed +- "Works on my machine" bugs +- CI vs local environment mismatches + +**Use case:** Migrating legacy projects or debugging strict mode issues. + +## Framework Inference (Automatic) + +Turborepo automatically detects frameworks and includes their conventional env vars. + +### Inferred Variables by Framework + +| Framework | Pattern | +| ---------------- | ------------------- | +| Next.js | `NEXT_PUBLIC_*` | +| Vite | `VITE_*` | +| Create React App | `REACT_APP_*` | +| Gatsby | `GATSBY_*` | +| Nuxt | `NUXT_*`, `NITRO_*` | +| Expo | `EXPO_PUBLIC_*` | +| Astro | `PUBLIC_*` | +| SvelteKit | `PUBLIC_*` | +| Remix | `REMIX_*` | +| Redwood | `REDWOOD_ENV_*` | +| Sanity | `SANITY_STUDIO_*` | +| Solid | `VITE_*` | + +### Disabling Framework Inference + +Globally via CLI: + +```bash +turbo run build --framework-inference=false +``` + +Or exclude specific patterns in config: + +```json +{ + "tasks": { + "build": { + "env": ["!NEXT_PUBLIC_*"] + } + } +} +``` + +### Why Disable? + +- You want explicit control over all env vars +- Framework vars shouldn't bust the cache (e.g., analytics IDs) +- Debugging unexpected cache misses + +## Checking Environment Mode + +Use `--dry` to see which vars affect each task: + +```bash +turbo run build --dry=json | jq '.tasks[].environmentVariables' +``` diff --git a/packages/mosaic/framework/skills/turborepo/references/filtering/RULE.md b/packages/mosaic/framework/skills/turborepo/references/filtering/RULE.md new file mode 100644 index 00000000..04e19cc8 --- /dev/null +++ b/packages/mosaic/framework/skills/turborepo/references/filtering/RULE.md @@ -0,0 +1,148 @@ +# Turborepo Filter Syntax Reference + +## Running Only Changed Packages: `--affected` + +**The primary way to run only changed packages is `--affected`:** + +```bash +# Run build/test/lint only in changed packages and their dependents +turbo run build test lint --affected +``` + +This compares your current branch to the default branch (usually `main` or `master`) and runs tasks in: + +1. Packages with file changes +2. Packages that depend on changed packages (dependents) + +### Why Include Dependents? + +If you change `@repo/ui`, packages that import `@repo/ui` (like `apps/web`) need to re-run their tasks to verify they still work with the changes. + +### Customizing --affected + +```bash +# Use a different base branch +turbo run build --affected --affected-base=origin/develop + +# Use a different head (current state) +turbo run build --affected --affected-head=HEAD~5 +``` + +### Common CI Pattern + +```yaml +# .github/workflows/ci.yml +- run: turbo run build test lint --affected +``` + +This is the most efficient CI setup - only run tasks for what actually changed. + +--- + +## Manual Git Comparison with --filter + +For more control, use `--filter` with git comparison syntax: + +```bash +# Changed packages + dependents (same as --affected) +turbo run build --filter=...[origin/main] + +# Only changed packages (no dependents) +turbo run build --filter=[origin/main] + +# Changed packages + dependencies (packages they import) +turbo run build --filter=[origin/main]... + +# Changed since last commit +turbo run build --filter=...[HEAD^1] + +# Changed between two commits +turbo run build --filter=[a1b2c3d...e4f5g6h] +``` + +### Comparison Syntax + +| Syntax | Meaning | +| ------------- | ------------------------------------- | +| `[ref]` | Packages changed since `ref` | +| `...[ref]` | Changed packages + their dependents | +| `[ref]...` | Changed packages + their dependencies | +| `...[ref]...` | Dependencies, changed, AND dependents | + +--- + +## Other Filter Types + +Filters select which packages to include in a `turbo run` invocation. + +### Basic Syntax + +```bash +turbo run build --filter= +turbo run build -F +``` + +Multiple filters combine as a union (packages matching ANY filter run). + +### By Package Name + +```bash +--filter=web # exact match +--filter=@acme/* # scope glob +--filter=*-app # name glob +``` + +### By Directory + +```bash +--filter=./apps/* # all packages in apps/ +--filter=./packages/ui # specific directory +``` + +### By Dependencies/Dependents + +| Syntax | Meaning | +| ----------- | -------------------------------------- | +| `pkg...` | Package AND all its dependencies | +| `...pkg` | Package AND all its dependents | +| `...pkg...` | Dependencies, package, AND dependents | +| `^pkg...` | Only dependencies (exclude pkg itself) | +| `...^pkg` | Only dependents (exclude pkg itself) | + +### Negation + +Exclude packages with `!`: + +```bash +--filter=!web # exclude web +--filter=./apps/* --filter=!admin # apps except admin +``` + +### Task Identifiers + +Run a specific task in a specific package: + +```bash +turbo run web#build # only web's build task +turbo run web#build api#test # web build + api test +``` + +### Combining Filters + +Multiple `--filter` flags create a union: + +```bash +turbo run build --filter=web --filter=api # runs in both +``` + +--- + +## Quick Reference: Changed Packages + +| Goal | Command | +| ---------------------------------- | ----------------------------------------------------------- | +| Changed + dependents (recommended) | `turbo run build --affected` | +| Custom base branch | `turbo run build --affected --affected-base=origin/develop` | +| Only changed (no dependents) | `turbo run build --filter=[origin/main]` | +| Changed + dependencies | `turbo run build --filter=[origin/main]...` | +| Since last commit | `turbo run build --filter=...[HEAD^1]` | diff --git a/packages/mosaic/framework/skills/turborepo/references/filtering/patterns.md b/packages/mosaic/framework/skills/turborepo/references/filtering/patterns.md new file mode 100644 index 00000000..17b9f1c5 --- /dev/null +++ b/packages/mosaic/framework/skills/turborepo/references/filtering/patterns.md @@ -0,0 +1,152 @@ +# Common Filter Patterns + +Practical examples for typical monorepo scenarios. + +## Single Package + +Run task in one package: + +```bash +turbo run build --filter=web +turbo run test --filter=@acme/api +``` + +## Package with Dependencies + +Build a package and everything it depends on: + +```bash +turbo run build --filter=web... +``` + +Useful for: ensuring all dependencies are built before the target. + +## Package Dependents + +Run in all packages that depend on a library: + +```bash +turbo run test --filter=...ui +``` + +Useful for: testing consumers after changing a shared package. + +## Dependents Only (Exclude Target) + +Test packages that depend on ui, but not ui itself: + +```bash +turbo run test --filter=...^ui +``` + +## Changed Packages + +Run only in packages with file changes since last commit: + +```bash +turbo run lint --filter=[HEAD^1] +``` + +Since a specific branch point: + +```bash +turbo run lint --filter=[main...HEAD] +``` + +## Changed + Dependents (PR Builds) + +Run in changed packages AND packages that depend on them: + +```bash +turbo run build test --filter=...[HEAD^1] +``` + +Or use the shortcut: + +```bash +turbo run build test --affected +``` + +## Directory-Based + +Run in all apps: + +```bash +turbo run build --filter=./apps/* +``` + +Run in specific directories: + +```bash +turbo run build --filter=./apps/web --filter=./apps/api +``` + +## Scope-Based + +Run in all packages under a scope: + +```bash +turbo run build --filter=@acme/* +``` + +## Exclusions + +Run in all apps except admin: + +```bash +turbo run build --filter=./apps/* --filter=!admin +``` + +Run everywhere except specific packages: + +```bash +turbo run lint --filter=!legacy-app --filter=!deprecated-pkg +``` + +## Complex Combinations + +Apps that changed, plus their dependents: + +```bash +turbo run build --filter=...[HEAD^1] --filter=./apps/* +``` + +All packages except docs, but only if changed: + +```bash +turbo run build --filter=[main...HEAD] --filter=!docs +``` + +## Debugging Filters + +Use `--dry` to see what would run without executing: + +```bash +turbo run build --filter=web... --dry +``` + +Use `--dry=json` for machine-readable output: + +```bash +turbo run build --filter=...[HEAD^1] --dry=json +``` + +## CI/CD Patterns + +PR validation (most common): + +```bash +turbo run build test lint --affected +``` + +Deploy only changed apps: + +```bash +turbo run deploy --filter=./apps/* --filter=[main...HEAD] +``` + +Full rebuild of specific app and deps: + +```bash +turbo run build --filter=production-app... +``` diff --git a/packages/mosaic/framework/skills/turborepo/references/watch/RULE.md b/packages/mosaic/framework/skills/turborepo/references/watch/RULE.md new file mode 100644 index 00000000..44bcf13e --- /dev/null +++ b/packages/mosaic/framework/skills/turborepo/references/watch/RULE.md @@ -0,0 +1,99 @@ +# turbo watch + +Full docs: https://turborepo.dev/docs/reference/watch + +Re-run tasks automatically when code changes. Dependency-aware. + +```bash +turbo watch [tasks] +``` + +## Basic Usage + +```bash +# Watch and re-run build task when code changes +turbo watch build + +# Watch multiple tasks +turbo watch build test lint +``` + +Tasks re-run in order configured in `turbo.json` when source files change. + +## With Persistent Tasks + +Persistent tasks (`"persistent": true`) won't exit, so they can't be depended on. They work the same in `turbo watch` as `turbo run`. + +### Dependency-Aware Persistent Tasks + +If your tool has built-in watching (like `next dev`), use its watcher: + +```json +{ + "tasks": { + "dev": { + "persistent": true, + "cache": false + } + } +} +``` + +### Non-Dependency-Aware Tools + +For tools that don't detect dependency changes, use `interruptible`: + +```json +{ + "tasks": { + "dev": { + "persistent": true, + "interruptible": true, + "cache": false + } + } +} +``` + +`turbo watch` will restart interruptible tasks when dependencies change. + +## Limitations + +### Caching + +Caching is experimental with watch mode: + +```bash +turbo watch your-tasks --experimental-write-cache +``` + +### Task Outputs in Source Control + +If tasks write files tracked by git, watch mode may loop infinitely. Watch mode uses file hashes to prevent this but it's not foolproof. + +**Recommendation**: Remove task outputs from git. + +## vs turbo run + +| Feature | `turbo run` | `turbo watch` | +| ----------------- | ----------- | ------------- | +| Runs once | Yes | No | +| Re-runs on change | No | Yes | +| Caching | Full | Experimental | +| Use case | CI, one-off | Development | + +## Common Patterns + +### Development Workflow + +```bash +# Run dev servers and watch for build changes +turbo watch dev build +``` + +### Type Checking During Development + +```bash +# Watch and re-run type checks +turbo watch check-types +``` diff --git a/packages/mosaic/framework/skills/two-factor-authentication-best-practices/SKILL.md b/packages/mosaic/framework/skills/two-factor-authentication-best-practices/SKILL.md new file mode 100644 index 00000000..2356bb09 --- /dev/null +++ b/packages/mosaic/framework/skills/two-factor-authentication-best-practices/SKILL.md @@ -0,0 +1,417 @@ +--- +name: two-factor-authentication-best-practices +description: This skill provides guidance and enforcement rules for implementing secure two-factor authentication (2FA) using Better Auth's twoFactor plugin. +--- + +## Setting Up Two-Factor Authentication + +When adding 2FA to your application, configure the `twoFactor` plugin with your app name as the issuer. This name appears in authenticator apps when users scan the QR code. + +```ts +import { betterAuth } from 'better-auth'; +import { twoFactor } from 'better-auth/plugins'; + +export const auth = betterAuth({ + appName: 'My App', // Used as the default issuer for TOTP + plugins: [ + twoFactor({ + issuer: 'My App', // Optional: override the app name for 2FA specifically + }), + ], +}); +``` + +**Note**: After adding the plugin, run `npx @better-auth/cli migrate` to add the required database fields and tables. + +### Client-Side Setup + +Add the client plugin and configure the redirect behavior for 2FA verification: + +```ts +import { createAuthClient } from 'better-auth/client'; +import { twoFactorClient } from 'better-auth/client/plugins'; + +export const authClient = createAuthClient({ + plugins: [ + twoFactorClient({ + onTwoFactorRedirect() { + window.location.href = '/2fa'; // Redirect to your 2FA verification page + }, + }), + ], +}); +``` + +## Enabling 2FA for Users + +When a user enables 2FA, require their password for verification. The enable endpoint returns a TOTP URI for QR code generation and backup codes for account recovery. + +```ts +const enable2FA = async (password: string) => { + const { data, error } = await authClient.twoFactor.enable({ + password, + }); + + if (data) { + // data.totpURI - Use this to generate a QR code + // data.backupCodes - Display these to the user for safekeeping + } +}; +``` + +**Important**: The `twoFactorEnabled` flag on the user is not set to `true` until the user successfully verifies their first TOTP code. This ensures users have properly configured their authenticator app before 2FA is fully active. + +### Skipping Initial Verification + +If you want to enable 2FA immediately without requiring verification, set `skipVerificationOnEnable`: + +```ts +twoFactor({ + skipVerificationOnEnable: true, // Not recommended for most use cases +}); +``` + +**Note**: This is generally not recommended as it doesn't confirm the user has successfully set up their authenticator app. + +## TOTP (Authenticator App) + +TOTP generates time-based codes using an authenticator app (Google Authenticator, Authy, etc.). Codes are valid for 30 seconds by default. + +### Displaying the QR Code + +Use the TOTP URI to generate a QR code for users to scan: + +```tsx +import QRCode from 'react-qr-code'; + +const TotpSetup = ({ totpURI }: { totpURI: string }) => { + return ; +}; +``` + +### Verifying TOTP Codes + +Better Auth accepts codes from one period before and one after the current time, accommodating minor clock differences between devices: + +```ts +const verifyTotp = async (code: string) => { + const { data, error } = await authClient.twoFactor.verifyTotp({ + code, + trustDevice: true, // Optional: remember this device for 30 days + }); +}; +``` + +### TOTP Configuration Options + +```ts +twoFactor({ + totpOptions: { + digits: 6, // 6 or 8 digits (default: 6) + period: 30, // Code validity period in seconds (default: 30) + }, +}); +``` + +## OTP (Email/SMS) + +OTP sends a one-time code to the user's email or phone. You must implement the `sendOTP` function to deliver codes. + +### Configuring OTP Delivery + +```ts +import { betterAuth } from 'better-auth'; +import { twoFactor } from 'better-auth/plugins'; +import { sendEmail } from './email'; + +export const auth = betterAuth({ + plugins: [ + twoFactor({ + otpOptions: { + sendOTP: async ({ user, otp }, ctx) => { + await sendEmail({ + to: user.email, + subject: 'Your verification code', + text: `Your code is: ${otp}`, + }); + }, + period: 5, // Code validity in minutes (default: 3) + digits: 6, // Number of digits (default: 6) + allowedAttempts: 5, // Max verification attempts (default: 5) + }, + }), + ], +}); +``` + +### Sending and Verifying OTP + +```ts +// Request an OTP to be sent +const sendOtp = async () => { + const { data, error } = await authClient.twoFactor.sendOtp(); +}; + +// Verify the OTP code +const verifyOtp = async (code: string) => { + const { data, error } = await authClient.twoFactor.verifyOtp({ + code, + trustDevice: true, + }); +}; +``` + +### OTP Storage Security + +Configure how OTP codes are stored in the database: + +```ts +twoFactor({ + otpOptions: { + storeOTP: 'encrypted', // Options: "plain", "encrypted", "hashed" + }, +}); +``` + +For custom encryption: + +```ts +twoFactor({ + otpOptions: { + storeOTP: { + encrypt: async (token) => myEncrypt(token), + decrypt: async (token) => myDecrypt(token), + }, + }, +}); +``` + +## Backup Codes + +Backup codes provide account recovery when users lose access to their authenticator app or phone. They are generated automatically when 2FA is enabled. + +### Displaying Backup Codes + +Always show backup codes to users when they enable 2FA: + +```tsx +const BackupCodes = ({ codes }: { codes: string[] }) => { + return ( +
+

Save these codes in a secure location:

+
    + {codes.map((code, i) => ( +
  • {code}
  • + ))} +
+
+ ); +}; +``` + +### Regenerating Backup Codes + +When users need new codes, regenerate them (this invalidates all previous codes): + +```ts +const regenerateBackupCodes = async (password: string) => { + const { data, error } = await authClient.twoFactor.generateBackupCodes({ + password, + }); + // data.backupCodes contains the new codes +}; +``` + +### Using Backup Codes for Recovery + +```ts +const verifyBackupCode = async (code: string) => { + const { data, error } = await authClient.twoFactor.verifyBackupCode({ + code, + trustDevice: true, + }); +}; +``` + +**Note**: Each backup code can only be used once and is removed from the database after successful verification. + +### Backup Code Configuration + +```ts +twoFactor({ + backupCodeOptions: { + amount: 10, // Number of codes to generate (default: 10) + length: 10, // Length of each code (default: 10) + storeBackupCodes: 'encrypted', // Options: "plain", "encrypted" + }, +}); +``` + +## Handling 2FA During Sign-In + +When a user with 2FA enabled signs in, the response includes `twoFactorRedirect: true`: + +```ts +const signIn = async (email: string, password: string) => { + const { data, error } = await authClient.signIn.email( + { + email, + password, + }, + { + onSuccess(context) { + if (context.data.twoFactorRedirect) { + // Redirect to 2FA verification page + window.location.href = '/2fa'; + } + }, + }, + ); +}; +``` + +### Server-Side 2FA Detection + +When using `auth.api.signInEmail` on the server, check for 2FA redirect: + +```ts +const response = await auth.api.signInEmail({ + body: { + email: 'user@example.com', + password: 'password', + }, +}); + +if ('twoFactorRedirect' in response) { + // Handle 2FA verification +} +``` + +## Trusted Devices + +Trusted devices allow users to skip 2FA verification on subsequent sign-ins for a configurable period. + +### Enabling Trust on Verification + +Pass `trustDevice: true` when verifying 2FA: + +```ts +await authClient.twoFactor.verifyTotp({ + code: '123456', + trustDevice: true, +}); +``` + +### Configuring Trust Duration + +```ts +twoFactor({ + trustDeviceMaxAge: 30 * 24 * 60 * 60, // 30 days in seconds (default) +}); +``` + +**Note**: The trust period refreshes on each successful sign-in within the trust window. + +## Security Considerations + +### Session Management + +During the 2FA flow: + +1. User signs in with credentials +2. Session cookie is removed (not yet authenticated) +3. A temporary two-factor cookie is set (default: 10-minute expiration) +4. User verifies via TOTP, OTP, or backup code +5. Session cookie is created upon successful verification + +Configure the two-factor cookie expiration: + +```ts +twoFactor({ + twoFactorCookieMaxAge: 600, // 10 minutes in seconds (default) +}); +``` + +### Rate Limiting + +Better Auth applies built-in rate limiting to all 2FA endpoints (3 requests per 10 seconds). For OTP verification, additional attempt limiting is applied: + +```ts +twoFactor({ + otpOptions: { + allowedAttempts: 5, // Max attempts per OTP code (default: 5) + }, +}); +``` + +### Encryption at Rest + +- TOTP secrets are encrypted using symmetric encryption with your auth secret +- Backup codes are stored encrypted by default +- OTP codes can be configured for plain, encrypted, or hashed storage + +### Constant-Time Comparison + +Better Auth uses constant-time comparison for OTP verification to prevent timing attacks. + +### Credential Account Requirement + +Two-factor authentication can only be enabled for credential (email/password) accounts. For social accounts, it's assumed the provider already handles 2FA. + +## Disabling 2FA + +Allow users to disable 2FA with password confirmation: + +```ts +const disable2FA = async (password: string) => { + const { data, error } = await authClient.twoFactor.disable({ + password, + }); +}; +``` + +**Note**: When 2FA is disabled, trusted device records are revoked. + +## Complete Configuration Example + +```ts +import { betterAuth } from 'better-auth'; +import { twoFactor } from 'better-auth/plugins'; +import { sendEmail } from './email'; + +export const auth = betterAuth({ + appName: 'My App', + plugins: [ + twoFactor({ + // TOTP settings + issuer: 'My App', + totpOptions: { + digits: 6, + period: 30, + }, + // OTP settings + otpOptions: { + sendOTP: async ({ user, otp }) => { + await sendEmail({ + to: user.email, + subject: 'Your verification code', + text: `Your code is: ${otp}`, + }); + }, + period: 5, + allowedAttempts: 5, + storeOTP: 'encrypted', + }, + // Backup code settings + backupCodeOptions: { + amount: 10, + length: 10, + storeBackupCodes: 'encrypted', + }, + // Session settings + twoFactorCookieMaxAge: 600, // 10 minutes + trustDeviceMaxAge: 30 * 24 * 60 * 60, // 30 days + }), + ], +}); +``` diff --git a/packages/mosaic/framework/skills/ui-animation/SKILL.md b/packages/mosaic/framework/skills/ui-animation/SKILL.md new file mode 100644 index 00000000..fc60e0b9 --- /dev/null +++ b/packages/mosaic/framework/skills/ui-animation/SKILL.md @@ -0,0 +1,59 @@ +--- +name: ui-animation +description: Guidelines and examples for UI motion and animation. Use when designing, implementing, or reviewing motion, easing, timing, reduced-motion behaviour, CSS transitions, keyframes, framer-motion, or spring animations. +--- + +# UI Animation + +## Core rules + +- Animate to clarify cause/effect or add deliberate delight. +- Keep interactions fast (200-300ms; up to 1s only for illustrative motion). +- Never animate keyboard interactions (arrow-key navigation, shortcut responses, tab/focus). +- Prefer CSS; use WAAPI or JS only when needed. +- Make animations interruptible and input-driven. +- Honor `prefers-reduced-motion` (reduce or disable). + +## What to animate + +- For movement and spatial change, animate only `transform` and `opacity`. +- For simple state feedback, `color`, `background-color`, and `opacity` transitions are acceptable. +- Never animate layout properties; never use `transition: all`. +- Avoid `filter` animation for core interactions; if unavoidable, keep blur <= 20px. +- SVG: apply transforms on a `` wrapper with `transform-box: fill-box; transform-origin: center`. +- Disable transitions during theme switches. + +## Spatial and sequencing + +- Set `transform-origin` at the trigger point. +- For dialogs/menus, start around `scale(0.85-0.9)`; avoid `scale(0)`. +- Stagger reveals <= 50ms. + +## Easing defaults + +- Enter and transform-based hover: `cubic-bezier(0.22, 1, 0.36, 1)`. +- Move: `cubic-bezier(0.25, 1, 0.5, 1)`. +- Simple hover colour/background/opacity: `200ms ease`. +- Avoid `ease-in` for UI (feels slow). + +## Accessibility + +- If `transform` is used, disable it in `prefers-reduced-motion`. +- Disable hover transitions on touch devices via `@media (hover: hover) and (pointer: fine)`. + +## Performance + +- Pause looping animations off-screen. +- Toggle `will-change` only during heavy motion and only for `transform`/`opacity`. +- Prefer `transform` over positional props in animation libraries. +- Do not animate drag gestures using CSS variables. + +## Reference + +- Snippets and practical tips: [examples.md](examples.md) + +## Workflow + +1. Start with the core rules, then pick a reference snippet from [examples.md](examples.md). +2. Keep motion functional; honor `prefers-reduced-motion`. +3. When reviewing, cite file paths and line numbers and propose concrete fixes. diff --git a/packages/mosaic/framework/skills/ui-animation/examples.md b/packages/mosaic/framework/skills/ui-animation/examples.md new file mode 100644 index 00000000..2f1ea14a --- /dev/null +++ b/packages/mosaic/framework/skills/ui-animation/examples.md @@ -0,0 +1,270 @@ +# UI Animation Examples + +Snippets and tips for the core rules in `SKILL.md`. + +## Table of contents + +- [Enter and exit](#enter-and-exit) +- [Spatial rules and stagger](#spatial-rules-and-stagger) +- [Drawer (move easing)](#drawer-move-easing) +- [Hover transitions](#hover-transitions) +- [Reduced motion](#reduced-motion) +- [Origin-aware animations](#origin-aware-animations) +- [Performance recipes](#performance-recipes) +- [Practical tips](#practical-tips) + +## Enter and exit + +```css +/* Toast.module.css */ +.toast { + transform: translate3d(0, 6px, 0); + opacity: 0; + transition: + transform 220ms cubic-bezier(0.22, 1, 0.36, 1), + opacity 220ms cubic-bezier(0.22, 1, 0.36, 1); +} +.toast[data-open='true'] { + transform: translate3d(0, 0, 0); + opacity: 1; +} + +/* Disable transitions during theme switch */ +[data-theme-switching='true'] * { + transition: none !important; +} +``` + +```tsx +// app/components/Panel.tsx +'use client'; +import { motion, useReducedMotion } from 'framer-motion'; + +export function Panel() { + const reduceMotion = useReducedMotion(); + + return ( + + ); +} +``` + +## Spatial rules and stagger + +```css +/* Menu.module.css */ +.menu { + transform-origin: top right; + transform: scale(0.88); + opacity: 0; + transition: + transform 200ms cubic-bezier(0.22, 1, 0.36, 1), + opacity 200ms cubic-bezier(0.22, 1, 0.36, 1); +} +.menu[data-open='true'] { + transform: scale(1); + opacity: 1; +} + +.list > * { + animation: fade-in 220ms cubic-bezier(0.22, 1, 0.36, 1) both; +} +.list > *:nth-child(2) { + animation-delay: 50ms; +} +.list > *:nth-child(3) { + animation-delay: 100ms; +} +``` + +```tsx +const listVariants = { + show: { transition: { staggerChildren: 0.05 } }, +}; +``` + +## Drawer (move easing) + +```css +.drawer { + transition: transform 240ms cubic-bezier(0.25, 1, 0.5, 1); +} +``` + +```tsx + +``` + +## Hover transitions + +```css +/* Link.module.css */ +@media (hover: hover) and (pointer: fine) { + .link { + transition: + color 200ms ease, + opacity 200ms ease; + } + .link:hover { + opacity: 0.8; + } +} +``` + +## Reduced motion + +```css +@media (prefers-reduced-motion: reduce) { + .menu, + .toast { + transform: none; + transition: none; + } +} +``` + +```tsx +'use client'; +import { motion, useReducedMotion } from 'framer-motion'; + +export function AnimatedCard() { + const reduceMotion = useReducedMotion(); + return ( + + ); +} +``` + +## Origin-aware animations + +```css +.popover[data-side='top'] { + transform-origin: bottom center; +} +.popover[data-side='bottom'] { + transform-origin: top center; +} +.popover[data-side='left'] { + transform-origin: center right; +} +.popover[data-side='right'] { + transform-origin: center left; +} +``` + +## Performance recipes + +### Pause looping animations off-screen + +```ts +// app/hooks/usePauseOffscreen.ts +'use client'; +import { useEffect, useRef } from 'react'; + +export function usePauseOffscreen() { + const ref = useRef(null); + useEffect(() => { + const el = ref.current; + if (!el) return; + const io = new IntersectionObserver(([entry]) => { + el.style.animationPlayState = entry.isIntersecting ? 'running' : 'paused'; + }); + io.observe(el); + return () => io.disconnect(); + }, []); + return ref; +} +``` + +### Toggle will-change during animation + +```css +.animating { + will-change: transform, opacity; +} +``` + +### Spring defaults (framer-motion) + +```tsx + +``` + +## Practical tips + +### Record your animations + +When something feels off, record the animation and play it back frame by frame. + +### Fix shaky 1px shifts + +Elements can shift by 1px at the start/end of CSS transforms due to GPU/CPU handoff. Apply `will-change: transform` during the animation (not permanently) to keep compositing on the GPU. + +### Scale buttons on press + +```css +button:active { + transform: scale(0.97); + opacity: 0.9; +} +``` + +### Avoid animating from scale(0) + +```css +.element { + transform: scale(0.95); + opacity: 0; +} +.element.visible { + transform: scale(1); + opacity: 1; +} +``` + +### Skip animation on subsequent tooltips + +```css +.tooltip { + transition: + transform 125ms ease-out, + opacity 125ms ease-out; + transform-origin: var(--transform-origin); +} +.tooltip[data-starting-style], +.tooltip[data-ending-style] { + opacity: 0; + transform: scale(0.97); +} +.tooltip[data-instant] { + transition-duration: 0ms; +} +``` + +### Fix hover flicker + +Apply the hover effect on a parent, animate the child: + +```css +.box:hover .box-inner { + transform: translateY(-20%); +} +.box-inner { + transition: transform 200ms ease; +} +``` diff --git a/packages/mosaic/framework/skills/unocss/GENERATION.md b/packages/mosaic/framework/skills/unocss/GENERATION.md new file mode 100644 index 00000000..dff7d606 --- /dev/null +++ b/packages/mosaic/framework/skills/unocss/GENERATION.md @@ -0,0 +1,5 @@ +# Generation Info + +- **Source:** `sources/unocss` +- **Git SHA:** `2f7f267d0cc0c43d44357208aabb35b049359a08` +- **Generated:** 2026-01-28 diff --git a/packages/mosaic/framework/skills/unocss/SKILL.md b/packages/mosaic/framework/skills/unocss/SKILL.md new file mode 100644 index 00000000..61220a39 --- /dev/null +++ b/packages/mosaic/framework/skills/unocss/SKILL.md @@ -0,0 +1,64 @@ +--- +name: unocss +description: UnoCSS instant atomic CSS engine, superset of Tailwind CSS. Use when configuring UnoCSS, writing utility rules, shortcuts, or working with presets like Wind, Icons, Attributify. +metadata: + author: Anthony Fu + version: '2026.1.28' + source: Generated from https://github.com/unocss/unocss, scripts located at https://github.com/antfu/skills +--- + +UnoCSS is an instant atomic CSS engine designed to be flexible and extensible. The core is un-opinionated - all CSS utilities are provided via presets. It's a superset of Tailwind CSS, so you can reuse your Tailwind knowledge for basic syntax usage. + +**Important:** Before writing UnoCSS code, agents should check for `uno.config.*` or `unocss.config.*` files in the project root to understand what presets, rules, and shortcuts are available. If the project setup is unclear, avoid using attributify mode and other advanced features - stick to basic `class` usage. + +> The skill is based on UnoCSS 66.x, generated at 2026-01-28. + +## Core + +| Topic | Description | Reference | +| -------------------- | --------------------------------------------------------- | ------------------------------------------------ | +| Configuration | Config file setup and all configuration options | [core-config](references/core-config.md) | +| Rules | Static and dynamic rules for generating CSS utilities | [core-rules](references/core-rules.md) | +| Shortcuts | Combine multiple rules into single shorthands | [core-shortcuts](references/core-shortcuts.md) | +| Theme | Theming system for colors, breakpoints, and design tokens | [core-theme](references/core-theme.md) | +| Variants | Apply variations like hover:, dark:, responsive to rules | [core-variants](references/core-variants.md) | +| Extracting | How UnoCSS extracts utilities from source code | [core-extracting](references/core-extracting.md) | +| Safelist & Blocklist | Force include or exclude specific utilities | [core-safelist](references/core-safelist.md) | +| Layers & Preflights | CSS layer ordering and raw CSS injection | [core-layers](references/core-layers.md) | + +## Presets + +### Main Presets + +| Topic | Description | Reference | +| ------------ | ----------------------------------------------------------- | ------------------------------------------ | +| Preset Wind3 | Tailwind CSS v3 / Windi CSS compatible preset (most common) | [preset-wind3](references/preset-wind3.md) | +| Preset Wind4 | Tailwind CSS v4 compatible preset with modern CSS features | [preset-wind4](references/preset-wind4.md) | +| Preset Mini | Minimal preset with essential utilities for custom builds | [preset-mini](references/preset-mini.md) | + +### Feature Presets + +| Topic | Description | Reference | +| ------------------ | --------------------------------------------------- | ------------------------------------------------------ | +| Preset Icons | Pure CSS icons using Iconify with any icon set | [preset-icons](references/preset-icons.md) | +| Preset Attributify | Group utilities in HTML attributes instead of class | [preset-attributify](references/preset-attributify.md) | +| Preset Typography | Prose classes for typographic defaults | [preset-typography](references/preset-typography.md) | +| Preset Web Fonts | Easy Google Fonts and other web fonts integration | [preset-web-fonts](references/preset-web-fonts.md) | +| Preset Tagify | Use utilities as HTML tag names | [preset-tagify](references/preset-tagify.md) | +| Preset Rem to Px | Convert rem units to px for utilities | [preset-rem-to-px](references/preset-rem-to-px.md) | + +## Transformers + +| Topic | Description | Reference | +| --------------- | ----------------------------------------------------- | ------------------------------------------------------------------------ | +| Variant Group | Shorthand for grouping utilities with common prefixes | [transformer-variant-group](references/transformer-variant-group.md) | +| Directives | CSS directives: @apply, @screen, theme(), icon() | [transformer-directives](references/transformer-directives.md) | +| Compile Class | Compile multiple classes into one hashed class | [transformer-compile-class](references/transformer-compile-class.md) | +| Attributify JSX | Support valueless attributify in JSX/TSX | [transformer-attributify-jsx](references/transformer-attributify-jsx.md) | + +## Integrations + +| Topic | Description | Reference | +| ---------------- | ------------------------------------------------------- | ---------------------------------------------------- | +| Vite Integration | Setting up UnoCSS with Vite and framework-specific tips | [integrations-vite](references/integrations-vite.md) | +| Nuxt Integration | UnoCSS module for Nuxt applications | [integrations-nuxt](references/integrations-nuxt.md) | diff --git a/packages/mosaic/framework/skills/unocss/references/core-config.md b/packages/mosaic/framework/skills/unocss/references/core-config.md new file mode 100644 index 00000000..98c9e602 --- /dev/null +++ b/packages/mosaic/framework/skills/unocss/references/core-config.md @@ -0,0 +1,192 @@ +--- +name: unocss-configuration +description: Config file setup and all configuration options for UnoCSS +--- + +# UnoCSS Configuration + +UnoCSS is configured via a dedicated config file in your project root. + +## Config File + +**Recommended:** Use a dedicated `uno.config.ts` file for best IDE support and HMR. + +```ts +// uno.config.ts +import { + defineConfig, + presetAttributify, + presetIcons, + presetTypography, + presetWebFonts, + presetWind3, + transformerDirectives, + transformerVariantGroup, +} from 'unocss'; + +export default defineConfig({ + shortcuts: [ + // ... + ], + theme: { + colors: { + // ... + }, + }, + presets: [ + presetWind3(), + presetAttributify(), + presetIcons(), + presetTypography(), + presetWebFonts({ + fonts: { + // ... + }, + }), + ], + transformers: [transformerDirectives(), transformerVariantGroup()], +}); +``` + +UnoCSS automatically looks for `uno.config.{js,ts,mjs,mts}` or `unocss.config.{js,ts,mjs,mts}` in the project root. + +## Key Configuration Options + +### rules + +Define CSS utility rules. Later entries have higher priority. + +```ts +rules: [ + ['m-1', { margin: '0.25rem' }], + [/^m-(\d+)$/, ([, d]) => ({ margin: `${d / 4}rem` })], +]; +``` + +### shortcuts + +Combine multiple rules into a single shorthand. + +```ts +shortcuts: { + 'btn': 'py-2 px-4 font-semibold rounded-lg shadow-md', +} +``` + +### theme + +Theme object for design tokens shared between rules. + +```ts +theme: { + colors: { + brand: '#942192', + }, + breakpoints: { + sm: '640px', + md: '768px', + }, +} +``` + +### presets + +Predefined configurations bundling rules, variants, and themes. + +```ts +presets: [presetWind3(), presetIcons()]; +``` + +### transformers + +Transform source code to support special syntax. + +```ts +transformers: [transformerDirectives(), transformerVariantGroup()]; +``` + +### variants + +Preprocess selectors with ability to rewrite CSS output. + +### extractors + +Handle source files and extract utility class names. + +### preflights + +Inject raw CSS globally. + +### layers + +Control the order of CSS layers. Default is `0`. + +```ts +layers: { + 'components': -1, + 'default': 1, + 'utilities': 2, +} +``` + +### safelist + +Utilities that are always included in output. + +```ts +safelist: ['p-1', 'p-2', 'p-3']; +``` + +### blocklist + +Utilities that are always excluded. + +```ts +blocklist: ['p-1', /^p-[2-4]$/]; +``` + +### content + +Configure where to extract utilities from. + +```ts +content: { + pipeline: { + include: [/\.(vue|svelte|tsx|html)($|\?)/], + }, + filesystem: ['src/**/*.php'], +} +``` + +### separators + +Variant separator characters. Default: `[':', '-']` + +### outputToCssLayers + +Output UnoCSS layers as CSS Cascade Layers. + +```ts +outputToCssLayers: true; +``` + +## Specifying Config File Location + +```ts +// vite.config.ts +import UnoCSS from 'unocss/vite'; + +export default defineConfig({ + plugins: [ + UnoCSS({ + configFile: '../my-uno.config.ts', + }), + ], +}); +``` + + diff --git a/packages/mosaic/framework/skills/unocss/references/core-extracting.md b/packages/mosaic/framework/skills/unocss/references/core-extracting.md new file mode 100644 index 00000000..81870729 --- /dev/null +++ b/packages/mosaic/framework/skills/unocss/references/core-extracting.md @@ -0,0 +1,134 @@ +--- +name: unocss-extracting +description: How UnoCSS extracts utilities from source code +--- + +# Extracting + +UnoCSS searches for utility usages in your codebase and generates CSS on-demand. + +## Content Sources + +### Pipeline Extraction (Vite/Webpack) + +Most efficient - extracts from build tool pipeline. + +**Default file types:** `.jsx`, `.tsx`, `.vue`, `.md`, `.html`, `.svelte`, `.astro`, `.marko` + +**Not included by default:** `.js`, `.ts` + +```ts +export default defineConfig({ + content: { + pipeline: { + include: [ + /\.(vue|svelte|[jt]sx|mdx?|astro|html)($|\?)/, + 'src/**/*.{js,ts}', // Add js/ts + ], + }, + }, +}); +``` + +### Filesystem Extraction + +For files not in build pipeline: + +```ts +export default defineConfig({ + content: { + filesystem: ['src/**/*.php', 'public/*.html'], + }, +}); +``` + +### Inline Text Extraction + +```ts +export default defineConfig({ + content: { + inline: [ + '
Some text
', + async () => (await fetch('https://example.com')).text(), + ], + }, +}); +``` + +## Magic Comments + +### @unocss-include + +Force scan a file: + +```ts +// @unocss-include +export const classes = { + active: 'bg-primary text-white', +}; +``` + +### @unocss-ignore + +Skip entire file: + +```ts +// @unocss-ignore +``` + +### @unocss-skip-start / @unocss-skip-end + +Skip specific blocks: + +```html +

Extracted

+ +

NOT extracted

+ +``` + +## Limitations + +UnoCSS works at **build time** - dynamic classes don't work: + +```html + +
+``` + +### Solutions + +**1. Safelist** - Pre-generate known values: + +```ts +safelist: ['p-1', 'p-2', 'p-3', 'p-4']; +``` + +**2. Static mapping** - List combinations statically: + +```ts +const colors = { + red: 'text-red border-red', + blue: 'text-blue border-blue', +}; +``` + +**3. Runtime** - Use `@unocss/runtime` for true runtime generation. + +## Custom Extractors + +```ts +extractors: [ + { + name: 'my-extractor', + extract({ code }) { + return code.match(/class:[\w-]+/g) || []; + }, + }, +]; +``` + + diff --git a/packages/mosaic/framework/skills/unocss/references/core-layers.md b/packages/mosaic/framework/skills/unocss/references/core-layers.md new file mode 100644 index 00000000..0be4b4c8 --- /dev/null +++ b/packages/mosaic/framework/skills/unocss/references/core-layers.md @@ -0,0 +1,105 @@ +--- +name: unocss-layers-preflights +description: CSS layer ordering and raw CSS injection +--- + +# Layers and Preflights + +Control CSS output order and inject global CSS. + +## Layers + +Set layer on rules: + +```ts +rules: [ + [/^m-(\d)$/, ([, d]) => ({ margin: `${d / 4}rem` }), { layer: 'utilities' }], + ['btn', { padding: '4px' }], // default layer +]; +``` + +### Layer Ordering + +```ts +layers: { + 'components': -1, + 'default': 1, + 'utilities': 2, +} +``` + +### Import Layers Separately + +```ts +import 'uno:components.css'; +import 'uno.css'; +import './my-custom.css'; +import 'uno:utilities.css'; +``` + +### CSS Cascade Layers + +```ts +outputToCssLayers: true; + +// Or with custom names +outputToCssLayers: { + cssLayerName: (layer) => { + if (layer === 'default') return 'utilities'; + if (layer === 'shortcuts') return 'utilities.shortcuts'; + }; +} +``` + +## Layer Variants + +```html + +

+ +

+ +

+``` + +## Preflights + +Inject raw CSS globally: + +```ts +preflights: [ + { + getCSS: ({ theme }) => ` + * { + color: ${theme.colors.gray?.[700] ?? '#333'}; + margin: 0; + } + `, + }, +]; +``` + +With layer: + +```ts +preflights: [ + { + layer: 'base', + getCSS: () => `html { font-family: system-ui; }`, + }, +]; +``` + +## preset-wind4 Layers + +| Layer | Description | Order | +| ------------ | ------------------- | ----- | +| `properties` | CSS @property rules | -200 | +| `theme` | Theme CSS variables | -150 | +| `base` | Reset styles | -100 | + + diff --git a/packages/mosaic/framework/skills/unocss/references/core-rules.md b/packages/mosaic/framework/skills/unocss/references/core-rules.md new file mode 100644 index 00000000..4511f4d8 --- /dev/null +++ b/packages/mosaic/framework/skills/unocss/references/core-rules.md @@ -0,0 +1,186 @@ +--- +name: unocss-rules +description: Static and dynamic rules for generating CSS utilities in UnoCSS +--- + +# UnoCSS Rules + +Rules define utility classes and the CSS they generate. UnoCSS has many built-in rules via presets and allows custom rules. + +## Static Rules + +Simple mapping from class name to CSS properties: + +```ts +rules: [ + ['m-1', { margin: '0.25rem' }], + ['font-bold', { 'font-weight': 700 }], +]; +``` + +Usage: `
` generates `.m-1 { margin: 0.25rem; }` + +**Note:** Use CSS property syntax with hyphens (e.g., `font-weight` not `fontWeight`). Quote properties with hyphens. + +## Dynamic Rules + +Use RegExp matcher with function body for flexible utilities: + +```ts +rules: [ + // Match m-1, m-2, m-100, etc. + [/^m-(\d+)$/, ([, d]) => ({ margin: `${d / 4}rem` })], + + // Access theme and context + [/^p-(\d+)$/, (match, ctx) => ({ padding: `${match[1] / 4}rem` })], +]; +``` + +The function receives: + +1. RegExp match result (destructure to get captured groups) +2. Context object with `theme`, `symbols`, etc. + +## CSS Fallback Values + +Return 2D array for CSS property fallbacks (browser compatibility): + +```ts +rules: [ + [ + /^h-(\d+)dvh$/, + ([_, d]) => [ + ['height', `${d}vh`], + ['height', `${d}dvh`], + ], + ], +]; +``` + +Generates: `.h-100dvh { height: 100vh; height: 100dvh; }` + +## Special Symbols + +Control CSS output with symbols from `@unocss/core`: + +```ts +import { symbols } from '@unocss/core'; + +rules: [ + [ + 'grid', + { + [symbols.parent]: '@supports (display: grid)', + display: 'grid', + }, + ], +]; +``` + +### Available Symbols + +| Symbol | Description | +| -------------------------- | -------------------------------------------- | +| `symbols.parent` | Parent wrapper (e.g., `@supports`, `@media`) | +| `symbols.selector` | Function to modify the selector | +| `symbols.layer` | Set the UnoCSS layer | +| `symbols.variants` | Array of variant handlers | +| `symbols.shortcutsNoMerge` | Disable merging in shortcuts | +| `symbols.noMerge` | Disable rule merging | +| `symbols.sort` | Override sorting order | +| `symbols.body` | Full control of CSS body | + +## Multi-Selector Rules + +Use generator functions to yield multiple CSS rules: + +```ts +rules: [ + [ + /^button-(.*)$/, + function* ([, color], { symbols }) { + yield { background: color }; + yield { + [symbols.selector]: (selector) => `${selector}:hover`, + background: `color-mix(in srgb, ${color} 90%, black)`, + }; + }, + ], +]; +``` + +Generates both `.button-red { background: red; }` and `.button-red:hover { ... }` + +## Fully Controlled Rules + +Return a string for complete CSS control (advanced): + +```ts +import { defineConfig, toEscapedSelector as e } from 'unocss'; + +rules: [ + [ + /^custom-(.+)$/, + ([, name], { rawSelector, theme }) => { + const selector = e(rawSelector); + return ` +${selector} { font-size: ${theme.fontSize.sm}; } +${selector}::after { content: 'after'; } +@media (min-width: ${theme.breakpoints.sm}) { + ${selector} { font-size: ${theme.fontSize.lg}; } +} +`; + }, + ], +]; +``` + +**Warning:** Fully controlled rules don't work with variants like `hover:`. + +## Symbols.body for Variant Support + +Use `symbols.body` to keep variant support with custom CSS: + +```ts +rules: [ + [ + 'custom-red', + { + [symbols.body]: ` + font-size: 1rem; + &::after { content: 'after'; } + & > .bar { color: red; } + `, + [symbols.selector]: (selector) => `:is(${selector})`, + }, + ], +]; +``` + +## Rule Ordering + +Later rules have higher priority. Dynamic rules output is sorted alphabetically within the group. + +## Rule Merging + +UnoCSS merges rules with identical CSS bodies: + +```html +
+``` + +Generates: + +```css +.hover\:m2:hover, +.m-2 { + margin: 0.5rem; +} +``` + +Use `symbols.noMerge` to disable. + + diff --git a/packages/mosaic/framework/skills/unocss/references/core-safelist.md b/packages/mosaic/framework/skills/unocss/references/core-safelist.md new file mode 100644 index 00000000..31f76d59 --- /dev/null +++ b/packages/mosaic/framework/skills/unocss/references/core-safelist.md @@ -0,0 +1,105 @@ +--- +name: unocss-safelist-blocklist +description: Force include or exclude specific utilities +--- + +# Safelist and Blocklist + +Control which utilities are always included or excluded. + +## Safelist + +Utilities always included, regardless of detection: + +```ts +export default defineConfig({ + safelist: [ + 'p-1', + 'p-2', + 'p-3', + // Dynamic generation + ...Array.from({ length: 4 }, (_, i) => `p-${i + 1}`), + ], +}); +``` + +### Function Form + +```ts +safelist: [ + 'p-1', + () => ['m-1', 'm-2'], + (context) => { + const colors = Object.keys(context.theme.colors || {}); + return colors.map((c) => `bg-${c}-500`); + }, +]; +``` + +### Common Use Cases + +```ts +safelist: [ + // Dynamic colors from CMS + () => ['primary', 'secondary'].flatMap((c) => [`bg-${c}`, `text-${c}`, `border-${c}`]), + + // Component variants + () => { + const variants = ['primary', 'danger']; + const sizes = ['sm', 'md', 'lg']; + return variants.flatMap((v) => sizes.map((s) => `btn-${v}-${s}`)); + }, +]; +``` + +## Blocklist + +Utilities never generated: + +```ts +blocklist: [ + 'p-1', // Exact match + /^p-[2-4]$/, // Regex +]; +``` + +### With Messages + +```ts +blocklist: [ + ['bg-red-500', { message: 'Use bg-red-600 instead' }], + [/^text-xs$/, { message: 'Use text-sm for accessibility' }], +]; +``` + +## Safelist vs Blocklist + +| Feature | Safelist | Blocklist | +| --------- | -------------- | -------------- | +| Purpose | Always include | Always exclude | +| Strings | ✅ | ✅ | +| Regex | ❌ | ✅ | +| Functions | ✅ | ❌ | + +**Note:** Blocklist wins if utility is in both. + +## Best Practice + +Prefer static mappings over safelist: + +```ts +// Better: UnoCSS extracts automatically +const sizes = { + sm: 'text-sm p-2', + md: 'text-base p-4', +}; + +// Avoid: Large safelist +safelist: ['text-sm', 'text-base', 'p-2', 'p-4']; +``` + + diff --git a/packages/mosaic/framework/skills/unocss/references/core-shortcuts.md b/packages/mosaic/framework/skills/unocss/references/core-shortcuts.md new file mode 100644 index 00000000..7b28bfe4 --- /dev/null +++ b/packages/mosaic/framework/skills/unocss/references/core-shortcuts.md @@ -0,0 +1,92 @@ +--- +name: unocss-shortcuts +description: Combine multiple utility rules into single shorthand classes +--- + +# UnoCSS Shortcuts + +Shortcuts combine multiple rules into a single shorthand, inspired by Windi CSS. + +## Static Shortcuts + +Define as an object mapping shortcut names to utility combinations: + +```ts +shortcuts: { + // Multiple utilities combined + 'btn': 'py-2 px-4 font-semibold rounded-lg shadow-md', + 'btn-green': 'text-white bg-green-500 hover:bg-green-700', + // Single utility alias + 'red': 'text-red-100', +} +``` + +Usage: + +```html + +``` + +## Dynamic Shortcuts + +Use RegExp matcher with function, similar to dynamic rules: + +```ts +shortcuts: [ + // Static shortcuts can be in array too + { + btn: 'py-2 px-4 font-semibold rounded-lg shadow-md', + }, + // Dynamic shortcut + [/^btn-(.*)$/, ([, c]) => `bg-${c}-400 text-${c}-100 py-2 px-4 rounded-lg`], +]; +``` + +Now `btn-green` and `btn-red` generate: + +```css +.btn-green { + padding: 0.5rem 1rem; + --un-bg-opacity: 1; + background-color: rgb(74 222 128 / var(--un-bg-opacity)); + border-radius: 0.5rem; + --un-text-opacity: 1; + color: rgb(220 252 231 / var(--un-text-opacity)); +} +``` + +## Accessing Theme in Shortcuts + +Dynamic shortcuts receive context with theme access: + +```ts +shortcuts: [ + [ + /^badge-(.*)$/, + ([, c], { theme }) => { + if (Object.keys(theme.colors).includes(c)) return `bg-${c}4:10 text-${c}5 rounded`; + }, + ], +]; +``` + +## Shortcuts Layer + +Shortcuts are output to the `shortcuts` layer by default. Configure with: + +```ts +shortcutsLayer: 'my-shortcuts-layer'; +``` + +## Key Points + +- Later shortcuts have higher priority +- Shortcuts can reference other shortcuts +- Dynamic shortcuts work like dynamic rules +- Shortcuts are expanded at build time, not runtime +- All variants work with shortcuts (`hover:btn`, `dark:btn`, etc.) + + diff --git a/packages/mosaic/framework/skills/unocss/references/core-theme.md b/packages/mosaic/framework/skills/unocss/references/core-theme.md new file mode 100644 index 00000000..1aeb022c --- /dev/null +++ b/packages/mosaic/framework/skills/unocss/references/core-theme.md @@ -0,0 +1,176 @@ +--- +name: unocss-theme +description: Theming system for colors, breakpoints, and design tokens +--- + +# UnoCSS Theme + +UnoCSS supports theming similar to Tailwind CSS / Windi CSS. The `theme` property is deep-merged with the default theme. + +## Basic Usage + +```ts +theme: { + colors: { + veryCool: '#0000ff', // class="text-very-cool" + brand: { + primary: 'hsl(var(--hue, 217) 78% 51%)', // class="bg-brand-primary" + DEFAULT: '#942192' // class="bg-brand" + }, + }, +} +``` + +## Using Theme in Rules + +Access theme values in dynamic rules: + +```ts +rules: [ + [ + /^text-(.*)$/, + ([, c], { theme }) => { + if (theme.colors[c]) return { color: theme.colors[c] }; + }, + ], +]; +``` + +## Using Theme in Variants + +```ts +variants: [ + { + name: 'variant-name', + match(matcher, { theme }) { + // Access theme.breakpoints, theme.colors, etc. + }, + }, +]; +``` + +## Using Theme in Shortcuts + +```ts +shortcuts: [ + [ + /^badge-(.*)$/, + ([, c], { theme }) => { + if (Object.keys(theme.colors).includes(c)) return `bg-${c}4:10 text-${c}5 rounded`; + }, + ], +]; +``` + +## Breakpoints + +**Warning:** Custom `breakpoints` object **overrides** the default, not merges. + +```ts +theme: { + breakpoints: { + sm: '320px', + md: '640px', + }, +} +``` + +Only `sm:` and `md:` variants will be available. + +### Inherit Default Breakpoints + +Use `extendTheme` to merge with defaults: + +```ts +extendTheme: (theme) => { + return { + ...theme, + breakpoints: { + ...theme.breakpoints, + sm: '320px', + md: '640px', + }, + }; +}; +``` + +**Note:** `verticalBreakpoints` works the same for vertical layout. + +### Breakpoint Sorting + +Breakpoints are sorted by size. Use consistent units to avoid errors: + +```ts +theme: { + breakpoints: { + sm: '320px', + // Don't mix units - convert rem to px + // md: '40rem', // Bad + md: `${40 * 16}px`, // Good + lg: '960px', + }, +} +``` + +## ExtendTheme + +`extendTheme` lets you modify the merged theme object: + +### Mutate Theme + +```ts +extendTheme: (theme) => { + theme.colors.veryCool = '#0000ff'; + theme.colors.brand = { + primary: 'hsl(var(--hue, 217) 78% 51%)', + }; +}; +``` + +### Replace Theme + +Return a new object to completely replace: + +```ts +extendTheme: (theme) => { + return { + ...theme, + colors: { + ...theme.colors, + veryCool: '#0000ff', + }, + }; +}; +``` + +## Theme Differences in Presets + +### preset-wind3 vs preset-wind4 + +| preset-wind3 | preset-wind4 | +| -------------------- | ---------------------------------- | +| `fontFamily` | `font` | +| `fontSize` | `text.fontSize` | +| `lineHeight` | `text.lineHeight` or `leading` | +| `letterSpacing` | `text.letterSpacing` or `tracking` | +| `borderRadius` | `radius` | +| `easing` | `ease` | +| `breakpoints` | `breakpoint` | +| `boxShadow` | `shadow` | +| `transitionProperty` | `property` | + +## Common Theme Keys + +- `colors` - Color palette +- `breakpoints` - Responsive breakpoints +- `fontFamily` - Font stacks +- `fontSize` - Text sizes +- `spacing` - Spacing scale +- `borderRadius` - Border radius values +- `boxShadow` - Shadow definitions +- `animation` - Animation keyframes and timing + + diff --git a/packages/mosaic/framework/skills/unocss/references/core-variants.md b/packages/mosaic/framework/skills/unocss/references/core-variants.md new file mode 100644 index 00000000..34e77d0e --- /dev/null +++ b/packages/mosaic/framework/skills/unocss/references/core-variants.md @@ -0,0 +1,195 @@ +--- +name: unocss-variants +description: Apply variations like hover:, dark:, responsive to rules +--- + +# UnoCSS Variants + +Variants apply modifications to utility rules, like `hover:`, `dark:`, or responsive prefixes. + +## How Variants Work + +When matching `hover:m-2`: + +1. `hover:m-2` is extracted from source +2. Sent to all variants for matching +3. `hover:` variant matches and returns `m-2` +4. Result `m-2` continues to next variants +5. Finally matches the rule `.m-2 { margin: 0.5rem; }` +6. Variant transformation applied: `.hover\:m-2:hover { margin: 0.5rem; }` + +## Creating Custom Variants + +```ts +variants: [ + // hover: variant + (matcher) => { + if (!matcher.startsWith('hover:')) + return matcher + return { + // Remove prefix, pass to next variants/rules + matcher: matcher.slice(6), + // Modify the selector + selector: s => `${s}:hover`, + } + }, +], +rules: [ + [/^m-(\d)$/, ([, d]) => ({ margin: `${d / 4}rem` })], +] +``` + +## Variant Return Object + +- `matcher` - The processed class name to pass forward +- `selector` - Function to customize the CSS selector +- `parent` - Wrapper like `@media`, `@supports` +- `layer` - Specify output layer +- `sort` - Control ordering + +## Built-in Variants (preset-wind3) + +### Pseudo-classes + +- `hover:`, `focus:`, `active:`, `visited:` +- `first:`, `last:`, `odd:`, `even:` +- `disabled:`, `checked:`, `required:` +- `focus-within:`, `focus-visible:` + +### Pseudo-elements + +- `before:`, `after:` +- `placeholder:`, `selection:` +- `marker:`, `file:` + +### Responsive + +- `sm:`, `md:`, `lg:`, `xl:`, `2xl:` +- `lt-sm:` (less than sm) +- `at-lg:` (at lg only) + +### Dark Mode + +- `dark:` - Class-based dark mode (default) +- `@dark:` - Media query dark mode + +### Group/Peer + +- `group-hover:`, `group-focus:` +- `peer-checked:`, `peer-focus:` + +### Container Queries + +- `@container`, `@sm:`, `@md:` + +### Print + +- `print:` + +### Supports + +- `supports-[display:grid]:` + +### Aria + +- `aria-checked:`, `aria-disabled:` + +### Data Attributes + +- `data-[state=open]:` + +## Dark Mode Configuration + +### Class-based (default) + +```ts +presetWind3({ + dark: 'class', +}); +``` + +```html +
+``` + +Generates: `.dark .dark\:bg-gray-800 { ... }` + +### Media Query + +```ts +presetWind3({ + dark: 'media', +}); +``` + +Generates: `@media (prefers-color-scheme: dark) { ... }` + +### Opt-in Media Query + +Use `@dark:` regardless of config: + +```html +
+``` + +### Custom Selectors + +```ts +presetWind3({ + dark: { + light: '.light-mode', + dark: '.dark-mode', + }, +}); +``` + +## CSS @layer Variant + +Native CSS `@layer` support: + +```html +
+``` + +Generates: + +```css +@layer foo { + .layer-foo\:p-4 { + padding: 1rem; + } +} +``` + +## Breakpoint Differences from Windi CSS + +| Windi CSS | UnoCSS | +| --------- | ----------- | +| `xl:p-1` | `xl:p-1` | + +## Media Hover (Experimental) + +Addresses sticky hover on touch devices: + +```html +
+``` + +Generates: + +```css +@media (hover: hover) and (pointer: fine) { + .\@hover-text-red:hover { + color: rgb(248 113 113); + } +} +``` + + diff --git a/packages/mosaic/framework/skills/unocss/references/integrations-nuxt.md b/packages/mosaic/framework/skills/unocss/references/integrations-nuxt.md new file mode 100644 index 00000000..271dca4e --- /dev/null +++ b/packages/mosaic/framework/skills/unocss/references/integrations-nuxt.md @@ -0,0 +1,187 @@ +--- +name: unocss-nuxt-integration +description: UnoCSS module for Nuxt applications +--- + +# UnoCSS Nuxt Integration + +The official Nuxt module for UnoCSS. + +## Installation + +```bash +pnpm add -D unocss @unocss/nuxt +``` + +Add to Nuxt config: + +```ts +// nuxt.config.ts +export default defineNuxtConfig({ + modules: ['@unocss/nuxt'], +}); +``` + +Create config file: + +```ts +// uno.config.ts +import { defineConfig, presetWind3 } from 'unocss'; + +export default defineConfig({ + presets: [presetWind3()], +}); +``` + +**Note:** The `uno.css` entry is automatically injected by the module. + +## Support Status + +| Build Tool | Nuxt 2 | Nuxt Bridge | Nuxt 3 | +| ------------- | ------ | ----------- | ------ | +| Webpack Dev | ✅ | ✅ | 🚧 | +| Webpack Build | ✅ | ✅ | ✅ | +| Vite Dev | - | ✅ | ✅ | +| Vite Build | - | ✅ | ✅ | + +## Configuration + +### Using uno.config.ts (Recommended) + +Use a dedicated config file for best IDE support: + +```ts +// uno.config.ts +import { defineConfig, presetWind3, presetIcons } from 'unocss'; + +export default defineConfig({ + presets: [presetWind3(), presetIcons()], + shortcuts: { + btn: 'py-2 px-4 font-semibold rounded-lg', + }, +}); +``` + +### Nuxt Layers Support + +Enable automatic config merging from Nuxt layers: + +```ts +// nuxt.config.ts +export default defineNuxtConfig({ + unocss: { + nuxtLayers: true, + }, +}); +``` + +Then in your root config: + +```ts +// uno.config.ts +import config from './.nuxt/uno.config.mjs'; + +export default config; +``` + +Or extend the merged config: + +```ts +// uno.config.ts +import { mergeConfigs } from '@unocss/core'; +import config from './.nuxt/uno.config.mjs'; + +export default mergeConfigs([ + config, + { + // Your overrides + shortcuts: { + custom: 'text-red-500', + }, + }, +]); +``` + +## Common Setup Example + +```ts +// nuxt.config.ts +export default defineNuxtConfig({ + modules: ['@unocss/nuxt'], +}); +``` + +```ts +// uno.config.ts +import { + defineConfig, + presetAttributify, + presetIcons, + presetTypography, + presetWebFonts, + presetWind3, + transformerDirectives, + transformerVariantGroup, +} from 'unocss'; + +export default defineConfig({ + presets: [ + presetWind3(), + presetAttributify(), + presetIcons({ + scale: 1.2, + }), + presetTypography(), + presetWebFonts({ + fonts: { + sans: 'DM Sans', + mono: 'DM Mono', + }, + }), + ], + transformers: [transformerDirectives(), transformerVariantGroup()], + shortcuts: [ + [ + 'btn', + 'px-4 py-1 rounded inline-block bg-teal-600 text-white cursor-pointer hover:bg-teal-700 disabled:cursor-default disabled:bg-gray-600 disabled:opacity-50', + ], + ], +}); +``` + +## Usage in Components + +```vue + +``` + +With attributify mode: + +```vue + +``` + +## Inspector + +In development, visit `/_nuxt/__unocss` to access the UnoCSS inspector. + +## Key Differences from Vite + +- No need to import `virtual:uno.css` - automatically injected +- Config file discovery works the same +- All Vite plugin features available +- Nuxt layers config merging available + + diff --git a/packages/mosaic/framework/skills/unocss/references/integrations-vite.md b/packages/mosaic/framework/skills/unocss/references/integrations-vite.md new file mode 100644 index 00000000..b787152c --- /dev/null +++ b/packages/mosaic/framework/skills/unocss/references/integrations-vite.md @@ -0,0 +1,265 @@ +--- +name: unocss-vite-integration +description: Setting up UnoCSS with Vite and framework-specific tips +--- + +# UnoCSS Vite Integration + +The Vite plugin is the most common way to use UnoCSS. + +## Installation + +```bash +pnpm add -D unocss +``` + +```ts +// vite.config.ts +import UnoCSS from 'unocss/vite'; +import { defineConfig } from 'vite'; + +export default defineConfig({ + plugins: [UnoCSS()], +}); +``` + +Create config file: + +```ts +// uno.config.ts +import { defineConfig, presetWind3 } from 'unocss'; + +export default defineConfig({ + presets: [presetWind3()], +}); +``` + +Add to entry: + +```ts +// main.ts +import 'virtual:uno.css'; +``` + +## Modes + +### global (default) + +Standard mode - generates global CSS injected via `uno.css` import. + +```ts +import 'virtual:uno.css'; +``` + +### vue-scoped + +Injects generated CSS into Vue SFC ` +
...
+`; +``` + +### per-module (experimental) + +Generates CSS per module with optional scoping. + +### dist-chunk (experimental) + +Generates CSS per chunk on build for MPA. + +## DevTools Support + +Edit classes directly in browser DevTools: + +```ts +import 'virtual:uno.css'; +import 'virtual:unocss-devtools'; +``` + +**Warning:** Uses MutationObserver to detect changes. Dynamic classes from scripts will also be included. + +## Framework-Specific Setup + +### React + +```ts +// vite.config.ts +import React from '@vitejs/plugin-react'; +import UnoCSS from 'unocss/vite'; + +export default { + plugins: [ + UnoCSS(), // Must be before React when using attributify + React(), + ], +}; +``` + +**Note:** Remove `tsc` from build script if using `@unocss/preset-attributify`. + +### Vue + +Works out of the box with `@vitejs/plugin-vue`. + +### Svelte + +```ts +import { svelte } from '@sveltejs/vite-plugin-svelte'; +import extractorSvelte from '@unocss/extractor-svelte'; +import UnoCSS from 'unocss/vite'; + +export default { + plugins: [ + UnoCSS({ + extractors: [extractorSvelte()], + }), + svelte(), + ], +}; +``` + +Supports `class:foo` and `class:foo={bar}` syntax. + +### SvelteKit + +Same as Svelte, use `sveltekit()` from `@sveltejs/kit/vite`. + +### Solid + +```ts +import UnoCSS from 'unocss/vite'; +import solidPlugin from 'vite-plugin-solid'; + +export default { + plugins: [UnoCSS(), solidPlugin()], +}; +``` + +### Preact + +```ts +import Preact from '@preact/preset-vite'; +import UnoCSS from 'unocss/vite'; + +export default { + plugins: [UnoCSS(), Preact()], +}; +``` + +### Elm + +```ts +import Elm from 'vite-plugin-elm'; +import UnoCSS from 'unocss/vite'; + +export default { + plugins: [Elm(), UnoCSS()], +}; +``` + +### Web Components (Lit) + +```ts +UnoCSS({ + mode: 'shadow-dom', + shortcuts: [{ 'cool-blue': 'bg-blue-500 text-white' }], +}); +``` + +```ts +// my-element.ts +@customElement('my-element') +export class MyElement extends LitElement { + static styles = css` + :host { ... } + @unocss-placeholder + `; +} +``` + +Supports `part-[]:` for `::part` styling. + +## Inspector + +Visit `http://localhost:5173/__unocss` in dev mode to: + +- Inspect generated CSS rules +- See applied classes per file +- Test utilities in REPL + +## Legacy Browser Support + +With `@vitejs/plugin-legacy`: + +```ts +import legacy from '@vitejs/plugin-legacy'; +import UnoCSS from 'unocss/vite'; + +export default { + plugins: [ + UnoCSS({ + legacy: { + renderModernChunks: false, + }, + }), + legacy({ + targets: ['defaults', 'not IE 11'], + renderModernChunks: false, + }), + ], +}; +``` + +## VanillaJS / TypeScript + +By default, `.js` and `.ts` files are not extracted. Configure to include: + +```ts +// uno.config.ts +export default defineConfig({ + content: { + pipeline: { + include: [/\.(vue|svelte|[jt]sx|html)($|\?)/, 'src/**/*.{js,ts}'], + }, + }, +}); +``` + +Or use magic comment in files: + +```ts +// @unocss-include +export const classes = { + active: 'bg-primary text-white', +}; +``` + + diff --git a/packages/mosaic/framework/skills/unocss/references/preset-attributify.md b/packages/mosaic/framework/skills/unocss/references/preset-attributify.md new file mode 100644 index 00000000..861e9130 --- /dev/null +++ b/packages/mosaic/framework/skills/unocss/references/preset-attributify.md @@ -0,0 +1,143 @@ +--- +name: preset-attributify +description: Group utilities in HTML attributes instead of class +--- + +# Preset Attributify + +Group utilities in HTML attributes for better readability. + +## Installation + +```ts +import { defineConfig, presetAttributify, presetWind3 } from 'unocss'; + +export default defineConfig({ + presets: [presetWind3(), presetAttributify()], +}); +``` + +## Basic Usage + +Instead of long class strings: + +```html + +``` + +Group by prefix in attributes: + +```html + +``` + +## Prefix Self-Referencing + +For utilities matching their prefix (`flex`, `grid`, `border`), use `~`: + +```html + + + + + +``` + +## Valueless Attributify + +Use utilities as boolean attributes: + +```html +
+``` + +## Handling Property Conflicts + +When attribute names conflict with HTML properties: + +```html + +Text color to red +``` + +### Enforce Prefix + +```ts +presetAttributify({ + prefix: 'un-', + prefixedOnly: true, +}); +``` + +## Options + +```ts +presetAttributify({ + strict: false, // Only generate CSS for attributify + prefix: 'un-', // Attribute prefix + prefixedOnly: false, // Require prefix for all + nonValuedAttribute: true, // Support valueless attributes + ignoreAttributes: [], // Attributes to ignore + trueToNonValued: false, // Treat value="true" as valueless +}); +``` + +## TypeScript Support + +### Vue 3 + +```ts +// html.d.ts +declare module '@vue/runtime-dom' { + interface HTMLAttributes { + [key: string]: any; + } +} +declare module '@vue/runtime-core' { + interface AllowedComponentProps { + [key: string]: any; + } +} +export {}; +``` + +### React + +```ts +import type { AttributifyAttributes } from '@unocss/preset-attributify'; + +declare module 'react' { + interface HTMLAttributes extends AttributifyAttributes {} +} +``` + +## JSX Support + +For JSX where `
` becomes `
`: + +```ts +import { transformerAttributifyJsx } from 'unocss'; + +export default defineConfig({ + transformers: [transformerAttributifyJsx()], +}); +``` + +**Important:** Only use attributify if `uno.config.*` shows `presetAttributify()` is enabled. + + diff --git a/packages/mosaic/framework/skills/unocss/references/preset-icons.md b/packages/mosaic/framework/skills/unocss/references/preset-icons.md new file mode 100644 index 00000000..b0bb0e36 --- /dev/null +++ b/packages/mosaic/framework/skills/unocss/references/preset-icons.md @@ -0,0 +1,182 @@ +--- +name: preset-icons +description: Pure CSS icons using Iconify with any icon set +--- + +# Preset Icons + +Use any icon as a pure CSS class, powered by Iconify. + +## Installation + +```bash +pnpm add -D @unocss/preset-icons @iconify-json/[collection-name] +``` + +Example: `@iconify-json/mdi` for Material Design Icons, `@iconify-json/carbon` for Carbon icons. + +```ts +import { defineConfig, presetIcons } from 'unocss'; + +export default defineConfig({ + presets: [presetIcons()], +}); +``` + +## Usage + +Two naming conventions: + +- `-` → `i-ph-anchor-simple-thin` +- `:` → `i-ph:anchor-simple-thin` + +```html + +
+ + +
+ + +
+ + + +``` + +Expands to: `hover:bg-blue-600 hover:text-white hover:scale-105` + +### Dark Mode + +```html +
Dark content
+``` + +Expands to: `dark:bg-gray-800 dark:text-white` + +### Responsive + +```html +
Responsive flex
+``` + +Expands to: `md:flex md:items-center md:gap-4` + +### Nested Groups + +```html +
Large screen hover
+``` + +Expands to: `lg:hover:bg-blue-500 lg:hover:text-white` + +### Multiple Prefixes + +```html +
Styled text
+``` + +Expands to: `text-sm text-gray-600 font-medium font-mono` + +## Key Points + +- Use parentheses `()` to group utilities +- The prefix applies to all utilities inside the group +- Can be combined with any variant (hover, dark, responsive, etc.) +- Nesting is supported +- Works in class attributes and other extraction sources + + diff --git a/packages/mosaic/framework/skills/using-git-worktrees/SKILL.md b/packages/mosaic/framework/skills/using-git-worktrees/SKILL.md new file mode 100644 index 00000000..1a35e592 --- /dev/null +++ b/packages/mosaic/framework/skills/using-git-worktrees/SKILL.md @@ -0,0 +1,223 @@ +--- +name: using-git-worktrees +description: Use when starting feature work that needs isolation from current workspace or before executing implementation plans - creates isolated git worktrees with smart directory selection and safety verification +--- + +# Using Git Worktrees + +## Overview + +Git worktrees create isolated workspaces sharing the same repository, allowing work on multiple branches simultaneously without switching. + +**Core principle:** Systematic directory selection + safety verification = reliable isolation. + +**Announce at start:** "I'm using the using-git-worktrees skill to set up an isolated workspace." + +## Directory Selection Process + +Follow this priority order: + +### 1. Check Existing Directories + +```bash +# Check in priority order +ls -d .worktrees 2>/dev/null # Preferred (hidden) +ls -d worktrees 2>/dev/null # Alternative +``` + +**If found:** Use that directory. If both exist, `.worktrees` wins. + +### 2. Check AGENTS.md / SOUL.md (fallback: CLAUDE.md) + +```bash +grep -i "worktree.*director" AGENTS.md SOUL.md CLAUDE.md 2>/dev/null +``` + +**If preference specified:** Use it without asking. + +### 3. Ask User + +If no directory exists and no AGENTS.md/SOUL.md/CLAUDE.md preference: + +``` +No worktree directory found. Where should I create worktrees? + +1. .worktrees/ (project-local, hidden) +2. ~/.config/superpowers/worktrees// (global location) + +Which would you prefer? +``` + +## Safety Verification + +### For Project-Local Directories (.worktrees or worktrees) + +**MUST verify directory is ignored before creating worktree:** + +```bash +# Check if directory is ignored (respects local, global, and system gitignore) +git check-ignore -q .worktrees 2>/dev/null || git check-ignore -q worktrees 2>/dev/null +``` + +**If NOT ignored:** + +Per Jesse's rule "Fix broken things immediately": + +1. Add appropriate line to .gitignore +2. Commit the change +3. Proceed with worktree creation + +**Why critical:** Prevents accidentally committing worktree contents to repository. + +### For Global Directory (~/.config/superpowers/worktrees) + +No .gitignore verification needed - outside project entirely. + +## Creation Steps + +### 1. Detect Project Name + +```bash +project=$(basename "$(git rev-parse --show-toplevel)") +``` + +### 2. Create Worktree + +```bash +# Determine full path +case $LOCATION in + .worktrees|worktrees) + path="$LOCATION/$BRANCH_NAME" + ;; + ~/.config/superpowers/worktrees/*) + path="~/.config/superpowers/worktrees/$project/$BRANCH_NAME" + ;; +esac + +# Create worktree with new branch +git worktree add "$path" -b "$BRANCH_NAME" +cd "$path" +``` + +### 3. Run Project Setup + +Auto-detect and run appropriate setup: + +```bash +# Node.js +if [ -f package.json ]; then npm install; fi + +# Rust +if [ -f Cargo.toml ]; then cargo build; fi + +# Python +if [ -f requirements.txt ]; then pip install -r requirements.txt; fi +if [ -f pyproject.toml ]; then poetry install; fi + +# Go +if [ -f go.mod ]; then go mod download; fi +``` + +### 4. Verify Clean Baseline + +Run tests to ensure worktree starts clean: + +```bash +# Examples - use project-appropriate command +npm test +cargo test +pytest +go test ./... +``` + +**If tests fail:** Report failures, ask whether to proceed or investigate. + +**If tests pass:** Report ready. + +### 5. Report Location + +``` +Worktree ready at +Tests passing ( tests, 0 failures) +Ready to implement +``` + +## Quick Reference + +| Situation | Action | +| -------------------------- | -------------------------------------------- | +| `.worktrees/` exists | Use it (verify ignored) | +| `worktrees/` exists | Use it (verify ignored) | +| Both exist | Use `.worktrees/` | +| Neither exists | Check AGENTS.md/SOUL.md/CLAUDE.md → Ask user | +| Directory not ignored | Add to .gitignore + commit | +| Tests fail during baseline | Report failures + ask | +| No package.json/Cargo.toml | Skip dependency install | + +## Common Mistakes + +### Skipping ignore verification + +- **Problem:** Worktree contents get tracked, pollute git status +- **Fix:** Always use `git check-ignore` before creating project-local worktree + +### Assuming directory location + +- **Problem:** Creates inconsistency, violates project conventions +- **Fix:** Follow priority: existing > AGENTS.md/SOUL.md/CLAUDE.md > ask + +### Proceeding with failing tests + +- **Problem:** Can't distinguish new bugs from pre-existing issues +- **Fix:** Report failures, get explicit permission to proceed + +### Hardcoding setup commands + +- **Problem:** Breaks on projects using different tools +- **Fix:** Auto-detect from project files (package.json, etc.) + +## Example Workflow + +``` +You: I'm using the using-git-worktrees skill to set up an isolated workspace. + +[Check .worktrees/ - exists] +[Verify ignored - git check-ignore confirms .worktrees/ is ignored] +[Create worktree: git worktree add .worktrees/auth -b feature/auth] +[Run npm install] +[Run npm test - 47 passing] + +Worktree ready at /Users/jesse/myproject/.worktrees/auth +Tests passing (47 tests, 0 failures) +Ready to implement auth feature +``` + +## Red Flags + +**Never:** + +- Create worktree without verifying it's ignored (project-local) +- Skip baseline test verification +- Proceed with failing tests without asking +- Assume directory location when ambiguous +- Skip AGENTS.md/SOUL.md/CLAUDE.md check + +**Always:** + +- Follow directory priority: existing > AGENTS.md/SOUL.md/CLAUDE.md > ask +- Verify directory is ignored for project-local +- Auto-detect and run project setup +- Verify clean test baseline + +## Integration + +**Called by:** + +- **brainstorming** (Phase 4) - REQUIRED when design is approved and implementation follows +- **subagent-driven-development** - REQUIRED before executing any tasks +- **executing-plans** - REQUIRED before executing any tasks +- Any skill needing isolated workspace + +**Pairs with:** + +- **finishing-a-development-branch** - REQUIRED for cleanup after work complete diff --git a/packages/mosaic/framework/skills/using-superpowers/SKILL.md b/packages/mosaic/framework/skills/using-superpowers/SKILL.md new file mode 100644 index 00000000..071c1aee --- /dev/null +++ b/packages/mosaic/framework/skills/using-superpowers/SKILL.md @@ -0,0 +1,95 @@ +--- +name: using-superpowers +description: Use when starting any conversation - establishes how to find and use skills, requiring Skill tool invocation before ANY response including clarifying questions +--- + + +If you think there is even a 1% chance a skill might apply to what you are doing, you ABSOLUTELY MUST invoke the skill. + +IF A SKILL APPLIES TO YOUR TASK, YOU DO NOT HAVE A CHOICE. YOU MUST USE IT. + +This is not negotiable. This is not optional. You cannot rationalize your way out of this. + + +## How to Access Skills + +**In Claude Code:** Use the `Skill` tool. When you invoke a skill, its content is loaded and presented to you—follow it directly. Never use the Read tool on skill files. + +**In other environments:** Check your platform's documentation for how skills are loaded. + +# Using Skills + +## The Rule + +**Invoke relevant or requested skills BEFORE any response or action.** Even a 1% chance a skill might apply means that you should invoke the skill to check. If an invoked skill turns out to be wrong for the situation, you don't need to use it. + +```dot +digraph skill_flow { + "User message received" [shape=doublecircle]; + "About to EnterPlanMode?" [shape=doublecircle]; + "Already brainstormed?" [shape=diamond]; + "Invoke brainstorming skill" [shape=box]; + "Might any skill apply?" [shape=diamond]; + "Invoke Skill tool" [shape=box]; + "Announce: 'Using [skill] to [purpose]'" [shape=box]; + "Has checklist?" [shape=diamond]; + "Create TodoWrite todo per item" [shape=box]; + "Follow skill exactly" [shape=box]; + "Respond (including clarifications)" [shape=doublecircle]; + + "About to EnterPlanMode?" -> "Already brainstormed?"; + "Already brainstormed?" -> "Invoke brainstorming skill" [label="no"]; + "Already brainstormed?" -> "Might any skill apply?" [label="yes"]; + "Invoke brainstorming skill" -> "Might any skill apply?"; + + "User message received" -> "Might any skill apply?"; + "Might any skill apply?" -> "Invoke Skill tool" [label="yes, even 1%"]; + "Might any skill apply?" -> "Respond (including clarifications)" [label="definitely not"]; + "Invoke Skill tool" -> "Announce: 'Using [skill] to [purpose]'"; + "Announce: 'Using [skill] to [purpose]'" -> "Has checklist?"; + "Has checklist?" -> "Create TodoWrite todo per item" [label="yes"]; + "Has checklist?" -> "Follow skill exactly" [label="no"]; + "Create TodoWrite todo per item" -> "Follow skill exactly"; +} +``` + +## Red Flags + +These thoughts mean STOP—you're rationalizing: + +| Thought | Reality | +| ----------------------------------- | ------------------------------------------------------ | +| "This is just a simple question" | Questions are tasks. Check for skills. | +| "I need more context first" | Skill check comes BEFORE clarifying questions. | +| "Let me explore the codebase first" | Skills tell you HOW to explore. Check first. | +| "I can check git/files quickly" | Files lack conversation context. Check for skills. | +| "Let me gather information first" | Skills tell you HOW to gather information. | +| "This doesn't need a formal skill" | If a skill exists, use it. | +| "I remember this skill" | Skills evolve. Read current version. | +| "This doesn't count as a task" | Action = task. Check for skills. | +| "The skill is overkill" | Simple things become complex. Use it. | +| "I'll just do this one thing first" | Check BEFORE doing anything. | +| "This feels productive" | Undisciplined action wastes time. Skills prevent this. | +| "I know what that means" | Knowing the concept ≠ using the skill. Invoke it. | + +## Skill Priority + +When multiple skills could apply, use this order: + +1. **Process skills first** (brainstorming, debugging) - these determine HOW to approach the task +2. **Implementation skills second** (frontend-design, mcp-builder) - these guide execution + +"Let's build X" → brainstorming first, then implementation skills. +"Fix this bug" → debugging first, then domain-specific skills. + +## Skill Types + +**Rigid** (TDD, debugging): Follow exactly. Don't adapt away discipline. + +**Flexible** (patterns): Adapt principles to context. + +The skill itself tells you which. + +## User Instructions + +Instructions say WHAT, not HOW. "Add X" or "Fix Y" doesn't mean skip workflows. diff --git a/packages/mosaic/framework/skills/vercel-composition-patterns/AGENTS.md b/packages/mosaic/framework/skills/vercel-composition-patterns/AGENTS.md new file mode 100644 index 00000000..595a7343 --- /dev/null +++ b/packages/mosaic/framework/skills/vercel-composition-patterns/AGENTS.md @@ -0,0 +1,917 @@ +# React Composition Patterns + +**Version 1.0.0** +Engineering +January 2026 + +> **Note:** +> This document is mainly for agents and LLMs to follow when maintaining, +> generating, or refactoring React codebases using composition. Humans +> may also find it useful, but guidance here is optimized for automation +> and consistency by AI-assisted workflows. + +--- + +## Abstract + +Composition patterns for building flexible, maintainable React components. Avoid boolean prop proliferation by using compound components, lifting state, and composing internals. These patterns make codebases easier for both humans and AI agents to work with as they scale. + +--- + +## Table of Contents + +1. [Component Architecture](#1-component-architecture) — **HIGH** + - 1.1 [Avoid Boolean Prop Proliferation](#11-avoid-boolean-prop-proliferation) + - 1.2 [Use Compound Components](#12-use-compound-components) +2. [State Management](#2-state-management) — **MEDIUM** + - 2.1 [Decouple State Management from UI](#21-decouple-state-management-from-ui) + - 2.2 [Define Generic Context Interfaces for Dependency Injection](#22-define-generic-context-interfaces-for-dependency-injection) + - 2.3 [Lift State into Provider Components](#23-lift-state-into-provider-components) +3. [Implementation Patterns](#3-implementation-patterns) — **MEDIUM** + - 3.1 [Create Explicit Component Variants](#31-create-explicit-component-variants) + - 3.2 [Prefer Composing Children Over Render Props](#32-prefer-composing-children-over-render-props) +4. [React 19 APIs](#4-react-19-apis) — **MEDIUM** + - 4.1 [React 19 API Changes](#41-react-19-api-changes) + +--- + +## 1. Component Architecture + +**Impact: HIGH** + +Fundamental patterns for structuring components to avoid prop +proliferation and enable flexible composition. + +### 1.1 Avoid Boolean Prop Proliferation + +**Impact: CRITICAL (prevents unmaintainable component variants)** + +Don't add boolean props like `isThread`, `isEditing`, `isDMThread` to customize + +component behavior. Each boolean doubles possible states and creates + +unmaintainable conditional logic. Use composition instead. + +**Incorrect: boolean props create exponential complexity** + +```tsx +function Composer({ + onSubmit, + isThread, + channelId, + isDMThread, + dmId, + isEditing, + isForwarding, +}: Props) { + return ( +
+
+ + {isDMThread ? ( + + ) : isThread ? ( + + ) : null} + {isEditing ? : isForwarding ? : } +