Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .pre-commit-config.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,9 @@ repos:
- id: polymath-cpp
- id: polymath-ros
- id: polymath-go
- id: polymath-javascript
- id: polymath-css
- id: polymath-html
- id: polymath-shell
- id: polymath-cmake
- id: polymath-docker
Expand Down
23 changes: 23 additions & 0 deletions .pre-commit-hooks.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,29 @@
entry: polymath_code_standard go
types_or: [go, go-mod, go-sum]

- <<: *python-hook
id: polymath-javascript
name: Polymath Code Standard [javascript]
description: >
JavaScript and TypeScript checks with ESLint and Prettier.
Accepts args: [--framework, next] and [--framework, storybook], repeatable.
entry: polymath_code_standard javascript
types_or: [javascript, jsx, ts, tsx]

- <<: *python-hook
id: polymath-css
name: Polymath Code Standard [css]
description: CSS and SCSS checks with Stylelint and Prettier.
entry: polymath_code_standard css
types_or: [css, scss]

- <<: *python-hook
id: polymath-html
name: Polymath Code Standard [html]
description: HTML formatting with Prettier.
entry: polymath_code_standard html
types: [html]

- <<: *python-hook
id: polymath-shell
name: Polymath Code Standard [shell]
Expand Down
68 changes: 68 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,9 @@ repos:
- id: polymath-cpp
- id: polymath-ros
- id: polymath-go
- id: polymath-javascript
- id: polymath-css
- id: polymath-html
- id: polymath-shell
- id: polymath-cmake
- id: polymath-docker
Expand Down Expand Up @@ -201,6 +204,51 @@ Files under `vendor/` are skipped.
On its first run the hook downloads a pinned `golangci-lint` release, verified against a checksum pinned in this repo, into its own pre-commit virtualenv.
Nothing is written to your repository, and later runs reuse the download.

---

### `polymath-javascript`

Runs `eslint --fix` and then `prettier` on JavaScript, JSX, TypeScript, and TSX files using Polymath's bundled configuration.
The rule set is `eslint:recommended`, `typescript-eslint` recommended, and for `.jsx`/`.tsx` files the recommended rules of `eslint-plugin-react`, `eslint-plugin-react-hooks`, and `eslint-plugin-jsx-a11y`.
`eslint-config-prettier` is applied last, so ESLint enforces no formatting rules.

Framework rule sets are opt-in, because they report on patterns that are only wrong inside those frameworks.

**Optional:**

- `--framework next` -- Add the `recommended` and `core-web-vitals` rules from `@next/eslint-plugin-next`
- `--framework storybook` -- Add the `flat/recommended` rules from `eslint-plugin-storybook`, which apply to story files

Repeat `--framework` to enable more than one:

```yaml
- id: polymath-javascript
args: [--framework, next, --framework, storybook]
```

> [!NOTE]
> The first run of this hook downloads its npm packages.
> See [Node tooling is installed on first use](#node-tooling-is-installed-on-first-use).

> [!NOTE]
> Whole-program type checking is not part of this hook.
> `tsc --noEmit` needs your repo's installed `node_modules` and is not a per-file check, so keep it in your own CI.

---

### `polymath-css`

Runs `stylelint --fix` and then `prettier` on CSS and SCSS files.
CSS uses `stylelint-config-standard` and SCSS uses `stylelint-config-standard-scss`.

No arguments.

---

### `polymath-html`

Runs `prettier` on HTML files.

No arguments.

---
Expand Down Expand Up @@ -286,6 +334,26 @@ No arguments.

---

## Node tooling is installed on first use

`polymath-javascript`, `polymath-css`, and `polymath-html` run ESLint, Stylelint, and Prettier, none of which pip can install.
Node itself comes from the `nodejs-wheel` PyPI package, so no system Node installation is required.

The first time one of these hooks runs, it installs its pinned npm packages with `npm ci` into `polymath-node/` inside the hook's own pre-commit virtualenv, then stamps a digest of the bundled lockfile and configs beside them.
Expect that first run to take a minute.
Later runs reuse the install, which is shared by every repo on the machine using the same hook revision.
Nothing is written into your repository, so no `.gitignore` entry is needed.

Prettier runs only on the file types these three hooks accept.
JSON, YAML, and Markdown belong to `polymath-json`, `polymath-yaml`, and `polymath-markdown`, and Prettier never sees them.

Prettier honors `.gitignore` and `.prettierignore` in your repository root, which can only narrow the set of files these hooks format.

An `.editorconfig` in your repository is ignored.
These hooks pin indentation and line width to the bundled configuration, so formatting does not change from repo to repo.

---

## `.ruff.toml` is written to the consuming repo

While `ruff` can take a `--config` argument to an absolute file, subdirectory overrides require Ruff to walk up the directory tree.
Expand Down
131 changes: 131 additions & 0 deletions polymath_code_standard/checkers/web/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,131 @@
# SPDX-FileCopyrightText: 2026 Polymath Robotics, Inc.
# SPDX-License-Identifier: Apache-2.0
import argparse
from pathlib import Path

from polymath_code_standard.checker import CheckerGroup, Result, check_group, run
from polymath_code_standard.checkers.web._node import (
ensure_node_modules,
node_config,
node_env,
node_tool,
)

# Rule sets a repo opts into with --framework.
FRAMEWORKS = ('next', 'storybook')

RESTAGE = '(files have been reformatted — please re-stage and recommit)'


def run_eslint(node_dir: Path, files: list[str], frameworks: list[str]) -> Result:
"""Lint and auto-fix, with the named framework rule sets enabled.

Exits non-zero only when errors remain after the fix pass.
"""
return run(
'eslint',
[
node_tool(node_dir, 'eslint'),
'--config',
node_config(node_dir, 'eslint.config.mjs'),
'--no-config-lookup',
'--no-warn-ignored',
'--fix',
],
files,
env={**node_env(), 'POLYMATH_ESLINT_FRAMEWORKS': ','.join(frameworks)},
)


def run_prettier(node_dir: Path, files: list[str]) -> Result:
"""Dry-run to detect issues, then fix in place if needed.

Prettier skips paths matched by the working directory's .gitignore or .prettierignore.
"""
if not files:
return Result(name='prettier', passed=True, skipped=True)
# --no-editorconfig: a consumer's .editorconfig overrides indent and line width even
# when --config names an absolute file.
base = [
node_tool(node_dir, 'prettier'),
'--config',
node_config(node_dir, 'prettier.config.mjs'),
'--no-editorconfig',
]
env = node_env()
check = run('prettier', base + ['--check'], files, env=env)
if check.passed:
return check
write = run('prettier', base + ['--write'], files, env=env)
output = f'{check.output}\n{RESTAGE}' if write.passed else check.output
return Result(name='prettier', passed=False, output=output, cmd=check.cmd)


def run_stylelint(node_dir: Path, files: list[str]) -> Result:
"""Lint and auto-fix stylesheets."""
return run(
'stylelint',
[node_tool(node_dir, 'stylelint'), '--config', node_config(node_dir, 'stylelint.config.mjs'), '--fix'],
files,
env=node_env(),
)


def _skipped(*names: str) -> list[Result]:
return [Result(name=name, passed=True, skipped=True) for name in names]


@check_group
class JavascriptGroup(CheckerGroup):
name = 'javascript'

def register_args(self, subparser: argparse.ArgumentParser) -> None:
super().register_args(subparser)
subparser.add_argument(
'--framework',
action='append',
choices=FRAMEWORKS,
default=[],
metavar='NAME',
help=f'Enable a framework rule set ({", ".join(FRAMEWORKS)}). Repeatable.',
)

def run(self, args: argparse.Namespace) -> list[Result]:
if not args.files:
return _skipped('eslint', 'prettier')
node_dir = ensure_node_modules()
if isinstance(node_dir, Result):
return [node_dir]
return [
run_eslint(node_dir, args.files, args.framework),
run_prettier(node_dir, args.files),
]


@check_group
class CssGroup(CheckerGroup):
name = 'css'

def run(self, args: argparse.Namespace) -> list[Result]:
if not args.files:
return _skipped('stylelint', 'prettier')
node_dir = ensure_node_modules()
if isinstance(node_dir, Result):
return [node_dir]
return [
run_stylelint(node_dir, args.files),
run_prettier(node_dir, args.files),
]


@check_group
class HtmlGroup(CheckerGroup):
name = 'html'

def run(self, args: argparse.Namespace) -> list[Result]:
if not args.files:
return _skipped('prettier')
node_dir = ensure_node_modules()
if isinstance(node_dir, Result):
return [node_dir]
return [run_prettier(node_dir, args.files)]
106 changes: 106 additions & 0 deletions polymath_code_standard/checkers/web/_node.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,106 @@
# SPDX-FileCopyrightText: 2026 Polymath Robotics, Inc.
# SPDX-License-Identifier: Apache-2.0
"""Install the bundled Node tooling into this virtualenv and locate it.

Usable on its own:

>>> from polymath_code_standard.checkers.web._node import ensure_node_modules, node_tool
>>> node_dir = ensure_node_modules()
>>> node_tool(node_dir, 'eslint')
"""

import fcntl
import hashlib
import importlib.resources
import os
import shutil
import sys
from collections.abc import Iterator
from contextlib import contextmanager
from pathlib import Path

from polymath_code_standard.checker import Result, run, tool

# Config files bundled alongside this checker
CONFIG_DIR = importlib.resources.files(__package__)

# Files copied into the install directory.
# Node resolves the configs' bare imports and extends from the config file's directory, so they sit
# beside node_modules.
BUNDLE = ('package.json', 'package-lock.json', 'eslint.config.mjs', 'prettier.config.mjs', 'stylelint.config.mjs')

# Inside the hook's virtualenv, so one install per hook revision.
INSTALL_DIR = Path(sys.prefix) / 'polymath-node'

STAMP_NAME = 'bundle-sha256'


def bundle_digest() -> str:
"""Return the sha256 over the bundled manifest and tool configs."""
digest = hashlib.sha256()
for name in BUNDLE:
digest.update((CONFIG_DIR / name).read_bytes())
return digest.hexdigest()


def node_tool(install_dir: Path, name: str) -> str:
"""Return the absolute path to an installed Node console script."""
return str(install_dir / 'node_modules' / '.bin' / name)


def node_config(install_dir: Path, name: str) -> str:
"""Return the absolute path to an installed tool config."""
return str(install_dir / name)


def node_env() -> dict:
"""Return the environment the installed console scripts need.

They are `#!/usr/bin/env node` scripts, so they need `node` on PATH.
"""
venv_bin = Path(sys.executable).parent
return {'PATH': os.pathsep.join([str(venv_bin), os.environ.get('PATH', '')])}


def ensure_node_modules(install_dir: Path = INSTALL_DIR) -> Path | Result:
"""Install the bundled Node tooling into install_dir and return that directory.

Reinstalls only when the bundle digest changes.
Concurrent callers serialize on a lock file beside install_dir.
Returns a failed Result carrying npm's output when the install fails.
"""
digest = bundle_digest()
stamp = install_dir / STAMP_NAME
if _is_stamped(stamp, digest):
return install_dir

install_dir.mkdir(parents=True, exist_ok=True)
with _exclusive(install_dir.with_name(f'{install_dir.name}.lock')):
if _is_stamped(stamp, digest):
return install_dir
for name in BUNDLE:
shutil.copy2(CONFIG_DIR / name, install_dir / name)
result = run(
'npm',
[tool('npm'), 'ci', '--prefix', str(install_dir), '--no-audit', '--no-fund', '--loglevel=error'],
)
if not result.passed:
return result
stamp.write_text(f'{digest}\n')
return install_dir


def _is_stamped(stamp: Path, digest: str) -> bool:
return stamp.is_file() and stamp.read_text().strip() == digest


@contextmanager
def _exclusive(lock_path: Path) -> Iterator[None]:
"""Hold an exclusive advisory lock on lock_path for the duration of the block."""
lock_path.parent.mkdir(parents=True, exist_ok=True)
with lock_path.open('w') as handle:
fcntl.flock(handle, fcntl.LOCK_EX)
try:
yield
finally:
fcntl.flock(handle, fcntl.LOCK_UN)
Loading
Loading