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
2 changes: 1 addition & 1 deletion .claude/commands/ci.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ Execute these steps in order, stopping on first failure. Report results as a sum

## Step 1: Lint & Format Check

Run `npm run check` from the project root. This runs biome lint, format check, page validation, and i18n validation.
Run `npm run check` from the project root. This runs biome lint + format check, page validation (`validate-pages`), i18n validation (`validate:i18n`), framework-scope validation (`validate:framework-scope`), and TypeScript type checking (`typecheck`).

## Step 2: Frontend Build

Expand Down
34 changes: 20 additions & 14 deletions .claude/commands/debug-module.md
Original file line number Diff line number Diff line change
Expand Up @@ -43,10 +43,10 @@ Read `modules/{Name}/src/SimpleModule.{Name}/SimpleModule.{Name}.csproj`.

Verify:
- The file exists.
- `Sdk="Microsoft.NET.Sdk"` (must NOT be `Microsoft.NET.Sdk.Razor` or `Microsoft.NET.Sdk.Web`).
- `Sdk="Microsoft.NET.Sdk.StaticWebAssets"` (the implementation project ships the module's static frontend bundle; must NOT be `Microsoft.NET.Sdk.Razor` or `Microsoft.NET.Sdk.Web`).
- Contains `<FrameworkReference Include="Microsoft.AspNetCore.App" />`.

Fix if failing: Set `Sdk="Microsoft.NET.Sdk"` if it is wrong. If `<FrameworkReference Include="Microsoft.AspNetCore.App" />` is missing, add it inside an `<ItemGroup>`.
Fix if failing: Set `Sdk="Microsoft.NET.Sdk.StaticWebAssets"` if it is wrong. If `<FrameworkReference Include="Microsoft.AspNetCore.App" />` is missing, add it inside an `<ItemGroup>`.

---

Expand Down Expand Up @@ -80,18 +80,24 @@ Run:
dotnet build --no-incremental 2>&1 | grep -E "SM0|error" | head -50
```

Surface any SM00xx source generator diagnostic codes. Their meanings:
Surface any SM00xx source generator diagnostic codes. Codes currently span **SM0001–SM0061** (non-contiguous); `docs/CONSTITUTION.md` is the authoritative catalog. The most commonly hit:

| Code | Meaning |
|------|---------|
| SM0001 | Module class must be public and non-sealed |
| SM0010 | IEndpoint implementation must be internal sealed |
| SM0020 | IViewEndpoint implementation must be internal sealed |
| SM0030 | [Dto] types must live in a Contracts assembly |
| SM0040 | No impl→impl project references allowed between modules |
| SM0044 | Inertia.Render component name not found in Pages/index.ts |

Fix each reported code using the guidance above before continuing.
| SM0011 | Direct module→module implementation reference (reference the `.Contracts` project instead) |
| SM0014 | Referenced Contracts assembly has no public interfaces |
| SM0025 | No implementation found for a contract interface (create the `{Name}ContractsService`) |
| SM0032 | Permission class is not `sealed` |
| SM0040 | Duplicate module name across modules |
| SM0041 | View page name not prefixed with the module name |
| SM0042 | Module has view endpoints but no `ViewPrefix` on `[Module]` |
| SM0043 | Module overrides no `IModule` method |
| SM0049 | More than one endpoint class in a single file |
| SM0054 | Endpoint missing `public const string Route` (Info) |
| SM0056 / SM0057 | `[FormRequest]` class not sealed / not extending `FormRequest<TSelf>` |
| SM0058–SM0061 | Policy class rules (resource not a `[Dto]`, not public, foreign module, or generic) |

For any code not listed here, look it up in `docs/CONSTITUTION.md`. Fix each reported code before continuing.

---

Expand All @@ -102,13 +108,13 @@ Run:
npm run validate-pages
```

If it exits with an error, identify each missing entry and show the exact line to add to `modules/{Name}/src/SimpleModule.{Name}/Pages/index.ts`:
If it exits with an error, identify each missing entry and show the exact line to add to `modules/{Name}/src/SimpleModule.{Name}/Pages/index.ts` (pages are co-located in `Pages/`, so the import path is relative to that folder):

```typescript
'{Name}/{ViewName}': () => import('../Views/{ViewName}'),
'{Name}/{ViewName}': () => import('./{ViewName}'),
```

Derive `{ViewName}` from the component name reported missing by `npm run validate-pages` (e.g., if it reports `Products/Browse`, the ViewName is `Browse`).
Derive `{ViewName}` from the component name reported missing by `npm run validate-pages` (e.g., if it reports `AuditLogs/Browse`, the ViewName is `Browse`).

Add the missing entries to the `pages` record in `Pages/index.ts`, then re-run `npm run validate-pages` to confirm it passes.

Expand Down
34 changes: 21 additions & 13 deletions .claude/commands/review-module.md
Original file line number Diff line number Diff line change
Expand Up @@ -34,11 +34,15 @@ Check each match:

3. **RequirePermission** — Endpoints must use `.RequirePermission(...)` not bare `.RequireAuthorization()`. Flag any `.RequireAuthorization()` call without a permission argument.

4. **Route const** — Every endpoint class must declare `public const string Route = {Name}Constants.Routes.X;` and pass `Route` to its `MapXxx` call (enforced by **SM0054**). Flag any endpoint that hardcodes the route literal in the `MapXxx` call instead of declaring/using the const.

5. **FormRequest binding** — Form posts should bind a `[FormRequest]` class from the module's `FormRequests/` folder, declared `sealed partial` and extending `FormRequest<TSelf>` (**SM0056** / **SM0057**), bound directly as a handler parameter. Flag `[FormRequest]` classes that are not sealed or do not extend `FormRequest<TSelf>`.

---

## Area 3 — Naming conventions

Grep for class definitions (`class `) in `modules/{Name}/src/SimpleModule.{Name}/Endpoints/` and `modules/{Name}/src/SimpleModule.{Name}/Views/`.
Grep for class definitions (`class `) in `modules/{Name}/src/SimpleModule.{Name}/Endpoints/` and `modules/{Name}/src/SimpleModule.{Name}/Pages/`.

For each source file found:

Expand All @@ -60,34 +64,34 @@ Compare the two sets: every component key from an `Inertia.Render` call must app

Then run `npm run validate-pages` for authoritative output and report any additional mismatches it finds.

Violation fix: Add a matching entry to `Pages/index.ts`:
Violation fix: Add a matching entry to `Pages/index.ts` (pages are co-located in `Pages/`):
```typescript
"{Name}/{ViewName}": () => import("../Views/{ViewName}"),
"{Name}/{ViewName}": () => import("./{ViewName}"),
```

---

## Area 5 — Events: Proper definition and registration
## Area 5 — Events: Wolverine definition and handler conventions

Grep for `: IEvent` in `modules/{Name}/src/SimpleModule.{Name}.Contracts/`.
Events use [Wolverine](https://wolverinefx.net/) in-process messaging — there is **no** `IEventHandler<>` interface and **no** DI registration for handlers.

For each event type found:
Grep for `: DomainEvent` in `modules/{Name}/src/SimpleModule.{Name}.Contracts/`.

1. **Record type** — events must be `record` types, not `class`. Flag any `class` that implements `IEvent`.
For each event type found:

Grep for `IEventHandler<` in `modules/{Name}/src/SimpleModule.{Name}/`.
1. **Record + Contracts** — events must be `record` types inheriting `DomainEvent` (from `SimpleModule.Core.Events`) and live in the `.Contracts` assembly. Flag any `class`-based event, any event missing the `DomainEvent` base, or any event defined in the implementation assembly.

For each handler found, check that a corresponding `services.AddScoped<IEventHandler<TEvent>, THandler>()` call exists in `ConfigureServices` (or wherever DI is configured in the module class). Flag any handler with no registration.
Handlers are auto-discovered by Wolverine convention — class name ending in `Handler`/`Consumer`, method named `Handle`/`HandleAsync`/`Consume`/`ConsumeAsync`, first parameter is the event, remaining parameters resolved from DI.

Violation fix for missing registration: Add `services.AddScoped<IEventHandler<{EventType}>, {HandlerType}>();` in `ConfigureServices`.
2. **Handler convention** — flag any would-be handler whose class/method names don't match the convention (and isn't opted in with `[WolverineHandler]`), since it will silently never fire. Do **not** flag a missing `services.AddScoped(...)` registration — handlers need none.

---

## Area 6 — Tests: Coverage by endpoint file

List all files matching `*Endpoint.cs` in:
- `modules/{Name}/src/SimpleModule.{Name}/Endpoints/`
- `modules/{Name}/src/SimpleModule.{Name}/Views/`
- `modules/{Name}/src/SimpleModule.{Name}/Pages/`

List all test files in `modules/{Name}/tests/SimpleModule.{Name}.Tests/`.

Expand All @@ -97,7 +101,7 @@ Note: integration tests using `SimpleModuleWebApplicationFactory` with `CreateAu

---

## Area 7 — Permissions: Sealed class with const strings
## Area 7 — Permissions & policies

Grep for `IModulePermissions` in `modules/{Name}/src/` (covers both the implementation assembly and the `.Contracts` assembly).

Expand All @@ -110,9 +114,13 @@ If a permissions class implementing `IModulePermissions` was found above, grep f

3. **ConfigurePermissions registration** — This check is conditional:
- If a permissions class implementing `IModulePermissions` exists **and** `builder.AddPermissions<{Name}Permissions>()` is absent from `{Name}Module.cs`: flag it as a violation.
- If no permissions class exists and no `AddPermissions` call exists: mark Area 7 as N/A.
- If no permissions class exists and no `AddPermissions` call exists: mark this check as N/A.
- If both exist and the type matches: OK.

**Policies (instance-level authorization).** Grep for `: IPolicy<` in `modules/{Name}/src/SimpleModule.{Name}/`. For each policy class found:

4. **Policy rules** — the class must be `public sealed`, non-generic, and target a resource type that is a contracts `[Dto]` owned by **this** module (enforced by **SM0058**–**SM0061**). Flag any policy that is non-public, generic, targets a non-`[Dto]` or foreign-module resource. Policies are auto-discovered — do **not** flag a missing DI registration. If the module has no `IPolicy<>` classes, mark this check as N/A.

---

## Final Report
Expand Down
66 changes: 59 additions & 7 deletions .claude/skills/minimal-api/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,8 @@ description: >
CrudEndpoints helpers, Inertia.Render, or form handling. Triggers on: "add endpoint",
"create endpoint", "new endpoint", "API endpoint", "view endpoint", "MapGet", "MapPost",
"MapPut", "MapDelete", "Inertia.Render", route handling, parameter binding questions,
or any work touching files in Endpoints/ or Pages/ directories.
"FormRequest", "Route const", "IAuthorizer", "policy", "RequirePermission",
or any work touching files in Endpoints/, Pages/, or FormRequests/ directories.
---

# SimpleModule Minimal API Endpoints
Expand All @@ -23,10 +24,26 @@ Both implement `void Map(IEndpointRouteBuilder app)`. The source generator disco

## Endpoint File Structure

One endpoint per file. Class name = `{Action}Endpoint`. Place in:
One endpoint per file (enforced by **SM0049**). Class name = `{Action}Endpoint`. Place in:
- `Endpoints/{Feature}/` for `IEndpoint`
- `Pages/` for `IViewEndpoint` (co-located with its `.tsx` component; optionally grouped in feature subfolders)

### Route const (required)

Every endpoint declares a `public const string Route` and passes it to its `MapXxx` call — enforced by **SM0054**. Route values live in a `Routes` nested class on the module constants:

```csharp
public class GetAllProductsEndpoint : IEndpoint
{
public const string Route = ProductsConstants.Routes.GetAll;

public void Map(IEndpointRouteBuilder app) =>
app.MapGet(Route, (IProductContracts contracts) =>
CrudEndpoints.GetAll(contracts.GetAllAsync))
.RequirePermission(ProductsPermissions.View);
}
```

## Parameter Binding Rules

### Implicit binding (no attribute needed)
Expand Down Expand Up @@ -76,6 +93,10 @@ CrudEndpoints.Update(() => contracts.UpdateAsync(id, request))

// DELETE
CrudEndpoints.Delete(() => contracts.DeleteAsync(id))

// Soft-delete-aware modules also have (204 on success, 404 if no rows affected):
CrudEndpoints.Restore(() => contracts.RestoreAsync(id))
CrudEndpoints.ForceDelete(() => contracts.ForceDeleteAsync(id))
```

## View Endpoint Patterns
Expand All @@ -84,14 +105,37 @@ CrudEndpoints.Delete(() => contracts.DeleteAsync(id))
// Render page with props (props serialize to camelCase)
Inertia.Render("Module/PageName", new { items = await svc.GetAllAsync() })

// Form: GET renders form, POST processes submission
app.MapGet("/create", () => Inertia.Render("Module/Create"));
app.MapPost("/", async ([FromForm] string name, [FromForm] decimal price, ISvc svc) => {
await svc.CreateAsync(new CreateRequest { Name = name, Price = price });
// Form: GET renders form, POST binds + validates a [FormRequest] directly
app.MapGet(Route, () => Inertia.Render("Module/Create"));
app.MapPost("/things", async (CreateThingFormRequest form, ISvc svc) => {
await svc.CreateAsync(new CreateRequest { Name = form.Name, Price = form.Price });
return Results.Redirect("/module/manage");
}).DisableAntiforgery();
});
```

### Form binding with `FormRequest<TSelf>`

Form posts bind a `[FormRequest]` class — `sealed partial`, extending `FormRequest<TSelf>` (**SM0056** / **SM0057**), in the module's `FormRequests/` folder. The framework hydrates it from the form body, runs `Prepare()`, then validates via `ConfigureRules` before the handler runs (no manual `IValidator`, no `.DisableAntiforgery()`):

```csharp
using FluentValidation;
using SimpleModule.Core.FormRequests;

[FormRequest]
public sealed partial class CreateThingFormRequest : FormRequest<CreateThingFormRequest>
{
public string Name { get; set; } = "";
public decimal Price { get; set; }

public override void Prepare() => Name = Name.Trim();

protected override void ConfigureRules(RuleConfigurator<CreateThingFormRequest> rules) =>
rules.RuleFor(x => x.Name).NotEmpty().WithMessage("Name is required.");
}
```

For one-off forms, a private `sealed record` of `[FromForm]` fields bound with `[AsParameters]` (plus manual `IValidator`) still works — see [references/patterns.md](references/patterns.md).

**Critical**: Every `IViewEndpoint` with `Inertia.Render("Module/Page", ...)` MUST have a matching entry in `Pages/index.ts`. Run `npm run validate-pages` to verify.

**Note**: CA1812 ("internal class never instantiated") is suppressed via `.editorconfig` for `Endpoints/` and `Pages/` (`*Endpoint.cs`) directories. No `[SuppressMessage]` attributes needed on payload classes.
Expand All @@ -111,6 +155,14 @@ app.MapPost("/", async ([FromForm] string name, [FromForm] decimal price, ISvc s

Note: `.RequireAuthorization()` is already applied to the route group by the source generator. Endpoint-level auth narrows or overrides this.

For **per-resource** rules (ownership, tenancy, state) beyond the coarse permission gate, inject `IAuthorizer` and call it after loading the resource — it dispatches to the resource's `IPolicy<TResource>` (deny-wins; throws `ForbiddenException` / `NotFoundException` on deny):

```csharp
await authorizer.AuthorizeAsync(user, PolicyActions.Update, product);
```

See the `simplemodule` skill's "Policies" section (diagnostics SM0058–SM0061).

## Validation

Use [FluentValidation](https://docs.fluentvalidation.net/). Define a `sealed` validator extending `AbstractValidator<T>`, register it once per module with `services.AddValidatorsFromAssemblyContaining<ThisModule>()` in `ConfigureServices`, then inject `IValidator<TRequest>` into the endpoint handler:
Expand Down
Loading
Loading