ioc-manifest 0.3.1 → 1.0.0

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.
Files changed (185) hide show
  1. package/README.md +389 -100
  2. package/dist/cli/ioc.js +30 -6
  3. package/dist/cli/ioc.js.map +1 -1
  4. package/dist/cli/parseIocCli.d.ts +10 -2
  5. package/dist/cli/parseIocCli.d.ts.map +1 -1
  6. package/dist/cli/parseIocCli.js +32 -5
  7. package/dist/cli/parseIocCli.js.map +1 -1
  8. package/dist/config/iocConfig.d.ts +25 -33
  9. package/dist/config/iocConfig.d.ts.map +1 -1
  10. package/dist/config/iocConfig.js.map +1 -1
  11. package/dist/config/iocMode.d.ts +9 -0
  12. package/dist/config/iocMode.d.ts.map +1 -0
  13. package/dist/config/iocMode.js +8 -0
  14. package/dist/config/iocMode.js.map +1 -0
  15. package/dist/config/loadIocConfig.d.ts.map +1 -1
  16. package/dist/config/loadIocConfig.js +162 -26
  17. package/dist/config/loadIocConfig.js.map +1 -1
  18. package/dist/config/packageIdentifier.d.ts +19 -0
  19. package/dist/config/packageIdentifier.d.ts.map +1 -0
  20. package/dist/config/packageIdentifier.js +87 -0
  21. package/dist/config/packageIdentifier.js.map +1 -0
  22. package/dist/config/parseDiscoveryScanDirs.d.ts.map +1 -1
  23. package/dist/config/parseDiscoveryScanDirs.js +13 -28
  24. package/dist/config/parseDiscoveryScanDirs.js.map +1 -1
  25. package/dist/core/index.d.ts +2 -1
  26. package/dist/core/index.d.ts.map +1 -1
  27. package/dist/core/index.js +1 -0
  28. package/dist/core/index.js.map +1 -1
  29. package/dist/core/manifest.d.ts +17 -2
  30. package/dist/core/manifest.d.ts.map +1 -1
  31. package/dist/core/manifest.js +1 -1
  32. package/dist/core/manifest.js.map +1 -1
  33. package/dist/core/resolver.d.ts +2 -2
  34. package/dist/core/resolver.d.ts.map +1 -1
  35. package/dist/core/resolver.js +7 -5
  36. package/dist/core/resolver.js.map +1 -1
  37. package/dist/generator/analyzeDemandSupply/emitTypeReference.d.ts +15 -0
  38. package/dist/generator/analyzeDemandSupply/emitTypeReference.d.ts.map +1 -0
  39. package/dist/generator/analyzeDemandSupply/emitTypeReference.js +137 -0
  40. package/dist/generator/analyzeDemandSupply/emitTypeReference.js.map +1 -0
  41. package/dist/generator/analyzeDemandSupply/enforceNamedDepsType.d.ts +17 -0
  42. package/dist/generator/analyzeDemandSupply/enforceNamedDepsType.d.ts.map +1 -0
  43. package/dist/generator/analyzeDemandSupply/enforceNamedDepsType.js +108 -0
  44. package/dist/generator/analyzeDemandSupply/enforceNamedDepsType.js.map +1 -0
  45. package/dist/generator/analyzeDemandSupply/index.d.ts +17 -0
  46. package/dist/generator/analyzeDemandSupply/index.d.ts.map +1 -0
  47. package/dist/generator/analyzeDemandSupply/index.js +162 -0
  48. package/dist/generator/analyzeDemandSupply/index.js.map +1 -0
  49. package/dist/generator/analyzeDemandSupply/types.d.ts +24 -0
  50. package/dist/generator/analyzeDemandSupply/types.d.ts.map +1 -0
  51. package/dist/generator/analyzeDemandSupply/types.js +2 -0
  52. package/dist/generator/analyzeDemandSupply/types.js.map +1 -0
  53. package/dist/generator/buildComposedRegistrationOverrides.d.ts +7 -0
  54. package/dist/generator/buildComposedRegistrationOverrides.d.ts.map +1 -0
  55. package/dist/generator/buildComposedRegistrationOverrides.js +59 -0
  56. package/dist/generator/buildComposedRegistrationOverrides.js.map +1 -0
  57. package/dist/generator/contractTypeSourceFile.d.ts +2 -3
  58. package/dist/generator/contractTypeSourceFile.d.ts.map +1 -1
  59. package/dist/generator/contractTypeSourceFile.js +2 -85
  60. package/dist/generator/contractTypeSourceFile.js.map +1 -1
  61. package/dist/generator/discoverFactories/discoverFactories.d.ts.map +1 -1
  62. package/dist/generator/discoverFactories/discoverFactories.js +0 -1
  63. package/dist/generator/discoverFactories/discoverFactories.js.map +1 -1
  64. package/dist/generator/discoverFactories/scanFactoryFile.js +1 -2
  65. package/dist/generator/discoverFactories/scanFactoryFile.js.map +1 -1
  66. package/dist/generator/generateManifest.d.ts.map +1 -1
  67. package/dist/generator/generateManifest.js +72 -7
  68. package/dist/generator/generateManifest.js.map +1 -1
  69. package/dist/generator/loadComposedManifestContracts.d.ts +9 -0
  70. package/dist/generator/loadComposedManifestContracts.d.ts.map +1 -0
  71. package/dist/generator/loadComposedManifestContracts.js +74 -0
  72. package/dist/generator/loadComposedManifestContracts.js.map +1 -0
  73. package/dist/generator/loadComposedManifestGroups.d.ts +15 -0
  74. package/dist/generator/loadComposedManifestGroups.d.ts.map +1 -0
  75. package/dist/generator/loadComposedManifestGroups.js +82 -0
  76. package/dist/generator/loadComposedManifestGroups.js.map +1 -0
  77. package/dist/generator/manifestOptions.d.ts.map +1 -1
  78. package/dist/generator/manifestOptions.js +1 -6
  79. package/dist/generator/manifestOptions.js.map +1 -1
  80. package/dist/generator/manifestPaths.d.ts +10 -35
  81. package/dist/generator/manifestPaths.d.ts.map +1 -1
  82. package/dist/generator/manifestPaths.js +25 -185
  83. package/dist/generator/manifestPaths.js.map +1 -1
  84. package/dist/generator/resolveComposedPackageExport.d.ts +11 -0
  85. package/dist/generator/resolveComposedPackageExport.d.ts.map +1 -0
  86. package/dist/generator/resolveComposedPackageExport.js +54 -0
  87. package/dist/generator/resolveComposedPackageExport.js.map +1 -0
  88. package/dist/generator/resolveRegistrationPlan.d.ts +4 -1
  89. package/dist/generator/resolveRegistrationPlan.d.ts.map +1 -1
  90. package/dist/generator/resolveRegistrationPlan.js +74 -7
  91. package/dist/generator/resolveRegistrationPlan.js.map +1 -1
  92. package/dist/generator/writeComposedManifest.d.ts +15 -0
  93. package/dist/generator/writeComposedManifest.d.ts.map +1 -0
  94. package/dist/generator/writeComposedManifest.js +183 -0
  95. package/dist/generator/writeComposedManifest.js.map +1 -0
  96. package/dist/generator/writeManifest.d.ts +16 -0
  97. package/dist/generator/writeManifest.d.ts.map +1 -1
  98. package/dist/generator/writeManifest.js +146 -74
  99. package/dist/generator/writeManifest.js.map +1 -1
  100. package/dist/groups/canonicalBaseTypeId.d.ts +27 -0
  101. package/dist/groups/canonicalBaseTypeId.d.ts.map +1 -0
  102. package/dist/groups/canonicalBaseTypeId.js +198 -0
  103. package/dist/groups/canonicalBaseTypeId.js.map +1 -0
  104. package/dist/groups/resolveGroupPlan.d.ts +4 -1
  105. package/dist/groups/resolveGroupPlan.d.ts.map +1 -1
  106. package/dist/groups/resolveGroupPlan.js +27 -6
  107. package/dist/groups/resolveGroupPlan.js.map +1 -1
  108. package/dist/index.d.ts +6 -3
  109. package/dist/index.d.ts.map +1 -1
  110. package/dist/index.js +4 -2
  111. package/dist/index.js.map +1 -1
  112. package/dist/inspection/runDiscoveryAnalysis.js +2 -2
  113. package/dist/inspection/runDiscoveryAnalysis.js.map +1 -1
  114. package/dist/runtime/bootstrap.d.ts +2 -1
  115. package/dist/runtime/bootstrap.d.ts.map +1 -1
  116. package/dist/runtime/bootstrap.js +6 -4
  117. package/dist/runtime/bootstrap.js.map +1 -1
  118. package/dist/runtime/composeManifests.d.ts +52 -0
  119. package/dist/runtime/composeManifests.d.ts.map +1 -0
  120. package/dist/runtime/composeManifests.js +427 -0
  121. package/dist/runtime/composeManifests.js.map +1 -0
  122. package/dist/runtime/composedOverrides.d.ts +22 -0
  123. package/dist/runtime/composedOverrides.d.ts.map +1 -0
  124. package/dist/runtime/composedOverrides.js +2 -0
  125. package/dist/runtime/composedOverrides.js.map +1 -0
  126. package/dist/runtime/groupBaseTypeEquivalence.d.ts +9 -0
  127. package/dist/runtime/groupBaseTypeEquivalence.d.ts.map +1 -0
  128. package/dist/runtime/groupBaseTypeEquivalence.js +18 -0
  129. package/dist/runtime/groupBaseTypeEquivalence.js.map +1 -0
  130. package/dist/runtime/index.d.ts +1 -0
  131. package/dist/runtime/index.d.ts.map +1 -1
  132. package/dist/runtime/index.js.map +1 -1
  133. package/dist/schemaVersion.d.ts +7 -0
  134. package/dist/schemaVersion.d.ts.map +1 -0
  135. package/dist/schemaVersion.js +6 -0
  136. package/dist/schemaVersion.js.map +1 -0
  137. package/dist/test-support/manifestFixtures.d.ts +20 -0
  138. package/dist/test-support/manifestFixtures.d.ts.map +1 -0
  139. package/dist/test-support/manifestFixtures.js +49 -0
  140. package/dist/test-support/manifestFixtures.js.map +1 -0
  141. package/dist/validate/checks/appConfig.d.ts +4 -0
  142. package/dist/validate/checks/appConfig.d.ts.map +1 -0
  143. package/dist/validate/checks/appConfig.js +92 -0
  144. package/dist/validate/checks/appConfig.js.map +1 -0
  145. package/dist/validate/checks/defaultAmbiguity.d.ts +3 -0
  146. package/dist/validate/checks/defaultAmbiguity.d.ts.map +1 -0
  147. package/dist/validate/checks/defaultAmbiguity.js +81 -0
  148. package/dist/validate/checks/defaultAmbiguity.js.map +1 -0
  149. package/dist/validate/checks/externals.d.ts +3 -0
  150. package/dist/validate/checks/externals.d.ts.map +1 -0
  151. package/dist/validate/checks/externals.js +27 -0
  152. package/dist/validate/checks/externals.js.map +1 -0
  153. package/dist/validate/checks/groups.d.ts +3 -0
  154. package/dist/validate/checks/groups.d.ts.map +1 -0
  155. package/dist/validate/checks/groups.js +136 -0
  156. package/dist/validate/checks/groups.js.map +1 -0
  157. package/dist/validate/checks/sameKeyConflict.d.ts +3 -0
  158. package/dist/validate/checks/sameKeyConflict.d.ts.map +1 -0
  159. package/dist/validate/checks/sameKeyConflict.js +78 -0
  160. package/dist/validate/checks/sameKeyConflict.js.map +1 -0
  161. package/dist/validate/checks/schemaVersion.d.ts +3 -0
  162. package/dist/validate/checks/schemaVersion.d.ts.map +1 -0
  163. package/dist/validate/checks/schemaVersion.js +21 -0
  164. package/dist/validate/checks/schemaVersion.js.map +1 -0
  165. package/dist/validate/formatValidationReport.d.ts +11 -0
  166. package/dist/validate/formatValidationReport.d.ts.map +1 -0
  167. package/dist/validate/formatValidationReport.js +36 -0
  168. package/dist/validate/formatValidationReport.js.map +1 -0
  169. package/dist/validate/loadParsedManifests.d.ts +12 -0
  170. package/dist/validate/loadParsedManifests.d.ts.map +1 -0
  171. package/dist/validate/loadParsedManifests.js +109 -0
  172. package/dist/validate/loadParsedManifests.js.map +1 -0
  173. package/dist/validate/parseGeneratedSource.d.ts +9 -0
  174. package/dist/validate/parseGeneratedSource.d.ts.map +1 -0
  175. package/dist/validate/parseGeneratedSource.js +258 -0
  176. package/dist/validate/parseGeneratedSource.js.map +1 -0
  177. package/dist/validate/runValidate.d.ts +30 -0
  178. package/dist/validate/runValidate.d.ts.map +1 -0
  179. package/dist/validate/runValidate.js +61 -0
  180. package/dist/validate/runValidate.js.map +1 -0
  181. package/dist/validate/types.d.ts +63 -0
  182. package/dist/validate/types.d.ts.map +1 -0
  183. package/dist/validate/types.js +5 -0
  184. package/dist/validate/types.js.map +1 -0
  185. package/package.json +11 -3
package/README.md CHANGED
@@ -1,6 +1,6 @@
1
1
  # ioc-manifest
2
2
 
3
- **Convention-based dependency discovery and codegen for [Awilix](https://github.com/jeffijoe/awilix).** Write factory functions, run the generator, get a fully typed IoC container — no manual registrations.
3
+ **Convention-based dependency discovery and codegen for [Awilix](https://github.com/jeffijoe/awilix).** Write factory functions, run the generator, get a fully typed IoC container — no manual registrations. Compose containers across packages in a monorepo with first-class support.
4
4
 
5
5
  ```
6
6
  npm install ioc-manifest
@@ -12,14 +12,22 @@ npm install ioc-manifest
12
12
 
13
13
  In most Node.js DI setups, every new service means another `container.register(...)` call, another import, another string key to keep in sync. Scale that to 50+ services and registration code becomes a maintenance burden. Awilix's `loadModules` helps, but you lose type safety — `container.resolve("userService")` returns `any` unless you maintain a cradle type by hand.
14
14
 
15
+ And once you have more than one package in a monorepo, the registration story gets worse: either one app's bootstrap scans into every other package's source (fragile, fights TypeScript's module resolution), or you duplicate registration glue everywhere.
16
+
15
17
  ## What this does
16
18
 
17
- `ioc-manifest` scans your TypeScript source at **build time**, discovers factory functions by naming convention, infers their contracts and dependencies from the type system, and generates two files:
19
+ `ioc-manifest` scans your TypeScript source at **build time**, discovers factory functions by naming convention, infers their contracts and dependencies from the type system, and generates manifest and types files that hand directly to Awilix.
20
+
21
+ For a single-package project, that's two generated files:
18
22
 
19
23
  1. **`ioc-manifest.ts`** — a registration manifest with every factory, its contract, lifetime, and module import
20
- 2. **`ioc-registry.types.ts`** — a fully typed `IocGeneratedCradle` interface for your container
24
+ 2. **`ioc-registry.types.ts`** — a fully typed `IocGeneratedCradle` interface for your container, plus an `IocExternals` interface describing dependencies the package expects from outside
25
+
26
+ For a monorepo where one app composes manifests from multiple packages, a third file appears in the app:
21
27
 
22
- Hand those to Awilix and you're done. Every factory is registered with the correct key and lifetime, the container is fully typed, and you never write a registration line again.
28
+ 3. **`ioc-composed.ts`** the composition glue: imports each package's manifest, intersects their cradle types into a single `AppCradle`, and emits compile-time assertions that every package's externals are satisfied.
29
+
30
+ Every factory is registered with the correct key and lifetime, the container is fully typed end-to-end, and you never write a registration line again.
23
31
 
24
32
  The approach is loosely inspired by [StructureMap](https://structuremap.github.io/)'s registry scanning conventions from the .NET world — convention over configuration, with a single config file as the policy surface when you need to override defaults.
25
33
 
@@ -29,15 +37,35 @@ The approach is loosely inspired by [StructureMap](https://structuremap.github.i
29
37
  - **Typed container** — `container.resolve("userService")` returns `UserService`, not `any`
30
38
  - **Plural collections** — two implementations of `MediaStorage` automatically get a `mediaStorages: ReadonlyArray<MediaStorage>` key
31
39
  - **Default selection** — convention picks the default; override in config when you have multiple implementations
40
+ - **Externals are explicit** — every dependency a package expects from outside is tracked in a generated `IocExternals` interface
41
+ - **Cross-package composition** — apps in a monorepo can compose manifests from multiple packages with no scanning across boundaries
42
+ - **Compile-time satisfaction checks** — when composing, TypeScript fails compilation if any composed package's externals aren't satisfied
43
+ - **`ioc validate`** — a CI-friendly command that reports every cross-manifest problem at once
32
44
  - **Works in dev and prod** — discovers from TypeScript source during development, and works just as well against a bundled single-file production build (see [Dev and production builds](#dev-and-production-builds))
33
45
 
34
46
  ---
35
47
 
48
+ ## Library mode vs app mode
49
+
50
+ `ioc-manifest` has two modes. Which one applies depends on a single config field: `composedManifests`.
51
+
52
+ **Library mode** is the default. A package generates its own manifest and types. Factories in that package can declare dependencies on things the package itself supplies (other local factories) _and_ on things it expects from outside (externals). The generated `IocExternals` interface documents the external contract — what the package needs to be handed at composition time.
53
+
54
+ **App mode** is what you turn on when a package composes manifests from other packages. The app declares `composedManifests: ['@scope/pkg-a', '@scope/pkg-b']` in its config. Codegen produces the extra `ioc-composed.ts` file, intersects the participating cradle types, and emits the compile-time assertion that every composed package's externals are satisfied somewhere in the composition.
55
+
56
+ A single-package project stays in library mode and never thinks about composition. A monorepo with one or more apps that consume shared packages has library-mode packages and one or more app-mode apps.
57
+
58
+ The quick start below walks through library mode. App mode is covered in [Cross-package composition](#cross-package-composition).
59
+
60
+ ---
61
+
36
62
  ## Quick start
37
63
 
64
+ This walks through a single-package setup in library mode.
65
+
38
66
  ### 1. Create factories
39
67
 
40
- Write plain factory functions. The naming convention `build<Name>` is the only requirement.
68
+ Write plain factory functions. The naming convention `build<Name>` is the only requirement. Each factory's first parameter is a **named local deps type** describing what it consumes.
41
69
 
42
70
  ```ts
43
71
  // src/services/buildUserRepository.ts
@@ -53,20 +81,23 @@ export const buildUserRepository = (): UserRepository => ({
53
81
  ```ts
54
82
  // src/services/buildUserService.ts
55
83
  import type { UserRepository } from "./buildUserRepository.js";
56
- import type { IocGeneratedCradle } from "../generated/ioc-registry.types.js";
57
84
 
58
85
  export type UserService = {
59
86
  getUser: (id: string) => Promise<User | undefined>;
60
87
  };
61
88
 
89
+ type UserServiceDeps = {
90
+ userRepository: UserRepository;
91
+ };
92
+
62
93
  export const buildUserService = ({
63
94
  userRepository,
64
- }: IocGeneratedCradle): UserService => ({
95
+ }: UserServiceDeps): UserService => ({
65
96
  getUser: (id) => userRepository.findById(id),
66
97
  });
67
98
  ```
68
99
 
69
- Dependencies are declared via parameter destructuring against the generated cradle type. After generation, TypeScript tells you exactly what's available.
100
+ The named deps type pattern is required — factories cannot destructure directly from `IocGeneratedCradle`. There's a real reason for this: the cradle is generated _from_ what your factories declare. Asking a factory to declare its inputs by referencing the cradle would be a chicken-and-egg loop. Naming the deps type at the factory site also makes each factory independently testable — see [Testing](#testing) below.
70
101
 
71
102
  ### 2. Configure
72
103
 
@@ -111,39 +142,46 @@ const container = createContainer<IocGeneratedCradle>({
111
142
  injectionMode: InjectionMode.PROXY,
112
143
  });
113
144
 
114
- registerIocFromManifest(container, iocManifest);
145
+ registerIocFromManifest(container, [iocManifest]);
115
146
 
116
147
  // Fully typed — no 'any', no string guessing
117
148
  const userService = container.resolve("userService");
118
149
  ```
119
150
 
120
- That's all you need for most applications. The sections below cover the conventions in more detail, and the [Advanced usage](#advanced-usage) section covers features you can reach for when your app grows folder-scoped lifetimes, groups, monorepo support, and environment-specific configs.
151
+ Note that `registerIocFromManifest` takes an **array** of manifests, even when there's only one. The array is set-like ordering is irrelevant, and the same input always produces the same registrations.
152
+
153
+ That's all you need for most single-package applications. The sections below cover the conventions in more detail. For monorepo composition, see [Cross-package composition](#cross-package-composition).
121
154
 
122
155
  ---
123
156
 
124
157
  ## What gets generated
125
158
 
126
- Here's what the output looks like for a small app. You never edit these files — they're regenerated from source.
159
+ Here's what library-mode output looks like for a small app. You never edit these files — they're regenerated from source.
127
160
 
128
- **`ioc-registry.types.ts`** — the typed cradle:
161
+ **`ioc-registry.types.ts`** — the typed cradle and externals:
129
162
 
130
163
  ```ts
131
164
  /* AUTO-GENERATED. DO NOT EDIT. */
132
165
  import type { Logger } from "../services/buildConsoleLogger.js";
133
166
  import type { MediaStorage } from "../services/buildLocalMediaStorage.js";
134
167
  import type { UserService } from "../services/buildUserService.js";
168
+ import type { Database } from "../types/Database.js";
135
169
 
136
- export interface IocGeneratedTypes {
170
+ export interface IocGeneratedCradle {
137
171
  logger: Logger;
138
172
  mediaStorage: MediaStorage;
139
173
  mediaStorages: ReadonlyArray<MediaStorage>;
140
174
  userService: UserService;
141
175
  }
142
176
 
143
- export type IocGeneratedCradle = IocGeneratedTypes;
177
+ export interface IocExternals {
178
+ database: Database;
179
+ }
144
180
  ```
145
181
 
146
- Notice `mediaStorages` (plural) that appeared automatically because there are multiple `MediaStorage` implementations.
182
+ `mediaStorages` (plural) appears automatically because there are multiple `MediaStorage` implementations.
183
+
184
+ `IocExternals` lists every dependency the package consumes from outside — keys destructured by factory deps types where no local factory supplies them. `IocGeneratedCradle` contains only what the package itself supplies. The two interfaces together describe the package's full contract: what it provides and what it needs.
147
185
 
148
186
  **`ioc-manifest.ts`** — the registration data:
149
187
 
@@ -159,6 +197,7 @@ import * as ioc_services_buildLocalMediaStorage from "../services/buildLocalMedi
159
197
  // ... more imports ...
160
198
 
161
199
  export const iocManifest = {
200
+ manifestSchemaVersion: 2,
162
201
  moduleImports: [
163
202
  /* ... */
164
203
  ] as const satisfies readonly IocModuleNamespace[],
@@ -201,9 +240,10 @@ The contract type must be a named type (interface or type alias) that is importe
201
240
 
202
241
  When a contract has only one implementation, it is the default. When there are multiple, the default is selected by this precedence:
203
242
 
204
- 1. **Explicit** — `default: true` on exactly one implementation in `ioc.config`
205
- 2. **Convention** — the implementation whose registration key equals the camel-cased contract name (e.g. `mediaStorage` for `MediaStorage`)
206
- 3. **Single** — if only one implementation exists, it's the default
243
+ 1. **App override** — `default: true` in an app-mode `ioc.config` (highest precedence; only relevant when composing)
244
+ 2. **Explicit** — `default: true` on exactly one implementation in the local `ioc.config`
245
+ 3. **Convention** — the implementation whose registration key equals the camel-cased contract name (e.g. `mediaStorage` for `MediaStorage`)
246
+ 4. **Single** — if only one implementation exists, it's the default
207
247
 
208
248
  If the choice is ambiguous, generation fails with a clear error telling you what to do.
209
249
 
@@ -222,7 +262,9 @@ This is the same fundamental idea behind having multiple implementations of a si
222
262
 
223
263
  ### Dependency inference
224
264
 
225
- The generator analyzes each factory's first parameter to determine which contracts it depends on. If `buildUserService` destructures `{ userRepository }` and `UserRepository` is a known contract, the manifest records that dependency relationship. This powers the resolution chain in error messages.
265
+ The generator analyzes each factory's first parameter — the named deps type — to determine which keys the factory consumes. Every property in the deps type becomes a **demand**. If a demanded key has a corresponding `build*` factory in the same package, it's a local dependency. If not, it's an external (and appears in `IocExternals`).
266
+
267
+ Codegen validates type agreement across factories: if `buildA` declares `database: Knex` and `buildB` declares `database: PostgresClient`, codegen fails with both locations and the conflicting types named.
226
268
 
227
269
  ---
228
270
 
@@ -243,19 +285,22 @@ export default defineIocConfig({
243
285
  groups: {
244
286
  /* cross-contract grouping by base type (advanced) */
245
287
  },
288
+ // app mode only:
289
+ composedManifests: [
290
+ /* package names to compose */
291
+ ],
246
292
  });
247
293
  ```
248
294
 
249
295
  ### `discovery`
250
296
 
251
- | Field | Purpose | Default |
252
- | ----------------------------- | ---------------------------------------------------------------------------------------------------------------------------- | ------------------------------------ |
253
- | `scanDirs` | **Required.** Directories to scan. String, string array, or array of `{ path, scope?, importPrefix?, importMode? }` objects. | — |
254
- | `includes` | Glob patterns for files to include. | `["**/*.{ts,tsx,js,mjs,cjs}"]` |
255
- | `excludes` | Glob patterns for files to exclude. | `["**/*.d.ts", "**/*.test.ts", ...]` |
256
- | `factoryPrefix` | Export name prefix for factory discovery. | `"build"` |
257
- | `generatedDir` | Output directory for generated files. | `"generated"` |
258
- | `workspacePackageImportBases` | Maps workspace roots to bare specifiers for generated imports (see [Monorepo support](#monorepo-support-importprefix)). | — |
297
+ | Field | Purpose | Default |
298
+ | --------------- | -------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------ |
299
+ | `scanDirs` | **Required.** Directories to scan. String, string array, or array of `{ path, scope? }` objects. Paths must resolve within the package root. | — |
300
+ | `includes` | Glob patterns for files to include. | `["**/*.{ts,tsx,js,mjs,cjs}"]` |
301
+ | `excludes` | Glob patterns for files to exclude. | `["**/*.d.ts", "**/*.test.ts", ...]` |
302
+ | `factoryPrefix` | Export name prefix for factory discovery. | `"build"` |
303
+ | `generatedDir` | Output directory for generated files. | `"generated"` |
259
304
 
260
305
  ### `registrations`
261
306
 
@@ -276,16 +321,201 @@ registrations: {
276
321
 
277
322
  Under each contract name, keys are implementation names from discovery (`buildFoo` → `foo`). The reserved `$contract` key holds contract-level options.
278
323
 
279
- | Per-implementation field | Effect |
280
- | ------------------------ | ------------------------------------------------------------ |
281
- | `name` | Overrides the Awilix registration key |
282
- | `lifetime` | `"singleton"` \| `"scoped"` \| `"transient"` |
283
- | `default` | `true` to select this implementation as the contract default |
324
+ | Per-implementation field | Effect |
325
+ | ------------------------ | ---------------------------------------------------------------------------------------------------------------------------------- |
326
+ | `name` | Overrides the Awilix registration key |
327
+ | `lifetime` | `"singleton"` \| `"scoped"` \| `"transient"` |
328
+ | `default` | `true` to select this implementation as the contract default |
329
+ | `source` | (app mode only) Resolve same-key conflicts across composed manifests. See [Cross-package composition](#cross-package-composition). |
284
330
 
285
331
  | `$contract` field | Effect |
286
332
  | ----------------- | ----------------------------------------------------------------------------------------------- |
287
333
  | `accessKey` | Overrides the cradle property name for the default slot (e.g. `"database"` instead of `"knex"`) |
288
334
 
335
+ ### App-mode fields
336
+
337
+ These only apply in app mode (a package that composes manifests from other packages):
338
+
339
+ | Field | Purpose |
340
+ | ---------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------- |
341
+ | `composedManifests` | Array of package names whose manifests this app composes. Setting this turns on app mode. |
342
+ | `packageName` | The local package's npm name. Used for self-reference detection. Falls back to `package.json` `name`; required if neither is available. |
343
+ | `groupBaseTypeAliases` | Equivalence sets for canonical base type identifiers when hoisting produces mismatches. See [Cross-package composition](#cross-package-composition). |
344
+
345
+ | Library-mode-only field | Purpose |
346
+ | ----------------------- | --------------------------------------------------------------------------------------------------------------------- |
347
+ | `manifestExportPath` | Informational. The path your `package.json` `exports` points at for the manifest. Default `./generated/ioc-manifest`. |
348
+
349
+ `composedManifests` and `manifestExportPath` are mutually exclusive — a config is either library or app mode.
350
+
351
+ ---
352
+
353
+ ## Cross-package composition
354
+
355
+ Once you have more than one package in a monorepo, you typically have one or more apps that compose manifests from shared libraries. This is what app mode is for.
356
+
357
+ ### The model
358
+
359
+ Each package generates its own manifest in library mode, scanning only its own source. The app's config declares which packages it composes with via `composedManifests`. Codegen produces an extra file in the app — `ioc-composed.ts` — that imports each package's manifest, intersects their cradle types, and emits compile-time assertions that every composed package's externals are satisfied.
360
+
361
+ At runtime, the app passes the composed manifests array to `registerIocFromManifest`. Composition is set-like: ordering doesn't matter. Conflicts (two manifests supplying the same registration key) are hard errors by default, resolved via explicit `source` config.
362
+
363
+ ### A monorepo example
364
+
365
+ ```
366
+ packages/
367
+ lib-storage/ # library mode
368
+ src/
369
+ ioc.config.ts
370
+ factories/
371
+ types/
372
+ lib-services/ # library mode
373
+ src/
374
+ ioc.config.ts
375
+ factories/
376
+ types/
377
+ app/ # app mode
378
+ src/
379
+ ioc.config.ts
380
+ bootstrap.ts
381
+ factories/
382
+ ```
383
+
384
+ `lib-storage` registers `Storage` implementations. `lib-services` registers services that consume `Storage` (declared in their deps types — so `storage` appears in `lib-services`'s `IocExternals`). The app composes both and supplies anything neither library supplies.
385
+
386
+ ### App config
387
+
388
+ ```ts
389
+ // packages/app/src/ioc.config.ts
390
+ import { defineIocConfig } from "ioc-manifest";
391
+
392
+ export default defineIocConfig({
393
+ discovery: {
394
+ scanDirs: "src",
395
+ generatedDir: "generated",
396
+ },
397
+ composedManifests: ["@example/lib-storage", "@example/lib-services"],
398
+ registrations: {
399
+ Storage: {
400
+ s3Storage: { default: true },
401
+ },
402
+ },
403
+ });
404
+ ```
405
+
406
+ ### Required package exports
407
+
408
+ Each composed package's `package.json` must expose two subpath exports:
409
+
410
+ ```jsonc
411
+ {
412
+ "exports": {
413
+ ".": "./src/index.ts",
414
+ "./iocManifest": "./src/generated/ioc-manifest.js",
415
+ "./iocTypes": "./src/generated/ioc-registry.types.js",
416
+ },
417
+ }
418
+ ```
419
+
420
+ (Substitute `./dist/...` for published packages with a build step.)
421
+
422
+ ### Generated `ioc-composed.ts`
423
+
424
+ ```ts
425
+ /* AUTO-GENERATED. DO NOT EDIT. */
426
+ import { iocManifest as localManifest } from "./ioc-manifest.js";
427
+ import { iocManifest as libStorageManifest } from "@example/lib-storage/iocManifest";
428
+ import { iocManifest as libServicesManifest } from "@example/lib-services/iocManifest";
429
+
430
+ import type { IocGeneratedCradle as LocalCradle } from "./ioc-registry.types.js";
431
+ import type { IocGeneratedCradle as LibStorageCradle } from "@example/lib-storage/iocTypes";
432
+ import type { IocGeneratedCradle as LibServicesCradle } from "@example/lib-services/iocTypes";
433
+ import type { IocExternals as LibStorageExternals } from "@example/lib-storage/iocTypes";
434
+ import type { IocExternals as LibServicesExternals } from "@example/lib-services/iocTypes";
435
+
436
+ export const composedManifests = [
437
+ localManifest,
438
+ libStorageManifest,
439
+ libServicesManifest,
440
+ ] as const;
441
+
442
+ export type AppCradle = LocalCradle & LibStorageCradle & LibServicesCradle;
443
+
444
+ // Compile-time externals satisfaction assertions
445
+ type _IocExpect<T extends true> = T;
446
+ type _LibStorageExternalsSatisfied =
447
+ LibStorageExternals extends Pick<AppCradle, keyof LibStorageExternals>
448
+ ? true
449
+ : false;
450
+ type _LibStorageExternalsAssert = _IocExpect<_LibStorageExternalsSatisfied>;
451
+ type _LibServicesExternalsSatisfied =
452
+ LibServicesExternals extends Pick<AppCradle, keyof LibServicesExternals>
453
+ ? true
454
+ : false;
455
+ type _LibServicesExternalsAssert = _IocExpect<_LibServicesExternalsSatisfied>;
456
+
457
+ export const composedRegistrationOverrides = {
458
+ /* ... */
459
+ };
460
+ ```
461
+
462
+ If `lib-services` requires a `logger` and no manifest in the composition supplies it, `_LibServicesExternalsAssert` fails compilation with a TypeScript error pointing at the assertion line. You don't have to run anything to find out you forgot something.
463
+
464
+ ### App bootstrap
465
+
466
+ ```ts
467
+ import { createContainer } from "awilix";
468
+ import { registerIocFromManifest } from "ioc-manifest";
469
+ import {
470
+ composedManifests,
471
+ composedRegistrationOverrides,
472
+ type AppCradle,
473
+ } from "./generated/ioc-composed.js";
474
+
475
+ const container = createContainer<AppCradle>();
476
+ registerIocFromManifest(
477
+ container,
478
+ composedManifests,
479
+ composedRegistrationOverrides,
480
+ );
481
+
482
+ const uploadService = container.resolve("uploadService");
483
+ ```
484
+
485
+ ### Resolving same-key conflicts
486
+
487
+ If two composed manifests both supply the same Awilix registration key, composition fails with a hard error naming both manifests. Resolve via the `source` field:
488
+
489
+ ```ts
490
+ registrations: {
491
+ AlbumRepository: {
492
+ albumRepository: { source: "local" }, // or "@example/lib-services"
493
+ },
494
+ }
495
+ ```
496
+
497
+ `source: "local"` picks the app's own factory; a package name picks that package's registration. There's no last-write-wins or array-position semantics — you decide explicitly which manifest's registration wins.
498
+
499
+ ### Groups across manifests
500
+
501
+ If multiple composed packages declare contributors to the same group (e.g. several packages register `DiscountStrategy` implementations and all declare a `discountStrategies` collection group), the group merges across manifests. `container.resolve("discountStrategies")` returns the union.
502
+
503
+ For this to work, all contributors must reference the same canonical base type — typically by importing it from a shared contracts package. If npm hoisting produces a single physical file for the base type, identity matching works automatically.
504
+
505
+ In rare cases (version skew, peer-dep conflicts, nested installs) two contributors may end up with different physical paths for what is structurally the same type. The library reports this with a clear error and a remediation hint, including the exact config block to paste:
506
+
507
+ ```ts
508
+ // in the app's ioc.config.ts
509
+ groupBaseTypeAliases: {
510
+ discountStrategies: [
511
+ "/path/to/a.ts:DiscountStrategy",
512
+ "/path/to/b.ts:DiscountStrategy",
513
+ ],
514
+ }
515
+ ```
516
+
517
+ The library treats the listed identifiers as equivalent. This is an escape hatch, not a normal-path mechanism.
518
+
289
519
  ---
290
520
 
291
521
  ## Dev and production builds
@@ -304,21 +534,106 @@ This was a deliberate design choice (and a painful one to get right). There's no
304
534
 
305
535
  ```bash
306
536
  npx ioc # prints help
307
- npx ioc generate # discover factories, emit manifest + types
537
+ npx ioc generate # discover factories, emit manifest + types (and ioc-composed.ts in app mode)
308
538
  npx ioc generate -c ./ioc.config.test.ts # generate with a specific config
309
539
  npx ioc inspect # loads the generated manifest and prints a summary
310
540
  npx ioc inspect --discovery # re-runs discovery without reading the manifest
311
- npx ioc inspect --config ./src/ioc.config.ts --project ./packages/api
541
+ npx ioc validate # app mode: cross-manifest checks against composedManifests
542
+ npx ioc validate --json # machine-readable issue list
312
543
  ```
313
544
 
314
545
  | Flag | Purpose |
315
546
  | -------------------------- | --------------------------------------------------------------------------------------- |
316
547
  | `--discovery` | (inspect only) Re-run factory discovery and planning; don't read the generated manifest |
548
+ | `--json` | (validate only) Emit issues as JSON |
317
549
  | `--config PATH`, `-c PATH` | Explicit path to `ioc.config.ts` |
318
550
  | `--project PATH` | Project directory for config resolution (default: cwd) |
319
551
 
320
552
  Set `IOC_DEBUG=1` for full stack traces on errors.
321
553
 
554
+ ### `ioc validate`
555
+
556
+ A separate command from `generate` because they have different audiences. `generate` runs frequently during development and shouldn't fail on transient inconsistencies (a sibling package mid-refactor). `validate` is the pre-merge / pre-deploy gate.
557
+
558
+ `validate` loads every composed manifest, runs every cross-manifest check at once, and reports all issues — not just the first. It does not modify any files; pure inspection. Exit code is non-zero if any error-severity issue is reported.
559
+
560
+ Typical output for a failing run:
561
+
562
+ ```
563
+ [app-config] registrations references unknown contract "Storge"
564
+ Known local contracts: Logger.
565
+ Known composed contracts: Logger, LoggingService, Storage, UploadService.
566
+ Did you mean: "Storage"?
567
+ Suggested fix: Fix the contract name in ioc.config.ts registrations, or add a factory for "Storge".
568
+
569
+ Validation failed: 1 error, 0 warnings.
570
+ ```
571
+
572
+ Library-mode invocations print an informational message and exit 0 — there's nothing cross-manifest to validate.
573
+
574
+ Recommended workflow: `ioc generate` → `ioc validate` → `tsc --noEmit` → deploy.
575
+
576
+ ---
577
+
578
+ ## Testing
579
+
580
+ The named-deps-type pattern at the factory site enables three levels of testing, each with the ergonomics that fit:
581
+
582
+ ### Factory-level (no container)
583
+
584
+ Most unit tests don't need a container. Import the factory, import its deps type, hand-build a stub, call the factory:
585
+
586
+ ```ts
587
+ import { buildValidateOperationService } from "../src/...";
588
+ import type { ValidateOperationServiceDeps } from "../src/...";
589
+
590
+ const deps: ValidateOperationServiceDeps = {
591
+ mediaItemReadRepository: {
592
+ /* stub */
593
+ },
594
+ grantReadRepository: {
595
+ /* stub */
596
+ },
597
+ albumMemberReadRepository: {
598
+ /* stub */
599
+ },
600
+ };
601
+ const svc = buildValidateOperationService(deps);
602
+ ```
603
+
604
+ No container, no manifest, no awilix. TypeScript enforces what must be provided.
605
+
606
+ ### Container-level with mocked externals
607
+
608
+ When you want the full container — testing wiring, lifetimes, multi-service interactions inside the package — register the package's manifest then fill `IocExternals` with `asValue` stubs:
609
+
610
+ ```ts
611
+ import { createContainer, asValue } from "awilix";
612
+ import { registerIocFromManifest } from "ioc-manifest";
613
+ import { iocManifest } from "../src/generated/ioc-manifest.js";
614
+ import type {
615
+ IocGeneratedCradle,
616
+ IocExternals,
617
+ } from "../src/generated/ioc-registry.types.js";
618
+
619
+ const container = createContainer<IocGeneratedCradle>();
620
+ registerIocFromManifest(container, [iocManifest]);
621
+
622
+ const externals: IocExternals = {
623
+ database: mockKnex,
624
+ logger: silentLogger,
625
+ };
626
+ for (const [k, v] of Object.entries(externals)) {
627
+ container.register({ [k]: asValue(v) });
628
+ }
629
+ ```
630
+
631
+ The `IocExternals` type makes the external surface a typed checklist: forget one and TypeScript errors; add a new external dep in the package and every test breaks until updated.
632
+
633
+ ### Test-specific manifest
634
+
635
+ For shared stubs across many tests, write stub factories under `tests/stubs/` and a separate `ioc.config.test.ts` scanning both `src` and `tests/stubs`. Generate a test manifest. Use as above. Run with `npx ioc generate -c ./ioc.config.test.ts`.
636
+
322
637
  ---
323
638
 
324
639
  ## Error handling
@@ -327,7 +642,9 @@ Errors are designed to tell you exactly what went wrong and what to do about it.
327
642
 
328
643
  **Config errors** are prefixed `[ioc-config]` — unknown contracts in `registrations`, duplicate defaults, key collisions. These fail at generation time before any files are written.
329
644
 
330
- **Discovery errors** are prefixed `[ioc]` — duplicate registration keys, unresolvable contract types, overlapping scan directories with conflicting scopes.
645
+ **Discovery errors** are prefixed `[ioc]` — duplicate registration keys, unresolvable contract types, overlapping scan directories with conflicting scopes, factories destructuring directly from `IocGeneratedCradle` (use named deps types instead).
646
+
647
+ **Validation errors** are prefixed by category (`[externals]`, `[same-key-conflict]`, `[group-base-type]`, etc.) and emitted by `ioc validate`. Validate aggregates: a failing run reports every issue at once, not just the first.
331
648
 
332
649
  **Runtime resolution errors** use `IocResolutionError` with structured dependency chains:
333
650
 
@@ -402,9 +719,13 @@ groups: {
402
719
  Now `container.resolve("discountStrategies")` gives you `ReadonlyArray<DiscountStrategy>` — every implementation that's assignable to the base type, discovered automatically. Your strategy runner just iterates through the array:
403
720
 
404
721
  ```ts
722
+ type PricingEngineDeps = {
723
+ discountStrategies: ReadonlyArray<DiscountStrategy>;
724
+ };
725
+
405
726
  export const buildPricingEngine = ({
406
727
  discountStrategies,
407
- }: IocGeneratedCradle): PricingEngine => ({
728
+ }: PricingEngineDeps): PricingEngine => ({
408
729
  applyDiscounts: (order) => {
409
730
  for (const strategy of discountStrategies) {
410
731
  if (strategy.applies(order)) {
@@ -418,6 +739,8 @@ export const buildPricingEngine = ({
418
739
 
419
740
  Add a sixth strategy? Just create the factory. It shows up in the group automatically — no registration changes.
420
741
 
742
+ If you need strategies to run in a specific order, put ordering metadata on the strategy interface itself (e.g. a `priority` field) and sort at use time. The library never tries to order group members.
743
+
421
744
  #### Object groups: bundling related services
422
745
 
423
746
  Object groups are for when you have several services that implement a common base type and you want to access them as a keyed bundle rather than an array. A real example: in a GraphQL API, you might have a set of user-scoped read services that all need to be available on the resolver context:
@@ -445,75 +768,29 @@ Now `container.resolve("readServices")` returns an object keyed by each contract
445
768
 
446
769
  #### Group validation
447
770
 
448
- The generator validates that group names don't collide with implementation keys, access keys, or collection keys. If a base type has no assignable implementations, generation fails with an actionable error.
771
+ The generator validates that group names don't collide with implementation keys, access keys, or collection keys. If a base type has no assignable implementations, generation fails with an actionable error. Cross-manifest group composition is covered in [Cross-package composition](#cross-package-composition).
449
772
 
450
- ### Monorepo support (`importPrefix`)
451
-
452
- In a monorepo, factories in one package often return types defined in another. Without configuration, the generated manifest would emit deep relative paths like `../../../packages/shared/src/types/UserService.js` — fragile and ugly.
453
-
454
- `importPrefix` and `importMode` fix this. They tell the generator how to write import statements for factories discovered under a given scan root:
455
-
456
- ```ts
457
- discovery: {
458
- scanDirs: [
459
- {
460
- path: "packages/shared/src",
461
- importPrefix: "@acme/shared",
462
- importMode: "subpath",
463
- },
464
- {
465
- path: "packages/api/src",
466
- importPrefix: "@acme/api",
467
- importMode: "subpath",
468
- },
469
- ],
470
- },
471
- ```
773
+ ### Environment-specific configs
472
774
 
473
- With `importMode: "subpath"`, a factory at `packages/shared/src/services/buildLogger.ts` gets imported as `@acme/shared/services/buildLogger.js` in the generated manifestmatching your package's published exports. With `importMode: "root"`, it would emit just `@acme/shared`.
775
+ The separation between factory code and `ioc.config.ts` makes it straightforward to swap implementations by environment. Your factories don't changethe config (or the set of composed manifests) is the only thing that differs.
474
776
 
475
- For contract type imports (the `import type` lines in `ioc-registry.types.ts`), use `workspacePackageImportBases` to achieve the same mapping:
777
+ For a single-package app, point the generator at a different config:
476
778
 
477
- ```ts
478
- discovery: {
479
- scanDirs: "packages/api/src",
480
- workspacePackageImportBases: [
481
- { root: "packages/shared", importBase: "@acme/shared" },
482
- ],
483
- },
779
+ ```bash
780
+ npx ioc generate --config ./ioc.config.test.ts
484
781
  ```
485
782
 
486
- This ensures the generated types file uses `import type { UserService } from "@acme/shared"` instead of a deep relative path into another package's source tree.
487
-
488
- ### Environment-specific configs
489
-
490
- The separation between factory code and `ioc.config.ts` makes it straightforward to swap implementations by environment. Your factories don't change — the config is the only thing that differs:
783
+ For a monorepo app, you can swap `composedManifests` entries to compose with mock packages in tests:
491
784
 
492
785
  ```ts
493
- // ioc.config.ts (production)
494
- registrations: {
495
- EmailService: {
496
- sesEmailService: { default: true },
497
- },
498
- Cache: {
499
- redisCache: { default: true, lifetime: "singleton" },
500
- },
501
- },
786
+ // ioc.config.test.ts
787
+ composedManifests: [
788
+ "@example/lib-storage-mock", // a sibling test-only package
789
+ "@example/lib-services",
790
+ ],
502
791
  ```
503
792
 
504
- ```ts
505
- // ioc.config.test.ts (testing)
506
- registrations: {
507
- EmailService: {
508
- mockEmailService: { default: true },
509
- },
510
- Cache: {
511
- inMemoryCache: { default: true, lifetime: "transient" },
512
- },
513
- },
514
- ```
515
-
516
- Point the generator at a different config with `npx ioc generate --config ./ioc.config.test.ts` and you get a completely different wiring — all mocks, all stubs, whatever you need — without touching a single factory file.
793
+ Either way, factory source code doesn't change.
517
794
 
518
795
  ---
519
796
 
@@ -523,13 +800,23 @@ Point the generator at a different config with `npx ioc generate --config ./ioc.
523
800
 
524
801
  **Contract not discovered** — the factory's return type must resolve to a named type (interface or type alias). The contract symbol must be imported or declared in the same file as the factory. Anonymous `{ foo: string }` return types are silently skipped.
525
802
 
526
- **Duplicate registration keys**every implementation needs a globally unique Awilix key. If two factories produce the same key, rename the exports or use `registrations[Contract][impl].name` to override.
803
+ **Factory destructures `IocGeneratedCradle`**not allowed. Use a named local deps type instead. The error message names the factory and shows the correct pattern.
804
+
805
+ **Duplicate registration keys within a manifest** — every implementation needs a globally unique Awilix key. If two factories produce the same key, rename the exports or use `registrations[Contract][impl].name` to override.
806
+
807
+ **Duplicate registration keys across composed manifests** — composition errors with both manifest sources named. Resolve via `registrations[Contract][impl].source` in the app's `ioc.config`.
527
808
 
528
809
  **Overlapping scan directories with different scopes** — if a factory file matches multiple scan roots that specify different `scope` values, generation fails. Narrow the roots or set lifetimes per implementation in `registrations`.
529
810
 
530
- **`registrations` for unknown contracts** — keys in `registrations` must match discovered contract type names exactly. A typo fails with a list of what was actually discovered.
811
+ **`registrations` for unknown contracts** — keys in `registrations` must match a discovered contract type name exactly. In app mode, that includes contracts from composed manifests. A typo fails with a list of what was actually discovered, locally and from composed packages.
812
+
813
+ **App mode codegen fails to resolve a composed package** — the package needs `./iocManifest` and `./iocTypes` subpath exports in its `package.json`. Until those are added, app codegen can't import the manifest.
814
+
815
+ **`_<Pkg>ExternalsAssert` fails to compile** — a composed package's externals are not satisfied by the composition. Add a factory in the app (or in another composed package) that supplies the missing key, or compose another manifest that does.
816
+
817
+ **Group base type mismatch across manifests** — caused by hoisting producing different physical paths for the same logical type. The error includes the remediation block to paste into `groupBaseTypeAliases`.
531
818
 
532
- **`inspect` shows `lifetimeSource: factory-config`** — this means the lifetime came from `ioc.config`, not from the factory source file (the label is historical).
819
+ **Library-mode invocation of `ioc validate`** — prints an informational message and exits 0. Validate is a cross-manifest tool; a library has no cross-manifest concerns to validate.
533
820
 
534
821
  ---
535
822
 
@@ -537,10 +824,12 @@ Point the generator at a different config with `npx ioc generate --config ./ioc.
537
824
 
538
825
  This package is **not** an IoC container. It is a codegen layer over Awilix that trades manual registration for convention.
539
826
 
540
- - **Factories are plain functions.** No decorators, no base classes, no `RESOLVER` symbols. A factory is an exported function that takes a cradle and returns a value.
827
+ - **Factories are plain functions.** No decorators, no base classes, no `RESOLVER` symbols. A factory is an exported function that takes a named deps type and returns a value.
541
828
  - **Policy lives in one file.** Lifetimes, defaults, and key overrides are in `ioc.config.ts` — never scattered across factory sources. Looking at a factory tells you _what_ it builds; looking at the config tells you _how_ it's registered.
542
829
  - **Types are inferred, not declared.** The generator reads the TypeScript program to discover contracts, dependencies, and assignability. You don't maintain a parallel type registry.
543
- - **Errors fail fast and explain themselves.** Ambiguous defaults, key collisions, and missing contracts are caught at generation time with messages that name the problem and suggest the fix.
830
+ - **Library packages own their boundary.** Each package generates its own manifest. What it supplies appears in `IocGeneratedCradle`; what it expects from outside appears in `IocExternals`. The contract is explicit and machine-readable.
831
+ - **App-mode composition is set-like.** `registerIocFromManifest(container, [a, b, c])` is order-independent. Conflicts are hard errors with explicit resolution, never silent override.
832
+ - **Errors fail fast and explain themselves.** Ambiguous defaults, key collisions, missing externals, and base-type mismatches are caught at generation, validation, or compile time — with messages that name the problem, suggest a fix, and where possible give you the exact config block to paste.
544
833
  - **Static imports, not runtime scanning.** The generated manifest is a plain TypeScript module with static imports. It works in dev with loose source files and in production with a single bundled file — no filesystem walking at runtime.
545
834
 
546
835
  ---