diff --git a/.bec/rules.yaml b/.bec/rules.yaml index ed79270..cb6f0b0 100644 --- a/.bec/rules.yaml +++ b/.bec/rules.yaml @@ -13,7 +13,7 @@ rules: paths: - "src/**/*.py" - "tests/**/*.py" - check: "python3 -m becwright.checks.redundant_comments" + check: "becwright run redundant_comments" severity: warning - id: no-debug-remnants @@ -26,7 +26,7 @@ rules: # definition (a check that searches for a string can't run over its own source). paths: - "src/becwright/*.py" - check: "python3 -m becwright.checks.debug_remnants" + check: "becwright run debug_remnants" severity: blocking - id: no-wildcard-imports @@ -37,7 +37,7 @@ rules: paths: - "src/becwright/**/*.py" - "tests/**/*.py" - check: "python3 -m becwright.checks.wildcard_imports" + check: "becwright run wildcard_imports" severity: blocking - id: no-dangerous-eval @@ -50,5 +50,5 @@ rules: # matches its own pattern definition). paths: - "src/becwright/*.py" - check: "python3 -m becwright.checks.dangerous_eval" + check: "becwright run dangerous_eval" severity: blocking diff --git a/.github/workflows/build-binaries.yml b/.github/workflows/build-binaries.yml new file mode 100644 index 0000000..8323ba8 --- /dev/null +++ b/.github/workflows/build-binaries.yml @@ -0,0 +1,47 @@ +name: Build binaries + +# Freezes a standalone `becwright` binary per platform with PyInstaller. +# These artifacts are what the npm packages ship, so users without Python can +# install becwright via npm/pnpm. Release publishing is wired in publish.yml. +on: + workflow_dispatch: + workflow_call: # invoked by release.yml on a published release + +jobs: + build: + name: ${{ matrix.target }} + runs-on: ${{ matrix.os }} + strategy: + fail-fast: false + matrix: + include: + - { os: ubuntu-latest, target: linux-x64, ext: "" } + - { os: ubuntu-24.04-arm, target: linux-arm64, ext: "" } + - { os: macos-13, target: darwin-x64, ext: "" } + - { os: macos-14, target: darwin-arm64, ext: "" } + - { os: windows-latest, target: win32-x64, ext: ".exe" } + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-python@v5 + with: + python-version: "3.12" + - name: Install + run: pip install ".[build]" + - name: Build + run: pyinstaller --clean --noconfirm packaging/becwright.spec + - name: Smoke test + shell: bash + run: | + BIN="dist/becwright${{ matrix.ext }}" + "$BIN" --version + "$BIN" list + echo 'console.log(1)' > x.js + if echo x.js | "$BIN" run forbid --pattern 'console\.log'; then + echo "forbid should have exited non-zero on a match"; exit 1 + fi + echo "frozen 'run' dispatch works" + - uses: actions/upload-artifact@v4 + with: + name: becwright-${{ matrix.target }} + path: dist/becwright${{ matrix.ext }} + if-no-files-found: error diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml deleted file mode 100644 index 5e7a6f4..0000000 --- a/.github/workflows/publish.yml +++ /dev/null @@ -1,22 +0,0 @@ -name: Publish to PyPI - -on: - release: - types: [published] - -jobs: - publish: - runs-on: ubuntu-latest - permissions: - id-token: write # required for PyPI Trusted Publishing (OIDC) - steps: - - uses: actions/checkout@v4 - - uses: actions/setup-python@v5 - with: - python-version: "3.12" - - name: Build - run: | - pip install build - python -m build - - name: Publish - uses: pypa/gh-action-pypi-publish@release/v1 diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..3ecbd5c --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,62 @@ +name: Release + +# On a published GitHub release: build the per-platform binaries, publish the +# npm packages (launcher + 5 platform packages) and publish to PyPI. The tag +# name (e.g. v0.1.0) drives the version. +on: + release: + types: [published] + +jobs: + binaries: + uses: ./.github/workflows/build-binaries.yml + + npm: + needs: binaries + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version: "20" + registry-url: "https://registry.npmjs.org" + - name: Download built binaries + uses: actions/download-artifact@v4 + with: + path: artifacts + - name: Stage binaries into platform packages + run: | + node npm/stage.mjs linux-x64 artifacts/becwright-linux-x64/becwright + node npm/stage.mjs linux-arm64 artifacts/becwright-linux-arm64/becwright + node npm/stage.mjs darwin-x64 artifacts/becwright-darwin-x64/becwright + node npm/stage.mjs darwin-arm64 artifacts/becwright-darwin-arm64/becwright + node npm/stage.mjs win32-x64 artifacts/becwright-win32-x64/becwright.exe + - name: Set version from tag + run: node npm/set-version.mjs "${GITHUB_REF_NAME#v}" + - name: Publish platform packages + run: | + for dir in npm/platforms/*/; do + npm publish "$dir" --access public + done + env: + NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} + - name: Publish launcher package + run: npm publish npm/becwright --access public + env: + NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} + + pypi: + runs-on: ubuntu-latest + permissions: + id-token: write # PyPI Trusted Publishing (OIDC) + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-python@v5 + with: + python-version: "3.12" + - name: Build + run: | + pip install build + python -m build + - name: Publish + uses: pypa/gh-action-pypi-publish@release/v1 diff --git a/.gitignore b/.gitignore index d539326..3045f2c 100644 --- a/.gitignore +++ b/.gitignore @@ -15,6 +15,11 @@ dist/ .env .DS_Store +# npm: staged binaries (built in CI) and installed deps +node_modules/ +npm/platforms/*/becwright +npm/platforms/*/becwright.exe + # Prototype and documentation prototype/ docs/ \ No newline at end of file diff --git a/README.es.md b/README.es.md index 8ed0608..17e9464 100644 --- a/README.es.md +++ b/README.es.md @@ -51,7 +51,10 @@ becwright se instala una vez como herramienta; cada repo solo aporta su propio `.bec/rules.yaml`. ```bash -# 1. Instalar el motor (una vez, global) +# 1. Instalar el motor. Elegí tu ecosistema — sin Python vía npm/pnpm, que +# traen un binario autónomo: +npm install --save-dev becwright # o global: npm install -g becwright +pnpm add -D becwright pipx install becwright # o: pip install becwright # 2. En tu repo, generar reglas + instalar el hook @@ -60,6 +63,11 @@ becwright init # detecta tu lenguaje, escribe .bec/rules.ya # 3. Listo: cada commit corre los chequeos; si una regla blocking falla, frena. ``` +Instalado como devDependency, el hook de pre-commit resuelve el binario local +desde `node_modules/.bin`, así funciona sin instalación global. Los paquetes npm +cubren `linux-x64`, `linux-arm64`, `darwin-x64`, `darwin-arm64` y `win32-x64`; en +cualquier otra plataforma usá `pipx install becwright`. + Comandos disponibles: | Comando | Qué hace | @@ -83,7 +91,7 @@ rules: Si un token aparece en los logs, cualquiera con acceso a ellos puede robar la sesión de un usuario. paths: ["src/**/*.py"] - check: "python3 -m becwright.checks.no_token_in_logs" + check: "becwright run no_token_in_logs" severity: blocking # blocking = frena el commit | warning = solo avisa ``` @@ -120,7 +128,7 @@ rules: why_it_matters: > Un 'debugger' olvidado detiene la ejecución y no debería llegar a producción. paths: ["**/*.js", "**/*.ts"] - check: "python3 -m becwright.checks.forbid --pattern '\\bdebugger\\b'" + check: "becwright run forbid --pattern '\\bdebugger\\b'" severity: blocking ``` @@ -151,7 +159,7 @@ commit. Usá `--yes` para saltar la confirmación en entornos automatizados. Hay un **catálogo de BECs listas para usar** en [`becs/`](becs/) que podés importar directo desde su URL cruda. -Los checks built-in (`python3 -m becwright.checks.*`) viajan con el paquete, así +Los checks built-in (`becwright run *`) viajan con el paquete, así que el bundle solo guarda su nombre. Un check **custom** (`.bec/checks/foo.py`) viaja con su código embebido y aterriza en `.bec/checks/` del repo destino. diff --git a/README.md b/README.md index c73e8ec..0809a7d 100644 --- a/README.md +++ b/README.md @@ -50,7 +50,10 @@ becwright is installed once as a tool; each repo only contributes its own `.bec/rules.yaml`. ```bash -# 1. Install the engine (once, global) +# 1. Install the engine. Pick your ecosystem — no Python needed via npm/pnpm, +# which ship a self-contained binary: +npm install --save-dev becwright # or global: npm install -g becwright +pnpm add -D becwright pipx install becwright # or: pip install becwright # 2. In your repo, scaffold rules + install the hook @@ -59,6 +62,11 @@ becwright init # detects your language, writes .bec/rules.y # 3. Done: each commit runs the checks; if a blocking rule fails, it stops. ``` +Installed as a devDependency, the pre-commit hook resolves the local binary from +`node_modules/.bin`, so it works without a global install. The npm packages cover +`linux-x64`, `linux-arm64`, `darwin-x64`, `darwin-arm64` and `win32-x64`; on any +other platform use `pipx install becwright`. + Available commands: | Command | What it does | @@ -82,7 +90,7 @@ rules: If a token shows up in the logs, anyone with access to them can steal a user's session. paths: ["src/**/*.py"] - check: "python3 -m becwright.checks.no_token_in_logs" + check: "becwright run no_token_in_logs" severity: blocking # blocking = stops the commit | warning = only warns ``` @@ -113,7 +121,7 @@ rules: A secret in the repo stays in git history forever and is visible to anyone with access to the code. paths: ["src/**/*.py"] - check: "python3 -m becwright.checks.hardcoded_secrets" + check: "becwright run hardcoded_secrets" severity: blocking - id: no-debug-remnants @@ -122,7 +130,7 @@ rules: why_it_matters: > A forgotten breakpoint hangs the process in production or CI. paths: ["src/**/*.py"] - check: "python3 -m becwright.checks.debug_remnants" + check: "becwright run debug_remnants" severity: blocking - id: no-dangerous-eval @@ -131,7 +139,7 @@ rules: why_it_matters: > eval/exec on untrusted input is remote code execution. paths: ["src/**/*.py"] - check: "python3 -m becwright.checks.dangerous_eval" + check: "becwright run dangerous_eval" severity: blocking - id: no-wildcard-imports @@ -141,7 +149,7 @@ rules: Wildcard imports hide where each name comes from and break static analysis. paths: ["src/**/*.py"] - check: "python3 -m becwright.checks.wildcard_imports" + check: "becwright run wildcard_imports" severity: warning ``` @@ -162,7 +170,7 @@ rules: why_it_matters: > A forgotten 'debugger' halts execution and should not reach production. paths: ["**/*.js", "**/*.ts"] - check: "python3 -m becwright.checks.forbid --pattern '\\bdebugger\\b'" + check: "becwright run forbid --pattern '\\bdebugger\\b'" severity: blocking ``` @@ -193,7 +201,7 @@ Use `--yes` to skip the confirmation in automated environments. There is a **catalog of ready-to-use BECs** in [`becs/`](becs/) that you can import directly from their raw URL. -Built-in checks (`python3 -m becwright.checks.*`) travel with the package, so +Built-in checks (`becwright run *`) travel with the package, so the bundle only stores their name. A **custom** check (`.bec/checks/foo.py`) travels with its code embedded and lands in `.bec/checks/` of the target repo. diff --git a/documentation/portability.es.md b/documentation/portability.es.md index d0ff1e8..82879b9 100644 --- a/documentation/portability.es.md +++ b/documentation/portability.es.md @@ -51,10 +51,13 @@ Al exportar una regla, su comando `check` se clasifica: | Tipo | Cuándo | Qué viaja en el bundle | |---|---|---| -| `builtin` | `python3 -m becwright.checks.X [args]` | el nombre del módulo (y los args) | +| `builtin` | `becwright run X [args]` | el nombre del módulo (y los args) | | `script` | referencia un archivo del repo, p.ej. `.bec/checks/foo.py` | el código fuente del script, embebido | | `command` | cualquier otra cosa | el string crudo del comando (se avisa al importar) | +La forma antigua `python3 -m becwright.checks.X` se sigue reconociendo al +importar, así que los bundles exportados por versiones previas siguen funcionando. + Un bundle `script` aterriza su código embebido en `.bec/checks/` del repo destino, así un check propio viaja con su código. Un bundle `builtin` solo necesita el nombre, porque ese código viene con el paquete becwright. diff --git a/documentation/portability.md b/documentation/portability.md index 0410a79..73a02f9 100644 --- a/documentation/portability.md +++ b/documentation/portability.md @@ -51,10 +51,13 @@ When you export a rule, its `check` command is classified: | Kind | When | What travels in the bundle | |---|---|---| -| `builtin` | `python3 -m becwright.checks.X [args]` | the module name (and args) | +| `builtin` | `becwright run X [args]` | the module name (and args) | | `script` | references a repo file, e.g. `.bec/checks/foo.py` | the script's source, embedded | | `command` | anything else | the raw command string (a warning is shown on import) | +The legacy `python3 -m becwright.checks.X` form is still recognized on import, +so bundles exported by older versions keep working. + A `script` bundle lands its embedded code in `.bec/checks/` of the target repo, so a custom check travels with its code. A `builtin` bundle only needs the name, because that code ships with the becwright package. diff --git a/documentation/releasing.es.md b/documentation/releasing.es.md new file mode 100644 index 0000000..5c9dd81 --- /dev/null +++ b/documentation/releasing.es.md @@ -0,0 +1,40 @@ +# Publicar una versión + +becwright se distribuye por tres canales desde un único release de GitHub: + +- **PyPI** — el paquete de Python (`pip` / `pipx`). +- **npm** — un paquete lanzador (`becwright`) más cinco paquetes por plataforma + con `os`/`cpu` (`@becwright/`), cada uno con un binario precompilado, + para que quien no usa Python pueda instalarlo. + +## Configuración inicial (una vez) + +- **PyPI**: vía Trusted Publishing (OIDC), sin token almacenado. Ver el job + `pypi` en [`.github/workflows/release.yml`](../.github/workflows/release.yml). +- **npm**: + - Crear el scope/org `@becwright` en npm y asegurar que la cuenta también sea + dueña del nombre sin scope `becwright`. + - Agregar un token de acceso de automatización como secreto del repositorio + `NPM_TOKEN`. + +## Sacar una versión + +1. Subir la versión en `pyproject.toml` (las versiones de npm se derivan del tag + de git al publicar, no hace falta editarlas). +2. Commit y crear un **release de GitHub** con tag `vX.Y.Z` (debe coincidir con + `pyproject.toml`). +3. Publicar el release dispara `release.yml`, que: + - construye y prueba el binario en las cinco plataformas, + - stagea cada binario en su paquete `@becwright/` (`npm/stage.mjs`), + - fija la versión de cada paquete npm desde el tag (`npm/set-version.mjs`), + - publica primero los paquetes de plataforma y luego el lanzador, + - construye y publica el paquete de Python en PyPI. + +## Notas + +- Los paquetes de plataforma se publican antes que el lanzador para que sus + `optionalDependencies` resuelvan de inmediato. +- `npm publish` no es idempotente: re-ejecutar una versión ya publicada falla. + Subí la versión para volver a publicar. +- Para probar los binarios sin publicar, ejecutá el workflow **Build binaries** + manualmente (`workflow_dispatch`). diff --git a/documentation/releasing.md b/documentation/releasing.md new file mode 100644 index 0000000..c44c2ea --- /dev/null +++ b/documentation/releasing.md @@ -0,0 +1,40 @@ +# Releasing + +becwright ships through three channels from a single GitHub release: + +- **PyPI** — the Python package (`pip` / `pipx`). +- **npm** — a launcher package (`becwright`) plus five `os`/`cpu`-gated platform + packages (`@becwright/`) that each carry a prebuilt binary, so users + without Python can install it. + +## One-time setup + +- **PyPI**: configured via Trusted Publishing (OIDC), no token stored. See the + `pypi` job in [`.github/workflows/release.yml`](../.github/workflows/release.yml). +- **npm**: + - Create the `@becwright` scope/org on npm and ensure the account also owns the + unscoped `becwright` name. + - Add an automation access token as the `NPM_TOKEN` repository secret. + +## Cutting a release + +1. Bump the version in `pyproject.toml` (the npm versions are derived from the + git tag at publish time, so they don't need editing). +2. Commit, then create a **GitHub release** whose tag is `vX.Y.Z` (must match + `pyproject.toml`). +3. Publishing the release triggers `release.yml`, which: + - builds and smoke-tests the binary on all five platforms, + - stages each binary into its `@becwright/` package + (`npm/stage.mjs`), + - sets every npm package version from the tag (`npm/set-version.mjs`), + - publishes the platform packages first, then the launcher, + - builds and publishes the Python package to PyPI. + +## Notes + +- Platform packages are published before the launcher so the launcher's + `optionalDependencies` resolve immediately. +- `npm publish` is not idempotent: re-running for an already-published version + fails. Bump the version to re-release. +- To test binary builds without releasing, run the **Build binaries** workflow + manually (`workflow_dispatch`). diff --git a/documentation/usage.es.md b/documentation/usage.es.md index fbc95b4..064fb1d 100644 --- a/documentation/usage.es.md +++ b/documentation/usage.es.md @@ -51,7 +51,7 @@ rules: - "Redactar al loguear -> demasiado fácil de saltarse" paths: # globs de los archivos a los que aplica la regla - "src/**/*.py" - check: "python3 -m becwright.checks.no_token_in_logs" + check: "becwright run no_token_in_logs" severity: blocking # blocking (frena el commit) | warning (solo avisa) ``` diff --git a/documentation/usage.md b/documentation/usage.md index b18b9b8..684c55c 100644 --- a/documentation/usage.md +++ b/documentation/usage.md @@ -50,7 +50,7 @@ rules: - "Redact at log time -> too easy to bypass" paths: # glob patterns of files this rule applies to - "src/**/*.py" - check: "python3 -m becwright.checks.no_token_in_logs" + check: "becwright run no_token_in_logs" severity: blocking # blocking (stops commit) | warning (only warns) ``` diff --git a/documentation/writing-checks.es.md b/documentation/writing-checks.es.md index 294f73d..c54b890 100644 --- a/documentation/writing-checks.es.md +++ b/documentation/writing-checks.es.md @@ -20,7 +20,7 @@ genérico incluido: ```yaml - id: no-debugger-js paths: ["**/*.js", "**/*.ts"] - check: "python3 -m becwright.checks.forbid --pattern '\\bdebugger\\b'" + check: "becwright run forbid --pattern '\\bdebugger\\b'" severity: blocking ``` diff --git a/documentation/writing-checks.md b/documentation/writing-checks.md index 0ebb952..fe5c7ba 100644 --- a/documentation/writing-checks.md +++ b/documentation/writing-checks.md @@ -20,7 +20,7 @@ generic check: ```yaml - id: no-debugger-js paths: ["**/*.js", "**/*.ts"] - check: "python3 -m becwright.checks.forbid --pattern '\\bdebugger\\b'" + check: "becwright run forbid --pattern '\\bdebugger\\b'" severity: blocking ``` diff --git a/npm/becwright/README.md b/npm/becwright/README.md new file mode 100644 index 0000000..09f34ff --- /dev/null +++ b/npm/becwright/README.md @@ -0,0 +1,44 @@ +# becwright + +Deterministically enforces constraints (**BECs** — Bound Executable Constraints) +on your code, blocking commits that violate them. Unlike `CLAUDE.md` or +`.cursorrules`, which *ask* an agent to respect rules, becwright **verifies the +result** by running a real check against the code. + +This npm package ships a self-contained binary, so **no Python is required**. The +right binary for your platform is installed automatically as an optional +dependency (the same model used by esbuild and ruff). + +## Install + +```bash +# project-local (recommended) +npm install --save-dev becwright +pnpm add -D becwright + +# or global +npm install -g becwright +``` + +## Usage + +```bash +becwright init # scaffold .bec/rules.yaml and install the git hook +becwright list # show the built-in checks +becwright check # check staged files (runs automatically on commit) +becwright import # add a BEC from the catalog +``` + +When installed as a devDependency, the generated pre-commit hook resolves the +local binary from `node_modules/.bin`, so it works without a global install. + +## Supported platforms + +`linux-x64`, `linux-arm64`, `darwin-x64`, `darwin-arm64`, `win32-x64`. + +On other platforms, install via [pipx](https://pipx.pypa.io): `pipx install becwright`. + +## Links + +- Source & docs: https://github.com/DataDave-Dev/becwright +- BEC catalog: https://github.com/DataDave-Dev/becwright/tree/main/becs diff --git a/npm/becwright/bin/becwright.js b/npm/becwright/bin/becwright.js new file mode 100755 index 0000000..432efd5 --- /dev/null +++ b/npm/becwright/bin/becwright.js @@ -0,0 +1,52 @@ +#!/usr/bin/env node +"use strict"; + +// Thin launcher: resolves the prebuilt binary for this platform (shipped as an +// optional, os/cpu-gated dependency) and execs it. Mirrors the model used by +// esbuild, ruff and biome. No Python is involved. +const { spawnSync } = require("child_process"); +const fs = require("fs"); +const path = require("path"); + +const TARGET = `${process.platform}-${process.arch}`; // e.g. linux-x64, darwin-arm64, win32-x64 +const EXE = process.platform === "win32" ? "becwright.exe" : "becwright"; + +function resolveBinary() { + try { + return require.resolve(`@becwright/${TARGET}/${EXE}`); + } catch { + return null; + } +} + +const binary = resolveBinary(); +if (!binary) { + console.error(`becwright: no prebuilt binary for ${TARGET}.`); + console.error("Supported: linux-x64, linux-arm64, darwin-x64, darwin-arm64, win32-x64."); + console.error("Alternatively install via pipx: pipx install becwright"); + process.exit(1); +} + +// Best effort: npm preserves the executable bit from the tarball, but restore it +// defensively in case it was lost. +if (process.platform !== "win32") { + try { + fs.chmodSync(binary, 0o755); + } catch { + /* ignore */ + } +} + +// becwright's engine shells out to `becwright run ` for built-in checks. +// Put the binary's own directory on PATH so that subprocess resolves even when +// becwright is only a local devDependency (node_modules/.bin is not on PATH +// inside git hooks). +const env = { ...process.env }; +env.PATH = path.dirname(binary) + path.delimiter + (env.PATH || ""); + +const result = spawnSync(binary, process.argv.slice(2), { stdio: "inherit", env }); +if (result.error) { + console.error(`becwright: ${result.error.message}`); + process.exit(1); +} +process.exit(result.status === null ? 1 : result.status); diff --git a/npm/becwright/package.json b/npm/becwright/package.json new file mode 100644 index 0000000..897fc83 --- /dev/null +++ b/npm/becwright/package.json @@ -0,0 +1,34 @@ +{ + "name": "becwright", + "version": "0.1.0", + "description": "Deterministically enforces constraints (BECs) on your code, blocking commits that violate them. No Python required.", + "bin": { + "becwright": "bin/becwright.js" + }, + "files": [ + "bin/" + ], + "keywords": [ + "lint", + "pre-commit", + "git-hooks", + "constraints", + "code-quality" + ], + "homepage": "https://github.com/DataDave-Dev/becwright", + "repository": { + "type": "git", + "url": "git+https://github.com/DataDave-Dev/becwright.git" + }, + "license": "MIT", + "engines": { + "node": ">=16" + }, + "optionalDependencies": { + "@becwright/linux-x64": "0.1.0", + "@becwright/linux-arm64": "0.1.0", + "@becwright/darwin-x64": "0.1.0", + "@becwright/darwin-arm64": "0.1.0", + "@becwright/win32-x64": "0.1.0" + } +} diff --git a/npm/platforms/darwin-arm64/package.json b/npm/platforms/darwin-arm64/package.json new file mode 100644 index 0000000..75c5e94 --- /dev/null +++ b/npm/platforms/darwin-arm64/package.json @@ -0,0 +1,16 @@ +{ + "name": "@becwright/darwin-arm64", + "version": "0.1.0", + "description": "macOS arm64 (Apple Silicon) binary for becwright.", + "os": [ + "darwin" + ], + "cpu": [ + "arm64" + ], + "files": [ + "becwright" + ], + "license": "MIT", + "homepage": "https://github.com/DataDave-Dev/becwright" +} diff --git a/npm/platforms/darwin-x64/package.json b/npm/platforms/darwin-x64/package.json new file mode 100644 index 0000000..395ef40 --- /dev/null +++ b/npm/platforms/darwin-x64/package.json @@ -0,0 +1,16 @@ +{ + "name": "@becwright/darwin-x64", + "version": "0.1.0", + "description": "macOS x64 binary for becwright.", + "os": [ + "darwin" + ], + "cpu": [ + "x64" + ], + "files": [ + "becwright" + ], + "license": "MIT", + "homepage": "https://github.com/DataDave-Dev/becwright" +} diff --git a/npm/platforms/linux-arm64/package.json b/npm/platforms/linux-arm64/package.json new file mode 100644 index 0000000..c581796 --- /dev/null +++ b/npm/platforms/linux-arm64/package.json @@ -0,0 +1,16 @@ +{ + "name": "@becwright/linux-arm64", + "version": "0.1.0", + "description": "Linux arm64 binary for becwright.", + "os": [ + "linux" + ], + "cpu": [ + "arm64" + ], + "files": [ + "becwright" + ], + "license": "MIT", + "homepage": "https://github.com/DataDave-Dev/becwright" +} diff --git a/npm/platforms/linux-x64/package.json b/npm/platforms/linux-x64/package.json new file mode 100644 index 0000000..2e2c317 --- /dev/null +++ b/npm/platforms/linux-x64/package.json @@ -0,0 +1,16 @@ +{ + "name": "@becwright/linux-x64", + "version": "0.1.0", + "description": "Linux x64 binary for becwright.", + "os": [ + "linux" + ], + "cpu": [ + "x64" + ], + "files": [ + "becwright" + ], + "license": "MIT", + "homepage": "https://github.com/DataDave-Dev/becwright" +} diff --git a/npm/platforms/win32-x64/package.json b/npm/platforms/win32-x64/package.json new file mode 100644 index 0000000..63844cb --- /dev/null +++ b/npm/platforms/win32-x64/package.json @@ -0,0 +1,16 @@ +{ + "name": "@becwright/win32-x64", + "version": "0.1.0", + "description": "Windows x64 binary for becwright.", + "os": [ + "win32" + ], + "cpu": [ + "x64" + ], + "files": [ + "becwright.exe" + ], + "license": "MIT", + "homepage": "https://github.com/DataDave-Dev/becwright" +} diff --git a/npm/set-version.mjs b/npm/set-version.mjs new file mode 100644 index 0000000..d4758b4 --- /dev/null +++ b/npm/set-version.mjs @@ -0,0 +1,35 @@ +// Sets the version across the launcher and every platform package, and keeps the +// launcher's optionalDependencies in sync. Run by CI from the release tag. +// node npm/set-version.mjs (e.g. 0.1.0) +import { readFileSync, writeFileSync, readdirSync } from "node:fs"; +import { dirname, join } from "node:path"; +import { fileURLToPath } from "node:url"; + +const version = process.argv[2]; +if (!version || !/^\d+\.\d+\.\d+/.test(version)) { + console.error("usage: node npm/set-version.mjs (e.g. 0.1.0)"); + process.exit(1); +} + +const here = dirname(fileURLToPath(import.meta.url)); + +function patch(file, fn) { + const pkg = JSON.parse(readFileSync(file, "utf8")); + fn(pkg); + writeFileSync(file, JSON.stringify(pkg, null, 2) + "\n"); + console.log(`set ${pkg.name} -> ${version}`); +} + +patch(join(here, "becwright", "package.json"), (pkg) => { + pkg.version = version; + for (const dep of Object.keys(pkg.optionalDependencies || {})) { + pkg.optionalDependencies[dep] = version; + } +}); + +const platforms = join(here, "platforms"); +for (const target of readdirSync(platforms)) { + patch(join(platforms, target, "package.json"), (pkg) => { + pkg.version = version; + }); +} diff --git a/npm/stage.mjs b/npm/stage.mjs new file mode 100644 index 0000000..447ac58 --- /dev/null +++ b/npm/stage.mjs @@ -0,0 +1,22 @@ +// Copies a freshly built PyInstaller binary into its platform package so it can +// be published to npm. Used by CI (one call per built target) and for local +// testing. +// node npm/stage.mjs +// e.g. node npm/stage.mjs linux-x64 dist/becwright +import { copyFileSync, chmodSync } from "node:fs"; +import { dirname, join } from "node:path"; +import { fileURLToPath } from "node:url"; + +const [target, binaryPath] = process.argv.slice(2); +if (!target || !binaryPath) { + console.error("usage: node npm/stage.mjs "); + process.exit(1); +} + +const here = dirname(fileURLToPath(import.meta.url)); +const isWin = target.startsWith("win32"); +const dest = join(here, "platforms", target, isWin ? "becwright.exe" : "becwright"); + +copyFileSync(binaryPath, dest); +if (!isWin) chmodSync(dest, 0o755); +console.log(`staged ${binaryPath} -> ${dest}`); diff --git a/packaging/becwright.spec b/packaging/becwright.spec new file mode 100644 index 0000000..417689c --- /dev/null +++ b/packaging/becwright.spec @@ -0,0 +1,47 @@ +# PyInstaller spec for a standalone `becwright` binary (one file, no Python needed). +# Build from the repo root after `pip install .`: +# pyinstaller packaging/becwright.spec +import os + +from PyInstaller.utils.hooks import collect_submodules + +root = os.path.dirname(SPECPATH) # noqa: F821 - SPECPATH is injected by PyInstaller +entry = os.path.join(SPECPATH, "becwright_entry.py") # noqa: F821 + +# Checks are loaded dynamically via importlib (`becwright run `), so +# PyInstaller's static analysis cannot find them. Collect them all explicitly. +hiddenimports = collect_submodules("becwright.checks") + +a = Analysis( + [entry], + pathex=[os.path.join(root, "src")], + binaries=[], + datas=[], + hiddenimports=hiddenimports, + hookspath=[], + hooksconfig={}, + runtime_hooks=[], + excludes=[], + noarchive=False, +) + +pyz = PYZ(a.pure) + +exe = EXE( + pyz, + a.scripts, + a.binaries, + a.datas, + [], + name="becwright", + debug=False, + bootloader_ignore_signals=False, + strip=False, + upx=False, + console=True, + disable_windowed_traceback=False, + argv_emulation=False, + target_arch=None, + codesign_identity=None, + entitlements_file=None, +) diff --git a/packaging/becwright_entry.py b/packaging/becwright_entry.py new file mode 100644 index 0000000..176567a --- /dev/null +++ b/packaging/becwright_entry.py @@ -0,0 +1,5 @@ +import sys + +from becwright.cli import main + +sys.exit(main()) diff --git a/pyproject.toml b/pyproject.toml index 91ac9ae..60f564e 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -29,6 +29,8 @@ becwright = "becwright.cli:main" [project.optional-dependencies] dev = ["pytest>=8", "pytest-cov>=5"] +# Used to freeze the standalone binary distributed via npm (see packaging/). +build = ["pyinstaller>=6"] [tool.setuptools.packages.find] where = ["src"] diff --git a/src/becwright/__main__.py b/src/becwright/__main__.py new file mode 100644 index 0000000..dbdd066 --- /dev/null +++ b/src/becwright/__main__.py @@ -0,0 +1,6 @@ +import sys + +from .cli import main + +if __name__ == "__main__": + sys.exit(main()) diff --git a/src/becwright/bundle.py b/src/becwright/bundle.py index 0dd22e8..bcf2e3d 100644 --- a/src/becwright/bundle.py +++ b/src/becwright/bundle.py @@ -11,7 +11,11 @@ BUNDLE_VERSION = 1 -_BUILTIN = re.compile(r"^python3?\s+-m\s+becwright\.checks\.(\w+)(?:\s+(.*))?$") +# Accept the current `becwright run ` form and the legacy +# `python3 -m becwright.checks.` form so older bundles still import. +_BUILTIN = re.compile( + r"^(?:becwright\s+run\s+(\w+)|python3?\s+-m\s+becwright\.checks\.(\w+))(?:\s+(.*))?$" +) _PY_PATH = re.compile(r"[\w./-]+\.py") _ITEM_INDENT = re.compile(r"^([ \t]+)-\s", re.MULTILINE) _EMPTY_RULES = re.compile(r"^rules:[ \t]*(?:\[[ \t]*\]|\{[ \t]*\})[ \t]*$", re.MULTILINE) @@ -41,9 +45,9 @@ def classify_check(command: str, root: Path) -> dict: command = command.strip() builtin = _BUILTIN.match(command) if builtin: - out = {"kind": "builtin", "module": builtin.group(1)} - if builtin.group(2): - out["args"] = builtin.group(2).strip() + out = {"kind": "builtin", "module": builtin.group(1) or builtin.group(2)} + if builtin.group(3): + out["args"] = builtin.group(3).strip() return out for token in _PY_PATH.findall(command): candidate = root / token @@ -108,7 +112,7 @@ def materialize(bundle: dict, root: Path) -> dict: check = bundle["check"] kind = check.get("kind") if kind == "builtin": - command = f"python3 -m becwright.checks.{check['module']}" + command = f"becwright run {check['module']}" if check.get("args"): command += f" {check['args']}" elif kind == "script": diff --git a/src/becwright/cli.py b/src/becwright/cli.py index 1d830e7..7ee292b 100644 --- a/src/becwright/cli.py +++ b/src/becwright/cli.py @@ -87,8 +87,19 @@ def _builtin_check_names() -> list[str]: return sorted(m.name for m in pkgutil.iter_modules(checks.__path__) if not m.name.startswith("_")) +def _cmd_run(args: argparse.Namespace) -> int: + if args.module not in _builtin_check_names(): + print(f"{RED}Unknown built-in check: {args.module}{RESET}", file=sys.stderr) + return 2 + from importlib import import_module + # Forward any args to the check through sys.argv: checks that take options + # (e.g. forbid --pattern) read sys.argv[1:]; the rest just ignore it. + sys.argv = [f"becwright run {args.module}", *args.args] + return import_module(f"becwright.checks.{args.module}").main() + + def _cmd_list(_: argparse.Namespace) -> int: - print(f"{BOLD}Built-in checks{RESET} {DIM}(use as: python3 -m becwright.checks.){RESET}") + print(f"{BOLD}Built-in checks{RESET} {DIM}(use as: becwright run ){RESET}") for name in _builtin_check_names(): desc = _CHECK_DESCRIPTIONS.get(name, "") line = f" {GREEN}{name}{RESET}" @@ -123,30 +134,30 @@ def _starter_rules(langs: list[str]) -> list[dict]: if source_globs: rules.append(dict( id="no-hardcoded-secrets", paths=source_globs, severity="blocking", - check="python3 -m becwright.checks.hardcoded_secrets", + check="becwright run hardcoded_secrets", intent="No secret (key, token, password) should be hardcoded in the code.", why="A secret in the repo stays in git history forever and is visible to anyone with access to the code.")) if "python" in langs: rules.append(dict( id="no-debug-remnants", paths=["**/*.py"], severity="blocking", - check="python3 -m becwright.checks.debug_remnants", + check="becwright run debug_remnants", intent="Debug code (breakpoints, pdb) must not be committed.", why="A forgotten breakpoint hangs the process in production or CI.")) rules.append(dict( id="no-dangerous-eval", paths=["**/*.py"], severity="blocking", - check="python3 -m becwright.checks.dangerous_eval", + check="becwright run dangerous_eval", intent="Avoid eval and exec, which run arbitrary code.", why="Dynamic eval/exec on untrusted input is remote code execution.")) if "js" in langs or "ts" in langs: js_globs = [g for g in source_globs if g.endswith((".js", ".ts"))] rules.append(dict( id="no-debugger-js", paths=js_globs, severity="blocking", - check="python3 -m becwright.checks.forbid --pattern '\\bdebugger\\b'", + check="becwright run forbid --pattern '\\bdebugger\\b'", intent="Do not leave 'debugger;' in JavaScript/TypeScript code.", why="A forgotten 'debugger' halts execution and should not reach production.")) rules.append(dict( id="no-console-log-js", paths=js_globs, severity="warning", - check="python3 -m becwright.checks.forbid --pattern 'console\\.log\\s*\\('", + check="becwright run forbid --pattern 'console\\.log\\s*\\('", intent="Avoid 'console.log(...)' in JavaScript/TypeScript code.", why="Debug console.log statements clutter production output.")) return rules @@ -278,6 +289,11 @@ def _build_parser() -> argparse.ArgumentParser: p_init.add_argument("--force", action="store_true", help="overwrite an existing .bec/rules.yaml") p_init.set_defaults(func=_cmd_init) + p_run = sub.add_parser("run", help="run a built-in check against files on stdin (used inside rules)") + p_run.add_argument("module", help="built-in check name (see `becwright list`)") + p_run.add_argument("args", nargs=argparse.REMAINDER, help="arguments forwarded to the check") + p_run.set_defaults(func=_cmd_run) + sub.add_parser("list", help="list the built-in checks").set_defaults(func=_cmd_list) sub.add_parser("install", help="install the pre-commit hook").set_defaults(func=_cmd_install) sub.add_parser("uninstall", help="remove the pre-commit hook").set_defaults(func=_cmd_uninstall) diff --git a/src/becwright/git.py b/src/becwright/git.py index d00fc2a..137c295 100644 --- a/src/becwright/git.py +++ b/src/becwright/git.py @@ -6,10 +6,19 @@ # Marker used to recognize (and safely remove) a hook written by becwright. _HOOK_MARK = "# >>> becwright hook >>>" +# Prefer a project-local install (npm/pnpm devDependency) over a global one, so +# the hook works whether becwright is on PATH or only in node_modules/.bin. _HOOK_CONTENT = f"""#!/bin/sh {_HOOK_MARK} # Generated by `becwright install`. Do not edit by hand: use `becwright uninstall`. -exec becwright check +if [ -x "./node_modules/.bin/becwright" ]; then + exec ./node_modules/.bin/becwright check +elif command -v becwright >/dev/null 2>&1; then + exec becwright check +else + echo "becwright not found. Install it globally (pipx / npm i -g) or as a devDependency." >&2 + exit 1 +fi # <<< becwright hook <<< """ diff --git a/tests/test_bundle.py b/tests/test_bundle.py index c50be1c..74bc75b 100644 --- a/tests/test_bundle.py +++ b/tests/test_bundle.py @@ -45,6 +45,11 @@ def _init_repo(path): # --- classify_check --- def test_classify_builtin(tmp_path): + out = bundle.classify_check("becwright run debug_remnants", tmp_path) + assert out == {"kind": "builtin", "module": "debug_remnants"} + + +def test_classify_builtin_legacy_form(tmp_path): out = bundle.classify_check("python3 -m becwright.checks.debug_remnants", tmp_path) assert out == {"kind": "builtin", "module": "debug_remnants"} @@ -65,6 +70,13 @@ def test_classify_opaque_command(tmp_path): def test_classify_builtin_with_args(tmp_path): + out = bundle.classify_check( + r"becwright run forbid --pattern '\bdebugger\b'", tmp_path) + assert out == {"kind": "builtin", "module": "forbid", + "args": r"--pattern '\bdebugger\b'"} + + +def test_classify_builtin_legacy_form_with_args(tmp_path): out = bundle.classify_check( r"python3 -m becwright.checks.forbid --pattern '\bdebugger\b'", tmp_path) assert out == {"kind": "builtin", "module": "forbid", @@ -75,18 +87,26 @@ def test_materialize_builtin_with_args(tmp_path): data = {"rule": {"id": "r", "paths": ["*.js"], "severity": "blocking"}, "check": {"kind": "builtin", "module": "forbid", "args": r"--pattern '\bx\b'"}} rd = bundle.materialize(data, tmp_path) - assert rd["check"] == r"python3 -m becwright.checks.forbid --pattern '\bx\b'" + assert rd["check"] == r"becwright run forbid --pattern '\bx\b'" def test_roundtrip_builtin_args_preserved(tmp_path): rule = Rule(id="no-dbg", paths=("**/*.js",), - check=r"python3 -m becwright.checks.forbid --pattern '\bdebugger\b'", + check=r"becwright run forbid --pattern '\bdebugger\b'", severity="blocking") data = bundle.parse_bundle(bundle.export_bec(rule, tmp_path)) assert data["check"]["args"] == r"--pattern '\bdebugger\b'" assert bundle.materialize(data, tmp_path)["check"] == rule.check +def test_legacy_check_materializes_to_new_form(tmp_path): + rule = Rule(id="no-dbg", paths=("**/*.py",), + check="python3 -m becwright.checks.debug_remnants", + severity="blocking") + data = bundle.parse_bundle(bundle.export_bec(rule, tmp_path)) + assert bundle.materialize(data, tmp_path)["check"] == "becwright run debug_remnants" + + def test_catalog_bundles_are_valid(tmp_path): becs = Path(__file__).resolve().parents[1] / "becs" files = list(becs.glob("*.bec.yaml")) @@ -94,7 +114,7 @@ def test_catalog_bundles_are_valid(tmp_path): for f in files: data = bundle.parse_bundle(f.read_text(encoding="utf-8")) rd = bundle.materialize(data, tmp_path) - assert rd["check"].startswith("python3 -m becwright.checks.") + assert rd["check"].startswith("becwright run ") # --- export / parse --- @@ -124,7 +144,7 @@ def test_materialize_builtin_command(tmp_path): data = {"rule": {"id": "r", "paths": ["*.py"], "severity": "blocking"}, "check": {"kind": "builtin", "module": "debug_remnants"}} rule_dict = bundle.materialize(data, tmp_path) - assert rule_dict["check"] == "python3 -m becwright.checks.debug_remnants" + assert rule_dict["check"] == "becwright run debug_remnants" def test_materialize_script_writes_file(tmp_path): diff --git a/tests/test_cli_and_git.py b/tests/test_cli_and_git.py index faa521c..aef681d 100644 --- a/tests/test_cli_and_git.py +++ b/tests/test_cli_and_git.py @@ -140,3 +140,28 @@ def test_main_install_uninstall(tmp_path, monkeypatch): def test_main_outside_repo_returns_2(tmp_path, monkeypatch): monkeypatch.chdir(tmp_path) assert cli.main(["install"]) == 2 + + +# --- the run subcommand --- + +def test_run_forwards_pattern_and_blocks(tmp_path, monkeypatch, capsys): + import io + f = tmp_path / "app.js" + f.write_text(" debugger;\n", encoding="utf-8") + monkeypatch.setattr("sys.stdin", io.StringIO(str(f) + "\n")) + assert cli.main(["run", "forbid", "--pattern", r"\bdebugger\b"]) == 1 + assert str(f) in capsys.readouterr().out + + +def test_run_no_arg_check_reads_stdin(tmp_path, monkeypatch): + import io + f = tmp_path / "bad.py" + f.write_text("breakpoint()\n", encoding="utf-8") + monkeypatch.setattr("sys.stdin", io.StringIO(str(f) + "\n")) + assert cli.main(["run", "debug_remnants"]) == 1 + + +def test_run_unknown_check_returns_2(monkeypatch): + import io + monkeypatch.setattr("sys.stdin", io.StringIO("")) + assert cli.main(["run", "nope_check"]) == 2