@craft-ts/mcp 0.7.0-beta.13
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/README.md +71 -0
- package/content/agents.md +35 -0
- package/content/best-practices.md +79 -0
- package/content/docs-index.json +658 -0
- package/dist/catalog.d.ts +27 -0
- package/dist/catalog.js +67 -0
- package/dist/catalog.js.map +1 -0
- package/dist/main.d.ts +2 -0
- package/dist/main.js +13 -0
- package/dist/main.js.map +1 -0
- package/dist/mcp-server.d.ts +3 -0
- package/dist/mcp-server.js +144 -0
- package/dist/mcp-server.js.map +1 -0
- package/dist/resources.d.ts +10 -0
- package/dist/resources.js +63 -0
- package/dist/resources.js.map +1 -0
- package/mcp.json +10 -0
- package/package.json +62 -0
- package/plugin.json +19 -0
- package/skills/craft-ts/SKILL.md +43 -0
- package/skills/craft-ts-architecture-tests/SKILL.md +110 -0
- package/skills/craft-ts-effect-v4/SKILL.md +31 -0
- package/skills/craft-ts-routes/SKILL.md +135 -0
- package/skills/craft-ts-routes/references/di-checks.md +101 -0
- package/skills/craft-ts-routes/references/eslint-workflow.md +52 -0
- package/skills/craft-ts-routes/references/pending-and-exceptions.md +93 -0
- package/skills/craft-ts-routes/references/scaling-and-pitfalls.md +111 -0
- package/skills/craft-ts-service-migration/SKILL.md +86 -0
- package/skills/migrate-to-craft-ts/SKILL.md +135 -0
- package/skills/translate-spec-to-craft-ts/SKILL.md +86 -0
- package/skills/translate-spec-to-craft-ts/references/lexical-map.md +322 -0
- package/skills/translate-spec-to-craft-ts/references/pattern-recipes.md +123 -0
- package/skills/translate-spec-to-craft-ts/references/project-index.md +52 -0
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: craft-ts-routes
|
|
3
|
+
description: Best practices for creating and maintaining type-safe routes in Angular apps that use @craft-ts/core (craft-ts). Use this whenever you create or edit a routes file, call craftRoutes(...) / route(...), declare componentDeps, wire loadChildren, add a pendingComponent or a view-transition payload, see a "TS2589 excessively deep" error in a routes file, or need a per-file DI check (ValidateCascadeRoutesFile / RouteCheckedDI). Apply it even when the user only says "add a route", "split this routes file", "fix this routing type error", or "the injectXxx helper is missing" — getting the DI checks, the loadChildren split, and exhaustiveness right is non-obvious and easy to skip, and skipping it silently disables type-safe DI.
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# Creating craft-ts routes
|
|
7
|
+
|
|
8
|
+
## Objective
|
|
9
|
+
|
|
10
|
+
Produce route files that keep **compile-time dependency-injection safety** intact. In craft-ts a route
|
|
11
|
+
is not just a path → component map: each route declares the dependencies its component injects
|
|
12
|
+
(`componentDeps`), and a per-file check turns any unmet dependency into a TypeScript error. The whole
|
|
13
|
+
value proposition collapses the moment a route ships without its check, so the rules below are about
|
|
14
|
+
never letting that happen — while staying under TypeScript's instantiation ceiling.
|
|
15
|
+
|
|
16
|
+
If the public API names here don't match the installed version, confirm against the app's own routes and
|
|
17
|
+
`node_modules/@craft-ts/core`; prefer the patterns already used in the repo.
|
|
18
|
+
|
|
19
|
+
## The rules you must not skip
|
|
20
|
+
|
|
21
|
+
1. **Wrap routes in `craftRoutes(name, [...])`** — never export a plain Angular `Routes` array. Give every
|
|
22
|
+
component route a `componentDeps: {} as import('./x').GenDeps_X` line. That line is the wire between
|
|
23
|
+
the component's generated dependency type and the route check; without it the route's DI is invisible.
|
|
24
|
+
|
|
25
|
+
2. **Every `craftRoutes(...) file carries its own DI check.`** Add, in the same file:
|
|
26
|
+
```ts
|
|
27
|
+
type _CheckXDI = ValidateCascadeRoutesFile<ParentNames, ParentValues, typeof xRoutes>;
|
|
28
|
+
type _CanRunX = CanRun<_CheckXDI>;
|
|
29
|
+
```
|
|
30
|
+
This is the *iron rule*: the check reads only the current collection and **does not descend into
|
|
31
|
+
`loadChildren`**, so each file — including lazy children — must re-declare it or its components go
|
|
32
|
+
unchecked. A missing provider then surfaces as `Injected SomeService is not provided in path: "…"`.
|
|
33
|
+
Architecture tests (`assertRouteDiProofs`) fail CI if a collection ships without an armed check.
|
|
34
|
+
That helper belongs in the app's `architecture/` suite — load `craft-ts-architecture-tests` to
|
|
35
|
+
scaffold or keep it armed. Do not add an architecture rule for the feature.
|
|
36
|
+
|
|
37
|
+
3. **Stay under the instantiation ceiling — split with `loadChildren`.** One collection has a finite
|
|
38
|
+
route budget; past it TypeScript throws `TS2589: Type instantiation is excessively deep`, which then
|
|
39
|
+
**collapses inference for the whole file** (helpers vanish, `route(...)` calls degrade). The fix is
|
|
40
|
+
architectural, not a tweak: move routes into lazy child collections joined by `loadChildren`, each with
|
|
41
|
+
its own check. See [references/scaling-and-pitfalls.md](references/scaling-and-pitfalls.md).
|
|
42
|
+
|
|
43
|
+
4. **Keep exception handling exhaustive.** A route whose `canActivate` / `canMatch` / `resolve` can throw
|
|
44
|
+
a typed `craftException` must handle exactly those codes. Use the 3-arg `route(path, def, handlers)`
|
|
45
|
+
form, or assert the whole collection with `assertExhaustiveRouteExceptions(xRoutes)`.
|
|
46
|
+
|
|
47
|
+
5. **Let ESLint do the bookkeeping — don't hand-maintain the checks.** The check blocks, the `GenDeps_*`
|
|
48
|
+
aliases, the asserts and imports are all generated/refreshed by ESLint `--fix`. Run it after editing
|
|
49
|
+
routes or a component's DI shape rather than editing those blocks by hand (an ESLint error is *not* a
|
|
50
|
+
compile error, so a stale check silently hides real DI bugs). See
|
|
51
|
+
[references/eslint-workflow.md](references/eslint-workflow.md).
|
|
52
|
+
|
|
53
|
+
## Anatomy of a correct routes file
|
|
54
|
+
|
|
55
|
+
Use this as the template for a new collection. The app-level parent context is `<never, Router>` (no
|
|
56
|
+
extra named providers; `Router` is provided by value). For a child mounted under a route that adds
|
|
57
|
+
`providers`, thread the cumulative context instead — see the DI-checks reference.
|
|
58
|
+
|
|
59
|
+
```ts
|
|
60
|
+
import {
|
|
61
|
+
craftRoutes,
|
|
62
|
+
route,
|
|
63
|
+
assertExhaustiveRouteExceptions,
|
|
64
|
+
type CanRun,
|
|
65
|
+
type ValidateCascadeRoutesFile,
|
|
66
|
+
} from '@craft-ts/core';
|
|
67
|
+
import type { Router } from '@angular/router';
|
|
68
|
+
|
|
69
|
+
export const { featureRoutes, injectFeatureUserIdParams } = craftRoutes('feature', [
|
|
70
|
+
route(':userId', {
|
|
71
|
+
componentDeps: {} as import('./user-detail').GenDeps_UserDetailComponent,
|
|
72
|
+
loadComponent: () => import('./user-detail'),
|
|
73
|
+
// guards / resolve / providers / queryParams as needed
|
|
74
|
+
}),
|
|
75
|
+
]);
|
|
76
|
+
|
|
77
|
+
// Exhaustive over canActivate ∪ canMatch ∪ resolve for the whole collection.
|
|
78
|
+
assertExhaustiveRouteExceptions(featureRoutes);
|
|
79
|
+
|
|
80
|
+
// DI safety for THIS collection — the parent's cascade does NOT cover loadChildren.
|
|
81
|
+
type _CheckFeatureDI = ValidateCascadeRoutesFile<never, Router, typeof featureRoutes>;
|
|
82
|
+
type _CanRunFeature = CanRun<_CheckFeatureDI>;
|
|
83
|
+
```
|
|
84
|
+
|
|
85
|
+
The parent registers the child as a near-free lazy entry:
|
|
86
|
+
|
|
87
|
+
```ts
|
|
88
|
+
// app.routes.ts
|
|
89
|
+
{
|
|
90
|
+
path: 'feature',
|
|
91
|
+
loadChildren: () => import('./feature.routes').then((m) => m.featureRoutes),
|
|
92
|
+
},
|
|
93
|
+
```
|
|
94
|
+
|
|
95
|
+
## Decision rules
|
|
96
|
+
|
|
97
|
+
- **Adding a component route** → add `loadComponent` (or `component`) **and** `componentDeps:
|
|
98
|
+
{} as import('./x').GenDeps_X`. If `GenDeps_X` doesn't exist yet, generate it (ESLint Quick Fix
|
|
99
|
+
`brand-angular-gen-deps-required`, or `craft:brand`), don't write it by hand.
|
|
100
|
+
- **A routes file is getting large / hits TS2589** → don't fight the type; split a slice into a new
|
|
101
|
+
`craftRoutes(...)` file mounted via `loadChildren`, and give that file its own check. Repeat as a tree.
|
|
102
|
+
- **A lazy child only makes sense under one specific path** (it relies on that route's params, payload, or
|
|
103
|
+
providers) → pin it with `.withParent<ParentRoutes<'path'>>()` and enforce placement in the parent with
|
|
104
|
+
`assertChildRouteMounts(parentRoutes)`. See the scaling reference.
|
|
105
|
+
- **Slow guard/resolve + you want a skeleton** → add `pendingComponent: () => import('./skeleton')`. The
|
|
106
|
+
cascade never sees the skeleton, so verify its DI separately with a per-component `RouteCheckedDI`. For a
|
|
107
|
+
shared-element morph, declare the payload with `viewTransitionPayload<T>()`. See
|
|
108
|
+
[references/pending-and-exceptions.md](references/pending-and-exceptions.md).
|
|
109
|
+
- **A single file genuinely must hold a big flat list** (no natural `loadChildren` boundary) → switch from
|
|
110
|
+
`ValidateCascadeRoutesFile` to the per-route `RouteCheckedDI` (no recursion between routes, scales to
|
|
111
|
+
thousands). See the DI-checks reference.
|
|
112
|
+
- **You changed a component's `inject`/`imports`/`providers`** → regenerate its `GenDeps_*` (Quick Fix /
|
|
113
|
+
`craft:brand`) so the route check sees the new shape, then re-run ESLint `--fix` on the routes file.
|
|
114
|
+
|
|
115
|
+
## Never do this
|
|
116
|
+
|
|
117
|
+
- Export a plain `Routes` array, or omit `componentDeps` on a routed component — the route's DI becomes
|
|
118
|
+
unchecked.
|
|
119
|
+
- Ship a `craftRoutes(...)` file without its own check, or assume the parent's check covers a
|
|
120
|
+
`loadChildren` child — it does not.
|
|
121
|
+
- "Fix" a `TS2589` by deleting the check or casting to `any` — that hides every DI error in the file.
|
|
122
|
+
Split with `loadChildren` instead.
|
|
123
|
+
- Hand-edit the generated `_Check*` / `_CanRun*` blocks or `GenDeps_*` aliases — run ESLint `--fix`.
|
|
124
|
+
|
|
125
|
+
## References
|
|
126
|
+
|
|
127
|
+
- [references/di-checks.md](references/di-checks.md) — the per-file check in depth: `ValidateCascadeRoutesFile`,
|
|
128
|
+
threading the parent DI context, the `RouteCheckedDI` escape hatch, and `GenDeps_*` regeneration.
|
|
129
|
+
- [references/scaling-and-pitfalls.md](references/scaling-and-pitfalls.md) — the TS2589 ceiling, the
|
|
130
|
+
loadChildren-tree architecture, `.withParent` + `assertChildRouteMounts`, and approaches that look
|
|
131
|
+
tempting but don't work (so you don't re-derive them).
|
|
132
|
+
- [references/pending-and-exceptions.md](references/pending-and-exceptions.md) — `pendingComponent` DI
|
|
133
|
+
verification, `viewTransitionPayload`, `handleExceptions`, and exhaustiveness.
|
|
134
|
+
- [references/eslint-workflow.md](references/eslint-workflow.md) — the ESLint rules that keep all of the
|
|
135
|
+
above in sync, and the `--fix` workflow.
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
# Per-file DI checks
|
|
2
|
+
|
|
3
|
+
## What the check does
|
|
4
|
+
|
|
5
|
+
`ValidateCascadeRoutesFile<ParentNames, ParentValues, typeof xRoutes>` walks every route in the
|
|
6
|
+
collection at the type level and, for each routed component, compares the dependencies it injects
|
|
7
|
+
(`componentDeps` → its `GenDeps_*`) against what is provided — the route's own auto-provided services
|
|
8
|
+
(params, `data`, `queryParams`, guarded/resolved data), the route `providers`, and the **parent context**
|
|
9
|
+
you pass in. Any gap becomes a TypeScript error on `_CanRun*`:
|
|
10
|
+
|
|
11
|
+
```
|
|
12
|
+
Injected SomeService is not provided in path: "some/path"
|
|
13
|
+
Input "userId" is not provided in path: "some/path"
|
|
14
|
+
```
|
|
15
|
+
|
|
16
|
+
`CanRun<Check>` is what turns the check result into a hard compile error — always pair them:
|
|
17
|
+
|
|
18
|
+
```ts
|
|
19
|
+
type _CheckXDI = ValidateCascadeRoutesFile<ParentNames, ParentValues, typeof xRoutes>;
|
|
20
|
+
type _CanRunX = CanRun<_CheckXDI>;
|
|
21
|
+
```
|
|
22
|
+
|
|
23
|
+
## The iron rule (why every file needs its own)
|
|
24
|
+
|
|
25
|
+
The check reads only the **current** collection's metadata. It does **not** descend into `loadChildren`.
|
|
26
|
+
So a lazy child loaded from another file is invisible to the parent's check — that child file must
|
|
27
|
+
declare its own, or its components are never verified. This is the single most common way DI safety
|
|
28
|
+
silently disappears.
|
|
29
|
+
|
|
30
|
+
```
|
|
31
|
+
app.routes.ts → ValidateCascadeRoutesFile<…, typeof appRoutes> (checks its own leaves)
|
|
32
|
+
└── loadChildren → feature.routes.ts → ValidateCascadeRoutesFile<…, typeof featureRoutes> (MUST have its own)
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
## Threading the parent DI context
|
|
36
|
+
|
|
37
|
+
`ParentNames` / `ParentValues` describe everything provided **at the collection's mount point** — app
|
|
38
|
+
providers **plus** every ancestor route's `providers`.
|
|
39
|
+
|
|
40
|
+
- **App-level / no ancestor providers** (the common case): `<never, Router>`. No extra named providers;
|
|
41
|
+
`Router` is provided by value via `provideCraftRouter` / `provideRouter`. This pair is identical in
|
|
42
|
+
every file mounted directly under the app.
|
|
43
|
+
|
|
44
|
+
```ts
|
|
45
|
+
type _Check = ValidateCascadeRoutesFile<never, Router, typeof featureRoutes>;
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
- **Mounted under a route that adds `providers: [provideBilling()]`**: re-export the ancestor's cumulative
|
|
49
|
+
context next to the route that adds it, and union your own onto it:
|
|
50
|
+
|
|
51
|
+
```ts
|
|
52
|
+
// billing.routes.ts
|
|
53
|
+
export type BillingChildNames = AppProvidedNames | 'BillingService';
|
|
54
|
+
export type BillingChildValues = AppProvidedValues;
|
|
55
|
+
|
|
56
|
+
// sub-billing.routes.ts
|
|
57
|
+
type _Check = ValidateCascadeRoutesFile<BillingChildNames, BillingChildValues, typeof subRoutes>;
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
Forgetting to fold in an ancestor's provider makes the check wrong in **both** directions: a genuinely
|
|
61
|
+
missing provider can slip through, or a provided service gets flagged as missing. Keep the re-export
|
|
62
|
+
beside the route that adds the providers.
|
|
63
|
+
|
|
64
|
+
> Note: deriving the names automatically (e.g. `AppProvidedServiceNamesOf<typeof appConfig>`) can itself
|
|
65
|
+
> hit TS2589 when app providers are complex (function wrappers, monitoring, …). Listing the value types
|
|
66
|
+
> explicitly (`<never, Router, …>`) is the reliable workaround.
|
|
67
|
+
|
|
68
|
+
## The `RouteCheckedDI` escape hatch (O(1) per route)
|
|
69
|
+
|
|
70
|
+
When a single file genuinely must hold a large **flat** list with no natural `loadChildren` boundary,
|
|
71
|
+
switch from the aggregated cascade to the per-route check. It validates one component at a time with **no
|
|
72
|
+
recursion between routes**, so it never hits the depth ceiling and scales to thousands of routes — at the
|
|
73
|
+
cost of one block per component instead of one per file:
|
|
74
|
+
|
|
75
|
+
```ts
|
|
76
|
+
import { type CanRun, type RouteCheckedDI } from '@craft-ts/core';
|
|
77
|
+
|
|
78
|
+
type _CheckItem0 = RouteCheckedDI<
|
|
79
|
+
import('./item-0').GenDeps_Item0Component,
|
|
80
|
+
AppProvidedNames, // available provider names (parent context)
|
|
81
|
+
AppProvidedValues, // provided value types
|
|
82
|
+
'Item0Component' // label shown in the error
|
|
83
|
+
>;
|
|
84
|
+
type _CanRunItem0 = CanRun<_CheckItem0>;
|
|
85
|
+
// …one pair per route component
|
|
86
|
+
```
|
|
87
|
+
|
|
88
|
+
Prefer the `loadChildren` tree (it also code-splits); reach for `RouteCheckedDI` only when one big flat
|
|
89
|
+
file is unavoidable — or to verify a component the cascade can't see, like a `pendingComponent`.
|
|
90
|
+
|
|
91
|
+
## `GenDeps_*` — keep it fresh
|
|
92
|
+
|
|
93
|
+
`componentDeps: {} as import('./x').GenDeps_X` points at a generated alias describing the component's DI
|
|
94
|
+
shape. It is **generated, not hand-written**. Regenerate it whenever the component's DI changes:
|
|
95
|
+
|
|
96
|
+
- adding/removing `inject(...)` or constructor injection
|
|
97
|
+
- changing component `imports`, `providers`, or `viewProviders`
|
|
98
|
+
|
|
99
|
+
Use the ESLint Quick Fix (`brand-angular-gen-deps-required` to create, `brand-angular-deps-match` to
|
|
100
|
+
refresh) or the project's `craft:brand` codemod. A stale `GenDeps_*` makes the route check validate the
|
|
101
|
+
wrong shape — so after regenerating it, re-run ESLint `--fix` on the routes file.
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
# ESLint workflow — let the rules keep the checks in sync
|
|
2
|
+
|
|
3
|
+
The route DI checks, the `GenDeps_*` aliases, the asserts and their imports are **generated and refreshed
|
|
4
|
+
by ESLint**, not hand-written. This matters because an ESLint error is **not** a TypeScript compile error:
|
|
5
|
+
a stale or missing check passes the build while silently hiding real DI bugs. So the discipline is: after
|
|
6
|
+
touching routes or a component's DI shape, run `--fix` and trust the rules to regenerate the bookkeeping.
|
|
7
|
+
|
|
8
|
+
## The routing-relevant rules
|
|
9
|
+
|
|
10
|
+
Enable these in the flat ESLint config (plugin exposed as `@craft-ts/dev-tools/eslint-rules`, registered
|
|
11
|
+
under the `craft-ts/` namespace):
|
|
12
|
+
|
|
13
|
+
| Rule | Keeps in sync |
|
|
14
|
+
| --- | --- |
|
|
15
|
+
| `brand-angular-gen-deps-required` | creates a missing `GenDeps_*` alias for a component/directive/pipe |
|
|
16
|
+
| `brand-angular-deps-match` | refreshes an existing `GenDeps_*` when the component's DI changes |
|
|
17
|
+
| `require-assert-exhaustive-route-exceptions` | adds `assertExhaustiveRouteExceptions(xRoutes)` (+ import) per collection |
|
|
18
|
+
| `require-pending-component-di-check` | generates/refreshes the `RouteCheckedDI` block for a `pendingComponent` |
|
|
19
|
+
| `require-child-route-mount-check` | adds `assertChildRouteMounts(xRoutes)` (+ import) for collections mounting lazy `loadChildren` |
|
|
20
|
+
| `global-exception-registry-match` | mirrors `globalError()` codes into `CraftGlobalExceptionRegistry` |
|
|
21
|
+
|
|
22
|
+
> The aggregated `ValidateCascadeRoutesFile<…>` check itself is **not** auto-generated (its parent context
|
|
23
|
+
> can't be guessed across files). Write that one block by hand per file — see di-checks.md — and let the
|
|
24
|
+
> rules above handle everything else.
|
|
25
|
+
|
|
26
|
+
## The loop
|
|
27
|
+
|
|
28
|
+
1. **Create/edit a routed component** → run the Quick Fix `brand-angular-gen-deps-required` (or
|
|
29
|
+
`brand-angular-deps-match` if it already has a `GenDeps_*`), or the project's `craft:brand` codemod, to
|
|
30
|
+
(re)generate the alias.
|
|
31
|
+
2. **Edit the routes file** → run ESLint `--fix` on it. The asserts, the pending-component `RouteCheckedDI`
|
|
32
|
+
block, the child-mount assert and their imports are added/refreshed.
|
|
33
|
+
3. **Read the remaining TypeScript errors** — those are the real DI gaps (`Injected X is not provided…`,
|
|
34
|
+
`Input "y" is not provided…`). Fix by providing the service / adding the input / correcting the route.
|
|
35
|
+
|
|
36
|
+
```bash
|
|
37
|
+
# one file
|
|
38
|
+
eslint --fix src/app/feature/feature.routes.ts
|
|
39
|
+
# the component whose DI changed
|
|
40
|
+
eslint --fix src/app/feature/feature-detail.ts
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
## Gotchas
|
|
44
|
+
|
|
45
|
+
- **Quick Fix is per-file.** It won't cascade to other files; run it on each file you changed.
|
|
46
|
+
- **Rename a component class → rerun the generator** so the `GenDeps_*` alias name stays aligned with the
|
|
47
|
+
class name.
|
|
48
|
+
- **Flat config resolves rules from the CWD.** If `--fix` seems to do nothing, run it from the directory
|
|
49
|
+
whose ESLint config actually enables the `craft-ts/*` rules (often the app project root, not the repo
|
|
50
|
+
root).
|
|
51
|
+
- **Don't commit a green build with a red ESLint.** Because the ESLint error isn't a compile error, a route
|
|
52
|
+
can build fine while its check is stale — CI should run both.
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
# Pending UI, view transitions & exhaustive exceptions
|
|
2
|
+
|
|
3
|
+
## Non-blocking navigation + pending component
|
|
4
|
+
|
|
5
|
+
With `<craft-router-outlet>` (wired via `provideCraftRouter(...)`) navigation is non-blocking: the URL
|
|
6
|
+
commits immediately, a `pendingComponent` (skeleton) shows while a slow guard/resolve chain is in flight,
|
|
7
|
+
and the target mounts only on success. Add one per route as a craft-only field:
|
|
8
|
+
|
|
9
|
+
```ts
|
|
10
|
+
route('user/:userId', {
|
|
11
|
+
componentDeps: {} as import('./user').GenDeps_UserComponent,
|
|
12
|
+
loadComponent: () => import('./user'),
|
|
13
|
+
pendingComponent: () => import('./user-skeleton'),
|
|
14
|
+
canActivate: craftCanActivate(/* slow guard */),
|
|
15
|
+
}),
|
|
16
|
+
```
|
|
17
|
+
|
|
18
|
+
### Verifying the skeleton's DI (the cascade can't see it)
|
|
19
|
+
|
|
20
|
+
`ValidateCascadeRoutesFile` only checks the **target** component — it never descends into
|
|
21
|
+
`pendingComponent`. A skeleton is a real component that injects things (route params, a payload,
|
|
22
|
+
monitoring), so verify it **directly** with the per-component `RouteCheckedDI` (not a second aggregated
|
|
23
|
+
pass — that would add to the budget the cascade already spends):
|
|
24
|
+
|
|
25
|
+
```ts
|
|
26
|
+
type _CheckTargetDI = ValidateCascadeRoutesFile<AppNames, AppValues, typeof userRoutes>;
|
|
27
|
+
type _CanRunTarget = CanRun<_CheckTargetDI>;
|
|
28
|
+
|
|
29
|
+
// The skeleton injects the route-auto-provided :userId param — list its service name as available.
|
|
30
|
+
type _CheckPendingDI = RouteCheckedDI<
|
|
31
|
+
import('./user-skeleton').GenDeps_UserSkeletonComponent,
|
|
32
|
+
'UserUserIdParams', // route-auto-provided names the skeleton may inject
|
|
33
|
+
AppValues, // same parent context the cascade uses
|
|
34
|
+
'pending component: user/:userId'
|
|
35
|
+
>;
|
|
36
|
+
type _CanRunPending = CanRun<_CheckPendingDI>;
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
The ESLint rule `require-pending-component-di-check` **generates and refreshes this whole block** on
|
|
40
|
+
`--fix` — deriving the skeleton's `GenDeps_*`, the auto-provided service names from the route's path params
|
|
41
|
+
(+ view-transition payload), and the parent context from the collection's own `ValidateCascadeRoutesFile`.
|
|
42
|
+
Don't hand-maintain it.
|
|
43
|
+
|
|
44
|
+
## View transitions (shared-element morph across a slow chain)
|
|
45
|
+
|
|
46
|
+
For a shared-element morph to survive a slow navigation, the route **declares the payload shape** with
|
|
47
|
+
`viewTransitionPayload<T>()` — the view-transition analogue of how `queryParams` declares query-params shape:
|
|
48
|
+
|
|
49
|
+
```ts
|
|
50
|
+
export const { photosRoutes, injectPhotosPhotoIdViewTransition } = craftRoutes('photos', [
|
|
51
|
+
route(':photoId', {
|
|
52
|
+
componentDeps: {} as import('./photo-detail').GenDeps_PhotoDetailComponent,
|
|
53
|
+
loadComponent: () => import('./photo-detail'),
|
|
54
|
+
withLoaderViewTransitionImage: viewTransitionPayload<{ name: string; image: string | null }>(),
|
|
55
|
+
pendingComponent: () => import('./photo-skeleton'),
|
|
56
|
+
}),
|
|
57
|
+
]).withParent<ParentRoutes<'photos'>>();
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
This makes a typed `viewTransition: T | null` **required** on every `craftRouterLink` / `navigate` targeting
|
|
61
|
+
the route, and exposes a route-generated `injectPhotosPhotoIdViewTransition(): Signal<T | null>` helper the
|
|
62
|
+
skeleton/target reads to wear the matching `view-transition-name`. Use `withCraftViewTransitions()` (not
|
|
63
|
+
Angular's `withViewTransitions()`) so the morph is driven by the outlet's own swaps. The payload travels in
|
|
64
|
+
navigation `state`, so it is lost on reload/direct URL — the app stays functional without the preview image.
|
|
65
|
+
|
|
66
|
+
## Exhaustive exception handling
|
|
67
|
+
|
|
68
|
+
A route whose `canActivate` / `canMatch` / `resolve` can short-circuit with a typed `craftException({ code })`
|
|
69
|
+
must handle exactly those codes — no missing, no extra. Two equivalent ways:
|
|
70
|
+
|
|
71
|
+
- **3-arg `route(path, def, handlers)`** — enforces exhaustiveness at the call site:
|
|
72
|
+
|
|
73
|
+
```ts
|
|
74
|
+
route(':photoId', {
|
|
75
|
+
canActivate: craftCanActivate(/* may craftException({ code: 'DENIED' }) */),
|
|
76
|
+
// …
|
|
77
|
+
}, {
|
|
78
|
+
DENIED: ({ redirect }) => redirect('/photos'),
|
|
79
|
+
}),
|
|
80
|
+
```
|
|
81
|
+
|
|
82
|
+
- **`handleExceptions` field + a collection-level assert** as a safety net for 2-arg routes:
|
|
83
|
+
|
|
84
|
+
```ts
|
|
85
|
+
assertExhaustiveRouteExceptions(photosRoutes);
|
|
86
|
+
```
|
|
87
|
+
|
|
88
|
+
A route that can throw but was written with the 2-arg form surfaces its unhandled codes here. The ESLint
|
|
89
|
+
rule `require-assert-exhaustive-route-exceptions` adds this assert (+ import) on `--fix`.
|
|
90
|
+
|
|
91
|
+
Handlers receive helpers like `redirect('/path')` and `globalError()` (delegates to the global error
|
|
92
|
+
component). Codes routed to `globalError()` are mirrored in the `CraftGlobalExceptionRegistry` augmentation
|
|
93
|
+
by the `global-exception-registry-match` ESLint autofix — don't edit that by hand either.
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
# Scaling routes & the instantiation ceiling
|
|
2
|
+
|
|
3
|
+
## The ceiling, and why it's loud
|
|
4
|
+
|
|
5
|
+
`ValidateCascadeRoutesFile` instantiates a recursive type over the route tuple. TypeScript caps both how
|
|
6
|
+
**deep** it will recurse and how many instantiations it will do in **total**, so a single collection has a
|
|
7
|
+
finite budget — in practice a few dozen routes, sooner if routes carry guards / `resolve` /
|
|
8
|
+
`handleExceptions` (each costs several times more than a trivial route).
|
|
9
|
+
|
|
10
|
+
Past the budget:
|
|
11
|
+
|
|
12
|
+
```
|
|
13
|
+
TS2589: Type instantiation is excessively deep and possibly infinite.
|
|
14
|
+
app.routes.ts → ValidateCascadeRoutesFile<never, Router, typeof appRoutes>
|
|
15
|
+
```
|
|
16
|
+
|
|
17
|
+
**Watch the knock-on collapse.** A `TS2589` makes TypeScript abandon that type and fall back to `any`,
|
|
18
|
+
which poisons inference of neighbouring `const`s in the *same file*. The symptoms are misleading:
|
|
19
|
+
`route(...)` calls degrade to a bare builder type, the `craftRoutes(...)` helpers go missing
|
|
20
|
+
(`Property 'injectXxx' does not exist`), `craftRouterLink` targets type as `never`. The root cause is the
|
|
21
|
+
overflowing check — fix that, not the symptoms.
|
|
22
|
+
|
|
23
|
+
## The fix is architectural: a tree of `loadChildren`
|
|
24
|
+
|
|
25
|
+
Don't shrink the check or cast to `any`. Move routes into lazy child collections joined by `loadChildren`
|
|
26
|
+
(which you want anyway for code-splitting), each with **its own** check:
|
|
27
|
+
|
|
28
|
+
```
|
|
29
|
+
app.routes.ts # "manifest": ~N cheap { path, loadChildren } entries
|
|
30
|
+
├── billing.routes.ts # ~15–20 leaf routes + its own ValidateCascadeRoutesFile
|
|
31
|
+
├── admin.routes.ts # ~15–20 leaf routes + its own check
|
|
32
|
+
└── reporting.routes.ts # if itself large → re-split into sub-loadChildren (level 3+)
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
- A `{ path, loadChildren }` entry has no `componentDeps`, so it is **nearly free** in the parent's check —
|
|
36
|
+
the manifest can list dozens.
|
|
37
|
+
- Each feature file pays only for its own leaves. ~500 routes ÷ ~17 per file ≈ ~30 files; two levels are
|
|
38
|
+
plenty and you can nest further without limit.
|
|
39
|
+
- The takeaway: **DI is always verified — never drop the check; move it next to the routes it covers.**
|
|
40
|
+
|
|
41
|
+
The child returns its named collection from `loadChildren`:
|
|
42
|
+
|
|
43
|
+
```ts
|
|
44
|
+
// app.routes.ts
|
|
45
|
+
{ path: 'billing', loadChildren: () => import('./billing.routes').then((m) => m.billingRoutes) },
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
## Pinning a child to its mount — `.withParent` + `assertChildRouteMounts`
|
|
49
|
+
|
|
50
|
+
A child whose components rely on a *specific* mount (its `:param`, a view-transition payload, an ancestor's
|
|
51
|
+
providers) is only correct under that one path. Nothing enforces that by default. Pin it:
|
|
52
|
+
|
|
53
|
+
```ts
|
|
54
|
+
// view-transitions.routes.ts — the child declares where it belongs
|
|
55
|
+
import { craftRoutes, route, type ParentRoutes } from '@craft-ts/core';
|
|
56
|
+
|
|
57
|
+
export const { viewTransitionsRoutes } = craftRoutes('viewTransitions', [
|
|
58
|
+
route(':photoId', { /* … */ }),
|
|
59
|
+
]).withParent<ParentRoutes<'view-transitions'>>();
|
|
60
|
+
```
|
|
61
|
+
|
|
62
|
+
```ts
|
|
63
|
+
// app.routes.ts — the parent enforces placement (scoped to this file)
|
|
64
|
+
import { assertChildRouteMounts, craftRoutes } from '@craft-ts/core';
|
|
65
|
+
|
|
66
|
+
export const { demoRoutes } = craftRoutes('demo', [
|
|
67
|
+
{ path: 'view-transitions', loadChildren: () => import('./view-transitions.routes').then((m) => m.viewTransitionsRoutes) },
|
|
68
|
+
]);
|
|
69
|
+
|
|
70
|
+
assertChildRouteMounts(demoRoutes);
|
|
71
|
+
```
|
|
72
|
+
|
|
73
|
+
Mount the pinned collection elsewhere and the **parent file** fails to compile:
|
|
74
|
+
|
|
75
|
+
```
|
|
76
|
+
craftRoutes(...).withParent<ParentRoutes<'view-transitions'>>() must be
|
|
77
|
+
loadChildren-mounted under the route with path 'view-transitions', not 'admin'
|
|
78
|
+
```
|
|
79
|
+
|
|
80
|
+
- **Opt-in.** A collection without `.withParent` is unpinned and mountable anywhere (backward compatible).
|
|
81
|
+
Pin only the children whose placement actually matters.
|
|
82
|
+
- **Scoped to the parent.** `assertChildRouteMounts` reads the parent's own routes — it doesn't re-validate
|
|
83
|
+
the child, so it adds nothing to the child's budget.
|
|
84
|
+
- **Type-only.** `.withParent<…>()` returns the same object at runtime; `ParentRoutes<'path'>` carries only
|
|
85
|
+
the path string, so it creates no runtime coupling.
|
|
86
|
+
- **ESLint** (`require-child-route-mount-check`) adds the missing `assertChildRouteMounts(...)` + import on
|
|
87
|
+
`--fix`. Whether a child opts in with `.withParent` stays your call.
|
|
88
|
+
|
|
89
|
+
## Pitfalls — approaches that look tempting but don't work
|
|
90
|
+
|
|
91
|
+
These were tried and rejected; recording them so they aren't re-derived.
|
|
92
|
+
|
|
93
|
+
- **Casting away a `TS2589`** (`as any`, deleting the check, `@ts-ignore`). It silences the error but
|
|
94
|
+
disables DI checking for the whole file — the opposite of the goal. Always split with `loadChildren`.
|
|
95
|
+
|
|
96
|
+
- **Enforcing child placement inside `craftRoutes(...)` itself** (weaving a mount check into the `routes`
|
|
97
|
+
argument type so a wrong mount errors at the literal). It type-checks, but the extra per-collection
|
|
98
|
+
instantiation tips already-at-ceiling files into `TS2589` — even a 2-route child with no `loadChildren`
|
|
99
|
+
pays the cost, because *every* collection runs the same inference. Placement checks must be **scoped to
|
|
100
|
+
the parent** that mounts children (a standalone `assertChildRouteMounts`), not folded into the hot
|
|
101
|
+
`craftRoutes` path every file pays.
|
|
102
|
+
|
|
103
|
+
- **A `loadChildrenType: {} as typeof import('./x').xRoutes` carrier** to skip inferring the child through
|
|
104
|
+
`import().then()`. In isolation it builds; applied broadly it **materialises the child's full type
|
|
105
|
+
(components included)**, creating a **circular reference** for any child whose components inject the
|
|
106
|
+
*parent's* route data (`TS2615` "circularly references itself" + `TS2589`). The dynamic-import resolution
|
|
107
|
+
it replaced is cycle-safe and, measured against build-time noise, no slower — so don't add such a carrier.
|
|
108
|
+
|
|
109
|
+
- **Auto-deriving the parent context** from the app config in a generic constraint
|
|
110
|
+
(`AppProvidedServiceNamesOf<typeof appConfig>`). It overflows when app providers are complex. List the
|
|
111
|
+
value types explicitly: `<never, Router, …>`.
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: craft-ts-service-migration
|
|
3
|
+
description: Guides migration of existing Angular services and direct Angular DI toward @craft-ts/core craftService/toCraftService patterns. Use when editing or reviewing Angular services, @Injectable classes, inject(...), InjectionToken adapters, HttpClient/Router/Dialog usage, service tests, or route DI checks affected by service migration.
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# craft-ts Service Migration
|
|
7
|
+
|
|
8
|
+
Use this skill when a task touches dependency injection, service architecture, or migration from an existing Angular codebase to `@craft-ts/core`.
|
|
9
|
+
|
|
10
|
+
## Target Architecture
|
|
11
|
+
|
|
12
|
+
- Application/domain services are authored with `craftService(...)`.
|
|
13
|
+
- Existing Angular tokens, framework services, third-party services, and `InjectionToken`s are adapted once with `toCraftService(...)`.
|
|
14
|
+
- Components, route guards, resolvers, primitives, and other craft services consume the generated `X()` helper, not Angular `inject(Token)`.
|
|
15
|
+
- Direct Angular DI is treated as legacy at the app boundary. Do not add new direct `inject(...)` calls.
|
|
16
|
+
- Route DI checks should rely on craft service provider names. Keep `ProvidedValues = never` unless supporting legacy direct Angular tokens is explicitly required.
|
|
17
|
+
|
|
18
|
+
## Migration Workflow
|
|
19
|
+
|
|
20
|
+
1. Inventory the current dependency:
|
|
21
|
+
- Find `@Injectable`, `inject(...)`, constructor injection, `InjectionToken`, `HttpClient`, `Router`, `Dialog`, `Title`, and app-specific API services.
|
|
22
|
+
- Classify each dependency as authored business logic, external Angular/framework token, or third-party adapter.
|
|
23
|
+
|
|
24
|
+
2. Choose the right primitive:
|
|
25
|
+
- Business/domain logic -> `craftService({ name, scope }, function* () { ... })`.
|
|
26
|
+
- Existing Angular/third-party token -> `toCraftService({ name, scope, token })`.
|
|
27
|
+
- Existing token requiring `inject(...)` internally -> `toCraftService({ name, scope: 'global', inject: () => inject(TOKEN) })`.
|
|
28
|
+
- Dependencies that must be provided per feature/route/test -> `scope: 'toProvide'` or `scope: 'manuallyProvidedAtRoot'`.
|
|
29
|
+
|
|
30
|
+
3. Preserve the public interface deliberately:
|
|
31
|
+
- Expose the smallest useful surface from adapters.
|
|
32
|
+
- Prefer yielding a narrowed method/property map: `yield* Router(undefined, ({ navigateByUrl }) => ({ navigateByUrl }))`.
|
|
33
|
+
- Avoid pass-through wrappers that expose the whole Angular service unless the service is intentionally an adapter.
|
|
34
|
+
|
|
35
|
+
4. Replace consumption sites:
|
|
36
|
+
- In components/pages and craft service generators: use the generated `X()` helper, typically as `yield* X(...)`.
|
|
37
|
+
- In guards/resolvers/route helpers: use craft helpers and keep thrown exceptions typed.
|
|
38
|
+
- Remove direct Angular `inject(...)` and constructor injection from migrated code.
|
|
39
|
+
|
|
40
|
+
5. Update DI registration:
|
|
41
|
+
- Add `provideX(...)` where the selected scope requires it.
|
|
42
|
+
- Keep route-level providers close to the route that owns the instance.
|
|
43
|
+
- Re-export cumulative route provider names only when child route files need them.
|
|
44
|
+
|
|
45
|
+
6. Refresh generated artifacts:
|
|
46
|
+
- Run ESLint `--fix` for `brand-angular-gen-deps-required` and `brand-angular-deps-match`.
|
|
47
|
+
- Check route files still have `componentDeps` and `ValidateCascadeRoutesFile` or `RouteCheckedDI`.
|
|
48
|
+
- If direct Angular tokens disappeared, remove them from `ProvidedValues`; prefer `never`.
|
|
49
|
+
|
|
50
|
+
## Route DI Rule
|
|
51
|
+
|
|
52
|
+
For migrated features, prefer:
|
|
53
|
+
|
|
54
|
+
```ts
|
|
55
|
+
type _CheckFeatureDI = ValidateCascadeRoutesFile<
|
|
56
|
+
AppProvidedNames,
|
|
57
|
+
never,
|
|
58
|
+
typeof featureRoutes
|
|
59
|
+
>;
|
|
60
|
+
type _CanRunFeature = CanRun<_CheckFeatureDI>;
|
|
61
|
+
```
|
|
62
|
+
|
|
63
|
+
Use `ProvidedValues` only as a temporary legacy bridge for direct Angular tokens still injected by components. Treat unions like `Router | Dialog | Title | typeof SomeApi` in route files as migration debt unless the user explicitly wants legacy support.
|
|
64
|
+
|
|
65
|
+
## ESLint Expectations
|
|
66
|
+
|
|
67
|
+
Ensure these rules are enabled for migrated projects:
|
|
68
|
+
|
|
69
|
+
- `craft-ts/prefer-craft-service`: blocks new `@Injectable` / `@Service` app services.
|
|
70
|
+
- `craft-ts/prefer-craft-http-client`: keeps HTTP access inside craft-compatible APIs.
|
|
71
|
+
- `craft-ts/brand-angular-gen-deps-required` and `craft-ts/brand-angular-deps-match`: keep `GenDeps_*` current.
|
|
72
|
+
- Route rules: `require-assert-exhaustive-route-exceptions`, `require-pending-component-di-check`, and `require-child-route-mount-check`.
|
|
73
|
+
|
|
74
|
+
## Review Checklist
|
|
75
|
+
|
|
76
|
+
- [ ] No new direct Angular `inject(...)` in app code.
|
|
77
|
+
- [ ] No new app-authored `@Injectable` service where `craftService` fits.
|
|
78
|
+
- [ ] Angular/framework tokens adapted exactly once with `toCraftService`.
|
|
79
|
+
- [ ] Consumption sites use the generated `X()` helper.
|
|
80
|
+
- [ ] Route checks use provider names, with `ProvidedValues = never` after migration.
|
|
81
|
+
- [ ] `GenDeps_*` aliases are regenerated after dependency changes.
|
|
82
|
+
- [ ] Tests use craft service testing helpers instead of TestBed-only service wiring where possible.
|
|
83
|
+
|
|
84
|
+
## When To Push Back
|
|
85
|
+
|
|
86
|
+
Push back when a change adds a direct Angular token to `ProvidedValues` instead of adapting it with `toCraftService`, introduces a pass-through `craftService` with no meaningful interface, or disables DI checks to avoid fixing provider registration.
|