diff --git a/.git-blame-ignore-revs b/.git-blame-ignore-revs new file mode 100644 index 000000000000..78fd225c4bf5 --- /dev/null +++ b/.git-blame-ignore-revs @@ -0,0 +1,7 @@ +# Commits that only reformat code. GitHub skips these in blame automatically; +# locally, opt in once with: +# +# git config blame.ignoreRevsFile .git-blame-ignore-revs + +# chore: format the repo with prettier (ENG-2267) +0c8fc5e974d6f9b608286b965d9bb63b01038fe6 diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml index 60c71b1fb387..2337640a91bb 100644 --- a/.github/workflows/lint.yml +++ b/.github/workflows/lint.yml @@ -35,5 +35,12 @@ jobs: - name: Create .env run: pnpm dev:setup + # Runs before the linters: formatting takes seconds where linting takes + # minutes, so a formatting slip fails fast instead of after a full lint. + # Otherwise only applied by the lint-staged pre-commit hook, which + # `git commit --no-verify` skips. Run `pnpm format` to fix failures. + - name: Check formatting + run: pnpm format:check + - name: Lint run: pnpm lint diff --git a/.github/workflows/pr-size-check.yml b/.github/workflows/pr-size-check.yml index 85879bc0ecdb..f594dff8f4e7 100644 --- a/.github/workflows/pr-size-check.yml +++ b/.github/workflows/pr-size-check.yml @@ -12,41 +12,41 @@ jobs: check-pr-size: runs-on: ubuntu-latest timeout-minutes: 10 - + steps: - name: Harden the runner uses: step-security/harden-runner@ec9f2d5744a09debf3a187a3f4f675c53b671911 # v2.13.0 with: egress-policy: audit - + - name: Checkout code uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 with: fetch-depth: 0 - + - name: Check PR size id: check-size run: | set -euo pipefail - + # Fetch the base branch git fetch origin "${{ github.base_ref }}" - + # Get diff stats diff_output=$(git diff --numstat "origin/${{ github.base_ref }}"...HEAD) - + # Count lines, excluding: # - Test files (*.test.ts, *.spec.tsx, etc.) # - Locale files (locales/*.json, i18n/*.json) # - Lock files (pnpm-lock.yaml, package-lock.json, yarn.lock) # - Generated files (dist/, coverage/, build/, .next/) # - Storybook stories (*.stories.tsx) - + total_additions=0 total_deletions=0 counted_files=0 excluded_files=0 - + while IFS=$'\t' read -r additions deletions file; do # Skip if additions or deletions are "-" (binary files) if [ "$additions" = "-" ] || [ "$deletions" = "-" ]; then @@ -83,22 +83,22 @@ jobs: done <> "${GITHUB_OUTPUT}" echo "excluded_files=${excluded_files}" >> "${GITHUB_OUTPUT}" echo "total_additions=${total_additions}" >> "${GITHUB_OUTPUT}" echo "total_deletions=${total_deletions}" >> "${GITHUB_OUTPUT}" echo "total_changes=${total_changes}" >> "${GITHUB_OUTPUT}" - + # Set flag if PR is too large (> 800 lines) if [ ${total_changes} -gt 800 ]; then echo "is_too_large=true" >> "${GITHUB_OUTPUT}" else echo "is_too_large=false" >> "${GITHUB_OUTPUT}" fi - + - name: Comment on PR if too large if: steps.check-size.outputs.is_too_large == 'true' uses: actions/github-script@60a0d83039c74a4aee543508d2ffcb1c3799cdea # v7.0.1 @@ -110,7 +110,7 @@ jobs: const excludedFiles = ${{ steps.check-size.outputs.excluded_files }}; const additions = ${{ steps.check-size.outputs.total_additions }}; const deletions = ${{ steps.check-size.outputs.total_deletions }}; - + const body = '## 🚨 PR Size Warning\n\n' + 'This PR has approximately **' + totalChanges + ' lines** of changes (' + additions + ' additions, ' + deletions + ' deletions across ' + countedFiles + ' files).\n\n' + 'Large PRs (>800 lines) are significantly harder to review and increase the chance of merge conflicts. Consider splitting this into smaller, self-contained PRs.\n\n' + @@ -126,19 +126,19 @@ jobs: '- **Warning:** 500-800 lines\n' + '- **Critical:** 800+ lines ⚠️\n\n' + 'If this large PR is unavoidable (e.g., migration, dependency update, major refactor), please explain in the PR description why it couldn\'t be split.'; - + // Check if we already commented const { data: comments } = await github.rest.issues.listComments({ owner: context.repo.owner, repo: context.repo.repo, issue_number: context.issue.number, }); - + const botComment = comments.find(comment => comment.user.type === 'Bot' && comment.body.includes('🚨 PR Size Warning') ); - + if (botComment) { // Update existing comment await github.rest.issues.updateComment({ @@ -156,4 +156,3 @@ jobs: body: body }); } - diff --git a/.lintstagedrc.mjs b/.lintstagedrc.mjs index 155660dd257d..a2a433dafd42 100644 --- a/.lintstagedrc.mjs +++ b/.lintstagedrc.mjs @@ -1,37 +1,8 @@ -import path from "node:path"; - -const ESLINT_BIN = "node_modules/.bin/eslint"; - -// Groups staged files by their owning app/package directory (e.g. "apps/web", -// "packages/database"), each exactly one level under apps/ or packages/. -function groupByPackage(files) { - const groups = new Map(); - for (const absFile of files) { - const rel = path.relative(process.cwd(), absFile).split(path.sep).join("/"); - const match = /^(apps|packages)\/([^/]+)\//.exec(rel); - if (!match) continue; - const pkgDir = `${match[1]}/${match[2]}`; - if (!groups.has(pkgDir)) groups.set(pkgDir, []); - groups.get(pkgDir).push(path.relative(pkgDir, rel)); - } - return groups; -} - -// Lints only the staged files themselves (not the whole package) by cd-ing into -// each touched package and invoking its own eslint.config.mjs directly, so a -// commit only pays for the files it touches and pre-existing violations -// elsewhere in the package can't block it. -function lintStagedFiles(files) { - const groups = groupByPackage(files); - return [...groups.entries()].map(([pkgDir, relFiles]) => { - const eslintBin = path.relative(pkgDir, ESLINT_BIN); - const quotedFiles = relFiles.map((f) => `"${f}"`).join(" "); - return `sh -c 'cd "${pkgDir}" && "${eslintBin}" ${quotedFiles}'`; - }); -} - export default { - "(apps|packages)/**/*.{js,ts,jsx,tsx,mjs}": ["prettier --write", lintStagedFiles], + // scripts/lint-staged-eslint.mjs receives the staged paths from lint-staged as + // argv, so nothing is interpolated into a shell command. It lints each touched + // package from that package's own directory. + "(apps|packages)/**/*.{js,ts,jsx,tsx,mjs}": ["prettier --write", "node scripts/lint-staged-eslint.mjs"], "*.json": ["prettier --write"], "packages/database/schema/**/*.prisma": ["prisma format"], }; diff --git a/.prettierignore b/.prettierignore new file mode 100644 index 000000000000..c3a205c58012 --- /dev/null +++ b/.prettierignore @@ -0,0 +1,31 @@ +# Prettier already skips everything in .gitignore (that is a default ignore +# path in Prettier 3), so this file only needs to list *tracked* files that +# `prettier --write .` must not touch. + +# Generated artifacts: the tool that writes them would immediately undo any +# formatting, which would turn `pnpm format:check` red on an unrelated PR. +pnpm-lock.yaml +docs/api-v3-reference/openapi.yml +docs/api-v3-reference/.redocly.lint-ignore.yaml +charts/**/README.md + +# Helm templates are Go templates that only look like YAML β€” Prettier's YAML +# parser cannot parse them. +charts/**/templates/ + +# Prettier's MDX printer treats Markdown inside JSX children as a single text +# block and re-wraps it, so lists and numbered steps inside Mintlify +# components (, , , …) collapse into one paragraph. +# Formatting the docs would silently change what they render. +*.mdx + +# Intentional empty list items are the fill-in placeholders of this form; +# Prettier folds them into the label above and changes the template. +.github/pull_request_template.md + +# Partly owned by a generator: .agents/install.sh rewrites the `robots:start` +# block, and Prettier wants a blank line after that marker where the installer +# emits none. Ignoring is per-file, not per-region, so the whole file is out β€” +# otherwise every regeneration of the block turns format:check red on whichever +# unrelated PR happens to carry it. +AGENTS.md diff --git a/AGENTS.md b/AGENTS.md index d13294fb6bc6..5cc81e4517d9 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -11,6 +11,7 @@ Formbricks runs as a pnpm/turbo monorepo. `apps/web` is the Next.js product surf - `pnpm dev` β€” run all app and worker dev servers in parallel via Turborepo. - `pnpm build` β€” generate production builds for every package and app. - `pnpm lint` β€” apply the shared ESLint rules across the workspace. +- `pnpm format` / `pnpm format:check` β€” apply or verify Prettier across the workspace; `format:check` is what CI runs, so run `pnpm format` before pushing if you committed with `--no-verify`. - `pnpm test` / `pnpm test:coverage` β€” execute Vitest suites with optional coverage. - `pnpm test:e2e` β€” launch the Playwright browser regression suite. - `pnpm db:migrate:dev` β€” apply Prisma migrations against the dev database. @@ -31,6 +32,7 @@ The `@formbricks/surveys` package is pre-compiled (Vite β†’ UMD + ESM) and the b ## Coding Style & Naming Conventions TypeScript, React, and Prisma are the primary languages. Use the shared ESLint presets (`@formbricks/eslint-config`) and Prettier preset (110-char width, semicolons, double quotes, sorted import groups). Two-space indentation is standard; prefer `PascalCase` for React components and folders under `modules/`, `camelCase` for functions/variables, and `SCREAMING_SNAKE_CASE` only for constants. When adding mocks, place them inside `__mocks__` so import ordering stays stable. +Import order is set by `@trivago/prettier-plugin-sort-imports` and verified in CI by `pnpm format:check`, so it is not a matter of taste: `__mocks__` imports come first (they carry `vi.mock` calls), then `server-only`, then third-party packages, then `@formbricks/*`, `~/*`, `@/*`, and relative imports. Do not ask for or apply a different order in review β€” it will fail the check. We are using SonarQube to identify code smells and security hotspots. Always mark React component props as `Readonly<>` (e.g., `({ children }: Readonly)`). diff --git a/apps/web/app/(app)/workspaces/[workspaceId]/components/WidgetStatusIndicator.tsx b/apps/web/app/(app)/workspaces/[workspaceId]/components/WidgetStatusIndicator.tsx index 7d60ba3c0cfe..f40d66e4d084 100644 --- a/apps/web/app/(app)/workspaces/[workspaceId]/components/WidgetStatusIndicator.tsx +++ b/apps/web/app/(app)/workspaces/[workspaceId]/components/WidgetStatusIndicator.tsx @@ -52,7 +52,7 @@ export const WidgetStatusIndicator = ({ workspace }: WidgetStatusIndicatorProps)

{currentStatus.title}

-

{currentStatus.subtitle}

+

{currentStatus.subtitle}

{status === "notImplemented" && ( @@ -51,7 +51,7 @@ const Loading = () => {
-
+
diff --git a/apps/web/app/(app)/workspaces/[workspaceId]/settings/workspace/integrations/notion/loading.tsx b/apps/web/app/(app)/workspaces/[workspaceId]/settings/workspace/integrations/notion/loading.tsx index fd7305c5ee08..3ba680e4cf1b 100644 --- a/apps/web/app/(app)/workspaces/[workspaceId]/settings/workspace/integrations/notion/loading.tsx +++ b/apps/web/app/(app)/workspaces/[workspaceId]/settings/workspace/integrations/notion/loading.tsx @@ -10,7 +10,7 @@ const Loading = () => {
-
@@ -48,7 +48,7 @@ const Loading = () => {
-
+
diff --git a/apps/web/app/(app)/workspaces/[workspaceId]/surveys/[surveyId]/(analysis)/summary/components/ElementSummaryHeader.tsx b/apps/web/app/(app)/workspaces/[workspaceId]/surveys/[surveyId]/(analysis)/summary/components/ElementSummaryHeader.tsx index 7436044e9c68..c07aa4c76317 100644 --- a/apps/web/app/(app)/workspaces/[workspaceId]/surveys/[surveyId]/(analysis)/summary/components/ElementSummaryHeader.tsx +++ b/apps/web/app/(app)/workspaces/[workspaceId]/surveys/[surveyId]/(analysis)/summary/components/ElementSummaryHeader.tsx @@ -27,7 +27,7 @@ export const ElementSummaryHeader = ({ const elementType = getElementTypes(t).find((type) => type.id === elementSummary.element.type); return ( -
+

{formatTextWithSlashes( diff --git a/apps/web/app/layout.tsx b/apps/web/app/layout.tsx index 540c28bc8c4a..cc0fc401fb4d 100644 --- a/apps/web/app/layout.tsx +++ b/apps/web/app/layout.tsx @@ -1,14 +1,8 @@ import { Metadata } from "next"; import React from "react"; import { NoScriptWarning } from "@/app/components/NoScriptWarning"; -import { SentryProvider } from "@/app/sentry/SentryProvider"; -import { - DEFAULT_LOCALE, - IS_PRODUCTION, - SENTRY_DSN, - SENTRY_ENVIRONMENT, - SENTRY_RELEASE, -} from "@/lib/constants"; +import { DEFAULT_LOCALE } from "@/lib/constants"; +import { SentryClientConfigScript } from "@/lib/sentry/SentryClientConfigScript"; import { I18nProvider } from "@/lingodotdev/client"; import { getLocale } from "@/lingodotdev/language"; import "../modules/ui/globals.css"; @@ -27,16 +21,12 @@ const RootLayout = async ({ children }: { children: React.ReactNode }) => { return ( + {/* First in the document so instrumentation-client.ts can start Sentry as early as possible. */} + - - - {children} - - + + {children} + ); diff --git a/apps/web/app/sentry/SentryProvider.tsx b/apps/web/app/sentry/SentryProvider.tsx deleted file mode 100644 index ddfe51fccc03..000000000000 --- a/apps/web/app/sentry/SentryProvider.tsx +++ /dev/null @@ -1,69 +0,0 @@ -"use client"; - -import * as Sentry from "@sentry/nextjs"; -import { useEffect } from "react"; - -interface SentryProviderProps { - children: React.ReactNode; - sentryDsn?: string; - sentryRelease?: string; - sentryEnvironment?: string; - isEnabled?: boolean; -} - -export const SentryProvider = ({ - children, - sentryDsn, - sentryRelease, - sentryEnvironment, - isEnabled, -}: SentryProviderProps) => { - useEffect(() => { - if (sentryDsn && isEnabled) { - Sentry.init({ - dsn: sentryDsn, - release: sentryRelease, - environment: sentryEnvironment, - - // No tracing while Sentry doesn't update to telemetry 2.0.0 - https://github.com/getsentry/sentry-javascript/issues/15737 - tracesSampleRate: 0, - - // Setting this option to true will print useful information to the console while you're setting up Sentry. - debug: false, - - // Disable telemetry and additional data collection - sendDefaultPii: false, - sendClientReports: false, - - replaysOnErrorSampleRate: 1.0, - - // This sets the sample rate to be 10%. You may want this to be 100% while - // in development and sample at a lower rate in production - replaysSessionSampleRate: 0.1, - - // You can remove this option if you're not planning to use the Sentry Session Replay feature: - integrations: [ - Sentry.replayIntegration({ - // Additional Replay configuration goes in here, for example: - maskAllText: true, - blockAllMedia: true, - }), - ], - - beforeSend(event, hint) { - const error = hint.originalException as Error; - - // @ts-expect-error - if (error && error.digest === "NEXT_NOT_FOUND") { - return null; - } - - return event; - }, - }); - } - // We only want to run this once - }, []); - - return <>{children}; -}; diff --git a/apps/web/eslint.config.mjs b/apps/web/eslint.config.mjs index d1ca21334ab6..b290ac04fb5d 100644 --- a/apps/web/eslint.config.mjs +++ b/apps/web/eslint.config.mjs @@ -3,14 +3,7 @@ import next from "@formbricks/eslint-config/next"; const config = [ // carried over from the legacy .eslintignore / ignorePatterns { - ignores: [ - ".next/**", - "public/**", - "playwright/**", - "vendor/**", - "**/package.json", - "**/tsconfig.json", - ], + ignores: [".next/**", "public/**", "playwright/**", "vendor/**", "**/package.json", "**/tsconfig.json"], }, ...next, { diff --git a/apps/web/instrumentation-client.ts b/apps/web/instrumentation-client.ts new file mode 100644 index 000000000000..c1654be01ba9 --- /dev/null +++ b/apps/web/instrumentation-client.ts @@ -0,0 +1,9 @@ +// Next.js client instrumentation hook: runs in the browser before any app code and before +// hydration, so module-evaluation, hydration and early-navigation errors are captured. Replaces the +// former `SentryProvider` `useEffect`, which only started Sentry after hydration (ENG-1686). +import * as Sentry from "@sentry/nextjs"; +import { initClientSentryFromRuntimeConfig } from "@/lib/sentry/init-client-sentry"; + +initClientSentryFromRuntimeConfig(); + +export const onRouterTransitionStart = Sentry.captureRouterTransitionStart; diff --git a/apps/web/lib/sentry/SentryClientConfigScript.tsx b/apps/web/lib/sentry/SentryClientConfigScript.tsx new file mode 100644 index 000000000000..c8077c116118 --- /dev/null +++ b/apps/web/lib/sentry/SentryClientConfigScript.tsx @@ -0,0 +1,37 @@ +import { IS_PRODUCTION, SENTRY_DSN, SENTRY_ENVIRONMENT, SENTRY_RELEASE } from "@/lib/constants"; +import { + SENTRY_CLIENT_RUNTIME_CONFIG_KEY, + type TSentryClientRuntimeConfig, +} from "@/lib/sentry/client-runtime-config"; + +/** + * Serialises the server-only Sentry env vars into the document so `instrumentation-client.ts` can + * start the browser SDK with runtime values (see `client-runtime-config.ts` for why this hand-off + * exists). Rendering nothing is the gate: without a DSN or outside production no config -- and no + * DSN -- reaches the browser, matching the previous `SentryProvider` gating. + */ +export const SentryClientConfigScript = () => { + if (!IS_PRODUCTION || !SENTRY_DSN) { + return null; + } + + const config: TSentryClientRuntimeConfig = { + dsn: SENTRY_DSN, + release: SENTRY_RELEASE, + environment: SENTRY_ENVIRONMENT, + }; + + // `` cannot appear in a DSN, but escaping `<` to its unicode form keeps the + // inline script safe regardless of what the env vars hold. String.raw avoids + // double-escaping the backslash, so the replacement reads as it lands. + const serializedConfig = JSON.stringify(config).replaceAll("<", String.raw`\u003c`); + + return ( +