Compare commits
100 Commits
70c211cbf5
...
e87e554a7c
| Author | SHA1 | Date | |
|---|---|---|---|
| e87e554a7c | |||
| 072e08d855 | |||
| 7ecb365e63 | |||
| d34b841256 | |||
| 1655cd0879 | |||
| 749938e48b | |||
| 7c7a65f7e3 | |||
| e1d54a1f3e | |||
| 2722305398 | |||
| 85fb050019 | |||
| 10603bd250 | |||
| 9089d13c5f | |||
| f16c7b1b60 | |||
| f761dbb9b1 | |||
| f43c314156 | |||
| 4fa108dd48 | |||
| 6921abcd11 | |||
| 06c9fb1823 | |||
| d1b00f1cf5 | |||
| da811eb461 | |||
| 1e7bd68b17 | |||
| 35664f67d5 | |||
| 28ba9d04e8 | |||
| 1385219b48 | |||
| 5bf636e0b3 | |||
| e441d0f477 | |||
| 4ec804107b | |||
| 988667fc47 | |||
| b28d7a6f71 | |||
| 7fc4c23036 | |||
| 7d08aff089 | |||
| 4d5e349233 | |||
| e92610e7f1 | |||
| 4cf979aaa5 | |||
| 23fedac1a8 | |||
| adabb3428d | |||
| 886826d620 | |||
| ca006494c5 | |||
| a325794309 | |||
| 1ebffa68a6 | |||
| 56ed918b09 | |||
| a2eeccfd9d | |||
| ef64c015e0 | |||
| ee315352ff | |||
| 9858d49787 | |||
| be8e89baed | |||
| 6b57d76dc2 | |||
| eac711ecec | |||
| 7a080efcb6 | |||
| c97810015d | |||
| 69d84a598d | |||
| 159db9e542 | |||
| f933ca74ff | |||
| 9ef545b714 | |||
| 89c72bd30a | |||
| 83faf07ab1 | |||
| 2399f950c2 | |||
| f06f7dbadf | |||
| 171ed20527 | |||
| d83d97755e | |||
| e6d9600fbe | |||
| 7877c1645c | |||
| 87da671432 | |||
| 4966191c8b | |||
| 798281dd14 | |||
| efcb65e726 | |||
| cc6f87e6e2 | |||
| 0a2c570a69 | |||
| 3c810decb2 | |||
| 5cc59e79db | |||
| be1938404a | |||
| 9177d1f1db | |||
| 79f96a94a1 | |||
| 5fa7de48d8 | |||
| 7953a44203 | |||
| dc7aafb97f | |||
| 9bbc068498 | |||
| 81a075a9b9 | |||
| 7d38ff3bb9 | |||
| c3da533e42 | |||
| 5bf9a140ed | |||
| 300143e7e1 | |||
| 8da21c0405 | |||
| 19611fb92c | |||
| a90bdef219 | |||
| f374d8b874 | |||
| 132ebc689f | |||
| dfd6e1c3cc | |||
| 24769eb442 | |||
| a0b2ecee2b | |||
| b5771fc4ba | |||
| b0563e4712 | |||
| a07e62befd | |||
| 4d7b25fd15 | |||
| 15e90820d2 | |||
| 3e2b096c3c | |||
| 21a6822d55 | |||
| 7cfb78b99e | |||
| bfb2324dd9 | |||
| fb23980d85 |
62
.fallowrc.json
Normal file
62
.fallowrc.json
Normal 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
|
||||||
|
}
|
||||||
|
}
|
||||||
14
.github/workflows/ci.yml
vendored
14
.github/workflows/ci.yml
vendored
@@ -49,6 +49,9 @@ jobs:
|
|||||||
- run: pnpm install --frozen-lockfile
|
- run: pnpm install --frozen-lockfile
|
||||||
- run: pnpm typecheck
|
- run: pnpm typecheck
|
||||||
- run: pnpm lint
|
- run: pnpm lint
|
||||||
|
- run: pnpm conformance
|
||||||
|
- name: Fallow whole-codebase analysis
|
||||||
|
run: pnpm fallow --format annotations
|
||||||
- run: pnpm turbo boundaries
|
- run: pnpm turbo boundaries
|
||||||
- name: Test with coverage
|
- name: Test with coverage
|
||||||
env:
|
env:
|
||||||
@@ -60,7 +63,7 @@ jobs:
|
|||||||
if: always()
|
if: always()
|
||||||
with:
|
with:
|
||||||
name: coverage
|
name: coverage
|
||||||
path: '**/coverage/lcov.info'
|
path: "**/coverage/lcov.info"
|
||||||
retention-days: 7
|
retention-days: 7
|
||||||
|
|
||||||
e2e:
|
e2e:
|
||||||
@@ -99,7 +102,7 @@ jobs:
|
|||||||
run: pnpm test:e2e
|
run: pnpm test:e2e
|
||||||
|
|
||||||
storybook:
|
storybook:
|
||||||
name: Storybook smoke tests
|
name: Storybook smoke tests + visual regression
|
||||||
needs: validate
|
needs: validate
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
@@ -113,5 +116,10 @@ jobs:
|
|||||||
cache: pnpm
|
cache: pnpm
|
||||||
- run: pnpm install --frozen-lockfile
|
- run: pnpm install --frozen-lockfile
|
||||||
- run: pnpm exec playwright install --with-deps chromium
|
- 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
|
- 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
18
.husky/pre-commit
Executable 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
11
.sandcastle/.env.example
Normal 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
3
.sandcastle/.gitignore
vendored
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
.env
|
||||||
|
*.log
|
||||||
|
.cache/
|
||||||
14
.sandcastle/Dockerfile
Normal file
14
.sandcastle/Dockerfile
Normal 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
37
.sandcastle/README.md
Normal 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.
|
||||||
61
.sandcastle/adr-eliciter.prompt.md
Normal file
61
.sandcastle/adr-eliciter.prompt.md
Normal 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.
|
||||||
45
.sandcastle/decomposer.prompt.md
Normal file
45
.sandcastle/decomposer.prompt.md
Normal 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.
|
||||||
79
.sandcastle/implementer.prompt.md
Normal file
79
.sandcastle/implementer.prompt.md
Normal 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.
|
||||||
64
.sandcastle/prd-eliciter.prompt.md
Normal file
64
.sandcastle/prd-eliciter.prompt.md
Normal 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
|
||||||
50
.sandcastle/reviewer.prompt.md
Normal file
50
.sandcastle/reviewer.prompt.md
Normal 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).
|
||||||
16
AGENTS.md
16
AGENTS.md
@@ -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)
|
### Cross-feature events and background jobs (Plan 10, ADR-015)
|
||||||
|
|
||||||
Three rules:
|
Three rules:
|
||||||
|
|||||||
43
CLAUDE.md
43
CLAUDE.md
@@ -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/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/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/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/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/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/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
|
- `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`) | ~30–60s | 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
|
## Key Conventions
|
||||||
|
|
||||||
- **Relative imports in `src/`** — Source files use relative paths (`../repositories/...`), not `@/` alias
|
- **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)
|
- **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
|
- **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
|
- **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`)
|
- **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` only — direct `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 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`
|
- **`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
|
## MCP Servers
|
||||||
|
|
||||||
@@ -83,10 +102,10 @@ Storybook MCP available at `http://localhost:6006/mcp` — use `list-all-documen
|
|||||||
|
|
||||||
## Key Ports
|
## Key Ports
|
||||||
|
|
||||||
| Service | Port |
|
| Service | Port |
|
||||||
|---|---|
|
| -------------- | ---- |
|
||||||
| Next.js | 3000 |
|
| Next.js | 3000 |
|
||||||
| Payload CMS | 3001 |
|
| Payload CMS | 3001 |
|
||||||
| TanStack Start | 3002 |
|
| TanStack Start | 3002 |
|
||||||
| PostgreSQL | 5432 |
|
| PostgreSQL | 5432 |
|
||||||
| Storybook | 6006 |
|
| Storybook | 6006 |
|
||||||
|
|||||||
@@ -14,6 +14,7 @@
|
|||||||
},
|
},
|
||||||
"dependencies": {},
|
"dependencies": {},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
"@playwright/test": "^1.49.0",
|
||||||
"@repo/core-eslint": "workspace:*",
|
"@repo/core-eslint": "workspace:*",
|
||||||
"@repo/core-typescript": "workspace:*",
|
"@repo/core-typescript": "workspace:*",
|
||||||
"@storybook/addon-essentials": "^8.6.0",
|
"@storybook/addon-essentials": "^8.6.0",
|
||||||
|
|||||||
51
apps/storybook/tests/visual.spec.ts
Normal file
51
apps/storybook/tests/visual.spec.ts
Normal 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`);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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
|
## 1. Overview
|
||||||
|
|
||||||
Every feature package owns:
|
Every feature package owns:
|
||||||
|
|||||||
137
docs/guides/conformance-quickref.md
Normal file
137
docs/guides/conformance-quickref.md
Normal 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` | ~30–60s | 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`.
|
||||||
@@ -32,6 +32,17 @@ pnpm install # link the new workspace package
|
|||||||
pnpm --filter @repo/widgets lint typecheck test
|
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
|
## What it generates
|
||||||
|
|
||||||
- Package files: `package.json`, `tsconfig.json`, `vitest.config.ts`,
|
- Package files: `package.json`, `tsconfig.json`, `vitest.config.ts`,
|
||||||
|
|||||||
@@ -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`.
|
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`.
|
**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.
|
||||||
|
|||||||
@@ -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"
|
||||||
|
```
|
||||||
@@ -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`.
|
||||||
@@ -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`.
|
||||||
@@ -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
414
docs/work/_state.json
Normal 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": []
|
||||||
|
}
|
||||||
42
docs/work/agent-workflow-docs-v1/01-docs-rewrite/_story.md
Normal file
42
docs/work/agent-workflow-docs-v1/01-docs-rewrite/_story.md
Normal 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
|
||||||
36
docs/work/agent-workflow-docs-v1/_epic.md
Normal file
36
docs/work/agent-workflow-docs-v1/_epic.md
Normal 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)
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
27
docs/work/conformance-hardening-v1/_epic.md
Normal file
27
docs/work/conformance-hardening-v1/_epic.md
Normal 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)
|
||||||
@@ -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)
|
||||||
47
docs/work/conformance-system-v1/04-ci-drift-gate/_story.md
Normal file
47
docs/work/conformance-system-v1/04-ci-drift-gate/_story.md
Normal 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
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -3,7 +3,7 @@ id: conformance-system-v1
|
|||||||
prd: null
|
prd: null
|
||||||
title: Conformance system v1
|
title: Conformance system v1
|
||||||
type: epic
|
type: epic
|
||||||
status: in-progress
|
status: done
|
||||||
features: [cross-cutting]
|
features: [cross-cutting]
|
||||||
created: 2026-05-12
|
created: 2026-05-12
|
||||||
---
|
---
|
||||||
@@ -32,10 +32,10 @@ See `docs/architecture/feature-conformance-explainer.html` and
|
|||||||
## Stories
|
## Stories
|
||||||
- [x] [01 — defineFeature helper + Instrumented/Captured/Audited brands](01-define-feature-helper/_story.md)
|
- [x] [01 — defineFeature helper + Instrumented/Captured/Audited brands](01-define-feature-helper/_story.md)
|
||||||
- [x] [02 — `assertFeatureConformance` + boot wiring](02-boot-assertions/_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)
|
- [x] [03.a — Structural rules](03-a-structural-eslint-rules/_story.md)
|
||||||
- [ ] 03.b — Manifest-aware AST rules (later plan)
|
- [x] [03.b — Manifest-aware AST rules](03-b-ast-eslint-rules/_story.md)
|
||||||
- [ ] 04 — CI drift gate (later plan)
|
- [x] [04 — CI drift gate](04-ci-drift-gate/_story.md)
|
||||||
- [ ] 05 — Generator emits manifest + contracts + test stubs (later plan)
|
- [x] [05 — Generator updates](05-generator-updates/_story.md)
|
||||||
- [ ] 06 — Documentation rewrite (later plan)
|
- [x] [06 — Migrate blog / media / navigation / marketing-pages](06-feature-migrations/_story.md)
|
||||||
- [ ] 07 — Migrate auth feature reference (later plan)
|
- [x] 07 — Migrate auth feature reference (completed inline in milestone i; signIn through ProductionUseCase slot)
|
||||||
|
|||||||
17
docs/work/dag-and-readiness-v1/01-extended-state/_story.md
Normal file
17
docs/work/dag-and-readiness-v1/01-extended-state/_story.md
Normal 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
|
||||||
16
docs/work/dag-and-readiness-v1/02-dag-computation/_story.md
Normal file
16
docs/work/dag-and-readiness-v1/02-dag-computation/_story.md
Normal 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
|
||||||
18
docs/work/dag-and-readiness-v1/03-cli-subcommands/_story.md
Normal file
18
docs/work/dag-and-readiness-v1/03-cli-subcommands/_story.md
Normal 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
|
||||||
21
docs/work/dag-and-readiness-v1/_epic.md
Normal file
21
docs/work/dag-and-readiness-v1/_epic.md
Normal 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)
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
22
docs/work/elicitation-prompts-v1/_epic.md
Normal file
22
docs/work/elicitation-prompts-v1/_epic.md
Normal 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)
|
||||||
17
docs/work/fallow-integration-v1/01-fallow-install/_story.md
Normal file
17
docs/work/fallow-integration-v1/01-fallow-install/_story.md
Normal 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
|
||||||
@@ -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
|
||||||
15
docs/work/fallow-integration-v1/03-ci-integration/_story.md
Normal file
15
docs/work/fallow-integration-v1/03-ci-integration/_story.md
Normal 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
|
||||||
@@ -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
|
||||||
28
docs/work/fallow-integration-v1/_epic.md
Normal file
28
docs/work/fallow-integration-v1/_epic.md
Normal 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)
|
||||||
@@ -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
|
||||||
22
docs/work/frontend-conformance-v1/_epic.md
Normal file
22
docs/work/frontend-conformance-v1/_epic.md
Normal 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)
|
||||||
28
docs/work/pre-commit-hooks-v1/01-husky-install/_story.md
Normal file
28
docs/work/pre-commit-hooks-v1/01-husky-install/_story.md
Normal 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/`
|
||||||
15
docs/work/pre-commit-hooks-v1/02-pre-commit-hook/_story.md
Normal file
15
docs/work/pre-commit-hooks-v1/02-pre-commit-hook/_story.md
Normal 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
|
||||||
17
docs/work/pre-commit-hooks-v1/03-state-sync-guard/_story.md
Normal file
17
docs/work/pre-commit-hooks-v1/03-state-sync-guard/_story.md
Normal 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
|
||||||
27
docs/work/pre-commit-hooks-v1/_epic.md
Normal file
27
docs/work/pre-commit-hooks-v1/_epic.md
Normal 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)
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
27
docs/work/sandcastle-dispatch-v1/_epic.md
Normal file
27
docs/work/sandcastle-dispatch-v1/_epic.md
Normal 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)
|
||||||
@@ -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)
|
||||||
@@ -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
|
||||||
15
docs/work/visual-regression-v1/03-ci-integration/_story.md
Normal file
15
docs/work/visual-regression-v1/03-ci-integration/_story.md
Normal 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
|
||||||
27
docs/work/visual-regression-v1/_epic.md
Normal file
27
docs/work/visual-regression-v1/_epic.md
Normal 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)
|
||||||
33
docs/work/work-system-v1/01-state-builder-and-cli/_story.md
Normal file
33
docs/work/work-system-v1/01-state-builder-and-cli/_story.md
Normal 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
|
||||||
28
docs/work/work-system-v1/_epic.md
Normal file
28
docs/work/work-system-v1/_epic.md
Normal 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
41
eslint.config.js
Normal 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,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
];
|
||||||
21
package.json
21
package.json
@@ -12,15 +12,34 @@
|
|||||||
"test": "turbo run test",
|
"test": "turbo run test",
|
||||||
"test:e2e": "turbo run test:e2e",
|
"test:e2e": "turbo run test:e2e",
|
||||||
"test:stories": "turbo run test:stories",
|
"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",
|
"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": "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": {
|
"devDependencies": {
|
||||||
|
"@ai-hero/sandcastle": "*",
|
||||||
|
"@playwright/test": "^1.49.0",
|
||||||
"@turbo/gen": "^2.4.0",
|
"@turbo/gen": "^2.4.0",
|
||||||
"@types/node": "^22.0.0",
|
"@types/node": "^22.0.0",
|
||||||
|
"fallow": "^2.73.0",
|
||||||
|
"husky": "^9.0.0",
|
||||||
|
"lint-staged": "^16.0.0",
|
||||||
"prettier": "^3.5.0",
|
"prettier": "^3.5.0",
|
||||||
"turbo": "^2.4.0",
|
"turbo": "^2.4.0",
|
||||||
"typescript": "^5.8.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"
|
||||||
|
]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,6 +6,8 @@ import {
|
|||||||
type ILogger,
|
type ILogger,
|
||||||
} from "@repo/core-shared/instrumentation";
|
} from "@repo/core-shared/instrumentation";
|
||||||
import type { BindContext } from "@repo/core-shared/di";
|
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 { authContainer } from "./container.js";
|
||||||
import { AUTH_SYMBOLS } from "./symbols.js";
|
import { AUTH_SYMBOLS } from "./symbols.js";
|
||||||
import { MockUsersRepository } from "../infrastructure/repositories/users.repository.mock.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:event-handlers>
|
||||||
// <gen:jobs>
|
// <gen:jobs>
|
||||||
// <gen:realtime-handlers>
|
// <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,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,6 +6,8 @@ import {
|
|||||||
type ILogger,
|
type ILogger,
|
||||||
} from "@repo/core-shared/instrumentation";
|
} from "@repo/core-shared/instrumentation";
|
||||||
import type { BindContext } from "@repo/core-shared/di";
|
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 { blogContainer } from "./container.js";
|
||||||
import { BLOG_SYMBOLS } from "./symbols.js";
|
import { BLOG_SYMBOLS } from "./symbols.js";
|
||||||
import { MockArticlesRepository } from "../infrastructure/repositories/articles.repository.mock.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:event-handlers>
|
||||||
// <gen:jobs>
|
// <gen:jobs>
|
||||||
// <gen:realtime-handlers>
|
// <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,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,8 +6,10 @@ import {
|
|||||||
type ILogger,
|
type ILogger,
|
||||||
} from "@repo/core-shared/instrumentation";
|
} from "@repo/core-shared/instrumentation";
|
||||||
import type { BindProductionContext } from "@repo/core-shared/di";
|
import type { BindProductionContext } from "@repo/core-shared/di";
|
||||||
|
import { assertFeatureConformance } from "@repo/core-shared/conformance";
|
||||||
import { blogContainer } from "./container";
|
import { blogContainer } from "./container";
|
||||||
import { BLOG_SYMBOLS } from "./symbols";
|
import { BLOG_SYMBOLS } from "./symbols";
|
||||||
|
import { blogManifest } from "../feature.manifest";
|
||||||
import { ArticlesRepository } from "../infrastructure/repositories/articles.repository";
|
import { ArticlesRepository } from "../infrastructure/repositories/articles.repository";
|
||||||
import { getArticlesUseCase } from "../application/use-cases/get-articles.use-case";
|
import { getArticlesUseCase } from "../application/use-cases/get-articles.use-case";
|
||||||
import { getArticleBySlugUseCase } from "../application/use-cases/get-article-by-slug.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:event-handlers>
|
||||||
// <gen:jobs>
|
// <gen:jobs>
|
||||||
// <gen:realtime-handlers>
|
// <gen:realtime-handlers>
|
||||||
|
|
||||||
|
// Boot-time conformance check.
|
||||||
|
assertFeatureConformance(
|
||||||
|
blogContainer,
|
||||||
|
blogManifest,
|
||||||
|
{
|
||||||
|
getArticles: BLOG_SYMBOLS.IGetArticlesUseCase,
|
||||||
|
getArticleBySlug: BLOG_SYMBOLS.IGetArticleBySlugUseCase,
|
||||||
|
createArticle: BLOG_SYMBOLS.ICreateArticleUseCase,
|
||||||
|
},
|
||||||
|
ctx,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
33
packages/blog/src/feature.manifest.ts
Normal file
33
packages/blog/src/feature.manifest.ts
Normal 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;
|
||||||
@@ -33,3 +33,4 @@ export type { IGetArticleBySlugController } from "./interface-adapters/controlle
|
|||||||
|
|
||||||
// <gen:events>
|
// <gen:events>
|
||||||
// <gen:realtime-channels>
|
// <gen:realtime-channels>
|
||||||
|
export { blogManifest, type BlogManifest } from "./feature.manifest";
|
||||||
|
|||||||
@@ -33,10 +33,9 @@ export default [
|
|||||||
plugins: { conformance: conformancePlugin },
|
plugins: { conformance: conformancePlugin },
|
||||||
rules: {
|
rules: {
|
||||||
// Structural conformance rules (milestone iii.a).
|
// Structural conformance rules (milestone iii.a).
|
||||||
// `feature-must-have-manifest` is WARN today because only auth has a manifest;
|
// All 5 features now have manifests; promoted to ERROR.
|
||||||
// flip to ERROR after blog/media/navigation/marketing-pages migrate.
|
|
||||||
"conformance/feature-must-have-manifest": [
|
"conformance/feature-must-have-manifest": [
|
||||||
"warn",
|
"error",
|
||||||
{ repoRoot },
|
{ repoRoot },
|
||||||
],
|
],
|
||||||
"conformance/usecase-must-have-test-file": "error",
|
"conformance/usecase-must-have-test-file": "error",
|
||||||
@@ -44,6 +43,11 @@ export default [
|
|||||||
"error",
|
"error",
|
||||||
{ repoRoot },
|
{ 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",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -1,6 +1,11 @@
|
|||||||
import featureMustHaveManifest from "./rules/feature-must-have-manifest.js";
|
import featureMustHaveManifest from "./rules/feature-must-have-manifest.js";
|
||||||
import usecaseMustHaveTestFile from "./rules/usecase-must-have-test-file.js";
|
import usecaseMustHaveTestFile from "./rules/usecase-must-have-test-file.js";
|
||||||
import requiredCoresInstalled from "./rules/required-cores-installed.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
|
* The `@repo/core-eslint` conformance plugin. Aggregates custom rules that
|
||||||
@@ -16,11 +21,16 @@ import requiredCoresInstalled from "./rules/required-cores-installed.js";
|
|||||||
* ];
|
* ];
|
||||||
*/
|
*/
|
||||||
const plugin = {
|
const plugin = {
|
||||||
meta: { name: "conformance", version: "0.1.0" },
|
meta: { name: "conformance", version: "0.3.0" },
|
||||||
rules: {
|
rules: {
|
||||||
"feature-must-have-manifest": featureMustHaveManifest,
|
"feature-must-have-manifest": featureMustHaveManifest,
|
||||||
"usecase-must-have-test-file": usecaseMustHaveTestFile,
|
"usecase-must-have-test-file": usecaseMustHaveTestFile,
|
||||||
"required-cores-installed": requiredCoresInstalled,
|
"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,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
167
packages/core-eslint/rules/_manifest-ast.js
Normal file
167
packages/core-eslint/rules/_manifest-ast.js
Normal 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;
|
||||||
|
}
|
||||||
100
packages/core-eslint/rules/_manifest-ast.test.js
Normal file
100
packages/core-eslint/rules/_manifest-ast.test.js
Normal 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: {},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,35 +1,16 @@
|
|||||||
import fs from "node:fs";
|
|
||||||
import path from "node:path";
|
import path from "node:path";
|
||||||
|
import { parseManifestFully } from "./_manifest-ast.js";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Reads a feature.manifest.ts file and extracts the manifest's `name` field
|
* Reads a feature.manifest.ts and returns { name, requiredCores }.
|
||||||
* and `requiredCores` array via regex. Returns null if the file does not
|
* Backed by the AST parser from _manifest-ast.js — no longer uses regex.
|
||||||
* 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.
|
|
||||||
*
|
*
|
||||||
* Returns: { name: string, requiredCores: string[] } | null
|
* Returns: { name: string, requiredCores: string[] } | null
|
||||||
*/
|
*/
|
||||||
export function readManifestSource(manifestPath) {
|
export function readManifestSource(manifestPath) {
|
||||||
let src;
|
const full = parseManifestFully(manifestPath);
|
||||||
try {
|
if (!full) return null;
|
||||||
src = fs.readFileSync(manifestPath, "utf8");
|
return { name: full.name, requiredCores: full.requiredCores };
|
||||||
} 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 };
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
17
packages/core-eslint/rules/_usecase-name.js
Normal file
17
packages/core-eslint/rules/_usecase-name.js
Normal 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("");
|
||||||
|
}
|
||||||
19
packages/core-eslint/rules/_usecase-name.test.js
Normal file
19
packages/core-eslint/rules/_usecase-name.test.js
Normal 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();
|
||||||
|
});
|
||||||
|
});
|
||||||
47
packages/core-eslint/rules/atomic-tier-import-direction.js
Normal file
47
packages/core-eslint/rules/atomic-tier-import-direction.js
Normal 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 },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
},
|
||||||
|
};
|
||||||
|
},
|
||||||
|
};
|
||||||
@@ -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: [],
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
59
packages/core-eslint/rules/component-must-have-story.js
Normal file
59
packages/core-eslint/rules/component-must-have-story.js
Normal 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),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
},
|
||||||
|
};
|
||||||
|
},
|
||||||
|
};
|
||||||
84
packages/core-eslint/rules/component-must-have-story.test.js
Normal file
84
packages/core-eslint/rules/component-must-have-story.test.js
Normal 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: [],
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
53
packages/core-eslint/rules/component-must-have-test.js
Normal file
53
packages/core-eslint/rules/component-must-have-test.js
Normal 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),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
},
|
||||||
|
};
|
||||||
|
},
|
||||||
|
};
|
||||||
49
packages/core-eslint/rules/component-must-have-test.test.js
Normal file
49
packages/core-eslint/rules/component-must-have-test.test.js
Normal 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" }],
|
||||||
|
}],
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
67
packages/core-eslint/rules/no-undeclared-audit.js
Normal file
67
packages/core-eslint/rules/no-undeclared-audit.js
Normal 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 } });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
};
|
||||||
|
},
|
||||||
|
};
|
||||||
80
packages/core-eslint/rules/no-undeclared-audit.test.js
Normal file
80
packages/core-eslint/rules/no-undeclared-audit.test.js
Normal 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: [],
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
56
packages/core-eslint/rules/no-undeclared-event-publish.js
Normal file
56
packages/core-eslint/rules/no-undeclared-event-publish.js
Normal 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 } });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
};
|
||||||
|
},
|
||||||
|
};
|
||||||
@@ -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: [],
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -6,6 +6,8 @@ import {
|
|||||||
type ILogger,
|
type ILogger,
|
||||||
} from "@repo/core-shared/instrumentation";
|
} from "@repo/core-shared/instrumentation";
|
||||||
import type { BindContext } from "@repo/core-shared/di";
|
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 { marketingPagesContainer } from "./container.js";
|
||||||
import { MARKETING_PAGES_SYMBOLS } from "./symbols.js";
|
import { MARKETING_PAGES_SYMBOLS } from "./symbols.js";
|
||||||
import { MockPagesRepository } from "../infrastructure/repositories/pages.repository.mock.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 realtime;
|
||||||
void realtimeRegistry;
|
void realtimeRegistry;
|
||||||
// <gen:realtime-handlers>
|
// <gen:realtime-handlers>
|
||||||
|
|
||||||
|
// Boot-time conformance check (dev-seed mode).
|
||||||
|
assertFeatureConformance(
|
||||||
|
marketingPagesContainer,
|
||||||
|
marketingPagesManifest,
|
||||||
|
{
|
||||||
|
getPageBySlug: MARKETING_PAGES_SYMBOLS.IGetPageBySlugUseCase,
|
||||||
|
getSiteSettings: MARKETING_PAGES_SYMBOLS.IGetSiteSettingsUseCase,
|
||||||
|
},
|
||||||
|
ctx,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,8 +6,10 @@ import {
|
|||||||
type ILogger,
|
type ILogger,
|
||||||
} from "@repo/core-shared/instrumentation";
|
} from "@repo/core-shared/instrumentation";
|
||||||
import type { BindProductionContext } from "@repo/core-shared/di";
|
import type { BindProductionContext } from "@repo/core-shared/di";
|
||||||
|
import { assertFeatureConformance } from "@repo/core-shared/conformance";
|
||||||
import { marketingPagesContainer } from "./container";
|
import { marketingPagesContainer } from "./container";
|
||||||
import { MARKETING_PAGES_SYMBOLS } from "./symbols";
|
import { MARKETING_PAGES_SYMBOLS } from "./symbols";
|
||||||
|
import { marketingPagesManifest } from "../feature.manifest";
|
||||||
import { PagesRepository } from "../infrastructure/repositories/pages.repository";
|
import { PagesRepository } from "../infrastructure/repositories/pages.repository";
|
||||||
import { SiteSettingsRepository } from "../infrastructure/repositories/site-settings.repository";
|
import { SiteSettingsRepository } from "../infrastructure/repositories/site-settings.repository";
|
||||||
import { getSiteSettingsUseCase } from "../application/use-cases/get-site-settings.use-case";
|
import { getSiteSettingsUseCase } from "../application/use-cases/get-site-settings.use-case";
|
||||||
@@ -172,4 +174,15 @@ export function bindProductionMarketingPages(ctx: BindProductionContext): void {
|
|||||||
void realtime;
|
void realtime;
|
||||||
void realtimeRegistry;
|
void realtimeRegistry;
|
||||||
// <gen:realtime-handlers>
|
// <gen:realtime-handlers>
|
||||||
|
|
||||||
|
// Boot-time conformance check.
|
||||||
|
assertFeatureConformance(
|
||||||
|
marketingPagesContainer,
|
||||||
|
marketingPagesManifest,
|
||||||
|
{
|
||||||
|
getPageBySlug: MARKETING_PAGES_SYMBOLS.IGetPageBySlugUseCase,
|
||||||
|
getSiteSettings: MARKETING_PAGES_SYMBOLS.IGetSiteSettingsUseCase,
|
||||||
|
},
|
||||||
|
ctx,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
17
packages/marketing-pages/src/feature.manifest.ts
Normal file
17
packages/marketing-pages/src/feature.manifest.ts
Normal 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;
|
||||||
@@ -26,3 +26,4 @@ export type { IGetSiteSettingsController } from "./interface-adapters/controller
|
|||||||
|
|
||||||
// <gen:events>
|
// <gen:events>
|
||||||
// <gen:realtime-channels>
|
// <gen:realtime-channels>
|
||||||
|
export { marketingPagesManifest, type MarketingPagesManifest } from "./feature.manifest";
|
||||||
|
|||||||
@@ -6,6 +6,8 @@ import {
|
|||||||
type ILogger,
|
type ILogger,
|
||||||
} from "@repo/core-shared/instrumentation";
|
} from "@repo/core-shared/instrumentation";
|
||||||
import type { BindContext } from "@repo/core-shared/di";
|
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 { mediaContainer } from "./container.js";
|
||||||
import { MEDIA_SYMBOLS } from "./symbols.js";
|
import { MEDIA_SYMBOLS } from "./symbols.js";
|
||||||
import { MockMediaRepository } from "../infrastructure/repositories/media.repository.mock.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:event-handlers>
|
||||||
// <gen:jobs>
|
// <gen:jobs>
|
||||||
// <gen:realtime-handlers>
|
// <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,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,8 +6,10 @@ import {
|
|||||||
type ILogger,
|
type ILogger,
|
||||||
} from "@repo/core-shared/instrumentation";
|
} from "@repo/core-shared/instrumentation";
|
||||||
import type { BindProductionContext } from "@repo/core-shared/di";
|
import type { BindProductionContext } from "@repo/core-shared/di";
|
||||||
|
import { assertFeatureConformance } from "@repo/core-shared/conformance";
|
||||||
import { mediaContainer } from "./container";
|
import { mediaContainer } from "./container";
|
||||||
import { MEDIA_SYMBOLS } from "./symbols";
|
import { MEDIA_SYMBOLS } from "./symbols";
|
||||||
|
import { mediaManifest } from "../feature.manifest";
|
||||||
import { MediaRepository } from "../infrastructure/repositories/media.repository";
|
import { MediaRepository } from "../infrastructure/repositories/media.repository";
|
||||||
import { getMediaUseCase } from "../application/use-cases/get-media.use-case";
|
import { getMediaUseCase } from "../application/use-cases/get-media.use-case";
|
||||||
import { listMediaUseCase } from "../application/use-cases/list-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:event-handlers>
|
||||||
// <gen:jobs>
|
// <gen:jobs>
|
||||||
// <gen:realtime-handlers>
|
// <gen:realtime-handlers>
|
||||||
|
|
||||||
|
// Boot-time conformance check.
|
||||||
|
assertFeatureConformance(
|
||||||
|
mediaContainer,
|
||||||
|
mediaManifest,
|
||||||
|
{
|
||||||
|
getMedia: MEDIA_SYMBOLS.IGetMediaUseCase,
|
||||||
|
listMedia: MEDIA_SYMBOLS.IListMediaUseCase,
|
||||||
|
deleteMedia: MEDIA_SYMBOLS.IDeleteMediaUseCase,
|
||||||
|
},
|
||||||
|
ctx,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
18
packages/media/src/feature.manifest.ts
Normal file
18
packages/media/src/feature.manifest.ts
Normal 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;
|
||||||
@@ -31,3 +31,4 @@ export type { IDeleteMediaController } from "./interface-adapters/controllers/de
|
|||||||
|
|
||||||
// <gen:events>
|
// <gen:events>
|
||||||
// <gen:realtime-channels>
|
// <gen:realtime-channels>
|
||||||
|
export { mediaManifest, type MediaManifest } from "./feature.manifest";
|
||||||
|
|||||||
@@ -6,6 +6,8 @@ import {
|
|||||||
type ILogger,
|
type ILogger,
|
||||||
} from "@repo/core-shared/instrumentation";
|
} from "@repo/core-shared/instrumentation";
|
||||||
import type { BindContext } from "@repo/core-shared/di";
|
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 { navigationContainer } from "./container.js";
|
||||||
import { NAVIGATION_SYMBOLS } from "./symbols.js";
|
import { NAVIGATION_SYMBOLS } from "./symbols.js";
|
||||||
import { MockHeaderRepository } from "../infrastructure/repositories/header.repository.mock.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:event-handlers>
|
||||||
// <gen:jobs>
|
// <gen:jobs>
|
||||||
// <gen:realtime-handlers>
|
// <gen:realtime-handlers>
|
||||||
|
|
||||||
|
// Boot-time conformance check (dev-seed mode).
|
||||||
|
assertFeatureConformance(
|
||||||
|
navigationContainer,
|
||||||
|
navigationManifest,
|
||||||
|
{ getHeader: NAVIGATION_SYMBOLS.IGetHeaderUseCase },
|
||||||
|
ctx,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,8 +6,10 @@ import {
|
|||||||
type ILogger,
|
type ILogger,
|
||||||
} from "@repo/core-shared/instrumentation";
|
} from "@repo/core-shared/instrumentation";
|
||||||
import type { BindProductionContext } from "@repo/core-shared/di";
|
import type { BindProductionContext } from "@repo/core-shared/di";
|
||||||
|
import { assertFeatureConformance } from "@repo/core-shared/conformance";
|
||||||
import { navigationContainer } from "./container";
|
import { navigationContainer } from "./container";
|
||||||
import { NAVIGATION_SYMBOLS } from "./symbols";
|
import { NAVIGATION_SYMBOLS } from "./symbols";
|
||||||
|
import { navigationManifest } from "../feature.manifest";
|
||||||
import { HeaderRepository } from "../infrastructure/repositories/header.repository";
|
import { HeaderRepository } from "../infrastructure/repositories/header.repository";
|
||||||
import { getHeaderUseCase } from "../application/use-cases/get-header.use-case";
|
import { getHeaderUseCase } from "../application/use-cases/get-header.use-case";
|
||||||
import { getHeaderController } from "../interface-adapters/controllers/get-header.controller";
|
import { getHeaderController } from "../interface-adapters/controllers/get-header.controller";
|
||||||
@@ -78,4 +80,12 @@ export function bindProductionNavigation(ctx: BindProductionContext): void {
|
|||||||
// <gen:event-handlers>
|
// <gen:event-handlers>
|
||||||
// <gen:jobs>
|
// <gen:jobs>
|
||||||
// <gen:realtime-handlers>
|
// <gen:realtime-handlers>
|
||||||
|
|
||||||
|
// Boot-time conformance check.
|
||||||
|
assertFeatureConformance(
|
||||||
|
navigationContainer,
|
||||||
|
navigationManifest,
|
||||||
|
{ getHeader: NAVIGATION_SYMBOLS.IGetHeaderUseCase },
|
||||||
|
ctx,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
16
packages/navigation/src/feature.manifest.ts
Normal file
16
packages/navigation/src/feature.manifest.ts
Normal 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
Reference in New Issue
Block a user