100 Commits

Author SHA1 Message Date
e87e554a7c Merge branch 'worktree-fallow-integration-v1': fallow v1 — 5th gate (whole-codebase)
Some checks failed
CI / typecheck + lint + boundaries + test + build (push) Has been cancelled
CI / Playwright e2e (push) Has been cancelled
CI / Storybook smoke tests + visual regression (push) Has been cancelled
Sentry PII guard (R31) / pii-guard (push) Has been cancelled
2026-05-13 08:57:42 +02:00
072e08d855 docs(work): close fallow-integration-v1 epic 2026-05-13 08:55:50 +02:00
7ecb365e63 feat(sandcastle): implementer + reviewer prompts include fallow audit 2026-05-13 08:53:00 +02:00
d34b841256 docs(guides): conformance-quickref includes fallow row + audit usage 2026-05-13 08:52:37 +02:00
1655cd0879 docs: surface fallow as 5th conformance gate in CLAUDE.md 2026-05-13 08:52:09 +02:00
749938e48b ci: add fallow whole-codebase gate after pnpm conformance 2026-05-13 08:51:53 +02:00
7c7a65f7e3 feat: pnpm fallow + fallow:audit scripts, turbo task entry 2026-05-13 08:51:45 +02:00
e1d54a1f3e feat: install fallow + .fallowrc.json config 2026-05-13 08:48:08 +02:00
2722305398 docs(work): scaffold fallow-integration-v1 epic + stories 2026-05-13 08:47:30 +02:00
85fb050019 Merge branch 'worktree-visual-regression-v1': visual regression v1 infrastructure 2026-05-13 08:33:32 +02:00
10603bd250 docs(work): close visual-regression-v1 epic 2026-05-13 08:32:58 +02:00
9089d13c5f fix(storybook): add @playwright/test to devDependencies for turbo boundaries 2026-05-13 08:32:01 +02:00
f16c7b1b60 ci: add Storybook build + visual regression step 2026-05-13 08:27:04 +02:00
f761dbb9b1 feat(tests): Storybook visual regression harness (Playwright) 2026-05-13 08:26:35 +02:00
f43c314156 feat: install @playwright/test + playwright.config.ts 2026-05-13 08:25:54 +02:00
4fa108dd48 docs(work): scaffold visual-regression-v1 epic + stories 2026-05-13 08:24:55 +02:00
6921abcd11 Merge branch 'worktree-sandcastle-dispatch-v1': sandcastle dispatch v1 2026-05-13 08:22:33 +02:00
06c9fb1823 docs(work): close sandcastle-dispatch-v1 epic 2026-05-13 08:22:08 +02:00
d1b00f1cf5 feat(scripts): pnpm work dispatch — wire CLI to dispatch.mjs 2026-05-13 08:19:19 +02:00
da811eb461 feat(scripts): dispatch.mjs — planner + execute-mode skeleton 2026-05-13 08:18:58 +02:00
1e7bd68b17 feat: install @ai-hero/sandcastle + minimal Dockerfile 2026-05-13 08:17:32 +02:00
35664f67d5 docs(work): scaffold sandcastle-dispatch-v1 epic + stories 2026-05-13 08:16:09 +02:00
28ba9d04e8 Merge branch 'worktree-elicitation-prompts-v1': elicitation + dispatch prompts v1 2026-05-13 08:12:55 +02:00
1385219b48 docs(work): close elicitation-prompts-v1 epic 2026-05-13 08:12:28 +02:00
5bf636e0b3 feat(sandcastle): reviewer prompt template (verifies generator usage) 2026-05-13 08:11:41 +02:00
e441d0f477 feat(sandcastle): implementer prompt template (manifest-first + generators) 2026-05-13 08:11:24 +02:00
4ec804107b feat(sandcastle): decomposer prompt template (generator-first task lists) 2026-05-13 08:11:02 +02:00
988667fc47 feat(sandcastle): ADR elicitation prompt template 2026-05-13 08:10:40 +02:00
b28d7a6f71 feat(sandcastle): PRD elicitation prompt template 2026-05-13 08:10:25 +02:00
7fc4c23036 feat(sandcastle): scaffold .sandcastle/ + README + env example 2026-05-13 08:10:04 +02:00
7d08aff089 docs(work): scaffold elicitation-prompts-v1 epic + stories 2026-05-13 08:09:38 +02:00
4d5e349233 Merge branch 'worktree-dag-and-readiness-v1': DAG + readiness v1 2026-05-13 08:07:02 +02:00
e92610e7f1 docs(work): close dag-and-readiness-v1 epic 2026-05-13 08:06:21 +02:00
4cf979aaa5 feat(scripts): pnpm work ready + blocked subcommands, DAG-aware next 2026-05-13 08:05:19 +02:00
23fedac1a8 feat(scripts): state-builder reads depends-on + blocks from frontmatter 2026-05-13 08:04:38 +02:00
adabb3428d docs(work): scaffold dag-and-readiness-v1 epic + stories 2026-05-13 07:59:53 +02:00
886826d620 Merge branch 'worktree-pre-commit-hooks-v1': pre-commit hooks v1 — husky + state-sync guard 2026-05-13 07:57:38 +02:00
ca006494c5 docs(work): close pre-commit-hooks-v1 epic 2026-05-13 07:56:33 +02:00
a325794309 feat: pre-commit hook script 2026-05-13 07:54:22 +02:00
1ebffa68a6 feat(scripts): state-sync-guard for pre-commit safety net 2026-05-13 07:54:03 +02:00
56ed918b09 feat: install husky + lint-staged for pre-commit hooks 2026-05-13 07:52:49 +02:00
a2eeccfd9d docs(work): scaffold pre-commit-hooks-v1 epic + stories 2026-05-13 07:52:29 +02:00
ef64c015e0 Merge branch 'worktree-work-system-v1': work-system v1 MVP — state + pnpm work CLI 2026-05-13 07:48:07 +02:00
ee315352ff docs(work): close work-system-v1 epic 2026-05-13 07:47:33 +02:00
9858d49787 feat: wire pnpm work CLI + initial _state.json snapshot 2026-05-13 07:47:03 +02:00
be8e89baed feat(scripts): pnpm work CLI — rebuild-state, status, next 2026-05-13 07:46:51 +02:00
6b57d76dc2 feat(scripts): work state-builder — walks docs/work/ tree 2026-05-13 07:46:28 +02:00
eac711ecec docs(work): scaffold work-system-v1 epic + story 2026-05-13 07:44:54 +02:00
7a080efcb6 Merge branch 'worktree-frontend-conformance': frontend conformance rules v1 2026-05-13 07:42:29 +02:00
c97810015d docs(work): close frontend-conformance-v1 epic
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-13 07:42:09 +02:00
69d84a598d feat(core-eslint): wire frontend conformance rules into plugin + base
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-13 07:41:24 +02:00
159db9e542 feat(core-eslint): atomic-tier-import-direction rule
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-13 07:41:01 +02:00
f933ca74ff feat(core-eslint): component-must-have-test rule
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-13 07:40:40 +02:00
9ef545b714 feat(core-eslint): component-must-have-story rule
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-13 07:40:19 +02:00
89c72bd30a docs(work): scaffold frontend-conformance-v1 epic + story
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-13 07:39:50 +02:00
83faf07ab1 Merge branch 'worktree-conformance-hardening': conformance hardening v1 2026-05-13 07:37:02 +02:00
2399f950c2 docs(work): close conformance-hardening-v1 epic 2026-05-13 07:36:39 +02:00
f06f7dbadf feat(features): extend assertFeatureConformance to bind-dev-seed paths 2026-05-13 07:35:40 +02:00
171ed20527 refactor(core-eslint): readManifestSource delegates to AST parser 2026-05-13 07:34:00 +02:00
d83d97755e feat(core-eslint): parseManifestFully — AST-based full manifest extraction 2026-05-13 07:33:41 +02:00
e6d9600fbe docs(work): scaffold conformance-hardening-v1 epic + stories 2026-05-13 07:33:00 +02:00
7877c1645c Merge branch 'worktree-agent-workflow-docs': agent-workflow-docs v1 — surface conformance system 2026-05-13 07:29:50 +02:00
87da671432 docs(work): close story 01 + agent-workflow-docs-v1 epic 2026-05-13 07:29:06 +02:00
4966191c8b docs(guides): conformance-quickref single-page agent reference 2026-05-13 07:28:46 +02:00
798281dd14 docs(guides): tdd-workflow links conformance gates 2026-05-13 07:28:16 +02:00
efcb65e726 docs(guides): scaffolding-a-feature notes conformance-ready output 2026-05-13 07:28:02 +02:00
cc6f87e6e2 docs(guides): adding-a-feature reflects manifest-first ordering 2026-05-13 07:27:52 +02:00
0a2c570a69 docs: AGENTS.md notes per-feature manifest contract 2026-05-13 07:27:37 +02:00
3c810decb2 docs: CLAUDE.md surfaces conformance system + manifest-first ordering 2026-05-13 07:27:26 +02:00
5cc59e79db docs(work): scaffold agent-workflow-docs-v1 epic + story 01 2026-05-13 07:27:04 +02:00
be1938404a Merge branch 'worktree-conformance-milestone-vi': conformance milestone vi — feature migrations 2026-05-13 00:11:51 +02:00
9177d1f1db docs(work): close story 06 + conformance-system-v1 epic 2026-05-13 00:11:32 +02:00
79f96a94a1 feat(core-eslint): flip feature-must-have-manifest from warn to error 2026-05-13 00:10:48 +02:00
5fa7de48d8 feat(marketing-pages): conformance manifest + self-asserting bind-production 2026-05-13 00:10:29 +02:00
7953a44203 feat(navigation): conformance manifest + self-asserting bind-production 2026-05-13 00:10:03 +02:00
dc7aafb97f feat(media): conformance manifest + self-asserting bind-production 2026-05-13 00:09:36 +02:00
9bbc068498 feat(blog): conformance manifest + self-asserting bind-production 2026-05-13 00:09:08 +02:00
81a075a9b9 docs(work): story 06 — feature migrations 2026-05-13 00:08:37 +02:00
7d38ff3bb9 plan(conformance): milestone vi — feature migrations 2026-05-13 00:06:48 +02:00
c3da533e42 Merge branch 'worktree-conformance-milestone-v': conformance milestone v — generator updates 2026-05-13 00:05:23 +02:00
5bf9a140ed docs(work): close story 05 — generator updates 2026-05-13 00:04:56 +02:00
300143e7e1 feat(generators): emit feature.manifest.ts + self-asserting bind-production 2026-05-13 00:02:35 +02:00
8da21c0405 docs(work): story 05 — generator updates 2026-05-13 00:02:05 +02:00
19611fb92c plan(conformance): milestone v — generator updates 2026-05-13 00:00:32 +02:00
a90bdef219 Merge branch 'worktree-conformance-milestone-iv': conformance milestone iv — CI drift gate 2026-05-12 23:59:13 +02:00
f374d8b874 docs(work): close story 04 — CI drift gate 2026-05-12 23:58:51 +02:00
132ebc689f ci: add conformance step after lint 2026-05-12 23:58:20 +02:00
dfd6e1c3cc feat: wire pnpm conformance script + turbo task 2026-05-12 23:58:10 +02:00
24769eb442 feat(scripts): conformance drift gate + tests 2026-05-12 23:57:52 +02:00
a0b2ecee2b docs(work): story 04 — CI drift gate 2026-05-12 23:56:34 +02:00
b5771fc4ba plan(conformance): milestone iv — CI drift gate 2026-05-12 23:55:14 +02:00
b0563e4712 Merge branch 'worktree-conformance-milestone-iii-b': conformance milestone iii.b — AST-aware ESLint rules 2026-05-12 23:53:30 +02:00
a07e62befd docs(work): close story 03.b — AST-aware ESLint rules
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-12 23:53:13 +02:00
4d7b25fd15 feat(core-eslint): wire AST-aware conformance rules into plugin + base
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-12 23:52:31 +02:00
15e90820d2 feat(core-eslint): no-undeclared-audit rule
AST-aware ESLint rule that catches auditLog.record({ type: "X" }) calls
in use-case files where X is not declared in the matching
manifest.useCases[name].audits array.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-12 23:51:42 +02:00
3e2b096c3c feat(core-eslint): no-undeclared-event-publish rule
AST-aware ESLint rule that catches bus.publish("X") calls in use-case
files where X is not declared in the matching manifest.useCases[name].publishes array.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-12 23:51:06 +02:00
21a6822d55 feat(core-eslint): use-case name helper (file slug → manifest key) 2026-05-12 23:49:32 +02:00
7cfb78b99e feat(core-eslint): manifest AST parser for per-use-case attributes 2026-05-12 23:49:12 +02:00
bfb2324dd9 docs(work): story 03.b — AST-aware ESLint rules 2026-05-12 23:48:35 +02:00
fb23980d85 plan(conformance): milestone iii.b — AST-aware ESLint rules 2026-05-12 23:47:18 +02:00
117 changed files with 17927 additions and 6003 deletions

62
.fallowrc.json Normal file
View File

@@ -0,0 +1,62 @@
{
"$schema": "https://raw.githubusercontent.com/fallow-rs/fallow/main/schema.json",
"ignorePatterns": [
"**/node_modules/**",
"**/dist/**",
"**/.next/**",
"**/.turbo/**",
"**/storybook-static/**",
"**/__snapshots__/**",
"**/turbo/generators/templates/**",
"**/*.generated.ts",
"**/*.d.ts"
],
"dynamicallyLoaded": [
"packages/**/__factories__/**",
"packages/**/__seeds__/**",
"apps/**/instrumentation.ts",
"apps/**/instrumentation-client.ts",
"apps/storybook/test-runner.config.ts",
"scripts/**/*.mjs"
],
"publicPackages": ["@repo/core-*"],
"ignoreDependencies": [
"@payloadcms/ui",
"sass",
"sharp",
"@tanstack/react-query",
"@trpc/server",
"superjson",
"@repo/blog",
"@repo/core-api",
"@repo/marketing-pages",
"@repo/navigation",
"@repo/core-testing",
"http-server",
"wait-on",
"@opentelemetry/api-logs",
"@typescript-eslint/eslint-plugin",
"@testing-library/user-event",
"zod",
"@eslint/js",
"@opentelemetry/sdk-node",
"@sentry/opentelemetry"
],
"ignoreExportsUsedInFile": true,
"rules": {
"unused-files": "warn",
"unused-exports": "warn",
"unused-types": "off",
"unused-class-members": "warn",
"unused-dependencies": "warn",
"unused-dev-dependencies": "warn",
"unlisted-dependencies": "warn",
"circular-dependencies": "error",
"duplicate-code": "warn"
},
"health": {
"maxCyclomatic": 25,
"maxCognitive": 30,
"maxCrap": 400
}
}

View File

@@ -49,6 +49,9 @@ jobs:
- run: pnpm install --frozen-lockfile
- run: pnpm typecheck
- run: pnpm lint
- run: pnpm conformance
- name: Fallow whole-codebase analysis
run: pnpm fallow --format annotations
- run: pnpm turbo boundaries
- name: Test with coverage
env:
@@ -60,7 +63,7 @@ jobs:
if: always()
with:
name: coverage
path: '**/coverage/lcov.info'
path: "**/coverage/lcov.info"
retention-days: 7
e2e:
@@ -99,7 +102,7 @@ jobs:
run: pnpm test:e2e
storybook:
name: Storybook smoke tests
name: Storybook smoke tests + visual regression
needs: validate
runs-on: ubuntu-latest
steps:
@@ -113,5 +116,10 @@ jobs:
cache: pnpm
- run: pnpm install --frozen-lockfile
- run: pnpm exec playwright install --with-deps chromium
- run: pnpm build-storybook --filter @repo/storybook
- name: Build Storybook
run: pnpm --filter @repo/storybook build:storybook
- run: pnpm test:stories
- name: Install Playwright browsers
run: pnpm exec playwright install chromium --with-deps
- name: Visual regression
run: pnpm test:visual

18
.husky/pre-commit Executable file
View File

@@ -0,0 +1,18 @@
#!/usr/bin/env sh
# Pre-commit gates — fast checks only. Slow checks (full conformance, full
# test, full typecheck) stay in CI.
# 1. lint-staged: format + lint staged files
pnpm exec lint-staged || exit 1
# 2. If any docs/work/ markdown is staged, regenerate _state.json + re-stage it
if git diff --cached --name-only | grep -qE '^docs/work/.*\.md$'; then
pnpm work rebuild-state
git add docs/work/_state.json
fi
# 3. Run the state-sync guard: refuses to commit if _state.json is
# staged but doesn't match what rebuild-state would produce. Catches the case
# where someone hand-edits _state.json without going through rebuild-state.
node scripts/work/state-sync-guard.mjs || exit 1

11
.sandcastle/.env.example Normal file
View File

@@ -0,0 +1,11 @@
# Anthropic Claude API
ANTHROPIC_API_KEY=
# OpenAI / Codex
OPENAI_API_KEY=
# GitHub (for PR creation by the orchestrator)
GITHUB_TOKEN=
# Sandcastle's own configuration — sandbox provider (docker | podman | vercel | custom)
SANDCASTLE_PROVIDER=docker

3
.sandcastle/.gitignore vendored Normal file
View File

@@ -0,0 +1,3 @@
.env
*.log
.cache/

14
.sandcastle/Dockerfile Normal file
View File

@@ -0,0 +1,14 @@
# Minimal sandbox for sandcastle dispatch.
# Will be extended once the CI image is identified (see ADR-future).
FROM node:22-bookworm-slim
# pnpm via corepack (matches the repo's pnpm version)
RUN corepack enable && corepack prepare pnpm@9 --activate
WORKDIR /workspace
# Repo files are mounted by sandcastle at /workspace at runtime.
# The agent runs `pnpm install --frozen-lockfile` as its first step
# per the implementer prompt template.
CMD ["bash"]

37
.sandcastle/README.md Normal file
View File

@@ -0,0 +1,37 @@
# .sandcastle/
This directory holds prompt templates that the future orchestrator
(`pnpm work dispatch` in the `sandcastle-dispatch-v1` epic) feeds to
[sandcastle](https://github.com/mattpocock/sandcastle) when dispatching
agents.
## Prompt templates
| File | Role | Variables |
| ------------------------ | ----------------------------------------- | ----------------------------------- |
| `prd-eliciter.prompt.md` | Interview a human to produce a PRD draft | `{{INITIAL_BRIEF}}` |
| `adr-eliciter.prompt.md` | Interview a human to produce an ADR draft | `{{INITIAL_PROPOSAL}}` |
| `decomposer.prompt.md` | Turn a PRD into epic + story files | `{{PRD_FILE_CONTENT}}` |
| `implementer.prompt.md` | Execute a single task | `{{TASK_FILE_CONTENT}}` |
| `reviewer.prompt.md` | Review the implementer's diff | `{{TASK_FILE_CONTENT}}`, `{{DIFF}}` |
## Convention: every prompt enforces "generators first"
Each prompt template starts with the same non-negotiable rule: **the agent
must prefer `pnpm turbo gen <kind>` over hand-rolled scaffolding.** This
applies to feature packages, events, jobs, realtime channels, optional
core packages, and atomic-design components. Hand-rolled code is only
acceptable when the generator's output doesn't cover the case — and even
then, the agent runs the generator first and modifies its output rather
than starting from scratch.
## Environment
Configure runtime tokens via `.env` (gitignored). Copy `.env.example`
and fill values for the providers you use.
## Manual usage
Until the orchestrator ships, these templates are usable manually: copy
the relevant `.prompt.md` content into a Claude / Codex / other agent
session, fill the `{{VARIABLE}}` placeholders by hand, and run.

View File

@@ -0,0 +1,61 @@
# ADR Elicitation Agent
You are an Architecture Decision Record (ADR) elicitation agent for the template-vertical monorepo. Your job is to interview a human (one question at a time) and produce a complete ADR that captures the trade-offs of a proposed infrastructure decision.
## Use generators first (non-negotiable)
When the ADR concerns adopting infrastructure that has a generator path, the ADR's "Decision" section MUST reference the generator:
- **New optional core package** (cache, email, feature-flags, etc.) → `pnpm turbo gen core-package <name>`
- **Atomic-design component library** → `pnpm turbo gen core-ui-component <name>` to seed
- **Feature package as part of the integration** → `pnpm turbo gen feature <name>`
If the ADR is about adopting a package that has a generator and you describe the integration as hand-rolled, you have failed.
## Input
The human's initial proposal:
```
{{INITIAL_PROPOSAL}}
```
## Interview rules
1. Ask ONE question at a time.
2. **Push the human to articulate alternatives.** If they only describe one option, your next question is "What other options did you consider and reject?" — ADRs without alternatives are weak.
3. Topics, in order:
- **Context**: what's the situation? What problem is forcing a decision?
- **Drivers**: what's making this decision urgent (timeline, cost, deprecation, …)?
- **Considered options**: enumerate ALL alternatives, minimum 2. For each, pros + cons.
- **Decision**: which option, and why. Reference generators if applicable.
- **Consequences**: positive + negative + follow-up work (PRDs).
4. Minimum 5 substantive answers before drafting.
## Output
Write the ADR to `docs/adr/NNN-<slug>.md` (use the next available NNN number; check `docs/adr/` for existing ADRs).
Frontmatter:
```yaml
---
id: NNN
title: <decision title>
status: proposed
date: <today>
supersedes: []
superseded-by: null
related-prds: []
---
```
Body: Context, Drivers, Considered options, Decision, Consequences (Positive / Negative / Follow-up work).
Tell the human the file path. Tell them to review and flip `status: proposed``status: accepted` (or `rejected` / `superseded`) before any downstream PRDs are decomposed.
## Don't
- Don't accept a single-option ADR. Push for alternatives.
- Don't skip the generator check.
- Don't write code or PRDs.

View File

@@ -0,0 +1,45 @@
# Decomposer Agent
You are the decomposer agent. Given an approved PRD, you produce the epic file + one story file per requirement under `docs/work/<epic-slug>/`. Each story has its own checkbox-driven Tasks list.
## Use generators first (non-negotiable)
When decomposing requirements into stories + tasks, your first task in every story that creates a feature / event / job / realtime / core-package / component MUST be `Run \`pnpm turbo gen <kind> <name>\``. Do not write a story whose first task is "hand-write src/foo.ts" when a generator can produce src/foo.ts. The generators are:
- `pnpm turbo gen feature <name>` — feature scaffold (manifest, contracts, binders, controllers, tests)
- `pnpm turbo gen event` — event contract (publish) or handler (consume)
- `pnpm turbo gen job` — background job
- `pnpm turbo gen realtime` — realtime channel or inbound handler
- `pnpm turbo gen core-package <name>` — optional core package
- `pnpm turbo gen core-ui-component <name>` — atomic-design component
For each requirement, ask: "is there a generator for this?" If yes, the first task is the generator invocation; subsequent tasks customise the generator's output (add use-case behaviours, declare audits/publishes, etc.).
## Input
The approved PRD:
```
{{PRD_FILE_CONTENT}}
```
## Your job
1. Read the PRD. Extract: epic id (slugified from title + date), story list (one per Requirement), dependency edges (from "depends on" hints in the PRD), out-of-scope items.
2. Write `docs/work/<epic-id>/_epic.md` with the standard frontmatter (id, prd, title, type: epic, status: in-progress, features, created).
3. For each Requirement, write `docs/work/<epic-id>/<NN>-<story-slug>/_story.md`:
- Frontmatter: id, epic, title, type: technical-story | user-story, status: in-progress (for the first) or todo (subsequent), feature, depends-on (array, may reference other stories in this epic by id), blocks.
- Sections: Goal, Why, Done when, In scope, Out of scope, Tasks (checkbox list).
- **Each story's Tasks list:** if a generator is applicable, list the generator invocation as the FIRST checkbox; subsequent checkboxes are post-generator customisations.
## Output
Do not implement anything. Do not write code. Do not invent requirements not in the PRD. Each story should be a thin descriptor; the implementer fills in details when it picks up each task.
When done, tell the human the epic folder path and offer them a chance to review + edit before invoking the implementer.
## Constraints
- Stay literal to the PRD. The decomposer's judgment is about structure (which requirement becomes which story, what depends-on edges look like), not content.
- If a Requirement is too broad for one story, split it into multiple stories with clear depends-on chains. Don't merge unrelated Requirements into one story.
- If the PRD's status is not `approved`, refuse to decompose and tell the human to flip it first.

View File

@@ -0,0 +1,79 @@
# Implementer Agent
You are the implementer agent. You execute ONE task at a time, identified by the task description below. Your output is a single green commit (or a series of commits squashed at merge time).
## Use generators first (non-negotiable)
Before writing any code: if your task description includes `pnpm turbo gen <kind> ...`, run that command FIRST and use its output as your starting point. Even if the generator only emits half of what you need, customising generator output is always preferred over hand-rolling.
Available generators:
- `pnpm turbo gen feature <name>` — full feature scaffold
- `pnpm turbo gen event` — event contract or handler
- `pnpm turbo gen job` — background job
- `pnpm turbo gen realtime` — realtime channel or handler
- `pnpm turbo gen core-package <name>` — optional core package
- `pnpm turbo gen core-ui-component <name>` — atomic-design component
If your task's first checkbox is a generator invocation, that's your first action. Do not skip ahead.
## Task
```
{{TASK_FILE_CONTENT}}
```
## Manifest-first ordering
For any new use case, the order is non-negotiable:
1. **Manifest entry** — add to `feature.manifest.ts`
2. **Contracts**`xInputSchema`, `xOutputSchema`, `IXUseCase` exports in the use-case file (factory body throws `not implemented` initially)
3. **Tests (red)** — write the failing test
4. **Implementation (green)** — fill the factory body until tests pass
The generator handles step 1 + 2 for you when scaffolding a new feature.
## Conformance gates (run before declaring done)
```
pnpm typecheck # TS brand-slot enforcement, 0s
pnpm lint # ESLint rules incl. conformance/* — <1s
pnpm test --filter @repo/<feature> # tests for the feature you touched
pnpm conformance # cross-feature event closure
pnpm fallow:audit # whole-codebase analysis: dead exports, dupes, circular deps, complexity
```
All five pass before you commit. If any fail, fix or report BLOCKED — do not paper over.
## Commit message format
`<type>(<scope>): <imperative subject>`
Examples:
- `feat(auth): hash password before persisting`
- `test(blog): assert article not found error`
- `feat(scripts): conformance drift gate + tests`
Subject line ≤72 chars. Body explains WHY if non-obvious.
## When you're stuck
Report status `BLOCKED` (don't silently produce work you're unsure about). State specifically: what you tried, what's unclear, what kind of help you need (more context / different model / smaller task / plan is wrong).
## Output format
When done, return structured JSON:
```json
{
"status": "complete" | "blocked" | "needs-clarification",
"ac_satisfied": [0, 1, 2],
"files_changed": ["packages/..."],
"commit_sha": "abc123",
"notes": "..."
}
```
Do NOT modify the task markdown or `_state.json` yourself — the orchestrator handles state writes.

View File

@@ -0,0 +1,64 @@
# PRD Elicitation Agent
You are a PRD elicitation agent for the template-vertical monorepo. Your job is to interview a human (one question at a time) and produce a complete, agent-ready PRD that the decomposer can turn into stories.
## Use generators first (non-negotiable)
When the human's idea maps to creating any of these, the PRD's "Requirements" section must explicitly reference the generator that will produce the artefact:
- **Feature package** → `pnpm turbo gen feature <name>`
- **Event contract / handler** → `pnpm turbo gen event`
- **Background job** → `pnpm turbo gen job`
- **Realtime channel / handler** → `pnpm turbo gen realtime`
- **Optional core package** → `pnpm turbo gen core-package <name>`
- **Atomic-design component** → `pnpm turbo gen core-ui-component <name>`
If a requirement could be satisfied by a generator and you write it instead as a hand-rolled file list, you have failed. Always check first whether a generator covers the requirement.
## Input
The human's initial brief:
```
{{INITIAL_BRIEF}}
```
## Interview rules
1. Ask ONE question at a time. Never bundle multiple questions in one turn.
2. Prefer multiple-choice when the answer space is small. Open-ended only when the answer is genuinely open.
3. Topics to cover, in order:
- **Problem**: what's broken or missing today; who hurts because of it?
- **Goal**: what state are we trying to reach?
- **In scope** / **Out of scope**: the explicit fence.
- **Constraints**: what existing APIs / performance budgets / SLAs must we preserve?
- **Success criteria**: how do we observe success?
- **Requirements**: numbered list (R1, R2, …). For each, identify the generator that produces it if applicable.
- **Open questions**: decisions you couldn't resolve in the interview.
4. After enough information is gathered (you decide; minimum 6 substantive answers), draft the PRD and present it to the human for review. The PRD's `status` is `draft` until the human flips it to `approved`.
## Output
When you've gathered enough, write the PRD to `docs/work/prds/<YYYY-MM-DD>-<slug>.prd.md` with this frontmatter:
```yaml
---
id: <YYYY-MM-DD>-<slug>
title: <one-line title>
type: prd
status: draft
author: <human's name or "human">
elicitation-session: <this session's id>
created: <today>
---
```
And the body sections (in order): Problem, Goal, In scope, Out of scope, Constraints, Success criteria, Requirements (numbered), Open questions.
Tell the human the file path. Tell them to review and flip `status: draft``status: approved` before invoking the decomposer.
## Don't
- Don't decompose into stories — that's the decomposer's job
- Don't write code or tests
- Don't skip the generator check on each requirement

View File

@@ -0,0 +1,50 @@
# Reviewer Agent
You are the reviewer agent. You verify the implementer's diff against the task's AC + scope. You do NOT modify the repo.
## Generator-first check (verify, don't bypass)
If the task's first checkbox was a generator invocation, verify the implementer actually ran the generator. Signs the generator was run:
- The diff includes files at canonical generator paths (e.g., `packages/<name>/src/feature.manifest.ts`, `packages/<name>/src/di/bind-production.ts`, etc.)
- The generator's anchor comments (`// <gen:event-handlers>`, `// <gen:jobs>`, etc.) are present
- The file shapes match what `pnpm turbo gen <kind>` would produce
If you suspect the implementer hand-rolled what should have been generator output, reject. Tell them to delete what they wrote and run the generator.
## Task
```
{{TASK_FILE_CONTENT}}
```
## Diff
```
{{DIFF}}
```
## Your checks
1. **AC coverage**: every checkbox in the task's AC list is verifiably satisfied by the diff. Verify by reading the actual code, not by trusting the implementer's report.
2. **Out-of-scope discipline**: the diff does NOT touch anything listed under the task's "Out of scope" (or anything not related to the AC). Over-engineering / drive-by refactors are rejection causes.
3. **Manifest-first ordering**: if a new use case landed, the manifest was updated; tests exist; the factory was wrapped at bind time.
4. **Conformance gates**: the diff's tests + lint + typecheck pass. (You don't run them yourself; sandcastle's CI step does. Trust the CI status, reject if it's red.)
5. **Generator-first**: see the section above. Hand-rolled code that should have been generated is a rejection.
6. **Fallow audit**: verify the implementer ran `pnpm fallow:audit` and it passed. If their diff increases dead exports / dupes / circular deps / complexity beyond the baseline, that's a rejection cause unless the implementer's notes explicitly justify it.
## Output format
Return structured JSON:
```json
{
"decision": "approve" | "reject",
"ac_verified": [0, 1, 2],
"scope_violations": ["files touched that weren't in scope"],
"generator_skipped": false,
"notes": "..."
}
```
If you reject, the orchestrator passes your notes back to the implementer for a fix-up cycle (up to the task's `max-attempts`, default 3).

View File

@@ -366,6 +366,22 @@ Each feature binder signature is `(ctx: BindProductionContext): void` for produc
---
### Conformance contract (every feature)
Every feature package MUST declare a `src/feature.manifest.ts` using `defineFeature` from `@repo/core-shared/conformance`. The manifest declares the use cases, what they audit/publish/consume, and which optional cores they require.
The feature's `src/di/bind-production.ts` MUST call `assertFeatureConformance(container, manifest, symbols, ctx)` at the tail of `bindProduction<Name>` so `pnpm dev` refuses to boot if a binding loses its brand.
Re-export the manifest from `src/index.ts`:
```ts
export { fooManifest, type FooManifest } from "./feature.manifest";
```
See `docs/guides/conformance-quickref.md` for the canonical pattern; the generator (`pnpm turbo gen feature <name>`) emits all of this correctly by default.
---
### Cross-feature events and background jobs (Plan 10, ADR-015)
Three rules:

View File

@@ -39,11 +39,27 @@ Turborepo + pnpm monorepo organized by vertical features. Each feature (`auth`,
- `docs/architecture/vertical-feature-spec.md` — Design spec with rationale and decision log
- `docs/guides/scaffolding-a-feature.md``turbo gen feature` reference (fast path; prefer this over the manual walkthrough)
- `docs/guides/adding-a-feature.md` — End-to-end new feature walkthrough (manual path; for cases the generator's Phase-1 scope doesn't cover)
- `docs/guides/events-and-jobs.md` — publish/consume/schedule cookbook (cross-feature events + background jobs; *requires `gen core-package events`*)
- `docs/guides/realtime.md` — Socket.IO channels, broadcasts, handlers (*requires `gen core-package realtime`*)
- `docs/guides/audit-and-compliance.md` — DPA-compliant audit logging cookbook (*requires `gen core-package audit`*)
- `docs/guides/events-and-jobs.md` — publish/consume/schedule cookbook (cross-feature events + background jobs; _requires `gen core-package events`_)
- `docs/guides/realtime.md` — Socket.IO channels, broadcasts, handlers (_requires `gen core-package realtime`_)
- `docs/guides/audit-and-compliance.md` — DPA-compliant audit logging cookbook (_requires `gen core-package audit`_)
- `docs/architecture/template-tiers.md` — must-have vs optional packages and how to scaffold the optionals
## Conformance system
Every feature has a `src/feature.manifest.ts` declaring its use cases, audits, publishes, consumes, and required cores. Drift is caught at five latencies:
| Layer | Latency | Catches |
| -------------------------------------- | ------- | ----------------------------------------------------------------------------------------------------------- |
| **TypeScript brands** | 0s | forgotten `withSpan` / `withCapture` / `withAudit` at bind time |
| **ESLint rules** | <1s | manifest code drift; undeclared `bus.publish` / `auditLog.record`; missing manifest; missing sibling test |
| **Boot assertion** (`pnpm dev`) | ~3s | binding without required brand at runtime; manifest edited without rebinder |
| **CI drift gate** (`pnpm conformance`) | ~120s | orphan event consumers across features |
| **Fallow** (`pnpm fallow`) | ~3060s | dead exports / unused files; duplicate code; circular deps; complexity hotspots; AI-change audit drift |
The five conformance ESLint rules: `feature-must-have-manifest` (error), `usecase-must-have-test-file` (error), `required-cores-installed` (error), `no-undeclared-event-publish` (warn), `no-undeclared-audit` (warn). Fallow runs as a fifth layer, post-ESLint, whole-codebase.
See `docs/architecture/agent-first-workflow-and-conformance.md` for the full design and `docs/guides/conformance-quickref.md` for the day-to-day reference.
## Key Conventions
- **Relative imports in `src/`** Source files use relative paths (`../repositories/...`), not `@/` alias
@@ -68,12 +84,15 @@ Turborepo + pnpm monorepo organized by vertical features. Each feature (`auth`,
- **PII handling is non-negotiable** `sendDefaultPii: false` everywhere (R31, CI grep gate); replay default-masks all text/inputs/media (R34, R35, allowlist starts empty); `setUser({ id })` only no email/username (R36); server-side PII scrubbing happens at the OTel processor layer (`PiiScrubSpanProcessor` + `PiiScrubLogRecordProcessor`) before any exporter sees the data (R32, R33, ADR-017 §7)
- **Three apps, three Sentry projects** `WEB_NEXT_SENTRY_DSN`, `CMS_SENTRY_DSN`, `WEB_TANSTACK_SENTRY_DSN`. Browser DSNs use `NEXT_PUBLIC_` (web-next) and `VITE_` (web-tanstack) prefixes
- **Instrumentation binding is orthogonal to repo binding** `bindAll()`'s Rule 0 (DSN OTel+Sentry vs Noop) is independent of `USE_DEV_SEED` / `NODE_ENV`. Run `pnpm dev` with `WEB_NEXT_SENTRY_DSN` set to test the integration locally
- **Cross-feature events go through `IEventBus` (E0)** — In-feature reactions are direct use-case calls, not bus publishes. The bus is for *crossing* feature boundaries (e.g. `auth` `marketing-pages` welcome email)
- **Cross-feature events go through `IEventBus` (E0)** In-feature reactions are direct use-case calls, not bus publishes. The bus is for _crossing_ feature boundaries (e.g. `auth` `marketing-pages` welcome email)
- **Event contracts are public; handlers are private (E1)** Publisher's `events/<x>.event.ts` is exported from the feature root barrel. Consumer's `events/handlers/on-<publisher>-<event>.handler.ts` is never re-exported (ESLint-enforced via `core-eslint/rules/no-handler-reexport`)
- **Jobs are for *deferred* work, not abstraction (J0)** — Synchronous code stays synchronous. A job exists only when something must run off the request path (latency, retries, cron). Feature packages enqueue via `IJobQueue` onlydirect `payload.jobs.queue()` is ESLint-blocked outside `core-shared/jobs/`
- **Jobs are for _deferred_ work, not abstraction (J0)** Synchronous code stays synchronous. A job exists only when something must run off the request path (latency, retries, cron). Feature packages enqueue via `IJobQueue` only direct `payload.jobs.queue()` is ESLint-blocked outside `core-shared/jobs/`
- **Realtime is for state delivery, not for replacing tRPC (R0)** Persistent request/response operations belong on tRPC procedures. Use realtime when the server needs to push without a request or the data is too high-frequency for HTTP
- **Realtime channel descriptors are exported; handlers are private (R1)** — A feature's `realtime/<name>.channel.ts` is re-exported from the root barrel; `realtime/handlers/*.handler.ts` is wired only in bind-* files and never re-exported (ESLint-enforced via `no-realtime-handler-reexport`)
- **Realtime channel descriptors are exported; handlers are private (R1)** A feature's `realtime/<name>.channel.ts` is re-exported from the root barrel; `realtime/handlers/*.handler.ts` is wired only in bind-\* files and never re-exported (ESLint-enforced via `no-realtime-handler-reexport`)
- **`socket.io` lives in `@repo/core-realtime` only (R2)** Feature packages MUST NOT import `socket.io` or `socket.io-client`. ESLint rule `no-direct-socket-io` enforces this; allowlist covers `core-realtime/src/socket-io-*.ts` and `apps/*/server.ts`
- **Manifest-first ordering** for any new use case, the workflow is **(1) manifest entry** **(2) contracts** (`xInputSchema`, `xOutputSchema`, `IXUseCase`) **(3) tests (red)** **(4) implementation (green)**. The generator emits the manifest + a self-asserting `bind-production.ts` so new features are conformance-compliant by default
- **Self-asserting `bindProductionX(ctx)`** every feature's bind-production calls `assertFeatureConformance(container, manifest, symbols, ctx)` at its tail. `pnpm dev` refuses to boot on drift
- **`pnpm conformance`** cross-feature event-closure check; fails CI on orphan consumers
## MCP Servers
@@ -83,10 +102,10 @@ Storybook MCP available at `http://localhost:6006/mcp` — use `list-all-documen
## Key Ports
| Service | Port |
|---|---|
| Next.js | 3000 |
| Payload CMS | 3001 |
| Service | Port |
| -------------- | ---- |
| Next.js | 3000 |
| Payload CMS | 3001 |
| TanStack Start | 3002 |
| PostgreSQL | 5432 |
| Storybook | 6006 |
| PostgreSQL | 5432 |
| Storybook | 6006 |

View File

@@ -14,6 +14,7 @@
},
"dependencies": {},
"devDependencies": {
"@playwright/test": "^1.49.0",
"@repo/core-eslint": "workspace:*",
"@repo/core-typescript": "workspace:*",
"@storybook/addon-essentials": "^8.6.0",

View File

@@ -0,0 +1,51 @@
import { test, expect } from "@playwright/test";
/**
* Iterates every story registered in Storybook and takes a screenshot.
*
* Storybook exposes its story manifest at /index.json (Storybook 7+). For
* each entry where `type === "story"`, we navigate to the iframe URL and
* snapshot.
*
* Today the index is empty (no components in the repo). The harness still
* runs — it just finds zero stories. The moment a story lands, the
* baseline is captured on first run and subsequent runs diff against it.
*/
type StoryEntry = {
id: string;
title: string;
name: string;
type: "story" | "docs";
};
async function fetchStoryIndex(baseURL: string): Promise<StoryEntry[]> {
const res = await fetch(`${baseURL}/index.json`);
if (!res.ok) return [];
const json = (await res.json()) as {
entries?: Record<string, StoryEntry>;
};
return Object.values(json.entries ?? {}).filter((e) => e.type === "story");
}
test.describe("Storybook visual regression", () => {
test("captures a screenshot for every registered story", async ({
page,
baseURL,
}) => {
const stories = await fetchStoryIndex(baseURL!);
if (stories.length === 0) {
test.skip(
true,
"No stories registered yet — visual regression harness is inactive until the first story lands.",
);
return;
}
for (const story of stories) {
await test.step(`${story.title}${story.name}`, async () => {
await page.goto(`/iframe.html?id=${story.id}&viewMode=story`);
await page.waitForLoadState("networkidle");
await expect(page).toHaveScreenshot(`${story.id}.png`);
});
}
});
});

View File

@@ -25,6 +25,21 @@ per-use-case patterns below.
---
## Workflow ordering
For any new use case, follow these four steps in order:
1. **Manifest entry** — declare the use case in `src/feature.manifest.ts` with its `mutates` flag and (initially empty) `audits` / `publishes` / `consumes` arrays.
2. **Contracts** — export `xInputSchema`, `xOutputSchema`, and the `IXUseCase` type alias from the use-case file. Factory body starts as `throw new Error("not implemented")`.
3. **Tests (red)** — write the failing test that exercises the contract via the factory + a mock repository.
4. **Implementation (green)** — fill the factory body until the tests pass.
The `feature-must-have-manifest` ESLint rule will catch step 1 omissions; `usecase-must-have-test-file` catches step 3. The boot assertion (`assertFeatureConformance` at the tail of `bindProductionX`) catches forgotten wrappers at startup.
For the fast path, run `pnpm turbo gen feature <name>` — the generator emits the manifest + contracts + bind-production with the assertion already wired in.
---
## 1. Overview
Every feature package owns:

View File

@@ -0,0 +1,137 @@
# Conformance system — quick reference
Day-to-day reference for the manifest-first workflow. For design rationale see `docs/architecture/agent-first-workflow-and-conformance.md` and the interactive `feature-conformance-explainer.html`.
---
## The manifest
Every feature has one at `src/feature.manifest.ts`:
```ts
import { defineFeature } from "@repo/core-shared/conformance";
export const fooManifest = defineFeature({
name: "foo",
requiredCores: [],
useCases: {
getThing: { mutates: false, audits: [], publishes: [], consumes: [] },
createThing: {
mutates: true,
audits: ["thing.created"],
publishes: ["foo.thing-created"],
consumes: [],
},
},
realtimeChannels: [],
jobs: [],
} as const);
export type FooManifest = typeof fooManifest;
```
Field reference:
| Field | Type | Meaning |
| --------------------------- | -------------- | --------------------------------------------------------------------------- |
| `name` | string literal | Feature name (kebab-case, matches package name) |
| `requiredCores` | string[] | Optional cores this feature requires (e.g. `["audit", "events"]`) |
| `useCases.<name>.mutates` | boolean | True for create/update/delete; drives whether `__audited` brand is required |
| `useCases.<name>.audits` | string[] | Audit event types this use case emits via `auditLog.record({ type: "X" })` |
| `useCases.<name>.publishes` | string[] | Cross-feature events this use case publishes via `bus.publish("X")` |
| `useCases.<name>.consumes` | string[] | Cross-feature events this use case consumes (via an event handler) |
| `realtimeChannels` | string[] | Realtime channels this feature owns |
| `jobs` | string[] | Job slugs this feature enqueues |
Re-export from `src/index.ts`:
```ts
export { fooManifest, type FooManifest } from "./feature.manifest";
```
## bindProductionX self-assertion
Every feature's `bind-production.ts` calls the assertion at the tail:
```ts
import { assertFeatureConformance } from "@repo/core-shared/conformance";
import { fooManifest } from "../feature.manifest";
export function bindProductionFoo(ctx: BindProductionContext): void {
// ... bind use cases, wrapped with withSpan + withCapture + (if mutating + audits) withAudit ...
assertFeatureConformance(
fooContainer,
fooManifest,
{
getThing: FOO_SYMBOLS.IGetThingUseCase,
createThing: FOO_SYMBOLS.ICreateThingUseCase,
},
ctx,
);
}
```
The symbol map declares which container symbol each manifest use-case key resolves to.
## The five gates
| Gate | When it fires | What it catches | Severity |
| ------------------ | --------------------- | ------------------------------------------------------------------------ | -------------------- |
| `tsc` | on save | forgotten wrappers; manifest-derived slot type rejects unwrapped factory | error |
| `eslint` | on save / `pnpm lint` | manifest ↔ code drift; missing sibling test; missing manifest | error or warn |
| `pnpm dev` | at boot | binding lost its runtime brand; manifest declares more than wired | throws synchronously |
| `pnpm conformance` | CI | orphan event consumers across features | exits non-zero |
| `pnpm fallow` | ~3060s | unused exports/files, dupes, circular deps, complexity, AI-change audit | warn (currently) |
## ESLint rules
| Rule | Severity | What it does |
| ----------------------------------------- | -------- | -------------------------------------------------------------------------------------- |
| `conformance/feature-must-have-manifest` | error | Use-case files require a sibling manifest |
| `conformance/usecase-must-have-test-file` | error | Every `*.use-case.ts` has a sibling `*.use-case.test.ts` |
| `conformance/required-cores-installed` | error | Manifest's `requiredCores` must exist as `core-<name>` packages in pnpm-workspace.yaml |
| `conformance/no-undeclared-event-publish` | warn | `bus.publish("X")` literal must match the manifest's `publishes` for the use case |
| `conformance/no-undeclared-audit` | warn | `auditLog.record({ type: "X" })` literal must match the manifest's `audits` |
## Workflow ordering for new use cases
1. **Manifest** — add the use case to `feature.manifest.ts` with empty `audits` / `publishes` / `consumes`
2. **Contracts** — export `xInputSchema`, `xOutputSchema`, `IXUseCase` from the use-case file (factory body throws "not implemented")
3. **Tests (red)** — write the test importing the contracts; verify it fails
4. **Implementation (green)** — fill the factory body until tests pass
For the fast path: `pnpm turbo gen feature <name>` scaffolds steps 1 + 2 in a single command.
## Common drift patterns and the gate that catches them
- **Forgot `withSpan` at bind time** → tsc TS2322 + boot assertion
- **Manifest declares `audits: ["X"]` but factory doesn't call `auditLog.record({type:"X"})`** → no automatic catch yet; future story
- **Factory calls `bus.publish("Y")` but manifest doesn't declare it** → `conformance/no-undeclared-event-publish` (warn)
- **Feature has use cases but no manifest** → `conformance/feature-must-have-manifest` (error)
- **Manifest references `requiredCores: ["X"]` but no `core-X` package exists** → `conformance/required-cores-installed` (error)
- **One feature consumes `Y` but no feature publishes `Y`** → `pnpm conformance` orphan check (CI gate)
## Pinning down a drift
When a gate fires, the error message tells you what to run. For example:
> `Feature blog has use cases but no feature.manifest.ts. Run 'pnpm turbo gen feature blog' or scaffold the manifest manually at packages/blog/src/feature.manifest.ts.`
That's the "fix" line — follow it.
## Fallow audit for AI changes
When you (the agent) finish a task and are about to commit, run:
```
pnpm fallow:audit
```
This runs `fallow audit --base main`, comparing your branch's diff against main. If your change adds dead exports, dupes, or complexity hotspots, fallow tells you exactly what and where. Fix or accept (with --gate flag to ignore inherited findings).
This is the catch-all for whole-codebase drift the per-file gates can't see.
---
For the deeper design rationale see `docs/architecture/agent-first-workflow-and-conformance.md` and the interactive `feature-conformance-explainer.html`.

View File

@@ -32,6 +32,17 @@ pnpm install # link the new workspace package
pnpm --filter @repo/widgets lint typecheck test
```
## Conformance-ready by default
Since milestone v of the conformance system, `pnpm turbo gen feature <name>` emits two conformance artefacts:
- **`src/feature.manifest.ts`** declaring the scaffolded `getX` use case
- **`src/di/bind-production.ts`** with `assertFeatureConformance(...)` called at the tail
Run `pnpm conformance` after generating a feature — it should pass cleanly. If you add `bus.publish("X")` calls in a factory body, you'll also need to add `"X"` to the manifest's `publishes[]` array for that use case, or the `no-undeclared-event-publish` ESLint rule will warn.
See `docs/guides/conformance-quickref.md` for the manifest field reference.
## What it generates
- Package files: `package.json`, `tsconfig.json`, `vitest.config.ts`,

View File

@@ -704,3 +704,16 @@ expect(logger.captures[0]).toMatchObject({
For an end-to-end example (controller → use case → repo, all wrapped, asserting no double-capture across layers), see `packages/blog/tests/r44-no-double-capture.test.ts`.
**Default mocks** (when you don't need assertions): construct `new MockArticlesRepository()` with no args — the constructor defaults bind `NoopTracer` + `NoopLogger`.
---
## Conformance gates (post-TDD)
After your tests are green and the impl is committed, four gates check that the new code stays consistent with the feature's manifest:
1. **TypeScript brands** — the `ProductionUseCase<I, O, M>` slot in `bind-production.ts` only accepts factories wrapped through `withSpan` + `withCapture` + (if mutating with audits) `withAudit`.
2. **ESLint rules** — five `conformance/*` rules check manifest ↔ code drift; see `docs/guides/conformance-quickref.md`.
3. **Boot assertion**`assertFeatureConformance` runs at the tail of every `bindProductionX(ctx)`; `pnpm dev` refuses to start on drift.
4. **CI drift gate**`pnpm conformance` runs after `pnpm lint` in CI; fails on orphan event consumers across features.
The TDD red-green cycle covers behavioural correctness; the conformance gates cover architectural correctness.

View File

@@ -0,0 +1,811 @@
# Conformance Milestone iii.b — AST-aware ESLint rules
> **For agentic workers:** REQUIRED SUB-SKILL: Use `superpowers:subagent-driven-development` to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Ship `no-undeclared-event-publish` and `no-undeclared-audit` rules that walk use-case AST and the parent feature.manifest.ts AST, fail on mismatch. Closes the inner ESLint feedback loop for cross-feature events and audit emissions.
**Architecture:** Each rule uses `@typescript-eslint/parser` (already a dep) to parse the parent feature.manifest.ts and extract per-use-case `publishes[]` / `audits[]` arrays as an AST tree walk (regex was sufficient for iii.a's flat fields, not for nested useCases). Rules visit `CallExpression` nodes in the lint target. For each call matching `<x>.publish("Y")` or `<x>.record({ type: "Y", ... })`, compare against the manifest's declared array for the current use case. Use case identity is derived from the file slug (`sign-in.use-case.ts``signIn`).
**Tech Stack:** TypeScript, `@typescript-eslint/parser` (already devDependency), Vitest, ESLint RuleTester.
---
## File structure
### Create
- `packages/core-eslint/rules/_manifest-ast.js` — parse manifest.ts and extract per-use-case publishes/audits/consumes via AST walking
- `packages/core-eslint/rules/_manifest-ast.test.js`
- `packages/core-eslint/rules/_usecase-name.js` — slug→useCase-name helper (sign-in → signIn)
- `packages/core-eslint/rules/_usecase-name.test.js`
- `packages/core-eslint/rules/no-undeclared-event-publish.js`
- `packages/core-eslint/rules/no-undeclared-event-publish.test.js`
- `packages/core-eslint/rules/no-undeclared-audit.js`
- `packages/core-eslint/rules/no-undeclared-audit.test.js`
- `docs/work/conformance-system-v1/03-b-ast-eslint-rules/_story.md`
### Modify
- `packages/core-eslint/plugin.js` — register new rules
- `packages/core-eslint/base.js` — enable rules (as `warn` initially)
- `docs/work/conformance-system-v1/_epic.md` — tick 03.b, then tick the parent 03 since both halves now shipped
---
## Task 1: Story 03.b scaffold
**Files:** Create `docs/work/conformance-system-v1/03-b-ast-eslint-rules/_story.md`
- [ ] **Step 1: Write the story file**
```markdown
---
id: 03-b-ast-eslint-rules
epic: conformance-system-v1
title: AST-aware ESLint rules (no-undeclared-event-publish, no-undeclared-audit)
type: technical-story
status: in-progress
feature: core-eslint
depends-on: [03-a-structural-eslint-rules]
blocks: [04-ci-drift-gate]
---
## Goal
Ship two AST-aware rules that catch manifest ↔ use-case drift inside factory bodies:
- `no-undeclared-event-publish`: `bus.publish("X")` in a factory must match `manifest.useCases[name].publishes`
- `no-undeclared-audit`: `auditLog.record({ type: "X" })` must match `manifest.useCases[name].audits`
## Why
Boot assertion + structural rules can't see what happens inside a factory body. AST-aware rules catch publish/audit drift the moment a developer (or agent) saves the file.
## Done when
- Manifest AST parser extracts per-use-case publishes/audits arrays
- Two rules registered in the conformance plugin
- Tests cover positive (declared event) and negative (undeclared event) cases for each rule
- `pnpm lint` passes (auth's signUp has empty publishes/audits today → no false positives)
## In scope
- `_manifest-ast.js` helper using `@typescript-eslint/parser` to extract per-use-case arrays
- `_usecase-name.js` helper (file slug → camelCase use-case key)
- The two rules + RuleTester tests
- Plugin + base.js wiring
## Out of scope
- Bus / auditLog parameter detection beyond the conventional names `bus` and `auditLog`
- Detection of dynamic event names (`bus.publish(eventVar, payload)` is allowed without warning)
- Conditional / nested calls — rules only check top-level CallExpressions in factory bodies
## Tasks
- [ ] Story 03.b scaffold
- [ ] Manifest AST parser + tests
- [ ] Use-case name helper + tests
- [ ] `no-undeclared-event-publish` rule + tests
- [ ] `no-undeclared-audit` rule + tests
- [ ] Plugin update + base.js wiring
- [ ] Verify `pnpm lint` passes
- [ ] Final verification + story closeout (tick 03 + 03.b in epic)
```
- [ ] **Step 2: Commit**
```bash
git add docs/work/conformance-system-v1/03-b-ast-eslint-rules/_story.md
git commit -m "docs(work): story 03.b — AST-aware ESLint rules"
```
---
## Task 2: Manifest AST parser (`_manifest-ast.js`)
Parses a `feature.manifest.ts` using `@typescript-eslint/parser` and returns a structured `{ useCases: { [name]: { publishes, audits, consumes } } }` object.
**Files:**
- Create: `packages/core-eslint/rules/_manifest-ast.js`
- Create: `packages/core-eslint/rules/_manifest-ast.test.js`
- [ ] **Step 1: Write the failing test**
```js
import { describe, it, expect } from "vitest";
import path from "node:path";
import os from "node:os";
import fs from "node:fs";
import { parseManifestUseCases } from "./_manifest-ast.js";
function writeManifest(content) {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "manifest-ast-"));
const fp = path.join(dir, "feature.manifest.ts");
fs.writeFileSync(fp, content);
return fp;
}
describe("parseManifestUseCases", () => {
it("returns an empty object for a manifest with no useCases", () => {
const fp = writeManifest(`export const xManifest = defineFeature({
name: "x",
requiredCores: [],
useCases: {},
realtimeChannels: [],
jobs: [],
} as const);`);
expect(parseManifestUseCases(fp)).toEqual({});
});
it("extracts per-use-case publishes/audits/consumes arrays", () => {
const fp = writeManifest(`export const authManifest = defineFeature({
name: "auth",
requiredCores: [],
useCases: {
signIn: { mutates: false, audits: [], publishes: [], consumes: [] },
signUp: { mutates: true, audits: ["user.created"], publishes: ["auth.signed-up"], consumes: [] },
signOut: { mutates: true, audits: ["session.ended"], publishes: [], consumes: [] },
},
realtimeChannels: [],
jobs: [],
} as const);`);
expect(parseManifestUseCases(fp)).toEqual({
signIn: { mutates: false, audits: [], publishes: [], consumes: [] },
signUp: { mutates: true, audits: ["user.created"], publishes: ["auth.signed-up"], consumes: [] },
signOut: { mutates: true, audits: ["session.ended"], publishes: [], consumes: [] },
});
});
it("returns null when file does not exist", () => {
expect(parseManifestUseCases("/nonexistent/manifest.ts")).toBeNull();
});
it("returns null when the file has no defineFeature call", () => {
const fp = writeManifest(`export const x = 1;`);
expect(parseManifestUseCases(fp)).toBeNull();
});
});
```
- [ ] **Step 2: Run test to verify it fails**
```
pnpm --filter @repo/core-eslint test _manifest-ast
```
Expected: FAIL.
- [ ] **Step 3: Write `_manifest-ast.js`**
```js
import fs from "node:fs";
import { parse } from "@typescript-eslint/parser";
/**
* Parse a feature.manifest.ts file and extract per-use-case attributes.
* Walks the AST to find the `defineFeature({...} as const)` call expression
* and reads literal values from its argument object.
*
* Returns: { [useCaseName]: { mutates, audits[], publishes[], consumes[] } }
* Returns null if the file is missing or doesn't match the expected shape.
*/
export function parseManifestUseCases(manifestPath) {
let src;
try {
src = fs.readFileSync(manifestPath, "utf8");
} catch {
return null;
}
let ast;
try {
ast = parse(src, { sourceType: "module", ecmaVersion: "latest", loc: false, range: false });
} catch {
return null;
}
const defineCall = findDefineFeatureCall(ast);
if (!defineCall) return null;
const arg = unwrapAsConst(defineCall.arguments[0]);
if (!arg || arg.type !== "ObjectExpression") return null;
const useCasesProp = arg.properties.find(
(p) => p.type === "Property" && p.key.type === "Identifier" && p.key.name === "useCases",
);
if (!useCasesProp || useCasesProp.value.type !== "ObjectExpression") return {};
const result = {};
for (const entry of useCasesProp.value.properties) {
if (entry.type !== "Property" || entry.value.type !== "ObjectExpression") continue;
const name = entry.key.type === "Identifier" ? entry.key.name : entry.key.value;
result[name] = extractUseCaseEntry(entry.value);
}
return result;
}
function findDefineFeatureCall(ast) {
for (const node of ast.body) {
if (node.type !== "ExportNamedDeclaration" || !node.declaration) continue;
if (node.declaration.type !== "VariableDeclaration") continue;
for (const decl of node.declaration.declarations) {
const init = decl.init;
if (!init) continue;
// Match defineFeature(...) or defineFeature(...) chained
if (init.type === "CallExpression" && init.callee.type === "Identifier" && init.callee.name === "defineFeature") {
return init;
}
}
}
return null;
}
function unwrapAsConst(node) {
// @typescript-eslint produces TSAsExpression for `expr as const`
if (node && node.type === "TSAsExpression") return node.expression;
return node;
}
function extractUseCaseEntry(objExpr) {
const entry = { mutates: false, audits: [], publishes: [], consumes: [] };
for (const prop of objExpr.properties) {
if (prop.type !== "Property" || prop.key.type !== "Identifier") continue;
const key = prop.key.name;
if (key === "mutates" && prop.value.type === "Literal") {
entry.mutates = prop.value.value === true;
} else if ((key === "audits" || key === "publishes" || key === "consumes") && prop.value.type === "ArrayExpression") {
entry[key] = prop.value.elements
.filter((el) => el && el.type === "Literal" && typeof el.value === "string")
.map((el) => el.value);
}
}
return entry;
}
```
- [ ] **Step 4: Run tests**
```
pnpm --filter @repo/core-eslint test _manifest-ast
```
Expected: PASS, 4 tests.
- [ ] **Step 5: Commit**
```bash
git add packages/core-eslint/rules/_manifest-ast.js packages/core-eslint/rules/_manifest-ast.test.js
git commit -m "feat(core-eslint): manifest AST parser for per-use-case attributes"
```
---
## Task 3: Use-case name helper
Converts the file path slug to the manifest's use-case key.
**Files:**
- Create: `packages/core-eslint/rules/_usecase-name.js`
- Create: `packages/core-eslint/rules/_usecase-name.test.js`
- [ ] **Step 1: Write the failing test**
```js
import { describe, it, expect } from "vitest";
import { useCaseNameFromFile } from "./_usecase-name.js";
describe("useCaseNameFromFile", () => {
it("converts kebab-case slug to camelCase", () => {
expect(useCaseNameFromFile("/repo/packages/auth/src/application/use-cases/sign-in.use-case.ts")).toBe("signIn");
expect(useCaseNameFromFile("/repo/packages/auth/src/application/use-cases/sign-up.use-case.ts")).toBe("signUp");
expect(useCaseNameFromFile("/repo/packages/blog/src/application/use-cases/get-article-by-slug.use-case.ts")).toBe("getArticleBySlug");
});
it("handles single-word slugs", () => {
expect(useCaseNameFromFile("/repo/packages/x/src/application/use-cases/login.use-case.ts")).toBe("login");
});
it("returns null for non-use-case files", () => {
expect(useCaseNameFromFile("/repo/packages/auth/src/index.ts")).toBeNull();
expect(useCaseNameFromFile("/repo/packages/auth/src/feature.manifest.ts")).toBeNull();
});
});
```
- [ ] **Step 2: Run test to verify it fails**
```
pnpm --filter @repo/core-eslint test _usecase-name
```
- [ ] **Step 3: Write `_usecase-name.js`**
```js
/**
* Derive the manifest use-case key from a use-case file path.
*
* Convention: `packages/<feature>/src/application/use-cases/<kebab-slug>.use-case.ts`
* → manifest.useCases.<camelCaseSlug>
*
* Returns null for non-use-case files.
*/
export function useCaseNameFromFile(filepath) {
if (!filepath.endsWith(".use-case.ts")) return null;
if (!filepath.includes("/application/use-cases/") && !filepath.includes("\\application\\use-cases\\")) {
return null;
}
const base = filepath.split(/[\\/]/).pop();
const slug = base.replace(/\.use-case\.ts$/, "");
return slug.split("-").map((part, i) => i === 0 ? part : part[0].toUpperCase() + part.slice(1)).join("");
}
```
- [ ] **Step 4: Run tests**
```
pnpm --filter @repo/core-eslint test _usecase-name
```
Expected: PASS, 3 tests.
- [ ] **Step 5: Commit**
```bash
git add packages/core-eslint/rules/_usecase-name.js packages/core-eslint/rules/_usecase-name.test.js
git commit -m "feat(core-eslint): use-case name helper (file slug → manifest key)"
```
---
## Task 4: `no-undeclared-event-publish` rule
Fires on `<x>.publish("Y")` calls inside use-case files when `Y` is not declared in the parent manifest's `useCases[name].publishes`.
**Files:**
- Create: `packages/core-eslint/rules/no-undeclared-event-publish.js`
- Create: `packages/core-eslint/rules/no-undeclared-event-publish.test.js`
- [ ] **Step 1: Write the failing test**
```js
import { describe, it } from "vitest";
import { RuleTester } from "eslint";
import path from "node:path";
import os from "node:os";
import fs from "node:fs";
import rule from "./no-undeclared-event-publish.js";
function makeFixture({ manifestUseCases, useCaseBody }) {
const repoRoot = fs.mkdtempSync(path.join(os.tmpdir(), "nuep-"));
const featureDir = path.join(repoRoot, "packages", "demo");
fs.mkdirSync(path.join(featureDir, "src", "application", "use-cases"), { recursive: true });
const useCasesObj = Object.entries(manifestUseCases)
.map(([name, uc]) =>
` ${name}: { mutates: ${uc.mutates}, audits: [${uc.audits.map((a) => `"${a}"`).join(", ")}], publishes: [${uc.publishes.map((p) => `"${p}"`).join(", ")}], consumes: [] },`,
)
.join("\n");
fs.writeFileSync(
path.join(featureDir, "src", "feature.manifest.ts"),
`export const demoManifest = defineFeature({
name: "demo",
requiredCores: [],
useCases: {
${useCasesObj}
},
realtimeChannels: [],
jobs: [],
} as const);`,
);
const useCaseFile = path.join(featureDir, "src", "application", "use-cases", "sign-up.use-case.ts");
fs.writeFileSync(useCaseFile, useCaseBody);
return { repoRoot, useCaseFile };
}
const tester = new RuleTester({
languageOptions: {
parser: await import("@typescript-eslint/parser"),
ecmaVersion: "latest",
sourceType: "module",
},
});
describe("no-undeclared-event-publish", () => {
it("passes when bus.publish event name matches manifest publishes[]", () => {
const { repoRoot, useCaseFile } = makeFixture({
manifestUseCases: { signUp: { mutates: true, audits: [], publishes: ["demo.signed-up"] } },
useCaseBody: `export const signUpUseCase = (bus) => async () => { bus.publish("demo.signed-up", {}); };`,
});
tester.run("no-undeclared-event-publish", rule, {
valid: [{ filename: useCaseFile, code: fs.readFileSync(useCaseFile, "utf8"), options: [{ repoRoot }] }],
invalid: [],
});
});
it("fires when bus.publish event name is not in manifest", () => {
const { repoRoot, useCaseFile } = makeFixture({
manifestUseCases: { signUp: { mutates: true, audits: [], publishes: [] } },
useCaseBody: `export const signUpUseCase = (bus) => async () => { bus.publish("demo.signed-up", {}); };`,
});
tester.run("no-undeclared-event-publish", rule, {
valid: [],
invalid: [{
filename: useCaseFile,
code: fs.readFileSync(useCaseFile, "utf8"),
options: [{ repoRoot }],
errors: [{ messageId: "undeclared", data: { event: "demo.signed-up", useCase: "signUp" } }],
}],
});
});
it("is a no-op when bus.publish is called with a non-literal argument", () => {
const { repoRoot, useCaseFile } = makeFixture({
manifestUseCases: { signUp: { mutates: true, audits: [], publishes: [] } },
useCaseBody: `export const signUpUseCase = (bus, name) => async () => { bus.publish(name, {}); };`,
});
tester.run("no-undeclared-event-publish", rule, {
valid: [{ filename: useCaseFile, code: fs.readFileSync(useCaseFile, "utf8"), options: [{ repoRoot }] }],
invalid: [],
});
});
});
```
- [ ] **Step 2: Run test to verify it fails**
```
pnpm --filter @repo/core-eslint test no-undeclared-event-publish
```
- [ ] **Step 3: Write `no-undeclared-event-publish.js`**
```js
import { parseManifestUseCases } from "./_manifest-ast.js";
import { useCaseNameFromFile } from "./_usecase-name.js";
import { manifestPathForFeature, featureRootForFile } from "./_manifest-source.js";
/** @type {import("eslint").Rule.RuleModule} */
export default {
meta: {
type: "problem",
docs: {
description:
"bus.publish(\"X\") inside a use-case factory must declare X in manifest.useCases[name].publishes.",
},
schema: [
{
type: "object",
properties: { repoRoot: { type: "string" } },
additionalProperties: false,
},
],
messages: {
undeclared:
"{{useCase}} calls bus.publish(\"{{event}}\") but {{event}} is not declared in manifest.useCases.{{useCase}}.publishes. Add it to the manifest or remove the call.",
},
},
create(context) {
const opts = context.options[0] ?? {};
const repoRoot = opts.repoRoot ?? context.cwd ?? process.cwd();
const filename = context.filename;
const useCaseName = useCaseNameFromFile(filename);
if (!useCaseName) return {};
const featureRoot = featureRootForFile(filename, repoRoot);
if (!featureRoot) return {};
const manifest = parseManifestUseCases(manifestPathForFeature(featureRoot));
if (!manifest || !manifest[useCaseName]) return {};
const declared = new Set(manifest[useCaseName].publishes);
return {
CallExpression(node) {
if (
node.callee.type === "MemberExpression" &&
node.callee.object.type === "Identifier" &&
node.callee.object.name === "bus" &&
node.callee.property.type === "Identifier" &&
node.callee.property.name === "publish" &&
node.arguments.length > 0 &&
node.arguments[0].type === "Literal" &&
typeof node.arguments[0].value === "string"
) {
const event = node.arguments[0].value;
if (!declared.has(event)) {
context.report({ node, messageId: "undeclared", data: { event, useCase: useCaseName } });
}
}
},
};
},
};
```
- [ ] **Step 4: Run tests**
```
pnpm --filter @repo/core-eslint test no-undeclared-event-publish
```
Expected: PASS, 3 tests.
- [ ] **Step 5: Commit**
```bash
git add packages/core-eslint/rules/no-undeclared-event-publish.js packages/core-eslint/rules/no-undeclared-event-publish.test.js
git commit -m "feat(core-eslint): no-undeclared-event-publish rule"
```
---
## Task 5: `no-undeclared-audit` rule
Fires on `auditLog.record({ type: "Y", ... })` calls when `Y` is not in `manifest.useCases[name].audits`.
**Files:**
- Create: `packages/core-eslint/rules/no-undeclared-audit.js`
- Create: `packages/core-eslint/rules/no-undeclared-audit.test.js`
- [ ] **Step 1: Write the failing test**
```js
import { describe, it } from "vitest";
import { RuleTester } from "eslint";
import path from "node:path";
import os from "node:os";
import fs from "node:fs";
import rule from "./no-undeclared-audit.js";
function makeFixture({ manifestUseCases, useCaseBody }) {
const repoRoot = fs.mkdtempSync(path.join(os.tmpdir(), "nua-"));
const featureDir = path.join(repoRoot, "packages", "demo");
fs.mkdirSync(path.join(featureDir, "src", "application", "use-cases"), { recursive: true });
const useCasesObj = Object.entries(manifestUseCases)
.map(([name, uc]) =>
` ${name}: { mutates: ${uc.mutates}, audits: [${uc.audits.map((a) => `"${a}"`).join(", ")}], publishes: [], consumes: [] },`,
)
.join("\n");
fs.writeFileSync(
path.join(featureDir, "src", "feature.manifest.ts"),
`export const demoManifest = defineFeature({
name: "demo",
requiredCores: [],
useCases: {
${useCasesObj}
},
realtimeChannels: [],
jobs: [],
} as const);`,
);
const useCaseFile = path.join(featureDir, "src", "application", "use-cases", "sign-up.use-case.ts");
fs.writeFileSync(useCaseFile, useCaseBody);
return { repoRoot, useCaseFile };
}
const tester = new RuleTester({
languageOptions: {
parser: await import("@typescript-eslint/parser"),
ecmaVersion: "latest",
sourceType: "module",
},
});
describe("no-undeclared-audit", () => {
it("passes when auditLog.record type matches manifest audits[]", () => {
const { repoRoot, useCaseFile } = makeFixture({
manifestUseCases: { signUp: { mutates: true, audits: ["user.created"] } },
useCaseBody: `export const signUpUseCase = (auditLog) => async () => { auditLog.record({ type: "user.created", subject: "x", actor: "y" }); };`,
});
tester.run("no-undeclared-audit", rule, {
valid: [{ filename: useCaseFile, code: fs.readFileSync(useCaseFile, "utf8"), options: [{ repoRoot }] }],
invalid: [],
});
});
it("fires when auditLog.record type is not in manifest", () => {
const { repoRoot, useCaseFile } = makeFixture({
manifestUseCases: { signUp: { mutates: true, audits: [] } },
useCaseBody: `export const signUpUseCase = (auditLog) => async () => { auditLog.record({ type: "user.created", subject: "x" }); };`,
});
tester.run("no-undeclared-audit", rule, {
valid: [],
invalid: [{
filename: useCaseFile,
code: fs.readFileSync(useCaseFile, "utf8"),
options: [{ repoRoot }],
errors: [{ messageId: "undeclared", data: { event: "user.created", useCase: "signUp" } }],
}],
});
});
it("is a no-op when auditLog.record is called with a non-literal type", () => {
const { repoRoot, useCaseFile } = makeFixture({
manifestUseCases: { signUp: { mutates: true, audits: [] } },
useCaseBody: `export const signUpUseCase = (auditLog, type) => async () => { auditLog.record({ type, subject: "x" }); };`,
});
tester.run("no-undeclared-audit", rule, {
valid: [{ filename: useCaseFile, code: fs.readFileSync(useCaseFile, "utf8"), options: [{ repoRoot }] }],
invalid: [],
});
});
});
```
- [ ] **Step 2: Run test to verify it fails**
- [ ] **Step 3: Write `no-undeclared-audit.js`**
```js
import { parseManifestUseCases } from "./_manifest-ast.js";
import { useCaseNameFromFile } from "./_usecase-name.js";
import { manifestPathForFeature, featureRootForFile } from "./_manifest-source.js";
/** @type {import("eslint").Rule.RuleModule} */
export default {
meta: {
type: "problem",
docs: {
description:
"auditLog.record({ type: \"X\" }) inside a use-case factory must declare X in manifest.useCases[name].audits.",
},
schema: [
{
type: "object",
properties: { repoRoot: { type: "string" } },
additionalProperties: false,
},
],
messages: {
undeclared:
"{{useCase}} calls auditLog.record with type \"{{event}}\" but {{event}} is not declared in manifest.useCases.{{useCase}}.audits. Add it to the manifest or remove the call.",
},
},
create(context) {
const opts = context.options[0] ?? {};
const repoRoot = opts.repoRoot ?? context.cwd ?? process.cwd();
const filename = context.filename;
const useCaseName = useCaseNameFromFile(filename);
if (!useCaseName) return {};
const featureRoot = featureRootForFile(filename, repoRoot);
if (!featureRoot) return {};
const manifest = parseManifestUseCases(manifestPathForFeature(featureRoot));
if (!manifest || !manifest[useCaseName]) return {};
const declared = new Set(manifest[useCaseName].audits);
return {
CallExpression(node) {
if (
node.callee.type === "MemberExpression" &&
node.callee.object.type === "Identifier" &&
node.callee.object.name === "auditLog" &&
node.callee.property.type === "Identifier" &&
node.callee.property.name === "record" &&
node.arguments.length > 0 &&
node.arguments[0].type === "ObjectExpression"
) {
const typeProp = node.arguments[0].properties.find(
(p) =>
p.type === "Property" &&
p.key.type === "Identifier" &&
p.key.name === "type",
);
if (
typeProp &&
typeProp.value.type === "Literal" &&
typeof typeProp.value.value === "string"
) {
const event = typeProp.value.value;
if (!declared.has(event)) {
context.report({ node, messageId: "undeclared", data: { event, useCase: useCaseName } });
}
}
}
},
};
},
};
```
- [ ] **Step 4: Run tests**
```
pnpm --filter @repo/core-eslint test no-undeclared-audit
```
Expected: PASS, 3 tests.
- [ ] **Step 5: Commit**
```bash
git add packages/core-eslint/rules/no-undeclared-audit.js packages/core-eslint/rules/no-undeclared-audit.test.js
git commit -m "feat(core-eslint): no-undeclared-audit rule"
```
---
## Task 6: Wire new rules into plugin + base.js
**Files:**
- Modify: `packages/core-eslint/plugin.js`
- Modify: `packages/core-eslint/base.js`
- [ ] **Step 1: Update plugin.js**
Add two imports near the existing rule imports:
```js
import noUndeclaredEventPublish from "./rules/no-undeclared-event-publish.js";
import noUndeclaredAudit from "./rules/no-undeclared-audit.js";
```
Add two entries in the `rules` object:
```js
"no-undeclared-event-publish": noUndeclaredEventPublish,
"no-undeclared-audit": noUndeclaredAudit,
```
Bump the plugin version to `0.2.0`.
- [ ] **Step 2: Update base.js**
In the conformance plugin block, add two more rule entries (after `required-cores-installed`):
```js
"conformance/no-undeclared-event-publish": ["warn", { repoRoot }],
"conformance/no-undeclared-audit": ["warn", { repoRoot }],
```
WARN initially because the rules are new — flip to ERROR after a few days of clean runs.
- [ ] **Step 3: Verify the eslint config still parses**
```
pnpm --filter @repo/core-eslint exec eslint --print-config base.js > /dev/null
```
- [ ] **Step 4: Commit**
```bash
git add packages/core-eslint/plugin.js packages/core-eslint/base.js
git commit -m "feat(core-eslint): wire AST-aware conformance rules into plugin + base"
```
---
## Task 7: Verify `pnpm lint` passes
```
pnpm lint
```
Expected: 0 errors. New rules WARN by default; auth's manifest has empty publishes/audits so no false positives expected today.
If any errors appear, STOP and report.
---
## Task 8: Final verification + closeout
**Files:**
- Modify: `docs/work/conformance-system-v1/03-b-ast-eslint-rules/_story.md`
- Modify: `docs/work/conformance-system-v1/_epic.md`
- [ ] **Step 1: Run verification matrix**
```
pnpm typecheck
pnpm test
pnpm lint
pnpm turbo boundaries
```
All four PASS (warnings OK).
- [ ] **Step 2: Tick story 03.b checkboxes + flip status to done**
Edit `docs/work/conformance-system-v1/03-b-ast-eslint-rules/_story.md`:
- frontmatter `status: in-progress``status: done`
- All `- [ ]``- [x]` in Tasks section
- [ ] **Step 3: Tick 03.b in epic + tick parent 03**
Edit `docs/work/conformance-system-v1/_epic.md`. The current structure is:
```markdown
- [ ] 03 — AST-aware ESLint rules (continuing — see 03.a + future 03.b)
- [x] [03.a — Structural rules](03-a-structural-eslint-rules/_story.md)
- [ ] 03.b — Manifest-aware AST rules (later plan)
```
Replace with:
```markdown
- [x] 03 — AST-aware ESLint rules (both halves shipped)
- [x] [03.a — Structural rules](03-a-structural-eslint-rules/_story.md)
- [x] [03.b — Manifest-aware AST rules](03-b-ast-eslint-rules/_story.md)
```
- [ ] **Step 4: Commit**
```bash
git add docs/work/conformance-system-v1/03-b-ast-eslint-rules/_story.md docs/work/conformance-system-v1/_epic.md
git commit -m "docs(work): close story 03.b — AST-aware ESLint rules"
```

View File

@@ -0,0 +1,336 @@
# Conformance Milestone iv — CI drift gate
**Goal:** Ship `pnpm conformance` — a cross-feature drift check that aggregates ALL feature manifests and fails on an orphan event consumer (a manifest declares `consumes: ["X"]` but no other manifest declares `publishes: ["X"]`). Wired into turbo.json and GitHub Actions CI.
**Architecture:** A small Node.js script (`scripts/conformance.mjs`) walks `packages/*/src/feature.manifest.ts` files, reuses the iii.b AST parser (`parseManifestUseCases`), builds global publish/consume sets across all features, exits non-zero on orphan consumers. Turbo task + CI step wire it into the normal validate pipeline.
---
## Tasks
### Task 1: Story 04 scaffold
Create `docs/work/conformance-system-v1/04-ci-drift-gate/_story.md`:
```markdown
---
id: 04-ci-drift-gate
epic: conformance-system-v1
title: CI drift gate — pnpm conformance with cross-feature event closure
type: technical-story
status: in-progress
feature: scripts
depends-on: [03-b-ast-eslint-rules]
blocks: [05-generator-updates]
---
## Goal
`pnpm conformance` aggregates cross-feature checks that no single-file
ESLint rule can perform — most importantly, event closure: every event
declared in any manifest's `consumes` must have at least one matching
`publishes` somewhere in the repo.
## Why
Per-file lint can't see cross-feature contracts. Without this gate, a
feature can declare it consumes `X` while no feature publishes `X`
silent until the broken handler is exercised in prod.
## Done when
- `pnpm conformance` exits 0 when manifests are consistent; non-zero
with a clear error message on orphan consumers
- Wired into `turbo.json` as the `conformance` task
- Wired into `.github/workflows/ci.yml` after `pnpm lint`
## In scope
- `scripts/conformance.mjs` — orphan-consumer check
- Tests via vitest
- turbo.json + CI wiring
## Out of scope
- Scaffold drift check (regenerate via `turbo gen feature`, diff against
on-disk state) — depends on generator updates landing
- Repository write outside use-cases check — separate concern
- Reverse check (orphan publishers — events nothing consumes) — many
events are intentionally "fire and forget"; not a closure violation
## Tasks
- [ ] Story scaffold
- [ ] `scripts/conformance.mjs` implementation
- [ ] Tests for the script
- [ ] Wire into root package.json + turbo.json
- [ ] Wire into ci.yml
- [ ] Final verification + closeout
```
Commit: `docs(work): story 04 — CI drift gate`.
### Task 2: `scripts/conformance.mjs` + tests
Create the script and its test alongside.
`scripts/conformance.mjs`:
```js
#!/usr/bin/env node
/**
* pnpm conformance — cross-feature drift gate.
*
* Walks every `packages/*\/src/feature.manifest.ts`, reuses the AST parser
* from `@repo/core-eslint` to extract per-use-case publishes/consumes,
* builds global publish + consume sets across all features, and fails on:
*
* - Orphan consumer: a feature declares `consumes: ["X"]` but no
* feature publishes "X".
*
* Exits 0 on success, 1 on any violation. Prints a tabular summary of
* the event graph for transparency.
*/
import fs from "node:fs";
import path from "node:path";
import { fileURLToPath } from "node:url";
import { parseManifestUseCases } from "../packages/core-eslint/rules/_manifest-ast.js";
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const REPO_ROOT = path.resolve(__dirname, "..");
export function findAllManifests(repoRoot = REPO_ROOT) {
const packagesDir = path.join(repoRoot, "packages");
if (!fs.existsSync(packagesDir)) return [];
const out = [];
for (const entry of fs.readdirSync(packagesDir)) {
const manifestPath = path.join(packagesDir, entry, "src", "feature.manifest.ts");
if (fs.existsSync(manifestPath)) {
out.push({ feature: entry, path: manifestPath });
}
}
return out;
}
export function buildEventGraph(manifests) {
// event -> { publishers: [{ feature, useCase }], consumers: [{ feature, useCase }] }
const graph = new Map();
for (const { feature, path: manifestPath } of manifests) {
const useCases = parseManifestUseCases(manifestPath);
if (!useCases) continue;
for (const [useCase, entry] of Object.entries(useCases)) {
for (const event of entry.publishes) {
if (!graph.has(event)) graph.set(event, { publishers: [], consumers: [] });
graph.get(event).publishers.push({ feature, useCase });
}
for (const event of entry.consumes) {
if (!graph.has(event)) graph.set(event, { publishers: [], consumers: [] });
graph.get(event).consumers.push({ feature, useCase });
}
}
}
return graph;
}
export function findOrphanConsumers(graph) {
const orphans = [];
for (const [event, { publishers, consumers }] of graph.entries()) {
if (consumers.length > 0 && publishers.length === 0) {
orphans.push({ event, consumers });
}
}
return orphans;
}
function main() {
const manifests = findAllManifests();
console.log(`Found ${manifests.length} feature manifest(s):`);
for (const { feature } of manifests) console.log(` - ${feature}`);
console.log();
const graph = buildEventGraph(manifests);
if (graph.size === 0) {
console.log("No cross-feature events declared yet — nothing to check.");
process.exit(0);
}
console.log(`Event graph (${graph.size} event(s)):`);
for (const [event, { publishers, consumers }] of graph.entries()) {
console.log(` ${event}`);
console.log(` publishers: ${publishers.length === 0 ? "(none)" : publishers.map((p) => `${p.feature}.${p.useCase}`).join(", ")}`);
console.log(` consumers: ${consumers.length === 0 ? "(none)" : consumers.map((c) => `${c.feature}.${c.useCase}`).join(", ")}`);
}
console.log();
const orphans = findOrphanConsumers(graph);
if (orphans.length === 0) {
console.log("✓ pnpm conformance — passed");
process.exit(0);
}
console.error(`✗ pnpm conformance — ${orphans.length} orphan consumer(s):`);
for (const { event, consumers } of orphans) {
console.error(` ${event}`);
for (const c of consumers) {
console.error(` consumed by ${c.feature}.${c.useCase}, but no feature publishes it`);
}
}
process.exit(1);
}
// Only run main when invoked as a script (not when imported by tests).
if (import.meta.url === `file://${process.argv[1]}`) {
main();
}
```
`scripts/conformance.test.mjs`:
```js
import { describe, it, expect } from "vitest";
import path from "node:path";
import os from "node:os";
import fs from "node:fs";
import { findAllManifests, buildEventGraph, findOrphanConsumers } from "./conformance.mjs";
function makeRepo({ features }) {
const root = fs.mkdtempSync(path.join(os.tmpdir(), "conformance-"));
for (const [name, useCases] of Object.entries(features)) {
const dir = path.join(root, "packages", name, "src");
fs.mkdirSync(dir, { recursive: true });
const useCasesStr = Object.entries(useCases)
.map(([ucName, uc]) =>
` ${ucName}: { mutates: ${uc.mutates ?? false}, audits: [], publishes: [${(uc.publishes ?? []).map((p) => `"${p}"`).join(", ")}], consumes: [${(uc.consumes ?? []).map((c) => `"${c}"`).join(", ")}] },`,
)
.join("\n");
fs.writeFileSync(
path.join(dir, "feature.manifest.ts"),
`export const ${name}Manifest = defineFeature({
name: "${name}",
requiredCores: [],
useCases: {
${useCasesStr}
},
realtimeChannels: [],
jobs: [],
} as const);`,
);
}
return root;
}
describe("conformance script", () => {
describe("findAllManifests", () => {
it("returns one entry per feature with a manifest", () => {
const root = makeRepo({
auth: { signIn: {} },
blog: { getArticles: {} },
});
const ms = findAllManifests(root);
expect(ms.map((m) => m.feature).sort()).toEqual(["auth", "blog"]);
});
it("skips packages without a manifest", () => {
const root = fs.mkdtempSync(path.join(os.tmpdir(), "conformance-empty-"));
fs.mkdirSync(path.join(root, "packages", "no-manifest", "src"), { recursive: true });
expect(findAllManifests(root)).toEqual([]);
});
});
describe("buildEventGraph + findOrphanConsumers", () => {
it("finds zero orphans when consumers and publishers line up", () => {
const root = makeRepo({
auth: { signUp: { mutates: true, publishes: ["auth.signed-up"] } },
marketing: { onAuthSignedUp: { consumes: ["auth.signed-up"] } },
});
const manifests = findAllManifests(root);
const graph = buildEventGraph(manifests);
expect(findOrphanConsumers(graph)).toEqual([]);
});
it("flags orphan consumers", () => {
const root = makeRepo({
marketing: { onAuthSignedUp: { consumes: ["auth.signed-up"] } },
});
const manifests = findAllManifests(root);
const graph = buildEventGraph(manifests);
const orphans = findOrphanConsumers(graph);
expect(orphans).toHaveLength(1);
expect(orphans[0].event).toBe("auth.signed-up");
expect(orphans[0].consumers).toEqual([{ feature: "marketing", useCase: "onAuthSignedUp" }]);
});
it("treats publish-only events as fine (no consumers is not an orphan)", () => {
const root = makeRepo({
auth: { signUp: { mutates: true, publishes: ["auth.signed-up"] } },
});
const manifests = findAllManifests(root);
const graph = buildEventGraph(manifests);
expect(findOrphanConsumers(graph)).toEqual([]);
});
});
});
```
Commits:
1. `git add scripts/conformance.mjs scripts/conformance.test.mjs && git commit -m "feat(scripts): conformance drift gate + tests"`
(One commit for both because they're trivially related and the test file is part of the script's interface.)
### Task 3: Wire `pnpm conformance` + turbo task
Modify root `package.json`. Add this script entry (alongside existing scripts):
```json
"conformance": "node scripts/conformance.mjs"
```
Modify `turbo.json`. Add this task (alongside existing `lint`, `test`, etc.):
```json
"conformance": {
"inputs": [
"packages/*/src/feature.manifest.ts",
"scripts/conformance.mjs",
"packages/core-eslint/rules/_manifest-ast.js"
],
"outputs": []
}
```
Verify by running `pnpm conformance`. With auth as the only manifest (no publishes, no consumes), output should report "No cross-feature events declared yet — nothing to check." and exit 0.
Also run the vitest:
```
pnpm vitest run scripts/conformance.test.mjs
```
5 tests should pass.
Commit: `feat: wire pnpm conformance script + turbo task`.
### Task 4: Wire conformance into CI
Modify `.github/workflows/ci.yml`. Find the step `- run: pnpm lint`. Add immediately after it:
```yaml
- run: pnpm conformance
```
Commit: `ci: add conformance step after lint`.
### Task 5: Final verification + closeout
Run:
```
pnpm typecheck
pnpm test
pnpm lint
pnpm conformance
pnpm turbo boundaries
```
All pass.
Update `docs/work/conformance-system-v1/04-ci-drift-gate/_story.md`:
- frontmatter `status: in-progress``done`
- 6 task checkboxes → all `- [x]`
Update `docs/work/conformance-system-v1/_epic.md`:
- Find `- [ ] 04 — CI drift gate (later plan)`
- Replace with `- [x] [04 — CI drift gate](04-ci-drift-gate/_story.md)`
Commit: `docs(work): close story 04 — CI drift gate`.

View File

@@ -0,0 +1,213 @@
# Conformance Milestone v — Generator updates
**Goal:** `pnpm turbo gen feature <name>` now emits a `feature.manifest.ts` declaring the scaffolded use case, and the scaffolded `bind-production.ts` calls `assertFeatureConformance` at its tail. New features are conformance-compliant from creation.
**Architecture:** Two template additions/changes in `turbo/generators/templates/feature/`:
1. NEW: `src/feature.manifest.ts.hbs` — emits `defineFeature({...})` with the scaffolded use case declared
2. UPDATE: `src/di/bind-production.ts.hbs` — adds imports for `assertFeatureConformance` + `<name>Manifest` and calls the assertion at the tail
Plus one `config.ts` action to emit the manifest template, and snapshot regeneration if the existing tests snapshot the full file tree.
---
## Tasks
### Task 1: Story 05 scaffold
Create `docs/work/conformance-system-v1/05-generator-updates/_story.md`:
```markdown
---
id: 05-generator-updates
epic: conformance-system-v1
title: Generator updates — emit feature.manifest.ts + self-asserting bind-production
type: technical-story
status: in-progress
feature: turbo-generators
depends-on: [04-ci-drift-gate]
blocks: [06-feature-migrations]
---
## Goal
`pnpm turbo gen feature <name>` produces a feature that's conformance-
compliant out of the box: ships a `feature.manifest.ts` declaring the
scaffolded use case, and the scaffolded `bind-production.ts` calls
`assertFeatureConformance` at its tail.
## Why
Today, generating a new feature does NOT emit a manifest, so
`feature-must-have-manifest` would warn the moment the developer saves.
And `bindProductionX` doesn't self-assert. New features should be
conformance-ready by default.
## Done when
- `feature.manifest.ts.hbs` template exists, declaring the scaffolded
`getX` use case with mutates: false / empty arrays
- `bind-production.ts.hbs` imports `assertFeatureConformance` +
`<name>Manifest` and calls the assertion at the tail
- `config.ts` action emits the manifest file
- Running `pnpm turbo gen feature demo` into a tmp dir produces a
feature directory containing a valid `feature.manifest.ts`
- Existing snapshot tests pass (regenerate snapshots if needed)
## In scope
- Manifest template
- bind-production template update
- config.ts action wiring
- Snapshot regen
## Out of scope
- Multi-use-case feature scaffolding
- Generator changes to other generators (event/job/realtime)
- Backfilling manifests for existing features (milestone vi)
## Tasks
- [ ] Story scaffold
- [ ] feature.manifest.ts.hbs template
- [ ] bind-production.ts.hbs update
- [ ] config.ts action wiring
- [ ] Run generator into tmp; verify shape
- [ ] Snapshot regen (if needed) + verify tests pass
- [ ] Final verification + closeout
```
```bash
git add docs/work/conformance-system-v1/05-generator-updates/_story.md
git commit -m "docs(work): story 05 — generator updates"
```
### Task 2: `feature.manifest.ts.hbs` template
Create `turbo/generators/templates/feature/src/feature.manifest.ts.hbs`:
```hbs
import { defineFeature } from "@repo/core-shared/conformance";
/**
* The {{camelCase name}} feature's conformance manifest. Drives binding-slot
* types in `di/bind-production.ts` and is read by ESLint, the boot
* assertion, and the CI drift gate.
*
* Conventions:
* - `mutates: true` for any use case that creates, updates, or deletes state
* - `audits` lists every audit event the use case emits (must match calls
* to `auditLog.record(...)` in the factory body — ESLint enforces this)
* - `publishes` / `consumes` cover cross-feature events through `IEventBus`
*/
export const {{camelCase name}}Manifest = defineFeature({
name: "{{kebabCase name}}",
requiredCores: [],
useCases: {
get{{pascalCase entity}}: {
mutates: false,
audits: [],
publishes: [],
consumes: [],
},
},
realtimeChannels: [],
jobs: [],
} as const);
export type {{pascalCase name}}Manifest = typeof {{camelCase name}}Manifest;
```
(No test file for the template itself; the generator's existing snapshot tests will cover that the file is emitted with expected content.)
### Task 3: `bind-production.ts.hbs` update — call assertFeatureConformance at tail
Modify `turbo/generators/templates/feature/src/di/bind-production.ts.hbs`. Add three new imports at the top (after the existing imports):
```hbs
import { assertFeatureConformance } from "@repo/core-shared/conformance";
import { {{camelCase name}}Manifest } from "../feature.manifest";
```
At the very end of the `bindProduction{{pascalCase name}}` function body — AFTER the `// <gen:event-handlers>`, `// <gen:jobs>`, `// <gen:realtime-handlers>` anchor comments and BEFORE the closing `}` — add:
```hbs
// Boot-time conformance check: refuses to start if any use-case binding
// is missing a required brand (withSpan / withCapture / withAudit).
assertFeatureConformance(
{{camelCase name}}Container,
{{camelCase name}}Manifest,
{
get{{pascalCase entity}}: {{constantCase name}}_SYMBOLS.IGet{{pascalCase entity}}UseCase,
},
ctx,
);
```
### Task 4: `config.ts` action wiring — emit the manifest
Open `turbo/generators/config.ts`. Find the `actions` array for the `feature` generator. After the action that emits `src/index.ts` (or in the appropriate position with other `src/` files), add an action to emit `src/feature.manifest.ts` from the new template:
```ts
{
type: "add",
path: "{{ turbo_paths.root }}/packages/{{kebabCase name}}/src/feature.manifest.ts",
templateFile: "templates/feature/src/feature.manifest.ts.hbs",
},
```
If the existing actions use a different path-resolution pattern (e.g., `path.join(...)`), match that pattern. Read the surrounding actions before adding.
### Task 5: Run the generator into tmp + verify
Run a generator invocation against a tmp dir to confirm the output:
```bash
cd /tmp && rm -rf gen-test && mkdir gen-test && cd gen-test
# Initialize an empty turbo repo-like structure:
mkdir -p packages
ln -s /Users/danijel/Documents/Projects/template-vertical/.claude/worktrees/conformance-milestone-v/turbo .
```
Actually, the simpler path: run the existing snapshot tests, which already exercise the generator:
```
pnpm --filter @repo/turbo-generators test
```
If they pass (likely with snapshot mismatches because we added a new file), regenerate snapshots:
```
pnpm --filter @repo/turbo-generators test -- -u
```
Verify the regenerated snapshots include `src/feature.manifest.ts` and the updated bind-production with the assertion at the tail.
Commit the regenerated snapshots:
```bash
git add turbo/generators/__snapshots__/ turbo/generators/templates/ turbo/generators/config.ts
git commit -m "feat(generators): emit feature.manifest.ts + self-asserting bind-production"
```
### Task 6: Final verification + closeout
Run:
```
pnpm typecheck
pnpm test
pnpm lint
pnpm conformance
pnpm turbo boundaries
```
All five pass.
Update `docs/work/conformance-system-v1/05-generator-updates/_story.md`:
- frontmatter `status: in-progress``done`
- All `- [ ]``- [x]`
Update `docs/work/conformance-system-v1/_epic.md`. Find:
```markdown
- [ ] 05 — Generator emits manifest + contracts + test stubs (later plan)
```
Replace with:
```markdown
- [x] [05 — Generator updates](05-generator-updates/_story.md)
```
Commit: `docs(work): close story 05 — generator updates`.

View File

@@ -0,0 +1,349 @@
# Conformance Milestone vi — Feature migrations
**Goal:** Backfill manifests + self-asserting `bind-production` for the remaining four features (`blog`, `media`, `navigation`, `marketing-pages`). After this milestone, every feature is conformance-compliant; `feature-must-have-manifest` flips from WARN to ERROR.
**Architecture:** For each feature: create `src/feature.manifest.ts` declaring its use cases (with `mutates: true` for state-changing ops like createArticle / deleteMedia; rest read-only; all with empty audits/publishes/consumes — none of the current use-case bodies have literal-string `bus.publish(...)` or `auditLog.record({type: "..."})` calls, so empty arrays are correct). Re-export from feature `index.ts`. Update each `bind-production.ts` to import `assertFeatureConformance` + `<name>Manifest` and call the assertion at the tail with the appropriate symbol map.
**Inventory:**
- `blog` — useCases: `getArticles` (read), `createArticle` (mutate), `getArticleBySlug` (read)
- `media` — useCases: `getMedia` (read), `listMedia` (read), `deleteMedia` (mutate)
- `navigation` — useCases: `getHeader` (read)
- `marketing-pages` — useCases: `getPageBySlug` (read), `getSiteSettings` (read)
---
## Tasks
### Task 1: Story 06 scaffold
Create `docs/work/conformance-system-v1/06-feature-migrations/_story.md`:
```markdown
---
id: 06-feature-migrations
epic: conformance-system-v1
title: Migrate blog/media/navigation/marketing-pages to conformance pattern
type: technical-story
status: in-progress
feature: <cross-feature>
depends-on: [05-generator-updates]
blocks: []
---
## Goal
Every feature in the repo has a manifest + a self-asserting bindProduction.
After this milestone, `feature-must-have-manifest` flips from WARN to ERROR.
## Why
Three of the four enforcement layers already exist; the only thing keeping
them from being fully effective is that 4 of 5 features still don't have
manifests. This story closes that gap.
## In scope
- `feature.manifest.ts` for blog / media / navigation / marketing-pages
- Manifest re-export from each feature's `src/index.ts`
- `bind-production.ts` update for each: imports + tail `assertFeatureConformance` call
- Flip `feature-must-have-manifest` from `warn` to `error` in base.js
## Out of scope
- Adding publishes/audits to existing use cases (declared empty for now — the
existing `auth.signUp` `bus.publish(userSignedUpEvent, ...)` uses a non-literal
event name so the rule doesn't fire; other features have no publish/record calls)
- Cross-feature event wiring (blog→marketing welcome flows, etc.)
- Migrating realtime channels or jobs into manifests
## Tasks
- [ ] Story scaffold
- [ ] blog manifest + binding + re-export
- [ ] media manifest + binding + re-export
- [ ] navigation manifest + binding + re-export
- [ ] marketing-pages manifest + binding + re-export
- [ ] Flip feature-must-have-manifest to error
- [ ] Final verification + closeout
```
Commit: `docs(work): story 06 — feature migrations`.
### Task 2: Migrate blog
Create `packages/blog/src/feature.manifest.ts`:
```ts
import { defineFeature } from "@repo/core-shared/conformance";
/**
* The blog feature's conformance manifest.
*/
export const blogManifest = defineFeature({
name: "blog",
requiredCores: [],
useCases: {
getArticles: {
mutates: false,
audits: [],
publishes: [],
consumes: [],
},
getArticleBySlug: {
mutates: false,
audits: [],
publishes: [],
consumes: [],
},
createArticle: {
mutates: true,
audits: [],
publishes: [],
consumes: [],
},
},
realtimeChannels: [],
jobs: [],
} as const);
export type BlogManifest = typeof blogManifest;
```
Re-export from `packages/blog/src/index.ts` — append at the bottom:
```ts
export { blogManifest, type BlogManifest } from "./feature.manifest";
```
Update `packages/blog/src/di/bind-production.ts`. Read the file. Add these two imports near the existing imports:
```ts
import { assertFeatureConformance } from "@repo/core-shared/conformance";
import { blogManifest } from "../feature.manifest";
```
At the end of the `bindProductionBlog(ctx)` function body — after the existing `// <gen:*>` anchors, before the closing `}` — add:
```ts
// Boot-time conformance check.
assertFeatureConformance(
blogContainer,
blogManifest,
{
getArticles: BLOG_SYMBOLS.IGetArticlesUseCase,
getArticleBySlug: BLOG_SYMBOLS.IGetArticleBySlugUseCase,
createArticle: BLOG_SYMBOLS.ICreateArticleUseCase,
},
ctx,
);
```
(The container variable name might be `blogContainer` — verify by reading the existing file; if it's named differently, use the actual name.)
Verify:
```
pnpm --filter @repo/blog typecheck
pnpm --filter @repo/blog test
```
Commit:
```bash
git add packages/blog/src/feature.manifest.ts packages/blog/src/index.ts packages/blog/src/di/bind-production.ts
git commit -m "feat(blog): conformance manifest + self-asserting bind-production"
```
### Task 3: Migrate media
Same shape. Create `packages/media/src/feature.manifest.ts`:
```ts
import { defineFeature } from "@repo/core-shared/conformance";
export const mediaManifest = defineFeature({
name: "media",
requiredCores: [],
useCases: {
getMedia: { mutates: false, audits: [], publishes: [], consumes: [] },
listMedia: { mutates: false, audits: [], publishes: [], consumes: [] },
deleteMedia: { mutates: true, audits: [], publishes: [], consumes: [] },
},
realtimeChannels: [],
jobs: [],
} as const);
export type MediaManifest = typeof mediaManifest;
```
Append to `packages/media/src/index.ts`:
```ts
export { mediaManifest, type MediaManifest } from "./feature.manifest";
```
Update `packages/media/src/di/bind-production.ts` — add imports and tail call:
```ts
import { assertFeatureConformance } from "@repo/core-shared/conformance";
import { mediaManifest } from "../feature.manifest";
```
End of function body:
```ts
assertFeatureConformance(
mediaContainer,
mediaManifest,
{
getMedia: MEDIA_SYMBOLS.IGetMediaUseCase,
listMedia: MEDIA_SYMBOLS.IListMediaUseCase,
deleteMedia: MEDIA_SYMBOLS.IDeleteMediaUseCase,
},
ctx,
);
```
Verify + commit:
```bash
pnpm --filter @repo/media typecheck && pnpm --filter @repo/media test
git add packages/media/src/feature.manifest.ts packages/media/src/index.ts packages/media/src/di/bind-production.ts
git commit -m "feat(media): conformance manifest + self-asserting bind-production"
```
### Task 4: Migrate navigation
`packages/navigation/src/feature.manifest.ts`:
```ts
import { defineFeature } from "@repo/core-shared/conformance";
export const navigationManifest = defineFeature({
name: "navigation",
requiredCores: [],
useCases: {
getHeader: { mutates: false, audits: [], publishes: [], consumes: [] },
},
realtimeChannels: [],
jobs: [],
} as const);
export type NavigationManifest = typeof navigationManifest;
```
Append to `packages/navigation/src/index.ts`:
```ts
export { navigationManifest, type NavigationManifest } from "./feature.manifest";
```
Update `packages/navigation/src/di/bind-production.ts`. Imports:
```ts
import { assertFeatureConformance } from "@repo/core-shared/conformance";
import { navigationManifest } from "../feature.manifest";
```
Tail of function:
```ts
assertFeatureConformance(
navigationContainer,
navigationManifest,
{ getHeader: NAVIGATION_SYMBOLS.IGetHeaderUseCase },
ctx,
);
```
Verify + commit.
### Task 5: Migrate marketing-pages
`packages/marketing-pages/src/feature.manifest.ts`:
```ts
import { defineFeature } from "@repo/core-shared/conformance";
export const marketingPagesManifest = defineFeature({
name: "marketing-pages",
requiredCores: [],
useCases: {
getPageBySlug: { mutates: false, audits: [], publishes: [], consumes: [] },
getSiteSettings: { mutates: false, audits: [], publishes: [], consumes: [] },
},
realtimeChannels: [],
jobs: [],
} as const);
export type MarketingPagesManifest = typeof marketingPagesManifest;
```
Append to `packages/marketing-pages/src/index.ts`:
```ts
export { marketingPagesManifest, type MarketingPagesManifest } from "./feature.manifest";
```
Update `packages/marketing-pages/src/di/bind-production.ts`:
```ts
import { assertFeatureConformance } from "@repo/core-shared/conformance";
import { marketingPagesManifest } from "../feature.manifest";
```
Tail:
```ts
assertFeatureConformance(
marketingPagesContainer,
marketingPagesManifest,
{
getPageBySlug: MARKETING_PAGES_SYMBOLS.IGetPageBySlugUseCase,
getSiteSettings: MARKETING_PAGES_SYMBOLS.IGetSiteSettingsUseCase,
},
ctx,
);
```
Note: `MARKETING_PAGES_SYMBOLS` might be a different name in this package — verify by reading the existing `symbols.ts` first and use the actual exported constant name.
Verify + commit.
### Task 6: Flip `feature-must-have-manifest` from WARN to ERROR
Now that all features have manifests, the rule should hard-fail.
Modify `packages/core-eslint/base.js`. Find:
```js
"conformance/feature-must-have-manifest": [
"warn",
{ repoRoot },
],
```
Change `"warn"` to `"error"`. Other rule severities stay as they are.
Run `pnpm lint` to verify still passes (now 0 warnings from feature-must-have-manifest because all features have manifests).
Commit:
```bash
git add packages/core-eslint/base.js
git commit -m "feat(core-eslint): flip feature-must-have-manifest from warn to error"
```
### Task 7: Final verification + closeout
```
pnpm typecheck
pnpm test
pnpm lint
pnpm conformance
pnpm turbo boundaries
```
All pass.
Update `docs/work/conformance-system-v1/06-feature-migrations/_story.md`:
- frontmatter `status: in-progress``done`
- All `- [ ]``- [x]`
Update `docs/work/conformance-system-v1/_epic.md`. Find:
```markdown
- [ ] 07 — Migrate auth feature reference (later plan)
```
Replace with:
```markdown
- [x] [06 — Migrate blog / media / navigation / marketing-pages](06-feature-migrations/_story.md)
- [x] 07 — Migrate auth feature reference (completed inline in milestone i; signIn through ProductionUseCase slot)
```
Also: since this completes the conformance-system-v1 epic's planned stories, flip the epic frontmatter `status: in-progress``status: done`.
Commit: `docs(work): close story 06 + conformance-system-v1 epic`.

414
docs/work/_state.json Normal file
View File

@@ -0,0 +1,414 @@
{
"updated_at": "2026-05-13T06:55:52.068Z",
"epics": {
"agent-workflow-docs-v1": {
"status": "done",
"title": "Agent-workflow docs rollout",
"stories": {
"01-docs-rewrite": {
"status": "done",
"title": "Surface conformance system across top-level docs",
"ac_total": 8,
"ac_completed": 8,
"depends_on": [],
"blocks": []
}
}
},
"conformance-hardening-v1": {
"status": "done",
"title": "Conformance hardening v1 — AST manifest parsing + dev-seed boot assertion",
"stories": {
"01-ast-manifest-source": {
"status": "done",
"title": "Replace regex manifest source parser with AST",
"ac_total": 5,
"ac_completed": 5,
"depends_on": [],
"blocks": [
"02-dev-seed-assertion"
]
},
"02-dev-seed-assertion": {
"status": "done",
"title": "Extend assertFeatureConformance to all bind-dev-seed paths",
"ac_total": 6,
"ac_completed": 6,
"depends_on": [
"01-ast-manifest-source"
],
"blocks": []
}
}
},
"conformance-system-v1": {
"status": "done",
"title": "Conformance system v1",
"stories": {
"01-define-feature-helper": {
"status": "done",
"title": "defineFeature helper + Instrumented/Captured/Audited brands",
"ac_total": 9,
"ac_completed": 9,
"depends_on": [],
"blocks": [
"02-boot-assertions"
]
},
"02-boot-assertions": {
"status": "done",
"title": "assertFeatureConformance + boot wiring",
"ac_total": 11,
"ac_completed": 11,
"depends_on": [
"01-define-feature-helper"
],
"blocks": [
"03-eslint-rules"
]
},
"03-a-structural-eslint-rules": {
"status": "done",
"title": "Structural ESLint rules (feature-must-have-manifest, usecase-must-have-test-file, required-cores-installed)",
"ac_total": 10,
"ac_completed": 10,
"depends_on": [
"02-boot-assertions"
],
"blocks": [
"03-b-ast-aware-eslint-rules"
]
},
"03-b-ast-eslint-rules": {
"status": "done",
"title": "AST-aware ESLint rules (no-undeclared-event-publish, no-undeclared-audit)",
"ac_total": 8,
"ac_completed": 8,
"depends_on": [
"03-a-structural-eslint-rules"
],
"blocks": [
"04-ci-drift-gate"
]
},
"04-ci-drift-gate": {
"status": "done",
"title": "CI drift gate — pnpm conformance with cross-feature event closure",
"ac_total": 6,
"ac_completed": 6,
"depends_on": [
"03-b-ast-eslint-rules"
],
"blocks": [
"05-generator-updates"
]
},
"05-generator-updates": {
"status": "done",
"title": "Generator updates — emit feature.manifest.ts + self-asserting bind-production",
"ac_total": 7,
"ac_completed": 7,
"depends_on": [
"04-ci-drift-gate"
],
"blocks": [
"06-feature-migrations"
]
},
"06-feature-migrations": {
"status": "done",
"title": "Migrate blog/media/navigation/marketing-pages to conformance pattern",
"ac_total": 7,
"ac_completed": 7,
"depends_on": [
"05-generator-updates"
],
"blocks": []
}
}
},
"dag-and-readiness-v1": {
"status": "done",
"title": "DAG + readiness — depends-on / blocks parsing",
"stories": {
"01-extended-state": {
"status": "done",
"title": "Frontmatter array parsing + extended state shape",
"ac_total": 4,
"ac_completed": 4,
"depends_on": [],
"blocks": [
"02-dag-computation"
]
},
"02-dag-computation": {
"status": "done",
"title": "DAG computation — ready + blocked story sets",
"ac_total": 3,
"ac_completed": 3,
"depends_on": [
"01-extended-state"
],
"blocks": [
"03-cli-subcommands"
]
},
"03-cli-subcommands": {
"status": "done",
"title": "CLI ready / blocked subcommands",
"ac_total": 5,
"ac_completed": 5,
"depends_on": [
"02-dag-computation"
],
"blocks": []
}
}
},
"elicitation-prompts-v1": {
"status": "done",
"title": "Elicitation + dispatch prompt templates",
"stories": {
"01-sandcastle-scaffold": {
"status": "done",
"title": ".sandcastle/ directory + README + .env.example + .gitignore",
"ac_total": 4,
"ac_completed": 4,
"depends_on": [],
"blocks": [
"02-elicitation-prompts",
"03-dispatch-prompts"
]
},
"02-elicitation-prompts": {
"status": "done",
"title": "PRD + ADR elicitation prompt templates",
"ac_total": 2,
"ac_completed": 2,
"depends_on": [
"01-sandcastle-scaffold"
],
"blocks": []
},
"03-dispatch-prompts": {
"status": "done",
"title": "Decomposer + implementer + reviewer prompt templates",
"ac_total": 4,
"ac_completed": 4,
"depends_on": [
"01-sandcastle-scaffold"
],
"blocks": []
}
}
},
"fallow-integration-v1": {
"status": "done",
"title": "Fallow integration v1 — whole-codebase static analysis as 5th gate",
"stories": {
"01-fallow-install": {
"status": "done",
"title": "Install fallow + create .fallowrc.json",
"ac_total": 4,
"ac_completed": 4,
"depends_on": [],
"blocks": [
"02-pnpm-turbo-wiring"
]
},
"02-pnpm-turbo-wiring": {
"status": "done",
"title": "pnpm script + turbo task wiring",
"ac_total": 4,
"ac_completed": 4,
"depends_on": [
"01-fallow-install"
],
"blocks": [
"03-ci-integration"
]
},
"03-ci-integration": {
"status": "done",
"title": "CI workflow runs pnpm fallow",
"ac_total": 2,
"ac_completed": 2,
"depends_on": [
"02-pnpm-turbo-wiring"
],
"blocks": [
"04-docs-and-prompts"
]
},
"04-docs-and-prompts": {
"status": "done",
"title": "Docs + agent prompt updates",
"ac_total": 5,
"ac_completed": 5,
"depends_on": [
"03-ci-integration"
],
"blocks": []
}
}
},
"frontend-conformance-v1": {
"status": "done",
"title": "Frontend conformance rules v1",
"stories": {
"01-frontend-rules": {
"status": "done",
"title": "Three structural frontend conformance ESLint rules",
"ac_total": 6,
"ac_completed": 6,
"depends_on": [],
"blocks": []
}
}
},
"pre-commit-hooks-v1": {
"status": "done",
"title": "Pre-commit hooks v1 — husky + fast conformance gates on commit",
"stories": {
"01-husky-install": {
"status": "done",
"title": "Install husky + lint-staged",
"ac_total": 3,
"ac_completed": 3,
"depends_on": [],
"blocks": [
"02-pre-commit-hook"
]
},
"02-pre-commit-hook": {
"status": "done",
"title": "Pre-commit hook script",
"ac_total": 2,
"ac_completed": 2,
"depends_on": [
"01-husky-install"
],
"blocks": [
"03-state-sync-guard"
]
},
"03-state-sync-guard": {
"status": "done",
"title": "State-sync guard + tests",
"ac_total": 4,
"ac_completed": 4,
"depends_on": [
"02-pre-commit-hook"
],
"blocks": []
}
}
},
"sandcastle-dispatch-v1": {
"status": "done",
"title": "Sandcastle dispatch v1 — pnpm work dispatch with planning + execute modes",
"stories": {
"01-sandcastle-install": {
"status": "done",
"title": "Install @ai-hero/sandcastle + Dockerfile",
"ac_total": 4,
"ac_completed": 4,
"depends_on": [],
"blocks": [
"02-dispatch-planner"
]
},
"02-dispatch-planner": {
"status": "done",
"title": "Dispatch planner — finds next ready bullet, prints plan",
"ac_total": 3,
"ac_completed": 3,
"depends_on": [
"01-sandcastle-install"
],
"blocks": [
"03-dispatch-execute",
"04-dispatch-cli-wiring"
]
},
"03-dispatch-execute": {
"status": "done",
"title": "Dispatch execute mode — invoke sandcastle.run for impl + review",
"ac_total": 4,
"ac_completed": 4,
"depends_on": [
"02-dispatch-planner"
],
"blocks": [
"04-dispatch-cli-wiring"
]
},
"04-dispatch-cli-wiring": {
"status": "done",
"title": "pnpm work dispatch CLI wiring + smoke tests",
"ac_total": 4,
"ac_completed": 4,
"depends_on": [
"02-dispatch-planner",
"03-dispatch-execute"
],
"blocks": []
}
}
},
"visual-regression-v1": {
"status": "done",
"title": "Visual regression v1 — Playwright screenshot infrastructure",
"stories": {
"01-playwright-install": {
"status": "done",
"title": "Install @playwright/test + playwright.config.ts",
"ac_total": 3,
"ac_completed": 3,
"depends_on": [],
"blocks": [
"02-storybook-visual-tests"
]
},
"02-storybook-visual-tests": {
"status": "done",
"title": "Visual test harness in apps/storybook",
"ac_total": 2,
"ac_completed": 2,
"depends_on": [
"01-playwright-install"
],
"blocks": [
"03-ci-integration"
]
},
"03-ci-integration": {
"status": "done",
"title": "CI workflow runs pnpm test:visual",
"ac_total": 2,
"ac_completed": 2,
"depends_on": [
"02-storybook-visual-tests"
],
"blocks": []
}
}
},
"work-system-v1": {
"status": "done",
"title": "Work system v1 (MVP) — state tracking + pnpm work CLI",
"stories": {
"01-state-builder-and-cli": {
"status": "done",
"title": "State builder + pnpm work CLI",
"ac_total": 5,
"ac_completed": 5,
"depends_on": [],
"blocks": []
}
}
}
},
"ready": [],
"blocked": []
}

View File

@@ -0,0 +1,42 @@
---
id: 01-docs-rewrite
epic: agent-workflow-docs-v1
title: Surface conformance system across top-level docs
type: technical-story
status: done
feature: docs
depends-on: []
blocks: []
---
## Goal
Update CLAUDE.md, AGENTS.md, and four guides so agents picking up this
repo discover and follow the manifest-first workflow by default.
## Done when
- CLAUDE.md has a "Conformance system" section + manifest-first ordering
added to Key Conventions
- AGENTS.md's per-feature conventions mention the manifest contract
- adding-a-feature.md updated to the 4-step ordering
- scaffolding-a-feature.md notes the manifest emission + boot assertion
- tdd-workflow.md links to the conformance enforcement
- New conformance-quickref.md exists as a single-page agent reference
## In scope
Mechanical doc updates pointing at existing artifacts (the design doc, the
feature-conformance-explainer.html, the actual rules / wrappers / script
already merged).
## Out of scope
- Rewriting the existing architecture docs (already in place)
- Updating per-package AGENTS.md inside packages/*
## Tasks
- [x] Epic + story scaffold
- [x] CLAUDE.md update
- [x] AGENTS.md update
- [x] adding-a-feature.md update
- [x] scaffolding-a-feature.md update
- [x] tdd-workflow.md update
- [x] conformance-quickref.md (new)
- [x] Final verification + closeout

View File

@@ -0,0 +1,36 @@
---
id: agent-workflow-docs-v1
prd: null
title: Agent-workflow docs rollout
type: epic
status: done
features: [docs]
created: 2026-05-13
---
## Goal
Surface the conformance-system-v1 mechanics across CLAUDE.md, AGENTS.md,
and the feature-development guides so agents picking up this codebase
follow the manifest-first workflow by default.
## Why
The conformance system is the substrate, but agents read the top-level
docs first. Until the docs say "feature.manifest.ts exists, here's what
it does, here's how the gates fire," the system stays invisible to
agents iterating on this repo for the first time.
## In scope
- CLAUDE.md: add conformance system section + manifest-first ordering
- AGENTS.md: add manifest convention to per-package conventions
- docs/guides/adding-a-feature.md: update workflow to manifest → contracts → tests → code
- docs/guides/scaffolding-a-feature.md: note that generator now emits manifest + boot assertion
- docs/guides/tdd-workflow.md: link conformance system as the enforcement layer
- New docs/guides/conformance-quickref.md: ~80-line agent-facing reference
## Out of scope
- Frontend-conformance docs (next epic)
- Work-system-v1 docs (those land with the work-system epic)
- Editing the original conformance design docs (already in place)
## Stories
- [x] [01 — Surface conformance system across CLAUDE.md / AGENTS.md / guides](01-docs-rewrite/_story.md)

View File

@@ -0,0 +1,33 @@
---
id: 01-ast-manifest-source
epic: conformance-hardening-v1
title: Replace regex manifest source parser with AST
type: technical-story
status: done
feature: core-eslint
depends-on: []
blocks: [02-dev-seed-assertion]
---
## Goal
`readManifestSource` in `_manifest-source.js` returns the same shape but
uses `@typescript-eslint/parser` AST traversal instead of regex.
## In scope
- Rewrite `readManifestSource` to delegate to a new `parseManifestFully`
in `_manifest-ast.js` that returns `{ name, requiredCores, useCases }`
- Existing 4 tests for `readManifestSource` must still pass
- Add tests covering the previously-fragile cases: `name:` in JSDoc,
single-quoted strings, manifest declared with `let` instead of `const`
## Out of scope
- Changing the `_manifest-source.js` exports surface (filesystem helpers
`manifestPathForFeature` and `featureRootForFile` stay untouched)
- Changing any rule's behaviour beyond the parsing path
## Tasks
- [x] Epic + story scaffold
- [x] Extend `_manifest-ast.js` with `parseManifestFully`
- [x] Switch `readManifestSource` to delegate to `parseManifestFully`
- [x] Add edge-case tests
- [x] Verify all rules still pass; commit

View File

@@ -0,0 +1,29 @@
---
id: 02-dev-seed-assertion
epic: conformance-hardening-v1
title: Extend assertFeatureConformance to all bind-dev-seed paths
type: technical-story
status: done
feature: <cross-feature>
depends-on: [01-ast-manifest-source]
blocks: []
---
## Goal
Each feature's `bind-dev-seed.ts` calls `assertFeatureConformance(...)`
at its tail, matching the bind-production pattern.
## In scope
- Update bind-dev-seed.ts in auth, blog, media, navigation, marketing-pages
- Each gets the same import + tail call as its bind-production sibling
## Out of scope
- Behavioural changes to dev-seed binders
## Tasks
- [x] Update auth bind-dev-seed
- [x] Update blog bind-dev-seed
- [x] Update media bind-dev-seed
- [x] Update navigation bind-dev-seed
- [x] Update marketing-pages bind-dev-seed
- [x] Verify pnpm test passes

View File

@@ -0,0 +1,27 @@
---
id: conformance-hardening-v1
prd: null
title: Conformance hardening v1 — AST manifest parsing + dev-seed boot assertion
type: epic
status: done
features: [core-eslint, auth, blog, media, navigation, marketing-pages]
created: 2026-05-13
---
## Goal
Tighten two known fragilities in conformance-system-v1:
1. `_manifest-source.js` uses regex for `name` + `requiredCores` extraction —
replace with AST parsing for correctness (comments, quote styles, edge cases)
2. Boot assertion only runs at the production binder's tail. Dev mode
(`USE_DEV_SEED=true`) skips it. Extend to dev-seed paths.
## Why
- The regex false-match concern was flagged by the milestone iii.a final
reviewer and held only by today's convention; a future manifest with
a `// name: "x"` comment would break `required-cores-installed`.
- `pnpm dev` is the inner loop. Drift in dev-seed binders is currently
invisible until production boot — too late.
## Stories
- [x] [01 — AST-based manifest source parser](01-ast-manifest-source/_story.md)
- [x] [02 — Dev-seed boot assertion across all 5 features](02-dev-seed-assertion/_story.md)

View File

@@ -0,0 +1,45 @@
---
id: 03-b-ast-eslint-rules
epic: conformance-system-v1
title: AST-aware ESLint rules (no-undeclared-event-publish, no-undeclared-audit)
type: technical-story
status: done
feature: core-eslint
depends-on: [03-a-structural-eslint-rules]
blocks: [04-ci-drift-gate]
---
## Goal
Ship two AST-aware rules that catch manifest ↔ use-case drift inside factory bodies:
- `no-undeclared-event-publish`: `bus.publish("X")` in a factory must match `manifest.useCases[name].publishes`
- `no-undeclared-audit`: `auditLog.record({ type: "X" })` must match `manifest.useCases[name].audits`
## Why
Boot assertion + structural rules can't see what happens inside a factory body. AST-aware rules catch publish/audit drift the moment a developer (or agent) saves the file.
## Done when
- Manifest AST parser extracts per-use-case publishes/audits arrays
- Two rules registered in the conformance plugin
- Tests cover positive (declared event) and negative (undeclared event) cases for each rule
- `pnpm lint` passes (auth's signUp has empty publishes/audits today → no false positives)
## In scope
- `_manifest-ast.js` helper using `@typescript-eslint/parser` to extract per-use-case arrays
- `_usecase-name.js` helper (file slug → camelCase use-case key)
- The two rules + RuleTester tests
- Plugin + base.js wiring
## Out of scope
- Bus / auditLog parameter detection beyond the conventional names `bus` and `auditLog`
- Detection of dynamic event names (`bus.publish(eventVar, payload)` is allowed without warning)
- Conditional / nested calls — rules only check top-level CallExpressions in factory bodies
## Tasks
- [x] Story 03.b scaffold
- [x] Manifest AST parser + tests
- [x] Use-case name helper + tests
- [x] `no-undeclared-event-publish` rule + tests
- [x] `no-undeclared-audit` rule + tests
- [x] Plugin update + base.js wiring
- [x] Verify `pnpm lint` passes
- [x] Final verification + story closeout (tick 03 + 03.b in epic)

View File

@@ -0,0 +1,47 @@
---
id: 04-ci-drift-gate
epic: conformance-system-v1
title: CI drift gate — pnpm conformance with cross-feature event closure
type: technical-story
status: done
feature: scripts
depends-on: [03-b-ast-eslint-rules]
blocks: [05-generator-updates]
---
## Goal
`pnpm conformance` aggregates cross-feature checks that no single-file
ESLint rule can perform — most importantly, event closure: every event
declared in any manifest's `consumes` must have at least one matching
`publishes` somewhere in the repo.
## Why
Per-file lint can't see cross-feature contracts. Without this gate, a
feature can declare it consumes `X` while no feature publishes `X`
silent until the broken handler is exercised in prod.
## Done when
- `pnpm conformance` exits 0 when manifests are consistent; non-zero
with a clear error message on orphan consumers
- Wired into `turbo.json` as the `conformance` task
- Wired into `.github/workflows/ci.yml` after `pnpm lint`
## In scope
- `scripts/conformance.mjs` — orphan-consumer check
- Tests via vitest
- turbo.json + CI wiring
## Out of scope
- Scaffold drift check (regenerate via `turbo gen feature`, diff against
on-disk state) — depends on generator updates landing
- Repository write outside use-cases check — separate concern
- Reverse check (orphan publishers — events nothing consumes) — many
events are intentionally "fire and forget"; not a closure violation
## Tasks
- [x] Story scaffold
- [x] `scripts/conformance.mjs` implementation
- [x] Tests for the script
- [x] Wire into root package.json + turbo.json
- [x] Wire into ci.yml
- [x] Final verification + closeout

View File

@@ -0,0 +1,52 @@
---
id: 05-generator-updates
epic: conformance-system-v1
title: Generator updates — emit feature.manifest.ts + self-asserting bind-production
type: technical-story
status: done
feature: turbo-generators
depends-on: [04-ci-drift-gate]
blocks: [06-feature-migrations]
---
## Goal
`pnpm turbo gen feature <name>` produces a feature that's conformance-
compliant out of the box: ships a `feature.manifest.ts` declaring the
scaffolded use case, and the scaffolded `bind-production.ts` calls
`assertFeatureConformance` at its tail.
## Why
Today, generating a new feature does NOT emit a manifest, so
`feature-must-have-manifest` would warn the moment the developer saves.
And `bindProductionX` doesn't self-assert. New features should be
conformance-ready by default.
## Done when
- `feature.manifest.ts.hbs` template exists, declaring the scaffolded
`getX` use case with mutates: false / empty arrays
- `bind-production.ts.hbs` imports `assertFeatureConformance` +
`<name>Manifest` and calls the assertion at the tail
- `config.ts` action emits the manifest file
- Running `pnpm turbo gen feature demo` into a tmp dir produces a
feature directory containing a valid `feature.manifest.ts`
- Existing snapshot tests pass (regenerate snapshots if needed)
## In scope
- Manifest template
- bind-production template update
- config.ts action wiring
- Snapshot regen
## Out of scope
- Multi-use-case feature scaffolding
- Generator changes to other generators (event/job/realtime)
- Backfilling manifests for existing features (milestone vi)
## Tasks
- [x] Story scaffold
- [x] feature.manifest.ts.hbs template
- [x] bind-production.ts.hbs update
- [x] config.ts action wiring
- [x] Run generator into tmp; verify shape
- [x] Snapshot regen (if needed) + verify tests pass
- [x] Final verification + closeout

View File

@@ -0,0 +1,39 @@
---
id: 06-feature-migrations
epic: conformance-system-v1
title: Migrate blog/media/navigation/marketing-pages to conformance pattern
type: technical-story
status: done
feature: <cross-feature>
depends-on: [05-generator-updates]
blocks: []
---
## Goal
Every feature in the repo has a manifest + a self-asserting bindProduction.
After this milestone, `feature-must-have-manifest` flips from WARN to ERROR.
## Why
Three of the four enforcement layers already exist; the only thing keeping
them from being fully effective is that 4 of 5 features still don't have
manifests. This story closes that gap.
## In scope
- `feature.manifest.ts` for blog / media / navigation / marketing-pages
- Manifest re-export from each feature's `src/index.ts`
- `bind-production.ts` update for each: imports + tail `assertFeatureConformance` call
- Flip `feature-must-have-manifest` from `warn` to `error` in base.js
## Out of scope
- Adding publishes/audits to existing use cases (declared empty for now)
- Cross-feature event wiring (blog→marketing welcome flows, etc.)
- Migrating realtime channels or jobs into manifests
## Tasks
- [x] Story scaffold
- [x] blog manifest + binding + re-export
- [x] media manifest + binding + re-export
- [x] navigation manifest + binding + re-export
- [x] marketing-pages manifest + binding + re-export
- [x] Flip feature-must-have-manifest to error
- [x] Final verification + closeout

View File

@@ -3,7 +3,7 @@ id: conformance-system-v1
prd: null
title: Conformance system v1
type: epic
status: in-progress
status: done
features: [cross-cutting]
created: 2026-05-12
---
@@ -32,10 +32,10 @@ See `docs/architecture/feature-conformance-explainer.html` and
## Stories
- [x] [01 — defineFeature helper + Instrumented/Captured/Audited brands](01-define-feature-helper/_story.md)
- [x] [02 — `assertFeatureConformance` + boot wiring](02-boot-assertions/_story.md)
- [ ] 03 — AST-aware ESLint rules (continuing — see 03.a + future 03.b)
- [x] 03 — AST-aware ESLint rules (both halves shipped)
- [x] [03.a — Structural rules](03-a-structural-eslint-rules/_story.md)
- [ ] 03.b — Manifest-aware AST rules (later plan)
- [ ] 04 — CI drift gate (later plan)
- [ ] 05 — Generator emits manifest + contracts + test stubs (later plan)
- [ ] 06 — Documentation rewrite (later plan)
- [ ] 07 — Migrate auth feature reference (later plan)
- [x] [03.b — Manifest-aware AST rules](03-b-ast-eslint-rules/_story.md)
- [x] [04 — CI drift gate](04-ci-drift-gate/_story.md)
- [x] [05 — Generator updates](05-generator-updates/_story.md)
- [x] [06 — Migrate blog / media / navigation / marketing-pages](06-feature-migrations/_story.md)
- [x] 07 — Migrate auth feature reference (completed inline in milestone i; signIn through ProductionUseCase slot)

View File

@@ -0,0 +1,17 @@
---
id: 01-extended-state
epic: dag-and-readiness-v1
title: Frontmatter array parsing + extended state shape
type: technical-story
status: done
feature: scripts
depends-on: []
blocks: [02-dag-computation]
---
## Tasks
- [x] Story scaffold
- [x] Extend parseFrontmatter to parse array values like `depends-on: [a, b]`
- [x] Extend buildState to include depends_on + blocks per story entry
- [x] Update existing tests; add new array-parsing tests

View File

@@ -0,0 +1,16 @@
---
id: 02-dag-computation
epic: dag-and-readiness-v1
title: DAG computation — ready + blocked story sets
type: technical-story
status: done
feature: scripts
depends-on: [01-extended-state]
blocks: [03-cli-subcommands]
---
## Tasks
- [x] computeReadyBlocked(state) — pure function
- [x] Tests
- [x] Wire into buildState output

View File

@@ -0,0 +1,18 @@
---
id: 03-cli-subcommands
epic: dag-and-readiness-v1
title: CLI ready / blocked subcommands
type: technical-story
status: done
feature: scripts
depends-on: [02-dag-computation]
blocks: []
---
## Tasks
- [x] `pnpm work ready` — prints ready stories
- [x] `pnpm work blocked` — prints blocked stories with waiting_on
- [x] Update `pnpm work next` to use the ready set
- [x] CLI smoke tests
- [x] Final verification + closeout

View File

@@ -0,0 +1,21 @@
---
id: dag-and-readiness-v1
prd: null
title: DAG + readiness — depends-on / blocks parsing
type: epic
status: done
features: [scripts]
created: 2026-05-13
---
## Goal
Extend the state builder + CLI so `pnpm work next` / `pnpm work ready` /
`pnpm work blocked` answer "what's actually unblocked right now?"
from the depends-on graph instead of just the first non-done story.
## Stories
- [x] [01 — Frontmatter array parsing + extended state shape](01-extended-state/_story.md)
- [x] [02 — DAG computation: ready + blocked](02-dag-computation/_story.md)
- [x] [03 — CLI ready / blocked subcommands](03-cli-subcommands/_story.md)

View File

@@ -0,0 +1,17 @@
---
id: 01-sandcastle-scaffold
epic: elicitation-prompts-v1
title: .sandcastle/ directory + README + .env.example + .gitignore
type: technical-story
status: done
feature: tooling
depends-on: []
blocks: [02-elicitation-prompts, 03-dispatch-prompts]
---
## Tasks
- [x] Story scaffold
- [x] Create .sandcastle/README.md
- [x] Create .sandcastle/.env.example
- [x] Create .sandcastle/.gitignore

View File

@@ -0,0 +1,15 @@
---
id: 02-elicitation-prompts
epic: elicitation-prompts-v1
title: PRD + ADR elicitation prompt templates
type: technical-story
status: done
feature: agent-prompts
depends-on: [01-sandcastle-scaffold]
blocks: []
---
## Tasks
- [x] prd-eliciter.prompt.md
- [x] adr-eliciter.prompt.md

View File

@@ -0,0 +1,17 @@
---
id: 03-dispatch-prompts
epic: elicitation-prompts-v1
title: Decomposer + implementer + reviewer prompt templates
type: technical-story
status: done
feature: agent-prompts
depends-on: [01-sandcastle-scaffold]
blocks: []
---
## Tasks
- [x] decomposer.prompt.md
- [x] implementer.prompt.md
- [x] reviewer.prompt.md
- [x] Final verification + closeout

View File

@@ -0,0 +1,22 @@
---
id: elicitation-prompts-v1
prd: null
title: Elicitation + dispatch prompt templates
type: epic
status: done
features: [tooling, agent-prompts]
created: 2026-05-13
---
## Goal
Ship five prompt templates under `.sandcastle/` so the future orchestrator
(and humans manually copy-pasting) can dispatch PRD elicitation, ADR
elicitation, decomposition, implementation, and review with a consistent
shape. Every template enforces the generator-first rule.
## Stories
- [x] [01 — .sandcastle/ scaffold + README](01-sandcastle-scaffold/_story.md)
- [x] [02 — PRD + ADR elicitation prompts](02-elicitation-prompts/_story.md)
- [x] [03 — Decomposer + implementer + reviewer prompts](03-dispatch-prompts/_story.md)

View File

@@ -0,0 +1,17 @@
---
id: 01-fallow-install
epic: fallow-integration-v1
title: Install fallow + create .fallowrc.json
type: technical-story
status: done
feature: tooling
depends-on: []
blocks: [02-pnpm-turbo-wiring]
---
## Tasks
- [x] Story scaffold
- [x] Add fallow to root devDependencies
- [x] Create .fallowrc.json with monorepo-aware config
- [x] Verify `pnpm exec fallow --version` works

View File

@@ -0,0 +1,17 @@
---
id: 02-pnpm-turbo-wiring
epic: fallow-integration-v1
title: pnpm script + turbo task wiring
type: technical-story
status: done
feature: tooling
depends-on: [01-fallow-install]
blocks: [03-ci-integration]
---
## Tasks
- [x] pnpm fallow script (runs all)
- [x] pnpm fallow:audit script (runs `fallow audit --base main`)
- [x] turbo.json task entry for fallow
- [x] Run `pnpm fallow` against current repo, capture findings, tune config

View File

@@ -0,0 +1,15 @@
---
id: 03-ci-integration
epic: fallow-integration-v1
title: CI workflow runs pnpm fallow
type: technical-story
status: done
feature: ci
depends-on: [02-pnpm-turbo-wiring]
blocks: [04-docs-and-prompts]
---
## Tasks
- [x] Add `pnpm fallow` step after `pnpm conformance` in ci.yml
- [x] Use --format annotations for inline GH PR comments

View File

@@ -0,0 +1,18 @@
---
id: 04-docs-and-prompts
epic: fallow-integration-v1
title: Docs + agent prompt updates
type: technical-story
status: done
feature: agent-prompts
depends-on: [03-ci-integration]
blocks: []
---
## Tasks
- [x] Update CLAUDE.md conformance-system section to list Fallow as 5th gate
- [x] Update docs/guides/conformance-quickref.md with Fallow row
- [x] Update .sandcastle/implementer.prompt.md: run `fallow audit` before commit
- [x] Update .sandcastle/reviewer.prompt.md: verify implementer ran fallow audit
- [x] Final verification + closeout

View File

@@ -0,0 +1,28 @@
---
id: fallow-integration-v1
prd: null
title: Fallow integration v1 — whole-codebase static analysis as 5th gate
type: epic
status: done
features: [tooling, ci, agent-prompts]
created: 2026-05-13
---
## Goal
Wire Fallow as the 5th conformance gate: install, configure, pnpm script,
turbo task, CI step, docs update, agent prompt update.
## Why
Fallow catches what file-local lint, type-check, manifest assertions, and
conformance event closure cannot — dead code, dupes, circular deps,
complexity hotspots, and AI-change drift. `fallow audit --base main` is
purpose-built for AI agents to self-validate before submission.
## Stories
- [x] [01 — Install fallow + .fallowrc.json](01-fallow-install/_story.md)
- [x] [02 — pnpm + turbo wiring](02-pnpm-turbo-wiring/_story.md)
- [x] [03 — CI workflow integration](03-ci-integration/_story.md)
- [x] [04 — Docs + agent prompt updates](04-docs-and-prompts/_story.md)

View File

@@ -0,0 +1,42 @@
---
id: 01-frontend-rules
epic: frontend-conformance-v1
title: Three structural frontend conformance ESLint rules
type: technical-story
status: done
feature: core-eslint
depends-on: []
blocks: []
---
## Goal
Three new rules under the `conformance/` plugin namespace:
`component-must-have-story`, `component-must-have-test`,
`atomic-tier-import-direction`. All ship as WARN initially because
no components exist in the repo today.
## Done when
- Three rules registered in `@repo/core-eslint/plugin`
- Each has RuleTester tests
- `base.js` registers them at WARN severity
- `pnpm lint` passes (zero firings expected today; rules activate when
first component lands)
## In scope
- Three new rule files in `packages/core-eslint/rules/`
- Plugin + base.js wiring
- Tests via RuleTester
## Out of scope
- Visual regression infrastructure (Playwright screenshots — separate plan)
- `story-must-cover-all-prop-variants` advisory rule
- Detection beyond filesystem siblings (no AST-level component-detection
heuristics)
## Tasks
- [x] Epic + story scaffold
- [x] `component-must-have-story` rule + tests
- [x] `component-must-have-test` rule + tests
- [x] `atomic-tier-import-direction` rule + tests
- [x] Plugin update + base.js wiring
- [x] Final verification + closeout

View File

@@ -0,0 +1,22 @@
---
id: frontend-conformance-v1
prd: null
title: Frontend conformance rules v1
type: epic
status: done
features: [core-eslint]
created: 2026-05-13
---
## Goal
Three structural ESLint rules for frontend component files: every
component has a sibling Storybook story, a sibling test, and respects
atomic-design tier direction (atoms can't import from organisms, etc.).
## Why
The frontend-work-shape guide describes these conventions; without
rules, they're advisory only. Ship them now so they activate the moment
core-ui or feature UI components land — preventing drift from day one.
## Stories
- [x] [01 — Frontend ESLint rules](01-frontend-rules/_story.md)

View File

@@ -0,0 +1,28 @@
---
id: 01-husky-install
epic: pre-commit-hooks-v1
title: Install husky + lint-staged
type: technical-story
status: done
feature: tooling
depends-on: []
blocks: [02-pre-commit-hook]
---
## Goal
husky + lint-staged are dependencies of the root workspace and the
`prepare` script wires the hooks on `pnpm install`.
## In scope
- Add husky + lint-staged to root devDependencies
- Add `prepare` script that runs `husky` (initialises `.husky/`)
- Create `.husky/` directory + the husky shim
- Add `lint-staged` config to root package.json
## Tasks
- [x] Story scaffold
- [x] Install husky + lint-staged + add scripts/config
- [x] Verify `pnpm install` initialises `.husky/`

View File

@@ -0,0 +1,15 @@
---
id: 02-pre-commit-hook
epic: pre-commit-hooks-v1
title: Pre-commit hook script
type: technical-story
status: done
feature: tooling
depends-on: [01-husky-install]
blocks: [03-state-sync-guard]
---
## Tasks
- [x] `.husky/pre-commit` runs lint-staged
- [x] Pre-commit also regenerates \_state.json when docs/work/ changes and re-stages it

View File

@@ -0,0 +1,17 @@
---
id: 03-state-sync-guard
epic: pre-commit-hooks-v1
title: State-sync guard + tests
type: technical-story
status: done
feature: tooling
depends-on: [02-pre-commit-hook]
blocks: []
---
## Tasks
- [x] `scripts/work/state-sync-guard.mjs` fails when \_state.json differs from rebuild output
- [x] Tests
- [x] Wire into pre-commit
- [x] Final verification + closeout

View File

@@ -0,0 +1,27 @@
---
id: pre-commit-hooks-v1
prd: null
title: Pre-commit hooks v1 — husky + fast conformance gates on commit
type: epic
status: done
features: [tooling]
created: 2026-05-13
---
## Goal
Install husky and wire pre-commit hooks that catch the cheap drift classes
before code leaves the developer's machine. Slow checks stay in CI.
## Why
Slip a manifest update without re-running `pnpm work rebuild-state` and
the committed \_state.json drifts from markdown reality. Forget to lint
and a broken commit lands. Pre-commit hooks shrink the cycle from "wait
for CI" to "wait one second".
## Stories
- [x] [01 — Install husky + lint-staged](01-husky-install/_story.md)
- [x] [02 — Pre-commit hook: lint-staged + state-sync](02-pre-commit-hook/_story.md)
- [x] [03 — State-sync guard script + tests](03-state-sync-guard/_story.md)

View File

@@ -0,0 +1,17 @@
---
id: 01-sandcastle-install
epic: sandcastle-dispatch-v1
title: Install @ai-hero/sandcastle + Dockerfile
type: technical-story
status: done
feature: tooling
depends-on: []
blocks: [02-dispatch-planner]
---
## Tasks
- [x] Story scaffold
- [x] Add @ai-hero/sandcastle to devDependencies
- [x] Create .sandcastle/Dockerfile (minimal node + pnpm)
- [x] Verify install + import resolves

View File

@@ -0,0 +1,16 @@
---
id: 02-dispatch-planner
epic: sandcastle-dispatch-v1
title: Dispatch planner — finds next ready bullet, prints plan
type: technical-story
status: done
feature: scripts
depends-on: [01-sandcastle-install]
blocks: [03-dispatch-execute, 04-dispatch-cli-wiring]
---
## Tasks
- [x] findNextTask() — returns { epic, story, bullet, storyContent } or null
- [x] Tests
- [x] CLI: printPlan() — prints the dispatch plan

View File

@@ -0,0 +1,17 @@
---
id: 03-dispatch-execute
epic: sandcastle-dispatch-v1
title: Dispatch execute mode — invoke sandcastle.run for impl + review
type: technical-story
status: done
feature: scripts
depends-on: [02-dispatch-planner]
blocks: [04-dispatch-cli-wiring]
---
## Tasks
- [x] dispatchImplementer() — calls sandcastle.run with implementer prompt
- [x] dispatchReviewer() — calls sandcastle.run with reviewer prompt + diff
- [x] applyReviewerDecision() — prints what state mutation WOULD happen
- [x] Tests with mocked sandcastle.run

View File

@@ -0,0 +1,17 @@
---
id: 04-dispatch-cli-wiring
epic: sandcastle-dispatch-v1
title: pnpm work dispatch CLI wiring + smoke tests
type: technical-story
status: done
feature: scripts
depends-on: [02-dispatch-planner, 03-dispatch-execute]
blocks: []
---
## Tasks
- [x] cli.mjs: add `dispatch` subcommand with `--execute` flag
- [x] CLI smoke tests
- [x] README / docs update
- [x] Final verification + closeout

View File

@@ -0,0 +1,27 @@
---
id: sandcastle-dispatch-v1
prd: null
title: Sandcastle dispatch v1 — pnpm work dispatch with planning + execute modes
type: epic
status: done
features: [tooling, scripts]
created: 2026-05-13
---
## Goal
Install `@ai-hero/sandcastle` + ship `pnpm work dispatch` that picks the
next ready AC bullet, prints the dispatch plan, and (with `--execute`)
invokes sandcastle to run the implementer then reviewer.
## Why
The orchestrator closes the loop: docs/work/ tracks state, `.sandcastle/`
holds the prompts, and dispatch.mjs connects them to sandcastle.
## Stories
- [x] [01 — Install @ai-hero/sandcastle + Dockerfile](01-sandcastle-install/_story.md)
- [x] [02 — Dispatch planner (default mode)](02-dispatch-planner/_story.md)
- [x] [03 — Dispatch execute mode (sandcastle.run integration)](03-dispatch-execute/_story.md)
- [x] [04 — pnpm work dispatch wiring + tests](04-dispatch-cli-wiring/_story.md)

View File

@@ -0,0 +1,16 @@
---
id: 01-playwright-install
epic: visual-regression-v1
title: Install @playwright/test + playwright.config.ts
type: technical-story
status: done
feature: tooling
depends-on: []
blocks: [02-storybook-visual-tests]
---
## Tasks
- [x] Story scaffold
- [x] Add @playwright/test to root devDependencies
- [x] Create playwright.config.ts (single browser, fixed viewport, screenshot diff threshold)

View File

@@ -0,0 +1,15 @@
---
id: 02-storybook-visual-tests
epic: visual-regression-v1
title: Visual test harness in apps/storybook
type: technical-story
status: done
feature: tooling
depends-on: [01-playwright-install]
blocks: [03-ci-integration]
---
## Tasks
- [x] apps/storybook/tests/visual.spec.ts — iterates Storybook's stories.json
- [x] pnpm test:visual script at root

View File

@@ -0,0 +1,15 @@
---
id: 03-ci-integration
epic: visual-regression-v1
title: CI workflow runs pnpm test:visual
type: technical-story
status: done
feature: ci
depends-on: [02-storybook-visual-tests]
blocks: []
---
## Tasks
- [x] Add visual regression step to .github/workflows/ci.yml
- [x] Final verification + closeout

View File

@@ -0,0 +1,27 @@
---
id: visual-regression-v1
prd: null
title: Visual regression v1 — Playwright screenshot infrastructure
type: epic
status: done
features: [tooling, ci]
created: 2026-05-13
---
## Goal
Install Playwright and wire a screenshot test harness against Storybook so
the moment the first component+story lands, the visual regression gate
is already active.
## Why
Per the frontend-work-shape guide, visual regression is the CI gate that
locks in component appearance. Setting up the infrastructure now means
zero ramp-up when components arrive.
## Stories
- [x] [01 — Install Playwright + config](01-playwright-install/_story.md)
- [x] [02 — Storybook visual test harness](02-storybook-visual-tests/_story.md)
- [x] [03 — CI integration](03-ci-integration/_story.md)

View File

@@ -0,0 +1,33 @@
---
id: 01-state-builder-and-cli
epic: work-system-v1
title: State builder + pnpm work CLI
type: technical-story
status: done
feature: scripts
depends-on: []
blocks: []
---
## Goal
`pnpm work status` shows the current epic/story state across `docs/work/`.
`pnpm work next` prints the next unblocked story. `pnpm work rebuild-state`
regenerates `_state.json` from markdown.
## In scope
- `scripts/work/state-builder.mjs` — pure function, fully tested
- `scripts/work/cli.mjs` — argument dispatch + I/O wrapping the builder
- `docs/work/_state.json` — initial committed snapshot
- pnpm work script entry
## Out of scope
- Per-task state (tasks are markdown checkboxes, not separate entries)
- Orchestrator dispatch logic
- Dependency-graph analysis
## Tasks
- [x] Epic + story scaffold
- [x] state-builder.mjs + tests
- [x] cli.mjs + tests
- [x] _state.json initial commit + pnpm work script entry
- [x] Final verification + closeout

View File

@@ -0,0 +1,28 @@
---
id: work-system-v1
prd: null
title: Work system v1 (MVP) — state tracking + pnpm work CLI
type: epic
status: done
features: [scripts]
created: 2026-05-13
---
## Goal
Filesystem-only state tracking for `docs/work/` + a `pnpm work` CLI with
status / next / rebuild-state subcommands. Foundation for future
orchestration; no agent dispatch in v1.
## Why
Agents and humans both need a fast way to see "what's the next unblocked
work?" and "where are we?" — without scanning every story file by hand.
## Out of scope (v2+)
- Sandcastle / agent dispatch
- PRD and ADR elicitation skills
- Decomposer / implementer / reviewer prompts
- DAG analysis (depends-on / blocks)
- Pre-commit hooks (the state file is rebuilt manually for now)
## Stories
- [x] [01 — State builder + CLI](01-state-builder-and-cli/_story.md)

41
eslint.config.js Normal file
View File

@@ -0,0 +1,41 @@
/**
* Root ESLint config — covers scripts/ and any other plain-JS files at the
* repo root. Package-level configs (packages/*, apps/*) define their own
* eslint.config.js and take precedence for files inside those directories.
*/
import js from "@eslint/js";
// Minimal Node.js globals for scripts — avoids a peer-dep on `globals`.
const nodeGlobals = {
process: "readonly",
console: "readonly",
URL: "readonly",
Buffer: "readonly",
__dirname: "readonly",
__filename: "readonly",
setTimeout: "readonly",
clearTimeout: "readonly",
setInterval: "readonly",
clearInterval: "readonly",
};
export default [
{
ignores: [
"node_modules/**",
"packages/**",
"apps/**",
"turbo/**",
"playwright.config.ts",
],
},
js.configs.recommended,
{
files: ["scripts/**/*.{mjs,cjs,js}"],
languageOptions: {
ecmaVersion: 2022,
sourceType: "module",
globals: nodeGlobals,
},
},
];

View File

@@ -12,15 +12,34 @@
"test": "turbo run test",
"test:e2e": "turbo run test:e2e",
"test:stories": "turbo run test:stories",
"test:visual": "pnpm --filter @repo/storybook exec concurrently -k -s first -n 'SB,VRT' -c 'magenta,blue' 'pnpm --filter @repo/storybook exec http-server storybook-static --port 6006 --silent' 'pnpm --filter @repo/storybook exec wait-on tcp:6006 && pnpm exec playwright test'",
"typecheck": "turbo run typecheck",
"conformance": "node scripts/conformance.mjs",
"fallow": "fallow",
"fallow:audit": "fallow audit --base main",
"work": "node scripts/work/cli.mjs",
"format": "prettier --write \"**/*.{ts,tsx,js,jsx,json,md}\"",
"format:check": "prettier --check \"**/*.{ts,tsx,js,jsx,json,md}\""
"format:check": "prettier --check \"**/*.{ts,tsx,js,jsx,json,md}\"",
"prepare": "husky"
},
"devDependencies": {
"@ai-hero/sandcastle": "*",
"@playwright/test": "^1.49.0",
"@turbo/gen": "^2.4.0",
"@types/node": "^22.0.0",
"fallow": "^2.73.0",
"husky": "^9.0.0",
"lint-staged": "^16.0.0",
"prettier": "^3.5.0",
"turbo": "^2.4.0",
"typescript": "^5.8.0"
},
"lint-staged": {
"*.{ts,tsx,js,mjs,jsx}": [
"eslint --fix --max-warnings=0 --no-warn-ignored"
],
"*.{ts,tsx,js,mjs,jsx,json,md,yml,yaml}": [
"prettier --write"
]
}
}

View File

@@ -6,6 +6,8 @@ import {
type ILogger,
} from "@repo/core-shared/instrumentation";
import type { BindContext } from "@repo/core-shared/di";
import { assertFeatureConformance } from "@repo/core-shared/conformance";
import { authManifest } from "../feature.manifest.js";
import { authContainer } from "./container.js";
import { AUTH_SYMBOLS } from "./symbols.js";
import { MockUsersRepository } from "../infrastructure/repositories/users.repository.mock.js";
@@ -152,4 +154,16 @@ export async function bindDevSeedAuth(ctx: BindContext): Promise<void> {
// <gen:event-handlers>
// <gen:jobs>
// <gen:realtime-handlers>
// Boot-time conformance check (dev-seed mode).
assertFeatureConformance(
authContainer,
authManifest,
{
signIn: AUTH_SYMBOLS.ISignInUseCase,
signUp: AUTH_SYMBOLS.ISignUpUseCase,
signOut: AUTH_SYMBOLS.ISignOutUseCase,
},
ctx,
);
}

View File

@@ -6,6 +6,8 @@ import {
type ILogger,
} from "@repo/core-shared/instrumentation";
import type { BindContext } from "@repo/core-shared/di";
import { assertFeatureConformance } from "@repo/core-shared/conformance";
import { blogManifest } from "../feature.manifest.js";
import { blogContainer } from "./container.js";
import { BLOG_SYMBOLS } from "./symbols.js";
import { MockArticlesRepository } from "../infrastructure/repositories/articles.repository.mock.js";
@@ -145,4 +147,16 @@ export async function bindDevSeedBlog(ctx: BindContext): Promise<void> {
// <gen:event-handlers>
// <gen:jobs>
// <gen:realtime-handlers>
// Boot-time conformance check (dev-seed mode).
assertFeatureConformance(
blogContainer,
blogManifest,
{
getArticles: BLOG_SYMBOLS.IGetArticlesUseCase,
getArticleBySlug: BLOG_SYMBOLS.IGetArticleBySlugUseCase,
createArticle: BLOG_SYMBOLS.ICreateArticleUseCase,
},
ctx,
);
}

View File

@@ -6,8 +6,10 @@ import {
type ILogger,
} from "@repo/core-shared/instrumentation";
import type { BindProductionContext } from "@repo/core-shared/di";
import { assertFeatureConformance } from "@repo/core-shared/conformance";
import { blogContainer } from "./container";
import { BLOG_SYMBOLS } from "./symbols";
import { blogManifest } from "../feature.manifest";
import { ArticlesRepository } from "../infrastructure/repositories/articles.repository";
import { getArticlesUseCase } from "../application/use-cases/get-articles.use-case";
import { getArticleBySlugUseCase } from "../application/use-cases/get-article-by-slug.use-case";
@@ -138,4 +140,16 @@ export function bindProductionBlog(ctx: BindProductionContext): void {
// <gen:event-handlers>
// <gen:jobs>
// <gen:realtime-handlers>
// Boot-time conformance check.
assertFeatureConformance(
blogContainer,
blogManifest,
{
getArticles: BLOG_SYMBOLS.IGetArticlesUseCase,
getArticleBySlug: BLOG_SYMBOLS.IGetArticleBySlugUseCase,
createArticle: BLOG_SYMBOLS.ICreateArticleUseCase,
},
ctx,
);
}

View File

@@ -0,0 +1,33 @@
import { defineFeature } from "@repo/core-shared/conformance";
/**
* The blog feature's conformance manifest.
*/
export const blogManifest = defineFeature({
name: "blog",
requiredCores: [],
useCases: {
getArticles: {
mutates: false,
audits: [],
publishes: [],
consumes: [],
},
getArticleBySlug: {
mutates: false,
audits: [],
publishes: [],
consumes: [],
},
createArticle: {
mutates: true,
audits: [],
publishes: [],
consumes: [],
},
},
realtimeChannels: [],
jobs: [],
} as const);
export type BlogManifest = typeof blogManifest;

View File

@@ -33,3 +33,4 @@ export type { IGetArticleBySlugController } from "./interface-adapters/controlle
// <gen:events>
// <gen:realtime-channels>
export { blogManifest, type BlogManifest } from "./feature.manifest";

View File

@@ -33,10 +33,9 @@ export default [
plugins: { conformance: conformancePlugin },
rules: {
// Structural conformance rules (milestone iii.a).
// `feature-must-have-manifest` is WARN today because only auth has a manifest;
// flip to ERROR after blog/media/navigation/marketing-pages migrate.
// All 5 features now have manifests; promoted to ERROR.
"conformance/feature-must-have-manifest": [
"warn",
"error",
{ repoRoot },
],
"conformance/usecase-must-have-test-file": "error",
@@ -44,6 +43,11 @@ export default [
"error",
{ repoRoot },
],
"conformance/no-undeclared-event-publish": ["warn", { repoRoot }],
"conformance/no-undeclared-audit": ["warn", { repoRoot }],
"conformance/component-must-have-story": "warn",
"conformance/component-must-have-test": "warn",
"conformance/atomic-tier-import-direction": "warn",
},
},
{

View File

@@ -1,6 +1,11 @@
import featureMustHaveManifest from "./rules/feature-must-have-manifest.js";
import usecaseMustHaveTestFile from "./rules/usecase-must-have-test-file.js";
import requiredCoresInstalled from "./rules/required-cores-installed.js";
import noUndeclaredEventPublish from "./rules/no-undeclared-event-publish.js";
import noUndeclaredAudit from "./rules/no-undeclared-audit.js";
import componentMustHaveStory from "./rules/component-must-have-story.js";
import componentMustHaveTest from "./rules/component-must-have-test.js";
import atomicTierImportDirection from "./rules/atomic-tier-import-direction.js";
/**
* The `@repo/core-eslint` conformance plugin. Aggregates custom rules that
@@ -16,11 +21,16 @@ import requiredCoresInstalled from "./rules/required-cores-installed.js";
* ];
*/
const plugin = {
meta: { name: "conformance", version: "0.1.0" },
meta: { name: "conformance", version: "0.3.0" },
rules: {
"feature-must-have-manifest": featureMustHaveManifest,
"usecase-must-have-test-file": usecaseMustHaveTestFile,
"required-cores-installed": requiredCoresInstalled,
"no-undeclared-event-publish": noUndeclaredEventPublish,
"no-undeclared-audit": noUndeclaredAudit,
"component-must-have-story": componentMustHaveStory,
"component-must-have-test": componentMustHaveTest,
"atomic-tier-import-direction": atomicTierImportDirection,
},
};

View File

@@ -0,0 +1,167 @@
import fs from "node:fs";
import { parse } from "@typescript-eslint/parser";
/**
* Parse a feature.manifest.ts file and extract per-use-case attributes.
* Walks the AST to find the `defineFeature({...} as const)` call expression
* and reads literal values from its argument object.
*
* Returns: { [useCaseName]: { mutates, audits[], publishes[], consumes[] } }
* Returns null if the file is missing or doesn't match the expected shape.
*/
export function parseManifestUseCases(manifestPath) {
let src;
try {
src = fs.readFileSync(manifestPath, "utf8");
} catch {
return null;
}
let ast;
try {
ast = parse(src, { sourceType: "module", ecmaVersion: "latest", loc: false, range: false });
} catch {
return null;
}
const defineCall = findDefineFeatureCall(ast);
if (!defineCall) return null;
const arg = unwrapAsConst(defineCall.arguments[0]);
if (!arg || arg.type !== "ObjectExpression") return null;
const useCasesProp = arg.properties.find(
(p) => p.type === "Property" && p.key.type === "Identifier" && p.key.name === "useCases",
);
if (!useCasesProp || useCasesProp.value.type !== "ObjectExpression") return {};
const result = {};
for (const entry of useCasesProp.value.properties) {
if (entry.type !== "Property" || entry.value.type !== "ObjectExpression") continue;
const name = entry.key.type === "Identifier" ? entry.key.name : entry.key.value;
result[name] = extractUseCaseEntry(entry.value);
}
return result;
}
function findDefineFeatureCall(ast) {
for (const node of ast.body) {
if (node.type !== "ExportNamedDeclaration" || !node.declaration) continue;
if (node.declaration.type !== "VariableDeclaration") continue;
for (const decl of node.declaration.declarations) {
const init = decl.init;
if (!init) continue;
if (init.type === "CallExpression" && init.callee.type === "Identifier" && init.callee.name === "defineFeature") {
return init;
}
}
}
return null;
}
function unwrapAsConst(node) {
if (node && node.type === "TSAsExpression") return node.expression;
return node;
}
function extractUseCaseEntry(objExpr) {
const entry = { mutates: false, audits: [], publishes: [], consumes: [] };
for (const prop of objExpr.properties) {
if (prop.type !== "Property" || prop.key.type !== "Identifier") continue;
const key = prop.key.name;
if (key === "mutates" && prop.value.type === "Literal") {
entry.mutates = prop.value.value === true;
} else if ((key === "audits" || key === "publishes" || key === "consumes") && prop.value.type === "ArrayExpression") {
entry[key] = prop.value.elements
.filter((el) => el && el.type === "Literal" && typeof el.value === "string")
.map((el) => el.value);
}
}
return entry;
}
/**
* Parse a feature.manifest.ts and return the full manifest shape:
* { name, requiredCores: string[], useCases: { [name]: {...} } }
*
* Same AST walking as parseManifestUseCases but additionally extracts
* the top-level name + requiredCores fields. Returns null on parse failure.
*/
export function parseManifestFully(manifestPath) {
// Reuse the AST parser used by parseManifestUseCases by inlining the
// file-read + AST walk. We need access to the manifest's top-level
// argument object beyond just useCases.
let src;
try {
src = fs.readFileSync(manifestPath, "utf8");
} catch {
return null;
}
let ast;
try {
ast = parse(src, { sourceType: "module", ecmaVersion: "latest", loc: false, range: false });
} catch {
return null;
}
const defineCall = findDefineFeatureCallFromBody(ast);
if (!defineCall) return null;
const arg = unwrapAsConstNode(defineCall.arguments[0]);
if (!arg || arg.type !== "ObjectExpression") return null;
let name = null;
let requiredCores = [];
let useCases = {};
for (const prop of arg.properties) {
if (prop.type !== "Property" || prop.key.type !== "Identifier") continue;
if (prop.key.name === "name" && prop.value.type === "Literal" && typeof prop.value.value === "string") {
name = prop.value.value;
} else if (prop.key.name === "requiredCores" && prop.value.type === "ArrayExpression") {
requiredCores = prop.value.elements
.filter((el) => el && el.type === "Literal" && typeof el.value === "string")
.map((el) => el.value);
} else if (prop.key.name === "useCases" && prop.value.type === "ObjectExpression") {
for (const entry of prop.value.properties) {
if (entry.type !== "Property" || entry.value.type !== "ObjectExpression") continue;
const ucName = entry.key.type === "Identifier" ? entry.key.name : entry.key.value;
useCases[ucName] = extractUseCaseEntryFromObj(entry.value);
}
}
}
if (name === null) return null;
return { name, requiredCores, useCases };
}
// Helper aliases for the existing private functions — exposed under
// different names to avoid touching existing code paths.
function findDefineFeatureCallFromBody(ast) {
for (const node of ast.body) {
if (node.type !== "ExportNamedDeclaration" || !node.declaration) continue;
if (node.declaration.type !== "VariableDeclaration") continue;
for (const decl of node.declaration.declarations) {
const init = decl.init;
if (!init) continue;
if (init.type === "CallExpression" && init.callee.type === "Identifier" && init.callee.name === "defineFeature") {
return init;
}
}
}
return null;
}
function unwrapAsConstNode(node) {
if (node && node.type === "TSAsExpression") return node.expression;
return node;
}
function extractUseCaseEntryFromObj(objExpr) {
const entry = { mutates: false, audits: [], publishes: [], consumes: [] };
for (const prop of objExpr.properties) {
if (prop.type !== "Property" || prop.key.type !== "Identifier") continue;
const key = prop.key.name;
if (key === "mutates" && prop.value.type === "Literal") {
entry.mutates = prop.value.value === true;
} else if ((key === "audits" || key === "publishes" || key === "consumes") && prop.value.type === "ArrayExpression") {
entry[key] = prop.value.elements
.filter((el) => el && el.type === "Literal" && typeof el.value === "string")
.map((el) => el.value);
}
}
return entry;
}

View File

@@ -0,0 +1,100 @@
import { describe, it, expect } from "vitest";
import path from "node:path";
import os from "node:os";
import fs from "node:fs";
import { parseManifestUseCases, parseManifestFully } from "./_manifest-ast.js";
function writeManifest(content) {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "manifest-ast-"));
const fp = path.join(dir, "feature.manifest.ts");
fs.writeFileSync(fp, content);
return fp;
}
describe("parseManifestUseCases", () => {
it("returns an empty object for a manifest with no useCases", () => {
const fp = writeManifest(`export const xManifest = defineFeature({
name: "x",
requiredCores: [],
useCases: {},
realtimeChannels: [],
jobs: [],
} as const);`);
expect(parseManifestUseCases(fp)).toEqual({});
});
it("extracts per-use-case publishes/audits/consumes arrays", () => {
const fp = writeManifest(`export const authManifest = defineFeature({
name: "auth",
requiredCores: [],
useCases: {
signIn: { mutates: false, audits: [], publishes: [], consumes: [] },
signUp: { mutates: true, audits: ["user.created"], publishes: ["auth.signed-up"], consumes: [] },
signOut: { mutates: true, audits: ["session.ended"], publishes: [], consumes: [] },
},
realtimeChannels: [],
jobs: [],
} as const);`);
expect(parseManifestUseCases(fp)).toEqual({
signIn: { mutates: false, audits: [], publishes: [], consumes: [] },
signUp: { mutates: true, audits: ["user.created"], publishes: ["auth.signed-up"], consumes: [] },
signOut: { mutates: true, audits: ["session.ended"], publishes: [], consumes: [] },
});
});
it("returns null when file does not exist", () => {
expect(parseManifestUseCases("/nonexistent/manifest.ts")).toBeNull();
});
it("returns null when the file has no defineFeature call", () => {
const fp = writeManifest(`export const x = 1;`);
expect(parseManifestUseCases(fp)).toBeNull();
});
});
describe("parseManifestFully", () => {
it("returns name + requiredCores + useCases for a complete manifest", () => {
// Use the writeManifest helper that already exists in the test file
const fp = writeManifest(`export const authManifest = defineFeature({
name: "auth",
requiredCores: ["audit", "events"],
useCases: {
signIn: { mutates: false, audits: [], publishes: [], consumes: [] },
signUp: { mutates: true, audits: ["user.created"], publishes: [], consumes: [] },
},
realtimeChannels: [],
jobs: [],
} as const);`);
expect(parseManifestFully(fp)).toEqual({
name: "auth",
requiredCores: ["audit", "events"],
useCases: {
signIn: { mutates: false, audits: [], publishes: [], consumes: [] },
signUp: { mutates: true, audits: ["user.created"], publishes: [], consumes: [] },
},
});
});
it("returns null when the manifest has no name", () => {
const fp = writeManifest(`export const x = 1;`);
expect(parseManifestFully(fp)).toBeNull();
});
it("is not fooled by 'name:' inside a JSDoc comment (regex would false-match)", () => {
const fp = writeManifest(`/**
* Sample comment with name: "fake" embedded in it.
*/
export const realManifest = defineFeature({
name: "real",
requiredCores: [],
useCases: {},
realtimeChannels: [],
jobs: [],
} as const);`);
expect(parseManifestFully(fp)).toEqual({
name: "real",
requiredCores: [],
useCases: {},
});
});
});

View File

@@ -1,35 +1,16 @@
import fs from "node:fs";
import path from "node:path";
import { parseManifestFully } from "./_manifest-ast.js";
/**
* Reads a feature.manifest.ts file and extracts the manifest's `name` field
* and `requiredCores` array via regex. Returns null if the file does not
* exist or does not match the expected literal `as const` manifest shape.
*
* The repo's convention is that every feature.manifest.ts uses defineFeature
* with literal `as const` syntax — this is enforced by the type-system
* design (defineFeature has `<const M>` to preserve literal types). The
* regex extraction is therefore safe; the AST path is overkill.
* Reads a feature.manifest.ts and returns { name, requiredCores }.
* Backed by the AST parser from _manifest-ast.js — no longer uses regex.
*
* Returns: { name: string, requiredCores: string[] } | null
*/
export function readManifestSource(manifestPath) {
let src;
try {
src = fs.readFileSync(manifestPath, "utf8");
} catch {
return null;
}
const nameMatch = src.match(/name:\s*"([^"]+)"/);
if (!nameMatch) return null;
const coresMatch = src.match(/requiredCores:\s*\[([^\]]*)\]/);
const cores = coresMatch
? coresMatch[1]
.split(",")
.map((s) => s.trim().replace(/^"/, "").replace(/"$/, ""))
.filter((s) => s.length > 0)
: [];
return { name: nameMatch[1], requiredCores: cores };
const full = parseManifestFully(manifestPath);
if (!full) return null;
return { name: full.name, requiredCores: full.requiredCores };
}
/**

View File

@@ -0,0 +1,17 @@
/**
* Derive the manifest use-case key from a use-case file path.
*
* Convention: `packages/<feature>/src/application/use-cases/<kebab-slug>.use-case.ts`
* → manifest.useCases.<camelCaseSlug>
*
* Returns null for non-use-case files.
*/
export function useCaseNameFromFile(filepath) {
if (!filepath.endsWith(".use-case.ts")) return null;
if (!filepath.includes("/application/use-cases/") && !filepath.includes("\\application\\use-cases\\")) {
return null;
}
const base = filepath.split(/[\\/]/).pop();
const slug = base.replace(/\.use-case\.ts$/, "");
return slug.split("-").map((part, i) => i === 0 ? part : part[0].toUpperCase() + part.slice(1)).join("");
}

View File

@@ -0,0 +1,19 @@
import { describe, it, expect } from "vitest";
import { useCaseNameFromFile } from "./_usecase-name.js";
describe("useCaseNameFromFile", () => {
it("converts kebab-case slug to camelCase", () => {
expect(useCaseNameFromFile("/repo/packages/auth/src/application/use-cases/sign-in.use-case.ts")).toBe("signIn");
expect(useCaseNameFromFile("/repo/packages/auth/src/application/use-cases/sign-up.use-case.ts")).toBe("signUp");
expect(useCaseNameFromFile("/repo/packages/blog/src/application/use-cases/get-article-by-slug.use-case.ts")).toBe("getArticleBySlug");
});
it("handles single-word slugs", () => {
expect(useCaseNameFromFile("/repo/packages/x/src/application/use-cases/login.use-case.ts")).toBe("login");
});
it("returns null for non-use-case files", () => {
expect(useCaseNameFromFile("/repo/packages/auth/src/index.ts")).toBeNull();
expect(useCaseNameFromFile("/repo/packages/auth/src/feature.manifest.ts")).toBeNull();
});
});

View File

@@ -0,0 +1,47 @@
const TIERS = ["atoms", "molecules", "organisms", "templates", "pages"];
function tierOf(filepath) {
for (let i = 0; i < TIERS.length; i++) {
const tier = TIERS[i];
if (filepath.includes(`/${tier}/`) || filepath.includes(`\\${tier}\\`)) {
return { name: tier, index: i };
}
}
return null;
}
/** @type {import("eslint").Rule.RuleModule} */
export default {
meta: {
type: "problem",
docs: {
description:
"Atomic-design tier imports respect direction: atoms ← molecules ← organisms ← templates ← pages.",
},
schema: [],
messages: {
wrongDirection:
"{{fromTier}} cannot import from {{toTier}} ({{importPath}}). Tier direction: atoms ← molecules ← organisms ← templates ← pages.",
},
},
create(context) {
const filename = context.filename;
const from = tierOf(filename);
if (!from) return {};
return {
ImportDeclaration(node) {
const source = node.source.value;
if (typeof source !== "string") return;
const to = tierOf(source);
if (!to) return;
if (to.index > from.index) {
context.report({
node,
messageId: "wrongDirection",
data: { fromTier: from.name, toTier: to.name, importPath: source },
});
}
},
};
},
};

View File

@@ -0,0 +1,55 @@
import { describe, it } from "vitest";
import { RuleTester } from "eslint";
import rule from "./atomic-tier-import-direction.js";
const tester = new RuleTester({
languageOptions: {
parser: await import("@typescript-eslint/parser"),
ecmaVersion: "latest",
sourceType: "module",
parserOptions: { ecmaFeatures: { jsx: true } },
},
});
describe("atomic-tier-import-direction", () => {
it("passes when an organism imports from atoms", () => {
tester.run("atomic-tier-import-direction", rule, {
valid: [{
filename: "/repo/packages/core-ui/src/organisms/Card/Card.tsx",
code: `import { Button } from "../../atoms/Button/Button";`,
}],
invalid: [],
});
});
it("fires when an atom imports from organisms", () => {
tester.run("atomic-tier-import-direction", rule, {
valid: [],
invalid: [{
filename: "/repo/packages/core-ui/src/atoms/Button/Button.tsx",
code: `import { Card } from "../../organisms/Card/Card";`,
errors: [{ messageId: "wrongDirection", data: { fromTier: "atoms", toTier: "organisms", importPath: "../../organisms/Card/Card" } }],
}],
});
});
it("is a no-op for files outside any tier folder", () => {
tester.run("atomic-tier-import-direction", rule, {
valid: [{
filename: "/repo/packages/auth/src/application/use-cases/sign-in.use-case.ts",
code: `export const x = 1;`,
}],
invalid: [],
});
});
it("passes for same-tier imports", () => {
tester.run("atomic-tier-import-direction", rule, {
valid: [{
filename: "/repo/packages/core-ui/src/molecules/SearchBar/SearchBar.tsx",
code: `import { FormField } from "../FormField/FormField";`,
}],
invalid: [],
});
});
});

View File

@@ -0,0 +1,59 @@
import fs from "node:fs";
import path from "node:path";
/**
* Identifies "component files" by location + extension:
* - `.tsx` extension
* - Inside `packages/core-ui/src/` OR `packages/<feature>/src/ui/`
* - NOT a test, stories, spec, or barrel file
*/
function isComponentFile(filename) {
if (!filename.endsWith(".tsx")) return false;
const base = path.basename(filename);
if (
base === "index.tsx" ||
base.endsWith(".test.tsx") ||
base.endsWith(".stories.tsx") ||
base.endsWith(".spec.tsx")
) {
return false;
}
return (
filename.includes("/packages/core-ui/src/") ||
/\/packages\/[^/]+\/src\/ui\//.test(filename)
);
}
/** @type {import("eslint").Rule.RuleModule} */
export default {
meta: {
type: "problem",
docs: {
description:
"Every component file must have a sibling *.stories.tsx for Storybook coverage.",
},
schema: [],
messages: {
missingStory:
"Component {{filename}} has no sibling Storybook story at {{expected}}. Stories are the spec for visual conformance.",
},
},
create(context) {
return {
Program(node) {
const filename = context.filename;
if (!isComponentFile(filename)) return;
const expected = filename.replace(/\.tsx$/, ".stories.tsx");
if (fs.existsSync(expected)) return;
context.report({
node,
messageId: "missingStory",
data: {
filename: path.basename(filename),
expected: path.basename(expected),
},
});
},
};
},
};

View File

@@ -0,0 +1,84 @@
import { describe, it } from "vitest";
import { RuleTester } from "eslint";
import path from "node:path";
import os from "node:os";
import fs from "node:fs";
import rule from "./component-must-have-story.js";
function makeFixture({ withStory, location = "core-ui" }) {
const root = fs.mkdtempSync(path.join(os.tmpdir(), "cmhs-"));
const dir =
location === "core-ui"
? path.join(root, "packages", "core-ui", "src", "atoms", "Button")
: path.join(root, "packages", "demo", "src", "ui", "atoms", "Button");
fs.mkdirSync(dir, { recursive: true });
const component = path.join(dir, "Button.tsx");
fs.writeFileSync(component, `export const Button = () => <button>x</button>;`);
if (withStory) {
fs.writeFileSync(path.join(dir, "Button.stories.tsx"), `export default { title: "Button" };`);
}
return { component };
}
const tester = new RuleTester({
languageOptions: {
parser: await import("@typescript-eslint/parser"),
ecmaVersion: "latest",
sourceType: "module",
parserOptions: { ecmaFeatures: { jsx: true } },
},
});
describe("component-must-have-story", () => {
it("passes for a core-ui component with a sibling .stories.tsx", () => {
const { component } = makeFixture({ withStory: true });
tester.run("component-must-have-story", rule, {
valid: [{ filename: component, code: fs.readFileSync(component, "utf8") }],
invalid: [],
});
});
it("fires for a core-ui component without a sibling .stories.tsx", () => {
const { component } = makeFixture({ withStory: false });
tester.run("component-must-have-story", rule, {
valid: [],
invalid: [{
filename: component,
code: fs.readFileSync(component, "utf8"),
errors: [{ messageId: "missingStory" }],
}],
});
});
it("passes for a feature ui component with a sibling .stories.tsx", () => {
const { component } = makeFixture({ withStory: true, location: "feature" });
tester.run("component-must-have-story", rule, {
valid: [{ filename: component, code: fs.readFileSync(component, "utf8") }],
invalid: [],
});
});
it("is a no-op for index.tsx", () => {
const root = fs.mkdtempSync(path.join(os.tmpdir(), "cmhs-"));
const dir = path.join(root, "packages", "core-ui", "src", "atoms", "Button");
fs.mkdirSync(dir, { recursive: true });
const file = path.join(dir, "index.tsx");
fs.writeFileSync(file, `export * from "./Button";`);
tester.run("component-must-have-story", rule, {
valid: [{ filename: file, code: fs.readFileSync(file, "utf8") }],
invalid: [],
});
});
it("is a no-op for files outside packages/core-ui/ and packages/*/src/ui/", () => {
const root = fs.mkdtempSync(path.join(os.tmpdir(), "cmhs-"));
const dir = path.join(root, "packages", "auth", "src", "application", "use-cases");
fs.mkdirSync(dir, { recursive: true });
const file = path.join(dir, "sign-in.use-case.tsx");
fs.writeFileSync(file, `export const x = 1;`);
tester.run("component-must-have-story", rule, {
valid: [{ filename: file, code: fs.readFileSync(file, "utf8") }],
invalid: [],
});
});
});

View File

@@ -0,0 +1,53 @@
import fs from "node:fs";
import path from "node:path";
function isComponentFile(filename) {
if (!filename.endsWith(".tsx")) return false;
const base = path.basename(filename);
if (
base === "index.tsx" ||
base.endsWith(".test.tsx") ||
base.endsWith(".stories.tsx") ||
base.endsWith(".spec.tsx")
) {
return false;
}
return (
filename.includes("/packages/core-ui/src/") ||
/\/packages\/[^/]+\/src\/ui\//.test(filename)
);
}
/** @type {import("eslint").Rule.RuleModule} */
export default {
meta: {
type: "problem",
docs: {
description:
"Every component file must have a sibling *.test.tsx for behavioural coverage.",
},
schema: [],
messages: {
missingTest:
"Component {{filename}} has no sibling test at {{expected}}. Write the red test first.",
},
},
create(context) {
return {
Program(node) {
const filename = context.filename;
if (!isComponentFile(filename)) return;
const expected = filename.replace(/\.tsx$/, ".test.tsx");
if (fs.existsSync(expected)) return;
context.report({
node,
messageId: "missingTest",
data: {
filename: path.basename(filename),
expected: path.basename(expected),
},
});
},
};
},
};

View File

@@ -0,0 +1,49 @@
import { describe, it } from "vitest";
import { RuleTester } from "eslint";
import path from "node:path";
import os from "node:os";
import fs from "node:fs";
import rule from "./component-must-have-test.js";
function makeFixture({ withTest }) {
const root = fs.mkdtempSync(path.join(os.tmpdir(), "cmht-"));
const dir = path.join(root, "packages", "core-ui", "src", "atoms", "Button");
fs.mkdirSync(dir, { recursive: true });
const component = path.join(dir, "Button.tsx");
fs.writeFileSync(component, `export const Button = () => <button>x</button>;`);
if (withTest) {
fs.writeFileSync(path.join(dir, "Button.test.tsx"), `import { it } from "vitest"; it("works", () => {});`);
}
return { component };
}
const tester = new RuleTester({
languageOptions: {
parser: await import("@typescript-eslint/parser"),
ecmaVersion: "latest",
sourceType: "module",
parserOptions: { ecmaFeatures: { jsx: true } },
},
});
describe("component-must-have-test", () => {
it("passes when sibling .test.tsx exists", () => {
const { component } = makeFixture({ withTest: true });
tester.run("component-must-have-test", rule, {
valid: [{ filename: component, code: fs.readFileSync(component, "utf8") }],
invalid: [],
});
});
it("fires when no sibling .test.tsx exists", () => {
const { component } = makeFixture({ withTest: false });
tester.run("component-must-have-test", rule, {
valid: [],
invalid: [{
filename: component,
code: fs.readFileSync(component, "utf8"),
errors: [{ messageId: "missingTest" }],
}],
});
});
});

View File

@@ -0,0 +1,67 @@
import { parseManifestUseCases } from "./_manifest-ast.js";
import { useCaseNameFromFile } from "./_usecase-name.js";
import { manifestPathForFeature, featureRootForFile } from "./_manifest-source.js";
/** @type {import("eslint").Rule.RuleModule} */
export default {
meta: {
type: "problem",
docs: {
description:
"auditLog.record({ type: \"X\" }) inside a use-case factory must declare X in manifest.useCases[name].audits.",
},
schema: [
{
type: "object",
properties: { repoRoot: { type: "string" } },
additionalProperties: false,
},
],
messages: {
undeclared:
"{{useCase}} calls auditLog.record with type \"{{event}}\" but {{event}} is not declared in manifest.useCases.{{useCase}}.audits. Add it to the manifest or remove the call.",
},
},
create(context) {
const opts = context.options[0] ?? {};
const repoRoot = opts.repoRoot ?? context.cwd ?? process.cwd();
const filename = context.filename;
const useCaseName = useCaseNameFromFile(filename);
if (!useCaseName) return {};
const featureRoot = featureRootForFile(filename, repoRoot);
if (!featureRoot) return {};
const manifest = parseManifestUseCases(manifestPathForFeature(featureRoot));
if (!manifest || !manifest[useCaseName]) return {};
const declared = new Set(manifest[useCaseName].audits);
return {
CallExpression(node) {
if (
node.callee.type === "MemberExpression" &&
node.callee.object.type === "Identifier" &&
node.callee.object.name === "auditLog" &&
node.callee.property.type === "Identifier" &&
node.callee.property.name === "record" &&
node.arguments.length > 0 &&
node.arguments[0].type === "ObjectExpression"
) {
const typeProp = node.arguments[0].properties.find(
(p) =>
p.type === "Property" &&
p.key.type === "Identifier" &&
p.key.name === "type",
);
if (
typeProp &&
typeProp.value.type === "Literal" &&
typeof typeProp.value.value === "string"
) {
const event = typeProp.value.value;
if (!declared.has(event)) {
context.report({ node, messageId: "undeclared", data: { event, useCase: useCaseName } });
}
}
}
},
};
},
};

View File

@@ -0,0 +1,80 @@
import { describe, it } from "vitest";
import { RuleTester } from "eslint";
import path from "node:path";
import os from "node:os";
import fs from "node:fs";
import rule from "./no-undeclared-audit.js";
function makeFixture({ manifestUseCases, useCaseBody }) {
const repoRoot = fs.mkdtempSync(path.join(os.tmpdir(), "nua-"));
const featureDir = path.join(repoRoot, "packages", "demo");
fs.mkdirSync(path.join(featureDir, "src", "application", "use-cases"), { recursive: true });
const useCasesObj = Object.entries(manifestUseCases)
.map(([name, uc]) =>
` ${name}: { mutates: ${uc.mutates}, audits: [${uc.audits.map((a) => `"${a}"`).join(", ")}], publishes: [], consumes: [] },`,
)
.join("\n");
fs.writeFileSync(
path.join(featureDir, "src", "feature.manifest.ts"),
`export const demoManifest = defineFeature({
name: "demo",
requiredCores: [],
useCases: {
${useCasesObj}
},
realtimeChannels: [],
jobs: [],
} as const);`,
);
const useCaseFile = path.join(featureDir, "src", "application", "use-cases", "sign-up.use-case.ts");
fs.writeFileSync(useCaseFile, useCaseBody);
return { repoRoot, useCaseFile };
}
const tester = new RuleTester({
languageOptions: {
parser: await import("@typescript-eslint/parser"),
ecmaVersion: "latest",
sourceType: "module",
},
});
describe("no-undeclared-audit", () => {
it("passes when auditLog.record type matches manifest audits[]", () => {
const { repoRoot, useCaseFile } = makeFixture({
manifestUseCases: { signUp: { mutates: true, audits: ["user.created"] } },
useCaseBody: `export const signUpUseCase = (auditLog) => async () => { auditLog.record({ type: "user.created", subject: "x", actor: "y" }); };`,
});
tester.run("no-undeclared-audit", rule, {
valid: [{ filename: useCaseFile, code: fs.readFileSync(useCaseFile, "utf8"), options: [{ repoRoot }] }],
invalid: [],
});
});
it("fires when auditLog.record type is not in manifest", () => {
const { repoRoot, useCaseFile } = makeFixture({
manifestUseCases: { signUp: { mutates: true, audits: [] } },
useCaseBody: `export const signUpUseCase = (auditLog) => async () => { auditLog.record({ type: "user.created", subject: "x" }); };`,
});
tester.run("no-undeclared-audit", rule, {
valid: [],
invalid: [{
filename: useCaseFile,
code: fs.readFileSync(useCaseFile, "utf8"),
options: [{ repoRoot }],
errors: [{ messageId: "undeclared", data: { event: "user.created", useCase: "signUp" } }],
}],
});
});
it("is a no-op when auditLog.record is called with a non-literal type", () => {
const { repoRoot, useCaseFile } = makeFixture({
manifestUseCases: { signUp: { mutates: true, audits: [] } },
useCaseBody: `export const signUpUseCase = (auditLog, type) => async () => { auditLog.record({ type, subject: "x" }); };`,
});
tester.run("no-undeclared-audit", rule, {
valid: [{ filename: useCaseFile, code: fs.readFileSync(useCaseFile, "utf8"), options: [{ repoRoot }] }],
invalid: [],
});
});
});

View File

@@ -0,0 +1,56 @@
import { parseManifestUseCases } from "./_manifest-ast.js";
import { useCaseNameFromFile } from "./_usecase-name.js";
import { manifestPathForFeature, featureRootForFile } from "./_manifest-source.js";
/** @type {import("eslint").Rule.RuleModule} */
export default {
meta: {
type: "problem",
docs: {
description:
"bus.publish(\"X\") inside a use-case factory must declare X in manifest.useCases[name].publishes.",
},
schema: [
{
type: "object",
properties: { repoRoot: { type: "string" } },
additionalProperties: false,
},
],
messages: {
undeclared:
"{{useCase}} calls bus.publish(\"{{event}}\") but {{event}} is not declared in manifest.useCases.{{useCase}}.publishes. Add it to the manifest or remove the call.",
},
},
create(context) {
const opts = context.options[0] ?? {};
const repoRoot = opts.repoRoot ?? context.cwd ?? process.cwd();
const filename = context.filename;
const useCaseName = useCaseNameFromFile(filename);
if (!useCaseName) return {};
const featureRoot = featureRootForFile(filename, repoRoot);
if (!featureRoot) return {};
const manifest = parseManifestUseCases(manifestPathForFeature(featureRoot));
if (!manifest || !manifest[useCaseName]) return {};
const declared = new Set(manifest[useCaseName].publishes);
return {
CallExpression(node) {
if (
node.callee.type === "MemberExpression" &&
node.callee.object.type === "Identifier" &&
node.callee.object.name === "bus" &&
node.callee.property.type === "Identifier" &&
node.callee.property.name === "publish" &&
node.arguments.length > 0 &&
node.arguments[0].type === "Literal" &&
typeof node.arguments[0].value === "string"
) {
const event = node.arguments[0].value;
if (!declared.has(event)) {
context.report({ node, messageId: "undeclared", data: { event, useCase: useCaseName } });
}
}
},
};
},
};

View File

@@ -0,0 +1,80 @@
import { describe, it } from "vitest";
import { RuleTester } from "eslint";
import path from "node:path";
import os from "node:os";
import fs from "node:fs";
import rule from "./no-undeclared-event-publish.js";
function makeFixture({ manifestUseCases, useCaseBody }) {
const repoRoot = fs.mkdtempSync(path.join(os.tmpdir(), "nuep-"));
const featureDir = path.join(repoRoot, "packages", "demo");
fs.mkdirSync(path.join(featureDir, "src", "application", "use-cases"), { recursive: true });
const useCasesObj = Object.entries(manifestUseCases)
.map(([name, uc]) =>
` ${name}: { mutates: ${uc.mutates}, audits: [${uc.audits.map((a) => `"${a}"`).join(", ")}], publishes: [${uc.publishes.map((p) => `"${p}"`).join(", ")}], consumes: [] },`,
)
.join("\n");
fs.writeFileSync(
path.join(featureDir, "src", "feature.manifest.ts"),
`export const demoManifest = defineFeature({
name: "demo",
requiredCores: [],
useCases: {
${useCasesObj}
},
realtimeChannels: [],
jobs: [],
} as const);`,
);
const useCaseFile = path.join(featureDir, "src", "application", "use-cases", "sign-up.use-case.ts");
fs.writeFileSync(useCaseFile, useCaseBody);
return { repoRoot, useCaseFile };
}
const tester = new RuleTester({
languageOptions: {
parser: await import("@typescript-eslint/parser"),
ecmaVersion: "latest",
sourceType: "module",
},
});
describe("no-undeclared-event-publish", () => {
it("passes when bus.publish event name matches manifest publishes[]", () => {
const { repoRoot, useCaseFile } = makeFixture({
manifestUseCases: { signUp: { mutates: true, audits: [], publishes: ["demo.signed-up"] } },
useCaseBody: `export const signUpUseCase = (bus) => async () => { bus.publish("demo.signed-up", {}); };`,
});
tester.run("no-undeclared-event-publish", rule, {
valid: [{ filename: useCaseFile, code: fs.readFileSync(useCaseFile, "utf8"), options: [{ repoRoot }] }],
invalid: [],
});
});
it("fires when bus.publish event name is not in manifest", () => {
const { repoRoot, useCaseFile } = makeFixture({
manifestUseCases: { signUp: { mutates: true, audits: [], publishes: [] } },
useCaseBody: `export const signUpUseCase = (bus) => async () => { bus.publish("demo.signed-up", {}); };`,
});
tester.run("no-undeclared-event-publish", rule, {
valid: [],
invalid: [{
filename: useCaseFile,
code: fs.readFileSync(useCaseFile, "utf8"),
options: [{ repoRoot }],
errors: [{ messageId: "undeclared", data: { event: "demo.signed-up", useCase: "signUp" } }],
}],
});
});
it("is a no-op when bus.publish is called with a non-literal argument", () => {
const { repoRoot, useCaseFile } = makeFixture({
manifestUseCases: { signUp: { mutates: true, audits: [], publishes: [] } },
useCaseBody: `export const signUpUseCase = (bus, name) => async () => { bus.publish(name, {}); };`,
});
tester.run("no-undeclared-event-publish", rule, {
valid: [{ filename: useCaseFile, code: fs.readFileSync(useCaseFile, "utf8"), options: [{ repoRoot }] }],
invalid: [],
});
});
});

View File

@@ -6,6 +6,8 @@ import {
type ILogger,
} from "@repo/core-shared/instrumentation";
import type { BindContext } from "@repo/core-shared/di";
import { assertFeatureConformance } from "@repo/core-shared/conformance";
import { marketingPagesManifest } from "../feature.manifest.js";
import { marketingPagesContainer } from "./container.js";
import { MARKETING_PAGES_SYMBOLS } from "./symbols.js";
import { MockPagesRepository } from "../infrastructure/repositories/pages.repository.mock.js";
@@ -196,4 +198,15 @@ export async function bindDevSeedMarketingPages(ctx: BindContext): Promise<void>
void realtime;
void realtimeRegistry;
// <gen:realtime-handlers>
// Boot-time conformance check (dev-seed mode).
assertFeatureConformance(
marketingPagesContainer,
marketingPagesManifest,
{
getPageBySlug: MARKETING_PAGES_SYMBOLS.IGetPageBySlugUseCase,
getSiteSettings: MARKETING_PAGES_SYMBOLS.IGetSiteSettingsUseCase,
},
ctx,
);
}

View File

@@ -6,8 +6,10 @@ import {
type ILogger,
} from "@repo/core-shared/instrumentation";
import type { BindProductionContext } from "@repo/core-shared/di";
import { assertFeatureConformance } from "@repo/core-shared/conformance";
import { marketingPagesContainer } from "./container";
import { MARKETING_PAGES_SYMBOLS } from "./symbols";
import { marketingPagesManifest } from "../feature.manifest";
import { PagesRepository } from "../infrastructure/repositories/pages.repository";
import { SiteSettingsRepository } from "../infrastructure/repositories/site-settings.repository";
import { getSiteSettingsUseCase } from "../application/use-cases/get-site-settings.use-case";
@@ -172,4 +174,15 @@ export function bindProductionMarketingPages(ctx: BindProductionContext): void {
void realtime;
void realtimeRegistry;
// <gen:realtime-handlers>
// Boot-time conformance check.
assertFeatureConformance(
marketingPagesContainer,
marketingPagesManifest,
{
getPageBySlug: MARKETING_PAGES_SYMBOLS.IGetPageBySlugUseCase,
getSiteSettings: MARKETING_PAGES_SYMBOLS.IGetSiteSettingsUseCase,
},
ctx,
);
}

View File

@@ -0,0 +1,17 @@
import { defineFeature } from "@repo/core-shared/conformance";
/**
* The marketing-pages feature's conformance manifest.
*/
export const marketingPagesManifest = defineFeature({
name: "marketing-pages",
requiredCores: [],
useCases: {
getPageBySlug: { mutates: false, audits: [], publishes: [], consumes: [] },
getSiteSettings: { mutates: false, audits: [], publishes: [], consumes: [] },
},
realtimeChannels: [],
jobs: [],
} as const);
export type MarketingPagesManifest = typeof marketingPagesManifest;

View File

@@ -26,3 +26,4 @@ export type { IGetSiteSettingsController } from "./interface-adapters/controller
// <gen:events>
// <gen:realtime-channels>
export { marketingPagesManifest, type MarketingPagesManifest } from "./feature.manifest";

View File

@@ -6,6 +6,8 @@ import {
type ILogger,
} from "@repo/core-shared/instrumentation";
import type { BindContext } from "@repo/core-shared/di";
import { assertFeatureConformance } from "@repo/core-shared/conformance";
import { mediaManifest } from "../feature.manifest.js";
import { mediaContainer } from "./container.js";
import { MEDIA_SYMBOLS } from "./symbols.js";
import { MockMediaRepository } from "../infrastructure/repositories/media.repository.mock.js";
@@ -145,4 +147,16 @@ export async function bindDevSeedMedia(ctx: BindContext): Promise<void> {
// <gen:event-handlers>
// <gen:jobs>
// <gen:realtime-handlers>
// Boot-time conformance check (dev-seed mode).
assertFeatureConformance(
mediaContainer,
mediaManifest,
{
getMedia: MEDIA_SYMBOLS.IGetMediaUseCase,
listMedia: MEDIA_SYMBOLS.IListMediaUseCase,
deleteMedia: MEDIA_SYMBOLS.IDeleteMediaUseCase,
},
ctx,
);
}

View File

@@ -6,8 +6,10 @@ import {
type ILogger,
} from "@repo/core-shared/instrumentation";
import type { BindProductionContext } from "@repo/core-shared/di";
import { assertFeatureConformance } from "@repo/core-shared/conformance";
import { mediaContainer } from "./container";
import { MEDIA_SYMBOLS } from "./symbols";
import { mediaManifest } from "../feature.manifest";
import { MediaRepository } from "../infrastructure/repositories/media.repository";
import { getMediaUseCase } from "../application/use-cases/get-media.use-case";
import { listMediaUseCase } from "../application/use-cases/list-media.use-case";
@@ -134,4 +136,16 @@ export function bindProductionMedia(ctx: BindProductionContext): void {
// <gen:event-handlers>
// <gen:jobs>
// <gen:realtime-handlers>
// Boot-time conformance check.
assertFeatureConformance(
mediaContainer,
mediaManifest,
{
getMedia: MEDIA_SYMBOLS.IGetMediaUseCase,
listMedia: MEDIA_SYMBOLS.IListMediaUseCase,
deleteMedia: MEDIA_SYMBOLS.IDeleteMediaUseCase,
},
ctx,
);
}

View File

@@ -0,0 +1,18 @@
import { defineFeature } from "@repo/core-shared/conformance";
/**
* The media feature's conformance manifest.
*/
export const mediaManifest = defineFeature({
name: "media",
requiredCores: [],
useCases: {
getMedia: { mutates: false, audits: [], publishes: [], consumes: [] },
listMedia: { mutates: false, audits: [], publishes: [], consumes: [] },
deleteMedia: { mutates: true, audits: [], publishes: [], consumes: [] },
},
realtimeChannels: [],
jobs: [],
} as const);
export type MediaManifest = typeof mediaManifest;

View File

@@ -31,3 +31,4 @@ export type { IDeleteMediaController } from "./interface-adapters/controllers/de
// <gen:events>
// <gen:realtime-channels>
export { mediaManifest, type MediaManifest } from "./feature.manifest";

View File

@@ -6,6 +6,8 @@ import {
type ILogger,
} from "@repo/core-shared/instrumentation";
import type { BindContext } from "@repo/core-shared/di";
import { assertFeatureConformance } from "@repo/core-shared/conformance";
import { navigationManifest } from "../feature.manifest.js";
import { navigationContainer } from "./container.js";
import { NAVIGATION_SYMBOLS } from "./symbols.js";
import { MockHeaderRepository } from "../infrastructure/repositories/header.repository.mock.js";
@@ -89,4 +91,12 @@ export async function bindDevSeedNavigation(ctx: BindContext): Promise<void> {
// <gen:event-handlers>
// <gen:jobs>
// <gen:realtime-handlers>
// Boot-time conformance check (dev-seed mode).
assertFeatureConformance(
navigationContainer,
navigationManifest,
{ getHeader: NAVIGATION_SYMBOLS.IGetHeaderUseCase },
ctx,
);
}

View File

@@ -6,8 +6,10 @@ import {
type ILogger,
} from "@repo/core-shared/instrumentation";
import type { BindProductionContext } from "@repo/core-shared/di";
import { assertFeatureConformance } from "@repo/core-shared/conformance";
import { navigationContainer } from "./container";
import { NAVIGATION_SYMBOLS } from "./symbols";
import { navigationManifest } from "../feature.manifest";
import { HeaderRepository } from "../infrastructure/repositories/header.repository";
import { getHeaderUseCase } from "../application/use-cases/get-header.use-case";
import { getHeaderController } from "../interface-adapters/controllers/get-header.controller";
@@ -78,4 +80,12 @@ export function bindProductionNavigation(ctx: BindProductionContext): void {
// <gen:event-handlers>
// <gen:jobs>
// <gen:realtime-handlers>
// Boot-time conformance check.
assertFeatureConformance(
navigationContainer,
navigationManifest,
{ getHeader: NAVIGATION_SYMBOLS.IGetHeaderUseCase },
ctx,
);
}

View File

@@ -0,0 +1,16 @@
import { defineFeature } from "@repo/core-shared/conformance";
/**
* The navigation feature's conformance manifest.
*/
export const navigationManifest = defineFeature({
name: "navigation",
requiredCores: [],
useCases: {
getHeader: { mutates: false, audits: [], publishes: [], consumes: [] },
},
realtimeChannels: [],
jobs: [],
} as const);
export type NavigationManifest = typeof navigationManifest;

Some files were not shown because too many files have changed in this diff Show More