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
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ All notable changes to this project are documented in this file. The format is b

### Fixed

- A fresh `smpy new` app passes its own `make lint`. `host/alembic.ini` gains a `[post_write_hooks]` entry that runs the project's ruff (`check --select I --fix`, then `format`) on every new revision, so the initial-schema revision and each `make migration` output no longer fail `ruff format --check` and `ruff check` (this repo's `host/alembic.ini` has it too); a standalone `smpy create-host` project, which has no workspace root, gets a `dev` dependency group with `ruff` for the hook. `create-module` sorts the `module.py` constants import for any module name (it was unsorted for names that sort before `group`, such as `bookmarks`). The `# ty: ignore[invalid-assignment]` in `contracts/schemas.py` is generated only for a module inside this repo, where ty checks that rule, and new apps pin `ty>=0.0.90,<0.0.100`, since ty 0.0.100 renamed the `unresolved-attribute` rule the scaffold's config names. The hooks use Alembic's `module` runner, so `simple_module_db` and the host template now require `alembic>=1.16.3`. An existing app can copy the `[post_write_hooks]` block from a new app's `host/alembic.ini` (and raise its own `alembic` requirement to `>=1.16.3`), drop the unused ignore comments and pin ty the same way (#896).
- `make doctor` (and `smpy doctor`) now runs the migration checks it documents. It reads `alembic_version` and the table list from `SM_DATABASE_URL` and the heads from the project's `alembic.ini`: `SM010` is an error when the database lacks any head and a warning when it was never migrated or could not be read, and `SM011` reports a module table that a database at every head does not have. A development boot reports `SM011` too once its database is at head (or once the setup wizard has migrated it), and the Doctor screen keeps it on re-run. Doctor now resolves a relative SQLite `SM_DATABASE_URL` against the project root as the app does, so run from `host/` it reads the app's database for `SM033` too. Before, neither code was ever reported. **Behaviour change:** `make doctor` now exits 1 against a dev database that is behind head; run `make migrate` (#895).
- `background_tasks`: `SM_BG_TASKS_BROKER_URL` and `SM_BG_TASKS_RESULT_BACKEND` are reported as deprecated only when they hold a Redis URL (`redis`, `rediss`), the only values `SM_REDIS_URL` can replace. A production host that runs tasks in-process with `memory://` + `cache+memory://`, or uses an AMQP, SQS or `rpc://` transport, no longer logs a warning on every boot that it cannot act on, and the production localhost-broker error no longer calls those vars deprecated for non-Redis values (#868).
- `SecurityHeadersMiddleware` no longer overwrites a `Content-Security-Policy` that a response sets. The app policy is sent beside it as a second header and browsers enforce both, so a page can tighten the policy (for example `frame-ancestors 'none'` on a public page) but never relax it. Before, only a policy containing `sandbox` survived and any other was dropped silently. **Behaviour change:** a response that set a looser policy, relying on it being replaced, now has both policies enforced: remove that header, or make it a policy the page can live with alongside the app policy (#870).
Expand Down
2 changes: 2 additions & 0 deletions docs/database/migrations.md
Original file line number Diff line number Diff line change
Expand Up @@ -130,6 +130,8 @@ make migration msg="add orders tables"

This runs `alembic -c host/alembic.ini revision --autogenerate` from the repo root. The resulting file lands in `host/migrations/versions/XXXX_add_orders_tables.py`.

A `post_write_hooks` entry in `host/alembic.ini` (this repo's and every scaffolded app's) runs the project's ruff on each new revision: `ruff check --select I --fix`, then `ruff format`. A fresh revision therefore passes `make lint`. Only the import rules are fixed, so an empty handwritten revision keeps its `sa` and `op` imports. Ruff must be installed in the venv Alembic runs from: every scaffold puts it in the default `dev` dependency group (the workspace root's, or the host's for `smpy create-host`). Without it Alembic writes the file and then fails with `Could not find module ruff`; run `uv add --dev ruff`. An app scaffolded before 0.0.45 can copy the `[post_write_hooks]` block from a new `smpy new` app's `host/alembic.ini`, provided its Alembic is 1.16.3 or newer, the first release with the `module` hook runner (#896).

**Always open and read the generated file** before committing. Autogenerate is good but not perfect:

- It detects column additions, drops, type changes, index/constraint changes, new tables.
Expand Down
25 changes: 25 additions & 0 deletions docs/superpowers/plans/2026-11-10-scaffold-lint-clean.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
# Plan: a fresh app passes its own `make lint` (#896)

Design: `docs/superpowers/specs/2026-11-10-scaffold-lint-clean-design.md`.

1. **ty pin** (independent). Test first: `TY_REQUIREMENT` admits the ty version in
`uv.lock`, and both the workspace root template and `_APP_PY_DEV_DEPS` use it. Add
`TY_REQUIREMENT` to `scaffolding.py`, a `{{TY_REQUIREMENT}}` placeholder in
`templates/workspace/pyproject.toml.tpl`, and use it in `_app_project_manifests.py`.
2. **ty ignore comment** (independent). Test first: `create_module` renders no ignore
comment by default and `# ty: ignore[invalid-assignment]` with `framework_repo=True`.
Add the `{{MODEL_CONFIG_TY_IGNORE}}` placeholder in `contracts/schemas.py.tpl`.
3. **Sorted constants import** (independent). Test first: `create_module("bookmarks")`
and `create_module("zebra")` produce a `module.py` that passes `ruff check --select I`.
Add the `{{CONSTANTS_IMPORTS}}` placeholder in `module.py.tpl`, rendered by
`sorted(..., key=str.lower)`.
4. **Alembic write hook**. Test first: a plain `alembic revision` in a `--no-install`
scaffold passes `ruff format --check` and `ruff check --select I,W`. Add
`[post_write_hooks]` to `templates/host/alembic.ini` and `host/alembic.ini`.
5. **End-to-end and docs**.
- Extend the "passes its own ruff" test with `create-module bookmarks`/`zebra`.
- Update `docs/database/migrations.md` and add a CHANGELOG Fixed entry.
- Real `smpy new` + `create-module` + `make migration` + `make lint`.
- `make new-module` sanity check.

Done check for every task: its targeted tests pass, `ruff`, and `ty` pass.
79 changes: 79 additions & 0 deletions docs/superpowers/specs/2026-11-10-scaffold-lint-clean-design.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
# A fresh app passes its own `make lint` (#896)

## Goal and scope

`smpy new bookshelf -y && make lint` must pass with no edits, and so must the app after
`smpy create-module <any name>` and after `make migration`. Reproduced on `main` with the
local CLI:

| Gate | Failure | Cause |
|---|---|---|
| `ruff format --check` | the initial-schema revision would be reformatted | Alembic writes single-quoted, unwrapped code |
| `ruff check` | 33 × E501, W291, I001 in that revision | same, plus `Revises: ` trailing space and the `${imports}` line in `script.py.mako` |
| `ruff check` after `create-module bookmarks` | I001 in `module.py` | the template lists `PERM_GROUP` before `PERM_<PKG>_*`, which is sorted only when the package name sorts after `group` (`hello` does, `bookmarks` does not) |
| `ty check` (any ty 0.0.90–0.0.100) | `unused-ignore-comment` on `model_config = ConfigDict(...) # ty: ignore[invalid-assignment]` | the app's ty config turns `invalid-assignment` off, so the ignore never applies; the framework repo turns it on, which is why the template has it |
| `ty check` with ty 0.0.100 | `renamed-rule`: `unresolved-attribute` is now `missing-attribute` | the scaffold pins `ty>=0.0.29`, so a new app gets the newest ty |

Out of scope: #872 (`path_separator` in `alembic.ini`), the Vite warnings (#900), the
layout (#897), and bumping the framework repo's own ty.

## Approach

1. **Revisions: an Alembic `post_write_hooks` entry**, in the scaffold's `host/alembic.ini`
and in this repo's `host/alembic.ini`. It runs `python -m ruff check --select I --fix`
and then `python -m ruff format` on each new revision. Ruff picks up the project's own
config from the file's location. Only the isort rules get `--fix`, so the hook never
deletes the unused `sa`/`op` imports in an empty handwritten revision, which the
author is about to fill in.
- Rejected: excluding `host/migrations/versions/` from ruff. Every revision would pass,
but hand-edited revisions would no longer be linted, and this repo lints its own.
2. **`module.py` constants import: sorted when rendered.** A new `{{CONSTANTS_IMPORTS}}`
placeholder renders the names sorted the way ruff's isort sorts them (case-insensitive),
so `PERM_GROUP` lands in the right place for every package name.
- Rejected: renaming `PERM_GROUP`. Every module in this repo uses that name.
3. **ty:**
- The ignore comment moves behind a `{{MODEL_CONFIG_TY_IGNORE}}` placeholder: empty for
apps, ` # ty: ignore[invalid-assignment]` for `framework_repo=True`.
- The scaffold pins ty to the tested range `ty>=0.0.90,<0.0.100`, from one
`TY_REQUIREMENT` constant used by both the workspace root template and
`_app_project_manifests`.
- Rejected: renaming the rule to `missing-attribute`. Every ty before 0.0.100 would
then warn about an unknown rule, the framework repo included (it is on 0.0.90).

## Decisions made

- The upper bound is the version that renamed the rule. 0.0.90, 0.0.97 and 0.0.99 were
run against a fresh app and are clean once the ignore comment is gone. A test asserts
that the ty in this repo's `uv.lock` satisfies `TY_REQUIREMENT`. A Dependabot bump past
the range then fails that test, so the scaffold gets updated in the same PR.
- The hook uses Alembic's `module` runner (`sys.executable -m ruff`), so it runs the
project venv's ruff. Both scaffold layouts have ruff in the default dev group. Without
ruff, Alembic raises `Could not find module ruff` after the file is written; that is
acceptable for a dev-only command.
- Existing apps are left alone. The CHANGELOG entry gives the `[post_write_hooks]`
block to paste into their `host/alembic.ini`.
- `script.py.mako` itself is unchanged; the hook normalizes its output.

## Testing

- `test_cli_new_scaffold_layout.py`: the existing "passes its own ruff" test also runs
`create-module` for `bookmarks` (sorts before `group`) and `zebra` (after) before
running `ruff format --check` and `ruff check`.
- A plain `alembic revision` against a `--no-install` scaffold, run with the test
interpreter, which has ruff: the revision passes `ruff format --check` and
`ruff check --select I,W`.
- Unit tests:
- the placeholder renders with and without `framework_repo`;
- `TY_REQUIREMENT` admits the version in `uv.lock`;
- both manifests use it.
- Real check in /vf: `smpy new` with install from the worktree CLI, then
`create-module bookmarks`, `make migration`, and `make lint` (ruff and ty) pass.
`make new-module` in this repo still passes `make lint`, and this repo's
`make migration` output passes ruff.

## Risks

- `smpy new` bootstraps the first revision with `uv run --project host alembic`. If that
environment lacks ruff, the hook raises after the revision is written, and the
bootstrap fails. The real `smpy new` check covers this. The fallback is to bootstrap
without hooks and format explicitly.
3 changes: 2 additions & 1 deletion framework/cli/simple_module_cli/_app_project_manifests.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@

from collections.abc import Sequence

from simple_module_cli._lint_tooling import TY_REQUIREMENT
from simple_module_cli.scaffolding import resolve_framework_version

_FRAMEWORK_VERSION = resolve_framework_version()
Expand All @@ -22,7 +23,7 @@
# Floor: unpinned, uv backtracks playwright to 1.18 (GH #445).
"playwright>=1.45",
"ruff>=0.8",
"ty>=0.0.29",
TY_REQUIREMENT,
]

_APP_NPM_DEPS = {
Expand Down
37 changes: 37 additions & 0 deletions framework/cli/simple_module_cli/_lint_tooling.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
"""Values that keep a scaffolded app passing its own ``make lint`` (GH #896)."""

from __future__ import annotations

# ty is pre-1.0 and renames rules between releases (0.0.100 renamed
# ``unresolved-attribute``, which the scaffold's ``[tool.ty.rules]`` names), so
# new apps get the range this repo is tested on, not the newest ty.
# ``test_scaffold_lint_tooling.py`` fails once ``uv.lock`` moves past it.
TY_REQUIREMENT = "ty>=0.0.90,<0.0.100"

# Apps turn ty's ``invalid-assignment`` off, so this ignore would be reported as
# unused there; only a module inside the framework repo, which checks the rule,
# gets it.
MODEL_CONFIG_TY_IGNORE = " # ty: ignore[invalid-assignment]"

_MODULE_CONSTANTS = (
"API_PREFIX",
"LOCALE_NAMESPACE",
"MENU_ICON",
"MENU_LABEL",
"MENU_LABEL_KEY",
"MENU_ORDER",
"MENU_URL",
"PERM_GROUP",
"VIEW_PREFIX",
)
_MODULE_PERMISSIONS = ("CREATE", "DELETE", "EDIT", "VIEW")


def module_constants_import(package_upper: str) -> str:
"""The ``module.py`` constants import list, in the order ruff's isort wants.

Where ``PERM_GROUP`` falls among ``PERM_<PACKAGE>_*`` depends on the package
name, so a fixed template order is unsorted for half of all names.
"""
names = [*_MODULE_CONSTANTS, *(f"PERM_{package_upper}_{p}" for p in _MODULE_PERMISSIONS)]
return "\n".join(f" {name}," for name in sorted(names, key=str.lower))
9 changes: 9 additions & 0 deletions framework/cli/simple_module_cli/scaffolding.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,12 @@
from pathlib import Path
from typing import TypeGuard

from simple_module_cli._lint_tooling import (
MODEL_CONFIG_TY_IGNORE,
TY_REQUIREMENT,
module_constants_import,
)

# The template engine lives in _templating.py; the private names are imported
# here because long-standing tests monkeypatch them on this module.
from simple_module_cli._templating import (
Expand Down Expand Up @@ -137,6 +143,7 @@ def create_workspace(
"{{HOST_NAME}}": validate_scaffold_name(name),
"{{HOST_PYPI_NAME}}": to_kebab_case(name),
"{{FRAMEWORK_VERSION}}": framework_version,
"{{TY_REQUIREMENT}}": TY_REQUIREMENT,
},
preserve_existing=preserve_existing,
)
Expand Down Expand Up @@ -239,6 +246,8 @@ def create_module(
"{{SINGULAR}}": singular,
"{{SINGULAR_UPPER}}": singular.upper(),
"{{SINGULAR_CLASS}}": to_pascal_case(singular),
"{{CONSTANTS_IMPORTS}}": module_constants_import(package_name.upper()),
"{{MODEL_CONFIG_TY_IGNORE}}": MODEL_CONFIG_TY_IGNORE if framework_repo else "",
# npm-side pin for @simple-module-py/* devDependencies; "*" when the
# caller skips pinning (mirrors _should_pin_framework_version).
"{{FRAMEWORK_VERSION}}": (
Expand Down
12 changes: 12 additions & 0 deletions framework/cli/simple_module_cli/templates/host/alembic.ini
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,18 @@
script_location = %(here)s/migrations
sqlalchemy.url =

# Run the project's ruff on every new revision, so `make migration` output
# passes `make lint` (GH #896). Only the import rules are fixed: the unused
# `sa`/`op` imports of an empty revision stay for its author to use.
[post_write_hooks]
hooks = ruff_imports, ruff_format
ruff_imports.type = module
ruff_imports.module = ruff
ruff_imports.options = check --select I --fix --quiet REVISION_SCRIPT_FILENAME
ruff_format.type = module
ruff_format.module = ruff
ruff_format.options = format --quiet REVISION_SCRIPT_FILENAME

[loggers]
keys = root,sqlalchemy,alembic

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,11 +8,17 @@ dependencies = [
"simple_module_db>=1.0,<2.0",
"simple_module_hosting>=1.0,<2.0",
"simple_module_settings>=1.0,<2.0",
"alembic>=1.13",
# 1.16.3 added the `module` runner host/alembic.ini's post-write hooks use.
"alembic>=1.16.3",
"uvicorn[standard]>=0.34",
{{MODULE_DEPS}}
]

# `host/alembic.ini` runs ruff on every new revision. `smpy new` replaces this
# group with the app's full dev tooling; a standalone `smpy create-host` keeps it.
[dependency-groups]
dev = ["ruff>=0.8"]

# Host is an application, not a distributable package.
[tool.uv]
package = false
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ from {{PACKAGE_NAME}}.core.constants import DESCRIPTION_MAX_LENGTH, NAME_MAX_LEN
class {{SINGULAR_CLASS}}Out(SQLModel):
"""{{SINGULAR_CLASS}} data returned by the API."""

model_config = ConfigDict(from_attributes=True) # ty: ignore[invalid-assignment]
model_config = ConfigDict(from_attributes=True){{MODEL_CONFIG_TY_IGNORE}}

id: int
name: str
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -12,19 +12,7 @@ from simple_module_core.module_settings import register_module_settings
from simple_module_core.permissions import PermissionRegistry

from {{PACKAGE_NAME}}.core.constants import (
API_PREFIX,
LOCALE_NAMESPACE,
MENU_ICON,
MENU_LABEL,
MENU_LABEL_KEY,
MENU_ORDER,
MENU_URL,
PERM_GROUP,
PERM_{{PACKAGE_NAME_UPPER}}_CREATE,
PERM_{{PACKAGE_NAME_UPPER}}_DELETE,
PERM_{{PACKAGE_NAME_UPPER}}_EDIT,
PERM_{{PACKAGE_NAME_UPPER}}_VIEW,
VIEW_PREFIX,
{{CONSTANTS_IMPORTS}}
)


Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@ dev = [
# pin, which pytest-playwright cannot load (GH #445).
"playwright>=1.45",
"ruff>=0.8",
"ty>=0.0.29",
"{{TY_REQUIREMENT}}",
# Provides the build_test_app / fake_event_bus fixtures (pytest11 plugin).
"simple_module_test=={{FRAMEWORK_VERSION}}",
]
Expand Down
Loading
Loading