@craft-ts/mcp 0.8.2 → 0.8.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/content/docs-index.json +23 -8
- package/package.json +2 -2
package/content/docs-index.json
CHANGED
|
@@ -182,7 +182,7 @@
|
|
|
182
182
|
{
|
|
183
183
|
"path": "/guide/create-project",
|
|
184
184
|
"title": "Create a CraftTS project",
|
|
185
|
-
"body": "# Create a CraftTS project\n\nUse `craft create` to generate a framework-independent CraftTS application\nwith routing, a typed API example, linting, tests, and the architecture\ncontract already wired up.\n\n## Prerequisites\n\nThe beta toolchain requires Node.js 20.19 or newer. The `craft` executable is\npublished by `@craft-ts/dev-tools`; it is not provided by the unrelated npm\npackage named `craft`.\n\nFor a new project, invoke the executable explicitly through `npx`:\n\n```bash\nnpx --yes --package @craft-ts/dev-tools@beta craft create my-app\n```\n\nThe first `--yes` belongs to `npx`: it accepts the temporary package\ninstallation. The command remains interactive because `craft create` itself\nwas not given `--yes`.\n\nThe command uses the published `beta` package. A checkout of CraftTS can\ncontain a newer creation flow than the version currently published on npm;\ncheck the resolved version with `npm view @craft-ts/dev-tools@beta version` if\nthe prompts shown by your terminal do not match this page.\n\n## Interactive creation\n\nRun the command in a real terminal without `craft create --yes`:\n\n```bash\nnpx --yes --package @craft-ts/dev-tools@beta craft create my-app\n```\n\nThe generator presents menus in this order:\n\n- the application type: frontend-only or full-stack;\n- for a full-stack app, the backend runtime: `promise` or `effect` (EffectTS\n v4 is recommended);\n- the frontend runtime: `plain` or `effect`;\n- type-safe i18n, its locales, and its default locale;\n- the design system;\n- typed CSS;\n- a standalone or Nx workspace;\n- integrations for Codex, Cursor, or Claude Code.\n\nThe frontend and backend choices are independent. To create a plain browser\napplication whose server functions use Effect v4, choose `plain` for the\nfrontend and `effect` for the backend.\n\nUse `↑`/`↓` to move and `Enter` to confirm a single choice. For locales and\nagent integrations, use `Space` to select or deselect several items, then\n`Enter` to confirm. The project directory remains a text field because it is\na free-form path. If the directory is omitted, the generator asks for it too:\n\n```bash\nnpx --yes --package @craft-ts/dev-tools@beta craft create\n```\n\nThe agent question is a multi-selection list. Use `↑`/`↓` to move, `Space` to\nselect or deselect an integration, and `Enter` to confirm. Codex starts\nselected, preserving the default used by scripted creation. Every starter\nreceives an `AGENTS.md` project guide describing its selected runtimes and\nfeatures; selected integrations additionally receive their editor-specific\nproject instructions and skills. Claude Code receives `CLAUDE.md` and skills\nunder `.claude/skills/`.\n\n## Agent-assisted creation\n\nWhen an agent starts a new project, it should first ask what kind of\napplication is being built and what its main features are, without collecting\ndetailed requirements yet. If those features imply a backend, it should\npropose EffectTS v4 for the backend and explain that its typed services, Layers\nand errors fit CraftTS's typed server boundary. The user can confirm that\nstack, reject it, or name another backend; the agent must not add an EffectTS\nbackend after an explicit rejection.\n\nThe agent should create a domain-ready but empty starter with the design\nsystem, typed CSS and strict i18n enabled, and without the explanatory demo\npages:\n\n```bash\nnpx --yes --package @craft-ts/dev-tools@beta craft create my-app \\\n --yes --no-demos --domain app \\\n --frontend-runtime=plain --backend-runtime=effect \\\n --i18n=strict --design-system=basic --typed-css \\\n --references=all --agents=codex\n```\n\nUse `--backend-runtime=none` when the user declines a backend, or the explicit\nrequested backend when it is supported. When no Effect runtime is selected,\nuse `--references=craft-ts` instead of `--references=all`. The `--no-demos`\nstarter still\ncontains the architecture/tooling baseline and a domain boundary, but no\nprefilled product pages or demo content.\n\n### Creating inside an existing Git repository\n\nAn existing `.git` directory makes the destination non-empty. Generate into\nthe current repository with `--force`:\n\n```bash\ncd pet-foster-family\nnpx --yes --package @craft-ts/dev-tools@beta craft create . --force\n```\n\n`--force` only permits writing into a non-empty destination; it does not turn\noff the configuration prompts. Review generated file changes before\ncommitting when the repository already contains application code.\n\nDuring the interactive flow, reference sources are vendored automatically with\n`git subtree`:\n\n- CraftTS sources go into `.references/craft-ts`;\n- EffectTS sources are also vendored when an Effect frontend or backend is\n selected;\n- the sources are committed in the project repository for agents without\n replacing the installed npm packages.\n\nThere is no reference confirmation prompt. The same defaults apply in\nnon-interactive mode: CraftTS is vendored, and EffectTS is vendored whenever an\nEffect frontend or backend is selected. Use `--references=none` to opt out, or\n`--references=craft-ts` / `--references=all` to choose explicitly.\n\nThe vendored repositories are read-only reference material for coding agents\nonly. The generated application always imports the published CraftTS and\nEffectTS npm packages from `package.json`; it does not use `file:` dependencies\nor TypeScript/Vite aliases to the references. Use `npm run update:references`\nto run `git subtree pull` and refresh the recorded source SHA.\n\n## Non-interactive creation\n\nPass `--yes` after `create` to use defaults and disable all prompts. Combine it\nwith explicit options when the generated configuration must be reproducible:\n\n```bash\nnpx --yes --package @craft-ts/dev-tools@beta craft create my-app \\\n --yes --effect=none --agents=codex\n```\n\nFor a minimal plain starter:\n\n```bash\nnpx --yes --package @craft-ts/dev-tools@beta craft create my-app \\\n --yes --effect=none --i18n=none --design-system=none --no-typed-css \\\n --agents=none\n```\n\nTo create a backend-only Effect project and vendor both reference sources:\n\n```bash\nnpx --yes --package @craft-ts/dev-tools@beta craft create my-app \\\n --yes --frontend-runtime=plain --backend-runtime=effect \\\n --references=all\n```\n\nThe main configuration options are:\n\n| Option | Values | Purpose |\n| -------------------- | ------------------------------------- | ------------------------------------------------------------------------- |\n| `--effect` | `v4`, `none` | Select the Effect v4 or plain starter |\n| `--frontend-runtime` | `plain`, `effect` | Choose the frontend runtime |\n| `--backend-runtime` | `none`, `promise`, `effect` | Choose server functions |\n| `--effect-scope` | `none`, `frontend`, `backend`, `both` | Set Effect placement |\n| `--agents` | comma-separated names or `none` | Add editor-specific agent integrations; `AGENTS.md` is always generated |\n| `--i18n` | `strict`, `loose`, `none` | Configure type-safe i18n |\n| `--design-system` | `basic`, `none` | Include the design-system starter |\n| `--typed-css` | flag / `--no-typed-css` | Enable or disable typed CSS |\n| `--workspace` | `standalone`, `nx` | Choose the workspace layout |\n| `--references` | `none`, `craft-ts`, `all` | Include source references (default: CraftTS, plus EffectTS when selected) |\n| `--no-demos` | flag | Generate a domain feature without explanatory demo pages |\n| `--domain` | slug | Name the first domain feature when using `--no-demos` |\n| `--force` | flag | Allow an existing non-empty destination |\n| `--json` | flag | Print the effective configuration as JSON |\n\nUse `craft create --help` to see the complete list:\n\n```bash\nnpx --yes --package @craft-ts/dev-tools@beta craft create --help\n```\n\nFor a domain-first starting point, omit the explanatory home/services/about\npages and name the feature explicitly:\n\n```bash\nnpx --yes --package @craft-ts/dev-tools@beta craft create pet-foster \\\n --yes --no-demos --domain animal --frontend-runtime=effect \\\n --backend-runtime=effect\n```\n\nThe generated feature lives under `src/app/features/animal/`. Add a form to\nthat feature with the existing primitives and its unit/submission test:\n\n```bash\ncraft add form animal\n# advanced nested/schema variant:\ncraft add form animal --advanced\n```\n\n## After generation\n\nThe generator creates a Git repository when the destination is not already\ninside another repository. When references are enabled, it adds them as\ntracked Git subtrees and creates the minimal Git history required by\n`git subtree` when the destination is a new repository. The generated\n`.gitignore` excludes `node_modules/`, build outputs, and test reports.\n\nInstall dependencies and start the generated application:\n\n```bash\ncd my-app\nnpm install\nnpm run dev\n```\n\nThe generated project also includes the following checks:\n\n```bash\nnpm run lint\nnpm run typecheck\nnpm test\nnpm run architecture\nnpm run build\n```\n\n### Generated architecture rules\n\nThe generated `eslint.config.mjs` imports `@craft-ts/dev-tools/eslint-rules`\nand activates the selected `recommended` or `effect` preset. These presets\nenforce the same architecture as the generated project guide:\n\n- remote reads and writes stay directly in query or mutation loaders; they\n must not be hidden in `craftMethod`;\n- `query`, `mutation` and `asyncProcess` loaders are generator functions;\n express asynchronous work with `yield*`, never with `async` or a native\n `Promise` return;\n- resource loaders infer their result instead of using casts such as\n `as PromiseLike<...>`;\n- route-visible filters, search, sort and pagination use route-level\n `queryParams`, not component-local `state`;\n- template event handlers emit one `source$`; query, mutation and state react\n through `on$` instead of chaining imperative method calls.\n\nThe generated agent skill repeats these boundaries so new features follow the\nsame rules. Run `npm run lint` after generation to verify the project.\n\nWith a backend, `src/server/application.ts` owns the registry and runtime\nLayer, while `src/server/node-http.ts` is only the Node stream adapter.\n`server.ts` re-exports both for compatibility. In the backend-only Effect\nprofile, the browser remains plain CraftTS; Effect services, middleware and\nerror projections stay under the server boundary.\n\n## Troubleshooting\n\n### `could not determine executable to run`\n\nIf the error mentions `craft@0.1.0`, `npx` resolved the unrelated public npm\npackage named `craft`. Use the explicit `--package @craft-ts/dev-tools@beta`\nform shown above.\n\nIf `@craft-ts/dev-tools` is already installed in the project, its local binary\ncan also be called with:\n\n```bash\nnpx craft create my-app\n```\n\nThe explicit form is still the safest command when bootstrapping a project\nthat has no `package.json` yet.\n"
|
|
185
|
+
"body": "# Create a CraftTS project\n\nUse `craft create` to generate a framework-independent CraftTS application\nwith routing, a typed API example, linting, tests, and the architecture\ncontract already wired up.\n\n## Prerequisites\n\nThe beta toolchain requires Node.js 20.19 or newer. The `craft` executable is\npublished by `@craft-ts/dev-tools`; it is not provided by the unrelated npm\npackage named `craft`.\n\nFor a new project, invoke the executable explicitly through `npx`:\n\n```bash\nnpx --yes --package @craft-ts/dev-tools@beta craft create my-app\n```\n\nThe first `--yes` belongs to `npx`: it accepts the temporary package\ninstallation. The command remains interactive because `craft create` itself\nwas not given `--yes`.\n\nThe command uses the published `beta` package. A checkout of CraftTS can\ncontain a newer creation flow than the version currently published on npm;\ncheck the resolved version with `npm view @craft-ts/dev-tools@beta version` if\nthe prompts shown by your terminal do not match this page.\n\n## Interactive creation\n\nRun the command in a real terminal without `craft create --yes`:\n\n```bash\nnpx --yes --package @craft-ts/dev-tools@beta craft create my-app\n```\n\nThe generator presents menus in this order:\n\n- the application type: frontend-only or full-stack;\n- for a full-stack app, the backend runtime: `promise` or `effect` (EffectTS\n v4 is recommended);\n- the frontend runtime: `plain` or `effect`;\n- type-safe i18n, its locales, and its default locale;\n- the design system;\n- typed CSS;\n- a standalone or Nx workspace;\n- integrations for Codex, Cursor, or Claude Code.\n\nThe frontend and backend choices are independent. To create a plain browser\napplication whose server functions use Effect v4, choose `plain` for the\nfrontend and `effect` for the backend.\n\nUse `↑`/`↓` to move and `Enter` to confirm a single choice. For locales and\nagent integrations, use `Space` to select or deselect several items, then\n`Enter` to confirm. The project directory remains a text field because it is\na free-form path. If the directory is omitted, the generator asks for it too:\n\n```bash\nnpx --yes --package @craft-ts/dev-tools@beta craft create\n```\n\nThe agent question is a multi-selection list. Use `↑`/`↓` to move, `Space` to\nselect or deselect an integration, and `Enter` to confirm. Codex starts\nselected, preserving the default used by scripted creation. Every starter\nreceives an `AGENTS.md` project guide describing its selected runtimes and\nfeatures; selected integrations additionally receive their editor-specific\nproject instructions and skills. Claude Code receives `CLAUDE.md` and skills\nunder `.claude/skills/`.\n\n## Agent-assisted creation\n\nWhen an agent starts a new project, it should first ask what kind of\napplication is being built and what its main features are, without collecting\ndetailed requirements yet. If those features imply a backend, it should\npropose EffectTS v4 for the backend and explain that its typed services, Layers\nand errors fit CraftTS's typed server boundary. The user can confirm that\nstack, reject it, or name another backend; the agent must not add an EffectTS\nbackend after an explicit rejection.\n\nThe agent should create a domain-ready but empty starter with the design\nsystem, typed CSS and strict i18n enabled, and without the explanatory demo\npages:\n\n```bash\nnpx --yes --package @craft-ts/dev-tools@beta craft create my-app \\\n --yes --no-demos --domain app \\\n --frontend-runtime=plain --backend-runtime=effect \\\n --i18n=strict --design-system=basic --typed-css \\\n --references=all --agents=codex\n```\n\nUse `--backend-runtime=none` when the user declines a backend, or the explicit\nrequested backend when it is supported. When no Effect runtime is selected,\nuse `--references=craft-ts` instead of `--references=all`. The `--no-demos`\nstarter still\ncontains the architecture/tooling baseline and a domain boundary, but no\nprefilled product pages or demo content.\n\n### Creating inside an existing Git repository\n\nAn existing `.git` directory makes the destination non-empty. Generate into\nthe current repository with `--force`:\n\n```bash\ncd pet-foster-family\nnpx --yes --package @craft-ts/dev-tools@beta craft create . --force\n```\n\n`--force` only permits writing into a non-empty destination; it does not turn\noff the configuration prompts. Review generated file changes before\ncommitting when the repository already contains application code.\n\nDuring the interactive flow, reference sources are vendored automatically with\n`git subtree`:\n\n- CraftTS sources go into `.references/craft-ts`;\n- EffectTS sources are also vendored when an Effect frontend or backend is\n selected;\n- the sources are committed in the project repository for agents without\n replacing the installed npm packages.\n\nThere is no reference confirmation prompt. The same defaults apply in\nnon-interactive mode: CraftTS is vendored, and EffectTS is vendored whenever an\nEffect frontend or backend is selected. Use `--references=none` to opt out, or\n`--references=craft-ts` / `--references=all` to choose explicitly.\n\nThe vendored repositories are read-only reference material for coding agents\nonly. The generated application always imports the published CraftTS and\nEffectTS npm packages from `package.json`; it does not use `file:` dependencies\nor TypeScript/Vite aliases to the references. Use `npm run update:references`\nto run `git subtree pull` and refresh the recorded source SHA.\n\n## Non-interactive creation\n\nPass `--yes` after `create` to use defaults and disable all prompts. Combine it\nwith explicit options when the generated configuration must be reproducible:\n\n```bash\nnpx --yes --package @craft-ts/dev-tools@beta craft create my-app \\\n --yes --effect=none --agents=codex\n```\n\nFor a minimal plain starter:\n\n```bash\nnpx --yes --package @craft-ts/dev-tools@beta craft create my-app \\\n --yes --effect=none --i18n=none --design-system=none --no-typed-css \\\n --agents=none\n```\n\nTo create a backend-only Effect project and vendor both reference sources:\n\n```bash\nnpx --yes --package @craft-ts/dev-tools@beta craft create my-app \\\n --yes --frontend-runtime=plain --backend-runtime=effect \\\n --references=all\n```\n\nThe main configuration options are:\n\n| Option | Values | Purpose |\n| -------------------- | ------------------------------------- | ------------------------------------------------------------------------- |\n| `--effect` | `v4`, `none` | Select the Effect v4 or plain starter |\n| `--frontend-runtime` | `plain`, `effect` | Choose the frontend runtime |\n| `--backend-runtime` | `none`, `promise`, `effect` | Choose server functions |\n| `--effect-scope` | `none`, `frontend`, `backend`, `both` | Set Effect placement |\n| `--agents` | comma-separated names or `none` | Add editor-specific agent integrations; `AGENTS.md` is always generated |\n| `--i18n` | `strict`, `loose`, `none` | Configure type-safe i18n |\n| `--design-system` | `basic`, `none` | Include the design-system starter |\n| `--typed-css` | flag / `--no-typed-css` | Enable or disable typed CSS |\n| `--workspace` | `standalone`, `nx` | Choose the workspace layout |\n| `--references` | `none`, `craft-ts`, `all` | Include source references (default: CraftTS, plus EffectTS when selected) |\n| `--no-demos` | flag | Generate a domain feature without explanatory demo pages |\n| `--domain` | slug | Name the first domain feature when using `--no-demos` |\n| `--force` | flag | Allow an existing non-empty destination |\n| `--json` | flag | Print the effective configuration as JSON |\n\nUse `craft create --help` to see the complete list:\n\n```bash\nnpx --yes --package @craft-ts/dev-tools@beta craft create --help\n```\n\nFor a domain-first starting point, omit the explanatory home/services/about\npages and name the feature explicitly:\n\n```bash\nnpx --yes --package @craft-ts/dev-tools@beta craft create pet-foster \\\n --yes --no-demos --domain animal --frontend-runtime=effect \\\n --backend-runtime=effect\n```\n\nThe generated feature lives under `src/app/features/animal/`. Add a form to\nthat feature with the existing primitives and its unit/submission test:\n\n```bash\ncraft add form animal\n# advanced nested/schema variant:\ncraft add form animal --advanced\n```\n\n## After generation\n\nThe generator creates a Git repository when the destination is not already\ninside another repository. When references are enabled, it adds them as\ntracked Git subtrees and creates the minimal Git history required by\n`git subtree` when the destination is a new repository. The generated\n`.gitignore` excludes `node_modules/`, build outputs, and test reports.\n\nInstall dependencies and start the generated application:\n\n```bash\ncd my-app\nnpm install\nnpm run dev\n```\n\nThe generated project also includes the following checks:\n\n```bash\nnpm run lint\nnpm run typecheck\nnpm test\nnpm run architecture\nnpm run build\n```\n\n### `npm run style:check`\n\nWith typed CSS enabled, the project gets one more, and it is the only one that\nneeds explaining:\n\n```bash\nnpm run style:check\n```\n\nIt builds once — which is how the style plugin writes\n`.craft/style-graph.json` — then proves WCAG 2.2 AA **text contrast** for\nevery element the graph can show holds text, in every state your axes can\nproduce, with no browser involved. It is in the generated CI workflow.\n\nThe starter is set up to pass it out of the box: the palette is named, so a\nfailure can say `ui.accent.dangerHover` rather than a hexadecimal string, and\nthe generated link writes its hovered colour through `interaction.hover`\nrather than a hand-written selector, so the hovered state is a state the check\ncan actually measure.\n\nTwo things to know before your first failure:\n\n- **A result the analysis cannot prove fails the run.** `--allow-indeterminate`\n turns those into warnings and you have to type it. A check whose default\n treats \"I could not tell\" as \"fine\" reports a clean bill on the part of the\n application it did not understand.\n- **A clean run is a contrast proof, not an accessibility audit.**\n\n[Text contrast](./style/contrast.md) has the full coverage contract: what is\nproven, what comes back as `indeterminate`, and how to close a gap honestly.\n\n### Generated architecture rules\n\nThe generated `eslint.config.mjs` imports `@craft-ts/dev-tools/eslint-rules`\nand activates the selected `recommended` or `effect` preset. These presets\nenforce the same architecture as the generated project guide:\n\n- remote reads and writes stay directly in query or mutation loaders; they\n must not be hidden in `craftMethod`;\n- `query`, `mutation` and `asyncProcess` loaders are generator functions;\n express asynchronous work with `yield*`, never with `async` or a native\n `Promise` return;\n- resource loaders infer their result instead of using casts such as\n `as PromiseLike<...>`;\n- route-visible filters, search, sort and pagination use route-level\n `queryParams`, not component-local `state`;\n- template event handlers emit one `source$`; query, mutation and state react\n through `on$` instead of chaining imperative method calls.\n\nThe generated agent skill repeats these boundaries so new features follow the\nsame rules. Run `npm run lint` after generation to verify the project.\n\nWith a backend, `src/server/application.ts` owns the registry and runtime\nLayer, while `src/server/node-http.ts` is only the Node stream adapter.\n`server.ts` re-exports both for compatibility. In the backend-only Effect\nprofile, the browser remains plain CraftTS; Effect services, middleware and\nerror projections stay under the server boundary.\n\n## Troubleshooting\n\n### `could not determine executable to run`\n\nIf the error mentions `craft@0.1.0`, `npx` resolved the unrelated public npm\npackage named `craft`. Use the explicit `--package @craft-ts/dev-tools@beta`\nform shown above.\n\nIf `@craft-ts/dev-tools` is already installed in the project, its local binary\ncan also be called with:\n\n```bash\nnpx craft create my-app\n```\n\nThe explicit form is still the safest command when bootstrapping a project\nthat has no `package.json` yet.\n"
|
|
186
186
|
},
|
|
187
187
|
{
|
|
188
188
|
"path": "/guide/deployment",
|
|
@@ -242,7 +242,7 @@
|
|
|
242
242
|
{
|
|
243
243
|
"path": "/guide/i18n",
|
|
244
244
|
"title": "Type-safe i18n",
|
|
245
|
-
"body": "# Type-safe i18n\n\n`@craft-ts/i18n` is the CraftTS i18n integration. The catalogue remains a plain\ndeclarative TypeScript value, while DI-aware tokens use the existing CraftTS\nservice contracts. A catalogue that does not use DI can still be formatted by\n`runtime.t`; a catalogue with DI is rendered through the reactive CraftTS\ntranslator so its dependencies are checked like component dependencies.\n\n## The contract\n\nSix things are guaranteed, and all six are checked before the app runs.\n\n| guarantee | what it costs you to break |\n| ----------------------------------------------------------- | ------------------------------------------------------------------- |\n| the key set is a **closed union** | an unknown key does not compile — no silent `order.totl` |\n| every locale has the **same keys with the same parameters** | a translation you forgot is a compile error, not a fallback |\n| parameters are **typed by their token** | a date cannot be passed where a currency amount belongs |\n| a plural carries **every category the locale requires** | Polish needs `one`/`few`/`many`/`other`; French needs `one`/`other` |\n| a DI-aware token declares its **CraftTS services** | a missing provider is a compile error at the component/route boundary |\n| a token declared with a **schema** types its own input | the call site passes what the schema parses, not what the formatter wants |\n\nThe usual failure mode of a translation layer is that all of these are\nruntime concerns: a missing key renders its own name, a wrong parameter renders\n`[object Object]`, and a missing plural category renders the wrong branch to the\nusers of one locale only. None of that is observable from the code that calls\n`t`.\n\n## The shape of it\n\n```\nsrc/i18n/\n catalog.ts the reference locale — defineCatalog + msg + plural\n locales/fr-FR.ts every other locale — defineLocaleLike\n project-tokens.ts business tokens: defineToken / defineTokenFactory\n runtime.ts createI18nRuntime, and the reactive binding\n```\n\nA key is its dotted path: `order.total` reaches\n`{ order: { total: msg`…` } }`.\n\n## Where to go next\n\n- [The catalogue](./catalog.md) — `defineCatalog`, `msg`, `plural`,\n `defineLocale`, `defineLocaleLike`.\n- [Tokens](./tokens.md) — the shipped semantic tokens, and how to add your own.\n- [The runtime](./runtime.md) — `createI18nRuntime`, `t`, `bind`, lazy locales.\n- [With Effect](./effect.md) — `@craft-ts/i18n-effect`.\n\nTwo checks belong in CI, and `craft create` wires both:\n\n```bash\nnpm run i18n:check\nnpm run i18n:test\n```\n\nA working example lives in the demo, at `apps/demo/src/app/examples/i18n/`.\n\n## Guard visible text in Craft templates\n\n`craft create` enables this preset for every project generated **with** i18n —\nits own pages already take their copy from the catalogue. A project generated\nwithout i18n never sees the rule. To add it by hand to an existing application:\n\n```js\nimport craftRules from '@craft-ts/dev-tools/eslint-rules';\n\nexport default [\n {\n plugins: { 'craft-ts': craftRules },\n rules: { ...craftRules.configs.i18n.rules },\n },\n];\n```\n\n`craft-ts/require-i18n-text` reports static text in visible headings,\nparagraphs, labels, buttons, links and options, plus visible `placeholder`,\n`aria-label` and `title` attributes — and it looks *inside* the visible\nposition, so `p('Total: ' + t('cart.total'))`, `` span(`Total: ${amount}`) ``,\n`label(isNew ? 'New' : 'Returning')`, `p(name || 'Anonymous')` and a literal in\na children array are reported too. Only what carries letters counts: `first + ' ' + last`\nis glue between values, not copy. Dynamic business values, `i18n.t(...)`, its\nkey and parameters, a generator child and catalogue files are accepted. Server files and tests are excluded so\ntechnical messages and assertions can remain literal. The rule stays separate\nfrom the recommended preset, because it only makes sense once the catalogue is\nthe application's source of truth — which is exactly the condition `craft\ncreate` checks when it decides to enable it.\n"
|
|
245
|
+
"body": "# Type-safe i18n\n\n`@craft-ts/i18n` is the CraftTS i18n integration. The catalogue remains a plain\ndeclarative TypeScript value, while DI-aware tokens use the existing CraftTS\nservice contracts. A catalogue that does not use DI can still be formatted by\n`runtime.t`; a catalogue with DI is rendered through the reactive CraftTS\ntranslator so its dependencies are checked like component dependencies.\n\n## The contract\n\nSix things are guaranteed, and all six are checked before the app runs.\n\n| guarantee | what it costs you to break |\n| ----------------------------------------------------------- | ------------------------------------------------------------------- |\n| the key set is a **closed union** | an unknown key does not compile — no silent `order.totl` |\n| every locale has the **same keys with the same parameters** | a translation you forgot is a compile error, not a fallback |\n| parameters are **typed by their token** | a date cannot be passed where a currency amount belongs |\n| a plural carries **every category the locale requires** | Polish needs `one`/`few`/`many`/`other`; French needs `one`/`other` |\n| a DI-aware token declares its **CraftTS services** | a missing provider is a compile error at the component/route boundary |\n| a token declared with a **schema** types its own input | the call site passes what the schema parses, not what the formatter wants |\n\nThe usual failure mode of a translation layer is that all of these are\nruntime concerns: a missing key renders its own name, a wrong parameter renders\n`[object Object]`, and a missing plural category renders the wrong branch to the\nusers of one locale only. None of that is observable from the code that calls\n`t`.\n\n## The shape of it\n\n```\nsrc/i18n/\n catalog.ts the reference locale — defineCatalog + msg + plural\n locales/fr-FR.ts every other locale — defineLocaleLike\n project-tokens.ts business tokens: defineToken / defineTokenFactory\n runtime.ts createI18nRuntime, and the reactive binding\n```\n\nA key is its dotted path: `order.total` reaches\n`{ order: { total: msg`…` } }`.\n\n## Where to go next\n\n- [The catalogue](./catalog.md) — `defineCatalog`, `msg`, `plural`,\n `defineLocale`, `defineLocaleLike`.\n- [Tokens](./tokens.md) — the shipped semantic tokens, and how to add your own.\n- [The runtime](./runtime.md) — `createI18nRuntime`, `t`, `bind`, lazy locales.\n- [With Effect](./effect.md) — `@craft-ts/i18n-effect`.\n\nTwo checks belong in CI, and `craft create` wires both:\n\n```bash\nnpm run i18n:check\nnpm run i18n:test\n```\n\nA working example lives in the demo, at `apps/demo/src/app/examples/i18n/`.\n\n## Guard visible text in Craft templates\n\n`craft create` enables this preset for every project generated **with** i18n —\nits own pages already take their copy from the catalogue. A project generated\nwithout i18n never sees the rule. To add it by hand to an existing application:\n\n```js\nimport craftRules from '@craft-ts/dev-tools/eslint-rules';\n\nexport default [\n {\n plugins: { 'craft-ts': craftRules },\n rules: { ...craftRules.configs.i18n.rules },\n },\n];\n```\n\n`craft-ts/require-i18n-text` reports static text in visible headings,\nparagraphs, labels, buttons, links and options, plus visible `placeholder`,\n`aria-label` and `title` attributes — and it looks *inside* the visible\nposition, so `p('Total: ' + t('cart.total'))`, `` span(`Total: ${amount}`) ``,\n`label(isNew ? 'New' : 'Returning')`, `p(name || 'Anonymous')` and a literal in\na children array are reported too. Only what carries letters counts: `first + ' ' + last`\nis glue between values, not copy. Dynamic business values, `i18n.t(...)`, its\nkey and parameters, a generator child and catalogue files are accepted. Server files and tests are excluded so\ntechnical messages and assertions can remain literal. The rule stays separate\nfrom the recommended preset, because it only makes sense once the catalogue is\nthe application's source of truth — which is exactly the condition `craft\ncreate` checks when it decides to enable it.\n\n## Translation parameters\n\nThe complete visible sentence belongs to the catalogue. Do not translate one\npiece and append another piece in the template: word order, punctuation and\ngrammar can change between locales. `craft-ts/no-i18n-composition` reports this\npattern in visible text and attributes, including generator children.\n\n### Before: a translated fragment followed by a value\n\n```ts\nspan(function* () {\n return `${i18n.t('ui.space.expires')} ${formatDate(yield* item.expiresAt())}`;\n});\n```\n\nThis forces every locale to keep the same sentence shape and makes the date a\nsecond, untranslated fragment. With the i18n preset, ESLint reports:\n\n> Do not compose translated text with other text or values. Put the complete\n> message in the i18n catalogue and pass dynamic values as translation\n> parameters.\n\n### After: one message with a typed parameter\n\nDeclare the value as a semantic token in the catalogue:\n\n```ts\nimport { dateLong, defineCatalog, msg } from '@craft-ts/i18n';\n\nconst expiresAt = dateLong('expiresAt');\n\nexport const catalog = defineCatalog({\n ui: {\n space: {\n expires: msg`Space expires ${expiresAt}`,\n },\n },\n});\n```\n\nThen pass the value to the translation in the template:\n\n```ts\nspan(function* () {\n return i18n.t('ui.space.expires', {\n expiresAt: yield* item.expiresAt(),\n });\n});\n```\n\nEach locale can now choose its own word order, punctuation and date placement,\nwhile the parameter remains typed and formatted through `Intl`. For a custom\nformat, define a project token rather than rebuilding a translated sentence in\nthe template; see [Tokens](./tokens.md).\n"
|
|
246
246
|
},
|
|
247
247
|
{
|
|
248
248
|
"path": "/guide/i18n/catalog",
|
|
@@ -322,7 +322,7 @@
|
|
|
322
322
|
{
|
|
323
323
|
"path": "/guide/routing/eslint-rules",
|
|
324
324
|
"title": "ESLint rules",
|
|
325
|
-
"body": "# ESLint rules\n\nThe rule set is not decoration: several checks in this documentation only work\nbecause a rule generated or maintained the code they read. Others enforce the\narchitecture — no hidden runtime dependencies or direct transport calls — and most of them\n**autofix**.\n\n**Install them once** when you set up routing and type-safe DI.\n**Then lean on the quick fixes** rather than writing the boilerplate by hand.\n\n::: warning An ESLint error is not a compile error\nA missing autofix does not break the build. If you skip the quick fix after\nchanging a component's DI shape, `main.ts` keeps reading a stale `GenDeps_*` and\ncan miss a real DI error. Run `eslint --fix` in CI.\n:::\n\nThe plugin is exposed from `@craft-ts/dev-tools/eslint-rules`.\n\nFor a project using `@craft-ts/effect`, the published preset enables the Craft\nrules and the Effect adapter rule in one entry:\n\n```ts\nimport craftRules from '@craft-ts/dev-tools/eslint-rules';\n\nexport default [\n {\n files: ['**/*.ts'],\n ...craftRules.configs.effect,\n },\n];\n```\n\nUse `craftRules.configs.recommended` for projects that do not use Effect.\n\nAdd it to your ESLint flat config:\n\n```ts\nimport craftRules from '@craft-ts/dev-tools/eslint-rules';\n\nexport default [\n // keep your existing ESLint config entries\n {\n files: ['**/*.ts'],\n plugins: {\n 'craft-ts': craftRules,\n },\n rules: {\n 'craft-ts/prefer-craft-template-blocks': 'error',\n 'craft-ts/no-render-writes': 'error',\n 'craft-ts/require-reactive-template-bindings': 'error',\n 'craft-ts/no-craft-use': 'error',\n 'craft-ts/no-craft-component-return-type': 'error',\n 'craft-ts/require-craft-component-for-exported-node-factory': 'error',\n 'craft-ts/no-raw-craft-router-url': 'error',\n 'craft-ts/no-type-assertions-in-template': 'error',\n 'craft-ts/no-ephemeral-template-form-state': 'error',\n 'craft-ts/template-element-name-unique': 'error',\n 'craft-ts/no-craft-computed-side-effects': 'error',\n 'craft-ts/require-craft-method-for-yieldable-callback': 'error',\n 'craft-ts/prefer-direct-yieldable-callback': 'error',\n 'craft-ts/prefer-deep-yieldable-for-item': 'warn',\n 'craft-ts/require-yieldable-reactive-read': 'error',\n 'craft-ts/require-yieldable-template-method': 'error',\n 'craft-ts/require-yieldable-insertion-write': 'error',\n 'craft-ts/no-craft-service-component-same-file': 'error',\n 'craft-ts/max-craft-declarations-per-file': 'error',\n 'craft-ts/prefer-craft-http-transport': 'error',\n 'craft-ts/no-injection-token': 'error',\n 'craft-ts/require-primitive-derived-property': 'error',\n 'craft-ts/no-reused-primitive-method': 'error',\n 'craft-ts/no-async-await': 'error',\n 'craft-ts/no-throw': 'error',\n 'craft-ts/no-imperative-craft-resource-trigger': 'error',\n 'craft-ts/no-imperative-craft-method-actions': 'error',\n 'craft-ts/no-remote-work-in-craft-method': 'error',\n 'craft-ts/no-type-assertions-in-resource-loader': 'error',\n 'craft-ts/no-imperative-template-action-chain': 'error',\n 'craft-ts/prefer-route-query-params-for-filter-state': 'warn',\n 'craft-ts/no-imperative-storage-in-craft-method': 'error',\n 'craft-ts/no-transition-actions': 'error',\n 'craft-ts/require-craft-resource-trigger-yield': 'error',\n 'craft-ts/require-assert-exhaustive-route-exceptions': 'error',\n 'craft-ts/require-craft-exception-handler': 'error',\n 'craft-ts/require-exception-component-di-check': 'error',\n 'craft-ts/require-pending-component-di-check': 'error',\n 'craft-ts/require-child-route-mount-check': 'error',\n 'craft-ts/require-lazy-load-with-retry': 'error',\n 'craft-ts/global-exception-registry-match': 'error',\n },\n },\n];\n```\n\nWhat each rule does:\n\n- `craft-ts/prefer-craft-template-blocks`: keeps `craftComponent(...)` templates declarative by rejecting ternaries, logical expressions, negations, and imperative control flow; use `ifNode(...)`, `matchNode.exhaustive(...)`, `forNode(...)`, or `deferNode(...)`\n- `craft-ts/no-render-writes`: rejects detectable `set()`, `update()`, and `mutate()` calls in component templates and render bindings while allowing DOM event and `onXxx` output callbacks\n- `craft-ts/require-reactive-template-bindings`: requires signals, named Craft values, and component inputs to be read inside granular binding callbacks instead of during VNode construction; static values remain valid\n- `craft-ts/no-craft-use`: forbids the synchronous `craftUse(...)` escape hatch in Craft TypeScript files; use a generator and delegate the reader with `yield*` instead\n- `craft-ts/require-craft-component-for-exported-node-factory`: requires an exported function that directly returns a Craft node, such as `button(...)`, to be declared with `craftComponent(...)` so Craft directives and composition remain available\n\nSmall node factories are valid when they stay private to the file:\n\n```ts\nfunction filterButton(filter: TodoFilter, label: string) {\n return button('todoFilterButton', { type: 'button' }, label);\n}\n```\n\nOnce the function is exported, use a Craft component so directives and\ncomposition can be applied at the module boundary:\n\n```ts\n// ❌ craft-ts/require-craft-component-for-exported-node-factory\nexport function filterButton(filter: TodoFilter, label: string) {\n return button('todoFilterButton', { type: 'button' }, label);\n}\n\n// ✅\nexport const FilterButton = craftComponent(\n 'FilterButton',\n {},\n (filter: Input<TodoFilter>, label: Input<string>) => ({ filter, label }),\n ({ label }) => button('todoFilterButton', { type: 'button' }, label),\n);\n```\n\nThe rule also follows named exports such as `export { filterButton }` and\nchecks exported arrow functions.\n\n- `craft-ts/no-type-assertions-in-template`: forbids `as ...` and angle-bracket type assertions in Craft templates; fix the type in the logic factory or expose a correctly typed derived value\n- `craft-ts/no-ephemeral-template-form-state`: forbids `let` / `const` / `var` in the fourth argument of `craftComponent(...)` and `craftDirective(...)` (inline or a same-file identifier). Declare that state in the logic factory with `state()` or `craftComputed()` instead\n- `craft-ts/template-element-name-unique`: requires named HTML helpers to use a static, unique local name within a component; use the object-first helper form for unnamed elements such as `p({ id: 'hint' }, ...)`\n- `craft-ts/no-craft-computed-side-effects`: forbids writes and asynchronous work inside `craftComputed`; only reactive reads and `settled(...)` are allowed. The graph-wide counterpart is [`assertCraftComputedPure`](/guide/testing/architecture#assertcraftcomputedpure).\n- `craft-ts/no-effect-outside-loaders`: keeps `params`, methods, `craftComputed(...)`, and `craftEffect(...)` synchronous by allowing Effect values and Effect service reads only in Effect loaders; `no-effect-in-params` remains as a compatibility alias\n- `craft-ts/sync-effect-body`: keeps a body declared synchronous (`SyncOp` in its requirements) free of anything that may suspend — async constructors such as `Effect.sleep`/`Effect.promise`, and members nothing declares synchronous. Type-aware: the ESLint parser must use `projectService: true` or a TypeScript `project`\n- `craft-ts/no-explicit-effect-type`: lets `Effect.gen` infer its complete type instead of repeating an explicit Effect annotation; contracts declared in interfaces and type aliases remain allowed\n- `craft-ts/prefer-inline-effect-insertion`: keeps the `queryEffect` insertion factory inline so its resource and exception types are inferred without a separate `InsertionParams` context alias\n- `craft-ts/prefer-inline-route-providers`: inlines a route provider tuple used only once by `loadCraftComponent(...)`, preserving the route-level type proof\n- `craft-ts/prefer-craft-reactivity`: rejects authored signal/computed/effect/resource APIs, explicit `.subscribe()` calls, and RxJS `Subject`/`BehaviorSubject`/`ReplaySubject`; use `state`, `craftComputed`, `craftEffect`, `query`, and named `source$`/`on$` flows\n- `craft-ts/prefer-craft-service`: keeps services in the `craftService(...)` model\n- `craft-ts/no-craft-service-component-same-file`: forbids declaring `craftService(...)` and `craftComponent(...)` in the same file; a route-level service provider combined with a lazy-loaded component can break lazy loading, so keep them in separate files\n- `craft-ts/max-craft-declarations-per-file`: reports the third and subsequent `craftComponent(...)`, `craftService(...)`, or `craftDirective(...)` declaration of the same kind in a file; keep Craft entities split across focused files\n- `craft-ts/no-injection-token`: forbids authored `InjectionToken` contracts; declare them with `craftService({ name, providedIn: 'abstract' }, abstract<Contract>())`\n- `craft-ts/prefer-craft-http-client`: forbids direct transport usage in favor of `CraftHttpClient`\n- `craft-ts/prefer-craft-http-transport`: forbids direct `fetch()` and `XMLHttpRequest` because they bypass typed responses and exceptions, tracing, cancellation, and the architecture graph; use `query()` for reads or `mutation()` for writes with `CraftHttpClient`, or `CraftBinaryHttpClient` for raw binary bodies\n- `craft-ts/prefer-craft-input-output`: keeps component inputs and outputs in the `Input`/`Output` model used by `craftComponent(...)`\n- `craft-ts/require-primitive-derived-property`: requires a `computed` or `craftComputed` that only depends on one primitive in the same component/service to be exposed by that primitive's insertion; simple cases are autofixed\n- `craft-ts/no-reused-primitive-method`: requires an exposed primitive insertion method to have one call site per file; create a context-specific method for each distinct use\n- `craft-ts/no-async-await`: forbids `async` functions, `await`, and `for await...of` because native Promise suspension hides Craft dependencies and can lose cancellation or exception tracking; use generator-based Craft primitives, `craftSleep`, and `CraftHttpClient` instead\n- `craft-ts/require-generator-resource-loader`: requires `query`, `mutation`, and `asyncProcess` loaders to be generator functions because a plain or async return hides remote dependencies from the resource lifecycle; use `yield*` to keep each suspension tracked\n- `craft-ts/no-throw`: forbids `throw` in Craft code because it bypasses the typed resource exception channel, and offers a Quick Fix that returns `craftException({ _tag: 'UNEXPECTED_ERROR' }, { error: ... })`; keep technical boundaries and tests outside this rule when their contracts require thrown errors\n- `craft-ts/no-imperative-craft-resource-trigger`: forbids `query.call(...)`, `mutation.mutate(...)`, and `asyncProcess.method(...)` in a `craftEffect` dependency graph, including through `craftGen(...)`. The graph-wide counterpart, including `state` / `source$` writes, is [`assertCraftEffectNoImperativeSync`](/guide/testing/architecture#assertcrafteffectnoimperativesync).\n- `craft-ts/no-imperative-craft-method-actions`: forbids composing multiple imperative actions in a `craftMethod`; emit a `source$` event and let the affected query react with `insertReactOnMutation(...)` instead. A handler such as `event.preventDefault()` followed by one `mutation.mutate(...)` remains valid.\n- `craft-ts/no-remote-work-in-craft-method`: forbids `CraftHttpClient.*(...)` inside `craftMethod` because that action boundary does not own request loading, cancellation, exceptions, or graph dependencies; define the request directly in the `query` or `mutation` loader.\n- `craft-ts/no-type-assertions-in-resource-loader`: forbids `as ...` and angle-bracket assertions inside `query`, `mutation`, and `asyncProcess` loaders because assertions only silence TypeScript and can hide Promise, response, or transport mismatches; repair the request or adapter typing instead.\n- `craft-ts/no-imperative-template-action-chain`: forbids chaining multiple Craft actions in one template event callback; emit one `source$` event and let the query, mutation, and state react through `on$`.\n- `craft-ts/prefer-route-query-params-for-filter-state`: warns when a local `state()` is used directly or through a local derivation as `params` for `query`, `queryEffect`, `asyncProcess`, or `asyncProcessEffect`; use `queryParams()` for values that should survive reloads and be represented in the URL. The graph-wide counterpart, which also sees cross-file dependencies, is [`assertResourceParamsPreferQueryParams`](/guide/testing/architecture/resource-params-query-state).\n- `craft-ts/no-imperative-storage-in-craft-method`: forbids direct storage access and imperative location changes in a `craftMethod`; use `insertReactOnMutation(...)` with `optimisticUpdate: () => undefined` to clear the affected query and let its persistence follow the query state.\n- `craft-ts/no-transition-actions`: forbids `query.call(...)`, `mutation.mutate(...)`, and `asyncProcess.method(...)` inside `transitionStep(...)`; validate the event and emit a source, then let the resource react to that source.\n- `craft-ts/require-craft-resource-trigger-yield`: requires those triggers to use `yield*` inside generator functions, while ordinary UI callbacks may keep imperative calls\n- `craft-ts/require-craft-method-for-yieldable-callback`: requires callbacks returned by a `craftComponent` factory to wrap yieldable Craft method calls in `craftMethod(...)`\n- `craft-ts/prefer-direct-yieldable-callback`: replaces a template generator or generator method that only delegates `yield* callback()` with the callback reference itself (`callback` or `object.method`)\n- `craft-ts/prefer-deep-yieldable-for-item`: warns when a `forNode` item is read repeatedly through `yield* item()` property accesses; expose a named `insertDeepYieldable('property')` collection and use direct item property readers\n- `craft-ts/require-yieldable-reactive-read`: requires Craft reactive readers to be delegated with `yield*` inside generator functions; a function that reads a Craft reader must itself be a generator\n- `craft-ts/require-yieldable-template-method`: requires yieldable Craft method calls in a `craftComponent` template to be delegated with `yield*`, or passed as a reference (`click: counter.increment`)\n- `craft-ts/require-yieldable-insertion-write`: requires `set(...)`, `patch(...)`, and `update(...)` to be delegated with `yield*` when they are used inside a generator method\n- `craft-ts/require-assert-exhaustive-route-exceptions`: adds the collection-level `assertExhaustiveRouteExceptions(...)` safety net\n- `craft-ts/require-craft-exception-handler`: enforces `craftExceptionHandler(function* (...) {})`; simple handlers are autofixed and ambiguous raw redirects are reported for manual migration\n- `craft-ts/require-exception-component-di-check`: generates O(1) `RouteExceptionComponentCheckedDI` checks for `renderComponent`, route-level `errorComponent`, `withErrorComponent`, `withRouteLoadError`, and route-local `provideRouteLoadErrorComponent`\n- `craft-ts/require-pending-component-di-check`: generates the independent `RouteCheckedDI` check for each `pendingComponent`\n- `craft-ts/no-raw-class`: forbids a `class:` binding that is a string, a template literal or a function, in any file that imports `@craft-ts/style`. A class assembled at render time is a visual state nothing recorded, so the [visual matrix](/guide/style/testing) would enumerate what the sheets declare while the DOM shows something else. Move the rule into the sheet and bind the class it returns; make the variation an axis and set a `data-*` attribute\n- `craft-ts/no-raw-css-value`: forbids a string or number literal as an argument to a `@craft-ts/style` helper — `p('12px')`, `bg('red')`. If the scale is missing the step, add it to the scale; if the value genuinely cannot be proven, `unsafeLength('13px', reason)` compiles and makes the debt countable in the [graph](/guide/style/testing#what-the-graph-adds)\n- `craft-ts/no-free-has`: forbids a hand-written `:has()` in styles. It reaches across the component boundary, so what a component looks like depends on markup it does not own — a state the matrix cannot enumerate. Use the `descendant` axis, which is a closed set and carries its own test driver\n- `craft-ts/style-file-boundary`: restricts a `*.style.ts` to style-vocabulary imports. The [build plugin](/guide/style/setup) imports the file in Node to read what it registered, so an application import would run application code at build time\n- `craft-ts/craft-css-token-registry`: reports a custom property registered with `@property` by two different components. A custom property may have only one owner; two silently fight over its syntax and initial value\n- `craft-ts/require-effect-adapters`: requires the Effect-aware adapters — `queryEffect`, `mutationEffect`, `asyncProcessEffect`, and `transitionGuardEffect` — instead of the plain primitives and `transitionGuard` in an Effect application. See [Choose the right adapter](/guide/advanced/effect#choose-the-right-adapter)\n- `craft-ts/craft-signal-source-name-match`: requires `signalSource(name, ...)` to take a string literal matching the variable, class property or object property it is assigned to, so the name in a trace is the name in the source. A computed name defeats the [architecture graph](/guide/testing/architecture), which reads these names statically\n- `craft-ts/require-child-route-mount-check`: adds the missing `assertChildRouteMounts(...)` call + import (Quick Fix) for any `craftRoutes(...)` collection that mounts lazy `loadChildren`, so a `.withParent`-pinned child mounted under the wrong path is a compile error\n- `craft-ts/require-lazy-load-with-retry`: wraps route `loadComponent` and `loadChildren` imports with the generated `withRetry(...)` loader helper while preserving a statically analyzable import specifier\n- `craft-ts/global-exception-registry-match`: keeps `CraftGlobalExceptionRegistry` synchronized with handlers delegating to `globalError()`\n- `craft-ts/prefer-craft-router-link`: requires `CraftRouterLink` for internal `a(..., { href: ... })` navigation; external URLs, fragment links, downloads, `_blank`, and links marked with `data-navigation: 'external'` remain native\n- `craft-ts/no-raw-craft-router-url`: rejects reading `CraftRouter.url`; use the typed route parameter helper generated by `craftRoutes(...)` instead of parsing the URL\n- `craft-ts/no-craft-component-return-type`: rejects explicit annotations on `craftComponent(...)` results so dependency and template inference remains intact\n\n## Promise and transport boundaries\n\nThese rules protect the same boundary: asynchronous work must remain visible to\nthe Craft resource that owns it. A native `Promise` may eventually resolve, but\nit does not describe which Craft dependencies were read, where suspension\noccurred, or which resource should be cancelled and receive the exception.\n\n### Keep resource loaders generator-based\n\n```ts\n// Incorrect: the native Promise hides the request from the Craft lifecycle.\nquery('usersQuery', {\n loader: async () => (await fetch('/api/users')).json(),\n});\n\n// Correct: the resource owns a tracked, yieldable request.\nquery('usersQuery', {\n loader: function* () {\n return yield* CraftHttpClient.get(({ response }) => ({\n url: '/api/users',\n success: response<User[]>(),\n }));\n },\n});\n```\n\n`no-async-await` rejects `async`, `await`, and `for await...of` in Craft code.\n`require-generator-resource-loader` additionally checks that `query`,\n`mutation`, and `asyncProcess` loaders are generators. Use `yield*` for Craft\noperations so every suspension stays tracked.\n\n### Keep transport and types honest\n\n```ts\n// Incorrect: direct fetch bypasses Craft response/error tracking.\nconst result = await fetch('/api/users');\n\n// Correct: use the Craft client in the owning resource loader.\nreturn yield* CraftHttpClient.get(({ response }) => ({\n url: '/api/users',\n success: response<User>(),\n}));\n```\n\nFor a raw binary body, use `CraftBinaryHttpClient.put(...)`; do not use a type\nassertion to force `CraftHttpClient` to accept a `Blob`. An assertion only\nsilences TypeScript — it does not change the runtime value or transport.\nThat is why `prefer-craft-http-transport` and\n`no-type-assertions-in-resource-loader` report these patterns.\n\nExpected failures should use `craftException(...)` so they remain typed and\navailable through the resource's exception state. `no-throw` keeps technical\nthrows limited to explicit adapter boundaries, where they can be translated\ninto the Craft exception channel.\n\n### Accessibility (`craft-ts/a11y`)\n\nSpread `craftRules.configs.a11y.rules` to enable the WCAG 2.2 AA preset as\n`error`. The rules walk **all** hyperscript in the file (`craftTemplate`,\nextracted factories, `h('tag')`), not only `craftComponent` argument 3.\n\n- `prefer-named-html-helpers`: forbids `h('img')` / `h('button')` when a named helper exists\n- `require-interactive-local-name`: requires a string-literal first argument on interactive helpers; the local name is the third segment of `data-craft-name=\"${component}:${tag}:${localName}\"`\n- `img-has-alt`, `iframe-has-title`, `button-has-type`, `anchor-has-href`\n- `control-has-accessible-name`, `label-has-associated-control`, `heading-has-content`\n- `no-noninteractive-element-interactions`, `no-positive-tabindex`\n- `valid-aria`, `role-has-required-aria`, `target-blank-noopener`\n- `prefer-relative-heading`, `require-route-heading-outline`,\n `require-outlet-heading-section`, `no-heading-level-skip`\n- `require-focus-visible`, `require-reduced-motion` (CSS of `craftComponent`)\n\nSee [Accessibility](/guide/components/accessibility).\n\nThe two migration rules also expose a VS Code ESLint Quick Fix suggestion that inserts a temporary local disable comment with the intended migration note when you need to unblock a file before doing the full refactor.\n\nThe template and reactivity rules are intentionally diagnostic-only: replacing a\nresource or subscription can change lifecycle and error semantics, so the rule\npoints at the Craft primitive without applying a potentially unsafe rewrite.\n\n### Why templates use blocks\n\nCraft template blocks preserve the branch structure in the type-level render\ncontract. A ternary or `condition && node` produces only a computed value, so\nthe type checker cannot assert which branch renders which content. Keep derived\nvalues and business decisions in the component's state/query layer, then make\nthe template express visibility explicitly:\n\n```ts\nifNode(\n isReady,\n () => p('Ready'),\n () => p('Loading…'),\n);\n\nmatchNode.exhaustive(query.exceptions, '_tag', {\n NOT_FOUND: () => p('Not found'),\n FORBIDDEN: () => p('Forbidden'),\n});\n```\n\nThis rule is for Craft's TypeScript templates. It does not rewrite external\ntemplate languages.\n\nThe same restriction applies to boolean expressions. A negation is still\napplication logic, even when it is used only for a DOM property:\n\n```ts\n// Incorrect: the template derives the disabled state.\nbutton(\n {\n disabled: function* () {\n return !(yield* machine.canGoBack());\n },\n },\n 'Back',\n);\n\n// Correct: derive it in the logic factory and bind the result.\nconst backDisabled = craftComputed('backDisabled', function* () {\n return !(yield* history.canGoBack());\n});\nreturn { backDisabled };\n```\n\nKeep the template to layout and binding. Move labels, formatted values,\nvalidation state, and other decisions into `state()` or `craftComputed()`.\n\n### Derived values belong to their primitive\n\nWhen a computed reads only one local primitive, declare it in that primitive's\ninsertion. This keeps the dependency visible and lets pending/exception\nboundaries name the actual source:\n\n```ts\nconst users =\n yield *\n query('users', config, ({ resource }) => ({\n total: craftComputed('total', function* () {\n return (yield* settled(resource)).length;\n }),\n }));\n```\n\nDo not create `craftComputed('total', ...)` beside the query when the\ncomputation depends only on `users`.\n\n### Keep casts and synchronous reads out of templates\n\nCraft templates reject both `as ...` / angle-bracket assertions and\n`craftUse(...)`. Fix the type or perform the synchronous-to-reactive\nconversion in the component logic, then expose a typed reader or generator to\nthe template:\n\n```ts\nconst typedStep = machine.stepState as unknown as () => { step: Step };\nreturn { typedStep };\n\n// Template: no cast and no craftUse.\nmatchNode.exhaustive(typedStep, 'step', steps);\n```\n\n`no-craft-use` applies to Craft TypeScript files, not only the fourth\n`craftComponent(...)` argument. A synchronous integration boundary may opt out\nlocally when its external API cannot consume a generator, but application\nstate and templates should use `yield*`.\n\n### Form and accessibility diagnostics\n\nThe accessibility preset also checks the static structure of hyperscript:\n\n- give every `label` an `htmlFor` matching the control `id`, or wrap the control;\n- give named controls and helpers a unique string local name;\n- use `button` or `a` for interactions instead of adding `click` to a `div`;\n- add a `prefers-reduced-motion` branch whenever component CSS defines an\n animation or transition.\n\nThese checks run on Craft TypeScript templates and extracted helper factories,\nso moving markup into a local function does not bypass them.\n\n### Reactive values belong in binding callbacks\n\n`require-reactive-template-bindings` uses TypeScript type information to find\nreactive reads. Reading a signal while constructing a VNode would make it a\ndependency of the structural component render, so the rule rejects this form:\n\n```ts\n// Incorrect: count is read by the component template.\np(`Count: ${count()}`);\nbutton({ disabled: isDisabled() }, 'Save');\ndiv({ class: { active: isActive() } });\n```\n\nKeep each read inside the callback owned by its DOM binding. Pass a yieldable\nreader, or use a generator when the binding must format:\n\n```ts\np(count);\np(function* () {\n return `Count: ${yield* count()}`;\n});\nbutton({ disabled: isDisabled }, 'Save');\ndiv({ class: isActiveClass });\n```\n\nLiteral and otherwise static values are still allowed, as are reads performed\nfrom DOM events and `onXxx` output callbacks. Because the rule is type-aware,\nthe ESLint parser must use `projectService: true` or a TypeScript `project`.\n\n### Pass simple yieldable callbacks directly\n\n`prefer-direct-yieldable-callback` removes a generator wrapper when the\ntemplate only delegates one zero-argument callback. It handles both a value\nbinding and a generator method:\n\n```ts\n// Before: redundant wrappers around the callbacks.\nbutton(\n {\n *click() {\n yield* press();\n },\n },\n function* () {\n return yield* label();\n },\n);\n\n// After `eslint --fix`.\nbutton({ click: press }, label);\n```\n\nMember callbacks are supported as well when the access is static and has no\narguments:\n\n```ts\n// Before.\nspan(function* () {\n return yield* counter.increment();\n});\n\n// After.\nspan(counter.increment);\n```\n\nThe rule leaves callbacks with parameters, extra statements, or additional\ncomputation unchanged. In those cases the generator contains behavior that\ncannot be represented by passing the callback reference alone.\n\n### Prefer deep-yieldable `forNode` items\n\n`prefer-deep-yieldable-for-item` detects when a component reads several\nproperties from the same `forNode` item through repeated `yield* item()` calls.\nKeep the original collection available, and expose a named deep-yieldable\nview for the component:\n\n```ts\nimport { insertDeepYieldable, state } from '@craft-ts/core';\n\n// Before: every property read yields the whole item again.\nforNode(catalog.products, { track: (product) => product.id }, (product) =>\n article([\n span(function* () {\n return (yield* product()).category;\n }),\n span(function* () {\n return (yield* product()).name;\n }),\n ]),\n);\n\n// After: the named view keeps each property read lazy and reactive.\nconst catalog = yield* state(\n 'catalog',\n { products },\n insertDeepYieldable('products'),\n);\n\nforNode(\n catalog.deepYieldableProducts,\n { track: (product) => product.id },\n (product) => article([span(product.category), span(product.name)]),\n);\n```\n\nThe rule is diagnostic-only because choosing the insertion belongs to the\nprimitive that owns the collection. `insertDeepYieldable('products')` leaves\n`catalog.products` unchanged and adds `catalog.deepYieldableProducts`.\n\n### Yield insertion writes from generator methods\n\n`require-yieldable-insertion-write` requires `set(...)`, `patch(...)`, and\n`update(...)` calls to be delegated with `yield*` when they are used inside a\ngenerator method:\n\n```ts\nnextPage: function* () {\n const current = yield* state();\n return yield* patch({ page: current.page + 1 });\n},\n```\n\nInsertion callbacks that are not generators may return a write directly; the\ninsertion wrapper consumes that result for them.\n\n## What generates what\n\nThree rules do more than complain — they write code you would otherwise\nmaintain by hand:\n\n| Rule | Generates |\n| -------------------------------------------- | ------------------------------------------------------------- |\n| `require-assert-exhaustive-route-exceptions` | the collection-level exhaustiveness assert |\n| `require-child-route-mount-check` | the `assertChildRouteMounts(...)` call and its import |\n| `require-lazy-load-with-retry` | the `withRetry(...)` wrapper on lazy route imports |\n| `prefer-direct-yieldable-callback` | replaces redundant generators with direct callback references |\n\n## Adopting them progressively\n\nOn an existing codebase, enable them in waves rather than all at once:\n\n1. **The route safety nets** — the `require-*` rules. Mostly autofixable. They\n generate the proofs; [architecture tests](/guide/testing/architecture#assertroutediproofs)\n (`assertRouteDiProofs`) fail CI if a proof is later removed or left unarmed.\n2. **The architecture rules last** — `prefer-craft-service`,\n `no-craft-service-component-same-file`, `prefer-craft-http-client`,\n `require-yieldable-reactive-read`,\n `require-yieldable-template-method`, `require-yieldable-insertion-write`.\n These ask for real refactors.\n\nThe four style rules — `no-raw-class`, `no-raw-css-value`, `no-free-has`,\n`style-file-boundary` — are in `craftRules.configs.recommended` at `'error'`,\nand they are **gated on the import**: they fire only in files that import\n`@craft-ts/style`. A component you have not migrated is not claiming the\nguarantee, so nothing reports it. The day a file starts using the design system\nis the day it starts being held to it — which is why enabling them on an\nunmigrated codebase costs nothing.\n\nThe two migration rules also expose a VS Code quick fix that inserts a temporary\nlocal disable comment with the intended migration note, so you can unblock a\nfile before doing the full refactor.\n\n## See Also\n\n- [Routing setup](/guide/routing/setup) — where these rules are installed\n- [CLI automation](/guide/routing/automation) — the codemods they complement\n- [Architecture rules](/guide/testing/architecture) — graph-wide constraints ESLint cannot see\n- [Activating the style system](/guide/style/setup) — what the four style rules are guarding\n"
|
|
325
|
+
"body": "# ESLint rules\n\nThe rule set is not decoration: several checks in this documentation only work\nbecause a rule generated or maintained the code they read. Others enforce the\narchitecture — no hidden runtime dependencies or direct transport calls — and most of them\n**autofix**.\n\n**Install them once** when you set up routing and type-safe DI.\n**Then lean on the quick fixes** rather than writing the boilerplate by hand.\n\n::: warning An ESLint error is not a compile error\nA missing autofix does not break the build. If you skip the quick fix after\nchanging a component's DI shape, `main.ts` keeps reading a stale `GenDeps_*` and\ncan miss a real DI error. Run `eslint --fix` in CI.\n:::\n\nThe plugin is exposed from `@craft-ts/dev-tools/eslint-rules`.\n\nThe recommended preset bans every TypeScript assertion in authored Craft code,\nincluding `as const`:\n\n```ts\nimport craftRules from '@craft-ts/dev-tools/eslint-rules';\n\nexport default [{ files: ['**/*.ts'], ...craftRules.configs.recommended }];\n```\n\nFor a project using `@craft-ts/effect`, the published preset enables the Craft\nrules and the Effect adapter rule in one entry:\n\n```ts\nimport craftRules from '@craft-ts/dev-tools/eslint-rules';\n\nexport default [\n {\n files: ['**/*.ts'],\n ...craftRules.configs.effect,\n },\n];\n```\n\nUse `craftRules.configs.recommended` for projects that do not use Effect.\n\nAdd it to your ESLint flat config:\n\n```ts\nimport craftRules from '@craft-ts/dev-tools/eslint-rules';\n\nexport default [\n // keep your existing ESLint config entries\n {\n files: ['**/*.ts'],\n plugins: {\n 'craft-ts': craftRules,\n },\n rules: {\n 'craft-ts/prefer-craft-template-blocks': 'error',\n 'craft-ts/no-render-writes': 'error',\n 'craft-ts/require-reactive-template-bindings': 'error',\n 'craft-ts/no-craft-use': 'error',\n 'craft-ts/no-craft-component-return-type': 'error',\n 'craft-ts/require-craft-component-for-exported-node-factory': 'error',\n 'craft-ts/no-raw-craft-router-url': 'error',\n 'craft-ts/no-type-assertions-in-template': 'error',\n 'craft-ts/no-explicit-craft-template-return-type': 'error',\n 'craft-ts/no-extracted-craft-component-parts': 'error',\n 'craft-ts/no-ephemeral-template-form-state': 'error',\n 'craft-ts/template-element-name-unique': 'error',\n 'craft-ts/no-craft-computed-side-effects': 'error',\n 'craft-ts/require-craft-method-for-yieldable-callback': 'error',\n 'craft-ts/prefer-direct-yieldable-callback': 'error',\n 'craft-ts/prefer-deep-yieldable-for-item': 'warn',\n 'craft-ts/require-yieldable-reactive-read': 'error',\n 'craft-ts/require-yieldable-template-method': 'error',\n 'craft-ts/require-yieldable-insertion-write': 'error',\n 'craft-ts/no-craft-service-component-same-file': 'error',\n 'craft-ts/max-craft-declarations-per-file': 'error',\n 'craft-ts/max-craft-component-lines': 'warn',\n 'craft-ts/prefer-craft-http-transport': 'error',\n 'craft-ts/no-injection-token': 'error',\n 'craft-ts/require-primitive-derived-property': 'error',\n 'craft-ts/no-reused-primitive-method': 'error',\n 'craft-ts/no-async-await': 'error',\n 'craft-ts/no-throw': 'error',\n 'craft-ts/no-imperative-craft-resource-trigger': 'error',\n 'craft-ts/no-imperative-craft-method-actions': 'error',\n 'craft-ts/no-remote-work-in-craft-method': 'error',\n 'craft-ts/no-type-assertions-in-resource-loader': 'error',\n 'craft-ts/no-explicit-resource-loader-type': 'error',\n 'craft-ts/no-explicit-craft-insertion-type': 'error',\n 'craft-ts/no-craft-primitive-type-assertion': 'error',\n 'craft-ts/prefer-insert-deep-yieldable': 'error',\n 'craft-ts/no-imperative-template-action-chain': 'error',\n 'craft-ts/prefer-route-query-params-for-filter-state': 'warn',\n 'craft-ts/no-imperative-storage-in-craft-method': 'error',\n 'craft-ts/no-transition-actions': 'error',\n 'craft-ts/require-craft-resource-trigger-yield': 'error',\n 'craft-ts/require-assert-exhaustive-route-exceptions': 'error',\n 'craft-ts/require-craft-exception-handler': 'error',\n 'craft-ts/require-exception-component-di-check': 'error',\n 'craft-ts/require-pending-component-di-check': 'error',\n 'craft-ts/require-child-route-mount-check': 'error',\n 'craft-ts/require-lazy-load-with-retry': 'error',\n 'craft-ts/global-exception-registry-match': 'error',\n },\n },\n];\n```\n\nWhat each rule does:\n\n- `craft-ts/prefer-craft-template-blocks`: keeps `craftComponent(...)` templates declarative by rejecting ternaries, logical expressions, negations, and imperative control flow; use `ifNode(...)`, `matchNode.exhaustive(...)`, `forNode(...)`, or `deferNode(...)`\n- `craft-ts/require-craft-computed-for-dynamic-template-lookup`: rejects dynamic object or array lookups in a Craft template when the lookup key comes from a template parameter; move the lookup to a named `craftComputed()` in the component logic factory and bind that value directly\n- `craft-ts/no-render-writes`: rejects detectable `set()`, `update()`, and `mutate()` calls in component templates and render bindings while allowing DOM event and `onXxx` output callbacks\n- `craft-ts/require-reactive-template-bindings`: requires signals, named Craft values, and component inputs to be read inside granular binding callbacks instead of during VNode construction; static values remain valid\n- `craft-ts/no-craft-use`: forbids the synchronous `craftUse(...)` escape hatch in Craft TypeScript files; use a generator and delegate the reader with `yield*` instead\n- `craft-ts/require-craft-component-for-exported-node-factory`: requires an exported function that directly returns a Craft node, such as `button(...)`, to be declared with `craftComponent(...)` so Craft directives and composition remain available\n\nSmall node factories are valid when they stay private to the file:\n\n```ts\nfunction filterButton(filter: TodoFilter, label: string) {\n return button('todoFilterButton', { type: 'button' }, label);\n}\n```\n\nOnce the function is exported, use a Craft component so directives and\ncomposition can be applied at the module boundary:\n\n```ts\n// ❌ craft-ts/require-craft-component-for-exported-node-factory\nexport function filterButton(filter: TodoFilter, label: string) {\n return button('todoFilterButton', { type: 'button' }, label);\n}\n\n// ✅\nexport const FilterButton = craftComponent(\n 'FilterButton',\n {},\n (filter: Input<TodoFilter>, label: Input<string>) => ({ filter, label }),\n ({ label }) => button('todoFilterButton', { type: 'button' }, label),\n);\n```\n\nThe rule also follows named exports such as `export { filterButton }` and\nchecks exported arrow functions.\n\n- `craft-ts/no-type-assertions-in-template`: forbids `as ...` and angle-bracket type assertions in Craft templates; fix the type in the logic factory or expose a correctly typed derived value\n- `craft-ts/no-explicit-craft-template-return-type`: forbids explicit return annotations on render callbacks inside `craftComponent(...)`. A broad annotation such as `(): CraftNodeChildren` widens the concrete node type, breaks dependency and type-safe DI inference, and can surface as a runtime error. Let the callback return type be inferred:\n\n ```ts\n const pendingStatusMessage = (message: string) => p(message);\n\n // ❌ The annotation erases the concrete node/dependency information.\n pendingNode({\n fallback: (): CraftNodeChildren => pendingStatusMessage('Loading…'),\n reloading: (): CraftNodeChildren => pendingStatusMessage('Reloading…'),\n });\n\n // ✅ The concrete `p(...)` node stays visible to Craft's inference.\n pendingNode({\n fallback: () => pendingStatusMessage('Loading…'),\n reloading: () => pendingStatusMessage('Reloading…'),\n });\n ```\n\n The rule is autofixable with `eslint --fix`. Return annotations on DOM event\n and output callbacks remain allowed because those callbacks do not produce\n rendered children.\n\n- `craft-ts/no-extracted-craft-component-parts`: requires the logic factory and\n template passed to `craftComponent(...)` to stay inline. Keeping both parts at\n the component boundary preserves contextual type inference and makes the\n component's behaviour readable in one place. The rule reports both extracted\n identifiers independently.\n\n Before — extracted `ReviewLogic` and `ReviewTemplate` hide the component's\n two halves behind names at the call site:\n\n ```ts\n // ❌ craft-ts/no-extracted-craft-component-parts\n const ReviewLogic = craftGen(function* () {\n return { review, decide };\n });\n\n const ReviewTemplate = craftTemplate(({ decide }) =>\n div([button({ click: decide }, 'Review')]),\n );\n\n export const ReviewApp = craftComponent(\n 'ReviewApp',\n {},\n ReviewLogic,\n ReviewTemplate,\n );\n ```\n\n After — keep the logic and template callback in the component call:\n\n ```ts\n // ✅\n export const ReviewApp = craftComponent(\n 'ReviewApp',\n {},\n craftGen(function* () {\n return { review, decide };\n }),\n ({ decide }) => div([button({ click: decide }, 'Review')]),\n );\n ```\n\n The rule only rejects identifiers in the logic and template argument\n positions. Inline callbacks and inline `craftGen(...)` / `craftTemplate(...)`\n expressions remain valid. A direct template callback is usually the simplest\n form because `craftComponent(...)` can contextually type it from the inline\n logic factory.\n\n- `craft-ts/no-ephemeral-template-form-state`: forbids `let` / `const` / `var` in the fourth argument of `craftComponent(...)` and `craftDirective(...)` (inline or a same-file identifier). Declare that state in the logic factory with `state()` or `craftComputed()` instead\n- `craft-ts/template-element-name-unique`: requires named HTML helpers to use a static, unique local name within a component; use the object-first helper form for unnamed elements such as `p({ id: 'hint' }, ...)`\n- `craft-ts/no-craft-computed-side-effects`: forbids writes and asynchronous work inside `craftComputed`; only reactive reads and `settled(...)` are allowed. The graph-wide counterpart is [`assertCraftComputedPure`](/guide/testing/architecture#assertcraftcomputedpure).\n- `craft-ts/no-effect-outside-loaders`: keeps `params`, methods, `craftComputed(...)`, and `craftEffect(...)` synchronous by allowing Effect values and Effect service reads only in Effect loaders; `no-effect-in-params` remains as a compatibility alias\n- `craft-ts/sync-effect-body`: keeps a body declared synchronous (`SyncOp` in its requirements) free of anything that may suspend — async constructors such as `Effect.sleep`/`Effect.promise`, and members nothing declares synchronous. Type-aware: the ESLint parser must use `projectService: true` or a TypeScript `project`\n- `craft-ts/no-explicit-effect-type`: lets `Effect.gen` infer its complete type instead of repeating an explicit Effect annotation; contracts declared in interfaces and type aliases remain allowed\n- `craft-ts/prefer-inline-effect-insertion`: keeps the `queryEffect` insertion factory inline so its resource and exception types are inferred without a separate `InsertionParams` context alias\n- `craft-ts/prefer-inline-route-providers`: inlines a route provider tuple used only once by `loadCraftComponent(...)`, preserving the route-level type proof\n- `craft-ts/prefer-craft-reactivity`: rejects authored signal/computed/effect/resource APIs, explicit `.subscribe()` calls, and RxJS `Subject`/`BehaviorSubject`/`ReplaySubject`; use `state`, `craftComputed`, `craftEffect`, `query`, and named `source$`/`on$` flows\n- `craft-ts/prefer-craft-service`: keeps services in the `craftService(...)` model\n- `craft-ts/no-craft-service-component-same-file`: forbids declaring `craftService(...)` and `craftComponent(...)` in the same file; a route-level service provider combined with a lazy-loaded component can break lazy loading, so keep them in separate files\n- `craft-ts/max-craft-declarations-per-file`: reports the third and subsequent `craftComponent(...)`, `craftService(...)`, or `craftDirective(...)` declaration of the same kind in a file; keep Craft entities split across focused files\n- `craft-ts/max-craft-component-lines`: reports a file that declares a `craftComponent(...)` once it exceeds **700 non-import lines** (`import` statements and blank lines are not counted, so a component with many dependencies is not penalized for its import block). A file this long usually mixes business logic, view logic, and markup that could live in separate, independently testable units:\n\n ```ts\n // ❌ craft-ts/max-craft-component-lines\n // review-app.ts — 3894 lines: filtering, sorting, diff computation,\n // pagination, and the full markup tree all inlined in one logic factory\n // and one template.\n export const ReviewApp = craftComponent(\n 'ReviewApp',\n {},\n (subjects: Input<Subject[]>) => {\n const filtered = craftComputed(() => /* 80 lines of filtering */ []);\n const diff = craftComputed(() => /* 150 lines of diffing */ null);\n // …dozens more computeds and craftMethods…\n return { subjects, filtered, diff /* … */ };\n },\n ({ filtered, diff /* … */ }) =>\n div(\n {},\n /* a thousand-plus lines of markup for the filter bar, the diff\n viewport, the review card list, and the pagination controls */\n ),\n );\n\n // ✅ Business logic moves to a craftService; independent template\n // regions become their own craftComponent, each testable and readable\n // on its own.\n export const ReviewFilters = craftService(\n { name: 'ReviewFilters', scope: 'global' },\n () => ({\n filter: (subjects: Subject[], criteria: FilterCriteria) => /* … */ [],\n }),\n );\n\n export const SubjectDiffViewport = craftComponent(\n 'SubjectDiffViewport',\n {},\n (subject: Input<Subject>) => ({ subject }),\n ({ subject }) => div({} /* … */),\n );\n\n export const ReviewApp = craftComponent(\n 'ReviewApp',\n {},\n (subjects: Input<Subject[]>) => {\n const filters = injectX(ReviewFilters);\n const filtered = craftComputed(() =>\n filters.filter(subjects(), criteria()),\n );\n return { filtered /* … */ };\n },\n ({ filtered }) =>\n div(\n {},\n forNode(filtered, (subject) => SubjectDiffViewport({ subject })),\n ),\n );\n ```\n\n Set a project-specific threshold with `['warn', { max: 600 }]` if 700 lines is\n still too generous for your team.\n\n- `craft-ts/no-injection-token`: forbids authored `InjectionToken` contracts; declare them with `craftService({ name, providedIn: 'abstract' }, abstract<Contract>())`\n- `craft-ts/prefer-craft-http-client`: forbids direct transport usage in favor of `CraftHttpClient`\n- `craft-ts/prefer-craft-http-transport`: forbids direct `fetch()` and `XMLHttpRequest` because they bypass typed responses and exceptions, tracing, cancellation, and the architecture graph; use `query()` for reads or `mutation()` for writes with `CraftHttpClient`, or `CraftBinaryHttpClient` for raw binary bodies\n- `craft-ts/prefer-craft-input-output`: keeps component inputs and outputs in the `Input`/`Output` model used by `craftComponent(...)`\n- `craft-ts/require-primitive-derived-property`: requires a `computed` or `craftComputed` that only depends on one primitive in the same component/service to be exposed by that primitive's insertion; simple cases are autofixed\n- `craft-ts/no-reused-primitive-method`: requires an exposed primitive insertion method to have one call site per file, including unchanged aliases forwarded through a component template context; create a context-specific insertion method for each distinct use\n- `craft-ts/no-async-await`: forbids `async` functions, `await`, and `for await...of` because native Promise suspension hides Craft dependencies and can lose cancellation or exception tracking; use generator-based Craft primitives, `craftSleep`, and `CraftHttpClient` instead\n- `craft-ts/require-generator-resource-loader`: requires `query`, `mutation`, and `asyncProcess` loaders to be generator functions because a plain or async return hides remote dependencies from the resource lifecycle; use `yield*` to keep each suspension tracked\n- `craft-ts/no-throw`: forbids `throw` in Craft code because it bypasses the typed resource exception channel, and offers a Quick Fix that returns `craftException({ _tag: 'UNEXPECTED_ERROR' }, { error: ... })`; keep technical boundaries and tests outside this rule when their contracts require thrown errors\n- `craft-ts/no-imperative-craft-resource-trigger`: forbids `query.call(...)`, `mutation.mutate(...)`, and `asyncProcess.method(...)` in a `craftEffect` dependency graph, including through `craftGen(...)`. The graph-wide counterpart, including `state` / `source$` writes, is [`assertCraftEffectNoImperativeSync`](/guide/testing/architecture#assertcrafteffectnoimperativesync).\n- `craft-ts/no-imperative-craft-method-actions`: forbids composing multiple imperative actions in a `craftMethod`; emit a `source$` event and let the affected query react with `insertReactOnMutation(...)` instead. A handler such as `event.preventDefault()` followed by one `mutation.mutate(...)` remains valid.\n- `craft-ts/no-remote-work-in-craft-method`: forbids `CraftHttpClient.*(...)` inside `craftMethod` because that action boundary does not own request loading, cancellation, exceptions, or graph dependencies; define the request directly in the `query` or `mutation` loader.\n- `craft-ts/no-type-assertions-in-resource-loader`: forbids `as ...` and angle-bracket assertions inside `query`, `mutation`, and `asyncProcess` loaders because assertions only silence TypeScript and can hide Promise, response, or transport mismatches; repair the request or adapter typing instead.\n- `craft-ts/no-type-assertions-in-craft-code`: forbids TypeScript type assertions in authored Craft code, including `as const` and angle-bracket assertions; the narrow `undefined as T | undefined` seed is allowed for intentionally optional state values. Use correct API typing or `satisfies` for shape validation. Low-level technical adapters may disable this rule locally when an explicit runtime boundary cast is unavoidable.\n- `craft-ts/no-explicit-resource-loader-type`: forbids explicit parameter and return annotations on `query`, `mutation`, and `asyncProcess` loaders; let the resource infer its contract from `params`, `method`, and the yielded operations instead of writing `Generator<...>` or `{ params: string }`\n- `craft-ts/no-explicit-craft-insertion-type`: forbids explicit parameter and return annotations on callbacks passed to `insert*Pipe`; let the primitive infer the insertion context and derived output\n- `craft-ts/no-craft-primitive-type-assertion`: forbids chained assertions such as `as unknown as Generator<...>` around Craft primitive generators, which can hide the inferred output and dependency contract\n- `craft-ts/prefer-insert-deep-yieldable`: rejects adapting a property of a primitive result with `deepYieldable(...)`; add `insertDeepYieldable()` to the primitive and read the property directly\n- `craft-ts/no-imperative-template-action-chain`: forbids chaining multiple Craft actions in one template event callback; emit one `source$` event and let the query, mutation, and state react through `on$`.\n- `craft-ts/prefer-route-query-params-for-filter-state`: warns when a local `state()` is used directly or through a local derivation as `params` for `query`, `queryEffect`, `asyncProcess`, or `asyncProcessEffect`; use `queryParams()` for values that should survive reloads and be represented in the URL. The graph-wide counterpart, which also sees cross-file dependencies, is [`assertResourceParamsPreferQueryParams`](/guide/testing/architecture/resource-params-query-state).\n- `craft-ts/no-imperative-storage-in-craft-method`: forbids direct storage access and imperative location changes in a `craftMethod`; use `insertReactOnMutation(...)` with `optimisticUpdate: () => undefined` to clear the affected query and let its persistence follow the query state.\n- `craft-ts/no-transition-actions`: forbids `query.call(...)`, `mutation.mutate(...)`, and `asyncProcess.method(...)` inside `transitionStep(...)`; validate the event and emit a source, then let the resource react to that source.\n- `craft-ts/require-craft-resource-trigger-yield`: requires those triggers to use `yield*` inside generator functions, while ordinary UI callbacks may keep imperative calls\n- `craft-ts/require-craft-method-for-yieldable-callback`: requires callbacks returned by a `craftComponent` factory to wrap yieldable Craft method calls in `craftMethod(...)`\n- `craft-ts/prefer-direct-yieldable-callback`: replaces a template generator or generator method that only delegates `yield* callback()` with the callback reference itself (`callback` or `object.method`)\n- `craft-ts/prefer-deep-yieldable-for-item`: warns when a `forNode` item is read repeatedly through `yield* item()` property accesses; expose a named `insertDeepYieldable('property')` collection and use direct item property readers\n- `craft-ts/require-yieldable-reactive-read`: requires Craft reactive readers to be delegated with `yield*` inside generator functions; a function that reads a Craft reader must itself be a generator\n- `craft-ts/require-yieldable-template-method`: requires yieldable Craft method calls in a `craftComponent` template to be delegated with `yield*`, or passed as a reference (`click: counter.increment`)\n- `craft-ts/require-yieldable-insertion-write`: requires `set(...)`, `patch(...)`, and `update(...)` to be delegated with `yield*` when they are used inside a generator method\n- `craft-ts/require-assert-exhaustive-route-exceptions`: adds the collection-level `assertExhaustiveRouteExceptions(...)` safety net\n- `craft-ts/require-craft-exception-handler`: enforces `craftExceptionHandler(function* (...) {})`; simple handlers are autofixed and ambiguous raw redirects are reported for manual migration\n- `craft-ts/require-exception-component-di-check`: generates O(1) `RouteExceptionComponentCheckedDI` checks for `renderComponent`, route-level `errorComponent`, `withErrorComponent`, `withRouteLoadError`, and route-local `provideRouteLoadErrorComponent`\n- `craft-ts/require-pending-component-di-check`: generates the independent `RouteCheckedDI` check for each `pendingComponent`\n- `craft-ts/no-raw-class`: forbids a `class:` binding that is a string, a template literal or a function, in any file that imports `@craft-ts/style`. A class assembled at render time is a visual state nothing recorded, so the [visual matrix](/guide/style/testing) would enumerate what the sheets declare while the DOM shows something else. Move the rule into the sheet and bind the class it returns; make the variation an axis and set a `data-*` attribute\n- `craft-ts/no-raw-css-value`: forbids a string or number literal as an argument to a `@craft-ts/style` helper — `p('12px')`, `bg('red')`. If the scale is missing the step, add it to the scale; if the value genuinely cannot be proven, `unsafeLength('13px', reason)` compiles and makes the debt countable in the [graph](/guide/style/testing#what-the-graph-adds)\n- `craft-ts/no-free-has`: forbids a hand-written `:has()` in styles. It reaches across the component boundary, so what a component looks like depends on markup it does not own — a state the matrix cannot enumerate. Use the `descendant` axis, which is a closed set and carries its own test driver\n- `craft-ts/style-file-boundary`: restricts a `*.style.ts` to style-vocabulary imports. The [build plugin](/guide/style/setup) imports the file in Node to read what it registered, so an application import would run application code at build time\n- `craft-ts/craft-css-token-registry`: reports a custom property registered with `@property` by two different components. A custom property may have only one owner; two silently fight over its syntax and initial value\n- `craft-ts/require-effect-adapters`: requires the Effect-aware adapters — `queryEffect`, `mutationEffect`, `asyncProcessEffect`, and `transitionGuardEffect` — instead of the plain primitives and `transitionGuard` in an Effect application. See [Choose the right adapter](/guide/advanced/effect#choose-the-right-adapter)\n- `craft-ts/craft-signal-source-name-match`: requires `signalSource(name, ...)` to take a string literal matching the variable, class property or object property it is assigned to, so the name in a trace is the name in the source. A computed name defeats the [architecture graph](/guide/testing/architecture), which reads these names statically\n- `craft-ts/require-child-route-mount-check`: adds the missing `assertChildRouteMounts(...)` call + import (Quick Fix) for any `craftRoutes(...)` collection that mounts lazy `loadChildren`, so a `.withParent`-pinned child mounted under the wrong path is a compile error\n- `craft-ts/require-lazy-load-with-retry`: wraps route `loadComponent` and `loadChildren` imports with the generated `withRetry(...)` loader helper while preserving a statically analyzable import specifier\n- `craft-ts/global-exception-registry-match`: keeps `CraftGlobalExceptionRegistry` synchronized with handlers delegating to `globalError()`\n- `craft-ts/prefer-craft-router-link`: requires `CraftRouterLink` for internal `a(..., { href: ... })` navigation; external URLs, fragment links, downloads, `_blank`, and links marked with `data-navigation: 'external'` remain native\n- `craft-ts/no-raw-craft-router-url`: rejects reading `CraftRouter.url`; use the typed route parameter helper generated by `craftRoutes(...)` instead of parsing the URL\n- `craft-ts/no-craft-component-return-type`: rejects explicit annotations on `craftComponent(...)` results so dependency and template inference remains intact\n\n## Promise and transport boundaries\n\nThese rules protect the same boundary: asynchronous work must remain visible to\nthe Craft resource that owns it. A native `Promise` may eventually resolve, but\nit does not describe which Craft dependencies were read, where suspension\noccurred, or which resource should be cancelled and receive the exception.\n\n### Keep resource loaders generator-based\n\n```ts\n// Incorrect: the native Promise hides the request from the Craft lifecycle.\nquery('usersQuery', {\n loader: async () => (await fetch('/api/users')).json(),\n});\n\n// Correct: the resource owns a tracked, yieldable request.\nquery('usersQuery', {\n loader: function* () {\n return yield* CraftHttpClient.get(({ response }) => ({\n url: '/api/users',\n success: response<User[]>(),\n }));\n },\n});\n```\n\n`no-async-await` rejects `async`, `await`, and `for await...of` in Craft code.\n`require-generator-resource-loader` additionally checks that `query`,\n`mutation`, and `asyncProcess` loaders are generators. Use `yield*` for Craft\noperations so every suspension stays tracked.\n\nThe loader signature should also stay inferred:\n\n```ts\n// Incorrect: these annotations can mask a mismatch in the resource contract.\nloader: function* ({ params }: { params: string }): Generator<Yielded, Result, unknown> {\n return yield* client({ token: params });\n}\n\n// Correct: infer params and the generator result from the resource and body.\nloader: function* ({ params }) {\n return yield* client({ token: params });\n}\n```\n\n`no-explicit-resource-loader-type` reports only annotations on the loader\nsignature. Type annotations for local variables and function contracts outside\nthe loader remain allowed.\n\n### Keep transport and types honest\n\n```ts\n// Incorrect: direct fetch bypasses Craft response/error tracking.\nconst result = await fetch('/api/users');\n\n// Correct: use the Craft client in the owning resource loader.\nreturn (\n yield *\n CraftHttpClient.get(({ response }) => ({\n url: '/api/users',\n success: response<User>(),\n }))\n);\n```\n\nFor a raw binary body, use `CraftBinaryHttpClient.put(...)`; do not use a type\nassertion to force `CraftHttpClient` to accept a `Blob`. An assertion only\nsilences TypeScript — it does not change the runtime value or transport.\nThat is why `prefer-craft-http-transport` and\n`no-type-assertions-in-resource-loader` report these patterns.\n\n### Preserve primitive inference\n\nThe insertion callback already receives a contextual type, and the primitive\nalready knows the complete type of its generator. Do not repeat either type at\nthe boundary:\n\n```ts\n// ❌ craft-ts/no-explicit-craft-insertion-type\ninsertQueryPipe(\n ({ resource }): SpaceQueryView => ({\n items: craftComputed(() => resource.value()),\n }),\n);\n\n// ❌ craft-ts/no-craft-primitive-type-assertion\nconst generator = query('spaceItems', config) as unknown as Generator<\n unknown,\n SpaceQueryRef,\n unknown\n>;\n\n// ✅\nconst generator = query(\n 'spaceItems',\n config,\n insertQueryPipe(({ resource }) => ({\n items: craftComputed(() => resource.value()),\n })),\n);\n```\n\nThe assertion is especially harmful around a composed insertion pipe: it\nreplaces the type that carries the derived properties and their dependencies.\n\n### Prefer primitive deep-yieldable insertions\n\nWhen a property is read from the result of a primitive, expose the deep view at\nthe primitive boundary. This keeps the property reader connected to the\nprimitive and avoids an extra adapter:\n\n```ts\n// ❌ craft-ts/prefer-insert-deep-yieldable\nconst spaceQuery = yield * spaceQueryGenerator;\nconst deepItems = deepYieldable(spaceQuery.items);\n\n// ✅ add insertDeepYieldable() to the query call, then:\nconst spaceQuery = yield * spaceQueryGenerator;\nconst items = spaceQuery.items;\n```\n\nExpected failures should use `craftException(...)` so they remain typed and\navailable through the resource's exception state. `no-throw` keeps technical\nthrows limited to explicit adapter boundaries, where they can be translated\ninto the Craft exception channel.\n\n### Accessibility (`craft-ts/a11y`)\n\nSpread `craftRules.configs.a11y.rules` to enable the WCAG 2.2 AA preset as\n`error`. The rules walk **all** hyperscript in the file (`craftTemplate`,\nextracted factories, `h('tag')`), not only `craftComponent` argument 3.\n\n- `prefer-named-html-helpers`: forbids `h('img')` / `h('button')` when a named helper exists\n- `require-interactive-local-name`: requires a string-literal first argument on interactive helpers; the local name is the third segment of `data-craft-name=\"${component}:${tag}:${localName}\"`\n- `img-has-alt`, `iframe-has-title`, `button-has-type`, `anchor-has-href`\n- `control-has-accessible-name`, `label-has-associated-control`, `heading-has-content`\n- `no-noninteractive-element-interactions`, `no-positive-tabindex`\n- `valid-aria`, `role-has-required-aria`, `target-blank-noopener`\n- `prefer-relative-heading`, `require-route-heading-outline`,\n `require-outlet-heading-section`, `no-heading-level-skip`\n- `require-focus-visible`, `require-reduced-motion` (CSS of `craftComponent`)\n\nSee [Accessibility](/guide/components/accessibility).\n\nThe two migration rules also expose a VS Code ESLint Quick Fix suggestion that inserts a temporary local disable comment with the intended migration note when you need to unblock a file before doing the full refactor.\n\nThe template and reactivity rules are intentionally diagnostic-only: replacing a\nresource or subscription can change lifecycle and error semantics, so the rule\npoints at the Craft primitive without applying a potentially unsafe rewrite.\n\n### Why templates use blocks\n\nCraft template blocks preserve the branch structure in the type-level render\ncontract. A ternary or `condition && node` produces only a computed value, so\nthe type checker cannot assert which branch renders which content. Keep derived\nvalues and business decisions in the component's state/query layer, then make\nthe template express visibility explicitly:\n\n```ts\nifNode(\n isReady,\n () => p('Ready'),\n () => p('Loading…'),\n);\n\nmatchNode.exhaustive(query.exceptions, '_tag', {\n NOT_FOUND: () => p('Not found'),\n FORBIDDEN: () => p('Forbidden'),\n});\n```\n\nThis rule is for Craft's TypeScript templates. It does not rewrite external\ntemplate languages.\n\nThe same restriction applies to boolean expressions. A negation is still\napplication logic, even when it is used only for a DOM property:\n\n```ts\n// Incorrect: the template derives the disabled state.\nbutton(\n {\n disabled: function* () {\n return !(yield* machine.canGoBack());\n },\n },\n 'Back',\n);\n\n// Correct: derive it in the logic factory and bind the result.\nconst backDisabled = craftComputed('backDisabled', function* () {\n return !(yield* history.canGoBack());\n});\nreturn { backDisabled };\n```\n\nKeep the template to layout and binding. Move labels, formatted values,\nvalidation state, and other decisions into `state()` or `craftComputed()`.\n\n### Derived values belong to their primitive\n\nWhen a computed reads only one local primitive, declare it in that primitive's\ninsertion. This keeps the dependency visible and lets pending/exception\nboundaries name the actual source:\n\n```ts\nconst users =\n yield *\n query('users', config, ({ resource }) => ({\n total: craftComputed('total', function* () {\n return (yield* settled(resource)).length;\n }),\n }));\n```\n\nDo not create `craftComputed('total', ...)` beside the query when the\ncomputation depends only on `users`.\n\n### Keep casts and synchronous reads out of templates\n\nCraft templates reject both `as ...` / angle-bracket assertions and\n`craftUse(...)`. Fix the type or perform the synchronous-to-reactive\nconversion in the component logic, then expose a typed reader or generator to\nthe template:\n\n```ts\nconst typedStep = machine.stepState as unknown as () => { step: Step };\nreturn { typedStep };\n\n// Template: no cast and no craftUse.\nmatchNode.exhaustive(typedStep, 'step', steps);\n```\n\n`no-craft-use` applies to Craft TypeScript files, not only the fourth\n`craftComponent(...)` argument. A synchronous integration boundary may opt out\nlocally when its external API cannot consume a generator, but application\nstate and templates should use `yield*`.\n\n### Form and accessibility diagnostics\n\nThe accessibility preset also checks the static structure of hyperscript:\n\n- give every `label` an `htmlFor` matching the control `id`, or wrap the control;\n- give named controls and helpers a unique string local name;\n- use `button` or `a` for interactions instead of adding `click` to a `div`;\n- add a `prefers-reduced-motion` branch whenever component CSS defines an\n animation or transition.\n\nThese checks run on Craft TypeScript templates and extracted helper factories,\nso moving markup into a local function does not bypass them.\n\n### Reactive values belong in binding callbacks\n\n`require-reactive-template-bindings` uses TypeScript type information to find\nreactive reads. Reading a signal while constructing a VNode would make it a\ndependency of the structural component render, so the rule rejects this form:\n\n```ts\n// Incorrect: count is read by the component template.\np(`Count: ${count()}`);\nbutton({ disabled: isDisabled() }, 'Save');\ndiv({ class: { active: isActive() } });\n```\n\nKeep each read inside the callback owned by its DOM binding. Pass a yieldable\nreader, or use a generator when the binding must format:\n\n```ts\np(count);\np(function* () {\n return `Count: ${yield* count()}`;\n});\nbutton({ disabled: isDisabled }, 'Save');\ndiv({ class: isActiveClass });\n```\n\nLiteral and otherwise static values are still allowed, as are reads performed\nfrom DOM events and `onXxx` output callbacks. Because the rule is type-aware,\nthe ESLint parser must use `projectService: true` or a TypeScript `project`.\n\n### Pass simple yieldable callbacks directly\n\n`prefer-direct-yieldable-callback` removes a generator wrapper when the\ntemplate only delegates one zero-argument callback. It handles both a value\nbinding and a generator method:\n\n```ts\n// Before: redundant wrappers around the callbacks.\nbutton(\n {\n *click() {\n yield* press();\n },\n },\n function* () {\n return yield* label();\n },\n);\n\n// After `eslint --fix`.\nbutton({ click: press }, label);\n```\n\nMember callbacks are supported as well when the access is static and has no\narguments:\n\n```ts\n// Before.\nspan(function* () {\n return yield* counter.increment();\n});\n\n// After.\nspan(counter.increment);\n```\n\nThe rule leaves callbacks with parameters, extra statements, or additional\ncomputation unchanged. In those cases the generator contains behavior that\ncannot be represented by passing the callback reference alone.\n\n### Prefer deep-yieldable `forNode` items\n\n`prefer-deep-yieldable-for-item` detects when a component reads several\nproperties from the same `forNode` item through repeated `yield* item()` calls.\nKeep the original collection available, and expose a named deep-yieldable\nview for the component:\n\n```ts\nimport { insertDeepYieldable, state } from '@craft-ts/core';\n\n// Before: every property read yields the whole item again.\nforNode(catalog.products, { track: (product) => product.id }, (product) =>\n article([\n span(function* () {\n return (yield* product()).category;\n }),\n span(function* () {\n return (yield* product()).name;\n }),\n ]),\n);\n\n// After: the named view keeps each property read lazy and reactive.\nconst catalog =\n yield * state('catalog', { products }, insertDeepYieldable('products'));\n\nforNode(\n catalog.deepYieldableProducts,\n { track: (product) => product.id },\n (product) => article([span(product.category), span(product.name)]),\n);\n```\n\nThe rule is diagnostic-only because choosing the insertion belongs to the\nprimitive that owns the collection. `insertDeepYieldable('products')` leaves\n`catalog.products` unchanged and adds `catalog.deepYieldableProducts`.\n\n### Yield insertion writes from generator methods\n\n`require-yieldable-insertion-write` requires `set(...)`, `patch(...)`, and\n`update(...)` calls to be delegated with `yield*` when they are used inside a\ngenerator method:\n\n```ts\nnextPage: function* () {\n const current = yield* state();\n return yield* patch({ page: current.page + 1 });\n},\n```\n\nInsertion callbacks that are not generators may return a write directly; the\ninsertion wrapper consumes that result for them.\n\n## What generates what\n\nThree rules do more than complain — they write code you would otherwise\nmaintain by hand:\n\n| Rule | Generates |\n| -------------------------------------------- | ------------------------------------------------------------- |\n| `require-assert-exhaustive-route-exceptions` | the collection-level exhaustiveness assert |\n| `require-child-route-mount-check` | the `assertChildRouteMounts(...)` call and its import |\n| `require-lazy-load-with-retry` | the `withRetry(...)` wrapper on lazy route imports |\n| `prefer-direct-yieldable-callback` | replaces redundant generators with direct callback references |\n\n## Adopting them progressively\n\nOn an existing codebase, enable them in waves rather than all at once:\n\n1. **The route safety nets** — the `require-*` rules. Mostly autofixable. They\n generate the proofs; [architecture tests](/guide/testing/architecture#assertroutediproofs)\n (`assertRouteDiProofs`) fail CI if a proof is later removed or left unarmed.\n2. **The architecture rules last** — `prefer-craft-service`,\n `no-craft-service-component-same-file`, `prefer-craft-http-client`,\n `require-yieldable-reactive-read`,\n `require-yieldable-template-method`, `require-yieldable-insertion-write`.\n These ask for real refactors.\n\nThe four style rules — `no-raw-class`, `no-raw-css-value`, `no-free-has`,\n`style-file-boundary` — are in `craftRules.configs.recommended` at `'error'`,\nand they are **gated on the import**: they fire only in files that import\n`@craft-ts/style`. A component you have not migrated is not claiming the\nguarantee, so nothing reports it. The day a file starts using the design system\nis the day it starts being held to it — which is why enabling them on an\nunmigrated codebase costs nothing.\n\nThe two migration rules also expose a VS Code quick fix that inserts a temporary\nlocal disable comment with the intended migration note, so you can unblock a\nfile before doing the full refactor.\n\n## See Also\n\n- [Routing setup](/guide/routing/setup) — where these rules are installed\n- [CLI automation](/guide/routing/automation) — the codemods they complement\n- [Architecture rules](/guide/testing/architecture) — graph-wide constraints ESLint cannot see\n- [Activating the style system](/guide/style/setup) — what the four style rules are guarding\n"
|
|
326
326
|
},
|
|
327
327
|
{
|
|
328
328
|
"path": "/guide/routing/exception-handling",
|
|
@@ -437,7 +437,17 @@
|
|
|
437
437
|
{
|
|
438
438
|
"path": "/guide/style",
|
|
439
439
|
"title": "Typed styles",
|
|
440
|
-
"body": "# Typed styles\n\n::: tip Two style systems, and which to pick\nThis section is `@craft-ts/style`: typed values, CSS emitted at build time, and\na visual matrix you can enumerate. It costs a Vite plugin — see\n[Activating the style system](./setup.md) — and a design system to declare.\n\n[`meta.styles`](../components/styles.md) is the other one: a string of CSS\nshipped with the component and scoped with `@scope`, with no build step. It is\nthe shortest path to a component's own appearance.\n\nPick `meta.styles` for a component whose look is settled and local. Pick this\none when the variants are a matrix you need to prove you covered. They coexist.\n:::\n\n`@craft-ts/style` makes a component's visual surface **derivable** instead of\nguessed. For any component you can ask what the exhaustive set of visual states\nis, which of them are impossible, and whether the context it needs exists — and\nthe answers come from the same values the CSS is emitted from, not from a second\ndescription that can drift.\n\nIt buys that in three levels, and they do not adopt the same way.\n\n| level | what it gives you | granularity of adoption |\n| ------------------------------ | ---------------------------------------------------------------------- | --------------------------- |\n| 1 — tokens and typed variables | no value is a string; no class is built at runtime | **one component at a time** |\n| 2 — axes and the matrix | the exhaustive list of visual states, with the drivers that reach them | **per component** |\n| 3 — context obligations | a missing scroll port, container or clipping ancestor fails the build | **per whole route** |\n\nRead that last column carefully, because it is the part that is easy to get\nwrong. Level 3 is not a per-component guarantee: one unmigrated link in a route\nand the requirement travels past it unanswered, so the compiler has nothing to\ncheck. A partial level-3 adoption gives **zero** of the guarantee, not most of\nit — and the graph reports it rather than hiding it.\n\n## The rule the whole thing turns on\n\n**Static goes to a class at build time; dynamic goes through a typed custom\nproperty.** No class is ever assembled in the browser.\n\n```ts\n// tone is an axis: five rules the emitter already wrote.\nwhen(tone.danger, [set(v.bg, palette.accent.danger)]);\n```\n\n```ts\n// a width that depends on a signal cannot be a class — there is no finite set\n// of widths to emit — so it goes through a registered <percentage>.\nstyle: function* () {\n return assign(meterVars.value, unit.pct(yield* value()));\n}\n```\n\nThat split is what keeps the set of visual states finite, and therefore\nenumerable. A class built from a signal is a state nothing recorded.\n\n## Where to go next\n\nStart with [Activating `@craft-ts/style`](./setup.md): the system is a build\nstep, and none of the pages below produce a single byte of CSS until the Vite\nplugin is wired. Then [Define your design system](./define.md), which is where\n`bp`, `palette` and the theme variables the other pages spend come from.\n\n- [Tokens and typed variables](./tokens.md) — level 1.\n- [Axes and the visual matrix](./variants.md) — level 2.\n- [Context obligations](./obligations.md) — level 3.\n- [Testing what you built](./testing.md) — drivers, baselines, exhaustiveness.\n\nA working example lives in the demo, at\n`apps/demo/src/app/examples/design-system/`, with a README that walks through\nthe same three levels in code.\n"
|
|
440
|
+
"body": "# Typed styles\n\n::: tip Two style systems, and which to pick\nThis section is `@craft-ts/style`: typed values, CSS emitted at build time, and\na visual matrix you can enumerate. It costs a Vite plugin — see\n[Activating the style system](./setup.md) — and a design system to declare.\n\n[`meta.styles`](../components/styles.md) is the other one: a string of CSS\nshipped with the component and scoped with `@scope`, with no build step. It is\nthe shortest path to a component's own appearance.\n\nPick `meta.styles` for a component whose look is settled and local. Pick this\none when the variants are a matrix you need to prove you covered. They coexist.\n:::\n\n`@craft-ts/style` makes a component's visual surface **derivable** instead of\nguessed. For any component you can ask what the exhaustive set of visual states\nis, which of them are impossible, and whether the context it needs exists — and\nthe answers come from the same values the CSS is emitted from, not from a second\ndescription that can drift.\n\nIt buys that in three levels, and they do not adopt the same way.\n\n| level | what it gives you | granularity of adoption |\n| ------------------------------ | ---------------------------------------------------------------------- | --------------------------- |\n| 1 — tokens and typed variables | no value is a string; no class is built at runtime | **one component at a time** |\n| 2 — axes and the matrix | the exhaustive list of visual states, with the drivers that reach them | **per component** |\n| 3 — context obligations | a missing scroll port, container or clipping ancestor fails the build | **per whole route** |\n\nRead that last column carefully, because it is the part that is easy to get\nwrong. Level 3 is not a per-component guarantee: one unmigrated link in a route\nand the requirement travels past it unanswered, so the compiler has nothing to\ncheck. A partial level-3 adoption gives **zero** of the guarantee, not most of\nit — and the graph reports it rather than hiding it.\n\n## The rule the whole thing turns on\n\n**Static goes to a class at build time; dynamic goes through a typed custom\nproperty.** No class is ever assembled in the browser.\n\n```ts\n// tone is an axis: five rules the emitter already wrote.\nwhen(tone.danger, [set(v.bg, palette.accent.danger)]);\n```\n\n```ts\n// a width that depends on a signal cannot be a class — there is no finite set\n// of widths to emit — so it goes through a registered <percentage>.\nstyle: function* () {\n return assign(meterVars.value, unit.pct(yield* value()));\n}\n```\n\nThat split is what keeps the set of visual states finite, and therefore\nenumerable. A class built from a signal is a state nothing recorded.\n\n## Where to go next\n\nStart with [Activating `@craft-ts/style`](./setup.md): the system is a build\nstep, and none of the pages below produce a single byte of CSS until the Vite\nplugin is wired. Then [Define your design system](./define.md), which is where\n`bp`, `palette` and the theme variables the other pages spend come from.\n\n- [Tokens and typed variables](./tokens.md) — level 1.\n- [Axes and the visual matrix](./variants.md) — level 2.\n- [Context obligations](./obligations.md) — level 3.\n- [Testing what you built](./testing.md) — drivers, baselines, exhaustiveness.\n- [Text contrast](./contrast.md) — WCAG AA proven from the sheets and the\n templates, with no browser, and an explicit list of what it does not cover.\n\nA working example lives in the demo, at\n`apps/demo/src/app/examples/design-system/`, with a README that walks through\nthe same three levels in code.\n"
|
|
441
|
+
},
|
|
442
|
+
{
|
|
443
|
+
"path": "/guide/style/attestation",
|
|
444
|
+
"title": "Attestation: a judgement that survives a refactor",
|
|
445
|
+
"body": "# Attestation: a judgement that survives a refactor\n\nA snapshot suite records _what the output was_. Renaming a local variable\nchanges no pixel, and yet the whole suite asks to be looked at again — so people\nrun `--update-snapshots`, and the file that was supposed to record a human\ndecision records nothing at all.\n\nThis records something else:\n\n> **A person looked at this output and judged it correct, and that judgement\n> holds for as long as the code producing it has not moved.**\n\n## Two caches, never one\n\nTwo questions look alike and are not:\n\n| question | keyed on | a wrong answer costs |\n| ------------------------------ | --------------------------- | -------------------- |\n| should this be re-run? | fingerprint of a code slice | CPU |\n| should a human be asked again? | hash of the evidence | somebody's afternoon |\n\nFrom which the rule the whole design rests on: **when the code changes and the\nevidence does not, the attestation carries itself forward**, marked `renewed`,\nwith a note saying \"code changed, output unchanged\".\n\nThat is also why the code fingerprint is allowed to be _cautious_. A slice that\nis too wide only costs a re-run. Only a slice that is too narrow is dangerous —\nit misses a regression in silence, and nobody is ever asked about it again.\n\n| state | meaning |\n| --------- | --------------------------------------------- |\n| `current` | the fingerprint has not moved: nothing to do |\n| `renewed` | code moved, output did not: carried, no human |\n| `review` | the output differs: a human has to look |\n| `missing` | never attested |\n\n## The evidence is a digest, not a picture\n\nWhat a person judges is the **layout digest**: boxes rounded to the half pixel,\nintrinsic sizes, a closed list of computed styles, and a set of discrete facts —\nline counts, column counts, what wraps, what clips, what scrolls, what overlaps.\n\nThree things follow, and each is why the digest exists rather than a screenshot:\n\n- it is **diffable**. `.card padding 8→12` is a sentence a reviewer reads in a\n second; two images are not, and a reviewer who cannot see what changed\n approves everything.\n- it is **assertable**. Overflow, truncation, overlap and contrast are\n comparisons of numbers, so they are **failures**, not queue items — nobody is\n asked, and the message names the node and the pixel count.\n- it is **stable**. Anti-aliasing and font hinting move pixels without moving\n layout; under an image comparison every one of those is a review item.\n\nThe PNG is still kept, in the content-addressed store, for the human. It is a\nreview aid, never the reference.\n\n```ts\nimport {\n assertNoLayoutViolations,\n collectLayoutDigest,\n makeDeterministic,\n} from '@craft-ts/style-testing';\n\nawait makeDeterministic(page);\nawait page.goto('/users');\nconst digest = await collectLayoutDigest(page, {\n root: '[data-testid=userCard]',\n});\n\nassertNoLayoutViolations(digest, { scenario: 'locale=de-DE' });\n// → userCard/title hides 34px of \"Benutzerkontoeinstellungen\".\n```\n\n## Determinism is a feature, not hygiene\n\nIt carries two independent mechanisms. The **carry-forward** reads \"the code\nmoved, the output did not\"; a wobbling render never produces the same output\ntwice, the queue fills with changes nobody made, and people start stamping. The\n**bisection** reads a discrete signature as a function of one parameter; a wobble\nmanufactures thresholds that do not exist.\n\n`makeDeterministic` freezes the clock, seeds `Math.random`, kills animations,\ntransitions and the caret, and refuses every network request by default. A\nhundred consecutive renders of one scenario must produce a hundred identical\ndigests before anything else is worth building:\n\n```ts\nawait assertDeterministic(\n async () => JSON.stringify(await digestOf(page)),\n 100,\n);\n```\n\n## Where it tips over\n\nA content axis is continuous and a layout does not care about most of it. What\nit has are **thresholds**. `findTransitions` samples a coarse grid — word\nboundaries, digit-count changes — and bisects only inside the intervals where\nthe discrete signature actually moved.\n\nThen the report that arrives _before_ the bug:\n\n```\nuserCard/title: 1 → 2 lines at 34 characters.\nToday's German string is 33. Margin: 1 (3%), below 15%.\n```\n\nNothing is broken. That is the point: it fails in CI, with no human and no\npixel, on the translation nobody has written yet.\n\n```ts\nconst search = await findTransitions(signatureAt, {\n axis: 'title',\n min: 1,\n max: 80,\n});\nassertMargins([marginOf(search, longest.longestLength)]);\n```\n\nA bisection is not exactly true — it finds the thresholds that exist between the\npoints it looked at. So the sample count goes into the attestation as an\n**assumption**, and an attestation never says \"validated\"; it says \"validated,\nunder this assumption\". A changed assumption sends the subject back to review\nrather than quietly becoming a lie.\n\n## Translation as a source of axes\n\nThe catalogue is a TypeScript value, which makes two of these exact:\n\n- **the longest locale** for a screen is a computation over the keys that screen\n uses. One axis point, and the right one — where \"test it in German\" is only an\n approximation.\n- **the plural categories** are already declared and already checked exhaustive\n per locale. They are axis points by construction.\n\nThe **pseudo-locale** is the approximation, and it is the one that finds the\n_future_ case: 40% longer, `[[bracketed]]` so truncation is visible, every letter\naccented so an un-externalised string stands out.\n\n```ts\nimport {\n longestLocale,\n pseudoCatalog,\n findHardCodedText,\n} from '@craft-ts/i18n/testing';\n\nlongestLocale([en, de, ja], usedKeys); // → { id: 'de-DE', longestKey: 'account.settings' }\nfindHardCodedText(visibleStrings); // → ['Submit'] ← never went through the catalogue\n```\n\nTwo pressures, opposite failure modes, kept apart throughout: a rising\n`min-content` (an unbreakable word, a URL, a long number) stops a column\nshrinking; a rising `max-content` (a long but breakable sentence) steals width\nfrom its siblings in an `auto` track. A long sentence with spaces in it usually\ndoes not move `min-content` at all.\n\n## The command line\n\nCapture a real route into a portable report, then let the CLI derive every\nfingerprint from the current dependency graph:\n\n```sh\nnpm run attest:visual:capture\n\nnpm run attest:visual:status\n```\n\nThe capture starts the demo server when needed and writes the report, PNG\nscreenshots, and frozen `.snapshot.html` documents to `.craft/runs/`. The\nreport contains repository-relative graph node ids, digests and screenshot\npaths. It deliberately contains no code fingerprint: accepting a fingerprint\nfrom an old browser run could keep a stale slice current forever. Reports,\nscreenshots and frozen documents are regenerable and ignored; the ledger is\nnot.\n\nTo choose another report path, set `CRAFT_VISUAL_REPORT` on both commands:\n\n```sh\nCRAFT_VISUAL_REPORT=.craft/runs/my-run.json npm run attest:visual:capture\nCRAFT_VISUAL_REPORT=.craft/runs/my-run.json npm run attest:visual:status\n```\n\n```sh\nnpm run attest:visual:review\n\nnpx tsx libs/cli/src/bin/craft-ts.ts attest why 'visual:userCard#viewport=md'\nnpx tsx libs/cli/src/bin/craft-ts.ts attest renew \\\n --subject 'visual:userCard#viewport=md' --verdict ok\nnpx tsx libs/cli/src/bin/craft-ts.ts attest review \\\n --kind visual \\\n --report .craft/runs/design-system.json \\\n --tsconfig apps/demo/tsconfig.graph.json\nnpx tsx libs/cli/src/bin/craft-ts.ts attest unwatched\n```\n\nFor the unified review surface, use the DevTool. It combines visual captures\nand template obligations in one queue:\n\n```sh\nnpm run attest:devtools\n```\n\n### The reviewer reviews itself\n\nThe review application can use the same mechanism on its own UI. It runs in two\nsuccessive sessions so the queue cannot change while it is being captured: the\nfirst instance renders a deterministic fixture queue and freezes representative\nstates; the second instance reviews that visual report together with template\nobligations derived from the review application's own CraftTS graph.\n\n```sh\nnpm run attest:review-app:capture\nnpm run attest:review-app:status\nnpm run attest:review-app:review\n```\n\nThe capture includes the review page's happy path at mobile and desktop sizes,\nthe review queue in dark French, the regeneration confirmation, the visual-test\ninventory, and the template-obligation inventory. Every portable snapshot is\nreplayed immediately and must reproduce the live layout digest.\nThe first run reports missing decisions until a reviewer explicitly accepts or\nrejects them. Later unchanged evidence is carried forward by the usual ledger.\n\nThe review sidebar also offers **Regenerate all evidence**. It opens a\nconfirmation describing the current scope and whether previous decisions\nexist. Regeneration replaces the report, screenshots, and frozen documents,\nthen re-reads the graph and rebuilds the queue. It never clears the ledger:\nunchanged evidence stays current and only new or changed evidence returns to a\nreviewer. Any unsaved reason on the open card is discarded.\n\nThis control is shown only when the CLI session explicitly names an npm script:\n\n```sh\ncraft-ts attest devtools \\\n --report .craft/runs/project.json \\\n --tsconfig apps/project/tsconfig.graph.json \\\n --regenerate-script attest:project:capture\n```\n\nOnly an npm script name is accepted, not an arbitrary shell command. The script\nmust recreate the report supplied to `--report`; a failed run preserves the\nexisting queue.\n\nAfter rejecting views with comments, use **Prepare Codex iteration** in the\nreview sidebar. It generates, next to the report, a readable\n`<report>.review-feedback.md`, a structured `<report>.review-feedback.json`,\nand a copyable `<report>.codex-prompt.md`. The prompt contains the project root,\nreport, ledger, evidence store, graph `tsconfig`, capture script, source file\npaths, scenarios, measured changes, comments and any digest nodes pointed to by\nthe reviewer. Only the latest `rejected` cards are included. Because these\npaths come from the CLI session, `apps/demo` and the review application's\nself-attestation resolve to different, correct project contexts.\n\n### One happy path for every page\n\nApplication-level coverage is declared once and expanded into mobile and\ndesktop captures by default:\n\n```ts\nimport {\n defineHappyPathHttpMocks,\n defineVisualAppConfig,\n visualAppHappyPaths,\n} from '@craft-ts/style-testing';\n\nexport const homeHappyPath = defineHappyPathHttpMocks(\n 'home-page.happy-path.ts',\n {\n 'GET /api/users': { response: [{ id: '42', name: 'Ada' }] },\n },\n);\n\nexport const visualTestConfig = defineVisualAppConfig({\n pages: [\n {\n id: 'home',\n route: '',\n url: '/',\n component: 'component:src/app/home-page.ts:HomePage',\n mocks: homeHappyPath,\n },\n ],\n});\n\nfor (const scenario of visualAppHappyPaths(visualTestConfig)) {\n test(scenario.id, async ({ page }) => {\n await page.setViewportSize(scenario.viewport);\n await page.goto(scenario.page.url);\n // Install scenario.page.mocks, wait for the happy UI, then collectCapture.\n });\n}\n```\n\nWithout an explicit `viewports` value, CraftTS uses `mobile: 390x844` and\n`desktop: 1440x1000`. Keep each response dataset in a sibling\n`*.happy-path.ts` file. `matchHappyPathHttpRequest` turns that dataset into a\nrequest match suitable for `page.route`; when a `craftRoutes` registry is\navailable, wrap its exhaustive, response-typed `mockHttpRequestForRoute` result\nwith `defineRouteHappyPathHttpMocks('page.happy-path.ts', routeMock)`.\n\nAdd `assertVisualHappyPathArchitecture(graph.graph, visualTestConfig)` to the\napplication architecture suite. It fails when a routed page, a required\nviewport, or a Craft HTTP endpoint has no successful happy-path fixture. The\nfixture feeds a deterministic test environment only; application code keeps\nall remote work inside its `query`, `mutation`, or `asyncProcess` loader.\n\nSet `CRAFT_REVIEW_APP_REPORT` to the same path on all three commands to relocate\nthe default `.craft/runs/review-app.json` report. The implementation notes and\nthe exact workflow live in `libs/review-attestation/attestation-app/README.md` in the\nrepository.\n\nTemplate obligations do not need a Playwright report. They are derived from the\ncurrent graph and their canonical proof objects are written to\n`.craft/evidence/`:\n\n```sh\nnpm run attest:templates:status\nnpx tsx libs/cli/src/bin/craft-ts.ts attest review \\\n --kind template \\\n --tsconfig apps/demo/tsconfig.graph.json\n```\n\nTwo of these carry the rest.\n\n**`why`** names the graph nodes that moved inside the subject's slice, and when\na person last actually looked at it. A review that cannot answer \"why am I being\nasked this?\" is a review that gets stamped.\n\n**`unwatched`** lists the nodes that moved and belong to no attested subject —\n_what changed while nobody was looking_. It falls out of the machinery for free.\n\n`renew --all` is allowed and is **marked** as a bulk renewal in every\nattestation it writes, and `status` counts them. A bulk renewal that left no\ntrace would turn the register into a rubber stamp, which is worse than having no\nregister.\n\n## What a reviewer is shown\n\nTwo artefacts, and the reviewer switches between them.\n\n**The frozen page** is the render itself: the DOM, the styles, and the form\nstate, serialised at the moment the digest was taken. It replays as a real\ndocument — real boxes, real `:hover` — which is what lets someone click an\nelement and name it instead of clicking a pixel and hoping.\n\nFreezing means more than serialising the DOM. Craft injects its styles through\n`adoptedStyleSheets`, which `outerHTML` cannot see at all. And keeping the\nstylesheets verbatim would leave every `@media` to be re-evaluated against the\n_reviewer's_ window: on the demo's route the two conditions in play are\n`(min-width: 48rem)` and `(prefers-color-scheme: dark)` — exactly the two axes of\nthe matrix — so four scenarios would collapse into whatever that laptop said.\nMedia and supports are therefore evaluated at capture time and their winning\nbranch inlined. Container queries are left alone, because they ask about the\npage's own layout, which the replay reproduces.\n\nThe snapshot carries **no script**. Inertness is a property of the artefact, not\na guard that has to hold: nothing to block, nothing to leak, no `craftMethod`\nfiring on a stray click. The review application does its interactive work from\nthe parent frame, reaching into a same-origin iframe.\n\n**The screenshot** is the fallback, and the check on the checker: the digest is\nblind to anything that does not move a box or a listed style, so a swapped\nbackground or a wrong icon passes every automated test and is obvious to an eye.\n\n### The replay is checked, not trusted\n\nBefore anything is drawn on it, the replay is re-measured with the collector\nthat produced the evidence and compared against the attested digest. A missing\nfont, a media query left conditional, a stylesheet that could be neither read\nnor fetched — each produces a document that looks plausible and measures\ndifferently, and a reviewer would judge it without ever knowing.\n\nWhen it does not match, the card **moves the reviewer to the screenshot by\nitself** and says why in the same sentence, and the verdict is recorded as\n`degraded`: judging a photograph and judging the document are different claims.\nThe choice is a fallback, not a lock — asking for the page brings it back, still\nlabelled for what it is.\n\nThe message names the cause, not its symptoms. A subject the frozen page does\nnot contain reported \"36 attested node(s) are absent\" followed by forty\naddresses beginning `html/head/meta`: every consequence of one fact, and none of\nthem stating it. It now reads\n\n> The frozen page has no `.design-system-host` in it, so what it shows is not\n> this component. That happens when the stored snapshot is older than the report\n> it is paired with, or when the component's root selector changed after it was\n> captured.\n\nwhich is the same finding with the reviewer's next move in it.\n\nTwo things the check caught while it was being built, which is what it is for:\na marker stylesheet that set `position: relative` on the attested root and moved\nthe tree it was supposed to annotate, and a 1px border on the frame, which is\nsubtracted from the viewport inside it and made every measurement 2px narrow.\n\n## Knowing what is actually being judged\n\nA capture shows the whole page — shell, navigation, neighbours — because a\ncomponent has to be judged in the frame it sits in. So the reviewer has to be\nable to tell the subject from the decor, or a remark lands on a card that does\nnot cover it.\n\nThe digest answers this exactly: its paths **are** the attested set. Three tiers\nfollow, and all three come from data that already exists:\n\nThe screen is laid out in the order the work happens: the queue on the left,\nthe evidence in the middle, the verdict on the right, where it stays in place\nwhile a long capture is scrolled.\n\nThe surface speaks English and French, and follows the system's light or dark\npreference until the reviewer chooses otherwise — both controls sit in the\nsidebar, and both are applied before the first paint rather than corrected a\nframe later. The French dictionary is typed as the English one, so a message\nadded on one side and forgotten on the other does not compile.\n\nNeither reaches the frozen page. It is a render that was captured, not an\ninterface: translating it, or repainting its ground, would make it something\nother than what was measured. Enforcing that turned up a fidelity bug the tool\nhad been hiding by being permanently dark — a page paints its own colours, but\nnot the canvas underneath, and that comes from `color-scheme`, which was the\n_reviewer's_ preference. A component captured on white came back on black for\nanyone whose machine asks for dark. The replay now declares the scheme its\ncapture was taken in.\n\n| tier | source | shown as |\n| -------- | ------------------------------ | ----------------------------------------- |\n| changed | the paths in the readable diff | outlined, and the reason the card is here |\n| attested | the digest's own paths | selectable, highlighted on hover |\n| decor | everything else | dimmed, never removed |\n\nThe outlines carry a legend, drawn from the same object that paints them — a key\nthat keeps its own copy of a colour is a key that will one day name the wrong\none. Entries for tiers this card has none of are not shown, so the legend\ndescribes the page in front of the reviewer rather than the system in general.\n\nSelection is a set, not a node. Ctrl-click (cmd on a Mac) adds one, and dragging\na box takes everything it touches; the count is stated beside the reason field\nthat is about to name them. A remark covering a row of buttons was otherwise the\nsame sentence retyped once per button, which is also how a queue fills with\nfindings nobody can group afterwards. The band is drawn beside the frame and\nnever inside it: adding an element to the frozen document would break the only\nclaim it makes.\n\n### One reason, several complaints\n\nA rejection is rarely about one thing. Right-clicking a selection drops a\nreference into the reason **where the reviewer is typing**:\n\n> The title is cut at 34px in German. `[#1: 2 nodes]` And the row below\n> overflows its box. `[#2: 5 nodes]`\n\nThe reason stays one piece of prose, and each reference carries the text written\nsince the one before it — so the second complaint is filed against the second\ngroup and not, as a single note against every node would have it, against all\nseven.\n\nSelecting _is_ referencing: there is no second gesture. Pointing at part of the\npage drops the reference straight into the reason, and refining the selection\nedits that same reference rather than adding another — a click followed by a\nctrl-click leaves one saying \"2 nodes\", not a stale \"1 node\" beside it. Typing\nends the session: the reference belongs to a sentence now, and the next\nselection starts its own. Emptying the selection takes the reference back out,\nbecause a reference to nothing is worse than none.\n\nThe field is a `contenteditable`, not a `textarea`, so a reference is an element\nrather than the literal characters `[#1: 2 nodes]`: hovering it lists the\naddresses it stands for **and paints those nodes in the frozen page**, dashed\nrather than solid so it cannot be mistaken for the selection. That is the whole reason for the swap. As text, the\nanswer to \"which nodes is this one about\" had to live in a list somewhere else\non the page, and a reviewer reading a sentence had to leave it to find out. The\nplain text is still the model — everything downstream reads the serialised\nstring — so the chips are a rendering of the reason and never a second version\nof it.\n\nTwo things that swap broke, both worth stating because neither is obvious. The\nfield is not scrollable: a clipping context cuts the tooltip off any reference\non the first line, and which nodes a reference covers must not depend on where\nin the sentence it was written. And the keyboard shortcuts had to learn about\nit — the guard knew `input`, `textarea` and `select`, so typing \"And the row is\ncut\" pressed `a`, Accept, and filed a verdict the reviewer never reached.\n\nPosition settles that, not punctuation. The first rule tried was \"the sentence\nthe token stands in\", and it was wrong for the way people write: the complaint\nis typed, ended, and _then_ the group is pointed at, so the caret is past the\nfull stop and the token opens the next sentence rather than closing its own.\nReferencing first and explaining after reads the other way round, so a group\nwith nothing before it takes what follows.\n\nThe tokens are scaffolding. What is recorded is the prose with them removed,\nplus the addresses each one pointed at — and the text remains the only state:\ndeleting a reference deletes it, with no second list left holding a claim the\nreason no longer makes.\n\nAnd the mistake is made unrecordable rather than merely discouraged. A rejection\ncarries the path of the node it is about, so the server can refuse one that\nnames something this subject does not attest:\n\n> `demo-nav/toggle` is not attested by this subject. File the remark on the card\n> that covers it.\n\n### Attested is not the same as looked at\n\nThe capture also records the gap, because it is large. On the demo's route: **36\nnodes attested, 21 off screen, 1 covered** by the page's own fixed button. An\nattestation that stayed quiet about that would claim a coverage it does not\nhave, so the card states it and the screenshot draws the line where the viewport\nended.\n\nThe verdict buttons carry what they _do_. Three of the five are accepted by the\nledger and two are not, and nothing in the words says which — a reviewer\nchoosing between \"Known issue\" and \"Block\" is choosing between \"stops asking\"\nand \"asks every run\", which is the only difference that matters and the one they\ncould not see.\n\n\"Fit to window\" applies to the frozen page too, by `transform: scale()` and\nnever by a width: the frame has to stay exactly the viewport the page laid\nitself out in, or the replay stops being the render that was measured. A\ntransform changes what is painted and nothing about what was measured.\n\n\"Fit to window\" bounds both axes. Bounding the width alone — which is what it\ndid — fits a picture wider than the canvas and does nothing whatsoever to a\nnarrow one, and every capture on the demo route is 375 or 768 wide and 916 tall:\nthe control showed the whole render on one scenario and two thirds of it on the\nnext, for a reason that had nothing to do with what was being judged. It is not\noffered while the frozen page is on screen, because scaling that page would\nrelayout it and it would stop being the render that was measured.\n\nThe one covered node is the case only the frozen page can resolve: **lift what\nis covering it** and see what was underneath. In a screenshot those pixels have\nalready been replaced.\n\nThe control names what it will lift — `Hide button.clear-cache-btn` — and is\noffered only when something is actually covering the component. It used to read\n\"Hide 1 overlay\", which asked the reviewer what an overlay is and counted the\nwrong thing: covered _nodes_, when one button sitting on five of them is one\nthing to lift. Worse, it marked every fixed element on the page whether or not\nit covered anything, and marked nothing that covered without being fixed — so on\nmost cards it lifted something irrelevant, and on the cards that mattered it\ncould do nothing while the coverage line insisted a node was covered.\n\nWhat is lifted is now decided by probing the replay with the collector's own\nrule, which is also where the clamping bug in that rule was found: a sample\npoint outside the viewport was pulled back to the edge, so a node straddling the\nfold was reported as covered by whatever happened to sit on the fold line.\nSamples outside the viewport are skipped in both places now.\n\n## The review queue\n\nTwo mechanisms keep it from being abandoned, and neither is optional. The\ncarry-forward keeps everything whose output did not move out of the queue\nentirely. And the queue **clusters by the shape of the diff**: one border-radius\nchange produces two hundred scenarios with an identical delta, and one decision\ncovers all of them — with the cluster written into every attestation it covered,\nso \"judged\" and \"judged alongside 199 others\" stay distinguishable.\n\nThe review surface is itself a CraftTS application. A `query` owns the live\nqueue, a `mutation` records each decision, and local `state` owns navigation,\nnotes and evidence zoom. The Node server remains the authority for the ledger\nand the content-addressed evidence store. A card disappears only after that\nserver confirms the write; failures remain visible and reviewable.\n`attest review` attempts to open the local URL in the default browser and always\nprints it so headless or remote environments can open it manually.\n\nFirst-time captures are deliberately **not clustered**. With no approved digest\nthere is no delta proving that two new screenshots represent the same change.\nThe UI shows one decision per scenario, its exact viewport, captured element\nsize, colour scheme, browser version and target selector. Real identical deltas\nmay still be clustered, with every covered scenario listed before the decision.\n\nThe default shortcuts are `j`/`k` to move, `a` to accept, `n` to accept with a\nnon-empty note and `r` to reject. A rejection requires a non-empty reason. That\nreason is stored in the ledger and shown prominently if the scenario returns to\nthe review queue, so it can guide the corrective code change. The same actions\nare available as buttons.\n\n## What this does not replace\n\n`visualMatrix` stays. It is the cheap tier for out-of-flow things — modals,\npopovers, tooltips — which have no neighbourhood, and for purely pictorial axes,\nwhich have no layout consequence. Both are enumerable from the sheets alone,\nwith no page in sight.\n"
|
|
446
|
+
},
|
|
447
|
+
{
|
|
448
|
+
"path": "/guide/style/contrast",
|
|
449
|
+
"title": "Text contrast, proven without a browser",
|
|
450
|
+
"body": "# Text contrast, proven without a browser\n\n`npm run style:check` reads your sheets and your templates and answers one\nquestion, for every element it can prove holds text, in every state your axes\ncan produce:\n\n> is this text readable on the background it is actually painted on?\n\nIt is WCAG 2.2 §1.4.3 level AA — `4.5:1` for normal text, `3:1` for large text\n— and it needs no browser, no screenshot and no Playwright run.\n\n::: warning What this is not\nThis proves **text contrast**, in a declared subset of CSS. It is not an\naccessibility audit, and a green run is not a claim that your application is\naccessible. Focus order, names, roles, motion, target size and everything else\nare elsewhere. Read [the coverage contract](#the-coverage-contract) before you\nput a badge on it.\n:::\n\n## Running it\n\n```bash\nnpm run style:check\n```\n\nIn a project generated with typed CSS this is already wired: it builds once so\nthe style plugin writes `.craft/style-graph.json`, then analyses that dump\ntogether with your TypeScript program.\n\nBy hand, on an existing project:\n\n```bash\nnpx craft-graph --style-contrast --style-dump .craft/style-graph.json --project tsconfig.app.json\n```\n\n`--json` gives a stable machine-readable report for CI. Two runs on unchanged\nsources produce byte-identical output.\n\nUnlike `--style-matrix` and `--style-debt`, this command **does** build the\nTypeScript program. It has to: a contrast proof needs to know which element\ncarries which class and what sits above it, and no style dump has ever seen a\ntemplate.\n\n## Reading a failure\n\n```text\ncontrast/fail\nroute: /checkout\ncomponent: SubmitButton\nelement: button.root\nscenario: interaction.hover=active+tone=warning\nforeground: ui.text.onAccent #ffffff (dsButton-root → --dsButton-ink (initial))\nbackground: ui.accent.warning.dark #f5b544 (button.root: dsButton-root → --dsButton-bg)\nfont: 14px / 600 (normal text)\nratio: 1.81:1\nrequired: 4.5:1\n```\n\nEvery line is there because a report missing it sends you to the wrong file:\n\n- **scenario** — the exact combination. Not \"the warning button\": the warning\n button *under the pointer*, which is often the only failing one.\n- **foreground / background** — the token name first, then the value, then the\n chain that produced it. `ui.accent.warning.dark` tells you which token to\n change; `dsButton-root → --dsButton-bg` tells you which rule put it there.\n- **font** — with the threshold it earned. See\n [large text](#which-threshold-applies).\n\nRows that resolve to the same answer are folded together and list the\nscenarios they stand for under `also in:`, so a five-tone button does not\nprint five identical lines.\n\n## The two halves, and why only one of them fails a build\n\n| | `--palette-contrast` | `--style-contrast` |\n|---|---|---|\n| reads | the dump alone | the dump **and** the templates |\n| answers | every pair your palette can express | every pair an element is actually painted in |\n| verdict | informative | **blocking** |\n\nA palette of twenty tokens has hundreds of pairs and an application renders a\nfew dozen of them. Failing a build on `ui.text.onAccent` over\n`ui.surface.page` — white on white, and a combination no element uses — trains\npeople to switch the check off, and takes the real failures with it.\n\nSo the matrix is a table you read while designing, and the analysis is the\ngate. When you pass the analysis's results to `paletteContrastMatrix`, each\npair also gets a `usedBy` list of the elements that render it; without them the\nfield is **absent** rather than empty, because \"nobody looked\" and \"the\nanalysis looked and found nowhere\" are different answers.\n\n## Which threshold applies\n\n- large if `font-size >= 24px`;\n- large if `font-size >= 18.5px` **and** the weight is at least `700`;\n- normal otherwise.\n\nTwo consequences worth knowing before you argue with a report:\n\n- `text.lg` is `1.125rem` — **18px** — so a bold title at that size is *normal*\n text and needs `4.5:1`. It misses the large threshold by half a pixel.\n- `600` is not bold. WCAG says \"bold\" without a number and CSS says bold is\n 700; reading `600` as bold would lower a threshold on an ambiguity, which is\n the wrong side to err on.\n\n`rem` becomes pixels against a 16px root. If your page sets a different root\nsize outside CraftTS, say so — nothing in the dump can know.\n\nThe ratio is **never rounded before it is compared**. `4.4999:1` fails a 4.5\nthreshold, even though the report prints it as `4.49:1`.\n\n## How colour is resolved\n\n### `color` is inherited\n\nThe analysis walks the element's ancestor chain outside-in, exactly as the\ncascade does. A paragraph that sets no colour of its own takes the one from\nits card, or from the theme wrapper above it.\n\n### The background is the first opaque thing underneath\n\nStarting at the element and walking outwards:\n\n- an element that paints nothing is transparent, and the search continues;\n- the first opaque colour wins;\n- an element that paints something the model cannot read — an image, a\n semi-transparent fill, anything behind an `opacity` — **stops** the search\n and produces `indeterminate`, because whatever is behind it is no longer\n what the text is composited against.\n\nIf nothing in the chain paints, that is `unknown-background`. It is not\n\"assume white\": a white assumption is right on one theme and wrong on the\nother.\n\n### Variables resolve the way `@property` says they do\n\n- a registered variable nobody wrote resolves to its **registered initial\n value**, not to the `var()` fallback;\n- `inherits: false` really does not cross into a child — a theme variable set\n on a wrapper reaches the button, a component variable does not.\n\nThat last one is the trap the design system is full of, and getting it wrong\nwould prove the wrong colour for every component under a themed wrapper.\n\n### The cascade is replayed, not approximated\n\nThree tie-breaks, in the browser's order:\n\n1. **Layer.** Unconditional atoms land in `components`, conditional ones in\n `variants`, so every variant beats every base rule.\n2. **Specificity**, inside `variants`. `&[data-tone='warning']:hover` has one\n more selector fragment than `&[data-tone='warning']`, so the hovered fill\n wins — whatever the source order. A media query contributes nothing.\n3. **Source order**, last, which is atomic class-name order because that is\n how the emitter sorts the layer.\n\nGetting the second wrong is the interesting failure: a solver would resolve a\ntone-plus-hover button to its resting fill and report `pass` on the state that\nfails.\n\n## Hover is an axis\n\n```ts\nwhen(tone.warning, [\n set(buttonVars.bg, ui.accent.warning),\n when(interaction.hover, [set(buttonVars.bg, ui.accent.warningHover)]),\n]);\n```\n\n`interaction.hover` emits the same `:hover` rule a hand-written selector would.\nWhat it adds is that the point lands in the class's variant contract — so the\nvisual matrix captures the hovered state, and this analysis crosses the\ncolours it writes with the text that sits on them.\n\nA `:hover` typed into a string is invisible to both. That is how a button ends\nup readable at rest and unreadable under the pointer: the one state nobody\nscreenshots. The `prefer-hover-axis` lint rule refuses it.\n\nThe axis carries its own driver (`{ kind: 'selfState', state: 'hover' }`), so a\ncapture of the hovered state is something a harness can actually produce.\n`applyScenario` asks the page to move a real pointer and throws if it cannot —\ndispatching a `mouseover` event would fire listeners and leave the pseudo-class\nuntouched, producing a screenshot of the base state that passes forever.\n\nThe cost is real and it is a decision: hover doubled the demo button's matrix\nfrom 18 scenarios to 36. That is why the axis has to be in the sheet's budget.\n\n## Naming your palette\n\n```ts\nexport const ui = definePalette('ui', {\n text: { onAccent: { light: '#ffffff', dark: '#0b0d11' } },\n accent: { warning: { light: '#8a5a00', dark: '#f5b544' } },\n});\n```\n\nThe name travels with every colour, through variables and `darkOf()`, all the\nway into the report. `definePalette(spec)` without a name still works and still\ncarries the group and the token — you get `(unnamed).accent.warning`, which\npoints at the right entry and asks to be named.\n\nWrite hovered and pressed fills as **tokens**, not as a `darken()` at the use\nsite. A function hides the resulting colour from the palette, and the palette\nis where the contrast question gets settled once instead of per component.\n\n## The coverage contract\n\n### Covered in v1\n\n- opaque colours in hexadecimal or `rgb()`/`rgba()`;\n- inherited `color`;\n- `background-color`, local or seen through transparent ancestors;\n- CraftTS variables declared with `cssVars()`, their initial values, their\n conditional writes and their `var()` fallbacks;\n- constant classes from `craftStyles()`;\n- light and dark themes;\n- every finite state and size axis, `interaction.hover` included;\n- static and dynamic text, wherever the element can be proven to hold text;\n- a component evaluated once per surface it is rendered on.\n\n### Not covered in v1\n\nEach of these produces `indeterminate` with its reason — never a pass.\n\n- images and gradients behind text;\n- `canvas`, text inside SVG, generated pseudo-element content;\n- `filter`, `backdrop-filter`, `mix-blend-mode`, and any `opacity` below 1;\n- semi-transparent colours, which would need compositing;\n- CSS expressions the DSL does not model;\n- colours computed from runtime data that is not a finite set;\n- external stylesheets and inline styles outside CraftTS;\n- CJK metrics and unusual font geometry — the size in CSS pixels is not the\n size on screen, and the large-text convention assumes latin faces.\n\n### What `indeterminate` means, and why it fails by default\n\n| reason | what happened |\n|---|---|\n| `unknown-foreground` | nothing readable sets the text colour |\n| `unknown-background` | nothing in the chain paints an opaque surface |\n| `unknown-font-size` | the size is not a length this can turn into pixels |\n| `unsupported-background` | an image, a gradient, a blend, or an alpha |\n| `dynamic-style` | the class is assembled at runtime |\n| `external-style` | the styles come from outside CraftTS |\n| `incomplete-render-context` | the component is rendered somewhere unanalysed |\n\n**Indeterminate results fail the run.** `--allow-indeterminate` downgrades them\nto warnings, and you have to type it. A check whose default treats \"I could not\ntell\" as \"fine\" reports a clean bill on the half of the application it\nunderstood — and that half is exactly where the gradients and the runtime\ncolours live.\n\nA report with **no violations and open indeterminates is not a proof.** The\nsummary prints all three counts for that reason:\n\n```text\nText contrast: 41 pass, 0 fail, 3 indeterminate (44 checked).\n```\n\nZero checked is not a pass either, and the tool says so: it nearly always means\nthe dump and the program describe different applications.\n\n## Fixing a violation\n\n1. **Read the scenario.** If only the hovered or only the dark row fails, the\n fix belongs to that one rule, not to the token everything uses.\n2. **Read the token names.** A pair that fails in several places is a palette\n decision — change the token once, and `usedBy` tells you what moves.\n3. **Change the token, not the call site.** A local override is a colour the\n palette no longer describes, and the next component repeats the bug.\n4. **If the text is genuinely large**, check that the sheet says so. 18px bold\n is normal text; 22px bold is large.\n5. **Re-run.** The report is deterministic, so a diff of two JSON runs shows\n exactly what your change moved.\n\n## Clearing an indeterminate\n\nYou have three honest moves, and inventing a colour is not one of them.\n\n- **Bring the surface into the model.** A `background-color` set in raw CSS is\n the common case; `no-unmodelled-text-color` points at it.\n- **Give the text a surface it can be measured against.** Text over a hero\n image has no ratio because it has no single background — put it on a panel,\n or accept that it cannot be proven.\n- **Declare the surface uncovered.** Add its path to the\n `no-unmodelled-text-color` rule's `uncovered` option. The gap is then\n counted as a gap rather than mistaken for a proof, which is the whole point.\n\n## Migrating an existing project\n\n1. Name your palette: `definePalette('ui', spec)`. Nothing else changes.\n2. Turn hand-written `:hover` rules into `when(interaction.hover, …)` and add\n `interaction` to those sheets' budgets. `prefer-hover-axis` finds them.\n3. Add `dumpPath: '.craft/style-graph.json'` to `craftStyle()` in\n `vite.config.ts`.\n4. Replace `style:check` with a build followed by\n `craft-graph --style-contrast`.\n5. Run it with `--allow-indeterminate` **once**, to see the size of the gap.\n6. Close the gaps, or declare them uncovered, and drop the flag. Leaving it on\n permanently is the same as not having the check.\n"
|
|
441
451
|
},
|
|
442
452
|
{
|
|
443
453
|
"path": "/guide/style/define",
|
|
@@ -454,6 +464,11 @@
|
|
|
454
464
|
"title": "Activating `@craft-ts/style`",
|
|
455
465
|
"body": "# Activating `@craft-ts/style`\n\nThe typed style system is not a runtime library you import and call. It is a\n**build step**: a Vite plugin evaluates every `*.style.ts` in Node, deduplicates\nwhat they registered, and emits one stylesheet. Without that plugin the\nvocabulary still typechecks and still compiles — and the page renders with no\nCSS at all.\n\nThis page is the one to follow before the other four.\n\n## Install\n\n```bash\nnpm install @craft-ts/style\nnpm install --save-dev @craft-ts/style-testing\n```\n\n`@craft-ts/style` carries the vocabulary — tokens, kinds, typed custom\nproperties, axes, sheets, obligations. `@craft-ts/style-testing` carries the\nscenario matrix and the drivers that reach each of its points; it never ships to\nthe browser, so it belongs in `devDependencies`.\n\n`@craft-ts/style` declares `@craft-ts/core` as a peer dependency, and\n`@craft-ts/style-testing` declares `@craft-ts/style`. Both are `sideEffects:\nfalse`.\n\n## Wire the plugin\n\n\n\n`craftStyle` takes four options, all optional:\n\n| option | default | what it decides |\n| ---------- | ------------------------------------------------ | -------------------------------------------------------- |\n| `suffix` | `'.style.ts'` | the filename suffix that marks a module as a sheet |\n| `ignore` | `['node_modules', 'dist', '.git', '.nx', 'tmp']` | directory names the walk never descends into |\n| `dumpPath` | none — no dump is written | where to write the graph dump |\n| `alias` | none | module aliases for the **Node** evaluation of the sheets |\n\n`alias` exists because the sheets are evaluated by a real bundler in a separate\npass, before your app's own resolution applies. In a published project, Node\nresolution finds `@craft-ts/style` on its own and you can leave `alias` out. In\nthis monorepo the demo passes the workspace source paths — see\n[`apps/demo/vite.config.ts`](https://github.com/craft-ts/craft-ts/blob/main/apps/demo/vite.config.ts),\nwhich is the working reference for everything on this page.\n\nThen import the emitted sheet once, at the app entry:\n\n```ts\nimport 'virtual:craft-style.css';\n```\n\nIf your `tsconfig` does not already know that id, declare it next to your other\nambient types:\n\n```ts\ndeclare module 'virtual:craft-style.css';\n```\n\n## What the plugin produces\n\nTwo artefacts, from one evaluation.\n\n**The CSS.** Every `when(...)` and `set(...)` the sheets registered, rendered as\natomic rules and `@property` registrations, deduplicated across files, and\nserved under `virtual:craft-style.css`. This is the whole stylesheet: no class\nis ever assembled in the browser, so what the browser gets is exactly what the\nemitter proved.\n\n**The dump**, when `dumpPath` is set. A JSON picture of the registry — classes,\natoms, and typed variables — written on _every_ emission, so it can never\ndescribe a sheet older than the CSS that was served alongside it. The dump is\nthe style half of the dependency graph: it is what\n[`style_impact`, `style_matrix` and `style_debt`](./testing.md#what-the-graph-adds)\nread, and what the `@craft-ts/dev-tools` style queries read.\n\nThe plugin re-derives the whole sheet when a `*.style.ts` changes rather than\npatching it. Atomic output is small and the emission is one bundle away; an\nincremental path here would be a second source of truth about what the CSS says.\n\n## What breaks without it\n\n| missing | symptom |\n| ---------------------------------- | ---------------------------------------------------------------------------- |\n| the plugin | no CSS at all — the classes exist as strings, nothing ever wrote their rules |\n| `import 'virtual:craft-style.css'` | same, and less obviously: the plugin runs but nothing pulls its output |\n| `dumpPath` | `style_matrix` and the other graph queries have nothing to read and say so |\n\nThe MCP server names the fix in its own error message: it points you back at\n`craftStyle({ dumpPath })`. If you are reading this because you saw that\nmessage, `dumpPath` is the line you are missing.\n\n## Emitting without a Vite server\n\n`@craft-ts/style/vite` exports the emitter itself, so a test or a script can get\nthe same two artefacts without standing up a dev server:\n\n\n\n`vite` is a peer of that entry point, not a dependency: a project that builds\nwith something else can still call `emitStyles` without pulling Vite's types\ninto its own program.\n\n## Next\n\n- [Define your design system](./define.md) — palette, axes, theme: where `bp`,\n `scheme` and `palette` come from.\n- [Tokens and typed variables](./tokens.md) — level 1.\n- [Axes and the visual matrix](./variants.md) — level 2.\n"
|
|
456
466
|
},
|
|
467
|
+
{
|
|
468
|
+
"path": "/guide/style/template-obligations",
|
|
469
|
+
"title": "Template obligations",
|
|
470
|
+
"body": "# Template obligations\n\nTests and visual captures describe things that were observed. A component\ntemplate describes something earlier: what the component promises to display\nand what actions it exposes. CraftTS can derive those promises directly from\nthe dependency graph and record a human judgement about each one.\n\n```sh\ncraft-ts attest status \\\n --kind template \\\n --tsconfig apps/demo/tsconfig.graph.json\n```\n\nNo test or browser report is required. The command reads each\n`craftComponent` template and derives two kinds of obligation.\n\n## Render and command\n\nA **render** obligation starts at a reactive binding in the template and follows\nthe graph to the state, computed value, query, or property that produces it.\n\n```ts\n({ total, user }) => div([ifNode(user.isAdmin, () => strong(total))]);\n```\n\nThis template promises to render `user.isAdmin` and `total`. Two occurrences of\nthe same target are one promise, not two.\n\nA **command** obligation starts at a handler on an interactive element and\nfollows the call chain it triggers.\n\n```ts\n({ users }) => button('remove', { click: () => users.remove(id) }, 'Remove');\n```\n\nThis template promises that the named button invokes `users.remove`. The\nelement tag and its literal name are part of the promise, so moving the action\nto a different control asks for a new judgement.\n\nComputed or dynamic accesses that cannot be addressed are printed as\n`template-obligation-unresolved` diagnostics. They are known extraction gaps;\nthey are never silently treated as if the template made no promise.\n\nThe derived obligation keeps two presentation forms. Its `statement` is a\ncanonical English sentence and remains available in the API, CLI and agency\nhandoffs. The review application uses the accompanying structured statement\nparts to render that same promise in the selected language. Neither form is\npart of the attested evidence hash, so wording changes do not invalidate a\ndecision.\n\n## What `renewed` means\n\nEvery obligation has two independent keys:\n\n| key | meaning |\n| ---------------- | ------------------------------------------------------------------------------------- |\n| code fingerprint | the transitive code slice behind the bound or invoked target |\n| evidence | the canonical shape of the promise: direction, element, name, target, and target kind |\n\nWhen implementation code changes but the template still promises the same\nthing, the state is `renewed`. The previous judgement carries forward without\nasking a person to review it again. When the template binds or invokes a\ndifferent target, the evidence changes and the state is `review`.\n\nAn attested obligation is **not a passing test**. It says that a person confirmed\nthe promise was intentional. It does not prove that the implementation fulfils\nthat promise at runtime.\n\n## Removing a promise is a decision\n\nIf an attested template obligation disappears, `status --kind template` exits\nwith a failure until the removal is signed. The ledger line is retained and\nmarked with why the promise went away:\n\n```sh\ncraft-ts attest retire \\\n --kind template \\\n --subject 'template:component:src/card.ts:Card#command:property:src/card.ts:save' \\\n --reason superseded \\\n --note 'Saving is automatic now.'\n```\n\nThe reasons are:\n\n- `superseded`: the product now fulfils the need another way;\n- `defect`: the former promise was wrong;\n- `derivation`: the extractor produced an obligation it should not have. Track\n this count as a quality signal for the extractor.\n\nThe note is mandatory because absence is otherwise indistinguishable from an\naccidental deletion. If a retired obligation later reappears, it returns to the\nreview queue; the next human verdict clears the retirement.\n\n## Measured rename noise\n\nThe target identity remains part of the evidence because the implementation\nmeasurement stayed inside its review budget. Replaying the latest 20 commits\nthat touched `apps/demo` produced a median of **0 actionable obligation changes\nper commit** (one commit removed four obligations, one added three, and the\nother eighteen changed none), with **0 changes attributable to a pure target\nrename**. This is below the threshold of three, so mass renames can continue to\nbe handled by review clustering without weakening the promise recorded in the\nevidence.\n"
|
|
471
|
+
},
|
|
457
472
|
{
|
|
458
473
|
"path": "/guide/style/testing",
|
|
459
474
|
"title": "Testing what you built",
|
|
@@ -467,12 +482,12 @@
|
|
|
467
482
|
{
|
|
468
483
|
"path": "/guide/style/variants",
|
|
469
484
|
"title": "Axes and the visual matrix",
|
|
470
|
-
"body": "# Axes and the visual matrix\n\nLevel 2. Adopted per component, and it is what turns \"I think that is all the\nstates\" into a list.\n\n## A variant is an axis, not a class name\n\n```ts\nimport {\n bg,\n craftStyles,\n defineStateAxis,\n palette,\n set,\n when,\n} from '@craft-ts/style';\n// `v` is your own sheet's typed variables, `bp` your own breakpoints — see\n// [Defining a design system](./define.md).\nimport { bp, v } from './foundation.style';\n\nexport const tone = defineStateAxis('tone', ['neutral', 'danger']);\n\nexport const badge = craftStyles('badge', {\n root: [bg(v.bg), when(tone.danger, [set(v.bg, palette.accent.danger)])],\n});\n```\n\nThe template sets **one static class** and a `data-tone` attribute. Nothing\nconcatenates a class at render time, which is what makes the set of states\nenumerable. The `no-raw-class` rule enforces it in files that use the package.\n\nConjunction is nesting, and only nesting:\n\n```ts\nimport { fontWeight, scheme, when } from '@craft-ts/style';\n\nwhen(scheme.dark, [when(bp.md, [fontWeight.bold])]);\n```\n\nOne way to write each thing, so two identical components cannot produce two\ndifferent contracts.\n\n## Only the points you actually cross\n\n`bp` may define `sm`, `md` and `lg`; a component that cuts at `md` contributes\n**two** cells, not four. The contract records what the sheet uses, never what the\naxis offers.\n\nAn interval nothing can satisfy — `above(bp.lg)` containing `below(bp.sm)` —\nthrows when the sheet is registered, which under the build plugin is a build\nfailure.\n\n## The budget\n\n```ts\nimport { craftStyles } from '@craft-ts/style';\n\ncraftStyles('button', { root: [...] }, { axes: [tone, size] })\n```\n\nAn axis outside the budget is a compile error naming it. Without this, an axis\nadded deep in a leaf shows up as a doubled capture bill three levels up and\nnobody decided that. A declared axis that goes unused is reported, not rejected.\n\n## The matrix\n\n```ts\nimport { visualMatrix, branch } from '@craft-ts/style-testing';\n\nvisualMatrix(card);\n// [{ id: 'base', … }, { id: 'viewport=md', … }]\n```\n\nIt takes **sheets**, not a component: a component's classes are only knowable by\nrendering it, and a matrix that silently missed a child's sheet would be the\nworst possible outcome.\n\nIdentifiers name only the axes away from `base`, so adding an axis elsewhere in\nthe app does not invalidate every baseline in the suite.\n\nTwo reductions are applied, and both are exactly true rather than probably true:\n\n- **A branch adds, it does not multiply.** The two sides of an `ifNode` are\n never on screen together, so declare it — `branch('footer', footerSheet)` —\n and the absent side stops carrying the footer's axes.\n- **A container axis stops at its owner.** An ancestor cannot change how wide\n that box is, so only the component naming the container keeps the axis.\n\nNothing else is reduced. A coverage that claims to be complete without being\ncomplete is worse than no coverage.\n"
|
|
485
|
+
"body": "# Axes and the visual matrix\n\nLevel 2. Adopted per component, and it is what turns \"I think that is all the\nstates\" into a list.\n\n## A variant is an axis, not a class name\n\n```ts\nimport {\n bg,\n craftStyles,\n defineStateAxis,\n palette,\n set,\n when,\n} from '@craft-ts/style';\n// `v` is your own sheet's typed variables, `bp` your own breakpoints — see\n// [Defining a design system](./define.md).\nimport { bp, v } from './foundation.style';\n\nexport const tone = defineStateAxis('tone', ['neutral', 'danger']);\n\nexport const badge = craftStyles('badge', {\n root: [bg(v.bg), when(tone.danger, [set(v.bg, palette.accent.danger)])],\n});\n```\n\nThe template sets **one static class** and a `data-tone` attribute. Nothing\nconcatenates a class at render time, which is what makes the set of states\nenumerable. The `no-raw-class` rule enforces it in files that use the package.\n\nConjunction is nesting, and only nesting:\n\n```ts\nimport { fontWeight, scheme, when } from '@craft-ts/style';\n\nwhen(scheme.dark, [when(bp.md, [fontWeight.bold])]);\n```\n\nOne way to write each thing, so two identical components cannot produce two\ndifferent contracts.\n\n## Only the points you actually cross\n\n`bp` may define `sm`, `md` and `lg`; a component that cuts at `md` contributes\n**two** cells, not four. The contract records what the sheet uses, never what the\naxis offers.\n\nAn interval nothing can satisfy — `above(bp.lg)` containing `below(bp.sm)` —\nthrows when the sheet is registered, which under the build plugin is a build\nfailure.\n\n## Hover, and every pseudo-class after it\n\n```ts\nwhen(interaction.hover, [set(buttonVars.bg, ui.accent.warningHover)]);\n```\n\n`interaction.hover` emits the same `&:hover` rule you would write by hand.\nWhat it adds is that the point enters the class's contract — so the matrix\nenumerates the hovered state, and the\n[static contrast check](./contrast.md) crosses the colours it writes with the\ntext on top of them. A `:hover` typed into a string emits identical CSS and is\ninvisible to both, which is how a button ends up readable at rest and\nunreadable under the pointer. `prefer-hover-axis` refuses it.\n\nIt is a real axis with a real price: it doubles the sheet's matrix, so it has\nto be in the budget below. Its driver is `{ kind: 'selfState', state: 'hover' }`,\nand `applyScenario` honours it by asking the page to move a pointer — a\ndispatched `mouseover` sets no pseudo-class and would capture the base state\nwhile looking correct.\n\n## The budget\n\n```ts\nimport { craftStyles } from '@craft-ts/style';\n\ncraftStyles('button', { root: [...] }, { axes: [tone, size, interaction] })\n```\n\nAn axis outside the budget is a compile error naming it. Without this, an axis\nadded deep in a leaf shows up as a doubled capture bill three levels up and\nnobody decided that. A declared axis that goes unused is reported, not rejected.\n\n## The matrix\n\n```ts\nimport { visualMatrix, branch } from '@craft-ts/style-testing';\n\nvisualMatrix(card);\n// [{ id: 'base', … }, { id: 'viewport=md', … }]\n```\n\nIt takes **sheets**, not a component: a component's classes are only knowable by\nrendering it, and a matrix that silently missed a child's sheet would be the\nworst possible outcome.\n\nIdentifiers name only the axes away from `base`, so adding an axis elsewhere in\nthe app does not invalidate every baseline in the suite.\n\nTwo reductions are applied, and both are exactly true rather than probably true:\n\n- **A branch adds, it does not multiply.** The two sides of an `ifNode` are\n never on screen together, so declare it — `branch('footer', footerSheet)` —\n and the absent side stops carrying the footer's axes.\n- **A container axis stops at its owner.** An ancestor cannot change how wide\n that box is, so only the component naming the container keeps the axis.\n\nNothing else is reduced. A coverage that claims to be complete without being\ncomplete is worse than no coverage.\n"
|
|
471
486
|
},
|
|
472
487
|
{
|
|
473
488
|
"path": "/guide/testing/architecture",
|
|
474
489
|
"title": "Architecture rules",
|
|
475
|
-
"body": "# Architecture rules\n\nArchitecture tests answer one question:\n\n> **Is the dependency shape of the app still allowed?**\n\nThey read the static Craft graph — routes, services, components, primitives and\ntheir edges — without starting the application. That makes them useful for\nrules that are about relationships, ownership or declarations rather than\nruntime behaviour.\n\n## Choose the right kind of test\n\n| If you want to verify… | Use… | Example |\n| --- | --- | --- |\n| one unit computes the right result | [service tests](/guide/testing/services) | a service returns the expected value |\n| one component renders and reacts correctly | [component tests](/guide/testing/components) | a button disables after a click |\n| two parts of the app are allowed to depend on each other | architecture tests | `checkout` must not depend on `admin` |\n| a complete user journey works in a browser | `e2e/` tests | a user can create and then see a task |\n\nUse an architecture rule when the requirement sounds like one of these:\n\n- **must not depend on** — a feature must not reach into another feature;\n- **must be owned once** — an HTTP endpoint or persisted identity has one owner;\n- **must declare a relationship** — a mutation must refresh a query;\n- **must remain pure** — reading a computed value must not perform work.\n\nA green architecture suite does not prove that a button works. It proves that\nthe app still respects the boundaries that make that button maintainable.\n\n::: tip Start with the graph-wide baseline\nAdd `assertDeclarativeArchitecture(graph.graph)` first. It checks the core\ninvariants that are easiest to break during a refactor: unique identities,\nunique HTTP ownership, pure `craftComputed` values, no dependency cycles and\ndeclared mutation reactions. Add focused rules when your application has an\nadditional boundary, such as route DI, folder ownership or URL-backed resource\nparams.\n:::\n\n## What a rule looks like\n\nA rule is an ordinary Vitest assertion. Look up a node, inspect its graph\nrelationships or call a built-in assertion, then let CI protect the invariant:\n\n```typescript\nit('keeps checkout away from admin internals', () => {\n noExclusiveLink(graph.route('/checkout'), graph.route('/admin'));\n});\n```\n\nThe rest of this page explains the graph, the setup and the built-in rules.\n\n## Import\n\n```typescript\nimport {\n analyzeDependencyGraph,\n architectureCatalogToTypeScript,\n assertCraftComputedPure,\n assertCraftEffectNoImperativeSync,\n assertCraftEffectNoNetwork,\n assertCraftUnique,\n assertDeclarativeArchitecture,\n assertHttpEndpointUnique,\n assertInsertSelectUnique,\n assertInteractiveElementNamed,\n assertMutationHasReactOn,\n assertNoDependencyCycles,\n assertPathBoundaries,\n assertPrimitiveLoaderRequirements,\n assertQueryMutationHasServerState,\n assertResourceParamsPreferQueryParams,\n assertPersistedPrimitiveHasUnique,\n assertRouteComponentsInSeparateFiles,\n assertRouteDiProofs,\n buildArchitectureCatalog,\n createArchitectureGraph,\n noExclusiveLink,\n} from '@craft-ts/dev-tools';\n```\n\n## Mental model\n\n`analyzeDependencyGraph` reads the application sources with the TypeScript\nprogram — routes, services, components, HTTP calls, `craftUnique` identities,\nroute DI proofs (`CanRun`, `RouteCheckedDI`) —\nand builds a graph of nodes and edges.\n\n`createArchitectureGraph` wraps that graph with typed lookups. Names come from\na generated **catalog** (`as const`): autocomplete, and a type error when a\nrenamed symbol disappears.\n\nA rule is then a Vitest assertion on those lookups. The suite lives next to\n`e2e/`, in an `architecture/` folder, and runs in Node — no `TestBed`, no\nbrowser.\n\nESLint already forbids local slips (`inject`, raw `HttpClient`) and can generate\nthe route proof blocks. Architecture tests catch **graph-wide** slips those\nrules cannot see: a feature leaking into another, an endpoint called from two\nAPIs, a duplicate storage key, a route or `app.config` error screen whose DI\nproof was never armed. See [ESLint rules](/guide/routing/eslint-rules).\n\n## Setting it up\n\nThe demo app is the working reference: `apps/demo/architecture/`, run with\n`npx nx architecture demo`. Commands are listed in `apps/demo/README.md`.\nCopy that layout, or scaffold it with the migrator (Vitest, Node):\n\n```shell\nnpx craft-migrate-architecture \\\n --project tsconfig.app.json \\\n --root src \\\n --write\n```\n\nThat writes `tsconfig.graph.json`, `tsconfig.architecture.json`,\n`vitest.architecture.config.ts`, the `architecture/` suite (loader, catalog,\nbaseline rules, and an `architecture.spec.ts`), an\nNx `architecture` target or a `package.json` script, and ignores the generated\ncatalog in the nearest flat ESLint config. `--write` overwrites the scaffold.\n`--check` fails when the suite is missing or the generated tooling files\ndrifted. `craft-migrate --write` runs this as its last step.\n\nKeep the rules and app-specific lookups in one `architecture.spec.ts` file when\nthe graph is expensive to analyze. `loadArchitectureGraph()` caches only within\none Vitest worker; separate spec files rebuild the TypeScript graph separately.\nThe three demo apps use this single-file layout, which performs one graph\nanalysis per app run.\n\n### 1. Analysis tsconfig\n\nPoint analysis at **every application source file**. `tsconfig.app.json` often\nlists only `main.ts`; the graph would then miss routes, services and components.\n\n```json\n{\n \"extends\": \"./tsconfig.json\",\n \"compilerOptions\": {\n \"skipLibCheck\": true\n },\n \"include\": [\"src/**/*.ts\"],\n \"exclude\": [\"src/**/*.spec.ts\", \"src/**/*.test.ts\"]\n}\n```\n\n### 2. Suite tsconfig\n\nA second project compiles only the architecture folder, with Node and Vitest\ntypes:\n\n```json\n{\n \"extends\": \"./tsconfig.json\",\n \"compilerOptions\": {\n \"types\": [\"node\", \"vitest/globals\"],\n \"module\": \"esnext\",\n \"moduleResolution\": \"bundler\"\n },\n \"include\": [\"architecture/**/*.ts\"]\n}\n```\n\nReference it from the app `tsconfig.json` `references` array so the IDE\ntypechecks the suite.\n\n### 3. Vitest, at the app root\n\nKeep the config next to `project.json` — **not** inside `architecture/`. A nested\n`vitest.config.ts` is picked up by the Nx Vitest plugin and breaks the app's\nunit-test target.\n\n```typescript\n/// <reference types=\"vitest\" />\nimport { defineConfig } from 'vite';\n\nexport default defineConfig(() => ({\n root: import.meta.dirname,\n cacheDir: '../../node_modules/.vite/apps/demo-architecture',\n plugins: [],\n resolve: {\n tsconfigPaths: true,\n },\n test: {\n name: 'demo-architecture',\n watch: false,\n globals: true,\n environment: 'node',\n testTimeout: 180_000,\n hookTimeout: 180_000,\n include: ['architecture/**/*.spec.ts'],\n },\n}));\n```\n\nAnalysis of a real app takes seconds, not milliseconds. Size the timeouts\naccordingly; `beforeAll` uses `hookTimeout`.\n\n### 4. Load the graph, rewrite the catalog\n\n```typescript\nimport { writeFileSync } from 'node:fs';\nimport { join, resolve } from 'node:path';\nimport {\n analyzeDependencyGraph,\n architectureCatalogToTypeScript,\n buildArchitectureCatalog,\n createArchitectureGraph,\n} from '@craft-ts/dev-tools';\nimport { architectureCatalog } from './catalog';\n\nconst workspaceRoot = resolve(import.meta.dirname, '../../..');\nconst catalogPath = join(import.meta.dirname, 'catalog.ts');\n\nexport function loadArchitectureGraph() {\n const graph = analyzeDependencyGraph({\n rootDir: workspaceRoot,\n tsConfigFilePath: 'apps/your-app/tsconfig.graph.json',\n });\n writeFileSync(\n catalogPath,\n `// Generated. Do not edit.\\n${architectureCatalogToTypeScript(buildArchitectureCatalog(graph))}`,\n );\n return createArchitectureGraph(graph, architectureCatalog);\n}\n```\n\nThe imported catalog is what TypeScript autocompletes against. The rewrite\nkeeps it in sync with the sources: after a rename, the next typecheck of the\nsuite fails until the lookups are updated.\n\nIgnore the generated catalog in ESLint. Commit it so the first clone\ntypechecks.\n\nBootstrap with `npx craft-graph --project apps/your-app/tsconfig.graph.json --root . --out apps/your-app/architecture/catalog --format json`.\nRename the generated `catalog.architecture.ts` to `catalog.ts`. After that,\nloading the graph keeps it current.\n\n### 5. Nx target\n\n```json\n{\n \"architecture\": {\n \"executor\": \"nx:run-commands\",\n \"options\": {\n \"command\": \"npx vitest run --config vitest.architecture.config.ts\",\n \"cwd\": \"apps/your-app\"\n },\n \"inputs\": [\n \"{projectRoot}/src/**/*.ts\",\n \"{projectRoot}/architecture/**/*.ts\",\n \"{projectRoot}/tsconfig.graph.json\"\n ],\n \"cache\": true\n }\n}\n```\n\n```shell\nnpx nx architecture your-app\n```\n\n## Looking up nodes\n\nPass the catalog into `createArchitectureGraph` and names become unions.\nA missing name throws `Unknown service '…'`. Two nodes sharing a name throw\nuntil you pass a relative file path.\n\n```typescript\ngraph.route('craft/query/:userId');\ngraph.service('UsersApiOnError');\ngraph.service('ApiService', 'users/api.service.ts'); // homonym\ngraph.component('ListWithPagination');\ngraph.providedOn('UserList');\ngraph.httpEndpoint('GET', 'users');\ngraph.unique('{\"key\":\"user-query\",\"storeName\":\"demo-app\"}');\ngraph.services({ browserBoundary: true, providedIn: 'global' });\ngraph.usingHttp();\ngraph.dependingOnBrowserBoundary();\ngraph.craftMethods();\n```\n\n| Lookup | Returns |\n| ------------------------------ | ---------------------------------------------------- |\n| `route(path, file?)` | one route node |\n| `service(name, file?)` | one service node |\n| `component(name, file?)` | one component node |\n| `providedOn(name)` | every node that `provides` that service |\n| `httpEndpoint(method, url)` | one HTTP endpoint |\n| `unique(canonicalJson)` | one `craftUnique` identity |\n| `services({ browserBoundary, scope })` | filtered services |\n| `usingHttp()` | nodes that call `CraftHttpClient` |\n| `dependingOnBrowserBoundary()` | nodes that depend on a `browserBoundary` service |\n| `uniques()` / `httpEndpoints()` / `craftMethods()` | all nodes of that kind |\n\nEach node exposes `providers()`, `provider(name)`, `outgoing(kind?)`,\n`incoming(kind?)` and `httpEndpoints()`. Edge kinds include `depends-on`,\n`provides`, `calls`, `loads`, `renders`, `reads`, `writes`, `checks`,\n`triggers`.\n\n`unique(...)` takes the **canonical JSON** of the identity object: keys sorted\nin depth. `{ storeName, key }` and `{ key, storeName }` index as the same\nstring.\n\nFor adding a TypeScript backend with its own typed nodes and relations, see\n[Extensible architecture graph](/guide/testing/extensible-architecture-graph).\n\n## Built-in helpers\n\nThe declarative baseline is the aggregate set of graph-wide checks below.\nImport them all, then either call each one or\n`assertDeclarativeArchitecture` for the aggregate checks together.\nThe demo suite keeps all checks in `apps/demo/architecture/architecture.spec.ts`\nso the graph is loaded once. Run it with `npx nx architecture demo`.\n\nEach rule has a focused page with the invariant it protects, the failure it\nprevents and the smallest useful test. Start with the [declarative\nbaseline](/guide/testing/architecture/declarative-baseline), then add the\nrules that express your application's boundaries.\n\n| Helper | Fails when |\n| --- | --- |\n| [`assertCraftUnique`](/guide/testing/architecture/unique-identities) | the same `craftUnique` identity appears twice, or the argument is not a static literal |\n| [`assertHttpEndpointUnique`](/guide/testing/architecture/http-endpoint-ownership) | the same HTTP verb+URL is called from more than one site |\n| [`assertCraftComputedPure`](/guide/testing/architecture/computed-purity) | a `craftComputed` `calls` a method or `writes` a `source$` |\n| [`assertPrimitiveMethodsUsedOnce`](/guide/testing/architecture/primitive-method-usage) | an exposed primitive insertion method is used from more than one call site |\n| [`assertNoUnusedPrimitiveMethods`](/guide/testing/architecture/unused-primitive-method) | an exposed primitive insertion method has no call site anywhere in the project |\n| [`assertNoDependencyCycles`](/guide/testing/architecture/dependency-cycles) | a directed cycle exists on `depends-on` (services, components, computeds) |\n| [`assertMutationHasReactOn`](/guide/testing/architecture/mutation-reactions) | a `mutation` has no query `insertReactOnMutation` edge (`allow` skips named fire-and-forget mutations) |\n| [`assertDeclarativeArchitecture`](/guide/testing/architecture/declarative-baseline) | any of the baseline checks fail |\n| [`assertRouteDiProofs`](/guide/testing/architecture/route-di-proofs) | a routed component, pending UI or error screen has no armed `CanRun` mapper, a collection is missing `assertExhaustiveRouteExceptions`, or `app.config.ts` registers a global / route-load error screen without its `RouteExceptionComponentCheckedDI` |\n| [`assertRouteComponentsInSeparateFiles`](/guide/testing/architecture/route-component-files) | a route loads its page component from the routing file, or multiple routed page components share one component file |\n| [`assertPathBoundaries`](/guide/testing/architecture/path-boundaries) | a `depends-on` (or opted-in `calls`) crosses a folder allowlist / denylist |\n| [`noExclusiveLink(a, b)`](/guide/testing/architecture/exclusive-links) | the only path between two branches is a leak, not a shared kernel |\n| [`assertPersistedPrimitiveHasUnique`](/guide/testing/architecture/persisted-identities) | `insertStoragePersister` is used without wrapping the identity in `craftUnique` |\n| [`assertInsertSelectUnique`](/guide/testing/architecture/insert-select-keys) | the same `insertSelect` key appears twice on one host primitive |\n| [`assertCraftEffectNoNetwork`](/guide/testing/architecture/craft-effect-network) | a `craftEffect` `calls` HTTP or a `mutation` |\n| [`assertCraftEffectNoImperativeSync`](/guide/testing/architecture/craft-effect-imperative-sync) | a `craftEffect` writes a `state` / `source$` or triggers a `query` / `mutation` / `asyncProcess` |\n| [`assertInteractiveElementNamed`](/guide/testing/architecture/interactive-element-names) | an interactive element lacks a literal name or duplicates a `data-craft-name` |\n| [`assertQueryMutationHasServerState`](/guide/testing/architecture/server-state-loader) | a `query` or `mutation` does not reach an allowed server-state boundary |\n| [`assertPrimitiveLoaderRequirements`](/guide/testing/architecture/primitive-loader-requirements) | an Effect-aware primitive does not declare an allowed dependency boundary |\n| [`assertResourceParamsPreferQueryParams`](/guide/testing/architecture/resource-params-query-state) | a `query` or `asyncProcess` params graph depends on a `state` instead of URL-backed `queryParams` |\n\n### `noExclusiveLink`\n\nForbids edges that exist only because two branches touch each other. A shared\nkernel — auth, HTTP client, browser boundaries — is allowed. Membership stops\nat other `provides` sites, so a leak into a third feature is not reclassified\nas shared.\n\n```typescript\nit('keeps exclusive feature branches from linking', () => {\n const [userList] = graph.providedOn('UserList');\n const [userMutation] = graph.providedOn('UserMutation');\n expect(userList).toBeDefined();\n expect(userMutation).toBeDefined();\n noExclusiveLink(userList, userMutation);\n});\n```\n\nThe same helper works on routes: `noExclusiveLink(graph.route('/admin'), graph.route('/checkout'))`.\n\n### `assertPathBoundaries`\n\nNx `depConstraints` tag **projects** and forbid TypeScript imports. This helper\ntags **folders** on the Craft graph and forbids `depends-on` (optionally\n`calls`) between them — including inside one app, where module-boundary ESLint\ndoes not run. Same intention, different altitude: [Craft graph vs\nNx](/guide/testing/craft-graph-vs-nx).\n\nPaths are relative to `graph.rootDir`. `*` is one segment, `**` is any depth,\n`:name` captures a segment. The same capture in `source` and `onlyDependOn` /\n`forbidTarget` must match, so a feature can depend on itself but not on\nsiblings.\n\n`onlyDependOn` is an allowlist; `forbidTarget` is a denylist. When both are\nset, the target must match the allowlist **and** miss the denylist. Nodes whose\npath matches no `source` are unconstrained. Edges without a `filePath` on\neither end, and structural edges (`provides`, `loads`, `renders`, `contains`),\nare ignored.\n\n```typescript\nit('keeps features and UI in their folders', () => {\n assertPathBoundaries(graph.graph, {\n constraints: [\n {\n source: 'src/app/features/:feature/**',\n onlyDependOn: [\n 'src/app/features/:feature/**',\n 'src/app/shared/**',\n 'src/app/ui/**',\n ],\n },\n {\n source: 'src/app/ui/**',\n onlyDependOn: ['src/app/ui/**', 'src/app/shared/**'],\n forbidTarget: ['src/app/data/**'],\n },\n ],\n });\n});\n```\n\nSibling features are an allowlist job (`onlyDependOn` includes\n`features/:feature/**`). A denylist `features/**` would also forbid self.\n\n### `assertCraftUnique`\n\nEach `craftUnique(...)` identity must appear once, and the argument must be a\nstatic literal — otherwise the graph cannot tell two call sites apart. Used\nwith [persistence](/guide/state/persistence) so two queries cannot silently\nshare a storage key.\n\n```typescript\nit('requires craftUnique identities to appear once', () => {\n assertCraftUnique(graph.graph);\n});\n```\n\nA duplicate or a non-literal argument fails the test with the file:line of\neach call site.\n\n### `assertHttpEndpointUnique`\n\nA `GET users` node is one verb + one URL. Two call sites — two services, or\nthe same service twice — fail the test. Distinct pairs (`GET users` and\n`POST users`, or `GET orders`) are allowed.\n\n```typescript\nit('owns each HTTP endpoint once', () => {\n assertHttpEndpointUnique(graph.graph);\n});\n```\n\nThis is the graph-wide counterpart of `craftUnique`. Wrapping `CraftHttpClient`\nin `craftUnique` is not required: the identity is the verb+URL.\n\n### `assertCraftComputedPure`\n\nA `craftComputed` may only **read**. Outgoing `calls` (a `craftMethod`,\n`increment`, `mutate`, …) and `writes` (`source$.emit` / `.set`) fail.\n\nLocal slips are also caught by ESLint\n`craft-ts/no-craft-computed-side-effects`. The graph catches a computed that\ncalls a method declared in another binding.\n\n```typescript\nit('keeps craftComputed free of methods and source$ writes', () => {\n assertCraftComputedPure(graph.graph);\n});\n```\n\n### `assertNoDependencyCycles`\n\nDirected cycles on `depends-on` only: service A → B → A, two `craftComputed`\nthat yield each other, a self-`yield*`. `provides`, `contains`, `loads` and\n`renders` are structure, not a cycle of use. A shared kernel (Left → Auth,\nRight → Auth) is not a cycle.\n\n```typescript\nit('forbids depends-on cycles', () => {\n assertNoDependencyCycles(graph.graph);\n});\n```\n\n### `assertDeclarativeArchitecture`\n\nRuns the aggregate checks above and joins their messages. Pass `{ allow }`\nthrough to `assertMutationHasReactOn` for fire-and-forget mutations.\n\n```typescript\nit('keeps the app declarative', () => {\n assertDeclarativeArchitecture(graph.graph, { allow: ['logout'] });\n});\n```\n\n### `assertRouteDiProofs`\n\nThe routing DI contract is type-level by design. `CanRun`, `RouteCheckedDI` and\n`RouteExceptionComponentCheckedDI` are unused aliases unless they stay in the\nfile: comment one out and TypeScript still compiles. That is the one fragile\nstep in an otherwise compile-time guarantee.\n\nThis helper makes that step a test failure. It walks the static graph and\nrequires every routed component — including lazy `loadChildren` collections,\nwhich a parent proof never covers — every pending or error screen, and every\n`craftAppConfig` error surface to be hooked to an armed mapper. A mapper\nwithout `CanRun` is dead: the graph indexes it, then this rule fails.\nTypeScript still judges whether a dependency is provided; the architecture\nsuite judges whether that judgement was invoked.\n\n```typescript\nit('requires a DI proof on every routed component and app-config error screen', () => {\n assertRouteDiProofs(graph.graph);\n});\n```\n\nA missing proof, an unarmed mapper, a pending/error screen without its own\n`RouteCheckedDI`, a collection without `assertExhaustiveRouteExceptions`, or an\n`app.config.ts` that registers `provideCraftGlobalErrorComponent` /\n`provideCraftRouteLoadErrorComponent` (or `withErrorComponent` /\n`withRouteLoadError`) without an armed `RouteExceptionComponentCheckedDI` fails\nwith the file:line of the hole.\n\n### `assertRouteComponentsInSeparateFiles`\n\nRoute definitions describe navigation and loading; page components live in\ntheir own files. This assertion compares the route file with every component\ntarget discovered through `component`, `loadComponent` or a lazy `import()`,\nthen rejects multiple routed page components that share one component file.\n\n```typescript\nit('keeps route definitions separate from page components', () => {\n assertRouteComponentsInSeparateFiles(graph.graph);\n});\n```\n\nThe rule checks the page file boundary only. It does not restrict components\nrendered inside a page, and it does not require one route collection per file.\n\n### `assertMutationHasReactOn`\n\nA mutation that no query reacts to is the graph-wide form of\n[the button that knows which lists to refresh](/guide/state/react-on-mutation).\nThe analyzer records `insertReactOnMutation` as a `triggers` edge from the\nmutation to the query — including when the insertion is nested in\n`insertQueryPipe`. This helper fails on every `mutation` primitive that has no\nsuch edge.\n\nFire-and-forget writes (logout, a form submit with no cache, a demo that\nrefreshes by incrementing local state) pass an `allow` list of mutation names:\n\n```typescript\nit('requires a query to react to each mutation', () => {\n assertMutationHasReactOn(graph.graph, { allow: ['logout'] });\n});\n```\n\n### `assertPersistedPrimitiveHasUnique`\n\n`assertCraftUnique` says an identity appears once. This helper says a persisted\nprimitive *has* an identity: `insertStoragePersister` / `insertLocalStoragePersister`\nmust take `craftUnique(...)`. A raw `{ key, storeName }` indexes the primitive\nas persisted and fails here.\n\n```typescript\nit('requires craftUnique on every persisted primitive', () => {\n assertPersistedPrimitiveHasUnique(graph.graph);\n});\n```\n\nSee [Persistence](/guide/state/persistence).\n\n### `assertInsertSelectUnique`\n\n`insertSelect('cell')` names a slice on its host `state` / `query`. Two\nsiblings with the same key on the same host stomp each other. The same key on\ntwo different hosts is allowed — each list can have a `cell`.\n\n```typescript\nit('keeps insertSelect keys unique on each host', () => {\n assertInsertSelectUnique(graph.graph);\n});\n```\n\nSee [Selecting](/guide/state/select).\n\n### `assertCraftEffectNoNetwork`\n\nA `craftEffect` that `calls` `CraftHttpClient` or a `mutation` is a `query` or\n`mutation` in disguise. Reads of local `state` stay valid.\n\n```typescript\nit('keeps craftEffect off HTTP and mutations', () => {\n assertCraftEffectNoNetwork(graph.graph);\n});\n```\n\n### `assertCraftEffectNoImperativeSync`\n\nA `craftEffect` that writes another `state` or `source$`, or that calls\n`query.call` / `mutation.mutate` / `asyncProcess.method`, is glue that should\nbe a sourced `state` or reactive `params` instead. Logging, focus, and other\nI/O that does not push into a Craft primitive stay valid. ESLint\n`craft-ts/no-imperative-craft-resource-trigger` catches the resource-trigger\nhalf in the editor; this helper is the graph-wide counterpart, including\nstate writes.\n\n```typescript\nit('keeps craftEffect from pushing into other primitives', () => {\n assertCraftEffectNoImperativeSync(graph.graph);\n});\n```\n\n### `assertInteractiveElementNamed`\n\n`button('increment', {}, '+')` stamps `data-craft-name=\"increment\"`. Type-level\nproofs and DOM tests already key off that name. This helper makes the first\nstring **mandatory** on clickable and fillable elements, and **unique in the\napp**: two `button('save')` in two components fail, and so does\n`button({ click() {} }, 'Save')`. ESLint `craft-ts/require-interactive-local-name`\nis the editor counterpart for the missing / non-static cases.\n\n```typescript\nit('requires a unique literal data-craft-name on every interactive element', () => {\n assertInteractiveElementNamed(graph.graph);\n});\n```\n\n## Writing your own rules\n\nStart from a node you care about and assert what should be true of its\nneighbourhood. The demo suite does this for routes and HTTP; the same pattern\ncovers any invariant you can see on the graph.\n\n### A route provides the feature service\n\n```typescript\nit('indexes demo routes and provided feature services', () => {\n expect(graph.route('craft/query/:userId').kind).toBe('route');\n expect(graph.providedOn('UserList').map((node) => node.label)).toEqual(\n expect.arrayContaining([expect.stringMatching(/ListWithPagination/)]),\n );\n});\n```\n\n### An HTTP endpoint has a single owner\n\n```typescript\nit('indexes the users HTTP endpoint', () => {\n expect(graph.httpEndpoint('GET', 'users').label).toBe('GET users');\n expect(graph.usingHttp().map((node) => node.label)).toEqual(\n expect.arrayContaining(['UsersApiOnError']),\n );\n});\n```\n\n### HTTP only from a browser boundary\n\n[Browser boundaries](/guide/testing/browser-boundaries) are the line to the\nnetwork. A rule can require that `CraftHttpClient` is only yielded from a\nservice marked `browserBoundary: true`:\n\n```typescript\nit('only browser-boundary services call HTTP', () => {\n const boundaryIds = new Set(\n graph.services({ browserBoundary: true }).map((node) => node.id),\n );\n const leaked = graph\n .usingHttp()\n .filter((node) => node.kind === 'service' && !boundaryIds.has(node.id));\n expect(leaked.map((node) => node.label)).toEqual([]);\n});\n```\n\n### A persisted identity exists\n\n```typescript\nit('looks up a persisted unique identity', () => {\n expect(\n graph.unique('{\"key\":\"user-query\",\"storeName\":\"demo-app\"}').kind,\n ).toBe('unique');\n});\n```\n\nIf the lookup throws, the identity left the graph — the key changed, or\n`craftUnique` was removed.\n\nAnything you can express with `outgoing` / `incoming` is a rule: “this\n`craftMethod` is either called or writes a `source$`, never both”, “this\ncomponent does not `depends-on` that service”, “only `providedIn: 'global'` services\nappear under `usingTemporal()`”. Keep the assertion next to a comment that\nstates the product invariant, not the graph traversal.\n\n## Inspecting the graph\n\n`npx craft-graph` (also `npx craft graph`) writes the same analysis to disk\nwithout running tests:\n\n```shell\nnpx craft-graph \\\n --project apps/your-app/tsconfig.graph.json \\\n --root . \\\n --out craft-dependency-graph \\\n --format all\n```\n\n| `--format` | Writes |\n| ---------- | ------------------------------------------------------------------- |\n| `json` | the raw graph + a `.architecture.ts` catalog |\n| `mermaid` | a `.mmd` diagram |\n| `html` | a standalone explorer (no server, no runtime) |\n| `both` | JSON + catalog + Mermaid |\n| `all` | JSON + catalog + Mermaid + HTML |\n\n`--include <text>` restricts analysis to matching source paths. Use the HTML\nexplorer to see a route expand into components and services before you write\nthe assertion.\n\n## Pitfalls\n\n**The analysis tsconfig must include the app, not just `main.ts`.** An empty\ngraph with a passing `usingHttp()` is the usual symptom.\n\n**Do not nest `vitest.config.ts` under `architecture/`.** Put\n`vitest.architecture.config.ts` at the app root.\n\n**The catalog lags by one run.** Lookups are typed against the committed file.\nAfter adding a route or service, run the suite once so the rewrite lands, then\nthe new name typechecks.\n\n**Homonyms need a file path.** `graph.service('ApiService')` throws\n`Ambiguous service 'ApiService'` when two files export that name. Pass\n`'users/api.service.ts'`.\n\n**`craftUnique` must be a literal.** A computed `{ storeName, key }` indexes as\n`static: false` and `assertCraftUnique` fails — the graph cannot prove\nuniqueness.\n\n**A commented `CanRun` still type-checks.** Unused aliases are not errors.\n`assertRouteDiProofs` is the CI counterpart — that is the whole point of the\nhelper.\n\n**These tests are not e2e.** They never boot the app. Pair them with\n[service](/guide/testing/services) and [component](/guide/testing/components)\ntests for behaviour, and with ESLint for local architecture.\n\n## See Also\n\n- [Craft graph vs Nx](/guide/testing/craft-graph-vs-nx) — what each graph can\n and cannot see\n- [Testing services](/guide/testing/services) — the runtime graph of one service\n- [Browser boundaries](/guide/testing/browser-boundaries) — the nodes\n `browserBoundary: true` refers to\n- [Persistence](/guide/state/persistence) — why `craftUnique` identities must be\n unique\n- [ESLint rules](/guide/routing/eslint-rules) — local architecture, autofixed\n- [Routing setup](/guide/routing/setup) — the proofs this helper keeps armed\n- [Learn: test what you wrote](/learn/10-testing)\n"
|
|
490
|
+
"body": "# Architecture rules\n\nArchitecture tests answer one question:\n\n> **Is the dependency shape of the app still allowed?**\n\nThey read the static Craft graph — routes, services, components, primitives and\ntheir edges — without starting the application. That makes them useful for\nrules that are about relationships, ownership or declarations rather than\nruntime behaviour.\n\n## Choose the right kind of test\n\n| If you want to verify… | Use… | Example |\n| -------------------------------------------------------- | -------------------------------------------- | ------------------------------------- |\n| one unit computes the right result | [service tests](/guide/testing/services) | a service returns the expected value |\n| one component renders and reacts correctly | [component tests](/guide/testing/components) | a button disables after a click |\n| two parts of the app are allowed to depend on each other | architecture tests | `checkout` must not depend on `admin` |\n| a complete user journey works in a browser | `e2e/` tests | a user can create and then see a task |\n\nUse an architecture rule when the requirement sounds like one of these:\n\n- **must not depend on** — a feature must not reach into another feature;\n- **must be owned once** — an HTTP endpoint or persisted identity has one owner;\n- **must declare a relationship** — a mutation must refresh a query;\n- **must remain pure** — reading a computed value must not perform work.\n\nA green architecture suite does not prove that a button works. It proves that\nthe app still respects the boundaries that make that button maintainable.\n\n::: tip Start with the graph-wide baseline\nAdd `assertDeclarativeArchitecture(graph.graph)` first. It checks the core\ninvariants that are easiest to break during a refactor: unique identities,\nunique HTTP ownership, pure `craftComputed` values, no dependency cycles and\ndeclared mutation reactions. Add focused rules when your application has an\nadditional boundary, such as route DI, folder ownership or URL-backed resource\nparams.\n:::\n\n## What a rule looks like\n\nA rule is an ordinary Vitest assertion. Look up a node, inspect its graph\nrelationships or call a built-in assertion, then let CI protect the invariant:\n\n```typescript\nit('keeps checkout away from admin internals', () => {\n noExclusiveLink(graph.route('/checkout'), graph.route('/admin'));\n});\n```\n\nThe rest of this page explains the graph, the setup and the built-in rules.\n\n## Import\n\n```typescript\nimport {\n analyzeDependencyGraph,\n architectureCatalogToTypeScript,\n assertCraftComputedPure,\n assertCraftEffectNoImperativeSync,\n assertCraftEffectNoNetwork,\n assertCraftUnique,\n assertDeclarativeArchitecture,\n assertHttpEndpointUnique,\n assertInsertSelectUnique,\n assertInteractiveElementNamed,\n assertMutationHasReactOn,\n assertNoDependencyCycles,\n assertPathBoundaries,\n assertPrimitiveLoaderRequirements,\n assertQueryMutationHasServerState,\n assertResourceParamsPreferQueryParams,\n assertPersistedPrimitiveHasUnique,\n assertRouteComponentsInSeparateFiles,\n assertRouteDiProofs,\n buildArchitectureCatalog,\n createArchitectureGraph,\n noExclusiveLink,\n} from '@craft-ts/dev-tools';\n```\n\n## Mental model\n\n`analyzeDependencyGraph` reads the application sources with the TypeScript\nprogram — routes, services, components, HTTP calls, `craftUnique` identities,\nroute DI proofs (`CanRun`, `RouteCheckedDI`) —\nand builds a graph of nodes and edges.\n\n`createArchitectureGraph` wraps that graph with typed lookups. Names come from\na generated **catalog** (`as const`): autocomplete, and a type error when a\nrenamed symbol disappears.\n\nA rule is then a Vitest assertion on those lookups. The suite lives next to\n`e2e/`, in an `architecture/` folder, and runs in Node — no `TestBed`, no\nbrowser.\n\nESLint already forbids local slips (`inject`, raw `HttpClient`) and can generate\nthe route proof blocks. Architecture tests catch **graph-wide** slips those\nrules cannot see: a feature leaking into another, an endpoint called from two\nAPIs, a duplicate storage key, a route or `app.config` error screen whose DI\nproof was never armed. See [ESLint rules](/guide/routing/eslint-rules).\n\n## The graph vocabulary\n\nThink of the graph as a typed inventory of architectural facts, not as a\nsecond runtime. A **node** is a thing the architecture can name; an **edge** is\nan observed relationship between two nodes. The graph is intentionally more\nfine-grained than a project graph: one app can contain many services,\ncomponents, primitives and HTTP endpoints.\n\n### Node families\n\nNot every application produces every kind of node. The built-in vocabulary is\ngrouped below by the questions it helps answer:\n\n| Family | Node kinds | What they represent |\n| --- | --- | --- |\n| Application structure | `route`, `route-hook`, `route-check`, `app-config`, `component`, `service` | Navigation, route-level checks, application configuration, UI entry points and injectable units. |\n| Reactive structure | `primitive`, `property`, `source`, `template-element` | A `state`, `query`, `mutation`, `craftComputed`, `craftEffect`, `craftMethod`, `queryParams`, or an exposed member/source/template element. A primitive's `details.name` keeps its concrete primitive name. |\n| Boundaries and identities | `http-endpoint`, `unique` | A verb + URL boundary and a canonical `craftUnique` identity, such as a persisted query key. |\n| Server functions | `server-function-family`, `server-function-contract`, `server-function-client`, `server-function-server`, `server-function-misnamed`, `server-function-middleware`, `server-function-middleware-misnamed`, `client-function-middleware`, `client-function-middleware-misnamed` | The client/server contract, implementation, middleware and naming checks around server functions. |\n| Protocol and extensions | `handshake`, plus adapter/contributed kinds such as `effect-service`, `effect-operation`, `effect-layer`, `data-classification`, and `external-output` | Protocol facts or backend concepts. Effect and data-flow extensions are still queried through the same graph API. |\n\nFor example, a page can be represented as these facts: a `route` **loads** a\n`component`; the component **contains** a `query`; a `service` **calls** the\n`GET users` `http-endpoint`; a consumer service **depends-on** a browser\nboundary; and a `mutation` **triggers** a query. These are independent,\ntyped relations that a rule can inspect directly.\n\nThe labels are deliberately semantic. A rule can ask “which service calls this\nendpoint?” or “which mutation triggers this query?” without matching file text\nor reconstructing the dependency tree itself.\n\n### Edge families\n\nThe built-in edge kinds describe different types of fact; they should not all\nbe treated as interchangeable dependency arrows:\n\n| Edge kinds | Meaning | Typical architecture question |\n| --- | --- | --- |\n| `loads`, `renders`, `contains`, `provides` | Structural ownership or composition | Which component does a route load? Which service is provided by a route or component? |\n| `depends-on`, `calls` | A unit reaches another unit or invokes a boundary/method | Can this feature depend on that feature? Who calls HTTP or a mutation? |\n| `reads`, `writes`, `subscribes`, `triggers` | Data-flow and reactive behaviour | Is a computed pure? Does a mutation refresh a query? |\n| `checks`, `uses-property` | Proof and member-level usage | Is a route DI proof armed? Which service member is actually selected? |\n| Extension relations | Backend-specific facts, for example `requires-service`, `provided-by-layer`, `composes-layer`, `exposes-data`, `flows-data` | Is an Effect service supplied by a Layer? Can a classified value reach an external output? |\n\nThe direction matters: `from --kind--> to` is the fact asserted by the\nanalyzer. A `depends-on` edge is therefore different from a `provides` edge,\nand a structural `contains` edge should not be mistaken for a runtime cycle.\nThis is why `assertNoDependencyCycles` follows `depends-on` rather than every\nedge in the graph.\n\n### What the graph is based on\n\nThe analyzer works from the TypeScript program selected by the analysis\n`tsconfig`:\n\n- **AST evidence** records syntax that is visible in the source: a route\n loading a component, a component rendering an element, or a service calling\n an HTTP client.\n- **Type evidence** records relationships resolved through TypeScript: an\n injected/yielded service, a provider, or a route proof connected to its\n target.\n- **Source proofs** keep the file, line, symbol and pattern that explain an\n edge when the analyzer has one. `graph.proofs(edge)` exposes them, so a\n failing rule can point back to the declaration that created the fact.\n\nThe result is static and deterministic: architecture tests do not boot the\napplication, instantiate services, make HTTP requests or observe user\nbehaviour. They prove that the source still has an allowed shape. Runtime\nbehaviour belongs in [service tests](/guide/testing/services), [component\ntests](/guide/testing/components) and e2e tests.\n\n### Choosing the granularity of a rule\n\nStart at the smallest graph level that expresses the invariant, then widen only\nwhen the invariant is genuinely architectural:\n\n| Granularity | Example assertion | Best for |\n| --- | --- | --- |\n| Node property | every `unique` is static; every interactive element has a name | Presence, identity and declaration rules |\n| Direct edge | a `mutation` has a `triggers` edge to a query | Required relationships and ownership |\n| Neighbourhood | a service calling HTTP is a `browserBoundary` | Local boundary policies |\n| Path or subgraph | no exclusive path links `admin` and `checkout`; no `depends-on` cycle | Feature isolation, reachability and cycles |\n| Whole graph | every endpoint is unique; every route has its DI proof | Global invariants and completeness |\n\nThe public API mirrors those levels: use `graph.nodes(kind)` and\n`graph.edges(kind)` for typed collections, `node.incoming()` / `node.outgoing()`\nfor neighbourhoods, and `graph.pathsBetween()` when the rule is about\nreachability. Built-in `assert*` helpers package recurring whole-graph checks;\ncustom rules should state the product or team invariant before describing the\ntraversal.\n\n## Setting it up\n\nThe demo app is the working reference: `apps/demo/architecture/`, run with\n`npx nx architecture demo`. Commands are listed in `apps/demo/README.md`.\nCopy that layout, or scaffold it with the migrator (Vitest, Node):\n\n```shell\nnpx craft-migrate-architecture \\\n --project tsconfig.app.json \\\n --root src \\\n --write\n```\n\nThat writes `tsconfig.graph.json`, `tsconfig.architecture.json`,\n`vitest.architecture.config.ts`, the `architecture/` suite (loader, catalog,\nbaseline rules, and an `architecture.spec.ts`), an\nNx `architecture` target or a `package.json` script, and ignores the generated\ncatalog in the nearest flat ESLint config. `--write` overwrites the scaffold.\n`--check` fails when the suite is missing or the generated tooling files\ndrifted. `craft-migrate --write` runs this as its last step.\n\nKeep the rules and app-specific lookups in one `architecture.spec.ts` file when\nthe graph is expensive to analyze. `loadArchitectureGraph()` caches only within\none Vitest worker; separate spec files rebuild the TypeScript graph separately.\nThe three demo apps use this single-file layout, which performs one graph\nanalysis per app run.\n\n### 1. Analysis tsconfig\n\nPoint analysis at **every application source file**. `tsconfig.app.json` often\nlists only `main.ts`; the graph would then miss routes, services and components.\n\n```json\n{\n \"extends\": \"./tsconfig.json\",\n \"compilerOptions\": {\n \"skipLibCheck\": true\n },\n \"include\": [\"src/**/*.ts\"],\n \"exclude\": [\"src/**/*.spec.ts\", \"src/**/*.test.ts\"]\n}\n```\n\n### 2. Suite tsconfig\n\nA second project compiles only the architecture folder, with Node and Vitest\ntypes:\n\n```json\n{\n \"extends\": \"./tsconfig.json\",\n \"compilerOptions\": {\n \"types\": [\"node\", \"vitest/globals\"],\n \"module\": \"esnext\",\n \"moduleResolution\": \"bundler\"\n },\n \"include\": [\"architecture/**/*.ts\"]\n}\n```\n\nReference it from the app `tsconfig.json` `references` array so the IDE\ntypechecks the suite.\n\n### 3. Vitest, at the app root\n\nKeep the config next to `project.json` — **not** inside `architecture/`. A nested\n`vitest.config.ts` is picked up by the Nx Vitest plugin and breaks the app's\nunit-test target.\n\n```typescript\n/// <reference types=\"vitest\" />\nimport { defineConfig } from 'vite';\n\nexport default defineConfig(() => ({\n root: import.meta.dirname,\n cacheDir: '../../node_modules/.vite/apps/demo-architecture',\n plugins: [],\n resolve: {\n tsconfigPaths: true,\n },\n test: {\n name: 'demo-architecture',\n watch: false,\n globals: true,\n environment: 'node',\n testTimeout: 180_000,\n hookTimeout: 180_000,\n include: ['architecture/**/*.spec.ts'],\n },\n}));\n```\n\nAnalysis of a real app takes seconds, not milliseconds. Size the timeouts\naccordingly; `beforeAll` uses `hookTimeout`.\n\n### 4. Load the graph, rewrite the catalog\n\n```typescript\nimport { writeFileSync } from 'node:fs';\nimport { join, resolve } from 'node:path';\nimport {\n analyzeDependencyGraph,\n architectureCatalogToTypeScript,\n buildArchitectureCatalog,\n createArchitectureGraph,\n} from '@craft-ts/dev-tools';\nimport { architectureCatalog } from './catalog';\n\nconst workspaceRoot = resolve(import.meta.dirname, '../../..');\nconst catalogPath = join(import.meta.dirname, 'catalog.ts');\n\nexport function loadArchitectureGraph() {\n const graph = analyzeDependencyGraph({\n rootDir: workspaceRoot,\n tsConfigFilePath: 'apps/your-app/tsconfig.graph.json',\n });\n writeFileSync(\n catalogPath,\n `// Generated. Do not edit.\\n${architectureCatalogToTypeScript(buildArchitectureCatalog(graph))}`,\n );\n return createArchitectureGraph(graph, architectureCatalog);\n}\n```\n\nThe imported catalog is what TypeScript autocompletes against. The rewrite\nkeeps it in sync with the sources: after a rename, the next typecheck of the\nsuite fails until the lookups are updated.\n\nIgnore the generated catalog in ESLint. Commit it so the first clone\ntypechecks.\n\nBootstrap with `npx craft-graph --project apps/your-app/tsconfig.graph.json --root . --out apps/your-app/architecture/catalog --format json`.\nRename the generated `catalog.architecture.ts` to `catalog.ts`. After that,\nloading the graph keeps it current.\n\n### 5. Nx target\n\n```json\n{\n \"architecture\": {\n \"executor\": \"nx:run-commands\",\n \"options\": {\n \"command\": \"npx vitest run --config vitest.architecture.config.ts\",\n \"cwd\": \"apps/your-app\"\n },\n \"inputs\": [\n \"{projectRoot}/src/**/*.ts\",\n \"{projectRoot}/architecture/**/*.ts\",\n \"{projectRoot}/tsconfig.graph.json\"\n ],\n \"cache\": true\n }\n}\n```\n\n```shell\nnpx nx architecture your-app\n```\n\n## Looking up nodes\n\nPass the catalog into `createArchitectureGraph` and names become unions.\nA missing name throws `Unknown service '…'`. Two nodes sharing a name throw\nuntil you pass a relative file path.\n\n```typescript\ngraph.route('craft/query/:userId');\ngraph.service('UsersApiOnError');\ngraph.service('ApiService', 'users/api.service.ts'); // homonym\ngraph.component('ListWithPagination');\ngraph.providedOn('UserList');\ngraph.httpEndpoint('GET', 'users');\ngraph.unique('{\"key\":\"user-query\",\"storeName\":\"demo-app\"}');\ngraph.services({ browserBoundary: true, providedIn: 'global' });\ngraph.usingHttp();\ngraph.dependingOnBrowserBoundary();\ngraph.craftMethods();\n```\n\n| Lookup | Returns |\n| -------------------------------------------------- | ------------------------------------------------ |\n| `route(path, file?)` | one route node |\n| `service(name, file?)` | one service node |\n| `component(name, file?)` | one component node |\n| `providedOn(name)` | every node that `provides` that service |\n| `httpEndpoint(method, url)` | one HTTP endpoint |\n| `unique(canonicalJson)` | one `craftUnique` identity |\n| `services({ browserBoundary, scope })` | filtered services |\n| `usingHttp()` | nodes that call `CraftHttpClient` |\n| `dependingOnBrowserBoundary()` | nodes that depend on a `browserBoundary` service |\n| `uniques()` / `httpEndpoints()` / `craftMethods()` | all nodes of that kind |\n\nEach node exposes `providers()`, `provider(name)`, `outgoing(kind?)`,\n`incoming(kind?)` and `httpEndpoints()`. Edge kinds include `depends-on`,\n`provides`, `calls`, `loads`, `renders`, `reads`, `writes`, `checks`,\n`triggers`.\n\n`unique(...)` takes the **canonical JSON** of the identity object: keys sorted\nin depth. `{ storeName, key }` and `{ key, storeName }` index as the same\nstring.\n\nFor adding a TypeScript backend with its own typed nodes and relations, see\n[Extensible architecture graph](/guide/testing/extensible-architecture-graph).\n\n## Built-in helpers\n\nThe declarative baseline is the aggregate set of graph-wide checks below.\nImport them all, then either call each one or\n`assertDeclarativeArchitecture` for the aggregate checks together.\nThe demo suite keeps all checks in `apps/demo/architecture/architecture.spec.ts`\nso the graph is loaded once. Run it with `npx nx architecture demo`.\n\nEach rule has a focused page with the invariant it protects, the failure it\nprevents and the smallest useful test. Start with the [declarative\nbaseline](/guide/testing/architecture/declarative-baseline), then add the\nrules that express your application's boundaries.\n\n| Helper | Fails when |\n| -------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |\n| [`assertCraftUnique`](/guide/testing/architecture/unique-identities) | the same `craftUnique` identity appears twice, or the argument is not a static literal |\n| [`assertHttpEndpointUnique`](/guide/testing/architecture/http-endpoint-ownership) | the same HTTP verb+URL is called from more than one site |\n| `assertVisualHappyPathArchitecture` | a routed page, mobile/desktop viewport, or Craft HTTP endpoint has no successful visual happy-path fixture |\n| [`assertCraftComputedPure`](/guide/testing/architecture/computed-purity) | a `craftComputed` `calls` a method or `writes` a `source$` |\n| [`assertPrimitiveMethodsUsedOnce`](/guide/testing/architecture/primitive-method-usage) | an exposed primitive insertion method is used from more than one call site |\n| [`assertNoUnusedPrimitiveMethods`](/guide/testing/architecture/unused-primitive-method) | an exposed primitive insertion method has no call site anywhere in the project |\n| [`assertNoDependencyCycles`](/guide/testing/architecture/dependency-cycles) | a directed cycle exists on `depends-on` (services, components, computeds) |\n| [`assertMutationHasReactOn`](/guide/testing/architecture/mutation-reactions) | a `mutation` has no query `insertReactOnMutation` edge (`allow` skips named fire-and-forget mutations) |\n| [`assertDeclarativeArchitecture`](/guide/testing/architecture/declarative-baseline) | any of the baseline checks fail |\n| [`assertRouteDiProofs`](/guide/testing/architecture/route-di-proofs) | a routed component, pending UI or error screen has no armed `CanRun` mapper, a collection is missing `assertExhaustiveRouteExceptions`, or `app.config.ts` registers a global / route-load error screen without its `RouteExceptionComponentCheckedDI` |\n| [`assertRouteComponentsInSeparateFiles`](/guide/testing/architecture/route-component-files) | a route loads its page component from the routing file, or multiple routed page components share one component file |\n| [`assertPathBoundaries`](/guide/testing/architecture/path-boundaries) | a `depends-on` (or opted-in `calls`) crosses a folder allowlist / denylist |\n| [`noExclusiveLink(a, b)`](/guide/testing/architecture/exclusive-links) | the only path between two branches is a leak, not a shared kernel |\n| [`assertPersistedPrimitiveHasUnique`](/guide/testing/architecture/persisted-identities) | `insertStoragePersister` is used without wrapping the identity in `craftUnique` |\n| [`assertInsertSelectUnique`](/guide/testing/architecture/insert-select-keys) | the same `insertSelect` key appears twice on one host primitive |\n| [`assertCraftEffectNoNetwork`](/guide/testing/architecture/craft-effect-network) | a `craftEffect` `calls` HTTP or a `mutation` |\n| [`assertCraftEffectNoImperativeSync`](/guide/testing/architecture/craft-effect-imperative-sync) | a `craftEffect` writes a `state` / `source$` or triggers a `query` / `mutation` / `asyncProcess` |\n| [`assertInteractiveElementNamed`](/guide/testing/architecture/interactive-element-names) | an interactive element lacks a literal name or duplicates a `data-craft-name` |\n| [`assertQueryMutationHasServerState`](/guide/testing/architecture/server-state-loader) | a `query` or `mutation` does not reach an allowed server-state boundary |\n| [`assertPrimitiveLoaderRequirements`](/guide/testing/architecture/primitive-loader-requirements) | an Effect-aware primitive does not declare an allowed dependency boundary |\n| [`assertResourceParamsPreferQueryParams`](/guide/testing/architecture/resource-params-query-state) | a `query` or `asyncProcess` params graph depends on a `state` instead of URL-backed `queryParams` |\n\n### `noExclusiveLink`\n\nForbids edges that exist only because two branches touch each other. A shared\nkernel — auth, HTTP client, browser boundaries — is allowed. Membership stops\nat other `provides` sites, so a leak into a third feature is not reclassified\nas shared.\n\n```typescript\nit('keeps exclusive feature branches from linking', () => {\n const [userList] = graph.providedOn('UserList');\n const [userMutation] = graph.providedOn('UserMutation');\n expect(userList).toBeDefined();\n expect(userMutation).toBeDefined();\n noExclusiveLink(userList, userMutation);\n});\n```\n\nThe same helper works on routes: `noExclusiveLink(graph.route('/admin'), graph.route('/checkout'))`.\n\n### `assertPathBoundaries`\n\nNx `depConstraints` tag **projects** and forbid TypeScript imports. This helper\ntags **folders** on the Craft graph and forbids `depends-on` (optionally\n`calls`) between them — including inside one app, where module-boundary ESLint\ndoes not run. Same intention, different altitude: [Craft graph vs\nNx](/guide/testing/craft-graph-vs-nx).\n\nPaths are relative to `graph.rootDir`. `*` is one segment, `**` is any depth,\n`:name` captures a segment. The same capture in `source` and `onlyDependOn` /\n`forbidTarget` must match, so a feature can depend on itself but not on\nsiblings.\n\n`onlyDependOn` is an allowlist; `forbidTarget` is a denylist. When both are\nset, the target must match the allowlist **and** miss the denylist. Nodes whose\npath matches no `source` are unconstrained. Edges without a `filePath` on\neither end, and structural edges (`provides`, `loads`, `renders`, `contains`),\nare ignored.\n\n```typescript\nit('keeps features and UI in their folders', () => {\n assertPathBoundaries(graph.graph, {\n constraints: [\n {\n source: 'src/app/features/:feature/**',\n onlyDependOn: [\n 'src/app/features/:feature/**',\n 'src/app/shared/**',\n 'src/app/ui/**',\n ],\n },\n {\n source: 'src/app/ui/**',\n onlyDependOn: ['src/app/ui/**', 'src/app/shared/**'],\n forbidTarget: ['src/app/data/**'],\n },\n ],\n });\n});\n```\n\nSibling features are an allowlist job (`onlyDependOn` includes\n`features/:feature/**`). A denylist `features/**` would also forbid self.\n\n### `assertCraftUnique`\n\nEach `craftUnique(...)` identity must appear once, and the argument must be a\nstatic literal — otherwise the graph cannot tell two call sites apart. Used\nwith [persistence](/guide/state/persistence) so two queries cannot silently\nshare a storage key.\n\n```typescript\nit('requires craftUnique identities to appear once', () => {\n assertCraftUnique(graph.graph);\n});\n```\n\nA duplicate or a non-literal argument fails the test with the file:line of\neach call site.\n\n### `assertHttpEndpointUnique`\n\nA `GET users` node is one verb + one URL. Two call sites — two services, or\nthe same service twice — fail the test. Distinct pairs (`GET users` and\n`POST users`, or `GET orders`) are allowed.\n\n```typescript\nit('owns each HTTP endpoint once', () => {\n assertHttpEndpointUnique(graph.graph);\n});\n```\n\nThis is the graph-wide counterpart of `craftUnique`. Wrapping `CraftHttpClient`\nin `craftUnique` is not required: the identity is the verb+URL.\n\n### `assertVisualHappyPathArchitecture`\n\nThe visual overview contract connects routed pages, the default mobile and\ndesktop viewports, and deterministic API datasets. It consumes the config\ncreated with `defineVisualAppConfig` and fails if a routed page is absent, a\nconfigured component is unknown, or any `CraftHttpClient` /\n`CraftBinaryHttpClient` endpoint lacks a successful mock in a dedicated\n`*.happy-path.ts` file.\n\n```typescript\nimport { assertVisualHappyPathArchitecture } from '@craft-ts/dev-tools';\nimport { visualTestConfig } from '../../e2e/visual-test.config';\n\nit('covers every page and HTTP endpoint in the visual happy path', () => {\n assertVisualHappyPathArchitecture(graph.graph, visualTestConfig);\n});\n```\n\nThe assertion is separate from `assertDeclarativeArchitecture` because it\nneeds the application's visual config. Dynamic URL segments are represented by\n`*`, so a template URL such as `` `/api/users/${id}` `` is indexed as\n`/api/users/*` and uses the same key in its fixture.\n\n### `assertCraftComputedPure`\n\nA `craftComputed` may only **read**. Outgoing `calls` (a `craftMethod`,\n`increment`, `mutate`, …) and `writes` (`source$.emit` / `.set`) fail.\n\nLocal slips are also caught by ESLint\n`craft-ts/no-craft-computed-side-effects`. The graph catches a computed that\ncalls a method declared in another binding.\n\n```typescript\nit('keeps craftComputed free of methods and source$ writes', () => {\n assertCraftComputedPure(graph.graph);\n});\n```\n\n### `assertNoDependencyCycles`\n\nDirected cycles on `depends-on` only: service A → B → A, two `craftComputed`\nthat yield each other, a self-`yield*`. `provides`, `contains`, `loads` and\n`renders` are structure, not a cycle of use. A shared kernel (Left → Auth,\nRight → Auth) is not a cycle.\n\n```typescript\nit('forbids depends-on cycles', () => {\n assertNoDependencyCycles(graph.graph);\n});\n```\n\n### `assertDeclarativeArchitecture`\n\nRuns the aggregate checks above and joins their messages. Pass `{ allow }`\nthrough to `assertMutationHasReactOn` for fire-and-forget mutations.\n\n```typescript\nit('keeps the app declarative', () => {\n assertDeclarativeArchitecture(graph.graph, { allow: ['logout'] });\n});\n```\n\n### `assertRouteDiProofs`\n\nThe routing DI contract is type-level by design. `CanRun`, `RouteCheckedDI` and\n`RouteExceptionComponentCheckedDI` are unused aliases unless they stay in the\nfile: comment one out and TypeScript still compiles. That is the one fragile\nstep in an otherwise compile-time guarantee.\n\nThis helper makes that step a test failure. It walks the static graph and\nrequires every routed component — including lazy `loadChildren` collections,\nwhich a parent proof never covers — every pending or error screen, and every\n`craftAppConfig` error surface to be hooked to an armed mapper. A mapper\nwithout `CanRun` is dead: the graph indexes it, then this rule fails.\nTypeScript still judges whether a dependency is provided; the architecture\nsuite judges whether that judgement was invoked.\n\n```typescript\nit('requires a DI proof on every routed component and app-config error screen', () => {\n assertRouteDiProofs(graph.graph);\n});\n```\n\nA missing proof, an unarmed mapper, a pending/error screen without its own\n`RouteCheckedDI`, a collection without `assertExhaustiveRouteExceptions`, or an\n`app.config.ts` that registers `provideCraftGlobalErrorComponent` /\n`provideCraftRouteLoadErrorComponent` (or `withErrorComponent` /\n`withRouteLoadError`) without an armed `RouteExceptionComponentCheckedDI` fails\nwith the file:line of the hole.\n\n### `assertRouteComponentsInSeparateFiles`\n\nRoute definitions describe navigation and loading; page components live in\ntheir own files. This assertion compares the route file with every component\ntarget discovered through `component`, `loadComponent` or a lazy `import()`,\nthen rejects multiple routed page components that share one component file.\n\n```typescript\nit('keeps route definitions separate from page components', () => {\n assertRouteComponentsInSeparateFiles(graph.graph);\n});\n```\n\nThe rule checks the page file boundary only. It does not restrict components\nrendered inside a page, and it does not require one route collection per file.\n\n### `assertMutationHasReactOn`\n\nA mutation that no query reacts to is the graph-wide form of\n[the button that knows which lists to refresh](/guide/state/react-on-mutation).\nThe analyzer records `insertReactOnMutation` as a `triggers` edge from the\nmutation to the query — including when the insertion is nested in\n`insertQueryPipe`. This helper fails on every `mutation` primitive that has no\nsuch edge.\n\nFire-and-forget writes (logout, a form submit with no cache, a demo that\nrefreshes by incrementing local state) pass an `allow` list of mutation names:\n\n```typescript\nit('requires a query to react to each mutation', () => {\n assertMutationHasReactOn(graph.graph, { allow: ['logout'] });\n});\n```\n\n### `assertPersistedPrimitiveHasUnique`\n\n`assertCraftUnique` says an identity appears once. This helper says a persisted\nprimitive _has_ an identity: `insertStoragePersister` / `insertLocalStoragePersister`\nmust take `craftUnique(...)`. A raw `{ key, storeName }` indexes the primitive\nas persisted and fails here.\n\n```typescript\nit('requires craftUnique on every persisted primitive', () => {\n assertPersistedPrimitiveHasUnique(graph.graph);\n});\n```\n\nSee [Persistence](/guide/state/persistence).\n\n### `assertInsertSelectUnique`\n\n`insertSelect('cell')` names a slice on its host `state` / `query`. Two\nsiblings with the same key on the same host stomp each other. The same key on\ntwo different hosts is allowed — each list can have a `cell`.\n\n```typescript\nit('keeps insertSelect keys unique on each host', () => {\n assertInsertSelectUnique(graph.graph);\n});\n```\n\nSee [Selecting](/guide/state/select).\n\n### `assertCraftEffectNoNetwork`\n\nA `craftEffect` that `calls` `CraftHttpClient` or a `mutation` is a `query` or\n`mutation` in disguise. Reads of local `state` stay valid.\n\n```typescript\nit('keeps craftEffect off HTTP and mutations', () => {\n assertCraftEffectNoNetwork(graph.graph);\n});\n```\n\n### `assertCraftEffectNoImperativeSync`\n\nA `craftEffect` that writes another `state` or `source$`, or that calls\n`query.call` / `mutation.mutate` / `asyncProcess.method`, is glue that should\nbe a sourced `state` or reactive `params` instead. Logging, focus, and other\nI/O that does not push into a Craft primitive stay valid. ESLint\n`craft-ts/no-imperative-craft-resource-trigger` catches the resource-trigger\nhalf in the editor; this helper is the graph-wide counterpart, including\nstate writes.\n\n```typescript\nit('keeps craftEffect from pushing into other primitives', () => {\n assertCraftEffectNoImperativeSync(graph.graph);\n});\n```\n\n### `assertInteractiveElementNamed`\n\n`button('increment', {}, '+')` stamps `data-craft-name=\"increment\"`. Type-level\nproofs and DOM tests already key off that name. This helper makes the first\nstring **mandatory** on clickable and fillable elements, and **unique in the\napp**: two `button('save')` in two components fail, and so does\n`button({ click() {} }, 'Save')`. ESLint `craft-ts/require-interactive-local-name`\nis the editor counterpart for the missing / non-static cases.\n\n```typescript\nit('requires a unique literal data-craft-name on every interactive element', () => {\n assertInteractiveElementNamed(graph.graph);\n});\n```\n\n## Writing your own rules\n\nStart from a node you care about and assert what should be true of its\nneighbourhood. The demo suite does this for routes and HTTP; the same pattern\ncovers any invariant you can see on the graph.\n\n### A route provides the feature service\n\n```typescript\nit('indexes demo routes and provided feature services', () => {\n expect(graph.route('craft/query/:userId').kind).toBe('route');\n expect(graph.providedOn('UserList').map((node) => node.label)).toEqual(\n expect.arrayContaining([expect.stringMatching(/ListWithPagination/)]),\n );\n});\n```\n\n### An HTTP endpoint has a single owner\n\n```typescript\nit('indexes the users HTTP endpoint', () => {\n expect(graph.httpEndpoint('GET', 'users').label).toBe('GET users');\n expect(graph.usingHttp().map((node) => node.label)).toEqual(\n expect.arrayContaining(['UsersApiOnError']),\n );\n});\n```\n\n### HTTP only from a browser boundary\n\n[Browser boundaries](/guide/testing/browser-boundaries) are the line to the\nnetwork. A rule can require that `CraftHttpClient` is only yielded from a\nservice marked `browserBoundary: true`:\n\n```typescript\nit('only browser-boundary services call HTTP', () => {\n const boundaryIds = new Set(\n graph.services({ browserBoundary: true }).map((node) => node.id),\n );\n const leaked = graph\n .usingHttp()\n .filter((node) => node.kind === 'service' && !boundaryIds.has(node.id));\n expect(leaked.map((node) => node.label)).toEqual([]);\n});\n```\n\n### A persisted identity exists\n\n```typescript\nit('looks up a persisted unique identity', () => {\n expect(graph.unique('{\"key\":\"user-query\",\"storeName\":\"demo-app\"}').kind).toBe(\n 'unique',\n );\n});\n```\n\nIf the lookup throws, the identity left the graph — the key changed, or\n`craftUnique` was removed.\n\nAnything you can express with `outgoing` / `incoming` is a rule: “this\n`craftMethod` is either called or writes a `source$`, never both”, “this\ncomponent does not `depends-on` that service”, “only `providedIn: 'global'` services\nappear under `usingTemporal()`”. Keep the assertion next to a comment that\nstates the product invariant, not the graph traversal.\n\n## Inspecting the graph\n\n`npx craft-graph` (also `npx craft graph`) writes the same analysis to disk\nwithout running tests:\n\n```shell\nnpx craft-graph \\\n --project apps/your-app/tsconfig.graph.json \\\n --root . \\\n --out craft-dependency-graph \\\n --format all\n```\n\n| `--format` | Writes |\n| ---------- | --------------------------------------------- |\n| `json` | the raw graph + a `.architecture.ts` catalog |\n| `mermaid` | a `.mmd` diagram |\n| `html` | a standalone explorer (no server, no runtime) |\n| `both` | JSON + catalog + Mermaid |\n| `all` | JSON + catalog + Mermaid + HTML |\n\n`--include <text>` restricts analysis to matching source paths. Use the HTML\nexplorer to see a route expand into components and services before you write\nthe assertion.\n\n## Pitfalls\n\n**The analysis tsconfig must include the app, not just `main.ts`.** An empty\ngraph with a passing `usingHttp()` is the usual symptom.\n\n**Do not nest `vitest.config.ts` under `architecture/`.** Put\n`vitest.architecture.config.ts` at the app root.\n\n**The catalog lags by one run.** Lookups are typed against the committed file.\nAfter adding a route or service, run the suite once so the rewrite lands, then\nthe new name typechecks.\n\n**Homonyms need a file path.** `graph.service('ApiService')` throws\n`Ambiguous service 'ApiService'` when two files export that name. Pass\n`'users/api.service.ts'`.\n\n**`craftUnique` must be a literal.** A computed `{ storeName, key }` indexes as\n`static: false` and `assertCraftUnique` fails — the graph cannot prove\nuniqueness.\n\n**A commented `CanRun` still type-checks.** Unused aliases are not errors.\n`assertRouteDiProofs` is the CI counterpart — that is the whole point of the\nhelper.\n\n**These tests are not e2e.** They never boot the app. Pair them with\n[service](/guide/testing/services) and [component](/guide/testing/components)\ntests for behaviour, and with ESLint for local architecture.\n\n## See Also\n\n- [Craft graph vs Nx](/guide/testing/craft-graph-vs-nx) — what each graph can\n and cannot see\n- [Testing services](/guide/testing/services) — the runtime graph of one service\n- [Browser boundaries](/guide/testing/browser-boundaries) — the nodes\n `browserBoundary: true` refers to\n- [Persistence](/guide/state/persistence) — why `craftUnique` identities must be\n unique\n- [ESLint rules](/guide/routing/eslint-rules) — local architecture, autofixed\n- [Routing setup](/guide/routing/setup) — the proofs this helper keeps armed\n- [Learn: test what you wrote](/learn/10-testing)\n"
|
|
476
491
|
},
|
|
477
492
|
{
|
|
478
493
|
"path": "/guide/testing/architecture/computed-purity",
|
|
@@ -542,7 +557,7 @@
|
|
|
542
557
|
{
|
|
543
558
|
"path": "/guide/testing/architecture/primitive-method-usage",
|
|
544
559
|
"title": "Primitive method usage",
|
|
545
|
-
"body": "# Primitive method usage\n\n`assertPrimitiveMethodsUsedOnce` requires every method exposed by a primitive\ninsertion to have one source-level call site. It complements\n`craft-ts/no-reused-primitive-method`, which checks usages inside one file.\n\n\n\nThe rule applies to methods returned by insertions on `state`, `query`,\n`mutation`, `asyncProcess` and `queryParams`. A callback reference counts as a\nusage just like an explicit generator call:\n\n```typescript\nbutton({ click: counter.increment });\nyield* counter.increment();\n```\n\nTwo distinct source locations must have distinct names so the method itself\nexplains its context:\n\n```typescript\nconst counter = yield* state('counter', 0, ({ update }) => ({\n incrementFromToolbar: () => update((value) => value + 1),\n incrementFromKeyboard: () => update((value) => value + 1),\n}));\n```\n\nMethods bound internally with `on$` are not exposed and are not checked. A\nsingle call inside a loop is also one call site: the rule concerns the source\nshape, not how many times the application executes it.\n\nThe architecture assertion keeps the invariant across service, component and\nfeature-file boundaries. Its error lists every known file and
|
|
560
|
+
"body": "# Primitive method usage\n\n`assertPrimitiveMethodsUsedOnce` requires every method exposed by a primitive\ninsertion to have one source-level call site. It complements\n`craft-ts/no-reused-primitive-method`, which checks usages inside one file.\n\n\n\nThe rule applies to methods returned by insertions on `state`, `query`,\n`mutation`, `asyncProcess` and `queryParams`. A callback reference counts as a\nusage just like an explicit generator call:\n\n```typescript\nbutton({ click: counter.increment });\nyield* counter.increment();\n```\n\nTwo distinct source locations must have distinct names so the method itself\nexplains its context:\n\n```typescript\nconst counter = yield* state('counter', 0, ({ update }) => ({\n incrementFromToolbar: () => update((value) => value + 1),\n incrementFromKeyboard: () => update((value) => value + 1),\n}));\n```\n\nMethods bound internally with `on$` are not exposed and are not checked. A\nsingle call inside a loop is also one call site: the rule concerns the source\nshape, not how many times the application executes it.\n\nThe architecture assertion keeps the invariant across service, component and\nfeature-file boundaries, including unchanged method references forwarded\nthrough a component template context. Its error lists every known file and\nline so the method can be split into context-specific insertion methods.\n\n## See also\n\n- [`craft-ts/no-reused-primitive-method`](/guide/routing/eslint-rules)\n- [Insertions](/guide/concepts/insertions)\n- [The architecture graph](/guide/testing/architecture)\n"
|
|
546
561
|
},
|
|
547
562
|
{
|
|
548
563
|
"path": "/guide/testing/architecture/resource-params-query-state",
|
|
@@ -617,7 +632,7 @@
|
|
|
617
632
|
{
|
|
618
633
|
"path": "/learn-effect/00-start-here",
|
|
619
634
|
"title": "Effect users: start here",
|
|
620
|
-
"body": "# Effect users: start here\n\nThis page is for teams that already use Effect and are evaluating CraftTS for\nthe frontend.\n\nThe important distinction is this:\n\n> You do not need to replace your domain model or your Effect programs. You do\n> need to adopt Craft's UI model for components, templates, reactive state,\n> forms and routing.\n\nEffect remains the place for domain programs, typed failures, services and\n`Layer`s. Craft owns the browser-facing lifecycle: rendering, reactivity,\nloading, cancellation and URL state.\n\n## The boundary in one picture\n\
|
|
635
|
+
"body": "# Effect users: start here\n\nThis page is for teams that already use Effect and are evaluating CraftTS for\nthe frontend.\n\nThe important distinction is this:\n\n> You do not need to replace your domain model or your Effect programs. You do\n> need to adopt Craft's UI model for components, templates, reactive state,\n> forms and routing.\n\nEffect remains the place for domain programs, typed failures, services and\n`Layer`s. Craft owns the browser-facing lifecycle: rendering, reactivity,\nloading, cancellation and URL state.\n\n## The boundary in one picture\n\nThe two sides have different responsibilities:\n\n| Concern | Effect | CraftTS |\n| --- | --- | --- |\n| Domain rules | `Effect<A, E, R>` | consumes the result |\n| Services | `Context.Service` + `Layer` | provides the Layer at a Craft scope |\n| Business failures | tagged errors in `E` | typed exceptions to render or handle |\n| UI state | not the owner | `state`, `queryParams`, derived readers |\n| Loading and cancellation | Effect runtime | `queryEffect`, `mutationEffect`, `asyncProcessEffect` |\n| Components and templates | not the owner | `craftComponent` and typed hyperscript |\n\n`yield*` appears on both sides, but it does not mean the same thing. Inside an\nEffect program it reads an Effect service or runs another Effect. Inside a Craft\nfactory it declares a Craft dependency or crosses the boundary through an\nEffect adapter.\n\n## A 15-minute quickstart\n\nThe goal is one page that loads a user from an Effect program and renders the\nresult through a Craft query.\n\n### 1. Install the matching packages — 2 minutes\n\n```shell\nnpm i @craft-ts/core@beta @craft-ts/component@beta @craft-ts/effect@beta\nnpm i effect@rc\nnpm i -D @craft-ts/dev-tools@beta\n```\n\nKeep the Craft packages on the same version. See the\n[compatibility and maturity matrix](/resources/effect-compatibility) before\nusing this in a production application.\n\n### 2. Define the domain program — 4 minutes\n\nThis code is ordinary Effect code. It does not import Craft.\n\n\n\nThe component will call `loadUser`, but it will not resolve\n`UserRepositoryService`. The nearest `Layer` will provide it.\n\n### 3. Cross the boundary with `queryEffect` — 4 minutes\n\nThe adapter turns `Effect<User, UserNotFound, UserRepositoryService>` into a\nCraft resource with loading, value and exception readers.\n\n\n\nDo not call `Effect.runPromise` or subscribe inside the component. The resource\nowns execution, cancellation and the transition between loading, success and\nfailure.\n\n### 4. Provide the Layer and install the bridge — 3 minutes\n\nInstall the bridge once at application bootstrap. Provide the Effect Layer at\nthe same Craft scope where the operation is used.\n\n\n\nRun the application with your normal frontend command. The executable version\nof this example is also covered by the docs test suite.\n\n### 5. Verify the boundary — 2 minutes\n\n```shell\nnpx nx test docs\nnpx nx typecheck demo-effect\nnpx nx test demo-effect\n```\n\nThe docs test target now performs three checks: it transpiles every TypeScript\nor TSX code fence in `learn-effect`, type-checks the complete snippets under\n`tests/snippets/learn-effect`, and executes their Vitest tests. The transpilation\ncheck is intentionally syntax-focused because several excerpts are meant to be\ncopied into an existing Craft or Effect generator; complete examples receive\nthe stronger typecheck and runtime coverage. The Effect demo covers success,\ntyped business errors, defects, application Layers and route-scoped Layers.\n\nFor a runnable starter that keeps this boundary intentionally small, use the\nrepository's [`quickstart-effect`](https://github.com/craft-ts/craft-ts/tree/main/apps/quickstart-effect)\napplication. It is wired into the same ESLint, EffectTS diagnostics and\narchitecture checks that a new Effect frontend should adopt.\n\n## Which adapter should I choose?\n\n| Situation | Adapter |\n| --- | --- |\n| Local toggle, draft or selection | `state` |\n| Server or domain read | `queryEffect` |\n| Explicit write | `mutationEffect` |\n| Synchronous business calculation from an Effect service | `computedEffect` |\n| Export, refresh or other explicit command | `asyncProcessEffect` |\n| One Effect in a guard or resolver | `runEffect` |\n| URL filters and pagination state | native Craft `queryParams` |\n\nThere is intentionally no `stateEffect`: local UI state belongs to Craft; an\nEffect is introduced when a computation, I/O operation or service dependency\ncrosses into the UI.\n\n## Continue from here\n\n- Read the [full Effect learning path](/learn-effect/).\n- Check [compatibility and maturity](/resources/effect-compatibility).\n- Follow the [progressive adoption plan](/resources/effect-adoption).\n- For the detailed API contract, read [Using Effect with CraftTS](/guide/advanced/effect).\n"
|
|
621
636
|
},
|
|
622
637
|
{
|
|
623
638
|
"path": "/learn-effect/01-first-component",
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@craft-ts/mcp",
|
|
3
|
-
"version": "0.8.
|
|
3
|
+
"version": "0.8.4",
|
|
4
4
|
"description": "MCP server, Agent Skills, and LLM files for coding agents using @craft-ts/core",
|
|
5
5
|
"author": "Romain Geffrault",
|
|
6
6
|
"license": "MIT",
|
|
@@ -35,7 +35,7 @@
|
|
|
35
35
|
"test": "vitest run --config vitest.config.mts"
|
|
36
36
|
},
|
|
37
37
|
"dependencies": {
|
|
38
|
-
"@craft-ts/dev-tools": "^0.8.
|
|
38
|
+
"@craft-ts/dev-tools": "^0.8.4",
|
|
39
39
|
"@modelcontextprotocol/sdk": "1.26.0",
|
|
40
40
|
"zod": "4.3.6"
|
|
41
41
|
},
|