diff --git a/BUILD.bazel b/BUILD.bazel index acdfd98..b3b7c0f 100644 --- a/BUILD.bazel +++ b/BUILD.bazel @@ -6,6 +6,11 @@ load("@npm//:defs.bzl", "npm_link_all_packages") # Link npm packages for the root workspace npm_link_all_packages(name = "node_modules") +exports_files( + ["pyproject.toml"], + visibility = ["//visibility:public"], +) + # Empty target for the root workspace package override py_library( name = "root", @@ -38,3 +43,8 @@ gazelle( name = "gazelle", gazelle = ":gazelle_binary", ) + +alias( + name = "format", + actual = "//tools/format", +) diff --git a/MODULE.bazel b/MODULE.bazel index 05baa81..aa7e564 100644 --- a/MODULE.bazel +++ b/MODULE.bazel @@ -5,6 +5,7 @@ module( bazel_dep(name = "aspect_rules_py", version = "1.11.2") bazel_dep(name = "aspect_rules_js", version = "3.0.3") +bazel_dep(name = "aspect_rules_lint", version = "2.5.0") bazel_dep(name = "bazel_lib", version = "3.2.2") bazel_dep(name = "rules_nodejs", version = "6.7.3") bazel_dep(name = "platforms", version = "1.0.0") diff --git a/TODO.md b/TODO.md index 94f24de..a5010d6 100644 --- a/TODO.md +++ b/TODO.md @@ -1,7 +1,4 @@ -- [ ] Add React + Vite + PNPM FE monorepo infra - - [ ] Add Bazel support -- [ ] Fix various Bazel hard-coded paths -- [ ] Add Ruff and ESLint support to Bazel +- [ ] Remove ESLint hardcoded deps - [ ] Add Sentry - [ ] Add Vulture dead code analysis - [ ] Add Autofix CI \ No newline at end of file diff --git a/apps/backend/main.py b/apps/backend/main.py index d62bf46..bae7a8e 100644 --- a/apps/backend/main.py +++ b/apps/backend/main.py @@ -4,7 +4,10 @@ from shared import hello app = FastAPI() -_shared_is_compiled = shared.__file__ is not None and not shared.__file__.endswith(".py") +_shared_is_compiled = shared.__file__ is not None and not shared.__file__.endswith( + ".py" +) + @app.get("/") def read_root(): @@ -21,4 +24,4 @@ def debug_shared(): @app.get("/items/{item_id}") def read_item(item_id: int, q: str | None = None): - return {"item_id": item_id, "q": q} \ No newline at end of file + return {"item_id": item_id, "q": q} diff --git a/apps/backend/package.json b/apps/backend/package.json index 01be1f9..b0bb225 100644 --- a/apps/backend/package.json +++ b/apps/backend/package.json @@ -2,4 +2,4 @@ "name": "backend", "version": "0.0.0", "private": true -} \ No newline at end of file +} diff --git a/apps/frontend/BUILD.bazel b/apps/frontend/BUILD.bazel index f091f88..cdafc9a 100644 --- a/apps/frontend/BUILD.bazel +++ b/apps/frontend/BUILD.bazel @@ -1,3 +1,4 @@ +load("@aspect_rules_js//js:defs.bzl", "js_library") load("@npm//:defs.bzl", "npm_link_all_packages") load("@npm//apps/frontend:vite/package_json.bzl", vite_bin = "bin") load("@npm//apps/frontend:vitest/package_json.bzl", vitest_bin = "bin") @@ -41,3 +42,15 @@ vitest_bin.vitest_test( chdir = package_name(), data = SRCS + [":node_modules"], ) + +js_library( + name = "eslintrc", + srcs = ["eslint.config.js"], + visibility = ["//visibility:public"], + deps = [ + ":node_modules/@eslint/js", + ":node_modules/eslint-plugin-react-hooks", + ":node_modules/eslint-plugin-react-refresh", + ":node_modules/typescript-eslint", + ], +) diff --git a/apps/frontend/eslint.config.js b/apps/frontend/eslint.config.js index 5e6b472..75d3c46 100644 --- a/apps/frontend/eslint.config.js +++ b/apps/frontend/eslint.config.js @@ -1,14 +1,14 @@ -import js from '@eslint/js' -import globals from 'globals' -import reactHooks from 'eslint-plugin-react-hooks' -import reactRefresh from 'eslint-plugin-react-refresh' -import tseslint from 'typescript-eslint' -import { defineConfig, globalIgnores } from 'eslint/config' +import js from "@eslint/js"; +import globals from "globals"; +import reactHooks from "eslint-plugin-react-hooks"; +import reactRefresh from "eslint-plugin-react-refresh"; +import tseslint from "typescript-eslint"; +import { defineConfig, globalIgnores } from "eslint/config"; export default defineConfig([ - globalIgnores(['dist']), + globalIgnores(["dist"]), { - files: ['**/*.{ts,tsx}'], + files: ["**/*.{ts,tsx}"], extends: [ js.configs.recommended, tseslint.configs.recommended, @@ -20,4 +20,4 @@ export default defineConfig([ globals: globals.browser, }, }, -]) +]); diff --git a/apps/frontend/src/App.tsx b/apps/frontend/src/App.tsx index 3baf5a8..2d5306f 100644 --- a/apps/frontend/src/App.tsx +++ b/apps/frontend/src/App.tsx @@ -1,11 +1,11 @@ -import { HelloWorld } from 'components' +import { HelloWorld } from "components"; function App() { return ( <> - ) + ); } -export default App +export default App; diff --git a/apps/frontend/src/main.tsx b/apps/frontend/src/main.tsx index 4aff025..5d4a2be 100644 --- a/apps/frontend/src/main.tsx +++ b/apps/frontend/src/main.tsx @@ -1,9 +1,9 @@ -import { StrictMode } from 'react' -import { createRoot } from 'react-dom/client' -import App from './App.tsx' +import { StrictMode } from "react"; +import { createRoot } from "react-dom/client"; +import App from "./App.tsx"; -createRoot(document.getElementById('root')!).render( +createRoot(document.getElementById("root")!).render( , -) +); diff --git a/apps/frontend/src/placeholder.spec.ts b/apps/frontend/src/placeholder.spec.ts index 42bb064..f449c08 100644 --- a/apps/frontend/src/placeholder.spec.ts +++ b/apps/frontend/src/placeholder.spec.ts @@ -1,7 +1,7 @@ -import {describe, expect, it} from "vitest"; +import { describe, expect, it } from "vitest"; describe("placeholder", () => { it("should be a placeholder test", () => { expect(true).toBe(true); }); -}); \ No newline at end of file +}); diff --git a/apps/frontend/vite.config.ts b/apps/frontend/vite.config.ts index 5885bce..8c0e5ac 100644 --- a/apps/frontend/vite.config.ts +++ b/apps/frontend/vite.config.ts @@ -1,8 +1,8 @@ -import { defineConfig } from 'vite' -import react from '@vitejs/plugin-react' +import { defineConfig } from "vite"; +import react from "@vitejs/plugin-react"; // https://vite.dev/config/ export default defineConfig({ plugins: [react()], root: import.meta.dirname, -}) +}); diff --git a/package.json b/package.json index b397877..a2e1efd 100644 --- a/package.json +++ b/package.json @@ -1,11 +1,13 @@ { - "name": "root", - "private": true, - "scripts": { - "sherif": "sherif" - }, - "devDependencies": { - "sherif": "^1.5.0" - }, - "packageManager": "pnpm@10.33.0+sha512.10568bb4a6afb58c9eb3630da90cc9516417abebd3fabbe6739f0ae795728da1491e9db5a544c76ad8eb7570f5c4bb3d6c637b2cb41bfdcdb47fa823c8649319" + "name": "root", + "private": true, + "scripts": { + "sherif": "sherif" + }, + "devDependencies": { + "eslint": "^9.39.4", + "prettier": "^3.8.2", + "sherif": "^1.5.0" + }, + "packageManager": "pnpm@10.33.0+sha512.10568bb4a6afb58c9eb3630da90cc9516417abebd3fabbe6739f0ae795728da1491e9db5a544c76ad8eb7570f5c4bb3d6c637b2cb41bfdcdb47fa823c8649319" } diff --git a/packages/components/BUILD.bazel b/packages/components/BUILD.bazel index 2db39ac..b5ebb99 100644 --- a/packages/components/BUILD.bazel +++ b/packages/components/BUILD.bazel @@ -25,3 +25,15 @@ npm_package( srcs = [":components"], visibility = ["//visibility:public"], ) + +js_library( + name = "eslintrc", + srcs = ["eslint.config.js"], + visibility = ["//visibility:public"], + deps = [ + ":node_modules/@eslint/js", + ":node_modules/eslint-plugin-react-hooks", + ":node_modules/eslint-plugin-react-refresh", + ":node_modules/typescript-eslint", + ], +) diff --git a/packages/components/eslint.config.js b/packages/components/eslint.config.js new file mode 100644 index 0000000..223b5ae --- /dev/null +++ b/packages/components/eslint.config.js @@ -0,0 +1,21 @@ +import js from "@eslint/js"; +import reactHooks from "eslint-plugin-react-hooks"; +import reactRefresh from "eslint-plugin-react-refresh"; +import tseslint from "typescript-eslint"; +import { defineConfig, globalIgnores } from "eslint/config"; + +export default defineConfig([ + globalIgnores(["dist"]), + { + files: ["**/*.{ts,tsx}"], + extends: [ + js.configs.recommended, + tseslint.configs.recommended, + reactHooks.configs.flat.recommended, + reactRefresh.configs.vite, + ], + languageOptions: { + ecmaVersion: 2020, + }, + }, +]); diff --git a/packages/components/package.json b/packages/components/package.json index 569f4c3..4d89e2d 100644 --- a/packages/components/package.json +++ b/packages/components/package.json @@ -14,8 +14,13 @@ "react-dom": "^19.0.0" }, "devDependencies": { + "@eslint/js": "^9.39.4", "@types/react": "^19.2.14", "@types/react-dom": "^19.2.3", - "typescript": "~6.0.2" + "eslint": "^9.39.4", + "eslint-plugin-react-hooks": "^7.0.1", + "eslint-plugin-react-refresh": "^0.5.2", + "typescript": "~6.0.2", + "typescript-eslint": "^8.58.1" } } diff --git a/packages/shared/package.json b/packages/shared/package.json index 1a99424..2397f19 100644 --- a/packages/shared/package.json +++ b/packages/shared/package.json @@ -2,4 +2,4 @@ "name": "shared", "version": "0.0.0", "private": true -} \ No newline at end of file +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 3bc05b1..20862ba 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -8,6 +8,12 @@ importers: .: devDependencies: + eslint: + specifier: ^9.39.4 + version: 9.39.4(jiti@2.6.1) + prettier: + specifier: ^3.8.2 + version: 3.8.2 sherif: specifier: ^1.5.0 version: 1.11.1 @@ -75,15 +81,30 @@ importers: specifier: ^19.0.0 version: 19.2.5(react@19.2.5) devDependencies: + '@eslint/js': + specifier: ^9.39.4 + version: 9.39.4 '@types/react': specifier: ^19.2.14 version: 19.2.14 '@types/react-dom': specifier: ^19.2.3 version: 19.2.3(@types/react@19.2.14) + eslint: + specifier: ^9.39.4 + version: 9.39.4(jiti@2.6.1) + eslint-plugin-react-hooks: + specifier: ^7.0.1 + version: 7.0.1(eslint@9.39.4(jiti@2.6.1)) + eslint-plugin-react-refresh: + specifier: ^0.5.2 + version: 0.5.2(eslint@9.39.4(jiti@2.6.1)) typescript: specifier: ~6.0.2 version: 6.0.2 + typescript-eslint: + specifier: ^8.58.1 + version: 8.58.1(eslint@9.39.4(jiti@2.6.1))(typescript@6.0.2) packages/shared: {} @@ -933,6 +954,11 @@ packages: resolution: {integrity: sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==} engines: {node: '>= 0.8.0'} + prettier@3.8.2: + resolution: {integrity: sha512-8c3mgTe0ASwWAJK+78dpviD+A8EqhndQPUBpNUIPt6+xWlIigCwfN01lWr9MAede4uqXGTEKeQWTvzb3vjia0Q==} + engines: {node: '>=14'} + hasBin: true + punycode@2.3.1: resolution: {integrity: sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==} engines: {node: '>=6'} @@ -2028,6 +2054,8 @@ snapshots: prelude-ls@1.2.1: {} + prettier@3.8.2: {} + punycode@2.3.1: {} react-dom@19.2.5(react@19.2.5): diff --git a/pyproject.toml b/pyproject.toml index a6de0fb..c4d129c 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -33,6 +33,8 @@ _gazelle = "bazel run //:gazelle" _update-module-overrides = "uv run python tools/update_module_overrides.py" build = "bazel build //..." test = "bazel test //..." +format = "bazel run //tools/format" +lint = "bazel build //tools/lint:all" [tool.poe.tasks.sync] help = "Sync Bazel BUILD files after adding and app, deps or packages" diff --git a/tools/deploy_mypyc.py b/tools/deploy_mypyc.py index 0b6e7da..4de138d 100644 --- a/tools/deploy_mypyc.py +++ b/tools/deploy_mypyc.py @@ -10,6 +10,7 @@ import argparse import glob import os import shutil + ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) BAZEL_BIN = os.path.join(ROOT, ".bazel", "bin") PACKAGES_DIR = os.path.join(ROOT, "packages") @@ -35,9 +36,7 @@ def deploy(): # Determine the package name from the directory structure rel = os.path.relpath(root, mypyc_dir) # e.g. "shared" # Find matching source package - src_pkg = glob.glob( - os.path.join(PACKAGES_DIR, "*", "src", rel) - ) + src_pkg = glob.glob(os.path.join(PACKAGES_DIR, "*", "src", rel)) if src_pkg: dest = os.path.join(src_pkg[0], f) shutil.copy2(os.path.join(root, f), dest) @@ -46,7 +45,9 @@ def deploy(): def clean(): """Remove all deployed .so files from source packages.""" - for pkg_src in glob.glob(os.path.join(PACKAGES_DIR, "*", "src", "**"), recursive=True): + for pkg_src in glob.glob( + os.path.join(PACKAGES_DIR, "*", "src", "**"), recursive=True + ): if _is_extension(os.path.basename(pkg_src)): os.remove(pkg_src) print(f" removed: {os.path.relpath(pkg_src, ROOT)}") @@ -54,7 +55,9 @@ def clean(): def main(): parser = argparse.ArgumentParser() - parser.add_argument("--clean", action="store_true", help="Remove deployed .so files") + parser.add_argument( + "--clean", action="store_true", help="Remove deployed .so files" + ) args = parser.parse_args() if args.clean: diff --git a/tools/format/BUILD.bazel b/tools/format/BUILD.bazel new file mode 100644 index 0000000..8315b15 --- /dev/null +++ b/tools/format/BUILD.bazel @@ -0,0 +1,27 @@ +"""Formatter binary definitions. + +This is in its own package so that loading-time symbols +don't trigger eager fetches for builds that don't run format. +""" + +load("@aspect_rules_lint//format:defs.bzl", "format_multirun") +load("@npm//:prettier/package_json.bzl", prettier = "bin") + +package(default_visibility = ["//:__subpackages__"]) + +prettier.prettier_binary( + name = "prettier", + env = {"BAZEL_BINDIR": "."}, + fixed_args = [ + "--loglevel=warn", + ], +) + +format_multirun( + name = "format", + css = ":prettier", + html = ":prettier", + javascript = ":prettier", + python = "@aspect_rules_lint//format:ruff", + visibility = ["//:__subpackages__"], +) diff --git a/tools/lint/BUILD.bazel b/tools/lint/BUILD.bazel new file mode 100644 index 0000000..f93fe7a --- /dev/null +++ b/tools/lint/BUILD.bazel @@ -0,0 +1,21 @@ +"""Linter binary definitions. + +This is in its own package so that loading-time symbols +don't trigger eager fetches for builds that don't run lint. +""" + +load("@npm//:eslint/package_json.bzl", eslint_bin = "bin") + +package(default_visibility = ["//:__subpackages__"]) + +eslint_bin.eslint_binary( + name = "eslint_frontend", + data = ["//apps/frontend:eslintrc"], + fixed_args = ["--config=apps/frontend/eslint.config.js"], +) + +eslint_bin.eslint_binary( + name = "eslint_components", + data = ["//packages/components:eslintrc"], + fixed_args = ["--config=packages/components/eslint.config.js"], +) diff --git a/tools/lint/linters.bzl b/tools/lint/linters.bzl new file mode 100644 index 0000000..be02afa --- /dev/null +++ b/tools/lint/linters.bzl @@ -0,0 +1,32 @@ +"Define linter aspects for the repository" + +load("@aspect_rules_lint//lint:eslint.bzl", "lint_eslint_aspect") +load("@aspect_rules_lint//lint:lint_test.bzl", "lint_test") +load("@aspect_rules_lint//lint:ruff.bzl", "lint_ruff_aspect") + +ruff = lint_ruff_aspect( + binary = Label("@aspect_rules_lint//lint:ruff_bin"), + configs = [ + Label("@//:pyproject.toml"), + ], +) + +ruff_test = lint_test(aspect = ruff) + +eslint_frontend = lint_eslint_aspect( + binary = Label("//tools/lint:eslint_frontend"), + configs = [ + Label("//apps/frontend:eslintrc"), + ], +) + +eslint_frontend_test = lint_test(aspect = eslint_frontend) + +eslint_components = lint_eslint_aspect( + binary = Label("//tools/lint:eslint_components"), + configs = [ + Label("//packages/components:eslintrc"), + ], +) + +eslint_components_test = lint_test(aspect = eslint_components) diff --git a/tools/mypyc_compile.py b/tools/mypyc_compile.py index 112fd21..87148c9 100644 --- a/tools/mypyc_compile.py +++ b/tools/mypyc_compile.py @@ -41,9 +41,7 @@ def main() -> None: # Use mypyc's Python API directly (avoids subprocess + PATH issues) from mypyc.build import mypycify - src_paths = [ - os.path.join(pkg_dir, os.path.basename(s)) for s in args.srcs - ] + src_paths = [os.path.join(pkg_dir, os.path.basename(s)) for s in args.srcs] ext_modules = mypycify(src_paths) if not ext_modules: @@ -74,8 +72,10 @@ def main() -> None: dest_name = f pkg_prefix = args.package_name + "." if f.startswith(pkg_prefix): - dest_name = "__init__." + f[len(pkg_prefix):] - shutil.copy2(os.path.join(root, f), os.path.join(out_pkg, dest_name)) + dest_name = "__init__." + f[len(pkg_prefix) :] + shutil.copy2( + os.path.join(root, f), os.path.join(out_pkg, dest_name) + ) print(f" compiled: {args.package_name}/{dest_name}") diff --git a/tools/update_gazelle_resolves.py b/tools/update_gazelle_resolves.py index bfe7770..46be426 100644 --- a/tools/update_gazelle_resolves.py +++ b/tools/update_gazelle_resolves.py @@ -64,7 +64,11 @@ def update_build_file(resolve_lines: list[str]) -> None: """Replace the resolve block in BUILD.bazel with the new lines.""" content = BUILD_FILE.read_text() - block = f"{RESOLVE_MARKER_START}\n" + "\n".join(resolve_lines) + f"\n{RESOLVE_MARKER_END}" + block = ( + f"{RESOLVE_MARKER_START}\n" + + "\n".join(resolve_lines) + + f"\n{RESOLVE_MARKER_END}" + ) pattern = re.compile( rf"^{re.escape(RESOLVE_MARKER_START)}$.*?^{re.escape(RESOLVE_MARKER_END)}$", @@ -102,7 +106,9 @@ def main() -> None: resolve_lines = generate_resolve_lines(packages) update_build_file(resolve_lines) - print(f"Updated {BUILD_FILE.name} with resolve directives for: {', '.join(packages)}") + print( + f"Updated {BUILD_FILE.name} with resolve directives for: {', '.join(packages)}" + ) for line in resolve_lines: print(f" {line}") diff --git a/tools/update_module_overrides.py b/tools/update_module_overrides.py index cea3c59..6372c02 100644 --- a/tools/update_module_overrides.py +++ b/tools/update_module_overrides.py @@ -20,7 +20,7 @@ def read_pyproject_name(pyproject: Path) -> str | None: try: data = tomllib.loads(pyproject.read_text()) return data.get("project", {}).get("name") - except (OSError, tomllib.TOMLDecodeError): + except OSError, tomllib.TOMLDecodeError: return None @@ -85,7 +85,9 @@ def generate_override_lines(overrides: list[tuple[str, str]]) -> list[str]: """Generate the Starlark lines for uv.override_package calls.""" lines: list[str] = [] for name, target in overrides: - lines.append(f'uv.override_package(name = "{name}", lock = "//:uv.lock", target = "{target}")') + lines.append( + f'uv.override_package(name = "{name}", lock = "//:uv.lock", target = "{target}")' + ) return lines